Skip to main content

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}