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;