Skip to content

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:

  1. The outer not means: "block if the inner all_of passes."
  2. The inner all_of checks three conditions:
  3. Current step is a high-impact action (exec, outbound message, or mutating resource call).
  4. External content has been fetched earlier in the task.
  5. The current step is NOT directly preceded by a gate.
  6. If all three are true → dangerous → all_of passes → not blocks.
  7. If any is false → safe → all_of fails → not passes.

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