diff --git a/src/main/agent-hooks/server-cwd-attribution.test.ts b/src/main/agent-hooks/server-cwd-attribution.test.ts new file mode 100644 index 000000000000..9a8f7ca1e1c4 --- /dev/null +++ b/src/main/agent-hooks/server-cwd-attribution.test.ts @@ -0,0 +1,239 @@ +import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { AgentHookServer } from './server' +import { makePaneKey } from '../../shared/stable-pane-id' + +const { trackMock } = vi.hoisted(() => ({ trackMock: vi.fn() })) + +vi.mock('../telemetry/client', () => ({ track: trackMock })) +vi.mock('../telemetry/cohort-classifier', () => ({ getCohortAtEmit: vi.fn() })) + +const AGENT_PANE = makePaneKey('tab-agent', '11111111-1111-4111-8111-111111111111') +const AGENT_WORKTREE = 'repo-agent::/Users/dev/workspace/agent' + +/** + * Hook body reproducing the shared-daemon leak: a session running in one project posts + * the pane identity it inherited from the pane that first spawned the agent daemon. + */ +function buildBody(payload: Record): Record { + return { + paneKey: AGENT_PANE, + tabId: 'tab-agent', + worktreeId: AGENT_WORKTREE, + env: 'production', + payload + } +} + +/** Relay a hook for the agent pane from a session running somewhere else entirely. */ +function ingestForeign(server: AgentHookServer, prompt: string): void { + server.ingestRemote( + { + paneKey: AGENT_PANE, + tabId: 'tab-agent', + worktreeId: AGENT_WORKTREE, + // Why: the relay forwards cwd beside the payload — normalization strips it from the payload itself. + sourceCwd: '/srv/other-project', + payload: { state: 'working', prompt } + }, + 'conn-1' + ) +} + +function unattributedCallCount(): number { + return trackMock.mock.calls.filter(([name]) => name === 'agent_hook_unattributed').length +} + +beforeEach(() => { + trackMock.mockReset() +}) + +describe('AgentHookServer cwd attribution guard', () => { + it('drops an HTTP hook whose session cwd belongs to another workspace', async () => { + const server = new AgentHookServer() + await server.start({ env: 'production' }) + try { + const env = server.buildPtyEnv() + const postHook = (payload: Record): Promise => + fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/claude`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN + }, + body: JSON.stringify(buildBody(payload)) + }) + + await expect( + postHook({ + hook_event_name: 'UserPromptSubmit', + prompt: 'own session', + cwd: '/Users/dev/workspace/agent' + }) + ).resolves.toMatchObject({ status: 204 }) + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ paneKey: AGENT_PANE, prompt: 'own session' }) + ]) + + await expect( + postHook({ + hook_event_name: 'UserPromptSubmit', + prompt: 'foreign session', + cwd: '/Users/dev/projects/api' + }) + ).resolves.toMatchObject({ status: 204 }) + + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ paneKey: AGENT_PANE, prompt: 'own session' }) + ]) + expect(trackMock).toHaveBeenCalledWith('agent_hook_unattributed', { + reason: 'cwd_worktree_mismatch' + }) + } finally { + server.stop() + } + }) + + it('drops a relayed hook whose session cwd belongs to another workspace', () => { + const server = new AgentHookServer() + ingestForeign(server, 'foreign session') + + expect(server.getStatusSnapshot()).toEqual([]) + expect(trackMock).toHaveBeenCalledWith('agent_hook_unattributed', { + reason: 'cwd_worktree_mismatch' + }) + }) + + it('reports a mis-attributing daemon once per runtime', () => { + // Why: the telemetry per-session ceiling never refills, so a daemon that mis-attributes + // every hook it hosts would otherwise silence every other event in the session. + const server = new AgentHookServer() + for (const prompt of ['first', 'second', 'third']) { + ingestForeign(server, prompt) + } + + expect(server.getStatusSnapshot()).toEqual([]) + expect(unattributedCallCount()).toBe(1) + }) + + it('reports again after a restart, so one runtime does not silence the next', () => { + const server = new AgentHookServer() + ingestForeign(server, 'before restart') + server.stop() + ingestForeign(server, 'after restart') + + expect(unattributedCallCount()).toBe(2) + }) + + it('refuses a foreign hook before it can seed the pane subagent roster', async () => { + // Why: normalization mutates per-pane listener state; if the drop happens after it, a + // foreign SubagentStart still plants a roster row that the pane's own next event re-emits. + const server = new AgentHookServer() + await server.start({ env: 'production' }) + try { + const env = server.buildPtyEnv() + const postHook = (payload: Record): Promise => + fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/claude`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN + }, + body: JSON.stringify(buildBody(payload)) + }) + + await expect( + postHook({ + hook_event_name: 'SubagentStart', + agent_id: 'sa-foreign', + cwd: '/Users/dev/projects/api' + }) + ).resolves.toMatchObject({ status: 204 }) + await expect( + postHook({ + hook_event_name: 'UserPromptSubmit', + prompt: 'own session', + cwd: '/Users/dev/workspace/agent' + }) + ).resolves.toMatchObject({ status: 204 }) + + const rows = server.getStatusSnapshot() + expect(rows).toEqual([ + expect.objectContaining({ paneKey: AGENT_PANE, prompt: 'own session' }) + ]) + expect(rows[0]?.subagents ?? []).toEqual([]) + } finally { + server.stop() + } + }) + + it('keeps a status whose cwd is only a symlink alias of the worktree path', async () => { + // Why: Orca stores the workspace path as picked while agents report physical getcwd + // (macOS /tmp is /private/tmp); one directory spelled two ways is not a foreign session. + const base = mkdtempSync(join(tmpdir(), 'orca-hook-cwd-')) + const real = join(base, 'real-workspace') + mkdirSync(real, { recursive: true }) + const link = join(base, 'linked-workspace') + try { + symlinkSync(real, link, process.platform === 'win32' ? 'junction' : 'dir') + } catch { + rmSync(base, { recursive: true, force: true }) + return // Restricted hosts that cannot create links have nothing to verify here. + } + const server = new AgentHookServer() + await server.start({ env: 'production' }) + try { + const env = server.buildPtyEnv() + const res = await fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/claude`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN + }, + body: JSON.stringify({ + paneKey: AGENT_PANE, + tabId: 'tab-agent', + worktreeId: `repo-agent::${link}`, + env: 'production', + payload: { + hook_event_name: 'UserPromptSubmit', + prompt: 'aliased session', + cwd: realpathSync(real) + } + }) + }) + expect(res.status).toBe(204) + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ paneKey: AGENT_PANE, prompt: 'aliased session' }) + ]) + expect(trackMock).not.toHaveBeenCalledWith('agent_hook_unattributed', { + reason: 'cwd_worktree_mismatch' + }) + } finally { + server.stop() + rmSync(base, { recursive: true, force: true }) + } + }) + + it('keeps hooks that report no cwd, so sources without one stay attributed', () => { + const server = new AgentHookServer() + server.ingestRemote( + { + paneKey: AGENT_PANE, + tabId: 'tab-agent', + worktreeId: AGENT_WORKTREE, + payload: { state: 'working', prompt: 'no cwd reported' } + }, + 'conn-1' + ) + + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ paneKey: AGENT_PANE, prompt: 'no cwd reported' }) + ]) + expect(trackMock).not.toHaveBeenCalledWith('agent_hook_unattributed', { + reason: 'cwd_worktree_mismatch' + }) + }) +}) diff --git a/src/main/agent-hooks/server-last-status-write.test.ts b/src/main/agent-hooks/server-last-status-write.test.ts index f693c2d4c36b..1f20ae2004ce 100644 --- a/src/main/agent-hooks/server-last-status-write.test.ts +++ b/src/main/agent-hooks/server-last-status-write.test.ts @@ -288,7 +288,7 @@ describe('Last-status persistence', () => { } }) - it('does not write prompt interaction keys to last-status.json', async () => { + it('does not write transport-only fields to last-status.json', async () => { const server = new AgentHookServer() await server.start({ env: 'production', @@ -301,7 +301,8 @@ describe('Last-status persistence', () => { hook_event_name: 'MessagePart', role: 'user', text: 'persist status only', - messageID: 'opencode-local-message-id' + messageID: 'opencode-local-message-id', + cwd: '/srv/session' }), '/hook/opencode' ) @@ -309,6 +310,9 @@ describe('Last-status persistence', () => { const file = JSON.parse(readFileSync(lastStatusPath(), 'utf8')) expect(file.entries[PANE].payload.prompt).toBe('persist status only') expect(file.entries[PANE].promptInteractionKey).toBeUndefined() + // Why: hydrate's whitelist drops both, so persisting them leaves the write dedupe + // comparing against bytes hydration can never reproduce. + expect(file.entries[PANE].sourceCwd).toBeUndefined() } finally { server.stop() } diff --git a/src/main/agent-hooks/server.ts b/src/main/agent-hooks/server.ts index 58ad669784be..542cca2df310 100644 --- a/src/main/agent-hooks/server.ts +++ b/src/main/agent-hooks/server.ts @@ -9,6 +9,10 @@ import { track } from '../telemetry/client' import { getCohortAtEmit } from '../telemetry/cohort-classifier' import { AGENT_KIND_VALUES, type AgentKind } from '../../shared/telemetry-events' import { ORCA_HOOK_PROTOCOL_VERSION } from '../../shared/agent-hook-types' +import { + hookCwdContradictsWorktree, + hookCwdContradictsWorktreeAfterLocalResolve +} from '../../shared/agent-hook-cwd-attribution' import { clearAllListenerCaches, clearPaneCacheState, @@ -17,6 +21,7 @@ import { getEndpointFileName, hasCodexTranscriptSubagents, hasPendingAgentResultText, + HOOK_CWD_MAX_LENGTH, HOOK_REQUEST_SLOWLORIS_MS, isNewTurnEvent, markClaudeLeadTurnInterrupted, @@ -27,6 +32,7 @@ import { normalizeClaudePromptId, normalizeHookPayload, parseFormEncodedBody, + readHookBodyCwdAttribution, readRequestBody, reapRestoredClaudeSubagentsForDeadPane, reconcileRemoteCodexState, @@ -126,6 +132,7 @@ type PersistedAgentHookEventPayload = Omit< | 'launchToken' | 'promptInteractionKey' | 'restoredUnconfirmed' + | 'sourceCwd' // Why: revision counters are in-memory and the authority id is regenerated per process, so // a stored observation could only rehydrate as a stale ordering claim from a dead authority. | 'observation' @@ -204,6 +211,9 @@ const AGENT_PROMPT_SENT_AGENT_KINDS = new Set(AGENT_KIND_VALUES) // Why: bound file growth from PTYs that never re-attach; 7 days is the "still relevant?" horizon beyond which entries shouldn't resurrect on hydrate. const HYDRATE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000 +// Why: paneKey in the warn set is attacker-influenced pre-validation input; cap membership so it can't grow unboundedly. +const FOREIGN_CWD_WARN_PANE_CAP = 64 + // Why: a long-closed tab can't receive status events; bound the set so it can't grow one entry per close for the whole session. export const CLOSED_AGENT_STATUS_TAB_IDS_MAX = 1024 export const CLOSED_AGENT_STATUS_PANE_KEYS_MAX = 1024 @@ -742,6 +752,11 @@ export class AgentHookServer { private closedAgentStatusTabIds = new Set() private closedAgentStatusPaneKeys = new Set() private connectionTimestampWatermarkById = new Map() + // Why: telemetry once per runtime — a mis-attributing daemon fires on every hook of every + // session it hosts, and the per-session telemetry ceiling never refills. The warn is per + // pane (bounded) so a second workspace going dark is still diagnosable from the log. + private reportedForeignCwdTelemetry = false + private warnedForeignCwdPaneKeys = new Set() // Why: skip disk writes when the JSON exactly matches the last write; guards against re-firing trailing timers when nothing changed. private lastWrittenJson: string | null = null // Why: main is the pane authority for local/WSL/SSH panes — hook HTTP, relay, and its own @@ -1150,6 +1165,43 @@ export class AgentHookServer { return 'suppress' } + /** Refuse a hook whose reported worktree is disproven by the session cwd it carries. + * `resolveLocalAliases` is true only where this host owns both paths (local HTTP ingest); + * remote events were already alias-resolved by the relay that owns them. */ + private shouldSuppressForeignCwdStatus( + event: Pick, + resolveLocalAliases: boolean + ): boolean { + const contradicts = resolveLocalAliases + ? hookCwdContradictsWorktreeAfterLocalResolve(event.worktreeId, event.sourceCwd) + : hookCwdContradictsWorktree(event.worktreeId, event.sourceCwd) + if (!contradicts) { + return false + } + if (!this.reportedForeignCwdTelemetry) { + this.reportedForeignCwdTelemetry = true + track('agent_hook_unattributed', { reason: 'cwd_worktree_mismatch' }) + } + // Why: the early guard runs pre-validation, so slice before retaining/logging — a + // token-holding client could otherwise park 1MB strings in the set and in the log. + const boundedPaneKey = event.paneKey.slice(0, MAX_PANE_KEY_LEN) + if ( + !this.warnedForeignCwdPaneKeys.has(boundedPaneKey) && + this.warnedForeignCwdPaneKeys.size < FOREIGN_CWD_WARN_PANE_CAP + ) { + this.warnedForeignCwdPaneKeys.add(boundedPaneKey) + console.warn( + '[agent-hooks] dropping status: reported worktree does not own the reporting session', + { + paneKey: boundedPaneKey, + worktreeId: event.worktreeId?.slice(0, HOOK_CWD_MAX_LENGTH), + sourceCwd: event.sourceCwd + } + ) + } + return true + } + // Why: a fence can span tabs (a pane detached into another tab), and legacy numeric // keys never parse as stable ones — resolve both forms so neither slips the tab check. private isClosedAgentStatusTabForPaneKey(paneKey: string): boolean { @@ -2184,6 +2236,7 @@ export class AgentHookServer { toolAgentType?: string providerSession?: unknown providerSessionOnly?: unknown + sourceCwd?: string isReplay?: boolean /** Payload fields the relay dropped to fit an oversized frame; validated below. */ shedFields?: unknown @@ -2373,12 +2426,19 @@ export class AgentHookServer { providerSession, providerSessionOnly: envelope.providerSessionOnly === true ? true : undefined, isReplay: envelope.isReplay === true ? true : undefined, + sourceCwd: + typeof envelope.sourceCwd === 'string' && envelope.sourceCwd.length <= HOOK_CWD_MAX_LENGTH + ? envelope.sourceCwd.trim() || undefined + : undefined, claudeRunningNonAgentTask: typeof envelope.claudeRunningNonAgentTask === 'boolean' ? envelope.claudeRunningNonAgentTask : undefined, payload: normalizedPayload } + if (this.shouldSuppressForeignCwdStatus(event, false)) { + return + } this.recordCurrentAuthorityObservation(event) this.applyNormalizedStatus( event, @@ -2464,6 +2524,13 @@ export class AgentHookServer { trackEmptyPaneKeyHook(body) const aliasedBody = this.normalizeHookBodyPaneKeyAlias(body) + // Why: refuse before normalization — a foreign event must not seed per-pane listener + // state (subagent rosters, lead-turn records) that later legitimate events re-emit. + if (this.shouldSuppressForeignCwdStatus(readHookBodyCwdAttribution(aliasedBody), true)) { + res.writeHead(204) + res.end() + return + } const normalized = this.normalizeLocalHookPayload(source, aliasedBody) const statusDisposition = normalized.event ? this.getAgentStatusDisposition(normalized.event.paneKey, { @@ -2471,7 +2538,13 @@ export class AgentHookServer { isReplay: normalized.event.isReplay }) : 'suppress' - if (normalized.event && statusDisposition !== 'suppress') { + if ( + normalized.event && + statusDisposition !== 'suppress' && + // Why: redundant with the pre-normalization refusal by construction; kept as drift + // armor should the raw-body read and the normalizer ever disagree on a field. + !this.shouldSuppressForeignCwdStatus(normalized.event, true) + ) { const event = statusDisposition === 'restart' ? { ...normalized.event, launchToken: undefined } @@ -2554,6 +2627,8 @@ export class AgentHookServer { this.lastStatusFilePath = null this.lastWrittenJson = null this.runtimeObservedStatusPaneKeys.clear() + this.reportedForeignCwdTelemetry = false + this.warnedForeignCwdPaneKeys.clear() this.hydratedAuthorityCommitments = Object.freeze([]) this.hydratedLaunchTokenHashByPaneKey.clear() this.persistedAuthorityCommitmentsByPaneKey.clear() @@ -3117,9 +3192,12 @@ export class AgentHookServer { } const enrichedPayload = payload as EnrichedAgentHookEventPayload const childOnlyBoundary = enrichedPayload.claudeLeadBoundaryChildOnly === true + // Why: transport-only fields the hydrate whitelist drops — persisting them would + // make hydration lossy and defeat the lastWrittenJson write dedupe. const { claudeRunningNonAgentTask: _claudeRunningNonAgentTask, promptInteractionKey: _promptInteractionKey, + sourceCwd: _sourceCwd, // Why: never persisted — hydrate re-stamps it, so a stored copy could only drift. restoredUnconfirmed: _restoredUnconfirmed, // Why: same — the sequencer that issued it dies with the process (see PersistedAgentHookEventPayload). diff --git a/src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts b/src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts index 2b8fc30c8178..21a64cc82b34 100644 --- a/src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts +++ b/src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts @@ -549,6 +549,8 @@ describe('SshRelaySession agent hooks over a fake relay transport', () => { toolAgentId: 'agent-subagent-a', teammateName: 'reviewer', toolAgentType: 'Review', + // Why: the cwd attribution guard is inert for every SSH event if this hop drops sourceCwd. + sourceCwd: '/srv/remote-session', claudeRunningNonAgentTask: true, providerSessionOnly: true, providerSession: { @@ -573,6 +575,7 @@ describe('SshRelaySession agent hooks over a fake relay transport', () => { toolAgentId: 'agent-subagent-a', teammateName: 'reviewer', toolAgentType: 'Review', + sourceCwd: '/srv/remote-session', claudeRunningNonAgentTask: true, providerSessionOnly: true, providerSession: { diff --git a/src/main/ssh/ssh-relay-session.ts b/src/main/ssh/ssh-relay-session.ts index eb59d0a56fad..26832ec29b77 100644 --- a/src/main/ssh/ssh-relay-session.ts +++ b/src/main/ssh/ssh-relay-session.ts @@ -1507,6 +1507,7 @@ export class SshRelaySession { isReplay?: unknown providerSession?: unknown providerSessionOnly?: unknown + sourceCwd?: unknown shedFields?: unknown claudeRunningNonAgentTask?: unknown payload?: unknown @@ -1542,6 +1543,9 @@ export class SshRelaySession { isReplay: envelope.isReplay === true ? true : undefined, providerSession: envelope.providerSession, providerSessionOnly: envelope.providerSessionOnly === true ? true : undefined, + // Why: without this pass-through the cwd attribution guard in ingestRemote sees no + // cwd and stays inert for every SSH event; ingestRemote re-bounds and trims it. + sourceCwd: typeof envelope.sourceCwd === 'string' ? envelope.sourceCwd : undefined, // Why: names the fields the relay dropped to fit the frame; ingestRemote restores them. shedFields: envelope.shedFields, claudeRunningNonAgentTask: diff --git a/src/relay/agent-hook-envelope-build.ts b/src/relay/agent-hook-envelope-build.ts index 34b4cbbdec61..65bfd592ceed 100644 --- a/src/relay/agent-hook-envelope-build.ts +++ b/src/relay/agent-hook-envelope-build.ts @@ -36,6 +36,10 @@ export function buildRelayHookEnvelope( isReplay: options.isReplay === true ? true : undefined, env, version, + // Already sanitized by the caller: applyEvent clears a cwd that contradicts the worktree + // before caching or forwarding, so both the live and replay legs reach here with a cwd this + // host judged keep-worthy. + sourceCwd: event.sourceCwd, payload: event.payload } } diff --git a/src/relay/agent-hook-server.test.ts b/src/relay/agent-hook-server.test.ts index 3458814ed5ba..3144e7eaf045 100644 --- a/src/relay/agent-hook-server.test.ts +++ b/src/relay/agent-hook-server.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' import { homedir, tmpdir } from 'node:os' import { join } from 'node:path' import { RelayAgentHookServer } from './agent-hook-server' @@ -54,7 +54,7 @@ describe('RelayAgentHookServer', () => { worktreeId: 'wt-1', env: 'remote', version: '1', - payload: { hook_event_name: 'UserPromptSubmit', prompt: 'hi' } + payload: { hook_event_name: 'UserPromptSubmit', prompt: 'hi', cwd: '/srv/app' } }) }) expect(res.status).toBe(204) @@ -66,6 +66,9 @@ describe('RelayAgentHookServer', () => { expect(envelope.connectionId).toBeNull() expect(envelope.payload.state).toBe('working') expect(envelope.payload.prompt).toBe('hi') + // Why: normalization strips cwd from the payload, so Orca can only re-check + // the remote pane attribution if the relay forwards it alongside. + expect(envelope.sourceCwd).toBe('/srv/app') expect(envelope.claudeRunningNonAgentTask).toBe(false) // Why: the relay forwards body env/version so Orca's warn-once // protocol diagnostics and remote-location marker survive the wire. @@ -106,6 +109,187 @@ describe('RelayAgentHookServer', () => { } }) + it('refuses a hook whose cwd disproves the reported worktree before caching or forwarding', async () => { + // Why: this host owns the session's paths, so the relay is the authoritative place to + // refuse a daemon-inherited pane identity — before it can seed listener state, occupy + // the one-slot replay cache, or reach Orca at all. + const forward = vi.fn<(envelope: AgentHookRelayEnvelope) => void>() + const server = new RelayAgentHookServer({ endpointDir: dir, forward }) + await server.start() + try { + const { port, token } = server.getCoordinates() + const res = await fetch(`http://127.0.0.1:${port}/hook/claude`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': token + }, + body: JSON.stringify({ + paneKey: PANE_KEY, + tabId: 'tab-1', + worktreeId: 'repo-1::/nonexistent-orca-relay/worktree-a', + env: 'remote', + version: '1', + payload: { + hook_event_name: 'UserPromptSubmit', + prompt: 'foreign session', + cwd: '/nonexistent-orca-relay/session-b' + } + }) + }) + expect(res.status).toBe(204) + expect(forward).not.toHaveBeenCalled() + expect(server.replayCachedPayloadsForPanes()).toBe(0) + } finally { + server.stop() + } + }) + + it('refuses the foreign event before it can seed relay listener state', async () => { + // Why: normalization mutates per-pane listener state; a refusal placed after it would + // let a foreign SubagentStart plant roster rows that the pane's own next event re-emits. + const forward = vi.fn<(envelope: AgentHookRelayEnvelope) => void>() + const server = new RelayAgentHookServer({ endpointDir: dir, forward }) + await server.start() + try { + const { port, token } = server.getCoordinates() + const postHook = (payload: Record): Promise => + fetch(`http://127.0.0.1:${port}/hook/claude`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': token + }, + body: JSON.stringify({ + paneKey: PANE_KEY, + tabId: 'tab-1', + worktreeId: 'repo-1::/nonexistent-orca-relay/worktree-a', + env: 'remote', + version: '1', + payload + }) + }) + await postHook({ + hook_event_name: 'SubagentStart', + agent_id: 'sa-foreign', + cwd: '/nonexistent-orca-relay/session-b' + }) + expect(forward).not.toHaveBeenCalled() + await postHook({ + hook_event_name: 'UserPromptSubmit', + prompt: 'own session', + cwd: '/nonexistent-orca-relay/worktree-a' + }) + expect(forward).toHaveBeenCalledTimes(1) + expect(forward.mock.calls[0][0].payload.prompt).toBe('own session') + expect(forward.mock.calls[0][0].payload.subagents ?? []).toEqual([]) + } finally { + server.stop() + } + }) + + it('keeps the alias strip on the assistant-message retry leg', async () => { + // Why: the retry re-normalizes the raw body, which re-attaches the contradicting cwd; + // the strip must hold at the cache/forward choke point or Orca's raw re-guard drops the + // enriched row (and the poisoned replay cache drops it again after reconnect). + const real = join(dir, 'real-workspace') + mkdirSync(real, { recursive: true }) + const link = join(dir, 'linked-workspace') + try { + symlinkSync(real, link, process.platform === 'win32' ? 'junction' : 'dir') + } catch { + return // Restricted hosts that cannot create links have nothing to verify here. + } + const forward = vi.fn<(envelope: AgentHookRelayEnvelope) => void>() + const server = new RelayAgentHookServer({ endpointDir: dir, forward }) + const transcriptPath = join(dir, 'events.jsonl') + writeFileSync(transcriptPath, '') + await server.start() + try { + const { port, token } = server.getCoordinates() + const res = await fetch(`http://127.0.0.1:${port}/hook/copilot`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': token + }, + body: JSON.stringify({ + paneKey: PANE_KEY, + tabId: 'tab-1', + worktreeId: `repo-1::${link}`, + env: 'remote', + version: '1', + payload: { hook_event_name: 'Stop', transcriptPath, cwd: realpathSync(real) } + }) + }) + expect(res.status).toBe(204) + expect(forward.mock.calls[0]?.[0].sourceCwd).toBeUndefined() + + // Let the first 50ms retry miss, then land the transcript for a later attempt. + await new Promise((resolve) => setTimeout(resolve, 70)) + writeFileSync( + transcriptPath, + `${JSON.stringify({ + type: 'assistant.message', + data: { content: 'Retry leg completed.' } + })}\n` + ) + await new Promise((resolve) => setTimeout(resolve, 120)) + + const last = forward.mock.calls.at(-1)?.[0] + expect(last?.payload.lastAssistantMessage).toBe('Retry leg completed.') + expect(last?.sourceCwd).toBeUndefined() + } finally { + server.stop() + } + }) + + it('strips sourceCwd when the contradiction is only symlink aliasing of the worktree', async () => { + // Why: raw disjoint but resolved nested means the same directory spelled two ways. + // Orca's re-guard cannot resolve this host's paths, so forwarding the cwd would make + // it re-drop a proven-legitimate row. + const real = join(dir, 'real-workspace') + mkdirSync(real, { recursive: true }) + const link = join(dir, 'linked-workspace') + try { + symlinkSync(real, link, process.platform === 'win32' ? 'junction' : 'dir') + } catch { + return // Restricted hosts that cannot create links have nothing to verify here. + } + const forward = vi.fn<(envelope: AgentHookRelayEnvelope) => void>() + const server = new RelayAgentHookServer({ endpointDir: dir, forward }) + await server.start() + try { + const { port, token } = server.getCoordinates() + const res = await fetch(`http://127.0.0.1:${port}/hook/claude`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': token + }, + body: JSON.stringify({ + paneKey: PANE_KEY, + tabId: 'tab-1', + worktreeId: `repo-1::${link}`, + env: 'remote', + version: '1', + payload: { + hook_event_name: 'UserPromptSubmit', + prompt: 'aliased session', + cwd: realpathSync(real) + } + }) + }) + expect(res.status).toBe(204) + expect(forward).toHaveBeenCalledTimes(1) + const envelope = forward.mock.calls[0][0] + expect(envelope.payload.prompt).toBe('aliased session') + expect(envelope.sourceCwd).toBeUndefined() + } finally { + server.stop() + } + }) + it('rejects requests with the wrong bearer token (403)', async () => { const forward = vi.fn() const server = new RelayAgentHookServer({ endpointDir: dir, forward }) diff --git a/src/relay/agent-hook-server.ts b/src/relay/agent-hook-server.ts index 7dc54d820d50..c680f94518fa 100644 --- a/src/relay/agent-hook-server.ts +++ b/src/relay/agent-hook-server.ts @@ -8,13 +8,20 @@ import { randomUUID } from 'node:crypto' import { join } from 'node:path' import { ORCA_HOOK_PROTOCOL_VERSION } from '../shared/agent-hook-types' +import { + hookCwdContradictsWorktree, + hookCwdContradictsWorktreeAfterLocalResolve +} from '../shared/agent-hook-cwd-attribution' import { clearAllListenerCaches, clearPaneCacheState, createHookListenerState, getEndpointFileName, + HOOK_CWD_MAX_LENGTH, HOOK_REQUEST_SLOWLORIS_MS, + MAX_PANE_KEY_LEN, normalizeHookPayload, + readHookBodyCwdAttribution, readRequestBody, resolveCachedClaudeCompactOwnership, resolveHookSource, @@ -38,6 +45,8 @@ import { AgentHookResultRetryScheduler } from './agent-hook-result-retry-schedul export type RelayHookForward = (envelope: AgentHookRelayEnvelope) => void +const FOREIGN_CWD_WARN_PANE_CAP = 64 + // Why: WSL lacks per-pane teardown, so cap replay-cache recency. const MAX_CACHED_PANES = 256 @@ -80,6 +89,9 @@ export class RelayAgentHookServer { private fixedToken: string | undefined private preferredPort: number private portFallbackApplied = false + // Why: one stderr line per pane — a mis-attributing daemon fires on every hook it hosts. + // Bounded because paneKey here is pre-validation input. + private warnedForeignCwdPaneKeys = new Set() private retryScheduler: AgentHookResultRetryScheduler constructor(options: RelayHookServerOptions) { @@ -176,6 +188,23 @@ export class RelayAgentHookServer { this.retryScheduler.clearAll() clearAllListenerCaches(this.state) this.lastEnvelopeMetaByPaneKey.clear() + this.warnedForeignCwdPaneKeys.clear() + } + + private warnForeignCwdOnce(paneKey: string, worktreeId?: string, sourceCwd?: string): void { + // Why: these run pre-validation, so slice before retaining/logging — a token-holding + // client could otherwise park 1MB strings in the set and emit multi-MB log lines. + const boundedPaneKey = paneKey.slice(0, MAX_PANE_KEY_LEN) + if ( + this.warnedForeignCwdPaneKeys.has(boundedPaneKey) || + this.warnedForeignCwdPaneKeys.size >= FOREIGN_CWD_WARN_PANE_CAP + ) { + return + } + this.warnedForeignCwdPaneKeys.add(boundedPaneKey) + process.stderr.write( + `[relay-hook-server] dropping status: reported worktree does not own the reporting session (paneKey=${boundedPaneKey} worktreeId=${(worktreeId ?? '').slice(0, HOOK_CWD_MAX_LENGTH)} sourceCwd=${sourceCwd ?? ''})\n` + ) } /** Request-driven replay: re-forwards each cached paneKey payload as a fresh notification. Forwards are @@ -248,6 +277,18 @@ export class RelayAgentHookServer { res.end() return } + // Why: same refusal as Orca's local ingest, run on the host that owns the session's + // paths — before normalization, so a foreign daemon-hosted session cannot seed + // per-pane listener state (subagent rosters, lead-turn records) or the replay cache. + const attribution = readHookBodyCwdAttribution(body) + if ( + hookCwdContradictsWorktreeAfterLocalResolve(attribution.worktreeId, attribution.sourceCwd) + ) { + this.warnForeignCwdOnce(attribution.paneKey, attribution.worktreeId, attribution.sourceCwd) + res.writeHead(204) + res.end() + return + } const event = normalizeHookPayload(this.state, source, body, this.env, { allowUnanchoredPreCompact: true, allowUnanchoredPostCompact: true @@ -283,6 +324,18 @@ export class RelayAgentHookServer { env?: string, version?: string ): void { + if ( + event.sourceCwd !== undefined && + hookCwdContradictsWorktree(event.worktreeId, event.sourceCwd) + ) { + // Why: every leg that caches/forwards funnels here — live ingest, the assistant-message + // retry, and the codex subagent poll re-normalize the raw body and re-attach the cwd. + // An event only reaches this point after the pre-normalize refusal, so this host already + // judged a surviving raw contradiction keep-worthy (symlink aliasing, or an unresolvable + // or one-side-stat-able spelling); forwarding the cwd would make Orca's raw re-guard + // re-drop the proven-keep row, and caching it would poison replay after reconnect. + event.sourceCwd = undefined + } if (event.payload.state !== 'done' || event.payload.lastAssistantMessage) { this.retryScheduler.clearAssistantMessageRetry(event.paneKey) } diff --git a/src/shared/agent-hook-cwd-attribution-local-resolve.test.ts b/src/shared/agent-hook-cwd-attribution-local-resolve.test.ts new file mode 100644 index 000000000000..c35dc0f3d291 --- /dev/null +++ b/src/shared/agent-hook-cwd-attribution-local-resolve.test.ts @@ -0,0 +1,58 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { existsSyncMock, realpathNativeMock } = vi.hoisted(() => ({ + existsSyncMock: vi.fn<(path: string) => boolean>(), + realpathNativeMock: vi.fn<(path: string) => string>() +})) + +// Why: the fault semantics (exists-but-unresolvable, one-side-stat-able) cannot be produced +// with real fixtures on stock hosts; deterministic fs stubs pin them instead. +vi.mock('node:fs', () => ({ + existsSync: existsSyncMock, + realpathSync: Object.assign(vi.fn(), { native: realpathNativeMock }) +})) + +import { hookCwdContradictsWorktreeAfterLocalResolve } from './agent-hook-cwd-attribution' + +const WORKTREE = 'repo-1::/data/workspace-a' +const FOREIGN_CWD = '/data/session-b' + +describe('hookCwdContradictsWorktreeAfterLocalResolve local-resolve semantics', () => { + beforeEach(() => { + existsSyncMock.mockReset() + realpathNativeMock.mockReset() + }) + + it('still refuses when both sides resolve to disjoint directories', () => { + existsSyncMock.mockReturnValue(true) + realpathNativeMock.mockImplementation((path) => path) + expect(hookCwdContradictsWorktreeAfterLocalResolve(WORKTREE, FOREIGN_CWD)).toBe(true) + }) + + it('keeps when a local path exists but cannot be resolved', () => { + // Why: EPERM-style mounts or a directory deleted mid-check — the alias question is + // unanswerable for a path this host owns, and unclear keeps. + existsSyncMock.mockReturnValue(true) + realpathNativeMock.mockImplementation(() => { + throw Object.assign(new Error('operation not permitted'), { code: 'EPERM' }) + }) + expect(hookCwdContradictsWorktreeAfterLocalResolve(WORKTREE, FOREIGN_CWD)).toBe(false) + }) + + it('keeps when only one side is stat-able locally', () => { + // Why: a recorded worktree spelling that cannot be stat-ed on the host that owns it + // (renamed, deleted, whitespace-mangled in transit) cannot prove a foreign session. + existsSyncMock.mockImplementation((path) => path === FOREIGN_CWD) + realpathNativeMock.mockImplementation((path) => path) + expect(hookCwdContradictsWorktreeAfterLocalResolve(WORKTREE, FOREIGN_CWD)).toBe(false) + + existsSyncMock.mockImplementation((path) => path === '/data/workspace-a') + expect(hookCwdContradictsWorktreeAfterLocalResolve(WORKTREE, FOREIGN_CWD)).toBe(false) + }) + + it('still refuses when neither side exists locally, preserving the foreign-host verdict', () => { + existsSyncMock.mockReturnValue(false) + expect(hookCwdContradictsWorktreeAfterLocalResolve(WORKTREE, FOREIGN_CWD)).toBe(true) + expect(realpathNativeMock).not.toHaveBeenCalled() + }) +}) diff --git a/src/shared/agent-hook-cwd-attribution.test.ts b/src/shared/agent-hook-cwd-attribution.test.ts new file mode 100644 index 000000000000..c303d0825467 --- /dev/null +++ b/src/shared/agent-hook-cwd-attribution.test.ts @@ -0,0 +1,223 @@ +import { existsSync, mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { + hookCwdContradictsWorktree, + hookCwdContradictsWorktreeAfterLocalResolve +} from './agent-hook-cwd-attribution' +import { FOLDER_WORKSPACE_INSTANCE_SEPARATOR } from './worktree/id' + +const REPO = 'repo-1' +const WORKTREE = `${REPO}::/Users/dev/projects/api` + +describe('hookCwdContradictsWorktree', () => { + it('accepts a session running at or under the reported worktree', () => { + expect(hookCwdContradictsWorktree(WORKTREE, '/Users/dev/projects/api')).toBe(false) + expect(hookCwdContradictsWorktree(WORKTREE, '/Users/dev/projects/api/')).toBe(false) + expect(hookCwdContradictsWorktree(WORKTREE, '/Users/dev/projects/api/src/server')).toBe(false) + }) + + it('rejects a session whose cwd lies outside the reported worktree', () => { + // Why: the daemon-inherited env case — a session in one project reporting another's pane. + expect(hookCwdContradictsWorktree(WORKTREE, '/Users/dev/projects/agent')).toBe(true) + expect(hookCwdContradictsWorktree(WORKTREE, '/Users/dev/projects/api-two')).toBe(true) + }) + + it('accepts a worktree nested under the session cwd', () => { + // Why: folder workspaces can sit below the directory the agent was started in. + expect(hookCwdContradictsWorktree(WORKTREE, '/Users/dev/projects')).toBe(false) + expect(hookCwdContradictsWorktree(WORKTREE, '/')).toBe(false) + }) + + it('stays quiet when either side is missing or unparseable', () => { + expect(hookCwdContradictsWorktree(undefined, '/Users/dev/projects/agent')).toBe(false) + expect(hookCwdContradictsWorktree(WORKTREE, undefined)).toBe(false) + expect(hookCwdContradictsWorktree(WORKTREE, '')).toBe(false) + // Worktree ids without the `::` separator carry no path to compare. + expect(hookCwdContradictsWorktree('onboarding-inline-terminal', '/Users/dev/x')).toBe(false) + expect(hookCwdContradictsWorktree(`${REPO}::`, '/Users/dev/x')).toBe(false) + // Relative and untrimmed cwds have no comparable root. + expect(hookCwdContradictsWorktree(WORKTREE, 'projects/agent')).toBe(false) + expect(hookCwdContradictsWorktree(WORKTREE, ' /Users/dev/projects/agent')).toBe(false) + }) + + it('ignores case and trailing separators when comparing', () => { + expect(hookCwdContradictsWorktree(WORKTREE, '/users/dev/projects/API/src')).toBe(false) + expect( + hookCwdContradictsWorktree(`${REPO}::/Users/dev/projects/api/`, '/Users/dev/projects/api') + ).toBe(false) + }) + + it('treats canonically equivalent non-ASCII paths as the same directory', () => { + // Why: the folder picker stores NFD on macOS while agents report cwd in NFC, so a + // byte comparison would drop every status from a non-ASCII workspace (#10832). + const folder = '/Users/dev/projects/한글' + expect( + hookCwdContradictsWorktree( + `${REPO}::${folder.normalize('NFD')}`, + `${folder.normalize('NFC')}/src` + ) + ).toBe(false) + }) + + it('compares Windows worktrees against Windows cwds', () => { + const windowsWorktree = `${REPO}::C:\\Users\\dev\\api` + expect(hookCwdContradictsWorktree(windowsWorktree, 'C:\\Users\\dev\\api\\src')).toBe(false) + expect(hookCwdContradictsWorktree(windowsWorktree, 'C:/Users/dev/agent')).toBe(true) + }) + + it('treats a drive root as sitting above every path on that drive', () => { + expect(hookCwdContradictsWorktree(`${REPO}::C:\\Users\\dev`, 'C:\\..')).toBe(false) + expect(hookCwdContradictsWorktree(`${REPO}::C:\\`, 'C:\\Users\\dev')).toBe(false) + }) + + it('resolves dot segments so a path cannot pose as being inside the worktree', () => { + // Why: `…/api/../agent` starts with the worktree path as plain text but resolves outside it. + expect(hookCwdContradictsWorktree(WORKTREE, '/Users/dev/projects/api/../agent')).toBe(true) + expect( + hookCwdContradictsWorktree(`${REPO}::C:\\Users\\dev\\api`, 'C:\\Users\\dev\\api\\..\\agent') + ).toBe(true) + // Dot segments that stay inside, or that only spell the worktree a longer way, are not conflicts. + expect(hookCwdContradictsWorktree(WORKTREE, '/Users/dev/projects/api/./src')).toBe(false) + expect(hookCwdContradictsWorktree(WORKTREE, '/Users/dev/projects/api/src/../lib')).toBe(false) + expect( + hookCwdContradictsWorktree(`${REPO}::/Users/dev/projects/x/../api`, '/Users/dev/projects/api') + ).toBe(false) + // Popping past the root lands on the root, which every worktree sits under. + expect(hookCwdContradictsWorktree(WORKTREE, '/../../..')).toBe(false) + }) + + it('refuses to judge across notations a single host can express two ways', () => { + // WSL reports the POSIX mount for a drive-rooted worktree, and UNC names the same + // directory a third way — a mapped drive or a distro share. + expect(hookCwdContradictsWorktree(`${REPO}::C:\\Users\\dev\\api`, '/mnt/c/Users/dev/api')).toBe( + false + ) + expect( + hookCwdContradictsWorktree( + `${REPO}::C:\\Users\\dev\\api`, + '\\\\wsl.localhost\\Ubuntu\\mnt\\c\\Users\\dev\\api' + ) + ).toBe(false) + expect( + hookCwdContradictsWorktree(`${REPO}::\\\\wsl$\\Ubuntu\\home\\dev\\api`, '/home/dev/api') + ).toBe(false) + expect( + hookCwdContradictsWorktree(`${REPO}::/home/dev/api`, '\\\\wsl$\\Ubuntu\\home\\dev\\x') + ).toBe(false) + expect(hookCwdContradictsWorktree(`${REPO}::C:\\..\\..`, '/Users/dev/projects/api')).toBe(false) + expect(hookCwdContradictsWorktree(WORKTREE, 'C:\\..\\..')).toBe(false) + }) + + it('compares the real folder path of a folder-workspace instance id', () => { + const instanceId = '11111111-2222-4333-8444-555555555555' + const folderWorkspace = `${REPO}::/Users/dev/notes${FOLDER_WORKSPACE_INSTANCE_SEPARATOR}${instanceId}` + expect(hookCwdContradictsWorktree(folderWorkspace, '/Users/dev/notes/inbox')).toBe(false) + expect(hookCwdContradictsWorktree(folderWorkspace, '/Users/dev/projects/api')).toBe(true) + }) +}) + +describe('hookCwdContradictsWorktreeAfterLocalResolve', () => { + it('clears a contradiction that is only symlink aliasing of the same directory', () => { + // Why: Orca stores the path as picked while agents report physical getcwd — macOS /tmp + // is /private/tmp, project roots sit behind symlinks — so raw strings can be fully + // disjoint for one directory and the guard must not read that as a foreign session. + const base = mkdtempSync(join(tmpdir(), 'orca-cwd-attr-')) + try { + const real = join(base, 'real-workspace') + mkdirSync(join(real, 'src'), { recursive: true }) + const link = join(base, 'linked-workspace') + try { + symlinkSync(real, link, process.platform === 'win32' ? 'junction' : 'dir') + } catch { + return // Restricted hosts that cannot create links have nothing to verify here. + } + const physicalSessionCwd = join(realpathSync(real), 'src') + expect(hookCwdContradictsWorktree(`${REPO}::${link}`, physicalSessionCwd)).toBe(true) + expect( + hookCwdContradictsWorktreeAfterLocalResolve(`${REPO}::${link}`, physicalSessionCwd) + ).toBe(false) + } finally { + rmSync(base, { recursive: true, force: true }) + } + }) + + it('clears the mirror alias where the session cwd is the symlinked spelling', () => { + // Why: the alias can sit on either side — a logical $PWD-style cwd against a physically + // stored worktree needs the cwd side resolved, not just the worktree side. + const base = mkdtempSync(join(tmpdir(), 'orca-cwd-attr-')) + try { + const real = join(base, 'real-workspace') + mkdirSync(join(real, 'src'), { recursive: true }) + const link = join(base, 'linked-workspace') + try { + symlinkSync(real, link, process.platform === 'win32' ? 'junction' : 'dir') + } catch { + return // Restricted hosts that cannot create links have nothing to verify here. + } + const physicalWorktree = realpathSync(real) + const linkSpelledCwd = join(link, 'src') + expect(hookCwdContradictsWorktree(`${REPO}::${physicalWorktree}`, linkSpelledCwd)).toBe(true) + expect( + hookCwdContradictsWorktreeAfterLocalResolve(`${REPO}::${physicalWorktree}`, linkSpelledCwd) + ).toBe(false) + } finally { + rmSync(base, { recursive: true, force: true }) + } + }) + + it('treats the macOS data-volume firmlink spelling as the same directory', () => { + // Why: firmlinks are not symlinks — realpath keeps /System/Volumes/Data/… even though it + // names the same directory, so resolution alone cannot rescue a firmlink-spelled workspace. + const base = mkdtempSync(join(tmpdir(), 'orca-cwd-attr-')) + try { + const physical = realpathSync(base) + const firmlinkSpelled = `/System/Volumes/Data${physical}` + if (process.platform !== 'darwin' || !existsSync(firmlinkSpelled)) { + return // The alias only exists on macOS data-volume layouts. + } + mkdirSync(join(physical, 'ws', 'src'), { recursive: true }) + expect( + hookCwdContradictsWorktree(`${REPO}::${firmlinkSpelled}/ws`, join(physical, 'ws', 'src')) + ).toBe(true) + expect( + hookCwdContradictsWorktreeAfterLocalResolve( + `${REPO}::${firmlinkSpelled}/ws`, + join(physical, 'ws', 'src') + ) + ).toBe(false) + } finally { + rmSync(base, { recursive: true, force: true }) + } + }) + + it('still refuses genuinely disjoint real directories', () => { + const base = mkdtempSync(join(tmpdir(), 'orca-cwd-attr-')) + try { + const workspace = join(base, 'workspace-a') + const session = join(base, 'session-b') + mkdirSync(workspace) + mkdirSync(session) + expect(hookCwdContradictsWorktreeAfterLocalResolve(`${REPO}::${workspace}`, session)).toBe( + true + ) + } finally { + rmSync(base, { recursive: true, force: true }) + } + }) + + it('keeps the raw verdict for paths that do not exist on this host', () => { + // Why: remote (SSH/WSL) paths fail existsSync here, so the string verdict must stand — + // the relay that owns those paths runs its own resolved check before forwarding. + expect( + hookCwdContradictsWorktreeAfterLocalResolve( + `${REPO}::/nonexistent-orca-guard-test/worktree-a`, + '/nonexistent-orca-guard-test/session-b' + ) + ).toBe(true) + expect( + hookCwdContradictsWorktreeAfterLocalResolve(WORKTREE, '/Users/dev/projects/api/src') + ).toBe(false) + }) +}) diff --git a/src/shared/agent-hook-cwd-attribution.ts b/src/shared/agent-hook-cwd-attribution.ts new file mode 100644 index 000000000000..a4745266cc75 --- /dev/null +++ b/src/shared/agent-hook-cwd-attribution.ts @@ -0,0 +1,119 @@ +import { existsSync, realpathSync } from 'node:fs' +import { + isPathInsideOrEqual, + isRuntimePathAbsolute, + isWindowsAbsolutePathLike, + resolveRuntimePath +} from './cross-platform-path' +import { splitWorktreeIdForFilesystem, WORKTREE_ID_SEPARATOR } from './worktree/id' + +const UNC_NOTATION = /^(?:\/\/|\\\\)/ + +/** + * True when the worktree a hook claims cannot own the session that sent it, judged + * against the cwd that session reported in its own payload. + * + * Why: paneKey and worktreeId ride in PTY env, and env is inherited. A shared agent + * daemon pre-warmed from one pane hands its Orca identity to every session it later + * hosts, so a session in worktree A reports pane B and its status lands on the wrong + * workspace card. Refuse only when both paths are absolute, written in the same + * notation, and disjoint; anything unclear stays attributed, since dropping a real + * status row is the worse failure. + */ +export function hookCwdContradictsWorktree( + worktreeId: string | undefined, + cwd: string | undefined +): boolean { + const worktreePath = worktreeId + ? splitWorktreeIdForFilesystem(worktreeId)?.worktreePath + : undefined + if (!worktreePath || !cwd) { + return false + } + // Why: a relative path has no root, so containment either way is unknowable. + if (!isRuntimePathAbsolute(worktreePath) || !isRuntimePathAbsolute(cwd)) { + return false + } + // Why: UNC aliases another notation — \\wsl$\Ubuntu\mnt\c\x is C:\x, and \\server\share + // is a mapped drive — so it can never disprove the other side. + if (UNC_NOTATION.test(worktreePath) || UNC_NOTATION.test(cwd)) { + return false + } + // Why: a WSL session reports /mnt/c/… for a C:\… worktree; mixed notations aren't comparable. + // Together with the UNC bail this leaves the guard inert across WSL, since translating + // either way needs the session's execution host, which the hook server doesn't know. + if (isWindowsAbsolutePathLike(worktreePath) !== isWindowsAbsolutePathLike(cwd)) { + return false + } + // Why: fold case even on POSIX, which normalizeRuntimePathForComparison deliberately + // won't. Its callers pick candidates, so a missed match costs them nothing; here a + // missed match drops a live status row, and macOS and Windows are case-insensitive. + const workspace = resolveRuntimePath(worktreePath.toLowerCase(), '.') + const session = resolveRuntimePath(cwd.toLowerCase(), '.') + // Why: a session started in a subdirectory is normal, and so is a workspace nested + // under the session root (folder workspaces); only fully disjoint paths are proof. + return !isPathInsideOrEqual(workspace, session) && !isPathInsideOrEqual(session, workspace) +} + +/** `resolved: null` means the path exists here but cannot be resolved (EPERM-style mounts, + * deleted mid-check) — with the alias question unanswerable on a local path, callers keep. + * Nonexistent paths keep their raw spelling so foreign-host paths keep the string verdict. */ +function resolveLocalPath(path: string): { exists: boolean; resolved: string | null } { + try { + if (!existsSync(path)) { + return { exists: false, resolved: path } + } + return { exists: true, resolved: realpathSync.native(path) } + } catch { + return { exists: true, resolved: null } + } +} + +const MACOS_DATA_VOLUME_PREFIX = '/System/Volumes/Data' + +/** Firmlinks are not symlinks — realpath keeps /System/Volumes/Data/Users/… even though it + * names /Users/…. Folding the data-volume prefix runs only when the raw verdict already + * said drop, so it can only rescue rows. */ +function foldMacOsDataVolumePrefix(path: string): string { + return process.platform === 'darwin' && path.startsWith(`${MACOS_DATA_VOLUME_PREFIX}/`) + ? path.slice(MACOS_DATA_VOLUME_PREFIX.length) + : path +} + +/** + * `hookCwdContradictsWorktree`, re-judged on locally resolved paths before trusting a drop. + * + * Why: one directory can spell two fully disjoint strings — macOS /tmp is /private/tmp, + * symlinked project roots, subst drives — and agents report physical getcwd while Orca + * stores the path as picked, so a raw contradiction is only proof once symlink aliasing + * is ruled out. Call this only where the current process runs on the host that owns both + * paths (Orca's local HTTP ingest, the relay's own hook server). + */ +export function hookCwdContradictsWorktreeAfterLocalResolve( + worktreeId: string | undefined, + cwd: string | undefined +): boolean { + if (!hookCwdContradictsWorktree(worktreeId, cwd)) { + return false + } + const parsed = worktreeId ? splitWorktreeIdForFilesystem(worktreeId) : undefined + if (!parsed || !cwd) { + return true + } + const worktree = resolveLocalPath(parsed.worktreePath) + const session = resolveLocalPath(cwd) + if (worktree.resolved === null || session.resolved === null) { + return false + } + // Why: exactly one side stat-able means the other side's recorded spelling cannot be + // trusted on the host that owns it (renamed, deleted, or whitespace-mangled in transit) — + // unclear keeps. Both-nonexistent still drops on the raw strings: that is the foreign-host + // shape, where this process is not the judge of existence. + if (worktree.exists !== session.exists) { + return false + } + return hookCwdContradictsWorktree( + `${parsed.repoId}${WORKTREE_ID_SEPARATOR}${foldMacOsDataVolumePrefix(worktree.resolved)}`, + foldMacOsDataVolumePrefix(session.resolved) + ) +} diff --git a/src/shared/agent-hook-listener.ts b/src/shared/agent-hook-listener.ts index e6a7be4e2dfc..8d7366ebdb2d 100644 --- a/src/shared/agent-hook-listener.ts +++ b/src/shared/agent-hook-listener.ts @@ -363,6 +363,10 @@ export type AgentHookEventPayload = { providerSessionOnly?: boolean /** True when this event is a relay cache replay rather than a live hook. */ isReplay?: boolean + /** Session cwd from the agent's own payload; cross-checks the env-derived pane attribution. + * Whoever populates this must run `hookCwdContradictsWorktree` before applying the status — + * an ingest path that carries the field but skips the guard re-opens the mis-attribution. */ + sourceCwd?: string /** Transport-only Claude background-work evidence used to reject false input-based interrupts. */ claudeRunningNonAgentTask?: boolean payload: ParsedAgentStatusPayload @@ -1008,7 +1012,9 @@ const TRANSCRIPT_MAX_SCAN_BYTES = 4 * 1024 * 1024 const EMPTY_TRANSCRIPT_REGION = Buffer.alloc(0) const AMP_THREAD_ID_MAX_LENGTH = 256 const AMP_MAX_SCOPED_THREAD_CACHE_KEYS = 32 -const GROK_SESSION_CWD_MAX_LENGTH = 4096 +/** Keys agents use for the session cwd; Grok spells it two extra ways. */ +const HOOK_CWD_KEYS = ['cwd', 'workspaceRoot', 'workspace_root'] as const +export const HOOK_CWD_MAX_LENGTH = 4096 const GROK_HOME_ENVELOPE_MAX_LENGTH = 4096 function extractAssistantTextFromLine(line: string): string | undefined { @@ -1309,6 +1315,28 @@ function parseHookBodyPayloadRecord(body: unknown): Record | nu : null } +/** Pre-normalization read of the fields the cwd attribution guard needs, so ingest can + * refuse a foreign event before `normalizeHookPayload` seeds per-pane listener state + * (subagent rosters, lead-turn records) that later legitimate events would re-emit. */ +export function readHookBodyCwdAttribution(body: unknown): { + paneKey: string + worktreeId?: string + sourceCwd?: string +} { + if (typeof body !== 'object' || body === null) { + return { paneKey: '' } + } + const record = body as Record + const hookPayload = parseHookBodyPayloadRecord(body) + return { + paneKey: typeof record.paneKey === 'string' ? record.paneKey.trim() : '', + worktreeId: readStringField(record, 'worktreeId'), + sourceCwd: hookPayload + ? readBoundedString(hookPayload, HOOK_CWD_KEYS, HOOK_CWD_MAX_LENGTH)?.trim() || undefined + : undefined + } +} + function readBoundedString( record: Record, keys: readonly string[], @@ -1351,11 +1379,7 @@ function readGrokSessionMetadata( if (!sessionId || !isSafeGrokSessionId(sessionId)) { return undefined } - const cwd = readBoundedString( - hookPayload, - ['cwd', 'workspaceRoot', 'workspace_root'], - GROK_SESSION_CWD_MAX_LENGTH - ) + const cwd = readBoundedString(hookPayload, HOOK_CWD_KEYS, HOOK_CWD_MAX_LENGTH) // Why: hook scripts report the effective per-PTY/remote Grok home; old scripts fall back to the runtime's for compatibility. const sessionsDir = grokHome ? join(grokHome, 'sessions') @@ -4646,6 +4670,9 @@ export function normalizeHookPayload( : {}), ...(providerSession ? { providerSession } : {}), ...(providerSessionOnly ? { providerSessionOnly: true } : {}), + sourceCwd: + readBoundedString(hookPayloadRecord, HOOK_CWD_KEYS, HOOK_CWD_MAX_LENGTH)?.trim() || + undefined, payload: transportPayload } : null diff --git a/src/shared/agent-hook-relay.ts b/src/shared/agent-hook-relay.ts index 0ea809997667..ab3b16b72e2f 100644 --- a/src/shared/agent-hook-relay.ts +++ b/src/shared/agent-hook-relay.ts @@ -111,6 +111,9 @@ export type AgentHookRelayEnvelope = { /** Forwarded verbatim from the agent CLI POST body. Lets Orca's warn-once * protocol-version diagnostic fire on remote events the same as on local. */ version?: string + /** Session cwd the remote agent reported, used to disprove a pane attribution + * the PTY env got wrong. The normalized `payload` no longer carries it. */ + sourceCwd?: string /** Pre-normalized status payload from the relay's `normalizeHookPayload`. * Orca's `ingestRemote` validates it again at the SSH trust boundary. */ payload: ParsedAgentStatusPayload diff --git a/src/shared/telemetry-events.ts b/src/shared/telemetry-events.ts index 0466ca79430c..d0e856beb5fe 100644 --- a/src/shared/telemetry-events.ts +++ b/src/shared/telemetry-events.ts @@ -778,7 +778,7 @@ const agentHookInstallFailedSchema = z // Why: regression signal for paneKey attribution — a hook event that can't route to a pane. See docs/cli-terminal-hook-pane-key.md. const agentHookUnattributedSchema = z - .object({ reason: z.enum(['empty_pane_key', 'unknown_tab_id']) }) + .object({ reason: z.enum(['empty_pane_key', 'unknown_tab_id', 'cwd_worktree_mismatch']) }) .strict() // Why (#11217): loopback hook POSTs reset mid-body by local security software kill agent status for