Skip to content

Fix shell quote bypass in terminal permission system#48436

Merged
rtfeldman merged 1 commit intomainfrom
fix-shell-quote-bypass
Feb 5, 2026
Merged

Fix shell quote bypass in terminal permission system#48436
rtfeldman merged 1 commit intomainfrom
fix-shell-quote-bypass

Conversation

@rtfeldman
Copy link
Contributor

@rtfeldman rtfeldman commented Feb 5, 2026

Problem

Zed's terminal permission system validates commands using regex patterns against string representations of parsed commands. The SimpleCommand::to_string() method in brush-parser preserves the raw lexical form including shell quotes, causing pattern matching to fail on obfuscated commands.

For example, all of these are semantically equivalent to rm -rf / in POSIX shells, but produced different strings from to_string() that bypassed the hardcoded deny pattern rm\s+(-[rf]+\s+)*/\s*$:

Input extract_commands() returned Matched deny pattern?
rm -rf / "rm -rf /" ✅ Yes
rm -rf '/' "rm -rf '/'" ❌ No
'rm' -rf / "'rm' -rf /" ❌ No
r'm' -rf / "r'm' -rf /" ❌ No
rm -r'f' / "rm -r'f' /" ❌ No
rm -rf \/ "rm -rf \\/" ❌ No

Both hardcoded deny patterns (Phase 2, which operates on extracted sub-commands) and user-configured always_deny patterns were affected.

Fix

Replace the simple_command.to_string() call in extract_commands_from_simple_command with manual construction that:

  1. Iterates the command name (word_or_name) and word arguments from the suffix
  2. Normalizes each word by parsing it into WordPieces via brush_parser::word::parse
  3. Reconstructs the semantic (unquoted) value from each piece:
    • Text — used as-is
    • SingleQuotedText — used as-is (brush-parser already strips the wrapping quotes)
    • EscapeSequence — leading \ stripped to produce the unescaped character
    • AnsiCQuotedText — used as-is (brush-parser already evaluates $'...')
    • DoubleQuotedSequence / GettextDoubleQuotedSequence — inner pieces recursively normalized
    • TildePrefix — preserved as ~ + suffix
    • ParameterExpansion / CommandSubstitution / BackquotedCommandSubstitution / ArithmeticExpression — original source text preserved so patterns like \$HOME and \${HOME} continue to match
  4. Joins normalized words with spaces

Falls back to the raw word.value if word parsing fails.

Cross-shell safety

The normalization operates on POSIX-parsed ASTs (brush-parser uses POSIX grammar), so it applies POSIX quoting rules. For non-POSIX shells (Nushell, PowerShell, Xonsh, Elvish, etc.) where quoting semantics differ, any mismatch results in false positives (overly cautious denials), never false negatives. This is the correct error direction for a security feature. When brush-parser fails to parse a non-POSIX command, extract_commands returns None and the existing fallback (raw-string matching with allow_enabled=false) handles it.

(No release notes because granular tool permissions are still feautre-flagged.)

Release Notes:

  • N/A

@cla-bot cla-bot bot added the cla-signed The user has signed the Contributor License Agreement label Feb 5, 2026
@rtfeldman rtfeldman force-pushed the fix-shell-quote-bypass branch from 90d876e to da021d7 Compare February 5, 2026 03:55
Normalize command strings by stripping shell quotes from the parsed AST
instead of using SimpleCommand::to_string(), which preserves raw lexical
quoting. This ensures security patterns match regardless of quoting style.

Previously, commands like rm -rf '/' or 'rm' -rf / would bypass both
hardcoded deny patterns and user-configured always_deny patterns because
the regex was matching against the quote-preserved string (e.g.,
"rm -rf '/'") rather than the semantic value ("rm -rf /").

The fix replaces the to_string() call in extract_commands_from_simple_command
with manual construction that iterates words and normalizes each one via
brush_parser::word::parse, which decomposes words into WordPieces (Text,
SingleQuotedText, EscapeSequence, etc.) from which we reconstruct the
unquoted semantic value. Parameter expansions, command substitutions, and
arithmetic expressions preserve their original source text so that
patterns like $HOME continue to match.

If word normalization fails, the failure propagates via Option<()> returns
through the extraction chain, causing extract_commands to return None —
the same as a shell parse failure. The caller then falls back to raw-input
matching with always_allow disabled.
@rtfeldman rtfeldman force-pushed the fix-shell-quote-bypass branch from da021d7 to 57cb5e9 Compare February 5, 2026 04:02
@rtfeldman rtfeldman marked this pull request as ready for review February 5, 2026 04:09
@rtfeldman rtfeldman enabled auto-merge (squash) February 5, 2026 04:09
@rtfeldman rtfeldman merged commit 323680f into main Feb 5, 2026
27 checks passed
@rtfeldman rtfeldman deleted the fix-shell-quote-bypass branch February 5, 2026 04:14
rtfeldman added a commit that referenced this pull request Feb 5, 2026
## Problem

Zed's terminal permission system validates commands using regex patterns
against string representations of parsed commands. The
`SimpleCommand::to_string()` method in brush-parser preserves the raw
lexical form including shell quotes, causing pattern matching to fail on
obfuscated commands.

For example, all of these are semantically equivalent to `rm -rf /` in
POSIX shells, but produced different strings from `to_string()` that
bypassed the hardcoded deny pattern `rm\s+(-[rf]+\s+)*/\s*$`:

| Input | `extract_commands()` returned | Matched deny pattern? |
|---|---|---|
| `rm -rf /` | `"rm -rf /"` | ✅ Yes |
| `rm -rf '/'` | `"rm -rf '/'"` | ❌ No |
| `'rm' -rf /` | `"'rm' -rf /"` | ❌ No |
| `r'm' -rf /` | `"r'm' -rf /"` | ❌ No |
| `rm -r'f' /` | `"rm -r'f' /"` | ❌ No |
| `rm -rf \/` | `"rm -rf \\/"` | ❌ No |

Both hardcoded deny patterns (Phase 2, which operates on extracted
sub-commands) and user-configured `always_deny` patterns were affected.

## Fix

Replace the `simple_command.to_string()` call in
`extract_commands_from_simple_command` with manual construction that:

1. Iterates the command name (`word_or_name`) and word arguments from
the suffix
2. Normalizes each word by parsing it into `WordPiece`s via
`brush_parser::word::parse`
3. Reconstructs the semantic (unquoted) value from each piece:
   - `Text` — used as-is
- `SingleQuotedText` — used as-is (brush-parser already strips the
wrapping quotes)
- `EscapeSequence` — leading `\` stripped to produce the unescaped
character
- `AnsiCQuotedText` — used as-is (brush-parser already evaluates
`$'...'`)
- `DoubleQuotedSequence` / `GettextDoubleQuotedSequence` — inner pieces
recursively normalized
   - `TildePrefix` — preserved as `~` + suffix
- `ParameterExpansion` / `CommandSubstitution` /
`BackquotedCommandSubstitution` / `ArithmeticExpression` — original
source text preserved so patterns like `\$HOME` and `\${HOME}` continue
to match
4. Joins normalized words with spaces

Falls back to the raw `word.value` if word parsing fails.

## Cross-shell safety

The normalization operates on POSIX-parsed ASTs (brush-parser uses POSIX
grammar), so it applies POSIX quoting rules. For non-POSIX shells
(Nushell, PowerShell, Xonsh, Elvish, etc.) where quoting semantics
differ, any mismatch results in false positives (overly cautious
denials), never false negatives. This is the correct error direction for
a security feature. When brush-parser fails to parse a non-POSIX
command, `extract_commands` returns `None` and the existing fallback
(raw-string matching with `allow_enabled=false`) handles it.

(No release notes because granular tool permissions are still
feautre-flagged.)

Release Notes:

- N/A
naaiyy added a commit to Glass-HQ/Glass that referenced this pull request Feb 16, 2026
Key changes:
- Thermal state detection (zed-industries#45638) - GPUI detects system thermal state, throttles to ~60fps when overheating
- Checkerboard shader for side-by-side diff (zed-industries#48417) - visual pattern for diff backgrounds
- cosmic-text v0.17 (zed-industries#48504) - fixes font ligatures on Linux
- Middle click tab close (zed-industries#44916) - on_aux_click/is_middle_click API additions
- Soft wrap modes for wrap width (zed-industries#46422)
- Tab switcher mode similar to vim/helix buffer picker (zed-industries#47079)
- Multi_buffer optimization batch (zed-industries#48519)
- TreeMap for diagnostics (zed-industries#48482) - performance improvement
- Semantic token follow-up fixes (zed-industries#48485)
- Claude Opus 4.6 and 1M context window model variants (zed-industries#48508)
- Anthropic adaptive thinking types (zed-industries#48517)
- Side-by-side diff: hunk gutter highlights restored, toolbar buttons for SplittableEditor
- Shell quote bypass fix in terminal permission system (zed-industries#48436)
- Project panel: Collapse All improvements (zed-industries#47328, zed-industries#48443)
- Edit prediction: trailing newlines fix, cursor position in global coords
- Properly discard tokens on language server stop (zed-industries#48490)
- AgentTool::NAME const instead of hardcoded strings (zed-industries#48506)

Conflict resolution:
- collab/editor_tests.rs: deleted (collab removed)
- vim (helix, motion, increment): deleted (vim removed)
- GPUI (17 files): deleted from Glass (handled in Obsydian-HQ/gpui)
- editor/items.rs: merged imports (added BufferId, kept Theme)
- project_diff.rs: removed old native_button toggle (upstream uses toolbar buttons now)
- lsp_store.rs: added SemanticTokenConfig, removed GlobalLogStore/LanguageServerKind
- project_panel.rs: merged UI imports (added ContextMenuEntry, ScrollAxes)
- Keymaps: took upstream JetBrains bindings

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

cla-signed The user has signed the Contributor License Agreement

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant