Repository navigation
refactor(a2a): every inbound A2A call needs a credential — drop the keyless trusted-network path; the trusted list becomes an outbound allowlist (ent#885) - #3684
Conversation
…the inbound half of the trusted list (ent#885) Operator ruling 2026-10-10 (ent#862), confirmed in the ent#885 body: a caller with no Trinity credential never runs a task, whatever network it arrives from. The admin trusted-networks list STAYS, outbound-only (operator ruling 2026-10-13, ent#885 comment 19:25Z): nothing inbound reads it any more. Removed in `routers/a2a.py`: `_InternalGate`/`_internal_gate`/`_UNSET`, `_card_base_url_for`, the three `A2A_INTERNAL_*` budgets, `caller_source` / `source_host=` threading through `_a2a_idem_scope`, `_run_a2a_task`, `_stream_task` and the AAuth-era `_dispatch_jsonrpc`, `_keyless_owns`, the `unexposed=` arm of `_anonymous_jsonrpc`, the `.refuse` gate on the bearer and AAuth doors, `source_host` in three audit `details`. `a2a_jsonrpc` is the two-arm `if current_user is None … else _authorize_inbound(...)` again; `_not_authenticated()` stays a returned JSONResponse (AAuth decorates it). `services/a2a_trusted_networks.py` loses its inbound half (`internal_source`, `_source_address`, `_proxy_ips`, the proxy/ingress constants, `internal_base_url`, `validate_internal_base_url`, `INTERNAL_BASE_URL_KEY`); the list + predicates + the gateway guard `set_entries` needs stay. The `get_a2a_scope`/`set_a2a_scope` mixin methods and their `database.py` delegators go; the columns from 0096 stay, unread. The settings route keeps its path and key but drops `internal_base_url`; the generic-PUT rung keeps `a2a_trusted_networks` only. TDD: `tests/unit/test_ent885_keyless_refused.py` drives the real router over the real unit SQLite with an rc-shaped row (`a2a_scope='internal'`, `a2a_keyless_internal=1`) and a seeded trusted CIDR. Red at the base: the tailnet peer got 200 + a dispatch, the keyed public caller and the card got 404. Its static "nothing reads the inert columns" assertion still names `source_host` in the dispatch plumbing — closed by the next commit. Tests re-pinned in this commit: the AAuth suite loses its `get_a2a_scope` stub and the two `test_internal_scope_*` cases (they patched the deleted `internal_source`); six keyed/x402 fakes lose their `get_a2a_scope` stub; `test_ent838` keeps its admin-list + outbound + gateway-guard groups only. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The FREEZE-classified `source_host` field was only ever set by the keyless
inbound door removed in the previous commit ("a declared field is not a sent
field", learning 2026-10-08). Gone from `execute_task`'s signature and both
`TaskExecutionFields(...)` sites, `DISPATCH_FIELDS`, `TaskExecutionFields`,
`ScheduleExecution`, and the row mapper. The `schedule_executions.source_host`
column stays in the DDL + 0096 + its SQLite twin, unwritten and unread (binding:
no drop-column migration in freeze week). `test_1482`'s field set shrinks with it
and `test_ent885`'s static assertion is now green.
`tests/registry.json`: the ent#838 entry is reworded outbound-only; the new
`test_ent885_keyless_refused.py` entry is added.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…both sides (ent#885) Operator ruling 2026-10-13 (ent#885 comment 19:25Z + the body's "Kept" section): the admin trusted-networks list stays and governs OUTBOUND ONLY. An A2A endpoint on a CGNAT address or plain http:// may be registered and called only when its host is on the list; a host off it gets the v0.9.5 SSRF refusal. Managed instances talk over a tailnet on plain http, and the keyed builder-agent cross-instance test (ent#762) must run there before 1.0; token sign-in (ent#862) stays post-1.0. `tests/unit/test_ent885_outbound_allowlist.py` drives the two production readers of the stored list — `a2a_outbound.upsert_endpoint` (register) and `a2a_client.validate_endpoint` (every call) — with the list written through the real `set_entries` into the real unit SQLite and the peer name resolving to 100.70.1.2. Off the list: EndpointValidationError at register, A2ACallError(endpoint_not_https / endpoint_private_address) at call time. On the list (CIDR, *.suffix or exact name): registered and called on port 80. A stranger's name under another suffix gets nothing; un-listing refuses the next call. Neither reader is handed `trusted_entries=` — the stored row is what admits the host. No code change in this commit: the `url_validation.py` hook and the outbound half of `services/a2a_trusted_networks.py` were kept as ruled; the inbound half went in the first ent#885 commit. Registry entry added. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The keyless trusted-network door and the internal-only scope are gone on
the backend, so the A2A tab loses its "Who can reach it" select, the
keyless toggle, the internal-URL row and the internal cardUrl branch (and
the four base-primitive imports only that block used). setA2aExposure is
back to (name, enabled) and PUTs exactly { enabled }.
The outbound endpoint copy now names the admin's outbound endpoint
allowlist as the one way a private-network or plain-http:// endpoint is
accepted (setting key and route unchanged). The Vite /a2a/ X-Real-IP
overwrite stays (T3 keep), its comment now citing
routers/public.py::_get_client_ip (#181) rather than the trusted-network
check that no longer exists.
a2aPanelScope.spec.js is replaced by a2aPanelNoScope.spec.js, mounted
over the real store with axios stubbed: red before this change on the
three removed behaviours (scope controls rendered, internal card URL,
store forwarding scope/keyless), green after.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…allowlist (ent#885) The admin's a2a_trusted_networks list now governs outbound only, so the docs that described the keyless trusted-network door are rewritten, not deleted: - requirements mcp.md §32.7: "A2A Outbound Endpoint Allowlist" — the list admits a private/CGNAT or plain-http:// endpoint at register and call time; nothing inbound reads it; the inert rc-era columns are named. - feature-flows a2a-inbound-server.md: the trusted-network section becomes the outbound allowlist, with what was removed before 1.0 listed. - feature-flows a2a-outbound-call.md: the SSRF bullets and the two refusal rows name the allowlist as the single exception. - architecture security.md §5b: outbound-only; inbound needs a credential or a payment from any network. - user-docs a2a-protocol.md "Between your own instances": a keyed call to a tailnet peer once its host is on the admin list; the keyless step and the scope select are gone. No release-notes line (binding: the keyless path never shipped stable). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
test_ent885_keyless_refused seeds a2a_trusted_networks = ["100.64.0.0/10"] and an rc-era agent_ownership row into the island's shared SQLite and left them there. The outbound validator does read that list, so under pytest-randomly any later test asserting "CGNAT is refused" admitted the address: with randomization off, keyless_refused followed by test_736_a2a_url_validation fails 3 cases (DID NOT RAISE) before this change and passes after. The door fixture now yields and deletes both rows, and drops the trusted-networks entry cache if another test loaded it (this file still never imports the module). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ent#885) The static guard's leading \b meant `get_a2a_scope` never matched (`_` is a word character), so a re-added scope read written through getattr slipped past it. Dropping the leading boundary on the two column names closes that; `source_host` keeps it. Mutation: a getattr(db, "get_a2a_scope", ...) read in a2a_jsonrpc with no behaviour change was green before, is red now. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…#885) - db/tables.py + db/schema.py: the a2a_scope / a2a_keyless_internal column comments described the removed inbound keyless door; they now read "inert since ent#885; drop under #746" on both tracks (comment-only, no migration — the columns stay in the DDL per the ruling). - requirements/mcp.md §32.7: status 🚧 In Progress → ✅ Implemented, outbound allowlist only. A grep of docs/ found no other stale keyless-A2A / In Progress claim; the remaining "keyless" hits are the #848 MCP email sign-in tier. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CodeQL py/incomplete-url-substring-sanitization (#434, #435): the registered-host assertions checked "peer.tail1234.ts.net" in the URL string. Compare urlsplit(url).hostname exactly instead. No other ent885/ent838 test file carries the pattern. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
trinity-ability
left a comment
There was a problem hiding this comment.
PR Validation Report — /validate-pr
PR: #3684, ent#885: drop the keyless trusted-network A2A path; the trusted list becomes outbound-only
Branch: feature/885-drop-keyless-a2a @ 84fe4d358 → dev · Files: 36 (+654/−889)
Lane: B, with the schema flag set. No Lane C path is touched. The schema hunks change comments only, and routers/a2a.py is an auth surface, so I read it at Lane C depth anyway.
| Category | Status | Notes |
|---|---|---|
| Commit messages / base branch / size | ✅ | Conventional prefixes; targets dev; 36 files |
| Status auto-promotion | Fixes abilityai/trinity-enterprise#885 is a cross-tracker link, so it won't promote itself. Set status-in-dev on ent#885 by hand after merge; it closes at release |
|
| Requirements / architecture / flows | ✅ | mcp.md §32.7, security.md §5b, both A2A flows and the user docs are rewritten as outbound-only, not deleted |
| Enterprise disclosure | ✅ | The guard pattern finds 0 matches in added lines |
| Security §4.1–4.10 | ✅ | No keys, emails, .env files, hardcoded secrets, mode flips or host paths. The IPs that appear (100.64.0.0, 100.70.1.2, 8.8.8.8, 1.2.3.4) are CGNAT/public test fixtures |
+schema |
✅ | No DDL change. schema.py and tables.py only add "inert since ent#885" comments. Migration 0096 and its SQLite twin are kept on purpose, so no new revision and no fork |
| Config / build packaging | ➖ | No new env reads; no new backend directory |
| Binding ruling (2026-10-13T19:25Z) | ✅ | The only readers of a2a_trusted_networks are utils/url_validation.py:881 (outbound, via _validate_trusted_a2a_url) and the admin routes in routers/settings/integrations.py:713,730. Nothing inbound reads it. a2a_jsonrpc now sends every caller without credentials straight to _anonymous_jsonrpc, with no source lookup |
| Leftovers | ✅ | grep over src/ and tests/ for internal_source, _proxy_ips, internal_base_url, _internal_gate, get/set_a2a_scope, caller_source finds nothing. The only remaining matches are the inert columns and tests that assert nothing reads them |
| Structural review | ✅ | /review and /cso --diff are on file (ent-885-review.md): 0 critical, 0 CSO findings, attack surface shrinks |
| Merge gate | ✅ | Tier 1 green, including regression diff and pytest (head, seed 12345). journey-smoke and frontend-e2e/e2e also passed on the PR. dev-ci is success; no open dev-ci red issue |
Execution coverage (§5.4)
| Test file / changed symbol | What runs it | Live consumer | Verdict |
|---|---|---|---|
Keyless door removed from a2a_jsonrpc |
test_ent885_keyless_refused.py: drives the real route with no credential for each method |
routers/a2a.py::a2a_jsonrpc |
✅ |
| Outbound allowlist (registration and call time) | test_ent885_outbound_allowlist.py: both sides, host on and off the list |
url_validation.py:878-885 |
✅ This pins kept behaviour, so it passes on dev too, as it should |
A2aPanel.vue scope controls removed |
a2aPanelNoScope.spec.js: jsdom mount over the real store, PUT body asserted |
the component | ✅ (mounted, not source-text) |
Mutation: I ran the PR's new tests against dev's code (5a52f35). test_ent885_keyless_refused.py goes red: 7 of 9 tests fail, including every test_a_trusted_peer_with_no_credential_is_refused[...] case. The regression test really does test the behaviour.
Local run in the PR worktree: 451 of 452 passed across 14 related files: A2A (157/736/838/623/679/751/885), 1482, 3215, 186, 1028, ent435.
Warnings
⚠️ Flaky, not caused by this PR.test_ent751_gate_callers.py::TestPortal::test_a_held_turn_is_answered_with_the_notice_never_failedfailed in 2 of 11 runs on the PR head; both failures came in the first two runs. It passed in the next 9 runs, and in 5 of 5 runs ondev. The PR changes nothing in that file's portal path: it drops oneget_a2a_scopestub from the A2A fixture and thesource_hostFREEZE entry fromDISPATCH_FIELDS, neither of which the portal turn reads. CIregression diffis green. Recorded so whoever next sees it red has a starting point; it does not block merge.
Recommendation
APPROVE. The inbound keyless door, the internal-only scope and the source-address code are gone. Every inbound principal is now a key, an x402 payment or a verified AAuth identity. The trusted list is kept outbound-only, as the operator ruled. A two-sided test covers that list, and a mutation-proven regression test covers the removed door. The live B11 proof on rc4 is a later ops step and is outside this review.
…text (#3657) — mechanical, per the merge-train note on the PR Resolves the adjacent-line collision with #3684 (ent#885) in skill_gate_service.DISPATCH_FIELDS and task_execution_service.execute_task: #3684 removed the source_host lines, this PR adds source_channel_agent beside them. Resolution keeps source_channel_agent and drops source_host, matching dev. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Fixes abilityai/trinity-enterprise#885. One of the freeze-week chains (chain-freeze-1013). This one gates rc4 (
/release-planv1.0.0 cluster C2 / M2). Draft until the operator merges.What
Every inbound A2A call needs a credential again: a Trinity key, an x402 payment, or a verified AAuth identity. Nothing is let through because of the network the call came from. This removes the keyless "trusted network" inbound path that #3361 added. The path was on
devthrough rc1–rc3 and never shipped in a stable release.routers/a2a.pyloses the keyless branch, its rate limiters, the source-address checks and_InternalGate. Also removed:get/set_a2a_scopemixin and its delegators;source_hostdispatch plumbing;services/a2a_trusted_networks.py;dev.100.64.0.0/10, e.g. a Tailscale peer) or on plainhttp://can be registered and called only when its host is on the list.PUT /api/settings/a2a-trusted-networksare unchanged; there is no rename in freeze week.A2aPanel.vueloses its scope select, keyless toggle and internal-URL row.stores/agents.js::setA2aExposure(name, enabled)goes back to its v0.9.5 shape.0096and its SQLite twin. Upgraded rc databases already carry them, so the columns stay, unread, with comments marking them inert. A drop goes under Collapse schema.py + migrations.py into single source of truth (follow-up to #713) #746 after 1.0.X-Real-IPline, which protects dev-mode rate limiting.devwith feat(aauth): outbound resource client + call_aauth_resource — an agent calls an external AAuth resource through the full challenge ladder (abilityai/trinity-enterprise#879) #3667. The AAuth hooks, the_not_authenticated()returned response that AAuth decorates, and the outbound client are all intact.Why the outbound list stays
Managed instances talk to each other over a tailnet on plain HTTP. The builder-agent cross-instance test (ent#762, a keyed call) has to run there before 1.0. Token sign-in between instances (ent#862) stays after 1.0.
Review + security
/review(Fable, report-only): MERGEABLE, 0 critical findings._anonymous_jsonrpc,_not_authenticated,_serve_cardand_card_base_urlare byte-identical to the pre-feat(a2a): trusted internal networks — call agents across your own instances over a private network without keys (abilityai/trinity-enterprise#838) #3361 tree.internal, keyless=1.dev.Both informational items are fixed in
897c7f9a9: the inert column comments and the §32.7 status line./cso --diff: zero findings.Tests
test_ent885_keyless_refused.py. It is red ondevin 7 of 10 cases and green on this branch, so it is a real regression test.test_ent885_outbound_allowlist.py. It goes through the real stored list in both directions: on the list → registered and called; off the list → refused; removed from the list → the next call is refused.get_a2a_scoperead, and puttingsource_hostback inDISPATCH_FIELDS, each turn the static pin red.Disclosures
a2a_scope='internal'is now treated as a public agent with a credential required, never as keyless.Before merge
🤖 Generated with Claude Code