Skip to content

feat(core): make the portable shell scanner authoritative - #44016

Merged
kitlangton merged 11 commits into
v2from
harden-shell-scan
Aug 26, 2026
Merged

kitlangton merged 11 commits into
v2from
harden-shell-scan

Conversation

@kitlangton

@kitlangton kitlangton commented Aug 21, 2026 •

Copy link
Copy Markdown
Contributor

Why

The experimental Tree-sitter replacement needs to handle normal shell scripts without adding incidental permission prompts. Falling back to Tree-sitter hides missing syntax, while rejecting ordinary heredocs and loops makes the experiment impractical to use.

This keeps the experimental flag as a genuine native-only switch and fills in ordinary shell syntax, while testing permission decisions rather than just comparing parser objects.

What Changes

Command or syntax Enabled scanner behavior
Bash/Zsh heredocs, loops, nested conditionals, functions, parameter expansion, arithmetic, and quoting Extracts commands natively rather than rejecting the whole script. Quoted heredoc bodies stay literal; explicit substitutions in expanding bodies remain visible.
PowerShell expressions, assignments, control flow, scriptblocks, here-strings, backticks, and expandable strings Scans the syntax directly and retains commands inside expressions and blocks.
printf hello | cat > marker Preserves existing exact cat approvals and denials, as well as broad cat * approvals. Standalone redirect resources are not blanket-stripped.
export VERSION=$(git describe --tags); npm run build Does not add an approval requirement for the declaration wrapper. The Git substitution and build command remain checked.
PowerShell tabbed commands and & 'Write-Output' hello Saved approvals use lexical source boundaries when the conventional prefix would not match the command. Repetition works without normalizing resources or losing raw exact-deny matching.

With experimental.portable_shell_scanner: true, only the portable scanner runs. There is no Tree-sitter retry or shadow comparison. With the flag disabled, the existing Tree-sitter implementation, prefix table, and shared helpers are unchanged.

Both scanners retain lexical provenance for resource boundaries and saved prefixes. The native Bash scanner walks control structures and queued heredocs without interpreting shell values; the PowerShell scanner handles its expression and argument contexts. Existing input/work/depth bounds remain in place.

Scope

This is an opt-in parser replacement, not a permission-policy redesign. The permission service and production shell tool are unchanged. Tree-sitter is used as a test oracle, not a dependency of the enabled analysis path.

The native parser does not deliberately reproduce erroneous command nodes from literal heredoc bodies or arithmetic values. It also recognizes some commands legacy parsing missed, including tab-separated PowerShell commands; those can require approval, and their saved patterns now cover repeat execution. Numeric arguments and some legacy error-recovery cases retain explicitly tested differences. This is not a claim of complete shell-language or Tree-sitter parity.

Verification

# packages/core, with SHELL_SCAN_PWSH pointing to verified PowerShell 7.6.5
bun run test
bun typecheck
bun run build

# repository
git diff --check
bun turbo typecheck --concurrency=3 # normal pre-push hook

The final Core suite passed 3,629 tests, with 19 skipped and zero failures. Core build, formatting, and diff checks passed, as did all repository typechecks through the normal pre-push hook. The skips include three explicitly marked Bash-only syntax cases in the Zsh runtime matrix; native scanning of those fixtures is still tested.

Coverage includes native-only generated oracle comparisons, real Bash/Zsh and PowerShell execution, real permission-service and shell-tool tests for existing approvals/denials and once/always/reject responses, and malformed-input tests proving native failures do not fall back or partially execute the script.

The simplify pass removed obsolete nesting metadata and parser arguments, reused one heredoc delimiter reader, and removed avoidable argument copies and escape-table allocations. It also caught and fixed grouped here-string and quoted-delimiter bugs. A final cleanup review found no further issues. The legacy parser, arity table, shared helpers, permission service, and production shell tool were verified unchanged.

@kitlangton
kitlangton changed the base branch from v2 to shell-scan-core August 21, 2026 23:55
Base automatically changed from shell-scan-core to v2 August 22, 2026 00:06
@Enough1122

Copy link
Copy Markdown

AI code review — automated review for reference; please use your judgment.

  1. packages/core/src/permission.ts:186 — In evaluated() exact mode, the condition rule.resource === resource || rule.resource === "*" || rule.effect !== "allow" returns any ask/deny rule whose action matches, even when the rule's resource pattern doesn't cover the requested resource. Why it matters: a scoped config like deny external_directory /secrets/** becomes an action-wide deny for all exact-mode requests (["*"]), diverging from wildcard-mode semantics where resource patterns gate the effect. Suggestion: only return a rule when rule.resource === resource || rule.resource === "*"; otherwise continue for every effect, falling through to the ask default — i.e. drop the || rule.effect !== "allow" arm.

  2. packages/core/tool/plugin/shell.ts (behavioral) — After a user approves an unknown-directory prompt with "always allow" (saved against a concrete path), later exact-mode asserts use resources: ["*"], which no saved concrete-path allow can ever match (and evaluated skips wildcard-allow rules). Why it matters: "don't ask again" silently degrades to ask-every-time for subsequent opaque commands. Possibly intended ("unknown dirs can't inherit narrower approvals"), but it should be stated in the changeset/permissions.mdx so users understand the persistent prompting.

  3. packages/core/src/permission.ts:186 (test gap) — evaluated() is a pure function carrying the subtlest logic in this PR, yet it's only exercised indirectly. Suggestion: add a direct matrix test: exact-vs-wildcard allow, "*" allow hit, wildcard-allow skip, non-matching deny (pins item 1's chosen semantics), non-matching ask, and empty-ruleset default.

  4. packages/core/src/shell/parse.ts:384 — popd/pushd now unconditionally report unknown: true, and any second directory change flips directoryUnknown — correct and conservative, but note the whole command string then goes out as a single opaque resource with save: [] (shell.ts), losing the per-command breakdown in approval history/audit. Suggestion: keep the strictness, but consider still resolving/publishing the known directories for observability alongside the blanket prompt.

  5. packages/core/src/shell/parse.ts:795 — ~ now expands via $HOME only; on typical Windows setups (no HOME, PowerShell default), cd ~ becomes unknown ⇒ prompt. Fail-closed is right, but mapping USERPROFILE as a fallback for the portable path would avoid a noisy UX regression on Windows while staying deterministic.

  6. packages/core/src/shell/scan.ts:498 (nit) — The curly-quote blocklist covers U+2018–U+201D; other confusables (fullwidth "', angle quotes «»‹›) pass through to word parsing. Low risk since they'd rarely execute, but a broader "non-ASCII quote-like" class would be cheaper than enumerating.

Nice work overall — the BASH_ENV/BASH_FUNC_* startup gating, alias-command opacity in PowerShell, and the adversarial/generated test suites close real bypass classes.

— automated review (ox-alpha, round2)

Bound recursive scan work and prefix lookup, simplify directory bookkeeping and approval tests, and drop the unrelated OpenAPI snapshot refresh.
@kitlangton kitlangton changed the title fix(core): harden portable shell authorization refactor(core): preserve tree-sitter behavior in portable scanning Aug 26, 2026
@kitlangton kitlangton changed the title refactor(core): preserve tree-sitter behavior in portable scanning feat(core): make the portable shell scanner authoritative Aug 26, 2026
@kitlangton
kitlangton merged commit 74a0b2d into v2 Aug 26, 2026
14 of 15 checks passed
@kitlangton
kitlangton deleted the harden-shell-scan branch August 26, 2026 20:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants