@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
@@ -66,6 +66,29 @@ export async function ensureHitlGateDir(workspaceRoot) {
66
66
  export function getPlatformDir(sessionId) {
67
67
  return join(getSessionDir(sessionId), "platform");
68
68
  }
69
+ /**
70
+ * Compute the durable-checkpoint database path for a session — the SQLite file
71
+ * backing the local LangGraph checkpointer (see shared/checkpointer/sqlite-saver.ts).
72
+ *
73
+ * Session-scoped by design: a session has exactly one LangGraph thread
74
+ * (`thread-{sessionId}`), so one DB per session keeps checkpoint lifetime bound
75
+ * to session lifetime (retention becomes "remove the session dir") and mirrors
76
+ * the sibling `platform/` and `hitl/` trees. Pure function — performs no I/O.
77
+ */
78
+ export function getCheckpointDbPath(sessionId) {
79
+ return join(getSessionDir(sessionId), "checkpoints.db");
80
+ }
81
+ /**
82
+ * Ensure the session directory exists and return the checkpoint DB path.
83
+ *
84
+ * Creates the session directory tree if needed (the SQLite driver requires the
85
+ * parent directory to exist before opening the file). Idempotent — safe across
86
+ * executions, HITL reinvocations, and activity retries.
87
+ */
88
+ export async function ensureCheckpointDbPath(sessionId) {
89
+ await mkdir(getSessionDir(sessionId), { recursive: true });
90
+ return getCheckpointDbPath(sessionId);
91
+ }
69
92
  /**
70
93
  * Ensure the platform directory exists and return its path.
71
94
  *
@@ -1 +1 @@
1
- {"version":3,"file":"platform-dir.js","sourceRoot":"","sources":["../../../src/shared/workspace/platform-dir.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAEH,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AACjC,OAAO,EAAE,KAAK,EAAE,MAAM,kBAAkB,CAAC;AACzC,OAAO,EAAE,OAAO,EAAE,MAAM,SAAS,CAAC;AAClC,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAEzC,qFAAqF;AACrF,SAAS,cAAc;IACrB,OAAO,OAAO,CAAC,GAAG,CAAC,IAAI,IAAI,OAAO,CAAC,GAAG,CAAC,WAAW,IAAI,OAAO,EAAE,CAAC;AAClE,CAAC;AAED,+EAA+E;AAC/E,SAAS,aAAa,CAAC,SAAiB;IACtC,OAAO,IAAI,CAAC,cAAc,EAAE,EAAE,UAAU,EAAE,UAAU,EAAE,SAAS,CAAC,CAAC;AACnE,CAAC;AAED;;;;;;;;;;;;;;;;;;GAkBG;AACH,MAAM,UAAU,cAAc,CAAC,aAAqB;IAClD,MAAM,GAAG,GAAG,UAAU,CAAC,QAAQ,CAAC,CAAC,MAAM,CAAC,aAAa,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,KAAK,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC;IAClF,OAAO,IAAI,CAAC,cAAc,EAAE,EAAE,UAAU,EAAE,WAAW,EAAE,GAAG,CAAC,CAAC;AAC9D,CAAC;AAED;;;GAGG;AACH,MAAM,CAAC,KAAK,UAAU,iBAAiB,CAAC,aAAqB;IAC3D,MAAM,GAAG,GAAG,cAAc,CAAC,aAAa,CAAC,CAAC;IAC1C,MAAM,KAAK,CAAC,GAAG,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;IACtC,OAAO,GAAG,CAAC;AACb,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,cAAc,CAAC,SAAiB;IAC9C,OAAO,IAAI,CAAC,aAAa,CAAC,SAAS,CAAC,EAAE,UAAU,CAAC,CAAC;AACpD,CAAC;AAED;;;;;GAKG;AACH,MAAM,CAAC,KAAK,UAAU,iBAAiB,CAAC,SAAiB;IACvD,MAAM,GAAG,GAAG,cAAc,CAAC,SAAS,CAAC,CAAC;IACtC,MAAM,KAAK,CAAC,GAAG,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;IACtC,OAAO,GAAG,CAAC;AACb,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,UAAU,UAAU,CAAC,SAAiB;IAC1C,OAAO,IAAI,CAAC,aAAa,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC,CAAC;AAChD,CAAC;AAED;;;;;;GAMG;AACH,MAAM,CAAC,KAAK,UAAU,aAAa,CAAC,SAAiB;IACnD,MAAM,GAAG,GAAG,UAAU,CAAC,SAAS,CAAC,CAAC;IAClC,MAAM,KAAK,CAAC,GAAG,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;IACtC,OAAO,GAAG,CAAC;AACb,CAAC"}
1
+ {"version":3,"file":"platform-dir.js","sourceRoot":"","sources":["../../../src/shared/workspace/platform-dir.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAEH,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AACjC,OAAO,EAAE,KAAK,EAAE,MAAM,kBAAkB,CAAC;AACzC,OAAO,EAAE,OAAO,EAAE,MAAM,SAAS,CAAC;AAClC,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAEzC,qFAAqF;AACrF,SAAS,cAAc;IACrB,OAAO,OAAO,CAAC,GAAG,CAAC,IAAI,IAAI,OAAO,CAAC,GAAG,CAAC,WAAW,IAAI,OAAO,EAAE,CAAC;AAClE,CAAC;AAED,+EAA+E;AAC/E,SAAS,aAAa,CAAC,SAAiB;IACtC,OAAO,IAAI,CAAC,cAAc,EAAE,EAAE,UAAU,EAAE,UAAU,EAAE,SAAS,CAAC,CAAC;AACnE,CAAC;AAED;;;;;;;;;;;;;;;;;;GAkBG;AACH,MAAM,UAAU,cAAc,CAAC,aAAqB;IAClD,MAAM,GAAG,GAAG,UAAU,CAAC,QAAQ,CAAC,CAAC,MAAM,CAAC,aAAa,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,KAAK,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC;IAClF,OAAO,IAAI,CAAC,cAAc,EAAE,EAAE,UAAU,EAAE,WAAW,EAAE,GAAG,CAAC,CAAC;AAC9D,CAAC;AAED;;;GAGG;AACH,MAAM,CAAC,KAAK,UAAU,iBAAiB,CAAC,aAAqB;IAC3D,MAAM,GAAG,GAAG,cAAc,CAAC,aAAa,CAAC,CAAC;IAC1C,MAAM,KAAK,CAAC,GAAG,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;IACtC,OAAO,GAAG,CAAC;AACb,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,cAAc,CAAC,SAAiB;IAC9C,OAAO,IAAI,CAAC,aAAa,CAAC,SAAS,CAAC,EAAE,UAAU,CAAC,CAAC;AACpD,CAAC;AAED;;;;;;;;GAQG;AACH,MAAM,UAAU,mBAAmB,CAAC,SAAiB;IACnD,OAAO,IAAI,CAAC,aAAa,CAAC,SAAS,CAAC,EAAE,gBAAgB,CAAC,CAAC;AAC1D,CAAC;AAED;;;;;;GAMG;AACH,MAAM,CAAC,KAAK,UAAU,sBAAsB,CAAC,SAAiB;IAC5D,MAAM,KAAK,CAAC,aAAa,CAAC,SAAS,CAAC,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;IAC3D,OAAO,mBAAmB,CAAC,SAAS,CAAC,CAAC;AACxC,CAAC;AAED;;;;;GAKG;AACH,MAAM,CAAC,KAAK,UAAU,iBAAiB,CAAC,SAAiB;IACvD,MAAM,GAAG,GAAG,cAAc,CAAC,SAAS,CAAC,CAAC;IACtC,MAAM,KAAK,CAAC,GAAG,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;IACtC,OAAO,GAAG,CAAC;AACb,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,UAAU,UAAU,CAAC,SAAiB;IAC1C,OAAO,IAAI,CAAC,aAAa,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC,CAAC;AAChD,CAAC;AAED;;;;;;GAMG;AACH,MAAM,CAAC,KAAK,UAAU,aAAa,CAAC,SAAiB;IACnD,MAAM,GAAG,GAAG,UAAU,CAAC,SAAS,CAAC,CAAC;IAClC,MAAM,KAAK,CAAC,GAAG,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;IACtC,OAAO,GAAG,CAAC;AACb,CAAC"}
package/package.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "@stigmer/runner",
3
- "version": "3.1.7",
3
+ "version": "3.1.8",
4
4
  "description": "Embeddable Temporal worker for the Stigmer AI agent platform — handles agent execution, workflow orchestration, and MCP server management",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
7
7
  "engines": {
8
- "node": ">=20"
8
+ "node": ">=22.13"
9
9
  },
10
10
  "bin": {
11
11
  "stigmer-runner": "./dist/main.js"
@@ -85,7 +85,7 @@
85
85
  "@opentelemetry/sdk-metrics": "^2.0.0",
86
86
  "@opentelemetry/sdk-trace-base": "^2.0.0",
87
87
  "@opentelemetry/sdk-trace-node": "^2.0.0",
88
- "@stigmer/protos": "3.1.7",
88
+ "@stigmer/protos": "3.1.8",
89
89
  "@temporalio/activity": "^1.11.0",
90
90
  "@temporalio/client": "^1.11.0",
91
91
  "@temporalio/common": "^1.11.0",
@@ -21,6 +21,9 @@ export function mockStigmerClient(overrides: MockMethods = {}): StigmerClient {
21
21
  }),
22
22
  getExecution: vi.fn().mockResolvedValue({}),
23
23
  getExecutionContextByExecutionId: vi.fn().mockResolvedValue({}),
24
+ // No scoped token by default — the OSS/local shape (no runner credential).
25
+ acquireScopedRunnerToken: vi.fn().mockResolvedValue(undefined),
26
+ getRunnerScopedToken: vi.fn().mockResolvedValue(undefined),
24
27
  getSession: vi.fn().mockResolvedValue({}),
25
28
  updateSession: vi.fn().mockResolvedValue({}),
26
29
  getAgent: vi.fn().mockResolvedValue({}),
@@ -57,6 +57,8 @@ function makeMockClient(opts: {
57
57
  : vi.fn().mockResolvedValue(opts.workflowInstance ?? {
58
58
  spec: { workflowId: "wfl_resolved-from-instance" },
59
59
  }),
60
+ // No scoped token — the OSS/local shape (no runner credential to exchange).
61
+ acquireScopedRunnerToken: vi.fn().mockResolvedValue(undefined),
60
62
  getExecutionContextByExecutionId: opts.executionContextError
61
63
  ? vi.fn().mockRejectedValue(opts.executionContextError)
62
64
  : vi.fn().mockResolvedValue(opts.executionContext ?? {
@@ -447,6 +447,7 @@ export function createDiscoverMcpServerActivities(config: Config) {
447
447
  endpoint: config.stigmerBackendEndpoint,
448
448
  token: config.stigmerToken,
449
449
  tokenRef: config.stigmerTokenRef,
450
+ runnerTokenRef: config.stigmerRunnerTokenRef,
450
451
  });
451
452
 
452
453
  return {
@@ -45,10 +45,12 @@ export interface CursorHookHarness {
45
45
  /** Run the hook against a single hook-input payload and report its decision. */
46
46
  decide(input: object): { permission: string; raw: string };
47
47
  /**
48
- * The denial ledger entries the hook has appended this turn. `input` is the
49
- * base64(JSON(tool_input)) the hook captures (decode + JSON.parse to inspect).
48
+ * The denial ledger entries the hook has appended this turn. `kind` is the
49
+ * attribution taxonomy (approval/secret/capture-error/fail-closed); `input`
50
+ * is the base64(JSON(tool_input)) the hook captures on APPROVAL-kind entries
51
+ * only (decode + JSON.parse to inspect).
50
52
  */
51
- ledger(): Array<{ toolName: string; token: string; input?: string }>;
53
+ ledger(): Array<{ toolName: string; token: string; kind?: string; input?: string }>;
52
54
  /** Truncate the denial ledger (a fresh turn). */
53
55
  resetLedger(): void;
54
56
  /**
@@ -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
  });