From e65adbe9c23c89c48f7d9eb8c58e4ee01bf231c7 Mon Sep 17 00:00:00 2001 From: Jon Gallant <2163001+jongio@users.noreply.github.com> Date: Sat, 11 Jul 2026 11:41:25 -0700 Subject: [PATCH] feat: add redacted session exports Add a --redact flag to dispatch export that masks common secret patterns before Markdown or JSON output is written. Document the flag and keep usage capture tests safe as usage text grows.\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 3 ++- cmd/dispatch/export.go | 54 +++++++++++++++++++++++++++++++++++++ cmd/dispatch/export_test.go | 45 +++++++++++++++++++++++++++++++ cmd/dispatch/main.go | 1 + cmd/dispatch/main_test.go | 17 +++++++----- 5 files changed, 113 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index eaec257..04a3912 100644 --- a/README.md +++ b/README.md @@ -249,10 +249,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 e6833e7..14f81ca 100644 --- a/cmd/dispatch/main.go +++ b/cmd/dispatch/main.go @@ -163,6 +163,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 08e19a2..63a3379 100644 --- a/cmd/dispatch/main_test.go +++ b/cmd/dispatch/main_test.go @@ -456,16 +456,21 @@ func TestPrintUsage_Output(t *testing.T) { origStdout := os.Stdout os.Stdout = w + defer func() { os.Stdout = origStdout }() + + readDone := make(chan string, 1) + go func() { + var buf bytes.Buffer + _, _ = io.Copy(&buf, r) + readDone <- buf.String() + }() printUsage() - w.Close() + _ = w.Close() os.Stdout = origStdout - - var buf bytes.Buffer - _, _ = io.Copy(&buf, r) - - output := buf.String() + output := <-readDone + _ = r.Close() for _, want := range []string{"dispatch", "help", "version", "update", "--demo"} { if !strings.Contains(output, want) { t.Errorf("printUsage() should mention %q, got:\n%s", want, output)