@stigmer/runner 3.1.7 → 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 (110) hide show
  1. package/README.md +1 -1
  2. package/dist/.build-fingerprint +1 -1
  3. package/dist/activities/discover-mcp-server.js +1 -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/tool-row.js +14 -1
  64. package/dist/shared/tool-row.js.map +1 -1
  65. package/dist/shared/workspace/platform-dir.d.ts +18 -0
  66. package/dist/shared/workspace/platform-dir.js +23 -0
  67. package/dist/shared/workspace/platform-dir.js.map +1 -1
  68. package/package.json +3 -3
  69. package/src/__test-utils__/mock-client.ts +3 -0
  70. package/src/activities/__tests__/hydrate-workflow-execution.test.ts +2 -0
  71. package/src/activities/discover-mcp-server.ts +1 -0
  72. package/src/activities/execute-cursor/__test-utils__/cursor-hook-harness.ts +5 -3
  73. package/src/activities/execute-cursor/__tests__/hitl-ledger.test.ts +272 -14
  74. package/src/activities/execute-cursor/__tests__/hook-script.test.ts +72 -16
  75. package/src/activities/execute-cursor/__tests__/message-translator.test.ts +65 -0
  76. package/src/activities/execute-cursor/__tests__/turn-boundary.test.ts +128 -1
  77. package/src/activities/execute-cursor/__tests__/workspace-setup.test.ts +95 -0
  78. package/src/activities/execute-cursor/approval-state.ts +75 -11
  79. package/src/activities/execute-cursor/env-resolver.ts +8 -1
  80. package/src/activities/execute-cursor/hook-script.ts +66 -21
  81. package/src/activities/execute-cursor/index.ts +61 -0
  82. package/src/activities/execute-cursor/message-translator.ts +124 -1
  83. package/src/activities/execute-cursor/turn-boundary.ts +79 -6
  84. package/src/activities/execute-cursor/turn-stream.ts +6 -2
  85. package/src/activities/execute-cursor/workspace-setup.ts +42 -14
  86. package/src/activities/execute-deep-agent/__tests__/environment.test.ts +5 -0
  87. package/src/activities/execute-deep-agent/__tests__/hitl-reject.test.ts +314 -0
  88. package/src/activities/execute-deep-agent/__tests__/hitl.test.ts +64 -46
  89. package/src/activities/execute-deep-agent/environment.ts +8 -1
  90. package/src/activities/execute-deep-agent/hitl.ts +43 -45
  91. package/src/activities/execute-deep-agent/index.ts +36 -27
  92. package/src/activities/execute-deep-agent/setup.ts +15 -2
  93. package/src/activities/hydrate-workflow-execution.ts +9 -1
  94. package/src/client/__tests__/stigmer-client.test.ts +239 -2
  95. package/src/client/__tests__/token-claims.test.ts +45 -0
  96. package/src/client/stigmer-client.ts +152 -3
  97. package/src/client/token-claims.ts +42 -0
  98. package/src/config.ts +16 -3
  99. package/src/middleware/__tests__/approval-gate.test.ts +8 -1
  100. package/src/middleware/approval-gate.ts +9 -2
  101. package/src/runner-manager.ts +22 -8
  102. package/src/runner.ts +3 -3
  103. package/src/shared/__tests__/tool-row.test.ts +35 -0
  104. package/src/shared/checkpointer/__tests__/factory.test.ts +27 -1
  105. package/src/shared/checkpointer/__tests__/sqlite-saver.test.ts +212 -0
  106. package/src/shared/checkpointer/factory.ts +25 -8
  107. package/src/shared/checkpointer/sqlite-saver.ts +384 -0
  108. package/src/shared/checkpointer/types.ts +8 -4
  109. package/src/shared/tool-row.ts +14 -1
  110. package/src/shared/workspace/platform-dir.ts +25 -0
@@ -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
@@ -17,7 +17,7 @@ import { tmpdir } from "node:os";
17
17
  import { join } from "node:path";
18
18
  import { promisify } from "node:util";
19
19
  import { afterEach, beforeEach, describe, expect, it } from "vitest";
20
- import { create } from "@bufbuild/protobuf";
20
+ import { create, type JsonObject } from "@bufbuild/protobuf";
21
21
  import {
22
22
  AgentMessageSchema,
23
23
  ToolCallSchema,
@@ -238,6 +238,133 @@ describe("runTurnBoundary", () => {
238
238
  expect(shellCall.status).toBe(ToolCallStatus.TOOL_CALL_WAITING_APPROVAL);
239
239
  });
240
240
 
241
+ // ── Issue #205: unattributed hook blocks and the kind split ────────────────
242
+
243
+ /** A FAILED tool call carrying Cursor's generic hook-block error text. */
244
+ function hookBlockedCall(id: string, name: string, args: JsonObject) {
245
+ return create(ToolCallSchema, {
246
+ id,
247
+ name,
248
+ status: ToolCallStatus.TOOL_CALL_FAILED,
249
+ error: "Command blocked by a hook.",
250
+ args,
251
+ });
252
+ }
253
+
254
+ /** Append one entry to this turn's denial ledger. */
255
+ async function appendLedgerEntry(entry: Record<string, unknown>): Promise<void> {
256
+ await writeFile(denialLedgerPath(hitlDir), JSON.stringify(entry) + "\n", { flag: "a" });
257
+ }
258
+
259
+ it("reports a foreign hook block on a non-pausing turn (the #205 silent-complete shape)", async () => {
260
+ const status = newStatus();
261
+ const baseline = await captureBaselineToLedger({
262
+ status, gitRoot: repo, executionId: EXEC_ID, changeSetId: CHANGE_SET_ID,
263
+ });
264
+
265
+ // A foreign .cursor/hooks.json hook denied the write: Cursor stamped its
266
+ // generic error, our ledger stayed empty, the tree is untouched. Before
267
+ // this fix the boundary reported a clean non-waiting turn and the run
268
+ // completed silently with the work undone.
269
+ const blocked = hookBlockedCall("tc-foreign", "edit", { path: "notes.md" });
270
+ status.messages.push(
271
+ create(AgentMessageSchema, { type: MessageType.MESSAGE_AI, toolCalls: [blocked] }),
272
+ );
273
+
274
+ const result = await runTurnBoundary(
275
+ boundaryOpts(status, baseline, { foreignGatingHooks: ["./team-policy.sh"] }),
276
+ );
277
+
278
+ expect(result.waiting).toBe(false);
279
+ expect(result.unattributedHookBlocks).toEqual([
280
+ { toolCallId: "tc-foreign", toolName: "edit", error: "Command blocked by a hook." },
281
+ ]);
282
+ // The row itself is untouched — no gate was manufactured for a block no
283
+ // approval can lift; the caller fails the execution instead.
284
+ expect(blocked.status).toBe(ToolCallStatus.TOOL_CALL_FAILED);
285
+ });
286
+
287
+ it("attributes a secret hard-block (kind:'secret'): no pause, no false foreign-hook report", async () => {
288
+ const status = newStatus();
289
+ const baseline = await captureBaselineToLedger({
290
+ status, gitRoot: repo, executionId: EXEC_ID, changeSetId: CHANGE_SET_ID,
291
+ });
292
+
293
+ const secretRow = hookBlockedCall("tc-secret", "edit", { path: ".env" });
294
+ status.messages.push(
295
+ create(AgentMessageSchema, { type: MessageType.MESSAGE_AI, toolCalls: [secretRow] }),
296
+ );
297
+ await writeFile(
298
+ denialLedgerPath(hitlDir),
299
+ JSON.stringify({
300
+ toolName: "Write", token: toolCallIdentityToken(secretRow), kind: "secret",
301
+ }) + "\n",
302
+ "utf-8",
303
+ );
304
+
305
+ const result = await runTurnBoundary(boundaryOpts(status, baseline));
306
+
307
+ // Not a pause (the agent was told to move on), not a foreign block (our
308
+ // own kinded entry attributes it), and never a WAITING_APPROVAL overlay.
309
+ expect(result.waiting).toBe(false);
310
+ expect(result.deniedToolCallCount).toBe(0);
311
+ expect(result.unattributedHookBlocks).toEqual([]);
312
+ expect(secretRow.status).toBe(ToolCallStatus.TOOL_CALL_FAILED);
313
+ });
314
+
315
+ it("mixed turn: pauses on our anchor while still reporting the foreign block", async () => {
316
+ const status = newStatus();
317
+ const baseline = await captureBaselineToLedger({
318
+ status, gitRoot: repo, executionId: EXEC_ID, changeSetId: CHANGE_SET_ID,
319
+ });
320
+
321
+ const ourGated = create(ToolCallSchema, {
322
+ id: "tc-ours", name: "shell",
323
+ status: ToolCallStatus.TOOL_CALL_RUNNING,
324
+ args: { command: "rm -rf build" },
325
+ });
326
+ const foreign = hookBlockedCall("tc-theirs", "shell", { command: "terraform apply" });
327
+ status.messages.push(
328
+ create(AgentMessageSchema, { type: MessageType.MESSAGE_AI, toolCalls: [ourGated, foreign] }),
329
+ );
330
+ await appendLedgerEntry({ toolName: "shell", token: toolCallIdentityToken(ourGated), kind: "approval" });
331
+
332
+ const result = await runTurnBoundary(boundaryOpts(status, baseline));
333
+
334
+ // The pause wins (a pausing turn is never silent); the foreign block is
335
+ // still surfaced so the caller can log it next to the approval.
336
+ expect(result.waiting).toBe(true);
337
+ expect(ourGated.status).toBe(ToolCallStatus.TOOL_CALL_WAITING_APPROVAL);
338
+ expect(result.unattributedHookBlocks.map((b) => b.toolCallId)).toEqual(["tc-theirs"]);
339
+ });
340
+
341
+ it("excludes a kinded (secret) deny from the flowed-edit stamp (full-ledger deniedTokens)", async () => {
342
+ const status = newStatus();
343
+ const baseline = await captureBaselineToLedger({
344
+ status, gitRoot: repo, executionId: EXEC_ID, changeSetId: CHANGE_SET_ID,
345
+ });
346
+
347
+ // One edit genuinely flowed (tree changed + streamed row); one secret-like
348
+ // write was hard-blocked (FAILED row + kind:"secret" entry, tree untouched).
349
+ await write("notes.md", "original notes\nplus a real edit\n");
350
+ status.messages.push(streamedEdit("tc-flowed", "notes.md", "original notes\nplus a real edit\n"));
351
+ const secretRow = hookBlockedCall("tc-secret", "edit", { path: ".env" });
352
+ status.messages.push(
353
+ create(AgentMessageSchema, { type: MessageType.MESSAGE_AI, toolCalls: [secretRow] }),
354
+ );
355
+ await appendLedgerEntry({ toolName: "Write", token: toolCallIdentityToken(secretRow), kind: "secret" });
356
+
357
+ const result = await runTurnBoundary(boundaryOpts(status, baseline));
358
+
359
+ // The flowed edit is captured + stamped; the secret-blocked row is NOT
360
+ // stamped into a change set that does not contain its file — every ledger
361
+ // kind means "this action did not execute", so the stamp uses the FULL set.
362
+ expect(result.waiting).toBe(true);
363
+ expect(result.capturedChangeCount).toBe(1);
364
+ expect(status.messages[0].toolCalls[0].fileChangeSetId).toBe(CHANGE_SET_ID);
365
+ expect(secretRow.fileChangeSetId).toBe("");
366
+ });
367
+
241
368
  it("waits for the denial-stop cancel to settle before reading the ledger", async () => {
242
369
  const status = newStatus();
243
370
  const baseline = await captureBaselineToLedger({
@@ -216,6 +216,65 @@ describe("buildMergedConfig", () => {
216
216
  });
217
217
  });
218
218
 
219
+ // ── Issue #205: foreign gating hook reporting ────────────────────────────────
220
+ // The merge PRESERVES the user's own hooks (correct — never clobber their
221
+ // config), but Cursor runs every configured hook and a deny from any of them
222
+ // blocks the runner's tools without writing our denial ledger. buildMergedConfig
223
+ // therefore reports the foreign commands left on the GATING events so the
224
+ // runner can log the exposure and name the culprit when the turn boundary
225
+ // detects an unattributed hook block.
226
+ describe("buildMergedConfig — foreign gating hook reporting (issue #205)", () => {
227
+ const bothEvents = [
228
+ { event: "preToolUse", scriptPath: "/abs/hitl/stigmer-approval.sh" },
229
+ { event: "beforeMCPExecution", scriptPath: "/abs/hitl/stigmer-approval.sh" },
230
+ ];
231
+
232
+ it("reports no foreign hooks when no hooks.json exists", () => {
233
+ expect(buildMergedConfig(null, bothEvents).foreignGatingHooks).toEqual([]);
234
+ });
235
+
236
+ it("reports user commands on the gating events, ignoring non-gating events", () => {
237
+ const original = JSON.stringify({
238
+ version: 1,
239
+ hooks: {
240
+ preToolUse: [{ command: "./gate-writes.sh", failClosed: true }],
241
+ beforeMCPExecution: [{ command: "./gate-mcp.sh" }],
242
+ // postToolUse cannot deny a tool — it must not be reported.
243
+ postToolUse: [{ command: "./audit.sh" }],
244
+ },
245
+ });
246
+ const { foreignGatingHooks } = buildMergedConfig(original, bothEvents);
247
+ expect(foreignGatingHooks).toEqual(["./gate-writes.sh", "./gate-mcp.sh"]);
248
+ });
249
+
250
+ it("excludes Stigmer's own entries (current and legacy) from the report", () => {
251
+ const original = JSON.stringify({
252
+ version: 1,
253
+ hooks: {
254
+ preToolUse: [
255
+ { command: "/home/u/.stigmer/sessions/ses-1/hitl/stigmer-approval.sh" },
256
+ { command: ".cursor/hooks/stigmer-approval.sh" }, // pre-#173 legacy
257
+ { command: "./user.sh" },
258
+ ],
259
+ },
260
+ });
261
+ const { foreignGatingHooks } = buildMergedConfig(original, bothEvents);
262
+ expect(foreignGatingHooks).toEqual(["./user.sh"]);
263
+ });
264
+
265
+ it("ignores malformed entries without a string command", () => {
266
+ const original = JSON.stringify({
267
+ version: 1,
268
+ hooks: { preToolUse: [{ command: 42 }, { timeout: 5 }, null, "bare-string"] },
269
+ });
270
+ expect(buildMergedConfig(original, bothEvents).foreignGatingHooks).toEqual([]);
271
+ });
272
+
273
+ it("reports nothing for an unparseable hooks.json (replaced for the turn, so it cannot run)", () => {
274
+ expect(buildMergedConfig("{ not json", bothEvents).foreignGatingHooks).toEqual([]);
275
+ });
276
+ });
277
+
219
278
  describe("installHitlGate / removeHitlGate", () => {
220
279
  const approvalState = buildApprovalState(new Map(), false, new Set());
221
280
 
@@ -290,6 +349,42 @@ describe("installHitlGate / removeHitlGate", () => {
290
349
  expect(existsSync(join(workspaceRoot, ".cursor", "hooks"))).toBe(false);
291
350
  });
292
351
 
352
+ it("surfaces the preserved foreign gating hooks on the install handle", async () => {
353
+ const { workspaceRoot, hitlDir } = dirs();
354
+ const cursorDir = join(workspaceRoot, ".cursor");
355
+ mkdirSync(cursorDir, { recursive: true });
356
+ writeFileSync(
357
+ join(cursorDir, "hooks.json"),
358
+ JSON.stringify({
359
+ version: 1,
360
+ hooks: {
361
+ preToolUse: [{ command: "./gate-writes.sh", failClosed: true }],
362
+ postToolUse: [{ command: "./audit.sh" }],
363
+ },
364
+ }),
365
+ "utf-8",
366
+ );
367
+
368
+ const handle = await installHitlGate({
369
+ workspaceRoot, hitlDir, approvalState, runnerPid: process.pid,
370
+ });
371
+ // The user's gating hook is preserved in the merged config AND reported on
372
+ // the handle (both registered events read the same preToolUse array here,
373
+ // so the report carries the one foreign command once per gating event it
374
+ // could fire on — preToolUse only, since beforeMCPExecution was absent).
375
+ expect(handle.foreignGatingHooks).toEqual(["./gate-writes.sh"]);
376
+ await removeHitlGate(handle);
377
+ });
378
+
379
+ it("reports no foreign hooks for a pristine workspace", async () => {
380
+ const { workspaceRoot, hitlDir } = dirs();
381
+ const handle = await installHitlGate({
382
+ workspaceRoot, hitlDir, approvalState, runnerPid: process.pid,
383
+ });
384
+ expect(handle.foreignGatingHooks).toEqual([]);
385
+ await removeHitlGate(handle);
386
+ });
387
+
293
388
  it("restores a pre-existing user hooks.json byte-for-byte after teardown", async () => {
294
389
  const { workspaceRoot, hitlDir } = dirs();
295
390
  const cursorDir = join(workspaceRoot, ".cursor");
@@ -56,14 +56,24 @@
56
56
  *
57
57
  * Denial ledger (hook → runner):
58
58
  * The state file is the runner's INPUT to the hook. Its symmetric OUTPUT is the
59
- * denial ledger (denials.jsonl): when the hook denies a tool, it appends
60
- * the call's identity token to this file. The runner reads the ledger after the
61
- * run to learn which tool calls were gated the hook is the only component that
62
- * actually makes the per-call allow/deny decision, so its ledger is the
63
- * authoritative "what was denied this turn" signal (the cursor analog of the
64
- * native harness's LangGraph interrupts). The token uses the SAME identity space
65
- * as approvedGrantTokens, so a denial token approved this turn becomes a grant
66
- * token next turn.
59
+ * denial ledger (denials.jsonl): EVERY deny the hook issues appends the call's
60
+ * identity token to this file, tagged with a `kind` (see {@link DenialKind}) —
61
+ * so the ledger is the complete, authoritative record of what THIS hook blocked
62
+ * this turn (the cursor analog of the native harness's LangGraph interrupts).
63
+ * The kinds split by consumer semantics:
64
+ *
65
+ * - Only APPROVAL-kind entries pause the run (the first-denial stop and
66
+ * reconcileDeniedToolCalls filter to them via {@link approvalDenials}); a
67
+ * secret hard-block or a fail-closed deny must never surface an approval the
68
+ * user cannot meaningfully grant.
69
+ * - The FULL ledger (all kinds) means "these actions did NOT execute": it feeds
70
+ * capture stamping and DD-28 provenance, and it is the ATTRIBUTION set for
71
+ * issue #205 — a hook-blocked tool call with NO ledger entry of any kind was
72
+ * blocked by a FOREIGN hook (or a failed ledger append), an invariant
73
+ * violation the turn boundary surfaces instead of completing silently.
74
+ *
75
+ * The token uses the SAME identity space as approvedGrantTokens, so a denial
76
+ * token approved this turn becomes a grant token next turn.
67
77
  */
68
78
 
69
79
  import { watch } from "node:fs";
@@ -433,12 +443,40 @@ export async function writeApprovalStateFile(
433
443
 
434
444
  const DENIAL_LEDGER_FILE = "denials.jsonl";
435
445
 
446
+ /**
447
+ * The attribution taxonomy of a hook denial (mirrored byte-for-byte by the
448
+ * generated bash script — see hook-script.ts record_denial):
449
+ *
450
+ * - `approval` — the normal gate: the runner surfaces it as a
451
+ * WAITING_APPROVAL pause. The ONLY kind that pauses.
452
+ * - `secret` — DD-26 secret hard-block: intentional, non-pausing (the
453
+ * agent was told to move on), recorded content-free.
454
+ * - `capture-error` — CAS staging failed; the write stayed on the deny-gate.
455
+ * Content-free (the staging error means secret
456
+ * classification may never have run).
457
+ * - `fail-closed` — the approval state file was missing, so everything gated
458
+ * denied. A turn-level "the gate itself was broken" fact.
459
+ *
460
+ * An unknown kind string is preserved as-is: it is treated as non-pausing (an
461
+ * unknown deny must never manufacture an approval) but still attributes the
462
+ * blocked call to our own hook.
463
+ */
464
+ export type DenialKind = "approval" | "secret" | "capture-error" | "fail-closed";
465
+
466
+ /** The one kind that pauses the run for user approval. */
467
+ export const APPROVAL_DENIAL_KIND: DenialKind = "approval";
468
+
436
469
  /**
437
470
  * One denial recorded by the preToolUse hook. `token` is the call's identity in
438
471
  * the same space as grantToken() (base64 of `toolName \n salientArg`), used to
439
472
  * correlate the denial back to the streamed tool call. `toolName` is carried raw
440
473
  * for human-readable debugging of the ledger file.
441
474
  *
475
+ * `kind` is the attribution taxonomy entry (see {@link DenialKind}). Optional
476
+ * for tolerance: an entry written without one (the pre-kind ledger format, or a
477
+ * hand-built test fixture) is an approval-kind denial — see
478
+ * {@link denialKindOf}.
479
+ *
442
480
  * `input` is the authoritative pre-execution tool arguments the hook captured
443
481
  * from the Cursor `tool_input` payload (decoded from the ledger's base64 form).
444
482
  * It is the cursor analog of the native harness reading the AI-message tool-call
@@ -446,15 +484,33 @@ const DENIAL_LEDGER_FILE = "denials.jsonl";
446
484
  * proposed change is in hand before the tool runs. The runner overlays it onto
447
485
  * the gated tool call so the approval card can show the proposed content/diff
448
486
  * before the user approves. Absent when the hook ran its grep fallback (the Node
449
- * binary was unavailable), in which case the gate degrades to stream-recovered
450
- * args exactly as before.
487
+ * binary was unavailable) the gate then degrades to stream-recovered args —
488
+ * and ALWAYS absent for non-approval kinds (DD-26: only an approval-kind entry
489
+ * may carry proposed content).
451
490
  */
452
491
  export interface DeniedLedgerEntry {
453
492
  toolName: string;
454
493
  token: string;
494
+ kind?: string;
455
495
  input?: Record<string, unknown>;
456
496
  }
457
497
 
498
+ /** The effective kind of a ledger entry (absent → approval, the pre-kind format). */
499
+ export function denialKindOf(entry: DeniedLedgerEntry): string {
500
+ return entry.kind || APPROVAL_DENIAL_KIND;
501
+ }
502
+
503
+ /**
504
+ * The entries that pause the run for user approval — the ONLY kind
505
+ * reconcileDeniedToolCalls may turn into WAITING_APPROVAL gates and the only
506
+ * kind the first-denial stop may cancel the run for. Every other consumer
507
+ * (capture stamping, DD-28 provenance, foreign-hook attribution) wants the FULL
508
+ * ledger: all kinds mean "this action did not execute".
509
+ */
510
+ export function approvalDenials(entries: readonly DeniedLedgerEntry[]): DeniedLedgerEntry[] {
511
+ return entries.filter((e) => denialKindOf(e) === APPROVAL_DENIAL_KIND);
512
+ }
513
+
458
514
  /**
459
515
  * Absolute path of the per-turn denial ledger the hook appends to, inside the
460
516
  * session's runner-owned HITL directory (never the user's workspace).
@@ -540,11 +596,19 @@ export async function readDenialLedger(
540
596
  const trimmed = line.trim();
541
597
  if (!trimmed) continue;
542
598
  try {
543
- const obj = JSON.parse(trimmed) as { toolName?: unknown; token?: unknown; input?: unknown };
599
+ const obj = JSON.parse(trimmed) as {
600
+ toolName?: unknown;
601
+ token?: unknown;
602
+ kind?: unknown;
603
+ input?: unknown;
604
+ };
544
605
  if (typeof obj.token === "string" && obj.token) {
545
606
  entries.push({
546
607
  toolName: typeof obj.toolName === "string" ? obj.toolName : "",
547
608
  token: obj.token,
609
+ // A garbled/missing kind degrades to undefined → approval (the
610
+ // pre-kind format); see denialKindOf.
611
+ kind: typeof obj.kind === "string" && obj.kind ? obj.kind : undefined,
548
612
  input: decodeLedgerInput(obj.input),
549
613
  });
550
614
  }