@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
@@ -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
  }
@@ -23,9 +23,16 @@ export async function resolveExecutionEnv(
23
23
  client: StigmerClient,
24
24
  executionId: string,
25
25
  ): Promise<EnvResult> {
26
+ // A desktop runner exchanges its bootstrap credential for a token scoped to
27
+ // this execution's session, so cloud's decrypt gate binds the read (#156).
28
+ // No-op for cloud sandbox and OSS runners.
29
+ const scopedToken = await client.acquireScopedRunnerToken({
30
+ agentExecutionId: executionId,
31
+ });
32
+
26
33
  let execCtx;
27
34
  try {
28
- execCtx = await client.getExecutionContextByExecutionId(executionId);
35
+ execCtx = await client.getExecutionContextByExecutionId(executionId, scopedToken);
29
36
  } catch (err) {
30
37
  const code = (err as { code?: number })?.code;
31
38
  // NOT_FOUND (5) is expected for executions without env vars
@@ -19,11 +19,24 @@
19
19
  * by event — gated built-in tools (preToolUse) or MCP require-approval
20
20
  * policies (beforeMCPExecution)
21
21
  * 4. On a deny, appends the call's identity token to the denial ledger
22
- * (stigmer-denials.jsonl) so the runner can mark the gated tool call as
23
- * WAITING_APPROVAL the hook is the only place the deny decision is made,
24
- * so its ledger is the authoritative record of what was gated this turn
22
+ * (denials.jsonl) EVERY deny path records, each tagged with a `kind`
23
+ * (see below), so the ledger is the complete, authoritative record of what
24
+ * this hook blocked this turn. The runner marks approval-kind denials as
25
+ * WAITING_APPROVAL; the full ledger is its attribution set for
26
+ * distinguishing our denials from a FOREIGN hook's (issue #205): a
27
+ * hook-blocked tool with NO ledger entry of any kind was not blocked by us.
25
28
  * 5. Returns { "permission": "allow" } or { "permission": "deny" } on stdout
26
29
  *
30
+ * Denial kinds (the attribution taxonomy, mirrored in approval-state.ts):
31
+ * - "approval" — the normal gate: pauses the run for user approval.
32
+ * - "secret" — DD-26 secret hard-block: the agent continues, no pause.
33
+ * - "capture-error" — CAS staging failed, write kept on the deny-gate.
34
+ * - "fail-closed" — approval state file missing, everything gated denies.
35
+ * Only approval-kind records carry the captured tool_input: a secret write's
36
+ * content must never be persisted (DD-26), a capture-error's content is
37
+ * UNCLASSIFIED (the staging error means secret classification may never have
38
+ * run), and fail-closed has no state to classify against.
39
+ *
27
40
  * Identity extraction runs on the SAME Node.js binary as the runner (its
28
41
  * absolute path — process.execPath — is baked into the script at generation
29
42
  * time), because the identity token must be byte-identical to the one the
@@ -436,9 +449,47 @@ ${categoryCaseArms}
436
449
  INPUT_B64=""
437
450
  CONTENT_TOKEN=""
438
451
  fi
452
+ # The single identity a deny is recorded under: content-exact when the input
453
+ # carried edit content, else coarse — the same PRIMARY-token choice the runner
454
+ # grants on approval (approval-state.ts primaryToken).
455
+ if [ -n "$CONTENT_TOKEN" ]; then
456
+ PRIMARY_TOKEN="$CONTENT_TOKEN"
457
+ else
458
+ PRIMARY_TOKEN="$TOKEN"
459
+ fi
460
+
461
+ # Append a denial record to the ledger: $1 = identity token, $2 = kind (the
462
+ # attribution taxonomy — see the module doc in hook-script.ts). EVERY deny arm
463
+ # below records, so the ledger is the complete record of what THIS hook blocked
464
+ # and the runner can tell its own denials from a foreign hook's (issue #205).
465
+ # Best-effort: a ledger write failure must never abort the decision (the deny
466
+ # still goes out on stdout). ONLY approval-kind records carry the captured
467
+ # tool_input (DD-26: secret / unclassified content must never be persisted);
468
+ # input is base64(JSON(tool_input)) — the authoritative pre-execution args the
469
+ # runner overlays for the approval preview (empty on the grep fallback path).
470
+ # Written with printf (a builtin, so no ARG_MAX limit) because the input can be
471
+ # a large multi-MB file body. Defined BEFORE the first deny arm: bash resolves
472
+ # function calls at execution time, and under 'set -euo pipefail' with our
473
+ # failClosed hooks.json registration a call-before-define would abort with no
474
+ # decision and block EVERY tool.
475
+ record_denial() {
476
+ if [ "$2" = "approval" ]; then
477
+ printf '{"toolName":"%s","token":"%s","kind":"%s","input":"%s"}\\n' "$TOOL_NAME" "$1" "$2" "$INPUT_B64" >> "$LEDGER_FILE" 2>/dev/null || true
478
+ else
479
+ printf '{"toolName":"%s","token":"%s","kind":"%s"}\\n' "$TOOL_NAME" "$1" "$2" >> "$LEDGER_FILE" 2>/dev/null || true
480
+ fi
481
+ }
439
482
 
440
483
  # --- Failsafe: missing state file → deny (fail-closed) ---
484
+ # Recorded as kind "fail-closed" so the runner attributes the blocked call to
485
+ # its own (broken) gate instead of misdiagnosing a foreign hook. The ledger
486
+ # path comes from the POINTER, not the state file, so it is recordable here.
441
487
  if [ ! -f "$STATE_FILE" ]; then
488
+ if [ "$HOOK_EVENT" = "beforeMCPExecution" ]; then
489
+ record_denial "$MCP_TOKEN" "fail-closed"
490
+ else
491
+ record_denial "$PRIMARY_TOKEN" "fail-closed"
492
+ fi
442
493
  echo '{"permission":"deny","agent_message":"${APPROVAL_REQUIRED_AGENT_MESSAGE}","user_message":"Tool requires approval: '"$TOOL_NAME"'"}'
443
494
  exit 0
444
495
  fi
@@ -494,12 +545,18 @@ if [ "$CAPTURE_IGNORED" = "true" ] && [ "$CATEGORY" = "write" ] && [ -n "$SALIEN
494
545
  echo '{"permission":"allow"}'
495
546
  exit 0
496
547
  elif [ "$OBS_RESULT" = "secret" ]; then
548
+ # Kind "secret": attributable but deliberately non-pausing (the agent moves
549
+ # on) and content-free (DD-26 — the token is the only identity recorded).
550
+ record_denial "$PRIMARY_TOKEN" "secret"
497
551
  echo '{"permission":"deny","agent_message":"${SECRET_BLOCKED_AGENT_MESSAGE}","user_message":"Blocked for security: this file matches a secret-like path and was not written or captured for review."}'
498
552
  exit 0
499
553
  else
500
554
  # Node unavailable or a staging error: fail closed. A gitignored write we
501
555
  # cannot stage cannot be captured for review, so keep gating it (today's
502
- # behavior) rather than letting unreviewable bytes flow.
556
+ # behavior) rather than letting unreviewable bytes flow. Kind
557
+ # "capture-error" and content-free: the staging error means secret
558
+ # classification may never have run, so the content is UNCLASSIFIED.
559
+ record_denial "$PRIMARY_TOKEN" "capture-error"
503
560
  echo '{"permission":"deny","agent_message":"${APPROVAL_REQUIRED_AGENT_MESSAGE}","user_message":"Tool requires approval: '"$TOOL_NAME"'"}'
504
561
  exit 0
505
562
  fi
@@ -511,17 +568,6 @@ if echo "$STATE" | grep -q '"autoApproveAll":true'; then
511
568
  exit 0
512
569
  fi
513
570
 
514
- # Append a denial record to the ledger. Best-effort: a ledger write failure must
515
- # never abort the decision (the deny still goes out on stdout). toolName is raw
516
- # for human-readable debugging; token drives correlation in the runner; input is
517
- # base64(JSON(tool_input)) — the authoritative pre-execution args the runner
518
- # overlays for the approval preview (empty on the grep fallback path). Written
519
- # with printf (a builtin, so no ARG_MAX limit) because the input can be a large
520
- # multi-MB file body.
521
- record_denial() {
522
- printf '{"toolName":"%s","token":"%s","input":"%s"}\\n' "$TOOL_NAME" "$1" "$INPUT_B64" >> "$LEDGER_FILE" 2>/dev/null || true
523
- }
524
-
525
571
  # --- 2. MCP tools (beforeMCPExecution event) ---
526
572
  # preToolUse does NOT enforce gating for MCP calls — beforeMCPExecution does — so
527
573
  # MCP is gated here and ONLY here (never double-recorded). mcpToolPolicies holds
@@ -542,7 +588,7 @@ if [ "$HOOK_EVENT" = "beforeMCPExecution" ]; then
542
588
  if [ -z "$MSG" ]; then
543
589
  MSG="Tool requires approval: $TOOL_NAME"
544
590
  fi
545
- record_denial "$MCP_TOKEN"
591
+ record_denial "$MCP_TOKEN" "approval"
546
592
  echo '{"permission":"deny","agent_message":"${APPROVAL_REQUIRED_AGENT_MESSAGE}","user_message":"'"$MSG"'"}'
547
593
  exit 0
548
594
  fi
@@ -583,6 +629,9 @@ if [ -n "$CATEGORY" ]; then
583
629
  if [ "$CATEGORY" = "write" ] && [ -n "$SALIENT" ]; then
584
630
  SECRET_RESULT=$(printf '%s' "$SALIENT" | ELECTRON_RUN_AS_NODE=1 "$NODE_BIN" -e '${secretClassifyScript}' 2>/dev/null || echo ok)
585
631
  if [ "$SECRET_RESULT" = "secret" ]; then
632
+ # Kind "secret": attributable but non-pausing and content-free (DD-26 —
633
+ # only the identity token is recorded, never the proposed content).
634
+ record_denial "$PRIMARY_TOKEN" "secret"
586
635
  echo '{"permission":"deny","agent_message":"${SECRET_BLOCKED_AGENT_MESSAGE}","user_message":"Blocked for security: this file matches a secret-like path and was not written."}'
587
636
  exit 0
588
637
  fi
@@ -606,11 +655,7 @@ if [ -n "$CATEGORY" ]; then
606
655
  fi
607
656
  # Record the PRIMARY token (content-exact when available, else coarse) so the
608
657
  # runner's denial correlation keys on the SAME identity it grants on approval.
609
- if [ -n "$CONTENT_TOKEN" ]; then
610
- record_denial "$CONTENT_TOKEN"
611
- else
612
- record_denial "$TOKEN"
613
- fi
658
+ record_denial "$PRIMARY_TOKEN" "approval"
614
659
  echo '{"permission":"deny","agent_message":"${APPROVAL_REQUIRED_AGENT_MESSAGE}","user_message":"Tool requires approval: '"$TOOL_NAME"'"}'
615
660
  exit 0
616
661
  fi
@@ -125,6 +125,7 @@ export function createCursorActivities(config: Config) {
125
125
  endpoint: config.stigmerBackendEndpoint,
126
126
  token: config.stigmerToken,
127
127
  tokenRef: config.stigmerTokenRef,
128
+ runnerTokenRef: config.stigmerRunnerTokenRef,
128
129
  });
129
130
 
130
131
  return {
@@ -714,6 +715,19 @@ async function executeCursorInner(
714
715
  await removeHitlGate(hitlGate);
715
716
  await removeStigmerSymlink(primaryWorkspaceDir);
716
717
  };
718
+ // Issue #205 diagnosability: the merge preserved the user's own hooks on
719
+ // the gating events, and Cursor runs every configured hook — so any of
720
+ // these can deny this turn's tools without writing our denial ledger. Log
721
+ // the exposure up front; the turn boundary uses the same list to name the
722
+ // likely culprit if it detects an unattributed hook block.
723
+ if (hitlGate.foreignGatingHooks.length > 0) {
724
+ console.warn(
725
+ `ExecuteCursor: workspace hooks.json carries ${hitlGate.foreignGatingHooks.length} ` +
726
+ `foreign gating hook(s) [${hitlGate.foreignGatingHooks.join(", ")}] — a deny from ` +
727
+ `any of them blocks the runner's tools outside Stigmer's approval flow ` +
728
+ `(execution=${executionId})`,
729
+ );
730
+ }
717
731
  // Arm the denial watcher as soon as the gate exists. The per-turn ledger
718
732
  // reset may flip the flag once before the run starts; the loop's read then
719
733
  // sees an empty ledger and clears it — harmless by construction.
@@ -1198,6 +1212,7 @@ async function executeCursorInner(
1198
1212
  artifactStorage,
1199
1213
  mergedPolicies,
1200
1214
  denialCancelSettled: denialSettled,
1215
+ foreignGatingHooks: hitlGate.foreignGatingHooks,
1201
1216
  });
1202
1217
  // Pauses for review exactly like the native harness: the boundary mutated
1203
1218
  // the transcript in place; we flip the phase, persist, and RETURN to the
@@ -1212,6 +1227,41 @@ async function executeCursorInner(
1212
1227
  return slimStatus(status);
1213
1228
  };
1214
1229
 
1230
+ // Issue #205: a tool was blocked by a hook Stigmer does not own (the merge
1231
+ // preserves the user's own gating hooks, and Cursor runs every one), so no
1232
+ // approval can unblock it — an approval grants a token only OUR hook reads,
1233
+ // and the foreign hook would deny the re-attempt forever. Completing would
1234
+ // be the silent-failure shape the issue describes; instead fail with a
1235
+ // diagnosable reason naming the blocked tools and the likely culprit.
1236
+ // Shared by the primary turn and both recovery retries.
1237
+ const enterUnattributedHookBlockFailure = async (boundary: TurnBoundaryResult) => {
1238
+ const blockedTools = [...new Set(boundary.unattributedHookBlocks.map((b) => b.toolName))]
1239
+ .join(", ");
1240
+ const culprit = hitlGate.foreignGatingHooks.length > 0
1241
+ ? ` The workspace's .cursor/hooks.json registers hook(s) outside Stigmer's control ` +
1242
+ `[${hitlGate.foreignGatingHooks.join(", ")}], which most likely denied it.`
1243
+ : "";
1244
+ status.phase = ExecutionPhase.EXECUTION_FAILED;
1245
+ status.error =
1246
+ `A Cursor hook outside Stigmer's approval gate blocked tool(s): ${blockedTools}.` +
1247
+ culprit +
1248
+ ` Stigmer cannot request approval on a foreign hook's behalf — remove or adjust ` +
1249
+ `the hook in .cursor/hooks.json and retry.`;
1250
+ status.completedAt = utcTimestamp();
1251
+ status.messages.push(create(AgentMessageSchema, {
1252
+ type: MessageType.MESSAGE_SYSTEM,
1253
+ content: `Execution failed: ${status.error}`,
1254
+ timestamp: utcTimestamp(),
1255
+ }));
1256
+ await persist(status);
1257
+ try { resolution.agent.close(); } catch { /* best effort */ }
1258
+ console.error(
1259
+ `ExecuteCursor failed (unattributed hook block): execution=${executionId}, ` +
1260
+ `tools=[${blockedTools}], foreignHooks=[${hitlGate.foreignGatingHooks.join(", ")}]`,
1261
+ );
1262
+ return slimStatus(status);
1263
+ };
1264
+
1215
1265
  // Re-enter the turn boundary for a recovery retry: author the retry's net
1216
1266
  // change set to the file_review ledger and overlay any denials as gates —
1217
1267
  // without this a retry's file edits silently escape review (production case
@@ -1285,8 +1335,13 @@ async function executeCursorInner(
1285
1335
  turnState.firstDenialDetected ? turnState.denialCancelSettled : undefined,
1286
1336
  );
1287
1337
  if (boundary.waiting) {
1338
+ // A pausing turn is never silent, so an unattributed block alongside our
1339
+ // own gate only warns (logged by the boundary) — the pause wins.
1288
1340
  return enterApprovalPause(boundary);
1289
1341
  }
1342
+ if (boundary.unattributedHookBlocks.length > 0) {
1343
+ return enterUnattributedHookBlockFailure(boundary);
1344
+ }
1290
1345
 
1291
1346
  // Phase 13: Map final result
1292
1347
  const result = await run.wait();
@@ -1407,6 +1462,9 @@ async function executeCursorInner(
1407
1462
  );
1408
1463
  return enterApprovalPause(retryBoundary);
1409
1464
  }
1465
+ if (retryBoundary && retryBoundary.unattributedHookBlocks.length > 0) {
1466
+ return enterUnattributedHookBlockFailure(retryBoundary);
1467
+ }
1410
1468
 
1411
1469
  if (retryResult.status === "finished") {
1412
1470
  status.phase = ExecutionPhase.EXECUTION_COMPLETED;
@@ -1488,6 +1546,9 @@ async function executeCursorInner(
1488
1546
  );
1489
1547
  return enterApprovalPause(retryBoundary);
1490
1548
  }
1549
+ if (retryBoundary && retryBoundary.unattributedHookBlocks.length > 0) {
1550
+ return enterUnattributedHookBlockFailure(retryBoundary);
1551
+ }
1491
1552
 
1492
1553
  if (retryResult.status === "finished") {
1493
1554
  status.phase = ExecutionPhase.EXECUTION_COMPLETED;