Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ The diagrams live in the [README](README.md) (system, order sequence, compile pi
| red team · fairness · conformance | 19/19 hand-written + 190/190 generated · 159,840 cohorts clean · 24/24 | same |
| latency p50 / p95 | 47 / 62 ms (deterministic) | cache hit ≈ offline; a live gpt-4o proposal adds ~1.5–4 s |

92 tests, fully offline, green in CI.
96 tests, fully offline, green in CI.

## Honest limitations

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -234,7 +234,7 @@ Every one of these still goes through propose → verify → execute: the model

```bash
pip install -e ".[dev]" # Python 3.10+
python -m pytest -q # 92 tests, fully offline (incl. README-vs-results consistency)
python -m pytest -q # 96 tests, fully offline (incl. README-vs-results consistency)
python -m bazaar.simulator.run # regenerates results/
uvicorn bazaar.gateway.app:default_app --factory --port 8000
cd console && npm install && npm run dev # http://localhost:5173
Expand Down
2 changes: 2 additions & 0 deletions THREAT_MODEL.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ The published numbers are small-n demonstrations on one framework each, not popu
## What this does not cover

- **Buyer identity is a demo shortcut.** In production the buyer key that signs the AP2 mandates must be presented by an authenticated channel (Login with Razorpay OAuth, or a UPI mandate id) and bound to the buyer at registration. Today a self-registered agent chooses its own `buyer_ref` and registers the buyer key that signs the mandates — so the mandate proves *some* registered key signed it, not that a specific human authorised it. The gate does enforce `mandate_binds_grant_buyer` (a mandate must be for the grant's buyer) and `grant_usable`, but the root of trust is agent-asserted until the OAuth/UPI binding lands. The ACP adapter's server-side buyer-key generation is a demo convenience for the same reason and must be removed for production.
- **Human-present is a self-asserted flag in the demo.** Above the ₹15,000 threshold the gate requires `human_confirmation`, but today that is a boolean the calling agent sets, not an out-of-band factor. In production it must be a short-lived confirmation minted by a channel the agent does not control — a buyer-key-signed statement over `quote_id + amount + nonce`, or a Razorpay-side AFA hook — and the check verifies that signature and its binding rather than a flag. Same root-of-trust gap as buyer identity above.
- **The pricing segment is derived, not the alias set.** Untrusted callers can no longer self-declare a pricing segment — `server_segment` fixes it to facts Bazaar owns (a claimed `b2b` is denied; `new`/`returning` follows the agent's completed-order history), and only the merchant's own admin-authenticated console may set a segment directly. Separately, ranking has no model in the loop and prose in a catalog cannot instruct it, but `match_products` does read merchant-supplied `synonyms`/`use_case_tags` for *relevance* — so a merchant can still shape its own ordering through its aliases (it cannot touch another merchant's). Capping alias count/length and requiring a token overlap with the canonical name is the planned hardening; the Branded Whisper guarantee is specifically that injected *instructions* never reach the ranker, not that a merchant cannot describe its own products.
- **Real UPI mandates.** Reserve Pay is a sandbox ledger with NPCI's ₹10,000 / 90-day defaults; `trust/uap.py` is where a real binding lands once the Unified Agent Protocol is public.
- **Distributed state.** Nonce cache, rate limits, reservations and sessions are in-memory; a multi-instance deployment needs Redis (planned for Phase 1).
- **Vision inputs.** A rate-card photo is transcribed by a vision model and then treated like any other untrusted catalog text, but a photo carrying an injection has not been red-teamed yet.
Expand Down
47 changes: 44 additions & 3 deletions bazaar/gateway/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,23 @@ def _idem_key(request: Request, body: bytes) -> str | None:
return f"{request.method}:{request.url.path}:{k}:{hashlib.sha256(body).hexdigest()[:16]}"


def server_segment(state: BazaarState, keyid: str, merchant_id: str, requested: Segment) -> Segment:
"""The pricing segment must reflect facts Bazaar owns, not a label an untrusted buyer
asserts (S-1): a caller could otherwise claim ``b2b`` for wholesale pricing or ``new`` for a
first-order discount it is not entitled to. ``any`` grants nothing extra, so it is left as
asked; a claimed ``b2b`` requires a merchant/admin-designated agent, so it is denied here;
``new`` vs ``returning`` is decided by whether this agent already completed an order with the
merchant. Trusted callers (admin token — the merchant's own console and the simulator) keep
the segment they pass, since segments are the merchant's to define."""
if requested == Segment.ANY:
return Segment.ANY
returning = bool(keyid) and any(
x.agent_keyid == keyid and x.merchant_id == merchant_id and x.status == "completed"
for x in state.sessions.values()
)
return Segment.RETURNING if returning else Segment.NEW


DEV_ADMIN_TOKEN = "dev-admin-token"
DEV_WEBHOOK_SECRET = "bazaar-dev-webhook-secret"

Expand Down Expand Up @@ -249,6 +266,16 @@ def set_tier(keyid: str, body: TierIn, request: Request):
require_admin(request, st)
return st.registry.set_tier(keyid, body.tier, body.reason).model_dump(mode="json")

@app.post("/bazaar/v1/agents/{keyid}/revoke")
def revoke_agent(keyid: str, request: Request):
# a compromised agent key must be cuttable without a restart (P1-4). Admin-only; a revoked
# identity fails signature verification on the next request.
require_admin(request, st)
if st.registry.get(keyid) is None:
raise HTTPException(404, detail={"error": "agent_not_found"})
st.registry.revoke(keyid, "revoked via admin API")
return {"keyid": keyid, "revoked": True}

@app.post("/bazaar/v1/buyers/keys", status_code=201)
def buyer_key(body: BuyerKey):
return {"keyid": st.register_buyer_key(body.public_key_b64u)}
Expand All @@ -257,6 +284,14 @@ def buyer_key(body: BuyerKey):
async def issue_grant(body: GrantIn, request: Request):
caller = await identify(request, st, required_tag=TAG_PAY)
_m(body.merchant_id)
# A grant cannot authorise more than the agent's own per-order tier ceiling (S-3): without
# this a T2 agent could self-issue a ₹1-crore block against Reserve Pay. Admin-issued grants
# (the merchant's own console) are trusted to override.
is_admin = request.headers.get("x-admin-token", "") == st.settings.bazaar_admin_token
ident = st.registry.get(caller.keyid)
ceiling = ident.max_order_paise if ident else 0
if not is_admin and ceiling and body.max_amount_paise > ceiling:
raise HTTPException(422, detail={"error": "grant_exceeds_agent_ceiling", "max_amount_paise": body.max_amount_paise, "agent_ceiling_paise": ceiling})
g = st.grants.issue(body.buyer_ref, caller.keyid, body.merchant_id, body.max_amount_paise, body.ttl_minutes, body.single_use, payment_mandate_id=body.payment_mandate_id)
st.audit.record({"session": "", "kind": "grant", "action": "issue", "outcome": "ok", "money": {"grant_id": g.grant_id, "max_amount_paise": g.max_amount_paise}, "note": f"agent {caller.keyid} for merchant {body.merchant_id}"})
return g.model_dump(mode="json")
Expand All @@ -282,7 +317,9 @@ async def create_session(body: SessionCreate, request: Request):
m = _m(body.merchant_id)
if m.policy.kill_switch:
raise HTTPException(409, detail={"error": "merchant_agent_disabled"})
s = st.new_session(merchant_id=m.merchant_id, agent_keyid=caller.keyid, tier=caller.tier, segment=body.segment, language=body.language or "en")
is_admin = request.headers.get("x-admin-token", "") == st.settings.bazaar_admin_token
segment = body.segment if is_admin else server_segment(st, caller.keyid, m.merchant_id, body.segment)
s = st.new_session(merchant_id=m.merchant_id, agent_keyid=caller.keyid, tier=caller.tier, segment=segment, language=body.language or "en")
st.audit.record({"session": s.session_id, "kind": "session", "action": "create", "outcome": "ok", "note": f"agent={caller.keyid or 'unsigned'} tier={int(caller.tier)}"})
if body.message:
return run_turn(st, s, body.message, caller.keyid, caller.tier)
Expand Down Expand Up @@ -343,8 +380,12 @@ async def cancel(sid: str, request: Request, reason: str = "buyer canceled"):
s = st.session(sid)
if s is None:
raise HTTPException(404, detail={"error": "session_not_found"})
# the session's own agent (signed) or an admin may cancel it — not any passer-by
if request.headers.get("x-admin-token", "") != st.settings.bazaar_admin_token and s.agent_keyid:
# the session's own agent (signed) or an admin may cancel it — not any passer-by. An
# unsigned (T0) session has no owning key, so only an admin may cancel it (S-5) —
# otherwise anyone with the publicly-listed id could cancel it and release its hold.
if request.headers.get("x-admin-token", "") != st.settings.bazaar_admin_token:
if not s.agent_keyid:
raise HTTPException(403, detail={"error": "unsigned_session_admin_only"})
caller = await identify(request, st)
if caller.keyid != s.agent_keyid:
raise HTTPException(403, detail={"error": "not_session_owner"})
Expand Down
46 changes: 46 additions & 0 deletions tests/test_gateway.py
Original file line number Diff line number Diff line change
Expand Up @@ -423,3 +423,49 @@ def test_webhook_ignores_capture_for_canceled_session(env):
r = c.post("/webhooks/razorpay", content=raw, headers={"x-razorpay-signature": webhook_signature(raw, st.settings.razorpay_webhook_secret), "content-type": "application/json"})
assert r.json()["status"] == "inactive_session"
assert st.session(sid).status == "canceled"


def test_untrusted_caller_cannot_self_declare_segment(env):
"""S-1: an unauthenticated buyer must not claim a gated pricing segment; the merchant's own
console (admin token) may."""
st, c, _ = env
mid = _grocer_id(st)
anon = TestClient(c.app) # no admin token, no signature
r = anon.post("/bazaar/v1/sessions", json={"merchant_id": mid, "segment": "b2b"})
assert r.status_code == 201 and r.json()["session"]["segment"] != "b2b"
# a brand-new anonymous caller is 'new', never 'returning'
assert anon.post("/bazaar/v1/sessions", json={"merchant_id": mid, "segment": "returning"}).json()["session"]["segment"] == "new"
# the admin-authenticated console keeps the segment it sets
assert c.post("/bazaar/v1/sessions", json={"merchant_id": mid, "segment": "b2b"}).json()["session"]["segment"] == "b2b"


def test_grant_cannot_exceed_agent_tier_ceiling(env):
"""S-3: an agent may not self-issue a grant larger than its own per-order tier ceiling."""
st, c, _ = env
mid = _grocer_id(st)
anon = TestClient(c.app)
b = BuyerAgentClient(anon)
b.register() # T1 by default
ceiling = st.registry.get(b.keyid).max_order_paise
r = b.pay_call("POST", "/bazaar/v1/grants", {"buyer_ref": "x@ok", "merchant_id": mid, "max_amount_paise": ceiling * 100})
assert r.status_code == 422 and r.json()["detail"]["error"] == "grant_exceeds_agent_ceiling"
assert b.pay_call("POST", "/bazaar/v1/grants", {"buyer_ref": "x@ok", "merchant_id": mid, "max_amount_paise": ceiling}).status_code == 201


def test_unsigned_session_cancel_requires_admin(env):
"""S-5: an unsigned session has no owning key, so only an admin may cancel it."""
st, c, _ = env
mid = _grocer_id(st)
anon = TestClient(c.app)
sid = anon.post("/bazaar/v1/sessions", json={"merchant_id": mid}).json()["session"]["session_id"]
assert anon.post(f"/bazaar/v1/sessions/{sid}/cancel").status_code == 403
assert c.post(f"/bazaar/v1/sessions/{sid}/cancel").status_code == 200


def test_admin_can_revoke_a_compromised_agent_key(env):
"""P1-4: a revoked key can no longer authenticate a pay-tag route."""
st, c, buyer = env
mid = _grocer_id(st)
assert c.post(f"/bazaar/v1/agents/{buyer.keyid}/revoke").status_code == 200
r = buyer.pay_call("POST", "/bazaar/v1/grants", {"buyer_ref": "x@ok", "merchant_id": mid, "max_amount_paise": 1000})
assert r.status_code == 401
Loading