Skip to content

Fix redirect tokens (2>/dev/null) parsed as rm positional arguments#30

Open
pyxl-dev wants to merge 4 commits intokenryu42:mainfrom
pyxl-dev:fix/redirect-token-parsing
Open

Fix redirect tokens (2>/dev/null) parsed as rm positional arguments#30
pyxl-dev wants to merge 4 commits intokenryu42:mainfrom
pyxl-dev:fix/redirect-token-parsing

Conversation

@pyxl-dev
Copy link

@pyxl-dev pyxl-dev commented Mar 9, 2026

Summary

  • Fix false positive where rm -rf <valid-path> 2>/dev/null is blocked because shell-quote parses 2>/dev/null into separate string tokens ("2", "/dev/null") that get treated as rm target paths
  • Add isRedirectOp() helper and redirect handling in splitShellCommands() that strips fd numbers and skips redirect targets
  • Add 6 tests covering redirects with rm (allowed within cwd, allowed temp paths, still blocked outside cwd)

Root Cause

shell-quote parses 2>/dev/null into:

  1. "2" (string) → treated as positional arg to rm
  2. { op: ">" } (object) → skipped by non-string check
  3. "/dev/null" (string) → treated as positional arg to rm

Since /dev/null is outside cwd, the command is incorrectly blocked.

Fix

In splitShellCommands(), before the generic non-string token skip, detect redirect operators (>, >>, <, >&, <&, >|), pop any trailing bare digit from the current segment (the fd number), and skip the next token (the redirect target).

Test plan

  • bun run check passes (lint, types, dead code, 1057 tests)
  • New tests for rm -rf with 2>/dev/null, 2>&1, > /tmp/log
  • Verified rm -rf /outside/cwd 2>/dev/null is still blocked
  • Tested locally with Claude Code

Fix #29

Summary by CodeRabbit

  • Bug Fixes

    • Improved parsing of shell redirect operators so redirects (including numeric descriptors) are no longer misinterpreted as command arguments; handling applied to command splitting and command-substitution contexts.
  • Tests

    • Added comprehensive tests covering various redirect forms (e.g., stderr/stdout redirection, descriptor merges) to validate in-directory vs outside-directory behavior.

pyxl added 2 commits March 9, 2026 17:22
shell-quote parses "2>/dev/null" into ["2", {op:">"}, "/dev/null"],
leaving the fd number and redirect target as string tokens in the
same segment as the command. This causes false positives when
rm -rf is used with redirects, because the redirect target (e.g.
/dev/null) is treated as an rm target path outside cwd.

Add redirect operator handling in splitShellCommands() that strips
the fd number from the current segment and skips the redirect target.

Fix kenryu42#29
@coderabbitai
Copy link

coderabbitai bot commented Mar 9, 2026

📝 Walkthrough

Walkthrough

Added redirect-operator handling to shell command tokenization: introduced REDIRECT_OPS and isRedirectOp(), updated parsing in splitShellCommands() and extractCommandSubstitution (depth === 1) to remove preceding numeric file descriptors and skip redirect operator + target tokens. New tests verify rm -rf with redirects no longer yields false positives.

Changes

Cohort / File(s) Summary
Shell Command Parsing
src/core/shell.ts
Added REDIRECT_OPS constant and isRedirectOp(ParseEntry) helper. Updated splitShellCommands() to detect redirect operators, pop a preceding numeric FD token if present, and skip the operator plus its target (advance tokens). Applied same redirect handling inside extractCommandSubstitution for depth === 1.
Redirect Behavior Tests
tests/core/rules-rm.test.ts
Added "rm -rf with redirects" tests covering allowed in-CWD redirects (e.g., 2>/dev/null, 2>&1, > /tmp/log) and blocked outside-CWD cases; includes temp-dir setup/cleanup.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

🐰
Tokens hopped and caused a scare,
Redirects tangled everywhere.
I nudged the fd, then skipped the rest,
Now rm behaves and passes the test. 🎋

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately and concisely describes the main change: fixing the issue where redirect tokens like 2>/dev/null are incorrectly parsed as rm positional arguments.
Description check ✅ Passed The description is comprehensive, covering summary, root cause, fix approach, and test plan. However, it lacks a formal link to the issue and does not explicitly mention following CONTRIBUTING.md or testing requirements.
Linked Issues check ✅ Passed The PR addresses all requirements from issue #29: adds isRedirectOp() helper and redirect handling in splitShellCommands() to detect and skip redirect operators and their targets [#29], and includes tests for rm with redirects [#29].
Out of Scope Changes check ✅ Passed All changes are within scope: modifications to src/core/shell.ts handle redirect operators as required by #29, and new tests in tests/core/rules-rm.test.ts verify the fix aligns with the stated requirements.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@greptile-apps
Copy link

greptile-apps bot commented Mar 9, 2026

Greptile Summary

This PR fixes a false positive in the rm -rf safety check where shell commands like rm -rf /tmp/foo 2>/dev/null were incorrectly blocked. The root cause was that shell-quote parses 2>/dev/null into three tokens — "2" (string), { op: ">" } (object), and "/dev/null" (string) — and without redirect awareness, both "2" and "/dev/null" were passed to the rm path-checking logic, causing /dev/null (outside cwd) to trigger a block.

Key changes:

  • Adds REDIRECT_OPS set (>, >>, <, >&, <&, >|) and an isRedirectOp() helper in src/core/shell.ts
  • Inserts redirect handling in splitShellCommands() before the generic non-string token skip: pops a trailing pure-digit fd number from the current segment, then skips both the redirect operator and its target token (i += 2)
  • Adds 6 well-structured tests covering allowed cases (2>/dev/null, 2>&1, > /tmp/log) and a regression case confirming that rm -rf /outside/cwd 2>/dev/null remains blocked
  • Updates dist/ build artifacts to reflect the source changes

One logic concern: the /^\d+$/ heuristic used to identify fd numbers also matches pure-digit filenames (e.g. a directory literally named 123). If someone runs rm -rf 123 > /dev/null where 123 is a path outside the cwd, the 123 token gets popped and the command is no longer blocked. Tightening the regex to /^\d{1,2}$/ (fd numbers are always single or double digit) would eliminate this class of false negative.

Confidence Score: 4/5

  • Safe to merge with minor regex refinement to eliminate false negative on pure-digit filenames.
  • The fix is correct for the stated problem and is well-tested. The redirect-skipping logic properly handles standard redirects (2>/dev/null, 2>&1, etc.). However, the fd-number heuristic (/^\d+$/) over-matches pure-digit directory names, creating a rare but real false negative where rm -rf 123 > /dev/null would bypass the safety check if 123 is a directory outside the cwd. This is a legitimate edge case for a security tool, but unlikely to occur in practice. Tightening the regex to /^\d{1,2}$/ would resolve this without breaking any of the existing test cases.
  • src/core/shell.ts — The fd-number detection regex at line 44 should be refined from /^\d+$/ to /^\d{1,2}$/ to avoid matching multi-digit filenames.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[Next token from shell-quote] --> B{token undefined?}
    B -- yes --> INC[i++, continue]
    B -- no --> C{isOperator?\ne.g. &&, pipe, semicolon}
    C -- yes --> D[Push current segment\nReset current\ni++]
    C -- no --> E{isRedirectOp?\ne.g. >, >>, <, >&}
    E -- yes --> F{current not empty AND\ncurrent.last matches /^\\d+$/ ?}
    F -- yes --> G[Pop fd number\ne.g. pop '2' from 2>]
    F -- no --> H[Skip redirect op + target\ni += 2]
    G --> H
    E -- no --> I{typeof token !== string?}
    I -- yes --> INC2[i++, continue]
    I -- no --> J[Process string token\nAdd to current\ni++]
    D --> A
    H --> A
    INC --> A
    INC2 --> A
    J --> A
Loading

Last reviewed commit: 6896e62

Comment on lines +43 to +48
if (isRedirectOp(token)) {
if (current.length > 0 && /^\d+$/.test(current[current.length - 1] ?? '')) {
current.pop();
}
i += 2; // skip operator + redirect target
continue;
Copy link

Choose a reason for hiding this comment

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

Pure-digit filenames incorrectly stripped as fd numbers

The heuristic /^\d+$/ at line 44 pops the last current token when it's all digits, assuming it's an fd number (e.g. 2 in 2>/dev/null). However, it also incorrectly matches legitimate pure-digit filenames that happen to appear immediately before a redirect operator.

For example, consider rm -rf 123 > /dev/null where 123 is a real directory name outside the cwd. The parsed token sequence is ['rm', '-rf', '123', { op: '>' }, '/dev/null']. When the redirect op is encountered, '123' matches /^\d+$/, gets popped, and the final segment becomes ['rm', '-rf'] — no path argument, so the command is no longer blocked even though it should be.

This is a false negative in a security check. A safer alternative would be to only pop when the last token consists of exactly one or two digits (covering fd numbers 099):

if (current.length > 0 && /^\d{1,2}$/.test(current[current.length - 1] ?? '')) {

This still covers every realistic fd number while being far less likely to match a directory named with a pure-digit string of three or more characters.

Copy link

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 6896e622e3

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +47 to +48
i += 2; // skip operator + redirect target
continue;

Choose a reason for hiding this comment

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

P1 Badge Preserve nested command parsing in redirect targets

splitShellCommands() now advances by two tokens for every redirect, which also skips the $ token in targets like >$(git reset --hard). That prevents the existing $/( command-substitution branch from extracting the inner segment, so the dangerous command is no longer analyzed as its own command; in flows where fallback scanning is intentionally skipped (for example display-command heads in analyzeSegment), this becomes an allow-path for destructive nested commands.

Useful? React with 👍 / 👎.

Comment on lines +44 to +45
if (current.length > 0 && /^\d+$/.test(current[current.length - 1] ?? '')) {
current.pop();

Choose a reason for hiding this comment

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

P2 Badge Avoid stripping real numeric args before redirection

The fd cleanup pops any trailing all-digit token before a redirect, but that also removes legitimate positional arguments in commands like rm -rf 123 > /tmp/log (where 123 is a path, not an fd). After this pop, analyzeRm() can see no deletion target and return allow, which weakens protections (notably paranoid/unknown-cwd behavior) for numeric-only paths.

Useful? React with 👍 / 👎.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
src/core/shell.ts (1)

459-465: Rename the helper to match the internal-function convention.

This helper is module-private, so the repo guideline expects a leading underscore.

♻️ Suggested rename
-    if (isRedirectOp(token)) {
+    if (_isRedirectOp(token)) {
-function isRedirectOp(token: ParseEntry): boolean {
+function _isRedirectOp(token: ParseEntry): boolean {

As per coding guidelines, "Use leading underscore for private/internal module functions in TypeScript".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/core/shell.ts` around lines 459 - 465, Rename the module-private helper
function isRedirectOp to follow the internal-function convention by changing its
name to _isRedirectOp, and update every call site and export (if any) to use
_isRedirectOp; the function signature and logic using ParseEntry and
REDIRECT_OPS should remain the same, so search for references to isRedirectOp
and replace them with _isRedirectOp to keep consistency with the repository
guideline.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/core/shell.ts`:
- Around line 43-48: The top-level scanner strips redirection tokens but nested
command substitutions still include them; update extractCommandSubstitution (and
any helper that builds nested segments) to detect redirect operators using
isRedirectOp and skip the operator and its target just like the top-level loop:
if the token before a redirect is a bare number remove it from the current
segment (current.pop()), then advance the index to skip operator + redirect
target (i += 2) and continue, ensuring nested segments do not retain fd or
redirect targets.

---

Nitpick comments:
In `@src/core/shell.ts`:
- Around line 459-465: Rename the module-private helper function isRedirectOp to
follow the internal-function convention by changing its name to _isRedirectOp,
and update every call site and export (if any) to use _isRedirectOp; the
function signature and logic using ParseEntry and REDIRECT_OPS should remain the
same, so search for references to isRedirectOp and replace them with
_isRedirectOp to keep consistency with the repository guideline.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 2e4f5d2a-e2a4-47c3-83c2-3ac3b58bb08c

📥 Commits

Reviewing files that changed from the base of the PR and between 82f676b and 6896e62.

⛔ Files ignored due to path filters (2)
  • dist/bin/cc-safety-net.js is excluded by !**/dist/**
  • dist/index.js is excluded by !**/dist/**
📒 Files selected for processing (2)
  • src/core/shell.ts
  • tests/core/rules-rm.test.ts

- Limit fd number stripping to 1-2 digits (/^\d{1,2}$/) to avoid
  false negatives with numeric directory names like "123"
- Add redirect handling in extractCommandSubstitution() so redirects
  in $() subshells are also properly stripped
- Add tests for numeric dir before redirect and subshell redirect

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@kenryu42
Copy link
Owner

@pyxl-dev please address the codex's p1, p2

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
src/core/shell.ts (1)

38-49: Redirect stripping logic is sound, but consider edge case for malformed redirects.

The logic correctly handles the shell-quote tokenization of redirects. However, i += 2 assumes the redirect target token always exists. For a malformed command like rm -rf foo > (missing target), tokens[i+1] may be undefined, and skipping it is benign but leaves intent unclear.

Consider adding a bounds check for clarity and defensive coding:

🛡️ Optional: Add bounds check before skipping target
     if (isRedirectOp(token)) {
       if (current.length > 0 && /^\d{1,2}$/.test(current[current.length - 1] ?? '')) {
         current.pop();
       }
-      i += 2; // skip operator + redirect target
+      // skip operator; also skip redirect target if present
+      i += (i + 1 < tokens.length) ? 2 : 1;
       continue;
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/core/shell.ts` around lines 38 - 49, The redirect-handling block should
defensively check for the existence of the redirect target token before skipping
it: in the isRedirectOp(token) branch (function/variable names: isRedirectOp,
tokens, current, i) verify that tokens[i+1] exists (and is not another operator)
before doing the i += 2 skip; if tokens[i+1] is missing, only advance i by 1 (or
handle the malformed redirect explicitly) and leave a comment explaining the
defensive behavior so malformed inputs like "rm >" are handled clearly.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@src/core/shell.ts`:
- Around line 38-49: The redirect-handling block should defensively check for
the existence of the redirect target token before skipping it: in the
isRedirectOp(token) branch (function/variable names: isRedirectOp, tokens,
current, i) verify that tokens[i+1] exists (and is not another operator) before
doing the i += 2 skip; if tokens[i+1] is missing, only advance i by 1 (or handle
the malformed redirect explicitly) and leave a comment explaining the defensive
behavior so malformed inputs like "rm >" are handled clearly.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 25b37c83-d224-4255-abe8-92a6a16c447d

📥 Commits

Reviewing files that changed from the base of the PR and between 6896e62 and 0cccb48.

⛔ Files ignored due to path filters (2)
  • dist/bin/cc-safety-net.js is excluded by !**/dist/**
  • dist/index.js is excluded by !**/dist/**
📒 Files selected for processing (2)
  • src/core/shell.ts
  • tests/core/rules-rm.test.ts

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.

rm -rf false positive: 2>/dev/null redirect parsed as positional arguments

2 participants