@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
|
@@ -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
|
|
@@ -1224,50 +1104,24 @@ function runTrustReconcile({ bundle = null, commands = [], repoRoot = null, mani
|
|
|
1224
1104
|
}
|
|
1225
1105
|
|
|
1226
1106
|
// finding 4 (server-side): a command-backed (test_output-evidence) claim carrying a
|
|
1227
|
-
// waiver is a divergence —
|
|
1228
|
-
//
|
|
1229
|
-
|
|
1230
|
-
issues.push({
|
|
1231
|
-
type: 'waiver-on-command-check',
|
|
1232
|
-
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`,
|
|
1233
|
-
});
|
|
1234
|
-
}
|
|
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));
|
|
1235
1110
|
|
|
1236
1111
|
// not-run divergences: never-captured command claims (no evidence) AND test_output
|
|
1237
|
-
// claims that did not reconcile (no manifest-matchable execution.label).
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
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) {
|
|
1247
1123
|
const normalCmd = normalizeCmd(cmd);
|
|
1248
1124
|
|
|
1249
|
-
// (a) Laundering operator check — must come first (most specific signal).
|
|
1250
|
-
if (hasLaunderingOperator(cmd)) {
|
|
1251
|
-
issues.push({
|
|
1252
|
-
type: 'laundering',
|
|
1253
|
-
cmd,
|
|
1254
|
-
message: `trust divergence: agent claimed '${cmd}' passed; command contains exit-code-laundering operator (|| ... / ; true / ; exit 0 / etc.)`,
|
|
1255
|
-
});
|
|
1256
|
-
continue;
|
|
1257
|
-
}
|
|
1258
|
-
|
|
1259
|
-
// A test_output claim MUST name a manifest (required-lane) command. An agent
|
|
1260
|
-
// cannot self-label an arbitrary command test_output to dodge the manifest.
|
|
1261
|
-
const entry = manifestByCmd.get(normalCmd);
|
|
1262
|
-
if (!entry) {
|
|
1263
|
-
issues.push({
|
|
1264
|
-
type: 'not-run',
|
|
1265
|
-
cmd,
|
|
1266
|
-
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)`,
|
|
1267
|
-
});
|
|
1268
|
-
continue;
|
|
1269
|
-
}
|
|
1270
|
-
|
|
1271
1125
|
// Prefer the fresh-verify Step 1 result; otherwise re-run this (manifest, and
|
|
1272
1126
|
// therefore required-lane) command fresh now. Only manifest commands are ever
|
|
1273
1127
|
// run on demand, so the set is bounded by the registry.
|
|
@@ -1303,90 +1157,26 @@ function runTrustReconcile({ bundle = null, commands = [], repoRoot = null, mani
|
|
|
1303
1157
|
process.stdout.write(`[trust-reconcile] RECONCILED: '${cmd}' (manifest id: ${entry.id}) — claimed pass, CI fresh run = PASS\n`);
|
|
1304
1158
|
}
|
|
1305
1159
|
|
|
1306
|
-
// Session-local claims: not CI-reconcilable, but NOT a pass bypass.
|
|
1307
|
-
//
|
|
1308
|
-
//
|
|
1309
|
-
//
|
|
1310
|
-
//
|
|
1311
|
-
//
|
|
1312
|
-
//
|
|
1313
|
-
//
|
|
1314
|
-
//
|
|
1315
|
-
//
|
|
1316
|
-
//
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
// the underlying attestation is true. A fully fabricated bundle (fake claim + fake
|
|
1323
|
-
// evidence + fake event, all hand-authored to agree) is INDISTINGUISHABLE from a real
|
|
1324
|
-
// one at this layer. This was previously printed as a quiet `SESSION-LOCAL OK` line
|
|
1325
|
-
// indistinguishable from a genuinely reconciled check. It is now always printed as a
|
|
1326
|
-
// loud, distinct `ATTESTED (not independently verifiable at L0)` marker — see ADR 0020
|
|
1327
|
-
// Residuals. This does NOT change the exit code (attestations are not blocked at L0;
|
|
1328
|
-
// blocking every honest human-attestation use is not the fix) — it is a visibility-only
|
|
1329
|
-
// change so a reviewer/downstream tool can grep for and count exactly how many claims in
|
|
1330
|
-
// a passing bundle rest on bundle-internal consistency alone rather than independent
|
|
1331
|
-
// (CI fresh-run or cryptographically-signed) verification.
|
|
1332
|
-
let attestedCount = 0;
|
|
1333
|
-
for (const { claimId, claimType, assertedStatus, waiver, subject, evidenceType } of sessionLocal) {
|
|
1334
|
-
// finding 3: re-derive; never trust the asserted status.
|
|
1335
|
-
let status;
|
|
1336
|
-
if (!derivedStatus) {
|
|
1337
|
-
issues.push({
|
|
1338
|
-
type: 'status-underivable',
|
|
1339
|
-
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)`,
|
|
1340
|
-
});
|
|
1341
|
-
continue;
|
|
1342
|
-
}
|
|
1343
|
-
const derived = derivedStatus.get(claimId);
|
|
1344
|
-
if (derived === undefined || derived === null) {
|
|
1345
|
-
issues.push({
|
|
1346
|
-
type: 'status-underivable',
|
|
1347
|
-
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)`,
|
|
1348
|
-
});
|
|
1349
|
-
continue;
|
|
1350
|
-
}
|
|
1351
|
-
if (derived !== assertedStatus) {
|
|
1352
|
-
issues.push({
|
|
1353
|
-
type: 'status-misassertion',
|
|
1354
|
-
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`,
|
|
1355
|
-
});
|
|
1356
|
-
continue;
|
|
1357
|
-
}
|
|
1358
|
-
status = derived;
|
|
1359
|
-
|
|
1360
|
-
if (status === 'disputed' || status === 'rejected') {
|
|
1361
|
-
issues.push({
|
|
1362
|
-
type: 'session-local-failed',
|
|
1363
|
-
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)`,
|
|
1364
|
-
});
|
|
1365
|
-
continue;
|
|
1366
|
-
}
|
|
1367
|
-
// finding 2: a waiver is the ONLY way an `assumed` (or otherwise non-`verified`)
|
|
1368
|
-
// session-local claim passes, and it is printed loudly. `verified` still passes on its
|
|
1369
|
-
// own re-derived status.
|
|
1370
|
-
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') {
|
|
1371
1176
|
process.stdout.write(`[trust-reconcile] WAIVED: ${subject} (${claimType}) status=${status} — ${waiver.reason} (approved by ${waiver.approved_by})\n`);
|
|
1372
|
-
|
|
1373
|
-
}
|
|
1374
|
-
if (status === 'verified') {
|
|
1375
|
-
attestedCount++;
|
|
1177
|
+
} else if (kind === 'attested') {
|
|
1376
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`);
|
|
1377
|
-
continue;
|
|
1378
1179
|
}
|
|
1379
|
-
if (status === 'assumed') {
|
|
1380
|
-
issues.push({
|
|
1381
|
-
type: 'unwaived-assumed',
|
|
1382
|
-
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`,
|
|
1383
|
-
});
|
|
1384
|
-
continue;
|
|
1385
|
-
}
|
|
1386
|
-
issues.push({
|
|
1387
|
-
type: 'unwaived-session-local',
|
|
1388
|
-
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`,
|
|
1389
|
-
});
|
|
1390
1180
|
}
|
|
1391
1181
|
|
|
1392
1182
|
// WS8 iteration-4: always emit the summary count, even when zero, so a passing bundle
|
|
@@ -1496,6 +1286,41 @@ function main() {
|
|
|
1496
1286
|
// The direct-CLI behavior is preserved: when run as a script, main() is called below.
|
|
1497
1287
|
module.exports.runTrustReconcile = runTrustReconcile;
|
|
1498
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
|
+
|
|
1499
1324
|
if (require.main === module) {
|
|
1500
1325
|
main();
|
|
1501
1326
|
}
|
|
@@ -229,6 +229,75 @@ function sanitizeSegment(value) {
|
|
|
229
229
|
return cleaned || 'unknown';
|
|
230
230
|
}
|
|
231
231
|
|
|
232
|
+
/**
|
|
233
|
+
* #398: detect a CI-triggered session and derive a STABLE actor identity from the CI provider's
|
|
234
|
+
* published identifiers. The right granularity is the JOB/RUN (a CI job ≈ one agent session), NOT
|
|
235
|
+
* the runner machine (hostname can be shared/reused across jobs). Detection is order-independent
|
|
236
|
+
* (each provider gates on its own canonical marker env var). Returns `{ runtime, session_id }` on a
|
|
237
|
+
* recognized provider WITH a non-blank stable id, else `null` (caller falls through to
|
|
238
|
+
* process-ancestry, where #293's advisory verify-hold net still protects).
|
|
239
|
+
*
|
|
240
|
+
* Deliberately conservative: a generic/unrecognized `CI=true` returns null rather than fabricating
|
|
241
|
+
* a "stable" id from something that might shift between subprocesses — a wrong stable classification
|
|
242
|
+
* would let the #293 hard gate ENFORCE against a shifting identity, which is worse than advisory.
|
|
243
|
+
* All segments are sanitized by serializeActor at the call site (allowed charset, length-capped),
|
|
244
|
+
* so a hostile env var value cannot inject.
|
|
245
|
+
*
|
|
246
|
+
* ACCEPTED GRANULARITY GAP (matrix / parallelism): the id is job/run-granular. GitLab (`CI_JOB_ID`),
|
|
247
|
+
* Azure (`SYSTEM_JOBID`), and Buildkite (`BUILDKITE_JOB_ID`) expose a per-job-INSTANCE id that is
|
|
248
|
+
* already unique across matrix/parallel legs. CircleCI parallelism is disambiguated below via
|
|
249
|
+
* `CIRCLE_NODE_INDEX`. But GitHub Actions and Jenkins declarative-`matrix{}` cells share
|
|
250
|
+
* `GITHUB_JOB` / `BUILD_TAG` across all legs of one run+attempt — matrix values live only in the
|
|
251
|
+
* `${{ matrix }}` / axis context, not in any env var this can read — so two concurrent legs collapse
|
|
252
|
+
* to the SAME CI actor. This degrades SAFELY: worst case is idempotent self-recognition (one leg
|
|
253
|
+
* treats another's claim as its own); it never false-blocks and never injects. The coordination-
|
|
254
|
+
* relevant CI jobs (trust-reconcile / publish) run as single, non-matrix jobs today. Filed as a
|
|
255
|
+
* fast-follow to add a GitHub-matrix disambiguator if a coordination path ever runs under a matrix.
|
|
256
|
+
*
|
|
257
|
+
* @param {NodeJS.ProcessEnv} [env]
|
|
258
|
+
* @returns {{runtime: string, session_id: string} | null}
|
|
259
|
+
*/
|
|
260
|
+
function detectCiActor(env = process.env) {
|
|
261
|
+
env = env || {};
|
|
262
|
+
const s = (v) => String(v == null ? '' : v).trim();
|
|
263
|
+
const compose = (...parts) => parts.map(s).filter(Boolean).join('-');
|
|
264
|
+
|
|
265
|
+
// GitHub Actions — run id + attempt + job is unique and stable across the whole job.
|
|
266
|
+
if (s(env.GITHUB_ACTIONS) === 'true') {
|
|
267
|
+
const id = compose(env.GITHUB_RUN_ID, env.GITHUB_RUN_ATTEMPT, env.GITHUB_JOB);
|
|
268
|
+
return id ? { runtime: 'github-actions', session_id: id } : null;
|
|
269
|
+
}
|
|
270
|
+
// GitLab CI — CI_JOB_ID is stable per job.
|
|
271
|
+
if (s(env.GITLAB_CI) === 'true') {
|
|
272
|
+
const id = s(env.CI_JOB_ID);
|
|
273
|
+
return id ? { runtime: 'gitlab-ci', session_id: id } : null;
|
|
274
|
+
}
|
|
275
|
+
// CircleCI — workflow id + job name + node index (CIRCLE_NODE_INDEX disambiguates the containers
|
|
276
|
+
// of a `parallelism: N` job; "0" for a single-container job, so it is always present and stable).
|
|
277
|
+
if (s(env.CIRCLECI) === 'true') {
|
|
278
|
+
const id = compose(env.CIRCLE_WORKFLOW_ID, env.CIRCLE_JOB, env.CIRCLE_NODE_INDEX) || s(env.CIRCLE_BUILD_NUM);
|
|
279
|
+
return id ? { runtime: 'circleci', session_id: id } : null;
|
|
280
|
+
}
|
|
281
|
+
// Jenkins — BUILD_TAG is the stable per-build identifier; fall back to job + build id.
|
|
282
|
+
if (s(env.JENKINS_URL)) {
|
|
283
|
+
const id = s(env.BUILD_TAG) || compose(env.JOB_NAME, env.BUILD_ID);
|
|
284
|
+
return id ? { runtime: 'jenkins', session_id: id } : null;
|
|
285
|
+
}
|
|
286
|
+
// Azure Pipelines — build id + system job id (SYSTEM_JOBID is a per-job-instance GUID, unique
|
|
287
|
+
// across matrix/strategy legs).
|
|
288
|
+
if (s(env.TF_BUILD) === 'true') {
|
|
289
|
+
const id = compose(env.BUILD_BUILDID, env.SYSTEM_JOBID);
|
|
290
|
+
return id ? { runtime: 'azure-pipelines', session_id: id } : null;
|
|
291
|
+
}
|
|
292
|
+
// Buildkite — job id is a stable UUID per job.
|
|
293
|
+
if (s(env.BUILDKITE) === 'true') {
|
|
294
|
+
const id = s(env.BUILDKITE_JOB_ID);
|
|
295
|
+
return id ? { runtime: 'buildkite', session_id: id } : null;
|
|
296
|
+
}
|
|
297
|
+
// Generic/unrecognized CI: do NOT fabricate stability — fall through to process-ancestry.
|
|
298
|
+
return null;
|
|
299
|
+
}
|
|
300
|
+
|
|
232
301
|
/**
|
|
233
302
|
* Serialize a runtime-agnostic actor struct into a single string safe for
|
|
234
303
|
* the existing `${subjectId}::${actor}` grouping key: each field is passed
|
|
@@ -334,6 +403,18 @@ function resolveActor(env = process.env) {
|
|
|
334
403
|
return { actor, source: `runtime-session-id:${runtime}` };
|
|
335
404
|
}
|
|
336
405
|
|
|
406
|
+
// #398: CI-runtime tier — sits ABOVE process-ancestry (stable across every subprocess in a CI
|
|
407
|
+
// job) and BELOW an explicit override / native runtime session id (those are more specific and
|
|
408
|
+
// already returned above). A CI-triggered agent session otherwise falls to process-ancestry,
|
|
409
|
+
// whose seed differs across subprocesses within one job (a subject claimed in `ensure-session`
|
|
410
|
+
// isn't recognized as self at `publish`) — the exact instability #293 had to degrade the
|
|
411
|
+
// verify-hold gate to advisory for. A stable CI identity lets that gate ENFORCE instead.
|
|
412
|
+
const ci = detectCiActor(env);
|
|
413
|
+
if (ci && ci.session_id) {
|
|
414
|
+
const actor = serializeActor({ runtime: ci.runtime, session_id: ci.session_id, host: os.hostname() });
|
|
415
|
+
return { actor, source: `ci-runtime:${ci.runtime}` };
|
|
416
|
+
}
|
|
417
|
+
|
|
337
418
|
const seed = ancestorActorSeed();
|
|
338
419
|
if (seed) {
|
|
339
420
|
const actor = serializeActor({ runtime, session_id: `anc-${seed}`, host: os.hostname() });
|
|
@@ -360,6 +441,7 @@ function isUnresolvedActor(actor) {
|
|
|
360
441
|
module.exports = {
|
|
361
442
|
detectRuntime,
|
|
362
443
|
runtimeSessionId,
|
|
444
|
+
detectCiActor,
|
|
363
445
|
ancestorActorSeed,
|
|
364
446
|
sanitizeSegment,
|
|
365
447
|
serializeActor,
|
|
@@ -159,8 +159,25 @@ function latestWorkflowState(root) {
|
|
|
159
159
|
return states[0] || null;
|
|
160
160
|
}
|
|
161
161
|
|
|
162
|
+
// #293 AC7 (injection discipline): strips C0 (0x00-0x1F), DEL (0x7F), and C1 (0x80-0x9F,
|
|
163
|
+
// which includes ANSI-CSI-adjacent bytes) BEFORE whitespace-collapse/truncation — mirroring
|
|
164
|
+
// src/cli/workflow-sidecar.ts's stripControlCharsForDisplay / assignment-provider.ts's
|
|
165
|
+
// sanitizeDisplayField exactly (same charset, same rationale: holder/actor/assignee strings
|
|
166
|
+
// sourced from the shared multi-writer liveness stream or an attacker-postable GitHub comment
|
|
167
|
+
// are untrusted display input). Previously this function only collapsed whitespace, so a raw
|
|
168
|
+
// ESC/BEL byte embedded in a hostile actor string passed through unstripped into every steering
|
|
169
|
+
// notice that calls safeStateText (resumeSteering's LIVENESS WARNING block, supersessionSteering's
|
|
170
|
+
// SUPERSEDED notice) — fixed here, in the one shared helper, rather than in each call site.
|
|
162
171
|
function safeStateText(value, maxLength = 240) {
|
|
163
|
-
|
|
172
|
+
// Whitespace-collapse FIRST (so an embedded newline/tab becomes a joining space, preserving
|
|
173
|
+
// the existing multiline-summary neutralization behavior), THEN strip control/ANSI bytes
|
|
174
|
+
// (#293 AC7) — reversing this order would strip \n before the collapse ever sees it, losing
|
|
175
|
+
// the join-space between sentences. Only C0/DEL/C1 bytes THAT SURVIVE the collapse (ESC, BEL,
|
|
176
|
+
// and other non-whitespace control bytes — real \s already became a plain space above) are
|
|
177
|
+
// stripped, mirroring src/cli/workflow-sidecar.ts's stripControlCharsForDisplay /
|
|
178
|
+
// assignment-provider.ts's sanitizeDisplayField charset exactly.
|
|
179
|
+
const collapsed = String(value || '').replace(/\s+/g, ' ').trim();
|
|
180
|
+
const text = collapsed.replace(/[\u0000-\u001F\u007F-\u009F]/g, '');
|
|
164
181
|
if (text.length <= maxLength) return text;
|
|
165
182
|
return `${text.slice(0, maxLength - 3)}...`;
|
|
166
183
|
}
|
|
@@ -411,6 +428,51 @@ function resumeSteering(root, current) {
|
|
|
411
428
|
}
|
|
412
429
|
}
|
|
413
430
|
|
|
431
|
+
/**
|
|
432
|
+
* Compose the every-turn SUPERSEDED steering notice (issue #293).
|
|
433
|
+
*
|
|
434
|
+
* Unlike resumeSteering()'s RESUME block (SessionStart only), this notice must
|
|
435
|
+
* surface on EVERY turn (UserPromptSubmit as well as SessionStart) once another
|
|
436
|
+
* actor holds a fresh liveness claim on the caller's own subject — the
|
|
437
|
+
* "don't publish, you may be stale" signal the verify-hold gate backstops at
|
|
438
|
+
* publish time. Reuses the SAME liveness-only join resumeSteering()'s liveness
|
|
439
|
+
* advisory block already performs (freshHolders(events, slug, selfActor, now)),
|
|
440
|
+
* not a second computation.
|
|
441
|
+
*
|
|
442
|
+
* freshHolders() already excludes selfActor internally (see
|
|
443
|
+
* scripts/hooks/lib/liveness-read.js), so any holder returned here is by
|
|
444
|
+
* definition another actor — no additional self-filter is required.
|
|
445
|
+
*
|
|
446
|
+
* Fully fail-open: any error anywhere in this function returns '' rather than
|
|
447
|
+
* throwing, matching every other steering helper in this file.
|
|
448
|
+
*
|
|
449
|
+
* @param {string} root Repository root
|
|
450
|
+
* @param {{ file: string, payload: object }} current Latest active state entry (this actor's own)
|
|
451
|
+
* @returns {string}
|
|
452
|
+
*/
|
|
453
|
+
function supersessionSteering(root, current) {
|
|
454
|
+
try {
|
|
455
|
+
const state = current.payload;
|
|
456
|
+
const workflowDir = path.dirname(current.file);
|
|
457
|
+
const slug = state.task_slug || path.basename(workflowDir);
|
|
458
|
+
|
|
459
|
+
const resolved = resolveActor(process.env);
|
|
460
|
+
const selfActor = resolved.actor || 'local';
|
|
461
|
+
|
|
462
|
+
const events = flowAgentsArtifactRootsForRead(root)
|
|
463
|
+
.flatMap(artifactRoot => readLivenessEvents(path.join(artifactRoot, 'liveness', 'events.jsonl')));
|
|
464
|
+
if (events.length === 0) return '';
|
|
465
|
+
|
|
466
|
+
const holders = freshHolders(events, slug, selfActor, Date.now());
|
|
467
|
+
if (holders.length === 0) return '';
|
|
468
|
+
|
|
469
|
+
const holder = holders[0];
|
|
470
|
+
return `[SUPERSEDED: another agent appears live on this work: actor ${safeStateText(holder.actor)}, last seen ${holder.lastAt}. Your claim on this subject may be stale — do not publish until you re-verify hold (see verify-hold gate) or hand off.]`;
|
|
471
|
+
} catch {
|
|
472
|
+
return '';
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
414
476
|
function run(rawInput) {
|
|
415
477
|
try {
|
|
416
478
|
const input = JSON.parse(rawInput);
|
|
@@ -451,10 +513,19 @@ function run(rawInput) {
|
|
|
451
513
|
}
|
|
452
514
|
}
|
|
453
515
|
|
|
516
|
+
// Every-turn supersession notice (#293): fires on UserPromptSubmit (every turn),
|
|
517
|
+
// not just SessionStart, so a takeover mid-session keeps surfacing the warning.
|
|
518
|
+
if (event === 'UserPromptSubmit' && current) {
|
|
519
|
+
const supersessionHint = supersessionSteering(root, current);
|
|
520
|
+
if (supersessionHint) hints.push(supersessionHint);
|
|
521
|
+
}
|
|
522
|
+
|
|
454
523
|
// SessionStart only: append the RESUME block for richer situational awareness
|
|
455
524
|
if (event === 'SessionStart' && current) {
|
|
456
525
|
const resumeBlock = resumeSteering(root, current);
|
|
457
526
|
if (resumeBlock) hints.push(resumeBlock);
|
|
527
|
+
const supersessionHint = supersessionSteering(root, current);
|
|
528
|
+
if (supersessionHint) hints.push(supersessionHint);
|
|
458
529
|
}
|
|
459
530
|
|
|
460
531
|
if (shouldAppendWorkflowContext) {
|
|
@@ -492,6 +563,7 @@ module.exports = {
|
|
|
492
563
|
safeStateText,
|
|
493
564
|
stateNeedsAmbientSteering,
|
|
494
565
|
resumeSteering,
|
|
566
|
+
supersessionSteering,
|
|
495
567
|
promptText,
|
|
496
568
|
looksLikeBuilderWork,
|
|
497
569
|
builderWorkflowSteering,
|