Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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.
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
37 changes: 0 additions & 37 deletions internal/archtest/installer_gate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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 —
Expand Down
20 changes: 15 additions & 5 deletions internal/lifecycle/install.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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))
Expand All @@ -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)
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -302,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
Expand All @@ -310,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)
Expand Down
45 changes: 45 additions & 0 deletions internal/lifecycle/install_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
// 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)
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)
}
})
}
}
14 changes: 13 additions & 1 deletion internal/lifecycle/live.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
19 changes: 17 additions & 2 deletions internal/lifecycle/liveguard_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package lifecycle

import (
"path/filepath"
"strings"
"testing"
)
Expand Down Expand Up @@ -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")
}
})

Expand Down