@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
@@ -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;
@@ -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(