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