Skip to content

feat(shell): support |, &&, ||, ; chain operators in run_command#233

Merged
esengine merged 1 commit intomainfrom
feature/run-command-chaining
May 4, 2026
Merged

feat(shell): support |, &&, ||, ; chain operators in run_command#233
esengine merged 1 commit intomainfrom
feature/run-command-chaining

Conversation

@esengine
Copy link
Copy Markdown
Owner

@esengine esengine commented May 4, 2026

Closes #232. Addresses discussion #231.

What changes

run_command now supports the four most common chain operators: |, &&, ||, ;. Each segment is parsed via shell-quote and spawned as a separate process — no system shell is ever invoked.

git status | grep main           # pipe — wired stdout→stdin
node -v && cargo check           # AND — short-circuit on non-zero
node -v || echo "node missing"   # OR — short-circuit on zero
mkdir tmp ; ls tmp               # sequence — always continue

Why not just shell: true

The four shells we'd hand the string to disagree on the basics:

Operator bash/zsh PowerShell 5.1 PowerShell 7+ cmd.exe
| bytes objects, not bytes objects bytes
&& || yes parse error yes yes
; yes yes yes no
2>&1 merge wraps stderr as ErrorRecord same merge

PowerShell 5.1 (the default on Windows 10/11) literally parse-errors on &&, so a shell-passthrough path would break node -v && npm test for every Windows user. Parsing ourselves and spawning each segment sidesteps the whole zoo — semantics are identical on every OS.

Granular allowlist (the bit @wviana asked for)

Each segment is allowlist-checked independently: git status | grep main auto-runs because both halves are individually allowed. Previously the whole string was checked as one prefix and never matched.

isCommandAllowed("git status | grep main")  // → true
isCommandAllowed("git status | rm -rf dist") // → false (rm not allowed)

Out of scope (rejected with a clear error)

  • Redirects (>, <, 2>&1, &>) — Phase 2 will implement these as stream piping, still no shell.
  • Background & — doesn't fit run_command's synchronous model; use run_background.
  • Env-var expansion \$VAR — pass values literally or use the binary's own flags.
  • Command substitution \$(…) and subshells (…) — split into separate calls.
  • Glob expansion *.ts — passed through as a literal argument; tools like grep -r / rg / find already glob themselves.

Compatibility note

The strict POSIX parser disagrees with the previous lenient tokenizer in one edge case: an unquoted & mid-token (cargo run -- --flag=1&2) used to pass through as a single argv. It now rejects with "&" is not supported. The fix is to quote: cargo run -- "--flag=1&2". This is the POSIX-correct form and matches what every real shell would do.

Test plan

  • 29 new tests in tests/shell-chain.test.ts covering parser, allowlist, pipe wiring, &&/|| short-circuit, ; sequence, exit-code propagation, stderr merge, timeouts, dispatch integration.
  • Existing 7 rejection tests in tests/shell-tools.test.ts updated to reflect chain ops being supported and redirects / & / \$VAR / \$(...) / empty-segment / trailing-op being rejected.
  • npm run verify — 2198 tests pass, no biome / typecheck errors on changed files.

…d-spawn

Parse chain operators ourselves with shell-quote and spawn each segment
as a separate process — never invoke a system shell. Sidesteps PowerShell
5.1's `&&` parse error and the object-vs-bytes pipe semantics gap on
Windows. Each segment is allowlist-checked independently, so
`git status | grep main` runs when both halves are individually allowed.

Redirects (`>`, `<`, `2>&1`), background `&`, env-var expansion `$VAR`,
command substitution `$(…)`, and subshells `(…)` remain rejected up-front
with clear errors.

Tracks #232, addresses #231.
@esengine esengine merged commit 5ea5f76 into main May 4, 2026
1 check passed
@esengine esengine deleted the feature/run-command-chaining branch May 4, 2026 23:56
esengine added a commit that referenced this pull request May 5, 2026
…t tokenization (#234)

Phase 1 (#233) used shell-quote for chain segmentation, but its strict
POSIX tokenization split `--flag=1&2` into three tokens with `&` as a
background operator — a regression vs. pre-Phase-1 behavior, where the
existing lenient `tokenizeCommand` left embedded `&`/`|`/`;` chars as
literal bytes inside larger tokens.

Replace with a small whitespace-bounded chain splitter that matches the
existing `detectShellOperator` convention: chain ops only count when
they begin a whitespace-separated token. Each segment then runs through
the existing `tokenizeCommand` (lenient) and `detectShellOperator`
(rejects `>`, `<`, `&`, `2>&1`, `&>`).

Recovers:
- `cargo run -- --flag=1&2`           — runs (was rejected post-#233)
- `git status ; cargo run -- --flag=1&2` — chain runs, second segment keeps `&`

Drops:
- `git status|grep main` (no spaces) — never worked pre-Phase-1, was a
  shell-quote-only side effect; align with the project's
  whitespace-bounded operator convention.

Also drops `$VAR` / `$(...)` rejection — these now pass through as
literal arguments, matching the existing single-command tokenizer's
behavior. Models see the literal `$HOME` in echo's output and learn.
esengine added a commit that referenced this pull request May 5, 2026
`run_command` learns the four common shell chain operators (`|`, `||`,
`&&`, `;`) and the seven file redirect operators (`>`, `>>`, `<`, `2>`,
`2>>`, `2>&1`, `&>`). Parsed and spawned natively — no shell is invoked,
so semantics are identical on Windows / macOS / Linux; PowerShell 5.1's
`&&` parse error and the object-vs-bytes pipe gap are sidestepped.

Each chain segment is allowlist-checked independently: `git status |
grep main` now auto-runs when both halves are individually allowed, the
granular-approval behaviour wviana asked for in discussion #231.

The chain parser stays consistent with the project's long-standing
lenient tokenizer — `cargo run -- --flag=1&2` and similar embedded-
operator args stay literal instead of getting POSIX-strict-rejected.
shell-quote is no longer a dependency; `splitOnChainOps` is whitespace-
bounded like the existing `detectShellOperator`.

Mid-pipe `2>&1` correctly merges stderr into the next segment's stdin
without truncating on stdout-end (counter-based close-when-both-ended).

Targets resolve relative to the project root. Heredoc `<<` and
background `&` remain explicitly rejected with clear errors. `\$VAR`
and `\$(...)` pass through as literal characters — no shell expansion.

Closes #232. Driven by discussion #231. PRs: #233 (chain ops), #234
(lenient-tokenizer regression fix), #235 (redirects + mid-pipe `2>&1`).
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.

Support command piping and chaining via split-and-spawn

1 participant