返回 DeepSeek-Reasonix
session_redact_test.go
根目录 / internal / doctor / session_redact_test.go
1 package doctor
2
3 import (
4 "encoding/json"
5 "errors"
6 "os"
7 "path/filepath"
8 "strings"
9 "testing"
10
11 "reasonix/internal/agent"
12 "reasonix/internal/provider"
13 "reasonix/internal/store"
14 )
15
16 func TestRedactSessionsScrubsHistoricalSessionArtifacts(t *testing.T) {
17 dir := t.TempDir()
18 const secret = "sk-real-secret-value-123456"
19 sessionPath := filepath.Join(dir, "abc.jsonl")
20 files := map[string]string{
21 sessionPath: `{"role":"tool","content":"DEEPSEEK_API_KEY=` + secret + `"}` + "\n",
22 store.SessionEventLog(sessionPath): `{"schema_version":1,"type":"replace","messages":[{"role":"tool","content":"DEEPSEEK_API_KEY=` + secret + `"}]}` + "\n",
23 store.SessionMeta(sessionPath): `{"id":"abc","preview":"DEEPSEEK_API_KEY=` + secret + `"}` + "\n",
24 store.SessionGoalState(sessionPath): `{"goal":"rotate token ` + secret + `"}` + "\n",
25 filepath.Join(store.SessionJobsDir(sessionPath), "bash-1.log"): "DEEPSEEK_API_KEY=" + secret + "\n",
26 filepath.Join(store.SessionJobsDir(sessionPath), "bash-1.json"): `{"label":"echo DEEPSEEK_API_KEY=` + secret + `"}` + "\n",
27 store.SessionEventIndex(sessionPath): `{"schema_version":1}` + "\n",
28 }
29 for path, body := range files {
30 if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
31 t.Fatal(err)
32 }
33 if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
34 t.Fatal(err)
35 }
36 }
37
38 res := RedactSessions(RedactSessionsOptions{Dirs: []string{dir}})
39 if len(res.Errors) > 0 {
40 t.Fatalf("RedactSessions errors = %v", res.Errors)
41 }
42 if res.FilesChanged != 6 {
43 t.Fatalf("FilesChanged = %d, want 6", res.FilesChanged)
44 }
45 for path := range files {
46 data, err := os.ReadFile(path)
47 if err != nil {
48 t.Fatalf("read %s: %v", path, err)
49 }
50 if strings.Contains(string(data), secret) {
51 t.Fatalf("%s still leaked secret:\n%s", path, data)
52 }
53 }
54 // The rewrite must go through the real save machinery: the session still
55 // loads, the event log still replays, and the masked value survived.
56 loaded, err := agent.LoadSession(sessionPath)
57 if err != nil {
58 t.Fatalf("redacted session no longer loads: %v", err)
59 }
60 if len(loaded.Messages) != 1 || !strings.Contains(loaded.Messages[0].Content, "DEEPSEEK_API_KEY=sk-rea") {
61 t.Fatalf("redacted session lost its masked content: %+v", loaded.Messages)
62 }
63 }
64
65 // TestRedactSessionsHandlesQuotedSecretsWithoutCorruption pins the decode-
66 // before-redact contract: on disk a quoted secret is JSON-encoded with \"
67 // escapes, and masking the raw bytes would eat the escape's backslash,
68 // truncate the JSON string, and leave the transcript undecodable — while the
69 // secret itself stayed in the clear.
70 func TestRedactSessionsHandlesQuotedSecretsWithoutCorruption(t *testing.T) {
71 dir := t.TempDir()
72 const secret = "hunter2-longer-secret-value"
73 sessionPath := filepath.Join(dir, "abc.jsonl")
74 line, err := json.Marshal(provider.Message{
75 Role: provider.RoleTool,
76 Content: `export PASSWORD="` + secret + `"` + "\n",
77 })
78 if err != nil {
79 t.Fatal(err)
80 }
81 if err := os.WriteFile(sessionPath, append(line, '\n'), 0o644); err != nil {
82 t.Fatal(err)
83 }
84
85 res := RedactSessions(RedactSessionsOptions{Dirs: []string{dir}})
86 if len(res.Errors) > 0 {
87 t.Fatalf("RedactSessions errors = %v", res.Errors)
88 }
89 if res.FilesChanged != 1 {
90 t.Fatalf("FilesChanged = %d, want 1", res.FilesChanged)
91 }
92 loaded, err := agent.LoadSession(sessionPath)
93 if err != nil {
94 t.Fatalf("redaction corrupted the transcript: %v", err)
95 }
96 if len(loaded.Messages) != 1 {
97 t.Fatalf("message count = %d, want 1", len(loaded.Messages))
98 }
99 if strings.Contains(loaded.Messages[0].Content, secret) {
100 t.Fatalf("quoted secret leaked: %q", loaded.Messages[0].Content)
101 }
102 }
103
104 // TestRedactSessionsIsNoOpOnHealthyStore pins idempotence: after an explicit
105 // cleanup, rerunning the command must not rewrite or corrupt the clean store.
106 func TestRedactSessionsIsNoOpOnHealthyStore(t *testing.T) {
107 dir := t.TempDir()
108 sessionPath := filepath.Join(dir, "abc.jsonl")
109 s := agent.NewSession("sys")
110 s.Add(provider.Message{Role: provider.RoleUser, Content: "inspect"})
111 s.Add(provider.Message{
112 Role: provider.RoleTool,
113 Name: "bash",
114 ToolCallID: "call_1",
115 Content: `export PASSWORD="hunter2-longer-secret-value"` + "\n",
116 })
117 if err := s.Save(sessionPath); err != nil {
118 t.Fatalf("Save: %v", err)
119 }
120 first := RedactSessions(RedactSessionsOptions{Dirs: []string{dir}})
121 if len(first.Errors) > 0 || first.FilesChanged == 0 {
122 t.Fatalf("first RedactSessions() = %+v, want a successful rewrite", first)
123 }
124 before, err := os.ReadFile(sessionPath)
125 if err != nil {
126 t.Fatal(err)
127 }
128
129 second := RedactSessions(RedactSessionsOptions{Dirs: []string{dir}})
130 if len(second.Errors) > 0 {
131 t.Fatalf("second RedactSessions errors = %v", second.Errors)
132 }
133 if second.FilesChanged != 0 {
134 t.Fatalf("healthy already-redacted store rewritten: %+v", second)
135 }
136 after, err := os.ReadFile(sessionPath)
137 if err != nil {
138 t.Fatal(err)
139 }
140 if string(before) != string(after) {
141 t.Fatalf("healthy transcript bytes changed:\nbefore: %s\nafter: %s", before, after)
142 }
143 if _, err := agent.LoadSession(sessionPath); err != nil {
144 t.Fatalf("healthy session no longer loads: %v", err)
145 }
146 }
147
148 func TestRedactSessionsDryRunDoesNotWrite(t *testing.T) {
149 dir := t.TempDir()
150 const secret = "sk-real-secret-value-123456"
151 path := filepath.Join(dir, "abc.jsonl")
152 body := `{"role":"tool","content":"DEEPSEEK_API_KEY=` + secret + `"}` + "\n"
153 if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
154 t.Fatal(err)
155 }
156
157 res := RedactSessions(RedactSessionsOptions{Dirs: []string{dir}, DryRun: true})
158 if res.FilesChanged != 1 {
159 t.Fatalf("FilesChanged = %d, want 1", res.FilesChanged)
160 }
161 data, err := os.ReadFile(path)
162 if err != nil {
163 t.Fatal(err)
164 }
165 if string(data) != body {
166 t.Fatalf("dry-run modified file:\n%s", data)
167 }
168 }
169
170 func TestRedactSessionsSkipsLeasedSession(t *testing.T) {
171 dir := t.TempDir()
172 const secret = "sk-real-secret-value-123456"
173 path := filepath.Join(dir, "abc.jsonl")
174 if err := os.WriteFile(path, []byte(`{"role":"tool","content":"DEEPSEEK_API_KEY=`+secret+`"}`+"\n"), 0o644); err != nil {
175 t.Fatal(err)
176 }
177 lease, err := agent.TryAcquireSessionLease(path)
178 if err != nil {
179 t.Fatalf("TryAcquireSessionLease: %v", err)
180 }
181 defer lease.Release()
182
183 res := RedactSessions(RedactSessionsOptions{Dirs: []string{dir}})
184 if res.FilesSkipped != 1 {
185 t.Fatalf("FilesSkipped = %d, want 1", res.FilesSkipped)
186 }
187 data, err := os.ReadFile(path)
188 if err != nil {
189 t.Fatal(err)
190 }
191 if !strings.Contains(string(data), secret) {
192 t.Fatalf("leased session should not be rewritten:\n%s", data)
193 }
194 }
195
196 func TestRedactSessionsHoldsLeaseAcrossRewrite(t *testing.T) {
197 dir := t.TempDir()
198 secret := "sk-" + "real-secret-value-123456"
199 path := filepath.Join(dir, "abc.jsonl")
200 if err := os.WriteFile(path, []byte(`{"role":"tool","content":"DEEPSEEK_API_KEY=`+secret+`"}`+"\n"), 0o644); err != nil {
201 t.Fatal(err)
202 }
203
204 acquired := make(chan struct{})
205 continueRedaction := make(chan struct{})
206 sessionRedactionLeaseAcquired = func(got string) {
207 if agent.CanonicalSessionPath(got) != agent.CanonicalSessionPath(path) {
208 return
209 }
210 close(acquired)
211 <-continueRedaction
212 }
213 t.Cleanup(func() { sessionRedactionLeaseAcquired = nil })
214
215 done := make(chan RedactSessionsResult, 1)
216 go func() {
217 done <- RedactSessions(RedactSessionsOptions{Dirs: []string{dir}})
218 }()
219 <-acquired
220 competing, err := agent.AcquireSessionWriter(path)
221 if competing != nil {
222 competing.Release()
223 }
224 if !errors.Is(err, agent.ErrSessionLeaseHeld) {
225 close(continueRedaction)
226 t.Fatalf("competing AcquireSessionWriter err = %v, want ErrSessionLeaseHeld", err)
227 }
228 close(continueRedaction)
229 res := <-done
230 if len(res.Errors) > 0 || res.FilesChanged != 1 {
231 t.Fatalf("RedactSessions = %+v, want one successful rewrite", res)
232 }
233 }
234
235 // TestRedactSessionsRemovesDamagedSalvageSidecar pins the salvage-sidecar
236 // privacy gap (#6613 review): the .events.jsonl.damaged file preserves raw
237 // bytes tail repair truncated away, which can include secrets. The bytes are
238 // undecodable by definition, so no format-aware masking can prove them clean —
239 // the scrub must delete the file so no secret survives.
240 func TestRedactSessionsRemovesDamagedSalvageSidecar(t *testing.T) {
241 dir := t.TempDir()
242 const secret = "sk-real-secret-value-123456"
243 sessionPath := filepath.Join(dir, "abc.jsonl")
244 if err := os.WriteFile(sessionPath, []byte(`{"role":"user","content":"clean"}`+"\n"), 0o644); err != nil {
245 t.Fatal(err)
246 }
247 damagedPath := store.SessionEventLogDamaged(sessionPath)
248 salvage := `{"damaged_tail":true,"preserved_at":"2026-01-01T00:00:00Z","log_offset":10,"bytes":80}` + "\n" +
249 `{"schema_version":1,"type":"append","message_index":99,"messages":[{"role":"tool","content":"DEEPSEEK_API_KEY=` + secret + `"}]` + "\n"
250 if err := os.WriteFile(damagedPath, []byte(salvage), 0o644); err != nil {
251 t.Fatal(err)
252 }
253
254 // Dry run reports the file without touching it.
255 res := RedactSessions(RedactSessionsOptions{Dirs: []string{dir}, DryRun: true})
256 if len(res.Errors) > 0 {
257 t.Fatalf("dry-run errors = %v", res.Errors)
258 }
259 if res.FilesChanged == 0 {
260 t.Fatal("dry run did not report the damaged salvage sidecar")
261 }
262 if _, err := os.Stat(damagedPath); err != nil {
263 t.Fatalf("dry run must not delete the sidecar: %v", err)
264 }
265
266 // The real run deletes it: no secret can survive in bytes we cannot parse.
267 res = RedactSessions(RedactSessionsOptions{Dirs: []string{dir}})
268 if len(res.Errors) > 0 {
269 t.Fatalf("RedactSessions errors = %v", res.Errors)
270 }
271 if _, err := os.Stat(damagedPath); !os.IsNotExist(err) {
272 data, _ := os.ReadFile(damagedPath)
273 t.Fatalf("damaged salvage sidecar survived redaction (stat err=%v):\n%s", err, data)
274 }
275 }
276
277 // TestRedactSessionsSkipsLeasedDamagedSalvage: like every other artifact, the
278 // salvage sidecar of a session another process is actively running must not
279 // be touched.
280 func TestRedactSessionsSkipsLeasedDamagedSalvage(t *testing.T) {
281 dir := t.TempDir()
282 sessionPath := filepath.Join(dir, "abc.jsonl")
283 if err := os.WriteFile(sessionPath, []byte(`{"role":"user","content":"clean"}`+"\n"), 0o644); err != nil {
284 t.Fatal(err)
285 }
286 damagedPath := store.SessionEventLogDamaged(sessionPath)
287 if err := os.WriteFile(damagedPath, []byte("torn bytes"), 0o644); err != nil {
288 t.Fatal(err)
289 }
290 lease, err := agent.TryAcquireSessionLease(sessionPath)
291 if err != nil {
292 t.Fatalf("TryAcquireSessionLease: %v", err)
293 }
294 defer lease.Release()
295
296 res := RedactSessions(RedactSessionsOptions{Dirs: []string{dir}})
297 if res.FilesSkipped < 1 {
298 t.Fatalf("FilesSkipped = %d, want >= 1", res.FilesSkipped)
299 }
300 if _, err := os.Stat(damagedPath); err != nil {
301 t.Fatalf("leased session's salvage sidecar must survive: %v", err)
302 }
303 }
304
305 // TestRedactSessionsScrubsStaleEventLogRecords pins the stale-record gap: a
306 // later replace event supersedes — but does not erase — earlier records, so a
307 // raw key can survive in an old event while the replayed view is already
308 // clean. Cleanup must compact the log anyway, and the replayed transcript
309 // (the clean current view) must be what survives.
310 func TestRedactSessionsScrubsStaleEventLogRecords(t *testing.T) {
311 dir := t.TempDir()
312 const secret = "sk-real-secret-value-123456"
313 sessionPath := filepath.Join(dir, "abc.jsonl")
314 events := `{"schema_version":1,"type":"replace","messages":[{"role":"tool","content":"DEEPSEEK_API_KEY=` + secret + `"}]}` + "\n" +
315 `{"schema_version":1,"type":"replace","messages":[{"role":"user","content":"clean"}]}` + "\n"
316 if err := os.WriteFile(sessionPath, []byte(`{"role":"user","content":"clean"}`+"\n"), 0o644); err != nil {
317 t.Fatal(err)
318 }
319 evPath := store.SessionEventLog(sessionPath)
320 if err := os.WriteFile(evPath, []byte(events), 0o644); err != nil {
321 t.Fatal(err)
322 }
323
324 res := RedactSessions(RedactSessionsOptions{Dirs: []string{dir}})
325 if len(res.Errors) > 0 {
326 t.Fatalf("RedactSessions errors = %v", res.Errors)
327 }
328 if res.FilesChanged != 2 {
329 t.Fatalf("FilesChanged = %d, want 2 (anchor + event log)", res.FilesChanged)
330 }
331 data, err := os.ReadFile(evPath)
332 if err != nil {
333 t.Fatal(err)
334 }
335 if strings.Contains(string(data), secret) {
336 t.Fatalf("stale event log record still leaks secret:\n%s", data)
337 }
338 loaded, err := agent.LoadSession(sessionPath)
339 if err != nil {
340 t.Fatalf("session no longer loads after compaction: %v", err)
341 }
342 if len(loaded.Messages) != 1 || loaded.Messages[0].Content != "clean" {
343 t.Fatalf("compaction lost the current replayed view: %+v", loaded.Messages)
344 }
345 }
346
346 lines GO