Skip to content

fix: stop leaking the seller's floor price and concession budget on negotiation reads - #95

Merged
Sirajmx merged 2 commits into
mainfrom
fix/negotiation-status-leak
Sep 30, 2026
Merged

Sirajmx merged 2 commits into
mainfrom
fix/negotiation-status-leak

Conversation

@atc964

@atc964 atc964 commented Sep 17, 2026 •

Copy link
Copy Markdown
Collaborator

⚠️ Security-relevant — please do not merge casually

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}/negotiation had no auth dependency at all — no Depends(...), no response_model — and returned whatever negotiation_service.get_negotiation_status produced. That projection was effectively a dump of the internal NegotiationHistory, including:

  • strategy — the seller's negotiation posture for this buyer tier
  • base_price — the seller's starting anchor
  • floor_price — the seller's absolute floor
  • max_rounds — the remaining concession budget (with total_rounds alongside 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 Negotiation primitive excludes. From RECONCILIATION.md in iab-agentic-primitives: "floor_price, base_price, strategy, and NegotiationLimits are 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 NegotiationRound whole, and the rounds carry the same information in derived form:

  • cumulative_concession_pct is computed as (base_price − counter_price) / base_price, so seller_price / (1 − cumulative_concession_pct) reconstructs the seller's base_price exactly — dropping base_price at the top level while serializing the rounds whole handed the anchor right back.
  • rationale on 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_status now 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 just negotiation_id and rounds, so nothing needed the removed fields.

2. The wire shape is pinned — including the rounds. New NegotiationStatusResponse is the route's response_model, so a later addition to the history dict cannot silently reach the wire again. rounds is a typed NegotiationRoundView whitelisting round_number, buyer_price, seller_price, action, timestamp — not a passthrough of the internal round dumps. concession_pct / cumulative_concession_pct (the exact base_price reconstruction above) and rationale (the floor in prose) are excluded at the wire. The service keeps projecting full rounds because terminal_round_response legitimately reads rationale from them internally; the filter lives in the response model, which is the boundary.

Two deliberate scoping notes on the round filter:

  • Excluding rationale here 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.
  • The engine counters every below-floor offer at the floor, so on such a round the counter seller_price numerically 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) via deps._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 gets 401 instead of another buyer's negotiation state.

4. Forward-compat with #96: NegotiationStatusResponse declares quote_id: Optional[str] = None. #96 adds "quote_id": history.quote_id to 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.json regenerated with the repo's own generator (python scripts/generate_openapi.py — the same method #83's drift guard prescribes), so test_openapi_drift passes over the merged pair. The committed document on main was 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_rounds off this route, or concession_pct / cumulative_concession_pct / rationale off its rounds, will stop seeing them, and anonymous readers will now get 401. 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. NegotiationHistory records no buyer identity — only buyer_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 from BuyerContext.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):

  • the response carries none of 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 key
  • the service projection itself omits them, so a future consumer cannot re-expose them
  • an anonymous caller gets 401 and no negotiation data in the body
  • an authenticated caller still gets 200 (the gate is authentication, not a blanket denial)
  • rounds produced by the real engine leak nothing: the fixture drives negotiation_engine (via build_negotiation_engine → start_negotiation → evaluate_buyer_offer → record_round) with a below-floor buyer offer, so the recorded round genuinely carries a nonzero cumulative_concession_pct (sanity-asserted to reconstruct base_price exactly 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 of concession_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 counter seller_price itself
  • quote_id passes 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 to origin/main (leak restored), the original guardrail, service-projection, and 401 assertions fail, each reporting the actual leaked values. Separately, with only the typed rounds reverted to list[dict] (top-level fix kept), the engine-driven test fails naming cumulative_concession_pct and rationale as having reached the wire — i.e., the new test catches exactly the round-level leak the original fixture (default 0.0 concession, hand-written rationale) could not see.

Full unit suite: 1527 passed. ruff check and ruff format --check clean.

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 /products and GET /products/{product_id} are unauthenticated and return the shared Product with ext.floor_cpm populated from the product's floor (contract_mappers.py:504). Same information class, wider exposure, and the shared Package dropped floor_price for this exact reason.
  • AuthenticatedPackageView (models/media_kit.py:250) exposes floor_price to any authenticated buyer.
  • The MCP surface returns floor_cpm per product from list_products (mcp_server.py:388) and the configured default floor in setup status (mcp_server.py:237, :314).
  • The chat adapter states the floor outright in its walk-away message: "Our floor for this inventory is $X CPM" (chat/main.py:544). Possibly an intentional product decision, but it is a floor disclosure.
  • GET /api/v1/rate-card is unauthenticated while PUT on the same path is operator-gated, so the operator's stored base CPMs are world-readable. RECONCILIATION.md marks the rate card as never public.

Assessed and cleared:

  • The NEGOTIATION_STARTED event payload does carry strategy and base_price (negotiation_service.py:~260), but it is not externally visible: GET /events and GET /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:891 emits a floor_price key 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.
  • An earlier revision of this PR cleared rounds[].cumulative_concession_pct as "contract-sanctioned derived inference." That assessment was wrong twice over: the reconstruction is exact, not an inference, and this route serializes the seller's internal NegotiationRound model, 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_remaining on the counter response implies max_rounds, and the shared NegotiationRound / NegotiationRoundResponse primitives themselves carry rationale on 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.

…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.
@atc964
atc964 force-pushed the fix/negotiation-status-leak branch from 4f4081e to 85417ac Compare September 22, 2026 18:56

@Sirajmx Sirajmx left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed the defect live on current main before touching the fix: the route has no
Depends(...) and no response_model at all, so it returns the raw service projection —
strategy, base_price, floor_price, max_rounds included.

Ran a real negative control myself, not just the PR's claimed numbers: mounted the new test file
into a container running current main (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 gets 200 not 401, and the engine-driven round dump concretely shows
concession_pct/cumulative_concession_pct/rationale on the wire. That last one matters most:
checked the reconstruction claim mathematically (seller_price / (1 - cumulative_concession_pct)
does exactly reconstruct base_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 typed NegotiationRoundView whitelist 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_pct anywhere; the base_price/
floor_price hits 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, since NegotiationHistory records
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, MCP list_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.

@Sirajmx
Sirajmx merged commit c5bbbc7 into main Sep 30, 2026
5 checks passed
atc964 added a commit that referenced this pull request Sep 30, 2026
…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.
Sirajmx pushed a commit that referenced this pull request Oct 1, 2026
…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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants