Files
msd-core/src/real-home-guard.cts
Tom Boucher 39673ae9ff fix(#3738): antigravity global skills/agents install to ~/.gemini/config (#3921)
* 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>
2026-08-27 02:24:03 -04:00

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 };