Repository navigation
fix(command-risk): resolve relative targets after cd, stop reading redirects as operands #1978
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -214,8 +214,28 @@ pub fn assess(command: &str, ctx: &RiskContext) -> RiskAssessment { | |
| }); | ||
| } | ||
|
|
||
| // A `cd` earlier in the same command line decides where later relative | ||
| // paths resolve (#932). Without that, `cd /etc && rm -rf passwd` was judged | ||
| // as a delete inside the workspace while the shell would remove | ||
| // `/etc/passwd`. Subshell parentheses are flattened away by the tokenizer, | ||
| // so a `cd` inside `( ... )` over-approximates the rest of the line; that | ||
| // direction only ever costs a confirmation. | ||
| let mut cwd = ctx.working_dir.clone(); | ||
| for segment in tokenize::split_segments(command) { | ||
| assess_segment(&segment, ctx, &mut findings); | ||
| let segment_ctx = RiskContext { | ||
| working_dir: cwd.clone(), | ||
| ..ctx.clone() | ||
| }; | ||
| match directory_change(&segment, &segment_ctx) { | ||
| DirectoryChange::None => {} | ||
| DirectoryChange::Known(dir) => cwd = Some(dir), | ||
| DirectoryChange::Unknown => cwd = None, | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Clearing 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. ArtifactsExecuted comparison and safe-shell runner
Executed candidate-specific Rust harness
Production refusal function compiled into the harness
Base assessment and gate results
HEAD assessment and gate results
Disposable-directory shell execution
Relevant source lines and evidence hashes
Prompt To Fix With AIThis 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); | ||
|
Comment on lines
+234
to
+238
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Redirects on 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. ArtifactsExecuted Rust harness for public assessment and gate
Executed independent comparison and disposable shell harness
Base execution denies the relative redirect
HEAD execution allows the same relative redirect
Exact numbered assessment and gate source
Prompt To Fix With AIThis 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. |
||
| } | ||
|
|
||
| // `"$(...)"`, backticks and unquoted heredoc bodies run commands the | ||
|
|
@@ -235,6 +255,67 @@ pub fn assess(command: &str, ctx: &RiskContext) -> RiskAssessment { | |
| RiskAssessment::from_findings(findings) | ||
| } | ||
|
|
||
| /// What a segment does to the shell's working directory. | ||
| enum DirectoryChange { | ||
| /// Not a `cd`. | ||
| None, | ||
| /// A `cd` whose destination can be resolved statically. | ||
| Known(std::path::PathBuf), | ||
| /// A `cd` whose destination depends on runtime state (`cd -`, `cd $VAR`). | ||
| Unknown, | ||
| } | ||
|
|
||
| /// Track `cd` so later relative operands resolve where the shell will be. | ||
| fn directory_change(tokens: &[Token], ctx: &RiskContext) -> DirectoryChange { | ||
| let mut rest = tokens; | ||
| while rest.len() > 1 && SHELL_CONTROL_PREFIXES.contains(&rest[0].text.as_str()) { | ||
| rest = &rest[1..]; | ||
| } | ||
| // Leading `VAR=value` assignments prefix a command without changing it. | ||
| while rest.len() > 1 && rest[0].text.contains('=') && !rest[0].is_flag() { | ||
| rest = &rest[1..]; | ||
| } | ||
| if rest.first().map(Token::basename).as_deref() != Some("cd") { | ||
| return DirectoryChange::None; | ||
| } | ||
|
|
||
| let mut destination: Option<&str> = None; | ||
| for token in rest.iter().skip(1) { | ||
| if token.is_operator || token.is_redirect_target { | ||
| continue; | ||
| } | ||
| // `cd -P /x`, `cd -- /x`: options precede the destination. | ||
| if destination.is_none() && (token.is_flag() || token.text == "--") { | ||
| continue; | ||
| } | ||
| if destination.is_some() { | ||
| // `cd a b` is an error in every shell, so the directory did not | ||
| // change in a way worth guessing at. | ||
| return DirectoryChange::Unknown; | ||
| } | ||
| destination = Some(token.text.as_str()); | ||
| } | ||
|
|
||
| let Some(destination) = destination else { | ||
| // A bare `cd` goes home. | ||
| return match &ctx.home_dir { | ||
| Some(home) => DirectoryChange::Known(home.clone()), | ||
| None => DirectoryChange::Unknown, | ||
| }; | ||
| }; | ||
| // `cd -` uses OLDPWD, and substitutions or globs need the shell's own | ||
| // expansion before the directory is known. | ||
| if destination == "-" || destination.contains(['$', '`', '*', '?']) { | ||
| return DirectoryChange::Unknown; | ||
| } | ||
| let resolved = paths::expand(destination, ctx); | ||
| if resolved.is_absolute() { | ||
| DirectoryChange::Known(resolved) | ||
| } else { | ||
| DirectoryChange::Unknown | ||
| } | ||
| } | ||
|
|
||
| fn assess_segment(tokens: &[Token], ctx: &RiskContext, findings: &mut Vec<RiskFinding>) { | ||
| // Shell redirects happen even when a wrapper only prints information. | ||
| for target in tokens.iter().filter(|t| t.is_truncating_redirect_target) { | ||
|
|
@@ -389,7 +470,7 @@ fn assess_segment(tokens: &[Token], ctx: &RiskContext, findings: &mut Vec<RiskFi | |
| let targets: Vec<&Token> = tokens | ||
| .iter() | ||
| .skip(1) | ||
| .filter(|t| !t.is_flag() && !t.is_operator && !t.is_truncating_redirect_target) | ||
| .filter(|t| !t.is_flag() && !t.is_operator && !t.is_redirect_target) | ||
| .collect(); | ||
|
|
||
| // A destructive command fed by a pipe takes its operands from the previous | ||
|
|
@@ -449,7 +530,7 @@ fn assess_find(tokens: &[Token], ctx: &RiskContext, findings: &mut Vec<RiskFindi | |
| .skip(1) | ||
| .skip_while(|t| matches!(t.text.as_str(), "-H" | "-L" | "-P" | "--")) | ||
| .take_while(|t| !t.is_flag() && !t.is_operator && !matches!(t.text.as_str(), "!" | "(")) | ||
| .filter(|t| !t.is_truncating_redirect_target) | ||
| .filter(|t| !t.is_redirect_target) | ||
| .collect(); | ||
| let classify_roots = |findings: &mut Vec<RiskFinding>| { | ||
| for raw in roots | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -14,6 +14,11 @@ pub struct Token { | |
| pub receives_pipe: bool, | ||
| /// True for `>` / `>|` redirect destinations, which are truncated on open. | ||
| pub is_truncating_redirect_target: bool, | ||
| /// True for every redirect destination (`>`, `>>`, `<`, `&>`). These are | ||
| /// never operands of the command, so they are excluded from target | ||
| /// collection; only the truncating subset is also classified as a write | ||
| /// (#932: `rm -rf x 2>/dev/null` reported `/dev/null` as a delete target). | ||
| pub is_redirect_target: bool, | ||
| /// True for control operators like `&&`, which are never path targets. | ||
| pub is_operator: bool, | ||
| } | ||
|
|
@@ -24,6 +29,7 @@ impl Token { | |
| text: text.into(), | ||
| receives_pipe: false, | ||
| is_truncating_redirect_target: false, | ||
| is_redirect_target: false, | ||
| is_operator: false, | ||
| } | ||
| } | ||
|
|
@@ -99,22 +105,40 @@ pub fn tokenize(command: &str) -> Vec<Token> { | |
| let mut has_content = false; | ||
| let mut chars = command.chars().peekable(); | ||
| let mut pending_redirect = false; | ||
| let mut pending_truncating = false; | ||
|
|
||
| macro_rules! flush { | ||
| () => { | ||
| () => {{ | ||
| if has_content { | ||
| let mut token = Token::word(std::mem::take(&mut current)); | ||
| if pending_redirect { | ||
| token.is_redirect_target = true; | ||
| token.is_truncating_redirect_target = pending_truncating; | ||
| } | ||
| #[allow(unused_assignments)] | ||
| { | ||
| if pending_redirect { | ||
| token.is_truncating_redirect_target = true; | ||
| pending_redirect = false; | ||
| } | ||
| has_content = false; | ||
| } | ||
| tokens.push(token); | ||
| #[allow(unused_assignments)] | ||
| { | ||
| pending_redirect = false; | ||
| pending_truncating = false; | ||
| } | ||
| } | ||
| }}; | ||
| } | ||
|
|
||
| // A redirect operator with no destination word leaves nothing to mark, and | ||
| // the flag must not carry over across a command boundary to a later word. | ||
| macro_rules! clear_redirect { | ||
| () => {{ | ||
| #[allow(unused_assignments)] | ||
| { | ||
| pending_redirect = false; | ||
| pending_truncating = false; | ||
| } | ||
| }; | ||
| }}; | ||
| } | ||
|
|
||
| while let Some(c) = chars.next() { | ||
|
|
@@ -154,12 +178,14 @@ pub fn tokenize(command: &str) -> Vec<Token> { | |
| ' ' | '\t' => flush!(), | ||
| '\n' | ';' => { | ||
| flush!(); | ||
| clear_redirect!(); | ||
| let mut op = Token::word(c.to_string()); | ||
| op.is_operator = true; | ||
| tokens.push(op); | ||
| } | ||
| '&' | '|' => { | ||
| flush!(); | ||
| clear_redirect!(); | ||
| let mut text = c.to_string(); | ||
| if chars.peek() == Some(&c) { | ||
| text.push(c); | ||
|
|
@@ -171,16 +197,19 @@ pub fn tokenize(command: &str) -> Vec<Token> { | |
| } | ||
| '(' | ')' => { | ||
| flush!(); | ||
| clear_redirect!(); | ||
| let mut op = Token::word(c.to_string()); | ||
| op.is_operator = true; | ||
| tokens.push(op); | ||
| } | ||
| '>' => { | ||
| drop_fd_prefix(&mut current, &mut has_content); | ||
| flush!(); | ||
| // `>>` appends and does not truncate, so it is far less | ||
| // destructive; only a single `>` clobbers. | ||
| if chars.peek() == Some(&'>') { | ||
| chars.next(); | ||
| pending_redirect = true; | ||
| } else if chars.peek() == Some(&'&') { | ||
| // `>&N` / `>&-` duplicate or close a file descriptor and | ||
| // never name a file to truncate. Consume the FD word. | ||
|
|
@@ -197,15 +226,21 @@ pub fn tokenize(command: &str) -> Vec<Token> { | |
| if fd.is_empty() { | ||
| // `>& file` (bash: redirect stdout+stderr) truncates. | ||
| pending_redirect = true; | ||
| pending_truncating = true; | ||
| } | ||
| } else { | ||
| if chars.peek() == Some(&'|') { | ||
| chars.next(); | ||
| } | ||
| pending_redirect = true; | ||
| pending_truncating = true; | ||
| } | ||
| } | ||
| '<' => flush!(), | ||
| '<' => { | ||
| drop_fd_prefix(&mut current, &mut has_content); | ||
| flush!(); | ||
| pending_redirect = true; | ||
| } | ||
| _ => { | ||
| has_content = true; | ||
| current.push(c); | ||
|
|
@@ -217,6 +252,21 @@ pub fn tokenize(command: &str) -> Vec<Token> { | |
| tokens | ||
| } | ||
|
|
||
| /// Drop a bare file-descriptor number that prefixes a redirect (`2>/dev/null`). | ||
| /// | ||
| /// The number is redirect syntax, not an operand, but it was emitted as an | ||
| /// ordinary word and then collected as a delete target: `rm -rf x 2>/dev/null` | ||
| /// reported a recursive delete of `.../2` (#932). A descriptor prefix has to be | ||
| /// adjacent to the operator, and a space flushes the word first, so only the | ||
| /// adjacent spelling reaches here. `rm -rf x 2 >/dev/null` really does name a | ||
| /// file called `2` and is left alone. | ||
| 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; | ||
|
Comment on lines
+263
to
+266
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
ArtifactsComplete Python validation driver
Complete Rust public API harness
Base revision behavior and Bash deletion checks
PR revision behavior and Bash deletion checks
Exact tokenizer, assessment, and gate source lines
Prompt To Fix With AIThis 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! |
||
| } | ||
| } | ||
|
|
||
| /// Remove heredoc payloads before tokenizing shell source. | ||
| /// | ||
| /// A heredoc body is input data for the command on the declaration line, not | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If the shell starts in
/etc,(cd /tmp); rm -rf passwdstill targets/etc/passwd: the subshell does not change the parent’s directory. A failedcd /missing-directory; rm -rf passwdalso leaves the deletion in/etc. The new directory tracking instead resolves these targets under/tmpor/missing-directoryand returnsLow, so the gate allows commands the base blocked asCatastrophic. This protected-file denial bypass must be fixed before merging: preserve shell scope and account for paths wherecdfails 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
Executed runner for isolated base and PR comparisons
Base results showing protected deletions denied
PR results showing protected deletions allowed
Relevant source lines and unchanged tracked-source status
Executed comparison and safe-shell runner
Executed candidate-specific Rust harness
Production refusal function compiled into the harness
Base assessment and gate results
HEAD assessment and gate results
Disposable-directory shell execution
Relevant source lines and evidence hashes
Prompt To Fix With AI