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