Brainy Brainy
Docs Brainy

The policy door

In this section

A subagent does not go rogue because it is malicious. It goes rogue because it lost the context it was handed: the rules were prose in a prompt, the window flooded, the prompt was compacted, and what is left is a capable model with no restrictions and a tool belt. Everything downstream of that — the deleted directory, the publish nobody approved, the public repo — is a consequence of one design mistake: the rules lived in the agent.

The policy door moves them into the store. A rule is a ROW: durable, generation-pinned, bound to an actor, a scope and a list of doors, carrying the authority that let it exist and the one sentence a refused caller is shown. Every door evaluates the rows before it decides anything. An agent that never read a rule still meets it. An agent whose window flooded loses nothing that binds it.

The shape of a rule

{
  "id": "only-the-source-registry",
  "subject": "*",
  "scope": { "pathPrefix": "/projects/brainy" },
  "doors": ["Bash", "add", "github_*"],
  "effect": "refuse",
  "reason": "Packages publish only to The Source, never npmjs.",
  "authority": "record:PLATFORM-RENAME-TRAIN-FLEET",
  "locked": true,
  "since": "2026-09-15",
  "until": null,
  "match": [
    { "field": "command", "matches": "npm publish" },
    { "field": "command", "notMatches": "--registry[= ]https://source\\.soulcraft\\.com" }
  ]
}JSON

field

what it is

why it is a field and not a convention

id

a slug

it is quoted verbatim in the refusal, so it must read as a reason

subject

an actor slug, role:<name>, either negated (!role:steward), or *

an actor is whoever the door AUTHENTICATED — never a name the request supplied about itself

scope

pathPrefix · world · store

absent axes are unscoped; a present axis must match or the rule stands aside

doors

door names, tool names, globs (github_*), or *

a rule may govern tools brainy does not serve — see below

effect

allow · refuse · ask

ask files a decision and refuses until it is ruled

reason

one sentence

a rule with no reason is a wall; a rule with a reason is an instruction

authority

mandate:<id> · decision:<id> · record:<id>

no agent grants itself a rule — a rule with no authority cannot be declared

locked

whether it moves under a lesser authority

a ratified rule is locked by default

since / until

calendar days

until is how a rule expires with nobody having to rule on it again

match

clauses over the call's own arguments (matches · notMatches · equals · prefix · anyOf · absent · isActor), ALL of which must hold

it is what lets a rule say "not gh repo create --public" rather than only "not the shell"

There is no lookaround in a pattern: the engine's regular expressions are linear-time by construction, which is a property worth keeping in a gate that runs on every write. "This, but not that" is therefore two clauses — matches and notMatches — which is also the form a person reading the rule can check. A pattern carrying (?!…) is refused by name, saying so.

Rules are immutable rows. A rule that must change is retired (a retirement row that names it) and declared again — two audited writes — so the question "what bound whom on the day of the incident" stays answerable.

The verdict, and the precedence law

policy.check({ actor, door, args, scope }) answers { verdict: 'allow' | 'refuse' | 'ask', rule?, reason, skipped[], digest }. It is pure, deterministic, and implemented ONCE — in Rust, in native/src/policy/evaluate.rs. The in-process API, brainy serve, the MCP door and the harness hook all reach that one function through the crossing profileWriteDoor already is, which is why they cannot drift.

  1. The most restrictive matching rule winsrefuse beats ask beats allow. Not "the most specific wins": specificity decides which rule is NAMED, never whether the call proceeds. A narrow allow must not punch a hole in a wide refuse, because the actor most motivated to declare that narrow rule is the one the wide rule binds.

  2. No matching rule is allow — stated loudly, because it is a decision. This engine is not a whitelist: a store with no rules behaves exactly as it did before this profile existed. The refusal a fresh agent meets comes from a rule that binds *.

  3. A rule that needs a name the door does not have STANDS ASIDE, counted. That is the isActor clause — "the author is not the caller" — and the subject forms that name an actor. Learned from the Self's own 0.6.366 incident (2026-09-14, rolled back in seven minutes): a rule whose subject names a specific actor cannot be evaluated at a door that knows only an account. It is skipped, NAMED in skipped[], and never silently dropped. The failure mode being avoided is not "too permissive" — it is "every write refused, for every party, because one substitution was unknowable at that door".

  4. A refusal on a READ door is a refusal, never an empty answer. An empty answer reads as "this brain knows nothing", which is a different fact.

Where it is enforced: inside the door

policy.check is called INSIDE add, relate, update, remove, the memory writes, the accords writes and vfs.writeFile — before the plan is decided, so a caller cannot skip it by not calling it. The actor is whoever the door already knows: the minted key's subject on serve/MCP, the caller the in-process API carries (the same field memory.turn() refuses without).

Every refuse and every ask writes an audit row — actor, door, rule, generation, and the call's DIGEST rather than its arguments, because an audit trail read by whoever is investigating an agent must not become a second copy of what that agent was writing. policy.audit({ since, actor }) reads them. Audit rows, rule rows, retirement rows and ratification rows all carry visibility: internal and are fenced from ordinary recall exactly as board rows are.

Rules over tools brainy does not serve

A rule's doors may name ANY tool the agent might call — Bash, web_search, github_*, an MCP tool from another server. Brainy enforces its own doors inside the engine. For foreign tools there are two adapters, and the evaluation is the same function in both.

(a) The harness hook — available now

brainy policy check --actor <slug> --tool <name> --args-json -   # stdinBASH

Exit 0 = allowed. Exit 2 = refused, with refused by rule <id>: <reason> on stderr. A coding-agent harness with a pre-tool hook blocks the tool call on exit 2 and shows the agent stderr — which is the whole mechanism: the agent is told the rule at the moment it matters, by something it cannot forget to consult.

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "*",
        "hooks": [
          {
            "type": "command",
            "command": "brainy policy check --brain $BRAINY_STORE --actor $AGENT_SLUG --tool \"$TOOL_NAME\" --args-json -"
          }
        ]
      }
    ]
  }
}JSON

Other harnesses have the same shape: a pre-tool command, stdin carrying the arguments, a non-zero exit that blocks — name the actor/tool environment variables however your own harness's hook convention supplies them.

(b) The gateway — designed here, built later

brainy serve fronts the client's OTHER MCP servers: the client connects to brainy, brainy holds the upstream connections, every tools/call is checked against the same rules, an allowed call is forwarded verbatim and a refused one comes back as a named refusal (never a protocol error). The hook governs one machine's harness; the gateway governs every client of one store, including harnesses with no hook mechanism at all. The design constraint that decides it: the gateway must forward BYTE-IDENTICAL arguments and must never see a credential it does not need — the upstream server's own auth stays between the client and that server wherever the transport allows it.

(c) The mandate slice — always

memory.context() returns a mandate slice FIRST, never budget-trimmed: the rules that bind this caller, by actor and scope. The door enforces them anyway. The slice exists so the agent is TOLD, every turn, what it is bound by — an agent that knows the rule does not spend a turn discovering it by refusal.

Who may make a rule: two tiers

TIER 1 — tighten-only, no approval. Any agent may declare rules that only RESTRICT (refuse or ask) and only bind ITSELF and the subagents it filed an action for. Effective immediately, audited, retireable by the declarer or the owner. A tier-1 rule can never loosen, override or lock anything — declare refuses each of those by name, pointing at propose.

TIER 2 — everything else needs the owner. An allow rule, a rule on another agent, a lock, an unlock, retiring a rule the agent did not declare, a rule outside its own scope: policy.propose writes an INERT pending row and files a decision on the owner's board (adopt · adopt with edits · refuse — a picker with exactly one recommendation, rung by the doorbell). The rule activates only when policy.ratify is called under the owner's own credential — a key whose scope includes owner, minted only by a human sign-in. Never by author slug: every session today shares one tenant key, and a slug can be typed by anyone.

Rules the owner ratifies are locked by default. Unlock is a further owner ruling that names the rule (policy.unlock, a new row, audited). until expires a rule with no ruling at all. And there is one kill switch: policy.freeze — an owner ruling that suspends every tier-1 rule at once, its audit row naming each rule it suspended.

The doors

door

what it does

who

policy_check

evaluate one call; audit a refusal or an ask

anyone

policy_declare

declare a tier-1 rule — tighten-only, binds the declarer and its own subagents

any agent

policy_propose

write an inert rule and file the owner's decision

any agent

policy_ratify

activate a pending rule under the owner's ruling

owner credential

policy_retire

retire a rule (a retirement row)

the declarer, or the owner

policy_unlock

lift a lock, naming the ruling

owner credential

policy_freeze

suspend every tier-1 rule, naming each

owner credential

policy_audit

read the refusals and asks

anyone

policy_binding

read the rules that bind one actor, matched on subject exactly as policy_check resolves it

anyone

policy_rules

read every rule in force, optionally narrowed to a scope

anyone

What this is not

It is not a sandbox and it does not contain a process. An agent with a shell and no hook can run anything the operating system lets it run; what the policy door guarantees is that every door brainy serves, and every tool routed through an adapter, is checked against rules the agent cannot forget, edit or outrun — and that every refusal leaves a row saying what was stopped, for whom, and under whose authority.