Skip to content

Commit b1326ef

Browse files
committed
Fix plan mode review regressions
Honor shell quoting when screening plan-mode bash commands, allow explicitly declared MCP servers to connect in token economy plan mode, and strip the legacy plan marker from restored display text. Co-authored-by: SivanCola <32437197+SivanCola@users.noreply.github.com>
1 parent e3b4d2a commit b1326ef

7 files changed

Lines changed: 250 additions & 15 deletions

File tree

internal/boot/boot.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -922,7 +922,8 @@ func Build(ctx context.Context, opts Options) (*control.Controller, error) {
922922
}
923923
return fmt.Sprintf("enabled MCP server %q tools: %s.", spec.Name, strings.Join(names, ", ")), nil
924924
},
925-
mcpNames: onDemandMCPNames,
925+
mcpNames: onDemandMCPNames,
926+
planModeAllowedTools: cfg.Agent.PlanModeAllowedTools,
926927
})
927928
}
928929

internal/boot/boot_test.go

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1360,6 +1360,76 @@ READ ONLY SKILL BODY`)
13601360
}
13611361
}
13621362

1363+
func TestBuildTokenEconomyPlanModeCanConnectAllowedMCPSource(t *testing.T) {
1364+
isolateConfigHome(t)
1365+
dir := robustTempDir(t)
1366+
t.Chdir(dir)
1367+
1368+
registerBootTokenProfileTestProvider()
1369+
prov := testutil.NewMock("token-economy",
1370+
testutil.Turn{ToolCalls: []provider.ToolCall{
1371+
{ID: "source-1", Name: "connect_tool_source", Arguments: `{"source":"mcp","name":"mockmcp"}`},
1372+
}},
1373+
testutil.Turn{Text: "done"},
1374+
)
1375+
setBootTokenProfileTestProvider(t, prov)
1376+
writeFile(t, dir, "reasonix.toml", fmt.Sprintf(`
1377+
default_model = "test-model"
1378+
1379+
[agent]
1380+
system_prompt = "BASE"
1381+
plan_mode_allowed_tools = ["mcp__mockmcp__echo"]
1382+
1383+
[[providers]]
1384+
name = "test-model"
1385+
kind = "boot-token-profile-test"
1386+
model = "x"
1387+
1388+
[[plugins]]
1389+
name = "mockmcp"
1390+
command = %q
1391+
args = ["-test.run=TestHelperProcess", "--"]
1392+
env = { GO_WANT_HELPER_PROCESS = "1" }
1393+
`, os.Args[0]))
1394+
1395+
ctrl, err := Build(context.Background(), Options{Sink: event.Discard, TokenMode: TokenModeEconomy})
1396+
if err != nil {
1397+
t.Fatalf("Build: %v", err)
1398+
}
1399+
defer ctrl.Close()
1400+
ctrl.SetPlanMode(true)
1401+
if err := ctrl.Run(context.Background(), "connect allowed mcp while planning"); err != nil {
1402+
t.Fatalf("Run: %v", err)
1403+
}
1404+
1405+
reqs := prov.Requests()
1406+
if len(reqs) != 2 {
1407+
t.Fatalf("requests = %d, want 2", len(reqs))
1408+
}
1409+
if !requestHasTool(reqs[1], "mcp__mockmcp__echo") {
1410+
t.Fatalf("second request should expose allowed MCP source in plan economy mode; tools=%v", toolSchemaNames(reqs[1].Tools))
1411+
}
1412+
for _, msg := range ctrl.History() {
1413+
if msg.Role == provider.RoleTool && msg.Name == "connect_tool_source" {
1414+
if strings.Contains(msg.Content, "blocked:") {
1415+
t.Fatalf("connect_tool_source should not block allowed MCP in plan mode, got:\n%s", msg.Content)
1416+
}
1417+
if !strings.Contains(msg.Content, `enabled MCP server "mockmcp" tools: mcp__mockmcp__echo`) {
1418+
t.Fatalf("connect_tool_source should report enabled MCP tools, got:\n%s", msg.Content)
1419+
}
1420+
}
1421+
}
1422+
}
1423+
1424+
func TestPlanModeAllowsMCPServerRequiresConcreteToolName(t *testing.T) {
1425+
if planModeAllowsMCPServer([]string{"mcp__mockmcp__"}, "mockmcp") {
1426+
t.Fatal("bare MCP namespace prefix should not allow a server in plan mode")
1427+
}
1428+
if !planModeAllowsMCPServer([]string{"mcp__mockmcp__echo"}, "mockmcp") {
1429+
t.Fatal("concrete MCP tool name should allow its server in plan mode")
1430+
}
1431+
}
1432+
13631433
func TestBuildTokenEconomyPlanModeBlocksSourcesWithPolicy(t *testing.T) {
13641434
tests := []struct {
13651435
source string

internal/boot/token_profile.go

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010

1111
"reasonix/internal/agent"
1212
"reasonix/internal/planmode"
13+
"reasonix/internal/plugin"
1314
"reasonix/internal/tool"
1415
)
1516

@@ -80,6 +81,8 @@ type toolSourceConnector struct {
8081
lsp func(context.Context) (string, error)
8182
mcp func(context.Context, string) (string, error)
8283
mcpNames []string
84+
85+
planModeAllowedTools []string
8386
}
8487

8588
func (*toolSourceConnector) Name() string { return "connect_tool_source" }
@@ -113,11 +116,12 @@ func (t *toolSourceConnector) Execute(ctx context.Context, args json.RawMessage)
113116
if source == "" {
114117
return "", fmt.Errorf("unknown tool source %q; available: %s", p.Source, strings.Join(t.availableSources(), ", "))
115118
}
119+
name := strings.TrimSpace(p.Name)
116120

117121
t.mu.Lock()
118122
defer t.mu.Unlock()
119123

120-
if blocked, msg := planModeSourceBlocked(ctx, source); blocked {
124+
if blocked, msg := t.planModeSourceBlocked(ctx, source, name); blocked {
121125
return msg, nil
122126
}
123127

@@ -137,7 +141,6 @@ func (t *toolSourceConnector) Execute(ctx context.Context, args json.RawMessage)
137141
case "lsp":
138142
return runSourceInstaller(ctx, "lsp", t.lsp)
139143
case "mcp":
140-
name := strings.TrimSpace(p.Name)
141144
if name == "" {
142145
if len(t.mcpNames) == 0 {
143146
return "No configured MCP servers are available in this session.", nil
@@ -155,10 +158,16 @@ func (t *toolSourceConnector) Execute(ctx context.Context, args json.RawMessage)
155158
}
156159
}
157160

158-
func planModeSourceBlocked(ctx context.Context, source string) (bool, string) {
161+
func (t *toolSourceConnector) planModeSourceBlocked(ctx context.Context, source, name string) (bool, string) {
159162
if !agent.PlanModeFromContext(ctx) {
160163
return false, ""
161164
}
165+
if source == "mcp" {
166+
if name == "" || planModeAllowsMCPServer(t.planModeAllowedTools, name) {
167+
return false, ""
168+
}
169+
return true, fmt.Sprintf("blocked: MCP source %q is not available in plan mode unless plan_mode_allowed_tools declares at least one concrete tool with prefix %q. Keep exploring with read-only tools, then write your plan for approval before using this MCP server.", name, plugin.ToolPrefix(name))
170+
}
162171
// Sources are read-only iff they expose only read-only research surfaces; the
163172
// moderate plan-mode gate then trusts that ReadOnly flag (step 6), while any
164173
// other source stays non-read-only and is fail-closed by the policy.
@@ -167,6 +176,17 @@ func planModeSourceBlocked(ctx context.Context, source string) (bool, string) {
167176
return decision.Blocked, decision.Message
168177
}
169178

179+
func planModeAllowsMCPServer(allowedTools []string, server string) bool {
180+
prefix := plugin.ToolPrefix(server)
181+
for _, name := range allowedTools {
182+
name = strings.TrimSpace(name)
183+
if strings.HasPrefix(name, prefix) && len(name) > len(prefix) {
184+
return true
185+
}
186+
}
187+
return false
188+
}
189+
170190
func normalizeToolSource(source string) string {
171191
switch strings.ToLower(strings.TrimSpace(source)) {
172192
case "skill", "skills":

internal/control/input.go

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ import (
1515
// prompt prefix is left untouched and the toggle costs nothing in cache hits.
1616
const PlanModeMarker = planmode.Marker
1717

18+
const legacyPlanModeMarker = "[Plan mode — read-only. Explore the codebase first (read_file, ls, grep, glob, web_fetch, task, ask are available; writers are refused by the harness). Before planning, if a decision that is genuinely the user's — tech stack, an ambiguous requirement, scope, an irreversible choice — would materially shape the plan and you can't settle it from the codebase or a sensible default, use the ask tool to clarify it first; otherwise pick the obvious default and state the assumption in the plan instead of asking. Then present a LAYERED plan as your reply and stop — do not write files, edit, or run side-effecting bash. Structure the plan as a two-level markdown list so it becomes a layered task list: each PHASE is a top-level numbered list item (a coherent milestone, e.g. \"1. Add the config loader\"), and each phase's concrete, verifiable sub-steps are bullets indented beneath it (e.g. \" - parse the TOML into Config\"). Use plain numbered list items for phases — do NOT write phases as markdown headings (##, ###) — so both levels parse. Keep phases few (about 2-6). The user will be asked to approve before any changes are made.]"
19+
1820
const (
1921
activeGoalOpen = "<active-goal>"
2022
activeGoalClose = "</active-goal>"
@@ -44,12 +46,17 @@ const (
4446
// feature, or synthetic user messages injected by the controller).
4547
func StripComposePrefixes(content string) string {
4648
s := agent.StripTransientUserBlocks(content)
47-
s = strings.TrimPrefix(s, PlanModeMarker+"\n\n")
48-
s = strings.TrimPrefix(s, PlanModeMarker)
49+
s = stripComposeMarker(s, PlanModeMarker)
50+
s = stripComposeMarker(s, legacyPlanModeMarker)
4951
s = strings.TrimSpace(s)
5052
return s
5153
}
5254

55+
func stripComposeMarker(s, marker string) string {
56+
s = strings.TrimPrefix(s, marker+"\n\n")
57+
return strings.TrimPrefix(s, marker)
58+
}
59+
5360
// StripReferencedContextPrefix removes the "Referenced context:" preamble and
5461
// the trailing XML reference blocks (<file>, <dir>, <resource>, <image>) that
5562
// controller.ResolveRefs injects when the user @-references files or resources.

internal/control/input_test.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -783,6 +783,11 @@ func TestStripComposePrefixes(t *testing.T) {
783783
input: PlanModeMarker + "\n\nexplain this function",
784784
want: "explain this function",
785785
},
786+
{
787+
name: "legacy plan mode marker stripped",
788+
input: legacyPlanModeMarker + "\n\nexplain this function",
789+
want: "explain this function",
790+
},
786791
{
787792
name: "plan mode marker without trailing newlines",
788793
input: PlanModeMarker,

internal/planmode/policy.go

Lines changed: 91 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -328,7 +328,14 @@ func decideBash(args json.RawMessage) Decision {
328328
if !bashMatchesSafePrefix(lower, safe) {
329329
continue
330330
}
331-
if arg := unsafeSafeCommandArg(cmd, safe); arg != "" {
331+
arg, err := unsafeSafeCommandArg(cmd, safe)
332+
if err != "" {
333+
return Decision{
334+
Blocked: true,
335+
Message: fmt.Sprintf("blocked: bash command in plan mode has malformed shell quoting (%s). Use a simple read-only command while planning.", err),
336+
}
337+
}
338+
if arg != "" {
332339
return Decision{
333340
Blocked: true,
334341
Message: fmt.Sprintf("blocked: bash command in plan mode uses a write-capable argument (%q). Use a read-only command while planning.", arg),
@@ -354,11 +361,14 @@ func bashMatchesSafePrefix(lower, safe string) bool {
354361
return unicode.IsSpace(r)
355362
}
356363

357-
func unsafeSafeCommandArg(cmd, safe string) string {
358-
fields := strings.Fields(cmd)
364+
func unsafeSafeCommandArg(cmd, safe string) (string, string) {
365+
fields, err := shellFields(cmd)
366+
if err != "" {
367+
return "", err
368+
}
359369
base := strings.Fields(safe)
360370
if len(fields) <= len(base) {
361-
return ""
371+
return "", ""
362372
}
363373
args := fields[len(base):]
364374
lowerArgs := make([]string, len(args))
@@ -368,7 +378,7 @@ func unsafeSafeCommandArg(cmd, safe string) string {
368378
if strings.HasPrefix(safe, "git ") {
369379
for _, arg := range lowerArgs {
370380
if arg == "--output" || strings.HasPrefix(arg, "--output=") || arg == "--ext-diff" {
371-
return arg
381+
return arg, ""
372382
}
373383
}
374384
}
@@ -377,21 +387,93 @@ func unsafeSafeCommandArg(cmd, safe string) string {
377387
for i, arg := range args {
378388
lowerArg := lowerArgs[i]
379389
if arg == "-O" || strings.HasPrefix(arg, "-O") || strings.HasPrefix(lowerArg, "--open-files-in-pager") {
380-
return arg
390+
return arg, ""
381391
}
382392
}
383393
case "find":
384394
for _, arg := range lowerArgs {
385395
if findWriteArgs[arg] {
386-
return arg
396+
return arg, ""
387397
}
388398
}
389399
case "go list", "go vet":
390400
for _, arg := range lowerArgs {
391401
if goWriteOrExecArgs[arg] || strings.HasPrefix(arg, "-mod=mod") || strings.HasPrefix(arg, "-modfile=") || strings.HasPrefix(arg, "-toolexec=") || strings.HasPrefix(arg, "-vettool=") {
392-
return arg
402+
return arg, ""
403+
}
404+
}
405+
}
406+
return "", ""
407+
}
408+
409+
func shellFields(s string) ([]string, string) {
410+
var fields []string
411+
var b strings.Builder
412+
inSingle := false
413+
inDouble := false
414+
escaped := false
415+
haveField := false
416+
flush := func() {
417+
if haveField {
418+
fields = append(fields, b.String())
419+
b.Reset()
420+
haveField = false
421+
}
422+
}
423+
for _, r := range s {
424+
if escaped {
425+
b.WriteRune(r)
426+
haveField = true
427+
escaped = false
428+
continue
429+
}
430+
if inSingle {
431+
if r == '\'' {
432+
inSingle = false
433+
continue
393434
}
435+
b.WriteRune(r)
436+
haveField = true
437+
continue
394438
}
439+
if inDouble {
440+
switch r {
441+
case '"':
442+
inDouble = false
443+
case '\\':
444+
escaped = true
445+
default:
446+
b.WriteRune(r)
447+
haveField = true
448+
}
449+
continue
450+
}
451+
switch {
452+
case unicode.IsSpace(r):
453+
flush()
454+
case r == '\'':
455+
inSingle = true
456+
haveField = true
457+
case r == '"':
458+
inDouble = true
459+
haveField = true
460+
case r == '\\':
461+
escaped = true
462+
haveField = true
463+
default:
464+
b.WriteRune(r)
465+
haveField = true
466+
}
467+
}
468+
if escaped {
469+
return nil, "dangling escape"
470+
}
471+
if inSingle {
472+
return nil, "unterminated single quote"
473+
}
474+
if inDouble {
475+
return nil, "unterminated double quote"
395476
}
396-
return ""
477+
flush()
478+
return fields, ""
397479
}

0 commit comments

Comments
 (0)