diff --git a/README.md b/README.md index 5caf5aa..fbc67b9 100644 --- a/README.md +++ b/README.md @@ -215,6 +215,15 @@ claims that are *already written* into the change: (sorted path + per-file content hash, deletions marked absent), so a mid-branch receipt over staged, unstaged, or untracked work — which no commit SHA identifies — is reproducible and comparable too. +- **Probe details are machine-clean by a categorical rule, not a blocklist.** + A receipt is shareable, and runners echo arbitrary tool output (a failing + test's own message, a compiler error) into evidence details. Every detail + passes one sanitization chokepoint: the repository root becomes `.` (in-repo + paths stay fully readable), every OTHER absolute path collapses to + `…/` — closing temp paths, toolchain roots, other users' homes, + and sibling project names in one rule instead of enumerating known-bad + roots — and the machine's hostname is scrubbed. File:line actionability + survives the collapse (`…/testing.go:1576`); URLs pass untouched. - **Same-id claims merge; accept/reject pairs earn T2.** Every test named for one invariant becomes a probe of the same claim — all run, any can refute. When one of those tests is accept-polarity and another is reject-polarity diff --git a/internal/receipt/receipt.go b/internal/receipt/receipt.go index 610d277..bdb4252 100644 --- a/internal/receipt/receipt.go +++ b/internal/receipt/receipt.go @@ -8,7 +8,9 @@ import ( "fmt" "io" "os" + "path" "path/filepath" + "regexp" "strings" "github.com/joshft/correctful/internal/gitdiff" @@ -211,10 +213,23 @@ func exclusionNote(excl []schema.Exclusion) string { return "scope excluded " + strings.Join(parts, " · ") } -// sanitizePaths scrubs local filesystem locations from probe detail text: the -// repository root becomes "." and the home directory "~". External tools -// (compilers, test runners) print absolute paths freely, and a receipt that -// echoes them leaks machine layout into a shareable artifact. +// sanitizePaths scrubs machine details from probe detail text. Assemble is +// the CHOKEPOINT: every Evidence.Detail passes through here regardless of +// which runner produced it, so a runner that echoes arbitrary tool output +// (a failing test's own message, a compiler error) cannot leak machine +// layout into a shareable receipt. +// +// The rule is categorical, not an enumerated blocklist: the repository root +// becomes "." (keeping in-repo paths fully readable, relative), and every +// OTHER absolute path collapses to "…/". Enumerating known-bad +// roots (the old repoRoot + $HOME pair) is incomplete by construction — it +// left temp paths, toolchain roots, other users' homes, and sibling project +// names (a $HOME replacement renders "~/src//…", which still names +// the sibling). An absolute path outside the repo is never the receipt +// reader's business; its basename preserves the actionable part +// ("…/testing.go:1576"). The machine's hostname is scrubbed to "". +// URLs pass untouched: "//" never matches the path shape, and a path +// preceded by an alphanumeric (https:…) is not token-initial. func sanitizePaths(detail, repoRoot string) string { if detail == "" { return detail @@ -222,10 +237,36 @@ func sanitizePaths(detail, repoRoot string) string { if repoRoot != "" && repoRoot != "." { detail = strings.ReplaceAll(detail, repoRoot, ".") } - if home, err := os.UserHomeDir(); err == nil && home != "" && home != "/" { - detail = strings.ReplaceAll(detail, home, "~") + return scrubHost(collapseAbsPaths(detail), hostname) +} + +// absPathRe matches a token-initial absolute path of at least two +// components. The colon is a terminator, not a path character, so +// "/a/b/x.go:12: msg" collapses to "…/x.go:12: msg" — the file:line +// actionability survives the redaction. +var absPathRe = regexp.MustCompile("(^|[\\s\"'`=,;(\\[])(/[^/\\x00\\s\"'`:,;)\\]]+(?:/[^/\\x00\\s\"'`:,;)\\]]+)+)") + +func collapseAbsPaths(s string) string { + return absPathRe.ReplaceAllStringFunc(s, func(m string) string { + sub := absPathRe.FindStringSubmatch(m) + return sub[1] + "…/" + path.Base(sub[2]) + }) +} + +// hostname is resolved once; the empty string (lookup failure) disables the +// scrub rather than failing a receipt over it. +var hostname = func() string { h, _ := os.Hostname(); return h }() + +// scrubHost replaces whole-word occurrences of the machine's hostname. +// Hostnames shorter than 3 bytes are left alone — a host named "go" would +// otherwise redact ordinary prose, and a leak needs a name distinctive +// enough to identify anything. +func scrubHost(s, host string) string { + if len(host) < 3 || !strings.Contains(s, host) { + return s } - return detail + re := regexp.MustCompile(`\b` + regexp.QuoteMeta(host) + `\b`) + return re.ReplaceAllString(s, "") } // weigh reduces a claim's evidence to a status and an effective tier. diff --git a/internal/receipt/receipt_test.go b/internal/receipt/receipt_test.go index e934f51..fbeed00 100644 --- a/internal/receipt/receipt_test.go +++ b/internal/receipt/receipt_test.go @@ -346,3 +346,43 @@ func TestReceiptSanitizesPathsAndPinsSHAs(t *testing.T) { t.Errorf("text header missing SHA pins:\n%s", text.String()) } } + +// TestDetailSanitizationIsCategorical: the sanitizer is a categorical rule at +// the Assemble chokepoint, not an enumerated blocklist — EVERY absolute path +// outside the repo collapses to its basename, closing the classes a +// root+home enumeration left open: temp paths, toolchain roots, sibling +// project names, other users' homes. In-repo paths stay fully readable, and +// file:line actionability survives the collapse. +func TestDetailSanitizationIsCategorical(t *testing.T) { + const repo = "/home/someone/src/proj" + cases := []struct{ in, want string }{ + // In-repo: full relative path preserved (the reader's business). + {repo + "/pkg/x_test.go:12: boom", "./pkg/x_test.go:12: boom"}, + // Temp path. + {"wrote /tmp/probe-x1/out.json", "wrote …/out.json"}, + // Sibling project beside the repo: the name must not survive. + {"read /home/someone/src/otherproj/secret.cs:3", "read …/secret.cs:3"}, + // Toolchain root, file:line intact. + {"panic at /usr/lib/go/src/testing/testing.go:1576: died", "panic at …/testing.go:1576: died"}, + // Flag-glued path. + {"-coverprofile=/tmp/cov1/prof.out", "-coverprofile=…/prof.out"}, + // URLs are not filesystem paths and pass untouched. + {"GET http://example.com/a/b: refused", "GET http://example.com/a/b: refused"}, + // Repo-relative output was never a leak. + {"internal/probe/gotest.go:41: ok", "internal/probe/gotest.go:41: ok"}, + // Single-component absolute names identify nothing. + {"read /dev: is a directory", "read /dev: is a directory"}, + } + for _, c := range cases { + if got := sanitizePaths(c.in, repo); got != c.want { + t.Errorf("sanitizePaths(%q)\n got %q\n want %q", c.in, got, c.want) + } + } + + if got := scrubHost("dial tcp devbox42:8080: refused", "devbox42"); got != "dial tcp :8080: refused" { + t.Errorf("hostname survived: %q", got) + } + if got := scrubHost("go test ok", "go"); got != "go test ok" { + t.Errorf("short-hostname guard failed: %q", got) + } +}