[object Object]

← 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

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 →