diff --git a/CHANGELOG.md b/CHANGELOG.md index 6dab17b..6e5d94d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,20 @@ new version heading in the same commit. ## [Unreleased] +## [0.283.1] — 2026-07-31 +### Fixed +- **Every allowed Codex tool call logged `PreToolUse hook (failed)`.** Codex's parser acts on `deny` but + **rejects `allow`** (`unsupported permissionDecision:allow`) and then runs the tool anyway. Governance + was never at risk — deny genuinely blocks, verified live (`PreToolUse hook (blocked)`, command never + ran) — but the pane showed a hook FAILURE on every allowed call, which reads as "the gate is broken" + to anyone watching. The audit trail looked identical either way, which is why it survived several + releases: it is only visible in the pane. The gate now expresses an allow on Codex as **silence + + exit 0**, the same way it already expresses "not my business" for `Read`/`Glob`/`Grep`; Claude Code + still gets the explicit `allow` it needs (there it is what bypasses Claude's own permission engine). + The `instruct` verb survives — Codex makes `permissionDecision` optional, so an allow-with-note is sent + as `additionalContext` alone; verified end to end (`hook (completed)`, note reached the model, model + acted on it). + ## [0.281.5] — 2026-07-31 ### Fixed - **The approval-friction signal no longer divides by a denominator that isn't there.** v0.280.0 started diff --git a/docs/codex-runtime.md b/docs/codex-runtime.md index f0bb990..8183550 100644 --- a/docs/codex-runtime.md +++ b/docs/codex-runtime.md @@ -53,6 +53,28 @@ workdir is exactly the escape worth catching. `network_access` stays **true** — shell egress is already gated, and cutting it would break `git push` / `npm install` for every agent. +### The decision wire differs: Codex acts on `deny`, rejects `allow` + +Both CLIs take the same PreToolUse stdin, but their **output** contracts diverge, and the difference is +only visible in the pane — the audit trail looks identical either way, which is how it went unnoticed +through several releases: + +| Emitted | Claude Code | Codex | +| --- | --- | --- | +| `permissionDecision: "deny"` | blocks | **blocks** — `PreToolUse hook (blocked)`, command never runs | +| `permissionDecision: "allow"` | authoritative allow (bypasses Claude's own permission engine) | **rejected** — `hook (failed) … unsupported permissionDecision:allow`, then runs the tool anyway | +| silence + exit 0 | defer to Claude's permission flow | proceed | +| `additionalContext` with **no** decision | n/a | **works** — `hook (completed)`, note reaches the model | + +So on Codex the gate expresses an allow as **silence**, exactly the way Claude's hook already expresses +"not my business" for `Read`/`Glob`/`Grep`. Governance was never at risk — deny is honoured, verified +live — but emitting `allow` painted a hook FAILURE into the pane on every allowed call, which reads as +"the gate is broken" to anyone watching a session. + +The `instruct` verb survives: Codex's output wire makes `permissionDecision` optional, so an +allow-with-note is sent as `additionalContext` alone. Verified end to end — the note appears as +`hook context:` and the model acts on it. + ### One shared hook `terminal/gate-hook.sh` serves both runtimes. Codex 0.145 uses the same PreToolUse stdin fields diff --git a/package-lock.json b/package-lock.json index 44fe036..fd96d62 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "agent-os", - "version": "0.281.5", + "version": "0.283.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "agent-os", - "version": "0.281.5", + "version": "0.283.1", "license": "MIT", "bin": { "agent-os": "bin/agent-os" diff --git a/package.json b/package.json index c6c5f0a..70f7ae9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "agent-os", - "version": "0.281.5", + "version": "0.283.1", "description": "A generic, governed operating system for running autonomous agents safely across brands. Ships with a local web console.", "license": "MIT", "type": "commonjs", diff --git a/terminal/gate-hook.sh b/terminal/gate-hook.sh index ddd73a2..f3aac85 100755 --- a/terminal/gate-hook.sh +++ b/terminal/gate-hook.sh @@ -34,8 +34,22 @@ set -u EVENT=$(cat) -# Emit an authoritative PreToolUse decision and exit 0. $1 = allow|deny, $2 = reason (shown to Claude). +# Emit an authoritative PreToolUse decision and exit 0. $1 = allow|deny, $2 = reason (shown to the model). +# +# RUNTIME DIFFERENCE, found by reading a live Codex pane: Codex's parser acts on `deny` but REJECTS +# `allow` outright — +# PreToolUse hook (failed) +# error: PreToolUse hook returned unsupported permissionDecision:allow +# — and then runs the tool anyway. Governance is intact either way (deny genuinely blocks; verified live: +# "PreToolUse hook (blocked)" and the command never ran), but emitting `allow` painted a hook FAILURE +# into the pane on every single allowed call, which reads as "the gate is broken" to anyone watching. +# So on Codex an allow is expressed the way Claude's hook already expresses "not my business": silence +# plus exit 0. Claude Code still gets the explicit allow, which it needs — there it is what BYPASSES +# Claude's own permission engine, so dropping it would hand the decision back to a second brain. emit() { + if [ "$1" = "allow" ] && [ "${AOS_RUNTIME:-claude-code}" = "codex" ]; then + exit 0 + fi node -e 'const[d,r]=process.argv.slice(1);console.log(JSON.stringify({hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:d,permissionDecisionReason:r}}))' "$1" "$2" exit 0 } @@ -45,6 +59,12 @@ emit() { # (docs/decision-brief-layer-plan.md §8a); permissionDecisionReason on allow is audit-only. $1 = reason, # $2 = note. Used for a reliability nudge (e.g. a detected loop) — soft; the model may ignore it. emit_allow_note() { + # On Codex the same `allow` rejection applies, but `permissionDecision` is optional in its output wire + # (it defaults to null) — so the note is carried on its own, with no decision, and the tool proceeds. + if [ "${AOS_RUNTIME:-claude-code}" = "codex" ]; then + node -e 'const[c]=process.argv.slice(1);console.log(JSON.stringify({hookSpecificOutput:{hookEventName:"PreToolUse",additionalContext:c}}))' "$2" + exit 0 + fi node -e 'const[r,c]=process.argv.slice(1);console.log(JSON.stringify({hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"allow",permissionDecisionReason:r,additionalContext:c}}))' "$1" "$2" exit 0 }