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