@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.
- package/dist/cjs/graphs/Graph.cjs +9 -0
- package/dist/cjs/graphs/Graph.cjs.map +1 -1
- package/dist/cjs/hitl/askUserQuestion.cjs +67 -0
- package/dist/cjs/hitl/askUserQuestion.cjs.map +1 -0
- package/dist/cjs/hooks/HookRegistry.cjs +54 -0
- package/dist/cjs/hooks/HookRegistry.cjs.map +1 -1
- package/dist/cjs/hooks/createToolPolicyHook.cjs +115 -0
- package/dist/cjs/hooks/createToolPolicyHook.cjs.map +1 -0
- package/dist/cjs/hooks/executeHooks.cjs +40 -1
- package/dist/cjs/hooks/executeHooks.cjs.map +1 -1
- package/dist/cjs/hooks/types.cjs +1 -0
- package/dist/cjs/hooks/types.cjs.map +1 -1
- package/dist/cjs/main.cjs +29 -0
- package/dist/cjs/main.cjs.map +1 -1
- package/dist/cjs/run.cjs +400 -42
- package/dist/cjs/run.cjs.map +1 -1
- package/dist/cjs/tools/ToolNode.cjs +551 -55
- package/dist/cjs/tools/ToolNode.cjs.map +1 -1
- package/dist/cjs/tools/search/tavily-scraper.cjs.map +1 -1
- package/dist/cjs/tools/search/tavily-search.cjs.map +1 -1
- package/dist/cjs/tools/search/tool.cjs.map +1 -1
- package/dist/esm/graphs/Graph.mjs +9 -0
- package/dist/esm/graphs/Graph.mjs.map +1 -1
- package/dist/esm/hitl/askUserQuestion.mjs +65 -0
- package/dist/esm/hitl/askUserQuestion.mjs.map +1 -0
- package/dist/esm/hooks/HookRegistry.mjs +54 -0
- package/dist/esm/hooks/HookRegistry.mjs.map +1 -1
- package/dist/esm/hooks/createToolPolicyHook.mjs +113 -0
- package/dist/esm/hooks/createToolPolicyHook.mjs.map +1 -0
- package/dist/esm/hooks/executeHooks.mjs +40 -1
- package/dist/esm/hooks/executeHooks.mjs.map +1 -1
- package/dist/esm/hooks/types.mjs +1 -0
- package/dist/esm/hooks/types.mjs.map +1 -1
- package/dist/esm/main.mjs +3 -0
- package/dist/esm/main.mjs.map +1 -1
- package/dist/esm/run.mjs +400 -42
- package/dist/esm/run.mjs.map +1 -1
- package/dist/esm/tools/ToolNode.mjs +552 -56
- package/dist/esm/tools/ToolNode.mjs.map +1 -1
- package/dist/esm/tools/search/tavily-scraper.mjs.map +1 -1
- package/dist/esm/tools/search/tavily-search.mjs.map +1 -1
- package/dist/esm/tools/search/tool.mjs.map +1 -1
- package/dist/types/graphs/Graph.d.ts +7 -0
- package/dist/types/hitl/askUserQuestion.d.ts +55 -0
- package/dist/types/hitl/index.d.ts +6 -0
- package/dist/types/hooks/HookRegistry.d.ts +58 -0
- package/dist/types/hooks/createToolPolicyHook.d.ts +87 -0
- package/dist/types/hooks/index.d.ts +4 -1
- package/dist/types/hooks/types.d.ts +109 -3
- package/dist/types/index.d.ts +9 -0
- package/dist/types/run.d.ts +117 -1
- package/dist/types/tools/ToolNode.d.ts +26 -1
- package/dist/types/types/hitl.d.ts +272 -0
- package/dist/types/types/index.d.ts +1 -0
- package/dist/types/types/run.d.ts +33 -0
- package/dist/types/types/tools.d.ts +19 -0
- package/package.json +1 -1
- package/src/graphs/Graph.ts +9 -0
- package/src/hitl/askUserQuestion.ts +72 -0
- package/src/hitl/index.ts +7 -0
- package/src/hooks/HookRegistry.ts +71 -0
- package/src/hooks/__tests__/createToolPolicyHook.test.ts +259 -0
- package/src/hooks/createToolPolicyHook.ts +184 -0
- package/src/hooks/executeHooks.ts +50 -1
- package/src/hooks/index.ts +6 -0
- package/src/hooks/types.ts +112 -0
- package/src/index.ts +19 -0
- package/src/run.ts +456 -47
- package/src/tools/ToolNode.ts +701 -62
- package/src/tools/__tests__/hitl.test.ts +3593 -0
- package/src/tools/search/tavily-scraper.ts +4 -4
- package/src/tools/search/tavily-search.ts +32 -32
- package/src/tools/search/tool.ts +3 -3
- package/src/tools/search/types.ts +3 -1
- package/src/types/hitl.ts +303 -0
- package/src/types/index.ts +1 -0
- package/src/types/run.ts +33 -0
- 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
package/src/graphs/Graph.ts
CHANGED
|
@@ -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
|
+
}
|
|
@@ -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
|
+
});
|