@librechat/agents 3.1.76 → 3.1.77

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 (85) hide show
  1. package/dist/cjs/graphs/Graph.cjs +9 -0
  2. package/dist/cjs/graphs/Graph.cjs.map +1 -1
  3. package/dist/cjs/hitl/askUserQuestion.cjs +67 -0
  4. package/dist/cjs/hitl/askUserQuestion.cjs.map +1 -0
  5. package/dist/cjs/hooks/HookRegistry.cjs +54 -0
  6. package/dist/cjs/hooks/HookRegistry.cjs.map +1 -1
  7. package/dist/cjs/hooks/createToolPolicyHook.cjs +115 -0
  8. package/dist/cjs/hooks/createToolPolicyHook.cjs.map +1 -0
  9. package/dist/cjs/hooks/executeHooks.cjs +40 -1
  10. package/dist/cjs/hooks/executeHooks.cjs.map +1 -1
  11. package/dist/cjs/hooks/types.cjs +1 -0
  12. package/dist/cjs/hooks/types.cjs.map +1 -1
  13. package/dist/cjs/llm/openai/index.cjs +317 -1
  14. package/dist/cjs/llm/openai/index.cjs.map +1 -1
  15. package/dist/cjs/main.cjs +29 -0
  16. package/dist/cjs/main.cjs.map +1 -1
  17. package/dist/cjs/run.cjs +400 -42
  18. package/dist/cjs/run.cjs.map +1 -1
  19. package/dist/cjs/tools/ToolNode.cjs +551 -55
  20. package/dist/cjs/tools/ToolNode.cjs.map +1 -1
  21. package/dist/cjs/tools/search/tavily-scraper.cjs.map +1 -1
  22. package/dist/cjs/tools/search/tavily-search.cjs.map +1 -1
  23. package/dist/cjs/tools/search/tool.cjs.map +1 -1
  24. package/dist/esm/graphs/Graph.mjs +9 -0
  25. package/dist/esm/graphs/Graph.mjs.map +1 -1
  26. package/dist/esm/hitl/askUserQuestion.mjs +65 -0
  27. package/dist/esm/hitl/askUserQuestion.mjs.map +1 -0
  28. package/dist/esm/hooks/HookRegistry.mjs +54 -0
  29. package/dist/esm/hooks/HookRegistry.mjs.map +1 -1
  30. package/dist/esm/hooks/createToolPolicyHook.mjs +113 -0
  31. package/dist/esm/hooks/createToolPolicyHook.mjs.map +1 -0
  32. package/dist/esm/hooks/executeHooks.mjs +40 -1
  33. package/dist/esm/hooks/executeHooks.mjs.map +1 -1
  34. package/dist/esm/hooks/types.mjs +1 -0
  35. package/dist/esm/hooks/types.mjs.map +1 -1
  36. package/dist/esm/llm/openai/index.mjs +318 -2
  37. package/dist/esm/llm/openai/index.mjs.map +1 -1
  38. package/dist/esm/main.mjs +3 -0
  39. package/dist/esm/main.mjs.map +1 -1
  40. package/dist/esm/run.mjs +400 -42
  41. package/dist/esm/run.mjs.map +1 -1
  42. package/dist/esm/tools/ToolNode.mjs +552 -56
  43. package/dist/esm/tools/ToolNode.mjs.map +1 -1
  44. package/dist/esm/tools/search/tavily-scraper.mjs.map +1 -1
  45. package/dist/esm/tools/search/tavily-search.mjs.map +1 -1
  46. package/dist/esm/tools/search/tool.mjs.map +1 -1
  47. package/dist/types/graphs/Graph.d.ts +7 -0
  48. package/dist/types/hitl/askUserQuestion.d.ts +55 -0
  49. package/dist/types/hitl/index.d.ts +6 -0
  50. package/dist/types/hooks/HookRegistry.d.ts +58 -0
  51. package/dist/types/hooks/createToolPolicyHook.d.ts +87 -0
  52. package/dist/types/hooks/index.d.ts +4 -1
  53. package/dist/types/hooks/types.d.ts +109 -3
  54. package/dist/types/index.d.ts +9 -0
  55. package/dist/types/llm/openai/index.d.ts +17 -0
  56. package/dist/types/run.d.ts +117 -1
  57. package/dist/types/tools/ToolNode.d.ts +26 -1
  58. package/dist/types/types/hitl.d.ts +272 -0
  59. package/dist/types/types/index.d.ts +1 -0
  60. package/dist/types/types/run.d.ts +33 -0
  61. package/dist/types/types/tools.d.ts +19 -0
  62. package/package.json +1 -1
  63. package/src/graphs/Graph.ts +9 -0
  64. package/src/hitl/askUserQuestion.ts +72 -0
  65. package/src/hitl/index.ts +7 -0
  66. package/src/hooks/HookRegistry.ts +71 -0
  67. package/src/hooks/__tests__/createToolPolicyHook.test.ts +259 -0
  68. package/src/hooks/createToolPolicyHook.ts +184 -0
  69. package/src/hooks/executeHooks.ts +50 -1
  70. package/src/hooks/index.ts +6 -0
  71. package/src/hooks/types.ts +112 -0
  72. package/src/index.ts +19 -0
  73. package/src/llm/openai/deepseek.test.ts +479 -0
  74. package/src/llm/openai/index.ts +484 -1
  75. package/src/run.ts +456 -47
  76. package/src/tools/ToolNode.ts +701 -62
  77. package/src/tools/__tests__/hitl.test.ts +3593 -0
  78. package/src/tools/search/tavily-scraper.ts +4 -4
  79. package/src/tools/search/tavily-search.ts +32 -32
  80. package/src/tools/search/tool.ts +3 -3
  81. package/src/tools/search/types.ts +3 -1
  82. package/src/types/hitl.ts +303 -0
  83. package/src/types/index.ts +1 -0
  84. package/src/types/run.ts +33 -0
  85. package/src/types/tools.ts +19 -0
@@ -0,0 +1,259 @@
1
+ import { describe, it, expect } from '@jest/globals';
2
+ import type {
3
+ HookCallback,
4
+ PreToolUseHookInput,
5
+ PreToolUseHookOutput,
6
+ } from '../types';
7
+ import { createToolPolicyHook } from '../createToolPolicyHook';
8
+
9
+ const baseInput: Omit<PreToolUseHookInput, 'toolName'> = {
10
+ hook_event_name: 'PreToolUse',
11
+ runId: 'r-1',
12
+ toolInput: {},
13
+ toolUseId: 'call-1',
14
+ stepId: 'step-1',
15
+ turn: 0,
16
+ };
17
+
18
+ async function callHook(
19
+ hook: HookCallback<'PreToolUse'>,
20
+ toolName: string
21
+ ): Promise<PreToolUseHookOutput> {
22
+ const signal = new AbortController().signal;
23
+ return await hook({ ...baseInput, toolName }, signal);
24
+ }
25
+
26
+ describe('createToolPolicyHook — default mode', () => {
27
+ it('asks for tools that match no rule', async () => {
28
+ const hook = createToolPolicyHook({ mode: 'default' });
29
+ expect((await callHook(hook, 'unknown_tool')).decision).toBe('ask');
30
+ });
31
+
32
+ it('allows tools that match an allow pattern', async () => {
33
+ const hook = createToolPolicyHook({
34
+ mode: 'default',
35
+ allow: ['read_file', 'grep'],
36
+ });
37
+ expect((await callHook(hook, 'read_file')).decision).toBe('allow');
38
+ expect((await callHook(hook, 'grep')).decision).toBe('allow');
39
+ expect((await callHook(hook, 'write_file')).decision).toBe('ask');
40
+ });
41
+
42
+ it('denies tools that match a deny pattern', async () => {
43
+ const hook = createToolPolicyHook({
44
+ mode: 'default',
45
+ deny: ['delete_*'],
46
+ });
47
+ expect((await callHook(hook, 'delete_file')).decision).toBe('deny');
48
+ expect((await callHook(hook, 'read_file')).decision).toBe('ask');
49
+ });
50
+
51
+ it('asks tools that match an ask pattern (redundant in default mode but explicit)', async () => {
52
+ const hook = createToolPolicyHook({
53
+ mode: 'default',
54
+ ask: ['execute_*'],
55
+ });
56
+ expect((await callHook(hook, 'execute_code')).decision).toBe('ask');
57
+ });
58
+ });
59
+
60
+ describe('createToolPolicyHook — dontAsk mode', () => {
61
+ it('denies tools that match no rule (no human prompt)', async () => {
62
+ const hook = createToolPolicyHook({ mode: 'dontAsk' });
63
+ expect((await callHook(hook, 'unknown_tool')).decision).toBe('deny');
64
+ });
65
+
66
+ it('still allows tools that match an allow pattern', async () => {
67
+ const hook = createToolPolicyHook({
68
+ mode: 'dontAsk',
69
+ allow: ['read_*'],
70
+ });
71
+ expect((await callHook(hook, 'read_file')).decision).toBe('allow');
72
+ expect((await callHook(hook, 'write_file')).decision).toBe('deny');
73
+ });
74
+
75
+ it('still asks tools that match an explicit ask pattern (overrides dontAsk default)', async () => {
76
+ const hook = createToolPolicyHook({
77
+ mode: 'dontAsk',
78
+ ask: ['execute_*'],
79
+ });
80
+ expect((await callHook(hook, 'execute_code')).decision).toBe('ask');
81
+ expect((await callHook(hook, 'unknown_tool')).decision).toBe('deny');
82
+ });
83
+ });
84
+
85
+ describe('createToolPolicyHook — bypass mode', () => {
86
+ it('allows everything by default', async () => {
87
+ const hook = createToolPolicyHook({ mode: 'bypass' });
88
+ expect((await callHook(hook, 'anything')).decision).toBe('allow');
89
+ expect((await callHook(hook, 'execute_code')).decision).toBe('allow');
90
+ });
91
+
92
+ it('still denies tools that match a deny pattern (deny always wins)', async () => {
93
+ const hook = createToolPolicyHook({
94
+ mode: 'bypass',
95
+ deny: ['delete_*'],
96
+ });
97
+ expect((await callHook(hook, 'delete_file')).decision).toBe('deny');
98
+ expect((await callHook(hook, 'read_file')).decision).toBe('allow');
99
+ });
100
+
101
+ it('overrides explicit ask patterns (bypass means stop asking)', async () => {
102
+ const hook = createToolPolicyHook({
103
+ mode: 'bypass',
104
+ ask: ['execute_*'],
105
+ });
106
+ expect((await callHook(hook, 'execute_code')).decision).toBe('allow');
107
+ });
108
+ });
109
+
110
+ describe('createToolPolicyHook — pattern matching', () => {
111
+ it('matches glob `*` wildcards', async () => {
112
+ const hook = createToolPolicyHook({
113
+ mode: 'default',
114
+ allow: ['mcp:github:*'],
115
+ });
116
+ expect((await callHook(hook, 'mcp:github:create_issue')).decision).toBe(
117
+ 'allow'
118
+ );
119
+ expect((await callHook(hook, 'mcp:github:list_repos')).decision).toBe(
120
+ 'allow'
121
+ );
122
+ expect((await callHook(hook, 'mcp:slack:post')).decision).toBe('ask');
123
+ });
124
+
125
+ it('matches exact tool names', async () => {
126
+ const hook = createToolPolicyHook({
127
+ mode: 'default',
128
+ allow: ['read_file'],
129
+ });
130
+ expect((await callHook(hook, 'read_file')).decision).toBe('allow');
131
+ expect((await callHook(hook, 'read_file_lines')).decision).toBe('ask');
132
+ });
133
+
134
+ it('escapes regex metacharacters in literal portions', async () => {
135
+ const hook = createToolPolicyHook({
136
+ mode: 'default',
137
+ allow: ['tool.with.dots'],
138
+ });
139
+ expect((await callHook(hook, 'tool.with.dots')).decision).toBe('allow');
140
+ /** A literal regex `.` would also match `tool_with_dots`; glob shouldn't. */
141
+ expect((await callHook(hook, 'tool_with_dots')).decision).toBe('ask');
142
+ });
143
+
144
+ it('matches wildcards in the middle and end', async () => {
145
+ const hook = createToolPolicyHook({
146
+ mode: 'default',
147
+ ask: ['*search*'],
148
+ });
149
+ expect((await callHook(hook, 'web_search')).decision).toBe('ask');
150
+ expect((await callHook(hook, 'searcher')).decision).toBe('ask');
151
+ expect((await callHook(hook, 'read_file')).decision).toBe('ask'); // default mode
152
+ /** Confirm the ask path tagged it (not the fallthrough): explicit ask hits before mode fallthrough. */
153
+ });
154
+ });
155
+
156
+ describe('createToolPolicyHook — precedence', () => {
157
+ it('deny wins over allow', async () => {
158
+ const hook = createToolPolicyHook({
159
+ mode: 'default',
160
+ allow: ['read_*'],
161
+ deny: ['read_secret'],
162
+ });
163
+ expect((await callHook(hook, 'read_secret')).decision).toBe('deny');
164
+ expect((await callHook(hook, 'read_file')).decision).toBe('allow');
165
+ });
166
+
167
+ it('deny wins over bypass mode', async () => {
168
+ const hook = createToolPolicyHook({
169
+ mode: 'bypass',
170
+ deny: ['delete_*'],
171
+ });
172
+ expect((await callHook(hook, 'delete_file')).decision).toBe('deny');
173
+ expect((await callHook(hook, 'anything_else')).decision).toBe('allow');
174
+ });
175
+
176
+ it('allow wins over ask in default mode', async () => {
177
+ const hook = createToolPolicyHook({
178
+ mode: 'default',
179
+ allow: ['execute_safe'],
180
+ ask: ['execute_*'],
181
+ });
182
+ expect((await callHook(hook, 'execute_safe')).decision).toBe('allow');
183
+ expect((await callHook(hook, 'execute_dangerous')).decision).toBe('ask');
184
+ });
185
+ });
186
+
187
+ describe('createToolPolicyHook — reason', () => {
188
+ it('attaches the configured reason to ask and deny decisions', async () => {
189
+ const hook = createToolPolicyHook({
190
+ mode: 'default',
191
+ deny: ['delete_*'],
192
+ reason: 'Tool {tool} requires manual review',
193
+ });
194
+ const denied = await callHook(hook, 'delete_file');
195
+ expect(denied.decision).toBe('deny');
196
+ expect(denied.reason).toBe('Tool delete_file requires manual review');
197
+
198
+ const asked = await callHook(hook, 'unknown_tool');
199
+ expect(asked.decision).toBe('ask');
200
+ expect(asked.reason).toBe('Tool unknown_tool requires manual review');
201
+ });
202
+
203
+ it('omits the reason field for allow decisions', async () => {
204
+ const hook = createToolPolicyHook({
205
+ mode: 'default',
206
+ allow: ['read_*'],
207
+ reason: 'never seen',
208
+ });
209
+ const result = await callHook(hook, 'read_file');
210
+ expect(result.decision).toBe('allow');
211
+ expect(result.reason).toBeUndefined();
212
+ });
213
+
214
+ it('does not add a reason field when no template is configured', async () => {
215
+ const hook = createToolPolicyHook({ mode: 'dontAsk' });
216
+ const result = await callHook(hook, 'unknown_tool');
217
+ expect(result.decision).toBe('deny');
218
+ expect(result.reason).toBeUndefined();
219
+ });
220
+ });
221
+
222
+ describe('createToolPolicyHook — registry integration', () => {
223
+ it('works when registered as a PreToolUse hook (round-trip via executeHooks)', async () => {
224
+ const { HookRegistry, executeHooks } = await import('../index');
225
+ const registry = new HookRegistry();
226
+ registry.register('PreToolUse', {
227
+ hooks: [
228
+ createToolPolicyHook({
229
+ mode: 'default',
230
+ allow: ['read_file'],
231
+ deny: ['delete_*'],
232
+ reason: 'review {tool}',
233
+ }),
234
+ ],
235
+ });
236
+
237
+ const allow = await executeHooks({
238
+ registry,
239
+ input: { ...baseInput, toolName: 'read_file' },
240
+ matchQuery: 'read_file',
241
+ });
242
+ expect(allow.decision).toBe('allow');
243
+
244
+ const deny = await executeHooks({
245
+ registry,
246
+ input: { ...baseInput, toolName: 'delete_file' },
247
+ matchQuery: 'delete_file',
248
+ });
249
+ expect(deny.decision).toBe('deny');
250
+ expect(deny.reason).toBe('review delete_file');
251
+
252
+ const ask = await executeHooks({
253
+ registry,
254
+ input: { ...baseInput, toolName: 'mystery_tool' },
255
+ matchQuery: 'mystery_tool',
256
+ });
257
+ expect(ask.decision).toBe('ask');
258
+ });
259
+ });
@@ -0,0 +1,184 @@
1
+ /**
2
+ * Declarative `PreToolUse` hook factory. Lets hosts express common
3
+ * permission policies (allow / deny / ask lists + a global mode) without
4
+ * hand-rolling matching, precedence, and decision logic per-host.
5
+ *
6
+ * Maps directly to the Claude Code Agent SDK permission vocabulary
7
+ * (`allowed_tools` / `disallowed_tools` / `permissionMode`) so users of
8
+ * either SDK can think in the same terms. See the README's HITL section
9
+ * for the cross-walk and `docs/hooks-design-report.md` for the broader
10
+ * hook system context.
11
+ */
12
+
13
+ import type { HookCallback, PreToolUseHookOutput, ToolDecision } from './types';
14
+
15
+ /**
16
+ * Permission mode controlling how tool calls that match no rule are
17
+ * resolved. Mirrors Claude Code's `permissionMode`.
18
+ *
19
+ * - `default` — unmatched tools fall through to `'ask'` (interrupt).
20
+ * - `dontAsk` — unmatched tools are denied; the human is never
21
+ * prompted. Useful for headless / API agents where a
22
+ * silent denial is preferable to a hung interrupt.
23
+ * - `bypass` — every tool is approved, except those matching `deny`
24
+ * patterns. The kill switch you flip when you trust
25
+ * the agent and want to stop being asked. Equivalent to
26
+ * Claude Code's `bypassPermissions`.
27
+ */
28
+ export type ToolPolicyMode = 'default' | 'dontAsk' | 'bypass';
29
+
30
+ export interface ToolPolicyConfig {
31
+ /**
32
+ * Global mode applied to tools that don't match any rule.
33
+ * Defaults to `'default'` (ask the human).
34
+ */
35
+ mode?: ToolPolicyMode;
36
+ /**
37
+ * Tool name patterns that are auto-approved without a prompt.
38
+ * Patterns support glob `*` wildcards: `read_file`, `mcp:github:*`,
39
+ * `*search*`. Match is anchored (`^pattern$`).
40
+ */
41
+ allow?: readonly string[];
42
+ /**
43
+ * Tool name patterns that are blocked outright. Wins over `allow`
44
+ * and `ask`, and overrides `mode: 'bypass'` — a deny rule always
45
+ * holds, matching Claude Code's "deny rules are checked first" guarantee.
46
+ */
47
+ deny?: readonly string[];
48
+ /**
49
+ * Tool name patterns that always trigger human approval, regardless
50
+ * of `mode: 'default'` vs `'dontAsk'`. In `mode: 'bypass'` these are
51
+ * still bypassed (because that's what bypass means).
52
+ */
53
+ ask?: readonly string[];
54
+ /**
55
+ * Optional reason attached to the resulting `ask` / `deny` hook
56
+ * decision so the host UI can render why approval is required.
57
+ * The literal token `{tool}` is replaced with the tool name.
58
+ */
59
+ reason?: string;
60
+ }
61
+
62
+ /**
63
+ * Compile a glob string with `*` wildcards into a single anchored
64
+ * `RegExp`. Other regex metacharacters are escaped, so `read_file.md`
65
+ * matches the literal dot. Patterns are short (tool names), so we do
66
+ * not cache here — the registry's `matchesQuery` already caches its own
67
+ * regex compilations and our patterns are evaluated once per ToolNode
68
+ * batch, not once per stream chunk.
69
+ */
70
+ function globToRegex(pattern: string): RegExp {
71
+ const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&');
72
+ return new RegExp('^' + escaped.replace(/\*/g, '.*') + '$');
73
+ }
74
+
75
+ /** Pre-compile a list of glob patterns into a single match function. */
76
+ function compileMatchers(
77
+ patterns: readonly string[] | undefined
78
+ ): (toolName: string) => boolean {
79
+ if (patterns == null || patterns.length === 0) {
80
+ return () => false;
81
+ }
82
+ const regexes = patterns.map(globToRegex);
83
+ return (toolName: string): boolean => {
84
+ for (const regex of regexes) {
85
+ if (regex.test(toolName)) {
86
+ return true;
87
+ }
88
+ }
89
+ return false;
90
+ };
91
+ }
92
+
93
+ function formatReason(
94
+ template: string | undefined,
95
+ toolName: string
96
+ ): string | undefined {
97
+ if (template == null) {
98
+ return undefined;
99
+ }
100
+ return template.replace(/\{tool\}/g, toolName);
101
+ }
102
+
103
+ /**
104
+ * Build a `PreToolUse` hook callback that applies a declarative tool
105
+ * permission policy. Register it with a `HookRegistry` and the SDK's
106
+ * `humanInTheLoop` machinery handles the rest:
107
+ *
108
+ * ```ts
109
+ * const policyHook = createToolPolicyHook({
110
+ * mode: 'default',
111
+ * allow: ['read_*', 'grep', 'glob'],
112
+ * deny: ['delete_*'],
113
+ * ask: ['execute_*', 'mcp:*'],
114
+ * });
115
+ * registry.register('PreToolUse', { hooks: [policyHook] });
116
+ * ```
117
+ *
118
+ * Evaluation order matches Claude Code's permission flow:
119
+ *
120
+ * 1. `deny` rule match → `'deny'` (always wins, even in `bypass`).
121
+ * 2. `mode === 'bypass'` → `'allow'`.
122
+ * 3. `allow` rule match → `'allow'`.
123
+ * 4. `ask` rule match → `'ask'`.
124
+ * 5. `mode === 'dontAsk'` → `'deny'`.
125
+ * 6. fallthrough → `'ask'`.
126
+ *
127
+ * The returned callback is a single `HookCallback`, not a `HookMatcher` —
128
+ * register it under the matcher with the pattern you want (omit the
129
+ * pattern to fire on every tool call, which is the typical case since
130
+ * the policy itself does the filtering).
131
+ */
132
+ export function createToolPolicyHook(
133
+ config: ToolPolicyConfig
134
+ ): HookCallback<'PreToolUse'> {
135
+ const denyMatcher = compileMatchers(config.deny);
136
+ const allowMatcher = compileMatchers(config.allow);
137
+ const askMatcher = compileMatchers(config.ask);
138
+ const mode: ToolPolicyMode = config.mode ?? 'default';
139
+ const reasonTemplate = config.reason;
140
+
141
+ return async (input): Promise<PreToolUseHookOutput> => {
142
+ const toolName = input.toolName;
143
+ const decision = decide(
144
+ toolName,
145
+ mode,
146
+ denyMatcher,
147
+ allowMatcher,
148
+ askMatcher
149
+ );
150
+ if (decision === 'allow') {
151
+ return { decision };
152
+ }
153
+ const reason = formatReason(reasonTemplate, toolName);
154
+ if (reason != null) {
155
+ return { decision, reason };
156
+ }
157
+ return { decision };
158
+ };
159
+ }
160
+
161
+ function decide(
162
+ toolName: string,
163
+ mode: ToolPolicyMode,
164
+ denyMatch: (n: string) => boolean,
165
+ allowMatch: (n: string) => boolean,
166
+ askMatch: (n: string) => boolean
167
+ ): ToolDecision {
168
+ if (denyMatch(toolName)) {
169
+ return 'deny';
170
+ }
171
+ if (mode === 'bypass') {
172
+ return 'allow';
173
+ }
174
+ if (allowMatch(toolName)) {
175
+ return 'allow';
176
+ }
177
+ if (askMatch(toolName)) {
178
+ return 'ask';
179
+ }
180
+ if (mode === 'dontAsk') {
181
+ return 'deny';
182
+ }
183
+ return 'ask';
184
+ }
@@ -255,6 +255,19 @@ function applyUpdatedOutput(
255
255
  agg.updatedOutput = output.updatedOutput;
256
256
  }
257
257
 
258
+ function applyAllowedDecisions(
259
+ agg: AggregatedHookResult,
260
+ output: HookOutput
261
+ ): void {
262
+ if (
263
+ !('allowedDecisions' in output) ||
264
+ output.allowedDecisions === undefined
265
+ ) {
266
+ return;
267
+ }
268
+ agg.allowedDecisions = output.allowedDecisions;
269
+ }
270
+
258
271
  function fold(outcomes: readonly HookOutcome[]): AggregatedHookResult {
259
272
  const agg = freshResult();
260
273
  for (const outcome of outcomes) {
@@ -268,11 +281,21 @@ function fold(outcomes: readonly HookOutcome[]): AggregatedHookResult {
268
281
  if (output === null) {
269
282
  continue;
270
283
  }
284
+ /**
285
+ * Skip fire-and-forget outputs entirely: the agent has already
286
+ * moved on, so an async hook cannot influence the run. Background
287
+ * work inside the hook body still runs (we don't cancel it), it
288
+ * just doesn't fold into the aggregate result.
289
+ */
290
+ if (output.async === true) {
291
+ continue;
292
+ }
271
293
  applyContext(agg, output);
272
294
  applyStopFlag(agg, output);
273
295
  applyDecision(agg, output);
274
296
  applyUpdatedInput(agg, output);
275
297
  applyUpdatedOutput(agg, output);
298
+ applyAllowedDecisions(agg, output);
276
299
  }
277
300
  return agg;
278
301
  }
@@ -371,5 +394,31 @@ export async function executeHooks(
371
394
 
372
395
  const outcomes = await Promise.all(tasks);
373
396
  reportErrors(outcomes, event, logger);
374
- return fold(outcomes);
397
+ const aggregated = fold(outcomes);
398
+ /**
399
+ * Centralized `preventContinuation` propagation: when any hook (across
400
+ * any callsite — RunStart, PreToolUse, PostToolBatch, SubagentStop,
401
+ * etc.) returns `preventContinuation: true`, raise a halt signal on
402
+ * the registry scoped to the run's `sessionId`. `Run.processStream`
403
+ * polls the signal between stream events using its own id and exits
404
+ * cleanly, skipping the `Stop` hook (since the run is being halted,
405
+ * not naturally completing).
406
+ *
407
+ * First-write-wins per session inside the registry — a halt already
408
+ * raised by an earlier hook in the same run is preserved so the
409
+ * original `reason` / `source` are not clobbered. Hooks fired
410
+ * without a `sessionId` cannot raise a halt (there's no run for the
411
+ * loop to poll under), which is fine: every in-tree callsite passes
412
+ * `sessionId: runId`. Pre-stream callsites in `Run.processStream`
413
+ * still read `preventContinuation` directly off the result for an
414
+ * early return because they have not yet entered the stream loop.
415
+ */
416
+ if (aggregated.preventContinuation === true && sessionId !== undefined) {
417
+ registry.haltRun(
418
+ sessionId,
419
+ aggregated.stopReason ?? 'preventContinuation',
420
+ event
421
+ );
422
+ }
423
+ return aggregated;
375
424
  }
@@ -7,6 +7,7 @@
7
7
  // `createSummarizeNode` (PreCompact, PostCompact), and
8
8
  // `SubagentExecutor.execute` (SubagentStart, SubagentStop).
9
9
  export { HookRegistry } from './HookRegistry';
10
+ export type { HookHaltSignal } from './HookRegistry';
10
11
  export { executeHooks, DEFAULT_HOOK_TIMEOUT_MS } from './executeHooks';
11
12
  export {
12
13
  matchesQuery,
@@ -14,6 +15,8 @@ export {
14
15
  MAX_PATTERN_LENGTH,
15
16
  MAX_CACHE_SIZE,
16
17
  } from './matchers';
18
+ export { createToolPolicyHook } from './createToolPolicyHook';
19
+ export type { ToolPolicyMode, ToolPolicyConfig } from './createToolPolicyHook';
17
20
  export { HOOK_EVENTS } from './types';
18
21
  export type {
19
22
  HookEvent,
@@ -34,6 +37,8 @@ export type {
34
37
  PreToolUseHookInput,
35
38
  PostToolUseHookInput,
36
39
  PostToolUseFailureHookInput,
40
+ PostToolBatchHookInput,
41
+ PostToolBatchEntry,
37
42
  PermissionDeniedHookInput,
38
43
  SubagentStartHookInput,
39
44
  SubagentStopHookInput,
@@ -46,6 +51,7 @@ export type {
46
51
  PreToolUseHookOutput,
47
52
  PostToolUseHookOutput,
48
53
  PostToolUseFailureHookOutput,
54
+ PostToolBatchHookOutput,
49
55
  PermissionDeniedHookOutput,
50
56
  SubagentStartHookOutput,
51
57
  SubagentStopHookOutput,