Skip to content

feat: Codex hook adapter, real-world corpus golden, tuned profiles, redirections, and rule suggestions - #81

Merged
safe-agentic-world merged 8 commits into
mainfrom
claude/clever-ride-i2smjt
Sep 26, 2026
Merged

safe-agentic-world merged 8 commits into
mainfrom
claude/clever-ride-i2smjt

Conversation

@safe-agentic-world

@safe-agentic-world safe-agentic-world commented Sep 26, 2026 •

Copy link
Copy Markdown
Owner

Problem And Change

The incident reports are cross-vendor and a Claude Code-only hook caps who Nomos can protect (#75); the hook also asked too often on ordinary work (#73, #74) and had no measured evidence of either. This PR adds the Codex adapter, closes the findings of an adversarial review of it against the Codex source (and of the re-verification that followed), and ships the measurement and tuning work the review depended on.

Codex adapter (nomos hook codex)

Reuses the shell parser, profiles, decision pipeline, and hash-chained audit of the Claude Code hook. Bash maps to process.exec per simple command, apply_patch to one fs.write per file header, mcp__server__tool to mcp.call with --mcp; other tools pass through. Contract verified from openai/codex at e72da2b5 (codex-rs/hooks, core/src/hook_runtime.rs, core/src/exec_policy.rs, apply-patch) and then reviewed adversarially against the same source. Findings and their fixes:

  • Ask semantics (F4, F9). Codex's PreToolUse cannot ask, and in its usual on-request mode an unblocked command runs sandboxed without a prompt. An ask is therefore a deny in every mode by default, with a reason the model can act on; --ask passthrough restores silence.
  • Approval prompts (F3). A Nomos allow never answers a PermissionRequest. The re-verification showed that a retry outside the sandbox after a denial is payload-identical to a plain prompt, so the opt-in that answered plain prompts could have approved an unsandboxed retry of an allowed command; the flag was removed. A deny is still answered in every mode, including with approvals disabled, where Codex's strict automated reviewer may be asking.
  • Patch parsing (F1). Headers are matched the way Codex's streaming parser matches them: whitespace trimmed outside update hunks, trailing whitespace only inside them; unknown *** markers, a Move to outside an update hunk, and a patch without headers are errors, never allows.
  • Whole-patch decisions (F2). Under --outside-workspace passthrough a patch that touches a workspace file and an escaping file is left to Codex entirely; the allowed part never decides the call alone and a decided part still wins.
  • Workspace root (F5). CLAUDE_PROJECT_DIR is never consulted for Codex (it replays the environment it was started from into its hooks).
  • Audit (F6, F7). turn_id is read; PermissionRequest records, which carry no tool_use_id, get distinct action ids from the turn id plus a hash of the request; every record carries wire_decision (what Codex received), ask_mode, outside_workspace, and turn_id.
  • Install (F10, F11, F13). Generated command arguments are shell-quoted (Codex runs hooks through $SHELL -lc; the Claude Code installer gets the same), a hooks.json Codex would drop (unknown top-level key or event) is refused instead of extended, the --mcp matcher is anchored (^(Bash|apply_patch|Write|Edit|mcp__.*)$), --simulate validates --permission-mode, --event, and --command/--input exclusivity, non-decision lifecycle events are ignored rather than audited, the help example is a patch Codex accepts, and the root help names both hooks.
  • Interpreters (F8, F15). Codex can write to the stdin of a running process without a hook event, so the default profiles now ask (safe-dev) or deny (ci-strict, prod-locked) before inline interpreter code (python -c, node -e, ruby -e, perl -e, php -r, deno eval), the common option clusters (-uc, -pe, for perl anywhere after the command), two-token options before the code flag, the REPL modules (python -m pdb), bare interpreters and shells, awk programs that call system or getline or pipe, and tar/zip options that run a command. ci-strict no longer allows sed, whose scripts can read, write, and run commands. Glued forms, rarer clusters, and program text are documented as a limit; workdir not being in the hook input is documented as a Codex limit.

docs/codex-hook.md states the modes, why an allow never answers an approval prompt, the audit fields, the limits, and that a live end-to-end run against a Codex binary is still pending.

Measurement and tuning

  • testdata/realworld/: 1,526 build and test commands from ten permissively licensed repositories (sources pinned in SOURCES.md) with a decision golden per profile; TestRealWorldCorpusDecisionsMatchGolden fails when a profile change denies a benign command or frees a dangerous one (-update-corpus regenerates it).
  • Default profiles: precise catastrophic-delete rules (home, root, drive roots, parent directory, literal globs) instead of every absolute path; safe-dev asks before any workspace rm and before writing a secrets file and allows the everyday git workflow, the project toolchain, and workspace file operations; ci-strict gains conservative file operations and denies secret-file writes. Backslash escapes in argv patterns ('~/\*').
  • Shell parser: file redirections map to fs.write/fs.read actions the policy decides; simple parameter expansions are accepted in the arguments of print-only commands; shell keywords are refused rather than treated as commands.
  • nomos hook claude-code --replay and --replay-transcripts, a PostToolUse completion record, and --suggest, which proposes allow rules from calls that were asked about and then approved, never writing anything itself.
  • README: leads with the hook, the sixty-second install, the outcomes of the real Claude Code sessions, and the measured corpus numbers; the roadmap records the milestone status.

Verification

  • go test ./... and go test -race ./...: all packages ok. go vet ./..., gofmt -l .: clean. python3 scripts/check_docs.py: 53 files, 0 broken links. Profile hashes re-pinned.
  • Corpus golden on this head (of 1,526 commands): safe-dev allow 669, ask 848, deny 2, passthrough 7; ci-strict allow 96, ask 1376, deny 47; prod-locked allow 14, ask 1404, deny 101. The interpreter rules moved 6 safe-dev allows to asks and added 9 denies to each strict profile (curl ... | sh, deno eval, python -c, and two perl -pe in-place edits among them); no benign safe-dev allow was lost. Every incident case in the suites is still denied or reviewed.
  • New tests cover indented and in-hunk patch headers, unknown markers, whole-patch passthrough, silence on every approval prompt that is not a deny, CLAUDE_PROJECT_DIR ignored, distinct action ids, wire-decision metadata, quoting, matcher anchoring, hooks.json validation, and the interpreter rules per profile (clusters, two-token options, debugger sessions, awk, tar, and perl forms, sed no longer allowed in ci-strict).
  • Quickstart smoke commands from CLAUDE.md pass on the built binary.
  • Re-verification by the review agent against the first fix round: every original repro re-run plus about 100 new cases (patch-parser edges against the Codex parser as oracle, escalation probes, quoting round trips through sh -lc, matcher anchoring, 70 interpreter probes); all emitted documents validate against Codex's schemas. Its two required changes (drop or document the approval-prompt allow; widen the interpreter rules for two-token options and -m forms, and treat awk/sed like interpreters in the strict profiles) are in the follow-up commits. Summary posted as a comment below.

Compatibility And Docs

  • nomos hook claude-code behavior is unchanged except for quoted installer arguments and the profile changes above; existing audit records keep their fields.
  • Changelog, README, the roadmap, the Claude Code hook guide (interpreter limit), and the Codex guide are updated.

🤖 Generated with Claude Code

https://claude.ai/code/session_01HnquYGTpTSAvnvp7UJTVpv

`nomos hook codex` decides Codex's `Bash` commands and `apply_patch`
files through Codex's `PreToolUse` and `PermissionRequest` lifecycle
hooks, reusing the shell parser, profiles, decision pipeline, and
hash-chained audit of the Claude Code hook.

The contract was verified from the Codex source at commit e72da2b5:
PreToolUse input carries the Claude-shaped fields, shell commands arrive
as tool Bash, patches as tool apply_patch with the patch text in
tool_input.command, MCP tools as mcp__server__tool; a PreToolUse hook can
only block (permissionDecision deny, or exit 2 with a reason), while ask
and a bare allow are rejected; PermissionRequest answers behavior
allow|deny or stays silent. Nomos therefore prints a deny where Codex can
act on it, an allow as a PermissionRequest decision that skips the
prompt, and leaves an ask to Codex's own prompt, except when approvals
are disabled, where nothing can ask and it becomes a deny unless
--ask-in-bypass passthrough is set.

`--install` writes .codex/hooks.json with only the keys Codex accepts;
the settings merge is shared with the Claude Code installer. Audit events
record the harness event name. Tests cover patch-path extraction, the
wire shapes, bypass handling, install merging, and the CLI end to end.
The guide states that a live run against a Codex binary is still pending.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HnquYGTpTSAvnvp7UJTVpv
testdata/realworld/commands.jsonl holds 1,526 single-line build, test,
and release commands extracted from ten permissively licensed
open-source repositories (CI workflow steps, Makefile recipes,
package.json scripts), pinned to commits and attributed in SOURCES.md.
scripts/build_command_corpus.py rebuilds it byte for byte;
scripts/replay_corpus.sh replays it through the built binary and writes
a report.

TestRealWorldCorpusDecisionsMatchGolden replays the corpus in process
against every embedded profile and compares the allow, ask, and deny
counts, the ask classes, and the full list of denied commands with
testdata/realworld/expected.json, so a profile change that denies a
benign command or frees a dangerous one fails review, and every tuning
change updates the measured numbers explicitly (-update-corpus).

The first measurement: safe-dev allows 157 of 1,526 commands and asks on
1,349, mostly from variable expansions the parser refuses and from
tooling without an allow rule. Ten of its thirteen denies come from the
catastrophic-delete pattern matching every absolute path.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HnquYGTpTSAvnvp7UJTVpv
The end-to-end runs and the real-world corpus measured the noise of the
shipped profiles: safe-dev allowed 157 of 1,526 real build and test
commands, and ten of its thirteen denies were benign deletes of absolute
paths, because the catastrophic-delete pattern `/*` matched every
absolute path and `~/*` every path under the home directory.

Argv patterns gain backslash escapes so a literal glob can be pinned:
the catastrophic-delete rules now deny the home directory, the
filesystem root, drive roots, and the parent directory as a whole or as
a literal glob (`~/\*`, `/\*`, `../\*`), while any other delete outside
the workspace is left to the hook's boundary check, which asks.

safe-dev now asks before any `rm` inside the workspace and before
writing a secrets file, treats `git commit --amend`, `--force*` pushes,
`git restore`, and `git checkout .` as history rewrites that need
confirmation, and allows the everyday git workflow, the project
toolchain (Go, Rust, Node, Python, Make, Gradle, and friends),
workspace file operations, and more read-only git and inspection
commands (`git remote -v`, `printenv`, `du`, ...). ci-strict and
prod-locked gain the same read-only additions; ci-strict also gains
conservative file operations and denies secret-file writes. A bare
`env` is a read-only command, not a wrapper.

Measured on the corpus: safe-dev allows 578, asks 939, denies 2
(`cat /sysroot/.env` and a URL ending in `.key`); every incident case
still denies or requires confirmation, and the two deletes the incident
suites previously expected to deny by pattern now require confirmation.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HnquYGTpTSAvnvp7UJTVpv
…mmands

The parser refused every `> file`, `>> file`, `2> file`, `&> file`, and
`< file`, and every `$VAR`, which on the real-world corpus turned 626 of
1,526 commands into prompts. Neither refusal was buying safety a policy
could not express.

A file redirection is now an `fs.write` or `fs.read` on the named file,
attached to the simple command and decided by the policy: `go test
./... > out.txt` allows under safe-dev, `cat main.go > /tmp/copy` asks
because the target is outside the workspace, and `echo KEY=1 >
config/.env` asks because writing a secrets file requires confirmation.
Descriptor duplication, `/dev/null`, and plain here-strings stay
transparent; a redirection target with a quote or an expansion, a
heredoc, and process substitution are still refused.

A simple parameter expansion (`$NAME`, `${NAME}`, `$?`) is kept as
literal text and accepted only in the arguments of a print-only command
(`echo`, `printf`, `printenv`, `test`, `true`), which cannot run, open,
or write anything. It is refused in every other position: `rm -rf $DIR`,
a shell wrapper (`bash -c "echo $X"`, where the expanded text would be
parsed as commands), a redirection target, and the command name.

Measured on the corpus: safe-dev allows 675 (from 578), asks 842, denies
2. prod-locked now denies the redirections that write into the workspace
by its own rule instead of asking about unknown syntax.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HnquYGTpTSAvnvp7UJTVpv
…eter code

Codex's PreToolUse cannot ask and its on-request mode runs an unblocked
command sandboxed without a prompt, so an ask is now a deny in every mode
by default (--ask passthrough keeps the old silence). PermissionRequest
never answers allow unless --permission-request-allow is set, and even
then not for sandbox or network escalations and not with approvals
disabled, where Codex's own reviewer may be asking.

Patch headers are matched the way Codex's parser matches them (trimmed
outside update hunks, unknown markers and a stray Move to are errors), a
patch is decided as a whole under --outside-workspace passthrough, the
workspace root ignores CLAUDE_PROJECT_DIR, PermissionRequest records get
distinct action ids from turn_id, the audit carries the wire decision and
adapter settings, --install refuses a hooks.json Codex would drop, quotes
generated arguments, and anchors the --mcp matcher, and --simulate
validates its mode, event, and input flags.

The default profiles now ask (safe-dev) or deny (ci-strict, prod-locked)
before inline interpreter code and bare interpreters or shells, because
the policy cannot see what they run and Codex can feed a running process
without a hook event. Golden and profile hashes re-pinned; corpus effect:
safe-dev allows 675 to 669, ci-strict denies 38 to 45, prod-locked 92 to
99.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HnquYGTpTSAvnvp7UJTVpv
@safe-agentic-world safe-agentic-world changed the title feat: add the Codex hook adapter feat: Codex hook adapter, real-world corpus golden, tuned profiles, redirections, and rule suggestions Sep 26, 2026
…terpreter rules

The re-verification of the Codex adapter found that a retry outside the
sandbox after a denial is payload-identical to a plain PermissionRequest,
so the --permission-request-allow opt-in could approve an unsandboxed
retry of an allowed command. The flag is removed: a Nomos allow never
answers a PermissionRequest, and a deny still does in every mode.

The interpreter rules now also cover the common option clusters (-uc,
-pe), two-token options before the code flag (node -r x -e, php -d k=v
-r), the REPL modules (python -m code|pdb|timeit|asyncio), deno run -,
awk programs that call system or getline or pipe, and tar or zip options
that run a command. ci-strict no longer allows sed, whose scripts can
read, write, and run commands the policy cannot see. Profile hashes and
the corpus golden are re-pinned (no corpus decision changed).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HnquYGTpTSAvnvp7UJTVpv

Copy link
Copy Markdown
Owner Author

Adversarial re-verification of the Codex adapter (first fix round, head dae66de)

Method: binary rebuilt from the clean tree; every case from the original repro index re-run plus about 100 new ones (PatchPaths state-machine edges checked against the Codex parser as the oracle, approval-prompt escalation probes, quoting round trips through sh -lc, matcher anchoring checked with RE2, 70 interpreter-rule probes). go vet and the tests pass; every emitted document validates against Codex's hook schemas.

Per finding: F1, F2, F4, F5, F6, F7, F9, F10, F11, F12, F13 fixed; F14 unchanged by design. Two were partial:

  • F3. The --permission-request-allow opt-in blocked allows for requests carrying a description, extra keys, non-object input, and bypass mode, but a sandbox-denial retry or a require_escalated request without a model justification is payload-identical to a plain prompt (approvals.rs:169-173, handlers/mod.rs:95-106, orchestrator.rs:407-452), so the opt-in could still approve an unsandboxed retry of an allowed command.
  • F8/F15. The interpreter rules caught python -c, bare interpreters and shells, versioned and absolute spellings, node -e/-p/--eval, bun -e, deno eval, and the ruby/perl/php forms, but not python3 -uc, ruby -ne/-pe, node -r x -e, php -d k=v -r, python3 -m code|pdb|timeit, deno run -, or awk 'BEGIN{system()}' and GNU sed ... e through the file-operation allow rules.

Bottom line from the review: safe to ship with the defaults after two changes. Both are in the second commit on this PR:

  1. --permission-request-allow is removed. A Nomos allow never answers a PermissionRequest; a deny still does in every mode.
  2. The interpreter rules cover the common clusters (-?c, -??c, -?e), two-token options before the code flag, the REPL modules, deno run -, awk programs that call system or getline or pipe, and tar/zip options that run a command; ci-strict no longer allows sed. Glued forms, rarer clusters, and program text stay documented as a limit in both hook guides.

Low notes recorded, not changed: the same command repeated in one turn shares an audit action id (Codex's own retry case; told apart by timestamp and chain position, now documented), and the removed pre-release --ask-in-bypass flag has no alias (an old hooks.json fails closed with exit 2).


Generated by Claude Code

The README now opens with what the hook does, a sixty-second install, the
outcomes of the real Claude Code sessions, and the measured corpus numbers
with their explanation, and moves the SDK, LangGraph, MCP, and gateway
material under one section. The CI example pins v0.15.0 with its published
checksum. The roadmap marks milestones 1 to 4 delivered and names what is
next.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HnquYGTpTSAvnvp7UJTVpv
perl -0 -i.bak -pe '...' carried the code flag in a cluster after other
options, which the perl patterns missed while the ruby ones caught it.
The perl patterns now match -e, -E, and clusters ending in e or E
anywhere after the command name. Hashes and the corpus golden re-pinned.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HnquYGTpTSAvnvp7UJTVpv
@safe-agentic-world
safe-agentic-world merged commit f477b11 into main Sep 26, 2026
40 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants