cool-workflow 0.1.87 → 0.1.89

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.
Files changed (75) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codex-plugin/plugin.json +1 -1
  3. package/README.md +111 -71
  4. package/apps/architecture-review/app.json +1 -1
  5. package/apps/architecture-review-fast/app.json +1 -1
  6. package/apps/end-to-end-golden-path/app.json +1 -1
  7. package/apps/pr-review-fix-ci/app.json +1 -1
  8. package/apps/release-cut/app.json +1 -1
  9. package/apps/research-synthesis/app.json +1 -1
  10. package/dist/agent-config.js +42 -1
  11. package/dist/capability-core.js +16 -4
  12. package/dist/capability-registry.js +24 -0
  13. package/dist/cli/command-surface.js +105 -2
  14. package/dist/cli.js +2 -1
  15. package/dist/doctor.js +14 -1
  16. package/dist/drive.js +222 -16
  17. package/dist/execution-backend.js +4 -4
  18. package/dist/loop-expansion.js +60 -0
  19. package/dist/onramp.js +25 -0
  20. package/dist/orchestrator/lifecycle-operations.js +134 -3
  21. package/dist/orchestrator.js +48 -77
  22. package/dist/run-export.js +106 -2
  23. package/dist/state-node.js +13 -3
  24. package/dist/state.js +21 -0
  25. package/dist/telemetry-attestation.js +30 -6
  26. package/dist/telemetry-demo.js +29 -1
  27. package/dist/telemetry-ledger.js +6 -0
  28. package/dist/version.js +1 -1
  29. package/dist/worker-accept/telemetry-ledger.js +12 -2
  30. package/dist/workflow-api.js +33 -0
  31. package/dist/workflow-app-framework.js +20 -0
  32. package/docs/agent-delegation-drive.7.md +8 -0
  33. package/docs/capability-topology-registry.7.md +69 -46
  34. package/docs/cli-mcp-parity.7.md +16 -2
  35. package/docs/contract-migration-tooling.7.md +8 -0
  36. package/docs/control-plane-scheduling.7.md +8 -0
  37. package/docs/durable-state-and-locking.7.md +8 -0
  38. package/docs/evidence-adoption-reasoning-chain.7.md +8 -0
  39. package/docs/execution-backends.7.md +8 -0
  40. package/docs/launch/launch-kit.md +9 -9
  41. package/docs/multi-agent-cli-mcp-surface.7.md +8 -0
  42. package/docs/multi-agent-eval-replay-harness.7.md +8 -0
  43. package/docs/multi-agent-operator-ux.7.md +8 -0
  44. package/docs/node-snapshot-diff-replay.7.md +8 -0
  45. package/docs/observability-cost-accounting.7.md +8 -0
  46. package/docs/project-index.md +22 -5
  47. package/docs/readme-v0.1.87-full.md +301 -0
  48. package/docs/real-execution-backends.7.md +8 -0
  49. package/docs/release-and-migration.7.md +8 -0
  50. package/docs/release-tooling.7.md +8 -0
  51. package/docs/report-verifiable-bundle.7.md +34 -2
  52. package/docs/run-registry-control-plane.7.md +18 -0
  53. package/docs/run-retention-reclamation.7.md +8 -0
  54. package/docs/state-explosion-management.7.md +8 -0
  55. package/docs/team-collaboration.7.md +8 -0
  56. package/docs/trust-model.md +6 -4
  57. package/docs/web-desktop-workbench.7.md +8 -0
  58. package/manifest/plugin.manifest.json +1 -1
  59. package/manifest/source-context-profiles.json +1 -1
  60. package/package.json +8 -5
  61. package/scripts/agents/agent-adapter-core.js +1 -1
  62. package/scripts/agents/builtin-templates.json +2 -1
  63. package/scripts/agents/claude-p-agent.js +7 -33
  64. package/scripts/agents/codex-agent.js +1 -1
  65. package/scripts/agents/cw-attest-wrap.js +9 -1
  66. package/scripts/agents/gemini-agent.js +1 -1
  67. package/scripts/agents/opencode-agent.js +1 -1
  68. package/scripts/canonical-apps.js +4 -4
  69. package/scripts/coverage-gate.js +15 -1
  70. package/scripts/cw.js +0 -0
  71. package/scripts/dogfood-release.js +1 -1
  72. package/scripts/golden-path.js +4 -4
  73. package/scripts/release-flow.js +49 -2
  74. package/scripts/release-gate.sh +11 -2
  75. package/tsconfig.json +3 -1
@@ -800,10 +800,20 @@ function parseArgv(argv) {
800
800
  positionals.push(rest[restIndex]);
801
801
  break;
802
802
  }
803
- if (!token.startsWith("--")) {
803
+ if (!token.startsWith("-")) {
804
804
  positionals.push(token);
805
805
  continue;
806
806
  }
807
+ if (!token.startsWith("--")) {
808
+ // Single-dash short flag aliases: -q → question, -r → repo, -a → agent-command, -h → help, -v → version
809
+ const shortMap = { q: "question", r: "repo", a: "agent-command", h: "help", v: "version" };
810
+ const flag = token.slice(1);
811
+ // Handle combined short flags like -qr (not common but safe to ignore)
812
+ const key = shortMap[flag] || flag;
813
+ const val = rest[index + 1] && !rest[index + 1].startsWith("-") ? rest[++index] : true;
814
+ appendOption(options, key, val);
815
+ continue;
816
+ }
807
817
  const withoutPrefix = token.slice(2);
808
818
  const equalsIndex = withoutPrefix.indexOf("=");
809
819
  let key;
@@ -829,7 +839,7 @@ exports.KNOWN_COMMANDS = new Set([
829
839
  "history", "audit-run", "multi-agent", "topology", "summary", "blackboard",
830
840
  "coordinator", "metrics", "operator", "sched", "gc", "telemetry",
831
841
  "migration", "demo", "workbench", "approve", "reject", "comment", "handoff",
832
- "graph", "eval"
842
+ "graph", "eval", "version", "update", "fix"
833
843
  ]);
834
844
  /** Levenshtein distance between two short strings. */
835
845
  function levenshtein(a, b) {
@@ -913,85 +923,46 @@ function formatInfo(appId, data) {
913
923
  return lines.join("\n");
914
924
  }
915
925
  function formatHelp() {
926
+ // Help is written to stdout, so color must key off stdout (not the term default).
927
+ const out = process.stdout;
928
+ const moreCommands = ("list search info init plan status next dispatch result state commit report app " +
929
+ "sandbox backend contract node feedback worker audit candidate review loop schedule " +
930
+ "routine registry run queue history quickstart audit-run multi-agent topology summary " +
931
+ "blackboard coordinator metrics operator sched gc telemetry migration demo workbench " +
932
+ "approve reject comment handoff graph eval man version update fix").split(" ");
933
+ // Wrap the command list into clean, indented, pipe-joined lines (<=76 cols) instead of
934
+ // one 400-char line that wraps raggedly and merges with the next shell prompt. Pipe-joined
935
+ // (no internal spaces) keeps it parseable by the CLI/MCP parity help-token check.
936
+ const wrapped = [];
937
+ let line = " ";
938
+ for (const cmd of moreCommands) {
939
+ const sep = line.length > 2 ? "|" : "";
940
+ if (line.length + sep.length + cmd.length > 76) {
941
+ wrapped.push(line);
942
+ line = " ";
943
+ }
944
+ line += (line.length > 2 ? "|" : "") + cmd;
945
+ }
946
+ if (line.length > 2)
947
+ wrapped.push(line);
916
948
  return [
917
- (0, term_1.bold)("Cool Workflow"),
918
- "",
919
- " Quick start (one command — plan → drive → report):",
920
- " cw quickstart [app] --repo . --question \"...\" --agent-command builtin:claude",
921
- " (--preview for a dry run without an agent; --bundle for a portable sealed report)",
922
- "",
923
- (0, term_1.bold)("Getting Started"),
924
- " list List available workflow apps",
925
- " search <keyword> Search workflows by title or description",
926
- " info <app-id> [--json] Show what a workflow app does and how to run it",
927
- " doctor [--json] [--onramp] [--fix] Check setup (--fix for consolidated fix commands)",
928
- " init <id> [--title T] Create a new workflow app",
929
- " quickstart [app] [...] Plan → drive → report in one command",
930
- " demo tamper|bundle Prove trust checks work (30s, no agent needed)",
931
- " man <topic> Show a man page (e.g. cw man release-tooling)",
932
- "",
933
- (0, term_1.bold)("Run Management"),
934
- " plan <id> [--repo P] [--question Q] Create a new run plan",
935
- " quickstart|audit-run [app] [...] Plan → drive → report in one command",
936
- " status <run-id> [--json] [--brief] Show run status (--brief for compact summary)",
937
- " next <run-id> [--limit N] Show pending dispatch tasks",
938
- " dispatch <run-id> [--limit N] Dispatch tasks to workers",
939
- " result <run-id> <task-id> <file> Record a task result",
940
- " state check <run-id> [--write] Validate run state",
941
- " commit <run-id> <mode> [...] Record a gated commit",
942
- " report <run-id> [--show|--summary] Show the report (or bundle/verify-bundle)",
943
- " graph <run-id> [--json] Show operator graph",
944
- "",
945
- (0, term_1.bold)("Inspection & Diagnostics"),
946
- " operator status|report <run-id> [--json] Human-friendly operator panel",
947
- " metrics show|summary <run-id> [--json] Cost and usage metrics",
948
- " telemetry verify <run-id> [--pubkey P] Verify tamper-evident telemetry",
949
- " migration list|check|prove [target] Schema migration tools",
950
- " gc plan|run|verify [run-id] Garbage collection",
951
- "",
952
- (0, term_1.bold)("Audit & Trust"),
953
- " audit summary|worker|provenance|multi-agent|... <run-id> Trust audit operations",
954
- " candidate list|register|score|rank|select|reject <run-id> Candidate management",
955
- " node list|show|graph|snapshot|diff|replay|verify <run-id> State-node inspection",
956
- " eval snapshot|replay|compare|score|gate|report Eval/replay harness",
957
- "",
958
- (0, term_1.bold)("Multi‑Agent & Collaboration"),
959
- " multi-agent run|status|step|blackboard|score|... <run-id> Multi-agent coordination",
960
- " topology list|show|validate|apply|summary|graph Topology management",
961
- " blackboard summary|graph|resolve|topic|message|... <run-id> Blackboard workspace",
962
- " coordinator summary|decision <run-id> Coordinator interface",
963
- " summary refresh|show <run-id> State explosion summaries",
964
- " approve|reject|comment <kind> <run-id> <id> [--reason T] Team approval actions",
965
- " handoff <kind> <run-id> <id> [--to ROLE] Team handoff",
966
- " review status|policy <run-id> [--json] Review status",
949
+ (0, term_1.bold)("Cool Workflow", out),
967
950
  "",
968
- (0, term_1.bold)("Run Registry & Scheduling"),
969
- " run search|list|show|resume|archive|export|import <id> Cross-repo run management",
970
- " registry refresh|show [--scope repo|home] [--json] Run registry index",
971
- " queue add|list|drain|show [queue-id] Work queue operations",
972
- " history [--scope repo|home] [--json] Run history",
973
- " schedule create|list|due|complete|... Scheduled tasks",
974
- " routine create|fire|list|events|delete Event-driven triggers",
975
- " sched plan|lease|release|complete|... Lease-based scheduling",
976
- " loop --prompt T [--interval-minutes N] Continuous loop runner",
951
+ " -q \"question\" [-claude|-codex|-deepseek] Ask a question, get a report",
952
+ " version Show version",
953
+ " update Update to latest release",
954
+ " doctor Check setup",
955
+ " fix Show fix commands for setup issues",
977
956
  "",
978
- (0, term_1.bold)("Developer & Workspace"),
979
- " app list|show|validate|init|package|run [id|path] Workflow app management",
980
- " sandbox list|show|validate|choose|resolve [id] Sandbox profiles",
981
- " backend list|show|probe [id] Agent execution backends",
982
- " contract show <run-id> [id] Run contract view",
983
- " worker list|summary|show|manifest|output|... <id> Worker operations",
984
- " feedback list|show|summary|collect|task|resolve <id> Feedback loop",
985
- " workbench serve [--port N] | view <run-id> Optional localhost workbench",
957
+ (0, term_1.bold)("Flags", out),
958
+ " -q, --question TEXT The task or question to answer",
959
+ " -r, --repo PATH Target repository path (default: .)",
960
+ " -claude Use Claude agent",
961
+ " -codex Use Codex agent",
962
+ " -deepseek Use DeepSeek (via opencode)",
986
963
  "",
987
- (0, term_1.bold)("Common Flags"),
988
- " --json, --format json Machine-readable JSON output",
989
- " --repo PATH Target repository path",
990
- " --question TEXT The task or question to answer",
991
- " --agent-command CMD Agent backend (e.g. builtin:claude, builtin:codex)",
992
- " --scope repo|home Scope for cross-repo operations",
993
- " --cwd PATH Working directory override",
994
- ""
964
+ (0, term_1.bold)("More commands", out),
965
+ ...wrapped
995
966
  ].join("\n");
996
967
  }
997
968
  function appendOption(options, key, value) {
@@ -26,6 +26,7 @@ const version_1 = require("./version");
26
26
  const telemetry_ledger_1 = require("./telemetry-ledger");
27
27
  const telemetry_attestation_1 = require("./telemetry-attestation");
28
28
  const trust_audit_1 = require("./trust-audit");
29
+ const execution_backend_1 = require("./execution-backend");
29
30
  const compare_1 = require("./compare");
30
31
  /** Export a run to a portable JSON archive with run-local bytes and digests. */
31
32
  function exportRun(run, outputPath, options = {}) {
@@ -84,9 +85,21 @@ function importRun(exportPath, targetDir) {
84
85
  const archiveSha256 = sha256Bytes(node_fs_1.default.readFileSync(exportPath));
85
86
  const files = normalizeArchiveFiles(raw);
86
87
  verifyArchiveFileDigests(files, raw.integrity);
88
+ if (!raw.run || typeof raw.run !== "object") {
89
+ throw new Error("Invalid run export: missing run object");
90
+ }
91
+ // The run id from the archive becomes a directory name under the target's
92
+ // runs root; a crafted id like "../../etc" would otherwise escape it. Refuse
93
+ // any id that is not a single safe path segment, then assert containment as
94
+ // defense-in-depth (catches a symlinked runs root too) before any write.
95
+ const runId = (0, state_1.assertSafeRunId)(raw.run.id);
96
+ const runsRoot = node_path_1.default.join(targetDir, ".cw", "runs");
97
+ const runDir = node_path_1.default.join(runsRoot, runId);
98
+ if (!(0, state_1.isContainedPath)(runDir, runsRoot)) {
99
+ throw new Error(`Run id escapes the runs directory: ${JSON.stringify(raw.run.id)}`);
100
+ }
87
101
  const oldRunDir = raw.run.paths.runDir;
88
102
  const oldCwd = raw.run.cwd;
89
- const runDir = node_path_1.default.join(targetDir, ".cw", "runs", raw.run.id);
90
103
  const paths = (0, state_1.createRunPaths)(runDir);
91
104
  (0, state_1.ensureRunDirs)(paths);
92
105
  for (const file of files) {
@@ -294,6 +307,28 @@ function importManifestPath(run) {
294
307
  *
295
308
  * Key precedence is bundle > argument > environment, so the artifact verifies the
296
309
  * same on any machine; only when the bundle omits a key do the override/env apply. */
310
+ /** True when report.md embeds `expected` (the trimmed result) at the task's OWN
311
+ * section, exactly as orchestrator/report.ts renderResults emits it: a
312
+ * `### <taskId>` heading, a `Result: <path>` line, then the result body — and the
313
+ * body STARTS WITH `expected`. Anchoring to the section (not a whole-file substring)
314
+ * means a decoy copy buried elsewhere does not satisfy it; matching from the heading
315
+ * forward (rather than to the next heading) means a result body that itself contains
316
+ * `###` cannot break the bound. The `Result:` path is matched loosely since it is
317
+ * host-specific (rebased on import). */
318
+ function reportSectionEmbedsResult(reportMd, taskId, expected) {
319
+ const needle = `### ${taskId}\n`;
320
+ // Walk EVERY `### <taskId>` occurrence — not just the first — so a stray heading
321
+ // inside an earlier task's result body (which is not followed by the `Result:`
322
+ // structure) is skipped rather than mis-anchoring the check (a false positive on a
323
+ // legitimate, fully-signed bundle whose findings contain markdown headings).
324
+ for (let from = reportMd.indexOf(needle); from >= 0; from = reportMd.indexOf(needle, from + 1)) {
325
+ const after = reportMd.slice(from);
326
+ const prefix = after.match(/^### [^\n]*\n\nResult: [^\n]*\n\n/);
327
+ if (prefix && after.slice(prefix[0].length).startsWith(expected))
328
+ return true;
329
+ }
330
+ return false;
331
+ }
297
332
  function verifyReportBundle(archivePath, options = {}) {
298
333
  const inspect = inspectArchive(archivePath);
299
334
  const failedChecks = inspect.checks
@@ -312,6 +347,8 @@ function verifyReportBundle(archivePath, options = {}) {
312
347
  signaturesChecked: 0,
313
348
  signaturesReverified: 0,
314
349
  signaturesFailed: 0,
350
+ trustLevel: "unsigned",
351
+ reportFindingsVerified: false,
315
352
  failedChecks
316
353
  };
317
354
  // A bundle that is not even a supported archive cannot be restored — report the
@@ -348,6 +385,8 @@ function verifyReportBundle(archivePath, options = {}) {
348
385
  let signaturesChecked = 0;
349
386
  let signaturesReverified = 0;
350
387
  let signaturesFailed = 0;
388
+ let signaturesResultBound = 0;
389
+ let reportFindingsOk = true;
351
390
  let reportExtractedTo;
352
391
  try {
353
392
  // Restore into the throwaway tree. importRun digest-checks every file and throws
@@ -370,9 +409,48 @@ function verifyReportBundle(archivePath, options = {}) {
370
409
  signaturesChecked = sig.checked;
371
410
  signaturesReverified = sig.reverified;
372
411
  signaturesFailed = sig.failed;
412
+ signaturesResultBound = sig.resultBound.length;
373
413
  for (const check of sig.checks)
374
414
  if (!check.pass)
375
415
  failedChecks.push({ name: check.name, code: check.code });
416
+ // Report ⇄ result ⇄ signature cross-check — three links so the agent's findings
417
+ // cannot be altered undetected on a signed bundle. CRUCIALLY this is driven by
418
+ // sig.resultBound (the records whose signature actually COVERED the result
419
+ // digest), NOT the run.tasks list — run.tasks is in the archive but bound by
420
+ // nothing (not the manifest digest, the chain, or the signature), so iterating it
421
+ // would let an attacker silence the check by dropping/un-completing a task. The
422
+ // obligation comes from the chained+signed ledger record instead:
423
+ // 1. each bound record's resultDigest is anchored by the executor signature
424
+ // (coversResult re-verified above) — a 4-field signature is excluded, so an
425
+ // injected resultDigest is never trusted;
426
+ // 2. the matching completed task's RESTORED result file must hash to that signed
427
+ // digest (a missing/un-completed task or edited/empty result is a forgery);
428
+ // 3. report.md must embed that result at the task's own `### <taskId>` section.
429
+ // Editing the report breaks link 3; editing the result breaks link 2; editing both
430
+ // to one consistent lie still breaks link 2 (the signed digest does not move);
431
+ // dropping the task fails link 2 (the signed obligation remains).
432
+ const reportPath = imported.run.paths.report;
433
+ const reportMd = reportPath && node_fs_1.default.existsSync(reportPath) ? node_fs_1.default.readFileSync(reportPath, "utf8") : "";
434
+ const completedById = new Map(imported.run.tasks.filter((task) => task.status === "completed").map((task) => [task.id, task]));
435
+ for (const bound of sig.resultBound) {
436
+ const failBound = (code) => {
437
+ reportFindingsOk = false;
438
+ failedChecks.push({ name: "report-findings", code: `${code}:${bound.taskId}` });
439
+ };
440
+ const task = completedById.get(bound.taskId);
441
+ if (!task || !task.resultPath || !node_fs_1.default.existsSync(task.resultPath)) {
442
+ failBound("result-missing");
443
+ continue;
444
+ }
445
+ const resultRaw = node_fs_1.default.readFileSync(task.resultPath, "utf8");
446
+ if ((0, execution_backend_1.sha256)(resultRaw) !== bound.resultDigest) {
447
+ failBound("result-digest-mismatch");
448
+ continue;
449
+ }
450
+ if (!reportSectionEmbedsResult(reportMd, bound.taskId, resultRaw.trim())) {
451
+ failBound("report-result-mismatch");
452
+ }
453
+ }
376
454
  if (options.extractReportTo && reportContent !== undefined) {
377
455
  reportExtractedTo = node_path_1.default.resolve(options.extractReportTo);
378
456
  node_fs_1.default.writeFileSync(reportExtractedTo, reportContent);
@@ -389,6 +467,23 @@ function verifyReportBundle(archivePath, options = {}) {
389
467
  const strictShortfall = Boolean(options.strictSignatures) && signaturesChecked > 0 && !trustKey;
390
468
  if (strictShortfall)
391
469
  failedChecks.push({ name: "signatures", code: "signature-key-required" });
470
+ // Trust level + requireSignatures — closes the prior fail-open. "signed" means the
471
+ // agent's SIGNED findings are present and unaltered: at least one result-COVERING
472
+ // signature re-verified against a key, none failed, and each signed result is
473
+ // faithfully embedded in report.md (the forward cross-check held). A usage-only
474
+ // (4-field) signature, an unverifiable one (no key), or a tampered signed finding
475
+ // all yield "unsigned". requireSignatures refuses "unsigned".
476
+ //
477
+ // SCOPE (honest): "signed" attests the SIGNED findings, NOT that the report is
478
+ // exhaustively signed. CW holds no key to sign the rendered report and the ledger
479
+ // chain is self-recomputable, so the report MAY carry additional unsigned content
480
+ // (prose, or extra sections), and a determined re-chainer can OMIT a signed
481
+ // finding. Verify findings against the signed results; full report-completeness
482
+ // needs an external anchor. See report-verifiable-bundle.7.md / trust-model.md.
483
+ const trustLevel = signaturesResultBound > 0 && signaturesFailed === 0 && reportFindingsOk ? "signed" : "unsigned";
484
+ const unsignedShortfall = Boolean(options.requireSignatures) && trustLevel === "unsigned";
485
+ if (unsignedShortfall)
486
+ failedChecks.push({ name: "signatures", code: "signatures-required" });
392
487
  // Extraction was requested but could not be fulfilled (no report.md in the bundle,
393
488
  // or the write failed): fail closed rather than silently green a missing artifact —
394
489
  // otherwise `report bundle <run> --extract-report r.md && send r.md` would ship
@@ -401,7 +496,14 @@ function verifyReportBundle(archivePath, options = {}) {
401
496
  schemaVersion: 1,
402
497
  archivePath,
403
498
  runId: inspect.runId,
404
- ok: inspect.ok && telemetryVerified && trustAuditVerified && signaturesFailed === 0 && !strictShortfall && !extractShortfall,
499
+ ok: inspect.ok &&
500
+ telemetryVerified &&
501
+ trustAuditVerified &&
502
+ signaturesFailed === 0 &&
503
+ reportFindingsOk &&
504
+ !strictShortfall &&
505
+ !extractShortfall &&
506
+ !unsignedShortfall,
405
507
  archiveOk: inspect.ok,
406
508
  telemetryVerified,
407
509
  trustAuditVerified,
@@ -410,6 +512,8 @@ function verifyReportBundle(archivePath, options = {}) {
410
512
  signaturesChecked,
411
513
  signaturesReverified,
412
514
  signaturesFailed,
515
+ trustLevel,
516
+ reportFindingsVerified: reportFindingsOk,
413
517
  reportExtractedTo,
414
518
  failedChecks
415
519
  };
@@ -164,10 +164,20 @@ function linkStateNodes(parent, child) {
164
164
  ];
165
165
  }
166
166
  function appendRunNode(run, node) {
167
- const nodes = run.nodes || [];
167
+ // Mutate run.nodes in place rather than rebuilding the whole array on every
168
+ // append. The old code did `nodes.map(...)` / `[...nodes, node]` per call, so a
169
+ // run that appends N nodes allocated arrays of size 1..N — O(N^2) churn + GC on
170
+ // a hot path (every dispatch/result/blackboard node). In place: O(1) per append
171
+ // for the common new-node case, and the resulting array — content AND order — is
172
+ // byte-identical to before (push appends at the end, replace keeps the slot), so
173
+ // the persisted state.json is unchanged. appendRunNode is the only writer of
174
+ // run.nodes, so the array reference stays stable across a run.
175
+ const nodes = run.nodes || (run.nodes = []);
168
176
  const index = nodes.findIndex((candidate) => candidate.id === node.id);
169
- const nextNodes = index >= 0 ? nodes.map((candidate) => (candidate.id === node.id ? node : candidate)) : [...nodes, node];
170
- run.nodes = nextNodes;
177
+ if (index >= 0)
178
+ nodes[index] = node;
179
+ else
180
+ nodes.push(node);
171
181
  writeRunNode(run, node);
172
182
  return node;
173
183
  }
package/dist/state.js CHANGED
@@ -19,6 +19,7 @@ exports.realResolve = realResolve;
19
19
  exports.isContainedPath = isContainedPath;
20
20
  exports.withFileLock = withFileLock;
21
21
  exports.safeFileName = safeFileName;
22
+ exports.assertSafeRunId = assertSafeRunId;
22
23
  exports.hashArtifactFile = hashArtifactFile;
23
24
  const node_fs_1 = __importDefault(require("node:fs"));
24
25
  const node_path_1 = __importDefault(require("node:path"));
@@ -293,6 +294,26 @@ function withFileLock(targetPath, fn) {
293
294
  function safeFileName(value) {
294
295
  return String(value).replace(/[^a-zA-Z0-9_.:-]+/g, "_");
295
296
  }
297
+ /** Refuse a run id that is not a single safe path segment, so an id taken from
298
+ * an untrusted source (an imported run archive / bundle) can never escape the
299
+ * runs directory via a separator or a `..`/`.` component (path traversal). A
300
+ * real run id is `${workflowId}-${stamp}-${suffix}` (createRunId), and a
301
+ * workflow id is alnum-bounded [a-z0-9.-] (validateWorkflowId) — all within
302
+ * [A-Za-z0-9._-]. Because the charset already forbids any separator, the whole
303
+ * id is ONE path component, so the only values that could traverse are the
304
+ * components `.` and `..` themselves; an embedded `..` (e.g. a workflow id like
305
+ * `v1..2`) is a safe directory name and is allowed. A separator, an absolute
306
+ * path, or an empty value is refused, fail-closed. Returns the id on success so
307
+ * callers can use it inline. */
308
+ function assertSafeRunId(value, context = "run id") {
309
+ if (typeof value !== "string" || value.length === 0) {
310
+ throw new Error(`Invalid ${context}: expected a non-empty string`);
311
+ }
312
+ if (!/^[A-Za-z0-9._-]+$/.test(value) || value === "." || value === "..") {
313
+ throw new Error(`Unsafe ${context}: ${JSON.stringify(value)} must be a single path segment ([A-Za-z0-9._-], not '.' or '..')`);
314
+ }
315
+ return value;
316
+ }
296
317
  /** Compute and set SHA256 + sizeBytes on a StateArtifact from its file path
297
318
  * (v0.1.73). Fails silently when the file doesn't exist — does not throw. */
298
319
  function hashArtifactFile(artifact) {
@@ -53,7 +53,11 @@ function canonicalTelemetryPayload(usage, ctx) {
53
53
  usage: usage ?? null,
54
54
  runId: ctx.runId,
55
55
  taskId: ctx.taskId,
56
- promptDigest: ctx.promptDigest
56
+ promptDigest: ctx.promptDigest,
57
+ // Present ONLY when the signer covered the result. Omitting the key keeps the
58
+ // canonical bytes byte-identical to the original 4-field payload, so every
59
+ // pre-result-coverage signature still verifies (POLA / back-compat).
60
+ ...(ctx.resultDigest !== undefined ? { resultDigest: ctx.resultDigest } : {})
57
61
  });
58
62
  }
59
63
  function messageOf(error) {
@@ -110,16 +114,26 @@ function verifyTelemetryAttestation(usage, signatureB64, trustPublicKeyPem, ctx)
110
114
  catch {
111
115
  return { status: "unattested", reason: "signature is not valid base64" };
112
116
  }
113
- const payload = Buffer.from(canonicalTelemetryPayload(usage, ctx), "utf8");
117
+ const matches = (c) => node_crypto_1.default.verify(null, Buffer.from(canonicalTelemetryPayload(usage, c), "utf8"), publicKey, signature);
114
118
  let ok = false;
119
+ let coversResult = false;
115
120
  try {
116
- ok = node_crypto_1.default.verify(null, payload, publicKey, signature);
121
+ ok = matches(ctx);
122
+ // A match on the first arm (which carries resultDigest when CW has one) means
123
+ // the signature covered the result — the findings — not just the usage.
124
+ coversResult = ok && ctx.resultDigest !== undefined;
125
+ // Back-compat: a signer that predates result coverage signed only the 4-field
126
+ // payload, so on a miss retry WITHOUT resultDigest. A NEW signer who covered
127
+ // the result fails BOTH arms when the result is edited (its resultDigest no
128
+ // longer matches), so result tampering is still caught.
129
+ if (!ok && ctx.resultDigest !== undefined)
130
+ ok = matches({ ...ctx, resultDigest: undefined });
117
131
  }
118
132
  catch (error) {
119
133
  return { status: "unattested", reason: `verification error: ${messageOf(error)}` };
120
134
  }
121
135
  return ok
122
- ? { status: "attested", algorithm: "ed25519" }
136
+ ? { status: "attested", algorithm: "ed25519", ...(coversResult ? { coversResult: true } : {}) }
123
137
  : { status: "unattested", reason: "signature does not match reported usage (tampered, replayed, or wrong key)" };
124
138
  }
125
139
  /** Resolve a trust key from a config value that is EITHER an inline PEM or a path
@@ -160,6 +174,7 @@ function stableDigest(value) {
160
174
  }
161
175
  function verifyTelemetrySignatures(records, trustPublicKeyPem) {
162
176
  const checks = [];
177
+ const resultBound = [];
163
178
  let checked = 0;
164
179
  let reverified = 0;
165
180
  let failed = 0;
@@ -190,11 +205,20 @@ function verifyTelemetrySignatures(records, trustPublicKeyPem) {
190
205
  const result = verifyTelemetryAttestation(record.reportedUsage, record.usageSignature, trustPublicKeyPem, {
191
206
  runId: record.runId,
192
207
  taskId: record.taskId,
193
- promptDigest: record.promptDigest
208
+ promptDigest: record.promptDigest,
209
+ // Result-bound records carry the signed digest; verifyTelemetryAttestation
210
+ // reconstructs the 5-field payload (and falls back to 4-field for old records).
211
+ resultDigest: record.resultDigest
194
212
  });
195
213
  if (result.status === "attested") {
196
214
  reverified += 1;
197
215
  checks.push({ name: `signature[${i}]`, pass: true });
216
+ // Only a signature that actually COVERED the result digest anchors it — a
217
+ // 4-field fallback (coversResult false) must not let an injected resultDigest
218
+ // be trusted downstream.
219
+ if (result.coversResult && record.resultDigest) {
220
+ resultBound.push({ taskId: record.taskId, resultDigest: record.resultDigest });
221
+ }
198
222
  }
199
223
  else {
200
224
  failed += 1;
@@ -207,5 +231,5 @@ function verifyTelemetrySignatures(records, trustPublicKeyPem) {
207
231
  });
208
232
  }
209
233
  }
210
- return { keyProvided: Boolean(trustPublicKeyPem), checked, reverified, failed, checks };
234
+ return { keyProvided: Boolean(trustPublicKeyPem), checked, reverified, failed, resultBound, checks };
211
235
  }
@@ -6,13 +6,17 @@
6
6
  // Fully hermetic + deterministic: generates an EPHEMERAL ed25519 keypair, builds
7
7
  // a REAL telemetry ledger through the production append API (appendTelemetryAttestation
8
8
  // + signTelemetry — byte-identical to what a live attested run writes), then
9
- // demonstrates BOTH tamper-evidence layers catching a forgery:
9
+ // demonstrates all THREE tamper-evidence layers catching a forgery:
10
10
  // A) LEDGER layer — flip a recorded verdict on disk (unattested -> attested, the
11
11
  // canonical "forge a green record" attack) -> verifyTelemetryLedger recomputes
12
12
  // every hash independently, so the edited record's hash mismatches AND every
13
13
  // record after it breaks the chain (cascade).
14
14
  // B) SIGNATURE layer — inflate the reported tokens but keep the original ed25519
15
15
  // signature -> verifyTelemetryAttestation rejects it ("signature does not match").
16
+ // C) RESULT layer — edit the agent's SIGNED FINDING after signing. The executor
17
+ // binds sha256(result.md) into the ed25519 payload; CW re-derives the digest
18
+ // from the result file at verify time, so an edited finding no longer joins the
19
+ // signature. This is the threat a usage-only signature never covered.
16
20
  //
17
21
  // No model, no network, no API key, no second repo — runs in a private tmpdir.
18
22
  var __importDefault = (this && this.__importDefault) || function (mod) {
@@ -161,6 +165,30 @@ function runTamperDemo(options = {}) {
161
165
  after: { verified: sigCheck.status === "attested", detail: sigCheck.reason || sigCheck.status },
162
166
  failures: sigCheck.status === "attested" ? [] : [`signature: ${sigCheck.reason}`]
163
167
  });
168
+ // 3c. RESULT layer — the REAL threat the result-bound signature closes: the agent's
169
+ // signed FINDING is edited after signing. The executor binds sha256(result.md)
170
+ // into the ed25519 payload (a 5-field, result-COVERING signature); CW re-derives
171
+ // the digest from the result file at verify time. An edited finding hashes to a
172
+ // different digest, so the signature joins neither the 5-field nor the 4-field
173
+ // payload and the verify rejects it. A usage-only signature never covered this.
174
+ const findingSigned = "Finding: auth bypass in login() — severity HIGH";
175
+ const findingEdited = findingSigned.replace("HIGH", "LOW");
176
+ const resultCtx = { runId, taskId: target.hop.taskId, promptDigest: target.hop.promptDigest };
177
+ const resultSignature = (0, telemetry_attestation_1.signTelemetry)(target.hop.usage, privateKeyPem, { ...resultCtx, resultDigest: (0, execution_backend_1.sha256)(findingSigned) });
178
+ // Baseline: the signed finding verifies, and the signature actually COVERED the result.
179
+ const resultClean = (0, telemetry_attestation_1.verifyTelemetryAttestation)(target.hop.usage, resultSignature, publicKeyPem, { ...resultCtx, resultDigest: (0, execution_backend_1.sha256)(findingSigned) });
180
+ // Tamper: CW re-derives the digest from the EDITED finding -> no longer the signed digest.
181
+ const resultCheck = (0, telemetry_attestation_1.verifyTelemetryAttestation)(target.hop.usage, resultSignature, publicKeyPem, { ...resultCtx, resultDigest: (0, execution_backend_1.sha256)(findingEdited) });
182
+ layers.push({
183
+ layer: "result",
184
+ tamper: `edited the agent's signed finding "severity HIGH" -> "severity LOW" after it was signed`,
185
+ before: {
186
+ verified: resultClean.status === "attested" && resultClean.coversResult === true,
187
+ detail: `the signed finding verifies — ed25519 binds usage + sha256(result), result-covering`
188
+ },
189
+ after: { verified: resultCheck.status === "attested", detail: resultCheck.reason || resultCheck.status },
190
+ failures: resultCheck.status === "attested" ? [] : [`result: ${resultCheck.reason}`]
191
+ });
164
192
  if (!options.keepDir && !options.dir)
165
193
  node_fs_1.default.rmSync(runDir, { recursive: true, force: true });
166
194
  const proven = baseline.ledgerVerified &&
@@ -94,6 +94,9 @@ function recordHashInput(record) {
94
94
  reportedUsageDigest: record.reportedUsageDigest,
95
95
  ...(record.reportedUsage !== undefined ? { reportedUsage: record.reportedUsage } : {}),
96
96
  usageSignature: record.usageSignature || null,
97
+ // Chain-bind resultDigest only when present, so a usage-only record's hash is
98
+ // byte-identical to a pre-result-coverage one (back-compat with old ledgers).
99
+ ...(record.resultDigest !== undefined ? { resultDigest: record.resultDigest } : {}),
97
100
  attestation: record.attestation,
98
101
  attestationReason: record.attestationReason || null,
99
102
  prevHash: record.prevHash
@@ -131,6 +134,9 @@ function appendTelemetryAttestation(run, input) {
131
134
  // signature can be independently re-verified offline at `telemetry verify`.
132
135
  ...(input.reportedUsage ? { reportedUsage: input.reportedUsage } : {}),
133
136
  usageSignature: input.usageSignature,
137
+ // Present only for a result-bound signature, so usage-only records are
138
+ // byte-identical (and their recordHash unchanged) — back-compat.
139
+ ...(input.resultDigest ? { resultDigest: input.resultDigest } : {}),
134
140
  attestation: input.attestation,
135
141
  attestationReason: input.attestationReason,
136
142
  prevHash
package/dist/version.js CHANGED
@@ -1,7 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.MIN_SUPPORTED_RUN_STATE_SCHEMA_VERSION = exports.LEGACY_RUN_STATE_SCHEMA_VERSION = exports.CURRENT_RUN_STATE_SCHEMA_VERSION = exports.WORKFLOW_APP_SCHEMA_VERSION = exports.CURRENT_COOL_WORKFLOW_VERSION = void 0;
4
- exports.CURRENT_COOL_WORKFLOW_VERSION = "0.1.87";
4
+ exports.CURRENT_COOL_WORKFLOW_VERSION = "0.1.89";
5
5
  exports.WORKFLOW_APP_SCHEMA_VERSION = 1;
6
6
  exports.CURRENT_RUN_STATE_SCHEMA_VERSION = 1;
7
7
  exports.LEGACY_RUN_STATE_SCHEMA_VERSION = 0;
@@ -24,7 +24,12 @@ function attestWorkerDelegation(accept, deps) {
24
24
  // usage. Absent/invalid signature => `unattested`/`absent`, surfaced loudly,
25
25
  // NEVER silently recorded as trusted.
26
26
  const telemetry = options.agentDelegation
27
- ? (0, telemetry_attestation_1.verifyTelemetryAttestation)(options.agentDelegation.reportedUsage, options.agentDelegation.usageSignature, (0, telemetry_attestation_1.resolveTrustPublicKey)(options.agentDelegation.usageTrustPublicKey), { runId: run.id, taskId: task.id, promptDigest: options.agentDelegation.promptDigest })
27
+ ? (0, telemetry_attestation_1.verifyTelemetryAttestation)(options.agentDelegation.reportedUsage, options.agentDelegation.usageSignature, (0, telemetry_attestation_1.resolveTrustPublicKey)(options.agentDelegation.usageTrustPublicKey),
28
+ // resultDigest binds the agent's findings into the signature: CW recomputes
29
+ // the digest from the accepted result (the SAME raw bytes the executor
30
+ // signed) so a result edited after signing fails verification. A signer
31
+ // that did not cover the result still verifies (verifier back-compat).
32
+ { runId: run.id, taskId: task.id, promptDigest: options.agentDelegation.promptDigest, resultDigest: (0, execution_backend_1.sha256)(rawResult) })
28
33
  : undefined;
29
34
  // Track 1 fail-closed (Decision 2 — OPT-IN, off by default). When the operator
30
35
  // requires attested telemetry, a delegated hop whose verdict is not `attested`
@@ -59,7 +64,7 @@ function attestWorkerDelegation(accept, deps) {
59
64
  * event can cross-link the record hash), then emits the worker.agent-delegation
60
65
  * audit event. No-op for non-agent hops. */
61
66
  function recordWorkerDelegationLedger(accept, delegation) {
62
- const { agentDelegation } = delegation;
67
+ const { agentDelegation, telemetry } = delegation;
63
68
  // The agent-hop attestation event — hung off worker.output, alongside
64
69
  // worker.backend. Recorded in trust-audit/provenance, NEVER in node evidence.
65
70
  if (!agentDelegation)
@@ -76,6 +81,11 @@ function recordWorkerDelegationLedger(accept, delegation) {
76
81
  promptDigest: agentDelegation.promptDigest,
77
82
  reportedUsage: agentDelegation.reportedUsage,
78
83
  usageSignature: agentDelegation.usageSignature,
84
+ // Store the signed result digest ONLY when the signature actually covered
85
+ // it, so the offline re-verifier (telemetry verify --pubkey / report verify)
86
+ // can reconstruct the 5-field payload. A usage-only signature stores none
87
+ // (its record stays byte-identical to a pre-result-coverage one).
88
+ resultDigest: telemetry?.coversResult ? agentDelegation.resultDigest : undefined,
79
89
  attestation: agentDelegation.usageAttestation,
80
90
  attestationReason: agentDelegation.usageAttestationReason
81
91
  })