Study guides / CCDV-F / Domain 6

Security & Safety · Lesson 2 of 3

6.2 - Tool Permissions: Least Privilege, Allowlists and Denylists

Scope what an agent is allowed to do as tightly as the task allows, and know the difference between an allowlist and a denylist approach.

The principle of least privilege applies to an agent's tools exactly as it applies to a human's system access: grant only what the current task actually needs, not broad access that happens to be convenient to set up once. A code-review agent doesn't need a delete-file tool; a read-only research agent doesn't need write access to anything; a customer-support agent that only ever needs to look up order status doesn't need a tool that can issue arbitrary refunds. Every tool you give an agent is a capability that a bug, a misjudgment, or an injected instruction (Lesson 6.1) could trigger — so the safest tool set is the smallest one that still lets the agent do its actual job.

Allowlist vs. denylist

An allowlist (only these specific actions are permitted, everything else is blocked by default) is the safer default for anything with real consequences — it fails closed. A denylist (everything is permitted except these specific blocked actions) fails open: anything you didn't think to list is allowed, including an action nobody anticipated at design time. Denylists are easier to write initially and easy to get dangerously wrong for exactly that reason — they require you to have already imagined every bad outcome in advance, which is a much harder bar than it sounds. New tools, new capabilities added by an MCP server update, or an unexpected combination of two individually-safe actions can all slip through a denylist that was correct on the day it was written.

This isn't a purely theoretical distinction — it maps directly onto how real systems configure agent permissions. Claude Code's own permission system, for instance, supports rules in three tiers: allow (runs without prompting), ask (prompts the user before running), and deny (blocked outright) — and the safe default for a new, unscoped tool is to fall into ask or deny, not allow, until someone has deliberately decided it belongs on the allowlist.

Key concept

When you're unsure which approach fits, ask what happens with an action nobody thought to list: an allowlist blocks it by default; a denylist permits it by default. For anything with real consequences, failing closed is usually the safer default.

Scoping beyond the tool name: parameters, credentials, and blast radius

Least privilege doesn't stop at "does this agent have the run_sql tool or not." A tool itself can be over-scoped even when its presence is justified:

Static scoping vs. task-appropriate, session-scoped access

The tightest scoping isn't necessarily fixed for the lifetime of a deployment. A well-designed system can grant a broader tool set only for the duration of a specific session or task, and revoke it afterward — e.g. an agent doing a one-off data migration is temporarily granted write access to a specific table, then that access is removed once the migration completes, rather than left standing indefinitely "in case it's needed again." Static, permanent over-provisioning is a common real-world failure mode: access granted for a past task that nobody remembered to revoke becomes exactly the kind of unscoped capability an injection or a bug can later exploit, long after the original justification is gone.

Common exam distractor

Watch for answers that treat a tool's description or an instruction to the model ("only use this tool for read operations") as if it were an enforcement mechanism. A description tells the model what the tool is for; it does not restrict what the tool is technically capable of if called with different parameters, and it provides no protection against an injected instruction that persuades the model to call it differently. Enforcement lives in the tool's actual implementation, its underlying credential scope, and the allow/deny boundary around it - not in prose.

Exam traps

Practice question

A team is scoping tool access for an agent that manages production infrastructure. They're deciding between listing specifically forbidden commands (a denylist) versus listing specifically permitted commands (an allowlist). Which is the safer default, and why?

  • A A denylist, because it's shorter and easier to maintain as new tools are added.

    Ease of maintenance doesn't offset the core risk: a denylist permits anything unlisted by default, including consequential actions nobody thought to forbid.

  • B An allowlist, because it fails closed - anything not explicitly permitted is blocked by default, including actions nobody anticipated. Correct

    For high-consequence infrastructure access, failing closed is the safer structural default - an allowlist doesn't depend on anticipating every possible dangerous action in advance.

  • C Neither matters, since the model's own judgment is the real safeguard either way.

    Relying on model judgment alone, without a structural permission boundary, is exactly the gap least-privilege scoping is meant to close.

  • D A denylist, because allowlists prevent the agent from ever being useful.

    An allowlist scoped to the actual task's real needs doesn't prevent usefulness - it prevents unanticipated, unscoped actions, which is the point.

Build exercise: Write and enforce an allowlist for a scoped agent

Beginner · 25 minutes

You'll practice:

  1. For an agent with file-system access, write an explicit allowlist of exactly the operations it needs (e.g. read within one directory, write to a specific output path) rather than a list of forbidden paths.

    Practicing the allowlist framing, rather than defaulting to the more familiar denylist instinct, is the actual skill - and writing it down concretely forces you to notice how much narrower the real requirement is than 'file-system access' sounds.

    You should see: A short, specific list where anything not on it - including operations you didn't think to consider - is blocked by default.

    Hints
    1. If you had to name the exact three or four operations this agent performs today, not everything the filesystem could theoretically support, what would that list look like?
    2. Write the allowlist as a small set of (operation, path-scope) pairs - e.g. read anywhere under ./reports, write only to ./output/summary.md - rather than a single blanket 'file access: yes/no' flag.
    3. ALLOWED_OPERATIONS = [
          {"op": "read", "path_prefix": "./reports/"},
          {"op": "write", "path_prefix": "./output/summary.md"},
      ]
  2. Implement a Python function that enforces this allowlist programmatically - given a requested (operation, path) pair, it returns allowed or denied, defaulting to denied for anything not explicitly matched.

    The exam and real security reviews distinguish a written policy from an enforced one - an allowlist that exists only as a comment or a prompt instruction isn't actually a permission boundary.

    You should see: A function that returns False for any operation/path combination not explicitly present in ALLOWED_OPERATIONS, including plausible-looking near-misses like a path just outside the allowed prefix.

    Hints
    1. What should the function return if it doesn't recognize the operation or path at all - and does your loop have an implicit 'else: allow' anywhere?
    2. Iterate the allowlist checking op equality and whether the requested path starts with the allowed prefix; return True only on a match found, and explicitly return False once the loop completes with no match - never default to True.
    3. def is_allowed(op: str, path: str) -> bool:
          for rule in ALLOWED_OPERATIONS:
              if rule["op"] == op and path.startswith(rule["path_prefix"]):
                  return True
          return False  # fail closed: no match means denied
      
      assert is_allowed("read", "./reports/q1.csv") is True
      assert is_allowed("write", "./reports/q1.csv") is False  # write not allowed here
      assert is_allowed("delete", "./output/summary.md") is False  # delete never allowed
  3. Wire this enforcement function into a tool-call handler in an agent loop, so that a tool_use block requesting a disallowed operation is rejected before any actual filesystem call is made, and a tool_result explaining the denial is returned to the model instead.

    This is where a written allowlist becomes a real control - enforcement has to sit between the model's decision to call a tool and the tool's actual execution, not inside the model's own judgment.

    You should see: A tool handler that checks is_allowed() before touching the filesystem, and returns a clear denial message as the tool_result content when the check fails, letting the agent loop continue rather than crash.

    Hints
    1. Where in the tool-execution step of your agent loop does the actual side-effecting call happen - and can you insert a check immediately before that line?
    2. In the function that executes a tool_use block, extract the operation and path from toolUse.input, call is_allowed first, and short-circuit with a denial tool_result if it fails, only falling through to the real file operation if allowed.
    3. def execute_file_tool(tool_use):
          op = tool_use.input["operation"]
          path = tool_use.input["path"]
          if not is_allowed(op, path):
              return {
                  "type": "tool_result",
                  "tool_use_id": tool_use.id,
                  "content": f"Denied: {op} on {path} is outside this agent's allowlist.",
                  "is_error": True,
              }
          # only reached if allowed - perform the real read/write here
          return perform_file_operation(op, path)
  4. Write three test cases: one clearly-allowed call, one clearly-denied call, and one edge case (a path that looks similar to an allowed prefix but isn't actually inside it, e.g. './reports_backup/' vs './reports/') - and confirm the edge case is correctly denied.

    Prefix-matching bugs are a realistic way an allowlist silently becomes more permissive than intended - testing the near-miss case is what actually validates the boundary, not just the obvious cases.

    You should see: All three assertions pass, with the edge-case path correctly rejected because it doesn't share the exact allowed prefix, even though it looks superficially similar.

    Hints
    1. What's a path string that would pass a naive substring check but should fail a correct prefix check?
    2. './reports_backup/file.csv' starts with './reports' as a raw substring but is not actually inside './reports/' as a directory - make sure your prefix includes the trailing slash so this distinction is enforced correctly.
    3. assert is_allowed("read", "./reports/q1.csv") is True
      assert is_allowed("write", "/etc/passwd") is False
      assert is_allowed("read", "./reports_backup/q1.csv") is False  # near-miss prefix, must fail

Sources