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 4578e7e61b..8446fbe2f6 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,18 @@ 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. 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
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..0b57581c6f 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,14 @@ 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 操作符、重定向、命令替换、后台进程、
+以及内置命令里的写能力参数在计划模式下仍会被阻断。交互式计划模式第一次需要某个未知查询前缀时,
+Reasonix 也可以提示你确认是否信任它为只读;选择持久信任会写入同一个
+`[agent].plan_mode_read_only_commands` 配置。Auto/YOLO 审批不会替用户回答这个信任提示。
+
### 环境变量
多数日常设置应写在 `config.toml` 或前文提到的 Reasonix 全局 `.env` 中。下面这些变量是进程级高级开关;
diff --git a/docs/MIGRATING.md b/docs/MIGRATING.md
index 051fd4bb51..c7dfcca003 100644
--- a/docs/MIGRATING.md
+++ b/docs/MIGRATING.md
@@ -103,14 +103,23 @@ 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. 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
+ 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..d37f4a12d0 100644
--- a/docs/SPEC.md
+++ b/docs/SPEC.md
@@ -316,8 +316,16 @@ 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
- control, and `complete_step` (read-only yet post-approval only, so it
+ Bash is gated separately: built-in read-only commands and concrete prefixes
+ 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
@@ -523,6 +531,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..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)
}
@@ -344,7 +350,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.
@@ -568,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
@@ -781,6 +789,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 +864,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 {
@@ -2074,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.
@@ -2091,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
}
@@ -2103,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",
}
@@ -2111,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),
@@ -2225,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
}
@@ -2260,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())
@@ -2273,14 +2327,21 @@ 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 := 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{
Name: toolName,
ReadOnly: readOnly,
Untrusted: untrusted,
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 b97d719b03..ffa5b66a8e 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 {
@@ -213,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
@@ -301,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 e5093af8fd..ee1803d51b 100644
--- a/internal/boot/boot.go
+++ b/internal/boot/boot.go
@@ -176,11 +176,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", strings.Join(ignored, ", ")),
+ 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_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 {
@@ -1002,6 +1013,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,
@@ -1043,18 +1055,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
}
@@ -1099,6 +1112,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)
+ },
SessionRecoveryMeta: opts.SessionRecoveryMeta,
OnSessionRecovered: opts.OnSessionRecovered,
}
@@ -1182,6 +1198,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 48c5600194..c8e60e6085 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)
@@ -2502,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 {
@@ -2511,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/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/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 f94ac7bcd0..b1bdd18af8 100644
--- a/internal/control/controller.go
+++ b/internal/control/controller.go
@@ -98,14 +98,15 @@ 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
- sessionRecoveryMeta func(SessionRecoveryRequest) agent.BranchMeta
- onSessionRecovered func(SessionRecoveryInfo) error
+ 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
+ sessionRecoveryMeta func(SessionRecoveryRequest) agent.BranchMeta
+ onSessionRecovered func(SessionRecoveryInfo) error
// balanceURL/balanceKey target the active provider's optional wallet-balance
// endpoint (empty when the provider declares none). Captured at build so a
@@ -256,6 +257,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 SessionRecoveryRequest struct {
OriginalPath string
Reason string
@@ -337,6 +348,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
// SessionRecoveryMeta lets a frontend attach scope/topic/profile metadata to
// an automatic recovery branch before it is written.
SessionRecoveryMeta func(SessionRecoveryRequest) agent.BranchMeta
@@ -368,40 +383,41 @@ 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,
- sessionRecoveryMeta: opts.SessionRecoveryMeta,
- onSessionRecovered: opts.OnSessionRecovered,
- 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,
+ sessionRecoveryMeta: opts.SessionRecoveryMeta,
+ onSessionRecovered: opts.OnSessionRecovered,
+ 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)
@@ -3644,6 +3660,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 == "" {
@@ -3667,6 +3686,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:
@@ -4165,6 +4211,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 3ae503b22c..389874f1f1 100644
--- a/internal/control/controller_test.go
+++ b/internal/control/controller_test.go
@@ -1703,6 +1703,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{
@@ -1761,6 +1818,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 c467eb111a..f64d9f20e6 100644
--- a/internal/planmode/policy.go
+++ b/internal/planmode/policy.go
@@ -51,11 +51,21 @@ 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.
type Policy struct {
- AllowedTools []string
+ AllowedTools []string
+ ReadOnlyCommands []string
}
var knownBlockedTools = map[string]bool{
@@ -91,6 +101,48 @@ 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,
+}
+
+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
@@ -141,7 +193,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 +249,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 unsafeDeclaredReadOnlyCommandPrefix(fields) {
+ out = append(out, cmd)
+ seen[cmd] = true
+ }
+ }
+ sort.Strings(out)
+ return out
+}
+
func (p Policy) allowed(name string) bool {
for _, allowed := range p.AllowedTools {
if strings.TrimSpace(allowed) == name {
@@ -279,7 +356,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 +391,24 @@ 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{}
+ }
+ 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, 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 +425,116 @@ 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 unsafeDeclaredReadOnlyCommandPrefix(prefixFields) {
+ continue
+ }
+ if len(prefixFields) > len(fields) {
+ continue
+ }
+ matches := true
+ for i, want := range prefixFields {
+ if fields[i] != want {
+ matches = false
+ break
+ }
+ }
+ if matches {
+ return true, ""
+ }
+ }
+ return false, ""
+}
+
+func readOnlyCommandTrustCandidate(cmd string) *ReadOnlyCommandTrust {
+ fields, malformed := shellFields(cmd)
+ if malformed != "" || len(fields) == 0 || unsafeDeclaredReadOnlyCommandPrefix(fields) {
+ return nil
+ }
+ prefixFields := readOnlyCommandTrustPrefixFields(fields)
+ if len(prefixFields) == 0 {
+ return nil
+ }
+ return &ReadOnlyCommandTrust{
+ Command: cmd,
+ Prefix: strings.Join(prefixFields, " "),
+ }
+}
+
+func readOnlyCommandTrustPrefixFields(fields []string) []string {
+ if len(fields) < 2 {
+ return nil
+ }
+ out := []string{fields[0]}
+ for _, field := range fields[1:] {
+ if strings.HasPrefix(field, "-") {
+ out = append(out, field)
+ break
+ }
+ if !readOnlyCommandPrefixWord(field) {
+ break
+ }
+ out = append(out, field)
+ if len(out) >= 3 {
+ break
+ }
+ }
+ if len(out) < 2 || unsafeDeclaredReadOnlyCommandPrefix(out) {
+ return nil
+ }
+ return out
+}
+
+func readOnlyCommandPrefixWord(field string) bool {
+ if field == "" {
+ return false
+ }
+ for i, r := range field {
+ if r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' {
+ continue
+ }
+ if i > 0 && (r >= '0' && r <= '9' || r == '_' || r == '-') {
+ continue
+ }
+ return false
+ }
+ return true
+}
+
+func unsafeDeclaredReadOnlyCommandPrefix(fields []string) bool {
+ if len(fields) < 2 {
+ return true
+ }
+ if readOnlyCommandNeedsThreeTokenPrefixBases[strings.ToLower(fields[0])] && len(fields) < 3 {
+ return true
+ }
+ if unsafeDeclaredReadOnlyCommandBases[strings.ToLower(fields[0])] {
+ return true
+ }
+ for _, field := range fields[1:] {
+ if unsafeDeclaredReadOnlyCommandWords[strings.ToLower(field)] {
+ return true
+ }
+ }
+ return false
+}
+
// unsafePlanModeArg applies plan-mode's stricter, quote-aware argument check on
// top of the shared read-only command classification: a write-capable flag on
// an otherwise read-only command (git --output, git grep --open-files-in-pager,
diff --git a/internal/planmode/policy_test.go b/internal/planmode/policy_test.go
index 69b7385cda..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"
)
@@ -120,6 +121,88 @@ 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", "gh", "gh issue", "gh issue close"}}
+ 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")
+ }
+ 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)
+ }
+ })
+ }
+}
+
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