Keep retrieval and agent tools inside an enforceable boundary
Python 3.11+; standard library only. All identities, text and effects are synthetic.
No model, network service, paid API, credentials or protocol adapter is required.

Reference solution: not included here, so the exercise does not hand you the answer.
Attempt the repair first. Then download agent-tool-boundaries-solution.zip from the project card
on https://softwaresecurity.com/labs/#project-agent-tool-boundaries and extract it into this folder;
it adds reference.py without changing your files, and the reference commands below
need it.

Objective: control what reaches a ranker and what an untrusted proposal can do,
then recover from an uncertain side effect without performing it twice.
Prerequisites: basic SQL transactions, Python and tenant/document authorization.

Run python3 demo.py, then python3 -m unittest -v test_boundaries.py.
The starter deliberately leaks documents and repeats an effect after a crash.
Tests must show assertion failures alongside successful legitimate cases.

Walkthrough
1. Read app.py's schema and seed data. Alice belongs to Acme, Bob to Beta, Carol
   to Acme. Alice may read a-public/a-poison; Carol alone may read a-private.
   The Beta document deliberately ranks highly for "refund". List the document
   IDs that may enter Alice's ranker, then inspect demo.py's actual ranker input.
2. Repair retrieve in exercise.py. Check current membership, tenant and document
   ACL in SQL before ranking. Filtering the final answer is too late if a ranker
   or model has already received the text. The poison document remains readable
   as data; it does not acquire permission to invoke tools or grant approvals.
3. Trace the proposal {tool: credit-v1, destination: account-acme, amount: 25}.
   app.py's operator-owned schema rejects unknown tools, extra fields and other
   tenant destinations. The metadata description is untrusted. A trusted human
   action calls approve; an untrusted model/proposal cannot call this control.
4. Repair enqueue. In BEGIN IMMEDIATE check the live member and exact approval
   actor, tenant, effect digest, expiry, revocation and unused state. Consume the
   approval and insert the outbox together. Reusing the same operation ID with
   the same payload is a retry; changing its payload is a conflict. Run the two
   concurrent submission test: one approval must yield exactly one new operation.
5. Repair dispatch. Recheck authority immediately before a new effect. The tests
   revoke users and approvals after enqueue. Expiry at now=200 must also deny.
   For this local simulation, the app transaction serializes these changes with
   dispatch. The actual side effect lives in a second SQLite database.
6. Force crash='after_effect': the provider has committed +25, while the app's
   outbox transaction rolls back and remains pending. A fresh process cannot
   infer from "pending" whether an effect happened. Use a stable operation ID
   and provider receipt lookup; its UNIQUE operation key prevents reapplication.
   Retry and prove the balance stays 25 with one receipt. Reconcile an existing
   receipt even if authority has since been revoked; that records a past effect,
   not permission for a new one. A different effect under the same key must fail.
7. Run all 25 tests. Reference: EXERCISE_MODULE=reference python3 -m unittest -v test_boundaries.py
   Demo reference: EXERCISE_MODULE=reference python3 demo.py
   PowerShell: $env:EXERCISE_MODULE='reference'; python -m unittest -v test_boundaries.py
   Remove the variable before testing your repairs. Compare reference.py last.

Keep: an authorization matrix; a timeline showing approval, enqueue, provider
commit, lost acknowledgement and reconciliation; evidence of provider balances
and receipt counts before/after each crash. All 25 tests and the reference demo
should pass, but the explanation of each boundary is part of completion.

Architecture and limits
The deterministic ranker is an observable stand-in for a model/reranker; this
project demonstrates enforcement independent of prompt wording. It does not
measure model resistance to prompt injection. There is no MCP implementation:
metadata/proposals are ordinary in-process Python values, not protocol messages.
Actor identity is a trusted test harness input, standing in for authenticated
session middleware. Never accept it from an unverified request field.
The provider really persists balances/idempotency receipts in a separate SQLite
file. Failure injection raises exceptions at specific transaction boundaries;
it models process termination there, not filesystem corruption or power failure.
The gateway holds a local DB transaction during the bounded local provider call
to define revocation ordering. A remote provider requires a separately designed
lease/cancellation protocol and explicit handling of revocation racing a call.
No generic outbox guarantees exactly-once external effects: this fixture relies
on the provider's durable idempotency and matching receipt contract. Where that
contract is absent, require reconciliation/compensation and report uncertainty.
Approval IDs and operation IDs are fixed for inspectable tests; production must
generate unguessable IDs, enforce access to them and bound their retention.

Sources (checked 9 October 2026):
https://sqlite.org/lang_transaction.html
https://sqlite.org/lang_conflict.html
https://genai.owasp.org/llmrisk/llm082025-vector-and-embedding-weaknesses/
https://genai.owasp.org/llmrisk/llm062025-excessive-agency/

Dev container
.devcontainer/devcontainer.json opens this folder in VS Code Dev Containers or
GitHub Codespaces using the Dev Containers Python 3.13 image (Debian 13, Expat
2.8), pinned by digest. Run the commands above in its terminal.
