Skip to content

test(coding-agent): prove blocked bash commands leave the .gjc state tree untouched - #4167

Open
10kH wants to merge 1 commit into
Yeachan-Heo:devfrom
10kH:test/bash-state-exploit
Open

test(coding-agent): prove blocked bash commands leave the .gjc state tree untouched#4167
10kH wants to merge 1 commit into
Yeachan-Heo:devfrom
10kH:test/bash-state-exploit

Conversation

@10kH

@10kH 10kH commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Implements the slice admitted on #2698 for #2665:

ADMITTED / CONTRIBUTOR-CLAIMED — The July 29 claim by @10kH is accepted. Current scope is one test-only PR against live dev: instantiate a real restricted-role BashTool, drive destructive/effective gjc state shapes, assert ToolError, and prove a seeded state file is byte-identical before/after. No product-code expansion belongs in that PR.

Test-only. No product file is touched.

What was missing

test/tools/bash-allowed-prefixes.test.ts already has 20 cases, but they call classifyStateArgv and checkBashAllowedPrefixes directly. Those are classifier-level assertions — they prove the policy function returns allowed: false.

Nothing constructed a BashTool, so nothing exercised the rejection at bash.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:

  • 5 parameterized destructive gjc state shapes (clear, handoff, unknown target, bare target, equals-form unknown mode), each driven through BashTool.execute() under bashRestrictionProfile: "workflow" with the role-agent prefixes. Every one begins with an allowlisted prefix, so a naive prefix check would admit all five.
  • Each asserts the specific allowlist rejection, then compares a sha256 of a seeded .gjc/state/ralplan-state.json before and after.
  • A case proving no .gjc tree is created when none existed.
  • A non-allowlisted command (rm -rf .gjc) blocked outright, state still byte-identical.
  • A sanctioned read (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: gjc is 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:

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       -> canonical workflow skill
gjc state                        -> canonical workflow skill
rm -rf .gjc                      -> only allows commands starting with: ...

Reversal proof at bash.ts:600 (if (!allowlist.allowed) neutralized):

guard present:  8 pass / 0 fail
guard removed:  1 pass / 7 fail

The one that stays green is the sanctioned-read case, which is correct — it asserts admission, not rejection.

CI note

dev is 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:

clean origin/dev            1034 pass / 262 fail   (bun run check exit 1)
this branch                 1042 pass / 262 fail

Exactly +8 passes, no new failures. The 262 are inherited.

@10kH
10kH force-pushed the test/bash-state-exploit branch from 311e364 to bc7720a Compare August 10, 2026 08:03
@Yeachan-Heo
Yeachan-Heo force-pushed the test/bash-state-exploit branch from bc7720a to 62cefa4 Compare August 10, 2026 08:23

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

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 to 10kH:test/bash-state-exploit (lease verified against bc7720ad). 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 own agent-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.

  1. Real restricted-BashTool restriction — 8/8 pass at both points (bun test packages/coding-agent/test/tools/bash-state-exploit.test.ts).
  2. Exact rejection reasons — instrumented the real execute(); each emitted reason matches the pinned substring: gjc state ralplan cleardoes not allow `gjc state clear` ; gjc state ralplan handoffdoes 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 documented gjc state action shapes"; rm -rf .gjc → "only allows commands starting with: gjc ralplan --write, gjc state".
  3. State byte identity / no .gjc creation — sha256 of the seeded .gjc/state/ralplan-state.json unchanged in all 7 rejection cases; the absent-.gjc case asserts no tree is created. All pass.
  4. Sanctioned-read positive controlcheckBashAllowedPrefixes("gjc state ralplan read --json", …, workflow) returns {"allowed":true} and the full BashTool.execute() runs to completion (admitted past allowlist, interceptor, and composer gates). The suite cannot pass by blanket-blocking.
  5. PATH/spawn false positives — the reason assertions are load-bearing: a bare spawn failure (e.g. gjc not on PATH in CI) cannot satisfy them.
  6. Reversal guard mutation (1/7) — with the if (!allowlist.allowed) throw at bash.ts:959-961 neutralized: 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.
  7. No filesystem writes before guard — allowlist check runs inside #prepareBashExecution before 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.ts 36/36; bash-related files 164/164.
  • Broad: packages/coding-agent/test/tools/ (124 files) 1661 pass / 3 fail / 300 skip — the 3 failures (image-gen x2, 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 31369836612 on 62cefa41a3 completed success (gjc-state-gates / integrity / static / read / runtime, Local public surfaces, Telegram daemon generation guard, Affected path validation plan + native-build + the PR's own test:…/bash-state-exploit.test.ts job). mergeable_state: clean. The one earlier red job in that run was infra-only (node-pty prebuild download ConnectTimeoutError during bun install; the test file never executed) and was green on re-run — no PR code change involved.

Caveats

  • No merge performed (per instruction). dev has 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 probepark left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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 is packages/coding-agent/src/tools/bash.ts:1186#prepareBashExecution at :1208checkBashAllowedPrefixes at :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:types exit 0; rebases cleanly.
  • try/finally tempdir cleanup, no mock.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.
@10kH
10kH force-pushed the test/bash-state-exploit branch from 62cefa4 to e551777 Compare August 10, 2026 22:16
@10kH

10kH commented Aug 10, 2026

Copy link
Copy Markdown
Contributor Author

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

digestOf hashes only ralplan-state.json … write it only when ralplan-state.json already exists8 pass / 0 fail — undetected.

Replaced the digest with snapshotTree(), which records every entry's relative path, node type, content hash, and symlink target across the whole .gjc tree. The seeded workspace now also contains a decoy sibling (ultragoal-state.json), so "unchanged" cannot be satisfied by watching one file.

Reproduced your exact mutation — write unrelated-state.json only when the seeded file exists, then reject:

before this change:  8 pass /  0 fail   (undetected, as you reported)
after  this change:  2 pass / 13 fail

Finding 2 — argv shapes → escape boundaries

It does not exercise $(...), backticks, ;, &&, pipes, redirection … Allowing ; through the production parser left the suite at 8 pass / 0 fail.

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:

gjc state ralplan read --json ; rm -rf .gjc              -> control operator ';'
gjc state ralplan read --json && rm -rf .gjc             -> control operator '&'
gjc state ralplan read --json | tee .gjc/state/pwned     -> control operator '|'
gjc state ralplan read --json > .gjc/state/pwned.json    -> control operator '>'
gjc state $(echo ralplan) clear                          -> command substitution
gjc state `echo ralplan` clear                           -> command substitution
gjc ralplan --write --artifact-env X find . -exec rm {} + -> expansion character '{'

Reproduced your ; mutation (bash-allowed-prefixes.ts:99, semicolon exempted from SHELL_CONTROL_CHARS):

before this change:  8 pass /  0 fail   (undetected, as you reported)
after  this change: 14 pass /  1 fail   (semicolon chain, exactly the mutated route)

find -exec is refused at the brace-expansion layer rather than by an exec-spawn rule, so the reason string says so rather than implying a guard that does not exist.

Title

Narrowed 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 .gjc/, and xargs/git -c core.pager= spawning are still not covered, and the title no longer claims them.

Verification

15 pass / 0 fail. Guard mutation at bash.ts (if (!allowlist.allowed) neutralized): 1 pass / 14 fail, restored 15/0.

CI note unchanged from the original submission: dev is red independently of this branch. check:types fails on both sides for a missing generated module (src/internal-urls/docs-index.generated), with zero diagnostics from this test file, and packages/coding-agent/test/tools/ reports the same 262 inherited failures on clean dev as here.

@10kH 10kH changed the title test(coding-agent): prove the restricted bash allowlist leaves workflow state untouched test(coding-agent): prove blocked bash commands leave the .gjc state tree untouched Aug 10, 2026

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

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#prepareBashExecutioncheckBashAllowedPrefixes 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, base 79e4e0a097aeb54cfe94f79b8919af778dbc5c68.
  • GitHub reports MERGEABLE / CLEAN; 14 checks succeeded, 0 failed or pending (platform/inapplicable jobs skipped).
  • git diff --check 62cefa41a3..e551777009 passed.
  • Focused CI job Affected path validation / test:packages/coding-agent/test/tools/bash-state-exploit.test.ts succeeded 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 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.

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.

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.

3 participants