supercode_harness/tools/mod.rs
1//! Tools the agent can call.
2//!
3//! A [`Tool`] is a named capability with a JSON-Schema input and an async
4//! `execute`. The [`ToolRegistry`] holds the set offered to a model; built-ins
5//! cover file read/write/edit, directory listing, glob, content search, and
6//! shell execution. Every tool can be disabled or re-described per
7//! [`crate::Config`], so the capability surface is entirely yours to shape.
8
9mod builtins;
10pub(crate) mod tiers;
11
12use std::collections::HashMap;
13use std::collections::HashSet;
14use std::path::{Path, PathBuf};
15use std::sync::{Arc, Mutex};
16
17use async_trait::async_trait;
18
19use crate::config::Config;
20use crate::error::Result;
21use crate::modules::ModuleId;
22
23pub use builtins::{
24 ApplyPatchTool, BashTool, EditFileTool, GlobTool, ListDirTool, PersistentShellTool,
25 ReadFileTool, SearchTool, UpdatePlanTool, ViewImageTool, WebFetchTool, WebSearchTool,
26 WriteFileTool, WEB_SEARCH_URL_ENV,
27};
28// P5-1 F4: `crate::agent`'s permissions gate needs to check an
29// `apply_patch` envelope's write surface against `protected_paths` — not
30// part of the crate's public tool-registration API, so `pub(crate)` rather
31// than folded into the `pub use` list above.
32pub(crate) use builtins::patch_target_paths;
33// P5-6 (§2 module 4 `tools.background`): `crate::agent::Agent`'s
34// `background_exec` intrinsic reuses `BashTool`'s own sandboxed-spawn
35// builder rather than duplicating it — see that function's doc comment.
36pub(crate) use builtins::build_sandboxed_sh;
37pub use tiers::{minify as minify_tool_schema, SchemaTier};
38// `SandboxPolicy` and `ToolContext` are defined below in this module.
39
40/// P4c (COMPOSABLE-HARNESS-DESIGN.md S1.2/S3.1 `core.tools.read_file
41/// multimodal`, S1.2 `view_image`): the sentinel prefix a tool's plain
42/// `String` result carries when it is actually an image data URL rather
43/// than ordinary text — `Agent::run_loop` detects this prefix (before
44/// `cap_tool_output` ever sees it) and builds a `content_parts` image
45/// block instead of a plain-text tool result. Using a control character
46/// (`\u{1}`, SOH) as part of the marker keeps a false-positive collision
47/// with real tool output astronomically unlikely without requiring a new
48/// `Tool::execute` return type across all ten built-ins (an L-sized
49/// trait-signature change this S-sized catalog item does not call for).
50pub const MULTIMODAL_IMAGE_MARKER: &str = "\u{1}SUPERCODE_IMAGE_DATA_URL\u{1}";
51
52/// P4c (S1.2 `core.tools.read_file.multimodal` / `view_image`): recognized
53/// image file extensions (lowercase, no dot) — the same set CC/pi treat as
54/// "images" for multimodal read (catalog D1 row 2's `✓*`/`✓*` variants).
55pub const IMAGE_EXTENSIONS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "bmp"];
56
57/// Whether `path`'s extension is a recognized image type (case-insensitive).
58pub fn is_image_path(path: &Path) -> bool {
59 path.extension()
60 .and_then(|e| e.to_str())
61 .map(|e| IMAGE_EXTENSIONS.contains(&e.to_ascii_lowercase().as_str()))
62 .unwrap_or(false)
63}
64
65/// The `image/<subtype>` MIME type for a recognized image extension, for
66/// the `data:` URL — falls back to `png` for anything [`is_image_path`]
67/// didn't already gate (defensive; never actually hit through
68/// [`is_image_path`]'s own extension list).
69pub fn image_mime_for(path: &Path) -> &'static str {
70 match path
71 .extension()
72 .and_then(|e| e.to_str())
73 .map(|e| e.to_ascii_lowercase())
74 .as_deref()
75 {
76 Some("jpg") | Some("jpeg") => "image/jpeg",
77 Some("gif") => "image/gif",
78 Some("webp") => "image/webp",
79 Some("bmp") => "image/bmp",
80 _ => "image/png",
81 }
82}
83
84/// P4c (S1.2 `core.tools.edit_file.notebook_aware`): the extension that
85/// gates `EditFileTool`'s Jupyter cell-surgery branch.
86pub const NOTEBOOK_EXTENSION: &str = "ipynb";
87
88/// P4c (S2 module 5 `tools.web`, S2.1 dep "network sandbox rules", S17):
89/// the network-domain policy a caller (SDK embedder) may install on a
90/// [`ToolContext`] so [`crate::tools::WebFetchTool`]/[`crate::tools::WebSearchTool`]
91/// respect it — see [`ToolContext::check_network`]. `None` on the context
92/// (the default) means no policy is configured, matching today's honest
93/// gap (no P5 `capabilities.permissions.sandbox.network` engine exists
94/// yet, C3 — tracked, not hidden).
95#[derive(Debug, Clone, Default)]
96pub struct NetworkPolicy {
97 /// Whether the policy is enforced at all. `false` behaves exactly like
98 /// `None` on the context.
99 pub enabled: bool,
100 /// If non-empty, only these hosts (exact match) are allowed.
101 pub allow_domains: Vec<String>,
102 /// These hosts (exact match) are always denied, even if also present in
103 /// `allow_domains`.
104 pub deny_domains: Vec<String>,
105}
106
107/// Filesystem confinement applied to write-capable tools — the analog of
108/// Codex's `read-only` / `workspace-write` / `danger-full-access` sandbox modes.
109///
110/// Enforced at the tool layer for file operations (`write_file`, `edit_file`,
111/// `apply_patch`). Note: this confines the *file tools*; it does not OS-sandbox
112/// arbitrary subprocesses (`bash`/`shell`) — true process isolation needs
113/// platform primitives (seatbelt/landlock) and is a separate concern. Use
114/// [`shell_sandbox_unenforceable`] as the runtime check for whether that gap
115/// applies to the current platform and enabled tools.
116#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
117pub enum SandboxPolicy {
118 /// No file writes are permitted by the file tools.
119 ReadOnly,
120 /// Writes are permitted only inside the working directory.
121 WorkspaceWrite,
122 /// No confinement (default — preserves prior behavior).
123 #[default]
124 DangerFullAccess,
125}
126
127/// P5-9 (design §2 module 20 `checkpoint`, §2.1 D-5 "write-path
128/// interception seam shared with `formatters`"): the ONE well-defined
129/// interception point around every file-mutating built-in tool
130/// (`write_file`/`edit_file`/`apply_patch`) — installed on
131/// [`ToolContext::write_observer`], `None` by default. Both hooks fire
132/// AFTER [`ToolContext::check_write`] has already approved the call (so an
133/// observer never sees a write the sandbox itself refused) and BEFORE/AFTER
134/// the actual mutation:
135/// - [`Self::before_write`] — pre-image capture. `crate::checkpoint`'s
136/// [`crate::checkpoint::CheckpointObserver`] is the only implementation
137/// today: it snapshots `path`'s current on-disk content (or records "did
138/// not exist") so a later `checkpoint restore` can undo the write.
139/// - [`Self::after_write`] — post-write. A true no-op in every
140/// implementation shipped so far; reserved for `formatters` (P5-11,
141/// design line 510 "shared seam with checkpoint") to run format-on-write
142/// from, without needing a SECOND interception point wired through the
143/// same three tools.
144///
145/// `None` (the default — `[capabilities.checkpoint]` off and no formatters
146/// module yet) means neither hook is ever consulted: every write-tool
147/// call-site's observer check is `if let Some(obs) = &ctx.write_observer`,
148/// a branch that's simply never taken, so behavior is byte-identical to
149/// before this seam existed.
150///
151/// P5-11 (§2 modules 28/29 `lsp`/`formatters`, C10): `async_trait` (rather
152/// than the plain sync methods P5-9 originally shipped) because BOTH new
153/// observers need real async I/O in `after_write` — `formatters` spawns and
154/// awaits a subprocess, `lsp` writes/reads framed JSON-RPC over a child's
155/// stdio — and neither can block the tokio runtime thread the way a
156/// synchronous call from inside an already-`async fn execute()` would.
157/// `CheckpointObserver`'s own hooks stay synchronous *internally* (plain
158/// blocking `std::fs` calls); wrapping them in `async fn` changes nothing
159/// observable for it, since that blocking work already ran on the calling
160/// task before this signature changed. `after_write` now RETURNS
161/// `Option<String>` — an annotation to append to the calling tool's result
162/// string (formatter diff-back content, or LSP diagnostics) — `None` when
163/// the observer has nothing to report, which is the only value
164/// `CheckpointObserver::after_write` (still a no-op) ever returns, keeping
165/// today's tool-result text byte-identical whenever checkpoint is the only
166/// observer installed.
167#[async_trait]
168pub trait WriteObserver: Send + Sync + std::fmt::Debug {
169 /// `path` (already resolved + sandbox-checked) is about to be
170 /// created/overwritten/deleted. Implementations must be fast and must
171 /// never propagate a failure as a tool error — a capture failure should
172 /// degrade the OBSERVER (e.g. disable itself with a one-time warning),
173 /// never block or fail the user's actual edit.
174 async fn before_write(&self, path: &Path);
175 /// `path` was just written/deleted successfully. Not called when the
176 /// tool call itself failed (e.g. the write errored before completing).
177 /// Returns an optional annotation for the calling tool's result text —
178 /// see the trait doc comment above.
179 async fn after_write(&self, path: &Path) -> Option<String>;
180}
181
182/// P5-11 (§2 modules 28/29, D-5 "shared write-path interception seam"): an
183/// ORDERED chain of [`WriteObserver`]s installed as a single
184/// `ToolContext::write_observer`, so the ONE seam P5-9 built keeps
185/// supporting exactly one call site per tool while now composing multiple
186/// concerns. Order is caller-determined (`crate::agent::build_tool_context`
187/// builds it `checkpoint → formatters → lsp`, design's own required
188/// ordering: checkpoint must capture the PRE-image before anything mutates
189/// the file; formatters must run before lsp so diagnostics reflect the
190/// FINAL, formatted file, not the model's pre-format draft).
191/// `before_write` runs every observer in order; `after_write` runs every
192/// observer in order too and joins any non-empty annotations with a blank
193/// line, so a formatter's diff-back and an LSP diagnostics block can both
194/// appear in one tool result without one silently discarding the other.
195#[derive(Debug)]
196pub struct WriteObserverChain(Vec<Arc<dyn WriteObserver>>);
197
198impl WriteObserverChain {
199 /// Build a chain that runs `observers` in order for both hooks.
200 pub fn new(observers: Vec<Arc<dyn WriteObserver>>) -> Self {
201 WriteObserverChain(observers)
202 }
203}
204
205#[async_trait]
206impl WriteObserver for WriteObserverChain {
207 async fn before_write(&self, path: &Path) {
208 for obs in &self.0 {
209 obs.before_write(path).await;
210 }
211 }
212 async fn after_write(&self, path: &Path) -> Option<String> {
213 let mut notes: Vec<String> = Vec::new();
214 for obs in &self.0 {
215 if let Some(note) = obs.after_write(path).await {
216 if !note.is_empty() {
217 notes.push(note);
218 }
219 }
220 }
221 if notes.is_empty() {
222 None
223 } else {
224 Some(notes.join("\n\n"))
225 }
226 }
227}
228
229/// Ambient context passed to every tool invocation.
230#[derive(Debug, Clone)]
231pub struct ToolContext {
232 /// The working directory tools resolve relative paths against.
233 pub cwd: PathBuf,
234 /// Filesystem confinement for write-capable tools.
235 pub sandbox: SandboxPolicy,
236 /// P4c (S1.2 `core.tools.read_file.multimodal`): whether `read_file`
237 /// (and `view_image`, unconditionally) returns a recognized image file
238 /// as a model-visible image content block. `false` (the default) is
239 /// byte-identical to today's UTF-8-lossy-decode behavior.
240 pub multimodal_read: bool,
241 /// P4c (S1.2 `core.tools.edit_file.require_read_before_edit`, UNIQUE CC
242 /// row): whether `edit_file` refuses a path not yet read this
243 /// conversation. `false` (the default) is byte-identical to today's
244 /// behavior — [`Self::read_paths`] is simply never consulted.
245 pub require_read_before_edit: bool,
246 /// P4c: canonicalized paths `read_file` has successfully read so far
247 /// this conversation — shared (via `Arc<Mutex<_>>`) across every clone
248 /// of this context, since `Agent` constructs one `ToolContext` at
249 /// startup and reuses it for every tool call. Consulted by `EditFileTool`
250 /// only when [`Self::require_read_before_edit`] is `true`.
251 pub read_paths: Arc<Mutex<HashSet<PathBuf>>>,
252 /// P4c (S1.2 `core.tools.edit_file.notebook_aware`, UNIQUE CC row
253 /// "NotebookEdit"): whether `edit_file` accepts Jupyter cell
254 /// replace/insert/delete operations against a `.ipynb` target. `false`
255 /// (the default) is byte-identical to today's exact-string-replace-only
256 /// behavior.
257 pub notebook_aware: bool,
258 /// P4c (S1.2 `core.shell_env_snapshot`): the user's captured
259 /// interactive-shell environment, if [`crate::Config::shell_env_snapshot`]
260 /// is on — `BashTool`/`PersistentShellTool` merge this into the spawned
261 /// process's environment. `None` (the default) is byte-identical to
262 /// today's behavior: no extra environment is injected.
263 pub shell_env: Option<Arc<HashMap<String, String>>>,
264 /// P4c (S1.4 `core.nested_instructions`, deferred from P4b): whether a
265 /// file-touching tool injects an as-yet-unseen subdirectory's own
266 /// `CLAUDE.md`/`AGENTS.md` into its result the first time a path under
267 /// it is touched. `false` (the default) is byte-identical to today's
268 /// behavior.
269 pub nested_instructions: bool,
270 /// P4c: subdirectories (relative to [`Self::cwd`]) whose nested
271 /// instructions have already been injected this conversation — shared
272 /// across clones, same rationale as [`Self::read_paths`]. Consulted only
273 /// when [`Self::nested_instructions`] is `true`.
274 pub injected_instruction_dirs: Arc<Mutex<HashSet<PathBuf>>>,
275 /// P4c (S2 module 5 `tools.web`, S17): the network-domain policy
276 /// `web_fetch`/`web_search` must respect, if one is configured. `None`
277 /// (the default) means no policy is enforced — see [`NetworkPolicy`]'s
278 /// doc comment for the honest-gap rationale.
279 pub network_policy: Option<NetworkPolicy>,
280 /// P4e (S3.1 `core.tools.bash.timeout_secs`, S14): the DEFAULT
281 /// execution timeout (seconds) `BashTool::execute` falls back to when a
282 /// model-issued call carries no `timeout_ms` argument of its own -- see
283 /// `crate::config::ToolOverride::timeout_secs`. `None` (the default) is
284 /// byte-identical to today's behavior: `BashTool`'s built-in
285 /// `DEFAULT_BASH_TIMEOUT_MS` (120s) stands.
286 pub bash_timeout_secs: Option<u64>,
287 /// P5-9 (§2 module 20, D-5 shared write-path interception seam) — see
288 /// [`WriteObserver`]'s doc comment. `None` (the default) is a true
289 /// no-op: every write-tool call site's `if let Some(obs) = ...` branch
290 /// is simply never taken.
291 pub write_observer: Option<Arc<dyn WriteObserver>>,
292 /// P5-10 (§2 module 12 `permissions.sandbox`): whether the OS-level
293 /// backstop (Landlock/seatbelt) is engaged for the `bash`/`shell`
294 /// subprocess — see `crate::sandbox::os_sandbox_active`. `None` (the
295 /// default) preserves the pre-P5-10 trigger (confine whenever
296 /// [`Self::sandbox`] isn't [`SandboxPolicy::DangerFullAccess`]).
297 pub sandbox_os_enabled: Option<bool>,
298 /// P5-10 (§2 module 12, `escalation`): what to do when a confining fs
299 /// tier can't actually be enforced on this platform/kernel — see
300 /// `crate::sandbox::SandboxEscalation`. Defaults to `Deny`
301 /// (fail-closed).
302 pub sandbox_escalation: crate::sandbox::SandboxEscalation,
303 /// P5-10 (§2 module 12, `env_policy`): child-process environment
304 /// sanitization for the spawned subprocess — see
305 /// `crate::sandbox::SandboxEnvPolicy`. Defaults to `Inherit`
306 /// (byte-identical to pre-P5-10 behavior).
307 pub sandbox_env_policy: crate::sandbox::SandboxEnvPolicy,
308 /// P5-10 (§2 module 12, `escalation = "ask"` → `permissions.approvals`,
309 /// P5-1): the ambient handler `crate::sandbox::decide_fs` consults for
310 /// an `ask`-tier sandbox-unenforceable decision. `None` (the default —
311 /// no handler installed) is fail-closed, same posture as the P5-1 rule
312 /// engine's own `Ask` tier with no handler.
313 pub sandbox_approval_handler: Option<crate::sandbox::SandboxApprovalHandler>,
314}
315
316impl ToolContext {
317 /// A context rooted at `cwd` with no confinement.
318 pub fn new(cwd: impl Into<PathBuf>) -> Self {
319 ToolContext {
320 cwd: cwd.into(),
321 sandbox: SandboxPolicy::DangerFullAccess,
322 multimodal_read: false,
323 require_read_before_edit: false,
324 read_paths: Arc::new(Mutex::new(HashSet::new())),
325 notebook_aware: false,
326 shell_env: None,
327 nested_instructions: false,
328 injected_instruction_dirs: Arc::new(Mutex::new(HashSet::new())),
329 network_policy: None,
330 bash_timeout_secs: None,
331 write_observer: None,
332 sandbox_os_enabled: None,
333 sandbox_escalation: crate::sandbox::SandboxEscalation::default(),
334 sandbox_env_policy: crate::sandbox::SandboxEnvPolicy::default(),
335 sandbox_approval_handler: None,
336 }
337 }
338
339 /// P5-10: whether the OS-level backstop is active for this context —
340 /// thin wrapper over `crate::sandbox::os_sandbox_active`.
341 pub fn os_sandbox_active(&self) -> bool {
342 crate::sandbox::os_sandbox_active(self.sandbox, self.sandbox_os_enabled)
343 }
344
345 /// P4c: record `path` (canonicalized if possible, else the resolved
346 /// path as-is) as having been read this conversation — called by
347 /// `ReadFileTool` on every successful read, unconditionally (cheap; the
348 /// set is only ever CONSULTED when [`Self::require_read_before_edit`] is
349 /// on, but recording it unconditionally means turning the knob on
350 /// mid-conversation sees every read that already happened).
351 pub fn mark_read(&self, path: &Path) {
352 let key = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
353 if let Ok(mut set) = self.read_paths.lock() {
354 set.insert(key);
355 }
356 }
357
358 /// P4c: whether `path` was previously recorded via [`Self::mark_read`].
359 pub fn was_read(&self, path: &Path) -> bool {
360 let key = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
361 self.read_paths
362 .lock()
363 .map(|set| set.contains(&key))
364 .unwrap_or(false)
365 }
366
367 /// P4c (S2.1 S17): does `url` pass `Self::network_policy`, if one is
368 /// configured? `Ok(())` when no policy is set (the honest-gap default)
369 /// or the policy is present-but-disabled; `Err` names the reason
370 /// otherwise. A URL with no parseable host is denied whenever a policy
371 /// is actively enforced (fail closed — an unparseable host can't be
372 /// matched against an allowlist).
373 pub fn check_network(&self, url: &str) -> Result<()> {
374 check_network_policy(self.network_policy.as_ref(), url)
375 }
376
377 /// Resolve a possibly-relative path against the working directory.
378 pub fn resolve(&self, path: &str) -> PathBuf {
379 let p = PathBuf::from(path);
380 if p.is_absolute() {
381 p
382 } else {
383 self.cwd.join(p)
384 }
385 }
386
387 /// Enforce the sandbox policy for a write to `path`. `Err` if denied.
388 pub fn check_write(&self, path: &Path) -> Result<()> {
389 match self.sandbox {
390 SandboxPolicy::DangerFullAccess => Ok(()),
391 SandboxPolicy::ReadOnly => Err(crate::error::Error::tool(
392 "sandbox",
393 "write denied: sandbox is read-only",
394 )),
395 SandboxPolicy::WorkspaceWrite => {
396 if path_within(&self.cwd, path) {
397 Ok(())
398 } else {
399 Err(crate::error::Error::tool(
400 "sandbox",
401 format!(
402 "write denied: {} is outside the workspace {}",
403 path.display(),
404 self.cwd.display()
405 ),
406 ))
407 }
408 }
409 }
410 }
411}
412
413/// P5-2 (§2 module 15, security note "remote MCP over http/sse: respect the
414/// NetworkPolicy from P5-1 if one is active"): the same policy-and-url check
415/// [`ToolContext::check_network`] performs, factored out to a free function
416/// so `crate::mcp::McpClient::connect_http`/`connect_sse` can enforce the
417/// identical allow/deny/SSRF floor a `web_fetch` call would get — one
418/// enforcement point, not a second parallel one that could silently drift
419/// from it.
420pub(crate) fn check_network_policy(policy: Option<&NetworkPolicy>, url: &str) -> Result<()> {
421 let Some(policy) = policy else {
422 return Ok(());
423 };
424 if !policy.enabled {
425 return Ok(());
426 }
427 check_host_against_policy(policy, url_host(url).as_deref())
428}
429
430/// P4c-review (MEDIUM/LOW follow-up, dep 8's neighboring `tools.web` SSRF
431/// gap): the SAME allow/deny decision [`ToolContext::check_network`] applies
432/// to the INITIAL url, factored out so [`network_checked_redirect_policy`]
433/// can apply it to every REDIRECT hop too. Without this, `check_network`
434/// validated only the url the caller passed in — once a real network policy
435/// is wired up (P5), a denied host reachable only via an allowed host's HTTP
436/// redirect (reqwest follows up to 10 by default) bypassed the check
437/// entirely. `host: None` (unparseable/absent) fails closed, exactly like
438/// `check_network`'s own prior inline behavior.
439fn check_host_against_policy(policy: &NetworkPolicy, host: Option<&str>) -> Result<()> {
440 let Some(host) = host else {
441 return Err(crate::error::Error::tool(
442 "network",
443 "cannot determine host from url; denied under an active network policy",
444 ));
445 };
446 let host = host.to_ascii_lowercase();
447 if policy.deny_domains.iter().any(|d| d == &host) {
448 return Err(crate::error::Error::tool(
449 "network",
450 format!("host `{host}` is denied by the active network policy"),
451 ));
452 }
453 if !policy.allow_domains.is_empty() && !policy.allow_domains.iter().any(|d| d == &host) {
454 return Err(crate::error::Error::tool(
455 "network",
456 format!("host `{host}` is not on the network policy's allowlist"),
457 ));
458 }
459 Ok(())
460}
461
462/// P4c-review (MEDIUM/LOW follow-up): a `reqwest::redirect::Policy` for
463/// `WebFetchTool`/`WebSearchTool`'s client that re-runs
464/// [`check_host_against_policy`] (the exact same check
465/// [`ToolContext::check_network`] applies to the initial url) against every
466/// redirect hop's target host, refusing to follow one that a network policy
467/// denies. `policy: None` (no policy configured) or a present-but-disabled
468/// one behaves like reqwest's own default policy — follow, capped at the
469/// same 10-hop limit `redirect::Policy::default()` uses (the crate's `custom`
470/// variant does NOT enforce a redirect cap on its own — see its doc comment
471/// — so this reimplements that cap by hand).
472pub(crate) fn network_checked_redirect_policy(
473 policy: Option<NetworkPolicy>,
474) -> reqwest::redirect::Policy {
475 const MAX_REDIRECTS: usize = 10; // matches reqwest::redirect::Policy::default()
476 reqwest::redirect::Policy::custom(move |attempt| {
477 if attempt.previous().len() >= MAX_REDIRECTS {
478 return attempt.error("too many redirects");
479 }
480 if let Some(policy) = &policy {
481 if policy.enabled {
482 if let Err(e) = check_host_against_policy(policy, attempt.url().host_str()) {
483 return attempt.error(e.to_string());
484 }
485 }
486 }
487 attempt.follow()
488 })
489}
490
491/// P4c (S2.1 S17): extract the host from an `http(s)://` URL — the smallest
492/// parser that satisfies [`ToolContext::check_network`]'s needs without a
493/// new `url`-crate dependency (matches this crate's existing `glob_match`
494/// precedent of hand-rolling a small parser rather than reaching for a
495/// dependency for an S-sized need). Returns `None` for anything that isn't
496/// `http://`/`https://` or has an empty host component.
497fn url_host(url: &str) -> Option<String> {
498 let rest = url
499 .strip_prefix("https://")
500 .or_else(|| url.strip_prefix("http://"))?;
501 let end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
502 let authority = &rest[..end];
503 // Strip a `user:pass@` prefix and a `:port` suffix, keeping the host.
504 let host_and_port = authority.rsplit('@').next().unwrap_or(authority);
505 let host = host_and_port.split(':').next().unwrap_or(host_and_port);
506 if host.is_empty() {
507 None
508 } else {
509 Some(host.to_ascii_lowercase())
510 }
511}
512
513/// True when the requested sandbox policy cannot be enforced for shell
514/// subprocesses: a confining policy, a platform without an OS sandbox
515/// primitive wired up (only macOS/seatbelt is, via `sandbox-exec`), and at
516/// least one shell tool (`"bash"` or `"shell"`) enabled.
517///
518/// This is a pure function so it's mechanically testable on any host OS:
519/// callers pass the platform (typically `std::env::consts::OS`) and the set
520/// of enabled tool names rather than relying on `cfg!`/`target_os`. It does
521/// not itself sandbox anything — it only tells embedders/CLIs whether the
522/// gap documented on [`SandboxPolicy`] applies right now, so they can warn.
523/// P5-10 (§2 module 12): `landlock_available` is the caller's REAL Linux
524/// Landlock-availability probe (`crate::sandbox::landlock_available()`,
525/// typically) — a PARAMETER, not an internal `cfg!`/probe call, same "pure,
526/// mechanically testable" contract this function already had. Before
527/// P5-10, `platform == "linux"` always meant "unenforceable" (no OS
528/// primitive existed yet); now it means "unenforceable UNLESS Landlock is
529/// actually available on this kernel" — a confining tier on a
530/// Landlock-capable Linux box is REAL enforcement, not a gap, so this must
531/// say `false` for it (never claim a gap that no longer exists).
532/// `platform == "macos"` is unconditionally `false` regardless of this
533/// parameter (seatbelt, a separate primitive, always exists there); every
534/// other platform (including `platform == "linux"` with
535/// `landlock_available == false`) is unaffected by this parameter and
536/// keeps the pre-P5-10 "no primitive" answer.
537pub fn shell_sandbox_unenforceable(
538 policy: SandboxPolicy,
539 platform: &str,
540 tools_enabled: &[&str],
541 landlock_available: bool,
542) -> bool {
543 policy != SandboxPolicy::DangerFullAccess
544 && platform != "macos"
545 && !(platform == "linux" && landlock_available)
546 && tools_enabled.iter().any(|t| *t == "bash" || *t == "shell")
547}
548
549/// Whether `path` is inside `root`. SECURITY (safe-path consolidation,
550/// LOWER-URGENCY fix folded into the permissions-gate CRITICAL fix): this
551/// used to compare only LEXICALLY-normalized paths (`..` traversal caught,
552/// but a pre-existing in-workspace symlink pointing outside `root` was NOT —
553/// `link -> /etc` plus a write to `link/passwd` lexically normalizes to
554/// `<root>/link/passwd`, which "starts with" `root` even though it actually
555/// resolves outside it). Now delegates to `crate::safe_path::contained`,
556/// which ALSO resolves symlinks along the longest existing ancestor (the
557/// same proven dual lexical+resolved check `crate::checkpoint`'s P5-9 fix
558/// uses), so a symlink escape is caught here too. A non-existent target
559/// (e.g. a file about to be created) is still handled correctly.
560fn path_within(root: &Path, path: &Path) -> bool {
561 crate::safe_path::contained(root, path)
562}
563
564/// `pub(crate)`: also the lexical-`..`-collapse step
565/// [`crate::checkpoint`]'s containment check builds on (P5-9) — one
566/// normalizer, not a second hand-rolled one.
567pub(crate) fn normalize(path: &Path) -> Option<PathBuf> {
568 use std::path::Component;
569 // Make absolute against CWD if needed (paths are already joined to cwd by
570 // resolve(), but be defensive).
571 let abs = if path.is_absolute() {
572 path.to_path_buf()
573 } else {
574 std::env::current_dir().ok()?.join(path)
575 };
576 let mut out = PathBuf::new();
577 for c in abs.components() {
578 match c {
579 Component::ParentDir => {
580 out.pop();
581 }
582 Component::CurDir => {}
583 other => out.push(other.as_os_str()),
584 }
585 }
586 Some(out)
587}
588
589/// A callable capability.
590#[async_trait]
591pub trait Tool: Send + Sync {
592 /// Stable, unique tool name (what the model calls).
593 fn name(&self) -> &str;
594
595 /// The built-in description. May be overridden via [`crate::Config`].
596 fn description(&self) -> &str;
597
598 /// JSON Schema describing the tool's input object.
599 fn parameters(&self) -> serde_json::Value;
600
601 /// Run the tool. Returns text to feed back to the model.
602 async fn execute(&self, args: serde_json::Value, ctx: &ToolContext) -> Result<String>;
603}
604
605/// An ordered set of tools offered to the model.
606#[derive(Default)]
607pub struct ToolRegistry {
608 tools: Vec<Box<dyn Tool>>,
609}
610
611impl ToolRegistry {
612 /// An empty registry.
613 pub fn new() -> Self {
614 ToolRegistry::default()
615 }
616
617 /// A registry pre-populated with all built-in tools.
618 pub fn with_builtins() -> Self {
619 let mut r = ToolRegistry::new();
620 r.register(ReadFileTool);
621 r.register(WriteFileTool);
622 r.register(EditFileTool);
623 r.register(ListDirTool);
624 r.register(GlobTool);
625 r.register(SearchTool);
626 r.register(ApplyPatchTool);
627 r.register(BashTool::default());
628 r.register(PersistentShellTool::default());
629 r.register(UpdatePlanTool::default());
630 r
631 }
632
633 /// P3 (COMPOSABLE-HARNESS-DESIGN.md §5.2 phase P3): build a registry
634 /// from a resolved [`Config`]'s module-activation set, the intended
635 /// replacement for unconditional [`Self::with_builtins`] call sites.
636 ///
637 /// **Mandatory risk-2 mitigation (§5.3 risk 2 — "land P3 behind
638 /// `[experimental] module_registry = true`"):** when
639 /// [`Config::module_registry`] is `false` (the default), this returns
640 /// EXACTLY [`Self::with_builtins`] — same 10 tools, same order, zero
641 /// behavior change. Only when the flag is explicitly on does
642 /// [`Config::module_activation`]/[`Config::core_tools_enabled`] start
643 /// shaping which tool objects get registered AT ALL: a disabled module
644 /// contributes no tool (never registered, so never advertised and never
645 /// mentioned anywhere) — e.g. `todos` off means `update_plan` is not in
646 /// this registry; `tools_search` off (or its `list_dir`/`glob`/
647 /// `content_search` sub-flags off) means the corresponding tool is
648 /// absent too.
649 pub fn from_config(config: &Config) -> Self {
650 if !config.module_registry {
651 return Self::with_builtins();
652 }
653 let mut r = ToolRegistry::new();
654 let core_has = |name: &str| config.core_tools_enabled.iter().any(|t| t == name);
655 let act = &config.module_activation;
656
657 // Same relative order as `with_builtins()` for everything both paths
658 // can register, so a partial activation set stays predictable.
659 if core_has("read_file") {
660 r.register(ReadFileTool);
661 }
662 if core_has("write_file") {
663 r.register(WriteFileTool);
664 }
665 if core_has("edit_file") {
666 r.register(EditFileTool);
667 }
668 // P4c (S1.2 `view_image`, S12): a fifth OPTIONAL default-tool name —
669 // "recognized alongside read_file/bash/edit_file/write_file as a
670 // fifth optional default-tool name, not a new module" — so it's
671 // read from the SAME `core_tools_enabled` list as the other four,
672 // not a `ModuleId`. Absent from the list by default (today's
673 // `["read_file","bash","edit_file","write_file"]` default), so this
674 // is a no-op unless a caller explicitly adds `"view_image"`.
675 if core_has("view_image") {
676 r.register(ViewImageTool);
677 }
678 if act.is_active(ModuleId::ToolsSearch) {
679 if act.tools_search_list_dir {
680 r.register(ListDirTool);
681 }
682 if act.tools_search_glob {
683 r.register(GlobTool);
684 }
685 if act.tools_search_content_search {
686 r.register(SearchTool);
687 }
688 }
689 if act.is_active(ModuleId::ToolsApplyPatch) {
690 r.register(ApplyPatchTool);
691 }
692 if core_has("bash") {
693 r.register(BashTool::default());
694 }
695 if act.is_active(ModuleId::ToolsPersistentShell) {
696 r.register(PersistentShellTool::default());
697 }
698 if act.is_active(ModuleId::Todos) {
699 r.register(UpdatePlanTool::default());
700 }
701 // P4c (S2 module 5 `tools.web`, S4a "trivially addable"): single
702 // tool each, gated by the module's own `fetch`/`search` sub-flags
703 // (S3.1: `[capabilities.tools_web] { enabled = false, fetch = true,
704 // search = true }`) exactly like `tools_search`'s three sub-flags.
705 if act.is_active(ModuleId::ToolsWeb) {
706 if act.tools_web_fetch {
707 r.register(WebFetchTool);
708 }
709 if act.tools_web_search {
710 r.register(WebSearchTool);
711 }
712 }
713 r
714 }
715
716 /// Add a tool. A later registration with the same name shadows the earlier.
717 pub fn register(&mut self, tool: impl Tool + 'static) {
718 self.tools.push(Box::new(tool));
719 }
720
721 /// Look up a tool by name (last registration wins).
722 pub fn get(&self, name: &str) -> Option<&dyn Tool> {
723 self.tools
724 .iter()
725 .rev()
726 .find(|t| t.name() == name)
727 .map(|b| b.as_ref())
728 }
729
730 /// Iterate all tools.
731 pub fn iter(&self) -> impl Iterator<Item = &dyn Tool> {
732 self.tools.iter().map(|b| b.as_ref())
733 }
734
735 /// Number of registered tools.
736 pub fn len(&self) -> usize {
737 self.tools.len()
738 }
739
740 /// Whether the registry is empty.
741 pub fn is_empty(&self) -> bool {
742 self.tools.is_empty()
743 }
744}
745
746#[cfg(test)]
747mod tests {
748 use super::*;
749
750 /// Truth table for [`shell_sandbox_unenforceable`]. Pure inputs — no
751 /// `cfg!`/`target_os` gating — so this passes identically on macOS,
752 /// Linux, and Windows CI hosts. P5-10 added the `landlock_available`
753 /// parameter: a Linux host WITH Landlock is no longer a gap (this box
754 /// IS one — see `sandbox::tests`/the integration test for the REAL
755 /// enforcement proof); a Linux host WITHOUT it still is.
756 #[test]
757 fn shell_sandbox_unenforceable_truth_table() {
758 // Confining policy + non-macOS + a shell tool enabled + Landlock
759 // NOT available => true (still an honest gap).
760 assert!(shell_sandbox_unenforceable(
761 SandboxPolicy::ReadOnly,
762 "linux",
763 &["bash"],
764 false,
765 ));
766 assert!(shell_sandbox_unenforceable(
767 SandboxPolicy::WorkspaceWrite,
768 "linux",
769 &["shell"],
770 false,
771 ));
772 // No Landlock concept on Windows at all — `landlock_available` is
773 // irrelevant there (still unenforceable regardless of its value).
774 assert!(shell_sandbox_unenforceable(
775 SandboxPolicy::WorkspaceWrite,
776 "windows",
777 &["bash", "shell"],
778 true,
779 ));
780
781 // P5-10: Linux WITH real Landlock support => NOT a gap anymore —
782 // enforcement now exists, so this must say `false` (never claim a
783 // gap that no longer applies).
784 assert!(!shell_sandbox_unenforceable(
785 SandboxPolicy::ReadOnly,
786 "linux",
787 &["bash"],
788 true,
789 ));
790 assert!(!shell_sandbox_unenforceable(
791 SandboxPolicy::WorkspaceWrite,
792 "linux",
793 &["shell"],
794 true,
795 ));
796
797 // DangerFullAccess => false regardless of platform/tools/landlock.
798 assert!(!shell_sandbox_unenforceable(
799 SandboxPolicy::DangerFullAccess,
800 "linux",
801 &["bash", "shell"],
802 false,
803 ));
804
805 // macOS => false regardless of policy (seatbelt sandboxes the
806 // shell) — even with `landlock_available = true` passed in (an
807 // impossible-in-practice combination, but the function must still
808 // ignore it, since macOS's own primitive is what actually applies).
809 assert!(!shell_sandbox_unenforceable(
810 SandboxPolicy::ReadOnly,
811 "macos",
812 &["bash", "shell"],
813 true,
814 ));
815
816 // No bash/shell in tools_enabled => false.
817 assert!(!shell_sandbox_unenforceable(
818 SandboxPolicy::ReadOnly,
819 "linux",
820 &[],
821 false,
822 ));
823 assert!(!shell_sandbox_unenforceable(
824 SandboxPolicy::ReadOnly,
825 "linux",
826 &["write_file"],
827 false,
828 ));
829 }
830}