10 KiB
Plugins
A plugin extends the agent in four ways: it can add tools, mark tools auto-approved, block a tool call before it runs, and append to the system prompt. It can also run something after each turn.
Two kinds exist, and only one can contain code:
- Builtin plugins are compiled into the binary and may do anything in the interface below.
- Installed plugins come from a registry as a JSON manifest of refusal rules. They are data: the guard evaluating them is compiled code, identical for every install. See registry.
Loading TypeScript from disk or a URL is deliberately not supported. A plugin that can block tool calls can also lie about blocking them, and one that could execute could read every file the agent can read. That is a sandbox problem, not a loader problem — see ROADMAP.md.
Enabling
{ "plugins": ["guard", "secrets", "protect", "time", "no-force-push", "no-net-pipe", "no-root", "no-env-write"] }
That is also the default when the field is absent, and it lists builtin plugins only.
Installed plugins are always active once present, because installing one was the decision to
enable it; remove it with /registry remove <name>.
--no-plugins disables everything, builtin and installed, including the guard. /plugins
lists what is active, marks installed entries, and reports any name that did not resolve.
The interface
export type Plugin = {
name: string;
description: string;
tools?: ToolSet;
autoApprove?: readonly string[];
beforeToolCall?: (ctx: { toolName: string; input: unknown; cwd: string }) => string | undefined | Promise<string | undefined>;
afterTurn?: () => void | Promise<void>;
appendix?: string;
};
beforeToolCall returning a string blocks the call, and the string is given to the model
as the reason. Returning undefined allows it.
Two decisions worth knowing about:
A throwing hook blocks. A guard that crashes must fail closed. Treating an exception as "allow" would mean a bug in a security plugin silently disables it.
Blocks are checked before approval. --yolo skips prompts; it does not skip guards. A
plugin block is a refusal, not a permission question.
A guard sees bash before the command runs, not while it runs. The guard is the only thing
that can refuse a command outright; once one is running, ctrl-c is what stops it. Both matter:
a pattern the guard does not know about is still interruptible by hand.
Builtins
guard (default on)
Refuses irreversible shell commands outright. Approval alone is a weak defence here: a user
holding a through a batch of edits will approve one of these without reading it.
| Pattern | Why |
|---|---|
rm -rf, rm -f |
recursive or forced delete |
git reset --hard |
discards uncommitted work |
git clean -f |
deletes untracked files |
git push --force, --force-with-lease, -f |
rewrites remote history |
git branch -D |
deletes a branch without a merge check |
DROP TABLE, TRUNCATE |
destroys database data |
mkfs, dd of=/dev/…, > /dev/sd… |
writes to a raw device |
chmod 777 |
makes files world-writable |
shutdown, reboot, halt |
affects the whole machine |
:(){ :|:& };: |
fork bomb |
curl … | sh, wget … | sh |
pipes a download into a shell |
Blocked by the guard plugin: refusing "rm -rf build" (recursive or forced delete).
Ask the user to run it themselves if it is really needed.
The model is told to relay the command rather than work around it. rm build/one-file.js,
git push origin feature, and git commit all pass — the patterns target irreversibility,
not the commands themselves.
Two honest limits. The patterns match the command string, so bash -c "$(echo cm0gLXJm | base64 -d)"
is not caught, and neither is a script the agent wrote and then ran. And it only inspects bash:
a write_file overwriting something important is an approval question, not a guard question.
The guard is the last line before a command runs; ctrl-c is the one after. A pattern the guard
does not know about is still interruptible by hand — see tools.
protect (default on)
Refuses writes to files whose contents belong to a tool rather than to anyone editing them by hand. This is a different failure from a secret: the repository looks fine and behaves wrongly, and the breakage surfaces somewhere else entirely.
| Refused | Why |
|---|---|
.git/** |
git's own object store |
bun.lock, package-lock.json, pnpm-lock.yaml, yarn.lock, Cargo.lock, go.sum, poetry.lock, uv.lock, composer.lock, Gemfile.lock |
the package manager owns it |
node_modules/** |
an installed dependency |
vendor/**, target/debug/**, target/release/** |
vendored or build directory |
dist/**, build/**, out/**, .next/**, .nuxt/**, .svelte-kit/**, coverage/** |
generated output |
.venv/**, .tox/**, .mypy_cache/**, .ruff_cache/**, .turbo/** |
tool caches |
refusing to write bun.lock (a lockfile the package manager owns). Regenerate it with the
tool that owns it rather than editing it.
The message says what to do instead, which matters: a model told only "no" writes the same
content somewhere else. A lockfile is regenerated by bun install; build output is regenerated
by the build.
Both separators match, so node_modules\react\index.js is refused on Windows too. Lookalike
names are not: src/gitignore-parser.ts, docs/dist-layout.md, and distributed/queue.ts all
write normally.
time (default on)
Adds current_time, returning ISO 8601 plus the local string. Auto-approved; it reads
nothing. Useful because models are confidently wrong about the date.
The narrow safety refusals (default on)
Six small plugins, each blocking one irreversible class of mistake. They are on by default for
the same reason the guard is: a safety check you have to opt into is not one. Each is data — a
name, a pattern list, and a refusal message — matched against the bash command string (or, for
confirm-delete, the path a delete_file carries).
| Plugin | Refuses | Why |
|---|---|---|
no-force-push |
git push --force, --force-with-lease, -f, +<ref> |
rewrites remote history |
no-net-pipe |
curl … | sh, wget … | node, iex (iwr …) |
executes a download unseen |
no-root |
sudo …, elevated runas / Start-Process -Verb RunAs |
nothing the agent does should need root |
no-env-write |
export …KEY/TOKEN/SECRET/PASSWORD=… |
writes a credential into the environment |
no-main-commit |
git commit/git merge naming main/master |
touches the default branch directly |
no-git-config |
git config --global, identity/runner keys |
changes how git identifies or runs |
Normal commands pass: git push origin feature, npm test, export NODE_ENV=production. The
patterns target the irreversible act, not the command family.
The advisory plugins (opt in)
Three plugins carry only a prompt appendix — no blocking hook — so they shape behaviour without ever refusing a call. Enable them in config when you want the nudge:
| Plugin | Advises |
|---|---|
conventional-commit |
commit subjects as type(scope): summary, e.g. fix(auth): reject expired tokens |
tests-first |
for a bug, pin it with a failing test before fixing; watch it fail, then pass |
small-diffs |
one change does one thing; split a diff that is really two |
{ "plugins": ["guard", "secrets", "protect", "time", "conventional-commit", "small-diffs"] }
confirm-delete (opt in)
Refuses delete_file calls whose path is broad or ambiguous — a wildcard, a trailing slash, or
an empty path — so a delete is always one explicit file:
refusing to delete "src/*" (ambiguous or broad). Delete one explicit file.
bell (opt in)
Writes \u0007 to stderr when a turn ends. Off by default — a bell after every turn is
intrusive, but it is genuinely useful when a turn takes minutes.
{ "plugins": ["guard", "secrets", "protect", "time", "bell"] }
Writing one
A refusal rule is usually better as an installed manifest: no rebuild, and nothing to review. See registry for the manifest shape. Reach for a builtin only when the plugin needs to contribute a tool or run something after a turn.
Builtin plugins live in src/plugins-builtin.ts and are registered in BUILTIN_PLUGINS.
export const noSecretsPlugin: Plugin = {
name: 'no-secrets',
description: 'refuses to write files that look like credentials',
appendix:
'The no-secrets plugin refuses writes to .env and credential files. Ask the user to ' +
'add secrets themselves rather than working around it.',
beforeToolCall: ({ toolName, input }) => {
const WRITE_TOOLS = ['write_file', 'edit_file', 'multi_edit', 'apply_patch', 'move_file', 'delete_file'];
if (!WRITE_TOOLS.includes(toolName)) return undefined;
const path = String((input as { path?: unknown } | null)?.path ?? '');
if (/(^|\/)\.env|credentials|\.pem$/.test(path)) {
return `refusing to write ${path}; add secrets yourself`;
}
return undefined;
},
};
Then add it to BUILTIN_PLUGINS and, if it should be on by default, DEFAULT_ENABLED.
Note the six tool names. Every write tool has to be listed, and multi_edit, apply_patch,
and move_file are all easy to miss — a guard that only checks write_file and edit_file is
bypassed by a batch edit, a patch, or a rename. apply_patch and move_file also carry their
paths somewhere other than path, so a guard reading only that field sees nothing to check.
The builtins share one writtenPaths helper for exactly that reason.
Write the appendix whenever the plugin can block something. Without it the model hits a
refusal it was never told about and tries to route around it.
Ordering
Builtin plugins run first, in the order they are enabled, then installed ones. The first
beforeToolCall to block wins; later hooks are not consulted. afterTurn runs every hook, and
one throwing does not stop the rest.