Files
2026-07-13 13:00:08 +08:00

609 lines
20 KiB
Go

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")
}