| 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 |