Open Agent Rules
Contents
  1. The two things worth reading closely
  2. Running them
  3. bulk-delete.yaml
  4. capability.yaml
  5. config.yaml
  6. mcp-provider-disabled.yaml
  7. read-outside-scope.yaml
  8. redact-pii.yaml
  9. redact-secrets.yaml
  10. repeat-loop.yaml
  11. write-outside-workspace.yaml

A portable rule pack

Ten rules, across seven files, that exercise most of Open Agent Rules 1.0 and are portable in the sense [OAR-PROF-6] defines: every anchor is a core anchor, and every fact comes from the core tier or from a profile the rule declares in requires. They run unchanged on any engine providing those profiles.

Three of the files carry more than one rule, separated by a YAML ---. That is a rule set ([OAR-DOC-31]), and a member's position in one has no effect on its identity, ordering, or precedence.

They are written in YAML because that is the comfortable authoring form. JSON is the interchange form, and the mapping is lossless ([OAR-DOC-1]).

FileWhat it demonstrates
read-outside-scope.yamlThe basic shape: anchor, selector, condition, effect, fail-closed.
redact-secrets.yaml · redact-pii.yamlTwo transforms at one anchor, accumulating rather than competing.
mcp-provider-disabled.yamlA profile a host may not provide, declared through requires.
repeat-loop.yamlEscalation over time: one rule counts, two read the count, and the counter is scoped to the call.
write-outside-workspace.yamlA path prefix matched with a built-in rather than a regular expression.
bulk-delete.yamloverrides: a specific rule silencing the general one it refines.
capability.yamlThe host these rules assume.
config.yamlAn operator disabling and downgrading rules without forking the pack.

The two things worth reading closely#

The threshold is in the rule. redact-secrets.yaml says size(secret_matches) > 0 and repeat-loop.yaml says fire_count_of("REPEAT_CALL_TALLY") >= 5. A detector produced the spans and the engine maintained the counter; neither decided what was too much. That is the whole argument of the format — move either number into the host and the policy becomes unreviewable.

Counting and gating are different rules. repeat-loop.yaml needs three rules where it looks like it needs two. A rule's on_fire runs only when that rule fires ([OAR-FIRE-2]), so a rule waiting for its own counter to reach two would never fire, never count, and never reach two. REPEAT_CALL_TALLY therefore counts unconditionally and the other two read its counter through fire_count_of ([OAR-FIRE-11]). Its counter_scope is what makes the pack about a repeated call rather than about traffic in general ([OAR-FIRE-10]).

Two transforms both apply. redact-secrets and redact-pii fire at the same model.output occurrence and neither suppresses the other; the engine accumulates both and applies them in evaluation order ([OAR-OPS-15], [OAR-OPS-16]). This is why transform does not short-circuit the way block does.

Running them#

Against the reference implementation:

node packages/oar-ref/bin/oar-conformance.mjs --capability examples/portable-pack/capability.yaml examples/portable-pack

A rule here that fails to load is a bug in the pack or the engine, not in the specification — report it either way.


The other worked example: Converting a moderation policy.

bulk-delete.yaml#

Raw file

# The specific rule silences the general one it refines. Without `overrides`
# the author would have to negate DESTRUCTIVE_TOOL's whole condition inside
# this one, which breaks silently the moment either rule changes.
oar: "1.0"
id: BULK_DELETE_DENIED
namespace: example.security
kind: policy
anchor: tool.pre_invoke
selector:
  tool: [delete]
requires:
  profiles: [tool]
when: '"recursive" in tool_args && tool_arg_bool("recursive")'
effect: block
overrides: [DESTRUCTIVE_TOOL_WARN]
status: stable
copy:
  what: A recursive delete is refused outright rather than merely flagged.
---
oar: "1.0"
id: DESTRUCTIVE_TOOL_WARN
namespace: example.security
kind: policy
anchor: tool.pre_invoke
selector:
  tool: [delete, move, chmod]
requires:
  profiles: [tool]
effect: warn                # no `when`: fires whenever selected
status: stable
copy:
  what: This tool changes state that is awkward to restore.

capability.yaml#

Raw file

# The host these rules assume.
oar_capability_version: "1.0"
host: example.gateway
anchors:
  core:
    tool.pre_invoke: tool.pre_invoke
    tool.handler: tool.handler
    tool.post_invoke: tool.post_invoke
    model.input: model.input
    model.output: model.output
    model.tool_result: model.tool_result
  unsupported:
    - agent.post_turn
    - agent.finalize
profiles: [tool, session, filesystem, content, secrets, pii, mcp]
activity_window: 16
detectors:
  - detector://noop
  - detector://error
  - detector://fixture
supports_transform: true
expression_nodes_max: 1024

config.yaml#

Raw file

# What an operator writes instead of forking the pack: silence one rule, and
# roll another out in monitor mode before enforcing it.
oar_config: "1.0"
disable:
  - example.hygiene/REPEAT_LOOP_WARN
enforcement:
  example.security/MCP_PROVIDER_DISABLED: monitor

mcp-provider-disabled.yaml#

Raw file

# Uses a profile many hosts will not provide. `requires` is what makes that a
# clean load-time refusal instead of a mystery at run time.
oar: "1.0"
id: MCP_PROVIDER_DISABLED
namespace: example.security
kind: policy
anchor: tool.pre_invoke
requires:
  profiles: [mcp]
when: 'mcp_provider_id != "" && !mcp_provider_enabled'
effect: block
status: stable
copy:
  what: The call targets a bridged provider that is configured but not enabled.
  fix: Enable the provider in the catalogue, or route the call elsewhere.

read-outside-scope.yaml#

Raw file

# The basic shape. Six lines carry the policy; copy carries the explanation.
oar: "1.0"
id: READ_OUTSIDE_SCOPE
namespace: example.security
kind: policy
anchor: tool.pre_invoke
selector:
  tool: [read, grep]        # clause names the `tool` fact; matches by membership
requires:
  profiles: [tool, filesystem]
when: 'path_outside_scope(tool)'
effect: block
on_error: fail_closed       # if the scope check itself fails, refuse
status: stable
copy:
  title: Read outside the declared scope
  what: The call reads a path outside the scope in force for this tool.
  fix: Read inside the scope, or widen the scope deliberately.

redact-pii.yaml#

Raw file

oar: "1.0"
id: REDACT_PII
namespace: example.security
kind: detector
anchor: model.output
requires:
  profiles: [pii]
detector:
  ref: detector://fixture
when: 'size(pii_entities) > 0'
effect: transform
transform:
  action: redact
  target: pii_entities
  replacement: "[redacted]"
status: stable
copy:
  what: The response contained personal data.

redact-secrets.yaml#

Raw file

# A transform. The detector reported spans; this rule decides that any span at
# all is too many. Compare redact-pii.yaml: both fire, and both apply.
oar: "1.0"
id: REDACT_SECRETS
namespace: example.security
kind: detector
anchor: model.output
requires:
  profiles: [secrets]
detector:
  ref: detector://fixture
when: 'size(secret_matches) > 0'
effect: transform
transform:
  action: redact
  target: secret_matches
  replacement: "[secret redacted]"
status: stable
copy:
  what: The response contained something shaped like a credential.

repeat-loop.yaml#

Raw file

# Escalation over time: notice a repeat, warn about it, then refuse.
#
# Three rules, because a rule cannot both keep a count and gate on the count it
# is keeping — `on_fire` applies only when a rule fires, so a rule waiting for
# its own counter to reach two would never fire and never count ([OAR-FIRE-11]).
# One rule therefore counts unconditionally, and the other two read its counter.
#
# `counter_scope` is what makes this about a *repeat* rather than about traffic
# in general: the counter is keyed by the fingerprint of the tool call, so the
# tally counts how many times *this same call* has been made rather than how
# many calls there have been ([OAR-FIRE-10]).
oar: "1.0"
id: REPEAT_CALL_TALLY
namespace: example.hygiene
kind: schema
anchor: tool.pre_invoke
requires:
  profiles: [tool]
counter_scope: tool_args_fingerprint
effect: allow                   # contributes no decision; it is here to count
on_fire: [increment_counter]
status: stable
copy:
  what: Records that this exact call was made, so the rules below can see a repeat.
---
oar: "1.0"
id: REPEAT_LOOP_WARN
namespace: example.hygiene
kind: invariant
anchor: tool.pre_invoke
requires:
  profiles: [tool]
when: 'fire_count_of("REPEAT_CALL_TALLY") >= 2 && fire_count_of("REPEAT_CALL_TALLY") < 5'
effect: warn
status: stable
copy:
  what: The same call has been retried several times without a change of approach.
---
oar: "1.0"
id: REPEAT_LOOP_BLOCK
namespace: example.hygiene
kind: invariant
anchor: tool.pre_invoke
requires:
  profiles: [tool]
when: 'fire_count_of("REPEAT_CALL_TALLY") >= 5'
effect: block
on_fire: [publish_event]
status: stable
copy:
  what: The same call has been retried too many times.
  fix: Inspect the environment or change the command rather than replaying it.

write-outside-workspace.yaml#

Raw file

# A path-prefix check, written without a regular expression and without a host
# observation function — so it travels.
#
# `starts_with` is one of the four built-ins ([OAR-EXPR-16]). The guard in front
# of it is not decoration: `tool_args` is an unparameterised map and cannot be
# indexed, so the typed accessor is how a member is reached, and `in` short-
# circuits ahead of it ([OAR-EXPR-5], [OAR-EXPR-19]).
oar: "1.0"
id: WRITE_OUTSIDE_WORKSPACE
namespace: example.security
kind: policy
anchor: tool.pre_invoke
selector:
  tool: [write, edit]
requires:
  profiles: [tool]
when: '"path" in tool_args && !starts_with(tool_arg_string("path"), "/workspace/")'
effect: block
on_error: fail_closed
status: stable
copy:
  title: Write outside the workspace
  what: The call writes to a path that is not under /workspace/.
  fix: Write inside the workspace, or move the file there deliberately.