@kontourai/flow-agents 3.2.0 → 3.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.
Files changed (50) hide show
  1. package/.github/workflows/ci.yml +12 -0
  2. package/CHANGELOG.md +13 -0
  3. package/build/src/cli/assignment-provider.js +10 -1
  4. package/build/src/cli/workflow-artifact-cleanup-audit.js +418 -11
  5. package/build/src/cli/workflow-sidecar.d.ts +224 -0
  6. package/build/src/cli/workflow-sidecar.js +775 -4
  7. package/build/src/tools/validate-source-tree.js +3 -2
  8. package/context/contracts/artifact-contract.md +16 -2
  9. package/context/scripts/hooks/workflow-steering.js +73 -1
  10. package/docs/coordination-guide.md +370 -0
  11. package/docs/decisions/agent-coordination.md +26 -9
  12. package/docs/decisions/index.md +2 -2
  13. package/docs/decisions/trust-reconcile.md +42 -9
  14. package/docs/fixture-ownership.md +3 -2
  15. package/docs/index.md +4 -0
  16. package/docs/integrations/flow-agents-console.md +108 -0
  17. package/docs/integrations/index.md +4 -0
  18. package/docs/workflow-artifact-lifecycle.md +38 -1
  19. package/evals/ci/antigaming-suite.sh +1 -0
  20. package/evals/ci/run-baseline.sh +6 -0
  21. package/evals/fixtures/reconcile-preflight/disputed-critique-unsuperseded.json +48 -0
  22. package/evals/fixtures/reconcile-preflight/standalone-disputed-session-local.json +59 -0
  23. package/evals/integration/test_checkpoint_signing.sh +10 -2
  24. package/evals/integration/test_ci_actor_identity.sh +221 -0
  25. package/evals/integration/test_fixture_retirement_audit.sh +2 -2
  26. package/evals/integration/test_publish_delivery.sh +59 -2
  27. package/evals/integration/test_reconcile_preflight.sh +304 -0
  28. package/evals/integration/test_takeover_protocol.sh +340 -0
  29. package/evals/integration/test_trust_reconcile_negatives.sh +91 -0
  30. package/evals/integration/test_verify_hold.sh +910 -0
  31. package/evals/integration/test_veritas_governance_kit.sh +257 -0
  32. package/evals/integration/test_workflow_artifact_cleanup_audit.sh +575 -3
  33. package/evals/run.sh +8 -0
  34. package/kits/builder/skills/continue-work/SKILL.md +2 -0
  35. package/kits/builder/skills/deliver/SKILL.md +73 -0
  36. package/kits/builder/skills/pull-work/SKILL.md +12 -2
  37. package/kits/veritas-governance/docs/README.md +81 -3
  38. package/kits/veritas-governance/fixtures/exemption/approved.trust-bundle.json +74 -0
  39. package/kits/veritas-governance/fixtures/exemption/not-approved.trust-bundle.json +74 -0
  40. package/kits/veritas-governance/flows/exemption-issuance.flow.json +35 -0
  41. package/kits/veritas-governance/kit.json +5 -0
  42. package/package.json +1 -1
  43. package/scripts/ci/trust-reconcile.js +78 -253
  44. package/scripts/hooks/lib/actor-identity.js +82 -0
  45. package/scripts/hooks/workflow-steering.js +73 -1
  46. package/scripts/lib/reconcile-shape.js +381 -0
  47. package/src/cli/assignment-provider.ts +12 -1
  48. package/src/cli/workflow-artifact-cleanup-audit.ts +483 -10
  49. package/src/cli/workflow-sidecar.ts +866 -4
  50. package/src/tools/validate-source-tree.ts +3 -2
@@ -0,0 +1,340 @@
1
+ #!/usr/bin/env bash
2
+ # test_takeover_protocol.sh — takeover protocol integration eval (issue #294, ADR 0021 §5).
3
+ #
4
+ # Exercises `runTakeoverPreflight()`/`takeover-preflight` CLI subcommand and its companion
5
+ # `ensure-session --supersede-stale` takeover-as-resumption path (src/cli/workflow-sidecar.ts),
6
+ # reusing the SAME assignment ⋈ liveness join #290/#291 already built (computeEffectiveState/
7
+ # readLocalAssignmentStatus/freshHolders) — no second computation is invented here.
8
+ #
9
+ # Follows evals/integration/test_verify_hold.sh's harness conventions VERBATIM (set -uo
10
+ # pipefail / ROOT / node.sh / WRITER / TMPDIR_EVAL / trap / ARTIFACT_ROOT / pass|fail|errors /
11
+ # json_query / flow_agents_node) and reuses its write_assignment_record / append_liveness_event
12
+ # fixture-writer helpers verbatim — no new plumbing invented where an existing helper already
13
+ # does the job.
14
+ #
15
+ # Sections (1-to-1 with the plan's acceptance criteria):
16
+ # 1. AC1 — takeover offered + continues the incumbent's branch (the headline): a STALE record
17
+ # with a specific branch resolves `grace-then-supersede`/`reclaimable`; `--supersede-stale`
18
+ # as a different successor prints SupersedeTakeover with resumed_branch == the incumbent's
19
+ # branch (NOT a new branch) and records a `supersede` audit entry ("resuming from trust
20
+ # bundle").
21
+ # 2. AC2a — revive during grace -> back off: a fresh incumbent liveness heartbeat flips the
22
+ # same fixture to `back-off`/`held`/ok:false; a follow-on `--supersede-stale` REFUSES.
23
+ # 3. AC2b — a superseded incumbent is blocked at publish (compose with verify-hold, #293): the
24
+ # woken zombie incumbent's verify-hold on the AC1 subject exits non-zero.
25
+ # 4. free / self / human quick cases: no record -> `claim`/free; self+fresh -> `proceed`.
26
+ # 5. Injection discipline: hostile control bytes + >64 chars in actor_key/branch never leak raw
27
+ # into holder.actor / resume_branch, and are length-capped (<=64). Modeled on
28
+ # test_verify_hold.sh's AC7 / section-6 injection check.
29
+ #
30
+ # Deterministic, no model spend, self-cleaning, no network, no `gh` process anywhere.
31
+ # Usage: bash evals/integration/test_takeover_protocol.sh
32
+
33
+ set -uo pipefail
34
+
35
+ ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
36
+ source "$ROOT/evals/lib/node.sh"
37
+
38
+ CLI="$ROOT/build/src/cli.js"
39
+ WRITER="workflow-sidecar"
40
+
41
+ TMPDIR_EVAL="$(mktemp -d)"
42
+ trap 'rm -rf "$TMPDIR_EVAL"' EXIT
43
+ ARTIFACT_ROOT="$TMPDIR_EVAL/artifact-root"
44
+
45
+ errors=0
46
+ pass() { echo " [PASS] $1"; }
47
+ fail() { echo " [FAIL] $1"; errors=$((errors + 1)); }
48
+
49
+ json_query() {
50
+ node -e 'const fs=require("fs"); let cur=JSON.parse(fs.readFileSync(process.argv[1],"utf8")); for (const part of process.argv[2].split(".")) cur=part==="length" ? cur.length : (Array.isArray(cur) ? cur[Number(part)] : cur[part]); console.log(cur);' "$1" "$2"
51
+ }
52
+
53
+ # append_liveness_event <root> <subjectId> <actor> <at-iso> [ttlSeconds]
54
+ # Reused verbatim from test_verify_hold.sh's own helper.
55
+ append_liveness_event() {
56
+ local root="$1" subject="$2" actor="$3" at="$4" ttl="${5:-1800}"
57
+ mkdir -p "$root/liveness"
58
+ node -e '
59
+ const fs = require("fs");
60
+ const evt = { type: "claim", subjectId: process.argv[1], actor: process.argv[2], at: process.argv[3], ttlSeconds: Number(process.argv[4]) };
61
+ fs.appendFileSync(process.argv[5], JSON.stringify(evt) + "\n");
62
+ ' "$subject" "$actor" "$at" "$ttl" "$root/liveness/events.jsonl"
63
+ }
64
+
65
+ # write_assignment_record <artifactRoot> <slug> <sessionId> <actorKey> [claimedAt]
66
+ # Reused verbatim from test_verify_hold.sh's own helper. Writes a minimal, well-formed,
67
+ # ALREADY-CLAIMED assignment record directly (branch:"main", claimed_at defaults to now). For a
68
+ # staleness fixture with NO liveness event, computeEffectiveState classifies it reclaimable.
69
+ write_assignment_record() {
70
+ local root="$1" slug="$2" sessionId="$3" actorKey="$4" claimedAt="${5:-}"
71
+ mkdir -p "$root/assignment"
72
+ node -e '
73
+ const fs = require("fs");
74
+ const [dest, slug, sessionId, actorKey, claimedAtOverride] = process.argv.slice(1);
75
+ const now = new Date().toISOString();
76
+ const claimedAt = claimedAtOverride || now;
77
+ const actor = { runtime: "claude-code", session_id: sessionId, host: "eval-host" };
78
+ const rec = {
79
+ schema_version: "1.0", role: "AssignmentClaimRecord", subject_id: slug,
80
+ actor, actor_key: actorKey, claimed_at: claimedAt, ttl_seconds: 1800, branch: "main",
81
+ artifact_dir: slug, status: "claimed",
82
+ audit_trail: [{ at: now, transition: "claim", from_actor: null, to_actor: actor, reason: "claim" }],
83
+ };
84
+ fs.writeFileSync(dest, JSON.stringify(rec, null, 2));
85
+ ' "$root/assignment/$slug.json" "$slug" "$sessionId" "$actorKey" "$claimedAt"
86
+ }
87
+
88
+ # patch_record_branch <assignmentRecord.json> <branch> — read/modify/write the record's `branch`
89
+ # field so takeover-preflight/SupersedeTakeover surface a SPECIFIC resume branch (write_assignment_record
90
+ # always sets branch:"main"). This is the incumbent's branch the successor must CONTINUE (AC1).
91
+ patch_record_branch() {
92
+ node -e '
93
+ const fs = require("fs");
94
+ const [file, branch] = process.argv.slice(1);
95
+ const rec = JSON.parse(fs.readFileSync(file, "utf8"));
96
+ rec.branch = branch;
97
+ fs.writeFileSync(file, JSON.stringify(rec, null, 2));
98
+ ' "$1" "$2"
99
+ }
100
+
101
+ if [[ ! -f "$CLI" ]]; then
102
+ echo "build/src/cli.js not found — run 'npm run build' first" >&2
103
+ exit 1
104
+ fi
105
+ flow_agents_build_ts || { echo "build failed" >&2; exit 1; }
106
+
107
+ echo "=== takeover protocol (#294) ==="
108
+
109
+ # ─── 1. AC1 — takeover offered + continues the incumbent's branch (the headline) ───────────
110
+ echo "--- 1. AC1: stale claim -> grace-then-supersede; --supersede-stale continues the incumbent's branch (not a new one) ---"
111
+
112
+ AC1_SLUG="takeover-ac1-resume-branch"
113
+ AC1_DIR="$ARTIFACT_ROOT/$AC1_SLUG"
114
+ INCUMBENT_ACTOR="eval-actor-incumbent-a"
115
+ SUCCESSOR_ACTOR="eval-actor-successor"
116
+ AC1_BRANCH="agent/a/$AC1_SLUG"
117
+ AC1_RECORD="$ARTIFACT_ROOT/assignment/$AC1_SLUG.json"
118
+
119
+ # Seed a STALE reclaimable record: write_assignment_record (branch:"main", claimed_at=now) then
120
+ # patch its branch to the incumbent's real branch. NO liveness event -> no fresh heartbeat, so
121
+ # the join classifies it reclaimable (assignment_present_liveness_stale_or_absent).
122
+ write_assignment_record "$ARTIFACT_ROOT" "$AC1_SLUG" "incumbent-a-session" "$INCUMBENT_ACTOR"
123
+ patch_record_branch "$AC1_RECORD" "$AC1_BRANCH"
124
+ mkdir -p "$AC1_DIR"
125
+
126
+ if flow_agents_node "$WRITER" takeover-preflight "$AC1_DIR" --actor "$SUCCESSOR_ACTOR" \
127
+ >"$TMPDIR_EVAL/tp-ac1.out" 2>"$TMPDIR_EVAL/tp-ac1.err"; then
128
+ pass "takeover-preflight on a reclaimable subject exits 0 (ok:true — a takeover is offered) (AC1)"
129
+ else
130
+ fail "takeover-preflight on a reclaimable subject should have exited 0: $(cat "$TMPDIR_EVAL/tp-ac1.out" "$TMPDIR_EVAL/tp-ac1.err")"
131
+ fi
132
+ [[ "$(json_query "$TMPDIR_EVAL/tp-ac1.out" "role")" == "TakeoverPreflight" ]] && pass "takeover-preflight JSON role is TakeoverPreflight (AC1)" || fail "takeover-preflight JSON role was not TakeoverPreflight: $(cat "$TMPDIR_EVAL/tp-ac1.out")"
133
+ [[ "$(json_query "$TMPDIR_EVAL/tp-ac1.out" "action")" == "grace-then-supersede" ]] && pass "takeover-preflight action is grace-then-supersede for a reclaimable subject (AC1)" || fail "takeover-preflight action was not grace-then-supersede: $(cat "$TMPDIR_EVAL/tp-ac1.out")"
134
+ [[ "$(json_query "$TMPDIR_EVAL/tp-ac1.out" "effective_state")" == "reclaimable" ]] && pass "takeover-preflight effective_state is reclaimable (AC1)" || fail "takeover-preflight effective_state was not reclaimable: $(cat "$TMPDIR_EVAL/tp-ac1.out")"
135
+ [[ "$(json_query "$TMPDIR_EVAL/tp-ac1.out" "resume_branch")" == "$AC1_BRANCH" ]] && pass "takeover-preflight resume_branch is the incumbent's branch ($AC1_BRANCH), never a new one (AC1 headline)" || fail "takeover-preflight resume_branch was not the incumbent's branch: $(cat "$TMPDIR_EVAL/tp-ac1.out")"
136
+ AC1_GRACE="$(json_query "$TMPDIR_EVAL/tp-ac1.out" "grace_seconds")"
137
+ if [[ "$AC1_GRACE" =~ ^[0-9]+$ && "$AC1_GRACE" -gt 0 ]]; then
138
+ pass "takeover-preflight grace_seconds is a positive number ($AC1_GRACE) (AC1)"
139
+ else
140
+ fail "takeover-preflight grace_seconds was not a positive number: '$AC1_GRACE'"
141
+ fi
142
+ [[ "$(json_query "$TMPDIR_EVAL/tp-ac1.out" "holder.actor")" == "$INCUMBENT_ACTOR" ]] && pass "takeover-preflight holder.actor names the incumbent ($INCUMBENT_ACTOR) (AC1)" || fail "takeover-preflight holder.actor did not name the incumbent: $(cat "$TMPDIR_EVAL/tp-ac1.out")"
143
+
144
+ # Now the successor takes over via ensure-session --supersede-stale — takeover-as-resumption:
145
+ # resumed_branch must be the incumbent's branch (NOT a fresh branch), and a `supersede` audit
146
+ # entry naming the incumbent + "resuming from trust bundle" must be recorded.
147
+ flow_agents_node "$WRITER" ensure-session \
148
+ --flow-id builder.build \
149
+ --task-slug "$AC1_SLUG" \
150
+ --work-item "kontourai/flow-agents#294" \
151
+ --title "Takeover AC1" \
152
+ --artifact-root "$ARTIFACT_ROOT" \
153
+ --supersede-stale \
154
+ --actor "$SUCCESSOR_ACTOR" \
155
+ >"$TMPDIR_EVAL/tp-ac1-supersede.out" 2>"$TMPDIR_EVAL/tp-ac1-supersede.err"
156
+ AC1_SUPERSEDE_EXIT=$?
157
+ [[ $AC1_SUPERSEDE_EXIT -eq 0 ]] && pass "ensure-session --supersede-stale as the successor exits 0 (AC1)" || fail "ensure-session --supersede-stale unexpectedly failed: $(cat "$TMPDIR_EVAL/tp-ac1-supersede.out" "$TMPDIR_EVAL/tp-ac1-supersede.err")"
158
+ # ensure-session's stdout carries the single-line SupersedeTakeover JSON AND a trailing
159
+ # `console.log(dir)` line — isolate the JSON object line (printJson emits it single-line) before
160
+ # json_query'ing it.
161
+ grep -F '"role": "SupersedeTakeover"' "$TMPDIR_EVAL/tp-ac1-supersede.out" > "$TMPDIR_EVAL/tp-ac1-supersede.json" || true
162
+ [[ "$(json_query "$TMPDIR_EVAL/tp-ac1-supersede.json" "role")" == "SupersedeTakeover" ]] && pass "supersede stdout JSON role is SupersedeTakeover (AC1)" || fail "supersede stdout JSON role was not SupersedeTakeover: $(cat "$TMPDIR_EVAL/tp-ac1-supersede.out")"
163
+ [[ "$(json_query "$TMPDIR_EVAL/tp-ac1-supersede.json" "resumed_branch")" == "$AC1_BRANCH" ]] && pass "supersede resumed_branch is the incumbent's branch ($AC1_BRANCH), NOT a new branch (AC1 headline)" || fail "supersede resumed_branch was not the incumbent's branch: $(cat "$TMPDIR_EVAL/tp-ac1-supersede.out")"
164
+ [[ "$(json_query "$TMPDIR_EVAL/tp-ac1-supersede.json" "superseded_actor")" == "$INCUMBENT_ACTOR" ]] && pass "supersede superseded_actor names the incumbent ($INCUMBENT_ACTOR) (AC1)" || fail "supersede superseded_actor did not name the incumbent: $(cat "$TMPDIR_EVAL/tp-ac1-supersede.out")"
165
+
166
+ # The record now carries a `supersede` audit_trail entry whose reason resumes from the trust
167
+ # bundle and names the superseded incumbent.
168
+ AC1_SUPERSEDE_REASON="$(node -e '
169
+ const fs = require("fs");
170
+ const rec = JSON.parse(fs.readFileSync(process.argv[1], "utf8"));
171
+ const trail = Array.isArray(rec.audit_trail) ? rec.audit_trail : [];
172
+ const entry = trail.find((e) => e && e.transition === "supersede");
173
+ process.stdout.write(entry && typeof entry.reason === "string" ? entry.reason : "");
174
+ ' "$AC1_RECORD")"
175
+ [[ -n "$AC1_SUPERSEDE_REASON" ]] && pass "the assignment record now contains a supersede audit_trail entry (AC1)" || fail "no supersede audit_trail entry found on the record: $(cat "$AC1_RECORD")"
176
+ [[ "$AC1_SUPERSEDE_REASON" == *"resuming from trust bundle"* ]] && pass "the supersede audit reason contains 'resuming from trust bundle' (AC1)" || fail "supersede audit reason did not contain 'resuming from trust bundle': '$AC1_SUPERSEDE_REASON'"
177
+ [[ "$AC1_SUPERSEDE_REASON" == *"$INCUMBENT_ACTOR"* ]] && pass "the supersede audit reason names the superseded incumbent ($INCUMBENT_ACTOR) (AC1)" || fail "supersede audit reason did not name the superseded incumbent: '$AC1_SUPERSEDE_REASON'"
178
+
179
+ # ─── 2. AC2a — revive during grace -> back off; --supersede-stale then REFUSES ─────────────
180
+ echo "--- 2. AC2a: a fresh incumbent liveness heartbeat flips reclaimable -> back-off/held; --supersede-stale then refuses ---"
181
+
182
+ AC2A_SLUG="takeover-ac2a-revive"
183
+ AC2A_DIR="$ARTIFACT_ROOT/$AC2A_SLUG"
184
+ AC2A_INCUMBENT="eval-actor-incumbent-revived"
185
+ write_assignment_record "$ARTIFACT_ROOT" "$AC2A_SLUG" "incumbent-revived-session" "$AC2A_INCUMBENT"
186
+ mkdir -p "$AC2A_DIR"
187
+
188
+ # A FRESH liveness heartbeat for the INCUMBENT — the incumbent revived during the grace window,
189
+ # so the join now classifies the subject held(holder=incumbent), not reclaimable.
190
+ append_liveness_event "$ARTIFACT_ROOT" "$AC2A_SLUG" "$AC2A_INCUMBENT" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" 1800
191
+
192
+ if flow_agents_node "$WRITER" takeover-preflight "$AC2A_DIR" --actor "$SUCCESSOR_ACTOR" \
193
+ >"$TMPDIR_EVAL/tp-ac2a.out" 2>"$TMPDIR_EVAL/tp-ac2a.err"; then
194
+ fail "takeover-preflight against a revived incumbent should have exited non-zero (back off) (AC2a)"
195
+ else
196
+ pass "takeover-preflight against a revived incumbent exits non-zero — do NOT supersede a live holder (AC2a)"
197
+ fi
198
+ [[ "$(json_query "$TMPDIR_EVAL/tp-ac2a.out" "ok")" == "false" ]] && pass "takeover-preflight reports ok:false for the revived incumbent (AC2a)" || fail "takeover-preflight did not report ok:false: $(cat "$TMPDIR_EVAL/tp-ac2a.out")"
199
+ [[ "$(json_query "$TMPDIR_EVAL/tp-ac2a.out" "action")" == "back-off" ]] && pass "takeover-preflight action is back-off for the revived incumbent (AC2a)" || fail "takeover-preflight action was not back-off: $(cat "$TMPDIR_EVAL/tp-ac2a.out")"
200
+ [[ "$(json_query "$TMPDIR_EVAL/tp-ac2a.out" "effective_state")" == "held" ]] && pass "takeover-preflight effective_state is held (incumbent revived) (AC2a)" || fail "takeover-preflight effective_state was not held: $(cat "$TMPDIR_EVAL/tp-ac2a.out")"
201
+
202
+ # The successor's --supersede-stale must now REFUSE (the state is held, not reclaimable) and never
203
+ # write a supersede audit entry.
204
+ flow_agents_node "$WRITER" ensure-session \
205
+ --flow-id builder.build \
206
+ --task-slug "$AC2A_SLUG" \
207
+ --work-item "kontourai/flow-agents#294" \
208
+ --title "Takeover AC2a" \
209
+ --artifact-root "$ARTIFACT_ROOT" \
210
+ --supersede-stale \
211
+ --actor "$SUCCESSOR_ACTOR" \
212
+ >"$TMPDIR_EVAL/tp-ac2a-supersede.out" 2>"$TMPDIR_EVAL/tp-ac2a-supersede.err"
213
+ AC2A_SUPERSEDE_EXIT=$?
214
+ [[ $AC2A_SUPERSEDE_EXIT -ne 0 ]] && pass "ensure-session --supersede-stale as the successor REFUSES (exits non-zero) because the incumbent revived (AC2a)" || fail "ensure-session --supersede-stale should have refused against a revived incumbent: $(cat "$TMPDIR_EVAL/tp-ac2a-supersede.out" "$TMPDIR_EVAL/tp-ac2a-supersede.err")"
215
+ AC2A_HAS_SUPERSEDE="$(node -e '
216
+ const fs = require("fs");
217
+ const rec = JSON.parse(fs.readFileSync(process.argv[1], "utf8"));
218
+ const trail = Array.isArray(rec.audit_trail) ? rec.audit_trail : [];
219
+ process.stdout.write(trail.some((e) => e && e.transition === "supersede") ? "yes" : "no");
220
+ ' "$ARTIFACT_ROOT/assignment/$AC2A_SLUG.json")"
221
+ [[ "$AC2A_HAS_SUPERSEDE" == "no" ]] && pass "no supersede audit entry was written for the refused takeover — the live incumbent's record is untouched (AC2a)" || fail "a supersede audit entry was written despite the refusal (AC2a)"
222
+
223
+ # ─── 3. AC2b — a superseded incumbent is blocked at publish (compose with verify-hold, #293) ─
224
+ echo "--- 3. AC2b: the woken zombie incumbent is blocked by verify-hold on the AC1 subject (takeover + #293 gate compose) ---"
225
+
226
+ # After AC1's takeover the AC1 record is held by the successor (successor's actor_key). The
227
+ # original incumbent, if it wakes, is no longer the holder — verify-hold (#293) must block it.
228
+ if flow_agents_node "$WRITER" verify-hold "$AC1_DIR" --actor "$INCUMBENT_ACTOR" \
229
+ >"$TMPDIR_EVAL/tp-ac2b.out" 2>"$TMPDIR_EVAL/tp-ac2b.err"; then
230
+ fail "verify-hold for the superseded (zombie) incumbent should have exited non-zero — it no longer holds the subject (AC2b)"
231
+ else
232
+ pass "verify-hold blocks the woken zombie incumbent (exit non-zero) — takeover + the #293 gate compose (AC2b)"
233
+ fi
234
+ [[ "$(json_query "$TMPDIR_EVAL/tp-ac2b.out" "ok")" == "false" ]] && pass "verify-hold reports ok:false for the superseded incumbent (AC2b)" || fail "verify-hold did not report ok:false for the superseded incumbent: $(cat "$TMPDIR_EVAL/tp-ac2b.out")"
235
+
236
+ # ─── 4. free / self quick cases ────────────────────────────────────────────────────────────
237
+ echo "--- 4. free (no record) -> claim; self+fresh -> proceed ---"
238
+
239
+ # free: no assignment record at all for this fresh slug.
240
+ FREE_SLUG="takeover-free-subject"
241
+ FREE_DIR="$ARTIFACT_ROOT/$FREE_SLUG"
242
+ mkdir -p "$FREE_DIR"
243
+ if flow_agents_node "$WRITER" takeover-preflight "$FREE_DIR" --actor "$SUCCESSOR_ACTOR" \
244
+ >"$TMPDIR_EVAL/tp-free.out" 2>"$TMPDIR_EVAL/tp-free.err"; then
245
+ pass "takeover-preflight on a free subject (no record) exits 0 (AC free)"
246
+ else
247
+ fail "takeover-preflight on a free subject unexpectedly refused: $(cat "$TMPDIR_EVAL/tp-free.out" "$TMPDIR_EVAL/tp-free.err")"
248
+ fi
249
+ [[ "$(json_query "$TMPDIR_EVAL/tp-free.out" "action")" == "claim" ]] && pass "takeover-preflight action is claim for a free subject (AC free)" || fail "takeover-preflight action was not claim: $(cat "$TMPDIR_EVAL/tp-free.out")"
250
+ [[ "$(json_query "$TMPDIR_EVAL/tp-free.out" "effective_state")" == "free" ]] && pass "takeover-preflight effective_state is free for a free subject (AC free)" || fail "takeover-preflight effective_state was not free: $(cat "$TMPDIR_EVAL/tp-free.out")"
251
+
252
+ # self_is_holder: a fresh record + fresh liveness for the SAME actor passed as --actor.
253
+ SELF_SLUG="takeover-self-holder"
254
+ SELF_DIR="$ARTIFACT_ROOT/$SELF_SLUG"
255
+ SELF_ACTOR="eval-actor-self-holder"
256
+ write_assignment_record "$ARTIFACT_ROOT" "$SELF_SLUG" "self-holder-session" "$SELF_ACTOR"
257
+ append_liveness_event "$ARTIFACT_ROOT" "$SELF_SLUG" "$SELF_ACTOR" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" 1800
258
+ mkdir -p "$SELF_DIR"
259
+ if flow_agents_node "$WRITER" takeover-preflight "$SELF_DIR" --actor "$SELF_ACTOR" \
260
+ >"$TMPDIR_EVAL/tp-self.out" 2>"$TMPDIR_EVAL/tp-self.err"; then
261
+ pass "takeover-preflight when the current actor IS the fresh holder exits 0 (AC self)"
262
+ else
263
+ fail "takeover-preflight for the self holder unexpectedly refused: $(cat "$TMPDIR_EVAL/tp-self.out" "$TMPDIR_EVAL/tp-self.err")"
264
+ fi
265
+ [[ "$(json_query "$TMPDIR_EVAL/tp-self.out" "action")" == "proceed" ]] && pass "takeover-preflight action is proceed when self is the holder (AC self)" || fail "takeover-preflight action was not proceed: $(cat "$TMPDIR_EVAL/tp-self.out")"
266
+
267
+ # ─── 5. Injection discipline — hostile control bytes + >64 chars are stripped AND capped ─────
268
+ echo "--- 5. injection: hostile actor_key/branch never leak raw control/ANSI bytes; holder.actor & resume_branch are length-capped (<=64) ---"
269
+
270
+ HOSTILE_SLUG="takeover-hostile-injection"
271
+ HOSTILE_DIR="$ARTIFACT_ROOT/$HOSTILE_SLUG"
272
+ HOSTILE_RECORD="$ARTIFACT_ROOT/assignment/$HOSTILE_SLUG.json"
273
+ # actor_key and branch: embed raw ESC (\x1b) + BEL (\x07) control bytes AND >64 chars each.
274
+ HOSTILE_ACTOR=$'hostile-actor\x1b[31;1mFAKE\x07-admin-'"$(printf 'A%.0s' {1..300})"
275
+ HOSTILE_BRANCH=$'agent/hostile\x1b[31;1mFAKE\x07-branch-'"$(printf 'B%.0s' {1..300})"
276
+ write_assignment_record "$ARTIFACT_ROOT" "$HOSTILE_SLUG" "hostile-session" "$HOSTILE_ACTOR"
277
+ patch_record_branch "$HOSTILE_RECORD" "$HOSTILE_BRANCH"
278
+ mkdir -p "$HOSTILE_DIR"
279
+
280
+ # No liveness event -> reclaimable, so both holder.actor (from the record's actor_key) and
281
+ # resume_branch (from the record's branch) are populated and pass through sanitize().
282
+ flow_agents_node "$WRITER" takeover-preflight "$HOSTILE_DIR" --actor eval-actor-hostile-reader \
283
+ >"$TMPDIR_EVAL/tp-hostile.out" 2>"$TMPDIR_EVAL/tp-hostile.err"
284
+
285
+ if grep -qF $'\x1b[31;1m' "$TMPDIR_EVAL/tp-hostile.out" "$TMPDIR_EVAL/tp-hostile.err" || grep -qF $'\x07' "$TMPDIR_EVAL/tp-hostile.out" "$TMPDIR_EVAL/tp-hostile.err"; then
286
+ fail "hostile actor_key/branch's raw ANSI/control bytes leaked into takeover-preflight's output (stdout+stderr) (injection AC)"
287
+ else
288
+ pass "hostile actor_key/branch's raw ANSI/control bytes never leak into takeover-preflight's output (injection AC)"
289
+ fi
290
+
291
+ HOSTILE_HOLDER_ACTOR="$(json_query "$TMPDIR_EVAL/tp-hostile.out" "holder.actor")"
292
+ if [[ "$HOSTILE_HOLDER_ACTOR" == *$'\x1b'* || "$HOSTILE_HOLDER_ACTOR" == *$'\x07'* ]]; then
293
+ fail "holder.actor leaked raw control/ANSI bytes (injection AC): $HOSTILE_HOLDER_ACTOR"
294
+ else
295
+ pass "holder.actor is control-char-stripped (injection AC)"
296
+ fi
297
+ HOSTILE_HOLDER_ACTOR_LEN="${#HOSTILE_HOLDER_ACTOR}"
298
+ [[ "$HOSTILE_HOLDER_ACTOR_LEN" -le 64 ]] && pass "holder.actor is length-capped at <=64 chars despite a 300+ char source (len=$HOSTILE_HOLDER_ACTOR_LEN) (injection AC)" || fail "holder.actor exceeded the 64-char cap (len=$HOSTILE_HOLDER_ACTOR_LEN): $HOSTILE_HOLDER_ACTOR"
299
+
300
+ HOSTILE_RESUME_BRANCH="$(json_query "$TMPDIR_EVAL/tp-hostile.out" "resume_branch")"
301
+ if [[ "$HOSTILE_RESUME_BRANCH" == *$'\x1b'* || "$HOSTILE_RESUME_BRANCH" == *$'\x07'* ]]; then
302
+ fail "resume_branch leaked raw control/ANSI bytes (injection AC): $HOSTILE_RESUME_BRANCH"
303
+ else
304
+ pass "resume_branch is control-char-stripped (injection AC)"
305
+ fi
306
+ HOSTILE_RESUME_BRANCH_LEN="${#HOSTILE_RESUME_BRANCH}"
307
+ # Branch uses the 240-char FREE-TEXT tier (not the 64-char id tier holder.actor uses): a realistic
308
+ # `agent/<actor>/<slug>` branch legitimately exceeds 64 chars, so capping the branch at 64 would
309
+ # truncate a VALID branch into a non-existent `git checkout` target (review finding #2). It is still
310
+ # capped (bounded at 240) and control-stripped (asserted above).
311
+ [[ "$HOSTILE_RESUME_BRANCH_LEN" -le 240 ]] && pass "resume_branch is length-capped at <=240 (free-text tier) despite a 300+ char source (len=$HOSTILE_RESUME_BRANCH_LEN) (injection AC)" || fail "resume_branch exceeded the 240-char free-text cap (len=$HOSTILE_RESUME_BRANCH_LEN): $HOSTILE_RESUME_BRANCH"
312
+
313
+ # ─── 6. A legitimate LONG branch (>64 chars, no control bytes) survives INTACT (review finding #2) ─
314
+ echo "--- 6. a realistic >64-char branch is NOT truncated (resume path would otherwise checkout a bad target) ---"
315
+ LONG_SLUG="takeover-long-branch"
316
+ LONG_DIR="$ARTIFACT_ROOT/$LONG_SLUG"
317
+ LONG_RECORD="$ARTIFACT_ROOT/assignment/$LONG_SLUG.json"
318
+ # 78 chars, well over 64, entirely valid git-branch charset — the exact case that would break AC1 if
319
+ # the branch were capped at 64.
320
+ LONG_BRANCH="agent/claude-code-sess-abcdef0123456789-hostname/flow-agents-294-takeover-xyz"
321
+ write_assignment_record "$ARTIFACT_ROOT" "$LONG_SLUG" "long-session" "long-actor-key"
322
+ patch_record_branch "$LONG_RECORD" "$LONG_BRANCH"
323
+ mkdir -p "$LONG_DIR"
324
+ flow_agents_node "$WRITER" takeover-preflight "$LONG_DIR" --actor eval-actor-long-reader \
325
+ >"$TMPDIR_EVAL/tp-long.out" 2>"$TMPDIR_EVAL/tp-long.err"
326
+ LONG_RESUME_BRANCH="$(json_query "$TMPDIR_EVAL/tp-long.out" "resume_branch")"
327
+ [[ "$LONG_RESUME_BRANCH" == "$LONG_BRANCH" ]] && pass "a legitimate ${#LONG_BRANCH}-char branch survives INTACT in resume_branch (never truncated into a bad checkout target — AC1 / review finding #2)" || fail "a legitimate long branch was altered/truncated in resume_branch: got '$LONG_RESUME_BRANCH' expected '$LONG_BRANCH'"
328
+ # And through the real supersede (resumed_branch) path.
329
+ flow_agents_node "$WRITER" ensure-session --flow-id builder.build --task-slug "$LONG_SLUG" --work-item 294 --title t --artifact-root "$ARTIFACT_ROOT" --supersede-stale --actor eval-actor-long-reader \
330
+ >"$TMPDIR_EVAL/tp-long-supersede.out" 2>"$TMPDIR_EVAL/tp-long-supersede.err"
331
+ grep -F '"role": "SupersedeTakeover"' "$TMPDIR_EVAL/tp-long-supersede.out" >"$TMPDIR_EVAL/tp-long-supersede.json"
332
+ [[ "$(json_query "$TMPDIR_EVAL/tp-long-supersede.json" "resumed_branch")" == "$LONG_BRANCH" ]] && pass "supersede resumed_branch preserves the full ${#LONG_BRANCH}-char incumbent branch (review finding #2)" || fail "supersede resumed_branch truncated the long branch: $(cat "$TMPDIR_EVAL/tp-long-supersede.json")"
333
+
334
+ echo ""
335
+ if [[ "$errors" -eq 0 ]]; then
336
+ echo "test_takeover_protocol: all checks passed."
337
+ else
338
+ echo "test_takeover_protocol: $errors check(s) failed."
339
+ fi
340
+ exit "$errors"
@@ -40,6 +40,7 @@
40
40
  set -uo pipefail
41
41
 
42
42
  ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
43
+ source "$ROOT/evals/lib/node.sh"
43
44
  RECONCILE="$ROOT/scripts/ci/trust-reconcile.js"
44
45
  FX="$ROOT/evals/fixtures/trust-reconcile-exploits"
45
46
  WS3="$ROOT/evals/fixtures/trust-reconcile-ws3/ws3-bundle.json"
@@ -919,6 +920,96 @@ else
919
920
  _fail "prefer-newest: expected 'owning, newest wins' in the selection line -- output: $out8d"
920
921
  fi
921
922
 
923
+ # 9. [iteration-1 F1 CRITICAL regression guard] CI trust-reconcile.js MUST stay fail-closed
924
+ # when scripts/ci/derive-claim-status.mjs is unavailable (Surface unresolvable, spawn
925
+ # failure, malformed bundle, etc.) -- deriveClaimStatuses() returns null in that case, and
926
+ # BEFORE #356's extraction the inline session-local loop turned every session-local
927
+ # pass-asserting claim into a `status-underivable` divergence (hard FAIL). The extraction's
928
+ # shared sessionLocalShapeIssues() briefly made that a caller-controlled `onUnderivable` mode
929
+ # (see reconcile-shape.js) -- this section proves CI still defaults/forces the safe 'fail'
930
+ # mode, and (paired) that the LOCAL reconcile-preflight intentionally diverges into the
931
+ # 'reduce' mode on the exact same bundle. Without the fix, case 9a below FAILS (exits 0
932
+ # instead of 1) -- this is the single regression eval that let the CRITICAL through
933
+ # code-review-356 iteration 0.
934
+ echo ""
935
+ echo "=== 9. derive-claim-status.mjs unavailable: CI fail-closed vs local-preflight reduced-coverage (iteration-1 F1) ==="
936
+
937
+ DERIVE_HELPER="$ROOT/scripts/ci/derive-claim-status.mjs"
938
+ DERIVE_HELPER_HIDDEN="$DERIVE_HELPER.hidden-by-test-9"
939
+
940
+ # hide_derive_helper / restore_derive_helper: rename the real ESM helper out of the way so
941
+ # scripts/ci/trust-reconcile.js's deriveClaimStatuses() -- which does fs.existsSync(helper)
942
+ # before ever spawning it -- returns null exactly as it would if Surface could not resolve.
943
+ # Always restored via trap, including on a hard failure of this script.
944
+ hide_derive_helper() {
945
+ if [[ -f "$DERIVE_HELPER" ]]; then
946
+ mv "$DERIVE_HELPER" "$DERIVE_HELPER_HIDDEN"
947
+ fi
948
+ }
949
+ restore_derive_helper() {
950
+ if [[ -f "$DERIVE_HELPER_HIDDEN" ]]; then
951
+ mv "$DERIVE_HELPER_HIDDEN" "$DERIVE_HELPER"
952
+ fi
953
+ }
954
+ trap 'cleanup_declared; rm -rf "$PERSESSION_TMPROOT"; restore_derive_helper' EXIT
955
+
956
+ # A one-claim, session-local, pass-asserting bundle (reuses the finding-3 exploit fixture --
957
+ # a human_attestation-backed claim asserting status "verified"; any session-local
958
+ # pass-asserting claim exercises this path since the whole derivedStatus map is null, not a
959
+ # single claim's lookup).
960
+ CASE9_BUNDLE="$FX/status-misassertion.json"
961
+
962
+ hide_derive_helper
963
+
964
+ # 9a. CI (real scripts/ci/trust-reconcile.js): derive-claim-status.mjs unavailable -> MUST
965
+ # still exit non-zero (status-underivable, fail-closed) -- the pre-#356 guarantee.
966
+ out9a="$(TRUST_RECONCILE_COMMANDS="node -e 'process.exit(0)'" \
967
+ node "$RECONCILE" --bundle "$CASE9_BUNDLE" --repo-root "$ROOT" 2>&1)"
968
+ code9a=$?
969
+ if [[ $code9a -ne 0 ]]; then
970
+ _pass "derive-unavailable-ci: reconciler exits non-zero ($code9a) -- fail-closed preserved when re-derivation is unavailable"
971
+ else
972
+ _fail "derive-unavailable-ci: expected non-zero exit (fail-closed), got 0 -- output: $out9a"
973
+ fi
974
+ if echo "$out9a" | grep -qF "[status-underivable]"; then
975
+ _pass "derive-unavailable-ci: emitted the status-underivable divergence"
976
+ else
977
+ _fail "derive-unavailable-ci: expected a [status-underivable] divergence -- output: $out9a"
978
+ fi
979
+ if echo "$out9a" | grep -qF "refusing to trust a self-reported status (fail-closed)"; then
980
+ _pass "derive-unavailable-ci: emitted the verbatim fail-closed message"
981
+ else
982
+ _fail "derive-unavailable-ci: expected the verbatim fail-closed message -- output: $out9a"
983
+ fi
984
+
985
+ # 9b. Paired proof the modes diverge intentionally: the LOCAL reconcile-preflight, on the
986
+ # SAME bundle with the SAME helper hidden, DEGRADES -- it does not hard-fail on the
987
+ # underivable-status dimension (it opts into reduce mode explicitly; see
988
+ # src/cli/workflow-sidecar.ts's runReconcilePreflight).
989
+ # Nested under a subdirectory (not the raw mktemp -d path) -- mktemp -d's own
990
+ # "tmp.XXXXXXXXXX" basename contains a dot that path.extname()/artifactDirFrom() would
991
+ # misparse as a file extension, incorrectly stripping the directory segment.
992
+ CASE9_TMPROOT="$(mktemp -d)"
993
+ CASE9_SESSION="$CASE9_TMPROOT/session"
994
+ mkdir -p "$CASE9_SESSION"
995
+ cp "$CASE9_BUNDLE" "$CASE9_SESSION/trust.bundle"
996
+ out9b="$(flow_agents_node workflow-sidecar reconcile-preflight "$CASE9_SESSION" --repo-root "$ROOT" 2>&1)"
997
+ code9b=$?
998
+ rm -rf "$CASE9_TMPROOT"
999
+ if [[ $code9b -eq 0 ]]; then
1000
+ _pass "derive-unavailable-local-preflight: reconcile-preflight exits 0 (reduced-coverage degrade, not a hard fail) -- proves the CI/local modes diverge intentionally"
1001
+ else
1002
+ _fail "derive-unavailable-local-preflight: expected exit 0 (reduced-coverage degrade), got $code9b -- output: $out9b"
1003
+ fi
1004
+ if echo "$out9b" | grep -qF "status-underivable"; then
1005
+ _fail "derive-unavailable-local-preflight: must NOT emit status-underivable in reduced-coverage mode -- output: $out9b"
1006
+ else
1007
+ _pass "derive-unavailable-local-preflight: does not emit status-underivable (reduced-coverage checks only)"
1008
+ fi
1009
+
1010
+ restore_derive_helper
1011
+
1012
+
922
1013
  echo ""
923
1014
  if [[ $errors -eq 0 ]]; then
924
1015
  echo "test_trust_reconcile_negatives: all checks passed."