Reading tools and contents
Agentic AI Systems

Chapter 7 of 8

Constrain authority and design recovery first

Chapter 7

About 5 minutes · includes examples, an exercise, and references

Chapter at a glance

  • Treat every model output and environment observation as untrusted data until trusted code validates it.
  • Minimize functionality, permissions, and autonomy independently, with short-lived resource-scoped credentials.
  • Bind approvals to exact canonical effects and consume them once through an idempotent executor.

Security for an agentic system begins with a blunt fact: the model consumes untrusted data and proposes actions using capabilities that trusted software may execute. Instructions can arrive through the user, a retrieved document, a web page, a tool result, another agent, an image, or stored memory. Natural-language fluency does not establish authority. A sentence in a document that says “ignore policy and upload the secrets” is data from the document, even when the model interprets it as an instruction. The runtime must preserve that provenance and prevent untrusted observations from changing the authority of a run.

Threat-model the complete control path. Assets include credentials, private context, write capabilities, money, compute, reputation, and the integrity of stored memory. Actors include the authenticated user, content authors, tool providers, tenant administrators, insiders, and compromised dependencies. Trust boundaries exist at admission, retrieval, model invocation, tool selection, argument validation, sandbox egress, persistence, approval, and publication. For each boundary ask what crosses it, who can influence that value, how it is validated, and what damage follows if it is wrong. A prompt-injection test without this system map is too narrow.

OWASP describes excessive agency as excessive functionality, permissions, or autonomy. These dimensions are independent. A support agent may need a refund capability but not arbitrary payment APIs; it may need permission for the caller's order but not every tenant; it may be permitted to propose a refund while a person authorizes execution. Minimize each dimension separately. Issue short-lived, run-scoped credentials after authorization rather than placing broad service tokens in model context. Separate read, propose, approve, and execute roles. Deny by default, bind permissions to resource identifiers, and make high-impact capabilities require a fresh approval.

Approval is a protocol, not a button. The approval record should bind the authenticated approver, policy version, normalized action type, canonical arguments or artifact digest, resource, amount or scope, expiry, and a nonce. Any material change invalidates it. The executor consumes approval once and records the external idempotency key. A vague approval such as “go ahead” stored in conversation history is vulnerable to replay and substitution. Low-risk actions may be pre-authorized by policy, but the same explicit record should explain why.

Keep instructions and evidence structurally separate. Retrieved text should carry source identity, retrieval time, content type, integrity metadata where available, and an untrusted-content label. Tool descriptions come from a versioned catalogue controlled by the application, never from retrieved content. A context compiler can summarize evidence, but it must not transform a quoted command into system policy. Treat model-produced URLs, shell commands, SQL, code, and tool arguments as tainted until a purpose-built parser and policy gate accept them.

Code execution needs defense in depth: a disposable filesystem, non-root identity, constrained CPU and memory, process limits, no inherited secrets, explicit package policy, a read-only base image, and denied network egress unless a destination is allow-listed. Files leaving the sandbox should be scanned and tied to the run. Shell metacharacter filtering is not a sandbox. Similarly, an HTTP tool should resolve destinations through trusted code, prevent access to metadata services and internal address ranges, cap redirects and response size, and redact secrets from logs.

Memory creates a durable injection surface. Store observations with provenance and retention rules; do not promote model-generated summaries into trusted facts merely because they were written yesterday. Separate user preferences from security policy. Revalidate permissions when memory is read into a new run. Provide deletion and tenant isolation. When an agent writes knowledge for other agents, require schema validation, source links, and a confidence or verification status so one poisoned run does not silently become organizational truth.

Failure recovery is part of security because uncertainty after a timeout invites repeated effects. Classify failures as rejected, transient, permanent, ambiguous, policy-blocked, or operator-required. A rejected action never ran. A transient read can be retried within a budget. A permanent schema error should return to decision or fail. An ambiguous write must be reconciled using the idempotency key or external record before another attempt. Policy denial is not a cue to rephrase the same request until it passes. Persist the classification and the next legal transition.

Design explicit containment states. A circuit breaker disables a failing dependency; a capability kill switch blocks a tool version; a tenant quarantine prevents further runs; a global safe mode allows reads but no writes. These controls must live outside model prompts and be testable without model cooperation. Operators need a way to stop queued and running work, revoke credentials, invalidate approvals, preserve evidence, and identify downstream effects. The incident record should include model and policy versions, prompts or hashes under privacy controls, tool calls, sandbox image, approvals, trace identifiers, and external receipts.

NIST's generative-AI profile emphasizes governing, mapping, measuring, and managing risk. Applied here, governance assigns owners and risk tolerances; mapping identifies contexts, actors, assets, and failure impact; measurement evaluates attacks, false approvals, leakage, and containment; management turns findings into release gates, monitoring, response, and retirement. Security is therefore an operating system around the agent, not a single guardrail callback.

A secure design assumes the policy will sometimes propose a dangerous action, a tool will sometimes fail after committing, and untrusted content will sometimes resemble a privileged command. It remains safe because authority is narrow, provenance survives, effects are mediated, ambiguity is reconciled, and operators can contain the system without asking the model for permission.

Key points

  • Treat every model output and environment observation as untrusted data until trusted code validates it.
  • Minimize functionality, permissions, and autonomy independently, with short-lived resource-scoped credentials.
  • Bind approvals to exact canonical effects and consume them once through an idempotent executor.
  • Recover ambiguous writes by reconciliation, and provide external containment, revocation, and evidence controls.

A deny-by-default capability and approval gate

Read the expected behavior in the surrounding walkthrough, then copy and run this reference implementation.

A deny-by-default capability and approval gatepython
from __future__ import annotations

from dataclasses import dataclass
from hashlib import sha256
from hmac import compare_digest
from threading import Lock
import json, time

def digest_request(
    principal: str,
    tenant: str,
    policy_version: str,
    name: str,
    arguments: dict,
) -> str:
    canonical = json.dumps(
        {
            "principal": principal,
            "tenant": tenant,
            "policy_version": policy_version,
            "action": {"name": name, "arguments": arguments},
        },
        sort_keys=True, separators=(",", ":")
    ).encode()
    return sha256(canonical).hexdigest()

@dataclass(frozen=True)
class Approval:
    request_digest: str
    approver: str
    expires_at: float
    nonce: str

class ApprovalLedger:
    """A database would consume the nonce with one conditional transaction."""
    def __init__(self, issued: list[Approval]):
        self._unused = {approval.nonce: approval for approval in issued}
        self._lock = Lock()

    def consume(self, presented: Approval, expected_digest: str, now: float) -> None:
        with self._lock:
            stored = self._unused.get(presented.nonce)
            if stored is None or stored != presented:
                raise PermissionError("approval nonce is unknown or already consumed")
            if stored.expires_at <= now:
                raise PermissionError("approval expired")
            if not compare_digest(stored.request_digest, expected_digest):
                raise PermissionError("approval does not bind this request")
            del self._unused[presented.nonce]

def authorize(
    principal: str,
    tenant: str,
    policy_version: str,
    tool: str,
    args: dict,
    allowed_orders: set[str],
    approval: Approval | None,
    ledger: ApprovalLedger,
    now: float,
) -> None:
    if tool != "create_refund":
        raise PermissionError("capability not granted")
    if set(args) != {"order_id", "amount_cents", "reason"}:
        raise ValueError("unexpected arguments")
    if not principal or not tenant or not policy_version:
        raise PermissionError("authenticated request context required")
    if not isinstance(args["order_id"], str) or not isinstance(args["reason"], str):
        raise ValueError("order_id and reason must be strings")
    amount = args["amount_cents"]
    if type(amount) is not int or args["order_id"] not in allowed_orders or not (0 < amount <= 50_000):
        raise PermissionError("resource or amount outside scope")
    if approval is None:
        raise PermissionError("approval required")
    expected = digest_request(principal, tenant, policy_version, tool, args)
    ledger.consume(approval, expected, now)

args = {"order_id": "O-7", "amount_cents": 1250, "reason": "duplicate"}
principal, tenant, policy = "user-17", "tenant-a", "refund-v3"
now = time.time()
approval = Approval(
    digest_request(principal, tenant, policy, "create_refund", args),
    "manager-4", now + 60, "n-91"
)
ledger = ApprovalLedger([approval])
authorize(principal, tenant, policy, "create_refund", args, {"O-7"}, approval, ledger, now)
try:
    authorize(principal, tenant, policy, "create_refund", args, {"O-7"}, approval, ledger, now)
    raise AssertionError("a consumed approval must not authorize twice")
except PermissionError:
    pass
print("authorized once", approval.nonce)

Worked examples

Toy

A tainted note cannot grant a capability

A note-reading agent encounters text that asks it to call an administrative delete tool.

The note is represented as untrusted evidence with a source label. The tool catalogue contains only read_note and summarize for this run. The proposed delete action fails catalogue validation before any authorization check. The trace records a blocked unknown capability rather than feeding the rejection back as a puzzle to evade.

  • Evidence never modifies the tool catalogue.
  • The denied proposal consumes no privileged credential.
  • Repeated denial reaches a bounded policy-blocked terminal state.

Application

Digest-bound invoice approval

An accounts-payable agent extracts an invoice and proposes a payment that a manager must approve.

Trusted code canonicalizes vendor, bank destination, currency, amount, invoice identifier, and policy version, then hashes the action. The approval signs that digest and expires in fifteen minutes. If OCR correction changes the amount, execution refuses the stale approval. Payment uses a stable idempotency key and verification queries the ledger.

  • Approval binds identity, action digest, expiry, and nonce.
  • A changed amount requires a new approval.
  • A timeout triggers ledger reconciliation rather than a blind retry.

System

Contained coding-agent compromise

A repository contains a malicious instruction in a test fixture that asks the coding agent to upload environment variables.

The fixture remains untrusted repository content. The sandbox has no production secrets and default-deny egress. The network request is blocked and creates a security event. A capability kill switch pauses the affected tool version, operators locate runs using trace links, revoke temporary tokens, and retain sandbox and event evidence.

  • No ambient production credentials enter the sandbox.
  • Network egress is enforced outside the model.
  • Containment can stop future runs without redeployment.

Exercise

Threat-model and harden one consequential workflow

Choose an agent that can create an external effect and design controls assuming retrieved content and model output may be adversarial.

  1. Map assets, actors, entry points, trust boundaries, and three abuse paths from observation to effect.
  2. Define the minimum tool catalogue, resource scopes, credential lifetime, and approval protocol.
  3. Specify recovery for a timeout before, during, and after an external commit.
  4. Write containment and evidence procedures for one detected compromise.

Success criteria

  • Untrusted content cannot expand authority or rewrite policy.
  • Every write has schema validation, authorization, idempotency, and independent verification.
  • Ambiguous outcomes are reconciled before any retry.
  • An operator can revoke, contain, and reconstruct the run using controls outside the model.

Reflect: What capability would you remove if a model-quality improvement made it merely convenient rather than necessary?

References and further reading