* test(#3738): antigravity global skills/agents must resolve under ~/.gemini/config Regression tests (RED first): --skills-root and gsd-tools query surfaces, install-plan dest dirs, and converter skills-path rewrite. * fix(#3738): antigravity global skills/agents install to ~/.gemini/config Antigravity's machine-local discovery scans ~/.gemini/config/{skills,agents}; the configHome (~/.gemini/antigravity) is deprecated for artifacts. Declare the ADR-1239 skills/agents 'home' override on the antigravity global layout — the same mechanism codex uses (.agents) — and divert ~/.claude/skills/ references in converted global content to ~/.gemini/config/skills/. configHome, settings, probe/migration semantics, and the local .agents layout are unchanged. * fix(#3738): retire deprecated configHome artifacts via installer migration 010 Next install converges an existing antigravity install: manifest-managed skills/gsd-*/ and agents/gsd-*.md under the configHome (a location AGY does not scan) are removed — modified files backed up first, unmanifested and non-gsd entries preserved — and now-empty containers retired. Global scope only; the local .agents surface is live. Docs + inventory updated. * fix(#3738): converter sync in bin/install.js, harness emit-root coverage, migration baseline - bin/install.js converter gains the same ~/.claude/skills → ~/.gemini/config/ rewrite as src (ADR-1508 dual copy must stay in sync). - Parity-manifest walk covers home-override emit roots (extraEmitRootsFor) so antigravity's emitted skills/agents stay differential-visible at their new install root; install-tree fixture regen confirms an unchanged key set. - skills-from-commands rule declares the antigravity converter as a runtime-scoped transform; one ack fragment covers the identity-classed workflow whose antigravity copy embeds the old skills path. - Migration 010 checksum baseline + home-override set doc updated; existing tests updated to the #3738 contract (global dest, golden parity via layout dest, integration expectations). * fix(#3738): tolerate an absent extra emit root on baseline-side measurement The base tree's installer predates the home override, so <HOME>/.gemini/config does not exist there; walk() threw ENOENT and the in-job baseline build failed. An absent extra root is the legitimate pre-override shape — skip it. * fix(#3738): review findings — manifest agents root, bare skills-path rewrite, guard comment - writeManifest resolves the agents-kind home override (_kindDestDirSafe), so the manifest records agents at their actual install root and drift detection keeps working (isolated review finding 1, major). - Converter bare forms ~/.claude/skills and $HOME/.claude/skills (no trailing slash) divert to ~/.gemini/config/skills instead of falling through to the retired configHome path (finding 2). - real-home-guard comment updated: antigravity's global agents kind is the first agents-kind home override (finding 3, doc-only). - Regression tests for both behavioral findings. * chore(#3738): changeset fragment (pr number backfilled after PR creation) * chore(#3738): backfill changeset PR number (3921) * fix(#3738): sandbox HOME in tests that install antigravity global artifacts antigravity is the first home-override runtime in the golden-parity and skills-wrapper suites (codex is not in their runtime lists), so those tests never needed HOME sandboxing — the real-home guard now (correctly) refuses their un-sandboxed global installs on CI, where HOME is the passwd home. * fix(#3738): stop the K3 sequential-sandbox env leak; sandbox L2's home-override plans K3's two back-to-back sandboxHome calls leave HOME pointing at the first sandbox once the after-hooks restore (each call saves the env as it found it, so the second saves the first's sandbox as 'original'). On the windows matrix that leaked gsd-k3-qwen-* home into the L2 property, whose antigravity/global run then (correctly) refused via the #3712 real-home guard — antigravity is the runtime that made L2's plan escape into os.homedir(). K3 now manages the env with a single restore; L2 sandboxes HOME per run, mirroring L1. * fix(#3738): L2 property's HOME sandbox must exist on disk The #3712 guard's sandbox exemption fails closed when identify(effectiveHome) is 'absent' — L2 never created its configDir, so on the windows matrix (tmpdir under the real home) the antigravity/global run refused even with HOME sandboxed. Create the per-run sandbox dir and clean it up. --------- Co-authored-by: sim <sim@local>
450 lines
23 KiB
TypeScript
450 lines
23 KiB
TypeScript
'use strict';
|
|
|
|
/**
|
|
* #3712 — refuse to let an in-process test run reach the developer's REAL home.
|
|
*
|
|
* NAMED `real-home-guard`, NOT `test-home-guard`: this is a SOURCE module, and a
|
|
* source filename matching Node's `test-*` convention gets COLLECTED and EXECUTED
|
|
* as a test by the remote runner, which never imports it and throws immediately
|
|
* on load — proven against the unmodified `next` tip 622f43353 (linux-node24,
|
|
* 37199 passed / 1 failed, failure at `src/test-home-guard.cts`). Meanwhile
|
|
* `scripts/run-tests.cjs` only globs `tests/**\/*.test.cjs`, so GitHub CI never
|
|
* sees the failure and stays green while the remote push-gate runner goes red.
|
|
* Any name works except one matching `test-*`, `*-test`, `*_test`, `*.test.*`,
|
|
* or `test.*`.
|
|
*
|
|
* A runtime kind may declare a global `home` override that is resolved from
|
|
* `os.homedir()` rather than from the caller's `configDir` (today: codex's
|
|
* skills kind, `home: ".agents"`, ADR-1239 / #2088 — Codex auto-discovers
|
|
* global skills from `$HOME/.agents/skills`). Sandboxing `configDir`/`targetDir`
|
|
* therefore does NOT contain such a kind, and no `assertDestWithinConfigHome`
|
|
* check can see it: that gate confines a destSubpath to whatever root it is
|
|
* handed, and here the root IS the escaped home.
|
|
*
|
|
* The live defect: an in-process caller that forgot to sandbox HOME installed —
|
|
* and, via `_removeGsdEntries` / `_syncGsdDir`, PRUNED — inside the real
|
|
* `~/.agents/skills`, deleting every `gsd-*` skill (observed 71 -> 0, a foreign
|
|
* `cloudflare` skill surviving) while the suite still exited 0. It is silent
|
|
* because the runtime's own config home is untouched, so the manifest keeps
|
|
* reporting a healthy install.
|
|
*
|
|
* This lives in its own module because SIX writers resolve a kind `home` and then
|
|
* write or destroy what is under it. Four are reachable today —
|
|
* `installRuntimeArtifacts` and `uninstallRuntimeArtifacts` (install-engine.cts)
|
|
* and `applySurface` (surface.cts); guarding only the install path, as the first
|
|
* cut of this fix did, left the other two as live bypasses. Two more are
|
|
* descriptor-dependent and guarded against a future descriptor change rather than
|
|
* a present escape: `installOpencodeFamilySkills`, which sits behind the
|
|
* combined-family early return and honors `skillsKindEntry.home` (no
|
|
* combined-family runtime declares one), and `installAgentsKindStandalone`, which
|
|
* honors `agentsKindEntry.home` and prunes it (antigravity's global agents kind declares one since #3738; it was the first agents-kind override).
|
|
* The sixth is `migrateLegacyDevPreferencesToSkill`, which resolves the skills
|
|
* kind's `home` and writes `SKILL.md` under it. It CREATES rather than prunes,
|
|
* which is why the first pass of this fix missed it, and it runs from
|
|
* `_runLegacyInstallMigrations` — i.e. BEFORE `installRuntimeArtifacts`' own
|
|
* assertion — so it carries its own call.
|
|
*
|
|
* DETECTION — three signals, in order:
|
|
*
|
|
* 1. `NODE_TEST_CONTEXT` is set by `node --test` and inherited by children, and
|
|
* is absent from normal installs outside a Node test context, so ordinary
|
|
* production installs are untouched. Precisely: an install spawned BENEATH a
|
|
* `node --test` process with an un-sandboxed HOME is refused — that is the
|
|
* point, and it is also why this is not stated as "never affects installs".
|
|
* `GSD_TEST_MODE` is NOT usable: several in-process test files — including
|
|
* the one that caused #3712 — never set it, so gating on it would miss the
|
|
* exact case this guard exists for.
|
|
* 2. `os.userInfo().homedir` reads the passwd entry and ignores `$HOME`, while
|
|
* `os.homedir()` prefers `$HOME`. They disagree exactly when a caller
|
|
* redirected HOME and agree when one forgot. This is the PRIMARY signal and
|
|
* the only one used whenever a passwd entry is readable. It is asked about
|
|
* the DESTINATION, not about HOME state: a destination outside the passwd
|
|
* home is allowed, and one inside it is refused UNLESS it sits beneath a
|
|
* HOME that was sandboxed away from the passwd home. That last exemption is
|
|
* not a softening — on Windows the temp root is inside the user's home, so
|
|
* without it every sandboxed run is refused (#3725). Both halves are
|
|
* required; see `derivesFromSandboxedHome`.
|
|
* 3. `SANDBOX_MARKER` — consulted ONLY when the passwd entry cannot be read.
|
|
*
|
|
* FAILS CLOSED, with one named exception. If the passwd entry is unreadable
|
|
* (some CI images) we cannot establish that HOME is sandboxed, so a home-override
|
|
* kind is refused rather than allowed — UNLESS the marker below names the home in
|
|
* effect. That branch is a deliberate weakening, not a closed door: with no passwd
|
|
* entry there is nothing that could contradict a marker naming the real home, so a
|
|
* caller that sandboxed to its own real home would be believed. The alternative is
|
|
* refusing every run on such a host. Everything else in that branch answers NO
|
|
* when it cannot tell — see `sameDirectory`. The cost of a false refusal is a failed test naming its own fix;
|
|
* the cost of a false allow is silent, unrecoverable deletion of a developer's
|
|
* skills.
|
|
*
|
|
* The marker carries the sandbox PATH, not a boolean, and is checked only in that
|
|
* unreadable-passwd branch — deliberately, because a boolean checked first is not
|
|
* proof of anything. An ambient or stale `=1` inherited from a parent process, or
|
|
* left set by an earlier in-process test, would have disarmed the guard entirely
|
|
* even when HOME plainly equalled the passwd home. Requiring the value to EQUAL
|
|
* the home now in effect means a leftover marker from some other directory cannot
|
|
* vouch for this call.
|
|
*/
|
|
|
|
import fs from 'node:fs';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
|
|
/**
|
|
* Set by tests/helpers.cjs `sandboxHome()` to the sandboxed home PATH. Only
|
|
* consulted when the passwd entry is unreadable; see the module docblock.
|
|
*/
|
|
const SANDBOX_MARKER = 'GSD_TEST_HOME_SANDBOX';
|
|
|
|
/**
|
|
* Are these two paths the same directory on disk?
|
|
*
|
|
* Compared by FILESYSTEM IDENTITY — `st_dev` + `st_ino` — not by pathname.
|
|
* `path.resolve()` normalizes separators and `..` but resolves neither symlinks
|
|
* nor case, and `realpathSync` returns a canonical *pathname*, which two routes to
|
|
* one directory can still disagree on (a Linux bind mount is the standard case).
|
|
* `statSync` follows symlinks and reports the inode itself, so a case-variant
|
|
* HOME, a symlinked HOME, and a bind-mounted HOME all compare equal.
|
|
*
|
|
* Fails CLOSED, and "closed" here means returning FALSE. The sole caller is the
|
|
* passwd-less marker branch, which READS a true as permission to proceed:
|
|
*
|
|
* if (marker && sameDirectory(marker, osMod.homedir())) return; // allows
|
|
*
|
|
* So "cannot tell" must answer NO. An earlier revision returned true for the
|
|
* unknown/unknown case (both stats failing with something other than
|
|
* ENOENT/ENOTDIR — EACCES, EPERM, EIO) on the reasoning that "cannot tell" should
|
|
* make the caller refuse; that reasoning was inverted with respect to this
|
|
* caller, and it turned an unreadable-stat environment into an unconditional
|
|
* bypass for anyone who set the marker to any value at all. Reported in review of
|
|
* #3725. Only two things now answer yes: the same resolved pathname, or two
|
|
* readable identities that match.
|
|
*/
|
|
function sameDirectory(a: string, b: string): boolean {
|
|
if (path.resolve(a) === path.resolve(b)) return true;
|
|
const ia = identify(a);
|
|
const ib = identify(b);
|
|
if (ia.kind === 'ok' && ib.kind === 'ok') return ia.dev === ib.dev && ia.ino === ib.ino;
|
|
return false;
|
|
}
|
|
|
|
type Identity =
|
|
| { kind: 'ok'; dev: number; ino: number }
|
|
| { kind: 'absent' }
|
|
| { kind: 'unknown' };
|
|
|
|
function identify(p: string): Identity {
|
|
try {
|
|
const st = fs.statSync(p);
|
|
return { kind: 'ok', dev: st.dev, ino: st.ino };
|
|
} catch (err) {
|
|
const code = (err as NodeJS.ErrnoException | undefined)?.code;
|
|
if (code === 'ENOENT' || code === 'ENOTDIR') return { kind: 'absent' };
|
|
return { kind: 'unknown' };
|
|
}
|
|
}
|
|
|
|
type Kind = { kind?: string; home?: string; destSubpath?: string };
|
|
/** The slice of `node:os` this guard needs — injected so the trigger condition is testable. */
|
|
type OsLike = { homedir(): string; userInfo(): { homedir: string } };
|
|
type Deps = { os?: OsLike; env?: Record<string, string | undefined> };
|
|
|
|
/**
|
|
* @param operation - the writer being guarded, for the error message
|
|
* (e.g. `installRuntimeArtifacts`), so a failure names its own call site.
|
|
* @param runtime - canonical runtime id, for the error message.
|
|
* @param kinds - resolved layout kinds; only those carrying `home` can escape.
|
|
* @param deps - test seam. The guard's own trigger condition is "HOME equals the
|
|
* passwd home", which cannot be reproduced without pointing at the developer's
|
|
* real home, so it is injected rather than simulated. Mirrors the `deps.os`
|
|
* seam in scripts/live-config-guard.cjs.
|
|
* @throws {Error} when a global `home` override cannot be shown to be sandboxed.
|
|
*/
|
|
/**
|
|
* Stamped on every Error this module throws.
|
|
*
|
|
* A refusal happens before any LAYOUT-DRIVEN write — legacy install migrations
|
|
* run first and are rolled back on their own path — so it is not a partial
|
|
* codex-skills install and must not trigger that rollback. `bin/install.js`'s
|
|
* pre-config rollback
|
|
* deletes and recreates every snapshotted `gsd-*` directory in the resolved
|
|
* skills root, which for an un-sandboxed codex install IS the real
|
|
* `~/.agents/skills`. Without this marker the guard's own refusal would provoke
|
|
* the mutation it exists to prevent. Found by review, not by CI.
|
|
*/
|
|
const REFUSAL_FLAG = 'gsdTestHomeGuardRefusal';
|
|
|
|
/**
|
|
* Static remediation line, shared by every refusal this module raises. Hoisted to
|
|
* module scope because `resolveThroughLinks` refuses too and sits outside
|
|
* `assertTestHomeSandboxed`'s body, where this used to be a local.
|
|
*/
|
|
const SANDBOX_FIX_HINT =
|
|
`Fix the TEST, not this guard: sandbox HOME and USERPROFILE BEFORE resolving the ` +
|
|
`layout, for the duration of the call — use sandboxHome(t, dir) from tests/helpers.cjs ` +
|
|
`(see #3712).`;
|
|
|
|
function refusal(message: string): Error {
|
|
const err = new Error(message);
|
|
(err as Error & Record<string, unknown>)[REFUSAL_FLAG] = true;
|
|
return err;
|
|
}
|
|
|
|
/** Did `err` come from this guard refusing before any write happened? */
|
|
function isTestHomeGuardRefusal(err: unknown): boolean {
|
|
return Boolean(err && typeof err === 'object' && (err as Record<string, unknown>)[REFUSAL_FLAG]);
|
|
}
|
|
|
|
function assertTestHomeSandboxed(
|
|
operation: string,
|
|
runtime: string,
|
|
kinds: Kind[] | undefined,
|
|
deps: Deps = {},
|
|
): void {
|
|
const osMod = deps.os ?? os;
|
|
const env = deps.env ?? process.env;
|
|
|
|
if (!env['NODE_TEST_CONTEXT']) return; // a real install
|
|
|
|
const overriding = (kinds ?? []).filter((k) => k && k.home);
|
|
if (overriding.length === 0) return; // nothing can escape
|
|
|
|
let passwdHome: string | null = null;
|
|
try {
|
|
passwdHome = osMod.userInfo().homedir || null;
|
|
} catch {
|
|
passwdHome = null;
|
|
}
|
|
|
|
const fix = SANDBOX_FIX_HINT;
|
|
|
|
const realHome = passwdHome === null ? { kind: 'unknown' as const } : identify(passwdHome);
|
|
if (realHome.kind === 'ok') {
|
|
let effectiveHome: string | null = null;
|
|
try {
|
|
effectiveHome = osMod.homedir() || null;
|
|
} catch {
|
|
effectiveHome = null;
|
|
}
|
|
|
|
// The question is NOT "is HOME sandboxed right now" — it is "does this
|
|
// destination land in the real home, other than by deriving from a sandbox
|
|
// beneath it". The first half alone is not enough: a layout resolved BEFORE
|
|
// sandboxHome() captures the real `~/.agents` in `kind.home`, and applySurface
|
|
// takes an already-resolved layout, so a HOME-state check returns "sandboxed"
|
|
// while the stale destination still points at the real home.
|
|
//
|
|
// The second half is not optional either — see `derivesFromSandboxedHome`.
|
|
// Containment in the real home is not by itself evidence of danger on a
|
|
// platform whose temp root lives inside the home.
|
|
for (const kind of overriding) {
|
|
const declared = path.resolve(path.join(kind.home as string, kind.destSubpath ?? ''));
|
|
// Both questions are asked of the path the write will REACH, not the one
|
|
// it was spelled as; see resolveThroughLinks.
|
|
const dest = resolveThroughLinks(declared);
|
|
if (!isInside(dest, realHome)) continue;
|
|
if (derivesFromSandboxedHome(dest, effectiveHome, realHome, passwdHome as string)) continue;
|
|
throw refusal(
|
|
`${operation}("${runtime}") was called under a test runner with a destination inside ` +
|
|
`your REAL home. The "${kind.kind}" kind declares a global home override, so it ` +
|
|
`resolves from os.homedir() and NOT from the sandboxed configDir — this call would ` +
|
|
`write inside ${dest} (and install, uninstall and surface-apply also PRUNE GSD ` +
|
|
`entries there).\nThe real home it was ` +
|
|
`compared against is ${passwdHome} — if that is not your home, this ` +
|
|
`refusal is the bug and not the call.\n${fix}`,
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// The real home cannot be identified, so containment against it cannot be
|
|
// evaluated at all. Fall back to the weaker signal: a marker naming the home
|
|
// currently in effect. Only reachable on hosts with no readable passwd entry,
|
|
// where the alternative is refusing every such run.
|
|
//
|
|
// TWO things are required, not one. An earlier revision returned as soon as the
|
|
// marker matched the effective HOME, which attested that a caller sandboxed HOME
|
|
// but said NOTHING about where these destinations resolve — so a layout captured
|
|
// BEFORE sandboxHome(), still naming the real `~/.agents`, was waved straight
|
|
// through on a passwd-less host. That is the same stale-layout shape the primary
|
|
// branch above refuses by design, and it made the marker a bypass for exactly
|
|
// the case the guard exists for. Reported in Codex review of #3725.
|
|
//
|
|
// The marker must also IDENTIFY: `sameDirectory` answers yes for two identical
|
|
// unidentifiable pathnames (see its docblock), and that is not enough to place a
|
|
// destination against.
|
|
const marker = env[SANDBOX_MARKER];
|
|
const markerId = marker ? identify(marker) : { kind: 'unknown' as const };
|
|
if (marker && markerId.kind === 'ok' && sameDirectory(marker, osMod.homedir())) {
|
|
const stale = overriding.find(
|
|
(kind) => !isInside(resolveThroughLinks(path.resolve(path.join(kind.home as string, kind.destSubpath ?? ''))), markerId),
|
|
);
|
|
if (!stale) return;
|
|
throw refusal(
|
|
`${operation}("${runtime}") was called under a test runner on a host with no identifiable ` +
|
|
`passwd home. HOME was sandboxed and recorded, but the "${stale.kind}" kind resolves to ` +
|
|
`${resolveThroughLinks(path.resolve(path.join(stale.home as string, stale.destSubpath ?? '')))}, ` +
|
|
`which is NOT beneath that sandbox — so this layout was resolved before the sandbox and still ` +
|
|
`names another home. A recorded sandbox vouches for HOME, never for a destination that does ` +
|
|
`not derive from it.\n${fix}`,
|
|
);
|
|
}
|
|
throw refusal(
|
|
`${operation}("${runtime}") was called under a test runner and this environment has no ` +
|
|
`identifiable passwd home, so GSD cannot establish where the real home is. The ` +
|
|
`"${overriding[0]?.kind}" kind declares a global home override, which resolves from ` +
|
|
`os.homedir() and would write inside whatever real home that is (and, for the pruning ` +
|
|
`writers, delete GSD entries there). Refusing rather than guessing.\n${fix}`,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* The path `dest` will actually resolve to when it is written.
|
|
*
|
|
* `dest` usually does not exist yet — that is the point, it is about to be
|
|
* created — so `realpathSync` cannot be called on it directly. Walk up to the
|
|
* nearest ancestor that DOES exist, canonicalize that, and re-append the tail.
|
|
*
|
|
* Without this the sandbox exemption below is escapable: with HOME sandboxed to
|
|
* `$REAL/tmp-home` and `$REAL/tmp-home/.agents` a symlink (or Windows junction)
|
|
* onto the real `$REAL/.agents`, the lexical ancestor
|
|
* walk reaches the sandbox and the write is allowed — straight into the real
|
|
* home. Identity comparison alone does not close it, because each ancestor is
|
|
* identified in isolation and the alias sits BETWEEN dest and the sandbox.
|
|
* Found by review, not by CI.
|
|
*
|
|
* KNOWN LIMITATION — a subordinate BIND MOUNT of the real `~/.agents` at
|
|
* `<sandbox>/.agents` is NOT closed by this. A bind mount is not a link:
|
|
* `realpathSync` keeps the mount-point spelling, so the canonical destination
|
|
* still reads as "beneath the sandbox" while the write reaches the real
|
|
* directory. `sameDirectory` above already records that realpath cannot unify
|
|
* bind-mounted spellings; this inherits that limit. Closing it needs mount-table
|
|
* introspection, which is not portable, and the setup — deliberately mounting the
|
|
* one directory this guard protects into a sandbox inside the home — is not a
|
|
* shape any caller here produces. A cross-process swap of a checked directory
|
|
* between this call and the write (TOCTOU) is likewise out of reach and not
|
|
* claimed.
|
|
*
|
|
* Canonicalization itself fails CLOSED — see the errno split below. A component
|
|
* that EXISTS but cannot be resolved is refused, because falling back to the
|
|
* lexical spelling is the exact ALLOW an unresolvable alias needs.
|
|
*/
|
|
function resolveThroughLinks(dest: string): string {
|
|
const tail: string[] = [];
|
|
let cur = path.resolve(dest);
|
|
for (;;) {
|
|
try {
|
|
return path.join(fs.realpathSync(cur), ...tail);
|
|
} catch (err) {
|
|
// Same errno split `identify` already draws, and deliberately the same one:
|
|
// both functions are asking "does this path exist as named?", so they must
|
|
// not disagree. ENOENT/ENOTDIR mean NOT THERE — the expected case, since a
|
|
// fresh install resolves a destination that does not exist yet and realpath
|
|
// fails on the leaf and on every not-yet-created ancestor. That is what this
|
|
// walk is FOR, so keep walking.
|
|
//
|
|
// Any other errno means the component EXISTS but could not be canonicalized —
|
|
// EACCES/EPERM on a directory whose mode changed, ELOOP on a symlink cycle,
|
|
// EIO on a failing mount. Falling through to the lexical spelling there is a
|
|
// FAIL-OPEN that precisely inverts this function's purpose: an aliased
|
|
// `<sandbox>/.agents` that cannot be resolved keeps its sandbox spelling,
|
|
// satisfies the nested-sandbox exemption, and the write is ALLOWED straight
|
|
// into the real home. The module documents that it fails CLOSED with exactly
|
|
// ONE named exception (the marker branch); a swallowed canonicalization error
|
|
// was a second, unnamed one. Refuse instead. (Codex review of #3725.)
|
|
const code = (err as NodeJS.ErrnoException | null)?.code;
|
|
if (code !== 'ENOENT' && code !== 'ENOTDIR') {
|
|
throw refusal(
|
|
`A component of the destination could not be canonicalized, so this guard cannot ` +
|
|
`tell whether the write would reach your REAL home.\nFailed on: ${cur} ` +
|
|
`(${code ?? 'unknown error'})\nSymlinks and junctions are resolved BEFORE the ` +
|
|
`decision, because an aliased "<sandbox>/.agents" would otherwise read as confined ` +
|
|
`while pointing at the real one. A component that cannot be resolved is refused ` +
|
|
`rather than assumed safe.\n${SANDBOX_FIX_HINT}`,
|
|
);
|
|
}
|
|
const parent = path.dirname(cur);
|
|
if (parent === cur) return path.resolve(dest);
|
|
tail.unshift(path.basename(cur));
|
|
cur = parent;
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Is `dest` inside a HOME that has been sandboxed away from the passwd home?
|
|
*
|
|
* Asked only once `dest` is already known to be inside the real home, and it is
|
|
* what makes that fact non-fatal on Windows: there `os.tmpdir()` is
|
|
* `%USERPROFILE%\AppData\Local\Temp` by default (Node honors `TEMP`/`TMP`,
|
|
* so this is the usual case rather than a guarantee) — so in practice every
|
|
* sandbox a test creates is a descendant of the real home. Containment in the
|
|
* real home is therefore true of the correctly-sandboxed case and the dangerous
|
|
* one alike, and cannot separate them by itself. POSIX conceals this, because
|
|
* `/tmp` and `/var/folders` both sit outside `$HOME`. All six Windows shards of
|
|
* #3725 failed on legitimately sandboxed destinations before this conjunct
|
|
* existed.
|
|
*
|
|
* TWO conditions carry the decision, and neither suffices alone:
|
|
*
|
|
* - the passwd home is NOT beneath that HOME — see the comment on that check;
|
|
* without it a HOME that merely spells the real home more widely (`/Users`,
|
|
* `C:\Users`) is mistaken for a sandbox.
|
|
* - `dest` is beneath that sandboxed HOME — otherwise this decays into the
|
|
* "is HOME sandboxed?" check the module docblock rejects, and a layout
|
|
* resolved before the sandbox walks straight through.
|
|
*
|
|
* The first check below — HOME differs from the passwd home — is a cheap fast
|
|
* path, NOT an independent condition: `isInside` is reflexive, so whenever HOME
|
|
* identifies the passwd home the second check already returns false on its own.
|
|
* Removing it changes no outcome; it is kept to answer the common case without
|
|
* an ancestor walk. Raised in review of #3725 against prose that claimed the
|
|
* three were independent.
|
|
*
|
|
* Fails CLOSED: an unreadable or unidentifiable HOME returns false, so the
|
|
* caller refuses rather than exempting a destination it cannot place.
|
|
*/
|
|
function derivesFromSandboxedHome(
|
|
dest: string,
|
|
effectiveHome: string | null,
|
|
realHome: { dev: number; ino: number },
|
|
passwdHome: string,
|
|
): boolean {
|
|
if (effectiveHome === null) return false;
|
|
const eff = identify(effectiveHome);
|
|
if (eff.kind !== 'ok') return false;
|
|
if (eff.dev === realHome.dev && eff.ino === realHome.ino) return false;
|
|
// A sandbox sits BENEATH the real home (the Windows temp shape) — never above
|
|
// it. A destination's ancestor chain is linear, so "inside the real home AND
|
|
// inside the effective HOME" admits two arrangements, not one: the intended
|
|
// `effectiveHome ⊂ realHome`, and `realHome ⊂ effectiveHome` — HOME pointed at
|
|
// `/Users`, `/home`, or `C:\Users`. The second is not a sandbox, it is the real
|
|
// home reached by a wider spelling, and without this it exempts a stale
|
|
// destination. Reported in review of #3725 as the one leaking cell of a
|
|
// six-row truth table.
|
|
if (isInside(passwdHome, eff)) return false;
|
|
return isInside(dest, eff);
|
|
}
|
|
|
|
/**
|
|
* Is `child` at or beneath the directory identified by `rootId`?
|
|
*
|
|
* Walks `child`'s ancestors comparing filesystem identity rather than string
|
|
* prefixes, because `child` typically does not exist yet (that is the point — it
|
|
* is about to be created) while its ancestors do. A prefix test would miss a
|
|
* case-variant or symlinked spelling of the same ancestor, which is the exact
|
|
* class this guard exists to catch.
|
|
*/
|
|
function isInside(child: string, rootId: { dev: number; ino: number }): boolean {
|
|
let cur = path.resolve(child);
|
|
for (;;) {
|
|
const id = identify(cur);
|
|
if (id.kind === 'ok' && id.dev === rootId.dev && id.ino === rootId.ino) return true;
|
|
const parent = path.dirname(cur);
|
|
if (parent === cur) return false;
|
|
cur = parent;
|
|
}
|
|
}
|
|
|
|
export = { assertTestHomeSandboxed, isTestHomeGuardRefusal, SANDBOX_MARKER };
|