Skip to content

v0.41.0

Latest

Choose a tag to compare

@github-actions github-actions released this 16 Jul 02:10
8de6841

Security

  • The understanding-gate approval marker (and its branch-protection twin) is now HMAC-signed, closing the "existence is enough" filesystem-marker forgery hole — the persisted-report approval path is a separate, still-unsigned residual, see below (task f9485cc7, M10 / Feature idea #7, promised in the [0.32.0] entry's "cryptographic marker signing... is a tracked follow-up" note). The marker's integrity used to rest entirely on an UNENFORCED invariant — "no configured MCP exposes a filesystem-write primitive" — checked nowhere; one future MCP tool with local file-write would silently reopen it, since its tool name wouldn't match the Edit|Write|Bash blocker matcher, and the marker's contract was "existence is enough" (a bare filesystem-write forged it). harness approve understanding now writes an HMAC-SHA256 signature over (markerId, approvedAt, approvedBy, reportContentHash)reportContentHash is the sha256 of the persisted Understanding Report bound to this approval; this is groundwork only, NOT yet enforced at gate-check time (the live cross-check against the currently-selected report is the C1 staleness follow-up, task fa423e9b) — using an operator-side key lazily generated (crypto.randomBytes(32), mode 0600) at <generatedDir>/.approval-signing.key. checkApprovalMarker (shared by the session marker, task-scoped markers, and checkBranchProtectionMarker) now REJECTS a marker with a missing or invalid signature — matched:false, same as no marker, but with a distinct forged/unsigned marker rejected diagnostic surfaced through both the Claude and Codex PreToolUse hooks and the branch-protection hook, so an operator/auditor can tell an active forgery attempt (or a pre-signing legacy marker) apart from the routine "never approved" case. Binding markerId into the signature also means the "copy a marker onto a new session id" admin trick this doc used to describe no longer works — a copied marker fails verification. Back-compat is strict, no migration window: a marker written before this shipped has no signature field and is rejected exactly like a forgery; re-run harness approve understanding (and/or harness approve branch-protection) once after upgrading a machine with a live approval.
    • What is NOT closed by this task (read before assuming more than it delivers). (1) The persisted-report approval path (checkPersistedReport, consulted by both PreToolUse hooks with EQUAL authority immediately after a forged marker is correctly rejected) is not signed at all and, under this task's own threat model (a future write primitive the Edit|Write|Bash matcher doesn't cover), is an equal-or-easier forgery target than the marker used to be: no signing key to read, no sessionId match required (the gate-read path's tolerant fallback is "any", unrestricted by age), just a JSON file with approvalStatus: "approved" dropped into the reports dir. Tracked as follow-up task 7402301d-14bb-4b03-8d88-a2bb8bad8084. (2) The solution-acceptance pack's verdict marker remains unsigned (tracked separately, [0.32.0] note). (3) An agent with BOTH arbitrary local file-read (true today, by design — the blocker never gates Read) AND a write primitive the Edit|Write|Bash matcher doesn't cover can still forge a validly-signed marker: read the key, compute the HMAC, write the result through the uncovered path — signing does not close that combined case, only the narrower, previously totally-undefended write-only-forgery case, and it makes any attempt materially more expensive and forensically visible. Given the agent and operator share one OS user account, this is deliberate defense-in-depth, not a hard authorization boundary; full write-up in src/runtime/approval-signing.ts and docs/policy-packs/understanding-before-execution.md "Marker signing". (4) An interim MCP-catalog write-capability warning at init/smoke time was considered but did not fall out naturally from this change and is left as a candidate follow-up.
    • New tests: tests/runtime/approval-signing.test.ts (key generation/persistence/corruption-recovery/rotation, sign/verify round-trip, tamper + wrong-markerId + missing-field rejections), forgery + tamper-mutation regressions in tests/policy-packs/runtime.test.ts, tests/io/read-regular-file-unreadable.test.ts, tests/cli/pack-hook-pre-tool-use.test.ts, tests/cli/pack-hook-codex-pre-tool-use.test.ts, and tests/cli/pack-hook-branch-protection.test.ts.

Added

  • Warn-only OKF staleness watch on every PR (.github/workflows/okf-staleness.yml, PR #350): runs okf-kit check (exact pin 0.3.1) against docs/okf, surfaces STALE/structural findings as per-doc annotations plus a full job summary, never blocks a merge; tool errors fail red so a broken check cannot look green. Canonical pattern for the five OKF bundle repos; never mark it required in branch protection.
  • harness pack reseed <name> pulls a pack's shipped config.ux (and config.producers) into an already-installed manifest (task 68b9ad9c). Before this, a deny-message wording fix in the init templates (e.g. the heredoc submission form, agent-tasks/e48e3b45) only reached manifests generated by a fresh harness init AFTER the fix shipped: harness apply only ever projects the manifest OUT to settings.json, nothing propagated a template fix back INTO an operator's existing policy_packs[].config.ux, so an already-installed manifest kept teaching stale wording indefinitely, even after the CLI itself was upgraded. harness doctor now also warns when an enabled pack's config.ux / config.producers textually diverges from the CLI's shipped default for that pack — compared against the pack's OWN configured mode (understanding-before-execution's required: line varies by mode), not a hardcoded one, so switching modes is never mistaken for drift. Both the warning and the reseed write share one canonical source per pack (defaultUx() / defaultProducers() in src/policy-packs/builtin/understanding-before-execution.ts and src/policy-packs/builtin/branch-protection.ts, wired through the new resolveBuiltinDefaultConfig in src/policy-packs/registry.ts), so the check and the fix can never independently drift on what "the shipped template" means; the init Custom composer (src/cli/init/composer.ts) now reads from the same source instead of carrying its own copy of the literal text. reseed is deliberately explicit-only — never invoked by apply, doctor, or any automatic path — and only ever touches config.ux / config.producers, leaving every other key on the pack entry (mode, approval_lifecycle, permission_profile, min_version, ...) untouched, so an operator's own deliberate ux customisation is never silently clobbered by an upgrade (the same reasoning harness adopt exists for in the opposite direction). --dry-run prints the diff without writing; a pack whose config.ux already matches the shipped default is a no-op. solution-acceptance has no registered shipped default (ships enabled: false with no config: block in any template) and is reported clearly as such rather than silently reseeded with nothing. A new parity test, tests/cli/init-templates-ux-parity.test.ts, pins FULL_TEMPLATE / SOLO_TEMPLATE / TEAM_TEMPLATE (src/cli/init/templates.ts, src/cli/init/profiles.ts) and the Custom composer's config.ux / config.producers against defaultUx() / defaultProducers(), so a future wording fix landed in a template WITHOUT updating those functions (which would make reseed silently pull operators back to the stale wording — the same bug class one layer removed) fails the build instead of shipping unnoticed; matching "KEEP IN SYNC" comments were added at each template's ux: / producers: block. New tests: tests/policy-packs/ux-compare.test.ts, tests/policy-packs/ux-drift-check.test.ts, tests/policy-packs/builtin-default-config.test.ts, tests/cli/pack-reseed.test.ts, tests/cli/init-templates-ux-parity.test.ts, plus new doctor coverage in tests/cli/doctor.test.ts. Docs: docs/policy-packs/understanding-before-execution.md ("Refreshing config.ux after a harness upgrade"), docs/policy-packs/branch-protection.md, docs/CLI.md. check:duplication's MAX_CLONES pin raised 82→86 with a recorded justification (scripts/check-duplication.mjs): the new cli/pack/reseed.ts necessarily clones the validate/lock/diff/write shape already repeated undeduped across add/remove's CLI and pack add/pack remove.
  • Codex sessions now get the active-claim tracker and the stay-in-scope reminder too (task cf4cdc93, closes docs/okf/codex-adapter-parity-gaps.md gap #3, the last hook-parity gap between the two runtimes). Before this, harness pack hook track-active-claim (writes/clears harness.generated/active-claim on task_start/task_finish/task_abandon so harness approve understanding can auto-resolve --task) and harness pack hook stay-in-scope (soft reminder + audit row on a review-derived follow-up task) were Claude-only: a Codex session could honor an existing task-scoped marker but could never produce the active-claim file itself, and got no stay-in-scope reminder at all.
    • No new Codex-specific CLI verb needed, unlike the other Codex hooks. Both hook bodies (src/cli/pack/hook-track-active-claim.ts, src/cli/pack/hook-stay-in-scope.ts) needed no session-id resolution and no shell-command extraction the way codex-post-tool-use/codex-pre-tool-use need. harness apply --runtime codex now emits two more [[hooks.PostToolUse]] groups running the SAME commands (harness pack hook track-active-claim / harness pack hook stay-in-scope) as the Claude branch — the Codex adapter now contributes 6 hooks total, matching the Claude branch's hook roster.
    • Same two-layer alias fix task a1348c89 established, applied to these two hooks. The existing Claude matchers (TRACK_ACTIVE_CLAIM_MATCH, STAY_IN_SCOPE_MATCH) are anchored ^(?:...)$ regexes that expandCodexHookMatchPattern's "simple token" guard passes through UNCHANGED at TOML-emit time, so a Codex session sending an MCP tool-name variant (server hyphen/underscore swap, the mcp__server__.tool dotted form) would never reach the hook at all. Fixed with new Codex-specific bare |-joined sibling constants (TRACK_ACTIVE_CLAIM_MATCH_CODEX, STAY_IN_SCOPE_MATCH_CODEX, same shape as codexPostToolUseMatchPattern's output). Both hook bodies' tool-name comparisons were also switched from raw ===/.includes to the shared alias-aware toolNameMatchesAny (understanding-before-execution-runtime.ts) so a variant tool_name the widened matcher now routes to the hook is also recognized once inside it — closing the same class of dispatcher-vs-body gap task a1348c89 fixed once already for the marker-expiry hook.
    • Review finding, same task, empirically confirmed (MEDIUM): the two hooks read ONLY tool_name/tool_input, silently no-op-ing on the field-name synonyms the Codex envelope also tolerates. hook-codex-post-tool-use.ts already accepts tool as a tool_name synonym and raw_input as a tool_input synonym (pickString(event.tool_name, event.tool), resolveToolInput); track-active-claim and stay-in-scope did not, so a probe of { tool_name, raw_input: { taskId } } or { tool, tool_input: { taskId } } silently no-op'd in the two new hooks while the sibling hook worked. Fixed by extracting pickString (already shared) and a new resolveToolInput into src/cli/pack/hook-bootstrap.ts and consuming both from the two new hooks, rather than a third/fourth hand-copy. stay-in-scope's tool_response taskId fallback (a Claude-side convention the Codex envelope may not carry) was deliberately left reading the field as-is, not folded into the tool_input/raw_input resolution. New tests cover the raw_input-only shape, the tool-only shape, tool_input-over-raw_input precedence, the still-working tool_response fallback, and a negative control (neither name field present still skips).
    • New tests: dispatcher-layer pins in tests/policy-packs/expand.test.ts (hook count/roster, matcher values, alias-expansion, always-on-regardless-of-approval_lifecycle-config parity with the Claude opt-out) and tests/cli/apply/generate-codex-config.test.ts (real expandPolicyPacks + generateCodexConfig output, positive + negative control on the actual emitted TOML matchers — the must-pass control this task's brief asked for); hook-body alias-variant AND wire-format-synonym tests in tests/cli/pack-hook-track-active-claim.test.ts and tests/cli/pack-hook-stay-in-scope.test.ts (canonical + dotted + underscore-server tool-name forms, a tasks_transition status-filter-under-aliasing negative control mirroring the a1348c89 regression guard, plus the tool/raw_input synonym coverage above). Docs updated: docs/policy-packs/understanding-before-execution.md, docs/CLI.md, docs/okf/codex-adapter-parity-gaps.md.
  • Codex sessions now expire the understanding-gate approval marker on task-completion boundaries (task a1348c89, follow-up to the [0.39.0] task e7c2ec3c TTL/task-marker parity fix, whose entry named this exact residual). Before this, a Codex approval marker survived every task_finish / task_abandon / pull_requests_merge and only died via approval_lifecycle.max_age or a manual rm.
    • harness apply --runtime codex now also emits a [[hooks.PostToolUse]] group running the new harness pack hook codex-post-tool-use, sharing its match/clear/diagnostic logic with the existing Claude post-tool-use hook through three functions extracted into understanding-before-execution-runtime.ts (matchPostToolUseBoundary, applyPostToolUseExpiry, describePostToolUseExpiry) — the same drift-avoidance pattern task e7c2ec3c used on the PreToolUse side. Advisory only (never blocks; every error path is exit 0 + stderr). The Codex [[hooks.PostToolUse]] TOML schema support already existed in the generator (PR #211, real Codex 0.131.0 schema); this task contributes the pack-side hook and CLI verb that consume it.
    • Also fixes (review finding, same task): MCP tool-name variants a runtime may emit for the identical tool (server hyphen/underscore swap, the mcp__server__.tool dotted form) are now handled at BOTH layers this feature needs. (1) Dispatcher layer: the Codex PostToolUse match field is now built by a new codexPostToolUseMatchPattern (bare |-joined list) instead of the Claude postToolUseMatchPattern helper (anchored ^(?:...)$ regex) — the anchor characters silently defeated generate-codex-config.ts's expandCodexHookMatchPattern alias expansion, so Codex's own dispatcher would never have invoked the hook at all for a variant tool_name. (2) Hook-body layer: toolNameMatchesAny (understanding-before-execution-runtime.ts) now expands the incoming tool_name through the same expandToolNameAliases normalization harness policy intercept's policyMatchesEvent already applies, so the body's expire_on_tool_match comparison recognizes a variant too. The legacy v1 tasks_transition status filter is alias-aware now as well, closing a status-filter-bypass the body-only half of this fix would otherwise have introduced (a variant tasks_transition call would have matched generally but skipped the status === "done" check entirely). Verified both fixes independently via mutation testing (each reverts to the pre-fix behavior red before the fix, green after).
    • Known limitation at the time of task a1348c89, shared with Claude Code, not Codex-specific (since closed in this same Unreleased batch by task bea04a03, see Fixed below): approval_lifecycle.expire_on_bash_match was not yet routed to the PostToolUse hook on either runtime — the hook's trigger matched only expire_on_tool_match verbs, so a real Bash/shell/exec_command call did not invoke the hook on Codex OR Claude. Was tracked as a follow-up (agent-tasks bea04a03); see docs/okf/codex-adapter-parity-gaps.md gap 12.
    • 29 new tests in tests/cli/pack-hook-codex-post-tool-use.test.ts (Claude-suite parity coverage, Codex-specific synonyms/aliases, MCP tool-name-variant matching, a tasks_transition-under-aliasing regression guard, an integration must-pass control proving the expiry re-locks the PreToolUse blocker), plus new dispatcher-layer pins in tests/policy-packs/expand.test.ts and tests/cli/apply/generate-codex-config.test.ts (real expandPolicyPacks + generateCodexConfig output, positive + negative control on the actual emitted TOML matcher). Docs updated: docs/policy-packs/understanding-before-execution.md, docs/CLI.md, docs/okf/codex-adapter-parity-gaps.md.

Fixed

  • harness doctor exits 1 when the report contains errors (PR #342), so CI and scripts can gate on it instead of parsing text.
  • harness doctor survives a spawn ENOENT on a missing MCP/CLI binary (PR #340) and reports the missing binary as a finding instead of crashing the whole report.
  • harness pause no longer recommends the broken !-prefix advice (PR #341): a !-shell inherits the session env, so the operator-only guardrail fired anyway; the agent-shell bypass is now denied at the policy layer instead (see the operator_only entry above for the policy primitive that grew out of this).
  • Four reference-doc statements that had drifted from code were corrected (PR #339, task 6a79738e, found by the OKF wave-1 fact-check).
  • harness preflight no longer reports a false failing: npm-test when the operator has non-empty real harness state (task 6ffa5672; third incident of the operator-state-isolation class PR #199 pinned, after the v0.21.1 preflight-stage and v0.22.0 approveUnderstanding leaks). The launcher (src/cli/main.ts) sets HARNESS_ALLOW_REAL_GENERATED_DIR=1 for the real binary; spawnPreflight passed no env to execFile, so agent-preflight and its nested npm test vitest run inherited the flag, re-enabling the implicit real-homedir fallback INSIDE the test processes. With a real harness pause sentinel present, 110 tests across 9 files failed (intercept tests receive "PAUSED since Nm ago" instead of policy output), the npm-test check went red, and the producer left the preflight tag unwritten with a misleading one-word reason, deterministically for as long as the sentinel existed. Reproduced safely via a fake HOME with a planted sentinel plus the flag (never against the real home dir). Fix: spawnPreflight now spawns the child with preflightChildEnv(), the parent env minus that single key; nothing else is scrubbed (PATH/HOME stay, preflight checks legitimately need the environment). Spawn-site audit alongside the fix: every other child harness starts is a git/version/install probe or a ledger/MCP client (none read the flag), or the deliberately operator-real smoke / runtime-reality commands, all left inheriting by design. Also fixed the diagnosability gap that made this expensive to find: describeNotReady now surfaces the failing check's own first details line (failing: npm-test (<detail>), whitespace-collapsed, capped at 140 chars) instead of the bare check name, and the producer's PreflightJson slice learned the details field. New tests in tests/cli/session-start/preflight.test.ts: a pure preflightChildEnv unit (strips exactly the one key, does not mutate the parent env) and a real-spawn regression that drives runSessionStartPreflight WITHOUT the runner injection against a fake preflight binary on PATH recording its environment, asserting the flag is absent in the child while a sibling variable passes through, plus a pin on the enriched stderr detail. Mutation-verified: removing the env: option from the execFile call turns the spawn regression red.
  • SECURITY: policies can now express a genuine, unconditional operator-only deny — closing the self-satisfy hole PR #341 had to ship with permanent harness validate warnings (task 2cc73f55). Every block policy previously had to carry a requires.ledger_tag, and the only satisfaction primitives the engine has — a ledger tag (writable in-session via mcp__agent-grounding__ledger_add) or a filesystem marker (operator-only only while a gate already locks Bash/Write down, circular on a default install) — are agent-satisfiable: whoever can write the ledger can open the gate (docs/writing-custom-policies.md tripwire 4). That left no honest way to say "the agent may NEVER do this and cannot self-satisfy it in-session"; PR #341's three pause/resume kill-switch policies (deny-kill-switch-bypass, deny-session-env-strip, deny-pause-sentinel-forgery) had to declare a requires.ledger_tag an agent could forge, ship with NO producers: on purpose, and accept 3 permanent self-attestation warnings as the honest signal. Fix: a new operator_only: true marker on PolicySchema (src/schema/policies.ts), mutually exclusive with requires: (declaring both is rejected) and valid only with enforcement: block (warn / require_approval already have their own always-evaluated evidence paths). evaluateOnePolicy (src/runtime/intercept.ts) short-circuits an operator_only: true policy to an unconditional deny BEFORE the requires pipeline runs at all — no ledger query, no template substitution — so no ledger write, marker file, or flag can ever flip it to allow, from ANY in-session evidence. checkPolicySelfAttestation (src/cli/validate/checks.ts) now recognises the form as correct-by-construction and emits neither the warning nor (under --strict) an error for it. All three PR #341 policies migrated onto the new form in both src/cli/init/templates.ts and docs/examples/full-manifest.yaml (kept in parity by tests/cli/init-full-template-parity.test.ts, which now also pins operator_only as a load-bearing field); the 3 permanent validate warnings are gone and validate --strict on the full template returns 0 errors. Existing requires:-carrying block policies are byte-for-byte unaffected (additive path only, pinned by the full pre-existing test suite passing unchanged). New tests: a dedicated intercept() suite (tests/runtime/intercept.test.ts) proving the ledger is never queried and a forge-all-signals case (exact tag content, spoofed source: "operator", a head:<sha>-matching entry, a replayed policy_decision row) never flips the outcome, plus a defensive-branch test for a hand-built Policy object satisfying neither requires: nor operator_only: (degrades to warn-degraded, not a crash — unreachable through parseManifest, but not assumed unreachable at runtime); tests/cli/init-full-template-kill-switch-deny.test.ts's former "SELF-SATISFY HOLE" cases are now "FORGE-ALL-SIGNALS" regression guards proving the hole stays closed; new parseManifest schema tests and three new invalid-fixture files (docs/examples/invalid/24-26) pin the operator_only/requires mutual-exclusion and enforcement restriction. Mutation-verified: temporarily removing the operator_only short-circuit in evaluateOnePolicy turns the forge-all-signals tests red — with the migrated no-requires: policies, disabling only the short-circuit lands them in the requires === undefined defensive branch, so the outcome becomes warn-degraded (blockJson: null) rather than deny, not an allow; the forged ledger entries never even participate, since nothing queries the ledger either way. Reproducing the actual pre-fix allow would require reverting the whole task (the short-circuit AND the requiresoperator_only migration together), which is a stronger, not weaker, confirmation that the tests exercise the fix. Docs: new "Recipe C: operator-only unconditional deny" in docs/writing-custom-policies.md, updated field-reference tables there and in docs/ARCHITECTURE.md §6, and docs/okf/pause-vs-gate-kill-switch.md rewritten to describe the fix instead of the (now closed) gap. Explicitly out of scope, left as a follow-up: migrating the understanding-before-execution / branch-protection packs' filesystem-marker "operator-only" claims onto this primitive (they remain forgeable in-session on a default install unless a gate already blocks all Bash) — the primitive now exists to state that trust model honestly when someone picks that follow-up up; also out of scope: bash_match's own regex-coverage gaps against exotic shell shapes (heredocs, sh -c, base64), which is a trigger-matching problem, not a requires-satisfaction one, and remains as documented in PR #341.
  • approval_lifecycle.expire_on_bash_match now actually expires the marker end-to-end, on both runtimes (task bea04a03, closes the gap the a1348c89 entry above named "NOT fixed here" and docs/okf/codex-adapter-parity-gaps.md gap 12). The PostToolUse hook's trigger — the matcher field harness apply writes into settings.json (Claude) / config.toml (Codex) — was built ONLY from approval_lifecycle.expire_on_tool_match; a real Bash/shell/exec_command/functions.exec_command call never reached the hook at all, no matter how expire_on_bash_match was configured, so matchPostToolUseBoundary's bash-regex check (which DOES correctly evaluate expire_on_bash_match once invoked) was unreachable in practice — a gh pr merge never re-armed the gate. Fix: postToolUseMatchPattern (Claude) and codexPostToolUseMatchPattern (Codex) in understanding-before-execution.ts now widen the emitted matcher with the Bash tool name / the Codex shell-tool aliases whenever expire_on_bash_match carries at least one pattern (new resolveExpireOnBashMatchConfigured presence check); the hook is now also emitted (instead of silently suppressed) when expire_on_tool_match is explicitly empty but expire_on_bash_match is configured. The widened tool names are NEVER folded into expire_on_tool_match's own semantics — the hook body still classifies a matched Bash/shell call as a bash-regex match, not a tool-name match, exactly as before. New tests in tests/policy-packs/expand.test.ts (matcher-widening + empty-tool-list emitHook pins, both runtimes), tests/cli/apply/generate-codex-config.test.ts (real config.toml generator-layer pin), and tests/cli/pack-hook-post-tool-use.test.ts / tests/cli/pack-hook-codex-post-tool-use.test.ts (end-to-end: the REAL emitted matcher routes a gh pr merge Bash/shell call to the hook, which expires the marker, plus a negative control on a non-boundary tool) — the existing hook-body test suites had exercised expire_on_bash_match only by calling the hook CLI directly, bypassing the matcher construction that was actually broken. Docs updated: docs/policy-packs/understanding-before-execution.md, docs/okf/codex-adapter-parity-gaps.md.
  • SECURITY: the quote-aware recovery-commit metachar screen had a backslash-escaped-quote command-injection bypass, found and fixed before this landed (task 6e888423, same bug class the 0.40.0 entry above already documents once for the harness approve heredoc matcher). hasUnsafeMetachar/tokenize (src/runtime/recovery-git-commit.ts) toggled quote state on every raw "/' with no concept of backslash-escaping, but bash does: \" outside a quote is a LITERAL " that does not open a quote context. A payload like git commit -am a\" ; echo INJECTED ; \" was therefore misread as one safely-quoted message — the classifier "entered" a phantom quote span at the escaped " and treated the live ; inside it as inert text — while bash itself never entered a quote at all and ran echo INJECTED as a separate command. Confirmed end-to-end (classifier ADMIT + a real shell executing the injected command) for ;, ||, and | riding this exact shape, reachable exactly at markerExpired === true, the state the gate is supposed to hard-block everything in. Fix: reject any backslash anywhere in the command outright, before the quote-aware scan runs, rather than modeling bash's actual escape grammar (deliberately not attempted — too risky to get subtly wrong twice in one security boundary). Without a backslash present the naive quote-toggling matches bash's real quoting exactly, so this closes the whole escape-based attack surface at the cost of not admitting a message that happens to contain a literal backslash; the documented main case (this repo's own Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> trailer) has none. New regression tests pin all three confirmed injection payloads as rejected in the classifier unit suite AND in both the Claude and Codex PreToolUse hook suites (Codex shares the classifier and inherited the same bypass), and confirm the real trailer still converges.
  • The understanding-gate no longer hard-wedges the recovery git commit when approval_lifecycle.max_age expires mid-task (task 6e888423). max_age (4h in the full template) expires the operator-approval marker independently of any task-completion boundary — correct for genuinely stale sessions, but it can also land mid-task during a long reviewer-amendment loop (apply fixes, wait on CI, iterate again). Before this fix, an agent finishing such a loop had its recovery git commit — the one that consolidates already-approved Edit/Write output into a new HEAD so preflight/solution-acceptance can re-pin their verdict there — hard-blocked exactly like any other Bash call, recoverable only via an operator-run harness approve understanding (agent-grounding frictions #2/#9/#58/#71; distinct from, and downstream of, the solution-acceptance/preflight-before-push livelock task 5fb64db9 already fixed). Reproduced live: an expired marker plus a bare git commit blocked with approvalCheck.source: "none". Fix is narrow by design, not a general Bash whitelist: checkApprovalMarker now distinguishes expired (a real marker existed and aged past max_age) from merely absent (never approved, or cleared by a task-completion boundary tool via clearApprovalMarker — that case must keep hard-blocking so the gate still re-arms for the next task), threaded through checkOperatorApprovalMarkers so both the Claude and Codex PreToolUse hooks share one decision (expired is computed only on the non-matched path, so a matched marker never also reads expired:true even when a sibling marker is stale). A new classifier (src/runtime/recovery-git-commit.ts, isRecoveryGitCommit) admits only a bare, unchained git commit (-a/--all/--allow-empty/-m/--message/-am, any number of message flags — -ma is deliberately excluded, since getopt would parse it as -m with inline value "a" rather than -a plus a real message). The metachar screen is quote-aware: ;/&/|/</> are only dangerous OUTSIDE a quoted span (bash treats them as literal text inside single or double quotes) and are rejected there; a backtick or $(...) is rejected outside a quote AND inside a double-quoted span (both still expand it) but admitted inside a single-quoted span (fully inert); ANY backslash anywhere rejects the whole command outright (see the SECURITY entry above). This is what lets a real quoted commit-message trailer — including this very repo's own Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> convention, which needs the angle brackets — through without opening a metachar hole; an UNQUOTED chain/redirect/substitution still fails closed exactly as before. Any unrecognised flag also fails closed — --amend and --no-verify included. The exemption fires ONLY when both signals hold: expired:true proves this exact session/task had a real prior approval, and the classifier proves the command cannot smuggle other work or introduce new file content (everything in the working tree was itself produced by previously-gated Edit/Write/Bash). Edit/Write and every other Bash shape stay hard-gated regardless; a never-approved session's identical git commit still blocks; a task_finish-cleared marker's next commit still blocks (re-arm preserved). Mutation-verified via control/fix/convergence triples (including an inert-test guard carrying the real <email> trailer, which fails against the pre-fix quote-blind screen) in both the Claude and Codex PreToolUse hook test suites, plus a dedicated isRecoveryGitCommit unit-test suite covering the admit/reject boundary and the quote-aware metachar rule specifically.