package planmode import ( "encoding/json" "fmt" "sort" "strings" "reasonix/internal/shellparse" "reasonix/internal/shellsafe" ) // Marker is the model-facing plan-mode instruction block. It rides in the user // turn, not the system prompt or tool schema, so plan toggles preserve cache shape. const Marker = "[Plan mode — planning only. You may research the codebase and web, ask clarifying questions with ask, maintain planning state with todo_write, and delegate isolated read-only research with read_only_task or read_only_skill. You must not write files, run unsafe shell commands, install capabilities, mutate memory, delegate to writer-capable sub-agents or skills, control long-lived processes, or mark execution steps complete. Before planning, if a decision that is genuinely the user's — tech stack, an ambiguous requirement, scope, an irreversible choice — would materially shape the plan and you can't settle it from the codebase or a sensible default, use the ask tool to clarify it first; otherwise pick the obvious default and state the assumption in the plan instead of asking. Then present a LAYERED plan as your reply and stop. Structure the plan as a two-level markdown list so it becomes a layered task list: each PHASE is a top-level numbered list item (a coherent milestone, e.g. \"1. Add the config loader\"), and each phase's concrete, verifiable sub-steps are bullets indented beneath it (e.g. \" - parse the TOML into Config\"). Use plain numbered list items for phases — do NOT write phases as markdown headings (##, ###) — so both levels parse. Keep phases few (about 2-6). The user will be asked to approve before any changes are made.]" // PlanSafety is a tool's self-reported stance on running during the planning // phase, surfaced via tool.PlanModeClassifier. It is deliberately distinct from // ReadOnly(): a tool can be side-effect-free (ReadOnly) yet still belong only to // the post-approval execution phase — complete_step is the canonical example. type PlanSafety int const ( // PlanSafetyUnknown means the tool does not implement PlanModeClassifier, so // the policy falls back to its audited read-only whitelist. PlanSafetyUnknown PlanSafety = iota // PlanSafetySafe means the tool asserts it is safe to run while planning. PlanSafetySafe // PlanSafetyUnsafe means the tool asserts it must not run while planning, // even though ReadOnly() may be true. PlanSafetyUnsafe ) // Call is the plan-mode view of one tool invocation. type Call struct { Name string ReadOnly bool // Untrusted is true when ReadOnly came from an external, untrusted source — // an MCP server's readOnlyHint. Plan mode does not take such a flag at face // value and gates the tool like a writer. Set by the agent from // tool.PlanModeUntrustedReadOnly at the gate call site. Untrusted bool // Safety is the tool's self-reported plan-mode stance. It is Unknown when // the tool does not implement tool.PlanModeClassifier; the agent translates // the interface result into this field at the gate call site. Safety PlanSafety Args json.RawMessage } // Decision reports whether plan mode refuses a call and why. type Decision struct { Blocked bool Message string // ReadOnlyCommandTrust, when set, means the bash command is syntactically safe // enough to ask the user whether this concrete prefix should be trusted as a // plan-mode read-only command. ReadOnlyCommandTrust *ReadOnlyCommandTrust } type ReadOnlyCommandTrust struct { Command string Prefix string } // Policy is the single plan-mode stage policy. type Policy struct { AllowedTools []string ReadOnlyCommands []string } var knownBlockedTools = map[string]bool{ "write_file": true, "edit_file": true, "multi_edit": true, "move_file": true, "apply_patch": true, "edit_notebook": true, "notebook_edit": true, "range_delete": true, "symbol_delete": true, "delete_range": true, "delete_symbol": true, "complete_step": true, "task": true, "parallel_tasks": true, "run_skill": true, "explore": true, "research": true, "review": true, "security_review": true, "security-review": true, "install_source": true, "install_skill": true, "remember": true, "forget": true, "kill_shell": true, } var alwaysAllowedTools = map[string]bool{ "ask": true, "todo_write": true, } var unsafeDeclaredReadOnlyCommandBases = map[string]bool{ "bash": true, "sh": true, "zsh": true, "fish": true, "powershell": true, "pwsh": true, } var unsafeDeclaredReadOnlyCommandWords = map[string]bool{ "add": true, "apply": true, "approve": true, "cancel": true, "close": true, "commit": true, "comment": true, "create": true, "delete": true, "edit": true, "exec": true, "merge": true, "push": true, "remove": true, "restart": true, "review": true, "rm": true, "scale": true, "set": true, "start": true, "stop": true, "submit": true, "update": true, } var readOnlyCommandNeedsThreeTokenPrefixBases = map[string]bool{ "aws": true, "az": true, "gcloud": true, "gh": true, } // planSafeReadOnly is the audited set of read-only built-in tools confirmed safe // to run during planning. It is the AUDIT record, not Decide's allow path: Decide // already trusts any in-process ReadOnly()==true tool. reconcile_test.go uses this // map (via Classify) to force every built-in into an explicit bucket, so a newly // added built-in cannot merge without a reviewer recording its plan-mode stance — // here, in knownBlockedTools, or via tool.PlanModeClassifier. var planSafeReadOnly = map[string]bool{ "read_file": true, "ls": true, "glob": true, "grep": true, "code_index": true, "web_fetch": true, "bash_output": true, // observes an already-running job's buffered output; no new side effect "wait": true, // observes job status; cannot start, preserve, or kill processes } var findWriteArgs = map[string]bool{ "-delete": true, "-exec": true, "-execdir": true, "-ok": true, "-okdir": true, "-fprint": true, "-fprintf": true, "-fls": true, } var goWriteOrExecArgs = map[string]bool{ "-fix": true, "-mod": true, "-modfile": true, "-toolexec": true, "-vettool": true, } // Decide applies the plan-mode stage gate before permission policy. The boundary // is fail-closed for untrusted tools: a tool whose ReadOnly() is false, or whose // ReadOnly() is asserted by an untrusted external source (an MCP server's // readOnlyHint, surfaced via Call.Untrusted), is refused unless it self-reports // plan-safe, is declared in plan_mode_allowed_tools, or is trusted by plugin // configuration before reaching this policy. A trustworthy ReadOnly()==true tool // — a built-in or a first-party/user MCP override — is allowed, // EXCEPT one that self-reports PlanSafetyUnsafe (complete_step: read-only yet // post-approval only), which is refused regardless. The invariant // PlanSafe ⇒ ReadOnly is enforced: a writer that claims plan-safe is a wiring // bug and is refused. func (p Policy) Decide(call Call) Decision { name := strings.TrimSpace(call.Name) if name == "bash" { return decideBash(call.Args, p.ReadOnlyCommands) } if knownBlockedTools[name] { return blockKnown(name) } if call.Safety == PlanSafetyUnsafe { return blockKnown(name) } if alwaysAllowedTools[name] { return Decision{} } if call.Safety == PlanSafetySafe { if !call.ReadOnly { return planSafeContractViolation(name) } return Decision{} } if call.ReadOnly && !call.Untrusted { // Trusted: built-ins and first-party MCP overrides report a trustworthy // ReadOnly()==true. A read-only tool that is nonetheless unsafe while // planning is caught above via PlanSafetyUnsafe / knownBlockedTools. return Decision{} } if p.allowed(name) { return Decision{} } if call.ReadOnly && call.Untrusted { return Decision{ Blocked: true, Message: fmt.Sprintf("blocked: %q reports read-only, but that flag is self-reported by an untrusted external source (e.g. an MCP server's readOnlyHint). Interactive sessions can ask once and remember the decision; non-interactive sessions should pre-seed trusted_read_only_tools or declare the concrete tool in plan_mode_allowed_tools.", name), } } return Decision{ Blocked: true, Message: fmt.Sprintf("blocked: %q is a writer tool and plan mode is read-only. Keep exploring with read-only tools, then write your plan as your reply — the user will be asked to approve it before any changes are made.", name), } } // IgnoredAllowedTools names configured overrides that plan mode will not honor. func (p Policy) IgnoredAllowedTools() []string { var out []string seen := map[string]bool{} for _, name := range p.AllowedTools { name = strings.TrimSpace(name) if name == "" || seen[name] { continue } if name == "bash" || knownBlockedTools[name] { out = append(out, name) seen[name] = true } } sort.Strings(out) return out } // IgnoredReadOnlyCommands names configured plan-mode read-only command prefixes // that are too broad to honor safely. func (p Policy) IgnoredReadOnlyCommands() []string { var out []string seen := map[string]bool{} for _, cmd := range p.ReadOnlyCommands { cmd = strings.TrimSpace(cmd) if cmd == "" || seen[cmd] { continue } fields, err := shellFields(cmd) if err != "" || len(fields) == 0 { out = append(out, cmd) seen[cmd] = true continue } if unsafeDeclaredReadOnlyCommandPrefix(fields) { out = append(out, cmd) seen[cmd] = true } } sort.Strings(out) return out } func (p Policy) allowed(name string) bool { for _, allowed := range p.AllowedTools { if strings.TrimSpace(allowed) == name { return true } } return false } func blockKnown(name string) Decision { if name == "complete_step" { return Decision{ Blocked: true, Message: "blocked: complete_step is only available after plan approval. While planning, keep task state with todo_write and present the plan for user approval.", } } return Decision{ Blocked: true, Message: fmt.Sprintf("blocked: %q is not available in plan mode. Keep exploring with read-only tools — the user will be asked to approve the plan before any changes are made.", name), } } func planSafeContractViolation(name string) Decision { return Decision{ Blocked: true, Message: fmt.Sprintf("blocked: %q is classified plan-safe but reports ReadOnly()==false; refusing on the PlanSafe ⇒ ReadOnly invariant. This is a tool wiring bug — fix the tool's ReadOnly()/PlanModeSafe() contract.", name), } } // Class is the plan-mode bucket a tool falls into, independent of any // plan_mode_allowed_tools override. It exists so reconcile_test.go and // marker_test.go can assert that every built-in is *explicitly* classified // rather than implicitly allowed. Branch order matches Decide; the override is // excluded on purpose because it is a deployment-specific escape valve, not part // of the built-in taxonomy. type Class int const ( // ClassBashGated is bash, whose safety is decided per-argument in decideBash. ClassBashGated Class = iota // ClassBlockedKnown is a tool in knownBlockedTools. ClassBlockedKnown // ClassBlockedUnsafe is a tool that self-reports PlanSafetyUnsafe. ClassBlockedUnsafe // ClassAlwaysAllowed is ask / todo_write. ClassAlwaysAllowed // ClassPlanSafeSelfReported is a tool that self-reports PlanSafetySafe. ClassPlanSafeSelfReported // ClassPlanSafeAudited is a tool in the planSafeReadOnly whitelist. ClassPlanSafeAudited // ClassDefaultBlocked is the fail-closed bucket: nothing classified the tool // plan-safe, so plan mode refuses it. ClassDefaultBlocked ) // Classify reports the plan-mode bucket for a tool. It mirrors Decide's // classification (minus the override and the PlanSafe ⇒ ReadOnly invariant // check, which callers assert separately): a plan-safe class still requires // ReadOnly(), and reconcile_test.go enforces that. func Classify(name string, readOnly bool, safety PlanSafety) Class { name = strings.TrimSpace(name) if name == "bash" { return ClassBashGated } if knownBlockedTools[name] { return ClassBlockedKnown } if safety == PlanSafetyUnsafe { return ClassBlockedUnsafe } if alwaysAllowedTools[name] { return ClassAlwaysAllowed } if safety == PlanSafetySafe { return ClassPlanSafeSelfReported } if planSafeReadOnly[name] { return ClassPlanSafeAudited } return ClassDefaultBlocked } func decideBash(args json.RawMessage, readOnlyCommands []string) Decision { var p struct { Command string `json:"command"` RunInBackground bool `json:"run_in_background"` PreserveBackgroundProcesses bool `json:"preserve_background_processes"` } if err := json.Unmarshal(args, &p); err != nil || strings.TrimSpace(p.Command) == "" { return Decision{ Blocked: true, Message: "blocked: bash command in plan mode must include a valid read-only command.", } } if p.RunInBackground { return Decision{ Blocked: true, Message: "blocked: bash background execution is not available in plan mode. Use foreground read-only commands while planning.", } } if p.PreserveBackgroundProcesses { return Decision{ Blocked: true, Message: "blocked: bash process preservation is not available in plan mode. Use foreground read-only commands while planning.", } } cmd := strings.TrimSpace(p.Command) checkCmd, safeRedirects := shellsafe.NormalizeBashSafeRedirectsForMatch(cmd) if !safeRedirects || shellsafe.ContainsShellSyntax(checkCmd) { if _, malformed := shellFields(cmd); malformedShellQuoting(malformed) { return Decision{ Blocked: true, Message: fmt.Sprintf("blocked: bash command in plan mode has malformed shell quoting (%s). Use a simple read-only command while planning.", malformed), } } return Decision{ Blocked: true, Message: "blocked: bash command in plan mode must not contain shell operators. Use separate calls for chained commands.", } } base, sub, ok := shellsafe.CommandIsReadOnly(checkCmd) if !ok { if ok, malformed := declaredReadOnlyCommand(checkCmd, readOnlyCommands); malformed != "" { return Decision{ Blocked: true, Message: fmt.Sprintf("blocked: bash command in plan mode has malformed shell quoting (%s). Use a simple read-only command while planning.", malformed), } } else if ok { return Decision{} } if trust := readOnlyCommandTrustCandidate(checkCmd); trust != nil { return Decision{ Blocked: true, Message: fmt.Sprintf("blocked: bash commands in plan mode must be read-only. %q is not a known read-only command. Ask the user whether to trust %q as a plan-mode read-only command prefix, or exit plan mode to run it.", checkCmd, trust.Prefix), ReadOnlyCommandTrust: trust, } } return Decision{ Blocked: true, Message: fmt.Sprintf("blocked: bash commands in plan mode must be read-only. %q is not a known read-only command. Use read-only tools for exploration, declare a concrete prefix in plan_mode_read_only_commands, or exit plan mode to run this command.", checkCmd), } } if arg, malformed := unsafePlanModeArg(checkCmd, base, sub); malformed != "" { return Decision{ Blocked: true, Message: fmt.Sprintf("blocked: bash command in plan mode has malformed shell quoting (%s). Use a simple read-only command while planning.", malformed), } } else if arg != "" { return Decision{ Blocked: true, Message: fmt.Sprintf("blocked: bash command in plan mode uses a write-capable argument (%q). Use a read-only command while planning.", arg), } } return Decision{} } func declaredReadOnlyCommand(cmd string, prefixes []string) (bool, string) { fields, malformed := shellFields(cmd) if malformed != "" { return false, malformed } if len(fields) == 0 { return false, "" } for _, prefix := range prefixes { prefix = strings.TrimSpace(prefix) if prefix == "" { continue } prefixFields, prefixMalformed := shellFields(prefix) if prefixMalformed != "" || len(prefixFields) == 0 { continue } if unsafeDeclaredReadOnlyCommandPrefix(prefixFields) { continue } if len(prefixFields) > len(fields) { continue } matches := true for i, want := range prefixFields { if fields[i] != want { matches = false break } } if matches { return true, "" } } return false, "" } func readOnlyCommandTrustCandidate(cmd string) *ReadOnlyCommandTrust { fields, malformed := shellFields(cmd) if malformed != "" || len(fields) == 0 || unsafeDeclaredReadOnlyCommandPrefix(fields) { return nil } prefixFields := readOnlyCommandTrustPrefixFields(fields) if len(prefixFields) == 0 { return nil } return &ReadOnlyCommandTrust{ Command: cmd, Prefix: strings.Join(prefixFields, " "), } } func readOnlyCommandTrustPrefixFields(fields []string) []string { if len(fields) < 2 { return nil } out := []string{fields[0]} for _, field := range fields[1:] { if strings.HasPrefix(field, "-") { out = append(out, field) break } if !readOnlyCommandPrefixWord(field) { break } out = append(out, field) if len(out) >= 3 { break } } if len(out) < 2 || unsafeDeclaredReadOnlyCommandPrefix(out) { return nil } return out } func readOnlyCommandPrefixWord(field string) bool { if field == "" { return false } for i, r := range field { if r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' { continue } if i > 0 && (r >= '0' && r <= '9' || r == '_' || r == '-') { continue } return false } return true } func unsafeDeclaredReadOnlyCommandPrefix(fields []string) bool { if len(fields) < 2 { return true } if readOnlyCommandNeedsThreeTokenPrefixBases[strings.ToLower(fields[0])] && len(fields) < 3 { return true } if unsafeDeclaredReadOnlyCommandBases[strings.ToLower(fields[0])] { return true } for _, field := range fields[1:] { if unsafeDeclaredReadOnlyCommandWords[strings.ToLower(field)] { return true } } return false } // unsafePlanModeArg applies plan-mode's stricter, quote-aware argument check on // top of the shared read-only command classification: a write-capable flag on // an otherwise read-only command (git --output, git grep --open-files-in-pager, // find -exec, go list -mod=mod, …) is refused while planning even though the // base command is read-only. Returns the offending arg, or a malformed-quoting // description. Plan mode is intentionally stricter here than permission's // auto-approve arg check. func unsafePlanModeArg(cmd, base, sub string) (arg, malformed string) { fields, err := shellFields(cmd) if err != "" { return "", err } skip := 1 if sub != "" { skip = 2 } if len(fields) <= skip { return "", "" } args := fields[skip:] lowerArgs := make([]string, len(args)) for i, a := range args { lowerArgs[i] = strings.ToLower(a) } switch base { case "git": for i, la := range lowerArgs { if la == "--output" || strings.HasPrefix(la, "--output=") || la == "--ext-diff" { return args[i], "" } } if sub == "grep" { for i, a := range args { la := lowerArgs[i] if a == "-O" || strings.HasPrefix(a, "-O") || strings.HasPrefix(la, "--open-files-in-pager") { return a, "" } } } case "find": for i, la := range lowerArgs { if findWriteArgs[la] { return args[i], "" } } case "go": if sub == "list" || sub == "vet" { for i, la := range lowerArgs { if goWriteOrExecArgs[la] || strings.HasPrefix(la, "-mod=mod") || strings.HasPrefix(la, "-modfile=") || strings.HasPrefix(la, "-toolexec=") || strings.HasPrefix(la, "-vettool=") { return args[i], "" } } } } return "", "" } func shellFields(s string) ([]string, string) { return shellparse.StaticFields(s) } func malformedShellQuoting(malformed string) bool { return strings.Contains(malformed, "quote") }