Inside the policy engine: how Enforgate decides every tool call
The Enforgate Team ·
Every tool call that reaches Enforgate — through POST /v1/check or the MCP proxy — runs through the same function: evaluate(call, policies) in @enforgate/policy. It's a small, deliberately boring piece of code, and that's the point: the part of the system that decides whether an agent gets to do something should be easy to read top to bottom.
Policies, rules, and first-match-wins
An API key has one policy. A policy is an ordered list of rules plus a default decision. When a call comes in, Enforgate sorts a key's enabled policies by priority (highest first) and walks each policy's rules in array order. The first rule whose tool glob and conditions match wins — there's no rule scoring, no "most specific wins" heuristic to second-guess. Order is the mechanism, which means you can read a policy top-to-bottom and know exactly what it does.
[
{ "tool": "delete_*", "action": "deny", "reason": "never delete in prod" },
{ "tool": "send_*", "action": "require_approval", "reason": "human reviews outgoing mail",
"where": [{ "path": "to", "matches": "^(?!.*@enforgate\\.com$)" }] },
{ "tool": "*", "action": "allow" }
]tool is a glob over the tool name (* and ?), matched against the exposed name once it's split from its upstream prefix (<upstream>__<tool>). whereadds parameter conditions: a dot path into the call's args, checked with a regex (matches / notMatches) or a deep equals. Conditions in a rule are AND'ed together.
Failing closed, on purpose, at every layer
Three separate fallbacks all point the same direction:
- If a rule's regex is invalid, it simply never matches — a malformed rule can't accidentally become an allow-everything rule.
- If no rule in a policy matches, the policy's own
defaultDecisionapplies, when one is set. - If nothing decides — no matching rule, no default, or no policy at all — the engine default-denies. There is no implicit allow anywhere in the path.
The same philosophy shows up one layer up, in the audit write: if the database write that records a verdict fails, the gateway returns a 500 instead of letting the call through unrecorded. An allow that never gets logged is treated as a bug, not a degraded mode.
What never gets evaluated against the args themselves
ToolCallContext.args only ever exists in memory for the duration of evaluate(). What lands in the database is argsHash, a SHA-256 over the canonicalized arguments — enough to prove a specific call happened and correlate repeats, never enough to reconstruct what was actually sent. The policy engine sees the real arguments to decide; the audit log only ever sees their fingerprint.
Try a policy without wiring up an agent
The playground runs this exact evaluate() function in-process against your real policies, so you can see a verdict, the matched rule, and the latency before pointing a live agent at the gateway. See writing policies for the full rule reference.