Skip to main content

defect_agent/
policy.rs

1//! Sandbox policy: "Allow / Deny / Ask user" decision for tool calls.
2//!
3//! ## Interface with the main loop
4//!
5//! [`SandboxPolicy::classify`] is a pure decision; it returns a [`PolicyDecision`]:
6//! - `Allow` / `Deny`: the main loop branches directly.
7//! - `Ask(Ask)`: the main loop packs `Ask::options` into an ACP
8//!   `RequestPermissionRequest`
9//!   and waits for the user's response. When the response arrives, it calls
10//!   [`SandboxPolicy::record`] so the policy can update its internal "already authorized"
11//!   table.
12//!
13//! ## Boundary with the OS-level sandbox
14//!
15//! This module **only makes decisions** — OS-level isolation (landlock / seatbelt / child
16//! process
17//! permission dropping) is a separate trait (a future `ToolSandbox`). This module's
18//! output is
19//! "whether to execute", orthogonal to "how much permission to grant at execution time".
20
21use std::collections::HashSet;
22use std::path::Path;
23use std::sync::{Arc, Mutex};
24
25use agent_client_protocol_schema::{PermissionOptionId, PermissionOptionKind};
26use serde::{Deserialize, Serialize};
27
28use crate::tool::SafetyClass;
29
30/// The decision result.
31///
32/// `Ask::options` must be assembled by the policy itself (including the prompt text, wire
33/// id, and `allows`).
34/// The main loop no longer infers whether to allow for
35/// [`PermissionOptionKind::AllowOnce`] / `RejectOnce`, etc. — that is the policy's
36/// semantics.
37#[non_exhaustive]
38#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
39#[serde(tag = "kind", rename_all = "snake_case")]
40pub enum PolicyDecision {
41    /// Allow the action without prompting the user.
42    Allow,
43    /// Deny directly; the main loop feeds "denied by policy" back to the LLM as a
44    /// `tool_result`.
45    Deny,
46    /// Requires user confirmation. The main loop fires ACP `session/request_permission`
47    /// and waits for the user to pick an item from [`Ask::options`].
48    Ask(Ask),
49}
50
51/// Payload for populating `Ask` options.
52#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
53pub struct Ask {
54    /// The list of options presented to the client. **An empty vector is equivalent to
55    /// [`PolicyDecision::Deny`]**.
56    pub options: Vec<AskOption>,
57}
58
59/// A permission option presented to the user.
60///
61/// `kind` is the ACP UI hint; `allows` is the policy-level "allow/deny" decision.
62/// They are usually consistent (`AllowOnce` / `AllowAlways` → `allows = true`), but
63/// decoupling
64/// lets future partial-allow options like `AllowReadOnly` be added without breaking the
65/// current shape.
66#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
67pub struct AskOption {
68    pub id: PermissionOptionId,
69    pub name: String,
70    pub kind: PermissionOptionKind,
71    /// Whether the user's selection allows (`true`) or denies (`false`) this option.
72    pub allows: bool,
73}
74
75/// The "user response" that the main loop writes back to the policy.
76///
77/// `Selected::allows` is filled into [`AskOption`] by the policy during
78/// [`SandboxPolicy::classify`]; the main loop looks it up by `option_id` and feeds it
79/// back, avoiding the policy having to re-parse the option id it just sent.
80#[non_exhaustive]
81#[derive(Debug, Clone, PartialEq, Eq)]
82pub enum RecordedOutcome {
83    Selected {
84        option_id: PermissionOptionId,
85        allows: bool,
86    },
87    /// The user cancelled the turn. The policy does not update the authorization table,
88    /// but may perform auditing.
89    Cancelled,
90}
91
92/// Context shared by `classify` and `record`.
93#[non_exhaustive]
94pub struct PolicyCtx<'a> {
95    pub tool_name: &'a str,
96    pub safety_hint: SafetyClass,
97    pub args: &'a serde_json::Value,
98    /// The working directory of the current session. Required by path-allowlist policies;
99    /// implementations that do not need it may ignore this field.
100    pub cwd: &'a Path,
101}
102
103impl<'a> PolicyCtx<'a> {
104    pub fn new(
105        tool_name: &'a str,
106        safety_hint: SafetyClass,
107        args: &'a serde_json::Value,
108        cwd: &'a Path,
109    ) -> Self {
110        Self {
111            tool_name,
112            safety_hint,
113            args,
114            cwd,
115        }
116    }
117}
118
119/// A decision-maker for tool invocations.
120///
121/// The implementation must be purely functional: `classify` performs no I/O and no
122/// persistence; persisting the "authorized" table is done via [`Self::record`].
123pub trait SandboxPolicy: Send + Sync {
124    fn classify(&self, ctx: PolicyCtx<'_>) -> PolicyDecision;
125
126    /// A write-back hook invoked after the user responds to an `Ask`.
127    ///
128    /// The main loop calls this once after receiving
129    /// [`crate::event::PermissionResolution::Selected`] but *before* enqueuing the tool
130    /// for execution or rejecting it. `outcome.allows()` has already been resolved from
131    /// [`AskOption::allows`].
132    fn record(&self, ctx: PolicyCtx<'_>, outcome: RecordedOutcome);
133}
134
135// Built-in policies
136
137/// Allows everything. Intended for testing / dev mode.
138pub struct OpenPolicy;
139
140impl SandboxPolicy for OpenPolicy {
141    fn classify(&self, _ctx: PolicyCtx<'_>) -> PolicyDecision {
142        PolicyDecision::Allow
143    }
144    fn record(&self, _ctx: PolicyCtx<'_>, _outcome: RecordedOutcome) {}
145}
146
147/// Only allows `ReadOnly`; everything else is denied.
148pub struct ReadOnlyPolicy;
149
150impl SandboxPolicy for ReadOnlyPolicy {
151    fn classify(&self, ctx: PolicyCtx<'_>) -> PolicyDecision {
152        match ctx.safety_hint {
153            SafetyClass::ReadOnly => PolicyDecision::Allow,
154            _ => PolicyDecision::Deny,
155        }
156    }
157    fn record(&self, _ctx: PolicyCtx<'_>, _outcome: RecordedOutcome) {}
158}
159
160/// Deny everything. Used for smoke testing.
161pub struct DenyAllPolicy;
162
163impl SandboxPolicy for DenyAllPolicy {
164    fn classify(&self, _ctx: PolicyCtx<'_>) -> PolicyDecision {
165        PolicyDecision::Deny
166    }
167    fn record(&self, _ctx: PolicyCtx<'_>, _outcome: RecordedOutcome) {}
168}
169
170/// Default policy: `ReadOnly` is directly `Allow`; `Mutating`, `Destructive`, and
171/// `Network` go through `Ask`. `AllowAlways` maintains an internal whitelist of tool
172/// names; a match results in an immediate `Allow`.
173pub struct AskWritesPolicy {
174    always_allow: Mutex<HashSet<String>>,
175}
176
177impl AskWritesPolicy {
178    pub fn new() -> Self {
179        Self {
180            always_allow: Mutex::new(HashSet::new()),
181        }
182    }
183}
184
185impl Default for AskWritesPolicy {
186    fn default() -> Self {
187        Self::new()
188    }
189}
190
191impl SandboxPolicy for AskWritesPolicy {
192    fn classify(&self, ctx: PolicyCtx<'_>) -> PolicyDecision {
193        if matches!(ctx.safety_hint, SafetyClass::ReadOnly) {
194            return PolicyDecision::Allow;
195        }
196        if let Ok(table) = self.always_allow.lock()
197            && table.contains(ctx.tool_name)
198        {
199            return PolicyDecision::Allow;
200        }
201        PolicyDecision::Ask(default_ask_options(ctx.tool_name))
202    }
203
204    fn record(&self, ctx: PolicyCtx<'_>, outcome: RecordedOutcome) {
205        let RecordedOutcome::Selected { option_id, allows } = outcome else {
206            return;
207        };
208        if !allows {
209            return;
210        }
211        if option_id.0.as_ref() != ALLOW_ALWAYS_ID {
212            return;
213        }
214        if let Ok(mut table) = self.always_allow.lock() {
215            table.insert(ctx.tool_name.to_string());
216        }
217    }
218}
219
220/// Adapts any inner policy to a non-interactive semantics: when the inner policy returns
221/// [`PolicyDecision::Ask`], it is downgraded to [`PolicyDecision::Deny`]; `Allow` /
222/// `Deny` are passed through unchanged.
223///
224/// Used for nested turns of a subagent (`spawn_agent`) — the subagent has no human
225/// present to answer permission requests. If `Ask` were allowed into the main loop, it
226/// would permanently hang on [`PermissionGate`](crate::session::PermissionGate). Wrapping
227/// with this policy ensures the sub-turn **never blocks and never escalates privileges**:
228/// the subagent's actual authorization is always ≤ that of the wrapped parent policy
229/// (what the parent would `Ask`, the child directly `Deny`s).
230///
231/// This is a separate gate from "tool allowlist trimming": the allowlist determines which
232/// tools the subagent **sees**, while this policy determines how much access is granted
233/// **at runtime** on those tools.
234pub struct NonInteractivePolicy {
235    inner: Arc<dyn SandboxPolicy>,
236}
237
238impl NonInteractivePolicy {
239    pub fn new(inner: Arc<dyn SandboxPolicy>) -> Self {
240        Self { inner }
241    }
242}
243
244impl SandboxPolicy for NonInteractivePolicy {
245    fn classify(&self, ctx: PolicyCtx<'_>) -> PolicyDecision {
246        match self.inner.classify(ctx) {
247            PolicyDecision::Ask(_) => PolicyDecision::Deny,
248            other => other,
249        }
250    }
251
252    // This policy never returns `Ask`, so the main loop will never feed a record back to
253    // it — a no-op implementation suffices.
254    fn record(&self, _ctx: PolicyCtx<'_>, _outcome: RecordedOutcome) {}
255}
256
257// ----------------------------------------------------------------------------
258// permission mode section
259// ----------------------------------------------------------------------------
260
261/// A permission mode entry that can be selected by an ACP client.
262///
263/// `defect-agent` does not know about the higher-level `SandboxMode` (that is a
264/// `defect-config` concept; this crate is a dependency base of it and cannot have a
265/// reverse dependency). The assembler (CLI) provides each
266/// [`crate::policy::SandboxPolicy`] together with a stable `id`, a display `name`, and a
267/// `description`; this crate only performs "look up by id and swap the active policy" on
268/// opaque entries, corresponding one-to-one with the `SessionMode` in ACP
269/// `session/set_mode`.
270#[derive(Clone)]
271pub struct PolicyMode {
272    /// Stable identifier — the `mode_id` on the ACP wire. Convention is kebab-case (e.g.
273    /// `ask-writes`), aligned with `SandboxMode::as_str()`.
274    pub id: String,
275    /// A human-readable name for display to clients.
276    pub name: String,
277    /// Optional description, shown in the client UI.
278    pub description: Option<String>,
279    /// The decision policy for this mode. When `set_mode` matches this entry, the policy
280    /// is swapped in entirely.
281    pub policy: Arc<dyn SandboxPolicy>,
282}
283
284impl std::fmt::Debug for PolicyMode {
285    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
286        f.debug_struct("PolicyMode")
287            .field("id", &self.id)
288            .field("name", &self.name)
289            .field("description", &self.description)
290            .finish_non_exhaustive()
291    }
292}
293
294/// A set of mutually exclusive permission modes plus the currently selected one. Maps to
295/// ACP's `SessionModeState`.
296///
297/// Constructed once by the assembler (CLI) and flows into each session via
298/// [`crate::session::AgentCore`]. Each session holds its own copy (`current` can be
299/// switched independently); `set_mode` looks up the corresponding policy by id and swaps
300/// it in.
301#[derive(Debug, Clone)]
302pub struct ModeCatalog {
303    modes: Vec<PolicyMode>,
304    current: String,
305}
306
307impl ModeCatalog {
308    /// Constructs a catalog. `current` must match one of the `id`s in `modes`, otherwise
309    /// returns `None`
310    /// (assembly errors should fail loud, not silently fall back). An empty catalog also
311    /// returns `None`.
312    #[must_use]
313    pub fn new(modes: Vec<PolicyMode>, current: impl Into<String>) -> Option<Self> {
314        let current = current.into();
315        if modes.is_empty() || !modes.iter().any(|m| m.id == current) {
316            return None;
317        }
318        Some(Self { modes, current })
319    }
320
321    /// The ID of the currently selected mode.
322    #[must_use]
323    pub fn current_id(&self) -> &str {
324        &self.current
325    }
326
327    /// The policy for the currently selected mode.
328    #[must_use]
329    pub fn current_policy(&self) -> Arc<dyn SandboxPolicy> {
330        self.modes
331            .iter()
332            .find(|m| m.id == self.current)
333            .map(|m| m.policy.clone())
334            // Invariant: `current` always resolves to an entry (checked at construction
335            // and on every `set`).
336            .expect("ModeCatalog current id must always resolve to a mode")
337    }
338
339    /// All available modes, in assembly order.
340    #[must_use]
341    pub fn modes(&self) -> &[PolicyMode] {
342        &self.modes
343    }
344
345    /// Switch the current mode. Returns `false` if `id` does not match any entry, leaving
346    /// `current` unchanged.
347    pub fn set_current(&mut self, id: &str) -> bool {
348        if self.modes.iter().any(|m| m.id == id) {
349            self.current = id.to_string();
350            true
351        } else {
352            false
353        }
354    }
355}
356
357const ALLOW_ONCE_ID: &str = "allow_once";
358const ALLOW_ALWAYS_ID: &str = "allow_always";
359const REJECT_ONCE_ID: &str = "reject_once";
360
361/// The default set of three `Ask` options: Allow once / Allow always / Reject once.
362///
363/// `RejectAlways` is not currently included — there is no need for persistent rejection;
364/// if the user rejects once, the prompt will be shown again on the next invocation.
365fn default_ask_options(tool_name: &str) -> Ask {
366    let options = vec![
367        AskOption {
368            id: PermissionOptionId::new(ALLOW_ONCE_ID),
369            name: format!("Allow `{tool_name}` once"),
370            kind: PermissionOptionKind::AllowOnce,
371            allows: true,
372        },
373        AskOption {
374            id: PermissionOptionId::new(ALLOW_ALWAYS_ID),
375            name: format!("Allow `{tool_name}` always"),
376            kind: PermissionOptionKind::AllowAlways,
377            allows: true,
378        },
379        AskOption {
380            id: PermissionOptionId::new(REJECT_ONCE_ID),
381            name: "Reject".to_string(),
382            kind: PermissionOptionKind::RejectOnce,
383            allows: false,
384        },
385    ];
386    Ask { options }
387}
388
389#[cfg(test)]
390mod tests;