@@ -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+
260288require_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