Are you an LLM? You can read better optimized documentation at /docs/agent-guard/policies/policy-schema.md for this page in Markdown format
Policy YAML schema
Every Agent Guard policy file shares a top-level shape, with a spec that differs by kind. This page covers all three specs. For the fields available inside an assert expression, see the CEL context reference. For a walkthrough that builds a policy from scratch, see Write your first policy.
Top-level shape
yaml
apiVersion: gerty.jozu.dev/v1
kind: <ToolPolicy | ArtifactPolicy | GuardrailPolicy>
metadata:
name: <string>
spec:
match: <match-block>
action: <Enforce | Elicit | Audit | Redact>
rules:
- name: <string>
assert: |
<CEL expression>
message: <string>Required fields: apiVersion, kind, metadata.name, spec.action, spec.rules. Every rule needs a name and an assert. message is optional but strongly recommended because it is what the agent sees on a block.
spec.match is optional, and its shape differs by kind: a ToolPolicy matches on tool names and servers, an ArtifactPolicy on registries and tags, a GuardrailPolicy on scanners and direction. An omitted match applies the policy to every request of that kind. See the per-kind sections below.
The assertion convention is the inverse of OPA/Rego deny rules: assert describes the safe case, and the action fires when the assertion is false. See Writing assertions: the convention for the full explanation.
apiVersion
yaml
apiVersion: gerty.jozu.dev/v1The only supported value today. The policy engine rejects anything else. Future versions will be additive and will declare a different value.
kind
The three valid kinds correspond to three evaluation points in the agent lifecycle.
| Kind | Evaluated at | What it controls |
|---|---|---|
ToolPolicy | Every tool invocation | Which tools the agent can call and what arguments those calls can have |
ArtifactPolicy | Artifact admission (when an OCI artifact is pulled) | Which agents, MCP servers, skills, and policies can load into the runtime |
GuardrailPolicy | Inference request and response | Content of prompts and completions: PII, toxicity, prompt injection patterns |
See How policies work for the conceptual overview.
ToolPolicy is the kind most people write first, because it governs what a running agent does. ArtifactPolicy governs what the agent is built from, and is evaluated once before the agent starts. GuardrailPolicy governs content crossing the model boundary. All three are documented below.
metadata
yaml
metadata:
name: no-force-push| Field | Required | What it does |
|---|---|---|
name | yes | Identifies the policy in logs and audit output. Pick something descriptive (no-force-push rather than policy-1). Policies are also evaluated in alphabetical order by this name, which matters once you load more than one. |
labels | no | Arbitrary string map. Useful for grouping policies by team, control family, or compliance regime. Not interpreted by the engine. |
The engine accepts any string as a name, spaces and punctuation included. Two things make a hyphenated, sortable name worth sticking to anyway: the name is what identifies the policy in audit records, and it decides evaluation order, so z1-mask-phone and Mask phone numbers end up in very different positions relative to the rest of your set.
Names are the one place loose characters are harmless. Inside an assert expression they are not: a space in the middle of a field path (tool.arguments.file path) is a CEL syntax error, the policy fails to load, and a source that fails to load blocks every agent that pulls it.
ToolPolicy spec
yaml
spec:
match:
tool:
names:
- "Bash"
- "Write"
action: Enforce
rules:
- name: no-force-push
assert: |
!("command" in tool.arguments) ||
(!tool.arguments.command.contains("git push --force") &&
!tool.arguments.command.contains("git push -f"))
message: "Force push is prohibited by security policy"The match covers Bash and Write, but only Bash sends a command argument. The !("command" in tool.arguments) guard is what keeps this policy from blocking every Write call with an evaluation error. See Guard every argument access.
spec.match (ToolPolicy)
All selectors are optional. When several are given, the call has to satisfy all of them. An omitted match applies the policy to every tool call.
| Selector | Type | Matches against |
|---|---|---|
tool.names | list of strings | Tool name. Built-in agent tools (Bash, Read, Write, Edit, WebFetch) and MCP-exposed tools are both valid |
tool.servers | list of strings | The MCP server exposing the tool. Combine with tool.names to scope a rule to specific tools on specific servers |
request.user | map | types, roles, groups of the calling identity |
request.clientIps | list of strings | Originating client IP |
expression | CEL string | Any expression over the runtime context, for cases the selectors above cannot express |
If a tool call doesn't match the policy's criteria, its rules don't run at all. The policy expresses no opinion about that call. That is not the same as allowing it, since another policy may still deny it.
spec.action
What to do when any rule's assertion returns false.
| Action | Available on | Behavior |
|---|---|---|
Enforce | all kinds | Block the operation. Return an error to the agent. Does not short-circuit: every other policy still runs and all violations are collected |
Audit | all kinds | Allow the operation and write a record to the audit log |
Elicit | ToolPolicy | Ask a human to approve the operation. See Elicit below. Short-circuits |
Redact | GuardrailPolicy | Let the content through with the sensitive part masked. Short-circuits |
A single policy has one action. If you need different actions for different cases, write multiple policies.
spec.rules
A list of named rules. Every rule is evaluated; if any rule's assert returns false, the policy's action fires.
| Field | Required | What it does |
|---|---|---|
name | yes | Identifies the rule in audit output. The audit record captures which rule fired. |
assert | yes | CEL expression that must return true for the rule to pass. |
message | no | Human-readable string returned to the agent on a block. Write it as instructions for an audience that is half-human, half-agent. |
Elicit
Elicit is the action for "ask a human." Write it whenever the operation is one a person should be able to approve: a recursive delete, a schema change, a deploy to production. Enforce and Elicit encode different intents, and they are not interchangeable. Enforce means never; Elicit means not without approval.
An Elicit policy carries a spec.elicitation block whose message frames the decision for whoever is approving:
yaml
spec:
match:
tool:
names:
- "Bash"
action: Elicit
elicitation:
message: >-
This command destroys data irreversibly. Review the exact command
before approving -- it will run as written.
rules:
- name: confirm-recursive-delete
assert: |
!("command" in tool.arguments) ||
!tool.arguments.command.matches("(?i)(^|[;&|] *)rm +(-[a-z]* +)*-[a-z]*[rR]")
message: "Recursive delete (rm -r) requires operator confirmation"The elicitation.message explains the decision. Each rule's own message names the specific rule that fired. Both reach the operator.
Composing with other policies
Two behaviors matter when an Elicit policy is loaded alongside others:
Elicitshort-circuits policy execution. It suspends the request pending a human decision, so there is no outcome yet for a later policy to add to, and any policy evaluated after it is skipped. See Evaluation and ordering.- An
Elicitpolicy and anEnforcepolicy can both contribute to one response. They fire in sequence, not at the same time: anEnforcepolicy evaluated earlier collects its violation and evaluation continues, then a laterElicitpolicy fires and stops it. The single response that comes back is labeled as needing approval but still carriesallowed: falseand the earlierEnforceviolation, so approving would not release it. Display the full violation list, not just the elicitation message.
CEL evaluation context
Assertions read the context the engine builds for the request: tool for a ToolPolicy, artifact for an ArtifactPolicy, guardrail for a GuardrailPolicy, and request for both runtime kinds. Every field, which ones can be absent, and the helper functions are in the CEL context reference.
CEL helpers and gotchas
Guard every argument access
Not every tool call carries every argument. tool.arguments.command is set for Bash calls but not for Read calls. Reading an argument the tool did not send is an evaluation error, and evaluation errors fail closed: the call is blocked.
An unguarded rule written for Bash does not quietly skip other tools, it blocks them, and the agent gets an evaluation error instead of your message. Open every rule with a membership check:
yaml
assert: |
!("command" in tool.arguments) ||
!tool.arguments.command.contains("rm -rf")The || short-circuits. When the argument is absent the rule passes without ever touching it.
To cover several arguments in one rule, guard each one separately:
yaml
assert: |
(!("command" in tool.arguments) || !tool.arguments.command.contains("rm -rf")) &&
(!("file_path" in tool.arguments) || !tool.arguments.file_path.contains("/etc/"))String methods
CEL strings expose .contains(substring), .startsWith(prefix), .endsWith(suffix), .matches(regex), .size(), and standard concatenation with +.
.contains is substring matching, not regex. tool.arguments.command.contains("curl") matches both curl https://example.com and concurly. For host-anchored matching use .matches(regex):
yaml
assert: |
!tool.arguments.command.matches("\\bcurl\\b")Booleans in assert
The assert field is parsed as a CEL expression, but YAML parses the bare values true and false as YAML booleans. To write an always-failing assertion (for example, an Audit-everything rule), quote the literal so the engine parses it as CEL:
yaml
rules:
- name: audit-all
assert: "false"
message: "audit-everything"Path traversal
Substring checks against paths do not normalize. A literal-string check against /etc/passwd does not block /etc/../etc/passwd. If the policy is security-critical, block any path containing ..:
yaml
assert: |
!("file_path" in tool.arguments) ||
(!tool.arguments.file_path.contains("..") &&
!tool.arguments.file_path.startsWith("/etc/"))Combining assertions
A policy can hold as many rules as you like, and each one has its own independent assert. The engine evaluates all of them, and the policy's action fires if any rule's assertion is false. The effect is an AND across rules, since every rule has to pass for the call to get through, but the rules themselves stay separate: each keeps its own name and its own message, and the audit record names the one that fired.
Separate rules are the right choice when each condition deserves its own message. Reach for CEL's || inside a single assertion when you want OR semantics, since there is no way to express "either of these rules passing is enough" across two rules.
ArtifactPolicy spec
An ArtifactPolicy is evaluated once per artifact, at admission, before the agent starts. It decides what the agent may be built from: the agent definition, each MCP server, each skill, each policy bundle.
yaml
apiVersion: gerty.jozu.dev/v1
kind: ArtifactPolicy
metadata:
name: 01-registry-trust-boundary
spec:
match:
expression: |
!(artifact.registry in ["jozu.ml", "registry.internal.example.com"])
action: Enforce
rules:
- name: registry-not-on-allowlist
assert: "false"
message: "Registry is not on the organization's allowlist. Mirror the artifact into an approved registry."This is the shape to reach for when writing an allowlist. The match selects the artifacts you want to reject, and the rule asserts "false" so every one of them fires the action. Writing it the other way around (matching everything and asserting the good case) works too, but reads worse once the list grows.
Enforce and Audit are the available actions. Elicit and Redact are runtime actions and do not apply at admission.
spec.match (ArtifactPolicy)
spec.match accepts these keys. Several may be combined; an artifact has to satisfy all of them for the rules to run. An empty or omitted match matches every artifact.
| Selector | Type | Matches against |
|---|---|---|
artifactTypes | list of strings | The artifact type from the OCI config descriptor |
registries | list of strings | Registry hostname |
repositories | list of strings | Repository path |
tags | list of strings | Version tag |
annotations | map | OCI annotations, key and value |
expression | CEL string | Any expression over the artifact context |
Unknown keys under match are silently ignored rather than rejected. A misspelled selector therefore leaves the match empty, and an empty match applies the policy to every artifact. This is the one place where a typo fails open rather than closed, so check selector spelling against the table above.
Local refs
A local kit ref is an artifact you packed with kit pack and have not pushed to a registry. Agent Guard resolves it the way the kit CLI does: a ref whose first segment has no dot or colon is not a registry hostname, so my-agent:v1 and localhost/my-agent:v1 both resolve against the kitops on-disk store. In policy, both appear as artifact.registry == "localhost".
Local refs admit normally until you configure trusted cosign keys. Once you do, admission has nothing to check them against: cosign fetches signatures from a registry, and a local ref has no registry. So they are blocked rather than silently allowed.
Loading an ArtifactPolicy is what unblocks them. With at least one ArtifactPolicy loaded, admission routes through policy evaluation instead of the signature-only path, and local refs skip cosign verification and go straight to your rules. The decision becomes yours to make explicitly:
yaml
apiVersion: gerty.jozu.dev/v1
kind: ArtifactPolicy
metadata:
name: allow-local-refs
spec:
match:
expression: |
artifact.registry == "localhost"
action: Audit
rules:
- name: record-local-ref
assert: "false"
message: "Local ref admitted for development"It is the presence of an ArtifactPolicy that moves admission onto the policy path, not anything this policy asserts. Audit never blocks, so this bundle admits local refs and records each one. Tighten the match expression or switch to Enforce when you want admission to turn on something more specific than "is it local."
GuardrailPolicy spec
A GuardrailPolicy is evaluated on inference traffic: the prompt on its way to the model, the completion on its way back, or both. It is the only kind that inspects content, which makes it the only one that can catch a secret that reached the prompt through tool calls that were each individually fine.
Its spec differs from the other two. There is no match.tool or match.artifact; scanners and direction are selected with their own top-level fields.
yaml
apiVersion: gerty.jozu.dev/v1
kind: GuardrailPolicy
metadata:
name: 01-credential-leakage
spec:
guardrails:
- "*"
direction: both
action: Enforce
rules:
- name: no-private-keys
assert: |
!guardrail.findings.exists(f,
f.category in ["body", "file", "json"] &&
f.message.matches("BEGIN (RSA |EC |OPENSSH |PGP )?PRIVATE KEY")
)
message: "A private key was detected in content bound for the model provider. Remove it and retry."| Field | Required | What it does |
|---|---|---|
guardrails | yes | Which scanners this policy applies to. "*" matches all of them |
direction | yes | input (prompt), output (completion), or both |
action | yes | Enforce, Redact or Audit |
match.request | no | Narrow by calling identity or client IP, as in a ToolPolicy |
match.expression | no | Any CEL expression over the runtime context |
rules | yes | Same shape as every other kind |
Content reaches the policy as categorized findings rather than as raw text, so rules are written against guardrail.findings and, where a scanner supplies them, guardrail.scores. Both are documented in the CEL context reference, including which finding categories exist and why a rule that checks only body misses attachment-only turns.
Redact is specific to this kind: it masks the offending content and lets the rest through, which suits data that is sensitive but not disqualifying. A credential is worth blocking outright, because a secret that reaches a third-party API has to be rotated rather than hidden.
Two limits are worth knowing before you rely on a rule.
Streamed responses are not inspected. A response delivered as a stream arrives in fragments, and evaluating each fragment would mean denying mid-stream after bytes had already reached the client, so fragments are skipped. Server-sent-event responses are passed through unevaluated for the same reason. A secret that appears only in a streamed completion is not caught. Write direction: input rules as the primary control and treat output rules as a second line for non-streamed traffic.
Redact cannot mask inside a PDF or an image. Word, Excel and PowerPoint documents can be redacted in place, because matches are re-derived against the original structured content. PDFs and images have no such path, so a Redact rule that matches inside one denies the request instead of masking it. If that is not what you want, narrow the rule so it does not match attachment findings.
File layout
Policy files live under ~/.agentguard/policies/ in a structured layout. Agent Guard manages this layout when you use agentguard policy add, but you can also drop YAML files directly into the right location for fast iteration.
~/.agentguard/policies/
├── global/
│ └── <sanitized-ref>/
│ └── <any>.yaml
└── workspaces/
└── <sha256-of-workspace-path>/
└── <sanitized-ref>/
└── <any>.yamlA single source directory can hold multiple YAML files. Every file is loaded and evaluated.
Versioning
apiVersion: gerty.jozu.dev/v1 is the current schema. Future schema changes will use a new API version (v1alpha2, v2) and the engine will support both during a deprecation window. Mixing API versions within a single source directory is allowed.
