Skip to content

Commit ce3c510

Browse files
authored
Merge pull request #5246 from lifu963/pr/plan-mode-policy
Centralize plan mode policy
2 parents 948cd3e + b1326ef commit ce3c510

32 files changed

Lines changed: 2285 additions & 264 deletions

docs/GUIDE.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,8 @@ default_model = "deepseek-flash" # executor; set [agent].planner_model to add
5555
max_steps = 0 # user/global only; executor tool-call rounds; 0 = no limit
5656
planner_max_steps = 0 # user/global only; planner read-only tool-call rounds; 0 = no limit
5757
reasoning_language = "auto" # visible reasoning text: auto|zh|en
58+
# plan_mode_allowed_tools = ["custom_reader"] # extra read-only custom tools only;
59+
# # does not unlock blocked tools or unsafe bash
5860
# planner_model = "deepseek-pro" # optional low-frequency planner
5961
# subagent_model = "deepseek-pro" # optional default for runAs=subagent skills
6062
# subagent_models = { review = "deepseek-pro", security_review = "deepseek-pro" }
@@ -100,6 +102,15 @@ command = "reasonix-plugin-example"
100102

101103
For the full schema and every field's contract, see [`SPEC.md` §5](./SPEC.md#5-configuration-toml).
102104

105+
`[agent].plan_mode_allowed_tools` is an extra read-only declaration for custom or
106+
external tools Reasonix cannot classify itself — it is also the escape valve for
107+
MCP/plugin tools whose read-only flag comes from an untrusted server
108+
`readOnlyHint`, which plan mode does not trust and so fails closed on until
109+
declared here (first-party `ReadOnlyToolNames` overrides and built-ins stay
110+
trusted). It never unlocks known blocked plan-mode tools such as `bash`, `task`,
111+
writers, installers, or memory mutation tools, and it never bypasses bash's
112+
plan-mode safety checks.
113+
103114
## Serve web frontend
104115

105116
`reasonix serve` starts the same local engine behind a browser UI. Use it when
@@ -452,6 +463,15 @@ Subagent skills inherit the executor model by default. Set `subagent_model` to
452463
run them on another configured model, or use `subagent_models` to override only
453464
specific skills such as `review` or `security_review`.
454465

466+
Use `read_only_task` when planning needs isolated, deeper research without
467+
granting write-capable delegation. Use `read_only_skill` when the same need is
468+
best expressed through an existing skill. Both run ephemeral read-only
469+
subagents with only read-only research tools plus safe foreground bash, return
470+
only the final answer, and do not create resumable subagent transcripts. In
471+
token economy mode, connect only this narrow surface with
472+
`connect_tool_source(source="read_only_skill")`; the full `skills` source still
473+
enables writer-capable skill tools and remains blocked in plan mode.
474+
455475
For interactive frontends, plan mode is manual by default. Set
456476
`agent.auto_plan = "on"` to make complex-looking tasks enter plan mode
457477
automatically: Reasonix first drafts a read-only plan, then waits for approval

docs/GUIDE.zh-CN.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,8 @@ default_model = "deepseek-flash" # 执行器;设 [agent].planner_model 可
4747
max_steps = 0 # 仅用户/全局;执行器工具调用轮数;0 表示不限
4848
planner_max_steps = 0 # 仅用户/全局;规划器只读工具调用轮数;0 表示不限
4949
reasoning_language = "auto" # 可见思考过程语言:auto|zh|en
50+
# plan_mode_allowed_tools = ["custom_reader"] # 仅声明额外只读自定义工具;
51+
# # 不能解锁被计划模式阻断的工具或 unsafe bash
5052
# planner_model = "deepseek-pro" # 可选的低频规划器
5153
# subagent_model = "deepseek-pro" # runAs=subagent skill 的默认模型
5254
# subagent_models = { review = "deepseek-pro", security_review = "deepseek-pro" }
@@ -92,6 +94,11 @@ command = "reasonix-plugin-example"
9294

9395
完整 schema 与每个字段的契约见 [`SPEC.md` §5](./SPEC.md#5-configuration-toml)
9496

97+
`[agent].plan_mode_allowed_tools` 用于把 Reasonix 无法自动分类的自定义/外部工具声明为额外只读工具,
98+
它也是 MCP/plugin 工具的逃生阀——当 MCP 工具的只读标志来自服务器自报的 `readOnlyHint`(不可信)时,
99+
计划模式不信任它、默认 fail-closed,需在此显式声明才能使用(first-party `ReadOnlyToolNames` 覆盖与 builtin 仍可信)。
100+
它不再解锁 `bash``task`、写文件工具、安装器、记忆变更工具等计划模式已知阻断项,也不会绕过 bash 在计划模式下的安全检查。
101+
95102
## Serve Web 前端
96103

97104
`reasonix serve` 会用同一个本地 Reasonix 引擎启动浏览器 UI。适合不安装桌面端但想用可视化界面、
@@ -388,6 +395,13 @@ Planner 会看到已加载的 `REASONIX.md` / `AGENTS.md` 记忆,并拿到一
388395
Subagent skills 默认继承执行器模型。设置 `subagent_model` 可让它们统一走另一个已配置
389396
模型;设置 `subagent_models` 则只覆盖 `review``security_review` 等指定 skill。
390397

398+
当计划阶段需要隔离上下文做更深的调研时,用 `read_only_task`,而不是放开可写的
399+
`task`。如果这类调研更适合复用已有 skill,用 `read_only_skill`。两者都会启动
400+
ephemeral 只读 subagent,只暴露只读研究工具和安全前台 bash,只返回最终答案,不创建
401+
可续接的 subagent transcript。在 token economy 模式下,只用
402+
`connect_tool_source(source="read_only_skill")` 连接这条窄入口;完整的 `skills`
403+
source 仍会启用可写 skill 工具,plan mode 下继续阻断。
404+
391405
交互式前端中,计划模式默认手动开启。设置 `agent.auto_plan = "on"` 后,看起来复杂
392406
的任务会自动进入 plan mode:Reasonix 先只读生成计划,待用户批准后才
393407
编辑文件或执行有副作用的命令。`auto_plan_classifier` 可以指定便宜的 provider,例如

docs/MIGRATING.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,19 @@ and DeepSeek prefix-cache–oriented design.
9696
search + tree-sitter symbol index is not bundled in v2 yet, and CodeGraph is no
9797
longer shipped as an internal MCP server.
9898
- **Plan mode** + `complete_step` (evidence-backed step sign-off).
99+
- **Plan-mode tool overrides are narrower, and plan mode is fail-closed for
100+
external tools**: `[agent].plan_mode_allowed_tools` now only declares extra
101+
read-only custom/external tools. It no longer unlocks known blocked plan-mode
102+
tools such as `bash`, `task`, writers, installers, or memory mutation tools, and
103+
unsafe bash commands still remain blocked. An MCP/plugin tool whose read-only
104+
status comes from the server's untrusted `readOnlyHint` is not trusted by plan
105+
mode; declare it here to use it while planning — otherwise plan mode fails closed
106+
on it. First-party `ReadOnlyToolNames` overrides and built-ins stay trusted.
107+
- **Read-only subagent research**: use `read_only_task` for generic isolated
108+
research in plan mode, or `read_only_skill` when the work should follow an
109+
existing skill. Both expose only read-only tools and safe foreground bash, do
110+
not write resumable transcripts, and keep writer-capable `task` / `run_skill`
111+
blocked until after plan approval.
99112
- **No web dashboard** — the v2 line is terminal + desktop (Wails), by design.
100113
- Some granular v1 tools are intentionally consolidated (e.g. file-management ops
101114
go through `bash`); a few v1 tools are not yet ported (tracked on Discussions).

docs/SPEC.md

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -284,8 +284,21 @@ func (p Policy) Decide(toolName string, readOnly bool, args json.RawMessage) Dec
284284
hard block in *every* mode: the tool never executes and the model receives a
285285
"blocked" result it can adapt to (the same shape as a plan-mode refusal).
286286
- **Relationship to plan mode.** Plan mode (§3.4) is an orthogonal, coarser gate
287-
that refuses *all* writers regardless of policy; it is checked first. The
288-
permission layer is the fine-grained, always-on gate underneath it.
287+
checked before the permission layer. Its boundary is fail-closed for untrusted
288+
tools: while planning, a tool runs only if it reports a *trustworthy*
289+
`ReadOnly()==true` — a built-in or a first-party MCP `ReadOnlyToolNames`
290+
override — or self-reports plan-safe via `tool.PlanModeClassifier`. An MCP
291+
tool's `ReadOnly()` may instead come from the server's self-reported
292+
`readOnlyHint`, which plan mode does not trust (`tool.PlanModeUntrustedReadOnly`):
293+
such a tool is gated like a writer. Writers, installers, memory mutation, process
294+
control, and `complete_step` (read-only yet post-approval only, so it
295+
self-reports plan-unsafe) are refused; the enforced invariant is
296+
PlanSafe ⇒ ReadOnly. An untrusted read-only MCP/plugin tool is therefore blocked
297+
until declared in `[agent].plan_mode_allowed_tools`, and is likewise excluded
298+
from read-only research sub-agents. Plan mode still allows `read_only_task` and
299+
`read_only_skill`, whose sub-agents receive only read-only research tools and
300+
safe foreground bash; writer-capable `task` delegation and full skill execution
301+
remain blocked.
289302
- **User decisions are separate from tool approvals.** Runtime tool approval has
290303
three user-facing postures: `ask` ("需要批准"), `auto` ("自动批准"), and
291304
`yolo` ("Yolo批准"). `auto` lets the permission policy auto-approve the writer
@@ -470,6 +483,8 @@ max_steps = 0 # user/global only; executor tool-call rounds; 0 = no l
470483
planner_max_steps = 0 # user/global only; planner read-only tool-call rounds; 0 = no limit
471484
temperature = 0.0
472485
reasoning_language = "auto" # visible reasoning text: auto|zh|en
486+
# plan_mode_allowed_tools = ["custom_reader"] # extra read-only declarations for custom tools;
487+
# # cannot unlock known blocked tools or unsafe bash
473488
# planner_model = "deepseek-pro" # optional: two-model collaboration (low-frequency planner)
474489
# subagent_model = "deepseek-pro" # optional default for runAs=subagent skills
475490
# subagent_models = { review = "deepseek-pro", security_review = "deepseek-pro" }

internal/agent/agent.go

Lines changed: 35 additions & 173 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ import (
99
"sync"
1010
"sync/atomic"
1111
"time"
12-
"unicode"
1312
"unicode/utf8"
1413

1514
"reasonix/internal/diff"
@@ -19,6 +18,7 @@ import (
1918
"reasonix/internal/jobs"
2019
"reasonix/internal/memory"
2120
"reasonix/internal/nilutil"
21+
"reasonix/internal/planmode"
2222
"reasonix/internal/provider"
2323
"reasonix/internal/tool"
2424
)
@@ -29,56 +29,6 @@ import (
2929
// window before the next compaction runs.
3030
const maxToolOutputBytes = 32 * 1024
3131

32-
// planModeDeniedTools lists tools that are unconditionally denied in plan mode.
33-
// These are never shown to the LLM and cannot be called even if the agent
34-
// somehow references them. The write_file, edit_file, and multi_edit tools are
35-
// the canonical file-writing tools; apply_patch is a structured write variant.
36-
var planModeDeniedTools = map[string]bool{
37-
"write_file": true,
38-
"edit_file": true,
39-
"multi_edit": true,
40-
"apply_patch": true,
41-
}
42-
43-
// planModeBashMetachars defines shell metacharacters that indicate command
44-
// chaining, redirection, or substitution. When any of these appear in a bash
45-
// command during plan mode, the command is blocked — even if the command prefix
46-
// matches a safe read-only entry — because chaining can introduce side effects
47-
// after an otherwise safe prefix.
48-
var planModeBashMetachars = []string{"&&", "||", ">>", "<<", "$(", "\x60", ";", "|", ">", "<", "&", "\n", "\r"}
49-
50-
// planModeSafeBashCommands are bash command prefixes that are safe to run in
51-
// plan mode. Each entry is matched as a prefix against the trimmed, lowercased
52-
// command string. The match requires a shell-argument boundary after the prefix:
53-
// whitespace or end-of-string — so "echop" never matches "echo".
54-
var planModeSafeBashCommands = []string{
55-
"git status", "git diff", "git log", "git show",
56-
"git ls-files", "git grep", "git blame",
57-
"ls", "cat", "grep", "find", "head", "tail", "pwd",
58-
"echo", "wc", "which", "type", "uname", "hostname",
59-
"go version", "go list", "go doc", "go vet",
60-
"node -v", "npm list", "python --version",
61-
}
62-
63-
var planModeFindWriteArgs = map[string]bool{
64-
"-delete": true,
65-
"-exec": true,
66-
"-execdir": true,
67-
"-ok": true,
68-
"-okdir": true,
69-
"-fprint": true,
70-
"-fprintf": true,
71-
"-fls": true,
72-
}
73-
74-
var planModeGoWriteOrExecArgs = map[string]bool{
75-
"-fix": true,
76-
"-mod": true,
77-
"-modfile": true,
78-
"-toolexec": true,
79-
"-vettool": true,
80-
}
81-
8232
const maxFinalReadinessBlocks = 3
8333
const maxEmptyFinalBlocks = 3
8434
const maxStreamRecoveries = 3
@@ -310,10 +260,10 @@ type Agent struct {
310260
// session without touching the cache-stable prefix. Set via SetMemoryQueue.
311261
memQueue memory.Queue
312262

313-
// planModeAllowedTools is the set of tool names that are exempt from the
314-
// plan-mode gate. When non-empty, these tools bypass the read-only check.
263+
// planModeAllowedTools declares extra custom tools that the centralized
264+
// plan-mode policy may treat as read-only. Known blocked tools still lose.
315265
// Populated from Options.PlanModeAllowedTools during construction.
316-
planModeAllowedTools map[string]bool
266+
planModeAllowedTools []string
317267

318268
// Context management: when a turn's prompt nears contextWindow, the older
319269
// middle of the session is summarized away, keeping a token-bounded recent
@@ -578,25 +528,11 @@ type Options struct {
578528
// user-turn context. Empty/auto injects nothing.
579529
ReasoningLanguage string
580530

581-
// PlanModeAllowedTools names tools that bypass the plan-mode read-only gate.
582-
// When a tool named here is called while planMode is true, it executes
583-
// without the "plan mode is read-only" block — even if its ReadOnly contract
584-
// returns false. Use sparingly; the caller is responsible for ensuring the
585-
// tool invocation is safe in a read-only context (e.g. bash for git status).
531+
// PlanModeAllowedTools names extra custom tools the plan-mode policy may treat
532+
// as read-only. It cannot unlock known blocked tools or unsafe bash commands.
586533
PlanModeAllowedTools []string
587534
}
588535

589-
func stringSet(ss []string) map[string]bool {
590-
if len(ss) == 0 {
591-
return nil
592-
}
593-
m := make(map[string]bool, len(ss))
594-
for _, s := range ss {
595-
m[s] = true
596-
}
597-
return m
598-
}
599-
600536
// New constructs an Agent. MaxSteps <= 0 means no cap — the run loop continues
601537
// until the model gives a final answer, the context is cancelled, or the
602538
// provider errors (compaction keeps the context bounded). A nil sink is replaced
@@ -651,7 +587,7 @@ func New(prov provider.Provider, tools *tool.Registry, session *Session, opts Op
651587
recentKeep: opts.RecentKeep,
652588
archiveDir: opts.ArchiveDir,
653589
keepPolicy: opts.KeepPolicy,
654-
planModeAllowedTools: stringSet(opts.PlanModeAllowedTools),
590+
planModeAllowedTools: append([]string(nil), opts.PlanModeAllowedTools...),
655591
}
656592
a.SetReasoningLanguage(opts.ReasoningLanguage)
657593
return a
@@ -1725,7 +1661,23 @@ func (a *Agent) executeOne(ctx context.Context, call provider.ToolCall) toolOutc
17251661
}
17261662
}
17271663
if a.planMode.Load() {
1728-
if blocked, msg := a.planModeBlocked(call.Name, t.ReadOnly(), json.RawMessage(call.Arguments)); blocked {
1664+
// Translate the tool's optional plan-mode self-report into the policy's
1665+
// tri-state. Mirrors the t.(tool.Previewer) assertion precedent below.
1666+
safety := planmode.PlanSafetyUnknown
1667+
if c, ok := t.(tool.PlanModeClassifier); ok {
1668+
if c.PlanModeSafe() {
1669+
safety = planmode.PlanSafetySafe
1670+
} else {
1671+
safety = planmode.PlanSafetyUnsafe
1672+
}
1673+
}
1674+
// External tools (MCP) whose ReadOnly() is only a server-reported
1675+
// readOnlyHint are not trusted by plan mode's read-only fast path.
1676+
untrusted := false
1677+
if u, ok := t.(tool.PlanModeUntrustedReadOnly); ok {
1678+
untrusted = u.PlanModeUntrustedReadOnly()
1679+
}
1680+
if blocked, msg := a.planModeBlocked(call.Name, t.ReadOnly(), untrusted, safety, json.RawMessage(call.Arguments)); blocked {
17291681
return toolOutcome{
17301682
output: msg,
17311683
blocked: true,
@@ -1842,110 +1794,20 @@ func (a *Agent) executeOne(ctx context.Context, call provider.ToolCall) toolOutc
18421794
return toolOutcome{output: body, truncated: truncMsg != "", truncMsg: truncMsg}
18431795
}
18441796

1845-
func (a *Agent) planModeBlocked(toolName string, readOnly bool, args json.RawMessage) (blocked bool, message string) {
1846-
if readOnly {
1847-
return false, ""
1848-
}
1849-
if planModeDeniedTools[toolName] {
1850-
return true, 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.", toolName)
1851-
}
1852-
if a.planModeAllowedTools != nil && a.planModeAllowedTools[toolName] {
1853-
return false, ""
1854-
}
1855-
if toolName == "bash" {
1856-
if blocked, msg := planModeBashBlocked(args); blocked {
1857-
return true, msg
1858-
}
1859-
return false, ""
1860-
}
1861-
return true, 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.", toolName)
1797+
func (a *Agent) planModeBlocked(toolName string, readOnly, untrusted bool, safety planmode.PlanSafety, args json.RawMessage) (blocked bool, message string) {
1798+
decision := planmode.Policy{AllowedTools: a.planModeAllowedTools}.Decide(planmode.Call{
1799+
Name: toolName,
1800+
ReadOnly: readOnly,
1801+
Untrusted: untrusted,
1802+
Safety: safety,
1803+
Args: args,
1804+
})
1805+
return decision.Blocked, decision.Message
18621806
}
18631807

18641808
func planModeBashBlocked(args json.RawMessage) (bool, string) {
1865-
var p struct {
1866-
Command string `json:"command"`
1867-
}
1868-
if err := json.Unmarshal(args, &p); err != nil || p.Command == "" {
1869-
return false, ""
1870-
}
1871-
cmd := strings.TrimSpace(p.Command)
1872-
lower := strings.ToLower(cmd)
1873-
1874-
// Reject commands containing shell metacharacters — chaining, piping,
1875-
// redirection, or command substitution can introduce side effects after
1876-
// an otherwise safe prefix.
1877-
for _, mc := range planModeBashMetachars {
1878-
if strings.Contains(lower, mc) {
1879-
return true, fmt.Sprintf("blocked: bash command in plan mode must not contain shell operators (%q). Use separate calls for chained commands.", mc)
1880-
}
1881-
}
1882-
1883-
// Check the command prefix against the safe read-only whitelist. Require a
1884-
// shell-argument boundary after the match to avoid prefix collisions.
1885-
for _, safe := range planModeSafeBashCommands {
1886-
if !planModeBashMatchesSafePrefix(lower, safe) {
1887-
continue
1888-
}
1889-
if arg := planModeUnsafeSafeCommandArg(cmd, safe); arg != "" {
1890-
return true, fmt.Sprintf("blocked: bash command in plan mode uses a write-capable argument (%q). Use a read-only command while planning.", arg)
1891-
}
1892-
return false, ""
1893-
}
1894-
1895-
return true, fmt.Sprintf("blocked: bash commands in plan mode must be read-only. %q is not in the safe command list. Use read-only tools for exploration, then exit plan mode to run this command.", cmd)
1896-
}
1897-
1898-
func planModeBashMatchesSafePrefix(lower, safe string) bool {
1899-
if !strings.HasPrefix(lower, safe) {
1900-
return false
1901-
}
1902-
if len(lower) == len(safe) {
1903-
return true
1904-
}
1905-
r, _ := utf8.DecodeRuneInString(lower[len(safe):])
1906-
return unicode.IsSpace(r)
1907-
}
1908-
1909-
func planModeUnsafeSafeCommandArg(cmd, safe string) string {
1910-
fields := strings.Fields(cmd)
1911-
base := strings.Fields(safe)
1912-
if len(fields) <= len(base) {
1913-
return ""
1914-
}
1915-
args := fields[len(base):]
1916-
lowerArgs := make([]string, len(args))
1917-
for i, arg := range args {
1918-
lowerArgs[i] = strings.ToLower(arg)
1919-
}
1920-
if strings.HasPrefix(safe, "git ") {
1921-
for _, arg := range lowerArgs {
1922-
if arg == "--output" || strings.HasPrefix(arg, "--output=") || arg == "--ext-diff" {
1923-
return arg
1924-
}
1925-
}
1926-
}
1927-
switch safe {
1928-
case "git grep":
1929-
for i, arg := range args {
1930-
lowerArg := lowerArgs[i]
1931-
if arg == "-O" || strings.HasPrefix(arg, "-O") || strings.HasPrefix(lowerArg, "--open-files-in-pager") {
1932-
return arg
1933-
}
1934-
}
1935-
case "find":
1936-
for _, arg := range lowerArgs {
1937-
if planModeFindWriteArgs[arg] {
1938-
return arg
1939-
}
1940-
}
1941-
case "go list", "go vet":
1942-
for _, arg := range lowerArgs {
1943-
if planModeGoWriteOrExecArgs[arg] || strings.HasPrefix(arg, "-mod=mod") || strings.HasPrefix(arg, "-modfile=") || strings.HasPrefix(arg, "-toolexec=") || strings.HasPrefix(arg, "-vettool=") {
1944-
return arg
1945-
}
1946-
}
1947-
}
1948-
return ""
1809+
decision := planmode.Policy{}.Decide(planmode.Call{Name: "bash", Args: args})
1810+
return decision.Blocked, decision.Message
19491811
}
19501812

19511813
func (a *Agent) repeatedSuccessBlock(call provider.ToolCall, t tool.Tool) (string, bool) {

0 commit comments

Comments
 (0)