Prove

Guardrails: block risky commands before your agent runs them.

The commands your agents may never run. Turn on the ready-made guardrails your team already asks for, or write your own, and the PreToolUse hook Harbor installs refuses the call in Claude Code, Codex, Cursor and GitHub Copilot. Nothing is counted until one is on, and a refusal tells the agent why, with something to run instead when the rule has one.

A deny list is the half anyone can ship

Every tool that runs shell commands for you can be handed a list of commands to refuse. That list is a commodity, and writing it is not the hard part. The hard part is that the list you write from imagination protects you against the disasters you can picture, and the one that has already cost your team a day is usually not on it.

Harbor starts from what your team has already written down. The command that cost somebody a day is usually named somewhere afterwards, in a rule or in a deny entry added to a repo’s settings. Harbor reads those, with the package scripts and tools your repos use, and puts the guardrails they point at in front of you. You still decide which ones refuse.

Hooks or CLAUDE.md: which enforces a rule?

A hook does. CLAUDE.md, AGENTS.md and Cursor rules are context: the agent reads them along with everything else in its window, and nothing stops it acting against a line it read. A PreToolUse hook runs before the tool call itself, outside the model, and can refuse the call whatever the agent decided.

So the two do different jobs. A rules file is where you say how your team works (which agent reads which rules file), and harbor init and harbor bind send the committed ones to Review as proposed rules. A guardrail is for the short list of things that must not happen even once: force-pushing a shared branch, deleting a source tree, resetting a database. Harbor’s guardrails run in the hook it installs for each agent, and the refusal goes back to the model as the reason its call was stopped, so the agent learns the rule at the moment it matters instead of somewhere in a long file.

Claude Code has its own deny list, permissions.deny in .claude/settings.json, and it is worth keeping. Harbor reads those entries to suggest guardrails. What a Harbor guardrail adds is the same rule in Codex, Cursor and Copilot as well, on every machine on the team, a reason and a safer command in the refusal, and a count of how often it fired.

How do I stop Claude Code from running a command?

Turn on a guardrail for it, and the PreToolUse hook Harbor installed refuses the call before it runs, in Claude Code and in Codex, Cursor and Copilot too. Three steps:

  1. Turn it on

    In the web app, open Guardrails. If the command is in the library, press Add and it blocks. If it is not, New guardrail takes the program, its subcommand and its flags, and starts in Observe until you move it to Block.

  2. Let it arrive

    A machine where an agent is working fetches the new rule in the background, at most every 30 seconds, and refuses from its next tool call after that. Nothing to restart.

  3. Check it

    In the repo, harbor check "git push --force" prints which guardrail stops the command and what the agent will read. The hook examples for Claude Code, Codex, Cursor and Copilot below walk through a force push, rm -rf and a database reset.

Start from the library

Guardrails live in the web app, on Guardrails, in two parts. On now is every guardrail your team has turned on: the command, what the agent is told when it is stopped, how often it has fired, and whether it observes, holds or blocks. Library is the ready-made ones on shelves by tool: Git, GitHub, Databases, Docker, Cloud and releases, Files, MCP servers.

Suggested for you comes first, and it holds only the commands your team has already asked for:

  • a rule in your team’s knowledge that names the command
  • a package script in a bound repo that runs it
  • a Bash(…) deny entry in a repo’s committed .claude/settings.json
  • a tool your repos use, such as Docker, Terraform or a published npm package

A deny entry that names a command no ready-made guardrail covers is still suggested, as its own guardrail titled This repo’s Claude settings deny … with the command it denies. An MCP guardrail is never suggested here. Which MCP servers a person installed lives in their own agent config, not in a repo, so those guardrails sit on the MCP servers shelf only.

Prisma’s commands are not on the shelves. They are still built in, and a rule, a deny entry or a package script that names one still puts it under Suggested for you.

Add turns a row on at block, and the toast that confirms it says so and offers Undo: Blocking git push --force. Each machine refuses it from its next sync. The row says On, in Block where it stands rather than moving. Open a row for what the agent is told, what it is offered instead and why it is suggested; a suggestion you do not want goes with Not for us.

Once a guardrail is on, a machine where an agent is working picks it up within about a minute, and on the first turn of the next session Claude Code and Codex tell the developer, once: Your team now stops … in this repo. Why: … A rule that changes what happens to somebody’s next command should not be something they learn from its first refusal. The line says what this machine will actually do, so it has four forms:

The first line of a session, once per guardrail and per effect.
WhenThe developer reads
It blocks⚓ Your team now stops `git push --force` in this repo. Why: This overwrites the remote branch, and commits other people pushed to it are dropped.
It holds⚓ Your team now asks a person to confirm `git push --force` in this repo. Why: This overwrites the remote branch, and commits other people pushed to it are dropped.
It holds or blocks, and this machine has toolGuard off⚓ Your team asks to stop `git push --force`, but guardrails are off on this machine (`toolGuard: false`), so here it is only counted. Why: This overwrites the remote branch, and commits other people pushed to it are dropped.
It observes⚓ Your team now counts `git push --force` in this repo: it still runs, and each run is reported to the team. Why: This overwrites the remote branch, and commits other people pushed to it are dropped.

A guardrail that moves from counting to stopping is said again, because it is a different fact about the next command. Past three new ones the session names three and says how many more come next time. In a folder outside any repo, in this repo reads in folders outside a repo. Cursor and Copilot have nowhere to show the line, so their sessions do not use it up. The line names command and file guardrails only. A guardrail on an MCP tool is not announced, and neither is a rule somebody wrote into their own guardrails.json, which they already know about.

Three rungs: observe, hold, block

A rule observes, holds or blocks. Observing resolves the command, counts what the matcher would have caught, and stays silent. Blocking returns a refusal to the agent instead of letting the tool call run.

Holding means a person confirms each call. In Claude Code the agent pauses on Claude Code’s own permission prompt, with the rule’s reason beside it, and the person at the keyboard approves or denies. Codex and Cursor cannot pause on this event: Codex accepts only a refusal there, and Cursor lets an “ask” through with no prompt at all. So on those two, on Copilot and on Devin, a held call is refused, with a code a person can use to let that one call run. A hold that quietly let the call through on every host but one would be the failure this whole ladder exists to avoid.

The wording follows the host. Claude Code’s prompt ends Held by the git-push-force guardrail. It runs only if you approve it here. followed by the harbor dispute line for a rule that is wrong. Everywhere else the refusal says Held by the git-push-force guardrail. A person must confirm this call, and this agent cannot pause to ask them. and then gives the code.

There is no warn rung. A warning needs a channel that reaches the agent without stopping it, and this event has no dependable one on every host. The value is still in the database enum, unreachable.

A rule is bound to the command, not to how it was spelled

A guardrail is not a pattern over the text somebody typed. The hook resolves the command first, then compares the resolved { program, argv } against the rule. That is the difference between a product and a regex, and it is worth being concrete about what resolving buys.

  • The program is its own name. terraform, /usr/local/bin/terraform and ./bin/terraform are one program, so a rule written the obvious way catches the spelling nobody thought to write down.
  • Shells are entered, not judged on their wrapper. bash -c "rm -rf build" is read as the command inside it rather than as a run of /bin/bash, and a grouping paren or a $(…) in front of a real command does not hide it.
  • Script indirection is followed. An npm run that reaches a destructive command two package manifests away resolves to the command, which is precisely the invocation a pattern over the typed string never sees.
  • Operands are classified, not just counted. Deleting a build directory and deleting a source tree are the same command with different targets, and a rule can say it cares about one and not the other. The worst target in a call decides the call.

The matcher itself is a small predicate rather than a language: program, leading subcommand words, flags that must all be present, flags where any will do, flags that must be absent, a token the operands have to follow, and a minimum number of operands. That last one is the difference between git checkout -b feat/x and git checkout -- src/index.ts, one of which is how you start work and the other of which destroys it.

Everything that has to be true before a command is refused

Four things. Two are switches somebody sets on purpose: the rule is off until somebody turns it on, and refusals are on until somebody turns them off on their machine. The other two say where Harbor works at all. Any one machine can opt out of refusals with one line.

A refusal needs all four. Any one missing means the command runs.
What has to be trueSet whereWhy it is the default it is
The rule is at hold or blockSomebody turned it on, in the web app or in ~/.harbor/guardrails.jsonAdd in the library turns a guardrail on at block; one you write starts in Observe. The machine reads the mode in any case, so BLOCK arms like block, and anything else it does not know, a typo or a rung such as warn, lands on observe.
toolGuard is not false on the machine"toolGuard": falseIn ~/.harbor/synced-origins.json, and the line is what turns refusals off: since 2026-09-10 the default is on. Before that it was opt-in, and every machine nobody had hand-edited refused nothing while its rules looked armed. The person holding the consequences of a refused command can still switch refusals off on their own machine, in one line.
The folder is one Harbor works inThe repo is connected to your workspace (harbor init or harbor bind), or it is a folder outside any repo and your workspace sends Work outside a repo to a SpaceIn a repo nobody connected, the hooks stay quiet: no rule runs, nothing is counted and nothing is sent. A repo that is not your team’s should not be judged by your team’s rules.
Harbor is not off in that folderharbor offA person who pauses Harbor in a repo pauses its guardrails there too. harbor off --capture is different: it stops the counting and keeps the refusing.

One thing lets a single call through when all four are true: a person’s harbor allow-once, with the code from the refusal and a reason. It lifts that one call, once, and the reason goes on the rule’s record. The lift waits 10 minutes for the agent to try again. A refusal’s code can be lifted or disputed for 24 hours after it was printed.

Somebody who switches a team rule down on their own machine, by setting it to observe in guardrails.json, is not doing it in secret. The rule’s row under On now shows off for 1 person, a count and never a name, once that machine has reported the rule firing.

What Harbor refuses with no rule

The hook protects Harbor itself, with no guardrail turned on, because an agent that can switch off its own rules has no rules. On a machine where refusals are on, it refuses an agent’s call to harbor allow-once, bind, dispute, login, logout, off, on and uninstall: each is a person’s decision, and the agent is told to hand it to the person. It also refuses a file write into ~/.harbor, and an edit to an agent’s config that would switch its hooks off. These hold in every folder, connected or not.

And in a folder Harbor is quiet in, a repo nobody connected or one a person paused, an agent’s call to one of Harbor’s own tools is refused with Harbor is off in this folder: it is not a repo the team connected, or a person paused Harbor here. That keeps your team’s rules out of somebody else’s code, and that code out of your team’s brain.

Watching needs neither switch. An observing rule counts on every machine it reaches, in every folder Harbor works in, refusals switched off or not, because gating the counter on the switch that authorises stopping people would leave a team with no way to see how often a rule would fire before anybody is refused. The one thing that stops the counting is a developer pausing capture, below.

What a refusal says

The refusal does not go to a log. It goes back to the model as the reason its tool call was rejected, which is what makes a team’s rule reach the thing that was about to act. This is the shipped rm-recursive rule, armed in a machine’s own guardrails.json, refusing one path. Added from the library instead, it opens with the library’s sentence, This deletes the whole tree for good. Nothing goes to a trash folder, so uncommitted work in it has no other copy., and the note under the remedy is the library’s wording too:

tool call refusedrm -rf packages/api/src/legacy
This deletes the named trees recursively and permanently. `rm -rf` does not move anything to a trash directory, so whatever here is untracked or uncommitted has no copy anywhere else.

Run this instead:

    D=$(mktemp -d) && mv packages/api/src/legacy "$D" && echo "moved to $D"

Takes them out of the working tree exactly as `rm -rf` would, but into a temp directory whose path it prints - so if this was the wrong path, it is one `mv` from being undone.

Blocked by the rm-recursive guardrail.

Code KZRNG5. If this one call should run, ask the person to type `harbor allow-once KZRNG5 --because "why"` in their own terminal, then try again. If the rule is wrong, `harbor dispute KZRNG5 "why"` tells its author.

A person can still run it in their own terminal.
Four parts, in this order, and the order is deliberate.
PartWhat it is for
What the command doesNot what the matcher matched. Somebody who just typed the command knows what they typed; what they did not know is the consequence. git checkout -- <files> reads as checking out and destroys uncommitted work.
What to run insteadThe remedy, with the real paths from the call substituted in and shell-quoted. An agent handed mv <paths> "$D" has to work out what <paths> was; handed the line above, it has something it can run.
Who said soThe rule that fired, by name, and nothing else. It used to add a count of real invocations, and that number came from somewhere other than this repo, so it went.
What a person can doA six-character code, then the two commands that use it: harbor allow-once lets this one call run, and harbor dispute tells the rule’s author it was wrong. Both are for the person; the hook refuses them to the agent. Last, that a person can still run the command in their own terminal.

The remedy is above everything else on purpose. A model acts on the first actionable line it finds, so the replacement command comes before any way past the rule. The way past is written for the person and goes last, and the agent cannot take it: the hook refuses an agent’s own call to harbor allow-once or harbor dispute. The refusal does not name the switch that turns refusals off on a machine. The agent is the one reading it, so harbor doctor tells the person instead.

Paths from the call are substituted into the remedy and each one is shell-quoted, which is a safety property rather than a nicety: those strings came off a command line an agent composed, and interpolating them raw would let a path turn a remedy we wrote into a command we did not, in the one message an agent is most likely to run verbatim. A remedy template with nothing to fill it renders nothing at all, because a line that looks runnable and fails is worse than no line.

A refused MCP tool call gets the same message. The ready-made MCP guardrails carry no remedy, so theirs says what the tool does and who stopped it, with nothing to run instead. If your team has a sanctioned route, write it on the rule as the remedy.

Every path through the hook fails open. An error inside it is a silent allow, never a blocked tool call. A check that has not decided within 4 seconds lets the call go ahead and prints one line on stderr: Harbor’s guardrail check on this tool call did not finish within 4 s, so the call went ahead without it. Run harbor doctor to see why. The process exits zero, refusals included, with one exception: Devin refuses on exit code 2, so under Devin a refusal exits 2 with the reason on stderr. A guardrail that could wedge a session would be removed within a week, and then there would be no guardrails.

Hook examples for Claude Code, Codex, Cursor and Copilot

You do not write a hook to get these. harbor init registers one script, session-toolcontext.mjs in ~/.harbor/bin, on each agent’s pre-tool event, and every guardrail on this page runs inside it. Turning a guardrail on changes what that script refuses; it never touches the agent’s config again.

Which hook it runs on, per agent

Where harbor init registers the guardrail check, and how a refusal reaches the agent.
AgentRegistered asTool calls it seesHow a refusal goes back
Claude CodePreToolUse in ~/.claude/settings.jsonBash, Write, Edit, MultiEdit, NotebookEdit and every mcp__ toolpermissionDecision deny, with the refusal in permissionDecisionReason; ask, for a held call
CodexPreToolUse in ~/.codex/hooks.json. Codex runs it once trusted, and harbor init stamps the trust in ~/.codex/config.tomlEvery tool callThe same answer as Claude Code. A held call is refused
CursorpreToolUse in ~/.cursor/hooks.json, as three entriesShell, Write and every MCP: callpermission deny, with the refusal in user_message
GitHub CopilotPreToolUse in ~/.copilot/hooks/harbor.json, or under $COPILOT_HOMEBash, Write, Edit, MultiEdit, NotebookEdit, write_bash, write_powershell and every MCP toolThe same answer as Claude Code
DevinClaude Code’s PreToolUse entry, which Devin runsexec, edit, write, read and grepExit code 2, with the refusal on stderr

In Claude Code, this is the entry harbor init writes, with your own home directory in the path:

~/.claude/settings.json
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash|Write|Edit|MultiEdit|NotebookEdit|mcp__.*|exec|edit|write|read|grep",
        "hooks": [
          {
            "type": "command",
            "command": "/Users/you/.harbor/bin/session-toolcontext.mjs",
            "timeout": 5
          }
        ]
      }
    ]
  }
}

The lowercase names at the end are Devin’s, which runs the same file; Claude Code has no tools by those names. When a guardrail stops a call, the script answers on stdout and exits zero. This is the answer Claude Code, Codex and Copilot get for git push --force origin main, shown with line breaks added:

json
{
  "hookSpecificOutput": {
    "hookEventName": "PreToolUse",
    "permissionDecision": "deny",
    "permissionDecisionReason": "This overwrites the remote branch, and commits other people pushed to it are dropped.\n\nBlocked by the git-push-force guardrail.\n\nCode FW28VD. If this one call should run, ask the person to type `harbor allow-once FW28VD --because \"why\"` in their own terminal, then try again. If the rule is wrong, `harbor dispute FW28VD \"why\"` tells its author.\n\nA person can still run it in their own terminal."
  }
}

Block git push --force

On Guardrails, open the Git shelf of the library and press Add on git push --force. It blocks for everyone in the workspace, in every agent above, from each machine’s next refresh. The built-in matches git push with any of --force, -f or --force-with-lease, including --force-with-lease=main and a push wrapped in bash -c. It does not catch a force push written as a refspec, git push origin +main.

On the next session each developer reads Your team now stops `git push --force` in this repo. To see what the agent will be told, ask from inside the repo:

harbor check "git push --force origin main"~/acme-api
git-push-force (block): it is refused.

The agent would read:

    This overwrites the remote branch, and commits other people pushed to it are dropped.

    Blocked by the git-push-force guardrail.

    A person can still run it in their own terminal.

A real refusal also carries the code, here FW28VD, and the two lines a person uses it with. The library has no remedy for a force push, because no single safer command is right in every repo; if your team has one, write it on the rule.

Stop rm -rf from deleting source files

rm -r is on the Files shelf. It does not refuse every recursive delete, because most of them are harmless: it refuses when a target is source code, your home directory or an agent’s config, or a path it cannot read. The worst target in the call decides.

  • Let through, checked first: a path with a build or scratch segment anywhere in it, even inside a source tree. The segments are dist, build, out, release, coverage, node_modules, .next, .turbo, .vite, hooks, target, runs, .venv, __pycache__, tmp, temp, scratch, .scratch, scratchpad, .cache, fixture-tmp and fixtures-tmp. Also any path under /var/folders/ or with scratchpad anywhere in it, and any file ending .tgz, .tar.gz, .log, .pid, .map or .tsbuildinfo.
  • Refused: a path that starts with ~, uses $HOME, or goes through a .harbor, .claude, .codex, .cursor or .alef0 directory; a $VAR the command does not set itself; and a path with a segment named src, app, packages, prisma, docs, tasks, components, routes, migrations, __tests__ or scripts.
  • Let through, because it matches neither: any other path, such as lib, README.md or . itself. If your source lives under another name, this rule does not see it. A rule of your own on rm with -r and no path condition refuses every recursive delete instead.
  • Not matched at all: the long flag, rm --recursive, because the rule names -r. Combined flags match: -rf, -fr and -r -f all count, and so do sudo rm, /bin/rm and xargs rm.

Its refusal is the one in what a refusal says: the reason, then a mv into a temp directory with the real paths filled in, so the agent still has a way to clear the tree that can be undone. An agent stopped here could still open each file and save it empty, which is why the file tools reach the same hook (where it watches).

Block a database reset

The library’s database guardrails are pg_restore -d on the Databases shelf, docker compose down -v and docker volume rm on Docker, and, for an agent that talks to the database through an MCP server, MCP: execute_sql that writes and Supabase MCP: apply_migration on MCP servers.

prisma migrate reset is built in but not on a shelf. It reaches Suggested for you once your team has asked for it: a rule in your knowledge that names it, a Bash(npx prisma migrate reset:*) deny entry in a repo’s committed .claude/settings.json, or a package script that runs it. Add it there. For another tool’s reset, write your own guardrail: the program, then its subcommand words (below).

Arm one on a single machine

Every built-in can be armed on one machine with no web app at all, by naming its id in ~/.harbor/guardrails.json. This arms all three examples here. A why is optional, and is what the refusal opens with:

~/.harbor/guardrails.json
{
  "rules": [
    { "id": "git-push-force", "mode": "block" },
    { "id": "rm-recursive", "mode": "block" },
    {
      "id": "prisma-migrate-reset",
      "mode": "block",
      "why": "This drops the whole database and rebuilds it from the migrations. Every row in it is lost."
    }
  ]
}
tool call refusednpx prisma migrate reset
This drops the whole database and rebuilds it from the migrations. Every row in it is lost.

Blocked by the prisma-migrate-reset guardrail.

Code MM7HK7. If this one call should run, ask the person to type `harbor allow-once MM7HK7 --because "why"` in their own terminal, then try again. If the rule is wrong, `harbor dispute MM7HK7 "why"` tells its author.

A person can still run it in their own terminal.

A rule armed this way applies in every repo on that machine that Harbor works in, under the same four conditions as a team’s. It refuses with only the words the CLI ships for that id plus your own: rm-recursive brings its reason and its remedy, git-push-force and prisma-migrate-reset bring neither, so without a why their refusal opens at Blocked by. Nobody else is told, and the session line is not printed for it. To stop the agent changing the file, see the sandbox lines in how a rule reaches a machine.

Writing your own, and arming it

Rules are written and armed in the web app, on Guardrails: curating which commands may be refused is administration. The CLI has four commands for the machine side, and none of them turns a rule on:

  • harbor check "<cmd>" says which guardrail stops a command here and what the agent would read. It runs nothing and sends nothing.
  • harbor guardrail test [rule] counts how often each rule would have fired over this machine’s agent history. --share sends the two counts.
  • harbor allow-once <code> --because "<why>" lets the one call a guardrail just refused run once, with your reason on the rule’s record.
  • harbor dispute <code> "<why>" tells a rule’s author it refused something it should not.
  1. Describe the command

    New guardrail first asks what kind of thing it catches: a command, a file write or an MCP tool. A command takes the program, its leading subcommand words and its flags. An MCP tool takes the tool’s name, an optional server and a box for only SQL that writes. Or start from a rule: Enforce this on a rule in Knowledge drafts the matcher from what the rule says and opens the form with it, saying what the draft catches and asking you to check it catches what the rule means before you save. A guardrail drafted from a Space’s rule is filed in that Space.

  2. Give it something to run instead

    Optional, and worth the two minutes. Whatever authors it owes it one check: that the command it names actually exists in the repo it names. An unreal remedy is worse than none, because it spends the one moment the agent is listening on a line that will fail.

  3. Arm it

    A rule you write starts in Observe: the button says Create in Observe. Moving it to Hold or Block is its own action on its own route, not a field on a general edit, because changing what a rule says and changing what it does are separate decisions. It needs knowledge.curate, the same capability that edits and archives a published rule (the route asks for it by name); see spaces and access for who holds it.

Nothing refuses you, and the row remembers why

For one day this product would not let you arm a rule that had never fired. That gate is gone, and the reason it went is the more useful half of this page. There are two ways to write a matcher wrong and they are not symmetric. A rule that is too narrow catches nothing: it refuses nothing, costs a string compare per tool call, and its only real damage is that its author believes they are covered. A rule that is too broad refuses ordinary work, and it banks evidence within minutes precisely because it is too broad. A gate built on fire counts forbade the harmless mistake and waved the dangerous one straight through.

It also could not do the job it claimed. Has this fired was standing in for is this matcher correct, and a rule written thirty seconds ago has no fires however good it is, because no time has passed. The cure for false confidence was always telling the author the truth, and a lock on top of a warning somebody has already read is not a second safeguard.

So what happens instead is a record. Arming a rule stamps who armed it, when, and what this workspace had actually seen the matcher catch at that moment, in the same write that arms it. A rule armed on nothing is allowed, and the row says so afterwards rather than a button saying no beforehand. A count that came from a replay is never overwritten by a smaller live one.

Change what a rule catches and the count is dropped and watermarked, because it described the old predicate and a rule narrowed from one flag to another is a different rule. Rename it, or fix its remedy, and the evidence stays: only changing what it catches invalidates what was observed about it.

How a rule reaches a machine

harbor init fetches the workspace’s rules once and writes them to a file on the machine. After that, while an agent works, the hook that checks each tool call starts a refresh in the background, at most every 30 seconds. The check itself reads only the file. A tool call never waits on a fetch, which has two consequences worth stating separately: an offline laptop keeps enforcing whatever it last heard, and our backend’s latency is never in front of one of your Bash calls. The call in hand is judged by the rules already on disk, and the next one by the refreshed set. A machine where no agent is working fetches nothing.

A guardrail in a Space refuses only in the repos bound to that Space, and the row in the web app carries the Space’s name. One with no Space applies in every repo.

The two files, and which one wins.
FileWritten byWhat it is for
~/.harbor/guardrails.synced.jsoninit, then the hook in the background, whollyThe workspace’s rules. Both rungs travel: an observing rule that never reached a machine would never fire and never accumulate anything to look at.
~/.harbor/guardrails.jsona person, by handThis machine’s own overrides. Merged field by field over the synced set, and it wins, so one line switches one rule off, or arms a built-in on this machine alone.
json
{ "rules": [{ "id": "rm-recursive", "mode": "observe" }] }

The person at the keyboard is holding the consequences of both decisions, so the local file wins. A rule somebody cannot switch off on their own machine is a rule they uninstall the tool over. The override is one field: it does not delete the rule, and the rule keeps counting after it stops refusing.

What a machine sends, and when

Two things on their own, and two more only when a person runs a command. Only the first carries commands, and only the ones a repo’s committed files already name.

What leaves a machine for guardrails.
WhatWhenWhat it holds
What a repo saysharbor daily, at most once a day, only when it changedRead from the repo’s committed files at HEAD: the names of the tools it uses, each package script that runs a command in the library, with any NAME=value in its command masked, and the Bash(…) deny entries in .claude/settings.json. A deny entry is sent as written, beside the command read out of it, and only that command is masked, so a value written inside a deny entry leaves with it. This is what fills Suggested for you.
ObservationsOnly for a guardrail that is on, carried up at the end of an agent’s turnWhich guardrail, the program’s name, how it was launched, the session, the time, the mode it was in, what kind of target it touched and how many, and the repo’s remote URL so it is filed in the right Space. A call a person let through carries the reason they typed. Never the command line, and never its paths. For an MCP call, the tool name as the agent sent it, with the server’s name where the agent gives one, and never its arguments.
A disputeWhen a person runs harbor disputeWhich guardrail, their reason, the mode it was in, the kind of target, and which agent it was.
A shared dry runWhen a person runs harbor guardrail test --shareWhich guardrail, how many calls it would have caught, out of how many, over how many days.

A CLI older than 0.5.14 still records a ready-made guardrail’s matches before anybody turns it on. The server drops every observation for a guardrail the workspace never made, so none of them land; upgrading stops the recording on the machine as well.

Where it watches

Bash calls, the file tools (write, edit, multi-edit and notebook edit), and MCP tool calls. The second arm exists because of an obvious hole in the first one. An agent refused rm -rf packages/backend/src could open those files and save them empty without any guardrail code running at all, and that was true from the day the ladder shipped. The third closes the same kind of hole for MCP servers: before it, the GitHub MCP server’s merge_pull_request went through whatever your team had said about gh pr merge. Every ready-made file or MCP guardrail is off until somebody turns it on, at which point the same two switches apply, unchanged.

An MCP rule names the tool exactly as its server declares it, such as merge_pull_request, and can also name the server. Claude Code and Codex send the call as mcp__server__tool, so a server in the rule is checked there. Copilot sends server-tool from the CLI and mcp_server_tool from VS Code, and the server is checked there too. Cursor sends MCP:tool with no server, so on Cursor the tool name alone decides. The ready-made MCP guardrails name no server, so each one catches its tool on any server. A rule on gh pr merge still covers the shell only: the MCP route needs its own rule.

One argument can be checked: only SQL that writes. It lets a plain SELECT, EXPLAIN or SHOW through and catches the rest. A second statement after a semicolon counts as a write, and so do WITH (it can wrap a DELETE), SELECT … INTO (it creates a table), EXPLAIN ANALYZE (it runs what it explains) and SQL it cannot read. A call with no sql or query argument does not match. The SQL is read on the machine and never sent. This checks what the agent sends. It is not a database permission: a SELECT can still call a function that changes data, and a read-only database role is the real boundary.

On all three arms the counting is gated on capture and the refusing is not, and the difference is the ethics of the feature rather than an implementation detail. A count is a record of what somebody ran, written down, so harbor off --capture stops it: a counter that kept counting through that would be exactly the thing you asked us to stop. A refusal records nothing. Until 0.5.8 one switch governed both, and a developer who paused capture for privacy also switched off the rules their team had set, with no way to find out.

You need a CLI that can refuse

A rule armed in the web app refuses nothing on a machine whose CLI is too old to enforce it, and every caption on this page would still be true while the product was a lie. The floor is harborloop 0.4.3. Earlier published versions carry the wire but match by spelling rather than by command, which means a rule on terraform would miss ./bin/terraform apply: refusing some spellings of a command and not others is not a guardrail, and the page would be describing something you do not have.

Guardrails on MCP tool calls need 0.5.18 or later. An older CLI never registers its hook on an MCP call, so a rule on one refuses nothing there. harbor update installs the latest release and re-runs init, which registers the hook for MCP calls on Claude Code and Cursor. Codex already sends every tool call to it. On Copilot, every guardrail needs 0.6.7, the first release that wires it, and so does Devin, which runs Claude Code’s hooks but names its tools its own way. A repo wired with harbor init --repo needs that command run again, and its .claude/settings.json committed again, to pick up the MCP matcher.

Hold, allow-once and dispute need 0.5.21 or later. An older CLI reads a rule at Hold as observing, so it refuses nothing there.

bash
harbor update

harbor doctor checks the version against what is published and warns when you are behind, naming the command that fixes it. Be precise about what that line does and does not know: it compares you against the latest release, not against a guardrail floor. The honest test is therefore the simple one, which is to be current, and it covers every floor on this page.

Guardrails and measurement are the same instrument pointed in opposite directions: the evidence that arms a rule is the evidence that retires one. To see which of your team’s rules reached an agent and which it said it used, measure whether your agents follow your team’s rules.