Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 22 additions & 0 deletions docs/codex-runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
22 changes: 21 additions & 1 deletion terminal/gate-hook.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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
}
Expand Down
Loading