Showing posts with label MCP. Show all posts
Showing posts with label MCP. Show all posts

Friday, June 5, 2026

Coding Agents Need a Workstation Security Boundary

A developer workstation split into trusted identity, constrained agent sandbox, package firewall, and audited tool gateway zones

Introduction

The first time I let a coding agent loose on a real service repo, the failure did not look like a security incident. It looked like helpfulness.

The agent found the test suite, installed a missing package, opened a generated config file, and proposed a fix that touched the deployment script. Every individual action seemed reasonable. The uncomfortable part came later, when I tried to reconstruct what the agent had been allowed to see. It had read .env.example, generated a local token for a test harness, inspected package scripts, and tried to run a command that would have reached a staging endpoint if my network policy had not blocked it.

Nothing malicious happened. That was the point. The workstation boundary had worked by accident, not design.

Coding agents are now powerful enough to behave like junior platform engineers with shell access. They clone repositories, modify code, run test commands, inspect logs, install packages, call MCP servers, and sometimes operate inside the same laptop profile that holds production credentials. OpenAI's May 2026 Codex safety write-up describes the operating model clearly: enterprise adoption needs sandboxing, approval controls, network policy, configuration management, and agent-aware telemetry, not just better prompts (OpenAI).

The workstation is the new trust boundary because it is where three risk surfaces collide: developer identity, autonomous tool execution, and supply-chain input. If you secure only the repository, the package manager can still betray you. If you secure only the package manager, an agent can still misuse a legitimate secret. If you secure only the agent prompt, the shell still does what the process is allowed to do.

This guide builds a practical workstation boundary for coding agents. It is not a product pitch or a locked-down fantasy environment that developers will bypass by lunchtime. It is an engineering pattern: isolate the agent runtime, minimize credential exposure, restrict package and network access, gate MCP tools, and preserve enough evidence that security teams can answer what happened after the fact.

The Problem: The Agent Inherits the Workstation

Most developer security programs were designed around a human sitting at a keyboard. The controls assume intent comes from the developer, execution happens through familiar tools, and risky actions are visible enough for review. Coding agents bend those assumptions.

An agent can read faster than a human, follow dependency hints across many files, and trigger commands the developer did not personally type. It can also act on poisoned instructions embedded in code comments, generated documentation, package metadata, issue text, or tool responses. The workstation becomes a translation layer between untrusted text and privileged execution.

The OpenAI response to the Axios developer-tool compromise is useful here because it shows how mundane the blast path can be. OpenAI reported that a compromised third-party developer tool affected a macOS signing workflow and announced certificate rotation plus older app support changes effective May 8, 2026 (OpenAI). The lesson is not that every developer tool is unsafe. The lesson is that trusted developer workflows can inherit upstream compromise before anyone at the keyboard notices.

Endor Labs is seeing the same shape from the application-security side. Its May 2026 launch post for AI coding agent and workstation security focuses on monitoring agent behavior, enforcing policies across workstations, controlling MCP interactions, and blocking malicious packages before agents pull them into local or CI environments (Endor Labs). That framing matters. The workstation is no longer just where code is edited. It is where an automated actor may acquire dependencies, invoke tools, and transform intent into side effects.

Here is the minimum threat model I use:

Surface Old assumption Agent-era failure mode Boundary control
Filesystem A developer intentionally opens sensitive files Agent sweeps repo, dotfiles, build caches, and generated configs Path allowlist, secret-file denylist, read logging
Shell Human reviews commands before running them Agent chains package scripts and helper commands Command policy, approval gates, restricted PATH
Network Local tools need broad outbound access Agent exfiltrates through package postinstall or test harness Default-deny egress, domain allowlist
Package manager Lockfiles and scanners catch enough Agent installs fresh malicious package or poisoned version Package firewall, registry allowlist, install approvals
Credentials Developer can protect secrets manually Agent reads tokens or passes them into tools Scoped credentials, brokered access, redaction
MCP tools Tool descriptions are trusted integration docs Tool output or metadata becomes instruction payload Tool registry, argument policy, response inspection

The problem is not that agents are careless. The problem is that they are obedient. A workstation boundary gives obedience a shape.

Architecture diagram showing a coding agent running inside a constrained workstation boundary with package, network, credential, and MCP policy gates
flowchart LR A[Developer request] --> B[Agent runtime] B --> C{Workspace policy} C -->|allowed path| D[Repo files] C -->|sensitive path| E[Deny and log] B --> F{Command policy} F -->|safe command| G[Sandbox shell] F -->|risky command| H[Human approval] G --> I{Network policy} I -->|approved domain| J[Package registry or docs] I -->|unknown destination| K[Block] B --> L{Credential broker} L -->|scoped token| M[Test or staging service] L -->|raw secret request| N[Deny]

Boundary Design: Four Rings, Not One Sandbox

The common answer is "run the agent in a sandbox." That is necessary, but it is not sufficient. A sandbox that still has your SSH keys, package-manager tokens, cloud profiles, and broad outbound network access is a nicer room with the same keys on the table.

I prefer four rings.

Ring one is identity separation. The agent should not run as the full developer identity. Give it a local operating-system user, container identity, or remote workspace identity with a narrow filesystem view. If the agent needs GitHub, cloud, or package registry access, issue scoped tokens for that task instead of inheriting the developer's long-lived credentials.

Ring two is execution control. Commands should be classified before they run. Reading files, running unit tests, and formatting code can usually be allowed. Installing dependencies, invoking package scripts, changing deployment configuration, writing outside the repo, and reaching the network should require policy checks or human approval.

Ring three is data control. The agent needs enough context to work, but not every secret-bearing file on the machine. Deny access to .env, shell history, cloud config directories, browser profiles, SSH keys, password-manager exports, local database dumps, and artifact caches unless a broker grants a narrow view. If a task genuinely needs a secret, pass a short-lived capability to the command, not the raw value to the model.

Ring four is evidence. OpenAI notes that Codex logs can help inspect original requests, tool activity, approval decisions, tool results, and network policy decisions (OpenAI). That is the right audit shape. Logs are not a compliance afterthought. They are how the team debugs agent behavior without guessing.

The gotcha is tool transitivity. You can restrict the agent but forget that npm test runs a package script, the package script runs a local helper, and the helper reads environment variables. The boundary must apply to subprocesses, not just the top-level agent process.

flowchart TD A[Agent wants command] --> B{Classify command} B -->|read-only repo command| C[Run in sandbox] B -->|dependency install| D{Package policy} D -->|approved registry and package| C D -->|unknown or fresh package| E[Require approval] B -->|network command| F{Destination allowlisted?} F -->|yes| C F -->|no| G[Block and log] B -->|secret path or deploy command| H[Human approval plus scoped token] C --> I[Capture stdout, stderr, exit code] E --> I G --> I H --> I

Implementation Guide: A Small Policy Wrapper

You do not need a giant platform to start. The first useful version is a wrapper that all agent shell execution goes through. It classifies commands, blocks obvious secrets, restricts network by environment, and writes an audit record.

Below is a compact Python implementation. It is deliberately conservative. The point is not to catch every possible attack. The point is to make unsafe actions explicit instead of invisible.

from __future__ import annotations

import json
import os
import shlex
import subprocess
import time
from dataclasses import dataclass, asdict
from pathlib import Path


SAFE_PREFIXES = {
    "git status",
    "git diff",
    "pytest",
    "npm test",
    "npm run test",
    "pnpm test",
    "go test",
    "cargo test",
}

BLOCKED_TOKENS = {
    "curl",
    "wget",
    "scp",
    "ssh",
    "aws",
    "gcloud",
    "az",
    "kubectl",
    "docker push",
    "npm publish",
    "pnpm publish",
}

SENSITIVE_PATHS = {
    ".env",
    ".npmrc",
    ".pypirc",
    ".ssh",
    ".aws",
    ".config/gcloud",
    "id_rsa",
    "id_ed25519",
}


@dataclass
class Decision:
    command: str
    allowed: bool
    reason: str
    approval_required: bool
    timestamp: float


def command_text(argv: list[str]) -> str:
    return " ".join(shlex.quote(part) for part in argv)


def touches_sensitive_path(text: str) -> bool:
    lowered = text.lower()
    return any(path.lower() in lowered for path in SENSITIVE_PATHS)


def classify(argv: list[str]) -> Decision:
    text = command_text(argv)
    normalized = " ".join(argv)

    if touches_sensitive_path(normalized):
        return Decision(text, False, "sensitive path reference", True, time.time())

    for blocked in BLOCKED_TOKENS:
        if normalized == blocked or normalized.startswith(blocked + " "):
            return Decision(text, False, f"blocked command family: {blocked}", True, time.time())

    for safe in SAFE_PREFIXES:
        if normalized == safe or normalized.startswith(safe + " "):
            return Decision(text, True, "safe command prefix", False, time.time())

    return Decision(text, False, "unknown command requires approval", True, time.time())


def run_agent_command(argv: list[str], cwd: Path, audit_path: Path) -> int:
    decision = classify(argv)
    audit_path.parent.mkdir(parents=True, exist_ok=True)
    with audit_path.open("a", encoding="utf-8") as fh:
        fh.write(json.dumps({"decision": asdict(decision), "cwd": str(cwd)}) + "\n")

    if not decision.allowed:
        print(f"blocked: {decision.reason}")
        return 126

    env = {
        "PATH": os.environ.get("PATH", ""),
        "HOME": str(cwd / ".agent-home"),
        "NO_COLOR": "1",
    }
    result = subprocess.run(argv, cwd=cwd, env=env, text=True)

    with audit_path.open("a", encoding="utf-8") as fh:
        fh.write(json.dumps({"command": decision.command, "exit_code": result.returncode}) + "\n")

    return result.returncode

Example output from a local policy check:

$ python agent_policy.py git status
allowed: safe command prefix
exit_code=0

$ python agent_policy.py cat .env
blocked: sensitive path reference
exit_code=126

$ python agent_policy.py npm publish
blocked: blocked command family: npm publish
exit_code=126

The important design choice is not the specific denylist. It is the choke point. Once every agent command crosses a local policy wrapper, you can refine decisions with team-specific rules: approved package registries, safe MCP servers, repository-specific command allowlists, or mandatory approval for migrations.

For production teams, wire the wrapper into the agent runner rather than asking developers to remember it. Put it in the devcontainer, remote workspace, CI agent profile, or local launcher script. If the agent can bypass the wrapper with a raw terminal, the boundary is documentation, not enforcement.

Credential Handling: Broker Capabilities, Not Secrets

The fastest way to lose trust in a coding agent rollout is to let the model see raw credentials. It does not matter whether the model provider stores them. It does not matter whether the prompt says not to reveal them. The better pattern is simple: the agent can request a capability, but a broker decides whether to mint it.

A capability is short-lived, scoped, and contextual. It might allow read-only access to one staging API for ten minutes. It might allow package download from an internal registry, but not publish. It might allow a test database migration in a disposable schema, but not production.

The agent never needs to know the long-lived secret. The command receives the temporary credential through an environment variable or file descriptor. The audit log records why it was issued, who approved it, and which command consumed it.

from dataclasses import dataclass
from datetime import datetime, timedelta, timezone


@dataclass
class CapabilityRequest:
    actor: str
    repo: str
    purpose: str
    resource: str
    access: str


def mint_capability(req: CapabilityRequest) -> dict:
    if req.access not in {"read", "test-write"}:
        raise PermissionError("agent cannot request privileged access")

    if req.resource.startswith("prod:"):
        raise PermissionError("production access requires human approval")

    expires = datetime.now(timezone.utc) + timedelta(minutes=10)
    return {
        "token": "opaque-short-lived-token-from-vault",
        "resource": req.resource,
        "access": req.access,
        "expires_at": expires.isoformat(),
    }

Example broker decision:

request actor=agent repo=payments-api resource=staging:ledger-db access=test-write
decision allow ttl=10m approver=policy

request actor=agent repo=payments-api resource=prod:ledger-db access=write
decision deny reason=production access requires human approval

This is also where endpoint detection and response should become agent-aware. A raw process tree only tells you that a command ran. An agent-aware record tells you which prompt caused it, which files informed it, which approval was granted, and which tool result came back.

Package and MCP Guardrails

Package management is the sharp edge of workstation security because a coding agent often treats dependency installation as routine cleanup. A missing import becomes npm install. A failing test becomes pip install. A build error becomes "try the latest package." That is useful until a fresh malicious package lands in the path.

OpenAI's Axios incident response and related 2026 supply-chain reporting show why signing and update channels matter for developer tools (OpenAI). Endor Labs describes package firewall controls that analyze newly uploaded packages across ecosystems such as npm, PyPI, NuGet, and Maven before agents can pull them into workstations or CI (Endor Labs). You can start smaller:

Action Default policy Exception path
Install from lockfile Allow Log package manager and diff
Add new direct dependency Require approval Security review or package score
Run install scripts Deny by default Allow only in disposable sandbox
Use public registry Allow through proxy Block typosquats and fresh packages
Publish package Human-only Separate CI release identity
Add MCP server Require registration Security review of tool scope

MCP needs the same treatment as packages. A server is not just a dependency. It is a live tool surface with descriptions, arguments, credentials, and responses. The workstation boundary should ask:

  • Is this MCP server registered for this repo?
  • Which tools can this agent call?
  • Which arguments are allowed?
  • Does the response contain instructions that should be kept out of model context?
  • Which credential scope is injected for this call?
sequenceDiagram participant Dev as Developer participant Agent as Coding Agent participant Gate as Workstation Boundary participant Pkg as Package Proxy participant MCP as MCP Gateway participant Audit as Audit Log Dev->>Agent: Fix failing integration test Agent->>Gate: npm install missing-package Gate->>Pkg: Check package policy Pkg-->>Gate: Unknown fresh package Gate-->>Agent: Block, request approval Gate->>Audit: Record package decision Agent->>Gate: call mcp.search_vulns(package) Gate->>MCP: Validate server, tool, arguments MCP-->>Gate: Safe result Gate->>Audit: Record MCP decision Gate-->>Agent: Return result

The gotcha is that package and MCP controls are often owned by different teams. AppSec owns dependency policy. Platform owns developer workstations. AI platform owns agent configuration. Security operations owns endpoint telemetry. If each team ships its own partial control, the agent finds the gaps between them. Make the workstation boundary a shared contract.

Rollout Plan

Do not start with a theoretical policy matrix for every repository. Start with one high-risk repo and one coding agent. Instrument before you block. Then block only the actions that your evidence shows are dangerous enough to justify interruption.

Week one: observe.

  • Run the agent in a separate OS user, devcontainer, or remote workspace.
  • Log commands, working directories, file paths, package installs, network destinations, MCP calls, and approval prompts.
  • Do not capture secret values. Redact aggressively.
  • Review the top 20 commands and top 20 file paths after three days.

Week two: deny the obvious.

  • Block secret paths.
  • Block package publish commands.
  • Block cloud CLIs unless a broker grants a scoped token.
  • Block unknown outbound destinations.
  • Require approval for new dependencies and MCP servers.

Week three: move secrets behind a broker.

  • Remove long-lived tokens from the agent environment.
  • Issue short-lived capabilities for staging-only work.
  • Store approval decisions with the command and prompt context.
  • Add alerts for denied secret access and repeated policy violations.

Week four: scale by repo class.

  • Create policy profiles for frontend apps, backend services, infrastructure repos, data repos, and security repos.
  • Make safe commands fast and low-friction.
  • Keep dangerous commands rare, visible, and reviewable.
Comparison visual showing an unbounded coding agent workstation beside a governed workstation with identity, package, network, credential, and audit controls

Comparison and Tradeoffs

A workstation boundary has costs. It can slow down dependency experiments. It can annoy senior developers if every command needs approval. It can create false confidence if the logs are noisy and nobody reviews them.

The alternative is worse: an agent with broad local authority, vague prompts, inherited credentials, and no audit trail. That model might be acceptable for toy repositories. It is not acceptable for payment systems, deployment automation, internal platforms, or security-sensitive codebases.

The pragmatic compromise is tiered autonomy:

Tier Agent autonomy Use case Required controls
Read-only Agent can inspect code and suggest patches Security review, unfamiliar repos File allowlist, no shell writes
Test sandbox Agent can edit and run tests Normal feature work Command policy, no secrets, package proxy
Staging-capable Agent can call staging services Integration work Credential broker, network allowlist
Release-adjacent Agent can modify release scripts but not deploy Platform maintenance Human approval, signed commits, audit review
Production-capable Agent can affect production Rare emergency workflows Break-glass approval, session recording, post-review

Most teams should live in the first three tiers. The point is not to eliminate developer judgment. It is to keep agent autonomy proportional to the blast radius.

Conclusion

Coding agents are not just editors with autocomplete. They are tool-using processes that act through the developer workstation. That makes the workstation an application-security boundary, an endpoint-security boundary, and an AI-governance boundary at the same time.

The design is straightforward: separate identity, constrain execution, protect secrets, mediate packages and MCP tools, restrict network access, and log every meaningful decision. The hard part is ownership. Someone has to decide which commands are safe, which package events require review, which MCP servers are registered, and which credentials an agent may receive.

Start small. Put one repo behind a wrapper. Log for a week. Block secret reads, package publishing, unknown outbound traffic, and production credentials. Then make the controls boring enough that developers keep using them.

The best workstation boundary is not the one that wins a policy argument. It is the one that lets agents move fast without inheriting every key on the machine.


Get the next one

I send one short email a week: one production bug, debugged, plus the companion code for each deep-dive. No spam, unsubscribe anytime.

👉 Subscribe (free)

If this helped you tighten an agent workstation boundary, you can support the work here: Buy Me a Coffee.

Reader challenge: try breaking the workstation boundary above in your own setup. Which action gets through first: package install, secret read, network egress, or MCP tool call? Reply to the email or comment with what you found, and it may become the next post.

Sources

About the Author

Toc Am

Founder of AmtocSoft. Writing practical deep-dives on AI engineering, cloud architecture, and developer tooling. Previously built backend systems at scale. Reviews every post published under this byline.

LinkedIn X / Twitter

Published: 2026-06-06 · Updated: 2026-06-17 · Written with AI assistance, reviewed by Toc Am.

Get These In Your Inbox

Weekly deep-dives on AI engineering, no fluff. Join the newsletter →

Subscribe (free)

Or grab the book ($39, ~100 pages) · Buy me a coffee

Buy Me a Coffee · 🔔 YouTube · 💼 LinkedIn · 🐦 X/Twitter

Wednesday, June 3, 2026

MCP Runtime Governance After the 19,000-Server Sweep

An operations team watching an AI agent tool gateway enforce policy before MCP calls reach production systems

Introduction

The first MCP failure that changed my mind about agent security was not dramatic. Nothing crashed. No alert fired. An internal assistant had a read-only database tool, and the tool was on the approved list. During a support investigation, the agent called it with a query broad enough to pull customer records from regions outside the ticket's scope. The server did exactly what its schema allowed. The model did exactly what its prompt requested. The policy failure lived in the quiet gap between those two facts.

I had reviewed the server package, checked the tool names, and verified that the connection used authentication. I had not asked the harder runtime question: should this agent, acting for this user, be allowed to invoke this tool with these arguments at this moment?

That question matters more after Trend Micro's May 2026 research sweep of 19,000 open-source MCP repositories. Trend Micro sampled 2,287 agent-flagged candidates, manually confirmed 93 exploitable cases, and estimated a point prevalence near 4.1 percent across the corpus (Trend Micro). A separate Trend Micro analysis of more than 19,000 MCP server source trees reported that 48 percent recommended secrets in .env files or plaintext JSON configuration (Trend Micro).

Those numbers do not mean every MCP server is unsafe. They mean installation-time trust is not enough. A signed package can still expose an over-broad tool. An authenticated server can still accept dangerous arguments. A clean schema can still return poisoned context. Runtime governance is the missing layer between an agent's intent and a tool server's execution.

This guide builds that layer. The implementation is deliberately small: an allowlist, argument policy, scoped identity, response inspection, circuit breaker, approval boundary, and append-only audit trail. The point is not to invent a new protocol. The point is to put a deterministic control plane around the protocol you already use.

The Problem: MCP Standardizes Execution, Not Your Risk Appetite

MCP solves a valuable interoperability problem. A client discovers tools from a server, the model chooses a tool, the client serializes a request, and the server executes it. That common path is why MCP adoption moved quickly across coding agents, databases, file systems, and third-party services.

The protocol does not decide whether your production policy allows a particular action. Microsoft's April 2026 runtime-governance write-up states the gap plainly: MCP standardizes the execution surface without defining a built-in policy checkpoint before execution (Microsoft for Developers). The same article reports an internal red-team evaluation where prompt-only safety instructions produced a 26.67 percent policy-violation rate across its benchmark. Instructions help, but they are not an authorization system.

The OWASP MCP Top 10 makes the failure modes concrete (OWASP):

Risk Production symptom Runtime control
Token exposure A tool receives or logs credentials it never needed Inject scoped credentials at execution time; redact logs
Scope creep A convenience tool gradually acquires administrative verbs Bind identity, tool, action, and resource scope
Tool poisoning A description or response carries adversarial instructions Scan definitions and inspect responses before model reuse
Command injection Untrusted text lands in shell, SQL, or API arguments Parse structured arguments; deny unsafe shapes
Missing telemetry Nobody can reconstruct why a sensitive call ran Emit immutable, correlated decision records
Shadow servers A developer adds an unreviewed endpoint Registry allowlist plus server identity verification

Supply-chain controls still matter. Verify manifests, pin versions, review dependencies, and scan packages. Blog 249 covered that boundary. Runtime governance solves the next problem: what happens after a trusted server is connected and a real agent starts calling it.

Architecture diagram showing agent intent passing through an MCP runtime governance gateway before approved tool calls reach servers
flowchart LR A[Agent proposes tool call] --> B[Governance gateway] B --> C{Server registered?} C -->|no| D[Deny and alert] C -->|yes| E{Tool and arguments allowed?} E -->|no| F[Deny or require approval] E -->|yes| G[Inject scoped credential] G --> H[MCP server executes] H --> I[Inspect response] I --> J[Return safe result to agent] B --> K[Append audit record] F --> K I --> K

Architecture: Put Policy Between Intent and Execution

The gateway belongs in the client-side execution path or immediately in front of the MCP servers. It should run after the model proposes a call and before any privileged side effect occurs. That placement is load-bearing: the gateway sees the concrete tool name, concrete arguments, acting identity, target server, and request context.

There are three common approaches:

Approach What it gets right Where it fails
Trust the connected server Low friction for demos No deterministic per-call decision
Sandbox every server Reduces host blast radius Does not stop valid-but-dangerous API calls
Runtime policy gateway plus sandboxing Evaluates intent, identity, arguments, and outcome Requires explicit policy ownership

The third approach is the production default. Sandboxing contains server-side compromise. Runtime policy prevents an agent from using an otherwise healthy server in a way your organization did not authorize.

OpenAI describes a parallel operational pattern for Codex: bounded sandbox execution, approvals for higher-risk actions, managed network policies, keyring-backed credentials, and OpenTelemetry logs for prompts, tool decisions, tool results, MCP usage, and network allow-or-deny events (OpenAI). The exact implementation differs by platform, but the control-plane shape is the same.

flowchart TD A[Tool call arrives] --> B{Read-only and scoped?} B -->|yes| C{Arguments pass policy?} B -->|no| D{Approved change window?} D -->|no| E[Require human approval] D -->|yes| C C -->|no| F[Deny with policy reason] C -->|yes| G{Repeated identical failure?} G -->|yes| H[Trip circuit breaker] G -->|no| I[Execute with short-lived credential]

Implementation: A Small Deterministic Gateway

The gateway below is intentionally ordinary Python. The policy data is explicit. The decision result is structured. Every request receives a correlation ID. A sensitive tool can require approval even if it appears on the allowlist.

from __future__ import annotations

from dataclasses import asdict, dataclass
from hashlib import sha256
from json import dumps
from time import time
from typing import Any
from uuid import uuid4


@dataclass(frozen=True)
class ToolCall:
    server: str
    tool: str
    args: dict[str, Any]
    actor: str
    approved: bool = False


@dataclass(frozen=True)
class Decision:
    allowed: bool
    reason: str
    request_id: str


POLICY = {
    "inventory-mcp": {
        "inventory.lookup": {"effect": "read"},
        "inventory.adjust": {"effect": "write", "approval": True},
    },
    "support-mcp": {
        "ticket.get": {"effect": "read"},
    },
}


def stable_hash(value: Any) -> str:
    return sha256(dumps(value, sort_keys=True).encode()).hexdigest()[:16]


def validate_args(call: ToolCall) -> str | None:
    if call.tool == "inventory.lookup":
        region = call.args.get("region")
        if region not in {"us-east", "us-west"}:
            return "region must be explicitly scoped"
        if int(call.args.get("limit", 0)) > 100:
            return "limit exceeds read policy"
    if call.tool == "inventory.adjust":
        delta = int(call.args.get("delta", 0))
        if abs(delta) > 10:
            return "inventory delta exceeds approval envelope"
    return None


def decide(call: ToolCall) -> Decision:
    request_id = str(uuid4())
    server = POLICY.get(call.server)
    if not server:
        return Decision(False, "server is not registered", request_id)
    rule = server.get(call.tool)
    if not rule:
        return Decision(False, "tool is not allowed", request_id)
    if rule.get("approval") and not call.approved:
        return Decision(False, "human approval required", request_id)
    if reason := validate_args(call):
        return Decision(False, reason, request_id)
    return Decision(True, "policy passed", request_id)


def audit(call: ToolCall, decision: Decision) -> None:
    record = {
        "ts": round(time(), 3),
        "request_id": decision.request_id,
        "actor": call.actor,
        "server": call.server,
        "tool": call.tool,
        "args_hash": stable_hash(call.args),
        "allowed": decision.allowed,
        "reason": decision.reason,
    }
    print(dumps(record, sort_keys=True))


def govern(call: ToolCall) -> Decision:
    decision = decide(call)
    audit(call, decision)
    return decision

Run three calls through the policy:

calls = [
    ToolCall("inventory-mcp", "inventory.lookup",
             {"region": "us-east", "limit": 25}, "agent:triage"),
    ToolCall("inventory-mcp", "inventory.lookup",
             {"region": "*", "limit": 10000}, "agent:triage"),
    ToolCall("inventory-mcp", "inventory.adjust",
             {"sku": "A-17", "delta": -2}, "agent:triage"),
]

for call in calls:
    result = govern(call)
    print(result.allowed, result.reason)

The output is predictable:

{"actor":"agent:triage","allowed":true,"reason":"policy passed","server":"inventory-mcp","tool":"inventory.lookup",...}
True policy passed
{"actor":"agent:triage","allowed":false,"reason":"region must be explicitly scoped","server":"inventory-mcp","tool":"inventory.lookup",...}
False region must be explicitly scoped
{"actor":"agent:triage","allowed":false,"reason":"human approval required","server":"inventory-mcp","tool":"inventory.adjust",...}
False human approval required

The gateway does not ask the model whether the call is safe. It asks deterministic code. This matters because a model can explain a dangerous call convincingly. Policy code should remain boring enough that an on-call engineer can understand it under pressure.

The Gotcha: An Allowlisted Tool Can Still Be Dangerous

The incident from the introduction survived our first fix. We created an allowlist, registered the server, and permitted only customer.search. The next test still pulled too much data.

The tool was read-only, but its arguments were broad:

{
  "tool": "customer.search",
  "arguments": {
    "region": "*",
    "fields": ["name", "email", "billing_address", "support_notes"],
    "limit": 50000
  }
}

That request did not violate a tool-name allowlist. It violated the policy we had failed to encode: support agents should see one ticket's customer record, a narrow field projection, and a bounded row count. We had authorized the verb while ignoring the object.

The repair was to validate argument semantics:

def validate_customer_search(args: dict) -> str | None:
    if args.get("region") == "*":
        return "wildcard region is forbidden"
    if int(args.get("limit", 0)) > 25:
        return "row limit exceeds support policy"
    forbidden = {"billing_address", "payment_token", "internal_notes"}
    requested = set(args.get("fields", []))
    if requested & forbidden:
        return "field projection includes restricted data"
    return None

After the change, our local policy test produced:

$ python -m pytest tests/test_gateway.py -q
8 passed in 0.06s

$ python demo.py
DENY customer.search: wildcard region is forbidden
DENY customer.search: row limit exceeds support policy
ALLOW customer.search: region=us-east limit=1 fields=[name,support_notes]

The broader lesson is useful beyond MCP. Authorization is not only a mapping from identity to endpoint. Production authorization is a mapping from identity to action, resource, argument envelope, time, and approval state.

Identity and Credentials: Scope Them at the Boundary

The MCP authorization specification defines authorization for HTTP transports using OAuth 2.1 patterns (MCP specification). The MCP tutorial explains that authorization protects sensitive resources and operations exposed by MCP servers and uses standard discovery metadata for OAuth flows (MCP documentation).

Use that transport authentication, then add workload policy at the gateway:

  1. Bind the human user and agent identity to each request.
  2. Mint or retrieve the shortest-lived credential the tool needs.
  3. Limit audience, scopes, resources, and network origin.
  4. Never put raw credentials into model context.
  5. Redact tokens from logs while preserving a credential fingerprint for correlation.

OpenAI's Codex deployment guidance is a useful concrete example: CLI and MCP OAuth credentials are stored in the secure OS keyring, and MCP server usage can be exported as OpenTelemetry events (OpenAI). OWASP's MCP01 guidance similarly recommends short-lived, scoped credentials and secret-scanning controls (OWASP).

sequenceDiagram participant A as Agent participant G as Governance Gateway participant I as Identity Provider participant M as MCP Server A->>G: propose tool call with user context G->>G: evaluate tool and argument policy G->>I: request short-lived scoped token I-->>G: token for approved audience and scope G->>M: execute tool call with scoped token M-->>G: tool response G->>G: redact, inspect, audit G-->>A: safe response

Response Inspection: Treat Tool Output as Untrusted Input

The request path is half the boundary. Tool output flows back into model context, where text can influence the agent's next action. OWASP describes MCP tool poisoning as an indirect prompt-injection attack where a malicious tool response lands in the context window and is treated as trusted input (OWASP).

Response inspection should be conservative:

  • Reject unexpected schema shapes.
  • Redact secrets before any result enters model context.
  • Flag instruction-like text returned from tools that should return data.
  • Cap payload size.
  • Preserve a hash of the original response for forensic review.
  • Separate data from instructions in the host application.

Do not promise that a regex solves prompt injection. It does not. A response scanner is a tripwire and a sanitization layer, not a proof of safety. The stronger pattern is architectural: return typed data to a host that controls how the model sees it, and require policy checks again before the next action.

Circuit Breakers and Sequence Controls

Per-call policy is necessary, but a sequence of individually valid calls can still become harmful. An agent can enumerate resources one page at a time, retry a failing write until a downstream service collapses, or combine low-risk reads into an unexpected data export.

Start with two controls:

from collections import Counter

failures: Counter[str] = Counter()

def repeated_failure(tool: str, args: dict, error: str) -> bool:
    key = stable_hash({"tool": tool, "args": args, "error": error})
    failures[key] += 1
    return failures[key] >= 3

def breadth_exceeded(history: list[str]) -> bool:
    sensitive = {name for name in history if name.startswith("admin.")}
    return len(sensitive) > 4

The first stops identical retry spirals. The second catches breadth: too many distinct sensitive actions in a short window. Blog 260 covered the general circuit-breaker pattern. MCP governance gives it a specific enforcement point.

Microsoft's AGT article is candid about this boundary: the preview governs individual tool calls, while workflow-level policy for sequences is still a roadmap item (Microsoft for Developers). Treat that limitation as a design requirement in your own gateway.

Comparison and Rollout Plan

Comparison visual showing installation-time MCP checks beside runtime governance controls
Control Installation time Connection time Every call Every response
Dependency scan Yes No No No
Manifest signature Yes Yes Optional pin check No
Server identity No Yes Yes No
Tool allowlist No Yes Yes No
Argument policy No No Yes No
Scoped credential injection No No Yes No
Response inspection No No No Yes
Immutable audit record No Yes Yes Yes

Roll out in four passes:

  1. Observe. Log server, tool, actor, argument hash, result hash, latency, and outcome. Redact secrets before storage.
  2. Deny unknown servers. Require registry membership and identity verification.
  3. Enforce argument envelopes. Start with destructive tools, broad reads, shell execution, cloud administration, and credential access.
  4. Add approval and sequence policy. Require review for writes and alert on repeated failures or suspicious action breadth.

The observe-first pass matters. A policy written without real traffic usually blocks harmless workflows and misses dangerous argument shapes. Collect enough structured traces to understand the normal envelope, then enforce it deliberately.

Production Considerations

Keep the gateway small and observable. A control plane that nobody can debug will become a bypass target the first time it slows a release.

Measure:

  • Allow, deny, and approval rates by tool.
  • Policy-evaluation latency.
  • Repeated-failure trips.
  • Response redactions.
  • Shadow-server attempts.
  • Scope-expansion requests.
  • Audit-log delivery health.

Microsoft reports sub-millisecond policy-evaluation overhead for typical AGT rule sets in its internal microbenchmarks (Microsoft for Developers). Your numbers will depend on policy engine, network topology, and logging path, so benchmark your own gateway and alert on regressions.

Fail closed for destructive actions. For low-risk reads, choose consciously whether a telemetry outage should fail open, fail closed, or queue work. Keep emergency kill switches outside the agent's own tool surface. Store audit records in an append-only destination and include schema versions so you can reconstruct which contract governed a historical invocation.

Policy Ownership: Make the Boundary Operable

A gateway is easy to demo and surprisingly easy to neglect. The hard production question is who owns each rule after the first month. If every policy change requires a security architect, teams route around the gateway. If every application team can loosen policy silently, the gateway becomes decorative.

I use a split ownership model:

Policy layer Primary owner Review requirement
Server registry and identity Platform security Security review for additions
Tool discovery allowlist Platform team Application-owner approval
Argument envelope Application owner Code review plus policy tests
Credential scope Identity team Security review for expansion
Human-approval triggers Risk owner Product and security sign-off
Response inspection Platform security Threat-model review
Audit retention Compliance or SRE Retention-policy approval

This division matters because the application owner understands semantic risk. A platform team can recognize that inventory.adjust writes state. It may not know that a delta above ten units requires a separate warehouse workflow, or that reading support notes across regions creates a data-residency problem. The platform provides the enforcement mechanism. The application owner defines the safe envelope.

Policy changes should travel through the same path as code. Require review, preserve diffs, run contract tests, and attach a reason. An emergency override should expire automatically. If an engineer must remember to remove it on Friday afternoon, assume it will still exist Monday morning.

Keep the rule language narrow at first. A YAML file or plain Python policy table is often better than a powerful general-purpose DSL when the deployment is new. You want on-call engineers to answer three questions quickly:

  1. Which rule denied the call?
  2. What request shape would pass?
  3. Who can approve a temporary exception?

Complex policy engines become valuable when you need shared libraries, formal decision traces, or organization-wide reuse. Do not start there solely because the policy language looks sophisticated. Start with the smallest representation that makes dangerous actions explicit and testable.

Audit Records: Capture Enough Context Without Capturing Secrets

The audit trail is not a debug print. It is the evidence surface for incident response, compliance review, and policy tuning. OWASP's MCP08 guidance calls out the need for structured, centralized activity logging and warns that missing telemetry blocks forensic analysis (OWASP).

Each tool-call record should include:

Field Why it exists
Request ID and parent trace ID Reconstruct the agent workflow
User, agent, and workload identity Identify the acting principal
Server identity and manifest digest Bind execution to the discovered server version
Tool name and schema version Explain the invoked contract
Argument hash and redacted summary Investigate shape without storing secrets
Policy version and decision reason Explain why execution was allowed or blocked
Approval identity and expiry Audit sensitive exceptions
Credential fingerprint and audience Correlate scope without logging the token
Response hash and redaction count Track output inspection
Duration and outcome Tune operations and detect failure spirals

The redacted summary deserves care. Hashing the full argument object protects raw values, but a hash alone is not enough during an incident. Store a deliberately limited structural summary: field names, row-limit bucket, resource category, region, and whether restricted fields were requested. Do not store free-form prompt text by default. Do not store bearer tokens at all. Preserve the original payload only in a tightly controlled forensic path if your risk model genuinely requires it.

The useful test is simple: can an incident responder distinguish a normal one-record support lookup from a wildcard export attempt without opening raw customer data? If not, improve the summary schema. If the summary itself contains customer secrets, reduce it.

OpenTelemetry is a natural transport because it keeps agent events close to the rest of your operational traces. Emit a span or structured log around the policy decision, attach the request ID to the downstream call, and alert when audit delivery fails. OpenAI's Codex deployment uses OpenTelemetry export for tool decisions, tool results, MCP usage, and network-policy events, which is the same correlation shape a production MCP gateway needs (OpenAI).

Failure Handling: Decide What Happens When the Gateway Is Sick

The gateway is now part of the critical path. Treat it like one.

There are four failures to design before rollout:

Policy service unavailable

For destructive tools, deny or queue the call. Do not bypass policy because the policy service is down. For narrow read-only tools, a cached last-known-good decision may be acceptable if the cache binds server digest, tool, identity scope, argument envelope, and a short expiry. Document that exception instead of letting it emerge during an outage.

Identity provider unavailable

Do not fall back to a shared static credential. Queue work or require the operator to retry after recovery. Static fallback credentials quietly become the most privileged path in the system because they outlive every intended boundary.

Audit sink unavailable

Buffer records locally with bounded storage and alert immediately. For destructive calls, decide whether missing durable audit delivery should halt execution. Regulated workflows often need a fail-closed rule. Low-risk reads may tolerate a bounded buffer. Either way, make the behavior explicit and test it.

Response inspector timeout

Do not pass an uninspected response into model context merely because inspection exceeded its latency budget. Return a typed failure, quarantine the payload for review, and let the agent choose a safe recovery path. The agent may retry a different source, ask for approval, or stop.

flowchart TD A[Gateway dependency fails] --> B{Destructive action?} B -->|yes| C[Fail closed or queue] B -->|no| D{Valid short-lived cached decision?} D -->|yes| E[Allow narrow read and audit locally] D -->|no| F[Return typed retryable failure] C --> G[Alert operator] E --> G F --> G

Exercise these paths in a staging environment. Disable the policy backend. Rotate the signing key. Return an oversized response. Break audit delivery. Let an agent hit the same denied call repeatedly. The first time you observe these behaviors should not be during an incident.

Testing the Boundary

Policy tests should read like security requirements. Keep a compact suite beside each policy package:

def test_wildcard_region_is_denied():
    call = ToolCall(
        "inventory-mcp",
        "inventory.lookup",
        {"region": "*", "limit": 25},
        "agent:triage",
    )
    assert govern(call).allowed is False


def test_write_requires_approval():
    call = ToolCall(
        "inventory-mcp",
        "inventory.adjust",
        {"sku": "A-17", "delta": -2},
        "agent:triage",
    )
    assert govern(call).reason == "human approval required"

Add adversarial fixtures for every argument parser. Test wildcard values, negative limits, empty resource IDs, encoded shell separators, oversized payloads, unexpected fields, and schema-version drift. Then test sequences: many distinct sensitive reads, repeated identical failures, approval reuse after expiry, and an agent switching servers mid-workflow.

The test suite is also a communication artifact. An application owner can review a dozen explicit examples faster than a dense policy document. When a requirement changes, update the test first, then change the rule. That keeps security policy close to the behavior engineers can observe.

Finally, replay real traces against policy updates before rollout. A deny rule that blocks an existing workflow should be visible in staging. An allow rule that unexpectedly widens access should be visible in the diff. Runtime governance works best when teams treat policy evolution as engineering work, not as a one-time security checklist.

A Practical Migration Runbook

If you already have MCP servers in production, do not attempt a single cutover where every call becomes governed overnight. The safer path is to move one tool family at a time while preserving evidence about what changed.

Start with inventory. List every MCP server your agents can reach, including developer-local servers, staging endpoints, experimental connectors, and servers configured through project files. Record owner, repository, deployment environment, transport, authentication mode, exposed tools, credential source, and whether the server can cause side effects. The shadow-server pass is usually revealing. A platform team may know about the official database connector and miss the local helper that a team added during an incident.

Then classify tools into four rings:

Ring Typical capability Default runtime decision
0 Static documentation and public metadata Allow with logging
1 Narrow internal reads Allow with argument validation
2 Broad reads or bounded writes Require stronger scope and conditional approval
3 Destructive actions, credentials, deployments, identity changes Deny by default; explicit approval and audit required

Do not classify only by tool name. A database query tool can move from Ring 1 to Ring 3 depending on accessible tables, row limits, export options, and credential scope. A file reader can be low risk inside a documentation folder and high risk when its root is a developer home directory. The ring belongs to the effective capability, not the marketing label.

Run the gateway in observe mode for a bounded period. During that window, every call receives the policy decision it would have received under enforcement, but the gateway does not block normal traffic. Review the would-deny records daily. Separate legitimate workflows from accidental breadth. Tighten arguments where a tool is more general than the workflow needs. Fix credentials where one token spans too many systems.

Promote enforcement in this order:

  1. Unknown server denial.
  2. Ring 3 approval requirements.
  3. Credential redaction and scoped injection.
  4. Destructive-argument denial.
  5. Broad-read limits.
  6. Response-shape validation.
  7. Sequence alerts and circuit breakers.

This order catches the highest-impact failures early without turning the first rollout into an organization-wide productivity outage. It also creates a useful feedback loop: every enforcement phase produces traces that improve the next phase.

Keep an exception register. Each temporary bypass should name an owner, reason, affected tool, exact widened scope, approval identity, creation time, and automatic expiration. Review active exceptions weekly. If a bypass survives repeated renewals, treat it as a missing product requirement and redesign the policy or the tool. Permanent emergency flags are policy debt with a friendly name.

Finally, rehearse revocation. Pick a test server, mark its identity as compromised, and verify that new calls stop. Revoke a credential and confirm that the gateway does not reuse a cached token. Change a schema digest and verify that the client requires revalidation. Search the audit store for the affected server digest and reconstruct the call history. A control plane is only credible if you can use it while the system is under pressure.

The migration is complete when teams can answer three questions without guesswork:

  1. Which MCP servers can each agent reach?
  2. Which calls require approval or denial, including argument boundaries?
  3. Can an incident responder reconstruct what happened without exposing secrets?

If any answer is unclear, keep the gateway in the rollout plan. The missing clarity is exactly the risk runtime governance is meant to remove.

Conclusion

MCP made tool integration easier. That is exactly why the governance boundary matters. Once an agent can discover and call real systems, server trust is only the starting point.

The production question is not whether the model usually behaves. It is whether every meaningful side effect passes a deterministic checkpoint that understands identity, tool, arguments, scope, approval state, recent history, and response shape.

Start with the smallest useful gateway. Deny unknown servers. Validate arguments. Mint scoped credentials at the boundary. Inspect responses before they re-enter model context. Emit audit records you can replay during an incident. Then add sequence policy as your traces expose the normal shape of real work.

That is how you turn MCP from a convenient execution surface into a governed one.


Get the next one

Each week I send a short engineering note with one production failure, the debugging path, and companion code from the latest deep-dive. It is free, brief, and easy to leave.

👉 Join the free weekly note

If this saved you a governance incident, you can support the work here: Buy Me a Coffee.

Reader challenge: try mapping the runtime governance checklist above to one MCP server you already use. Reply to the email or comment with the first missing gate you find.

Sources

About the Author

Toc Am

Founder of AmtocSoft. Writing practical deep-dives on AI engineering, cloud architecture, and developer tooling. Previously built backend systems at scale. Reviews every post published under this byline.

LinkedIn X / Twitter

Published: 2026-06-03 · Updated: 2026-06-17 · Written with AI assistance, reviewed by Toc Am.

Get These In Your Inbox

Weekly deep-dives on AI engineering, no fluff. Join the newsletter →

Subscribe (free)

Or grab the book ($39, ~100 pages) · Buy me a coffee

Buy Me a Coffee · 🔔 YouTube · 💼 LinkedIn · 🐦 X/Twitter

Friday, May 22, 2026

MCP Server Supply Chain Integrity: Authorization-Bound Replay and Token-Scope Drift Composition

Hero image showing an MCP replay worker comparing archived evidence receipts, authorization scope, and current tool-contract impact.

Introduction

I once watched an agent replay pass every artifact check and still fail the security review for the right reason. The binary had not changed. The registry metadata matched the archived receipt. The provenance bundle verified. The bug was quieter: the replayed tool was now invoked under a broader authorization scope than the one the original admission decision had assumed.

That kind of failure is annoying because every individual subsystem can look healthy. Supply-chain verification says the artifact is still the artifact. Runtime tracing says the tool call happened along the expected route. Authorization middleware says the token was valid. The uncomfortable question sits between those facts: did the original trust decision compose with the authority now being handed to the tool?

Blog 253 built the archive receipt for MCP server supply-chain evidence. Blog 254 added receipt-bound replay, so the platform could review old evidence against current policy without rewriting the old decision. Blog 255 adds the next rule: authorization-bound replay and token-scope drift composition. The core claim is simple. An MCP replay decision is incomplete if it proves artifact integrity but ignores the authority envelope used by the current application layer.

This matters because MCP is not only a package discovery problem. MCP servers are used through clients, transports, tools, resources, and authorization flows. The MCP authorization specification describes transport-level authorization for HTTP-based transports, where clients can make restricted-server requests on behalf of resource owners per the MCP authorization spec. That makes authorization scope a first-class part of the replay question, not a footnote after signature verification.

The rule in this post keeps four records separate: archived supply-chain evidence, archived authorization assumptions, current token-scope envelope, and current tool-contract impact. It emits a bounded disposition instead of a generic pass. If the artifact still verifies but the authority envelope widened, the correct answer may be re-admit rather than continue.

The Problem

Most MCP supply-chain reviews start with the artifact because artifacts are concrete. A server package has a digest. A manifest can be signed. A provenance statement can name a builder. A registry entry can be captured in an archive receipt. Those checks are necessary, and the earlier posts in this cluster intentionally spent a lot of space on them.

The problem is that agents do not execute artifacts in a vacuum. They call tools under application contracts, route decisions, user intent, and authorization grants. A read-only documentation helper and a privileged customer-record writer can point at the same server artifact but carry very different risk. If replay only asks whether the artifact remained trustworthy, it can approve the wrong operational use.

Here is the failure pattern I want to prevent:

  1. A tool server is admitted with a narrow scope, such as read-only access to a documentation resource.
  2. The server's artifact receipt is archived and later rechecked successfully.
  3. A new workflow routes the same tool through a broader token scope.
  4. The replay system says "continue" because the artifact evidence still passes.
  5. A human reviewer later discovers that the original decision never covered the new authority envelope.

The fifth step is the expensive one. The platform has not been hacked, necessarily. It has drifted into an unsupported trust composition. That is still a security defect because the authorization boundary changed without a fresh admission decision.

The same pattern can happen in the opposite direction. A server may lose scope, become read-only, or move behind a more restrictive policy. In that case replay should not panic just because scope changed. The disposition should depend on the direction of drift, current contract impact, retained evidence, and policy. A scope delta is not automatically good or bad. It is a fact that must be composed with the rest of the replay record.

Architecture diagram showing archived receipt, authorization envelope, policy digest, and current contract impact feeding an authorization-bound replay decision.

I would not model this as one giant "agent safety" field. That field becomes impossible to audit. A better record has named inputs:

Input Retained field Replay question
Artifact receipt digest, signer, provenance reference Does the original supply-chain evidence still verify?
Authorization assumption scope class, resource class, delegation mode What authority did the original decision assume?
Current token envelope granted scopes, audience, expiry class What authority does the current call carry?
Application contract read/write impact, data sensitivity What can this tool do now?
Replay policy digest and rule version Which review rule is binding?

The table is intentionally boring. Security replay fails when boring fields are missing. If scope is only present in a prose note, it will disappear from the join when the replay worker needs it.

How the Composition Rule Works

The authorization-bound replay rule starts with the receipt-bound replay result from blog 254, then joins it with two additional projections: the archived authorization assumption and the current token-scope envelope. The archived assumption is not the entire token. It should not retain secrets. It should retain a normalized scope class, resource class, delegation mode, audience class, and policy digest. The current envelope is also normalized before comparison.

That normalization matters. Raw authorization systems have provider-specific names, tenant-specific audiences, and token formats that change over time. The replay worker should compare stable semantic classes rather than brittle strings. For example, docs.read, kb.view, and reference:read might all map to resource_read. A privileged customer write scope might map to customer_write_privileged. The mapping must be policy-owned, versioned, and visible in the review record.

The first pass evaluates evidence continuity. If the artifact receipt cannot verify, the authorization join should not rescue it. The tool is either re-admit, quarantine, or retire depending on policy and impact. If evidence passes, the rule evaluates scope drift.

The second pass evaluates the direction of token-scope drift:

Drift direction Example Default disposition
Same scope class read-only docs then read-only docs Continue if evidence and policy pass
Narrowed scope write-capable then read-only Continue or re-admit, depending on policy
Lateral scope docs read then ticket read Re-admit if resource class changed
Widened scope docs read then customer write Re-admit or quarantine
Unattributed scope missing archived assumption Quarantine for privileged contracts

That disposition table is not meant to replace local policy. It is a starting rubric. The important move is to stop treating scope drift as a note attached to artifact verification. Scope drift changes the trust composition.

flowchart LR A[Archived artifact receipt] --> B[Receipt-bound evidence recheck] C[Archived authorization assumption] --> D[Scope-class comparator] E[Current token envelope] --> D F[Current application contract] --> G[Impact-class evaluator] B --> H[Authorization-bound replay] D --> H G --> H H --> I{Disposition} I -->|same or narrowed| J[Continue with reason code] I -->|lateral or widened| K[Re-admit with current policy] I -->|missing evidence| L[Quarantine]

The third pass evaluates application-contract impact. A widened scope that only permits a low-risk read may be re-admitted through a lightweight path. A widened scope that permits production writes, customer data access, payment actions, or expensive external calls should receive a stricter disposition. Artifact integrity does not lower that impact class.

The fourth pass writes reason codes. I would use reason codes like:

scope_class_unchanged
scope_class_widened
resource_class_changed
delegation_mode_changed
archived_scope_assumption_missing
privileged_contract_requires_re_admission
artifact_receipt_verified
artifact_receipt_unavailable

Reason codes are the difference between a useful replay program and a dashboard-shaped fog machine. They let a team see whether failures are caused by missing retained scope assumptions, product teams adding broader tool authority, or verifier evidence disappearing.

Implementation Guide

Here is a compact implementation sketch. It is not a replacement for a full authorization engine. It shows the shape of the join that a replay worker should perform after it has already loaded the archived receipt and current policy.

from dataclasses import dataclass
from enum import Enum


class ScopeDrift(str, Enum):
    SAME = "same"
    NARROWED = "narrowed"
    LATERAL = "lateral"
    WIDENED = "widened"
    UNATTRIBUTED = "unattributed"


@dataclass(frozen=True)
class AuthAssumption:
    scope_class: str
    resource_class: str
    delegation_mode: str
    policy_digest: str


@dataclass(frozen=True)
class TokenEnvelope:
    scope_class: str
    resource_class: str
    delegation_mode: str
    audience_class: str


@dataclass(frozen=True)
class ContractImpact:
    impact_class: str
    can_write: bool
    touches_sensitive_data: bool


def classify_scope_drift(old: AuthAssumption | None, new: TokenEnvelope) -> ScopeDrift:
    if old is None:
        return ScopeDrift.UNATTRIBUTED
    if old.scope_class == new.scope_class and old.resource_class == new.resource_class:
        return ScopeDrift.SAME
    if old.resource_class != new.resource_class and old.scope_class == new.scope_class:
        return ScopeDrift.LATERAL
    order = {"read": 1, "read_write": 2, "privileged_write": 3}
    old_rank = order.get(old.scope_class, 99)
    new_rank = order.get(new.scope_class, 99)
    if new_rank < old_rank:
        return ScopeDrift.NARROWED
    if new_rank > old_rank:
        return ScopeDrift.WIDENED
    return ScopeDrift.LATERAL


def replay_disposition(
    evidence_verified: bool,
    old_auth: AuthAssumption | None,
    new_token: TokenEnvelope,
    impact: ContractImpact,
) -> tuple[str, tuple[str, ...]]:
    reasons: list[str] = []

    if not evidence_verified:
        reasons.append("artifact_receipt_unavailable_or_failed")
        if impact.impact_class == "privileged":
            return "quarantine", tuple(reasons)
        return "re_admit", tuple(reasons)

    reasons.append("artifact_receipt_verified")
    drift = classify_scope_drift(old_auth, new_token)
    reasons.append(f"scope_drift_{drift.value}")

    privileged = impact.impact_class == "privileged" or impact.can_write or impact.touches_sensitive_data
    if drift == ScopeDrift.UNATTRIBUTED and privileged:
        reasons.append("privileged_contract_missing_archived_scope")
        return "quarantine", tuple(reasons)
    if drift in {ScopeDrift.WIDENED, ScopeDrift.LATERAL}:
        if privileged:
            reasons.append("privileged_contract_requires_re_admission")
        return "re_admit", tuple(reasons)
    return "continue", tuple(reasons)

The most important line is not the enum. It is the refusal to return continue when the archived authorization assumption is missing for a privileged contract. That is the security posture. Missing old scope context is not a neutral state. It is an attribution gap.

Here is the terminal fixture I use for the failure from the introduction:

case=customer-write-expanded-scope
artifact_receipt=verified
old_scope=read
old_resource=docs
new_scope=privileged_write
new_resource=customer_records
contract_impact=privileged
disposition=re_admit
reasons=artifact_receipt_verified,scope_drift_widened,privileged_contract_requires_re_admission

That output is deliberately short. It gives an incident responder enough to know that the artifact was not the problem. The new authority envelope was.

Decision Flow

The decision flow should be strict about ordering. First verify the artifact receipt. Then compare scope. Then evaluate contract impact. Then emit the disposition. If the implementation checks scope first, it may accidentally explain away a missing artifact receipt. If it checks contract impact first, it may overreact to a low-risk tool whose artifact evidence failed in a recoverable way.

flowchart TD A[Start replay] --> B{Artifact receipt verifies?} B -->|No| C{Privileged contract?} C -->|Yes| D[Quarantine] C -->|No| E[Re-admit] B -->|Yes| F{Archived auth assumption exists?} F -->|No| G{Privileged contract?} G -->|Yes| D G -->|No| E F -->|Yes| H{Scope drift direction} H -->|Same| I[Continue] H -->|Narrowed| I H -->|Lateral| E H -->|Widened| J{Sensitive or write-capable?} J -->|Yes| E J -->|No| E

There is a subtle gotcha in that flow. The widened-scope branch returns re-admit even when the tool is not sensitive. That may feel conservative, but it keeps the replay system honest. A widened authority envelope means the current use is outside the old trust composition. Low-risk use can have a lightweight re-admission path. It still deserves a fresh decision.

The same principle applies to lateral drift. Reading from a different resource class can change risk without changing the apparent permission rank. A token that moves from documentation read to ticket read may expose customer details, incident notes, or internal operational data. Lateral is not harmless just because it is not wider.

Comparison and Tradeoffs

There are three common ways teams handle this problem.

The first approach is artifact-only replay. It is simple, fast, and easy to explain. It is also incomplete for MCP tools that cross authorization boundaries. Artifact-only replay answers whether the artifact still verifies against retained evidence and current policy. It does not answer whether the current token authority is covered by the old admission decision.

The second approach is runtime-only authorization enforcement. This approach says the tool call is safe if the current token is valid and the runtime policy allows the call. It is better than ignoring authorization, but it misses the historical admission question. The token can be valid while the supply-chain admission decision is stale for that scope.

The third approach is authorization-bound replay. It keeps artifact verification, runtime authorization, and admission replay as separate layers. That separation costs more schema work. It also gives reviewers a better audit story.

Comparison visual contrasting artifact-only replay, runtime-only authorization, and authorization-bound replay.
Approach Strength Failure mode
Artifact-only replay Strong supply-chain evidence discipline Misses token-scope expansion
Runtime-only auth Enforces current access policy Ignores historical admission assumptions
Authorization-bound replay Composes evidence, authority, and impact Requires retained normalized scope fields

I prefer the third approach for production agents because it keeps each layer narrow. Sigstore's verification tooling focuses on signatures and attestations per Sigstore. SLSA defines supply-chain levels and recommended attestation formats including provenance per SLSA v1.2. OpenTelemetry's GenAI semantic conventions help runtime telemetry use common attributes per OpenTelemetry. None of those sources should be forced to impersonate the others. The platform composes them at the replay layer.

sequenceDiagram participant Old as Archived admission participant Replay as Replay worker participant Auth as Authorization policy participant App as Application contract participant Result as Review result Old->>Replay: receipt digest + scope assumption Auth->>Replay: current scope mapping + policy digest App->>Replay: current impact class Replay->>Result: continue / re-admit / quarantine / retire Result-->>App: reason-coded decision

Production Considerations

Do not store raw access tokens in the replay archive. Store normalized authority projections and enough metadata to prove which mapping policy produced them. A projection can include scope class, resource class, audience class, delegation mode, tenant boundary, and policy digest. The exact set depends on your environment, but the principle is stable: retain what replay needs without retaining bearer secrets.

Treat the normalization policy as code. If the mapping from provider scopes to semantic scope classes changes, replay should record both the old mapping digest and the new mapping digest. Otherwise a future reviewer cannot tell whether scope drift came from the token, the resource, or the team's interpretation of provider-specific strings.

Monitor three counters from day one:

Counter Why it matters
Re-admits caused by widened scope Shows product workflows expanding tool authority
Quarantines caused by missing archived auth assumptions Shows archive schema gaps
Lateral resource-class drifts Finds quiet movement into sensitive data classes

Those counters should be sliced by tool family, contract impact, and owner. A single global "scope drift" percentage will hide the repair path. If most quarantines come from missing archived assumptions, improve the archive writer. If most re-admits come from one workflow owner, review the workflow's tool-contract design.

Finally, keep enforcement staged. Start with report-only results for low-impact tools. Enforce re-admission for privileged contracts first. Quarantine only when the replay system can point to a clear reason code: missing archived scope for privileged use, failed artifact evidence, or current policy that explicitly disallows the authority composition.

Debugging the Non-Obvious Failure

The bug that tends to survive the first rollout is not a failed verifier. It is a stale scope mapping. A provider renames a scope, a gateway team updates a policy bundle, or a product team splits one resource class into two. The replay worker still receives a token envelope, but the normalization policy no longer maps it to the same semantic class that the archive writer used months earlier.

That failure can look like real drift. In one fixture, resource_read became case_read after a policy cleanup. The application contract had not gained authority. The old mapping was simply coarser than the new mapping. My first implementation emitted lateral and required re-admission for hundreds of low-risk reads. The replay system was technically consistent and operationally noisy.

The repair was to version the mapping and add a migration table for semantic splits. If an old class splits into narrower new classes, replay can emit scope_class_refined instead of scope_class_lateral, as long as the new class is a subset of the old authority. That reason code still records the mapping change, but it does not punish the team for making authorization metadata more precise.

Here is the terminal output I want from that regression test:

case=resource-class-refinement
old_mapping=auth-map:2026-04-01
new_mapping=auth-map:2026-05-22
old_scope=resource_read
new_scope=case_read
subset_proof=present
contract_impact=standard
disposition=continue
reasons=artifact_receipt_verified,scope_class_refined,subset_proof_present

The subset_proof field is doing real work. Without it, a renamed scope can sneak past review as if it were narrower. With it, the replay worker has to show why the new class is contained by the old assumption. That proof can be a policy-table row, a signed mapping bundle, or an internal authorization schema version. The exact mechanism matters less than the discipline: refinement is not a synonym for trust.

The second non-obvious failure is clock-bound authority. A token may have been valid for a short-lived delegated action, while the replay archive only retained its scope class. Months later the replay worker sees the same class and misses the fact that the original decision assumed a narrow delegation window. That is why I retain an expiry class, not an expiry timestamp. The archive does not need the old bearer token. It does need to know whether the admission assumed a five-minute user delegation, a service account, or a long-lived automation credential.

I use three expiry classes in fixtures:

Expiry class Replay meaning
interactive_short User-mediated action with a short review window
service_rotated Service credential with normal rotation evidence
long_lived_exception Exception path that should force re-admission

This is boring, but it catches a class of incidents that otherwise become arguments. The artifact still verifies. The scope class may be the same. The delegation duration changed from interactive to long-lived. That is authority drift.

Review Result Schema

The review result should be append-only and separate from the original admission receipt. That separation is the same discipline used in blog 254. The old decision remains the old decision. The replay result records what the current review discovered under current policy, current scope mapping, and current contract impact.

A minimal review result needs these fields:

Field Purpose
receipt_digest Links the review to the archived supply-chain evidence
archived_auth_digest Links to the normalized authority assumption retained at admission
scope_mapping_digest Names the policy-owned mapping used during replay
current_token_envelope_digest Identifies the normalized current authority envelope
contract_impact_class Separates low-risk reads from privileged writes
disposition Emits continue, re-admit, quarantine, or retire
reason_codes Explains why the disposition was chosen

I would also include reviewed_at, review_worker_version, and policy_digest. Those fields are not glamorous, but they make a future dispute answerable. If a team asks why a tool moved from continue to re-admit between two review runs, the platform can compare the mapping digest, policy digest, and worker version before accusing the tool owner.

The review result should avoid copying raw verifier logs or raw token material. It can point to evidence bundles and normalized projections. That keeps the operational dashboard useful without turning it into a sensitive-data lake. When an incident responder needs deeper evidence, they can open the referenced receipt and policy bundles through the normal access path.

One design constraint is worth stating plainly: a replay result should never mutate the old archived assumption. If the old assumption was too thin, append a result that says so. Do not patch history to make the review pass. The whole point of replay is to preserve the difference between what the platform knew then and what it knows now.

Testing Strategy

The test suite should be built around joins, not just individual validators. Unit-test the scope classifier, of course. Also test the full replay disposition because most bugs appear when evidence state, scope drift, and contract impact interact.

I would start with eight fixtures:

Fixture Expected disposition
Verified artifact, same read scope, low-impact contract Continue
Verified artifact, narrowed scope, standard contract Continue
Verified artifact, widened scope, low-impact contract Re-admit
Verified artifact, widened scope, privileged contract Re-admit with privileged reason
Verified artifact, missing archived scope, privileged contract Quarantine
Failed artifact evidence, low-impact contract Re-admit
Failed artifact evidence, privileged contract Quarantine
Scope-class refinement with subset proof Continue

The fixture names should include the reason code being tested. That sounds fussy until an incident review asks why a decision changed between policy versions. A reason-coded fixture lets the team see whether the code changed the disposition rule or the policy mapping changed the input.

I also like snapshot tests for the review record. A review result is part of the audit surface. If a code change removes policy_digest, scope_mapping_digest, or contract_impact, the snapshot should fail. It is easier to catch a missing field in CI than in a quarterly review when the person who changed the serializer is working on something else.

Rollout Checklist

Before enforcing authorization-bound replay, I would require five operational checks.

First, the archive writer must retain normalized authorization assumptions for new admissions. If the archive only has raw prose, start in report-only mode and mark privileged gaps clearly.

Second, the authorization team must own the mapping table from provider scopes to semantic scope classes. The table should have a digest. Replay should record that digest in every result.

Third, the application platform must classify tool contracts by impact. A replay worker cannot decide whether a scope widening is dangerous if every contract is simply "tool call."

Fourth, dashboards must show reason-code distribution, not only disposition counts. A spike in archived_scope_assumption_missing means a data-retention problem. A spike in scope_drift_widened may mean product workflows are expanding authority. Those are different repair queues.

Fifth, enforcement should begin with privileged contracts. Report-only for low-impact reads gives teams time to improve mapping and archives without blocking harmless traffic. Privileged contracts deserve less patience because the cost of approving unsupported authority is higher.

Conclusion

Artifact integrity is necessary for MCP server trust, but it is not the whole trust decision. A tool can still verify and still be unsafe for the authority envelope now attached to it. Authorization-bound replay closes that gap by joining archived evidence, archived scope assumptions, current token scope, and current application impact.

The payoff is a sharper review result. The platform can say: the artifact still verifies, the old decision assumed read-only documentation access, the current workflow grants privileged customer-record write authority, and the correct disposition is re-admit. That is much better than a green checkmark that only proves the easiest part.

Sources

  1. Model Context Protocol, "Authorization," https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization
  2. Model Context Protocol, "The MCP Registry," https://modelcontextprotocol.io/registry/about
  3. Sigstore, "Verifying Signatures," https://docs.sigstore.dev/cosign/verifying/verify/
  4. SLSA, "SLSA Specification v1.2," https://slsa.dev/spec/v1.2/
  5. OpenTelemetry, "Semantic conventions for generative AI," https://opentelemetry.io/docs/specs/semconv/gen-ai/

About the Author

Toc Am

Founder of AmtocSoft. Writing practical deep-dives on AI engineering, cloud architecture, and developer tooling. Previously built backend systems at scale. Reviews every post published under this byline.

LinkedIn X / Twitter

Published: 2026-05-22 · Written with AI assistance, reviewed by Toc Am.

Get These In Your Inbox

Weekly deep-dives on AI engineering, no fluff. Join the newsletter →

Subscribe (free)

Or grab the book ($39, ~100 pages) · Buy me a coffee

Buy Me a Coffee · 🔔 YouTube · 💼 LinkedIn · 🐦 X/Twitter

AI as Infrastructure: Value Moves Up-Stack

For a few years the AI conversation was about who had the biggest model. That is the wrong altitude now. Models still matter, the way CPUs s...