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}