Skip to main content

supercode_harness/tools/
plan_mode.rs

1//! BP-3 (§2 module 8 `plan_mode`, catalog rows "Plan-mode enter/exit tools"
2//! and "Plan mode (read-only research phase)"): the model-invocable
3//! restriction mode.
4//!
5//! Three parts, one state object:
6//!
7//! * [`EnterPlanModeTool`] / [`ExitPlanModeTool`] — the two tools the module
8//!   is DEFINED by (design §2 module 8 "D1 plan enter/exit tools"). Both are
9//!   ordinary [`crate::tools::Tool`]s registered by
10//!   [`crate::tools::ToolRegistry::from_config`] when the module is active,
11//!   so they are advertised, permission-gated, and evidence-checkable
12//!   exactly like every other tool.
13//! * [`PlanModeState`] — the shared mode flag plus the accumulated plan
14//!   text. Lives on [`crate::tools::ToolContext`] (an `Arc`, shared with
15//!   every clone of the context), so the agent's permission gate and the
16//!   tools see one state.
17//! * [`deny_rules`] — the module's actual RESTRICTION. `plan_mode`'s §2.1
18//!   dependency edge is `plan_mode → permissions.rules | permissions.sandbox`
19//!   ("a restriction mechanism"), and this is that edge honored literally:
20//!   while the mode is active the agent folds these patterns into the
21//!   permissions engine's DENY tier, the same first-match deny→ask→allow
22//!   evaluation every other rule gets. There is no second, parallel
23//!   enforcement path.
24//!
25//! **Exit requires an approval, not a flag flip.** `exit_plan_mode` carries
26//! the plan into a [`crate::permissions::ApprovalRequest`] answered by
27//! whatever [`crate::permissions::PermissionsApprovalHandler`] the embedder
28//! installed — under an SDK-owned runtime that is the frontend request
29//! broker (`crate::server`), i.e. the same door `harness.v1.approvals.list`
30//! lists and `harness.v1.runtimes.respond` answers. No handler installed
31//! means no human can answer, so the exit is refused and the mode stays on
32//! (fail-closed, the same posture `crate::permissions::resolve_ask` takes).
33
34use std::sync::atomic::{AtomicBool, Ordering};
35use std::sync::Mutex;
36
37use async_trait::async_trait;
38use serde::Deserialize;
39use serde_json::{json, Value};
40
41use crate::error::{Error, Result};
42use crate::tools::{Tool, ToolContext};
43
44/// Registered name of the enter tool (Claude Code's `EnterPlanMode`).
45pub const ENTER_PLAN_MODE: &str = "enter_plan_mode";
46
47/// Registered name of the exit tool (Claude Code's `ExitPlanMode`).
48pub const EXIT_PLAN_MODE: &str = "exit_plan_mode";
49
50/// The shared plan-mode state: whether the read-only research phase is
51/// active, and the plan accumulated so far.
52///
53/// Held as an `Arc` on [`ToolContext`], so `enter_plan_mode`,
54/// `exit_plan_mode`, the REPL's `/plan` command, and the agent's permission
55/// gate all read and write ONE object.
56#[derive(Debug, Default)]
57pub struct PlanModeState {
58    active: AtomicBool,
59    plan: Mutex<Vec<String>>,
60}
61
62impl PlanModeState {
63    /// A state with the mode off and no plan recorded.
64    pub fn new() -> Self {
65        Self::default()
66    }
67
68    /// Whether the read-only research phase is active right now.
69    pub fn is_active(&self) -> bool {
70        self.active.load(Ordering::SeqCst)
71    }
72
73    /// Enter the mode, optionally seeding the plan with an opening note.
74    /// Returns `false` when the mode was already active.
75    pub fn enter(&self, note: Option<&str>) -> bool {
76        let was = self.active.swap(true, Ordering::SeqCst);
77        if let Some(note) = note {
78            self.append(note);
79        }
80        !was
81    }
82
83    /// Append a paragraph to the accumulated plan.
84    pub fn append(&self, text: &str) {
85        let text = text.trim();
86        if text.is_empty() {
87            return;
88        }
89        if let Ok(mut plan) = self.plan.lock() {
90            plan.push(text.to_string());
91        }
92    }
93
94    /// The plan accumulated so far, paragraphs joined by a blank line.
95    pub fn plan(&self) -> String {
96        self.plan.lock().map(|p| p.join("\n\n")).unwrap_or_default()
97    }
98
99    /// Leave the mode and clear the accumulated plan, returning it.
100    pub fn exit(&self) -> String {
101        self.active.store(false, Ordering::SeqCst);
102        let plan = self.plan();
103        if let Ok(mut p) = self.plan.lock() {
104            p.clear();
105        }
106        plan
107    }
108}
109
110/// The permissions-engine DENY patterns that narrow the tool surface to a
111/// read-only research phase while plan mode is active — an EMPTY vector
112/// when it is not, so a session that never enters plan mode evaluates
113/// byte-identically to one built before this module existed.
114///
115/// The patterns are written in the engine's own rule grammar
116/// ([`crate::permissions::RuleSet`]):
117///
118/// * `write(*)` — the tool-agnostic write pseudo-tool
119///   [`crate::permissions::evaluate_path_safe`] evaluates for every
120///   path-bearing call, so `write_file`/`edit_file` are refused no matter
121///   which spelling reaches them.
122/// * the write/exec tool names themselves (a bare tool-name glob matches
123///   regardless of subject), covering the shapes that carry no path
124///   argument: shell commands, patch envelopes, background execution, and
125///   image generation (which writes a file into the cwd).
126///
127/// Deliberately NOT denied: `read_file`, `view_image`, `glob`, `search`,
128/// `list_dir`, `web_fetch`, `web_search`, `ask_user`, `current_time`,
129/// `get_context_remaining`, `update_plan` and [`EXIT_PLAN_MODE`] — the
130/// research surface plus the two ways out of the mode. Refusing
131/// `exit_plan_mode` here would make plan mode a one-way door.
132pub fn deny_rules(state: &PlanModeState) -> Vec<String> {
133    if !state.is_active() {
134        return Vec::new();
135    }
136    [
137        "write(*)",
138        "write_file",
139        "edit_file",
140        "apply_patch",
141        "bash",
142        "shell",
143        "background_exec",
144        "image_gen",
145        "new_context",
146    ]
147    .iter()
148    .map(|s| (*s).to_string())
149    .collect()
150}
151
152#[derive(Debug, Default, Deserialize)]
153struct EnterArgs {
154    /// Optional opening note recorded as the first paragraph of the plan.
155    #[serde(default)]
156    plan: Option<String>,
157}
158
159/// `enter_plan_mode` — start the read-only research phase.
160#[derive(Debug, Default)]
161pub struct EnterPlanModeTool;
162
163#[async_trait]
164impl Tool for EnterPlanModeTool {
165    fn name(&self) -> &str {
166        ENTER_PLAN_MODE
167    }
168    fn description(&self) -> &str {
169        "Enter plan mode: a read-only research phase. While it is active every write and \
170         execution tool is refused by the permissions engine, and anything you pass to this \
171         tool (or to further calls) accumulates as the plan. Call exit_plan_mode with the \
172         finished plan to ask the user to approve it and leave the mode."
173    }
174    fn parameters(&self) -> Value {
175        json!({
176            "type": "object",
177            "properties": {
178                "plan": {
179                    "type": "string",
180                    "description": "Optional opening note or draft plan to record."
181                }
182            },
183            "additionalProperties": false
184        })
185    }
186    async fn execute(&self, args: Value, ctx: &ToolContext) -> Result<String> {
187        let a: EnterArgs = serde_json::from_value(args).map_err(|e| Error::InvalidArguments {
188            tool: self.name().to_string(),
189            message: e.to_string(),
190        })?;
191        let fresh = ctx.plan_mode.enter(a.plan.as_deref());
192        Ok(if fresh {
193            "Plan mode is ON: write and execution tools are refused until the user approves a \
194             plan. Research with the read-only tools, then call exit_plan_mode with the plan."
195                .to_string()
196        } else {
197            "Plan mode was already on; the note was appended to the plan.".to_string()
198        })
199    }
200}
201
202#[derive(Debug, Deserialize)]
203struct ExitArgs {
204    /// The plan presented to the user for approval.
205    plan: String,
206}
207
208/// `exit_plan_mode` — present the plan for approval and, if approved, leave
209/// the read-only phase.
210#[derive(Debug, Default)]
211pub struct ExitPlanModeTool;
212
213/// How much of the plan travels in the approval request's `subject` line
214/// (the short field a listing renders); the full text is always in
215/// `raw_args`.
216const SUBJECT_BUDGET: usize = 400;
217
218#[async_trait]
219impl Tool for ExitPlanModeTool {
220    fn name(&self) -> &str {
221        EXIT_PLAN_MODE
222    }
223    fn description(&self) -> &str {
224        "Present the finished plan to the user and ask to leave plan mode. The user must \
225         approve; only then are write and execution tools re-enabled. A refusal keeps plan \
226         mode on so you can revise the plan."
227    }
228    fn parameters(&self) -> Value {
229        json!({
230            "type": "object",
231            "properties": {
232                "plan": {
233                    "type": "string",
234                    "description": "The complete plan the user is being asked to approve."
235                }
236            },
237            "required": ["plan"],
238            "additionalProperties": false
239        })
240    }
241    async fn execute(&self, args: Value, ctx: &ToolContext) -> Result<String> {
242        let a: ExitArgs =
243            serde_json::from_value(args.clone()).map_err(|e| Error::InvalidArguments {
244                tool: self.name().to_string(),
245                message: e.to_string(),
246            })?;
247        if !ctx.plan_mode.is_active() {
248            return Err(Error::tool(
249                self.name(),
250                "plan mode is not active; there is nothing to exit",
251            ));
252        }
253        ctx.plan_mode.append(&a.plan);
254        let plan = ctx.plan_mode.plan();
255
256        // The approval travels the harness's ONE approval door — the same
257        // handler `Agent::set_permissions_approval_handler` installs, which
258        // under an SDK-owned runtime is the frontend request broker. No
259        // handler means nobody can answer: refuse and stay in plan mode
260        // rather than silently self-approving.
261        let Some(handler) = ctx.approval_handler.as_ref() else {
262            return Err(Error::tool(
263                self.name(),
264                "no approval door is attached, so the plan cannot be approved; plan mode stays \
265                 on (attach an interactive frontend, or leave plan mode from the REPL's /plan)",
266            ));
267        };
268        let mut subject: String = plan.chars().take(SUBJECT_BUDGET).collect();
269        if subject.chars().count() < plan.chars().count() {
270            subject.push('…');
271        }
272        let raw_args = json!({ "plan": plan });
273        let req = crate::permissions::ApprovalRequest {
274            tool: self.name(),
275            subject: Some(subject.as_str()),
276            raw_args: &raw_args,
277        };
278        let outcome = handler.ask(&req);
279        match outcome {
280            crate::permissions::ApprovalOutcome::Allow
281            | crate::permissions::ApprovalOutcome::AllowForSession => {
282                let approved = ctx.plan_mode.exit();
283                Ok(format!(
284                    "The user APPROVED the plan. Plan mode is off; write and execution tools \
285                     are available again.\n\nApproved plan:\n{approved}"
286                ))
287            }
288            crate::permissions::ApprovalOutcome::Deny => Ok(
289                "The user did NOT approve the plan. Plan mode stays on — revise the plan and \
290                 call exit_plan_mode again."
291                    .to_string(),
292            ),
293        }
294    }
295}
296
297#[cfg(test)]
298mod tests {
299    use super::*;
300    use std::sync::Arc;
301
302    #[test]
303    fn deny_rules_are_empty_until_the_mode_is_entered() {
304        let state = PlanModeState::new();
305        assert!(deny_rules(&state).is_empty());
306        state.enter(None);
307        let rules = deny_rules(&state);
308        assert!(rules.contains(&"write(*)".to_string()));
309        assert!(rules.contains(&"bash".to_string()));
310        assert!(!rules.contains(&EXIT_PLAN_MODE.to_string()));
311        assert!(!rules.contains(&"read_file".to_string()));
312        state.exit();
313        assert!(deny_rules(&state).is_empty());
314    }
315
316    #[test]
317    fn the_plan_accumulates_and_clears_on_exit() {
318        let state = PlanModeState::new();
319        state.enter(Some("first"));
320        state.append("second");
321        assert_eq!(state.plan(), "first\n\nsecond");
322        assert_eq!(state.exit(), "first\n\nsecond");
323        assert!(state.plan().is_empty());
324        assert!(!state.is_active());
325    }
326
327    #[tokio::test]
328    async fn exit_without_an_approval_door_keeps_the_mode_on() {
329        let ctx = ToolContext::new(std::env::temp_dir());
330        ctx.plan_mode.enter(None);
331        let err = ExitPlanModeTool
332            .execute(json!({"plan": "do the thing"}), &ctx)
333            .await
334            .expect_err("no handler must refuse");
335        assert!(err.to_string().contains("no approval door"), "{err}");
336        assert!(ctx.plan_mode.is_active());
337    }
338
339    #[tokio::test]
340    async fn enter_then_approved_exit_clears_the_restriction() {
341        struct Approve;
342        impl crate::permissions::PermissionsApprovalHandler for Approve {
343            fn ask(
344                &self,
345                _req: &crate::permissions::ApprovalRequest,
346            ) -> crate::permissions::ApprovalOutcome {
347                crate::permissions::ApprovalOutcome::Allow
348            }
349        }
350        let mut ctx = ToolContext::new(std::env::temp_dir());
351        ctx.approval_handler = Some(crate::tools::ToolApprovalHandler(Arc::new(Approve)));
352        EnterPlanModeTool
353            .execute(json!({"plan": "research first"}), &ctx)
354            .await
355            .unwrap();
356        assert!(ctx.plan_mode.is_active());
357        assert!(!deny_rules(&ctx.plan_mode).is_empty());
358        let out = ExitPlanModeTool
359            .execute(json!({"plan": "then build"}), &ctx)
360            .await
361            .unwrap();
362        assert!(out.contains("APPROVED"), "{out}");
363        assert!(!ctx.plan_mode.is_active());
364        assert!(deny_rules(&ctx.plan_mode).is_empty());
365    }
366
367    #[tokio::test]
368    async fn a_denied_exit_keeps_the_mode_and_the_plan() {
369        struct Refuse;
370        impl crate::permissions::PermissionsApprovalHandler for Refuse {
371            fn ask(
372                &self,
373                _req: &crate::permissions::ApprovalRequest,
374            ) -> crate::permissions::ApprovalOutcome {
375                crate::permissions::ApprovalOutcome::Deny
376            }
377        }
378        let mut ctx = ToolContext::new(std::env::temp_dir());
379        ctx.approval_handler = Some(crate::tools::ToolApprovalHandler(Arc::new(Refuse)));
380        ctx.plan_mode.enter(None);
381        let out = ExitPlanModeTool
382            .execute(json!({"plan": "ship it"}), &ctx)
383            .await
384            .unwrap();
385        assert!(out.contains("did NOT approve"), "{out}");
386        assert!(ctx.plan_mode.is_active());
387        assert_eq!(ctx.plan_mode.plan(), "ship it");
388    }
389}