Expand the documentation with measured figures and operational detail
Most of this replaces "roughly 550 characters per tool" with the actual per-tool measurements, and fills in the parts a reader hits after the happy path: what a specific error means, what a setting costs, what is not covered. Measured rather than estimated: - Per-tool byte cost, all fourteen, and the per-set totals. 7,673 B for the full set, averaging 548. - Builtin skill bodies at 5,284 B against a 681 B catalogue, which is the argument for loading bodies on demand. - Full system prompt 3,571 chars, core-only 2,045. New sections: - tools: which sets to keep and why, the jail function itself, an output-cap table, and the real error strings for edit_file and multi_edit. - configuration: env var per provider preset, cost-estimate limits, what each --no-* flag isolates, and three settings that do more than they look like. - agents: step caps per variant, which variant to reach for, and the fact that reasoning is charged as output and discarded first by compaction. - headless: exit code 0 means "the turn completed", not "the answer was yes" — with the jq pattern for gating on content. Timeouts, concurrent -c runs fighting over one session, CI recipes for --no-skills. - mcp: parallel connect, startup cost, a debugging ladder, and that toolSets does not gate MCP tools. - registry: publishing, local testing over http://localhost, and a troubleshooting section keyed on the actual validator messages. - memory: what compaction discards in what order, /compact versus automatic pruning, and that -c matches on cwd. - skills: the frontmatter reader's limits, and how to verify a skill loaded. Corrections found while cross-checking against the source: - The guard table was missing --force-with-lease and > /dev/sd… - The done event's token fields are optional, so the jq example filters on one rather than assuming it. Two honest limits now written down: the guard matches command strings, so a base64-decoded or script-wrapped command is not caught; and a registry index is trusted for its contents, not its authorship. Verified: all internal links and heading anchors resolve, every docs/ page is reachable from the README, 538 tests pass, typecheck clean.
This commit is contained in:
+180
-31
@@ -27,19 +27,42 @@ y allow once | a always allow edit_file | n deny
|
||||
`a` whitelists that tool for the rest of the session. `n` tells the model it was denied and
|
||||
to ask what to do instead. `--yolo` skips all prompts.
|
||||
|
||||
The approval is enforced by the SDK, not by the tools. A denied call **provably never
|
||||
executes**: the SDK never reaches the tool's `execute`, so a tool cannot forget to honour a
|
||||
denial or opt out of the check. See [architecture](architecture.md#why-approval-goes-through-the-sdk).
|
||||
|
||||
MCP tools are gated as a group because they are third-party code with unknown side effects —
|
||||
`mcp__fs__read_file` sounds harmless and might not be. See [MCP](mcp.md).
|
||||
|
||||
**The guard runs before all of this.** It is not an approval — it is a refusal, and `--yolo`
|
||||
does not reach it. See [plugins](plugins.md).
|
||||
|
||||
## Tool sets
|
||||
|
||||
Each tool costs roughly 550 characters of JSON schema on every request, and selection
|
||||
accuracy drops as the list grows. Sets let you switch off what a project does not need:
|
||||
Each tool costs its name, its description, and its JSON schema on **every request**. Measured
|
||||
across the fourteen built-ins:
|
||||
|
||||
| Set | Tools |
|
||||
|---|---|
|
||||
| `core` | `read_file` `write_file` `edit_file` `glob` `grep` `bash` |
|
||||
| `edit-plus` | `multi_edit` `list_dir` `read_many_files` |
|
||||
| `git` | `git_status` `git_diff` `git_log` `git_show` `git_blame` |
|
||||
| Tool | Bytes | Tool | Bytes |
|
||||
|---|---|---|---|
|
||||
| `read_many_files` | 972 | `git_blame` | 499 |
|
||||
| `multi_edit` | 934 | `git_log` | 484 |
|
||||
| `edit_file` | 618 | `git_diff` | 473 |
|
||||
| `grep` | 595 | `bash` | 466 |
|
||||
| `list_dir` | 594 | `git_show` | 432 |
|
||||
| `read_file` | 526 | `git_status` | 292 |
|
||||
| `glob` | 499 | `write_file` | 289 |
|
||||
|
||||
7,673 bytes for all fourteen, averaging 548. Roughly 1,900 tokens per request before your
|
||||
prompt or the conversation. Selection accuracy also falls as the list grows: a model choosing
|
||||
between six tools picks better than one choosing between twenty.
|
||||
|
||||
Sets let you switch off what a project does not need:
|
||||
|
||||
| Set | Tools | Cost |
|
||||
|---|---|---|
|
||||
| `core` | `read_file` `write_file` `edit_file` `glob` `grep` `bash` | ~2,993 B |
|
||||
| `edit-plus` | `multi_edit` `list_dir` `read_many_files` | ~2,500 B |
|
||||
| `git` | `git_status` `git_diff` `git_log` `git_show` `git_blame` | ~2,180 B |
|
||||
|
||||
```json
|
||||
{ "toolSets": ["edit-plus"] }
|
||||
@@ -50,7 +73,32 @@ agent is not an agent. A disabled set reaches neither the wire nor the system pr
|
||||
a prompt that names an absent tool teaches the model to attempt calls that cannot succeed.
|
||||
Session, plugin, and MCP tools are not part of this budget and are never gated here.
|
||||
|
||||
`/tools` shows which set each live tool came from.
|
||||
An unrecognised set name is dropped silently. The header line at startup shows which sets
|
||||
actually loaded, so a typo reads as "that set is off" rather than as an error — worth checking
|
||||
if a tool you expected is missing.
|
||||
|
||||
`/tools` shows which set each live tool came from:
|
||||
|
||||
```
|
||||
tools
|
||||
20 offered this turn of 22 registered
|
||||
- `bash` core
|
||||
- `git_diff` git
|
||||
- `list_dir` edit-plus
|
||||
- `remember`
|
||||
```
|
||||
|
||||
A tool with no set is a session, plugin, or MCP tool.
|
||||
|
||||
### Which sets to keep
|
||||
|
||||
Both extra sets earn their place in most projects, but not all:
|
||||
|
||||
- **No git in the repo?** `git` is 2,180 bytes the model can never use. Switch it off.
|
||||
- **A model that handles many tools badly?** `{ "toolSets": [] }` trims to six, which is the
|
||||
smallest set that still lets the agent work.
|
||||
- **Reading a lot, editing rarely?** Keep `edit-plus` for `list_dir` and `read_many_files`
|
||||
alone; they pay for themselves in round trips saved.
|
||||
|
||||
## File tools
|
||||
|
||||
@@ -107,7 +155,14 @@ replaceAll replace every occurrence instead of requiring exactly one
|
||||
|
||||
`oldString` must match byte-for-byte and appear exactly once unless `replaceAll` is set.
|
||||
An ambiguous match is an error naming the count, which pushes the model to add surrounding
|
||||
context rather than guessing which occurrence it meant.
|
||||
context rather than guessing which occurrence it meant:
|
||||
|
||||
```
|
||||
oldString appears 3 times in src/users.ts. Add surrounding context or set replaceAll.
|
||||
```
|
||||
|
||||
That error is deliberately specific. `edit failed` would leave the model to retry blind; the
|
||||
count tells it what to do next.
|
||||
|
||||
### `multi_edit`
|
||||
|
||||
@@ -121,7 +176,14 @@ the previous one, so edits may build on each other.
|
||||
|
||||
Atomic: every edit is validated and applied in memory first, so a failure on the third edit
|
||||
leaves the file exactly as it was rather than half-changed. The same uniqueness rule as
|
||||
`edit_file` applies per edit, and the error names which edit failed.
|
||||
`edit_file` applies per edit, and the error names which edit failed:
|
||||
|
||||
```
|
||||
edit 2: oldString not found in src/users.ts. No edits were applied.
|
||||
```
|
||||
|
||||
The last sentence matters. Without it a model reading the error has to guess whether edit 1
|
||||
landed, and its next move — retry the whole batch, or only what failed — depends on the answer.
|
||||
|
||||
### `list_dir`
|
||||
|
||||
@@ -131,9 +193,19 @@ depth levels to descend, 1-6, default 2
|
||||
includeIgnored also show files git ignores
|
||||
```
|
||||
|
||||
Tree view honouring `.gitignore`. Directories end with `/`, files show their size. Past the
|
||||
depth limit the containing directory is still listed, so the shape of the tree stays visible
|
||||
without its contents. Capped at 300 entries.
|
||||
Tree view honouring `.gitignore`. Directories end with `/`, files show their size:
|
||||
|
||||
```
|
||||
.
|
||||
README.md 2B
|
||||
src/
|
||||
app.ts 2K
|
||||
ui/
|
||||
```
|
||||
|
||||
Past the depth limit the containing directory is still listed, so the shape of the tree stays
|
||||
visible without its contents — `src/ui/` above appears at `depth: 2` even though its files do
|
||||
not. Capped at 300 entries.
|
||||
|
||||
### `glob`
|
||||
|
||||
@@ -145,9 +217,12 @@ includeIgnored also return files git ignores
|
||||
|
||||
Walks the tree honouring `.gitignore` and `.shiroignore`, skipping `.git` and
|
||||
`node_modules` unconditionally. Nested ignore files apply only within their own directory,
|
||||
as git does. Returns posix paths relative to the workspace root. A symlinked directory is
|
||||
classified as a directory and not descended into, since it can point anywhere including
|
||||
back into the tree.
|
||||
as git does. Returns posix paths relative to the workspace root.
|
||||
|
||||
A symlinked directory is classified as a directory and not descended into. Both halves matter:
|
||||
`readdir` reports a junction as a non-directory, so without the extra `stat` a symlinked
|
||||
directory leaked past `dir/` ignore rules and was yielded as a file with a nonsense size. Not
|
||||
descending is separate — a link can point anywhere, including back into the tree.
|
||||
|
||||
### `grep`
|
||||
|
||||
@@ -162,6 +237,15 @@ Shells out to ripgrep when it is on PATH — roughly 15x faster on a real repo
|
||||
back to a JavaScript walker otherwise. Output is `path:line: text` either way, so the model
|
||||
sees one format regardless. Skips binaries. Caps at 200 hits.
|
||||
|
||||
Two details keep the two paths in agreement. ripgrep is passed `--no-require-git`, because it
|
||||
otherwise ignores `.gitignore` outside a repository while the JavaScript fallback always honours
|
||||
it. And an rg exit code above 1 means rg could not run the search at all, so the fallback takes
|
||||
over; exit 1 is simply "no matches" and is reported as such.
|
||||
|
||||
Regex syntax differs between the two: ripgrep is Rust regex, the fallback is JavaScript. A
|
||||
pattern using look-around works in the fallback and fails under rg. An invalid pattern is
|
||||
reported as `Invalid regex: <reason>` rather than returning an empty result set.
|
||||
|
||||
### `bash`
|
||||
|
||||
```
|
||||
@@ -194,12 +278,25 @@ running quits as usual.
|
||||
|
||||
All five are read-only and therefore approval-free. Each spawns `git` with a fixed argument
|
||||
array rather than a shell string, so an argument like `--author="; rm -rf /"` can only ever
|
||||
be a literal argument — which is what makes auto-approval safe.
|
||||
be a literal argument — which is what makes auto-approval safe. A test asserts exactly that:
|
||||
`git_log` with the path `; touch pwned.txt` creates no file.
|
||||
|
||||
Output is described rather than raw porcelain: `git_status` names the branch and says
|
||||
`staged modified` or `untracked` per file instead of leaving the model to decode two columns
|
||||
of flags. Outside a repository they fail with `<cwd> is not a git repository.` rather than
|
||||
passing git's own error text through.
|
||||
Output is described rather than raw porcelain. `git_status` names the branch and says
|
||||
`staged modified` or `untracked` per file instead of leaving the model to decode porcelain's two
|
||||
leading columns:
|
||||
|
||||
```
|
||||
On main, 2 changed:
|
||||
src/app.ts (staged modified, modified)
|
||||
new.ts (untracked)
|
||||
```
|
||||
|
||||
That file has a staged change *and* a later unstaged one, which the raw `MM` prefix conveys only
|
||||
to a reader who knows the format.
|
||||
|
||||
Outside a repository they fail with `<cwd> is not a git repository.` rather than passing git's
|
||||
own error text through. Other git failures do pass through, on purpose: `git_show no-such-ref`
|
||||
reports what git said, because git's own message is the most useful thing available.
|
||||
|
||||
```
|
||||
git_status branch, staged, modified, untracked
|
||||
@@ -209,6 +306,13 @@ git_show ref path? one commit: message, author, diff
|
||||
git_blame path startLine? endLine? who last changed each line
|
||||
```
|
||||
|
||||
`git_log` defaults to 15 commits and caps at 40. `git_blame` without a range blames the whole
|
||||
file; with `startLine` and no `endLine` it covers 40 lines from there.
|
||||
|
||||
Everything here is also reachable through `bash`. The reason the set exists anyway is the
|
||||
approval boundary: `bash git diff` stops for a decision on every call, while `git_diff` cannot
|
||||
mutate anything and so never needs one.
|
||||
|
||||
## Agent tools
|
||||
|
||||
### `task`
|
||||
@@ -223,8 +327,17 @@ Spawns a read-only subagent with `read_file`, `glob`, and `grep` only. It return
|
||||
report, so the parent pays for findings rather than the whole search transcript. It sees
|
||||
none of the parent conversation, so its prompt has to stand alone.
|
||||
|
||||
Two properties follow from that tool set rather than from policy: it can never trigger an
|
||||
approval prompt, because it has no gated tools; and the parent's context holds the conclusion
|
||||
instead of the search. A subagent reading forty files to answer one question costs the parent
|
||||
the answer, not the forty files.
|
||||
|
||||
`explore` finds and reports. `review` critiques code in severity order. Progress streams to
|
||||
the subagent panel.
|
||||
the subagent panel. Capped at 20 steps, and it shares the parent's model — an `explore` run
|
||||
pays reasoning rates for what is really a search, which is [on the list](../TODO.md) to fix.
|
||||
|
||||
Not worth delegating a single grep: the subagent is a whole extra model loop, so it wins on a
|
||||
search spanning many files and loses on anything you could answer in one call.
|
||||
|
||||
### `ask`
|
||||
|
||||
@@ -235,9 +348,11 @@ multiple allow more than one
|
||||
```
|
||||
|
||||
Stops the turn and puts the question on screen. With options it is a picker; without, free
|
||||
text. `esc` skips, which tells the model to decide and state its assumption.
|
||||
text. `esc` skips, which returns "the user dismissed this; use your best judgement" — so a
|
||||
dismissal is an instruction to decide, not a dead end.
|
||||
|
||||
Withheld entirely in headless mode — a question with no one to answer it would hang.
|
||||
Withheld entirely in headless mode — a question with no one to answer it would hang. The system
|
||||
prompt says so, and tells the model to decide and state its assumption instead.
|
||||
|
||||
### `todo_write`
|
||||
|
||||
@@ -249,6 +364,9 @@ Statuses: `pending`, `in_progress`, `done`, `blocked`. Send the whole list each
|
||||
replaces the previous one. Warns when more than one task is `in_progress`, when nothing is
|
||||
`in_progress` while work remains, or when a `blocked` task has no note.
|
||||
|
||||
The list lives in the system prompt, rebuilt every step, so it survives both pruning and
|
||||
`/compact`. See [memory](memory.md).
|
||||
|
||||
### `remember`, `recall`, `forget`
|
||||
|
||||
Durable per-project notes. See [memory](memory.md).
|
||||
@@ -264,15 +382,46 @@ Loads the body of a skill. See [skills](skills.md).
|
||||
## Path safety
|
||||
|
||||
Every path a tool receives goes through a jail: resolved against the workspace root, then
|
||||
checked that it did not escape. `../../etc/passwd` and absolute paths outside the root are
|
||||
both refused before any filesystem call.
|
||||
checked that it did not escape.
|
||||
|
||||
```ts
|
||||
export function jail(p: string, root = process.cwd()): string {
|
||||
const abs = isAbsolute(p) ? resolve(p) : resolve(root, p);
|
||||
const rel = relative(resolve(root), abs);
|
||||
if (rel.startsWith('..') || isAbsolute(rel)) throw new Error(`Path escapes workspace: ${p}`);
|
||||
return abs;
|
||||
}
|
||||
```
|
||||
|
||||
`../../etc/passwd`, `a/../../secret`, and absolute paths outside the root are all refused
|
||||
before any filesystem call. Resolving first and comparing after is what catches the middle
|
||||
case: string-prefix checks on the raw input miss `a/../../secret` entirely.
|
||||
|
||||
The model's output is a trust boundary. It can emit any string, so the check happens on
|
||||
every call rather than being assumed.
|
||||
every call rather than being assumed. That includes `read_many_files`, where a bad path is
|
||||
reported in its block like any other unreadable file.
|
||||
|
||||
`jail` guards the workspace, not the shell. `bash` runs whatever it is given, which is why
|
||||
every call needs approval and why the guard plugin exists — see [plugins](plugins.md).
|
||||
|
||||
## Output caps
|
||||
|
||||
Any single tool result is truncated at 30,000 characters with a note saying how much was
|
||||
cut. `grep` stops at 200 hits, `glob` at 200 paths, `list_dir` at 300 entries,
|
||||
`read_many_files` at 20 files, `read_file` at 2000 lines by default. Without caps one `grep`
|
||||
for `function` can end a session.
|
||||
Any single tool result is truncated at 30,000 characters with a note saying how much was cut:
|
||||
|
||||
```
|
||||
... [truncated 41,233 chars]
|
||||
```
|
||||
|
||||
| Tool | Cap |
|
||||
|---|---|
|
||||
| any result | 30,000 characters |
|
||||
| `grep` | 200 hits, each line cut at 300 chars |
|
||||
| `glob` | 200 paths |
|
||||
| `list_dir` | 300 entries |
|
||||
| `read_many_files` | 20 files |
|
||||
| `read_file` | 2,000 lines by default |
|
||||
| `bash` | 120 s default timeout, 600 s max |
|
||||
|
||||
Without caps one `grep` for `function` can end a session. The caps are per call, so a model
|
||||
that needs more can narrow and ask again — which is cheaper than one call that fills the
|
||||
context and forces compaction.
|
||||
|
||||
Reference in New Issue
Block a user