@librechat/agents 3.1.76 → 3.1.77-dev.1

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 (78) 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/main.cjs +29 -0
  14. package/dist/cjs/main.cjs.map +1 -1
  15. package/dist/cjs/run.cjs +400 -42
  16. package/dist/cjs/run.cjs.map +1 -1
  17. package/dist/cjs/tools/ToolNode.cjs +551 -55
  18. package/dist/cjs/tools/ToolNode.cjs.map +1 -1
  19. package/dist/cjs/tools/search/tavily-scraper.cjs.map +1 -1
  20. package/dist/cjs/tools/search/tavily-search.cjs.map +1 -1
  21. package/dist/cjs/tools/search/tool.cjs.map +1 -1
  22. package/dist/esm/graphs/Graph.mjs +9 -0
  23. package/dist/esm/graphs/Graph.mjs.map +1 -1
  24. package/dist/esm/hitl/askUserQuestion.mjs +65 -0
  25. package/dist/esm/hitl/askUserQuestion.mjs.map +1 -0
  26. package/dist/esm/hooks/HookRegistry.mjs +54 -0
  27. package/dist/esm/hooks/HookRegistry.mjs.map +1 -1
  28. package/dist/esm/hooks/createToolPolicyHook.mjs +113 -0
  29. package/dist/esm/hooks/createToolPolicyHook.mjs.map +1 -0
  30. package/dist/esm/hooks/executeHooks.mjs +40 -1
  31. package/dist/esm/hooks/executeHooks.mjs.map +1 -1
  32. package/dist/esm/hooks/types.mjs +1 -0
  33. package/dist/esm/hooks/types.mjs.map +1 -1
  34. package/dist/esm/main.mjs +3 -0
  35. package/dist/esm/main.mjs.map +1 -1
  36. package/dist/esm/run.mjs +400 -42
  37. package/dist/esm/run.mjs.map +1 -1
  38. package/dist/esm/tools/ToolNode.mjs +552 -56
  39. package/dist/esm/tools/ToolNode.mjs.map +1 -1
  40. package/dist/esm/tools/search/tavily-scraper.mjs.map +1 -1
  41. package/dist/esm/tools/search/tavily-search.mjs.map +1 -1
  42. package/dist/esm/tools/search/tool.mjs.map +1 -1
  43. package/dist/types/graphs/Graph.d.ts +7 -0
  44. package/dist/types/hitl/askUserQuestion.d.ts +55 -0
  45. package/dist/types/hitl/index.d.ts +6 -0
  46. package/dist/types/hooks/HookRegistry.d.ts +58 -0
  47. package/dist/types/hooks/createToolPolicyHook.d.ts +87 -0
  48. package/dist/types/hooks/index.d.ts +4 -1
  49. package/dist/types/hooks/types.d.ts +109 -3
  50. package/dist/types/index.d.ts +9 -0
  51. package/dist/types/run.d.ts +117 -1
  52. package/dist/types/tools/ToolNode.d.ts +26 -1
  53. package/dist/types/types/hitl.d.ts +272 -0
  54. package/dist/types/types/index.d.ts +1 -0
  55. package/dist/types/types/run.d.ts +33 -0
  56. package/dist/types/types/tools.d.ts +19 -0
  57. package/package.json +1 -1
  58. package/src/graphs/Graph.ts +9 -0
  59. package/src/hitl/askUserQuestion.ts +72 -0
  60. package/src/hitl/index.ts +7 -0
  61. package/src/hooks/HookRegistry.ts +71 -0
  62. package/src/hooks/__tests__/createToolPolicyHook.test.ts +259 -0
  63. package/src/hooks/createToolPolicyHook.ts +184 -0
  64. package/src/hooks/executeHooks.ts +50 -1
  65. package/src/hooks/index.ts +6 -0
  66. package/src/hooks/types.ts +112 -0
  67. package/src/index.ts +19 -0
  68. package/src/run.ts +456 -47
  69. package/src/tools/ToolNode.ts +701 -62
  70. package/src/tools/__tests__/hitl.test.ts +3593 -0
  71. package/src/tools/search/tavily-scraper.ts +4 -4
  72. package/src/tools/search/tavily-search.ts +32 -32
  73. package/src/tools/search/tool.ts +3 -3
  74. package/src/tools/search/types.ts +3 -1
  75. package/src/types/hitl.ts +303 -0
  76. package/src/types/index.ts +1 -0
  77. package/src/types/run.ts +33 -0
  78. package/src/types/tools.ts +19 -0
@@ -8,6 +8,7 @@ import type * as e from '@/common/enum';
8
8
  import type * as g from '@/types/graph';
9
9
  import type * as l from '@/types/llm';
10
10
  import type { ToolSessionMap, ToolOutputReferencesConfig } from '@/types/tools';
11
+ import type { HumanInTheLoopConfig } from '@/types/hitl';
11
12
  import type { HookRegistry } from '@/hooks';
12
13
  export type ZodObjectAny = z.ZodObject<any, any, any, any>;
13
14
  export type BaseGraphConfig = {
@@ -144,6 +145,38 @@ export type RunConfig = {
144
145
  * placeholders. Disabled by default so existing runs are unaffected.
145
146
  */
146
147
  toolOutputReferences?: ToolOutputReferencesConfig;
148
+ /**
149
+ * First-class human-in-the-loop (HITL) flow for this run.
150
+ *
151
+ * **HITL is OFF by default.** Omitting this field — or passing
152
+ * `{ enabled: false }` — keeps the pre-HITL fail-closed semantics
153
+ * where `ask` decisions collapse into a synchronous deny. Hosts opt
154
+ * in explicitly with `{ enabled: true }` once their UI can render
155
+ * and resolve `tool_approval` interrupts (otherwise the run just
156
+ * pauses with no resolver, which surfaces to end users as a hung
157
+ * tool-call card).
158
+ *
159
+ * Plan of record: the default flips back to ON in a future minor
160
+ * once the consumer ecosystem (notably LibreChat) ships HITL UI
161
+ * end-to-end. See `HumanInTheLoopConfig` JSDoc.
162
+ *
163
+ * When enabled (`{ enabled: true }`):
164
+ * - `PreToolUse` hooks returning `decision: 'ask'` raise a real
165
+ * LangGraph `interrupt()` instead of being treated as a synchronous
166
+ * deny. The graph pauses and the run exits cleanly.
167
+ * - If `graphConfig.compileOptions.checkpointer` is missing, the SDK
168
+ * installs an in-memory `MemorySaver` as a fallback so scripts and
169
+ * tests can resume without external infrastructure. Production
170
+ * hosts should always provide a durable checkpointer.
171
+ * - Hosts inspect the pending interrupt via `run.getInterrupt()` and
172
+ * continue with `Run.resume(decisions)` against a Run rebuilt with
173
+ * the same `thread_id` and checkpointer.
174
+ *
175
+ * When disabled (the default): `ask` decisions remain fail-closed
176
+ * (blocked with an error `ToolMessage`) and no checkpointer is
177
+ * implicitly attached.
178
+ */
179
+ humanInTheLoop?: HumanInTheLoopConfig;
147
180
  };
148
181
  export type ProvidedCallbacks = (BaseCallbackHandler | CallbackHandlerMethods)[] | undefined;
149
182
  export type TokenCounter = (message: BaseMessage) => number;
@@ -4,6 +4,7 @@ import type { ToolCall } from '@langchain/core/messages/tool';
4
4
  import type { HookRegistry } from '@/hooks';
5
5
  import type { ToolOutputReferenceRegistry } from '@/tools/toolOutputReferences';
6
6
  import type { MessageContentComplex, ToolErrorData } from './stream';
7
+ import type { HumanInTheLoopConfig } from './hitl';
7
8
  /** Replacement type for `import type { ToolCall } from '@langchain/core/messages/tool'` in order to have stringified args typed */
8
9
  export type CustomToolCall = {
9
10
  name: string;
@@ -46,6 +47,24 @@ export type ToolNodeOptions = {
46
47
  * routed through `directToolNames` bypass hook dispatch entirely.
47
48
  */
48
49
  hookRegistry?: HookRegistry;
50
+ /**
51
+ * Run-scoped HITL config. **HITL is OFF by default** — omitting this
52
+ * field (or passing `{ enabled: false }`) keeps the pre-HITL
53
+ * fail-closed behavior where a `PreToolUse` `ask` decision collapses
54
+ * into a blocked `ToolMessage`. Hosts opt in with
55
+ * `{ enabled: true }` once their UI can render and resolve a
56
+ * `tool_approval` interrupt; that engages the interrupt path where
57
+ * `ask` raises a real LangGraph `interrupt()` carrying a
58
+ * `HumanInterruptPayload` and the host resumes with
59
+ * `Run.resume(decisions)`.
60
+ *
61
+ * Mirrors `RunConfig.humanInTheLoop` (which is the canonical place
62
+ * to set this); the Graph threads it down to every ToolNode it
63
+ * compiles. Same caveat: the interrupt path is only wired into the
64
+ * event-driven dispatch (`dispatchToolEvents`), not into
65
+ * `directToolNames` execution — direct tools bypass HITL entirely.
66
+ */
67
+ humanInTheLoop?: HumanInTheLoopConfig;
49
68
  /** Max context tokens for the agent — used to compute tool result truncation limits. */
50
69
  maxContextTokens?: number;
51
70
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@librechat/agents",
3
- "version": "3.1.76",
3
+ "version": "3.1.77-dev.1",
4
4
  "main": "./dist/cjs/main.cjs",
5
5
  "module": "./dist/esm/main.mjs",
6
6
  "types": "./dist/types/index.d.ts",
@@ -129,6 +129,13 @@ export abstract class Graph<
129
129
  invokedToolIds?: Set<string>;
130
130
  handlerRegistry: HandlerRegistry | undefined;
131
131
  hookRegistry: HookRegistry | undefined;
132
+ /**
133
+ * Run-scoped HITL configuration. When `humanInTheLoop?.enabled` is
134
+ * `true`, `ToolNode` raises a real `interrupt()` for `PreToolUse`
135
+ * `ask` decisions instead of treating them as a synchronous deny.
136
+ * Threaded from `RunConfig.humanInTheLoop`.
137
+ */
138
+ humanInTheLoop: t.HumanInTheLoopConfig | undefined;
132
139
  /**
133
140
  * Run-scoped config for the tool output reference registry. Threaded
134
141
  * from `RunConfig.toolOutputReferences` down into every ToolNode this
@@ -167,6 +174,7 @@ export abstract class Graph<
167
174
  this.invokedToolIds = undefined;
168
175
  this.handlerRegistry = undefined;
169
176
  this.hookRegistry = undefined;
177
+ this.humanInTheLoop = undefined;
170
178
  this.toolOutputReferences = undefined;
171
179
  /**
172
180
  * ToolNodes compiled from this graph captured the registry
@@ -579,6 +587,7 @@ export class StandardGraph extends Graph<t.BaseGraphState, t.GraphNode> {
579
587
  toolCallStepIds: this.toolCallStepIds,
580
588
  toolRegistry: agentContext?.toolRegistry,
581
589
  hookRegistry: this.hookRegistry,
590
+ humanInTheLoop: this.humanInTheLoop,
582
591
  directToolNames: directToolNames.size > 0 ? directToolNames : undefined,
583
592
  maxContextTokens: agentContext?.maxContextTokens,
584
593
  maxToolResultChars: agentContext?.maxToolResultChars,
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Typed convenience wrapper around LangGraph's `interrupt()` for the
3
+ * `ask_user_question` interrupt category. Lets a custom graph node
4
+ * (or a tool implementation) suspend execution to collect a free-form
5
+ * answer from the human, without the host having to assemble the
6
+ * interrupt payload by hand. The companion to `Run.resume(answer)` on
7
+ * the host side.
8
+ *
9
+ * AsyncLocalStorage anchoring: this helper does NOT call
10
+ * `runWithConfig` itself — it expects to be invoked from inside a
11
+ * LangGraph node where the framework has already established the
12
+ * runnable config. ToolNode is the one place in this codebase that
13
+ * needs the manual `runWithConfig` shim, because its
14
+ * `RunnableCallable.trace = false` skips the upstream tracing path
15
+ * that normally sets up the AsyncLocalStorage frame; ordinary user
16
+ * nodes (RunnableLambda, addNode callbacks) do not have that
17
+ * constraint.
18
+ */
19
+
20
+ import { interrupt } from '@langchain/langgraph';
21
+ import type {
22
+ AskUserQuestionRequest,
23
+ AskUserQuestionResolution,
24
+ AskUserQuestionInterruptPayload,
25
+ } from '@/types/hitl';
26
+
27
+ /**
28
+ * Suspend the current graph node to ask the human a question. Returns
29
+ * the host-supplied resolution after `Run.resume(resolution)` is
30
+ * called against a Run rebuilt with the same `thread_id` and
31
+ * checkpointer.
32
+ *
33
+ * On the FIRST call (no resume value available), `interrupt()` throws
34
+ * a `GraphInterrupt` that LangGraph catches; this function does not
35
+ * return — execution unwinds, the SDK persists the checkpoint, and
36
+ * the run completes with `run.getInterrupt()` returning a
37
+ * `RunInterruptResult` whose `payload` is an
38
+ * `AskUserQuestionInterruptPayload`.
39
+ *
40
+ * On RESUME, LangGraph re-runs the node from the start and this call
41
+ * returns the host's `AskUserQuestionResolution` directly.
42
+ *
43
+ * Hosts that prefer the raw `interrupt()` (e.g., to attach extra
44
+ * metadata) can construct an `AskUserQuestionInterruptPayload` and
45
+ * call `interrupt()` themselves — this helper is purely convenience.
46
+ *
47
+ * @example
48
+ * ```ts
49
+ * const builder = new StateGraph(MessagesAnnotation)
50
+ * .addNode('clarifier', () => {
51
+ * const { answer } = askUserQuestion({
52
+ * question: 'Which environment should I deploy to?',
53
+ * options: [
54
+ * { label: 'Staging', value: 'staging' },
55
+ * { label: 'Production', value: 'production' },
56
+ * ],
57
+ * });
58
+ * return { messages: [new HumanMessage(`Use ${answer}`)] };
59
+ * });
60
+ * ```
61
+ */
62
+ export function askUserQuestion(
63
+ question: AskUserQuestionRequest
64
+ ): AskUserQuestionResolution {
65
+ const payload: AskUserQuestionInterruptPayload = {
66
+ type: 'ask_user_question',
67
+ question,
68
+ };
69
+ return interrupt<AskUserQuestionInterruptPayload, AskUserQuestionResolution>(
70
+ payload
71
+ );
72
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Human-in-the-loop helpers. Type definitions live in `@/types/hitl`
3
+ * and re-export from the top-level types barrel; runtime helpers (like
4
+ * `askUserQuestion()`) live here.
5
+ */
6
+
7
+ export { askUserQuestion } from './askUserQuestion';
@@ -13,6 +13,20 @@ import type { HookEvent, HookMatcher } from './types';
13
13
  */
14
14
  type MatcherBucket = Partial<Record<HookEvent, HookMatcher<HookEvent>[]>>;
15
15
 
16
+ /**
17
+ * Snapshot of a halt request raised by a hook returning
18
+ * `preventContinuation: true`. The SDK's run loop polls for this between
19
+ * stream events and exits cleanly when set, skipping the `Stop` hook
20
+ * (the run is being halted, not naturally completing). One per registry
21
+ * instance — the first hook to halt wins; subsequent halts are ignored
22
+ * so the original reason isn't clobbered.
23
+ */
24
+ export interface HookHaltSignal {
25
+ reason: string;
26
+ /** Event of the hook that triggered the halt (for diagnostics). */
27
+ source: HookEvent;
28
+ }
29
+
16
30
  /**
17
31
  * Run-scoped storage for hook matchers with an additional layer for
18
32
  * session-scoped matchers that should be cleaned up between sessions.
@@ -34,6 +48,18 @@ type MatcherBucket = Partial<Record<HookEvent, HookMatcher<HookEvent>[]>>;
34
48
  export class HookRegistry {
35
49
  private readonly global: MatcherBucket = {};
36
50
  private readonly sessions: Map<string, MatcherBucket> = new Map();
51
+ /**
52
+ * Per-session halt signals. Scoped by `sessionId` (= the run id the
53
+ * hook fired under) so a host that shares one registry across
54
+ * concurrent runs cannot leak `preventContinuation` from one run
55
+ * into another. Without scoping, a halt raised by run A's hook
56
+ * would trip run B's stream-loop poll on the next iteration —
57
+ * silently terminating an unrelated run.
58
+ *
59
+ * Map storage mirrors the reasoning above for session matchers:
60
+ * O(1) insertion in hot paths, no spread-on-write.
61
+ */
62
+ private readonly haltSignals: Map<string, HookHaltSignal> = new Map();
37
63
 
38
64
  /**
39
65
  * Register a matcher for the lifetime of this registry (= one Run).
@@ -125,6 +151,51 @@ export class HookRegistry {
125
151
  this.sessions.delete(sessionId);
126
152
  }
127
153
 
154
+ /**
155
+ * Raise a halt signal scoped to `sessionId` (= the run id the hook
156
+ * fired under). The SDK's run loop polls for this between stream
157
+ * events with the run's own id. First-write-wins per session: a
158
+ * halt already raised by an earlier hook in the same run is
159
+ * preserved so the original `reason` / `source` aren't overwritten.
160
+ *
161
+ * Per-session scoping is critical when hosts share one registry
162
+ * across concurrent runs (e.g. a global policy registered once and
163
+ * reused). Without it, a `preventContinuation` from run A would
164
+ * trip run B's stream-loop poll on the next iteration and silently
165
+ * terminate an unrelated run.
166
+ *
167
+ * Called by the SDK after `executeHooks` returns an aggregate with
168
+ * `preventContinuation: true`. Hosts can also call it directly from
169
+ * inside a hook callback if they want to halt without going through
170
+ * the aggregated return value, but `preventContinuation` is the
171
+ * canonical path.
172
+ */
173
+ haltRun(sessionId: string, reason: string, source: HookEvent): void {
174
+ if (this.haltSignals.has(sessionId)) {
175
+ return;
176
+ }
177
+ this.haltSignals.set(sessionId, { reason, source });
178
+ }
179
+
180
+ /**
181
+ * Returns the halt signal raised by hooks running under `sessionId`,
182
+ * or `undefined` if no hook in that run has halted. Polled by
183
+ * `Run.processStream` between stream events using the run's own id.
184
+ */
185
+ getHaltSignal(sessionId: string): HookHaltSignal | undefined {
186
+ return this.haltSignals.get(sessionId);
187
+ }
188
+
189
+ /**
190
+ * Clears the halt signal for `sessionId`. Called by
191
+ * `Run.processStream` in its `finally` block so a subsequent
192
+ * invocation of the same Run (e.g. resume) starts with a fresh
193
+ * halt state. No-op when no signal exists for that session.
194
+ */
195
+ clearHaltSignal(sessionId: string): void {
196
+ this.haltSignals.delete(sessionId);
197
+ }
198
+
128
199
  /** True if at least one matcher exists for `event` (global + session). */
129
200
  hasHookFor(event: HookEvent, sessionId?: string): boolean {
130
201
  if (readList(this.global, event).length > 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
+ });