Skip to content
advanced Phase 13 · Multi-Tool Agents and MCP

Bounding Agent Capabilities

Apply allowlists, sandboxes, and human approval gates to dangerous actions.

55m
0 problems
Topic Progress 0%

When the Agent Goes Too Far

A support agent with full tool access receives a ticket: "clean up the old test orders." It calls delete_order in a loop against what it believes is a test database. It is production. Four thousand orders are gone before anyone notices, and the audit log records only that the agent ran — not what it deleted, or why.

The autopsy is uncomfortable: the model did exactly what it was told. The failure was architectural. Four boundaries were missing at once:

  • No allowlist: the agent held every tool, including destructive ones the task never needed.
  • No approval gate: an irreversible action executed without a human seeing it first.
  • No sandbox: the deletion ran with full production credentials.
  • No audit trail: the log captured that calls happened, not their arguments, so the incident could not be reconstructed.

Each boundary is a separate mechanism, and each maps to a tool you already have: the tool registry tells you what exists, tool selection decides what the model may see, and this topic decides what the model may do. The next three chapters build the boundaries in the order you should deploy them.

The guiding principle is least privilege: an agent should hold the minimum set of capabilities its current task requires, nothing more. Full tool access is the default that makes sense only for trusted, human-operated tooling — not for autonomous agents acting on live data.

Tool Allowlists and Least Privilege

The first boundary is the cheapest: an explicit per-task allowlist. The agent for a refund review gets exactly the tools a refund review needs — no more.

ALLOWLIST = {
    "refund_review": {"lookup_order", "lookup_refund_policy"},
    "refund_execute": {"lookup_order", "issue_refund"},
}

def enforce_allowlist(task: str):
    def decorator(fn):
        @functools.wraps(fn)
        async def wrapper(tool_name: str, arguments: dict) -> dict:
            if tool_name not in ALLOWLIST[task]:
                raise PermissionError(f"{tool_name} not allowed for task {task}")
            return await fn(tool_name, arguments)
        return wrapper
    return decorator

The decorator wraps every tool entry point. A call to issue_refund under the refund_review task raises before any tool logic runs — the model never reaches the capability, so it cannot misuse it.

Task Allowed tools Requires approval
refund_review lookup_order, lookup_refund_policy none
refund_execute lookup_order, issue_refund issue_refund
data_export query_orders, export_csv export_csv

The allowlist is the same least-privilege idea as permissioned tool access, applied at the task level. Review it whenever the registry grows: a new tool added to the catalog but never added to an allowlist is safe by default — the agent cannot use what it was never granted.

Approval Gates and Sandboxes

Some actions are irreversible: payments, deletions, outbound email. For these, the agent proposes and a human disposes.

IRREVERSIBLE = {"issue_refund", "delete_account", "send_email"}

async def call_with_approval(tool_name: str, arguments: dict, user: str) -> dict:
    if tool_name in IRREVERSIBLE:
        ticket = await approval_service.request(
            tool=tool_name, arguments=arguments, requester=user
        )
        if ticket.status != "approved":
            return {"status": "denied", "ticket_id": ticket.id}
    return await execute_tool(tool_name, arguments)

The approval ticket carries the exact arguments, so the human reviews what will actually run — not a paraphrase. This is the same pattern as human review loops: the agent's job is to prepare the decision, not to make it.

Shell and code execution get a different boundary: a sandbox with no ambient network and no credentials.

def run_untrusted(code: str) -> str:
    result = subprocess.run(
        ["docker", "run", "--rm", "--network", "none",
         "-v", f"{SANDBOX_DIR}:/workspace", "python:3.12-slim",
         "python", "-c", code],
        capture_output=True, timeout=30,
    )
    return result.stdout.decode()

--network none cuts egress; the read-only mount and slim image mean there are no credentials to steal and no internal services to reach. The timeout bounds the blast radius of an infinite loop. Sandboxing is not about distrusting the model's intent — it is about bounding the damage of a wrong one.

Sandboxes fail in predictable ways. A container with a mounted Docker socket escapes to the host. A sandbox with ambient environment variables leaks API keys. A network-isolated sandbox with a writable home directory lets the agent stage data for exfiltration through the one channel you left open. Audit the sandbox configuration with the same rigor you audit the agent's tool access.

Audit Logging and Replay

The last boundary is the one that makes the others debuggable: log every invocation, with arguments, to an append-only stream.

def audit_log(event: dict) -> None:
    entry = {
        "timestamp": datetime.now(timezone.utc).isoformat(),
        "agent_id": event["agent_id"],
        "user_id": event["user_id"],
        "tool": event["tool"],
        "arguments": event["arguments"],
        "result_status": event["status"],
    }
    audit_stream.write(json.dumps(entry) + "\n")

When an incident happens, you replay the log to reconstruct exactly what the agent did: which task it ran as, which user it acted for, which tools it called, with which arguments, and what came back. This is the agent-level counterpart to tracing LLM calls — the same discipline, one layer down.

Failure modes that defeat audit logging:

  • Logging only tool names: you know what ran, not with what arguments — you cannot reconstruct intent.
  • Ambient credentials in the sandbox: the agent exfiltrates data through the one channel you left open.
  • Wildcard allowlists: a "*" entry turns the allowlist into a rubber stamp and least privilege evaporates.

An audit log you cannot replay is a log you cannot learn from. Arguments are the entry.

Quiz

1. What does a per-task tool allowlist enforce?

Question 1 options

2. Which actions should require human approval?

Question 2 options

3. Why run untrusted code with --network none and a read-only mount?

Question 3 options

4. What must an audit log capture to reconstruct an incident?

Question 4 options

Flashcards

Question

What is the core safety principle for agent tool access?

Answer

Least privilege: an explicit per-task allowlist, never full tool access. The agent cannot misuse a capability it was never granted.

Question

Which actions need a human approval gate?

Answer

Irreversible ones: payments, deletions, outbound email — anything the user cannot easily undo. The ticket carries the exact arguments.

Question

How should shell and code execution be sandboxed?

Answer

No ambient network (--network none), no credential access, read-only mounts, and timeouts. Bound the damage of a wrong action.

Question

What must an audit log record per invocation?

Answer

Tool name, arguments, actor, and result status — enough to replay exactly what the agent did.

Question

Why did the delete-everything incident happen?

Answer

The model did what it was told; the failure was architectural — full access, no approval gate, no sandbox, no audit trail.

Revision Notes

Key Takeaways

  • 1. Destructive incidents are architectural failures: the model does what it was told, so the boundaries must be structural
  • 2. A per-task allowlist enforces least privilege — the agent cannot misuse a capability it was never granted
  • 3. Irreversible actions route through a human approval ticket that carries the exact arguments
  • 4. Sandboxes with no network and no credentials bound the damage of a wrong action
  • 5. Audit logs must capture arguments, not just tool names, or incidents cannot be replayed

Interview Tips

  • • Open with the incident pattern: the model did what it was told, so the fix is architectural, not a better prompt
  • • Present the four boundaries as a layered defense: allowlist, approval gate, sandbox, audit log
  • • Justify the approval gate with reversibility: payments and deletions need a human, read-only queries do not
  • • Close with the audit trail: without arguments in the log, none of the other boundaries are debuggable

Cheat Sheet

Bounding Agent Capabilities Cheat Sheet

The incident pattern: the model does what it was told; the failure is architectural.

Four boundaries, in deployment order:

  1. Allowlist — per-task tool set, enforced before tool logic runs
if tool_name not in ALLOWLIST[task]:
    raise PermissionError(...)
  1. Approval gate — irreversible actions (payments, deletions, email) require a human ticket carrying the exact arguments

  2. Sandbox — --network none, no credentials, read-only mounts, timeouts

  3. Audit log — every invocation with arguments, append-only

{timestamp, agent_id, user_id, tool, arguments, result_status}

Task → tools example:

Task Allowed Approval
refund_review lookup_order, lookup_refund_policy none
refund_execute lookup_order, issue_refund issue_refund

Failure modes: wildcard allowlists, logging without arguments, ambient credentials in the sandbox, auth checked only at connection time.

Continue Learning

Before You Start

  • No prerequisites listed.

Related Reading