99 "sync"
1010 "sync/atomic"
1111 "time"
12- "unicode"
1312 "unicode/utf8"
1413
1514 "reasonix/internal/diff"
@@ -19,6 +18,7 @@ import (
1918 "reasonix/internal/jobs"
2019 "reasonix/internal/memory"
2120 "reasonix/internal/nilutil"
21+ "reasonix/internal/planmode"
2222 "reasonix/internal/provider"
2323 "reasonix/internal/tool"
2424)
@@ -29,56 +29,6 @@ import (
2929// window before the next compaction runs.
3030const maxToolOutputBytes = 32 * 1024
3131
32- // planModeDeniedTools lists tools that are unconditionally denied in plan mode.
33- // These are never shown to the LLM and cannot be called even if the agent
34- // somehow references them. The write_file, edit_file, and multi_edit tools are
35- // the canonical file-writing tools; apply_patch is a structured write variant.
36- var planModeDeniedTools = map [string ]bool {
37- "write_file" : true ,
38- "edit_file" : true ,
39- "multi_edit" : true ,
40- "apply_patch" : true ,
41- }
42-
43- // planModeBashMetachars defines shell metacharacters that indicate command
44- // chaining, redirection, or substitution. When any of these appear in a bash
45- // command during plan mode, the command is blocked — even if the command prefix
46- // matches a safe read-only entry — because chaining can introduce side effects
47- // after an otherwise safe prefix.
48- var planModeBashMetachars = []string {"&&" , "||" , ">>" , "<<" , "$(" , "\x60 " , ";" , "|" , ">" , "<" , "&" , "\n " , "\r " }
49-
50- // planModeSafeBashCommands are bash command prefixes that are safe to run in
51- // plan mode. Each entry is matched as a prefix against the trimmed, lowercased
52- // command string. The match requires a shell-argument boundary after the prefix:
53- // whitespace or end-of-string — so "echop" never matches "echo".
54- var planModeSafeBashCommands = []string {
55- "git status" , "git diff" , "git log" , "git show" ,
56- "git ls-files" , "git grep" , "git blame" ,
57- "ls" , "cat" , "grep" , "find" , "head" , "tail" , "pwd" ,
58- "echo" , "wc" , "which" , "type" , "uname" , "hostname" ,
59- "go version" , "go list" , "go doc" , "go vet" ,
60- "node -v" , "npm list" , "python --version" ,
61- }
62-
63- var planModeFindWriteArgs = map [string ]bool {
64- "-delete" : true ,
65- "-exec" : true ,
66- "-execdir" : true ,
67- "-ok" : true ,
68- "-okdir" : true ,
69- "-fprint" : true ,
70- "-fprintf" : true ,
71- "-fls" : true ,
72- }
73-
74- var planModeGoWriteOrExecArgs = map [string ]bool {
75- "-fix" : true ,
76- "-mod" : true ,
77- "-modfile" : true ,
78- "-toolexec" : true ,
79- "-vettool" : true ,
80- }
81-
8232const maxFinalReadinessBlocks = 3
8333const maxEmptyFinalBlocks = 3
8434const maxStreamRecoveries = 3
@@ -310,10 +260,10 @@ type Agent struct {
310260 // session without touching the cache-stable prefix. Set via SetMemoryQueue.
311261 memQueue memory.Queue
312262
313- // planModeAllowedTools is the set of tool names that are exempt from the
314- // plan-mode gate. When non-empty, these tools bypass the read-only check .
263+ // planModeAllowedTools declares extra custom tools that the centralized
264+ // plan-mode policy may treat as read-only. Known blocked tools still lose .
315265 // Populated from Options.PlanModeAllowedTools during construction.
316- planModeAllowedTools map [ string ] bool
266+ planModeAllowedTools [] string
317267
318268 // Context management: when a turn's prompt nears contextWindow, the older
319269 // middle of the session is summarized away, keeping a token-bounded recent
@@ -578,25 +528,11 @@ type Options struct {
578528 // user-turn context. Empty/auto injects nothing.
579529 ReasoningLanguage string
580530
581- // PlanModeAllowedTools names tools that bypass the plan-mode read-only gate.
582- // When a tool named here is called while planMode is true, it executes
583- // without the "plan mode is read-only" block — even if its ReadOnly contract
584- // returns false. Use sparingly; the caller is responsible for ensuring the
585- // tool invocation is safe in a read-only context (e.g. bash for git status).
531+ // PlanModeAllowedTools names extra custom tools the plan-mode policy may treat
532+ // as read-only. It cannot unlock known blocked tools or unsafe bash commands.
586533 PlanModeAllowedTools []string
587534}
588535
589- func stringSet (ss []string ) map [string ]bool {
590- if len (ss ) == 0 {
591- return nil
592- }
593- m := make (map [string ]bool , len (ss ))
594- for _ , s := range ss {
595- m [s ] = true
596- }
597- return m
598- }
599-
600536// New constructs an Agent. MaxSteps <= 0 means no cap — the run loop continues
601537// until the model gives a final answer, the context is cancelled, or the
602538// provider errors (compaction keeps the context bounded). A nil sink is replaced
@@ -651,7 +587,7 @@ func New(prov provider.Provider, tools *tool.Registry, session *Session, opts Op
651587 recentKeep : opts .RecentKeep ,
652588 archiveDir : opts .ArchiveDir ,
653589 keepPolicy : opts .KeepPolicy ,
654- planModeAllowedTools : stringSet ( opts .PlanModeAllowedTools ),
590+ planModeAllowedTools : append ([] string ( nil ), opts .PlanModeAllowedTools ... ),
655591 }
656592 a .SetReasoningLanguage (opts .ReasoningLanguage )
657593 return a
@@ -1725,7 +1661,23 @@ func (a *Agent) executeOne(ctx context.Context, call provider.ToolCall) toolOutc
17251661 }
17261662 }
17271663 if a .planMode .Load () {
1728- if blocked , msg := a .planModeBlocked (call .Name , t .ReadOnly (), json .RawMessage (call .Arguments )); blocked {
1664+ // Translate the tool's optional plan-mode self-report into the policy's
1665+ // tri-state. Mirrors the t.(tool.Previewer) assertion precedent below.
1666+ safety := planmode .PlanSafetyUnknown
1667+ if c , ok := t .(tool.PlanModeClassifier ); ok {
1668+ if c .PlanModeSafe () {
1669+ safety = planmode .PlanSafetySafe
1670+ } else {
1671+ safety = planmode .PlanSafetyUnsafe
1672+ }
1673+ }
1674+ // External tools (MCP) whose ReadOnly() is only a server-reported
1675+ // readOnlyHint are not trusted by plan mode's read-only fast path.
1676+ untrusted := false
1677+ if u , ok := t .(tool.PlanModeUntrustedReadOnly ); ok {
1678+ untrusted = u .PlanModeUntrustedReadOnly ()
1679+ }
1680+ if blocked , msg := a .planModeBlocked (call .Name , t .ReadOnly (), untrusted , safety , json .RawMessage (call .Arguments )); blocked {
17291681 return toolOutcome {
17301682 output : msg ,
17311683 blocked : true ,
@@ -1842,110 +1794,20 @@ func (a *Agent) executeOne(ctx context.Context, call provider.ToolCall) toolOutc
18421794 return toolOutcome {output : body , truncated : truncMsg != "" , truncMsg : truncMsg }
18431795}
18441796
1845- func (a * Agent ) planModeBlocked (toolName string , readOnly bool , args json.RawMessage ) (blocked bool , message string ) {
1846- if readOnly {
1847- return false , ""
1848- }
1849- if planModeDeniedTools [toolName ] {
1850- return true , fmt .Sprintf ("blocked: %q is not available in plan mode. Keep exploring with read-only tools — the user will be asked to approve the plan before any changes are made." , toolName )
1851- }
1852- if a .planModeAllowedTools != nil && a .planModeAllowedTools [toolName ] {
1853- return false , ""
1854- }
1855- if toolName == "bash" {
1856- if blocked , msg := planModeBashBlocked (args ); blocked {
1857- return true , msg
1858- }
1859- return false , ""
1860- }
1861- return true , fmt .Sprintf ("blocked: %q is a writer tool and plan mode is read-only. Keep exploring with read-only tools, then write your plan as your reply — the user will be asked to approve it before any changes are made." , toolName )
1797+ func (a * Agent ) planModeBlocked (toolName string , readOnly , untrusted bool , safety planmode.PlanSafety , args json.RawMessage ) (blocked bool , message string ) {
1798+ decision := planmode.Policy {AllowedTools : a .planModeAllowedTools }.Decide (planmode.Call {
1799+ Name : toolName ,
1800+ ReadOnly : readOnly ,
1801+ Untrusted : untrusted ,
1802+ Safety : safety ,
1803+ Args : args ,
1804+ })
1805+ return decision .Blocked , decision .Message
18621806}
18631807
18641808func planModeBashBlocked (args json.RawMessage ) (bool , string ) {
1865- var p struct {
1866- Command string `json:"command"`
1867- }
1868- if err := json .Unmarshal (args , & p ); err != nil || p .Command == "" {
1869- return false , ""
1870- }
1871- cmd := strings .TrimSpace (p .Command )
1872- lower := strings .ToLower (cmd )
1873-
1874- // Reject commands containing shell metacharacters — chaining, piping,
1875- // redirection, or command substitution can introduce side effects after
1876- // an otherwise safe prefix.
1877- for _ , mc := range planModeBashMetachars {
1878- if strings .Contains (lower , mc ) {
1879- return true , fmt .Sprintf ("blocked: bash command in plan mode must not contain shell operators (%q). Use separate calls for chained commands." , mc )
1880- }
1881- }
1882-
1883- // Check the command prefix against the safe read-only whitelist. Require a
1884- // shell-argument boundary after the match to avoid prefix collisions.
1885- for _ , safe := range planModeSafeBashCommands {
1886- if ! planModeBashMatchesSafePrefix (lower , safe ) {
1887- continue
1888- }
1889- if arg := planModeUnsafeSafeCommandArg (cmd , safe ); arg != "" {
1890- return true , fmt .Sprintf ("blocked: bash command in plan mode uses a write-capable argument (%q). Use a read-only command while planning." , arg )
1891- }
1892- return false , ""
1893- }
1894-
1895- return true , fmt .Sprintf ("blocked: bash commands in plan mode must be read-only. %q is not in the safe command list. Use read-only tools for exploration, then exit plan mode to run this command." , cmd )
1896- }
1897-
1898- func planModeBashMatchesSafePrefix (lower , safe string ) bool {
1899- if ! strings .HasPrefix (lower , safe ) {
1900- return false
1901- }
1902- if len (lower ) == len (safe ) {
1903- return true
1904- }
1905- r , _ := utf8 .DecodeRuneInString (lower [len (safe ):])
1906- return unicode .IsSpace (r )
1907- }
1908-
1909- func planModeUnsafeSafeCommandArg (cmd , safe string ) string {
1910- fields := strings .Fields (cmd )
1911- base := strings .Fields (safe )
1912- if len (fields ) <= len (base ) {
1913- return ""
1914- }
1915- args := fields [len (base ):]
1916- lowerArgs := make ([]string , len (args ))
1917- for i , arg := range args {
1918- lowerArgs [i ] = strings .ToLower (arg )
1919- }
1920- if strings .HasPrefix (safe , "git " ) {
1921- for _ , arg := range lowerArgs {
1922- if arg == "--output" || strings .HasPrefix (arg , "--output=" ) || arg == "--ext-diff" {
1923- return arg
1924- }
1925- }
1926- }
1927- switch safe {
1928- case "git grep" :
1929- for i , arg := range args {
1930- lowerArg := lowerArgs [i ]
1931- if arg == "-O" || strings .HasPrefix (arg , "-O" ) || strings .HasPrefix (lowerArg , "--open-files-in-pager" ) {
1932- return arg
1933- }
1934- }
1935- case "find" :
1936- for _ , arg := range lowerArgs {
1937- if planModeFindWriteArgs [arg ] {
1938- return arg
1939- }
1940- }
1941- case "go list" , "go vet" :
1942- for _ , arg := range lowerArgs {
1943- if planModeGoWriteOrExecArgs [arg ] || strings .HasPrefix (arg , "-mod=mod" ) || strings .HasPrefix (arg , "-modfile=" ) || strings .HasPrefix (arg , "-toolexec=" ) || strings .HasPrefix (arg , "-vettool=" ) {
1944- return arg
1945- }
1946- }
1947- }
1948- return ""
1809+ decision := planmode.Policy {}.Decide (planmode.Call {Name : "bash" , Args : args })
1810+ return decision .Blocked , decision .Message
19491811}
19501812
19511813func (a * Agent ) repeatedSuccessBlock (call provider.ToolCall , t tool.Tool ) (string , bool ) {
0 commit comments