返回 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 outside every root that
249 // matches a Reasonix-managed config file (see ManagedConfigPaths) may proceed
250 // after a fresh per-write human approval carried on ctx; without an approver it
251 // fails closed with the original confinement error semantics.
252 func confineWrite(ctx context.Context, roots []string, guard SessionDataGuard, managed ManagedConfigPaths, target string) error {
253 confineErr := confine(roots, target)
254 if confineErr == nil {
255 return guard.Check(target)
256 }
257 if !managed.Match(target) {
258 return confineErr
259 }
260 if err := guard.Check(target); err != nil {
261 return err
262 }
263 return managed.approve(ctx, target)
264 }
265
266 // confinePreview is stricter than confineWrite because Preview runs before the
267 // permission gate and reads old content into the approval card and session.
268 // Managed config files outside the roots therefore stay unreadable here; only
269 // Execute may use their explicit, per-write approval escape hatch.
270 func confinePreview(roots []string, guard SessionDataGuard, _ ManagedConfigPaths, target string) error {
271 if err := confine(roots, target); err != nil {
272 return err
273 }
274 return guard.Check(target)
275 }
276
277 // realPath resolves path to an absolute, symlink-free form. Because a write
278 // target need not exist yet (write_file creates it), it resolves the deepest
279 // existing ancestor with EvalSymlinks and re-appends the not-yet-existing tail.
280 // This stops a symlinked directory from smuggling a write outside a root.
281 func realPath(path string) (string, error) {
282 abs, err := filepath.Abs(path)
283 if err != nil {
284 return "", err
285 }
286 abs = filepath.Clean(abs)
287 tail := ""
288 cur := abs
289 for {
290 if real, err := filepath.EvalSymlinks(cur); err == nil {
291 return filepath.Join(real, tail), nil
292 }
293 parent := filepath.Dir(cur)
294 if parent == cur {
295 return abs, nil // nothing along the path exists; use the cleaned abs
296 }
297 tail = filepath.Join(filepath.Base(cur), tail)
298 cur = parent
299 }
300 }
301
302 // within reports whether path is at or below root. Both must be absolute,
303 // cleaned, symlink-free. It uses filepath.Rel so it is correct across volumes
304 // and is not fooled by a prefix that only matches a partial path component
305 // (e.g. /work-other is not within /work).
306 func within(root, path string) bool {
307 rel, err := filepath.Rel(root, path)
308 if err != nil {
309 return false
310 }
311 return rel == "." || (rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)))
312 }
313
314 // foldPaths reports whether deny-side path checks on this platform must ignore
315 // case: the default filesystems on Windows (NTFS) and macOS (APFS/HFS+) are
316 // case-insensitive, so /X/SESSIONS and /x/sessions reach the same bytes and a
317 // case-variant must not slip past a deny rule. EvalSymlinks does NOT normalize
318 // case, so realPath alone cannot be relied on for this.
319 var foldPaths = runtime.GOOS == "windows" || runtime.GOOS == "darwin"
320
321 // withinFold is within with platform case folding, for DENY-side checks only
322 // (forbid-read roots, the session-data guard). Allow-side checks (confine)
323 // keep the exact within: folding an allow rule on a case-sensitive filesystem
324 // would wave a genuinely different directory through, whereas folding a deny
325 // rule only ever refuses more. On a case-sensitive macOS volume this can
326 // refuse a legitimate same-letters-different-case path; the error text points
327 // at allow_write / forbid_read config as the way out.
328 func withinFold(root, path string) bool {
329 if foldPaths {
330 return within(strings.ToLower(root), strings.ToLower(path))
331 }
332 return within(root, path)
333 }
334
334 lines GO