@@ -7,10 +7,20 @@ import (
77 "strings"
88)
99
10+ // FilterResult indicates the outcome of a command filter check.
11+ type FilterResult int
12+
13+ const (
14+ FilterAllow FilterResult = iota // on allowlist, run freely
15+ FilterDeny // hard blocked
16+ FilterPrompt // not on allowlist, ask user
17+ )
18+
1019// CommandFilter validates shell commands against an allowlist or blocklist.
1120type CommandFilter struct {
12- allowed []string // if set, only these prefixes pass
13- blocked []string // if set, these prefixes are rejected
21+ allowed []string // if set, only these prefixes pass
22+ blocked []string // if set, these prefixes are rejected
23+ softAllow bool // if true, non-matching returns FilterPrompt instead of FilterDeny
1424}
1525
1626// NewAllowlistFilter creates a filter that only permits commands
@@ -19,34 +29,61 @@ func NewAllowlistFilter(prefixes []string) *CommandFilter {
1929 return & CommandFilter {allowed : prefixes }
2030}
2131
32+ // NewSoftAllowlistFilter creates a filter that auto-allows commands on the
33+ // allowlist and returns FilterPrompt (not FilterDeny) for everything else.
34+ // Use this for interactive mode where unknown commands should be approved by the user.
35+ func NewSoftAllowlistFilter (prefixes []string ) * CommandFilter {
36+ return & CommandFilter {allowed : prefixes , softAllow : true }
37+ }
38+
2239// NewBlocklistFilter creates a filter that rejects commands
2340// whose first token matches any of the given prefixes.
2441func NewBlocklistFilter (prefixes []string ) * CommandFilter {
2542 return & CommandFilter {blocked : prefixes }
2643}
2744
28- // Check validates the given command string. It splits on shell
29- // operators (|, &&, ;, ||) and checks each segment. It also
30- // detects command substitution via $() and backticks.
31- func (f * CommandFilter ) Check (command string ) error {
45+ // CheckWithResult validates the given command string and returns a FilterResult
46+ // indicating whether to allow, deny, or prompt the user.
47+ func (f * CommandFilter ) CheckWithResult (command string ) (FilterResult , error ) {
3248 if f == nil {
33- return nil
49+ return FilterAllow , nil
3450 }
3551
3652 segments := splitShellSegments (command )
3753 for _ , seg := range segments {
38- if err := f .checkSegment (seg ); err != nil {
39- return err
54+ result , err := f .checkSegmentResult (seg )
55+ if err != nil || result != FilterAllow {
56+ return result , err
4057 }
4158 }
4259
43- // Check inside $() and backtick substitutions.
4460 for _ , sub := range extractSubstitutions (command ) {
45- if err := f .Check (sub ); err != nil {
46- return fmt .Errorf ("in command substitution: %w" , err )
61+ result , err := f .CheckWithResult (sub )
62+ if err != nil {
63+ return result , fmt .Errorf ("in command substitution: %w" , err )
64+ }
65+ if result != FilterAllow {
66+ return result , nil
4767 }
4868 }
4969
70+ return FilterAllow , nil
71+ }
72+
73+ // Check validates the given command string. It splits on shell
74+ // operators (|, &&, ;, ||) and checks each segment. It also
75+ // detects command substitution via $() and backticks.
76+ func (f * CommandFilter ) Check (command string ) error {
77+ result , err := f .CheckWithResult (command )
78+ if err != nil {
79+ return err
80+ }
81+ if result == FilterDeny {
82+ return fmt .Errorf ("command not permitted" )
83+ }
84+ if result == FilterPrompt {
85+ return fmt .Errorf ("command requires approval" )
86+ }
5087 return nil
5188}
5289
@@ -55,41 +92,43 @@ func basename(token string) string {
5592 return filepath .Base (token )
5693}
5794
58- // checkSegment validates a single command segment.
59- // Allowlist: only the first token must match.
60- // Blocklist: every token is checked to catch wrappers like "env curl".
61- func (f * CommandFilter ) checkSegment (seg string ) error {
95+ // checkSegmentResult validates a single command segment and returns a FilterResult.
96+ func (f * CommandFilter ) checkSegmentResult (seg string ) (FilterResult , error ) {
6297 fields := strings .Fields (strings .TrimSpace (seg ))
6398 if len (fields ) == 0 {
64- return nil
99+ return FilterAllow , nil
65100 }
66101 if len (f .allowed ) > 0 {
67- return f .checkToken (fields [0 ])
102+ return f .checkTokenResult (fields [0 ])
68103 }
69104 for _ , tok := range fields {
70- if err := f .checkToken (tok ); err != nil {
71- return err
105+ result , err := f .checkTokenResult (tok )
106+ if err != nil || result != FilterAllow {
107+ return result , err
72108 }
73109 }
74- return nil
110+ return FilterAllow , nil
75111}
76112
77- func (f * CommandFilter ) checkToken (token string ) error {
113+ func (f * CommandFilter ) checkTokenResult (token string ) ( FilterResult , error ) {
78114 base := basename (token )
79115 if len (f .allowed ) > 0 {
80116 for _ , prefix := range f .allowed {
81117 if base == prefix {
82- return nil
118+ return FilterAllow , nil
83119 }
84120 }
85- return fmt .Errorf ("command %q not in allowlist" , token )
121+ if f .softAllow {
122+ return FilterPrompt , nil
123+ }
124+ return FilterDeny , fmt .Errorf ("command %q not in allowlist" , token )
86125 }
87126 for _ , prefix := range f .blocked {
88127 if base == prefix {
89- return fmt .Errorf ("command %q is blocked" , token )
128+ return FilterDeny , fmt .Errorf ("command %q is blocked" , token )
90129 }
91130 }
92- return nil
131+ return FilterAllow , nil
93132}
94133
95134// splitShellSegments splits a command on |, &&, ;, and || operators.
@@ -139,7 +178,18 @@ func extractSubstitutions(cmd string) []string {
139178 return subs
140179}
141180
142- // DefaultBlocklist is the default set of blocked commands for interactive mode.
181+ // InteractiveAllowlist is the set of commands auto-allowed in interactive mode.
182+ // Commands not on this list require user approval (FilterPrompt).
183+ var InteractiveAllowlist = []string {
184+ "obk" , "sqlite3" ,
185+ "ls" , "cat" , "head" , "tail" , "wc" , "sort" , "uniq" , "diff" ,
186+ "find" , "grep" , "rg" ,
187+ "date" , "cal" , "echo" , "printf" ,
188+ "git" , "tree" , "file" , "stat" , "jq" , "which" ,
189+ }
190+
191+ // DefaultBlocklist is the legacy blocklist used when no Interactor is provided
192+ // (e.g. subagents). Interactive mode now uses InteractiveAllowlist instead.
143193var DefaultBlocklist = []string {
144194 // Network
145195 "curl" , "wget" , "nc" , "ncat" , "nmap" ,
0 commit comments