supercode-harness 0.4.17

The optional native Supercode agent and tool harness
Documentation
//! BP-3 (§2 module 8 `plan_mode`, catalog rows "Plan-mode enter/exit tools"
//! and "Plan mode (read-only research phase)"): the model-invocable
//! restriction mode.
//!
//! Three parts, one state object:
//!
//! * [`EnterPlanModeTool`] / [`ExitPlanModeTool`] — the two tools the module
//!   is DEFINED by (design §2 module 8 "D1 plan enter/exit tools"). Both are
//!   ordinary [`crate::tools::Tool`]s registered by
//!   [`crate::tools::ToolRegistry::from_config`] when the module is active,
//!   so they are advertised, permission-gated, and evidence-checkable
//!   exactly like every other tool.
//! * [`PlanModeState`] — the shared mode flag plus the accumulated plan
//!   text. Lives on [`crate::tools::ToolContext`] (an `Arc`, shared with
//!   every clone of the context), so the agent's permission gate and the
//!   tools see one state.
//! * [`deny_rules`] — the module's actual RESTRICTION. `plan_mode`'s §2.1
//!   dependency edge is `plan_mode → permissions.rules | permissions.sandbox`
//!   ("a restriction mechanism"), and this is that edge honored literally:
//!   while the mode is active the agent folds these patterns into the
//!   permissions engine's DENY tier, the same first-match deny→ask→allow
//!   evaluation every other rule gets. There is no second, parallel
//!   enforcement path.
//!
//! **Exit requires an approval, not a flag flip.** `exit_plan_mode` carries
//! the plan into a [`crate::permissions::ApprovalRequest`] answered by
//! whatever [`crate::permissions::PermissionsApprovalHandler`] the embedder
//! installed — under an SDK-owned runtime that is the frontend request
//! broker (`crate::server`), i.e. the same door `harness.v1.approvals.list`
//! lists and `harness.v1.runtimes.respond` answers. No handler installed
//! means no human can answer, so the exit is refused and the mode stays on
//! (fail-closed, the same posture `crate::permissions::resolve_ask` takes).

use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Mutex;

use async_trait::async_trait;
use serde::Deserialize;
use serde_json::{json, Value};

use crate::error::{Error, Result};
use crate::tools::{Tool, ToolContext};

/// Registered name of the enter tool (Claude Code's `EnterPlanMode`).
pub const ENTER_PLAN_MODE: &str = "enter_plan_mode";

/// Registered name of the exit tool (Claude Code's `ExitPlanMode`).
pub const EXIT_PLAN_MODE: &str = "exit_plan_mode";

/// The shared plan-mode state: whether the read-only research phase is
/// active, and the plan accumulated so far.
///
/// Held as an `Arc` on [`ToolContext`], so `enter_plan_mode`,
/// `exit_plan_mode`, the REPL's `/plan` command, and the agent's permission
/// gate all read and write ONE object.
#[derive(Debug, Default)]
pub struct PlanModeState {
    active: AtomicBool,
    plan: Mutex<Vec<String>>,
}

impl PlanModeState {
    /// A state with the mode off and no plan recorded.
    pub fn new() -> Self {
        Self::default()
    }

    /// Whether the read-only research phase is active right now.
    pub fn is_active(&self) -> bool {
        self.active.load(Ordering::SeqCst)
    }

    /// Enter the mode, optionally seeding the plan with an opening note.
    /// Returns `false` when the mode was already active.
    pub fn enter(&self, note: Option<&str>) -> bool {
        let was = self.active.swap(true, Ordering::SeqCst);
        if let Some(note) = note {
            self.append(note);
        }
        !was
    }

    /// Append a paragraph to the accumulated plan.
    pub fn append(&self, text: &str) {
        let text = text.trim();
        if text.is_empty() {
            return;
        }
        if let Ok(mut plan) = self.plan.lock() {
            plan.push(text.to_string());
        }
    }

    /// The plan accumulated so far, paragraphs joined by a blank line.
    pub fn plan(&self) -> String {
        self.plan.lock().map(|p| p.join("\n\n")).unwrap_or_default()
    }

    /// Leave the mode and clear the accumulated plan, returning it.
    pub fn exit(&self) -> String {
        self.active.store(false, Ordering::SeqCst);
        let plan = self.plan();
        if let Ok(mut p) = self.plan.lock() {
            p.clear();
        }
        plan
    }
}

/// The permissions-engine DENY patterns that narrow the tool surface to a
/// read-only research phase while plan mode is active — an EMPTY vector
/// when it is not, so a session that never enters plan mode evaluates
/// byte-identically to one built before this module existed.
///
/// The patterns are written in the engine's own rule grammar
/// ([`crate::permissions::RuleSet`]):
///
/// * `write(*)` — the tool-agnostic write pseudo-tool
///   [`crate::permissions::evaluate_path_safe`] evaluates for every
///   path-bearing call, so `write_file`/`edit_file` are refused no matter
///   which spelling reaches them.
/// * the write/exec tool names themselves (a bare tool-name glob matches
///   regardless of subject), covering the shapes that carry no path
///   argument: shell commands, patch envelopes, background execution, and
///   image generation (which writes a file into the cwd).
///
/// Deliberately NOT denied: `read_file`, `view_image`, `glob`, `search`,
/// `list_dir`, `web_fetch`, `web_search`, `ask_user`, `current_time`,
/// `get_context_remaining`, `update_plan` and [`EXIT_PLAN_MODE`] — the
/// research surface plus the two ways out of the mode. Refusing
/// `exit_plan_mode` here would make plan mode a one-way door.
pub fn deny_rules(state: &PlanModeState) -> Vec<String> {
    if !state.is_active() {
        return Vec::new();
    }
    [
        "write(*)",
        "write_file",
        "edit_file",
        "apply_patch",
        "bash",
        "shell",
        "background_exec",
        "image_gen",
        "new_context",
    ]
    .iter()
    .map(|s| (*s).to_string())
    .collect()
}

#[derive(Debug, Default, Deserialize)]
struct EnterArgs {
    /// Optional opening note recorded as the first paragraph of the plan.
    #[serde(default)]
    plan: Option<String>,
}

/// `enter_plan_mode` — start the read-only research phase.
#[derive(Debug, Default)]
pub struct EnterPlanModeTool;

#[async_trait]
impl Tool for EnterPlanModeTool {
    fn name(&self) -> &str {
        ENTER_PLAN_MODE
    }
    fn description(&self) -> &str {
        "Enter plan mode: a read-only research phase. While it is active every write and \
         execution tool is refused by the permissions engine, and anything you pass to this \
         tool (or to further calls) accumulates as the plan. Call exit_plan_mode with the \
         finished plan to ask the user to approve it and leave the mode."
    }
    fn parameters(&self) -> Value {
        json!({
            "type": "object",
            "properties": {
                "plan": {
                    "type": "string",
                    "description": "Optional opening note or draft plan to record."
                }
            },
            "additionalProperties": false
        })
    }
    async fn execute(&self, args: Value, ctx: &ToolContext) -> Result<String> {
        let a: EnterArgs = serde_json::from_value(args).map_err(|e| Error::InvalidArguments {
            tool: self.name().to_string(),
            message: e.to_string(),
        })?;
        let fresh = ctx.plan_mode.enter(a.plan.as_deref());
        Ok(if fresh {
            "Plan mode is ON: write and execution tools are refused until the user approves a \
             plan. Research with the read-only tools, then call exit_plan_mode with the plan."
                .to_string()
        } else {
            "Plan mode was already on; the note was appended to the plan.".to_string()
        })
    }
}

#[derive(Debug, Deserialize)]
struct ExitArgs {
    /// The plan presented to the user for approval.
    plan: String,
}

/// `exit_plan_mode` — present the plan for approval and, if approved, leave
/// the read-only phase.
#[derive(Debug, Default)]
pub struct ExitPlanModeTool;

/// How much of the plan travels in the approval request's `subject` line
/// (the short field a listing renders); the full text is always in
/// `raw_args`.
const SUBJECT_BUDGET: usize = 400;

#[async_trait]
impl Tool for ExitPlanModeTool {
    fn name(&self) -> &str {
        EXIT_PLAN_MODE
    }
    fn description(&self) -> &str {
        "Present the finished plan to the user and ask to leave plan mode. The user must \
         approve; only then are write and execution tools re-enabled. A refusal keeps plan \
         mode on so you can revise the plan."
    }
    fn parameters(&self) -> Value {
        json!({
            "type": "object",
            "properties": {
                "plan": {
                    "type": "string",
                    "description": "The complete plan the user is being asked to approve."
                }
            },
            "required": ["plan"],
            "additionalProperties": false
        })
    }
    async fn execute(&self, args: Value, ctx: &ToolContext) -> Result<String> {
        let a: ExitArgs =
            serde_json::from_value(args.clone()).map_err(|e| Error::InvalidArguments {
                tool: self.name().to_string(),
                message: e.to_string(),
            })?;
        if !ctx.plan_mode.is_active() {
            return Err(Error::tool(
                self.name(),
                "plan mode is not active; there is nothing to exit",
            ));
        }
        ctx.plan_mode.append(&a.plan);
        let plan = ctx.plan_mode.plan();

        // The approval travels the harness's ONE approval door — the same
        // handler `Agent::set_permissions_approval_handler` installs, which
        // under an SDK-owned runtime is the frontend request broker. No
        // handler means nobody can answer: refuse and stay in plan mode
        // rather than silently self-approving.
        let Some(handler) = ctx.approval_handler.as_ref() else {
            return Err(Error::tool(
                self.name(),
                "no approval door is attached, so the plan cannot be approved; plan mode stays \
                 on (attach an interactive frontend, or leave plan mode from the REPL's /plan)",
            ));
        };
        let mut subject: String = plan.chars().take(SUBJECT_BUDGET).collect();
        if subject.chars().count() < plan.chars().count() {
            subject.push('');
        }
        let raw_args = json!({ "plan": plan });
        let req = crate::permissions::ApprovalRequest {
            tool: self.name(),
            subject: Some(subject.as_str()),
            raw_args: &raw_args,
        };
        let outcome = handler.ask(&req);
        match outcome {
            crate::permissions::ApprovalOutcome::Allow
            | crate::permissions::ApprovalOutcome::AllowForSession => {
                let approved = ctx.plan_mode.exit();
                Ok(format!(
                    "The user APPROVED the plan. Plan mode is off; write and execution tools \
                     are available again.\n\nApproved plan:\n{approved}"
                ))
            }
            crate::permissions::ApprovalOutcome::Deny => Ok(
                "The user did NOT approve the plan. Plan mode stays on — revise the plan and \
                 call exit_plan_mode again."
                    .to_string(),
            ),
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::sync::Arc;

    #[test]
    fn deny_rules_are_empty_until_the_mode_is_entered() {
        let state = PlanModeState::new();
        assert!(deny_rules(&state).is_empty());
        state.enter(None);
        let rules = deny_rules(&state);
        assert!(rules.contains(&"write(*)".to_string()));
        assert!(rules.contains(&"bash".to_string()));
        assert!(!rules.contains(&EXIT_PLAN_MODE.to_string()));
        assert!(!rules.contains(&"read_file".to_string()));
        state.exit();
        assert!(deny_rules(&state).is_empty());
    }

    #[test]
    fn the_plan_accumulates_and_clears_on_exit() {
        let state = PlanModeState::new();
        state.enter(Some("first"));
        state.append("second");
        assert_eq!(state.plan(), "first\n\nsecond");
        assert_eq!(state.exit(), "first\n\nsecond");
        assert!(state.plan().is_empty());
        assert!(!state.is_active());
    }

    #[tokio::test]
    async fn exit_without_an_approval_door_keeps_the_mode_on() {
        let ctx = ToolContext::new(std::env::temp_dir());
        ctx.plan_mode.enter(None);
        let err = ExitPlanModeTool
            .execute(json!({"plan": "do the thing"}), &ctx)
            .await
            .expect_err("no handler must refuse");
        assert!(err.to_string().contains("no approval door"), "{err}");
        assert!(ctx.plan_mode.is_active());
    }

    #[tokio::test]
    async fn enter_then_approved_exit_clears_the_restriction() {
        struct Approve;
        impl crate::permissions::PermissionsApprovalHandler for Approve {
            fn ask(
                &self,
                _req: &crate::permissions::ApprovalRequest,
            ) -> crate::permissions::ApprovalOutcome {
                crate::permissions::ApprovalOutcome::Allow
            }
        }
        let mut ctx = ToolContext::new(std::env::temp_dir());
        ctx.approval_handler = Some(crate::tools::ToolApprovalHandler(Arc::new(Approve)));
        EnterPlanModeTool
            .execute(json!({"plan": "research first"}), &ctx)
            .await
            .unwrap();
        assert!(ctx.plan_mode.is_active());
        assert!(!deny_rules(&ctx.plan_mode).is_empty());
        let out = ExitPlanModeTool
            .execute(json!({"plan": "then build"}), &ctx)
            .await
            .unwrap();
        assert!(out.contains("APPROVED"), "{out}");
        assert!(!ctx.plan_mode.is_active());
        assert!(deny_rules(&ctx.plan_mode).is_empty());
    }

    #[tokio::test]
    async fn a_denied_exit_keeps_the_mode_and_the_plan() {
        struct Refuse;
        impl crate::permissions::PermissionsApprovalHandler for Refuse {
            fn ask(
                &self,
                _req: &crate::permissions::ApprovalRequest,
            ) -> crate::permissions::ApprovalOutcome {
                crate::permissions::ApprovalOutcome::Deny
            }
        }
        let mut ctx = ToolContext::new(std::env::temp_dir());
        ctx.approval_handler = Some(crate::tools::ToolApprovalHandler(Arc::new(Refuse)));
        ctx.plan_mode.enter(None);
        let out = ExitPlanModeTool
            .execute(json!({"plan": "ship it"}), &ctx)
            .await
            .unwrap();
        assert!(out.contains("did NOT approve"), "{out}");
        assert!(ctx.plan_mode.is_active());
        assert_eq!(ctx.plan_mode.plan(), "ship it");
    }
}