@vellumai/assistant 0.3.15 → 0.3.16

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 (290) hide show
  1. package/ARCHITECTURE.md +142 -0
  2. package/Dockerfile +1 -1
  3. package/README.md +5 -5
  4. package/docs/architecture/http-token-refresh.md +252 -0
  5. package/docs/architecture/memory.md +5 -4
  6. package/docs/architecture/scheduling.md +4 -88
  7. package/docs/runbook-trusted-contacts.md +283 -0
  8. package/docs/trusted-contact-access.md +247 -0
  9. package/package.json +1 -1
  10. package/scripts/ipc/check-swift-decoder-drift.ts +2 -0
  11. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +2 -6
  12. package/src/__tests__/access-request-decision.test.ts +331 -0
  13. package/src/__tests__/asset-materialize-tool.test.ts +7 -7
  14. package/src/__tests__/asset-search-tool.test.ts +15 -15
  15. package/src/__tests__/attachments-store.test.ts +13 -13
  16. package/src/__tests__/call-controller.test.ts +150 -4
  17. package/src/__tests__/call-conversation-messages.test.ts +2 -2
  18. package/src/__tests__/call-pointer-messages.test.ts +28 -0
  19. package/src/__tests__/call-start-guardian-guard.test.ts +93 -0
  20. package/src/__tests__/channel-approval-routes.test.ts +108 -12
  21. package/src/__tests__/channel-guardian.test.ts +16 -14
  22. package/src/__tests__/checker.test.ts +24 -0
  23. package/src/__tests__/computer-use-skill-manifest-regression.test.ts +2 -2
  24. package/src/__tests__/config-watcher.test.ts +358 -0
  25. package/src/__tests__/conversation-pairing.test.ts +24 -24
  26. package/src/__tests__/conversation-store.test.ts +36 -36
  27. package/src/__tests__/date-context.test.ts +179 -1
  28. package/src/__tests__/db-migration-rollback.test.ts +4 -7
  29. package/src/__tests__/deterministic-verification-control-plane.test.ts +5 -5
  30. package/src/__tests__/emit-signal-routing-intent.test.ts +179 -0
  31. package/src/__tests__/gateway-only-guard.test.ts +188 -0
  32. package/src/__tests__/guardian-action-conversation-turn.test.ts +451 -0
  33. package/src/__tests__/guardian-action-copy-generator.test.ts +197 -0
  34. package/src/__tests__/guardian-action-followup-executor.test.ts +379 -0
  35. package/src/__tests__/guardian-action-followup-store.test.ts +376 -0
  36. package/src/__tests__/guardian-action-late-reply.test.ts +294 -0
  37. package/src/__tests__/guardian-action-no-hardcoded-copy.test.ts +71 -0
  38. package/src/__tests__/guardian-action-sweep.test.ts +9 -9
  39. package/src/__tests__/guardian-outbound-http.test.ts +194 -2
  40. package/src/__tests__/guardian-verification-intent-routing.test.ts +179 -0
  41. package/src/__tests__/guardian-verify-setup-skill-regression.test.ts +141 -0
  42. package/src/__tests__/handlers-telegram-config.test.ts +6 -6
  43. package/src/__tests__/hooks-runner.test.ts +13 -4
  44. package/src/__tests__/ingress-routes-http.test.ts +443 -0
  45. package/src/__tests__/intent-routing.test.ts +14 -0
  46. package/src/__tests__/ipc-snapshot.test.ts +2 -5
  47. package/src/__tests__/media-reuse-story.e2e.test.ts +7 -7
  48. package/src/__tests__/memory-regressions.test.ts +16 -12
  49. package/src/__tests__/non-member-access-request.test.ts +282 -0
  50. package/src/__tests__/notification-decision-strategy.test.ts +136 -0
  51. package/src/__tests__/notification-routing-intent.test.ts +11 -1
  52. package/src/__tests__/notification-thread-candidates.test.ts +166 -0
  53. package/src/__tests__/recording-intent.test.ts +1 -0
  54. package/src/__tests__/recording-state-machine.test.ts +328 -17
  55. package/src/__tests__/registry.test.ts +17 -8
  56. package/src/__tests__/relay-server.test.ts +105 -0
  57. package/src/__tests__/reminder.test.ts +13 -0
  58. package/src/__tests__/runtime-attachment-metadata.test.ts +4 -4
  59. package/src/__tests__/scheduler-recurrence.test.ts +50 -0
  60. package/src/__tests__/server-history-render.test.ts +8 -8
  61. package/src/__tests__/session-agent-loop.test.ts +1 -0
  62. package/src/__tests__/session-runtime-assembly.test.ts +49 -0
  63. package/src/__tests__/session-skill-tools.test.ts +1 -0
  64. package/src/__tests__/skill-projection.benchmark.test.ts +11 -3
  65. package/src/__tests__/slack-channel-config.test.ts +230 -0
  66. package/src/__tests__/subagent-manager-notify.test.ts +4 -4
  67. package/src/__tests__/swarm-session-integration.test.ts +2 -2
  68. package/src/__tests__/system-prompt.test.ts +43 -0
  69. package/src/__tests__/task-management-tools.test.ts +3 -3
  70. package/src/__tests__/task-tools.test.ts +3 -3
  71. package/src/__tests__/trust-store.test.ts +17 -1
  72. package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +491 -0
  73. package/src/__tests__/trusted-contact-multichannel.test.ts +409 -0
  74. package/src/__tests__/trusted-contact-verification.test.ts +360 -0
  75. package/src/__tests__/update-bulletin-format.test.ts +119 -0
  76. package/src/__tests__/update-bulletin-state.test.ts +129 -0
  77. package/src/__tests__/update-bulletin.test.ts +260 -0
  78. package/src/__tests__/update-template-contract.test.ts +29 -0
  79. package/src/agent/loop.ts +2 -2
  80. package/src/amazon/client.ts +2 -3
  81. package/src/calls/call-controller.ts +115 -34
  82. package/src/calls/call-conversation-messages.ts +2 -2
  83. package/src/calls/call-domain.ts +10 -3
  84. package/src/calls/call-pointer-messages.ts +17 -5
  85. package/src/calls/guardian-action-sweep.ts +77 -36
  86. package/src/calls/relay-server.ts +51 -12
  87. package/src/calls/twilio-routes.ts +3 -1
  88. package/src/calls/types.ts +1 -1
  89. package/src/calls/voice-session-bridge.ts +4 -4
  90. package/src/cli/core-commands.ts +3 -3
  91. package/src/cli/map.ts +8 -5
  92. package/src/config/bundled-skills/phone-calls/SKILL.md +16 -1
  93. package/src/config/bundled-skills/tasks/SKILL.md +1 -1
  94. package/src/config/bundled-skills/tasks/TOOLS.json +4 -4
  95. package/src/config/bundled-skills/time-based-actions/SKILL.md +11 -1
  96. package/src/config/computer-use-prompt.ts +1 -0
  97. package/src/config/core-schema.ts +16 -0
  98. package/src/config/env-registry.ts +1 -0
  99. package/src/config/env.ts +16 -1
  100. package/src/config/memory-schema.ts +5 -0
  101. package/src/config/schema.ts +4 -0
  102. package/src/config/system-prompt.ts +69 -2
  103. package/src/config/templates/BOOTSTRAP.md +1 -1
  104. package/src/config/templates/IDENTITY.md +8 -4
  105. package/src/config/templates/SOUL.md +14 -0
  106. package/src/config/templates/UPDATES.md +16 -0
  107. package/src/config/templates/USER.md +5 -1
  108. package/src/config/types.ts +1 -0
  109. package/src/config/update-bulletin-format.ts +52 -0
  110. package/src/config/update-bulletin-state.ts +49 -0
  111. package/src/config/update-bulletin.ts +82 -0
  112. package/src/config/vellum-skills/catalog.json +6 -0
  113. package/src/config/vellum-skills/chatgpt-import/tools/chatgpt-import.ts +1 -1
  114. package/src/config/vellum-skills/guardian-verify-setup/SKILL.md +44 -10
  115. package/src/config/vellum-skills/telegram-setup/SKILL.md +4 -4
  116. package/src/config/vellum-skills/trusted-contacts/SKILL.md +147 -0
  117. package/src/config/vellum-skills/twilio-setup/SKILL.md +2 -2
  118. package/src/context/window-manager.ts +43 -3
  119. package/src/daemon/config-watcher.ts +1 -0
  120. package/src/daemon/connection-policy.ts +21 -1
  121. package/src/daemon/daemon-control.ts +164 -7
  122. package/src/daemon/date-context.ts +174 -1
  123. package/src/daemon/guardian-action-generators.ts +175 -0
  124. package/src/daemon/guardian-verification-intent.ts +120 -0
  125. package/src/daemon/handlers/apps.ts +1 -3
  126. package/src/daemon/handlers/config-channels.ts +2 -2
  127. package/src/daemon/handlers/config-heartbeat.ts +1 -1
  128. package/src/daemon/handlers/config-inbox.ts +55 -159
  129. package/src/daemon/handlers/config-ingress.ts +1 -1
  130. package/src/daemon/handlers/config-integrations.ts +1 -1
  131. package/src/daemon/handlers/config-platform.ts +1 -1
  132. package/src/daemon/handlers/config-scheduling.ts +2 -2
  133. package/src/daemon/handlers/config-slack-channel.ts +190 -0
  134. package/src/daemon/handlers/config-telegram.ts +1 -1
  135. package/src/daemon/handlers/config-twilio.ts +1 -1
  136. package/src/daemon/handlers/config-voice.ts +100 -0
  137. package/src/daemon/handlers/config.ts +3 -0
  138. package/src/daemon/handlers/misc.ts +83 -5
  139. package/src/daemon/handlers/navigate-settings.ts +27 -0
  140. package/src/daemon/handlers/recording.ts +270 -144
  141. package/src/daemon/handlers/sessions.ts +100 -17
  142. package/src/daemon/handlers/subagents.ts +3 -3
  143. package/src/daemon/handlers/work-items.ts +10 -7
  144. package/src/daemon/ipc-contract/integrations.ts +9 -1
  145. package/src/daemon/ipc-contract/messages.ts +4 -0
  146. package/src/daemon/ipc-contract/sessions.ts +1 -1
  147. package/src/daemon/ipc-contract/settings.ts +26 -0
  148. package/src/daemon/ipc-contract/shared.ts +2 -0
  149. package/src/daemon/ipc-contract/work-items.ts +1 -7
  150. package/src/daemon/ipc-contract-inventory.json +5 -1
  151. package/src/daemon/ipc-contract.ts +5 -1
  152. package/src/daemon/lifecycle.ts +306 -266
  153. package/src/daemon/recording-intent.ts +0 -41
  154. package/src/daemon/response-tier.ts +2 -2
  155. package/src/daemon/server.ts +6 -6
  156. package/src/daemon/session-agent-loop-handlers.ts +34 -9
  157. package/src/daemon/session-agent-loop.ts +15 -8
  158. package/src/daemon/session-history.ts +3 -2
  159. package/src/daemon/session-media-retry.ts +3 -0
  160. package/src/daemon/session-messaging.ts +38 -4
  161. package/src/daemon/session-notifiers.ts +2 -2
  162. package/src/daemon/session-process.ts +256 -23
  163. package/src/daemon/session-queue-manager.ts +2 -0
  164. package/src/daemon/session-runtime-assembly.ts +39 -0
  165. package/src/daemon/session-skill-tools.ts +13 -4
  166. package/src/daemon/session-tool-setup.ts +5 -6
  167. package/src/daemon/session.ts +19 -8
  168. package/src/daemon/tls-certs.ts +55 -13
  169. package/src/daemon/tool-side-effects.ts +13 -5
  170. package/src/gallery/default-gallery.ts +32 -9
  171. package/src/influencer/client.ts +2 -1
  172. package/src/memory/channel-delivery-store.ts +37 -567
  173. package/src/memory/channel-guardian-store.ts +66 -1317
  174. package/src/memory/conflict-store.ts +4 -4
  175. package/src/memory/conversation-attention-store.ts +0 -3
  176. package/src/memory/conversation-crud.ts +668 -0
  177. package/src/memory/conversation-queries.ts +361 -0
  178. package/src/memory/conversation-store.ts +45 -983
  179. package/src/memory/db-connection.ts +3 -0
  180. package/src/memory/db-init.ts +25 -0
  181. package/src/memory/delivery-channels.ts +175 -0
  182. package/src/memory/delivery-crud.ts +211 -0
  183. package/src/memory/delivery-status.ts +199 -0
  184. package/src/memory/embedding-backend.ts +70 -4
  185. package/src/memory/embedding-local.ts +12 -2
  186. package/src/memory/entity-extractor.ts +3 -8
  187. package/src/memory/fts-reconciler.ts +121 -0
  188. package/src/memory/guardian-action-store.ts +366 -3
  189. package/src/memory/guardian-approvals.ts +569 -0
  190. package/src/memory/guardian-bindings.ts +130 -0
  191. package/src/memory/guardian-rate-limits.ts +196 -0
  192. package/src/memory/guardian-verification.ts +520 -0
  193. package/src/memory/job-handlers/index-maintenance.ts +2 -1
  194. package/src/memory/job-utils.ts +8 -5
  195. package/src/memory/jobs-store.ts +66 -6
  196. package/src/memory/jobs-worker.ts +23 -1
  197. package/src/memory/migrations/030-guardian-action-followup.ts +21 -0
  198. package/src/memory/migrations/030-guardian-verification-purpose.ts +17 -0
  199. package/src/memory/migrations/031-conversations-thread-type-index.ts +5 -0
  200. package/src/memory/migrations/100-core-tables.ts +1 -1
  201. package/src/memory/migrations/101-watchers-and-logs.ts +4 -0
  202. package/src/memory/migrations/108-tasks-and-work-items.ts +1 -1
  203. package/src/memory/migrations/112-assistant-inbox.ts +1 -1
  204. package/src/memory/migrations/113-late-migrations.ts +1 -1
  205. package/src/memory/migrations/116-messages-fts.ts +13 -0
  206. package/src/memory/migrations/119-schema-indexes-and-columns.ts +37 -0
  207. package/src/memory/migrations/120-fk-cascade-rebuilds.ts +161 -0
  208. package/src/memory/migrations/index.ts +8 -3
  209. package/src/memory/migrations/validate-migration-state.ts +114 -15
  210. package/src/memory/qdrant-circuit-breaker.ts +105 -0
  211. package/src/memory/retriever.ts +46 -13
  212. package/src/memory/schema-migration.ts +3 -0
  213. package/src/memory/schema.ts +25 -7
  214. package/src/memory/search/semantic.ts +8 -90
  215. package/src/notifications/README.md +1 -1
  216. package/src/notifications/broadcaster.ts +20 -2
  217. package/src/notifications/conversation-pairing.ts +3 -3
  218. package/src/notifications/decision-engine.ts +173 -8
  219. package/src/notifications/deliveries-store.ts +27 -8
  220. package/src/notifications/preferences-store.ts +7 -7
  221. package/src/notifications/thread-candidates.ts +234 -0
  222. package/src/notifications/types.ts +18 -0
  223. package/src/permissions/defaults.ts +11 -1
  224. package/src/permissions/prompter.ts +17 -0
  225. package/src/permissions/trust-store.ts +2 -0
  226. package/src/providers/failover.ts +19 -0
  227. package/src/providers/registry.ts +46 -1
  228. package/src/runtime/approval-message-composer.ts +1 -1
  229. package/src/runtime/channel-guardian-service.ts +15 -3
  230. package/src/runtime/channel-retry-sweep.ts +7 -2
  231. package/src/runtime/guardian-action-conversation-turn.ts +85 -0
  232. package/src/runtime/guardian-action-followup-executor.ts +301 -0
  233. package/src/runtime/guardian-action-message-composer.ts +245 -0
  234. package/src/runtime/guardian-outbound-actions.ts +26 -6
  235. package/src/runtime/guardian-verification-templates.ts +15 -9
  236. package/src/runtime/http-errors.ts +93 -0
  237. package/src/runtime/http-server.ts +133 -44
  238. package/src/runtime/http-types.ts +53 -0
  239. package/src/runtime/ingress-service.ts +237 -0
  240. package/src/runtime/middleware/error-handler.ts +4 -3
  241. package/src/runtime/middleware/rate-limiter.ts +160 -0
  242. package/src/runtime/middleware/request-logger.ts +71 -0
  243. package/src/runtime/middleware/twilio-validation.ts +7 -6
  244. package/src/runtime/pending-interactions.ts +12 -0
  245. package/src/runtime/routes/access-request-decision.ts +215 -0
  246. package/src/runtime/routes/app-routes.ts +25 -18
  247. package/src/runtime/routes/approval-routes.ts +18 -47
  248. package/src/runtime/routes/attachment-routes.ts +15 -41
  249. package/src/runtime/routes/call-routes.ts +20 -20
  250. package/src/runtime/routes/channel-delivery-routes.ts +6 -5
  251. package/src/runtime/routes/contact-routes.ts +4 -9
  252. package/src/runtime/routes/conversation-attention-routes.ts +2 -1
  253. package/src/runtime/routes/conversation-routes.ts +26 -57
  254. package/src/runtime/routes/debug-routes.ts +71 -0
  255. package/src/runtime/routes/events-routes.ts +3 -2
  256. package/src/runtime/routes/guardian-approval-interception.ts +221 -0
  257. package/src/runtime/routes/identity-routes.ts +14 -10
  258. package/src/runtime/routes/inbound-conversation.ts +3 -2
  259. package/src/runtime/routes/inbound-message-handler.ts +527 -62
  260. package/src/runtime/routes/ingress-routes.ts +174 -0
  261. package/src/runtime/routes/integration-routes.ts +78 -16
  262. package/src/runtime/routes/pairing-routes.ts +11 -10
  263. package/src/runtime/routes/secret-routes.ts +10 -18
  264. package/src/runtime/verification-rate-limiter.ts +83 -0
  265. package/src/schedule/schedule-store.ts +13 -1
  266. package/src/schedule/scheduler.ts +1 -1
  267. package/src/security/secret-ingress.ts +5 -2
  268. package/src/security/secret-scanner.ts +72 -6
  269. package/src/subagent/manager.ts +6 -4
  270. package/src/swarm/plan-validator.ts +4 -1
  271. package/src/tasks/task-runner.ts +3 -1
  272. package/src/tools/browser/api-map.ts +9 -6
  273. package/src/tools/calls/call-start.ts +20 -0
  274. package/src/tools/executor.ts +50 -568
  275. package/src/tools/permission-checker.ts +272 -0
  276. package/src/tools/registry.ts +14 -6
  277. package/src/tools/reminder/reminder-store.ts +7 -7
  278. package/src/tools/reminder/reminder.ts +6 -3
  279. package/src/tools/secret-detection-handler.ts +301 -0
  280. package/src/tools/subagent/message.ts +1 -1
  281. package/src/tools/system/voice-config.ts +62 -0
  282. package/src/tools/tasks/index.ts +3 -3
  283. package/src/tools/tasks/work-item-list.ts +3 -3
  284. package/src/tools/tasks/work-item-update.ts +4 -5
  285. package/src/tools/tool-approval-handler.ts +192 -0
  286. package/src/tools/tool-manifest.ts +2 -0
  287. package/src/watcher/watcher-store.ts +9 -9
  288. package/src/work-items/work-item-runner.ts +9 -6
  289. /package/src/memory/migrations/{026-embeddings-nullable-vector-json.ts → 026a-embeddings-nullable-vector-json.ts} +0 -0
  290. /package/src/memory/migrations/{027-guardian-bootstrap-token.ts → 027a-guardian-bootstrap-token.ts} +0 -0
@@ -0,0 +1,272 @@
1
+ import { getConfig } from '../config/loader.js';
2
+ import { getHookManager } from '../hooks/manager.js';
3
+ import { check, classifyRisk, generateAllowlistOptions, generateScopeOptions } from '../permissions/checker.js';
4
+ import type { PermissionPrompter } from '../permissions/prompter.js';
5
+ import { addRule } from '../permissions/trust-store.js';
6
+ import { RiskLevel } from '../permissions/types.js';
7
+ import { getLogger } from '../util/logger.js';
8
+ import type { ExecutionTarget } from './types.js';
9
+ import { buildPolicyContext } from './policy-context.js';
10
+ import { isSideEffectTool } from './side-effects.js';
11
+ import { wrapCommand } from './terminal/sandbox.js';
12
+ import type { Tool, ToolContext, ToolLifecycleEvent } from './types.js';
13
+
14
+ const log = getLogger('permission-checker');
15
+
16
+ export type PermissionDecision =
17
+ | { allowed: true; decision: string; riskLevel: string }
18
+ | { allowed: false; decision: string; riskLevel: string; content: string };
19
+
20
+ export class PermissionChecker {
21
+ private prompter: PermissionPrompter;
22
+
23
+ constructor(prompter: PermissionPrompter) {
24
+ this.prompter = prompter;
25
+ }
26
+
27
+ /**
28
+ * Run risk classification, trust rule evaluation, and (if needed) user
29
+ * prompting for a tool invocation. Returns whether the tool is allowed
30
+ * to execute, along with the decision string and risk level for lifecycle
31
+ * event reporting.
32
+ */
33
+ async checkPermission(
34
+ name: string,
35
+ input: Record<string, unknown>,
36
+ tool: Tool,
37
+ context: ToolContext,
38
+ executionTarget: ExecutionTarget,
39
+ emitLifecycleEvent: (event: ToolLifecycleEvent) => void,
40
+ sanitizeToolInput: (toolName: string, input: Record<string, unknown>) => Record<string, unknown>,
41
+ startTime: number,
42
+ computePreviewDiff: (toolName: string, input: Record<string, unknown>, workingDir: string) => { filePath: string; oldContent: string; newContent: string; isNewFile: boolean } | undefined,
43
+ ): Promise<PermissionDecision> {
44
+ const risk = await classifyRisk(name, input, context.workingDir, undefined, undefined, context.signal);
45
+ const riskLevel: string = risk;
46
+
47
+ // Wrap the rest of permission evaluation so that any exception
48
+ // carries the classified risk level back to the caller. Without
49
+ // this, the executor's catch block would fall back to the default
50
+ // low risk, degrading audit/alert accuracy for high-risk attempts.
51
+ try {
52
+ const policyContext = buildPolicyContext(tool, context);
53
+ const result = await check(name, input, context.workingDir, policyContext, undefined, context.signal);
54
+
55
+ // Private threads force prompting for side-effect tools even when a
56
+ // trust/allow rule would auto-allow. Deny decisions are preserved —
57
+ // only allow → prompt promotion happens here.
58
+ if (
59
+ context.forcePromptSideEffects
60
+ && result.decision === 'allow'
61
+ && isSideEffectTool(name, input)
62
+ ) {
63
+ result.decision = 'prompt';
64
+ result.reason = 'Private thread: side-effect tools require explicit approval';
65
+ }
66
+
67
+ if (result.decision === 'deny') {
68
+ const durationMs = Date.now() - startTime;
69
+ emitLifecycleEvent({
70
+ type: 'permission_denied',
71
+ toolName: name,
72
+ executionTarget,
73
+ input,
74
+ workingDir: context.workingDir,
75
+ sessionId: context.sessionId,
76
+ conversationId: context.conversationId,
77
+ requestId: context.requestId,
78
+ riskLevel,
79
+ decision: 'deny',
80
+ reason: result.reason,
81
+ durationMs,
82
+ });
83
+ return { allowed: false, decision: 'denied', riskLevel, content: result.reason };
84
+ }
85
+
86
+ if (result.decision === 'prompt') {
87
+ // Non-interactive sessions have no client to respond to prompts —
88
+ // deny immediately instead of blocking for the full permission timeout.
89
+ if (context.isInteractive === false) {
90
+ const durationMs = Date.now() - startTime;
91
+ log.info({ toolName: name, riskLevel }, 'Auto-denying prompt for non-interactive session');
92
+ emitLifecycleEvent({
93
+ type: 'permission_denied',
94
+ toolName: name,
95
+ executionTarget,
96
+ input,
97
+ workingDir: context.workingDir,
98
+ sessionId: context.sessionId,
99
+ conversationId: context.conversationId,
100
+ requestId: context.requestId,
101
+ riskLevel,
102
+ decision: 'deny',
103
+ reason: 'Non-interactive session: no client to approve prompt',
104
+ durationMs,
105
+ });
106
+ return {
107
+ allowed: false,
108
+ decision: 'denied',
109
+ riskLevel,
110
+ content: `Permission denied: tool "${name}" requires user approval but no interactive client is connected. The tool was not executed. To allow this tool in non-interactive sessions, add a trust rule via permission settings.`,
111
+ };
112
+ }
113
+
114
+ const allowlistOptions = await generateAllowlistOptions(name, input, context.signal);
115
+ const scopeOptions = generateScopeOptions(context.workingDir, name);
116
+ const previewDiff = computePreviewDiff(name, input, context.workingDir);
117
+
118
+ let sandboxed: boolean | undefined;
119
+ if (name === 'bash' && typeof input.command === 'string') {
120
+ const cfg = getConfig();
121
+ const sandboxConfig = context.sandboxOverride != null
122
+ ? { ...cfg.sandbox, enabled: context.sandboxOverride }
123
+ : cfg.sandbox;
124
+ const wrapped = wrapCommand(input.command, context.workingDir, sandboxConfig);
125
+ sandboxed = wrapped.sandboxed;
126
+ }
127
+
128
+ // Proxied bash prompts are non-persistent — no trust rule saving allowed
129
+ const persistentDecisionsAllowed = !(
130
+ name === 'bash'
131
+ && input.network_mode === 'proxied'
132
+ );
133
+
134
+ emitLifecycleEvent({
135
+ type: 'permission_prompt',
136
+ toolName: name,
137
+ executionTarget,
138
+ input,
139
+ workingDir: context.workingDir,
140
+ sessionId: context.sessionId,
141
+ conversationId: context.conversationId,
142
+ requestId: context.requestId,
143
+ riskLevel,
144
+ reason: result.reason,
145
+ allowlistOptions,
146
+ scopeOptions,
147
+ diff: previewDiff,
148
+ sandboxed,
149
+ persistentDecisionsAllowed,
150
+ });
151
+
152
+ await getHookManager().trigger('permission-request', {
153
+ toolName: name,
154
+ input: sanitizeToolInput(name, input),
155
+ riskLevel,
156
+ sessionId: context.sessionId,
157
+ });
158
+
159
+ const response = await this.prompter.prompt(
160
+ name,
161
+ input,
162
+ riskLevel,
163
+ allowlistOptions,
164
+ scopeOptions,
165
+ previewDiff,
166
+ sandboxed,
167
+ context.conversationId,
168
+ executionTarget,
169
+ persistentDecisionsAllowed,
170
+ context.signal,
171
+ );
172
+
173
+ const decision = response.decision;
174
+
175
+ await getHookManager().trigger('permission-resolve', {
176
+ toolName: name,
177
+ decision: response.decision,
178
+ riskLevel,
179
+ sessionId: context.sessionId,
180
+ });
181
+
182
+ if (response.decision === 'deny') {
183
+ const contextualDenial = typeof response.decisionContext === 'string'
184
+ ? response.decisionContext.trim()
185
+ : '';
186
+ const denialMessage = contextualDenial.length > 0
187
+ ? contextualDenial
188
+ : `Permission denied by user. The user chose not to allow the "${name}" tool. Do NOT retry this tool call immediately. Instead, tell the user that the action was not performed because they denied permission, and ask if they would like you to try again or take a different approach. Wait for the user to explicitly respond before retrying.`;
189
+ const denialReason = contextualDenial.length > 0
190
+ ? `Permission denied (${name}): contextual policy`
191
+ : 'Permission denied by user';
192
+ const durationMs = Date.now() - startTime;
193
+ emitLifecycleEvent({
194
+ type: 'permission_denied',
195
+ toolName: name,
196
+ executionTarget,
197
+ input,
198
+ workingDir: context.workingDir,
199
+ sessionId: context.sessionId,
200
+ conversationId: context.conversationId,
201
+ requestId: context.requestId,
202
+ riskLevel,
203
+ decision: 'deny',
204
+ reason: denialReason,
205
+ durationMs,
206
+ });
207
+ return { allowed: false, decision, riskLevel, content: denialMessage };
208
+ }
209
+
210
+ if (response.decision === 'always_deny') {
211
+ const ruleSaved = !!(persistentDecisionsAllowed && response.selectedPattern && response.selectedScope);
212
+ if (ruleSaved) {
213
+ addRule(name, response.selectedPattern!, response.selectedScope!, 'deny');
214
+ }
215
+ const denialReason = ruleSaved ? 'Permission denied by user (rule saved)' : 'Permission denied by user';
216
+ const denialMessage = ruleSaved
217
+ ? `Permission denied by user, and a rule was saved to always deny the "${name}" tool for this pattern. Do NOT retry this tool call. Inform the user that this action has been permanently blocked by their preference. If the user wants to allow it in the future, they can update their permission rules.`
218
+ : `Permission denied by user. The user chose not to allow the "${name}" tool. Do NOT retry this tool call immediately. Instead, tell the user that the action was not performed because they denied permission, and ask if they would like you to try again or take a different approach. Wait for the user to explicitly respond before retrying.`;
219
+ const durationMs = Date.now() - startTime;
220
+ emitLifecycleEvent({
221
+ type: 'permission_denied',
222
+ toolName: name,
223
+ executionTarget,
224
+ input,
225
+ workingDir: context.workingDir,
226
+ sessionId: context.sessionId,
227
+ conversationId: context.conversationId,
228
+ requestId: context.requestId,
229
+ riskLevel,
230
+ decision: 'always_deny',
231
+ reason: denialReason,
232
+ durationMs,
233
+ });
234
+ return { allowed: false, decision, riskLevel, content: denialMessage };
235
+ }
236
+
237
+ if (
238
+ persistentDecisionsAllowed
239
+ && (response.decision === 'always_allow' || response.decision === 'always_allow_high_risk')
240
+ && response.selectedPattern
241
+ && response.selectedScope
242
+ ) {
243
+ const ruleOptions: {
244
+ allowHighRisk?: boolean;
245
+ executionTarget?: string;
246
+ } = {};
247
+
248
+ if (response.decision === 'always_allow_high_risk') {
249
+ ruleOptions.allowHighRisk = true;
250
+ }
251
+
252
+ if (policyContext?.executionTarget != null) {
253
+ ruleOptions.executionTarget = policyContext.executionTarget;
254
+ }
255
+
256
+ const hasOptions = Object.keys(ruleOptions).length > 0;
257
+ addRule(name, response.selectedPattern, response.selectedScope, 'allow', 100, hasOptions ? ruleOptions : undefined);
258
+ }
259
+
260
+ return { allowed: true, decision, riskLevel };
261
+ }
262
+
263
+ // result.decision === 'allow'
264
+ return { allowed: true, decision: 'allow', riskLevel };
265
+ } catch (err) {
266
+ if (err instanceof Error) {
267
+ (err as Error & { riskLevel?: string }).riskLevel = riskLevel;
268
+ }
269
+ throw err;
270
+ }
271
+ }
272
+ }
@@ -109,20 +109,25 @@ export function getAllTools(): Tool[] {
109
109
 
110
110
  /**
111
111
  * Register multiple skill-origin tools at once.
112
- * Throws if any tool name collides with a core tool (origin !== 'skill' or undefined origin).
112
+ * Skips any tool whose name collides with a core tool (logs a warning instead
113
+ * of throwing so the remaining tools in the batch still get registered).
114
+ * Throws if a tool name collides with a skill tool owned by a different skill.
113
115
  * Allows replacement when the incoming tool has the same ownerSkillId as the existing one,
114
116
  * which supports hot-reloading a skill without tearing down first.
115
117
  */
116
- export function registerSkillTools(newTools: Tool[]): void {
117
- // Validate all tools before mutating the registry so we get atomic-or-nothing behavior.
118
+ export function registerSkillTools(newTools: Tool[]): Tool[] {
119
+ // Filter out tools that collide with core tools, and validate the rest.
120
+ const accepted: Tool[] = [];
118
121
  for (const tool of newTools) {
119
122
  const existing = tools.get(tool.name);
120
123
  if (existing) {
121
124
  const existingIsCore = existing.origin !== 'skill';
122
125
  if (existingIsCore) {
123
- throw new Error(
124
- `Skill tool "${tool.name}" collides with core tool of the same name`,
126
+ log.warn(
127
+ { toolName: tool.name, skillId: tool.ownerSkillId },
128
+ `Skill "${tool.ownerSkillId}" tried to register tool "${tool.name}" which conflicts with a core tool. Skipping.`,
125
129
  );
130
+ continue;
126
131
  }
127
132
  // Existing is also a skill tool — only allow replacement from the same owner.
128
133
  if (existing.ownerSkillId !== tool.ownerSkillId) {
@@ -131,11 +136,12 @@ export function registerSkillTools(newTools: Tool[]): void {
131
136
  );
132
137
  }
133
138
  }
139
+ accepted.push(tool);
134
140
  }
135
141
 
136
142
  // Collect unique skill IDs from the batch to bump ref counts
137
143
  const skillIds = new Set<string>();
138
- for (const tool of newTools) {
144
+ for (const tool of accepted) {
139
145
  tools.set(tool.name, tool);
140
146
  if (tool.ownerSkillId) skillIds.add(tool.ownerSkillId);
141
147
  log.info({ name: tool.name, ownerSkillId: tool.ownerSkillId }, 'Skill tool registered');
@@ -144,6 +150,8 @@ export function registerSkillTools(newTools: Tool[]): void {
144
150
  for (const id of skillIds) {
145
151
  skillRefCount.set(id, (skillRefCount.get(id) ?? 0) + 1);
146
152
  }
153
+
154
+ return accepted;
147
155
  }
148
156
 
149
157
  /**
@@ -1,7 +1,7 @@
1
1
  import { and, asc, eq, lte } from 'drizzle-orm';
2
2
  import { v4 as uuid } from 'uuid';
3
3
 
4
- import { getDb } from '../../memory/db.js';
4
+ import { getDb, rawChanges } from '../../memory/db.js';
5
5
  import { reminders } from '../../memory/schema.js';
6
6
  import { cast,createRowMapper, parseJson } from '../../util/row-mapper.js';
7
7
 
@@ -107,12 +107,12 @@ export function listReminders(options?: { pendingOnly?: boolean }): ReminderRow[
107
107
  export function cancelReminder(id: string): boolean {
108
108
  const db = getDb();
109
109
  const now = Date.now();
110
- const result = db
110
+ db
111
111
  .update(reminders)
112
112
  .set({ status: 'cancelled', updatedAt: now })
113
113
  .where(and(eq(reminders.id, id), eq(reminders.status, 'pending')))
114
- .run() as unknown as { changes?: number };
115
- return (result.changes ?? 0) > 0;
114
+ .run();
115
+ return rawChanges() > 0;
116
116
  }
117
117
 
118
118
  /**
@@ -132,13 +132,13 @@ export function claimDueReminders(now: number): ReminderRow[] {
132
132
 
133
133
  const claimed: ReminderRow[] = [];
134
134
  for (const row of candidates) {
135
- const result = db
135
+ db
136
136
  .update(reminders)
137
137
  .set({ status: 'firing', firedAt: now, updatedAt: now })
138
138
  .where(and(eq(reminders.id, row.id), eq(reminders.status, 'pending')))
139
- .run() as unknown as { changes?: number };
139
+ .run();
140
140
 
141
- if ((result.changes ?? 0) === 0) continue;
141
+ if (rawChanges() === 0) continue;
142
142
 
143
143
  claimed.push(parseRow({
144
144
  ...row,
@@ -22,7 +22,7 @@ export function executeReminderCreate(input: Record<string, unknown>): ToolExecu
22
22
  const message = input.message as string | undefined;
23
23
  const mode = (input.mode as string | undefined) ?? 'notify';
24
24
  const routingIntentRaw = input.routing_intent as string | undefined;
25
- const routingHintsRaw = input.routing_hints as Record<string, unknown> | undefined;
25
+ const routingHintsRaw = input.routing_hints as unknown;
26
26
 
27
27
  if (!fireAtStr) {
28
28
  return { content: 'Error: fire_at is required for create', isError: true };
@@ -47,10 +47,13 @@ export function executeReminderCreate(input: Record<string, unknown>): ToolExecu
47
47
  }
48
48
 
49
49
  // Validate routing_hints if provided
50
- const routingHints: RoutingHints = (routingHintsRaw as RoutingHints) ?? {};
51
- if (routingHintsRaw !== undefined && (typeof routingHintsRaw !== 'object' || Array.isArray(routingHintsRaw))) {
50
+ if (
51
+ routingHintsRaw !== undefined
52
+ && (!routingHintsRaw || typeof routingHintsRaw !== 'object' || Array.isArray(routingHintsRaw))
53
+ ) {
52
54
  return { content: 'Error: routing_hints must be a JSON object', isError: true };
53
55
  }
56
+ const routingHints: RoutingHints = routingHintsRaw === undefined ? {} : routingHintsRaw as RoutingHints;
54
57
 
55
58
  // Require strict ISO 8601 with timezone offset or Z to avoid ambiguous parsing
56
59
  if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:\d{2})$/.test(fireAtStr)) {
@@ -0,0 +1,301 @@
1
+ import { getConfig } from '../config/loader.js';
2
+ import { getHookManager } from '../hooks/manager.js';
3
+ import { PermissionPrompter } from '../permissions/prompter.js';
4
+ import { RiskLevel } from '../permissions/types.js';
5
+ import { compileCustomPatterns, redactSecrets, scanText } from '../security/secret-scanner.js';
6
+ import type { SecretPattern } from '../security/secret-scanner.js';
7
+ import type { ExecutionTarget, ToolContext, ToolExecutionResult, ToolLifecycleEvent } from './types.js';
8
+
9
+ /**
10
+ * Encapsulates post-execution secret detection, redaction, and action handling.
11
+ * Extracted from ToolExecutor to isolate the secret-scanning concern.
12
+ */
13
+ export class SecretDetectionHandler {
14
+ private prompter: PermissionPrompter;
15
+
16
+ constructor(prompter: PermissionPrompter) {
17
+ this.prompter = prompter;
18
+ }
19
+
20
+ /**
21
+ * Scan a tool execution result for secrets and apply the configured action
22
+ * (redact, block, or prompt). Returns the (possibly modified) result, or
23
+ * a blocked result if secrets were blocked. Returns `null` when no secret
24
+ * handling was needed and the caller should continue normally.
25
+ */
26
+ async handle(
27
+ execResult: ToolExecutionResult,
28
+ name: string,
29
+ input: Record<string, unknown>,
30
+ context: ToolContext,
31
+ executionTarget: ExecutionTarget,
32
+ riskLevel: string,
33
+ decision: string,
34
+ startTime: number,
35
+ emitLifecycleEvent: (context: ToolContext, event: ToolLifecycleEvent) => void,
36
+ sanitizeToolInput: (toolName: string, input: Record<string, unknown>) => Record<string, unknown>,
37
+ ): Promise<{ result: ToolExecutionResult; earlyReturn: boolean }> {
38
+ const sdConfig = getConfig().secretDetection;
39
+ if (!sdConfig.enabled || execResult.isError) {
40
+ return { result: execResult, earlyReturn: false };
41
+ }
42
+
43
+ const entropyConfig = { enabled: true, base64Threshold: sdConfig.entropyThreshold };
44
+ const compiledCustom = sdConfig.customPatterns?.length
45
+ ? compileCustomPatterns(sdConfig.customPatterns)
46
+ : undefined;
47
+
48
+ const allMatches = this.collectMatches(execResult, entropyConfig, compiledCustom);
49
+
50
+ if (allMatches.length === 0) {
51
+ return { result: execResult, earlyReturn: false };
52
+ }
53
+
54
+ const matchSummary = allMatches.map((m) => ({
55
+ type: m.type,
56
+ redactedValue: m.redactedValue,
57
+ }));
58
+
59
+ emitLifecycleEvent(context, {
60
+ type: 'secret_detected',
61
+ toolName: name,
62
+ executionTarget,
63
+ input,
64
+ workingDir: context.workingDir,
65
+ sessionId: context.sessionId,
66
+ conversationId: context.conversationId,
67
+ requestId: context.requestId,
68
+ matches: matchSummary,
69
+ action: sdConfig.action,
70
+ detectedAtMs: Date.now(),
71
+ });
72
+
73
+ if (sdConfig.action === 'redact') {
74
+ this.redactResult(execResult, entropyConfig, compiledCustom);
75
+ return { result: execResult, earlyReturn: false };
76
+ }
77
+
78
+ if (sdConfig.action === 'block') {
79
+ return this.handleBlock(
80
+ allMatches, name, input, context, executionTarget,
81
+ riskLevel, decision, startTime, emitLifecycleEvent, sanitizeToolInput,
82
+ );
83
+ }
84
+
85
+ if (sdConfig.action === 'prompt') {
86
+ return this.handlePrompt(
87
+ allMatches, execResult, name, input, context, executionTarget,
88
+ riskLevel, decision, startTime, emitLifecycleEvent, sanitizeToolInput,
89
+ );
90
+ }
91
+
92
+ return { result: execResult, earlyReturn: false };
93
+ }
94
+
95
+ private collectMatches(
96
+ execResult: ToolExecutionResult,
97
+ entropyConfig: { enabled: boolean; base64Threshold: number },
98
+ compiledCustom: SecretPattern[] | undefined,
99
+ ) {
100
+ const contentMatches = scanText(execResult.content, entropyConfig, compiledCustom);
101
+ const diffMatches = execResult.diff
102
+ ? scanText(execResult.diff.newContent, entropyConfig, compiledCustom)
103
+ : [];
104
+ const blockMatches = (execResult.contentBlocks ?? []).flatMap((block) => {
105
+ if (block.type === 'text') return scanText(block.text, entropyConfig, compiledCustom);
106
+ if (block.type === 'file' && block.extracted_text) return scanText(block.extracted_text, entropyConfig, compiledCustom);
107
+ return [];
108
+ });
109
+ return [...contentMatches, ...diffMatches, ...blockMatches];
110
+ }
111
+
112
+ private redactResult(
113
+ execResult: ToolExecutionResult,
114
+ entropyConfig: { enabled: boolean; base64Threshold: number },
115
+ compiledCustom: SecretPattern[] | undefined,
116
+ ): void {
117
+ execResult.content = redactSecrets(execResult.content, entropyConfig, compiledCustom);
118
+ if (execResult.diff) {
119
+ execResult.diff = {
120
+ ...execResult.diff,
121
+ newContent: redactSecrets(execResult.diff.newContent, entropyConfig, compiledCustom),
122
+ };
123
+ }
124
+ if (execResult.contentBlocks) {
125
+ execResult.contentBlocks = execResult.contentBlocks.map((block) => {
126
+ if (block.type === 'text') {
127
+ return { ...block, text: redactSecrets(block.text, entropyConfig, compiledCustom) };
128
+ }
129
+ if (block.type === 'file' && block.extracted_text) {
130
+ return { ...block, extracted_text: redactSecrets(block.extracted_text, entropyConfig, compiledCustom) };
131
+ }
132
+ return block;
133
+ });
134
+ }
135
+ }
136
+
137
+ private handleBlock(
138
+ allMatches: Array<{ type: string; redactedValue: string }>,
139
+ name: string,
140
+ input: Record<string, unknown>,
141
+ context: ToolContext,
142
+ executionTarget: ExecutionTarget,
143
+ riskLevel: string,
144
+ decision: string,
145
+ startTime: number,
146
+ emitLifecycleEvent: (context: ToolContext, event: ToolLifecycleEvent) => void,
147
+ sanitizeToolInput: (toolName: string, input: Record<string, unknown>) => Record<string, unknown>,
148
+ ): { result: ToolExecutionResult; earlyReturn: boolean } {
149
+ const types = [...new Set(allMatches.map((m) => m.type))].join(', ');
150
+ const blockedContent = `Tool output blocked: detected ${allMatches.length} potential secret(s) (${types}). Configure secretDetection.action to "redact" or "prompt" to allow output.`;
151
+ const durationMs = Date.now() - startTime;
152
+ const blockedResult: ToolExecutionResult = {
153
+ content: blockedContent,
154
+ isError: true,
155
+ };
156
+
157
+ emitLifecycleEvent(context, {
158
+ type: 'executed',
159
+ toolName: name,
160
+ executionTarget,
161
+ input,
162
+ workingDir: context.workingDir,
163
+ sessionId: context.sessionId,
164
+ conversationId: context.conversationId,
165
+ requestId: context.requestId,
166
+ riskLevel,
167
+ decision,
168
+ durationMs,
169
+ result: blockedResult,
170
+ });
171
+
172
+ void getHookManager().trigger('post-tool-execute', {
173
+ toolName: name,
174
+ input: sanitizeToolInput(name, input),
175
+ riskLevel,
176
+ isError: true,
177
+ durationMs,
178
+ sessionId: context.sessionId,
179
+ });
180
+
181
+ return { result: blockedResult, earlyReturn: true };
182
+ }
183
+
184
+ private async handlePrompt(
185
+ allMatches: Array<{ type: string; redactedValue: string }>,
186
+ execResult: ToolExecutionResult,
187
+ name: string,
188
+ input: Record<string, unknown>,
189
+ context: ToolContext,
190
+ executionTarget: ExecutionTarget,
191
+ riskLevel: string,
192
+ _decision: string,
193
+ startTime: number,
194
+ emitLifecycleEvent: (context: ToolContext, event: ToolLifecycleEvent) => void,
195
+ sanitizeToolInput: (toolName: string, input: Record<string, unknown>) => Record<string, unknown>,
196
+ ): Promise<{ result: ToolExecutionResult; earlyReturn: boolean }> {
197
+ const types = [...new Set(allMatches.map((m) => m.type))].join(', ');
198
+
199
+ // Non-interactive sessions: auto-block secret output instead of waiting for prompt
200
+ if (context.isInteractive === false) {
201
+ const blockedContent = `Tool output blocked: detected ${allMatches.length} potential secret(s) (${types}). No interactive client available to approve.`;
202
+ const durationMs = Date.now() - startTime;
203
+
204
+ emitLifecycleEvent(context, {
205
+ type: 'permission_denied',
206
+ toolName: name,
207
+ executionTarget,
208
+ input,
209
+ workingDir: context.workingDir,
210
+ sessionId: context.sessionId,
211
+ conversationId: context.conversationId,
212
+ requestId: context.requestId,
213
+ riskLevel: RiskLevel.High,
214
+ decision: 'deny',
215
+ reason: 'Non-interactive session: auto-blocked secret output',
216
+ durationMs,
217
+ });
218
+
219
+ void getHookManager().trigger('post-tool-execute', {
220
+ toolName: name,
221
+ input: sanitizeToolInput(name, input),
222
+ riskLevel,
223
+ isError: true,
224
+ durationMs,
225
+ sessionId: context.sessionId,
226
+ });
227
+
228
+ return { result: { content: blockedContent, isError: true }, earlyReturn: true };
229
+ }
230
+
231
+ const promptInput = {
232
+ _secretDetection: true,
233
+ summary: `Tool output contains ${allMatches.length} potential secret(s): ${types}`,
234
+ tool: name,
235
+ };
236
+
237
+ emitLifecycleEvent(context, {
238
+ type: 'permission_prompt',
239
+ toolName: name,
240
+ executionTarget,
241
+ input: promptInput,
242
+ workingDir: context.workingDir,
243
+ sessionId: context.sessionId,
244
+ conversationId: context.conversationId,
245
+ requestId: context.requestId,
246
+ riskLevel: RiskLevel.High,
247
+ reason: `Secret detected in tool output: ${types}`,
248
+ allowlistOptions: [],
249
+ scopeOptions: [],
250
+ persistentDecisionsAllowed: false,
251
+ });
252
+
253
+ const response = await this.prompter.prompt(
254
+ name,
255
+ promptInput,
256
+ RiskLevel.High,
257
+ [], // no allowlist options
258
+ [], // no scope options
259
+ undefined, // no diff
260
+ undefined, // not sandboxed
261
+ context.conversationId,
262
+ executionTarget,
263
+ false, // no persistent decisions
264
+ context.signal,
265
+ );
266
+
267
+ if (response.decision === 'deny' || response.decision === 'always_deny') {
268
+ const blockedContent = `Tool output blocked: user denied output containing ${allMatches.length} potential secret(s) (${types}).`;
269
+ const durationMs = Date.now() - startTime;
270
+
271
+ emitLifecycleEvent(context, {
272
+ type: 'permission_denied',
273
+ toolName: name,
274
+ executionTarget,
275
+ input,
276
+ workingDir: context.workingDir,
277
+ sessionId: context.sessionId,
278
+ conversationId: context.conversationId,
279
+ requestId: context.requestId,
280
+ riskLevel: RiskLevel.High,
281
+ decision: response.decision === 'always_deny' ? 'always_deny' : 'deny',
282
+ reason: `User denied output containing secrets: ${types}`,
283
+ durationMs,
284
+ });
285
+
286
+ void getHookManager().trigger('post-tool-execute', {
287
+ toolName: name,
288
+ input: sanitizeToolInput(name, input),
289
+ riskLevel,
290
+ isError: true,
291
+ durationMs,
292
+ sessionId: context.sessionId,
293
+ });
294
+
295
+ return { result: { content: blockedContent, isError: true }, earlyReturn: true };
296
+ }
297
+
298
+ // User allowed — pass content through unchanged
299
+ return { result: execResult, earlyReturn: false };
300
+ }
301
+ }