返回 DeepSeek-Reasonix
writable_roots.go
根目录 / internal / sandbox / writable_roots.go
1 package sandbox
2
3 import (
4 "context"
5 "path/filepath"
6 "strings"
7 "sync"
8 )
9
10 // WritableRootSet is the session-scoped writable directory manager. Baseline
11 // is the workspace plus configured allow_write and --add-dir roots. Session
12 // holds directories approved for the rest of this logical session. Per-call
13 // roots ride on the execution context and never leak to other tool calls.
14 type WritableRootSet struct {
15 mu sync.RWMutex
16 baseline []string
17 session []string
18 }
19
20 // NewWritableRootSet builds a set with the given baseline roots.
21 func NewWritableRootSet(baseline []string) *WritableRootSet {
22 return &WritableRootSet{baseline: CollapseWriteRoots(canonicalDirs(baseline))}
23 }
24
25 // ReplaceBaseline swaps the configured roots (workspace, allow_write, --add-dir)
26 // without dropping session grants.
27 func (s *WritableRootSet) ReplaceBaseline(roots []string) {
28 if s == nil {
29 return
30 }
31 s.mu.Lock()
32 s.baseline = CollapseWriteRoots(canonicalDirs(roots))
33 s.mu.Unlock()
34 }
35
36 // GrantVerifiedBaseline adds already-verified absolute identities to the
37 // persistent baseline. They survive ClearSession and are not re-resolved.
38 func (s *WritableRootSet) GrantVerifiedBaseline(dirs []string) {
39 if s == nil || len(dirs) == 0 {
40 return
41 }
42 s.mu.Lock()
43 s.baseline = CollapseWriteRoots(append(append([]string{}, s.baseline...), verifiedDirs(dirs)...))
44 s.mu.Unlock()
45 }
46
47 // GrantSession adds directories to the session grant set.
48 func (s *WritableRootSet) GrantSession(dirs []string) {
49 if s == nil || len(dirs) == 0 {
50 return
51 }
52 s.mu.Lock()
53 s.session = CollapseWriteRoots(append(append([]string{}, s.session...), canonicalDirs(dirs)...))
54 s.mu.Unlock()
55 }
56
57 // GrantVerifiedSession adds already-verified absolute identities without
58 // following their path components again after the user approved them.
59 func (s *WritableRootSet) GrantVerifiedSession(dirs []string) {
60 if s == nil || len(dirs) == 0 {
61 return
62 }
63 s.mu.Lock()
64 s.session = CollapseWriteRoots(append(append([]string{}, s.session...), verifiedDirs(dirs)...))
65 s.mu.Unlock()
66 }
67
68 // ClearSession drops session grants. Project baseline is left intact.
69 func (s *WritableRootSet) ClearSession() {
70 if s == nil {
71 return
72 }
73 s.mu.Lock()
74 s.session = nil
75 s.mu.Unlock()
76 }
77
78 // SessionRoots returns a copy of the session-approved directories.
79 func (s *WritableRootSet) SessionRoots() []string {
80 if s == nil {
81 return nil
82 }
83 s.mu.RLock()
84 defer s.mu.RUnlock()
85 return append([]string(nil), s.session...)
86 }
87
88 // RevokeSession removes one exact session-approved root. Ancestor and child
89 // grants are deliberately left alone so revocation cannot broaden or silently
90 // reshape another authorization.
91 func (s *WritableRootSet) RevokeSession(dir string) bool {
92 if s == nil {
93 return false
94 }
95 dir = canonicalDir(dir)
96 if dir == "" || dir == "." || !filepath.IsAbs(dir) {
97 return false
98 }
99 s.mu.Lock()
100 defer s.mu.Unlock()
101 removed := false
102 kept := s.session[:0]
103 for _, root := range s.session {
104 if sameWritePath(root, dir) {
105 removed = true
106 continue
107 }
108 kept = append(kept, root)
109 }
110 s.session = append([]string(nil), kept...)
111 return removed
112 }
113
114 // Snapshot returns baseline plus session grants, collapsed.
115 func (s *WritableRootSet) Snapshot() []string {
116 if s == nil {
117 return nil
118 }
119 s.mu.RLock()
120 defer s.mu.RUnlock()
121 return CollapseWriteRoots(append(append([]string{}, s.baseline...), s.session...))
122 }
123
124 // Effective returns baseline + session + per-call roots from ctx.
125 func (s *WritableRootSet) Effective(ctx context.Context) []string {
126 return CollapseWriteRoots(append(s.Snapshot(), PerCallWriteRoots(ctx)...))
127 }
128
129 // EffectiveSandboxRoots omits any approved root whose identity has changed.
130 // Bash uses this fail-closed view when constructing its OS sandbox.
131 func (s *WritableRootSet) EffectiveSandboxRoots(ctx context.Context) []string {
132 return stableWriteRoots(s.Effective(ctx))
133 }
134
135 // Covers reports whether dir is inside the current baseline+session snapshot.
136 func (s *WritableRootSet) Covers(dir string) bool {
137 dir = canonicalDir(dir)
138 if dir == "" {
139 return false
140 }
141 for _, root := range stableWriteRoots(s.Snapshot()) {
142 if PathWithin(root, dir) {
143 return true
144 }
145 }
146 return false
147 }
148
149 // Missing returns the subset of dirs not already covered by the snapshot.
150 func (s *WritableRootSet) Missing(dirs []string) []string {
151 if len(dirs) == 0 {
152 return nil
153 }
154 snap := stableWriteRoots(s.Snapshot())
155 var missing []string
156 for _, dir := range CollapseWriteRoots(canonicalDirs(dirs)) {
157 covered := false
158 for _, root := range snap {
159 if PathWithin(root, dir) {
160 covered = true
161 break
162 }
163 }
164 if !covered {
165 missing = append(missing, dir)
166 }
167 }
168 return missing
169 }
170
171 // CloneRestricted returns a new set whose baseline is the intersection of this
172 // set's snapshot with cap. The clone has no session grants. An empty cap
173 // copies the current snapshot (inherit, do not expand).
174 func (s *WritableRootSet) CloneRestricted(cap []string) *WritableRootSet {
175 snap := s.Snapshot()
176 if len(cap) == 0 {
177 return newVerifiedWritableRootSet(snap)
178 }
179 return newVerifiedWritableRootSet(intersectVerifiedWriteRoots(snap, canonicalDirs(cap)))
180 }
181
182 // IntersectWriteRoots returns directories that sit in both a and b, preferring
183 // the more specific path when one side is an ancestor of the other.
184 func IntersectWriteRoots(a, b []string) []string {
185 a = CollapseWriteRoots(canonicalDirs(a))
186 b = CollapseWriteRoots(canonicalDirs(b))
187 return intersectVerifiedWriteRoots(a, b)
188 }
189
190 func intersectVerifiedWriteRoots(a, b []string) []string {
191 if len(a) == 0 || len(b) == 0 {
192 return nil
193 }
194 var out []string
195 for _, left := range a {
196 for _, right := range b {
197 switch {
198 case PathWithin(left, right):
199 out = append(out, right)
200 case PathWithin(right, left):
201 out = append(out, left)
202 }
203 }
204 }
205 return CollapseWriteRoots(out)
206 }
207
208 type perCallWriteRootsKey struct{}
209
210 // WithPerCallWriteRoots stamps once-only writable directories onto ctx.
211 func WithPerCallWriteRoots(ctx context.Context, dirs []string) context.Context {
212 if ctx == nil {
213 ctx = context.Background()
214 }
215 dirs = CollapseWriteRoots(verifiedDirs(dirs))
216 if len(dirs) == 0 {
217 return ctx
218 }
219 return context.WithValue(ctx, perCallWriteRootsKey{}, dirs)
220 }
221
222 // PerCallWriteRoots returns once-only writable directories from ctx.
223 func PerCallWriteRoots(ctx context.Context) []string {
224 if ctx == nil {
225 return nil
226 }
227 dirs, _ := ctx.Value(perCallWriteRootsKey{}).([]string)
228 return append([]string(nil), dirs...)
229 }
230
231 func canonicalDirs(dirs []string) []string {
232 out := make([]string, 0, len(dirs))
233 for _, dir := range dirs {
234 if resolved := canonicalDir(dir); resolved != "" {
235 out = append(out, resolved)
236 }
237 }
238 return out
239 }
240
241 func newVerifiedWritableRootSet(baseline []string) *WritableRootSet {
242 return &WritableRootSet{baseline: CollapseWriteRoots(verifiedDirs(baseline))}
243 }
244
245 func verifiedDirs(dirs []string) []string {
246 out := make([]string, 0, len(dirs))
247 for _, dir := range dirs {
248 dir = filepath.Clean(strings.TrimSpace(dir))
249 if dir != "" && dir != "." && filepath.IsAbs(dir) {
250 out = append(out, dir)
251 }
252 }
253 return out
254 }
255
256 // stableWriteRoots drops roots whose current symlink-resolved identity no
257 // longer matches the identity captured when the root was configured or
258 // approved. Omitting a stale root makes sandbox construction fail closed.
259 func stableWriteRoots(dirs []string) []string {
260 out := make([]string, 0, len(dirs))
261 for _, dir := range verifiedDirs(dirs) {
262 resolved, err := ResolveAbsPath(dir)
263 if err == nil && sameWritePath(dir, resolved) {
264 out = append(out, dir)
265 }
266 }
267 return CollapseWriteRoots(out)
268 }
269
269 lines GO