Policy DSL#
policy "<name>" { ... } blocks are the first-party, kernel-privileged rule-matching DSL that decides allow/ask/deny for resource, data-source, and interactive tool calls. Mechanically, policy is the kernel-privileged veto-mode subscriber at the plan-ready hook — always run, always respected. See glossary.md and architecture.md#policy--first-party-not-a-plugin-category.
policy "auto_approve_reads" {
match = { kind = "data_source" }
action = "allow"
}
policy "gate_filesystem_writes" {
match = { provider = "filesystem", kind = "resource" }
action = "ask"
}
policy "block_high_risk" {
match = { risk = "critical" }
action = "deny"
}
Match schema#
PolicyMatch {
kind enum { resource, data_source }? // MAY be omitted
provider string? // MAY be omitted
tool_name string? // MAY be omitted
risk RiskClass? // per tool/data-types.md#riskclass // MAY be omitted
}
An omitted field matches anything; specified fields are ANDed together. A policy block with an entirely empty match = {} matches every call — legal, but MUST be flagged with a config-load-time warning (almost always an authoring mistake).
PolicyMatch.kind stays two-valued — a documented, deliberate v1 limitation. tool/protocol.md's third tool kind, interactive, is not mirrored onto policy match kinds — kind accepts only resource or data_source. An operator therefore cannot write match = { kind = "interactive" }; extending kind to a third value would require a deliberate future spec change. This does not put interactive calls outside policy evaluation: interactive calls can still be targeted through tool_name, provider, or risk, and they route through the same non-interactive-style precheck data-source calls use — see Evaluation semantics below and ../agent-loop/plan-apply-gate.md.
Each match field, when omitted, matches unambiguously as "not specified" rather than as some zero value. The call being evaluated, in contrast, does carry the full three-value tool kind (including interactive) — only the match criteria's kind field is restricted to two values.
Conflict detection#
Conflicting matches resolve most-specific-wins, with specificity defined as a fixed field hierarchy (most to least specific): tool_name > provider > risk > kind. Comparison is lexicographic over "does this rule specify this field," in that field order:
specificity(rule) = (rule.match.tool_name != nil,
rule.match.provider != nil,
rule.match.risk != nil,
rule.match.kind != nil)
compared as a 4-tuple of booleans, most-specific tuple wins.
A conflict requires an identical specificity tuple and, for every field both rules specify, equal values — a field only one of the two rules specifies never disqualifies a conflict; it's the fields both specify that must agree. Two rules whose match criteria conflict under this rule MUST be a config-load-time error.
Specificity-tuple equality alone is not sufficient for conflict detection: two rules can share an identical tuple (e.g. both (true, false, false, false), i.e. both specify only tool_name) while specifying disjoint values for that field, e.g. one rule matching tool_name = "read_file" and another matching tool_name = "write_file". A real call has exactly one tool_name, so the two rules can never both match the same call — they are not actually in conflict, and flagging them as a load-time error would force an operator to artificially over-specify two rules that were never ambiguous. See examples.md#a-non-conflicting-same-tuple-pair for a worked instance.
Conflict checking scans all rule pairs and reports the first conflicting pair found, not necessarily every conflicting pair in the set — catching at least one conflict at load time is all this rule requires; it does not guarantee an exhaustive report of every conflict when several exist simultaneously.
Evaluation semantics#
Policy evaluation covers all tool calls, not only resource calls — widened specifically so that a rule like match = { kind = "data_source" }, action = "allow" is meaningfully expressible, while preserving the "data sources execute freely by default" principle that motivated the resource/data-source split in the first place:
evaluate_policy(call) -> PolicyDecision:
candidates := [p for p in policy_rules if match(p.match, call)]
if candidates is empty:
return (kind(call) in {data_source, interactive}) ? allow : ask
// conservative default for resources, free default for reads
// and interactive calls (agent-loop.md's plan-apply-gate reuses
// this precheck verbatim for interactive calls)
winning := most_specific(candidates) // ties are a load-time error,
// never reached here
if kind(call) in {data_source, interactive} and winning.action == ask:
log_warning("policy rule %q resolved to ask against a %s call; "
"no apply-time gate exists for it — downgraded to deny",
winning.name, kind(call))
return deny
return winning.action
allowon adata_source/interactivecall is a no-op — the call was going to execute anyway — but now the auto-approve-reads pattern is meaningfully expressible as the explicit form of the otherwise-implicit default.denyon adata_source/interactivecall is meaningful: an operator can hard-block a specific read or interactive operation (e.g. "never callweb.web_search") as a genuine blocklist, distinct from the resource-oriented approval gate.denyMUST synthesize atool_resultdenial block the same way a denied resource call does — see../agent-loop/turn-algorithm.md.askhas no meaning against adata_source/interactivecall — there is no apply step to gate a read (or a blocked-on-human-input call) behind. Rather than making this a config-load-time error (which would force every operator to redundantly scopekind = resourceon everyaskrule just to avoid the edge case), the kernel downgrades it todenyat evaluation time and logs why — the operator sees the real behavior against reads without being forced into more verbose match criteria for the common case.- For
resourcecalls, evaluation andallow/ask/denysemantics are exactly what../agent-loop/plan-apply-gate.mdspecifies — unchanged by this section. - Interactive calls extend the same data-source-shaped precheck — they are not gated by the resource plan/apply flow (nothing to approve, only a question to answer), but they DO pass through this non-interactive, allow/deny-only policy lane before executing. See
../agent-loop/plan-apply-gate.md#data-source-and-interactive-calls.
Evaluation surfaces whether a winning ask was downgraded to deny for a data_source/interactive call, so the required warning (above) can be logged by the caller. If more than one candidate ties for most specific — a state conflict detection is meant to reject at config-load time — evaluation treats it as an already-validated precondition and deterministically picks one of the tied candidates rather than erroring at runtime.