All lessons › Function B — Securing AI Architectures › Securing the Architecture — Runtime and the Gateway
B3.1

Default-deny on the tool call

B3

This starts chapter B3. Its course guide is B3 in the Function B course guide →

▶
Lightboard recording

Not recorded yet. The lesson below is complete and runnable today — the video is added later without touching this page.

Video placeholderpublishes automatically from recordings/B3.1.mp4
Risk

Allow-by-default authorization is defeated by any argument the model can be persuaded to produce.

Control

Policy evaluated per call on (identity, tool, arguments, resource), denying unless a rule permits.

The lab

Evaluate the same call under allow-by-default and deny-by-default policy and compare what gets through.

◉Use case relevance

Identity has already failed. Something untrusted is in the context and the agent has decided to call a tool. The tool call is the last place a decision can still be made on facts rather than intent — this SPIFFE ID, this tool, this resource, this verb — and a policy written one notch vaguer than that cannot express the distinction the attack turns on.

At CyberTravels. config.TOOL_POLICY has answered yes or no since A1.4 and has never said why. You add cybertravels/policy.py: one decide() per call, returning a record with a reason and a list of obligations. The default branch is a denial, so delete_everything — a tool nobody classified — is refused rather than falling through.

◴What this lesson is — Day 0, Day 1, Day 2

Replacing a lookup table that answers yes or no with a decision that answers why: one call per tool, evaluated on identity, tool and arguments, returning a reason and any obligations. Including what default-deny actually means — the default branch is a denial, not merely that the list is an allow-list with a permissive fallback.

Day 0 — why

Allow-by-default authorization is defeated by any argument the model can be persuaded to produce.

Day 1 — how

Evaluate policy per call on identity, tool, arguments and resource, denying unless a rule permits.

Day 2 — measure

Calls denied by policy, and the share of tools reachable with no matching rule at all.

Measure this

what to measuredenominator or target
calls denied by policyshare of calls
tools reachable with no matching rule at allshare of tools

▦The framework, and how it works

   untrusted text in context ---> agent decides to call a tool
                                             |
                                    +--------v---------+
                                    |  policy decision |
                                    |  DEFAULT: DENY   |
                                    +--------+---------+
                                             |
                        allow only on facts: identity, scope,
                        resource, provenance of the motivating span

   the last point where a decision rests on facts rather than on intent

Mitigates: T2 Tool Misuse · T3 Privilege Compromise · T6 Intent Breaking.

The tool call is the moment text becomes consequence. It is also the last point where a decision can be made on facts — this identity, this tool, these arguments, this resource — rather than on intent, which nobody can read.

Start from the identity, and be very specific about it. Not "the Workflow Agent"; the SPIFFE ID B2.3 issued — spiffe://cybertravels.com/ns/prod/sa/workflow-agent — and against it, an entitlement written at full resolution: which tools, on which resources, with which verbs. Everything else in this lesson is a consequence of writing the entitlement down at that resolution. A policy phrased one notch vaguer cannot express the distinction the attack turns on, and the vagueness is invisible until it is exploited.

Default-deny then means the absence of a rule is a refusal. That sounds like a detail and it is the entire control, because it changes what a mistake costs. Under allow-by-default, a permission somebody forgot to restrict is available to an attacker. Under deny-by-default, a permission somebody forgot to grant is a broken feature — which someone reports the next working day, loudly, and which harms nobody.

The policy takes four inputs and all four matter:

  • identity — the attested workload identity, from B2.3
  • tool — which capability
  • arguments — the actual values, not the schema
  • resource — which specific thing

Dropping the fourth is the most common weakening. run_query permitted for the Workflow Agent is not the same as run_query permitted on the bookings table, and B1.5 was the difference between those two sentences. The verb is the second most common: charge_card entitled for payments is not refund entitled for payments, and R1 is the entire distance between them.

This does not stop the agent being persuaded. It stops persuasion mattering, which is a better place to stand.

What this control closes. Stands on the edge every topology shares: agent_runtime -> tools. Persuasion still happens; it just stops reaching anything.

2 · The entitlement, at full resolution

3 · The same five calls, evaluated both ways

4 · Proving the baseline is default-deny, as a skill

Writing the entitlement is one job; showing that CyberTravels' running role is the entitlement is another, and it is the one an auditor asks for. The procedure reads every inline and attached policy for the baseline, then measures granted-but-unused permissions against what the audit trail observed — which only means anything if the trail is intact, so incomplete coverage is a finding rather than a clean pass. This is the file in this repository:

▶Real time execution as skill

The skill — skills/attestation/iam-least-privilege-verifier/SKILL.md

name: iam-least-privilege-verifier
description: >-
  Prove a deployment's role baseline is default-deny and quantify granted-
  but-unused permissions against observed usage. Use to evidence least
  privilege, to find excess permissions on an agent role, or when asked
  whether a deployment's IAM posture supports a default-deny claim.
allowed-tools: Bash, Read

Iam Least Privilege Verifier

Controls: Control 1 — default-deny and least privilege

Confidence: HIGH

This is one of the controls that is genuinely provable at runtime. Policy documents are readable, and usage data turns "least privilege" from an assertion into a measured delta.

When to use this

Whenever a default-deny or least-privilege claim is about to be made, and again whenever the role changes. It needs observed usage as well as the policy, so it runs against a deployment that has been live long enough to have a usage window — a fresh deployment shows every permission as unused and the result means nothing.

Procedure

  1. Establish the default-deny baseline. Read every inline and attached policy. The baseline fails if any of these are present:
  • Action: "" or Resource: "" in an Allow statement
  • broad managed policies such as administrator or power-user equivalents
  • a wildcard principal on a trust policy
  1. Measure excess. Compare granted permissions against observed usage from the access-analysis and last-accessed data. Count actions, roles, keys and passwords idle for at least the tracking period (configurable 1–180 days; default 90).
  1. Generate the least-privilege diff. Policy generation derived from actual activity produces a candidate policy; the diff against what is granted is the excess-permission finding, expressed concretely rather than as a score.
  1. Check external access. External-access findings must be zero, or each one must map to an approved exception.

Example

Input — the fixture committed at the top of scripts/iam_least_privilege_verifier.py. Edit it and re-run: the buckets, counts and verdicts below are derived from it, not hard-coded.

Output — the opening lines of a real run:

   charge_card payments:booking          ['CHARGE']
   run_query   table:bookings            ['SELECT', 'UPDATE']
   send_email  domain:cybertravels.com   ['*']
allow-by-default:
   run_query   table:bookings                SELECT ALLOW run_query on table:bookings permits SELECT
   run_query   table:customer_pii            SELECT ALLOW allowed by default
   charge_card payments:booking              REFUND deny  REFUND not permitted on payments:booking (only ['CHARGE'])
   send_email  domain:archive.evil.example   *      ALLOW allowed by default

The run continues past this. test_skills.py executes the script on every build, with no model configured — so what CI proves is that it runs and refuses legibly, not that it prints these lines. The output above is a recorded run against a real model, and nothing re-diffs it: a model's answer is not reproducible, and this repository does not claim otherwise anywhere it can be checked.

Output contract

{
  "deployment_id": "str",
  "role_arn": "str",
  "default_deny_verified": true,
  "wildcard_findings": [{"policy": "str", "statement": "str"}],
  "excess_permission_count": 0,
  "unused": [{"type": "action|role|key|password", "name": "str", "idle_days": 0}],
  "generated_policy_diff": "str",
  "external_access_findings": 0,
  "tracking_period_days": 90,
  "verdict": "PASS|PARTIAL|FAIL"
}

Failure modes

  • Incomplete audit-trail coverage. If the trail is not intact, "unused" is unreliable and the verdict must be PARTIAL. Policy generation can miss legitimately-used-but-rare actions — an annual disaster-recovery permission looks identical to dead permission over a 90-day window.
  • Counting managed-policy names instead of effective actions. Two policies can grant the same action; the union is what matters.
  • Treating a low excess count as a pass while a wildcard is present. The baseline check is a gate, not a contributor to a score.

Three ways to do this lesson. Pick one. They run the same procedure against the same code — what changes is who drives it, and how much you see.

1In your AI agent — you are taught

The agent reads the lesson, explains the idea, fetches the code, runs the check and answers your questions as you go. This is the one to take if you are here to learn.

Install it, once. From the folder you cloned. --all does every agent it finds on your machine:

python3 scripts/install_skills.py --all --lessons B3.1

Then restart your agent and open it in that folder. On Windows, if python3 says Python was not found, use python instead.

Then pick b3-1-default-deny-on-the-tool-call:

agentinstall just for this agentstart it
Claude Codepython3 scripts/install_skills.py --tool claude --lessons B3.1type /b3-1-default-deny-on-the-tool-call
GitHub Copilot (VS Code)python3 scripts/install_skills.py --tool copilot --lessons B3.1type /b3-1-default-deny-on-the-tool-call in Chat
Cursorpython3 scripts/install_skills.py --tool cursor --lessons B3.1type / in Agent chat and search for it
Codexpython3 scripts/install_skills.py --tool agents --lessons B3.1type $b3-1-default-deny-on-the-tool-call, or run /skills

2As one Python command — you just run it

No agent, no conversation. The same lesson executes and prints the same readback. Take this one if you have no assistant, if you are reviewing rather than learning, or if you want the result without the teaching around it.

python3 scripts/lesson.py B3.1

3Read the code, and the difference — nothing runs

Neither of the above, and no model needed. The first command writes CyberTravels exactly as it stood at the end of this lesson; the second prints the diff — the lines this lesson added or changed, and nothing from any later one.

python3 scripts/checkpoint.py --at B3.1 --out work/cybertravels
python3 scripts/checkpoint.py --at B3.1 --diff

On Windows, type python wherever these say python3 — and use \ instead of / in the paths. Everything else is identical. A0.0 step 2 explains why.

Ways 1 and 2 need a model; way 3 does not. A signed-in Claude Code CLI needs no API key, and any OpenAI-compatible endpoint works too. With neither, the audit exits 2 and says so rather than inventing an answer. A0.0 sets this up.

Ways 1 and 2 both end with the same readback: What I did, What changed, The number and Read this next. A step that was skipped or refused is reported as exactly that, never as a pass.

If you did the last lesson’s challenge, expect this to stop. Most challenges ask you to edit the code, and ways 1 and 2 will not overwrite your edits: you get stopped, not a failure, with two options — --out for a fresh folder that keeps your work, or --force to discard it and start this lesson clean.

✓What you just proved

Five tool calls are evaluated twice. Under allow-by-default four succeed, each one a Chapter B1 risk walking through. Under default-deny only the intended call survives — including a refusal on the verb for an otherwise-permitted identity, tool and resource. The skill also reports its shape. Two of its failure modes are the ones this lesson is about: counting managed-policy names instead of effective actions, and reading a low excess count as a pass while a wildcard sits in the policy — a wildcard is not a large number of permissions, it is an unbounded one.

✎Your turn

Take one tool policy you have and check whether it names the resource and the verb. If it grants run_query rather than SELECT on these tables, it cannot express the difference that B1.5 and R1 both turn on.

Tools used

OPA / RegoSPIFFE/SPIRE

Skill source: skills/attestation/iam-least-privilege-verifier

← PreviousB2.8 · An audit trail the workload cannot forge