fix(utils): reject a project-.env agent directory override - #3285
Merged
Conversation
Documented as a pre-existing platform gap in the Yeachan-Heo#3273 review: Bun loads cwd/.env into process.env before any gjc module runs, so a planted GJC_CODING_AGENT_DIR redirects getAgentDir() (dirs.ts:251). The agent directory selects the agent's own .env, which is one of the trusted sources $credentialEnv consults — so a repository could ship a directory, point the agent at it, and have its .env treated as trusted. That recovers every endpoint and credential redirect the boundary is meant to reject. Measured on this source, with repo/.env setting the agent dir and repo/.evil-agent/.env setting a provider base URL: before: $credentialEnv("ANTHROPIC_BASE_URL") = "https://attacker.example" after: undefined dirs.ts cannot use $credentialEnv — env.ts imports dirs.ts — so the .env parsing primitives move to a leaf env-file module that both can import, and dirs.ts applies the same conservative ambiguity rule directly: an override that matches what the project .env sets is not honoured. Their public surface is unchanged (env.ts re-exports them). An operator exporting GJC_CODING_AGENT_DIR from their shell is unaffected; verified both directions. Regression: a child process with a controlled cwd proves an inherited agent dir still supplies its .env, a planted one does not, and a planted one cannot redirect an inherited one. Reverting the check fails the planted case. GJC_CONFIG_DIR has the same shape and is deliberately untouched here: Yeachan-Heo#3267 is already approved against that function, and binding its trust belongs in a successor rather than a conflicting edit.
Yeachan-Heo
approved these changes
Jul 27, 2026
Yeachan-Heo
left a comment
Owner
There was a problem hiding this comment.
Verdict: APPROVE — exact head e88771a26cd4bbc3215239973a81fdeba6641244
Closes a real, previously-documented gap (#3273 review): Bun loads cwd/.env before any module runs, so a planted GJC_CODING_AGENT_DIR could make a repo-shipped directory's .env a trusted credential source, upstream of every other trust fix in this batch.
Checks performed:
- Circular-dependency avoidance:
dirs.tsimports the new leafenv-file.ts(notenv.ts), andenv.tsre-exports fromenv-file.tsfor public-surface stability.env.tsstill importsdirs.ts— no cycle introduced. Confirmed by reading both files' import graphs directly. - Ambiguity-rule parity:
trustedAgentDirOverride()indirs.ts(reject when override value === project.envvalue) is functionally equivalent toresolveLiveCredentialEnvValue()'s three-condition rule inenv.ts— the third condition (inheritedEnvcheck) is always satisfied whenever the first two hold, sincefilterCredentialInheritedEnvalready excludes ambiguous project-matching entries frominheritedEnvby construction. No divergence in trust semantics between the two call sites. GJC_CONFIG_DIRexclusion: correctly untouched; diff confirms no changes togetConfigRootDir(), and the PR body explicitly defers it to #3267 (already approved against the same function) to avoid a conflicting head.- Tests: hermetic —
agent-dir-trust.test.tsneutralizesHOMEvia temp dir;GJC_CODING_AGENT_DIRis correctly left unneutralized since it's the variable under test, and the probed variable (GJC_TRUST_PROBE_VALUE) is test-invented so no real-environment collision risk. Three scenarios (operator-inherited honored, project-planted rejected, project cannot redirect an inherited override) all read as sound and match the stated proof-first claim. - CI: all 22 checks pass on the exact head, no pending/failing.
- Novelty: no other open PR in this batch or the broader open env-trust set touches
dirs.ts/env.ts/env-file.ts; not a duplicate. - No automated reviewer comments present on this PR.
No merge action taken — post-#3281 dev CI 30253189244 is nonterminal and this batch is read-only review only.
—
[repo owner's gaebal-gajae (clawdbot) 🦞]
This was referenced Jul 27, 2026
This was referenced Jul 27, 2026
10kH
added a commit
to 10kH/gajae-code
that referenced
this pull request
Jul 27, 2026
resolveConfigPaths (native-skill-hook.ts:156) built its config locations from raw process.env: GJC_CONFIG_DIR ?? PI_CONFIG_DIR ?? ".gjc" GJC_CODING_AGENT_DIR ?? join(os.homedir(), configDirName, "agent") Bun loads cwd/.env into process.env before any module runs, so both values are repository-controlled. The config.yml these paths select carries skills.customDirectories — the directories the agent then loads skills from — so this is the same escalation trustedAgentDirOverride (Yeachan-Heo#3285) and trustedConfigDirName (Yeachan-Heo#3295) were added to close, reached through a call site that never went through them. Measured on a scenario repo whose .env sets GJC_CODING_AGENT_DIR to a planted directory: getAgentDir() correctly resolved to /home/woody/.gjc/agent, while the hook loaded the planted config.yml and returned its customDirectories. After the change the injected entry is gone and a config in the trusted agent dir is still honored. getAgentDir()/getConfigDirName() resolve to the same locations this built by hand — dirs.agentDir is join(os.homedir(), getConfigDirName(), "agent") absent a trusted override — so only the untrusted case changes. Regression drives a child process, since the planted value is only visible to a process started with that cwd. Restoring the raw reads fails the two bypass cases while the trusted-config case keeps passing.
10kH
added a commit
to 10kH/gajae-code
that referenced
this pull request
Jul 27, 2026
resolveConfigPaths (native-skill-hook.ts:156) built its config locations from raw process.env: GJC_CONFIG_DIR ?? PI_CONFIG_DIR ?? ".gjc" GJC_CODING_AGENT_DIR ?? join(os.homedir(), configDirName, "agent") Bun loads cwd/.env into process.env before any module runs, so both values are repository-controlled. The config.yml these paths select carries skills.customDirectories — the directories the agent then loads skills from — so this is the same escalation trustedAgentDirOverride (Yeachan-Heo#3285) and trustedConfigDirName (Yeachan-Heo#3295) were added to close, reached through a call site that never went through them. Measured on a scenario repo whose .env sets GJC_CODING_AGENT_DIR to a planted directory: getAgentDir() correctly resolved to /home/woody/.gjc/agent, while the hook loaded the planted config.yml and returned its customDirectories. After the change the injected entry is gone and a config in the trusted agent dir is still honored. getAgentDir()/getConfigDirName() resolve to the same locations this built by hand — dirs.agentDir is join(os.homedir(), getConfigDirName(), "agent") absent a trusted override — so only the untrusted case changes. Regression drives a child process, since the planted value is only visible to a process started with that cwd. Restoring the raw reads fails the two bypass cases while the trusted-config case keeps passing.
Yeachan-Heo
pushed a commit
that referenced
this pull request
Jul 28, 2026
…3324) resolveConfigPaths (native-skill-hook.ts:156) built its config locations from raw process.env: GJC_CONFIG_DIR ?? PI_CONFIG_DIR ?? ".gjc" GJC_CODING_AGENT_DIR ?? join(os.homedir(), configDirName, "agent") Bun loads cwd/.env into process.env before any module runs, so both values are repository-controlled. The config.yml these paths select carries skills.customDirectories — the directories the agent then loads skills from — so this is the same escalation trustedAgentDirOverride (#3285) and trustedConfigDirName (#3295) were added to close, reached through a call site that never went through them. Measured on a scenario repo whose .env sets GJC_CODING_AGENT_DIR to a planted directory: getAgentDir() correctly resolved to /home/woody/.gjc/agent, while the hook loaded the planted config.yml and returned its customDirectories. After the change the injected entry is gone and a config in the trusted agent dir is still honored. getAgentDir()/getConfigDirName() resolve to the same locations this built by hand — dirs.agentDir is join(os.homedir(), getConfigDirName(), "agent") absent a trusted override — so only the untrusted case changes. Regression drives a child process, since the planted value is only visible to a process started with that cwd. Restoring the raw reads fails the two bypass cases while the trusted-config case keeps passing.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes the pre-existing platform gap you documented in the #3273 review:
Why it matters
The agent directory selects the agent's own
.env, which is one of the file sources$credentialEnvconsults. So this one variable is upstream of every trust fix: a repository ships a directory, points the agent at it, and its.envis then treated as trusted.Measured on this source —
repo/.envsets the agent dir,repo/.evil-agent/.envsets a provider base URL:And the operator path, verified in the same shape:
Fix
dirs.tscannot call$credentialEnv—env.tsimportsdirs.ts. Rather than duplicate a parser, the.envparsing primitives (isValidEnvName,parseShellEnvFile,parseEnvFile) move to a leafenv-filemodule that both can import;env.tsre-exports them so its public surface is unchanged.dirs.tsthen applies the same conservative ambiguity ruleresolveLiveCredentialEnvValuealready uses (env.ts:167-174): an override whose value matches what the project.envsets is not honoured. An operator whose environment happens to carry the identical value loses the override — the same trade-off your review called out as correct for scenario D.Tests
agent-dir-trust.test.tsspawns a probe with a controlled cwd (both the override andprojectEnvresolve at module load) and a tempHOMEso the other trusted file sources stay neutral: an inherited agent dir still supplies its.env, a planted one does not, and a planted one cannot redirect an inherited one.Proof-first: reverting the check fails
ignores an agent directory planted by the project .env.Verification
bun run checkinpackages/utils(whole-package biome + tsc): exit 0.packages/utils/test: 216 pass / 0 fail.coding-agentdiscovery + browser suites (the heaviestgetAgentDirconsumers): 249 pass / 0 fail.Scope
GJC_CONFIG_DIRhas the same shape. It is deliberately untouched: #3267 is already approved against that same function, and binding its trust here would conflict with a head you have already verified. Happy to send it as a successor once #3267 lands.