From aaf435b9e2605d5afefb206b8298a1331521fd73 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Tue, 15 Sep 2026 10:49:55 +0100 Subject: [PATCH 1/7] test: every install warning is redacted against this account's home Four rows, each a seam answering with the home path inside a sentence somebody else wrote, held to a report that prints none of them as they came. Three are red before the fix (iss-2609120438396694). Assisted-by: Claude:claude-fable-5-1 Claude-Session: https://claude.ai/code/session_01RooZgUESQnVnievtn48pHo --- internal/lifecycle/install_test.go | 45 ++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/internal/lifecycle/install_test.go b/internal/lifecycle/install_test.go index d3427a12..eb435376 100644 --- a/internal/lifecycle/install_test.go +++ b/internal/lifecycle/install_test.go @@ -557,3 +557,48 @@ func waitFor(t *testing.T, cond func() bool, msg string) { type discardWriter struct{} func (discardWriter) Write(p []byte) (int, error) { return len(p), nil } + +// Every warning the install verb writes is redacted against this account's +// home, the way its failure line is. The seams here answer with errors that +// carry the home path INSIDE a sentence somebody else wrote — which is how a +// path from os.Lstat, a launcher or a firewall tool arrives — and the report is +// held to printing none of them as they came (iss-2609120438396694). +func TestInstallWarningsAreRedacted(t *testing.T) { + leak := func(home string) error { + return errors.New("open " + filepath.Join(home, "Applications", "Gropius.app") + ": operation not permitted") + } + for _, tc := range []struct { + name string + setup func(ie *InstallEnv) + want string + }{ + {"a running copy that will not quit", func(ie *InstallEnv) { + bundleAt(t, ie.Dest, "installed") + ie.Quit = func() error { return leak(ie.Home) } + }, "could not be asked to quit"}, + {"a firewall grant that was not made", func(ie *InstallEnv) { + ie.Firewall = func(string) error { return leak(ie.Home) } + }, "was not made"}, + {"a bundle that could not be opened", func(ie *InstallEnv) { + ie.Launch = func(string) error { return leak(ie.Home) } + }, "could not be opened"}, + {"a repair with nothing to repair", func(ie *InstallEnv) { + ie.Bundle = "" + }, "nothing to repair"}, + } { + t.Run(tc.name, func(t *testing.T) { + env, ie, _, errOut := installFixture(t) + tc.setup(&ie) + runInstall(env, nil, ie) + if !strings.Contains(errOut.String(), tc.want) { + t.Fatalf("the warning was not written (want %q):\n%s", tc.want, errOut) + } + if strings.Contains(errOut.String(), ie.Home) { + t.Errorf("the home path reached the terminal unredacted:\n%s", errOut) + } + if !strings.Contains(errOut.String(), "~/") { + t.Errorf("the path was dropped rather than redacted:\n%s", errOut) + } + }) + } +} From 087d5ea3efb4d43eec353e7cab07547cf48c5604 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Tue, 15 Sep 2026 10:50:27 +0100 Subject: [PATCH 2/7] fix: the install verb's warnings are redacted against this account's home The quit, firewall and launch warnings and the nothing-to-repair line printed the error text as it came, and one of them redacted the destination on the same line while the error beside it was not. All four now pass the text through redact, the way the failure line always did. The refusal at the top of RunInstall stays as it is: it is the live-environment guard's own sentence or a home directory that could not be resolved, and neither carries a path of this account's (iss-2609120438396694). Assisted-by: Claude:claude-fable-5-1 Claude-Session: https://claude.ai/code/session_01RooZgUESQnVnievtn48pHo --- internal/lifecycle/install.go | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/internal/lifecycle/install.go b/internal/lifecycle/install.go index 8053aa57..a36b42fa 100644 --- a/internal/lifecycle/install.go +++ b/internal/lifecycle/install.go @@ -171,7 +171,7 @@ func runInstall(env Env, args []string, ie InstallEnv) int { if ie.Bundle == "" { if err := installedAt(ie.Dest); err != nil { writeLine(env.Err, "gropius install: there is nothing to repair at "+redact(ie.Dest, ie.Home)+ - " — "+err.Error()+".") + " — "+redact(err.Error(), ie.Home)+".") writeLine(env.Err, "Install Gropius first: "+bootstrapCommand) return ExitFailed } @@ -188,7 +188,8 @@ func runInstall(env Env, args []string, ie InstallEnv) int { // swap below replaces the bundle regardless. if _, err := os.Lstat(ie.Dest); err == nil { if err := ie.Quit(); err != nil { - writeLine(env.Err, "warning: a running copy could not be asked to quit ("+err.Error()+")") + writeLine(env.Err, "warning: a running copy could not be asked to quit ("+ + redact(err.Error(), ie.Home)+")") } } writeLine(env.Err, stagePlace+": "+redact(ie.Dest, ie.Home)) @@ -209,7 +210,7 @@ func runInstall(env Env, args []string, ie InstallEnv) int { // an installation that serves loopback, so it is reported with the commands // that make the grant by hand rather than failing the install. if err := ie.Firewall(binary); err != nil { - writeLine(env.Err, "warning: "+stageFirewall+" was not made ("+err.Error()+").") + writeLine(env.Err, "warning: "+stageFirewall+" was not made ("+redact(err.Error(), ie.Home)+").") writeLine(env.Err, "Other machines may see an empty response until an administrator runs:") for _, c := range firewallGrantCommands(binary, ie.Home) { writeLine(env.Err, " "+c) @@ -240,7 +241,8 @@ func runInstall(env Env, args []string, ie InstallEnv) int { } if err := ie.Launch(ie.Dest); err != nil { - writeLine(env.Err, "warning: "+redact(ie.Dest, ie.Home)+" could not be opened ("+err.Error()+")") + writeLine(env.Err, "warning: "+redact(ie.Dest, ie.Home)+" could not be opened ("+ + redact(err.Error(), ie.Home)+")") return ExitOK } if waitUntil(ie.Serving, ie.Poll, 30*time.Second) { From c59cc20d4cb85a3d457ca31da4cb8522778810f0 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Tue, 15 Sep 2026 10:52:13 +0100 Subject: [PATCH 3/7] test: an opened guard never probes the Mac for its destination With the guard open, the live install environment's destination is this account's own on every Mac, and the destination probe answers without asking. Red before the fix on a Mac whose /Applications is writable, which is every CI runner (iss-2609120444017291). Assisted-by: Claude:claude-fable-5-1 Claude-Session: https://claude.ai/code/session_01RooZgUESQnVnievtn48pHo --- internal/lifecycle/liveguard_test.go | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/internal/lifecycle/liveguard_test.go b/internal/lifecycle/liveguard_test.go index 2cee46af..320e6038 100644 --- a/internal/lifecycle/liveguard_test.go +++ b/internal/lifecycle/liveguard_test.go @@ -1,6 +1,7 @@ package lifecycle import ( + "path/filepath" "strings" "testing" ) @@ -31,13 +32,27 @@ func TestTheLiveEnvironmentsRefuseToBeBuiltInATest(t *testing.T) { } // And the one way past it is deliberate, named, and lasts for one test. +// +// Opening it also closes the one probe that touches the Mac: the live install +// and update environments choose the destination by asking whether this +// account can write /Applications, which creates and removes a file there — +// and a test binary doing that raced the installer tripwire in +// internal/archtest (iss-2609120444017291). With the guard open, the answer is +// "no" without asking, so the destination is this account's own, on every Mac. func TestTheGuardCanBeOpenedForOneTest(t *testing.T) { env, _, _ := testEnv() t.Run("opened", func(t *testing.T) { allowLiveEnvInTest(t) - if _, err := liveInstallEnv(env); err != nil { - t.Errorf("the guard was opened and still refused: %v", err) + ie, err := liveInstallEnv(env) + if err != nil { + t.Fatalf("the guard was opened and still refused: %v", err) + } + if ie.Dest != filepath.Join(ie.Home, "Applications", bundleName) { + t.Errorf("Dest = %q; an opened guard probed the Mac for its destination", ie.Dest) + } + if destinationWritable() { + t.Error("the destination probe still answers for the Mac while the guard is open") } }) From ab6648cfa1efd2e3a73a94451b9051505167c18e Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Tue, 15 Sep 2026 10:52:14 +0100 Subject: [PATCH 4/7] fix: no test binary creates a file in the Mac's /Applications The live install and update environments choose their destination by asking whether this account can write /Applications, which creates and removes a file there; the tests that open the live-environment guard built those environments, so a unit test wrote into the machine's applications directory and raced the installer tripwire in the merge queue. The probe is now a variable, and the one door into the live environment inside a test swaps it for an answer that touches nothing, restored when that test ends (iss-2609120444017291). Assisted-by: Claude:claude-fable-5-1 Claude-Session: https://claude.ai/code/session_01RooZgUESQnVnievtn48pHo --- internal/lifecycle/install.go | 10 +++++++++- internal/lifecycle/live.go | 14 +++++++++++++- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/internal/lifecycle/install.go b/internal/lifecycle/install.go index a36b42fa..2f41417c 100644 --- a/internal/lifecycle/install.go +++ b/internal/lifecycle/install.go @@ -304,6 +304,14 @@ func fail(env Env, ie InstallEnv, stage string, err error) int { return ExitFailed } +// destinationWritable answers whether this account can write the machine-wide +// applications directory, by creating and removing a file there. It is a +// variable for one reason: the door a test opens into the live environment +// (allowLiveEnvInTest) swaps it for an answer that touches nothing, so no test +// binary creates a file in the Mac's /Applications — which one did, and raced +// the installer tripwire in internal/archtest (iss-2609120444017291). +var destinationWritable = func() bool { return writableDir(systemApplications) == nil } + // installDest is where the bundle belongs on this Mac. // // The machine-wide directory when this account can write it, and this account's @@ -312,7 +320,7 @@ func fail(env Env, ie InstallEnv, stage string, err error) int { // equivalent, and installing for every account when one asked is not what was // asked. func installDest(home string) string { - if writableDir(systemApplications) == nil { + if destinationWritable() { return filepath.Join(systemApplications, bundleName) } return filepath.Join(home, "Applications", bundleName) diff --git a/internal/lifecycle/live.go b/internal/lifecycle/live.go index abfced22..aa9d265e 100644 --- a/internal/lifecycle/live.go +++ b/internal/lifecycle/live.go @@ -37,10 +37,22 @@ var liveEnvAllowedInTest bool // allowLiveEnvInTest opens the live builders to the test that calls it, and is // the only way past the guard. It takes the testing.TB so it cannot be called // from anything but a test, and restores the guard when that test ends. +// +// Opening the guard also closes the one probe that touches the Mac: the live +// install and update environments choose their destination by asking whether +// this account can write /Applications, which creates and removes a file +// there. Inside a test that answer is "no" without asking, so the destination +// is always this account's own and no test binary writes into the machine's +// applications directory (iss-2609120444017291). func allowLiveEnvInTest(tb testing.TB) { tb.Helper() liveEnvAllowedInTest = true - tb.Cleanup(func() { liveEnvAllowedInTest = false }) + probe := destinationWritable + destinationWritable = func() bool { return false } + tb.Cleanup(func() { + liveEnvAllowedInTest = false + destinationWritable = probe + }) } // liveEnvGuard refuses the live environment inside a test binary. From cd43c240a1848f022c9c565d36180fc21d956f44 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Tue, 15 Sep 2026 10:52:14 +0100 Subject: [PATCH 5/7] test: the installer tripwire counts every entry in /Applications again The exemption for the writability probe's file was a belt over a race whose source is now closed: no test binary creates that file. A listing that ignores nothing is the stronger tripwire. Assisted-by: Claude:claude-fable-5-1 Claude-Session: https://claude.ai/code/session_01RooZgUESQnVnievtn48pHo --- internal/archtest/installer_gate_test.go | 37 ------------------------ 1 file changed, 37 deletions(-) diff --git a/internal/archtest/installer_gate_test.go b/internal/archtest/installer_gate_test.go index 0a987a16..816d040c 100644 --- a/internal/archtest/installer_gate_test.go +++ b/internal/archtest/installer_gate_test.go @@ -863,9 +863,6 @@ func applicationsListing(t *testing.T) []string { var names []string if entries, err := os.ReadDir("/Applications"); err == nil { for _, e := range entries { - if isWritabilityProbe(e.Name()) { - continue - } names = append(names, e.Name()) } sort.Strings(names) @@ -876,40 +873,6 @@ func applicationsListing(t *testing.T) []string { return names } -// isWritabilityProbe reports whether a name is the file the lifecycle -// package's writability probe creates and removes to answer whether this -// account can write a directory (writableDir in internal/lifecycle/doctor.go). -// Choosing the install destination asks that of /Applications, and the test -// that proves the live-environment guard can be opened builds that -// environment — so on a Mac where /Applications is writable, the probe's file -// can exist there for the instant this listing is taken, in a package that -// runs alongside this one. It is not something install.sh writes, and the -// bundle fingerprints beside the listing are what catch an install; the name -// is left out of the comparison rather than read as an install -// (iss-2609120444017291 records the race itself). -func isWritabilityProbe(name string) bool { - return strings.HasPrefix(name, ".doctor-") && strings.HasSuffix(name, ".tmp") -} - -// The listing's one exemption is exactly the probe's name and nothing wider: a -// bundle, a hidden file of another shape, or the probe's name with either end -// changed all still count as a change to /Applications. -func TestTheTripwireExemptsOnlyTheWritabilityProbe(t *testing.T) { - for name, probe := range map[string]bool{ - ".doctor-400466334.tmp": true, - ".doctor-.tmp": true, - "Gropius.app": false, - ".localized": false, - ".doctor-1.tmp.app": false, - "doctor-1.tmp": false, - ".DS_Store": false, - } { - if got := isWritabilityProbe(name); got != probe { - t.Errorf("isWritabilityProbe(%q) = %v, want %v", name, got, probe) - } - } -} - // bundleFingerprint identifies a bundle directory by its modification time and // size, or reports it absent. install.sh installs by moving a freshly unpacked // directory into place, so a reinstall — even over an identical version — From 045b97208ae09d37a1dea3be955f0c8a179f02a9 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Tue, 15 Sep 2026 10:53:07 +0100 Subject: [PATCH 6/7] chore: close the two lifecycle captures and record the redaction fix Both issues move to resolved with their fixing commits; the changelog carries the user-facing half under [Unreleased]. Assisted-by: Claude:claude-fable-5-1 Claude-Session: https://claude.ai/code/session_01RooZgUESQnVnievtn48pHo --- ...nstall-prints-five-error-texts-unredacted-install-g.md | 8 ++++++++ ...packages-race-on-the-real-applications-testtheguard.md | 8 ++++++++ CHANGELOG.md | 8 ++++++++ 3 files changed, 24 insertions(+) rename .abcd/work/issues/{open => resolved}/iss-2609120438396694-gropius-install-prints-five-error-texts-unredacted-install-g.md (52%) rename .abcd/work/issues/{open => resolved}/iss-2609120444017291-two-test-packages-race-on-the-real-applications-testtheguard.md (57%) diff --git a/.abcd/work/issues/open/iss-2609120438396694-gropius-install-prints-five-error-texts-unredacted-install-g.md b/.abcd/work/issues/resolved/iss-2609120438396694-gropius-install-prints-five-error-texts-unredacted-install-g.md similarity index 52% rename from .abcd/work/issues/open/iss-2609120438396694-gropius-install-prints-five-error-texts-unredacted-install-g.md rename to .abcd/work/issues/resolved/iss-2609120438396694-gropius-install-prints-five-error-texts-unredacted-install-g.md index 3cf460fe..4809682a 100644 --- a/.abcd/work/issues/open/iss-2609120438396694-gropius-install-prints-five-error-texts-unredacted-install-g.md +++ b/.abcd/work/issues/resolved/iss-2609120438396694-gropius-install-prints-five-error-texts-unredacted-install-g.md @@ -8,6 +8,14 @@ source: "user-observation" found_during: "manual-capture" origin: researcher-authored production_mode: hand-written +resolution: "The four warning and refusal lines in install.go now pass the error text through redact against this account's home, the way the failure line always did; held by TestInstallWarningsAreRedacted, four rows, three watched red. The refusal at the top of RunInstall stays: it is the guard's own sentence or an unresolved home directory, neither carrying a path of this account's. Test aaf435b, fix 087d5ea." +impact: fix +resolved_by: + commit: "087d5ea" --- gropius install prints five error texts unredacted: install.go lines 108, 174, 191, 212 and 243 write err.Error() straight to the terminal, and 243 redacts the destination on the same line while the error beside it is not, which defeats the redaction. The update verb's quit warning was fixed on fix/update-verify-scope; the install verb's siblings were not, and there is no test holding any lifecycle warning line to the redaction rule. Found by the security review of that branch. + +## Grounds + +- pursued: we expect every path an install prints to arrive inside an error sentence somebody else wrote, so redacting the text rather than the operands is what holds; a leak the test table does not cover would show it wrong. diff --git a/.abcd/work/issues/open/iss-2609120444017291-two-test-packages-race-on-the-real-applications-testtheguard.md b/.abcd/work/issues/resolved/iss-2609120444017291-two-test-packages-race-on-the-real-applications-testtheguard.md similarity index 57% rename from .abcd/work/issues/open/iss-2609120444017291-two-test-packages-race-on-the-real-applications-testtheguard.md rename to .abcd/work/issues/resolved/iss-2609120444017291-two-test-packages-race-on-the-real-applications-testtheguard.md index 5275220f..75e853ca 100644 --- a/.abcd/work/issues/open/iss-2609120444017291-two-test-packages-race-on-the-real-applications-testtheguard.md +++ b/.abcd/work/issues/resolved/iss-2609120444017291-two-test-packages-race-on-the-real-applications-testtheguard.md @@ -8,6 +8,14 @@ source: "user-observation" found_during: "manual-capture" origin: researcher-authored production_mode: hand-written +resolution: "The /Applications writability probe behind installDest is a package variable, and allowLiveEnvInTest swaps it for a constant no while a test holds the guard open, restored on cleanup, so no test binary creates a file in the Mac's applications directory; held by TestTheGuardCanBeOpenedForOneTest, watched red on this Mac where /Applications is writable. The installer tripwire's exemption for the probe's file is removed with the source closed. Test c59cc20, fix ab6648c, tripwire cd43c24." +impact: internal +resolved_by: + commit: "ab6648c" --- Two test packages race on the real /Applications: TestTheGuardCanBeOpenedForOneTest (internal/lifecycle/liveguard_test.go) builds the live install environment, whose installDest asks writableDir of /Applications by creating and removing a .doctor-*.tmp file there, while the installer-gate tripwire (internal/archtest/installer_gate_test.go) snapshots /Applications before and after each install.sh run. go test runs the packages concurrently, and on a Mac where /Applications is writable (every CI runner) the probe's file can be in one snapshot and not the other. Seen once on PR 50 in the merge queue. The tripwire now leaves that one name out of its listing; the underlying fact stands that a unit test creates a file in the machine's /Applications, which is the class iss-2609111240578491 exists to stop, and the destination probe has no seam a test can point elsewhere. + +## Grounds + +- pursued: we expect the one door into the live environment inside a test to be the right place to close the one probe that touches the Mac, because every test that could reach the probe passes through it; a test that reaches installDest without opening the guard would show it wrong. diff --git a/CHANGELOG.md b/CHANGELOG.md index 09f7ae39..df036f5a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -66,6 +66,14 @@ GitHub release notes. bounded at 4 MiB ([how to](docs/self-test.md), [reference](docs/self-test-reference.md)). +### Fixed + +- **`gropius install` no longer prints your home directory in its warnings.** + A running copy that would not quit, a firewall grant that was not made, a + bundle that could not be opened and a repair with nothing to repair each + printed the error text as it came, with the path inside it; every line the + verb writes is now redacted to `~/`, the way its failure line always was. + ## [0.6.0] - 2026-09-12 ### Changed From e21b3582531aa0527e5b7033990818ee48270ef4 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Tue, 15 Sep 2026 10:56:10 +0100 Subject: [PATCH 7/7] test: drop the redaction row that could only pass on the line beside it The review found the nothing-to-repair row vacuous: installedAt's sentences carry no path, so the row passed on the destination already redacted beside it. Three rows remain, each red without its fix. Assisted-by: Claude:claude-fable-5-1 Claude-Session: https://claude.ai/code/session_01RooZgUESQnVnievtn48pHo --- internal/lifecycle/install_test.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/lifecycle/install_test.go b/internal/lifecycle/install_test.go index eb435376..0017e300 100644 --- a/internal/lifecycle/install_test.go +++ b/internal/lifecycle/install_test.go @@ -582,9 +582,9 @@ func TestInstallWarningsAreRedacted(t *testing.T) { {"a bundle that could not be opened", func(ie *InstallEnv) { ie.Launch = func(string) error { return leak(ie.Home) } }, "could not be opened"}, - {"a repair with nothing to repair", func(ie *InstallEnv) { - ie.Bundle = "" - }, "nothing to repair"}, + // The nothing-to-repair line is redacted the same way, but no row + // holds it: installedAt's sentences carry no path, so a row could only + // pass on the destination beside it and would prove nothing. } { t.Run(tc.name, func(t *testing.T) { env, ie, _, errOut := installFixture(t)