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