@stigmer/runner 3.9.0 → 3.11.0

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 (270) hide show
  1. package/README.md +12 -1
  2. package/dist/.build-fingerprint +1 -1
  3. package/dist/activities/call-llm.js +9 -10
  4. package/dist/activities/call-llm.js.map +1 -1
  5. package/dist/activities/classify-tool-approvals.d.ts +2 -1
  6. package/dist/activities/classify-tool-approvals.js +28 -2
  7. package/dist/activities/classify-tool-approvals.js.map +1 -1
  8. package/dist/activities/discover-mcp-server.d.ts +32 -0
  9. package/dist/activities/discover-mcp-server.js +162 -27
  10. package/dist/activities/discover-mcp-server.js.map +1 -1
  11. package/dist/activities/execute-cursor/__test-utils__/cursor-hook-harness.d.ts +8 -0
  12. package/dist/activities/execute-cursor/__test-utils__/cursor-hook-harness.js +1 -1
  13. package/dist/activities/execute-cursor/__test-utils__/cursor-hook-harness.js.map +1 -1
  14. package/dist/activities/execute-cursor/approval-state.d.ts +28 -2
  15. package/dist/activities/execute-cursor/approval-state.js +7 -1
  16. package/dist/activities/execute-cursor/approval-state.js.map +1 -1
  17. package/dist/activities/execute-cursor/attachment-resolver.d.ts +14 -0
  18. package/dist/activities/execute-cursor/attachment-resolver.js +25 -4
  19. package/dist/activities/execute-cursor/attachment-resolver.js.map +1 -1
  20. package/dist/activities/execute-cursor/blueprint-resolver.d.ts +1 -9
  21. package/dist/activities/execute-cursor/blueprint-resolver.js +6 -22
  22. package/dist/activities/execute-cursor/blueprint-resolver.js.map +1 -1
  23. package/dist/activities/execute-cursor/env-resolver.js +3 -1
  24. package/dist/activities/execute-cursor/env-resolver.js.map +1 -1
  25. package/dist/activities/execute-cursor/error-classifier.d.ts +40 -3
  26. package/dist/activities/execute-cursor/error-classifier.js +81 -3
  27. package/dist/activities/execute-cursor/error-classifier.js.map +1 -1
  28. package/dist/activities/execute-cursor/extract-structured-output.d.ts +29 -0
  29. package/dist/activities/execute-cursor/extract-structured-output.js +58 -0
  30. package/dist/activities/execute-cursor/extract-structured-output.js.map +1 -0
  31. package/dist/activities/execute-cursor/hook-script.d.ts +14 -3
  32. package/dist/activities/execute-cursor/hook-script.js +72 -10
  33. package/dist/activities/execute-cursor/hook-script.js.map +1 -1
  34. package/dist/activities/execute-cursor/index.d.ts +5 -1
  35. package/dist/activities/execute-cursor/index.js +59 -58
  36. package/dist/activities/execute-cursor/index.js.map +1 -1
  37. package/dist/activities/execute-cursor/mcp-resolver.d.ts +24 -1
  38. package/dist/activities/execute-cursor/mcp-resolver.js +5 -2
  39. package/dist/activities/execute-cursor/mcp-resolver.js.map +1 -1
  40. package/dist/activities/execute-cursor/prompt-builder.d.ts +18 -4
  41. package/dist/activities/execute-cursor/prompt-builder.js +12 -7
  42. package/dist/activities/execute-cursor/prompt-builder.js.map +1 -1
  43. package/dist/activities/execute-cursor/turn-stream.js +4 -1
  44. package/dist/activities/execute-cursor/turn-stream.js.map +1 -1
  45. package/dist/activities/execute-deep-agent/attachment-injector.d.ts +18 -1
  46. package/dist/activities/execute-deep-agent/attachment-injector.js +68 -23
  47. package/dist/activities/execute-deep-agent/attachment-injector.js.map +1 -1
  48. package/dist/activities/execute-deep-agent/environment.js +3 -1
  49. package/dist/activities/execute-deep-agent/environment.js.map +1 -1
  50. package/dist/activities/execute-deep-agent/index.js +15 -0
  51. package/dist/activities/execute-deep-agent/index.js.map +1 -1
  52. package/dist/activities/execute-deep-agent/prompt-builder.d.ts +7 -7
  53. package/dist/activities/execute-deep-agent/prompt-builder.js +8 -2
  54. package/dist/activities/execute-deep-agent/prompt-builder.js.map +1 -1
  55. package/dist/activities/execute-deep-agent/setup.d.ts +10 -0
  56. package/dist/activities/execute-deep-agent/setup.js +65 -26
  57. package/dist/activities/execute-deep-agent/setup.js.map +1 -1
  58. package/dist/activities/execute-deep-agent/subagent-transformer.d.ts +18 -1
  59. package/dist/activities/execute-deep-agent/subagent-transformer.js +8 -1
  60. package/dist/activities/execute-deep-agent/subagent-transformer.js.map +1 -1
  61. package/dist/activities/execute-deep-agent/subagent-wiring.d.ts +11 -4
  62. package/dist/activities/execute-deep-agent/subagent-wiring.js +13 -4
  63. package/dist/activities/execute-deep-agent/subagent-wiring.js.map +1 -1
  64. package/dist/activities/hydrate-workflow-execution.js +3 -1
  65. package/dist/activities/hydrate-workflow-execution.js.map +1 -1
  66. package/dist/activities/workflow-event-activities.d.ts +28 -10
  67. package/dist/activities/workflow-event-activities.js +87 -58
  68. package/dist/activities/workflow-event-activities.js.map +1 -1
  69. package/dist/claimcheck/payload-codec.js +21 -1
  70. package/dist/claimcheck/payload-codec.js.map +1 -1
  71. package/dist/client/stigmer-client.d.ts +9 -4
  72. package/dist/client/stigmer-client.js +28 -15
  73. package/dist/client/stigmer-client.js.map +1 -1
  74. package/dist/encryption/config.d.ts +32 -0
  75. package/dist/encryption/config.js +68 -0
  76. package/dist/encryption/config.js.map +1 -0
  77. package/dist/encryption/index.d.ts +3 -0
  78. package/dist/encryption/index.js +3 -0
  79. package/dist/encryption/index.js.map +1 -0
  80. package/dist/encryption/payload-codec.d.ts +41 -0
  81. package/dist/encryption/payload-codec.js +130 -0
  82. package/dist/encryption/payload-codec.js.map +1 -0
  83. package/dist/payload-codecs.d.ts +16 -0
  84. package/dist/payload-codecs.js +38 -0
  85. package/dist/payload-codecs.js.map +1 -0
  86. package/dist/preflight.d.ts +31 -0
  87. package/dist/preflight.js +43 -0
  88. package/dist/preflight.js.map +1 -1
  89. package/dist/runner-manager.js +5 -15
  90. package/dist/runner-manager.js.map +1 -1
  91. package/dist/runner.js +5 -16
  92. package/dist/runner.js.map +1 -1
  93. package/dist/shared/approval-policy.d.ts +9 -3
  94. package/dist/shared/approval-policy.js +15 -6
  95. package/dist/shared/approval-policy.js.map +1 -1
  96. package/dist/shared/attachment-naming.d.ts +53 -0
  97. package/dist/shared/attachment-naming.js +59 -0
  98. package/dist/shared/attachment-naming.js.map +1 -0
  99. package/dist/shared/attachment-vision.d.ts +43 -2
  100. package/dist/shared/attachment-vision.js +72 -6
  101. package/dist/shared/attachment-vision.js.map +1 -1
  102. package/dist/shared/caller-identity.d.ts +23 -2
  103. package/dist/shared/caller-identity.js +36 -5
  104. package/dist/shared/caller-identity.js.map +1 -1
  105. package/dist/shared/channel-attachment.js +1 -0
  106. package/dist/shared/channel-attachment.js.map +1 -1
  107. package/dist/shared/checkpointer/http-saver.d.ts +42 -1
  108. package/dist/shared/checkpointer/http-saver.js +96 -8
  109. package/dist/shared/checkpointer/http-saver.js.map +1 -1
  110. package/dist/shared/conversation-attachment.js +1 -0
  111. package/dist/shared/conversation-attachment.js.map +1 -1
  112. package/dist/shared/datastore-attachment.d.ts +50 -7
  113. package/dist/shared/datastore-attachment.js +93 -11
  114. package/dist/shared/datastore-attachment.js.map +1 -1
  115. package/dist/shared/http-retry.d.ts +43 -0
  116. package/dist/shared/http-retry.js +50 -0
  117. package/dist/shared/http-retry.js.map +1 -0
  118. package/dist/shared/llm-backend.d.ts +275 -0
  119. package/dist/shared/llm-backend.js +425 -0
  120. package/dist/shared/llm-backend.js.map +1 -0
  121. package/dist/shared/llm-proxy.d.ts +8 -0
  122. package/dist/shared/llm-proxy.js +15 -0
  123. package/dist/shared/llm-proxy.js.map +1 -1
  124. package/dist/shared/mcp-enabled-tools.d.ts +57 -0
  125. package/dist/shared/mcp-enabled-tools.js +86 -0
  126. package/dist/shared/mcp-enabled-tools.js.map +1 -0
  127. package/dist/shared/mcp-manager.d.ts +17 -2
  128. package/dist/shared/mcp-manager.js +37 -25
  129. package/dist/shared/mcp-manager.js.map +1 -1
  130. package/dist/shared/mcp-resolver.d.ts +39 -2
  131. package/dist/shared/mcp-resolver.js +38 -2
  132. package/dist/shared/mcp-resolver.js.map +1 -1
  133. package/dist/shared/model-client.d.ts +12 -5
  134. package/dist/shared/model-client.js +138 -18
  135. package/dist/shared/model-client.js.map +1 -1
  136. package/dist/shared/model-error.js +198 -5
  137. package/dist/shared/model-error.js.map +1 -1
  138. package/dist/shared/model-registry.d.ts +20 -2
  139. package/dist/shared/model-registry.js +37 -2
  140. package/dist/shared/model-registry.js.map +1 -1
  141. package/dist/shared/plan-mode-permissions.d.ts +26 -0
  142. package/dist/shared/plan-mode-permissions.js +28 -0
  143. package/dist/shared/plan-mode-permissions.js.map +1 -0
  144. package/dist/worker.d.ts +2 -1
  145. package/dist/worker.js +2 -4
  146. package/dist/worker.js.map +1 -1
  147. package/dist/workflow-engine/types.d.ts +18 -0
  148. package/dist/workflow-engine/types.js.map +1 -1
  149. package/dist/workflows/call-agent-orchestrator.d.ts +9 -0
  150. package/dist/workflows/call-agent-orchestrator.js +1 -0
  151. package/dist/workflows/call-agent-orchestrator.js.map +1 -1
  152. package/dist/workflows/connect-mcp-server.js +7 -0
  153. package/dist/workflows/connect-mcp-server.js.map +1 -1
  154. package/dist/workflows/engine-core.js +23 -2
  155. package/dist/workflows/engine-core.js.map +1 -1
  156. package/dist/workflows/execute-from-execution.d.ts +1 -1
  157. package/dist/workflows/execute-from-execution.js +11 -1
  158. package/dist/workflows/execute-from-execution.js.map +1 -1
  159. package/package.json +8 -2
  160. package/src/__tests__/claimcheck-codec.test.ts +36 -0
  161. package/src/__tests__/encryption-codec.test.ts +234 -0
  162. package/src/__tests__/fixtures/encrypted-payload-fixture.json +15 -0
  163. package/src/__tests__/history-encryption-e2e.test.ts +243 -0
  164. package/src/__tests__/preflight.test.ts +50 -2
  165. package/src/activities/__tests__/call-llm.test.ts +75 -0
  166. package/src/activities/__tests__/classify-tool-approvals.test.ts +117 -1
  167. package/src/activities/__tests__/discover-mcp-server.hang.test.ts +103 -0
  168. package/src/activities/__tests__/discover-mcp-server.test.ts +203 -0
  169. package/src/activities/__tests__/workflow-event-activities.test.ts +107 -8
  170. package/src/activities/call-llm.ts +9 -16
  171. package/src/activities/classify-tool-approvals.ts +34 -4
  172. package/src/activities/discover-mcp-server.ts +190 -32
  173. package/src/activities/execute-cursor/__test-utils__/cursor-hook-harness.ts +9 -0
  174. package/src/activities/execute-cursor/__tests__/approval-gate.test.ts +14 -0
  175. package/src/activities/execute-cursor/__tests__/attachment-resolver.test.ts +92 -0
  176. package/src/activities/execute-cursor/__tests__/build-prompt.test.ts +40 -14
  177. package/src/activities/execute-cursor/__tests__/error-classifier-extraction.test.ts +208 -0
  178. package/src/activities/execute-cursor/__tests__/extract-structured-output.test.ts +120 -0
  179. package/src/activities/execute-cursor/__tests__/hook-script.test.ts +93 -0
  180. package/src/activities/execute-cursor/__tests__/mcp-resolver.test.ts +125 -0
  181. package/src/activities/execute-cursor/__tests__/prompt-builder-delegation.test.ts +1 -1
  182. package/src/activities/execute-cursor/__tests__/turn-stream.test.ts +13 -0
  183. package/src/activities/execute-cursor/approval-state.ts +30 -1
  184. package/src/activities/execute-cursor/attachment-resolver.ts +38 -3
  185. package/src/activities/execute-cursor/blueprint-resolver.ts +7 -27
  186. package/src/activities/execute-cursor/env-resolver.ts +3 -1
  187. package/src/activities/execute-cursor/error-classifier.ts +91 -4
  188. package/src/activities/execute-cursor/extract-structured-output.ts +72 -0
  189. package/src/activities/execute-cursor/hook-script.ts +74 -10
  190. package/src/activities/execute-cursor/index.ts +63 -72
  191. package/src/activities/execute-cursor/mcp-resolver.ts +36 -2
  192. package/src/activities/execute-cursor/prompt-builder.ts +34 -9
  193. package/src/activities/execute-cursor/turn-stream.ts +5 -2
  194. package/src/activities/execute-deep-agent/__tests__/attachment-injector.test.ts +132 -8
  195. package/src/activities/execute-deep-agent/__tests__/datastore-degradation.test.ts +104 -0
  196. package/src/activities/execute-deep-agent/__tests__/hitl-reject.test.ts +2 -0
  197. package/src/activities/execute-deep-agent/__tests__/hitl-resume-approve-all.test.ts +1 -0
  198. package/src/activities/execute-deep-agent/__tests__/hitl-resume-history.test.ts +1 -0
  199. package/src/activities/execute-deep-agent/__tests__/prompt-builder.test.ts +34 -5
  200. package/src/activities/execute-deep-agent/__tests__/sequential-gate-resume.test.ts +1 -0
  201. package/src/activities/execute-deep-agent/__tests__/subagent-plan-mode-permissions.test.ts +173 -0
  202. package/src/activities/execute-deep-agent/__tests__/subagent-wiring.test.ts +12 -7
  203. package/src/activities/execute-deep-agent/attachment-injector.ts +94 -30
  204. package/src/activities/execute-deep-agent/environment.ts +3 -1
  205. package/src/activities/execute-deep-agent/index.ts +20 -0
  206. package/src/activities/execute-deep-agent/prompt-builder.ts +20 -10
  207. package/src/activities/execute-deep-agent/setup.ts +86 -31
  208. package/src/activities/execute-deep-agent/subagent-transformer.ts +23 -1
  209. package/src/activities/execute-deep-agent/subagent-wiring.ts +14 -4
  210. package/src/activities/hydrate-workflow-execution.ts +3 -1
  211. package/src/activities/workflow-event-activities.ts +96 -69
  212. package/src/claimcheck/payload-codec.ts +33 -1
  213. package/src/client/__tests__/stigmer-client.test.ts +8 -8
  214. package/src/client/stigmer-client.ts +32 -18
  215. package/src/encryption/config.ts +91 -0
  216. package/src/encryption/index.ts +3 -0
  217. package/src/encryption/payload-codec.ts +152 -0
  218. package/src/payload-codecs.ts +56 -0
  219. package/src/preflight.ts +45 -0
  220. package/src/runner-manager.ts +6 -24
  221. package/src/runner.ts +6 -25
  222. package/src/shared/__tests__/approval-policy.test.ts +82 -39
  223. package/src/shared/__tests__/attachment-naming.test.ts +159 -0
  224. package/src/shared/__tests__/attachment-vision.test.ts +97 -0
  225. package/src/shared/__tests__/bedrock-adapter.test.ts +213 -0
  226. package/src/shared/__tests__/bedrock-seam.test.ts +390 -0
  227. package/src/shared/__tests__/caller-identity.test.ts +25 -0
  228. package/src/shared/__tests__/channel-attachment.test.ts +1 -1
  229. package/src/shared/__tests__/connect-backfill.test.ts +1 -0
  230. package/src/shared/__tests__/conversation-attachment.test.ts +1 -1
  231. package/src/shared/__tests__/datastore-attachment.test.ts +129 -1
  232. package/src/shared/__tests__/foundry-adapter.test.ts +276 -0
  233. package/src/shared/__tests__/foundry-seam.test.ts +482 -0
  234. package/src/shared/__tests__/http-retry.test.ts +67 -0
  235. package/src/shared/__tests__/llm-backend.test.ts +616 -0
  236. package/src/shared/__tests__/mcp-enabled-tools.test.ts +86 -0
  237. package/src/shared/__tests__/mcp-manager.test.ts +170 -2
  238. package/src/shared/__tests__/mcp-resolver.test.ts +146 -3
  239. package/src/shared/__tests__/model-client.test.ts +154 -0
  240. package/src/shared/__tests__/model-error.test.ts +289 -1
  241. package/src/shared/__tests__/model-registry.test.ts +71 -0
  242. package/src/shared/__tests__/synthesized-attachment.test.ts +1 -0
  243. package/src/shared/__tests__/vertex-adapter.test.ts +169 -0
  244. package/src/shared/__tests__/vertex-seam.test.ts +295 -0
  245. package/src/shared/approval-policy.ts +14 -7
  246. package/src/shared/attachment-naming.ts +78 -0
  247. package/src/shared/attachment-vision.ts +92 -9
  248. package/src/shared/caller-identity.ts +40 -5
  249. package/src/shared/channel-attachment.ts +1 -0
  250. package/src/shared/checkpointer/__tests__/http-saver.test.ts +196 -1
  251. package/src/shared/checkpointer/http-saver.ts +117 -9
  252. package/src/shared/conversation-attachment.ts +1 -0
  253. package/src/shared/datastore-attachment.ts +106 -11
  254. package/src/shared/http-retry.ts +50 -0
  255. package/src/shared/llm-backend.ts +544 -0
  256. package/src/shared/llm-proxy.ts +15 -0
  257. package/src/shared/mcp-enabled-tools.ts +105 -0
  258. package/src/shared/mcp-manager.ts +43 -26
  259. package/src/shared/mcp-resolver.ts +73 -2
  260. package/src/shared/model-client.ts +161 -19
  261. package/src/shared/model-error.ts +222 -4
  262. package/src/shared/model-registry.ts +50 -2
  263. package/src/shared/plan-mode-permissions.ts +30 -0
  264. package/src/worker.ts +4 -5
  265. package/src/workflow-engine/types.ts +18 -0
  266. package/src/workflows/__tests__/execute-serverless-workflow.test.ts +68 -2
  267. package/src/workflows/call-agent-orchestrator.ts +10 -0
  268. package/src/workflows/connect-mcp-server.ts +7 -0
  269. package/src/workflows/engine-core.ts +23 -2
  270. package/src/workflows/execute-from-execution.ts +12 -2
@@ -0,0 +1,208 @@
1
+ /**
2
+ * Tests for the shape-aware run.wait() error extraction (oss#299).
3
+ *
4
+ * A bare String() on a structured error value yields "[object Object]",
5
+ * which end users saw verbatim AND which shadowed every lower-priority
6
+ * classifier source (stream, rejection, conversation introspection) because
7
+ * classification stops at the first non-empty source. These tests pin:
8
+ *
9
+ * - the extractRunErrorSources shape matrix (strings, Errors, field objects,
10
+ * hopeless values)
11
+ * - first-USABLE-candidate chain order (a hopeless object no longer hides a
12
+ * usable string one field later; an empty string no longer short-circuits)
13
+ * - end-to-end: structured errors classify and re-enable fresh-agent retry;
14
+ * hopeless extraction yields to the introspection sources
15
+ * - the "[object Object]" defense-in-depth guard in classifyFromSources
16
+ */
17
+
18
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
19
+ import {
20
+ extractRunErrorSources,
21
+ synthesizeError,
22
+ shouldRetryWithFreshAgent,
23
+ } from "../error-classifier.js";
24
+
25
+ const FALLBACK = { model: "default", mode: "cloud", agentId: "agent-1" };
26
+
27
+ function base() {
28
+ return {
29
+ sdkResultFields: undefined,
30
+ streamErrorMessage: undefined,
31
+ capturedRejection: undefined,
32
+ isResumedHandle: false,
33
+ fallbackContext: FALLBACK,
34
+ } as const;
35
+ }
36
+
37
+ /** A run.wait()-shaped error result carrying the given error-detail fields. */
38
+ function errorResult(fields: Record<string, unknown>): unknown {
39
+ return { id: "run-1", status: "error", ...fields };
40
+ }
41
+
42
+ const NOTHING = { sdkError: undefined, sdkResultFields: undefined };
43
+
44
+ describe("extractRunErrorSources shape matrix", () => {
45
+ it("routes a plain string to sdkResultFields", () => {
46
+ expect(extractRunErrorSources(errorResult({ result: "rate limit exceeded" })))
47
+ .toEqual({ sdkError: undefined, sdkResultFields: "rate limit exceeded" });
48
+ });
49
+
50
+ it("lifts an Error instance into the structured channel", () => {
51
+ expect(extractRunErrorSources(errorResult({ result: new Error("connection lost") })))
52
+ .toEqual({ sdkError: { message: "connection lost" }, sdkResultFields: undefined });
53
+ });
54
+
55
+ it("lifts an Error carrying a code (Node/SDK error shape)", () => {
56
+ const err = Object.assign(new Error("stream torn down"), { code: "unavailable" });
57
+ expect(extractRunErrorSources(errorResult({ result: err })))
58
+ .toEqual({ sdkError: { code: "unavailable", message: "stream torn down" }, sdkResultFields: undefined });
59
+ });
60
+
61
+ it("lifts { code, status, message } from a plain object", () => {
62
+ expect(extractRunErrorSources(errorResult({ error: { code: "unauthenticated", status: 401, message: "bad token" } })))
63
+ .toEqual({
64
+ sdkError: { code: "unauthenticated", status: 401, message: "bad token" },
65
+ sdkResultFields: undefined,
66
+ });
67
+ });
68
+
69
+ it("lifts a message-only object", () => {
70
+ expect(extractRunErrorSources(errorResult({ error: { message: "boom" } })))
71
+ .toEqual({ sdkError: { message: "boom" }, sdkResultFields: undefined });
72
+ });
73
+
74
+ it("lifts a code-only object", () => {
75
+ expect(extractRunErrorSources(errorResult({ error: { code: "resource_exhausted" } })))
76
+ .toEqual({ sdkError: { code: "resource_exhausted" }, sdkResultFields: undefined });
77
+ });
78
+
79
+ it("yields nothing for an object with no recognizable fields (no JSON.stringify junk)", () => {
80
+ expect(extractRunErrorSources(errorResult({ result: { weird: "shape" } }))).toEqual(NOTHING);
81
+ });
82
+
83
+ it("yields nothing for a circular object", () => {
84
+ const circular: Record<string, unknown> = {};
85
+ circular.self = circular;
86
+ expect(extractRunErrorSources(errorResult({ result: circular }))).toEqual(NOTHING);
87
+ });
88
+
89
+ it("refuses the '[object Object]' junk string itself", () => {
90
+ expect(extractRunErrorSources(errorResult({ result: "[object Object]" }))).toEqual(NOTHING);
91
+ });
92
+
93
+ it("stringifies non-string primitives losslessly", () => {
94
+ expect(extractRunErrorSources(errorResult({ result: 503 })))
95
+ .toEqual({ sdkError: undefined, sdkResultFields: "503" });
96
+ });
97
+
98
+ it("yields nothing when no candidate field is present", () => {
99
+ expect(extractRunErrorSources(errorResult({}))).toEqual(NOTHING);
100
+ });
101
+
102
+ it("yields nothing for non-object results", () => {
103
+ expect(extractRunErrorSources(undefined)).toEqual(NOTHING);
104
+ expect(extractRunErrorSources(null)).toEqual(NOTHING);
105
+ expect(extractRunErrorSources("not-a-result-object")).toEqual(NOTHING);
106
+ });
107
+ });
108
+
109
+ describe("extractRunErrorSources chain order (first USABLE candidate wins)", () => {
110
+ it("a hopeless object in result no longer hides a usable string in message", () => {
111
+ const extracted = extractRunErrorSources(
112
+ errorResult({ result: { weird: "shape" }, message: "the real reason" }),
113
+ );
114
+ expect(extracted).toEqual({ sdkError: undefined, sdkResultFields: "the real reason" });
115
+ });
116
+
117
+ it("an empty string in result no longer short-circuits the chain", () => {
118
+ const extracted = extractRunErrorSources(
119
+ errorResult({ result: "", reason: "torn down mid-stream" }),
120
+ );
121
+ expect(extracted).toEqual({ sdkError: undefined, sdkResultFields: "torn down mid-stream" });
122
+ });
123
+
124
+ it("respects the documented field order: result before error before message before reason", () => {
125
+ const extracted = extractRunErrorSources(
126
+ errorResult({ result: "from-result", error: "from-error", message: "from-message" }),
127
+ );
128
+ expect(extracted.sdkResultFields).toBe("from-result");
129
+ });
130
+
131
+ it("yields nothing when every candidate is hopeless", () => {
132
+ const extracted = extractRunErrorSources(
133
+ errorResult({ result: {}, error: "", message: "[object Object]" }),
134
+ );
135
+ expect(extracted).toEqual(NOTHING);
136
+ });
137
+ });
138
+
139
+ describe("end-to-end through synthesizeError", () => {
140
+ beforeEach(() => {
141
+ vi.spyOn(console, "log").mockImplementation(() => {});
142
+ });
143
+ afterEach(() => {
144
+ vi.restoreAllMocks();
145
+ });
146
+
147
+ it("a structured retryable error classifies and re-enables fresh-agent recovery", () => {
148
+ // The regression at the heart of oss#299: String() turned this into
149
+ // "[object Object]" -> category=unknown, retryable=false -> the
150
+ // poisoned-handle retry could never fire for a plain network flake.
151
+ const extracted = extractRunErrorSources(
152
+ errorResult({ error: { code: "unavailable", message: "upstream connect error" } }),
153
+ );
154
+ const classified = synthesizeError({ ...base(), ...extracted });
155
+
156
+ expect(classified.source).toBe("sdk");
157
+ expect(classified.category).toBe("network");
158
+ expect(classified.message).toBe("upstream connect error");
159
+ expect(classified.retryable).toBe(true);
160
+ expect(shouldRetryWithFreshAgent(classified)).toBe(true);
161
+ });
162
+
163
+ it("hopeless extraction yields to the conversation introspection source", () => {
164
+ const extracted = extractRunErrorSources(errorResult({ result: { weird: "shape" } }));
165
+ const classified = synthesizeError({
166
+ ...base(),
167
+ ...extracted,
168
+ conversationErrorText: "grpc-status 12: routing failure",
169
+ });
170
+
171
+ expect(classified.source).toBe("conversation");
172
+ expect(classified.message).toBe("grpc-status 12: routing failure");
173
+ });
174
+
175
+ it("hopeless extraction yields to the captured rejection source", () => {
176
+ const extracted = extractRunErrorSources(errorResult({ result: { weird: "shape" } }));
177
+ const classified = synthesizeError({
178
+ ...base(),
179
+ ...extracted,
180
+ capturedRejection: { code: "unavailable", message: "socket hang up", timestamp: Date.now() },
181
+ });
182
+
183
+ expect(classified.source).toBe("rejection");
184
+ expect(classified.message).toContain("socket hang up");
185
+ });
186
+ });
187
+
188
+ describe("classifyFromSources '[object Object]' defense-in-depth guard", () => {
189
+ beforeEach(() => {
190
+ vi.spyOn(console, "log").mockImplementation(() => {});
191
+ });
192
+ afterEach(() => {
193
+ vi.restoreAllMocks();
194
+ });
195
+
196
+ it("treats a leaked '[object Object]' sdkResultFields as absent", () => {
197
+ // Extraction never emits it, but any other producer of the junk string
198
+ // must not shadow the sources below it.
199
+ const classified = synthesizeError({
200
+ ...base(),
201
+ sdkResultFields: "[object Object]",
202
+ streamErrorMessage: "fetch failed",
203
+ });
204
+
205
+ expect(classified.source).toBe("stream");
206
+ expect(classified.category).toBe("network");
207
+ });
208
+ });
@@ -0,0 +1,120 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import type { Config } from "../../../config.js";
3
+
4
+ vi.mock("../../../shared/model-registry.js", () => ({
5
+ getEconomyModel: vi.fn().mockResolvedValue("gpt-4o-mini"),
6
+ }));
7
+
8
+ const mockInvoke = vi.fn();
9
+ const mockWithStructuredOutput = vi.fn().mockReturnValue({ invoke: mockInvoke });
10
+
11
+ vi.mock("../../../shared/model-client.js", () => ({
12
+ buildChatModel: vi.fn().mockResolvedValue({
13
+ model: { withStructuredOutput: (...args: unknown[]) => mockWithStructuredOutput(...args) },
14
+ provider: "openai",
15
+ apiModelId: "gpt-4o-mini",
16
+ }),
17
+ }));
18
+
19
+ // llm-backend.js and llm-proxy.js stay real: the pre-check behavior under
20
+ // test IS their composition, and both are pure modules.
21
+
22
+ const SCHEMA = { type: "object", properties: { answer: { type: "string" } } };
23
+
24
+ function makeConfig(overrides: Partial<Config> = {}): Config {
25
+ return {
26
+ proxyEndpoint: null,
27
+ stigmerToken: null,
28
+ ...overrides,
29
+ } as Config;
30
+ }
31
+
32
+ describe("extractStructuredOutput", () => {
33
+ beforeEach(() => {
34
+ vi.clearAllMocks();
35
+ // Deterministic regardless of the developer's shell: blank reads as
36
+ // missing, and backend vars must not leak in from outside.
37
+ vi.stubEnv("OPENAI_API_KEY", "");
38
+ vi.stubEnv("ANTHROPIC_API_KEY", "");
39
+ vi.stubEnv("STIGMER_ANTHROPIC_BACKEND", "");
40
+ vi.stubEnv("STIGMER_OPENAI_BACKEND", "");
41
+ });
42
+
43
+ afterEach(() => {
44
+ vi.unstubAllEnvs();
45
+ });
46
+
47
+ it("direct mode with a key builds a direct-mode model (no endpoint threaded)", async () => {
48
+ vi.stubEnv("OPENAI_API_KEY", "sk-direct");
49
+ const { buildChatModel } = await import("../../../shared/model-client.js");
50
+ const { extractStructuredOutput } = await import("../extract-structured-output.js");
51
+ mockInvoke.mockResolvedValueOnce({ answer: "42" });
52
+
53
+ const result = await extractStructuredOutput("the answer is 42", SCHEMA, makeConfig(), "gpt-4.1");
54
+
55
+ expect(result).toEqual({ answer: "42" });
56
+ // The regression pin: the gRPC control-plane endpoint must never
57
+ // reappear here as a stand-in LLM proxy.
58
+ expect(buildChatModel).toHaveBeenCalledWith(
59
+ expect.objectContaining({ proxyEndpoint: undefined }),
60
+ );
61
+ });
62
+
63
+ it("throws the credential message before any construction when no path exists", async () => {
64
+ const { buildChatModel } = await import("../../../shared/model-client.js");
65
+ const { extractStructuredOutput } = await import("../extract-structured-output.js");
66
+
67
+ await expect(
68
+ extractStructuredOutput("text", SCHEMA, makeConfig(), "gpt-4.1"),
69
+ ).rejects.toThrow(/'gpt-4o-mini'.*OPENAI_API_KEY/s);
70
+ expect(buildChatModel).not.toHaveBeenCalled();
71
+ expect(mockInvoke).not.toHaveBeenCalled();
72
+ });
73
+
74
+ it("proxy mode threads the proxy endpoint and token, consulting no keys", async () => {
75
+ const { buildChatModel } = await import("../../../shared/model-client.js");
76
+ const { extractStructuredOutput } = await import("../extract-structured-output.js");
77
+ mockInvoke.mockResolvedValueOnce({ answer: "ok" });
78
+
79
+ const result = await extractStructuredOutput(
80
+ "text", SCHEMA,
81
+ makeConfig({ proxyEndpoint: "https://api.stigmer.ai", stigmerToken: "tok" }),
82
+ "gpt-4.1",
83
+ );
84
+
85
+ expect(result).toEqual({ answer: "ok" });
86
+ expect(buildChatModel).toHaveBeenCalledWith(
87
+ expect.objectContaining({
88
+ proxyEndpoint: "https://api.stigmer.ai",
89
+ stigmerToken: "tok",
90
+ }),
91
+ );
92
+ });
93
+
94
+ it("defers an un-inferable extraction model to buildChatModel's own error", async () => {
95
+ // The registry-empty fallback returns the primary model verbatim; when
96
+ // its provider can't be inferred the pre-check must not guess — the
97
+ // construction path owns the precise message.
98
+ const { getEconomyModel } = await import("../../../shared/model-registry.js");
99
+ vi.mocked(getEconomyModel).mockResolvedValueOnce("mystery-model");
100
+ const { buildChatModel } = await import("../../../shared/model-client.js");
101
+ const { extractStructuredOutput } = await import("../extract-structured-output.js");
102
+ mockInvoke.mockResolvedValueOnce({ answer: "ok" });
103
+
104
+ await extractStructuredOutput("text", SCHEMA, makeConfig(), "mystery-model");
105
+
106
+ expect(buildChatModel).toHaveBeenCalledWith(
107
+ expect.objectContaining({ modelName: "mystery-model" }),
108
+ );
109
+ });
110
+
111
+ it("normalizes an empty extraction result to null", async () => {
112
+ vi.stubEnv("OPENAI_API_KEY", "sk-direct");
113
+ const { extractStructuredOutput } = await import("../extract-structured-output.js");
114
+ mockInvoke.mockResolvedValueOnce(undefined);
115
+
116
+ const result = await extractStructuredOutput("text", SCHEMA, makeConfig(), "gpt-4.1");
117
+
118
+ expect(result).toBeNull();
119
+ });
120
+ });
@@ -245,6 +245,99 @@ d("generated approval hook (preToolUse + beforeMCPExecution)", () => {
245
245
  });
246
246
  });
247
247
 
248
+ // The enabled_tools capability manifest (issue #350): mcpServerEnabledTools
249
+ // holds ONLY restricted servers; the hook denies a listed server's
250
+ // non-listed tool with the non-pausing, permanent "disabled" kind — BEFORE
251
+ // autoApproveAll and the grant checks, because a manifest is not an
252
+ // approval gate (nothing may resurrect a disabled tool). hookMcp payloads
253
+ // carry mcp_server_name "srv".
254
+ describe("MCP enabled_tools manifest (beforeMCPExecution, issue #350)", () => {
255
+ it("denies a non-enabled tool with kind disabled (content-free, single record) and the manifest message", () => {
256
+ const h = setup({ mcpServerEnabledTools: { srv: ["list_apps"] } });
257
+
258
+ const res = h.decide(hookMcp("click", { app: "Slack" }));
259
+
260
+ expect(res.permission).toBe("deny");
261
+ // Permanent-denial framing, never the approval promise: the model must
262
+ // adapt, not wait for a resume that will never come.
263
+ expect(res.raw).toContain("not enabled for this agent");
264
+ expect(res.raw).not.toContain("submitted to the user for approval");
265
+ const ledger = h.ledger();
266
+ expect(ledger).toHaveLength(1);
267
+ expect(ledger[0].kind).toBe("disabled");
268
+ // Attributable under the MCP name-token (the identity the stream row
269
+ // computes), content-free like every non-approval kind.
270
+ expect(ledger[0].token).toBe(grantToken("click", ""));
271
+ expect(ledger[0]).not.toHaveProperty("input");
272
+ });
273
+
274
+ it("allows an enabled tool on a restricted server", () => {
275
+ const h = setup({ mcpServerEnabledTools: { srv: ["list_apps"] } });
276
+ expect(h.decide(hookMcp("list_apps")).permission).toBe("allow");
277
+ expect(h.ledger()).toEqual([]);
278
+ });
279
+
280
+ it("denies even under autoApproveAll (a manifest is not an approval gate)", () => {
281
+ const h = setup({
282
+ autoApproveAll: true,
283
+ mcpServerEnabledTools: { srv: ["list_apps"] },
284
+ });
285
+ const res = h.decide(hookMcp("click"));
286
+ expect(res.permission).toBe("deny");
287
+ expect(h.ledger()[0].kind).toBe("disabled");
288
+ });
289
+
290
+ it("denies even when the tool holds a reinvocation grant (no approval may resurrect it)", () => {
291
+ const h = setup({
292
+ mcpServerEnabledTools: { srv: ["list_apps"] },
293
+ grants: [{ toolName: "click", mcpServerSlug: "srv", key: "click", salient: "", contentDigest: "", sourceToolCallId: "consent-1" }],
294
+ });
295
+ const res = h.decide(hookMcp("click"));
296
+ expect(res.permission).toBe("deny");
297
+ expect(h.ledger()[0].kind).toBe("disabled");
298
+ });
299
+
300
+ it("stays kind disabled under unattended mode (mode-independent, like secret)", () => {
301
+ const h = setup({
302
+ unattendedSkip: true,
303
+ mcpServerEnabledTools: { srv: ["list_apps"] },
304
+ });
305
+ const res = h.decide(hookMcp("click"));
306
+ expect(res.permission).toBe("deny");
307
+ expect(h.ledger()[0].kind).toBe("disabled");
308
+ });
309
+
310
+ it("an enabled tool still flows into the normal approval arm (manifest and gate compose)", () => {
311
+ const h = setup({
312
+ mcpPolicies: { click: { requiresApproval: true, message: "Approve click?" } },
313
+ mcpServerEnabledTools: { srv: ["click"] },
314
+ });
315
+ const res = h.decide(hookMcp("click"));
316
+ expect(res.permission).toBe("deny");
317
+ expect(res.raw).toContain("Approve click?");
318
+ expect(h.ledger()[0].kind).toBe("approval");
319
+ });
320
+
321
+ it("a restriction on ANOTHER server never narrows this one (server-scoped matching)", () => {
322
+ const h = setup({ mcpServerEnabledTools: { other: ["something_else"] } });
323
+ expect(h.decide(hookMcp("click")).permission).toBe("allow");
324
+ expect(h.ledger()).toEqual([]);
325
+ });
326
+
327
+ it("quoted-name matching is exact — an enabled name never allows its prefix-sibling", () => {
328
+ const h = setup({ mcpServerEnabledTools: { srv: ["list_apps_extended"] } });
329
+ const res = h.decide(hookMcp("list_apps"));
330
+ expect(res.permission).toBe("deny");
331
+ expect(h.ledger()[0].kind).toBe("disabled");
332
+ });
333
+
334
+ it("never gates a preToolUse (built-in) payload — the manifest arm is MCP-event-scoped", () => {
335
+ const h = setup({ mcpServerEnabledTools: { srv: ["list_apps"] } });
336
+ expect(h.decide(hookRead("/x/a.txt")).permission).toBe("allow");
337
+ expect(h.ledger()).toEqual([]);
338
+ });
339
+ });
340
+
248
341
  // The hook captures the COMPLETE tool_input on every denial (base64(JSON)),
249
342
  // so the runner can overlay the proposed change onto the gated tool call for
250
343
  // the approval preview — the cursor analog of the native harness reading args
@@ -0,0 +1,125 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ import { resolveMcpServers } from "../mcp-resolver.js";
3
+
4
+ function makeUsage(
5
+ slug: string,
6
+ enabledTools: string[] = [],
7
+ toolApprovalOverrides: Array<{ toolName: string; requiresApproval: boolean }> = [],
8
+ ) {
9
+ return {
10
+ mcpServerRef: { slug, org: "test-org", kind: 0 },
11
+ enabledTools,
12
+ toolApprovalOverrides,
13
+ } as any;
14
+ }
15
+
16
+ function httpMcpServer(slug: string, defaultEnabledTools: string[] = []) {
17
+ return {
18
+ metadata: { id: `id-${slug}`, slug },
19
+ spec: {
20
+ serverType: { case: "http", value: { url: "https://mcp.example.com/mcp", headers: {} } },
21
+ env: {},
22
+ defaultEnabledTools,
23
+ },
24
+ status: undefined,
25
+ } as any;
26
+ }
27
+
28
+ function clientReturning(serversBySlug: Record<string, unknown>) {
29
+ return {
30
+ getMcpServerByReference: vi.fn(async (ref: { slug: string }) => {
31
+ const server = serversBySlug[ref.slug];
32
+ if (!server) throw new Error(`not found: ${ref.slug}`);
33
+ return server;
34
+ }),
35
+ } as any;
36
+ }
37
+
38
+ // The effective-list semantics live in shared/mcp-enabled-tools.ts (tested
39
+ // there); these tests pin the CURSOR resolver's threading — the near-duplicate
40
+ // of shared/mcp-resolver.ts that must mirror it until oss#387 consolidates.
41
+ describe("resolveMcpServers (cursor) — enabled_tools threading (issue #350)", () => {
42
+ beforeEach(() => {
43
+ vi.restoreAllMocks();
44
+ vi.spyOn(console, "warn").mockImplementation(() => {});
45
+ });
46
+
47
+ it("carries the usage's enabled_tools as the effective allow-list", async () => {
48
+ const client = clientReturning({ github: httpMcpServer("github") });
49
+
50
+ const result = await resolveMcpServers(
51
+ client, [makeUsage("github", ["create_pr"])], {}, "stdio-forbidden",
52
+ );
53
+
54
+ expect(result.resolvedServers[0].enabledTools).toEqual(["create_pr"]);
55
+ });
56
+
57
+ it("falls back to default_enabled_tools for an empty usage list", async () => {
58
+ const client = clientReturning({
59
+ github: httpMcpServer("github", ["search_code"]),
60
+ });
61
+
62
+ const result = await resolveMcpServers(
63
+ client, [makeUsage("github")], {}, "stdio-forbidden",
64
+ );
65
+
66
+ expect(result.resolvedServers[0].enabledTools).toEqual(["search_code"]);
67
+ });
68
+
69
+ it("resolves unrestricted (absent field) when both lists are empty", async () => {
70
+ const client = clientReturning({ github: httpMcpServer("github") });
71
+
72
+ const result = await resolveMcpServers(
73
+ client, [makeUsage("github")], {}, "stdio-forbidden",
74
+ );
75
+
76
+ expect(result.resolvedServers[0].enabledTools).toBeUndefined();
77
+ });
78
+
79
+ it("never narrows the Cursor SDK config — the SDK has no allow-list field; enforcement is the hook's disabled arm", async () => {
80
+ const client = clientReturning({ github: httpMcpServer("github") });
81
+
82
+ const result = await resolveMcpServers(
83
+ client, [makeUsage("github", ["create_pr"])], {}, "stdio-forbidden",
84
+ );
85
+
86
+ expect(result.cursorConfig.github).toEqual({
87
+ type: "http",
88
+ url: "https://mcp.example.com/mcp",
89
+ headers: undefined,
90
+ });
91
+ });
92
+ });
93
+
94
+ describe("resolveMcpServers (cursor) — tool_approval_overrides threading (issue #349)", () => {
95
+ beforeEach(() => {
96
+ vi.restoreAllMocks();
97
+ vi.spyOn(console, "warn").mockImplementation(() => {});
98
+ });
99
+
100
+ it("carries the usage's overrides on its own resolved server only", async () => {
101
+ // Riding the server is the scoping mechanism: an override can no longer
102
+ // reach a same-named tool on another server, because it never exists
103
+ // anywhere but its own server's object.
104
+ const client = clientReturning({
105
+ github: httpMcpServer("github"),
106
+ slack: httpMcpServer("slack"),
107
+ });
108
+
109
+ const result = await resolveMcpServers(
110
+ client,
111
+ [
112
+ makeUsage("github", [], [{ toolName: "delete_item", requiresApproval: false }]),
113
+ makeUsage("slack"),
114
+ ],
115
+ {},
116
+ "stdio-forbidden",
117
+ );
118
+
119
+ const bySlug = new Map(result.resolvedServers.map((s) => [s.slug, s]));
120
+ expect(bySlug.get("github")!.toolApprovalOverrides).toEqual([
121
+ { toolName: "delete_item", requiresApproval: false },
122
+ ]);
123
+ expect(bySlug.get("slack")!.toolApprovalOverrides).toEqual([]);
124
+ });
125
+ });
@@ -70,7 +70,7 @@ describe("buildEnhancedPrompt delegation integration", () => {
70
70
  skills: [],
71
71
  subAgents: [],
72
72
  workspaceFileRefs: [],
73
- attachmentPaths: [],
73
+ attachments: [],
74
74
  };
75
75
 
76
76
  it("includes exploration guidance when a workspace dir is present", () => {
@@ -222,6 +222,19 @@ describe("consumeCursorTurnStream", () => {
222
222
  expect(state.streamErrorMessage).toBe("boom");
223
223
  });
224
224
 
225
+ it("does not capture a non-string stream ERROR message (oss#299 hardening)", async () => {
226
+ // message is untyped at runtime; a structured value assigned here would
227
+ // crash classifyText downstream (.toLowerCase() on a non-string).
228
+ const { deps, state } = buildDeps();
229
+
230
+ await consumeCursorTurnStream(
231
+ mockRun([ev({ type: "status", status: "ERROR", message: { code: 14 } })]),
232
+ deps,
233
+ );
234
+
235
+ expect(state.streamErrorMessage).toBeUndefined();
236
+ });
237
+
225
238
  describe("first-denial early stop", () => {
226
239
  let hitlDir: string;
227
240
 
@@ -13,6 +13,7 @@
13
13
  * "mcpToolPolicies": {
14
14
  * "apply_cloud_resource": { "requiresApproval": true, "message": "..." }
15
15
  * },
16
+ * "mcpServerEnabledTools": { "planton": ["get_cloud_resource"] },
16
17
  * "approvedGrants": [{ "toolName": "edit", "mcpServerSlug": "", "key": "write", "salient": "a.txt", "contentDigest": "<sha256>" }],
17
18
  * "approvedGrantTokens": ["<base64(key\nsalient[\ncontentDigest])>"]
18
19
  * }
@@ -174,6 +175,21 @@ export interface ApprovalStateFile {
174
175
  */
175
176
  leasedCategories: string[];
176
177
  mcpToolPolicies: Record<string, McpToolPolicyEntry>;
178
+ /**
179
+ * Per-server effective enabled_tools allow-lists (issue #350), keyed by
180
+ * MCP server slug — ONLY restricted servers appear (an absent slug means
181
+ * unrestricted, so the common case stays an empty object). The Cursor SDK
182
+ * config cannot hide a server's tools, so the hook enforces the manifest
183
+ * instead: on beforeMCPExecution it matches the payload's mcp_server_name
184
+ * against this map and denies a non-listed tool with the non-pausing,
185
+ * permanent "disabled" kind — BEFORE autoApproveAll and grants, because
186
+ * enabled_tools is a capability manifest, not an approval gate (no bypass
187
+ * may resurrect a disabled tool, and no human may be offered "approve" on
188
+ * one). Unlike mcpToolPolicies (name-keyed, server-blind), this map is
189
+ * server-scoped: the hook payload carries the server identity, so equal
190
+ * tool names on different servers cannot cross-grant.
191
+ */
192
+ mcpServerEnabledTools: Record<string, string[]>;
177
193
  approvedGrants: ApprovalGrant[];
178
194
  approvedGrantTokens: string[];
179
195
  /**
@@ -381,6 +397,8 @@ function parseArgs(argsPreview: string): Record<string, unknown> | undefined {
381
397
  * - leasedCategories: built-in categories with a run-lifetime lease
382
398
  * - mcpToolPolicies: per-tool policy for MCP tools requiring approval (leased
383
399
  * servers are already absent — dropped upstream by mergeApprovalPolicies)
400
+ * - mcpServerEnabledTools: per-server enabled_tools allow-lists (issue #350,
401
+ * restricted servers only) for the hook's permanent "disabled" arm
384
402
  * - approvedGrants / approvedGrantTokens: tools approved in the current HITL
385
403
  * cycle, allowed through on reinvocation
386
404
  *
@@ -396,6 +414,7 @@ export function buildApprovalState(
396
414
  captureIgnored = false,
397
415
  gitWorkspace = true,
398
416
  unattendedSkip = false,
417
+ mcpServerEnabledTools: Record<string, string[]> = {},
399
418
  ): ApprovalStateFile {
400
419
  const approvedGrants = grants ?? [];
401
420
 
@@ -411,6 +430,7 @@ export function buildApprovalState(
411
430
  autoApproveAll: globalBypass,
412
431
  leasedCategories: [...leasedCategories],
413
432
  mcpToolPolicies,
433
+ mcpServerEnabledTools,
414
434
  approvedGrants,
415
435
  // The hook matches a tool call's PRIMARY token (content when it can compute a
416
436
  // digest from tool_input, else coarse). A content-identified grant authorizes
@@ -474,12 +494,18 @@ const DENIAL_LEDGER_FILE = "denials.jsonl";
474
494
  * classification may never have run).
475
495
  * - `fail-closed` — the approval state file was missing, so everything gated
476
496
  * denied. A turn-level "the gate itself was broken" fact.
497
+ * - `disabled` — the agent's enabled_tools manifest excludes this MCP
498
+ * tool (issue #350). Permanent for the run and
499
+ * mode-independent: NOT an approval (a human must never be
500
+ * offered "approve" on a manifest-disabled tool), so it is
501
+ * non-pausing and the model adapts — the same consumer
502
+ * semantics as `secret`.
477
503
  *
478
504
  * An unknown kind string is preserved as-is: it is treated as non-pausing (an
479
505
  * unknown deny must never manufacture an approval) but still attributes the
480
506
  * blocked call to our own hook.
481
507
  */
482
- export type DenialKind = "approval" | "unattended" | "secret" | "capture-error" | "fail-closed";
508
+ export type DenialKind = "approval" | "unattended" | "secret" | "capture-error" | "fail-closed" | "disabled";
483
509
 
484
510
  /** The one kind that pauses the run for user approval. */
485
511
  export const APPROVAL_DENIAL_KIND: DenialKind = "approval";
@@ -487,6 +513,9 @@ export const APPROVAL_DENIAL_KIND: DenialKind = "approval";
487
513
  /** The unattended-mode resolution kind (non-pausing; stamped SKIPPED). */
488
514
  export const UNATTENDED_DENIAL_KIND: DenialKind = "unattended";
489
515
 
516
+ /** The enabled_tools manifest denial kind (non-pausing, permanent; issue #350). */
517
+ export const DISABLED_DENIAL_KIND: DenialKind = "disabled";
518
+
490
519
  /**
491
520
  * One denial recorded by the preToolUse hook. `token` is the call's identity in
492
521
  * the same space as grantToken() (base64 of `toolName \n salientArg`), used to