@stigmer/runner 3.1.6 → 3.1.8

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 (115) hide show
  1. package/README.md +1 -1
  2. package/dist/.build-fingerprint +1 -1
  3. package/dist/activities/discover-mcp-server.js +24 -0
  4. package/dist/activities/discover-mcp-server.js.map +1 -1
  5. package/dist/activities/execute-cursor/__test-utils__/cursor-hook-harness.d.ts +5 -2
  6. package/dist/activities/execute-cursor/__test-utils__/cursor-hook-harness.js.map +1 -1
  7. package/dist/activities/execute-cursor/approval-state.d.ts +58 -10
  8. package/dist/activities/execute-cursor/approval-state.js +37 -8
  9. package/dist/activities/execute-cursor/approval-state.js.map +1 -1
  10. package/dist/activities/execute-cursor/env-resolver.js +7 -1
  11. package/dist/activities/execute-cursor/env-resolver.js.map +1 -1
  12. package/dist/activities/execute-cursor/hook-script.d.ts +16 -3
  13. package/dist/activities/execute-cursor/hook-script.js +66 -21
  14. package/dist/activities/execute-cursor/hook-script.js.map +1 -1
  15. package/dist/activities/execute-cursor/index.js +59 -0
  16. package/dist/activities/execute-cursor/index.js.map +1 -1
  17. package/dist/activities/execute-cursor/message-translator.d.ts +52 -0
  18. package/dist/activities/execute-cursor/message-translator.js +107 -1
  19. package/dist/activities/execute-cursor/message-translator.js.map +1 -1
  20. package/dist/activities/execute-cursor/turn-boundary.d.ts +25 -3
  21. package/dist/activities/execute-cursor/turn-boundary.js +46 -8
  22. package/dist/activities/execute-cursor/turn-boundary.js.map +1 -1
  23. package/dist/activities/execute-cursor/turn-stream.js +6 -2
  24. package/dist/activities/execute-cursor/turn-stream.js.map +1 -1
  25. package/dist/activities/execute-cursor/workspace-setup.d.ts +16 -2
  26. package/dist/activities/execute-cursor/workspace-setup.js +22 -12
  27. package/dist/activities/execute-cursor/workspace-setup.js.map +1 -1
  28. package/dist/activities/execute-deep-agent/environment.js +7 -1
  29. package/dist/activities/execute-deep-agent/environment.js.map +1 -1
  30. package/dist/activities/execute-deep-agent/hitl.d.ts +23 -14
  31. package/dist/activities/execute-deep-agent/hitl.js +42 -37
  32. package/dist/activities/execute-deep-agent/hitl.js.map +1 -1
  33. package/dist/activities/execute-deep-agent/index.js +35 -26
  34. package/dist/activities/execute-deep-agent/index.js.map +1 -1
  35. package/dist/activities/execute-deep-agent/setup.d.ts +7 -0
  36. package/dist/activities/execute-deep-agent/setup.js +9 -2
  37. package/dist/activities/execute-deep-agent/setup.js.map +1 -1
  38. package/dist/activities/hydrate-workflow-execution.js +8 -1
  39. package/dist/activities/hydrate-workflow-execution.js.map +1 -1
  40. package/dist/client/stigmer-client.d.ts +69 -1
  41. package/dist/client/stigmer-client.js +108 -4
  42. package/dist/client/stigmer-client.js.map +1 -1
  43. package/dist/client/token-claims.d.ts +22 -0
  44. package/dist/client/token-claims.js +40 -0
  45. package/dist/client/token-claims.js.map +1 -0
  46. package/dist/config.d.ts +11 -1
  47. package/dist/config.js +5 -1
  48. package/dist/config.js.map +1 -1
  49. package/dist/middleware/approval-gate.js +9 -2
  50. package/dist/middleware/approval-gate.js.map +1 -1
  51. package/dist/runner-manager.d.ts +4 -2
  52. package/dist/runner-manager.js +20 -7
  53. package/dist/runner-manager.js.map +1 -1
  54. package/dist/runner.d.ts +2 -2
  55. package/dist/runner.js +1 -1
  56. package/dist/shared/checkpointer/factory.d.ts +11 -7
  57. package/dist/shared/checkpointer/factory.js +21 -8
  58. package/dist/shared/checkpointer/factory.js.map +1 -1
  59. package/dist/shared/checkpointer/sqlite-saver.d.ts +54 -0
  60. package/dist/shared/checkpointer/sqlite-saver.js +272 -0
  61. package/dist/shared/checkpointer/sqlite-saver.js.map +1 -0
  62. package/dist/shared/checkpointer/types.d.ts +8 -4
  63. package/dist/shared/mcp-oauth-detect.d.ts +53 -0
  64. package/dist/shared/mcp-oauth-detect.js +99 -0
  65. package/dist/shared/mcp-oauth-detect.js.map +1 -0
  66. package/dist/shared/tool-row.js +14 -1
  67. package/dist/shared/tool-row.js.map +1 -1
  68. package/dist/shared/workspace/platform-dir.d.ts +18 -0
  69. package/dist/shared/workspace/platform-dir.js +23 -0
  70. package/dist/shared/workspace/platform-dir.js.map +1 -1
  71. package/package.json +3 -3
  72. package/src/__test-utils__/mock-client.ts +3 -0
  73. package/src/activities/__tests__/hydrate-workflow-execution.test.ts +2 -0
  74. package/src/activities/discover-mcp-server.ts +26 -0
  75. package/src/activities/execute-cursor/__test-utils__/cursor-hook-harness.ts +5 -3
  76. package/src/activities/execute-cursor/__tests__/hitl-ledger.test.ts +272 -14
  77. package/src/activities/execute-cursor/__tests__/hook-script.test.ts +72 -16
  78. package/src/activities/execute-cursor/__tests__/message-translator.test.ts +65 -0
  79. package/src/activities/execute-cursor/__tests__/turn-boundary.test.ts +128 -1
  80. package/src/activities/execute-cursor/__tests__/workspace-setup.test.ts +95 -0
  81. package/src/activities/execute-cursor/approval-state.ts +75 -11
  82. package/src/activities/execute-cursor/env-resolver.ts +8 -1
  83. package/src/activities/execute-cursor/hook-script.ts +66 -21
  84. package/src/activities/execute-cursor/index.ts +61 -0
  85. package/src/activities/execute-cursor/message-translator.ts +124 -1
  86. package/src/activities/execute-cursor/turn-boundary.ts +79 -6
  87. package/src/activities/execute-cursor/turn-stream.ts +6 -2
  88. package/src/activities/execute-cursor/workspace-setup.ts +42 -14
  89. package/src/activities/execute-deep-agent/__tests__/environment.test.ts +5 -0
  90. package/src/activities/execute-deep-agent/__tests__/hitl-reject.test.ts +314 -0
  91. package/src/activities/execute-deep-agent/__tests__/hitl.test.ts +64 -46
  92. package/src/activities/execute-deep-agent/environment.ts +8 -1
  93. package/src/activities/execute-deep-agent/hitl.ts +43 -45
  94. package/src/activities/execute-deep-agent/index.ts +36 -27
  95. package/src/activities/execute-deep-agent/setup.ts +15 -2
  96. package/src/activities/hydrate-workflow-execution.ts +9 -1
  97. package/src/client/__tests__/stigmer-client.test.ts +239 -2
  98. package/src/client/__tests__/token-claims.test.ts +45 -0
  99. package/src/client/stigmer-client.ts +152 -3
  100. package/src/client/token-claims.ts +42 -0
  101. package/src/config.ts +16 -3
  102. package/src/middleware/__tests__/approval-gate.test.ts +8 -1
  103. package/src/middleware/approval-gate.ts +9 -2
  104. package/src/runner-manager.ts +22 -8
  105. package/src/runner.ts +3 -3
  106. package/src/shared/__tests__/mcp-oauth-detect.test.ts +147 -0
  107. package/src/shared/__tests__/tool-row.test.ts +35 -0
  108. package/src/shared/checkpointer/__tests__/factory.test.ts +27 -1
  109. package/src/shared/checkpointer/__tests__/sqlite-saver.test.ts +212 -0
  110. package/src/shared/checkpointer/factory.ts +25 -8
  111. package/src/shared/checkpointer/sqlite-saver.ts +384 -0
  112. package/src/shared/checkpointer/types.ts +8 -4
  113. package/src/shared/mcp-oauth-detect.ts +112 -0
  114. package/src/shared/tool-row.ts +14 -1
  115. package/src/shared/workspace/platform-dir.ts +25 -0
@@ -52,8 +52,17 @@ import {
52
52
  buildApprovalGrants,
53
53
  grantToken,
54
54
  watchDenialLedger,
55
+ approvalDenials,
56
+ denialKindOf,
55
57
  } from "../approval-state.js";
56
- import { reconcileDeniedToolCalls, clearProvisionalPostDenialNarration, collapseRedundantToolCallTwins, toolCallIdentityToken } from "../message-translator.js";
58
+ import {
59
+ reconcileDeniedToolCalls,
60
+ clearProvisionalPostDenialNarration,
61
+ collapseRedundantToolCallTwins,
62
+ toolCallIdentityToken,
63
+ detectUnattributedHookBlocks,
64
+ HOOK_BLOCK_ERROR_MARKERS,
65
+ } from "../message-translator.js";
57
66
  import { mockWorkspaceBackend } from "../../../__test-utils__/mock-workspace.js";
58
67
  import type { WorkspaceBackend } from "../../../shared/workspace/types.js";
59
68
  import { generateHookScript } from "../hook-script.js";
@@ -162,6 +171,54 @@ describe("denial ledger reset/read", () => {
162
171
  expect(entries[1].input).toBeUndefined();
163
172
  expect(entries[2].input).toBeUndefined();
164
173
  });
174
+
175
+ it("parses the kind tag, defaulting an absent/garbled kind to approval", async () => {
176
+ const ws = makeWorkspace();
177
+ await resetDenialLedger(ws);
178
+ await writeFile(
179
+ denialLedgerPath(ws),
180
+ // 1) pre-kind format (no kind field), 2) explicit approval, 3) secret,
181
+ // 4) garbled kind (non-string), 5) unknown future kind.
182
+ `{"toolName":"Write","token":"${grantToken("write", "a.txt")}"}\n` +
183
+ `{"toolName":"Shell","token":"${grantToken("shell", "rm x")}","kind":"approval"}\n` +
184
+ `{"toolName":"Write","token":"${grantToken("write", ".env")}","kind":"secret"}\n` +
185
+ `{"toolName":"Write","token":"${grantToken("write", "b.txt")}","kind":42}\n` +
186
+ `{"toolName":"Write","token":"${grantToken("write", "c.txt")}","kind":"quarantine"}\n`,
187
+ "utf-8",
188
+ );
189
+
190
+ const entries = await readDenialLedger(ws);
191
+ expect(entries.map((e) => denialKindOf(e))).toEqual([
192
+ "approval", // absent → the pre-kind format
193
+ "approval",
194
+ "secret",
195
+ "approval", // garbled (non-string) degrades to approval
196
+ "quarantine", // an unknown kind is preserved, not coerced
197
+ ]);
198
+ });
199
+
200
+ it("approvalDenials selects only the entries allowed to pause the run", async () => {
201
+ const ws = makeWorkspace();
202
+ await resetDenialLedger(ws);
203
+ await writeFile(
204
+ denialLedgerPath(ws),
205
+ `{"toolName":"Write","token":"${grantToken("write", "a.txt")}","kind":"approval"}\n` +
206
+ `{"toolName":"Write","token":"${grantToken("write", ".env")}","kind":"secret"}\n` +
207
+ `{"toolName":"Write","token":"${grantToken("write", "app.log")}","kind":"capture-error"}\n` +
208
+ `{"toolName":"Write","token":"${grantToken("write", "b.txt")}","kind":"fail-closed"}\n` +
209
+ // Pre-kind format counts as approval; an unknown kind must NOT pause.
210
+ `{"toolName":"Shell","token":"${grantToken("shell", "rm x")}"}\n` +
211
+ `{"toolName":"Write","token":"${grantToken("write", "c.txt")}","kind":"quarantine"}\n`,
212
+ "utf-8",
213
+ );
214
+
215
+ const entries = await readDenialLedger(ws);
216
+ expect(entries).toHaveLength(6);
217
+ expect(approvalDenials(entries).map((e) => e.token)).toEqual([
218
+ grantToken("write", "a.txt"),
219
+ grantToken("shell", "rm x"),
220
+ ]);
221
+ });
165
222
  });
166
223
 
167
224
  describe("reconcileDeniedToolCalls", () => {
@@ -1415,7 +1472,7 @@ describe("first-denial stop contract", () => {
1415
1472
  | {
1416
1473
  kind: "tool";
1417
1474
  tool: ToolCall;
1418
- denyToken?: { name: string; token: string };
1475
+ denyToken?: { name: string; token: string; kind?: string };
1419
1476
  // When the hook's ledger append lands relative to the loop's read for
1420
1477
  // THIS event. "before-read" is the common case (the hook adjudicates as
1421
1478
  // the tool_call event surfaces); "after-read" reproduces the production
@@ -1426,9 +1483,11 @@ describe("first-denial stop contract", () => {
1426
1483
 
1427
1484
  // Faithfully mirrors the index.ts loop rule: process each event, then read
1428
1485
  // the denial ledger when the fs watcher flagged it dirty OR the event is a
1429
- // tool_call (the backstop) — the instant the ledger is non-empty, cancel the
1430
- // run and stop consuming the stream. The watcher is simulated by flipping the
1431
- // dirty flag as the append happens (the real fs.watch notification).
1486
+ // tool_call (the backstop) — the instant an APPROVAL-kind denial appears,
1487
+ // cancel the run and stop consuming the stream (non-approval kinds a secret
1488
+ // hard-block, a capture error are attributable but never pause). The
1489
+ // watcher is simulated by flipping the dirty flag as the append happens (the
1490
+ // real fs.watch notification).
1432
1491
  async function runTurnWithFirstDenialStop(
1433
1492
  hitlDir: string,
1434
1493
  events: SimEvent[],
@@ -1438,12 +1497,13 @@ describe("first-denial stop contract", () => {
1438
1497
  let cancelled = false;
1439
1498
  let consumed = 0;
1440
1499
  let ledgerDirty = false;
1441
- let pendingAppend: { name: string; token: string } | undefined;
1500
+ let pendingAppend: { name: string; token: string; kind?: string } | undefined;
1442
1501
 
1443
- const appendDenial = async (deny: { name: string; token: string }) => {
1502
+ const appendDenial = async (deny: { name: string; token: string; kind?: string }) => {
1503
+ const kindField = deny.kind ? `,"kind":"${deny.kind}"` : "";
1444
1504
  await writeFile(
1445
1505
  denialLedgerPath(hitlDir),
1446
- `{"toolName":"${deny.name}","token":"${deny.token}"}\n`,
1506
+ `{"toolName":"${deny.name}","token":"${deny.token}"${kindField}}\n`,
1447
1507
  { flag: "a" },
1448
1508
  );
1449
1509
  ledgerDirty = true;
@@ -1470,7 +1530,7 @@ describe("first-denial stop contract", () => {
1470
1530
  }
1471
1531
  if (ledgerDirty || ev.kind === "tool") {
1472
1532
  ledgerDirty = false;
1473
- const denials = await readDenialLedger(hitlDir);
1533
+ const denials = approvalDenials(await readDenialLedger(hitlDir));
1474
1534
  if (denials.length > 0) {
1475
1535
  cancelled = true;
1476
1536
  break;
@@ -1527,6 +1587,30 @@ describe("first-denial stop contract", () => {
1527
1587
  expect(consumed).toBe(3); // the whole turn is consumed
1528
1588
  });
1529
1589
 
1590
+ it("does NOT stop on a non-approval denial (secret hard-block: the agent continues)", async () => {
1591
+ // The kinded ledger now records a secret hard-block for attribution (issue
1592
+ // #205), and the append fires the fs watcher — but the model was told to
1593
+ // move on, so the run must NOT be cancelled and no approval may surface.
1594
+ const ws = makeWorkspace();
1595
+ const secretWrite = toolCall({
1596
+ id: "c1", name: "edit", status: ToolCallStatus.TOOL_CALL_FAILED,
1597
+ error: "blocked by a hook", args: { path: ".env" },
1598
+ });
1599
+ const read = toolCall({ id: "c2", name: "read", status: ToolCallStatus.TOOL_CALL_COMPLETED, args: { path: "a.txt" } });
1600
+
1601
+ const { cancelled, consumed } = await runTurnWithFirstDenialStop(ws, [
1602
+ { kind: "tool", tool: secretWrite, denyToken: { name: "Write", token: grantToken("write", ".env"), kind: "secret" } },
1603
+ { kind: "text", content: "That file is protected — moving on." },
1604
+ { kind: "tool", tool: read },
1605
+ ]);
1606
+
1607
+ expect(cancelled).toBe(false);
1608
+ expect(consumed).toBe(3); // the whole turn is consumed
1609
+ // And the boundary reconcile manufactures no gate from it either.
1610
+ const denied = await reconcileDeniedToolCalls([], await readDenialLedger(ws));
1611
+ expect(denied).toEqual([]);
1612
+ });
1613
+
1530
1614
  it("stops on the NEXT event of any type when the denial lands after the tool_call read (the watcher rule)", async () => {
1531
1615
  // The production race (aex_01kwj07f7g23c3wp9sn8496z5g): the hook's ledger
1532
1616
  // append landed just after the gated tool's own tool_call-event read, so the
@@ -1594,8 +1678,173 @@ describe("watchDenialLedger", () => {
1594
1678
  });
1595
1679
  });
1596
1680
 
1681
+ // Issue #205: a FOREIGN `.cursor/hooks.json` hook (preserved by the merge) can
1682
+ // deny the runner's tools without writing our denial ledger; the SDK has no
1683
+ // structured "denied by hook" signal, so the only stream trace is Cursor's
1684
+ // generic hook-block error text on the FAILED call. The detector matches this
1685
+ // turn's hook-blocked FAILED rows against the FULL ledger (all kinds) and
1686
+ // reports the leftovers — which the activity surfaces as EXECUTION_FAILED
1687
+ // instead of the silent completion the issue describes. These pin the whole
1688
+ // attribution matrix.
1689
+ describe("detectUnattributedHookBlocks (issue #205)", () => {
1690
+ // Cursor's generic replacement text for a hook deny (Phase 0 ground truth).
1691
+ const HOOK_BLOCK = "Command blocked by a hook. Check the hooks configuration.";
1692
+
1693
+ const failedShell = (id: string, command: string, error: string = HOOK_BLOCK) =>
1694
+ toolCall({ id, name: "shell", status: ToolCallStatus.TOOL_CALL_FAILED, error, args: { command } });
1695
+ const failedEdit = (id: string, path: string, error: string = HOOK_BLOCK) =>
1696
+ toolCall({ id, name: "edit", status: ToolCallStatus.TOOL_CALL_FAILED, error, args: { path } });
1697
+
1698
+ it("detects a hook-blocked call when the ledger is EMPTY (the reproduce-first #205 shape)", () => {
1699
+ const edit = failedEdit("f1", "notes.md");
1700
+ const blocks = detectUnattributedHookBlocks([aiMessageWith([edit])], 0, []);
1701
+ expect(blocks).toEqual([{ toolCallId: "f1", toolName: "edit", error: HOOK_BLOCK }]);
1702
+ });
1703
+
1704
+ it("matches the marker case-insensitively (single-sourced marker family)", () => {
1705
+ expect(HOOK_BLOCK_ERROR_MARKERS).toContain("blocked by a hook");
1706
+ const edit = failedEdit("f1", "notes.md", "Tool was Blocked By A Hook.");
1707
+ expect(detectUnattributedHookBlocks([aiMessageWith([edit])], 0, [])).toHaveLength(1);
1708
+ });
1709
+
1710
+ it("attributes our own denial by exact token — not reported", () => {
1711
+ const shell = failedShell("s1", "rm -rf build");
1712
+ const ledger = [{ toolName: "Shell", token: toolCallIdentityToken(shell), kind: "approval" }];
1713
+ expect(detectUnattributedHookBlocks([aiMessageWith([shell])], 0, ledger)).toEqual([]);
1714
+ });
1715
+
1716
+ it("attributes a secret hard-block via its kind:'secret' entry — not reported", () => {
1717
+ // The agent continues past a secret block; its FAILED row must never be
1718
+ // misdiagnosed as a foreign hook (the false positive the kinded ledger
1719
+ // exists to prevent).
1720
+ const secretWrite = failedEdit("e1", ".env");
1721
+ const ledger = [{ toolName: "Write", token: toolCallIdentityToken(secretWrite), kind: "secret" }];
1722
+ expect(detectUnattributedHookBlocks([aiMessageWith([secretWrite])], 0, ledger)).toEqual([]);
1723
+ });
1724
+
1725
+ it("attributes a capture-error deny via its kind:'capture-error' entry — not reported", () => {
1726
+ const write = failedEdit("e1", "app.log");
1727
+ const ledger = [{ toolName: "Write", token: toolCallIdentityToken(write), kind: "capture-error" }];
1728
+ expect(detectUnattributedHookBlocks([aiMessageWith([write])], 0, ledger)).toEqual([]);
1729
+ });
1730
+
1731
+ it("attributes across abs-vs-rel path drift via the workspace-normalized salient", () => {
1732
+ // The hook records the RAW absolute path Cursor handed it; the stream row
1733
+ // carries the relative one. Exact tokens differ; the normalized (category,
1734
+ // path) must still attribute — the same drift reconcile's second pass heals.
1735
+ const edit = failedEdit("e1", "notes.md");
1736
+ const ledger = [{ toolName: "Write", token: grantToken("write", `${ROOT}/notes.md`), kind: "secret" }];
1737
+ // Without a workspace root the raw tokens cannot be reconciled…
1738
+ expect(detectUnattributedHookBlocks([aiMessageWith([edit])], 0, ledger)).toHaveLength(1);
1739
+ // …with it, the normalized pass attributes the block to our own entry.
1740
+ expect(detectUnattributedHookBlocks([aiMessageWith([edit])], 0, ledger, ROOT)).toEqual([]);
1741
+ });
1742
+
1743
+ it("treats a fail-closed turn as attributed — the broken gate denied everything it saw", () => {
1744
+ // With the state file missing, EVERY gated tool was denied by OUR OWN
1745
+ // (broken) hook; per-call correlation is meaningless and none of it is a
1746
+ // foreign hook. The boundary logs the broken-gate condition separately.
1747
+ const shell = failedShell("s1", "make build");
1748
+ const edit = failedEdit("e1", "other.txt");
1749
+ const ledger = [{ toolName: "Shell", token: grantToken("shell", "make build"), kind: "fail-closed" }];
1750
+ expect(
1751
+ detectUnattributedHookBlocks([aiMessageWith([shell, edit])], 0, ledger),
1752
+ ).toEqual([]);
1753
+ });
1754
+
1755
+ it("scopes to THIS turn only — seeded prior-turn rows never re-trigger", () => {
1756
+ const priorTurnRow = failedShell("old-1", "yarn deploy");
1757
+ const thisTurnRow = failedShell("new-1", "yarn build");
1758
+ const messages = [aiMessageWith([priorTurnRow]), aiMessageWith([thisTurnRow])];
1759
+ const blocks = detectUnattributedHookBlocks(messages, 1, []);
1760
+ expect(blocks.map((b) => b.toolCallId)).toEqual(["new-1"]);
1761
+ });
1762
+
1763
+ it("mixed turn: reports the foreign block even when our own anchor denial is present", () => {
1764
+ // The caller pauses on the anchor (a pausing turn is never silent) and only
1765
+ // logs this — but the detector itself must still see the foreign block.
1766
+ const ourEdit = failedEdit("ours", "gated.txt");
1767
+ const foreignShell = failedShell("theirs", "terraform apply");
1768
+ const ledger = [{ toolName: "Write", token: toolCallIdentityToken(ourEdit), kind: "approval" }];
1769
+ const blocks = detectUnattributedHookBlocks(
1770
+ [aiMessageWith([ourEdit, foreignShell])], 0, ledger,
1771
+ );
1772
+ expect(blocks.map((b) => b.toolCallId)).toEqual(["theirs"]);
1773
+ });
1774
+
1775
+ it("ignores ordinary tool failures without hook-block text", () => {
1776
+ const failed = failedShell("s1", "make test", "exit code 2: tests failed");
1777
+ const noError = toolCall({ id: "s2", name: "shell", status: ToolCallStatus.TOOL_CALL_FAILED, error: "", args: { command: "ls" } });
1778
+ expect(detectUnattributedHookBlocks([aiMessageWith([failed, noError])], 0, [])).toEqual([]);
1779
+ });
1780
+
1781
+ it("ignores non-FAILED rows even when their output mentions the marker", () => {
1782
+ // Adversarial: the marker appearing in a tool's legitimate RESULT (e.g. the
1783
+ // agent grepped these very words) must never trigger — only a FAILED row's
1784
+ // error text reads as a hook block.
1785
+ const completed = toolCall({
1786
+ id: "c1", name: "shell", status: ToolCallStatus.TOOL_CALL_COMPLETED,
1787
+ result: 'found "blocked by a hook" in docs/hitl.md', args: { command: "grep -r hook docs" },
1788
+ });
1789
+ const waiting = toolCall({
1790
+ id: "w1", name: "edit", status: ToolCallStatus.TOOL_CALL_WAITING_APPROVAL, args: { path: "gated.txt" },
1791
+ });
1792
+ expect(detectUnattributedHookBlocks([aiMessageWith([completed, waiting])], 0, [])).toEqual([]);
1793
+ });
1794
+
1795
+ it("reports every distinct foreign block in the turn", () => {
1796
+ const one = failedEdit("f1", "a.txt");
1797
+ const two = failedShell("f2", "rm -rf build");
1798
+ const blocks = detectUnattributedHookBlocks([aiMessageWith([one]), aiMessageWith([two])], 0, []);
1799
+ expect(blocks.map((b) => b.toolCallId)).toEqual(["f1", "f2"]);
1800
+ });
1801
+ });
1802
+
1803
+ // The reconcile itself must never manufacture a pause from a non-approval
1804
+ // denial, regardless of caller discipline (defense-in-depth for the kind split).
1805
+ describe("reconcileDeniedToolCalls — non-approval kinds never gate", () => {
1806
+ it("returns no gates and leaves the transcript untouched for secret/capture-error/fail-closed entries", async () => {
1807
+ const secretRow = toolCall({
1808
+ id: "e1", name: "edit", status: ToolCallStatus.TOOL_CALL_FAILED,
1809
+ error: "blocked by a hook", args: { path: ".env" },
1810
+ });
1811
+ const messages = [aiMessageWith([secretRow])];
1812
+ const denied = await reconcileDeniedToolCalls(messages, [
1813
+ { toolName: "Write", token: toolCallIdentityToken(secretRow), kind: "secret" },
1814
+ { toolName: "Write", token: grantToken("write", "app.log"), kind: "capture-error" },
1815
+ { toolName: "Shell", token: grantToken("shell", "make"), kind: "fail-closed" },
1816
+ ]);
1817
+ expect(denied).toEqual([]);
1818
+ // No overlay, no collapse, no synthesized placeholder appended.
1819
+ expect(secretRow.status).toBe(ToolCallStatus.TOOL_CALL_FAILED);
1820
+ expect(messages).toHaveLength(1);
1821
+ expect(messages[0].toolCalls).toHaveLength(1);
1822
+ });
1823
+
1824
+ it("gates the approval entry while ignoring a non-approval sibling in the same ledger", async () => {
1825
+ const gated = toolCall({
1826
+ id: "g1", name: "edit", status: ToolCallStatus.TOOL_CALL_FAILED,
1827
+ error: "blocked by a hook", args: { path: "gated.txt" },
1828
+ });
1829
+ const secretRow = toolCall({
1830
+ id: "e1", name: "edit", status: ToolCallStatus.TOOL_CALL_FAILED,
1831
+ error: "blocked by a hook", args: { path: ".env" },
1832
+ });
1833
+ const messages = [aiMessageWith([gated, secretRow])];
1834
+ const denied = await reconcileDeniedToolCalls(messages, [
1835
+ // Ledger order puts the secret FIRST: the anchor must still be the first
1836
+ // APPROVAL entry, never a non-approval kind.
1837
+ { toolName: "Write", token: toolCallIdentityToken(secretRow), kind: "secret" },
1838
+ { toolName: "Write", token: toolCallIdentityToken(gated), kind: "approval" },
1839
+ ]);
1840
+ expect(denied).toHaveLength(1);
1841
+ expect(gated.status).toBe(ToolCallStatus.TOOL_CALL_WAITING_APPROVAL);
1842
+ expect(secretRow.status).toBe(ToolCallStatus.TOOL_CALL_FAILED);
1843
+ });
1844
+ });
1845
+
1597
1846
  describe("generateHookScript ledger wiring", () => {
1598
- it("bakes the active-turn pointer, derives the ledger from it, and records denials in both deny branches", () => {
1847
+ it("bakes the active-turn pointer, derives the ledger from it, and records on EVERY deny arm", () => {
1599
1848
  const script = generateHookScript("/gate/active.json");
1600
1849
 
1601
1850
  // Stable script: it bakes the pointer path (not per-turn state/ledger), and
@@ -1604,9 +1853,18 @@ describe("generateHookScript ledger wiring", () => {
1604
1853
  expect(script).not.toContain('LEDGER_FILE="/');
1605
1854
  expect(script).toContain('>> "$LEDGER_FILE"');
1606
1855
  expect(script).toContain("record_denial()");
1607
- // One definition + a call in the gated-built-in branch + a call in the MCP
1608
- // branch = 3 occurrences.
1609
- const occurrences = script.split("record_denial").length - 1;
1610
- expect(occurrences).toBeGreaterThanOrEqual(3);
1856
+ // Every deny arm records a kind-tagged entry (issue #205 attribution):
1857
+ // the approval gates (built-in + MCP), both secret hard-block arms, the
1858
+ // CAS staging-error fail-closed deny, and the missing-state-file failsafe.
1859
+ expect(script).toContain('record_denial "$PRIMARY_TOKEN" "approval"');
1860
+ expect(script).toContain('record_denial "$MCP_TOKEN" "approval"');
1861
+ expect(script.split('"secret"').length - 1).toBeGreaterThanOrEqual(2);
1862
+ expect(script).toContain('record_denial "$PRIMARY_TOKEN" "capture-error"');
1863
+ expect(script).toContain('record_denial "$PRIMARY_TOKEN" "fail-closed"');
1864
+ expect(script).toContain('record_denial "$MCP_TOKEN" "fail-closed"');
1865
+ // The hoisting guard: under `set -euo pipefail` with our failClosed
1866
+ // hooks.json registration, a call-before-define would emit no decision and
1867
+ // block EVERY tool — so the definition must precede the first call.
1868
+ expect(script.indexOf("record_denial()")).toBeLessThan(script.indexOf('record_denial "'));
1611
1869
  });
1612
1870
  });
@@ -58,6 +58,8 @@ d("generated approval hook (preToolUse + beforeMCPExecution)", () => {
58
58
  // Byte-identical to the runner's primaryToken: content-exact when the hook
59
59
  // can compute an edit digest (the write), else the coarse grantToken.
60
60
  expect(ledger[0].token).toBe(primaryToken(category, salient, contentDigest(args)));
61
+ // The normal gate is the APPROVAL kind — the only kind that pauses.
62
+ expect(ledger[0].kind).toBe("approval");
61
63
  }
62
64
  });
63
65
 
@@ -88,9 +90,17 @@ d("generated approval hook (preToolUse + beforeMCPExecution)", () => {
88
90
  // through the Cursor substrate adapter alongside the deep-agent substrate. See
89
91
  // src/__tests__/approval-gateway-contract.test.ts.
90
92
 
91
- it("fails closed (deny) when the state file is missing", () => {
93
+ it("fails closed (deny) when the state file is missing, recorded as kind fail-closed", () => {
92
94
  const h = setup({ noStateFile: true });
93
95
  expect(h.decide(hookWrite("/x/a.txt")).permission).toBe("deny");
96
+ // The broken-gate deny is ATTRIBUTABLE (issue #205): recorded under the
97
+ // primary token with kind "fail-closed" and — like every non-approval
98
+ // kind — content-free (no input field, DD-26).
99
+ const ledger = h.ledger();
100
+ expect(ledger).toHaveLength(1);
101
+ expect(ledger[0].kind).toBe("fail-closed");
102
+ expect(ledger[0].token).toBe(primaryToken("write", "/x/a.txt", contentDigest({ content: "x" })));
103
+ expect(ledger[0]).not.toHaveProperty("input");
94
104
  });
95
105
 
96
106
  // MCP gating runs ONLY on the beforeMCPExecution event (preToolUse does not
@@ -105,6 +115,7 @@ d("generated approval hook (preToolUse + beforeMCPExecution)", () => {
105
115
  expect(res.raw).toContain("Approve click?");
106
116
  expect(h.ledger()).toHaveLength(1);
107
117
  expect(h.ledger()[0].token).toBe(grantToken("click", ""));
118
+ expect(h.ledger()[0].kind).toBe("approval");
108
119
  });
109
120
 
110
121
  it("denial agent_message frames approval as automatic and never trains ask-in-prose", () => {
@@ -149,9 +160,16 @@ d("generated approval hook (preToolUse + beforeMCPExecution)", () => {
149
160
  expect(h.decide(hookMcp("click")).permission).toBe("allow");
150
161
  });
151
162
 
152
- it("fails closed (deny) when the state file is missing", () => {
163
+ it("fails closed (deny) when the state file is missing, recorded as kind fail-closed", () => {
153
164
  const h = setup({ noStateFile: true });
154
165
  expect(h.decide(hookMcp("click")).permission).toBe("deny");
166
+ // Attributable under the MCP name-token (the identity the stream row
167
+ // computes for an MCP call), content-free like every non-approval kind.
168
+ const ledger = h.ledger();
169
+ expect(ledger).toHaveLength(1);
170
+ expect(ledger[0].kind).toBe("fail-closed");
171
+ expect(ledger[0].token).toBe(grantToken("click", ""));
172
+ expect(ledger[0]).not.toHaveProperty("input");
155
173
  });
156
174
 
157
175
  it("does NOT gate the same MCP tool delivered on preToolUse (no double-gating)", () => {
@@ -422,11 +440,14 @@ d("generated approval hook (preToolUse + beforeMCPExecution)", () => {
422
440
  // Deny-gate secret hard-block (DD-26 #2): with no capture substrate for a write
423
441
  // (capture off — the classic deny-gate — or captureIgnored off in a git-no-
424
442
  // storage workspace) a secret-like WRITE must NOT surface its content for
425
- // approval. The hook hard-blocks it with the security message and records NO
426
- // ledger entry, so it never becomes an approvable WAITING row; a non-secret
427
- // write still deny-gates, and a delete (content-less) stays gated.
443
+ // approval. The hook hard-blocks it with the security message and records a
444
+ // kind:"secret" ledger entry ATTRIBUTABLE (issue #205: the runner must know
445
+ // this block was ours) but non-pausing (approvalDenials filters it out, so it
446
+ // never becomes an approvable WAITING row) and content-free (DD-26: only the
447
+ // identity token, never the proposed bytes). A non-secret write still
448
+ // deny-gates as kind:"approval", and a delete (content-less) stays gated.
428
449
  describe("deny-gate secret hard-block (DD-26 #2)", () => {
429
- it("hard-blocks a secret-like write and records NO ledger entry", () => {
450
+ it("hard-blocks a secret-like write; records an attributable, content-free secret entry", () => {
430
451
  const h = setup({}); // captureMode off — the classic deny-gate
431
452
  const dec = h.decide(hookWrite(".env", "API_KEY=abc"));
432
453
  expect(dec.permission).toBe("deny");
@@ -434,7 +455,12 @@ d("generated approval hook (preToolUse + beforeMCPExecution)", () => {
434
455
  // SECRET_BLOCKED, not APPROVAL_REQUIRED: the model is told to move on.
435
456
  expect(dec.raw.toLowerCase()).toContain("nothing was written");
436
457
  expect(dec.raw).not.toContain("submitted to the user for approval");
437
- expect(h.ledger()).toEqual([]); // never recorded → never approvable
458
+ const ledger = h.ledger();
459
+ expect(ledger).toHaveLength(1);
460
+ expect(ledger[0].kind).toBe("secret");
461
+ // DD-26 on the raw ledger bytes: no input field, no trace of the content.
462
+ expect(ledger[0]).not.toHaveProperty("input");
463
+ expect(JSON.stringify(ledger[0])).not.toContain("API_KEY");
438
464
  });
439
465
 
440
466
  it("hard-blocks a secret-like edit (path-fragment match)", () => {
@@ -442,7 +468,7 @@ d("generated approval hook (preToolUse + beforeMCPExecution)", () => {
442
468
  const dec = h.decide(hookEdit(".ssh/id_rsa"));
443
469
  expect(dec.permission).toBe("deny");
444
470
  expect(dec.raw).toContain("blocked for security");
445
- expect(h.ledger()).toEqual([]);
471
+ expect(h.ledger().map((e) => e.kind)).toEqual(["secret"]);
446
472
  });
447
473
 
448
474
  it("still deny-gates a NON-secret write and records its content for approval", () => {
@@ -451,6 +477,7 @@ d("generated approval hook (preToolUse + beforeMCPExecution)", () => {
451
477
  expect(dec.permission).toBe("deny");
452
478
  expect(dec.raw).toContain("submitted to the user for approval"); // APPROVAL_REQUIRED
453
479
  expect(h.ledger().map((e) => e.toolName)).toContain("Write");
480
+ expect(h.ledger().map((e) => e.kind)).toEqual(["approval"]);
454
481
  });
455
482
 
456
483
  it("a write-category lease does NOT bypass the secret block", () => {
@@ -459,7 +486,8 @@ d("generated approval hook (preToolUse + beforeMCPExecution)", () => {
459
486
  const dec = h.decide(hookWrite(".env", "SECRET")); // secret is still hard-blocked
460
487
  expect(dec.permission).toBe("deny");
461
488
  expect(dec.raw).toContain("blocked for security");
462
- expect(h.ledger()).toEqual([]);
489
+ expect(h.ledger().map((e) => e.kind)).toEqual(["secret"]);
490
+ expect(JSON.stringify(h.ledger())).not.toContain("SECRET");
463
491
  });
464
492
 
465
493
  it("hard-blocks a secret write in a git workspace with captureIgnored off (no storage)", () => {
@@ -467,7 +495,7 @@ d("generated approval hook (preToolUse + beforeMCPExecution)", () => {
467
495
  const dec = h.decide(hookWrite(".env", "API_KEY=abc"));
468
496
  expect(dec.permission).toBe("deny");
469
497
  expect(dec.raw).toContain("blocked for security");
470
- expect(h.ledger()).toEqual([]);
498
+ expect(h.ledger().map((e) => e.kind)).toEqual(["secret"]);
471
499
  });
472
500
 
473
501
  it("does NOT hard-block a secret DELETE (no content; stays deny-gated)", () => {
@@ -517,19 +545,43 @@ d("generated approval hook (preToolUse + beforeMCPExecution)", () => {
517
545
  expect(Buffer.from(obs.captured[0].before!).toString("utf8")).toBe("ORIGINAL");
518
546
  });
519
547
 
520
- it("hard-blocks a secret-like gitignored write and records NO denial-ledger entry", async () => {
548
+ it("hard-blocks a secret-like gitignored write; records a non-pausing secret entry", async () => {
521
549
  const h = setup({ captureMode: true, captureIgnored: true, gitignored: [".env"] });
522
550
  const d = h.decide(hookWrite(".env", "API_KEY=abc"));
523
551
  expect(d.permission).toBe("deny");
524
552
  expect(d.raw).toContain("blocked for security");
525
- // A secret is NOT approvable: it must never enter the denial ledger (which
526
- // drives WAITING_APPROVAL) it surfaces as DIFF_UNREVIEWABLE instead.
527
- expect(h.ledger()).toEqual([]);
553
+ // A secret is NOT approvable: its kind:"secret" entry attributes the block
554
+ // to our own gate (issue #205) but is filtered out of the pause path — it
555
+ // surfaces as DIFF_UNREVIEWABLE instead, and carries no content (DD-26).
556
+ const ledger = h.ledger();
557
+ expect(ledger.map((e) => e.kind)).toEqual(["secret"]);
558
+ expect(ledger[0]).not.toHaveProperty("input");
559
+ expect(JSON.stringify(ledger[0])).not.toContain("API_KEY");
528
560
  const obs = await h.observations();
529
561
  expect(obs.captured).toEqual([]);
530
562
  expect(obs.secretPaths).toEqual([".env"]);
531
563
  });
532
564
 
565
+ it("a CAS staging error fails closed and records a content-free capture-error entry", async () => {
566
+ const h = setup({ captureMode: true, captureIgnored: true, gitignored: ["*.log"] });
567
+ // Force the staging script's "error" result: the sidecar dir path is
568
+ // occupied by a regular FILE, so mkdir fails and the blob write throws.
569
+ // (The harness keeps state/ledger/sidecar in .cursor/hooks — its hitlDir
570
+ // analog; see setupCursorHookHarness.)
571
+ writeFileSync(join(h.root, ".cursor", "hooks", "cas-observations"), "not a dir", "utf-8");
572
+ const d = h.decide(hookWrite("app.log", "payload"));
573
+ expect(d.permission).toBe("deny");
574
+ // Denied with the approval message (fail-closed, today's deny-gate text)
575
+ // but recorded as kind "capture-error": attributable, NON-pausing (there
576
+ // is no approval that could make the write reviewable), and content-free
577
+ // (the staging error means secret classification may never have run).
578
+ expect(d.raw).toContain("submitted to the user for approval");
579
+ const ledger = h.ledger();
580
+ expect(ledger.map((e) => e.kind)).toEqual(["capture-error"]);
581
+ expect(ledger[0]).not.toHaveProperty("input");
582
+ expect(JSON.stringify(ledger[0])).not.toContain("payload");
583
+ });
584
+
533
585
  it("captures under auto_approve_all too (capture is a turn property, not authorization)", async () => {
534
586
  const h = setup({
535
587
  captureMode: true,
@@ -594,12 +646,14 @@ d("generated approval hook (preToolUse + beforeMCPExecution)", () => {
594
646
  expect(obs.captured[0].before).toBeNull();
595
647
  });
596
648
 
597
- it("hard-blocks a secret-like write and records NO denial-ledger entry", async () => {
649
+ it("hard-blocks a secret-like write; records a non-pausing, content-free secret entry", async () => {
598
650
  const h = setup({ captureMode: true, captureIgnored: true, gitWorkspace: false });
599
651
  const dcn = h.decide(hookWrite(".env", "API_KEY=abc"));
600
652
  expect(dcn.permission).toBe("deny");
601
653
  expect(dcn.raw).toContain("blocked for security");
602
- expect(h.ledger()).toEqual([]);
654
+ const ledger = h.ledger();
655
+ expect(ledger.map((e) => e.kind)).toEqual(["secret"]);
656
+ expect(JSON.stringify(ledger[0])).not.toContain("API_KEY");
603
657
  const obs = await h.observations();
604
658
  expect(obs.captured).toEqual([]);
605
659
  expect(obs.secretPaths).toEqual([".env"]);
@@ -657,5 +711,7 @@ d("generated approval hook (preToolUse + beforeMCPExecution)", () => {
657
711
  const ledger = readFileSync(ledgerPath, "utf-8").split("\n").filter(Boolean).map((l) => JSON.parse(l));
658
712
  expect(ledger).toHaveLength(1);
659
713
  expect(ledger[0].token).toBe(grantToken("write", "/x/a.txt"));
714
+ // record_denial is pure bash, so the kind tag survives the Node outage too.
715
+ expect(ledger[0].kind).toBe("approval");
660
716
  });
661
717
  });
@@ -867,6 +867,71 @@ describe("MessageAccumulator tool call status transitions", () => {
867
867
  });
868
868
  });
869
869
 
870
+ // The recovery supersede rule (issue #207): TOOL_CALL_INTERRUPTED is
871
+ // server-authored when an execution terminalizes with the call in flight. If
872
+ // that FAILED execution is recovered, the harness checkpoint can re-execute
873
+ // the call under its original call id — so INTERRUPTED is the one settled
874
+ // status the monotonic guard lets live execution evidence supersede in
875
+ // place. Every other terminal status stays immovable.
876
+ describe("recovery supersede of TOOL_CALL_INTERRUPTED", () => {
877
+ function seededInterruptedTranscript(callId: string): AgentMessage[] {
878
+ return [
879
+ create(AgentMessageSchema, {
880
+ type: MessageType.MESSAGE_AI,
881
+ content: "Running a command.",
882
+ toolCalls: [
883
+ create(ToolCallSchema, {
884
+ id: callId,
885
+ name: "Shell",
886
+ status: ToolCallStatus.TOOL_CALL_INTERRUPTED,
887
+ completedAt: "2026-07-08T00:00:00Z",
888
+ }),
889
+ ],
890
+ }),
891
+ ];
892
+ }
893
+
894
+ it("a replayed lifecycle event advances a seeded INTERRUPTED row to its true outcome", () => {
895
+ const messages = seededInterruptedTranscript("tc-replayed");
896
+ const acc = new MessageAccumulator(messages);
897
+
898
+ acc.processEvent(toolCallEvent("tc-replayed", "Shell", "running"));
899
+ expect(findToolCallById(messages, "tc-replayed")!.status).toBe(
900
+ ToolCallStatus.TOOL_CALL_RUNNING,
901
+ );
902
+
903
+ acc.processEvent(toolCallEvent("tc-replayed", "Shell", "completed", "run-1", { result: "OK" }));
904
+
905
+ expect(countToolCallsWithId(messages, "tc-replayed")).toBe(1);
906
+ const tc = findToolCallById(messages, "tc-replayed")!;
907
+ expect(tc.status).toBe(ToolCallStatus.TOOL_CALL_COMPLETED);
908
+ expect(tc.result).toBe("OK");
909
+ });
910
+
911
+ it("a genuinely terminal seeded row (COMPLETED) still refuses to regress", () => {
912
+ const messages: AgentMessage[] = [
913
+ create(AgentMessageSchema, {
914
+ type: MessageType.MESSAGE_AI,
915
+ toolCalls: [
916
+ create(ToolCallSchema, {
917
+ id: "tc-done",
918
+ name: "Shell",
919
+ status: ToolCallStatus.TOOL_CALL_COMPLETED,
920
+ result: "final",
921
+ }),
922
+ ],
923
+ }),
924
+ ];
925
+ const acc = new MessageAccumulator(messages);
926
+
927
+ acc.processEvent(toolCallEvent("tc-done", "Shell", "running"));
928
+
929
+ expect(findToolCallById(messages, "tc-done")!.status).toBe(
930
+ ToolCallStatus.TOOL_CALL_COMPLETED,
931
+ );
932
+ });
933
+ });
934
+
870
935
  // Issue #179: the live thinking/tool-call trace was starved because the Cursor
871
936
  // loop's persist cadence had no force-flush for tool-call lifecycle. The
872
937
  // accumulator's isDirty signal is now the force-flush source: it MUST fire on