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.
This commit is contained in:
Brennan Benson
2026-07-14 22:41:22 -07:00
parent 0c2a807c20
commit eb5a76c31a
4 changed files with 140 additions and 17 deletions
@@ -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
}
}
+98 -16
View File
@@ -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<string>,
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<CodexEventLabel>(
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<CodexEventLabel>(
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 {
+11
View File
@@ -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
+23
View File
@@ -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,