返回 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 := g.stateRoot
89 allow := g.allowRoots
90 if foldPaths {
91 abs = strings.ToLower(abs)
92 root = strings.ToLower(root)
93 folded := make([]string, len(allow))
94 for i, a := range allow {
95 folded[i] = strings.ToLower(a)
96 }
97 allow = folded
98 }
99 for _, a := range allow {
100 if within(a, abs) {
101 return false
102 }
103 }
104 rel, err := filepath.Rel(root, abs)
105 if err != nil || rel == "." || strings.Contains(rel, string(filepath.Separator)) {
106 return false
107 }
108 return securityStateFile(rel)
109 }
110
111 // runtimeStateFile reports whether name (a state-root-direct file name, already
112 // case-folded when the platform folds) is a desktop runtime ledger the app
113 // rewrites wholesale while running — quit snapshots, topic-index rebuilds,
114 // periodic flushes — so an agent edit vanishes the same way a session-file edit
115 // does. config.toml / credentials / skills stay writable: editing those on the
116 // user's request is a legitimate flow with no autonomous rewriter racing it —
117 // but settings.json is a security boundary, not a ledger, and is denied
118 // separately by securityStateFile.
119 // heartbeat-tasks.json stays writable too — it is documented as human- and
120 // AI-editable (desktop/heartbeat.go, and the heartbeat panel tip says "AI
121 // agents can also edit heartbeat-tasks.json"), so the product explicitly
122 // accepts agent edits racing the engine there.
123 func runtimeStateFile(name string) bool {
124 if strings.HasPrefix(name, "desktop-") {
125 return true // desktop-tabs.json(+.tmp), desktop-projects.json, desktop-window.json, desktop-workspace…
126 }
127 switch name {
128 case "metrics-pending.json", "crash-pending.json":
129 return true
130 }
131 return false
132 }
133
134 // denies reports whether abs (absolute, symlink-free) is inside a guarded
135 // session store or a runtime ledger file, and not explicitly allowed. All
136 // comparisons are deny-side, so they fold case on case-insensitive platforms:
137 // EvalSymlinks keeps the caller's spelling, and on default macOS/Windows
138 // volumes ~/.reasonix/SESSIONS reaches the very same files (the same shape as
139 // the Windows lease-key case split fixed in #6023).
140 func (g SessionDataGuard) denies(abs string) bool {
141 root := g.stateRoot
142 allow := g.allowRoots
143 if foldPaths {
144 abs = strings.ToLower(abs)
145 root = strings.ToLower(root)
146 folded := make([]string, len(allow))
147 for i, a := range allow {
148 folded[i] = strings.ToLower(a)
149 }
150 allow = folded
151 }
152 for _, a := range allow {
153 if within(a, abs) {
154 return false
155 }
156 }
157 if within(filepath.Join(root, "sessions"), abs) {
158 return true
159 }
160 // State-root-direct runtime ledgers (desktop-tabs.json & friends).
161 if rel, err := filepath.Rel(root, abs); err == nil && rel != "." && !strings.Contains(rel, string(filepath.Separator)) {
162 if runtimeStateFile(rel) {
163 return true
164 }
165 }
166 // <state root>/projects/<slug>/sessions/** — every per-project store, so
167 // the slug segment is matched positionally rather than enumerated.
168 projects := filepath.Join(root, "projects")
169 if !within(projects, abs) {
170 return false
171 }
172 rel, err := filepath.Rel(projects, abs)
173 if err != nil {
174 return false
175 }
176 parts := strings.Split(rel, string(filepath.Separator))
177 return len(parts) >= 2 && parts[1] == "sessions"
178 }
179
180 // CommandHint returns a warning to append to bash output when the command
181 // references the guarded state trees, and "" otherwise. bash cannot know what a
182 // command actually wrote (off mode runs raw, and write roots may legitimately
183 // cover the state root), so this is a lexical check on the command text —
184 // enough to break the agent's "write → app overwrites it → looks like my write
185 // failed → retry" loop, which is how session-data self-writes burn tokens in
186 // the wild. It never blocks: reading session files for diagnostics is
187 // legitimate. workDir is the directory the command runs in: when it sits
188 // inside the state root (the desktop Global workspace lives at
189 // <state root>/global-workspace), relative references like ../sessions reach
190 // the stores without ever spelling an absolute path, so relative forms are
191 // matched too — and a workDir already inside a guarded store warns on every
192 // command.
193 func (g SessionDataGuard) CommandHint(workDir, command string) string {
194 if g.stateRoot == "" || command == "" {
195 return ""
196 }
197 warn := fmt.Sprintf("WARNING: this command referenced Reasonix's own session/state data under %s. "+
198 "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\". "+
199 "Do not modify session files from a chat — stop retrying and report the underlying problem instead.", g.stateRoot)
200 haystack := strings.ToLower(filepath.ToSlash(command))
201 for _, needle := range g.hintNeedles {
202 if strings.Contains(haystack, needle) {
203 return warn
204 }
205 }
206 if workDir != "" {
207 if absWork, err := realPath(workDir); err == nil {
208 if g.denies(absWork) {
209 return warn // cwd is already inside a guarded store: every command operates on it
210 }
211 if withinFold(g.stateRoot, absWork) {
212 for _, sub := range []string{"sessions", "projects"} {
213 rel, err := filepath.Rel(absWork, filepath.Join(g.stateRoot, sub))
214 if err != nil {
215 continue
216 }
217 if needle := strings.ToLower(filepath.ToSlash(rel)); strings.Contains(haystack, needle) {
218 return warn
219 }
220 }
221 }
222 }
223 }
224 return ""
225 }
226
227 // sessionHintNeedles precomputes the lowercase, slash-normalized textual forms
228 // of the guarded trees as they may appear in a command: under the state root as
229 // given, its symlink-resolved form, and abbreviated variants ("~/", "$HOME/",
230 // "${HOME}/", and on the config-dir side "%APPDATA%"/"$env:APPDATA") when a
231 // form sits under the respective base. A tree wholly covered by an allow_write
232 // root is skipped — the user sanctioned raw access there, so warnings would
233 // only nag.
234 func sessionHintNeedles(rawRoot, realRoot string, allowRoots []string) []string {
235 prefixes := map[string]bool{}
236 addPrefix := func(p string) {
237 if p == "" {
238 return
239 }
240 if abs, err := filepath.Abs(p); err == nil {
241 prefixes[filepath.Clean(abs)] = true
242 }
243 }
244 addPrefix(rawRoot)
245 addPrefix(realRoot)
246 home, _ := os.UserHomeDir()
247 cfgDir, _ := os.UserConfigDir()
248
249 var needles []string
250 abbreviate := func(base, tree string, forms ...string) {
251 if base == "" {
252 return
253 }
254 rel, err := filepath.Rel(base, tree)
255 if err != nil || rel == "." || !filepath.IsLocal(rel) {
256 return
257 }
258 slashRel := strings.ToLower(filepath.ToSlash(rel))
259 for _, form := range forms {
260 needles = append(needles, form+"/"+slashRel)
261 }
262 }
263 for prefix := range prefixes {
264 for _, sub := range []string{"sessions", "projects", "desktop-", "metrics-pending.json", "crash-pending.json"} {
265 tree := filepath.Join(prefix, sub)
266 if covered := func() bool {
267 for _, a := range allowRoots {
268 if withinFold(a, filepath.Join(realRoot, sub)) {
269 return true
270 }
271 }
272 return false
273 }(); covered {
274 continue
275 }
276 needles = append(needles, strings.ToLower(filepath.ToSlash(tree)))
277 abbreviate(home, tree, "~", "$home", "${home}")
278 abbreviate(cfgDir, tree, "%appdata%", "$env:appdata")
279 }
280 }
281 return needles
282 }
283
283 lines GO