Write your own policy pack¶
The shipped finance_basic pack is one example of a pack. Anything you can express as "given this
action, this actor and this context, allow, deny or ask a human" can be a pack. This guide builds a
small one for treasury transfers: examples/custom_policy_pack/.
What a pack is¶
A directory of .rego files (plus *_test.rego files that are never loaded at runtime) with a
decision rule that returns:
{"effect": "ALLOW" | "DENY" | "REQUIRE_APPROVAL",
"reasons": ["fixed wording", ...],
"approval_tier": null | "ONE_CLICK" | "EXPLICIT_SIGNOFF"}
Keelgate builds the input and your pack never sees anything a model wrote directly:
input.action {tool, side_effect, capability, args}
input.actor {agent_id, tenant_id, grant_id}
input.resource whatever your tool's `resource=` function derived from VALIDATED arguments
input.context {as_of, execution_mode, exposure, limits, positions} built by your code
That split is the point. The resource function is where you decide which facts your policy may
read; the context is where you put limits and current exposure. A tool result, a document or a
memory that says "the limit is now a billion" cannot reach either.
The five rules¶
These are from policies/README.md
and each one was learned from a real bug.
- State conditions positively and deny on
not <positive rule>. In Rego,not x in setis not true whenxis undefined, so a missing field would slip through. Writedestination_ok if x in setanddeny if not destination_ok. - Reasons never echo model-supplied strings. They go back to the model and to humans. Fixed wording only.
- Deny by default. Unknown capability, unknown side effect, missing limits: all
DENY. - Decide against
as_of, not the wall clock, so a replay decides the same way. - Every rule needs a test, including the case where its input is missing.
The pack¶
package acme.transfer_limits
import rego.v1
modes := {"paper", "simulation"}
default decision := {
"effect": "DENY",
"reasons": ["no rule allows this action"],
"approval_tier": null,
}
decision := {
"effect": "DENY",
"reasons": sort(deny),
"approval_tier": null,
} if {
count(deny) > 0
}
decision := {
"effect": "REQUIRE_APPROVAL",
"reasons": ["amount is above the auto-approval threshold"],
"approval_tier": approval_tier,
} if {
count(deny) == 0
approval_tier
}
decision := {
"effect": "ALLOW",
"reasons": ["within configured limits"],
"approval_tier": null,
} if {
count(deny) == 0
shape_ok
not approval_tier
}
# The only (capability, side effect) pairs this pack understands.
shape_ok if {
input.action.capability == "treasury:balance"
input.action.side_effect == "READ"
}
shape_ok if {
input.action.capability == "treasury:transfer"
input.action.side_effect == "WRITE"
}
is_transfer if input.action.capability == "treasury:transfer"
See the full file for the positive conditions and the deny rules. Note shape_ok: the pack
understands exactly two (capability, side effect) pairs, so a capability added to the system later
is denied until someone reviews it here.
Test it with real OPA¶
The tests include the cases a naive pack gets wrong: a missing destination, a non-numeric amount, missing limits, live mode, an unknown capability, and "reasons never echo the input".
A trap in Rego tests
object.union merges nested objects. A test that builds "an input with the destination
missing" by unioning onto a base input silently gets the base destination back, and passes for
the wrong reason. Remove the key first, then set it (the example's with_resource helper does).
Load it from Python¶
from keelgate.policy import RegoEngine
engine = RegoEngine("examples/custom_policy_pack", query="data.acme.transfer_limits.decision")
query is <package>.<rule>. A pack that does not compile fails here, at start-up, not at the
first decision. Give the engine to a ToolGateway(engine=engine, ...) and every WRITE is decided
by your rules; every decision records engine.policy_version, the hash of your sources.
Run the whole thing:
policy pack: transfer_limits, version sha256:816abb70ad81...
a small transfer to an allowed account -> OK
an account that is not on the allowlist -> DENIED ['destination is not on the allowlist']
over the per-transfer cap -> DENIED ['amount exceeds the per-transfer cap']
above the auto-approval threshold -> APPROVAL_REQUIRED
transfers actually sent: ['t-1']
In production¶
Use OPA rather than the in-process evaluator: OpaHttpEngine("http://opa:8181"). Both read the same
.rego files and the test suite runs the shipped packs through both, which is how divergence between
them gets caught. Do the same for yours: evaluate each case through both engines.
You are not limited to Rego. PolicyEngine is a protocol with a name and an async decide;
Cedar ships as an optional engine, and examples/use_keelgate_from_another_library.py shows a
plain-Python engine. Whatever you write must be deterministic and must fail closed.