Skip to content

feat(adagents)!: attribute properties to a publisher domain, gate permissive resolution, verify cross-domain claims - #1711

Open
bokelley wants to merge 3 commits into
nextfrom
claude/issue-1697-adagents-publisher-domain
Open

bokelley wants to merge 3 commits into
nextfrom
claude/issue-1697-adagents-publisher-domain

Conversation

@bokelley

Copy link
Copy Markdown
Contributor

Problem

get_properties_by_agent / resolve_properties_for_agent say which properties an agent is authorized for in one adagents.json. A sales agent syncing that file for publisher domain D then had to make three more authorization decisions itself, and every implementation made them differently (#1697).

Fix

1. properties_for_publisher_domain(properties, D, *, include_domainless=False)

This picks out D's properties from a shared or network file:

  • A declared publisher_domain wins when it names D. Identifiers are not consulted.
  • Otherwise, a type: "domain" identifier naming D or any subdomain counts (www., m., shop., *.D).
  • Domainless properties (bundle-only apps, venues) are excluded unless include_domainless=True.
  • www.D is treated as D, except www.com-style names. Hosts compare in A-label form.
  • D must be registrable: an IP, a single label or a public suffix (co.uk, github.io) raises.

2. Permissive resolution is gated (breaking)

resolve_properties_for_agent(mode="permissive") now requires publisher_domain= (the domain the document was fetched for), or an explicit allow_any_domain=True.

  • With the gate on, the bare-entry fallback keeps only properties whose identifiers are all type: "domain" under that domain.
  • It drops properties whose own publisher_domain names another domain, and properties carrying bundle, store or venue identifiers. A domain can't vouch for those.
  • Typed selectors and strict mode are unchanged.

3. verify_cross_domain_properties(properties, *, source_domain, agent_url, fetcher=None, ...)

A claim in A's file about another publisher's domain B is kept only when B vouches. Vouching means the document governing B, fetched with fetch_adagents, authorizes the agent (strict resolution, B's revocations honored) for every identifier of the property. Because fetch_adagents follows B's authoritative_location, and B's ads.txt MANAGERDOMAIN when B has no file, the same check covers "B authorizes the agent" and "B delegates to A".

  • A domain identifier is vouched for by its registrable domain (eTLD+1 under the pinned PSL, so private-suffix tenants vouch only for themselves).
  • A declared publisher_domain is vouched for by that domain.
  • Claims on A or its subdomains need no fetch.
  • The result has verified, unverified (with domain and a reason: not_vouched, fetch_failed, invalid_domain or domain_limit) and domainless.

Conservative choices to review

These are the places where I picked the stricter option. None of them weakens an existing check.

  • Permissive mode raises without a domain, rather than defaulting to ungated. allow_any_domain=True is the explicit escape hatch for the SDK 9 behavior.
  • The permissive gate requires all identifiers to be domains under D. The issue said "a domain identifier naming D", but that alone would let [D, victim.com] or [D, bundle:com.victim] through.
  • A property whose publisher_domain contradicts D is dropped rather than ignored.
  • Cross-domain checks require every named publisher to vouch for the whole property.
    • A relay publisher can't vouch for a victim's domain: the identifier's own registrable domain must also vouch.
    • A bundle bolted onto a vouched site fails unless B lists the bundle too.
  • Every fetch failure, a malformed document, and a bare entry in B's file vouch for nothing. Fan-out is capped at max_domains=50.
  • MANAGERDOMAIN counts only when B publishes no file, per fetch_adagents. If B's own file doesn't authorize the agent, that is B's answer.
  • The issue also floated a verify_cross_domain=True flag on the resolver. I implemented the async helper with an injectable fetcher instead, since the resolver is sync.

Tests

tests/test_adagents_publisher_domain.py has 49 tests, written from the attacker's side:

  • lookalike, suffix and IDN domains
  • mixed-domain and relabelled properties
  • victim sites, bundles and venues claimed through a bare entry
  • relay vouching and bundle bolt-ons
  • private-suffix tenants
  • fetch failures of every kind
  • the fan-out bound
  • delegation through authoritative_location and MANAGERDOMAIN with real fetch_adagents over a mock transport

Existing permissive tests now pass allow_any_domain=True (or the fetched domain). All tests/test_adagents*.py pass locally (379). ruff, black and mypy are clean on the touched modules. I'm relying on CI for the full suite: the shared VM is saturated by parallel workstreams, and a local full run timed out.

BREAKING CHANGE: resolve_properties_for_agent with mode permissive raises ValueError unless publisher_domain or allow_any_domain=True is passed, and with publisher_domain its bare-entry fallback returns only domain properties under that domain. See MIGRATION_v9_to_v10.md.

Closes #1697

🤖 Generated with Claude Code

…missive resolution, verify cross-domain claims

A sales agent syncing adagents.json for publisher domain D had three
authorization decisions left to it, and each implementation made them
differently (#1697):

- properties_for_publisher_domain(properties, D) answers "which of these
  authorized properties are D's" for shared and network files. A declared
  publisher_domain wins; otherwise a type=domain identifier naming D or any
  subdomain (www., m., shop., *.D) attributes the property. Domainless
  properties (bundle-only apps, venues) are excluded unless
  include_domainless=True. www.D is treated as D; hosts compare in A-label
  form; D must be registrable (an IP, single label or public suffix such as
  co.uk or github.io raises).

- resolve_properties_for_agent(mode="permissive") now requires
  publisher_domain=, the domain the document was fetched for. The bare-entry
  fallback keeps only properties whose identifiers are all type=domain under
  that domain, so a file on attacker.example can no longer bare-list an agent
  and claim another publisher's site, app bundle or DOOH venue. A property
  whose own publisher_domain names another domain, or that carries a bundle,
  store or venue identifier, is dropped. allow_any_domain=True keeps the
  SDK 9 result. Typed selectors and strict mode are unchanged.

- verify_cross_domain_properties(properties, source_domain=A, agent_url=...)
  keeps a claim about another publisher's domain B only when B vouches: the
  document governing B, fetched with fetch_adagents (so B's
  authoritative_location, or its ads.txt MANAGERDOMAIN when B has no file,
  is followed), authorizes the agent under strict resolution for every
  identifier of the property. A domain identifier is vouched for by its
  registrable domain; a declared publisher_domain by that domain. It fails
  closed: every named publisher must vouch for the whole property, any fetch
  failure or bare entry vouches for nothing, unusable domain values are
  invalid_domain, and at most max_domains (50) distinct domains are fetched.
  The fetcher is injectable.

Tests are written from the attacker's side: lookalike and suffix domains,
mixed-domain and relabelled properties, bundles bolted onto a vouched site,
a relay publisher vouching for a victim, private-suffix tenants, fetch
failures, fan-out bounds, and delegation through authoritative_location and
MANAGERDOMAIN with real fetch_adagents over a mock transport.

BREAKING CHANGE: resolve_properties_for_agent(mode="permissive") raises
ValueError unless publisher_domain= (or allow_any_domain=True) is passed,
and with publisher_domain its bare-entry fallback returns only domain
properties under that domain. See MIGRATION_v9_to_v10.md.

Closes #1697

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

@aao-secretariat aao-secretariat Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Ladon verdict: Approve

Approve — clean review, no blocking or medium findings.

Checked:

  • Security gate (fail-closed): _only_names_domain (adagents.py L371-384) drops bare-entry properties unless every identifier is type:"domain" under the fetched domain and a declared publisher_domain names it; [D, victim.com] and [D, bundle] are correctly rejected. _vouching_domains (L474-505) returns the offending raw value on any unusable host and reduces to eTLD+1, so a relay file cannot vouch for a victim's registrable domain. _claim_covered (L643-650) requires every named publisher to cover every identifier under strict resolution.
  • Bounds/ops: fan-out bounded via to_fetch[:max_domains] (L598); every fetch failure returns fetch_failed (L606-608), not a verified claim; default fetcher has a 10s timeout; max_concurrency semaphore caps parallelism; _authorized swallows Exception → None so asyncio.gather cannot propagate.
  • Semver signal: breaking API change (permissive mode raises without publisher_domain=) carries feat(adagents)!:, a BREAKING CHANGE footer, and a MIGRATION_v9_to_v10.md entry — the mandatory public-surface semver gate is satisfied.
  • Public surface: new exports wired consistently through all three src/adcp/__init__.py sites; they are utility functions (not ADCPHandler methods), so the AGENTS.md handler-table requirement does not apply.

high_risk is true only because src/adcp/__init__.py is modified, but the reviewer found no medium-or-higher concerns on it, so the modification is presumed safe (row 5 does not fire without a medium finding). gated_paths is false; no author-team gate; no prior decision. No rows 1–8 trigger — falls through to row 9.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

@aao-secretariat aao-secretariat Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Ladon verdict: Approve

Approve — subsequent pass is clean, net improvement over prior.

Delta since the prior approve is a single file: the generated tests/fixtures/public_api_snapshot.json, recording six purely-additive public names (AdagentsFetcher, CrossDomainFailureReason, CrossDomainVerification, UnverifiedProperty, properties_for_publisher_domain, verify_cross_domain_properties). No removals, renames, or signature changes — the snapshot gate stays armed. The breaking behavior change (resolve_properties_for_agent(mode="permissive") now requiring publisher_domain=) is correctly carried by the feat(adagents)!: commit and documented in MIGRATION_v9_to_v10.md, satisfying the semver-signal rule.

Decision-table walk: no critical/high/medium findings (row 1 N/A). gated_paths is false (row 2 N/A). high_risk is true via src/adcp/__init__.py (modified), but no medium-or-higher finding on it, so rows 3–5 do not fire — a modified high-risk file with no concern is presumed safe. Prior decision was approve, not escalate (row 6 N/A). No no-auto-approve team match (row 7 N/A). Zero medium findings (row 8 N/A). Falls through to row 9 → approve.

…pace

Its snapshot fingerprint differs on Python 3.10, where typing.Any is a
_SpecialForm. It stays importable from adcp.adagents.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

@aao-secretariat aao-secretariat Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Ladon verdict: Approve

Approve — delta-only pass since the prior approve, clean with no new findings.

What I checked:

  • The only meaningful change since the prior approve is un-exporting the AdagentsFetcher type alias from the public adcp.* surface (lazy map, __all__, TYPE_CHECKING block, and the API snapshot fixture). The reviewer confirms removal is complete with no dangling references and the alias remains defined in adagents.py for the fetcher= annotation.
  • No semver concern: the alias was introduced by this same unreleased PR, so trimming it before cut is not a public-surface break under the repo's semver-signal rule.
  • high_risk is true only because src/adcp/__init__.py is (modified). Per the decision rules, a modification with no medium-or-higher finding is presumed safe — not an escalation trigger (row 5 requires a medium finding; none exist).
  • gated_paths is false and review_decision is APPROVED, so no hard gate applies. No no-auto-approve team match.

Decision table: rows 1–8 do not fire (no critical/high findings, gated_paths false, no deletions, no medium findings, prior decision was approve, no team gate). Falls through to row 9 → approve.

Copy link
Copy Markdown
Contributor Author

Feedback from the Scope3 sales agent, which syncs publishers' adagents.json files and will adopt this. The attribution helper and the shape of verify_cross_domain_properties are what we need. We'd like two changes before merge, both about reporting a property the SDK can't authorize instead of dropping it.

1. Let the permissive gate report what it excludes

With publisher_domain=, resolve_properties_for_agent(mode="permissive") returns fewer properties and says nothing about the rest. A sales agent can't tell "the publisher listed nothing" apart from "the publisher listed 12 apps and 3 sites on a partner's domain, and the gate removed them". allow_any_domain=True is the only way to see them, and its docstring says "use it only for diagnostics".

Many sellers are onboarding while publisher files are still incomplete. For them, the right production behavior is to keep an unauthorized claim and mark it, so the seller and the buyer can see the status and the operator can decide when to enforce. Two ways to support that:

  • (preferred) Document allow_any_domain=True + properties_for_publisher_domain(..., include_domainless=True) + verify_cross_domain_properties(...) as a supported production path, not diagnostics. verify_cross_domain_properties already partitions the gated-out cases: other-domain claims go to unverified with a reason, and bundle, store and venue properties go to domainless.
  • Or have the gated resolve return the excluded properties alongside the kept ones, each with a reason (e.g. other_publisher_domain, non_domain_identifier), the way CrossDomainVerification does.

One case to note either way: _only_names_domain also drops a property whose identifiers are a domain under D plus a bundle ID, such as a publisher's site and its app listed together. That's the publisher's own inventory with an identifier a domain can't vouch for. Silently dropping it loses the site too.

2. Neutral wording for unverified

The CrossDomainVerification docstring says unverified is "cross-domain claims to drop". The API leaves the decision to the caller, which is right. Something like "cross-domain claims no named publisher vouched for, with the reason" would make it clear that marking them is a valid choice.

A related point on the reasons: fetch_failed is not a "no". We plan to treat it as pending and retry, not as unauthorized. If the docs say so, other implementations won't turn one timeout into a catalog-wide failure.

Neither change affects strict mode or typed selectors, and we agree that explicit revocations and agent mismatches should still be excluded outright.

This branch has not been deployed

No deployments
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.

1 participant