@sabaiway/agent-workflow-kit 5.2.0 → 5.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,46 @@ Semantically versioned ([semver](https://semver.org)), newest first. The `versio
4
4
  is the current release. `upgrade` mode reads a project's `docs/ai/.workflow-version` and applies
5
5
  every `migrations/<version>-<slug>.md` newer than it, in semver order.
6
6
 
7
+ ## 5.3.0 — a check must speak where it is built to speak: the two silent checks (AD-088)
8
+
9
+ **Two shipped checks stayed SILENT at exactly the point they exist to speak** — one a GATE that
10
+ refuses and blocks a commit, one a WARNING that never changes an exit status. They were one
11
+ sentence, so they ship together.
12
+
13
+ - **The `flow-check` gate stops demanding the receipt its own run will write.** A red final on the
14
+ current base was cleared only through a LATER completed retry — but `run-gates` appends the final
15
+ receipt only after every gate has run, so the in-matrix `flow-check` could never see the receipt
16
+ its OWN run would write, and each `--final` on an unchanged base minted red N+1. No number of
17
+ rerun-causes converged; the only exit was a hook bypass. The `#65` rung is now consumer-aware, the
18
+ same lane split the flow→final comparison already applies one arm away: on the `gate` lane a
19
+ current-base red is
20
+ ALSO answered by a provable IN-PROGRESS retry — an authoritative `rerun-cause` naming its attempt
21
+ and binding the current fingerprint, a `final-start` at that fingerprint ordered strictly after
22
+ that red whose attempt has no completed final, and that fingerprint correlating to exactly ONE
23
+ base, the current one. Inside a real final run the conjunction holds by construction; a standalone
24
+ check on a quiet tree still refuses.
25
+ - **`commit-guard` is unchanged.** It keeps the strict completed-retry demand, so the commit
26
+ boundary still sees a real receipt. The rung's own `consumer` defaults to that strict lane, the
27
+ relaxation is opt-in by EXACT match, and a tree whose fingerprint is unresolvable or ambiguously
28
+ base-correlated never relaxes.
29
+ - **The interrupted-run residual is stated, not papered over.** An interrupted final run leaves the
30
+ same record shape with no live run behind it, so a standalone `flow-check --check` reads PASS in
31
+ that window. It authorizes nothing — the commit boundary refuses that shape twice over,
32
+ independently — and the rung's own source comment says so, pinned end to end by a fixture that
33
+ asserts all three conditions at once.
34
+ - **The bundled `codex-cli-bridge` mirror moves to 3.4.0** — its nested-sandbox scan now runs on
35
+ every completed run, not only a failed one, so a delegated run that SURVIVES the failure and exits
36
+ 0 is no longer silent about it. On a successful run, it warns when the scanner recognises the
37
+ expected per-item shape — both tokens inside the `aggregated_output` of ONE `command_execution`
38
+ with a proven failure. The heuristic is biased toward under-firing on ambiguous or schema-drifted
39
+ input: field reordering stops the match, and only the first matching item on a line is judged.
40
+ The exit status deliberately stays 0 there — a heuristic scan must never gain the power to refuse
41
+ real work.
42
+
43
+ Evidence: an end-to-end fixture drives a caused red at the current tree through ONE real
44
+ `run-gates --final` to a GREEN newest-authoritative final and a passing `commit-guard --check` — the
45
+ test that would have failed before this release.
46
+
7
47
  ## 5.2.0 — the flow machinery ships whole: recorded subset budgets, flow-bound finals, the round arms, and the dogfooded pipeline (AD-086)
8
48
 
9
49
  **Everything the flow series built after 5.1.0 lands as one wave** — the record vocabulary, the
package/SKILL.md CHANGED
@@ -3,7 +3,7 @@ name: agent-workflow-kit
3
3
  description: Deploy or upgrade a portable AI-agent memory-and-workflow system in any project. Use when the user wants to bootstrap `docs/ai/` + an entry-point `AGENTS.md` (+ `CLAUDE.md` alias) + cap/archive/index enforcement in a new or existing repo, set up the Memory Map and session protocols, install the docs-rotation pre-commit hook, or run `/agent-workflow-kit` / `/agent-workflow-kit upgrade`. Triggers on phrases like "set up the memory system", "deploy the AI workflow here", "bootstrap docs/ai", "upgrade the workflow".
4
4
  disable-model-invocation: true
5
5
  metadata:
6
- version: '5.2.0'
6
+ version: '5.3.0'
7
7
  ---
8
8
 
9
9
  # agent-workflow-kit
@@ -2,7 +2,7 @@
2
2
  name: codex-cli-bridge
3
3
  description: Delegate work to the OpenAI Codex CLI (`codex`) under a ChatGPT subscription — run plan/instruction EXECUTION in a sandboxed workspace, or get a read-only ADVISORY review of a plan or working-tree diff — as a second delegated-execution backend beside Antigravity. Use when the user wants to hand a bounded coding task or plan to `codex exec`, get a second-opinion review from codex, install or authenticate Codex CLI, understand its sandbox/network/approval policy, drive codex efficiently from the main agent (exec vs review, resume, the commit boundary), bridge project context (`AGENTS.md`) into codex, or troubleshoot codex flags, models, auth, or its no-TTY headless behaviour.
4
4
  metadata:
5
- version: '3.3.0'
5
+ version: '3.4.0'
6
6
  ---
7
7
 
8
8
  # codex-cli-bridge
@@ -110,8 +110,15 @@ receipt field. **Quote the posture banner verbatim** when labeling a dispatch.
110
110
 
111
111
  `codex exec` is headless: there is **no TTY**, so `approval_policy=never` — anything needing
112
112
  escalation is refused and reported, never interactively approved. The wrappers capture only codex's
113
- **final message** (`-o`; the JSON event stream + reasoning go to a discarded trace), so output is
114
- clean; a successful **non-resume** `codex-exec` also records the session id to a sidecar
113
+ **final message** (`-o`), so output is clean; the JSON event stream + reasoning go to a run trace
114
+ that is **read before it is discarded**. Fresh and resumed runs share **ONE** capture posture
115
+ (`-o` + `--json` + `--color never`, everything redirected into the trace), so both modes have the
116
+ same evidence surface — and the wrapper scans that surface on **every completed run**: a run that
117
+ SURVIVES a nested-sandbox failure exits 0 with an ungrounded answer, so on `rc == 0` it prints the
118
+ answer first and then warns loudly on stderr when one `command_execution` item with a **proven**
119
+ failure carries both a sandbox-mechanism and a permission/read-only token in its `aggregated_output`.
120
+ The exit status stays 0 there (warning, not gate) — read the stderr line. A successful **non-resume**
121
+ `codex-exec` also records the session id to a sidecar
115
122
  (`${CODEX_SESSION_FILE:-./.codex-last-session}`) so `--resume-last` can find it. Extra `codex` flags
116
123
  go after a literal `--`; the wrapper rejects any that would defeat the policy or the pinned model (see
117
124
  [§ Environment knobs](#environment-knobs) and the flag tiers in
@@ -86,6 +86,18 @@ Notes:
86
86
  INFORMATIONAL only: it is never persisted in a receipt or session sidecar
87
87
  quote the posture banner verbatim when labeling this dispatch — the banner is the machine-stated
88
88
  posture; a prose re-type drifts
89
+ every-run nested-sandbox scan (DUAL policy, deliberately two different rules): the scan runs on
90
+ EVERY completed run, not only a failed one, because a run that SURVIVES the nested-sandbox failure
91
+ exits 0 with an ungrounded answer and nothing said so. Failed run (rc != 0): the existing loose
92
+ whole-trace combination rule prints the recovery hint. Successful run (rc == 0): a warning fires
93
+ ONLY on precise per-item evidence — both a sandbox-mechanism token AND a permission/read-only
94
+ failure token inside the aggregated_output of ONE command_execution item whose failure is PROVEN
95
+ (a nonzero exit_code, or the serialized status "failed"); a null exit_code is never failure by
96
+ itself, tokens split across two items never fire, and a successful command's output never fires.
97
+ The answer is printed FIRST on stdout, then the warning on stderr. HONEST RESIDUAL: the exit
98
+ status does NOT change on that lane (a distinct nonzero exit would give a heuristic scan DENY
99
+ polarity, refusing real work whenever the scan over-warns), so an orchestrator keying on exit
100
+ status alone can still bank an ungrounded answer — the stderr warning is the signal
89
101
 
90
102
  Settings file (KEY=VALUE, parsed never sourced; env wins over file, file wins over built-in default):
91
103
  ${XDG_CONFIG_HOME:-~/.config}/agent-workflow/bridge-settings.conf
@@ -412,9 +424,9 @@ fi
412
424
 
413
425
  # --- Resume detection (must be the FIRST argument) ---------------------------
414
426
  # A dedicated entrypoint for iterating on a session without re-sending context.
415
- # `codex exec resume` RESETS posture and rejects the -s/--add-dir/-C posture flags
416
- # (it DOES accept -o/--json on 0.142.3, but we capture stdout directly), so we
417
- # restate the FULL policy via -c.
427
+ # `codex exec resume` RESETS posture and rejects the -s/--add-dir/-C posture flags,
428
+ # so we restate the FULL policy via -c. It DOES accept -o/--json/--color, and it now
429
+ # carries the same capture posture as a fresh run (one evidence surface, both modes).
418
430
  resume_mode=""
419
431
  resume_id=""
420
432
  case "${1:-}" in
@@ -608,8 +620,10 @@ chmod 755 "$shim_dir/git"
608
620
  # --- Build the codex invocation + the prompt ---------------------------------
609
621
  if [[ -n "$resume_mode" ]]; then
610
622
  # Resume RESETS posture and rejects the -s/--add-dir/-C posture flags, so restate
611
- # the entire policy via -c. We deliberately pass no -o/--json (resume DOES accept
612
- # them) codex prints the final message to stdout, which we capture into $out.
623
+ # the entire policy via -c. It DOES accept -o/--json/--color, and it now carries the
624
+ # SAME capture posture as a fresh run: the mode asymmetry was a convenience, and it
625
+ # left resume — the lane the survived-nested-sandbox incident fired on — with no
626
+ # evidence surface at all (its event stream went nowhere).
613
627
  codex_cmd=(codex exec resume "$resume_id"
614
628
  --ignore-user-config
615
629
  -m "$CODEX_MODEL"
@@ -620,6 +634,9 @@ if [[ -n "$resume_mode" ]]; then
620
634
  -c hide_agent_reasoning=true
621
635
  -c model_reasoning_summary=none
622
636
  "${tier_flags[@]+"${tier_flags[@]}"}"
637
+ --color never
638
+ -o "$out"
639
+ --json
623
640
  -)
624
641
  full_prompt="$RESUME_REMINDER"$'\n\n'"$task"
625
642
  else
@@ -672,25 +689,136 @@ aw_session_label="fresh"
672
689
  [[ -n "$resume_mode" ]] && aw_session_label="resume:$resume_id"
673
690
  echo "exec posture: model=$CODEX_MODEL effort=$CODEX_EFFORT tier=${CODEX_SERVICE_TIER:-standard} sandbox=workspace-write session=$aw_session_label timeout=$aw_timeout_banner" >&2
674
691
 
675
- # Normal mode: -o writes $out, the JSON stream + logs go to $trace. Resume mode: the
676
- # final message is codex's stdout $out, logs $trace. Either way the final lands
677
- # in $out and diagnostics in $trace, so the post-processing below is shared.
678
- set +e
679
- if [[ -n "$resume_mode" ]]; then
680
- if [[ -n "$timeout_bin" ]]; then
681
- printf '%s' "$full_prompt" | "${run_env[@]}" "$timeout_bin" --kill-after=15s "$CODEX_HARD_TIMEOUT" "${codex_cmd[@]}" >"$out" 2>"$trace"
692
+ # --- Nested-sandbox evidence scan: ONE entry point, TWO policies ---------------
693
+ # The class: codex ships its OWN OS sandbox (bwrap); run nested inside a harness sandbox the FS is
694
+ # read-only and codex's sandbox setup fails. The scan runs on EVERY completed run, not only a failed
695
+ # one — when the backend SURVIVES the failure (degrades to "I cannot check" and exits 0) a paid run
696
+ # is spent on an ungrounded answer, and nothing said so.
697
+ #
698
+ # The two arms have DIFFERENT rules, deliberately:
699
+ # FAILED run (rc != 0) — the loose whole-trace COMBINATION rule, unchanged: a sandbox MECHANISM
700
+ # token AND a permission/read-only FAILURE token anywhere in the trace. The run already failed and
701
+ # the operator is already reading the tail, so an extra hint costs nothing. grep is line-oriented,
702
+ # so a plain alternation stays within a line — hence two `-q` passes rather than one pattern.
703
+ # SUCCESSFUL run (rc == 0) — per-item evidence ONLY: both tokens inside the aggregated_output of
704
+ # ONE command_execution item whose FAILURE is proven. Here a false positive would libel a good
705
+ # answer, so nothing loose is allowed near it.
706
+ # The successful-run scan is LINE-ORIENTED and TOLERANT: after the unified 2>&1 the trace
707
+ # legitimately mixes plain log lines with JSONL, so every line is judged alone, a line that is not a
708
+ # well-formed command_execution item is simply not evidence (never a parse error, never a stop), and
709
+ # no line can mask a later one.
710
+ AW_NS_MECHANISM='bwrap|landlock|user namespace|pivot_root|unshare|seccomp'
711
+ AW_NS_FAILURE='read-only file system|erofs|operation not permitted|permission denied|eperm'
712
+
713
+ # BOTH token classes present in one piece of text. Fed by here-string rather than `printf | grep`:
714
+ # with `pipefail` on, a producer that takes EPIPE when `grep -q` exits early on a match would make
715
+ # the pipeline non-zero and silently DROP a real signature — an under-fire invisible by
716
+ # construction. Not reproduced on this host, folded as portability hardening: with no explicit
717
+ # pipeline there is no producer left to fail.
718
+ aw_ns_both_tokens() {
719
+ grep -qiE "$AW_NS_MECHANISM" <<<"$1" || return 1
720
+ grep -qiE "$AW_NS_FAILURE" <<<"$1" || return 1
721
+ return 0
722
+ }
723
+
724
+ # Valid JSON string CONTENT — no UNESCAPED quote. This is what proves a delimiter slice stayed
725
+ # inside ONE string instead of crossing an object boundary: without it, a decoy object carrying the
726
+ # anchor lets the walk leave its own string and land in another item's fields.
727
+ # The predicate is the parity rule — a quote is escaped iff an ODD number of backslashes precedes
728
+ # it — expressed as an ERE and evaluated by grep: `(^|[^\])` then an EVEN run `(\\)*` then the
729
+ # quote. It is measured, not assumed: the obvious bash spelling (delete the `\\` and `\"` pairs,
730
+ # then look for a survivor) is the SAME predicate but bash's `${var//…}` is quadratic, and it hung
731
+ # the wrapper outright on a 200KB aggregated_output — a real tool call's output reaches that size.
732
+ aw_ns_is_string_content() {
733
+ if grep -qE '(^|[^\\])(\\\\)*"' <<<"$1"; then return 1; fi
734
+ return 0
735
+ }
736
+
737
+ # One trace line → 0 only when THAT line is a command_execution item with a PROVEN failure whose
738
+ # aggregated_output carries both token classes. The wrapper stays dependency-free, so this is not a
739
+ # JSON parse — it is ONE anchored walk over the CLI's observed serialization in which every SKIPPED
740
+ # gap is PROVEN to be a single JSON string's content (aw_ns_is_string_content). Both halves are
741
+ # load-bearing: testing the fields as independent substrings lets a decoy object supply the anchor
742
+ # while the failure fields belong to another item, and skipping a gap without validating it lets the
743
+ # walk leave its own string and land in that other item anyway.
744
+ # The observed shape (codex-cli 0.147.0, live-probed):
745
+ # {"id":…,"type":"command_execution","command":"…","aggregated_output":"…","exit_code":2,"status":"failed"}
746
+ # Failure proofs: A = a nonzero exit_code, B = the status "failed" immediately after it. A null
747
+ # exit_code is never failure by itself (an in-flight item carries "exit_code":null,"status":"in_progress").
748
+ # Two stated consequences, both deliberate:
749
+ # - a future CLI that REORDERS these fields makes the scan stop firing rather than misfire.
750
+ # Under-firing is the right direction here: over-firing would libel a correct answer.
751
+ # - only the FIRST matching item on a line is judged; a second item's evidence is missed.
752
+ # A hand-crafted trace line is outside the threat model (the same boundary the wrapper declares for
753
+ # a hostile parent environment): the trace's only non-CLI content is plain stderr, which does not
754
+ # start with `{`, and a tool call's own output is JSON-escaped into a string and cannot inject
755
+ # structure. Anything not matching the walk is "not evidence" — never an error, never a stop.
756
+ # Every slice is taken by a SHORT-pattern `#*` cut plus length arithmetic. That is not a style
757
+ # choice: `${var%%<long>*}` and a prefix removal whose PATTERN is a huge variable are both
758
+ # quadratic in bash, and either one hangs the wrapper outright on a 200KB aggregated_output
759
+ # (measured, not assumed — a real tool call's output reaches that size).
760
+ aw_ns_item_evidence() {
761
+ local line="$1" d1='","aggregated_output":"' d2='","exit_code":' rest tail cmd agg code after
762
+ case "$line" in '{'*) ;; *) return 1 ;; esac
763
+ rest="${line#*'"type":"command_execution","command":"'}"
764
+ if [[ "$rest" == "$line" ]]; then return 1; fi
765
+ tail="${rest#*"$d1"}"
766
+ if [[ "$tail" == "$rest" ]]; then return 1; fi
767
+ cmd="${rest:0:$(( ${#rest} - ${#tail} - ${#d1} ))}"
768
+ aw_ns_is_string_content "$cmd" || return 1
769
+ after="${tail#*"$d2"}"
770
+ if [[ "$after" == "$tail" ]]; then return 1; fi
771
+ agg="${tail:0:$(( ${#tail} - ${#after} - ${#d2} ))}"
772
+ aw_ns_is_string_content "$agg" || return 1
773
+ code="${after%%,*}"
774
+ if [[ "$code" =~ ^-?[0-9]+$ && "$code" != "0" ]]; then
775
+ :
776
+ elif [[ "${after#"$code",}" == '"status":"failed"'* ]]; then
777
+ :
682
778
  else
683
- printf '%s' "$full_prompt" | "${run_env[@]}" "${codex_cmd[@]}" >"$out" 2>"$trace"
779
+ return 1
684
780
  fi
685
- rc=$?
686
- else
687
- if [[ -n "$timeout_bin" ]]; then
688
- printf '%s' "$full_prompt" | "${run_env[@]}" "$timeout_bin" --kill-after=15s "$CODEX_HARD_TIMEOUT" "${codex_cmd[@]}" >"$trace" 2>&1
689
- else
690
- printf '%s' "$full_prompt" | "${run_env[@]}" "${codex_cmd[@]}" >"$trace" 2>&1
781
+ aw_ns_both_tokens "$agg"
782
+ }
783
+
784
+ aw_scan_nested_sandbox() { # $1 = rc, $2 = trace path
785
+ [[ -r "$2" ]] || return 0
786
+ if [[ "$1" -ne 0 ]]; then
787
+ if grep -qiE "$AW_NS_MECHANISM" "$2" 2>/dev/null && grep -qiE "$AW_NS_FAILURE" "$2" 2>/dev/null; then
788
+ echo "hint: this looks like a NESTED-SANDBOX failure — codex-exec ships its own OS sandbox (bwrap)," >&2
789
+ echo " which cannot run nested inside a harness sandbox (the FS is read-only). Route codex-exec" >&2
790
+ echo " OUTSIDE the harness sandbox: add it to the harness sandbox excludedCommands, or dispatch" >&2
791
+ echo " this one run via a per-run consented bypass. Do NOT blanket-disable the sandbox." >&2
792
+ fi
793
+ return 0
691
794
  fi
692
- rc=$?
795
+ local line
796
+ while IFS= read -r line || [[ -n "$line" ]]; do
797
+ if aw_ns_item_evidence "$line"; then
798
+ echo "warning: NESTED-SANDBOX — this run COMPLETED, but a tool call inside it FAILED with a sandbox-setup" >&2
799
+ echo " signature. codex-exec ships its own OS sandbox (bwrap), which cannot run nested inside a" >&2
800
+ echo " harness sandbox (the FS turns read-only), so the backend most likely could not read what" >&2
801
+ echo " it was asked to check: the answer above may be UNGROUNDED — treat it as such rather than" >&2
802
+ echo " banking it. Re-dispatch OUTSIDE the harness sandbox: add codex-exec to the harness" >&2
803
+ echo " sandbox excludedCommands, or use a per-run consented bypass. Do NOT blanket-disable the" >&2
804
+ echo " sandbox. The exit status stays 0 on purpose — this is a warning, never a gate." >&2
805
+ return 0
806
+ fi
807
+ done <"$2"
808
+ return 0
809
+ }
810
+
811
+ # ONE capture posture for BOTH modes: -o writes the final message to $out, the JSON
812
+ # event stream and every log line go to $trace (stderr merged in). The final always
813
+ # lands in $out and the diagnostics always in $trace, so the post-processing below —
814
+ # including the evidence scan — is genuinely shared instead of mode-dependent.
815
+ set +e
816
+ if [[ -n "$timeout_bin" ]]; then
817
+ printf '%s' "$full_prompt" | "${run_env[@]}" "$timeout_bin" --kill-after=15s "$CODEX_HARD_TIMEOUT" "${codex_cmd[@]}" >"$trace" 2>&1
818
+ else
819
+ printf '%s' "$full_prompt" | "${run_env[@]}" "${codex_cmd[@]}" >"$trace" 2>&1
693
820
  fi
821
+ rc=$?
694
822
  set -e
695
823
 
696
824
  if [[ $rc -eq 124 || $rc -eq 137 ]]; then
@@ -701,20 +829,7 @@ fi
701
829
  if [[ $rc -ne 0 ]]; then
702
830
  echo "error: codex exec failed (exit $rc). Last lines of the run trace:" >&2
703
831
  tail -n 40 "$trace" >&2
704
- # Nested-sandbox detection: codex ships its OWN OS sandbox (bwrap); run nested inside a harness
705
- # sandbox the FS is read-only and codex's own sandbox setup fails. Fire the STATED recovery hint
706
- # only on a COMBINATION — a sandbox MECHANISM token AND a permission/read-only FAILURE token in the
707
- # trace — so a lone 'bwrap' banner, or a lone 'permission denied' from unrelated code, is NOT enough
708
- # (never a preemptive blanket). grep is line-oriented, so a plain alternation stays within a line —
709
- # the old `[^\n]*` between the two halves was wrong twice over (it excluded the letter 'n', and grep
710
- # never spans lines anyway), so the split into two `-q` passes both fixes it and states the intent.
711
- if grep -qiE 'bwrap|landlock|user namespace|pivot_root|unshare|seccomp' "$trace" 2>/dev/null \
712
- && grep -qiE 'read-only file system|erofs|operation not permitted|permission denied|eperm' "$trace" 2>/dev/null; then
713
- echo "hint: this looks like a NESTED-SANDBOX failure — codex-exec ships its own OS sandbox (bwrap)," >&2
714
- echo " which cannot run nested inside a harness sandbox (the FS is read-only). Route codex-exec" >&2
715
- echo " OUTSIDE the harness sandbox: add it to the harness sandbox excludedCommands, or dispatch" >&2
716
- echo " this one run via a per-run consented bypass. Do NOT blanket-disable the sandbox." >&2
717
- fi
832
+ aw_scan_nested_sandbox "$rc" "$trace"
718
833
  exit $rc
719
834
  fi
720
835
 
@@ -739,3 +854,7 @@ else
739
854
  echo "warning: codex produced no final-message file — printing the run-trace tail instead." >&2
740
855
  tail -n 40 "$trace"
741
856
  fi
857
+
858
+ # The answer is printed FIRST, then the evidence speaks: a run that COMPLETED can still have been
859
+ # ungrounded, and saying so after the answer keeps stdout byte-identical for every caller.
860
+ aw_scan_nested_sandbox "$rc" "$trace"
@@ -46,6 +46,13 @@ const FAKE_CODEX = [
46
46
  ' cat <<EOF',
47
47
  '{"type":"turn.started"}',
48
48
  '{"type":"item.completed","item":{"id":"item_0","type":"agent_message","text":"FAKE_FINAL_MESSAGE"}}',
49
+ 'EOF',
50
+ // The event seam: verbatim extra stream lines (JSONL items, plain noise, or both) between the
51
+ // opening events and turn.completed — a multi-line value emits multiple lines.
52
+ ' if [[ -n "${CODEX_FAKE_EVENT:-}" ]]; then echo "$CODEX_FAKE_EVENT"; fi',
53
+ // A file-borne twin: a payload too large for the environment (E2BIG) still has to be emittable.
54
+ ' if [[ -n "${CODEX_FAKE_EVENT_FILE:-}" ]]; then cat "$CODEX_FAKE_EVENT_FILE"; fi',
55
+ ' cat <<EOF',
49
56
  '{"type":"turn.completed","usage":{}}',
50
57
  'EOF',
51
58
  'else',
@@ -365,6 +372,203 @@ describe('codex-exec.sh — clean output + session capture (1.2)', () => {
365
372
  assert.doesNotMatch(r.stderr, /NESTED-SANDBOX/, 'a permission failure from unrelated code is not nested-sandbox proof');
366
373
  });
367
374
 
375
+ // ── the rc == 0 arm: the SURVIVED nested-sandbox failure ──
376
+ // The class the failed-run arm cannot see: the backend hits the nested sandbox, degrades to "I
377
+ // cannot check", and exits 0 — a paid run spent on an ungrounded answer with nothing saying so.
378
+ // Both serialized shapes below were observed on the INSTALLED codex-cli 0.147.0: a finished item
379
+ // carries {"exit_code":2,"status":"failed"}, an in-flight one {"exit_code":null,"status":"in_progress"}.
380
+ const cmdItem = ({ command = '/bin/bash -lc probe', output = '', exitCode = null, status = 'completed', id = 'item_1' }) =>
381
+ JSON.stringify({ type: 'item.completed', item: { id, type: 'command_execution', command, aggregated_output: output, exit_code: exitCode, status } });
382
+ const MECHANISM = 'bwrap: setting up sandbox';
383
+ const FAILURE = 'mkdir /newroot: Read-only file system';
384
+ const SIGNATURE = `${MECHANISM}: ${FAILURE}\n`;
385
+
386
+ it('an rc == 0 run whose trace carries a command_execution with a NONZERO exit_code and the signature warns loudly and still prints the answer', () => {
387
+ const sb = makeSandbox();
388
+ const r = run(sb, { env: { CODEX_FAKE_EVENT: cmdItem({ output: SIGNATURE, exitCode: 1, status: 'completed' }) } });
389
+ rmSync(sb.root, { recursive: true, force: true });
390
+ assert.equal(r.status, 0, 'the warning lane never changes the exit status');
391
+ assert.match(r.stdout, /FAKE_FINAL_MESSAGE/, 'the answer is printed FIRST, on stdout, unchanged');
392
+ assert.match(r.stderr, /NESTED-SANDBOX/, 'names the class');
393
+ assert.match(r.stderr, /UNGROUNDED/, 'names the consequence for the answer above');
394
+ assert.match(r.stderr, /excludedCommands|per-run consented bypass/, 'names the reroute');
395
+ });
396
+
397
+ it('an rc == 0 run whose trace carries a command_execution with a null exit_code and an explicitly FAILED status warns', () => {
398
+ const sb = makeSandbox();
399
+ const r = run(sb, { env: { CODEX_FAKE_EVENT: cmdItem({ output: SIGNATURE, exitCode: null, status: 'failed' }) } });
400
+ rmSync(sb.root, { recursive: true, force: true });
401
+ assert.equal(r.status, 0);
402
+ assert.match(r.stderr, /NESTED-SANDBOX/, 'the serialized failed status is the second failure proof');
403
+ });
404
+
405
+ it('plain non-JSON stderr lines before and after a matching failed command_execution do not suppress the warning', () => {
406
+ const sb = makeSandbox();
407
+ const r = run(sb, {
408
+ env: {
409
+ CODEX_FAKE_STDERR: 'ERROR codex_core::session: failed to load skill /x/SKILL.md: missing field description',
410
+ CODEX_FAKE_EVENT: `not json at all\n${cmdItem({ output: SIGNATURE, exitCode: 2, status: 'failed' })}\nstill not json`,
411
+ },
412
+ });
413
+ rmSync(sb.root, { recursive: true, force: true });
414
+ assert.equal(r.status, 0);
415
+ assert.match(r.stderr, /NESTED-SANDBOX/, 'the merged stream is judged line by line — noise is not evidence and never a stop');
416
+ });
417
+
418
+ it('the resume lane warns on an rc == 0 nested-sandbox signature — the lane the incident fired on', () => {
419
+ const sb = makeSandbox();
420
+ const r = run(sb, {
421
+ args: ['--resume', 'sess-nested', '-'], input: 'continue',
422
+ env: { CODEX_FAKE_EVENT: cmdItem({ output: SIGNATURE, exitCode: 1, status: 'failed' }) },
423
+ });
424
+ rmSync(sb.root, { recursive: true, force: true });
425
+ assert.equal(r.status, 0);
426
+ assert.match(r.stdout, /FAKE_FINAL_MESSAGE/);
427
+ assert.match(r.stderr, /NESTED-SANDBOX/, 'the whole point of unifying the capture');
428
+ });
429
+
430
+ it('an rc == 0 run with a clean trace warns nothing', () => {
431
+ const sb = makeSandbox();
432
+ const r = run(sb);
433
+ rmSync(sb.root, { recursive: true, force: true });
434
+ assert.equal(r.status, 0, r.stderr);
435
+ assert.doesNotMatch(r.stderr, /NESTED-SANDBOX/, 'a clean run must stay silent');
436
+ });
437
+
438
+ it('a lone mechanism token and a lone failure token each warn nothing on the rc == 0 lane', () => {
439
+ for (const output of [`${MECHANISM} version 0.11.0\n`, `curl: (7) ${FAILURE}\n`]) {
440
+ const sb = makeSandbox();
441
+ const r = run(sb, { env: { CODEX_FAKE_EVENT: cmdItem({ output, exitCode: 1, status: 'failed' }) } });
442
+ rmSync(sb.root, { recursive: true, force: true });
443
+ assert.doesNotMatch(r.stderr, /NESTED-SANDBOX/, `a lone token class is not proof: ${output}`);
444
+ }
445
+ });
446
+
447
+ it('nested-sandbox text appearing ONLY inside an agent_message item never warns', () => {
448
+ const sb = makeSandbox();
449
+ const event = JSON.stringify({ type: 'item.completed', item: { id: 'item_9', type: 'agent_message', text: `I hit ${SIGNATURE}` } });
450
+ const r = run(sb, { env: { CODEX_FAKE_EVENT: event } });
451
+ rmSync(sb.root, { recursive: true, force: true });
452
+ assert.doesNotMatch(r.stderr, /NESTED-SANDBOX/, 'the model TALKING about a sandbox is not a failed tool call');
453
+ });
454
+
455
+ it('a SUCCESSFUL command_execution whose output merely QUOTES both tokens never warns', () => {
456
+ const sb = makeSandbox();
457
+ // The concrete false positive: codex-exec.sh itself carries both token classes, so any
458
+ // successful grep over it would trip a loose whole-trace rule.
459
+ const r = run(sb, {
460
+ env: { CODEX_FAKE_EVENT: cmdItem({ command: '/bin/bash -lc grep -n bwrap codex-exec.sh', output: SIGNATURE, exitCode: 0, status: 'completed' }) },
461
+ });
462
+ rmSync(sb.root, { recursive: true, force: true });
463
+ assert.equal(r.status, 0, r.stderr);
464
+ assert.doesNotMatch(r.stderr, /NESTED-SANDBOX/, 'a command that SUCCEEDED proves nothing failed');
465
+ });
466
+
467
+ it('a command_execution with a null exit_code and no proven failed status never warns', () => {
468
+ const sb = makeSandbox();
469
+ const r = run(sb, { env: { CODEX_FAKE_EVENT: cmdItem({ output: SIGNATURE, exitCode: null, status: 'in_progress' }) } });
470
+ rmSync(sb.root, { recursive: true, force: true });
471
+ assert.doesNotMatch(r.stderr, /NESTED-SANDBOX/, 'a null exit_code is never failure by itself');
472
+ });
473
+
474
+ it('tokens split across two different items never warn', () => {
475
+ const sb = makeSandbox();
476
+ const split = [
477
+ cmdItem({ id: 'item_1', output: `${MECHANISM} version 0.11.0\n`, exitCode: 1, status: 'failed' }),
478
+ cmdItem({ id: 'item_2', output: `curl: (7) ${FAILURE}\n`, exitCode: 1, status: 'failed' }),
479
+ ].join('\n');
480
+ const r = run(sb, { env: { CODEX_FAKE_EVENT: split } });
481
+ rmSync(sb.root, { recursive: true, force: true });
482
+ assert.doesNotMatch(r.stderr, /NESTED-SANDBOX/, 'the combination must sit in ONE item — two failures are not one nested sandbox');
483
+ });
484
+
485
+ // ── object membership, not substring co-occurrence ──
486
+ // Testing the four fields independently is not enough: position in the line is not membership in
487
+ // the item. The scan walks ONE contiguous chain of raw delimiters instead, and every gap in that
488
+ // chain is inside a JSON string, where a quote is escaped and cannot forge the next delimiter.
489
+ it('a decoy object carrying the type, with the failure fields on a DIFFERENT item, never warns', () => {
490
+ const sb = makeSandbox();
491
+ const decoy = '{"type":"item.completed","decoy":{"type":"command_execution"},"item":{"type":"agent_message","aggregated_output":"bwrap: operation not permitted","exit_code":0,"status":"failed"}}';
492
+ const r = run(sb, { env: { CODEX_FAKE_EVENT: decoy } });
493
+ rmSync(sb.root, { recursive: true, force: true });
494
+ assert.equal(r.status, 0, r.stderr);
495
+ assert.doesNotMatch(r.stderr, /NESTED-SANDBOX/, 'the type belongs to the decoy; the failure fields belong to an agent_message');
496
+ });
497
+
498
+ it('a decoy carrying BOTH the type and a command, with the failure fields on a DIFFERENT item, never warns', () => {
499
+ const sb = makeSandbox();
500
+ // Anchoring on a longer literal is not enough: the skip between fields must itself be PROVEN to
501
+ // be one JSON string's content, or the walk leaves the decoy's command and lands in the
502
+ // agent_message's fields.
503
+ const decoy = '{"type":"item.completed","decoy":{"type":"command_execution","command":"x"},"item":{"type":"agent_message","aggregated_output":"bwrap: setting up sandbox: operation not permitted","exit_code":1,"status":"failed"}}';
504
+ const r = run(sb, { env: { CODEX_FAKE_EVENT: decoy } });
505
+ rmSync(sb.root, { recursive: true, force: true });
506
+ assert.equal(r.status, 0, r.stderr);
507
+ assert.doesNotMatch(r.stderr, /NESTED-SANDBOX/, 'an unvalidated gap lets the walk cross an object boundary');
508
+ });
509
+
510
+ it('a genuinely failed item with a ~200KB aggregated_output still warns — and the scan does not hang', () => {
511
+ const sb = makeSandbox();
512
+ // Two edges at once: the signature sits FIRST, so any early-exit consumer must not lose it, and
513
+ // the field is far larger than a pipe buffer. It also pins the cost: the quadratic bash string
514
+ // spellings of this walk hang the wrapper outright at this size.
515
+ const big = `${SIGNATURE}${'x'.repeat(200000)}`;
516
+ // The payload rides a FILE: 200KB in the environment is E2BIG on a normal host.
517
+ const payload = join(sb.repo, 'big-event.jsonl');
518
+ writeFileSync(payload, `${cmdItem({ output: big, exitCode: 1, status: 'failed' })}\n`);
519
+ const r = run(sb, { env: { CODEX_FAKE_EVENT_FILE: payload } });
520
+ rmSync(sb.root, { recursive: true, force: true });
521
+ assert.equal(r.status, 0, r.stderr);
522
+ assert.match(r.stderr, /NESTED-SANDBOX/, 'a large output must not silently drop a real signature');
523
+ });
524
+
525
+ it('a plain non-JSON log line carrying the same substrings never warns', () => {
526
+ const sb = makeSandbox();
527
+ const lookalike = `ERROR codex_core: replaying "type":"command_execution","command":"x","aggregated_output":"${SIGNATURE.trim()}","exit_code":1,"status":"failed"`;
528
+ const r = run(sb, { env: { CODEX_FAKE_EVENT: lookalike } });
529
+ rmSync(sb.root, { recursive: true, force: true });
530
+ assert.doesNotMatch(r.stderr, /NESTED-SANDBOX/, 'prose ABOUT an event is not an event — an event line starts with {');
531
+ });
532
+
533
+ it('a FOREIGN "status":"failed" elsewhere on the line never proves a SUCCESSFUL item failed', () => {
534
+ const sb = makeSandbox();
535
+ const event = `${cmdItem({ output: SIGNATURE, exitCode: 0, status: 'completed' })}{"type":"turn.failed","status":"failed"}`;
536
+ const r = run(sb, { env: { CODEX_FAKE_EVENT: event } });
537
+ rmSync(sb.root, { recursive: true, force: true });
538
+ assert.doesNotMatch(r.stderr, /NESTED-SANDBOX/, 'the failed status must sit immediately after THIS item exit_code');
539
+ });
540
+
541
+ it('an escaped delimiter inside aggregated_output never fools the slice', () => {
542
+ const sb = makeSandbox();
543
+ const r = run(sb, {
544
+ env: { CODEX_FAKE_EVENT: cmdItem({ output: `${SIGNATURE}","exit_code":1,"status":"failed"`, exitCode: 0, status: 'completed' }) },
545
+ });
546
+ rmSync(sb.root, { recursive: true, force: true });
547
+ assert.doesNotMatch(r.stderr, /NESTED-SANDBOX/, 'a quote inside a JSON string is escaped, so the raw delimiter cannot occur there');
548
+ });
549
+
550
+ it('only the FIRST command_execution item of a line is judged — a second item on the same line is missed (a STATED false negative)', () => {
551
+ const sb = makeSandbox();
552
+ const glued = `${cmdItem({ id: 'item_1', output: 'all good\n', exitCode: 0, status: 'completed' })}${cmdItem({ id: 'item_2', output: SIGNATURE, exitCode: 1, status: 'failed' })}`;
553
+ const r = run(sb, { env: { CODEX_FAKE_EVENT: glued } });
554
+ rmSync(sb.root, { recursive: true, force: true });
555
+ assert.doesNotMatch(r.stderr, /NESTED-SANDBOX/, 'under-firing is the deliberate direction on a warning lane; this pins it so it cannot change silently');
556
+ });
557
+
558
+ it('a trace of plain non-JSON lines alone carrying both tokens never warns on the rc == 0 arm — while the FAILED arm warns on exactly those bytes', () => {
559
+ const bytes = `${MECHANISM}: ${FAILURE}`;
560
+ const clean = makeSandbox();
561
+ const ok = run(clean, { env: { CODEX_FAKE_STDERR: bytes } });
562
+ rmSync(clean.root, { recursive: true, force: true });
563
+ assert.equal(ok.status, 0, ok.stderr);
564
+ assert.doesNotMatch(ok.stderr, /NESTED-SANDBOX/, 'on a COMPLETED run only per-item evidence speaks');
565
+ const failed = makeSandbox();
566
+ const bad = run(failed, { env: { CODEX_FAKE_STDERR: bytes, CODEX_FAKE_EXIT: '1' } });
567
+ rmSync(failed.root, { recursive: true, force: true });
568
+ assert.equal(bad.status, 1);
569
+ assert.match(bad.stderr, /NESTED-SANDBOX/, 'the failed-run arm keeps its loose whole-trace rule — that is what makes the dual policy visible');
570
+ });
571
+
368
572
  it('warns (never silently) when the session sidecar cannot be written', () => {
369
573
  const sb = makeSandbox();
370
574
  const blocker = join(sb.repo, 'blocker');
@@ -447,10 +651,30 @@ describe('codex-exec.sh — resume entrypoint restates every invariant (3.1)', (
447
651
  assert.equal(r.status, 0, r.stderr);
448
652
  assert.match(r.argv, /(^|\n)sess-xyz(\n|$)/, 'the session id is passed positionally');
449
653
  for (const inv of RESUME_INVARIANTS) assert.match(r.argv, inv, `resume argv must include ${inv}`);
450
- assert.doesNotMatch(r.argv, /(^|\n)-o(\n|$)/, 'resume rejects -o');
451
- assert.doesNotMatch(r.argv, /(^|\n)--json(\n|$)/, 'resume rejects --json');
452
- assert.doesNotMatch(r.argv, /(^|\n)--color(\n|$)/, 'resume rejects --color');
453
- assert.match(r.stdout, /FAKE_FINAL_MESSAGE/, 'resume prints codex stdout');
654
+ assert.match(r.stdout, /FAKE_FINAL_MESSAGE/, 'resume prints the final message');
655
+ });
656
+
657
+ // The capture unification: resume used to be the odd mode out — no -o, no --json, its event
658
+ // stream nowhere — which is precisely why the lane the nested-sandbox incident fired on had no
659
+ // evidence surface. `codex exec resume` accepts both (live-probed, codex-cli 0.147.0).
660
+ it('resume composes the unified capture — the same -o/--json/--color posture as a fresh run', () => {
661
+ const sb = makeSandbox();
662
+ const r = run(sb, { args: ['--resume', 'sess-unified', '-'], input: 'continue please' });
663
+ rmSync(sb.root, { recursive: true, force: true });
664
+ assert.equal(r.status, 0, r.stderr);
665
+ assert.match(r.argv, /(^|\n)-o(\n|$)/, 'resume writes the final message through -o');
666
+ assert.match(r.argv, /(^|\n)--json(\n|$)/, 'resume streams the structured events');
667
+ assert.match(r.argv, /(^|\n)--color(\n|$)/, 'resume disables colour like a fresh run');
668
+ assert.match(r.stdout, /FAKE_FINAL_MESSAGE/, 'resume stdout is still the final message');
669
+ });
670
+
671
+ it('resume falls back to the trace tail when the final-message file is missing', () => {
672
+ const sb = makeSandbox();
673
+ const r = run(sb, { args: ['--resume', 'sess-noout', '-'], input: 'go', env: { CODEX_FAKE_NO_OUT: '1' } });
674
+ rmSync(sb.root, { recursive: true, force: true });
675
+ assert.equal(r.status, 0, r.stderr);
676
+ assert.match(r.stderr, /no final-message file/, 'the fallback is loud, never silent');
677
+ assert.match(r.stdout, /turn\.completed/, 'the trace tail carries the event stream resume now captures');
454
678
  });
455
679
 
456
680
  it('--resume-last reads the session id from the sidecar', () => {
@@ -278,7 +278,7 @@ DEFAULT_CODEX_EFFORT="xhigh"
278
278
  # Review-receipt identity (AD-038). AW_BRIDGE_VERSION mirrors this bridge's SKILL.md/capability.json
279
279
  # version (drift-guarded by codex-review.test.mjs against capability.json).
280
280
  AW_RECEIPT_BACKEND="codex"
281
- AW_BRIDGE_VERSION="3.3.0"
281
+ AW_BRIDGE_VERSION="3.4.0"
282
282
  CODEX_MODEL="${CODEX_MODEL:-$DEFAULT_CODEX_MODEL}"
283
283
  CODEX_EFFORT="${CODEX_EFFORT:-$DEFAULT_CODEX_EFFORT}"
284
284
  # Generous hard cap for a slow xhigh review (subscription latency varies).
@@ -3,7 +3,7 @@
3
3
  "schema": 1,
4
4
  "name": "codex-cli-bridge",
5
5
  "kind": "execution-backend",
6
- "version": "3.3.0",
6
+ "version": "3.4.0",
7
7
  "posture": { "model": "gpt-5.6-sol", "effort": "xhigh", "tier": null },
8
8
  "provides": ["execute", "review"],
9
9
  "roles": {
@@ -31,7 +31,8 @@
31
31
  "exec posture banner: ONE stderr line before dispatch states the ACTUAL run posture — exec posture: model=… effort=… tier=… sandbox=workspace-write session=fresh|resume:<id> timeout=… — from RESOLVED post-validation values; the resume id is validated pre-spend, and control bytes in any banner field refuse pre-spend",
32
32
  "threat model: the sidecar byte and grammar screens detect corrupted input under a trusted parent environment. A hostile parent environment — including exported shell functions or PATH substitution of core/backend commands — is outside the threat model and can substitute the backend itself. Targeted shadow-proof resolution protects banner/dispatch honesty from accidental shadowing; it is not an environment security boundary",
33
33
  "the exec posture banner appends a banner-only timeout=<duration|uncapped> field — exactly the duration handed to timeout(1), uncapped when no timeout/gtimeout binary caps the run; INFORMATIONAL only: it is never persisted in a receipt or session sidecar",
34
- "quote the posture banner verbatim when labeling this dispatch — the banner is the machine-stated posture; a prose re-type drifts"
34
+ "quote the posture banner verbatim when labeling this dispatch — the banner is the machine-stated posture; a prose re-type drifts",
35
+ "every-run nested-sandbox scan (DUAL policy, deliberately two different rules): the scan runs on EVERY completed run, not only a failed one, because a run that SURVIVES the nested-sandbox failure exits 0 with an ungrounded answer and nothing said so. Failed run (rc != 0): the existing loose whole-trace combination rule prints the recovery hint. Successful run (rc == 0): a warning fires ONLY on precise per-item evidence — both a sandbox-mechanism token AND a permission/read-only failure token inside the aggregated_output of ONE command_execution item whose failure is PROVEN (a nonzero exit_code, or the serialized status \"failed\"); a null exit_code is never failure by itself, tokens split across two items never fire, and a successful command's output never fires. The answer is printed FIRST on stdout, then the warning on stderr. HONEST RESIDUAL: the exit status does NOT change on that lane (a distinct nonzero exit would give a heuristic scan DENY polarity, refusing real work whenever the scan over-warns), so an orchestrator keying on exit status alone can still bank an ungrounded answer — the stderr warning is the signal"
35
36
  ]
36
37
  }
37
38
  },
@@ -75,9 +75,11 @@ codex-review code "focus on the reducer and its tests"
75
75
  `codex-exec` prepends an **orchestrator execution contract**: work in the current tree, never
76
76
  git-write, *obey* the already-merged `AGENTS.md` (Hard Constraints + declared gates), self-review the
77
77
  diff (incl. untracked files), run the project's declared gates (STOP if none are declared), don't
78
- commit, report blockers. It captures only codex's **final message** (`-o`; the JSON event stream +
79
- reasoning are discarded to a trace) and, on a **non-resume** run, records the session id to
80
- `${CODEX_SESSION_FILE:-./.codex-last-session}`.
78
+ commit, report blockers. It captures only codex's **final message** (`-o`) and, on a **non-resume**
79
+ run, records the session id to `${CODEX_SESSION_FILE:-./.codex-last-session}`. The JSON event stream
80
+ + reasoning go to a run trace that is **read before it is discarded** — fresh runs and resumes share
81
+ ONE capture posture, and the wrapper scans that trace on every completed run for a nested-sandbox
82
+ failure the run SURVIVED (see [`sandbox-and-flags.md`](sandbox-and-flags.md#clean-output-capture)).
81
83
 
82
84
  ## Resume — iterate without re-sending context
83
85