From 2f6b95415e6a1e4f859ec0b1cb5f135eb2a82f86 Mon Sep 17 00:00:00 2001 From: Yeachan Heo Date: Thu, 20 Aug 2026 17:18:50 +0000 Subject: [PATCH] test(utils): pin the trusted-home independent-evidence rule on NSS-less identities MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #4773. The fail-open that issue reported — `accountHomeFromSystem()`'s final fallback `os.userInfo().homedir` is the environment home returned verbatim whenever it is set, so the ambiguous branch could accept attacker-influenced input as the trusted home on identities without a local passwd entry — is closed on dev by the independent-evidence rule from #4766 (`independentAccountHome`: the account home counts only when it does not merely echo the runtime home; otherwise the filesystem-root sentinel marks user state unavailable). #4766's repro is macOS-shaped; the Linux no-passwd-entry shape (NSS/LDAP/SSSD, distroless) had no discriminating coverage, and this PR supplies it without changing any production behavior (the `packages/utils/src` delta is empty). - New `trusted-home-failopen.test.ts` runs the resolver probe under an unprivileged user namespace whose mapped uid is verified absent from `/etc/passwd` (skipping candidate uids the host already maps, then using the invoking user's subordinate range) — the exact #4773 shape, without root. Every candidate `unshare` argument form is probed and the first working one reused. The discriminating tests are Linux-only; on a host without the capability they skip with a loud warning naming the lost coverage instead of failing cross-platform CI. Compat shapes are asserted unchanged: passwd-backed uid still resolves through `/etc/passwd` (the raw passwd field, mirroring the resolver's validity rule), absent platform home variable still resolves through the account database, unambiguous operator home still wins. - Mutation proof: removing the distinctness rule from dirs.ts (`independentAccountHome = accountHome`) makes exactly the two no-passwd-entry cases fail with the attacker path as `trustedHome`; with the rule, all pass. - `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 (pre-existing on dev). - `docs/crash-reporting.md` now describes the landed contract accurately: independent evidence means not merely echoing the runtime home; without it, the root sentinel marks user state unavailable. The previous text claimed a filesystem root is never used as the refusal sentinel, which the implementation no longer honors. Relationship to #4772: none absorbed; that PR remains separate (its head carries its own version of the parent-side expectation fix). Lore-id: 4773-trusted-home-fail-open Constraint: no production-code change; the established dev contract (#4766 independent-evidence rule + root sentinel) is preserved verbatim Constraint: no overlap absorption from #4772 Rejected: shipping a competing implementation (eager throw at import) | dev's sentinel refusal already landed and is strictly more compatible at startup Confidence: high Scope-risk: narrow Reversibility: trivial Tested: bun test packages/utils/test/ (376 pass ambient + pristine HOME); coding-agent credential-boundary suites 61 pass under pristine HOME (auth-broker, credential-import, skill-hook-agent-dir, spawn-command, sdk-bus-token, exa-api-key, web-search, runtime-mcp redteam/precedence) Tested: mutation proof — distinctness rule removed → 2 no-passwd-entry cases fail with attacker home as trustedHome; rule present → 6/6 pass Not-tested: live NSS/LDAP/SSSD identity (simulated faithfully via uid without passwd entry in a user namespace); live macOS/Windows hosts (rule provenance established from Bun and libuv sources) --- docs/crash-reporting.md | 11 +- packages/utils/CHANGELOG.md | 1 + packages/utils/test/agent-dir-trust.test.ts | 26 +- .../fixtures/trusted-home-failopen-probe.ts | 12 + .../utils/test/trusted-home-failopen.test.ts | 313 ++++++++++++++++++ 5 files changed, 356 insertions(+), 7 deletions(-) create mode 100644 packages/utils/test/fixtures/trusted-home-failopen-probe.ts create mode 100644 packages/utils/test/trusted-home-failopen.test.ts diff --git a/docs/crash-reporting.md b/docs/crash-reporting.md index 368fad6f52..bf00499a53 100644 --- a/docs/crash-reporting.md +++ b/docs/crash-reporting.md @@ -208,9 +208,14 @@ resolver, not to raw `HOME`/`USERPROFILE` values supplied by a checkout. Externa configuration; values declared by the current checkout's `.env` cannot redirect trusted agent files or the relay's input stores. A checkout may still declare an XDG variable for ordinary project-facing caches, so those paths can move, but they are never trusted crash-report input. -If a checkout declares `HOME` in its `.env`, the resolver uses the OS account home -instead of treating that declaration as a trusted user root. It never uses a filesystem -root as the refusal sentinel for user state. +If a checkout declares `HOME` in its `.env`, the resolver uses an account home that is +independent evidence — one that does not merely echo the runtime home (the Linux +`/etc/passwd` lookup qualifies; a runtime `userInfo().homedir` that only mirrors the +environment variable does not, which is the failure #4773 reported on identities +without a local passwd entry). When no such home exists, the trusted home resolves to +the filesystem-root sentinel, user state is marked unavailable, and every user-scope +accessor refuses — credential resolution stays fail-closed and never reads a +checkout-controlled home. Project discovery uses the nearest existing `.gjc` directory, then the checkout's `.git` root as a fallback anchor. With an explicit project scope and neither anchor, the resolver uses `/.gjc` diff --git a/packages/utils/CHANGELOG.md b/packages/utils/CHANGELOG.md index 85fea889da..6bb39b4af4 100644 --- a/packages/utils/CHANGELOG.md +++ b/packages/utils/CHANGELOG.md @@ -2,6 +2,7 @@ ## [Unreleased] - Project dotenv declarations are now excluded from credential and agent-directory provenance even when Bun expands their values before startup, preventing repository-controlled redirects of trusted state and egress (#4715). +- The independent-evidence rule for the trusted account home (#4766) is now pinned by discriminating regression proof for the shape its macOS repro cannot reach: a Linux identity whose uid has no local `/etc/passwd` entry (NSS/LDAP/SSSD-backed accounts, minimal or distroless containers), reported as #4773. New subprocess tests run the resolver under an unprivileged user namespace whose mapped uid is verified absent from the local passwd database (chosen from common subordinate-style candidates or the invoking user's `/etc/subuid` range), so the runtime `userInfo().homedir` echo of a checkout-declared home variable cannot silently regress back into the trusted set; they fail on the pre-rule behavior, hosts without the capability skip with a loud warning naming the lost coverage, and the compat shapes (passwd-backed uid, absent platform home variable, unambiguous operator home, dynamic declaration) are asserted unchanged on every platform, using the platform-authoritative key (`USERPROFILE` on Windows). `agent-dir-trust.test.ts`'s account-home expectation now reads the passwd database directly instead of a parent-side `os.userInfo().homedir`, which follows an isolated `HOME` and made the assertion fail under a pristine `HOME` (#4773). ### Fixed - A project-declared `HOME` could again select the trusted home on macOS, so credentials were read from a checkout-controlled home directory. When the project dotenv declares the platform-authoritative home variable, the resolver falls back to `accountHomeFromSystem()`; that helper reads `os.userInfo().homedir`, which Bun resolves from `$HOME` on macOS (unlike Node, which reads the passwd database). The rejected value therefore came back as its own justification and `~/.env` under the hostile home was parsed for credentials. The account home is now accepted as independent evidence only when it differs from the runtime home -- true for the Linux `/etc/passwd` lookup, false for the macOS `$HOME` echo -- and an ambiguous home with no independent evidence resolves to the filesystem root sentinel, which marks user state unavailable and keeps credential resolution fail-closed. diff --git a/packages/utils/test/agent-dir-trust.test.ts b/packages/utils/test/agent-dir-trust.test.ts index ce5a352fe5..add27b374d 100644 --- a/packages/utils/test/agent-dir-trust.test.ts +++ b/packages/utils/test/agent-dir-trust.test.ts @@ -91,6 +91,19 @@ async function resolveWithoutPlatformHome(cwd: string, hostileHome: string): Pro return JSON.parse(stdout.trim()) as Resolved; } +/** The account home of the running user, from the passwd database on Linux. */ +function accountHomeOfRunningUser(): string { + 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; + } + return os.userInfo().homedir; +} describe("agent directory trust boundary", () => { it("honors an agent directory inherited from the launching shell", async () => { const agentDir = agentDirWith("from-operator-agent-env"); @@ -215,9 +228,13 @@ describe("agent directory trust boundary", () => { const cwd = projectDir("SOMETHING_ELSE=1\n"); const hostileHome = tempDir(); const resolved = await resolveWithoutPlatformHome(cwd, hostileHome); - expect(resolved.trustedHome).toBe(os.userInfo().homedir); + // 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(); + expect(resolved.trustedHome).toBe(accountHome); expect(resolved.trustedHome).not.toBe(hostileHome); - expect(resolved.configRoot).toBe(path.join(os.userInfo().homedir, ".gjc")); + expect(resolved.configRoot).toBe(path.join(accountHome, ".gjc")); }); it("uses the account home when Windows USERPROFILE is absent despite hostile HOME", async () => { @@ -225,8 +242,9 @@ describe("agent directory trust boundary", () => { const cwd = projectDir("SOMETHING_ELSE=1\n"); const hostileHome = tempDir(); const resolved = await resolveWithoutPlatformHome(cwd, hostileHome); - expect(resolved.trustedHome).toBe(os.userInfo().homedir); + const accountHome = accountHomeOfRunningUser(); + expect(resolved.trustedHome).toBe(accountHome); expect(resolved.trustedHome).not.toBe(hostileHome); - expect(resolved.configRoot).toBe(path.join(os.userInfo().homedir, ".gjc")); + expect(resolved.configRoot).toBe(path.join(accountHome, ".gjc")); }); }); diff --git a/packages/utils/test/fixtures/trusted-home-failopen-probe.ts b/packages/utils/test/fixtures/trusted-home-failopen-probe.ts new file mode 100644 index 0000000000..c30c6f4cf4 --- /dev/null +++ b/packages/utils/test/fixtures/trusted-home-failopen-probe.ts @@ -0,0 +1,12 @@ +// Reports the trusted-home resolution under the launched environment. A +// successful resolution prints {"ok":true,...}; when the resolver marks user +// state unavailable (the fail-closed state), getTrustedHomeDir() throws +// inside the try block and the refusal is printed as {"ok":false,...} on +// stdout so the parent can assert on the child's own outcome. +import { getConfigRootDir, getTrustedHomeDir } from "../../src/dirs"; + +try { + console.log(JSON.stringify({ ok: true, trustedHome: getTrustedHomeDir(), configRoot: getConfigRootDir() })); +} catch (error) { + console.log(JSON.stringify({ ok: false, error: String(error) })); +} diff --git a/packages/utils/test/trusted-home-failopen.test.ts b/packages/utils/test/trusted-home-failopen.test.ts new file mode 100644 index 0000000000..30e0985e84 --- /dev/null +++ b/packages/utils/test/trusted-home-failopen.test.ts @@ -0,0 +1,313 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import * as child_process from "node:child_process"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; + +/** + * Issue #4773: `accountHomeFromSystem()`'s final fallback is + * `os.userInfo().homedir`, which in this runtime is the environment home + * (`HOME` on POSIX, `USERPROFILE` on Windows) returned verbatim whenever it is + * set — the account database is consulted only when it is absent. So on an + * identity without a usable local passwd entry (NSS/LDAP/SSSD, distroless) + * and on non-Linux, the ambiguous branch could accept attacker-influenced + * input as the trusted home, and the config root / agent dir it derived then + * supplied `.env` files that `$credentialEnv` treats as trusted. + * + * The contract (landed with the independent-evidence rule in dirs.ts): in the + * ambiguous branch the account home is admissible only when it does not + * merely echo the runtime home. When no such independent evidence exists, the + * trusted home resolves to the filesystem-root sentinel, user state is marked + * unavailable, and every user-scope accessor refuses with + * "User state is unavailable: no trustworthy home directory" — credentials + * are never read from a checkout-controlled home. + * + * These are the discriminating regression tests for that rule on the shape + * PR #4766's macOS repro cannot reach: a Linux identity whose uid has NO + * entry in local /etc/passwd. An unprivileged user namespace with a + * subordinate uid provides exactly that without root; the tests are + * Linux-only, and when no user-namespace capability exists on the host they + * skip with a loud warning naming the lost security coverage. + */ + +const PROBE = path.join(import.meta.dir, "fixtures", "trusted-home-failopen-probe.ts"); + +const tempDirs: string[] = []; + +function tempDir(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-utils-trusted-home-")); + tempDirs.push(dir); + return dir; +} + +/** The platform-authoritative home variable, the only one that can select the trusted home. */ +function homeEnvKey(): "HOME" | "USERPROFILE" { + return process.platform === "win32" ? "USERPROFILE" : "HOME"; +} + +/** A checkout whose `.env` declares the platform-authoritative home key. */ +function projectDir(homeValue: string): string { + const dir = tempDir(); + fs.writeFileSync(path.join(dir, ".env"), `${homeEnvKey()}=${homeValue}\n`); + return dir; +} + +afterEach(() => { + for (const dir of tempDirs.splice(0)) fs.rmSync(dir, { recursive: true, force: true }); +}); + +interface ProbeResult { + ok: boolean; + trustedHome?: string; + error?: string; +} + +/** Run the probe as a subprocess; a refusal is reported as ok:false. */ +function runProbe(cwd: string, envHome: string | undefined): ProbeResult { + 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.GJC_CONFIG_DIR; + delete env.PI_CONFIG_DIR; + // Both platform home variables start cleared so the opposite-platform + // variable (or a dotenv value overlaid into it) cannot redirect the + // resolution; only the authoritative one is set when requested. + delete env.HOME; + delete env.USERPROFILE; + if (envHome !== undefined) env[homeEnvKey()] = envHome; + + const result = Bun.spawnSync({ + cmd: [process.execPath, PROBE], + cwd, + env, + stdout: "pipe", + stderr: "pipe", + }); + const stdout = new TextDecoder().decode(result.stdout).trim(); + if (stdout) { + try { + return JSON.parse(stdout) as ProbeResult; + } catch {} + } + return { ok: false, error: `probe exited ${result.exitCode}: ${new TextDecoder().decode(result.stderr).trim()}` }; +} + +/** + * Run the probe under an unprivileged user namespace whose mapped uid has no + * /etc/passwd entry — the NSS-less identity shape the fail-open needs. + * + * The candidate uid is chosen so the premise is verifiably true: the default + * subordinate uid is skipped when the host's /etc/passwd already contains it, + * and the first unused uid from the invoking user's /etc/subuid range is used + * instead; when every candidate is taken the shape is unavailable, never + * assumed. The exact `unshare` argument shape differs by util-linux/kernel + * policy (group mapping sometimes needs setgroups handling, some hosts allow + * user-mapping only), so every candidate form is probed and the first working + * one is reused by the probe runner. + * + * These tests are Linux-only (the shape needs the passwd lookup to miss) and + * require unprivileged user namespaces, which not every container permits. + * When the capability is missing the tests skip with a loud warning naming the + * lost coverage, so a restricted host shows it in the log instead of failing + * for reasons unrelated to the code under test. + */ +function passwdUids(): Set { + try { + return new Set( + fs + .readFileSync("/etc/passwd", "utf8") + .split("\n") + .map(line => line.split(":")[2]), + ); + } catch { + return new Set(); + } +} + +/** Candidate unmapped uids: the common subordinate default, then this user's /etc/subuid range. */ +function candidateUids(): number[] { + const taken = passwdUids(); + const candidates: number[] = []; + for (const uid of [100000, 165536]) { + if (!taken.has(String(uid))) candidates.push(uid); + } + try { + const user = os.userInfo().username; + for (const line of fs.readFileSync("/etc/subuid", "utf8").split("\n")) { + const fields = line.split(":"); + if (fields[0] !== user || !fields[1] || !fields[2]) continue; + const start = Number(fields[1]); + const count = Number(fields[2]); + if (!Number.isSafeInteger(start) || !Number.isSafeInteger(count) || count <= 0) continue; + for (let offset = 0; offset < count && candidates.length < 8; offset++) { + const uid = start + offset; + if (!taken.has(String(uid))) candidates.push(uid); + } + } + } catch {} + return [...new Set(candidates)]; +} + +let usernsSetup: { args: string[]; uid: number } | undefined | null; +function userNamespace(): { args: string[]; uid: number } | undefined { + if (usernsSetup !== undefined) return usernsSetup ?? undefined; + usernsSetup = null; + if (process.platform !== "linux") return undefined; + for (const uid of candidateUids()) { + for (const form of [ + ["-U", "--map-user", String(uid), "--map-group", String(uid)], + ["-U", "--map-user", String(uid)], + ["-U", "--map-user", String(uid), "--setgroups=deny", "--map-group", String(uid)], + ]) { + try { + const result = child_process.spawnSync( + "unshare", + [...form, process.execPath, "-e", "console.log(process.getuid())"], + { encoding: "utf8", timeout: 15_000 }, + ); + if (result.status === 0 && result.stdout.trim() === String(uid)) { + usernsSetup = { args: form, uid }; + return usernsSetup; + } + } catch {} + } + } + return undefined; +} + +/** Run the probe under a uid that has no local passwd entry. */ +function runProbeWithoutPasswdEntry(cwd: string, envHome: string | undefined): ProbeResult { + 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.GJC_CONFIG_DIR; + delete env.PI_CONFIG_DIR; + delete env.USERPROFILE; + if (envHome === undefined) delete env.HOME; + else env.HOME = envHome; + + const result = child_process.spawnSync("unshare", [...(userNamespace()?.args ?? []), process.execPath, PROBE], { + cwd, + env, + encoding: "utf8", + timeout: 30_000, + }); + const stdout = (result.stdout ?? "").trim(); + if (stdout) { + try { + return JSON.parse(stdout) as ProbeResult; + } catch {} + } + return { ok: false, error: `probe exited ${result.status}: ${(result.stderr ?? "").trim()}` }; +} + +describe("trusted-home fail-open regression (#4773)", () => { + it("fails closed when the .env-declared home is indistinguishable and no passwd-independent source exists", () => { + // The exact #4773 shape: an identity with no usable local passwd entry + // plus a checkout whose .env declares HOME equal to the inherited + // value. The vulnerable build accepted the attacker-influenced home as + // trusted; it must refuse instead. + if (process.platform !== "linux") return; + if (!userNamespace()) { + console.warn( + "[#4773] SKIPPING the no-passwd-entry regression: no usable unprivileged user namespace on this host; the independent-evidence rule is only covered by the compat shapes here", + ); + return; + } + const hostile = path.join(tempDir(), "attacker-home"); + const cwd = projectDir(hostile); + const resolved = runProbeWithoutPasswdEntry(cwd, hostile); + expect(resolved.ok).toBe(false); + expect(resolved.error ?? "").toContain("User state is unavailable: no trustworthy home directory"); + expect(JSON.stringify(resolved)).not.toContain(hostile); + }); + + it("does not expose the hostile home even when resolution fails", () => { + if (process.platform !== "linux") return; + if (!userNamespace()) { + console.warn( + "[#4773] SKIPPING the no-passwd-entry regression: no usable unprivileged user namespace on this host", + ); + return; + } + const hostile = "/definitely-not-a-real-home-4773"; + const cwd = projectDir(hostile); + const resolved = runProbeWithoutPasswdEntry(cwd, hostile); + expect(resolved.ok).toBe(false); + expect(JSON.stringify(resolved)).not.toContain(hostile); + }); + + it("still honors the passwd home for a passwd-backed uid in the ambiguous branch", () => { + // Compatibility: an ordinary Linux identity keeps working exactly as + // before — the local passwd entry, not the .env declaration, selects + // the trusted home. + if (process.platform !== "linux") return; + const passwdHome = (() => { + const uid = os.userInfo().uid; + for (const line of fs.readFileSync("/etc/passwd", "utf8").split("\n")) { + const fields = line.split(":"); + if (fields[2] === String(uid)) return fields[5]; + } + return undefined; + })(); + // Mirror the resolver's own validity rule: an absolute, non-root passwd + // home is what production accepts, and it is used verbatim. + if (!passwdHome || !path.isAbsolute(passwdHome) || passwdHome === path.parse(passwdHome).root) return; + const cwd = projectDir("/attacker/echo-home"); + const resolved = runProbe(cwd, "/attacker/echo-home"); + expect(resolved.ok).toBe(true); + expect(resolved.trustedHome).toBe(passwdHome); + }); + + it("still honors the account home when the platform home variable is absent", () => { + // Authoritative home variable absent: userInfo().homedir consults the + // account database (or the Windows token profile), so the value is + // independent and accepted. On Linux without a passwd entry Bun throws + // ENOENT, which also resolves to the fail-closed refusal. + const cwd = projectDir("/attacker/planted-home-4773"); + const resolved = runProbe(cwd, undefined); + if (process.platform === "win32") { + // USERPROFILE absent: libuv falls back to the access-token profile + // directory, which the environment cannot plant. + expect(resolved.ok).toBe(true); + expect(resolved.trustedHome).toBeTruthy(); + } else if (resolved.ok) { + // The real HOME is absent in the child, so os.userInfo().homedir + // consults the account database rather than echoing an env value; + // a planted dotenv HOME never becomes the trusted home. + expect(resolved.trustedHome).toBe(path.resolve(resolved.trustedHome ?? "")); + expect(resolved.trustedHome).not.toBe("/attacker/planted-home-4773"); + } else { + expect(resolved.error ?? "").toContain("User state is unavailable: no trustworthy home directory"); + } + }); + + it("keeps the operator's distinct runtime home in the non-ambiguous branch", () => { + // A .env declaring a DIFFERENT home than the inherited one is not + // ambiguous: the operator's own environment wins, unchanged. + const hostile = path.join(tempDir(), "planted-home"); + const operatorHome = tempDir(); + const cwd = projectDir(hostile); + const resolved = runProbe(cwd, operatorHome); + expect(resolved.ok).toBe(true); + expect(resolved.trustedHome).toBe(operatorHome); + }); + + it("a dynamic .env home stays ambiguous and never becomes trusted", () => { + // Dynamic dotenv declarations are ambiguous regardless of the runtime + // value, so the account database must decide — never the env value. + const dir = tempDir(); + fs.writeFileSync(path.join(dir, ".env"), `${homeEnvKey()}=$PLANTED/x\n`); + const planted = path.join(tempDir(), "dynamic-home"); + const resolved = runProbe(dir, planted); + if (resolved.ok) { + expect(resolved.trustedHome).not.toBe(planted); + } else { + expect(resolved.error ?? "").toContain("User state is unavailable: no trustworthy home directory"); + } + }); +});