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
9pub(crate) mod builtins;
10pub mod clock;
11pub mod context_budget;
12pub mod convert;
13pub mod image_gen;
14pub mod plan_mode;
15pub mod question;
16mod skill;
17pub(crate) mod tiers;
18
19use std::collections::HashMap;
20use std::collections::HashSet;
21use std::path::{Path, PathBuf};
22use std::sync::{Arc, Mutex};
23
24use async_trait::async_trait;
25
26use crate::config::Config;
27use crate::error::Result;
28use crate::modules::ModuleId;
29
30pub use builtins::{
31    ApplyPatchTool, BashTool, EditFileTool, GlobTool, ListDirTool, PersistentShellTool,
32    ReadFileTool, SearchTool, UpdatePlanTool, ViewImageTool, WebFetchTool, WebSearchTool,
33    WriteFileTool, DEFAULT_WEB_SEARCH_URL, WEB_CACHE_DIR_ENV, WEB_SEARCH_URL_ENV,
34};
35// BP-3 (§2 modules 6/8 + the catalog's clock, context-budget and image rows):
36// the new core tools. Each is a plain `Tool` registered by
37// `ToolRegistry::from_config` under its own preset gate, so `supercode
38// harness parity`'s `tool` evidence resolves against the real registry.
39pub use clock::{CurrentTimeTool, SleepTool, CURRENT_TIME, MAX_SLEEP_SECS, SLEEP};
40pub use context_budget::{
41    ContextBudget, GetContextRemainingTool, NewContextRequest, NewContextTool,
42    GET_CONTEXT_REMAINING, NEW_CONTEXT,
43};
44pub use image_gen::{ImageGenTool, IMAGE_GEN};
45pub use plan_mode::{
46    EnterPlanModeTool, ExitPlanModeTool, PlanModeState, ENTER_PLAN_MODE, EXIT_PLAN_MODE,
47};
48pub use question::{
49    AskUserTool, Question, QuestionOption, UserQuestionHandler, ASK_USER, REQUEST_USER_INPUT,
50};
51// P5-1 F4: `crate::agent`'s permissions gate needs to check an
52// `apply_patch` envelope's write surface against `protected_paths` — not
53// part of the crate's public tool-registration API, so `pub(crate)` rather
54// than folded into the `pub use` list above.
55pub(crate) use builtins::patch_target_paths;
56// P5-6 (§2 module 4 `tools.background`): `crate::agent::Agent`'s
57// `background_exec` intrinsic reuses `BashTool`'s own sandboxed-spawn
58// builder rather than duplicating it — see that function's doc comment.
59pub(crate) use builtins::build_sandboxed_sh;
60
61pub use skill::{SkillTool, SKILL_TOOL};
62pub use tiers::{minify as minify_tool_schema, SchemaTier};
63// `SandboxPolicy` and `ToolContext` are defined below in this module.
64
65/// P4c (COMPOSABLE-HARNESS-DESIGN.md S1.2/S3.1 `core.tools.read_file
66/// multimodal`, S1.2 `view_image`): the sentinel prefix a tool's plain
67/// `String` result carries when it is actually an image data URL rather
68/// than ordinary text — `Agent::run_loop` detects this prefix (before
69/// `cap_tool_output` ever sees it) and builds a `content_parts` image
70/// block instead of a plain-text tool result. Using a control character
71/// (`\u{1}`, SOH) as part of the marker keeps a false-positive collision
72/// with real tool output astronomically unlikely without requiring a new
73/// `Tool::execute` return type across all ten built-ins (an L-sized
74/// trait-signature change this S-sized catalog item does not call for).
75pub const MULTIMODAL_IMAGE_MARKER: &str = "\u{1}SUPERCODE_IMAGE_DATA_URL\u{1}";
76
77/// P4c (S1.2 `core.tools.read_file.multimodal` / `view_image`): recognized
78/// image file extensions (lowercase, no dot) — the same set CC/pi treat as
79/// "images" for multimodal read (catalog D1 row 2's `✓*`/`✓*` variants).
80pub const IMAGE_EXTENSIONS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "bmp"];
81
82/// Whether `path`'s extension is a recognized image type (case-insensitive).
83pub fn is_image_path(path: &Path) -> bool {
84    path.extension()
85        .and_then(|e| e.to_str())
86        .map(|e| IMAGE_EXTENSIONS.contains(&e.to_ascii_lowercase().as_str()))
87        .unwrap_or(false)
88}
89
90/// The `image/<subtype>` MIME type for a recognized image extension, for
91/// the `data:` URL — falls back to `png` for anything [`is_image_path`]
92/// didn't already gate (defensive; never actually hit through
93/// [`is_image_path`]'s own extension list).
94pub fn image_mime_for(path: &Path) -> &'static str {
95    match path
96        .extension()
97        .and_then(|e| e.to_str())
98        .map(|e| e.to_ascii_lowercase())
99        .as_deref()
100    {
101        Some("jpg") | Some("jpeg") => "image/jpeg",
102        Some("gif") => "image/gif",
103        Some("webp") => "image/webp",
104        Some("bmp") => "image/bmp",
105        _ => "image/png",
106    }
107}
108
109/// P4c (S1.2 `core.tools.edit_file.notebook_aware`): the extension that
110/// gates `EditFileTool`'s Jupyter cell-surgery branch.
111pub const NOTEBOOK_EXTENSION: &str = "ipynb";
112
113/// P4c (S2 module 5 `tools.web`, S2.1 dep "network sandbox rules", S17):
114/// the network-domain policy a caller (SDK embedder) may install on a
115/// [`ToolContext`] so [`crate::tools::WebFetchTool`]/[`crate::tools::WebSearchTool`]
116/// respect it — see [`ToolContext::check_network`]. `None` on the context
117/// (the default) means no policy is configured, matching today's honest
118/// gap (no P5 `capabilities.permissions.sandbox.network` engine exists
119/// yet, C3 — tracked, not hidden).
120#[derive(Debug, Clone, Default)]
121pub struct NetworkPolicy {
122    /// Whether the policy is enforced at all. `false` behaves exactly like
123    /// `None` on the context.
124    pub enabled: bool,
125    /// If non-empty, only these hosts are allowed. BP-10: matched as
126    /// `crate::config::glob_match` patterns through the one rule engine
127    /// (`domain(<entry>)`), so a bare hostname still matches exactly as
128    /// before and `*.example.com` now works too.
129    pub allow_domains: Vec<String>,
130    /// These hosts are always denied, even if also present in
131    /// `allow_domains`. Same pattern treatment as [`Self::allow_domains`].
132    pub deny_domains: Vec<String>,
133}
134
135impl NetworkPolicy {
136    /// BP-10 (catalog row "Allow/ask/deny rule language", the DOMAIN
137    /// subject): this policy's two lists expressed IN the rule algebra —
138    /// a [`crate::permissions::RuleSet`] of `domain(...)` patterns plus
139    /// the baseline [`crate::permissions::Decision`] a host matching
140    /// nothing gets.
141    ///
142    /// An allowlist is not a deny rule: in a deny→ask→allow FIRST-MATCH
143    /// engine "only these hosts" is expressed by the BASELINE being
144    /// `Deny`, with each allowed host in the `allow` tier — a
145    /// `domain(*)` deny rule would (correctly, per tier priority) also
146    /// swallow the allowlist. Empty `allow_domains` keeps the baseline
147    /// `Allow`, which is why a pure denylist behaves exactly as it did
148    /// before this translation existed.
149    pub fn domain_rule_set(&self) -> (crate::permissions::RuleSet, crate::permissions::Decision) {
150        let pattern = |d: &String| format!("domain({d})");
151        let default = if self.allow_domains.is_empty() {
152            crate::permissions::Decision::Allow
153        } else {
154            crate::permissions::Decision::Deny
155        };
156        (
157            crate::permissions::RuleSet {
158                deny: self.deny_domains.iter().map(pattern).collect(),
159                ask: Vec::new(),
160                allow: self.allow_domains.iter().map(pattern).collect(),
161            },
162            default,
163        )
164    }
165}
166
167/// BP-2 (catalog:32 "Edit refuses unless file was read (and unchanged)
168/// this conversation"): how a path stands relative to the model's own most
169/// recent read of it — see [`ToolContext::read_state`].
170#[derive(Debug, Clone, Copy, PartialEq, Eq)]
171pub enum ReadState {
172    /// No read of this path has been recorded this conversation.
173    NeverRead,
174    /// Read, but the file's bytes have changed since (or it is no longer
175    /// readable): the model's view is stale.
176    Stale,
177    /// Read, and the file is byte-identical to what the model saw.
178    Fresh,
179}
180
181/// BP-2: the content stamp behind [`ReadState`] — blake3 (already a
182/// dependency) truncated to 64 bits, which is a staleness detector, not a
183/// security boundary: an adversary who can rewrite the file can rewrite the
184/// edit too, so collision resistance beyond "different content looks
185/// different" buys nothing here.
186fn content_hash(bytes: &[u8]) -> u64 {
187    let digest = blake3::hash(bytes);
188    let b = digest.as_bytes();
189    u64::from_le_bytes([b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7]])
190}
191
192/// Filesystem confinement applied to write-capable tools — the analog of
193/// Codex's `read-only` / `workspace-write` / `danger-full-access` sandbox modes.
194///
195/// Enforced at the tool layer for file operations (`write_file`, `edit_file`,
196/// `apply_patch`). Note: this confines the *file tools*; it does not OS-sandbox
197/// arbitrary subprocesses (`bash`/`shell`) — true process isolation needs
198/// platform primitives (seatbelt/landlock) and is a separate concern. Use
199/// [`shell_sandbox_unenforceable`] as the runtime check for whether that gap
200/// applies to the current platform and enabled tools.
201#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
202pub enum SandboxPolicy {
203    /// No file writes are permitted by the file tools.
204    ReadOnly,
205    /// Writes are permitted only inside the working directory.
206    WorkspaceWrite,
207    /// No confinement (default — preserves prior behavior).
208    #[default]
209    DangerFullAccess,
210}
211
212/// P5-9 (design §2 module 20 `checkpoint`, §2.1 D-5 "write-path
213/// interception seam shared with `formatters`"): the ONE well-defined
214/// interception point around every file-mutating built-in tool
215/// (`write_file`/`edit_file`/`apply_patch`) — installed on
216/// [`ToolContext::write_observer`], `None` by default. Both hooks fire
217/// AFTER [`ToolContext::check_write`] has already approved the call (so an
218/// observer never sees a write the sandbox itself refused) and BEFORE/AFTER
219/// the actual mutation:
220/// - [`Self::before_write`] — pre-image capture. `crate::checkpoint`'s
221///   [`crate::checkpoint::CheckpointObserver`] is the only implementation
222///   today: it snapshots `path`'s current on-disk content (or records "did
223///   not exist") so a later `checkpoint restore` can undo the write.
224/// - [`Self::after_write`] — post-write. A true no-op in every
225///   implementation shipped so far; reserved for `formatters` (P5-11,
226///   design line 510 "shared seam with checkpoint") to run format-on-write
227///   from, without needing a SECOND interception point wired through the
228///   same three tools.
229///
230/// `None` (the default — `[capabilities.checkpoint]` off and no formatters
231/// module yet) means neither hook is ever consulted: every write-tool
232/// call-site's observer check is `if let Some(obs) = &ctx.write_observer`,
233/// a branch that's simply never taken, so behavior is byte-identical to
234/// before this seam existed.
235///
236/// P5-11 (§2 modules 28/29 `lsp`/`formatters`, C10): `async_trait` (rather
237/// than the plain sync methods P5-9 originally shipped) because BOTH new
238/// observers need real async I/O in `after_write` — `formatters` spawns and
239/// awaits a subprocess, `lsp` writes/reads framed JSON-RPC over a child's
240/// stdio — and neither can block the tokio runtime thread the way a
241/// synchronous call from inside an already-`async fn execute()` would.
242/// `CheckpointObserver`'s own hooks stay synchronous *internally* (plain
243/// blocking `std::fs` calls); wrapping them in `async fn` changes nothing
244/// observable for it, since that blocking work already ran on the calling
245/// task before this signature changed. `after_write` now RETURNS
246/// `Option<String>` — an annotation to append to the calling tool's result
247/// string (formatter diff-back content, or LSP diagnostics) — `None` when
248/// the observer has nothing to report, which is the only value
249/// `CheckpointObserver::after_write` (still a no-op) ever returns, keeping
250/// today's tool-result text byte-identical whenever checkpoint is the only
251/// observer installed.
252#[async_trait]
253pub trait WriteObserver: Send + Sync + std::fmt::Debug {
254    /// `path` (already resolved + sandbox-checked) is about to be
255    /// created/overwritten/deleted. Implementations must be fast and must
256    /// never propagate a failure as a tool error — a capture failure should
257    /// degrade the OBSERVER (e.g. disable itself with a one-time warning),
258    /// never block or fail the user's actual edit.
259    async fn before_write(&self, path: &Path);
260    /// `path` was just written/deleted successfully. Not called when the
261    /// tool call itself failed (e.g. the write errored before completing).
262    /// Returns an optional annotation for the calling tool's result text —
263    /// see the trait doc comment above.
264    async fn after_write(&self, path: &Path) -> Option<String>;
265}
266
267/// P5-11 (§2 modules 28/29, D-5 "shared write-path interception seam"): an
268/// ORDERED chain of [`WriteObserver`]s installed as a single
269/// `ToolContext::write_observer`, so the ONE seam P5-9 built keeps
270/// supporting exactly one call site per tool while now composing multiple
271/// concerns. Order is caller-determined (`crate::agent::build_tool_context`
272/// builds it `checkpoint → formatters → lsp`, design's own required
273/// ordering: checkpoint must capture the PRE-image before anything mutates
274/// the file; formatters must run before lsp so diagnostics reflect the
275/// FINAL, formatted file, not the model's pre-format draft).
276/// `before_write` runs every observer in order; `after_write` runs every
277/// observer in order too and joins any non-empty annotations with a blank
278/// line, so a formatter's diff-back and an LSP diagnostics block can both
279/// appear in one tool result without one silently discarding the other.
280#[derive(Debug)]
281pub struct WriteObserverChain(Vec<Arc<dyn WriteObserver>>);
282
283impl WriteObserverChain {
284    /// Build a chain that runs `observers` in order for both hooks.
285    pub fn new(observers: Vec<Arc<dyn WriteObserver>>) -> Self {
286        WriteObserverChain(observers)
287    }
288}
289
290#[async_trait]
291impl WriteObserver for WriteObserverChain {
292    async fn before_write(&self, path: &Path) {
293        for obs in &self.0 {
294            obs.before_write(path).await;
295        }
296    }
297    async fn after_write(&self, path: &Path) -> Option<String> {
298        let mut notes: Vec<String> = Vec::new();
299        for obs in &self.0 {
300            if let Some(note) = obs.after_write(path).await {
301                if !note.is_empty() {
302                    notes.push(note);
303                }
304            }
305        }
306        if notes.is_empty() {
307            None
308        } else {
309            Some(notes.join("\n\n"))
310        }
311    }
312}
313
314/// Ambient context passed to every tool invocation.
315#[derive(Debug, Clone)]
316pub struct ToolContext {
317    /// The working directory tools resolve relative paths against.
318    pub cwd: PathBuf,
319    /// BP-10 (catalog row "Additional working directories", cc/cx
320    /// `--add-dir`): extra roots granted BEYOND [`Self::cwd`], from
321    /// `core.additional_dirs`/`--add-dir`. These are real grants, not
322    /// discovery hints: [`Self::check_write`] treats a path under one of
323    /// them as inside the workspace, the OS backstop adds each to the
324    /// subprocess's writable set (`crate::sandbox::apply_linux_confinement`
325    /// on Linux, the seatbelt profile on macOS), and the permissions
326    /// engine's path rules are evaluated relative to each root as well as
327    /// to `cwd` (so a `write(.git/**)` floor still covers an extra root's
328    /// own `.git`). Empty (the default) is byte-identical to confining
329    /// everything to `cwd` alone.
330    pub extra_roots: Vec<PathBuf>,
331    /// Filesystem confinement for write-capable tools.
332    pub sandbox: SandboxPolicy,
333    /// P4c (S1.2 `core.tools.read_file.multimodal`): whether `read_file`
334    /// (and `view_image`, unconditionally) returns a recognized image file
335    /// as a model-visible image content block. `false` (the default) is
336    /// byte-identical to today's UTF-8-lossy-decode behavior.
337    pub multimodal_read: bool,
338    /// BP-2 (S1.2 `core.tools.read_file.line_numbers`, catalog:26): whether
339    /// `read_file` prefixes every returned line with its 1-based file line
340    /// number and a tab (`cat -n`), numbered from the requested `offset`.
341    /// `false` (the default) returns the raw slice, as today.
342    pub read_line_numbers: bool,
343    /// P4c (S1.2 `core.tools.edit_file.require_read_before_edit`, UNIQUE CC
344    /// row): whether `edit_file` refuses a path not yet read this
345    /// conversation. `false` (the default) is byte-identical to today's
346    /// behavior — [`Self::read_paths`] is simply never consulted.
347    pub require_read_before_edit: bool,
348    /// P4c: canonicalized paths `read_file` has successfully read so far
349    /// this conversation — shared (via `Arc<Mutex<_>>`) across every clone
350    /// of this context, since `Agent` constructs one `ToolContext` at
351    /// startup and reuses it for every tool call. Consulted by `EditFileTool`
352    /// only when [`Self::require_read_before_edit`] is `true`.
353    ///
354    /// BP-2 (catalog:32 "Edit refuses unless file was read **and
355    /// unchanged** this conversation"): the value is the content hash AT
356    /// READ TIME, so a file modified behind the model's back after its read
357    /// is detected as STALE instead of editing cleanly against a view that
358    /// no longer exists — see [`Self::read_state`].
359    pub read_paths: Arc<Mutex<HashMap<PathBuf, u64>>>,
360    /// P4c (S1.2 `core.tools.edit_file.notebook_aware`, UNIQUE CC row
361    /// "NotebookEdit"): whether `edit_file` accepts Jupyter cell
362    /// replace/insert/delete operations against a `.ipynb` target. `false`
363    /// (the default) is byte-identical to today's exact-string-replace-only
364    /// behavior.
365    pub notebook_aware: bool,
366    /// P4c (S1.2 `core.shell_env_snapshot`): the user's captured
367    /// interactive-shell environment, if [`crate::Config::shell_env_snapshot`]
368    /// is on — `BashTool`/`PersistentShellTool` merge this into the spawned
369    /// process's environment. `None` (the default) is byte-identical to
370    /// today's behavior: no extra environment is injected.
371    pub shell_env: Option<Arc<HashMap<String, String>>>,
372    /// P4c (S1.4 `core.nested_instructions`, deferred from P4b): whether a
373    /// file-touching tool injects an as-yet-unseen subdirectory's own
374    /// `CLAUDE.md`/`AGENTS.md` into its result the first time a path under
375    /// it is touched. `false` (the default) is byte-identical to today's
376    /// behavior.
377    pub nested_instructions: bool,
378    /// P4c: subdirectories (relative to [`Self::cwd`]) whose nested
379    /// instructions have already been injected this conversation — shared
380    /// across clones, same rationale as [`Self::read_paths`]. Consulted only
381    /// when [`Self::nested_instructions`] is `true`.
382    pub injected_instruction_dirs: Arc<Mutex<HashSet<PathBuf>>>,
383    /// BP-5 (catalog D2 "Path-scoped rules", cc§2 `.claude/rules` `paths:`):
384    /// the rule files this config loaded whose `paths:` selector holds them
385    /// back until a matching file is touched. Empty (and inert) unless
386    /// `[core.path_rules]` is on.
387    pub path_rules: Arc<Vec<crate::path_rules::RuleFile>>,
388    /// BP-5: which of [`Self::path_rules`] have already been injected this
389    /// conversation — one injection per rule, the same de-duplication
390    /// [`Self::injected_instruction_dirs`] gives nested instructions.
391    pub injected_rule_files: Arc<Mutex<HashSet<PathBuf>>>,
392    /// P4c (S2 module 5 `tools.web`, S17): the network-domain policy
393    /// `web_fetch`/`web_search` must respect, if one is configured. `None`
394    /// (the default) means no policy is enforced — see [`NetworkPolicy`]'s
395    /// doc comment for the honest-gap rationale.
396    pub network_policy: Option<NetworkPolicy>,
397    /// BP-10 (catalog row "Allow/ask/deny rule language"): the
398    /// CONFIG-DECLARED rule set (`capabilities.permissions.rules.*`, the
399    /// same arrays `crate::agent::Agent`'s dispatch gate evaluates), so a
400    /// `domain(...)` rule written there is enforced by the ONE engine at
401    /// the network surface too — see [`Self::check_network`]. `None` (the
402    /// default, and whenever `capabilities.permissions` is off) leaves the
403    /// network check reading `network_policy`'s own two lists alone,
404    /// byte-identical to before.
405    pub permission_rules: Option<Arc<crate::permissions::RuleSet>>,
406    /// P4e (S3.1 `core.tools.bash.timeout_secs`, S14): the DEFAULT
407    /// execution timeout (seconds) `BashTool::execute` falls back to when a
408    /// model-issued call carries no `timeout_ms` argument of its own -- see
409    /// `crate::config::ToolOverride::timeout_secs`. `None` (the default) is
410    /// byte-identical to today's behavior: `BashTool`'s built-in
411    /// `DEFAULT_BASH_TIMEOUT_MS` (120s) stands.
412    pub bash_timeout_secs: Option<u64>,
413    /// P5-9 (§2 module 20, D-5 shared write-path interception seam) — see
414    /// [`WriteObserver`]'s doc comment. `None` (the default) is a true
415    /// no-op: every write-tool call site's `if let Some(obs) = ...` branch
416    /// is simply never taken.
417    pub write_observer: Option<Arc<dyn WriteObserver>>,
418    /// P5-10 (§2 module 12 `permissions.sandbox`): whether the OS-level
419    /// backstop (Landlock/seatbelt) is engaged for the `bash`/`shell`
420    /// subprocess — see `crate::sandbox::os_sandbox_active`. `None` (the
421    /// default) preserves the pre-P5-10 trigger (confine whenever
422    /// [`Self::sandbox`] isn't [`SandboxPolicy::DangerFullAccess`]).
423    pub sandbox_os_enabled: Option<bool>,
424    /// P5-10 (§2 module 12, `escalation`): what to do when a confining fs
425    /// tier can't actually be enforced on this platform/kernel — see
426    /// `crate::sandbox::SandboxEscalation`. Defaults to `Deny`
427    /// (fail-closed).
428    pub sandbox_escalation: crate::sandbox::SandboxEscalation,
429    /// P5-10 (§2 module 12, `env_policy`): child-process environment
430    /// sanitization for the spawned subprocess — see
431    /// `crate::sandbox::SandboxEnvPolicy`. Defaults to `Inherit`
432    /// (byte-identical to pre-P5-10 behavior).
433    pub sandbox_env_policy: crate::sandbox::SandboxEnvPolicy,
434    /// P5-10 (§2 module 12, `escalation = "ask"` → `permissions.approvals`,
435    /// P5-1): the ambient handler `crate::sandbox::decide_fs` consults for
436    /// an `ask`-tier sandbox-unenforceable decision. `None` (the default —
437    /// no handler installed) is fail-closed, same posture as the P5-1 rule
438    /// engine's own `Ask` tier with no handler.
439    pub sandbox_approval_handler: Option<crate::sandbox::SandboxApprovalHandler>,
440    /// BP-3 (§2 module 6 `tools.question`): the door `ask_user` asks the
441    /// human through — the SAME `elicitation/create` handler the design
442    /// names as "the `tools.question` surface's PROTOCOL side"
443    /// (`crate::mcp::McpElicitationHandler`), installed by
444    /// `crate::agent::Agent::set_user_question_handler`. `None` (the
445    /// default) means no interactive frontend is attached, and the tool
446    /// says so rather than blocking on an answer nobody can give.
447    pub question_handler: Option<question::UserQuestionHandler>,
448    /// BP-3 (§2 module 8 `plan_mode`): the approval door `exit_plan_mode`
449    /// presents the plan on — the same
450    /// `crate::permissions::PermissionsApprovalHandler`
451    /// `Agent::set_permissions_approval_handler` installs (under an
452    /// SDK-owned runtime, that is the frontend request broker). `None` is
453    /// fail-closed: the plan cannot be approved, so plan mode stays on.
454    pub approval_handler: Option<ToolApprovalHandler>,
455    /// BP-3 (§2 module 8): the shared plan-mode state — read by the agent's
456    /// permission gate ([`plan_mode::deny_rules`]), written by
457    /// `enter_plan_mode`/`exit_plan_mode` and the REPL's `/plan`. Inactive
458    /// by default, and an inactive state contributes no rules at all.
459    pub plan_mode: Arc<plan_mode::PlanModeState>,
460    /// BP-3 (catalog row "Context-budget tools"): the shared token
461    /// accounting `get_context_remaining` reads and `new_context` parks its
462    /// request on. The agent publishes onto it; nothing is published until
463    /// a turn has actually run.
464    pub context_budget: Arc<context_budget::ContextBudget>,
465    /// BP-8 (§2 module `todos` `persist`, catalog:156 "Todos/plan persisted
466    /// per session"): the session's `update_plan` checklist. It lives HERE,
467    /// on the context the agent owns and shares with every clone, rather
468    /// than inside `UpdatePlanTool` — a plan the agent cannot read is a
469    /// plan it cannot persist, which is exactly the residue the ledger row
470    /// named. Empty by default, at zero cost.
471    pub plan: Arc<Mutex<Vec<crate::session_journal::PlanEntry>>>,
472}
473
474/// BP-3: an approval handler reachable from inside a `Tool::execute`
475/// (today, `exit_plan_mode`'s plan approval). A newtype purely so
476/// [`ToolContext`] can stay `Debug` — the same shape, and the same reason,
477/// as [`crate::sandbox::SandboxApprovalHandler`].
478#[derive(Clone)]
479pub struct ToolApprovalHandler(pub Arc<dyn crate::permissions::PermissionsApprovalHandler>);
480
481impl std::fmt::Debug for ToolApprovalHandler {
482    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
483        f.write_str("ToolApprovalHandler(..)")
484    }
485}
486
487impl std::ops::Deref for ToolApprovalHandler {
488    type Target = dyn crate::permissions::PermissionsApprovalHandler;
489    fn deref(&self) -> &Self::Target {
490        &*self.0
491    }
492}
493
494impl ToolContext {
495    /// A context rooted at `cwd` with no confinement.
496    pub fn new(cwd: impl Into<PathBuf>) -> Self {
497        ToolContext {
498            cwd: cwd.into(),
499            extra_roots: Vec::new(),
500            sandbox: SandboxPolicy::DangerFullAccess,
501            multimodal_read: false,
502            read_line_numbers: false,
503            require_read_before_edit: false,
504            read_paths: Arc::new(Mutex::new(HashMap::new())),
505            notebook_aware: false,
506            shell_env: None,
507            nested_instructions: false,
508            injected_instruction_dirs: Arc::new(Mutex::new(HashSet::new())),
509            path_rules: Arc::new(Vec::new()),
510            injected_rule_files: Arc::new(Mutex::new(HashSet::new())),
511            network_policy: None,
512            permission_rules: None,
513            bash_timeout_secs: None,
514            write_observer: None,
515            sandbox_os_enabled: None,
516            sandbox_escalation: crate::sandbox::SandboxEscalation::default(),
517            sandbox_env_policy: crate::sandbox::SandboxEnvPolicy::default(),
518            sandbox_approval_handler: None,
519            question_handler: None,
520            approval_handler: None,
521            plan_mode: Arc::new(plan_mode::PlanModeState::new()),
522            context_budget: Arc::new(context_budget::ContextBudget::new()),
523            plan: Arc::new(Mutex::new(Vec::new())),
524        }
525    }
526
527    /// BP-8: the current plan, as `(step, status)` pairs.
528    pub fn plan_snapshot(&self) -> Vec<crate::session_journal::PlanEntry> {
529        self.plan.lock().map(|p| p.clone()).unwrap_or_default()
530    }
531
532    /// BP-8: replace the plan wholesale (`update_plan` replaces; a resume
533    /// restores).
534    pub fn set_plan(&self, steps: Vec<crate::session_journal::PlanEntry>) {
535        if let Ok(mut p) = self.plan.lock() {
536            *p = steps;
537        }
538    }
539
540    /// BP-10: whether the permissions ENGINE adjudicated this call —
541    /// i.e. `capabilities.permissions.enabled` was on when this context
542    /// was built, so `crate::agent::Agent`'s dispatch gate ran the rule
543    /// algebra (and any `Ask` tier) before the tool was invoked.
544    ///
545    /// The one consumer is the sandbox-escalation path
546    /// (`builtins::escalation_requested`): a model-issued
547    /// `with_escalated_permissions` is only honored where a gate exists to
548    /// have approved it.
549    pub fn permissions_engine_active(&self) -> bool {
550        self.permission_rules.is_some()
551    }
552
553    /// P5-10: whether the OS-level backstop is active for this context —
554    /// thin wrapper over `crate::sandbox::os_sandbox_active`.
555    pub fn os_sandbox_active(&self) -> bool {
556        crate::sandbox::os_sandbox_active(self.sandbox, self.sandbox_os_enabled)
557    }
558
559    /// P4c: record `path` (canonicalized if possible, else the resolved
560    /// path as-is) as having been read this conversation — called by
561    /// `ReadFileTool` on every successful read, unconditionally (cheap; the
562    /// map is only ever CONSULTED when [`Self::require_read_before_edit`] is
563    /// on, but recording it unconditionally means turning the knob on
564    /// mid-conversation sees every read that already happened).
565    ///
566    /// BP-2: reads the file's CURRENT bytes to stamp the content hash. Use
567    /// [`Self::mark_read_bytes`] from a caller that already holds them.
568    pub fn mark_read(&self, path: &Path) {
569        let bytes = std::fs::read(path).unwrap_or_default();
570        self.mark_read_bytes(path, &bytes);
571    }
572
573    /// BP-2: the same record, stamped from bytes the caller just read.
574    pub fn mark_read_bytes(&self, path: &Path, bytes: &[u8]) {
575        let key = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
576        if let Ok(mut map) = self.read_paths.lock() {
577            map.insert(key, content_hash(bytes));
578        }
579    }
580
581    /// P4c: whether `path` was previously recorded via [`Self::mark_read`],
582    /// ignoring whether it has changed since.
583    pub fn was_read(&self, path: &Path) -> bool {
584        !matches!(self.read_state(path), ReadState::NeverRead)
585    }
586
587    /// BP-2 (catalog:32): what `edit_file` needs to know before accepting
588    /// an edit — never read, read but changed on disk since, or read and
589    /// still identical.
590    pub fn read_state(&self, path: &Path) -> ReadState {
591        let key = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
592        let Some(recorded) = self
593            .read_paths
594            .lock()
595            .ok()
596            .and_then(|map| map.get(&key).copied())
597        else {
598            return ReadState::NeverRead;
599        };
600        match std::fs::read(&key) {
601            Ok(bytes) if content_hash(&bytes) == recorded => ReadState::Fresh,
602            // Unreadable now (deleted/permissions) counts as changed: the
603            // model's view is provably not the file's current state.
604            _ => ReadState::Stale,
605        }
606    }
607
608    /// P4c (S2.1 S17): does `url` pass `Self::network_policy`, if one is
609    /// configured? `Ok(())` when no policy is set (the honest-gap default)
610    /// or the policy is present-but-disabled; `Err` names the reason
611    /// otherwise. A URL with no parseable host is denied whenever a policy
612    /// is actively enforced (fail closed — an unparseable host can't be
613    /// matched against an allowlist).
614    pub fn check_network(&self, url: &str) -> Result<()> {
615        check_network_policy(
616            self.network_policy.as_ref(),
617            self.permission_rules.as_deref(),
618            self.approval_handler.as_deref(),
619            url,
620        )
621    }
622
623    /// Resolve a possibly-relative path against the working directory.
624    pub fn resolve(&self, path: &str) -> PathBuf {
625        let p = PathBuf::from(path);
626        if p.is_absolute() {
627            p
628        } else {
629            self.cwd.join(p)
630        }
631    }
632
633    /// BP-10: every root a `WorkspaceWrite` call may write under — `cwd`
634    /// first, then each [`Self::extra_roots`] entry. The ONE list
635    /// [`Self::check_write`], the seatbelt profile, and the Landlock
636    /// writable set all read, so a grant can never be honored by one and
637    /// missed by another.
638    pub fn write_roots(&self) -> Vec<PathBuf> {
639        let mut roots = Vec::with_capacity(1 + self.extra_roots.len());
640        roots.push(self.cwd.clone());
641        roots.extend(self.extra_roots.iter().cloned());
642        roots
643    }
644
645    /// Enforce the sandbox policy for a write to `path`. `Err` if denied.
646    pub fn check_write(&self, path: &Path) -> Result<()> {
647        match self.sandbox {
648            SandboxPolicy::DangerFullAccess => Ok(()),
649            SandboxPolicy::ReadOnly => Err(crate::error::Error::tool(
650                "sandbox",
651                "write denied: sandbox is read-only",
652            )),
653            SandboxPolicy::WorkspaceWrite => {
654                // BP-10: an `--add-dir` root is a real grant — a write
655                // under one is inside the workspace, exactly as a write
656                // under `cwd` is. Empty `extra_roots` (the default) makes
657                // this the same single `cwd` check as before.
658                if self.write_roots().iter().any(|r| path_within(r, path)) {
659                    Ok(())
660                } else {
661                    Err(crate::error::Error::tool(
662                        "sandbox",
663                        format!(
664                            "write denied: {} is outside the workspace {} (and its {} \
665                             additional root(s))",
666                            path.display(),
667                            self.cwd.display(),
668                            self.extra_roots.len()
669                        ),
670                    ))
671                }
672            }
673        }
674    }
675}
676
677/// P5-2 (§2 module 15, security note "remote MCP over http/sse: respect the
678/// NetworkPolicy from P5-1 if one is active"): the same policy-and-url check
679/// [`ToolContext::check_network`] performs, factored out to a free function
680/// so `crate::mcp::McpClient::connect_http`/`connect_sse` can enforce the
681/// identical allow/deny/SSRF floor a `web_fetch` call would get — one
682/// enforcement point, not a second parallel one that could silently drift
683/// from it.
684pub(crate) fn check_network_policy(
685    policy: Option<&NetworkPolicy>,
686    rules: Option<&crate::permissions::RuleSet>,
687    approval: Option<&dyn crate::permissions::PermissionsApprovalHandler>,
688    url: &str,
689) -> Result<()> {
690    let ctx_like = domain_tier_of(policy, rules);
691    check_host_against_tier(&ctx_like, approval, url_host(url).as_deref())
692}
693
694/// BP-10: [`ToolContext::domain_tier`]'s body, as a free function, so the
695/// non-`ToolContext` caller (`crate::mcp::McpClient::connect_http`) folds
696/// the SAME two sources in the SAME order rather than a second, drifting
697/// copy. See that method's doc comment for the two sources.
698pub(crate) fn domain_tier_of(
699    policy: Option<&NetworkPolicy>,
700    rules: Option<&crate::permissions::RuleSet>,
701) -> (crate::permissions::RuleSet, crate::permissions::Decision) {
702    let mut out = crate::permissions::RuleSet::default();
703    let mut default = crate::permissions::Decision::Allow;
704    if let Some(policy) = policy {
705        if policy.enabled {
706            let (list_rules, list_default) = policy.domain_rule_set();
707            out.deny.extend(list_rules.deny);
708            out.allow.extend(list_rules.allow);
709            default = list_default;
710        }
711    }
712    if let Some(config_rules) = rules {
713        out.deny.extend(config_rules.deny.iter().cloned());
714        out.ask.extend(config_rules.ask.iter().cloned());
715        out.allow.extend(config_rules.allow.iter().cloned());
716    }
717    (out, default)
718}
719
720/// P4c-review (MEDIUM/LOW follow-up, dep 8's neighboring `tools.web` SSRF
721/// gap): the SAME allow/deny decision [`ToolContext::check_network`] applies
722/// to the INITIAL url, factored out so [`network_checked_redirect_policy`]
723/// can apply it to every REDIRECT hop too. Without this, `check_network`
724/// validated only the url the caller passed in — once a real network policy
725/// is wired up (P5), a denied host reachable only via an allowed host's HTTP
726/// redirect (reqwest follows up to 10 by default) bypassed the check
727/// entirely. `host: None` (unparseable/absent) fails closed, exactly like
728/// `check_network`'s own prior inline behavior.
729fn check_host_against_tier(
730    tier: &(crate::permissions::RuleSet, crate::permissions::Decision),
731    approval: Option<&dyn crate::permissions::PermissionsApprovalHandler>,
732    host: Option<&str>,
733) -> Result<()> {
734    use crate::permissions::{Decision, RuleSet};
735    let (rules, default): (&RuleSet, Decision) = (&tier.0, tier.1);
736    // Nothing to enforce: no domain rule from either source. Byte-identical
737    // to "no policy configured" — this is the common path.
738    if rules.is_empty() && default == Decision::Allow {
739        return Ok(());
740    }
741    let Some(host_str) = host else {
742        return Err(crate::error::Error::tool(
743            "network",
744            "cannot determine host from url; denied under an active network policy",
745        ));
746    };
747    let host = host_str.to_ascii_lowercase();
748    match crate::permissions::evaluate_domain(rules, Some(&host), default) {
749        Decision::Allow => Ok(()),
750        Decision::Ask => {
751            // BP-10: the `domain(...)` ASK tier resolves on the SAME door
752            // every other `Ask` in this engine uses. No door installed
753            // denies, the fail-closed posture
754            // `PermissionsApprovalHandler`'s own doc comment documents.
755            let raw_args = serde_json::json!({ "host": host });
756            let req = crate::permissions::ApprovalRequest {
757                tool: "domain",
758                subject: Some(&host),
759                raw_args: &raw_args,
760            };
761            match approval.map(|h| h.ask(&req)) {
762                Some(crate::permissions::ApprovalOutcome::Allow)
763                | Some(crate::permissions::ApprovalOutcome::AllowForSession) => Ok(()),
764                _ => Err(crate::error::Error::tool(
765                    "network",
766                    format!("host `{host}` requires approval and none was given"),
767                )),
768            }
769        }
770        Decision::Deny => {
771            if crate::permissions::domain_denied_explicitly(rules, &host) {
772                Err(crate::error::Error::tool(
773                    "network",
774                    format!("host `{host}` is denied by the active network policy"),
775                ))
776            } else {
777                Err(crate::error::Error::tool(
778                    "network",
779                    format!("host `{host}` is not on the network policy's allowlist"),
780                ))
781            }
782        }
783    }
784}
785
786/// P4c-review (MEDIUM/LOW follow-up): a `reqwest::redirect::Policy` for
787/// `WebFetchTool`/`WebSearchTool`'s client that re-runs
788/// [`check_host_against_policy`] (the exact same check
789/// [`ToolContext::check_network`] applies to the initial url) against every
790/// redirect hop's target host, refusing to follow one that a network policy
791/// denies. `policy: None` (no policy configured) or a present-but-disabled
792/// one behaves like reqwest's own default policy — follow, capped at the
793/// same 10-hop limit `redirect::Policy::default()` uses (the crate's `custom`
794/// variant does NOT enforce a redirect cap on its own — see its doc comment
795/// — so this reimplements that cap by hand).
796pub(crate) fn network_checked_redirect_policy(
797    policy: Option<NetworkPolicy>,
798    rules: Option<Arc<crate::permissions::RuleSet>>,
799) -> reqwest::redirect::Policy {
800    const MAX_REDIRECTS: usize = 10; // matches reqwest::redirect::Policy::default()
801    let tier = domain_tier_of(policy.as_ref(), rules.as_deref());
802    reqwest::redirect::Policy::custom(move |attempt| {
803        if attempt.previous().len() >= MAX_REDIRECTS {
804            return attempt.error("too many redirects");
805        }
806        // BP-10: a redirect hop gets the SAME domain tier as the initial
807        // url, but never an interactive prompt — an `Ask` mid-flight has
808        // no user-visible action to describe, so it fails closed here
809        // (`approval: None`) rather than blocking a redirect chain on a
810        // question about a host the user never typed.
811        if let Err(e) = check_host_against_tier(&tier, None, attempt.url().host_str()) {
812            return attempt.error(e.to_string());
813        }
814        attempt.follow()
815    })
816}
817
818/// P4c (S2.1 S17): extract the host from an `http(s)://` URL — the smallest
819/// parser that satisfies [`ToolContext::check_network`]'s needs without a
820/// new `url`-crate dependency (matches this crate's existing `glob_match`
821/// precedent of hand-rolling a small parser rather than reaching for a
822/// dependency for an S-sized need). Returns `None` for anything that isn't
823/// `http://`/`https://` or has an empty host component.
824fn url_host(url: &str) -> Option<String> {
825    let rest = url
826        .strip_prefix("https://")
827        .or_else(|| url.strip_prefix("http://"))?;
828    let end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
829    let authority = &rest[..end];
830    // Strip a `user:pass@` prefix and a `:port` suffix, keeping the host.
831    let host_and_port = authority.rsplit('@').next().unwrap_or(authority);
832    let host = host_and_port.split(':').next().unwrap_or(host_and_port);
833    if host.is_empty() {
834        None
835    } else {
836        Some(host.to_ascii_lowercase())
837    }
838}
839
840/// True when the requested sandbox policy cannot be enforced for shell
841/// subprocesses: a confining policy, a platform without an OS sandbox
842/// primitive wired up (only macOS/seatbelt is, via `sandbox-exec`), and at
843/// least one shell tool (`"bash"` or `"shell"`) enabled.
844///
845/// This is a pure function so it's mechanically testable on any host OS:
846/// callers pass the platform (typically `std::env::consts::OS`) and the set
847/// of enabled tool names rather than relying on `cfg!`/`target_os`. It does
848/// not itself sandbox anything — it only tells embedders/CLIs whether the
849/// gap documented on [`SandboxPolicy`] applies right now, so they can warn.
850/// P5-10 (§2 module 12): `landlock_available` is the caller's REAL Linux
851/// Landlock-availability probe (`crate::sandbox::landlock_available()`,
852/// typically) — a PARAMETER, not an internal `cfg!`/probe call, same "pure,
853/// mechanically testable" contract this function already had. Before
854/// P5-10, `platform == "linux"` always meant "unenforceable" (no OS
855/// primitive existed yet); now it means "unenforceable UNLESS Landlock is
856/// actually available on this kernel" — a confining tier on a
857/// Landlock-capable Linux box is REAL enforcement, not a gap, so this must
858/// say `false` for it (never claim a gap that no longer exists).
859/// `platform == "macos"` is unconditionally `false` regardless of this
860/// parameter (seatbelt, a separate primitive, always exists there); every
861/// other platform (including `platform == "linux"` with
862/// `landlock_available == false`) is unaffected by this parameter and
863/// keeps the pre-P5-10 "no primitive" answer.
864pub fn shell_sandbox_unenforceable(
865    policy: SandboxPolicy,
866    platform: &str,
867    tools_enabled: &[&str],
868    landlock_available: bool,
869) -> bool {
870    policy != SandboxPolicy::DangerFullAccess
871        && platform != "macos"
872        && !(platform == "linux" && landlock_available)
873        && tools_enabled.iter().any(|t| *t == "bash" || *t == "shell")
874}
875
876/// Whether `path` is inside `root`. SECURITY (safe-path consolidation,
877/// LOWER-URGENCY fix folded into the permissions-gate CRITICAL fix): this
878/// used to compare only LEXICALLY-normalized paths (`..` traversal caught,
879/// but a pre-existing in-workspace symlink pointing outside `root` was NOT —
880/// `link -> /etc` plus a write to `link/passwd` lexically normalizes to
881/// `<root>/link/passwd`, which "starts with" `root` even though it actually
882/// resolves outside it). Now delegates to `crate::safe_path::contained`,
883/// which ALSO resolves symlinks along the longest existing ancestor (the
884/// same proven dual lexical+resolved check `crate::checkpoint`'s P5-9 fix
885/// uses), so a symlink escape is caught here too. A non-existent target
886/// (e.g. a file about to be created) is still handled correctly.
887fn path_within(root: &Path, path: &Path) -> bool {
888    crate::safe_path::contained(root, path)
889}
890
891/// `pub(crate)`: also the lexical-`..`-collapse step
892/// [`crate::checkpoint`]'s containment check builds on (P5-9) — one
893/// normalizer, not a second hand-rolled one.
894pub(crate) fn normalize(path: &Path) -> Option<PathBuf> {
895    use std::path::Component;
896    // Make absolute against CWD if needed (paths are already joined to cwd by
897    // resolve(), but be defensive).
898    let abs = if path.is_absolute() {
899        path.to_path_buf()
900    } else {
901        std::env::current_dir().ok()?.join(path)
902    };
903    let mut out = PathBuf::new();
904    for c in abs.components() {
905        match c {
906            Component::ParentDir => {
907                out.pop();
908            }
909            Component::CurDir => {}
910            other => out.push(other.as_os_str()),
911        }
912    }
913    Some(out)
914}
915
916/// A callable capability.
917#[async_trait]
918pub trait Tool: Send + Sync {
919    /// Stable, unique tool name (what the model calls).
920    fn name(&self) -> &str;
921
922    /// The built-in description. May be overridden via [`crate::Config`].
923    fn description(&self) -> &str;
924
925    /// JSON Schema describing the tool's input object.
926    fn parameters(&self) -> serde_json::Value;
927
928    /// Whether a successful textual result is also a complete JSON value
929    /// that protocol adapters should expose as structured output. Text
930    /// remains the model-facing representation, preserving compatibility.
931    fn structured_output(&self) -> bool {
932        false
933    }
934
935    /// Run the tool. Returns text to feed back to the model.
936    async fn execute(&self, args: serde_json::Value, ctx: &ToolContext) -> Result<String>;
937}
938
939/// An ordered set of tools offered to the model.
940#[derive(Default)]
941pub struct ToolRegistry {
942    tools: Vec<Box<dyn Tool>>,
943}
944
945impl ToolRegistry {
946    /// An empty registry.
947    pub fn new() -> Self {
948        ToolRegistry::default()
949    }
950
951    /// A registry pre-populated with all built-in tools.
952    pub fn with_builtins() -> Self {
953        let mut r = ToolRegistry::new();
954        r.register(ReadFileTool);
955        r.register(WriteFileTool);
956        r.register(EditFileTool);
957        r.register(ListDirTool);
958        r.register(GlobTool);
959        r.register(SearchTool);
960        r.register(ApplyPatchTool);
961        r.register(BashTool::default());
962        r.register(PersistentShellTool::default());
963        r.register(UpdatePlanTool::default());
964        r
965    }
966
967    /// P3 (COMPOSABLE-HARNESS-DESIGN.md §5.2 phase P3): build a registry
968    /// from a resolved [`Config`]'s module-activation set, the intended
969    /// replacement for unconditional [`Self::with_builtins`] call sites.
970    ///
971    /// **BP-1: this is now the default path.** Every `Config` materialized
972    /// by [`crate::configfile::resolve`] carries
973    /// [`Config::module_registry`] `= true`, so a preset's
974    /// `[capabilities.*]` and `[core.tools] enabled` actually shape the
975    /// registry. `[experimental] module_registry = false` is the explicit
976    /// OPT-OUT that pins a resolved config back to the unfiltered stack,
977    /// and a hand-built [`Config::default`] (which never went through the
978    /// resolver) still has the flag `false`. In that `false` state this
979    /// returns EXACTLY [`Self::with_builtins`] — same 10 tools, same
980    /// order, zero behavior change. When it is on,
981    /// [`Config::module_activation`]/[`Config::core_tools_enabled`]
982    /// shape which tool objects get registered AT ALL: a disabled module
983    /// contributes no tool (never registered, so never advertised and never
984    /// mentioned anywhere) — e.g. `todos` off means `update_plan` is not in
985    /// this registry; `tools_search` off (or its `list_dir`/`glob`/
986    /// `content_search` sub-flags off) means the corresponding tool is
987    /// absent too.
988    pub fn from_config(config: &Config) -> Self {
989        // The opt-out (or a Config that never met the resolver at all).
990        if !config.module_registry {
991            return Self::with_builtins();
992        }
993        let mut r = ToolRegistry::new();
994        let act = &config.module_activation;
995
996        // BP-13 (catalog D9 "Per-model capability bits drive tools", cx§9
997        // "Model catalog"): the model's own capability bits, resolved out of
998        // the SAME `Config::model_routing` table every other routing
999        // decision reads. They shape THIS selection rather than a parallel
1000        // registry — the only thing they can do is decide which write
1001        // surface a model is offered.
1002        //
1003        // Armed only under `[capabilities.tools_apply_patch] per_model =
1004        // true` (the flag cx-parity already sets, and whose only previous
1005        // reader was the resolver's C1 warning suppression) AND only when a
1006        // rule actually matches this model. With no matching rule the
1007        // selection below is byte-identical to pre-BP-13: `core.tools`
1008        // decides edit/write, the module decides apply_patch.
1009        let bits = if act.is_active(ModuleId::ToolsApplyPatch) && act.tools_apply_patch_per_model {
1010            config.model_routing.rules_for(&config.model)
1011        } else {
1012            crate::model_catalog::ModelRules::default()
1013        };
1014        let core_has = |name: &str| match (name, bits.apply_patch) {
1015            // A model the catalog marks as NOT taking the freeform
1016            // apply_patch envelope gets the edit/write pair instead, even
1017            // where `core.tools` lists neither — that swap IS the row.
1018            ("edit_file" | "write_file", Some(false)) => true,
1019            // …and the converse: a model that DOES take apply_patch is not
1020            // also handed the pair, so the two write formats are never
1021            // co-advertised to it (§2.2 C1's whole point).
1022            ("edit_file" | "write_file", Some(true)) => false,
1023            _ => config.core_tools_enabled.iter().any(|t| t == name),
1024        };
1025
1026        // Same relative order as `with_builtins()` for everything both paths
1027        // can register, so a partial activation set stays predictable.
1028        if core_has("read_file") {
1029            r.register(ReadFileTool);
1030        }
1031        if core_has("write_file") {
1032            r.register(WriteFileTool);
1033        }
1034        if core_has("edit_file") {
1035            r.register(EditFileTool);
1036        }
1037        // P4c (S1.2 `view_image`, S12): a fifth OPTIONAL default-tool name —
1038        // "recognized alongside read_file/bash/edit_file/write_file as a
1039        // fifth optional default-tool name, not a new module" — so it's
1040        // read from the SAME `core_tools_enabled` list as the other four,
1041        // not a `ModuleId`. Absent from the list by default (today's
1042        // `["read_file","bash","edit_file","write_file"]` default), so this
1043        // is a no-op unless a caller explicitly adds `"view_image"`.
1044        if core_has("view_image") {
1045            r.register(ViewImageTool);
1046        }
1047        // BP-13: `search_tool = false` withdraws the dedicated search tool
1048        // for a model the catalog says cannot use it (cx§9
1049        // `supports_search_tool`); unset leaves the module's own sub-flags
1050        // in sole charge, exactly as before.
1051        if act.is_active(ModuleId::ToolsSearch) && bits.search_tool != Some(false) {
1052            if act.tools_search_list_dir {
1053                r.register(ListDirTool);
1054            }
1055            if act.tools_search_glob {
1056                r.register(GlobTool);
1057            }
1058            if act.tools_search_content_search {
1059                r.register(SearchTool);
1060            }
1061        }
1062        // BP-13: with per-model bits armed, a model the catalog says cannot
1063        // take the freeform envelope is not offered it.
1064        if act.is_active(ModuleId::ToolsApplyPatch) && bits.apply_patch != Some(false) {
1065            r.register(ApplyPatchTool);
1066        }
1067        if core_has("bash") {
1068            r.register(BashTool::default());
1069        }
1070        if act.is_active(ModuleId::ToolsPersistentShell) {
1071            r.register(PersistentShellTool::default());
1072        }
1073        if act.is_active(ModuleId::Todos) {
1074            r.register(UpdatePlanTool::default());
1075        }
1076        // P4c (S2 module 5 `tools.web`, S4a "trivially addable"): single
1077        // tool each, gated by the module's own `fetch`/`search` sub-flags
1078        // (S3.1: `[capabilities.tools_web] { enabled = false, fetch = true,
1079        // search = true }`) exactly like `tools_search`'s three sub-flags.
1080        if act.is_active(ModuleId::ToolsWeb) {
1081            if act.tools_web_fetch {
1082                r.register(WebFetchTool);
1083            }
1084            if act.tools_web_search {
1085                r.register(WebSearchTool);
1086            }
1087        }
1088        // BP-3 (§2 module 6 `tools.question`): the module contributes the
1089        // question tool. `cx-parity` additionally names Codex's own
1090        // experimental spelling in `[core.tools] enabled`, so a continued
1091        // Codex session's `request_user_input` calls keep resolving — the
1092        // SAME tool object under a second registered name, never a second
1093        // implementation.
1094        if act.is_active(ModuleId::ToolsQuestion) {
1095            r.register(AskUserTool::new(question::ASK_USER));
1096        }
1097        if core_has(question::REQUEST_USER_INPUT) {
1098            r.register(AskUserTool::new(question::REQUEST_USER_INPUT));
1099        }
1100        // BP-3 (§2 module 8 `plan_mode`): the two tools the module is
1101        // defined by. The restriction they establish is enforced by the
1102        // permissions engine (`plan_mode::deny_rules`, folded into its deny
1103        // tier by `crate::agent`'s gate), which is why the module's §2.1
1104        // dependency edge is `plan_mode → permissions.rules|sandbox`.
1105        if act.is_active(ModuleId::PlanMode) {
1106            r.register(EnterPlanModeTool);
1107            r.register(ExitPlanModeTool);
1108        }
1109        // BP-3 (catalog rows "Clock / sleep tools", "Context-budget tools",
1110        // "Image generation tool"): four more OPTIONAL default-tool names,
1111        // read from the same `[core.tools] enabled` list as `view_image`
1112        // (§1.2's "fifth optional default-tool name, not a new module"
1113        // precedent) — absent from the default four, so a config that does
1114        // not name them gets byte-identical tools to before.
1115        if core_has(clock::CURRENT_TIME) {
1116            r.register(CurrentTimeTool);
1117        }
1118        if core_has(clock::SLEEP) {
1119            r.register(SleepTool);
1120        }
1121        if core_has(context_budget::GET_CONTEXT_REMAINING) {
1122            r.register(GetContextRemainingTool);
1123        }
1124        if core_has(context_budget::NEW_CONTEXT) {
1125            r.register(NewContextTool);
1126        }
1127        if core_has(image_gen::IMAGE_GEN) {
1128            r.register(ImageGenTool::new(
1129                config.base_url.clone(),
1130                config.api_key.clone(),
1131                config.api_key_env.clone(),
1132            ));
1133        }
1134        // BP-6 (catalog D1 "Skill-invocation surface"): the one tool every
1135        // skill is invoked through (CC's `Skill` shape). The tool IS the
1136        // on-demand read pathway D-7 is about, so it does not itself depend
1137        // on `read_file`/`bash` being present (cx-parity has neither for
1138        // files). Discovery runs here, at registry construction, and reads
1139        // only frontmatter: no body is opened until the model calls it.
1140        //
1141        // Registered exactly when the config READS a skill-root table —
1142        // `[core.skills] enabled` plus a `harness` naming whose roots to
1143        // walk, the same precondition `load_for_config` applies. A config
1144        // that enables skills without naming a table discovers nothing, so
1145        // the tool would have nothing to load; leaving it out keeps every
1146        // pre-BP-6 config's tool set byte-identical.
1147        if config.skills_enabled && config.skills_harness.is_some() {
1148            r.register(
1149                SkillTool::new(crate::skills::load_for_config(config))
1150                    // BP-5: the tool door loads bodies under the same
1151                    // permission-engine authorization every other
1152                    // invocation door uses.
1153                    .with_shell(crate::skills::ShellInjection::from_config(config)),
1154            );
1155        }
1156        r
1157    }
1158
1159    /// Add a tool. A later registration with the same name shadows the earlier.
1160    pub fn register(&mut self, tool: impl Tool + 'static) {
1161        self.tools.push(Box::new(tool));
1162    }
1163
1164    /// Look up a tool by name (last registration wins).
1165    pub fn get(&self, name: &str) -> Option<&dyn Tool> {
1166        self.tools
1167            .iter()
1168            .rev()
1169            .find(|t| t.name() == name)
1170            .map(|b| b.as_ref())
1171    }
1172
1173    /// Iterate all tools.
1174    pub fn iter(&self) -> impl Iterator<Item = &dyn Tool> {
1175        self.tools.iter().map(|b| b.as_ref())
1176    }
1177
1178    /// Number of registered tools.
1179    pub fn len(&self) -> usize {
1180        self.tools.len()
1181    }
1182
1183    /// Whether the registry is empty.
1184    pub fn is_empty(&self) -> bool {
1185        self.tools.is_empty()
1186    }
1187}