From cec9a6627d2608eeecc0caf217b84630fbcd9458 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 10 Nov 2026 10:14:23 -0800 Subject: [PATCH] Enforce native Claude chat identity at launch boundaries (#40548) --- ...laude-chat-entrypoint-callers-snapshot.yml | 14 +++ .../claude-chat-entrypoint-owner-snapshot.yml | 20 +++ .../claude-entrypoint-key-owner-snapshot.yml | 14 +++ .../claude-chat-entrypoint-callers-test.yml | 6 + .../claude-chat-entrypoint-owner-test.yml | 8 ++ .../claude-entrypoint-key-owner-test.yml | 6 + .../ast-grep/rules/claude-chat-entrypoint.yml | 117 ++++++++++++++++++ .../claude-agent-sdk-process-spawn.test.ts | 19 +++ .../claude/claude-agent-sdk-process-spawn.ts | 3 +- src/main/claude/claude-model-catalog-probe.ts | 12 +- .../claude/claude-stream-json-connection.ts | 3 +- .../launch/claude-structured-child-env.ts | 43 ++----- .../startup/claude-chat-entrypoint.test.ts | 20 +++ .../startup/claude-chat-entrypoint.ts | 16 +++ .../startup/claude-parent-session-env.ts | 4 +- 15 files changed, 268 insertions(+), 37 deletions(-) create mode 100644 config/ast-grep/rule-tests/__snapshots__/claude-chat-entrypoint-callers-snapshot.yml create mode 100644 config/ast-grep/rule-tests/__snapshots__/claude-chat-entrypoint-owner-snapshot.yml create mode 100644 config/ast-grep/rule-tests/__snapshots__/claude-entrypoint-key-owner-snapshot.yml create mode 100644 config/ast-grep/rule-tests/claude-chat-entrypoint-callers-test.yml create mode 100644 config/ast-grep/rule-tests/claude-chat-entrypoint-owner-test.yml create mode 100644 config/ast-grep/rule-tests/claude-entrypoint-key-owner-test.yml create mode 100644 config/ast-grep/rules/claude-chat-entrypoint.yml create mode 100644 src/shared/agent-launch/startup/claude-chat-entrypoint.test.ts create mode 100644 src/shared/agent-launch/startup/claude-chat-entrypoint.ts diff --git a/config/ast-grep/rule-tests/__snapshots__/claude-chat-entrypoint-callers-snapshot.yml b/config/ast-grep/rule-tests/__snapshots__/claude-chat-entrypoint-callers-snapshot.yml new file mode 100644 index 00000000000..6bd5f856dd1 --- /dev/null +++ b/config/ast-grep/rule-tests/__snapshots__/claude-chat-entrypoint-callers-snapshot.yml @@ -0,0 +1,14 @@ +id: claude-chat-entrypoint-callers +snapshots: + "import { withOrcaClaudeChatEntrypoint as stamp } from './claude-chat-entrypoint'": + labels: + - source: withOrcaClaudeChatEntrypoint + style: primary + start: 9 + end: 37 + 'withOrcaClaudeChatEntrypoint(env, platform)': + labels: + - source: withOrcaClaudeChatEntrypoint + style: primary + start: 0 + end: 28 diff --git a/config/ast-grep/rule-tests/__snapshots__/claude-chat-entrypoint-owner-snapshot.yml b/config/ast-grep/rule-tests/__snapshots__/claude-chat-entrypoint-owner-snapshot.yml new file mode 100644 index 00000000000..05f177c95df --- /dev/null +++ b/config/ast-grep/rule-tests/__snapshots__/claude-chat-entrypoint-owner-snapshot.yml @@ -0,0 +1,20 @@ +id: claude-chat-entrypoint-owner +snapshots: + "const env = { CLAUDE_CODE_ENTRYPOINT: 'orca' }": + labels: + - source: CLAUDE_CODE_ENTRYPOINT + style: primary + start: 14 + end: 36 + const identity = 'sdk-cli': + labels: + - source: sdk-cli + style: primary + start: 18 + end: 25 + "env['claude_code_entrypoint'] = 'other'": + labels: + - source: claude_code_entrypoint + style: primary + start: 5 + end: 27 diff --git a/config/ast-grep/rule-tests/__snapshots__/claude-entrypoint-key-owner-snapshot.yml b/config/ast-grep/rule-tests/__snapshots__/claude-entrypoint-key-owner-snapshot.yml new file mode 100644 index 00000000000..ea2790098fb --- /dev/null +++ b/config/ast-grep/rule-tests/__snapshots__/claude-entrypoint-key-owner-snapshot.yml @@ -0,0 +1,14 @@ +id: claude-entrypoint-key-owner +snapshots: + "env[CLAUDE_ENTRYPOINT_ENV] = 'orca'": + labels: + - source: CLAUDE_ENTRYPOINT_ENV + style: primary + start: 4 + end: 25 + "import { CLAUDE_ENTRYPOINT_ENV as key } from './claude-chat-entrypoint'": + labels: + - source: CLAUDE_ENTRYPOINT_ENV + style: primary + start: 9 + end: 30 diff --git a/config/ast-grep/rule-tests/claude-chat-entrypoint-callers-test.yml b/config/ast-grep/rule-tests/claude-chat-entrypoint-callers-test.yml new file mode 100644 index 00000000000..cca0fcf607c --- /dev/null +++ b/config/ast-grep/rule-tests/claude-chat-entrypoint-callers-test.yml @@ -0,0 +1,6 @@ +id: claude-chat-entrypoint-callers +valid: + - 'stripInheritedClaudeSessionEnv(env)' +invalid: + - 'withOrcaClaudeChatEntrypoint(env, platform)' + - "import { withOrcaClaudeChatEntrypoint as stamp } from './claude-chat-entrypoint'" diff --git a/config/ast-grep/rule-tests/claude-chat-entrypoint-owner-test.yml b/config/ast-grep/rule-tests/claude-chat-entrypoint-owner-test.yml new file mode 100644 index 00000000000..87a75f4a143 --- /dev/null +++ b/config/ast-grep/rule-tests/claude-chat-entrypoint-owner-test.yml @@ -0,0 +1,8 @@ +id: claude-chat-entrypoint-owner +valid: + - 'const env = withOrcaClaudeChatEntrypoint(configured, platform)' + - "const binary = 'orca'" +invalid: + - "const env = { CLAUDE_CODE_ENTRYPOINT: 'orca' }" + - "env['claude_code_entrypoint'] = 'other'" + - "const identity = 'sdk-cli'" diff --git a/config/ast-grep/rule-tests/claude-entrypoint-key-owner-test.yml b/config/ast-grep/rule-tests/claude-entrypoint-key-owner-test.yml new file mode 100644 index 00000000000..c63b8b38063 --- /dev/null +++ b/config/ast-grep/rule-tests/claude-entrypoint-key-owner-test.yml @@ -0,0 +1,6 @@ +id: claude-entrypoint-key-owner +valid: + - "const binary = 'orca'" +invalid: + - "env[CLAUDE_ENTRYPOINT_ENV] = 'orca'" + - "import { CLAUDE_ENTRYPOINT_ENV as key } from './claude-chat-entrypoint'" diff --git a/config/ast-grep/rules/claude-chat-entrypoint.yml b/config/ast-grep/rules/claude-chat-entrypoint.yml new file mode 100644 index 00000000000..cb56d1fa679 --- /dev/null +++ b/config/ast-grep/rules/claude-chat-entrypoint.yml @@ -0,0 +1,117 @@ +id: claude-chat-entrypoint-owner +language: typescript +severity: error +message: Keep the Claude identity literal in claude-chat-entrypoint.ts. +files: ['src/**'] +ignores: + - '**/*.test.*' + - '**/*.test-fixtures.*' + - '**/__fixtures__/**' + - 'src/shared/agent-launch/startup/claude-chat-entrypoint.ts' +rule: + all: + - any: + - kind: string_fragment + - kind: property_identifier + - kind: identifier + - regex: '(?i)^(CLAUDE_CODE_ENTRYPOINT|sdk-cli)$' +--- +id: claude-chat-entrypoint-owner-tsx +language: tsx +severity: error +message: Keep the Claude identity literal in claude-chat-entrypoint.ts. +files: ['src/**'] +ignores: + - '**/*.test.*' + - '**/*.test-fixtures.*' + - '**/__fixtures__/**' + - 'src/shared/agent-launch/startup/claude-chat-entrypoint.ts' +rule: + all: + - any: + - kind: string_fragment + - kind: property_identifier + - kind: identifier + - regex: '(?i)^(CLAUDE_CODE_ENTRYPOINT|sdk-cli)$' +--- +id: claude-chat-entrypoint-callers +language: typescript +severity: error +message: Only structured native chat and its own probes may apply the chat identity. +files: ['src/**'] +ignores: + - '**/*.test.*' + - '**/*.test-fixtures.*' + - '**/__fixtures__/**' + - 'src/shared/agent-launch/startup/claude-chat-entrypoint.ts' + - 'src/main/claude/claude-stream-json-connection.ts' + - 'src/main/claude/claude-agent-sdk-process-spawn.ts' + - 'src/main/claude/structured/launch/claude-structured-child-env.ts' + - 'src/main/claude/claude-model-catalog-probe.ts' +rule: + all: + - any: + - kind: string_fragment + - kind: property_identifier + - kind: identifier + - regex: '^withOrcaClaudeChatEntrypoint$' +--- +id: claude-chat-entrypoint-callers-tsx +language: tsx +severity: error +message: Only structured native chat and its own probes may apply the chat identity. +files: ['src/**'] +ignores: + - '**/*.test.*' + - '**/*.test-fixtures.*' + - '**/__fixtures__/**' + - 'src/shared/agent-launch/startup/claude-chat-entrypoint.ts' + - 'src/main/claude/claude-stream-json-connection.ts' + - 'src/main/claude/claude-agent-sdk-process-spawn.ts' + - 'src/main/claude/structured/launch/claude-structured-child-env.ts' + - 'src/main/claude/claude-model-catalog-probe.ts' +rule: + all: + - any: + - kind: string_fragment + - kind: property_identifier + - kind: identifier + - regex: '^withOrcaClaudeChatEntrypoint$' +--- +id: claude-entrypoint-key-owner +language: typescript +severity: error +message: Only the chat identity owner and inherited-session isolation may access this key directly. +files: ['src/**'] +ignores: + - '**/*.test.*' + - '**/*.test-fixtures.*' + - '**/__fixtures__/**' + - 'src/shared/agent-launch/startup/claude-chat-entrypoint.ts' + - 'src/shared/agent-launch/startup/claude-parent-session-env.ts' +rule: + all: + - any: + - kind: string_fragment + - kind: property_identifier + - kind: identifier + - regex: '^CLAUDE_ENTRYPOINT_ENV$' +--- +id: claude-entrypoint-key-owner-tsx +language: tsx +severity: error +message: Only the chat identity owner and inherited-session isolation may access this key directly. +files: ['src/**'] +ignores: + - '**/*.test.*' + - '**/*.test-fixtures.*' + - '**/__fixtures__/**' + - 'src/shared/agent-launch/startup/claude-chat-entrypoint.ts' + - 'src/shared/agent-launch/startup/claude-parent-session-env.ts' +rule: + all: + - any: + - kind: string_fragment + - kind: property_identifier + - kind: identifier + - regex: '^CLAUDE_ENTRYPOINT_ENV$' diff --git a/src/main/claude/claude-agent-sdk-process-spawn.test.ts b/src/main/claude/claude-agent-sdk-process-spawn.test.ts index a79821a55b3..65dbbbfaddc 100644 --- a/src/main/claude/claude-agent-sdk-process-spawn.test.ts +++ b/src/main/claude/claude-agent-sdk-process-spawn.test.ts @@ -90,6 +90,25 @@ describe('claude agent SDK process spawn', () => { expect(spawn.managed?.stderrTail().length).toBe(8192) }) + it('keeps native chat identity after SDK and configured environment defaults', () => { + const process = fakeSpawn() + createClaudeCodeProcessSpawn(process.spawnImpl, 'win32').spawn( + sdkOptions({ + env: { + CLAUDE_CODE_ENTRYPOINT: 'sdk-ts', + claude_code_entrypoint: 'account', + PATH: 'C:\\bin' + } + }) + ) + expect(process.specs[0]?.env).toMatchObject({ CLAUDE_CODE_ENTRYPOINT: 'orca', PATH: 'C:\\bin' }) + expect( + Object.keys(process.specs[0]?.env ?? {}).filter( + (key) => key.toUpperCase() === 'CLAUDE_CODE_ENTRYPOINT' + ) + ).toEqual(['CLAUDE_CODE_ENTRYPOINT']) + }) + it('preserves a Windows .cmd shim and its arguments for the host spawner', () => { const process = fakeSpawn() createClaudeCodeProcessSpawn(process.spawnImpl, 'win32').spawn( diff --git a/src/main/claude/claude-agent-sdk-process-spawn.ts b/src/main/claude/claude-agent-sdk-process-spawn.ts index a5eb7f49e55..71b6adcd9e3 100644 --- a/src/main/claude/claude-agent-sdk-process-spawn.ts +++ b/src/main/claude/claude-agent-sdk-process-spawn.ts @@ -1,3 +1,4 @@ +import { withOrcaClaudeChatEntrypoint } from '../../shared/agent-launch/startup/claude-chat-entrypoint' import type { ChildProcessWithoutNullStreams } from 'node:child_process' import type { PipedProcessSpawner } from '@orca/process-host/process-spec' import type { SpawnOptions as ClaudeAgentSdkSpawnOptions } from '@anthropic-ai/claude-agent-sdk' @@ -70,7 +71,7 @@ export function createClaudeCodeProcessSpawn( { spawnImpl, platform, - inheritedEnv: definedEnv(options.env), + inheritedEnv: withOrcaClaudeChatEntrypoint(definedEnv(options.env), platform), site: 'claude-stream-json-teardown', policy: (supervised) => claudeChildClosePolicy(supervised, platform), acceptClose: claudeChildCloseProven diff --git a/src/main/claude/claude-model-catalog-probe.ts b/src/main/claude/claude-model-catalog-probe.ts index e58d16209ea..cac6371c9d1 100644 --- a/src/main/claude/claude-model-catalog-probe.ts +++ b/src/main/claude/claude-model-catalog-probe.ts @@ -1,3 +1,4 @@ +import { withOrcaClaudeChatEntrypoint } from '../../shared/agent-launch/startup/claude-chat-entrypoint' import { waitForPromiseWithSignal } from '../../shared/abort-signal-reason' import { assertStructuredAgentResolutionCurrent, @@ -88,7 +89,11 @@ export async function runClaudeListing( stdin: request.stdin, ...(request.cwd ? { cwd: request.cwd } : {}) }, - { site: request.site, inheritedEnv: env, signal: request.signal } + { + site: request.site, + inheritedEnv: withOrcaClaudeChatEntrypoint(env, process.platform), + signal: request.signal + } ) } const result = await runClaudeBackgroundAttempts({ @@ -197,7 +202,10 @@ export function createClaudeModelCatalogProbe( { site: 'claude-model-catalog-probe', // The listing runs from the account folder; relative PATH entries still mean the chat's. - inheritedEnv: launchDirectory ? envWithPathRelativeTo(env, launchDirectory) : env, + inheritedEnv: withOrcaClaudeChatEntrypoint( + launchDirectory ? envWithPathRelativeTo(env, launchDirectory) : env, + process.platform + ), signal, partialOutput: { timeoutMs: 2_000, diff --git a/src/main/claude/claude-stream-json-connection.ts b/src/main/claude/claude-stream-json-connection.ts index df808c208e2..ae39fdfb007 100644 --- a/src/main/claude/claude-stream-json-connection.ts +++ b/src/main/claude/claude-stream-json-connection.ts @@ -1,3 +1,4 @@ +import { withOrcaClaudeChatEntrypoint } from '../../shared/agent-launch/startup/claude-chat-entrypoint' import { claudeStartupAnswerObservation } from './claude-startup-answer-observation' import type { PipedProcessSpawner } from '@orca/process-host/process-spec' import type * as ClaudeAgentSdk from '@anthropic-ai/claude-agent-sdk' @@ -123,7 +124,7 @@ export async function openClaudeStreamJsonConnection( options: { ...launch.options, cwd: launch.cwd, - env, + env: withOrcaClaudeChatEntrypoint(env, process.platform), pathToClaudeCodeExecutable: launch.pathToClaudeCodeExecutable, spawnClaudeCodeProcess: spawner.spawn, ...(handlers.canUseTool ? { canUseTool: handlers.canUseTool } : {}), diff --git a/src/main/claude/structured/launch/claude-structured-child-env.ts b/src/main/claude/structured/launch/claude-structured-child-env.ts index 84cb638c262..64d70bf7717 100644 --- a/src/main/claude/structured/launch/claude-structured-child-env.ts +++ b/src/main/claude/structured/launch/claude-structured-child-env.ts @@ -1,3 +1,4 @@ +import { withOrcaClaudeChatEntrypoint } from '../../../../shared/agent-launch/startup/claude-chat-entrypoint' import { waitForPromiseWithSignal } from '../../../../shared/abort-signal-reason' import { assertStructuredAgentResolutionCurrent, @@ -91,24 +92,6 @@ async function readClaudeChildEnvSources(deps: ClaudeEnvDeps): Promise, - overlay: Record | undefined, - platform: NodeJS.Platform -): Record { - const inherited = { ...inheritedEnv } - stripInheritedClaudeSessionEnv(inherited, { platform }) - // The SDK reads only the exact name and otherwise adds its own default, which Windows would pick. - return overlayEnvironmentVariables( - { ...inherited, [CLAUDE_ENTRYPOINT_ENV]: 'orca' }, - overlay, - platform - ) -} - // Merge onto the shell environment to retain PATH and ambient Anthropic auth. export function claudeChildEnv( sources: ClaudeChildEnvSources, @@ -116,13 +99,9 @@ export function claudeChildEnv( /** False when the session's seal pairs the CLI runtime itself, behind this app's CLI. */ { runtimeOnPath = true }: { runtimeOnPath?: boolean } = {} ): Record { - const env = decorateEnv( - withOrcaClaudeEntrypoint( - withoutInheritedClaudeConfigDir(sources.inheritedEnv, process.platform), - sources.overlay, - process.platform - ) - ) + const inherited = withoutInheritedClaudeConfigDir(sources.inheritedEnv, process.platform) + stripInheritedClaudeSessionEnv(inherited) + const env = decorateEnv(overlayEnvironmentVariables(inherited, sources.overlay, process.platform)) return runtimeOnPath ? withCliRuntimeOnPath(sources.command, env, { platform: process.platform }) : env @@ -130,12 +109,12 @@ export function claudeChildEnv( // Version probes need the launch runtime and PATH, without auth credentials. export function claudeProbeEnv(sources: ClaudeChildEnvSources): Record { - return applyClaudeEnvPatch( - claudeChildEnv(sources), - {}, - { - stripAuthEnv: true, - platform: process.platform - } + return withOrcaClaudeChatEntrypoint( + applyClaudeEnvPatch( + claudeChildEnv(sources), + {}, + { stripAuthEnv: true, platform: process.platform } + ), + process.platform ) } diff --git a/src/shared/agent-launch/startup/claude-chat-entrypoint.test.ts b/src/shared/agent-launch/startup/claude-chat-entrypoint.test.ts new file mode 100644 index 00000000000..79280bc9271 --- /dev/null +++ b/src/shared/agent-launch/startup/claude-chat-entrypoint.test.ts @@ -0,0 +1,20 @@ +import { expect, it } from 'vitest' +import { withOrcaClaudeChatEntrypoint } from './claude-chat-entrypoint' + +it('gives the SDK one exact chat identity after differently cased configured values', () => { + expect( + withOrcaClaudeChatEntrypoint( + { + CLAUDE_CODE_ENTRYPOINT: 'sdk-ts', + claude_code_entrypoint: 'configured', + PATH: 'C:\\bin', + CLAUDE_CODE_SESSION_ID: 'deliberate' + }, + 'win32' + ) + ).toEqual({ + CLAUDE_CODE_ENTRYPOINT: 'orca', + PATH: 'C:\\bin', + CLAUDE_CODE_SESSION_ID: 'deliberate' + }) +}) diff --git a/src/shared/agent-launch/startup/claude-chat-entrypoint.ts b/src/shared/agent-launch/startup/claude-chat-entrypoint.ts new file mode 100644 index 00000000000..92eb33278cf --- /dev/null +++ b/src/shared/agent-launch/startup/claude-chat-entrypoint.ts @@ -0,0 +1,16 @@ +import { overlayEnvironmentVariables } from '../../environment-overlay' + +export const CLAUDE_ENTRYPOINT_ENV = 'CLAUDE_CODE_ENTRYPOINT' +const ORCA_CLAUDE_CHAT_ENTRYPOINT = 'orca' + +/** Only structured chats own this identity; other Claude processes retain their CLI default. */ +export function withOrcaClaudeChatEntrypoint( + env: Readonly>, + platform: NodeJS.Platform +): Record { + return overlayEnvironmentVariables( + env, + { [CLAUDE_ENTRYPOINT_ENV]: ORCA_CLAUDE_CHAT_ENTRYPOINT }, + platform + ) +} diff --git a/src/shared/agent-launch/startup/claude-parent-session-env.ts b/src/shared/agent-launch/startup/claude-parent-session-env.ts index 309f9f8a3cb..5d67d327356 100644 --- a/src/shared/agent-launch/startup/claude-parent-session-env.ts +++ b/src/shared/agent-launch/startup/claude-parent-session-env.ts @@ -1,3 +1,5 @@ +import { CLAUDE_ENTRYPOINT_ENV } from './claude-chat-entrypoint' + // A launching Claude's identity and messaging access do not belong to independent processes. export const CLAUDE_CHILD_SESSION_STAMP_ENV_KEYS = [ 'CLAUDECODE', @@ -9,7 +11,7 @@ export const CLAUDE_CHILD_SESSION_STAMP_ENV_KEYS = [ 'CLAUDE_CODE_MESSAGING_SOCKET', 'CLAUDE_CODE_MESSAGING_TOKEN', 'CLAUDE_CODE_EXECPATH', - 'CLAUDE_CODE_ENTRYPOINT', + CLAUDE_ENTRYPOINT_ENV, 'CLAUDE_CODE_DESKTOP_APP_VERSION', 'CLAUDE_EFFORT', 'CLAUDE_PID',