← Insights

Least Privilege for Non-Human Developers

An agent's permissions are whatever its tools can reach, not whatever its role description says, so the policy has to be written at the tool boundary and tested like a control.

Author
Aaron Smith
Reading time
8 min
# src/agents/python_security.py: the access-control fields, tool list reflowed
allowed_tools=["Read", "Glob", "Grep", "Bash"],
permission_mode="acceptEdits",
use_worktree=False,  # Security reads from main repo

That is the complete access-control definition for an agent I built to review other agents' code for vulnerabilities. Write and Edit are absent from the list, which is what makes it look read-only. Bash is present, which is what makes it not.

Anything expressible as a shell command sits inside that grant, including every write the two missing tools would have performed. The comment on the third line does more damage than either: the reviewer runs against the shared repository checkout, while the developer agent whose work it reviews runs inside an isolated git worktree. The role with fewer tools has the wider reach, and nothing in the system knows that.

What I expected the hard part to be

Earlier this year I spent a month building an agent pod in an internal architecture repository: a developer agent, a reviewer, a security reviewer, and an orchestrator that invokes them. I expected the difficult part to be expressing policy. So I wrote the policy first. The pull request that merged at the end of that first week added 3,489 lines to a single file, now at docs/ideas/architecture/RBAC_ARCHITECTURE.md, covering three identity classes (human operators, agent identities, service accounts), an Open Policy Agent rego module with default allow = false, agent identities carrying a token that expires after 24 hours, and delegated child identities that inherit no roles and expire after one hour.

None of it runs. Line 3 of that document carries **Status: [DEFERRED]**. The gap I actually hit was not policy expressiveness. It was that the code enforcing permissions never consulted a policy at all, and I could not tell, because the enforcement it did have failed silently in the direction of allow.

The tool list is the permission, not the description

Follow one invocation. The orchestrator holds an AgentConfig per role, and when it runs one, src/orchestrator.py assembles a subprocess:

cmd = [
    "agent-runner",
    "--session-id", session_id,
    "--system-prompt", agent.system_prompt,
    "--allowedTools", ",".join(agent.allowed_tools),
    "--permission-mode", agent.permission_mode,
    "--model", agent.model,
    "--output-format", "json",
    "-p", full_prompt,
]

Everything the agent may do is decided on the --allowedTools line, in a comma-separated string. The system prompt above it is instruction, not authorization; an agent that ignores its prompt still cannot call a tool absent from --allowedTools, and an agent that follows its prompt perfectly still holds every capability the flag granted.

The three roles I shipped read as a sensible separation of duties. python-dev holds Read, Write, Edit, Glob, Grep, Bash and gets use_worktree=True. python-qa and python-security hold Read, Glob, Grep, Bash and get use_worktree=False, each carrying a comment saying it reads from the main repository. Written out as roles, the reviewers are read-only and the developer is confined. Written out as capabilities, both reviewers can write anything anywhere in the checkout, and the developer cannot reach outside its worktree.

One role in the codebase is what its comment claims. The audit pod declares allowed_tools=["Read"] # Read-only access, and that grant is genuinely read-only, because no entry can produce a write. The difference is not care taken; it is that Bash was not on the list. A tool list is a capability grant, and a single entry can dominate every other entry in it.

Every enforcement point I built failed open

The backstop for all of this was a PreToolUse hook, which the platform runs before each tool call and which blocks the call by exiting with status 2. Mine validated file paths against a sandbox:

try:
    from src.security.sandbox import get_workspace_sandbox, SandboxViolationError
except ImportError:
    # If security module not available, allow (fail-open for import errors only)
    return True, ""

That fallback is the only path this function can take from a clean checkout, because no commit in the repository's history has ever contained a src/security/ directory. The module did exist on disk once: an audit log entry records a Read of src/security/sandbox.py, and that line is the only surviving evidence the import target was ever real. Later the same evening the first-generation tree was committed under archive/src-v1/, where the only copy in version control still sits, and fifty minutes after that a refactor put this function into the shape above and hoisted the fail-open branch to the top of it.

Running that import in the repository today returns ImportError: No module named 'src.security', so the sandbox check has never once evaluated a path. It is a well-commented, deliberately reasoned decision to allow everything, and the comment explaining the reasoning is what stopped me reading it again.

That was not the only one. In src/orchestrator.py, worktree creation is wrapped so any exception logs an error and sets cwd = self.project_root, so the one role holding Write, Edit and Bash is the one whose isolation quietly degrades to the shared checkout. And an early build of the peer review flow, now archived at archive/src-v1/agents/workflows.py, contains this:

if not qa_agent:
    # No QA agent available, auto-approve with warning
    return ReviewFeedback(
        result=ReviewResult.APPROVED,
        comments=["No QA agent configured, auto-approved"],
        reviewer="system",
    )

Three controls, three defaults to allow, each written deliberately and each reasonable in isolation. Two are still in src/; the third was archived when the tree was rewritten, which is luck rather than a fix. None of the three failed to run. Each executed, took its input, and returned allow, which is the harder version to catch: non-execution leaves a hole in a count somewhere, while a control that executes and permits produces a result identical to a real pass. What I changed is a rule on my own build checklist: every enforcement point now ships with an input it must reject, and that rejection runs on every build.

374 passing tests, none asserting a denial

The reason I did not notice any of this is the part worth dwelling on, because the tests existed. The commit message for build 0.0.0.32 records 318 passing tests including 48 hook unit tests, 42 hook integration tests and 85 permission tests; build 0.0.0.33 the next day records 374. Grepping the whole tests/ tree for validate_file_path returns nothing. Grepping it for sandbox returns nothing.

What the hook tests do assert is visible in the first test of the integration class in tests/hooks/test_pre_tool_use.py:

def test_allows_valid_read(self, run_hook, hook_input, project_root):
    """Read tool with valid path should be allowed."""

It writes a file inside the project, sends the hook a Read for it, and asserts exit code 0. That assertion holds when path validation works. It holds identically when path validation has been reduced to an unconditional allow. Every path-related test in that file asserts the allow direction, and no test anywhere hands the hook a path outside the sandbox and requires exit 2.

The manual validation script I wrote alongside them goes further. scripts/validate_hooks.sh sends the hook a Read for /tmp/test.py, a path outside the project entirely, and prints pass "Allows Read tool" on exit 0 and fail "Blocked Read tool" on anything else. If sandbox validation ever started working, that check would report a failure. I wrote a test that breaks when the control comes back.

The command blocklist in the same hook is the one part with a real denial test. test_blocks_dangerous_bash sends rm -rf / and asserts exit code 2 with a reason in stderr. That is the entire difference between the part of the hook that still works and the part that does not. A permission test that only proves the permitted case passes is a functionality test wearing a security label.

The audit trail records only what was allowed

Counting the lines in the agent audit directory gives 291 entries across three consecutive days, and every one of them carries "event_type": "tool_use". There is no other event type in the schema. The logger runs on the post-tool hook, and the platform's own hook documentation is explicit that the pre-tool hook is the one that can block a call, while the post-tool hook fires only after the tool has already run. A denied action therefore never reaches the log. What I built and called an audit trail is a record of what the agent was permitted to do, containing no evidence of anything it was stopped from doing, which is the half an investigator wants first.

The middle day's file is worse in a more ordinary way. Of its 166 entries, 58 carry the session identifier test-session-123, six carry test-session-456, and 23 carry unknown, because the test suite writes into the same daily file as real sessions with no separation. Each entry also stores tool_input_keys rather than values, and truncates command_preview at 100 characters. I can tell you that a Bash call happened. I cannot always tell you what it ran.

Tool lists were never meant to be authorization

The strongest objection to everything above is that it is a category error. Tool permissions in an agent runner were never designed to be an authorization system; they are an affordance for a human supervising a process on their own machine, and demanding the properties of an identity system from a comma-separated command-line flag is asking the wrong component for a guarantee it never offered. On that reading the real control is the boundary the agent executes inside, and it is the container, the service account and the scoped token that should carry the privilege. Harden the blast radius and stop auditing the tool list.

That is right about where durable enforcement belongs, and it is why the worktree existed and why the deferred policy engine was designed to evaluate outside the agent. Where the objection breaks is expressiveness. A container boundary is identical for every role that runs in it, so it cannot represent the distinction I actually needed, which is that the security reviewer may read the repository and may not push it. That distinction exists only at the tool boundary.

Both layers are required, and the tool boundary is the one carrying role. The flag was never the weak part of my design. The weak part was that I never tested it in the direction of denial.

What transfers and what does not

The rule I take from this build is that an agent's permissions are the union of what its tools can reach, so you enumerate a grant by asking what each tool can produce rather than what the role is named, and you test every enforcement point with an input it must refuse. A control that fails open is indistinguishable from a control that works, and the only thing that tells them apart is a test that expects a denial.

That transfers to any system where a tool list is the grant. It does not transfer where every action an agent takes traverses an interface you control end to end, because there the network boundary already carries the role and the tool list is advisory. It also has an untested edge: this was a single-operator system, and the multi-user role hierarchy sitting in RBAC_ARCHITECTURE.md is marked deferred because I have not run it that way yet. OWASP has carried Excessive Agency in its Top 10 for Large Language Model Applications since 2023, when it ranked LLM08, and the 2025 edition lists it as LLM06 and splits it into excessive functionality, excessive permissions and excessive autonomy. My build managed all three, in a repository where I had written the access model down first.