@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.
- package/.github/workflows/ci.yml +12 -0
- package/CHANGELOG.md +13 -0
- package/build/src/cli/assignment-provider.js +10 -1
- package/build/src/cli/workflow-artifact-cleanup-audit.js +418 -11
- package/build/src/cli/workflow-sidecar.d.ts +224 -0
- package/build/src/cli/workflow-sidecar.js +775 -4
- package/build/src/tools/validate-source-tree.js +3 -2
- package/context/contracts/artifact-contract.md +16 -2
- package/context/scripts/hooks/workflow-steering.js +73 -1
- 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 +6 -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 +10 -2
- package/evals/integration/test_ci_actor_identity.sh +221 -0
- package/evals/integration/test_fixture_retirement_audit.sh +2 -2
- package/evals/integration/test_publish_delivery.sh +59 -2
- package/evals/integration/test_reconcile_preflight.sh +304 -0
- package/evals/integration/test_takeover_protocol.sh +340 -0
- package/evals/integration/test_trust_reconcile_negatives.sh +91 -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 +8 -0
- package/kits/builder/skills/continue-work/SKILL.md +2 -0
- package/kits/builder/skills/deliver/SKILL.md +73 -0
- package/kits/builder/skills/pull-work/SKILL.md +12 -2
- 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/scripts/ci/trust-reconcile.js +78 -253
- package/scripts/hooks/lib/actor-identity.js +82 -0
- package/scripts/hooks/workflow-steering.js +73 -1
- package/scripts/lib/reconcile-shape.js +381 -0
- package/src/cli/assignment-provider.ts +12 -1
- package/src/cli/workflow-artifact-cleanup-audit.ts +483 -10
- package/src/cli/workflow-sidecar.ts +866 -4
- package/src/tools/validate-source-tree.ts +3 -2
|
@@ -0,0 +1,910 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# test_verify_hold.sh — verify-hold gate integration eval (issue #293, ADR 0021 §3).
|
|
3
|
+
#
|
|
4
|
+
# Exercises `runVerifyHold()`/`NotFreshHolderError`/`verify-hold` CLI subcommand and its
|
|
5
|
+
# composition into `publishDelivery()` (src/cli/workflow-sidecar.ts), reusing the SAME
|
|
6
|
+
# assignment ⋈ liveness join #290/#291 already built (computeEffectiveState/
|
|
7
|
+
# readLocalAssignmentStatus/freshHolders) — no second computation is invented here, and this
|
|
8
|
+
# eval never asserts a second one either. Also exercises `supersessionSteering()`
|
|
9
|
+
# (scripts/hooks/workflow-steering.js), the every-turn companion to the CLI/skill hard-stop.
|
|
10
|
+
#
|
|
11
|
+
# Follows evals/integration/test_ensure_session_ownership_guard.sh's actor/fixture idiom
|
|
12
|
+
# (pass/fail/json_query helpers, append_liveness_event, actor_key_from_record) and
|
|
13
|
+
# evals/integration/test_publish_delivery.sh's bundle-fixture-builder idiom (write_bundle_to)
|
|
14
|
+
# exactly — no new plumbing invented where an existing helper already does the job.
|
|
15
|
+
#
|
|
16
|
+
# AC5 (steering surfaces supersession every turn) is asserted HERE (not duplicated into
|
|
17
|
+
# test_workflow_steering_hook.sh) by driving scripts/hooks/workflow-steering.js directly via
|
|
18
|
+
# stdin JSON, matching that file's own existing direct-invocation convention
|
|
19
|
+
# (`node scripts/hooks/workflow-steering.js <<JSON ... JSON`).
|
|
20
|
+
#
|
|
21
|
+
# Sections (1-to-1 with the plan's Wave 4 task + acceptance.json's 8 ACs):
|
|
22
|
+
# 1. Superseded A->B (AC1): actor A's verify-hold blocks (held, holder=B); a manufactured
|
|
23
|
+
# STALE self-claim (reclaimable) ALSO blocks for its own original actor (the Stop-short
|
|
24
|
+
# risk regression — reclaimable is never silently treated as pass).
|
|
25
|
+
# 2. Composition (AC1, AC3 partial): publish-delivery / record-release called as actor A on
|
|
26
|
+
# the superseded fixture exit non-zero and never write delivery/<slug>/.
|
|
27
|
+
# 3. Fresh holder + free subject pass (AC2): actor B's verify-hold passes; a free subject
|
|
28
|
+
# passes for any actor.
|
|
29
|
+
# 4. #356 shape-gate composition ordering (AC3): shape-invalid AND not-held -> the SHAPE
|
|
30
|
+
# error specifically; shape-valid AND not-held -> the HOLD error specifically. Both
|
|
31
|
+
# reachable, distinct, and delivery/<slug>/ is never created in either case.
|
|
32
|
+
# 5. github-provider precomputed gate (AC6): --effective-state-json evaluates without any
|
|
33
|
+
# `gh` process; absent both local-file and github fixture -> not_evaluated, exit 0.
|
|
34
|
+
# 6. Injection discipline (AC7): hostile liveness holder string never leaks raw control/ANSI
|
|
35
|
+
# bytes into verify-hold's JSON/stderr or the steering supersession notice.
|
|
36
|
+
# 7. Steering every-turn (AC5): supersessionSteering fires on UserPromptSubmit AND
|
|
37
|
+
# SessionStart for a fixture with a fresh other-actor holder; absent for a fixture with
|
|
38
|
+
# none.
|
|
39
|
+
# 8. Registration (AC8) is verified by diffs of evals/run.sh, evals/ci/run-baseline.sh, and
|
|
40
|
+
# .github/workflows/ci.yml (not by this file) plus the parity check in
|
|
41
|
+
# test_trust_reconcile_manifest.sh.
|
|
42
|
+
#
|
|
43
|
+
# Deterministic, no model spend, self-cleaning, no network, no `gh` process anywhere.
|
|
44
|
+
# Usage: bash evals/integration/test_verify_hold.sh
|
|
45
|
+
|
|
46
|
+
set -uo pipefail
|
|
47
|
+
|
|
48
|
+
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
|
49
|
+
source "$ROOT/evals/lib/node.sh"
|
|
50
|
+
|
|
51
|
+
CLI="$ROOT/build/src/cli.js"
|
|
52
|
+
WRITER="workflow-sidecar"
|
|
53
|
+
|
|
54
|
+
TMPDIR_EVAL="$(mktemp -d)"
|
|
55
|
+
trap 'rm -rf "$TMPDIR_EVAL"' EXIT
|
|
56
|
+
ARTIFACT_ROOT="$TMPDIR_EVAL/artifact-root"
|
|
57
|
+
|
|
58
|
+
errors=0
|
|
59
|
+
pass() { echo " [PASS] $1"; }
|
|
60
|
+
fail() { echo " [FAIL] $1"; errors=$((errors + 1)); }
|
|
61
|
+
|
|
62
|
+
json_query() {
|
|
63
|
+
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"
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
ACTOR_IDENTITY_HELPER="$ROOT/scripts/hooks/lib/actor-identity.js"
|
|
67
|
+
# actor_key_from_record <assignment-record.json> — recomputes the SAME holderActorKey
|
|
68
|
+
# computeEffectiveState (src/cli/assignment-provider.ts) itself derives (record.actor_key when
|
|
69
|
+
# present, else serializeActor(record.actor) back-compat fallback). Reused verbatim from
|
|
70
|
+
# test_ensure_session_ownership_guard.sh's own helper — never hand-derived.
|
|
71
|
+
actor_key_from_record() {
|
|
72
|
+
node -e '
|
|
73
|
+
const fs = require("fs");
|
|
74
|
+
const { serializeActor } = require(process.argv[1]);
|
|
75
|
+
const rec = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));
|
|
76
|
+
process.stdout.write(rec.actor_key || serializeActor(rec.actor));
|
|
77
|
+
' "$ACTOR_IDENTITY_HELPER" "$1"
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
# append_liveness_event <root> <subjectId> <actor> <at-iso> [ttlSeconds]
|
|
81
|
+
append_liveness_event() {
|
|
82
|
+
local root="$1" subject="$2" actor="$3" at="$4" ttl="${5:-1800}"
|
|
83
|
+
mkdir -p "$root/liveness"
|
|
84
|
+
node -e '
|
|
85
|
+
const fs = require("fs");
|
|
86
|
+
const evt = { type: "claim", subjectId: process.argv[1], actor: process.argv[2], at: process.argv[3], ttlSeconds: Number(process.argv[4]) };
|
|
87
|
+
fs.appendFileSync(process.argv[5], JSON.stringify(evt) + "\n");
|
|
88
|
+
' "$subject" "$actor" "$at" "$ttl" "$root/liveness/events.jsonl"
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
# write_assignment_record <artifactRoot> <slug> <sessionId> <actorKey> [claimedAt]
|
|
92
|
+
# Writes a minimal, well-formed, ALREADY-CLAIMED assignment record directly (bypassing
|
|
93
|
+
# assignment-provider claim's --branch/--artifact-dir plumbing where the eval only needs a
|
|
94
|
+
# fixed actor_key/holder shape for runVerifyHold's join, matching how
|
|
95
|
+
# test_ensure_session_ownership_guard.sh's own human-held fixture writes a record directly).
|
|
96
|
+
# [claimedAt] (F1 fix, fix-plan iteration 1): optional override for `claimed_at` -- defaults to
|
|
97
|
+
# real wall-clock `now` (unchanged behavior for every existing caller) so section 6b below can
|
|
98
|
+
# inject a hostile/oversized value into this exact attacker-writable field without adding a
|
|
99
|
+
# second, parallel fixture-writer helper.
|
|
100
|
+
write_assignment_record() {
|
|
101
|
+
local root="$1" slug="$2" sessionId="$3" actorKey="$4" claimedAt="${5:-}"
|
|
102
|
+
mkdir -p "$root/assignment"
|
|
103
|
+
node -e '
|
|
104
|
+
const fs = require("fs");
|
|
105
|
+
const [dest, slug, sessionId, actorKey, claimedAtOverride] = process.argv.slice(1);
|
|
106
|
+
const now = new Date().toISOString();
|
|
107
|
+
const claimedAt = claimedAtOverride || now;
|
|
108
|
+
const actor = { runtime: "claude-code", session_id: sessionId, host: "eval-host" };
|
|
109
|
+
const rec = {
|
|
110
|
+
schema_version: "1.0", role: "AssignmentClaimRecord", subject_id: slug,
|
|
111
|
+
actor, actor_key: actorKey, claimed_at: claimedAt, ttl_seconds: 1800, branch: "main",
|
|
112
|
+
artifact_dir: slug, status: "claimed",
|
|
113
|
+
audit_trail: [{ at: now, transition: "claim", from_actor: null, to_actor: actor, reason: "claim" }],
|
|
114
|
+
};
|
|
115
|
+
fs.writeFileSync(dest, JSON.stringify(rec, null, 2));
|
|
116
|
+
' "$root/assignment/$slug.json" "$slug" "$sessionId" "$actorKey" "$claimedAt"
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
# write_bundle_to <dest> <label> <passing> — minimal bundle fixture, reused verbatim from
|
|
120
|
+
# test_publish_delivery.sh's own helper (a command-backed claim naming <label> as the
|
|
121
|
+
# evidence.execution.label; shape-VALID only when <label> resolves against the manifest, e.g.
|
|
122
|
+
# via TRUST_RECONCILE_COMMANDS).
|
|
123
|
+
write_bundle_to() {
|
|
124
|
+
local dest="$1" label="$2" passing="$3"
|
|
125
|
+
local helper="$TMPDIR_EVAL/bundle-writer.js"
|
|
126
|
+
if [[ ! -f "$helper" ]]; then
|
|
127
|
+
python3 - "$helper" << 'PY'
|
|
128
|
+
import sys
|
|
129
|
+
out = sys.argv[1]
|
|
130
|
+
code_lines = [
|
|
131
|
+
"const fs = require('fs');",
|
|
132
|
+
"const [,, dest, label, passingStr] = process.argv;",
|
|
133
|
+
"const passing = passingStr === 'true';",
|
|
134
|
+
"const b = { schemaVersion: 5, source: 'test-fixture',",
|
|
135
|
+
" claims: [{ id: 'c1', claimType: 'workflow.check.build',",
|
|
136
|
+
" value: passing ? 'pass' : 'fail', status: passing ? 'verified' : 'disputed',",
|
|
137
|
+
" subjectId: 'ts/build', facet: 'flow-agents.workflow',",
|
|
138
|
+
" subjectType: 'workflow-check', fieldOrBehavior: 'build',",
|
|
139
|
+
" createdAt: '2026-06-27T00:00:00Z', updatedAt: '2026-06-27T00:00:00Z',",
|
|
140
|
+
" impactLevel: 'high', verificationPolicyId: 'policy:wf.build' }],",
|
|
141
|
+
" evidence: [{ id: 'ev1', claimId: 'c1', evidenceType: 'test_output',",
|
|
142
|
+
" method: 'validation', sourceRef: 'ts/cmd.jsonl',",
|
|
143
|
+
" excerptOrSummary: 'build', observedAt: '2026-06-27T00:00:00Z',",
|
|
144
|
+
" collectedBy: 'flow-agents', passing: passing,",
|
|
145
|
+
" execution: { runner: 'bash', label: label, isError: !passing, exitCode: passing ? 0 : 1 } }],",
|
|
146
|
+
" policies: [], events: [] };",
|
|
147
|
+
"fs.writeFileSync(dest, JSON.stringify(b, null, 2));",
|
|
148
|
+
]
|
|
149
|
+
with open(out, 'w') as fh:
|
|
150
|
+
fh.write('\n'.join(code_lines) + '\n')
|
|
151
|
+
PY
|
|
152
|
+
fi
|
|
153
|
+
node "$helper" "$dest" "$label" "$passing"
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
# setup_session <artifactRoot> <slug> <bundleSrc> — a minimal deliverable session, reused from
|
|
157
|
+
# test_publish_delivery.sh's own helper.
|
|
158
|
+
setup_session() {
|
|
159
|
+
local aroot="$1" slug="$2" bundle_src="$3"
|
|
160
|
+
local session_dir="$aroot/$slug"
|
|
161
|
+
mkdir -p "$aroot"
|
|
162
|
+
flow_agents_node "$WRITER" ensure-session \
|
|
163
|
+
--artifact-root "$aroot" --task-slug "$slug" \
|
|
164
|
+
--title "Verify Hold Test" \
|
|
165
|
+
--summary "Test verify-hold gate." \
|
|
166
|
+
--criterion "Bundle published" \
|
|
167
|
+
--timestamp "2026-07-04T10:00:00Z" >/dev/null 2>&1
|
|
168
|
+
flow_agents_node "$WRITER" init-plan "$session_dir/${slug}--deliver.md" \
|
|
169
|
+
--source-request "Test" --summary "Test" \
|
|
170
|
+
--timestamp "2026-07-04T10:01:00Z" >/dev/null 2>&1
|
|
171
|
+
flow_agents_node "$WRITER" record-evidence "$session_dir" \
|
|
172
|
+
--verdict pass \
|
|
173
|
+
--check-json '{"id":"build","kind":"build","status":"pass","summary":"ok"}' \
|
|
174
|
+
--timestamp "2026-07-04T10:02:00Z" >/dev/null 2>&1
|
|
175
|
+
flow_agents_node "$WRITER" record-critique "$session_dir" \
|
|
176
|
+
--verdict pass --summary "ok." \
|
|
177
|
+
--timestamp "2026-07-04T10:03:00Z" >/dev/null 2>&1
|
|
178
|
+
if [[ -n "$bundle_src" && -f "$bundle_src" ]]; then
|
|
179
|
+
cp "$bundle_src" "$session_dir/trust.bundle"
|
|
180
|
+
fi
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
# write_steering_fixture_repo <repoRoot> <slug> <statusJsonUpdatedAt>
|
|
184
|
+
# Minimal repo scaffold + a bare in_progress state.json so workflow-steering.js's
|
|
185
|
+
# latestWorkflowState() finds an active `current` sidecar for the given slug. Writes the JSON
|
|
186
|
+
# via a node helper (never a raw shell heredoc) matching this file's other fixture-writer
|
|
187
|
+
# helpers above.
|
|
188
|
+
write_steering_fixture_repo() {
|
|
189
|
+
local repoRoot="$1" slug="$2" updatedAt="$3"
|
|
190
|
+
mkdir -p "$repoRoot/.kontourai/flow-agents/$slug" "$repoRoot/docs"
|
|
191
|
+
printf '# Repo\n' > "$repoRoot/AGENTS.md"
|
|
192
|
+
printf '# Context Map\n' > "$repoRoot/docs/context-map.md"
|
|
193
|
+
node "$ROOT_SCRATCH_STATE_WRITER" \
|
|
194
|
+
"$repoRoot/.kontourai/flow-agents/$slug/state.dot.json.tmp" \
|
|
195
|
+
"$slug" "in_progress" "execution" "$updatedAt" "Continue work."
|
|
196
|
+
mv "$repoRoot/.kontourai/flow-agents/$slug/state.dot.json.tmp" "$repoRoot/.kontourai/flow-agents/$slug/state.json"
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
ROOT_SCRATCH_STATE_WRITER="$TMPDIR_EVAL/write-state-json.js"
|
|
200
|
+
cat > "$ROOT_SCRATCH_STATE_WRITER" << 'JSEOF'
|
|
201
|
+
const fs = require("fs");
|
|
202
|
+
const [,, dest, slug, status, phase, updatedAt, summary] = process.argv;
|
|
203
|
+
const state = {
|
|
204
|
+
schema_version: "1.0",
|
|
205
|
+
task_slug: slug,
|
|
206
|
+
status,
|
|
207
|
+
phase,
|
|
208
|
+
updated_at: updatedAt,
|
|
209
|
+
next_action: { status, summary },
|
|
210
|
+
};
|
|
211
|
+
fs.writeFileSync(dest, JSON.stringify(state, null, 2));
|
|
212
|
+
JSEOF
|
|
213
|
+
|
|
214
|
+
if [[ ! -f "$CLI" ]]; then
|
|
215
|
+
echo "build/src/cli.js not found — run 'npm run build' first" >&2
|
|
216
|
+
exit 1
|
|
217
|
+
fi
|
|
218
|
+
flow_agents_build_ts || { echo "build failed" >&2; exit 1; }
|
|
219
|
+
|
|
220
|
+
echo "=== verify-hold gate (#293) ==="
|
|
221
|
+
|
|
222
|
+
# ─── 1. Superseded A->B blocks A; a stale self-claim (reclaimable) also blocks (AC1) ───────
|
|
223
|
+
echo "--- 1. superseded A->B blocks actor A (held, holder=B); stale self-claim (reclaimable) also blocks (AC1) ---"
|
|
224
|
+
|
|
225
|
+
SUPERSEDE_SLUG="verify-hold-superseded"
|
|
226
|
+
SUPERSEDE_DIR="$ARTIFACT_ROOT/$SUPERSEDE_SLUG"
|
|
227
|
+
|
|
228
|
+
flow_agents_node "$WRITER" ensure-session \
|
|
229
|
+
--artifact-root "$ARTIFACT_ROOT" \
|
|
230
|
+
--task-slug "$SUPERSEDE_SLUG" \
|
|
231
|
+
--actor eval-actor-a-hold \
|
|
232
|
+
--source-request "Actor A establishes the original claim." \
|
|
233
|
+
--summary "Actor A original holder." \
|
|
234
|
+
>"$TMPDIR_EVAL/vh-a-ensure.out" 2>"$TMPDIR_EVAL/vh-a-ensure.err"
|
|
235
|
+
A_RECORD="$ARTIFACT_ROOT/assignment/$SUPERSEDE_SLUG.json"
|
|
236
|
+
ACTOR_KEY_A="$(actor_key_from_record "$A_RECORD")"
|
|
237
|
+
[[ -n "$ACTOR_KEY_A" ]] && pass "actor A's claim established (setup)" || fail "actor A's claim setup failed: $(cat "$TMPDIR_EVAL/vh-a-ensure.out" "$TMPDIR_EVAL/vh-a-ensure.err")"
|
|
238
|
+
|
|
239
|
+
# Manufacture staleness for A's own claim (an "at" well in the past, past the 1800s ttl), then
|
|
240
|
+
# actor B supersedes A via --supersede-stale (the same mechanism
|
|
241
|
+
# test_ensure_session_ownership_guard.sh's stale-takeover section already establishes) —
|
|
242
|
+
# performLocalSupersede writes B's actor_key as the canonical bare value (F1 fix), so
|
|
243
|
+
# runVerifyHold's join sees a genuinely fresh, non-self holder for A's re-check.
|
|
244
|
+
append_liveness_event "$ARTIFACT_ROOT" "$SUPERSEDE_SLUG" "$ACTOR_KEY_A" "2026-06-01T10:00:00Z" 1800
|
|
245
|
+
|
|
246
|
+
flow_agents_node "$WRITER" ensure-session \
|
|
247
|
+
--artifact-root "$ARTIFACT_ROOT" \
|
|
248
|
+
--task-slug "$SUPERSEDE_SLUG" \
|
|
249
|
+
--actor eval-actor-b-hold \
|
|
250
|
+
--supersede-stale \
|
|
251
|
+
--source-request "Actor B takes over the stale claim." \
|
|
252
|
+
--summary "Actor B takeover." \
|
|
253
|
+
>"$TMPDIR_EVAL/vh-b-supersede.out" 2>"$TMPDIR_EVAL/vh-b-supersede.err"
|
|
254
|
+
B_SUPERSEDE_EXIT=$?
|
|
255
|
+
[[ $B_SUPERSEDE_EXIT -eq 0 ]] && pass "actor B's --supersede-stale takeover succeeds (setup)" || fail "actor B's takeover unexpectedly failed: $(cat "$TMPDIR_EVAL/vh-b-supersede.out" "$TMPDIR_EVAL/vh-b-supersede.err")"
|
|
256
|
+
|
|
257
|
+
B_RECORD="$ARTIFACT_ROOT/assignment/$SUPERSEDE_SLUG.json"
|
|
258
|
+
ACTOR_KEY_B="$(actor_key_from_record "$B_RECORD")"
|
|
259
|
+
[[ -n "$ACTOR_KEY_B" ]] && pass "actor B's canonical actor_key recovered from the superseded record (setup)" || fail "could not recover actor B's actor_key"
|
|
260
|
+
|
|
261
|
+
# Fresh liveness heartbeat for B so the join classifies A's re-check as held(holder=B), not
|
|
262
|
+
# merely reclaimable (the un-ambiguous "someone else has a fresh claim" row of the mapping
|
|
263
|
+
# table) — matches Conflict #3's resolution (no literal "superseded" state; a superseded-away
|
|
264
|
+
# actor's own re-check naturally resolves to held/holder=successor).
|
|
265
|
+
NOW_ISO="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
|
266
|
+
append_liveness_event "$ARTIFACT_ROOT" "$SUPERSEDE_SLUG" "$ACTOR_KEY_B" "$NOW_ISO" 1800
|
|
267
|
+
|
|
268
|
+
mkdir -p "$SUPERSEDE_DIR"
|
|
269
|
+
if flow_agents_node "$WRITER" verify-hold "$SUPERSEDE_DIR" --actor "$ACTOR_KEY_A" \
|
|
270
|
+
>"$TMPDIR_EVAL/vh-a-check.out" 2>"$TMPDIR_EVAL/vh-a-check.err"; then
|
|
271
|
+
fail "actor A's verify-hold on the superseded subject should have exited non-zero (AC1)"
|
|
272
|
+
else
|
|
273
|
+
pass "actor A's verify-hold on the superseded subject exits non-zero (AC1)"
|
|
274
|
+
fi
|
|
275
|
+
[[ "$(json_query "$TMPDIR_EVAL/vh-a-check.out" "ok")" == "false" ]] && pass "verify-hold JSON reports ok:false for actor A (AC1)" || fail "verify-hold JSON did not report ok:false: $(cat "$TMPDIR_EVAL/vh-a-check.out")"
|
|
276
|
+
[[ "$(json_query "$TMPDIR_EVAL/vh-a-check.out" "effective_state")" == "held" ]] && pass "verify-hold JSON reports effective_state:held (holder=B) for actor A (AC1)" || fail "verify-hold JSON did not report held: $(cat "$TMPDIR_EVAL/vh-a-check.out")"
|
|
277
|
+
GUIDANCE_LEN_A="$(json_query "$TMPDIR_EVAL/vh-a-check.out" "guidance.length")"
|
|
278
|
+
[[ "$GUIDANCE_LEN_A" -gt 0 ]] && pass "verify-hold's guidance array is non-empty for actor A's block (AC1)" || fail "verify-hold's guidance array was empty for actor A's block"
|
|
279
|
+
grep -qF "$ACTOR_KEY_B" "$TMPDIR_EVAL/vh-a-check.out" && pass "verify-hold JSON names actor B as the current holder (AC1)" || fail "verify-hold JSON did not name actor B as holder: $(cat "$TMPDIR_EVAL/vh-a-check.out")"
|
|
280
|
+
grep -qiF -- "--supersede-stale" "$TMPDIR_EVAL/vh-a-check.out" && pass "verify-hold's guidance names --supersede-stale as the reconcile path (AC1)" || fail "verify-hold's guidance did not name --supersede-stale: $(cat "$TMPDIR_EVAL/vh-a-check.out")"
|
|
281
|
+
|
|
282
|
+
# ─── 1b. A manufactured STALE claim (reclaimable) ALSO blocks a re-checking actor (Stop-short risk) ─
|
|
283
|
+
echo "--- 1b. stale claim (reclaimable) blocks a re-checking actor, never a silent pass (AC1) ---"
|
|
284
|
+
|
|
285
|
+
# computeEffectiveState's self-check (record.actor_key === selfActor) is checked BEFORE
|
|
286
|
+
# freshness (assignment-provider.ts around line 366) — so the ORIGINAL claimant's own re-check
|
|
287
|
+
# always self-recognizes as self_is_holder regardless of staleness (matching
|
|
288
|
+
# enforceEnsureSessionOwnership's own precedent: its reclaimable-takeover eval section always
|
|
289
|
+
# has a DIFFERENT actor re-check the stale claim, never the same stale actor checking itself).
|
|
290
|
+
# The Stop-short risk this guards against is therefore: a DIFFERENT, not-yet-superseded re-checking
|
|
291
|
+
# actor (e.g. a monitoring/handoff tool, or a second session considering takeover) must see
|
|
292
|
+
# reclaimable and be BLOCKED — never silently treated as "no one holds this, proceed" — until an
|
|
293
|
+
# explicit --supersede-stale takeover happens. This is exactly what "reclaimable is never PASS"
|
|
294
|
+
# protects: it would be wrong to auto-pass verify-hold for a reclaimable subject just because the
|
|
295
|
+
# original holder's liveness went stale.
|
|
296
|
+
RECLAIMABLE_SLUG="verify-hold-reclaimable"
|
|
297
|
+
RECLAIMABLE_DIR="$ARTIFACT_ROOT/$RECLAIMABLE_SLUG"
|
|
298
|
+
flow_agents_node "$WRITER" ensure-session \
|
|
299
|
+
--artifact-root "$ARTIFACT_ROOT" \
|
|
300
|
+
--task-slug "$RECLAIMABLE_SLUG" \
|
|
301
|
+
--actor eval-actor-stale-original \
|
|
302
|
+
--source-request "Actor establishes the soon-to-be-stale claim." \
|
|
303
|
+
--summary "Original holder." \
|
|
304
|
+
>"$TMPDIR_EVAL/vh-stale-setup.out" 2>"$TMPDIR_EVAL/vh-stale-setup.err"
|
|
305
|
+
RECLAIMABLE_RECORD="$ARTIFACT_ROOT/assignment/$RECLAIMABLE_SLUG.json"
|
|
306
|
+
ACTOR_KEY_STALE="$(actor_key_from_record "$RECLAIMABLE_RECORD")"
|
|
307
|
+
# A liveness event whose `at` is well past ttlSeconds (1800s) relative to real wall-clock now,
|
|
308
|
+
# with NO fresher heartbeat — the record exists but is no longer backed by a fresh liveness
|
|
309
|
+
# claim, so a DIFFERENT actor's re-check join classifies it reclaimable (not held/free).
|
|
310
|
+
append_liveness_event "$ARTIFACT_ROOT" "$RECLAIMABLE_SLUG" "$ACTOR_KEY_STALE" "2026-06-01T10:00:00Z" 1800
|
|
311
|
+
|
|
312
|
+
mkdir -p "$RECLAIMABLE_DIR"
|
|
313
|
+
if flow_agents_node "$WRITER" verify-hold "$RECLAIMABLE_DIR" --actor eval-actor-stale-rechecker \
|
|
314
|
+
>"$TMPDIR_EVAL/vh-reclaimable.out" 2>"$TMPDIR_EVAL/vh-reclaimable.err"; then
|
|
315
|
+
fail "verify-hold on a reclaimable (stale) claim, re-checked by a DIFFERENT actor, should have exited non-zero — reclaimable must never be silently treated as pass (AC1)"
|
|
316
|
+
else
|
|
317
|
+
pass "verify-hold on a reclaimable (stale) claim, re-checked by a different actor, exits non-zero — reclaimable is never treated as pass (AC1)"
|
|
318
|
+
fi
|
|
319
|
+
[[ "$(json_query "$TMPDIR_EVAL/vh-reclaimable.out" "effective_state")" == "reclaimable" ]] && pass "verify-hold JSON reports effective_state:reclaimable for the stale claim's re-check (AC1)" || fail "verify-hold JSON did not report reclaimable: $(cat "$TMPDIR_EVAL/vh-reclaimable.out")"
|
|
320
|
+
RECLAIMABLE_GUIDANCE_LEN="$(json_query "$TMPDIR_EVAL/vh-reclaimable.out" "guidance.length")"
|
|
321
|
+
[[ "$RECLAIMABLE_GUIDANCE_LEN" -gt 0 ]] && pass "verify-hold's guidance array is non-empty for the reclaimable block (AC1)" || fail "verify-hold's guidance array was empty for the reclaimable block"
|
|
322
|
+
|
|
323
|
+
# Regression: the ORIGINAL claimant's own re-check still self-recognizes (self_is_holder wins
|
|
324
|
+
# over staleness by design — this is NOT the reclaimable case; asserted here so the eval does not
|
|
325
|
+
# accidentally rely on the wrong actor identity to prove the reclaimable-blocks assertion above).
|
|
326
|
+
if flow_agents_node "$WRITER" verify-hold "$RECLAIMABLE_DIR" --actor "$ACTOR_KEY_STALE" \
|
|
327
|
+
>"$TMPDIR_EVAL/vh-reclaimable-self.out" 2>"$TMPDIR_EVAL/vh-reclaimable-self.err"; then
|
|
328
|
+
pass "the original claimant's own re-check on its stale claim still self-recognizes (self_is_holder), confirming reclaimable only applies to a re-check by a DIFFERENT actor (AC1 regression)"
|
|
329
|
+
else
|
|
330
|
+
fail "the original claimant's own re-check unexpectedly refused: $(cat "$TMPDIR_EVAL/vh-reclaimable-self.out" "$TMPDIR_EVAL/vh-reclaimable-self.err")"
|
|
331
|
+
fi
|
|
332
|
+
|
|
333
|
+
# ─── 1c. Liveness-only presence (NO durable assignment record) never blocks a mismatched actor
|
|
334
|
+
# (bug fix, #397) ───────────────────────────────────────────────────────────────────────────
|
|
335
|
+
echo "--- 1c. liveness claim by another actor with NO assignment record passes for a mismatched actor (#397 fix) ---"
|
|
336
|
+
|
|
337
|
+
# This is the exact CI-reproduced false-block: computeEffectiveState's `!isAssigned` branch
|
|
338
|
+
# (assignment-provider.ts) returns held/liveness_claim_present_assignment_lagging when there is
|
|
339
|
+
# a fresh liveness `claim` event for the subject but NO assignment record was ever written
|
|
340
|
+
# (never claimed via ensure-session/assignment-provider claim -- e.g. a liveness-only heartbeat
|
|
341
|
+
# with no durable follow-through, exactly what antigaming-suite.sh's own liveness-seeding does
|
|
342
|
+
# for its own unrelated tests). runVerifyHold must PASS this case: liveness alone is advisory
|
|
343
|
+
# everywhere else in the system, and this is the ONE hard gate, so it must fence the durable
|
|
344
|
+
# ASSIGNMENT hold, never ambient liveness presence.
|
|
345
|
+
LIVENESS_ONLY_SLUG="verify-hold-liveness-only-no-assignment"
|
|
346
|
+
LIVENESS_ONLY_DIR="$ARTIFACT_ROOT/$LIVENESS_ONLY_SLUG"
|
|
347
|
+
mkdir -p "$LIVENESS_ONLY_DIR"
|
|
348
|
+
|
|
349
|
+
# Fresh liveness `claim` event by "other-actor" -- NO write_assignment_record call at all, so
|
|
350
|
+
# readLocalAssignmentStatus resolves no record/no assignee for this slug (isAssigned === false).
|
|
351
|
+
append_liveness_event "$ARTIFACT_ROOT" "$LIVENESS_ONLY_SLUG" "other-actor" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" 1800
|
|
352
|
+
|
|
353
|
+
if flow_agents_node "$WRITER" verify-hold "$LIVENESS_ONLY_DIR" --actor "ci-mismatched-actor" \
|
|
354
|
+
>"$TMPDIR_EVAL/vh-liveness-only.out" 2>"$TMPDIR_EVAL/vh-liveness-only.err"; then
|
|
355
|
+
pass "verify-hold PASSES for a mismatched actor when only ambient liveness (no assignment record) exists (#397 fix)"
|
|
356
|
+
else
|
|
357
|
+
fail "verify-hold false-blocked a mismatched actor on liveness-only presence (NO assignment record) -- #397 regression: $(cat "$TMPDIR_EVAL/vh-liveness-only.out" "$TMPDIR_EVAL/vh-liveness-only.err")"
|
|
358
|
+
fi
|
|
359
|
+
[[ "$(json_query "$TMPDIR_EVAL/vh-liveness-only.out" "ok")" == "true" ]] && pass "verify-hold JSON reports ok:true for the liveness-only, no-assignment case (#397 fix)" || fail "verify-hold JSON did not report ok:true for the liveness-only case: $(cat "$TMPDIR_EVAL/vh-liveness-only.out")"
|
|
360
|
+
[[ "$(json_query "$TMPDIR_EVAL/vh-liveness-only.out" "effective_state")" == "held" ]] && pass "verify-hold JSON still reports effective_state:held for the liveness-only case (computeEffectiveState's own label is unchanged -- only the publish-decision mapping changed) (#397 fix)" || fail "verify-hold JSON did not report held: $(cat "$TMPDIR_EVAL/vh-liveness-only.out")"
|
|
361
|
+
[[ "$(json_query "$TMPDIR_EVAL/vh-liveness-only.out" "reason")" == "liveness_claim_present_assignment_lagging" ]] && pass "verify-hold JSON's reason is liveness_claim_present_assignment_lagging (the precise reason this fix targets) (#397 fix)" || fail "verify-hold JSON's reason was not liveness_claim_present_assignment_lagging: $(cat "$TMPDIR_EVAL/vh-liveness-only.out")"
|
|
362
|
+
|
|
363
|
+
# ─── 1d. Unstable (ancestry/unresolved) current actor is advisory-only, never hard-blocked;
|
|
364
|
+
# the SAME seed still hard-blocks a STABLE, differing FLOW_AGENTS_ACTOR (SECOND CI-blocking
|
|
365
|
+
# false-block fix, this iteration) ──────────────────────────────────────────────────────────
|
|
366
|
+
echo "--- 1d. unstable current actor (ancestry fallback) is advisory-only (ok:true); a STABLE differing actor still BLOCKS (AC1) ---"
|
|
367
|
+
|
|
368
|
+
# This pins the advisory-degradation path for an UNSTABLE actor identity: with no explicit override,
|
|
369
|
+
# no native runtime session id, AND no CI-provider identity (#398), resolveActor() falls through to
|
|
370
|
+
# the process-ancestry (or unresolved) layer -- an identity that is NOT guaranteed to match the
|
|
371
|
+
# identity that created the claim below, so the gate must degrade to advisory (never hard-block).
|
|
372
|
+
# NOTE (#398): this case must EXPLICITLY neutralize the CI-provider markers. When this suite runs in
|
|
373
|
+
# real CI (GitHub Actions etc.), those markers are set and resolveActor() would now resolve a STABLE
|
|
374
|
+
# `ci-runtime:*` actor -- which correctly ENFORCES (that IS the #398 payoff, covered by
|
|
375
|
+
# test_ci_actor_identity.sh). Reproducing the ancestry/unstable scenario therefore requires the test
|
|
376
|
+
# to control ALL identity inputs, not rely on the ambient absence of CI env vars. Seed a fresh
|
|
377
|
+
# assignment claim held by a clearly different, stable actor ("eval-actor-unstable-holder"), then:
|
|
378
|
+
# (a) run verify-hold with NO override, NO native session id, and NO CI markers (ancestry fallback)
|
|
379
|
+
# -- must be advisory-only: ok:true, exit 0, reason actor-identity-unstable-advisory-only.
|
|
380
|
+
# (b) run verify-hold with a STABLE FLOW_AGENTS_ACTOR that legitimately differs from the holder
|
|
381
|
+
# -- must still BLOCK exactly as before (zombie protection intact under a stable identity).
|
|
382
|
+
UNSTABLE_SLUG="verify-hold-unstable-actor"
|
|
383
|
+
UNSTABLE_DIR="$ARTIFACT_ROOT/$UNSTABLE_SLUG"
|
|
384
|
+
write_assignment_record "$ARTIFACT_ROOT" "$UNSTABLE_SLUG" "unstable-holder-session" "eval-actor-unstable-holder"
|
|
385
|
+
append_liveness_event "$ARTIFACT_ROOT" "$UNSTABLE_SLUG" "eval-actor-unstable-holder" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" 1800
|
|
386
|
+
mkdir -p "$UNSTABLE_DIR"
|
|
387
|
+
|
|
388
|
+
# (a) Unstable current actor: unset the explicit override, the native runtime session id, AND every
|
|
389
|
+
# CI-provider marker (#398) so resolveActor() genuinely falls through to process-ancestry/unresolved
|
|
390
|
+
# regardless of whether this suite is running inside a CI job.
|
|
391
|
+
if (unset FLOW_AGENTS_ACTOR CLAUDE_CODE_SESSION_ID CODEX_SESSION_ID OPENCODE_SESSION_ID PI_SESSION_ID CLAUDECODE \
|
|
392
|
+
GITHUB_ACTIONS GITLAB_CI CIRCLECI JENKINS_URL TF_BUILD BUILDKITE; \
|
|
393
|
+
flow_agents_node "$WRITER" verify-hold "$UNSTABLE_DIR") >"$TMPDIR_EVAL/vh-unstable.out" 2>"$TMPDIR_EVAL/vh-unstable.err"; then
|
|
394
|
+
pass "verify-hold with an unstable (ancestry/unresolved) current actor does NOT hard-block a subject held by a different actor (advisory-only, AC1 SECOND fix)"
|
|
395
|
+
else
|
|
396
|
+
fail "verify-hold with an unstable current actor incorrectly hard-blocked -- this IS the CI false-block this fix targets: $(cat "$TMPDIR_EVAL/vh-unstable.out" "$TMPDIR_EVAL/vh-unstable.err")"
|
|
397
|
+
fi
|
|
398
|
+
[[ "$(json_query "$TMPDIR_EVAL/vh-unstable.out" "ok")" == "true" ]] && pass "unstable-actor verify-hold JSON reports ok:true (AC1 SECOND fix)" || fail "unstable-actor verify-hold JSON did not report ok:true: $(cat "$TMPDIR_EVAL/vh-unstable.out")"
|
|
399
|
+
[[ "$(json_query "$TMPDIR_EVAL/vh-unstable.out" "reason")" == "actor-identity-unstable-advisory-only" ]] && pass "unstable-actor verify-hold JSON's reason is actor-identity-unstable-advisory-only (AC1 SECOND fix)" || fail "unstable-actor verify-hold JSON's reason was not actor-identity-unstable-advisory-only: $(cat "$TMPDIR_EVAL/vh-unstable.out")"
|
|
400
|
+
grep -qiF "advisory only for unstable identities" "$TMPDIR_EVAL/vh-unstable.err" && pass "unstable-actor verify-hold logs a visible stderr advisory note, never silent (AC1 SECOND fix)" || fail "unstable-actor verify-hold did not log the advisory stderr note: $(cat "$TMPDIR_EVAL/vh-unstable.err")"
|
|
401
|
+
|
|
402
|
+
# (b) STABLE but differing actor (explicit FLOW_AGENTS_ACTOR override) on the SAME seed -- must
|
|
403
|
+
# still hard-block exactly as before this fix (zombie protection intact under a stable identity).
|
|
404
|
+
if FLOW_AGENTS_ACTOR="eval-actor-unstable-rechecker" flow_agents_node "$WRITER" verify-hold "$UNSTABLE_DIR" >"$TMPDIR_EVAL/vh-stable-differs.out" 2>"$TMPDIR_EVAL/vh-stable-differs.err"; then
|
|
405
|
+
fail "verify-hold with a STABLE, differing FLOW_AGENTS_ACTOR should have hard-blocked on the same seed (AC1 SECOND fix, zombie-protection regression)"
|
|
406
|
+
else
|
|
407
|
+
pass "verify-hold with a STABLE, differing FLOW_AGENTS_ACTOR still hard-blocks on the same seed (AC1, zombie protection intact under stable identity)"
|
|
408
|
+
fi
|
|
409
|
+
[[ "$(json_query "$TMPDIR_EVAL/vh-stable-differs.out" "ok")" == "false" ]] && pass "stable-differing-actor verify-hold JSON reports ok:false (AC1 SECOND fix regression guard)" || fail "stable-differing-actor verify-hold JSON did not report ok:false: $(cat "$TMPDIR_EVAL/vh-stable-differs.out")"
|
|
410
|
+
grep -qF "eval-actor-unstable-holder" "$TMPDIR_EVAL/vh-stable-differs.out" && pass "stable-differing-actor verify-hold JSON still names the current holder (AC1 SECOND fix regression guard)" || fail "stable-differing-actor verify-hold JSON did not name the holder: $(cat "$TMPDIR_EVAL/vh-stable-differs.out")"
|
|
411
|
+
|
|
412
|
+
# ─── 2. Composition: record-release / publish-delivery as actor A refuse, no delivery/ write (AC1, AC3) ─
|
|
413
|
+
echo "--- 2. publishDelivery()/record-release as actor A (superseded) refuse with NotFreshHolderError, never write delivery/ (AC1, AC3) ---"
|
|
414
|
+
|
|
415
|
+
REPO_A="$TMPDIR_EVAL/repo-a"
|
|
416
|
+
mkdir -p "$REPO_A/kits"
|
|
417
|
+
AROOT_A="$REPO_A/.kontourai/flow-agents"
|
|
418
|
+
COMPOSE_SLUG="verify-hold-compose-block"
|
|
419
|
+
COMPOSE_DIR="$AROOT_A/$COMPOSE_SLUG"
|
|
420
|
+
|
|
421
|
+
BUNDLE_A="$TMPDIR_EVAL/bundle-a.json"
|
|
422
|
+
write_bundle_to "$BUNDLE_A" "node --version" "true"
|
|
423
|
+
setup_session "$AROOT_A" "$COMPOSE_SLUG" "$BUNDLE_A"
|
|
424
|
+
|
|
425
|
+
# Seed a fresh, un-ambiguous superseded-A->B fixture directly (write_assignment_record +
|
|
426
|
+
# append_liveness_event, matching section 1's shape), then run record-release/publish-delivery
|
|
427
|
+
# AS ACTOR A (FLOW_AGENTS_ACTOR override) with a manifest that makes the bundle shape-VALID —
|
|
428
|
+
# isolating the hold gate specifically here (the shape gate's ordering is proven separately in
|
|
429
|
+
# section 4 below).
|
|
430
|
+
write_assignment_record "$AROOT_A" "$COMPOSE_SLUG" "eval-actor-b-compose" "eval-actor-b-compose"
|
|
431
|
+
append_liveness_event "$AROOT_A" "$COMPOSE_SLUG" "eval-actor-b-compose" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" 1800
|
|
432
|
+
|
|
433
|
+
rr_out=$(FLOW_AGENTS_ACTOR="eval-actor-a-compose" TRUST_RECONCILE_COMMANDS="node --version" flow_agents_node "$WRITER" record-release "$COMPOSE_DIR" \
|
|
434
|
+
--decision merge \
|
|
435
|
+
--gate-json '{"name":"merge","status":"pass","summary":"Ready."}' \
|
|
436
|
+
--summary "Release." --repo-root "$REPO_A" \
|
|
437
|
+
--timestamp "2026-07-04T10:04:00Z" 2>&1)
|
|
438
|
+
rr_exit=$?
|
|
439
|
+
if [[ $rr_exit -ne 0 ]]; then
|
|
440
|
+
pass "record-release as superseded-away actor A exits non-zero (AC1)"
|
|
441
|
+
else
|
|
442
|
+
fail "record-release as superseded-away actor A should have exited non-zero: $rr_out"
|
|
443
|
+
fi
|
|
444
|
+
echo "$rr_out" | grep -qiF "verify-hold gate" && pass "record-release's refusal names the verify-hold gate (AC1, AC3)" || fail "record-release's refusal did not name the verify-hold gate: $rr_out"
|
|
445
|
+
[[ ! -d "$REPO_A/delivery/$COMPOSE_SLUG" ]] && pass "record-release's verify-hold refusal never created delivery/$COMPOSE_SLUG/ (AC1)" || fail "delivery/$COMPOSE_SLUG/ was created despite the verify-hold refusal"
|
|
446
|
+
|
|
447
|
+
pd_out=$(FLOW_AGENTS_ACTOR="eval-actor-a-compose" TRUST_RECONCILE_COMMANDS="node --version" flow_agents_node "$WRITER" publish-delivery "$COMPOSE_DIR" --repo-root "$REPO_A" 2>&1)
|
|
448
|
+
pd_exit=$?
|
|
449
|
+
if [[ $pd_exit -ne 0 ]]; then
|
|
450
|
+
pass "publish-delivery as superseded-away actor A exits non-zero (AC1, AC3)"
|
|
451
|
+
else
|
|
452
|
+
fail "publish-delivery as superseded-away actor A should have exited non-zero: $pd_out"
|
|
453
|
+
fi
|
|
454
|
+
echo "$pd_out" | grep -qiF "verify-hold refused publish" && pass "publish-delivery's thrown error message is the NotFreshHolderError text (AC3)" || fail "publish-delivery's error was not the NotFreshHolderError message: $pd_out"
|
|
455
|
+
[[ ! -d "$REPO_A/delivery/$COMPOSE_SLUG" ]] && pass "publish-delivery's verify-hold refusal never created delivery/$COMPOSE_SLUG/ (AC1)" || fail "delivery/$COMPOSE_SLUG/ was created despite the publish-delivery refusal"
|
|
456
|
+
|
|
457
|
+
# ─── 3. Fresh holder + free subject pass (AC2) ─────────────────────────────────────────────
|
|
458
|
+
echo "--- 3. actor B (fresh holder) passes; a free subject passes for any actor (AC2) ---"
|
|
459
|
+
|
|
460
|
+
if flow_agents_node "$WRITER" verify-hold "$SUPERSEDE_DIR" --actor "$ACTOR_KEY_B" \
|
|
461
|
+
>"$TMPDIR_EVAL/vh-b-check.out" 2>"$TMPDIR_EVAL/vh-b-check.err"; then
|
|
462
|
+
pass "actor B's (fresh holder) verify-hold on the same subject exits 0 (AC2)"
|
|
463
|
+
else
|
|
464
|
+
fail "actor B's verify-hold unexpectedly refused: $(cat "$TMPDIR_EVAL/vh-b-check.out" "$TMPDIR_EVAL/vh-b-check.err")"
|
|
465
|
+
fi
|
|
466
|
+
[[ "$(json_query "$TMPDIR_EVAL/vh-b-check.out" "ok")" == "true" ]] && pass "verify-hold JSON reports ok:true for actor B (AC2)" || fail "verify-hold JSON did not report ok:true for actor B: $(cat "$TMPDIR_EVAL/vh-b-check.out")"
|
|
467
|
+
|
|
468
|
+
FREE_SLUG="verify-hold-free-subject"
|
|
469
|
+
FREE_DIR="$ARTIFACT_ROOT/$FREE_SLUG"
|
|
470
|
+
mkdir -p "$FREE_DIR"
|
|
471
|
+
if flow_agents_node "$WRITER" verify-hold "$FREE_DIR" --actor "eval-actor-any-reader" \
|
|
472
|
+
>"$TMPDIR_EVAL/vh-free.out" 2>"$TMPDIR_EVAL/vh-free.err"; then
|
|
473
|
+
pass "verify-hold on a free (no assignment record) subject exits 0 for any actor (AC2)"
|
|
474
|
+
else
|
|
475
|
+
fail "verify-hold on a free subject unexpectedly refused: $(cat "$TMPDIR_EVAL/vh-free.out" "$TMPDIR_EVAL/vh-free.err")"
|
|
476
|
+
fi
|
|
477
|
+
[[ "$(json_query "$TMPDIR_EVAL/vh-free.out" "effective_state")" == "free" ]] && pass "verify-hold JSON reports effective_state:free for the untracked subject (AC2)" || fail "verify-hold JSON did not report free: $(cat "$TMPDIR_EVAL/vh-free.out")"
|
|
478
|
+
|
|
479
|
+
# ─── 4. #356 shape-gate composition ordering: shape runs BEFORE hold (AC3) ─────────────────
|
|
480
|
+
echo "--- 4. composition ordering: shape-invalid+not-held -> InvalidBundleShapeError; shape-valid+not-held -> NotFreshHolderError (AC3) ---"
|
|
481
|
+
|
|
482
|
+
REPO4="$TMPDIR_EVAL/repo4"
|
|
483
|
+
mkdir -p "$REPO4/kits"
|
|
484
|
+
AROOT4="$REPO4/.kontourai/flow-agents"
|
|
485
|
+
|
|
486
|
+
# 4a. BOTH shape-invalid AND not-held: no TRUST_RECONCILE_COMMANDS env, so "node --version"
|
|
487
|
+
# resolves to no manifest entry (shape-invalid, matching test_publish_delivery.sh TEST 6's own
|
|
488
|
+
# not-run fixture); PLUS not-held via a superseded-away FLOW_AGENTS_ACTOR override.
|
|
489
|
+
SLUG_4A="verify-hold-both-invalid"
|
|
490
|
+
DIR_4A="$AROOT4/$SLUG_4A"
|
|
491
|
+
BUNDLE_4A="$TMPDIR_EVAL/bundle-4a.json"
|
|
492
|
+
write_bundle_to "$BUNDLE_4A" "node --version" "true"
|
|
493
|
+
setup_session "$AROOT4" "$SLUG_4A" "$BUNDLE_4A"
|
|
494
|
+
|
|
495
|
+
write_assignment_record "$AROOT4" "$SLUG_4A" "eval-actor-holder-4a" "eval-actor-holder-4a"
|
|
496
|
+
append_liveness_event "$AROOT4" "$SLUG_4A" "eval-actor-holder-4a" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" 1800
|
|
497
|
+
|
|
498
|
+
both_invalid_out=$(FLOW_AGENTS_ACTOR="eval-actor-not-holder-4a" flow_agents_node "$WRITER" publish-delivery "$DIR_4A" --repo-root "$REPO4" 2>&1)
|
|
499
|
+
both_invalid_exit=$?
|
|
500
|
+
if [[ $both_invalid_exit -ne 0 ]]; then
|
|
501
|
+
pass "shape-invalid+not-held bundle: publish-delivery exits non-zero (AC3)"
|
|
502
|
+
else
|
|
503
|
+
fail "shape-invalid+not-held bundle should have exited non-zero: $both_invalid_out"
|
|
504
|
+
fi
|
|
505
|
+
echo "$both_invalid_out" | grep -qiF "reconcile-preflight shape check" && pass "shape-invalid+not-held bundle: SHAPE error fires specifically (InvalidBundleShapeError text), never the hold error (AC3, ordering proof)" || fail "shape-invalid+not-held bundle did not surface the shape-check error text: $both_invalid_out"
|
|
506
|
+
if echo "$both_invalid_out" | grep -qiF "verify-hold refused publish"; then
|
|
507
|
+
fail "shape-invalid+not-held bundle incorrectly surfaced the NotFreshHolderError text — the shape gate must run FIRST and refuse before the hold gate is ever reached (AC3 ordering violation)"
|
|
508
|
+
else
|
|
509
|
+
pass "shape-invalid+not-held bundle never surfaces the NotFreshHolderError text — confirms the shape gate runs strictly before the hold gate (AC3)"
|
|
510
|
+
fi
|
|
511
|
+
[[ ! -d "$REPO4/delivery/$SLUG_4A" ]] && pass "shape-invalid+not-held bundle: delivery/$SLUG_4A/ was never created (AC3)" || fail "delivery/$SLUG_4A/ was created despite the shape refusal"
|
|
512
|
+
|
|
513
|
+
# 4b. shape-VALID (manifest-matched) but not-held: proves the hold gate IS reachable once the
|
|
514
|
+
# shape gate passes — genuinely exercises the SECOND, distinct error type, not just "shape
|
|
515
|
+
# always wins".
|
|
516
|
+
SLUG_4B="verify-hold-shape-valid-not-held"
|
|
517
|
+
DIR_4B="$AROOT4/$SLUG_4B"
|
|
518
|
+
BUNDLE_4B="$TMPDIR_EVAL/bundle-4b.json"
|
|
519
|
+
write_bundle_to "$BUNDLE_4B" "node --version" "true"
|
|
520
|
+
setup_session "$AROOT4" "$SLUG_4B" "$BUNDLE_4B"
|
|
521
|
+
|
|
522
|
+
write_assignment_record "$AROOT4" "$SLUG_4B" "eval-actor-holder-4b" "eval-actor-holder-4b"
|
|
523
|
+
append_liveness_event "$AROOT4" "$SLUG_4B" "eval-actor-holder-4b" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" 1800
|
|
524
|
+
|
|
525
|
+
shape_valid_not_held_out=$(FLOW_AGENTS_ACTOR="eval-actor-not-holder-4b" TRUST_RECONCILE_COMMANDS="node --version" flow_agents_node "$WRITER" publish-delivery "$DIR_4B" --repo-root "$REPO4" 2>&1)
|
|
526
|
+
shape_valid_not_held_exit=$?
|
|
527
|
+
if [[ $shape_valid_not_held_exit -ne 0 ]]; then
|
|
528
|
+
pass "shape-valid+not-held bundle: publish-delivery exits non-zero (AC3)"
|
|
529
|
+
else
|
|
530
|
+
fail "shape-valid+not-held bundle should have exited non-zero: $shape_valid_not_held_out"
|
|
531
|
+
fi
|
|
532
|
+
echo "$shape_valid_not_held_out" | grep -qiF "verify-hold refused publish" && pass "shape-valid+not-held bundle: HOLD error fires specifically (NotFreshHolderError text) — the hold gate IS reachable once shape passes (AC3, ordering proof)" || fail "shape-valid+not-held bundle did not surface the NotFreshHolderError text: $shape_valid_not_held_out"
|
|
533
|
+
if echo "$shape_valid_not_held_out" | grep -qiF "reconcile-preflight shape check"; then
|
|
534
|
+
fail "shape-valid+not-held bundle incorrectly surfaced the shape-check error text — the bundle is shape-valid, only the hold gate should fire"
|
|
535
|
+
else
|
|
536
|
+
pass "shape-valid+not-held bundle never surfaces the shape-check error text — confirms the two gates are genuinely distinct, not conflated (AC3)"
|
|
537
|
+
fi
|
|
538
|
+
[[ ! -d "$REPO4/delivery/$SLUG_4B" ]] && pass "shape-valid+not-held bundle: delivery/$SLUG_4B/ was never created (AC3)" || fail "delivery/$SLUG_4B/ was created despite the hold refusal"
|
|
539
|
+
|
|
540
|
+
# ─── 4c. AC3 composition distinctness via .code/.instanceof (F3 fix, fix-plan iteration 1) ──
|
|
541
|
+
# The plan's own Stop-short risks section named this exact gap: the two error classes could be
|
|
542
|
+
# collapsed into one while keeping distinct message text, and a pure stderr string-match (4a/4b
|
|
543
|
+
# above) would never catch it. This harness imports the COMPILED build/src/cli/workflow-sidecar.js
|
|
544
|
+
# directly (never re-implements a parallel check) and calls the exported publishDelivery()
|
|
545
|
+
# against the SAME two fixtures 4a/4b already built above, asserting the CAUGHT error's `.code`
|
|
546
|
+
# discriminator and `instanceof` — a genuine type-discriminator defense, not a message-text match.
|
|
547
|
+
echo "--- 4c. composition distinctness: .code/instanceof assertion on the SAME 4a/4b fixtures (F3, AC3) ---"
|
|
548
|
+
|
|
549
|
+
# Compiled output is a real ES module (package.json "type": "module") -- require() cannot
|
|
550
|
+
# load it. Use dynamic import() via a file:// URL, matching this repo's own established
|
|
551
|
+
# dynamic-import-of-build-output convention (test_bundle_lifecycle.sh / test_checkpoint_signing.sh).
|
|
552
|
+
CODE_HARNESS="$TMPDIR_EVAL/verify-hold-code-harness.mjs"
|
|
553
|
+
python3 - "$CODE_HARNESS" << 'PY'
|
|
554
|
+
import sys
|
|
555
|
+
out = sys.argv[1]
|
|
556
|
+
code_lines = [
|
|
557
|
+
"import { pathToFileURL } from 'node:url';",
|
|
558
|
+
"",
|
|
559
|
+
"async function run() {",
|
|
560
|
+
" const buildJsPath = process.env.WORKFLOW_SIDECAR_BUILD_JS;",
|
|
561
|
+
" const mod = await import(pathToFileURL(buildJsPath).href);",
|
|
562
|
+
" const { publishDelivery, InvalidBundleShapeError, NotFreshHolderError } = mod;",
|
|
563
|
+
" const dir = process.argv[2];",
|
|
564
|
+
" const repoRoot = process.argv[3];",
|
|
565
|
+
" try {",
|
|
566
|
+
" await publishDelivery(dir, repoRoot);",
|
|
567
|
+
" console.log(JSON.stringify({ threw: false }));",
|
|
568
|
+
" } catch (err) {",
|
|
569
|
+
" console.log(JSON.stringify({",
|
|
570
|
+
" threw: true,",
|
|
571
|
+
" code: err && err.code,",
|
|
572
|
+
" name: err && err.name,",
|
|
573
|
+
" isInvalidBundleShapeError: err instanceof InvalidBundleShapeError,",
|
|
574
|
+
" isNotFreshHolderError: err instanceof NotFreshHolderError,",
|
|
575
|
+
" }));",
|
|
576
|
+
" }",
|
|
577
|
+
"}",
|
|
578
|
+
"",
|
|
579
|
+
"run();",
|
|
580
|
+
]
|
|
581
|
+
with open(out, "w") as f:
|
|
582
|
+
f.write("\n".join(code_lines) + "\n")
|
|
583
|
+
PY
|
|
584
|
+
|
|
585
|
+
WORKFLOW_SIDECAR_BUILD_JS="$ROOT/build/src/cli/workflow-sidecar.js"
|
|
586
|
+
export WORKFLOW_SIDECAR_BUILD_JS
|
|
587
|
+
|
|
588
|
+
# extract_json_field <jsonLine> <fieldName> -- the harness prints exactly one JSON line to
|
|
589
|
+
# stdout; parse it with a tiny inline node -p (no fragile shell string-splitting).
|
|
590
|
+
extract_json_field() {
|
|
591
|
+
node -p "(() => { try { return JSON.parse(process.argv[1])[process.argv[2]]; } catch (e) { return ''; } })()" "$1" "$2" 2>/dev/null
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
code_4a_out=$(FLOW_AGENTS_ACTOR="eval-actor-not-holder-4a" node "$CODE_HARNESS" "$DIR_4A" "$REPO4" 2>&1)
|
|
595
|
+
code_4a_json=$(echo "$code_4a_out" | tail -1)
|
|
596
|
+
code_4a_code=$(extract_json_field "$code_4a_json" code)
|
|
597
|
+
code_4a_is_shape=$(extract_json_field "$code_4a_json" isInvalidBundleShapeError)
|
|
598
|
+
if [[ "$code_4a_code" == "RECONCILE_PREFLIGHT_INVALID_SHAPE" ]]; then
|
|
599
|
+
pass "shape-invalid+not-held bundle: caught error .code === RECONCILE_PREFLIGHT_INVALID_SHAPE (F3, AC3 type-discriminator)"
|
|
600
|
+
else
|
|
601
|
+
fail "shape-invalid+not-held bundle: caught error .code was '$code_4a_code', expected RECONCILE_PREFLIGHT_INVALID_SHAPE: $code_4a_out"
|
|
602
|
+
fi
|
|
603
|
+
if [[ "$code_4a_is_shape" == "true" ]]; then
|
|
604
|
+
pass "shape-invalid+not-held bundle: caught error is instanceof InvalidBundleShapeError (F3, AC3)"
|
|
605
|
+
else
|
|
606
|
+
fail "shape-invalid+not-held bundle: caught error is NOT instanceof InvalidBundleShapeError: $code_4a_out"
|
|
607
|
+
fi
|
|
608
|
+
|
|
609
|
+
code_4b_out=$(FLOW_AGENTS_ACTOR="eval-actor-not-holder-4b" TRUST_RECONCILE_COMMANDS="node --version" node "$CODE_HARNESS" "$DIR_4B" "$REPO4" 2>&1)
|
|
610
|
+
code_4b_json=$(echo "$code_4b_out" | tail -1)
|
|
611
|
+
code_4b_code=$(extract_json_field "$code_4b_json" code)
|
|
612
|
+
code_4b_is_hold=$(extract_json_field "$code_4b_json" isNotFreshHolderError)
|
|
613
|
+
if [[ "$code_4b_code" == "VERIFY_HOLD_NOT_FRESH_HOLDER" ]]; then
|
|
614
|
+
pass "shape-valid+not-held bundle: caught error .code === VERIFY_HOLD_NOT_FRESH_HOLDER (F3, AC3 type-discriminator)"
|
|
615
|
+
else
|
|
616
|
+
fail "shape-valid+not-held bundle: caught error .code was '$code_4b_code', expected VERIFY_HOLD_NOT_FRESH_HOLDER: $code_4b_out"
|
|
617
|
+
fi
|
|
618
|
+
if [[ "$code_4b_is_hold" == "true" ]]; then
|
|
619
|
+
pass "shape-valid+not-held bundle: caught error is instanceof NotFreshHolderError (F3, AC3)"
|
|
620
|
+
else
|
|
621
|
+
fail "shape-valid+not-held bundle: caught error is NOT instanceof NotFreshHolderError: $code_4b_out"
|
|
622
|
+
fi
|
|
623
|
+
[[ "$code_4a_code" != "$code_4b_code" ]] && pass "F3: the two composition-gate error codes are genuinely distinct strings ('$code_4a_code' != '$code_4b_code'), not the same class with different message text" || fail "F3: the two composition-gate error codes are IDENTICAL — this is exactly the stop-short risk the plan named"
|
|
624
|
+
|
|
625
|
+
# ─── 5. github-provider precomputed gate (AC6) ─────────────────────────────────────────────
|
|
626
|
+
echo "--- 5. github-provider read-only precomputed gate: no gh process, not_evaluated fallback (AC6) ---"
|
|
627
|
+
|
|
628
|
+
GITHUB_NOT_HELD_FIXTURE="$TMPDIR_EVAL/github-not-held.json"
|
|
629
|
+
node -e '
|
|
630
|
+
const fs = require("fs");
|
|
631
|
+
const fixture = { effective: { effective_state: "held", reason: "fresh_liveness_heartbeat", holder: { actor: "github-holder-actor", idle_days: 0 } } };
|
|
632
|
+
fs.writeFileSync(process.argv[1], JSON.stringify(fixture, null, 2));
|
|
633
|
+
' "$GITHUB_NOT_HELD_FIXTURE"
|
|
634
|
+
GITHUB_DIR="$ARTIFACT_ROOT/verify-hold-github-not-held"
|
|
635
|
+
mkdir -p "$GITHUB_DIR"
|
|
636
|
+
if flow_agents_node "$WRITER" verify-hold "$GITHUB_DIR" \
|
|
637
|
+
--actor eval-actor-github-reader \
|
|
638
|
+
--assignment-provider github \
|
|
639
|
+
--effective-state-json "$GITHUB_NOT_HELD_FIXTURE" \
|
|
640
|
+
>"$TMPDIR_EVAL/vh-github-not-held.out" 2>"$TMPDIR_EVAL/vh-github-not-held.err"; then
|
|
641
|
+
fail "verify-hold --assignment-provider github with a not-held precomputed state should have exited non-zero (AC6)"
|
|
642
|
+
else
|
|
643
|
+
pass "verify-hold --assignment-provider github with a not-held precomputed state exits non-zero (AC6)"
|
|
644
|
+
fi
|
|
645
|
+
[[ "$(json_query "$TMPDIR_EVAL/vh-github-not-held.out" "ok")" == "false" ]] && pass "github-provider precomputed not-held state reports ok:false (AC6)" || fail "github-provider precomputed not-held state did not report ok:false: $(cat "$TMPDIR_EVAL/vh-github-not-held.out")"
|
|
646
|
+
|
|
647
|
+
GITHUB_HELD_SELF_FIXTURE="$TMPDIR_EVAL/github-held-self.json"
|
|
648
|
+
node -e '
|
|
649
|
+
const fs = require("fs");
|
|
650
|
+
const fixture = { effective: { effective_state: "held", reason: "self_is_holder", holder: { actor: "eval-actor-github-reader", idle_days: 0 } } };
|
|
651
|
+
fs.writeFileSync(process.argv[1], JSON.stringify(fixture, null, 2));
|
|
652
|
+
' "$GITHUB_HELD_SELF_FIXTURE"
|
|
653
|
+
if flow_agents_node "$WRITER" verify-hold "$GITHUB_DIR" \
|
|
654
|
+
--actor eval-actor-github-reader \
|
|
655
|
+
--assignment-provider github \
|
|
656
|
+
--effective-state-json "$GITHUB_HELD_SELF_FIXTURE" \
|
|
657
|
+
>"$TMPDIR_EVAL/vh-github-held-self.out" 2>"$TMPDIR_EVAL/vh-github-held-self.err"; then
|
|
658
|
+
pass "verify-hold --assignment-provider github with a held-self precomputed state exits 0 (AC6)"
|
|
659
|
+
else
|
|
660
|
+
fail "verify-hold --assignment-provider github with a held-self precomputed state unexpectedly refused: $(cat "$TMPDIR_EVAL/vh-github-held-self.out" "$TMPDIR_EVAL/vh-github-held-self.err")"
|
|
661
|
+
fi
|
|
662
|
+
|
|
663
|
+
# No `gh` process is ever spawned by this eval's own invocations (the fixtures above are read
|
|
664
|
+
# directly from disk via --effective-state-json — render-don't-execute). Additionally assert
|
|
665
|
+
# the CLI source itself never shells `gh` from workflow-sidecar.ts's build output (matching the
|
|
666
|
+
# render-don't-execute convention's own self-check pattern): only fail if a literal live-process
|
|
667
|
+
# invocation (execFileSync/spawn/exec) targets "gh" — a "gh" token elsewhere (e.g. render-claim's
|
|
668
|
+
# emitted gh_commands prose) is not itself a live invocation.
|
|
669
|
+
if grep -qE '(execFileSync|spawnSync|spawn|execFile|exec)\(\s*["'"'"']gh["'"'"']' "$ROOT/build/src/cli/workflow-sidecar.js" 2>/dev/null; then
|
|
670
|
+
fail "workflow-sidecar.js contains a live 'gh' process invocation — violates render-don't-execute (AC6)"
|
|
671
|
+
else
|
|
672
|
+
pass "workflow-sidecar.js never shells out to a live 'gh' process (render-don't-execute preserved) (AC6)"
|
|
673
|
+
fi
|
|
674
|
+
|
|
675
|
+
# Neither github handling (no --effective-state-json) nor a resolvable local-file join
|
|
676
|
+
# (--assignment-provider github, no local assignment record for this fresh slug) -> not_evaluated,
|
|
677
|
+
# never a silent block.
|
|
678
|
+
NOT_EVAL_DIR="$ARTIFACT_ROOT/verify-hold-not-evaluated"
|
|
679
|
+
mkdir -p "$NOT_EVAL_DIR"
|
|
680
|
+
if flow_agents_node "$WRITER" verify-hold "$NOT_EVAL_DIR" --actor eval-actor-not-eval --assignment-provider github \
|
|
681
|
+
>"$TMPDIR_EVAL/vh-not-evaluated.out" 2>"$TMPDIR_EVAL/vh-not-evaluated.err"; then
|
|
682
|
+
pass "verify-hold with neither github fixture nor local-file resolution exits 0 (not_evaluated, never a silent block) (AC6)"
|
|
683
|
+
else
|
|
684
|
+
fail "verify-hold with no resolvable join should PASS-through (not_evaluated), not block: $(cat "$TMPDIR_EVAL/vh-not-evaluated.out" "$TMPDIR_EVAL/vh-not-evaluated.err")"
|
|
685
|
+
fi
|
|
686
|
+
[[ "$(json_query "$TMPDIR_EVAL/vh-not-evaluated.out" "effective_state")" == "not_evaluated" ]] && pass "verify-hold JSON reports effective_state:not_evaluated for the unresolvable-provider case (AC6)" || fail "verify-hold JSON did not report not_evaluated: $(cat "$TMPDIR_EVAL/vh-not-evaluated.out")"
|
|
687
|
+
grep -qiF "not evaluated" "$TMPDIR_EVAL/vh-not-evaluated.err" && pass "verify-hold's not_evaluated case logs a visible stderr reason (AC6)" || fail "verify-hold's not_evaluated case did not log a visible stderr reason: $(cat "$TMPDIR_EVAL/vh-not-evaluated.err")"
|
|
688
|
+
|
|
689
|
+
# ─── 6. Injection discipline (AC7) ─────────────────────────────────────────────────────────
|
|
690
|
+
echo "--- 6. hostile holder actor string never leaks raw control/ANSI bytes (AC7) ---"
|
|
691
|
+
|
|
692
|
+
HOSTILE_SLUG="verify-hold-hostile-liveness"
|
|
693
|
+
HOSTILE_DIR="$ARTIFACT_ROOT/$HOSTILE_SLUG"
|
|
694
|
+
HOSTILE_HOLDER_ACTOR=$'hostile-holder-actor\x1b[31;1mFAKE\x07-admin'
|
|
695
|
+
write_assignment_record "$ARTIFACT_ROOT" "$HOSTILE_SLUG" "hostile-session" "$HOSTILE_HOLDER_ACTOR"
|
|
696
|
+
append_liveness_event "$ARTIFACT_ROOT" "$HOSTILE_SLUG" "$HOSTILE_HOLDER_ACTOR" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" 1800
|
|
697
|
+
mkdir -p "$HOSTILE_DIR"
|
|
698
|
+
|
|
699
|
+
if flow_agents_node "$WRITER" verify-hold "$HOSTILE_DIR" --actor eval-actor-hostile-reader \
|
|
700
|
+
>"$TMPDIR_EVAL/vh-hostile.out" 2>"$TMPDIR_EVAL/vh-hostile.err"; then
|
|
701
|
+
fail "verify-hold against a hostile holder should have refused (AC1 shape, AC7)"
|
|
702
|
+
else
|
|
703
|
+
pass "verify-hold against a hostile holder actor refuses (held, not-self) (AC7 setup)"
|
|
704
|
+
fi
|
|
705
|
+
if grep -qF $'\x1b[31;1m' "$TMPDIR_EVAL/vh-hostile.out" "$TMPDIR_EVAL/vh-hostile.err" || grep -qF $'\x07' "$TMPDIR_EVAL/vh-hostile.out" "$TMPDIR_EVAL/vh-hostile.err"; then
|
|
706
|
+
fail "hostile holder actor's raw ANSI/control bytes leaked into verify-hold's JSON output or stderr (AC7)"
|
|
707
|
+
else
|
|
708
|
+
pass "hostile holder actor's raw ANSI/control bytes never leak into verify-hold's JSON output or stderr (AC7)"
|
|
709
|
+
fi
|
|
710
|
+
grep -qF "hostile-holder-actor" "$TMPDIR_EVAL/vh-hostile.out" && pass "the sanitized (non-control-byte) portion of the hostile holder actor string still appears in verify-hold's output (AC7)" || fail "sanitized portion of the hostile holder actor string was unexpectedly dropped entirely: $(cat "$TMPDIR_EVAL/vh-hostile.out")"
|
|
711
|
+
|
|
712
|
+
# Steering hook side of AC7: the SAME hostile holder, surfaced via the supersession notice.
|
|
713
|
+
STEERING_REPO="$TMPDIR_EVAL/steering-hostile-repo"
|
|
714
|
+
STEERING_HOSTILE_NOW="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
|
715
|
+
write_steering_fixture_repo "$STEERING_REPO" "$HOSTILE_SLUG" "$STEERING_HOSTILE_NOW"
|
|
716
|
+
append_liveness_event "$STEERING_REPO/.kontourai/flow-agents" "$HOSTILE_SLUG" "$HOSTILE_HOLDER_ACTOR" "$STEERING_HOSTILE_NOW" 1800
|
|
717
|
+
|
|
718
|
+
if FLOW_AGENTS_ACTOR="eval-actor-hostile-reader" node "$ROOT/scripts/hooks/workflow-steering.js" \
|
|
719
|
+
>"$TMPDIR_EVAL/steering-hostile.out" 2>"$TMPDIR_EVAL/steering-hostile.err" <<STEERJSON
|
|
720
|
+
{"hook_event_name":"UserPromptSubmit","cwd":"$STEERING_REPO","prompt":"continue"}
|
|
721
|
+
STEERJSON
|
|
722
|
+
then
|
|
723
|
+
pass "steering hook processes the hostile-holder fixture without failing (AC7 setup)"
|
|
724
|
+
else
|
|
725
|
+
fail "steering hook should not fail for the hostile-holder fixture"
|
|
726
|
+
fi
|
|
727
|
+
if grep -qF $'\x1b[31;1m' "$TMPDIR_EVAL/steering-hostile.out" || grep -qF $'\x07' "$TMPDIR_EVAL/steering-hostile.out"; then
|
|
728
|
+
fail "hostile holder actor's raw ANSI/control bytes leaked into the steering supersession notice (AC7)"
|
|
729
|
+
else
|
|
730
|
+
pass "hostile holder actor's raw ANSI/control bytes never leak into the steering supersession notice (AC7)"
|
|
731
|
+
fi
|
|
732
|
+
|
|
733
|
+
# ─── 6b. holder.last_at injection via claimed_at/liveness `at` (F1 fix, fix-plan iteration 1) ──
|
|
734
|
+
# Section 6 above only injects hostile bytes into the ACTOR field -- it would pass even with
|
|
735
|
+
# the F1 bug present (runVerifyHold's held/reclaimable/human-held branches spread
|
|
736
|
+
# effective.holder and only sanitized actor/assignee, leaving last_at raw+uncapped). This
|
|
737
|
+
# section injects a hostile+oversized value into (a) the assignment record's claimed_at
|
|
738
|
+
# (forcing `reclaimable`, no fresh liveness event) and (b) a liveness event's `at` (forcing
|
|
739
|
+
# `held`), asserting the resulting `holder.last_at` in verify-hold's JSON is BOTH control-char-
|
|
740
|
+
# stripped AND length-capped (<=64, matching sanitize()'s stripControlCharsForDisplay(...).slice(0,64)).
|
|
741
|
+
echo "--- 6b. holder.last_at (claimed_at / liveness 'at') never leaks raw/oversized bytes (F1, AC7) ---"
|
|
742
|
+
|
|
743
|
+
HOSTILE_LAST_AT_RAW=$'2026-07-04T23:59:00Z-INJECTED-\x1b[31;1mFAKE\x07-'"$(printf 'Z%.0s' {1..300})"
|
|
744
|
+
|
|
745
|
+
# 6b-i. reclaimable: hostile claimed_at, NO liveness event at all (assignment present, no fresh
|
|
746
|
+
# heartbeat -> reclaimable per computeEffectiveState).
|
|
747
|
+
RECLAIM_INJECT_SLUG="verify-hold-hostile-claimed-at"
|
|
748
|
+
RECLAIM_INJECT_DIR="$ARTIFACT_ROOT/$RECLAIM_INJECT_SLUG"
|
|
749
|
+
write_assignment_record "$ARTIFACT_ROOT" "$RECLAIM_INJECT_SLUG" "hostile-claimed-at-session" "eval-actor-hostile-claimed-at-holder" "$HOSTILE_LAST_AT_RAW"
|
|
750
|
+
mkdir -p "$RECLAIM_INJECT_DIR"
|
|
751
|
+
|
|
752
|
+
if flow_agents_node "$WRITER" verify-hold "$RECLAIM_INJECT_DIR" --actor eval-actor-hostile-claimed-at-reader \
|
|
753
|
+
>"$TMPDIR_EVAL/vh-hostile-claimed-at.out" 2>"$TMPDIR_EVAL/vh-hostile-claimed-at.err"; then
|
|
754
|
+
fail "verify-hold against a hostile claimed_at fixture should have refused (reclaimable) (F1 setup)"
|
|
755
|
+
else
|
|
756
|
+
pass "verify-hold against a hostile claimed_at fixture refuses (reclaimable, not-self) (F1 setup)"
|
|
757
|
+
fi
|
|
758
|
+
[[ "$(json_query "$TMPDIR_EVAL/vh-hostile-claimed-at.out" "effective_state")" == "reclaimable" ]] && pass "hostile claimed_at fixture classifies reclaimable, exercising the claimed_at->last_at path specifically (F1 setup)" || fail "hostile claimed_at fixture did not classify reclaimable: $(cat "$TMPDIR_EVAL/vh-hostile-claimed-at.out")"
|
|
759
|
+
RECLAIM_LAST_AT="$(json_query "$TMPDIR_EVAL/vh-hostile-claimed-at.out" "holder.last_at")"
|
|
760
|
+
if [[ "$RECLAIM_LAST_AT" == *$'\x1b'* || "$RECLAIM_LAST_AT" == *$'\x07'* ]]; then
|
|
761
|
+
fail "holder.last_at (from claimed_at) leaked raw control/ANSI bytes into verify-hold's JSON (F1 HIGH regression): $RECLAIM_LAST_AT"
|
|
762
|
+
else
|
|
763
|
+
pass "holder.last_at (from claimed_at) is control-char-stripped in verify-hold's JSON (F1)"
|
|
764
|
+
fi
|
|
765
|
+
RECLAIM_LAST_AT_LEN="${#RECLAIM_LAST_AT}"
|
|
766
|
+
if [[ "$RECLAIM_LAST_AT_LEN" -le 64 ]]; then
|
|
767
|
+
pass "holder.last_at (from claimed_at) is length-capped at <=64 chars in verify-hold's JSON (F1) (len=$RECLAIM_LAST_AT_LEN)"
|
|
768
|
+
else
|
|
769
|
+
fail "holder.last_at (from claimed_at) exceeded the 64-char cap in verify-hold's JSON (F1 HIGH regression): len=$RECLAIM_LAST_AT_LEN value=$RECLAIM_LAST_AT"
|
|
770
|
+
fi
|
|
771
|
+
if grep -qF $'\x1b[31;1m' "$TMPDIR_EVAL/vh-hostile-claimed-at.out" "$TMPDIR_EVAL/vh-hostile-claimed-at.err" || grep -qF $'\x07' "$TMPDIR_EVAL/vh-hostile-claimed-at.out" "$TMPDIR_EVAL/vh-hostile-claimed-at.err"; then
|
|
772
|
+
fail "hostile claimed_at's raw ANSI/control bytes leaked ANYWHERE in verify-hold's output (stdout+stderr) (F1 HIGH regression)"
|
|
773
|
+
else
|
|
774
|
+
pass "hostile claimed_at's raw ANSI/control bytes never leak anywhere in verify-hold's output (F1)"
|
|
775
|
+
fi
|
|
776
|
+
|
|
777
|
+
# 6b-ii. held: OVERSIZED (but Date.parse-valid) liveness event `at`, no assignment record at
|
|
778
|
+
# all (liveness-only -> `held`/`liveness_claim_present_assignment_lagging` per
|
|
779
|
+
# computeEffectiveState's no-assignment branch), exercising the SECOND last_at source (the
|
|
780
|
+
# liveness event, not claimed_at). Unlike 6b-i's claimed_at (copied through with NO freshness
|
|
781
|
+
# gate -- control chars and any length pass straight through to reclaimable), this `at` value
|
|
782
|
+
# MUST survive freshHolders()'s `Date.parse(g.lastAt)` freshness check to remain in the fresh
|
|
783
|
+
# set and reach the `held` branch at all -- embedding raw control/ANSI bytes here makes
|
|
784
|
+
# Date.parse return NaN, which drops the event as stale/absent (verified directly: the fixture
|
|
785
|
+
# then resolves free/not-held, never reaching this code path, so control bytes are not a
|
|
786
|
+
# reachable injection vector via THIS source). What IS reachable and still exercises the exact
|
|
787
|
+
# length-cap half of the AC7 sanitize() contract on this second last_at source: an
|
|
788
|
+
# oversized-but-parseable timestamp (300+ garbage digits crammed into the fractional-seconds
|
|
789
|
+
# field, which Date.parse tolerates and rounds into a valid instant) -- proving the cap applies
|
|
790
|
+
# regardless of which of the two last_at sources fed it.
|
|
791
|
+
#
|
|
792
|
+
# NOTE (#397 fix): this fixture has NO assignment record at all (liveness-only), so per the
|
|
793
|
+
# #397 fix runVerifyHold now PASSES it (reason liveness_claim_present_assignment_lagging is no
|
|
794
|
+
# longer a publish-block -- liveness alone is never a durable ownership conflict). The
|
|
795
|
+
# effective_state/holder shape is UNCHANGED by that fix (computeEffectiveState's own
|
|
796
|
+
# classification and holder payload are untouched; only the publish-decision mapping in
|
|
797
|
+
# runVerifyHold changed) -- so the AC7 sanitize()/length-cap assertions on holder.last_at below
|
|
798
|
+
# remain fully valid and are asserted unchanged, just against an `ok:true` result now instead of
|
|
799
|
+
# `ok:false`.
|
|
800
|
+
HELD_INJECT_SLUG="verify-hold-hostile-liveness-at"
|
|
801
|
+
HELD_INJECT_DIR="$ARTIFACT_ROOT/$HELD_INJECT_SLUG"
|
|
802
|
+
OVERSIZED_FRESH_AT="$(node -e 'const iso = new Date().toISOString(); process.stdout.write(iso.replace(/\.\d+Z$/, "." + "1".repeat(300) + "Z"));')"
|
|
803
|
+
append_liveness_event "$ARTIFACT_ROOT" "$HELD_INJECT_SLUG" "eval-actor-hostile-liveness-at-holder" "$OVERSIZED_FRESH_AT" 1800
|
|
804
|
+
mkdir -p "$HELD_INJECT_DIR"
|
|
805
|
+
|
|
806
|
+
if flow_agents_node "$WRITER" verify-hold "$HELD_INJECT_DIR" --actor eval-actor-hostile-liveness-at-reader \
|
|
807
|
+
>"$TMPDIR_EVAL/vh-hostile-liveness-at.out" 2>"$TMPDIR_EVAL/vh-hostile-liveness-at.err"; then
|
|
808
|
+
pass "verify-hold against an oversized-liveness-at, no-assignment-record fixture PASSES (liveness-only is never a publish block) (#397 fix)"
|
|
809
|
+
else
|
|
810
|
+
fail "verify-hold against an oversized-liveness-at, no-assignment-record fixture should have PASSED (liveness-only, no durable assignment -- #397 regression): $(cat "$TMPDIR_EVAL/vh-hostile-liveness-at.out" "$TMPDIR_EVAL/vh-hostile-liveness-at.err")"
|
|
811
|
+
fi
|
|
812
|
+
[[ "$(json_query "$TMPDIR_EVAL/vh-hostile-liveness-at.out" "effective_state")" == "held" ]] && pass "oversized-liveness-at fixture classifies held, exercising the liveness-event->last_at path specifically (F1 setup)" || fail "oversized-liveness-at fixture did not classify held (Date.parse-valid timestamp should stay in the fresh set): $(cat "$TMPDIR_EVAL/vh-hostile-liveness-at.out")"
|
|
813
|
+
HELD_LAST_AT="$(json_query "$TMPDIR_EVAL/vh-hostile-liveness-at.out" "holder.last_at")"
|
|
814
|
+
if [[ "$HELD_LAST_AT" == *$'\x1b'* || "$HELD_LAST_AT" == *$'\x07'* ]]; then
|
|
815
|
+
fail "holder.last_at (from liveness 'at') leaked raw control/ANSI bytes into verify-hold's JSON (F1 HIGH regression): $HELD_LAST_AT"
|
|
816
|
+
else
|
|
817
|
+
pass "holder.last_at (from liveness 'at') has no raw control/ANSI bytes in verify-hold's JSON (F1, regression guard -- this source is Date.parse-gated so control bytes are unreachable here, see comment above)"
|
|
818
|
+
fi
|
|
819
|
+
HELD_LAST_AT_LEN="${#HELD_LAST_AT}"
|
|
820
|
+
if [[ "$HELD_LAST_AT_LEN" -le 64 ]]; then
|
|
821
|
+
pass "holder.last_at (from liveness 'at') is length-capped at <=64 chars in verify-hold's JSON despite a 300+ char source value (F1) (len=$HELD_LAST_AT_LEN)"
|
|
822
|
+
else
|
|
823
|
+
fail "holder.last_at (from liveness 'at') exceeded the 64-char cap in verify-hold's JSON (F1 HIGH regression): len=$HELD_LAST_AT_LEN value=$HELD_LAST_AT"
|
|
824
|
+
fi
|
|
825
|
+
if grep -qF $'\x1b[31;1m' "$TMPDIR_EVAL/vh-hostile-liveness-at.out" "$TMPDIR_EVAL/vh-hostile-liveness-at.err" || grep -qF $'\x07' "$TMPDIR_EVAL/vh-hostile-liveness-at.out" "$TMPDIR_EVAL/vh-hostile-liveness-at.err"; then
|
|
826
|
+
fail "oversized liveness 'at' leaked raw control/ANSI bytes ANYWHERE in verify-hold's output (stdout+stderr) (F1 HIGH regression)"
|
|
827
|
+
else
|
|
828
|
+
pass "oversized liveness 'at' never leaks raw control/ANSI bytes anywhere in verify-hold's output (F1, regression guard)"
|
|
829
|
+
fi
|
|
830
|
+
|
|
831
|
+
# ─── 7. Steering surfaces supersession on EVERY turn, not just SessionStart (AC5) ──────────
|
|
832
|
+
echo "--- 7. supersessionSteering fires on UserPromptSubmit AND SessionStart; absent with no other-actor holder (AC5) ---"
|
|
833
|
+
|
|
834
|
+
STEERING_SUP_REPO="$TMPDIR_EVAL/steering-supersede-repo"
|
|
835
|
+
SUP_SLUG="steering-superseded-demo"
|
|
836
|
+
SUP_NOW="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
|
837
|
+
write_steering_fixture_repo "$STEERING_SUP_REPO" "$SUP_SLUG" "$SUP_NOW"
|
|
838
|
+
append_liveness_event "$STEERING_SUP_REPO/.kontourai/flow-agents" "$SUP_SLUG" "eval-actor-steering-holder-b" "$SUP_NOW" 1800
|
|
839
|
+
|
|
840
|
+
# 7a. UserPromptSubmit on a SECOND, unrelated turn (not the takeover turn itself) — the
|
|
841
|
+
# every-turn requirement (AC5's core assertion, distinguishing this from SessionStart-only).
|
|
842
|
+
if FLOW_AGENTS_ACTOR="eval-actor-steering-self-a" node "$ROOT/scripts/hooks/workflow-steering.js" \
|
|
843
|
+
>"$TMPDIR_EVAL/steering-sup-prompt.out" 2>"$TMPDIR_EVAL/steering-sup-prompt.err" <<STEERJSON
|
|
844
|
+
{"hook_event_name":"UserPromptSubmit","cwd":"$STEERING_SUP_REPO","prompt":"what is the status of this work?"}
|
|
845
|
+
STEERJSON
|
|
846
|
+
then
|
|
847
|
+
pass "steering hook processes UserPromptSubmit for the superseded fixture without failing (AC5 setup)"
|
|
848
|
+
else
|
|
849
|
+
fail "steering hook should not fail for UserPromptSubmit on the superseded fixture"
|
|
850
|
+
fi
|
|
851
|
+
grep -qF "[SUPERSEDED:" "$TMPDIR_EVAL/steering-sup-prompt.out" && pass "supersession notice appears at UserPromptSubmit (every-turn, not just SessionStart) (AC5)" || fail "supersession notice missing at UserPromptSubmit: $(cat "$TMPDIR_EVAL/steering-sup-prompt.out")"
|
|
852
|
+
grep -qF "eval-actor-steering-holder-b" "$TMPDIR_EVAL/steering-sup-prompt.out" && pass "UserPromptSubmit supersession notice names the current (other-actor) holder (AC5)" || fail "UserPromptSubmit supersession notice did not name the holder: $(cat "$TMPDIR_EVAL/steering-sup-prompt.out")"
|
|
853
|
+
grep -qiF "verify-hold" "$TMPDIR_EVAL/steering-sup-prompt.out" && pass "UserPromptSubmit supersession notice references the verify-hold gate (AC5)" || fail "UserPromptSubmit supersession notice did not reference verify-hold: $(cat "$TMPDIR_EVAL/steering-sup-prompt.out")"
|
|
854
|
+
|
|
855
|
+
# 7b. SessionStart — regression check: still present there too.
|
|
856
|
+
if FLOW_AGENTS_ACTOR="eval-actor-steering-self-a" node "$ROOT/scripts/hooks/workflow-steering.js" \
|
|
857
|
+
>"$TMPDIR_EVAL/steering-sup-start.out" 2>"$TMPDIR_EVAL/steering-sup-start.err" <<STEERJSON
|
|
858
|
+
{"hook_event_name":"SessionStart","cwd":"$STEERING_SUP_REPO"}
|
|
859
|
+
STEERJSON
|
|
860
|
+
then
|
|
861
|
+
pass "steering hook processes SessionStart for the superseded fixture without failing (AC5 setup)"
|
|
862
|
+
else
|
|
863
|
+
fail "steering hook should not fail for SessionStart on the superseded fixture"
|
|
864
|
+
fi
|
|
865
|
+
grep -qF "[SUPERSEDED:" "$TMPDIR_EVAL/steering-sup-start.out" && pass "supersession notice still appears at SessionStart (regression check) (AC5)" || fail "supersession notice missing at SessionStart: $(cat "$TMPDIR_EVAL/steering-sup-start.out")"
|
|
866
|
+
|
|
867
|
+
# 7c. No other-actor fresh holder -> no notice at either event (no false positive).
|
|
868
|
+
NO_SUP_REPO="$TMPDIR_EVAL/steering-no-supersede-repo"
|
|
869
|
+
NO_SUP_SLUG="steering-not-superseded-demo"
|
|
870
|
+
NO_SUP_NOW="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
|
871
|
+
write_steering_fixture_repo "$NO_SUP_REPO" "$NO_SUP_SLUG" "$NO_SUP_NOW"
|
|
872
|
+
# No liveness stream at all for this repo — no other-actor holder can exist.
|
|
873
|
+
|
|
874
|
+
if FLOW_AGENTS_ACTOR="eval-actor-steering-self-a" node "$ROOT/scripts/hooks/workflow-steering.js" \
|
|
875
|
+
>"$TMPDIR_EVAL/steering-no-sup-prompt.out" 2>"$TMPDIR_EVAL/steering-no-sup-prompt.err" <<STEERJSON
|
|
876
|
+
{"hook_event_name":"UserPromptSubmit","cwd":"$NO_SUP_REPO","prompt":"what is the status of this work?"}
|
|
877
|
+
STEERJSON
|
|
878
|
+
then
|
|
879
|
+
pass "steering hook processes UserPromptSubmit for the non-superseded fixture without failing (AC5 setup)"
|
|
880
|
+
else
|
|
881
|
+
fail "steering hook should not fail for UserPromptSubmit on the non-superseded fixture"
|
|
882
|
+
fi
|
|
883
|
+
if grep -qF "[SUPERSEDED:" "$TMPDIR_EVAL/steering-no-sup-prompt.out"; then
|
|
884
|
+
fail "supersession notice incorrectly appeared at UserPromptSubmit with no other-actor fresh holder (false positive) (AC5)"
|
|
885
|
+
else
|
|
886
|
+
pass "no supersession notice at UserPromptSubmit when there is no other-actor fresh holder (AC5, no false positive)"
|
|
887
|
+
fi
|
|
888
|
+
|
|
889
|
+
if FLOW_AGENTS_ACTOR="eval-actor-steering-self-a" node "$ROOT/scripts/hooks/workflow-steering.js" \
|
|
890
|
+
>"$TMPDIR_EVAL/steering-no-sup-start.out" 2>"$TMPDIR_EVAL/steering-no-sup-start.err" <<STEERJSON
|
|
891
|
+
{"hook_event_name":"SessionStart","cwd":"$NO_SUP_REPO"}
|
|
892
|
+
STEERJSON
|
|
893
|
+
then
|
|
894
|
+
pass "steering hook processes SessionStart for the non-superseded fixture without failing (AC5 setup)"
|
|
895
|
+
else
|
|
896
|
+
fail "steering hook should not fail for SessionStart on the non-superseded fixture"
|
|
897
|
+
fi
|
|
898
|
+
if grep -qF "[SUPERSEDED:" "$TMPDIR_EVAL/steering-no-sup-start.out"; then
|
|
899
|
+
fail "supersession notice incorrectly appeared at SessionStart with no other-actor fresh holder (false positive) (AC5)"
|
|
900
|
+
else
|
|
901
|
+
pass "no supersession notice at SessionStart when there is no other-actor fresh holder (AC5, no false positive)"
|
|
902
|
+
fi
|
|
903
|
+
|
|
904
|
+
echo ""
|
|
905
|
+
if [[ "$errors" -eq 0 ]]; then
|
|
906
|
+
echo "test_verify_hold: all checks passed."
|
|
907
|
+
else
|
|
908
|
+
echo "test_verify_hold: $errors check(s) failed."
|
|
909
|
+
fi
|
|
910
|
+
exit "$errors"
|