diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index f9bcee7..d552916 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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 diff --git a/README.md b/README.md index 2bccbae..a90d662 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/THREAT_MODEL.md b/THREAT_MODEL.md index 0fdb90e..68c82de 100644 --- a/THREAT_MODEL.md +++ b/THREAT_MODEL.md @@ -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. diff --git a/bazaar/gateway/app.py b/bazaar/gateway/app.py index 9940f8b..b66299f 100644 --- a/bazaar/gateway/app.py +++ b/bazaar/gateway/app.py @@ -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" @@ -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)} @@ -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") @@ -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) @@ -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"}) diff --git a/tests/test_gateway.py b/tests/test_gateway.py index e4943fb..ea443ff 100644 --- a/tests/test_gateway.py +++ b/tests/test_gateway.py @@ -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