This starts chapter B3. Its course guide is B3 in the Function B course guide →
Not recorded yet. The lesson below is complete and runnable today — the video is added later without touching this page.
recordings/B3.1.mp4Allow-by-default authorization is defeated by any argument the model can be persuaded to produce.
Policy evaluated per call on (identity, tool, arguments, resource), denying unless a rule permits.
Evaluate the same call under allow-by-default and deny-by-default policy and compare what gets through.
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.
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.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.
Allow-by-default authorization is defeated by any argument the model can be persuaded to produce.
Evaluate policy per call on identity, tool, arguments and resource, denying unless a rule permits.
Calls denied by policy, and the share of tools reachable with no matching rule at all.
Measure this
| what to measure | denominator or target |
|---|---|
| calls denied by policy | share of calls |
| tools reachable with no matching rule at all | share of tools |
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 intentMitigates: 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:
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.
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:
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, ReadControls: Control 1 — default-deny and least privilege
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.
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.
Action: "" or Resource: "" in an Allow statementInput — 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.
{
"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"
}
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.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.
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.1Then 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:
| agent | install just for this agent | start it |
|---|---|---|
| Claude Code | python3 scripts/install_skills.py --tool claude --lessons B3.1 | type /b3-1-default-deny-on-the-tool-call |
| GitHub Copilot (VS Code) | python3 scripts/install_skills.py --tool copilot --lessons B3.1 | type /b3-1-default-deny-on-the-tool-call in Chat |
| Cursor | python3 scripts/install_skills.py --tool cursor --lessons B3.1 | type / in Agent chat and search for it |
| Codex | python3 scripts/install_skills.py --tool agents --lessons B3.1 | type $b3-1-default-deny-on-the-tool-call, or run /skills |
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.1Neither 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 --diffOn 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.
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.
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
Skill source: skills/attestation/iam-least-privilege-verifier