← back to Cli Printing Press
fix(cli): redact $HOME paths from publish-tree JSON artifacts (#1181)
a3c3c6537f36d371e14d1410c35d702300ca1b53 · 2026-05-12 02:05:08 -0700 · Trevin Chow
Closes #956
The dogfood, workflow-verify, and tools-polish writers each embedded
the operator's absolute CLI directory (and dogfood's spec path) in
their committed JSON output. Across the public library every modern
merged CLI leaks its author's $HOME under dogfood-results.json,
workflow-verify-report.json, and .printing-press-tools-polish.json.
Add internal/artifacts/paths.go with two redaction helpers and call
them at marshal time. RedactCLIDirRoot returns <cli-dir>/<slug> so
existing consumers that read filepath.Base(report.Dir) still get the
slug. RedactPathUnderCLI rebases nested paths under <cli-dir>, or
falls back to <runstate>/<basename> when the path lives outside the
CLI dir. The writers marshal a struct copy so callers retain the real
paths in-memory.
Tests assert no /Users/, /home/, /var/folders/, /tmp/, or C:\Users\
prefix survives in any of the three artifacts after a real write.
Files touched
A internal/artifacts/paths.goA internal/artifacts/paths_test.goM internal/cli/tools_audit.goA internal/cli/tools_audit_redact_test.goM internal/pipeline/dogfood.goA internal/pipeline/redact_paths_test.goM internal/pipeline/workflow_verify.go
Diff
commit a3c3c6537f36d371e14d1410c35d702300ca1b53
Author: Trevin Chow <trevin@trevinchow.com>
Date: Tue May 12 02:05:08 2026 -0700
fix(cli): redact $HOME paths from publish-tree JSON artifacts (#1181)
Closes #956
The dogfood, workflow-verify, and tools-polish writers each embedded
the operator's absolute CLI directory (and dogfood's spec path) in
their committed JSON output. Across the public library every modern
merged CLI leaks its author's $HOME under dogfood-results.json,
workflow-verify-report.json, and .printing-press-tools-polish.json.
Add internal/artifacts/paths.go with two redaction helpers and call
them at marshal time. RedactCLIDirRoot returns <cli-dir>/<slug> so
existing consumers that read filepath.Base(report.Dir) still get the
slug. RedactPathUnderCLI rebases nested paths under <cli-dir>, or
falls back to <runstate>/<basename> when the path lives outside the
CLI dir. The writers marshal a struct copy so callers retain the real
paths in-memory.
Tests assert no /Users/, /home/, /var/folders/, /tmp/, or C:\Users\
prefix survives in any of the three artifacts after a real write.
---
internal/artifacts/paths.go | 36 ++++++++++++
internal/artifacts/paths_test.go | 97 +++++++++++++++++++++++++++++++++
internal/cli/tools_audit.go | 2 +-
internal/cli/tools_audit_redact_test.go | 41 ++++++++++++++
internal/pipeline/dogfood.go | 7 ++-
internal/pipeline/redact_paths_test.go | 70 ++++++++++++++++++++++++
internal/pipeline/workflow_verify.go | 7 ++-
7 files changed, 257 insertions(+), 3 deletions(-)
diff --git a/internal/artifacts/paths.go b/internal/artifacts/paths.go
new file mode 100644
index 00000000..878f96d5
--- /dev/null
+++ b/internal/artifacts/paths.go
@@ -0,0 +1,36 @@
+package artifacts
+
+import (
+ "path/filepath"
+ "strings"
+)
+
+const (
+ CLIDirPlaceholder = "<cli-dir>"
+ RunStatePlaceholder = "<runstate>"
+)
+
+// RedactCLIDirRoot preserves the slug (basename) so existing consumers
+// that call filepath.Base still get a useful display name.
+func RedactCLIDirRoot(cliDir string) string {
+ if cliDir == "" {
+ return ""
+ }
+ return filepath.Join(CLIDirPlaceholder, filepath.Base(cliDir))
+}
+
+// RedactPathUnderCLI strips $HOME prefixes before the value reaches a
+// committed artifact, falling back to <runstate>/<basename> when p
+// lives outside cliDir.
+func RedactPathUnderCLI(cliDir, p string) string {
+ if p == "" {
+ return ""
+ }
+ if cliDir != "" {
+ rel, err := filepath.Rel(cliDir, p)
+ if err == nil && rel != "" && !strings.HasPrefix(rel, "..") {
+ return filepath.Join(CLIDirPlaceholder, rel)
+ }
+ }
+ return filepath.Join(RunStatePlaceholder, filepath.Base(p))
+}
diff --git a/internal/artifacts/paths_test.go b/internal/artifacts/paths_test.go
new file mode 100644
index 00000000..22d18e28
--- /dev/null
+++ b/internal/artifacts/paths_test.go
@@ -0,0 +1,97 @@
+package artifacts
+
+import (
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+func TestRedactCLIDirRoot(t *testing.T) {
+ cases := []struct {
+ name string
+ cliDir string
+ want string
+ wantBase string
+ }{
+ {
+ name: "absolute home path collapses to placeholder + slug",
+ cliDir: "/Users/operator/printing-press/library/amazon-orders",
+ want: filepath.Join(CLIDirPlaceholder, "amazon-orders"),
+ wantBase: "amazon-orders",
+ },
+ {
+ name: "linux home path collapses to placeholder + slug",
+ cliDir: "/home/operator/printing-press/library/recipe-goat",
+ want: filepath.Join(CLIDirPlaceholder, "recipe-goat"),
+ wantBase: "recipe-goat",
+ },
+ {
+ name: "empty stays empty",
+ cliDir: "",
+ want: "",
+ },
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ got := RedactCLIDirRoot(tc.cliDir)
+ if got != tc.want {
+ t.Fatalf("RedactCLIDirRoot(%q) = %q, want %q", tc.cliDir, got, tc.want)
+ }
+ if tc.wantBase != "" && filepath.Base(got) != tc.wantBase {
+ t.Fatalf("filepath.Base(%q) = %q, want %q", got, filepath.Base(got), tc.wantBase)
+ }
+ if got != "" && strings.HasPrefix(got, "/") {
+ t.Fatalf("redacted path %q must not start with /", got)
+ }
+ })
+ }
+}
+
+func TestRedactPathUnderCLI(t *testing.T) {
+ cli := "/Users/operator/printing-press/library/amazon-orders"
+ cases := []struct {
+ name string
+ p string
+ want string
+ }{
+ {
+ name: "spec inside CLI dir rebases under <cli-dir>",
+ p: filepath.Join(cli, "spec.yaml"),
+ want: filepath.Join(CLIDirPlaceholder, "spec.yaml"),
+ },
+ {
+ name: "nested spec inside CLI dir rebases under <cli-dir>",
+ p: filepath.Join(cli, ".manuscripts", "run1", "spec.yaml"),
+ want: filepath.Join(CLIDirPlaceholder, ".manuscripts", "run1", "spec.yaml"),
+ },
+ {
+ name: "spec outside CLI dir falls back to <runstate>/basename",
+ p: "/Users/operator/printing-press/.runstate/run1/spec.yaml",
+ want: filepath.Join(RunStatePlaceholder, "spec.yaml"),
+ },
+ {
+ name: "empty input stays empty",
+ p: "",
+ want: "",
+ },
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ got := RedactPathUnderCLI(cli, tc.p)
+ if got != tc.want {
+ t.Fatalf("RedactPathUnderCLI(_, %q) = %q, want %q", tc.p, got, tc.want)
+ }
+ if strings.Contains(got, "/Users/") || strings.Contains(got, "/home/") {
+ t.Fatalf("redacted path %q still contains $HOME-style prefix", got)
+ }
+ })
+ }
+}
+
+func TestRedactPathUnderCLI_EmptyCLIDir(t *testing.T) {
+ got := RedactPathUnderCLI("", "/Users/operator/specs/amazon.yaml")
+ want := filepath.Join(RunStatePlaceholder, "amazon.yaml")
+ if got != want {
+ t.Fatalf("RedactPathUnderCLI(empty, _) = %q, want %q", got, want)
+ }
+}
diff --git a/internal/cli/tools_audit.go b/internal/cli/tools_audit.go
index 1582f65e..5ef3a20e 100644
--- a/internal/cli/tools_audit.go
+++ b/internal/cli/tools_audit.go
@@ -802,7 +802,7 @@ func readPreviousLedger(cliDir string) *ToolsAuditLedger {
func writeLedger(cliDir string, manifest *pipeline.ToolsManifest, findings []ToolsAuditFinding, previous *ToolsAuditLedger) error {
ledger := ToolsAuditLedger{
Timestamp: time.Now().UTC(),
- CLIDir: cliDir,
+ CLIDir: artifacts.RedactCLIDirRoot(cliDir),
Findings: findings,
}
// ScorecardBefore is sticky: captured on the first run that has no
diff --git a/internal/cli/tools_audit_redact_test.go b/internal/cli/tools_audit_redact_test.go
new file mode 100644
index 00000000..135a2d3d
--- /dev/null
+++ b/internal/cli/tools_audit_redact_test.go
@@ -0,0 +1,41 @@
+package cli
+
+import (
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/mvanhorn/cli-printing-press/v4/internal/artifacts"
+)
+
+// t.TempDir() returns a $HOME-rooted path on macOS (/var/folders/.../) and
+// /tmp on Linux, so the redaction has a real absolute prefix to strip.
+func TestWriteLedger_RedactsHomePaths(t *testing.T) {
+ cliDir := t.TempDir()
+
+ if err := writeLedger(cliDir, nil, nil, nil); err != nil {
+ t.Fatalf("writeLedger: %v", err)
+ }
+
+ data, err := os.ReadFile(filepath.Join(cliDir, artifacts.ToolsPolishLedgerFilename))
+ if err != nil {
+ t.Fatalf("read ledger: %v", err)
+ }
+ s := string(data)
+ for _, prefix := range []string{`"/Users/`, `"/home/`, `"C:\\Users\\`, `"/var/folders/`, `"/tmp/`} {
+ if strings.Contains(s, prefix) {
+ t.Fatalf("ledger must not contain %q prefix; got:\n%s", prefix, s)
+ }
+ }
+
+ var loaded ToolsAuditLedger
+ if err := json.Unmarshal(data, &loaded); err != nil {
+ t.Fatalf("unmarshal ledger: %v", err)
+ }
+ want := filepath.Join(artifacts.CLIDirPlaceholder, filepath.Base(cliDir))
+ if loaded.CLIDir != want {
+ t.Fatalf("ledger CLIDir = %q, want %q", loaded.CLIDir, want)
+ }
+}
diff --git a/internal/pipeline/dogfood.go b/internal/pipeline/dogfood.go
index 083d49d1..b5140953 100644
--- a/internal/pipeline/dogfood.go
+++ b/internal/pipeline/dogfood.go
@@ -14,6 +14,7 @@ import (
"strings"
"time"
+ "github.com/mvanhorn/cli-printing-press/v4/internal/artifacts"
"github.com/mvanhorn/cli-printing-press/v4/internal/naming"
openapiparser "github.com/mvanhorn/cli-printing-press/v4/internal/openapi"
"github.com/mvanhorn/cli-printing-press/v4/internal/platform"
@@ -601,7 +602,11 @@ func LoadDogfoodResults(dir string) (*DogfoodReport, error) {
}
func writeDogfoodResults(report *DogfoodReport, dir string) error {
- data, err := json.MarshalIndent(report, "", " ")
+ // Marshal a copy so callers keep the real paths they passed in.
+ emitted := *report
+ emitted.Dir = artifacts.RedactCLIDirRoot(report.Dir)
+ emitted.SpecPath = artifacts.RedactPathUnderCLI(report.Dir, report.SpecPath)
+ data, err := json.MarshalIndent(&emitted, "", " ")
if err != nil {
return err
}
diff --git a/internal/pipeline/redact_paths_test.go b/internal/pipeline/redact_paths_test.go
new file mode 100644
index 00000000..c1540627
--- /dev/null
+++ b/internal/pipeline/redact_paths_test.go
@@ -0,0 +1,70 @@
+package pipeline
+
+import (
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/mvanhorn/cli-printing-press/v4/internal/artifacts"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestWriteDogfoodResults_RedactsHomePaths(t *testing.T) {
+ dir := t.TempDir()
+ leak := filepath.Join("/Users/operator/printing-press/library", filepath.Base(dir))
+ specPath := filepath.Join(leak, ".manuscripts", "run1", "spec.yaml")
+ report := &DogfoodReport{
+ Dir: leak,
+ SpecPath: specPath,
+ Verdict: "PASS",
+ }
+
+ require.NoError(t, writeDogfoodResults(report, dir))
+
+ data, err := os.ReadFile(filepath.Join(dir, "dogfood-results.json"))
+ require.NoError(t, err)
+ assertNoHomePrefix(t, data)
+
+ var loaded DogfoodReport
+ require.NoError(t, json.Unmarshal(data, &loaded))
+ assert.Equal(t, filepath.Join(artifacts.CLIDirPlaceholder, filepath.Base(dir)), loaded.Dir)
+ assert.Equal(t, filepath.Join(artifacts.CLIDirPlaceholder, ".manuscripts", "run1", "spec.yaml"), loaded.SpecPath)
+ // In-memory report is unchanged so downstream callers still see real paths.
+ assert.Equal(t, leak, report.Dir)
+ assert.Equal(t, specPath, report.SpecPath)
+}
+
+func TestWriteWorkflowVerifyReport_RedactsHomePaths(t *testing.T) {
+ dir := t.TempDir()
+ leak := filepath.Join("/Users/operator/printing-press/library", filepath.Base(dir))
+ report := &WorkflowVerifyReport{
+ Dir: leak,
+ Verdict: WorkflowVerdictPass,
+ }
+
+ require.NoError(t, writeWorkflowVerifyReport(dir, report))
+
+ data, err := os.ReadFile(filepath.Join(dir, "workflow-verify-report.json"))
+ require.NoError(t, err)
+ assertNoHomePrefix(t, data)
+
+ loaded, err := LoadWorkflowVerifyReport(dir)
+ require.NoError(t, err)
+ assert.Equal(t, filepath.Join(artifacts.CLIDirPlaceholder, filepath.Base(dir)), loaded.Dir)
+ assert.Equal(t, leak, report.Dir)
+}
+
+func assertNoHomePrefix(t *testing.T, data []byte) {
+ t.Helper()
+ s := string(data)
+ // Includes /var/folders/ and /tmp/ so a future test that drives
+ // real t.TempDir() paths through the writer still trips the gate.
+ for _, prefix := range []string{`"/Users/`, `"/home/`, `"C:\\Users\\`, `"/var/folders/`, `"/tmp/`} {
+ if strings.Contains(s, prefix) {
+ t.Fatalf("artifact must not contain %q prefix; got:\n%s", prefix, s)
+ }
+ }
+}
diff --git a/internal/pipeline/workflow_verify.go b/internal/pipeline/workflow_verify.go
index 8b24bbd2..152213c4 100644
--- a/internal/pipeline/workflow_verify.go
+++ b/internal/pipeline/workflow_verify.go
@@ -10,6 +10,8 @@ import (
"regexp"
"strings"
"time"
+
+ "github.com/mvanhorn/cli-printing-press/v4/internal/artifacts"
)
// RunWorkflowVerification builds the CLI and runs all workflows from the manifest.
@@ -392,7 +394,10 @@ func deriveOverallVerdict(manifest *WorkflowManifest, results []WorkflowResult)
// writeWorkflowVerifyReport writes the report as JSON to the given directory.
func writeWorkflowVerifyReport(dir string, report *WorkflowVerifyReport) error {
- data, err := json.MarshalIndent(report, "", " ")
+ // Marshal a copy so callers keep the real path they passed in.
+ emitted := *report
+ emitted.Dir = artifacts.RedactCLIDirRoot(report.Dir)
+ data, err := json.MarshalIndent(&emitted, "", " ")
if err != nil {
return fmt.Errorf("marshaling workflow verify report: %w", err)
}
← 5e3667df fix(cli): route scorer subcommand --spec URLs through genera
·
back to Cli Printing Press
·
fix(cli): make dogfood acceptance marker manifest read best- ee13fb16 →