From db780b1a98b8bf3b5506cee9274f8d6ad9051843 Mon Sep 17 00:00:00 2001 From: Yeachan Heo Date: Thu, 20 Aug 2026 19:04:20 +0000 Subject: [PATCH 01/10] fix(utils): resolve the authoritative home at call time The trusted-home provenance hardening anchored getTrustedHomeDir() to a value captured when dirs.ts loaded. Every user-scope path derives from it, so a home established or changed after module load silently resolved elsewhere: user-scope skills under ~/.gjc/agent/skills and user-scope MCP servers under ~/.gjc/agent/mcp.json stopped being discovered entirely. Provenance decides which candidate home may be honored; call-time resolution decides when it is read. Freezing the result bought the first by giving up the second. The home is now re-derived per access with cached paths rebuilt when it changes, and the dotenv-ambiguity rule is applied unchanged at each resolution. Folded in while hardening the same resolver: - The runtime home is validated like the account home. Bun returns HOME verbatim, so a relative value anchored user state under the working directory. Root detection normalizes first, because /., //, /foo/.. and C:\x\.. are all roots that a raw comparison misses. The original spelling is returned so both sides of the dotenv ambiguity check stay comparable. - The Linux account home comes from NSS (getent passwd) instead of parsing /etc/passwd, which missed LDAP/SSSD accounts and fell through to os.userInfo().homedir -- the very $HOME-derived value the lookup exists to reject. The lookup is consulted lazily, so an unambiguous runtime home never pays for a spawn on a path that runs on every directory access. - Only environment-independent evidence is memoized, and provenance travels with it, so a cached env-derived home can never later be promoted to independent evidence once the runtime home moves. - Independence is a property of the source, not string inequality. An NSS answer that agrees with HOME is corroboration; refusing it locked out any operator whose HOME matched their account entry once a checkout declared HOME dynamically. - XDG eligibility is decided once and made sticky, so an agent directory cannot change storage lane when a home refresh makes its path coincide with the new default. Lore-id: 4f1a7c92 Constraint: setAgentDir() selects the default profile, XDG included -- dirs-python-gateway.test.ts pins it Constraint: #4773's fail-closed contract (now merged via #4779) is preserved, not weakened or restated Rejected: keep the snapshot and rewrite the failing tests | the tests are a proxy for real user-scope discovery, not the defect Confidence: high Scope-risk: wide Reversibility: easy Tested: each fix reverted independently fails exactly its own regressions Tested: utils 395 pristine+ambient (incl. #4779's failopen suite), skills 36, MCP redteam 16, both checks clean Tested: 12-shape dotenv provenance matrix, zero bypasses Not-tested: a live LDAP/SSSD identity; NSS behavior is pinned against getent --- packages/utils/CHANGELOG.md | 4 + packages/utils/src/dirs.ts | 264 ++++++++++---- packages/utils/test/account-home-nss.test.ts | 233 +++++++++++++ packages/utils/test/agent-dir-trust.test.ts | 23 +- .../test/fixtures/agent-dir-override-probe.ts | 40 +++ .../test/trusted-home-resolution.test.ts | 330 ++++++++++++++++++ 6 files changed, 822 insertions(+), 72 deletions(-) create mode 100644 packages/utils/test/account-home-nss.test.ts create mode 100644 packages/utils/test/fixtures/agent-dir-override-probe.ts create mode 100644 packages/utils/test/trusted-home-resolution.test.ts diff --git a/packages/utils/CHANGELOG.md b/packages/utils/CHANGELOG.md index 6bb39b4af4..ab40ab3a29 100644 --- a/packages/utils/CHANGELOG.md +++ b/packages/utils/CHANGELOG.md @@ -5,6 +5,10 @@ - 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 +- 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 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. - 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. diff --git a/packages/utils/src/dirs.ts b/packages/utils/src/dirs.ts index 578a754f3b..2941148cec 100644 --- a/packages/utils/src/dirs.ts +++ b/packages/utils/src/dirs.ts @@ -231,22 +231,146 @@ function trustedValue( return value; } -function accountHomeFromSystem(): string | undefined { +/** + * 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 NSS answer cannot change during a process lifetime, so caching it 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. + */ +let accountHomeCache: { home: string; envDerived: boolean } | undefined; + +/** 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(): { home: string; envDerived: boolean } | undefined { + if (accountHomeCache !== undefined) return accountHomeCache; try { const info = os.userInfo(); 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(info.uid); + if (nss !== undefined) { + // NSS is environment-independent and stable: safe to memoize. + accountHomeCache = { home: nss, envDerived: false }; + return accountHomeCache; + } } - const home = info.homedir; - if (home && path.isAbsolute(home) && home !== path.parse(home).root) return home; + // `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 {} 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 +381,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 +397,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/. @@ -292,46 +418,37 @@ class DirResolver { 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.#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; + // Naming the default agent profile explicitly selects the default profile, + // XDG categories included: `setAgentDir(//agent)` is how a + // caller returns to it, and `dirs-python-gateway.test.ts` pins that. So the + // initial decision is path equality. 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 defaults to a path comparison, but an explicitly selected agent directory + * must never switch storage lanes just because it happens to equal the + * home-derived default — callers that know the override state pass it in. + */ private refreshCategoryDirs( snapshot: { values: Record; dynamic: Set }, - isDefault = this.agentDir === path.join(this.configRoot, "agent"), + isDefault = !this.#agentDirOverride && this.agentDir === path.join(this.configRoot, "agent"), ): void { let xdgData: string | undefined; let xdgState: string | undefined; @@ -363,23 +480,42 @@ 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 = + const nextConfigDirName = 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 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 +551,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 +569,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,7 +580,14 @@ 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; @@ -521,14 +660,15 @@ 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) { + if (home !== undefined && home !== dirs.trustedHome) { return path.join(home, getConfigDirName(), "plugins"); } 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..166e37e09a --- /dev/null +++ b/packages/utils/test/account-home-nss.test.ts @@ -0,0 +1,233 @@ +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[] = []; + +afterEach(async () => { + await Promise.all(scratch.splice(0).map(file => fs.rm(file, { 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. */ +async function nssHome(): Promise { + 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; +} + +describe("account home is resolved through the OS account database", () => { + it("reports the same home NSS does, not the inherited environment", async () => { + if (process.platform !== "linux") return; + const account = await nssHome(); + if (!account) return; // No NSS account 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); + }); + + it("ignores a hostile home that the account database contradicts", async () => { + if (process.platform !== "linux") return; + const account = await nssHome(); + if (!account) return; + + const hostile = await Bun.$`mktemp -d`.text().then(out => out.trim()); + 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 Bun.$`rm -rf ${hostile}`.quiet(); + } + }); + + it("never promotes a cached environment-derived home to independent evidence", async () => { + if (process.platform !== "linux") return; + // 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 Bun.$`mktemp -d`.text()).trim(); + const project = (await Bun.$`mktemp -d`.text()).trim(); + 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 { return "REFUSED"; } };', + "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 Bun.$`rm -rf ${attacker} ${project}`.quiet(); + } + }); + + it("still resolves an absolute home when the NSS front end is unavailable", async () => { + if (process.platform !== "linux") return; + // 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 Bun.$`mktemp -d`.text()).trim(); + 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 Bun.$`mktemp -d`.text()).trim(); + 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 Bun.$`rm -rf ${home}`.quiet(); + } finally { + await Bun.$`rm -rf ${work}`.quiet(); + } + }); + + it("accepts an NSS home that corroborates the runtime home", async () => { + if (process.platform !== "linux") return; + const account = await nssHome(); + if (!account) return; + + // 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 Bun.$`mktemp -d`.text()).trim(); + 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 Bun.$`rm -rf ${project}`.quiet(); + } + }); + + 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 Bun.$`mktemp -d`.text()).trim(); + const real = path.join(base, "attacker"); + const aliased = path.join(base, "decoy", "..", "attacker"); + const project = (await Bun.$`mktemp -d`.text()).trim(); + try { + await Bun.$`mkdir -p ${real} ${path.join(base, "decoy")}`.quiet(); + 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 Bun.$`rm -rf ${base} ${project}`.quiet(); + } + }); +}); diff --git a/packages/utils/test/agent-dir-trust.test.ts b/packages/utils/test/agent-dir-trust.test.ts index add27b374d..be4ec1111b 100644 --- a/packages/utils/test/agent-dir-trust.test.ts +++ b/packages/utils/test/agent-dir-trust.test.ts @@ -92,15 +92,18 @@ 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. + 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; + } } return os.userInfo().homedir; } @@ -231,7 +234,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 +245,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..e53febbb20 --- /dev/null +++ b/packages/utils/test/trusted-home-resolution.test.ts @@ -0,0 +1,330 @@ +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"); + +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 operator override on its storage lane when it equals the new default path", async () => { + // An explicit `GJC_CODING_AGENT_DIR` that happens to equal + // `/.gjc/agent` must not start following `$XDG_DATA_HOME` after a + // home refresh: the agent dir would look unchanged while `agent.db` silently + // moved to `$XDG_DATA_HOME/gjc/agent.db`. Only a genuinely default agent dir + // may follow XDG. + const firstHome = await tempDir(); + const secondHome = await tempDir(); + const xdgDataHome = await tempDir(); + await fs.mkdir(path.join(xdgDataHome, "gjc"), { recursive: true }); + // The override is exactly the default path under the *second* home. + 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 }); + + // At startup, before any home refresh: the constructor must decide XDG + // eligibility from the override state, not from path equality. Asserting + // only the post-refresh lane leaves the constructor free to route an + // operator-selected agent dir into `$XDG_DATA_HOME` on first resolution. + expect(probed.before.agentDir).toBe(override); + expect(probed.before.agentDb).toBe(path.join(override, "agent.db")); + expect(probed.before.agentDb).not.toBe(path.join(xdgDataHome, "gjc", "agent.db")); + + expect(probed.after.agentDir).toBe(override); + expect(probed.after.agentDb).toBe(path.join(override, "agent.db")); + expect(probed.after.agentDb).not.toBe(path.join(xdgDataHome, "gjc", "agent.db")); + }); + + 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 that was not XDG-eligible at construction must + // not become eligible because a home refresh made its path coincide with the + // new default. `getAgentDir()` would look unchanged while `agent.db` moved + // into `$XDG_DATA_HOME/gjc`. + 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")); + // After the refresh the path now equals `/.gjc/agent`, so a + // path-shape recomputation would flip it onto the XDG lane. + 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("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("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()); + }); +}); From 90027f11f557a97c0c1d3ae4c70f71c69d6eec4a Mon Sep 17 00:00:00 2001 From: Yeachan Heo Date: Thu, 20 Aug 2026 19:22:51 +0000 Subject: [PATCH 02/10] fix(utils): keep an inherited agent dir off XDG when it equals the default An agent directory inherited from GJC_CODING_AGENT_DIR names one specific directory, but eligibility was decided by path equality alone, so an override equal to /.gjc/agent was routed into $XDG_DATA_HOME/gjc from the first resolution -- getAgentDir() reported the named directory while agent.db lived elsewhere. setAgentDir() is the opposite statement: it re-selects the default profile, XDG included, which dirs-python-gateway.test.ts pins. The two arrive at the same constructor, so the caller now says which it means instead of the resolver guessing from path shape. Lore-id: e17b3c58 Confidence: high Scope-risk: narrow Reversibility: easy Tested: reverting to path-equality-only fails exactly the new startup case Tested: dirs-python-gateway 3/0 unchanged; utils 396 pristine+ambient --- packages/utils/src/dirs.ts | 34 ++++++++++++++----- .../test/trusted-home-resolution.test.ts | 23 +++++++++++++ 2 files changed, 49 insertions(+), 8 deletions(-) diff --git a/packages/utils/src/dirs.ts b/packages/utils/src/dirs.ts index 2941148cec..4ce67567c8 100644 --- a/packages/utils/src/dirs.ts +++ b/packages/utils/src/dirs.ts @@ -412,7 +412,13 @@ class DirResolver { readonly #rootCache = new Map(); readonly #agentCache = new Map(); - constructor(agentDirOverride?: string, snapshot = projectEnvSnapshot()) { + constructor( + agentDirOverride?: string, + snapshot = projectEnvSnapshot(), + // `setAgentDir()` names a profile; an inherited `GJC_CODING_AGENT_DIR` names a + // directory. Only the former re-selects the default profile by equality. + options: { defaultProfileWhenEqual?: boolean } = {}, + ) { this.#projectEnv = snapshot; this.#configDirName = sanitizeConfigDirName(trustedValue("GJC_CONFIG_DIR", snapshot)) ?? @@ -424,11 +430,14 @@ class DirResolver { const defaultAgent = path.join(this.configRoot, "agent"); this.#agentDirOverride = Boolean(agentDirOverride); this.agentDir = agentDirOverride ? path.resolve(agentDirOverride) : defaultAgent; - // Naming the default agent profile explicitly selects the default profile, - // XDG categories included: `setAgentDir(//agent)` is how a - // caller returns to it, and `dirs-python-gateway.test.ts` pins that. So the - // initial decision is path equality. - const isDefault = this.agentDir === defaultAgent; + // An agent directory inherited from the environment is an explicit selection + // of one directory, so it never follows `$XDG_*_HOME` -- not even when it + // equals the default path, which would otherwise route `agent.db` into + // `$XDG_DATA_HOME/gjc` while `getAgentDir()` still reported the named + // directory. `setAgentDir()` is the opposite statement: it re-selects the + // default *profile*, XDG included (pinned by `dirs-python-gateway.test.ts`). + const isDefault = + this.agentDir === defaultAgent && (!this.#agentDirOverride || options.defaultProfileWhenEqual === true); // 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 @@ -600,9 +609,18 @@ export function getTrustedConfigRootDir(): string { 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. + * + * Naming the default agent path through this entry point *selects the default + * profile*, XDG categories included -- that is how a caller returns to it, and + * `dirs-python-gateway.test.ts` pins it. An agent directory inherited from the + * environment is a different statement: it names one specific directory, so it + * keeps its own storage lane even when it happens to equal the default. + */ export function setAgentDir(dir: string): void { - dirs = new DirResolver(dir, dirs.trustSnapshot); + dirs = new DirResolver(dir, dirs.trustSnapshot, { defaultProfileWhenEqual: true }); process.env.GJC_CODING_AGENT_DIR = dir; } diff --git a/packages/utils/test/trusted-home-resolution.test.ts b/packages/utils/test/trusted-home-resolution.test.ts index e53febbb20..660483c745 100644 --- a/packages/utils/test/trusted-home-resolution.test.ts +++ b/packages/utils/test/trusted-home-resolution.test.ts @@ -305,6 +305,29 @@ describe("authoritative home resolution", () => { expect(probed.after.agentDb).not.toBe(path.join(xdgDataHome, "gjc", "agent.db")); }); + it("keeps an inherited agent dir off XDG at startup when it equals the default path", async () => { + // An agent directory inherited from the environment names one directory. If + // eligibility were decided by path equality alone, an override that happens + // to equal `/.gjc/agent` would be routed into `$XDG_DATA_HOME/gjc` from + // the very first resolution: `getAgentDir()` would report the named directory + // while `agent.db` lived somewhere else entirely. + // + // `setAgentDir()` is the opposite statement -- it re-selects the default + // profile, XDG included -- and that contract stays pinned by + // `dirs-python-gateway.test.ts`. + const home = await tempDir(); + const xdgDataHome = await tempDir(); + await fs.mkdir(path.join(xdgDataHome, "gjc"), { recursive: true }); + const override = path.join(home, CONFIG_DIR_NAME, "agent"); + await fs.mkdir(override, { recursive: true }); + + const probed = await probe({ agentDirOverride: override, secondHome: await tempDir(), home, xdgDataHome }); + + expect(probed.before.agentDir).toBe(override); + expect(probed.before.agentDb).toBe(path.join(override, "agent.db")); + expect(probed.before.agentDb).not.toBe(path.join(xdgDataHome, "gjc", "agent.db")); + }); + 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. From 7a95bbe06c0e15e77cfbf79150f9813027cf9ed5 Mon Sep 17 00:00:00 2001 From: Yeachan Heo Date: Thu, 20 Aug 2026 20:08:35 +0000 Subject: [PATCH 03/10] test(utils): survive a host without getent Bun.spawn throws when the executable is missing rather than returning a non-zero exit, so the NSS helpers took the suite down on a minimal or distroless host instead of falling through to the portable path. Both helpers now degrade, matching the production guard they mirror. Lore-id: d59e02a7 Confidence: high Scope-risk: narrow Reversibility: easy Tested: both suites 19/0 with getent present and with the lookup pointed at a non-existent binary --- packages/utils/test/account-home-nss.test.ts | 23 +++++++++++++++----- packages/utils/test/agent-dir-trust.test.ts | 19 ++++++++++------ 2 files changed, 29 insertions(+), 13 deletions(-) diff --git a/packages/utils/test/account-home-nss.test.ts b/packages/utils/test/account-home-nss.test.ts index 166e37e09a..f93742c669 100644 --- a/packages/utils/test/account-home-nss.test.ts +++ b/packages/utils/test/account-home-nss.test.ts @@ -46,13 +46,24 @@ async function resolveWith(env: Record, cwd = import return stdout.trim().split("\n").at(-1) ?? ""; } -/** The account home as NSS reports it, independent of any environment variable. */ +/** + * 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 { - 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; + 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; + } } describe("account home is resolved through the OS account database", () => { diff --git a/packages/utils/test/agent-dir-trust.test.ts b/packages/utils/test/agent-dir-trust.test.ts index be4ec1111b..182f748e53 100644 --- a/packages/utils/test/agent-dir-trust.test.ts +++ b/packages/utils/test/agent-dir-trust.test.ts @@ -97,13 +97,18 @@ async function accountHomeOfRunningUser(): Promise { // 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. - 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; - } + // `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; } From 20cdd61d8a33c46031075763de5e28ba833da7bc Mon Sep 17 00:00:00 2001 From: Yeachan Heo Date: Thu, 20 Aug 2026 21:07:54 +0000 Subject: [PATCH 04/10] test(utils): make the account-home suite portable The suite shelled out to mktemp/rm -rf/mkdir -p, which do not exist on Windows, so the traversal-provenance assertion and its neighbours could not run there at all. Replaced with node:fs/promises throughout, keeping every assertion cross-platform rather than skipping the platform. Lore-id: f6a3d81c Confidence: high Scope-risk: narrow Reversibility: easy Tested: account-home-nss 6/0; utils 396/0 pristine and ambient HOME --- packages/utils/test/account-home-nss.test.ts | 39 ++++++++++++-------- 1 file changed, 23 insertions(+), 16 deletions(-) diff --git a/packages/utils/test/account-home-nss.test.ts b/packages/utils/test/account-home-nss.test.ts index f93742c669..56f6053385 100644 --- a/packages/utils/test/account-home-nss.test.ts +++ b/packages/utils/test/account-home-nss.test.ts @@ -24,8 +24,15 @@ 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(file => fs.rm(file, { force: true }))); + 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. */ @@ -82,13 +89,13 @@ describe("account home is resolved through the OS account database", () => { const account = await nssHome(); if (!account) return; - const hostile = await Bun.$`mktemp -d`.text().then(out => out.trim()); + 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 Bun.$`rm -rf ${hostile}`.quiet(); + await fs.rm(hostile, { recursive: true, force: true }); } }); @@ -104,8 +111,8 @@ describe("account home is resolved through the OS account database", () => { // // A dynamic dotenv declaration keeps `ambiguousHome` true throughout, so the // resolver must fail closed both before and after the home moves. - const attacker = (await Bun.$`mktemp -d`.text()).trim(); - const project = (await Bun.$`mktemp -d`.text()).trim(); + 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 @@ -154,7 +161,7 @@ describe("account home is resolved through the OS account database", () => { expect(first).toBe("REFUSED"); expect(second).toBe("REFUSED"); } finally { - await Bun.$`rm -rf ${attacker} ${project}`.quiet(); + await Promise.all([attacker, project].map(dir => fs.rm(dir, { recursive: true, force: true }))); } }); @@ -164,7 +171,7 @@ describe("account home is resolved through the OS account database", () => { // 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 Bun.$`mktemp -d`.text()).trim(); + 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"],'); @@ -181,7 +188,7 @@ describe("account home is resolved through the OS account database", () => { // 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 Bun.$`mktemp -d`.text()).trim(); + const home = await tempDir(); const env: Record = {}; for (const [key, value] of Object.entries(process.env)) { if (value !== undefined) env[key] = value; @@ -196,9 +203,9 @@ describe("account home is resolved through the OS account database", () => { const resolved = stdout.trim().split("\n").at(-1) ?? ""; expect(resolved).toBe(home); expect(path.isAbsolute(resolved)).toBe(true); - await Bun.$`rm -rf ${home}`.quiet(); + await fs.rm(home, { recursive: true, force: true }); } finally { - await Bun.$`rm -rf ${work}`.quiet(); + await fs.rm(work, { recursive: true, force: true }); } }); @@ -213,13 +220,13 @@ describe("account home is resolved through the OS account database", () => { // 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 Bun.$`mktemp -d`.text()).trim(); + 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 Bun.$`rm -rf ${project}`.quiet(); + await fs.rm(project, { recursive: true, force: true }); } }); @@ -227,18 +234,18 @@ describe("account home is resolved through the OS account database", () => { // 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 Bun.$`mktemp -d`.text()).trim(); + const base = await tempDir(); const real = path.join(base, "attacker"); const aliased = path.join(base, "decoy", "..", "attacker"); - const project = (await Bun.$`mktemp -d`.text()).trim(); + const project = await tempDir(); try { - await Bun.$`mkdir -p ${real} ${path.join(base, "decoy")}`.quiet(); + 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 Bun.$`rm -rf ${base} ${project}`.quiet(); + await Promise.all([base, project].map(dir => fs.rm(dir, { recursive: true, force: true }))); } }); }); From 191d942dabe665dd034a019819e653e61d173a59 Mon Sep 17 00:00:00 2001 From: Yeachan Heo Date: Thu, 20 Aug 2026 21:34:41 +0000 Subject: [PATCH 05/10] fix(utils): keep one agent profile on one storage lane Reverts the override-state XDG gating added earlier in this branch. It answered a narrow review complaint but was unobservably wrong: setAgentDir() exports GJC_CODING_AGENT_DIR, so a child process inherits the exact value the parent set programmatically and cannot distinguish the two. Treating the inherited form as "not the default profile" put parent and child on different lanes for one logical profile -- parent reading $XDG_STATE_HOME/gjc/python-gateway, child reading /python-gateway -- silently splitting a live store in half. An agent directory equal to the home-derived default is the default profile, however it arrived. The sticky #xdgEligible decision is retained, so a lane still cannot change mid-process when a home refresh makes the path coincide. Lore-id: 8a05fe23 Constraint: parent and child must resolve one profile to one lane Rejected: gate XDG on override state | splits a live store across processes Confidence: high Scope-risk: wide Reversibility: easy Tested: reapplying the override-state gating fails the new parent/child case Tested: utils 396/0 pristine+ambient, skills 36/0, MCP 16/0, checks clean --- packages/utils/src/dirs.ts | 37 +++++------- .../test/trusted-home-resolution.test.ts | 58 ++++++++++++------- 2 files changed, 54 insertions(+), 41 deletions(-) diff --git a/packages/utils/src/dirs.ts b/packages/utils/src/dirs.ts index 4ce67567c8..b854f7e3ad 100644 --- a/packages/utils/src/dirs.ts +++ b/packages/utils/src/dirs.ts @@ -412,13 +412,7 @@ class DirResolver { readonly #rootCache = new Map(); readonly #agentCache = new Map(); - constructor( - agentDirOverride?: string, - snapshot = projectEnvSnapshot(), - // `setAgentDir()` names a profile; an inherited `GJC_CODING_AGENT_DIR` names a - // directory. Only the former re-selects the default profile by equality. - options: { defaultProfileWhenEqual?: boolean } = {}, - ) { + constructor(agentDirOverride?: string, snapshot = projectEnvSnapshot()) { this.#projectEnv = snapshot; this.#configDirName = sanitizeConfigDirName(trustedValue("GJC_CONFIG_DIR", snapshot)) ?? @@ -430,14 +424,18 @@ class DirResolver { const defaultAgent = path.join(this.configRoot, "agent"); this.#agentDirOverride = Boolean(agentDirOverride); this.agentDir = agentDirOverride ? path.resolve(agentDirOverride) : defaultAgent; - // An agent directory inherited from the environment is an explicit selection - // of one directory, so it never follows `$XDG_*_HOME` -- not even when it - // equals the default path, which would otherwise route `agent.db` into - // `$XDG_DATA_HOME/gjc` while `getAgentDir()` still reported the named - // directory. `setAgentDir()` is the opposite statement: it re-selects the - // default *profile*, XDG included (pinned by `dirs-python-gateway.test.ts`). - const isDefault = - this.agentDir === defaultAgent && (!this.#agentDirOverride || options.defaultProfileWhenEqual === true); + // 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 @@ -613,14 +611,11 @@ export function getTrustedConfigRootDir(): string { * Set the coding agent directory. Creates a fresh resolver, invalidating all * cached paths. * - * Naming the default agent path through this entry point *selects the default - * profile*, XDG categories included -- that is how a caller returns to it, and - * `dirs-python-gateway.test.ts` pins it. An agent directory inherited from the - * environment is a different statement: it names one specific directory, so it - * keeps its own storage lane even when it happens to equal the default. + * 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, { defaultProfileWhenEqual: true }); + dirs = new DirResolver(dir, dirs.trustSnapshot); process.env.GJC_CODING_AGENT_DIR = dir; } diff --git a/packages/utils/test/trusted-home-resolution.test.ts b/packages/utils/test/trusted-home-resolution.test.ts index 660483c745..21de1d49af 100644 --- a/packages/utils/test/trusted-home-resolution.test.ts +++ b/packages/utils/test/trusted-home-resolution.test.ts @@ -12,6 +12,7 @@ import { } 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; @@ -305,27 +306,44 @@ describe("authoritative home resolution", () => { expect(probed.after.agentDb).not.toBe(path.join(xdgDataHome, "gjc", "agent.db")); }); - it("keeps an inherited agent dir off XDG at startup when it equals the default path", async () => { - // An agent directory inherited from the environment names one directory. If - // eligibility were decided by path equality alone, an override that happens - // to equal `/.gjc/agent` would be routed into `$XDG_DATA_HOME/gjc` from - // the very first resolution: `getAgentDir()` would report the named directory - // while `agent.db` lived somewhere else entirely. - // - // `setAgentDir()` is the opposite statement -- it re-selects the default - // profile, XDG included -- and that contract stays pinned by - // `dirs-python-gateway.test.ts`. + 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 xdgDataHome = await tempDir(); - await fs.mkdir(path.join(xdgDataHome, "gjc"), { recursive: true }); - const override = path.join(home, CONFIG_DIR_NAME, "agent"); - await fs.mkdir(override, { recursive: true }); - - const probed = await probe({ agentDirOverride: override, secondHome: await tempDir(), home, xdgDataHome }); - - expect(probed.before.agentDir).toBe(override); - expect(probed.before.agentDb).toBe(path.join(override, "agent.db")); - expect(probed.before.agentDb).not.toBe(path.join(xdgDataHome, "gjc", "agent.db")); + 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 () => { From 60694e75f0b66a34fc35ff3150dcea27654384c2 Mon Sep 17 00:00:00 2001 From: Yeachan Heo Date: Thu, 20 Aug 2026 21:41:16 +0000 Subject: [PATCH 06/10] fix(utils): scope account-home cache by effective uid A process can cross setuid or container identity boundaries without restarting. Reusing one NSS home across those transitions can redirect trusted state into another user's directory. Key the environment-independent cache by effective uid and account identity, retain call-time provenance resolution, and prove A-to-B-to-A behavior with failed and concurrent lookups. Lore-id: 7c5b9e21 Constraint: no trusted-home state may cross effective-uid transitions Constraint: failed NSS lookup must never inherit another uid's cached home Tested: UID transition security probe, utils security suites, full utils test suite Confidence: high Scope-risk: narrow Reversibility: easy Directive: do not cache environment-derived fallback homes Not-tested: Windows native gate --- packages/utils/CHANGELOG.md | 1 + packages/utils/src/dirs.ts | 38 ++++++-- packages/utils/test/account-home-nss.test.ts | 95 ++++++++++++++++++++ 3 files changed, 126 insertions(+), 8 deletions(-) diff --git a/packages/utils/CHANGELOG.md b/packages/utils/CHANGELOG.md index ab40ab3a29..c851711488 100644 --- a/packages/utils/CHANGELOG.md +++ b/packages/utils/CHANGELOG.md @@ -5,6 +5,7 @@ - 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 +- 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). diff --git a/packages/utils/src/dirs.ts b/packages/utils/src/dirs.ts index b854f7e3ad..c1ccaead8e 100644 --- a/packages/utils/src/dirs.ts +++ b/packages/utils/src/dirs.ts @@ -264,7 +264,10 @@ function usableHome(home: string | undefined): string | undefined { * NSS front end, so it resolves local and directory-backed accounts alike. * * Only an **environment-independent** result is memoized, and only on success. - * The NSS answer cannot change during a process lifetime, so caching it is safe. + * 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 @@ -272,7 +275,18 @@ function usableHome(home: string | undefined): string | undefined { * the runtime home -- passing the echo check and being promoted to independent * evidence. Provenance is carried with the value so that can never happen. */ -let accountHomeCache: { home: string; envDerived: boolean } | undefined; +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 { @@ -294,16 +308,19 @@ function nssAccountHome(uid: number): string | undefined { } } -function accountHomeFromSystem(): { home: string; envDerived: boolean } | undefined { - if (accountHomeCache !== undefined) return accountHomeCache; +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 nss = nssAccountHome(info.uid); + const nss = nssAccountHome(identity.uid); if (nss !== undefined) { // NSS is environment-independent and stable: safe to memoize. - accountHomeCache = { home: nss, envDerived: false }; - return accountHomeCache; + const result = { home: nss, envDerived: false }; + accountHomeCache.set(identity.key, result); + return result; } } // `os.userInfo().homedir` is the portable path for macOS and Windows, and on @@ -312,7 +329,12 @@ function accountHomeFromSystem(): { home: string; envDerived: boolean } | undefi // caller can refuse to treat it as independent evidence. const fallback = usableHome(info.homedir); if (fallback !== undefined) return { home: fallback, envDerived: true }; - } catch {} + } 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; } diff --git a/packages/utils/test/account-home-nss.test.ts b/packages/utils/test/account-home-nss.test.ts index 56f6053385..9b422b60d3 100644 --- a/packages/utils/test/account-home-nss.test.ts +++ b/packages/utils/test/account-home-nss.test.ts @@ -73,7 +73,102 @@ async function nssHome(): Promise { } } +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 { return "REFUSED"; } };', + "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; +} + describe("account home is resolved through the OS account database", () => { + it("scopes NSS cache entries by effective UID and never leaks across transitions", async () => { + if (process.platform !== "linux") return; + 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); + }); + it("reports the same home NSS does, not the inherited environment", async () => { if (process.platform !== "linux") return; const account = await nssHome(); From 8ed2bc1df242cb0013afe61c11751b77ccd9b066 Mon Sep 17 00:00:00 2001 From: Yeachan Heo Date: Thu, 20 Aug 2026 22:15:45 +0000 Subject: [PATCH 07/10] test(utils): report skips and drop a duplicated storage-lane case Platform- and NSS-gated cases returned silently, so a vacuous pass looked identical to a real assertion in CI output; they now say why they skipped. Two storage-lane cases had identical setup and overlapping assertions, and one still carried the comment for the override-state gating that was reverted; consolidated into the single lane-stability case. Lore-id: 91c4ad6f Confidence: high Scope-risk: narrow Reversibility: easy Tested: utils 396/0 pristine and ambient HOME --- packages/utils/test/account-home-nss.test.ts | 26 ++++++++----- .../test/trusted-home-resolution.test.ts | 39 ++----------------- 2 files changed, 21 insertions(+), 44 deletions(-) diff --git a/packages/utils/test/account-home-nss.test.ts b/packages/utils/test/account-home-nss.test.ts index 9b422b60d3..b08663d250 100644 --- a/packages/utils/test/account-home-nss.test.ts +++ b/packages/utils/test/account-home-nss.test.ts @@ -151,9 +151,17 @@ async function runUidCacheProbe(homeA: string, homeAAfterMappingChange: string): 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", () => { it("scopes NSS cache entries by effective UID and never leaks across transitions", async () => { - if (process.platform !== "linux") return; + if (process.platform !== "linux") return skip("NSS account lookup is Linux-only"); const homeA = await tempDir(); const homeAAfterMappingChange = await tempDir(); const result = await runUidCacheProbe(homeA, homeAAfterMappingChange); @@ -170,9 +178,9 @@ describe("account home is resolved through the OS account database", () => { }); it("reports the same home NSS does, not the inherited environment", async () => { - if (process.platform !== "linux") return; + if (process.platform !== "linux") return skip("NSS account lookup is Linux-only"); const account = await nssHome(); - if (!account) return; // No NSS account for this uid; nothing to assert against. + 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. @@ -180,9 +188,9 @@ describe("account home is resolved through the OS account database", () => { }); it("ignores a hostile home that the account database contradicts", async () => { - if (process.platform !== "linux") return; + if (process.platform !== "linux") return skip("NSS account lookup is Linux-only"); const account = await nssHome(); - if (!account) return; + if (!account) return skip("no NSS account entry for this uid; nothing to assert against"); const hostile = await tempDir(); try { @@ -195,7 +203,7 @@ describe("account home is resolved through the OS account database", () => { }); it("never promotes a cached environment-derived home to independent evidence", async () => { - if (process.platform !== "linux") return; + if (process.platform !== "linux") return skip("NSS account lookup is Linux-only"); // The failure this guards, reproduced against the real resolver: // // With NSS unavailable the account lookup falls back to @@ -261,7 +269,7 @@ describe("account home is resolved through the OS account database", () => { }); it("still resolves an absolute home when the NSS front end is unavailable", async () => { - if (process.platform !== "linux") return; + if (process.platform !== "linux") return skip("NSS account lookup is Linux-only"); // 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 @@ -305,9 +313,9 @@ describe("account home is resolved through the OS account database", () => { }); it("accepts an NSS home that corroborates the runtime home", async () => { - if (process.platform !== "linux") return; + if (process.platform !== "linux") return skip("NSS account lookup is Linux-only"); const account = await nssHome(); - if (!account) return; + 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 -- diff --git a/packages/utils/test/trusted-home-resolution.test.ts b/packages/utils/test/trusted-home-resolution.test.ts index 21de1d49af..22fa050d3f 100644 --- a/packages/utils/test/trusted-home-resolution.test.ts +++ b/packages/utils/test/trusted-home-resolution.test.ts @@ -250,42 +250,13 @@ describe("authoritative home resolution", () => { expect(getConfigRootDir()).toBe(path.join(ambient, CONFIG_DIR_NAME)); }); - it("keeps an operator override on its storage lane when it equals the new default path", async () => { - // An explicit `GJC_CODING_AGENT_DIR` that happens to equal - // `/.gjc/agent` must not start following `$XDG_DATA_HOME` after a - // home refresh: the agent dir would look unchanged while `agent.db` silently - // moved to `$XDG_DATA_HOME/gjc/agent.db`. Only a genuinely default agent dir - // may follow XDG. - const firstHome = await tempDir(); - const secondHome = await tempDir(); - const xdgDataHome = await tempDir(); - await fs.mkdir(path.join(xdgDataHome, "gjc"), { recursive: true }); - // The override is exactly the default path under the *second* home. - 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 }); - - // At startup, before any home refresh: the constructor must decide XDG - // eligibility from the override state, not from path equality. Asserting - // only the post-refresh lane leaves the constructor free to route an - // operator-selected agent dir into `$XDG_DATA_HOME` on first resolution. - expect(probed.before.agentDir).toBe(override); - expect(probed.before.agentDb).toBe(path.join(override, "agent.db")); - expect(probed.before.agentDb).not.toBe(path.join(xdgDataHome, "gjc", "agent.db")); - - expect(probed.after.agentDir).toBe(override); - expect(probed.after.agentDb).toBe(path.join(override, "agent.db")); - expect(probed.after.agentDb).not.toBe(path.join(xdgDataHome, "gjc", "agent.db")); - }); - 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 that was not XDG-eligible at construction must - // not become eligible because a home refresh made its path coincide with the - // new default. `getAgentDir()` would look unchanged while `agent.db` moved - // into `$XDG_DATA_HOME/gjc`. + // *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(); @@ -299,8 +270,6 @@ describe("authoritative home resolution", () => { expect(probed.before.agentDir).toBe(override); expect(probed.before.agentDb).toBe(path.join(override, "agent.db")); - // After the refresh the path now equals `/.gjc/agent`, so a - // path-shape recomputation would flip it onto the XDG lane. 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")); From bf23f94498f7525ae3008f8fac6b76bc92618f78 Mon Sep 17 00:00:00 2001 From: Yeachan Heo Date: Thu, 20 Aug 2026 22:36:37 +0000 Subject: [PATCH 08/10] test(utils): report non-linux NSS skips explicitly Linux NSS account tests must not look like passing tests on platforms where getent is unavailable. Use Bun's explicit skip reporter for the platform capability while retaining loud capability warnings for missing account entries on Linux. Lore-id: 1d6b8e42 Constraint: non-Linux NSS tests must be reported as skipped, never vacuous passes Constraint: Linux NSS execution and unavailable-capability warnings remain unchanged Tested: account-home-nss, trusted-home, agent-dir-trust, fail-open, gateway, Windows provenance suites Confidence: high Scope-risk: narrow Reversibility: easy Directive: do not weaken trusted-home provenance --- packages/utils/test/account-home-nss.test.ts | 20 ++++++++------------ 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/packages/utils/test/account-home-nss.test.ts b/packages/utils/test/account-home-nss.test.ts index b08663d250..8cc1665acc 100644 --- a/packages/utils/test/account-home-nss.test.ts +++ b/packages/utils/test/account-home-nss.test.ts @@ -160,8 +160,9 @@ function skip(reason: string): void { } describe("account home is resolved through the OS account database", () => { - it("scopes NSS cache entries by effective UID and never leaks across transitions", async () => { - if (process.platform !== "linux") return skip("NSS account lookup is Linux-only"); + 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); @@ -177,8 +178,7 @@ describe("account home is resolved through the OS account database", () => { expect(result.nssCalls).toBe(3); }); - it("reports the same home NSS does, not the inherited environment", async () => { - if (process.platform !== "linux") return skip("NSS account lookup is Linux-only"); + 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"); @@ -187,8 +187,7 @@ describe("account home is resolved through the OS account database", () => { expect(await resolveWith({ HOME: undefined })).toBe(account); }); - it("ignores a hostile home that the account database contradicts", async () => { - if (process.platform !== "linux") return skip("NSS account lookup is Linux-only"); + 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"); @@ -202,8 +201,7 @@ describe("account home is resolved through the OS account database", () => { } }); - it("never promotes a cached environment-derived home to independent evidence", async () => { - if (process.platform !== "linux") return skip("NSS account lookup is Linux-only"); + 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 @@ -268,8 +266,7 @@ describe("account home is resolved through the OS account database", () => { } }); - it("still resolves an absolute home when the NSS front end is unavailable", async () => { - if (process.platform !== "linux") return skip("NSS account lookup is Linux-only"); + 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 @@ -312,8 +309,7 @@ describe("account home is resolved through the OS account database", () => { } }); - it("accepts an NSS home that corroborates the runtime home", async () => { - if (process.platform !== "linux") return skip("NSS account lookup is Linux-only"); + 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"); From c1542c4bce28716bc19ffdfd25e80cde1b02a4fb Mon Sep 17 00:00:00 2001 From: Yeachan Heo Date: Thu, 20 Aug 2026 22:57:57 +0000 Subject: [PATCH 09/10] fix(utils): require explicit XDG eligibility and tighten the harness Findings from an exact-head architect review. refreshCategoryDirs kept a defaulted isDefault argument that still carried the rejected override-state policy. It was unreachable but wrong, and it invited exactly the path-shape recomputation the sticky decision exists to prevent; the argument is now required. Added the converse regression: a dir that WAS default at construction stays on XDG after a refresh makes its path non-default. Both directions now fail if stickiness is removed. The cache-provenance probe mapped every exception to REFUSED, so an import failure or bad patch satisfied the assertion and hid the real error. It now rethrows anything that is not the known refusal. getTrustedConfigRootDir was still documented as stable although it is call-time, and the changelog still described independence as a value comparison after the source-provenance rule replaced it. Lore-id: 5d0f21ae Confidence: high Scope-risk: narrow Reversibility: easy Tested: reverting stickiness fails both lane cases (12/2) Tested: utils 397 pass / 5 skip / 0 fail, skills 36/0, MCP 16/0 --- packages/utils/CHANGELOG.md | 2 +- packages/utils/src/dirs.ts | 13 ++++++---- packages/utils/test/account-home-nss.test.ts | 4 ++-- .../test/trusted-home-resolution.test.ts | 24 +++++++++++++++++++ 4 files changed, 35 insertions(+), 8 deletions(-) diff --git a/packages/utils/CHANGELOG.md b/packages/utils/CHANGELOG.md index c851711488..536b01565f 100644 --- a/packages/utils/CHANGELOG.md +++ b/packages/utils/CHANGELOG.md @@ -10,7 +10,7 @@ - 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 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. +- 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 c1ccaead8e..8125aa643b 100644 --- a/packages/utils/src/dirs.ts +++ b/packages/utils/src/dirs.ts @@ -471,13 +471,16 @@ class DirResolver { /** * `isDefault` decides whether the agent directory may follow `$XDG_*_HOME`. - * It defaults to a path comparison, but an explicitly selected agent directory - * must never switch storage lanes just because it happens to equal the - * home-derived default — callers that know the override state pass it in. + * + * 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.#agentDirOverride && this.agentDir === path.join(this.configRoot, "agent"), + isDefault: boolean, ): void { let xdgData: string | undefined; let xdgState: string | undefined; @@ -622,7 +625,7 @@ export function getTrustedHomeDir(): string { 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(); diff --git a/packages/utils/test/account-home-nss.test.ts b/packages/utils/test/account-home-nss.test.ts index 8cc1665acc..51113ce930 100644 --- a/packages/utils/test/account-home-nss.test.ts +++ b/packages/utils/test/account-home-nss.test.ts @@ -123,7 +123,7 @@ async function runUidCacheProbe(homeA: string, homeAAfterMappingChange: string): [ `import { getTrustedHomeDir } from ${JSON.stringify(patchedPath)};`, "const state = globalThis as typeof globalThis & { GJC_TEST_NSS_CALLS?: number };", - 'const read = () => { try { return getTrustedHomeDir(); } catch { return "REFUSED"; } };', + '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)]);", @@ -229,7 +229,7 @@ describe("account home is resolved through the OS account database", () => { 'import { vi } from "bun:test";', 'import * as os from "node:os";', `import { getTrustedHomeDir } from ${JSON.stringify(brokenPath)};`, - 'const read = () => { try { return getTrustedHomeDir(); } catch { return "REFUSED"; } };', + '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() }));", diff --git a/packages/utils/test/trusted-home-resolution.test.ts b/packages/utils/test/trusted-home-resolution.test.ts index 22fa050d3f..c4d033124b 100644 --- a/packages/utils/test/trusted-home-resolution.test.ts +++ b/packages/utils/test/trusted-home-resolution.test.ts @@ -275,6 +275,30 @@ describe("authoritative home resolution", () => { 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. From ee43d7db2444ad6ad481d0044b1013b493e18ce0 Mon Sep 17 00:00:00 2001 From: Yeachan Heo Date: Thu, 20 Aug 2026 23:02:01 +0000 Subject: [PATCH 10/10] fix(utils): preserve explicit plugin home escape hatch An explicit plugin home is caller-owned, but the live trusted-home comparison could throw before returning it when authoritative home resolution became unavailable. Keep equal-home XDG routing while returning the explicit config path on fail-closed resolution. Lore-id: 5e2a4b67 Constraint: explicit plugin homes must not depend on trusted-home availability Constraint: equal-home calls retain default XDG-aware behavior Tested: explicit-home unavailable regression, trusted-home and NSS suites, utility check Confidence: high Scope-risk: narrow Reversibility: easy Directive: preserve fail-closed default trusted-home resolution --- packages/utils/CHANGELOG.md | 1 + packages/utils/src/dirs.ts | 30 ++++++++++++------- .../test/trusted-home-resolution.test.ts | 12 ++++++++ 3 files changed, 33 insertions(+), 10 deletions(-) diff --git a/packages/utils/CHANGELOG.md b/packages/utils/CHANGELOG.md index 536b01565f..20c02ea5d6 100644 --- a/packages/utils/CHANGELOG.md +++ b/packages/utils/CHANGELOG.md @@ -5,6 +5,7 @@ - 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 +- 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). diff --git a/packages/utils/src/dirs.ts b/packages/utils/src/dirs.ts index 8125aa643b..2e6dd2c93e 100644 --- a/packages/utils/src/dirs.ts +++ b/packages/utils/src/dirs.ts @@ -231,6 +231,14 @@ function trustedValue( return value; } +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 @@ -436,10 +444,7 @@ 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; + this.#configDirName = resolveConfigDirName(snapshot); this.#trustedHome = resolveTrustedHome(snapshot); this.configRoot = path.join(this.#trustedHome, this.#configDirName); @@ -523,10 +528,7 @@ class DirResolver { * so reads and writes cannot straddle two different homes. */ refreshConfigDirOverride(): void { - const nextConfigDirName = - sanitizeConfigDirName(trustedValue("GJC_CONFIG_DIR", this.#projectEnv)) ?? - sanitizeConfigDirName(trustedValue("PI_CONFIG_DIR", this.#projectEnv)) ?? - CONFIG_DIR_NAME; + const nextConfigDirName = resolveConfigDirName(this.#projectEnv); const nextHome = resolveTrustedHome(this.#projectEnv); if (nextConfigDirName === this.#configDirName && nextHome === this.#trustedHome) return; const nextConfigRoot = path.join(nextHome, nextConfigDirName); @@ -706,8 +708,16 @@ export function getLogPath(date = new Date()): string { * form — XDG semantics are preserved. */ export function getPluginsDir(home?: string): string { - if (home !== undefined && home !== dirs.trustedHome) { - 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/trusted-home-resolution.test.ts b/packages/utils/test/trusted-home-resolution.test.ts index c4d033124b..a1c7fa6a58 100644 --- a/packages/utils/test/trusted-home-resolution.test.ts +++ b/packages/utils/test/trusted-home-resolution.test.ts @@ -352,6 +352,18 @@ describe("authoritative home resolution", () => { 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