fix(coding-agent): inspect compound Bash commands in interceptor rules
Match interceptor regexes against conservative, raw shell command segments in addition to the complete command, so anchored rules can detect commands after &&, ||, ;, |, &, and newlines without treating quoted or escaped text as commands. Add extractFlatShellCommandSegments() to preserve source text for user-configured regexes, unlike the token-based approval matcher. Add skipShellWord() and environment-assignment stripping so rules can match commands prefixed with NAME=value assignments. Preserve the original command in interception errors after extracting a leading cd command.
This commit is contained in:
+73
-1
@@ -52,11 +52,82 @@ The tool returns a single `text` content block plus optional `details`.
|
||||
|
||||
Stdout and stderr are merged before the model sees them. Definite non-zero exit codes are appended to the returned error result text as `Command exited with code <n>`.
|
||||
|
||||
## Command policy and dedicated-tool routing
|
||||
|
||||
Two independent settings can prevent a Bash subprocess from starting. They serve different purposes and run at different points in the tool-call lifecycle.
|
||||
|
||||
| Setting | Purpose | Rule syntax | Result when matched |
|
||||
| --- | --- | --- | --- |
|
||||
| `bash.patterns` | Command-specific execution policy | Literal text with `*` wildcards | Allows the call, requests human approval, or denies it. |
|
||||
| `bashInterceptor.patterns` | Prefer a dedicated tool over Bash | JavaScript regular expression, optional flags, tool name, and message | Returns a Bash tool error telling the model to call the named dedicated tool instead. |
|
||||
|
||||
### `bash.patterns`: permission policy
|
||||
|
||||
`bash.patterns` is for commands that must be allowed, confirmed by a person, or refused regardless of whether another tool could perform the work. Rules are ordered; the first matching rule wins. Each rule has a `match` glob and an `approval` value of `allow`, `prompt`, or `deny`.
|
||||
|
||||
```yaml
|
||||
bash:
|
||||
patterns:
|
||||
- match: "git *"
|
||||
approval: allow
|
||||
- match: "curl *"
|
||||
approval: prompt
|
||||
- match: "rm -rf *"
|
||||
approval: deny
|
||||
```
|
||||
|
||||
- `deny` stops the call before `BashTool.execute()` runs, including in `yolo` mode.
|
||||
- `prompt` displays an approval request. Only an accepted request proceeds to `BashTool.execute()`.
|
||||
- `allow` can lower the approval tier for a simple command, but it cannot approve a compound command. For example, `match: "git *"` does not approve `git status && rm -rf build`.
|
||||
- `deny` and `prompt` check the complete command and each shell command segment. A rule such as `match: "rm -rf *"` therefore catches `cd /tmp && rm -rf build`.
|
||||
|
||||
Use this setting for safety and user control. It remains useful for commands with no appropriate replacement tool, such as destructive removal, network access, deployment scripts, or project-specific scripts.
|
||||
|
||||
### `bashInterceptor.patterns`: dedicated-tool routing
|
||||
|
||||
`bashInterceptor` is an opt-in routing layer (`bashInterceptor.enabled` defaults to `false`). It is for commands that are technically valid Bash but are better expressed through an available dedicated tool. Each pattern is a regular expression and includes the name of that replacement tool and the explanation shown to the model.
|
||||
|
||||
```yaml
|
||||
bashInterceptor:
|
||||
enabled: true
|
||||
patterns:
|
||||
- pattern: '^\s*(cat|head|tail)\s+'
|
||||
tool: read
|
||||
message: "Use the read tool instead; it handles binary files and provides better context."
|
||||
- pattern: '^\s*(grep|rg)\s+'
|
||||
tool: grep
|
||||
message: "Use the grep tool instead; it respects .gitignore and returns structured results."
|
||||
```
|
||||
|
||||
An interceptor rule only applies when its `tool` is available in the current session. If `read` is disabled, a `cat` rule targeting `read` does not block the Bash call. This makes the interceptor a best-effort capability preference rather than an execution-security boundary.
|
||||
|
||||
The built-in default rules route common operations such as `cat` to `read`, `rg` to `grep`, in-place `sed` to `edit`, shell redirection to `write`, and unmanaged services/background processes to `hub`. See `DEFAULT_BASH_INTERCEPTOR_RULES` in `packages/coding-agent/src/config/settings-schema.ts` for the complete list.
|
||||
|
||||
For compatibility with existing custom regexes, the interceptor always checks the complete original command first. It then checks raw, flat command fragments separated by unquoted and unescaped `&&`, `||`, `;`, `|`, `&`, or newlines. It also checks fragments after leading environment assignments are removed:
|
||||
|
||||
```bash
|
||||
git add file && git commit -m "message"
|
||||
GIT_AUTHOR_NAME=Dev git commit -m "message"
|
||||
```
|
||||
|
||||
An anchored rule such as `^\s*git\s+commit\b` can therefore match the `git commit` command in both examples. Quoted, escaped, and commented text is not treated as a command. Heredocs, command substitution, backticks, grouping, and malformed quoting retain only the complete-command check; the interceptor deliberately does not attempt to become a full shell parser.
|
||||
|
||||
### Interaction and selection guide
|
||||
|
||||
The approval policy is resolved before execution. A matching `bash.patterns` `deny` never reaches the interceptor. A matching `prompt` reaches the interceptor only after the user accepts the approval request. If an accepted call then matches an interceptor rule, the Bash call still does not run; the model receives the routing error and should invoke the dedicated tool.
|
||||
|
||||
Avoid configuring the same operation in both places unless that two-step behavior is intended. For example, a `prompt` rule for `cat *` plus an enabled `cat`-to-`read` interceptor first asks the user to approve Bash, then rejects Bash and asks the model to use `read`.
|
||||
|
||||
Choose the setting by the desired outcome:
|
||||
|
||||
- Use `bash.patterns` when the question is **whether the command may execute**.
|
||||
- Use `bashInterceptor.patterns` when the question is **which tool should perform the operation**.
|
||||
|
||||
## Flow
|
||||
1. `BashTool.execute()` in `packages/coding-agent/src/tools/bash.ts` reads `command`, normalizes `env`, and defaults `timeout` to `300`. Commands execute exactly as written — there is no pre-execution rewrite pass.
|
||||
2. If `cwd` is absent, it rewrites a leading `cd <path> && ...` into the structured `cwd` field and strips that prefix from `command`.
|
||||
3. If `async: true` is requested while `async.enabled` is off, it throws `ToolError` before any execution.
|
||||
4. If `bashInterceptor.enabled` is on, `checkBashInterception()` runs against both the original command and the `cd`-stripped command. A matching enabled rule throws before URL expansion or execution.
|
||||
4. If `bashInterceptor.enabled` is on, `checkBashInterception()` runs against both the original command and the `cd`-stripped command. For each form, configured regexes still check the complete input first, then each flat command separated by unquoted/unescaped `&&`, `||`, `;`, `|`, `&`, or newlines, followed by versions of those fragments without leading `NAME=value` assignments. A matching enabled rule throws before URL expansion or execution.
|
||||
5. `expandInternalUrls()` rewrites supported internal URLs inside `command`, each `env` value, and protocol-looking `cwd` values. Command replacements are shell-escaped; `env` and `cwd` replacements use raw filesystem/string values because they are not interpolated into shell text.
|
||||
6. `resolveToCwd()` resolves `cwd` against `session.cwd`; `fs.stat()` verifies that the target exists and is a directory.
|
||||
7. `clampTimeout("bash", requestedTimeoutSec)` enforces `TOOL_TIMEOUTS.bash` (`default: 300`, `min: 1`, `max: 3600`). When clamped, `#buildCompletedResult()` / `#buildBackgroundStartResult()` append a notice line.
|
||||
@@ -150,6 +221,7 @@ Stdout and stderr are merged before the model sees them. Definite non-zero exit
|
||||
- `strict = true` is set on `BashTool`; `concurrency` is resolved per call: `pty: true` is `"exclusive"` (it takes over the terminal UI), everything else is `"shared"`, so multiple non-pty bash calls in one assistant message run in parallel. When parallel calls overlap on the same shell session key, the first owns the persistent `Shell`; the rest run in isolated one-shot shells (see `shellSessionsInUse` in `bash-executor.ts`).
|
||||
- `command` URL expansions shell-escape replacements; `env` and `cwd` expansion use `noEscape: true` because they become environment values / filesystem paths, not shell text.
|
||||
- `checkBashInterception()` blocks only when the matching rule's `tool` name is present in `ctx.toolNames`; missing tools disable their corresponding rule.
|
||||
- Interceptor configuration syntax is unchanged. It handles common flat command lists, not full shell parsing: heredocs, command substitution, backticks, grouping, and malformed quoting only receive the existing whole-input check. This is best-effort routing toward dedicated tools, not a security boundary.
|
||||
- Default interceptor rules come from `DEFAULT_BASH_INTERCEPTOR_RULES` in `packages/coding-agent/src/config/settings-schema.ts`:
|
||||
- `cat|head|tail|less|more` -> `read`
|
||||
- `grep|rg|ripgrep|ag|ack` -> `grep`
|
||||
|
||||
Reference in New Issue
Block a user