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