--- issue: 72 issue_title: "Replace regex-based bash tokenizer with shell-quote or tree-sitter-bash" --- # Replace regex-based bash tokenizer with `shell-quote` ## Problem Statement `extractExternalPathsFromBashCommand` in `src/external-directory.ts` uses a hand-rolled regex tokenizer (`stripQuotedStrings` + `split(/[|;&><\s]+/)`) that produces false positives on edge cases. Issue #68 fixed bare-slash tokens, but the underlying tokenizer remains fragile: 1. `stripQuotedStrings` breaks on escaped quotes (`\"`), leaking post-break content into the token stream. 2. Shell comments (`# ...`) are not stripped — path-like tokens after `#` are scanned. 3. Heredoc content is tokenized as if it were command arguments. 4. Operators are treated as simple delimiters, losing structural information. These are not hypothetical — the escaped-quote bug triggered a false-positive external-directory prompt during #68 dog-fooding. ## Goals - Replace `stripQuotedStrings` and the `split(/[|;&><\s]+/)` tokenizer with `shell-quote`'s `parse()`. - Eliminate false positives from escaped quotes, shell comments, and operator conflation. - Keep `classifyTokenAsPathCandidate` as a separate concern operating on properly tokenized string arguments. - Add `shell-quote` and `@types/shell-quote` as runtime and dev dependencies respectively. - Add regression tests for the edge cases that the regex tokenizer gets wrong. ## Non-Goals - Adopting `web-tree-sitter` + `tree-sitter-bash` — deferred to a follow-up issue. `shell-quote` is sufficient for path extraction and avoids the 1.5MB WASM overhead. - Handling heredocs — `shell-quote` flattens heredoc content into tokens, which is the same behavior as the current tokenizer. This is a known limitation shared by both approaches. - Changing `classifyTokenAsPathCandidate` logic — the classification heuristics are orthogonal to tokenization. - Changing any permission surface, config format, or merge precedence. ## Background - **Permission surface**: `external_directory` (bash variant). - **Module**: `src/external-directory.ts` — `extractExternalPathsFromBashCommand` is the entry point; `stripQuotedStrings` and `classifyTokenAsPathCandidate` are internal helpers. - **Tests**: `tests/bash-external-directory.test.ts` (388 lines) covers extraction, formatting, and edge cases. - **Prerequisite**: #68 (bare-slash fix) — already shipped in v4.0.1. ### `shell-quote` API `shell-quote` exports `parse(cmd)` returning `ParseEntry[]` where: ```typescript type ParseEntry = | string // plain argument (quotes resolved) | { op: string } // shell operator (|, &&, ;, etc.) | { op: "glob"; pattern: string } // glob pattern | { comment: string }; // shell comment ``` String entries have quotes already resolved — `parse('git commit -m "fix /etc/hosts"')` returns `["git", "commit", "-m", "fix /etc/hosts"]`. Operator and comment entries are objects, trivially filtered out. ## Design Overview ### Tokenization change Replace: ```typescript const unquoted = stripQuotedStrings(command); const tokens = unquoted.split(/[|;&><\s]+/).filter(Boolean); ``` With: ```typescript import { parse } from "shell-quote"; const entries = parse(command); const tokens = entries.filter((e): e is string => typeof e === "string"); ``` This single change fixes escaped quotes, comments, and operator handling in one shot. `classifyTokenAsPathCandidate` continues to receive plain strings and is unchanged. ### Dead code removal `stripQuotedStrings` becomes dead code and is removed. The bare-slash guard in `classifyTokenAsPathCandidate` (`/^\/+$/.test(token)`) is kept — `shell-quote` can still produce bare-slash strings (e.g., `parse("echo /")` → `["echo", "/"]`), so the guard remains a valid defense-in-depth layer. ### Dependency addition - `shell-quote` as a runtime dependency (`dependencies` in `package.json`). - `@types/shell-quote` as a dev dependency (`devDependencies`). This is the first runtime dependency for this package. `shell-quote` is 23KB, zero transitive dependencies, MIT license, 47M+ weekly downloads — low risk. ## Module-Level Changes | File | Change | | --------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `package.json` | Add `shell-quote` to `dependencies`, `@types/shell-quote` to `devDependencies`. | | `src/external-directory.ts` | Import `parse` from `shell-quote`. Replace `stripQuotedStrings` + `split()` in `extractExternalPathsFromBashCommand` with `parse()` + type filter. Remove `stripQuotedStrings` function. | | `tests/bash-external-directory.test.ts` | Add tests for: escaped quotes in double-quoted strings, shell comments containing paths, operators as typed tokens (not leaking into path stream). Verify existing tests still pass. | ## TDD Order 1. **test: add failing tests for regex tokenizer edge cases (#72)** Add a new `describe("shell-quote tokenizer edge cases")` block in `tests/bash-external-directory.test.ts` with: - Escaped double quote: `git commit -m "fix \"the /etc/hosts\" issue"` → no external path (path is inside quotes). - Shell comment: `echo hello # read /etc/shadow` → no external path (path is in comment). - Comment alongside real path: `cat /etc/hosts # /etc/shadow` → only `/etc/hosts`. - Operator tokens don't leak: `cat /etc/hosts | grep foo` → only `/etc/hosts`, not `|` or `grep`. - Semicolons: `echo ok; cat /etc/hosts` → `/etc/hosts` extracted correctly. These tests will fail against the current regex tokenizer (red). Commit: `test: add failing cases for regex tokenizer edge cases (#72)` 2. **feat: replace regex tokenizer with shell-quote (#72)** - Add `shell-quote` and `@types/shell-quote` dependencies. - In `extractExternalPathsFromBashCommand`, replace `stripQuotedStrings` + `split()` with `parse()` + string filter. - Remove the `stripQuotedStrings` function. - All new tests pass (green). Run full suite to confirm no regressions. Commit: `feat: replace regex tokenizer with shell-quote (#72)` 3. **test: verify defense-in-depth for bare-slash tokens (#72)** Add or confirm a test that `parse("echo /")` still produces `/` as a token and `classifyTokenAsPathCandidate` still rejects it. This validates the bare-slash guard remains necessary even with `shell-quote`. Commit: `test: confirm bare-slash guard with shell-quote tokenizer (#72)` 4. **docs: update plan retro and close issue (#72)** Optional retro in `docs/retro/0072-shell-quote-tokenizer.md` if anything surprising surfaces. Commit: `docs: retro for shell-quote tokenizer migration (#72)` ## Risks and Mitigations | Risk | Mitigation | | ----------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | Could this silently weaken a permission? | No — `shell-quote` produces *fewer* tokens than the regex tokenizer (comments and operators are filtered out as non-strings). Fewer tokens means fewer path candidates, which means fewer prompts, never fewer blocks. A path that was correctly detected before will still be a string token from `shell-quote`. | | `shell-quote` misparses a command and drops a real path argument? | `shell-quote` has 47M weekly downloads and handles standard POSIX quoting. Edge cases (heredocs, complex expansions) are no worse than the regex tokenizer. The bare-slash guard and `classifyTokenAsPathCandidate` provide additional filtering layers. | | First runtime dependency — supply chain risk? | `shell-quote` is zero-dependency, MIT, maintained by the `shell-quote` org. The `@types/shell-quote` package is DefinitelyTyped-sourced. Both are widely audited. Pin versions via lockfile. | | `shell-quote` handles `$VAR` expansion by default? | `parse(cmd)` with no `env` argument replaces `$VAR` with empty string. This is acceptable — we don't want environment variables expanded for path extraction. If a command uses `$HOME/foo`, the path candidate will be `/foo` (or empty), not `~/foo`. This is the same behavior as the regex tokenizer, which has no variable awareness. | ## Open Questions - **Follow-up: `tree-sitter-bash` for full AST parsing.** Addressed by #74 — `shell-quote` has been replaced with `web-tree-sitter` + `tree-sitter-bash`, eliminating heredoc false positives and providing full AST-based path extraction. - **`$VAR` expansion**: tree-sitter parses `$HOME/foo` as an `expansion` + `word` concatenation. `classifyTokenAsPathCandidate` does not expand variables, so `$HOME/foo` is not detected as an external path. This is the same pre-existing limitation as with `shell-quote`. Deferred — not a regression from current behavior.