@@ -3,10 +3,8 @@ package exectool
33import (
44 "context"
55 "maps"
6- "os"
76 "path/filepath"
87 "runtime"
9- "slices"
108 "strings"
119 "sync"
1210 "time"
@@ -18,7 +16,7 @@ import (
1816)
1917
2018// ExecutionPolicy provides policy / hardening knobs (host-configured).
21- // All limits are clamped to executil hard maximums.
19+ // All limits are clamped to executil hard maximums (downstream enforcement) .
2220type ExecutionPolicy struct {
2321 // If true, skip heuristic checks (fork-bomb/backgrounding).
2422 // NOTE: hard-blocked commands are ALWAYS blocked.
@@ -30,6 +28,17 @@ type ExecutionPolicy struct {
3028 MaxCommandLength int
3129}
3230
31+ // Clone returns an independent copy of the ExecutionPolicy.
32+ // (All fields are value types, so a plain copy is sufficient.)
33+ func (p * ExecutionPolicy ) Clone () * ExecutionPolicy {
34+ if p == nil {
35+ return nil
36+ }
37+ cp := new (ExecutionPolicy )
38+ * cp = * p
39+ return cp
40+ }
41+
3342func DefaultExecutionPolicy () ExecutionPolicy {
3443 return ExecutionPolicy {
3544 AllowDangerous : false ,
@@ -40,48 +49,95 @@ func DefaultExecutionPolicy() ExecutionPolicy {
4049 }
4150}
4251
43- // ExecTool is an instance-owned execution tool runner (modeled after fstool.FSTool).
52+ type execToolPolicy struct {
53+ allowedRoots []string
54+ workBaseDir string
55+ blockSymlinks bool
56+ blockedCommands map [string ]struct {}
57+
58+ executionPolicy ExecutionPolicy
59+ runScriptPolicy RunScriptPolicy
60+ }
61+
62+ // Clone returns an independent copy of the policy snapshot.
63+ func (p * execToolPolicy ) Clone () * execToolPolicy {
64+ if p == nil {
65+ return nil
66+ }
67+
68+ cp := new (execToolPolicy )
69+ * cp = * p // copy all value fields (and slice/map headers)
70+
71+ if p .allowedRoots != nil {
72+ cp .allowedRoots = make ([]string , len (p .allowedRoots ))
73+ copy (cp .allowedRoots , p .allowedRoots )
74+ } else {
75+ cp .allowedRoots = nil
76+ }
77+
78+ if p .blockedCommands != nil {
79+ cp .blockedCommands = make (map [string ]struct {}, len (p .blockedCommands ))
80+ maps .Copy (cp .blockedCommands , p .blockedCommands )
81+ } else {
82+ cp .blockedCommands = nil
83+ }
84+
85+ if c := p .executionPolicy .Clone (); c != nil {
86+ cp .executionPolicy = * c
87+ }
88+
89+ if c := p .runScriptPolicy .Clone (); c != nil {
90+ cp .runScriptPolicy = * c
91+ }
92+
93+ return cp
94+ }
95+
96+ // ExecTool is an instance-owned execution tool runner.
4497// It centralizes:
45- // - workBaseDir: base for resolving relative paths
46- // - allowedRoots: optional sandbox roots; if empty, allow all
98+ // - path sandboxing (workBaseDir, allowedRoots, blockSymlinks)
4799// - execution policy (timeouts/output/limits)
100+ // - command blocklist
48101// - session store for shellcommand
49- // - runscript policy (optional tool)
102+ // - runscript policy (optional tool).
50103type ExecTool struct {
51104 mu sync.RWMutex
52105
53- allowedRoots []string
54- // "workBaseDir" is the base for resolving relative paths and the default working directory.
55- // If allowedRoots is set and workBaseDir is empty, InitPathPolicy will default workBaseDir to the first allowed
56- // root.
57- workBaseDir string
58- blockedCommands map [string ]struct {} // includes executil.HardBlockedCommands
59-
60- execPolicy ExecutionPolicy
61- runScriptPolicy RunScriptPolicy
62-
63- sessions * executil.SessionStore
106+ toolPolicy * execToolPolicy
107+ sessions * executil.SessionStore
64108}
65109
66110type ExecToolOption func (* ExecTool ) error
67111
68112func WithAllowedRoots (roots []string ) ExecToolOption {
69113 return func (et * ExecTool ) error {
70- et .allowedRoots = roots
114+ et .ensurePolicy ()
115+ et .toolPolicy .allowedRoots = roots
71116 return nil
72117 }
73118}
74119
75120func WithWorkBaseDir (base string ) ExecToolOption {
76121 return func (et * ExecTool ) error {
77- et .workBaseDir = base
122+ et .ensurePolicy ()
123+ et .toolPolicy .workBaseDir = base
124+ return nil
125+ }
126+ }
127+
128+ // WithBlockSymlinks configures whether symlink traversal should be blocked (if supported downstream).
129+ func WithBlockSymlinks (block bool ) ExecToolOption {
130+ return func (et * ExecTool ) error {
131+ et .ensurePolicy ()
132+ et .toolPolicy .blockSymlinks = block
78133 return nil
79134 }
80135}
81136
82137func WithExecutionPolicy (p ExecutionPolicy ) ExecToolOption {
83138 return func (et * ExecTool ) error {
84- et .execPolicy = p
139+ et .ensurePolicy ()
140+ et .toolPolicy .executionPolicy = p
85141 return nil
86142 }
87143}
@@ -90,6 +146,11 @@ func WithExecutionPolicy(p ExecutionPolicy) ExecToolOption {
90146// Entries must be single command names (not full command lines).
91147func WithBlockedCommands (cmds []string ) ExecToolOption {
92148 return func (et * ExecTool ) error {
149+ et .ensurePolicy ()
150+ if et .toolPolicy .blockedCommands == nil {
151+ et .toolPolicy .blockedCommands = maps .Clone (executil .HardBlockedCommands )
152+ }
153+
93154 for _ , c := range cmds {
94155 n , err := executil .NormalizeBlockedCommand (c )
95156 if err != nil {
@@ -98,55 +159,66 @@ func WithBlockedCommands(cmds []string) ExecToolOption {
98159 if n == "" {
99160 continue
100161 }
101- et .blockedCommands [n ] = struct {}{}
162+
163+ et .toolPolicy .blockedCommands [n ] = struct {}{}
102164 if runtime .GOOS == toolutil .GOOSWindows {
103165 ext := strings .ToLower (filepath .Ext (n ))
104166 switch ext {
105167 case ".exe" , ".com" , ".bat" , ".cmd" :
106- et .blockedCommands [strings .TrimSuffix (n , ext )] = struct {}{}
168+ et .toolPolicy . blockedCommands [strings .TrimSuffix (n , ext )] = struct {}{}
107169 }
108170 }
109171 }
110172 return nil
111173 }
112174}
113175
114- func WithSessionTTL ( ttl time. Duration ) ExecToolOption {
176+ func WithRunScriptPolicy ( p RunScriptPolicy ) ExecToolOption {
115177 return func (et * ExecTool ) error {
116- et .sessions .SetTTL (ttl )
178+ et .ensurePolicy ()
179+ norm , err := NormalizeRunScriptPolicy (p )
180+ if err != nil {
181+ return err
182+ }
183+ et .toolPolicy .runScriptPolicy = norm
117184 return nil
118185 }
119186}
120187
121- func WithMaxSessions ( maxSessions int ) ExecToolOption {
188+ func WithSessionTTL ( ttl time. Duration ) ExecToolOption {
122189 return func (et * ExecTool ) error {
123- et .sessions .SetMaxSessions (maxSessions )
190+ if et .sessions == nil {
191+ et .sessions = executil .NewSessionStore ()
192+ }
193+ et .sessions .SetTTL (ttl )
124194 return nil
125195 }
126196}
127197
128- func WithRunScriptPolicy ( p RunScriptPolicy ) ExecToolOption {
198+ func WithMaxSessions ( maxSessions int ) ExecToolOption {
129199 return func (et * ExecTool ) error {
130- norm , err := NormalizeRunScriptPolicy (p )
131- if err != nil {
132- return err
200+ if et .sessions == nil {
201+ et .sessions = executil .NewSessionStore ()
133202 }
134- et .runScriptPolicy = norm
203+ et .sessions . SetMaxSessions ( maxSessions )
135204 return nil
136205 }
137206}
138207
139208func NewExecTool (opts ... ExecToolOption ) (* ExecTool , error ) {
140209 et := & ExecTool {
141- allowedRoots : nil ,
142- workBaseDir : "" ,
143- blockedCommands : maps .Clone (executil .HardBlockedCommands ),
144-
145- execPolicy : DefaultExecutionPolicy (),
146- runScriptPolicy : DefaultRunScriptPolicy (),
147-
210+ toolPolicy : & execToolPolicy {
211+ allowedRoots : nil ,
212+ workBaseDir : "" ,
213+ blockSymlinks : false ,
214+ blockedCommands : maps .Clone (executil .HardBlockedCommands ),
215+
216+ executionPolicy : DefaultExecutionPolicy (),
217+ runScriptPolicy : DefaultRunScriptPolicy (),
218+ },
148219 sessions : executil .NewSessionStore (),
149220 }
221+
150222 for _ , opt := range opts {
151223 if opt == nil {
152224 continue
@@ -156,112 +228,67 @@ func NewExecTool(opts ...ExecToolOption) (*ExecTool, error) {
156228 }
157229 }
158230
159- eff , roots , err := fileutil .InitPathPolicy (et .workBaseDir , et .allowedRoots )
231+ // Canonicalize/initialize path policy.
232+ eff , roots , err := fileutil .InitPathPolicy (et .toolPolicy .workBaseDir , et .toolPolicy .allowedRoots )
160233 if err != nil {
161234 return nil , err
162235 }
163- et .workBaseDir = eff
164- et .allowedRoots = roots
165236
166237 // Final defensive normalization (covers defaults and any options that didn't normalize).
167- et . runScriptPolicy , err = NormalizeRunScriptPolicy (et .runScriptPolicy )
238+ rsPol , err : = NormalizeRunScriptPolicy (et . toolPolicy .runScriptPolicy )
168239 if err != nil {
169240 return nil , err
170241 }
171242
172- return et , nil
173- }
174-
175- func (et * ExecTool ) WorkBaseDir () string {
176- et .mu .RLock ()
177- defer et .mu .RUnlock ()
178- return et .workBaseDir
179- }
180-
181- func (et * ExecTool ) AllowedRoots () []string {
182- et .mu .RLock ()
183- defer et .mu .RUnlock ()
184- return slices .Clone (et .allowedRoots )
185- }
186-
187- // SetAllowedRoots updates allowed roots at runtime (best-effort).
188- // If the current workBaseDir is not within the new roots, this returns an error and leaves state unchanged.
189- func (et * ExecTool ) SetAllowedRoots (roots []string ) error {
190- canon , err := fileutil .CanonicalizeAllowedRoots (roots )
191- if err != nil {
192- return err
193- }
194- et .mu .Lock ()
195- defer et .mu .Unlock ()
196- if _ , err := fileutil .GetEffectiveWorkDir (et .workBaseDir , canon ); err != nil {
197- return err
243+ et .toolPolicy = & execToolPolicy {
244+ allowedRoots : roots ,
245+ workBaseDir : eff ,
246+ blockSymlinks : et .toolPolicy .blockSymlinks ,
247+ blockedCommands : et .toolPolicy .blockedCommands ,
248+ executionPolicy : et .toolPolicy .executionPolicy ,
249+ runScriptPolicy : rsPol ,
198250 }
199- et .allowedRoots = canon
200- return nil
201- }
202251
203- // SetWorkBaseDir updates the work base directory at runtime (best-effort).
204- func (et * ExecTool ) SetWorkBaseDir (base string ) error {
205- et .mu .RLock ()
206- roots := slices .Clone (et .allowedRoots )
207- et .mu .RUnlock ()
208-
209- b := strings .TrimSpace (base )
210- if b == "" {
211- // Mirror InitPathPolicy behavior:
212- // if a sandbox is configured, default base to the first allowed root;
213- // otherwise default to process CWD.
214- if len (roots ) > 0 {
215- b = roots [0 ]
216- } else {
217- cwd , err := os .Getwd ()
218- if err != nil {
219- return err
220- }
221- b = cwd
222- }
223- }
224- eff , err := fileutil .GetEffectiveWorkDir (b , roots )
225- if err != nil {
226- return err
227- }
228-
229- et .mu .Lock ()
230- et .workBaseDir = eff
231- et .mu .Unlock ()
232- return nil
233- }
234-
235- func (et * ExecTool ) Tools () []spec.Tool {
236- return []spec.Tool {et .ShellCommandTool (), et .RunScriptTool ()}
252+ return et , nil
237253}
238254
239- func (et * ExecTool ) RunScriptTool () spec.Tool { return toolutil .CloneTool (runScriptToolSpec ) }
255+ func (et * ExecTool ) RunScriptTool () spec.Tool { return toolutil .CloneTool (runScriptToolSpec ) }
256+ func (et * ExecTool ) ShellCommandTool () spec.Tool { return toolutil .CloneTool (shellCommandToolSpec ) }
240257
241258func (et * ExecTool ) RunScript (ctx context.Context , args RunScriptArgs ) (* RunScriptOut , error ) {
242259 return toolutil .WithRecoveryResp (func () (* RunScriptOut , error ) {
243- base , roots , execPol , blocked , rsPol := et .snapshot ()
244- return runScript (ctx , args , base , roots , execPol , blocked , rsPol )
260+ p := et .snapshotPolicy ()
261+ return runScript (ctx , args , * p )
245262 })
246263}
247264
248- func (et * ExecTool ) ShellCommandTool () spec.Tool { return toolutil .CloneTool (shellCommandToolSpec ) }
249-
250265func (et * ExecTool ) ShellCommand (ctx context.Context , args ShellCommandArgs ) (* ShellCommandOut , error ) {
251266 return toolutil .WithRecoveryResp (func () (* ShellCommandOut , error ) {
252- base , roots , policy , blocked , _ := et .snapshot ()
253- return shellCommand (ctx , args , base , roots , policy , blocked , et .sessions )
267+ p := et .snapshotPolicy ()
268+ return shellCommand (ctx , args , * p , et .sessions )
254269 })
255270}
256271
257- func (et * ExecTool ) snapshot () ( base string , roots [] string , pol ExecutionPolicy , blocked map [ string ] struct {}, rsPol RunScriptPolicy ) {
272+ func (et * ExecTool ) snapshotPolicy () * execToolPolicy {
258273 et .mu .RLock ()
259- base = et .workBaseDir
260- roots = slices .Clone (et .allowedRoots )
261- pol = et .execPolicy
262- blocked = et .blockedCommands // treated as immutable after construction/options
263- rsPol = et .runScriptPolicy
274+ p := et .toolPolicy
264275 et .mu .RUnlock ()
276+ if p == nil {
277+ return nil
278+ }
279+ return p .Clone ()
280+ }
265281
266- return base , roots , pol , blocked , rsPol
282+ func (et * ExecTool ) ensurePolicy () {
283+ if et .toolPolicy != nil {
284+ return
285+ }
286+ et .toolPolicy = & execToolPolicy {
287+ allowedRoots : nil ,
288+ workBaseDir : "" ,
289+ blockSymlinks : false ,
290+ blockedCommands : maps .Clone (executil .HardBlockedCommands ),
291+ executionPolicy : DefaultExecutionPolicy (),
292+ runScriptPolicy : DefaultRunScriptPolicy (),
293+ }
267294}
0 commit comments