fix(#2665): scrub config-location env vars in every TEST_ENV_BASE declaration

TEST_ENV_BASE blanks session-identity variables but none of the three that
decide WHERE a child process writes: CLAUDE_CONFIG_DIR, GSD_RUNTIME and
CODEX_HOME. The config-home resolver is env-first (runtime-homes.cts, the
dot-home case consults the env var before the home-derived fallback), so an
ambient CLAUDE_CONFIG_DIR in the developer's shell beats a call site that
sandboxes only HOME. The suite then writes into the developer's real config
directory -- including a registered skill under <configDir>/skills/ whose
body carries behavioural directives that load into later sessions.

Blank all three alongside the session-identity vars. `...env` still spreads
last, so the five call sites that already constrain these locally keep
winning with their explicit values.

TEST_ENV_BASE is re-declared in nine files, so the three lines are added
nine times rather than once. Consolidating the nine into a single exported
constant -- and fixing the TERM_SESSION / TERM_SESSION_ID drift between the
copies -- is deliberately left out of this change; see the PR body.

One call site needed adjusting. capability-state.test.cjs's
`capability state --runtime claude` CLI test passed no env at all and
compared the CHILD's resolved config dir against the PARENT process's
getGlobalConfigDir('claude'). That agreed only because the child inherited
the developer's ambient CLAUDE_CONFIG_DIR -- i.e. it passed *because of*
the leak. It now redirects both runtime homes into the sandbox and asserts
against values the test controls, so it is hermetic with the variable set
or unset.

Regression case folded into the owning module's test file rather than a new
bug-NNNN file, per scripts/lint-regression-test-names.cjs. It sets the
variable on the PARENT process, which is the actual vector; setting it in
the per-call env argument would exercise a path that was never broken.
This commit is contained in:
0xdhx
2026-07-26 17:41:40 -05:00
parent 343835facc
commit 08021b02c0
11 changed files with 112 additions and 8 deletions

View File

@@ -35,6 +35,12 @@ const TEST_ENV_BASE = {
GSD_WORKSTREAM: '',
TTY: '',
SSH_TTY: '',
// Config-LOCATION vars. Distinct in kind from the session-identity vars
// above: these decide WHERE a child writes, so leaving them ambient lets a
// test that sandboxes HOME still escape into the developer's real config dir.
CLAUDE_CONFIG_DIR: '',
GSD_RUNTIME: '',
CODEX_HOME: '',
};
/**

View File

@@ -44,6 +44,12 @@ const TEST_ENV_BASE = {
ZELLIJ_SESSION_NAME: '',
TTY: '',
SSH_TTY: '',
// Config-LOCATION vars. Distinct in kind from the session-identity vars
// above: these decide WHERE a child writes, so leaving them ambient lets a
// test that sandboxes HOME still escape into the developer's real config dir.
CLAUDE_CONFIG_DIR: '',
GSD_RUNTIME: '',
CODEX_HOME: '',
};
function runTools(args, cwd) {

View File

@@ -40,6 +40,12 @@ const TEST_ENV_BASE = {
ZELLIJ_SESSION_NAME: '',
TTY: '',
SSH_TTY: '',
// Config-LOCATION vars. Distinct in kind from the session-identity vars
// above: these decide WHERE a child writes, so leaving them ambient lets a
// test that sandboxes HOME still escape into the developer's real config dir.
CLAUDE_CONFIG_DIR: '',
GSD_RUNTIME: '',
CODEX_HOME: '',
};
function runTools(args, cwd) {

View File

@@ -2108,12 +2108,24 @@ describe('regressions: --runtime override bypasses persisted runtime (#2003)', (
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cap-rt-cli-'));
try {
writePersistedRuntime(tmpDir, 'codex');
const result = runGsdTools('capability state --runtime claude --raw', tmpDir);
// #2665: redirect both runtime homes into the sandbox so the expectation is
// built from values this test controls. Comparing the child's answer against
// the PARENT process's getGlobalConfigDir() compared two different
// environments -- it agreed only because the child inherited the developer's
// ambient CLAUDE_CONFIG_DIR, which is the leak this test's helper now blocks.
const claudeConfigDir = path.join(tmpDir, 'claude-config');
const codexHome = path.join(tmpDir, 'codex-home');
const result = runGsdTools('capability state --runtime claude --raw', tmpDir, {
HOME: tmpDir,
CLAUDE_CONFIG_DIR: claudeConfigDir,
CODEX_HOME: codexHome,
});
assert.ok(result.success, `capability state --runtime should succeed: ${result.error || ''}`);
const parsed = JSON.parse(result.output);
const runtimeHomes = require('../gsd-core/bin/lib/runtime-homes.cjs');
assert.strictEqual(parsed.runtimeConfigDir, runtimeHomes.getGlobalConfigDir('claude'),
assert.strictEqual(parsed.runtimeConfigDir, claudeConfigDir,
'`capability state --runtime claude` must resolve to the Claude config dir, not the persisted codex dir');
assert.notStrictEqual(parsed.runtimeConfigDir, codexHome,
'must NOT resolve to the codex config dir when --runtime claude is explicit');
} finally {
cleanup(tmpDir);
}

View File

@@ -151,6 +151,12 @@ const TEST_ENV_BASE = {
ZELLIJ_SESSION_NAME: '',
TTY: '',
SSH_TTY: '',
// Config-LOCATION vars. Distinct in kind from the session-identity vars
// above: these decide WHERE a child writes, so leaving them ambient lets a
// test that sandboxes HOME still escape into the developer's real config dir.
CLAUDE_CONFIG_DIR: '',
GSD_RUNTIME: '',
CODEX_HOME: '',
};
function runTools(args, cwd) {

View File

@@ -759,6 +759,12 @@ const TEST_ENV_BASE = {
ZELLIJ_SESSION_NAME: '',
TTY: '',
SSH_TTY: '',
// Config-LOCATION vars. Distinct in kind from the session-identity vars
// above: these decide WHERE a child writes, so leaving them ambient lets a
// test that sandboxes HOME still escape into the developer's real config dir.
CLAUDE_CONFIG_DIR: '',
GSD_RUNTIME: '',
CODEX_HOME: '',
};
/**

View File

@@ -34,6 +34,12 @@ const TEST_ENV_BASE = {
ZELLIJ_SESSION_NAME: '',
TTY: '',
SSH_TTY: '',
// Config-LOCATION vars. Distinct in kind from the session-identity vars
// above: these decide WHERE a child writes, so leaving them ambient lets a
// test that sandboxes HOME still escape into the developer's real config dir.
CLAUDE_CONFIG_DIR: '',
GSD_RUNTIME: '',
CODEX_HOME: '',
};
function runMigrateConfig(cwd, extraArgs = [], env = {}) {

View File

@@ -25,10 +25,9 @@ const TEST_ENV_BASE = {
ZELLIJ_SESSION_NAME: '',
TTY: '',
SSH_TTY: '',
// #2665: blank config-LOCATION vars so npm test never writes into the developer's
// live config directory. The resolver consults these before HOME, so an ambient
// value wins unconditionally over a sandboxed HOME. Per-site overrides still win
// because env is spread last in the child-env merge.
// Config-LOCATION vars. Distinct in kind from the session-identity vars
// above: these decide WHERE a child writes, so leaving them ambient lets a
// test that sandboxes HOME still escape into the developer's real config dir.
CLAUDE_CONFIG_DIR: '',
GSD_RUNTIME: '',
CODEX_HOME: '',

View File

@@ -12,7 +12,13 @@ const { test, describe, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const { runGsdTools, createTempProject, createTempGitProject, cleanup } = require('./helpers.cjs');
const {
runGsdTools,
createTempProject,
createTempGitProject,
cleanup,
withIsolatedProcessState,
} = require('./helpers.cjs');
const {
PROFILING_QUESTIONS,
@@ -184,6 +190,45 @@ describe('write-profile command', () => {
assert.strictEqual(out.profile_path, path.join(codexHome, 'gsd-core', 'USER-PROFILE.md'));
});
test('#2665: ambient CLAUDE_CONFIG_DIR cannot escape a HOME-only sandbox', () => {
// The defect: TEST_ENV_BASE blanked session-identity vars but none of the
// config-LOCATION vars, and the dot-home resolver is env-first. So an
// ambient CLAUDE_CONFIG_DIR in the DEVELOPER'S shell beat `{ HOME: tmpDir }`
// and the suite wrote into their real config directory. Setting it on the
// PARENT process is the actual vector — passing it in the per-call env
// argument would test nothing, because that path was never broken.
const analysis = {
profile_version: '1.0',
dimensions: { communication_style: { rating: 'terse-direct', confidence: 'HIGH' } },
};
const analysisPath = path.join(tmpDir, 'analysis.json');
fs.writeFileSync(analysisPath, JSON.stringify(analysis));
const ambientConfigDir = path.join(tmpDir, 'ambient-live-config');
fs.mkdirSync(ambientConfigDir, { recursive: true });
const out = withIsolatedProcessState(() => {
process.env.CLAUDE_CONFIG_DIR = ambientConfigDir;
const result = runGsdTools(
['write-profile', '--input', analysisPath, '--raw'],
tmpDir,
{ HOME: tmpDir }
);
assert.ok(result.success, `Failed: ${result.error}`);
return JSON.parse(result.output);
});
assert.deepStrictEqual(
fs.readdirSync(ambientConfigDir),
[],
'a call site that sandboxes HOME must not write into an ambient CLAUDE_CONFIG_DIR'
);
assert.ok(
!out.profile_path.startsWith(ambientConfigDir),
`profile must not resolve under the ambient config dir, got: ${out.profile_path}`
);
});
test('errors when --input is missing', () => {
const result = runGsdTools('write-profile --raw', tmpDir);
assert.ok(!result.success, 'should fail without --input');

View File

@@ -68,6 +68,12 @@ const TEST_ENV_BASE = {
ZELLIJ_SESSION_NAME: '',
TTY: '',
SSH_TTY: '',
// Config-LOCATION vars. Distinct in kind from the session-identity vars
// above: these decide WHERE a child writes, so leaving them ambient lets a
// test that sandboxes HOME still escape into the developer's real config dir.
CLAUDE_CONFIG_DIR: '',
GSD_RUNTIME: '',
CODEX_HOME: '',
};
function runTools(args, cwd) {

View File

@@ -1416,6 +1416,12 @@ describe('bug #969 B — runGsdTools kill-signal discrimination', () => {
GSD_SESSION_KEY: '',
CODEX_THREAD_ID: '',
CLAUDE_SESSION_ID: '',
// Config-LOCATION vars. Distinct in kind from the session-identity vars
// above: these decide WHERE a child writes, so leaving them ambient lets a
// test that sandboxes HOME still escape into the developer's real config dir.
CLAUDE_CONFIG_DIR: '',
GSD_RUNTIME: '',
CODEX_HOME: '',
};
try {
let result;