Attribute conditions (when:)

A rule's patterns say what the call looks like. when: says who is making it, against what, with which arguments. A rule fires only when its patterns match and its when: expression holds.

rules:
  - id: refund-cap
    title: Refunds of 500 or more need the finance role
    severity: HIGH
    category: custom-authz
    event_types: [shell]
    fields: [tool_name]
    patterns: ['^refund_order$']
    when: "args.amount >= 500 and 'finance' not in principal.roles"
    action: block
    mode: enforce

This is attribute-based access control for agent tool calls. The same rule governs an SDK adapter, the eval-server, the MCP gateway and the proxy, because they all go through one decision contract.

What an expression can read

PathValueComes from
principal.idend-user idthe resolved subject
principal.team, principal.orgteam / org idsubject
principal.roleslist of rolesverified identity token only; empty otherwise
principal.claims.*token claimsverified identity token only
principal.verifiedtrue for a verified token or an enrolled devicesubject source
principal.sourcejwt, device, explicit, context, env, anonymoussubject
args.*the tool call's argumentsadapter / eval-server arguments, or a hook's tool_input
resource.kind, resource.id, resource.attr.*the call's targetresource on the eval-server body or evaluate_tool_call(resource=...)
tool.nametool namemetadata.tool_name

A caller can name a user (subject: "user:alice") but cannot grant itself a role: roles and claims come only from a verified identity token. A rule that denies unless a role is present therefore stays a deny for every unverified caller.

Grammar

Logicand, or, not, parentheses
Comparison== != < <= > >= (chainable: 100 < args.amount <= 1000)
Membershipx in list, x not in list; on a string, substring match
Literalsstrings, numbers, true/false/null (or True/False/None), lists
Pathsargs.amount, args['dry-run'], args.items[0] (non-negative integer index)
Functionhas(path) — whether the path exists

Anything else is rejected when the policy loads, including calls, arithmetic and names outside the four roots. The expression is parsed into a checked tree and never passed to Python's eval, so a signed org policy cannot carry code.

Missing attributes fire the rule

If the event has no such path (args.amount on a call without an amount), or the comparison mixes types (900 > 'x'), the expression holds and the rule fires. An adapter that forgets to send arguments must not turn a guard off.

Guard optional fields with has():

when: "has(args.amount) and args.amount >= 500"

Rules without patterns

A rule with when: may leave out patterns. It then fires on every event of its event_types for which when: holds:

  - id: unverified-callers-read-only
    title: Unverified callers may not use write tools
    severity: HIGH
    category: custom-authz
    event_types: [shell, file_write, network]
    when: "not principal.verified and tool.name in ['delete_doc', 'refund_order', 'send_email']"
    action: block
    mode: enforce

A patternless rule whose when: does not parse is disabled (it would otherwise match everything), and prismor policy validate / the console refuse to save it. A rule that has patterns and a broken when: keeps firing on its patterns.

Recipes

Owner-only actions

patterns: ['^(delete|share)_doc$']
fields: [tool_name]
when: "resource.attr.owner != principal.id"

Amount cap by role

when: "args.amount >= 500 and 'finance' not in principal.roles"

Only verified users in production

when: "resource.attr.env == 'prod' and not principal.verified"

Allowed currencies

when: "args.currency not in ['USD', 'EUR', 'GBP']"

Named conditions

Conditions you use in several rules can be named once in settings.conditions and referenced by name, the way a derived role is defined once and reused:

settings:
  conditions:
    is_finance: "'finance' in principal.roles"
    is_owner: "resource.attr.owner == principal.id"

rules:
  - id: refund-cap
    # ...
    when: "args.amount >= 500 and not is_finance"
  - id: owner-only-delete
    # ...
    when: "not (is_owner or is_finance)"

A named condition reads the same four roots as when:. It cannot refer to other names, so there are no cycles. Names merge per name across policy layers: the signed org policy can redefine is_finance without erasing a project's other names. A missing attribute inside a named condition fails toward detection, exactly as it does inline.

Explaining a decision

Ask the eval-server for a trace with "explain": true:

"explain": {
  "rules": [
    {"rule_id": "refund-cap", "layer": "remote", "mode": "enforce",
     "when": "args.amount >= 500 and not is_finance", "when_holds": false, "fired": false}
  ],
  "policy_version": 14,
  "identity": "verified",
  "subject_source": "jwt",
  "decided_by": null
}

Every rule whose patterns matched is listed, including the ones a when: then switched off, so "why didn't my rule fire?" has an answer. In Python, pass evaluate_tool_call(..., explain=True) and read Decision.explain.

Core rules

when: can only narrow a rule, so it is refused on core protections (the non-overridable floor and the core block categories), the same as condition:.

when: and condition:

condition: combines named pattern groups, so it is about the text ("exfil_verb and secret_ref"). when: reads attributes. A rule may use both; it then fires when the condition holds and when: holds.

Sending attributes

eval-server

curl -s localhost:7071/v1/evaluate -d '{
  "tool_name": "refund_order",
  "arguments": {"order": "o-1", "amount": 900},
  "subject": "user:bob",
  "resource": {"kind": "order", "id": "o-1", "attr": {"owner": "alice"}}
}'

Python

from prismor.runtime.runtime import evaluate_tool_call

evaluate_tool_call(event=event, workspace=ws, agent="sdk",
                   resource={"kind": "order", "id": "o-1", "attr": {"owner": "alice"}})

Hook-based agents (Claude Code, Codex, Cursor) need nothing extra: their tool_input is available as args.

Testing

prismor policy test takes type: tool cases:

tests:
  - name: support agent cannot refund 900
    type: tool
    tool: refund_order
    args: {amount: 900}
    principal: {id: bob, roles: [support], verified: true}
    expect: block
    expect_rule: refund-cap
  - name: finance can
    type: tool
    tool: refund_order
    args: {amount: 900}
    principal: {id: carol, roles: [finance], verified: true}
    expect: pass

prismor check --explain prints a matched rule's when:.

Suites: fixtures and per-principal expectations

Name your principals and resources once, and assert one call for several callers in a single test. A matrix row is named test [principal].

fixtures:                      # or a sibling policy-fixtures.yaml, shared by every suite in the directory
  principals:
    bob:   {id: bob, roles: [support], verified: true}
    carol: {id: carol, roles: [finance], verified: true}
    anon:  {}
  resources:
    alices_order: {kind: order, id: o-1, attr: {owner: alice}}
tests:
  - name: refunds over 500
    tool: refund_order
    args: {amount: 900}
    resource: alices_order
    expect: {bob: block, carol: pass, anon: block}
    expect_rule: refund-cap
  - name: not yet
    skip: true
    skip_reason: waiting on the roles claim
prismor policy test                                 # .prismor/policy-tests.yaml
prismor policy test --policy policies/bot.yaml      # a policy file, with its own tests: if it has them
prismor policy test --filter '*[carol]' --json

A policy file can carry its own tests: and fixtures:; the engine ignores both keys. The console stores a policy's tests this way and runs them before publishing.

In CI

- uses: PrismorSec/prismor/.github/actions/policy-test@main
  with:
    policy: policies/support-bot.yaml   # optional

The job fails on any mismatch. The action installs the prismor source from the ref you pin, so the CLI always matches the action; pass prismor-version: "==X.Y.Z" to use a PyPI release instead.