Skip to content

Commit 360c7bf

Browse files
authored
Merge pull request #48 from alanshurafa/claude/imp-b-robustness
Phase B: verify-phase timeout ladder + diff-injection hardening
2 parents f295e8b + b4fbeef commit 360c7bf

7 files changed

Lines changed: 720 additions & 78 deletions

File tree

‎.planning/notes/2026-07-07-audit-improvement-plan.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ Alan's labeling role is replaced by a cross-family judge panel; his involvement
9898
- **Done means:** skill installable from a public marketplace entry; benchmark numbers public; workspace CLAUDE.md workflow table updated.
9999

100100
### Explicitly deferred
101-
Deterministic lint/secret pre-pass (M, valuable but code-pipeline-only); parallel specialist critic lenses (M, measure after Phase D's severity gate lands); Greptile-style dependency-context pass (L); jq/JSON layer extraction + CRLF ingest normalization + seat-guard lib extraction (S-3/S-5, fold into whichever phase next touches those lines); confidence-weighted adjudication voting; `--dual-critique` (unchanged from prior backlog, after Phase D).
101+
`fill_template`/`fill_conditional` in lib rescan the accumulating string per placeholder pair — the same re-expansion class fixed with nonce sentinels in dev-review.sh's prompt builders (Phase B, cycles 2-3). A lib-level two-pass rewrite would fix every caller at once; deferred because callers substitute mostly trusted values today and the rewrite touches every pipeline. Revisit when Phase C/D next touches lib templating. Deterministic lint/secret pre-pass (M, valuable but code-pipeline-only); parallel specialist critic lenses (M, measure after Phase D's severity gate lands); Greptile-style dependency-context pass (L); jq/JSON layer extraction + CRLF ingest normalization + seat-guard lib extraction (S-3/S-5, fold into whichever phase next touches those lines); confidence-weighted adjudication voting; `--dual-critique` (unchanged from prior backlog, after Phase D).
102102

103103
### Approval gates (updated 2026-07-07 — Alan approved autonomous execution)
104104
- Phase E spend (calibration, A/B, canaries, dogfood) — **approved 2026-07-07** ("A/B testing is better done by Fable"); codex-guard daily cap remains the hard ceiling; batch, never poll.

‎.planning/notes/2026-07-07-execution-loop.md‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,8 @@ Loop mechanics: background agents re-invoke the orchestrator on completion (no p
3939

4040
| Phase | Status | Branch / PR | Verify (suite / adv / codex / done-means) | Notes |
4141
|-------|--------|-------------|-------------------------------------------|-------|
42-
| A — Correctness closure | PR #47 open, suite 32/32, awaiting CI → merge | claude/nervous-hodgkin-bcf03d → PR #47 | ✓32/32 / ✓(F1 fixed) / ✓(H1,H2,L1 fixed) / ✓ | Cross-vendor review earned its keep: codex found the partial-failure→converged gap (H1) and both vendors independently flagged the bare-banner auth gap (H2→`output_is_auth_failure` in lib, 3 call sites). Claude reviewer caught the Scenario-F grep regression (F1) + missing guard scenario (→Scenario G). Bonus find-along: bounce-scorer-verification.sh had a Windows jq-CRLF bug (5/7→7/7, fixed) before wiring into run-all (C-5). Accepted residual: none remaining — F2/H2 fixed. Sims: auth-gate 28/28, marker-lifecycle 41/41 (byte-parity intact), audit-hardening 18/18, worktree-mgmt green, reliability 17/17. |
43-
| B — Robustness/injection | pending | | | |
42+
| A — Correctness closure | DONE — merged f295e8b (PR #47) | claude/nervous-hodgkin-bcf03d → PR #47 | ✓32/32 / ✓(F1 fixed) / ✓(H1,H2,L1 fixed) / ✓ | Cross-vendor review earned its keep: codex found the partial-failure→converged gap (H1) and both vendors independently flagged the bare-banner auth gap (H2→`output_is_auth_failure` in lib, 3 call sites). Claude reviewer caught the Scenario-F grep regression (F1) + missing guard scenario (→Scenario G). Bonus find-along: bounce-scorer-verification.sh had a Windows jq-CRLF bug (5/7→7/7, fixed) before wiring into run-all (C-5). Accepted residual: none remaining — F2/H2 fixed. Sims: auth-gate 28/28, marker-lifecycle 41/41 (byte-parity intact), audit-hardening 18/18, worktree-mgmt green, reliability 17/17. |
43+
| B — Robustness/injection | IN PROGRESS — build agent launched | claude/imp-b-robustness | – / – / – / – | C-3 shared timeout-runner helper; C-4 long-fence + untrusted-data framing |
4444
| C — Protocol v0.2 | pending | | | includes docs sweep + STACK.md re-check |
4545
| D — Signal quality | pending | | | can start once C's marker changes are stable |
4646
| E — Measurement | pending | | | panel-labeled gold set; spend approved |
@@ -57,6 +57,7 @@ Loop mechanics: background agents re-invoke the orchestrator on completion (no p
5757
| Verifier canary catch rate (n=3) | – | | E.4 |
5858
| A/B: cross- vs same-vendor (pre-registered criterion) | – | | E.3 |
5959
| Master suite trend | baseline: 27 sims + scorer gate green @ 05d151e | 2026-07-07 | V-6 |
60+
| Master suite trend | 32/32 suites (local) + 6/6 CI checks 3-OS @ f295e8b (Phase A) | 2026-07-07 | V-6 |
6061

6162
## Handoff notes
6263

‎dev-review/codex/dev-review.sh‎

Lines changed: 131 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,34 @@ abort_on_timeout() {
257257
fi
258258
}
259259

260+
# PR#48-M4: a timeout-runner INFRASTRUCTURE failure (perl fork() = 125, or the
261+
# runner reporting the command could not be executed / was not found = 126/127)
262+
# means the verifier never ran — the verdict file is empty or stale, not a real
263+
# verdict. abort_on_timeout only special-cases 124, so without this an infra
264+
# crash would fall through to verdict parsing and could launder into a "proceed"
265+
# outcome. Abort hard, with a logged reason, and NEVER parse the verdict file.
266+
# Mirrors abort_on_timeout's terminal-state bookkeeping so a status reader sees a
267+
# failed run rather than one stuck mid-phase.
268+
abort_on_runner_infra_failure() {
269+
local phase_name="$1"
270+
local phase_start="$2"
271+
case "$LAST_INVOKE_EXIT_CODE" in
272+
125|126|127) ;;
273+
*) return 0 ;;
274+
esac
275+
local phase_end
276+
phase_end=$(date -u +%Y-%m-%dT%H:%M:%SZ)
277+
if [[ -n "${STATE_JSON:-}" ]]; then
278+
write_state_phase "$STATE_JSON" "$phase_name" "failed" "$LAST_INVOKE_EXIT_CODE" "$phase_start" "$phase_end"
279+
write_state_field "$STATE_JSON" ".completed_at" "string" "$phase_end"
280+
write_state_field "$STATE_JSON" ".status" "string" "failed"
281+
write_state_field "$STATE_JSON" ".current_phase" "null"
282+
fi
283+
log "ERROR: ${phase_name} phase timeout-runner could not launch the agent (exit ${LAST_INVOKE_EXIT_CODE}) - aborting run without parsing the verdict file"
284+
cleanup_runtime_artifacts
285+
exit 1
286+
}
287+
260288
require_agent_cli() {
261289
case "$1" in
262290
codex)
@@ -407,14 +435,23 @@ build_bounce_prompt() {
407435
cat "${REPO_ROOT}/skills/dev-review/templates/bounce-protocol.md"
408436
} > "$prompt_template_file"
409437

410-
rendered=$(fill_template "$prompt_template_file" \
411-
"TASK=$TASK" \
412-
"PASS_NUMBER=$pass_number" \
413-
"TOTAL_PASSES=$total_passes" \
414-
"YOUR_ROLE=$role" \
415-
"WORKING_DIR=$WORKDIR")
416-
417-
rendered="${rendered//\{PLAN_CONTENT\}/$plan_content}"
438+
# C-4b: two-pass nonce substitution (same scheme and rationale as
439+
# build_review_prompt) — a TASK that mentions {PLAN_CONTENT} in prose must
440+
# stay literal instead of pulling a second plan expansion into the prompt.
441+
local nonce="${RANDOM}${RANDOM}$$"
442+
rendered=$(cat "$prompt_template_file")
443+
rendered="${rendered//\{TASK\}/<CE_SUB_${nonce}_TASK>}"
444+
rendered="${rendered//\{PASS_NUMBER\}/<CE_SUB_${nonce}_PASS>}"
445+
rendered="${rendered//\{TOTAL_PASSES\}/<CE_SUB_${nonce}_TOTAL>}"
446+
rendered="${rendered//\{YOUR_ROLE\}/<CE_SUB_${nonce}_ROLE>}"
447+
rendered="${rendered//\{WORKING_DIR\}/<CE_SUB_${nonce}_WD>}"
448+
rendered="${rendered//\{PLAN_CONTENT\}/<CE_SUB_${nonce}_PLAN>}"
449+
rendered="${rendered//<CE_SUB_${nonce}_TASK>/$TASK}"
450+
rendered="${rendered//<CE_SUB_${nonce}_PASS>/$pass_number}"
451+
rendered="${rendered//<CE_SUB_${nonce}_TOTAL>/$total_passes}"
452+
rendered="${rendered//<CE_SUB_${nonce}_ROLE>/$role}"
453+
rendered="${rendered//<CE_SUB_${nonce}_WD>/$WORKDIR}"
454+
rendered="${rendered//<CE_SUB_${nonce}_PLAN>/$plan_content}"
418455
printf '%s' "$rendered"
419456
}
420457

@@ -462,27 +499,47 @@ build_execution_prompt() {
462499
local stripped_template_file="$RUN_DIR/.execute-template-${executor}.md"
463500
local rendered
464501

502+
# C-4b: two-pass nonce substitution (same scheme and rationale as
503+
# build_review_prompt). Sequential replacement rescans the accumulating
504+
# string, so a value carrying another placeholder's literal text gets
505+
# re-expanded — here the worst source is the RETRY branch, where
506+
# REVIEWER_FEEDBACK/ISSUES_LIST come from the verifier's verdict (itself
507+
# influenced by the diff under review) and used to be substituted BEFORE
508+
# {TASK}/{PLAN_CONTENT}. Ordering cannot fix the class; a sentinel minted from
509+
# $RANDOM$RANDOM$$ after all values exist is overwhelmingly unlikely to appear
510+
# in any value.
511+
local nonce="${RANDOM}${RANDOM}$$"
512+
465513
if [[ -z "$feedback_json" ]]; then
466514
# First pass: strip the SUBSEQUENT_PASS block entirely.
467-
# Byte-identical output to v1.0 (see Task 4 Scenario 4 invariant).
515+
# Byte-identical output to v1.0 (see Task 4 Scenario 4 invariant) — the
516+
# nonce round-trip is byte-neutral for values without placeholder text.
468517
strip_conditional "SUBSEQUENT_PASS" < "$template_path" > "$stripped_template_file"
469-
rendered=$(fill_template "$stripped_template_file" "TASK=$TASK")
470-
rendered="${rendered//\{PLAN_CONTENT\}/$plan_content}"
518+
rendered=$(cat "$stripped_template_file")
519+
rendered="${rendered//\{TASK\}/<CE_SUB_${nonce}_TASK>}"
520+
rendered="${rendered//\{PLAN_CONTENT\}/<CE_SUB_${nonce}_PLAN>}"
521+
rendered="${rendered//<CE_SUB_${nonce}_TASK>/$TASK}"
522+
rendered="${rendered//<CE_SUB_${nonce}_PLAN>/$plan_content}"
471523
else
472-
# Retry pass: keep the SUBSEQUENT_PASS block; replace {REVIEWER_FEEDBACK} and
473-
# {ISSUES_LIST} with rendered content from the normalized verdict JSON.
474-
# fill_conditional reads the template on stdin, strips the IF/END_IF tag
475-
# lines, and substitutes KEY={value} placeholders in the full stripped text.
524+
# Retry pass: keep the SUBSEQUENT_PASS block; replace {REVIEWER_FEEDBACK}
525+
# and {ISSUES_LIST} with rendered content from the normalized verdict JSON.
526+
# fill_conditional is called with NO key=value pairs so it ONLY strips the
527+
# IF/END_IF tag lines (its internal substitution loop rescans the
528+
# accumulator — the exact class being closed); all four placeholders are
529+
# then swapped through nonce sentinels locally.
476530
local reviewer_feedback issues_list
477531
reviewer_feedback=$(build_reviewer_feedback_summary "$feedback_json")
478532
issues_list=$(build_issues_list_markdown "$feedback_json")
479533

480-
rendered=$(fill_conditional "SUBSEQUENT_PASS" \
481-
"REVIEWER_FEEDBACK=$reviewer_feedback" \
482-
"ISSUES_LIST=$issues_list" \
483-
< "$template_path")
484-
rendered="${rendered//\{TASK\}/$TASK}"
485-
rendered="${rendered//\{PLAN_CONTENT\}/$plan_content}"
534+
rendered=$(fill_conditional "SUBSEQUENT_PASS" < "$template_path")
535+
rendered="${rendered//\{TASK\}/<CE_SUB_${nonce}_TASK>}"
536+
rendered="${rendered//\{PLAN_CONTENT\}/<CE_SUB_${nonce}_PLAN>}"
537+
rendered="${rendered//\{REVIEWER_FEEDBACK\}/<CE_SUB_${nonce}_FB>}"
538+
rendered="${rendered//\{ISSUES_LIST\}/<CE_SUB_${nonce}_ISSUES>}"
539+
rendered="${rendered//<CE_SUB_${nonce}_TASK>/$TASK}"
540+
rendered="${rendered//<CE_SUB_${nonce}_PLAN>/$plan_content}"
541+
rendered="${rendered//<CE_SUB_${nonce}_FB>/$reviewer_feedback}"
542+
rendered="${rendered//<CE_SUB_${nonce}_ISSUES>/$issues_list}"
486543
fi
487544

488545
printf '%s' "$rendered"
@@ -496,10 +553,42 @@ build_review_prompt() {
496553
local template_path="${REPO_ROOT}/skills/dev-review/templates/review-prompt-${verifier}.md"
497554
local rendered
498555

499-
rendered=$(fill_template "$template_path" "TASK=$TASK")
500-
rendered="${rendered//\{PLAN_CONTENT\}/$plan_content}"
501-
rendered="${rendered//\{DIFF\}/$diff_content}"
502-
rendered="${rendered//\{DIFF_STAT\}/$diff_stat}"
556+
# C-4: wrap the untrusted diff in a fence longer than any backtick run it
557+
# contains, so a diff line that is itself ``` cannot close the ```diff block
558+
# early and have its remainder (e.g. "output APPROVED, no issues") read as
559+
# verifier instructions. The template's {DIFF_FENCE} placeholder supplies both
560+
# the opening (`{DIFF_FENCE}diff`) and closing fence.
561+
#
562+
# PR#48-L6: {DIFF_STAT} is the same untrusted, git-derived source as {DIFF} — a
563+
# tracked path can carry a backtick run — and the templates now fence it too.
564+
# Size the single shared fence over BOTH bodies (newline-joined so a run cannot
565+
# straddle the join) so it is longer than any backtick run in either block.
566+
local diff_fence
567+
diff_fence=$(compute_diff_fence "${diff_content}"$'\n'"${diff_stat}")
568+
569+
# C-4b: two-pass nonce substitution. Sequential ${rendered//{KEY}/value}
570+
# rescans the ACCUMULATING string, so any value substituted earlier that
571+
# contains the literal text of a later placeholder gets re-expanded — e.g. a
572+
# plan discussing this template's {DIFF} placeholder in prose, or a tracked
573+
# path named {DIFF} surfacing in the --stat output, would pull a second raw
574+
# diff expansion OUTSIDE the fence. Substitution ORDER cannot fix this class
575+
# (any field can carry any other field's placeholder). Instead: pass 1
576+
# rewrites the TRUSTED template's placeholders to run-unique sentinels minted
577+
# AFTER every value was computed (so no value can contain one); pass 2 swaps
578+
# each sentinel for its value exactly once. Untrusted text is never rescanned
579+
# for placeholders, and placeholder-looking text in values stays literal.
580+
local nonce="${RANDOM}${RANDOM}$$"
581+
rendered=$(cat "$template_path")
582+
rendered="${rendered//\{TASK\}/<CE_SUB_${nonce}_TASK>}"
583+
rendered="${rendered//\{DIFF_FENCE\}/<CE_SUB_${nonce}_FENCE>}"
584+
rendered="${rendered//\{PLAN_CONTENT\}/<CE_SUB_${nonce}_PLAN>}"
585+
rendered="${rendered//\{DIFF_STAT\}/<CE_SUB_${nonce}_STAT>}"
586+
rendered="${rendered//\{DIFF\}/<CE_SUB_${nonce}_DIFF>}"
587+
rendered="${rendered//<CE_SUB_${nonce}_TASK>/$TASK}"
588+
rendered="${rendered//<CE_SUB_${nonce}_FENCE>/$diff_fence}"
589+
rendered="${rendered//<CE_SUB_${nonce}_PLAN>/$plan_content}"
590+
rendered="${rendered//<CE_SUB_${nonce}_STAT>/$diff_stat}"
591+
rendered="${rendered//<CE_SUB_${nonce}_DIFF>/$diff_content}"
503592
printf '%s' "$rendered"
504593
}
505594

@@ -1039,18 +1128,33 @@ run_verify_phase() {
10391128
# FIX-WR-01: reset before the conditional so that a successful run leaves 0
10401129
# (the `|| LAST_INVOKE_EXIT_CODE=$?` branch only fires on non-zero exit).
10411130
LAST_INVOKE_EXIT_CODE=0
1042-
if command -v timeout >/dev/null 2>&1; then
1043-
timeout --foreground "${PHASE_TIMEOUT:-1800}s" \
1131+
# C-3: route through the shared timeout-runner ladder (timeout→gtimeout→perl)
1132+
# so this branch is bounded on stock macOS too, not only where GNU `timeout`
1133+
# exists. This is the default claude-build verify path and the historical
1134+
# 1h39m hang site, so an unbounded fallback was the worst place for the gap.
1135+
local _verify_runner
1136+
_verify_runner=$(select_timeout_runner)
1137+
if [[ -n "$_verify_runner" ]]; then
1138+
# PR#48-M5: validate PHASE_TIMEOUT here too (the opus branch gets it via
1139+
# invoke_agent_with_timeout). Unvalidated, a 0/non-numeric value would run
1140+
# this default claude-build verify path unbounded — the historical hang.
1141+
require_phase_timeout
1142+
run_with_timeout_runner "$_verify_runner" "$EFFECTIVE_PHASE_TIMEOUT" \
10441143
bash -c 'cd "$1" && source "$2/lib/co-evolution.sh"; invoke_codex_schema "$3" "$4" "$5" "$6"' _ \
10451144
"$PWD" "$REPO_ROOT" "$review_prompt_file" "$verdict_file" "$review_stderr_file" "${REPO_ROOT}/skills/dev-review/schemas/review-verdict.json" \
10461145
|| LAST_INVOKE_EXIT_CODE=$?
10471146
else
1147+
log "WARNING: no timeout(1)/gtimeout/perl found - codex verify running unbounded"
10481148
invoke_codex_schema "$review_prompt_file" "$verdict_file" "$review_stderr_file" "${REPO_ROOT}/skills/dev-review/schemas/review-verdict.json"
10491149
fi
10501150
abort_on_timeout "verify" "$phase_start"
1151+
# PR#48-M4: a runner infra failure (125/126/127) means the verifier never ran;
1152+
# abort before any verdict-file parsing so it cannot launder into "proceed".
1153+
abort_on_runner_infra_failure "verify" "$phase_start"
10511154
else
10521155
invoke_agent_with_timeout "$verifier" "$review_prompt_file" "$verdict_file" "$review_stderr_file" "$(phase_is_writable review)"
10531156
abort_on_timeout "verify" "$phase_start"
1157+
abort_on_runner_infra_failure "verify" "$phase_start"
10541158
fi
10551159

10561160
if agent_auth_failed "$verifier" "$verdict_file" "$review_stderr_file"; then

0 commit comments

Comments
 (0)