From eb5a76c31a230ab9f73ca094bef3b2d105840a8a Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 14 Jul 2026 22:41:22 -0700 Subject: [PATCH] fix(codex): grant managed hook trust via codex app-server RPCs in install/refresh Host and WSL installs now grant trust for Orca's managed status hooks through codex's own hooks/list -> config/batchWrite -> re-list verify, scoped to exactly the managed entries; the previous computeTrustedHash lane is the unchanged fallback for incapable/erroring CLIs. getStatus and the removal paths recognize ledger-recorded codex hashes so drift between codex's real algorithm and the replica no longer misreports or strands trust. SSH remote install is untouched by design. --- src/main/codex/codex-wsl-hook-install-plan.ts | 9 +- src/main/codex/hook-service.ts | 114 +++++++++++++++--- src/main/index.ts | 11 ++ src/shared/telemetry-events.ts | 23 ++++ 4 files changed, 140 insertions(+), 17 deletions(-) diff --git a/src/main/codex/codex-wsl-hook-install-plan.ts b/src/main/codex/codex-wsl-hook-install-plan.ts index 6edc4aed660..0f63d036443 100644 --- a/src/main/codex/codex-wsl-hook-install-plan.ts +++ b/src/main/codex/codex-wsl-hook-install-plan.ts @@ -13,6 +13,11 @@ export type CodexWslRuntimeHookInstallPlan = { scriptPath: string commandScriptPath: string trustConfigPath: string + /** Distro that executes Codex for this runtime home (RPC trust grants run + * codex inside it). */ + wslDistro: string + /** Canonical Linux-side runtime home — CODEX_HOME for in-distro codex runs. */ + linuxRuntimeHome: string } export type WslCanonicalPathSettlement = @@ -184,7 +189,9 @@ export function createCodexWslRuntimeHookInstallPlan( tomlPath: pathWin32.join(runtimeHomePath, 'config.toml'), scriptPath: pathWin32.join(runtimeHomePath, '.orca', 'agent-hooks', 'codex-hook.sh'), commandScriptPath: `${linuxRuntimeHome}/.orca/agent-hooks/codex-hook.sh`, - trustConfigPath: `${linuxRuntimeHome}/hooks.json` + trustConfigPath: `${linuxRuntimeHome}/hooks.json`, + wslDistro: distro, + linuxRuntimeHome } } diff --git a/src/main/codex/hook-service.ts b/src/main/codex/hook-service.ts index b09a94f5ede..1011d595ac3 100644 --- a/src/main/codex/hook-service.ts +++ b/src/main/codex/hook-service.ts @@ -66,6 +66,12 @@ import { promoteCodexRuntimeHookApprovalsToSystem, snapshotCodexRuntimeHookTrustProvenance } from './hook-trust-promotion' +import { grantManagedCodexHookTrust } from './codex-hook-trust-grant' +import { + readCodexTrustGrantLedgerHome, + removeCodexTrustGrantLedgerHome, + type CodexTrustGrantLedgerHome +} from './codex-trust-grant-ledger' // Why: PreToolUse/PostToolUse give the dashboard a live readout of the // in-flight tool (name + input preview) between UserPromptSubmit and Stop. @@ -630,12 +636,34 @@ function cleanupLegacyManagedHookRepresentations(): void { } } +function readLedgerHomeForCleanup(runtimeHomePath: string): CodexTrustGrantLedgerHome | null { + try { + return readCodexTrustGrantLedgerHome(runtimeHomePath) + } catch { + return null + } +} + +// Why: RPC-granted entries carry Codex's hash, which need not equal the +// self-computed one — the grant ledger is what proves those blocks are ours. +function addLedgerRecognizedHash( + recognizedHashes: Set, + ledgerHome: CodexTrustGrantLedgerHome | null, + key: string +): void { + const granted = ledgerHome?.entries[normalizeHookTrustKeyForLookup(key)] + if (granted?.trustedHash) { + recognizedHashes.add(granted.trustedHash) + } +} + function removeRuntimeManagedHookTrustEntries(configPath: string): void { try { const tomlPath = getCodexConfigTomlPath() const existingEntries = readHookTrustEntries(tomlPath) const scriptPath = getManagedScriptPath() const command = getManagedCommand(scriptPath) + const ledgerHome = readLedgerHomeForCleanup(getOrcaManagedCodexHomePath()) const managedEventLabels = new Set( CODEX_EVENTS.map((event) => CODEX_EVENT_LABEL[event]) ) @@ -670,6 +698,7 @@ function removeRuntimeManagedHookTrustEntries(configPath: string): void { computeTrustedHash(expectedEntry), computeTrustedHash({ ...expectedEntry, timeoutSec: undefined }) ]) + addLedgerRecognizedHash(recognizedHashes, ledgerHome, key) if (!state.trustedHash || !recognizedHashes.has(state.trustedHash)) { continue } @@ -678,6 +707,7 @@ function removeRuntimeManagedHookTrustEntries(configPath: string): void { if (ourKeys.length > 0) { removeHookTrustEntries(tomlPath, ourKeys) } + removeCodexTrustGrantLedgerHome(getOrcaManagedCodexHomePath()) } catch (error) { // Best effort — stale trust entries are harmless once hooks.json no // longer references the hook. Log so a programmer error doesn't disappear silently. @@ -689,6 +719,7 @@ function removeWslRuntimeManagedHookTrustEntries(plan: CodexWslRuntimeHookInstal try { const existingEntries = readHookTrustEntries(plan.tomlPath) const command = wrapReadablePosixHookCommand(plan.commandScriptPath) + const ledgerHome = readLedgerHomeForCleanup(pathWin32.dirname(plan.tomlPath)) const managedEventLabels = new Set( CODEX_EVENTS.map((event) => CODEX_EVENT_LABEL[event]) ) @@ -717,6 +748,7 @@ function removeWslRuntimeManagedHookTrustEntries(plan: CodexWslRuntimeHookInstal computeTrustedHash(expectedEntry), computeTrustedHash({ ...expectedEntry, timeoutSec: undefined }) ]) + addLedgerRecognizedHash(recognizedHashes, ledgerHome, key) if (state.trustedHash && recognizedHashes.has(state.trustedHash)) { ourKeys.push(key) } @@ -724,6 +756,7 @@ function removeWslRuntimeManagedHookTrustEntries(plan: CodexWslRuntimeHookInstal if (ourKeys.length > 0) { removeHookTrustEntries(plan.tomlPath, ourKeys) } + removeCodexTrustGrantLedgerHome(pathWin32.dirname(plan.tomlPath)) } catch (error) { // Why: removing disabled WSL status hooks should be best-effort like the // host cleanup path; stale trust is inert once hooks.json no longer points at us. @@ -739,6 +772,7 @@ function removeStaleWslRuntimeManagedHookTrustEntries( desiredEntries.map((entry) => normalizeHookTrustKeyForLookup(computeTrustKey(entry))) ) const existingEntries = readHookTrustEntries(tomlPath) + const ledgerHome = readLedgerHomeForCleanup(pathWin32.dirname(tomlPath)) const ourKeys: string[] = [] for (const [key, state] of existingEntries) { if (desiredKeys.has(normalizeHookTrustKeyForLookup(key))) { @@ -768,6 +802,7 @@ function removeStaleWslRuntimeManagedHookTrustEntries( computeTrustedHash(expectedEntry), computeTrustedHash({ ...expectedEntry, timeoutSec: undefined }) ]) + addLedgerRecognizedHash(recognizedHashes, ledgerHome, key) if (state.trustedHash && recognizedHashes.has(state.trustedHash)) { ourKeys.push(key) } @@ -923,10 +958,23 @@ function installManagedHooksIntoWslRuntime( writeManagedScript(plan.scriptPath, getManagedScript('posix')) writeCodexHooksJson(plan.configPath, nextHooks) try { - // Why: WSL runtime homes may carry user hook approvals we did not rebuild - // here; only upsert Orca's entries instead of sweeping the whole source. - upsertHookTrustEntries(plan.tomlPath, trustEntries) - removeStaleWslRuntimeManagedHookTrustEntries(plan.tomlPath, trustEntries) + // Why: same grant-then-fallback split as the host install — codex runs + // inside the distro so the hash authority matches the codex the pane runs. + const grant = grantManagedCodexHookTrust({ + runtimeHomePath: pathWin32.dirname(plan.tomlPath), + tomlPath: plan.tomlPath, + managedCommand: command, + managedEntries: trustEntries, + host: { kind: 'wsl', distro: plan.wslDistro, linuxRuntimeHome: plan.linuxRuntimeHome } + }) + if (grant.lane === 'rpc') { + removeStaleWslRuntimeManagedHookTrustEntries(plan.tomlPath, grant.entries) + } else { + // Why: WSL runtime homes may carry user hook approvals we did not rebuild + // here; only upsert Orca's entries instead of sweeping the whole source. + upsertHookTrustEntries(plan.tomlPath, trustEntries) + removeStaleWslRuntimeManagedHookTrustEntries(plan.tomlPath, trustEntries) + } } catch (error) { return { agent: 'codex', @@ -1131,6 +1179,10 @@ export class CodexHookService { trustEntries = new Map() trustReadError = error instanceof Error ? error.message : String(error) } + // Why: RPC-granted entries store Codex's own hash, which is authoritative + // even when it differs from computeTrustedHash — that difference is the + // drift bug class this lane exists to absorb, not a stale entry. + const ledgerHome = readLedgerHomeForCleanup(getOrcaManagedCodexHomePath()) const missing: string[] = [] const trustMissing: string[] = [] @@ -1175,9 +1227,14 @@ export class CodexHookService { command, timeoutSec: MANAGED_HOOK_TIMEOUT_SECONDS } - const expectedHash = computeTrustedHash(trustInput) - const actualState = trustEntries.get(computeTrustKey(trustInput)) - if (actualState?.trustedHash !== expectedHash) { + const trustKey = computeTrustKey(trustInput) + const validHashes = new Set([computeTrustedHash(trustInput)]) + const granted = ledgerHome?.entries[normalizeHookTrustKeyForLookup(trustKey)] + if (granted && granted.signature === getCodexHookTrustSignature(trustInput)) { + validHashes.add(granted.trustedHash) + } + const actualState = trustEntries.get(trustKey) + if (!actualState?.trustedHash || !validHashes.has(actualState.trustedHash)) { trustMissing.push(eventName) } else if (actualState?.enabled === false) { disabled.push(eventName) @@ -1277,7 +1334,10 @@ export class CodexHookService { const mirroredUserTrustEntries = moveMirroredRuntimeUserTrustAfterManagedStatusHook( hookPlan.trustEntries ) - const trustEntries: CodexTrustEntry[] = mirroredUserTrustEntries.map(({ entry }) => entry) + const mirroredTrustEntries: CodexTrustEntry[] = mirroredUserTrustEntries.map( + ({ entry }) => entry + ) + const managedTrustEntries: CodexTrustEntry[] = [] for (const eventName of CODEX_EVENTS) { const current = Array.isArray(nextHooks[eventName]) ? nextHooks[eventName] : [] const cleaned = removeManagedCommands(current, isManagedCommand) @@ -1290,7 +1350,7 @@ export class CodexHookService { // state while Codex visibly reports that hooks are still running. // timeoutSec mirrors the hook's `timeout` so the trust hash matches the // entry actually written to hooks.json. - trustEntries.push({ + managedTrustEntries.push({ sourcePath: configPath, eventLabel: CODEX_EVENT_LABEL[eventName], groupIndex: 0, @@ -1299,6 +1359,7 @@ export class CodexHookService { timeoutSec: MANAGED_HOOK_TIMEOUT_SECONDS }) } + const trustEntries: CodexTrustEntry[] = [...mirroredTrustEntries, ...managedTrustEntries] config.hooks = nextHooks writeManagedScript(scriptPath, getManagedScript()) @@ -1309,13 +1370,34 @@ export class CodexHookService { try { const tomlPath = getCodexConfigTomlPath() syncSystemConfigIntoManagedCodexHome() - // Why: system user hook approvals are mirrored into runtime CODEX_HOME. - // If the user later revokes approval in ~/.codex/config.toml, preserving - // all old runtime [hooks.state.*] blocks would keep Orca Codex trusted. - // Upsert first so duplicate repair can preserve a disabled managed copy - // before stale cleanup removes old managed hook keys. - upsertHookTrustEntries(tomlPath, trustEntries) - removeStaleRuntimeHookTrustEntries(tomlPath, configPath, trustEntries) + // Why: Codex is the only authority on its trust-hash algorithm, so the + // managed entries are granted through codex app-server RPCs (verified by + // re-list) whenever the installed CLI supports them; the granted entries + // then carry Codex's verbatim hashes into stale cleanup so it cannot + // delete what Codex just wrote. Mirrored user trust keeps its existing + // verbatim-carry lane either way. + const grant = grantManagedCodexHookTrust({ + runtimeHomePath: getOrcaManagedCodexHomePath(), + tomlPath, + managedCommand: command, + managedEntries: managedTrustEntries, + host: { kind: 'native' } + }) + if (grant.lane === 'rpc') { + upsertHookTrustEntries(tomlPath, mirroredTrustEntries) + removeStaleRuntimeHookTrustEntries(tomlPath, configPath, [ + ...mirroredTrustEntries, + ...grant.entries + ]) + } else { + // Why: system user hook approvals are mirrored into runtime CODEX_HOME. + // If the user later revokes approval in ~/.codex/config.toml, preserving + // all old runtime [hooks.state.*] blocks would keep Orca Codex trusted. + // Upsert first so duplicate repair can preserve a disabled managed copy + // before stale cleanup removes old managed hook keys. + upsertHookTrustEntries(tomlPath, trustEntries) + removeStaleRuntimeHookTrustEntries(tomlPath, configPath, trustEntries) + } applyMirroredRuntimeUserHookTrustStates(tomlPath, mirroredUserTrustEntries) } catch (error) { return { diff --git a/src/main/index.ts b/src/main/index.ts index 8bda7328eda..41e04cc7ace 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -129,6 +129,7 @@ import { } from './codex-accounts/runtime-selection' import { normalizeClaudeRuntimeSelection } from './claude-accounts/runtime-selection' import { codexHookService } from './codex/hook-service' +import { setCodexTrustGrantTelemetry } from './codex/codex-hook-trust-grant' import { getDefaultWslDistro } from './wsl' import { ClaudeAccountService } from './claude-accounts/service' import { ClaudeRuntimeAuthService } from './claude-accounts/runtime-auth-service' @@ -1727,6 +1728,16 @@ app.whenReady().then(async () => { // the Store reference, seeds common props, and resets per-session burst // caps. Actual transport initialization is still gated by both flags. initTelemetry(store) + // Why: the trust-grant module is bundled into plain-node CLI entries where + // the telemetry client cannot load, so the tracker is injected here instead + // of imported there. + setCodexTrustGrantTelemetry(({ outcome, hostKind, reason }) => { + track('codex_trust_grant', { + outcome, + host_kind: hostKind, + ...(reason !== undefined ? { fallback_reason: reason } : {}) + }) + }) // Why: the error-tracking lane (telemetry-error-tracking.md) is its own // composition root — independent of product telemetry — and must // initialize before any IPC handler / runtime span is created so the diff --git a/src/shared/telemetry-events.ts b/src/shared/telemetry-events.ts index ee34b53ec3c..28b754102a2 100644 --- a/src/shared/telemetry-events.ts +++ b/src/shared/telemetry-events.ts @@ -442,6 +442,27 @@ const agentErrorSchema = z // v1.4.129-rc.1, which was otherwise invisible until users filed bug reports. const daemonStartFailedSchema = z.object({ error_class: errorClassSchema }).strict() +// Rollout signal for granting Codex hook trust via codex app-server RPCs +// instead of Orca's self-computed trusted_hash. `fallback`/`verify_failed` +// spikes mean the RPC lane is not taking; steady-state ledger skips are not +// reported (they would only measure launch volume). +const codexTrustGrantSchema = z + .object({ + outcome: z.enum(['granted', 'fallback', 'verify_failed']), + host_kind: z.enum(['native', 'wsl']), + fallback_reason: z + .enum([ + 'disabled', + 'no-managed-entries', + 'unsupported', + 'unsupported-cached', + 'verify-failed', + 'error' + ]) + .optional() + }) + .strict() + const settingsChangedSchema = z .object({ setting_key: settingsChangedKeySchema, @@ -1428,6 +1449,8 @@ export const eventSchemas = { daemon_start_failed: daemonStartFailedSchema, + codex_trust_grant: codexTrustGrantSchema, + settings_changed: settingsChangedSchema, native_chat_toggled: nativeChatToggledSchema,