10 KiB
issue, issue_title
| issue | issue_title |
|---|---|
| 52 | Bash command arity table for smart approval pattern suggestions |
Bash command arity table for smart approval pattern suggestions
Problem Statement
When a user approves a bash command "for this session," the system suggests a wildcard pattern via suggestBashPattern().
Currently that function uses a naive first-word heuristic (git status --short → git *), which is too broad for commands like git where the subcommand is semantically significant.
A curated arity dictionary would let us suggest git checkout * instead of git *, and npm run dev* instead of npm *.
Goals
- Add a curated arity dictionary mapping command prefixes to their token depth.
- Expose a
prefix(tokens: string[]): string[]function that returns the meaningful prefix for a tokenized command. - Replace the naive first-word heuristic in
suggestBashPattern()with arity-aware logic. - Longest matching prefix wins; unknown commands default to arity 1.
- Cover common CLI tools: git, npm, npx, pnpm, yarn, docker, cargo, pip, go, kubectl, etc.
Non-Goals
- Shell-quoting-aware tokenization (already handled by
src/input-normalizer.ts/ #72). - Persisting session approvals across sessions.
- Changing how other surfaces (mcp, skill, tool) suggest patterns.
- Comprehensive coverage of every CLI tool — the dictionary is extensible and good-enough coverage suffices.
Background
Current state
src/pattern-suggest.ts contains suggestBashPattern(command: string): string:
const spaceIndex = trimmed.indexOf(" ");
if (spaceIndex === -1) return trimmed;
return `${trimmed.slice(0, spaceIndex)} *`;
This produces git * for any git command — overly permissive.
Permission surface
This change affects the bash surface only, specifically the pattern suggestion fed into session rules. It does not change permission evaluation, only what pattern is suggested to the user.
References
src/pattern-suggest.ts— existing suggestion logic.src/session-rules.ts—SessionRules.approve()stores the pattern.- OpenCode
packages/opencode/src/permission/arity.ts— prior art with ~150 entries.
Design Overview
New module: src/bash-arity.ts
/**
* Curated arity dictionary.
* Keys are space-joined command prefixes; values are the arity (token count).
* Multi-level entries allow `npm run` (arity 3) alongside `npm` (arity 2).
*/
const ARITY: Record<string, number> = {
"git": 2, // git <subcommand> *
"npm run": 3, // npm run <script>*
"npm": 2, // npm <subcommand> *
"docker": 2, // docker <subcommand> *
"cargo": 2, // cargo <subcommand> *
// ... ~50-150 entries
};
/**
* Return the semantically meaningful prefix tokens for a command.
* Longest matching prefix wins.
*
* @param tokens - The command split by whitespace.
* @returns The prefix tokens (length = arity value from dictionary, or 1 for unknown commands).
*/
export function prefix(tokens: string[]): string[];
Lookup algorithm
- Iterate from longest possible prefix down to 1 token.
- Join tokens with space, look up in
ARITY. - First (longest) match wins — return
tokens.slice(0, arity). - No match → default arity 1 → return
[tokens[0]].
Integration with suggestBashPattern
import { prefix } from "./bash-arity";
export function suggestBashPattern(command: string): string {
const trimmed = command.trim();
if (!trimmed) return "";
const tokens = trimmed.split(/\s+/);
const meaningful = prefix(tokens);
if (meaningful.length >= tokens.length) {
// The entire command IS the prefix — no wildcard needed.
return trimmed;
}
// If the next token after the prefix exists and is part of the original,
// append it with a trailing wildcard for tighter matching.
return `${meaningful.join(" ")} *`;
}
Result examples:
| Command | Prefix tokens | Suggested pattern |
|---|---|---|
git checkout main |
git checkout |
git checkout * |
npm run dev |
npm run dev |
npm run dev* (exact, arity=3 covers all 3 tokens) |
rm -rf node_modules |
rm |
rm * |
cat file.txt |
cat |
cat * |
ls |
ls |
ls (exact, no args) |
unknown-tool --flag |
unknown-tool |
unknown-tool * |
Wait — for npm run dev, arity is 3 so prefix(["npm","run","dev"]) returns ["npm","run","dev"].
Since meaningful.length >= tokens.length, we return the exact command npm run dev.
But the issue says it should suggest npm run dev* (trailing wildcard to match npm run dev:watch etc.).
Refinement: when meaningful.length === tokens.length, append * to the last token (no space) to allow suffix variants:
if (meaningful.length >= tokens.length) {
return `${trimmed}*`;
}
return `${meaningful.join(" ")} *`;
This gives:
npm run dev→npm run dev*(matchesnpm run dev,npm run dev:watch)git checkout main→git checkout *(arity 2 < 3 tokens → space wildcard)ls→ls*(matchesls, but that's fine — single command with no args)
Edge case: single token with no args, e.g. ls.
With the above, ls → ls* which is slightly broader than exact.
Better: only append trailing * when arity equals token count AND there are args beyond the base command.
Simplest: keep the original behavior for single-token no-arg commands.
if (tokens.length === 1) return trimmed; // exact match for bare commands
if (meaningful.length >= tokens.length) return `${trimmed}*`;
return `${meaningful.join(" ")} *`;
Dictionary structure considerations
- Keys are lowercase, space-joined prefixes.
- Lookup normalizes the first N tokens to lowercase for case-insensitive matching.
- The dictionary is a plain object — no runtime loading, no config file.
- Exported for testability.
Module-Level Changes
src/bash-arity.ts (new)
ARITYdictionary (exported for testing).prefix(tokens: string[]): string[]function.
src/pattern-suggest.ts (modified)
suggestBashPattern()refactored to useprefix()frombash-arity.ts.- Existing behavior preserved for edge cases (empty string, single token).
tests/bash-arity.test.ts (new)
- Unit tests for
prefix()covering multi-level lookups, longest-match-wins, unknown commands.
tests/pattern-suggest.test.ts (modified)
- Update
suggestBashPatterntests to reflect arity-aware patterns. - Add new cases for multi-level commands (git checkout, npm run, docker compose).
TDD Order
-
test: arity prefix lookup for known and unknown commands
- Red: tests for
prefix(["git","checkout","main"])→["git","checkout"],prefix(["npm","run","dev"])→["npm","run","dev"],prefix(["unknown","--flag"])→["unknown"]. - Green: implement
src/bash-arity.tswith dictionary andprefix(). - Commit:
feat: add bash arity table with prefix lookup (#52)
- Red: tests for
-
test: suggestBashPattern uses arity-aware logic
- Red: update existing
suggestBashPatterntests —npm run buildshould now producenpm run *(notnpm *),git status --shortproducesgit status *(notgit *). - Green: refactor
suggestBashPattern()to callprefix(). - Commit:
feat: integrate arity table into suggestBashPattern (#52)
- Red: update existing
-
test: suggestSessionPattern bash cases reflect arity
- Red: update
suggestSessionPatternbash tests to expect arity-refined patterns. - Green: already works via
suggestBashPatternchange. - Commit:
test: update session pattern tests for arity-aware bash suggestions (#52)
- Red: update
-
docs: document arity table and contribution guidelines
- Add a brief section to README explaining the arity dictionary and how to extend it.
- Commit:
docs: document bash arity table (#52)
Risks and Mitigations
| Risk | Mitigation |
|---|---|
| Arity entry too high → pattern too narrow (user still gets prompted) | Safe direction — prompts more, not less. User can still approve exact command. |
| Arity entry too low → pattern too broad | Same risk as current code. Mitigated by showing pattern in dialog label. User sees what they approve. |
| Could this silently weaken a permission? | No. The arity table only affects the suggested pattern shown in the dialog. The user must explicitly approve. If anything, it produces tighter patterns than before (reducing what's approved). |
| Dictionary maintenance burden | Start with ~50-80 common commands. Dictionary is static and easy to extend via PRs. |
Case sensitivity — Git vs git |
Normalize to lowercase during lookup. |
Open Questions
- Should the arity dictionary be user-configurable (e.g., in
config.json)? Leaning no — keep it curated in code. User can always decline the suggestion and rely on exact-match session rules. - Should flags (tokens starting with
-) be skipped when counting arity tokens? E.g.,rm -rf node_modules— the meaningful prefix isrm, notrm -rf. Leaning yes — skip flag tokens when matching against the dictionary. But this adds complexity; defer to a follow-up if the simple approach works well enough.