hardening: isolate third-party paths and clean external-build metadata (#311)

* hardening: isolate third-party paths and clean external-build metadata

* fix: restore external feedback flow and make privacy check portable
This commit is contained in:
KRATOS
2026-04-04 11:52:33 +05:30
committed by GitHub
parent cdbe016e6f
commit 27e6505bfd
18 changed files with 367 additions and 59 deletions

42
LICENSE
View File

@@ -1,21 +1,29 @@
MIT License NOTICE
Copyright (c) 2026 OpenClaude contributors This repository contains code derived from Anthropic's Claude Code CLI.
Permission is hereby granted, free of charge, to any person obtaining a copy The original Claude Code source is proprietary software:
of this software and associated documentation files (the "Software"), to deal Copyright (c) Anthropic PBC. All rights reserved.
in the Software without restriction, including without limitation the rights Subject to Anthropic's Commercial Terms of Service.
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all Modifications and additions by OpenClaude contributors are offered under
copies or substantial portions of the Software. the MIT License where legally permissible:
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR MIT License
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, Copyright (c) 2026 OpenClaude contributors (modifications only)
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER Permission is hereby granted, free of charge, to any person obtaining
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, a copy of the modifications made by OpenClaude contributors, to deal
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE in those modifications without restriction, including without limitation
SOFTWARE. the rights to use, copy, modify, merge, publish, distribute, sublicense,
and/or sell copies, subject to the following conditions:
The above copyright notice and this permission notice shall be included
in all copies or substantial portions of the modifications.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND.
The underlying derived code remains subject to Anthropic's copyright.
This project does not have Anthropic's authorization to distribute
their proprietary source. Users and contributors should evaluate their
own legal position.

View File

@@ -11,6 +11,22 @@ Use OpenAI-compatible APIs, Gemini, GitHub Models, Codex, Ollama, Atomic Chat, a
- Run locally with Ollama or Atomic Chat - Run locally with Ollama or Atomic Chat
- Keep core coding-agent workflows: bash, file tools, grep, glob, agents, tasks, MCP, and web tools - Keep core coding-agent workflows: bash, file tools, grep, glob, agents, tasks, MCP, and web tools
## Provenance & Legal Notice
OpenClaude is derived from Anthropic's Claude Code CLI source code, which was
inadvertently exposed in March 2026 through a packaging error in npm. The
original Claude Code source is proprietary software owned by Anthropic PBC.
This project adds multi-provider support, strips telemetry, and adapts the
codebase for open use. It is not an authorized fork or open-source release
by Anthropic.
**"Claude" and "Claude Code" are trademarks of Anthropic PBC.**
Contributors should be aware that the legal status of distributing code
derived from Anthropic's proprietary source is unresolved. See the LICENSE
file for details.
--- ---
## Quick Start ## Quick Start

View File

@@ -34,6 +34,8 @@
"test:provider-recommendation": "bun test src/utils/providerRecommendation.test.ts src/utils/providerProfile.test.ts", "test:provider-recommendation": "bun test src/utils/providerRecommendation.test.ts src/utils/providerProfile.test.ts",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"smoke": "bun run build && node dist/cli.mjs --version", "smoke": "bun run build && node dist/cli.mjs --version",
"verify:privacy": "bun run scripts/verify-no-phone-home.ts",
"build:verified": "bun run build && bun run verify:privacy",
"test:provider": "bun test src/services/api/*.test.ts src/utils/context.test.ts", "test:provider": "bun test src/services/api/*.test.ts src/utils/context.test.ts",
"doctor:runtime": "bun run scripts/system-check.ts", "doctor:runtime": "bun run scripts/system-check.ts",
"doctor:runtime:json": "bun run scripts/system-check.ts --json", "doctor:runtime:json": "bun run scripts/system-check.ts --json",
@@ -140,7 +142,7 @@
"ollama", "ollama",
"gemini" "gemini"
], ],
"license": "MIT", "license": "SEE LICENSE FILE",
"publishConfig": { "publishConfig": {
"access": "public" "access": "public"
} }

View File

@@ -196,6 +196,13 @@ export function classifyFetchError() { return 'disabled'; }
'components/FeedbackSurvey/submitTranscriptShare': ` 'components/FeedbackSurvey/submitTranscriptShare': `
export async function submitTranscriptShare() { return { success: false }; } export async function submitTranscriptShare() { return { success: false }; }
`,
// ─── Internal employee logging (not needed in the external build) ─────
'services/internalLogging': `
export async function logPermissionContextForAnts() {}
export const getContainerId = async () => null;
`, `,
} }

View File

@@ -0,0 +1,46 @@
#!/bin/bash
set -euo pipefail
DIST="dist/cli.mjs"
if [ ! -f "$DIST" ]; then
echo "ERROR: $DIST not found. Run 'bun run build' first."
exit 1
fi
EXIT=0
BANNED=(
"datadoghq.com"
"api/event_logging/batch"
"api/claude_code/metrics"
"getKubernetesNamespace"
"/var/run/secrets/kubernetes"
"/proc/self/mountinfo"
"tengu_internal_record_permission_context"
)
echo "Checking $DIST for banned patterns..."
echo ""
for pattern in "${BANNED[@]}"; do
COUNT=$(grep -F -c "$pattern" "$DIST" 2>/dev/null || true)
COUNT=${COUNT:-0}
if [ "$COUNT" -gt 0 ]; then
echo " FAIL: '$pattern' found ($COUNT occurrences)"
EXIT=1
else
echo " PASS: '$pattern' not found"
fi
done
echo ""
if [ "$EXIT" -eq 0 ]; then
echo "✓ All checks passed — no banned patterns in build output"
else
echo "✗ FAILED — banned patterns found in build output"
fi
exit $EXIT

View File

@@ -0,0 +1,43 @@
import { existsSync, readFileSync } from 'node:fs'
const DIST = 'dist/cli.mjs'
const BANNED_PATTERNS = [
'datadoghq.com',
'api/event_logging/batch',
'api/claude_code/metrics',
'getKubernetesNamespace',
'/var/run/secrets/kubernetes',
'/proc/self/mountinfo',
'tengu_internal_record_permission_context',
] as const
if (!existsSync(DIST)) {
console.error(`ERROR: ${DIST} not found. Run 'bun run build' first.`)
process.exit(1)
}
const contents = readFileSync(DIST, 'utf8')
let exitCode = 0
console.log(`Checking ${DIST} for banned patterns...`)
console.log('')
for (const pattern of BANNED_PATTERNS) {
const count = contents.split(pattern).length - 1
if (count > 0) {
console.log(` FAIL: '${pattern}' found (${count} occurrences)`)
exitCode = 1
} else {
console.log(` PASS: '${pattern}' not found`)
}
}
console.log('')
if (exitCode === 0) {
console.log('✓ All checks passed — no banned patterns in build output')
} else {
console.log('✗ FAILED — banned patterns found in build output')
}
process.exit(exitCode)

View File

@@ -0,0 +1,23 @@
import { expect, test } from 'bun:test'
import { createGitHubIssueUrl } from './Feedback.tsx'
(globalThis as { MACRO?: { VERSION?: string } }).MACRO = { VERSION: '0.1.7' }
test('createGitHubIssueUrl omits empty feedback IDs', () => {
const url = decodeURIComponent(
createGitHubIssueUrl('', 'Bug title', 'Bug description', []),
)
expect(url).not.toContain('Feedback ID:')
expect(url).toContain('Bug Description')
expect(url).toContain('Errors')
})
test('createGitHubIssueUrl includes feedback IDs when present', () => {
const url = decodeURIComponent(
createGitHubIssueUrl('fb-123', 'Bug title', 'Bug description', []),
)
expect(url).toContain('Feedback ID: fb-123')
})

File diff suppressed because one or more lines are too long

View File

@@ -1,11 +1,3 @@
import { isEnvTruthy } from '../utils/envUtils.js'
// Lazy read so ENABLE_GROWTHBOOK_DEV from globalSettings.env (applied after
// module load) is picked up. USER_TYPE is a build-time define so it's safe.
export function getGrowthBookClientKey(): string { export function getGrowthBookClientKey(): string {
return process.env.USER_TYPE === 'ant' return process.env.GROWTHBOOK_CLIENT_KEY ?? ''
? isEnvTruthy(process.env.ENABLE_GROWTHBOOK_DEV)
? 'sdk-yZQvlplybuXjYh6L'
: 'sdk-xRVcrliHIlrg4og4'
: 'sdk-zAZezfDKGoZuXXKe'
} }

View File

@@ -11,7 +11,7 @@ import { getEventMetadata } from './metadata.js'
const DATADOG_LOGS_ENDPOINT = const DATADOG_LOGS_ENDPOINT =
'https://http-intake.logs.us5.datadoghq.com/api/v2/logs' 'https://http-intake.logs.us5.datadoghq.com/api/v2/logs'
const DATADOG_CLIENT_TOKEN = 'pubbbf48e6d78dae54bceaa4acf463299bf' const DATADOG_CLIENT_TOKEN = process.env.DATADOG_CLIENT_TOKEN ?? ''
const DEFAULT_FLUSH_INTERVAL_MS = 15000 const DEFAULT_FLUSH_INTERVAL_MS = 15000
const MAX_BATCH_SIZE = 100 const MAX_BATCH_SIZE = 100
const NETWORK_TIMEOUT_MS = 5000 const NETWORK_TIMEOUT_MS = 5000

View File

@@ -1198,9 +1198,14 @@ export function getErrorMessageIfRefusal(
logEvent('tengu_refusal_api_response', {}) logEvent('tengu_refusal_api_response', {})
const usagePolicyUrl =
getAPIProvider() === 'firstParty'
? 'https://www.anthropic.com/legal/aup'
: "your provider's acceptable use policy"
const baseMessage = getIsNonInteractiveSession() const baseMessage = getIsNonInteractiveSession()
? `${API_ERROR_MESSAGE_PREFIX}: Claude Code is unable to respond to this request, which appears to violate our Usage Policy (https://www.anthropic.com/legal/aup). Try rephrasing the request or attempting a different approach.` ? `${API_ERROR_MESSAGE_PREFIX}: Claude Code is unable to respond to this request, which appears to violate our Usage Policy (${usagePolicyUrl}). Try rephrasing the request or attempting a different approach.`
: `${API_ERROR_MESSAGE_PREFIX}: Claude Code is unable to respond to this request, which appears to violate our Usage Policy (https://www.anthropic.com/legal/aup). Please double press esc to edit your last message or start a new session for Claude Code to assist with a different task.` : `${API_ERROR_MESSAGE_PREFIX}: Claude Code is unable to respond to this request, which appears to violate our Usage Policy (${usagePolicyUrl}). Please double press esc to edit your last message or start a new session for Claude Code to assist with a different task.`
const modelSuggestion = const modelSuggestion =
model !== 'claude-sonnet-4-20250514' model !== 'claude-sonnet-4-20250514'

View File

@@ -0,0 +1,63 @@
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'
import axios from 'axios'
const originalEnv = { ...process.env }
async function importFreshModule() {
return import(`./utils.ts?ts=${Date.now()}-${Math.random()}`)
}
beforeEach(() => {
process.env = { ...originalEnv }
})
afterEach(() => {
process.env = { ...originalEnv }
})
describe('checkDomainBlocklist', () => {
test('returns allowed without API call in OpenAI mode', async () => {
process.env.CLAUDE_CODE_USE_OPENAI = '1'
const getSpy = mock(() =>
Promise.resolve({ status: 200, data: { can_fetch: true } }),
)
axios.get = getSpy as typeof axios.get
const { checkDomainBlocklist } = await importFreshModule()
const result = await checkDomainBlocklist('example.com')
expect(result.status).toBe('allowed')
expect(getSpy).not.toHaveBeenCalled()
})
test('returns allowed without API call in Gemini mode', async () => {
process.env.CLAUDE_CODE_USE_GEMINI = '1'
const getSpy = mock(() =>
Promise.resolve({ status: 200, data: { can_fetch: true } }),
)
axios.get = getSpy as typeof axios.get
const { checkDomainBlocklist } = await importFreshModule()
const result = await checkDomainBlocklist('example.com')
expect(result.status).toBe('allowed')
expect(getSpy).not.toHaveBeenCalled()
})
test('calls Anthropic domain check in first-party mode', async () => {
delete process.env.CLAUDE_CODE_USE_OPENAI
delete process.env.CLAUDE_CODE_USE_GEMINI
delete process.env.CLAUDE_CODE_USE_GITHUB
const getSpy = mock(() =>
Promise.resolve({ status: 200, data: { can_fetch: true } }),
)
axios.get = getSpy as typeof axios.get
const { checkDomainBlocklist } = await importFreshModule()
const result = await checkDomainBlocklist('example.com')
expect(result.status).toBe('allowed')
expect(getSpy).toHaveBeenCalledTimes(1)
})
})

View File

@@ -8,6 +8,7 @@ import { queryHaiku } from '../../services/api/claude.js'
import { AbortError } from '../../utils/errors.js' import { AbortError } from '../../utils/errors.js'
import { getWebFetchUserAgent } from '../../utils/http.js' import { getWebFetchUserAgent } from '../../utils/http.js'
import { logError } from '../../utils/log.js' import { logError } from '../../utils/log.js'
import { getAPIProvider } from '../../utils/model/providers.js'
import { import {
isBinaryContentType, isBinaryContentType,
persistBinaryContent, persistBinaryContent,
@@ -176,6 +177,11 @@ type DomainCheckResult =
export async function checkDomainBlocklist( export async function checkDomainBlocklist(
domain: string, domain: string,
): Promise<DomainCheckResult> { ): Promise<DomainCheckResult> {
// Third-party providers should not consult Anthropic's domain policy.
if (getAPIProvider() !== 'firstParty') {
return { status: 'allowed' }
}
if (DOMAIN_CHECK_CACHE.has(domain)) { if (DOMAIN_CHECK_CACHE.has(domain)) {
return { status: 'allowed' } return { status: 'allowed' }
} }

View File

@@ -21,6 +21,7 @@ import {
import { logForDebugging } from './debug.js' import { logForDebugging } from './debug.js'
import { parseJSONL } from './json.js' import { parseJSONL } from './json.js'
import { logError } from './log.js' import { logError } from './log.js'
import { getAPIProvider } from './model/providers.js'
import { import {
getCanonicalName, getCanonicalName,
getMainLoopModel, getMainLoopModel,
@@ -75,11 +76,13 @@ export function getAttributionTexts(): AttributionTexts {
: 'Claude Opus 4.6' : 'Claude Opus 4.6'
const defaultAttribution = const defaultAttribution =
'🤖 Generated with [OpenClaude](https://github.com/Gitlawb/openclaude)' '🤖 Generated with [OpenClaude](https://github.com/Gitlawb/openclaude)'
const coAuthorDomain =
getAPIProvider() === 'firstParty' ? 'anthropic.com' : 'openclaude.dev'
const defaultCommit = isEnvTruthy( const defaultCommit = isEnvTruthy(
process.env.OPENCLAUDE_DISABLE_CO_AUTHORED_BY, process.env.OPENCLAUDE_DISABLE_CO_AUTHORED_BY,
) )
? '' ? ''
: `Co-Authored-By: ${modelName} <noreply@anthropic.com>` : `Co-Authored-By: ${modelName} <noreply@${coAuthorDomain}>`
const settings = getInitialSettings() const settings = getInitialSettings()

View File

@@ -10,6 +10,7 @@ import {
handleOAuth401Error, handleOAuth401Error,
isClaudeAISubscriber, isClaudeAISubscriber,
} from './auth.js' } from './auth.js'
import { getAPIProvider } from './model/providers.js'
import { getClaudeCodeUserAgent } from './userAgent.js' import { getClaudeCodeUserAgent } from './userAgent.js'
import { getWorkload } from './workloadContext.js' import { getWorkload } from './workloadContext.js'
@@ -54,7 +55,11 @@ export function getMCPUserAgent(): string {
// operators match in robots.txt); the claude-code suffix lets them distinguish // operators match in robots.txt); the claude-code suffix lets them distinguish
// local CLI traffic from claude.ai server-side fetches. // local CLI traffic from claude.ai server-side fetches.
export function getWebFetchUserAgent(): string { export function getWebFetchUserAgent(): string {
return `Claude-User (${getClaudeCodeUserAgent()}; +https://support.anthropic.com/)` const supportUrl =
getAPIProvider() === 'firstParty'
? 'https://support.anthropic.com/'
: 'https://github.com/Gitlawb/openclaude'
return `Claude-User (${getClaudeCodeUserAgent()}; +${supportUrl})`
} }
export type AuthHeaders = { export type AuthHeaders = {

File diff suppressed because one or more lines are too long

90
src/utils/user.test.ts Normal file
View File

@@ -0,0 +1,90 @@
import { afterEach, describe, expect, mock, test } from 'bun:test'
const originalEnv = { ...process.env }
async function importFreshUserModule() {
return import(`./user.ts?ts=${Date.now()}-${Math.random()}`)
}
function installCommonMocks(options?: {
oauthEmail?: string
gitEmail?: string
}) {
mock.module('../bootstrap/state.js', () => ({
getSessionId: () => 'session-test',
}))
mock.module('./auth.js', () => ({
getOauthAccountInfo: () =>
options?.oauthEmail
? {
emailAddress: options.oauthEmail,
organizationUuid: 'org-test',
accountUuid: 'acct-test',
}
: undefined,
getRateLimitTier: () => null,
getSubscriptionType: () => null,
}))
mock.module('./config.js', () => ({
getGlobalConfig: () => ({}),
getOrCreateUserID: () => 'device-test',
}))
mock.module('./cwd.js', () => ({
getCwd: () => 'C:\\repo',
}))
mock.module('./env.js', () => ({
env: { platform: 'windows' },
getHostPlatformForAnalytics: () => 'windows',
}))
mock.module('./envUtils.js', () => ({
isEnvTruthy: (value: string | undefined) =>
!!value && value !== '0' && value.toLowerCase() !== 'false',
}))
mock.module('execa', () => ({
execa: async () => ({
exitCode: options?.gitEmail ? 0 : 1,
stdout: options?.gitEmail ?? '',
}),
}))
}
afterEach(() => {
mock.restore()
process.env = { ...originalEnv }
delete (globalThis as Record<string, unknown>).MACRO
})
describe('user email fallbacks', () => {
test('getCoreUserData does not synthesize Anthropic email from COO_CREATOR', async () => {
process.env.USER_TYPE = 'ant'
process.env.COO_CREATOR = 'alice'
;(globalThis as Record<string, unknown>).MACRO = { VERSION: '0.0.0' }
installCommonMocks()
const { getCoreUserData } = await importFreshUserModule()
const result = getCoreUserData()
expect(result.email).toBeUndefined()
})
test('initUser falls back to git email when oauth email is missing', async () => {
process.env.USER_TYPE = 'ant'
process.env.COO_CREATOR = 'alice'
;(globalThis as Record<string, unknown>).MACRO = { VERSION: '0.0.0' }
installCommonMocks({ gitEmail: 'git@example.com' })
const { initUser, getCoreUserData } = await importFreshUserModule()
await initUser()
const result = getCoreUserData()
expect(result.email).toBe('git@example.com')
})
})

View File

@@ -146,15 +146,6 @@ function getEmail(): string | undefined {
return oauthAccount.emailAddress return oauthAccount.emailAddress
} }
// Ant-only fallbacks below (no execSync)
if (process.env.USER_TYPE !== 'ant') {
return undefined
}
if (process.env.COO_CREATOR) {
return `${process.env.COO_CREATOR}@anthropic.com`
}
// If initUser() wasn't called, we return undefined instead of blocking // If initUser() wasn't called, we return undefined instead of blocking
return undefined return undefined
} }
@@ -166,15 +157,6 @@ async function getEmailAsync(): Promise<string | undefined> {
return oauthAccount.emailAddress return oauthAccount.emailAddress
} }
// Ant-only fallbacks below
if (process.env.USER_TYPE !== 'ant') {
return undefined
}
if (process.env.COO_CREATOR) {
return `${process.env.COO_CREATOR}@anthropic.com`
}
return getGitEmail() return getGitEmail()
} }