@librechat/agents 3.1.77 → 3.1.78-dev.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (181) hide show
  1. package/dist/cjs/common/enum.cjs +54 -0
  2. package/dist/cjs/common/enum.cjs.map +1 -1
  3. package/dist/cjs/graphs/Graph.cjs +148 -4
  4. package/dist/cjs/graphs/Graph.cjs.map +1 -1
  5. package/dist/cjs/hooks/createWorkspacePolicyHook.cjs +291 -0
  6. package/dist/cjs/hooks/createWorkspacePolicyHook.cjs.map +1 -0
  7. package/dist/cjs/main.cjs +90 -0
  8. package/dist/cjs/main.cjs.map +1 -1
  9. package/dist/cjs/messages/anthropicToolCache.cjs +102 -0
  10. package/dist/cjs/messages/anthropicToolCache.cjs.map +1 -0
  11. package/dist/cjs/messages/prune.cjs +27 -0
  12. package/dist/cjs/messages/prune.cjs.map +1 -1
  13. package/dist/cjs/messages/recency.cjs +99 -0
  14. package/dist/cjs/messages/recency.cjs.map +1 -0
  15. package/dist/cjs/run.cjs +30 -0
  16. package/dist/cjs/run.cjs.map +1 -1
  17. package/dist/cjs/summarization/node.cjs +100 -6
  18. package/dist/cjs/summarization/node.cjs.map +1 -1
  19. package/dist/cjs/tools/ToolNode.cjs +635 -23
  20. package/dist/cjs/tools/ToolNode.cjs.map +1 -1
  21. package/dist/cjs/tools/local/CompileCheckTool.cjs +227 -0
  22. package/dist/cjs/tools/local/CompileCheckTool.cjs.map +1 -0
  23. package/dist/cjs/tools/local/FileCheckpointer.cjs +90 -0
  24. package/dist/cjs/tools/local/FileCheckpointer.cjs.map +1 -0
  25. package/dist/cjs/tools/local/LocalCodingTools.cjs +1098 -0
  26. package/dist/cjs/tools/local/LocalCodingTools.cjs.map +1 -0
  27. package/dist/cjs/tools/local/LocalExecutionEngine.cjs +1042 -0
  28. package/dist/cjs/tools/local/LocalExecutionEngine.cjs.map +1 -0
  29. package/dist/cjs/tools/local/LocalExecutionTools.cjs +122 -0
  30. package/dist/cjs/tools/local/LocalExecutionTools.cjs.map +1 -0
  31. package/dist/cjs/tools/local/LocalProgrammaticToolCalling.cjs +453 -0
  32. package/dist/cjs/tools/local/LocalProgrammaticToolCalling.cjs.map +1 -0
  33. package/dist/cjs/tools/local/attachments.cjs +183 -0
  34. package/dist/cjs/tools/local/attachments.cjs.map +1 -0
  35. package/dist/cjs/tools/local/bashAst.cjs +129 -0
  36. package/dist/cjs/tools/local/bashAst.cjs.map +1 -0
  37. package/dist/cjs/tools/local/editStrategies.cjs +188 -0
  38. package/dist/cjs/tools/local/editStrategies.cjs.map +1 -0
  39. package/dist/cjs/tools/local/resolveLocalExecutionTools.cjs +141 -0
  40. package/dist/cjs/tools/local/resolveLocalExecutionTools.cjs.map +1 -0
  41. package/dist/cjs/tools/local/syntaxCheck.cjs +182 -0
  42. package/dist/cjs/tools/local/syntaxCheck.cjs.map +1 -0
  43. package/dist/cjs/tools/local/textEncoding.cjs +30 -0
  44. package/dist/cjs/tools/local/textEncoding.cjs.map +1 -0
  45. package/dist/cjs/tools/local/workspaceFS.cjs +51 -0
  46. package/dist/cjs/tools/local/workspaceFS.cjs.map +1 -0
  47. package/dist/cjs/tools/subagent/SubagentExecutor.cjs +1 -0
  48. package/dist/cjs/tools/subagent/SubagentExecutor.cjs.map +1 -1
  49. package/dist/esm/common/enum.mjs +53 -1
  50. package/dist/esm/common/enum.mjs.map +1 -1
  51. package/dist/esm/graphs/Graph.mjs +149 -5
  52. package/dist/esm/graphs/Graph.mjs.map +1 -1
  53. package/dist/esm/hooks/createWorkspacePolicyHook.mjs +289 -0
  54. package/dist/esm/hooks/createWorkspacePolicyHook.mjs.map +1 -0
  55. package/dist/esm/main.mjs +17 -2
  56. package/dist/esm/main.mjs.map +1 -1
  57. package/dist/esm/messages/anthropicToolCache.mjs +99 -0
  58. package/dist/esm/messages/anthropicToolCache.mjs.map +1 -0
  59. package/dist/esm/messages/prune.mjs +26 -1
  60. package/dist/esm/messages/prune.mjs.map +1 -1
  61. package/dist/esm/messages/recency.mjs +97 -0
  62. package/dist/esm/messages/recency.mjs.map +1 -0
  63. package/dist/esm/run.mjs +30 -0
  64. package/dist/esm/run.mjs.map +1 -1
  65. package/dist/esm/summarization/node.mjs +100 -6
  66. package/dist/esm/summarization/node.mjs.map +1 -1
  67. package/dist/esm/tools/ToolNode.mjs +635 -23
  68. package/dist/esm/tools/ToolNode.mjs.map +1 -1
  69. package/dist/esm/tools/local/CompileCheckTool.mjs +223 -0
  70. package/dist/esm/tools/local/CompileCheckTool.mjs.map +1 -0
  71. package/dist/esm/tools/local/FileCheckpointer.mjs +87 -0
  72. package/dist/esm/tools/local/FileCheckpointer.mjs.map +1 -0
  73. package/dist/esm/tools/local/LocalCodingTools.mjs +1075 -0
  74. package/dist/esm/tools/local/LocalCodingTools.mjs.map +1 -0
  75. package/dist/esm/tools/local/LocalExecutionEngine.mjs +1022 -0
  76. package/dist/esm/tools/local/LocalExecutionEngine.mjs.map +1 -0
  77. package/dist/esm/tools/local/LocalExecutionTools.mjs +117 -0
  78. package/dist/esm/tools/local/LocalExecutionTools.mjs.map +1 -0
  79. package/dist/esm/tools/local/LocalProgrammaticToolCalling.mjs +448 -0
  80. package/dist/esm/tools/local/LocalProgrammaticToolCalling.mjs.map +1 -0
  81. package/dist/esm/tools/local/attachments.mjs +180 -0
  82. package/dist/esm/tools/local/attachments.mjs.map +1 -0
  83. package/dist/esm/tools/local/bashAst.mjs +126 -0
  84. package/dist/esm/tools/local/bashAst.mjs.map +1 -0
  85. package/dist/esm/tools/local/editStrategies.mjs +185 -0
  86. package/dist/esm/tools/local/editStrategies.mjs.map +1 -0
  87. package/dist/esm/tools/local/resolveLocalExecutionTools.mjs +137 -0
  88. package/dist/esm/tools/local/resolveLocalExecutionTools.mjs.map +1 -0
  89. package/dist/esm/tools/local/syntaxCheck.mjs +179 -0
  90. package/dist/esm/tools/local/syntaxCheck.mjs.map +1 -0
  91. package/dist/esm/tools/local/textEncoding.mjs +27 -0
  92. package/dist/esm/tools/local/textEncoding.mjs.map +1 -0
  93. package/dist/esm/tools/local/workspaceFS.mjs +49 -0
  94. package/dist/esm/tools/local/workspaceFS.mjs.map +1 -0
  95. package/dist/esm/tools/subagent/SubagentExecutor.mjs +1 -0
  96. package/dist/esm/tools/subagent/SubagentExecutor.mjs.map +1 -1
  97. package/dist/types/common/enum.d.ts +39 -1
  98. package/dist/types/graphs/Graph.d.ts +34 -0
  99. package/dist/types/hooks/createWorkspacePolicyHook.d.ts +95 -0
  100. package/dist/types/hooks/index.d.ts +2 -0
  101. package/dist/types/index.d.ts +1 -0
  102. package/dist/types/messages/anthropicToolCache.d.ts +51 -0
  103. package/dist/types/messages/index.d.ts +2 -0
  104. package/dist/types/messages/prune.d.ts +11 -0
  105. package/dist/types/messages/recency.d.ts +64 -0
  106. package/dist/types/run.d.ts +21 -0
  107. package/dist/types/tools/ToolNode.d.ts +145 -2
  108. package/dist/types/tools/local/CompileCheckTool.d.ts +31 -0
  109. package/dist/types/tools/local/FileCheckpointer.d.ts +39 -0
  110. package/dist/types/tools/local/LocalCodingTools.d.ts +57 -0
  111. package/dist/types/tools/local/LocalExecutionEngine.d.ts +149 -0
  112. package/dist/types/tools/local/LocalExecutionTools.d.ts +9 -0
  113. package/dist/types/tools/local/LocalProgrammaticToolCalling.d.ts +21 -0
  114. package/dist/types/tools/local/attachments.d.ts +84 -0
  115. package/dist/types/tools/local/bashAst.d.ts +11 -0
  116. package/dist/types/tools/local/editStrategies.d.ts +28 -0
  117. package/dist/types/tools/local/index.d.ts +12 -0
  118. package/dist/types/tools/local/resolveLocalExecutionTools.d.ts +38 -0
  119. package/dist/types/tools/local/syntaxCheck.d.ts +42 -0
  120. package/dist/types/tools/local/textEncoding.d.ts +21 -0
  121. package/dist/types/tools/local/workspaceFS.d.ts +49 -0
  122. package/dist/types/types/hitl.d.ts +56 -27
  123. package/dist/types/types/run.d.ts +8 -1
  124. package/dist/types/types/summarize.d.ts +30 -0
  125. package/dist/types/types/tools.d.ts +341 -6
  126. package/package.json +21 -2
  127. package/src/common/enum.ts +54 -0
  128. package/src/graphs/Graph.ts +164 -6
  129. package/src/hooks/__tests__/compactHooks.test.ts +38 -2
  130. package/src/hooks/__tests__/createWorkspacePolicyHook.test.ts +393 -0
  131. package/src/hooks/createWorkspacePolicyHook.ts +355 -0
  132. package/src/hooks/index.ts +6 -0
  133. package/src/index.ts +1 -0
  134. package/src/messages/__tests__/anthropicToolCache.test.ts +125 -0
  135. package/src/messages/__tests__/recency.test.ts +267 -0
  136. package/src/messages/anthropicToolCache.ts +116 -0
  137. package/src/messages/index.ts +2 -0
  138. package/src/messages/prune.ts +27 -1
  139. package/src/messages/recency.ts +155 -0
  140. package/src/run.ts +31 -0
  141. package/src/scripts/compare_pi_vs_ours.ts +840 -0
  142. package/src/scripts/local_engine.ts +166 -0
  143. package/src/scripts/local_engine_checkpointer.ts +205 -0
  144. package/src/scripts/local_engine_compile.ts +263 -0
  145. package/src/scripts/local_engine_hooks.ts +226 -0
  146. package/src/scripts/local_engine_image.ts +201 -0
  147. package/src/scripts/local_engine_ptc.ts +151 -0
  148. package/src/scripts/local_engine_workspace.ts +258 -0
  149. package/src/scripts/summarization-recency.ts +462 -0
  150. package/src/specs/prune.test.ts +39 -0
  151. package/src/summarization/__tests__/node.test.ts +499 -3
  152. package/src/summarization/node.ts +124 -7
  153. package/src/tools/ToolNode.ts +769 -20
  154. package/src/tools/__tests__/LocalExecutionTools.test.ts +2647 -0
  155. package/src/tools/__tests__/ProgrammaticToolCalling.test.ts +175 -0
  156. package/src/tools/__tests__/ToolNode.outputReferences.test.ts +114 -0
  157. package/src/tools/__tests__/ToolNode.session.test.ts +84 -0
  158. package/src/tools/__tests__/directToolHITLResumeScope.test.ts +467 -0
  159. package/src/tools/__tests__/directToolHooks.test.ts +411 -0
  160. package/src/tools/__tests__/localToolNames.test.ts +73 -0
  161. package/src/tools/__tests__/workspaceSeam.test.ts +134 -0
  162. package/src/tools/local/CompileCheckTool.ts +278 -0
  163. package/src/tools/local/FileCheckpointer.ts +93 -0
  164. package/src/tools/local/LocalCodingTools.ts +1342 -0
  165. package/src/tools/local/LocalExecutionEngine.ts +1329 -0
  166. package/src/tools/local/LocalExecutionTools.ts +167 -0
  167. package/src/tools/local/LocalProgrammaticToolCalling.ts +594 -0
  168. package/src/tools/local/__tests__/FileCheckpointer.test.ts +120 -0
  169. package/src/tools/local/__tests__/editStrategies.test.ts +134 -0
  170. package/src/tools/local/attachments.ts +251 -0
  171. package/src/tools/local/bashAst.ts +151 -0
  172. package/src/tools/local/editStrategies.ts +188 -0
  173. package/src/tools/local/index.ts +12 -0
  174. package/src/tools/local/resolveLocalExecutionTools.ts +208 -0
  175. package/src/tools/local/syntaxCheck.ts +243 -0
  176. package/src/tools/local/textEncoding.ts +37 -0
  177. package/src/tools/local/workspaceFS.ts +89 -0
  178. package/src/types/hitl.ts +56 -27
  179. package/src/types/run.ts +12 -1
  180. package/src/types/summarize.ts +31 -0
  181. package/src/types/tools.ts +359 -7
@@ -0,0 +1,355 @@
1
+ /**
2
+ * Workspace boundary policy as a `PreToolUse` hook.
3
+ *
4
+ * Local-engine file tools enforce a hard workspace boundary at the
5
+ * tool implementation layer (`resolveWorkspacePathSafe`). This hook
6
+ * adds a complementary, host-controlled layer on top that uses the
7
+ * standard PreToolUse / HITL machinery to *negotiate* access to
8
+ * paths outside the workspace — instead of just throwing.
9
+ *
10
+ * The host opts in by registering this hook on a `HookRegistry`; the
11
+ * hook inspects each tool call's input, extracts the file paths it
12
+ * mentions via per-tool extractors, and returns:
13
+ *
14
+ * - `allow` — every path is inside `workspace.root`
15
+ * (or `additionalRoots`)
16
+ * - `deny` — at least one path is outside, and the
17
+ * configured outside-policy is `'deny'`
18
+ * - `ask` — at least one path is outside, and the
19
+ * outside-policy is `'ask'` (default).
20
+ * When `humanInTheLoop.enabled` is true,
21
+ * the existing PreToolUse `'ask'` flow
22
+ * raises a tool_approval interrupt the
23
+ * host UI can render. When HITL is off,
24
+ * `'ask'` collapses to `deny` (matches
25
+ * the rest of the SDK's default).
26
+ *
27
+ * Default per-tool path extractors cover the local-engine coding
28
+ * suite (`read_file`, `write_file`, `edit_file`, `grep_search`,
29
+ * `glob_search`, `list_directory`, `compile_check`). The host can
30
+ * override or extend via `pathExtractors`. Bash/code paths are not
31
+ * extracted by default — bash command parsing is its own concern, and
32
+ * the existing `bashAst` validator + sandbox-runtime fs allowlist are
33
+ * the right gates for those.
34
+ *
35
+ * Important: this hook does NOT replace `resolveWorkspacePathSafe`.
36
+ * Even if the hook returns `allow`, the file tool still enforces its
37
+ * own clamp unless `workspace.allowReadOutside` /
38
+ * `workspace.allowWriteOutside` (or the legacy
39
+ * `allowOutsideWorkspace`) is set. The recommended composition for
40
+ * "ask the user" semantics is:
41
+ *
42
+ * workspace: {
43
+ * root,
44
+ * allowReadOutside: true,
45
+ * allowWriteOutside: true,
46
+ * },
47
+ * // …with the hook installed and humanInTheLoop.enabled = true.
48
+ */
49
+
50
+ import { homedir } from 'os';
51
+ import { isAbsolute, relative, resolve } from 'path';
52
+ import { realpath } from 'fs/promises';
53
+ import { Constants } from '@/common';
54
+ import type {
55
+ HookCallback,
56
+ PreToolUseHookInput,
57
+ PreToolUseHookOutput,
58
+ ToolDecision,
59
+ } from './types';
60
+
61
+ /**
62
+ * What to do when a tool call references a path outside the workspace.
63
+ *
64
+ * - `'ask'` : default. Raise a PreToolUse `ask` (host UI prompts
65
+ * via the HITL interrupt path).
66
+ * - `'allow'` : let the call through (use the existing tool clamp
67
+ * to actually enforce — the hook is purely advisory).
68
+ * - `'deny'` : block the call with an error ToolMessage.
69
+ */
70
+ export type OutsideAccessPolicy = 'ask' | 'allow' | 'deny';
71
+
72
+ export interface WorkspacePolicyConfig {
73
+ /** Canonical workspace root. Required. */
74
+ root: string;
75
+ /** Sibling roots that count as inside-workspace. */
76
+ additionalRoots?: readonly string[];
77
+ /** Policy applied to read-only file tools. Defaults to `'ask'`. */
78
+ outsideRead?: OutsideAccessPolicy;
79
+ /** Policy applied to write-shaped file tools. Defaults to `'ask'`. */
80
+ outsideWrite?: OutsideAccessPolicy;
81
+ /**
82
+ * Optional reason template surfaced in the `ask`/`deny` decision.
83
+ * Supports `{tool}` and `{paths}` substitution.
84
+ */
85
+ reason?: string;
86
+ /**
87
+ * Per-tool path extractors. Defaults cover the local-engine coding
88
+ * suite. Returning an empty array opts that tool out of policy.
89
+ */
90
+ pathExtractors?: Record<string, PathExtractor>;
91
+ }
92
+
93
+ export type PathExtractor = (
94
+ toolInput: Record<string, unknown>
95
+ ) => readonly string[];
96
+
97
+ const READ_TOOLS = new Set<string>([
98
+ Constants.READ_FILE,
99
+ Constants.GREP_SEARCH,
100
+ Constants.GLOB_SEARCH,
101
+ Constants.LIST_DIRECTORY,
102
+ Constants.COMPILE_CHECK,
103
+ ]);
104
+
105
+ const WRITE_TOOLS = new Set<string>([
106
+ Constants.WRITE_FILE,
107
+ Constants.EDIT_FILE,
108
+ ]);
109
+
110
+ /**
111
+ * Best-effort extractor for `compile_check` — pulls absolute and `~/`
112
+ * path tokens out of the `command` string so the workspace boundary
113
+ * sees them. Without this, a model could ship `command: 'cat
114
+ * /etc/passwd'` and the policy hook would short-circuit to `allow`
115
+ * (Codex P1 #26 — the prior `() => []` made the hook a no-op for
116
+ * compile_check). Conservative by design:
117
+ *
118
+ * - Matches `/foo`, `~/foo`, `$HOME/foo`, `${HOME}/foo` followed by
119
+ * non-shell-special chars. Stops at whitespace, quotes, redirect
120
+ * operators, pipes, semicolons.
121
+ * - Strips a leading `--flag=` so `--out=/etc/foo` extracts as
122
+ * `/etc/foo` (the path the agent's actually trying to write).
123
+ * - Misses relative paths (intended — those resolve under cwd
124
+ * anyway), and shell-substituted paths whose final form isn't
125
+ * visible at extract time. Hosts that need bulletproof gating
126
+ * should pair this with a `bash_tool`-level policy.
127
+ */
128
+ // `["']?` slots before AND after the captured path cover quoted
129
+ // forms like `cat "/etc/passwd"` and `--out='/tmp/x'`. Codex P1 #31
130
+ // — the previous regex only matched unquoted tokens, so a model
131
+ // could trivially bypass the workspace policy by quoting any
132
+ // destination path. The path content character class still excludes
133
+ // quotes/whitespace/shell-specials so we don't over-extract; that's
134
+ // the defensive trade we want for fallback-grep style matching.
135
+ //
136
+ // The `\.\.(?:\/[^…]*)?` alternation covers parent-traversal forms
137
+ // (`..`, `../secrets.txt`, `../foo/bar`). Without it, a model could
138
+ // exfiltrate parent-directory files via `cat ../secrets` and the
139
+ // hook would short-circuit to `allow` because the extractor saw no
140
+ // "absolute" token. The boundary check at the call site resolves
141
+ // non-absolute extracted tokens against `root`, so `../secrets`
142
+ // becomes `<parent-of-workspace>/secrets` which the boundary then
143
+ // correctly flags as outside. Codex P2 #35.
144
+ const PATH_TOKEN =
145
+ /(?:^|[\s=])(?:--[^\s=]+=)?["']?(\/[^\s'"|;&<>()`]+|~\/[^\s'"|;&<>()`]+|\$\{?HOME\}?\/[^\s'"|;&<>()`]+|\.\.(?:\/[^\s'"|;&<>()`]*)?)["']?/g;
146
+ // Back-compat alias kept for any downstream import.
147
+ const ABSOLUTE_PATH_TOKEN = PATH_TOKEN;
148
+ function expandHomeRelative(token: string): string {
149
+ // Expand ~/foo and $HOME/foo and ${HOME}/foo to absolute. The
150
+ // workspace boundary check resolves non-absolute paths against the
151
+ // workspace root, which would silently treat `~/secret` as
152
+ // `<workspace>/~/secret` — exactly the bypass the codex flagged.
153
+ const home = homedir();
154
+ if (token.startsWith('~/')) return `${home}/${token.slice(2)}`;
155
+ if (token.startsWith('${HOME}/')) return `${home}/${token.slice(8)}`;
156
+ if (token.startsWith('$HOME/')) return `${home}/${token.slice(6)}`;
157
+ return token;
158
+ }
159
+ function extractCompileCheckPaths(input: Record<string, unknown>): string[] {
160
+ const command = typeof input.command === 'string' ? input.command : '';
161
+ if (command === '') return [];
162
+ const out: string[] = [];
163
+ for (const match of command.matchAll(ABSOLUTE_PATH_TOKEN)) {
164
+ out.push(expandHomeRelative(match[1]));
165
+ }
166
+ return out;
167
+ }
168
+
169
+ const DEFAULT_EXTRACTORS: Record<string, PathExtractor> = {
170
+ [Constants.READ_FILE]: (i) =>
171
+ typeof i.file_path === 'string' ? [i.file_path] : [],
172
+ [Constants.WRITE_FILE]: (i) =>
173
+ typeof i.file_path === 'string' ? [i.file_path] : [],
174
+ [Constants.EDIT_FILE]: (i) =>
175
+ typeof i.file_path === 'string' ? [i.file_path] : [],
176
+ [Constants.GREP_SEARCH]: (i) =>
177
+ typeof i.path === 'string' && i.path !== '' ? [i.path] : [],
178
+ [Constants.GLOB_SEARCH]: (i) =>
179
+ typeof i.path === 'string' && i.path !== '' ? [i.path] : [],
180
+ [Constants.LIST_DIRECTORY]: (i) =>
181
+ typeof i.path === 'string' && i.path !== '' ? [i.path] : [],
182
+ [Constants.COMPILE_CHECK]: extractCompileCheckPaths,
183
+ };
184
+
185
+ function isInsideAnyRoot(absolutePath: string, roots: string[]): boolean {
186
+ for (const root of roots) {
187
+ if (absolutePath === root) return true;
188
+ const rel = relative(root, absolutePath);
189
+ if (!rel.startsWith('..') && !isAbsolute(rel)) return true;
190
+ }
191
+ return false;
192
+ }
193
+
194
+ /**
195
+ * Symlink-aware variant: realpaths the candidate AND the roots before
196
+ * comparing. Without this, a symlink inside the workspace pointing
197
+ * outside (e.g. `workspace/link → /etc/passwd`) compares as
198
+ * "in-workspace" lexically, but actually grants the agent reach
199
+ * outside the boundary. Critical when this hook is the primary gate
200
+ * (i.e. the host opted into `workspace.allowReadOutside: true` /
201
+ * `allowWriteOutside: true` so the file tools' own clamp is off).
202
+ *
203
+ * Handles paths that don't yet exist (e.g. `write_file` to a brand
204
+ * new path) by walking up to the nearest existing ancestor and
205
+ * realpathing that, then re-attaching the unresolved suffix. Mirrors
206
+ * `resolveWorkspacePathSafe`'s approach in LocalExecutionEngine.
207
+ */
208
+ async function realpathOrSelf(absolutePath: string): Promise<string> {
209
+ try {
210
+ return await realpath(absolutePath);
211
+ } catch {
212
+ return absolutePath;
213
+ }
214
+ }
215
+
216
+ async function realpathOfPathOrAncestor(
217
+ absolutePath: string
218
+ ): Promise<string> {
219
+ let current = absolutePath;
220
+ let suffix = '';
221
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
222
+ while (true) {
223
+ try {
224
+ const real = await realpath(current);
225
+ return suffix === '' ? real : resolve(real, suffix);
226
+ } catch {
227
+ const parent = resolve(current, '..');
228
+ if (parent === current) {
229
+ return absolutePath;
230
+ }
231
+ const base = current.slice(parent.length + 1);
232
+ suffix = suffix === '' ? base : `${base}/${suffix}`;
233
+ current = parent;
234
+ }
235
+ }
236
+ }
237
+
238
+ async function isInsideAnyRootRealpath(
239
+ absolutePath: string,
240
+ realRoots: readonly string[]
241
+ ): Promise<boolean> {
242
+ const real = await realpathOfPathOrAncestor(absolutePath);
243
+ return isInsideAnyRoot(real, [...realRoots]);
244
+ }
245
+
246
+ function formatReason(
247
+ template: string | undefined,
248
+ toolName: string,
249
+ outsidePaths: readonly string[]
250
+ ): string {
251
+ const fallback = `Tool "${toolName}" wants to touch ${outsidePaths.length} path(s) outside the workspace: ${outsidePaths.join(', ')}`;
252
+ if (template == null) return fallback;
253
+ return template
254
+ .replace(/\{tool\}/g, toolName)
255
+ .replace(/\{paths\}/g, outsidePaths.join(', '));
256
+ }
257
+
258
+ /**
259
+ * Build a `PreToolUse` callback that enforces the workspace policy.
260
+ * Register it on a `HookRegistry`:
261
+ *
262
+ * ```ts
263
+ * registry.register('PreToolUse', {
264
+ * hooks: [createWorkspacePolicyHook({ root, outsideWrite: 'ask' })],
265
+ * });
266
+ * ```
267
+ *
268
+ * The hook is composable with `createToolPolicyHook` — register both;
269
+ * `executeHooks` precedence (`deny > ask > allow`) sorts out which
270
+ * decision wins per call.
271
+ */
272
+ export function createWorkspacePolicyHook(
273
+ config: WorkspacePolicyConfig
274
+ ): HookCallback<'PreToolUse'> {
275
+ const root = resolve(config.root);
276
+ // Relative `additionalRoots` entries are anchored to `root` so a
277
+ // monorepo config like `additionalRoots: ['../shared']` resolves
278
+ // to a sibling of `root`, not of process.cwd. Matches
279
+ // `getWorkspaceRoots` in LocalExecutionEngine.
280
+ const additionalRoots = (config.additionalRoots ?? []).map((p) =>
281
+ isAbsolute(p) ? resolve(p) : resolve(root, p)
282
+ );
283
+ const allRoots = [root, ...additionalRoots];
284
+
285
+ // Pre-realpath the roots once at construction — these are stable
286
+ // per Run. The candidate paths get realpath'd lazily inside the
287
+ // hook callback. Cached so the per-call cost is just one realpath.
288
+ let realRootsPromise: Promise<string[]> | undefined;
289
+ const getRealRoots = (): Promise<string[]> => {
290
+ if (realRootsPromise == null) {
291
+ realRootsPromise = Promise.all(allRoots.map(realpathOrSelf));
292
+ }
293
+ return realRootsPromise;
294
+ };
295
+
296
+ const readPolicy: OutsideAccessPolicy = config.outsideRead ?? 'ask';
297
+ const writePolicy: OutsideAccessPolicy = config.outsideWrite ?? 'ask';
298
+
299
+ const extractors: Record<string, PathExtractor> = {
300
+ ...DEFAULT_EXTRACTORS,
301
+ ...(config.pathExtractors ?? {}),
302
+ };
303
+
304
+ return async (input: PreToolUseHookInput): Promise<PreToolUseHookOutput> => {
305
+ const extractor = extractors[input.toolName];
306
+ if (extractor == null) return { decision: 'allow' };
307
+
308
+ const paths = extractor(
309
+ (input.toolInput ?? {}) as Record<string, unknown>
310
+ );
311
+ if (paths.length === 0) return { decision: 'allow' };
312
+
313
+ // Two-stage check:
314
+ // 1. Lexical fast path — anything that's lexically inside the
315
+ // workspace AND doesn't get redirected by realpath stays
316
+ // allow-able without paying the realpath cost on every call.
317
+ // 2. For paths that look outside lexically OR look inside but
318
+ // may have been routed through a symlink, realpath both the
319
+ // candidate and the roots and compare. This catches the
320
+ // `workspace/link → /etc/passwd` escape that lexical-only
321
+ // checks miss.
322
+ const outside: string[] = [];
323
+ const realRoots = await getRealRoots();
324
+ for (const p of paths) {
325
+ const abs = isAbsolute(p) ? resolve(p) : resolve(root, p);
326
+ // Realpath is the source of truth — it catches both the
327
+ // symlink-escape case (lexically-inside path that resolves
328
+ // outside) and the alternate-mount case (lexically-outside
329
+ // path that resolves back inside the workspace). The lexical
330
+ // check alone gives the wrong answer for either, so we don't
331
+ // bother computing it.
332
+ const realInside = await isInsideAnyRootRealpath(abs, realRoots);
333
+ if (!realInside) {
334
+ outside.push(p);
335
+ }
336
+ }
337
+ if (outside.length === 0) return { decision: 'allow' };
338
+
339
+ const policy = WRITE_TOOLS.has(input.toolName)
340
+ ? writePolicy
341
+ : READ_TOOLS.has(input.toolName)
342
+ ? readPolicy
343
+ : writePolicy; // unknown tools — treat as write (stricter)
344
+ if (policy === 'allow') return { decision: 'allow' };
345
+
346
+ const decision: ToolDecision = policy === 'deny' ? 'deny' : 'ask';
347
+ return {
348
+ decision,
349
+ reason: formatReason(config.reason, input.toolName, outside),
350
+ ...(decision === 'ask'
351
+ ? { allowedDecisions: ['approve', 'reject'] as const }
352
+ : {}),
353
+ };
354
+ };
355
+ }
@@ -17,6 +17,12 @@ export {
17
17
  } from './matchers';
18
18
  export { createToolPolicyHook } from './createToolPolicyHook';
19
19
  export type { ToolPolicyMode, ToolPolicyConfig } from './createToolPolicyHook';
20
+ export { createWorkspacePolicyHook } from './createWorkspacePolicyHook';
21
+ export type {
22
+ OutsideAccessPolicy,
23
+ WorkspacePolicyConfig,
24
+ PathExtractor,
25
+ } from './createWorkspacePolicyHook';
20
26
  export { HOOK_EVENTS } from './types';
21
27
  export type {
22
28
  HookEvent,
package/src/index.ts CHANGED
@@ -26,6 +26,7 @@ export * from './tools/ToolSearch';
26
26
  export * from './tools/ToolNode';
27
27
  export * from './tools/schema';
28
28
  export * from './tools/handlers';
29
+ export * from './tools/local';
29
30
  export * from './tools/search';
30
31
 
31
32
  /* Misc. */
@@ -0,0 +1,125 @@
1
+ import { z } from 'zod';
2
+ import { tool } from '@langchain/core/tools';
3
+ import { describe, it, expect } from '@jest/globals';
4
+ import {
5
+ makeIsDeferred,
6
+ partitionAndMarkAnthropicToolCache,
7
+ } from '../anthropicToolCache';
8
+
9
+ function fakeTool(name: string): unknown {
10
+ return tool(async () => 'ok', {
11
+ name,
12
+ description: `tool ${name}`,
13
+ schema: z.object({}),
14
+ });
15
+ }
16
+
17
+ describe('partitionAndMarkAnthropicToolCache', () => {
18
+ it('returns input unchanged when there are no tools', () => {
19
+ expect(
20
+ partitionAndMarkAnthropicToolCache(undefined, () => false)
21
+ ).toBeUndefined();
22
+ const empty = [] as unknown as Parameters<
23
+ typeof partitionAndMarkAnthropicToolCache
24
+ >[0];
25
+ expect(partitionAndMarkAnthropicToolCache(empty, () => false)).toBe(empty);
26
+ });
27
+
28
+ it('returns input unchanged when every tool is deferred', () => {
29
+ const tools = [fakeTool('a'), fakeTool('b')] as never;
30
+ const result = partitionAndMarkAnthropicToolCache(tools, () => true);
31
+ expect(result).toBe(tools);
32
+ });
33
+
34
+ it('partitions static-first, deferred-last and stamps cache_control on the last static tool', () => {
35
+ const a = fakeTool('a-static');
36
+ const b = fakeTool('b-deferred');
37
+ const c = fakeTool('c-static');
38
+ const d = fakeTool('d-deferred');
39
+ const isDeferred = (n: string): boolean => n.endsWith('-deferred');
40
+ const out = partitionAndMarkAnthropicToolCache(
41
+ [a, b, c, d] as never,
42
+ isDeferred
43
+ ) as Array<{ name: string; extras?: { cache_control?: { type: string } } }>;
44
+
45
+ expect(out.map((t) => t.name)).toEqual([
46
+ 'a-static',
47
+ 'c-static',
48
+ 'b-deferred',
49
+ 'd-deferred',
50
+ ]);
51
+ expect(out[1].extras?.cache_control).toEqual({ type: 'ephemeral' });
52
+ expect(out[0].extras?.cache_control).toBeUndefined();
53
+ expect(out[2].extras?.cache_control).toBeUndefined();
54
+ expect(out[3].extras?.cache_control).toBeUndefined();
55
+ });
56
+
57
+ it('does not mutate the original tool instance', () => {
58
+ const a = fakeTool('a-static') as { extras?: unknown };
59
+ const out = partitionAndMarkAnthropicToolCache(
60
+ [a] as never,
61
+ () => false
62
+ ) as Array<{ extras?: unknown }>;
63
+ expect(out[0]).not.toBe(a);
64
+ expect((a as { extras?: unknown }).extras).toBeUndefined();
65
+ expect(out[0].extras).toBeDefined();
66
+ });
67
+
68
+ it('preserves the prototype chain so instanceof checks survive', () => {
69
+ const a = fakeTool('a-static');
70
+ const ctor = (a as object).constructor;
71
+ const out = partitionAndMarkAnthropicToolCache(
72
+ [a] as never,
73
+ () => false
74
+ ) as object[];
75
+ expect(out[0].constructor).toBe(ctor);
76
+ });
77
+
78
+ it('keeps existing extras keys intact when stamping', () => {
79
+ const a = fakeTool('a-static') as { extras?: Record<string, unknown> };
80
+ a.extras = { providerToolDefinition: { foo: 'bar' } };
81
+ const out = partitionAndMarkAnthropicToolCache(
82
+ [a] as never,
83
+ () => false
84
+ ) as Array<{ extras?: Record<string, unknown> }>;
85
+ expect(out[0].extras?.providerToolDefinition).toEqual({ foo: 'bar' });
86
+ expect(out[0].extras?.cache_control).toEqual({ type: 'ephemeral' });
87
+ });
88
+
89
+ it('is idempotent when re-marking a tool that already has the marker', () => {
90
+ const a = fakeTool('a-static') as { extras?: Record<string, unknown> };
91
+ a.extras = { cache_control: { type: 'ephemeral' } };
92
+ const input = [a] as never;
93
+ // No deferred tools and the only static tool is already marked → input
94
+ // is returned unchanged (same reference) so we don't churn the array.
95
+ expect(partitionAndMarkAnthropicToolCache(input, () => false)).toBe(input);
96
+ });
97
+ });
98
+
99
+ describe('makeIsDeferred', () => {
100
+ it('returns false for everything when no defs are supplied', () => {
101
+ const isDeferred = makeIsDeferred(undefined);
102
+ expect(isDeferred('anything')).toBe(false);
103
+ });
104
+
105
+ it('returns false for everything when no def has defer_loading=true', () => {
106
+ const isDeferred = makeIsDeferred([
107
+ { name: 'a' },
108
+ { name: 'b', defer_loading: false },
109
+ ]);
110
+ expect(isDeferred('a')).toBe(false);
111
+ expect(isDeferred('b')).toBe(false);
112
+ });
113
+
114
+ it('returns true only for names declared as deferred', () => {
115
+ const isDeferred = makeIsDeferred([
116
+ { name: 'a' },
117
+ { name: 'b', defer_loading: true },
118
+ { name: 'c', defer_loading: false },
119
+ ]);
120
+ expect(isDeferred('a')).toBe(false);
121
+ expect(isDeferred('b')).toBe(true);
122
+ expect(isDeferred('c')).toBe(false);
123
+ expect(isDeferred('unknown')).toBe(false);
124
+ });
125
+ });