@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
|
@@ -124,6 +124,18 @@ const path = require('path');
|
|
|
124
124
|
// copy here had drifted (it was missing the trailing `/bin/true` check), which is
|
|
125
125
|
// exactly why this is now imported rather than duplicated.
|
|
126
126
|
const { hasLaunderingOperator } = require('../lib/command-log-chain.js');
|
|
127
|
+
// #356: the bundle-shape classification (classifyBundleClaims) and the pure,
|
|
128
|
+
// shape-level divergence-construction helpers used by Step 2 below are shared with a
|
|
129
|
+
// local pre-push preflight (reconcile-preflight) via this module, so the preflight can
|
|
130
|
+
// never drift from what this CI reconciler actually enforces. See that module's own
|
|
131
|
+
// header comment for the extraction rationale.
|
|
132
|
+
const {
|
|
133
|
+
classifyBundleClaims,
|
|
134
|
+
waiverOnCommandIssues,
|
|
135
|
+
noEvidenceCommandIssues,
|
|
136
|
+
reconcilableManifestIssues,
|
|
137
|
+
sessionLocalShapeIssues,
|
|
138
|
+
} = require('../lib/reconcile-shape.js');
|
|
127
139
|
|
|
128
140
|
// ---------------------------------------------------------------------------
|
|
129
141
|
// Helpers
|
|
@@ -199,138 +211,6 @@ function runCommand(cmd, repoRoot) {
|
|
|
199
211
|
};
|
|
200
212
|
}
|
|
201
213
|
|
|
202
|
-
/**
|
|
203
|
-
* Classify a trust.bundle's claims into: reconcilable command claims (test_output +
|
|
204
|
-
* execution.label), session-local claims (attestation/observation/citation), never-captured
|
|
205
|
-
* or unbacked command claims (not-run divergence), and command-backed claims carrying a
|
|
206
|
-
* waiver (waiver-on-command divergence). Returns
|
|
207
|
-
* { reconcilable, sessionLocal, noEvidenceCommand, waiverOnCommand }.
|
|
208
|
-
*
|
|
209
|
-
* Source of truth: evidence[].execution.label is the command string recorded at capture time.
|
|
210
|
-
* evidence[].passing (normalized) means the agent claimed this passed. `claim.status` is NOT
|
|
211
|
-
* trusted here — the caller re-derives it CI-side (see derive-claim-status.mjs / finding-3).
|
|
212
|
-
*
|
|
213
|
-
* WS8 iteration-2 hardening:
|
|
214
|
-
* - finding 1: ANY pass-asserting claim whose evidence is `evidenceType: test_output`
|
|
215
|
-
* (Surface's default when unset) but which did NOT reconcile — i.e. it has no
|
|
216
|
-
* manifest-matchable execution.label — is a divergence, NOT session-local. A test_output
|
|
217
|
-
* claim either reconciles against the manifest or is a divergence; it is never accepted on
|
|
218
|
-
* self-reported status. (Previously only the literal claimType `workflow.check.command`
|
|
219
|
-
* was guarded, so a fabricated kind:"test" claim with no command slipped through.)
|
|
220
|
-
* - finding 4: a command-backed (test_output) claim carrying a waiver is a divergence — a
|
|
221
|
-
* command-backed check reconciles against CI or fails; it cannot be waived.
|
|
222
|
-
*/
|
|
223
|
-
function classifyBundleClaims(bundle) {
|
|
224
|
-
const evidence = Array.isArray(bundle.evidence) ? bundle.evidence : [];
|
|
225
|
-
const claims = Array.isArray(bundle.claims) ? bundle.claims : [];
|
|
226
|
-
|
|
227
|
-
const claimById = new Map();
|
|
228
|
-
for (const c of claims) if (c && c.id) claimById.set(c.id, c);
|
|
229
|
-
|
|
230
|
-
// Evidence indexing. A missing evidenceType defaults to test_output for backward
|
|
231
|
-
// compatibility with pre-classification bundles (same default classifyEvidence uses).
|
|
232
|
-
const claimHasLabeledTestOutput = new Set(); // test_output evidence WITH an execution.label
|
|
233
|
-
const claimHasTestOutputEvidence = new Set(); // ANY test_output evidence (label or not)
|
|
234
|
-
// WS8 iteration-4 (converged finding): the session-local (non-test_output) evidenceType per
|
|
235
|
-
// claim, so the reconciler can name it on the loud ATTESTED marker below — a fabricated
|
|
236
|
-
// human_attestation/attestation/external claim with no --command is otherwise
|
|
237
|
-
// indistinguishable, in the reconciler's own output, from a genuinely re-runnable check.
|
|
238
|
-
const claimEvidenceType = new Map();
|
|
239
|
-
for (const ev of evidence) {
|
|
240
|
-
if (!ev || !ev.claimId) continue;
|
|
241
|
-
const evType = ev.evidenceType || 'test_output';
|
|
242
|
-
if (evType !== 'test_output') {
|
|
243
|
-
if (!claimEvidenceType.has(ev.claimId)) claimEvidenceType.set(ev.claimId, evType);
|
|
244
|
-
continue;
|
|
245
|
-
}
|
|
246
|
-
claimHasTestOutputEvidence.add(ev.claimId);
|
|
247
|
-
if (ev.execution && ev.execution.label) claimHasLabeledTestOutput.add(ev.claimId);
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
// finding 4: a command-backed (test_output-evidence) claim that also carries a waiver.
|
|
251
|
-
const waiverOnCommand = [];
|
|
252
|
-
for (const c of claims) {
|
|
253
|
-
if (!c || !c.id) continue;
|
|
254
|
-
const waiver = (c.metadata && typeof c.metadata === 'object') ? c.metadata.waiver : undefined;
|
|
255
|
-
if (waiver && typeof waiver === 'object' && claimHasTestOutputEvidence.has(c.id)) {
|
|
256
|
-
waiverOnCommand.push({ claimId: c.id, claimType: String(c.claimType || ''), subject: c.subjectId || c.fieldOrBehavior || c.id });
|
|
257
|
-
}
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
// (A) Reconcilable claimed-passes: evidence items that are test_output (CI-reconcilable),
|
|
261
|
-
// carry an execution.label, and assert pass. Session-local evidenceTypes
|
|
262
|
-
// (crawl_observation, human_attestation, attestation, policy_rule, source_excerpt,
|
|
263
|
-
// document_citation, calculation_trace) are NOT reconciled per-command — they are handled
|
|
264
|
-
// by the session-local/waiver path below.
|
|
265
|
-
const reconcilable = [];
|
|
266
|
-
const reconcilableClaimIds = new Set();
|
|
267
|
-
const seen = new Set();
|
|
268
|
-
for (const ev of evidence) {
|
|
269
|
-
if (!ev || !ev.execution || !ev.execution.label) continue;
|
|
270
|
-
if (!isPassingValue(ev.passing)) continue;
|
|
271
|
-
const evType = ev.evidenceType || 'test_output';
|
|
272
|
-
if (evType !== 'test_output') continue; // session-local — not CI-reconcilable
|
|
273
|
-
const cmd = normalizeCmd(ev.execution.label);
|
|
274
|
-
if (!cmd) continue;
|
|
275
|
-
reconcilableClaimIds.add(ev.claimId);
|
|
276
|
-
if (seen.has(cmd)) continue;
|
|
277
|
-
seen.add(cmd);
|
|
278
|
-
const claim = claimById.get(ev.claimId);
|
|
279
|
-
reconcilable.push({ cmd, claimId: ev.claimId, evId: ev.id, claimType: claim ? String(claim.claimType || '') : '' });
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
// (B) Session-local claims, never-captured command claims, and unreconciled test_output.
|
|
283
|
-
const sessionLocal = [];
|
|
284
|
-
const noEvidenceCommand = [];
|
|
285
|
-
const seenClaims = new Set();
|
|
286
|
-
for (const c of claims) {
|
|
287
|
-
if (!c || !c.id || typeof c.claimType !== 'string') continue;
|
|
288
|
-
// #267/#282: a superseded critique write is HISTORY — excluded from reconcile evaluation so a
|
|
289
|
-
// resolved session converges (a fail critique that a later same-reviewer pass superseded no
|
|
290
|
-
// longer blocks). Scoped to NON-test_output claims so a command-backed claim can never launder
|
|
291
|
-
// a real failure by carrying superseded_by — a test_output claim always reconciles or diverges.
|
|
292
|
-
if (c.metadata && typeof c.metadata === 'object' && c.metadata.superseded_by && !claimHasTestOutputEvidence.has(c.id)) continue;
|
|
293
|
-
if (reconcilableClaimIds.has(c.id)) continue; // handled by (A)
|
|
294
|
-
if (seenClaims.has(c.id)) continue;
|
|
295
|
-
const status = String(c.status || '');
|
|
296
|
-
const assertsPass = isPassingValue(c.value) || status === 'verified' || status === 'assumed';
|
|
297
|
-
const isFailing = status === 'disputed' || status === 'rejected';
|
|
298
|
-
if (!assertsPass && !isFailing) continue; // pending/unknown non-asserting — ignore (as before)
|
|
299
|
-
seenClaims.add(c.id);
|
|
300
|
-
|
|
301
|
-
// finding 1: a pass-asserting claim backed by test_output evidence that did NOT reconcile
|
|
302
|
-
// (it has test_output evidence but no manifest-matchable execution.label — otherwise it
|
|
303
|
-
// would be in bucket A) is a not-run divergence. A test_output claim reconciles against the
|
|
304
|
-
// manifest or it is a divergence — it is NEVER accepted as session-local on self-report.
|
|
305
|
-
if (assertsPass && claimHasTestOutputEvidence.has(c.id)) {
|
|
306
|
-
const rawCmd = normalizeCmd(c.fieldOrBehavior || c.value || '');
|
|
307
|
-
noEvidenceCommand.push({ cmd: rawCmd || `[claim:${c.id}]`, claimId: c.id, claimType: c.claimType, reason: 'test_output-unreconciled' });
|
|
308
|
-
continue;
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
// A workflow.check.command claim with no captured (labeled) evidence is a never-captured
|
|
312
|
-
// claimed pass — not-run divergence (anti-gaming teeth preserved).
|
|
313
|
-
if (assertsPass && c.claimType === 'workflow.check.command' && !claimHasLabeledTestOutput.has(c.id)) {
|
|
314
|
-
const rawCmd = normalizeCmd(c.fieldOrBehavior || c.value || '');
|
|
315
|
-
noEvidenceCommand.push({ cmd: rawCmd || `[claim:${c.id}:${c.claimType}]`, claimId: c.id, claimType: c.claimType, reason: 'no-evidence-command' });
|
|
316
|
-
continue;
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
const waiver = (c.metadata && typeof c.metadata === 'object') ? c.metadata.waiver : undefined;
|
|
320
|
-
sessionLocal.push({
|
|
321
|
-
claimId: c.id,
|
|
322
|
-
claimType: c.claimType,
|
|
323
|
-
assertedStatus: status,
|
|
324
|
-
value: c.value,
|
|
325
|
-
waiver: (waiver && typeof waiver === 'object') ? waiver : null,
|
|
326
|
-
subject: c.subjectId || c.fieldOrBehavior || c.id,
|
|
327
|
-
evidenceType: claimEvidenceType.get(c.id) || 'unknown',
|
|
328
|
-
});
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
return { reconcilable, sessionLocal, noEvidenceCommand, waiverOnCommand };
|
|
332
|
-
}
|
|
333
|
-
|
|
334
214
|
/**
|
|
335
215
|
* WS8 (finding 3): re-derive every claim's status CI-side from the bundle's OWN
|
|
336
216
|
* evidence/events/policies, using @kontourai/surface's canonical deriveClaimStatus (the same
|
|
@@ -522,32 +402,139 @@ const DELIVERY_DIR = 'delivery';
|
|
|
522
402
|
|
|
523
403
|
/**
|
|
524
404
|
* Single seam: every delivery/ path resolution in this file routes through here.
|
|
525
|
-
*
|
|
526
|
-
*
|
|
527
|
-
* delivery/<
|
|
528
|
-
*
|
|
529
|
-
*
|
|
530
|
-
*
|
|
531
|
-
*
|
|
405
|
+
*
|
|
406
|
+
* #335/#379 (per-session delivery paths) is IMPLEMENTED here now, not deferred: a shared
|
|
407
|
+
* `delivery/<filename>` guarantees a git merge conflict between ANY two concurrent
|
|
408
|
+
* deliveries — and a conflicting (DIRTY) PR gets NO `pull_request` workflows scheduled, so
|
|
409
|
+
* the required Trust Reconcile check silently never runs (field incidents #330/#358/#378).
|
|
410
|
+
* The fix is per-session paths so concurrent deliveries write to DISTINCT files and never
|
|
411
|
+
* contend.
|
|
412
|
+
*
|
|
413
|
+
* Returned candidate order (precedence, first is highest):
|
|
414
|
+
* 1. the flat `delivery/<filename>` — kept FIRST for full back-compat. A repo that never
|
|
415
|
+
* adopts per-session layout sees byte-identical behavior; an already-committed flat
|
|
416
|
+
* bundle from before this change still resolves.
|
|
417
|
+
* 2. `delivery/<slug>/<filename>` for every immediate subdirectory of `delivery/`, sorted
|
|
418
|
+
* by name for determinism.
|
|
419
|
+
*
|
|
420
|
+
* Ordering here is ONLY the tie-break / fallback. Which candidate actually reconciles is
|
|
421
|
+
* decided by COMMIT-OWNERSHIP (discoverBundle() + bundleAttestsThisChange()), not directory
|
|
422
|
+
* order: among the candidates, the one whose checkpoint attests an ancestor-or-equal commit
|
|
423
|
+
* of THIS change wins, and stale siblings from other concurrent sessions are ignored.
|
|
424
|
+
* discoverBundle() and discoverDeclaredMarker() both iterate whatever this returns, so the
|
|
425
|
+
* per-session layout composes with zero call-site changes.
|
|
532
426
|
*/
|
|
533
427
|
function resolveDeliveryCandidates(repoRoot, filename) {
|
|
534
|
-
|
|
428
|
+
const deliveryRoot = path.join(repoRoot, DELIVERY_DIR);
|
|
429
|
+
const candidates = [path.join(deliveryRoot, filename)];
|
|
430
|
+
// #379: append delivery/<slug>/<filename> for each immediate subdirectory of delivery/.
|
|
431
|
+
// readdirSync is best-effort — a missing delivery/ (or an unreadable one) yields just the
|
|
432
|
+
// flat candidate, never throws.
|
|
433
|
+
let entries = [];
|
|
434
|
+
try {
|
|
435
|
+
entries = fs.readdirSync(deliveryRoot, { withFileTypes: true });
|
|
436
|
+
} catch {
|
|
437
|
+
entries = [];
|
|
438
|
+
}
|
|
439
|
+
const subdirs = entries
|
|
440
|
+
.filter((e) => e.isDirectory())
|
|
441
|
+
.map((e) => e.name)
|
|
442
|
+
.sort();
|
|
443
|
+
for (const name of subdirs) {
|
|
444
|
+
candidates.push(path.join(deliveryRoot, name, filename));
|
|
445
|
+
}
|
|
446
|
+
return candidates;
|
|
535
447
|
}
|
|
536
448
|
|
|
537
449
|
/**
|
|
538
450
|
* Auto-discover the delivery bundle when no explicit path is provided.
|
|
539
|
-
*
|
|
540
|
-
*
|
|
541
|
-
*
|
|
542
|
-
*
|
|
451
|
+
*
|
|
452
|
+
* Gathers every existing candidate — flat `delivery/trust.bundle`, flat
|
|
453
|
+
* `delivery/trust.checkpoint.json`, and their `delivery/<slug>/…` per-session siblings (via
|
|
454
|
+
* resolveDeliveryCandidates()) — with trust.bundle preferred over trust.checkpoint.json.
|
|
455
|
+
*
|
|
456
|
+
* #379 (per-session, ownership-aware selection): when `changeSha` is known, the candidate
|
|
457
|
+
* whose checkpoint attests an ancestor-or-equal commit of THIS change wins (same
|
|
458
|
+
* bundleAttestsThisChange() semantics the flat staleness gate already uses). Stale/older
|
|
459
|
+
* siblings from OTHER concurrent sessions are ignored.
|
|
460
|
+
*
|
|
461
|
+
* PREFER-NEWEST among owning candidates. More than one candidate can legitimately "own" the
|
|
462
|
+
* change at once: an inherited FLAT bundle's commit_sha can be a real ancestor of HEAD in a
|
|
463
|
+
* merge-commit repo (it was committed on the trunk's linear history before this branch
|
|
464
|
+
* point), AND this session's fresh per-session bundle attests a NEWER ancestor (the delivery
|
|
465
|
+
* commit right before HEAD). "First-fresh-wins" would then select the STALE inherited flat
|
|
466
|
+
* bundle purely because it sorts first — reconciling last delivery's claims against this
|
|
467
|
+
* change's CI. So among all owning candidates we pick the one attesting the NEWEST commit
|
|
468
|
+
* (the descendant-most: the owning candidate whose commit_sha every other owning candidate's
|
|
469
|
+
* commit_sha is an ancestor of), via the same `git merge-base --is-ancestor` primitive. This
|
|
470
|
+
* makes an inherited/older owning bundle HARMLESS whether or not it was ever pruned — the
|
|
471
|
+
* fresh per-session bundle wins on recency, not on being deleted first.
|
|
472
|
+
*
|
|
473
|
+
* When no candidate owns this change (or `changeSha` is unknown, e.g. a local run with no
|
|
474
|
+
* TRUST_RECONCILE_SHA), the first existing candidate is returned so the caller's staleness
|
|
475
|
+
* gate below handles it exactly as before: a stale bundle is treated as absent, a malformed
|
|
476
|
+
* one still raises the Step 2 read error.
|
|
477
|
+
*
|
|
478
|
+
* Returns null if no candidate exists at all — the caller then falls through to
|
|
479
|
+
* delivery/DECLARED marker resolution (ADR 0022 §1); bundle absence is not fail-open.
|
|
543
480
|
*/
|
|
544
|
-
function discoverBundle(repoRoot) {
|
|
481
|
+
function discoverBundle(repoRoot, changeSha) {
|
|
482
|
+
const candidates = [];
|
|
545
483
|
for (const filename of ['trust.bundle', 'trust.checkpoint.json']) {
|
|
546
484
|
for (const candidate of resolveDeliveryCandidates(repoRoot, filename)) {
|
|
547
|
-
if (fs.existsSync(candidate))
|
|
485
|
+
if (fs.existsSync(candidate)) candidates.push(candidate);
|
|
548
486
|
}
|
|
549
487
|
}
|
|
550
|
-
return null;
|
|
488
|
+
if (candidates.length === 0) return null;
|
|
489
|
+
|
|
490
|
+
const deliveryRoot = path.join(repoRoot, DELIVERY_DIR);
|
|
491
|
+
const hasPerSession = candidates.some((c) => path.dirname(c) !== deliveryRoot);
|
|
492
|
+
|
|
493
|
+
// Ownership-aware selection (#379). Only meaningful when we know this change's sha; a
|
|
494
|
+
// candidate that fails to parse is skipped here (never treated as owning) — if it turns
|
|
495
|
+
// out to be the only candidate, it is still returned below so Step 2's "failed to read
|
|
496
|
+
// bundle" diagnostic fires unchanged.
|
|
497
|
+
if (changeSha) {
|
|
498
|
+
const owning = []; // { candidate, bundleSha } — every candidate that attests this change
|
|
499
|
+
for (const candidate of candidates) {
|
|
500
|
+
let json = null;
|
|
501
|
+
try {
|
|
502
|
+
json = JSON.parse(fs.readFileSync(candidate, 'utf8'));
|
|
503
|
+
} catch {
|
|
504
|
+
json = null;
|
|
505
|
+
}
|
|
506
|
+
if (!json) continue;
|
|
507
|
+
const { fresh, bundleSha } = bundleAttestsThisChange(repoRoot, candidate, json, changeSha);
|
|
508
|
+
if (fresh) owning.push({ candidate, bundleSha });
|
|
509
|
+
}
|
|
510
|
+
if (owning.length > 0) {
|
|
511
|
+
// Prefer the owning candidate attesting the NEWEST (descendant-most) commit. `best` is
|
|
512
|
+
// upgraded to a candidate whose commit is a strict descendant of the current best's —
|
|
513
|
+
// i.e. best.sha is an ancestor of cand.sha. Every owning entry has a non-null bundleSha
|
|
514
|
+
// (bundleAttestsThisChange returns fresh=false otherwise). Ties / incomparable siblings
|
|
515
|
+
// keep the earlier (candidate-order) entry, so the flat path still wins a genuine tie.
|
|
516
|
+
let best = owning[0];
|
|
517
|
+
for (let i = 1; i < owning.length; i++) {
|
|
518
|
+
const cand = owning[i];
|
|
519
|
+
if (cand.bundleSha !== best.bundleSha && isAncestorCommit(repoRoot, best.bundleSha, cand.bundleSha)) {
|
|
520
|
+
best = cand;
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
if (hasPerSession) {
|
|
524
|
+
process.stdout.write(`[trust-reconcile] #379: selected delivery candidate ${path.relative(repoRoot, best.candidate)} — attests this change ${changeSha} (${candidates.length} candidate(s) present across flat + per-session delivery/<slug>/; ${owning.length} owning, newest wins; older/stale ignored)\n`);
|
|
525
|
+
}
|
|
526
|
+
return best.candidate;
|
|
527
|
+
}
|
|
528
|
+
// No candidate attests this change. When per-session candidates were in play, emit a
|
|
529
|
+
// loud, grep-stable concurrency hint (#379) so the next agent can tell a concurrent
|
|
530
|
+
// per-session collision apart from a plain stale/absent bundle — the per-candidate
|
|
531
|
+
// stale line still prints in runTrustReconcile's staleness gate for the returned one.
|
|
532
|
+
if (hasPerSession) {
|
|
533
|
+
const rels = candidates.map((c) => path.relative(repoRoot, c)).join(', ');
|
|
534
|
+
process.stdout.write(`[trust-reconcile] #379: examined ${candidates.length} delivery candidate(s) (flat + per-session delivery/<slug>/); none attests this change ${changeSha}. If this is a concurrent-delivery collision, the OWNING session's bundle is not on this ref — re-publish this session's delivery, or check whether main moved under the PR. Candidates: ${rels}\n`);
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
return candidates[0];
|
|
551
538
|
}
|
|
552
539
|
|
|
553
540
|
/**
|
|
@@ -813,22 +800,30 @@ function resolveDeclaredExemption(repoRoot, ctx) {
|
|
|
813
800
|
* src/cli/workflow-sidecar.ts). trust.bundle itself carries NO commit/branch metadata
|
|
814
801
|
* (schemaVersion 5: {schemaVersion, source, claims, evidence, policies, events} — confirmed
|
|
815
802
|
* by inspection), so when the discovered bundle IS trust.bundle, this falls through to its
|
|
816
|
-
*
|
|
817
|
-
*
|
|
803
|
+
* SIBLING trust.checkpoint.json for the binding — publishDelivery() always writes both
|
|
804
|
+
* together into the SAME directory.
|
|
805
|
+
*
|
|
806
|
+
* #379: the sibling is resolved from the bundle's OWN directory (path.dirname(bundlePath)),
|
|
807
|
+
* not from a global resolveDeliveryCandidates() scan. In the per-session layout each session
|
|
808
|
+
* gets its own delivery/<slug>/ dir, and a global scan would cross-contaminate — pairing a
|
|
809
|
+
* per-session trust.bundle with the FLAT (or a different session's) trust.checkpoint.json and
|
|
810
|
+
* reading the wrong commit binding. Same-directory resolution is identical to the prior
|
|
811
|
+
* behavior for the flat layout (delivery/trust.bundle ↔ delivery/trust.checkpoint.json) and
|
|
812
|
+
* correct for per-session.
|
|
818
813
|
*/
|
|
819
814
|
function extractBundleCommitSha(repoRoot, bundlePath, bundleJson) {
|
|
820
815
|
if (bundleJson && typeof bundleJson.commit_sha === 'string' && bundleJson.commit_sha.trim()) {
|
|
821
816
|
return bundleJson.commit_sha.trim();
|
|
822
817
|
}
|
|
823
|
-
|
|
824
|
-
|
|
818
|
+
const siblingCheckpoint = path.join(path.dirname(bundlePath), 'trust.checkpoint.json');
|
|
819
|
+
if (siblingCheckpoint !== bundlePath && fs.existsSync(siblingCheckpoint)) {
|
|
825
820
|
try {
|
|
826
|
-
const checkpoint = JSON.parse(fs.readFileSync(
|
|
821
|
+
const checkpoint = JSON.parse(fs.readFileSync(siblingCheckpoint, 'utf8'));
|
|
827
822
|
if (checkpoint && typeof checkpoint.commit_sha === 'string' && checkpoint.commit_sha.trim()) {
|
|
828
823
|
return checkpoint.commit_sha.trim();
|
|
829
824
|
}
|
|
830
825
|
} catch {
|
|
831
|
-
// Malformed sibling checkpoint — no usable binding from it;
|
|
826
|
+
// Malformed sibling checkpoint — no usable binding from it; fall through to null.
|
|
832
827
|
}
|
|
833
828
|
}
|
|
834
829
|
return null;
|
|
@@ -907,14 +902,17 @@ function runTrustReconcile({ bundle = null, commands = [], repoRoot = null, mani
|
|
|
907
902
|
// (.github/workflows/trust-reconcile.yml) never passes --bundle; it always relies on
|
|
908
903
|
// auto-discovery, which is exactly the path PR #278's stale-bundle incident went through.
|
|
909
904
|
const explicitBundlePath = bundle || process.env.TRUST_RECONCILE_BUNDLE || null;
|
|
910
|
-
let bundlePath = explicitBundlePath || discoverBundle(resolvedRepoRoot) || null;
|
|
911
|
-
const bundleWasAutoDiscovered = !explicitBundlePath && !!bundlePath;
|
|
912
905
|
|
|
913
|
-
// Scope-matching context (ref/actor/sha) — resolved once, reused by
|
|
914
|
-
//
|
|
915
|
-
//
|
|
906
|
+
// Scope-matching context (ref/actor/sha) — resolved once, reused by ownership-aware
|
|
907
|
+
// auto-discovery (#379, immediately below), the bundle-ownership staleness check further
|
|
908
|
+
// down (ADR 0022 addendum §2), and the delivery/DECLARED exemption path (bundle-absent
|
|
909
|
+
// branch). Resolved BEFORE auto-discovery because discoverBundle() now uses scopeCtx.sha
|
|
910
|
+
// to pick, among per-session candidates, the one that attests THIS change.
|
|
916
911
|
const scopeCtx = resolveScopeContext(resolvedRepoRoot);
|
|
917
912
|
|
|
913
|
+
let bundlePath = explicitBundlePath || discoverBundle(resolvedRepoRoot, scopeCtx.sha) || null;
|
|
914
|
+
const bundleWasAutoDiscovered = !explicitBundlePath && !!bundlePath;
|
|
915
|
+
|
|
918
916
|
// Event-scoped enforcement (ADR 0022 addendum, part 4): bundle-required enforcement
|
|
919
917
|
// (and the staleness-gate consequence of it, immediately below) applies ONLY when this
|
|
920
918
|
// run is gating a proposed change. A push run on main happens AFTER the gating PR run
|
|
@@ -1106,50 +1104,24 @@ function runTrustReconcile({ bundle = null, commands = [], repoRoot = null, mani
|
|
|
1106
1104
|
}
|
|
1107
1105
|
|
|
1108
1106
|
// finding 4 (server-side): a command-backed (test_output-evidence) claim carrying a
|
|
1109
|
-
// waiver is a divergence —
|
|
1110
|
-
//
|
|
1111
|
-
|
|
1112
|
-
issues.push({
|
|
1113
|
-
type: 'waiver-on-command-check',
|
|
1114
|
-
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`,
|
|
1115
|
-
});
|
|
1116
|
-
}
|
|
1107
|
+
// waiver is a divergence — shape-only, delegated to scripts/lib/reconcile-shape.js
|
|
1108
|
+
// (used byte-identically here and by the local reconcile-preflight).
|
|
1109
|
+
issues.push(...waiverOnCommandIssues(waiverOnCommand));
|
|
1117
1110
|
|
|
1118
1111
|
// not-run divergences: never-captured command claims (no evidence) AND test_output
|
|
1119
|
-
// claims that did not reconcile (no manifest-matchable execution.label).
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1112
|
+
// claims that did not reconcile (no manifest-matchable execution.label). Shape-only,
|
|
1113
|
+
// delegated to scripts/lib/reconcile-shape.js.
|
|
1114
|
+
issues.push(...noEvidenceCommandIssues(noEvidenceCommand));
|
|
1115
|
+
|
|
1116
|
+
// Reconcilable test_output claims: laundering → manifest match (shape-only, delegated
|
|
1117
|
+
// to scripts/lib/reconcile-shape.js) → CI fresh result (CI-only, stays here — requires
|
|
1118
|
+
// a live command execution the local preflight must not perform).
|
|
1119
|
+
const { issues: manifestIssues, unresolved: reconcilableUnresolved } =
|
|
1120
|
+
reconcilableManifestIssues(reconcilable, manifestByCmd);
|
|
1121
|
+
issues.push(...manifestIssues);
|
|
1122
|
+
for (const { cmd, manifestEntry: entry } of reconcilableUnresolved) {
|
|
1129
1123
|
const normalCmd = normalizeCmd(cmd);
|
|
1130
1124
|
|
|
1131
|
-
// (a) Laundering operator check — must come first (most specific signal).
|
|
1132
|
-
if (hasLaunderingOperator(cmd)) {
|
|
1133
|
-
issues.push({
|
|
1134
|
-
type: 'laundering',
|
|
1135
|
-
cmd,
|
|
1136
|
-
message: `trust divergence: agent claimed '${cmd}' passed; command contains exit-code-laundering operator (|| ... / ; true / ; exit 0 / etc.)`,
|
|
1137
|
-
});
|
|
1138
|
-
continue;
|
|
1139
|
-
}
|
|
1140
|
-
|
|
1141
|
-
// A test_output claim MUST name a manifest (required-lane) command. An agent
|
|
1142
|
-
// cannot self-label an arbitrary command test_output to dodge the manifest.
|
|
1143
|
-
const entry = manifestByCmd.get(normalCmd);
|
|
1144
|
-
if (!entry) {
|
|
1145
|
-
issues.push({
|
|
1146
|
-
type: 'not-run',
|
|
1147
|
-
cmd,
|
|
1148
|
-
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)`,
|
|
1149
|
-
});
|
|
1150
|
-
continue;
|
|
1151
|
-
}
|
|
1152
|
-
|
|
1153
1125
|
// Prefer the fresh-verify Step 1 result; otherwise re-run this (manifest, and
|
|
1154
1126
|
// therefore required-lane) command fresh now. Only manifest commands are ever
|
|
1155
1127
|
// run on demand, so the set is bounded by the registry.
|
|
@@ -1185,90 +1157,26 @@ function runTrustReconcile({ bundle = null, commands = [], repoRoot = null, mani
|
|
|
1185
1157
|
process.stdout.write(`[trust-reconcile] RECONCILED: '${cmd}' (manifest id: ${entry.id}) — claimed pass, CI fresh run = PASS\n`);
|
|
1186
1158
|
}
|
|
1187
1159
|
|
|
1188
|
-
// Session-local claims: not CI-reconcilable, but NOT a pass bypass.
|
|
1189
|
-
//
|
|
1190
|
-
//
|
|
1191
|
-
//
|
|
1192
|
-
//
|
|
1193
|
-
//
|
|
1194
|
-
//
|
|
1195
|
-
//
|
|
1196
|
-
//
|
|
1197
|
-
//
|
|
1198
|
-
//
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
// the underlying attestation is true. A fully fabricated bundle (fake claim + fake
|
|
1205
|
-
// evidence + fake event, all hand-authored to agree) is INDISTINGUISHABLE from a real
|
|
1206
|
-
// one at this layer. This was previously printed as a quiet `SESSION-LOCAL OK` line
|
|
1207
|
-
// indistinguishable from a genuinely reconciled check. It is now always printed as a
|
|
1208
|
-
// loud, distinct `ATTESTED (not independently verifiable at L0)` marker — see ADR 0020
|
|
1209
|
-
// Residuals. This does NOT change the exit code (attestations are not blocked at L0;
|
|
1210
|
-
// blocking every honest human-attestation use is not the fix) — it is a visibility-only
|
|
1211
|
-
// change so a reviewer/downstream tool can grep for and count exactly how many claims in
|
|
1212
|
-
// a passing bundle rest on bundle-internal consistency alone rather than independent
|
|
1213
|
-
// (CI fresh-run or cryptographically-signed) verification.
|
|
1214
|
-
let attestedCount = 0;
|
|
1215
|
-
for (const { claimId, claimType, assertedStatus, waiver, subject, evidenceType } of sessionLocal) {
|
|
1216
|
-
// finding 3: re-derive; never trust the asserted status.
|
|
1217
|
-
let status;
|
|
1218
|
-
if (!derivedStatus) {
|
|
1219
|
-
issues.push({
|
|
1220
|
-
type: 'status-underivable',
|
|
1221
|
-
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)`,
|
|
1222
|
-
});
|
|
1223
|
-
continue;
|
|
1224
|
-
}
|
|
1225
|
-
const derived = derivedStatus.get(claimId);
|
|
1226
|
-
if (derived === undefined || derived === null) {
|
|
1227
|
-
issues.push({
|
|
1228
|
-
type: 'status-underivable',
|
|
1229
|
-
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)`,
|
|
1230
|
-
});
|
|
1231
|
-
continue;
|
|
1232
|
-
}
|
|
1233
|
-
if (derived !== assertedStatus) {
|
|
1234
|
-
issues.push({
|
|
1235
|
-
type: 'status-misassertion',
|
|
1236
|
-
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`,
|
|
1237
|
-
});
|
|
1238
|
-
continue;
|
|
1239
|
-
}
|
|
1240
|
-
status = derived;
|
|
1241
|
-
|
|
1242
|
-
if (status === 'disputed' || status === 'rejected') {
|
|
1243
|
-
issues.push({
|
|
1244
|
-
type: 'session-local-failed',
|
|
1245
|
-
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)`,
|
|
1246
|
-
});
|
|
1247
|
-
continue;
|
|
1248
|
-
}
|
|
1249
|
-
// finding 2: a waiver is the ONLY way an `assumed` (or otherwise non-`verified`)
|
|
1250
|
-
// session-local claim passes, and it is printed loudly. `verified` still passes on its
|
|
1251
|
-
// own re-derived status.
|
|
1252
|
-
if (waiver && waiver.reason && waiver.approved_by) {
|
|
1160
|
+
// Session-local claims: not CI-reconcilable, but NOT a pass bypass. Shape-only,
|
|
1161
|
+
// delegated to scripts/lib/reconcile-shape.js (sessionLocalShapeIssues) — see that
|
|
1162
|
+
// module for the full WS8 finding-2/finding-3/iteration-4 rationale (waiver-only pass
|
|
1163
|
+
// for non-`verified` status, CI-re-derived status never self-reported, loud ATTESTED
|
|
1164
|
+
// marker for bundle-internal-only consistency). iteration-1 F1: CI MUST pass
|
|
1165
|
+
// `onUnderivable: 'fail'` explicitly — this is CI's trust anchor and must never silently
|
|
1166
|
+
// degrade to trusting a self-reported status when re-derivation is unavailable (that
|
|
1167
|
+
// reduced-coverage mode is opt-in only, for the LOCAL preflight — see reconcile-shape.js).
|
|
1168
|
+
// iteration-1 F3: the WAIVED/ATTESTED log lines below are driven by the shared function's
|
|
1169
|
+
// own `logEvents` classification (not a parallel re-derivation) so CI's stdout narrative
|
|
1170
|
+
// can never drift from what sessionLocalShapeIssues actually decided.
|
|
1171
|
+
const { issues: sessionLocalIssues, attestedCount, logEvents } =
|
|
1172
|
+
sessionLocalShapeIssues(sessionLocal, derivedStatus, { onUnderivable: 'fail' });
|
|
1173
|
+
issues.push(...sessionLocalIssues);
|
|
1174
|
+
for (const { kind, claimId, claimType, subject, evidenceType, status, waiver } of logEvents) {
|
|
1175
|
+
if (kind === 'waived') {
|
|
1253
1176
|
process.stdout.write(`[trust-reconcile] WAIVED: ${subject} (${claimType}) status=${status} — ${waiver.reason} (approved by ${waiver.approved_by})\n`);
|
|
1254
|
-
|
|
1255
|
-
}
|
|
1256
|
-
if (status === 'verified') {
|
|
1257
|
-
attestedCount++;
|
|
1177
|
+
} else if (kind === 'attested') {
|
|
1258
1178
|
process.stdout.write(`[trust-reconcile] ATTESTED (not independently verifiable at L0): '${claimId}' (${claimType}) evidenceType=${evidenceType} — accepted on bundle-internal consistency only; see ADR 0020 Residuals\n`);
|
|
1259
|
-
continue;
|
|
1260
1179
|
}
|
|
1261
|
-
if (status === 'assumed') {
|
|
1262
|
-
issues.push({
|
|
1263
|
-
type: 'unwaived-assumed',
|
|
1264
|
-
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`,
|
|
1265
|
-
});
|
|
1266
|
-
continue;
|
|
1267
|
-
}
|
|
1268
|
-
issues.push({
|
|
1269
|
-
type: 'unwaived-session-local',
|
|
1270
|
-
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`,
|
|
1271
|
-
});
|
|
1272
1180
|
}
|
|
1273
1181
|
|
|
1274
1182
|
// WS8 iteration-4: always emit the summary count, even when zero, so a passing bundle
|
|
@@ -1378,6 +1286,41 @@ function main() {
|
|
|
1378
1286
|
// The direct-CLI behavior is preserved: when run as a script, main() is called below.
|
|
1379
1287
|
module.exports.runTrustReconcile = runTrustReconcile;
|
|
1380
1288
|
|
|
1289
|
+
// #356: manifest-resolution helpers exported for reuse by the local reconcile-preflight
|
|
1290
|
+
// (Wave 2) so it resolves the manifest via the EXACT SAME priority chain this CI reconciler
|
|
1291
|
+
// uses (CLI --manifest > TRUST_RECONCILE_MANIFEST > package.json > run-baseline.sh
|
|
1292
|
+
// --manifest-json > legacy fresh-verify-commands fallback) — never a second implementation.
|
|
1293
|
+
// These stay defined here (not moved to scripts/lib/reconcile-shape.js) because they are
|
|
1294
|
+
// CI-adjacent resolution logic (they may spawn `evals/ci/run-baseline.sh --manifest-json`,
|
|
1295
|
+
// a cheap static-registry emit, not a fresh test run) rather than pure bundle-shape
|
|
1296
|
+
// classification.
|
|
1297
|
+
module.exports.resolveManifest = resolveManifest;
|
|
1298
|
+
module.exports.runBaselineManifest = runBaselineManifest;
|
|
1299
|
+
module.exports.normalizeManifestEntries = normalizeManifestEntries;
|
|
1300
|
+
module.exports.slugifyLabel = slugifyLabel;
|
|
1301
|
+
module.exports.normalizeCmd = normalizeCmd;
|
|
1302
|
+
module.exports.isAncestorCommit = isAncestorCommit;
|
|
1303
|
+
// #356: resolveManifest's legacy fallback tier (tier 5, "legacy:fresh-verify-commands")
|
|
1304
|
+
// folds the CANONICAL verify commands into the manifest when no dedicated manifest source
|
|
1305
|
+
// resolves. Exporting resolveCanonicalCommands too so the local preflight's manifest
|
|
1306
|
+
// resolution has genuine parity with CI on that fallback tier as well — otherwise a repo (or
|
|
1307
|
+
// a fixture/test repo) with no run-baseline.sh/package.json manifest source would silently
|
|
1308
|
+
// resolve an EMPTY legacy-fallback manifest locally while CI's own resolution (which always
|
|
1309
|
+
// threads its own resolved canonicalCommands into this same fallback) resolves a non-empty
|
|
1310
|
+
// one, a real parity gap, not merely a fixture quirk.
|
|
1311
|
+
module.exports.resolveCanonicalCommands = resolveCanonicalCommands;
|
|
1312
|
+
|
|
1313
|
+
// #356: re-export the shared bundle-shape classification/issue-construction functions
|
|
1314
|
+
// (primary home: scripts/lib/reconcile-shape.js) so a caller that already requires
|
|
1315
|
+
// trust-reconcile.js (e.g. an existing test harness) can reach them without also needing
|
|
1316
|
+
// to know the shared module's path. This is a re-export, not a second implementation —
|
|
1317
|
+
// the single definition lives in scripts/lib/reconcile-shape.js.
|
|
1318
|
+
module.exports.classifyBundleClaims = classifyBundleClaims;
|
|
1319
|
+
module.exports.waiverOnCommandIssues = waiverOnCommandIssues;
|
|
1320
|
+
module.exports.noEvidenceCommandIssues = noEvidenceCommandIssues;
|
|
1321
|
+
module.exports.reconcilableManifestIssues = reconcilableManifestIssues;
|
|
1322
|
+
module.exports.sessionLocalShapeIssues = sessionLocalShapeIssues;
|
|
1323
|
+
|
|
1381
1324
|
if (require.main === module) {
|
|
1382
1325
|
main();
|
|
1383
1326
|
}
|
|
@@ -180,7 +180,9 @@ function checkProtectedPathPattern(filePath) {
|
|
|
180
180
|
// copy a forged bundle here to corrupt the CI trust check.
|
|
181
181
|
// SAFE: publishDelivery writes via fs.copyFileSync (not Write/Edit tool).
|
|
182
182
|
// RESIDUAL: runtime-constructed paths and fs writes are unaffected.
|
|
183
|
-
|
|
183
|
+
// #379: the optional (?:[^/]+\/)? segment also covers the per-session path
|
|
184
|
+
// delivery/<slug>/trust.bundle — the forgery surface moved with the write path.
|
|
185
|
+
if (/(?:^|\/)delivery\/(?:[^/]+\/)?trust\.bundle$/.test(norm)) {
|
|
184
186
|
return {
|
|
185
187
|
name: "delivery/trust.bundle",
|
|
186
188
|
reason: "an agent could write a forged bundle to corrupt the CI trust-reconcile anchor",
|
|
@@ -189,7 +191,8 @@ function checkProtectedPathPattern(filePath) {
|
|
|
189
191
|
|
|
190
192
|
// delivery/trust.checkpoint.json -- the signed checkpoint companion.
|
|
191
193
|
// SAFE: publishDelivery writes via fs.copyFileSync, NOT via Write/Edit tool.
|
|
192
|
-
|
|
194
|
+
// #379: optional (?:[^/]+\/)? segment also covers delivery/<slug>/trust.checkpoint.json.
|
|
195
|
+
if (/(?:^|\/)delivery\/(?:[^/]+\/)?trust\.checkpoint\.json$/.test(norm)) {
|
|
193
196
|
return {
|
|
194
197
|
name: "delivery/trust.checkpoint.json",
|
|
195
198
|
reason: "an agent could forge a signed delivery by writing a tampered checkpoint",
|
|
@@ -421,7 +424,9 @@ function checkCommandForBypass(command) {
|
|
|
421
424
|
* .kontourai/flow-agents/<slug>/trust.bundle, and deprecated runtime-shaped
|
|
422
425
|
* .flow-agents equivalents.
|
|
423
426
|
*/
|
|
424
|
-
|
|
427
|
+
// #379: the delivery/ arms carry an optional (?:[^/]+\/)? segment so redirects/tee to the
|
|
428
|
+
// per-session path delivery/<slug>/trust.bundle (+ checkpoint) are caught, not just the flat path.
|
|
429
|
+
const REDIRECT_PROTECTED_RE = /(?:^|\/|~\/)(\.bash_profile|\.bashrc|\.profile|\.zprofile|\.zshrc)$|(?:^|\/)\.claude\/settings(?:\.local)?\.json$|(?:^|\/)(?:\.kontourai\/flow-agents|\.flow-agents)\/current\.json$|(?:^|\/)(?:\.kontourai\/flow-agents|\.flow-agents)\/current\/[^/]+\.json$|(?:^|\/)(?:\.kontourai\/flow-agents|\.flow-agents)\/\.goal-fit-block-streak\.json$|(?:^|\/)(?:\.kontourai\/flow-agents|\.flow-agents)\/[^/]+\/state\.json$|(?:^|\/)(?:\.kontourai\/flow-agents|\.flow-agents)\/[^/]+\/trust\.bundle$|(?:^|\/)delivery\/(?:[^/]+\/)?trust\.bundle$|(?:^|\/)delivery\/(?:[^/]+\/)?trust\.checkpoint\.json$/;
|
|
425
430
|
|
|
426
431
|
/**
|
|
427
432
|
* Return true when a token (an unquoted redirect target or tee argument) matches
|
|
@@ -569,8 +574,10 @@ function checkInterpreterWriteToProtected(command) {
|
|
|
569
574
|
* Delivery-protected path regex: delivery/trust.bundle and delivery/trust.checkpoint.json.
|
|
570
575
|
* These are the CI anchor files whose contents must not be agent-forged.
|
|
571
576
|
* Used by checkCopyMoveToProtected to catch `cp x delivery/trust.bundle`.
|
|
577
|
+
* #379: the optional (?:[^/]+\/)? segment also catches the per-session path
|
|
578
|
+
* `cp forged.json delivery/<slug>/trust.bundle`.
|
|
572
579
|
*/
|
|
573
|
-
const DELIVERY_COPY_PROTECTED_RE = /(?:^|\/)delivery\/trust\.bundle$|(?:^|\/)delivery\/trust\.checkpoint\.json$/;
|
|
580
|
+
const DELIVERY_COPY_PROTECTED_RE = /(?:^|\/)delivery\/(?:[^/]+\/)?trust\.bundle$|(?:^|\/)delivery\/(?:[^/]+\/)?trust\.checkpoint\.json$/;
|
|
574
581
|
|
|
575
582
|
/**
|
|
576
583
|
* Return true when a normalized token matches a delivery-protected path.
|