Compound Policies¶
What you'll learn: How to combine rules using all_of, any_of, and not, including the critical not(all_of(...)) pattern for blocking triggers.
Compound rules¶
Three compound rules let you combine conditions:
| Rule | Passes when | Use for |
|---|---|---|
all_of |
All sub-conditions pass | Multiple requirements that must all be met |
any_of |
Any sub-condition passes | Alternative conditions, any one sufficient |
not |
The sub-condition fails | Inverting a condition |
Compound rules recurse freely: all_of can contain any_of, which can contain not, which can contain a primitive rule.
The critical gotcha: not(all_of(...))¶
Rule functions return True to pass and False to block. This is the most common source of compound policy authoring mistakes.
If your intent is "block when conditions A, B, and C are all present," the naive approach is:
{"rule_type": "all_of", "params": {"conditions": [A, B, C]}}
This is wrong. all_of returns True (passes) when all conditions are met. It would block every step where any condition is not met — the opposite of what you want.
The correct pattern:
{
"rule_type": "not",
"params": {
"condition": {
"rule_type": "all_of",
"params": {"conditions": [A, B, C]}
}
}
}
How it works:
- all_of returns True when all danger conditions are present (= dangerous situation detected).
- not inverts to False (= violated = blocked).
- If any condition is absent, all_of returns False, not inverts to True (= passes).
A misspelled action kind or verb anywhere inside the nested conditions (step.mesage, post) is refused when the policy loads, the same as at the top level of a policy. A misspelled field or property_filter path is not: see Field and property paths.
Worked example: the taint policy¶
The OWASP default template's taint policy uses this pattern. It blocks high-impact actions when external content has entered the task and no fresh gate precedes the action.
{
"name": "External content taint: high-impact actions require a fresh gate",
"rule_type": "not",
"params": {
"condition": {
"rule_type": "all_of",
"params": {
"conditions": [
{
"rule_type": "any_of",
"params": {
"conditions": [
{"rule_type": "current_is", "params": {"kind": "step.exec"}},
{"rule_type": "current_is", "params": {"kind": "step.message", "verb": "POST"}},
{"rule_type": "current_is", "params": {"kind": "step.resource", "verb": "POST"}},
{"rule_type": "current_is", "params": {"kind": "step.resource", "verb": "PATCH"}},
{"rule_type": "current_is", "params": {"kind": "step.resource", "verb": "DELETE"}}
]
}
},
{
"rule_type": "history_contains",
"params": {
"kind": "step.resource",
"count_attempts": true,
"verb": "GET",
"property_filter": {"target.trust": "external"}
}
},
{
"rule_type": "not",
"params": {
"condition": {
"rule_type": "step_directly_preceded_by",
"params": {"required_kind": "step.gate"}
}
}
}
]
}
}
},
"severity": "critical",
"enforcement_point": "step_execution"
}
The same policy as a manifest entry, which is how you would normally ship it (kyvvu try --manifest, kyvvu serve --policy-file and the dashboard all take this form):
name: External content taint
version: "1.0.0"
policies:
- name: "External content taint: high-impact actions require a fresh gate"
rule_type: not
params:
condition:
rule_type: all_of
params:
conditions:
- rule_type: any_of
params:
conditions:
- {rule_type: current_is, params: {kind: step.exec}}
- {rule_type: current_is, params: {kind: step.message, verb: POST}}
- {rule_type: current_is, params: {kind: step.resource, verb: POST}}
- {rule_type: current_is, params: {kind: step.resource, verb: PATCH}}
- {rule_type: current_is, params: {kind: step.resource, verb: DELETE}}
- rule_type: history_contains
params:
kind: step.resource
count_attempts: true
verb: GET
property_filter: {target.trust: external}
- rule_type: not
params:
condition:
rule_type: step_directly_preceded_by
params: {required_kind: step.gate}
severity: critical
enforcement_point: step_execution
Reading the logic:
- The outer
notmeans: "block if the innerall_ofpasses." - The inner
all_ofchecks three conditions: - Current step is a high-impact action (exec, outbound message, or mutating resource call).
- External content has been fetched earlier in the task.
- The current step is NOT directly preceded by a gate.
- If all three are true → dangerous →
all_ofpasses →notblocks. - If any is false → safe →
all_offails →notpasses.
A step that a policy blocked stays in the task's history (the task is not ended), and the path rules treat it carefully: step_requires_gate and step_requires_predecessor ignore a blocked step, so a blocked gate never satisfies a requirement, and step_directly_preceded_by looks past blocked attempts to the last step that actually happened. A gate the agent approves and then retries therefore counts, and a blocked attempt does not hide the gate in front of it.
Another example: PII + product data + model requires approval¶
"If the agent has read customer-data AND product-data AND called a model, then sending an outbound message requires a human-approval gate."
{
"name": "PII + product data + model requires human approval",
"rule_type": "not",
"params": {
"condition": {
"rule_type": "all_of",
"params": {
"conditions": [
{"rule_type": "current_is",
"params": {"kind": "step.message", "verb": "POST"}},
{"rule_type": "history_contains",
"params": {"kind": "step.resource", "count_attempts": true, "verb": "GET",
"property_filter": {"target.table": "customer-data"}}},
{"rule_type": "history_contains",
"params": {"kind": "step.resource", "count_attempts": true, "verb": "GET",
"property_filter": {"target.table": "product-data"}}},
{"rule_type": "history_contains",
"params": {"kind": "step.model", "count_attempts": true}},
{"rule_type": "not",
"params": {"condition": {"rule_type": "step_requires_gate",
"params": {"target_kinds": ["step.message"],
"target_verb": "POST",
"gate_kind": "human"}}}}
]
}
}
},
"severity": "critical",
"enforcement_point": "step_execution"
}
The YAML equivalent:
name: Approval for combined data
version: "1.0.0"
policies:
- name: PII + product data + model requires human approval
rule_type: not
params:
condition:
rule_type: all_of
params:
conditions:
- rule_type: current_is
params: {kind: step.message, verb: POST}
- rule_type: history_contains
params:
kind: step.resource
count_attempts: true
verb: GET
property_filter: {target.table: customer-data}
- rule_type: history_contains
params:
kind: step.resource
count_attempts: true
verb: GET
property_filter: {target.table: product-data}
- rule_type: history_contains
params: {kind: step.model, count_attempts: true}
- rule_type: not
params:
condition:
rule_type: step_requires_gate
params:
target_kinds: ["step.message"]
target_verb: POST
gate_kind: human
severity: critical
enforcement_point: step_execution
This policy wraps all_of in an outer not, exactly like the previous example. The inner all_of passes (returns True) when the dangerous situation holds — customer-data and product-data were both read, a model was called, the current step is an outbound message, and no human-approval gate exists (the inner not(step_requires_gate) is True precisely when the gate is absent). Because a rule that returns True means compliant, that dangerous combination must be inverted by the outer not so it surfaces as a violation. The explain() output below shows this as ✓ all_of — negation failed — inner condition passed.
Debugging compound policies¶
Use the engine's explain() method to see policy verdicts and a compound-condition summary:
print(engine.explain(intended, context))
Evaluated 1 policies for step.message/POST "send_reply" (task=task-abc seq=2):
✗ External content taint: high-impact actions require a fresh gate (critical) FAILED:
✓ all_of — negation failed — inner condition passed
→ action=block (risk_score=1.00)
Incidents from compound policies carry the full condition tree in violation_details.
Next steps¶
- OWASP Default Template — see compound policies in context
- Built-in Rules Reference — all primitive rules you can compose
- Creating Policies — create and deploy your compound policy