@kontourai/flow-agents 3.1.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.
- package/.github/workflows/ci.yml +16 -0
- package/CHANGELOG.md +30 -0
- package/build/src/cli/assignment-provider.d.ts +45 -0
- package/build/src/cli/assignment-provider.js +107 -13
- package/build/src/cli/workflow-artifact-cleanup-audit.js +418 -11
- package/build/src/cli/workflow-sidecar.d.ts +238 -4
- package/build/src/cli/workflow-sidecar.js +875 -14
- package/build/src/tools/validate-source-tree.js +3 -2
- package/context/contracts/artifact-contract.md +16 -2
- package/context/contracts/assignment-provider-contract.md +1 -1
- package/context/contracts/execution-contract.md +78 -0
- package/context/scripts/hooks/config-protection.js +11 -4
- package/context/scripts/hooks/stop-goal-fit.js +259 -4
- package/context/scripts/hooks/workflow-steering.js +73 -1
- package/docs/adr/0022-fail-closed-delivery-reconciliation-with-governed-exemptions.md +111 -0
- package/docs/coordination-guide.md +370 -0
- package/docs/decisions/agent-coordination.md +26 -9
- package/docs/decisions/index.md +2 -2
- package/docs/decisions/trust-reconcile.md +42 -9
- package/docs/fixture-ownership.md +3 -2
- package/docs/index.md +4 -0
- package/docs/integrations/flow-agents-console.md +108 -0
- package/docs/integrations/index.md +4 -0
- package/docs/workflow-artifact-lifecycle.md +38 -1
- package/evals/ci/antigaming-suite.sh +1 -0
- package/evals/ci/run-baseline.sh +8 -0
- package/evals/fixtures/reconcile-preflight/disputed-critique-unsuperseded.json +48 -0
- package/evals/fixtures/reconcile-preflight/standalone-disputed-session-local.json +59 -0
- package/evals/integration/test_checkpoint_signing.sh +14 -5
- package/evals/integration/test_ci_actor_identity.sh +221 -0
- package/evals/integration/test_fixture_retirement_audit.sh +2 -2
- package/evals/integration/test_gate_lockdown.sh +36 -0
- package/evals/integration/test_model_routing_escalation.sh +145 -0
- package/evals/integration/test_publish_delivery.sh +73 -8
- package/evals/integration/test_reconcile_preflight.sh +304 -0
- package/evals/integration/test_stop_hook_release.sh +552 -0
- package/evals/integration/test_takeover_protocol.sh +340 -0
- package/evals/integration/test_trust_reconcile_negatives.sh +261 -0
- package/evals/integration/test_verify_hold.sh +910 -0
- package/evals/integration/test_veritas_governance_kit.sh +257 -0
- package/evals/integration/test_workflow_artifact_cleanup_audit.sh +575 -3
- package/evals/run.sh +14 -0
- package/evals/static/test_model_routing_hints.sh +107 -0
- package/kits/builder/skills/builder-shape/SKILL.md +10 -0
- package/kits/builder/skills/continue-work/SKILL.md +2 -0
- package/kits/builder/skills/deliver/SKILL.md +125 -11
- package/kits/builder/skills/design-probe/SKILL.md +10 -0
- package/kits/builder/skills/execute-plan/SKILL.md +13 -0
- package/kits/builder/skills/fix-bug/SKILL.md +17 -0
- package/kits/builder/skills/idea-to-backlog/SKILL.md +10 -0
- package/kits/builder/skills/plan-work/SKILL.md +9 -0
- package/kits/builder/skills/pull-work/SKILL.md +22 -2
- package/kits/builder/skills/review-work/SKILL.md +11 -0
- package/kits/builder/skills/tdd-workflow/SKILL.md +17 -0
- package/kits/builder/skills/verify-work/SKILL.md +11 -0
- package/kits/knowledge/adapters/default-store/index.js +56 -15
- package/kits/knowledge/adapters/flow-runner/index.js +912 -16
- package/kits/knowledge/adapters/obsidian-store/index.js +29 -11
- package/kits/knowledge/adapters/shared/codec.js +124 -0
- package/kits/knowledge/docs/store-contract.md +405 -3
- package/kits/knowledge/evals/audit-freshness/suite.test.js +92 -1
- package/kits/knowledge/evals/consolidate-incremental/suite.test.js +494 -0
- package/kits/knowledge/evals/consolidation/suite.test.js +1 -1
- package/kits/knowledge/evals/contract-suite/suite.test.js +36 -0
- package/kits/knowledge/evals/freshness/suite.test.js +339 -0
- package/kits/knowledge/evals/inbound-references/suite.test.js +351 -0
- package/kits/knowledge/evals/retirement/suite.test.js +1 -1
- package/kits/knowledge/evals/supersede-propagation/suite.test.js +384 -0
- package/kits/veritas-governance/docs/README.md +81 -3
- package/kits/veritas-governance/fixtures/exemption/approved.trust-bundle.json +74 -0
- package/kits/veritas-governance/fixtures/exemption/not-approved.trust-bundle.json +74 -0
- package/kits/veritas-governance/flows/exemption-issuance.flow.json +35 -0
- package/kits/veritas-governance/kit.json +5 -0
- package/package.json +1 -1
- package/schemas/workflow-handoff.schema.json +6 -0
- package/scripts/ci/mint-attestation.js +33 -6
- package/scripts/ci/trust-reconcile.js +222 -279
- package/scripts/hooks/config-protection.js +11 -4
- package/scripts/hooks/lib/actor-identity.js +82 -0
- package/scripts/hooks/stop-goal-fit.js +259 -4
- package/scripts/hooks/workflow-steering.js +73 -1
- package/scripts/lib/reconcile-shape.js +381 -0
- package/src/cli/assignment-provider.ts +122 -13
- package/src/cli/workflow-artifact-cleanup-audit.ts +483 -10
- package/src/cli/workflow-sidecar.ts +965 -14
- 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"
|
|
@@ -749,6 +750,266 @@ else
|
|
|
749
750
|
_fail "push-event-failing-verify: expected the push-event no-op line -- output: $out7v"
|
|
750
751
|
fi
|
|
751
752
|
|
|
753
|
+
# 8. #379 per-session delivery paths — concurrent-delivery collision resolution.
|
|
754
|
+
# resolveDeliveryCandidates() now discovers delivery/<slug>/trust.bundle per-session dirs
|
|
755
|
+
# in addition to the flat path; discoverBundle() selects the candidate whose checkpoint
|
|
756
|
+
# attests THIS change (ancestor-or-equal, same bundleAttestsThisChange() semantics the flat
|
|
757
|
+
# staleness gate uses), ignoring stale siblings from OTHER concurrent sessions. A shared
|
|
758
|
+
# flat path guaranteed a git conflict between any two concurrent deliveries → a DIRTY PR →
|
|
759
|
+
# NO pull_request workflows → the required check silently never ran (field #330/#358/#378).
|
|
760
|
+
echo ""
|
|
761
|
+
echo "=== 8. #379 per-session delivery paths (concurrent-delivery collision) ==="
|
|
762
|
+
|
|
763
|
+
PERSESSION_TMPROOT="$(mktemp -d)"
|
|
764
|
+
# Chain per-session cleanup onto the existing DECLARED cleanup without clobbering the trap.
|
|
765
|
+
trap 'cleanup_declared; rm -rf "$PERSESSION_TMPROOT"' EXIT
|
|
766
|
+
|
|
767
|
+
# write_session_seal <repo_root> <slug> <label> <commit_sha>
|
|
768
|
+
# Writes delivery/<slug>/trust.bundle (a well-formed schemaVersion-5 bundle whose only
|
|
769
|
+
# claimed-pass command is <label>) + delivery/<slug>/trust.checkpoint.json (naming
|
|
770
|
+
# <commit_sha>) — the per-session analogue of write_fresh_bundle + write_stale_checkpoint.
|
|
771
|
+
write_session_seal() {
|
|
772
|
+
local repo_root="$1" slug="$2" label="$3" sha="$4"
|
|
773
|
+
local sdir="$repo_root/delivery/$slug"
|
|
774
|
+
mkdir -p "$sdir"
|
|
775
|
+
cat > "$sdir/trust.bundle" << EOF
|
|
776
|
+
{
|
|
777
|
+
"schemaVersion": 5,
|
|
778
|
+
"source": "test-fixture:per-session:$slug",
|
|
779
|
+
"claims": [
|
|
780
|
+
{
|
|
781
|
+
"id": "c1", "claimType": "workflow.check.build", "value": "pass", "status": "verified",
|
|
782
|
+
"subjectId": "$slug/build", "facet": "flow-agents.workflow", "subjectType": "workflow-check",
|
|
783
|
+
"fieldOrBehavior": "build", "createdAt": "2026-07-01T00:00:00Z", "updatedAt": "2026-07-01T00:00:00Z",
|
|
784
|
+
"impactLevel": "high", "verificationPolicyId": "policy:workflow.check.build"
|
|
785
|
+
}
|
|
786
|
+
],
|
|
787
|
+
"evidence": [
|
|
788
|
+
{
|
|
789
|
+
"id": "ev1", "claimId": "c1", "evidenceType": "test_output", "method": "validation",
|
|
790
|
+
"sourceRef": "$slug/command-log.jsonl", "excerptOrSummary": "build",
|
|
791
|
+
"observedAt": "2026-07-01T00:00:00Z", "collectedBy": "flow-agents/evidence-capture",
|
|
792
|
+
"passing": true,
|
|
793
|
+
"execution": { "runner": "bash", "label": "$label", "isError": false, "exitCode": 0 }
|
|
794
|
+
}
|
|
795
|
+
],
|
|
796
|
+
"policies": [], "events": []
|
|
797
|
+
}
|
|
798
|
+
EOF
|
|
799
|
+
printf '{"schema_version":"1.0","slug":"%s","work_item":null,"status":"delivered","phase":"release","sealed_at":"2026-01-01T00:00:00Z","commit_sha":"%s","checkpoint":{"asOf":"2026-01-01T00:00:00.000Z","statusByClaimId":{}}}' \
|
|
800
|
+
"$slug" "$sha" > "$sdir/trust.checkpoint.json"
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
FRESH_LABEL_8="node -e 'process.exit(0)'"
|
|
804
|
+
|
|
805
|
+
# 8a. OWNER WINS: two sibling session dirs — one OWNING (checkpoint commit_sha == this
|
|
806
|
+
# change's sha), one STALE (a different sha). The reconciler MUST pick the owner and
|
|
807
|
+
# reconcile it; the stale sibling is ignored, NOT a failure.
|
|
808
|
+
CASE8A="$PERSESSION_TMPROOT/collision-owner-wins"
|
|
809
|
+
write_session_seal "$CASE8A" "owning-session" "$FRESH_LABEL_8" "$OURS_SHA"
|
|
810
|
+
write_session_seal "$CASE8A" "stale-sibling" "echo stale-should-never-be-reconciled" "$STALE_SHA"
|
|
811
|
+
out8a="$(TRUST_RECONCILE_SHA="$OURS_SHA" TRUST_RECONCILE_COMMANDS="$FRESH_LABEL_8" \
|
|
812
|
+
node "$RECONCILE" --repo-root "$CASE8A" 2>&1)"
|
|
813
|
+
code8a=$?
|
|
814
|
+
if [[ $code8a -eq 0 ]]; then
|
|
815
|
+
_pass "persession-owner-wins: reconciler exits 0 (owning per-session candidate selected, stale sibling ignored)"
|
|
816
|
+
else
|
|
817
|
+
_fail "persession-owner-wins: expected exit 0, got $code8a -- output: $out8a"
|
|
818
|
+
fi
|
|
819
|
+
if echo "$out8a" | grep -qF "selected delivery candidate delivery/owning-session/trust.bundle"; then
|
|
820
|
+
_pass "persession-owner-wins: emitted the #379 selection line naming the owning session dir"
|
|
821
|
+
else
|
|
822
|
+
_fail "persession-owner-wins: expected the #379 selection line for delivery/owning-session/trust.bundle -- output: $out8a"
|
|
823
|
+
fi
|
|
824
|
+
if echo "$out8a" | grep -qF "RECONCILED"; then
|
|
825
|
+
_pass "persession-owner-wins: Step 2 reconciled the owning candidate's claimed-pass command"
|
|
826
|
+
else
|
|
827
|
+
_fail "persession-owner-wins: expected RECONCILED -- output: $out8a"
|
|
828
|
+
fi
|
|
829
|
+
if echo "$out8a" | grep -qF "bundle-required-no-declared-marker"; then
|
|
830
|
+
_fail "persession-owner-wins: must NOT fail closed — an owning candidate exists"
|
|
831
|
+
else
|
|
832
|
+
_pass "persession-owner-wins: does not fail closed (owning candidate found)"
|
|
833
|
+
fi
|
|
834
|
+
|
|
835
|
+
# 8b. NO OWNER: two sibling session dirs, BOTH stale (neither attests this change), no
|
|
836
|
+
# DECLARED marker -> fail closed EXACTLY as bundle-absence, plus the #379 concurrency
|
|
837
|
+
# hint so the next agent can diagnose a collision rather than a plain stale bundle.
|
|
838
|
+
CASE8B="$PERSESSION_TMPROOT/collision-no-owner"
|
|
839
|
+
OTHER_STALE_SHA="1111111111111111111111111111111111111111"
|
|
840
|
+
write_session_seal "$CASE8B" "session-a" "echo a" "$STALE_SHA"
|
|
841
|
+
write_session_seal "$CASE8B" "session-b" "echo b" "$OTHER_STALE_SHA"
|
|
842
|
+
out8b="$(TRUST_RECONCILE_SHA="$OURS_SHA" TRUST_RECONCILE_COMMANDS="$FRESH_LABEL_8" \
|
|
843
|
+
node "$RECONCILE" --repo-root "$CASE8B" 2>&1)"
|
|
844
|
+
code8b=$?
|
|
845
|
+
if [[ $code8b -ne 0 ]]; then
|
|
846
|
+
_pass "persession-no-owner: reconciler exits non-zero ($code8b) -- no candidate attests this change, fail closed"
|
|
847
|
+
else
|
|
848
|
+
_fail "persession-no-owner: expected non-zero exit, got 0 -- output: $out8b"
|
|
849
|
+
fi
|
|
850
|
+
if echo "$out8b" | grep -qF "none attests this change $OURS_SHA"; then
|
|
851
|
+
_pass "persession-no-owner: emitted the #379 concurrency hint (none attests this change)"
|
|
852
|
+
else
|
|
853
|
+
_fail "persession-no-owner: expected the #379 'none attests this change' hint -- output: $out8b"
|
|
854
|
+
fi
|
|
855
|
+
if echo "$out8b" | grep -qF "bundle-required-no-declared-marker"; then
|
|
856
|
+
_pass "persession-no-owner: emitted 'bundle-required-no-declared-marker' (same fail-closed path as bundle-absence)"
|
|
857
|
+
else
|
|
858
|
+
_fail "persession-no-owner: expected 'bundle-required-no-declared-marker' -- output: $out8b"
|
|
859
|
+
fi
|
|
860
|
+
|
|
861
|
+
# 8c. BACK-COMPAT COEXISTENCE: a flat legacy owner (delivery/trust.bundle) alongside a stale
|
|
862
|
+
# per-session sibling -> the flat owner is still selected and reconciled (the flat path
|
|
863
|
+
# stays supported; per-session discovery does not break it).
|
|
864
|
+
CASE8C="$PERSESSION_TMPROOT/flat-owner-persession-stale"
|
|
865
|
+
write_fresh_bundle "$CASE8C" "$FRESH_LABEL_8" # flat delivery/trust.bundle
|
|
866
|
+
write_stale_checkpoint "$CASE8C" "$OURS_SHA" # flat delivery/trust.checkpoint.json (owns)
|
|
867
|
+
write_session_seal "$CASE8C" "stale-session" "echo ignored" "$STALE_SHA" # per-session stale sibling
|
|
868
|
+
out8c="$(TRUST_RECONCILE_SHA="$OURS_SHA" TRUST_RECONCILE_COMMANDS="$FRESH_LABEL_8" \
|
|
869
|
+
node "$RECONCILE" --repo-root "$CASE8C" 2>&1)"
|
|
870
|
+
code8c=$?
|
|
871
|
+
if [[ $code8c -eq 0 ]]; then
|
|
872
|
+
_pass "flat-owner-coexist: reconciler exits 0 (flat legacy owner selected despite a stale per-session sibling)"
|
|
873
|
+
else
|
|
874
|
+
_fail "flat-owner-coexist: expected exit 0, got $code8c -- output: $out8c"
|
|
875
|
+
fi
|
|
876
|
+
if echo "$out8c" | grep -qF "RECONCILED"; then
|
|
877
|
+
_pass "flat-owner-coexist: Step 2 reconciled the flat owner (back-compat preserved)"
|
|
878
|
+
else
|
|
879
|
+
_fail "flat-owner-coexist: expected RECONCILED -- output: $out8c"
|
|
880
|
+
fi
|
|
881
|
+
|
|
882
|
+
# 8d. PREFER-NEWEST among multiple OWNING candidates (the merge-commit-repo / concurrent-PR
|
|
883
|
+
# coexistence case). An inherited FLAT bundle can attest a REAL ANCESTOR of this change
|
|
884
|
+
# (committed on the trunk before this branch), AND this session's per-session bundle attests
|
|
885
|
+
# a NEWER ancestor. "First-fresh-wins" would wrongly pick the stale flat bundle because it
|
|
886
|
+
# sorts first; the reconciler must instead pick the NEWEST-owning candidate (the per-session
|
|
887
|
+
# one). Uses a REAL git repo so `git merge-base --is-ancestor` resolves the parent→child
|
|
888
|
+
# relationship (synthetic shas cannot exercise the ancestor comparison).
|
|
889
|
+
NEWEST_REPO="$PERSESSION_TMPROOT/prefer-newest-git"
|
|
890
|
+
mkdir -p "$NEWEST_REPO"
|
|
891
|
+
git -C "$NEWEST_REPO" init -q
|
|
892
|
+
git -C "$NEWEST_REPO" config user.email "test@example.com"
|
|
893
|
+
git -C "$NEWEST_REPO" config user.name "Test"
|
|
894
|
+
echo "base" > "$NEWEST_REPO/f.txt"; git -C "$NEWEST_REPO" add f.txt; git -C "$NEWEST_REPO" commit -q -m "base (inherited flat seal sealed here)"
|
|
895
|
+
FLAT_ANCESTOR_SHA="$(git -C "$NEWEST_REPO" rev-parse HEAD)"
|
|
896
|
+
echo "delivery" >> "$NEWEST_REPO/f.txt"; git -C "$NEWEST_REPO" add f.txt; git -C "$NEWEST_REPO" commit -q -m "this session's delivery commit"
|
|
897
|
+
PERSESSION_OWNER_SHA="$(git -C "$NEWEST_REPO" rev-parse HEAD)"
|
|
898
|
+
echo "head" >> "$NEWEST_REPO/f.txt"; git -C "$NEWEST_REPO" add f.txt; git -C "$NEWEST_REPO" commit -q -m "seal commit (HEAD)"
|
|
899
|
+
NEWEST_HEAD_SHA="$(git -C "$NEWEST_REPO" rev-parse HEAD)"
|
|
900
|
+
# Flat owner attesting the OLD ancestor; per-session owner attesting the NEWER ancestor.
|
|
901
|
+
write_fresh_bundle "$NEWEST_REPO" "$FRESH_LABEL_8" # flat delivery/trust.bundle
|
|
902
|
+
write_stale_checkpoint "$NEWEST_REPO" "$FLAT_ANCESTOR_SHA" # flat checkpoint -> OLD ancestor
|
|
903
|
+
write_session_seal "$NEWEST_REPO" "this-session" "$FRESH_LABEL_8" "$PERSESSION_OWNER_SHA" # per-session -> NEWER ancestor
|
|
904
|
+
out8d="$(TRUST_RECONCILE_SHA="$NEWEST_HEAD_SHA" TRUST_RECONCILE_COMMANDS="$FRESH_LABEL_8" \
|
|
905
|
+
node "$RECONCILE" --repo-root "$NEWEST_REPO" 2>&1)"
|
|
906
|
+
code8d=$?
|
|
907
|
+
if [[ $code8d -eq 0 ]]; then
|
|
908
|
+
_pass "prefer-newest: reconciler exits 0 (both flat and per-session own; newest selected)"
|
|
909
|
+
else
|
|
910
|
+
_fail "prefer-newest: expected exit 0, got $code8d -- output: $out8d"
|
|
911
|
+
fi
|
|
912
|
+
if echo "$out8d" | grep -qF "selected delivery candidate delivery/this-session/trust.bundle"; then
|
|
913
|
+
_pass "prefer-newest: selected the NEWER per-session bundle over the older inherited flat bundle"
|
|
914
|
+
else
|
|
915
|
+
_fail "prefer-newest: expected the per-session bundle to be selected (newest-owning) -- output: $out8d"
|
|
916
|
+
fi
|
|
917
|
+
if echo "$out8d" | grep -qF "owning, newest wins"; then
|
|
918
|
+
_pass "prefer-newest: emitted the 'owning, newest wins' selection detail"
|
|
919
|
+
else
|
|
920
|
+
_fail "prefer-newest: expected 'owning, newest wins' in the selection line -- output: $out8d"
|
|
921
|
+
fi
|
|
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
|
+
|
|
752
1013
|
echo ""
|
|
753
1014
|
if [[ $errors -eq 0 ]]; then
|
|
754
1015
|
echo "test_trust_reconcile_negatives: all checks passed."
|