DEV Community

Riley Wang
Riley Wang

Posted on

The Agent's Key Ring: Scoping Tool Permissions with a Deny-by-Default Wrapper

Agent safety is a permission problem, not a prompt problem. A carefully written system prompt can be bypassed by a clever tool argument or by model confusion. The only reliable enforcement point is a wrapper that inspects every tool call before it touches the outside world.

Most agent SDKs give the model direct access to functions and trust it to use them correctly. That trust is an accident waiting for a prompt injection. Instead, every tool call should pass a policy layer that can allow it, deny it, or route it to a human. This article shows a small deny-by-default wrapper, a decision table for common argument patterns, and a way to test it using free models and a free server tier.

Why prompting is not enough

A model can be instructed to never delete files, but an attacker can write a prompt that reorders those instructions. A model can also make a legitimate call with an unintended argument because it misread a value. In both cases the tool executes exactly what was passed to it, and the damage happens before anyone reads a trace.

A policy wrapper does not change the model's behavior; it changes what the model is allowed to influence. The wrapper acts as a small, deterministic program that evaluates each call against hard rules. Prompts are probabilistic; a deny list is not.

Build the wrapper

The core is a function that takes a tool definition and returns a wrapped version that enforces a policy. The policy receives the tool name, the arguments, and some minimal context such as the user ID and working directory.

import functools
from dataclasses import dataclass

@dataclass
class Decision:
    action: str  # "allow", "deny", "require_approval"
    reason: str = ""

class PolicyEnforcer:
    def __init__(self, policy_fn):
        self.policy_fn = policy_fn

    def __call__(self, func):
        @functools.wraps(func)
        async def wrapped(*args, **kwargs):
            tool_name = kwargs.get("tool_name", func.__name__)
            arguments = kwargs.get("arguments", {})
            context = kwargs.get("context", {})
            decision = self.policy_fn(tool_name, arguments, context)
            if decision.action == "allow":
                return await func(*args, **kwargs)
            if decision.action == "require_approval":
                return await self.request_approval(func, arguments, decision)
            raise PermissionError(f"Blocked by policy: {decision.reason}")
        return wrapped

    async def request_approval(self, func, arguments, decision):
        # Notify a human and wait, or fail closed.
        raise PermissionError(f"Requires approval: {decision.reason}")
Enter fullscreen mode Exit fullscreen mode

This is fail-closed. When a call does not match an explicit allow rule, it is denied. That is the deny-by-default property.

A policy table that covers the common cases

Policy rules are easier to review as a table. Each row should map an observation to an explicit action.

Observation Example Decision Rationale
Read only within allowed directory read_file(path="/tmp/report.md") allow No state mutation
Read outside allowed root path="/etc/passwd" deny Path traversal or secret exfiltration
Write to a known temp location write_file(path="/tmp/upload.bin") allow Expected scratch space
Write to a system path path="/usr/bin/agent" deny Privilege escalation
Network call to private IP range host="10.0.0.5" deny Internal reconnaissance
Network call to public API host="api.example.com" require_approval Cost and data leakage risk
Destructive shell command command="rm -rf /" deny Irreversible action

The table is not exhaustive, but it shows the shape of a good policy. The rules should be short, explicit, and tested just like code.

Test the wrapper without breaking production

The wrapper needs a runtime to be tested against. A local server is fine for unit tests, but the interesting failures happen when a real model generates unexpected arguments. Running the model on ephemeral tasks catches those surprises before they touch a real tool.

MonkeyCode currently provides free models and a free server tier. That combination is enough to run this test loop: the server hosts the agent, and the model calls come from the free token allowance. This lets a team experiment with aggressive deny lists without paying for every failed call. Quotas and terms change, so the README should be checked before relying on them.

Disclosure: This article was prepared as part of MonkeyCode's product outreach.

A test harness for the wrapper can be a short script that calls a model with a fixed prompt and inspects the wrapped result.

import asyncio

async def run_trial(agent, task):
    context = {"user_id": "test-user", "cwd": "/tmp/workspace"}
    try:
        result = await agent.run(task, context=context)
        return {"ok": True, "result": result}
    except PermissionError as e:
        return {"ok": False, "reason": str(e)}

async def main():
    agent = build_agent(tools=[wrapped_read, wrapped_write])
    tasks = [
        "Read /tmp/report.md",
        "Read /etc/passwd",
        "Write a summary to /tmp/upload.bin",
        "Fetch http://10.0.0.5/status",
    ]
    results = await asyncio.gather(*(run_trial(agent, t) for t in tasks))
    for task, r in zip(tasks, results):
        print(f"{task!r}: {'allowed' if r['ok'] else r['reason']}")

asyncio.run(main())
Enter fullscreen mode Exit fullscreen mode

Expected output should show the second and fourth trials being denied, the first and third being allowed. That is the wrapper behaving as designed.

Where the wrapper is not enough

A Python-level wrapper does not enforce anything if the model can escape its runtime. If the underlying tool is a shell command, the wrapper only sees the command string, not what the shell does with a chain like ls | cat /etc/shadow. Attackers can still abuse semantics even when a literal match fails.

The wrapper also cannot protect against malicious code that runs before the wrapper is registered. A compromised agent process could call tools directly if it has a direct handle to them. The design only works when all entry points go through the same enforcement object.

Who should skip this pattern

Teams that run agents only against mock data, with no real side effects, do not need a policy wrapper. A simpler assert in a test is enough. Teams that handle untrusted input with high-stakes tools should not rely on a wrapper alone; they should use operating-system sandboxes, containers, or network isolation.

The wrapper is a governance layer, not a security boundary. Put it between the model and the world, and pair it with lower-level isolation if the damage potential is high.

Start with one tool and one rule

Adopting deny-by-default can start small. Pick the tool that does the most damage, write two rules for it, and wrap it in front of a single agent flow. Let the model run a few honest tasks and watch the denials pile up.

Your agent will eventually ask for more power than it needs. Decide today whether that request gets a yes, a no, or an approval ticket.

Top comments (0)