@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,381 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
//
|
|
3
|
+
// Shared bundle-shape classification/divergence-construction, extracted from
|
|
4
|
+
// scripts/ci/trust-reconcile.js so a local, pre-push preflight (issue #356) can reuse
|
|
5
|
+
// EXACTLY the same shape checks CI enforces, rather than risk a forked copy that
|
|
6
|
+
// silently drifts from what trust-reconcile.js actually does (the historical failure
|
|
7
|
+
// mode this module exists to close off — see command-log-chain.js for the identical
|
|
8
|
+
// rationale applied to the hash-chain/laundering primitives).
|
|
9
|
+
//
|
|
10
|
+
// This module is SHAPE-only: it classifies a bundle's own claims/evidence and builds
|
|
11
|
+
// the `issues[]` entries that do not require a fresh CI command re-run (no `runCommand`,
|
|
12
|
+
// no manifest command execution). The ACTUAL fresh-run comparison for reconcilable
|
|
13
|
+
// command claims (`ciResult.passed`) stays in trust-reconcile.js, since that requires a
|
|
14
|
+
// live CI/local command execution a local preflight must not perform.
|
|
15
|
+
//
|
|
16
|
+
// trust-reconcile.js requires this module instead of defining these functions inline —
|
|
17
|
+
// see its own comments at the require() site and the (former) location of
|
|
18
|
+
// classifyBundleClaims for the extraction history.
|
|
19
|
+
|
|
20
|
+
// hasLaunderingOperator is imported (not re-implemented) so this module and
|
|
21
|
+
// scripts/ci/trust-reconcile.js apply the identical exit-code-mask heuristic.
|
|
22
|
+
const { hasLaunderingOperator } = require('./command-log-chain.js');
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Classify a trust.bundle's claims into: reconcilable command claims (test_output +
|
|
26
|
+
* execution.label), session-local claims (attestation/observation/citation), never-captured
|
|
27
|
+
* or unbacked command claims (not-run divergence), and command-backed claims carrying a
|
|
28
|
+
* waiver (waiver-on-command divergence). Returns
|
|
29
|
+
* { reconcilable, sessionLocal, noEvidenceCommand, waiverOnCommand }.
|
|
30
|
+
*
|
|
31
|
+
* Source of truth: evidence[].execution.label is the command string recorded at capture time.
|
|
32
|
+
* evidence[].passing (normalized) means the agent claimed this passed. `claim.status` is NOT
|
|
33
|
+
* trusted here — the caller re-derives it CI-side (see derive-claim-status.mjs / finding-3).
|
|
34
|
+
*
|
|
35
|
+
* WS8 iteration-2 hardening:
|
|
36
|
+
* - finding 1: ANY pass-asserting claim whose evidence is `evidenceType: test_output`
|
|
37
|
+
* (Surface's default when unset) but which did NOT reconcile — i.e. it has no
|
|
38
|
+
* manifest-matchable execution.label — is a divergence, NOT session-local. A test_output
|
|
39
|
+
* claim either reconciles against the manifest or is a divergence; it is never accepted on
|
|
40
|
+
* self-reported status. (Previously only the literal claimType `workflow.check.command`
|
|
41
|
+
* was guarded, so a fabricated kind:"test" claim with no command slipped through.)
|
|
42
|
+
* - finding 4: a command-backed (test_output) claim carrying a waiver is a divergence — a
|
|
43
|
+
* command-backed check reconciles against CI or fails; it cannot be waived.
|
|
44
|
+
*/
|
|
45
|
+
function classifyBundleClaims(bundle) {
|
|
46
|
+
const evidence = Array.isArray(bundle.evidence) ? bundle.evidence : [];
|
|
47
|
+
const claims = Array.isArray(bundle.claims) ? bundle.claims : [];
|
|
48
|
+
|
|
49
|
+
const claimById = new Map();
|
|
50
|
+
for (const c of claims) if (c && c.id) claimById.set(c.id, c);
|
|
51
|
+
|
|
52
|
+
// Evidence indexing. A missing evidenceType defaults to test_output for backward
|
|
53
|
+
// compatibility with pre-classification bundles (same default classifyEvidence uses).
|
|
54
|
+
const claimHasLabeledTestOutput = new Set(); // test_output evidence WITH an execution.label
|
|
55
|
+
const claimHasTestOutputEvidence = new Set(); // ANY test_output evidence (label or not)
|
|
56
|
+
// WS8 iteration-4 (converged finding): the session-local (non-test_output) evidenceType per
|
|
57
|
+
// claim, so the reconciler can name it on the loud ATTESTED marker below — a fabricated
|
|
58
|
+
// human_attestation/attestation/external claim with no --command is otherwise
|
|
59
|
+
// indistinguishable, in the reconciler's own output, from a genuinely re-runnable check.
|
|
60
|
+
const claimEvidenceType = new Map();
|
|
61
|
+
for (const ev of evidence) {
|
|
62
|
+
if (!ev || !ev.claimId) continue;
|
|
63
|
+
const evType = ev.evidenceType || 'test_output';
|
|
64
|
+
if (evType !== 'test_output') {
|
|
65
|
+
if (!claimEvidenceType.has(ev.claimId)) claimEvidenceType.set(ev.claimId, evType);
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
claimHasTestOutputEvidence.add(ev.claimId);
|
|
69
|
+
if (ev.execution && ev.execution.label) claimHasLabeledTestOutput.add(ev.claimId);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// finding 4: a command-backed (test_output-evidence) claim that also carries a waiver.
|
|
73
|
+
const waiverOnCommand = [];
|
|
74
|
+
for (const c of claims) {
|
|
75
|
+
if (!c || !c.id) continue;
|
|
76
|
+
const waiver = (c.metadata && typeof c.metadata === 'object') ? c.metadata.waiver : undefined;
|
|
77
|
+
if (waiver && typeof waiver === 'object' && claimHasTestOutputEvidence.has(c.id)) {
|
|
78
|
+
waiverOnCommand.push({ claimId: c.id, claimType: String(c.claimType || ''), subject: c.subjectId || c.fieldOrBehavior || c.id });
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// (A) Reconcilable claimed-passes: evidence items that are test_output (CI-reconcilable),
|
|
83
|
+
// carry an execution.label, and assert pass. Session-local evidenceTypes
|
|
84
|
+
// (crawl_observation, human_attestation, attestation, policy_rule, source_excerpt,
|
|
85
|
+
// document_citation, calculation_trace) are NOT reconciled per-command — they are handled
|
|
86
|
+
// by the session-local/waiver path below.
|
|
87
|
+
const reconcilable = [];
|
|
88
|
+
const reconcilableClaimIds = new Set();
|
|
89
|
+
const seen = new Set();
|
|
90
|
+
for (const ev of evidence) {
|
|
91
|
+
if (!ev || !ev.execution || !ev.execution.label) continue;
|
|
92
|
+
if (!isPassingValue(ev.passing)) continue;
|
|
93
|
+
const evType = ev.evidenceType || 'test_output';
|
|
94
|
+
if (evType !== 'test_output') continue; // session-local — not CI-reconcilable
|
|
95
|
+
const cmd = normalizeCmd(ev.execution.label);
|
|
96
|
+
if (!cmd) continue;
|
|
97
|
+
reconcilableClaimIds.add(ev.claimId);
|
|
98
|
+
if (seen.has(cmd)) continue;
|
|
99
|
+
seen.add(cmd);
|
|
100
|
+
const claim = claimById.get(ev.claimId);
|
|
101
|
+
reconcilable.push({ cmd, claimId: ev.claimId, evId: ev.id, claimType: claim ? String(claim.claimType || '') : '' });
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// (B) Session-local claims, never-captured command claims, and unreconciled test_output.
|
|
105
|
+
const sessionLocal = [];
|
|
106
|
+
const noEvidenceCommand = [];
|
|
107
|
+
const seenClaims = new Set();
|
|
108
|
+
for (const c of claims) {
|
|
109
|
+
if (!c || !c.id || typeof c.claimType !== 'string') continue;
|
|
110
|
+
// #267/#282: a superseded critique write is HISTORY — excluded from reconcile evaluation so a
|
|
111
|
+
// resolved session converges (a fail critique that a later same-reviewer pass superseded no
|
|
112
|
+
// longer blocks). Scoped to NON-test_output claims so a command-backed claim can never launder
|
|
113
|
+
// a real failure by carrying superseded_by — a test_output claim always reconciles or diverges.
|
|
114
|
+
if (c.metadata && typeof c.metadata === 'object' && c.metadata.superseded_by && !claimHasTestOutputEvidence.has(c.id)) continue;
|
|
115
|
+
if (reconcilableClaimIds.has(c.id)) continue; // handled by (A)
|
|
116
|
+
if (seenClaims.has(c.id)) continue;
|
|
117
|
+
const status = String(c.status || '');
|
|
118
|
+
const assertsPass = isPassingValue(c.value) || status === 'verified' || status === 'assumed';
|
|
119
|
+
const isFailing = status === 'disputed' || status === 'rejected';
|
|
120
|
+
if (!assertsPass && !isFailing) continue; // pending/unknown non-asserting — ignore (as before)
|
|
121
|
+
seenClaims.add(c.id);
|
|
122
|
+
|
|
123
|
+
// finding 1: a pass-asserting claim backed by test_output evidence that did NOT reconcile
|
|
124
|
+
// (it has test_output evidence but no manifest-matchable execution.label — otherwise it
|
|
125
|
+
// would be in bucket A) is a not-run divergence. A test_output claim reconciles against the
|
|
126
|
+
// manifest or it is a divergence — it is NEVER accepted as session-local on self-report.
|
|
127
|
+
if (assertsPass && claimHasTestOutputEvidence.has(c.id)) {
|
|
128
|
+
const rawCmd = normalizeCmd(c.fieldOrBehavior || c.value || '');
|
|
129
|
+
noEvidenceCommand.push({ cmd: rawCmd || `[claim:${c.id}]`, claimId: c.id, claimType: c.claimType, reason: 'test_output-unreconciled' });
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// A workflow.check.command claim with no captured (labeled) evidence is a never-captured
|
|
134
|
+
// claimed pass — not-run divergence (anti-gaming teeth preserved).
|
|
135
|
+
if (assertsPass && c.claimType === 'workflow.check.command' && !claimHasLabeledTestOutput.has(c.id)) {
|
|
136
|
+
const rawCmd = normalizeCmd(c.fieldOrBehavior || c.value || '');
|
|
137
|
+
noEvidenceCommand.push({ cmd: rawCmd || `[claim:${c.id}:${c.claimType}]`, claimId: c.id, claimType: c.claimType, reason: 'no-evidence-command' });
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const waiver = (c.metadata && typeof c.metadata === 'object') ? c.metadata.waiver : undefined;
|
|
142
|
+
sessionLocal.push({
|
|
143
|
+
claimId: c.id,
|
|
144
|
+
claimType: c.claimType,
|
|
145
|
+
assertedStatus: status,
|
|
146
|
+
value: c.value,
|
|
147
|
+
waiver: (waiver && typeof waiver === 'object') ? waiver : null,
|
|
148
|
+
subject: c.subjectId || c.fieldOrBehavior || c.id,
|
|
149
|
+
evidenceType: claimEvidenceType.get(c.id) || 'unknown',
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return { reconcilable, sessionLocal, noEvidenceCommand, waiverOnCommand };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Normalize a command string: collapse whitespace, trim. (Mirrors trust-reconcile.js's own.) */
|
|
157
|
+
function normalizeCmd(cmd) {
|
|
158
|
+
return String(cmd || '').replace(/\s+/g, ' ').trim();
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Normalize ev.passing to a boolean.
|
|
163
|
+
* Treats true / 1 / "true" / "pass" as passing.
|
|
164
|
+
* Prevents a claim from dodging reconciliation via a non-boolean value.
|
|
165
|
+
*/
|
|
166
|
+
function isPassingValue(v) {
|
|
167
|
+
return v === true || v === 1 || v === 'true' || v === 'pass';
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ---------------------------------------------------------------------------
|
|
171
|
+
// Pure shape-level divergence ("issue") construction.
|
|
172
|
+
//
|
|
173
|
+
// Each function below takes already-computed classification inputs (the buckets
|
|
174
|
+
// classifyBundleClaims returns, plus a resolved manifest / derived-status map) and
|
|
175
|
+
// returns an issues[] array structurally IDENTICAL in shape (`{ type, cmd?, message }`)
|
|
176
|
+
// to what trust-reconcile.js's Step 2 block pushes inline. None of these functions
|
|
177
|
+
// execute a command or otherwise perform a fresh CI run — they are pure, local, and fast.
|
|
178
|
+
// ---------------------------------------------------------------------------
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* finding 4 (server-side): a command-backed (test_output-evidence) claim carrying a
|
|
182
|
+
* waiver is a divergence — a command-backed check reconciles against CI or fails; it
|
|
183
|
+
* cannot be waived away.
|
|
184
|
+
*/
|
|
185
|
+
function waiverOnCommandIssues(waiverOnCommand) {
|
|
186
|
+
const issues = [];
|
|
187
|
+
for (const { claimId, claimType, subject } of waiverOnCommand || []) {
|
|
188
|
+
issues.push({
|
|
189
|
+
type: 'waiver-on-command-check',
|
|
190
|
+
message: `trust divergence: claim '${claimId}' (${subject}, claimType: ${claimType}) carries a waiver but is backed by test_output evidence — a command-backed check reconciles against CI or fails and cannot be waived`,
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
return issues;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* not-run divergences: never-captured command claims (no evidence) AND test_output
|
|
198
|
+
* claims that did not reconcile (no manifest-matchable execution.label).
|
|
199
|
+
*/
|
|
200
|
+
function noEvidenceCommandIssues(noEvidenceCommand) {
|
|
201
|
+
const issues = [];
|
|
202
|
+
for (const { cmd, claimId, claimType, reason } of noEvidenceCommand || []) {
|
|
203
|
+
const message = reason === 'test_output-unreconciled'
|
|
204
|
+
? `trust divergence: claim '${claimId}' (claimType: ${claimType}) asserts pass with test_output evidence but has no manifest-matched execution.label — a test_output claim must reconcile against the manifest or it is a divergence (never accepted as session-local)`
|
|
205
|
+
: `trust divergence: claim '${claimId}' (claimType: ${claimType}) asserts pass but has no supporting evidence item — command never captured`;
|
|
206
|
+
issues.push({ type: 'not-run', cmd, message });
|
|
207
|
+
}
|
|
208
|
+
return issues;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Manifest-membership subset of the `reconcilable` loop: ONLY the "not in the reconcile
|
|
213
|
+
* manifest" `not-run` case, plus the laundering check (via the shared hasLaunderingOperator).
|
|
214
|
+
* The ACTUAL fresh-run comparison (`ciResult.passed`) is NOT here — it requires a live CI/
|
|
215
|
+
* local command execution the preflight must not perform, and stays in trust-reconcile.js.
|
|
216
|
+
*
|
|
217
|
+
* Returns { issues, unresolved } where `unresolved` is the subset of `reconcilable` entries
|
|
218
|
+
* that passed the laundering + manifest-membership checks and therefore DO require a fresh
|
|
219
|
+
* CI run to fully reconcile — callers that need full parity (trust-reconcile.js) continue
|
|
220
|
+
* from there; callers that are shape-only (the local preflight) simply do not resolve them
|
|
221
|
+
* further and treat "manifest-matched, not laundered" as shape-clean.
|
|
222
|
+
*/
|
|
223
|
+
function reconcilableManifestIssues(reconcilable, manifestByCmd) {
|
|
224
|
+
const issues = [];
|
|
225
|
+
const unresolved = [];
|
|
226
|
+
for (const entry of reconcilable || []) {
|
|
227
|
+
const { cmd } = entry;
|
|
228
|
+
const normalCmd = normalizeCmd(cmd);
|
|
229
|
+
|
|
230
|
+
// (a) Laundering operator check — must come first (most specific signal).
|
|
231
|
+
if (hasLaunderingOperator(cmd)) {
|
|
232
|
+
issues.push({
|
|
233
|
+
type: 'laundering',
|
|
234
|
+
cmd,
|
|
235
|
+
message: `trust divergence: agent claimed '${cmd}' passed; command contains exit-code-laundering operator (|| ... / ; true / ; exit 0 / etc.)`,
|
|
236
|
+
});
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// A test_output claim MUST name a manifest (required-lane) command. An agent
|
|
241
|
+
// cannot self-label an arbitrary command test_output to dodge the manifest.
|
|
242
|
+
const manifestEntry = manifestByCmd.get(normalCmd);
|
|
243
|
+
if (!manifestEntry) {
|
|
244
|
+
issues.push({
|
|
245
|
+
type: 'not-run',
|
|
246
|
+
cmd,
|
|
247
|
+
message: `trust divergence: agent claimed '${cmd}' passed; command is not in the reconcile manifest — a test_output claim must name a manifest/required-lane command (CI cannot self-declare an arbitrary command)`,
|
|
248
|
+
});
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
unresolved.push({ ...entry, manifestEntry });
|
|
253
|
+
}
|
|
254
|
+
return { issues, unresolved };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Session-local claims: not CI-reconcilable, but NOT a pass bypass. Each must either
|
|
259
|
+
* (a) carry a loud, justified waiver, or (b) resolve a real CI-RE-DERIVED `verified`
|
|
260
|
+
* status. WS8 iteration-2 hardening:
|
|
261
|
+
* - finding 3: the status used here is RE-DERIVED CI-side, never the self-reported
|
|
262
|
+
* claim.status. A mismatch is a `status-misassertion` divergence.
|
|
263
|
+
* - finding 2: `assumed` alone is NO LONGER a silent pass. `assumed` is acceptable
|
|
264
|
+
* ONLY with a waiver (printed as a loud WAIVED line by the caller). An unwaived
|
|
265
|
+
* `assumed` claim is an `unwaived-assumed` divergence (restores pre-WS8 semantics
|
|
266
|
+
* where `assumed` alone never satisfied assertsPass).
|
|
267
|
+
*
|
|
268
|
+
* Q1/iteration-1-F1 (extraction-granularity + caller-controlled mode): `derivedStatus` is a
|
|
269
|
+
* `Map<string,string|null>|null` — the SAME value trust-reconcile.js's `deriveClaimStatuses()`
|
|
270
|
+
* produces (shells out to derive-claim-status.mjs, local-only, no CI command execution).
|
|
271
|
+
* `opts.onUnderivable` makes the `derivedStatus === null` behavior an EXPLICIT caller choice —
|
|
272
|
+
* there is no silent default that fails open:
|
|
273
|
+
* - `'fail'` (DEFAULT — the safe/original CI behavior; a caller that forgets `opts` never
|
|
274
|
+
* fails open): when `derivedStatus` is null, EVERY session-local pass-asserting claim
|
|
275
|
+
* becomes a `status-underivable` divergence (verbatim pre-#356 message + `continue`) —
|
|
276
|
+
* we never fall back to trusting the bundle's own status. `scripts/ci/trust-reconcile.js`
|
|
277
|
+
* MUST use this mode; it is CI's trust anchor.
|
|
278
|
+
* - `'reduce'` (LOCAL-PREFLIGHT-ONLY opt-in): when `derivedStatus` is null, DEGRADE to a
|
|
279
|
+
* documented reduced-coverage mode — status-misassertion/status-underivable checks are
|
|
280
|
+
* skipped entirely (nothing to re-derive against), but the waiver/unwaived-assumed/
|
|
281
|
+
* session-local-failed/unwaived-session-local checks still run against the claim's own
|
|
282
|
+
* self-reported `assertedStatus`. Only `src/cli/workflow-sidecar.ts`'s local
|
|
283
|
+
* `runReconcilePreflight` opts into this (and surfaces the reduced coverage to the user via
|
|
284
|
+
* a warning) — CI must never reach this branch.
|
|
285
|
+
* When `derivedStatus` is non-null, both modes behave identically (full parity with CI).
|
|
286
|
+
*
|
|
287
|
+
* Returns { issues, attestedCount, logEvents } — attestedCount mirrors trust-reconcile.js's
|
|
288
|
+
* own "N attested claim(s) accepted without independent verification" summary line;
|
|
289
|
+
* logEvents is the ordered list of WAIVED/ATTESTED terminal classifications (F3, iteration-1)
|
|
290
|
+
* so a caller's stdout narrative (e.g. trust-reconcile.js's WAIVED/ATTESTED log lines) is
|
|
291
|
+
* driven by this single classification instead of a parallel re-derivation.
|
|
292
|
+
*/
|
|
293
|
+
function sessionLocalShapeIssues(sessionLocal, derivedStatus, opts) {
|
|
294
|
+
const onUnderivable = (opts && opts.onUnderivable) || 'fail';
|
|
295
|
+
const issues = [];
|
|
296
|
+
let attestedCount = 0;
|
|
297
|
+
// F3 (iteration-1): single source of truth for the WAIVED/ATTESTED classification, so
|
|
298
|
+
// trust-reconcile.js's stdout narrative loop consumes this instead of re-deriving its own
|
|
299
|
+
// (previously parallel, driftable) copy. Only populated when a claim reaches the WAIVED or
|
|
300
|
+
// ATTESTED terminal below (never for issues) — callers that don't log can ignore it.
|
|
301
|
+
const logEvents = [];
|
|
302
|
+
|
|
303
|
+
for (const { claimId, claimType, assertedStatus, waiver, subject, evidenceType } of sessionLocal || []) {
|
|
304
|
+
let status;
|
|
305
|
+
if (derivedStatus) {
|
|
306
|
+
// finding 3: re-derive; never trust the asserted status.
|
|
307
|
+
const derived = derivedStatus.get(claimId);
|
|
308
|
+
if (derived === undefined || derived === null) {
|
|
309
|
+
issues.push({
|
|
310
|
+
type: 'status-underivable',
|
|
311
|
+
message: `trust divergence: session-local claim '${claimId}' (claimType: ${claimType}) could not be re-derived CI-side from the bundle's own evidence/events/policies — refusing to trust its self-reported status '${assertedStatus || 'unknown'}' (fail-closed)`,
|
|
312
|
+
});
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
if (derived !== assertedStatus) {
|
|
316
|
+
issues.push({
|
|
317
|
+
type: 'status-misassertion',
|
|
318
|
+
message: `trust divergence: session-local claim '${claimId}' (claimType: ${claimType}) asserts status '${assertedStatus || 'unknown'}' but CI re-derivation from the bundle's own evidence/events/policies yields '${derived}' — the reconciler does not trust self-reported claim.status`,
|
|
319
|
+
});
|
|
320
|
+
continue;
|
|
321
|
+
}
|
|
322
|
+
status = derived;
|
|
323
|
+
} else if (onUnderivable === 'reduce') {
|
|
324
|
+
// Reduced-coverage mode (derivedStatus === null, explicit local-preflight opt-in): trust
|
|
325
|
+
// the self-reported status for the remaining shape checks only. status-misassertion/
|
|
326
|
+
// status-underivable are, by definition, not checkable without a derivation source —
|
|
327
|
+
// documented gap, not a bug.
|
|
328
|
+
status = assertedStatus;
|
|
329
|
+
} else {
|
|
330
|
+
// Fail-closed mode (default; CI): restores the pre-#356 inline behavior verbatim — we
|
|
331
|
+
// never fall back to trusting a self-reported status.
|
|
332
|
+
issues.push({
|
|
333
|
+
type: 'status-underivable',
|
|
334
|
+
message: `trust divergence: session-local claim '${claimId}' (claimType: ${claimType}) asserts status '${assertedStatus || 'unknown'}' but CI-side re-derivation is unavailable — refusing to trust a self-reported status (fail-closed)`,
|
|
335
|
+
});
|
|
336
|
+
continue;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
if (status === 'disputed' || status === 'rejected') {
|
|
340
|
+
issues.push({
|
|
341
|
+
type: 'session-local-failed',
|
|
342
|
+
message: `trust divergence: session-local claim '${claimId}' (claimType: ${claimType}) has re-derived status '${status}' — a failing/rejected claim blocks (session-local classification is not a pass bypass)`,
|
|
343
|
+
});
|
|
344
|
+
continue;
|
|
345
|
+
}
|
|
346
|
+
// finding 2: a waiver is the ONLY way an `assumed` (or otherwise non-`verified`)
|
|
347
|
+
// session-local claim passes. `verified` still passes on its own re-derived status.
|
|
348
|
+
if (waiver && waiver.reason && waiver.approved_by) {
|
|
349
|
+
logEvents.push({ kind: 'waived', claimId, claimType, subject, evidenceType, status, waiver });
|
|
350
|
+
continue; // WAIVED — caller may log this loudly; not an issue.
|
|
351
|
+
}
|
|
352
|
+
if (status === 'verified') {
|
|
353
|
+
attestedCount++;
|
|
354
|
+
logEvents.push({ kind: 'attested', claimId, claimType, subject, evidenceType, status });
|
|
355
|
+
continue; // ATTESTED (not independently verifiable at L0) — caller may log; not an issue.
|
|
356
|
+
}
|
|
357
|
+
if (status === 'assumed') {
|
|
358
|
+
issues.push({
|
|
359
|
+
type: 'unwaived-assumed',
|
|
360
|
+
message: `trust divergence: session-local claim '${claimId}' (claimType: ${claimType}) has re-derived status 'assumed' but carries no waiver — 'assumed' alone is not a pass; it requires a documented waiver (--accepted-gap-reason/--waived-by) to be accepted`,
|
|
361
|
+
});
|
|
362
|
+
continue;
|
|
363
|
+
}
|
|
364
|
+
issues.push({
|
|
365
|
+
type: 'unwaived-session-local',
|
|
366
|
+
message: `trust divergence: session-local claim '${claimId}' (claimType: ${claimType}) asserts pass with re-derived status '${status || 'unknown'}' but has no waiver and no CI-re-derived verified status`,
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
return { issues, attestedCount, logEvents };
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
module.exports = {
|
|
374
|
+
classifyBundleClaims,
|
|
375
|
+
normalizeCmd,
|
|
376
|
+
isPassingValue,
|
|
377
|
+
waiverOnCommandIssues,
|
|
378
|
+
noEvidenceCommandIssues,
|
|
379
|
+
reconcilableManifestIssues,
|
|
380
|
+
sessionLocalShapeIssues,
|
|
381
|
+
};
|
|
@@ -120,6 +120,7 @@ function loadActorIdentityHelper(): {
|
|
|
120
120
|
isUnresolvedActor: (actor: string) => boolean;
|
|
121
121
|
sanitizeSegment: (value: unknown) => string;
|
|
122
122
|
detectRuntime: (env: NodeJS.ProcessEnv) => string;
|
|
123
|
+
detectCiActor: (env: NodeJS.ProcessEnv) => { runtime: string; session_id: string } | null;
|
|
123
124
|
} {
|
|
124
125
|
const _req = createRequire(import.meta.url);
|
|
125
126
|
const helperPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../scripts/hooks/lib/actor-identity.js");
|
|
@@ -129,6 +130,7 @@ function loadActorIdentityHelper(): {
|
|
|
129
130
|
isUnresolvedActor: (actor: string) => boolean;
|
|
130
131
|
sanitizeSegment: (value: unknown) => string;
|
|
131
132
|
detectRuntime: (env: NodeJS.ProcessEnv) => string;
|
|
133
|
+
detectCiActor: (env: NodeJS.ProcessEnv) => { runtime: string; session_id: string } | null;
|
|
132
134
|
};
|
|
133
135
|
}
|
|
134
136
|
|
|
@@ -199,7 +201,16 @@ function loadActorStruct(args: ParsedArgs): { actor: ActorStruct; actorKey?: str
|
|
|
199
201
|
const helper = loadActorIdentityHelper();
|
|
200
202
|
const resolved = helper.resolveActor(process.env);
|
|
201
203
|
if (helper.isUnresolvedActor(resolved.actor)) throw new Error("could not resolve an actor identity (no --actor-json and no resolvable environment actor); pass --actor-json explicitly");
|
|
202
|
-
|
|
204
|
+
// #398: reconstruct the SAME struct resolveActor serialized for a CI actor, mirroring
|
|
205
|
+
// resolveEnsureSessionActor (workflow-sidecar.ts) via the shared detectCiActor. Without this the
|
|
206
|
+
// else-branch would write `record.actor = {runtime:"unknown", session_id:<the whole triple>}` for a
|
|
207
|
+
// CI session — actor_key stays correct (so no false-block), but record.actor is malformed and the
|
|
208
|
+
// audit-trail / `assignment-provider status` output for CI sessions would be corrupt.
|
|
209
|
+
const ci = resolved.source.startsWith("ci-runtime") ? helper.detectCiActor(process.env) : null;
|
|
210
|
+
const actor: ActorStruct = ci && ci.session_id
|
|
211
|
+
? { runtime: ci.runtime, session_id: ci.session_id, host: os.hostname(), human: null }
|
|
212
|
+
: { runtime: helper.detectRuntime(process.env), session_id: resolved.actor, host: os.hostname(), human: null };
|
|
213
|
+
return { actor, actorKey: resolved.actor };
|
|
203
214
|
}
|
|
204
215
|
|
|
205
216
|
export function assignmentFilePath(artifactRoot: string, subjectId: string): string {
|
|
@@ -559,31 +570,129 @@ function claimLocalFile(argv: string[]): number {
|
|
|
559
570
|
return 0;
|
|
560
571
|
}
|
|
561
572
|
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
573
|
+
/**
|
|
574
|
+
* Wave 1 (#292) extraction: the durable-write body previously inlined inside releaseLocalFile's
|
|
575
|
+
* withSubjectLock() closure, now a parameter-driven pure function so the Stop hook's non-terminal
|
|
576
|
+
* release lifecycle (scripts/hooks/stop-goal-fit.js, #292 Wave 2) can reuse the EXACT same release
|
|
577
|
+
* logic — actor-ownership verification, audit-trail append, atomic write under withSubjectLock —
|
|
578
|
+
* rather than reimplementing a second, parallel release path. releaseLocalFile (CLI wrapper,
|
|
579
|
+
* below) is now a thin parse-args/print-envelope shell around this, mirroring the
|
|
580
|
+
* performLocalSupersede/supersedeLocalFile extraction shape exactly.
|
|
581
|
+
*
|
|
582
|
+
* Two behaviors are deliberately DIFFERENT from a naive inline release, both required for the
|
|
583
|
+
* Stop hook's idempotent, actor-scoped lifecycle release (never for the interactive CLI, which
|
|
584
|
+
* keeps `tolerateNoActiveClaim` unset/false and therefore 100% of its prior throw-on-error shape):
|
|
585
|
+
*
|
|
586
|
+
* - `opts.tolerateNoActiveClaim === true` and there is no existing record, or the existing
|
|
587
|
+
* record's status is not `"claimed"`: return `null` (a tolerated no-op) instead of throwing
|
|
588
|
+
* "no active claim to release". This is the one deliberate idempotency change vs today's
|
|
589
|
+
* releaseLocalFile — a second release call (e.g. a double Stop event) must be a safe no-op.
|
|
590
|
+
* - `releasedBy` is provided and does not match the existing record's holder: never force-release
|
|
591
|
+
* a claim held by a different actor — return `null` (if tolerateNoActiveClaim) or throw
|
|
592
|
+
* (otherwise), same as the no-active-claim case. The comparison mirrors computeEffectiveState()'s
|
|
593
|
+
* `record.actor_key || helper.serializeActor(record.actor)` canonical-key preference EXACTLY
|
|
594
|
+
* (actor_key-first, falling back to serializeActor only when actor_key is absent) — the read
|
|
595
|
+
* path (status/effective-state) and this write path (release) must use the identical
|
|
596
|
+
* canonical-key comparison, or a claim written under an explicit-override actor (`actor_key`
|
|
597
|
+
* bare, e.g. `"canonical-x"`, but `serializeActor(record.actor)` a DIFFERENT triple, e.g.
|
|
598
|
+
* `"explicit-override:canonical-x:host"`) can be self-recognized by computeEffectiveState() yet
|
|
599
|
+
* fail to release here because the releaser's canonical key was compared against the wrong
|
|
600
|
+
* (re-derived, triple) form instead of the stored actor_key. Comparing two serializeActor()
|
|
601
|
+
* calls unconditionally — as a prior version of this function did — is NOT correct for override
|
|
602
|
+
* actors and reintroduces the exact #291 seam on the release path.
|
|
603
|
+
*
|
|
604
|
+
* Contract: when `releasedBy` is provided AND the existing record is `actor_key`-stamped,
|
|
605
|
+
* `opts.actorKey` is REQUIRED (the canonical `resolveActor(env).actor` string) — otherwise
|
|
606
|
+
* ownership cannot be verified. A caller that passes `releasedBy` without `opts.actorKey` against
|
|
607
|
+
* an `actor_key`-stamped record would have its ownership compared as
|
|
608
|
+
* `existing.actor_key` (bare canonical) vs `serializeActor(releasedBy)` (re-derived triple), which
|
|
609
|
+
* can NEVER match even for the legitimate holder — a silent-failure trap, not a real ownership
|
|
610
|
+
* check. This is refused loudly (see the guard at the top of the `releasedBy` branch below) rather
|
|
611
|
+
* than allowed to silently no-op or wrongly refuse.
|
|
612
|
+
*/
|
|
613
|
+
export function performLocalRelease(
|
|
614
|
+
artifactRoot: string,
|
|
615
|
+
subjectId: string,
|
|
616
|
+
releasedBy: ActorStruct | null,
|
|
617
|
+
opts: { reason?: string; actorKey?: string; tolerateNoActiveClaim?: boolean } = {},
|
|
618
|
+
): AssignmentClaimRecord | null {
|
|
619
|
+
const helper = loadActorIdentityHelper();
|
|
620
|
+
const reason = opts.reason ?? "released";
|
|
621
|
+
const tolerateNoActiveClaim = opts.tolerateNoActiveClaim ?? false;
|
|
570
622
|
|
|
571
623
|
// F1 fix (fix-plan iteration 1, CRITICAL): release mutates the same record file claim/supersede
|
|
572
624
|
// do, under the same per-subject lock (see withSubjectLock()'s doc comment).
|
|
573
|
-
return withSubjectLock(artifactRoot, subjectId, ():
|
|
625
|
+
return withSubjectLock(artifactRoot, subjectId, (): AssignmentClaimRecord | null => {
|
|
574
626
|
const existing = readLocalRecord(artifactRoot, subjectId);
|
|
575
|
-
if (!existing || existing.status !== "claimed")
|
|
627
|
+
if (!existing || existing.status !== "claimed") {
|
|
628
|
+
if (tolerateNoActiveClaim) return null;
|
|
629
|
+
throw new Error(`no active claim to release for subject: ${subjectId}`);
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
if (releasedBy) {
|
|
633
|
+
// Contract guard (hardening fix, #292 review): a caller that supplies `releasedBy` but NOT
|
|
634
|
+
// `opts.actorKey` against a record that already carries `actor_key` cannot reliably prove
|
|
635
|
+
// ownership — see this function's doc comment. This is the ONLY combination that fires: it
|
|
636
|
+
// does NOT fire when `existing.actor_key` is absent (the CLI/fixture path, where both sides
|
|
637
|
+
// fall back to serializeActor() and legitimately compare equal). Fail loudly rather than
|
|
638
|
+
// silently no-op (tolerant callers) or wrongly refuse (throwing callers) — never silent.
|
|
639
|
+
if (!opts.actorKey && existing.actor_key) {
|
|
640
|
+
if (tolerateNoActiveClaim) {
|
|
641
|
+
console.error(
|
|
642
|
+
`[performLocalRelease] cannot verify ownership of an actor_key-stamped record without opts.actorKey; skipping release for ${subjectId}`,
|
|
643
|
+
);
|
|
644
|
+
return null;
|
|
645
|
+
}
|
|
646
|
+
throw new Error(
|
|
647
|
+
"performLocalRelease: pass opts.actorKey (the canonical resolveActor().actor string) when releasedBy is set and the record carries actor_key — serializeActor(releasedBy) is not a valid ownership key for actor_key-stamped records",
|
|
648
|
+
);
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
// AC6: never force-release a claim held by a different actor. Mirrors
|
|
652
|
+
// computeEffectiveState()'s canonical self-recognition comparison EXACTLY —
|
|
653
|
+
// `holderActorKey` prefers the stored `actor_key` (the canonical resolveActor(env).actor
|
|
654
|
+
// string, present on records written by the fixed performLocalClaim/performLocalSupersede
|
|
655
|
+
// paths) and only falls back to `serializeActor(existing.actor)` when `actor_key` is
|
|
656
|
+
// absent (every pre-fix record, every #290 eval fixture). The releaser's side must use the
|
|
657
|
+
// SAME canonical form: `opts.actorKey` (the caller's resolveActor(env).actor string, e.g.
|
|
658
|
+
// scripts/hooks/stop-goal-fit.js's Stop hook) when provided, else re-derived via
|
|
659
|
+
// serializeActor(releasedBy) — never serializeActor() unconditionally on both sides, which
|
|
660
|
+
// would compare the bare actor_key form against a re-derived triple form for an
|
|
661
|
+
// explicit-override actor and spuriously reject a legitimate same-actor release (the #291
|
|
662
|
+
// seam, relocated to this write path).
|
|
663
|
+
const holderActorKey = existing.actor_key || helper.serializeActor(existing.actor);
|
|
664
|
+
const releasedByActorKey = opts.actorKey || helper.serializeActor(releasedBy);
|
|
665
|
+
if (holderActorKey !== releasedByActorKey) {
|
|
666
|
+
if (tolerateNoActiveClaim) return null;
|
|
667
|
+
throw new Error(`--actor-json does not match the current holder (${holderActorKey}); refusing to release a claim held by someone else`);
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
|
|
576
671
|
const record: AssignmentClaimRecord = {
|
|
577
672
|
...existing,
|
|
673
|
+
...(opts.actorKey ? { actor_key: opts.actorKey } : {}),
|
|
578
674
|
status: "released",
|
|
579
675
|
audit_trail: [...(existing.audit_trail ?? []), { at: isoNow(), transition: "release", from_actor: existing.actor, to_actor: releasedBy, reason }],
|
|
580
676
|
};
|
|
581
677
|
writeLocalRecord(artifactRoot, subjectId, record);
|
|
582
|
-
|
|
583
|
-
return 0;
|
|
678
|
+
return record;
|
|
584
679
|
});
|
|
585
680
|
}
|
|
586
681
|
|
|
682
|
+
function releaseLocalFile(argv: string[]): number {
|
|
683
|
+
const args = parseArgs(argv);
|
|
684
|
+
const provider = flagString(args.flags, "provider", "local-file");
|
|
685
|
+
if (provider !== "local-file") throw new Error(`release: --provider must be local-file (use render-release for github); got ${provider}`);
|
|
686
|
+
const artifactRoot = requireFlag(args, "artifact-root");
|
|
687
|
+
const subjectId = requireFlag(args, "subject-id");
|
|
688
|
+
const releasedBy = flagString(args.flags, "actor-json") ? loadActorStructFromFile(requireFlag(args, "actor-json")) : null;
|
|
689
|
+
const reason = flagString(args.flags, "reason") ?? "released";
|
|
690
|
+
|
|
691
|
+
const record = performLocalRelease(artifactRoot, subjectId, releasedBy, { reason, tolerateNoActiveClaim: false });
|
|
692
|
+
console.log(JSON.stringify({ role: "AssignmentReleaseResult", subject_id: subjectId, record }, null, 2));
|
|
693
|
+
return 0;
|
|
694
|
+
}
|
|
695
|
+
|
|
587
696
|
/**
|
|
588
697
|
* Wave 1 (#291) extraction: the durable-write body previously inlined inside supersedeLocalFile's
|
|
589
698
|
* withSubjectLock() closure, now a parameter-driven pure function so ensure-session's
|