[object Object]

← back to Cli Printing Press

fix(cli): scope HOME/XDG_CONFIG_HOME for verify/dogfood subprocesses (#1416)

c5a51eadfede71058d5b4b8ab9c0ba7d17a7c82b · 2026-05-14 22:25:56 -0700 · Trevin Chow

* fix(cli): scope HOME/XDG_CONFIG_HOME for verify/dogfood subprocesses

Verify, dogfood, live-dogfood, live-check, and workflow-verify each
build the printed CLI and exec it as a child process with mock-mode
env vars (<API>_BASE_URL pointing at a test server, <API>_TOKEN set to
a placeholder). Until now the child also inherited the operator's
$HOME / $XDG_CONFIG_HOME, so any save path the printed CLI tripped
(auth login under a mock server, doctor repair flows, set-token, ...)
persisted those mock values to ~/.config/<api>-pp-cli/config.<format>,
clobbering the operator's real credentials. Their next real-API
invocation failed with "dial tcp 127.0.0.1:<port>: connection refused".

Each top-level pipeline entry point now installs an ephemeral
subprocess home for the duration of the run and overlays HOME, the
XDG_* triad, and the Windows equivalents (USERPROFILE / APPDATA /
LOCALAPPDATA) onto every child env. Children resolve
os.UserConfigDir / os.UserHomeDir under the tempdir and their writes
land there; the parent's real home is never touched.

The fix is in the pipeline package, not in the generator, so it covers
already-shipped printed CLIs without a regen.

Closes #1409

* fix(cli): guard scoped subprocess home with RWMutex; check test errors

Greptile flagged that scopedHomeDir was an unprotected package-level
global. Production entry points run sequentially, but the pipeline
package's own tests (synthetic_spec_test, dogfood_test, live_dogfood_test)
use t.Parallel(), and go test -race surfaces a real data race when those
tests concurrently invoke RunDogfood / RunVerify.

Add a sync.RWMutex around currentSubprocessHome and installScopedSubprocessHome
so the race detector is clean. Reads are RLock so the hot subprocessEnv()
path stays cheap; writes only happen at session install/restore.

Also fix the two test helpers that ignored scopeSubprocessHome's error
return; on a MkdirTemp failure the downstream assertions would have run
against an empty scoped home and produced a confusing failure mode.

Files touched

Diff

commit c5a51eadfede71058d5b4b8ab9c0ba7d17a7c82b
Author: Trevin Chow <trevin@trevinchow.com>
Date:   Thu May 14 22:25:56 2026 -0700

    fix(cli): scope HOME/XDG_CONFIG_HOME for verify/dogfood subprocesses (#1416)
    
    * fix(cli): scope HOME/XDG_CONFIG_HOME for verify/dogfood subprocesses
    
    Verify, dogfood, live-dogfood, live-check, and workflow-verify each
    build the printed CLI and exec it as a child process with mock-mode
    env vars (<API>_BASE_URL pointing at a test server, <API>_TOKEN set to
    a placeholder). Until now the child also inherited the operator's
    $HOME / $XDG_CONFIG_HOME, so any save path the printed CLI tripped
    (auth login under a mock server, doctor repair flows, set-token, ...)
    persisted those mock values to ~/.config/<api>-pp-cli/config.<format>,
    clobbering the operator's real credentials. Their next real-API
    invocation failed with "dial tcp 127.0.0.1:<port>: connection refused".
    
    Each top-level pipeline entry point now installs an ephemeral
    subprocess home for the duration of the run and overlays HOME, the
    XDG_* triad, and the Windows equivalents (USERPROFILE / APPDATA /
    LOCALAPPDATA) onto every child env. Children resolve
    os.UserConfigDir / os.UserHomeDir under the tempdir and their writes
    land there; the parent's real home is never touched.
    
    The fix is in the pipeline package, not in the generator, so it covers
    already-shipped printed CLIs without a regen.
    
    Closes #1409
    
    * fix(cli): guard scoped subprocess home with RWMutex; check test errors
    
    Greptile flagged that scopedHomeDir was an unprotected package-level
    global. Production entry points run sequentially, but the pipeline
    package's own tests (synthetic_spec_test, dogfood_test, live_dogfood_test)
    use t.Parallel(), and go test -race surfaces a real data race when those
    tests concurrently invoke RunDogfood / RunVerify.
    
    Add a sync.RWMutex around currentSubprocessHome and installScopedSubprocessHome
    so the race detector is clean. Reads are RLock so the hot subprocessEnv()
    path stays cheap; writes only happen at session install/restore.
    
    Also fix the two test helpers that ignored scopeSubprocessHome's error
    return; on a MkdirTemp failure the downstream assertions would have run
    against an empty scoped home and produced a confusing failure mode.
---
 internal/pipeline/dogfood.go             |   8 ++
 internal/pipeline/live_check.go          |   8 ++
 internal/pipeline/live_dogfood.go        |   7 +
 internal/pipeline/runtime.go             |   9 +-
 internal/pipeline/runtime_commands.go    |   4 +
 internal/pipeline/runtime_structural.go  |  11 +-
 internal/pipeline/subprocess_env.go      | 163 +++++++++++++++++++++
 internal/pipeline/subprocess_env_test.go | 239 +++++++++++++++++++++++++++++++
 internal/pipeline/workflow_verify.go     |   7 +
 9 files changed, 448 insertions(+), 8 deletions(-)

diff --git a/internal/pipeline/dogfood.go b/internal/pipeline/dogfood.go
index 99049456..763dd05c 100644
--- a/internal/pipeline/dogfood.go
+++ b/internal/pipeline/dogfood.go
@@ -204,6 +204,12 @@ func (s *openAPISpec) IsSynthetic() bool {
 }
 
 func RunDogfood(dir, specPath string, opts ...DogfoodOption) (*DogfoodReport, error) {
+	releaseHome, err := scopeSubprocessHome()
+	if err != nil {
+		return nil, err
+	}
+	defer releaseHome()
+
 	cfg := dogfoodConfig{}
 	for _, o := range opts {
 		o(&cfg)
@@ -1761,6 +1767,7 @@ func runStdoutOnly(binaryPath string, timeout time.Duration, args ...string) ([]
 	ctx, cancel := context.WithTimeout(context.Background(), timeout)
 	defer cancel()
 	cmd := exec.CommandContext(ctx, binaryPath, args...)
+	applyDefaultSubprocessEnv(cmd)
 	out, err := cmd.Output()
 	if ctx.Err() == context.DeadlineExceeded {
 		return nil, fmt.Errorf("timed out after %s", timeout)
@@ -1876,6 +1883,7 @@ func runDogfoodCmd(binary string, timeout time.Duration, args ...string) (string
 	ctx, cancel := context.WithTimeout(context.Background(), timeout)
 	defer cancel()
 	cmd := exec.CommandContext(ctx, binary, args...)
+	applyDefaultSubprocessEnv(cmd)
 	out, err := cmd.CombinedOutput()
 	if err != nil && ctx.Err() == context.DeadlineExceeded {
 		return "", fmt.Errorf("timed out after %s", timeout)
diff --git a/internal/pipeline/live_check.go b/internal/pipeline/live_check.go
index 5418987b..62300394 100644
--- a/internal/pipeline/live_check.go
+++ b/internal/pipeline/live_check.go
@@ -136,6 +136,13 @@ type LiveCheckOptions struct {
 // check doesn't penalize the CLI.
 func RunLiveCheck(opts LiveCheckOptions) *LiveCheckResult {
 	out := &LiveCheckResult{RanAt: time.Now().UTC()}
+	releaseHome, err := scopeSubprocessHome()
+	if err != nil {
+		out.Unable = true
+		out.Reason = err.Error()
+		return out
+	}
+	defer releaseHome()
 
 	if opts.CLIDir == "" {
 		out.Unable = true
@@ -388,6 +395,7 @@ func runOneFeatureCheck(cliDir, binaryPath string, f NovelFeature, timeout time.
 
 	cmd := exec.CommandContext(ctx, binaryPath, args...)
 	cmd.Dir = cliDir
+	applyDefaultSubprocessEnv(cmd)
 	// Capture stdout into a bounded buffer. An unbounded `cmd.Output()` call
 	// would let a misbehaving feature exhaust the scorecard's memory.
 	stdoutCap := &bytes.Buffer{}
diff --git a/internal/pipeline/live_dogfood.go b/internal/pipeline/live_dogfood.go
index 20255f94..713b795c 100644
--- a/internal/pipeline/live_dogfood.go
+++ b/internal/pipeline/live_dogfood.go
@@ -94,6 +94,12 @@ type liveDogfoodRun struct {
 }
 
 func RunLiveDogfood(opts LiveDogfoodOptions) (*LiveDogfoodReport, error) {
+	releaseHome, err := scopeSubprocessHome()
+	if err != nil {
+		return nil, err
+	}
+	defer releaseHome()
+
 	if strings.TrimSpace(opts.CLIDir) == "" {
 		return nil, fmt.Errorf("CLIDir is required")
 	}
@@ -852,6 +858,7 @@ func runLiveDogfoodProcess(binaryPath, cliDir string, args []string, timeout tim
 
 	cmd := exec.CommandContext(ctx, binaryPath, args...)
 	cmd.Dir = cliDir
+	applyDefaultSubprocessEnv(cmd)
 	stdout := &bytes.Buffer{}
 	stderr := &bytes.Buffer{}
 	cmd.Stdout = &limitedWriter{w: stdout, remaining: MaxOutputBytes}
diff --git a/internal/pipeline/runtime.go b/internal/pipeline/runtime.go
index 0ef18a73..9dcff7f6 100644
--- a/internal/pipeline/runtime.go
+++ b/internal/pipeline/runtime.go
@@ -85,6 +85,11 @@ type FreshnessResult struct {
 
 // RunVerify executes the runtime verification pipeline.
 func RunVerify(cfg VerifyConfig) (*VerifyReport, error) {
+	releaseHome, err := scopeSubprocessHome()
+	if err != nil {
+		return nil, err
+	}
+	defer releaseHome()
 	if cfg.NoSpec {
 		return runStructuralVerify(cfg)
 	}
@@ -225,7 +230,7 @@ func RunVerify(cfg VerifyConfig) (*VerifyReport, error) {
 	// buildEnv constructs the environment for test subprocesses, passing
 	// all auth-related env vars so auth-requiring commands can complete.
 	buildEnv := func() []string {
-		env := os.Environ()
+		env := subprocessEnv()
 		if report.Mode == "live" {
 			for _, ev := range authEnvVars {
 				if val := os.Getenv(ev); val != "" {
@@ -535,7 +540,7 @@ func runBrowserSessionProofTest(binary string, auth apispec.AuthConfig) CommandR
 		return result
 	}
 
-	output, err := runCLIWithOutput(binary, []string{"doctor", "--json"}, os.Environ(), 20*time.Second)
+	output, err := runCLIWithOutput(binary, []string{"doctor", "--json"}, subprocessEnv(), 20*time.Second)
 	if err != nil {
 		result.Error = fmt.Sprintf("doctor --json failed: %v", err)
 		result.Score = 0
diff --git a/internal/pipeline/runtime_commands.go b/internal/pipeline/runtime_commands.go
index 5c1d666c..8dea76b8 100644
--- a/internal/pipeline/runtime_commands.go
+++ b/internal/pipeline/runtime_commands.go
@@ -28,6 +28,7 @@ func discoverCommandsFromHelp(binaryPath string) []discoveredCommand {
 	defer cancel()
 
 	helpCmd := exec.CommandContext(ctx, binaryPath, "--help")
+	applyDefaultSubprocessEnv(helpCmd)
 	out, err := helpCmd.CombinedOutput()
 	if err != nil {
 		return nil
@@ -127,6 +128,7 @@ func inferPositionalArgs(binary string, cmd *discoveredCommand, paramDefaults ma
 	defer cancel()
 
 	helpCmd := exec.CommandContext(ctx, binary, cmd.Name, "--help")
+	applyDefaultSubprocessEnv(helpCmd)
 	out, err := helpCmd.CombinedOutput()
 	if err != nil {
 		return // fall back to no extra args
@@ -308,6 +310,7 @@ func helpScanIndicatesSideEffect(binary string, cmd *discoveredCommand) bool {
 	defer cancel()
 
 	helpCmd := exec.CommandContext(ctx, binary, cmd.Name, "--help")
+	applyDefaultSubprocessEnv(helpCmd)
 	out, err := helpCmd.CombinedOutput()
 	if err != nil {
 		return false
@@ -431,6 +434,7 @@ func inferRequiredFlags(binary, cmdName string) []string {
 	defer cancel()
 
 	probe := exec.CommandContext(ctx, binary, cmdName)
+	applyDefaultSubprocessEnv(probe)
 	out, _ := probe.CombinedOutput() // error expected when flags are missing
 
 	m := requiredFlagsRe.FindSubmatch(out)
diff --git a/internal/pipeline/runtime_structural.go b/internal/pipeline/runtime_structural.go
index c57e6e66..b92ed061 100644
--- a/internal/pipeline/runtime_structural.go
+++ b/internal/pipeline/runtime_structural.go
@@ -2,7 +2,6 @@ package pipeline
 
 import (
 	"fmt"
-	"os"
 	"time"
 
 	"github.com/mvanhorn/cli-printing-press/v4/internal/artifacts"
@@ -38,9 +37,9 @@ func runStructuralVerify(cfg VerifyConfig) (*VerifyReport, error) {
 		report.Results = append(report.Results, result)
 	}
 
-	versionOK := runCLI(binaryPath, []string{"version"}, os.Environ(), 10*time.Second) == nil
+	versionOK := runCLI(binaryPath, []string{"version"}, subprocessEnv(), 10*time.Second) == nil
 	if !versionOK {
-		versionOK = runCLI(binaryPath, []string{"--version"}, os.Environ(), 10*time.Second) == nil
+		versionOK = runCLI(binaryPath, []string{"--version"}, subprocessEnv(), 10*time.Second) == nil
 	}
 	report.DataPipeline = versionOK
 	if versionOK {
@@ -63,14 +62,14 @@ func runStructuralCommandTests(binary string, cmd discoveredCommand) CommandResu
 		Kind:    "structural",
 	}
 
-	result.Help = runCLI(binary, []string{cmd.Name, "--help"}, os.Environ(), 10*time.Second) == nil
-	result.DryRun = runCLI(binary, []string{cmd.Name, "--help", "--json"}, os.Environ(), 10*time.Second) == nil
+	result.Help = runCLI(binary, []string{cmd.Name, "--help"}, subprocessEnv(), 10*time.Second) == nil
+	result.DryRun = runCLI(binary, []string{cmd.Name, "--help", "--json"}, subprocessEnv(), 10*time.Second) == nil
 
 	switch cmd.Name {
 	case "doctor", "version", "auth", "completion", "api", "help":
 		result.Execute = true // these work without args
 	default:
-		err := runCLI(binary, []string{cmd.Name, "--json"}, os.Environ(), 10*time.Second)
+		err := runCLI(binary, []string{cmd.Name, "--json"}, subprocessEnv(), 10*time.Second)
 		result.Execute = true
 		_ = err
 	}
diff --git a/internal/pipeline/subprocess_env.go b/internal/pipeline/subprocess_env.go
new file mode 100644
index 00000000..35194d8c
--- /dev/null
+++ b/internal/pipeline/subprocess_env.go
@@ -0,0 +1,163 @@
+package pipeline
+
+import (
+	"fmt"
+	"os"
+	"os/exec"
+	"path/filepath"
+	"strings"
+	"sync"
+)
+
+// Subprocess HOME / XDG_CONFIG_HOME scoping for verify, dogfood, live-dogfood,
+// live-check, and workflow-verify runs. Without it, the printed CLI's
+// config-save paths (auth login, set-token, doctor repair, etc.) would
+// persist the mock BASE_URL / token values that verify injects to the
+// operator's real ~/.config/<api>-pp-cli/config.<format>.
+
+// configHomeEnvVars is the closed set of env vars Go's os.UserHomeDir,
+// os.UserConfigDir, and os.UserCacheDir consult (Unix + Windows). Rewriting
+// all of them together gives a printed CLI no path back to the operator's
+// real home regardless of which os.* helper its generated config code uses.
+var configHomeEnvVars = []string{
+	"HOME",
+	"XDG_CONFIG_HOME",
+	"XDG_CACHE_HOME",
+	"XDG_DATA_HOME",
+	"USERPROFILE",
+	"APPDATA",
+	"LOCALAPPDATA",
+}
+
+// newScopedConfigHome creates an ephemeral home root with the XDG
+// subtrees pre-created. Returns the path, a cleanup function (safe to
+// call once), and any creation error.
+func newScopedConfigHome() (string, func(), error) {
+	homeDir, err := os.MkdirTemp("", "printing-press-subprocess-")
+	if err != nil {
+		return "", func() {}, fmt.Errorf("creating subprocess home: %w", err)
+	}
+	cleanup := func() { _ = os.RemoveAll(homeDir) }
+	for _, sub := range []string{".config", ".cache", filepath.Join(".local", "share")} {
+		if err := os.MkdirAll(filepath.Join(homeDir, sub), 0o700); err != nil {
+			cleanup()
+			return "", func() {}, fmt.Errorf("creating subprocess %s: %w", sub, err)
+		}
+	}
+	return homeDir, cleanup, nil
+}
+
+// applyScopedConfigHome overlays the home-related env vars in env with
+// values rooted at homeDir so a receiving subprocess resolves
+// os.UserConfigDir / os.UserHomeDir under homeDir rather than the
+// parent's $HOME. Existing entries for the rewritten vars are dropped.
+// homeDir == "" returns env unchanged.
+func applyScopedConfigHome(env []string, homeDir string) []string {
+	if homeDir == "" {
+		return env
+	}
+	overrides := scopedConfigHomeOverrides(homeDir)
+	out := make([]string, 0, len(env)+len(overrides))
+	for _, kv := range env {
+		if isScopedConfigHomeEntry(kv) {
+			continue
+		}
+		out = append(out, kv)
+	}
+	for _, name := range configHomeEnvVars {
+		out = append(out, name+"="+overrides[name])
+	}
+	return out
+}
+
+// scopedConfigHomeOverrides maps each configHomeEnvVar to a path under
+// homeDir. APPDATA / XDG_CONFIG_HOME share .config so Windows and Linux
+// CLIs land in the same scoped dir; LOCALAPPDATA / XDG_CACHE_HOME share
+// .cache.
+func scopedConfigHomeOverrides(homeDir string) map[string]string {
+	configDir := filepath.Join(homeDir, ".config")
+	cacheDir := filepath.Join(homeDir, ".cache")
+	dataDir := filepath.Join(homeDir, ".local", "share")
+	return map[string]string{
+		"HOME":            homeDir,
+		"XDG_CONFIG_HOME": configDir,
+		"XDG_CACHE_HOME":  cacheDir,
+		"XDG_DATA_HOME":   dataDir,
+		"USERPROFILE":     homeDir,
+		"APPDATA":         configDir,
+		"LOCALAPPDATA":    cacheDir,
+	}
+}
+
+func isScopedConfigHomeEntry(kv string) bool {
+	for _, name := range configHomeEnvVars {
+		if strings.HasPrefix(kv, name+"=") {
+			return true
+		}
+	}
+	return false
+}
+
+// scopedHomeDir holds the active scoped home for child invocations of
+// the printed CLI. Production entry points run sequentially, but
+// pipeline tests use t.Parallel(), so the mutex protects against the
+// data race go test -race detects without it.
+var (
+	scopedHomeDirMu sync.RWMutex
+	scopedHomeDir   string
+)
+
+// currentSubprocessHome returns the active scoped home or "" if none.
+func currentSubprocessHome() string {
+	scopedHomeDirMu.RLock()
+	defer scopedHomeDirMu.RUnlock()
+	return scopedHomeDir
+}
+
+// installScopedSubprocessHome installs homeDir as the active scoped
+// home and returns a restore function the caller defers.
+func installScopedSubprocessHome(homeDir string) func() {
+	scopedHomeDirMu.Lock()
+	prev := scopedHomeDir
+	scopedHomeDir = homeDir
+	scopedHomeDirMu.Unlock()
+	return func() {
+		scopedHomeDirMu.Lock()
+		scopedHomeDir = prev
+		scopedHomeDirMu.Unlock()
+	}
+}
+
+// scopeSubprocessHome installs a fresh scoped home for the current
+// entry point. Callers defer the returned cleanup to restore the
+// previous home and remove the tempdir. Returning the error rather
+// than silently falling back is deliberate: the whole fix exists to
+// prevent data corruption, and a torn scope would leave the bug
+// exposed.
+func scopeSubprocessHome() (func(), error) {
+	homeDir, removeHome, err := newScopedConfigHome()
+	if err != nil {
+		return func() {}, err
+	}
+	restore := installScopedSubprocessHome(homeDir)
+	return func() {
+		restore()
+		removeHome()
+	}, nil
+}
+
+// subprocessEnv returns os.Environ() with the active scoped home
+// overlaid, or os.Environ() unchanged when no session is active.
+func subprocessEnv() []string {
+	return applyScopedConfigHome(os.Environ(), currentSubprocessHome())
+}
+
+// applyDefaultSubprocessEnv installs subprocessEnv() on cmd if the
+// caller hasn't already chosen cmd.Env. Every exec site that runs the
+// printed CLI calls this so the child inherits the scoped HOME.
+func applyDefaultSubprocessEnv(cmd *exec.Cmd) {
+	if cmd == nil || cmd.Env != nil {
+		return
+	}
+	cmd.Env = subprocessEnv()
+}
diff --git a/internal/pipeline/subprocess_env_test.go b/internal/pipeline/subprocess_env_test.go
new file mode 100644
index 00000000..e86da163
--- /dev/null
+++ b/internal/pipeline/subprocess_env_test.go
@@ -0,0 +1,239 @@
+package pipeline
+
+import (
+	"context"
+	"os"
+	"os/exec"
+	"path/filepath"
+	"runtime"
+	"strings"
+	"testing"
+	"time"
+)
+
+func TestApplyScopedConfigHomeRewritesHomeVars(t *testing.T) {
+	home := t.TempDir()
+	env := []string{
+		"HOME=/Users/operator",
+		"XDG_CONFIG_HOME=/Users/operator/.config",
+		"USERPROFILE=C:\\Users\\operator",
+		"APPDATA=C:\\Users\\operator\\AppData\\Roaming",
+		"UNRELATED=keepme",
+	}
+
+	got := applyScopedConfigHome(env, home)
+
+	assertEnv(t, got, "HOME", home)
+	assertEnv(t, got, "XDG_CONFIG_HOME", filepath.Join(home, ".config"))
+	assertEnv(t, got, "XDG_CACHE_HOME", filepath.Join(home, ".cache"))
+	assertEnv(t, got, "USERPROFILE", home)
+	assertEnv(t, got, "APPDATA", filepath.Join(home, ".config"))
+	assertEnv(t, got, "UNRELATED", "keepme")
+
+	for _, kv := range got {
+		if strings.HasPrefix(kv, "HOME=/Users/operator") ||
+			strings.HasPrefix(kv, "XDG_CONFIG_HOME=/Users/operator") {
+			t.Fatalf("operator's real HOME leaked into scoped env: %q", kv)
+		}
+	}
+}
+
+func TestApplyScopedConfigHomeEmptyHomeNoOps(t *testing.T) {
+	in := []string{"HOME=/real", "XDG_CONFIG_HOME=/real/.config"}
+	got := applyScopedConfigHome(in, "")
+	if len(got) != len(in) {
+		t.Fatalf("expected env unchanged, got %v", got)
+	}
+	for i, kv := range got {
+		if kv != in[i] {
+			t.Fatalf("entry %d changed: got %q want %q", i, kv, in[i])
+		}
+	}
+}
+
+func TestNewScopedConfigHomeCreatesXDGSubdirs(t *testing.T) {
+	home, cleanup, err := newScopedConfigHome()
+	if err != nil {
+		t.Fatalf("newScopedConfigHome: %v", err)
+	}
+	defer cleanup()
+
+	for _, sub := range []string{".config", ".cache", filepath.Join(".local", "share")} {
+		path := filepath.Join(home, sub)
+		info, err := os.Stat(path)
+		if err != nil {
+			t.Fatalf("missing %s: %v", path, err)
+		}
+		if !info.IsDir() {
+			t.Fatalf("%s exists but is not a directory", path)
+		}
+	}
+}
+
+func TestScopeSubprocessHomeInstallsAndRestores(t *testing.T) {
+	prev := currentSubprocessHome()
+	cleanup, err := scopeSubprocessHome()
+	if err != nil {
+		t.Fatalf("scopeSubprocessHome: %v", err)
+	}
+	scoped := currentSubprocessHome()
+	if scoped == "" || scoped == prev {
+		t.Fatalf("expected scoped home != prev (%q); got %q", prev, scoped)
+	}
+	cleanup()
+	if got := currentSubprocessHome(); got != prev {
+		t.Fatalf("currentSubprocessHome after cleanup = %q, want %q", got, prev)
+	}
+	if _, err := os.Stat(scoped); !os.IsNotExist(err) {
+		t.Fatalf("scoped home %q not removed by cleanup (err=%v)", scoped, err)
+	}
+}
+
+func TestApplyDefaultSubprocessEnvPreservesExplicitEnv(t *testing.T) {
+	cleanup, err := scopeSubprocessHome()
+	if err != nil {
+		t.Fatalf("scopeSubprocessHome: %v", err)
+	}
+	defer cleanup()
+
+	cmd := &exec.Cmd{Env: []string{"FOO=bar"}}
+	applyDefaultSubprocessEnv(cmd)
+	if len(cmd.Env) != 1 || cmd.Env[0] != "FOO=bar" {
+		t.Fatalf("explicit env was overwritten: %v", cmd.Env)
+	}
+}
+
+func TestApplyDefaultSubprocessEnvInstallsScopedHome(t *testing.T) {
+	cleanup, err := scopeSubprocessHome()
+	if err != nil {
+		t.Fatalf("scopeSubprocessHome: %v", err)
+	}
+	defer cleanup()
+	home := currentSubprocessHome()
+
+	cmd := &exec.Cmd{}
+	applyDefaultSubprocessEnv(cmd)
+	if !containsEnv(cmd.Env, "HOME", home) {
+		t.Fatalf("HOME=%s not present in scoped env: %v", home, cmd.Env)
+	}
+	if !containsEnv(cmd.Env, "XDG_CONFIG_HOME", filepath.Join(home, ".config")) {
+		t.Fatalf("XDG_CONFIG_HOME not present in scoped env: %v", cmd.Env)
+	}
+}
+
+// TestSubprocessWriteDoesNotEscapeScopedHome is the acceptance test for
+// issue #1409: a subprocess invoked under a scoped session must not write
+// to the parent's HOME-resolved config dir. The parent's HOME is
+// redirected to a sentinel tempdir via t.Setenv so the test never
+// touches the operator's real ~/.config/, even on a crash before
+// cleanup runs.
+func TestSubprocessWriteDoesNotEscapeScopedHome(t *testing.T) {
+	if testing.Short() {
+		t.Skip("compiles a probe binary; skipped under -short")
+	}
+	if runtime.GOOS == "windows" {
+		t.Skip("HOME-shaped probe is POSIX-only")
+	}
+
+	parentHome := t.TempDir()
+	t.Setenv("HOME", parentHome)
+	sentinelDir := filepath.Join(parentHome, ".config", "cli-printing-press-test")
+	sentinelPath := filepath.Join(sentinelDir, "config.toml")
+	if err := os.MkdirAll(sentinelDir, 0o700); err != nil {
+		t.Fatalf("mkdir sentinel dir: %v", err)
+	}
+	const sentinelValue = "sentinel-must-survive"
+	if err := os.WriteFile(sentinelPath, []byte(sentinelValue), 0o600); err != nil {
+		t.Fatalf("write sentinel: %v", err)
+	}
+
+	probeDir := t.TempDir()
+	probeSrc := filepath.Join(probeDir, "main.go")
+	if err := os.WriteFile(probeSrc, []byte(probeProgram), 0o600); err != nil {
+		t.Fatalf("write probe source: %v", err)
+	}
+	probeBin := filepath.Join(probeDir, "probe")
+	buildCtx, buildCancel := context.WithTimeout(context.Background(), 60*time.Second)
+	defer buildCancel()
+	build := exec.CommandContext(buildCtx, "go", "build", "-o", probeBin, probeSrc)
+	if out, err := build.CombinedOutput(); err != nil {
+		t.Fatalf("build probe: %v\n%s", err, out)
+	}
+
+	cleanup, err := scopeSubprocessHome()
+	if err != nil {
+		t.Fatalf("scopeSubprocessHome: %v", err)
+	}
+	defer cleanup()
+	scopedHome := currentSubprocessHome()
+
+	runCtx, runCancel := context.WithTimeout(context.Background(), 30*time.Second)
+	defer runCancel()
+	cmd := exec.CommandContext(runCtx, probeBin)
+	applyDefaultSubprocessEnv(cmd)
+	if out, err := cmd.CombinedOutput(); err != nil {
+		t.Fatalf("run probe: %v\n%s", err, out)
+	}
+
+	got, err := os.ReadFile(sentinelPath)
+	if err != nil {
+		t.Fatalf("read sentinel after probe: %v", err)
+	}
+	if string(got) != sentinelValue {
+		t.Fatalf("sentinel clobbered: got %q want %q", string(got), sentinelValue)
+	}
+
+	scopedConfig := filepath.Join(scopedHome, ".config", "cli-printing-press-test", "config.toml")
+	if _, err := os.Stat(scopedConfig); err != nil {
+		t.Fatalf("probe did not write into scoped home (%s): %v", scopedConfig, err)
+	}
+}
+
+// probeProgram replicates the shape of the generated config-save path:
+// resolves HOME, writes to $HOME/.config/<cli>/config.toml.
+const probeProgram = `package main
+
+import (
+	"fmt"
+	"os"
+	"path/filepath"
+)
+
+func main() {
+	home, err := os.UserHomeDir()
+	if err != nil {
+		fmt.Fprintln(os.Stderr, err)
+		os.Exit(1)
+	}
+	dir := filepath.Join(home, ".config", "cli-printing-press-test")
+	if err := os.MkdirAll(dir, 0o700); err != nil {
+		fmt.Fprintln(os.Stderr, err)
+		os.Exit(1)
+	}
+	path := filepath.Join(dir, "config.toml")
+	if err := os.WriteFile(path, []byte("written-by-probe"), 0o600); err != nil {
+		fmt.Fprintln(os.Stderr, err)
+		os.Exit(1)
+	}
+}
+`
+
+func assertEnv(t *testing.T, env []string, name, want string) {
+	t.Helper()
+	if !containsEnv(env, name, want) {
+		t.Fatalf("env missing %s=%q; got %v", name, want, env)
+	}
+}
+
+func containsEnv(env []string, name, want string) bool {
+	prefix := name + "="
+	for _, kv := range env {
+		if !strings.HasPrefix(kv, prefix) {
+			continue
+		}
+		if strings.TrimPrefix(kv, prefix) == want {
+			return true
+		}
+	}
+	return false
+}
diff --git a/internal/pipeline/workflow_verify.go b/internal/pipeline/workflow_verify.go
index 152213c4..c6d56664 100644
--- a/internal/pipeline/workflow_verify.go
+++ b/internal/pipeline/workflow_verify.go
@@ -16,6 +16,12 @@ import (
 
 // RunWorkflowVerification builds the CLI and runs all workflows from the manifest.
 func RunWorkflowVerification(dir string) (*WorkflowVerifyReport, error) {
+	releaseHome, err := scopeSubprocessHome()
+	if err != nil {
+		return nil, err
+	}
+	defer releaseHome()
+
 	manifest, err := LoadWorkflowManifest(dir)
 	if err != nil {
 		return nil, fmt.Errorf("loading workflow manifest: %w", err)
@@ -146,6 +152,7 @@ func executeStep(binary string, step WorkflowStep, cmdExpanded string, dir strin
 		ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
 		cmd := exec.CommandContext(ctx, binary, args...)
 		cmd.Dir = dir
+		applyDefaultSubprocessEnv(cmd)
 		out, err := cmd.CombinedOutput()
 		cancel()
 

← 33a6668b fix(cli): handle single-quoted args in validate-narrative sh  ·  back to Cli Printing Press  ·  feat(skills): contain session-state.json outside DISCOVERY_D 55b0f16b →