Repository navigation
fix: stop leaking the seller's floor price and concession budget on negotiation reads - #95
Conversation
…egotiation reads
GET /proposals/{proposal_id}/negotiation had no auth dependency and no
response model, and answered with whatever negotiation_service.
get_negotiation_status produced. That projection carried the internal
NegotiationHistory's strategy, base_price, floor_price and
limits.max_rounds, so any caller who knew or guessed a proposal id
learned the seller's floor and its remaining concession budget - the two
things a negotiating seller must never reveal. The shared Negotiation
primitive excludes those same four fields deliberately.
Three changes:
- get_negotiation_status now projects only buyer-visible facts. Dropping
the fields at the service rather than at the route means no consumer of
the projection can re-expose them; the one internal consumer
(apply_terminal_action -> contract_mappers.terminal_round_response)
reads only negotiation_id and rounds, so nothing needed them.
- The route pins its wire shape with a new NegotiationStatusResponse, so
a later addition to the history dict cannot silently reach the wire.
- The route resolves a verified buyer context the way its siblings
(POST /proposals/{id}/counter, POST /api/v1/negotiations/messages) do,
via _get_optional_api_key_record + _verified_buyer_context. A GET
carries no body identity to self-assert, so an unverifiable caller
cannot be established as a party to the negotiation and gets 401.
Not fixed here: the response is authentication-scoped, not buyer-scoped.
NegotiationHistory records no buyer identity, so there is nothing to
compare a requesting buyer against - any authenticated buyer can still
read any proposal's negotiation. Scoping needs a stored party identity,
which is a schema change beyond this defect.
…ct the redacted guardrails The status route's rounds were serialized whole as raw dicts, and each internal round carries cumulative_concession_pct, computed as (base_price - counter_price) / base_price — so seller_price / (1 - cumulative_concession_pct) reconstructed the seller's base_price EXACTLY, handing back the anchor the top level had just dropped. Round rationale on a below-floor offer also states the floor in prose. - NegotiationStatusResponse.rounds is now list[NegotiationRoundView], a whitelist of round_number / buyer_price / seller_price / action / timestamp. concession_pct, cumulative_concession_pct and rationale do not cross the wire. Excluding rationale here is boundary filtering on this read endpoint only; whether the engine should put the floor into rationale text at all remains an open engine-side decision. - The service keeps projecting full rounds: terminal_round_response reads rationale from them internally, so the filter belongs at the response model, which is the boundary. - New test drives the REAL engine (build_negotiation_engine -> start_negotiation -> evaluate_buyer_offer -> record_round) with a below-floor offer so the round genuinely carries the nonzero concession and the floor-naming rationale, then asserts neither survives serialization. The original fixture's default 0.0 concession and hand-written rationale could not exhibit either leak. Negative control: reverting rounds to list[dict] fails this test naming cumulative_concession_pct and rationale. - NegotiationStatusResponse declares quote_id (Optional, default None) so the projection #96 adds is passed through instead of silently stripped by the pinned response model. - docs/api/openapi.json regenerated via scripts/generate_openapi.py so the drift guard arriving in #83 passes over the merged pair.
4f4081e to
85417ac
Compare
Sirajmx
left a comment
There was a problem hiding this comment.
Confirmed the defect live on current
mainbefore touching the fix: the route has no
Depends(...)and noresponse_modelat all, so it returns the raw service projection —
strategy,base_price,floor_price,max_roundsincluded.Ran a real negative control myself, not just the PR's claimed numbers: mounted the new test file
into a container running currentmain(without the fix) and ran it directly. 5 of 6 tests
genuinely fail — guardrail fields present in both the route response and the service projection,
anonymous caller gets200not401, and the engine-driven round dump concretely shows
concession_pct/cumulative_concession_pct/rationaleon the wire. That last one matters most:
checked the reconstruction claim mathematically (seller_price / (1 - cumulative_concession_pct)
does exactly reconstructbase_price), confirmed via the test's own sanity assertion, so a fix
that only dropped the top-level fields while serializing rounds raw would have been a no-op.Fix verified on a clean merge onto current
main: all 6 new tests pass, full suite 1573 passed,
ruff clean. The typedNegotiationRoundViewwhitelist genuinely closes the round-level leak —
verified only the 5 allowed fields ever appear on the wire.Cross-repo: zero impact, confirmed by grep — buyer-agent never calls this route and doesn't read
strategy/floor_price/max_rounds/cumulative_concession_pctanywhere; thebase_price/
floor_pricehits that do exist there are unrelated (product catalog pricing, a different
connector's deal object).The PR is upfront about its own remaining gap (authentication-scoped but not buyer-scoped — any
authenticated buyer can still read any proposal's negotiation, sinceNegotiationHistoryrecords
no buyer identity to scope against) rather than half-building buyer scoping into this diff. Its
sweep of sibling leaks (unauthenticated/products, the rate card, MCPlist_products, the chat
floor disclosure) checks out and is exactly what #97 and #98 in this same batch then go fix.
Breaking wire change + new 401 justifies the security warning on this one — reviewed deliberately
rather than rubber-stamped. Approving.
…unter-offer
Two information-disclosure leaks on paths a buyer actually reaches.
1. interfaces/chat/main.py stated the floor outright in prose on a
negotiation REJECT -- "Our floor for this inventory is $X CPM",
formatted from history.floor_price. The seller told its counterparty
its own floor, mid-negotiation. The branch is now a walk-away that
discloses no number. It also no longer interpolates the engine's
REJECT rationale, whose max-rounds variant reads "Maximum N rounds
reached" and publishes the remaining concession budget.
2. flows/proposal_handling_flow.py put "floor_price": product.floor_cpm
into state.counter_terms, which is returned to the buyer on EVERY
counter-offer -- so the floor crossed the wire on the normal
negotiation path, not on an obscure read. The key is removed.
A counterparty who knows the floor concedes nothing above it. These are
two of the four fields the shared Negotiation primitive excludes
deliberately (floor_price, base_price, strategy, NegotiationLimits), per
RECONCILIATION.md.
Removing a key from counter_terms is a wire change, so the buyer was
checked first at origin/main: ad_buyer/orchestration/multi_seller.py
reads only counter_terms["proposed_price"] and
counter_terms["negotiation_id"], and no buyer code reads
counter_terms["floor_price"]. The buyer's only floor_price consumer is
media_kit/client.py, which parses GET /media-kit/packages/{id} -- a
different surface, untouched here.
tests/unit/test_counter_and_chat_floor_disclosure.py (11 tests) pins the
two sites and adds a by-field-name sweep: neither path may carry
floor_price, base_price, strategy or max_rounds as a field name at any
nesting depth, so the next guardrail added to either payload fails
without anyone remembering to write a test. Complements the
route-specific test in #95 rather than duplicating it.
Known remaining gap, recorded not half-built: the negotiation engine's
rationale strings state the floor and the strategy in prose (e.g.
"Countering at the floor price $X CPM ... (round 1/5)") and reach the
buyer via counter_terms["reason"]. Changing that is a change to the
engine's wording, on a different surface, and is not made here.
…unter-offer (#97) * fix: stop disclosing the seller's floor price in chat and on every counter-offer Two information-disclosure leaks on paths a buyer actually reaches. 1. interfaces/chat/main.py stated the floor outright in prose on a negotiation REJECT -- "Our floor for this inventory is $X CPM", formatted from history.floor_price. The seller told its counterparty its own floor, mid-negotiation. The branch is now a walk-away that discloses no number. It also no longer interpolates the engine's REJECT rationale, whose max-rounds variant reads "Maximum N rounds reached" and publishes the remaining concession budget. 2. flows/proposal_handling_flow.py put "floor_price": product.floor_cpm into state.counter_terms, which is returned to the buyer on EVERY counter-offer -- so the floor crossed the wire on the normal negotiation path, not on an obscure read. The key is removed. A counterparty who knows the floor concedes nothing above it. These are two of the four fields the shared Negotiation primitive excludes deliberately (floor_price, base_price, strategy, NegotiationLimits), per RECONCILIATION.md. Removing a key from counter_terms is a wire change, so the buyer was checked first at origin/main: ad_buyer/orchestration/multi_seller.py reads only counter_terms["proposed_price"] and counter_terms["negotiation_id"], and no buyer code reads counter_terms["floor_price"]. The buyer's only floor_price consumer is media_kit/client.py, which parses GET /media-kit/packages/{id} -- a different surface, untouched here. tests/unit/test_counter_and_chat_floor_disclosure.py (11 tests) pins the two sites and adds a by-field-name sweep: neither path may carry floor_price, base_price, strategy or max_rounds as a field name at any nesting depth, so the next guardrail added to either payload fails without anyone remembering to write a test. Complements the route-specific test in #95 rather than duplicating it. Known remaining gap, recorded not half-built: the negotiation engine's rationale strings state the floor and the strategy in prose (e.g. "Countering at the floor price $X CPM ... (round 1/5)") and reach the buyer via counter_terms["reason"]. Changing that is a change to the engine's wording, on a different surface, and is not made here. * fix: two rationales — the buyer-facing one never names the floor, the strategy, or the round budget The negotiation engine's rationale strings stated the seller's floor, strategy and round budget in prose, and that sentence shipped to the buyer as counter_terms["reason"], in the chat counter/final-offer text, and on the REST negotiation responses — so the floor was still derivable in prose on the normal counter path even with floor_price removed from the structured payload (the known gap this PR recorded). The engine now emits both: the internal rationale is byte-identical to before and stays in logs and the stored negotiation history for audit, and a deliberately constructed buyer_rationale explains the action without stating the seller's price in prose (the structured seller_price carries the number unlabeled) and without naming the floor, the strategy, the concession budget or the round limit. Every outbound surface — counter_terms["reason"], the chat COUNTER/FINAL_OFFER text, the REST counter response, and the terminal-round mapper — sends only the buyer-facing one; the mapper falls back to empty for rounds stored before the field existed, never to the internal rationale. Engine pricing arithmetic (concession math, floor clamping, gap-split, acceptance conditions) is untouched; NegotiationRound is a seller-local model, so no shared-contract change. 14 new tests extend the existing disclosure file: per-action engine assertions that the buyer rationale carries no guardrail while the internal one still does, the flow's reason, the chat prose, the REST response, and the mapper fallback.
This is an information-disclosure fix on a public reference implementation. It changes the response shape of a GET route and adds an auth requirement to it. Please review the wire-shape change and the auth decision deliberately rather than rubber-stamping.
The defect
GET /proposals/{proposal_id}/negotiationhad no auth dependency at all — noDepends(...), noresponse_model— and returned whatevernegotiation_service.get_negotiation_statusproduced. That projection was effectively a dump of the internalNegotiationHistory, including:strategy— the seller's negotiation posture for this buyer tierbase_price— the seller's starting anchorfloor_price— the seller's absolute floormax_rounds— the remaining concession budget (withtotal_roundsalongside it)So any caller who knew or guessed a proposal id learned the seller's floor and how many concessions it had left. In a negotiation that is the one thing the seller must never reveal: a counterparty who knows the floor concedes nothing above it. Because this is a reference implementation, the leak was also a pattern others would copy.
These are exactly the four fields the shared
Negotiationprimitive excludes. FromRECONCILIATION.mdiniab-agentic-primitives: "floor_price,base_price,strategy, andNegotiationLimitsare deliberately excluded — they are the seller's internal guardrails and must never cross the wire."The leak did not stop at the top level. The response also serialized each
NegotiationRoundwhole, and the rounds carry the same information in derived form:cumulative_concession_pctis computed as(base_price − counter_price) / base_price, soseller_price / (1 − cumulative_concession_pct)reconstructs the seller'sbase_priceexactly — droppingbase_priceat the top level while serializing the rounds whole handed the anchor right back.rationaleon a below-floor offer states the floor in prose: "Countering at the floor price $X CPM — the minimum viable price for this inventory."What changed
1. The guardrails are dropped at the service, not at the route.
get_negotiation_statusnow projects only buyer-visible facts (negotiation_id,proposal_id,product_id,buyer_tier,status,total_rounds,rounds,started_at,completed_at,package_id). Fixing it at the service rather than filtering at the route means no consumer of the projection can re-expose them. The only internal consumer —apply_terminal_action→contract_mappers.terminal_round_response— reads justnegotiation_idandrounds, so nothing needed the removed fields.2. The wire shape is pinned — including the rounds. New
NegotiationStatusResponseis the route'sresponse_model, so a later addition to the history dict cannot silently reach the wire again.roundsis a typedNegotiationRoundViewwhitelistinground_number,buyer_price,seller_price,action,timestamp— not a passthrough of the internal round dumps.concession_pct/cumulative_concession_pct(the exactbase_pricereconstruction above) andrationale(the floor in prose) are excluded at the wire. The service keeps projecting full rounds becauseterminal_round_responselegitimately readsrationalefrom them internally; the filter lives in the response model, which is the boundary.Two deliberate scoping notes on the round filter:
rationalehere is boundary filtering on this read endpoint only. Whether the engine should ever put the floor into rationale text at all (it also reaches the buyer on the counter/terminal responses) is a separate engine-side decision that is still open — this PR does not preempt it either way.seller_pricenumerically equals the floor. The counter offer itself obviously stays on the wire; what no longer crosses is anything that labels it as the floor or lets the buyer reconstruct the anchor above it.3. The route requires a verified buyer. It now resolves a verified buyer context exactly the way its siblings do (
POST /proposals/{proposal_id}/counter,POST /api/v1/negotiations/messages) viadeps._get_optional_api_key_record+deps._verified_buyer_context. A GET carries no body identity to self-assert, so an unverifiable caller cannot be established as a party to the negotiation and gets401instead of another buyer's negotiation state.4. Forward-compat with #96:
NegotiationStatusResponsedeclaresquote_id: Optional[str] = None. #96 adds"quote_id": history.quote_idto the service projection for quote-led negotiations; with the response model now pinning the wire shape, the field has to be declared here or the merged pair would silently strip it.5.
docs/api/openapi.jsonregenerated with the repo's own generator (python scripts/generate_openapi.py— the same method #83's drift guard prescribes), sotest_openapi_driftpasses over the merged pair. The committed document onmainwas already stale, so the regeneration includes both that pre-existing drift and this PR's route changes; whichever of #83/#95 merges second regenerates once more on the (mechanical) conflict.Operator impact
Operators on an earlier build should assume floor prices and concession budgets were readable for every negotiation that existed on that build — directly via the top-level fields, and reconstructibly via the round concession percentages. Called out explicitly in the CHANGELOG for that reason.
Breaking for any client reading the removed fields. That is the point of the fix, but it is a wire change: clients reading
strategy/base_price/floor_price/max_roundsoff this route, orconcession_pct/cumulative_concession_pct/rationaleoff its rounds, will stop seeing them, and anonymous readers will now get401. No buyer-side consumer in this repo reads them.Known gap, deliberately not fixed here
The response is authentication-scoped, not buyer-scoped. An authenticated buyer can still read any proposal's negotiation.
NegotiationHistoryrecords no buyer identity — onlybuyer_tier— so there is nothing to compare the requesting buyer against. The nearest candidate,ProposalRequest.buyer_id, is an optional self-asserted body field in a different namespace fromBuyerContext.get_pricing_key(), and is absent entirely on quote-led negotiations. Real scoping needs a stored party identity on the history, which is a schema change beyond this defect. Recorded in the CHANGELOG rather than half-built.Tests
tests/unit/test_negotiation_status_disclosure.py(6 tests):strategy/base_price/floor_price/max_rounds, asserted against both the parsed body and the raw response text, plus the literal floor and base values so they cannot reappear under another key401and no negotiation data in the body200(the gate is authentication, not a blanket denial)negotiation_engine(viabuild_negotiation_engine→start_negotiation→evaluate_buyer_offer→record_round) with a below-floor buyer offer, so the recorded round genuinely carries a nonzerocumulative_concession_pct(sanity-asserted to reconstructbase_priceexactly pre-serialization) and a floor-naming rationale (sanity-asserted to contain the floor). The serialized response is then asserted to expose exactly the whitelisted round fields, to contain none ofconcession_pct/cumulative_concession_pct/rationale, to contain neither the base value nor the word "floor" anywhere, and to carry the floor value nowhere except as the counterseller_priceitselfquote_idpasses through the response model instead of being stripped (forward-compat with Honour an accepted negotiation's price when its quote is booked #96)Negative controls: with
src/reverted toorigin/main(leak restored), the original guardrail, service-projection, and401assertions fail, each reporting the actual leaked values. Separately, with only the typed rounds reverted tolist[dict](top-level fix kept), the engine-driven test fails namingcumulative_concession_pctandrationaleas having reached the wire — i.e., the new test catches exactly the round-level leak the original fixture (default0.0concession, hand-written rationale) could not see.Full unit suite: 1527 passed.
ruff checkandruff format --checkclean.Sibling leaks found during the sweep — reported, not fixed
This class of leak does not appear once. Out of scope for this PR; flagging so the extent is known.
Same field, other surfaces:
GET /productsandGET /products/{product_id}are unauthenticated and return the sharedProductwithext.floor_cpmpopulated from the product's floor (contract_mappers.py:504). Same information class, wider exposure, and the sharedPackagedroppedfloor_pricefor this exact reason.AuthenticatedPackageView(models/media_kit.py:250) exposesfloor_priceto any authenticated buyer.floor_cpmper product fromlist_products(mcp_server.py:388) and the configured default floor in setup status (mcp_server.py:237,:314).chat/main.py:544). Possibly an intentional product decision, but it is a floor disclosure.GET /api/v1/rate-cardis unauthenticated whilePUTon the same path is operator-gated, so the operator's stored base CPMs are world-readable.RECONCILIATION.mdmarks the rate card as never public.Assessed and cleared:
NEGOTIATION_STARTEDevent payload does carrystrategyandbase_price(negotiation_service.py:~260), but it is not externally visible:GET /eventsandGET /events/{event_id}both require an operator key, and the event module has no webhook, HTTP, or external-broker sink. Operator-only.deal_service.py:891emits afloor_pricekey in the Xandr deal export, but the value is the deal's agreed price — an ad-server field name, not the seller's internal floor.rounds[].cumulative_concession_pctas "contract-sanctioned derived inference." That assessment was wrong twice over: the reconstruction is exact, not an inference, and this route serializes the seller's internalNegotiationRoundmodel, not the shared wire primitive — so filtering it here is boundary hygiene, not a contract change. Fixed in this PR (see "What changed" §2). What remains genuinely contract-sanctioned:rounds_remainingon the counter response impliesmax_rounds, and the sharedNegotiationRound/NegotiationRoundResponseprimitives themselves carryrationaleon the active-negotiation surfaces — narrowing those is a contract discussion, not a fix here.Unauthenticated routes worth a deliberate public/private ruling (many are legitimately public — health, agent card, catalog):
GET /api/v1/rate-card,GET /api/v1/deals/export,GET /api/v1/deals/{deal_id},GET /api/v1/deals/{deal_id}/performance,GET /api/v1/deals/{deal_id}/lineage,GET /api/v1/quotes/{quote_id},GET /sessions,GET /sessions/{session_id},POST /sessions/{session_id}/messages,POST /sessions/{session_id}/close,POST /deals,POST /api/v1/deals/curated,POST /api/v1/deals/{deal_id}/deprecate,GET /registry/agents,GET /registry/agents/{agent_id}. The deal/quote/session reads are the same read-anyone's-record-by-id shape as the defect fixed here.