Alguard: a two-tier trust layer for agentic purchases
I just submitted Alguard to the
Amazon Developer Hackathon (Alexa+ track). This is a short write-up of the
architecture and the reasoning behind it.
The problem
An agent that can spend money is an agent that can be prompt-injected.
Classic defenses (LLM guardrails, prompting) are advisory — a
sufficiently clever injection gets around them. If Alexa+ can "just buy
it," the failure mode is real money lost.
The invariant
The system that requests a risky action is never the system that
approves it.
This is a structural rule, not a prompt. It shapes the whole server.
Two tiers
Tier 1 — the MCP server (mcp_server/server.py). Exposes exactly
three tools over Streamable HTTP (MCP spec 2025-11-25):
attempt_purchaseget_session_statusget_audit_trail
No approve tool. Not hidden, not guarded — absent from the schema.
Tier 2 — the human channel (web_api/resolve_action.py). A separate
FastAPI service on port 8010, behind its own OAuth audience and its own
approve scope. The only place a HALTED action can be resolved.
There is no network path from Tier 1 to Tier 2. The customer carries an
action_id by hand.
The risk engine
Every attempt is scored on:
- Category severity — gift cards, crypto, wire transfers are High; groceries are Low
- Anomalies — new merchant, amount spike vs. history, frequency spike, retry-after-halt
- Owner trust — bumped +0.03 on approval, -0.15 on denial
Plus hard rules that no amount of trust bypasses: >$1000 single purchase,
$1500/h, >$3000/24h, High-severity category, retry-after-halt within 24h,
$250 at an unapproved merchant.
What made this hard
Concurrency. Two concurrent approvals must yield exactly one winner.
The resolver is a single conditional UPDATE with a rowcount == 1 check.
Crash consistency. If the process dies between "approved" and
"fulfilled," reconcile_unfulfilled() heals it on startup — idempotently.
Cross-process dedup. The in-process lock is a fast path, not the
guarantee. insert_action_if_not_duplicate() runs check + insert inside
BEGIN IMMEDIATE.
Bedrock is non-critical. The explanation text is generated by
converse, but a Bedrock failure never changes the decision — the
fallback template is honest about what it is (source = local-fallback).
Red-team
demo/showcase.py runs 15 attacks a hostile agent would try — category
lying, laundering a halt by retrying, session_id rotation, NaN amounts,
serial overspend. All caught. ALL ATTACKS HANDLED.
What I'd build next
- Wire
resolve_pending_actionto a real charge (Checkout /ChargePermissionId) - Replace the text-heuristic category inference with MCC data
- Split the shared SQLite into per-tier stores
- Add EWMA-based drift detection (the current check catches spikes, not slow upward drift)
Top comments (0)