返回 DeepSeek-Reasonix
session_guard.go
根目录 / internal / tool / builtin / session_guard.go
1 package builtin
2
3 import (
4 "fmt"
5 "os"
6 "path/filepath"
7 "strings"
8 )
9
10 // SessionDataGuard rejects agent writes into Reasonix's own session stores:
11 // <state root>/sessions and <state root>/projects/<slug>/sessions. The runtime
12 // is the only writer of those files (CAS ledger + autosave); an agent editing
13 // them from inside a chat races the app's own saves, which surfaces to the user
14 // as endless "conflict copy" forks — the agent sees its write "not take",
15 // retries, and loops. The zero value is unconfined, matching the confine
16 // helpers, so tools registered at init keep their historical behavior.
17 //
18 // allowRoots are the explicitly configured [sandbox] allow_write entries: a
19 // user who deliberately lists a session directory there keeps raw access, so
20 // the guard only blocks the accidental self-write path (a workspace root that
21 // happens to cover the state root, e.g. a home-directory workspace).
22 type SessionDataGuard struct {
23 stateRoot string
24 allowRoots []string
25 hintNeedles []string
26 }
27
28 // NewSessionDataGuard builds a guard for the given Reasonix state root
29 // (config.MemoryUserDir()) and the explicit allow_write entries. Both are
30 // resolved to absolute, symlink-free paths once here, mirroring realRoots.
31 // An empty stateRoot yields an unconfined guard.
32 func NewSessionDataGuard(stateRoot string, allowRoots []string) SessionDataGuard {
33 g := SessionDataGuard{}
34 if strings.TrimSpace(stateRoot) == "" {
35 return g
36 }
37 real, err := realPath(stateRoot)
38 if err != nil {
39 return g
40 }
41 g.stateRoot = real
42 g.allowRoots = realRoots(allowRoots)
43 g.hintNeedles = sessionHintNeedles(stateRoot, real, g.allowRoots)
44 return g
45 }
46
47 // Check returns an error when target resolves into a guarded session store and
48 // is not covered by an explicit allow_write root. The error text is written for
49 // the model: it names why the write is refused and the durable ways forward.
50 func (g SessionDataGuard) Check(target string) error {
51 if g.stateRoot == "" {
52 return nil
53 }
54 abs, err := realPath(target)
55 if err != nil {
56 return nil // can't resolve -> let the caller's normal error path handle it
57 }
58 if g.deniesSecurity(abs) {
59 return fmt.Errorf("path %q is a Reasonix security boundary file (%s holds the global hooks; hooks execute arbitrary shell commands on every future session). Agents may not modify it. "+
60 "Ask the user to edit it themselves, or to add the directory to [sandbox] allow_write in reasonix.toml if raw access is truly intended",
61 target, g.stateRoot)
62 }
63 if !g.denies(abs) {
64 return nil
65 }
66 return fmt.Errorf("path %q is inside Reasonix's own session/state data (%s); the app is the only writer of these files, and edits from a chat race its saves — that surfaces as repeated save-conflict copies. "+
67 "Do not modify session or runtime-state files directly; report the underlying problem instead. If raw access is truly intended, add the directory to [sandbox] allow_write in reasonix.toml",
68 target, g.stateRoot)
69 }
70
71 // securityStateFile reports whether name (a state-root-direct file name,
72 // already case-folded when the platform folds) is a security boundary rather
73 // than a mere runtime ledger. settings.json defines the global hooks:
74 // arbitrary shell commands executed on harness events in every project.
75 func securityStateFile(name string) bool {
76 switch name {
77 case "settings.json":
78 return true
79 }
80 return false
81 }
82
83 // deniesSecurity reports whether abs (absolute, symlink-free) is a state-root-
84 // direct security boundary file (see securityStateFile) not covered by an
85 // explicit allow_write root. Deny-side, so comparisons fold case on
86 // case-insensitive platforms, mirroring denies.
87 func (g SessionDataGuard) deniesSecurity(abs string) bool {
88 root, allow, target := foldGuardPaths(g.stateRoot, g.allowRoots, abs)
89 rel, err := filepath.Rel(root, target)
90 if err != nil || rel == "." || strings.Contains(rel, string(filepath.Separator)) {
91 return false
92 }
93 if !securityStateFile(rel) {
94 return false
95 }
96 return !allowLiftsProtected(allow, target, filepath.Join(root, rel))
97 }
98
99 // runtimeStateFile reports whether name (a state-root-direct file name, already
100 // case-folded when the platform folds) is a desktop runtime ledger the app
101 // rewrites wholesale while running — quit snapshots, topic-index rebuilds,
102 // periodic flushes — so an agent edit vanishes the same way a session-file edit
103 // does. config.toml / credentials / skills stay writable: editing those on the
104 // user's request is a legitimate flow with no autonomous rewriter racing it —
105 // but settings.json is a security boundary, not a ledger, and is denied
106 // separately by securityStateFile.
107 // heartbeat-tasks.json stays writable too — it is documented as human- and
108 // AI-editable (desktop/heartbeat.go, and the heartbeat panel tip says "AI
109 // agents can also edit heartbeat-tasks.json"), so the product explicitly
110 // accepts agent edits racing the engine there.
111 func runtimeStateFile(name string) bool {
112 if strings.HasPrefix(name, "desktop-") {
113 return true // desktop-tabs.json(+.tmp), desktop-projects.json, desktop-window.json, desktop-workspace…
114 }
115 switch name {
116 case "metrics-pending.json", "crash-pending.json":
117 return true
118 }
119 return false
120 }
121
122 // denies reports whether abs (absolute, symlink-free) is inside a guarded
123 // session store or a runtime ledger file, and not explicitly allowed. All
124 // comparisons are deny-side, so they fold case on case-insensitive platforms:
125 // EvalSymlinks keeps the caller's spelling, and on default macOS/Windows
126 // volumes ~/.reasonix/SESSIONS reaches the very same files (the same shape as
127 // the Windows lease-key case split fixed in #6023).
128 func (g SessionDataGuard) denies(abs string) bool {
129 root, allow, target := foldGuardPaths(g.stateRoot, g.allowRoots, abs)
130 if prot := filepath.Join(root, "sessions"); within(prot, target) {
131 return !allowLiftsProtected(allow, target, prot)
132 }
133 if rel, err := filepath.Rel(root, target); err == nil && rel != "." && !strings.Contains(rel, string(filepath.Separator)) {
134 if runtimeStateFile(rel) {
135 return !allowLiftsProtected(allow, target, filepath.Join(root, rel))
136 }
137 }
138 projects := filepath.Join(root, "projects")
139 if !within(projects, target) {
140 return false
141 }
142 rel, err := filepath.Rel(projects, target)
143 if err != nil {
144 return false
145 }
146 parts := strings.Split(rel, string(filepath.Separator))
147 if len(parts) == 2 && parts[1] == ".workspace-root" {
148 return !allowLiftsProtected(allow, target, filepath.Join(projects, parts[0], ".workspace-root"))
149 }
150 if len(parts) < 2 || parts[1] != "sessions" {
151 return false
152 }
153 return !allowLiftsProtected(allow, target, filepath.Join(projects, parts[0], "sessions"))
154 }
155
156 func foldGuardPaths(stateRoot string, allowRoots []string, abs string) (root string, allow []string, target string) {
157 root, allow, target = stateRoot, allowRoots, abs
158 if !foldPaths {
159 return root, allow, target
160 }
161 target = strings.ToLower(target)
162 root = strings.ToLower(root)
163 folded := make([]string, len(allow))
164 for i, a := range allow {
165 folded[i] = strings.ToLower(a)
166 }
167 return root, folded, target
168 }
169
170 // allowLiftsProtected reports whether an explicit allow_write root lifts
171 // protection for target. The allow root must itself sit inside protectedRoot;
172 // an ancestor such as $HOME does not lift.
173 func allowLiftsProtected(allowRoots []string, target, protectedRoot string) bool {
174 if protectedRoot == "" {
175 return false
176 }
177 for _, allow := range allowRoots {
178 if within(allow, target) && within(protectedRoot, allow) {
179 return true
180 }
181 }
182 return false
183 }
184
185 // CommandHint returns a warning to append to bash output when the command
186 // references the guarded state trees, and "" otherwise. bash cannot know what a
187 // command actually wrote (off mode runs raw, and write roots may legitimately
188 // cover the state root), so this is a lexical check on the command text —
189 // enough to break the agent's "write → app overwrites it → looks like my write
190 // failed → retry" loop, which is how session-data self-writes burn tokens in
191 // the wild. It never blocks: reading session files for diagnostics is
192 // legitimate. workDir is the directory the command runs in: when it sits
193 // inside the state root (the desktop Global workspace lives at
194 // <state root>/global-workspace), relative references like ../sessions reach
195 // the stores without ever spelling an absolute path, so relative forms are
196 // matched too — and a workDir already inside a guarded store warns on every
197 // command.
198 func (g SessionDataGuard) CommandHint(workDir, command string) string {
199 if g.stateRoot == "" || command == "" {
200 return ""
201 }
202 warn := fmt.Sprintf("WARNING: this command referenced Reasonix's own session/state data under %s. "+
203 "The app is actively saving those files; external modifications conflict with its saves and are preserved as conflict copies, so an edit can look like it \"did not take\". "+
204 "Do not modify session files from a chat — stop retrying and report the underlying problem instead.", g.stateRoot)
205 haystack := strings.ToLower(filepath.ToSlash(command))
206 for _, needle := range g.hintNeedles {
207 if strings.Contains(haystack, needle) {
208 return warn
209 }
210 }
211 if workDir != "" {
212 if absWork, err := realPath(workDir); err == nil {
213 if g.denies(absWork) {
214 return warn // cwd is already inside a guarded store: every command operates on it
215 }
216 if withinFold(g.stateRoot, absWork) {
217 for _, sub := range []string{"sessions", "projects"} {
218 rel, err := filepath.Rel(absWork, filepath.Join(g.stateRoot, sub))
219 if err != nil {
220 continue
221 }
222 if needle := strings.ToLower(filepath.ToSlash(rel)); strings.Contains(haystack, needle) {
223 return warn
224 }
225 }
226 }
227 }
228 }
229 return ""
230 }
231
232 // sessionHintNeedles precomputes the lowercase, slash-normalized textual forms
233 // of the guarded trees as they may appear in a command: under the state root as
234 // given, its symlink-resolved form, and abbreviated variants ("~/", "$HOME/",
235 // "${HOME}/", and on the config-dir side "%APPDATA%"/"$env:APPDATA") when a
236 // form sits under the respective base. A tree wholly covered by an allow_write
237 // root is skipped — the user sanctioned raw access there, so warnings would
238 // only nag.
239 func sessionHintNeedles(rawRoot, realRoot string, allowRoots []string) []string {
240 prefixes := map[string]bool{}
241 addPrefix := func(p string) {
242 if p == "" {
243 return
244 }
245 if abs, err := filepath.Abs(p); err == nil {
246 prefixes[filepath.Clean(abs)] = true
247 }
248 }
249 addPrefix(rawRoot)
250 addPrefix(realRoot)
251 home, _ := os.UserHomeDir()
252 cfgDir, _ := os.UserConfigDir()
253
254 var needles []string
255 abbreviate := func(base, tree string, forms ...string) {
256 if base == "" {
257 return
258 }
259 rel, err := filepath.Rel(base, tree)
260 if err != nil || rel == "." || !filepath.IsLocal(rel) {
261 return
262 }
263 slashRel := strings.ToLower(filepath.ToSlash(rel))
264 for _, form := range forms {
265 needles = append(needles, form+"/"+slashRel)
266 }
267 }
268 for prefix := range prefixes {
269 for _, sub := range []string{"sessions", "projects", "desktop-", "metrics-pending.json", "crash-pending.json"} {
270 tree := filepath.Join(prefix, sub)
271 if covered := func() bool {
272 tree := filepath.Join(realRoot, sub)
273 for _, a := range allowRoots {
274 if withinFold(a, tree) && withinFold(tree, a) {
275 return true
276 }
277 if withinFold(tree, a) {
278 return true
279 }
280 }
281 return false
282 }(); covered {
283 continue
284 }
285 needles = append(needles, strings.ToLower(filepath.ToSlash(tree)))
286 abbreviate(home, tree, "~", "$home", "${home}")
287 abbreviate(cfgDir, tree, "%appdata%", "$env:appdata")
288 }
289 }
290 return needles
291 }
292
292 lines GO