@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
@@ -45,7 +45,14 @@ import {
45
45
  resolveApprovalProvenance,
46
46
  toProtoPolicySource,
47
47
  } from "../../shared/approval-policy.js";
48
- import { grantToken, primaryToken, toolIdentity, type DeniedLedgerEntry } from "./approval-state.js";
48
+ import {
49
+ approvalDenials,
50
+ denialKindOf,
51
+ grantToken,
52
+ primaryToken,
53
+ toolIdentity,
54
+ type DeniedLedgerEntry,
55
+ } from "./approval-state.js";
49
56
  import { utcTimestamp } from "../../shared/status.js";
50
57
  import { hideToolCallRow, isToolCallRowHidden } from "../../shared/tool-row.js";
51
58
  import { classifyTool, toolApprovalCategory, type ToolApprovalCategory } from "../../shared/tool-kind.js";
@@ -316,6 +323,19 @@ function mapSubAgentStatus(cursorStatus: string): SubAgentStatus {
316
323
  }
317
324
  }
318
325
 
326
+ /**
327
+ * Terminal for the MONOTONIC status guard in {@link mergeToolCallEvent}: once a
328
+ * row reaches one of these, a later event re-emit must not regress it.
329
+ *
330
+ * TOOL_CALL_INTERRUPTED is deliberately NOT here, even though it is terminal
331
+ * everywhere else (server, clients, provenance scoping — see the shared
332
+ * TERMINAL_TOOL_CALL_STATUSES in tool-row.ts). INTERRUPTED is server-authored
333
+ * when an execution terminalizes with the call in flight; if that FAILED
334
+ * execution is later RECOVERED, the harness checkpoint can re-execute the call
335
+ * under its original call id — and live execution evidence outranks the
336
+ * interruption marker, so the replayed event must advance the row to its true
337
+ * outcome (the enum's documented recovery supersede rule).
338
+ */
319
339
  function isTerminalToolStatus(status: ToolCallStatus): boolean {
320
340
  return (
321
341
  status === ToolCallStatus.TOOL_CALL_COMPLETED ||
@@ -1217,6 +1237,13 @@ export async function reconcileDeniedToolCalls(
1217
1237
  mergedPolicies?: Map<string, MergedToolPolicy>,
1218
1238
  workspaceBackend?: WorkspaceBackend,
1219
1239
  ): Promise<ToolCall[]> {
1240
+ // Defense-in-depth: only APPROVAL-kind denials may become approval gates.
1241
+ // The turn boundary already passes the filtered subset; re-filtering here
1242
+ // makes it structurally impossible for a secret/capture-error/fail-closed
1243
+ // entry to manufacture a pause the user cannot meaningfully grant, no matter
1244
+ // what a future caller passes (an absent kind is the pre-kind format and
1245
+ // counts as approval).
1246
+ ledger = approvalDenials(ledger);
1220
1247
  if (ledger.length === 0) return [];
1221
1248
 
1222
1249
  // The workspace the gated files live in; its rootDir normalizes paths for the
@@ -1793,6 +1820,102 @@ export function clearProvisionalPostDenialNarration(
1793
1820
  return redacted;
1794
1821
  }
1795
1822
 
1823
+ /**
1824
+ * Substrings of the error text Cursor stamps onto a tool call blocked by a
1825
+ * `preToolUse`/`beforeMCPExecution` hook (its generic replacement for the
1826
+ * hook's own agent_message — confirmed by the Phase 0 ground-truth capture in
1827
+ * cursor_hitl_test.go). The SDK has NO structured "denied by hook" signal, so
1828
+ * this marker family is the only stream-side trace of a hook block and is
1829
+ * single-sourced here for every consumer (the issue #205 attribution detector
1830
+ * below; tests). Matched case-insensitively against the FAILED call's error.
1831
+ */
1832
+ export const HOOK_BLOCK_ERROR_MARKERS: readonly string[] = ["blocked by a hook"];
1833
+
1834
+ /** True when a tool call's error text reads as a hook block. */
1835
+ function isHookBlockError(errorText: string): boolean {
1836
+ if (!errorText) return false;
1837
+ const lowered = errorText.toLowerCase();
1838
+ return HOOK_BLOCK_ERROR_MARKERS.some((marker) => lowered.includes(marker));
1839
+ }
1840
+
1841
+ /** One hook-blocked tool call that no ledger entry accounts for (issue #205). */
1842
+ export interface UnattributedHookBlock {
1843
+ toolCallId: string;
1844
+ toolName: string;
1845
+ /** The hook-block error text Cursor stamped on the call. */
1846
+ error: string;
1847
+ }
1848
+
1849
+ /**
1850
+ * Detect tool calls blocked by a hook that STIGMER'S OWN hook did not deny —
1851
+ * the issue #205 invariant check "a blocked tool must never silently complete".
1852
+ *
1853
+ * Cursor runs EVERY hook registered in the workspace's `.cursor/hooks.json`
1854
+ * and a deny from any of them blocks the tool. Our hook records every deny it
1855
+ * issues to the denial ledger (all kinds — see {@link DeniedLedgerEntry}), so a
1856
+ * FAILED tool call carrying Cursor's hook-block error text with NO matching
1857
+ * ledger entry was blocked by a FOREIGN hook (a user/team `preToolUse` policy
1858
+ * hook the merge deliberately preserves) — or, equally fatally, by our own
1859
+ * hook whose best-effort ledger append failed. Either way the runner cannot
1860
+ * pause for approval (an approval grants a token only OUR hook reads; the
1861
+ * foreign hook would deny the re-attempt forever), so the caller surfaces an
1862
+ * explicit failure instead of completing with the work silently undone.
1863
+ *
1864
+ * Attribution, in order:
1865
+ * 1. Any `fail-closed` ledger entry → the gate itself was broken this turn and
1866
+ * denied EVERYTHING it saw; per-call correlation is meaningless, so every
1867
+ * hook block is attributed to our own (broken) gate. Nothing is reported.
1868
+ * 2. Exact identity: the call's {@link toolCallIdentityToken} appears in the
1869
+ * ledger (any kind — approval gates were overlaid to WAITING_APPROVAL or
1870
+ * collapsed by the reconcile that runs first, so a still-FAILED row here is
1871
+ * typically a secret/capture-error deny, correctly attributed as ours).
1872
+ * 3. Normalized-path fallback: the same abs-vs-rel drift the reconcile's
1873
+ * second pass handles — a FILE call matches a ledger entry by (category,
1874
+ * workspace-normalized path) even when the raw tokens differ.
1875
+ *
1876
+ * Scoped to THIS turn's messages (from `turnStartMessageIndex`): seeded
1877
+ * prior-turn rows were already adjudicated and must never re-trigger.
1878
+ * Deliberately conservative: an ordinary tool failure (no hook-block text)
1879
+ * is never reported, and a foreign hook denying with fully custom text evades
1880
+ * the marker match (the documented residual — the install-time
1881
+ * foreignGatingHooks warning still fires for diagnosability).
1882
+ */
1883
+ export function detectUnattributedHookBlocks(
1884
+ messages: readonly AgentMessage[],
1885
+ turnStartMessageIndex: number,
1886
+ ledger: readonly DeniedLedgerEntry[],
1887
+ workspaceRoot?: string,
1888
+ ): UnattributedHookBlock[] {
1889
+ if (ledger.some((e) => denialKindOf(e) === "fail-closed")) return [];
1890
+
1891
+ const ledgerTokens = new Set(ledger.map((e) => e.token));
1892
+ const ledgerNormalizedSalients = new Set<string>();
1893
+ if (workspaceRoot) {
1894
+ for (const entry of ledger) {
1895
+ const decoded = decodeIdentityToken(entry.token);
1896
+ if (!decoded) continue;
1897
+ const normalized = normalizedFileSalient(decoded.key, decoded.salient, workspaceRoot);
1898
+ if (normalized) ledgerNormalizedSalients.add(normalized);
1899
+ }
1900
+ }
1901
+
1902
+ const blocks: UnattributedHookBlock[] = [];
1903
+ for (const msg of messages.slice(Math.max(0, turnStartMessageIndex))) {
1904
+ for (const tc of msg.toolCalls) {
1905
+ if (tc.status !== ToolCallStatus.TOOL_CALL_FAILED) continue;
1906
+ if (!isHookBlockError(tc.error)) continue;
1907
+ if (ledgerTokens.has(toolCallIdentityToken(tc))) continue;
1908
+ if (workspaceRoot) {
1909
+ const id = toolIdentity(tc.name, tc.mcpServerSlug, toolCallArgs(tc));
1910
+ const normalized = normalizedFileSalient(id.key, id.salient, workspaceRoot);
1911
+ if (normalized && ledgerNormalizedSalients.has(normalized)) continue;
1912
+ }
1913
+ blocks.push({ toolCallId: tc.id, toolName: tc.name, error: tc.error });
1914
+ }
1915
+ }
1916
+ return blocks;
1917
+ }
1918
+
1796
1919
  /**
1797
1920
  * Compute a streamed tool call's identity token in the same canonical space the
1798
1921
  * preToolUse hook records denials in (see {@link toolIdentity} / primaryToken).
@@ -2,12 +2,17 @@
2
2
  * The Cursor harness's turn boundary — the single post-run pipeline that turns
3
3
  * a finished agent run into the durable review surfaces:
4
4
  *
5
- * 1. read the denial ledger the preToolUse hook appended this turn;
5
+ * 1. read the denial ledger the preToolUse hook appended this turn (ALL
6
+ * kinds — every entry means "this action did not execute"; only the
7
+ * approval-kind subset may pause, see approval-state.ts);
6
8
  * 2. derive the approved-command provenance (DD-28 auto-keep facts);
7
9
  * 3. capture the turn's net file change set to the file_review ledger
8
10
  * (CANDIDATE_CAPTURED) and stamp the flowed edit rows;
9
- * 4. reconcile denied tool calls to WAITING_APPROVAL gate rows and redact the
10
- * model's provisional post-denial narration.
11
+ * 4. reconcile denied (approval-kind) tool calls to WAITING_APPROVAL gate rows
12
+ * and redact the model's provisional post-denial narration;
13
+ * 5. detect UNATTRIBUTED hook blocks (issue #205) — a tool blocked by a hook
14
+ * with no ledger entry of any kind was denied by a FOREIGN hook the merge
15
+ * preserved, and the caller fails the run rather than completing silently.
11
16
  *
12
17
  * Extracted from the activity entry point (index.ts Phase 12) so it is directly
13
18
  * unit-testable AND re-enterable: the poisoned-handle / transport-timeout
@@ -32,12 +37,20 @@ import { collectSubAgentToolCallIds } from "../../shared/tool-row.js";
32
37
  import { LocalWorkspaceBackend } from "../../shared/workspace/local-backend.js";
33
38
  import type { ArtifactStorage } from "../../shared/artifact-storage.js";
34
39
  import type { MergedToolPolicy } from "../../shared/approval-policy.js";
35
- import { primaryToken, readDenialLedger, type ApprovalGrant } from "./approval-state.js";
40
+ import {
41
+ approvalDenials,
42
+ denialKindOf,
43
+ primaryToken,
44
+ readDenialLedger,
45
+ type ApprovalGrant,
46
+ } from "./approval-state.js";
36
47
  import { deriveTurnCommandProvenance } from "./command-provenance.js";
37
48
  import { captureTurnToLedger } from "./capture-flow.js";
38
49
  import {
39
50
  clearProvisionalPostDenialNarration,
51
+ detectUnattributedHookBlocks,
40
52
  reconcileDeniedToolCalls,
53
+ type UnattributedHookBlock,
41
54
  } from "./message-translator.js";
42
55
 
43
56
  // How long the boundary waits for the first-denial-stop's run.cancel() to
@@ -96,6 +109,13 @@ export interface TurnBoundaryOptions {
96
109
  * normal completion path and the recovery retries, which have no early stop).
97
110
  */
98
111
  readonly denialCancelSettled?: Promise<void>;
112
+ /**
113
+ * Foreign (non-Stigmer) hook commands the gate install preserved on the
114
+ * gating events (see HitlGateHandle.foreignGatingHooks). Used only for
115
+ * diagnostics: when an unattributed hook block is detected, these name the
116
+ * likely culprit in the logs and the caller's failure message.
117
+ */
118
+ readonly foreignGatingHooks?: readonly string[];
99
119
  }
100
120
 
101
121
  export interface TurnBoundaryResult {
@@ -109,6 +129,15 @@ export interface TurnBoundaryResult {
109
129
  readonly capturedChangeCount: number;
110
130
  /** Denied tool calls reconciled to WAITING_APPROVAL gate rows this call. */
111
131
  readonly deniedToolCallCount: number;
132
+ /**
133
+ * Hook-blocked tool calls this turn that NO denial-ledger entry accounts for
134
+ * (issue #205): a foreign `.cursor/hooks.json` hook — or our own hook with a
135
+ * failed ledger append — denied them, and Stigmer cannot approve on its
136
+ * behalf. When the turn is not otherwise pausing, the caller must surface an
137
+ * explicit EXECUTION_FAILED instead of completing with the work silently
138
+ * undone (a pausing turn is not silent — the caller logs and pauses as usual).
139
+ */
140
+ readonly unattributedHookBlocks: readonly UnattributedHookBlock[];
112
141
  }
113
142
 
114
143
  /**
@@ -140,6 +169,7 @@ export async function runTurnBoundary(opts: TurnBoundaryOptions): Promise<TurnBo
140
169
  artifactStorage,
141
170
  mergedPolicies,
142
171
  denialCancelSettled,
172
+ foreignGatingHooks,
143
173
  } = opts;
144
174
 
145
175
  // The timebox keeps a wedged cancel from hanging the pause; the reconcile
@@ -153,7 +183,13 @@ export async function runTurnBoundary(opts: TurnBoundaryOptions): Promise<TurnBo
153
183
  }),
154
184
  ]);
155
185
  }
186
+ // The FULL ledger (all kinds) vs its APPROVAL subset — the kind split:
187
+ // every ledger entry means "this action did NOT execute", so the full set
188
+ // feeds capture stamping, DD-28 provenance, and foreign-hook attribution;
189
+ // only approval-kind entries may become WAITING_APPROVAL gates (a secret
190
+ // hard-block or fail-closed deny is attributable but never pausable).
156
191
  const deniedLedger = await readDenialLedger(hitlDir ?? "");
192
+ const approvalLedger = approvalDenials(deniedLedger);
157
193
 
158
194
  // Capture mode: author the net change set to the file_review ledger as the
159
195
  // CANDIDATE_CAPTURED event (projected server-side to a file_change_set
@@ -230,7 +266,7 @@ export async function runTurnBoundary(opts: TurnBoundaryOptions): Promise<TurnBo
230
266
  const gateWorkspaceBackend = new LocalWorkspaceBackend(primaryWorkspaceDir);
231
267
  const deniedToolCalls = await reconcileDeniedToolCalls(
232
268
  status.messages,
233
- deniedLedger,
269
+ approvalLedger,
234
270
  mergedPolicies,
235
271
  gateWorkspaceBackend,
236
272
  );
@@ -266,9 +302,46 @@ export async function runTurnBoundary(opts: TurnBoundaryOptions): Promise<TurnBo
266
302
  }
267
303
  }
268
304
 
305
+ // Issue #205 invariant: a blocked tool must never silently complete. Match
306
+ // this turn's hook-blocked FAILED rows against the FULL ledger (all kinds) —
307
+ // anything left over was denied by a hook that is not ours (or by our hook
308
+ // with a failed ledger append). The reconcile above ran first, so our own
309
+ // approval gates are already WAITING_APPROVAL/collapsed and cannot appear
310
+ // here as false positives.
311
+ const unattributedHookBlocks = detectUnattributedHookBlocks(
312
+ status.messages,
313
+ turnStartMessageIndex,
314
+ deniedLedger,
315
+ primaryWorkspaceDir,
316
+ );
317
+ const waiting = deniedToolCalls.length > 0 || capturedChangeCount > 0;
318
+ if (unattributedHookBlocks.length > 0) {
319
+ const culprits = (foreignGatingHooks?.length ?? 0) > 0
320
+ ? ` — likely foreign workspace hook(s): ${foreignGatingHooks!.join(", ")}`
321
+ : "";
322
+ console.warn(
323
+ `ExecuteCursor turn boundary: ${unattributedHookBlocks.length} tool call(s) blocked ` +
324
+ `by a hook with NO matching denial-ledger entry ` +
325
+ `[${unattributedHookBlocks.map((b) => b.toolName).join(", ")}]${culprits} ` +
326
+ `(execution=${executionId})${waiting ? " — turn pauses anyway; not failing" : ""}`,
327
+ );
328
+ }
329
+ // Diagnosability for the broken-gate shape: fail-closed entries mean the
330
+ // approval state file was missing and the gate denied everything it saw.
331
+ // Attribution treats those blocks as ours (never a foreign-hook failure),
332
+ // but the condition itself deserves a loud log.
333
+ if (deniedLedger.some((e) => denialKindOf(e) === "fail-closed")) {
334
+ console.warn(
335
+ `ExecuteCursor turn boundary: fail-closed denial(s) in the ledger — the approval ` +
336
+ `state file was missing during this turn and gated tools were denied ` +
337
+ `(execution=${executionId})`,
338
+ );
339
+ }
340
+
269
341
  return {
270
- waiting: deniedToolCalls.length > 0 || capturedChangeCount > 0,
342
+ waiting,
271
343
  capturedChangeCount,
272
344
  deniedToolCallCount: deniedToolCalls.length,
345
+ unattributedHookBlocks,
273
346
  };
274
347
  }
@@ -33,7 +33,7 @@ import {
33
33
  type StallWatchdog,
34
34
  } from "../../shared/stall-watchdog.js";
35
35
  import { shouldPersistStreamingStatus } from "./persist-decision.js";
36
- import { readDenialLedger } from "./approval-state.js";
36
+ import { approvalDenials, readDenialLedger } from "./approval-state.js";
37
37
  import {
38
38
  captureFileChangeProgress,
39
39
  type ProgressCaptureState,
@@ -305,7 +305,11 @@ export async function consumeCursorTurnStream(
305
305
  // anchor's own row is always present for the turn-boundary gate overlay.
306
306
  if (!state.firstDenialDetected && hitlDir && (state.denialLedgerDirty || event.type === "tool_call")) {
307
307
  state.denialLedgerDirty = false;
308
- const denials = await readDenialLedger(hitlDir);
308
+ // APPROVAL-kind denials only: a secret hard-block or fail-closed deny
309
+ // also lands in the (kinded) ledger for attribution, but must never
310
+ // stop the run — the agent is told to continue past those, and there
311
+ // is no approval the user could meaningfully grant.
312
+ const denials = approvalDenials(await readDenialLedger(hitlDir));
309
313
  if (denials.length > 0) {
310
314
  state.firstDenialDetected = true;
311
315
  console.log(
@@ -103,6 +103,19 @@ export interface HitlGateHandle {
103
103
  * gate is inert between turns.
104
104
  */
105
105
  gateDir: string;
106
+ /**
107
+ * Commands of FOREIGN (non-Stigmer) hooks registered on the gating events
108
+ * (preToolUse/beforeMCPExecution) in the workspace's pre-existing hooks.json.
109
+ * The merge deliberately PRESERVES them (they are the user's own config), but
110
+ * because Cursor runs every configured hook and a deny from ANY of them blocks
111
+ * the tool, a foreign hook can deny the runner's tools without writing our
112
+ * denial ledger — the issue #205 silent-failure trigger. Surfaced here so the
113
+ * runner can log the exposure at install and name the likely culprit when the
114
+ * turn boundary detects an unattributed hook block. Empty when the workspace
115
+ * had no hooks.json, only Stigmer entries, or an unparseable file (which is
116
+ * REPLACED for the turn, so its hooks cannot run against us).
117
+ */
118
+ foreignGatingHooks: readonly string[];
106
119
  }
107
120
 
108
121
  /** Absolute path + restore target for a single workspace file the gate manages. */
@@ -334,12 +347,12 @@ async function installWorkspaceHook(
334
347
  originalRaw = null;
335
348
  }
336
349
 
337
- const { merged, restoreTo } = buildMergedConfig(originalRaw, registrations);
350
+ const { merged, restoreTo, foreignGatingHooks } = buildMergedConfig(originalRaw, registrations);
338
351
 
339
352
  await mkdir(cursorDir, { recursive: true });
340
353
  await writeFile(hooksJsonPath, merged, "utf-8");
341
354
 
342
- return { hooksJsonPath, restoreTo };
355
+ return { hooksJsonPath, restoreTo, foreignGatingHooks };
343
356
  }
344
357
 
345
358
  /**
@@ -406,21 +419,35 @@ const STANDALONE_CONFIG = (registrations: HookRegistration[]): string =>
406
419
  /**
407
420
  * Merge our registrations into a hooks object: for each event, drop any stale
408
421
  * Stigmer entry, then append our fresh one. Returns the merged hooks object, the
409
- * cleaned (Stigmer-free) hooks for restore, and whether anything was stripped.
422
+ * cleaned (Stigmer-free) hooks for restore, whether anything was stripped, and
423
+ * the FOREIGN commands preserved on the gating events (see
424
+ * {@link HitlGateHandle.foreignGatingHooks} for why they matter).
410
425
  */
411
426
  function mergeHooks(
412
427
  existingHooks: Record<string, unknown>,
413
428
  registrations: HookRegistration[],
414
- ): { hooks: Record<string, unknown>; cleaned: Record<string, unknown>; strippedStale: boolean } {
429
+ ): {
430
+ hooks: Record<string, unknown>;
431
+ cleaned: Record<string, unknown>;
432
+ strippedStale: boolean;
433
+ foreignGatingHooks: string[];
434
+ } {
415
435
  const hooks: Record<string, unknown> = { ...existingHooks };
416
436
  const cleaned: Record<string, unknown> = { ...existingHooks };
417
437
  let strippedStale = false;
438
+ const foreignGatingHooks: string[] = [];
418
439
 
419
440
  for (const { event, scriptPath } of registrations) {
420
441
  const hadEvent = Array.isArray(existingHooks[event]);
421
442
  const existing = hadEvent ? (existingHooks[event] as unknown[]) : [];
422
443
  const userEntries = existing.filter((e) => !isStigmerHookEntry(e));
423
444
  if (userEntries.length !== existing.length) strippedStale = true;
445
+ // The user entries surviving on a GATING event are exactly the hooks that
446
+ // can deny the runner's tools without touching our denial ledger.
447
+ for (const entry of userEntries) {
448
+ const command = (entry as { command?: unknown } | null)?.command;
449
+ if (typeof command === "string" && command) foreignGatingHooks.push(command);
450
+ }
424
451
 
425
452
  hooks[event] = [...userEntries, buildHookEntry(scriptPath)];
426
453
  // Restore target keeps the event key only if the user originally had it, so
@@ -428,12 +455,12 @@ function mergeHooks(
428
455
  if (hadEvent) cleaned[event] = userEntries;
429
456
  }
430
457
 
431
- return { hooks, cleaned, strippedStale };
458
+ return { hooks, cleaned, strippedStale, foreignGatingHooks };
432
459
  }
433
460
 
434
461
  /**
435
- * Compute the merged hooks.json to write for this turn and the content to
436
- * restore afterward.
462
+ * Compute the merged hooks.json to write for this turn, the content to restore
463
+ * afterward, and the foreign gating hooks the merge preserved.
437
464
  *
438
465
  * - No existing file → write our standalone config; restore by deleting (null).
439
466
  * - Existing, parseable file → append our entry to each registered event array,
@@ -448,19 +475,19 @@ function mergeHooks(
448
475
  export function buildMergedConfig(
449
476
  originalRaw: string | null,
450
477
  registrations: HookRegistration[],
451
- ): { merged: string; restoreTo: string | null } {
478
+ ): { merged: string; restoreTo: string | null; foreignGatingHooks: string[] } {
452
479
  if (originalRaw === null) {
453
- return { merged: STANDALONE_CONFIG(registrations), restoreTo: null };
480
+ return { merged: STANDALONE_CONFIG(registrations), restoreTo: null, foreignGatingHooks: [] };
454
481
  }
455
482
 
456
483
  let parsed: unknown;
457
484
  try {
458
485
  parsed = JSON.parse(originalRaw);
459
486
  } catch {
460
- return { merged: STANDALONE_CONFIG(registrations), restoreTo: originalRaw };
487
+ return { merged: STANDALONE_CONFIG(registrations), restoreTo: originalRaw, foreignGatingHooks: [] };
461
488
  }
462
489
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
463
- return { merged: STANDALONE_CONFIG(registrations), restoreTo: originalRaw };
490
+ return { merged: STANDALONE_CONFIG(registrations), restoreTo: originalRaw, foreignGatingHooks: [] };
464
491
  }
465
492
 
466
493
  const root = parsed as Record<string, unknown>;
@@ -470,13 +497,14 @@ export function buildMergedConfig(
470
497
  : {};
471
498
  const version = typeof root.version === "number" ? root.version : 1;
472
499
 
473
- const { hooks: mergedHooks, cleaned, strippedStale } = mergeHooks(hooks, registrations);
500
+ const { hooks: mergedHooks, cleaned, strippedStale, foreignGatingHooks } =
501
+ mergeHooks(hooks, registrations);
474
502
 
475
503
  const merged = JSON.stringify({ ...root, version, hooks: mergedHooks }, null, 2);
476
504
 
477
505
  // No stale Stigmer entry → restore the user's exact original bytes.
478
506
  if (!strippedStale) {
479
- return { merged, restoreTo: originalRaw };
507
+ return { merged, restoreTo: originalRaw, foreignGatingHooks };
480
508
  }
481
509
 
482
510
  // We stripped a stale Stigmer entry, so never restore the original bytes (that
@@ -495,5 +523,5 @@ export function buildMergedConfig(
495
523
  ? null
496
524
  : JSON.stringify({ ...root, version, hooks: cleaned }, null, 2);
497
525
 
498
- return { merged, restoreTo };
526
+ return { merged, restoreTo, foreignGatingHooks };
499
527
  }
@@ -3,12 +3,15 @@ import { resolveEnvironment } from "../environment.js";
3
3
 
4
4
  function createMockClient(execContext: any) {
5
5
  return {
6
+ // No scoped token — the OSS/local shape (no runner credential to exchange).
7
+ acquireScopedRunnerToken: vi.fn().mockResolvedValue(undefined),
6
8
  getExecutionContextByExecutionId: vi.fn().mockResolvedValue(execContext),
7
9
  } as any;
8
10
  }
9
11
 
10
12
  function createNotFoundClient() {
11
13
  return {
14
+ acquireScopedRunnerToken: vi.fn().mockResolvedValue(undefined),
12
15
  getExecutionContextByExecutionId: vi.fn().mockRejectedValue(
13
16
  Object.assign(new Error("not found"), { code: 5 }),
14
17
  ),
@@ -81,6 +84,7 @@ describe("resolveEnvironment", () => {
81
84
 
82
85
  it("propagates non-NOT_FOUND errors", async () => {
83
86
  const client = {
87
+ acquireScopedRunnerToken: vi.fn().mockResolvedValue(undefined),
84
88
  getExecutionContextByExecutionId: vi.fn().mockRejectedValue(
85
89
  new Error("connection refused"),
86
90
  ),
@@ -92,6 +96,7 @@ describe("resolveEnvironment", () => {
92
96
 
93
97
  it("handles NOT_FOUND with lowercase code", async () => {
94
98
  const client = {
99
+ acquireScopedRunnerToken: vi.fn().mockResolvedValue(undefined),
95
100
  getExecutionContextByExecutionId: vi.fn().mockRejectedValue(
96
101
  Object.assign(new Error("not found"), { code: "not_found" }),
97
102
  ),