Skip to content

fix(utils): keep the configured config-dir name beneath home - #3267

Merged
Yeachan-Heo merged 1 commit into
Yeachan-Heo:devfrom
10kH:fix/config-dir-name-containment
Jul 28, 2026
Merged

fix(utils): keep the configured config-dir name beneath home#3267
Yeachan-Heo merged 1 commit into
Yeachan-Heo:devfrom
10kH:fix/config-dir-name-containment

Conversation

@10kH

@10kH 10kH commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Problem

getConfigDirName() returned GJC_CONFIG_DIR / PI_CONFIG_DIR verbatim. Consumers join that name with <home> to locate user-level discovery inputs:

site path
discovery/builtin.ts:235 path.join(ctx.home, userAgentDir, "mcp.json")
discovery/builtin.ts:273 path.join(ctx.home, userAgentDir, "SYSTEM.md")
discovery/builtin.ts:328 path.join(ctx.home, userAgentDir, "skills")
runtime-skill-discovery.ts:66 path.join(home, canonicalUserDir, "skills")

The contract is documented in prompts/tools/skill-discovery.md: the value is the home-relative directory name, and “even an absolute-looking configured name is joined beneath <home>. path.join delivers that for a leading separator, but not for .. segments.

Measured against this source:

GJC_CONFIG_DIR=../../tmp/_cfg/evil
→ getConfigDirName()               = "../../tmp/_cfg/evil"
→ path.join(home, name + "/agent", "mcp.json") = /tmp/_cfg/evil/agent/mcp.json
→ escapes home: true

So an escaping name moves user-level MCP server definitions (which are spawned as processes), the system prompt, and skill/agent/plugin discovery outside the config root entirely.

Fix

Reject a configured name containing a .. segment and fall back to the default — evaluated per key, so a legacy PI_CONFIG_DIR still applies when the primary one is rejected. Ordinary names, and the documented absolute-looking case, are unchanged.

Tests

config-dir-name-containment.test.ts: default when unset, ordinary name honored, legacy key honored, absolute-looking name still lands beneath home, four escaping shapes rejected (../escape, ../../tmp/evil, .gjc/../../tmp/evil, a/b/../../../../tmp/evil), escaping legacy key rejected, fall-through from an escaping primary to a valid legacy key, and a blank value ignored.

Proof-first: reverting the guard turns seven of the eleven red.

bun run check in packages/utils (whole-package biome + tsc): exit 0. packages/utils/test + coding-agent/test/discovery + marketplace/discovery: 366 pass / 0 fail.

Scope note

This restores the documented containment, not a trust boundary. Bun loads cwd/.env into process.env before any JS runs, so the value can still originate from a project .env; dirs.ts is imported by env.ts, so it cannot reach $credentialEnv without a cycle. Making this name trusted the way #3252 / #3262 do needs a separate decision about where that primitive should live — happy to follow your preference if you want it moved.

@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.

Hostile exact-head review — PR #3267

Exact head: 284fc6a3b65b8e9e37bb0f266b908ce80a942dbb (matches headRefOid; local git rev-parse identical)
Base / live origin/dev: 2d8770169c15cd13efd54aba43ea2cddaab56a93 (matches baseRefOid; no base drift)
Diff: 3 files, +112/-1 (packages/utils/src/dirs.ts, new containment test, CHANGELOG)
Mergeability: MERGEABLE / CLEAN vs current origin/dev (merge-tree clean)
Exact-head CI: all completed non-skipped checks success on 284fc6a3b65b (incl. test:packages/utils/test/config-dir-name-containment.test.ts, check:@gajae-code/utils, native-build, darwin-arm64 tab-worker smoke, gjc-state-gates, install-methods, Local public surfaces). Evidence: https://github.com/Yeachan-Heo/gajae-code/actions/runs/30244506875
Contributor / scope: external fork 10kH:fix/config-dir-name-containment; single-commit utils-only surface; no workflow mutation; changelog under Unreleased Fixed. No predecessor merge-gate hard-dep (trust series #3252/#3256 merged; #3260/#3262/#3268 parallel, different env keys).

Verdict

MERGE_READY

Attack surface reviewed

getConfigDirName() previously returned GJC_CONFIG_DIR / PI_CONFIG_DIR verbatim. Documented contract (skill-discovery prompt): home-relative name, absolute-looking values still joined beneath <home>. path.join strips a leading separator but honors .., so discovery of user-level mcp.json (spawned MCP servers), SYSTEM.md, skills/agents/plugins could leave the home config root:

input old path.resolve(home, name, "agent/mcp.json") escapes home
../escape /home/escape/agent/mcp.json yes
../../tmp/evil /tmp/evil/agent/mcp.json yes
.gjc/../../tmp/evil /home/tmp/evil/agent/mcp.json yes

Diff assessment

Claim Result
Escaping .. names rejected → default .gjc PASSpath.normalize(...).split(/[\\/]/).includes("..")
Per-key evaluation (GJC then PI then default) PASS — fallthrough test + independent probe
Ordinary / legacy names unchanged PASS
Absolute-looking name stays under home after join PASS — Node/Bun path.join(home, "/etc/gjc") lands home/etc/gjc
Blank name ignored PASS
DirResolver / plugins path use sanitized API PASSconfigRoot = path.join(home, getConfigDirName())
Changelog accurate, scoped Fixed PASS
No over-claim of project-.env trust boundary PASS — body explicitly scopes containment only

Independent proofs (this head)

  1. Focused tests: bun test packages/utils/test/config-dir-name-containment.test.ts11 pass / 0 fail
  2. Live API probe: escaping suite all fall back to .gjc and resolve under $HOME; fallthrough GJC=../../evil + PI=.pi-ok.pi-ok; ordinary .gjc-alt honored
  3. Revert non-vacuity (old semantics reimplemented): same four escaping shapes all leave home (/home/escape, /tmp/evil, …)

Residual notes (non-blocking for this claim)

  1. Parallel raw readers still reimplement resolution and can escape — pre-existing, out of this diff’s API surface, but same env keys:
    • packages/coding-agent/src/hooks/native-skill-hook.tspath.join(homedir(), process.env.GJC_CONFIG_DIR ?? …, "agent") still escapes on ../../tmp/evil
    • gjc-runtime/{deep-interview,ralplan,ultragoal}-runtime.ts treat GJC_CONFIG_DIR as a path root (not home-joined name) for settings/config.yml
    • Follow-up: route those through getConfigDirName() / home-join contract
  2. Not a project-.env trust boundary — Bun preloads cwd/.env into process.env; a repo can still set a non-escaping home-relative name (e.g. .ssh). Author correctly defers $credentialEnv-class treatment (see #3252 family). Documented honesty; not a hole in the claimed fix.
  3. Sanitize returns original (possibly internally ..-bearing) text when normalize collapses — e.g. foo/../bar accepted as-is; after path.join(home, …) still under home. Cosmetic inconsistency only.
  4. origin/dev at 2d877016… has unrelated coding-agent shard failures on push run 30244279756. Not caused by this PR; exact-head PR CI is green. Prior policy: technical MERGE_READY stands; do not treat that red as a defect in this diff.

Merge policy note

Technical content of this PR is correct and proof-backed at exact head 284fc6a3b65b. Residual raw readers and trusted-env promotion are separate follow-ups, not regressions introduced here.


[repo owner's gaebal-gajae (clawdbot) 🦞]

@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.

stale exact-head review refresh: re-affirming APPROVED at current head (no diff since last review).

Yeachan-Heo pushed a commit that referenced this pull request Jul 27, 2026
Documented as a pre-existing platform gap 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). 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: #3267 is
already approved against that function, and binding its trust belongs in a
successor rather than a conflicting edit.
getConfigDirName() returned GJC_CONFIG_DIR / PI_CONFIG_DIR verbatim. Consumers
join that name with <home> to locate user-level discovery inputs:

  discovery/builtin.ts:235  path.join(ctx.home, userAgentDir, "mcp.json")
  discovery/builtin.ts:273  path.join(ctx.home, userAgentDir, "SYSTEM.md")
  discovery/builtin.ts:328  path.join(ctx.home, userAgentDir, "skills")
  runtime-skill-discovery.ts:66  path.join(home, canonicalUserDir, "skills")

The documented contract is that the value names a directory beneath <home> —
"even an absolute-looking configured name is joined beneath <home>" — which
path.join delivers for a leading separator but not for ".." segments. A name
such as ../../tmp/evil resolved to /tmp/evil/agent, moving user-level MCP server
definitions, the system prompt and skill discovery outside the config root.

Reject names with a ".." segment and fall back to the default, per key, so a
legacy PI_CONFIG_DIR still applies when the primary one is rejected.

Note: this restores the documented containment, not the trust boundary. Bun
loads cwd/.env into process.env before any JS runs, so the value can still
originate from the project .env; dirs.ts is imported by env.ts, so it cannot use
$credentialEnv without a cycle. Making the name trusted needs a separate
decision about where that primitive should live.
@10kH

10kH commented Jul 27, 2026

Copy link
Copy Markdown
Contributor Author

Rebased onto dev (b853f15d) after a real conflict in getConfigDirName().

The conflict was with #3295, which merged in the meantime and replaced the direct process.env reads with trustedConfigDirName(...). The two changes guard different things, so I composed them rather than picking a side:

return (
	sanitizeConfigDirName(trustedConfigDirName("GJC_CONFIG_DIR")) ??
	sanitizeConfigDirName(trustedConfigDirName("PI_CONFIG_DIR")) ??
	CONFIG_DIR_NAME
);

Neither subsumes the other: a trusted value can still contain .., and a contained value can still originate from an untrusted .env. sanitizeConfigDirName already takes string | undefined, so the composition needed no signature change.

Verification after the rebase: bun run check in packages/utils exit 0; this PR's own suite config-dir-name-containment.test.ts 11 pass / 0 fail, so the containment contract still holds on top of the trusted resolver.

@10kH
10kH force-pushed the fix/config-dir-name-containment branch from 284fc6a to a9fe219 Compare July 27, 2026 13:47

@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 a9fe2195c2b04ec1f30f2194cd1131ee4b24a08f

Rebase-only refresh of the prior exact-head review at 284fc6a3b65b8e9e37bb0f266b908ce80a942dbb (APPROVE).

Checks performed:

  • Rebase adaptation verified, not assumed identical: unlike the other 21 PRs in this batch, this PR's own commit is not byte-identical between 284fc6a3b65b8e9e37bb0f266b908ce80a942dbb and a9fe2195c2b04ec1f30f2194cd1131ee4b24a08fdiff <(git show 284fc6a3 --format= -- packages/utils/src/dirs.ts packages/utils/test/config-dir-name-containment.test.ts) <(git show a9fe2195 --format= -- packages/utils/src/dirs.ts packages/utils/test/config-dir-name-containment.test.ts) shows a real textual difference. Root cause traced: git merge-base 284fc6a3b65b8e9e37bb0f266b908ce80a942dbb a9fe2195c2b04ec1f30f2194cd1131ee4b24a08f shows two independent PRs (#3285, #3295) landed on dev between the two reviews and added a trustedConfigDirName() / trustedAgentDirOverride() wrapper around the same process.env reads this PR's sanitizeConfigDirName() also wraps. This PR's commit necessarily rebased its hunk to compose sanitizeConfigDirName(trustedConfigDirName(...)) instead of sanitizeConfigDirName(process.env...) directly.
  • Functional equivalence confirmed by direct read of dirs.ts at a9fe2195c2b04ec1f30f2194cd1131ee4b24a08f: sanitizeConfigDirName()'s own body (reject any .. path segment, else fall back to CONFIG_DIR_NAME) is unchanged from the prior review; it now composes correctly on top of the already-merged trust guard (comment at the call site: "Both guards apply: the value must come from a trusted source ... and it must still be a single name that stays beneath home"). This is mechanical conflict resolution required by upstream rebase, not a new claim or behavior change by this PR.
  • Test file untouched: packages/utils/test/config-dir-name-containment.test.ts is byte-identical between 284fc6a3b65b8e9e37bb0f266b908ce80a942dbb and a9fe2195c2b04ec1f30f2194cd1131ee4b24a08f.
  • Exact-head terminal CI: 17 success / 0 failure / 0 pending / 4 skipped (total 21). No pending/queued checks.

Prior technical finding (path.join delivers containment for a leading separator but not .. segments; consumers join the name with <home>) still holds verbatim.


[repo owner's gaebal-gajae (clawdbot) 🦞]

@Yeachan-Heo
Yeachan-Heo merged commit e48643f into Yeachan-Heo:dev Jul 28, 2026
21 checks passed
Yeachan-Heo pushed a commit that referenced this pull request Jul 28, 2026
- verify-gjc-sdk-canonicalization: allowlist the second sanctioned tmux
  send-keys fallback in relaunchWorkerPaneForMemoryGuard. The exact-match
  scan counted the memory-guard dispatch as a duplicate of the
  startTmuxSession loop dispatch and rejected all 12 occurrences. Loop
  counts are now scoped to startTmuxSession; the memory-guard block gets
  its own exact-shape matcher. Verified tamper-rejecting: payload swap,
  appended unsanctioned send-keys, and widened fallback predicate all
  still fail closed.

- stats tests: TempDir.createSync used os.tmpdir(), so
  path.relative(os.homedir(), tmp) produced ../.. segments that
  sanitizeConfigDirName rejects (by design, #3267). PI_CONFIG_DIR then
  silently fell back to the real ~/.gjc and four suites read/wrote the
  live stats.db instead of an isolated root. Create the temp root under
  $HOME so the relative name stays beneath home.
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