test(coding-agent): prove blocked bash commands leave the .gjc state tree untouched - #4167
test(coding-agent): prove blocked bash commands leave the .gjc state tree untouched#416710kH wants to merge 1 commit into
Conversation
311e364 to
bc7720a
Compare
bc7720a to
62cefa4
Compare
Yeachan-Heo
left a comment
There was a problem hiding this comment.
Review verdict: MERGE_READY
Reviewed head 62cefa41a3 (rebase of original bc7720ad onto current dev fbba1c67), base fbba1c67. Scope: exactly one test-only file, packages/coding-agent/test/tools/bash-state-exploit.test.ts (+161). No product code touched. Matches the admitted contributor-claimed slice recorded on #2698 for #2665.
Branch repair (permissions checked)
maintainer_can_modify=true, PR open/mergeable/rebaseable.- Rebased the single commit onto current
dev(fbba1c67) and force-with-lease pushed to10kH:test/bash-state-exploit(lease verified againstbc7720ad). The PR now sits on the exact current dev tip. The single test file is byte-identical to the original commit's version (git diff bc7720ad 62cefa41a3 -- <file>empty; tree delta vs original head is only dev's ownagent-session-midrun-maintenance.test.ts).
Adversarial verification (real BashTool, workflow profile)
Ran at both the immutable base bbb2a51b41 and the rebased head; bash sources (bash.ts, bash-allowed-prefixes.ts, state-argv.ts) are byte-identical across the two, so results carry over 1:1.
- Real restricted-BashTool restriction — 8/8 pass at both points (
bun test packages/coding-agent/test/tools/bash-state-exploit.test.ts). - Exact rejection reasons — instrumented the real
execute(); each emitted reason matches the pinned substring:gjc state ralplan clear→does not allow `gjc state clear`;gjc state ralplan handoff→does not allow `gjc state handoff`;gjc state not-a-skill read→ "requires a canonical workflow skill";gjc state→ "requires a canonical workflow skill";gjc state ralplan --mode=obliterate→ "only allows documentedgjc stateaction shapes";rm -rf .gjc→ "only allows commands starting with: gjc ralplan --write, gjc state". - State byte identity / no
.gjccreation — sha256 of the seeded.gjc/state/ralplan-state.jsonunchanged in all 7 rejection cases; the absent-.gjccase asserts no tree is created. All pass. - Sanctioned-read positive control —
checkBashAllowedPrefixes("gjc state ralplan read --json", …, workflow)returns{"allowed":true}and the fullBashTool.execute()runs to completion (admitted past allowlist, interceptor, and composer gates). The suite cannot pass by blanket-blocking. - PATH/spawn false positives — the reason assertions are load-bearing: a bare spawn failure (e.g.
gjcnot on PATH in CI) cannot satisfy them. - Reversal guard mutation (1/7) — with the
if (!allowlist.allowed) throwatbash.ts:959-961neutralized: 1 pass / 7 fail (the sanctioned read stays green; all 7 rejection cases fail on a non-allowlist error). Guard restored byte-identically afterward; 8/8 green again. Matches the PR's reversal claim exactly. - No filesystem writes before guard — allowlist check runs inside
#prepareBashExecutionbefore any spawn/write (verified by execution order). Direct probe: all six blocked shapes against an empty temp dir leave it with zero entries before/after.
Tests
- Focused: PR test 8/8; classifier suite
bash-allowed-prefixes.test.ts36/36; bash-related files 164/164. - Broad:
packages/coding-agent/test/tools/(124 files) 1661 pass / 3 fail / 300 skip — the 3 failures (image-genx2, offline stealth benchmark) are pre-existing environment issues, reproduced identically at the pure base without this PR's file. - Exact-head CI: Dev CI run
31369836612on62cefa41a3completed success (gjc-state-gates / integrity / static / read / runtime, Local public surfaces, Telegram daemon generation guard, Affected path validation plan + native-build + the PR's owntest:…/bash-state-exploit.test.tsjob).mergeable_state: clean. The one earlier red job in that run was infra-only (node-pty prebuild downloadConnectTimeoutErrorduringbun install; the test file never executed) and was green on re-run — no PR code change involved.
Caveats
- No merge performed (per instruction).
devhas a large pre-existing red baseline independent of this branch (documented on the PR and #2698); this PR adds exactly +8 passing tests and no new failures.
— Signed: maintainer review, GJC gate-allowed lane.
probepark
left a comment
There was a problem hiding this comment.
Thanks for this — attacking the verification story rather than just adding another blocked-command case is the right instinct, and the test does drive the real production path. NEEDS-WORK: the coverage is narrower than the title claims, and I can demonstrate the gap.
Confirmed good
- Real path exercised. The test calls
BashTool.execute; production entry ispackages/coding-agent/src/tools/bash.ts:1186→#prepareBashExecutionat:1208→checkBashAllowedPrefixesat:953-960. Not a re-implementation of the check. - The guard is genuinely pinned. Mutation: guard present 8 pass / 0 fail, guard disabled 1 pass / 7 fail, restored 8/0.
- Neighbouring suites 100 pass / 0 fail;
check:typesexit 0; rebases cleanly. try/finallytempdir cleanup, nomock.module. Matches repo test rules.
Finding 1 — state comparison only covers one file
digestOf (bash-state-exploit.test.ts:72-74) hashes only ralplan-state.json, so "workflow state untouched" is really "this one file's bytes are unchanged".
I verified the gap precisely, because the first version of this finding was imprecise. Two mutations, both writing an unrelated .gjc/state/unrelated-state.json before rejecting:
| mutation | result |
|---|---|
| write the sibling file unconditionally | 7 pass / 1 fail — caught by does not create a state file that was absent before a blocked attempt (:112-124) |
write it only when ralplan-state.json already exists (the seeded-workspace case) |
8 pass / 0 fail — undetected |
So the absent-.gjc case is covered; the seeded case is not. A blocked command that drops a new file alongside existing state passes this suite clean. Snapshot the whole .gjc tree — entries, contents, file types, and symlink targets — rather than one file's digest.
Finding 2 — argv shapes, not escape boundaries
:81-91 covers clear, handoff, invalid/bare targets, malformed mode, and plain rm. It does not exercise $(...), backticks, ;, &&, pipes, redirection, env indirection, symlinks into .gjc/, or an allowed program spawning a command (find -exec, xargs, git -c core.pager=...).
Allowing ; through the production parser left the suite at 8 pass / 0 fail.
That matters for the title: "proves the restricted bash allowlist leaves workflow state untouched" reads as a property over the allowlist, but what is proven is that a handful of specific argvs are rejected. Either add representative escape cases through BashTool.execute, or narrow the title to the blocked state-action property you actually cover. Narrowing is a perfectly good outcome — an honest smaller claim beats an overclaiming test.
If you do add escape cases, note this repo uses the vendored brush shell (crates/pi-shell), so do not assume /bin/sh semantics — verify against the actual parser.
Finding 3 — the sanctioned-read control can pass while reads are rejected
:148-155 only excludes the generic prefix-error message. Removing "read" from production's allowed actions produced a different allowlist error and the suite stayed 8 pass / 0 fail — so the positive control does not actually prove reads are admitted.
Assert successful admission unambiguously (a controlled executable with an observable effect), or reject every allowlist error category rather than one message substring.
Net
No production change, real path, guard properly pinned. Fix the state snapshot to cover the whole tree and either broaden the cases or narrow the title, and this is an approve. Finding 3 is a small fix worth doing regardless.
Verification note: every mutation above was run by me against this PR head, and I corrected my own first attempt at Finding 1 when it turned out one existing test did catch the unconditional case.
…tree untouched Adds the real-BashTool exploit regression admitted on Yeachan-Heo#2698 for Yeachan-Heo#2665. The existing coverage (bash-allowed-prefixes.test.ts) asserts at the classifier level. Nothing drove BashTool.execute and nothing observed the filesystem, so the claim that a blocked attempt mutates nothing was untested: a guard can reject while an earlier path has already written. These cases instantiate a real restricted-profile BashTool, run destructive gjc state argv shapes and shell-escape routes through execute(), require the specific rejection reason, and compare a full snapshot of the .gjc tree - paths, node types, content hashes, symlink targets - before and after. Two properties are load-bearing. Pinning the reason: gjc is not on PATH in CI, so a bare rejects.toThrow() is satisfied by a spawn failure and stays green with the guard removed. Snapshotting the tree rather than one file: a blocked command that drops a sibling file next to existing state leaves the seeded file's bytes intact and would otherwise go unnoticed.
62cefa4 to
e551777
Compare
|
Both findings were correct and are fixed. Thanks for running the two mutations — the second one in particular is what made Finding 1 concrete rather than theoretical. Finding 1 — single-file digest → full tree snapshot
Replaced the digest with Reproduced your exact mutation — write Finding 2 — argv shapes → escape boundaries
Added 7 cases that all begin with an allowlisted prefix and then try to reach the filesystem by a different route, each pinning the layer that refuses: Reproduced your
TitleNarrowed to "prove blocked bash commands leave the .gjc state tree untouched". The previous wording read as a property over the whole allowlist; what is proven is that the covered destructive shapes and escape routes are refused and mutate nothing. Env indirection, symlinks into Verification15 pass / 0 fail. Guard mutation at CI note unchanged from the original submission: |
Yeachan-Heo
left a comment
There was a problem hiding this comment.
Review verdict: REQUEST_CHANGES
Reviewed exact head e551777009454db0ce97f133e30eb31327a72b57 against prior blocked head 62cefa41a30be710a6b1e9936037bf79d8f92245 and base 79e4e0a097aeb54cfe94f79b8919af778dbc5c68. The delta is test-only: packages/coding-agent/test/tools/bash-state-exploit.test.ts (+62/-8). The new escape-route cases do exercise the production BashTool.execute restriction path (bash.ts:1208 → #prepareBashExecution → checkBashAllowedPrefixes at :956) and the tree snapshot fixes the prior sibling-content gap, but two verification gaps remain.
Blocking 1 — the claimed full state-tree identity omits metadata
snapshotTree() at bash-state-exploit.test.ts:82-102 records paths, coarse node type, regular-file content hash, and symlink target. It records no mode/permissions, timestamps, ownership, or other stat metadata. The title claims blocked commands leave the .gjc state tree untouched, and the requested invariant includes metadata; the current oracle can return byte-identical snapshots after real state-tree mutation.
Exact isolated probe using the PR's snapshotTree() implementation:
add-unenumerated-file: DETECTED
change-sibling-content: DETECTED
delete-nested-file: DETECTED
rename-nested-file: DETECTED
change-symlink-target: DETECTED
chmod-file-metadata: MISSED
touch-file-metadata: MISSED
chmod-directory-metadata: MISSED
This confirms the first prior mutation is fixed for additions/content outside the original single-file digest, but the broader "untouched tree" oracle is still false-green for metadata. Seed representative nested entries and symlinks, and snapshot/assert the metadata the contract intends to preserve (at minimum portable mode/type metadata; handle timestamps deliberately if they are part of the claim). Keep the implementation platform-deterministic rather than serializing unstable OS-specific stat fields wholesale.
Blocking 2 — the allowed control still neither proves admission nor proves the detector sees mutation
The prior review's Finding 3 remains unchanged at bash-state-exploit.test.ts:194-214. The control executes gjc state ralplan read --json, accepts execution failure, and only excludes one generic prefix-error substring (:207-209). A different allowlist rejection still passes. The exact logical mutation remains:
alternate allowlist rejection accepted: true
allowed control performed observable mutation: false
The final snapshot assertion expects the tree to remain unchanged, so this control cannot establish that the snapshot detector observes a real allowed mutation. It only shows that one particular error string was absent. Use a controlled allowed command through the real production restriction path that performs an isolated, observable filesystem mutation, assert the snapshot changes, restore it, and separately pin successful allowlist admission. That prevents blanket-deny and inert-oracle false greens without leaking global state.
Verification
- Exact remote/base identity: head
e551777009454db0ce97f133e30eb31327a72b57, base79e4e0a097aeb54cfe94f79b8919af778dbc5c68. - GitHub reports
MERGEABLE/CLEAN; 14 checks succeeded, 0 failed or pending (platform/inapplicable jobs skipped). git diff --check 62cefa41a3..e551777009passed.- Focused CI job
Affected path validation / test:packages/coding-agent/test/tools/bash-state-exploit.test.tssucceeded at this exact head. - Local dependencies are absent, so I did not install them or claim a local focused-suite run. The two dependency-free mutation probes above ran with Bun 1.3.14 in isolated temp directories and cleaned up after themselves.
- Worktree remained clean; no source mutation, push, merge, or release performed.
— Signed: maintainer review, GJC gate-allowed lane.
Yeachan-Heo
left a comment
There was a problem hiding this comment.
Exact-head verdict: REQUEST_CHANGES
Reviewed exact head e551777009454db0ce97f133e30eb31327a72b57. The sanctioned-read control still has two P1 false-green paths.
P1 — admission is not proved (bash-state-exploit.test.ts:202-209)
The control catches every BashTool.execute() error, then excludes only:
restricted role-agent bash only allows commands starting with
Production reaches #prepareBashExecution at bash.ts:1208-1211, calls checkBashAllowedPrefixes at :953-958, and throws any classifier reason before execution at :959-960. After prefix matching, bash-allowed-prefixes.ts:281-294 can reject a state action with a different allowlist reason.
Deterministic minimal repro against the production classifier:
{
"read": { "allowed": true },
"denied": {
"allowed": false,
"reason": "restricted role-agent bash does not allow `gjc state clear`"
},
"currentControlPassesRead": true,
"currentControlAlsoPassesDenied": true
}The current predicate therefore cannot distinguish successful admission from an alternate pre-execution allowlist rejection.
Required regression: run a deterministic controlled allowed executable through the real BashTool.execute path and assert a sentinel execution effect that cannot exist when any allowlist rejection occurs. Admission and execution must be asserted separately from the blocked-command invariants.
P1 — detector sensitivity is not proved (bash-state-exploit.test.ts:210)
The control expects the .gjc snapshot to remain equal. No test applies a known isolated mutation and requires the snapshot to differ, so an inert or incomplete snapshot oracle can remain green.
Required regression: capture the seeded .gjc snapshot, apply one deterministic isolated mutation inside that same tree, assert the snapshot changes, restore it, and reassert equality. Keep this sensitivity control separate from the allowed-command execution-effect control and from every denied-path unchanged assertion.
No source mutation, push, merge, or release was performed.
— Signed: maintainer review, GJC gate-allowed lane.
Implements the slice admitted on #2698 for #2665:
Test-only. No product file is touched.
What was missing
test/tools/bash-allowed-prefixes.test.tsalready has 20 cases, but they callclassifyStateArgvandcheckBashAllowedPrefixesdirectly. Those are classifier-level assertions — they prove the policy function returnsallowed: false.Nothing constructed a
BashTool, so nothing exercised the rejection atbash.ts:600, and nothing looked at the filesystem. "The policy said no" and "the state file was not modified" are separate claims: a guard can reject while an earlier path has already written.What this adds
test/tools/bash-state-exploit.test.ts— 8 cases:gjc stateshapes (clear, handoff, unknown target, bare target, equals-form unknown mode), each driven throughBashTool.execute()underbashRestrictionProfile: "workflow"with the role-agent prefixes. Every one begins with an allowlisted prefix, so a naive prefix check would admit all five.sha256of a seeded.gjc/state/ralplan-state.jsonbefore and after..gjctree is created when none existed.rm -rf .gjc) blocked outright, state still byte-identical.gjc state ralplan read --json) that must not be rejected by the allowlist, so the suite cannot pass by blocking everything.Asserting the specific reason is load-bearing
My first draft used a bare
.rejects.toThrow(). The reversal proof caught it:gjcis not on PATH in CI, so those five cases threw a spawn failure and stayed green with the guard removed. They were passing for the wrong reason.Each case now pins the reason the runtime actually emits:
Reversal proof at
bash.ts:600(if (!allowlist.allowed)neutralized):The one that stays green is the sanctioned-read case, which is correct — it asserts admission, not rejection.
CI note
devis currently red independently of this branch, consistent with the 08-09 note on #2698 that the exact-head Dev CI run is terminal failure. Measured on both sides:Exactly +8 passes, no new failures. The 262 are inherited.