Back to blog

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.

example policy rules
[
  { "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 defaultDecision applies, 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.