Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
ad5a970
os: in a test run, stateDir() refuses a state dir outside the test ma…
jayden1711 Oct 19, 2026
ee68854
tests: a lint that a test checks each machine location it writes with…
jayden1711 Oct 19, 2026
6d68bd9
Merge origin/main into issue-815-state-dir-refusal
jayden1711 Oct 19, 2026
55343d3
tests: the machine-path lint takes onRealMachine inside an assert, re…
jayden1711 Oct 19, 2026
ebf6dab
os: a symlink inside the test machine refuses the state and slot dir;…
jayden1711 Oct 19, 2026
7e89673
tests: the machine-path lint takes a guard only where it always runs …
jayden1711 Oct 19, 2026
692368f
tests: the machine-path lint takes no guard for a write in a hoisted …
jayden1711 Oct 19, 2026
29e8fc5
os: a state or slot dir written with a '..' part is refused in a test…
jayden1711 Oct 19, 2026
32f74e8
tests: the machine-path lint takes a guard only on what its argument …
jayden1711 Oct 19, 2026
e66f37d
Merge origin/main into issue-815-state-dir-refusal
jayden1711 Oct 19, 2026
9168629
os: the '..' check reads the dir with a RegExp, never a String method
jayden1711 Oct 19, 2026
5a20d35
package-files: raise PACKAGE_BUDGET to this PR's measured size + 2%
jayden1711 Oct 19, 2026
89218df
tests: the machine-path lint names its known gaps, each a fixture mis…
jayden1711 Oct 19, 2026
d916730
Merge origin/main into issue-815-state-dir-refusal
jayden1711 Oct 19, 2026
cb50296
tests: the machine-path lint says it is a backstop, and names the rea…
jayden1711 Oct 19, 2026
a0eb941
Merge origin/main into issue-815-state-dir-refusal
jayden1711 Oct 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .worklane/review.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,10 @@ categories:
- test/experiments-coordinator.test.ts
- test/intake-coordinator.test.ts
- test/domain-runner.test.ts
# Listed here only, not locked in code (#815): the state dir's refusal in a test run, and the lint that a test
# checks each machine location it creates, writes or removes with onRealMachine first.
- test/state-dir-refusal.test.ts
- test/machine-path-writes.test.ts
- .denylist*
- .gitleaks*
- .githooks/**
Expand Down
16 changes: 15 additions & 1 deletion src/os/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { safeProjectEnv } from '../project-env.js';
import { withoutPushGateHatch } from '../guardrails/push-hook.js';
import { readRegularSlotFile, slotFile, slotPathOf, statfsSlotDir, type SlotPath, type SlotPathLike } from '../slot-file.js';
import { devSlotsPath, etcPath, markTestRun, testSlotsRefusal, TestSlotsRefused, varLibPath } from './system-paths.js';
import { testStateRefusal, TestStateRefused } from './system-paths.js';

export type OsKind = 'macos' | 'linux' | 'windows-wsl' | 'windows';
export type OsSetting = 'auto' | 'macos' | 'linux' | 'windows-wsl';
Expand Down Expand Up @@ -343,8 +344,21 @@ export function canRunAgents(os: OsKind): boolean {
return os !== 'windows';
}

/** Machine-local state (event log, fingerprints, findings). Never inside a repo. */
/**
* Machine-local state (event log, fingerprints, findings). Never inside a repo.
*
* INVARIANT (#815): in a test run, only a directory inside the test machine (test/isolate.ts); anything else throws
* TestStateRefused before a file is opened (src/os/system-paths.ts testStateRefusal), so no test reaches the person's
* own state dir through a default it fell through to. Outside a test run, as always.
*/
export function stateDir(): string {
const dir = unguardedStateDir();
const refusal = testStateRefusal(dir);
if (refusal) throw new TestStateRefused(refusal);
return dir;
}

function unguardedStateDir(): string {
const override = process.env[`${BRAND.envPrefix}_STATE_DIR`];
if (override) return override;
if (process.platform === 'darwin') return join(homedir(), 'Library', 'Application Support', BRAND.cli);
Expand Down
100 changes: 93 additions & 7 deletions src/os/system-paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,11 @@ export class TestSlotsRefused extends Error {
override name = 'TestSlotsRefused';
}

/** A state directory a test run may not use (testStateRefusal): thrown by stateDir() before anything is opened. */
export class TestStateRefused extends Error {
override name = 'TestStateRefused';
}

const currentUid = (): number | null => (typeof process.getuid === 'function' ? process.getuid() : null);

/**
Expand Down Expand Up @@ -120,27 +125,108 @@ markTestRun(process.env);
* opens anything in the machine's own slot directory (#669): slotsDir() throws TestSlotsRefused.
*
* THREAT: accidents, not a hostile test: a test that builds a machine of its own (a directory, a root and a mark with
* a token it chose) on purpose gets it. `dir` is compared as written (symlinks not followed): it may not exist yet.
* a token it chose) on purpose gets it. `dir` is compared as written: it may not exist yet. Each part of it that
* exists inside the machine is lstat'ed, never followed, and a symlink among them is refused (#818), so no link in the
* machine leads a test to a directory outside it.
*/
export function testSlotsRefusal(dir: string | undefined, env: NodeJS.ProcessEnv = process.env, proc: Proc = process, uid: number | null = currentUid()): string | null {
return outsideTestMachine({ what: 'slot directory', unset: "AGENT_SLOTS_DIR is unset, so it would be the machine's own", fix: "pass a directory of the test's own" }, dir, env, proc, uid);
}

/**
* Why `dir` may not be this process's state directory, or null when it may: the same rule as the slot directory's
* (testSlotsRefusal), so stateDir() throws TestStateRefused in a test run for anything outside the test machine
* (#815): <PREFIX>_STATE_DIR pointed elsewhere, or unset with the home's (XDG_STATE_HOME, or HOME's .local/state)
* outside it, as in a test file run without the preload, or a test's child that inherited a real HOME. Always null
* outside a test run: the engine's state dir is what it always was. Nothing outside the test machine is looked at:
* the only paths lstat'ed or read are the machine's own (its directory, its system root and mark, the parts of `dir`
* inside it).
*
* EXCEPTION (owner-accepted, 2026-10-19, #818): a service mode a test starts (`dashboard --hub`, `--hub-setup`) runs
* without the system root (serviceModeRefusal; test/isolate.ts serviceModeEnv), so it can't show its test machine
* through the root's mark. Then, and only for the state dir, the machine shows it with its own mark: the same token
* in TEST_MARK_FILE in the machine directory itself, a file of this user's alone (test/isolate.ts writes both). As
* with the root's mark, never as root or in a process systemd started. Without either mark: refused.
*/
export function testStateRefusal(dir: string, env: NodeJS.ProcessEnv = process.env, proc: Proc = process, uid: number | null = currentUid()): string | null {
return outsideTestMachine({ what: 'state directory', unset: `${BRAND.envPrefix}_STATE_DIR is unset`, fix: `set ${BRAND.envPrefix}_STATE_DIR to a directory of the test's own`, rootless: true }, dir, env, proc, uid);
}

/**
* Why `machine` doesn't carry this run's mark itself (TEST_MARK_FILE in it, of this user's alone, holding the token in
* TEST_TOKEN_VAR), or null when it does.
*/
function machineMarkRefusal(machine: string, env: NodeJS.ProcessEnv, uid: number | null): string | null {
const token = env[TEST_TOKEN_VAR];
if (!token || !/^[0-9a-f]{64}$/.test(token)) return `${SYSTEM_ROOT_VAR} is unset, and ${TEST_TOKEN_VAR} doesn't carry the test preload's token`;
const mark = join(machine, TEST_MARK_FILE);
try {
const f = lstatSync(mark);
if (!f.isFile() || (process.platform !== 'win32' && ((uid !== null && f.uid !== uid) || (f.mode & 0o077) !== 0))) return `${SYSTEM_ROOT_VAR} is unset, and its own test mark isn't this user's alone`;
if (readFileSync(mark, 'utf8').trim() !== token) return `${SYSTEM_ROOT_VAR} is unset, and ${TEST_TOKEN_VAR} doesn't match its own test mark`;
} catch {
return `${SYSTEM_ROOT_VAR} is unset, and it holds no test mark of its own`;
}
return null;
}

/** testSlotsRefusal and testStateRefusal: in a test run, a directory the engine resolved must be inside the test machine. */
function outsideTestMachine(o: { what: string; unset: string; fix: string; rootless?: boolean }, dir: string | undefined, env: NodeJS.ProcessEnv, proc: Proc, uid: number | null): string | null {
const sign = testRunSign(env, proc);
if (!sign) return null;
const machine = env[TEST_MACHINE_VAR];
const no = (why: string) => `this is a test run (${sign}), and the slot directory isn't the test machine's: ${why}. Run tests with the isolation preloaded (node --import ./dist/test/isolate.js --test <file>, or npm test), or pass a directory of the test's own.`;
const no = (why: string) => `this is a test run (${sign}), and the ${o.what} isn't the test machine's: ${why}. Run tests with the isolation preloaded (node --import ./dist/test/isolate.js --test <file>, or npm test), or ${o.fix}.`;
if (!machine) return no(`${TEST_MACHINE_VAR} is unset, so the test isolation isn't loaded`);
if (!isAbsolute(machine)) return no(`${TEST_MACHINE_VAR} (${machine}) is not an absolute path`);
const root = env[SYSTEM_ROOT_VAR];
// Before anything is looked at: the machine's own mark is never honoured as root, nor in a process systemd started,
// as the root's mark isn't (systemRootRefusal). Native Windows has no uid: there, as on the root's route, no root check.
if (!root && o.rootless) {
const sd = SYSTEMD_MARKERS.find((k) => env[k] !== undefined);
if (uid === 0) return no(`${TEST_MACHINE_VAR} (${machine}) isn't this run's test machine: ${SYSTEM_ROOT_VAR} is unset, and this process runs as root`);
if (sd) return no(`${TEST_MACHINE_VAR} (${machine}) isn't this run's test machine: ${SYSTEM_ROOT_VAR} is unset, and systemd started this process (${sd} is set)`);
}
try {
const d = lstatSync(machine);
if (!d.isDirectory() || (process.platform !== 'win32' && ((uid !== null && d.uid !== uid) || (d.mode & 0o022) !== 0))) return no(`${TEST_MACHINE_VAR} (${machine}) isn't a directory of this user's alone`);
} catch {
return no(`${TEST_MACHINE_VAR} (${machine}) doesn't exist`);
}
const root = env[SYSTEM_ROOT_VAR];
if (!root || !inside(machine, root)) return no(`${TEST_MACHINE_VAR} (${machine}) doesn't hold the test preload's system root (${SYSTEM_ROOT_VAR})`);
const unmarked = systemRootRefusal(env, uid);
if (unmarked) return no(`${TEST_MACHINE_VAR} (${machine}) isn't this run's test machine: ${unmarked}`);
if (!dir) return no('AGENT_SLOTS_DIR is unset, so it would be the machine\'s own');
if (!root && o.rootless) {
const unmarked = machineMarkRefusal(machine, env, uid);
if (unmarked) return no(`${TEST_MACHINE_VAR} (${machine}) isn't this run's test machine: ${unmarked}`);
} else {
if (!root || !inside(machine, root)) return no(`${TEST_MACHINE_VAR} (${machine}) doesn't hold the test preload's system root (${SYSTEM_ROOT_VAR})`);
const unmarked = systemRootRefusal(env, uid);
if (unmarked) return no(`${TEST_MACHINE_VAR} (${machine}) isn't this run's test machine: ${unmarked}`);
}
if (!dir) return no(o.unset);
// A `..` after a link goes up from where the link points, not where it is written; resolve() would hide that.
// A RegExp, not dir.split(): no String or Array method is ever handed the slot dir (test/slot-path.test.ts, #806 A5).
if (/(^|[\\/])\.\.([\\/]|$)/.test(dir)) return no(`${dir} has a '..' part: written with one, a link before it could lead out of the machine`);
if (!inside(machine, dir)) return no(`${dir} is outside ${machine}`);
const link = linkInside(machine, dir);
if (link) return no(link);
return null;
}

/**
* Why a path inside `machine` may lead out of it, or null: a symlink at any part of it from the machine down, or a
* part that can't be looked at. Each part is lstat'ed (never followed), only inside the machine, stopping at the
* first one that doesn't exist yet (#818: fail closed on a link to the person's own state).
*/
function linkInside(machine: string, dir: string): string | null {
let p = machine;
for (const part of relative(machine, resolve(dir)).split(sep)) {
p = join(p, part);
try {
if (lstatSync(p).isSymbolicLink()) return `${p} is a symlink: no path inside the test machine may lead out of it`;
} catch (e) {
const code = (e as NodeJS.ErrnoException).code;
if (code === 'ENOENT' || code === 'ENOTDIR') return null;
return `${p} can't be looked at (${code ?? 'error'})`;
}
}
return null;
}

Expand Down
2 changes: 2 additions & 0 deletions test/init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { checksumOf, installPlan, PINS, type InstallableTool, type ToolInstall }
import { launchdUnload, shareReadWithGroup, toolPlatform, which } from '../src/os/index.js';
import { srvCheckoutPath } from '../src/os/system-paths.js';
import { asPerson, engineCli, exampleProject, exampleRoles, repoRoot } from './helpers.js';
import { onRealMachine } from './isolate.js';

const posix = process.platform === 'win32' && 'POSIX tools (tar, sh)';
process.env[`${BRAND.envPrefix}_STATE_DIR`] = mkdtempSync(join(tmpdir(), 'init-state-'));
Expand Down Expand Up @@ -325,6 +326,7 @@ test('init (#469), server mode: the same refusal, judged on the checkout add-pro
const ok = () => world({ tools: new Set(['git', 'claude', 'gitleaks']), addProject: async () => ({ code: 0, steps: all, details: {}, last: '' }) });
const { dir: upstream } = exampleProject();
const checkout = srvCheckoutPath('shopz', 'shop');
onRealMachine(checkout); // throws unless it is inside this test's system root: never the machine's own /srv (#815)
mkdirSync(dirname(checkout), { recursive: true });
execFileSync('git', ['clone', '-q', upstream, checkout]);
const first = ok();
Expand Down
9 changes: 6 additions & 3 deletions test/isolate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,9 @@ const systemRoot = isolatedEnv[SYSTEM_ROOT_VAR]!;
chmodSync(systemRoot, 0o700);
const token = randomBytes(32).toString('hex');
writeFileSync(join(systemRoot, TEST_MARK_FILE), `${token}\n`, { mode: 0o600 });
// The same mark in the machine itself: a service mode a test starts has no system root (serviceModeEnv), and shows
// its test machine to the state dir's refusal with this one (src/os/system-paths.ts testStateRefusal, #815).
writeFileSync(join(testMachine, TEST_MARK_FILE), `${token}\n`, { mode: 0o600 });
process.env[TEST_TOKEN_VAR] = token;

/**
Expand Down Expand Up @@ -122,13 +125,13 @@ process.on('exit', () => {

/**
* An environment for a service mode a test starts (`dashboard --hub`, `--hub-setup --service`): those never run
* with the system root (src/os/system-paths.ts serviceModeRefusal), so it and the test mark are left out. Only for a
* mode that reads no machine location.
* with the system root (src/os/system-paths.ts serviceModeRefusal), so it is left out. Only for a mode that reads no
* machine location. The token stays: with the machine's own mark it shows the state dir's refusal this is still the
* test machine (testStateRefusal, #815); without the root it moves nothing (systemRootRefusal reads it only with one).
*/
export function serviceModeEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
const out = { ...env };
delete out[SYSTEM_ROOT_VAR];
delete out[TEST_TOKEN_VAR];
return out;
}

Expand Down
Loading
Loading