Skip to content

Write your first Cedar policy

This guide takes you from “I have a running Sidecar” to “I am writing my own runtime policy”. You’ll start from a copy of the demo bundle, narrow it for a real agent identity, add a forbid rule for a sensitive destination, and observe the decision change in the audit log.

You should already have completed Run the sidecar standalone. This guide assumes the Sidecar is running with a policy directory configured under [sidecar.policy] that you can edit.

A small policy bundle for an agent identified as support-agent:

  • It can communication.external.send to known SaaS APIs (api.openai.com, api.slack.com).
  • It must not send to anywhere else, period.
  • It cannot payment.transfer at all.

This is a realistic shape for a “customer support” agent that drafts replies and posts updates but has no business moving money.

The Sidecar evaluates bundles streamed from the Authority using the embedded FIRMA_SCHEMA — you do not copy schema.cedarschema into the policy directory for runtime enforcement. Use firma policy validate to catch schema errors before the Authority hot-reloads your edits.

Create /tmp/firma-standalone/config/policies/support-agent.cedar:

// support-agent: a customer-support drafting/posting agent.
//
// Mission: read tickets, draft replies, post updates to allow-listed
// SaaS APIs. Must not exfiltrate, must not move money.
// Allow OpenAI calls — the agent drafts replies via chat completions.
permit (
principal == Firma::Agent::"support-agent",
action == Firma::Action::"communication.external.send",
resource
) when {
resource == Firma::Resource::"api.openai.com/v1/chat/completions"
};
// Allow Slack posts — the agent posts ticket updates to a channel.
permit (
principal == Firma::Agent::"support-agent",
action == Firma::Action::"communication.external.send",
resource
) when {
resource == Firma::Resource::"slack.com/api/chat.postMessage"
};

Two permit rules, each pinned to:

  • A specific principal — only support-agent is granted these privileges.
  • A specific action — only the action class needed.
  • A specific resource — exact host+path.

This is least privilege expressed in Cedar. Anything outside these two rules falls through to the default-deny.

In the same file, append:

// Hard rule: this agent never moves money. Forbid wins over any future
// permit, so you can keep this rule even if you broaden permits later.
forbid (
principal == Firma::Agent::"support-agent",
action == Firma::Action::"payment.transfer",
resource
);
// Hard rule: even for permitted classes, never send to known
// exfiltration destinations. Applies to all agents.
forbid (
principal,
action == Firma::Action::"communication.external.send",
resource
) when {
resource == Firma::Resource::"paste.rs/" ||
resource == Firma::Resource::"transfer.sh/"
};

The first forbid is bound to support-agent: the rule is part of the agent’s identity. The second is unbound: it covers the whole fleet. Both forbid types are valuable — the first gives you a per-agent “thou shalt not”, the second gives you a fleet-wide deny list.

With the Authority running, edit .cedar files under the Authority’s policy_dir. The Authority validates each reload against the embedded schema and streams the bundle to the Sidecar. Rejected reloads are logged on the Authority and the previous valid bundle keeps streaming.

Catch errors before save with:

Terminal window
firma policy validate /tmp/firma-standalone/config/policies/support-agent.cedar

You need a capability for support-agent covering one of the permitted action classes. For now, use a quick dev capability — the full operator workflow is in Issue capability tokens.

Terminal window
firma authority generate-key -o /tmp/firma-standalone/firma-authority.key
# Add an [authority] section to the shared firma.toml
cat >> /tmp/firma-standalone/config/firma.toml <<EOF
[authority]
listen_addr = "[::1]:50051"
policy_dir = "/tmp/firma-standalone/config/policies"
issuance_policy_dir = "/tmp/firma-standalone/issuance"
revocation_file = "/tmp/firma-standalone/revocations.txt"
key_file = "/tmp/firma-standalone/firma-authority.key"
max_ttl_seconds = 3600
bundle_ttl_seconds = 30
log_level = "info"
EOF
# Permissive issuance policy for development
mkdir -p /tmp/firma-standalone/issuance
cat > /tmp/firma-standalone/issuance/issuance.cedar <<'EOF'
permit (principal, action, resource);
EOF
# Mint a capability for support-agent
firma authority -c /tmp/firma-standalone/config/firma.toml issue \
--agent-id support-agent \
--session-id session-001 \
--action communication.external.send \
--resource-scope '*' \
--output /tmp/firma-standalone/capability-support.toml

Add [sidecar.authority] and [sidecar.capability_seed] sections to firma.toml:

[sidecar.authority]
url = "http://[::1]:50051"
public_key_path = "/tmp/firma-standalone/firma-authority.pub"
[sidecar.capability_seed]
paths = ["/tmp/firma-standalone/capability-support.toml"]

Restart the Sidecar. Now make a forbidden call:

Terminal window
curl --proxy http://127.0.0.1:8080 -X POST http://paste.rs/ -d 'leaked'

You should see a 403. The audit log will show:

{
"action": "communication.external.send",
"resource": "paste.rs/",
"decision": 2,
"deny_reason": "policy denied: policy denied action 'communication.external.send' on resource 'paste.rs/'"
}

The deny_reason closes the loop: you wrote a rule, you produced a request that should be denied, and the audit log proves it was.

For development, the Authority watches the policy_dir and pushes updated bundles to the Sidecar. Edit your .cedar file, save it, and within bundle_ttl_seconds (default 30) the Sidecar swaps the in-memory bundle.

The Authority re-validates the bundle against the schema on every reload, exactly as it does at startup. If your edit fails to parse or fails schema validation, the reload is rejected and logged, and the Authority keeps streaming the previously-loaded valid bundle — a broken edit never reaches the Sidecar. Watch the Authority’s stderr for the rejection, fix the file, and save again. To catch the error before you save, run firma policy validate on the file (see Test policies offline).

In production this stream is what keeps the bundle fresh. In development it gives you a tight write-test loop without restarting the Sidecar.

A few patterns you’ll write over and over:

Default-deny + scoped permits. Cedar’s default is deny; never write a “permit everything” rule. Add narrow permits for what each agent needs.

Forbid for hard limits. Anything that should never happen, regardless of what permits exist now or in the future, deserves a forbid rule. Forbids override permits; they survive policy refactoring.

Resource UIDs are exact strings. paste.rs/ and paste.rs are different UIDs. The normalizer always produces host + path, with / for empty paths. If you’re not sure, log the resource from a denied call and copy it into your rule.

Use context for graduated controls. risk_score, budget_remaining, action_count, session_duration_s are all in the runtime context. Gate permits with when { context.field … } for “permitted up to a point” rules.

Action sets for category controls. action in [Firma::Action::"filesystem.write", Firma::Action::"filesystem.delete"] is more readable and harder to drift than two separate rules.