返回 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] != "sessions" {
148 return false
149 }
150 return !allowLiftsProtected(allow, target, filepath.Join(projects, parts[0], "sessions"))
151 }
152
153 func foldGuardPaths(stateRoot string, allowRoots []string, abs string) (root string, allow []string, target string) {
154 root, allow, target = stateRoot, allowRoots, abs
155 if !foldPaths {
156 return root, allow, target
157 }
158 target = strings.ToLower(target)
159 root = strings.ToLower(root)
160 folded := make([]string, len(allow))
161 for i, a := range allow {
162 folded[i] = strings.ToLower(a)
163 }
164 return root, folded, target
165 }
166
167 // allowLiftsProtected reports whether an explicit allow_write root lifts
168 // protection for target. The allow root must itself sit inside protectedRoot;
169 // an ancestor such as $HOME does not lift.
170 func allowLiftsProtected(allowRoots []string, target, protectedRoot string) bool {
171 if protectedRoot == "" {
172 return false
173 }
174 for _, allow := range allowRoots {
175 if within(allow, target) && within(protectedRoot, allow) {
176 return true
177 }
178 }
179 return false
180 }
181
182 // CommandHint returns a warning to append to bash output when the command
183 // references the guarded state trees, and "" otherwise. bash cannot know what a
184 // command actually wrote (off mode runs raw, and write roots may legitimately
185 // cover the state root), so this is a lexical check on the command text —
186 // enough to break the agent's "write → app overwrites it → looks like my write
187 // failed → retry" loop, which is how session-data self-writes burn tokens in
188 // the wild. It never blocks: reading session files for diagnostics is
189 // legitimate. workDir is the directory the command runs in: when it sits
190 // inside the state root (the desktop Global workspace lives at
191 // <state root>/global-workspace), relative references like ../sessions reach
192 // the stores without ever spelling an absolute path, so relative forms are
193 // matched too — and a workDir already inside a guarded store warns on every
194 // command.
195 func (g SessionDataGuard) CommandHint(workDir, command string) string {
196 if g.stateRoot == "" || command == "" {
197 return ""
198 }
199 warn := fmt.Sprintf("WARNING: this command referenced Reasonix's own session/state data under %s. "+
200 "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\". "+
201 "Do not modify session files from a chat — stop retrying and report the underlying problem instead.", g.stateRoot)
202 haystack := strings.ToLower(filepath.ToSlash(command))
203 for _, needle := range g.hintNeedles {
204 if strings.Contains(haystack, needle) {
205 return warn
206 }
207 }
208 if workDir != "" {
209 if absWork, err := realPath(workDir); err == nil {
210 if g.denies(absWork) {
211 return warn // cwd is already inside a guarded store: every command operates on it
212 }
213 if withinFold(g.stateRoot, absWork) {
214 for _, sub := range []string{"sessions", "projects"} {
215 rel, err := filepath.Rel(absWork, filepath.Join(g.stateRoot, sub))
216 if err != nil {
217 continue
218 }
219 if needle := strings.ToLower(filepath.ToSlash(rel)); strings.Contains(haystack, needle) {
220 return warn
221 }
222 }
223 }
224 }
225 }
226 return ""
227 }
228
229 // sessionHintNeedles precomputes the lowercase, slash-normalized textual forms
230 // of the guarded trees as they may appear in a command: under the state root as
231 // given, its symlink-resolved form, and abbreviated variants ("~/", "$HOME/",
232 // "${HOME}/", and on the config-dir side "%APPDATA%"/"$env:APPDATA") when a
233 // form sits under the respective base. A tree wholly covered by an allow_write
234 // root is skipped — the user sanctioned raw access there, so warnings would
235 // only nag.
236 func sessionHintNeedles(rawRoot, realRoot string, allowRoots []string) []string {
237 prefixes := map[string]bool{}
238 addPrefix := func(p string) {
239 if p == "" {
240 return
241 }
242 if abs, err := filepath.Abs(p); err == nil {
243 prefixes[filepath.Clean(abs)] = true
244 }
245 }
246 addPrefix(rawRoot)
247 addPrefix(realRoot)
248 home, _ := os.UserHomeDir()
249 cfgDir, _ := os.UserConfigDir()
250
251 var needles []string
252 abbreviate := func(base, tree string, forms ...string) {
253 if base == "" {
254 return
255 }
256 rel, err := filepath.Rel(base, tree)
257 if err != nil || rel == "." || !filepath.IsLocal(rel) {
258 return
259 }
260 slashRel := strings.ToLower(filepath.ToSlash(rel))
261 for _, form := range forms {
262 needles = append(needles, form+"/"+slashRel)
263 }
264 }
265 for prefix := range prefixes {
266 for _, sub := range []string{"sessions", "projects", "desktop-", "metrics-pending.json", "crash-pending.json"} {
267 tree := filepath.Join(prefix, sub)
268 if covered := func() bool {
269 tree := filepath.Join(realRoot, sub)
270 for _, a := range allowRoots {
271 if withinFold(a, tree) && withinFold(tree, a) {
272 return true
273 }
274 if withinFold(tree, a) {
275 return true
276 }
277 }
278 return false
279 }(); covered {
280 continue
281 }
282 needles = append(needles, strings.ToLower(filepath.ToSlash(tree)))
283 abbreviate(home, tree, "~", "$home", "${home}")
284 abbreviate(cfgDir, tree, "%appdata%", "$env:appdata")
285 }
286 }
287 return needles
288 }
289
289 lines GO