Repository navigation
Conversation
1jehuang#932) The gate resolved every relative operand against the tool call's working directory, so `cd /etc && rm -rf passwd` was reported as a delete inside the workspace and ran immediately while the shell would remove /etc/passwd. The same mistake misattributed `cd /tmp && rm -rf build` to the workspace. assess() now walks the segments of a command line and carries the shell's working directory forward: a resolvable `cd` moves it, a bare `cd` goes home, and a destination that depends on runtime state (`cd -`, `cd $VAR`, globs) clears it so relative operands are never assumed to be inside the workspace. Regression test covers both directions: protected absolute paths after `cd` become Catastrophic, and temp or unknown destinations are no longer reported as workspace paths. cargo test -p jcode-command-risk: 86 passed.
…refs 1jehuang#932) `rm -rf build 2>/dev/null` was classified as three deletions: `build`, the file-descriptor prefix `2`, and the redirect destination `/dev/null`. Because `/dev/null` sits in the protected-path tier, the assessment was Catastrophic, which the bash gate hard-blocks with no `justification` escape, so the command could not be run at all. `2>>/dev/null` and `< /dev/null` were equally unrunnable. Two tokenizer defects, both in `crates/jcode-command-risk/src/tokenize.rs`: - A file-descriptor prefix was emitted as an ordinary word, so it was collected as an operand (`.../2`). It is now dropped when it is adjacent to the operator, which is the only spelling the shell treats as a descriptor. A space flushes the word first, so `rm -rf x 2 >/dev/null` still names a real file called `2`, as the shell would. - Only `>`/`>|` destinations were marked, so `>>` and `<` destinations stayed ordinary words and became delete targets. `Token::is_redirect_target` now marks every redirect destination; operand collection excludes all of them, while truncation classification still runs only on the truncating subset. A redirect operator with no destination no longer leaks its pending flag across a command boundary to a later word. Verdicts before -> after, `cwd = /home/u/proj` (bare `rm -rf build-xyz` is Low): | command | master | branch | |------------------------------------|---------------------------------|--------| | `rm -rf build-xyz 2>/dev/null` | Low [build-xyz, /home/u/proj/2] | Low [build-xyz] | | `rm -rf build-xyz 2> /dev/null` | Low [build-xyz, /home/u/proj/2] | Low [build-xyz] | | `rm -rf build-xyz >/dev/null 2>&1` | Low [build-xyz, /home/u/proj/2] | Low [build-xyz] | | `rm -rf build-xyz 2>>/dev/null` | Catastrophic [build-xyz, /2, /dev/null] | Low [build-xyz] | | `rm -rf build-xyz &>/dev/null` | Low [build-xyz] | Low [build-xyz] | | `rm -rf build-xyz < /dev/null` | Catastrophic [build-xyz, /dev/null] | Low [build-xyz] | Safety is unchanged: `echo pwned > /etc/passwd` is still Catastrophic, and every destination is still classified as a write, just not as an operand. Tests: `assess_tests::issue_932_redirect_syntax_is_not_a_delete_target` (fails on master at the `/2` operand, passes here) and `tokenize_tests::redirect_destinations_are_marked_and_fd_prefixes_are_not_words`. `cargo test -p jcode-command-risk` 88 passed, 0 failed; `cargo clippy -p jcode-command-risk --all-targets -- -D warnings` clean; `cargo test -p jcode-app-core --lib tool::bash -- --test-threads=1` 48 passed.
[Medium impact] The changes affect command-risk checks for shell commands. Not safe to merge until the three protected-path gate regressions are fixed; the numeric-filename issue is a separate, non-blocking usability regression. Findings
Fix with agent prompt### Issue 1
crates/jcode-command-risk/src/lib.rs:229-232
If the shell starts in `/etc`, `(cd /tmp); rm -rf passwd` still targets `/etc/passwd`: the subshell does not change the parent’s directory. A failed `cd /missing-directory; rm -rf passwd` also leaves the deletion in `/etc`. The new directory tracking instead resolves these targets under `/tmp` or `/missing-directory` and returns `Low`, so the gate allows commands the base blocked as `Catastrophic`. This protected-file denial bypass must be fixed before merging: preserve shell scope and account for paths where `cd` fails before replacing the directory used for later commands.
**How this was verified:** Both commands changed from denied to allowed in executed assessment checks, and disposable Bash equivalents deleted the original-directory file rather than the predicted target.
### Issue 2
crates/jcode-command-risk/src/lib.rs:232
Clearing `working_dir` does not escalate later relative targets. If the shell starts in `/etc` and `DEST=/etc`, `cd $DEST && rm -rf passwd` now returns `Low` and is allowed without justification; the base identified `/etc/passwd` and blocked it as `Catastrophic`. With no tracked directory, `paths::expand()` leaves `passwd` relative, and target classification treats it as a concrete, unprotected path. This denial bypass must be fixed before merging: carry the unknown-directory state into target checks and require confirmation when a destructive relative target’s location cannot be established.
**How this was verified:** Executed checks changed the command from Catastrophic/Deny to Low/Allow, and the production refusal function returned no refusal on HEAD.
### Issue 3
crates/jcode-command-risk/src/lib.rs:234-238
Redirects on `cd` are checked against the destination directory instead of where the shell opens them. If the shell starts in `/etc`, `cd /tmp > passwd` opens and truncates `/etc/passwd` before `cd` runs, provided the shell has write permission. The new ordering checks `/tmp/passwd` and returns `Safe`/`Allow`, where the base returned `Catastrophic`/`Deny`. This protected-write denial bypass must be fixed before merging: assess the current segment with its incoming directory, then update the directory for later segments.
**How this was verified:** Executed assessment checks reproduced Safe/Allow on HEAD, and a disposable Bash execution truncated the original-directory file before changing directories.
### Issue 4
crates/jcode-command-risk/src/tokenize.rs:263-266
`drop_fd_prefix()` also removes quoted or escaped numeric filenames. For example, `rm -rf "2">/dev/null` deletes the workspace directory named `2`, but the checker drops that argument and requests a justification retry because no target remains. Executed checks show the same regression for escaped and single-quoted spellings: `Low`/`Allow` becomes `Confirm`/`Reflect`. This is a non-blocking usability issue that adds an unnecessary retry and omits the actual filename from the findings. Only discard adjacent numeric prefixes that were entirely unquoted and unescaped.
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.SummaryThis PR tracks earlier
The protected-path regressions must be fixed before merging. The numeric-filename issue is non-blocking. Acknowledged scope: Reviews (1) · Last reviewed commit: "fix(command-risk): stop reading redirect..." · Reviewed by Greptile |
| match directory_change(&segment, &segment_ctx) { | ||
| DirectoryChange::None => {} | ||
| DirectoryChange::Known(dir) => cwd = Some(dir), | ||
| DirectoryChange::Unknown => cwd = None, |
There was a problem hiding this comment.
Scoped directory changes hide deletes
If the shell starts in /etc, (cd /tmp); rm -rf passwd still targets /etc/passwd: the subshell does not change the parent’s directory. A failed cd /missing-directory; rm -rf passwd also leaves the deletion in /etc. The new directory tracking instead resolves these targets under /tmp or /missing-directory and returns Low, so the gate allows commands the base blocked as Catastrophic. This protected-file denial bypass must be fixed before merging: preserve shell scope and account for paths where cd fails before replacing the directory used for later commands.
How this was verified: Both commands changed from denied to allowed in executed assessment checks, and disposable Bash equivalents deleted the original-directory file rather than the predicted target.
Artifacts
Executed Rust harness for public API checks and disposable deletions
- The compiled harness calls assess and gate for the exact reports and executes only disposable Bash equivalents, asserting both the verdicts and actual deletion targets.
Executed runner for isolated base and PR comparisons
- The runner exports each pinned revision’s leaf sources, compiles the same Rust harness, and captures real command output with exit codes, making the comparison reproducible.
Base results showing protected deletions denied
- The base run captures Catastrophic and Deny for both reported commands plus disposable shell deletion assertions, showing the original policy blocked these targets.
PR results showing protected deletions allowed
- The PR run captures Low and Allow for both reported commands while disposable Bash executions delete the original fixtures, confirming the safety regression.
Relevant source lines and unchanged tracked-source status
- Executed git and numbered-source commands capture the PR revision, cwd propagation, gate behavior, and working-tree status, locating the defect without modifying shared source.
Executed comparison and safe-shell runner
- The executed Python runner builds both revisions, captures their behavior, and runs a disposable shell analogue, making the verification reproducible.
Executed candidate-specific Rust harness
- The same Rust harness checks assessment, gate, and consumer refusal on both revisions, isolating the claimed regression.
Production refusal function compiled into the harness
- The runner extracts and verifies identical production refusal-function source from both revisions, exercising the real decision logic rather than a rewritten gate.
Base assessment and gate results
- Compiled base and executed the harness with DEST=/etc and assessment cwd=/etc, showing the candidate was Catastrophic and denied.
HEAD assessment and gate results
- Compiled HEAD and executed the identical harness inputs, showing the candidate becomes Low and allowed while both controls remain denied.
Disposable-directory shell execution
- Ran the exact candidate shell string with DEST pointing only to a disposable directory, confirming passwd is removed and the neighboring sentinel survives.
Relevant source lines and evidence hashes
- Captured numbered production source, revision identity, repository status, and evidence hashes, tracing the allowed verdict to the cwd-clearing change without tracked source edits.
Ran code and verified through TREX
Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/jcode-command-risk/src/lib.rs
Line: 229-232
Comment:
**Scoped directory changes hide deletes**
If the shell starts in `/etc`, `(cd /tmp); rm -rf passwd` still targets `/etc/passwd`: the subshell does not change the parent’s directory. A failed `cd /missing-directory; rm -rf passwd` also leaves the deletion in `/etc`. The new directory tracking instead resolves these targets under `/tmp` or `/missing-directory` and returns `Low`, so the gate allows commands the base blocked as `Catastrophic`. This protected-file denial bypass must be fixed before merging: preserve shell scope and account for paths where `cd` fails before replacing the directory used for later commands.
**How this was verified:** Both commands changed from denied to allowed in executed assessment checks, and disposable Bash equivalents deleted the original-directory file rather than the predicted target.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.| match directory_change(&segment, &segment_ctx) { | ||
| DirectoryChange::None => {} | ||
| DirectoryChange::Known(dir) => cwd = Some(dir), | ||
| DirectoryChange::Unknown => cwd = None, |
There was a problem hiding this comment.
Unknown directories hide protected targets
Clearing working_dir does not escalate later relative targets. If the shell starts in /etc and DEST=/etc, cd $DEST && rm -rf passwd now returns Low and is allowed without justification; the base identified /etc/passwd and blocked it as Catastrophic. With no tracked directory, paths::expand() leaves passwd relative, and target classification treats it as a concrete, unprotected path. This denial bypass must be fixed before merging: carry the unknown-directory state into target checks and require confirmation when a destructive relative target’s location cannot be established.
How this was verified: Executed checks changed the command from Catastrophic/Deny to Low/Allow, and the production refusal function returned no refusal on HEAD.
Artifacts
Executed comparison and safe-shell runner
- The executed Python runner builds both revisions, captures their behavior, and runs a disposable shell analogue, making the verification reproducible.
Executed candidate-specific Rust harness
- The same Rust harness checks assessment, gate, and consumer refusal on both revisions, isolating the claimed regression.
Production refusal function compiled into the harness
- The runner extracts and verifies identical production refusal-function source from both revisions, exercising the real decision logic rather than a rewritten gate.
Base assessment and gate results
- Compiled base and executed the harness with DEST=/etc and assessment cwd=/etc, showing the candidate was Catastrophic and denied.
HEAD assessment and gate results
- Compiled HEAD and executed the identical harness inputs, showing the candidate becomes Low and allowed while both controls remain denied.
Disposable-directory shell execution
- Ran the exact candidate shell string with DEST pointing only to a disposable directory, confirming passwd is removed and the neighboring sentinel survives.
Relevant source lines and evidence hashes
- Captured numbered production source, revision identity, repository status, and evidence hashes, tracing the allowed verdict to the cwd-clearing change without tracked source edits.
Ran code and verified through TREX
Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/jcode-command-risk/src/lib.rs
Line: 232
Comment:
**Unknown directories hide protected targets**
Clearing `working_dir` does not escalate later relative targets. If the shell starts in `/etc` and `DEST=/etc`, `cd $DEST && rm -rf passwd` now returns `Low` and is allowed without justification; the base identified `/etc/passwd` and blocked it as `Catastrophic`. With no tracked directory, `paths::expand()` leaves `passwd` relative, and target classification treats it as a concrete, unprotected path. This denial bypass must be fixed before merging: carry the unknown-directory state into target checks and require confirmation when a destructive relative target’s location cannot be established.
**How this was verified:** Executed checks changed the command from Catastrophic/Deny to Low/Allow, and the production refusal function returned no refusal on HEAD.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.| let segment_ctx = RiskContext { | ||
| working_dir: cwd.clone(), | ||
| ..ctx.clone() | ||
| }; | ||
| assess_segment(&segment, &segment_ctx, &mut findings); |
There was a problem hiding this comment.
Redirects use the wrong directory
Redirects on cd are checked against the destination directory instead of where the shell opens them. If the shell starts in /etc, cd /tmp > passwd opens and truncates /etc/passwd before cd runs, provided the shell has write permission. The new ordering checks /tmp/passwd and returns Safe/Allow, where the base returned Catastrophic/Deny. This protected-write denial bypass must be fixed before merging: assess the current segment with its incoming directory, then update the directory for later segments.
How this was verified: Executed assessment checks reproduced Safe/Allow on HEAD, and a disposable Bash execution truncated the original-directory file before changing directories.
Artifacts
Executed Rust harness for public assessment and gate
- Compiled and executed against both revisions, this harness assesses the candidate and two controls without sending any protected-path command to a shell.
Executed independent comparison and disposable shell harness
- Executed to extract and compile each revision in unique temporary paths, run the public APIs, and verify shell redirection ordering using disposable sentinel files.
Base execution denies the relative redirect
- Captured the base public API run and disposable Bash analogue, showing Catastrophic/Deny and truncation in the original directory.
HEAD execution allows the same relative redirect
- Captured the HEAD public API run and disposable Bash analogue, showing Safe/Allow despite Bash truncating the original-directory file.
Exact numbered assessment and gate source
- Captured the revision, working-tree status, and numbered base and HEAD source lines, locating the premature cwd update and resulting allow branch.
Ran code and verified through TREX
Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/jcode-command-risk/src/lib.rs
Line: 234-238
Comment:
**Redirects use the wrong directory**
Redirects on `cd` are checked against the destination directory instead of where the shell opens them. If the shell starts in `/etc`, `cd /tmp > passwd` opens and truncates `/etc/passwd` before `cd` runs, provided the shell has write permission. The new ordering checks `/tmp/passwd` and returns `Safe`/`Allow`, where the base returned `Catastrophic`/`Deny`. This protected-write denial bypass must be fixed before merging: assess the current segment with its incoming directory, then update the directory for later segments.
**How this was verified:** Executed assessment checks reproduced Safe/Allow on HEAD, and a disposable Bash execution truncated the original-directory file before changing directories.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.| fn drop_fd_prefix(current: &mut String, has_content: &mut bool) { | ||
| if *has_content && current.chars().all(|c| c.is_ascii_digit()) { | ||
| current.clear(); | ||
| *has_content = false; |
There was a problem hiding this comment.
Quoted numeric filenames disappear
drop_fd_prefix() also removes quoted or escaped numeric filenames. For example, rm -rf "2">/dev/null deletes the workspace directory named 2, but the checker drops that argument and requests a justification retry because no target remains. Executed checks show the same regression for escaped and single-quoted spellings: Low/Allow becomes Confirm/Reflect. This is a non-blocking usability issue that adds an unnecessary retry and omits the actual filename from the findings. Only discard adjacent numeric prefixes that were entirely unquoted and unescaped.
Artifacts
Complete Python validation driver
- The executed driver independently compiles both revisions and runs disposable-directory Bash checks, providing the complete reproducible procedure.
Complete Rust public API harness
- The executed Rust source calls tokenizer, assessment, and gate APIs for five spellings and asserts revision-specific results, reproducing the operand-loss regression.
Base revision behavior and Bash deletion checks
- The base library preserves quoted and escaped numeric operands while Bash deletes the disposable numeric paths, establishing the working baseline.
PR revision behavior and Bash deletion checks
- The PR library loses quoted and escaped numeric operands and requests reflection while Bash still deletes those paths, confirming the regression.
Exact tokenizer, assessment, and gate source lines
- A numbered source capture records the descriptor-dropping helper, no-target escalation, gate mapping, and repository status, locating the defect without shared source edits.
Ran code and verified through TREX
Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/jcode-command-risk/src/tokenize.rs
Line: 263-266
Comment:
**Quoted numeric filenames disappear**
`drop_fd_prefix()` also removes quoted or escaped numeric filenames. For example, `rm -rf "2">/dev/null` deletes the workspace directory named `2`, but the checker drops that argument and requests a justification retry because no target remains. Executed checks show the same regression for escaped and single-quoted spellings: `Low`/`Allow` becomes `Confirm`/`Reflect`. This is a non-blocking usability issue that adds an unnecessary retry and omits the actual filename from the findings. Only discard adjacent numeric prefixes that were entirely unquoted and unescaped.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
What this fixes
#932 reports two classes of defect in
jcode-command-risk, and the second one is the more serious of the two.False negative (safety). Relative targets were resolved against the tool call's cwd, ignoring an earlier
cd. Onmaster1bccbff75,cd /etc && rm -rf passwdassesses asLowand runs immediately: the gate believes it is deleting<cwd>/passwd, so the protected-path tier is never consulted. Same forcd ~/.ssh && rm -rf *andcd && rm -rf Documents.False positive (usability).
rm -rf build 2>/dev/nullwas assessed as three deletions:build, the file-descriptor prefix2, and the redirect destination/dev/null./dev/nullis in the protected-path tier, so the verdict wasCatastrophic, which the bash gate hard-blocks with nojustificationescape.2>>/dev/nulland< /dev/nullwere equally unrunnable, so ordinary cleanup lines could not be run at all.Two commits, both in
crates/jcode-command-risk:1. Resolve relative targets against an earlier
cd(bug 3 in the report)assess()now walks segments carrying the working directory forward instead of resolving every segment against the tool call's cwd. NewDirectoryChange/directory_change()recognisescdthroughSHELL_CONTROL_PREFIXESand leadingVAR=valueassignments, skips flags and--, treats a barecdas home, and returnsUnknownforcd -,$VAR/backtick/glob destinations, or multi-operand forms. When the destination cannot be resolved,working_diris cleared for the following segments, so relative operands are never assumed to be inside the workspace.2. Stop reading redirect syntax as an operand (bugs 1 and 2)
rm -rf x 2 >/dev/nullstill names a real file called2, exactly as the shell would.Token::is_redirect_targetnow marks every redirect destination (>,>>,<,&>), not just the truncating ones, and operand collection excludes all of them. Truncation classification still runs only on the truncating subset, soecho pwned > /etc/passwdis stillCatastrophic.Before / after
cwd = /home/u/proj; barerm -rf build-xyzisLowon both sides.1bccbff75rm -rf build-xyz 2>/dev/nullLow[build-xyz, /home/u/proj/2]Low[build-xyz]rm -rf build-xyz 2> /dev/nullLow[build-xyz, /home/u/proj/2]Low[build-xyz]rm -rf build-xyz >/dev/null 2>&1Low[build-xyz, /home/u/proj/2]Low[build-xyz]rm -rf build-xyz 2>>/dev/nullCatastrophic[build-xyz, /2, /dev/null]Low[build-xyz]rm -rf build-xyz < /dev/nullCatastrophic[build-xyz, /dev/null]Low[build-xyz]cd /tmp && rm -rf repo-xyz 2>/dev/nullLow[/home/u/proj/repo-xyz, /2]Low[/tmp/repo-xyz]Safety direction, same context. Measured on
1bccbff75with a throwaway probe (assess()on each line, printing level and findings):1bccbff75cd /etc && rm -rf passwdLow, target/home/u/proj/passwdCatastrophic, blockedcd ~/.ssh && rm -rf *Low, target*Catastrophic, blockedcd && rm -rf DocumentsLow, target/home/u/proj/DocumentsCatastrophic, blockedecho pwned > /etc/passwdCatastrophicCatastrophic(unchanged)Verification
cargo test -p jcode-command-risk→ 88 passed, 0 failedcargo clippy -p jcode-command-risk --all-targets -- -D warningsclean;cargo fmtappliedcargo test -p jcode-app-core --lib tool::bash -- --test-threads=1→ 48 passed, 0 failed (the consumer of this crate)New tests, each confirmed failing on
1bccbff75:assess_tests::issue_932_a_leading_cd_moves_where_relative_targets_resolve— on masterleft: Low, right: Catastrophic, becausecd /tmpwas ignoredassess_tests::issue_932_redirect_syntax_is_not_a_delete_target— fails on master at the/2operandtokenize_tests::redirect_destinations_are_marked_and_fd_prefixes_are_not_wordsInteraction with #1973 / PR #1974
PR #1974 (background
&as a segment separator) edits the same'&' | '|'match arm intokenize(). This branch does not fix #1973: on top of it,sleep 1 & rm -rf ~still assesses asSafe, because&is not a segment separator. The two changes are complementary and were stacked locally: rebased #1974 + this branch →89 passed, 0 failed, with one trivial append conflict at the end ofassess_tests.rs. Landing order does not matter; whichever lands second needs that single-hunk rebase.Notes
cdtracking is static: a destination that depends on runtime state is treated as unknown and escalates rather than guessing.— Jcode agent (automated triage), on behalf of @1jehuang