返回 DeepSeek-Reasonix
confine.go
根目录 / internal / tool / builtin / confine.go
1 package builtin
2
3 import (
4 "context"
5 "fmt"
6 "os"
7 "path/filepath"
8 "runtime"
9 "strings"
10 "time"
11
12 "reasonix/internal/netclient"
13 "reasonix/internal/persistentshell"
14 "reasonix/internal/sandbox"
15 "reasonix/internal/secrets"
16 "reasonix/internal/sessiontemp"
17 "reasonix/internal/tool"
18 )
19
20 // ConfineBash returns the bash built-in bound to an OS-sandbox spec, overriding
21 // the unconfined instance registered at init. When the spec enforces, bash runs
22 // each command through the sandbox (see package sandbox). guard appends a
23 // warning to command output when the command references Reasonix's own session
24 // stores (see SessionDataGuard).
25 //
26 // Session-private temporary directories are bound separately via
27 // BindSessionTemp (or Workspace.SessionTemp) so the timeout variadic form stays
28 // stable for existing callers.
29 func ConfineBash(spec sandbox.Spec, guard SessionDataGuard, timeout ...time.Duration) tool.Tool {
30 shell := spec.Shell
31 if shell.Path == "" {
32 shell = sandbox.ResolveShell("", "", nil)
33 }
34 b := bash{sb: spec, shell: shell, guard: guard}
35 if shell.Kind == sandbox.ShellPowerShell {
36 b.name = "pwsh"
37 }
38 if len(timeout) > 0 {
39 b.timeout = timeout[0]
40 }
41 return b
42 }
43
44 // AliasBash returns the same confined shell executor under a compatibility
45 // name. The alias shares every policy and sandbox binding with the primary tool.
46 func AliasBash(tl tool.Tool, name string) (tool.Tool, bool) {
47 b, ok := tl.(bash)
48 if !ok || strings.TrimSpace(name) == "" {
49 return nil, false
50 }
51 b.name = strings.TrimSpace(name)
52 return b, true
53 }
54
55 // BindSessionTemp attaches a session-private temporary directory manager to a
56 // confined bash (and, when present, grep) tool. ok is false when tl is not a
57 // bash tool (including wrappers that do not unwrap).
58 func BindSessionTemp(tl tool.Tool, m *sessiontemp.Manager) (tool.Tool, bool) {
59 switch t := tl.(type) {
60 case bash:
61 t.sessionTemp = m
62 return t, true
63 case grepTool:
64 t.sessionTemp = m
65 return t, true
66 default:
67 return nil, false
68 }
69 }
70
71 // BindPersistentShell attaches a session-scoped PTY manager to a bash tool.
72 // ok is false when tl is not a bash value (including wrappers that do not unwrap).
73 func BindPersistentShell(tl tool.Tool, m *persistentshell.Manager) (tool.Tool, bool) {
74 b, ok := tl.(bash)
75 if !ok {
76 return nil, false
77 }
78 b.persistent = m
79 return b, true
80 }
81
82 // RebindBashWriteRoots returns a copy of bash with its complete write surface
83 // narrowed to roots. ok is false when tl is not a confined bash tool, when the
84 // sandbox is not enforcing (cannot honour narrower roots), or when roots is empty.
85 // Callers that wrap bash (e.g. foreground-only subagent wrappers) must unwrap
86 // before calling and re-wrap the result.
87 func RebindBashWriteRoots(tl tool.Tool, roots []string) (tool.Tool, bool) {
88 b, ok := tl.(bash)
89 if !ok || !b.sb.Enforce() {
90 return nil, false
91 }
92 rs := realRoots(roots)
93 if len(rs) == 0 {
94 return nil, false
95 }
96 spec := b.sb
97 spec.WriteRoots = rs
98 // Sub-agent claims are strict capability boundaries. Do not add the normal
99 // build-cache and temporary-directory allowances outside the claimed roots.
100 spec.MinimalWrites = true
101 b.sb = spec
102 // rootSet is preserved so later session grants still apply inside the claim.
103 // sessionTemp is preserved: sub-agent write-root rebinding must not drop
104 // the session-private temporary directory manager.
105 return b, true
106 }
107
108 // ConfineWebFetch returns the web_fetch built-in bound to Reasonix proxy
109 // settings while preserving its SSRF-guarded dialer.
110 func ConfineWebFetch(proxySpec netclient.ProxySpec) tool.Tool {
111 return webFetch{proxySpec: proxySpec}
112 }
113
114 // ConfineWriters returns the file-writing built-ins (write_file, edit_file,
115 // multi_edit, move_file, notebook_edit) bound to roots — the only directories they may
116 // modify. The composition root adds these to the per-run registry to override
117 // the unconfined instances registered at init time, so writes stay inside the
118 // workspace by default. roots may be relative; they are resolved to absolute,
119 // symlink-free paths once here. An empty roots slice yields unconfined writers.
120 // guard additionally rejects writes into Reasonix's own session stores even
121 // when the roots would allow them (see SessionDataGuard). managed names the
122 // Reasonix-owned config files writable outside the roots after a fresh human
123 // approval (see ManagedConfigPaths).
124 func ConfineWriters(roots []string, guard SessionDataGuard, managed ManagedConfigPaths) []tool.Tool {
125 rs := realRoots(roots)
126 return []tool.Tool{
127 writeFile{roots: rs, guard: guard, managed: managed},
128 editFile{roots: rs, guard: guard, managed: managed},
129 multiEdit{roots: rs, guard: guard, managed: managed},
130 moveFile{roots: rs, guard: guard, managed: managed},
131 notebookEdit{roots: rs, guard: guard, managed: managed},
132 deleteRange{roots: rs, guard: guard, managed: managed},
133 deleteSymbol{roots: rs, guard: guard, managed: managed},
134 }
135 }
136
137 // ConfineReaders returns the read/list/search built-ins (read_file, glob,
138 // ls, code_index) bound to forbidRoots — directories the agent may not read or list.
139 // grep is handled separately by ConfineSearch so it can carry the
140 // sandbox spec for its ripgrep subprocess.
141 // An empty forbidRoots slice yields unconfined readers.
142 func ConfineReaders(forbidRoots []string) []tool.Tool {
143 rs := realRoots(forbidRoots)
144 return []tool.Tool{
145 readFile{forbidRoots: rs},
146 viewImage{forbidRoots: rs},
147 listDir{forbidRoots: rs},
148 globTool{forbidRoots: rs},
149 codeIndex{forbidRoots: rs},
150 }
151 }
152
153 // confineRead reports whether target is inside any forbidRoot or, when the
154 // user enabled [secrets] protect_sensitive_files, matches Reasonix's built-in
155 // sensitive credential path denylist. An empty forbidRoots slice with the
156 // denylist off is unconfined (returns false). Callers should return a result
157 // that mimics the directory appearing empty, matching the tmpfs semantics the
158 // bubblewrap sandbox provides. Deny-side, so the check folds case on
159 // case-insensitive platforms (see withinFold): a case-variant of a forbidden
160 // path reaches the same bytes there.
161 func confineRead(forbidRoots []string, target string) bool {
162 protect := secrets.ProtectSensitiveFiles()
163 if len(forbidRoots) == 0 && !protect {
164 return false
165 }
166 abs, err := realPath(target)
167 if err != nil {
168 return false // can't resolve -> let the caller's normal error path handle it
169 }
170 if protect && sensitiveReadPath(abs) {
171 return true
172 }
173 for _, r := range forbidRoots {
174 if withinFold(r, abs) {
175 return true
176 }
177 }
178 return false
179 }
180
181 // ReadPathForbidden applies the same resolved-path deny policy used by the
182 // built-in readers. Host-side viewers call this when they re-open a resource
183 // recorded by a trusted tool result so a later configuration or sensitive-file
184 // policy change cannot be bypassed by an old presentation card.
185 func ReadPathForbidden(forbidRoots []string, target string) bool {
186 return confineRead(realRoots(forbidRoots), target)
187 }
188
189 func sensitiveReadPath(abs string) bool {
190 clean := filepath.Clean(abs)
191 name := strings.ToLower(filepath.Base(clean))
192 switch name {
193 case ".env", ".git-credentials", ".netrc":
194 return true
195 }
196 for _, ext := range []string{".pem", ".key", ".p12", ".pfx"} {
197 if strings.HasSuffix(name, ext) {
198 return true
199 }
200 }
201 home, err := os.UserHomeDir()
202 if err == nil && home != "" {
203 if withinFold(filepath.Join(home, ".ssh"), clean) {
204 return true
205 }
206 }
207 return false
208 }
209
210 // realRoots resolves each root to an absolute, symlink-free path, dropping any
211 // that cannot be made absolute. Resolving here (once) means the per-call check
212 // only has to resolve the target.
213 func realRoots(roots []string) []string {
214 out := make([]string, 0, len(roots))
215 for _, r := range roots {
216 if real, err := realPath(r); err == nil {
217 out = append(out, real)
218 }
219 }
220 return out
221 }
222
223 // confine reports an error when target resolves outside every root. An empty
224 // roots slice is unconfined (returns nil) — the safe default for the built-in
225 // templates before a run configures the workspace. The error text is written
226 // for the model: it names the boundary and how the user can widen it.
227 func confine(roots []string, target string) error {
228 if len(roots) == 0 {
229 return nil
230 }
231 abs, err := realPath(target)
232 if err != nil {
233 return fmt.Errorf("resolve %s: %w", target, err)
234 }
235 for _, r := range roots {
236 if within(r, abs) {
237 return nil
238 }
239 }
240 return fmt.Errorf("path %q is outside the writable roots (writes are confined to %s); "+
241 "write inside the workspace or a configured allow_write root, or widen [sandbox] workspace_root / allow_write in reasonix.toml",
242 target, strings.Join(roots, ", "))
243 }
244
245 // confineWrite is the write-tool boundary check: workspace confinement first,
246 // then the session-data guard, so a write can be inside the roots (e.g. a
247 // home-directory workspace covering the state root) and still be refused when
248 // it targets Reasonix's own session stores. A target that matches a
249 // Reasonix-managed config file (see ManagedConfigPaths) is always gated by a
250 // fresh per-write human approval carried on ctx, even when the roots already
251 // cover it: those files configure providers, sandbox rules, and permissions
252 // for future sessions, so no posture (YOLO included) and no widened root may
253 // write them silently. Without an approver the write fails closed.
254 func confineWrite(ctx context.Context, roots []string, guard SessionDataGuard, managed ManagedConfigPaths, target string) error {
255 if managed.Match(target) {
256 if err := guard.Check(target); err != nil {
257 return err
258 }
259 return managed.approve(ctx, target)
260 }
261 if err := confine(roots, target); err != nil {
262 return err
263 }
264 return guard.Check(target)
265 }
266
267 // confinePreview is stricter than confineWrite because Preview runs before the
268 // permission gate and reads old content into the approval card and session.
269 // Managed config files outside the roots therefore stay unreadable here; only
270 // Execute may use their explicit, per-write approval escape hatch.
271 func confinePreview(roots []string, guard SessionDataGuard, _ ManagedConfigPaths, target string) error {
272 if err := confine(roots, target); err != nil {
273 return err
274 }
275 return guard.Check(target)
276 }
277
278 // realPath resolves path to an absolute, symlink-free form. Because a write
279 // target need not exist yet (write_file creates it), it resolves the deepest
280 // existing ancestor with EvalSymlinks and re-appends the not-yet-existing tail.
281 // This stops a symlinked directory from smuggling a write outside a root.
282 func realPath(path string) (string, error) {
283 abs, err := filepath.Abs(path)
284 if err != nil {
285 return "", err
286 }
287 abs = filepath.Clean(abs)
288 tail := ""
289 cur := abs
290 for {
291 if real, err := filepath.EvalSymlinks(cur); err == nil {
292 return filepath.Join(real, tail), nil
293 }
294 parent := filepath.Dir(cur)
295 if parent == cur {
296 return abs, nil // nothing along the path exists; use the cleaned abs
297 }
298 tail = filepath.Join(filepath.Base(cur), tail)
299 cur = parent
300 }
301 }
302
303 // within reports whether path is at or below root. Both must be absolute,
304 // cleaned, symlink-free. It uses filepath.Rel so it is correct across volumes
305 // and is not fooled by a prefix that only matches a partial path component
306 // (e.g. /work-other is not within /work).
307 func within(root, path string) bool {
308 rel, err := filepath.Rel(root, path)
309 if err != nil {
310 return false
311 }
312 return rel == "." || (rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)))
313 }
314
315 // foldPaths reports whether deny-side path checks on this platform must ignore
316 // case: the default filesystems on Windows (NTFS) and macOS (APFS/HFS+) are
317 // case-insensitive, so /X/SESSIONS and /x/sessions reach the same bytes and a
318 // case-variant must not slip past a deny rule. EvalSymlinks does NOT normalize
319 // case, so realPath alone cannot be relied on for this.
320 var foldPaths = runtime.GOOS == "windows" || runtime.GOOS == "darwin"
321
322 // withinFold is within with platform case folding, for DENY-side checks only
323 // (forbid-read roots, the session-data guard). Allow-side checks (confine)
324 // keep the exact within: folding an allow rule on a case-sensitive filesystem
325 // would wave a genuinely different directory through, whereas folding a deny
326 // rule only ever refuses more. On a case-sensitive macOS volume this can
327 // refuse a legitimate same-letters-different-case path; the error text points
328 // at allow_write / forbid_read config as the way out.
329 func withinFold(root, path string) bool {
330 if foldPaths {
331 return within(strings.ToLower(root), strings.ToLower(path))
332 }
333 return within(root, path)
334 }
335
335 lines GO