Skip to content

Commit 57a9590

Browse files
committed
policy object
1 parent 25101de commit 57a9590

35 files changed

Lines changed: 521 additions & 748 deletions

exectool/exectool.go

Lines changed: 147 additions & 120 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,8 @@ package exectool
33
import (
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).
2220
type 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+
3342
func 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).
50103
type 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

66110
type ExecToolOption func(*ExecTool) error
67111

68112
func 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

75120
func 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

82137
func 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).
91147
func 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

139208
func 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

241258
func (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-
250265
func (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

Comments
 (0)