Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Changed

- **Shell-first exploration.** The `list_files` and `search_files` tools are no longer offered to the model; it now searches and lists with `rg`, `find`, `ls` and `git` through `execute_command`, the way Claude Code does, and the system prompt teaches the common patterns. To keep this from becoming a prompt on every search, read-only commands (`rg`, `grep`, `find` without `-exec`/`-delete`, `ls`, `cat`, `head`, `wc`, `git status/diff/log/show/grep`, and pipes or `&&` chains of these, with no redirects or command substitution) skip the approval prompt and run in parallel. Anything unrecognised still asks. Old sessions that called the removed tools still resume.

- **`execute_command` is now `Bash`, and it really runs bash.** The tool is renamed to match Claude Code. Commands now run in bash instead of whatever `$SHELL` is (fish and csh choke on the `rg … | head` / `&&` syntax the model writes): bash on macOS/Linux (zsh if it is your shell and bash is missing, then `/bin/sh`), and Git Bash on Windows, falling back to `cmd.exe` only when Git for Windows isn't installed. The system prompt states the shell, and warns the model when it is stuck on `cmd.exe`. Hook commands use the same shell. Hook matchers written as `execute_command` still match, and sessions saved with the old tool name still resume.

## [6.9.1] - 2026-09-30

### Added
Expand Down
10 changes: 4 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -349,7 +349,7 @@ tools prompt first:

- **File edits** (`file_edit`, `multi_file_edit`, `file_write`) — prompt shows
the target; `y` allow once, `n` deny, `a` allow for the rest of the session.
- **Commands** (`execute_command`) — prompt shows the exact command line. The
- **Commands** (`Bash`) — prompt shows the exact command line. The
model classifies commands with an `isDangerous` flag; dangerous commands
(deletes, force-pushes, system changes…) can **never** be auto-approved — no
`a` option, and `--yolo`/session-approval don't apply.
Expand Down Expand Up @@ -466,7 +466,7 @@ shell commands from a repo — see [Security](https://github.com/MatterAIOrg/Orb
"hooks": {
"PreToolUse": [
{
"matcher": "execute_command",
"matcher": "Bash",
"hooks": [
{
"type": "command",
Expand All @@ -492,7 +492,7 @@ shell commands from a repo — see [Security](https://github.com/MatterAIOrg/Orb

Start OrbCode normally — that's all. Each event maps to a list of matchers; a
matcher has an optional `matcher` regex (omit, or use `"*"`, to match
everything; the regex is auto-anchored so `"execute_command"` matches exactly
everything; the regex is auto-anchored so `"Bash"` matches exactly
that tool name) and a list of `command` hooks (`timeout` is per-command
seconds, default 10).

Expand Down Expand Up @@ -979,9 +979,7 @@ Active in the CLI (aligned with the extension's native tools, with CLI-specific
| `file_edit` | single replacement; unique-match enforcement; `replace_all`; empty `old_string` = whole file |
| `multi_file_edit` | batched edits grouped per file, per-edit OK/FAILED results |
| `file_write` | creates parent dirs, full-content writes |
| `list_files` | optional recursive, ignores node_modules/.git/build dirs, 800-entry cap |
| `search_files` | FFF-first Rust-regex search, compact pagination, and bundled/system ripgrep fallback |
| `execute_command` | user's shell, 120s timeout, 30k output cap, optional cwd |
| `Bash` | user's shell, 120s timeout, 30k output cap; also used for search/listing (`rg`, `find`, `ls`) — read-only commands skip approval |
| `web_search` / `web_fetch` | proxied through the MatterAI backend with your token |
| `update_todo_list` | drives the TUI todo panel |
| `use_skill` | loads standalone or namespaced plugin skill instructions |
Expand Down
24 changes: 12 additions & 12 deletions docs/HOOKS.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ condition is met.

OrbCode's hooks follow the **same contract as Claude Code's hooks**, so scripts
written for Claude Code work here with two tweaks: use `$MATTERAI_PROJECT_DIR`
(not `$CLAUDE_PROJECT_DIR`) and use OrbCode's tool names (`execute_command`,
(not `$CLAUDE_PROJECT_DIR`) and use OrbCode's tool names (`Bash`,
`file_edit`, …) in your matchers. See [Differences from Claude
Code](#differences-from-claude-code).

Expand Down Expand Up @@ -67,7 +67,7 @@ chmod +x ~/.orbcode/hooks/guard.sh
"hooks": {
"PreToolUse": [
{
"matcher": "execute_command",
"matcher": "Bash",
"hooks": [{ "type": "command", "command": "~/.orbcode/hooks/guard.sh" }]
}
],
Expand Down Expand Up @@ -136,8 +136,8 @@ configuration you own.
- **`matcher`** — a JavaScript regex tested against one field of the event (the
tool name for `PreToolUse`/`PostToolUse`, `source` for `SessionStart`, etc.;
see the per-event tables). The regex is **auto-anchored** (`^…$`), so
`"execute_command"` matches exactly that tool name, not
`"execute_command_extra"`; use `"a|b"` for alternation. Omit it, or use
`"Bash"` matches exactly that tool name, not
`"BashExtra"`; use `"a|b"` for alternation. Omit it, or use
`"*"`, to match everything. An invalid regex falls back to an exact-string
comparison.
- **`hooks`** — the commands to run when the matcher matches. You can list
Expand Down Expand Up @@ -468,7 +468,7 @@ exit 0
"hooks": {
"PreToolUse": [
{
"matcher": "execute_command",
"matcher": "Bash",
"hooks": [{ "type": "command", "command": "~/.orbcode/hooks/guard.sh" }]
}
]
Expand All @@ -491,14 +491,14 @@ exit 0

### Auto-approve a safe, read-only tool

Skip the approval prompt for `read_file` and `list_files`:
Skip the approval prompt for `read_file` (searching and listing go through `Bash`, and read-only commands such as `rg`, `find` and `ls` already skip approval):

```json
{
"hooks": {
"PreToolUse": [
{
"matcher": "read_file|list_files|search_files",
"matcher": "read_file",
"hooks": [
{ "type": "command",
"command": "echo '{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"allow\"}}'" }
Expand Down Expand Up @@ -538,7 +538,7 @@ Force `ls` to always be `ls -la`:
"hooks": {
"PreToolUse": [
{
"matcher": "execute_command",
"matcher": "Bash",
"hooks": [{ "type": "command", "command": "~/.orbcode/hooks/rewrite.sh" }]
}
]
Expand Down Expand Up @@ -698,7 +698,7 @@ exit 0
**Run a hook by hand** — pipe it a fake payload and inspect the exit code:

```bash
echo '{"hook_event_name":"PreToolUse","tool_name":"execute_command","tool_input":{"command":"rm -rf /"}}' \
echo '{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"rm -rf /"}}' \
| ~/.orbcode/hooks/guard.sh
echo "exit=$?"
```
Expand All @@ -723,8 +723,8 @@ file=$(node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>console.l
with the workspace as the working directory.
- **`matcher` on a no-match-field event** (e.g. a `matcher` on `Stop`) — it will
never match. Omit the matcher for those events.
- **Wrong tool names** — OrbCode uses `execute_command`, `file_edit`,
`file_write`, `multi_file_edit`, `read_file`, `list_files`, `search_files`,
- **Wrong tool names** — OrbCode uses `Bash`, `file_edit`,
`file_write`, `multi_file_edit`, `read_file`,
`web_fetch`, `web_search`, `update_todo_list` (not Claude Code's `Bash`,
`Edit`, `Write`, …).

Expand All @@ -743,7 +743,7 @@ schema, matcher regexes, parallel execution, per-command timeout). Differences:
| Settings file | `~/.claude/settings.json` | `~/.orbcode/settings.json` |
| Project file | `.claude/settings.json` | `.orbcode/settings.json` |
| Project dir env var | `$CLAUDE_PROJECT_DIR` | `$MATTERAI_PROJECT_DIR` |
| Tool names in matchers | `Bash`, `Edit`, `Write`, `Read`, … | `execute_command`, `file_edit`, `file_write`, `read_file`, … |
| Tool names in matchers | `Bash`, `Edit`, `Write`, `Read`, … | `Bash` (same; the old name `execute_command` still matches), `file_edit`, `file_write`, `read_file`, … |
| Hook types | `command`, plus newer MCP/HTTP/prompt hooks | `command` only |
| Events | full internal SDK set | the documented set: `SessionStart`, `UserPromptSubmit`, `PreToolUse`, `PostToolUse`, `Notification`, `Stop`, `PreCompact`, `SessionEnd` (+ `SubagentStop`, reserved) |

Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
"test:plugins": "node --import tsx --test test/plugins.test.ts",
"test:metrics": "node --import tsx --test test/metrics.test.ts",
"test:json-repair": "node --import tsx --test test/json-repair.test.ts",
"test:readonly": "node --import tsx --test test/read-only-command.test.ts",
"test:search": "node --import tsx --test test/search-files.test.ts",
"test:ui": "bun test test/ui-viewport.test.tsx test/session-picker.test.tsx",
"prepublishOnly": "npm run typecheck && npm run build"
Expand Down
22 changes: 18 additions & 4 deletions src/core/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { walkFiles } from "../tools/executors/listFiles.js"
import { previewFileChange } from "../tools/executors/files.js"
import { extractFigmaUrls, figmaFetch } from "../tools/executors/figma.js"
import { stripSearchPageMetadataForDisplay } from "../tools/executors/searchFiles/format.js"
import { isReadOnlyCommand } from "../tools/readOnlyCommand.js"
import type { AgentCallbacks, AgentEvent, ApprovalDecision } from "./events.js"
import {
getSessionFilePath,
Expand Down Expand Up @@ -85,7 +86,7 @@ const PRUNE_BATCH = 6
/** Results shorter than this are not worth stubbing. */
const PRUNE_MIN_CHARS = 1500
/** Tools whose output is bulky and can simply be re-fetched. */
const PRUNABLE_TOOLS = new Set(["read_file", "search_files", "list_files", "execute_command", "web_fetch", "web_search"])
const PRUNABLE_TOOLS = new Set(["read_file", "search_files", "list_files", "Bash", "execute_command", "web_fetch", "web_search"])
/** Summarize the history before a step once context passes this fraction of the window. */
const AUTO_COMPACT_FRACTION = 0.8
/** Warn the model when the same call returns the same output this many times in a row. */
Expand Down Expand Up @@ -183,6 +184,18 @@ interface PendingToolCall {
arguments: string
}

/** Read-only tools, plus shell commands that only observe (rg, find, ls, git diff, ...). */
function isParallelReadOnlyCall(toolCall: PendingToolCall): boolean {
if (PARALLEL_READ_ONLY_TOOLS.has(toolCall.name)) return true
if (toolCall.name !== "Bash" && toolCall.name !== "execute_command") return false
try {
const args = JSON.parse(toolCall.arguments) as { command?: unknown; isDangerous?: unknown }
return typeof args.command === "string" && !args.isDangerous && isReadOnlyCommand(args.command)
} catch {
return false
}
}

function getGitSummary(cwd: string): string {
try {
const branch = execSync("git rev-parse --abbrev-ref HEAD", { cwd, stdio: ["ignore", "pipe", "ignore"] })
Expand Down Expand Up @@ -1372,7 +1385,7 @@ User time zone: ${timeZone}, UTC${timeZoneOffsetStr}`
// committed in model order so tool_call/tool_result pairing stays intact;
// mutating and interactive calls remain on the serialized path.
let batchEnd = 0
while (batchEnd < toolCalls.length && PARALLEL_READ_ONLY_TOOLS.has(toolCalls[batchEnd].name)) {
while (batchEnd < toolCalls.length && isParallelReadOnlyCall(toolCalls[batchEnd])) {
batchEnd++
}

Expand Down Expand Up @@ -1502,11 +1515,12 @@ User time zone: ${timeZone}, UTC${timeZoneOffsetStr}`

const approvalKind = getApprovalKind(toolCall.name, args)
const diff = approvalKind === "edit" ? previewFileChange(toolCall.name, args, this.options.cwd) : undefined
const isDangerous = toolCall.name === "execute_command" && Boolean(args.isDangerous)
const isDangerous = (toolCall.name === "Bash" || toolCall.name === "execute_command") && Boolean(args.isDangerous)
let needsApproval = false
if (approvalKind === "edit" && !this.sessionApproveEdits) needsApproval = true
if (approvalKind === "command") {
needsApproval = isDangerous || !(this.sessionApproveCommands || this.options.autoApproveSafeCommands)
const readOnly = !isDangerous && isReadOnlyCommand(String(args.command ?? ""))
needsApproval = isDangerous || !(readOnly || this.sessionApproveCommands || this.options.autoApproveSafeCommands)
Comment on lines +1522 to +1523

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟠 Security / NEEDS DISCUSSION

Issue: The old logic prompted for every command in default mode; the new readOnly short-circuit means cat, head, git show, etc. never prompt — but isReadOnlyCommand does not scope paths to the workspace. cat ~/.ssh/id_rsa, cat ~/.aws/credentials, or rg secrets /etc run silently, pulling sensitive files outside the workspace into model context, from where they can be exfiltrated via web_fetch (a realistic prompt-injection chain, since tool results/file contents are untrusted input). The system prompt only says "prefer commands scoped to the workspace" — it is not enforced.

Fix (discussion): Consider requiring approval when a classified-read-only command references paths outside the workspace (home-dir shorthand, absolute paths outside cwd), or limiting the no-prompt fast path to workspace-relative invocations. This is a policy decision worth settling before merge rather than a one-line patch.

Impact: Prevents silent reads of credentials/system files outside the project.

Suggested change
const readOnly = !isDangerous && isReadOnlyCommand(String(args.command ?? ""))
needsApproval = isDangerous || !(readOnly || this.sessionApproveCommands || this.options.autoApproveSafeCommands)

}
// A PreToolUse hook can force the approval prompt ("ask") or skip it ("allow").
if (forceApproval) needsApproval = true
Expand Down
6 changes: 4 additions & 2 deletions src/core/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -283,9 +283,11 @@ function getMatchQuery(event: HookEvent, fields: Record<string, unknown>): strin
function matcherMatches(matcher: string | undefined, query: string | undefined): boolean {
if (!matcher || matcher === "*") return true
if (query === undefined) return false
// Hooks written before the tool was renamed still match "execute_command".
if (query === "Bash" && matcherMatches(matcher, "execute_command")) return true
try {
// Auto-anchor so "execute_command" matches exactly that tool name, not
// "execute_command_extra". Alternation ("a|b") still works because the
// Auto-anchor so "Bash" matches exactly that tool name, not
// "BashExtra". Alternation ("a|b") still works because the
// anchors wrap a non-capturing group: ^(?:a|b)$.
return new RegExp(`^(?:${matcher})$`).test(query)
} catch {
Expand Down
Loading