Skip to content

fix(utils): reject a project-.env agent directory override - #3285

Merged
Yeachan-Heo merged 1 commit into
Yeachan-Heo:devfrom
10kH:fix/agent-dir-trust
Jul 27, 2026
Merged

fix(utils): reject a project-.env agent directory override#3285
Yeachan-Heo merged 1 commit into
Yeachan-Heo:devfrom
10kH:fix/agent-dir-trust

Conversation

@10kH

@10kH 10kH commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Closes the pre-existing platform gap you documented in the #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), making the “trusted” agent .env repo-internal and recovering the full attack. Separate follow-up against the shared resolver.

Why it matters

The agent directory selects the agent's own .env, which is one of the file sources $credentialEnv consults. So this one variable is upstream of every trust fix: a repository ships a directory, points the agent at it, and its .env is then treated as trusted.

Measured on this source — repo/.env sets the agent dir, repo/.evil-agent/.env sets a provider base URL:

before: $credentialEnv("ANTHROPIC_BASE_URL") = "https://attacker.example"
after:  undefined

And the operator path, verified in the same shape:

GJC_CODING_AGENT_DIR from the shell, agent .env -> "https://gateway.internal"   (honoured)
GJC_CODING_AGENT_DIR from repo .env,  agent .env -> undefined                    (rejected)

Fix

dirs.ts cannot call $credentialEnvenv.ts imports dirs.ts. Rather than duplicate a parser, the .env parsing primitives (isValidEnvName, parseShellEnvFile, parseEnvFile) move to a leaf env-file module that both can import; env.ts re-exports them so its public surface is unchanged.

dirs.ts then applies the same conservative ambiguity rule resolveLiveCredentialEnvValue already uses (env.ts:167-174): an override whose value matches what the project .env sets 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.ts spawns a probe with a controlled cwd (both the override and projectEnv resolve at module load) and a temp HOME so 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 check in packages/utils (whole-package biome + tsc): exit 0. packages/utils/test: 216 pass / 0 fail. coding-agent discovery + browser suites (the heaviest getAgentDir consumers): 249 pass / 0 fail.

Scope

GJC_CONFIG_DIR has 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.

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 Yeachan-Heo left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.ts imports the new leaf env-file.ts (not env.ts), and env.ts re-exports from env-file.ts for public-surface stability. env.ts still imports dirs.ts — no cycle introduced. Confirmed by reading both files' import graphs directly.
  • Ambiguity-rule parity: trustedAgentDirOverride() in dirs.ts (reject when override value === project .env value) is functionally equivalent to resolveLiveCredentialEnvValue()'s three-condition rule in env.ts — the third condition (inheritedEnv check) is always satisfied whenever the first two hold, since filterCredentialInheritedEnv already excludes ambiguous project-matching entries from inheritedEnv by construction. No divergence in trust semantics between the two call sites.
  • GJC_CONFIG_DIR exclusion: correctly untouched; diff confirms no changes to getConfigRootDir(), 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.ts neutralizes HOME via temp dir; GJC_CODING_AGENT_DIR is 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) 🦞]

@Yeachan-Heo
Yeachan-Heo merged commit 69faa20 into Yeachan-Heo:dev Jul 27, 2026
22 checks passed
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants