Enforcement and approvals

The collector can decide a tool call before it runs, not just record it afterwards. This page is the general picture: where enforcement happens, what a policy can decide, what happens when there is no policy to consult, and how a call that needs a person is handled.

If you are here for Claude Code specifically — installing the hooks, the install key, the per-call flow, managed rollout — that page covers it and this one does not repeat it.

Where enforcement happens

Policy is consulted at the MCP proxy and at the pre-tool hook of every runtime Trefur hooks — Claude Code, Codex, Gemini CLI, OpenHands, Cursor and Copilot. They all reach the same decision path, so a policy means the same thing wherever it is consulted. Each hook has two forms of it: the running collector answers from live policy, and with no collector running the one-shot hook decides from the signed policy on disk.

The collector's other inputs — the LLM proxy, the exec wrapper, the OTel receivers — observe and do not enforce.

Separately from policy, several inputs can block on a list you write in the collector's own configuration: the HTTP and WebSocket proxies on a URL blocklist, the DNS proxy on a domain list, the browser proxy on a navigation list, and the shell hook on a command pattern list. Those are local, static and operator-authored. They are not policy — nothing published from the platform reaches them, they carry no signature, they produce no policy reason code, there is no shadow mode and no approval path, and none of the rest of this page applies to them.

Turning it on

Enforcement is off by default. The collector is observe-only unless the configuration explicitly enables it, and it needs two more things before it will decide anything: a trusted public key to verify policy with, and the MCP proxy input. Without a usable key, enforcement stays off rather than falling back to deciding on unverified input.

The MCP proxy input matters even if you only care about the hooks: the running collector wires hook enforcement alongside the proxy, so with the proxy input off the daemon will not decide at a hook either. The one-shot hook is the exception and needs only enforcement.enabled and a usable policy on disk.

enforcement:
  enabled: true                      # off by default
  public_key: "<base64 Ed25519 key>" # required; no key means no enforcement
  # public_keys: ["<older key>"]     # accept both during a key rotation
  # hook_fail_mode: closed           # closed (default) | open
  # credential_max_age_seconds: 3600 # 0 disables the age check
  # hook_bundle_max_age_seconds: 86400
  # interval_seconds: 60
  # revocation_check_seconds: 10
  # report_decisions: true
  # schema_pin_gate: false           # block tools whose definition drifted
  # run_state_gate: false            # supply the caller's quarantine state
inputs:
  mcp_proxy:
    enabled: true                    # enforcement needs this input
KeyDefaultMeaning
enforcement.enabledfalseMaster switch. Off means observe-only.
enforcement.public_keynoneThe key policy must verify against. Required.
enforcement.public_keysemptyAdditional accepted keys, for rotating the signing key without a flag day.
enforcement.hook_fail_modeclosedWhat the one-shot hook does with no daemon and no usable policy. Only the literal open opens the gate, so a typo fails closed.
enforcement.credential_max_age_seconds3600How long a credential may go without a live confirmation before the fail mode decides. 0 disables the age check; it does not disable the platform's own verdicts.
enforcement.hook_bundle_max_age_seconds86400How old the policy on disk may be before the one-shot hook treats it as absent.
enforcement.interval_seconds60How often the collector refreshes policy.
enforcement.revocation_check_seconds10How often it checks whether enforcement has been withdrawn.
enforcement.report_decisionstrue when enabledReport enforced blocks to the platform. Set false to enforce without reporting.
enforcement.schema_pin_gatefalseBlock a tool whose advertised definition drifted from the one that was approved. See below.
enforcement.run_state_gatefalseSupply the calling agent's quarantine state with every decision, so a rule can match on it. See below.
enforcement.cache_path~/.trefur-collector/policy-bundle.jsonWhere verified policy is cached, written 0600.

How policy reaches the machine

The collector pulls policy from the platform, checks its signature against your configured key, checks that it is addressed to your workspace, and checks that its version strictly advances what it already has. Only then is it installed and written to the cache. The cached copy is re-verified every time it is read — it is a file in a home directory, and being on disk earns it no trust.

When the platform answers that there is nothing to enforce for you, enforcement is cleared: the cached copy is deleted and the collector goes degraded. A network failure or a server error is the opposite — the last good policy is kept, because an outage is not an instruction.

Authoring and publishing policy is done in the platform. trefur policy validate and trefur policy compile check and compile a policy file locally, and trefur policy show prints the local offline policy used by trefur run. None of them uploads anything.

What a policy can decide

Three outcomes:

  • Allow — the call runs.
  • Deny — the call is refused with a reason code.
  • Transform — the call runs with fields redacted, or carries an obligation such as needing a person's approval.

A rule can match on:

  • the tool name;
  • the MCP server the tool belongs to;
  • whether the calling agent is quarantined — only with enforcement.run_state_gate turned on, which is what makes the collector carry that state into a decision.

That is the whole of it today, and it is less than the policy language offers. The engine also understands an action class (reading, reversible write, irreversible write, execution, external communication, financial), a recipient kind and domain, data classes, the calling principal's assurance level, whether a run was flagged for injection, and the database table a query touches. Rules written on those fields validate, compile and ship inside a signed policy — and then never match, because the collector does not populate any of them at decision time. They are vocabulary the engine has and the collector does not yet feed. We name them because you will meet them in the authoring surface; do not build a control on one.

A policy also cannot match on the call's arguments or on file paths. Arguments are used for two things only: computing the fingerprint that binds an approval to one exact call, and applying a redaction the policy asked for. If you need a rule about a particular path, express it as a tool or an MCP server, not as an argument pattern.

A policy can be published in shadow mode: it evaluates, records what it would have blocked, and allows the call. That is how you find out what a rule costs before it costs it.

The reason a caller sees

A denial carries one of a fixed set of reason codes and nothing else — no policy id, no version, no explanation of the rule. Four are chosen by the collector itself:

  • policy.blocked — a rule denied the call, or a degraded decision failed closed.
  • policy.approval_required — the call needs a person; see below.
  • policy.schema_drift — the schema-pin gate refused the tool.
  • policy.transform_applied — on an allow that carried an obligation, not on a denial.

Four more exist in the policy language and a rule author can attach any of them as a label: policy.data_egress_blocked, policy.recipient_not_allowed, policy.injection_guard and policy.assurance_insufficient. Read them with the limitation above in mind. The conditions they name cannot match today, so a denial carrying one of those labels was in fact decided on the tool name or the server — the label says more than the rule did.

Through the MCP proxy a refusal comes back as a JSON-RPC error carrying the message and the reason code, so an agent gets a real error rather than a hang or a malformed response.

Two optional gates

Both are off unless you turn them on, and both need enforcement.enabled and the MCP proxy input.

schema_pin_gate

A tool's definition is not fixed: the server that serves it can change what it claims to do between one listing and the next. With this gate on, the MCP proxy fingerprints the tools a server advertises each time it lists them, and a call to a tool whose definition drifted from the one that was approved is blocked with policy.schema_drift — before policy is consulted at all. Only positively-known drift blocks: a tool that is approved, or that the platform has no pin for, goes through. The gate runs at the MCP proxy only; there is no equivalent at a runtime hook.

run_state_gate

The collector keeps a current list of the agents your workspace has quarantined and supplies the calling agent's quarantine state with every decision, so a rule can match on it. It decides nothing by itself — a policy still has to carry the condition. Two things are worth knowing: an agent the collector has no state for matches as unknown and is not stopped, and a quarantine it has already seen is held through a gap in the feed rather than forgotten. Quarantine is the only run-state field the collector fills in; turning this on does not make a rule about a run being flagged for injection work.

When there is no policy to consult

The two enforcement points answer this differently, because their situations differ.

  • The running collector goes degraded and decides by action class: irreversible writes, execution, external communication and financial actions fail closed; reads, reversible writes and calls it cannot classify fail open. The outcome is recorded as degraded either way, so the gap is visible rather than silent. This is the one path where the class is worked out at all, and it is worked out from the tool's name — a tool whose name does not say what it does is unclassified, and unclassified fails open.
  • The one-shot hook, with no daemon and no usable policy on disk, follows hook_fail_mode closed by default. Unsigned, tampered, wrong-workspace or stale policy is refused the same way as no policy at all.

A platform verdict outranks all of this. If the workspace has revoked this install's credential or engaged the install kill switch, the call is denied regardless of fail mode and regardless of what the policy says — see Claude Code for how that reaches the machine.

When a policy requires a person

The mechanics for Claude Code are on its own page: the call is denied at once with a request id, the request appears on the dashboard's Fleet page, and retrying the same call after approval runs it once. The same thing happens at the MCP proxy, using the calling agent's key where one is presented and the install's key otherwise. What is worth knowing wherever the call came from:

  • The arguments never leave the machine. What is sent is a fingerprint, the server and tool names, a hash of the arguments, and one human-readable target — the path or host a person needs in order to decide. For a Claude Code call the working directory is part of what is fingerprinted, because it is part of what a person is approving.
  • A person decides, never a credential. Approving or denying requires a signed-in user; every API key class is refused, including the requesting agent's own.
  • Requesting again does not get a fresh card. A request is idempotent on its fingerprint, and a request a person already denied comes back as denied for the rest of its validity.
  • An approval is consumed once. The second attempt to use it is refused, and the collector records a new request.
  • Requests expire — an hour by default, and a workspace can set anything from a minute to a day. An approval nobody used expires with its request.
  • There is a cap on pending requests per calling principal. An agent in a retry loop cannot bury your approvals queue; past the cap, further requests are refused until some are decided.
  • Nothing blocks on the network. The decision path answers immediately; a request that has not been recorded yet comes back as pending with a message saying to retry.

Approvals are reviewed on the dashboard's Fleet page, where anything waiting appears at the top of the page with Approve and Deny beside it. You can decide them one at a time or in bulk. There is no separate Approvals page.

Limits

  • Policy is enforced at the MCP proxy and at the runtime pre-tool hooks. Anything an agent does outside those paths is observed at best, or stopped by a blocklist you wrote yourself.
  • A rule can match on the tool, the MCP server, and — with its gate on — whether the agent is quarantined. Everything else in the policy language compiles and never fires.
  • Policies cannot match on call arguments or file paths.
  • Blocks decided at a runtime hook are enforced locally but are not reported back to the platform the way an MCP proxy block is. The schema-pin gate likewise runs only at the proxy.
  • There is no "require approval" verdict as such — approval is an obligation attached to an allow, which the collector turns into a deferred denial.
  • The one-shot hook cannot record an approval request on its own. Without the collector running, a call needing approval is denied and the message says to start it.
  • A denial tells the caller a reason code, never which rule fired. That detail is in the platform, for people, not in the agent's error message.
  • Policy is authored and published in the platform. The CLI validates and compiles locally; it does not upload, pull, or explain a published policy.
  • Clearing enforcement does not deny everything — it degrades to the per-class behaviour above. A deny-everything switch is the install kill switch, which is a different control.