Skip to main content

opi_coding_agent/
policy.rs

1//! Tool safety policy for non-interactive mode (S8.4, S10).
2//!
3//! Also contains tool selection types and resolution for --tools / --no-tools /
4//! --no-builtin-tools CLI flags (task 3.8).
5
6/// Returns `true` if the tool is considered mutating (write, edit, bash).
7pub fn is_mutating_tool(name: &str) -> bool {
8    matches!(name, "write" | "edit" | "bash")
9}
10
11/// Application mode used to resolve default active tools.
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum RunMode {
14    Interactive,
15    NonInteractive,
16}
17
18/// Pi-aligned built-in policy order consumed by the harness after Task 2 wiring.
19pub const BUILTIN_TOOL_NAMES: &[&str] = &[
20    "read", "write", "edit", "bash", "grep", "find", "ls", "glob",
21];
22
23const CODING_DEFAULT_TOOLS: &[&str] = &["read", "write", "edit", "bash"];
24const READ_ONLY_DEFAULT_TOOLS: &[&str] = &["read", "grep", "find", "ls", "glob"];
25
26/// Resolved tool runtime config used to choose active tools.
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub struct ToolRuntimeConfig {
29    pub run_mode: RunMode,
30    pub active_tool_names: Vec<String>,
31}
32
33#[derive(Debug, thiserror::Error, Clone, PartialEq, Eq)]
34pub enum ToolPolicyError {
35    #[error("mutating tool '{tool}' requires --allow-mutating in non-interactive mode")]
36    MutatingToolRequiresOptIn { tool: String },
37}
38
39impl ToolRuntimeConfig {
40    pub fn resolve(
41        run_mode: RunMode,
42        allow_mutating: bool,
43        selection: ToolSelection,
44    ) -> Result<Self, ToolPolicyError> {
45        let active_tool_names = resolve_active_tool_names(run_mode, allow_mutating, &selection)?;
46        Ok(Self {
47            run_mode,
48            active_tool_names,
49        })
50    }
51}
52
53fn resolve_active_tool_names(
54    run_mode: RunMode,
55    allow_mutating: bool,
56    selection: &ToolSelection,
57) -> Result<Vec<String>, ToolPolicyError> {
58    match selection {
59        ToolSelection::Disabled | ToolSelection::NoBuiltin => Ok(Vec::new()),
60        ToolSelection::Allowlist(names) => {
61            if run_mode == RunMode::NonInteractive
62                && !allow_mutating
63                && let Some(tool) = names.iter().find(|name| is_mutating_tool(name))
64            {
65                return Err(ToolPolicyError::MutatingToolRequiresOptIn { tool: tool.clone() });
66            }
67            Ok(filter_tool_names(BUILTIN_TOOL_NAMES, selection))
68        }
69        ToolSelection::Default => {
70            let names = match (run_mode, allow_mutating) {
71                (RunMode::Interactive, _) | (RunMode::NonInteractive, true) => CODING_DEFAULT_TOOLS,
72                (RunMode::NonInteractive, false) => READ_ONLY_DEFAULT_TOOLS,
73            };
74            Ok(names.iter().map(|name| (*name).to_owned()).collect())
75        }
76    }
77}
78
79// ---------------------------------------------------------------------------
80// Tool selection (task 3.8)
81// ---------------------------------------------------------------------------
82
83/// Resolved tool selection state driven by CLI flags.
84#[derive(Debug, Clone, PartialEq, Eq)]
85pub enum ToolSelection {
86    /// Use all default built-in tools.
87    Default,
88    /// Only include tools whose names appear in the allowlist.
89    Allowlist(Vec<String>),
90    /// No tools at all.
91    Disabled,
92    /// No built-in tools (reserved for Phase 4 extension/custom tools).
93    NoBuiltin,
94}
95
96/// CLI tool flags to be resolved into a `ToolSelection`.
97pub struct ToolFlags {
98    /// Tool allowlist from `--tools <comma-separated-list>`.
99    pub tools: Option<Vec<String>>,
100    /// Disable all tools (`--no-tools`).
101    pub no_tools: bool,
102    /// Disable built-in tools (`--no-builtin-tools`).
103    pub no_builtin_tools: bool,
104}
105
106/// Resolve tool flags into a `ToolSelection` with deterministic precedence:
107///
108/// `--no-tools` > `--tools` > `--no-builtin-tools` > default
109pub fn resolve_tool_selection(flags: ToolFlags) -> ToolSelection {
110    if flags.no_tools {
111        ToolSelection::Disabled
112    } else if let Some(tools) = flags.tools {
113        ToolSelection::Allowlist(tools)
114    } else if flags.no_builtin_tools {
115        ToolSelection::NoBuiltin
116    } else {
117        ToolSelection::Default
118    }
119}
120
121/// Filter a list of tool names based on the given selection.
122///
123/// Returns the subset of `all_names` that pass the selection filter,
124/// preserving the original order.
125pub fn filter_tool_names(all_names: &[&str], selection: &ToolSelection) -> Vec<String> {
126    match selection {
127        ToolSelection::Default => all_names.iter().map(|s| (*s).to_owned()).collect(),
128        ToolSelection::Disabled | ToolSelection::NoBuiltin => Vec::new(),
129        ToolSelection::Allowlist(names) => all_names
130            .iter()
131            .filter(|n| names.iter().any(|a| a == *n))
132            .map(|s| (*s).to_owned())
133            .collect(),
134    }
135}