Policy engine
How Klent evaluates an action against your policies. Operators, effects, field resolution, ordering.
The policy engine is the core of the control surface. Every time you call
evaluateAction, the engine:
- Loads the enabled policies for the project.
- Walks them in creation order (oldest first).
- For each policy, checks that all of its conditions match the action.
- Returns the first matching policy's effect, or
allowif no policy matched.
Evaluation is a pure function of the action payload and the policy set — no randomness, no time-of-day logic. Two identical inputs always produce the same decision.
Anatomy of a policy
{
"name": "Block high-value USD transfers",
"enabled": true,
"effect": "deny",
"conditions": [
{ "field": "tool", "operator": "equals", "value": "transfer_funds" },
{ "field": "input.amount", "operator": "greater_than", "value": 10000 },
{ "field": "input.currency", "operator": "equals", "value": "USD" }
]
}A policy is one rule with one effect. To express "OR" across two situations, create two policies with the same effect.
Field resolution
Conditions target fields via dot-paths into the action payload. The evaluation context exposes:
| Path | What it is |
|---|---|
tool | The tool name string. |
input.* | Any key (at any depth) inside the action input. |
metadata.* | Any key inside the action's metadata (if you pass it). |
Examples:
tool→"send_email"input.to→"[email protected]"input.amount→50000input.user.role→"admin"metadata.tenant→"acme"
Missing fields resolve to undefined. A condition against a missing field never
matches — it is treated as false, not as an error.
Operators
| Operator | Matches when |
|---|---|
equals | actual and expected are strictly === equal |
not_equals | actual and expected are not strictly equal |
greater_than | both are numbers and actual > expected |
less_than | both are numbers and actual < expected |
contains | actual is a string containing expected; or actual is an array that includes expected |
ends_with | both are strings and actual ends with expected |
Numeric operators (greater_than, less_than) return false for non-numeric inputs
rather than throwing. String operators return false if either side is not a string.
Conditions (AND)
All conditions in a single policy must match for the policy to apply. To match on different conditions, create separate policies.
Effects
deny
The action is blocked. evaluateAction returns:
{
"decision": "deny",
"matched_policy_id": "pol_…",
"modifications": null,
"reason": "Matched policy \"Block high-value USD transfers\""
}And Klent appends an action_blocked event to the execution timeline automatically.
Your code should treat a deny as terminal: surface the reason to the LLM as a tool
error, and move on.
allow
The action is permitted explicitly. Useful for creating a ranked allowlist — put your
allow policies at the top so denies further down never fire for them.
modify
The action is permitted with changes. The response includes a modifications array
describing fields to overwrite:
{
"decision": "modify",
"matched_policy_id": "pol_…",
"modifications": [{ "field": "input.cc", "value": "[email protected]" }],
"reason": "Matched policy \"Always CC audit on outbound email\""
}Apply the modifications to your action input before executing. The SDK does not apply them for you — the decision is yours, so you can log a warning, require user confirmation, or silently comply.
approve (human-in-the-loop)
The action is parked until a human resolves it from the dashboard. The engine returns:
{
"decision": "approve",
"matched_policy_id": "pol_…",
"modifications": null,
"redirect_to": null,
"pending_action_id": "pact_…",
"reason": "Matched policy \"Pause large transfers for review\""
}Klent creates a row in pending_actions and emits a pending_approval event on
the timeline. The SDK can either:
- Return immediately with
status: "pending"and let the agent decide what to do (retry later, narrate to the user, fall back to a safer tool). This is the default. - Block until resolved by passing
approval.wait(TS) /approval_wait(Python). The SDK pollsGET /v1/pending_actions/:id; when a reviewer approves in the dashboard, the tool runs (with any last-mile modifications the reviewer staged). When rejected, the SDK returnsstatus: "denied"with the reviewer's free-text note as the reason.
// TS — synchronous wait, server-side long-poll
const res = await runTool(klent, {
execution_id,
tool: 'transfer_funds',
input: { amount: 9000 },
execute: doTransfer,
approval: { wait: { timeoutMs: 600_000 } },
});
if (res.status === 'denied') return showRejection(res.reason);
if (res.status === 'pending') return queueForLater(res.pendingActionId);The dashboard's Approvals queue surfaces every pending row across the project,
with one-click Approve / Reject buttons. Resolved decisions land in the per-execution
timeline as approval_resolved events for audit.
Multi-approver (two-person rule)
Set required_approvals on the policy to demand more than one distinct human approve
before the action releases. A single rejection still terminates the action — there is
no override.
- name: finance / hold-large-refunds
effect: approve
required_approvals: 2 # default 1
conditions:
- field: tool
operator: equals
value: stripe.refunds.create
- field: input.amount
operator: greater_than
value: 50000required_approvals accepts integers from 1 to 10. Each individual vote emits an
approval_vote event on the timeline; the deciding vote ALSO emits approval_resolved
so existing alert / audit consumers keep their terminal-event semantics. Idempotency
keys prevent the same human from voting twice.
steer (redirect to a different tool)
The action is permitted, but the engine substitutes a different tool for it.
Stronger than modify, which only changes input fields — steer changes the
target tool entirely. The response carries a redirect_to envelope:
{
"decision": "steer",
"matched_policy_id": "pol_…",
"modifications": null,
"redirect_to": {
"tool": "send_email_via_audited_relay",
"input": { "to": "[email protected]", "audited": true }
},
"reason": "Matched policy \"Route prod email through audit relay\""
}The SDK's runTool runs the redirect transparently. If you provide an
executeSteered (tool, input) => … callback, that callback receives the new tool
name and input. Otherwise the original execute callback is reused with the
steered input — works when execute is a single dispatcher.
A steer match writes an action_steered event to the timeline before the
substitute call runs, so the dashboard timeline shows the redirect even if the
substitute later fails.
When to use it:
- Sandbox redirection. Steer prod-pointed tools to dev or staging implementations in non-prod environments.
- Vendor swap. Quietly route email through an audited relay, search through a cheaper provider, model calls through a smaller model.
- Hot-patching agent behavior. Fix an agent's intent without redeploying it — steer the dangerous tool to a safer variant.
Ordering and the default
Policies are evaluated in creation order. The first matching policy wins and
evaluation stops. If no policy matches, the default is allow.
Design implications:
- Put allow-lists above deny-alls. A narrow
allowfor a trusted tool will short-circuit before a broaddeny. - Denies at the bottom are catch-alls. Build your policy set as a funnel from specific-allow → specific-deny → broad-deny.
Disabled policies
A policy with enabled: false is never evaluated. Use this to:
- Stage changes. Create a policy disabled, validate it in the dashboard, then flip it on.
- Emergency-off without deletion. Turn a misbehaving policy off while keeping the audit history (the ID stays stable, just inert).
Shadow (dry-run) mode
Policies have an enforcement_mode of either enforce (the default) or
shadow. Shadow policies are evaluated normally — they match on the same
conditions, in the same order — but their deny / modify effects are not
applied. The action still runs.
When a shadow policy would have blocked or modified an action, Klent records
that on the execution timeline with a shadow_decision field in the payload:
{
"decision": "allow",
"matched_policy_id": null,
"reason": "No policy matched; default allow",
"shadow_decision": "deny",
"shadow_matched_policy_id": "pol_shadow_…",
"shadow_reason": "Matched shadow policy \"Block high-value transfers\""
}The dashboard surfaces these in the timeline with a shadow · would deny
badge, so you can see what would have happened without the real path being
affected.
When to use shadow mode
- Rolling out a new policy. Run it shadow for a day, inspect the matches in the timeline, then flip to enforce.
- Auditing. Encode organizational rules you don't yet want to enforce but want to count ("how often does the agent try X?"). The events are queryable without the real-world cost of denials.
- Regression safety. Before editing a broad policy, add a shadow copy so you can diff behavior for a window.
Shadow policy ordering
A shadow match does not short-circuit evaluation. The engine continues past
it looking for an enforce match. If none is found, the default allow applies
— but the shadow is still reported. If an enforce policy matches later, the
shadow is reported alongside the enforce decision.
Only the first shadow match is reported per action (the engine picks the earliest in creation order).
Caching
For MVP the policy set is re-fetched from Postgres on every evaluateAction. The query is
a single indexed lookup on (project_id, enabled) — latency measured in sub-millisecond
for typical projects (< 100 policies).
A per-project in-process cache is being reintroduced once cross-process invalidation
lands (via Postgres LISTEN/NOTIFY).
Pre-filters: rate limits and loop guard
Two historical-event clauses can attach to any policy and gate its effect on prior tool-call history. They run before the standard condition match and produce the same decision shape as a normal policy fire — full coverage in Rate limits and loop guard.
rate_limit— fire when N matching tool calls have run in a sliding window (per-tool or per-(agent, tool)). Use for project-wide caps and per-agent fairness.loop_guard— fire when the same tool has been called N times in a row inside the current execution. Use to break LLM retry loops without aborting the whole agent run.
What the engine does not do
- It does not call out to LLMs or other services. Evaluation is local to the API process. Determinism and latency are non-negotiable.
- It does not chain policies. Matching one policy does not "fall through" to the next. First match wins.