Prove

The commands your agents may never run.

You name the commands your agents may never run, and before you arm one Harbor tells you whether your own machines have ever run that command and how much ordinary work the rule would refuse. A refusal reaches the agent with something to run instead.

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 watches what your agents actually run, in the repos you have bound, and it can therefore tell you two things no list can tell itself: whether any machine of yours has ever run the command you are about to prohibit, and roughly how much ordinary work prohibiting it would refuse. You still decide. You decide with the numbers in front of you.

Two rungs, and there is no third

A rule observes or it 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.

There is no warn rung and no ask rung, and their absence is a decision with a date on it rather than a roadmap gap. The event a guardrail runs on is the pre-tool-use hook, and it has no dependable non-blocking channel on the hosts Harbor supports: Claude’s schema carries a permission decision and a reason, Codex rejects anything but a denial with the unsupported value in the error string, and Cursor has no third answer at all. A rung that works on one host out of three lies on the other two, which is the failure this whole ladder exists to avoid. The value is still in the database enum, unreachable, waiting for a channel that works everywhere.

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. prisma, /usr/bin/prisma and ./node_modules/.bin/prisma 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

Three switches, and all three are on the failing-safe side of their default. Nothing here can stop a command that somebody did not explicitly arm, on a machine that has not explicitly opted in.

A refusal needs all three. Any one of them missing means the command runs.
What has to be trueSet whereWhy it is the default it is
The rule is at blockSomebody armed this rule, in the web appThe default is the absence of the field, checked by equality. A typo, the wrong case, or a value from a bundle we did not write all land on observe.
toolGuard is true on the machine"toolGuard": trueIn ~/.harbor/synced-origins.json. Opt-in, machine-wide, and its default is the key not being there. The person holding the consequences of a refused command is the one who authorises refusals at all.
This repo is not paused for captureNothing to set: harbor onOn unless somebody paused this repo. Recording and watching are the same pass, so harbor off --capture stops the refusing along with the counting, and doctor and status both render a paused repo as paused, on purpose.

Watching needs only the third. A machine that never opted into refusals still counts what the rules would have caught, because gating the counter on the switch that authorises stopping people would leave every workspace with an empty ledger and no way to earn one.

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, refusing one path:

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. Turn it off with `toolGuard: false` in ~/.harbor/synced-origins.json, or run the command yourself outside the agent.
Three 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 so, and how to stop itThe rule that fired, the number of real invocations behind it when there is one, and the switch that turns enforcement off on this machine.

The remedy is above the attribution on purpose. A model acts on the first actionable line it finds, and a refusal that names the off switch before it names the replacement command has taught the agent that the way past a guardrail is to disarm the guardrail. Naming the switch is still right, because a rule whose off switch nobody can find is a rule the team removes wholesale. It just goes last.

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.

Every path through the hook fails open. An error inside it is a silent allow, never a blocked tool call, and the process exits zero on every branch. A guardrail that could wedge a session would be removed within a week, and then there would be no guardrails.

Arming a rule, and what the form tells you first

Rules are written and armed in the web app, on Guardrails. There is no harbor guardrail command: curating which commands may be refused is administration, and the machine’s only contribution is the opt-in described in the next section.

  1. Describe the command, and read the sentence under the box

    As you type a matcher, the form compares it against what your machines have described running and answers in one of three ways. All three are the product’s own words:

    • This catches docker exec, but no machine that reports here has ever run docker. Worth a second look at that box, though it is a fine thing to arm if you meant it.
    • The inventory says this would have caught up to N commands these machines actually ran. Enforcing it refuses all of them.
    • No machine here has described what it runs, so there is nothing to check it against. Which is the honest answer when nobody has opted in, rather than a claim that the command has never been run.
  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

    Promotion 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 the curator capability, the same one that approves knowledge; 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 sync and the background daemon write the workspace’s rules to a file on the machine. The hook reads that file. Nothing on the pre-tool-use path ever fetches anything, 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 two files, and which one wins.
FileWritten byWhat it is for
~/.harbor/guardrails.synced.jsonsync and the daemon tick, 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.
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.

Command shapes, and the switch that is off

The sentence under the authoring box has to come from somewhere. Machines can describe the commands they have run to the workspace, as an inventory of shapes rather than a history, and that is off unless you turn it on. The default is the absence of the key.

json
{ "shareShapes": true }

In ~/.harbor/synced-origins.json, per machine. The daemon then uploads on its own schedule, or harbor sync --shapes does the walk now, which is the flag to use if you do not run the daemon. A sync that uploaded something says so: N command shapes from M sessions described to the workspace. A sync on a machine that never opted in says nothing at all, because a line on every run explaining a feature you have declined is an advertisement rather than a receipt.

What a shape contains, and what cannot travel

A shape is the vocabulary of a command and its arithmetic: the program name, the leading subcommand words that are common enough to be vocabulary at all, flag names with their values already stripped, a histogram of how many operands followed, and two counts.

No path, no filename, no argument value. An operand that looks like a path is replaced by <path> and a rare or unique token by <arg>, and the rest of that command’s tail is dropped rather than described. This is asserted twice: once on your machine before a payload exists, where it throws rather than send, and again by the schema on arrival, because an assertion in our client defends against a bug in our own redactor and not at all against a client that skipped it.

Two switches, opposite in what they risk and identical in their default. toolGuard is opt-in because it can refuse a developer’s command. shareShapes is opt-in because an inventory of what somebody runs is a profile of how they work. Neither is on until a person turns it on, and the web app says plainly when nobody has.

Where it watches

Bash calls, and the file tools: write, edit, multi-edit and notebook edit. 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. Every shipped file candidate is watching; nothing in that family refuses until somebody arms it, at which point the same three switches apply, unchanged.

Both arms are gated on capture rather than on recall, and the difference is the ethics of the feature rather than an implementation detail. Everything else Harbor’s hooks do is recall: the graph answering a question. This is the opposite direction, a record of what somebody ran, written down. So harbor off --capture stops it, and a counter that kept counting through that would be exactly the thing you asked us to stop.

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 prisma would miss ./node_modules/.bin/prisma migrate reset: refusing some spellings of a command and not others is not a guardrail, and the page would be describing something you do not have.

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. Today those are the same version. The honest test is therefore the simple one, which is to be current.

Guardrails and measurement are the same instrument pointed in opposite directions: the evidence that arms a rule is the evidence that retires one. What your agents were served, and what they actually used, is served vs cited.