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