Swarm Swarm

Rules and configuration

Swarm evaluates every Bash command a Claude Code session is about to run against a small set of coordination rules. A rule that matches returns a real permission decision to Claude Code — ask (the agent must confirm with you) or deny (blocked) — and records an incident. Rules replace the "never do X" prose in CLAUDE.md with something enforced.

Two config layers

Configuration is TOML, deep-merged over built-in defaults. Later layers win:

  1. ~/.swarm/config.toml — global, for the whole machine
  2. <repo>/.swarm.toml — per repository, optional; commit it so the repo declares its own rules

Per-repo config is found from the git toplevel of the session's working directory, and a worktree picks up the .swarm.toml in its own checkout. The daemon re-reads repo config within about 30 seconds of a change; global config is read when the daemon starts (swarm restart to apply).

Bad config never takes the daemon down: invalid TOML is ignored with a warning, an unknown rule mode or an out-of-range port falls back to its default, and unknown keys are kept.

Reference

Every key, with its default:

[daemon]
# Preferred port. SWARM_PORT overrides it. If the port is taken the daemon
# binds a free one instead and records the real URL in ~/.swarm/daemon.json.
port = 7777
# "loopback-optional" (default): local callers may omit ~/.swarm/token.
# "required": every request must carry it. Non-loopback callers always need it.
auth = "loopback-optional"

[rules]
# Each rule is "ask" (agent must confirm), "deny" (blocked) or "off".
shared_tree     = "ask"
destructive_git = "ask"
pattern_kill    = "ask"
protected_ports = "ask"
no_foreign_worktree     = "ask"
claim_required_to_write = "off"
no_verify     = "off"      # "rewrite": git loses --no-verify / --no-gpg-sign and runs
dry_run_first = "off"      # "rewrite": first terraform apply / kubectl delete / helm uninstall runs dry
destructive_fs    = "ask"  # rm -rf of /, ~, .., "$VAR"/ or anything outside the repo
destructive_infra = "ask"  # terraform destroy, kubectl delete ns, cloud deletes, SQL DROP
pipe_to_shell     = "ask"  # curl … | sh
secrets           = "ask"  # cat .env, reading keys and cloud credentials
config_tamper     = "ask"  # editing agent / Swarm settings, swarm uninstall

# [[rules.custom]]         # your own: match (regex) → ask | deny | rewrite (with replace)
# name = "no-force-push"
# match = "git push .*--force"
# action = "deny"

collision_context = true   # after an edit, name the other live session that just edited the same file
collision_window = 15      # minutes

[rules.protected]
# Ports agents must not kill or free. Empty by default.
ports = []

[budget]
# Spend ceilings per repo (USD). warn_at opens an incident; past 100%, on_exceed:
# "warn" | "ask" (spending tools ask first) | "stop" (spawned runs stopped)
# daily = 25
# weekly = 100
warn_at = 0.8
on_exceed = "warn"
window_warn_at = 0.8       # plan's 5-hour / 7-day window (Pro / Max, via the status line): incident at this fraction; false = off

[tasks]
# The repo's backlog, read-only: a markdown file with ID | Task | Depends | Status
# tables, or "github" (issues via the gh CLI) / "linear" (via LINEAR_API_KEY).
# source = "docs/plan.md"
labels = []                # GitHub: only issues carrying every one of these
# team = "ENG"             # Linear: one team

[gates]
# Verifications a task must pass. See the Claims page.
required = []              # e.g. ["review", "tests"]
auto = "session-end"       # when the daemon runs executable gates itself: "session-end" | "stop" | "off"
on_stop = "record"         # "block": refuse a Stop while a required gate fails (the repair loop), at most max_blocks times
max_blocks = 3

# [gates.tests]
# cmd = "bun test"         # exit 0 is a pass; the rubric is the command that ran
# timeout = 900            # seconds, then killed and recorded as a fail
# cwd = "packages/api"     # relative to the worktree; default = the worktree root
# [gates.review]
# builtin = "review"       # a read-only `claude -p` over the diff, fixed rubric
# model = "sonnet"

# [[workflows]]            # a step sequence the daemon walks a task through
# name  = "ship"
# steps = ["implement", "gate:tests", "gate:review", "pr"]
# [workflows.prompts]
# implement = "Task {task}: {title}. Work only in this worktree."

[dispatch]
# Defaults for `swarm dispatch` / swarm_dispatch.
max_parallel = 2           # dispatched runs at once per project
require_pr = true          # a task counts as done only with a PR open for its branch
# permission_mode = "acceptEdits"
# profile = "no-edits"     # full | no-edits | read-only
# model = "sonnet"
# max_turns = 40

[events]
# Retention for chatter events, in days. 0 = keep forever. Global only.
retain_days = 30

[audit]
# The ledger-changing subset — claims, gates, permissions, incidents, PRs.
# Kept forever by default. Global only.
retain_days = 0

[privacy]
# What is stored at all. Global only.
store_prompts = true       # false: prompts and assistant text are not stored
store_reasoning = true     # false: thinking text is not stored (token counts still are)
redact = []                # extra regexes replaced by [redacted] in every stored string;
                           # API-key and Bearer-token shapes are always redacted

[worktree]
# Make new worktrees start warm: untracked files to copy from the main checkout,
# and one setup command to run inside the worktree. Both off by default.
# copy  = [".env.local"]
# setup = "bun install"
# open  = "code {path}"    # what `swarm wt open` / the Board's Open runs; default: the file manager

[models]
# Model allow-list: globs, empty = every model allowed. Spawned runs refuse a
# disallowed model; so does /model in Claude Code — the switch is refused with the
# reason. A session that started on one opens an incident (never interrupted).
allow = []                 # e.g. ["claude-*"]

[notify]
# Every incident POSTed as Slack-compatible {text} JSON. Off by default. Global only.
# webhook = "https://hooks.slack.com/services/…"

[team]
# Forward to a self-hosted team daemon — see the Teams page. Off by default. Global only.
# url = "https://swarm.example.internal"

A typical repo config, stricter than the defaults:

[rules]
shared_tree     = "deny"
destructive_git = "deny"
pattern_kill    = "ask"
protected_ports = "deny"

[rules.protected]
ports = [3000, 5432, 7777]

The rules

Rules are checked in the order below; the first match decides.

protected_ports

Matches commands that kill or free a port on the protected list. The command has to contain kill, fuser -k or kill-port, and name a port:

lsof -ti:5432 | xargs kill
kill $(lsof -t -i :3000)
fuser -k 3000/tcp
npx kill-port 3000

Rules that rewrite instead of refusing

Two rules have a third answer besides ask and deny: rewrite. The call runs, with the dangerous part taken out, and the agent is told what changed and why. no_verify = "rewrite" drops --no-verify and --no-gpg-sign from any git command. dry_run_first = "rewrite" turns the first terraform apply, kubectl delete or helm uninstall a session runs into its dry-run form (terraform plan, --dry-run=client, --dry-run); the second one is allowed, because the agent has now read what it would change. Both ship off; set them to "rewrite", or to "ask" / "deny" if you would rather refuse. Every rewrite lands on the Incidents view as Rewritten, with the command that was asked for beside the one that ran, so rule effectiveness scores it like any rule.

The destructive, secrets and tamper rules

Five more rules watch for the commands nobody can take back. destructive_fs catches a recursive rm aimed at /, your home, .., a path that starts with a variable (rm -rf "$OUT"/ is rm -rf / the day OUT is empty) or anything outside the repository; plus mkfs, dd onto a device and find / … -delete. rm -rf node_modules and anything under /tmp are fine. destructive_infra catches terraform destroy, kubectl delete of a namespace or --all, helm uninstall, cloud deletes (aws s3 rb, gcloud … delete), and SQL DROP / TRUNCATE / unbounded DELETE sent through a database client. pipe_to_shell catches curl … | sh and its bash <(curl …) cousins. secrets catches reading, printing or copying a credential file (cat .env, Read ~/.ssh/id_ed25519, curl -d @.env) and writing a .env* or key file; .env.example and friends are not secrets. config_tamper catches edits to Claude Code's settings, anything under ~/.swarm, .swarm.toml, the other agents' hook config, and swarm uninstall — the files that decide what agents may do.

All five ask (they shipped off in 0.15 so their hits could be watched first). The Security view (Guard → Security) counts what each one catches, with an example, whatever its mode — so a rule you set to "off" still shows what it would have stopped. Set one to "deny" or "off" like any other rule. On Codex, Gemini CLI and Cursor, which cannot ask, an ask is a refusal with the reason (see below). They match the command text, so like the rest they are a lint, not a sandbox — with one backstop: when Claude Code's own settings change in the middle of a command the agent ran (a script that edits ~/.claude/settings.json, say), config_tamper hears about it from Claude Code, opens the incident and tells the agent; set to "deny", it also blocks the change. A change you make yourself, or the "don't ask again" answer in a permission dialog, is not counted.

Other agents

The same rules hold on Codex, Gemini CLI and Cursor. swarm install adds Swarm's hook to Codex (~/.codex/hooks.json, PreToolUse) and Gemini CLI (~/.gemini/settings.json, BeforeTool) when their config folders exist, next to any hooks of your own. Codex runs a new hook only once you have trusted it: open Codex and run /hooks. Cursor needs nothing extra — it runs the Claude Code hooks from ~/.claude/settings.json itself, as long as Settings → Agents → Third-Party Imports is on (it is by default).

One difference: none of the three can put a question to you from a hook. A rule set to ask refuses the call there instead, and tells the agent to hand the command to you. The Rules view (Guard → Rules) lists each agent, whether the hook is in, and what it sees; swarm doctor says the same.

Your own rules

[[rules.custom]]
name = "no-force-push"
match = "git push .*--force"       # a regex over the whole command
action = "deny"                     # ask | deny | rewrite | off
reason = "force pushes go through a PR"

[[rules.custom]]
name = "pnpm"
match = "\\bnpm (install|i)\\b"
action = "rewrite"
replace = "pnpm add"                # every match becomes this ($1 works)

Custom rules run in config order after the built-in coordination rules, so a deny above is never softened into a rewrite. The incident names the rule (custom:pnpm), and Codify offers to tighten it from there. A regex that does not compile drops that rule with a warning, never the daemon. swarm rules dryrun replays the repo's history under any of these, so you can see what a rule would have done before turning it on.

Heads-up on a shared file

Two live sessions editing the same file is the collision the Graphs view draws after the fact. With collision_context on (the default), the second session hears about it the moment its edit lands: a line naming the other session, its task and branch, and how long ago it touched the file, with the suggestion to look at that diff before going further. It is context, never a refusal, and it is said once per pair of sessions per file per collision_window minutes; each heads-up is a collision.warned line in the session log. The rule that refuses an edit inside a worktree someone else holds, no_foreign_worktree, already names the task and its holder in its reason.

The protected list is the union of rules.protected.ports and every port currently held as a runtime resource. Acquiring db with --port 5432 protects 5432 for every other agent with no config change; when the holding is released or reaped the protection goes with it.

pattern_kill

Matches process kills by command pattern, which hit every matching process on the machine — other agents' and yours:

pkill -f node
pgrep -f vite | xargs kill

The agent is told to kill by pid instead. This rule does not depend on other sessions being present.

shared_tree

Matches broad staging while another live session is working in the same checkout (same git toplevel, with hook or transcript activity in the last ten minutes — a session deep in a long turn emits no hooks, but its uncommitted work is still there):

git add -A
git add --all
git add .
git add              # no pathspec at all
git commit -a
git commit -am "msg"

The danger is that git add -A sweeps the other session's uncommitted work into this commit. Two sessions in separate worktrees have different toplevels and never trigger it — which is the point of claims.

destructive_git

Matches git commands that can discard uncommitted work, under the same condition (another live session in the same checkout):

git reset --hard
git checkout .
git checkout -- .
git checkout -f
git restore .
git clean -f
git clean -fd
git stash drop
git stash clear
git branch -D <name>

no_foreign_worktree

The first rule that watches file writes, not just Bash. It matches a Write / Edit / MultiEdit / NotebookEdit whose path is inside a worktree that a held claim created — or a Bash command whose working directory is — when the session itself is not working from that worktree. Holding is inferred from position: every claim gets its own worktree, so the session running inside it is its holder, and any other session is a stranger.

Never touch a worktree you don't hold is Swarm's oldest rule; this makes it a hook decision. The session is told to edit its own checkout or claim the task.

claim_required_to_write

Off by default, because it demands a workflow. When a repo turns it on, file writes into the shared checkout (the main working tree, not any claimed worktree) are asked or denied unless the session is working from a claimed worktree. It turns claims from bookkeeping into the rule: on this repo, you claim a task, you get a worktree, you write there.

[rules]
claim_required_to_write = "deny"

Bash is not covered — a cat is not a write — and neither are paths outside the repo.

Budgets

A repo can carry a spend ceiling: [budget] daily = 25 and/or weekly = 100 (USD, from the same transcript-priced numbers the Spend view shows). At warn_at (80% by default) a budget incident opens once for the day; past 100% another does, and on_exceed says what else happens — "warn" nothing more, "ask" makes every Bash / Edit / Write in that repo ask first (the reason names the ceiling), "stop" stops the repo's spawned runs and clears its dispatch queue. The Spend view shows the ceiling as a tile when a project is selected. Budgets are checked every 30 seconds; an interactive session past the ceiling keeps working, it just confirms each change.

On a Pro / Max plan the meter that matters is the 5-hour and 7-day window, not dollars. With the status line installed, Claude Code reports both after every message and Swarm keeps them: Spend shows a tile per window (percent used, reset, burn rate, and whether the limit lands before the reset at the current pace), the status line names the window that runs out first, and window_warn_at (80% by default) opens a budget incident once per window per reset period, again at 100%. Swarm never asks Anthropic for usage; the status line is the only source, so the tiles appear only while a session on a plan keeps reporting.

What rules are — and aren't

Rules are guardrails against accidents, not a security boundary. They classify the Bash command or file write Claude Code is about to make; they do not sandbox the agent. An agent that is denied git add -A can still write the same command into a script, a Makefile target or a heredoc and run that, and it can edit files directly without Bash at all. Swarm's rules exist to stop the common collisions — the broad stage that sweeps up a colleague's work, the pkill -f that takes down a neighbour's dev server — and to leave a record when they fire. They are not a defence against an agent that is trying to get around them; for that you need Claude Code's own permission system and its sandbox settings, worktree isolation via claims, which removes the shared checkout rather than guarding it, and for the runs Swarm starts, sandboxed runs, where the operating system enforces the limits instead of a hook reading the command.

Incidents

Every ask and deny is recorded as an incident — rule, decision, the command or path, the reason the agent saw, and which session it was. The Incidents view is the feed: Open shows what you haven't looked at yet (the count sits in the nav), All is history. Ack a row once you've seen it, or Ack all; the Board shows only open incidents. Over HTTP: GET /v1/incidents?open=1, POST /v1/incidents/:seq/ack, POST /v1/incidents/ack.

Each one stores the rule, the action, the command (first 400 characters), the reason the agent was shown, the project and the session. Besides the Incidents view they appear on the Board, in the live event stream (swarm tail) as incident.opened, and as a yellow "waiting" state on the session in Fleet while the agent is blocked on you. Force-releasing a resource someone else holds is recorded too, so overrides leave a trace.

Incidents are not only a log. Codify on any row turns one into a .swarm.toml rule and a one-line CLAUDE.md lesson, and the Rules view says whether each rule is settling down or just costing everyone time.

ask, deny, off

  • ask — Claude Code pauses and asks you to approve the command, showing Swarm's reason (prefixed [swarm]). The default for every rule.
  • deny — the command is refused outright; the agent sees the reason and has to do something else.
  • off — the rule is not evaluated.

Both ask and deny are recorded as incidents. Set SWARM_GUARD=off in the daemon's environment to disable the rules at once — with one exception: rules an org policy locks stay enforced (and the attempt is recorded as a tamper incident).

Org policy

A third config layer sits above the two files: ~/.swarm/policy.toml (or $SWARM_POLICY), merged first and the only layer that may carry locked = ["rules.destructive_git", …] — dotted keys that machine and repo config cannot override. Overrides are reported, not silently applied, and swarm doctor shows which file each effective value came from. Locked rules keep working even when the daemon is down (the hook shim evaluates them from an integrity-checked local cache) and even under SWARM_GUARD=off.

On a team, the policy file is distributed automatically: the team daemon serves a signed policy, every machine verifies the signature against the key pinned at swarm login, and a tampered policy is never installed.

Trying a rule before turning it on

Dry-run rules (on the Incidents view, with a project selected) replays that project's recorded tool calls through the rules under modes you pick — what would have been asked or denied had the rule been on. Nothing is recorded. Change a mode, re-run, and read the counts before you commit it to .swarm.toml.

The same report flags flaky signals: a rule that fired three or more times on the same command and was allowed through almost every time. That rule has no teeth here — either the command is legitimate in this repo (turn the rule off) or it should be deny so it stops asking. From the CLI: swarm rules dryrun --set pattern_kill=deny,shared_tree=off; over HTTP: GET /v1/rules/dryrun?project=…&pattern_kill=deny.

How enforcement works, and when it doesn't

The PreToolUse hook posts each Bash command to the daemon and waits up to 400 ms for a decision. If no daemon answers, the hook fails open: the command runs as if Swarm were not installed. A hook must never block your work.

Two details make that fail-open narrow:

  • If ~/.swarm/daemon.json points at a daemon that has died, the hook also tries the default port (7777) before giving up, so a crashed daemon doesn't silently disable the rules while a fresh one is listening on the default port.
  • The hook itself never starts a daemon. Run swarm start (or any CLI command) to bring it back; swarm doctor tells you if it is down.

Environment variables

Read by the daemon and the CLI:

Variable Default Meaning
SWARM_PORT 7777 Preferred daemon port; beats [daemon].port
SWARM_HOME ~/.swarm Where state, config and worktrees live
SWARM_URL from daemon.json Force the daemon URL clients use
SWARM_STRICT_PORT – 1: fail instead of falling back to a free port
SWARM_OFFLINE – 1: never fetch model prices from the network
SWARM_GUARD – off: disable the rules (org-locked rules stay enforced)
SWARM_HOOK_TIMEOUT_MS 400 How long the hook waits for the daemon
SWARM_OWNER agent Owner name used by the MCP server for claims and resources

Port precedence is SWARM_PORT > [daemon].port > 7777.

Source: docs/guide/03-rules-and-config.md · v0.16.0.