From 2ba719ebab3f870f1383d70b17673b1da459feaa Mon Sep 17 00:00:00 2001 From: SivanCola Date: Fri, 3 Jul 2026 18:26:40 +0800 Subject: [PATCH 1/2] fix(planmode): allow declared read-only bash prefixes --- docs/GUIDE.md | 9 ++++ docs/GUIDE.zh-CN.md | 7 +++ docs/MIGRATING.md | 22 +++++--- docs/SPEC.md | 6 ++- internal/agent/agent.go | 69 ++++++++++++++----------- internal/agent/planmode_test.go | 8 +++ internal/boot/boot.go | 41 ++++++++++----- internal/boot/boot_test.go | 49 ++++++++++++++++++ internal/config/config.go | 4 ++ internal/config/render.go | 9 ++++ internal/config/render_test.go | 10 ++++ internal/planmode/policy.go | 88 ++++++++++++++++++++++++++++++-- internal/planmode/policy_test.go | 47 +++++++++++++++++ reasonix.example.toml | 2 + 14 files changed, 314 insertions(+), 57 deletions(-) diff --git a/docs/GUIDE.md b/docs/GUIDE.md index 4578e7e61b..8cc0337900 100644 --- a/docs/GUIDE.md +++ b/docs/GUIDE.md @@ -61,6 +61,7 @@ planner_max_steps = 0 # user/global only; planner read-only tool-call reasoning_language = "auto" # visible reasoning text: auto|zh|en # plan_mode_allowed_tools = ["custom_reader"] # extra read-only custom tools only; # # does not unlock blocked tools or unsafe bash +# plan_mode_read_only_commands = ["gh issue view", "gh pr diff"] # extra read-only shell prefixes for planning # planner_model = "deepseek-pro" # optional low-frequency planner # subagent_model = "deepseek-pro" # optional default for runAs=subagent skills # subagent_models = { review = "deepseek-pro", security_review = "deepseek-pro" } @@ -127,6 +128,14 @@ compatibility escape valve. It never unlocks known blocked plan-mode tools such as `bash`, `task`, writers, installers, or memory mutation tools, and it never bypasses bash's plan-mode safety checks. +Use `[agent].plan_mode_read_only_commands` when plan-mode research needs a +specific shell command that Reasonix cannot classify but you know is read-only, +such as `gh issue view` or an internal query CLI. Entries are concrete command +prefixes, not tool names: `["gh issue view"]` permits `gh issue view 4572`, while +`bash`, `sh`, and other shell interpreters are ignored. Shell operators, +redirection, command substitution, background execution, and unsafe built-in +command flags remain blocked while planning. + ### Environment variables Most day-to-day settings belong in `config.toml` or the global Reasonix `.env` diff --git a/docs/GUIDE.zh-CN.md b/docs/GUIDE.zh-CN.md index 387ffe3931..a52fd4940e 100644 --- a/docs/GUIDE.zh-CN.md +++ b/docs/GUIDE.zh-CN.md @@ -54,6 +54,7 @@ planner_max_steps = 0 # 仅用户/全局;规划器只读工具调 reasoning_language = "auto" # 可见思考过程语言:auto|zh|en # plan_mode_allowed_tools = ["custom_reader"] # 仅声明额外只读自定义工具; # # 不能解锁被计划模式阻断的工具或 unsafe bash +# plan_mode_read_only_commands = ["gh issue view", "gh pr diff"] # 计划模式额外只读 shell 前缀 # planner_model = "deepseek-pro" # 可选的低频规划器 # subagent_model = "deepseek-pro" # runAs=subagent skill 的默认模型 # subagent_models = { review = "deepseek-pro", security_review = "deepseek-pro" } @@ -116,6 +117,12 @@ planner / read-only research 可用的可信只读工具。优先使用 MCP 只 再在 plugin 上写 `trusted_read_only_tools`,`plan_mode_allowed_tools` 保留为兼容逃生阀。它不再解锁 `bash`、`task`、 写文件工具、安装器、记忆变更工具等计划模式已知阻断项,也不会绕过 bash 在计划模式下的安全检查。 +当计划阶段需要运行 Reasonix 尚不能自动分类、但你确认只读的 shell 查询命令时,使用 +`[agent].plan_mode_read_only_commands`,例如 `gh issue view`、`gh pr diff` 或内部只读查询 CLI。 +这里声明的是具体命令前缀,不是工具名:`["gh issue view"]` 会允许 `gh issue view 4572`, +但 `bash`、`sh` 等 shell 解释器前缀会被忽略。shell 操作符、重定向、命令替换、后台进程、 +以及内置命令里的写能力参数在计划模式下仍会被阻断。 + ### 环境变量 多数日常设置应写在 `config.toml` 或前文提到的 Reasonix 全局 `.env` 中。下面这些变量是进程级高级开关; diff --git a/docs/MIGRATING.md b/docs/MIGRATING.md index 051fd4bb51..b573d74f5a 100644 --- a/docs/MIGRATING.md +++ b/docs/MIGRATING.md @@ -103,14 +103,20 @@ and DeepSeek prefix-cache–oriented design. external tools**: `[agent].plan_mode_allowed_tools` now only declares extra read-only custom/external tools. It no longer unlocks known blocked plan-mode tools such as `bash`, `task`, writers, installers, or memory mutation tools, and - unsafe bash commands still remain blocked. An MCP/plugin tool whose read-only - status comes from the server's untrusted `readOnlyHint` is confirmed the first - time an interactive plan-mode run needs it; choose the persistent option to - write the plugin-level `trusted_read_only_tools` raw-name list. Auto/YOLO tool - approval does not answer this trust prompt, although a session or persistent - trust choice prevents repeat prompts for the same MCP tool. Non-interactive - runs still fail closed, so pre-seed `trusted_read_only_tools` or declare a - concrete `mcp____` when no user can approve. In the desktop MCP + unsafe bash commands still remain blocked. To migrate old + `plan_mode_allowed_tools = ["bash", ...]` configs, move concrete read-only + shell prefixes such as `gh issue view` or internal query CLIs to + `[agent].plan_mode_read_only_commands`; do not declare shell interpreters or + writer-capable commands there. Use `read_only_task` / `read_only_skill` instead + of trying to unlock `task` / `run_skill` while planning. An MCP/plugin tool + whose read-only status comes from the server's untrusted `readOnlyHint` is + confirmed the first time an interactive plan-mode run needs it; choose the + persistent option to write the plugin-level `trusted_read_only_tools` raw-name + list. Auto/YOLO tool approval does not answer this trust prompt, although a + session or persistent trust choice prevents repeat prompts for the same MCP + tool. Non-interactive runs still fail closed, so pre-seed + `trusted_read_only_tools` or declare a concrete `mcp____` when no + user can approve. In the desktop MCP panel, expand a server and use **Pre-trust read-only** for currently listed `readOnlyHint` tools, per-tool **Pre-trust** for audited readers, or **Untrust** to remove a tool; those actions write the same diff --git a/docs/SPEC.md b/docs/SPEC.md index 863d977cb0..1272baa00f 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -316,7 +316,10 @@ func (p Policy) Decide(toolName string, readOnly bool, args json.RawMessage) Dec decision: `auto`, `yolo`, and the approved-plan execution window do not answer it, but an explicit session grant still prevents repeat prompts for the same tool. Non-interactive sessions and declined approvals remain fail-closed. - Writers, installers, memory mutation, process + Bash is gated separately: built-in read-only commands and concrete prefixes + declared in `[agent].plan_mode_read_only_commands` may run, but shell + operators, background execution, shell interpreters, and unsafe arguments stay + blocked while planning. Writers, installers, memory mutation, process control, and `complete_step` (read-only yet post-approval only, so it self-reports plan-unsafe) are refused; the enforced invariant is PlanSafe ⇒ ReadOnly. An untrusted read-only MCP/plugin tool is therefore @@ -523,6 +526,7 @@ memory_compiler = { enabled = true, verbosity = "observe" } # user/global only reasoning_language = "auto" # visible reasoning text: auto|zh|en # plan_mode_allowed_tools = ["custom_reader"] # extra read-only declarations for custom tools; # # cannot unlock known blocked tools or unsafe bash +# plan_mode_read_only_commands = ["gh issue view", "gh pr diff"] # extra read-only shell prefixes for plan mode # planner_model = "deepseek-pro" # optional: two-model collaboration (low-frequency planner) # subagent_model = "deepseek-pro" # optional default for runAs=subagent skills # subagent_models = { review = "deepseek-pro", security_review = "deepseek-pro" } diff --git a/internal/agent/agent.go b/internal/agent/agent.go index 71dd8c8cda..9e5e4cb3b8 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -344,7 +344,8 @@ type Agent struct { // planModeAllowedTools declares extra custom tools that the centralized // plan-mode policy may treat as read-only. Known blocked tools still lose. // Populated from Options.PlanModeAllowedTools during construction. - planModeAllowedTools []string + planModeAllowedTools []string + planModeReadOnlyCommands []string // subagentDepth tracks the current agent's nesting depth. maxSubagentDepth // caps delegation; when reached, recursive agent/skill tools are excluded. @@ -781,6 +782,10 @@ type Options struct { // PlanModeAllowedTools names extra custom tools the plan-mode policy may treat // as read-only. It cannot unlock known blocked tools or unsafe bash commands. PlanModeAllowedTools []string + // PlanModeReadOnlyCommands names concrete shell command prefixes that plan mode + // may treat as read-only. Shell operators, background execution, and shell + // interpreter prefixes remain blocked. + PlanModeReadOnlyCommands []string // SubagentDepth is the current nesting depth for this agent. Root sessions are // depth 0; child subagents are depth 1. MaxSubagentDepth caps delegation. @@ -852,34 +857,35 @@ func New(prov provider.Provider, tools *tool.Registry, session *Session, opts Op subagentDepth = 0 } a := &Agent{ - prov: prov, - tools: tools, - session: session, - maxSteps: opts.MaxSteps, - maxStepsKey: maxStepsKey, - temperature: opts.Temperature, - pricing: opts.Pricing, - usageSource: usageSourceOrDefault(opts.UsageSource, event.UsageSourceExecutor), - sink: sink, - gate: gate, - planModeReadOnlyTrust: planModeReadOnlyTrust, - hooks: hooks, - jobs: opts.Jobs, - evidence: evidence.NewLedger(), - projectChecks: append([]instruction.VerifyCheck(nil), opts.ProjectChecks...), - contextWindow: opts.ContextWindow, - softCompactRatio: opts.SoftCompactRatio, - toolResultSnipRatio: opts.ToolResultSnipRatio, - compactRatio: opts.CompactRatio, - compactForceRatio: opts.CompactForceRatio, - recentKeep: opts.RecentKeep, - archiveDir: opts.ArchiveDir, - keepPolicy: opts.KeepPolicy, - planModeAllowedTools: append([]string(nil), opts.PlanModeAllowedTools...), - subagentDepth: subagentDepth, - maxSubagentDepth: maxSubagentDepth, - memoryCompiler: opts.MemoryCompiler, - memoryCompilerVerbosity: normalizeMemoryCompilerVerbosity(opts.MemoryCompilerVerbosity), + prov: prov, + tools: tools, + session: session, + maxSteps: opts.MaxSteps, + maxStepsKey: maxStepsKey, + temperature: opts.Temperature, + pricing: opts.Pricing, + usageSource: usageSourceOrDefault(opts.UsageSource, event.UsageSourceExecutor), + sink: sink, + gate: gate, + planModeReadOnlyTrust: planModeReadOnlyTrust, + hooks: hooks, + jobs: opts.Jobs, + evidence: evidence.NewLedger(), + projectChecks: append([]instruction.VerifyCheck(nil), opts.ProjectChecks...), + contextWindow: opts.ContextWindow, + softCompactRatio: opts.SoftCompactRatio, + toolResultSnipRatio: opts.ToolResultSnipRatio, + compactRatio: opts.CompactRatio, + compactForceRatio: opts.CompactForceRatio, + recentKeep: opts.RecentKeep, + archiveDir: opts.ArchiveDir, + keepPolicy: opts.KeepPolicy, + planModeAllowedTools: append([]string(nil), opts.PlanModeAllowedTools...), + planModeReadOnlyCommands: append([]string(nil), opts.PlanModeReadOnlyCommands...), + subagentDepth: subagentDepth, + maxSubagentDepth: maxSubagentDepth, + memoryCompiler: opts.MemoryCompiler, + memoryCompilerVerbosity: normalizeMemoryCompilerVerbosity(opts.MemoryCompilerVerbosity), } // 初始化分类器 if opts.UseMemoryCompilerLLMClassification && prov != nil { @@ -2273,7 +2279,10 @@ func planModeMCPTrustTarget(toolName string, t tool.Tool) (server, rawTool strin } func (a *Agent) planModeBlocked(toolName string, readOnly, untrusted bool, safety planmode.PlanSafety, args json.RawMessage) (blocked bool, message string) { - decision := planmode.Policy{AllowedTools: a.planModeAllowedTools}.Decide(planmode.Call{ + decision := planmode.Policy{ + AllowedTools: a.planModeAllowedTools, + ReadOnlyCommands: a.planModeReadOnlyCommands, + }.Decide(planmode.Call{ Name: toolName, ReadOnly: readOnly, Untrusted: untrusted, diff --git a/internal/agent/planmode_test.go b/internal/agent/planmode_test.go index b97d719b03..c19e3d5d64 100644 --- a/internal/agent/planmode_test.go +++ b/internal/agent/planmode_test.go @@ -152,6 +152,14 @@ func TestPlanModeAllowedToolsOverride(t *testing.T) { } } +func TestPlanModeReadOnlyCommandsOverride(t *testing.T) { + a := &Agent{planModeReadOnlyCommands: []string{"gh issue view"}} + blocked, msg := a.planModeBlocked("bash", false, false, planmode.PlanSafetyUnknown, bashCommandArgs(t, "gh issue view 4572 --json title")) + if blocked { + t.Fatalf("command in planModeReadOnlyCommands should not be blocked: %s", msg) + } +} + func TestPlanModeGenericWriterBlocked(t *testing.T) { blocked, msg := (&Agent{}).planModeBlocked("some_writer_tool", false, false, planmode.PlanSafetyUnknown, nil) if !blocked { diff --git a/internal/boot/boot.go b/internal/boot/boot.go index 0eea75d717..5933f17c23 100644 --- a/internal/boot/boot.go +++ b/internal/boot/boot.go @@ -172,11 +172,22 @@ func Build(ctx context.Context, opts Options) (*control.Controller, error) { // outlive a turn and are cancelled by Controller.Close. sink := event.Sync(opts.Sink) - if ignored := (planmode.Policy{AllowedTools: cfg.Agent.PlanModeAllowedTools}).IgnoredAllowedTools(); len(ignored) > 0 { + planModePolicy := planmode.Policy{ + AllowedTools: cfg.Agent.PlanModeAllowedTools, + ReadOnlyCommands: cfg.Agent.PlanModeReadOnlyCommands, + } + if ignored := planModePolicy.IgnoredAllowedTools(); len(ignored) > 0 { + sink.Emit(event.Event{ + Kind: event.Notice, + Level: event.LevelWarn, + Text: fmt.Sprintf("plan_mode_allowed_tools ignored known blocked entries: %s; this setting only declares extra read-only custom tools and cannot unlock known blocked tools or unsafe bash. For shell exploration, declare concrete read-only prefixes in plan_mode_read_only_commands (for example \"gh issue view\"); use read_only_task/read_only_skill instead of task/run_skill while planning.", strings.Join(ignored, ", ")), + }) + } + if ignored := planModePolicy.IgnoredReadOnlyCommands(); len(ignored) > 0 { sink.Emit(event.Event{ Kind: event.Notice, Level: event.LevelWarn, - Text: fmt.Sprintf("plan_mode_allowed_tools ignored known blocked entries: %s; this setting only declares extra read-only custom tools and cannot unlock known blocked tools or unsafe bash", strings.Join(ignored, ", ")), + Text: fmt.Sprintf("plan_mode_read_only_commands ignored unsafe entries: %s; declare concrete read-only commands such as \"gh issue view\", not shell interpreters or malformed command prefixes", strings.Join(ignored, ", ")), }) } if migErr != nil { @@ -998,6 +1009,7 @@ func Build(ctx context.Context, opts Options) (*control.Controller, error) { KeepPolicy: keepPolicy, ReasoningLanguage: cfg.ReasoningLanguage(), PlanModeAllowedTools: cfg.Agent.PlanModeAllowedTools, + PlanModeReadOnlyCommands: cfg.Agent.PlanModeReadOnlyCommands, SubagentDepth: 0, MaxSubagentDepth: maxSubagentDepth, MemoryCompiler: memCompiler, @@ -1039,18 +1051,19 @@ func Build(ctx context.Context, opts Options) (*control.Controller, error) { plannerSess := agent.NewSession(agent.PlannerPromptWithContext(mem.Block())) plannerTools := agent.PlannerToolRegistry(reg) runner = agent.NewCoordinator(plannerProv, plannerSess, pe.Price, plannerTools, agent.Options{ - MaxSteps: cfg.Agent.PlannerMaxSteps, - MaxStepsKey: "agent.planner_max_steps", - Gate: headlessGate, - ContextWindow: pe.ContextWindow, - SoftCompactRatio: cfg.Agent.SoftCompactRatio, - ToolResultSnipRatio: cfg.Agent.ToolResultSnipRatio, - CompactRatio: cfg.Agent.CompactRatio, - CompactForceRatio: cfg.Agent.CompactForceRatio, - RecentKeep: cfg.Agent.RecentKeep, - ArchiveDir: config.ArchiveDir(), - KeepPolicy: keepPolicy, - ReasoningLanguage: cfg.ReasoningLanguage(), + MaxSteps: cfg.Agent.PlannerMaxSteps, + MaxStepsKey: "agent.planner_max_steps", + Gate: headlessGate, + ContextWindow: pe.ContextWindow, + SoftCompactRatio: cfg.Agent.SoftCompactRatio, + ToolResultSnipRatio: cfg.Agent.ToolResultSnipRatio, + CompactRatio: cfg.Agent.CompactRatio, + CompactForceRatio: cfg.Agent.CompactForceRatio, + RecentKeep: cfg.Agent.RecentKeep, + ArchiveDir: config.ArchiveDir(), + KeepPolicy: keepPolicy, + ReasoningLanguage: cfg.ReasoningLanguage(), + PlanModeReadOnlyCommands: cfg.Agent.PlanModeReadOnlyCommands, }, executor, cfg.Agent.Temperature, sink, control.NewPlannerGate(classifier)) label = entry.Model + " + planner " + pe.Model } diff --git a/internal/boot/boot_test.go b/internal/boot/boot_test.go index 48c5600194..6d73c68dc4 100644 --- a/internal/boot/boot_test.go +++ b/internal/boot/boot_test.go @@ -1988,12 +1988,61 @@ model = "x" if strings.Contains(notice.Text, "custom_reader") { t.Fatalf("warning should name ignored entries only, got %q", notice.Text) } + if !strings.Contains(notice.Text, "plan_mode_read_only_commands") || !strings.Contains(notice.Text, "read_only_task/read_only_skill") { + t.Fatalf("warning should suggest plan-mode migration paths, got %q", notice.Text) + } return } } t.Fatalf("missing ignored plan_mode_allowed_tools warning; got %+v", notices) } +func TestBuildWarnsIgnoredPlanModeReadOnlyCommands(t *testing.T) { + isolateConfigHome(t) + dir := robustTempDir(t) + t.Chdir(dir) + + registerBootTokenProfileTestProvider() + prov := testutil.NewMock("plan-mode-read-only-commands", testutil.Turn{Text: "done"}) + setBootTokenProfileTestProvider(t, prov) + writeFile(t, dir, "reasonix.toml", ` +default_model = "test-model" + +[agent] +system_prompt = "BASE" +plan_mode_read_only_commands = ["bash", "gh issue view"] + +[[providers]] +name = "test-model" +kind = "boot-token-profile-test" +model = "x" +`) + + var notices []event.Event + sink := event.FuncSink(func(e event.Event) { + if e.Kind == event.Notice { + notices = append(notices, e) + } + }) + + ctrl, err := Build(context.Background(), Options{Sink: sink}) + if err != nil { + t.Fatalf("Build: %v", err) + } + defer ctrl.Close() + + for _, notice := range notices { + if notice.Level == event.LevelWarn && strings.Contains(notice.Text, "plan_mode_read_only_commands") && strings.Contains(notice.Text, "bash") { + ignoredList := strings.TrimSpace(strings.SplitN(strings.TrimPrefix(notice.Text, "plan_mode_read_only_commands ignored unsafe entries:"), ";", 2)[0]) + if ignoredList != "bash" { + t.Fatalf("warning should name ignored command prefixes only, got %q from %q", ignoredList, notice.Text) + } + return + } + } + t.Fatalf("missing ignored plan_mode_read_only_commands warning; got %+v", notices) +} + func TestBuildTokenEconomyWebFetchConnectorHonorsDisabledBuiltin(t *testing.T) { isolateConfigHome(t) dir := robustTempDir(t) diff --git a/internal/config/config.go b/internal/config/config.go index b2917d88e7..a4c498b173 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -910,6 +910,10 @@ type AgentConfig struct { // PlanModeAllowedTools names extra custom tools the plan-mode policy may treat // as read-only. It cannot unlock known blocked tools or unsafe bash commands. PlanModeAllowedTools []string `toml:"plan_mode_allowed_tools"` + // PlanModeReadOnlyCommands names concrete shell command prefixes that plan mode + // may treat as read-only. Shell operators, background execution, and shell + // interpreter prefixes remain blocked. + PlanModeReadOnlyCommands []string `toml:"plan_mode_read_only_commands"` // MemoryCompiler controls the v5 execution-memory compiler. Missing configs // default to enabled so users get the self-improving planner unless they opt // out explicitly. diff --git a/internal/config/render.go b/internal/config/render.go index 02421a05fb..f1da35cade 100644 --- a/internal/config/render.go +++ b/internal/config/render.go @@ -238,6 +238,11 @@ func RenderTOMLForScope(c *Config, scope RenderScope) string { } else { b.WriteString("# plan_mode_allowed_tools = [\"custom_reader\"] # extra read-only declarations; cannot unlock known blocked tools or unsafe bash\n") } + if len(c.Agent.PlanModeReadOnlyCommands) > 0 { + fmt.Fprintf(&b, "plan_mode_read_only_commands = %s # concrete read-only shell prefixes available while planning\n", renderStringArray(c.Agent.PlanModeReadOnlyCommands)) + } else { + b.WriteString("# plan_mode_read_only_commands = [\"gh issue view\", \"gh pr diff\"] # concrete read-only shell prefixes; does not allow shell operators or shell interpreters\n") + } if c.Agent.PlannerModel != "" { fmt.Fprintf(&b, "planner_model = %q # low-frequency planner (two-model collaboration)\n", c.Agent.PlannerModel) } else { @@ -729,6 +734,10 @@ func RenderTOMLProjectDelta(c *Config) string { fmt.Fprintf(&agentBuf, "plan_mode_allowed_tools = %s\n", renderStringArray(c.Agent.PlanModeAllowedTools)) anyAgent = true } + if len(c.Agent.PlanModeReadOnlyCommands) > 0 && !reflect.DeepEqual(c.Agent.PlanModeReadOnlyCommands, d.Agent.PlanModeReadOnlyCommands) { + fmt.Fprintf(&agentBuf, "plan_mode_read_only_commands = %s\n", renderStringArray(c.Agent.PlanModeReadOnlyCommands)) + anyAgent = true + } if c.Agent.PlannerModel != "" && c.Agent.PlannerModel != d.Agent.PlannerModel { fmt.Fprintf(&agentBuf, "planner_model = %q\n", c.Agent.PlannerModel) anyAgent = true diff --git a/internal/config/render_test.go b/internal/config/render_test.go index 20e047b290..b2640f6ea3 100644 --- a/internal/config/render_test.go +++ b/internal/config/render_test.go @@ -442,6 +442,7 @@ func TestRenderTOMLRoundTrips(t *testing.T) { func TestRenderTOMLDocumentsPlanModeAllowedTools(t *testing.T) { cfg := Default() cfg.Agent.PlanModeAllowedTools = []string{"custom_reader"} + cfg.Agent.PlanModeReadOnlyCommands = []string{"gh issue view"} rendered := RenderTOML(cfg) if !strings.Contains(rendered, `plan_mode_allowed_tools = ["custom_reader"]`) { @@ -458,6 +459,15 @@ func TestRenderTOMLDocumentsPlanModeAllowedTools(t *testing.T) { if !reflect.DeepEqual(got.Agent.PlanModeAllowedTools, cfg.Agent.PlanModeAllowedTools) { t.Fatalf("PlanModeAllowedTools round trip = %v, want %v", got.Agent.PlanModeAllowedTools, cfg.Agent.PlanModeAllowedTools) } + if !strings.Contains(rendered, `plan_mode_read_only_commands = ["gh issue view"]`) { + t.Fatalf("rendered config should preserve plan_mode_read_only_commands:\n%s", rendered) + } + if !strings.Contains(rendered, "concrete read-only shell prefixes") { + t.Fatalf("rendered config should document plan_mode_read_only_commands semantics:\n%s", rendered) + } + if !reflect.DeepEqual(got.Agent.PlanModeReadOnlyCommands, cfg.Agent.PlanModeReadOnlyCommands) { + t.Fatalf("PlanModeReadOnlyCommands round trip = %v, want %v", got.Agent.PlanModeReadOnlyCommands, cfg.Agent.PlanModeReadOnlyCommands) + } } func TestRenderTOMLDocumentsPluginTrustedReadOnlyTools(t *testing.T) { diff --git a/internal/planmode/policy.go b/internal/planmode/policy.go index c467eb111a..b846220948 100644 --- a/internal/planmode/policy.go +++ b/internal/planmode/policy.go @@ -55,7 +55,8 @@ type Decision struct { // Policy is the single plan-mode stage policy. type Policy struct { - AllowedTools []string + AllowedTools []string + ReadOnlyCommands []string } var knownBlockedTools = map[string]bool{ @@ -91,6 +92,15 @@ var alwaysAllowedTools = map[string]bool{ "todo_write": true, } +var unsafeDeclaredReadOnlyCommandBases = map[string]bool{ + "bash": true, + "sh": true, + "zsh": true, + "fish": true, + "powershell": true, + "pwsh": 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 @@ -141,7 +151,7 @@ var goWriteOrExecArgs = map[string]bool{ func (p Policy) Decide(call Call) Decision { name := strings.TrimSpace(call.Name) if name == "bash" { - return decideBash(call.Args) + return decideBash(call.Args, p.ReadOnlyCommands) } if knownBlockedTools[name] { return blockKnown(name) @@ -197,6 +207,31 @@ func (p Policy) IgnoredAllowedTools() []string { 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 unsafeDeclaredReadOnlyCommandBases[strings.ToLower(fields[0])] { + 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 { @@ -279,7 +314,7 @@ func Classify(name string, readOnly bool, safety PlanSafety) Class { return ClassDefaultBlocked } -func decideBash(args json.RawMessage) Decision { +func decideBash(args json.RawMessage, readOnlyCommands []string) Decision { var p struct { Command string `json:"command"` RunInBackground bool `json:"run_in_background"` @@ -314,9 +349,17 @@ func decideBash(args json.RawMessage) Decision { base, sub, ok := shellsafe.CommandIsReadOnly(cmd) if !ok { + if ok, malformed := declaredReadOnlyCommand(cmd, 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{} + } 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, then exit plan mode to run this command.", cmd), + 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.", cmd), } } if arg, malformed := unsafePlanModeArg(cmd, base, sub); malformed != "" { @@ -333,6 +376,43 @@ func decideBash(args json.RawMessage) Decision { 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 unsafeDeclaredReadOnlyCommandBases[strings.ToLower(prefixFields[0])] { + 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, "" +} + // 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, diff --git a/internal/planmode/policy_test.go b/internal/planmode/policy_test.go index 69b7385cda..08f56a6ffb 100644 --- a/internal/planmode/policy_test.go +++ b/internal/planmode/policy_test.go @@ -120,6 +120,53 @@ func TestDecideStillValidatesBashArgumentsWhenOverridden(t *testing.T) { } } +func TestDecideAllowsDeclaredReadOnlyBashCommandPrefix(t *testing.T) { + p := Policy{ReadOnlyCommands: []string{"gh issue view", "internal-report --read"}} + args, err := json.Marshal(map[string]any{"command": "gh issue view 4572 --json title,body"}) + if err != nil { + t.Fatal(err) + } + + if decision := p.Decide(Call{Name: "bash", Args: args}); decision.Blocked { + t.Fatalf("declared read-only command should be allowed in plan mode: %s", decision.Message) + } + + args, err = json.Marshal(map[string]any{"command": "internal-report --read service-a"}) + if err != nil { + t.Fatal(err) + } + if decision := p.Decide(Call{Name: "bash", Args: args}); decision.Blocked { + t.Fatalf("declared read-only command with flags should be allowed in plan mode: %s", decision.Message) + } +} + +func TestDecideDeclaredReadOnlyBashCommandStillBlocksShellSyntax(t *testing.T) { + p := Policy{ReadOnlyCommands: []string{"gh issue view"}} + args, err := json.Marshal(map[string]any{"command": "gh issue view 4572 && rm -rf /"}) + if err != nil { + t.Fatal(err) + } + + if decision := p.Decide(Call{Name: "bash", Args: args}); !decision.Blocked { + t.Fatal("declared read-only command must not bypass shell syntax checks") + } +} + +func TestDecideDeclaredReadOnlyBashCommandIgnoresShellInterpreters(t *testing.T) { + p := Policy{ReadOnlyCommands: []string{"bash"}} + args, err := json.Marshal(map[string]any{"command": "bash run-anything"}) + if err != nil { + t.Fatal(err) + } + + if decision := p.Decide(Call{Name: "bash", Args: args}); !decision.Blocked { + t.Fatal("plan_mode_read_only_commands must not reopen shell interpreters") + } + if got := p.IgnoredReadOnlyCommands(); len(got) != 1 || got[0] != "bash" { + t.Fatalf("IgnoredReadOnlyCommands() = %v, want [bash]", got) + } +} + func TestDecideBlocksQuotedBashWriteArguments(t *testing.T) { tests := []struct { name string diff --git a/reasonix.example.toml b/reasonix.example.toml index d4541d8d62..660f99dd46 100644 --- a/reasonix.example.toml +++ b/reasonix.example.toml @@ -38,6 +38,8 @@ auto_plan = "off" # user-level only; ignored in project reasonix.toml memory_compiler = { enabled = true } # user-level only; Memory v5 execution compiler # reasoning_language = "auto" # visible reasoning text: auto|zh|en # auto_plan_classifier = "deepseek-flash" # optional; only used for borderline tasks +# plan_mode_allowed_tools = ["custom_reader"] # extra read-only custom tools; cannot unlock bash/task/run_skill +# plan_mode_read_only_commands = ["gh issue view", "gh pr diff"] # extra read-only shell prefixes for plan mode soft_compact_ratio = 0.5 # notice only; keeps the cache-first prefix intact tool_result_snip_ratio = 0.6 # snip stale tool results before summary compaction compact_ratio = 0.8 # try compacting when prompt reaches this fraction From ee20852d1b6238217cfafe93d5ddb45e071f5ad9 Mon Sep 17 00:00:00 2001 From: SivanCola Date: Fri, 3 Jul 2026 19:09:45 +0800 Subject: [PATCH 2/2] Add plan-mode bash read-only trust approval --- .../frontend/src/components/ApprovalModal.tsx | 4 +- docs/GUIDE.md | 6 +- docs/GUIDE.zh-CN.md | 4 +- docs/MIGRATING.md | 7 +- docs/SPEC.md | 13 +- internal/agent/agent.go | 82 ++++++++-- internal/agent/planmode_test.go | 80 ++++++++++ internal/boot/boot.go | 52 ++++++- internal/boot/boot_test.go | 60 ++++++++ internal/cli/chat_tui.go | 6 + internal/control/approval.go | 48 ++++-- internal/control/controller.go | 140 +++++++++++++----- internal/control/controller_test.go | 116 +++++++++++++++ internal/control/yolo_test.go | 58 ++++++++ internal/i18n/i18n.go | 64 ++++---- internal/i18n/messages_en.go | 80 +++++----- internal/i18n/messages_zh.go | 80 +++++----- internal/i18n/messages_zh_tw.go | 76 +++++----- internal/planmode/policy.go | 126 +++++++++++++++- internal/planmode/policy_test.go | 42 +++++- 20 files changed, 923 insertions(+), 221 deletions(-) diff --git a/desktop/frontend/src/components/ApprovalModal.tsx b/desktop/frontend/src/components/ApprovalModal.tsx index 1263921ce4..c557320d55 100644 --- a/desktop/frontend/src/components/ApprovalModal.tsx +++ b/desktop/frontend/src/components/ApprovalModal.tsx @@ -52,6 +52,8 @@ export function ApprovalModal({ }) { const t = useT(); const isPlanApproval = approval.tool === "exit_plan_mode"; + const isPlanModeReadOnlyCommand = approval.tool === "plan_mode_read_only_command"; + const toolLabel = isPlanModeReadOnlyCommand ? "bash" : approval.tool; const isFreshHumanApproval = requiresFreshHumanApproval(approval.tool); const subject = approval.subject.trim(); const subjectSummary = subject.split(/\r?\n/).find((line) => line.trim())?.trim() ?? ""; @@ -298,7 +300,7 @@ export function ApprovalModal({ barRef={cardRef} titleId="tool-approval-title" title={t("approval.toolPending")} - badges={{approval.tool}} + badges={{toolLabel}} meta={toolMeta} headerActions={ <> diff --git a/docs/GUIDE.md b/docs/GUIDE.md index 8cc0337900..8446fbe2f6 100644 --- a/docs/GUIDE.md +++ b/docs/GUIDE.md @@ -134,7 +134,11 @@ such as `gh issue view` or an internal query CLI. Entries are concrete command prefixes, not tool names: `["gh issue view"]` permits `gh issue view 4572`, while `bash`, `sh`, and other shell interpreters are ignored. Shell operators, redirection, command substitution, background execution, and unsafe built-in -command flags remain blocked while planning. +command flags remain blocked while planning. In interactive plan mode, Reasonix +can also ask you to trust a concrete unknown query prefix the first time it is +needed; the persistent choice writes the same +`[agent].plan_mode_read_only_commands` entry. Auto/YOLO approval never answers +this trust prompt. ### Environment variables diff --git a/docs/GUIDE.zh-CN.md b/docs/GUIDE.zh-CN.md index a52fd4940e..0b57581c6f 100644 --- a/docs/GUIDE.zh-CN.md +++ b/docs/GUIDE.zh-CN.md @@ -121,7 +121,9 @@ planner / read-only research 可用的可信只读工具。优先使用 MCP 只 `[agent].plan_mode_read_only_commands`,例如 `gh issue view`、`gh pr diff` 或内部只读查询 CLI。 这里声明的是具体命令前缀,不是工具名:`["gh issue view"]` 会允许 `gh issue view 4572`, 但 `bash`、`sh` 等 shell 解释器前缀会被忽略。shell 操作符、重定向、命令替换、后台进程、 -以及内置命令里的写能力参数在计划模式下仍会被阻断。 +以及内置命令里的写能力参数在计划模式下仍会被阻断。交互式计划模式第一次需要某个未知查询前缀时, +Reasonix 也可以提示你确认是否信任它为只读;选择持久信任会写入同一个 +`[agent].plan_mode_read_only_commands` 配置。Auto/YOLO 审批不会替用户回答这个信任提示。 ### 环境变量 diff --git a/docs/MIGRATING.md b/docs/MIGRATING.md index b573d74f5a..c7dfcca003 100644 --- a/docs/MIGRATING.md +++ b/docs/MIGRATING.md @@ -107,8 +107,11 @@ and DeepSeek prefix-cache–oriented design. `plan_mode_allowed_tools = ["bash", ...]` configs, move concrete read-only shell prefixes such as `gh issue view` or internal query CLIs to `[agent].plan_mode_read_only_commands`; do not declare shell interpreters or - writer-capable commands there. Use `read_only_task` / `read_only_skill` instead - of trying to unlock `task` / `run_skill` while planning. An MCP/plugin tool + writer-capable commands there. Interactive plan-mode runs can also ask you to + trust a concrete unknown query prefix once, and the persistent choice writes + the same `plan_mode_read_only_commands` entry. Auto/YOLO tool approval does + not answer this bash trust prompt. Use `read_only_task` / `read_only_skill` + instead of trying to unlock `task` / `run_skill` while planning. An MCP/plugin tool whose read-only status comes from the server's untrusted `readOnlyHint` is confirmed the first time an interactive plan-mode run needs it; choose the persistent option to write the plugin-level `trusted_read_only_tools` raw-name diff --git a/docs/SPEC.md b/docs/SPEC.md index 1272baa00f..d37f4a12d0 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -317,10 +317,15 @@ func (p Policy) Decide(toolName string, readOnly bool, args json.RawMessage) Dec it, but an explicit session grant still prevents repeat prompts for the same tool. Non-interactive sessions and declined approvals remain fail-closed. Bash is gated separately: built-in read-only commands and concrete prefixes - declared in `[agent].plan_mode_read_only_commands` may run, but shell - operators, background execution, shell interpreters, and unsafe arguments stay - blocked while planning. Writers, installers, memory mutation, process - control, and `complete_step` (read-only yet post-approval only, so it + declared in `[agent].plan_mode_read_only_commands` may run. Interactive + controllers may also ask once before running an unknown query-shaped prefix + and may remember a persistent approval as the same + `plan_mode_read_only_commands` entry. This bash trust prompt is also a fresh + user decision: `auto`, `yolo`, and the approved-plan execution window do not + answer it, while explicit session/persistent trust prevents repeat prompts for + that prefix. Shell operators, background execution, shell interpreters, and + unsafe arguments stay blocked while planning. Writers, installers, memory + mutation, process control, and `complete_step` (read-only yet post-approval only, so it self-reports plan-unsafe) are refused; the enforced invariant is PlanSafe ⇒ ReadOnly. An untrusted read-only MCP/plugin tool is therefore blocked until the user approves or pre-trusts it, and it is excluded from diff --git a/internal/agent/agent.go b/internal/agent/agent.go index 9e5e4cb3b8..6c668c18da 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -151,19 +151,25 @@ type Gate interface { Check(ctx context.Context, toolName string, args json.RawMessage, readOnly bool) (allow bool, reason string, err error) } -// PlanModeReadOnlyTrustRequest describes an external read-only hint that plan -// mode will not trust without a user decision. ToolName is the provider-visible -// name; ServerName and RawToolName are the MCP identifiers persisted in config. +const PlanModeReadOnlyCommandApprovalTool = "plan_mode_read_only_command" + +// PlanModeReadOnlyTrustRequest describes a read-only claim that plan mode will +// not trust without a user decision. For MCP, ServerName and RawToolName are the +// identifiers persisted in config. For bash, Command is the concrete attempted +// command and Prefix is the command prefix to trust for planning. type PlanModeReadOnlyTrustRequest struct { ToolName string ServerName string RawToolName string + Command string + Prefix string Args json.RawMessage } -// PlanModeReadOnlyTrustGate optionally confirms an MCP server's self-reported -// read-only hint at execution time. It is separate from Gate because the -// plan-mode check runs before ordinary permission policy. +// PlanModeReadOnlyTrustGate optionally confirms MCP read-only hints and +// user-approved bash read-only command prefixes at execution time. It is +// separate from Gate because the plan-mode check runs before ordinary permission +// policy. type PlanModeReadOnlyTrustGate interface { CheckPlanModeReadOnlyTrust(ctx context.Context, req PlanModeReadOnlyTrustRequest) (allow bool, reason string, err error) } @@ -569,7 +575,8 @@ func (a *Agent) SetGate(g Gate) { } // SetPlanModeReadOnlyTrustGate installs the optional confirmation path for MCP -// tools whose read-only flag comes from an external readOnlyHint. +// tools whose read-only flag comes from an external readOnlyHint and bash +// commands the user may trust as plan-mode read-only. func (a *Agent) SetPlanModeReadOnlyTrustGate(g PlanModeReadOnlyTrustGate) { if nilutil.IsNil(g) { g = nil @@ -2080,6 +2087,7 @@ func (a *Agent) executeOne(ctx context.Context, call provider.ToolCall) toolOutc errMsg: "blocked: fresh read required", } } + planModeTrustedReadOnly := false if a.planMode.Load() { // Translate the tool's optional plan-mode self-report into the policy's // tri-state. Mirrors the t.(tool.Previewer) assertion precedent below. @@ -2097,10 +2105,18 @@ func (a *Agent) executeOne(ctx context.Context, call provider.ToolCall) toolOutc if u, ok := t.(tool.PlanModeUntrustedReadOnly); ok { untrusted = u.PlanModeUntrustedReadOnly() } - if blocked, msg := a.planModeBlocked(call.Name, t.ReadOnly(), untrusted, safety, json.RawMessage(call.Arguments)); blocked { + if decision := a.planModeDecision(call.Name, t.ReadOnly(), untrusted, safety, json.RawMessage(call.Arguments)); decision.Blocked { trustAllowed := false - if t.ReadOnly() && untrusted && safety != planmode.PlanSafetyUnsafe { - if allow, outcome, handled := a.checkPlanModeReadOnlyTrust(ctx, call, t); handled { + if decision.ReadOnlyCommandTrust != nil { + if allow, outcome, handled := a.checkPlanModeBashReadOnlyTrust(ctx, call, decision.ReadOnlyCommandTrust); handled { + if !allow { + return outcome + } + trustAllowed = true + planModeTrustedReadOnly = true + } + } else if t.ReadOnly() && untrusted && safety != planmode.PlanSafetyUnsafe { + if allow, outcome, handled := a.checkPlanModeMCPReadOnlyTrust(ctx, call, t); handled { if !allow { return outcome } @@ -2109,7 +2125,7 @@ func (a *Agent) executeOne(ctx context.Context, call provider.ToolCall) toolOutc } if !trustAllowed { return toolOutcome{ - output: msg, + output: decision.Message, blocked: true, errMsg: "blocked: plan mode is read-only", } @@ -2117,7 +2133,8 @@ func (a *Agent) executeOne(ctx context.Context, call provider.ToolCall) toolOutc } } if a.gate != nil { - allow, reason, err := a.gate.Check(ctx, call.Name, json.RawMessage(call.Arguments), t.ReadOnly()) + readOnly := t.ReadOnly() || planModeTrustedReadOnly + allow, reason, err := a.gate.Check(ctx, call.Name, json.RawMessage(call.Arguments), readOnly) if err != nil { return toolOutcome{ output: fmt.Sprintf("blocked: %s (%v)", reason, err), @@ -2231,7 +2248,7 @@ func (a *Agent) executeOne(ctx context.Context, call provider.ToolCall) toolOutc return toolOutcome{output: body, truncated: truncMsg != "", truncMsg: truncMsg} } -func (a *Agent) checkPlanModeReadOnlyTrust(ctx context.Context, call provider.ToolCall, t tool.Tool) (bool, toolOutcome, bool) { +func (a *Agent) checkPlanModeMCPReadOnlyTrust(ctx context.Context, call provider.ToolCall, t tool.Tool) (bool, toolOutcome, bool) { if a.planModeReadOnlyTrust == nil { return false, toolOutcome{}, false } @@ -2266,6 +2283,37 @@ func (a *Agent) checkPlanModeReadOnlyTrust(ctx context.Context, call provider.To return true, toolOutcome{}, true } +func (a *Agent) checkPlanModeBashReadOnlyTrust(ctx context.Context, call provider.ToolCall, trust *planmode.ReadOnlyCommandTrust) (bool, toolOutcome, bool) { + if a.planModeReadOnlyTrust == nil || trust == nil || strings.TrimSpace(trust.Prefix) == "" { + return false, toolOutcome{}, false + } + req := PlanModeReadOnlyTrustRequest{ + ToolName: PlanModeReadOnlyCommandApprovalTool, + Command: trust.Command, + Prefix: trust.Prefix, + Args: json.RawMessage(call.Arguments), + } + allow, reason, err := a.planModeReadOnlyTrust.CheckPlanModeReadOnlyTrust(ctx, req) + if err != nil { + return false, toolOutcome{ + output: fmt.Sprintf("blocked: plan-mode read-only command trust approval aborted (%v)", err), + blocked: true, + errMsg: fmt.Sprintf("blocked: %v", err), + }, true + } + if !allow { + if strings.TrimSpace(reason) == "" { + reason = "the user declined to trust this bash command as read-only for plan mode — do not retry it; continue planning with other trusted read-only tools." + } + return false, toolOutcome{ + output: "blocked: " + reason, + blocked: true, + errMsg: "blocked by plan-mode bash read-only trust", + }, true + } + return true, toolOutcome{}, true +} + func planModeMCPTrustTarget(toolName string, t tool.Tool) (server, rawTool string, ok bool) { if meta, metaOK := t.(tool.MCPMetadata); metaOK { server = strings.TrimSpace(meta.MCPServerName()) @@ -2279,7 +2327,12 @@ func planModeMCPTrustTarget(toolName string, t tool.Tool) (server, rawTool strin } func (a *Agent) planModeBlocked(toolName string, readOnly, untrusted bool, safety planmode.PlanSafety, args json.RawMessage) (blocked bool, message string) { - decision := planmode.Policy{ + decision := a.planModeDecision(toolName, readOnly, untrusted, safety, args) + return decision.Blocked, decision.Message +} + +func (a *Agent) planModeDecision(toolName string, readOnly, untrusted bool, safety planmode.PlanSafety, args json.RawMessage) planmode.Decision { + return planmode.Policy{ AllowedTools: a.planModeAllowedTools, ReadOnlyCommands: a.planModeReadOnlyCommands, }.Decide(planmode.Call{ @@ -2289,7 +2342,6 @@ func (a *Agent) planModeBlocked(toolName string, readOnly, untrusted bool, safet Safety: safety, Args: args, }) - return decision.Blocked, decision.Message } func planModeBashBlocked(args json.RawMessage) (bool, string) { diff --git a/internal/agent/planmode_test.go b/internal/agent/planmode_test.go index c19e3d5d64..ffa5b66a8e 100644 --- a/internal/agent/planmode_test.go +++ b/internal/agent/planmode_test.go @@ -221,6 +221,18 @@ func (g *fakePlanModeReadOnlyTrustGate) CheckPlanModeReadOnlyTrust(ctx context.C return g.allow, g.reason, nil } +type readOnlyRecordingGate struct { + readOnly []bool +} + +func (g *readOnlyRecordingGate) Check(ctx context.Context, toolName string, args json.RawMessage, readOnly bool) (bool, string, error) { + g.readOnly = append(g.readOnly, readOnly) + if !readOnly { + return false, "expected read-only", nil + } + return true, "", nil +} + // TestPlanModeUntrustedReadOnlyToolFailsClosed proves the gate does NOT trust an // MCP tool's self-reported readOnlyHint: a ReadOnly()==true external tool is // still fail-closed in plan mode, and runs only once declared in @@ -309,6 +321,74 @@ func TestPlanModeUnsafeUntrustedReadOnlyToolDoesNotAskForTrust(t *testing.T) { } } +func TestPlanModeUnknownBashReadOnlyCommandCanAskForTrust(t *testing.T) { + reg := tool.NewRegistry() + reg.Add(fakeTool{name: "bash", readOnly: false}) + gate := &fakePlanModeReadOnlyTrustGate{allow: true} + a := New(nil, reg, NewSession(""), Options{PlanModeReadOnlyTrustGate: gate}, event.Discard) + a.SetPlanMode(true) + + out := a.executeOne(context.Background(), provider.ToolCall{Name: "bash", Arguments: string(bashCommandArgs(t, "gh issue view 4572 --json title"))}) + if strings.HasPrefix(out.output, "blocked:") || !strings.Contains(out.output, "done") { + t.Fatalf("trusted unknown bash query should run, got: %q", out.output) + } + if gate.calls != 1 { + t.Fatalf("trust gate calls = %d, want 1", gate.calls) + } + if gate.req.ToolName != PlanModeReadOnlyCommandApprovalTool || gate.req.Command != "gh issue view 4572 --json title" || gate.req.Prefix != "gh issue view" { + t.Fatalf("trust request = %+v, want bash command prefix request", gate.req) + } +} + +func TestPlanModeTrustedUnknownBashCommandReachesGateAsReadOnly(t *testing.T) { + reg := tool.NewRegistry() + reg.Add(fakeTool{name: "bash", readOnly: false}) + trustGate := &fakePlanModeReadOnlyTrustGate{allow: true} + permissionGate := &readOnlyRecordingGate{} + a := New(nil, reg, NewSession(""), Options{ + Gate: permissionGate, + PlanModeReadOnlyTrustGate: trustGate, + }, event.Discard) + a.SetPlanMode(true) + + out := a.executeOne(context.Background(), provider.ToolCall{Name: "bash", Arguments: string(bashCommandArgs(t, "gh issue view 4572"))}) + if strings.HasPrefix(out.output, "blocked:") || !strings.Contains(out.output, "done") { + t.Fatalf("trusted unknown bash query should run through the normal gate as read-only, got: %q", out.output) + } + if len(permissionGate.readOnly) != 1 || !permissionGate.readOnly[0] { + t.Fatalf("permission gate readOnly calls = %v, want [true]", permissionGate.readOnly) + } +} + +func TestPlanModeUnknownBashReadOnlyCommandDeclineBlocks(t *testing.T) { + reg := tool.NewRegistry() + reg.Add(fakeTool{name: "bash", readOnly: false}) + gate := &fakePlanModeReadOnlyTrustGate{allow: false, reason: "not read-only"} + a := New(nil, reg, NewSession(""), Options{PlanModeReadOnlyTrustGate: gate}, event.Discard) + a.SetPlanMode(true) + + out := a.executeOne(context.Background(), provider.ToolCall{Name: "bash", Arguments: string(bashCommandArgs(t, "gh issue view 4572"))}) + if !strings.Contains(out.output, "not read-only") { + t.Fatalf("declined bash trust should block with reason, got: %q", out.output) + } +} + +func TestPlanModeUnsafeUnknownBashCommandDoesNotAskForTrust(t *testing.T) { + reg := tool.NewRegistry() + reg.Add(fakeTool{name: "bash", readOnly: false}) + gate := &fakePlanModeReadOnlyTrustGate{allow: true} + a := New(nil, reg, NewSession(""), Options{PlanModeReadOnlyTrustGate: gate}, event.Discard) + a.SetPlanMode(true) + + out := a.executeOne(context.Background(), provider.ToolCall{Name: "bash", Arguments: string(bashCommandArgs(t, "gh issue view 4572 && rm -rf /"))}) + if !strings.HasPrefix(out.output, "blocked:") { + t.Fatalf("unsafe shell syntax should remain blocked, got: %q", out.output) + } + if gate.calls != 0 { + t.Fatalf("trust gate calls = %d, want 0 for unsafe shell syntax", gate.calls) + } +} + // --- planModeBashBlocked tests --- func TestPlanModeBashBlocked_SafeCommands(t *testing.T) { diff --git a/internal/boot/boot.go b/internal/boot/boot.go index 5933f17c23..35f46cbe95 100644 --- a/internal/boot/boot.go +++ b/internal/boot/boot.go @@ -187,7 +187,7 @@ func Build(ctx context.Context, opts Options) (*control.Controller, error) { sink.Emit(event.Event{ Kind: event.Notice, Level: event.LevelWarn, - Text: fmt.Sprintf("plan_mode_read_only_commands ignored unsafe entries: %s; declare concrete read-only commands such as \"gh issue view\", not shell interpreters or malformed command prefixes", strings.Join(ignored, ", ")), + Text: fmt.Sprintf("plan_mode_read_only_commands ignored unsafe entries: %s; declare concrete read-only commands such as \"gh issue view\", not shell interpreters, overly broad prefixes, malformed prefixes, or writer-capable command verbs", strings.Join(ignored, ", ")), }) } if migErr != nil { @@ -1108,6 +1108,9 @@ func Build(ctx context.Context, opts Options) (*control.Controller, error) { OnRememberMCPReadOnlyTrust: func(serverName, rawToolName string) control.MCPReadOnlyTrustResult { return rememberMCPReadOnlyTrust(root, serverName, rawToolName) }, + OnRememberPlanModeReadOnlyCommand: func(prefix string) control.PlanModeReadOnlyCommandTrustResult { + return rememberPlanModeReadOnlyCommand(root, prefix) + }, } // Guardian: when guardian_model is configured, spawn an LLM safety reviewer // that can auto-allow safe Ask decisions and annotate risky ones before @@ -1189,6 +1192,53 @@ func rememberMCPReadOnlyTrust(workspaceRoot, serverName, rawToolName string) con return result } +func rememberPlanModeReadOnlyCommand(workspaceRoot, prefix string) control.PlanModeReadOnlyCommandTrustResult { + prefix = strings.TrimSpace(prefix) + path := rememberPermissionConfigPath(workspaceRoot) + edit := config.LoadForEdit(path) + result := control.PlanModeReadOnlyCommandTrustResult{Prefix: prefix, Path: path} + if prefix == "" { + result.Err = fmt.Errorf("empty plan-mode read-only command prefix") + return result + } + if coveredBy := coveredPlanModeReadOnlyCommand(edit.Agent.PlanModeReadOnlyCommands, prefix); coveredBy != "" { + result.CoveredBy = coveredBy + return result + } + edit.Agent.PlanModeReadOnlyCommands = append(edit.Agent.PlanModeReadOnlyCommands, prefix) + if err := edit.SaveTo(path); err != nil { + slog.Warn("persist plan-mode read-only command trust", "prefix", prefix, "err", err) + result.Err = err + return result + } + result.Saved = true + return result +} + +func coveredPlanModeReadOnlyCommand(existing []string, candidate string) string { + candidateFields := strings.Fields(strings.TrimSpace(candidate)) + if len(candidateFields) == 0 { + return "" + } + for _, item := range existing { + itemFields := strings.Fields(strings.TrimSpace(item)) + if len(itemFields) == 0 || len(itemFields) > len(candidateFields) { + continue + } + matches := true + for i, field := range itemFields { + if candidateFields[i] != field { + matches = false + break + } + } + if matches { + return strings.Join(itemFields, " ") + } + } + return "" +} + func coveredPermissionRule(rules []string, rule string) string { for _, existing := range rules { if permission.RuleCoversString(existing, rule) { diff --git a/internal/boot/boot_test.go b/internal/boot/boot_test.go index 6d73c68dc4..c8e60e6085 100644 --- a/internal/boot/boot_test.go +++ b/internal/boot/boot_test.go @@ -2551,6 +2551,57 @@ allow = ["Bash(go test ./...)", "Bash(go build ./...)"] } } +func TestRememberPlanModeReadOnlyCommandUsesWorkspaceRoot(t *testing.T) { + home := robustTempDir(t) + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config")) + t.Setenv("AppData", filepath.Join(home, "AppData")) + + cwd := robustTempDir(t) + workspace := robustTempDir(t) + t.Chdir(cwd) + writeFile(t, cwd, "reasonix.toml", ` +[agent] +plan_mode_read_only_commands = ["cwd query"] +`) + writeFile(t, workspace, "reasonix.toml", ` +[agent] +plan_mode_read_only_commands = ["workspace query"] +`) + + res := rememberPlanModeReadOnlyCommand(workspace, "gh issue view") + if !res.Saved || res.Path != filepath.Join(workspace, "reasonix.toml") { + t.Fatalf("remember result = %+v, want saved to workspace config", res) + } + + cwdCfg := config.LoadForEdit(filepath.Join(cwd, "reasonix.toml")) + if hasPlanModeReadOnlyCommand(cwdCfg.Agent.PlanModeReadOnlyCommands, "gh issue view") { + t.Fatalf("remembered command was written to cwd config: %v", cwdCfg.Agent.PlanModeReadOnlyCommands) + } + workspaceCfg := config.LoadForEdit(filepath.Join(workspace, "reasonix.toml")) + if !hasPlanModeReadOnlyCommand(workspaceCfg.Agent.PlanModeReadOnlyCommands, "gh issue view") { + t.Fatalf("remembered command missing from workspace config: %v", workspaceCfg.Agent.PlanModeReadOnlyCommands) + } +} + +func TestRememberPlanModeReadOnlyCommandSkipsCoveredPrefix(t *testing.T) { + workspace := robustTempDir(t) + writeFile(t, workspace, "reasonix.toml", ` +[agent] +plan_mode_read_only_commands = ["gh issue view"] +`) + + res := rememberPlanModeReadOnlyCommand(workspace, "gh issue view 5867") + if res.Saved || res.CoveredBy != "gh issue view" { + t.Fatalf("remember result = %+v, want already covered", res) + } + cfg := config.LoadForEdit(filepath.Join(workspace, "reasonix.toml")) + if len(cfg.Agent.PlanModeReadOnlyCommands) != 1 || cfg.Agent.PlanModeReadOnlyCommands[0] != "gh issue view" { + t.Fatalf("plan-mode read-only commands = %v, want only existing prefix", cfg.Agent.PlanModeReadOnlyCommands) + } +} + func hasPermissionRule(rules []string, want string) bool { for _, rule := range rules { if rule == want { @@ -2560,6 +2611,15 @@ func hasPermissionRule(rules []string, want string) bool { return false } +func hasPlanModeReadOnlyCommand(commands []string, want string) bool { + for _, cmd := range commands { + if strings.TrimSpace(cmd) == want { + return true + } + } + return false +} + // TestBuildMigratesLegacyConfigEndToEnd drives the real boot path: a v0.x // ~/.reasonix/config.json with no v1+ config present must be imported during // Build — config written, key pinned into the env, and the user told via a notice. diff --git a/internal/cli/chat_tui.go b/internal/cli/chat_tui.go index 8e7c2862d3..f4e16e6de0 100644 --- a/internal/cli/chat_tui.go +++ b/internal/cli/chat_tui.go @@ -2735,6 +2735,9 @@ func (m chatTUI) renderApprovalBanner() string { if !control.RequiresFreshHumanApprovalTool(m.pendingApproval.Tool) { choices = fmt.Sprintf(i18n.M.ToolApprovalChoices, exactSessionRule, exactPersistentRule) } + if m.pendingApproval.Tool == agent.PlanModeReadOnlyCommandApprovalTool { + choices = i18n.M.PlanModeReadOnlyCommandChoices + } if !control.RequiresFreshHumanApprovalTool(m.pendingApproval.Tool) && m.pendingApproval.Tool == "bash" && permission.BashCommandPrefix(m.pendingApproval.Subject) != "" { prefixRule := permission.RememberRuleForScope(m.pendingApproval.Tool, m.pendingApproval.Subject) choices = fmt.Sprintf(i18n.M.BashPrefixChoices, prefixRule, prefixRule) @@ -2750,6 +2753,9 @@ func (m chatTUI) renderApprovalBanner() string { // MCP tools are advertised as mcp____; showing the short tool name // first keeps the approval prompt readable while preserving the source. func approvalToolDetails(toolName string) (name, detail string) { + if toolName == agent.PlanModeReadOnlyCommandApprovalTool { + return "bash", fmt.Sprintf(i18n.M.ToolApprovalSourceFmt, i18n.M.ToolApprovalBuiltIn) + } if server, short, ok := tool.SplitMCPName(toolName); ok { lines := []string{} if strings.EqualFold(short, "understand_image") { diff --git a/internal/control/approval.go b/internal/control/approval.go index a0203ba580..c1a50c28a7 100644 --- a/internal/control/approval.go +++ b/internal/control/approval.go @@ -28,11 +28,12 @@ type approvalManager struct { // mu guards the prompt maps and posture fields; every critical section under // it is short and non-blocking. - mu sync.Mutex - approvals map[string]pendingApproval - asks map[string]pendingAsk - granted map[string]bool - nextID int + mu sync.Mutex + approvals map[string]pendingApproval + asks map[string]pendingAsk + granted map[string]bool + planModeReadOnlyCommands map[string]bool + nextID int // toolApprovalMode is the runtime approval posture: "ask" prompts, "auto" // lets the policy auto-approve the writer fallback while preserving ask/deny // rules, and "yolo" skips every tool approval prompt except plan approval. @@ -56,12 +57,13 @@ type approvalManager struct { func newApprovalManager(policy permission.Policy, mode string, timeout time.Duration) approvalManager { return approvalManager{ - policy: policy, - approvals: map[string]pendingApproval{}, - asks: map[string]pendingAsk{}, - granted: map[string]bool{}, - toolApprovalMode: mode, - approvalTimeout: timeout, + policy: policy, + approvals: map[string]pendingApproval{}, + asks: map[string]pendingAsk{}, + granted: map[string]bool{}, + planModeReadOnlyCommands: map[string]bool{}, + toolApprovalMode: mode, + approvalTimeout: timeout, } } @@ -135,6 +137,26 @@ func (a *approvalManager) grantSession(tool, subject string) { a.granted[permission.SessionGrantRuleForScope(tool, subject)] = true } +func (a *approvalManager) planModeReadOnlyCommandTrusted(prefix string) bool { + prefix = normalizePlanModeReadOnlyCommandPrefix(prefix) + if prefix == "" { + return false + } + a.mu.Lock() + defer a.mu.Unlock() + return a.planModeReadOnlyCommands[prefix] +} + +func (a *approvalManager) grantPlanModeReadOnlyCommand(prefix string) { + prefix = normalizePlanModeReadOnlyCommandPrefix(prefix) + if prefix == "" { + return + } + a.mu.Lock() + defer a.mu.Unlock() + a.planModeReadOnlyCommands[prefix] = true +} + // cancel drops a pending approval (timeout/abort path). func (a *approvalManager) cancel(id string) { a.mu.Lock() @@ -249,6 +271,10 @@ func (a *approvalManager) snapshotPrompts() ([]event.Approval, []event.Ask) { return approvals, asks } +func normalizePlanModeReadOnlyCommandPrefix(prefix string) string { + return strings.Join(strings.Fields(strings.TrimSpace(prefix)), " ") +} + // --- decision helpers (caller holds a.mu) --- func (a *approvalManager) bypassAllowsLocked(tool string) bool { diff --git a/internal/control/controller.go b/internal/control/controller.go index bf819094a1..673cd67247 100644 --- a/internal/control/controller.go +++ b/internal/control/controller.go @@ -97,12 +97,13 @@ type Controller struct { reasoningLanguage string // disableColdResumePrune skips stale-tool-result elision on cold resume. // Zero value keeps the prune on (the cheaper default). - disableColdResumePrune bool - shell sandbox.Shell // interpreter for user-invoked "!" commands; zero = auto - classifier autoPlanClassifier - startedOnce bool // guards the one-shot SessionStart hook on first turn - onRemember func(rule string) RememberResult // set via Options; invoked when user picks "always allow" - onRememberMCPReadOnlyTrust func(serverName, rawToolName string) MCPReadOnlyTrustResult + disableColdResumePrune bool + shell sandbox.Shell // interpreter for user-invoked "!" commands; zero = auto + classifier autoPlanClassifier + startedOnce bool // guards the one-shot SessionStart hook on first turn + onRemember func(rule string) RememberResult // set via Options; invoked when user picks "always allow" + onRememberMCPReadOnlyTrust func(serverName, rawToolName string) MCPReadOnlyTrustResult + onRememberPlanModeReadOnlyCommand func(prefix string) PlanModeReadOnlyCommandTrustResult // balanceURL/balanceKey target the active provider's optional wallet-balance // endpoint (empty when the provider declares none). Captured at build so a @@ -253,6 +254,16 @@ type MCPReadOnlyTrustResult struct { Err error } +// PlanModeReadOnlyCommandTrustResult describes what happened when a trusted bash +// command prefix was persisted for plan-mode research. +type PlanModeReadOnlyCommandTrustResult struct { + Prefix string + Path string + Saved bool + CoveredBy string + Err error +} + type externalFolderToolRefs interface { RegisterReadRoot(token, root string) } @@ -320,6 +331,10 @@ type Options struct { // read-only when the user chooses "always allow" from the plan-mode trust // prompt. OnRememberMCPReadOnlyTrust func(serverName, rawToolName string) MCPReadOnlyTrustResult + // OnRememberPlanModeReadOnlyCommand persists a bash command prefix as trusted + // read-only when the user chooses "always allow" from the plan-mode trust + // prompt. + OnRememberPlanModeReadOnlyCommand func(prefix string) PlanModeReadOnlyCommandTrustResult // PlanModeAllowedTools names extra custom tools the plan-mode policy may treat // as read-only. Known blocked tools and unsafe bash still lose. PlanModeAllowedTools []string @@ -345,38 +360,39 @@ func New(opts Options) *Controller { pluginCtx = context.Background() } c := &Controller{ - runner: opts.Runner, - executor: opts.Executor, - guardianSess: opts.Guardian, - guardianPath: guardian.PathFor(opts.SessionPath), - sink: sink, - policy: opts.Policy, - label: opts.Label, - modelRef: opts.ModelRef, - systemPrompt: opts.SystemPrompt, - sessionDir: opts.SessionDir, - sessionPath: opts.SessionPath, - commands: atomic.Pointer[[]command.Command]{}, - skills: newSkillSet(opts.Skills, opts.AllSkills, opts.SkillStore, opts.AllSkillStore), - hooks: opts.Hooks, - memory: newMemoryManager(opts.Memory), - cleanup: opts.Cleanup, - autoPlan: normalizeAutoPlan(opts.AutoPlan), - responseLanguage: config.NormalizeLanguage(opts.ResponseLanguage), - reasoningLanguage: config.NormalizeReasoningLanguage(opts.ReasoningLanguage), - disableColdResumePrune: opts.DisableColdResumePrune, - shell: opts.Shell, - classifier: classifier, - onRemember: opts.OnRemember, - onRememberMCPReadOnlyTrust: opts.OnRememberMCPReadOnlyTrust, - balanceURL: opts.BalanceURL, - balanceKey: opts.BalanceKey, - balanceClient: opts.BalanceClient, - jobs: opts.Jobs, - mcp: newMcpManager(opts.Host, opts.Registry, pluginCtx), - workspaceRoot: opts.WorkspaceRoot, - externalFolderToolRefs: opts.ExternalFolderToolRefs, - approval: newApprovalManager(opts.Policy, ToolApprovalAsk, opts.ApprovalTimeout), + runner: opts.Runner, + executor: opts.Executor, + guardianSess: opts.Guardian, + guardianPath: guardian.PathFor(opts.SessionPath), + sink: sink, + policy: opts.Policy, + label: opts.Label, + modelRef: opts.ModelRef, + systemPrompt: opts.SystemPrompt, + sessionDir: opts.SessionDir, + sessionPath: opts.SessionPath, + commands: atomic.Pointer[[]command.Command]{}, + skills: newSkillSet(opts.Skills, opts.AllSkills, opts.SkillStore, opts.AllSkillStore), + hooks: opts.Hooks, + memory: newMemoryManager(opts.Memory), + cleanup: opts.Cleanup, + autoPlan: normalizeAutoPlan(opts.AutoPlan), + responseLanguage: config.NormalizeLanguage(opts.ResponseLanguage), + reasoningLanguage: config.NormalizeReasoningLanguage(opts.ReasoningLanguage), + disableColdResumePrune: opts.DisableColdResumePrune, + shell: opts.Shell, + classifier: classifier, + onRemember: opts.OnRemember, + onRememberMCPReadOnlyTrust: opts.OnRememberMCPReadOnlyTrust, + onRememberPlanModeReadOnlyCommand: opts.OnRememberPlanModeReadOnlyCommand, + balanceURL: opts.BalanceURL, + balanceKey: opts.BalanceKey, + balanceClient: opts.BalanceClient, + jobs: opts.Jobs, + mcp: newMcpManager(opts.Host, opts.Registry, pluginCtx), + workspaceRoot: opts.WorkspaceRoot, + externalFolderToolRefs: opts.ExternalFolderToolRefs, + approval: newApprovalManager(opts.Policy, ToolApprovalAsk, opts.ApprovalTimeout), } if strings.TrimSpace(opts.WorkspaceRoot) != "" { c.autoResearch = autoresearch.NewStore(opts.WorkspaceRoot) @@ -3512,6 +3528,9 @@ func (g gateApprover) ApproveWithReason(ctx context.Context, tool, subject strin type planModeReadOnlyTrustApprover struct{ c *Controller } func (p planModeReadOnlyTrustApprover) CheckPlanModeReadOnlyTrust(ctx context.Context, req agent.PlanModeReadOnlyTrustRequest) (bool, string, error) { + if prefix := normalizePlanModeReadOnlyCommandPrefix(req.Prefix); prefix != "" { + return p.checkBashReadOnlyCommandTrust(ctx, req, prefix) + } server := strings.TrimSpace(req.ServerName) rawTool := strings.TrimSpace(req.RawToolName) if server == "" || rawTool == "" { @@ -3535,6 +3554,33 @@ func (p planModeReadOnlyTrustApprover) CheckPlanModeReadOnlyTrust(ctx context.Co return true, "", nil } +func (p planModeReadOnlyTrustApprover) checkBashReadOnlyCommandTrust(ctx context.Context, req agent.PlanModeReadOnlyTrustRequest, prefix string) (bool, string, error) { + if p.c.approval.planModeReadOnlyCommandTrusted(prefix) { + return true, "", nil + } + command := strings.TrimSpace(req.Command) + if command == "" { + command = strings.TrimSpace(string(req.Args)) + } + subject := fmt.Sprintf("Trust %q as a read-only command prefix while planning\nCommand: %s", prefix, command) + reason := "This bash command is not in Reasonix's built-in read-only set. Confirm only if this exact prefix is read-only for planning and research. Auto/YOLO approval cannot answer this trust prompt." + reply, err := p.c.requestFreshApprovalDecision(ctx, agent.PlanModeReadOnlyCommandApprovalTool, subject, req.Args, reason) + if err != nil { + return false, "approval aborted", err + } + if !reply.allow { + return false, "the user declined to trust this bash command as read-only for plan mode — do not retry it; continue with other trusted read-only tools or ask how to proceed.", nil + } + if reply.session { + p.c.approval.grantPlanModeReadOnlyCommand(prefix) + } + if reply.persist && p.c.onRememberPlanModeReadOnlyCommand != nil { + p.c.emitPlanModeReadOnlyCommandTrustResult(p.c.onRememberPlanModeReadOnlyCommand(prefix)) + p.c.approval.grantPlanModeReadOnlyCommand(prefix) + } + return true, "", nil +} + func approvalDisplaySubject(tool, subject string, args json.RawMessage) string { switch tool { case memoryRememberTool: @@ -4033,6 +4079,24 @@ func (c *Controller) emitMCPReadOnlyTrustResult(r MCPReadOnlyTrustResult) { } } +func (c *Controller) emitPlanModeReadOnlyCommandTrustResult(r PlanModeReadOnlyCommandTrustResult) { + prefix := strings.TrimSpace(r.Prefix) + if r.Err != nil { + c.sink.Emit(event.Event{ + Kind: event.Notice, + Level: event.LevelWarn, + Text: fmt.Sprintf(i18n.M.PlanModeReadOnlyCommandTrustFailedFmt, prefix, r.Err), + }) + return + } + switch { + case r.Saved: + c.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: fmt.Sprintf(i18n.M.PlanModeReadOnlyCommandTrustSavedFmt, r.Path, prefix)}) + case strings.TrimSpace(r.CoveredBy) != "": + c.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: fmt.Sprintf(i18n.M.PlanModeReadOnlyCommandTrustAlreadyFmt, r.Path, r.CoveredBy)}) + } +} + // detectProjectModules scans the workspace root for top-level source directories // to enable module-aware task routing in /plan-exec. func (c *Controller) detectProjectModules() []string { diff --git a/internal/control/controller_test.go b/internal/control/controller_test.go index a9eb0faa5a..61da9237ab 100644 --- a/internal/control/controller_test.go +++ b/internal/control/controller_test.go @@ -1639,6 +1639,63 @@ func TestPlanModeReadOnlyTrustApprovalPersistsMCPTrust(t *testing.T) { } } +func TestPlanModeReadOnlyTrustApprovalPersistsBashCommandTrust(t *testing.T) { + ids := make(chan string, 2) + var approval event.Approval + var notices []string + var rememberedPrefix string + prompts := 0 + c := New(Options{ + Sink: event.FuncSink(func(e event.Event) { + if e.Kind == event.ApprovalRequest { + prompts++ + approval = e.Approval + ids <- e.Approval.ID + } + if e.Kind == event.Notice { + notices = append(notices, e.Text) + } + }), + OnRememberPlanModeReadOnlyCommand: func(prefix string) PlanModeReadOnlyCommandTrustResult { + rememberedPrefix = prefix + return PlanModeReadOnlyCommandTrustResult{Prefix: prefix, Path: "reasonix.toml", Saved: true} + }, + }) + + go func() { + c.Approve(<-ids, true, true, true) + }() + req := agent.PlanModeReadOnlyTrustRequest{ + ToolName: agent.PlanModeReadOnlyCommandApprovalTool, + Command: "gh issue view 5867 --json title", + Prefix: "gh issue view", + Args: json.RawMessage(`{"command":"gh issue view 5867 --json title"}`), + } + allow, reason, err := planModeReadOnlyTrustApprover{c}.CheckPlanModeReadOnlyTrust(context.Background(), req) + if err != nil || !allow || reason != "" { + t.Fatalf("CheckPlanModeReadOnlyTrust = (%v,%q,%v), want allow", allow, reason, err) + } + if approval.Tool != agent.PlanModeReadOnlyCommandApprovalTool || !strings.Contains(approval.Subject, `Trust "gh issue view"`) || !strings.Contains(approval.Subject, "gh issue view 5867") || !strings.Contains(approval.Reason, "Auto/YOLO") { + t.Fatalf("approval = %+v, want plan-mode bash read-only command trust prompt", approval) + } + if rememberedPrefix != "gh issue view" { + t.Fatalf("remembered prefix = %q, want gh issue view", rememberedPrefix) + } + if len(notices) != 1 || !strings.Contains(notices[0], "gh issue view") { + t.Fatalf("notices = %v, want read-only command trust saved notice", notices) + } + + ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) + defer cancel() + allow, reason, err = planModeReadOnlyTrustApprover{c}.CheckPlanModeReadOnlyTrust(ctx, req) + if err != nil || !allow || reason != "" { + t.Fatalf("second CheckPlanModeReadOnlyTrust = (%v,%q,%v), want session grant", allow, reason, err) + } + if prompts != 1 { + t.Fatalf("approval prompts = %d, want 1", prompts) + } +} + func TestPlanModeReadOnlyTrustApprovalIgnoresToolAutoApproval(t *testing.T) { approvalRequests := make(chan event.Approval, 1) c := New(Options{ @@ -1697,6 +1754,65 @@ func TestPlanModeReadOnlyTrustApprovalIgnoresToolAutoApproval(t *testing.T) { } } +func TestPlanModeReadOnlyCommandTrustApprovalIgnoresToolAutoApproval(t *testing.T) { + approvalRequests := make(chan event.Approval, 1) + c := New(Options{ + Sink: event.FuncSink(func(e event.Event) { + if e.Kind == event.ApprovalRequest { + approvalRequests <- e.Approval + } + }), + }) + c.SetAutoApproveTools(true) + + type trustResult struct { + allow bool + reason string + err error + } + done := make(chan trustResult, 1) + req := agent.PlanModeReadOnlyTrustRequest{ + ToolName: agent.PlanModeReadOnlyCommandApprovalTool, + Command: "gh issue view 5867", + Prefix: "gh issue view", + Args: json.RawMessage(`{"command":"gh issue view 5867"}`), + } + go func() { + allow, reason, err := planModeReadOnlyTrustApprover{c}.CheckPlanModeReadOnlyTrust(context.Background(), req) + done <- trustResult{allow: allow, reason: reason, err: err} + }() + + var approval event.Approval + select { + case approval = <-approvalRequests: + case <-time.After(2 * time.Second): + t.Fatal("plan-mode bash read-only command trust prompt was not emitted under tool auto-approval") + } + if approval.Tool != agent.PlanModeReadOnlyCommandApprovalTool || !strings.Contains(approval.Subject, `Trust "gh issue view"`) { + t.Fatalf("approval = %+v, want plan-mode bash read-only command trust prompt", approval) + } + select { + case got := <-done: + t.Fatalf("tool auto-approval must not answer plan-mode bash read-only command trust, got %+v", got) + case <-time.After(50 * time.Millisecond): + } + + c.Approve(approval.ID, true, true, false) + select { + case got := <-done: + if got.err != nil || !got.allow || got.reason != "" { + t.Fatalf("CheckPlanModeReadOnlyTrust after approval = %+v, want allow", got) + } + case <-time.After(2 * time.Second): + t.Fatal("plan-mode bash read-only command trust prompt stayed blocked after Approve") + } + + allow, reason, err := planModeReadOnlyTrustApprover{c}.CheckPlanModeReadOnlyTrust(context.Background(), req) + if err != nil || !allow || reason != "" { + t.Fatalf("session-granted plan-mode bash read-only command trust under YOLO = (%v,%q,%v), want allow", allow, reason, err) + } +} + func TestApprovalSessionGrantGroupsFileMutationTools(t *testing.T) { c, ids, prompts := approvalIDs() go func() { c.Approve(<-ids, true, true, false) }() diff --git a/internal/control/yolo_test.go b/internal/control/yolo_test.go index 0d671afde6..9f360a32cd 100644 --- a/internal/control/yolo_test.go +++ b/internal/control/yolo_test.go @@ -540,6 +540,64 @@ func TestSetAutoApproveToolsDoesNotDrainPendingMCPReadOnlyTrust(t *testing.T) { } } +func TestSetAutoApproveToolsDoesNotDrainPendingPlanModeReadOnlyCommandTrust(t *testing.T) { + approvalRequests := make(chan event.Approval, 1) + c := New(Options{ + Sink: event.FuncSink(func(e event.Event) { + if e.Kind == event.ApprovalRequest { + approvalRequests <- e.Approval + } + }), + }) + + type trustResult struct { + allow bool + reason string + err error + } + done := make(chan trustResult, 1) + req := agent.PlanModeReadOnlyTrustRequest{ + ToolName: agent.PlanModeReadOnlyCommandApprovalTool, + Command: "gh issue view 5867", + Prefix: "gh issue view", + } + go func() { + allow, reason, err := planModeReadOnlyTrustApprover{c}.CheckPlanModeReadOnlyTrust(context.Background(), req) + done <- trustResult{allow: allow, reason: reason, err: err} + }() + + var approval event.Approval + select { + case approval = <-approvalRequests: + case <-time.After(2 * time.Second): + t.Fatal("plan-mode bash read-only command trust approval request was not emitted") + } + if approval.Tool != agent.PlanModeReadOnlyCommandApprovalTool { + t.Fatalf("approval tool = %q, want %q", approval.Tool, agent.PlanModeReadOnlyCommandApprovalTool) + } + + c.SetAutoApproveTools(true) + + select { + case got := <-done: + t.Fatalf("SetAutoApproveTools must not auto-answer plan-mode bash read-only command trust; got %+v", got) + case <-time.After(50 * time.Millisecond): + } + if !c.AutoApproveTools() { + t.Fatal("tool auto-approval should turn on while plan-mode bash read-only command trust stays pending") + } + + c.Approve(approval.ID, true, false, false) + select { + case got := <-done: + if got.err != nil || !got.allow || got.reason != "" { + t.Fatalf("manual plan-mode bash read-only command trust approval = %+v, want allow", got) + } + case <-time.After(2 * time.Second): + t.Fatal("plan-mode bash read-only command trust approval stayed blocked after Approve") + } +} + func TestSetAutoApproveToolsDoesNotDrainPendingMemoryApproval(t *testing.T) { approvalRequests := make(chan event.Approval, 1) c := New(Options{ diff --git a/internal/i18n/i18n.go b/internal/i18n/i18n.go index b4a40d9af0..8d348a1a57 100644 --- a/internal/i18n/i18n.go +++ b/internal/i18n/i18n.go @@ -68,36 +68,40 @@ type Messages struct { ResumePickHint string // keyboard hint in the interactive resume picker // chat TUI status line / approval banner. - ChatThinking string // live reasoning marker label, e.g. "thinking…" - ChatThoughtForFmt string // collapsed reasoning summary, "%d" = elapsed s - ChatStatusThinkingFmt string // "%s thinking… (%ds · )" — %s = spinner, %d = elapsed s - ChatToolWorkingFmt string // "%s working · %ds" under a running tool — %s = spinner, %d = elapsed s - ChatStatusRetryingFmt string // "%s retrying (%d/%d)…" — %s = spinner, %d/%d = attempt/max - ChatStatusCancellingFmt string // "%s stopping… (%ds · Ctrl+C exits)" — %s = spinner, %d = elapsed s - ChatStatusIdle string // shortcuts hint when idle - ChatStatusYoloIdle string // shortcuts hint when idle in YOLO/bypass mode - ChatStatusCycleHint string // plan-toggle shortcut hint shown when no modal prompt owns the status row - ChatStatusCacheNowFmt string // cache status tag, "%s" = latest-turn hit rate with percent sign - ChatStatusCacheAvgFmt string // cache status tag, "%s" = session-average hit rate with percent sign - ChatStatusPlanApproval string // shortcuts hint while a plan is pending - PlanApprovalPrompt string // one-line "plan above is ready" banner shown above the input - ChatStatusToolApproval string // shortcuts hint while a tool call awaits approval - ToolApprovalPromptFmt string // approval banner — tool, subject suffix, source/intent detail, choices - ToolApprovalChoices string // standard approval choice list - BashPrefixChoices string // approval choice list when a bash prefix can be granted - FreshHumanApprovalChoices string // approval choice list for prompts that cannot be remembered - ToolApprovalSourceFmt string // "Source: %s" / "来源: %s" - ToolApprovalBuiltIn string // built-in tool source label - ToolApprovalImageUse string // image-understanding detail for understand_image-style tools - PermissionSavedFmt string // permission rule saved notice: path, rule - PermissionAlreadyAllowedFmt string // permission rule already covered notice: path, rule - PermissionSaveFailedFmt string // permission rule save failure notice: rule, error - MCPReadOnlyTrustSavedFmt string // MCP trusted read-only saved notice: path, server, tool - MCPReadOnlyTrustAlreadyFmt string // MCP trusted read-only already covered notice: path, server, tool - MCPReadOnlyTrustFailedFmt string // MCP trusted read-only save failure notice: server, tool, error - DiffFoldedFmt string // "… +%d more lines" footer when a writer diff is folded - DiffFoldEnabledFmt string // notice when /diff-fold enables folding, %d = line limit - DiffFoldDisabled string // notice when /diff-fold disables folding (shows all lines) + ChatThinking string // live reasoning marker label, e.g. "thinking…" + ChatThoughtForFmt string // collapsed reasoning summary, "%d" = elapsed s + ChatStatusThinkingFmt string // "%s thinking… (%ds · )" — %s = spinner, %d = elapsed s + ChatToolWorkingFmt string // "%s working · %ds" under a running tool — %s = spinner, %d = elapsed s + ChatStatusRetryingFmt string // "%s retrying (%d/%d)…" — %s = spinner, %d/%d = attempt/max + ChatStatusCancellingFmt string // "%s stopping… (%ds · Ctrl+C exits)" — %s = spinner, %d = elapsed s + ChatStatusIdle string // shortcuts hint when idle + ChatStatusYoloIdle string // shortcuts hint when idle in YOLO/bypass mode + ChatStatusCycleHint string // plan-toggle shortcut hint shown when no modal prompt owns the status row + ChatStatusCacheNowFmt string // cache status tag, "%s" = latest-turn hit rate with percent sign + ChatStatusCacheAvgFmt string // cache status tag, "%s" = session-average hit rate with percent sign + ChatStatusPlanApproval string // shortcuts hint while a plan is pending + PlanApprovalPrompt string // one-line "plan above is ready" banner shown above the input + ChatStatusToolApproval string // shortcuts hint while a tool call awaits approval + ToolApprovalPromptFmt string // approval banner — tool, subject suffix, source/intent detail, choices + ToolApprovalChoices string // standard approval choice list + BashPrefixChoices string // approval choice list when a bash prefix can be granted + PlanModeReadOnlyCommandChoices string // approval choice list for plan-mode read-only command trust + FreshHumanApprovalChoices string // approval choice list for prompts that cannot be remembered + ToolApprovalSourceFmt string // "Source: %s" / "来源: %s" + ToolApprovalBuiltIn string // built-in tool source label + ToolApprovalImageUse string // image-understanding detail for understand_image-style tools + PermissionSavedFmt string // permission rule saved notice: path, rule + PermissionAlreadyAllowedFmt string // permission rule already covered notice: path, rule + PermissionSaveFailedFmt string // permission rule save failure notice: rule, error + MCPReadOnlyTrustSavedFmt string // MCP trusted read-only saved notice: path, server, tool + MCPReadOnlyTrustAlreadyFmt string // MCP trusted read-only already covered notice: path, server, tool + MCPReadOnlyTrustFailedFmt string // MCP trusted read-only save failure notice: server, tool, error + PlanModeReadOnlyCommandTrustSavedFmt string // plan-mode bash read-only prefix saved notice: path, prefix + PlanModeReadOnlyCommandTrustAlreadyFmt string // plan-mode bash read-only prefix already covered notice: path, prefix + PlanModeReadOnlyCommandTrustFailedFmt string // plan-mode bash read-only prefix save failure notice: prefix, error + DiffFoldedFmt string // "… +%d more lines" footer when a writer diff is folded + DiffFoldEnabledFmt string // notice when /diff-fold enables folding, %d = line limit + DiffFoldDisabled string // notice when /diff-fold disables folding (shows all lines) // `ask` tool question card. AskTypeSomething string // the "type your own answer" option label diff --git a/internal/i18n/messages_en.go b/internal/i18n/messages_en.go index 23c904b730..877b656fa7 100644 --- a/internal/i18n/messages_en.go +++ b/internal/i18n/messages_en.go @@ -42,44 +42,48 @@ var English = Messages{ ResumePickTitle: "Resume a saved session", ResumePickHint: "↑/↓ move · Enter resume · Esc cancel", - ChatThinking: "thinking…", - ChatThoughtForFmt: "thought for %ds", - ChatStatusThinkingFmt: "%s thinking… (%ds · Esc cancels)", - ChatToolWorkingFmt: "%s working · %ds", - ChatStatusRetryingFmt: "%s retrying (%d/%d)… (Esc cancels)", - ChatStatusCancellingFmt: "%s stopping… (%ds · Ctrl+C exits)", - ChatStatusIdle: "ready", - ChatStatusYoloIdle: "tool approvals skipped", - ChatStatusCycleHint: "shift+tab toggles plan · ctrl+y yolo", - ChatStatusCacheNowFmt: "turn hit %s", - ChatStatusCacheAvgFmt: "avg %s", - ChatStatusPlanApproval: "Enter/y approves & executes · n/Esc keeps planning · PgUp/PgDn/Ctrl+Home/End scrolls", - PlanApprovalPrompt: "Plan ready above — Enter/y to approve & execute, n/Esc to keep planning", - ChatStatusToolApproval: "1 approve once · 2 allow scope this session · 3/4 prefix or save when offered · n/Esc deny · Ctrl-C cancels turn", - AskTypeSomething: "Type something else", - AskTypingHint: "type below, Enter to confirm", - AskChatInstead: "None — just chat", - ChatStatusQuestion: "↑/↓ move · number to pick · space multi · Enter confirm · ←/→ switch · Esc cancel", - StatusResumePicker: "↑/↓ move · Enter resume · Esc cancel", - AskSubmitTitle: "Submit answers", - AskUnanswered: "(unanswered)", - AskSubmitHint: "Enter submits · ← returns to edit", - ToolApprovalPromptFmt: "Permission required\n\nWill call tool %s%s.\n%s\n%s", - ToolApprovalChoices: "1. Allow once\n2. Allow %s for this session\n3. Always allow %s (save to config)\n4. Deny\nChoose [1/2/3/4] (y/a/p/n also work)", - BashPrefixChoices: "1. Allow once\n2. Allow %s for this session\n3. Always allow %s (save to config)\n4. Deny\nChoose [1/2/3/4] (y/a/p/n also work)", - FreshHumanApprovalChoices: "1. Allow once\n4. Deny\nChoose [1/4] (y/n also work)", - ToolApprovalSourceFmt: "Source: %s", - ToolApprovalBuiltIn: "built-in tool", - ToolApprovalImageUse: "It will read provided image input for image understanding.", - PermissionSavedFmt: "permission saved to %s: %s", - PermissionAlreadyAllowedFmt: "permission already covered in %s: %s", - PermissionSaveFailedFmt: "permission save failed for %s: %v", - MCPReadOnlyTrustSavedFmt: "MCP read-only trust saved to %s: %s/%s", - MCPReadOnlyTrustAlreadyFmt: "MCP read-only trust already saved in %s: %s/%s", - MCPReadOnlyTrustFailedFmt: "MCP read-only trust save failed for %s/%s: %v", - DiffFoldedFmt: "… +%d more lines", - DiffFoldEnabledFmt: "diff folded to %d lines (/diff-fold to expand)", - DiffFoldDisabled: "diff expanded — showing all lines (/diff-fold to fold)", + ChatThinking: "thinking…", + ChatThoughtForFmt: "thought for %ds", + ChatStatusThinkingFmt: "%s thinking… (%ds · Esc cancels)", + ChatToolWorkingFmt: "%s working · %ds", + ChatStatusRetryingFmt: "%s retrying (%d/%d)… (Esc cancels)", + ChatStatusCancellingFmt: "%s stopping… (%ds · Ctrl+C exits)", + ChatStatusIdle: "ready", + ChatStatusYoloIdle: "tool approvals skipped", + ChatStatusCycleHint: "shift+tab toggles plan · ctrl+y yolo", + ChatStatusCacheNowFmt: "turn hit %s", + ChatStatusCacheAvgFmt: "avg %s", + ChatStatusPlanApproval: "Enter/y approves & executes · n/Esc keeps planning · PgUp/PgDn/Ctrl+Home/End scrolls", + PlanApprovalPrompt: "Plan ready above — Enter/y to approve & execute, n/Esc to keep planning", + ChatStatusToolApproval: "1 approve once · 2 allow scope this session · 3/4 prefix or save when offered · n/Esc deny · Ctrl-C cancels turn", + AskTypeSomething: "Type something else", + AskTypingHint: "type below, Enter to confirm", + AskChatInstead: "None — just chat", + ChatStatusQuestion: "↑/↓ move · number to pick · space multi · Enter confirm · ←/→ switch · Esc cancel", + StatusResumePicker: "↑/↓ move · Enter resume · Esc cancel", + AskSubmitTitle: "Submit answers", + AskUnanswered: "(unanswered)", + AskSubmitHint: "Enter submits · ← returns to edit", + ToolApprovalPromptFmt: "Permission required\n\nWill call tool %s%s.\n%s\n%s", + ToolApprovalChoices: "1. Allow once\n2. Allow %s for this session\n3. Always allow %s (save to config)\n4. Deny\nChoose [1/2/3/4] (y/a/p/n also work)", + BashPrefixChoices: "1. Allow once\n2. Allow %s for this session\n3. Always allow %s (save to config)\n4. Deny\nChoose [1/2/3/4] (y/a/p/n also work)", + PlanModeReadOnlyCommandChoices: "1. Trust once\n2. Trust this prefix for this session\n3. Always trust this prefix for plan mode (save to config)\n4. Deny\nChoose [1/2/3/4] (y/a/p/n also work)", + FreshHumanApprovalChoices: "1. Allow once\n4. Deny\nChoose [1/4] (y/n also work)", + ToolApprovalSourceFmt: "Source: %s", + ToolApprovalBuiltIn: "built-in tool", + ToolApprovalImageUse: "It will read provided image input for image understanding.", + PermissionSavedFmt: "permission saved to %s: %s", + PermissionAlreadyAllowedFmt: "permission already covered in %s: %s", + PermissionSaveFailedFmt: "permission save failed for %s: %v", + MCPReadOnlyTrustSavedFmt: "MCP read-only trust saved to %s: %s/%s", + MCPReadOnlyTrustAlreadyFmt: "MCP read-only trust already saved in %s: %s/%s", + MCPReadOnlyTrustFailedFmt: "MCP read-only trust save failed for %s/%s: %v", + PlanModeReadOnlyCommandTrustSavedFmt: "plan-mode read-only command trust saved to %s: %s", + PlanModeReadOnlyCommandTrustAlreadyFmt: "plan-mode read-only command trust already saved in %s: %s", + PlanModeReadOnlyCommandTrustFailedFmt: "plan-mode read-only command trust save failed for %s: %v", + DiffFoldedFmt: "… +%d more lines", + DiffFoldEnabledFmt: "diff folded to %d lines (/diff-fold to expand)", + DiffFoldDisabled: "diff expanded — showing all lines (/diff-fold to fold)", OutputStyleNone: "no output styles available", OutputStyleHeader: "output styles:", diff --git a/internal/i18n/messages_zh.go b/internal/i18n/messages_zh.go index 3337a1d7b8..183106671c 100644 --- a/internal/i18n/messages_zh.go +++ b/internal/i18n/messages_zh.go @@ -43,44 +43,48 @@ var Chinese = Messages{ ResumePickTitle: "选择要恢复的会话", ResumePickHint: "↑/↓ 移动 · Enter 恢复 · Esc 取消", - ChatThinking: "思考中…", - ChatThoughtForFmt: "思考了 %d 秒", - ChatStatusThinkingFmt: "%s 思考中… (%d 秒 · Esc 取消)", - ChatToolWorkingFmt: "%s 运行中 · %d 秒", - ChatStatusRetryingFmt: "%s 正在重试 (%d/%d)… (Esc 取消)", - ChatStatusCancellingFmt: "%s 正在停止… (%d 秒 · Ctrl+C 退出)", - ChatStatusIdle: "就绪", - ChatStatusYoloIdle: "已跳过工具批准", - ChatStatusCycleHint: "shift+tab 切换计划 · ctrl+y yolo", - ChatStatusCacheNowFmt: "本次命中 %s", - ChatStatusCacheAvgFmt: "平均 %s", - ChatStatusPlanApproval: "Enter/y 批准并执行 · n/Esc 继续规划 · PgUp/PgDn/Ctrl+Home/End 滚动", - PlanApprovalPrompt: "计划已生成(见上方)— Enter/y 批准执行,n/Esc 继续规划", - ChatStatusToolApproval: "1 本次允许 · 2 本会话允许此范围 · 提供时 3/4 为前缀或保存 · n/Esc 拒绝 · Ctrl-C 取消本轮", - AskTypeSomething: "自己输入", - AskTypingHint: "输入后按 Enter 确认", - AskChatInstead: "先不选择,直接回复", - ChatStatusQuestion: "↑/↓ 选 · 数字快选 · 空格多选 · Enter 确认 · ←/→ 切换问题 · Esc 取消", - StatusResumePicker: "↑/↓ 移动 · Enter 恢复 · Esc 取消", - AskSubmitTitle: "提交答案", - AskUnanswered: "(未答)", - AskSubmitHint: "Enter 提交 · ← 返回修改", - ToolApprovalPromptFmt: "需要你的许可\n\n将调用工具 %s%s。\n%s\n%s", - ToolApprovalChoices: "1. 本次允许\n2. 本会话允许 %s\n3. 总是允许 %s(保存到配置)\n4. 拒绝\n选择 [1/2/3/4](兼容 y/a/p/n)", - BashPrefixChoices: "1. 本次允许\n2. 本会话允许 %s\n3. 总是允许 %s(保存到配置)\n4. 拒绝\n选择 [1/2/3/4](兼容 y/a/p/n)", - FreshHumanApprovalChoices: "1. 本次允许\n4. 拒绝\n选择 [1/4](兼容 y/n)", - ToolApprovalSourceFmt: "来源: %s", - ToolApprovalBuiltIn: "内置工具", - ToolApprovalImageUse: "将读取提供的图片用于图像理解。", - PermissionSavedFmt: "授权已保存到 %s:%s", - PermissionAlreadyAllowedFmt: "授权已由 %s 中的规则覆盖:%s", - PermissionSaveFailedFmt: "保存授权 %s 失败:%v", - MCPReadOnlyTrustSavedFmt: "MCP 只读信任已保存到 %s:%s/%s", - MCPReadOnlyTrustAlreadyFmt: "MCP 只读信任已保存在 %s:%s/%s", - MCPReadOnlyTrustFailedFmt: "保存 MCP 只读信任 %s/%s 失败:%v", - DiffFoldedFmt: "… 还有 %d 行", - DiffFoldEnabledFmt: "diff 已折叠至 %d 行(/diff-fold 展开)", - DiffFoldDisabled: "diff 已展开 — 显示全部行(/diff-fold 折叠)", + ChatThinking: "思考中…", + ChatThoughtForFmt: "思考了 %d 秒", + ChatStatusThinkingFmt: "%s 思考中… (%d 秒 · Esc 取消)", + ChatToolWorkingFmt: "%s 运行中 · %d 秒", + ChatStatusRetryingFmt: "%s 正在重试 (%d/%d)… (Esc 取消)", + ChatStatusCancellingFmt: "%s 正在停止… (%d 秒 · Ctrl+C 退出)", + ChatStatusIdle: "就绪", + ChatStatusYoloIdle: "已跳过工具批准", + ChatStatusCycleHint: "shift+tab 切换计划 · ctrl+y yolo", + ChatStatusCacheNowFmt: "本次命中 %s", + ChatStatusCacheAvgFmt: "平均 %s", + ChatStatusPlanApproval: "Enter/y 批准并执行 · n/Esc 继续规划 · PgUp/PgDn/Ctrl+Home/End 滚动", + PlanApprovalPrompt: "计划已生成(见上方)— Enter/y 批准执行,n/Esc 继续规划", + ChatStatusToolApproval: "1 本次允许 · 2 本会话允许此范围 · 提供时 3/4 为前缀或保存 · n/Esc 拒绝 · Ctrl-C 取消本轮", + AskTypeSomething: "自己输入", + AskTypingHint: "输入后按 Enter 确认", + AskChatInstead: "先不选择,直接回复", + ChatStatusQuestion: "↑/↓ 选 · 数字快选 · 空格多选 · Enter 确认 · ←/→ 切换问题 · Esc 取消", + StatusResumePicker: "↑/↓ 移动 · Enter 恢复 · Esc 取消", + AskSubmitTitle: "提交答案", + AskUnanswered: "(未答)", + AskSubmitHint: "Enter 提交 · ← 返回修改", + ToolApprovalPromptFmt: "需要你的许可\n\n将调用工具 %s%s。\n%s\n%s", + ToolApprovalChoices: "1. 本次允许\n2. 本会话允许 %s\n3. 总是允许 %s(保存到配置)\n4. 拒绝\n选择 [1/2/3/4](兼容 y/a/p/n)", + BashPrefixChoices: "1. 本次允许\n2. 本会话允许 %s\n3. 总是允许 %s(保存到配置)\n4. 拒绝\n选择 [1/2/3/4](兼容 y/a/p/n)", + PlanModeReadOnlyCommandChoices: "1. 本次信任\n2. 本会话信任此前缀\n3. 总是在计划模式信任此前缀(保存到配置)\n4. 拒绝\n选择 [1/2/3/4](兼容 y/a/p/n)", + FreshHumanApprovalChoices: "1. 本次允许\n4. 拒绝\n选择 [1/4](兼容 y/n)", + ToolApprovalSourceFmt: "来源: %s", + ToolApprovalBuiltIn: "内置工具", + ToolApprovalImageUse: "将读取提供的图片用于图像理解。", + PermissionSavedFmt: "授权已保存到 %s:%s", + PermissionAlreadyAllowedFmt: "授权已由 %s 中的规则覆盖:%s", + PermissionSaveFailedFmt: "保存授权 %s 失败:%v", + MCPReadOnlyTrustSavedFmt: "MCP 只读信任已保存到 %s:%s/%s", + MCPReadOnlyTrustAlreadyFmt: "MCP 只读信任已保存在 %s:%s/%s", + MCPReadOnlyTrustFailedFmt: "保存 MCP 只读信任 %s/%s 失败:%v", + PlanModeReadOnlyCommandTrustSavedFmt: "计划模式只读命令信任已保存到 %s:%s", + PlanModeReadOnlyCommandTrustAlreadyFmt: "计划模式只读命令信任已保存在 %s:%s", + PlanModeReadOnlyCommandTrustFailedFmt: "保存计划模式只读命令信任 %s 失败:%v", + DiffFoldedFmt: "… 还有 %d 行", + DiffFoldEnabledFmt: "diff 已折叠至 %d 行(/diff-fold 展开)", + DiffFoldDisabled: "diff 已展开 — 显示全部行(/diff-fold 折叠)", OutputStyleNone: "没有可用的输出风格", OutputStyleHeader: "输出风格:", diff --git a/internal/i18n/messages_zh_tw.go b/internal/i18n/messages_zh_tw.go index da565d27c9..defae47b93 100644 --- a/internal/i18n/messages_zh_tw.go +++ b/internal/i18n/messages_zh_tw.go @@ -39,42 +39,46 @@ var ChineseTraditional = Messages{ ResumePickTitle: "選擇要恢復的會話", ResumePickHint: "↑/↓ 移動 · Enter 恢復 · Esc 取消", - ChatThinking: "思考中…", - ChatThoughtForFmt: "思考了 %d 秒", - ChatStatusThinkingFmt: "%s 思考中… (%d 秒 · Esc 取消)", - ChatToolWorkingFmt: "%s 執行中 · %d 秒", - ChatStatusRetryingFmt: "%s 正在重試 (%d/%d)… (Esc 取消)", - ChatStatusCancellingFmt: "%s 正在停止… (%d 秒 · Ctrl+C 退出)", - ChatStatusIdle: "就緒", - ChatStatusYoloIdle: "已跳過核准", - ChatStatusCycleHint: "shift+tab 循環切換", - ChatStatusCacheNowFmt: "本次命中 %s", - ChatStatusCacheAvgFmt: "平均 %s", - ChatStatusPlanApproval: "Enter/y 核准並執行 · n/Esc 繼續規劃 · PgUp/PgDn 捲動", - PlanApprovalPrompt: "計畫已生成(見上方)— Enter/y 核准執行,n/Esc 繼續規劃", - ChatStatusToolApproval: "1 本次允許 · 2 本會話允許此範圍 · 提供時 3/4 為前綴或儲存 · n/Esc 拒絕 · Ctrl-C 取消本輪", - AskTypeSomething: "自己輸入", - AskTypingHint: "輸入後按 Enter 確認", - AskChatInstead: "先不選擇,直接回覆", - ChatStatusQuestion: "↑/↓ 選 · 數字快選 · 空格多選 · Enter 確認 · ←/→ 切換問題 · Esc 取消", - StatusResumePicker: "↑/↓ 移動 · Enter 恢復 · Esc 取消", - AskSubmitTitle: "提交答案", - AskUnanswered: "(未答)", - AskSubmitHint: "Enter 提交 · ← 返回修改", - ToolApprovalPromptFmt: "需要你的許可\n\n將呼叫工具 %s%s。\n%s\n%s", - ToolApprovalChoices: "1. 本次允許\n2. 本會話允許 %s\n3. 總是允許 %s(儲存到設定)\n4. 拒絕\n選擇 [1/2/3/4](相容 y/a/p/n)", - BashPrefixChoices: "1. 本次允許\n2. 本會話允許 %s\n3. 總是允許 %s(儲存到設定)\n4. 拒絕\n選擇 [1/2/3/4](相容 y/a/p/n)", - FreshHumanApprovalChoices: "1. 本次允許\n4. 拒絕\n選擇 [1/4](相容 y/n)", - ToolApprovalSourceFmt: "來源: %s", - ToolApprovalBuiltIn: "內建工具", - ToolApprovalImageUse: "將讀取提供的圖片用於圖像理解。", - PermissionSavedFmt: "授權已儲存到 %s:%s", - PermissionAlreadyAllowedFmt: "授權已由 %s 中的規則覆蓋:%s", - PermissionSaveFailedFmt: "儲存授權 %s 失敗:%v", - MCPReadOnlyTrustSavedFmt: "MCP 唯讀信任已儲存到 %s:%s/%s", - MCPReadOnlyTrustAlreadyFmt: "MCP 唯讀信任已儲存在 %s:%s/%s", - MCPReadOnlyTrustFailedFmt: "儲存 MCP 唯讀信任 %s/%s 失敗:%v", - DiffFoldedFmt: "… 還有 %d 行", + ChatThinking: "思考中…", + ChatThoughtForFmt: "思考了 %d 秒", + ChatStatusThinkingFmt: "%s 思考中… (%d 秒 · Esc 取消)", + ChatToolWorkingFmt: "%s 執行中 · %d 秒", + ChatStatusRetryingFmt: "%s 正在重試 (%d/%d)… (Esc 取消)", + ChatStatusCancellingFmt: "%s 正在停止… (%d 秒 · Ctrl+C 退出)", + ChatStatusIdle: "就緒", + ChatStatusYoloIdle: "已跳過核准", + ChatStatusCycleHint: "shift+tab 循環切換", + ChatStatusCacheNowFmt: "本次命中 %s", + ChatStatusCacheAvgFmt: "平均 %s", + ChatStatusPlanApproval: "Enter/y 核准並執行 · n/Esc 繼續規劃 · PgUp/PgDn 捲動", + PlanApprovalPrompt: "計畫已生成(見上方)— Enter/y 核准執行,n/Esc 繼續規劃", + ChatStatusToolApproval: "1 本次允許 · 2 本會話允許此範圍 · 提供時 3/4 為前綴或儲存 · n/Esc 拒絕 · Ctrl-C 取消本輪", + AskTypeSomething: "自己輸入", + AskTypingHint: "輸入後按 Enter 確認", + AskChatInstead: "先不選擇,直接回覆", + ChatStatusQuestion: "↑/↓ 選 · 數字快選 · 空格多選 · Enter 確認 · ←/→ 切換問題 · Esc 取消", + StatusResumePicker: "↑/↓ 移動 · Enter 恢復 · Esc 取消", + AskSubmitTitle: "提交答案", + AskUnanswered: "(未答)", + AskSubmitHint: "Enter 提交 · ← 返回修改", + ToolApprovalPromptFmt: "需要你的許可\n\n將呼叫工具 %s%s。\n%s\n%s", + ToolApprovalChoices: "1. 本次允許\n2. 本會話允許 %s\n3. 總是允許 %s(儲存到設定)\n4. 拒絕\n選擇 [1/2/3/4](相容 y/a/p/n)", + BashPrefixChoices: "1. 本次允許\n2. 本會話允許 %s\n3. 總是允許 %s(儲存到設定)\n4. 拒絕\n選擇 [1/2/3/4](相容 y/a/p/n)", + PlanModeReadOnlyCommandChoices: "1. 本次信任\n2. 本會話信任此前綴\n3. 總是在計劃模式信任此前綴(儲存到設定)\n4. 拒絕\n選擇 [1/2/3/4](相容 y/a/p/n)", + FreshHumanApprovalChoices: "1. 本次允許\n4. 拒絕\n選擇 [1/4](相容 y/n)", + ToolApprovalSourceFmt: "來源: %s", + ToolApprovalBuiltIn: "內建工具", + ToolApprovalImageUse: "將讀取提供的圖片用於圖像理解。", + PermissionSavedFmt: "授權已儲存到 %s:%s", + PermissionAlreadyAllowedFmt: "授權已由 %s 中的規則覆蓋:%s", + PermissionSaveFailedFmt: "儲存授權 %s 失敗:%v", + MCPReadOnlyTrustSavedFmt: "MCP 唯讀信任已儲存到 %s:%s/%s", + MCPReadOnlyTrustAlreadyFmt: "MCP 唯讀信任已儲存在 %s:%s/%s", + MCPReadOnlyTrustFailedFmt: "儲存 MCP 唯讀信任 %s/%s 失敗:%v", + PlanModeReadOnlyCommandTrustSavedFmt: "計劃模式唯讀命令信任已儲存到 %s:%s", + PlanModeReadOnlyCommandTrustAlreadyFmt: "計劃模式唯讀命令信任已儲存在 %s:%s", + PlanModeReadOnlyCommandTrustFailedFmt: "儲存計劃模式唯讀命令信任 %s 失敗:%v", + DiffFoldedFmt: "… 還有 %d 行", OutputStyleNone: "沒有可用的輸出風格", OutputStyleHeader: "輸出風格:", diff --git a/internal/planmode/policy.go b/internal/planmode/policy.go index b846220948..f64d9f20e6 100644 --- a/internal/planmode/policy.go +++ b/internal/planmode/policy.go @@ -51,6 +51,15 @@ type Call struct { 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. @@ -101,6 +110,39 @@ var unsafeDeclaredReadOnlyCommandBases = map[string]bool{ "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 @@ -223,7 +265,7 @@ func (p Policy) IgnoredReadOnlyCommands() []string { seen[cmd] = true continue } - if unsafeDeclaredReadOnlyCommandBases[strings.ToLower(fields[0])] { + if unsafeDeclaredReadOnlyCommandPrefix(fields) { out = append(out, cmd) seen[cmd] = true } @@ -357,6 +399,13 @@ func decideBash(args json.RawMessage, readOnlyCommands []string) Decision { } else if ok { return Decision{} } + if trust := readOnlyCommandTrustCandidate(cmd); 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.", cmd, 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.", cmd), @@ -393,7 +442,7 @@ func declaredReadOnlyCommand(cmd string, prefixes []string) (bool, string) { if prefixMalformed != "" || len(prefixFields) == 0 { continue } - if unsafeDeclaredReadOnlyCommandBases[strings.ToLower(prefixFields[0])] { + if unsafeDeclaredReadOnlyCommandPrefix(prefixFields) { continue } if len(prefixFields) > len(fields) { @@ -413,6 +462,79 @@ func declaredReadOnlyCommand(cmd string, prefixes []string) (bool, string) { 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, diff --git a/internal/planmode/policy_test.go b/internal/planmode/policy_test.go index 08f56a6ffb..6d9c06d7b7 100644 --- a/internal/planmode/policy_test.go +++ b/internal/planmode/policy_test.go @@ -2,6 +2,7 @@ package planmode import ( "encoding/json" + "reflect" "strings" "testing" ) @@ -153,7 +154,7 @@ func TestDecideDeclaredReadOnlyBashCommandStillBlocksShellSyntax(t *testing.T) { } func TestDecideDeclaredReadOnlyBashCommandIgnoresShellInterpreters(t *testing.T) { - p := Policy{ReadOnlyCommands: []string{"bash"}} + p := Policy{ReadOnlyCommands: []string{"bash", "gh", "gh issue", "gh issue close"}} args, err := json.Marshal(map[string]any{"command": "bash run-anything"}) if err != nil { t.Fatal(err) @@ -162,8 +163,43 @@ func TestDecideDeclaredReadOnlyBashCommandIgnoresShellInterpreters(t *testing.T) if decision := p.Decide(Call{Name: "bash", Args: args}); !decision.Blocked { t.Fatal("plan_mode_read_only_commands must not reopen shell interpreters") } - if got := p.IgnoredReadOnlyCommands(); len(got) != 1 || got[0] != "bash" { - t.Fatalf("IgnoredReadOnlyCommands() = %v, want [bash]", got) + want := []string{"bash", "gh", "gh issue", "gh issue close"} + if got := p.IgnoredReadOnlyCommands(); !reflect.DeepEqual(got, want) { + t.Fatalf("IgnoredReadOnlyCommands() = %v, want %v", got, want) + } +} + +func TestDecideUnknownBashReadOnlyCommandCanAskForTrust(t *testing.T) { + args, err := json.Marshal(map[string]any{"command": "gh issue view 4572 --json title"}) + if err != nil { + t.Fatal(err) + } + decision := (Policy{}).Decide(Call{Name: "bash", Args: args}) + if !decision.Blocked || decision.ReadOnlyCommandTrust == nil { + t.Fatalf("unknown query should be blocked with trust candidate, got %+v", decision) + } + if decision.ReadOnlyCommandTrust.Prefix != "gh issue view" { + t.Fatalf("trust prefix = %q, want gh issue view", decision.ReadOnlyCommandTrust.Prefix) + } +} + +func TestDecideUnsafeUnknownBashCommandDoesNotAskForTrust(t *testing.T) { + for _, cmd := range []string{ + "gh issue close 4572", + "gh pr merge 5867", + "aws s3 rm s3://bucket/key", + "bash -lc 'gh issue view 1'", + } { + t.Run(cmd, func(t *testing.T) { + args, err := json.Marshal(map[string]any{"command": cmd}) + if err != nil { + t.Fatal(err) + } + decision := (Policy{}).Decide(Call{Name: "bash", Args: args}) + if decision.ReadOnlyCommandTrust != nil { + t.Fatalf("unsafe command should not ask for trust, got %+v", decision.ReadOnlyCommandTrust) + } + }) } }