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}