Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions docs/crash-reporting.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<cwd>/.gjc`
Expand Down
1 change: 1 addition & 0 deletions packages/utils/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
26 changes: 22 additions & 4 deletions packages/utils/test/agent-dir-trust.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -215,18 +228,23 @@ 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 () => {
if (process.platform !== "win32") return;
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"));
});
});
12 changes: 12 additions & 0 deletions packages/utils/test/fixtures/trusted-home-failopen-probe.ts
Original file line number Diff line number Diff line change
@@ -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) }));
}
Loading
Loading