返回 DeepSeek-Reasonix
session_redact.go
根目录 / internal / doctor / session_redact.go
1 package doctor
2
3 import (
4 "bytes"
5 "encoding/json"
6 "errors"
7 "fmt"
8 "io"
9 "os"
10 "path/filepath"
11 "sort"
12 "strings"
13
14 "reasonix/internal/agent"
15 "reasonix/internal/fileutil"
16 "reasonix/internal/provider"
17 "reasonix/internal/secrets"
18 "reasonix/internal/store"
19 )
20
21 // RedactSessionsOptions controls historical session-log redaction.
22 type RedactSessionsOptions struct {
23 Dirs []string
24 DryRun bool
25 }
26
27 // RedactSessionsResult summarizes a historical session-log redaction run.
28 type RedactSessionsResult struct {
29 Dirs []string `json:"dirs"`
30 FilesScanned int64 `json:"files_scanned"`
31 FilesChanged int64 `json:"files_changed"`
32 FilesSkipped int64 `json:"files_skipped"`
33 BytesRewritten int64 `json:"bytes_rewritten"`
34 DryRun bool `json:"dry_run"`
35 Errors []string `json:"errors,omitempty"`
36 }
37
38 // RedactSessions masks credential-shaped values already persisted in Reasonix
39 // session transcripts, event logs, branch metadata, goal state, and
40 // background-job artifacts. It is intentionally scoped to known Reasonix
41 // session directories; it is not a general-purpose filesystem scrubber.
42 //
43 // Every JSON-bearing artifact is decoded before masking and re-encoded after:
44 // running Redact over raw encoded bytes would eat the backslash of a \" escape
45 // whenever a secret-shaped value abuts a quote, truncating the JSON string and
46 // leaving the transcript undecodable (and the secret unmasked). Only plain-text
47 // job logs are redacted as raw bytes.
48 func RedactSessions(opts RedactSessionsOptions) RedactSessionsResult {
49 dirs := redactSessionDirs(opts.Dirs)
50 res := RedactSessionsResult{Dirs: dirs, DryRun: opts.DryRun}
51 for _, dir := range dirs {
52 if err := filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error {
53 if err != nil {
54 res.Errors = append(res.Errors, fmt.Sprintf("%s: %v", path, err))
55 if d != nil && d.IsDir() {
56 return filepath.SkipDir
57 }
58 return nil
59 }
60 if d.IsDir() || !redactSessionCandidate(path) {
61 return nil
62 }
63 res.FilesScanned++
64 if sessionPath := redactionSessionPath(path); sessionPath != "" && sessionRedactionLeaseHeld(sessionPath) {
65 res.FilesSkipped++
66 return nil
67 }
68 changed, rewritten, err := redactSessionArtifact(path, opts.DryRun)
69 if err != nil {
70 res.Errors = append(res.Errors, fmt.Sprintf("%s: %v", path, err))
71 return nil
72 }
73 res.FilesChanged += changed
74 res.BytesRewritten += rewritten
75 return nil
76 }); err != nil {
77 res.Errors = append(res.Errors, fmt.Sprintf("%s: %v", dir, err))
78 }
79 }
80 return res
81 }
82
83 func redactSessionDirs(in []string) []string {
84 var candidates []string
85 if len(in) > 0 {
86 candidates = append(candidates, in...)
87 } else {
88 candidates = append(candidates, sessionBundleSearchDirs()...)
89 }
90 seen := map[string]bool{}
91 var out []string
92 for _, dir := range candidates {
93 dir = strings.TrimSpace(dir)
94 if dir == "" {
95 continue
96 }
97 if abs, err := filepath.Abs(dir); err == nil {
98 dir = abs
99 }
100 dir = filepath.Clean(dir)
101 if seen[dir] {
102 continue
103 }
104 if info, err := os.Stat(dir); err != nil || !info.IsDir() {
105 continue
106 }
107 seen[dir] = true
108 out = append(out, dir)
109 }
110 sort.Strings(out)
111 return out
112 }
113
114 func redactSessionCandidate(path string) bool {
115 name := filepath.Base(path)
116 switch {
117 case store.IsSessionTranscriptName(name):
118 return true
119 case strings.HasSuffix(name, ".jsonl.meta"):
120 return true
121 case strings.HasSuffix(name, ".events.jsonl"):
122 return true
123 case strings.HasSuffix(name, ".events.jsonl.damaged"):
124 return true
125 case strings.HasSuffix(name, ".guardian.jsonl"):
126 return true
127 case strings.HasSuffix(name, ".goal-state.json"):
128 return true
129 case filepath.Base(filepath.Dir(path)) != "" && strings.HasSuffix(filepath.Base(filepath.Dir(path)), ".jobs"):
130 return strings.HasSuffix(name, ".log") || strings.HasSuffix(name, ".json")
131 default:
132 return false
133 }
134 }
135
136 func redactionSessionPath(path string) string {
137 name := filepath.Base(path)
138 switch {
139 case store.IsSessionTranscriptName(name), strings.HasSuffix(name, ".guardian.jsonl"):
140 return path
141 case strings.HasSuffix(path, ".jsonl.meta"):
142 return strings.TrimSuffix(path, ".meta")
143 case strings.HasSuffix(path, ".events.jsonl.damaged"):
144 return strings.TrimSuffix(path, ".events.jsonl.damaged") + ".jsonl"
145 case strings.HasSuffix(path, ".events.jsonl"):
146 return strings.TrimSuffix(path, ".events.jsonl") + ".jsonl"
147 case strings.HasSuffix(path, ".goal-state.json"):
148 return strings.TrimSuffix(path, ".goal-state.json") + ".jsonl"
149 case strings.HasSuffix(filepath.Base(filepath.Dir(path)), ".jobs"):
150 return strings.TrimSuffix(filepath.Dir(path), ".jobs") + ".jsonl"
151 default:
152 return ""
153 }
154 }
155
156 func sessionRedactionLeaseHeld(sessionPath string) bool {
157 if agent.SessionLeaseHeld(sessionPath) {
158 return true
159 }
160 if strings.HasSuffix(sessionPath, ".guardian.jsonl") {
161 parent := strings.TrimSuffix(sessionPath, ".guardian.jsonl") + ".jsonl"
162 return agent.SessionLeaseHeld(parent)
163 }
164 return false
165 }
166
167 // redactSessionArtifact dispatches one candidate file to a format-aware
168 // redactor and reports how many files it changed.
169 func redactSessionArtifact(path string, dryRun bool) (changed int64, bytesRewritten int64, err error) {
170 name := filepath.Base(path)
171 switch {
172 case store.IsSessionTranscriptName(name), strings.HasSuffix(name, ".guardian.jsonl"):
173 return redactSessionTranscript(path, dryRun)
174 case strings.HasSuffix(name, ".events.jsonl.damaged"):
175 // The salvage sidecar holds raw bytes tail repair truncated away —
176 // undecodable by definition, so format-aware masking is impossible,
177 // and raw-byte masking cannot guarantee a secret split by JSON
178 // escapes is even recognized. This explicit privacy scrub follows the
179 // event-log precedent (torn bytes are compacted away regardless of
180 // content): delete the sidecar outright. Privacy wins over forensics.
181 return removeDamagedSalvage(path, dryRun)
182 case strings.HasSuffix(name, ".events.jsonl"):
183 anchor := strings.TrimSuffix(path, ".events.jsonl") + ".jsonl"
184 if _, statErr := os.Stat(anchor); statErr == nil {
185 // The anchor's own walk entry rewrites the event log with it.
186 return 0, 0, nil
187 }
188 return redactSessionTranscript(anchor, dryRun)
189 case strings.HasSuffix(name, ".jsonl.meta"):
190 return redactBranchMeta(strings.TrimSuffix(path, ".meta"), dryRun)
191 case strings.HasSuffix(name, ".goal-state.json"):
192 return redactJSONFile(path, dryRun)
193 case strings.HasSuffix(name, ".json"):
194 return redactJSONFile(path, dryRun)
195 default:
196 // Background-job .log files are plain text: raw-byte redaction is
197 // correct there and only there.
198 return redactPlainTextFile(path, dryRun)
199 }
200 }
201
202 // redactSessionTranscript rewrites one session (anchor .jsonl plus its event
203 // log) through the agent's own save machinery. This explicit cleanup command
204 // redacts the loaded snapshot before saving it, folds the event log into one
205 // clean replace event, and refreshes the anchor, index, and revision under the
206 // same cross-process locks live sessions use. Ordinary Session.Save calls keep
207 // transcript content byte-for-byte intact.
208 func redactSessionTranscript(path string, dryRun bool) (int64, int64, error) {
209 s, err := agent.LoadSession(path)
210 if err != nil {
211 if os.IsNotExist(err) {
212 return 0, 0, nil
213 }
214 return 0, 0, err
215 }
216 files := int64(1)
217 eventLog := store.SessionEventLog(path)
218 eventLogExists := false
219 if _, err := os.Stat(eventLog); err == nil {
220 files++
221 eventLogExists = true
222 }
223 // The replayed view alone is not enough: a replace event supersedes
224 // earlier records without erasing them, so a raw secret can survive in a
225 // stale event while the current messages are already clean. Scan every
226 // record; Session.Save compacts the whole log into a single clean replace
227 // event, which erases the stale bytes.
228 if !messagesNeedRedaction(s.Messages) && !(eventLogExists && eventLogNeedsRedaction(eventLog)) {
229 return 0, 0, nil
230 }
231 if dryRun {
232 return files, redactedEncodedSize(s.Messages), nil
233 }
234 s.Replace(secrets.RedactMessages(s.Messages))
235 if err := s.Save(path); err != nil {
236 return 0, 0, err
237 }
238 var rewritten int64
239 if info, err := os.Stat(path); err == nil {
240 rewritten += info.Size()
241 }
242 if info, err := os.Stat(eventLog); err == nil {
243 rewritten += info.Size()
244 }
245 return files, rewritten, nil
246 }
247
248 // eventLogNeedsRedaction reports whether any event record — including ones a
249 // later replace event superseded — still carries redactable message content.
250 // Undecodable trailing bytes also count: a torn tail can hold raw secret text,
251 // and the compaction that a rewrite performs erases it either way.
252 func eventLogNeedsRedaction(path string) bool {
253 f, err := os.Open(path)
254 if err != nil {
255 return false
256 }
257 defer f.Close()
258 dec := json.NewDecoder(f)
259 for {
260 var rec struct {
261 Messages []provider.Message `json:"messages"`
262 }
263 if err := dec.Decode(&rec); err != nil {
264 // EOF is a clean end; anything else is an undecodable tail whose
265 // torn bytes may hold raw secret text — compact it away.
266 return !errors.Is(err, io.EOF)
267 }
268 if messagesNeedRedaction(rec.Messages) {
269 return true
270 }
271 }
272 }
273
274 // messagesNeedRedaction reports whether RedactMessages would alter the
275 // storage encoding of msgs. Comparing encoded forms (not struct equality)
276 // matches exactly what a rewrite would put on disk.
277 func messagesNeedRedaction(msgs []provider.Message) bool {
278 redacted := secrets.RedactMessages(msgs)
279 for i := range msgs {
280 before, errB := json.Marshal(msgs[i])
281 after, errA := json.Marshal(redacted[i])
282 if errB != nil || errA != nil || !bytes.Equal(before, after) {
283 return true
284 }
285 }
286 return false
287 }
288
289 func redactedEncodedSize(msgs []provider.Message) int64 {
290 var n int64
291 for _, m := range secrets.RedactMessages(msgs) {
292 if b, err := json.Marshal(m); err == nil {
293 n += int64(len(b)) + 1
294 }
295 }
296 return n
297 }
298
299 // redactBranchMeta masks the free-text fields of the branch-metadata sidecar
300 // (preview, titles, goal, recovery reason) through the typed load/save pair so
301 // revisions, digests, and timestamps survive untouched.
302 func redactBranchMeta(sessionPath string, dryRun bool) (int64, int64, error) {
303 unlock := agent.LockSessionMetaPath(sessionPath)
304 defer unlock()
305 meta, ok, err := agent.LoadBranchMeta(sessionPath)
306 if err != nil || !ok {
307 return 0, 0, err
308 }
309 changed := false
310 for _, field := range []*string{&meta.Name, &meta.TopicTitle, &meta.CustomTitle, &meta.Goal, &meta.Preview, &meta.RecoveryReason} {
311 if masked := secrets.Redact(*field); masked != *field {
312 *field = masked
313 changed = true
314 }
315 }
316 if !changed {
317 return 0, 0, nil
318 }
319 if dryRun {
320 return 1, 0, nil
321 }
322 if err := agent.SaveBranchMetaPreserveUpdated(sessionPath, meta); err != nil {
323 return 0, 0, err
324 }
325 var rewritten int64
326 if info, err := os.Stat(agent.BranchMetaPath(sessionPath)); err == nil {
327 rewritten = info.Size()
328 }
329 return 1, rewritten, nil
330 }
331
332 // redactJSONFile decodes a single-document JSON sidecar, masks every string
333 // value in the tree, and re-encodes. UseNumber keeps numeric literals (large
334 // IDs, timestamps) byte-faithful through the round trip. A file that does not
335 // parse is reported and left untouched rather than risked with a raw rewrite.
336 func redactJSONFile(path string, dryRun bool) (int64, int64, error) {
337 info, err := os.Stat(path)
338 if err != nil {
339 return 0, 0, err
340 }
341 raw, err := os.ReadFile(path)
342 if err != nil {
343 return 0, 0, err
344 }
345 if len(bytes.TrimSpace(raw)) == 0 {
346 return 0, 0, nil
347 }
348 dec := json.NewDecoder(bytes.NewReader(raw))
349 dec.UseNumber()
350 var doc any
351 if err := dec.Decode(&doc); err != nil {
352 return 0, 0, fmt.Errorf("not valid JSON, left untouched: %w", err)
353 }
354 doc, changed := redactJSONValue(doc)
355 if !changed {
356 return 0, 0, nil
357 }
358 next, err := json.Marshal(doc)
359 if err != nil {
360 return 0, 0, err
361 }
362 next = append(next, '\n')
363 if dryRun {
364 return 1, int64(len(next)), nil
365 }
366 perm := info.Mode().Perm()
367 if perm == 0 {
368 perm = 0o600
369 }
370 if err := fileutil.AtomicWriteFile(path, next, perm); err != nil {
371 return 0, 0, err
372 }
373 return 1, int64(len(next)), nil
374 }
375
376 func redactJSONValue(v any) (any, bool) {
377 switch t := v.(type) {
378 case string:
379 masked := secrets.Redact(t)
380 return masked, masked != t
381 case map[string]any:
382 changed := false
383 for key, val := range t {
384 next, ch := redactJSONValue(val)
385 if ch {
386 t[key] = next
387 changed = true
388 }
389 }
390 return t, changed
391 case []any:
392 changed := false
393 for i, val := range t {
394 next, ch := redactJSONValue(val)
395 if ch {
396 t[i] = next
397 changed = true
398 }
399 }
400 return t, changed
401 default:
402 return v, false
403 }
404 }
405
406 // removeDamagedSalvage deletes an .events.jsonl.damaged salvage sidecar. See
407 // the dispatch comment: damaged bytes cannot be masked reliably, so the scrub
408 // removes them entirely.
409 func removeDamagedSalvage(path string, dryRun bool) (int64, int64, error) {
410 info, err := os.Stat(path)
411 if err != nil {
412 if os.IsNotExist(err) {
413 return 0, 0, nil
414 }
415 return 0, 0, err
416 }
417 if info.IsDir() {
418 return 0, 0, nil
419 }
420 if dryRun {
421 return 1, 0, nil
422 }
423 if err := os.Remove(path); err != nil {
424 return 0, 0, err
425 }
426 return 1, 0, nil
427 }
428
429 // redactPlainTextFile masks raw bytes — safe only for non-JSON artifacts
430 // (background-job .log output).
431 func redactPlainTextFile(path string, dryRun bool) (int64, int64, error) {
432 info, err := os.Stat(path)
433 if err != nil {
434 return 0, 0, err
435 }
436 if info.IsDir() {
437 return 0, 0, nil
438 }
439 raw, err := os.ReadFile(path)
440 if err != nil {
441 return 0, 0, err
442 }
443 next := []byte(secrets.Redact(string(raw)))
444 if bytes.Equal(raw, next) {
445 return 0, 0, nil
446 }
447 if dryRun {
448 return 1, int64(len(next)), nil
449 }
450 perm := info.Mode().Perm()
451 if perm == 0 {
452 perm = 0o600
453 }
454 if err := fileutil.AtomicWriteFile(path, next, perm); err != nil {
455 return 0, 0, err
456 }
457 return 1, int64(len(next)), nil
458 }
459
459 lines GO