Skip to content

fix(command-risk): resolve relative targets after cd, stop reading redirects as operands - #1978

Open
ianalitis wants to merge 2 commits into
1jehuang:masterfrom
ianalitis:jcode/932-cd-tracking
Open

ianalitis wants to merge 2 commits into
1jehuang:masterfrom
ianalitis:jcode/932-cd-tracking

Conversation

@ianalitis

Copy link
Copy Markdown
Contributor

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. On master 1bccbff75, cd /etc && rm -rf passwd assesses as Low and runs immediately: the gate believes it is deleting <cwd>/passwd, so the protected-path tier is never consulted. Same for cd ~/.ssh && rm -rf * and cd && rm -rf Documents.

False positive (usability). rm -rf build 2>/dev/null was assessed as three deletions: build, the file-descriptor prefix 2, and the redirect destination /dev/null. /dev/null is in the protected-path tier, so the verdict was Catastrophic, which the bash gate hard-blocks with no justification escape. 2>>/dev/null and < /dev/null were 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. New DirectoryChange/directory_change() recognises cd through SHELL_CONTROL_PREFIXES and leading VAR=value assignments, skips flags and --, treats a bare cd as home, and returns Unknown for cd -, $VAR/backtick/glob destinations, or multi-operand forms. When the destination cannot be resolved, working_dir is 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)

  • A file-descriptor prefix is 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, exactly as the shell would.
  • Token::is_redirect_target now 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, so echo pwned > /etc/passwd is still Catastrophic.

Before / after

cwd = /home/u/proj; bare rm -rf build-xyz is Low on both sides.

command master 1bccbff75 this 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 Catastrophic [build-xyz, /dev/null] Low [build-xyz]
cd /tmp && rm -rf repo-xyz 2>/dev/null Low [/home/u/proj/repo-xyz, /2] Low [/tmp/repo-xyz]

Safety direction, same context. Measured on 1bccbff75 with a throwaway probe (assess() on each line, printing level and findings):

command master 1bccbff75 this branch
cd /etc && rm -rf passwd Low, target /home/u/proj/passwd Catastrophic, blocked
cd ~/.ssh && rm -rf * Low, target * Catastrophic, blocked
cd && rm -rf Documents Low, target /home/u/proj/Documents Catastrophic, blocked
echo pwned > /etc/passwd Catastrophic Catastrophic (unchanged)

Verification

  • cargo test -p jcode-command-risk → 88 passed, 0 failed
  • cargo clippy -p jcode-command-risk --all-targets -- -D warnings clean; cargo fmt applied
  • cargo 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 master left: Low, right: Catastrophic, because cd /tmp was ignored
  • assess_tests::issue_932_redirect_syntax_is_not_a_delete_target — fails on master at the /2 operand
  • tokenize_tests::redirect_destinations_are_marked_and_fd_prefixes_are_not_words

Interaction with #1973 / PR #1974

PR #1974 (background & as a segment separator) edits the same '&' | '|' match arm in tokenize(). This branch does not fix #1973: on top of it, sleep 1 & rm -rf ~ still assesses as Safe, 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 of assess_tests.rs. Landing order does not matter; whichever lands second needs that single-hunk rebase.

Notes


— Jcode agent (automated triage), on behalf of @1jehuang

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

greptile-apps Bot commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor

RetriggerView in GreptileConfidence Score: 0/5

[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

  1. P1 Security Scoped directory changes hide deletes ▶
  2. P1 Security Unknown directories hide protected targets ▶
  3. P1 Security Redirects use the wrong directory ▶
  4. P2 Quoted numeric filenames disappear ▶
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.

Summary

This PR tracks earlier cd commands and excludes redirect syntax from deletion targets.

  • Relative deletes follow the command line's latest known directory.
  • Redirect syntax stays out of delete target lists.

The protected-path regressions must be fixed before merging. The numeric-filename issue is non-blocking.

Acknowledged scope: ianalitis explicitly deferred background & handling to #1974 and heredoc handling to #922 as separate tokenizer defects. Neither is reported here.

Reviews (1) · Last reviewed commit: "fix(command-risk): stop reading redirect..." · Reviewed by Greptile

Comment on lines +229 to +232
match directory_change(&segment, &segment_ctx) {
DirectoryChange::None => {}
DirectoryChange::Known(dir) => cwd = Some(dir),
DirectoryChange::Unknown => cwd = None,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 security 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.

View artifacts

TREX 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,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 security 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.

View artifacts

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

Comment on lines +234 to +238
let segment_ctx = RiskContext {
working_dir: cwd.clone(),
..ctx.clone()
};
assess_segment(&segment, &segment_ctx, &mut findings);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 security 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.

View artifacts

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

Comment on lines +263 to +266
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;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

View artifacts

TREX 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!

This branch has not been deployed

No deployments
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.

Command risk gate rates sleep 1 & rm -rf ~ as Safe (background & not treated as separator)

1 participant