Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions dev-docs/bun.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,12 @@ trusted. These are the 16 entries in `trustedDependencies`:
When it was still a dependency it did not need trust: its platform binary was
delivered by the separate `@esbuild/<platform>` package, which Bun installs
directly without running a script.
- **`tree-sitter-pwsh`** β€” the shell validator loads only the package's
published `tree-sitter-powershell.wasm` with `web-tree-sitter`. Its install
script prepares the native Node binding, which the validator never imports
and which is deliberately runtime-gated out under Node because loading the
PowerShell grammar there is unstable. The published WASM does not require the
lifecycle script, so granting install-time trust would add unnecessary risk.
- **`node-pty`** β€” not trusted because the runtime prefers `@lydell/node-pty`
(see `packages/core/src/utils/getPty.ts`), whose native binary is supplied by
the prebuilt `@lydell/node-pty-*` platform packages. `node-pty` is the
Expand Down
58 changes: 58 additions & 0 deletions docs/shell-replacement.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,30 @@ LLxprt Code controls how command substitution patterns (`$()`, `` ` ` ``, `<()`,
| `all` | Allows all substitution unconditionally. Least restrictive. |
| `none` | Blocks all command substitution. Most restrictive. |

## Per-Shell Parsing

LLxprt Code selects a structural parser **matching the execution shell when
one is available** rather than applying one generic grammar to every command
(#3181):

- **Bash** execution β†’ `tree-sitter-bash` grammar.
- **PowerShell** execution under Bun β†’ `tree-sitter-pwsh` grammar.
- **cmd.exe** execution β†’ falls back to the Bash grammar (no dedicated cmd grammar exists; this is the same as pre-#3181 behavior).

Parsing does **not** always match the execution shell: under Node, PowerShell
structural validation intentionally fails closed (the PowerShell WASM is unstable
under Node), and cmd.exe always uses the Bash legacy fallback. In these cases
validation preserves the documented fail-closed or legacy fallback behavior
instead of silently treating PowerShell as Bash.

### PowerShell Substitution Semantics

PowerShell substitution differs from Bash:

- PowerShell **backticks** (`` ` ``) are escape/line-continuation characters, **not** command substitution. They are not treated as substitution in any mode.
- PowerShell **`$()`** subexpressions are substitution and follow the configured mode.
- PowerShell **`.NET` invocations** (e.g., `[System.Diagnostics.Process]::Start(...)`) are detected as expression targets. In strict allowlist mode they fail closed because they cannot be compared honestly against a command allowlist.

## Configuring

### Session Setting
Expand Down Expand Up @@ -41,6 +65,40 @@ In `allowlist` mode (the default), LLxprt Code uses tree-sitter to parse the com

This gives you command substitution where it's safe while preventing unexpected commands from running inside substitutions.

## Runtime Compatibility

The PowerShell grammar (`tree-sitter-pwsh`) loads under the **Bun** runtime β€” the shipped CLI runtime β€” where it is stable. Under **Node** (used by core library consumers, A2A, and other server paths), the PowerShell WASM causes a V8 out-of-memory crash at process shutdown. To prevent this, the codebase uses an `isBunRuntime()` guard so that:

- **Bun**: Both Bash and PowerShell grammars load. PowerShell structural validation works.
- **Node**: Only the Bash grammar loads. PowerShell validation **fails closed** with a truthful diagnostic (`PowerShell command rejected because the structural parser is unavailable`). PowerShell commands are never silently accepted without validation.

cmd.exe execution maps to the Bash grammar because no dedicated cmd grammar exists and cmd syntax is not PowerShell. This is the same behavior as before #3181 and does not make a false claim about the language.

## Case-Insensitive Matching (PowerShell)

PowerShell command resolution is case-insensitive. Blocklist and allowlist matching for PowerShell commands is therefore case-insensitive: `ShellTool(Get-Process)` matches `GET-PROCESS`, `get-process`, and `Get-Process`. Bash matching remains strictly case-sensitive. The case-insensitivity is PowerShell-scoped and does not affect Bash behavior.

Literal call targets (`& "C: ools ool.exe"`) and dot-source paths (`. .\script.ps1`) normalize to the basename before matching, so policy patterns do not require broad wildcards like `ShellTool(&)`.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

## Wrapper and Evaluator Bypass Prevention

PowerShell evaluators and shell wrappers are recursively validated to prevent statically resolvable payloads from bypassing an allowlist or blocklist:

| Construct | Literal payload | Dynamic payload |
| ---------------------------------------------- | ------------------------------------------------------ | ---------------------------------------------------- |
| `Invoke-Expression` / `iex` | Recursively parsed with PowerShell grammar | Fails closed under a strict allowlist |
| `powershell -Command` / `pwsh -Command` | Recursively parsed with PowerShell grammar | Fails closed under a strict allowlist |
| `bash -c` / `sh -c` | Recursively parsed with Bash grammar | Fails closed under a strict allowlist |
| `cmd /c` / `cmd.exe /c` | No dedicated grammar; treated as unresolved expression | Fails closed under a strict allowlist |
| `Start-Process` / `saps` / `start` | Static target extracted as command name | Dynamic target fails closed under a strict allowlist |
| Literal call-operator forms such as `& "pwsh"` | Handled like the corresponding direct wrapper | Fails closed under a strict allowlist |

Ordinary quoted strings and static here-strings are decoded before recursive parsing. Statically resolvable nested blocklisted commands are therefore still checked when wrapped in these constructs. Dynamic payloads cannot be compared honestly with a command allowlist and are hard-denied when a strict global or session allowlist applies; an `excludeTools` blocklist alone is not a complete sandbox for dynamically generated command text.

## Blocklist Recursion Across Modes

Blocklist (`excludeTools`) checks recurse into all nested commands β€” script blocks, subexpressions, pipelines, and wrapper payloads β€” in every mode (`none`, `allowlist`, `all`). A blocklisted command nested inside `ForEach-Object { ... }` or `$(...)` is caught even in `all` mode, which only relaxes substitution restrictions, not blocklist enforcement.

## Security Notes

- **`none` mode** is appropriate if you're running untrusted code or want maximum safety β€” it blocks all substitution patterns entirely.
Expand Down
31 changes: 31 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -352,6 +352,7 @@
"tar": "^7.5.16",
"tinygradient": "^1.1.5",
"tree-sitter-bash": "^0.25.0",
"tree-sitter-pwsh": "^0.38.1",
"turndown": "^7.2.2",
"typescript-language-server": "^4.0.0 || ^5.0.0",
"undici": "^7.28.0",
Expand Down
1 change: 1 addition & 0 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@
"strip-ansi": "^7.1.0",
"tar": "^7.5.16",
"tinygradient": "^1.1.5",
"tree-sitter-pwsh": "^0.38.1",
"undici": "^7.28.0",
"update-notifier": "^7.3.1",
"wrap-ansi": "9.0.2",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ describe('ShellProcessor', () => {
'git status',
expect.any(Object),
context.session.sessionShellAllowlist,
expect.any(String),
);
expect(mockShellExecute).toHaveBeenCalledWith(
'git status',
Expand Down Expand Up @@ -324,11 +325,13 @@ describe('ShellProcessor', () => {
'cmd1',
expect.any(Object),
context.session.sessionShellAllowlist,
expect.any(String),
);
expect(mockCheckCommandPermissions).toHaveBeenCalledWith(
'cmd2',
expect.any(Object),
context.session.sessionShellAllowlist,
expect.any(String),
);
expect(mockShellExecute).toHaveBeenCalledTimes(2);
expect(result).toBe('Run output1 and output2');
Expand Down Expand Up @@ -358,6 +361,7 @@ describe('ShellProcessor', () => {
expectedCommand,
expect.any(Object),
context.session.sessionShellAllowlist,
expect.any(String),
);
expect(mockShellExecute).toHaveBeenCalledWith(
expectedCommand,
Expand Down Expand Up @@ -397,6 +401,7 @@ describe('ShellProcessor', () => {
command,
expect.any(Object),
context.session.sessionShellAllowlist,
getShellConfiguration().shell,
);
expect(mockShellExecute).toHaveBeenCalledWith(
command,
Expand Down Expand Up @@ -627,6 +632,7 @@ describe('ShellProcessor', () => {
expectedResolvedCommand,
expect.any(Object),
context.session.sessionShellAllowlist,
getShellConfiguration().shell,
);
});

Expand Down
11 changes: 9 additions & 2 deletions packages/cli/src/services/prompt-processors/shellProcessor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
getShellConfiguration,
ShellExecutionService,
type ShellPermissionConfig,
type ShellType,
} from '@vybestack/llxprt-code-core';

import type { CommandContext } from '../../ui/commands/types.js';
Expand Down Expand Up @@ -95,7 +96,12 @@ export class ShellProcessor implements IPromptProcessor {
injections,
userArgsEscaped,
);
this.checkPermissions(resolvedInjections, config, sessionShellAllowlist);
this.checkPermissions(
resolvedInjections,
config,
sessionShellAllowlist,
shell,
);

return this.executeInjections(
prompt,
Expand Down Expand Up @@ -125,14 +131,15 @@ export class ShellProcessor implements IPromptProcessor {
resolvedInjections: ShellInjection[],
config: ShellProcessorRuntime,
sessionShellAllowlist: Set<string>,
shell: ShellType,
): void {
const commandsToConfirm = new Set<string>();
for (const injection of resolvedInjections) {
const command = injection.resolvedCommand;
if (!command) continue;

const { allAllowed, disallowedCommands, blockReason, isHardDenial } =
checkCommandPermissions(command, config, sessionShellAllowlist);
checkCommandPermissions(command, config, sessionShellAllowlist, shell);

if (allAllowed !== true) {
if (isHardDenial === true) {
Expand Down
1 change: 1 addition & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -611,6 +611,7 @@
"simple-git": "^3.36.0",
"strip-ansi": "^7.1.0",
"tree-sitter-bash": "^0.25.0",
"tree-sitter-pwsh": "^0.38.1",
"turndown": "^7.2.2",
"undici": "^7.28.0",
"vscode-jsonrpc": "^8.2.1",
Expand Down
Loading
Loading