返回 DeepSeek-Reasonix
session_guard_test.go
根目录 / internal / tool / builtin / session_guard_test.go
1 package builtin
2
3 import (
4 "context"
5 "encoding/json"
6 "os"
7 "path/filepath"
8 "strings"
9 "testing"
10
11 "reasonix/internal/sandbox"
12 )
13
14 // stateRootFor builds a fake Reasonix state root with the two guarded session
15 // trees populated, returning the root and one file path in each tree.
16 func stateRootFor(t *testing.T) (root, cliSession, projectSession string) {
17 t.Helper()
18 root = t.TempDir()
19 cliSession = filepath.Join(root, "sessions", "20260707-abc.jsonl")
20 projectSession = filepath.Join(root, "projects", "-Users-me-proj", "sessions", "20260707-def.jsonl")
21 for _, p := range []string{cliSession, projectSession} {
22 if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
23 t.Fatal(err)
24 }
25 if err := os.WriteFile(p, []byte("{}\n"), 0o644); err != nil {
26 t.Fatal(err)
27 }
28 }
29 return root, cliSession, projectSession
30 }
31
32 func TestSessionDataGuardDeniesSessionStores(t *testing.T) {
33 root, cliSession, projectSession := stateRootFor(t)
34 g := NewSessionDataGuard(root, nil)
35
36 for _, target := range []string{
37 cliSession,
38 projectSession,
39 filepath.Join(root, "sessions", "sub", "new.jsonl"), // not-yet-existing file under the store
40 filepath.Join(root, "projects", "any-slug", "sessions", "x.jsonl.meta"), // CAS ledger sidecar
41 filepath.Join(root, "projects", "@assigned", ".workspace-root"), // project state ownership
42 } {
43 if err := g.Check(target); err == nil {
44 t.Errorf("Check(%q) = nil, want session-data denial", target)
45 } else if !strings.Contains(err.Error(), "Reasonix's own session/state data") {
46 t.Errorf("Check(%q) error %q does not name session/state data", target, err)
47 }
48 }
49 }
50
51 func TestSessionDataGuardDeniesRuntimeLedgers(t *testing.T) {
52 root, _, _ := stateRootFor(t)
53 g := NewSessionDataGuard(root, nil)
54
55 for _, target := range []string{
56 filepath.Join(root, "desktop-tabs.json"),
57 filepath.Join(root, "desktop-tabs.json.tmp"), // the fixed atomic-save sibling
58 filepath.Join(root, "desktop-projects.json"),
59 filepath.Join(root, "desktop-window.json"),
60 filepath.Join(root, "desktop-workspace"),
61 filepath.Join(root, "metrics-pending.json"),
62 filepath.Join(root, "crash-pending.json"),
63 } {
64 if err := g.Check(target); err == nil {
65 t.Errorf("Check(%q) = nil, want runtime-ledger denial", target)
66 }
67 }
68 // Same names below a NESTED directory are ordinary files (only
69 // state-root-direct ledgers are the app's).
70 if err := g.Check(filepath.Join(root, "backups", "desktop-tabs.json")); err != nil {
71 t.Errorf("nested copy of a ledger name should be writable: %v", err)
72 }
73 // heartbeat-tasks.json is a documented human/AI-editable contract
74 // (desktop/heartbeat.go; the heartbeat panel tip tells users agents can
75 // edit it) — the guard must never break that flow.
76 if err := g.Check(filepath.Join(root, "heartbeat-tasks.json")); err != nil {
77 t.Errorf("heartbeat-tasks.json is AI-editable by product contract, got %v", err)
78 }
79 }
80
81 func TestSessionDataGuardCaseVariantOnFoldingSystems(t *testing.T) {
82 if !foldPaths {
83 t.Skip("case-sensitive default filesystem: a case variant is a genuinely different path")
84 }
85 root, cliSession, _ := stateRootFor(t)
86 g := NewSessionDataGuard(root, nil)
87
88 upper := filepath.Join(root, "SESSIONS", filepath.Base(cliSession))
89 if err := g.Check(upper); err == nil {
90 t.Fatalf("Check(%q) = nil; case variant reaches the same store on this filesystem and must be denied", upper)
91 }
92 mixedLedger := filepath.Join(root, "Desktop-Tabs.JSON")
93 if err := g.Check(mixedLedger); err == nil {
94 t.Fatalf("Check(%q) = nil, want case-folded ledger denial", mixedLedger)
95 }
96 }
97
98 func TestConfineReadCaseVariantOnFoldingSystems(t *testing.T) {
99 if !foldPaths {
100 t.Skip("case-sensitive default filesystem: a case variant is a genuinely different path")
101 }
102 forbidDir := t.TempDir()
103 secret := filepath.Join(forbidDir, "secret.txt")
104 if err := os.WriteFile(secret, []byte("classified"), 0o644); err != nil {
105 t.Fatal(err)
106 }
107 forbidRoots := realRoots([]string{forbidDir})
108 upper := filepath.Join(filepath.Dir(forbidDir), strings.ToUpper(filepath.Base(forbidDir)), "secret.txt")
109 if !confineRead(forbidRoots, upper) {
110 t.Fatalf("confineRead missed case variant %q of a forbidden root", upper)
111 }
112 }
113
114 func TestSessionDataGuardAllowsOrdinaryStatePaths(t *testing.T) {
115 root, _, _ := stateRootFor(t)
116 g := NewSessionDataGuard(root, nil)
117
118 for _, target := range []string{
119 filepath.Join(root, "config.toml"), // config is confine()'s job, not this guard's
120 filepath.Join(root, "projects", "slug", "memory", "a.md"), // memory files are not session data
121 filepath.Join(root, "skills", "demo", "SKILL.md"),
122 filepath.Join(t.TempDir(), "unrelated.txt"),
123 } {
124 if err := g.Check(target); err != nil {
125 t.Errorf("Check(%q) = %v, want nil", target, err)
126 }
127 }
128 }
129
130 func TestSessionDataGuardZeroValueUnconfined(t *testing.T) {
131 var g SessionDataGuard
132 if err := g.Check("/anywhere/sessions/x.jsonl"); err != nil {
133 t.Errorf("zero-value guard should be unconfined, got %v", err)
134 }
135 if hint := g.CommandHint("", "rm -rf ~/.reasonix/sessions"); hint != "" {
136 t.Errorf("zero-value guard hint = %q, want empty", hint)
137 }
138 }
139
140 func TestSessionDataGuardDeniesSecurityBoundaryFiles(t *testing.T) {
141 root, _, _ := stateRootFor(t)
142 g := NewSessionDataGuard(root, nil)
143
144 // settings.json holds the global hooks (arbitrary shell commands run on
145 // every future session), so it remains a security boundary.
146 target := filepath.Join(root, "settings.json")
147 if err := g.Check(target); err == nil {
148 t.Errorf("Check(%q) = nil, want security-boundary denial", target)
149 } else if !strings.Contains(err.Error(), "security boundary") {
150 t.Errorf("Check(%q) error %q should name the security boundary", target, err)
151 }
152 if err := g.Check(filepath.Join(root, "trust.json")); err != nil {
153 t.Errorf("obsolete trust.json should not remain a security boundary: %v", err)
154 }
155 // The same names nested below the state root are ordinary files (a project
156 // checkout under a home workspace may legitimately contain them).
157 if err := g.Check(filepath.Join(root, "backups", "settings.json")); err != nil {
158 t.Errorf("nested settings.json should be writable: %v", err)
159 }
160 // An explicit allow_write entry stays the sanctioned escape hatch.
161 allowed := NewSessionDataGuard(root, []string{root})
162 if err := allowed.Check(filepath.Join(root, "settings.json")); err == nil {
163 t.Error("allow_write of the state root is an ancestor and must not lift settings.json")
164 }
165 fileAllow := NewSessionDataGuard(root, []string{filepath.Join(root, "settings.json")})
166 if err := fileAllow.Check(filepath.Join(root, "settings.json")); err != nil {
167 t.Errorf("explicit allow_write of settings.json should pass, got %v", err)
168 }
169 }
170
171 func TestSessionDataGuardSecurityFilesCaseVariantOnFoldingSystems(t *testing.T) {
172 if !foldPaths {
173 t.Skip("case-sensitive default filesystem: a case variant is a genuinely different path")
174 }
175 root, _, _ := stateRootFor(t)
176 g := NewSessionDataGuard(root, nil)
177 if err := g.Check(filepath.Join(root, "Settings.JSON")); err == nil {
178 t.Fatal("case variant of settings.json reaches the same bytes on this filesystem and must be denied")
179 }
180 }
181
182 func TestSessionDataGuardHomeAncestorDoesNotLiftSessions(t *testing.T) {
183 root, cliSession, _ := stateRootFor(t)
184 home := filepath.Dir(root)
185 if home == "." || home == string(filepath.Separator) {
186 home = t.TempDir()
187 root = filepath.Join(home, "state")
188 if err := os.MkdirAll(filepath.Join(root, "sessions"), 0o755); err != nil {
189 t.Fatal(err)
190 }
191 cliSession = filepath.Join(root, "sessions", "x.json")
192 }
193 g := NewSessionDataGuard(root, []string{home})
194 if err := g.Check(cliSession); err == nil {
195 t.Fatal("allow_write of $HOME must not lift session-store protection")
196 }
197 }
198
199 func TestSessionDataGuardAllowWriteEscapeHatch(t *testing.T) {
200 root, cliSession, projectSession := stateRootFor(t)
201 g := NewSessionDataGuard(root, []string{filepath.Join(root, "sessions")})
202
203 if err := g.Check(cliSession); err != nil {
204 t.Errorf("allow_write-listed store should pass, got %v", err)
205 }
206 // The other store stays guarded.
207 if err := g.Check(projectSession); err == nil {
208 t.Error("project store should stay denied when only the CLI store is allowed")
209 }
210 }
211
212 func TestWriteToolsRejectSessionData(t *testing.T) {
213 root, cliSession, projectSession := stateRootFor(t)
214 // Workspace root covers the state root — the accidental self-write shape
215 // (e.g. a home-directory workspace).
216 guard := NewSessionDataGuard(root, nil)
217 tools := ConfineWriters([]string{root}, guard, ManagedConfigPaths{})
218
219 argsFor := func(name, target string) json.RawMessage {
220 var m map[string]any
221 switch name {
222 case "write_file":
223 m = map[string]any{"path": target, "content": "tampered"}
224 case "edit_file":
225 m = map[string]any{"path": target, "old_string": "{}", "new_string": "[]"}
226 case "multi_edit":
227 m = map[string]any{"path": target, "edits": []map[string]any{{"old_string": "{}", "new_string": "[]"}}}
228 case "move_file":
229 m = map[string]any{"source_path": target, "destination_path": target + ".bak"}
230 case "notebook_edit":
231 m = map[string]any{"path": target, "cell_index": 0, "mode": "delete"}
232 case "delete_range":
233 m = map[string]any{"path": target, "start_anchor": "{}", "end_anchor": "{}"}
234 case "delete_symbol":
235 m = map[string]any{"path": target, "name": "x"}
236 default:
237 t.Fatalf("unhandled tool %s", name)
238 }
239 b, err := json.Marshal(m)
240 if err != nil {
241 t.Fatal(err)
242 }
243 return b
244 }
245
246 for _, tl := range tools {
247 for _, target := range []string{cliSession, projectSession} {
248 _, err := tl.Execute(context.Background(), argsFor(tl.Name(), target))
249 if err == nil || !strings.Contains(err.Error(), "session/state data") {
250 t.Errorf("%s on %q: err = %v, want session-data denial", tl.Name(), target, err)
251 }
252 }
253 // The same tool still writes ordinary workspace files (guard is not a
254 // blanket block on the state root).
255 if tl.Name() == "write_file" {
256 ok := filepath.Join(root, "notes.txt")
257 if _, err := tl.Execute(context.Background(), argsFor("write_file", ok)); err != nil {
258 t.Errorf("write_file on ordinary path: %v", err)
259 }
260 }
261 }
262 }
263
264 func TestSessionDataGuardCommandHint(t *testing.T) {
265 root, cliSession, _ := stateRootFor(t)
266 g := NewSessionDataGuard(root, nil)
267
268 hinted := []string{
269 "python3 fix.py " + cliSession,
270 "rm -rf " + filepath.ToSlash(filepath.Join(root, "projects", "slug", "sessions")),
271 "Get-Content " + strings.ToUpper(filepath.ToSlash(filepath.Join(root, "sessions"))) + "/x.jsonl", // case-insensitive
272 }
273 for _, cmd := range hinted {
274 if hint := g.CommandHint("", cmd); hint == "" {
275 t.Errorf("CommandHint(%q) = empty, want warning", cmd)
276 } else if !strings.Contains(hint, "conflict cop") {
277 t.Errorf("CommandHint(%q) = %q, want conflict-copy explanation", cmd, hint)
278 }
279 }
280 for _, cmd := range []string{
281 "go test ./...",
282 "ls " + filepath.Join(t.TempDir(), "sessions"), // "sessions" under an unrelated root
283 "",
284 } {
285 if hint := g.CommandHint("", cmd); hint != "" {
286 t.Errorf("CommandHint(%q) = %q, want empty", cmd, hint)
287 }
288 }
289 }
290
291 func TestSessionDataGuardCommandHintEnvVarForm(t *testing.T) {
292 home := t.TempDir()
293 t.Setenv("HOME", home)
294 t.Setenv("USERPROFILE", home)
295 state := filepath.Join(home, ".reasonix")
296 if err := os.MkdirAll(filepath.Join(state, "sessions"), 0o755); err != nil {
297 t.Fatal(err)
298 }
299 g := NewSessionDataGuard(state, nil)
300
301 for _, cmd := range []string{
302 `python3 -c "open('$HOME/.reasonix/sessions/x.jsonl','w')"`,
303 "rm ${HOME}/.reasonix/projects/slug/sessions/y.jsonl",
304 } {
305 if hint := g.CommandHint("", cmd); hint == "" {
306 t.Errorf("CommandHint(%q) = empty, want warning for env-var path form", cmd)
307 }
308 }
309 }
310
311 func TestSessionDataGuardCommandHintRelativeFromStateRoot(t *testing.T) {
312 root, _, _ := stateRootFor(t)
313 g := NewSessionDataGuard(root, nil)
314 // The desktop Global workspace lives at <state root>/global-workspace, so a
315 // relative ../sessions reaches the store without an absolute path in the
316 // command text.
317 workDir := filepath.Join(root, "global-workspace")
318 if err := os.MkdirAll(workDir, 0o755); err != nil {
319 t.Fatal(err)
320 }
321
322 if hint := g.CommandHint(workDir, "python3 fix.py ../sessions/x.jsonl"); hint == "" {
323 t.Error("relative reference from a state-root workDir should warn")
324 }
325 if hint := g.CommandHint(workDir, "go build ./..."); hint != "" {
326 t.Errorf("ordinary command in the Global workspace should stay clean, got %q", hint)
327 }
328 // A workDir already inside a guarded store warns on every command: any
329 // relative operation there touches the store.
330 inStore := filepath.Join(root, "projects", "slug", "sessions")
331 if hint := g.CommandHint(inStore, "python3 fix.py x.jsonl"); hint == "" {
332 t.Error("workDir inside a session store should warn unconditionally")
333 }
334 // An unrelated workDir does not fabricate warnings.
335 if hint := g.CommandHint(t.TempDir(), "cat ../sessions/x.jsonl"); hint != "" {
336 t.Errorf("relative form outside the state root should stay clean, got %q", hint)
337 }
338 }
339
340 func TestBashAppendsSessionDataHint(t *testing.T) {
341 requirePOSIXShellTest(t)
342 root, cliSession, _ := stateRootFor(t)
343 guard := NewSessionDataGuard(root, nil)
344 b := ConfineBash(sandbox.Spec{Mode: "off"}, guard)
345
346 args, _ := json.Marshal(map[string]string{"command": "echo " + cliSession})
347 out, err := b.Execute(fullAccessBashTestContext(t.Context()), args)
348 if err != nil {
349 t.Fatalf("bash: %v", err)
350 }
351 if !strings.Contains(out, "WARNING: this command referenced Reasonix's own session/state data") {
352 t.Fatalf("bash output missing session-data warning:\n%s", out)
353 }
354 // An ordinary command stays clean.
355 args, _ = json.Marshal(map[string]string{"command": "echo hello"})
356 out, err = b.Execute(fullAccessBashTestContext(t.Context()), args)
357 if err != nil {
358 t.Fatalf("bash: %v", err)
359 }
360 if strings.Contains(out, "WARNING") {
361 t.Fatalf("bash output has spurious warning:\n%s", out)
362 }
363 }
364
364 lines GO