diff --git a/packages/utils/CHANGELOG.md b/packages/utils/CHANGELOG.md index 6bb39b4af4..20c02ea5d6 100644 --- a/packages/utils/CHANGELOG.md +++ b/packages/utils/CHANGELOG.md @@ -5,7 +5,13 @@ - The independent-evidence rule for the trusted account home (#4766) is now pinned by discriminating regression proof for the shape its macOS repro cannot reach: a Linux identity whose uid has no local `/etc/passwd` entry (NSS/LDAP/SSSD-backed accounts, minimal or distroless containers), reported as #4773. New subprocess tests run the resolver under an unprivileged user namespace whose mapped uid is verified absent from the local passwd database (chosen from common subordinate-style candidates or the invoking user's `/etc/subuid` range), so the runtime `userInfo().homedir` echo of a checkout-declared home variable cannot silently regress back into the trusted set; they fail on the pre-rule behavior, hosts without the capability skip with a loud warning naming the lost coverage, and the compat shapes (passwd-backed uid, absent platform home variable, unambiguous operator home, dynamic declaration) are asserted unchanged on every platform, using the platform-authoritative key (`USERPROFILE` on Windows). `agent-dir-trust.test.ts`'s account-home expectation now reads the passwd database directly instead of a parent-side `os.userInfo().homedir`, which follows an isolated `HOME` and made the assertion fail under a pristine `HOME` (#4773). ### Fixed -- A project-declared `HOME` could again select the trusted home on macOS, so credentials were read from a checkout-controlled home directory. When the project dotenv declares the platform-authoritative home variable, the resolver falls back to `accountHomeFromSystem()`; that helper reads `os.userInfo().homedir`, which Bun resolves from `$HOME` on macOS (unlike Node, which reads the passwd database). The rejected value therefore came back as its own justification and `~/.env` under the hostile home was parsed for credentials. The account home is now accepted as independent evidence only when it differs from the runtime home -- true for the Linux `/etc/passwd` lookup, false for the macOS `$HOME` echo -- and an ambiguous home with no independent evidence resolves to the filesystem root sentinel, which marks user state unavailable and keeps credential resolution fail-closed. +- Explicit plugin homes now short-circuit safely when authoritative-home resolution is unavailable, while equal-home calls retain the default XDG-aware path. +- NSS account-home cache entries are now scoped by effective uid and account identity, so setuid or container identity transitions cannot reuse another user's trusted home or state; failed lookups remain fail-closed and call-time HOME/XDG refresh is unchanged. +- The authoritative home for user-scope state is resolved at call time again, instead of being snapshotted when `dirs.ts` loads. The provenance hardening had anchored `getTrustedHomeDir()` (and everything derived from it: config root, default agent dir, plugins dir) to an import-time value, so any home established or changed after module load silently lost every user-scope location -- user-scope skills under `~/.gjc/agent/skills` and user-scope MCP servers under `~/.gjc/agent/mcp.json` stopped being discovered. The provenance rule is unchanged: a home the project dotenv could have planted is still rejected in favor of the OS account database, and a bare filesystem root is still refused (#4761). +- An ambiguous home is no longer refused when the account database corroborates it. Independence was tested by string inequality (`accountHome !== runtimeHome`), so an operator whose `HOME` legitimately matches their account entry was locked out of their own user state as soon as any checkout declared `HOME` dynamically -- the account lookup was treated as an echo precisely when it agreed. Independence is now a property of the *source*: an NSS answer is environment-independent evidence whichever path it names, while an `os.userInfo()` fallback (which Bun derives from `$HOME`) is never evidence. A planted home is still rejected whenever the account database contradicts it, and still fails closed when no environment-independent lookup is available (#4761). +- The runtime home is validated before it can be trusted. `resolveTrustedHome()` accepted whatever `os.homedir()` returned, so a relative home (Bun returns `HOME` verbatim) anchored the config root, agent dir and plugins dir beneath the current working directory. It is now held to the same absolute, non-root standard as the account home, and an unusable value falls through to the account lookup instead of being honored (#4761). +- The Linux account home is read through NSS (`getent passwd `) instead of parsing `/etc/passwd` directly. LDAP- and SSSD-backed accounts have no local passwd entry, so the file read missed them entirely and fell through to `os.userInfo().homedir`, which Bun derives from `$HOME` -- exactly the untrusted value the account lookup exists to avoid. `getent` is the NSS front end, so local and directory-backed accounts both resolve; the probe runs with a fixed `PATH`/`LC_ALL` and no inherited environment. macOS and Windows keep the portable `os.userInfo()` path (#4761). +- A project-declared `HOME` could again select the trusted home on macOS, so credentials were read from a checkout-controlled home directory. When the project dotenv declares the platform-authoritative home variable, the resolver falls back to `accountHomeFromSystem()`; that helper reads `os.userInfo().homedir`, which Bun resolves from `$HOME` on macOS (unlike Node, which reads the passwd database). The rejected value therefore came back as its own justification and `~/.env` under the hostile home was parsed for credentials. The account home is now accepted as independent evidence only when it comes from an environment-independent source -- true for the Linux NSS account lookup, false for the `os.userInfo()` fallback that Bun derives from `$HOME` -- and an ambiguous home with no independent evidence resolves to the filesystem root sentinel, which marks user state unavailable and keeps credential resolution fail-closed. - Windows environment names are case-insensitive, but every project-dotenv provenance lookup was keyed on the exact case parsed from the file. A `.env` line `userprofile=...`, `gjc_coding_agent_dir=...`, or a lowercase provider key was therefore invisible to the trusted-home, agent-directory and credential guards while `process.env`/`Bun.env` still resolved it -- the declaration was live but unguarded. Project snapshot keys and their lookups are now folded through `canonicalEnvKey()`, which upper-cases on Windows only; POSIX names stay case-sensitive, pinned by a test that fails if the fold is applied unconditionally. The `win32` branch itself is proven by `packages/utils/test/env-provenance.windows.test.ts`, which runs on the required windows-latest lane: a lowercase `userprofile`, a mixed-case `Gjc_Coding_Agent_Dir`, and a lowercase provider key declared by the project are all rejected, while a genuinely inherited uppercase credential still resolves. - `postmortem.test.ts` no longer writes its crash fixtures into the developer's real crash store. Every scenario there deliberately crashes a subprocess, and the fatal handler resolved `getCrashLogPath()` with no override, so each run injected a dozen `fixture: ...` signatures into `~/.gjc/agent/gjc-crash.log` -- visible in `gjc crash list`, offered up by `gjc crash report`, eligible for the opt-in upstream relay, and competing for the log's fixed byte cap against a genuine crash the developer might need to file. The spawned fixtures now run under a temp `GJC_CODING_AGENT_DIR`, and the tests assert the fixture store received the records and the real store was untouched. - The process-level fatal handler (`uncaughtException` / `unhandledRejection`) again journals an `occurrence` event for every recorded crash. The `writeCrashRecord` refactor had moved journaling exclusively into `recordFatalCrash`, so process-handler crashes wrote the log without the journal event that crash indexing, listing, nudging, and the upstream relay consume; the handler now routes through `recordFatalCrash`, which also restores the printed `crash recorded at ` line (it previously interpolated the record object as `[object Object]`). diff --git a/packages/utils/src/dirs.ts b/packages/utils/src/dirs.ts index 578a754f3b..2e6dd2c93e 100644 --- a/packages/utils/src/dirs.ts +++ b/packages/utils/src/dirs.ts @@ -231,22 +231,176 @@ function trustedValue( return value; } -function accountHomeFromSystem(): string | undefined { +function resolveConfigDirName(project: { values: Record; dynamic: Set }): string { + return ( + sanitizeConfigDirName(trustedValue("GJC_CONFIG_DIR", project)) ?? + sanitizeConfigDirName(trustedValue("PI_CONFIG_DIR", project)) ?? + CONFIG_DIR_NAME + ); +} + +/** + * A home directory is usable only when it is absolute and resolves to somewhere + * strictly below a filesystem root. A relative value would anchor user state + * beneath whatever the current directory happens to be, and a root would place + * it at `/.gjc`. + * + * The root test normalizes first, because a root has many spellings: `/.`, `//`, + * `/foo/..` and `C:\x\..` are all roots that a raw string comparison against + * `path.parse(home).root` misses, and `path.join(home, ".gjc")` would happily + * produce `/.gjc` from every one of them. + * + * The **original spelling** is returned, never the normalized form. Provenance + * compares the declared dotenv value against this result, and both sides must + * stay in the same spelling: canonicalizing only this side would make + * `HOME=/tmp/base/../attacker` compare unequal to its own declaration and let a + * project-planted home through as if it were operator-supplied. + */ +function usableHome(home: string | undefined): string | undefined { + if (!home || !path.isAbsolute(home)) return undefined; + const normalized = path.resolve(home); + return normalized === path.parse(normalized).root ? undefined : home; +} + +/** + * The account home for the running uid, read through the operating system's own + * account database. + * + * On Linux this must go through NSS rather than parsing `/etc/passwd`: LDAP and + * SSSD accounts have no local passwd entry, and a direct file read would miss + * them and fall through to an environment-derived value. `getent passwd` is the + * NSS front end, so it resolves local and directory-backed accounts alike. + * + * Only an **environment-independent** result is memoized, and only on success. + * The cache is keyed by the effective account identity, not by process lifetime: + * a setuid or container identity transition must never reuse another uid's home. + * The NSS answer cannot change during one identity's process lifetime, so + * per-identity caching is safe. + * The `os.userInfo()` fallback is different: Bun derives `homedir` from `$HOME`, + * so caching it would freeze one side of the independence comparison in + * {@link resolveTrustedHome}. A planted home that was live at first resolution + * would stay cached, and once the runtime home moved it would no longer *equal* + * the runtime home -- passing the echo check and being promoted to independent + * evidence. Provenance is carried with the value so that can never happen. + */ +type AccountHome = { home: string; envDerived: boolean }; +type AccountIdentity = { key: string; uid: number }; + +const accountHomeCache = new Map(); + +function accountIdentity(info: os.UserInfo): AccountIdentity { + const uid = process.platform === "win32" ? info.uid : (process.geteuid?.() ?? info.uid); + return { + key: `${process.platform}:uid=${uid}:user=${info.username}`, + uid, + }; +} + +/** The uid's home field from the NSS account database, or undefined. */ +function nssAccountHome(uid: number): string | undefined { + try { + // Spawned with an empty environment so nothing the caller controls (HOME, + // NSS module configuration, locale) can steer the answer. + const result = Bun.spawnSync({ + cmd: ["getent", "passwd", String(uid)], + env: { PATH: "/usr/bin:/bin:/usr/sbin:/sbin", LC_ALL: "C" }, + stdout: "pipe", + stderr: "ignore", + }); + if (result.exitCode !== 0) return undefined; + // `getent` echoes passwd-format records; the home directory is field 6. + const line = new TextDecoder().decode(result.stdout).split("\n")[0]; + return usableHome(line?.split(":")[5]); + } catch { + return undefined; + } +} + +function accountHomeFromSystem(): AccountHome | undefined { try { const info = os.userInfo(); + const identity = accountIdentity(info); + const cached = accountHomeCache.get(identity.key); + if (cached !== undefined) return cached; if (process.platform === "linux") { - const line = fs - .readFileSync("/etc/passwd", "utf8") - .split("\n") - .find(candidate => candidate.split(":")[2] === String(info.uid)); - const home = line?.split(":")[5]; - if (home && path.isAbsolute(home) && home !== path.parse(home).root) return home; + const nss = nssAccountHome(identity.uid); + if (nss !== undefined) { + // NSS is environment-independent and stable: safe to memoize. + const result = { home: nss, envDerived: false }; + accountHomeCache.set(identity.key, result); + return result; + } } - const home = info.homedir; - if (home && path.isAbsolute(home) && home !== path.parse(home).root) return home; - } catch {} + // `os.userInfo().homedir` is the portable path for macOS and Windows, and on + // Linux is reached only when NSS is unavailable. Bun derives it from `$HOME`, + // so it is re-read every time and never cached, and it is flagged so the + // caller can refuse to treat it as independent evidence. + const fallback = usableHome(info.homedir); + if (fallback !== undefined) return { home: fallback, envDerived: true }; + } catch { + // Do not retain or consult a prior identity's result when the current + // identity cannot be observed. An unavailable uid is not evidence for any + // other uid and must fail closed instead of inheriting stale state. + return undefined; + } return undefined; } + +/** + * Resolve the authoritative home for user-scope state. + * + * Two properties must hold together, and pinning either one alone breaks the + * other (issue #4761): + * + * 1. **Provenance.** Bun overlays a checkout's `.env` into `process.env` before + * any module runs, so a repository can plant HOME/USERPROFILE and redirect + * user state — including the `.env` files `$credentialEnv` treats as trusted. + * When the platform-authoritative variable is indistinguishable from the + * value the project dotenv declares, the OS account database wins instead. + * 2. **Call-time resolution.** The trusted home is *derived*, never snapshotted + * at module load. A resolution frozen at import silently loses every + * user-scope location whenever the runtime home is established or changed + * after this module initializes — which is exactly how user-scope skill and + * MCP discovery regressed. + * + * `os.homedir()` is the runtime candidate: on POSIX it reflects HOME, on Windows + * USERPROFILE, and it falls back to the account database on its own. Reading it + * per call is what makes the contract call-time; the provenance comparison above + * is what keeps an untrusted mutable home from being honored. + */ +function resolveTrustedHome(project: { values: Record; dynamic: Set }): string { + const authoritativeHomeKey = process.platform === "win32" ? "USERPROFILE" : "HOME"; + const declaredHomeKey = canonicalEnvKey(authoritativeHomeKey); + const declaredHome = project.values[declaredHomeKey]; + // A relative or filesystem-root runtime home would anchor user state beneath + // the current directory (or at `/`), so it is not a usable candidate no matter + // how it was supplied. Validate it exactly as the account home is validated. + const runtimeHome = usableHome(os.homedir()); + // Only the platform-authoritative variable can select the home. In particular, + // do not let the opposite platform variable (or a project dotenv value + // overlaid into it) redirect user state when this is absent. + const ambiguousHome = + declaredHome !== undefined && (project.dynamic.has(declaredHomeKey) || declaredHome === runtimeHome); + // The account lookup is consulted lazily. It can spawn the NSS front end, and + // this resolver runs on every directory access, so an unambiguous runtime home + // -- the ordinary CLI path -- must never pay for it. + if (!ambiguousHome && runtimeHome !== undefined) return runtimeHome; + + const accountHome = accountHomeFromSystem(); + if (ambiguousHome) { + // The account home is independent evidence only when it is not itself derived + // from the environment. An `os.userInfo()` fallback echoes `$HOME`, so a + // project-declared home would otherwise come back as its own justification. + // Fail closed: with no independent evidence the resolver yields a filesystem + // root, which `#homeAvailable` rejects, rather than honoring the declared + // home. Issue #4773 owns widening that fallback; do not weaken it here. + if (accountHome === undefined || accountHome.envDerived) return path.parse(process.cwd()).root; + return accountHome.home; + } + // No usable runtime home: fall back to whatever the account database reports. + if (accountHome !== undefined) return accountHome.home; + throw new Error("Unable to determine a trustworthy account home directory"); +} export function getConfigAgentDirName(): string { return `${getConfigDirName()}/agent`; } @@ -257,17 +411,15 @@ export function getConfigAgentDirName(): string { type XdgCategory = "data" | "state" | "cache"; -/** - * Resolve the home used for trusted agent state. Bun overlays a checkout's - * `.env` before module initialization, so `os.homedir()` can already reflect - * an attacker-controlled HOME. Prefer the OS account database, which is not - * affected by a hostile HOME/USERPROFILE overlay. - */ /** * Resolves and caches all gajae-code directory paths. On Linux, when XDG environment * variables are set, paths are redirected under $XDG_*_HOME/gjc/. A new * instance is created whenever the agent directory changes, which naturally * invalidates all cached paths. + * + * The trusted home is re-derived on each access (see {@link resolveTrustedHome}) + * and every cached path is rebuilt when it changes, so a home established or + * mocked after module load is honored without weakening the provenance rule. */ class DirResolver { configRoot: string; @@ -275,8 +427,12 @@ class DirResolver { readonly #projectEnv: { values: Record; dynamic: Set }; #configDirName: string; readonly #agentDirOverride: boolean; - readonly #trustedHome: string; - readonly #homeAvailable: boolean; + #trustedHome: string; + /** + * Whether this resolver's agent directory may follow `$XDG_*_HOME`, decided + * once at construction and never re-derived from the path afterwards. + */ + #xdgEligible: boolean; // Per-category base dirs. Without XDG, all three equal configRoot / agentDir. // With XDG on Linux, they point to $XDG_*_HOME/gjc/. @@ -288,50 +444,48 @@ class DirResolver { constructor(agentDirOverride?: string, snapshot = projectEnvSnapshot()) { this.#projectEnv = snapshot; - this.#configDirName = - sanitizeConfigDirName(trustedValue("GJC_CONFIG_DIR", snapshot)) ?? - sanitizeConfigDirName(trustedValue("PI_CONFIG_DIR", snapshot)) ?? - CONFIG_DIR_NAME; - const authoritativeHomeKey = process.platform === "win32" ? "USERPROFILE" : "HOME"; - const declaredHomeKey = canonicalEnvKey(authoritativeHomeKey); - const declaredHome = snapshot.values[declaredHomeKey]; - // Only the platform-authoritative variable can select the trusted home. - // In particular, do not let the opposite platform variable (or a project - // dotenv value overlaid into it) redirect user state when this is absent. - const runtimeHome = process.env[authoritativeHomeKey]; - const accountHome = accountHomeFromSystem(); - // The account home is independent evidence only when it does not merely - // echo the runtime home. Bun resolves `os.userInfo().homedir` from `$HOME` - // on macOS (unlike Node, which reads the passwd database), so a - // project-declared home would otherwise come back as its own justification - // and re-enter the trusted set. Only the Linux `/etc/passwd` lookup is - // genuinely env-independent. - const independentAccountHome = accountHome !== undefined && accountHome !== runtimeHome ? accountHome : undefined; - const ambiguousHome = - declaredHome !== undefined && (snapshot.dynamic.has(declaredHomeKey) || declaredHome === runtimeHome); - this.#trustedHome = ambiguousHome - ? (independentAccountHome ?? path.parse(process.cwd()).root) - : (runtimeHome ?? - accountHome ?? - (() => { - throw new Error("Unable to determine a trustworthy account home directory"); - })()); - this.#homeAvailable = this.#trustedHome !== path.parse(this.#trustedHome).root; + this.#configDirName = resolveConfigDirName(snapshot); + this.#trustedHome = resolveTrustedHome(snapshot); this.configRoot = path.join(this.#trustedHome, this.#configDirName); const defaultAgent = path.join(this.configRoot, "agent"); this.#agentDirOverride = Boolean(agentDirOverride); this.agentDir = agentDirOverride ? path.resolve(agentDirOverride) : defaultAgent; + // An agent directory equal to the home-derived default *is* the default + // profile, XDG categories included, however it arrived. + // + // Deciding this from override state instead was tried and reverted: it is + // unobservably wrong. `setAgentDir()` exports `GJC_CODING_AGENT_DIR`, so a + // child process inherits the same value the parent set programmatically and + // cannot tell the two apart. Treating the inherited form as "not default" + // put parent and child on different storage lanes for one logical profile -- + // the parent reading `$XDG_STATE_HOME/gjc/python-gateway` while the child + // read `/python-gateway`. Splitting a live store in half is worse + // than the narrower complaint it was meant to answer. const isDefault = this.agentDir === defaultAgent; + // That decision is then *sticky*. Recomputing it later from path shape is + // what let a pinned agent directory silently change storage lane when a home + // refresh made it coincide with the new default: `getAgentDir()` looked + // unchanged while `agent.db` moved into `$XDG_DATA_HOME/gjc`. + this.#xdgEligible = isDefault; this.#rootDirs = { data: this.configRoot, state: this.configRoot, cache: this.configRoot }; this.#agentDirs = { data: this.agentDir, state: this.agentDir, cache: this.agentDir }; this.refreshCategoryDirs(snapshot, isDefault); } + /** + * `isDefault` decides whether the agent directory may follow `$XDG_*_HOME`. + * + * It is always supplied by the caller and never defaulted: the only correct + * value is the construction-time decision held in `#xdgEligible`, and + * re-deriving it from path shape is exactly the bug that let a directory + * change storage lane when a home refresh made its path coincide with the + * new default. + */ private refreshCategoryDirs( snapshot: { values: Record; dynamic: Set }, - isDefault = this.agentDir === path.join(this.configRoot, "agent"), + isDefault: boolean, ): void { let xdgData: string | undefined; let xdgState: string | undefined; @@ -363,23 +517,39 @@ class DirResolver { }; } - /** Refresh caller-supplied config-dir overrides without replacing the trust snapshot. */ + /** + * Re-derive the trusted home and the caller-supplied config-dir override + * without replacing the trust snapshot. + * + * Both inputs are call-time: the home comes from {@link resolveTrustedHome} + * (provenance-checked, never an import-time snapshot) and the config-dir name + * from the trusted-value rule. When either changes, the config root, the + * default agent dir, the XDG category dirs and both path caches are rebuilt + * so reads and writes cannot straddle two different homes. + */ refreshConfigDirOverride(): void { - const next = - sanitizeConfigDirName(trustedValue("GJC_CONFIG_DIR", this.#projectEnv)) ?? - sanitizeConfigDirName(trustedValue("PI_CONFIG_DIR", this.#projectEnv)) ?? - CONFIG_DIR_NAME; - if (next === this.#configDirName) return; - const nextConfigRoot = path.join(this.#trustedHome, next); + const nextConfigDirName = resolveConfigDirName(this.#projectEnv); + const nextHome = resolveTrustedHome(this.#projectEnv); + if (nextConfigDirName === this.#configDirName && nextHome === this.#trustedHome) return; + const nextConfigRoot = path.join(nextHome, nextConfigDirName); const nextAgentDir = this.#agentDirOverride ? this.agentDir : path.join(nextConfigRoot, "agent"); - this.#configDirName = next; + this.#trustedHome = nextHome; + this.#configDirName = nextConfigDirName; this.configRoot = nextConfigRoot; this.agentDir = nextAgentDir; - this.refreshCategoryDirs(this.#projectEnv); + // Reuse the construction-time decision rather than re-deriving it, so an + // agent directory never changes storage lane just because a home refresh made + // its path coincide with (or diverge from) the new default. + this.refreshCategoryDirs(this.#projectEnv, this.#xdgEligible); this.#rootCache.clear(); this.#agentCache.clear(); } + /** Whether the resolved home is a real directory rather than a filesystem root. */ + get #homeAvailable(): boolean { + return this.#trustedHome !== path.parse(this.#trustedHome).root; + } + isProjectEnvDeclaration(name: string): boolean { return Object.hasOwn(this.#projectEnv.values, canonicalEnvKey(name)); } @@ -415,9 +585,11 @@ class DirResolver { return this.#configDirName; } get trustedHome(): string { + this.refreshConfigDirOverride(); return this.#trustedHome; } assertHomeAvailable(): void { + this.refreshConfigDirOverride(); if (!this.#homeAvailable) throw new Error("User state is unavailable: no trustworthy home directory"); } get trustSnapshot(): { values: Record; dynamic: Set } { @@ -431,12 +603,6 @@ const trustedAgentOverride = trustedValue("PI_CODING_AGENT_DIR", INITIAL_PROJECT_SNAPSHOT); let dirs = new DirResolver(trustedAgentOverride, INITIAL_PROJECT_SNAPSHOT); -// Anchor home for the resolver. Captured at module load to stay stable across -// test mocks of `os.homedir()`. `getPluginsDir(home)` compares against this so -// production callers (`home === RESOLVER_HOME`) hit the XDG-aware resolver while -// tests passing a temp HOME short-circuit to a deterministic path. -const RESOLVER_HOME = dirs.trustedHome; - // ============================================================================= // Root directories // ============================================================================= @@ -448,20 +614,33 @@ export function getConfigRootDir(): string { return dirs.configRoot; } -/** Stable, provenance-checked home captured by the resolver lifetime snapshot. */ +/** + * The authoritative home for user-scope state. + * + * Provenance-checked and resolved at call time: a home established or changed + * after this module loaded is honored, while a home the project dotenv could + * have planted is rejected in favor of the OS account database. See + * {@link resolveTrustedHome}. + */ export function getTrustedHomeDir(): string { dirs.assertHomeAvailable(); return dirs.trustedHome; } -/** Stable trusted config root; preserves the configured nested config-dir name. */ +/** Trusted config root, resolved at call time; preserves the configured nested config-dir name. */ export function getTrustedConfigRootDir(): string { dirs.refreshConfigDirOverride(); dirs.assertHomeAvailable(); return dirs.configRoot; } -/** Set the coding agent directory. Creates a fresh resolver, invalidating all cached paths. */ +/** + * Set the coding agent directory. Creates a fresh resolver, invalidating all + * cached paths. + * + * This also exports `GJC_CODING_AGENT_DIR`, so child processes inherit the same + * selection and resolve the same storage lane. + */ export function setAgentDir(dir: string): void { dirs = new DirResolver(dir, dirs.trustSnapshot); process.env.GJC_CODING_AGENT_DIR = dir; @@ -521,15 +700,24 @@ export function getLogPath(date = new Date()): string { * Get the plugins directory (~/.gjc/plugins or its XDG equivalent). * * No-arg form (production callers) goes through the XDG-aware DirResolver so - * reads and writes always agree. The optional `home` parameter is for test - * isolation: when it differs from `os.homedir()` it short-circuits the resolver - * and returns `//plugins` so tests with a temp HOME get a - * deterministic path. Passing `os.homedir()` explicitly is identical to the - * no-arg form — XDG semantics are preserved. + * reads and writes always agree. The optional `home` parameter names an explicit + * home: when it differs from the authoritative home resolved right now it + * short-circuits the resolver and returns `//plugins`, giving + * callers that carry their own home (and tests with a temp HOME) a deterministic + * path. Passing the authoritative home explicitly is identical to the no-arg + * form — XDG semantics are preserved. */ export function getPluginsDir(home?: string): string { - if (home !== undefined && home !== RESOLVER_HOME) { - return path.join(home, getConfigDirName(), "plugins"); + if (home !== undefined) { + const explicitPath = () => path.join(home, resolveConfigDirName(dirs.trustSnapshot), "plugins"); + try { + if (home !== dirs.trustedHome) return explicitPath(); + } catch { + // An explicit home is the caller's documented escape hatch. If the + // authoritative home is unavailable, do not let its fail-closed resolver + // prevent a caller-owned plugin path from being returned. + return explicitPath(); + } } return dirs.rootSubdir("plugins", "data"); } diff --git a/packages/utils/test/account-home-nss.test.ts b/packages/utils/test/account-home-nss.test.ts new file mode 100644 index 0000000000..51113ce930 --- /dev/null +++ b/packages/utils/test/account-home-nss.test.ts @@ -0,0 +1,350 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; + +/** + * The account home is the evidence that lets the resolver reject a home the + * project dotenv could have planted. It is only evidence if it is genuinely + * independent of the environment (issue #4761, snowykr P2). + * + * Reading `/etc/passwd` directly is not sufficient: LDAP- and SSSD-backed + * accounts have no local passwd entry, so the file read misses them and the + * resolver falls through to `os.userInfo().homedir`, which Bun derives from + * `$HOME` — exactly the untrusted value the account lookup exists to avoid. + * `getent passwd` is the NSS front end and resolves local and directory-backed + * accounts alike. + * + * These run out of process because the property under test is what the resolver + * does with a hostile environment it inherited at startup. + */ + +const DIRS = path.join(import.meta.dir, "..", "src", "dirs.ts"); + +/** Patched copies of `dirs.ts`; they must sit beside the original so its relative imports resolve. */ +const scratch: string[] = []; + +/** A temporary directory, created portably and removed after the test. */ +async function tempDir(): Promise { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "gjc-account-home-")); + scratch.push(dir); + return dir; +} + +afterEach(async () => { + await Promise.all(scratch.splice(0).map(entry => fs.rm(entry, { recursive: true, force: true }))); +}); + +/** Resolve the trusted home in a child process under a controlled environment. */ +async function resolveWith(env: Record, cwd = import.meta.dir): Promise { + const childEnv: Record = {}; + for (const [key, value] of Object.entries(process.env)) { + if (value !== undefined) childEnv[key] = value; + } + for (const [key, value] of Object.entries(env)) { + if (value === undefined) delete childEnv[key]; + else childEnv[key] = value; + } + const source = `import { getTrustedHomeDir } from ${JSON.stringify(DIRS)};\nconsole.log(getTrustedHomeDir());`; + const proc = Bun.spawn([process.execPath, "-e", source], { cwd, env: childEnv, stdout: "pipe", stderr: "pipe" }); + const [stdout, stderr] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]); + const exitCode = await proc.exited; + if (exitCode !== 0) throw new Error(`probe failed (${exitCode}): ${stderr}`); + return stdout.trim().split("\n").at(-1) ?? ""; +} + +/** + * The account home as NSS reports it, independent of any environment variable. + * + * Returns `undefined` when NSS cannot answer, including when `getent` is absent + * entirely: `Bun.spawn` *throws* on a missing executable rather than returning a + * non-zero exit, so a minimal or distroless host would otherwise fail this suite + * instead of skipping the assertions that need an account entry. + */ +async function nssHome(): Promise { + try { + const proc = Bun.spawn(["getent", "passwd", String(os.userInfo().uid)], { stdout: "pipe", stderr: "ignore" }); + const stdout = await new Response(proc.stdout).text(); + if ((await proc.exited) !== 0) return undefined; + const home = stdout.split("\n")[0]?.split(":")[5]; + return home && path.isAbsolute(home) ? home : undefined; + } catch { + return undefined; + } +} + +interface UidCacheProbeResult { + first: string; + uidB: string[]; + uidAAgain: string; + uidAAfterMappingChange: string; + nssCalls: number; +} + +/** Exercise the cache with a deterministic NSS/identity seam in a child process. */ +async function runUidCacheProbe(homeA: string, homeAAfterMappingChange: string): Promise { + const source = await Bun.file(DIRS).text(); + const identityStart = source.indexOf("function accountIdentity"); + const nssCommentStart = source.indexOf("/** The uid's home field", identityStart); + const nssStart = source.indexOf("function nssAccountHome", nssCommentStart); + const accountStart = source.indexOf("function accountHomeFromSystem", nssStart); + expect(identityStart).toBeGreaterThanOrEqual(0); + expect(nssCommentStart).toBeGreaterThan(identityStart); + expect(nssStart).toBeGreaterThan(nssCommentStart); + expect(accountStart).toBeGreaterThan(nssStart); + + const identityReplacement = `function accountIdentity(info: os.UserInfo): AccountIdentity { + const uid = Number(process.env.GJC_TEST_EFFECTIVE_UID ?? info.uid); + return { key: \`${process.platform}:uid=\${uid}:user=\${info.username}\`, uid }; + } + + `; + const withIdentitySeam = source.slice(0, identityStart) + identityReplacement + source.slice(nssCommentStart); + const nssReplacement = `function nssAccountHome(uid: number): string | undefined { + const state = globalThis as typeof globalThis & { GJC_TEST_NSS_CALLS?: number }; + state.GJC_TEST_NSS_CALLS = (state.GJC_TEST_NSS_CALLS ?? 0) + 1; + return usableHome(process.env[\`GJC_TEST_NSS_HOME_\${uid}\`]); + } + + `; + const patched = + withIdentitySeam.slice(0, withIdentitySeam.indexOf("function nssAccountHome")) + + nssReplacement + + withIdentitySeam.slice(withIdentitySeam.indexOf("function accountHomeFromSystem")); + const patchedPath = path.join(path.dirname(DIRS), `dirs-uid-cache-${Bun.randomUUIDv7()}.ts`); + scratch.push(patchedPath); + await Bun.write(patchedPath, patched); + + const project = await tempDir(); + await Bun.write(path.join(project, ".env"), "HOME=$GJC_TEST_RUNTIME_HOME\n"); + const probePath = path.join(project, "uid-cache-probe.ts"); + await Bun.write( + probePath, + [ + `import { getTrustedHomeDir } from ${JSON.stringify(patchedPath)};`, + "const state = globalThis as typeof globalThis & { GJC_TEST_NSS_CALLS?: number };", + 'const read = () => { try { return getTrustedHomeDir(); } catch (error) { if (String(error).includes("no trustworthy home directory")) return "REFUSED"; throw error; } };', + "const first = read();", + 'process.env.GJC_TEST_EFFECTIVE_UID = "2001";', + "const uidB = await Promise.all([Promise.resolve().then(read), Promise.resolve().then(read)]);", + 'process.env.GJC_TEST_EFFECTIVE_UID = "1000";', + "const uidAAgain = read();", + `process.env.GJC_TEST_NSS_HOME_1000 = ${JSON.stringify(homeAAfterMappingChange)};`, + "const uidAAfterMappingChange = read();", + "console.log(JSON.stringify({ first, uidB, uidAAgain, uidAAfterMappingChange, nssCalls: state.GJC_TEST_NSS_CALLS ?? 0 }));", + ].join("\n"), + ); + + const env: Record = {}; + for (const [key, value] of Object.entries(process.env)) { + if (value !== undefined) env[key] = value; + } + env.HOME = await tempDir(); + env.GJC_TEST_RUNTIME_HOME = env.HOME; + env.GJC_TEST_EFFECTIVE_UID = "1000"; + env.GJC_TEST_NSS_HOME_1000 = homeA; + delete env.GJC_TEST_NSS_HOME_2001; + const proc = Bun.spawn([process.execPath, probePath], { cwd: project, env, stdout: "pipe", stderr: "pipe" }); + const [stdout, stderr] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]); + const exitCode = await proc.exited; + if (exitCode !== 0) throw new Error(`uid cache probe failed (${exitCode}): ${stderr}`); + return JSON.parse(stdout.trim().split("\n").at(-1) ?? "{}") as UidCacheProbeResult; +} + +/** + * Report why a case cannot run here instead of passing vacuously. A silent + * `return` is indistinguishable from a real assertion in CI output. + */ +function skip(reason: string): void { + console.warn(`SKIP: ${reason}`); +} + +describe("account home is resolved through the OS account database", () => { + const itLinux = it.skipIf(process.platform !== "linux"); + + itLinux("scopes NSS cache entries by effective UID and never leaks across transitions", async () => { + const homeA = await tempDir(); + const homeAAfterMappingChange = await tempDir(); + const result = await runUidCacheProbe(homeA, homeAAfterMappingChange); + + expect(result.first).toBe(homeA); + expect(result.uidB).toEqual(["REFUSED", "REFUSED"]); + expect(result.uidAAgain).toBe(homeA); + // A's cached NSS answer is safely reused after B fails; the changed mapping + // must not alter A's already-established per-UID result. + expect(result.uidAAfterMappingChange).toBe(homeA); + // One initial A lookup plus both concurrent failed B lookups; no lookup for + // A after the identity returns, proving the cache is per identity. + expect(result.nssCalls).toBe(3); + }); + + itLinux("reports the same home NSS does, not the inherited environment", async () => { + const account = await nssHome(); + if (!account) return skip("no NSS account entry for this uid; nothing to assert against"); + + // HOME removed entirely: nothing environment-derived is left to echo, so a + // correct lookup still names the account home. + expect(await resolveWith({ HOME: undefined })).toBe(account); + }); + + itLinux("ignores a hostile home that the account database contradicts", async () => { + const account = await nssHome(); + if (!account) return skip("no NSS account entry for this uid; nothing to assert against"); + + const hostile = await tempDir(); + try { + // POSIX HOME absent and a hostile USERPROFILE present: the non-authoritative + // variable must never select the home on Linux. + expect(await resolveWith({ HOME: undefined, USERPROFILE: hostile })).toBe(account); + } finally { + await fs.rm(hostile, { recursive: true, force: true }); + } + }); + + itLinux("never promotes a cached environment-derived home to independent evidence", async () => { + // The failure this guards, reproduced against the real resolver: + // + // With NSS unavailable the account lookup falls back to + // `os.userInfo().homedir`, which Bun derives from `$HOME`. If that value were + // memoized while a planted home was live, a later call-time home change would + // leave the cached attacker value *differing* from the new runtime home -- + // passing an equality-based independence check and being returned as trusted. + // + // A dynamic dotenv declaration keeps `ambiguousHome` true throughout, so the + // resolver must fail closed both before and after the home moves. + const attacker = await tempDir(); + const project = await tempDir(); + try { + await Bun.write(path.join(project, ".env"), "HOME=$GJC_TEST_EVIL\n"); + // Point the resolver's own NSS lookup at a command that cannot exist, so + // the failure is injected through the resolver rather than beside it. + const source = await Bun.file(DIRS).text(); + const broken = source.replace('cmd: ["getent", "passwd", String(uid)],', 'cmd: ["gjc-no-such-nss-binary"],'); + expect(broken).not.toBe(source); + const brokenPath = path.join(path.dirname(DIRS), `dirs-no-nss-${Bun.randomUUIDv7()}.ts`); + scratch.push(brokenPath); + await Bun.write(brokenPath, broken); + + const probe = [ + 'import { vi } from "bun:test";', + 'import * as os from "node:os";', + `import { getTrustedHomeDir } from ${JSON.stringify(brokenPath)};`, + 'const read = () => { try { return getTrustedHomeDir(); } catch (error) { if (String(error).includes("no trustworthy home directory")) return "REFUSED"; throw error; } };', + "const first = read();", + 'vi.spyOn(os, "homedir").mockReturnValue("/tmp");', + "console.log(JSON.stringify({ first, second: read() }));", + ].join("\n"); + const probePath = path.join(project, "probe.ts"); + await Bun.write(probePath, probe); + + const env: Record = {}; + for (const [key, value] of Object.entries(process.env)) { + if (value !== undefined) env[key] = value; + } + env.HOME = attacker; + env.GJC_TEST_EVIL = attacker; + const proc = Bun.spawn([process.execPath, probePath], { + cwd: project, + env, + stdout: "pipe", + stderr: "pipe", + }); + const stdout = await new Response(proc.stdout).text(); + const stderr = await new Response(proc.stderr).text(); + const exitCode = await proc.exited; + if (exitCode !== 0) throw new Error(`probe failed (${exitCode}): ${stderr}`); + const { first, second } = JSON.parse(stdout.trim().split("\n").at(-1) ?? "{}"); + + // The planted home must never be returned, before or after the change. + expect(first).not.toBe(attacker); + expect(second).not.toBe(attacker); + // And with no environment-independent evidence, both must refuse outright. + expect(first).toBe("REFUSED"); + expect(second).toBe("REFUSED"); + } finally { + await Promise.all([attacker, project].map(dir => fs.rm(dir, { recursive: true, force: true }))); + } + }); + + itLinux("still resolves an absolute home when the NSS front end is unavailable", async () => { + // Minimal and distroless images ship no `getent`, and `Bun.spawnSync` throws + // outright when the executable is missing rather than returning a non-zero + // exit. The failure is injected into the resolver's own lookup so this + // exercises the real fallback path instead of simulating it alongside. + const work = await tempDir(); + try { + const source = await Bun.file(DIRS).text(); + const broken = source.replace('cmd: ["getent", "passwd", String(uid)],', 'cmd: ["gjc-no-such-nss-binary"],'); + expect(broken).not.toBe(source); + const brokenPath = path.join(path.dirname(DIRS), `dirs-no-nss-${Bun.randomUUIDv7()}.ts`); + scratch.push(brokenPath); + await Bun.write(brokenPath, broken); + + const probePath = path.join(work, "probe.ts"); + await Bun.write( + probePath, + `import { getTrustedHomeDir } from ${JSON.stringify(brokenPath)};\nconsole.log(getTrustedHomeDir());\n`, + ); + + // An honest operator home with no dotenv declaration: the lookup failure + // must degrade to the runtime home, not take the process down. + const home = await tempDir(); + const env: Record = {}; + for (const [key, value] of Object.entries(process.env)) { + if (value !== undefined) env[key] = value; + } + env.HOME = home; + const proc = Bun.spawn([process.execPath, probePath], { cwd: work, env, stdout: "pipe", stderr: "pipe" }); + const stdout = await new Response(proc.stdout).text(); + const stderr = await new Response(proc.stderr).text(); + const exitCode = await proc.exited; + if (exitCode !== 0) throw new Error(`probe failed (${exitCode}): ${stderr}`); + + const resolved = stdout.trim().split("\n").at(-1) ?? ""; + expect(resolved).toBe(home); + expect(path.isAbsolute(resolved)).toBe(true); + await fs.rm(home, { recursive: true, force: true }); + } finally { + await fs.rm(work, { recursive: true, force: true }); + } + }); + + itLinux("accepts an NSS home that corroborates the runtime home", async () => { + const account = await nssHome(); + if (!account) return skip("no NSS account entry for this uid; nothing to assert against"); + + // A checkout can declare HOME dynamically, which makes the home ambiguous no + // matter what it resolves to. The account lookup then decides. When NSS -- + // which no environment variable can influence -- independently reports the + // same path, that is corroboration, not the self-justifying echo the guard + // exists to catch. Refusing it locks a legitimate operator out of their own + // user state whenever their HOME agrees with their account entry. + const project = await tempDir(); + try { + await Bun.write(path.join(project, ".env"), "HOME=$GJC_TEST_DYNAMIC\n"); + const resolved = await resolveWith({ HOME: account, GJC_TEST_DYNAMIC: account }, project); + expect(resolved).toBe(account); + } finally { + await fs.rm(project, { recursive: true, force: true }); + } + }); + + it("does not honor a project-declared home that spells itself with a traversal", async () => { + // Provenance compares the declared dotenv value against the runtime home. If + // either side were canonicalized without the other, `HOME=/tmp/x/../y` would + // compare unequal to its own declaration and be honored as operator-supplied. + const base = await tempDir(); + const real = path.join(base, "attacker"); + const aliased = path.join(base, "decoy", "..", "attacker"); + const project = await tempDir(); + try { + await Promise.all([real, path.join(base, "decoy")].map(dir => fs.mkdir(dir, { recursive: true }))); + await Bun.write(path.join(project, ".env"), `HOME=${aliased}\n`); + const resolved = await resolveWith({ HOME: aliased }, project); + expect(resolved).not.toBe(aliased); + expect(resolved).not.toBe(real); + } finally { + await Promise.all([base, project].map(dir => fs.rm(dir, { recursive: true, force: true }))); + } + }); +}); diff --git a/packages/utils/test/agent-dir-trust.test.ts b/packages/utils/test/agent-dir-trust.test.ts index add27b374d..182f748e53 100644 --- a/packages/utils/test/agent-dir-trust.test.ts +++ b/packages/utils/test/agent-dir-trust.test.ts @@ -92,15 +92,23 @@ async function resolveWithoutPlatformHome(cwd: string, hostileHome: string): Pro } /** The account home of the running user, from the passwd database on Linux. */ -function accountHomeOfRunningUser(): string { +async function accountHomeOfRunningUser(): Promise { if (process.platform === "linux") { - const uid = String(os.userInfo().uid); - const line = fs - .readFileSync("/etc/passwd", "utf8") - .split("\n") - .find(candidate => candidate.split(":")[2] === uid); - const home = line?.split(":")[5]; - if (home && path.isAbsolute(home) && home !== path.parse(home).root) return home; + // NSS, matching production: reading `/etc/passwd` directly would disagree + // with the resolver for any LDAP- or SSSD-backed account, which has no local + // entry, and report a false failure. + // `Bun.spawn` throws when the executable is missing rather than returning a + // non-zero exit, so a host without `getent` must fall through to the portable + // path instead of failing the suite. + try { + const uid = os.userInfo().uid; + const proc = Bun.spawn(["getent", "passwd", String(uid)], { stdout: "pipe", stderr: "ignore" }); + const stdout = await new Response(proc.stdout).text(); + if ((await proc.exited) === 0) { + const home = stdout.split("\n")[0]?.split(":")[5]; + if (home && path.isAbsolute(home) && home !== path.parse(home).root) return home; + } + } catch {} } return os.userInfo().homedir; } @@ -231,7 +239,7 @@ describe("agent directory trust boundary", () => { // Expect the passwd database directly: a parent-side os.userInfo().homedir // follows an isolated HOME, while the child probe has HOME deleted and // resolves the real account home. - const accountHome = accountHomeOfRunningUser(); + const accountHome = await accountHomeOfRunningUser(); expect(resolved.trustedHome).toBe(accountHome); expect(resolved.trustedHome).not.toBe(hostileHome); expect(resolved.configRoot).toBe(path.join(accountHome, ".gjc")); @@ -242,7 +250,7 @@ describe("agent directory trust boundary", () => { const cwd = projectDir("SOMETHING_ELSE=1\n"); const hostileHome = tempDir(); const resolved = await resolveWithoutPlatformHome(cwd, hostileHome); - const accountHome = accountHomeOfRunningUser(); + const accountHome = await accountHomeOfRunningUser(); expect(resolved.trustedHome).toBe(accountHome); expect(resolved.trustedHome).not.toBe(hostileHome); expect(resolved.configRoot).toBe(path.join(accountHome, ".gjc")); diff --git a/packages/utils/test/fixtures/agent-dir-override-probe.ts b/packages/utils/test/fixtures/agent-dir-override-probe.ts new file mode 100644 index 0000000000..542bb838d9 --- /dev/null +++ b/packages/utils/test/fixtures/agent-dir-override-probe.ts @@ -0,0 +1,40 @@ +// Prints the agent directory and config root twice: once as the process starts, +// and once after the resolved home changes. Spawned as its own process so the +// operator-override assertion never mutates the parent's module-level resolver +// (`setAgentDir` installs an override resolver and cannot install a default one, +// so an in-process "restore" would latch an override on later tests). +// +// `GJC_CODING_AGENT_DIR` decides which lane is under test: set means an explicit +// operator selection that must stay pinned across a home change, absent means the +// default agent dir that must follow the resolved home. The second home is +// supplied by `GJC_PROBE_SECOND_HOME` and installed by mocking `os.homedir()`, +// which is how a home becomes visible only after module load. +import { vi } from "bun:test"; +import * as os from "node:os"; +import { getAgentDbPath, getAgentDir, getConfigRootDir, getTrustedHomeDir } from "../../src/dirs"; + +// `agentDb` rides the XDG data category, so it detects an agent directory that +// keeps its path but silently switches storage lanes across a home refresh. +const before = { + trustedHome: getTrustedHomeDir(), + agentDir: getAgentDir(), + configRoot: getConfigRootDir(), + agentDb: getAgentDbPath(), +}; + +const secondHome = process.env.GJC_PROBE_SECOND_HOME; +if (!secondHome) throw new Error("GJC_PROBE_SECOND_HOME is required"); +vi.spyOn(os, "homedir").mockReturnValue(secondHome); + +console.log( + JSON.stringify({ + overrideDeclared: process.env.GJC_CODING_AGENT_DIR ?? null, + before, + after: { + trustedHome: getTrustedHomeDir(), + agentDir: getAgentDir(), + configRoot: getConfigRootDir(), + agentDb: getAgentDbPath(), + }, + }), +); diff --git a/packages/utils/test/trusted-home-resolution.test.ts b/packages/utils/test/trusted-home-resolution.test.ts new file mode 100644 index 0000000000..a1c7fa6a58 --- /dev/null +++ b/packages/utils/test/trusted-home-resolution.test.ts @@ -0,0 +1,376 @@ +import { afterEach, describe, expect, it, vi } from "bun:test"; +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import { + CONFIG_DIR_NAME, + getAgentDir, + getConfigRootDir, + getPluginsDir, + getTrustedConfigRootDir, + getTrustedHomeDir, +} from "../src/dirs"; + +const PROBE = path.join(import.meta.dir, "fixtures", "agent-dir-override-probe.ts"); +const DIRS = path.join(import.meta.dir, "..", "src", "dirs.ts"); + +interface ProbeResolved { + trustedHome: string; + agentDir: string; + configRoot: string; + agentDb: string; +} + +interface ProbeResult { + overrideDeclared: string | null; + before: ProbeResolved; + after: ProbeResolved; +} + +/** + * Resolve the agent directory in a child process, before and after the home + * changes. Runs out of process so the parent's module-level resolver is never + * mutated: `setAgentDir` installs an override resolver and cannot install a + * default one, so an in-process restore would latch that override. + */ +async function probe(options: { + agentDirOverride: string | null; + secondHome: string; + home?: string; + xdgDataHome?: string; +}): Promise { + const env: Record = {}; + for (const [key, value] of Object.entries(process.env)) { + if (value !== undefined) env[key] = value; + } + delete env.GJC_CODING_AGENT_DIR; + delete env.PI_CODING_AGENT_DIR; + delete env.GJC_CONFIG_DIR; + delete env.PI_CONFIG_DIR; + delete env.XDG_DATA_HOME; + if (options.xdgDataHome) env.XDG_DATA_HOME = options.xdgDataHome; + if (options.agentDirOverride) env.GJC_CODING_AGENT_DIR = options.agentDirOverride; + if (options.home) { + // Only the platform-authoritative variable selects the home, and the + // opposite one is cleared so an inherited value cannot shadow it. The + // second home arrives through an `os.homedir()` mock in the fixture, which + // already resolves USERPROFILE on Windows. + const homeKey = process.platform === "win32" ? "USERPROFILE" : "HOME"; + const unusedHomeKey = process.platform === "win32" ? "HOME" : "USERPROFILE"; + env[homeKey] = options.home; + delete env[unusedHomeKey]; + } + env.GJC_PROBE_SECOND_HOME = options.secondHome; + + const proc = Bun.spawn([process.execPath, PROBE], { cwd: import.meta.dir, env, stdout: "pipe", stderr: "pipe" }); + const [stdout, stderr] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]); + const exitCode = await proc.exited; + if (exitCode !== 0) throw new Error(`probe failed (${exitCode}): ${stderr}`); + return JSON.parse(stdout.trim()) as ProbeResult; +} + +/** + * The authoritative home for user-scope state must satisfy two properties at + * once (issue #4761): + * + * 1. It is resolved at **call time**, not snapshotted at module load. A home + * established or changed after `dirs.ts` initializes must be honored — + * freezing it silently drops every user-scope location, which is how + * user-scope skill and MCP discovery regressed on `d9fabc8f5a`. + * 2. It stays **provenance-checked**. A checkout's `.env` is overlaid into + * `process.env` before any module runs, so a home the project dotenv could + * have planted must never be honored; the OS account database wins instead. + * That rule is exercised out-of-process in `agent-dir-trust.test.ts`, which + * can control cwd and the inherited environment; here we pin the in-process + * half — the resolution that discovery actually calls. + */ + +const tempDirs: string[] = []; + +async function tempDir(): Promise { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "gjc-trusted-home-")); + tempDirs.push(dir); + return dir; +} + +afterEach(async () => { + vi.restoreAllMocks(); + await Promise.all(tempDirs.splice(0).map(dir => fs.rm(dir, { recursive: true, force: true }))); +}); + +describe("authoritative home resolution", () => { + it("follows a home that only becomes visible after module load", async () => { + // The regression: `dirs.ts` had already initialized by the time this + // mock is installed, so an import-time snapshot keeps returning the + // ambient home and every user-scope path points at the wrong place. + const before = getTrustedHomeDir(); + const planted = await tempDir(); + expect(planted).not.toBe(before); + + vi.spyOn(os, "homedir").mockReturnValue(planted); + expect(getTrustedHomeDir()).toBe(planted); + }); + + it("re-points the home-derived config root and plugins dir at the resolved home", async () => { + // Discovery reads user-scope skills from `/agent/skills` and + // user-scope MCP from `/agent/mcp.json`. Everything derived from + // the home must move together, or reads and writes straddle two homes. + const planted = await tempDir(); + vi.spyOn(os, "homedir").mockReturnValue(planted); + + expect(getConfigRootDir()).toBe(path.join(planted, CONFIG_DIR_NAME)); + expect(getTrustedConfigRootDir()).toBe(path.join(planted, CONFIG_DIR_NAME)); + expect(getPluginsDir()).toBe(path.join(planted, CONFIG_DIR_NAME, "plugins")); + }); + + it("keeps every cached path consistent when the home changes twice", async () => { + // The resolver caches subdirectory paths. A stale cache is the same defect + // as a stale snapshot, so a second change must invalidate the first. + const first = await tempDir(); + const second = await tempDir(); + const spy = vi.spyOn(os, "homedir").mockReturnValue(first); + expect(getConfigRootDir()).toBe(path.join(first, CONFIG_DIR_NAME)); + expect(getPluginsDir()).toBe(path.join(first, CONFIG_DIR_NAME, "plugins")); + + spy.mockReturnValue(second); + expect(getTrustedHomeDir()).toBe(second); + expect(getConfigRootDir()).toBe(path.join(second, CONFIG_DIR_NAME)); + expect(getPluginsDir()).toBe(path.join(second, CONFIG_DIR_NAME, "plugins")); + }); + + it("restores the ambient home once the override is gone", async () => { + const ambient = getTrustedHomeDir(); + const planted = await tempDir(); + const spy = vi.spyOn(os, "homedir").mockReturnValue(planted); + expect(getTrustedHomeDir()).toBe(planted); + + spy.mockRestore(); + expect(getTrustedHomeDir()).toBe(ambient); + expect(getConfigRootDir()).toBe(path.join(ambient, CONFIG_DIR_NAME)); + }); + + it("never anchors user state at a filesystem root", async () => { + // A bare root would place user state at `/.gjc`. It is rejected as a + // candidate, so resolution falls through to the account home rather than + // adopting the root and failing later. + const root = path.parse(process.cwd()).root; + vi.spyOn(os, "homedir").mockReturnValue(root); + + const resolved = getTrustedHomeDir(); + expect(resolved).not.toBe(root); + expect(path.isAbsolute(resolved)).toBe(true); + expect(getConfigRootDir()).toBe(path.join(resolved, CONFIG_DIR_NAME)); + }); + + it("never anchors user state beneath the working directory for a relative home", async () => { + // Bun returns `HOME` verbatim, so a relative value would put the config + // root, agent dir and plugins dir under whatever cwd happens to be. The + // runtime home must be held to the same standard as the account home. + vi.spyOn(os, "homedir").mockReturnValue("relative/evil"); + + const resolved = getTrustedHomeDir(); + expect(path.isAbsolute(resolved)).toBe(true); + expect(resolved).not.toBe("relative/evil"); + expect(resolved).not.toBe(path.resolve("relative/evil")); + expect(getConfigRootDir().startsWith(process.cwd() + path.sep)).toBe(false); + expect(getPluginsDir().startsWith(process.cwd() + path.sep)).toBe(false); + }); + + it("rejects every spelling of a filesystem root, not just the canonical one", async () => { + // A root has many spellings. Comparing the raw string against + // `path.parse(home).root` misses `/.`, `//`, `/..` and `/foo/..`, and + // `path.join(home, ".gjc")` turns every one of them into `/.gjc`. + const root = path.parse(process.cwd()).root; + for (const alias of ["/.", "//", "/..", "/foo/..", "/./", "/../.."]) { + const spy = vi.spyOn(os, "homedir").mockReturnValue(alias); + const resolved = getTrustedHomeDir(); + expect(resolved).not.toBe(alias); + expect(path.resolve(resolved)).not.toBe(root); + expect(getConfigRootDir()).not.toBe(path.join(root, CONFIG_DIR_NAME)); + spy.mockRestore(); + } + }); + + it("resolves an empty runtime home to an absolute account home", async () => { + // An empty string is neither absolute nor a root; it must not survive as a + // candidate and produce a bare `/.gjc`. + vi.spyOn(os, "homedir").mockReturnValue(""); + + const resolved = getTrustedHomeDir(); + expect(path.isAbsolute(resolved)).toBe(true); + expect(resolved).not.toBe(path.parse(process.cwd()).root); + }); + + // The two agent-directory lanes run out of process. `setAgentDir` can only + // install an *override* resolver, so an in-process restore would latch + // `#agentDirOverride` on a resolver that started out as the default and pin + // later tests to a deleted temp dir. A subprocess cannot leak into this + // worker's module-level resolver at all, which is what the `before` snapshot + // in each result proves. + it("pins an operator override and re-roots a default agent dir, without touching this worker", async () => { + // Both lanes and the isolation check run in one ordered test so the parent + // agent directory is observed directly before and after the probes. + // `getConfigRootDir()` cannot stand in for that: it is home-derived and + // stays correct even when `#agentDirOverride` is latched, so it would pass + // against exactly the defect this is meant to rule out. + const parentAgentDirBefore = getAgentDir(); + + // Lane 1: `GJC_CODING_AGENT_DIR` is an explicit operator selection, not a + // home-derived path. Re-deriving the home must not drag it around. + const override = await tempDir(); + const overrideSecondHome = await tempDir(); + const pinned = await probe({ agentDirOverride: override, secondHome: overrideSecondHome }); + + expect(pinned.overrideDeclared).toBe(override); + expect(pinned.before.agentDir).toBe(override); + // The home moved, so config root follows; the operator's agent dir does not. + expect(pinned.after.trustedHome).toBe(overrideSecondHome); + expect(pinned.after.configRoot).toBe(path.join(overrideSecondHome, CONFIG_DIR_NAME)); + expect(pinned.after.agentDir).toBe(override); + expect(pinned.after.agentDb).toBe(pinned.before.agentDb); + + // Lane 2: without an override the agent dir is home-derived, so user-scope + // skills (`/skills`) and MCP (`/mcp.json`) must follow + // the resolved home. This is the discovery path that regressed. + const firstHome = await tempDir(); + const secondHome = await tempDir(); + const rerooted = await probe({ agentDirOverride: null, secondHome, home: firstHome }); + + expect(rerooted.overrideDeclared).toBeNull(); + expect(rerooted.before.trustedHome).toBe(firstHome); + expect(rerooted.before.agentDir).toBe(path.join(firstHome, CONFIG_DIR_NAME, "agent")); + expect(rerooted.after.trustedHome).toBe(secondHome); + expect(rerooted.after.agentDir).toBe(path.join(secondHome, CONFIG_DIR_NAME, "agent")); + expect(rerooted.after.configRoot).toBe(path.join(secondHome, CONFIG_DIR_NAME)); + + // Isolation: neither child mutated this worker's resolver. Asserting the + // agent directory itself is what proves no override was latched here. + expect(getAgentDir()).toBe(parentAgentDirBefore); + const ambient = getTrustedHomeDir(); + expect(getConfigRootDir()).toBe(path.join(ambient, CONFIG_DIR_NAME)); + }); + + it("keeps an agent directory on one storage lane across a home refresh", async () => { + // Naming the default profile explicitly IS the default profile, XDG included + // (pinned by dirs-python-gateway.test.ts). What must never happen is a lane + // *change*: an agent directory decided at construction must not be re-decided + // from path shape because a home refresh made it coincide with the new + // default. `getAgentDir()` would look unchanged while `agent.db` moved into + // `$XDG_DATA_HOME/gjc` -- the same store read through two roots. + const firstHome = await tempDir(); + const secondHome = await tempDir(); + const xdgDataHome = await tempDir(); + await fs.mkdir(path.join(xdgDataHome, "gjc"), { recursive: true }); + // Not the default under the startup home, but exactly the default under the + // home the resolver refreshes to. + const override = path.join(secondHome, CONFIG_DIR_NAME, "agent"); + await fs.mkdir(override, { recursive: true }); + + const probed = await probe({ agentDirOverride: override, secondHome, home: firstHome, xdgDataHome }); + + expect(probed.before.agentDir).toBe(override); + expect(probed.before.agentDb).toBe(path.join(override, "agent.db")); + expect(probed.after.agentDir).toBe(override); + expect(probed.after.agentDb).toBe(probed.before.agentDb); + expect(probed.after.agentDb).not.toBe(path.join(xdgDataHome, "gjc", "agent.db")); + }); + + it("keeps an XDG-eligible agent dir on XDG after a home refresh makes it non-default", async () => { + // The converse of the case above. A dir that WAS the default at construction + // stays XDG-eligible even once a home refresh makes its path no longer equal + // the default. Without stickiness the recomputation flips it off the XDG lane + // mid-process, so `agent.db` would move out of `$XDG_DATA_HOME/gjc` while the + // agent dir itself never changed. + const firstHome = await tempDir(); + const secondHome = await tempDir(); + const xdgDataHome = await tempDir(); + await fs.mkdir(path.join(xdgDataHome, "gjc"), { recursive: true }); + // Exactly the default under the *startup* home, so it starts XDG-eligible; + // after the refresh to `secondHome` the same path is no longer the default. + const agentDir = path.join(firstHome, CONFIG_DIR_NAME, "agent"); + await fs.mkdir(agentDir, { recursive: true }); + + const probed = await probe({ agentDirOverride: agentDir, secondHome, home: firstHome, xdgDataHome }); + + expect(probed.before.agentDir).toBe(agentDir); + expect(probed.before.agentDb).toBe(path.join(xdgDataHome, "gjc", "agent.db")); + // The home moved and the path is no longer default-shaped, but the lane holds. + expect(probed.after.agentDir).toBe(agentDir); + expect(probed.after.agentDb).toBe(probed.before.agentDb); + }); + + it("puts a parent and its child on the same storage lane for one profile", async () => { + // `setAgentDir()` exports `GJC_CODING_AGENT_DIR`, so a child inherits the + // exact value the parent set programmatically and cannot distinguish the two. + // If an inherited agent dir equal to the default were treated as "not the + // default profile", parent and child would read one logical store through two + // different lanes -- the parent under `$XDG_STATE_HOME/gjc`, the child under + // `` -- silently splitting live state in half. + const home = await tempDir(); + const xdgStateHome = await tempDir(); + await fs.mkdir(path.join(xdgStateHome, "gjc"), { recursive: true }); + const defaultAgent = path.join(home, CONFIG_DIR_NAME, "agent"); + await fs.mkdir(defaultAgent, { recursive: true }); + + const read = async (env: Record): Promise => { + const childEnv: Record = {}; + for (const [key, value] of Object.entries(process.env)) { + if (value !== undefined) childEnv[key] = value; + } + delete childEnv.GJC_CODING_AGENT_DIR; + delete childEnv.PI_CODING_AGENT_DIR; + delete childEnv.GJC_CONFIG_DIR; + delete childEnv.PI_CONFIG_DIR; + Object.assign(childEnv, env); + const source = `import { getPythonGatewayDir } from ${JSON.stringify(DIRS)};\nconsole.log(getPythonGatewayDir());`; + const proc = Bun.spawn([process.execPath, "-e", source], { env: childEnv, stdout: "pipe", stderr: "pipe" }); + const [out, err] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]); + if ((await proc.exited) !== 0) throw new Error(`probe failed: ${err}`); + return out.trim().split("\n").at(-1) ?? ""; + }; + + const withoutOverride = await read({ HOME: home, XDG_STATE_HOME: xdgStateHome }); + const withInherited = await read({ + HOME: home, + XDG_STATE_HOME: xdgStateHome, + GJC_CODING_AGENT_DIR: defaultAgent, + }); + + expect(withInherited).toBe(withoutOverride); + }); + + it("honors an explicit non-authoritative home for plugins without moving the resolver", async () => { + // `getPluginsDir(home)` is the documented escape hatch for callers that + // carry their own home. It must not disturb the authoritative resolution. + const planted = await tempDir(); + vi.spyOn(os, "homedir").mockReturnValue(planted); + const explicit = await tempDir(); + + expect(getPluginsDir(explicit)).toBe(path.join(explicit, CONFIG_DIR_NAME, "plugins")); + // Passing the authoritative home is identical to the no-arg form. + expect(getPluginsDir(planted)).toBe(getPluginsDir()); + expect(getTrustedHomeDir()).toBe(planted); + }); + + it("short-circuits an explicit plugin home when authoritative home is unavailable", async () => { + const explicit = await tempDir(); + const root = path.parse(process.cwd()).root; + vi.spyOn(os, "homedir").mockReturnValue(root); + vi.spyOn(os, "userInfo").mockImplementation(() => { + throw new Error("account identity unavailable"); + }); + if (process.platform !== "win32") vi.spyOn(process, "geteuid").mockReturnValue(65534); + + expect(getPluginsDir(explicit)).toBe(path.join(explicit, CONFIG_DIR_NAME, "plugins")); + }); + + it("never treats the project directory as the home", async () => { + // Project scope (`/.gjc`) and user scope (`/.gjc`) must stay + // distinct: collapsing them is what makes a checkout's `.gjc` readable as + // trusted user state. + const planted = await tempDir(); + vi.spyOn(os, "homedir").mockReturnValue(planted); + expect(getConfigRootDir()).not.toBe(path.join(process.cwd(), CONFIG_DIR_NAME)); + expect(getTrustedHomeDir()).not.toBe(process.cwd()); + }); +});