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}