@sabaiway/agent-workflow-kit 3.1.0 → 3.2.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.
@@ -36,7 +36,7 @@
36
36
  # codex-exec <file|-> -- <extra codex flags...> # passthrough codex flags
37
37
  # codex-exec --resume-last <file|-> # continue the last session (iterate, no re-send)
38
38
  # codex-exec --resume <session-id> <file|-> # continue a specific session
39
- # CODEX_HARD_TIMEOUT=2h codex-exec <file> # raise the hard wall-clock cap
39
+ # CODEX_HARD_TIMEOUT=7200 codex-exec <file> # raise the hard wall-clock cap (integer seconds)
40
40
  # CODEX_PROBE=1 CODEX_MODEL=<slug> codex-exec <file> # throwaway probe (relaxes the guard)
41
41
  set -euo pipefail
42
42
 
@@ -72,6 +72,20 @@ Notes:
72
72
  nested inside a harness sandbox (the FS turns read-only) — route it OUTSIDE the harness sandbox
73
73
  (excludedCommands / a per-run consented bypass) on the OBSERVED bwrap/EPERM failure, never a
74
74
  preemptive blanket
75
+ exec posture banner: ONE stderr line before dispatch states the ACTUAL run posture —
76
+ exec posture: model=… effort=… tier=… sandbox=workspace-write session=fresh|resume:<id> timeout=… —
77
+ from RESOLVED post-validation values; the resume id is validated pre-spend, and control bytes in
78
+ any banner field refuse pre-spend
79
+ threat model: the sidecar byte and grammar screens detect corrupted input under a trusted parent
80
+ environment. A hostile parent environment — including exported shell functions or PATH
81
+ substitution of core/backend commands — is outside the threat model and can substitute the
82
+ backend itself. Targeted shadow-proof resolution protects banner/dispatch honesty from accidental
83
+ shadowing; it is not an environment security boundary
84
+ the exec posture banner appends a banner-only timeout=<duration|uncapped> field — exactly the
85
+ duration handed to timeout(1), uncapped when no timeout/gtimeout binary caps the run;
86
+ INFORMATIONAL only: it is never persisted in a receipt or session sidecar
87
+ quote the posture banner verbatim when labeling this dispatch — the banner is the machine-stated
88
+ posture; a prose re-type drifts
75
89
 
76
90
  Settings file (KEY=VALUE, parsed never sourced; env wins over file, file wins over built-in default):
77
91
  ${XDG_CONFIG_HOME:-~/.config}/agent-workflow/bridge-settings.conf
@@ -193,6 +207,56 @@ aw_apply_settings() {
193
207
  }
194
208
  aw_apply_settings
195
209
 
210
+ # --- Effective-timeout resolver (D5 banner honesty; AD-061) --------------------
211
+ # ONE rule, both bridges: the posture banner prints EXACTLY the duration handed to timeout(1) —
212
+ # an integer-seconds value rendered with the `s` suffix, a duration string verbatim — and
213
+ # `timeout=uncapped` when no timeout/gtimeout binary can cap the run; never a fabricated number.
214
+ # The EFFECTIVE value (env included — closing the aw_settings_valid env bypass) is validated by
215
+ # the same per-key rule as the settings file, plus a 7-digit integer-part bound (overflow); an
216
+ # invalid value warns + falls back to the built-in default — a typo never silently masquerades
217
+ # as a cap. AGY_TIMEOUT shares AGY_HARD_TIMEOUT's duration rule (it has no settings-file arm).
218
+ aw_effective_timeout() {
219
+ local key="$1" default="$2" value="${!1:-}" rule="$1" intpart
220
+ [[ "$rule" == "AGY_TIMEOUT" ]] && rule="AGY_HARD_TIMEOUT"
221
+ [[ -n "$value" ]] || { printf '%s' "$default"; return 0; }
222
+ intpart="${value%%[!0-9]*}"
223
+ if ! aw_settings_valid "$rule" "$value" || (( ${#intpart} > 7 )); then
224
+ # %q escapes the raw value so a control byte in it can never forge an extra diagnostic line
225
+ # (the direct agy-run lane has no pre-spend screen — the warning itself must be injection-proof).
226
+ printf "warning: invalid value '%q' for %s — using the built-in default %s.\n" "$value" "$key" "$default" >&2
227
+ printf '%s' "$default"
228
+ return 0
229
+ fi
230
+ printf '%s' "$value"
231
+ }
232
+ aw_timeout_label() {
233
+ local bin="$1" value="$2"
234
+ [[ -n "$bin" ]] || { printf 'uncapped'; return 0; }
235
+ case "$value" in
236
+ *[!0-9]*) printf '%s' "$value" ;;
237
+ *) printf '%ss' "$value" ;;
238
+ esac
239
+ }
240
+ aw_resolve_timeout_bin() {
241
+ local bin dir base
242
+ bin="$(builtin type -P timeout 2>/dev/null || true)"
243
+ [[ -n "$bin" ]] || bin="$(builtin type -P gtimeout 2>/dev/null || true)"
244
+ [[ -n "$bin" ]] || { printf ''; return 0; }
245
+ case "$bin" in
246
+ /*) ;;
247
+ *)
248
+ case "$bin" in
249
+ */*) dir="${bin%/*}"; base="${bin##*/}" ;;
250
+ *) dir="."; base="$bin" ;;
251
+ esac
252
+ dir="$(builtin cd -- "$dir" 2>/dev/null && builtin pwd -P)" || { printf ''; return 0; }
253
+ bin="$dir/$base"
254
+ ;;
255
+ esac
256
+ [[ -f "$bin" && -x "$bin" ]] || { printf ''; return 0; }
257
+ printf '%s' "$bin"
258
+ }
259
+
196
260
  DEFAULT_CODEX_MODEL="gpt-5.6-sol" # frontier coding model (verified locally) — pinned
197
261
  DEFAULT_CODEX_EFFORT="xhigh" # maximum reasoning effort — pinned
198
262
  CODEX_MODEL="${CODEX_MODEL:-$DEFAULT_CODEX_MODEL}"
@@ -209,6 +273,15 @@ CODEX_HARD_TIMEOUT="${CODEX_HARD_TIMEOUT:-3600}"
209
273
  # the wrapper validates the effective value: an unsupported one warns and runs on the standard
210
274
  # tier — a typo can never silently masquerade as Fast.
211
275
  CODEX_SERVICE_TIER="${CODEX_SERVICE_TIER:-}"
276
+ # D5 pre-spend control-byte screen — BEFORE tier validation (the codex-review.sh order: a
277
+ # malformed value is not a policy question). Screens EVERY exec-banner field.
278
+ for _posture_pair in "CODEX_MODEL=$CODEX_MODEL" "CODEX_EFFORT=$CODEX_EFFORT" "CODEX_SERVICE_TIER=$CODEX_SERVICE_TIER" "CODEX_HARD_TIMEOUT=$CODEX_HARD_TIMEOUT"; do
279
+ if [[ "${_posture_pair#*=}" == *[$'\x01'-$'\x1f'$'\x7f']* ]]; then
280
+ echo "error: ${_posture_pair%%=*} contains control bytes — fix the setting (env or bridge-settings.conf) and re-run." >&2
281
+ exit 2
282
+ fi
283
+ done
284
+ CODEX_HARD_TIMEOUT="$(aw_effective_timeout CODEX_HARD_TIMEOUT 3600)"
212
285
  if [[ -n "$CODEX_SERVICE_TIER" ]] && ! aw_settings_valid CODEX_SERVICE_TIER "$CODEX_SERVICE_TIER"; then
213
286
  echo "warning: CODEX_SERVICE_TIER='$CODEX_SERVICE_TIER' is not a supported service tier ('priority') — running on the standard tier." >&2
214
287
  CODEX_SERVICE_TIER=""
@@ -257,14 +330,27 @@ fi
257
330
  # Resolve the real git to an ABSOLUTE executable, ignoring shell functions/aliases
258
331
  # (`type -P` forces a PATH lookup). The shim embeds this path so codex cannot recurse
259
332
  # into the shim or delegate to the wrong binary.
260
- real_git="$(type -P git 2>/dev/null || true)"
333
+ real_git="$(builtin type -P git 2>/dev/null || true)"
261
334
  if [[ -z "$real_git" ]]; then
262
335
  echo "error: 'git' not found on PATH (needed for the work tree and the git-write boundary shim)." >&2
263
336
  exit 127
264
337
  fi
338
+ # Normalize a relative-PATH hit to an ABSOLUTE path with the same shadow-proof discipline as
339
+ # aw_resolve_timeout_bin: parameter-expansion split (no external dirname/basename an exported
340
+ # function could shadow), builtin cd/pwd -P, fail-closed to empty (the -x check below STOPs).
265
341
  case "$real_git" in
266
342
  /*) ;;
267
- *) real_git="$(cd "$(dirname "$real_git")" 2>/dev/null && pwd)/$(basename "$real_git")" ;;
343
+ *)
344
+ case "$real_git" in
345
+ */*) _git_dir="${real_git%/*}"; _git_base="${real_git##*/}" ;;
346
+ *) _git_dir="."; _git_base="$real_git" ;;
347
+ esac
348
+ if _git_dir="$(builtin cd -- "$_git_dir" 2>/dev/null && builtin pwd -P)"; then
349
+ real_git="$_git_dir/$_git_base"
350
+ else
351
+ real_git=""
352
+ fi
353
+ ;;
268
354
  esac
269
355
  if [[ ! -x "$real_git" ]]; then
270
356
  echo "error: resolved git path '$real_git' is not an executable." >&2
@@ -363,12 +449,37 @@ if [[ -n "$resume_mode" ]]; then
363
449
  echo " Run a normal 'codex-exec' once (it records the session id there) before resuming." >&2
364
450
  exit 2
365
451
  fi
366
- resume_id="$(head -n1 -- "$sidecar" | tr -d '[:space:]')"
367
- if [[ -z "$resume_id" ]]; then
452
+ # RAW-byte NUL screen BEFORE the shell variable: bash command substitution silently DROPS
453
+ # NUL bytes, so a hostile `sess-\0target` would otherwise be repaired into a valid id.
454
+ nul_count="$(head -n1 -- "$sidecar" | LC_ALL=C tr -cd '\000' | wc -c)"
455
+ if (( nul_count != 0 )); then
456
+ echo "error: the session sidecar '$sidecar' carries NUL bytes — refusing before any spend; delete it or pass --resume <session-id>." >&2
457
+ exit 2
458
+ fi
459
+ # Read the first line WITHOUT content mutation — only a CRLF terminator is stripped. A
460
+ # whitespace-ONLY line is an empty sidecar; inner whitespace beside real content hits the
461
+ # session-id grammar below and REFUSES — it is never silently stripped into a different
462
+ # (accidentally valid) id.
463
+ resume_id="$(head -n1 -- "$sidecar")"
464
+ resume_id="${resume_id%$'\r'}"
465
+ if [[ -z "${resume_id//[[:space:]]/}" ]]; then
368
466
  echo "error: the session sidecar '$sidecar' is empty — no session id to resume." >&2
369
467
  exit 2
370
468
  fi
371
469
  fi
470
+ # Resume-id grammar (AD-061, derived from live codex ids — UUID-like and sess-* forms alike):
471
+ # a safe charset + length bound, FIRST char alphanumeric (a leading dash would reach
472
+ # `codex exec resume` as an OPTION, bypassing the guarded passthrough). A hostile/malformed
473
+ # id — explicit OR sidecar-read — refuses BEFORE any spend and before the id can reach the
474
+ # codex argv; the raw value is never echoed (it could carry control bytes).
475
+ aw_session_id_re='^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$'
476
+ if [[ ! "$resume_id" =~ $aw_session_id_re ]]; then
477
+ echo "error: the resolved session id is not a valid codex session id (letters, digits, dot," >&2
478
+ echo " underscore, hyphen; must START with a letter or digit; 1-128 chars) — refusing" >&2
479
+ echo " before any spend. If it came from the sidecar, the file may be corrupted —" >&2
480
+ echo " delete it or pass --resume <session-id>." >&2
481
+ exit 2
482
+ fi
372
483
  else
373
484
  # Normal mode: split off passthrough codex flags after a literal `--`. Extra args
374
485
  # WITHOUT the `--` separator are a mistake — fail loudly rather than drop them.
@@ -541,17 +652,24 @@ run_env=(env "PATH=$shim_dir:$PATH")
541
652
  # after the initial TERM if codex ignores it (a live probe confirmed plain
542
653
  # `timeout` reaps the whole codex child tree — no --foreground needed). If neither
543
654
  # binary exists we warn loudly and run uncapped rather than fail silently.
544
- timeout_bin=""
545
- if command -v timeout >/dev/null 2>&1; then
546
- timeout_bin="timeout"
547
- elif command -v gtimeout >/dev/null 2>&1; then
548
- timeout_bin="gtimeout"
549
- fi
655
+ # aw_resolve_timeout_bin: builtin type -P (an exported function can shadow neither `timeout` nor
656
+ # `type` itself), normalized to an ABSOLUTE path fail-closed the dispatch invokes the same
657
+ # absolute path the banner rendered from (banner and run never make independent conclusions).
658
+ timeout_bin="$(aw_resolve_timeout_bin)"
550
659
  if [[ -z "$timeout_bin" ]]; then
551
660
  echo "warning: no 'timeout'/'gtimeout' on PATH — running codex WITHOUT a hard wall-clock cap" >&2
552
661
  echo " (install coreutils to enable CODEX_HARD_TIMEOUT=$CODEX_HARD_TIMEOUT)." >&2
553
662
  fi
554
663
 
664
+ # --- D5 exec banner (one line, the ACTUAL run posture; AD-061) -----------------
665
+ # Emitted from RESOLVED post-validation values, AFTER the resume id is resolved and validated,
666
+ # BEFORE the dispatch. The timeout field is banner-only (never a receipt/sidecar field): it
667
+ # prints exactly the duration handed to timeout(1), or `uncapped` without a capping binary.
668
+ aw_timeout_banner="$(aw_timeout_label "$timeout_bin" "$CODEX_HARD_TIMEOUT")"
669
+ aw_session_label="fresh"
670
+ [[ -n "$resume_mode" ]] && aw_session_label="resume:$resume_id"
671
+ 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
672
+
555
673
  # Normal mode: -o writes $out, the JSON stream + logs go to $trace. Resume mode: the
556
674
  # final message is codex's stdout → $out, logs → $trace. Either way the final lands
557
675
  # in $out and diagnostics in $trace, so the post-processing below is shared.
@@ -28,7 +28,7 @@ const FAKE_CODEX = [
28
28
  '{ for a in "$@"; do echo "$a"; done; } >"$CODEX_FAKE_ARGV"',
29
29
  '{ echo "HOME=${HOME:-}"; echo "CODEX_HOME=${CODEX_HOME:-}"; echo "XDG_CONFIG_HOME=${XDG_CONFIG_HOME:-}"; echo "OPENAI_API_KEY=${OPENAI_API_KEY:-<unset>}"; echo "OPENAI_BASE_URL=${OPENAI_BASE_URL:-<unset>}"; echo "FOO_API_KEY=${FOO_API_KEY:-<unset>}"; echo "CODEX_REAL_GIT=${CODEX_REAL_GIT:-<unset>}"; } >"$CODEX_FAKE_ENV"',
30
30
  'cat >"$CODEX_FAKE_STDIN"',
31
- 'if [[ "${CODEX_FAKE_GIT_PROBE:-}" == "1" ]]; then { echo "realgit_env=${CODEX_REAL_GIT:-unset}"; echo "status=$(git status --short >/dev/null 2>&1; echo $?)"; echo "diff=$(git --no-pager diff >/dev/null 2>&1; echo $?)"; echo "dashC_read=$(git -C . status --short >/dev/null 2>&1; echo $?)"; echo "dashc_read=$(git -c core.pager=cat status --short >/dev/null 2>&1; echo $?)"; echo "bare=$(git >/dev/null 2>&1; echo $?)"; echo "commit=$(git commit -m x >/dev/null 2>&1; echo $?)"; echo "add=$(git add -A >/dev/null 2>&1; echo $?)"; echo "checkout=$(git checkout -- . >/dev/null 2>&1; echo $?)"; echo "unknown=$(git frobnicate >/dev/null 2>&1; echo $?)"; echo "config_read=$(git config user.name >/dev/null 2>&1; echo $?)"; echo "config_list=$(git config --list >/dev/null 2>&1; echo $?)"; echo "config_bare=$(git config >/dev/null 2>&1; echo $?)"; echo "config_write=$(git config user.name HACKED >/dev/null 2>&1; echo $?)"; echo "config_bypass=$(git config --get --add a.b v >/dev/null 2>&1; echo $?)"; echo "symref_write=$(git symbolic-ref HEAD refs/heads/x >/dev/null 2>&1; echo $?)"; echo "reflog_write=$(git reflog expire --all >/dev/null 2>&1; echo $?)"; } > "${CODEX_FAKE_GIT_RESULT:-/dev/null}" 2>&1; fi',
31
+ 'if [[ "${CODEX_FAKE_GIT_PROBE:-}" == "1" ]]; then { echo "realgit_env=${CODEX_REAL_GIT:-unset}"; echo "status=$(git status --short >/dev/null 2>&1; echo $?)"; echo "diff=$(git --no-pager diff >/dev/null 2>&1; echo $?)"; echo "dashC_read=$(git -C . status --short >/dev/null 2>&1; echo $?)"; echo "dashc_read=$(git -c core.pager=cat status --short >/dev/null 2>&1; echo $?)"; echo "bare=$(git >/dev/null 2>&1; echo $?)"; echo "commit=$(git commit -m x >/dev/null 2>&1; echo $?)"; echo "add=$(git add -A >/dev/null 2>&1; echo $?)"; echo "checkout=$(git checkout -- . >/dev/null 2>&1; echo $?)"; echo "unknown=$(git frobnicate >/dev/null 2>&1; echo $?)"; echo "config_read=$(git config user.name >/dev/null 2>&1; echo $?)"; echo "config_list=$(git config --list >/dev/null 2>&1; echo $?)"; echo "config_bare=$(git config >/dev/null 2>&1; echo $?)"; echo "config_write=$(git config user.name HACKED >/dev/null 2>&1; echo $?)"; echo "config_bypass=$(git config --get --add a.b v >/dev/null 2>&1; echo $?)"; echo "symref_write=$(git symbolic-ref HEAD refs/heads/x >/dev/null 2>&1; echo $?)"; echo "reflog_write=$(git reflog expire --all >/dev/null 2>&1; echo $?)"; echo "cdaway=$(cd / && git --version >/dev/null 2>&1; echo $?)"; } > "${CODEX_FAKE_GIT_RESULT:-/dev/null}" 2>&1; fi',
32
32
  'if [[ -n "${CODEX_FAKE_SLEEP:-}" ]]; then sleep "${CODEX_FAKE_SLEEP}"; fi',
33
33
  'out=""',
34
34
  'prev=""',
@@ -1270,3 +1270,213 @@ describe('codex-exec.sh — settings surface ⟷ manifest (D6, manifest-pinned)'
1270
1270
  }
1271
1271
  });
1272
1272
  });
1273
+
1274
+ describe('codex-exec.sh — dispatch-posture labeling (D5, AD-061)', () => {
1275
+ const banners = (stderr) => stderr.split('\n').filter((l) => l.startsWith('exec posture: '));
1276
+
1277
+ it('ONE banner line carries the ACTUAL {model, effort, tier, sandbox, session, timeout} on a fresh run', () => {
1278
+ const sb = makeSandbox();
1279
+ const r = run(sb, {});
1280
+ rmSync(sb.root, { recursive: true, force: true });
1281
+ assert.equal(r.status, 0, r.stderr);
1282
+ const lines = banners(r.stderr);
1283
+ assert.equal(lines.length, 1, 'EXACTLY ONE banner line per run');
1284
+ assert.equal(lines[0],
1285
+ 'exec posture: model=gpt-5.6-sol effort=xhigh tier=standard sandbox=workspace-write session=fresh timeout=3600s');
1286
+ });
1287
+
1288
+ it('an ARMED Fast tier rides the banner (tier=priority)', () => {
1289
+ const sb = makeSandbox();
1290
+ const r = run(sb, { env: { CODEX_SERVICE_TIER: 'priority' } });
1291
+ rmSync(sb.root, { recursive: true, force: true });
1292
+ assert.equal(r.status, 0, r.stderr);
1293
+ assert.match(r.stderr, /^exec posture: .* tier=priority .*$/m);
1294
+ });
1295
+
1296
+ it('a resume banner carries the RESOLVED session id (explicit --resume)', () => {
1297
+ const sb = makeSandbox();
1298
+ const r = run(sb, { args: ['--resume', 'sess-xyz', '-'] });
1299
+ rmSync(sb.root, { recursive: true, force: true });
1300
+ assert.equal(r.status, 0, r.stderr);
1301
+ const lines = banners(r.stderr);
1302
+ assert.equal(lines.length, 1, 'EXACTLY ONE banner line on resume too');
1303
+ assert.match(lines[0], / session=resume:sess-xyz /, 'the banner names the resolved id');
1304
+ });
1305
+
1306
+ it('--resume-last resolves the sidecar id into the banner', () => {
1307
+ const sb = makeSandbox();
1308
+ writeFileSync(join(sb.repo, '.codex-last-session'), 'sess-from-sidecar\n');
1309
+ const r = run(sb, { args: ['--resume-last', '-'] });
1310
+ rmSync(sb.root, { recursive: true, force: true });
1311
+ assert.equal(r.status, 0, r.stderr);
1312
+ assert.match(r.stderr, / session=resume:sess-from-sidecar /);
1313
+ });
1314
+
1315
+ it('a HOSTILE/malformed EXPLICIT session id refuses pre-spend (no codex invocation)', () => {
1316
+ for (const hostile of ['evil;rm -rf /', 'a b', `x${String.fromCharCode(1)}y`]) {
1317
+ const sb = makeSandbox();
1318
+ const r = run(sb, { args: ['--resume', hostile, '-'] });
1319
+ rmSync(sb.root, { recursive: true, force: true });
1320
+ assert.notEqual(r.status, 0, `must refuse: ${JSON.stringify(hostile)}`);
1321
+ assert.equal(r.capStdin, '', 'codex is never invoked');
1322
+ assert.match(r.stderr, /session id/i, 'named as the session-id class');
1323
+ }
1324
+ });
1325
+
1326
+ it('a HOSTILE SIDECAR-READ session id refuses pre-spend the same way', () => {
1327
+ const sb = makeSandbox();
1328
+ writeFileSync(join(sb.repo, '.codex-last-session'), 'evil$(touch pwned)\n');
1329
+ const r = run(sb, { args: ['--resume-last', '-'] });
1330
+ rmSync(sb.root, { recursive: true, force: true });
1331
+ assert.notEqual(r.status, 0);
1332
+ assert.equal(r.capStdin, '', 'codex is never invoked');
1333
+ assert.match(r.stderr, /session id/i);
1334
+ });
1335
+
1336
+ it('a FLAG-SHAPED sidecar id (leading dash) refuses at the grammar — never reaches codex as an option', () => {
1337
+ for (const bad of ['--last\n', '-x\n']) {
1338
+ const sb = makeSandbox();
1339
+ writeFileSync(join(sb.repo, '.codex-last-session'), bad);
1340
+ const r = run(sb, { args: ['--resume-last', '-'] });
1341
+ rmSync(sb.root, { recursive: true, force: true });
1342
+ assert.notEqual(r.status, 0, `must refuse: ${JSON.stringify(bad)}`);
1343
+ assert.equal(r.capStdin, '', 'codex is never invoked');
1344
+ assert.match(r.stderr, /session id/i, 'refused at the grammar, never parsed as a codex option');
1345
+ }
1346
+ });
1347
+
1348
+ it('a sidecar carrying a NUL byte refuses pre-spend — bash would silently repair it into a valid id', () => {
1349
+ const sb = makeSandbox();
1350
+ writeFileSync(join(sb.repo, '.codex-last-session'), Buffer.from('sess-\0target\n', 'binary'));
1351
+ const r = run(sb, { args: ['--resume-last', '-'] });
1352
+ rmSync(sb.root, { recursive: true, force: true });
1353
+ assert.notEqual(r.status, 0);
1354
+ assert.equal(r.capStdin, '', 'codex is never invoked');
1355
+ assert.match(r.stderr, /NUL/i, 'named as the NUL class — the raw bytes are checked before the shell variable');
1356
+ });
1357
+
1358
+ it('a valid id containing the ASCII digit 0 gets no false NUL refusal', () => {
1359
+ const sb = makeSandbox();
1360
+ writeFileSync(join(sb.repo, '.codex-last-session'), 'sess-01\n');
1361
+ const r = run(sb, { args: ['--resume-last', '-'] });
1362
+ rmSync(sb.root, { recursive: true, force: true });
1363
+ assert.equal(r.status, 0, r.stderr);
1364
+ assert.match(r.stderr, / session=resume:sess-01 /);
1365
+ });
1366
+
1367
+ it('a sidecar id with inner WHITESPACE refuses — never silently repaired into a different id', () => {
1368
+ for (const bad of ['sess bad\n', 'sess\tbad\n']) {
1369
+ const sb = makeSandbox();
1370
+ writeFileSync(join(sb.repo, '.codex-last-session'), bad);
1371
+ const r = run(sb, { args: ['--resume-last', '-'] });
1372
+ rmSync(sb.root, { recursive: true, force: true });
1373
+ assert.notEqual(r.status, 0, `must refuse: ${JSON.stringify(bad)}`);
1374
+ assert.equal(r.capStdin, '', 'codex is never invoked');
1375
+ assert.match(r.stderr, /session id/i, 'the grammar refusal fires — the id is never whitespace-stripped into validity');
1376
+ }
1377
+ });
1378
+
1379
+ it('a banner field carrying CONTROL BYTES refuses pre-spend (model / effort / tier / timeout / DEL)', () => {
1380
+ const cases = [
1381
+ { CODEX_MODEL: `gpt-5.6-sol${String.fromCharCode(1)}` },
1382
+ { CODEX_EFFORT: `xhigh${String.fromCharCode(2)}` },
1383
+ { CODEX_SERVICE_TIER: `priority${String.fromCharCode(3)}` },
1384
+ { CODEX_HARD_TIMEOUT: `3600${String.fromCharCode(4)}` },
1385
+ { CODEX_MODEL: `gpt-5.6-sol${String.fromCharCode(127)}` },
1386
+ ];
1387
+ for (const env of cases) {
1388
+ const sb = makeSandbox();
1389
+ const r = run(sb, { env });
1390
+ rmSync(sb.root, { recursive: true, force: true });
1391
+ assert.notEqual(r.status, 0, `must refuse: ${JSON.stringify(env)}`);
1392
+ assert.equal(r.capStdin, '', 'codex is never invoked');
1393
+ assert.match(r.stderr, /control/i, 'named as the control-byte class');
1394
+ }
1395
+ });
1396
+
1397
+ it('timeout honesty: no timeout/gtimeout on PATH → timeout=uncapped, never a fabricated number', () => {
1398
+ const sb = makeSandbox();
1399
+ const r = run(sb, { path: `${sb.bin}:${farmFor(['timeout', 'gtimeout'])}` });
1400
+ rmSync(sb.root, { recursive: true, force: true });
1401
+ assert.equal(r.status, 0, r.stderr);
1402
+ assert.match(r.stderr, /^exec posture: .* timeout=uncapped$/m);
1403
+ });
1404
+
1405
+ it('an EXPORTED shell function shadowing timeout never fools the banner (type -P discipline)', () => {
1406
+ const sb = makeSandbox();
1407
+ const r = run(sb, {
1408
+ path: `${sb.bin}:${farmFor(['timeout', 'gtimeout'])}`,
1409
+ env: { 'BASH_FUNC_timeout%%': '() { return 0; }' },
1410
+ });
1411
+ rmSync(sb.root, { recursive: true, force: true });
1412
+ assert.equal(r.status, 0, r.stderr);
1413
+ assert.match(r.stderr, /^exec posture: .* timeout=uncapped$/m, 'a shell function is not a capping binary');
1414
+ });
1415
+
1416
+ it('an EXPORTED `type` function faking a path never fools the resolver (builtin type discipline)', () => {
1417
+ const sb = makeSandbox();
1418
+ const r = run(sb, {
1419
+ path: `${sb.bin}:${farmFor(['timeout', 'gtimeout'])}`,
1420
+ env: { 'BASH_FUNC_type%%': '() { echo /fake/timeout; }' },
1421
+ });
1422
+ rmSync(sb.root, { recursive: true, force: true });
1423
+ assert.equal(r.status, 0, r.stderr);
1424
+ assert.match(r.stderr, /^exec posture: .* timeout=uncapped$/m, 'builtin type bypasses an exported type function');
1425
+ });
1426
+
1427
+ it('a RELATIVE first PATH git entry + shadowed dirname/basename still bakes an ABSOLUTE real git into the shim', () => {
1428
+ const sb = makeSandbox();
1429
+ const realGit = (process.env.PATH || '').split(':').filter(Boolean).map((d) => join(d, 'git')).find((p) => existsSync(p));
1430
+ assert.ok(realGit, 'a real git exists on PATH');
1431
+ mkdirSync(join(sb.repo, 'relgit'), { recursive: true });
1432
+ writeFileSync(join(sb.repo, 'relgit', 'git'), `#!/usr/bin/env bash\nexec ${realGit} "$@"\n`, { mode: 0o755 });
1433
+ const gitResult = join(sb.repo, '.cap-git');
1434
+ const r = run(sb, {
1435
+ path: `relgit:${sb.bin}:${process.env.PATH}`,
1436
+ env: {
1437
+ CODEX_FAKE_GIT_PROBE: '1',
1438
+ CODEX_FAKE_GIT_RESULT: gitResult,
1439
+ 'BASH_FUNC_dirname%%': '() { echo /shadowed; }',
1440
+ 'BASH_FUNC_basename%%': '() { echo shadowed; }',
1441
+ },
1442
+ });
1443
+ const probe = existsSync(gitResult) ? readFileSync(gitResult, 'utf8') : '';
1444
+ rmSync(sb.root, { recursive: true, force: true });
1445
+ assert.equal(r.status, 0, r.stderr);
1446
+ assert.match(probe, /cdaway=0/, 'the shim git works from a different cwd — the embedded path is absolute, shadow-proof');
1447
+ });
1448
+
1449
+ it('a RELATIVE PATH entry still yields an ABSOLUTE capping binary (the stub sees an absolute $0)', () => {
1450
+ const sb = makeSandbox();
1451
+ mkdirSync(join(sb.repo, 'relbin'), { recursive: true });
1452
+ const cap = join(sb.repo, '.stub-argv0');
1453
+ writeFileSync(join(sb.repo, 'relbin', 'timeout'), [
1454
+ '#!/usr/bin/env bash',
1455
+ 'echo "$0" >"$TIMEOUT_STUB_CAP"',
1456
+ 'while [[ "$1" == --* ]]; do shift; done',
1457
+ 'shift',
1458
+ 'exec "$@"',
1459
+ '',
1460
+ ].join('\n'), { mode: 0o755 });
1461
+ const r = run(sb, {
1462
+ path: `relbin:${sb.bin}:${farmFor(['timeout', 'gtimeout'])}`,
1463
+ env: { TIMEOUT_STUB_CAP: cap },
1464
+ });
1465
+ const argv0 = existsSync(cap) ? readFileSync(cap, 'utf8').trim() : '';
1466
+ rmSync(sb.root, { recursive: true, force: true });
1467
+ assert.equal(r.status, 0, r.stderr);
1468
+ assert.match(r.stderr, /^exec posture: .* timeout=3600s$/m, 'the relative-PATH binary still caps the run');
1469
+ assert.ok(argv0.startsWith('/'), `the stub must be invoked by ABSOLUTE path, got: ${JSON.stringify(argv0)}`);
1470
+ });
1471
+
1472
+ it('an INVALID effective CODEX_HARD_TIMEOUT (env — the closed aw_settings_valid bypass) warns and falls back to the default', () => {
1473
+ for (const bad of ['abc', '0', '999999999']) {
1474
+ const sb = makeSandbox();
1475
+ const r = run(sb, { env: { CODEX_HARD_TIMEOUT: bad } });
1476
+ rmSync(sb.root, { recursive: true, force: true });
1477
+ assert.equal(r.status, 0, r.stderr);
1478
+ assert.match(r.stderr, new RegExp(`invalid value '${bad}' for CODEX_HARD_TIMEOUT`), 'the fallback is loud');
1479
+ assert.match(r.stderr, /^exec posture: .* timeout=3600s$/m, 'the banner prints the built-in default, never the bad value');
1480
+ }
1481
+ });
1482
+ });
@@ -79,12 +79,21 @@ Settings file (KEY=VALUE, parsed never sourced; env wins over file, file wins ov
79
79
  CODEX_HARD_TIMEOUT — hard wall-clock cap, integer seconds 1..86400 (built-in default 1800)
80
80
  CODEX_REVIEW_MAX_TOTAL_BYTES — inline-payload cap, integer bytes 1..100000000 (default 1500000); above it the diff rides via a git-dir temp file
81
81
 
82
+ Notes:
83
+ the review posture banner appends a banner-only timeout=<duration|uncapped> field — exactly the
84
+ duration handed to timeout(1), uncapped when no timeout/gtimeout binary caps the run;
85
+ INFORMATIONAL only: it never enters the receipt posture or the D5 banner↔receipt parity
86
+ quote the posture banner verbatim when labeling this dispatch — the banner is the machine-stated
87
+ posture; a prose re-type drifts
88
+
82
89
  Honesty + posture (D4/D5):
83
90
  a run whose final message carries NO recognized 'Verdict: <ship|revise|rethink>' line — empty or
84
91
  missing output included — exits 4 with NO receipt: a FAILED review to RE-RUN, never a fatal
85
92
  session error. One stderr banner line states the ACTUAL run posture (review posture: model=…
86
- effort=… tier=…) and the receipt records the same posture {model, effort, tier} (tier null on
87
- the standard tier). A posture value carrying control bytes refuses pre-spend in every mode.
93
+ effort=… tier=… timeout=…) and the receipt records the same posture {model, effort, tier} (tier
94
+ null on the standard tier; the timeout field is banner-only never a receipt field). Quote the
95
+ posture banner verbatim when labeling this dispatch. A posture value carrying control bytes
96
+ refuses pre-spend in every mode.
88
97
 
89
98
  Environment: CODEX_REVIEW_SCHEMA=1 (structured JSON findings), CODEX_HARD_TIMEOUT (seconds, default 1800), CODEX_PROBE=1 (throwaway probe only), AW_REVIEW_RECEIPTS (receipt file override).
90
99
  Requires at run time: the codex CLI on PATH, a ChatGPT-subscription login, a git work tree with a root AGENTS.md (--help needs none of these).
@@ -201,12 +210,62 @@ aw_apply_settings() {
201
210
  }
202
211
  aw_apply_settings
203
212
 
213
+ # --- Effective-timeout resolver (D5 banner honesty; AD-061) --------------------
214
+ # ONE rule, both bridges: the posture banner prints EXACTLY the duration handed to timeout(1) —
215
+ # an integer-seconds value rendered with the `s` suffix, a duration string verbatim — and
216
+ # `timeout=uncapped` when no timeout/gtimeout binary can cap the run; never a fabricated number.
217
+ # The EFFECTIVE value (env included — closing the aw_settings_valid env bypass) is validated by
218
+ # the same per-key rule as the settings file, plus a 7-digit integer-part bound (overflow); an
219
+ # invalid value warns + falls back to the built-in default — a typo never silently masquerades
220
+ # as a cap. AGY_TIMEOUT shares AGY_HARD_TIMEOUT's duration rule (it has no settings-file arm).
221
+ aw_effective_timeout() {
222
+ local key="$1" default="$2" value="${!1:-}" rule="$1" intpart
223
+ [[ "$rule" == "AGY_TIMEOUT" ]] && rule="AGY_HARD_TIMEOUT"
224
+ [[ -n "$value" ]] || { printf '%s' "$default"; return 0; }
225
+ intpart="${value%%[!0-9]*}"
226
+ if ! aw_settings_valid "$rule" "$value" || (( ${#intpart} > 7 )); then
227
+ # %q escapes the raw value so a control byte in it can never forge an extra diagnostic line
228
+ # (the direct agy-run lane has no pre-spend screen — the warning itself must be injection-proof).
229
+ printf "warning: invalid value '%q' for %s — using the built-in default %s.\n" "$value" "$key" "$default" >&2
230
+ printf '%s' "$default"
231
+ return 0
232
+ fi
233
+ printf '%s' "$value"
234
+ }
235
+ aw_timeout_label() {
236
+ local bin="$1" value="$2"
237
+ [[ -n "$bin" ]] || { printf 'uncapped'; return 0; }
238
+ case "$value" in
239
+ *[!0-9]*) printf '%s' "$value" ;;
240
+ *) printf '%ss' "$value" ;;
241
+ esac
242
+ }
243
+ aw_resolve_timeout_bin() {
244
+ local bin dir base
245
+ bin="$(builtin type -P timeout 2>/dev/null || true)"
246
+ [[ -n "$bin" ]] || bin="$(builtin type -P gtimeout 2>/dev/null || true)"
247
+ [[ -n "$bin" ]] || { printf ''; return 0; }
248
+ case "$bin" in
249
+ /*) ;;
250
+ *)
251
+ case "$bin" in
252
+ */*) dir="${bin%/*}"; base="${bin##*/}" ;;
253
+ *) dir="."; base="$bin" ;;
254
+ esac
255
+ dir="$(builtin cd -- "$dir" 2>/dev/null && builtin pwd -P)" || { printf ''; return 0; }
256
+ bin="$dir/$base"
257
+ ;;
258
+ esac
259
+ [[ -f "$bin" && -x "$bin" ]] || { printf ''; return 0; }
260
+ printf '%s' "$bin"
261
+ }
262
+
204
263
  DEFAULT_CODEX_MODEL="gpt-5.6-sol"
205
264
  DEFAULT_CODEX_EFFORT="xhigh"
206
265
  # Review-receipt identity (AD-038). AW_BRIDGE_VERSION mirrors this bridge's SKILL.md/capability.json
207
266
  # version (drift-guarded by codex-review.test.mjs against capability.json).
208
267
  AW_RECEIPT_BACKEND="codex"
209
- AW_BRIDGE_VERSION="3.0.0"
268
+ AW_BRIDGE_VERSION="3.1.0"
210
269
  CODEX_MODEL="${CODEX_MODEL:-$DEFAULT_CODEX_MODEL}"
211
270
  CODEX_EFFORT="${CODEX_EFFORT:-$DEFAULT_CODEX_EFFORT}"
212
271
  # Generous hard cap for a slow xhigh review (subscription latency varies).
@@ -225,12 +284,13 @@ CODEX_SERVICE_TIER="${CODEX_SERVICE_TIER:-}"
225
284
  # D5 pre-spend control-byte screen — BEFORE tier validation (a malformed value is not a policy
226
285
  # question; validating first would echo the hostile value into a multiline warning and then run
227
286
  # on the standard tier, defeating the stated refusal). Screens the RAW model/effort/tier.
228
- for _posture_pair in "CODEX_MODEL=$CODEX_MODEL" "CODEX_EFFORT=$CODEX_EFFORT" "CODEX_SERVICE_TIER=$CODEX_SERVICE_TIER"; do
229
- if [[ "${_posture_pair#*=}" == *[$'\x01'-$'\x1f']* ]]; then
287
+ for _posture_pair in "CODEX_MODEL=$CODEX_MODEL" "CODEX_EFFORT=$CODEX_EFFORT" "CODEX_SERVICE_TIER=$CODEX_SERVICE_TIER" "CODEX_HARD_TIMEOUT=$CODEX_HARD_TIMEOUT"; do
288
+ if [[ "${_posture_pair#*=}" == *[$'\x01'-$'\x1f'$'\x7f']* ]]; then
230
289
  echo "error: ${_posture_pair%%=*} contains control bytes — fix the setting (env or bridge-settings.conf) and re-run." >&2
231
290
  exit 2
232
291
  fi
233
292
  done
293
+ CODEX_HARD_TIMEOUT="$(aw_effective_timeout CODEX_HARD_TIMEOUT 1800)"
234
294
  if [[ -n "$CODEX_SERVICE_TIER" ]] && ! aw_settings_valid CODEX_SERVICE_TIER "$CODEX_SERVICE_TIER"; then
235
295
  echo "warning: CODEX_SERVICE_TIER='$CODEX_SERVICE_TIER' is not a supported service tier ('priority') — running on the standard tier." >&2
236
296
  CODEX_SERVICE_TIER=""
@@ -243,8 +303,15 @@ CHATGPT_LOGIN_GUARD="Logged in using ChatGPT"
243
303
 
244
304
  # --- D5 banner (one line, the ACTUAL run posture) -------------------------------------------
245
305
  # The control-byte screen already ran above (BEFORE tier validation); the banner states the
246
- # EFFECTIVE posture (post tier validation) and the receipt records the same fields.
247
- echo "review posture: model=$CODEX_MODEL effort=$CODEX_EFFORT tier=${CODEX_SERVICE_TIER:-standard}" >&2
306
+ # EFFECTIVE posture (post tier validation) and the receipt records the same fields — except
307
+ # `timeout`, a BANNER-ONLY informational field (AD-061): it prints exactly the duration handed
308
+ # to timeout(1), or `uncapped` without a capping binary, and never enters the receipt.
309
+ # aw_resolve_timeout_bin: builtin type -P (an exported function can shadow neither `timeout` nor
310
+ # `type` itself), normalized to an ABSOLUTE path fail-closed; the dispatch below reuses this SAME
311
+ # resolved path (banner and run never make independent conclusions).
312
+ timeout_bin="$(aw_resolve_timeout_bin)"
313
+ aw_timeout_banner="$(aw_timeout_label "$timeout_bin" "$CODEX_HARD_TIMEOUT")"
314
+ echo "review posture: model=$CODEX_MODEL effort=$CODEX_EFFORT tier=${CODEX_SERVICE_TIER:-standard} timeout=$aw_timeout_banner" >&2
248
315
 
249
316
  # --- Quality-first guard: refuse a silent model/effort downgrade ---------------
250
317
  # A relaxed run is RECORDED, not just warned about: the receipt carries probe:true so the kit's
@@ -663,12 +730,8 @@ fence_env=(env
663
730
  CODEX_HOME="$real_codex_home")
664
731
 
665
732
  # --- Hard wall-clock cap via timeout(1) (gtimeout on macOS) -------------------
666
- timeout_bin=""
667
- if command -v timeout >/dev/null 2>&1; then
668
- timeout_bin="timeout"
669
- elif command -v gtimeout >/dev/null 2>&1; then
670
- timeout_bin="gtimeout"
671
- fi
733
+ # $timeout_bin was resolved ONCE (absolute path, type -P) at the banner emit above — the run
734
+ # reuses the exact binary the banner reported.
672
735
  if [[ -z "$timeout_bin" ]]; then
673
736
  echo "warning: no 'timeout'/'gtimeout' on PATH — running codex WITHOUT a hard wall-clock cap" >&2
674
737
  echo " (install coreutils to enable CODEX_HARD_TIMEOUT=$CODEX_HARD_TIMEOUT)." >&2
@@ -732,6 +732,12 @@ describe('codex-review.sh — --help contract (manifest-pinned)', () => {
732
732
  assert.equal(norm(helpSection(help, 'Receipt:').join(' ')), norm(REVIEW_CONTRACT.receipt));
733
733
  assert.match(REVIEW_CONTRACT.receipt, /sha256 over the canonical uncommitted-state payload/, 'the fingerprint definition lives in the manifest contract');
734
734
  });
735
+
736
+ it('Notes renders the manifest review contract.notes verbatim (AD-061 — a typed contract key that MUST surface)', () => {
737
+ const help = runHelp('--help').stdout;
738
+ assert.ok((REVIEW_CONTRACT.notes ?? []).length >= 2, 'the review contract declares the banner-only-timeout + quote-verbatim notes');
739
+ assert.equal(norm(helpSection(help, 'Notes:').join(' ')), norm(REVIEW_CONTRACT.notes.join(' ')));
740
+ });
735
741
  });
736
742
 
737
743
  describe('codex-review.sh — source-level reverse guard (parser arms ⟷ manifest)', () => {
@@ -1325,4 +1331,45 @@ describe('codex-review.sh — dispatch-posture labeling (D5)', () => {
1325
1331
  assert.equal(receipts.length, 0);
1326
1332
  assert.match(r.stderr, /control/i, 'named as the control-byte class, not a policy refusal');
1327
1333
  });
1334
+
1335
+ it('the banner appends the RESOLVED hard timeout — banner-only, never in the receipt (AD-061)', () => {
1336
+ const sb = makeSandbox();
1337
+ const r = run(sb, {});
1338
+ const receipts = readReceipts(sb.repo);
1339
+ rmSync(sb.root, { recursive: true, force: true });
1340
+ assert.equal(r.status, 0, r.stderr);
1341
+ assert.match(r.stderr, /^review posture: model=gpt-5\.6-sol effort=xhigh tier=standard timeout=1800s$/m);
1342
+ assert.deepEqual(Object.keys(receipts[0].posture), ['model', 'effort', 'tier'], 'timeout never enters the receipt posture');
1343
+ });
1344
+
1345
+ it('an INVALID effective CODEX_HARD_TIMEOUT (env — the closed aw_settings_valid bypass) warns and the banner prints the default', () => {
1346
+ const sb = makeSandbox();
1347
+ const r = run(sb, { env: { CODEX_HARD_TIMEOUT: 'nonsense' } });
1348
+ rmSync(sb.root, { recursive: true, force: true });
1349
+ assert.equal(r.status, 0, r.stderr);
1350
+ assert.match(r.stderr, /invalid value 'nonsense' for CODEX_HARD_TIMEOUT/, 'the fallback is loud');
1351
+ assert.match(r.stderr, /^review posture: .* timeout=1800s$/m, 'the banner prints the built-in default');
1352
+ });
1353
+
1354
+ it('a CODEX_HARD_TIMEOUT carrying CONTROL BYTES refuses pre-spend (the banner-field screen)', () => {
1355
+ const sb = makeSandbox();
1356
+ const r = run(sb, { env: { CODEX_HARD_TIMEOUT: `1800${String.fromCharCode(1)}` } });
1357
+ const receipts = readReceipts(sb.repo);
1358
+ rmSync(sb.root, { recursive: true, force: true });
1359
+ assert.notEqual(r.status, 0);
1360
+ assert.equal(r.capStdin, '', 'codex is never invoked');
1361
+ assert.equal(receipts.length, 0);
1362
+ assert.match(r.stderr, /control/i);
1363
+ });
1364
+
1365
+ it('a DEL (0x7f) byte in a banner field refuses pre-spend like the C0 range', () => {
1366
+ const sb = makeSandbox();
1367
+ const r = run(sb, { env: { CODEX_MODEL: `gpt-5.6-sol${String.fromCharCode(127)}` } });
1368
+ const receipts = readReceipts(sb.repo);
1369
+ rmSync(sb.root, { recursive: true, force: true });
1370
+ assert.notEqual(r.status, 0);
1371
+ assert.equal(r.capStdin, '', 'codex is never invoked');
1372
+ assert.equal(receipts.length, 0);
1373
+ assert.match(r.stderr, /control/i);
1374
+ });
1328
1375
  });