@kontourai/flow-agents 2.1.0 → 2.2.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/CODEOWNERS +2 -0
- package/.github/workflows/ci.yml +4 -4
- package/.github/workflows/docs-pages.yml +1 -1
- package/.github/workflows/kit-gates-demo.yml +2 -2
- package/.github/workflows/publish-npm.yml +2 -2
- package/.github/workflows/runtime-compat.yml +2 -2
- package/.github/workflows/trust-reconcile.yml +1 -1
- package/CHANGELOG.md +29 -0
- package/build/src/cli/sidecar-claim-explain.d.ts +45 -0
- package/build/src/cli/sidecar-claim-explain.js +87 -0
- package/build/src/cli/telemetry-doctor.js +2 -4
- package/build/src/cli/usage-feedback.js +16 -8
- package/build/src/cli/workflow-artifact-cleanup-audit.js +4 -2
- package/build/src/cli/workflow-sidecar.d.ts +2 -44
- package/build/src/cli/workflow-sidecar.js +18 -87
- package/build/src/index.d.ts +1 -0
- package/build/src/index.js +1 -0
- package/build/src/lib/local-artifact-root.d.ts +11 -0
- package/build/src/lib/local-artifact-root.js +30 -0
- package/build/src/tools/validate-source-tree.js +1 -0
- package/context/scripts/telemetry/lib/config.sh +3 -4
- package/docs/adr/0018-freeze-local-shell-heuristics.md +95 -0
- package/docs/repository-structure.md +7 -3
- package/evals/integration/test_bundle_lifecycle.sh +9 -9
- package/evals/integration/test_gate_lockdown.sh +25 -6
- package/evals/integration/test_migrate_local_artifacts.sh +102 -0
- package/evals/integration/test_workflow_sidecar_writer.sh +34 -0
- package/evals/run.sh +2 -0
- package/evals/static/test_unit_helpers.sh +26 -0
- package/integrations/strands-ts/package.json +3 -0
- package/integrations/strands-ts/src/telemetry.ts +31 -50
- package/kits/knowledge/adapters/flow-runner/index.js +1 -1
- package/kits/knowledge/adapters/flow-runner/telemetry.js +2 -2
- package/package.json +2 -1
- package/scripts/README.md +1 -0
- package/scripts/ci/trust-reconcile.js +7 -23
- package/scripts/hooks/config-protection.js +6 -0
- package/scripts/hooks/evidence-capture.js +26 -47
- package/scripts/hooks/lib/local-artifact-paths.js +32 -0
- package/scripts/hooks/stop-goal-fit.js +37 -58
- package/scripts/hooks/workflow-steering.js +5 -3
- package/scripts/lib/command-log-chain.js +78 -0
- package/scripts/migrate-local-artifacts.mjs +230 -0
- package/scripts/repair-command-log.js +8 -15
- package/scripts/telemetry/lib/config.sh +3 -4
- package/src/cli/public-api.test.mjs +21 -0
- package/src/cli/sidecar-claim-explain.ts +130 -0
- package/src/cli/sidecar-pure-helpers.test.mjs +168 -0
- package/src/cli/telemetry-doctor.ts +2 -4
- package/src/cli/usage-feedback.ts +15 -8
- package/src/cli/workflow-artifact-cleanup-audit.ts +4 -2
- package/src/cli/workflow-sidecar.ts +17 -131
- package/src/index.ts +14 -0
- package/src/lib/local-artifact-root.ts +39 -0
- package/src/tools/validate-source-tree.ts +1 -0
|
@@ -14,6 +14,11 @@ import fs from "node:fs";
|
|
|
14
14
|
import path from "node:path";
|
|
15
15
|
import { fileURLToPath } from "node:url";
|
|
16
16
|
import { randomUUID } from "node:crypto";
|
|
17
|
+
import {
|
|
18
|
+
costForModel as pkgCostForModel,
|
|
19
|
+
currentPricingVersion as pkgPricingVersion,
|
|
20
|
+
setRegistry as setPkgRegistry
|
|
21
|
+
} from "@kontourai/console-telemetry";
|
|
17
22
|
|
|
18
23
|
// ESM has no __dirname; derive it (this package is "type":"module").
|
|
19
24
|
const __filename = fileURLToPath(import.meta.url);
|
|
@@ -308,7 +313,12 @@ export class TelemetrySink {
|
|
|
308
313
|
}
|
|
309
314
|
|
|
310
315
|
// ---------------------------------------------------------------------------
|
|
311
|
-
// Usage / cost —
|
|
316
|
+
// Usage / cost — pricing math + registry shape come from the shared contract
|
|
317
|
+
// package @kontourai/console-telemetry (single source). This sink only loads
|
|
318
|
+
// the flow-agents authored registry (scripts/telemetry/pricing.json) and feeds
|
|
319
|
+
// it to the package so cost prices against pricing.json rather than the
|
|
320
|
+
// package's bundled fallback. Tokens are exact regardless; the console
|
|
321
|
+
// recomputes cost authoritatively.
|
|
312
322
|
// ---------------------------------------------------------------------------
|
|
313
323
|
|
|
314
324
|
export interface TokenCounts {
|
|
@@ -331,37 +341,15 @@ interface NormalizedTokens {
|
|
|
331
341
|
cacheRead: number;
|
|
332
342
|
}
|
|
333
343
|
|
|
334
|
-
//
|
|
335
|
-
//
|
|
336
|
-
//
|
|
337
|
-
//
|
|
338
|
-
//
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
zero_cost_models: string[];
|
|
344
|
-
}
|
|
345
|
-
interface PricingRegistry {
|
|
346
|
-
current_version: string;
|
|
347
|
-
versions: Record<string, PricingVersionBlock>;
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
const FALLBACK_REGISTRY: PricingRegistry = {
|
|
351
|
-
current_version: "fallback",
|
|
352
|
-
versions: {
|
|
353
|
-
fallback: {
|
|
354
|
-
cache_multipliers: { write_5m: 1.25, write_1h: 2.0, read: 0.1 },
|
|
355
|
-
models: {},
|
|
356
|
-
default: { input: 5.0, output: 25.0 },
|
|
357
|
-
zero_cost_models: ["<synthetic>", "synthetic", "unknown", ""]
|
|
358
|
-
}
|
|
359
|
-
}
|
|
360
|
-
};
|
|
361
|
-
|
|
362
|
-
let cachedRegistry: PricingRegistry | null = null;
|
|
363
|
-
function loadRegistry(): PricingRegistry {
|
|
364
|
-
if (cachedRegistry) return cachedRegistry;
|
|
344
|
+
// Feed the package the flow-agents authored registry when present (its single
|
|
345
|
+
// source of truth). Resolution: TELEMETRY_PRICING_FILE / FLOW_AGENTS_PRICING_FILE
|
|
346
|
+
// env path, else the repo-relative registry. Runs once; on failure the package
|
|
347
|
+
// keeps its bundled fallback. The console recomputes cost authoritatively, so a
|
|
348
|
+
// missing file only degrades the sink's stamped estimate.
|
|
349
|
+
let registryLoaded = false;
|
|
350
|
+
function ensureRegistry(): void {
|
|
351
|
+
if (registryLoaded) return;
|
|
352
|
+
registryLoaded = true;
|
|
365
353
|
const candidates = [
|
|
366
354
|
process.env.TELEMETRY_PRICING_FILE,
|
|
367
355
|
process.env.FLOW_AGENTS_PRICING_FILE,
|
|
@@ -372,19 +360,18 @@ function loadRegistry(): PricingRegistry {
|
|
|
372
360
|
try {
|
|
373
361
|
const parsed = JSON.parse(fs.readFileSync(candidate, "utf8"));
|
|
374
362
|
if (parsed && typeof parsed.current_version === "string" && parsed.versions) {
|
|
375
|
-
|
|
376
|
-
return
|
|
363
|
+
setPkgRegistry(parsed);
|
|
364
|
+
return;
|
|
377
365
|
}
|
|
378
366
|
} catch {
|
|
379
367
|
// try next candidate
|
|
380
368
|
}
|
|
381
369
|
}
|
|
382
|
-
cachedRegistry = FALLBACK_REGISTRY;
|
|
383
|
-
return cachedRegistry;
|
|
384
370
|
}
|
|
385
371
|
|
|
386
372
|
function pricingVersion(): string {
|
|
387
|
-
|
|
373
|
+
ensureRegistry();
|
|
374
|
+
return pkgPricingVersion();
|
|
388
375
|
}
|
|
389
376
|
|
|
390
377
|
function num(value: number | undefined): number {
|
|
@@ -405,17 +392,11 @@ function normalizeTokens(tokens: TokenCounts): NormalizedTokens {
|
|
|
405
392
|
}
|
|
406
393
|
|
|
407
394
|
function costForModel(model: string | undefined, tokens: NormalizedTokens): number {
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
(tokens.input * rate.input +
|
|
416
|
-
tokens.output * rate.output +
|
|
417
|
-
tokens.cacheCreation * rate.input * cm.write_5m +
|
|
418
|
-
tokens.cacheRead * rate.input * cm.read) /
|
|
419
|
-
1_000_000
|
|
420
|
-
);
|
|
395
|
+
ensureRegistry();
|
|
396
|
+
return pkgCostForModel(model, {
|
|
397
|
+
inputTokens: tokens.input,
|
|
398
|
+
outputTokens: tokens.output,
|
|
399
|
+
cacheCreationInputTokens: tokens.cacheCreation,
|
|
400
|
+
cacheReadInputTokens: tokens.cacheRead
|
|
401
|
+
});
|
|
421
402
|
}
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
* - defaultSimilarityDetector — pluggable similarity interface default (R3)
|
|
18
18
|
*
|
|
19
19
|
* Telemetry:
|
|
20
|
-
* Gate events are emitted to <workspace>/.telemetry/full.jsonl using
|
|
20
|
+
* Gate events are emitted to <workspace>/.kontourai/telemetry/full.jsonl using
|
|
21
21
|
* canonical schema v0.3.0 events (preToolUse at gate entry, postToolUse at gate exit).
|
|
22
22
|
*
|
|
23
23
|
* Similarity Interface (R3):
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* Zero runtime dependencies beyond Node.js built-ins.
|
|
9
9
|
* Fails open: telemetry errors never block kit operations.
|
|
10
10
|
*
|
|
11
|
-
* Sink path: <workspace>/.telemetry/full.jsonl
|
|
11
|
+
* Sink path: <workspace>/.kontourai/telemetry/full.jsonl
|
|
12
12
|
* The workspace is resolved from FLOW_AGENTS_WORKSPACE env var, falling back
|
|
13
13
|
* to process.cwd().
|
|
14
14
|
*
|
|
@@ -47,7 +47,7 @@ function schemaEventType(canonical) {
|
|
|
47
47
|
|
|
48
48
|
function resolveSinkPath(workspace) {
|
|
49
49
|
const ws = workspace || process.env.FLOW_AGENTS_WORKSPACE || process.cwd();
|
|
50
|
-
return path.join(ws, ".telemetry", "full.jsonl");
|
|
50
|
+
return path.join(ws, ".kontourai", "telemetry", "full.jsonl");
|
|
51
51
|
}
|
|
52
52
|
|
|
53
53
|
// ---------------------------------------------------------------------------
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kontourai/flow-agents",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.2.0",
|
|
4
4
|
"description": "Flow Agents — a Kontour product that applies Flow and Veritas discipline as a portable process layer inside the agent tools you already use: Claude Code, Codex, Kiro, opencode, pi, and GitHub Actions — with framework adapters (AWS Strands preview) on the same policy-engine contract.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"agents",
|
|
@@ -99,6 +99,7 @@
|
|
|
99
99
|
"scripts": {
|
|
100
100
|
"build": "tsc -p tsconfig.json",
|
|
101
101
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
102
|
+
"test:unit": "npm run build --silent && node --test src/cli/*.test.mjs",
|
|
102
103
|
"validate:source": "npm run build --silent && node build/src/cli.js validate-source",
|
|
103
104
|
"validate:artifacts": "npm run build --silent && node build/src/cli/validate-workflow-artifacts.js",
|
|
104
105
|
"context-map": "npm run build --silent && node build/src/cli.js context-map",
|
package/scripts/README.md
CHANGED
|
@@ -70,6 +70,7 @@ renamed, or changes category, update the table and the validator together.
|
|
|
70
70
|
| `lib/audit-transport.sh` | shared hook library | `evals/integration/test_hook_category_behaviors.sh`, `evals/integration/test_telemetry.sh` | Shared audit event transport functions. |
|
|
71
71
|
| `lib/hook-flags.js` | shared hook library | `evals/integration/test_hook_category_behaviors.sh` | Shared profile/disable flag parsing. |
|
|
72
72
|
| `lib/liveness-read.js` | shared hook library | `evals/integration/test_session_resume_roundtrip.sh` | Shared liveness event reader + freshness check (`readLivenessEvents`, `freshHolders`); consumed by the reground hook and `workflow-sidecar liveness status`. |
|
|
73
|
+
| `lib/local-artifact-paths.js` | shared hook library | `evals/integration/test_migrate_local_artifacts.sh`, `evals/integration/test_workflow_sidecar_writer.sh` | Shared `.kontourai/flow-agents` artifact-root helpers with legacy `.flow-agents` read compatibility for CJS hooks. |
|
|
73
74
|
| `lib/patterns.sh` | shared hook library | `evals/integration/test_hook_category_behaviors.sh`, `evals/integration/test_telemetry.sh` | Shared shell pattern constants. |
|
|
74
75
|
| `lib/resolve-formatter.js` | shared hook library | `evals/integration/test_hook_category_behaviors.sh` | Shared formatter resolution helper. |
|
|
75
76
|
|
|
@@ -61,6 +61,10 @@ const { spawnSync } = require('child_process');
|
|
|
61
61
|
const fs = require('fs');
|
|
62
62
|
const os = require('os');
|
|
63
63
|
const path = require('path');
|
|
64
|
+
// One normative definition shared with scripts/hooks/stop-goal-fit.js — the local
|
|
65
|
+
// copy here had drifted (it was missing the trailing `/bin/true` check), which is
|
|
66
|
+
// exactly why this is now imported rather than duplicated.
|
|
67
|
+
const { hasLaunderingOperator } = require('../lib/command-log-chain.js');
|
|
64
68
|
|
|
65
69
|
// ---------------------------------------------------------------------------
|
|
66
70
|
// Helpers
|
|
@@ -80,29 +84,9 @@ function isPassingValue(v) {
|
|
|
80
84
|
return v === true || v === 1 || v === 'true' || v === 'pass';
|
|
81
85
|
}
|
|
82
86
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
*
|
|
87
|
-
* Rules (applied to claimed verification commands only):
|
|
88
|
-
* - ANY || operator — verify commands must not contain ||. This catches:
|
|
89
|
-
* || exit 0, || echo ok, || /bin/true, || true, || :, etc.
|
|
90
|
-
* - ; or newline followed by true / : / exit 0 — trailing success injection
|
|
91
|
-
*
|
|
92
|
-
* NOTE: Logic must stay identical to scripts/hooks/stop-goal-fit.js hasLaunderingOperator.
|
|
93
|
-
* Centralize into a shared module as a follow-up (coordinate-free duplication for now).
|
|
94
|
-
*/
|
|
95
|
-
function hasLaunderingOperator(cmd) {
|
|
96
|
-
// Flag ANY || operator — masks the exit code of the left-hand command.
|
|
97
|
-
if (/\|\|/.test(cmd)) return true;
|
|
98
|
-
// Flag ; or newline followed by true / : / exit 0
|
|
99
|
-
if (/[;\n]\s*true\b/.test(cmd)) return true;
|
|
100
|
-
if (/[;\n]\s*:\s*(?:$|\s|;|\n)/.test(cmd)) return true;
|
|
101
|
-
if (/[;\n]\s*exit\s+0\b/.test(cmd)) return true;
|
|
102
|
-
// Flag pipe to true (pipeline absorbs exit code)
|
|
103
|
-
if (/\|\s*true\b/.test(cmd)) return true;
|
|
104
|
-
return false;
|
|
105
|
-
}
|
|
87
|
+
// hasLaunderingOperator is imported from ../lib/command-log-chain.js (above) so this
|
|
88
|
+
// CI reconciler and the stop-goal-fit verifier apply the identical exit-code-mask
|
|
89
|
+
// heuristic — see that module for the rules.
|
|
106
90
|
|
|
107
91
|
/**
|
|
108
92
|
* Run a single shell command under bash, capturing exit code.
|
|
@@ -40,6 +40,12 @@
|
|
|
40
40
|
* any interpreter not in the list (ruby, php, etc.), and multiline here-docs.
|
|
41
41
|
* The real anchor remains external (clean CI env + human review).
|
|
42
42
|
*
|
|
43
|
+
* FROZEN bar-raiser (ADR 0018). The redirect / interpreter-write / cp-target detection
|
|
44
|
+
* is intentionally non-exhaustive and is NOT to be grown with new evasion-pattern rules.
|
|
45
|
+
* A newly-discovered self-tamper vector belongs in the external CI anchor (trust-reconcile
|
|
46
|
+
* does not trust agent-written files) and the required anti-gaming suite — not another
|
|
47
|
+
* local string matcher. Bug fixes that don't expand the pattern surface are still welcome.
|
|
48
|
+
*
|
|
43
49
|
* Exit codes: 0 = allow, 2 = block
|
|
44
50
|
*/
|
|
45
51
|
|
|
@@ -48,6 +48,7 @@
|
|
|
48
48
|
|
|
49
49
|
const fs = require('fs');
|
|
50
50
|
const path = require('path');
|
|
51
|
+
const { flowAgentsArtifactRootsForRead } = require('./lib/local-artifact-paths');
|
|
51
52
|
const crypto = require('crypto');
|
|
52
53
|
|
|
53
54
|
const MAX_STDIN = 1024 * 1024;
|
|
@@ -62,43 +63,16 @@ const COMMAND_TOOL_NAME = /(^|[^a-z])(bash|shell|sh|exec|run|command|terminal|cm
|
|
|
62
63
|
|
|
63
64
|
// ─── Hash-chain integrity (tamper-EVIDENCE) ───────────────────────────────────
|
|
64
65
|
//
|
|
65
|
-
//
|
|
66
|
-
//
|
|
67
|
-
//
|
|
68
|
-
//
|
|
69
|
-
//
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
// HONEST FRAMING: this makes alteration DETECTABLE, not impossible. An agent
|
|
76
|
-
// that rewrites all hashes can still forge the chain. The real tamper-proof
|
|
77
|
-
// boundary is the signed checkpoint (B1). We do not oversell this boundary.
|
|
78
|
-
const CHAIN_GENESIS = 'a3f9e2b7d5c84f1e6a0d2c3b9f7e1a4d8c6b5f2e9a0d3c7b1f4e8a2d6c0b9f3';
|
|
79
|
-
|
|
80
|
-
/**
|
|
81
|
-
* Stable canonical JSON for the chain input: the record WITHOUT the `_chain`
|
|
82
|
-
* field, keys sorted alphabetically. This ensures the hash is independent of
|
|
83
|
-
* key insertion order and that `_chain` itself does not contribute to its own
|
|
84
|
-
* hash (circular dependency).
|
|
85
|
-
*/
|
|
86
|
-
function canonicalJsonForChain(record) {
|
|
87
|
-
// Strip _chain if present (should not be, but defensive).
|
|
88
|
-
const keys = Object.keys(record).filter(k => k !== '_chain').sort();
|
|
89
|
-
const obj = {};
|
|
90
|
-
for (const k of keys) obj[k] = record[k];
|
|
91
|
-
return JSON.stringify(obj);
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
/**
|
|
95
|
-
* Compute the sha256 hex hash for this chain link.
|
|
96
|
-
* hash = sha256(prevHash + canonicalJson(record))
|
|
97
|
-
*/
|
|
98
|
-
function computeChainHash(prevHash, record) {
|
|
99
|
-
const input = prevHash + canonicalJsonForChain(record);
|
|
100
|
-
return crypto.createHash('sha256').update(input, 'utf8').digest('hex');
|
|
101
|
-
}
|
|
66
|
+
// CHAIN_GENESIS is a fixed arbitrary sentinel — NOT the SHA256 of any specific
|
|
67
|
+
// input string (a previous comment incorrectly claimed sha256("…:genesis")). The
|
|
68
|
+
// writer here and the verifier in stop-goal-fit.js MUST canonicalize and seed
|
|
69
|
+
// identically, so the genesis constant and the canonicalJson/hash helpers live in
|
|
70
|
+
// ONE shared module that both import — divergence is structurally impossible.
|
|
71
|
+
const {
|
|
72
|
+
CHAIN_GENESIS,
|
|
73
|
+
canonicalJsonForChain,
|
|
74
|
+
computeChainHash,
|
|
75
|
+
} = require('../lib/command-log-chain.js');
|
|
102
76
|
|
|
103
77
|
/**
|
|
104
78
|
* Read the last entry from command-log.jsonl that has a `_chain` block.
|
|
@@ -224,18 +198,23 @@ function latestStateDir(flowAgentsDir) {
|
|
|
224
198
|
* to the newest-mtime state.json directory.
|
|
225
199
|
*/
|
|
226
200
|
function resolveArtifactDir(root) {
|
|
227
|
-
const flowAgentsDir
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
201
|
+
for (const flowAgentsDir of flowAgentsArtifactRootsForRead(root)) {
|
|
202
|
+
const current = readJsonFile(path.join(flowAgentsDir, 'current.json'));
|
|
203
|
+
if (current) {
|
|
204
|
+
const slug = current.artifact_dir || current.active_slug;
|
|
205
|
+
if (typeof slug === 'string' && slug.trim()) {
|
|
206
|
+
// Guard against path traversal in the slug.
|
|
207
|
+
const safe = slug.replace(/\.\.+/g, '').replace(/^[/\\]+/, '');
|
|
208
|
+
const dir = path.join(flowAgentsDir, safe);
|
|
209
|
+
if (dir.startsWith(flowAgentsDir + path.sep) && fs.existsSync(dir)) return dir;
|
|
210
|
+
}
|
|
236
211
|
}
|
|
237
212
|
}
|
|
238
|
-
|
|
213
|
+
for (const flowAgentsDir of flowAgentsArtifactRootsForRead(root)) {
|
|
214
|
+
const latest = latestStateDir(flowAgentsDir);
|
|
215
|
+
if (latest) return latest;
|
|
216
|
+
}
|
|
217
|
+
return null;
|
|
239
218
|
}
|
|
240
219
|
|
|
241
220
|
function isCommandTool(toolName, command) {
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
|
|
6
|
+
const KONTOURAI_DIR = '.kontourai';
|
|
7
|
+
const LEGACY_FLOW_AGENTS_DIR = '.flow-agents';
|
|
8
|
+
|
|
9
|
+
function flowAgentsArtifactRoot(cwd = process.cwd()) {
|
|
10
|
+
return path.resolve(cwd, KONTOURAI_DIR, 'flow-agents');
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function legacyFlowAgentsArtifactRoot(cwd = process.cwd()) {
|
|
14
|
+
return path.resolve(cwd, LEGACY_FLOW_AGENTS_DIR);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function flowAgentsArtifactRootsForRead(cwd = process.cwd()) {
|
|
18
|
+
const roots = [flowAgentsArtifactRoot(cwd), legacyFlowAgentsArtifactRoot(cwd)];
|
|
19
|
+
return roots.filter((root, index) => roots.indexOf(root) === index && fs.existsSync(root));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function defaultArtifactRootForRead(cwd = process.cwd()) {
|
|
23
|
+
const roots = flowAgentsArtifactRootsForRead(cwd);
|
|
24
|
+
return roots[0] || flowAgentsArtifactRoot(cwd);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
module.exports = {
|
|
28
|
+
flowAgentsArtifactRoot,
|
|
29
|
+
legacyFlowAgentsArtifactRoot,
|
|
30
|
+
flowAgentsArtifactRootsForRead,
|
|
31
|
+
defaultArtifactRootForRead,
|
|
32
|
+
};
|
|
@@ -29,6 +29,20 @@ const path = require('path');
|
|
|
29
29
|
const { spawnSync } = require('child_process');
|
|
30
30
|
const crypto = require('crypto');
|
|
31
31
|
|
|
32
|
+
// Hash-chain primitives + the exit-code-laundering heuristic come from ONE shared
|
|
33
|
+
// module, so this verifier can never drift from the writer (evidence-capture.js).
|
|
34
|
+
// CHAIN_GENESIS is re-aliased to CHAIN_GENESIS_VERIFY to preserve the long-standing
|
|
35
|
+
// export name consumed by repair-command-log.js and the fork-classification eval.
|
|
36
|
+
const {
|
|
37
|
+
CHAIN_GENESIS: CHAIN_GENESIS_VERIFY,
|
|
38
|
+
canonicalJsonForChain,
|
|
39
|
+
hasLaunderingOperator,
|
|
40
|
+
} = require('../lib/command-log-chain.js');
|
|
41
|
+
const {
|
|
42
|
+
flowAgentsArtifactRoot,
|
|
43
|
+
flowAgentsArtifactRootsForRead,
|
|
44
|
+
} = require('./lib/local-artifact-paths');
|
|
45
|
+
|
|
32
46
|
const MAX_STDIN = 1024 * 1024;
|
|
33
47
|
const ACTIVE_STATUSES = new Set([
|
|
34
48
|
'planning',
|
|
@@ -733,36 +747,10 @@ function claimAcknowledgesFailure(status, value) {
|
|
|
733
747
|
|| v === 'fail' || v === 'failed' || v === 'not_verified' || v === 'failing';
|
|
734
748
|
}
|
|
735
749
|
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
*
|
|
741
|
-
* R6 extended logic (identical patterns used by scripts/ci/trust-reconcile.js — centralize
|
|
742
|
-
* as a follow-up if drift becomes a maintenance concern):
|
|
743
|
-
* - ANY || operator is flagged. A legitimate verification command never needs || — its
|
|
744
|
-
* only purpose in a verification command is to mask the real exit code (e.g.
|
|
745
|
-
* `npm test || exit 0`, `npm test || echo ok`, `npm test || /bin/true`, `npm test || (exit 0)`).
|
|
746
|
-
* - | true (single pipe into true — always exits 0)
|
|
747
|
-
* - Trailing ; or newline followed by: true : exit 0 /bin/true
|
|
748
|
-
*
|
|
749
|
-
* Fix D: applied in captureCrossReference's satisfied path and capturedFailReconciliation.
|
|
750
|
-
*/
|
|
751
|
-
function hasLaunderingOperator(cmd) {
|
|
752
|
-
// ANY || in a claimed verification command is an exit-code mask.
|
|
753
|
-
// Legitimate verification commands never need || — its only purpose there is to
|
|
754
|
-
// suppress the real exit code (|| exit 0, || echo ok, || /bin/true, || (exit 0), etc.).
|
|
755
|
-
if (/\|\|/.test(cmd)) return true;
|
|
756
|
-
// | true — single-pipe into true: `cmd | true` always exits 0 regardless of left-side exit code.
|
|
757
|
-
if (/\|\s*true\b/.test(cmd)) return true;
|
|
758
|
-
// Trailing ; or \n followed by exit-neutralizing commands (same threat, appended after the real cmd):
|
|
759
|
-
// ; true ; : ; exit 0 ; /bin/true (and \n variants)
|
|
760
|
-
if (/[;\n]\s*true\b/.test(cmd)) return true;
|
|
761
|
-
if (/[;\n]\s*:\s*(?:$|\s|;)/.test(cmd)) return true;
|
|
762
|
-
if (/[;\n]\s*exit\s+0\b/.test(cmd)) return true;
|
|
763
|
-
if (/[;\n]\s*\/bin\/true\b/.test(cmd)) return true;
|
|
764
|
-
return false;
|
|
765
|
-
}
|
|
750
|
+
// hasLaunderingOperator (the exit-code-mask heuristic) is imported from
|
|
751
|
+
// ../lib/command-log-chain.js so this verifier and scripts/ci/trust-reconcile.js
|
|
752
|
+
// share one normative definition. Applied in captureCrossReference's satisfied
|
|
753
|
+
// path and capturedFailReconciliation.
|
|
766
754
|
|
|
767
755
|
// ─── Hash-chain integrity verification (Increment B2, tamper-EVIDENCE) ────────
|
|
768
756
|
//
|
|
@@ -786,20 +774,9 @@ function hasLaunderingOperator(cmd) {
|
|
|
786
774
|
// The genesis prevHash is a fixed arbitrary sentinel — NOT the SHA256 of any
|
|
787
775
|
// specific input string. The comment in evidence-capture.js previously (and
|
|
788
776
|
// incorrectly) claimed it was sha256("flow-agents:command-log:genesis"); it is not.
|
|
789
|
-
//
|
|
790
|
-
//
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
/**
|
|
794
|
-
* Canonical JSON for chain verification: record WITHOUT `_chain`, keys sorted.
|
|
795
|
-
* Must be byte-identical to canonicalJsonForChain() in evidence-capture.js.
|
|
796
|
-
*/
|
|
797
|
-
function canonicalJsonForVerify(record) {
|
|
798
|
-
const keys = Object.keys(record).filter(k => k !== '_chain').sort();
|
|
799
|
-
const obj = {};
|
|
800
|
-
for (const k of keys) obj[k] = record[k];
|
|
801
|
-
return JSON.stringify(obj);
|
|
802
|
-
}
|
|
777
|
+
// Both the genesis (CHAIN_GENESIS_VERIFY, imported above) and the canonical-JSON
|
|
778
|
+
// helper (canonicalJsonForChain) come from ../lib/command-log-chain.js, the single
|
|
779
|
+
// source the writer in evidence-capture.js imports too — so they cannot diverge.
|
|
803
780
|
|
|
804
781
|
/**
|
|
805
782
|
* Verify the hash chain of command-log.jsonl.
|
|
@@ -870,7 +847,7 @@ function verifyCommandLogChain(artifactDir) {
|
|
|
870
847
|
// (a) Self-consistency. A content edit without rehashing fails here.
|
|
871
848
|
if (typeof chain.prevHash !== 'string') return { status: 'broken', brokenAt: i, forkAt: null };
|
|
872
849
|
const selfHash = crypto.createHash('sha256')
|
|
873
|
-
.update(chain.prevHash +
|
|
850
|
+
.update(chain.prevHash + canonicalJsonForChain(entry), 'utf8')
|
|
874
851
|
.digest('hex');
|
|
875
852
|
if (chain.hash !== selfHash) return { status: 'broken', brokenAt: i, forkAt: null };
|
|
876
853
|
|
|
@@ -1602,11 +1579,11 @@ const HARD_BLOCK = /contradicts evidence\.json|caught false-completion|evidence
|
|
|
1602
1579
|
const FULL_BLOCK = /status:|Definition Of Done|Goal Fit|sidecar validation:|contradicts evidence\.json|workflow state|evidence verdict|evidence check|NOT_VERIFIED gap|critique status|critique open|next action|caught false-completion|NOT_VERIFIED —|command-log integrity check FAILED|gate misconfiguration:|surface unavailable —|expected capture log is missing|exit-code-laundered/;
|
|
1603
1580
|
|
|
1604
1581
|
async function analyze(root, now = Date.now()) {
|
|
1605
|
-
const
|
|
1582
|
+
const flowAgentsDirs = flowAgentsArtifactRootsForRead(root);
|
|
1606
1583
|
// Scope to the session's current task when current.json names one, so an
|
|
1607
1584
|
// unrelated active workflow elsewhere in the repo does not gate this stop.
|
|
1608
|
-
const scoped = preferredArtifactDir(
|
|
1609
|
-
const searchDirs = scoped ? [scoped] :
|
|
1585
|
+
const scoped = flowAgentsDirs.map(preferredArtifactDir).find(Boolean);
|
|
1586
|
+
const searchDirs = scoped ? [scoped] : flowAgentsDirs;
|
|
1610
1587
|
const artifacts = searchDirs
|
|
1611
1588
|
.flatMap(dir => walkMarkdown(dir))
|
|
1612
1589
|
.map(readArtifact)
|
|
@@ -1616,6 +1593,7 @@ async function analyze(root, now = Date.now()) {
|
|
|
1616
1593
|
if (artifacts.length === 0) return { warnings: [], blocking: false };
|
|
1617
1594
|
|
|
1618
1595
|
const latest = artifacts[0];
|
|
1596
|
+
const latestArtifactDir = path.dirname(latest.file);
|
|
1619
1597
|
const warnings = [];
|
|
1620
1598
|
const relPath = relative(root, latest.file);
|
|
1621
1599
|
const status = latest.status || 'unknown';
|
|
@@ -1631,11 +1609,11 @@ async function analyze(root, now = Date.now()) {
|
|
|
1631
1609
|
|
|
1632
1610
|
// ADR 0016 P-c: load the active FlowDefinition gate (fail-open: null when absent).
|
|
1633
1611
|
// Null → existing workflow.* fallback path unchanged. Non-null → expects[]-driven claim selection.
|
|
1634
|
-
const activeFlowStep = loadActiveFlowStep(
|
|
1612
|
+
const activeFlowStep = loadActiveFlowStep(path.dirname(latestArtifactDir));
|
|
1635
1613
|
|
|
1636
|
-
warnings.push(...sidecarValidation(root,
|
|
1637
|
-
warnings.push(...sidecarGuidance(root,
|
|
1638
|
-
const captureWarnings = captureCrossReference(root,
|
|
1614
|
+
warnings.push(...sidecarValidation(root, latestArtifactDir));
|
|
1615
|
+
warnings.push(...sidecarGuidance(root, latestArtifactDir, activeFlowStep));
|
|
1616
|
+
const captureWarnings = captureCrossReference(root, latestArtifactDir, activeFlowStep);
|
|
1639
1617
|
warnings.push(...captureWarnings);
|
|
1640
1618
|
// Dedup: bundleEnforcement and captureCrossReference can both fire "caught false-completion"
|
|
1641
1619
|
// for the same disputed claim. Suppress the bundleEnforcement warning ONLY when
|
|
@@ -1649,7 +1627,7 @@ async function analyze(root, now = Date.now()) {
|
|
|
1649
1627
|
const m = /evidence check ([^\s:]+):/.exec(w);
|
|
1650
1628
|
if (m) captureHardBlockIds.add(m[1]);
|
|
1651
1629
|
}
|
|
1652
|
-
const bundleWarnings = (await bundleEnforcement(
|
|
1630
|
+
const bundleWarnings = (await bundleEnforcement(latestArtifactDir, activeFlowStep)).filter(w => {
|
|
1653
1631
|
if (!captureHardBlockIds.size) return true;
|
|
1654
1632
|
// bundleEnforcement warns: "trust.bundle claim disputed: <subjectId> ..."
|
|
1655
1633
|
const m = /trust\.bundle claim (?:disputed|tampered): ([^\s(]+)/.exec(w);
|
|
@@ -1661,21 +1639,21 @@ async function analyze(root, now = Date.now()) {
|
|
|
1661
1639
|
return !captureHardBlockIds.has(checkId);
|
|
1662
1640
|
});
|
|
1663
1641
|
warnings.push(...bundleWarnings);
|
|
1664
|
-
warnings.push(...missingBundleOrStateSignal(
|
|
1642
|
+
warnings.push(...missingBundleOrStateSignal(latestArtifactDir, activeFlowStep));
|
|
1665
1643
|
|
|
1666
1644
|
// A pre-execution task (not started) OR a terminal task (which is itself a
|
|
1667
1645
|
// completion *claim*) must not block on mere incompleteness — but a FALSE claim
|
|
1668
1646
|
// (capture/evidence contradiction) still blocks at any phase. This is the whole
|
|
1669
1647
|
// point of the capture cross-reference: catch a task that falsely claims done.
|
|
1670
|
-
const gateState = readJsonFile(path.join(
|
|
1648
|
+
const gateState = readJsonFile(path.join(latestArtifactDir, 'state.json'));
|
|
1671
1649
|
const taskStatus = gateState ? normalizedStatus(gateState.status) : normalizedStatus(status);
|
|
1672
|
-
const preExecution = isPreExecution(
|
|
1650
|
+
const preExecution = isPreExecution(latestArtifactDir, status);
|
|
1673
1651
|
const terminal = TERMINAL_STATUSES.has(taskStatus);
|
|
1674
1652
|
|
|
1675
1653
|
// Namespace-agnostic captured-FAIL reconciliation (AC1 — closes the allowlist bypass).
|
|
1676
1654
|
// Fix A: status-independent — runs on EVERY stop. A claim contradicting the capture
|
|
1677
1655
|
// is a false-completion whether or not the agent says the task is 'done'.
|
|
1678
|
-
warnings.push(...capturedFailReconciliation(root,
|
|
1656
|
+
warnings.push(...capturedFailReconciliation(root, latestArtifactDir, taskStatus));
|
|
1679
1657
|
|
|
1680
1658
|
// Use module-scope HARD_BLOCK / FULL_BLOCK (defined above analyze()).
|
|
1681
1659
|
// pre-execution/terminal tasks: only HARD_BLOCK signals cause a block.
|
|
@@ -1713,7 +1691,8 @@ function resolveMaxBlocks() {
|
|
|
1713
1691
|
}
|
|
1714
1692
|
|
|
1715
1693
|
function blockStreakFile(root) {
|
|
1716
|
-
|
|
1694
|
+
const artifactRoot = flowAgentsArtifactRoot(root);
|
|
1695
|
+
return path.join(artifactRoot, '.goal-fit-block-streak.json');
|
|
1717
1696
|
}
|
|
1718
1697
|
|
|
1719
1698
|
function reasonsHash(warnings) {
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
const fs = require('fs');
|
|
18
18
|
const path = require('path');
|
|
19
19
|
const { readLivenessEvents, freshHolders } = require('./lib/liveness-read');
|
|
20
|
+
const { flowAgentsArtifactRootsForRead } = require('./lib/local-artifact-paths');
|
|
20
21
|
|
|
21
22
|
const STEERING = {
|
|
22
23
|
'tool-planner': [
|
|
@@ -101,7 +102,8 @@ function readJson(file) {
|
|
|
101
102
|
}
|
|
102
103
|
|
|
103
104
|
function latestWorkflowState(root) {
|
|
104
|
-
const states =
|
|
105
|
+
const states = flowAgentsArtifactRootsForRead(root)
|
|
106
|
+
.flatMap(artifactRoot => walkStateFiles(artifactRoot))
|
|
105
107
|
.map(file => {
|
|
106
108
|
let stat;
|
|
107
109
|
try { stat = fs.statSync(file); } catch { return null; }
|
|
@@ -295,8 +297,8 @@ function resumeSteering(root, current) {
|
|
|
295
297
|
|
|
296
298
|
// Liveness advisory
|
|
297
299
|
try {
|
|
298
|
-
const
|
|
299
|
-
|
|
300
|
+
const events = flowAgentsArtifactRootsForRead(root)
|
|
301
|
+
.flatMap(artifactRoot => readLivenessEvents(path.join(artifactRoot, 'liveness', 'events.jsonl')));
|
|
300
302
|
if (events.length > 0) {
|
|
301
303
|
const selfActor = (process.env.FLOW_AGENTS_ACTOR || '').trim() || 'local';
|
|
302
304
|
const holders = freshHolders(events, slug, selfActor, Date.now());
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
//
|
|
3
|
+
// Single normative source for the command-log hash-chain primitives and the
|
|
4
|
+
// exit-code-laundering heuristic.
|
|
5
|
+
//
|
|
6
|
+
// These were previously copy-pasted across the writer (hooks/evidence-capture.js),
|
|
7
|
+
// the verifier (hooks/stop-goal-fit.js), the repair tool (repair-command-log.js),
|
|
8
|
+
// and CI reconcile (ci/trust-reconcile.js) under "keep byte-identical" comments —
|
|
9
|
+
// the most security-sensitive path in the bundle, since the chain's integrity
|
|
10
|
+
// claim rests on writer and verifier canonicalizing identically. The copies had
|
|
11
|
+
// ALREADY drifted (ci/trust-reconcile's hasLaunderingOperator was missing the
|
|
12
|
+
// trailing `/bin/true` check), which is exactly the failure mode duplication
|
|
13
|
+
// invites. Importing from one module makes that divergence structurally impossible.
|
|
14
|
+
//
|
|
15
|
+
const crypto = require('crypto');
|
|
16
|
+
|
|
17
|
+
// The genesis prevHash is a FIXED ARBITRARY SENTINEL — NOT the SHA256 of any
|
|
18
|
+
// specific input string. (An earlier comment incorrectly claimed it was
|
|
19
|
+
// sha256("flow-agents:command-log:genesis"); that is wrong.) Writer and verifier
|
|
20
|
+
// MUST share this exact value — existing chained logs depend on it.
|
|
21
|
+
//
|
|
22
|
+
// HONEST FRAMING: this makes alteration DETECTABLE, not impossible. An agent that
|
|
23
|
+
// rewrites all hashes can still forge the chain. The real tamper-proof boundary is
|
|
24
|
+
// the signed checkpoint (B1). We do not oversell this boundary.
|
|
25
|
+
const CHAIN_GENESIS = 'a3f9e2b7d5c84f1e6a0d2c3b9f7e1a4d8c6b5f2e9a0d3c7b1f4e8a2d6c0b9f3';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Stable canonical JSON for a chain link: the record WITHOUT its `_chain` field,
|
|
29
|
+
* keys sorted alphabetically. This makes the hash independent of key insertion
|
|
30
|
+
* order and keeps `_chain` from contributing to its own hash.
|
|
31
|
+
*/
|
|
32
|
+
function canonicalJsonForChain(record) {
|
|
33
|
+
const keys = Object.keys(record).filter((k) => k !== '_chain').sort();
|
|
34
|
+
const obj = {};
|
|
35
|
+
for (const k of keys) obj[k] = record[k];
|
|
36
|
+
return JSON.stringify(obj);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Chain link hash: sha256(prevHash + canonicalJsonForChain(record)), hex. */
|
|
40
|
+
function computeChainHash(prevHash, record) {
|
|
41
|
+
return crypto
|
|
42
|
+
.createHash('sha256')
|
|
43
|
+
.update(prevHash + canonicalJsonForChain(record), 'utf8')
|
|
44
|
+
.digest('hex');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* True when a claimed verification command contains an exit-code-laundering
|
|
49
|
+
* operator. Legitimate verification commands never need these — their only
|
|
50
|
+
* purpose is to suppress a real non-zero exit:
|
|
51
|
+
* - ANY `||` (e.g. `npm test || exit 0`, `|| echo ok`, `|| /bin/true`)
|
|
52
|
+
* - `| true` (pipe into true — the pipeline absorbs the exit code)
|
|
53
|
+
* - trailing `; true` / `; :` / `; exit 0` / `; /bin/true` (and `\n` variants)
|
|
54
|
+
*
|
|
55
|
+
* FROZEN bar-raiser (ADR 0018). Do NOT add new evasion-pattern rules here; route new
|
|
56
|
+
* laundering shapes to the external CI anchor (trust-reconcile + the anti-gaming suite).
|
|
57
|
+
* Accepted limitation: the blanket `||` rule over-blocks legitimate control-flow `||`
|
|
58
|
+
* (e.g. `test -d node_modules || npm ci`) — it fails toward blocking by design.
|
|
59
|
+
*/
|
|
60
|
+
function hasLaunderingOperator(cmd) {
|
|
61
|
+
// ANY || in a claimed verification command is an exit-code mask.
|
|
62
|
+
if (/\|\|/.test(cmd)) return true;
|
|
63
|
+
// | true — single-pipe into true always exits 0 regardless of the left side.
|
|
64
|
+
if (/\|\s*true\b/.test(cmd)) return true;
|
|
65
|
+
// Trailing ; or \n followed by an exit-neutralizing command:
|
|
66
|
+
if (/[;\n]\s*true\b/.test(cmd)) return true;
|
|
67
|
+
if (/[;\n]\s*:\s*(?:$|\s|;)/.test(cmd)) return true;
|
|
68
|
+
if (/[;\n]\s*exit\s+0\b/.test(cmd)) return true;
|
|
69
|
+
if (/[;\n]\s*\/bin\/true\b/.test(cmd)) return true;
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
module.exports = {
|
|
74
|
+
CHAIN_GENESIS,
|
|
75
|
+
canonicalJsonForChain,
|
|
76
|
+
computeChainHash,
|
|
77
|
+
hasLaunderingOperator,
|
|
78
|
+
};
|