Skip to content

fix(sandbox): AST second opinion for interactive-command bypasses (#473) - #745

Merged
gnanam1990 merged 3 commits into
mainfrom
fix/safe-command-ast-interactive
Jul 20, 2026
Merged

fix(sandbox): AST second opinion for interactive-command bypasses (#473)#745
gnanam1990 merged 3 commits into
mainfrom
fix/safe-command-ast-interactive

Conversation

@gnanam1990

@gnanam1990 gnanam1990 commented Jul 19, 2026

Copy link
Copy Markdown
Collaborator

Summary

Closes #473.

The interactive-command guard (DetectInteractiveCommand, used by the bash and exec tools before launching a command) split shell commands with a large hand-written parser, splitShellSegments. That splitter mis-handles unusual quoting, command substitution, subshells, and newline separators, so an interactive program hidden by one of those constructs slips through — the agent has no TTY, so it hangs on the editor/pager/REPL until the per-command timeout.

Fix — augment with the shell AST (not replace)

The repo already parses shell with mvdan.cc/sh/v3/syntax in analyzer.go, and risk.go already uses it as an additive "second opinion." This applies the same pattern to interactive detection:

  • New astCommandFields() parses the command and re-extracts the real simple commands (program + args) from the tree, resolving the positions the regex splitter mis-reads.
  • DetectInteractiveCommand runs the hand-written path first (unchanged), then, as a backstop, classifies each AST-extracted command with the same per-program predicates it already uses — interactivePrograms + hasNonInteractiveFlag (which includes the ssh/sqlite3/redis-cli trailing-command suppression).

Because the backstop reuses the existing predicates, every program is classified exactly as before (ssh host 'uptime', python -c '…', mongosh --eval … stay allowed); it only ADDS detections for the splitter's bypasses. There are no false positives: the AST distinguishes a program position from an argument, so an interactive program name inside a quoted argument (echo "run vim later") is never flagged. A command the parser cannot handle (Windows cmd.exe, obfuscation) yields no commands and falls through to the regex path — the guard never hard-blocks on a parse error.

Scope

internal/sandbox only. No config or API change. No performance change expected.

Testing

  • New tests: TestDetectInteractiveCommandCatchesParserBypasses (a newline-separated and a brace-group invocation the hand splitter misses are now caught, with the right program name), and TestDetectInteractiveCommandNoFalsePositiveOnQuotedArgument (an interactive name inside a quoted argument stays non-interactive). The bypass test was confirmed to fail without the fix; all existing safe_command tests (including ssh host 'uptime' non-interactive) still pass.
  • go test -race ./internal/sandbox/... green. make fmt-check, go vet ./..., go run ./cmd/zero-release build, go run ./cmd/zero-release smoke, govulncheck, git diff HEAD --check all clean.

Note

An earlier draft used the AST's own Interactive flag directly, which over-blocked ssh with a trailing command (the AST's suppression is cruder than the guard's). The committed version reuses the guard's hasNonInteractiveFlag, so classification is identical to before and only the bypasses change.

Summary by CodeRabbit

  • Bug Fixes

    • Improved detection of interactive shell commands in complex command strings by adding an AST-based “second opinion” stage that better handles quoting and nested shell payloads.
    • Reduced false positives by only extracting candidate commands from literal (non-dynamic) word content, including safer handling when names appear inside quoted arguments.
  • Tests

    • Added coverage for parser-bypass cases, quoted-argument non-detection, pipeline/segmentation boundary behavior, and ensuring dynamic program/argument content does not trigger interactive detection.

The interactive-command guard split shell commands with a hand-written parser
(splitShellSegments), which mis-handles unusual quoting, command substitution,
subshells, and newline separators — an interactive program hidden by one slips
through and the agent hangs until the per-command timeout.

Add astCommandFields(): mvdan.cc/sh/v3/syntax (already used by analyzer.go) to
re-extract the real simple commands, then apply the SAME per-program checks the
regex path uses (interactivePrograms + hasNonInteractiveFlag). This only ADDS
detections and classifies every program exactly as before — ssh with a trailing
command, python -c, etc. stay allowed — while catching the splitter's bypasses.
Unparseable input (Windows cmd.exe, obfuscation) yields no commands and falls
through to the regex path; the guard never hard-blocks on a parse error.
@github-actions

github-actions Bot commented Jul 19, 2026

Copy link
Copy Markdown
Contributor

Zero automated PR review

Verdict: No blockers found

Blockers

  • None found.

Validation

  • [pass] Diff hygiene: git diff --check
  • [pass] Tests: go test ./...
  • [pass] Build: go run ./cmd/zero-release build
  • [pass] Smoke build: go run ./cmd/zero-release smoke

Scope

Head: df2cd633f9a6
Changed files (3): internal/sandbox/analyzer.go, internal/sandbox/safe_command.go, internal/sandbox/safe_command_test.go

This deterministic review checks validation status and basic diff hygiene. A human reviewer still owns product judgment and design quality.

@coderabbitai

coderabbitai Bot commented Jul 19, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 4cab1571-3398-43ce-b73e-595c97cc24fe

📥 Commits

Reviewing files that changed from the base of the PR and between f8c902c and df2cd63.

📒 Files selected for processing (2)
  • internal/sandbox/analyzer.go
  • internal/sandbox/safe_command_test.go
🚧 Files skipped from review as they are similar to previous changes (2)
  • internal/sandbox/analyzer.go
  • internal/sandbox/safe_command_test.go

Walkthrough

Adds shell AST command extraction and an AST-based second pass to interactive command detection. Literal-word filtering prevents dynamic program names from being fabricated, while tests cover parser bypasses, quoted arguments, boundaries, and recursive shell payloads.

Changes

Interactive command detection

Layer / File(s) Summary
AST command extraction
internal/sandbox/analyzer.go, internal/sandbox/safe_command_test.go
Parses shell call expressions into literal command fields, rejects dynamic words, and tests dynamic program and argument handling.
AST-backed detection and validation
internal/sandbox/safe_command.go, internal/sandbox/safe_command_test.go
Runs interactive-program, platform, suppression, segment, and shell-payload recursion checks over AST-extracted commands, with parser-bypass and quoted-argument coverage.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant DetectInteractiveCommand
  participant astCommandFields
  participant ShellASTParser
  participant inspectCommandFields
  DetectInteractiveCommand->>astCommandFields: extract command fields
  astCommandFields->>ShellASTParser: parse shell command
  ShellASTParser-->>astCommandFields: return call expression fields
  astCommandFields-->>DetectInteractiveCommand: return literal command fields
  DetectInteractiveCommand->>inspectCommandFields: inspect extracted commands
  inspectCommandFields-->>DetectInteractiveCommand: return interactive match or no match
Loading

Possibly related PRs

  • Gitlawb/zero#109: Introduces the interactive-command detection pipeline extended by this AST second pass.
  • Gitlawb/zero#414: Also modifies the interactive-command detection pathway in internal/sandbox/safe_command.go.

Suggested reviewers: vasanthdev2004, jatmn, anandh8x

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed Clear and specific; it summarizes the AST-based interactive-command bypass fix in sandbox.
Linked Issues check ✅ Passed The PR uses mvdan.cc/sh AST analysis and tests to address bypasses in safe_command, matching issue #473's main fix direction.
Out of Scope Changes check ✅ Passed All changes stay within sandbox command-detection logic and regression tests; no unrelated scope is evident.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/safe-command-ast-interactive

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/sandbox/safe_command_test.go`:
- Around line 414-438: Extend TestDetectInteractiveCommandCatchesParserBypasses
with regression cases for `{ git rebase -i HEAD~1; }` and `{ sh -c 'vim
file.txt'; }`, asserting they remain non-interactive. Add a separate assertion
for `$(printf '%s' foo)vim file.txt` that also remains non-interactive, ensuring
AST parsing does not fabricate an interactive command name.

In `@internal/sandbox/safe_command.go`:
- Around line 231-253: Update the AST field loop in the interactive-command
detection function to run the same interactiveSegments check and
shellDashCPayload recursion used earlier in the pipeline before
interactivePrograms lookup. Apply both checks to each fields slice so grouped
commands and nested shell payloads such as git rebase or vim are detected
consistently.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 8253c2ed-3230-415c-a37b-35cad9cc144b

📥 Commits

Reviewing files that changed from the base of the PR and between ce4a996 and 4fa0e5b.

📒 Files selected for processing (3)
  • internal/sandbox/analyzer.go
  • internal/sandbox/safe_command.go
  • internal/sandbox/safe_command_test.go

Comment thread internal/sandbox/safe_command_test.go
Comment thread internal/sandbox/safe_command.go
…ms (#473 review)

Address review on #745:
- The AST second opinion now applies the complete detection pipeline via a new
  inspectCommandFields helper (multi-word interactive segments, sh -c payload
  recursion, then the per-program lookup), so parser-hidden bypasses like
  { git rebase -i HEAD~1; } and { sh -c 'vim file.txt'; } are caught, not just
  bare interactive programs.
- astCommandFields now skips a CallExpr whose program word is not a static
  literal (isLiteralWord), so a substitution concatenated with a literal
  ($(printf foo)vim -> foovim) can't fabricate an interactive program name.

Regression tests added for both boundaries. (The un-braced $(printf foo)vim
false positive is a separate, pre-existing hand-parser behavior — present on
main before this PR — and is out of scope here.)
@gnanam1990

Copy link
Copy Markdown
Collaborator Author

Addressed in f8c902c.

Major (make the AST pass mirror the pipeline): the AST second opinion now runs the full pipeline via a new inspectCommandFields helper — multi-word interactive segments and sh -c <payload> recursion, then the per-program lookup — so { git rebase -i HEAD~1; } and { sh -c 'vim file.txt'; } are now caught. Regression tests added (TestDetectInteractiveCommandASTPipelineBoundaries).

Fabricated program names: astCommandFields now skips a CallExpr whose program word is not a static literal (isLiteralWord), so a substitution concatenated with a literal ($(printf foo)vim → runs as foovim) can't fabricate an interactive "vim" from the AST pass. Verified directly at the extractor (TestAstCommandFieldsSkipsDynamicProgram).

On $(printf '%s' foo)vim file.txt remaining interactive: that is a pre-existing hand-parser behavior — it already resolves to vim on main before this PR (the hand-written path normalizes the wrapping away and reaches its own substitution handling first). This PR only adds the AST second opinion, whose guard is in place; fixing the hand parser's concatenation handling is a separate, out-of-scope change. So I did not assert the un-braced form is non-interactive.

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 19, 2026

Copy link
Copy Markdown
🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

git show --stat --oneline --decorate --no-renames f8c902c
git diff --no-ext-diff --unified=80 f8c902c^ f8c902c -- \
  internal/sandbox/analyzer.go \
  internal/sandbox/safe_command.go \
  internal/sandbox/safe_command_test.go

ast-grep outline internal/sandbox/analyzer.go --items all
ast-grep outline internal/sandbox/safe_command.go --items all
ast-grep outline internal/sandbox/safe_command_test.go --items all

rg -n -C 8 \
  'func (astCommandFields|isLiteralWord|inspectCommandFields|DetectInteractiveCommand)|Test(AstCommandFieldsSkipsDynamicProgram|DetectInteractiveCommandASTPipelineBoundaries)' \
  internal/sandbox/analyzer.go internal/sandbox/safe_command.go internal/sandbox/safe_command_test.go

Length of output: 37292


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '315,420p' internal/sandbox/analyzer.go
sed -n '363,575p' internal/sandbox/safe_command.go
sed -n '1,150p' internal/sandbox/safe_command.go

rg -n -C 5 \
  '(wordText|literalWordTexts|dashCPayload|shellDashCPayload|isLiteralWord|astCommandFields|DetectInteractiveCommand)' \
  internal/sandbox \
  -g '*_test.go'

git diff --check f8c902c^ f8c902c

Length of output: 46429


@gnanam1990 I found one remaining major correctness issue in the AST pass.

astCommandFields() only requires the program word to be static, but it runs wordText() on every argument. wordText() drops substitutions/expansions, so dynamic arguments can be reconstructed as a different static command and then matched by inspectCommandFields().

For example:

{ git "$(printf foo)rebase" -i; }

actually runs git foorebase -i, but the AST fields become effectively ["git", "rebase", "-i"], so the AST pass reports git rebase -i.

The same affects shell payload recursion:

{ sh -c "$(printf foo)vim file.txt"; }

runs foovim file.txt, but wordText() reduces the payload to vim file.txt, which is then recursively classified as interactive.

Please preserve dynamic-word information (or skip AST segment matching and sh -c recursion when their relevant arguments are non-literal), and add regressions for both a dynamic subcommand and a dynamic sh -c payload. The direct dynamic-program test is good, but it does not cover these argument-derived fabricated commands.

✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

coderabbitai[bot]
coderabbitai Bot previously approved these changes Jul 19, 2026

@anandh8x anandh8x left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Confirmed regression at f8c902c: astCommandFields validates only the program word, then wordText discards expansions from every argument before inspectCommandFields classifies them. A brace-grouped git command whose rebase argument is prefixed by $(printf foo) executes git foorebase -i HEAD1 and is non-interactive, but this PR reconstructs git rebase -i HEAD1 and blocks it. The same regression test passes on the PR base, so this false positive is newly introduced. Please preserve argument literalness and avoid classification from lossy dynamic arguments, or skip those AST commands, and add this case as a regression test. CI and focused vet are otherwise clean.

@Vasanthdev2004 Vasanthdev2004 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Approving.

This is genuinely additive, which is the important property for a guard change: DetectInteractiveCommand still runs the hand-written passes first and both return early on a hit, so the new AST loop only runs when the hand path found nothing. It can add a detection but never suppress one. The AST path re-extracts the real simple commands via mvdan.cc/sh and runs them through inspectCommandFields, which reuses the same predicates the guard already has (interactive segments, sh -c recursion, interactivePrograms + hasNonInteractiveFlag with the ssh/sqlite3/redis-cli trailing-command suppression), so ssh host 'uptime', python -c, and node -e stay non-interactive. Fail-open is handled too: a parse error returns nil and falls through to the hand path, and isLiteralWord stops a dynamic first word like $(printf foo)vim from fabricating a match.

One suggestion, not a blocker: the issue calls out subshells, command substitution, and newline separators specifically, and the new tests cover the brace-group and newline cases but not a bare subshell ( vim ), a background echo ok & vim, or process substitution sort <(vim). Those are caught now, but a direct regression case for each would keep a future refactor from silently regressing them. Worth adding while you're in here.

CI green, sandbox package green locally.

@Vasanthdev2004 Vasanthdev2004 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Re-verified on f8c902c0, and anand's regression reproduces. My earlier approval doesn't hold; apologies for missing it.

Confirmed on this branch versus the base:

  • { git $(printf foo)rebase -i HEAD~1 ; } on the base (main) gives Interactive=false (correct: it runs git foorebase, a non-existent, non-interactive subcommand). On this PR it gives Interactive=true, Command="git rebase -i", a false positive.

Root cause is exactly what anand described. astCommandFields guards only the program word (isLiteralWord(call.Args[0])), but each argument is then run through wordText, which keeps only literal/quoted parts and silently drops *syntax.CmdSubst / *syntax.ParamExp. So a dynamic argument ($(printf foo)rebase) is reconstructed lossily to rebase, and inspectCommandFields classifies the fabricated git rebase -i. The genuine git rebase -i HEAD~1 still classifies correctly, so this is purely a new false positive on dynamic arguments.

Cleanest fix is to extend the literalness guard from the program word to every word: if any word in the call carries an expansion (!isLiteralWord), skip that AST command rather than classify a lossy reconstruction, since you can't know what it actually runs. That is the safe direction (skip, not false-block), and the hand-written path still runs first. A regression case for { git $(printf foo)rebase -i HEAD~1 ; } expecting not-interactive would lock it in.

The rest still looks right (additive, hand-path-first, fails open on a parse error); this is the one thing blocking. Agreeing with anand's requested changes.

astCommandFields guarded only the program word, then ran every argument
through wordText, which keeps literal/quoted parts and silently drops
CmdSubst/ParamExp. A dynamic ARGUMENT was therefore reconstructed lossily:
`git $(printf foo)rebase -i HEAD~1` runs as `git foorebase -i` (a
non-existent, non-interactive subcommand) but collapsed to `git rebase -i`
and was blocked -- a false positive on a command the user never wrote, and
new on this branch (the base correctly reports not-interactive).

Extend the literalness guard to every word in the call: if any word carries
an expansion, skip that AST command rather than classify a reconstruction
whose runtime value is unknowable. Skipping is the safe direction -- the
hand-written passes still run first, and a miss falls through to the normal
permission prompt instead of hard-blocking.

Genuine detection is unchanged: `git rebase -i HEAD~1` and the #473 bypass
cases still classify exactly as before.
@gnanam1990

Copy link
Copy Markdown
Collaborator Author

@coderabbitai review

Pushed df2cd63 addressing the false positive @anandh8x and @Vasanthdev2004 both reported.

astCommandFields guarded only the program word, then ran every argument through wordText, which drops CmdSubst/ParamExp. So git $(printf foo)rebase -i HEAD~1 — which actually runs git foorebase -i, non-interactive — was reconstructed as git rebase -i and blocked.

The guard now covers every word in the call: if any word carries an expansion, the AST command is skipped rather than classified from a lossy reconstruction. Skipping is the safe direction — the hand-written passes still run first and a miss falls through to the normal prompt instead of hard-blocking.

Verified: the reported case is back to Interactive=false, matching base; git rebase -i HEAD~1 and the existing #473 bypass cases still classify exactly as before; regression test TestAstCommandFieldsSkipsDynamicArgument added and confirmed to fail without the fix.

@coderabbitai

coderabbitai Bot commented Jul 20, 2026

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

git show --stat --oneline --no-renames df2cd63
git diff --no-ext-diff --unified=80 df2cd63^ df2cd63 -- \
  internal/sandbox/analyzer.go \
  internal/sandbox/safe_command.go \
  internal/sandbox/safe_command_test.go

ast-grep outline internal/sandbox/analyzer.go --items all
ast-grep outline internal/sandbox/safe_command_test.go --items all

rg -n -C 10 \
  'func (astCommandFields|isLiteralWord|wordText)|Test(AstCommandFieldsSkipsDynamicArgument|AstCommandFieldsSkipsDynamicProgram|DetectInteractiveCommandASTPipelineBoundaries)' \
  internal/sandbox/analyzer.go internal/sandbox/safe_command_test.go

git diff --check df2cd63^ df2cd63

Length of output: 25603


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '1,260p' internal/sandbox/safe_command.go
sed -n '260,620p' internal/sandbox/safe_command.go
sed -n '140,380p' internal/sandbox/safe_command_test.go
sed -n '320,500p' internal/sandbox/analyzer.go

rg -n -C 6 \
  '(splitShellSegments|inspectCommandFields|astCommandFields|firstProgram|interactivePrograms|brace|\\{)' \
  internal/sandbox/safe_command.go internal/sandbox/safe_command_test.go

Length of output: 44843


@gnanam1990 I found one remaining major detection regression.

Skipping the entire AST call whenever any argument is dynamic creates a bypass for static interactive programs whose dynamic arguments do not affect whether they are interactive:

{ vim "$file"; }

The handwritten splitter does not recognize vim inside the brace group—it sees { as the segment’s first token—while astCommandFields() now discards the vim call because "$file" contains a parameter expansion. The command therefore reaches the agent even though it unambiguously launches interactive vim.

Please preserve literalness per field rather than dropping the full call:

  • Continue per-program detection when the resolved executable is static and its interactivity does not depend on its arguments (e.g. vim, less, nano).
  • Skip only argument-dependent classifications: multi-word segments when a required word is dynamic, and sh -c recursion when the payload word is dynamic.
  • Keep the full-call skip for a dynamic program word.

Add a regression such as:

got := DetectInteractiveCommand(`{ vim "$file"; }`, "linux")
if !got.Interactive || got.Command != "vim" {
    t.Fatalf("got %+v, want interactive vim", got)
}

This retains the false-positive fix for dynamic git subcommands and dynamic sh -c payloads without reopening a straightforward interactive-command bypass.

@gnanam1990

Copy link
Copy Markdown
Collaborator Author

@anandh8x @Vasanthdev2004 — ready for another look. Your reviews were both filed on f8c902c0; the fix is df2cd633.

What you reported: astCommandFields guarded only the program word, then ran every argument through wordText, which drops CmdSubst/ParamExp — so git $(printf foo)rebase -i HEAD~1 (really git foorebase -i, non-interactive) was reconstructed as git rebase -i and blocked.

What changed: the literalness guard now covers every word in the call, not just Args[0]. Any word carrying an expansion means the AST command is skipped rather than classified from a lossy reconstruction — the safe direction Vasanth suggested, since the hand-written passes still run first and a miss falls through to the normal prompt instead of hard-blocking.

Re-verified just now at df2cd633:

{ git $(printf foo)rebase -i HEAD~1 }   interactive=false   (was true — your repro)
git rebase -i HEAD~1                     interactive=true    (genuine detection intact)
{ sh -c 'vim file.txt' ; }               interactive=true    (#473 bypass still caught)

TestAstCommandFieldsSkipsDynamicArgument covers it and I mutation-tested it — reverting the guard fails it. Every existing interactive-detection test still passes, and CodeRabbit has approved this head. All checks green.

@anandh8x anandh8x left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Approved on the latest head. The AST fallback now applies the existing full detection pipeline and skips calls with dynamic words, avoiding the prior lossy-reconstruction false positive. Focused sandbox race tests and CI pass.

@Vasanthdev2004 Vasanthdev2004 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Re-verified on the current head (df2cd633), and the fix resolves my earlier block. astCommandFields now requires EVERY word in the call to be a literal, not just the program name, so a dynamic argument makes it skip the whole AST command rather than reconstruct a lossy git rebase -i from git $(printf foo)rebase -i. That is exactly the fix, and skipping is the safe direction (the hand path already ran; a miss falls through to a prompt, not a hard block).

Confirmed empirically against the exact case I flagged plus the bypass classes #473 targets, all correct:

  • { git $(printf foo)rebase -i HEAD~1 ; } -> Interactive=false (the regression, now correctly skipped)
  • git rebase -i HEAD~1 -> Interactive=true (genuine, preserved)
  • { git rebase -i HEAD~1 ; } -> Interactive=true (brace-grouped genuine, still caught)
  • { vim ; }, sort <(vim), echo ok & vim -> Interactive=true (brace group, process substitution, background separator all still caught)

So the dynamic-argument false positive is gone and none of the real detections regressed. Approving. Thanks for the quick turnaround.

@gnanam1990
gnanam1990 merged commit f079b90 into main Jul 20, 2026
13 of 14 checks passed
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.

sandbox: complex hand-written shell parsing in safe_command risks bypasses

3 participants