Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Labs: learn by failed attacks

From cognokratos/tauros-revenue · docs/EXERCISES.md · pinned revision facbbc927eb4

The runnable labs of the course. Each one tries something an agent (or a careless human) should not be able to do, shows the refusal, and asks you to find the exact declaration that caused it. They run against the demo data from mix setup.

Start a console:

iex -S mix

and paste this once:

alias Tauros.{Accounts, Revenue}
require Ash.Query

human = Ash.read_one!(Ash.Query.for_read(Accounts.User, :get_by_email, %{email: "demo@tauros.local"}), authorize?: false)
[agent | _] = Accounts.list_agents!(actor: human)
[customer | _] = Revenue.list_customers!(actor: agent)
[destination | _] = Revenue.list_payment_destinations!(actor: agent, query: [filter: [currency: :EUR]])

draft = fn attrs ->
  Map.merge(%{
    idempotency_key: "exercise-#{System.unique_integer([:positive])}",
    customer_id: customer.id,
    payment_destination_id: destination.id,
    currency: :EUR,
    due_date: Date.add(Date.utc_today(), 30),
    lines: [%{description: "Consulting", quantity: "2", unit_amount: "500"}],
    reasoning: "Exercise"
  }, attrs)
end

human is the demo approver, agent its billing agent.

1. Break ownership

Make a second agent with its own customer, then let the first agent bill that customer:

other = Accounts.create_agent!("Other agent", actor: human)
theirs = Revenue.create_customer!(%{name: "Not yours", email: "x@example.com", agent_id: other.id}, actor: human)

Revenue.create_invoice_draft(draft.(%{customer_id: theirs.id}), actor: agent)
# {:error, %Ash.Error.Invalid{… message: "is not one of this agent's customers"}}

Revenue.create_invoice_draft(draft.(%{customer_id: Ash.UUID.generate()}), actor: agent)
# the same error for an id that does not exist

Find it. lib/tauros/revenue/invoice_revision/validations/usable_references.ex. Why does it load the customer with authorize?: false and compare agent_id instead of trusting the id it was given? Why is the error for "someone else's" and "does not exist" identical?

2. Skip the state machine

Approve a draft that was never submitted:

invoice = Revenue.create_invoice_draft!(draft.(%{}), actor: agent)
revision = Ash.load!(invoice, :current_revision, actor: human).current_revision

Revenue.approve_invoice(invoice, %{revision_id: revision.id, payload_hash: revision.payload_hash}, actor: human)
# {:error, … %AshStateMachine.Errors.NoMatchingTransition{old_state: :draft, target: :approved}}

Find it. The state_machine block in lib/tauros/revenue/invoice.ex: which line lists the states :approve may leave from? Now try Ash.Changeset.for_update(invoice, :submit_for_approval, %{state: :approved}, actor: agent). Why is that refused before the state machine is even asked?

3. Replay a request

Over HTTP, as an agent would. The seed output printed the agent's API key; if you lost it, rotate it on the agent's page in the UI.

KEY=tauros_…   # the agent key
CUSTOMER=…     # GET /api/v1/customers with the key
DESTINATION=…  # GET /api/v1/payment-destinations with the key (pick the EUR one)

BODY='{"data":{"type":"invoice","attributes":{
  "idempotency_key":"replay-1","customer_id":"'$CUSTOMER'",
  "payment_destination_id":"'$DESTINATION'","currency":"EUR","due_date":"2099-01-31",
  "lines":[{"description":"Consulting","quantity":"2","unit_amount":"500"}],
  "reasoning":"First try"}}}'

for i in 1 2; do
  curl -s -X POST localhost:4000/api/v1/invoices \
    -H "authorization: Bearer $KEY" -H 'content-type: application/vnd.api+json' \
    -d "$BODY" | jq '{id: .data.id, replay: .meta.idempotent_replay}'
done
# the same id twice; replay is false, then true

curl -s -X POST localhost:4000/api/v1/invoices \
  -H "authorization: Bearer $KEY" -H 'content-type: application/vnd.api+json' \
  -d "$(echo "$BODY" | sed 's/"500"/"501"/')" | jq '.errors[0] | {status, code}'
# {"status": "409", "code": "idempotency_conflict"}

Find it. lib/tauros/revenue/invoice/changes/propose_revision.ex. What is locked before the key is looked up, and why? What exactly is compared? Try the replay again with a different reasoning: is it a conflict?

4. Mutate approved intent

Submit, approve, then try to change the amount:

invoice = Revenue.create_invoice_draft!(draft.(%{}), actor: agent) |> Revenue.submit_invoice!(actor: agent)
r1 = Ash.load!(invoice, :current_revision, actor: human).current_revision
{:ok, approved} = Revenue.approve_invoice(invoice, %{revision_id: r1.id, payload_hash: r1.payload_hash}, actor: human)

Revenue.revise_invoice(approved, %{lines: [%{description: "Consulting", quantity: "3", unit_amount: "500"}], reasoning: "More"}, actor: agent)
# {:error, … NoMatchingTransition{old_state: :approved, target: :draft}}

Now do it before approval, and approve the revision you saw first:

invoice = Revenue.create_invoice_draft!(draft.(%{}), actor: agent) |> Revenue.submit_invoice!(actor: agent)
seen = Ash.load!(invoice, :current_revision, actor: human).current_revision

Revenue.revise_invoice!(invoice, %{lines: [%{description: "Consulting", quantity: "3", unit_amount: "500"}], reasoning: "More"}, actor: agent)
|> Revenue.submit_invoice!(actor: agent)

current = Ash.load!(invoice, :current_revision, actor: human).current_revision
{seen.payload_hash, current.payload_hash}   # two different hashes

Revenue.approve_invoice(invoice, %{revision_id: seen.id, payload_hash: seen.payload_hash}, actor: human)
# {:error, … %Tauros.Revenue.Errors.Conflict{code: :stale_revision}}

Find it. Which fields of seen and current differ? Read Tauros.Revenue.FinancialPayload: which of them are part of the hash? Then read the checks in lib/tauros/revenue/invoice/changes/decide.ex.

5. Impersonate authority

Call the approval action as the agent:

invoice = Revenue.create_invoice_draft!(draft.(%{}), actor: agent) |> Revenue.submit_invoice!(actor: agent)
r = Ash.load!(invoice, :current_revision, actor: agent).current_revision

Revenue.approve_invoice(invoice, %{revision_id: r.id, payload_hash: r.payload_hash}, actor: agent)
# {:error, %Ash.Error.Forbidden{}}

Find it. The last policy in lib/tauros/revenue/invoice.ex and lib/tauros/accounts/checks/human_approver.ex. Which line fails for an agent?

Then break it on purpose. In that policy, replace forbid_unless HumanApprover with authorize_if relates_to_actor_via(:agent) and run:

mix test test/tauros/authority_test.exs test/tauros/adversarial_test.exs

AuthorityTest fails ("agent was allowed Tauros.Revenue.Invoice.approve"), but the agent still cannot approve. Why? (Read the policies block of lib/tauros/revenue/approval.ex.) Restore the line afterwards.

6. Tamper behind Tauros's back (bonus)

invoice = Revenue.create_invoice_draft!(draft.(%{}), actor: agent) |> Revenue.submit_invoice!(actor: agent)
r = Ash.load!(invoice, :current_revision, actor: human).current_revision

Tauros.Repo.query!("UPDATE invoice_revisions SET due_date = due_date + 1 WHERE id = $1", [Ecto.UUID.dump!(r.id)])

Revenue.approve_invoice(invoice, %{revision_id: r.id, payload_hash: r.payload_hash}, actor: human)
# {:error, … Conflict{code: :payload_integrity}}

Find it. sealed?/2 in decide.ex. What would stop this attack at the database level instead? (See Epic 5 in ROADMAP.md.)


Exercises for AI clients (MCP)

These use the MCP endpoint as an AI client would. Start the app (mix phx.server) and, in another shell:

source docs/examples/mcp_env.sh

That gives you a fresh agent key in $KEY, the demo approver's token in $TOKEN, and the helpers mcp, call and out (see MCP.md).

7. Discover tools

mcp "$KEY" tools/list '{}' | jq '[.result.tools[] | {name, description: (.description | split("\n")[0])}]'

Eight tools. Find what is missing. Which invoice actions exist in lib/tauros/revenue/invoice.ex but have no tool? Which agent-safe action in lib/tauros/authority.ex is deliberately not a tool, and why (MCP.md)? Then try the same request with $TOKEN instead of $KEY: why is a human refused here but not on REST?

8. Create a proposal

CUSTOMER=$(call list_customers '{}' | jq -r '.result.structuredContent.results[0].id')
DEST=$(call list_payment_destinations '{}' | jq -r '.result.content[0].text | fromjson | .[0].id')

INPUT=$(jq -n --arg c "$CUSTOMER" --arg d "$DEST" '{input:{idempotency_key:"ex-8",
  customer_id:$c, payment_destination_id:$d, currency:"USDC", due_date:"2099-01-31",
  lines:[{description:"Retainer",quantity:"1",unit_amount:"1200.00"}],
  reasoning:"Retainer per the agreement."}}')

INVOICE=$(call create_invoice_draft "$INPUT" | jq -r .result.structuredContent.id)
call submit_invoice "{\"id\":\"$INVOICE\"}" | out
# {"id":"…","state":"pending_approval"}

Now sign in at http://localhost:4000 as demo@tauros.local. The Overview shows the proposal waiting; open it from Needs review and see the agent's reasoning, who proposed it, who decides, and the exact revision. Explain what the agent can no longer do to this proposal, and what it still can (revise_invoice): what happens to a human looking at the old revision?

9. Try to approve

call approve_invoice "{\"id\":\"$INVOICE\"}" | out
# {"code":-32602,"message":"Tool not found: approve_invoice"}

That is layer 1: the capability is not offered. Now go around MCP with the same key:

curl -s -X PATCH localhost:4000/api/v1/invoices/$INVOICE/approve -H "authorization: Bearer $KEY" \
  -H 'content-type: application/vnd.api+json' \
  -d '{"data":{"type":"invoice","id":"'$INVOICE'","attributes":{"revision_id":"'$INVOICE'","payload_hash":"'$(printf '0%.0s' {1..64})'"}}}' \
  | jq '.errors[0].status'
# "403"

That is layer 2. Find the line that refuses the agent (the last policy in lib/tauros/revenue/invoice.ex), then read "prompt injection cannot manufacture authority" in test/tauros_web/mcp/attacks_test.exs.

10. Replay a draft

call create_invoice_draft "$INPUT" | out    # the same id as in exercise 8
call create_invoice_draft "$(echo "$INPUT" | jq '.input.lines[0].unit_amount="1300.00"')" | out
# "idempotency_key: was already used by this agent for a different financial payload (idempotency_conflict)"
call create_invoice_draft "$(echo "$INPUT" | jq '.input.idempotency_key="ex-10" | .input.lines[0].unit_amount=1200.5')" | out
# "input.lines.0.unit_amount: send amounts as decimal strings …"

Explain each answer. Which one would silently lose precision if Tauros accepted it, and where is it refused (lib/tauros_web/mcp/strict_arguments.ex)?

11. Cross-tenant request

Use the demo seeds' customer, which belongs to another agent ("Billing agent"):

THEIRS=$(curl -s localhost:4000/api/v1/customers -H "authorization: Bearer $TOKEN" \
  | jq -r '.data[] | select(.attributes.name=="Acme Inc") | .id')

call create_invoice_draft "$(echo "$INPUT" | jq --arg c "$THEIRS" '.input.idempotency_key="ex-11" | .input.customer_id=$c')" | out
call create_invoice_draft "$(echo "$INPUT" | jq '.input.idempotency_key="ex-11b" | .input.customer_id="00000000-0000-4000-8000-000000000000"')" | out
# both: "revisions.0.customer_id: is not one of this agent's customers"

The agent's owner can see that customer; the agent cannot use it. Find where it fails (lib/tauros/revenue/invoice_revision/validations/usable_references.ex), and explain why the two answers must be identical.

This chapter is maintained in cognokratos/tauros-revenue beside the code it teaches. The book shows docs/EXERCISES.md at revision facbbc927eb4b9090937524ca02486c026f1f025 (branch main). View source at this revision · Report a correction.

Corrections are made upstream against the current main branch and appear here when the book's pin for this source is updated.