Skip to content

Writing Policies

Policies are written in Cedar, an open-source language for authorization. This guide covers syntax and patterns. For the concepts behind policy-as-code, see Policies.

Getting Started

Why Policies?

Agents are autonomous but need boundaries. Policies define what actions are allowed or blocked, evaluated at six stages in the agent loop:

Stage When Example
PRE_RUN Before agent starts Session auth, rate limits
PRE_MODEL Before LLM call Block prompt injection
POST_MODEL After LLM response Filter sensitive output
PRE_TOOL Before tool runs Check arguments, spending limits
POST_TOOL After tool runs Sanitize results, block PII
POST_RUN After agent completes Audit logging

See Stages for details on each stage.

When a policy denies an action, the agent receives feedback and can try a different approach. This is called steering.

Agent: "I'll delete the temp files with rm -rf /"
Policy: DENIED - "Destructive command blocked"
Agent: "Let me try a safer approach: rm /tmp/cache/*.tmp"
Policy: ALLOWED

Without policies, agents can execute destructive commands, leak secrets, get stuck in loops, or access systems they shouldn't.


Quick Start

Hello World

The simplest policy allows everything:

permit(principal, action, resource);

This is useful for development but not production. Let's add a safety rail:

// Allow everything by default
permit(principal, action, resource);

// Block one dangerous pattern
@id("no-rm-rf")
forbid(principal, action, resource)
when { context has parameters_json && context.parameters_json like "*rm -rf*" };

How Evaluation Works

  • If any forbid policy matches → DENY (or ESCALATE if all matching forbids have @escalate)
  • If any permit policy matches and no forbidALLOW
  • If no policies match → DENY (default deny)

Forbid always wins. This means you can start permissive and add restrictions.

Starter Policy Set

A minimal starting point:

// Allow all actions by default
permit(principal, action, resource);

// Prevent runaway agents
@id("step-limit")
forbid(principal, action, resource)
when { resource has step_count && resource.step_count > 100 };

// Block dangerous commands (add more from Safety Policies section)
@id("no-rm-rf")
forbid(principal, action, resource)
when { context has parameters_json && context.parameters_json like "*rm -rf*" };

See Policy Patterns below for comprehensive safety, security, and compliance policies to add.


Policy Syntax

A Cedar policy has three parts:

// Allow an action unconditionally
@id("allow-reads")
permit(principal, action, resource);

// Block an action with a condition
@id("block-dangerous")
forbid(principal, action, resource)
when { context has parameters_json };
Part Description
@id Required identifier for the policy
permit / forbid Allow or deny the action
principal Who is making the request (the agent)
action The tool being called (e.g., My_Agent::Action::"Bash")
resource The trajectory being acted upon (has step_count)
context Data about the request: tool arguments (parameters_json) and responses (response_json)
when Conditions that must be true for the policy to match

Action Names

Cedar action names are derived from your tool names:

Tool Name Cedar Action
Bash My_Agent::Action::"Bash"
File Write My_Agent::Action::"File_Write"
send-email My_Agent::Action::"send_email"

Name Transformation Rules

  • Agent name becomes the namespace (e.g., My_Agent::)
  • Spaces become underscores: File WriteFile_Write
  • Hyphens become underscores: send-emailsend_email
  • Action names are quoted: "Bash"

About Example Action Names

Policies in this guide use placeholder names like My_Agent::Action::"Bash". Replace My_Agent with your agent's name and use your actual tool names. The schema is generated automatically from your agent definition.

Context Object

The context object contains the tool's arguments and response. There are two ways to access this data:

Field When Available Use Case
parameters_json Always (PRE_TOOL) Pattern matching with like
parameters Only if tool has JSON schema Typed access, numeric comparisons
response_json Always (POST_TOOL) Pattern matching with like
response Only if tool has JSON schema Typed access to response fields

Why two versions? parameters_json is always available as a string, so you can use like "*pattern*" for simple matching. parameters gives you typed fields (for > 1000 comparisons), but requires defining a JSON schema on your tool.

// String version - always works, use pattern matching
context.parameters_json like "*rm -rf*"

// Typed version - needs JSON schema, enables comparisons
context.parameters.amount > 1000

Always Check for Existence

Cedar errors if you access a field that doesn't exist.

For pattern matching (always works at PRE_TOOL):

when {
  context has parameters_json &&
  context.parameters_json like "*dangerous*"
};

For typed access (requires JSON schema on tool):

when {
  context has parameters &&
  context.parameters has amount &&
  context.parameters.amount > 1000
};

Custom Context Fields

Fields like context.environment, context.user_role, context.file_was_read, or context.hour shown in examples below are not automatically populated. Your application must track this state and pass these fields when calling adjudicate().

Resource Object

The resource is the current trajectory (a single agent run from start to finish). It tracks:

resource.step_count  // Total steps taken so far (across all tools)

Use step_count to limit how long an agent can run, preventing infinite loops or runaway costs. The count increments with each tool call.


Policy Patterns

Reliability Policies

These policies make agents more predictable and prevent runaway behavior.

Limit Total Steps

Stop agents that run too long. step_count tracks the total number of steps in the trajectory (across all tools):

@id("step-limit")
forbid(principal, action, resource)
when { resource has step_count && resource.step_count > 100 };

You can set different limits for different tools. This blocks expensive tools earlier in the trajectory:

// Block external API calls after 20 total steps
@id("api-call-limit")
forbid(principal, action == My_Agent::Action::"ExternalAPI", resource)
when { resource has step_count && resource.step_count > 20 };

// Block LLM calls after 10 total steps
@id("llm-tool-limit")
forbid(principal, action == My_Agent::Action::"AskClaude", resource)
when { resource has step_count && resource.step_count > 10 };

// Allow other tools up to 100 steps
@id("general-step-limit")
forbid(principal, action, resource)
when { resource has step_count && resource.step_count > 100 };

Step Count is Trajectory-Wide

step_count is the total steps across ALL tools, not per-tool. To track per-tool call counts, your application must maintain that state and pass it via custom context fields.

Enforce Workflow Order

Require certain actions before others. These examples use custom context fields that your application must populate:

// Must read a file before writing to it (prevents blind overwrites)
// Requires your app to set context.file_was_read = true after reads
@id("read-before-write")
forbid(principal, action == My_Agent::Action::"FileWrite", resource)
when {
  context has parameters &&
  context.parameters has path &&
  !(context has file_was_read && context.file_was_read == true)
};

// Must run tests before committing
// Requires your app to set context.tests_passed = true after test runs
@id("test-before-commit")
forbid(principal, action == My_Agent::Action::"GitCommit", resource)
when {
  !(context has tests_passed && context.tests_passed == true)
};

Note

file_was_read and tests_passed are custom context fields your app must provide. See Custom Context Fields above.

Output Validation

Ensure tool responses meet expectations:

// Block empty responses that might indicate failures
@id("no-empty-responses")
forbid(principal, action, resource)
when {
  context has response_json &&
  (context.response_json == "{}" ||
   context.response_json == "null" ||
   context.response_json == "\"\"")
};

Safety Policies

These policies prevent agents from causing harm, whether intentional or accidental.

Block Dangerous Commands

@id("no-destructive-bash")
forbid(principal, action == My_Agent::Action::"Bash", resource)
when {
  context has parameters_json &&
  (context.parameters_json like "*rm -rf /*" ||
   context.parameters_json like "*rm -rf ~*" ||
   context.parameters_json like "*rm -rf .*" ||
   context.parameters_json like "*rm -fr /*" ||
   context.parameters_json like "*rm -fr ~*" ||
   context.parameters_json like "*rm -fr .*" ||
   context.parameters_json like "*mkfs*" ||
   context.parameters_json like "*dd if=/dev/zero*" ||
   context.parameters_json like "*dd of=/dev/*" ||
   context.parameters_json like "*> /dev/sda*" ||
   context.parameters_json like "*format c:*")
};

Block Sensitive Paths

@id("no-system-writes")
forbid(principal, action == My_Agent::Action::"FileWrite", resource)
when {
  context has parameters_json &&
  (context.parameters_json like "*\"/etc/*" ||
   context.parameters_json like "*\"/usr/*" ||
   context.parameters_json like "*\"/bin/*" ||
   context.parameters_json like "*\"/sbin/*" ||
   context.parameters_json like "*\"/boot/*")
};

@id("no-ssh-access")
forbid(principal, action, resource)
when {
  context has parameters_json &&
  context.parameters_json like "*/.ssh/*"
};

Workspace Sandboxing

Only allow file operations within a specific directory. This requires not using the blanket permit(principal, action, resource); for file operations:

Option 1: Default-deny approach (no blanket permit for files)

// Allow reads only in workspace
permit(principal, action == My_Agent::Action::"FileRead", resource)
when {
  context has parameters_json &&
  context.parameters_json like "*\"/workspace/*"
};

// Allow writes only in workspace
permit(principal, action == My_Agent::Action::"FileWrite", resource)
when {
  context has parameters_json &&
  context.parameters_json like "*\"/workspace/*"
};

// No blanket permit for file operations - default deny kicks in
// Other tools can have their own permits
permit(principal, action == My_Agent::Action::"Bash", resource);

Option 2: Blanket permit with forbid overrides

// Allow everything by default
permit(principal, action, resource);

// Block file operations outside workspace
@id("workspace-only-reads")
forbid(principal, action == My_Agent::Action::"FileRead", resource)
when {
  context has parameters_json &&
  !(context.parameters_json like "*\"/workspace/*")
};

@id("workspace-only-writes")
forbid(principal, action == My_Agent::Action::"FileWrite", resource)
when {
  context has parameters_json &&
  !(context.parameters_json like "*\"/workspace/*")
};

Prompt Injection Defense

Block common prompt injection patterns in user inputs:

@id("prompt-injection-defense")
forbid(principal, action == My_Agent::Action::"Prompt", resource)
when {
  context has parameters_json &&
  (context.parameters_json like "*ignore previous instructions*" ||
   context.parameters_json like "*ignore all previous*" ||
   context.parameters_json like "*disregard above*" ||
   context.parameters_json like "*forget your instructions*" ||
   context.parameters_json like "*you are now*" ||
   context.parameters_json like "*new persona*" ||
   context.parameters_json like "*jailbreak*" ||
   context.parameters_json like "*DAN mode*")
};

Block Harmful Outputs

Prevent the agent from generating dangerous content:

@id("no-malicious-output")
forbid(principal, action, resource)
when {
  context has response_json &&
  (context.response_json like "*<script>*" ||
   context.response_json like "*javascript:*" ||
   context.response_json like "*onclick=*" ||
   context.response_json like "*onerror=*")
};

Spending Limits

These examples require parameters_json_schema on the TransferMoney tool for typed access:

// Hard limit: block transactions over $10,000
@id("transaction-hard-limit")
forbid(principal, action == My_Agent::Action::"TransferMoney", resource)
when {
  context has parameters &&
  context.parameters has amount &&
  context.parameters.amount > 10000
};

// Soft limit: require approval for $1,000-$10,000
// Your app must set context.approved = true after human approval
@id("transaction-approval-required")
forbid(principal, action == My_Agent::Action::"TransferMoney", resource)
when {
  context has parameters &&
  context.parameters has amount &&
  context.parameters.amount > 1000 &&
  context.parameters.amount <= 10000 &&
  !(context has approved && context.approved == true)
};

Security Policies

These policies protect secrets, credentials, and system access.

Prevent Secret Leakage

Block API keys and credentials from appearing in outputs:

@id("no-secrets-in-output")
forbid(principal, action, resource)
when {
  context has response_json &&
  (context.response_json like "*sk-*" ||           // OpenAI API keys
   context.response_json like "*sk_live_*" ||      // Stripe keys
   context.response_json like "*sk_test_*" ||      // Stripe test keys
   context.response_json like "*AKIA*" ||          // AWS access keys
   context.response_json like "*ghp_*" ||          // GitHub personal tokens
   context.response_json like "*gho_*" ||          // GitHub OAuth tokens
   context.response_json like "*glpat-*" ||        // GitLab tokens
   context.response_json like "*xoxb-*" ||         // Slack bot tokens
   context.response_json like "*xoxp-*")           // Slack user tokens
};

@id("no-password-patterns")
forbid(principal, action, resource)
when {
  context has response_json &&
  (context.response_json like "*password=*" ||
   context.response_json like "*password\":*" ||
   context.response_json like "*passwd=*" ||
   context.response_json like "*secret=*" ||
   context.response_json like "*api_key=*" ||
   context.response_json like "*apikey=*")
};

Block Credential File Access

@id("no-credential-files")
forbid(principal, action, resource)
when {
  context has parameters_json &&
  (context.parameters_json like "*/.env*" ||
   context.parameters_json like "*/.netrc*" ||
   context.parameters_json like "*/.aws/credentials*" ||
   context.parameters_json like "*/.config/gcloud*" ||
   context.parameters_json like "*/credentials.json*" ||
   context.parameters_json like "*/secrets.yaml*" ||
   context.parameters_json like "*/secrets.json*" ||
   context.parameters_json like "*/.npmrc*" ||
   context.parameters_json like "*/.pypirc*")
};

Privilege Escalation Prevention

@id("no-privilege-escalation")
forbid(principal, action == My_Agent::Action::"Bash", resource)
when {
  context has parameters_json &&
  (context.parameters_json like "*sudo *" ||
   context.parameters_json like "*sudo\t*" ||
   context.parameters_json like "*su -*" ||
   context.parameters_json like "*su root*" ||
   context.parameters_json like "*chmod 777*" ||
   context.parameters_json like "*chmod +s*" ||
   context.parameters_json like "*chown root*" ||
   context.parameters_json like "*setuid*")
};

Network Access Control

Restrict which domains the agent can access:

// Allow only approved domains (requires parameters_json_schema for typed access)
@id("approved-domains-only")
forbid(principal, action == My_Agent::Action::"HttpRequest", resource)
when {
  context has parameters &&
  context.parameters has url &&
  !(context.parameters.url like "*api.example.com*" ||
    context.parameters.url like "*internal.company.com*" ||
    context.parameters.url like "*github.com*")
};

// Block known dangerous destinations (pattern matching works without schema)
@id("block-dangerous-urls")
forbid(principal, action == My_Agent::Action::"HttpRequest", resource)
when {
  context has parameters_json &&
  (context.parameters_json like "*localhost*" ||
   context.parameters_json like "*127.0.0.1*" ||
   context.parameters_json like "*0.0.0.0*" ||
   context.parameters_json like "*169.254.*" ||      // Link-local
   context.parameters_json like "*metadata.google*")  // Cloud metadata
};

Database Security

// Block all write operations (forbid approach is safer than permit)
@id("read-only-database")
forbid(principal, action == My_Agent::Action::"DatabaseQuery", resource)
when {
  context has parameters_json &&
  (context.parameters_json like "*INSERT *" ||
   context.parameters_json like "*INSERT\t*" ||
   context.parameters_json like "*UPDATE *" ||
   context.parameters_json like "*UPDATE\t*" ||
   context.parameters_json like "*DELETE *" ||
   context.parameters_json like "*DELETE\t*" ||
   context.parameters_json like "*DROP *" ||
   context.parameters_json like "*TRUNCATE *" ||
   context.parameters_json like "*ALTER *" ||
   context.parameters_json like "*CREATE *")
};

// Block SQL injection patterns
@id("no-sql-injection")
forbid(principal, action == My_Agent::Action::"DatabaseQuery", resource)
when {
  context has parameters_json &&
  (context.parameters_json like "*; DROP*" ||
   context.parameters_json like "*; DELETE*" ||
   context.parameters_json like "*' OR '1'='1*" ||
   context.parameters_json like "*\" OR \"1\"=\"1*" ||
   context.parameters_json like "*UNION SELECT*")
};

Forbid vs Permit for Security

Use forbid to block dangerous operations rather than permit to allow safe ones. A forbid-based approach is safer because it blocks known-bad patterns, while a permit-based approach might miss edge cases.


Compliance Policies

These policies help meet regulatory and organizational requirements.

PII Protection (GDPR/CCPA)

Block personally identifiable information from appearing in outputs:

@id("no-pii-in-output")
forbid(principal, action, resource)
when {
  context has response_json &&
  (context.response_json like "*SSN:*" ||
   context.response_json like "*social security*" ||
   context.response_json like "*driver's license*" ||
   context.response_json like "*passport number*" ||
   context.response_json like "*credit card*" ||
   context.response_json like "*bank account*")
};

Email Detection Limitations

Pattern-based email detection (e.g., *@*.com*) produces many false positives. For robust PII detection, consider using a dedicated PII scanner before passing data to the harness, then set a context flag like context.contains_pii.

Escalation with @escalate

Use the @escalate annotation to flag actions that require approval before proceeding. When a forbid policy with @escalate matches, the harness returns Decision.ESCALATE instead of Decision.DENY, signaling that the action should be paused for approval rather than blocked outright.

// Require approval for production deployments
@id("production-approval-required")
@escalate("ops-team")
@reason("Production deployments require ops team approval")
forbid(principal, action == My_Agent::Action::"Deploy", resource)
when {
  context has environment &&
  context.environment == "production"
};

// Require approval for customer-facing communications
@id("customer-email-approval")
@escalate("customer-success")
@reason("Customer emails require review before sending")
forbid(principal, action == My_Agent::Action::"SendEmail", resource)
when {
  context has parameters &&
  context.parameters has recipient_type &&
  context.parameters.recipient_type == "customer"
};

// Require approval for data deletion
@id("deletion-approval")
@escalate
@reason("Data deletion requires approval")
forbid(principal, action == My_Agent::Action::"DeleteRecord", resource);

Key points:

  • @escalate is only valid on forbid policies
  • The optional argument (e.g., @escalate("ops-team")) is available as annotation.escalate_arg for routing
  • If ANY forbid without @escalate matches, the result is DENY (hard deny wins)
  • Use @reason to provide a human-readable explanation

See Decisions for how to handle Decision.ESCALATE in your code.

Role-Based Access

Restrict actions based on user roles. Your application must populate context.user_role:

// Requires your app to pass context.user_role (string)
@id("admin-only-config")
forbid(principal, action == My_Agent::Action::"UpdateConfig", resource)
when {
  !(context has user_role && context.user_role == "admin")
};

@id("finance-only-transactions")
forbid(principal, action == My_Agent::Action::"TransferMoney", resource)
when {
  !(context has user_role &&
    (context.user_role == "finance" || context.user_role == "admin"))
};

Reference

Operators Reference

Comparison Operators

Operator Description Example
== Equals action == Agent::Action::"Bash"
!= Not equals context.role != "SYSTEM"
< Less than context.parameters.amount < 100
<= Less than or equal context.parameters.count <= 10
> Greater than context.parameters.amount > 1000
>= Greater than or equal resource.step_count >= 50

String Operators

Operator Description Example
like Wildcard match context.parameters_json like "/tmp/*"
== Exact match context.parameters.tool == "Bash"

Wildcard Patterns

  • * matches any characters (including none)
  • "*.txt" matches "file.txt" and ".txt"
  • "*rm*" matches "rm", "rm -rf", and "perform" (careful!)
  • Escape literal asterisks with \*

Logical Operators

Operator Description Example
&& And a > 0 && b > 0
\|\| Or a == 1 \|\| a == 2
! Not !(context has approved)

Existence Check

Always check if a field exists before accessing it:

// Check single field
context has parameters_json

// Check nested field
context has parameters && context.parameters has amount

Testing Policies

Test policies without running your full agent. The SDK includes a complete example:

# Clone the repo and run the example
git clone https://github.com/sondera-ai/sondera-harness-python.git
cd sondera-harness-python
uv run python examples/cedar/coding_agent.py

The example tests multiple scenarios:

  • Allow: Reading files, glob searches, safe bash commands
  • Deny: Writing to .env files, editing SSH keys, dangerous bash commands, fetching from untrusted URLs

See examples/cedar/coding_agent.py for the full source.

Testing Policy Changes Against Historical Trajectories

When updating policies, validate that new rules don't allow previously-denied actions. This pattern replays historical trajectories against your new policy set:

from sondera import CedarPolicyHarness, SonderaRemoteHarness, Decision
from sondera.harness.cedar.schema import agent_to_cedar_schema

async def test_policy_change_is_safe(agent, new_policies):
    """Ensure new policy doesn't allow previously-denied actions."""
    platform = SonderaRemoteHarness()
    local = CedarPolicyHarness(policy_set=new_policies, schema=agent_to_cedar_schema(agent))

    for traj_summary in await platform.list_trajectories(agent_id=agent.id):
        traj = await platform.get_trajectory(traj_summary.id)
        if traj is None:
            continue

        await local.initialize(agent=agent)
        for adj_step in traj.steps:
            new_result = await local.adjudicate(
                adj_step.step.stage, adj_step.step.role, adj_step.step.content
            )
            # If old policy denied, new policy must also deny
            if adj_step.adjudication.decision == Decision.DENY:
                assert new_result.decision == Decision.DENY, (
                    f"Regression: {adj_step.step.stage.value} was denied, now allowed"
                )
        await local.finalize()

This approach catches policy regressions before they reach production.


More Resources