Skip to content

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

Merged
vybe merged 10 commits into
devfrom
feature/885-drop-keyless-a2a
Oct 14, 2026
Merged

vybe merged 10 commits into
devfrom
feature/885-drop-keyless-a2a

Conversation

@vybe

@vybe vybe commented Oct 13, 2026

Copy link
Copy Markdown
Contributor

Fixes abilityai/trinity-enterprise#885. One of the freeze-week chains (chain-freeze-1013). This one gates rc4 (/release-plan v1.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 dev through rc1–rc3 and never shipped in a stable release.

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.

Both informational items are fixed in 897c7f9a9: the inert column comments and the §32.7 status line. /cso --diff: zero findings.

Tests

  • New: test_ent885_keyless_refused.py. It is red on dev in 7 of 10 cases and green on this branch, so it is a real regression test.
  • New: 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.
  • Verification set: 509 + 375 passed, plus 31 AAuth tests. Vitest: the panel spec and the three ratchets passed.
  • Mutations: re-adding a get_a2a_scope read, and putting source_host back in DISPATCH_FIELDS, each turn the static pin red.

Disclosures

  • A database row hand-edited to a2a_scope='internal' is now treated as a public agent with a credential required, never as keyless.
  • An endpoint registered on an rc with a private or plain-http URL now fails at call time with a named error unless its host is on the admin list. It fails closed.
  • No trusted-networks UI control exists in the OSS frontend or the enterprise submodule, so the ruling's "UI wording" item has nothing to change. The list is set through the API.
  • No release-notes line: the keyless path never shipped in a stable release.

Before merge

  • On rc4: run ent#847 row B11 as "keyless call refused" plus the keyed A2A smoke test, and record the build SHA in ent#847.

🤖 Generated with Claude Code

Trinity Agent (trinity) and others added 9 commits October 13, 2026 15:50
…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>
@vybe vybe added the ui PR touches the frontend UI — triggers Playwright e2e tests label Oct 13, 2026
Comment thread tests/unit/test_ent885_outbound_allowlist.py Fixed
Comment thread tests/unit/test_ent885_outbound_allowlist.py Fixed
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 trinity-ability 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.

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_failed failed 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 on dev. The PR changes nothing in that file's portal path: it drops one get_a2a_scope stub from the A2A fixture and the source_host FREEZE entry from DISPATCH_FIELDS, neither of which the portal turn reads. CI regression diff is 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.

@vybe
vybe marked this pull request as ready for review October 13, 2026 23:46
@vybe
vybe merged commit 5ef36f0 into dev Oct 14, 2026
28 checks passed
vybe pushed a commit that referenced this pull request Oct 14, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ui PR touches the frontend UI — triggers Playwright e2e tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants