MAPL Policy Guide
A Tutorial for the MACAW Agentic Policy Language
Version: 3.7.1
Last Updated: November 2, 2026
Introduction
Welcome to the MAPL (MACAW Agentic Policy Language) tutorial! This guide will teach you how to write security policies for AI agent systems.
What is MAPL?
MAPL is a declarative policy language designed specifically for agentic AI systems. Unlike traditional access control systems designed for static files and databases, MAPL handles the unique challenges of AI agents:
- Agents that morph identities and delegate capabilities
- Systems that scale 10-100x per user through agent multiplication
- Dynamic workflows where agents spawn sub-agents and invoke tools
Why MAPL is Different
Traditional policy systems require O(M×N) rules for M principals and N resources. This explodes quickly:
- 1,000 users × 100 resources = 100,000 rules to manage ❌
MAPL uses hierarchical composition to achieve O(log M + N) policies:
- log(1,000) ≈ 3-4 organizational levels + 100 resource policies ✅
Three Key Innovations
Deferred Principal Binding: Principals are resolved at runtime from authenticated context (JWT claims), not hardcoded in policies. Alice's role changes? No policy updates needed.
Dual Perspective Enforcement: Caller and resource policies are enforced independently, then composed via intersection. Even if the caller is compromised, the resource enforces its own limits.
Provable Security: MAPL policies compose via mathematical intersection semantics with formal guarantees:
- Monotonic Restriction: Adding policies can only make things more restrictive, never less
- Transitive Denial: If any policy denies something, it stays denied
- No Privilege Escalation: Combining policies cannot grant denied permissions
What You'll Learn
By the end of this tutorial, you'll be able to:
- Write MAPL policies for your organization
- Use inheritance to create hierarchical policies
- Specify parameter constraints for security and compliance
- Use attestations for multi-step workflows (including conditional attestations)
- Add checks of your own with extractors, verifiers and processors
- Understand how policies compose and intersect
- Debug policy issues when things don't work as expected
Let's get started!
Part 1: Understanding MAPL
The Agentic Challenge
Traditional access control was designed for:
User → File System
"Alice can READ /etc/passwd"
But AI agents are different:
User → Agent → Sub-Agent → Tool → LLM → Database
Alice invokes AnalystAgent
→ AnalystAgent spawns DataAgent (as Alice's delegate)
→ DataAgent calls DatabaseTool
→ DatabaseTool queries SQL
→ Returns to Alice
This creates challenges:
- Contextual identity: The same agent represents different users at different times
- Delegation chains: Sub-agents inherit but restrict parent permissions
- Scale: 1 user might spawn 100 agents, creating combinatorial explosion
MAPL's Solution
Instead of writing rules like:
❌ alice CAN invoke data-agent
❌ data-agent CAN query database
❌ database CAN return financial-records
MAPL uses hierarchical composition:
✅ Company policy: "employees can access company tools"
✅ Finance BU policy: "finance team can access finance:* resources"
✅ Analyst team policy: "analysts limited to read-only, 500 token limits"
✅ Alice's runtime context: {dept: "finance", team: "analysts"}
→ Policies automatically composed at invocation time
The Composition Algebra (Simplified)
When policies compose, they follow simple rules:
Resources (what you can access):
- Parent allows A, B, C
- Child allows B, C, D
- Result: B, C (intersection - both must allow)
Denied Resources (what's explicitly blocked):
- Parent denies X
- Child denies Y
- Result: X, Y (union - either can deny)
Constraints (limits on parameters):
- Parent: max_tokens ≤ 2000
- Child: max_tokens ≤ 500
- Result: max_tokens ≤ 500 (most restrictive wins)
This means:
- ✅ Policies always get MORE restrictive down the hierarchy
- ✅ You can't accidentally grant more permissions by inheriting
- ✅ Denials always propagate (no overrides!)
Part 2: Your First MAPL Policy
The Basic Structure
A MAPL policy is a JSON document with this structure:
{
"policy_id": "unique-identifier",
"extends": "parent-policy-id",
"resources": ["resource-patterns"],
"denied_resources": ["denial-patterns"],
"attestations": ["attestation-requirements"],
"constraints": {
"parameters": {},
"denied_parameters": {},
"attestations": {}
}
}
Your First Policy
Let's create a simple policy for a financial analyst:
{
"policy_id": "user:alice",
"version": "1.0",
"description": "Alice - Financial Analyst",
"resources": [
"llm:openai/chat.completions",
"tool:database/query"
],
"denied_resources": [
"admin:**",
"secrets:**"
],
"constraints": {
"parameters": {
"llm:openai/chat.completions": {
"max_tokens": {"max": 500}
}
}
}
}
What this policy does:
- ✅ Alice can use OpenAI chat completions
- ✅ Alice can query the database
- ❌ Alice cannot access admin resources
- ❌ Alice cannot access the secrets store
- ⚠️ Alice's OpenAI requests are limited to 500 tokens
Understanding Each Section
policy_id
"policy_id": "user:alice"
- Unique identifier for this policy
- Convention:
{scope}:{name}(e.g.,company:acme,bu:finance,team:analysts,user:alice) - Starts with a letter or digit, then letters, digits, spaces and
- _ : / @ . #(e.g.,user:alice@acme.com,order:#W2611340) - Used for inheritance and reference
version
"version": "1.0"
- Semantic versioning for policy tracking
- Helps with auditing and rollback
description
"description": "Alice - Financial Analyst"
- Human-readable explanation
- Helps other admins understand the policy's purpose
resources
"resources": [
"llm:openai/chat.completions",
"tool:database/query"
]
- Arrays of operation patterns (not resources + actions!)
- In MAPL, resources ARE operations:
llm:openai/chat.completions- the operation of creating a chat completiontool:database/query- the operation of querying the database
- Supports wildcards (more on this below)
- Left out,
resourceshas no opinion: the other levels decide.[]allows nothing at request time; in anextendschild it inherits the parent's resources.
denied_resources
"denied_resources": [
"admin:**",
"secrets:**"
]
- Explicit blocks that override allows
- Denials always win in conflicts
- Used for sensitive resources that should never be accessed
- Patterns match operations, not the values passed to them. To block a file a tool reads, deny the parameter value with
denied_parameters
constraints
"constraints": {
"parameters": {
"llm:openai/chat.completions": {
"max_tokens": {"max": 500}
}
}
}
- Limits on what parameters can be passed to operations
- We'll cover this in detail in Part 4
Resource Patterns and Wildcards
MAPL supports hierarchical resource naming with wildcards:
| Pattern | Meaning | Examples |
|---|---|---|
llm:openai/chat.completions |
Exact match | Only that specific operation |
llm:openai/* |
Single-level wildcard | llm:openai/chat.completions, llm:openai/embeddings |
llm:openai/** |
Multi-level wildcard | All OpenAI LLM operations at any depth, but not llm:openai itself |
llm:** |
All LLMs | Any LLM provider, any operation |
tool:**/generate |
Any depth before a segment | tool:generate, tool:openai/generate, tool:openai/v2/generate |
file:**/.env |
A name at any depth | file:.env, file:app/.env, file:/home/me/.env, not file:prod.env (that is file:**/*.env) |
** |
Everything | All resources (use with caution!) |
A resource is a domain, the text before the first colon, and a path of segments separated by /. A pattern's domain must equal the resource's; a pattern without a domain matches no resource that has one. Within a segment, * matches any characters. A whole segment ** matches zero or more segments, so the separators on either side of it merge (a/**/b matches a/b); at the end of a pattern it matches one or more, so llm:openai/** does not match llm:openai.
Examples:
{
"resources": [
"llm:openai/*", // All OpenAI operations
"tool:database/query", // Specific operation
"file:data/*/read" // Read any file in data/
]
}
Why Resources ARE Operations
You might wonder: "Where are the actions like read, write, execute?"
In traditional systems:
Resource: /etc/passwd
Actions: [read, write, execute]
In MAPL (agentic/RPC world):
Operations:
- file:/etc/passwd/read ← operation
- file:/etc/passwd/write ← operation
- file:/etc/passwd/execute ← operation
The resource path INCLUDES the operation. This is because:
Tools are callable operations by nature:
llm:openai/chat.completions- the operation IS creating completionstool:database/query- the operation IS querying
Granularity is in the path, not separate actions:
- Instead of:
database:users+ action=read - MAPL uses:
database:users/read(clearer!)
- Instead of:
No combinatorial explosion:
- Traditional: M resources × N actions = M×N rules
- MAPL: Resources encode operations = M patterns
Wildcards group operations naturally:
database:users/*- all user database operationsdatabase:*/read- read from any database table
Part 3: Policy Inheritance with extends
Why Inheritance?
Imagine you have:
- 1 company
- 5 business units
- 20 teams
- 500 users
Without inheritance, you'd need to repeat common rules in all 500 user policies. Inheritance lets you:
✅ Define common rules once (company level)
✅ Specialize at each level (BU, team, user)
✅ Scale from O(M×N) rules to O(log M + N) policies
Your First Inheritance Chain
Let's build a 3-level hierarchy:
Level 1: Company Policy
{
"policy_id": "company:FinTech",
"description": "Company-wide base policy",
"resources": [
"llm:openai/*"
],
"denied_resources": [
"secrets:**"
],
"constraints": {
"rate_limit": 100,
"parameters": {
"llm:openai/chat.completions": {
"max_tokens": {"max": 4000}
}
}
}
}
Level 2: Business Unit Policy (extends company)
{
"policy_id": "bu:Analytics",
"extends": "company:FinTech",
"description": "Analytics BU - enforces deterministic results",
"constraints": {
"rate_limit": 50,
"parameters": {
"llm:openai/chat.completions": {
"max_tokens": {"max": 2000},
"temperature": {"max": 0.3}
}
}
}
}
Level 3: User Policy (extends BU)
{
"policy_id": "user:alice",
"extends": "bu:Analytics",
"description": "Alice - Analyst",
"resources": [
"llm:openai/chat.completions"
],
"constraints": {
"rate_limit": 10,
"parameters": {
"llm:openai/chat.completions": {
"model": ["gpt-3.5-turbo"],
"max_tokens": {"max": 500}
}
}
},
"denied_resources": [
"data:executive/*"
]
}
How Inheritance Works: Step-by-Step Resolution
When Alice makes a request, MAPL resolves the policy chain:
company:FinTech → bu:Analytics → user:alice
Step 1: Start with Company Policy
Resources: [llm:openai/*]
Denied: [secrets:**]
max_tokens: 4000
rate_limit: 100
Step 2: Intersect with BU Policy
Resources: [llm:openai/*] (unchanged - BU didn't specify)
Denied: [secrets:**] (unchanged - BU didn't add denials)
max_tokens: min(4000, 2000) = 2000 ← More restrictive!
temperature: 0.3 ← New constraint added
rate_limit: min(100, 50) = 50 ← More restrictive!
Step 3: Intersect with Alice's Policy
Resources: [llm:openai/chat.completions] ← More specific!
(intersection of llm:openai/* and llm:openai/chat.completions)
Denied: [secrets:**, data:executive/*] ← Alice adds executive denial
max_tokens: min(2000, 500) = 500 ← Most restrictive!
temperature: 0.3 (Alice didn't override)
model: [gpt-3.5-turbo] ← Alice adds model restriction
rate_limit: min(50, 10) = 10 ← Most restrictive!
Final Effective Policy for Alice:
{
"resources": ["llm:openai/chat.completions"],
"denied_resources": ["secrets:**", "data:executive/*"],
"constraints": {
"rate_limit": 10,
"parameters": {
"llm:openai/chat.completions": {
"model": ["gpt-3.5-turbo"],
"max_tokens": {"max": 500},
"temperature": {"max": 0.3}
}
}
}
}
The Intersection Rules
Here's how different fields merge during inheritance:
| Field | Merge Strategy | Example |
|---|---|---|
| resources | Domain-Aware Extend+Restrict | Parent [finance:**, tool:calc] + Child [finance:trading/*] = [finance:trading/*, tool:calc] |
| denied_resources | Union | Parent [X] + Child [Y] = [X,Y] |
| max | Minimum | Parent max:2000 + Child max:500 = max:500 |
| min | Maximum | Parent min:0 + Child min:10 = min:10 |
| range | Intersection | Parent [0,2000] + Child [0,500] = [0,500] |
| allowed_values | Intersection | Parent ["A","B","C"] + Child ["B","C"] = ["B","C"] |
| max_length, max_items | Minimum | Parent max_length:100 + Child max_length:10 = max_length:10 |
| min_length, min_items | Maximum | Parent min_items:1 + Child min_items:3 = min_items:3 |
| type, pattern | Both apply | Parent pattern:"^[a-z]+$" + Child pattern:"^.{1,8}$" = a value must match both |
| List shorthand | Both apply | Parent ["gpt-4", "gpt-3.5-turbo"] + Child ["gpt-4"] = only gpt-4 passes |
| validity | Overlap | Parent January–December + Child June–September = June–September |
The key principle: Most restrictive always wins! The same constraint rules apply when policies from different levels (company, team, user, service) combine at request time. Resources differ: at request time, levels intersect directly, so a level that names no patterns in a domain allows nothing in it. Domain-aware inheritance (below) applies only through extends.
Which levels load. The identity provider's claims name the requester's company, business unit and team, and each of those loads its own policy, alongside the requester's user policy. For a request that passed through services, the requester is the person who started it (see Chained Invocations). Each one resolves its own extends first; then all of them intersect. Keep the claims and the extends chains consistent: if a claim names a different business unit than a team's extends chain, both apply, and the result can only be more restrictive. Each level takes one value from the claims; map several IdP groups to one team in the claims mapping.
Note on resources: Resource inheritance uses domain-aware logic (not simple set intersection). If a child doesn't mention a domain (e.g., tool:), it automatically inherits the parent's resources for that domain. If the child specifies patterns in a domain, its patterns intersect the parent's in that domain: what the child leaves out of a domain it names is not inherited. This is what enables O(log M + N) complexity - children don't need to re-specify everything they inherit!
Visual Example: Range Intersection
Company Policy:
max_tokens: [------------ 0 to 4000 ------------]
BU Analytics:
max_tokens: [------ 0 to 2000 ------]
User Alice:
max_tokens: [-- 0 to 500 --]
Final Result:
max_tokens: [-- 0 to 500 --] ← Intersection = most restrictive
Visual Example: Allowed Values Intersection
Company Policy:
model: [gpt-3.5-turbo, gpt-4, gpt-4-turbo]
BU Analytics:
model: [gpt-3.5-turbo, gpt-4] (removes gpt-4-turbo)
User Alice:
model: [gpt-3.5-turbo] (removes gpt-4)
Final Result:
model: [gpt-3.5-turbo] ← Intersection
Advanced: Domain-Aware Resource Inheritance
Why domain-aware inheritance matters: This is one of the core innovations that enables MAPL to achieve O(log M + N) complexity instead of O(M×N). Without it, every child policy would need to re-specify every resource it wants, defeating the purpose of hierarchical composition.
The Algorithm: Restrict Per Domain
Resource inheritance works differently than simple set intersection. Here's the actual algorithm:
- Group by domain: Group parent and child patterns by domain prefix (
finance:,tool:,llm:, etc.) - Intersect named domains: In each domain the child names, keep what both the parent's and the child's patterns allow
- Inherit unnamed domains: In each domain the child doesn't name, keep the parent's patterns as-is
Result: Child inherits the domains it doesn't mention, and in the domains it does mention, gets only what it lists and the parent allows. If the parent allows tool:trade/*, tool:reports/* and tool:database/query, a child that lists tool:trade/* and tool:reports/* no longer has tool:database/query.
Example 1: Automatic Inheritance Without Re-Specification
This is the key use case - child doesn't need to re-list everything:
// Parent: BU Finance
{
"policy_id": "bu:finance",
"resources": [
"finance:**", // All finance operations
"tool:calculator", // Calculator tool
"tool:analyzer", // Analyzer tool
"report:*" // All reports
]
}
// Child: Trading Team
{
"policy_id": "team:trading",
"extends": "bu:finance",
"resources": [
"finance:trading/*", // Restrict finance domain
"finance:positions/*" // Additional finance restriction
]
// NOTE: Child doesn't mention tool: or report: domains!
}
// Final Effective Policy for team:trading
{
"resources": [
"finance:trading/*", // ← Restricted from finance:**
"finance:positions/*", // ← Additional restriction
"tool:calculator", // ← Inherited (child didn't mention tool: domain)
"tool:analyzer", // ← Inherited (child didn't mention tool: domain)
"report:*" // ← Inherited (child didn't mention report: domain)
]
}
Why this works:
- Child specifies patterns in
finance:domain → restricts parent'sfinance:**to specific sub-patterns - Child doesn't specify anything in
tool:domain → inheritstool:calculatorandtool:analyzerautomatically - Child doesn't specify anything in
report:domain → inheritsreport:*automatically
Without domain-aware inheritance, the child would need to manually list tool:calculator, tool:analyzer, and report:* - defeating the purpose of hierarchical composition!
A child can only narrow what the parent's patterns cover. * is one level: finance:* covers finance:audit, not finance:trading/buy. For a child to narrow to deeper paths such as finance:trading/*, the parent must allow them with finance:**.
The :base Fallback
What happens when a policy doesn't exist? MAPL loads {namespace}:base instead.
Every call loads the policy of the resource it calls (tool:bash for a call to tool:bash) and the policy of each level the caller's identity names (user:alice, team:traders). Ask for tool:custom-scanner when no such policy exists, and MAPL loads tool:base.
extends is different: it names the parent, and the parent must exist. A policy whose parent is missing denies everything until its extends names one that exists, so deleting a parent can only narrow what its children allow. To inherit from the namespace default, name it: "extends": "team:base".
// Requested: data:finance/transactions (no policy exists)
// Loaded: data:base (the namespace default)
Why this matters: you write one policy per namespace instead of one per object.
{
"policy_id": "data:base",
"version": "1.0",
"description": "Default rules for every dataset",
"resources": ["tool:reporting/*"],
"denied_resources": ["tool:*/export", "tool:*/send"]
}
That single policy covers every data: object you haven't written a policy for. Write per-object policies only for the exceptions.
The fallback is one level deep, to the namespace. data:finance/transactions falls back to data:base — not to data:finance. Path segments are not a hierarchy; they're part of the name.
If you want a middle tier, declare it:
{
"policy_id": "data:finance/transactions",
"extends": "data:finance"
}
Create a :base for every namespace you use. Without it, a missing policy has nothing to fall back to, and that layer adds nothing to the call. Object policies are the exception: a reference that resolves to nothing denies the call — see Part 6.
Service Agent Policies (app: prefix)
So far we've focused on organizational policies (company, BU, team, user). But MAPL also supports service agent policies that define what services themselves allow.
The Dual Perspective Model
MAPL enforces policies from two perspectives:
- Caller Perspective (user:alice): What can Alice request?
- Service Perspective (app:openai-service): What does the OpenAI service allow?
The final effective policy is the intersection of both:
Effective Policy = Caller Policies ∩ Service Policies
This means BOTH must allow an operation for it to succeed.
Chained Invocations
A service often calls other tools while it handles a request. An assistant hands a payment to an orchestrator, and the orchestrator calls the bank. The request keeps the identity of the person who started it, and each service it passes through is recorded with it. When the bank's tool is called, the caller perspective is that person and every service in between:
maya → app:assistant → app:orchestrator → tool:banking/transfer
| Layer | Policies |
|---|---|
| The person who started the request | user:maya, and her company, business unit and team |
| Each service the request passed through | app:assistant, app:orchestrator |
| The service and the tool being called | app:banking, tool:banking/transfer |
They all intersect, as for a single call. Two things follow:
- A service's
app:policy lists what it calls for others. For payments to pass through the assistant,app:assistantmust allowtool:banking/*. A service whose policy leaves banking out cannot cause a transfer, whoever asks it. - The identity is the person's. Their organizational levels apply, and claim-based attestations read their token. A service in between adds its own limits and never its own identity.
A call made directly is a chain of one: its caller is the person who started it.
Why No Overrides?
You might ask: "What if I need to give Alice emergency access?"
MAPL explicitly prohibits overrides. Why?
- Provable security: The theorems (Monotonic Restriction, Transitive Denial) don't hold with overrides
- Audit nightmare: If policies can be overridden, you can't trust the policy hierarchy
- Privilege escalation risk: Overrides create paths to bypass restrictions
Instead, use a time-bounded policy:
{
"policy_id": "team:incident-response",
"validity": {
"not_before": "2025-01-17T09:00:00Z",
"not_after": "2025-01-17T17:00:00Z"
},
"resources": ["admin:**"],
"attestations": ["incident_approval"],
"constraints": {
"rate_limit": 10,
"attestations": {
"incident_approval": {"approval_criteria": "role:incident-commander", "timeout": 300}
}
}
}
Bind Alice to team:incident-response for the window through her identity provider group. A policy holds only inside its validity window: outside it, the policy refuses every call, so a membership left in place grants nothing. not_before and not_after are inclusive, and each needs a UTC offset (Z or +00:00); a policy whose timestamp has none denies every call. Like every policy, it can only narrow — admin:** must already be allowed at the company or BU level.
Part 4: Parameter Constraints
What Are Parameter Constraints?
Parameter constraints limit the values passed to operations, not just which operations are allowed.
Example scenario:
- ✅ Alice can call
llm:openai/chat.completions - ⚠️ But her
max_tokensmust be ≤ 500 - ⚠️ And she can only use
gpt-3.5-turbo(not gpt-4)
This prevents:
- Cost overruns: Limit token usage
- Security issues: Block dangerous parameter values (injection attacks)
- Compliance violations: Restrict parameters to approved values
Basic Constraint Structure
Parameter constraints are nested under constraints.parameters. A constraint applies when the call sends that parameter; a parameter the call omits is not checked: a parameter constraint cannot require one to be present. A verifier can refuse a call that leaves a parameter out (see Part 7).
{
"constraints": {
"parameters": {
"operation-pattern": {
"parameter_name": constraint_specification
}
}
}
}
Example:
{
"constraints": {
"parameters": {
"llm:openai/chat.completions": {
"max_tokens": {"max": 500},
"model": ["gpt-3.5-turbo"],
"temperature": {"type": "number", "range": [0.0, 0.8]}
}
}
}
}
Constraint Types
1. Type Validation
Ensure parameters match expected types:
{
"constraints": {
"parameters": {
"llm:openai/chat.completions": {
"max_tokens": {"type": "integer"},
"temperature": {"type": "number"},
"model": {"type": "string"},
"stream": {"type": "boolean"},
"messages": {"type": "array"}
}
}
}
}
Supported types:
integer- Whole numbers (1, 2, 100)number- Integers or floats (1, 1.5, 3.14)string- Text valuesboolean- true or falsearray- Listsobject- Dictionaries
2. Numeric Constraints
Control numeric ranges:
{
"constraints": {
"parameters": {
"llm:openai/chat.completions": {
"max_tokens": {
"type": "integer",
"min": 1,
"max": 4000
},
"temperature": {
"type": "number",
"range": [0.0, 2.0]
}
},
"finance:transfer": {
"amount": {
"type": "number",
"min": 0,
"max": 1000000
}
}
}
}
}
Available numeric constraints:
min- Minimum value (inclusive)max- Maximum value (inclusive)range- Array[min, max](shorthand for min+max)
Note: If a constraint sets both min/max and range, all of them apply.
Always set type with numeric constraints. min, max and range apply only when the value is a number: without "type": "number" (or "integer"), a string such as "15000" passes {"max": 1000}.
3. String Constraints
Control string values:
{
"constraints": {
"parameters": {
"llm:openai/chat.completions": {
"model": {
"type": "string",
"allowed_values": ["gpt-3.5-turbo", "gpt-4"]
}
},
"report:generate": {
"format": {
"type": "string",
"allowed_values": ["PDF", "XLSX", "CSV"]
},
"time_period": {
"type": "string",
"pattern": "^(Q[1-4]|H[1-2]|FY)\\d{4}$"
}
},
"user:create": {
"username": {
"type": "string",
"min_length": 3,
"max_length": 32,
"pattern": "^[a-zA-Z0-9_]+$"
}
}
}
}
}
Available string constraints:
allowed_values- Array of permitted strings (enumeration);[]allows no valuepattern- Regular expression in Python syntax (must match the entire string)min_length- Minimum string lengthmax_length- Maximum string length (prevents buffer overflow, prompt injection)
4. Array Constraints
Control array sizes:
{
"constraints": {
"parameters": {
"llm:openai/chat.completions": {
"messages": {
"type": "array",
"min_items": 1,
"max_items": 100
}
},
"database:batch_insert": {
"records": {
"type": "array",
"max_items": 1000
}
}
}
}
}
Available array constraints:
min_items- Minimum array lengthmax_items- Maximum array length (prevents resource exhaustion)
Length and item limits are inclusive: max_items: 5 allows five.
5. Combining Constraints
You can combine multiple constraint types:
{
"constraints": {
"parameters": {
"llm:openai/chat.completions": {
"model": {
"type": "string",
"allowed_values": ["gpt-3.5-turbo", "gpt-4"]
},
"max_tokens": {
"type": "integer",
"min": 1,
"max": 4000
},
"temperature": {
"type": "number",
"range": [0.0, 2.0]
},
"messages": {
"type": "array",
"min_items": 1,
"max_items": 100
}
}
}
}
}
Shorthand Formats
List Shorthand
A list is a shorthand allowlist:
{
"constraints": {
"parameters": {
"llm:openai/chat.completions": {
"model": ["gpt-3.5-turbo", "gpt-4*"]
}
}
}
}
Each entry is a wildcard pattern matched against the whole value, so gpt-4* allows gpt-4 and gpt-4o. Use allowed_values for exact matches.
A single value is an exact match: "temperature": 0.7 allows only 0.7.
Denied Parameters (Injection Defense)
The constraints.denied_parameters field lets you block dangerous parameter patterns:
{
"constraints": {
"parameters": {
"llm:openai/chat.completions": {
"model": ["gpt-3.5-turbo", "gpt-4"],
"max_tokens": {"max": 4000}
}
},
"denied_parameters": {
"llm:**": {
"prompt": [
"*DROP TABLE*",
"*rm -rf*",
"*eval(*",
"*exec(*"
]
},
"tool:shell/*": {
"command": [
"*sudo*",
"*rm -*",
"*dd if=*"
]
}
}
}
}
Patterns use wildcards:
*matches any characters, including spaces,/,:, and line breaks**matches everything (same as*for parameter values)- Patterns are case-sensitive:
*DROP TABLE*≠*drop table*. So is everything else in MAPL: resource patterns, values, and policy IDs - A parameter the call omits is not checked, and neither are the expressions listed under it
- Use wildcards to match substrings:
*DROP TABLE*matches "foo DROP TABLE bar" - A pattern without
*matches only the whole value:IIdoes not matchIII - A single pattern can be written without the list:
"symbol": "GME" - For better patterns:
*rm -*(requires dash) instead of*rm *(matches "perform task")
Combining Patterns
Wrap a condition in {} when one pattern is too broad on its own:
{
"constraints": {
"denied_parameters": {
"tool:shell/*": {
"command": [
"{params.command MATCHES '*curl*' AND params.command MATCHES '*| sh*'}"
]
}
}
}
}
This denies curl https://example.com/install.sh | sh and allows curl https://example.com. The expression uses the condition syntax and may reference any parameter of the call.
How Constraints Merge During Inheritance
When policies inherit, constraints merge using the "most restrictive wins" rule:
Example: Numeric Constraints
// Company policy
{
"constraints": {
"parameters": {
"llm:openai/chat.completions": {
"max_tokens": {"max": 4000}
}
}
}
}
// BU policy (extends company)
{
"constraints": {
"parameters": {
"llm:openai/chat.completions": {
"max_tokens": {"max": 2000} // More restrictive
}
}
}
}
// User policy (extends BU)
{
"constraints": {
"parameters": {
"llm:openai/chat.completions": {
"max_tokens": {"max": 500} // Even more restrictive
}
}
}
}
// Final result: max_tokens max = 500 (most restrictive)
The same rules apply when policies from different levels combine at request time. See The Intersection Rules for every constraint.
Part 5: Attestations - Provable Workflow Dependencies
What Are Attestations?
Imagine you're building a financial trading system with an AI agent. The agent needs to:
- Verify the user's identity
- Get manager approval for large trades
- Execute the trade
Traditional approach:
# Agent memory (can be tampered with!)
agent.memory = {
"identity_verified": True, # ← Agent says it did this
"manager_approved": True # ← But can you trust it?
}
if agent.memory["identity_verified"] and agent.memory["manager_approved"]:
execute_trade() # Hope the agent isn't lying!
The problem: You're trusting the agent to honestly report its own state. If the agent is compromised (or just buggy), it can:
- Skip the identity verification step
- Lie about getting manager approval
- Execute unauthorized trades
MAPL attestations solve this through cryptographic proofs:
# Attestation (cryptographically signed proof)
attestation = {
"key": "identity_verified",
"value": {"user_id": "alice@acme.com"},
"set_by": "tool.verify_identity", # ← Who created it
"signature": "a4b8c2...", # ← Cryptographic proof
"timestamp": 1700000000,
"one_time": True # ← Self-enforcing metadata
}
The signature proves:
- ✅ The
verify_identitytool actually executed - ✅ The attestation wasn't forged or tampered with
- ✅ The metadata (one_time, time_to_live) is cryptographically bound
Attestation Types
An attestation is proven in one of four ways, and its definition says which:
| Type | Proven by | Defined by |
|---|---|---|
| Internal | a tool, after it runs | "internal": true |
| Built-in | the control plane, from the login | the platform; the names are idp_verified and phishing_resistant |
| Claim-based | the requester's token | claim |
| External | a person, when asked | approval_criteria |
Every attestation a call requires is declared, and has one type. Declare it under constraints.attestations in any policy that applies to the call; the built-in attestations are declared by the platform. A required attestation that no policy declares denies the call (Attestation 'tests_passed' is required but not declared), and so does a name that two policies declare as different types. This holds for a conditional requirement too: an undeclared name::{condition} denies the call even when its condition is false. A definition that names no type, such as {"time_to_live": 60}, only adds metadata to the type declared elsewhere. Examples in this guide that show only an attestations list are excerpts; the policy they come from also declares each attestation.
Internal Attestations (Workflow/Self-Set)
Created by tools during workflow execution. The tool itself sets the attestation after successful completion. Declare it internal in the policy that requires it, and no other policy can give the name another meaning:
{
"attestations": ["tests_passed"],
"constraints": {
"attestations": {
"tests_passed": { "internal": true }
}
}
}
Characteristics:
- Set by the tool that performed the operation
- Immediate (no waiting)
- Used for workflow sequencing ("A must happen before B")
- Kept in the caller's session: a service keeps one session per calling principal, so only that principal's later calls can use it
- One value per name: a later
attest()of the same name, for the same caller, replaces it. To keep one per record, put the record's id in the name (reservation_checked/E123) and read it in a verifier - Held in the service's memory: it does not survive a restart, and another service cannot see it
Built-in Attestations (Control-Plane Minted)
Two attestations are not set by a tool and not approved by a person. The control plane mints them at registration, signed by the tenant attestor, from evidence only it can see. Require them the same way you require any other: add the name to attestations. Neither takes an approval_criteria, and neither blocks waiting for anyone. Neither can be defined either: a policy that gives phishing_resistant or idp_verified a definition under constraints.attestations (a claim, an approval_criteria, or any other field) denies every call it applies to, because a definition would change what the name means for every policy that requires it.
They answer different questions about the same login, and neither implies the other. A policy that needs both requires both.
phishing_resistant — was a human present, and did they prove it with something unphishable?
Minted from the token's amr / acr claims when the login used a passkey, WebAuthn, or a hardware key. Use it where the thing you actually care about is that a person acted, not a process wearing their name:
{
"constraints": {
"tool:approvals/*": { "attestations": ["phishing_resistant"] }
}
}
It is minted when the agent registers, from that login's token, and lasts as long as the token. A login with a password or another phishable method never gets it.
idp_verified — did the tenant's IdP vouch for this identity at all?
Minted when registration validated a JWT against the tenant's identity provider. Registration records the outcome in the agent record's identity_source field — idp_verified, api_key, or self_reported — and that field never leaves the control plane, so this attestation is how a policy gets to read it. Use it to keep anything sensitive away from callers whose identity is merely claimed:
{
"resources": ["data:customer_pii/*"],
"attestations": ["idp_verified"]
}
A self-reported agent — one that registered without a verified credential — never receives this grant, so the dataset stays closed to it.
Lifetime. phishing_resistant expires with the token that produced it, because it describes one login. idp_verified does not expire; it describes how the record was established, and is revoked if the agent re-registers with a weaker identity.
Absence is the denial. An unverified login mints nothing rather than minting something that says "weak", so a gated tool denies by default. Nothing needs to remember to check.
Claim-Based Attestations
A claim-based attestation holds when the requester's token carries the claims its definition names. Built-in attestations answer fixed questions about a login; a claim-based attestation asks the policy's own.
{
"attestations": ["writer", "consented_to_payments", "transfer_approved::{params.amount > 25000}"],
"constraints": {
"attestations": {
"writer": { "claim": { "roles": "Banking.Writer" } },
"consented_to_payments": { "claim": { "scopes": "Banking.Write" } },
"transfer_approved": { "approval_criteria": "role:Banking.Approver", "timeout": 120 }
}
}
}
A payment needs a token with the Banking.Writer role and the Banking.Write scope. Above 25,000 it also needs an approver.
Writing the rule. claim maps each claim field to the value it must contain:
claim |
Holds when |
|---|---|
{"roles": "Banking.Writer"} |
roles contains Banking.Writer |
{"roles": ["Banking.Reader", "Banking.Writer"]} |
roles contains either value |
{"roles": "Banking.Writer", "scopes": "Banking.Write"} |
both fields hold |
[{"roles": "Banking.Writer"}, {"roles": "Banking.Approver"}] |
both rules hold: roles contains both values |
A list of values means any one of them. Every field in a rule must hold, and every rule in a list. A field contains a value when it is a list that includes it, a space-separated string that has it as a word (such as scp), or that value itself. A field the token does not carry never holds.
For example, {"claim": {"foo": ["a", "b"], "bar": "c"}}:
| Token | Holds |
|---|---|
foo: ["a"], bar: "c" |
yes |
foo: ["b", "x"], bar: "c" |
yes |
foo: ["a"], bar: "d" |
no: bar does not contain c |
foo: ["x"], bar: "c" |
no: foo contains neither a nor b |
foo: ["a"], no bar |
no: the token has no bar |
Where the claims come from. The fields are those the tenant's identity bridge maps from the identity provider's token: roles, and any other mapped field, such as scopes from Entra's scp. MACAW checks that the token is genuine and current (signed by the tenant's identity provider, issued for this application, not expired) and takes its claims as issued. Who holds which role or scope is managed in the identity provider.
How it is decided:
- Whose token. The person or service that started the request. In a chained invocation, that is the first caller.
- For how long. While that token is valid. Nothing is consumed, so
one_time,time_to_liveandtimeoutdo not apply. - Only from the token. No approval and no tool can provide it. When the claims do not hold, the call is denied with
Attestation 'writer' is not proven by the requester's token, and no approval is requested. - Only with a verified identity. A requester registered without a validated token proves no claim.
- Only a requirement. Like any attestation, it can deny a call. It never allows a resource the policies leave out or lifts a denial.
- When it is required is set by the
::{condition}on the requirement, as for any attestation. It is decided where a policy requires it and is not stored, socontext.has_attestation()does not see it. - One type per name. Definitions merge by name across policies, and a name has one type (see Attestation Types).
External Attestations (Third-Party Approval)
Require approval from an external party (manager, admin, compliance officer).
{
"attestations": [
"trade_approved::{params.amount > 5000}"
],
"constraints": {
"attestations": {
"trade_approved": {
"approval_criteria": "role:manager",
"timeout": 300,
"time_to_live": 3600
}
}
}
}
Characteristics:
- Require
approval_criteriain metadata - Can block execution waiting for approval (
timeout > 0) - Used for human-in-the-loop workflows
The discriminator: approval_criteria declares an external attestation, claim a claim-based one, and "internal": true an internal one. A definition with none of them only adds metadata.
Approval Outcomes:
- Approved: Approver approved with
client.approve_attestation()- requestor proceeds - Denied: Approver rejected with
client.deny_attestation()- requestor fails with reason
approval_criteria Formats:
| Format | Example | Matches |
|---|---|---|
user:xxx |
user:alice |
caller_id == "alice" OR email == "alice" |
user:xxx@domain |
user:alice@fintech.com |
Exact match on caller_id or email |
role:xxx |
role:manager |
Approver has role "manager" from IDP/JWT |
xxx (bare) |
manager |
Fallback: treated as role:manager |
| Comma-separated | role:manager,role:compliance |
Approver meets every listed criterion |
When two policies define the same attestation with different criteria, the approver must meet all of them.
approval_criteria governs approvals made through the SDK. In the console, workspace admins, and members with permission to manage attestations, can approve any request.
The Blocking Flow
When a request requires an external attestation with timeout > 0, the system blocks and waits for approval:
Request arrives → Policy requires attestation
↓
Check the control plane's store → Not found
↓
Create pending attestation (for_agent, approval_criteria, timeout)
↓
Poll with backoff [1, 1, 2, 2, 3, 5, 7, 9, 10] seconds
↓
┌─────────────────────────────────────────────┐
│ Outcome: │
│ • approved → Request proceeds │
│ • denied → Request fails with reason │
│ • timeout → Request fails (expired) │
└─────────────────────────────────────────────┘
Key points:
- An approval is looked up only in the control plane's store. A tool's attestation of the same name never satisfies it
- The
for_agentfield identifies WHO needs the attestation (the requester) - The
approval_criteriafield specifies WHO can approve - Approvers call
client.list_attestations(status='pending')to see requests they can approve - Approvers call
client.approve_attestation()orclient.deny_attestation()to respond
The for_agent Field
When an attestation is created, for_agent identifies who needs it approved:
{
"key": "trade_approved",
"for_agent": "alice@trading-app", // WHO needs this approved
"approval_criteria": "role:manager", // WHO can approve
"invocation_id": "inv-123", // Links to the blocked request
"status": "pending"
}
Why for_agent matters:
Filtering in list_attestations(): When an agent calls
list_attestations(status='pending'), they see:- Attestations where they ARE the
for_agent(attestations they're waiting on) - Attestations where they MATCH the
approval_criteria(attestations they can approve)
- Attestations where they ARE the
Activity Graph correlation: The
invocation_idlinks the attestation to the original request, enabling end-to-end tracing in the Activity Graph.Audit trail: The
for_agentfield is recorded in audit events, showing who requested the attestation and who approved it.
Conditional Attestations (NEW in v3)
The most powerful feature: Attestation requirements that are conditional on runtime parameters.
Basic Syntax
"attestation_key::{condition}"
Examples:
{
"attestations": [
"identity_verified",
"trade_approved::{params.amount > 5000}",
"manager_override::{params.priority == 'urgent' AND params.amount > 10000}"
]
}
What this means:
identity_verified- Always required (no condition), on every call the policy coverstrade_approved::{params.amount > 5000}- Only required when amount exceeds 5000manager_override::{...}- Only required when both conditions are true
Condition Syntax
References:
| Pattern | Description | Example |
|---|---|---|
params.X |
Invocation parameter | params.amount > 5000 |
context.has_attestation('X') |
Existing attestation check | context.has_attestation('mfa') |
Operators:
| Operator | Description | Example |
|---|---|---|
== |
Equals | params.currency == 'USD' |
!= |
Not equals | params.status != 'draft' |
<, <=, >, >= |
Comparison | params.amount > 5000 |
IN |
In list | params.region IN ('us', 'eu') |
MATCHES |
Glob pattern matching | params.command MATCHES '*npm install*' |
Boolean Logic:
| Operator | Description | Example |
|---|---|---|
AND |
Both true | params.a > 10 AND params.b < 5 |
OR |
Either true | params.x == 'a' OR params.x == 'b' |
NOT |
Negation | NOT params.currency == 'USD' |
() |
Grouping | (params.a > 10 AND params.b < 5) OR params.urgent == true |
Precedence: NOT binds tightest, then AND, then OR. Use parentheses to group: NOT (params.a > 10 OR params.b > 10).
Boolean parameters are compared explicitly: params.urgent == true.
Conditions fail closed: a malformed condition requires the attestation. See Common Issue 5.
Type the parameters you compare. A numeric comparison holds only for numbers, so params.amount > 5000 is false for the string "10000". Give the parameter a type in constraints.parameters: parameter constraints are checked before attestations, so a string is refused before the condition is evaluated.
Complex Conditional Examples
Tiered approval based on amount:
{
"attestations": [
"identity_verified",
"team_lead_approval::{params.amount > 1000 AND params.amount <= 10000}",
"manager_approval::{params.amount > 10000 AND params.amount <= 50000}",
"director_approval::{params.amount > 50000}"
]
}
Multi-factor conditions:
{
"attestations": [
"large_trade::{(params.amount > 25000 AND params.currency == 'USD') OR params.priority == 'urgent'}"
]
}
Pattern matching for command strings:
{
"attestations": [
"tier2_session::{params.command MATCHES '*npm install*'}",
"tier3_operation::{params.command MATCHES '*rm -rf*' OR params.command MATCHES '*curl*'}",
"tier4_critical::{params.command MATCHES '*sudo*'}"
]
}
Pattern syntax uses glob-style wildcards:
*matches any characters*npm*matches any string containing "npm"npm*matches strings starting with "npm"*lodashmatches strings ending with "lodash"
Different requirements for different callers:
Conditions read the call's parameters. The caller's identity decides which policies load (deferred principal binding), so put a caller-specific requirement in that caller's policy:
{
"policy_id": "team:junior-traders",
"attestations": [
"extra_approval::{params.amount > 5000}"
],
"constraints": {
"attestations": {
"extra_approval": { "approval_criteria": "role:manager", "timeout": 300 }
}
}
}
Senior traders' policies leave it out.
Attestation Keys That Are Policy IDs
An attestation key with a namespace prefix names another policy to load, not an attestation to verify:
"attestations": [
"identity_verified", // an attestation: must be presented and signed
"data:customer_master" // a reference: loads that policy
]
Attestation Metadata
Configure attestation behavior in constraints.attestations:
{
"constraints": {
"attestations": {
"trade_approved": {
"approval_criteria": "role:manager",
"timeout": 300,
"time_to_live": 3600,
"one_time": true,
"priority": 100
}
}
}
}
Metadata Fields
| Field | Type | Description |
|---|---|---|
approval_criteria |
string | Who can approve (external attestation). Format: role:X, user:email@domain.com, admin; comma-separate criteria that must all be met |
timeout |
integer | Seconds to wait for approval. 0 or omitted = don't block, > 0 = block and wait |
time_to_live |
integer | Seconds the approval remains valid. 0 or omitted = no expiry |
one_time |
boolean | If true (the default), the approval is consumed after a single use |
priority |
integer | Processing order when multiple attestations match. Higher values processed first (default: 0) |
claim |
object or list | The claims that prove it (claim-based attestation). See Claim-Based Attestations |
internal |
boolean | true declares an internal attestation: a fact a tool records |
claim defines a claim-based attestation, and internal an internal one. The other fields apply to approvals (external attestations). A tool sets the limits for internal attestations when it creates them (see Creating Attestations).
Metadata Semantics
Internal, External or Claim-based (declared by internal, approval_criteria or claim):
- Internal attestation (
"internal": true): the tool that creates it sets its lifetime and use limits. - External attestation (has
approval_criteria): the policy's metadata governs the approval request. - Claim-based attestation (has
claim): the requester's token decides it; no other metadata applies.
// External attestation
{
"trade_approved": {
"approval_criteria": "role:manager",
"timeout": 300,
"time_to_live": 3600,
"one_time": true
}
}
Blocking behavior (determined by timeout):
timeout: 0or absent → Don't block, deny immediately if attestation missing. No approval request is created, so only an approval that already exists satisfies ittimeout: 300withapproval_criteria→ Block up to 300 seconds waiting for approval
one_time (Boolean)
logger.attest(key="sudo_verified", one_time=True)
What it does:
- Attestation is consumed after first verification
- Second verification attempt fails (attestation deleted)
Use cases:
- Authentication tokens (one login = one session)
- Approval workflows (one approval = one action)
- One-time operations (password reset, account activation)
When it is spent: A call checks its attestations one by one, highest
priority first, and spends each one-time attestation as it verifies it. A call
refused at a later attestation has already spent the earlier ones. A one-time
approval belongs to the call it was approved for and could answer no other, so
this costs something only for a one-time attestation a tool created. Checks
that spend nothing run before the attestations
(Where They Run).
Grants: Reusable Approvals (one_time: false)
Set one_time: false to create a grant that can be reused multiple times:
{
"constraints": {
"attestations": {
"trade_approved": {
"approval_criteria": "role:manager",
"one_time": false, // Reusable grant
"time_to_live": 3600 // Valid for 1 hour
}
}
}
}
What it does:
- Manager approves once → Grant is created
- Agent can execute multiple trades for 1 hour without re-approval
- Each use emits
attestation_accessedaudit event - Grant expires when
time_to_liveelapses - The grant belongs to the agent that requested it, and covers any of its calls that need
trade_approveduntil it expires
Use cases:
- Batch operations (approve once, run many)
- Time-boxed elevated access (trading window)
- Delegated authority (manager grants permission for a period)
time_to_live (Integer, seconds)
logger.attest(key="session_authenticated", ttl=300)
What it does:
- Attestation expires after 300 seconds (5 minutes)
Use cases:
- Session management (5 minute authentication window)
- Time-sensitive operations (trading hours enforcement)
- Temporary escalations (sudo access for 10 minutes)
max_uses (Integer) - Monitor Pattern
max_uses is set when a tool creates an internal attestation:
logger.attest(key="api_quota", max_uses=10)
What it does:
- Attestation valid for 10 verifications
- Use counter incremented on each verification
- Fails when
use_count >= max_uses
Use cases:
- API rate limiting (10 calls per attestation)
- Quota enforcement (10 file downloads)
- Multi-use but not unlimited (batch operations)
Example:
# Create attestation with max_uses
logger.attest(key="batch_quota", max_uses=3)
# Use 1
verify("batch_quota") # ✅ Works (use_count: 1/3)
# Use 2
verify("batch_quota") # ✅ Works (use_count: 2/3)
# Use 3
verify("batch_quota") # ✅ Works (use_count: 3/3)
# Use 4
verify("batch_quota") # ❌ Fails (use_count: 3/3, exhausted)
When Several Policies Define the Same Attestation
Attestation requirements accumulate: if any applicable policy requires an attestation, it is required.
When two policies both define metadata for the same attestation, each field keeps the stricter value:
| Field | Combined value |
|---|---|
timeout |
The shorter wait |
time_to_live |
The shorter duration (0 means no expiry) |
one_time |
true if either policy sets it to true; a policy that leaves it out takes the other's value |
approval_criteria |
Every criterion from both policies |
priority |
The higher value |
claim |
Every rule from both policies must hold |
Fields a policy leaves out take the other policy's value or the default. Set a field to enforce it. An attestation declared as one type in one policy and as another in a second policy cannot be both, and the call is denied.
The Security Model: ToolAgent Signing + Registry TCB
Why Independent Verification?
In MACAW, each tool is wrapped in a ToolAgent (Policy Enforcement Point). This creates isolation:
User Agent (orchestrator)
↓ invokes
ToolAgent PEP (independent verifier)
↓ enforces policies for
Tool Code (business logic)
Benefit: If a tool is compromised, the blast radius is limited:
- Compromised tool can't access other tools directly
- ToolAgent PEP enforces resource policies
- ToolAgent verifies signatures on invocations
How Verification Works
- ToolAgent Creates Attestations (Not the Tool)
verify_identity tool executes successfully
↓
ToolAgent PEP creates attestation
↓
ToolAgent signs with its own private key
↓
Stores in session context
Why this matters: The tool code never sees the private key. Even if the tool is compromised, it cannot forge attestations.
- Registry as Trusted Computing Base (TCB)
When verifying an attestation:
Attestation says: "Created by tool.verify_identity"
↓
Need public key to verify signature
↓
Look up in GlobalAgentRegistry (TCB)
↓
Registry returns: tool.verify_identity's registered public key
Why this matters:
- Registry is part of the Trusted Computing Base (TCB)
- All ToolAgents register their public keys on startup
- Verifier trusts ONLY keys from the registry
- Attackers can't embed fake public keys in attestation data
Creating Attestations
1. Auto-Attestation (Config-Driven)
For simple "I executed successfully" cases, configure attestations declaratively:
client = MACAWClient(
app_name="trading-app",
tools={
"verify_identity": {
"handler": verify_identity_handler,
"attestation": {
"key": "identity_verified",
"one_time": True,
"ttl": 300,
"scope": "session"
}
}
}
)
Use when:
- Simple "this operation completed" semantics
- No custom data needed in attestation value
- Standard metadata sufficient
2. Explicit Attestation (Programmatic)
For cases where you need custom values:
def risk_check_handler(params, logger):
"""Risk check tool - records the trade it checked and the limit it applied.
A handler that declares a `logger` parameter receives the ToolAgent's logger.
"""
trade_id = params.get('trade_id')
limit = risk_limit_for(trade_id) # the service's own data
# Create attestation with custom value
logger.attest(
key="risk_checked",
value={"trade_id": trade_id, "limit": limit},
one_time=True
)
return {"status": "checked", "trade_id": trade_id}
Use when:
- Need to embed custom data (the record checked, the result)
- Need application-specific metadata
A tool attests what it established itself: what it looked up or did. An approval comes from an approver (approval_criteria) and a built-in from the control plane; a tool's attestation never stands in for either, even under the same name.
A later call uses the attestation in either or both of two ways:
| Requirement | Verifier | |
|---|---|---|
| Written as | "attestations": ["risk_checked"], declared "internal": true |
a verifier that reads context.attestations["risk_checked"]["value"] |
| Checks | it exists for this caller, its signature, time_to_live, max_uses |
anything about its value against the call's parameters |
| Spends | one_time and max_uses are spent when the requirement is checked |
nothing: a verifier reads a copy |
{
"attestations": ["risk_checked"],
"verifiers": ["risk_matches"],
"constraints": { "attestations": { "risk_checked": { "internal": true } } }
}
def verify(invocation, context):
value = ((context.attestations or {}).get("risk_checked") or {}).get("value") or {}
if value.get("trade_id") != invocation.params.get("trade_id"):
return VerificationResult(False, "risk_checked records a different trade")
if invocation.params.get("amount", 0) > value.get("limit", 0):
return VerificationResult(False, "amount is over the checked limit")
return VerificationResult(True, "ok")
Verifiers run before requirements, so a call the verifier refuses spends nothing.
Enforcing Attestation Requirements in Policies
In your MAPL policy, require attestations before allowing operations:
{
"policy_id": "intent:trading",
"resources": [
"tool:verify_identity",
"tool:approve_trade",
"tool:execute_trade"
],
"constraints": {
"tool:execute_trade": {
"attestations": [
"identity_verified",
"trade_approved::{params.amount > 5000}"
]
},
"attestations": {
"identity_verified": { "internal": true },
"trade_approved": {
"approval_criteria": "role:manager",
"timeout": 300,
"time_to_live": 3600,
"one_time": true
}
}
}
}
The requirements sit on execute_trade only. verify_identity is the tool that records identity_verified, so it cannot require it.
How enforcement works:
- Agent tries to execute trade with amount=10000
- Policy enforcer evaluates attestation requirements:
identity_verified- Always required → Checktrade_approved::{params.amount > 5000}- Condition: 10000 > 5000 = true → Required
- For each required attestation:
- Find it where its type says: an internal one in the caller's session in this service, an approval or built-in in the control plane's store
- Look up signer's public key from registry (TCB)
- Verify cryptographic signature
- Check metadata (one_time used? time_to_live expired? max_uses exhausted?)
- Decision:
- ✅ All attestations verify → Allow execution
- ❌ Any attestation fails → Deny (or block if timeout > 0 for external)
Real-World Example: Financial Trading Workflow
The Requirement
Scenario: Trading platform where:
- Users can execute trades
- Trades over $5,000 require manager approval
- Identity must be verified before any trade
- Each approval is one-time use (prevents replay)
- Verifications expire after 5 minutes
Step 1: Define the Policy
{
"policy_id": "intent:secure-trading",
"description": "Secure trading with conditional approval",
"resources": [
"tool:verify_identity",
"tool:approve_trade",
"tool:execute_trade"
],
"constraints": {
"tool:execute_trade": {
"attestations": [
"identity_verified",
"trade_approved::{params.amount > 5000}"
]
},
"attestations": {
"identity_verified": { "internal": true },
"trade_approved": {
"approval_criteria": "role:manager",
"timeout": 300,
"time_to_live": 3600,
"one_time": true
}
},
"parameters": {
"tool:execute_trade": {
"amount": {"type": "number", "min": 0, "max": 1000000}
}
}
}
}
Step 2: Configure Auto-Attestation
tools = {
"verify_identity": {
"handler": verify_identity_handler,
"attestation": {
"key": "identity_verified",
"one_time": True,
"ttl": 300
}
}
}
Step 3: Execute Workflow
# Trade 1: Small trade ($1000) - no manager approval needed
client.invoke_tool("verify_identity", {"user_id": "alice@acme.com"})
result = client.invoke_tool("execute_trade", {"trade_id": "T-001", "amount": 1000})
# ✅ Succeeds - identity_verified present, trade_approved not required (1000 < 5000)
# Trade 2: Large trade ($10000) - manager approval needed
client.invoke_tool("verify_identity", {"user_id": "alice@acme.com"})
result = client.invoke_tool("execute_trade", {"trade_id": "T-002", "amount": 10000})
# ⏳ Blocks waiting for manager approval (up to 300 seconds)
# Manager approves via separate interface
# ✅ Succeeds after approval
# Trade 3: Try to reuse attestations
result = client.invoke_tool("execute_trade", {"trade_id": "T-003", "amount": 500})
# ❌ Fails - identity_verified was consumed (one_time=true)
Step 4: Bob's Approval Code (Separate Terminal)
While Alice's Trade 2 is blocking, Bob runs this in another terminal:
from macaw_client import MACAWClient, RemoteIdentityProvider
# Bob authenticates via Keycloak (has "manager" role)
jwt_token, _ = RemoteIdentityProvider().login("bob", "Bob@123!")
client = MACAWClient(
user_name="bob",
iam_token=jwt_token,
agent_type="user",
app_name="attestation-approver"
)
client.register()
# List pending attestations Bob can approve (matches his roles)
attestations = client.list_attestations(status="pending")
print(f"Found {len(attestations)} pending attestations")
# Approve Alice's trade request
for att in attestations:
if att['key'] == 'trade_approved':
# Approve
client.approve_attestation(att, reason="Manager approved")
print("Approved!")
# OR Deny
# client.deny_attestation(att, reason="Budget exceeded")
client.unregister()
When Bob approves, Alice's blocked request (in the other terminal) automatically proceeds.
MACAWClient Approval APIs
The SDK provides three methods for working with attestations:
list_attestations(status)
List attestations visible to the current agent:
# List all pending attestations (you can approve OR are waiting on)
pending = client.list_attestations(status="pending")
# List approved attestations
approved = client.list_attestations(status="approved")
# List all attestations (no filter)
all_atts = client.list_attestations()
Returns attestations where:
- You ARE the
for_agent(attestations you're waiting on, in any status), OR - You MATCH the
approval_criteriaand the attestation is stillpending(attestations you can approve)
approve_attestation(attestation, reason)
Approve a pending attestation:
client.approve_attestation(
attestation,
reason="Approved for Q4 trading window"
)
Requirements:
- Attestation must be in
pendingstatus - Your identity must match
approval_criteria(e.g., have "manager" role) - Emits
attestation_approvedaudit event with your signature
deny_attestation(attestation, reason)
Deny a pending attestation:
client.deny_attestation(
attestation,
reason="Budget exceeded for this quarter"
)
Requirements:
- Attestation must be in
pendingstatus - Your identity must match
approval_criteria - Emits
attestation_deniedaudit event - Blocked requester receives denial with your reason
Security Guarantees Achieved:
- ✅ Agent can't skip identity verification
- ✅ Agent can't forge manager approval
- ✅ Agent can't reuse old approvals (one_time=true)
- ✅ Agent can't use expired verifications (time_to_live)
- ✅ Small trades don't need manager approval (conditional)
Comparison to Alternatives
| Approach | Forgeable? | Verifiable? | Temporal Control? | Conditional? | Infrastructure |
|---|---|---|---|---|---|
| Agent Memory | ✅ Yes | ❌ No | ❌ No | ❌ No | None |
| Blockchain | ❌ No | ✅ Yes | ⚠️ Via contract | ⚠️ Complex | 🔴 High |
| OAuth2 Scopes | ❌ No | ✅ Yes | ✅ Yes | ❌ No | 🟡 Medium |
| MAPL Attestations | ❌ No | ✅ Yes | ✅ Yes | ✅ Yes | 🟢 Low |
Part 6: Object Policies
Attaching a Policy to a Thing
Every policy so far attaches to a caller — a company, a team, a user, a service. That works because callers invoke things, and invoking is what triggers policy resolution.
Some things are never invoked. A table, a file, a document, a model — these get used. A tool reads them. Nothing invokes them, so nothing loads their policy.
Object policies attach the rules to the thing itself. MAPL enforces them whenever a tool consumes it:
{
"policy_id": "data:employee_comp",
"version": "1.0",
"description": "Compensation data",
"resources": ["tool:reporting/query"],
"denied_resources": ["tool:*/export", "tool:*/send"],
"attestations": ["pii_training_current"],
"constraints": {
"attestations": { "pii_training_current": { "internal": true } }
}
}
Read it as: only reporting/query may touch me, never an export or messaging tool, and only from someone with current PII training.
The object doesn't care which team or user is calling. Whoever consumes it is bound by it.
Loading an Object Policy
Name the object in attestations. A key with a namespace prefix is a policy reference:
{
"policy_id": "tool:reporting/query",
"resources": ["tool:reporting/query"],
"attestations": ["data:employee_comp"]
}
MAPL loads data:employee_comp and merges it like any other layer.
So an attestation name must not contain a colon. The namespace prefix is
what tells MAPL to load a policy rather than look for an attestation, and it cannot
tell the two apart by anything else: "capability:web_search" inattestations is read as a reference to a policy calledcapability:web_search, and a reference that resolves to nothing denies.
Name attestations the way the shipped policies do - unknown_tool_approved,cron_schedule_approved, capability_web_search.
This applies to the name in attestations. Colons are fine elsewhere: in thetier::{condition} form, where the part before :: is the tier, and inside
expressions such as context.has_attestation('scope:workspace'), which the evaluator
reads rather than the resolver.
Dynamic References
Hardcoding one object per policy doesn't scale. Build the policy ID from the invocation instead:
{
"policy_id": "app:reporting",
"resources": ["tool:reporting/*"],
"attestations": ["data:{params.dataset}"]
}
A call with dataset = "employee_comp" loads data:employee_comp. A call with dataset = "finance/transactions" loads data:finance/transactions — or data:base if that object has no policy of its own.
One line covers every object in your system.
List parameters expand to one reference each, and an empty list loads nothing:
// params: { "datasets": ["transactions", "employee_comp"] }
// loads: data:transactions AND data:employee_comp
The Two Rules
1. Fail closed. A reference must resolve — to its own policy, or to {namespace}:base. If neither exists, the call is denied. Naming an object declares that it has rules, so a missing policy is a configuration error, not permission.
2. Narrowing only. Object policies merge by intersection like every other layer. They can restrict; they can never grant. This is the same Monotonic Restriction property that governs organizational policies, so you can generate thousands of object policies with no risk of privilege escalation.
What Can Be Substituted
Only invocation parameters, using {params.name}:
| Situation | Result |
|---|---|
| Parameter present | Substituted into the policy ID |
| Parameter absent | Reference doesn't apply — nothing loaded, no denial |
Value contains a character outside A–Z a–z 0–9 _ . / - @ #, or contains .. |
Denied — a value may only name an object inside the namespace |
finance/Q1-2026, #W2611340 and alice@acme.com resolve, so order:{params.order_id} loads order:#W2611340; hr:comp and ../hr are denied.
"Parameter absent" is what lets you put one reference at company level and have it apply only to the tools that have that parameter.
Binding Objects Per Tool Family
Use a tool pattern in constraints to give tool families different object defaults:
{
"policy_id": "company:acme",
"resources": ["tool:**"],
"constraints": {
"tool:reporting/*": { "attestations": ["data:{params.dataset}"] },
"tool:docs/*": { "attestations": ["doc:{params.document}"] }
}
}
Chaining: Derived Objects
An object policy can reference other objects. This is how you handle a summary, view, or export whose underlying sources never appear in the call:
{
"policy_id": "data:quarterly_summary",
"version": "1.0",
"description": "Derived from transactions and employee_comp",
"resources": ["**"],
"attestations": ["data:transactions", "data:employee_comp"]
}
It sets no restrictions of its own — ["**"] restricts nothing. Its sources carry the rules.
A call against quarterly_summary loads the derived object's policy, which loads both sources. Every restriction on either source applies.
Chains follow to any depth. Cycles terminate safely, and each policy loads once.
Worked Example: Three Objects, Any Number of Callers
The floor — applies to every object without a policy of its own:
{
"policy_id": "data:base",
"version": "1.0",
"description": "Default rules for every dataset",
"resources": ["**"],
"denied_resources": ["tool:*/export", "tool:*/send"]
}
A classified object with one extra restriction:
{
"policy_id": "data:transactions",
"version": "1.0",
"extends": "data:base",
"description": "Transaction records",
"resources": ["tool:reporting/*"],
"denied_resources": ["tool:*/publish"]
}
A sensitive object that requires proof:
{
"policy_id": "data:employee_comp",
"version": "1.0",
"extends": "data:base",
"description": "Compensation data - PII",
"resources": ["tool:reporting/query"],
"attestations": ["data:pii_training_current"]
}
The proof it requires, as its own policy, so any object can reference it:
{
"policy_id": "data:pii_training_current",
"version": "1.0",
"description": "Current PII training, confirmed by a privacy officer",
"resources": ["**"],
"attestations": ["pii_training_current"],
"constraints": {
"attestations": {
"pii_training_current": {"approval_criteria": "role:privacy-officer", "timeout": 300}
}
}
}
A derived object built from the other two:
{
"policy_id": "data:quarterly_summary",
"version": "1.0",
"description": "Derived from transactions and employee_comp",
"resources": ["**"],
"attestations": ["data:transactions", "data:employee_comp"]
}
Wire it to the tools once:
{
"policy_id": "app:reporting",
"version": "1.0",
"description": "Reporting tools",
"resources": ["tool:reporting/*"],
"attestations": ["data:{params.dataset}"]
}
Now:
| Alice calls | Loads | Result |
|---|---|---|
reporting/query(dataset="transactions") |
data:transactions → data:base |
✅ Allowed |
reporting/export(dataset="transactions") |
same | ❌ Denied by tool:*/export, inherited from data:base |
reporting/publish(dataset="transactions") |
same | ❌ Denied by tool:*/publish, the object's own rule |
reporting/query(dataset="employee_comp") |
data:employee_comp → data:base, + data:pii_training_current |
⏳ Blocks for pii_training_current (a privacy officer, up to 300 s); denied without it |
reporting/query(dataset="quarterly_summary") |
derived → data:transactions + data:employee_comp |
⏳ Blocks for pii_training_current; denied without it. Carries both objects' rules |
reporting/query(dataset="anything_new") |
data:base (fallback) |
✅ Allowed under the floor |
Two rows carry the lesson.
Row 5 is composition. Alice never named employee_comp, but querying a summary built on it inherits its PII requirement and its resource restriction:
query(dataset="quarterly_summary")
resources: ["tool:reporting/query"] ← from employee_comp
denied: [tool:*/export, tool:*/publish, tool:*/send] ← publish from transactions
attestations: ["pii_training_current"] ← from employee_comp, via data:pii_training_current
Row 6 is the floor. An object nobody has classified still gets data:base. Without data:base, that call would be denied instead.
Limitations
- Conditions on references are rejected.
data:employee_comp::{params.mode == 'write'}is a validation error. Put the condition insidedata:employee_compinstead. - Only parameters substitute.
{principal.x}is not supported. - Namespaces are lowercase.
Data:xis not recognized as a reference; it is read as an attestation namedData:x. - Objects must be namable before the call. An object that appears only in a result cannot be referenced. Use a processor for those: it sees the result and can withhold it.
Part 7: Extractors, Verifiers and Processors
Status: Available from client 0.9.20 and control plane 0.9.20. All
three are additive — a policy that uses none of them behaves exactly as in
MAPL 3.2.
What Are They?
Three roles that a policy can invoke at the enforcement point, around a call.
The pipeline is the same whatever serves the call, a tool, an LLM or another
agent, so a processor applies to an LLM's reply as it does to a tool's result.
| Role | Signature | Runs | Can deny? |
|---|---|---|---|
| Extractor | extract(value, **config) -> dict |
before policy checks | No |
| Verifier | verify(invocation, context, **config) -> VerificationResult |
before the tool | Yes |
| Processor | process(result, invocation, context, **config) -> ProcessingResult |
after the tool | Withholds the result |
Example scenario:
- ✅ Alice can call
tool:deploy/run - ⚠️ But her
scriptmust not contain credentials — a verifier refuses it - ⚠️ And the token the tool echoes back is masked — a processor removes it
- ⚠️ And approval is required when her SQL raises a bonus above $10,000 — an
extractor supplies the number, an attestation gates on it
Where They Run
call arrives at the tool's enforcement point
│
1 RESOLVE POLICY identity + resource → one effective policy
│
2 EXTRACTORS each reads its field → derived.<name>.<key>
│ a failure refuses the call
3 POLICY CHECKS resources, parameters, denied parameters; conditions can read derived.*
│
4 VERIFIERS by priority; an enforcing failure refuses the call
│
5 RATE LIMIT counts the call
│
6 ATTESTATIONS Part 5; may wait for an approver; spends one-time attestations
│
7 the tool runs
│
8 PROCESSORS by priority, on the result; an enforcing failure withholds it
│
9 sign the result → caller
Extractors run before every check that can read their output. A verifier
returns only pass or fail, so it cannot supply a value that a condition reads —
that is what extractors are for.
Every check that can refuse a call without spending anything runs before the
attestations. So a call that a verifier refuses asks no approver and spends no
attestation, and a refused call does not count against the rate limit.
Extractors run before the policy checks, so they also run on a call the checks
will refuse.
Functions Live in the Workspace
Every extractor, verifier and processor a policy names is a short Python
function stored in the workspace, beside its policies. The enforcement point
fetches it the way it fetches a policy, and runs it where the tool runs.
Library ~/.macaw/functions/<kind>/ MACAW's functions, and your organisation's
│ Add (console)
▼
Workspace extractors/ verifiers/ processors/ versioned like policies; the latest is served
│ manifest sync
▼
Enforcement point checks it, runs it, refuses the call if it cannot
A function enters the workspace one way: saved in the console, under
Policies → Functions, either written there or added from the library.
Nothing is stored until both checks pass:
| Check | Refuses the save when |
|---|---|
| Allowlist | the source uses anything the allowlist does not name |
| Samples | any sample returns something other than what it expects |
The enforcement point checks the allowlist again before it loads a function.
Every save is a new version. The previous one is archived, and the history
records who saved it, when, and its content hash. Enforcement points use the
latest version; a running agent picks up a new one at its next sync, without a
restart.
Disable a function from its page under Policies → Functions. Its
versions move to the archive with their history, and enforcement points drop
it at their next sync. A function a policy applies cannot be disabled: its page
shows In use · N policies in place of Disable, and the API refuses withUsed by app:memo. Remove it from that policy first. Saving a disabled
function again continues its versions: 1.0.0, disabled, then saved again, is
1.0.1.
A policy can name only functions the workspace has. Saving one that names a
missing function is refused:
Policy names functions this workspace does not have: processors/mask_everything
Adding a function from the library copies it, with its version, into the
workspace. From then on the copy belongs to the workspace: each save there is a
new workspace version, and a change to the library does not reach it.
Upgrading the control plane refreshes the library and leaves every workspace as
it was. The console marks a library function ✓ when the workspace has the
library's version, and otherwise offers Add, which opens the library's
source in the editor and saves it as the workspace's next version.
Your organisation can put its own functions in the library folder. Give them
names MACAW does not use: when the control plane starts, it rewrites each of
its own files there that differs from the one it ships, unless the file is a
later version.
Writing a Function
A function is its kind's entry point, from the table in
What Are They?, plus any helpers and constants it needs.config is what the policy declares for it, less the keys the enforcement
point uses itself: priority and enforce for a verifier or processor,field for an extractor. A function lists the keys it accepts, such asfields=(), or takes **config.
Example: the accounts named in a payment memo
ACCOUNT = re.compile(r"\bacct-[a-z0-9]+\b")
def extract(value, **config):
found = ACCOUNT.findall(value or "")
return {"count": len(found), "accounts": sorted(set(found))}
[
{"value": "pay acct-nw", "expect": {"count": 1, "accounts": ["acct-nw"]}},
{"value": "no accounts", "expect": {"count": 0, "accounts": []}}
]
The samples are part of the function. Each gives an input and what the
function should return for it, and every one must pass before the function is
stored. Each kind's sample format is in
Appendix D.4.
Run in the editor checks the allowlist, runs every sample, and reports how
long each sample's call took: 4 calls · mean 0.025 ms · slowest 0.049 ms. The
time covers the function's call only, not loading it or comparing the result.
A time that grows with the input's length, from a regular expression in
particular, is the warning sign.
Using It in a Policy
Saved as memo_accounts, the function's facts arederived.memo_accounts.count and derived.memo_accounts.accounts. This
policy sends a payment that names an account for treasury review, and refuses
one that names more than two:
{
"policy_id": "app:payments",
"version": "1.0",
"scope": "global",
"resources": ["tool:payments/send"],
"extractors": {
"memo_accounts": {"field": "params.memo"}
},
"attestations": [
"treasury_review::{ derived.memo_accounts.count > 0 }"
],
"constraints": {
"attestations": {
"treasury_review": {"approval_criteria": "role:manager", "timeout": 300}
},
"denied_parameters": {
"tool:payments/send": {
"memo": ["{derived.memo_accounts.count > 2}"]
}
}
}
}
memo |
derived.memo_accounts.count |
Result |
|---|---|---|
"March rent" |
0 |
✅ allowed |
"pay acct-nw" |
1 |
⏳ waits for role:manager approval |
"split acct-nw acct-se acct-ce" |
3 |
❌ refused |
The third call is refused even when treasury_review has been granted: an
approval satisfies an attestation and does not lift a denial. The function
decides nothing. It counts, and the policy sets both thresholds.
What a Function May Use
A function computes from what it is handed and returns. It has re and json
in scope, and the builtins for working with strings, numbers, lists and dicts.
It has no import, no files, no network, no clock, and no names that begin
with _. The allowlist in Appendix D.2 names everything a
function may contain. Anything else is refused at save, with the line:
Line 4: '.encode' is not available
'SEEN' is a list; top-level values are constants (str, number, tuple, compiled pattern)
Three more things hold where it runs:
- Copies. Each function receives its own copies of what it is handed: the
value, the invocation, the context and the result. A change it makes reaches
neither the tool, the policy checks nor the next function. Only a
processor's returned result is carried forward. - No state. Top-level values are constants, so a call leaves nothing
behind for the next one. - A bound. A function may run 1,000,000 lines of its own code per call.
Past that it is stopped, and it has failed.
When a Function Fails
A function fails when it raises, returns the wrong type, runs past its bound,
or is declared with a key it does not accept.
| Kind | enforce: true (default) |
enforce: false |
|---|---|---|
| Extractor | the call is refused | — extractors always enforce |
| Verifier | the call is refused | logged; the call proceeds |
| Processor | the result is withheld | logged; the result passes on without its change |
When Several Policies Declare One Function
Any policy in the chain may declare a function, and all of them apply. A
function declared by one policy is used as that policy declares it. When two
policies declare the same function:
| Key | Combined value |
|---|---|
enforce |
true if either declaration enforces; leaving it out enforces |
priority |
The lower value, so it runs earlier |
| Every other key | Must be the same in both |
Every other key belongs to the function, and only the function knows which of
two values is stricter: for fields, a longer list; for a length limit, a
smaller number. So MAPL does not choose. Declarations of one function that
differ in those keys refuse the call:
The verifier 'secret_scanner' is declared differently by two policies
To inspect more fields than a parent declares, change the parent's declaration.
A layer that declares the same function differently cannot narrow a check
another layer relies on, or redirect what a condition reads.
Functions with the same priority run in name order.
Verifiers
What Are Verifiers?
A verifier inspects an invocation before the tool executes and returns pass or
fail. It cannot modify the invocation: the caller signed those parameters, and
rewriting them would mean the tool executes something nobody signed.
Basic Structure
{
"constraints": {
"verifiers": {
"verifier-name": { "configuration" }
}
},
"verifiers": ["verifier-name"]
}
constraints.verifiers.<name> declares and configures. The top-levelverifiers array applies. This is the same split as attestations, so a parent
policy can declare what a child applies.
Example:
{
"constraints": {
"verifiers": {
"secret_scanner": {
"priority": 50,
"enforce": true,
"fields": ["params.script"]
}
}
},
"verifiers": ["secret_scanner"]
}
Configuration Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
priority |
integer | 100 |
Execution order; lower runs first |
enforce |
boolean | true |
false logs the failure and allows the call |
Every other key is passed to the verifier. The library's verifiers takefields, the dotted parameter paths to inspect; see
Appendix D.6.
Patterns belong to the function, where its samples test them. A declaration
says which fields to inspect.
Set enforce: false to observe what a new verifier would refuse before
promoting it. The default is true: a verifier that does not enforce is
indistinguishable from an absent one.
Conditional Application
A verifier applies unconditionally, or only when a condition holds:
{
"verifiers": [
"secret_scanner",
"length_limit::{ params.env == 'prod' }"
]
}
Condition syntax is identical to
conditional attestations.
In the Library
| Name | Inspects | Fails when |
|---|---|---|
secret_scanner |
fields |
a credential shape is present |
length_limit |
fields |
a value exceeds max_length |
Example: Blocking Credentials in Deployment Scripts
{
"policy_id": "app:deploy",
"version": "1.0",
"scope": "global",
"resources": ["tool:deploy/**"],
"constraints": {
"verifiers": {
"secret_scanner": {"fields": ["params.script"]}
}
},
"verifiers": ["secret_scanner"]
}
| Call | Result |
|---|---|
script = "deploy --env staging" |
✅ allowed |
script = "export KEY=AKIA..." |
❌ refused before the tool runs |
Result Processors
What Are Processors?
A processor transforms a tool's result before it is signed and returned. It
runs inside the callee's authority, so it may rewrite the result — the tool
could equally have returned different data. Because it runs before signing, the
caller verifies what it actually received.
A processor can also withhold a result. It returns success=False, and if it
enforces, the caller receives a refusal in place of the result.
Basic Structure
{
"constraints": {
"processors": {
"processor-name": { "configuration" }
}
},
"processors": ["processor-name"]
}
Example:
{
"constraints": {
"processors": {
"secret_scrubber": {"enforce": false}
}
},
"processors": ["secret_scrubber"]
}
Configuration Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
priority |
integer | 50 |
Execution order; lower runs first |
enforce |
boolean | true |
false allows the result through if the processor fails |
Every other key is passed to the processor. secret_scrubber takes fields,
the dotted result paths to transform; see Appendix D.6.
Direction Rules
| Direction | Permitted action | Reason |
|---|---|---|
| Inbound (verifier) | flag or block | the caller signed the parameters |
| Outbound (processor) | rewrite | the tool signs after processing |
Masking an inbound parameter does not protect the data: by then it has left the
caller, crossed the control plane and sat in a mailbox.
In the Library
| Name | Transforms | Effect |
|---|---|---|
secret_scrubber |
fields, or the whole payload |
replaces credential shapes with [REDACTED] |
Example: Masking a Token a Tool Legitimately Holds
A deployment tool holds a GitHub token and writes it to its log.
{
"constraints": {
"processors": {"secret_scrubber": {}}
},
"processors": ["secret_scrubber"]
}
Tool returns: {"log": "deployed with ghp_aaaa... ok"}
Caller sees: {"log": "deployed with [REDACTED] ok"}
Extractors
What Are Extractors?
An extractor reads one parameter value and returns facts about it. Policy
conditions then read those facts. It makes no decision and modifies nothing.
Use an extractor when the value your policy needs is not a parameter, but is
computable from one — a number inside a SQL statement, the hosts inside a
request body, the paths inside a shell command.
Function Signature
extract(value, **config) -> dict
| Input | the value at the declared field, of any type; None if absent |
| Output | a flat or nested dict of facts |
| Raises | when the extractor cannot run; the enforcement point denies the call |
An extractor receives no invocation, no context and no principal. It is a
function of its input alone, which is what makes it exhaustively testable.
Basic Structure
{
"extractors": {
"extractor-name": {"field": "params.parameter-name"}
}
}
Extractors are declared in a single map, not a declare/apply pair. There is no
application condition and no ordering: each extractor writes only its own
namespace, and none of them can read another's output.
Example:
{
"extractors": {
"secrets": {"field": "params.notes"}
}
}
Configuration Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
field |
string | yes | Dotted path to the value; must begin with params |
field may name the parameter block itself — "field": "params" passes every
parameter as a dict. Any other key in the declaration is passed to the
extractor as a keyword argument.
Paths outside params are rejected. An extractor that could read principal or
context would no longer be a function of its input. Conditions read params.*,derived.* and context.has_attestation(); put a caller-specific rule in that
caller's policy.
Reading Facts
Facts are read as derived.<extractor>.<key>, using the same operators asparams.:
derived.secrets.count > 0
derived.sql.bonus > 10000
derived.secrets.kinds MATCHES '*aws*'
derived.sql.bonus > 10000 AND params.env == 'prod'
Nested facts are read by path: derived.sql.tables.payroll.rows.
Missing Values
| Expression | State | Result |
|---|---|---|
derived.sql.bonus |
extractor ran, no such fact | false |
derived.sql.bonus |
extractor was not declared | raises — call refused |
The second case is refused rather than evaluated as false, because a
condition that silently evaluates false produces a policy that reads as
enforcing a threshold and enforces nothing. The denial states the cause:
Denial rule {derived.sql.bonus > 10000} could not be evaluated:
No facts from extractor 'sql': it did not run for this invocation
In the Library
| Name | Reads | Returns |
|---|---|---|
secrets |
any value | {present: bool, count: int, kinds: [str]} |
A policy names a function stored in the workspace; see
Functions Live in the Workspace.
Use Case: Natural Language to SQL
An agent converts "give the top performers their bonuses" into:
UPDATE payroll SET bonus = 50000 WHERE rating = 'exceeds'
Policy must require approval for compensation changes above $10,000. The
parameter is the SQL string; the controlled value is the number assigned tobonus.
Pattern matching cannot express this. Consider a policy that gates on the
statement text:
{
"attestations": [
"allow_update::{ params.query MATCHES '*UPDATE *' OR params.query MATCHES '*MERGE INTO*' }",
"allow_delete::{ params.query MATCHES '*DELETE *' }"
]
}
Against MERGE INTO payroll ... WHEN MATCHED THEN DELETE:
| Gate | Fires | Reason |
|---|---|---|
allow_update |
✅ | matches *MERGE INTO* |
allow_delete |
❌ | *DELETE * requires a trailing space |
A row deletion is approved by a manager where the policy requires an admin. The
globs matched exactly as written — "DELETE " is a string, and "this statement
removes rows" is a property of the statement.
With an extractor, the statement is parsed and the policy reads the value:
{
"extractors": {
"sql": {"field": "params.query"}
},
"attestations": [
"comp_change::{ derived.sql.bonus > 10000 }"
]
}
| Statement | derived.sql.bonus |
Approval |
|---|---|---|
UPDATE payroll SET bonus = 5000 ... |
5000 |
not required |
UPDATE payroll SET bonus = 50000 ... |
50000 |
required |
MERGE INTO payroll ... THEN UPDATE SET bonus = 50000 |
50000 |
required |
The rule is indifferent to statement form, comments and whitespace.
Selecting the approver by value:
{
"attestations": [
"manager_ok::{ derived.sql.bonus > 10000 AND derived.sql.bonus <= 100000 }",
"admin_ok::{ derived.sql.bonus > 100000 }"
]
}
A SQL extractor is not in the library; the example shows the shape.
Complete Example
One policy using all three roles, where a single extracted fact produces three
different outcomes.
{
"policy_id": "app:demo",
"version": "1.2",
"scope": "global",
"resources": [
"tool:demo/**"
],
"extractors": {
"secrets": {
"field": "params.notes"
}
},
"attestations": [
"security_review::{ derived.secrets.count == 1 }"
],
"constraints": {
"attestations": {
"security_review": {
"approval_criteria": "role:manager",
"timeout": 300,
"time_to_live": 3600,
"one_time": true
}
},
"verifiers": {
"secret_scanner": {
"priority": 50,
"enforce": true,
"fields": [
"params.script"
]
}
},
"processors": {
"secret_scrubber": {
"enforce": false
}
},
"denied_parameters": {
"tool:demo/run": {
"notes": [
"{derived.secrets.count > 1}"
]
}
}
},
"verifiers": [
"secret_scanner"
],
"processors": [
"secret_scrubber"
]
}
The workspace needs secrets, secret_scanner and secret_scrubber before
this policy can be saved. Add them from the library.
| Call | derived.secrets.count |
Result | Enforced by |
|---|---|---|---|
clean script |
— | ✅ allowed, token in the result masked | processor |
script contains an AWS key |
— | ❌ refused before the tool runs | verifier |
notes has no credential |
0 |
✅ allowed | extractor + rule |
notes has one credential |
1 |
⏳ blocks for role:manager approval |
extractor + attestation |
notes has two credentials |
2 |
❌ refused | extractor + denial rule |
The middle row is what a verifier cannot express. A verifier returns pass or
fail, so "this needs a person" has no way to be said. The same count that
refuses at two requires an approver at one.
Changing > 1 to > 2 allows the last call, and changing == 1 to >= 1
requires approval for both. The extractor is unchanged in either case.
A runnable version is in examples/policy_driven_verifiers.py.
Limitations
| Limitation | Detail |
|---|---|
| Client version | The enforcement point ships in the client. Policies using derived.* are inert below 0.9.9.11. Clients before 0.9.20 run only the functions compiled into their release. They refuse a call whose policy names any other extractor or verifier, and skip any other processor. |
| No chaining | An extractor cannot read derived. |
| Processors combine two jobs | secret_scrubber detects and transforms. A result extractor feeding a policy-selected processor is not implemented. |
| No state between calls | A function cannot count, cache or remember. Limits over time belong to policy: rate_limit, one_time and time_to_live. |
| Regular expressions | The bound counts the function's own lines. Time spent inside re is not counted, and at the enforcement point nothing else bounds it. The 3-second sample run at save is the check, so give samples the longest inputs you expect. |
| The caller in samples | Samples run with a stand-in caller that has no roles, so a verifier that reads roles cannot be tested on them. |
| Library updates | A newer library version reaches a workspace only when someone adds it. |
Part 8: Complete Tutorial Example
Let's build a complete policy system for a financial services company.
The Scenario
FinTech Corp has:
- Company-wide security baseline
- Analytics Business Unit (BU)
- Reporting Team
- Alice (Junior Analyst)
- Bob (Manager)
- Reporting Team
- All users access OpenAI for financial analysis
Requirements:
- Company: Max 4000 tokens, all OpenAI models allowed
- Analytics BU: Max 2000 tokens, low temperature for consistency
- Reporting Team: Max 1000 tokens
- Alice: GPT-3.5 only, 500 tokens max
- Bob: GPT-3.5 and GPT-4, 2000 tokens max
- Large trades (>$5000) require manager approval
Step 1: Company-Wide Base Policy
{
"policy_id": "company:FinTech",
"version": "1.0",
"description": "FinTech Corp company-wide policy",
"resources": [
"llm:openai/*",
"tool:trade/*"
],
"denied_resources": [
"secrets:**"
],
"attestations": [
"identity_verified"
],
"constraints": {
"rate_limit": 100,
"attestations": {
"identity_verified": { "internal": true }
},
"parameters": {
"llm:openai/chat.completions": {
"model": ["gpt-3.5-turbo", "gpt-4"],
"max_tokens": {"max": 4000},
"temperature": {"min": 0, "max": 1.0}
}
}
}
}
Step 2: Analytics BU Policy
{
"policy_id": "bu:Analytics",
"version": "1.0",
"extends": "company:FinTech",
"description": "Analytics BU - deterministic results",
"attestations": [
"trade_approved::{params.amount > 5000}"
],
"constraints": {
"rate_limit": 50,
"parameters": {
"llm:openai/chat.completions": {
"max_tokens": {"max": 2000},
"temperature": {"max": 0.3}
}
},
"attestations": {
"trade_approved": {
"approval_criteria": "role:manager",
"timeout": 300,
"time_to_live": 3600,
"one_time": true
}
}
}
}
Step 3: Reporting Team Policy
{
"policy_id": "team:Reporting",
"version": "1.0",
"extends": "bu:Analytics",
"description": "Reporting team under Analytics BU",
"constraints": {
"rate_limit": 30,
"parameters": {
"llm:openai/chat.completions": {
"max_tokens": {"max": 1000}
}
}
}
}
Step 4: Alice's Policy (Junior Analyst)
{
"policy_id": "user:alice",
"version": "1.0",
"extends": "team:Reporting",
"description": "Alice - Junior Financial Analyst",
"resources": [
"llm:openai/chat.completions"
],
"constraints": {
"rate_limit": 10,
"parameters": {
"llm:openai/chat.completions": {
"model": ["gpt-3.5-turbo"],
"max_tokens": {"max": 500},
"temperature": {"max": 0.5}
}
}
},
"denied_resources": [
"data:executive/*",
"data:confidential/*"
]
}
Step 5: Bob's Policy (Manager)
{
"policy_id": "user:bob",
"version": "1.0",
"extends": "team:Reporting",
"description": "Bob - Finance Manager",
"constraints": {
"rate_limit": 30,
"parameters": {
"llm:openai/chat.completions": {
"model": ["gpt-3.5-turbo", "gpt-4"],
"max_tokens": {"max": 2000},
"temperature": {"max": 0.8}
}
}
}
}
Final Effective Policies
Alice's Final Policy:
{
"resources": ["llm:openai/chat.completions", "tool:trade/*"],
"denied_resources": ["secrets:**", "data:executive/*", "data:confidential/*"],
"attestations": ["identity_verified", "trade_approved::{params.amount > 5000}"],
"constraints": {
"rate_limit": 10,
"parameters": {
"llm:openai/chat.completions": {
"model": ["gpt-3.5-turbo"],
"max_tokens": {"max": 500},
"temperature": {"min": 0, "max": 0.3}
}
}
}
}
Bob's Final Policy:
{
"resources": ["llm:openai/*", "tool:trade/*"],
"denied_resources": ["secrets:**"],
"attestations": ["identity_verified", "trade_approved::{params.amount > 5000}"],
"constraints": {
"rate_limit": 30,
"parameters": {
"llm:openai/chat.completions": {
"model": ["gpt-3.5-turbo", "gpt-4"],
"max_tokens": {"max": 1000},
"temperature": {"min": 0, "max": 0.3}
}
}
}
}
Testing the Policies
Alice tries to execute trades:
# Test 1: Alice, small trade ($1000)
client.invoke_tool("trade/execute", {"trade_id": "T-001", "amount": 1000})
# Result: ✅ SUCCESS (identity_verified required, trade_approved NOT required)
# Test 2: Alice, large trade ($10000)
client.invoke_tool("trade/execute", {"trade_id": "T-002", "amount": 10000})
# Result: ⏳ BLOCKS waiting for manager approval
# After Bob approves: ✅ SUCCESS
Alice tries to call OpenAI:
# Test 1: Alice, gpt-3.5-turbo, 400 tokens
request = {"model": "gpt-3.5-turbo", "max_tokens": 400}
# Result: ✅ SUCCESS
# Test 2: Alice, gpt-3.5-turbo, 600 tokens
request = {"model": "gpt-3.5-turbo", "max_tokens": 600}
# Result: ❌ BLOCKED - "max_tokens=600 exceeds maximum: 500"
# Test 3: Alice, gpt-4, 400 tokens
request = {"model": "gpt-4", "max_tokens": 400}
# Result: ❌ BLOCKED - "model=gpt-4 not in allowed values"
Part 9: Best Practices
1. Start with Least Privilege
Inheritance only narrows, so the top of the hierarchy holds the broadest allow-list and each level below restricts it. Keep that top list to what the organization actually uses:
{
"policy_id": "company:acme",
"resources": ["llm:openai/*", "tool:reporting/*", "tool:database/query"],
"denied_resources": ["admin:**"]
}
A child cannot grant what its parent does not allow, and a denial is permanent: "denied_resources": ["**"] at the top would deny everything below it.
2. Use Clear Policy IDs
Follow a consistent naming convention:
company:{name}- Top-level company policybu:{name}- Business unit policiesteam:{name}- Team policiesuser:{username}- User-specific policies
Make ids differ in a letter, digit, _, - or .. The control plane names a
policy's files after its id with every other character replaced by _, sodata:sales/orders and data:sales_orders would share files, and the second
one is refused.
3. Document Your Policies
Always include meaningful descriptions:
{
"policy_id": "team:analysts",
"description": "Financial analysts: GPT-3.5 only, 500 token limit, read-only finance data",
"version": "1.2.0"
}
4. Version Your Policies
The control plane numbers versions. A new policy keeps the version it is saved
with. Each later save becomes the next version (1.0.0 → 1.0.1), whatever
version the saved policy names, and the previous one is archived with its
history. A policy that is disabled and added again continues from its last
version. Say what changed in description.
5. Use Wildcards Wisely
{
"resources": [
"llm:openai/chat.completions", // ✅ Specific first
"llm:openai/text-embedding-*", // ✅ Targeted wildcard
"tool:database/query" // ✅ Specific operation
],
"denied_resources": [
"llm:openai/gpt-4*", // ✅ Block expensive models
"admin:**" // ✅ Block all admin operations
]
}
Avoid overly broad wildcards like ** unless necessary.
6. Leverage Inheritance for DRY
Don't repeat constraints across policies:
// ✅ GOOD: Define once in company policy, inherit everywhere
// company.json
{
"constraints": {
"parameters": {
"llm:**": {"max_tokens": {"max": 4000}}
}
}
}
// team-analysts.json (only override what changes)
{
"extends": "company:acme",
"constraints": {
"parameters": {
"llm:**": {"max_tokens": {"max": 500}}
}
}
}
7. Use Conditional Attestations for Tiered Approval
{
"attestations": [
"team_lead_approval::{params.amount > 1000 AND params.amount <= 10000}",
"manager_approval::{params.amount > 10000 AND params.amount <= 50000}",
"director_approval::{params.amount > 50000}"
],
"constraints": {
"attestations": {
"team_lead_approval": { "approval_criteria": "role:team-lead", "timeout": 300 },
"manager_approval": { "approval_criteria": "role:manager", "timeout": 300 },
"director_approval": { "approval_criteria": "role:director", "timeout": 600 }
}
}
}
Declare every tier. An undeclared tier denies every call, including those below its threshold.
8. Set Appropriate Timeouts for External Attestations
{
"constraints": {
"attestations": {
"urgent_approval": {
"approval_criteria": "role:manager",
"timeout": 60, // Short timeout for urgent
"time_to_live": 300
},
"standard_approval": {
"approval_criteria": "role:manager",
"timeout": 3600, // 1 hour for standard
"time_to_live": 86400
}
}
}
}
9. Use one_time for Security-Critical Attestations
{
"constraints": {
"attestations": {
"trade_approved": {
"one_time": true // Prevents replay attacks
}
}
}
}
10. Monitor and Audit
Audit logging is configured on the MACAW service. Review audit logs regularly for:
- Unexpected denials
- Policy violations
- Usage patterns
- Attestation failures
11. Create a :base for Every Namespace You Reference
Object references fail closed. Without data:base, a reference to an unclassified object denies the call.
{
"policy_id": "data:base",
"version": "1.0",
"description": "Floor for every dataset",
"resources": ["**"],
"denied_resources": ["tool:*/export"]
}
Make the base restrictive. It is what protects the objects nobody has classified yet.
12. Put Object Rules on the Object
If a rule is about a dataset, write it in that dataset's policy — not as a parameter constraint on every tool that might touch it. One object policy applies to every tool, including tools added later.
// Prefer this - one policy, applies everywhere
{ "policy_id": "data:employee_comp", "denied_resources": ["tool:*/export"] }
// Over this - repeated per tool, and misses the next tool you add
{ "constraints": { "denied_parameters": { "tool:reporting/export": { "dataset": ["employee_comp"] } } } }
Part 10: Troubleshooting
Common Issue 1: Access Denied Unexpectedly
Symptom: Request is denied but should be allowed.
Debugging steps:
Check inheritance chain: Verify which policies are being merged. With DEBUG logging (step 5), the resolver logs each policy it loads and the chain it merges.
Check denied_resources: Denials override allows
{ "resources": ["llm:openai/*"], "denied_resources": ["llm:openai/gpt-4"] // ← This blocks gpt-4! }Check wildcards: Ensure patterns match intended resources
llm:openai/*matchesllm:openai/chat.completions- But NOT
llm:openai/v1/chat.completions(too deep!) - Use
llm:openai/**for multi-level
Check parameter constraints: Even if resource is allowed, constraints can cause denial
Enable debug logging:
export MACAW_LOG_LEVEL=DEBUG python your_app.py
Common Issue 2: Attestation Verification Failing
Symptom: "Missing or invalid attestation" error but attestation was created.
Debugging steps:
Check where it was recorded:
- A tool's attestation is kept for the caller whose call recorded it, in the service whose tool recorded it
- Another caller, a tool in another service, or the same service after a restart does not see it
Check its type:
- A name declared as an approval or a built-in is looked up only in the control plane's store
- A tool that attests the same name does not satisfy it
Check conditional attestation evaluation:
- Is the condition evaluating to true?
- Check parameter values:
params.amount > 5000- is amount actually > 5000?
Enable attestation debug logging:
import logging logging.getLogger("macaw.protocol.mcp_types").setLevel(logging.DEBUG)Check metadata expiration:
# time_to_live expired? if "expires_at" in attestation: import time if time.time() > attestation["expires_at"]: print("Attestation expired!") # one_time already used? if attestation.get("one_time") and "use_count" in attestation: print("Attestation already consumed!") # max_uses exhausted? if attestation.get("max_uses") and attestation.get("use_count", 0) >= attestation["max_uses"]: print("Attestation max_uses exhausted!")Verify ToolAgent registered:
- Check if ToolAgent is in registry
- Is the ToolAgent's public key in the registry?
Common Issue 3: External Attestation Not Blocking
Symptom: Request denied immediately instead of waiting for approval.
Debugging steps:
Check timeout value:
{ "attestations": { "trade_approved": { "approval_criteria": "role:manager", "timeout": 300 // Must be > 0 to block } } }Check approval_criteria is present:
- External attestations require
approval_criteria - Without it the attestation is not an approval: a definition with no type only adds metadata, and a name declared
internalorclaimnever waits for anyone
- External attestations require
Check other policies that define the same attestation:
- When several policies define it, the shortest
timeoutapplies
- When several policies define it, the shortest
Check condition evaluation:
- Is the conditional attestation actually required?
- Verify:
trade_approved::{params.amount > 5000}- is amount > 5000?
Common Issue 4: Conditional Attestation Not Triggering
Symptom: Attestation should be required but isn't.
Cause: The condition evaluated to false. A comparison on a parameter the call does not send is false, so a misspelled parameter name means the requirement never applies.
Debugging steps:
Check parameter names against what the tool sends:
// ❌ Wrong parameter name "trade_approved::{params.total > 5000}" // Should be 'amount' // ✅ Correct "trade_approved::{params.amount > 5000}"Check boolean logic:
// Both must be true "approval::{params.a > 10 AND params.b < 5}" // Either can be true "approval::{params.a > 10 OR params.b < 5}" // NOT binds tightest; group with parentheses when negating more "approval::{NOT (params.a > 10 OR params.b < 5)}"
Common Issue 5: Attestation Required on Every Call
Symptom: A conditional attestation, or a {...} denial pattern, applies to every call regardless of parameter values.
Cause: The condition is malformed, and MAPL fails closed: it requires the attestation, or denies the call for a denial pattern.
Debugging steps:
Check the logs: the message names the condition that could not be evaluated.
Check for these common mistakes:
// ❌ Unknown operator "trade_approved::{params.amount => 5000}" // Use >= // ❌ Parameter used without a comparison "override_ok::{params.urgent}" // Use params.urgent == true // ❌ Unbalanced parentheses "approval::{(params.a > 10 AND params.b < 5}"
Common Issue 6: External Attestation Approval Rejected
Symptom: "Not authorized: caller does not match criteria" when approving.
Debugging steps:
Check approver's roles match approval_criteria:
# approval_criteria: "role:manager" # Bob's JWT must have "manager" in realm_access.roles claims = decode_jwt(bob_token) print(claims['realm_access']['roles']) # Should include 'manager'Check approval_criteria format:
// For exact user match "approval_criteria": "user:bob@acme.com" // For role match (most common) "approval_criteria": "role:manager" // Bare name (treated as role) "approval_criteria": "manager"Ensure approver is registered:
- Bob must call
client.register()before approving - Bob's roles are extracted from JWT claims and stored in registry
- Check:
metadata.principal.rolesin registry entry
- Bob must call
Verify approver can see the attestation:
# Bob should see attestations matching his approval_criteria attestations = client.list_attestations(status="pending") if not attestations: print("No pending attestations visible - check roles")
Common Issue 7: Object Reference Denies Everything
Symptom: Every call is denied with "not in allowed resources", and the policy chain shows an object layer.
Cause: A reference didn't resolve. The referenced policy doesn't exist, and neither does its {namespace}:base. Or the substituted value is not an object name: it holds a character outside A–Z a–z 0–9 _ . / - @ #, or ... Object references fail closed, so either denies the call.
Diagnosis: look for the resolver warning naming the id it couldn't load, or the value it refused.
[RESOLVER] Object reference data:employee_comp did not resolve: Policy not found
[RESOLVER] Bad object reference 'data:{params.dataset}': unsafe value for {params.dataset}: 'q1 report'
Fix: create the object's policy, or create the namespace base:
{
"policy_id": "data:base",
"version": "1.0",
"resources": ["**"],
"denied_resources": ["tool:*/export"]
}
Common Issue 8: Dynamic Reference Never Fires
Symptom: "attestations": ["data:{params.dataset}"] appears to do nothing. No object policy is loaded, and no denial occurs.
Cause: the parameter isn't present on the invocation. A reference whose parameter is absent doesn't apply — that's deliberate, so one reference can sit at company level across tools that don't all share the parameter.
Fix: check the actual parameter name the tool sends. If the tool calls it table, the reference must be data:{params.table}.
Common Issue 9: Claim-Based Attestation Not Proven
Symptom: Attestation 'writer' is not proven by the requester's token for someone who holds the role.
Debugging steps: a policy sees the claims the identity bridge maps from the token.
- The field is mapped. The identity provider's claims mapping includes it (
roles_path,scopes_path, or a custom field). - The role filter allows it. The role is in the provider's
role_filter.allowed. Withcase_sensitive: false, roles reach policies in lowercase; set it totrueto keep the identity provider's spelling. - The token carries it. Decode the token and look for the claim. A role assigned after the person signed in appears in their next token.
- The right requester. In a chained invocation, the token is that of the person who started the request.
Common Issue 10: Every Call Is Refused with "could not be evaluated"
The policy reached the enforcement point without its extractors block. Read
the policy back from the store and confirm the key survived. A control plane
below 0.9.9.11 drops keys it does not recognise when saving, which leaves the
rule in place and removes the extractor that supplies its input.
Common Issue 11: A Condition on derived.* Never Fires
Confirm the extractor produces the key being read. derived.sql.bonus isfalse when the extractor ran and found no bonus, which is correct for a
statement that does not assign that column.
Common Issue 12: A Verifier Does Not Run
Check both halves. constraints.verifiers.<name> declares it andverifiers: [...] applies it. A declaration without an application is a policy
that reads as protected and is not.
Common Issue 13: A Processor Does Not Modify the Result
Confirm fields names a path that exists in the result. An absent path is
skipped, not reported.
Common Issue 14: A Function Will Not Save
Symptom: Run fails in the function editor, and Save stays disabled.
Cause: the source uses something the allowlist does not name, or a sample
does not return what it expects. Messages that begin Line come from the
allowlist; messages that begin sample come from a sample.
Fix: Appendix D.5 lists each message and its cause, and
Appendix D.2 lists what a function may use. the samples did not finish within 3 s means a loop or a regular expression is too slow on
one of the samples.
Common Issue 15: A Policy Will Not Save
Symptom: Policy names functions this workspace does not have: verifiers/secret_scanner
Cause: the policy names an extractor, verifier or processor the workspace
does not have.
Fix: open Policies → Functions, add the function from the library or
write it, then save the policy again.
Common Issue 16: Calls Refused with "ran more than 1,000,000 lines"
Symptom: a call that names a function is refused, or its result withheld,
with ran more than 1,000,000 lines.
Cause: the function loops longer than its bound on this input.
Fix: add the input as a sample and run it in the editor. To watch a
verifier or processor on live traffic while you fix it, set enforce: false.
Common Issue 17: Every Call Is Refused with "cannot be resolved"
Symptom: The audit record shows a policy that allows nothing, with the reason Policy team:trading cannot be resolved: ...
Cause: The policy's extends cannot be met, so the policy denies everything:
| Reason | Fix |
|---|---|
its parent bu:finance does not exist |
Restore the parent, or name one that exists |
Circular inheritance detected |
Break the cycle in the extends chain |
a definition the parent and child make two ways, such as The verifier 'secret_scanner' is declared differently by two policies |
Make the two declarations agree, or keep one |
Common Issue 18: Calls Refused Because of an Attestation's Declaration
Symptom: a call is denied with one of these, before any approver is asked:
| Message | Cause | Fix |
|---|---|---|
Attestation 'tests_passed' is required but not declared |
No policy that applies declares its type. This holds for a conditional requirement even when its condition is false | Declare it under constraints.attestations: "internal": true, approval_criteria or claim |
Attestation 'trade_approved' is defined as approval and as internal |
Two policies that apply declare it as different types | Give the name one type in every policy, or rename one |
Attestation 'idp_verified' is built in, and a policy cannot define it |
A policy defines a built-in attestation | Remove the definition and require it by name |
See Attestation Types.
Common Issue 19: A Function Will Not Disable
Symptom: the function's page shows In use · N policies instead of Disable, and the API answers Used by app:memo. Remove it from that policy first.
Cause: a policy applies the function. A policy that names a function the workspace does not have denies every call it covers, so the function stays while any policy names it.
Fix: remove the function from the policies listed under Used by, then disable it.
Getting Help
If you encounter issues:
- Check audit logs for policy evaluation traces
- Enable DEBUG logging for detailed policy resolution
- Validate policies before saving them (Console → Policies)
- Review this guide's examples and best practices
Appendix A: Quick Reference
Complete Policy Template
{
"policy_id": "{scope}:{name}",
"version": "1.0",
"extends": "parent-policy-id",
"description": "Human-readable description",
"validity": {
"not_before": "2026-01-01T00:00:00Z",
"not_after": "2026-12-31T23:59:59Z"
},
"resources": [
"operation-pattern",
"llm:openai/*",
"tool:database/query"
],
"denied_resources": [
"admin:**",
"secrets:**"
],
"attestations": [
"always_required",
"conditional::{params.amount > threshold}",
"data:some_object",
"data:{params.dataset}"
],
"extractors": {
"extractor_name": {"field": "params.param_name"}
},
"verifiers": [
"verifier_name",
"conditional_verifier::{params.env == 'prod'}"
],
"processors": ["processor_name"],
"constraints": {
"rate_limit": 100,
"operation-pattern": {
"attestations": ["attestation_key"]
},
"parameters": {
"operation-pattern": {
"param_name": {
"type": "integer|number|string|boolean|array|object",
"min": 0,
"max": 1000,
"range": [0, 1000],
"allowed_values": ["value1", "value2"],
"pattern": "^regex$",
"min_length": 1,
"max_length": 100,
"min_items": 1,
"max_items": 100
}
}
},
"denied_parameters": {
"operation-pattern": {
"param_name": ["*dangerous*", "*DROP TABLE*"]
}
},
"attestations": {
"always_required": { "internal": true },
"conditional": { "claim": { "roles": "role-name" } },
"attestation_key": {
"approval_criteria": "role:manager",
"timeout": 300,
"time_to_live": 3600,
"one_time": true,
"priority": 100
}
},
"verifiers": {
"verifier_name": {"priority": 100, "enforce": true, "fields": ["params.param_name"]}
},
"processors": {
"processor_name": {"priority": 50, "enforce": true}
}
}
}
An operation pattern inside constraints can carry its own attestations, required only for calls it matches; every pattern that matches a call applies. rate_limit is calls per minute for each caller and operation: 0 sets no limit and does not lift another level's.
extractors, verifiers and processors name functions in the workspace. See Part 7 and Appendix D.
Attestation Formats
Simple (always required):
"attestations": ["identity_verified", "mfa_complete"]
Conditional (required when condition is true):
"attestations": [
"trade_approved::{params.amount > 5000}",
"manager_override::{params.priority == 'urgent' AND params.amount > 10000}"
]
Policy reference (loads another policy):
"attestations": [
"data:some_object", // static
"data:{params.dataset}" // built from the invocation
]
Each attestation name is declared under constraints.attestations (see Attestation Types). An undeclared one denies the call, conditional or not.
A key with a lowercase namespace prefix names a policy to load. Conditions are not allowed on a reference. See Part 6.
Namespace Fallback
| Requested | Falls back to |
|---|---|
data:finance/transactions (missing) |
data:base |
data:base (missing) |
nothing — a reference to it denies the call |
extends: "team:missing" |
nothing — the policy denies everything |
The fallback is one level deep, to the namespace. Path segments are not a hierarchy. extends has no fallback: it names its parent.
Condition Syntax Reference
| Pattern | Example |
|---|---|
| Parameter comparison | params.amount > 5000 |
| String equality | params.currency == 'USD' |
| In list | params.region IN ('us', 'eu') |
| Glob pattern match | params.command MATCHES '*rm -rf*' |
| Boolean parameter | params.urgent == true |
| Attestation check | context.has_attestation('mfa') |
| Boolean AND | params.a > 10 AND params.b < 5 |
| Boolean OR | params.x == 'a' OR params.x == 'b' |
| Negation | NOT params.currency == 'USD' |
| Grouping | (params.a > 10 OR params.b > 10) AND params.urgent == true |
NOT binds tightest, then AND, then OR.
Attestation Metadata Fields
| Field | Type | Description |
|---|---|---|
approval_criteria |
string | Who can approve (makes it external attestation); comma-separate criteria that must all be met |
claim |
object or list | The claims that prove it (makes it claim-based) |
internal |
boolean | true makes it internal: a fact a tool records |
timeout |
integer | Seconds to wait (0 or omitted = don't block, >0 = block) |
time_to_live |
integer | Validity duration after approval, in seconds (0 or omitted = no expiry) |
one_time |
boolean | Consume after single use (default: true) |
priority |
integer | Processing order (higher = first, default: 0) |
claim |
object or list | Claims the requester's token must carry (claim-based attestation) |
Attestation Status Lifecycle
| Status | Description |
|---|---|
pending |
Awaiting approval (external) or set (internal) |
approved |
Approved by authorized party |
denied |
Rejected by authorized party |
expired |
Time window (TTL) elapsed |
approved + alive=false |
Consumed: one-time attestation used (status remains approved, but inactive) |
approval_criteria Format
| Format | Example | Matches |
|---|---|---|
user:xxx |
user:alice |
caller_id or email == "alice" |
user:xxx@domain |
user:alice@fintech.com |
Exact match on caller_id or email |
role:xxx |
role:manager |
Approver has role from IDP/JWT |
xxx (bare) |
manager |
Treated as role:manager |
| Comma-separated | role:manager,role:compliance |
Approver meets every listed criterion |
Attestation Audit Events
| Event | When | Key Fields |
|---|---|---|
attestation_created |
Pending request created | key, for_agent, approval_criteria |
attestation_approved |
Approver granted | approved_by, reason, signature |
attestation_denied |
Approver rejected | denied_by, reason |
attestation_accessed |
Grant reused (one_time=false) | invocation_id, accessed_by |
attestation_consumed |
One-time used up | consumed_at |
attestation_expired |
Timeout/TTL exceeded | expires_at, reason |
All Constraint Types
| Constraint | Applies To | Format | Example |
|---|---|---|---|
type |
All | String | {"type": "integer"} |
min |
Numbers | Number | {"min": 0} |
max |
Numbers | Number | {"max": 4000} |
range |
Numbers | [min, max] |
{"range": [0, 100]} |
allowed_values |
All | Array | {"allowed_values": ["A", "B"]}, {"allowed_values": [false]} |
pattern |
Strings | Regex matching the whole value | {"pattern": "^[a-z]+$"} |
min_length |
Strings | Integer | {"min_length": 3} |
max_length |
Strings | Integer | {"max_length": 255} |
min_items |
Arrays | Integer | {"min_items": 1} |
max_items |
Arrays | Integer | {"max_items": 100} |
Inheritance Merge Rules
| Constraint | Merge Strategy | Example |
|---|---|---|
resources |
Domain-Aware | Child restricts within domains it mentions |
denied_resources |
Union | Both enforced |
attestations |
Union | All required |
range |
Intersection | Parent [0,100] + Child [10,50] = [10,50] |
min, min_length, min_items |
Maximum | Parent min:0 + Child min:10 = min:10 |
max, max_length, max_items |
Minimum | Parent max:100 + Child max:50 = max:50 |
allowed_values |
Intersection | Parent ["A","B","C"] + Child ["B","C","D"] = ["B","C"] |
type, pattern, list shorthand |
Both apply | A value must satisfy both |
denied_parameters |
Union | Both patterns enforced |
validity |
Overlap | Later not_before, earlier not_after |
Attestation timeout |
Minimum | Parent 300 + Child 60 = 60 |
Attestation time_to_live |
Shorter positive duration | Parent 0 (no expiry) + Child 300 = 300 |
Attestation one_time |
true if either sets it |
Parent false + Child true = true |
Attestation type (internal, claim, approval_criteria) |
Must be the same | Internal in one policy, a claim in another: the call is denied |
Attestation approval_criteria |
All criteria | role:manager + role:compliance = both required |
Attestation priority |
Maximum | Parent 0 + Child 100 = 100 (higher wins) |
verifiers, processors |
Union | Every layer's are applied |
extractors |
Union by name | A layer may add one |
Function enforce |
true if either enforces |
Parent false + Child unset = true |
Function priority |
Minimum | Parent 10 + Child 90 = 10 (runs earlier) |
A function's own keys (field, fields, …) |
Must be equal | Different fields for one verifier: the call is refused |
These rules apply to extends and to policies combining at request time, except resources: at request time, levels intersect directly.
Appendix B: Theoretical Foundations
Composition Algebra
A policy P = (R, D, A, C) consists of:
- R: Allowed resource patterns
- D: Denied resource patterns
- A: Required attestations (simple and conditional)
- C: Operational constraints (parameters, attestation metadata)
Policy Intersection P1 ∩ P2 = (R', D', A', C') where:
- R' = R1 ∩ R2 (allow only if both policies permit)
- D' = D1 ∪ D2 (deny if either policy forbids)
- A' = A1 ∪ A2 (all attestations required)
- C' = MostRestrictive(C1, C2) (apply tightest constraint)
Formal Security Properties
Theorem 1 (Monotonic Restriction): For composition P0 ∩ ... ∩ Pn:
∀i, j : (i < j) ⇒ π(P0 ∩ ... ∩ Pj) ⊆ π(P0 ∩ ... ∩ Pi)
Proof Sketch: By construction, Pi+1 = Pi ∩ Pnext. Resource intersection: Ri+1 ⊆ Ri. Denial union: Di+1 ⊇ Di. Therefore π(Pi+1) ⊆ π(Pi). By induction, π(Pj) ⊆ π(Pi) for i < j. □
Theorem 2 (Transitive Denial): If resource r is denied by any policy, it remains denied:
∃i: r ∈ Match(Di) ⇒ r ∈ Match(Deff)
Proof Sketch: Deff = D0 ∪ D1 ∪ ... ∪ Dn. If r ∈ Di for any i, then r ∈ Deff by set union. □
Theorem 3 (No Privilege Escalation): If base policy P0 denies r, no composition grants access:
(r ∈ Match(D0)) ⇒ r ∉ Allowed(Peff)
Proof: By Theorem 2, if r ∈ Match(D0), then r ∈ Match(Deff). By definition of π, denied resources cannot be allowed. □
Attestation Security Properties
Theorem 4 (Unforgeable Attestations): An adversary without access to a ToolAgent's private key cannot create a valid attestation that verifies under that ToolAgent's public key.
Proof Sketch: Follows from the security of the signature scheme (RSA/Ed25519). □
Theorem 5 (Metadata Binding): An adversary cannot modify attestation metadata (time_to_live, one_time, max_uses) without invalidating the signature.
Proof Sketch: Metadata is included in the canonical representation signed by the ToolAgent. Any modification changes the signed data, causing signature verification to fail. □
Theorem 6 (Conditional Attestation Soundness): A conditional attestation key::{condition} is required if and only if the condition evaluates to true at runtime. (A condition that cannot be evaluated counts as true: the attestation is required, so it fails closed.)
Proof Sketch: The condition reads the invocation parameters and existing attestations. Evaluation is deterministic and side-effect-free. □
Security Implications
These theorems provide formal guarantees:
- Theorem 1 prevents privilege expansion
- Theorem 2 ensures any layer's denial is absolute
- Theorem 3 prevents escalation
- Theorems 4-6 ensure attestations are cryptographically verifiable and unforgeable
- Theorem 6 ensures conditional attestations are correctly enforced
Appendix C: Future Enhancements
The following features are planned for future releases of MAPL:
Phase 2 Enhancements
Enhanced Error Messages with Policy Context:
// Current error:
"Parameter 'max_tokens'=600 exceeds maximum: 500"
// Future error:
"Parameter 'max_tokens'=600 exceeds maximum: 500
Policy Chain: company:acme → bu:finance → team:analysts → user:alice
Constraint Source: team:analysts.json (line 15)
Resolved Path: 4000 → 2000 → 500 (most restrictive)"
Performance Optimizations:
- Condition evaluation caching
- Policy resolution caching
- Optimized inheritance chain evaluation
Phase 3 Enhancements (Advanced)
Array Item Validation:
{
"constraints": {
"parameters": {
"database:batch_insert": {
"records": {
"type": "array",
"max_items": 1000,
"items": {
"type": "object",
"required": ["id", "name"]
}
}
}
}
}
}
Nested Object Validation:
{
"constraints": {
"parameters": {
"user:create": {
"profile": {
"type": "object",
"properties": {
"email": {"pattern": "^[^@]+@[^@]+$"},
"age": {"min": 18, "max": 120}
}
}
}
}
}
}
Appendix D: Function Reference
The contract for extractors, verifiers and processors.
Part 7 explains how to use them.
D.1 Entry Points
| Kind | Entry point | Receives | Must return |
|---|---|---|---|
| Extractor | extract(value, **config) |
a copy of the value at the declared field; None if absent |
dict |
| Verifier | verify(invocation, context, **config) |
copies of the invocation and the caller's context | VerificationResult |
| Processor | process(result, invocation, context, **config) |
copies of the tool's result, the invocation and the context | ProcessingResult |
config is the policy's declaration of the function, less the keys the
enforcement point uses:
| Kind | Declared in | Not passed to the function |
|---|---|---|
| Extractor | extractors.<name> |
field |
| Verifier | constraints.verifiers.<name> |
priority, enforce |
| Processor | constraints.processors.<name> |
priority, enforce |
What a function can read:
| Object | Fields |
|---|---|
invocation |
params, resource_id, operation, tool_name, invocation_id, context_id, timestamp, prompt |
context |
principal_context, attestations, has_attestation(name), embedded_context, metadata, context_id |
context.principal_context |
user_id, roles, groups, attributes, has_role(role), has_group(group) |
result (processor) |
result (the payload), status, invocation_id, context_id |
What a function returns:
| Type | Constructor | Meaning |
|---|---|---|
VerificationResult |
VerificationResult(success, message="", metadata=None) |
success=False refuses the call when the verifier enforces. message appears in the denial and the audit log, so name what was found and leave out its value. |
ProcessingResult |
ProcessingResult(result, modified=False, success=True, message="", metadata={}) |
result is the result object it was handed, with the payload at result.result. success=False withholds the result when the processor enforces. |
D.2 The Allowlist
A function's source is checked when it is saved, and again when an
enforcement point loads it. Everything in it must be on these lists.
Top level: function definitions, constants assigned to plain names, and a
docstring. A constant is a str, int, float, bool, None, a compiled
pattern, or a tuple or frozenset of constants. Argument defaults are
constants too.
Statements and expressions:
| Allowed | def (nested too), assignment, augmented assignment, if, for, while, break, continue, pass, return, raise, try / except / finally, lambda, comprehensions, conditional expressions, f-strings, slices, and the arithmetic, comparison, boolean and bitwise operators |
| Refused | import, class, with, global, nonlocal, del, assert, yield, async / await, :=, match, and an except that names no exception |
Names a function may read, besides its own variables, arguments and
helpers:
| Group | Names |
|---|---|
| Builtins | abs all any bool dict enumerate float frozenset int isinstance len list max min range reversed round set sorted str sum tuple zip |
| Exceptions | Exception ValueError KeyError TypeError IndexError |
re |
compile search match fullmatch findall finditer sub subn split escape IGNORECASE MULTILINE DOTALL I M S |
json |
dumps loads |
| Results | VerificationResult ProcessingResult |
Every name a function binds starts with a letter.
Attributes a function may use:
| On | Attributes |
|---|---|
| The objects in D.1 | params resource_id operation context_id invocation_id tool_name timestamp prompt metadata principal_context user_id roles groups attributes embedded_context attestations has_role has_group has_attestation result status success message modified |
| Strings | lower upper strip lstrip rstrip split rsplit splitlines startswith endswith replace find rfind count join isdigit isalpha isalnum isspace casefold partition rpartition |
| Lists, dicts and sets | append extend insert pop remove index sort reverse copy clear get items keys values setdefault update add discard union intersection difference issubset issuperset |
| Patterns and matches | the re names above, and pattern group groups groupdict start end span |
D.3 Limits
| Limit | Value | Checked |
|---|---|---|
| Lines per call | 1,000,000 of the function's own lines | at every call |
| Sample run | 3 seconds for all of a function's samples | at save |
| Samples | at least one | at save |
| Name | lowercase letters, digits and _, starting with a letter |
at save |
D.4 Samples
A sample is a JSON object. Its input takes the form the kind receives:
| Kind | A sample | Passes when |
|---|---|---|
| Extractor | {"value": …, "expect": {facts}} |
the returned facts equal expect |
| Verifier | {"params": {…}, "expect": "pass"}, or "fail" |
the result's success agrees |
| Processor | {"params": {…}, "result": …, "expect": …} |
the returned payload equals expect |
Any sample may add "config", the declaration a policy would make:
{"params": {"query": "xxxxxxxxxxx"},
"config": {"fields": ["params.query"], "max_length": 10},
"expect": "fail"}
The invocation is built from params, and has no parameters when a sample
leaves it out. The caller is a stand-in with no roles, groups or attestations.
D.5 Messages
When a function is saved:
| Message | Meaning |
|---|---|
Line 6: '.encode' is not available |
an attribute missing from D.2 |
Line 6: 'open' is not available |
a name missing from D.2 |
Line 6: With is not allowed |
a statement or expression missing from D.2 |
Line 6: name the exception to catch |
an except with no exception |
Line 6: argument defaults are constants |
a default that is a list, dict or call |
Line 6: assign top-level values to plain names |
A, B = … or X[0] = … at the top level |
Line 6: the top level may hold functions, constants and a docstring |
a statement other than those at the top level |
'SEEN' is a list; top-level values are constants (str, number, tuple, compiled pattern) |
a top-level value that can change |
'_cache': names start with a letter |
a name that begins with _ |
verify() is missing |
no entry point for the kind |
'Wire-Memo': names are lowercase letters, digits and _ |
the function's name |
A function needs at least one sample |
no samples |
sample 2: expected {'count': 1}, got {'count': 0} |
a sample's result differs from expect |
sample 2: KeyError: 'memo' |
the function raised on a sample |
the samples did not finish within 3 s |
a loop or a regular expression is too slow on a sample |
When a policy is saved:
| Message | Meaning |
|---|---|
Policy names functions this workspace does not have: verifiers/secret_scanner |
add the function, then save the policy |
At the enforcement point (the call is refused, or the result withheld, as
in When a Function Fails):
| Message | Meaning |
|---|---|
Policy names an unknown verifier: 'x' |
the workspace has no such function. Also extractor and processor. |
ran more than 1,000,000 lines |
the function ran past its bound on this input |
verify() returned dict, not a VerificationResult |
the wrong return type. Also process(). |
Extractor 'x' returned list, not facts |
an extractor must return a dict |
Extractor 'x' failed on params.memo: … |
the extractor raised |
Extractor 'x' declares no field to read |
the declaration has no field |
Extractor 'x' may only read params, not 'principal.user_id' |
field is outside params |
D.6 The Library
MACAW's functions, in the library on the control plane. A workspace has one
only after someone adds it under Policies → Functions. Each entry below
gives the function's source, how it works, the samples it ships with, how a
policy declares it, and what a caller sees.
| Function | Kind | Version | What it does |
|---|---|---|---|
secrets |
extractor | 1.0.0 | reports which credential shapes a value contains |
secret_scanner |
verifier | 1.0.0 | refuses a call whose named parameters carry a credential |
length_limit |
verifier | 1.0.0 | refuses a call whose named parameters are too long |
secret_scrubber |
processor | 1.0.0 | masks credentials in the result |
The Credential Shapes
secrets, secret_scanner and secret_scrubber each begin with the same
constant, so what the verifier refuses on the way in and what the processor
masks on the way out are the same shapes:
SECRET_PATTERNS = (
("aws_access_key", re.compile(r"\bAKIA[0-9A-Z]{16}\b")),
("github_token", re.compile(r"\bgh[pousr]_[A-Za-z0-9]{36,}\b")),
("slack_token", re.compile(r"\bxox[baprs]-[0-9A-Za-z-]{10,}\b")),
("google_api_key", re.compile(r"\bAIza[0-9A-Za-z_\-]{35}\b")),
("stripe_key", re.compile(r"\b[rs]k_(?:live|test)_[0-9A-Za-z]{24,}\b")),
("private_key", re.compile(r"-----BEGIN (?:[A-Z ]+ )?PRIVATE KEY-----")),
("jwt", re.compile(r"\beyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b")),
)
| Kind | Shape |
|---|---|
aws_access_key |
AKIA and 16 characters |
github_token |
ghp_, gho_, ghu_, ghs_ or ghr_ and 36 or more |
slack_token |
xoxb-, xoxa-, xoxp-, xoxr- or xoxs- and 10 or more |
google_api_key |
AIza and 35 characters |
stripe_key |
sk_live_, sk_test_, rk_live_ or rk_test_ and 24 or more |
private_key |
a -----BEGIN … PRIVATE KEY----- line |
jwt |
three base64url segments, the first two starting eyJ |
A function cannot import another, so each of the three carries its own copy.
Editing the table in one of them in the workspace leaves the other two as they
were. To add a shape, edit all three.
secrets (extractor)
Reports which credential shapes a value contains, so a policy can decide what
to do about them.
def extract(value, **config):
if value is None:
return {"present": False, "count": 0, "kinds": []}
text = value if isinstance(value, str) else json.dumps(value, default=str)
kinds = []
count = 0
for name, pattern in SECRET_PATTERNS:
found = len(pattern.findall(text))
if found:
kinds.append(name)
count += found
return {"present": count > 0, "count": count, "kinds": kinds}
- An absent field arrives as
Noneand returns the empty facts, so a
condition onderived.secrets.countevaluates instead of failing. - A value that is not a string is searched as its JSON text, so a
credential anywhere inside a dict or list is found. countadds up every match of every shape.kindsnames each shape
found once, in the table's order.
| Reads | the value at field, of any type |
| Returns | {"present": bool, "count": int, "kinds": [str]} |
| Config | none |
Samples:
[
{"value": "deploy --env staging", "expect": {"present": false, "count": 0, "kinds": []}},
{"value": "export KEY=AKIAIOSFODNN7EXAMPLE", "expect": {"present": true, "count": 1, "kinds": ["aws_access_key"]}},
{"value": {"a": "AKIAIOSFODNN7EXAMPLE", "b": "ghp_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},
"expect": {"present": true, "count": 2, "kinds": ["aws_access_key", "github_token"]}},
{"value": null, "expect": {"present": false, "count": 0, "kinds": []}}
]
Declared, and read by a condition:
"extractors": {"secrets": {"field": "params.notes"}},
"attestations": ["security_review::{ derived.secrets.count > 0 }"]
For notes = "export KEY=AKIAIOSFODNN7EXAMPLE", the facts arederived.secrets.present = true, derived.secrets.count = 1 andderived.secrets.kinds = ["aws_access_key"], and the call waits forsecurity_review. The Complete Example uses the same
count three ways.
secret_scanner (verifier)
Refuses a call when a credential is in one of the parameters the policy names.
After SECRET_PATTERNS:
def read(invocation, path):
"""The value at a dotted path such as "params.body.token", or None."""
parts = path.split(".")
if parts[0] != "params":
return None
current = invocation.params
for part in parts[1:]:
if not isinstance(current, dict):
return None
current = current.get(part)
return current
def verify(invocation, context, fields=()):
found = {}
for path in fields:
value = read(invocation, path)
if value is None:
continue
text = value if isinstance(value, str) else str(value)
kinds = [name for name, pattern in SECRET_PATTERNS if pattern.search(text)]
if kinds:
found[path] = kinds
if not found:
return VerificationResult(True, "No credentials found")
# The kinds, never the values: this message reaches logs and audit records.
summary = ", ".join(f"{path} ({', '.join(kinds)})" for path, kinds in sorted(found.items()))
return VerificationResult(
False,
f"Credentials present in {summary}. They have already left the caller - "
f"rotate them rather than only blocking this call.",
metadata={"fields": sorted(found),
"kinds": sorted({k for kinds in found.values() for k in kinds})},
)
readfollows a dotted path underparams. A path outsideparams, or
one that is absent, givesNone, and that field is skipped.fieldsis the only thing it inspects. With none declared, the loop
has nothing to read and the call passes, so always declare the fields.- A value that is not a string is searched as
str(value), which includes
every string inside a dict or list. - The message names each field and the shapes found in it, never the
value, because it reaches the denial, the logs and the audit record.
| Config | fields: dotted parameter paths |
| Fails | Credentials present in params.script (aws_access_key). They have already left the caller - rotate them rather than only blocking this call. The metadata lists fields and kinds. |
| Passes | No credentials found |
Samples:
[
{"params": {"script": "deploy --env staging"}, "config": {"fields": ["params.script"]}, "expect": "pass"},
{"params": {"script": "export KEY=AKIAIOSFODNN7EXAMPLE"}, "config": {"fields": ["params.script"]}, "expect": "fail"},
{"params": {"notes": "export KEY=AKIAIOSFODNN7EXAMPLE"}, "config": {"fields": ["params.script"]}, "expect": "pass"}
]
The third sample is the boundary: a credential in a field the policy did not
name is not inspected.
Declared and applied:
"constraints": {"verifiers": {"secret_scanner": {"fields": ["params.script"]}}},
"verifiers": ["secret_scanner"]
A call with script = "export KEY=AKIAIOSFODNN7EXAMPLE" is refused before the
tool runs, with the message above.
length_limit (verifier)
Refuses a call when one of the parameters the policy names is longer thanmax_length.
After the same read as secret_scanner:
def verify(invocation, context, fields=(), max_length=10000):
over = []
for path in fields:
value = read(invocation, path)
if value is None:
continue
size = len(value) if isinstance(value, str) else len(json.dumps(value, default=str))
if size > max_length:
over.append(f"{path} ({size})")
if over:
return VerificationResult(False, f"Longer than {max_length}: {', '.join(over)}")
return VerificationResult(True, "Within length limits")
- A string is measured in characters. Anything else is measured by its
JSON text, so[1, 2, 3]has length 9. - An absent field is skipped. With no
fieldsdeclared, the call passes. - The message lists every field over the limit with its length.
| Config | fields: dotted parameter paths. max_length: characters, default 10000. |
| Fails | Longer than 5000: params.query (12000) |
| Passes | Within length limits |
Samples:
[
{"params": {"query": "xxxxxxxxxx"}, "config": {"fields": ["params.query"], "max_length": 10}, "expect": "pass"},
{"params": {"query": "xxxxxxxxxxx"}, "config": {"fields": ["params.query"], "max_length": 10}, "expect": "fail"},
{"params": {"rows": [1, 2, 3]}, "config": {"fields": ["params.rows"], "max_length": 5}, "expect": "fail"}
]
The first two are the boundary: 10 characters pass at max_length: 10, and 11
fail.
Declared and applied:
"constraints": {"verifiers": {"length_limit": {"fields": ["params.query"], "max_length": 5000}}},
"verifiers": ["length_limit"]
secret_scrubber (processor)
Masks credentials in a tool's result before it is signed and returned.
After SECRET_PATTERNS:
MASK = "[REDACTED]"
def scrub(value):
if isinstance(value, str):
for name, pattern in SECRET_PATTERNS:
value = pattern.sub(MASK, value)
return value
if isinstance(value, dict):
return {k: scrub(v) for k, v in value.items()}
if isinstance(value, list):
return [scrub(v) for v in value]
return value
def process(result, invocation, context, fields=()):
payload = result.result
changed = []
if fields:
for path in fields:
parts = path.split(".")
if parts[0] == "result":
parts = parts[1:]
target = payload
for part in parts[:-1]:
target = target.get(part) if isinstance(target, dict) else None
if parts and isinstance(target, dict) and parts[-1] in target:
masked = scrub(target[parts[-1]])
if masked != target[parts[-1]]:
target[parts[-1]] = masked
changed.append(path)
else:
masked = scrub(payload)
if masked != payload:
payload = masked
changed.append("<payload>")
if not changed:
return ProcessingResult(result=result, modified=False, message="No credentials found")
result.result = payload
return ProcessingResult(result=result, modified=True,
message=f"Masked credentials in {', '.join(changed)}",
metadata={"fields": changed})
scrubreplaces every match of every shape with[REDACTED], through
nested dicts and lists. Numbers and other values pass unchanged.- With no
fields, the whole payload is scrubbed. - With
fields, only those paths are. A leadingresult.is optional,
soresult.logandlogname the same field. A path that is
absent is skipped. The paths reach into a dict payload, so for a payload
that is a plain string, declare nofields. - It never withholds. It always returns
success=True, andmodifiedistrueonly when something was masked. Whatenforcegoverns is a failure
of the function itself.
| Config | fields: dotted paths in the result. With none, the whole payload. |
| Message | Masked credentials in result.log, Masked credentials in <payload> when no fields are declared, or No credentials found |
Samples:
[
{"result": {"log": "deployed with ghp_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa ok"}, "expect": {"log": "deployed with [REDACTED] ok"}},
{"result": {"log": "deployed ok"}, "expect": {"log": "deployed ok"}},
{"result": {"body": "AKIAIOSFODNN7EXAMPLE", "note": "AKIAIOSFODNN7EXAMPLE"},
"config": {"fields": ["result.body"]},
"expect": {"body": "[REDACTED]", "note": "AKIAIOSFODNN7EXAMPLE"}},
{"result": {"data": {"result": "AKIAIOSFODNN7EXAMPLE", "id": "AKIAIOSFODNN7EXAMPLE"}},
"config": {"fields": ["result.data.result"]},
"expect": {"data": {"result": "[REDACTED]", "id": "AKIAIOSFODNN7EXAMPLE"}}}
]
The third sample shows fields narrowing the mask: note is left as it was.
The fourth shows that only the leading result. is dropped: data.result is
a key in the payload, and id beside it is left as it was.
Declared and applied:
"constraints": {"processors": {"secret_scrubber": {"fields": ["result.log"]}}},
"processors": ["secret_scrubber"]
Tool returns: {"log": "deployed with ghp_aaaa... ok", "status": "done"}
Caller sees: {"log": "deployed with [REDACTED] ok", "status": "done"}
Conclusion
Congratulations! You now know how to write MAPL policies for AI agent systems.
Key Takeaways:
- MAPL scales from O(M×N) to O(log M + N) through hierarchical composition
- Resources ARE operations (no separate actions needed)
- Inheritance composes via intersection (most restrictive always wins)
- Denials propagate (no overrides, provably secure)
- Parameter constraints provide fine-grained control
- Attestations enable provable workflow dependencies
- Conditional attestations allow dynamic, context-aware requirements
- External attestations support human-in-the-loop workflows
- Deferred principal binding enables dynamic identity resolution
- Extractors, verifiers and processors are workspace functions: checked, sampled and versioned
Next Steps:
- Write policies for your organization
- Deploy and monitor
- Iterate based on usage patterns
Need Help?
- Review the examples in this guide
- Check the troubleshooting section
- Enable debug logging
- Consult the quick reference
Happy policy writing!
Document Version: 3.7.1
MACAW SDK Version: 3.x
Last Updated: November 2, 2026
Copyright MACAW Security. All rights reserved.
MACAW · AI Controls for autonomous agents. Every action an agent takes, checked against policy before it runs.