diff --git a/README.md b/README.md index ecb6053..d5c94e6 100644 --- a/README.md +++ b/README.md @@ -251,10 +251,11 @@ Save a full session (metadata and the complete conversation) to a file with `dis dispatch export 0a1b2c3d dispatch export 0a1b2c3d --format json dispatch export 0a1b2c3d --stdout +dispatch export 0a1b2c3d --redact --stdout dispatch export 0a1b2c3d --out ./exports ``` -By default the session is written as Markdown to the exports directory. Use `--format json` for machine-readable output, `--stdout` to print to the terminal instead of writing a file, and `--out ` to choose the destination directory. `--stdout` and `--out` cannot be combined. +By default the session is written as Markdown to the exports directory. Use `--format json` for machine-readable output, `--stdout` to print to the terminal instead of writing a file, `--out ` to choose the destination directory, and `--redact` to mask common secret patterns before writing. `--stdout` and `--out` cannot be combined. ### Search (JSON) diff --git a/cmd/dispatch/export.go b/cmd/dispatch/export.go index f9453ef..d88d8af 100644 --- a/cmd/dispatch/export.go +++ b/cmd/dispatch/export.go @@ -12,6 +12,7 @@ import ( "strings" "github.com/jongio/dispatch/internal/data" + "github.com/jongio/dispatch/internal/platform" ) // Function variables allow test substitution of external calls, matching the @@ -27,6 +28,7 @@ type exportOptions struct { format string // "md" or "json" stdout bool outDir string + redact bool } // runExport writes a session's full content as Markdown or JSON. It writes to @@ -50,6 +52,10 @@ func runExport(w io.Writer, args []string) error { return fmt.Errorf("session %q not found", opts.id) } + if opts.redact { + detail = redactedSessionDetail(detail) + } + content, err := renderExport(detail, opts.format) if err != nil { return err @@ -122,6 +128,8 @@ func parseExportArgs(args []string) (exportOptions, error) { i = ni case name == "--stdout": opts.stdout = true + case name == "--redact": + opts.redact = true case strings.HasPrefix(arg, "-"): return exportOptions{}, fmt.Errorf("unknown flag: %s", arg) default: @@ -157,6 +165,52 @@ func normalizeExportFormat(format string) (string, error) { } } +func redactedSessionDetail(detail *data.SessionDetail) *data.SessionDetail { + if detail == nil { + return nil + } + redacted := *detail + redacted.Session = detail.Session + redacted.Session.Cwd = platform.RedactSecrets(redacted.Session.Cwd) + redacted.Session.Repository = platform.RedactSecrets(redacted.Session.Repository) + redacted.Session.Branch = platform.RedactSecrets(redacted.Session.Branch) + redacted.Session.Summary = platform.RedactSecrets(redacted.Session.Summary) + + if len(detail.Turns) > 0 { + redacted.Turns = append([]data.Turn(nil), detail.Turns...) + for i := range redacted.Turns { + redacted.Turns[i].UserMessage = platform.RedactSecrets(redacted.Turns[i].UserMessage) + redacted.Turns[i].AssistantResponse = platform.RedactSecrets(redacted.Turns[i].AssistantResponse) + } + } + if len(detail.Checkpoints) > 0 { + redacted.Checkpoints = append([]data.Checkpoint(nil), detail.Checkpoints...) + for i := range redacted.Checkpoints { + redacted.Checkpoints[i].Title = platform.RedactSecrets(redacted.Checkpoints[i].Title) + redacted.Checkpoints[i].Overview = platform.RedactSecrets(redacted.Checkpoints[i].Overview) + redacted.Checkpoints[i].History = platform.RedactSecrets(redacted.Checkpoints[i].History) + redacted.Checkpoints[i].WorkDone = platform.RedactSecrets(redacted.Checkpoints[i].WorkDone) + redacted.Checkpoints[i].TechnicalDetails = platform.RedactSecrets(redacted.Checkpoints[i].TechnicalDetails) + redacted.Checkpoints[i].ImportantFiles = platform.RedactSecrets(redacted.Checkpoints[i].ImportantFiles) + redacted.Checkpoints[i].NextSteps = platform.RedactSecrets(redacted.Checkpoints[i].NextSteps) + } + } + if len(detail.Files) > 0 { + redacted.Files = append([]data.SessionFile(nil), detail.Files...) + for i := range redacted.Files { + redacted.Files[i].FilePath = platform.RedactSecrets(redacted.Files[i].FilePath) + redacted.Files[i].ToolName = platform.RedactSecrets(redacted.Files[i].ToolName) + } + } + if len(detail.Refs) > 0 { + redacted.Refs = append([]data.SessionRef(nil), detail.Refs...) + for i := range redacted.Refs { + redacted.Refs[i].RefValue = platform.RedactSecrets(redacted.Refs[i].RefValue) + } + } + return &redacted +} + // renderExport produces the export content for the given format. func renderExport(detail *data.SessionDetail, format string) (string, error) { switch format { diff --git a/cmd/dispatch/export_test.go b/cmd/dispatch/export_test.go index 590d68f..6657a28 100644 --- a/cmd/dispatch/export_test.go +++ b/cmd/dispatch/export_test.go @@ -48,6 +48,7 @@ func TestParseExportArgs(t *testing.T) { wantFormat string wantStdout bool wantOut string + wantRedact bool wantErr bool }{ {name: "id only", args: []string{"export", "abc"}, wantID: "abc", wantFormat: "md"}, @@ -55,6 +56,7 @@ func TestParseExportArgs(t *testing.T) { {name: "format markdown alias", args: []string{"export", "abc", "--format=markdown"}, wantID: "abc", wantFormat: "md"}, {name: "short format", args: []string{"export", "-f", "json", "abc"}, wantID: "abc", wantFormat: "json"}, {name: "stdout", args: []string{"export", "abc", "--stdout"}, wantID: "abc", wantFormat: "md", wantStdout: true}, + {name: "redact", args: []string{"export", "abc", "--redact"}, wantID: "abc", wantFormat: "md", wantRedact: true}, {name: "out dir", args: []string{"export", "abc", "--out", "/tmp/x"}, wantID: "abc", wantFormat: "md", wantOut: "/tmp/x"}, {name: "missing id", args: []string{"export"}, wantErr: true}, {name: "two ids", args: []string{"export", "a", "b"}, wantErr: true}, @@ -87,6 +89,9 @@ func TestParseExportArgs(t *testing.T) { if opts.outDir != tc.wantOut { t.Errorf("outDir = %q, want %q", opts.outDir, tc.wantOut) } + if opts.redact != tc.wantRedact { + t.Errorf("redact = %v, want %v", opts.redact, tc.wantRedact) + } }) } } @@ -120,6 +125,46 @@ func TestRunExport_StdoutJSON(t *testing.T) { } } +func TestRunExport_RedactsStdout(t *testing.T) { + detail := sampleDetail() + detail.Turns = []data.Turn{ + {UserMessage: "Authorization: Bearer abcdefghijklmnopqrstuvwxyz123456", AssistantResponse: "API_TOKEN=super-secret-token"}, + } + withExportDetail(t, func(string) (*data.SessionDetail, error) { return detail, nil }) + + var buf bytes.Buffer + if err := runExport(&buf, []string{"export", "ses-001", "--stdout", "--redact"}); err != nil { + t.Fatalf("runExport: %v", err) + } + out := buf.String() + if strings.Contains(out, "abcdefghijklmnopqrstuvwxyz123456") || strings.Contains(out, "super-secret-token") { + t.Fatalf("redacted export leaked a secret:\n%s", out) + } + if !strings.Contains(out, "[redacted]") { + t.Fatalf("redacted export missing placeholder:\n%s", out) + } +} + +func TestRunExport_RedactsJSONWithoutBreakingJSON(t *testing.T) { + detail := sampleDetail() + detail.Turns = []data.Turn{ + {UserMessage: "Bearer abcdefghijklmnopqrstuvwxyz123456", AssistantResponse: "safe"}, + } + withExportDetail(t, func(string) (*data.SessionDetail, error) { return detail, nil }) + + var buf bytes.Buffer + if err := runExport(&buf, []string{"export", "ses-001", "--stdout", "--format", "json", "--redact"}); err != nil { + t.Fatalf("runExport: %v", err) + } + var got data.SessionDetail + if err := json.Unmarshal(buf.Bytes(), &got); err != nil { + t.Fatalf("redacted output is not valid JSON: %v\n%s", err, buf.String()) + } + if strings.Contains(buf.String(), "abcdefghijklmnopqrstuvwxyz123456") { + t.Fatalf("redacted JSON leaked a token:\n%s", buf.String()) + } +} + func TestRunExport_WritesFile(t *testing.T) { withExportDetail(t, func(string) (*data.SessionDetail, error) { return sampleDetail(), nil }) diff --git a/cmd/dispatch/main.go b/cmd/dispatch/main.go index c7c164f..0638951 100644 --- a/cmd/dispatch/main.go +++ b/cmd/dispatch/main.go @@ -164,6 +164,7 @@ Export flags: --format md|json Output format (default md) --out Write to a directory instead of the exports folder --stdout Print to stdout instead of writing a file + --redact Mask common secret patterns in the export Flags: -h, --help Show this help message diff --git a/cmd/dispatch/main_test.go b/cmd/dispatch/main_test.go index c76a10e..c0b63e9 100644 --- a/cmd/dispatch/main_test.go +++ b/cmd/dispatch/main_test.go @@ -456,6 +456,7 @@ func TestPrintUsage_Output(t *testing.T) { origStdout := os.Stdout os.Stdout = w + defer func() { os.Stdout = origStdout }() var buf bytes.Buffer readDone := make(chan struct{}) @@ -466,7 +467,7 @@ func TestPrintUsage_Output(t *testing.T) { printUsage() - w.Close() + _ = w.Close() os.Stdout = origStdout <-readDone