assurance · Level 4
Threat-model an agent and its tool boundary
Turn a trust-boundary sketch into precise permissions, tested refusals and a useful handover.

Start with the essentials
The short answer
An agent threat model follows how untrusted content can become a consequential action. Identify the assets, caller identity, data flows and enforcement points, then write testable refusal rules. This course builds a harmless in-memory Python gateway: model proposals cannot choose their identity or approve themselves, and a reviewed write is bound to its exact content, owner, revision and time window. The tests demonstrate a local contract, not security certification.
What you will learn
- Draw an agent data flow with explicit trust boundaries.
- Distinguish authentication, authorisation and approval.
- Convert plausible abuse stories into concrete refusal rules.
- Bind an approval to the exact operation and current resource revision.
- Run negative tests and detect deliberately weakened checks.
- Write a release record with owners, evidence and unresolved risks.
Who it is for
Builders who understand an agent loop and want to review the permissions around its tools.
Before you start
- Read Python functions, dictionaries, classes and unit tests.
- Understand an agent loop and why retrieved instructions can be untrusted.
- Run Python 3.12 in a local folder containing only fictional teaching data.
Read a sample · Chapter 01 of 06
Draw what the agent can actually reach
Begin with assets and data flows before choosing controls.
Our fictional service helps Orchard staff revise draft notes. A model can propose reading a note or replacing its text. Harbour has separate notes. The protected assets are note confidentiality, note integrity and the ability to explain who authorised a change. A helpful answer is not the only outcome to consider: the same tool route could disclose another organisation's draft or overwrite a recently reviewed version.
The lab has three files: gateway.py, demo.py and test_gateway.py. All are printed in full below. When a file spans blocks, join them in order with one blank line and preserve indentation. Run them from one fresh local folder. They use only the Python standard library, were executed with Python 3.12.10 on Windows, and make no model, filesystem-tool or network call. This is a simulator for the boundary, not a deployed agent.
Draw these flows on paper: a document reaches the model; the model returns a JSON proposal; trusted host code supplies the signed-in actor to a gateway; the gateway checks its note store and, for writes, a separate approval record. The response returns to the host. Add a distinct reviewer path that can create approvals. It must not be exposed among the model's callable tools. The lab represents that trusted path with a direct Python call.
Scroll sideways to see every column.
| Component | Trust decision |
|---|---|
| Document and model proposal | Untrusted content may suggest actions; it supplies no authority. |
| Host-supplied actor and time | Trusted inputs assumed by this lab; supplied outside proposal JSON. |
| Gateway and note store | Enforce the policy on every operation and retain state. |
| Reviewer path | Approves exact proposed writes after a separate review. |
OWASP describes threat modelling as a repeatable design and validation activity. For this exercise, put a boundary wherever data gains a different kind of authority: document to model, model to gateway, and reviewer decision to execution. A line on the drawing is useful only when you can name the code or service that enforces it. If the model can directly edit the note store, drawing a gateway beside it changes nothing.
Authentication answers who the caller is. Authorisation decides what that caller may do to this object now. Approval records a deliberate decision about a particular operation. Those checks support one another, but do not replace one another. Our actor strings are supplied by trusted test code; accepting an actor field from the model would erase that distinction. A production host must obtain identity from its verified session or equivalent mechanism.
Try it yourself · Activity 01
15 minDraw the smallest useful model
Sketch the fictional note service and label the two paths into the gateway.
- Mark which inputs the model controls.
- Label where actor identity and approval originate.
- Draw an attempted direct path to the store and state why it must be unavailable.
Which additional boundary appears if a note is fetched from a remote search service?
Worked answer
The model controls proposal text. Trusted host code supplies actor and time. A separately authorised reviewer creates an approval record. Both normal execution and approval creation check object ownership. Direct store access by model-controlled code would bypass every gateway rule, so the deployment architecture must prevent it.
Read a sample · Chapter 02 of 06
Write abuse stories that lead to tests
Give each threat a specific precondition, action and observable consequence.
A useful abuse story names the entry point and the violated property. For example: a hostile sentence inside an Orchard document persuades the model to request harbour-brief; if the gateway trusts the requested identifier without checking ownership, Harbour's text is returned. The injection changes the proposal, but the harmful effect depends on excess authority at the tool boundary. This is why testing only whether a model refuses hostile prose leaves an important gap.
Use the fictional register below as a starting point. The owner is a role responsible for the control, not a fabricated named reviewer. Each acceptance test can be evaluated without contacting a real system. Classifying an issue as spoofing or tampering may help discussion, but the acceptance condition is what turns the description into work. Prioritise unauthorised disclosure and overwrite before cosmetic response quality because they violate the explicit service purpose.
Scroll sideways to see every column.
| Threat and owner | Control and evidence |
|---|---|
| Other-owner read; gateway owner | Check current note owner on every request; test_read_is_owner_scoped. |
| Model says approved=true; schema owner | Reject undeclared fields; test_schema_rejects_model_authority_and_unknown_actions. |
| Body changes after review; review-flow owner | Compare exact parsed proposal; test_changed_body_or_target_cannot_reuse_approval. |
| Old or reused approval; gateway owner | Check time and consume successful ticket; expiry and replay tests. |
| Two reviewed drafts overwrite each other; storage owner | Recheck current revision; test_two_approvals_cannot_overwrite_new_revision. |
| Requests exhaust memory; service operator | Per-input cap is present; rate limits and approval cleanup remain unresolved. |
OWASP's excessive-agency guidance draws attention to the functions, permissions and autonomy available to a model-backed system. Our tool surface is deliberately two operations on named notes. There is no shell, arbitrary path, email sender, purchase or unrestricted database query. Removing an unnecessary effect can be stronger than trying to filter every sentence that might request it. If the product later needs a new tool, extend the model and tests before enabling it.
Separate a threat from a missing implementation detail. Expiry enforcement is implemented here. Authentication, a trustworthy approval screen, rate limiting and durable audit are not. Record them as unresolved conditions rather than describing the whole service as secure. The register can contain acceptance criteria for future controls, but a planned test is not a passed test. Keep source revision, test name and observed result beside any closure decision.
Do not invent precise likelihood percentages for a fictional service. Write the assumption instead: an attacker can influence document text, an authorised user can submit proposals, and host code remains trusted. A compromised Python process falls outside this lab because it can edit the public dictionaries or call approve directly. That is a crucial scope boundary, not a weakness that a frozen dataclass repairs.
Try it yourself · Activity 02
15 minTurn a vague concern into a refusal rule
Rewrite “the agent might do something unsafe” as a testable changed-approval threat.
- Name a reviewed body and a different submitted body.
- Keep actor, target and ticket constant.
- State the required result and which state must remain unchanged.
What should a reviewer see to notice that a target or body has changed?
Worked answer
Approve Reviewed draft for orchard-brief at revision 1. Submit Changed after review using the same actor and ticket. Execution must raise Denied; note text, revision, approval store and approval counter must remain unchanged. The original ticket is not consumed by the refused attempt.
Read a sample · Chapter 03 of 06
Define a small gateway contract
Validate structure before checking who may act.
Save gateway.py from the following consecutive blocks. The parser accepts a Python string containing one JSON object, at most 2,048 UTF-8 bytes. Read has exactly action and note fields. Replace adds exactly revision and text. There are no identity, permission or approved fields. Unknown actions and extra fields fail. The note ID grammar admits a small lowercase identifier; it is not interpreted as a filesystem path.
"""In-memory teaching gateway. Trusted host code owns identity and approval."""
from dataclasses import dataclass
import json
import re
MAX_INPUT = 2048
MAX_TEXT = 512
MAX_REVISION = 1_000_000
class Denied(ValueError):
pass
def unique_object(pairs):
result = {}
for key, value in pairs:
if key in result:
raise Denied("duplicate key")
result[key] = value
return result
def reject_constant(value):
raise Denied("non-JSON number")@dataclass(frozen=True)
class Proposal:
action: str
note: str
revision: int | None = None
text: str | None = Nonedef parse(raw):
try:
if type(raw) is not str or len(raw.encode("utf-8")) > MAX_INPUT:
raise Denied("input size or type")
data = json.loads(raw, object_pairs_hook=unique_object,
parse_constant=reject_constant)
except (ValueError, RecursionError) as error:
raise Denied("invalid proposal") from error
if type(data) is not dict:
raise Denied("object required")
action = data.get("action")
if action == "read":
keys = {"action", "note"}
elif action == "replace":
keys = {"action", "note", "revision", "text"}
else:
raise Denied("unknown action")
if set(data) != keys:
raise Denied("unexpected fields")
note = data["note"]
if type(note) is not str or not re.fullmatch(r"[a-z][a-z0-9-]{0,31}", note):
raise Denied("invalid note ID")
if action == "read":
return Proposal(action, note)
revision, text = data["revision"], data["text"]
if type(revision) is not int or not 1 <= revision <= MAX_REVISION:
raise Denied("invalid revision")
try:
valid_text = type(text) is str and 1 <= len(text.encode("utf-8")) <= MAX_TEXT
except UnicodeError:
valid_text = False
if not valid_text:
raise Denied("invalid text")
return Proposal(action, note, revision, text)@dataclass(frozen=True)
class Note:
owner: str
text: str
revision: int = 1
@dataclass(frozen=True)
class Approval:
actor: str
proposal: Proposal
issued: int
expires: int
class Gateway:
def __init__(self):
self.notes = {
"orchard-brief": Note("orchard", "Draft A"),
"orchard-plan": Note("orchard", "Plan A"),
"harbour-brief": Note("harbour", "Draft B"),
}
self.approvals = {}
self.serial = 0 def owned(self, proposal, actor):
note = self.notes.get(proposal.note)
if note is None or note.owner != actor:
raise Denied("not permitted")
return note
def current(self, proposal, actor):
note = self.owned(proposal, actor)
if proposal.action != "replace" or proposal.revision != note.revision:
raise Denied("stale or non-write proposal")
if note.revision >= MAX_REVISION:
raise Denied("revision capacity reached")
return note
@staticmethod
def clock(now):
if type(now) is not int or now < 0:
raise Denied("invalid trusted time") def approve(self, raw, actor, now):
"""Trusted reviewer path only; never register this as an agent tool."""
self.clock(now)
proposal = parse(raw)
self.current(proposal, actor)
self.serial += 1
ticket = f"approval-{self.serial}"
self.approvals[ticket] = Approval(actor, proposal, now, now + 30)
return ticket
def execute(self, raw, actor, now, ticket=None):
self.clock(now)
proposal = parse(raw)
note = self.owned(proposal, actor)
if proposal.action == "read":
return {"text": note.text, "revision": note.revision}
approval = self.approvals.get(ticket) if type(ticket) is str else None
if (approval is None or approval.actor != actor
or approval.proposal != proposal
or not approval.issued <= now < approval.expires):
raise Denied("approval required")
note = self.current(proposal, actor)
self.notes[proposal.note] = Note(actor, proposal.text, note.revision + 1)
del self.approvals[ticket]
return {"revision": note.revision + 1}Python's JSON decoder normally permits repeated object names and non-standard constants such as NaN. The object_pairs_hook rejects duplicate keys, and parse_constant rejects those constants. Malformed JSON and excessive nesting become Denied. A JSON object that parses successfully still has to meet the exact field contract. Validation establishes the shape of a proposal; the owned method separately establishes permission for its target.
Replacement text must contain 1 to 512 UTF-8 bytes. The test uses the escaped character \u00e9, which encodes as two bytes: 256 repetitions fit, 257 do not. Revision must be an actual integer from 1 to 1,000,000; booleans are refused even though Python treats bool as an int subclass. A store at the maximum revision refuses another write. These are teaching limits, chosen to make boundary cases small and repeatable.
Proposal, Note and Approval are frozen dataclasses, and their fields here contain immutable values. Dataclass equality lets us compare the full parsed operation without depending on JSON spacing or key order. This is a local equality check, not a digital signature or a general canonical-JSON protocol. Frozen fields discourage accidental assignment; they do not isolate the Python process from hostile executable code.
Owned returns the same not permitted message for a missing note and another owner's note. That avoids directly disclosing existence through these two message variants; it is not a constant-time non-disclosure proof. A permitted read returns a new dictionary containing text and revision. Changing that returned dictionary does not edit the stored Note. The returned text remains untrusted content if it is later placed into a model prompt or rendered on a page.
The parser receives a string already present in memory. Its length check is not a network receive cap, total memory bound, timeout or rate limit. A real transport adapter must enforce its own request size and timing policy before passing a body to this function. The in-memory approval store also grows when trusted approvals are created but never used; there is no cleanup loop in the lab.
Try it yourself · Activity 03
20 minFind the authority-bearing fields
Trace a replace proposal containing an extra approved field through parse and execute.
- List the four permitted replace keys.
- Explain why JSON validity does not make the approved field legitimate.
- Compare that rejected field with the separately supplied actor.
Which trust assumption would fail if a web handler copied actor directly from the request body?
Worked answer
Only action, note, revision and text belong in replace JSON. Adding approved causes unexpected fields, even when its value is true. Actor is supplied separately by trusted host code; the gateway compares it with the current stored owner. There is no path from a model-controlled approved value to the approval store.
Read a sample · Chapter 04 of 06
Bind review to the action that executes
An approval is useful only while its exact conditions still hold.
Approve an exact action. Recheck before writing.
The model supplies JSON action details. The host supplies actor orchard
and an integer time. The separate trusted reviewer calls approve at time
100 for replace, target orchard-brief, expected
revision 1 and text Reviewed draft. The stored approval binds
all these parsed values and the actor, with issue time 100 and exclusive
expiry 130. Changing JSON whitespace or key order does not change the
parsed proposal. The comparison is not a cryptographic signature.
On the write path, the gateway validates host time, parses the strict schema and
checks current ownership. It then checks the approval's actor, exact proposal and
time window, followed by current revision, ownership again and revision capacity.
Only then does it replace the note and remove that approval. A model-supplied
actor field is refused with unexpected fields; it cannot
override the host's actor. The read path checks ownership but requires no write approval.
| Event | Outcome | Note revision | Approval record |
|---|---|---|---|
| Approve at 100 | approval issued | 1 | present |
| Changed body at 101 | approval required | 1 | present |
| Exact action at 129 | revision 2 | 2 | absent |
| Replay at 129 | approval required | 2 | absent |
The changed body is Unreviewed draft. Its refusal leaves notes,
approval records and the serial counter unchanged. The exact action at 129 changes
the note to Reviewed draft at revision 2 and consumes the approval.
Replaying the same action is refused without another write.
| Execution time | Outcome | Note revision | Approval record |
|---|---|---|---|
| 99 | approval required | 1 | present |
| 100 | revision 2 | 2 | absent |
| 129 | revision 2 | 2 | absent |
| 130 | approval required | 1 | present |
The valid interval is 100 <= now < 130. These four rows are independent,
not repeated uses of one consumed ticket. At 99 the approval is not yet valid; at
130 it has expired. Refused approvals remain stored in this lab; no cleanup policy
is implemented.
Two additional checks explain the recheck. Two separately approved revision-1
proposals are created at 100. After the first writes at 101, the second at 102 is
refused with stale or non-write proposal: the note stays at revision 2
and the second approval stays present. In a separate fixture, trusted host code
transfers the note to harbour after approval. Execution by orchard at
101 returns not permitted, with revision 1 and the approval unchanged.
This is a serial, in-memory teaching simulator. The reviewer method must never be registered as an agent tool. Host code is trusted; Python objects are not an isolation boundary against hostile executable code. Predictable ticket labels are not bearer credentials. The write and approval deletion are separate statements, not an atomic database transaction. Authentication integration, persistent audit, concurrent requests, restart recovery and external-effect delivery need their own design. No production security or browser acceptance is implied.
Approve parses the proposal, checks ownership and current revision, then stores the actor, complete Proposal and a 30-tick validity window. Its name is not a claim that a human has reviewed anything. The caller is responsible for obtaining a real, informed decision first. Never register approve as a model tool, automatically call it after a refusal, or let retrieved text supply its result. The demo invokes it explicitly to represent the trusted path.
The ticket label is a predictable local counter such as approval-1. It is not a secret bearer credential, authentication token or capability suitable for a network service. Merely guessing it does not satisfy actor and operation equality in this simulator. Production transport needs its own authenticated binding, appropriately protected identifiers, review state and rate limits. This course makes no recommendation to expose these labels to the internet.
Execution parses again and checks current ownership. For replace, it looks up the ticket, compares the actor and full Proposal, and requires issued <= now < expires. A ticket issued at 100 expires at 130: tick 129 is allowed, tick 130 is not. Tick 99 is also refused. Time is supplied by trusted code as an integer; the model cannot choose it. A real in-process elapsed-time policy can use a monotonic clock, with explicit handling for restarts.
After approval matches, current checks the live revision again. Two reviews of revision 1 do not authorise two successive overwrites. The first successful write stores revision 2 and consumes its ticket. The second proposal still names revision 1, so it is stale. If another trusted operation transferred the note to a different owner, ownership and actor binding also prevent the earlier ticket from following that transfer.
The state change and ticket deletion are consecutive statements in a serial simulator. They are not a database transaction and provide no concurrency or crash assurance. A real service needs an atomic conditional update, consumption of the approval and an appropriate audit record, plus a clear recovery policy. If the effect is an external send, local storage alone cannot promise exactly-once delivery. Do not attach a live side effect while leaving this gap implicit.
Save demo.py, then run python demo.py. Every printed value refers to fictional in-memory data. The unapproved write is refused, a distinct trusted approval enables one write, replay fails, and the foreign read is refused. The output is deliberately small enough to compare line by line.
import json
from gateway import Denied, Gateway
def main():
gateway = Gateway()
read = json.dumps({"action": "read", "note": "orchard-brief"})
write = json.dumps({"action": "replace", "note": "orchard-brief",
"revision": 1, "text": "Reviewed draft"})
print("before", gateway.execute(read, "orchard", 100))
try:
gateway.execute(write, "orchard", 100)
except Denied as error:
print("unapproved", error)
# This call represents a separate trusted review, not a model decision.
ticket = gateway.approve(write, "orchard", 100)
print("approved", gateway.execute(write, "orchard", 101, ticket))
try:
gateway.execute(write, "orchard", 102, ticket)
except Denied as error:
print("replay", error)
print("after", gateway.execute(read, "orchard", 103))
foreign = json.dumps({"action": "read", "note": "harbour-brief"})
try:
gateway.execute(foreign, "orchard", 104)
except Denied as error:
print("other owner", error)if __name__ == "__main__":
main()before {'text': 'Draft A', 'revision': 1}
unapproved approval required
approved {'revision': 2}
replay approval required
after {'text': 'Reviewed draft', 'revision': 2}
other owner not permittedOWASP's transaction-authorisation guidance emphasises tying a review to the significant operation details and checking it at execution. For this lab those details are actor, action, note ID, expected revision and exact text. Equivalent JSON formatting has no effect because parsed values are compared. Normalising or trimming the replacement text would change this contract; such a change needs a deliberate decision and corresponding tests.
Try it yourself · Activity 04
20 minTrace two competing reviews
Create two tickets for different replacement texts at revision 1, then execute the first.
- Record the new note revision and the first ticket's state.
- Try the second ticket with its original proposal.
- Explain the difference between a stale proposal and an expired proposal.
Where would a real database enforce the revision condition and ticket consumption together?
Worked answer
The first write stores revision 2 and removes its ticket. The second ticket may still be within its time window, but its proposal expects revision 1 and is refused without changing state. Staleness concerns the target's current version; expiry concerns the approval's time window. Both checks are necessary in this contract.
Read a sample · Chapter 05 of 06
Test refusals and challenge the evidence
A negative test should check the state as well as the error.
Save test_gateway.py. Sixteen tests cover reads, missing and foreign targets, exact schema, duplicate keys, numeric types, UTF-8 limits, unapproved writes, successful consumption, body and target changes, validity-window edges, actor and ownership changes, stale writes, the trusted approval path, equivalent JSON formatting, returned dictionaries and revision capacity. Run python -m unittest -v test_gateway.py. No additional package is needed.
import json
import unittest
from gateway import Denied, Gateway, MAX_INPUT, MAX_REVISION, Note, parse
def proposal(**changes):
data = {"action": "replace", "note": "orchard-brief",
"revision": 1, "text": "Reviewed draft"}
data.update(changes)
return json.dumps(data)
class BoundaryTests(unittest.TestCase):
def setUp(self):
self.g = Gateway()
def denied_without_change(self, raw, actor="orchard", now=101, ticket=None):
before = (dict(self.g.notes), dict(self.g.approvals), self.g.serial)
with self.assertRaises(Denied):
self.g.execute(raw, actor, now, ticket)
self.assertEqual(before, (self.g.notes, self.g.approvals, self.g.serial))
def test_read_is_owner_scoped(self):
raw = json.dumps({"action": "read", "note": "orchard-brief"})
self.assertEqual(self.g.execute(raw, "orchard", 100),
{"text": "Draft A", "revision": 1})
self.denied_without_change(raw, actor="harbour")
self.denied_without_change(raw, actor="unknown") def test_absent_and_foreign_have_same_error(self):
for target in ("missing", "harbour-brief"):
with self.assertRaisesRegex(Denied, "^not permitted$"):
self.g.execute(json.dumps({"action": "read", "note": target}),
"orchard", 100)
def test_schema_rejects_model_authority_and_unknown_actions(self):
for change in ({"approved": True}, {"actor": "harbour"},
{"action": "delete"}, {"note": "../secret"}):
self.denied_without_change(proposal(**change))
def test_json_duplicates_and_shapes(self):
for raw in ('{"action":"read","action":"replace"}', '[]', 'null',
'{', '{"value":NaN}', '{"value":Infinity}'):
self.denied_without_change(raw)
def test_revision_requires_bounded_integer_not_bool(self):
for value in (True, False, 0, -1, 1.0, "1", MAX_REVISION + 1):
self.denied_without_change(proposal(revision=value)) def test_input_and_utf8_text_bounds(self):
raw = '{"action":"read","note":"orchard-brief"}'
self.assertEqual(parse(raw + " " * (MAX_INPUT - len(raw))).action, "read")
for bad in (raw + " " * (MAX_INPUT + 1 - len(raw)), b"{}", "\ud800"):
self.denied_without_change(bad)
self.assertEqual(len(parse(proposal(text="\u00e9" * 256)).text), 256)
for text in ("", "\u00e9" * 257, "\ud800", None, 12):
self.denied_without_change(proposal(text=text))
def test_unapproved_write_is_inert(self):
self.denied_without_change(proposal())
self.denied_without_change(proposal(), ticket="approval-999")
def test_exact_approved_write_consumes_ticket(self):
raw = proposal()
ticket = self.g.approve(raw, "orchard", 100)
self.assertEqual(self.g.execute(raw, "orchard", 129, ticket), {"revision": 2})
self.assertEqual(self.g.notes["orchard-brief"].text, "Reviewed draft")
self.assertNotIn(ticket, self.g.approvals)
self.denied_without_change(raw, now=129, ticket=ticket)
def test_changed_body_or_target_cannot_reuse_approval(self):
ticket = self.g.approve(proposal(), "orchard", 100)
for change in ({"text": "Changed after review"}, {"note": "orchard-plan"}):
self.denied_without_change(proposal(**change), ticket=ticket)
self.assertIn(ticket, self.g.approvals) def test_expiry_is_exclusive(self):
ticket = self.g.approve(proposal(), "orchard", 100)
self.denied_without_change(proposal(), now=99, ticket=ticket)
self.denied_without_change(proposal(), now=130, ticket=ticket)
self.denied_without_change(proposal(), now=131, ticket=ticket)
def test_actor_binding_and_ownership_recheck(self):
ticket = self.g.approve(proposal(), "orchard", 100)
self.denied_without_change(proposal(), actor="harbour", ticket=ticket)
self.g.notes["orchard-brief"] = Note("harbour", "Transferred")
self.denied_without_change(proposal(), ticket=ticket)
self.denied_without_change(proposal(), actor="harbour", ticket=ticket)
def test_two_approvals_cannot_overwrite_new_revision(self):
first = self.g.approve(proposal(), "orchard", 100)
second = self.g.approve(proposal(text="Other draft"), "orchard", 100)
self.g.execute(proposal(), "orchard", 101, first)
self.denied_without_change(proposal(text="Other draft"), ticket=second)
def test_approval_path_rejects_unauthorised_and_nonwrite(self):
for raw, actor in ((proposal(), "harbour"), (proposal(revision=2), "orchard"),
('{"action":"read","note":"orchard-brief"}', "orchard")):
before = (dict(self.g.approvals), self.g.serial)
with self.assertRaises(Denied):
self.g.approve(raw, actor, 100)
self.assertEqual(before, (self.g.approvals, self.g.serial)) def test_json_formatting_does_not_change_approved_action(self):
raw = proposal()
ticket = self.g.approve(raw, "orchard", 100)
equivalent = json.dumps(json.loads(raw), indent=2, sort_keys=True)
self.assertEqual(self.g.execute(equivalent, "orchard", 101, ticket),
{"revision": 2})
def test_read_result_does_not_mutate_stored_note(self):
result = self.g.execute('{"action":"read","note":"orchard-brief"}',
"orchard", 100)
result["text"] = "Changed locally"
self.assertEqual(self.g.notes["orchard-brief"].text, "Draft A")
def test_revision_capacity_and_trusted_clock(self):
self.g.notes["orchard-brief"] = Note("orchard", "Last", MAX_REVISION)
with self.assertRaisesRegex(Denied, "capacity"):
self.g.approve(proposal(revision=MAX_REVISION), "orchard", 100)
for value in (True, -1, 1.5, "100"):
self.denied_without_change(proposal(), now=value)
if __name__ == "__main__":
unittest.main()Denied_without_change snapshots notes, approvals and serial before attempting an operation. It requires both a Denied exception and identical state afterwards. A function that raises after modifying a note would fail this check. Positive tests matter too: an always-deny gateway would avoid many harmful actions while failing its basic purpose. The approved-write test requires revision 2 and the expected text, while the read test requires the intended owner's data.
The release also tested four deliberately defective copies outside the learner folder. Each remained syntactically valid, but failed the tests: removing the owner comparison, removing full-proposal equality, removing the time-window condition, and removing the current-revision comparison. The originals were preserved. This establishes that the suite distinguishes these four faults; it does not estimate coverage of every possible attack or certify the architecture.
When extending the exercise, change one check at a time in a disposable copy and state which test should fail first. Record the diff, test result and restored source hash. Do not weaken a running service to demonstrate a defect. The published fixture is deliberately disconnected from external actions, so its consequences are limited to invented dictionary entries.
Keep test results separate from broad security claims. These tests prove observed outcomes for specific data in one serial Python process. They do not test real login sessions, cross-origin requests, a human approval screen, compromised dependencies, concurrent workers, persistent records, network retries or audit access control. Those scenarios need their own integration environment and owner before the service can claim them.
Try it yourself · Activity 05
20 minRemove one check and explain the failure
In a separate local copy, replace approval.proposal != proposal with False.
- Run the complete tests and locate the changed-body failure.
- Inspect the store state from the failing attempt.
- Restore the original file and rerun before proceeding.
What additional test would distinguish a target-binding defect from a body-binding defect?
Worked answer
The changed-body test fails because a ticket for Reviewed draft can now be used with Changed after review while actor and revision still match. The altered implementation accepts a different operation. Restoring full Proposal equality returns the suite to sixteen passing tests. This does not test whether a human reviewer understood the original body.
Read a sample · Chapter 06 of 06
Hand over a threat model that can be maintained
Record what is implemented, what was observed and what remains a condition of release.
A useful handover contains the system sketch, assets, explicit trusted assumptions, threat register, source revision, executable tests, observed outputs and unresolved controls with responsible roles. Keep them together so the next builder can connect a refusal rule to the code that enforces it. A screenshot of green tests without the tested revision is not enough to repeat the result.
For the fictional note service, the implemented claim is narrow: serial in-memory calls enforce the documented owner and approval rules for the tested proposal grammar. The unimplemented production conditions are equally concrete: authenticate the caller; protect the reviewer path; show exact action details; enforce receive limits and rate limits; make update, consumption and audit atomic where needed; persist state; handle restart and clock policy; and restrict the process from bypassing the gateway.
Scroll sideways to see every column.
| Release item | Example evidence or unresolved condition |
|---|---|
| Source and test identity | Hashes of gateway.py, demo.py and test_gateway.py; Python 3.12.10. |
| Observed behaviour | Sixteen passing tests; six exact demo lines; four defective copies detected. |
| Approval experience | Not implemented: reviewer identity, exact-body display and accessible decision controls. |
| Operational state | Not implemented: durable store, cleanup, concurrency, recovery and audit. |
| Stop condition | Do not connect a live effect until its added boundaries and tests are reviewed. |
Design audit events around the decision: request ID, actor reference, operation type, target reference, policy version, approval reference, result code and resource revision. Avoid putting entire note bodies or model prompts into routine logs. Such fields can create a second confidential-data store and complicate access and retention. The lab prints a fictional demonstration only; it has no persistent audit implementation and does not satisfy these requirements by printing to the terminal.
Threat modelling should be revisited when the tool set, data source, identity system, reviewer flow or deployment topology changes. Moving from one process to two workers invalidates the serial-state assumption. Adding a tool that fetches URLs introduces destinations and network permissions absent here. Allowing generated Python would invalidate the assumption that the model controls only JSON. These are changes to the system being evaluated, not just more test cases for the old model.
Finish with an owner decision that names the remaining conditions. For this course, the appropriate outcome is acceptance as a tested educational simulator. A production owner would need evidence from the real integration and an explicit decision about its unresolved risks. Do not describe a local teaching approval as a user's permission to deploy, send messages, change records or spend money elsewhere.
Try it yourself · Activity 06
15 minWrite the next builder's release note
Draft a short handover for the fictional service without claiming production readiness.
- Name two implemented boundaries and their tests.
- Name three missing production controls and responsible roles.
- State one architecture change that requires a new review.
Could a new builder reproduce every claimed result using only the files and commands in your handover?
Worked answer
The gateway checks current ownership and binds reviewed writes to exact parsed proposals; the owner-scoped read and changed-body tests verify those examples. Identity integration belongs to the host owner, approval-screen integrity to the review-flow owner, and atomic persistent state to the storage owner. Adding multiple workers requires a new concurrency design and tests. The current result is a tested local simulator only.
Keep learning
The complete workbook
Build and test a three-file, standard-library Python lab with fictional notes, then connect each protection to a worked threat register. Six activities cover scope, abuse stories, strict proposals, approval state, adversarial tests and release evidence. Complete code, expected output and explicit operational limits are included.
- 01Draw what the agent can actually reachRead here · 1 exercise
Begin with assets and data flows before choosing controls.
- 02Write abuse stories that lead to testsRead here · 1 exercise
Give each threat a specific precondition, action and observable consequence.
- 03Define a small gateway contractRead here · 1 exercise
Validate structure before checking who may act.
- 04Bind review to the action that executesRead here · 1 exercise
An approval is useful only while its exact conditions still hold.
- 05Test refusals and challenge the evidenceRead here · 1 exercise
A negative test should check the state as well as the error.
- 06Hand over a threat model that can be maintainedRead here · 1 exercise
Record what is implemented, what was observed and what remains a condition of release.
Also inside: a 8-point checklist, a glossary of 10 terms and 10 questions and answers to test yourself. 6 hands-on exercises, each with a worked answer at the back where the workbook gives one.
No login, no card, no account. Before the download we ask you to follow Mickai (two quick links). Free to download and use for personal learning, study groups and inside your own team. Please do not resell the workbooks or republish them as your own. Link people to trust-agent.ai instead.
Test yourself
Questions and answers
What does an agent threat model follow?
It follows assets, data flows and trust boundaries from untrusted content to possible effects, then connects abuse stories to controls and evidence.
Can the model supply its own actor identity?
Not in this contract. Trusted host code supplies actor separately; actor inside proposal JSON is an undeclared field and is refused.
Why is prompt refusal alone insufficient?
A model may still propose an unwanted action. The gateway must enforce ownership and operation permissions independently of the model response.
Does valid JSON establish permission?
No. Syntax and shape checks validate a proposal; current ownership and approval checks determine whether it may execute.
Why reject duplicate keys and boolean revisions?
Repeated keys can obscure which value is intended, and True must not stand in for numeric revision 1. The lab defines an exact grammar and integer type.
What is bound to an approval?
The actor and complete parsed proposal: action, note ID, expected revision and exact text, plus issue and expiry ticks.
Is a ticket issued at 100 valid at 130?
No. The 30-tick window is inclusive at issue and exclusive at expiry: 100 through 129. Earlier ticks are also refused.
Do two approved revision-1 writes both succeed?
No. The first changes the current revision to 2; the second still expects revision 1 and is refused.
What do the four deliberate defects prove?
The suite catches those four specific weakened checks. It does not prove complete attack coverage or production security.
Is this a production tool gateway?
No. It is a serial in-memory simulator without authentication integration, review UI, transactional storage, durable audit or restart recovery.
When you have finished
Get your certificate of completion
Type your name and download a certificate for this workbook as a PDF, ready to print or to add to LinkedIn. It is made on your own device, so your name is never sent to us. It is a self-declared certificate, not an accredited qualification.
Learn the language
Key terms
- Trust boundary
- A point where data crosses into a component with different authority or assumptions.
- Asset
- Information, state or a capability whose confidentiality, integrity or availability matters.
- Authentication
- Establishing the identity of a caller through a trusted mechanism.
- Authorisation
- Deciding whether a caller may perform an operation on a particular resource.
- Approval
- A separately obtained decision permitting a specific operation under stated conditions.
- Proposal
- An untrusted description of an intended operation; it carries no authority by itself.
6 of the workbook's 10 terms. The complete glossary is in the workbook.
Follow the evidence
Sources and checks
Facts last checked: .
Examples in this workbook were run on: Python 3.12.10 on Windows, standard library only. Sixteen unit tests, a six-line demo and four caught logic mutations. All records and actors are fictional. Single-process serial in-memory behaviour only; no authentication provider, model call, network transport, persistent audit or production security acceptance. (2026-09-27).
These workbooks use AI assistance. See how the workbooks are made.
- Threat Modeling Cheat SheetOWASP
- Authorization Cheat SheetOWASP
- LLM06:2025 Excessive AgencyOWASP GenAI Security Project
- LLM Prompt Injection Prevention Cheat SheetOWASP
- Transaction Authorization Cheat SheetOWASP
- JSON decoding, duplicate names and non-standard constantsPython Software Foundation
- Dataclass equality and frozen instancesPython Software Foundation
- Unit testing and assertionsPython Software Foundation
- Monotonic clocksPython Software Foundation
Created by Mickarle Wagstaff-Irons - Micky Irons with the Mickai team. Published by Mickai LTD. Last updated 27 September 2026.
NextKeep going
Where to go next
Recommended for you
Testing agent loops: budgets, termination and failure recovery
Test an agent loop locally: enforce budgets, replay model decisions, reject late results and verify bounded retries without paying for model calls.
Recommended for you
Design a repeatable evaluation suite for an AI application
Build an offline AI evaluation harness with test cases, a rubric, regression comparisons, uncertainty checks and a reproducible release record.