From 61472653c8b789e074a0db7302e8de66c8cbfbf0 Mon Sep 17 00:00:00 2001 From: Andrii Pasternak <44377756+AndriiPasternak31@users.noreply.github.com> Date: Mon, 28 Sep 2026 01:14:37 +0100 Subject: [PATCH 01/23] docs(requirements): creating an MCP key requires a signed-in session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Records the rule in security.md §20.10 ahead of the change: POST /api/mcp/keys and POST /api/mcp/keys/ensure-default take a signed-in (JWT) session, and ensure-default writes the same key_create audit row as the create route. --- docs/memory/requirements/security.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/memory/requirements/security.md b/docs/memory/requirements/security.md index 2bbae5729f..d7dc5ee88f 100644 --- a/docs/memory/requirements/security.md +++ b/docs/memory/requirements/security.md @@ -484,6 +484,13 @@ - **Honest bound**: the credential narrows the **API** surface only. Ops tooling that mutates containers over SSH never touches the API; SSH remains the real privilege boundary on those hosts. +- **Creating an MCP key requires a signed-in session**: `POST /api/mcp/keys` + (every scope) and `POST /api/mcp/keys/ensure-default` take + `Depends(require_interactive)`, so only an interactive (JWT) session may mint + one. A credential minter is at least as strict as the principal it produces. + `ensure-default` writes the same `key_create` audit row as `POST /api/mcp/keys`, + so every mint is attributable. Guard: + `tests/unit/test_mcp_key_creation_requires_session.py`. --- From d687979af94cec2b9441ed39831e82648fbacb5c Mon Sep 17 00:00:00 2001 From: Andrii Pasternak <44377756+AndriiPasternak31@users.noreply.github.com> Date: Mon, 28 Sep 2026 01:15:43 +0100 Subject: [PATCH 02/23] =?UTF-8?q?feat(auth):=20human-only=20principal=20pr?= =?UTF-8?q?imitives=20=E2=80=94=20assert=5Fperson,=20require=5Fperson,=20r?= =?UTF-8?q?equire=5Finteractive?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two allowlist rules over mcp_scope, both fail-closed on a principal without one: PERSON (a JWT session or the person's own user-scoped key, via is_person_principal) and INTERACTIVE (a JWT session only). The Depends forms make a route's rule a one-line signature change an AST guard can see. Both also refuse a principal carrying vouched_source_agent. The ent#611 ask-endings detail and predicate are unchanged, and pinned so. --- src/backend/dependencies.py | 66 +++++++ tests/unit/test_human_only_principals.py | 218 +++++++++++++++++++++++ 2 files changed, 284 insertions(+) create mode 100644 tests/unit/test_human_only_principals.py diff --git a/src/backend/dependencies.py b/src/backend/dependencies.py index c9b3ebd19f..65263f883b 100644 --- a/src/backend/dependencies.py +++ b/src/backend/dependencies.py @@ -1154,6 +1154,72 @@ def reject_non_person_principal(current_user: User) -> None: ) + +# Human-only grant surfaces (#2996, trinity-enterprise#711). Two rules, both +# ALLOWlists over `mcp_scope` that fail closed on a principal without one: +# +# PERSON `is_person_principal` above — a JWT session or the person's own +# `user`-scoped key. For routes that grant or change what an agent +# may do (autonomy, the agent-config toggles). +# INTERACTIVE `is_interactive_principal` — a JWT session only. For routes that +# mint, bind or rotate a credential or sign-in identity: a minter +# must be stricter than the principal class it produces, or the +# PERSON rule is circular. +# +# These are credential-class rules, not proof of human origin: a `user` key is +# the person's delegated credential, and a JWT counts as interactive only because +# non-human JWTs are fenced at `get_current_user`. Both primitives additionally +# refuse a principal carrying `vouched_source_agent` (the event-loopback JWT has +# `mcp_scope=None`; its entry-point fence is the first layer, this the second). +# +# Enumeration-safe (Invariant #8, #186): the refusal depends only on the +# principal, never on whether the addressed agent exists, so a 403 answered +# before a path's 404 discloses nothing. +HUMAN_ONLY_DETAIL = { + "code": "person_required", + "message": ( + "This operation is human-only; agent- and system-scoped keys " + "cannot perform it" + ), +} + + +def assert_person(current_user: User) -> None: + """Refuse (403, `HUMAN_ONLY_DETAIL`) any caller that is not a PERSON. + + The imperative form of `require_person`, for a handler that must run the + check itself. Same predicate as ent#611's `reject_non_person_principal` plus + the loopback refusal; the detail is neutral rather than ask-specific. + """ + if not is_person_principal(current_user) or getattr( + current_user, "vouched_source_agent", None + ): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=dict(HUMAN_ONLY_DETAIL), + ) + + +def require_person(current_user: User = Depends(get_current_user)) -> User: + """`Depends` form of `assert_person` — the PERSON rule on a route.""" + assert_person(current_user) + return current_user + + +def require_interactive(current_user: User = Depends(get_current_user)) -> User: + """`Depends` form of the INTERACTIVE rule: a JWT session only. + + A key-authenticated caller gets `reject_non_interactive_principal`'s + existing refusal, unchanged; a loopback JWT gets `HUMAN_ONLY_DETAIL`. + """ + reject_non_interactive_principal(current_user) + if getattr(current_user, "vouched_source_agent", None): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=dict(HUMAN_ONLY_DETAIL), + ) + return current_user + def enforce_agent_spawn_scope(current_user: User, target_agent: str) -> None: """Lifecycle-mutation gate for agent-scoped callers (trinity-enterprise#69 Part 2) — INTERIM until #948 capability tokens. diff --git a/tests/unit/test_human_only_principals.py b/tests/unit/test_human_only_principals.py new file mode 100644 index 0000000000..a8ce3bef2a --- /dev/null +++ b/tests/unit/test_human_only_principals.py @@ -0,0 +1,218 @@ +"""The two human-only principal rules and their shared primitives. + +`dependencies.py` carries two allowlists over `User.mcp_scope`: + +* PERSON — `is_person_principal` (ent#611): a JWT session or the person's own + `user`-scoped key, and nothing that carries an agent, connector or delegate + identity. +* INTERACTIVE — `is_interactive_principal` (#1854): a JWT session only. + +This suite pins the three primitives built on them — `assert_person`, +`require_person` (its `Depends` form) and `require_interactive` — across every +principal kind, one per arm, with a stated principal (never a `MagicMock`, +whose auto-truthy attributes pass for the wrong reason). Both new primitives +also refuse a principal carrying `vouched_source_agent`: the event-loopback JWT +has `mcp_scope=None` and is otherwise contained only by its entry-point fence. + +ent#611's `reject_non_person_principal` and `PERSON_REQUIRED_DETAIL` are pinned +byte-identical so this change cannot alter the ask-endings contract. + +Related: docs/memory/requirements/auth.md §2.8, requirements/security.md §20.10. +""" + +from __future__ import annotations + +import inspect +import os +import sys +from types import SimpleNamespace + +import pytest + +os.environ.setdefault("REDIS_URL", "redis://u:p@localhost:6379") +os.environ.setdefault("SECRET_KEY", "test-secret") + +_BACKEND = os.path.abspath( + os.path.join(os.path.dirname(__file__), "..", "..", "src", "backend") +) +if _BACKEND not in sys.path: + sys.path.insert(0, _BACKEND) + +pytestmark = pytest.mark.unit + + +def _principal(**kw): + from models import User + + base = {"id": 7, "username": "op", "email": "op@example.com", "role": "admin"} + base.update(kw) + return User(**base) + + +# (id, principal kwargs, passes PERSON, passes INTERACTIVE) +KINDS = [ + ("jwt-session", {}, True, True), + ("user-key", {"mcp_scope": "user"}, True, False), + ("agent-key", {"mcp_scope": "agent", "agent_name": "agent-a"}, False, False), + ("system-key", {"mcp_scope": "system"}, False, False), + ( + "connector-key", + {"mcp_scope": "connector", "connector_agent": "agent-a"}, + False, + False, + ), + ( + "portal-delegate-key", + {"mcp_scope": "portal_delegate", "portal_delegate": True}, + False, + False, + ), + ("ops-key", {"mcp_scope": "ops"}, False, False), + ("unknown-future-scope", {"mcp_scope": "a-scope-shipped-tomorrow"}, False, False), + ( + "user-scope-carrying-an-agent", + {"mcp_scope": "user", "agent_name": "agent-a"}, + False, + False, + ), + ("loopback-jwt", {"vouched_source_agent": "agent-a"}, False, False), +] + + +def _kind_params(pick): + return [ + pytest.param(kw, id=kid) + for kid, kw, person, interactive in KINDS + if pick(person, interactive) + ] + + +class TestAssertPerson: + @pytest.mark.parametrize("kw", _kind_params(lambda p, i: p)) + def test_a_person_passes(self, kw): + from dependencies import assert_person, require_person + + user = _principal(**kw) + assert_person(user) # no raise + assert require_person(user) is user + + @pytest.mark.parametrize("kw", _kind_params(lambda p, i: not p)) + def test_every_other_principal_is_refused_with_the_human_only_detail(self, kw): + from fastapi import HTTPException + from dependencies import HUMAN_ONLY_DETAIL, assert_person, require_person + + for gate in (assert_person, require_person): + with pytest.raises(HTTPException) as ei: + gate(_principal(**kw)) + assert ei.value.status_code == 403 + assert ei.value.detail == HUMAN_ONLY_DETAIL + assert ei.value.detail["code"] == "person_required" + + def test_a_principal_without_a_scope_attribute_fails_closed(self): + from fastapi import HTTPException + from dependencies import assert_person + + with pytest.raises(HTTPException) as ei: + assert_person( + SimpleNamespace(id=1, username="x", email="x@example.com", role="admin") + ) + assert ei.value.status_code == 403 + + def test_the_detail_is_a_fresh_copy_each_time(self): + """A caller mutating one refusal's detail must not change the next.""" + from fastapi import HTTPException + from dependencies import HUMAN_ONLY_DETAIL, assert_person + + with pytest.raises(HTTPException) as ei: + assert_person(_principal(mcp_scope="agent", agent_name="a")) + ei.value.detail["code"] = "tampered" + assert HUMAN_ONLY_DETAIL["code"] == "person_required" + + +class TestRequireInteractive: + @pytest.mark.parametrize("kw", _kind_params(lambda p, i: i)) + def test_only_a_jwt_session_passes(self, kw): + from dependencies import require_interactive + + user = _principal(**kw) + assert require_interactive(user) is user + + @pytest.mark.parametrize("kw", _kind_params(lambda p, i: not i)) + def test_every_other_principal_is_refused(self, kw): + from fastapi import HTTPException + from dependencies import require_interactive + + with pytest.raises(HTTPException) as ei: + require_interactive(_principal(**kw)) + assert ei.value.status_code == 403 + + def test_a_key_refusal_keeps_the_existing_interactive_detail(self): + """The key branch delegates to `reject_non_interactive_principal`, so its + detail is unchanged for every existing caller.""" + from fastapi import HTTPException + from dependencies import reject_non_interactive_principal, require_interactive + + user = _principal(mcp_scope="user") + with pytest.raises(HTTPException) as a: + require_interactive(user) + with pytest.raises(HTTPException) as b: + reject_non_interactive_principal(user) + assert a.value.detail == b.value.detail + + def test_the_loopback_jwt_is_refused_with_the_human_only_detail(self): + from fastapi import HTTPException + from dependencies import HUMAN_ONLY_DETAIL, require_interactive + + with pytest.raises(HTTPException) as ei: + require_interactive(_principal(vouched_source_agent="agent-a")) + assert ei.value.detail == HUMAN_ONLY_DETAIL + + def test_a_principal_without_a_scope_attribute_fails_closed(self): + from fastapi import HTTPException + from dependencies import require_interactive + + with pytest.raises(HTTPException): + require_interactive( + SimpleNamespace(id=1, username="x", email="x@example.com", role="admin") + ) + + +class TestDependsForm: + @pytest.mark.parametrize("name", ["require_person", "require_interactive"]) + def test_resolves_the_principal_through_get_current_user(self, name): + import dependencies + + param = inspect.signature(getattr(dependencies, name)).parameters[ + "current_user" + ] + assert ( + getattr(param.default, "dependency", None) is dependencies.get_current_user + ) + + +class TestAskEndingsContractUnchanged: + def test_person_required_detail_is_byte_identical(self): + from dependencies import PERSON_REQUIRED_DETAIL + + assert PERSON_REQUIRED_DETAIL == { + "code": "person_required", + "message": ( + "Only a person can answer or cancel an ask; agent- and " + "system-scoped keys cannot end one" + ), + } + + def test_the_ask_refusal_still_carries_its_own_detail(self): + from fastapi import HTTPException + from dependencies import PERSON_REQUIRED_DETAIL, reject_non_person_principal + + with pytest.raises(HTTPException) as ei: + reject_non_person_principal(_principal(mcp_scope="system")) + assert ei.value.detail == PERSON_REQUIRED_DETAIL + + def test_the_loopback_jwt_is_still_a_person_to_the_ask_predicate(self): + """The loopback refusal lives only in the new primitives; the ent#611 + predicate is left exactly as it shipped.""" + from dependencies import is_person_principal + + assert is_person_principal(_principal(vouched_source_agent="agent-a")) is True From 5a9c7eb5121b6c4be92fecb07322211cbe98f2d3 Mon Sep 17 00:00:00 2001 From: Andrii Pasternak <44377756+AndriiPasternak31@users.noreply.github.com> Date: Mon, 28 Sep 2026 01:17:10 +0100 Subject: [PATCH 03/23] fix(mcp-keys): creating a key requires a signed-in session POST /api/mcp/keys (every scope) and POST /api/mcp/keys/ensure-default now take Depends(require_interactive): a credential minter is at least as strict as the principal it produces. The existing per-scope checks stay as they were. ensure-default also writes the same key_create audit row as the create route, so every created key is attributable. Tests go through the real get_current_user with seeded key rows and assert the mcp_api_keys row count is unchanged on every refusal. --- src/backend/routers/mcp_keys.py | 28 ++- .../test_mcp_key_creation_requires_session.py | 208 ++++++++++++++++++ 2 files changed, 234 insertions(+), 2 deletions(-) create mode 100644 tests/unit/test_mcp_key_creation_requires_session.py diff --git a/src/backend/routers/mcp_keys.py b/src/backend/routers/mcp_keys.py index 8bdb6ee1d8..c17d238f88 100644 --- a/src/backend/routers/mcp_keys.py +++ b/src/backend/routers/mcp_keys.py @@ -15,6 +15,7 @@ get_current_user, reject_agent_principal, reject_non_interactive_principal, + require_interactive, ) from services.platform_audit_service import platform_audit_service, AuditEventType @@ -25,12 +26,15 @@ async def create_mcp_api_key_endpoint( key_data: McpApiKeyCreate, request: Request, - current_user: User = Depends(get_current_user) + current_user: User = Depends(require_interactive) ): """ Create a new MCP API key for the current user. The full API key is only returned once during creation - store it securely. + Requires a signed-in (JWT) session, for every scope: a credential minter is + at least as strict as the principal it produces. + Scope is `user` unless explicitly requested. `portal_delegate` (ent#163) is admin-only: it lets the holder act as any end user who has portal access, so it must never be self-issuable by an ordinary account. @@ -110,12 +114,15 @@ async def list_mcp_api_keys_endpoint( @router.post("/keys/ensure-default", response_model=McpApiKeyWithSecret | None) async def ensure_default_mcp_api_key( request: Request, - current_user: User = Depends(get_current_user) + current_user: User = Depends(require_interactive) ): """ Ensure the user has at least one user-scoped MCP API key. If no user-scoped keys exist, creates a default one and returns it. + Requires a signed-in (JWT) session, like `POST /keys`, and writes the same + `key_create` audit row. + This is used for first-time setup to provide users with a ready-to-use MCP configuration. @@ -144,6 +151,23 @@ async def ensure_default_mcp_api_key( if not api_key: raise HTTPException(status_code=400, detail="Failed to create default API key") + await platform_audit_service.log( + event_type=AuditEventType.MCP_OPERATION, + event_action="key_create", + source="api", + actor_user=current_user, + actor_ip=request.client.host if request.client else None, + target_type="mcp_key", + target_id=getattr(api_key, "id", None), + endpoint=str(request.url.path), + request_id=getattr(request.state, "request_id", None), + details={ + "name": key_data.name, + "scope": getattr(api_key, "scope", None), + "agent_name": getattr(api_key, "agent_name", None), + }, + ) + return api_key except HTTPException: diff --git a/tests/unit/test_mcp_key_creation_requires_session.py b/tests/unit/test_mcp_key_creation_requires_session.py new file mode 100644 index 0000000000..1c615d2513 --- /dev/null +++ b/tests/unit/test_mcp_key_creation_requires_session.py @@ -0,0 +1,208 @@ +"""Creating an MCP key requires a signed-in session. + +`POST /api/mcp/keys` (every scope) and `POST /api/mcp/keys/ensure-default` +take `Depends(require_interactive)`: only a JWT session may create a key. A +credential minter must be at least as strict as the principal it produces. +`ensure-default` also writes the same `key_create` audit row as the create +route, so every created key is attributable. + +Every refusal below goes through the REAL `get_current_user` with a key row +seeded in the real schema (`tests/db_harness.py`) and presented as a bearer — +never a dependency override, which would bypass the `scope or "user"` coercion +and the entry-point fences the real path applies. Each refusal also asserts the +`mcp_api_keys` row count did not move, so a 403 answered after a write would +still fail. + +Related: docs/memory/requirements/security.md §20.10, +docs/memory/feature-flows/mcp-api-keys.md. +""" + +from __future__ import annotations + +import json +import os +import sys +from pathlib import Path + +import pytest + +os.environ.setdefault("REDIS_URL", "redis://u:p@localhost:6379") +os.environ.setdefault("SECRET_KEY", "test-secret") + +_BACKEND = Path(__file__).resolve().parents[2] / "src" / "backend" +if str(_BACKEND) not in sys.path: + sys.path.insert(0, str(_BACKEND)) + +from fastapi import FastAPI # noqa: E402 +from fastapi.testclient import TestClient # noqa: E402 + +from db_harness import ( # noqa: E402,F401 + count as _count, + db_backend, + run as _hrun, +) + +import dependencies # noqa: E402 +import routers.mcp_keys as _KEYS # noqa: E402 +from database import db # noqa: E402 +from db_models import McpApiKeyCreate, UserCreate # noqa: E402 + +pytestmark = pytest.mark.unit + +OWNER = "keymint-owner" # admin, the default install's owner +PLAIN = "keymint-plain" # an ordinary account +AGENT = "keymint-agent" +SYSTEM = "keymint-system" + +CREATE = "/api/mcp/keys" +ENSURE = "/api/mcp/keys/ensure-default" + + +@pytest.fixture +def client(db_backend, monkeypatch): + # JWT revocation is a Redis read; no Redis here. + monkeypatch.setattr(dependencies, "get_breaker_redis", lambda: None) + db.create_user( + UserCreate(username=OWNER, role="admin", email="keymint-owner@example.com") + ) + db.create_user( + UserCreate(username=PLAIN, role="user", email="keymint-plain@example.com") + ) + db.register_agent_owner(AGENT, OWNER) + app = FastAPI() + app.include_router(_KEYS.router) + return TestClient(app) + + +def _auth(token: str) -> dict: + return {"Authorization": f"Bearer {token}"} + + +def _jwt(username: str = OWNER) -> dict: + return _auth(dependencies.create_access_token({"sub": username})) + + +def _agent_key() -> dict: + return _auth(db.create_agent_mcp_api_key(AGENT, OWNER).api_key) + + +def _rescoped_key(scope: str, agent_name: str) -> dict: + """A key row carrying `scope`, created the way the platform creates the + system and connector keys: an agent key, then its scope column set.""" + key = db.create_agent_mcp_api_key(agent_name, OWNER) + _hrun("UPDATE mcp_api_keys SET scope = :s WHERE id = :i", s=scope, i=key.id) + return _auth(key.api_key) + + +def _user_key() -> dict: + return _auth(db.create_mcp_api_key(OWNER, McpApiKeyCreate(name="own key")).api_key) + + +KEY_KINDS = { + "agent-key": _agent_key, + "system-key": lambda: _rescoped_key("system", SYSTEM), + "connector-key": lambda: _rescoped_key("connector", AGENT), + "user-key": _user_key, +} + + +def _keys() -> int: + return _count("mcp_api_keys") + + +def _create_audits() -> list[dict]: + from sqlalchemy import text + from db.engine import get_engine + + with get_engine().begin() as conn: + rows = ( + conn.execute( + text( + "SELECT target_id, mcp_scope, actor_email, details FROM audit_log " + "WHERE event_action = 'key_create' ORDER BY timestamp" + ) + ) + .mappings() + .all() + ) + return [dict(r) for r in rows] + + +class TestKeyCreationRequiresSession: + @pytest.mark.parametrize("kind", sorted(KEY_KINDS)) + @pytest.mark.parametrize("path", [CREATE, ENSURE]) + def test_a_key_caller_is_refused_and_no_key_is_written(self, client, kind, path): + headers = KEY_KINDS[kind]() + before = _keys() + body = {"name": "another"} if path == CREATE else None + res = client.post(path, json=body, headers=headers) + assert res.status_code == 403, res.text + assert _keys() == before + assert _create_audits() == [] + + @pytest.mark.parametrize("scope", ["ops", "portal_delegate"]) + def test_a_key_caller_is_refused_for_the_admin_scopes_too(self, client, scope): + headers = _user_key() + before = _keys() + res = client.post(CREATE, json={"name": "x", "scope": scope}, headers=headers) + assert res.status_code == 403, res.text + assert _keys() == before + + def test_a_signed_in_session_creates_a_key_and_gets_the_secret(self, client): + before = _keys() + res = client.post(CREATE, json={"name": "laptop"}, headers=_jwt()) + assert res.status_code == 200, res.text + body = res.json() + assert body["api_key"].startswith("trinity_mcp_") + assert body["scope"] == "user" + assert _keys() == before + 1 + audits = _create_audits() + assert [a["target_id"] for a in audits] == [body["id"]] + + @pytest.mark.parametrize("scope", ["ops", "portal_delegate"]) + def test_an_admin_session_still_creates_the_admin_scopes(self, client, scope): + res = client.post( + CREATE, json={"name": "svc", "scope": scope}, headers=_jwt(OWNER) + ) + assert res.status_code == 200, res.text + assert res.json()["scope"] == scope + + def test_a_non_admin_session_still_cannot_create_a_delegate_key(self, client): + before = _keys() + res = client.post( + CREATE, json={"name": "x", "scope": "portal_delegate"}, headers=_jwt(PLAIN) + ) + assert res.status_code == 403, res.text + assert _keys() == before + + def test_the_created_key_authenticates_as_its_owner(self, client): + """The session path is unchanged end to end: the new key is live.""" + created = client.post(CREATE, json={"name": "laptop"}, headers=_jwt()).json() + info = db.validate_mcp_api_key(created["api_key"]) + assert (info["user_id"], info["scope"]) == (OWNER, "user") + + +class TestEnsureDefault: + def test_a_signed_in_session_gets_a_default_key_and_one_audit_row(self, client): + before = _keys() + res = client.post(ENSURE, headers=_jwt()) + assert res.status_code == 200, res.text + body = res.json() + assert body["api_key"].startswith("trinity_mcp_") + assert body["name"] == "Default MCP Key" + assert _keys() == before + 1 + audits = _create_audits() + assert len(audits) == 1 + assert audits[0]["target_id"] == body["id"] + assert audits[0]["mcp_scope"] is None # a session, not a key + assert audits[0]["actor_email"] == "keymint-owner@example.com" + assert json.loads(audits[0]["details"])["scope"] == "user" + + def test_an_existing_user_key_means_nothing_is_created_or_audited(self, client): + db.create_mcp_api_key(PLAIN, McpApiKeyCreate(name="already")) + before = _keys() + res = client.post(ENSURE, headers=_jwt(PLAIN)) + assert res.status_code == 200, res.text + assert res.json() is None + assert _keys() == before + assert _create_audits() == [] From f800b802ec8ecf18ab2f84530bb08a9199f9b436 Mon Sep 17 00:00:00 2001 From: Andrii Pasternak <44377756+AndriiPasternak31@users.noreply.github.com> Date: Mon, 28 Sep 2026 01:18:04 +0100 Subject: [PATCH 04/23] docs(mcp-keys): key creation flow, index row, learnings fragment, test registry mcp-api-keys.md: POST /keys and ensure-default are signed-in-session only; ensure-default is audited as key_create. Adds the feature-flows index row, a learnings fragment and the two new test files to tests/registry.json. --- docs/memory/feature-flows.md | 2 +- docs/memory/feature-flows/mcp-api-keys.md | 28 +++++++++++++++---- ...-is-at-least-as-strict-as-what-it-mints.md | 3 ++ tests/registry.json | 23 +++++++++++++++ 4 files changed, 49 insertions(+), 7 deletions(-) create mode 100644 docs/memory/learnings/2026-09-28-a-credential-minter-is-at-least-as-strict-as-what-it-mints.md diff --git a/docs/memory/feature-flows.md b/docs/memory/feature-flows.md index d2627ecbf8..4a89987cfb 100644 --- a/docs/memory/feature-flows.md +++ b/docs/memory/feature-flows.md @@ -169,7 +169,7 @@ | Admin Login | [admin-login.md](feature-flows/admin-login.md) | Password-based admin auth | | First-Time Setup | [first-time-setup.md](feature-flows/first-time-setup.md) | Admin password wizard | | First-Run Overlay | [onboarding-wizard.md](feature-flows/onboarding-wizard.md) | The blocking post-login setup sequence — step registry, derived completion, re-run (ent#581; formerly the ent#52 wizard) | -| MCP API Keys | [mcp-api-keys.md](feature-flows/mcp-api-keys.md) | API key management | +| MCP API Keys | [mcp-api-keys.md](feature-flows/mcp-api-keys.md) | API key management — creating a key (`POST /keys`, `ensure-default`) requires a signed-in session; every creation is audited as `key_create` | | Execution Origin Tracking | [AUDIT-001-execution-origin-tracking.md](feature-flows/AUDIT-001-execution-origin-tracking.md) | Track who triggered executions | | Agent-Server Authentication | [agent-server-authentication.md](feature-flows/agent-server-authentication.md) | Per-agent inbound auth for the in-container agent server (`:8000`) — derived `X-Trinity-Agent-Token` (HMAC over `AGENT_AUTH_SECRET`) enforced by a pure-ASGI middleware on every HTTP/WS route (#1159) | | 4-Tier Role Model | [role-model.md](feature-flows/role-model.md) | user < operator < creator < admin role hierarchy (ROLE-001) | diff --git a/docs/memory/feature-flows/mcp-api-keys.md b/docs/memory/feature-flows/mcp-api-keys.md index 04408bc199..37c83e4bf2 100644 --- a/docs/memory/feature-flows/mcp-api-keys.md +++ b/docs/memory/feature-flows/mcp-api-keys.md @@ -241,9 +241,9 @@ app.include_router(mcp_keys_router) | Method | Endpoint | Handler | Line | Description | |--------|----------|---------|------|-------------| -| `POST` | `/api/mcp/keys` | `create_mcp_api_key_endpoint()` | `mcp_keys.py:14-34` | Create new API key | +| `POST` | `/api/mcp/keys` | `create_mcp_api_key_endpoint()` | `mcp_keys.py:25` | Create new API key — **signed-in session only** (`Depends(require_interactive)`, every scope) | | `GET` | `/api/mcp/keys` | `list_mcp_api_keys_endpoint()` | `mcp_keys.py:37-51` | List user's keys (admin sees all) | -| `POST` | `/api/mcp/keys/ensure-default` | `ensure_default_mcp_api_key()` | `mcp_keys.py:54-96` | Auto-create default key | +| `POST` | `/api/mcp/keys/ensure-default` | `ensure_default_mcp_api_key()` | `mcp_keys.py:114` | Auto-create default key — **signed-in session only**; audited as `key_create` | | `GET` | `/api/mcp/keys/{key_id}` | `get_mcp_api_key_endpoint()` | `mcp_keys.py:99-111` | Get single key details | | `POST` | `/api/mcp/keys/{key_id}/revoke` | `revoke_mcp_api_key_endpoint()` | `mcp_keys.py:114-126` | Revoke (deactivate) key | | `DELETE` | `/api/mcp/keys/{key_id}` | `delete_mcp_api_key_endpoint()` | `mcp_keys.py:129-141` | Permanently delete key | @@ -253,19 +253,27 @@ app.include_router(mcp_keys_router) #### Create API Key (POST /api/mcp/keys) ```python -# mcp_keys.py:14-34 +# mcp_keys.py:25 @router.post("/keys", response_model=McpApiKeyWithSecret) async def create_mcp_api_key_endpoint( key_data: McpApiKeyCreate, request: Request, - current_user: User = Depends(get_current_user) + current_user: User = Depends(require_interactive) ): + # scope != "user": ops/portal_delegate only, admin-only (see Key scopes) api_key = db.create_mcp_api_key(current_user.username, key_data) if not api_key: raise HTTPException(status_code=400, detail="Failed to create API key") + await platform_audit_service.log(... event_action="key_create" ...) return api_key ``` +**Who may create a key.** Only a signed-in (JWT) session, for every scope +(`Depends(require_interactive)`, `dependencies.py`). A request authenticated with +any MCP key gets a 403 and nothing is written. A credential minter is at least as +strict as the principal it produces. The UI (`McpKeysTab.vue`) and the CLI +(`trinity login`) both create keys on the login JWT. + #### List API Keys (GET /api/mcp/keys) ```python # mcp_keys.py:37-51 @@ -287,7 +295,7 @@ async def list_mcp_api_keys_endpoint( @router.post("/keys/ensure-default", response_model=McpApiKeyWithSecret | None) async def ensure_default_mcp_api_key( request: Request, - current_user: User = Depends(get_current_user) + current_user: User = Depends(require_interactive) # signed-in session only ): # Check if user has any active user-scoped keys keys = db.list_mcp_api_keys(current_user.username) @@ -301,9 +309,15 @@ async def ensure_default_mcp_api_key( name="Default MCP Key", description="Auto-generated key for MCP access" ) - return db.create_mcp_api_key(current_user.username, key_data) + api_key = db.create_mcp_api_key(current_user.username, key_data) + await platform_audit_service.log(... event_action="key_create" ...) # same row as POST /keys + return api_key ``` +The created default key is audited exactly like `POST /keys` (`key_create`, target += the new key id, `details.scope = "user"`). When a user-scoped key already +exists nothing is created and nothing is audited. + #### Validate API Key (POST /api/mcp/validate) ```python # mcp_keys.py:144-180 - Called by MCP server, NOT JWT-protected @@ -534,6 +548,7 @@ const server = new FastMCP({ | Revoke not found | 404 | MCP API key not found | `revoke_mcp_api_key_endpoint` | | Delete not found | 404 | MCP API key not found | `delete_mcp_api_key_endpoint` | | Creation failed | 400 | Failed to create API key | `create_mcp_api_key_endpoint` | +| Create / ensure-default with an MCP key | 403 | This operation requires an interactive session; MCP API keys cannot perform it | `require_interactive` | | Server error | 500 | Failed to [action] MCP API key: {error} | All endpoints | --- @@ -726,3 +741,4 @@ accessible_agents = db.get_accessible_agent_names(user_email, is_admin) | 2026-01-13 | Initial documentation | | 2026-02-05 | **Trinity Connect Authentication**: Added section documenting MCP API key usage for `/ws/events` WebSocket authentication. Keys provide user identity for server-side event filtering. | | 2026-02-20 | **NOTIF-003 Agent Attribution**: Added "Agent-Scoped Key Authentication Context" section. Agent-scoped keys now populate `User.agent_name` field during authentication (dependencies.py:147-154). Enables correct attribution for notifications, activity tracking, and audit logging. | +| 2026-09-28 | **Creating a key requires a signed-in session**: `POST /api/mcp/keys` (every scope) and `POST /api/mcp/keys/ensure-default` take `Depends(require_interactive)`; ensure-default now writes the `key_create` audit row. Test: `tests/unit/test_mcp_key_creation_requires_session.py`. | diff --git a/docs/memory/learnings/2026-09-28-a-credential-minter-is-at-least-as-strict-as-what-it-mints.md b/docs/memory/learnings/2026-09-28-a-credential-minter-is-at-least-as-strict-as-what-it-mints.md new file mode 100644 index 0000000000..a60876dbf9 --- /dev/null +++ b/docs/memory/learnings/2026-09-28-a-credential-minter-is-at-least-as-strict-as-what-it-mints.md @@ -0,0 +1,3 @@ +## 2026-09-28 — architecture — A credential minter must be at least as strict as the principal class it produces +**Context**: `routers/mcp_keys.py` — `POST /api/mcp/keys` and `POST /api/mcp/keys/ensure-default` now take `Depends(require_interactive)`, and `ensure-default` writes the `key_create` audit row the create route already wrote. +**Lesson**: When an auth rule admits a credential class, the routes that create that class (mint, bind, rotate — convenience paths included) must take a rule at least as strict as every rule the credential will satisfy. In Trinity that means INTERACTIVE (`require_interactive`, JWT only) on every key-creation path; anything weaker makes the admitting rule circular. Audit every creation path, not only the main one, so each credential can be traced to the session that made it. diff --git a/tests/registry.json b/tests/registry.json index a4048ca873..d0e6ec7bff 100644 --- a/tests/registry.json +++ b/tests/registry.json @@ -1,5 +1,28 @@ { "test_files": [ + { + "file": "unit/test_human_only_principals.py", + "feature": "#2996", + "added": "2026-09-28", + "categories": [ + "backend", + "auth", + "security" + ], + "description": "Human-only principal primitives (dependencies.py): assert_person / require_person (PERSON rule: JWT or the person's own user key) and require_interactive (INTERACTIVE: JWT only) across 10 principal kinds (JWT, user, agent, system, connector, portal_delegate, ops, unknown future scope, user scope carrying an agent identity, event-loopback JWT) plus a principal missing mcp_scope (fails closed); HUMAN_ONLY_DETAIL code person_required, a fresh copy per refusal; the key branch keeps reject_non_interactive_principal's detail; Depends forms resolve through get_current_user; ent#611 PERSON_REQUIRED_DETAIL and is_person_principal pinned byte-identical." + }, + { + "file": "unit/test_mcp_key_creation_requires_session.py", + "feature": "security §20.10", + "added": "2026-09-28", + "categories": [ + "backend", + "auth", + "security", + "mcp-keys" + ], + "description": "Creating an MCP key requires a signed-in session: POST /api/mcp/keys (every scope) and POST /api/mcp/keys/ensure-default through the REAL get_current_user with seeded agent/system/connector/user key rows -> 403 and mcp_api_keys row count unchanged, no key_create audit row; JWT -> 200 with the secret and one key_create audit row (ensure-default included, target = new key id); admin JWT still creates ops/portal_delegate; non-admin JWT portal_delegate still 403; a user-scoped key asking an admin scope 403; existing user key -> ensure-default returns null, creates and audits nothing; the created key validates as its owner." + }, { "file": "unit/test_ent600_telegram_group_context.py", "feature": "ent#600", From 5773df443a4e78d04707742a934bc801b697fb63 Mon Sep 17 00:00:00 2001 From: Andrii Pasternak <44377756+AndriiPasternak31@users.noreply.github.com> Date: Mon, 28 Sep 2026 11:19:41 +0100 Subject: [PATCH 05/23] style(auth): PEP 8 spacing around the human-only primitives block --- src/backend/dependencies.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/backend/dependencies.py b/src/backend/dependencies.py index 65263f883b..34ccc05773 100644 --- a/src/backend/dependencies.py +++ b/src/backend/dependencies.py @@ -1154,7 +1154,6 @@ def reject_non_person_principal(current_user: User) -> None: ) - # Human-only grant surfaces (#2996, trinity-enterprise#711). Two rules, both # ALLOWlists over `mcp_scope` that fail closed on a principal without one: # @@ -1220,6 +1219,7 @@ def require_interactive(current_user: User = Depends(get_current_user)) -> User: ) return current_user + def enforce_agent_spawn_scope(current_user: User, target_agent: str) -> None: """Lifecycle-mutation gate for agent-scoped callers (trinity-enterprise#69 Part 2) — INTERIM until #948 capability tokens. From 890f8306f11fcafb525d3a5829646c3117e4a474 Mon Sep 17 00:00:00 2001 From: Andrii Pasternak <44377756+AndriiPasternak31@users.noreply.github.com> Date: Mon, 28 Sep 2026 01:19:01 +0100 Subject: [PATCH 06/23] =?UTF-8?q?docs(auth):=20human-only=20grant=20surfac?= =?UTF-8?q?es=20=E2=80=94=20the=20two=20principal=20rules=20and=20the=20ro?= =?UTF-8?q?ute=20census?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit requirements/auth.md §2.8 (and the §2.7 helper table): the PERSON and INTERACTIVE rules, which routes take which, the primitives, and the route census with its shrink-only baseline. architecture/security.md §5 and §6, and one sentence on Invariant #8. Refs #2996, trinity-enterprise#711 --- docs/memory/architecture.md | 2 +- docs/memory/architecture/security.md | 4 ++++ docs/memory/requirements/auth.md | 31 ++++++++++++++++++++++++++++ 3 files changed, 36 insertions(+), 1 deletion(-) diff --git a/docs/memory/architecture.md b/docs/memory/architecture.md index 1b440eb217..916c988d29 100644 --- a/docs/memory/architecture.md +++ b/docs/memory/architecture.md @@ -91,7 +91,7 @@ These are structural patterns that must be preserved. Breaking them causes casca 7. **Single API Client (`api.js`)** — One Axios instance with auth interceptor. Stores call `api.get()`/`api.post()`. No raw `fetch()` or duplicate Axios instances. -8. **Auth Pattern: `Depends(get_current_user)` + `AuthorizedAgent`** — Every authenticated endpoint uses FastAPI `Depends()` for auth. Agent-scoped endpoints use `AuthorizedAgent` or `OwnedAgentByName` for access control. Role-gated endpoints use `require_role("creator")` or `require_admin` (ROLE-001). `internal.py` is the only exception (no auth, for agent-to-backend calls). **Enumeration-safety (self-uniform, #186):** an agent-access handler must be *self-uniform* — it must **never** return an existence-`404` followed by an access-`403` in the same function (that differential lets any caller enumerate which agents exist). The four dependency helpers (`get_authorized_agent`/`get_owned_agent`/`…_by_name`, `dependencies.py`) now return a **uniform 404** for both a non-existent AND an inaccessible/unowned agent — they evaluate existence and access **before** branching (equal query-count → equal timing) and run `_enforce_connector_scope` first. Access-first inline handlers (`can_user_access/share` → 403 before any existence lookup) are already self-uniform and stay **403**. When adding an agent endpoint: route through a dependency, or if checking inline, check access first — do **not** reintroduce a 404-then-403 split. The MCP third surface mirrors this (Invariant #13): `chat.ts checkAgentAccess` returns one uniform reason and never discloses the owner username. Guarded by `tests/unit/test_186_enumeration_uniformity.py`. **Shared imperative-guard family (#1310):** the inline access/owner/admin checks are consolidated behind five leaf helpers in `dependencies.py` — `assert_admin` (403; `_reject_connector_principal` + `reject_agent_principal` + `role != "admin"`), `assert_agent_access` (403; `_enforce_connector_scope` + `can_user_access_agent`), `assert_agent_owner` (403; `_enforce_connector_scope(owner_op=True)` + `can_user_share_agent`), `assert_owns_or_admin` (403; `id != owner AND role != "admin"`), `assert_owns` (403; `id != owner`, **no admin bypass**). All raise **403** (access-first → self-uniform, above). **An admin gate is never agent-callable (ent#293/ent#297):** `get_current_user` resolves an agent-scoped MCP key to its owner **carrying the owner's role**, so on a default admin-owned install any non-ephemeral agent's injected `TRINITY_MCP_API_KEY` satisfied every admin gate. Five occurrences of that one class — trinity-ops-agent#232, #1644 (retention acknowledge), #1816 (system-agent restart), ent#236 and ent#293 (skills-library repointing) — were each closed by bolting `reject_agent_principal` onto one more endpoint, 18 of them against 114 admin-gated call sites. Five occurrences meant the **gate** was wrong, not the endpoints, so **`require_admin` and `assert_admin` now reject agent principals themselves** (#1890): a route added tomorrow inherits the protection without anyone remembering to ask, and the per-endpoint `reject_agent_principal` calls are belt-and-braces rather than the mechanism. Safe by construction and verified, not assumed — the agent-key flows that must keep working (heartbeat, structured reports, the #1083 result callback) authorize on `current_user.agent_name` self-checks and never touch an admin gate, and `User.agent_name` is populated only for `scope == "agent"`, so `trinity-system` still passes. This is the grant-vs-use line: the endpoint that **uses** a capability may be agent-callable; the endpoint that **grants** one is human-only. Two admin-gated reads that were *uses*, not grants, were re-opened rather than left half-working — `POST /api/monitoring/agents/{name}/check` drops to `get_current_user` (the admin gate was redundant with `AuthorizedAgentByName`) and `GET /api/subscriptions` is **owner-scoped, not un-gated** (un-gating would hand any `role=user` a fleet-wide `owner_email` + agent-name enumeration oracle — the Invariant #8 disclosure class, reintroduced by a fix for a different disclosure). **Never write `require_role("admin")`** — it is a third spelling that rejects connector but *not* agent principals, and `require_role` stays deliberately permissive because agent-spawned agent creation runs through `require_role("creator")` (ent#69 Part 2), so a blanket rejection there would break ghost spawning. Use `require_admin` (equivalent — `admin` is last in `ROLE_HIERARCHY` — and it rejects agent principals); an AST guard in `tests/unit/test_293_admin_gate_rejects_agent_keys.py` fails the build if the spelling reappears. The trigger to revisit an existing gate is a change in what the endpoint **does** — escalating a handler's destructiveness silently re-prices every principal that could already reach it. **Imperative vs path-dependency:** agent name *in the path* → prefer the path-dependency (uniform-404); agent name *derived from a resolved resource* (session/notification/subscription/execution row) or a *composite* gate → use the imperative helper; both run `_enforce_connector_scope` first so the connector boundary is enforced identically. **`assert_agent_owner` ≠ delete-authorization** — it wraps the owner-or-admin `can_user_share_agent`, NOT the `is_system`-guarded `can_user_delete_agent`; delete paths keep the delete predicate. Permanent exceptions (own intentional-404 helpers, enumeration-safe by construction): `nevermined._require_read_access`/`_require_write_access`, `reports.get_report`, `sessions._session_or_404` (compound 404). **The admin gate is an ALLOWLIST, not two named enemies (#2323).** ent#293/#297 closed it against `agent` and `connector` **by name**, which is a denylist over a free-text column: `mcp_api_keys.scope` carries no CHECK constraint, and any scope setting neither `agent_name` nor `connector_agent` walks both rejections and inherits the owner's role across ~163 admin-gated sites. `models.User.mcp_scope`'s own docstring predicted it — *"fail-closed against a sixth scope a future PR invents"* — and #2323's `ops` scope was that sixth. Both gates now additionally require `mcp_scope ∈ ADMIN_GATE_SCOPES = {None, "user", "system"}`: `None` is the JWT human the role check was written for, `user` legitimately drives admin endpoints today (ops dashboards, `trinity-ops-agent`) so removing it would be a behaviour change, and `system` is `trinity-system`. Zero behaviour change over the live domain except `portal_delegate`, which passed both named guards and was contained **only** by its route fence — it now has a second layer, unreachable in normal operation and firing only if that fence is holed. A principal object lacking `mcp_scope` entirely fails **closed** via a dedicated sentinel — never `getattr(..., None)`, which would make the absent attribute the *privileged* JWT value (the documented getattr-discriminator trap); `models.User` always declares the field, so the only objects this rejects are stand-ins that do not match the real principal. `assert_admin` takes `allow_scopes=` so an individual endpoint can opt a bounded scope in (`allow_scopes={"ops"}` on the ops reads) — the grant that makes a machine credential authorized by **what it is** rather than by who owns it, and which flips the failure direction so a new ops route is inaccessible until opted in rather than silently reachable. `require_role` is deliberately **not** consulted (agent-spawned creation through `require_role("creator")` is supported ent#69 behaviour). Static guard: `tests/unit/test_1310_auth_wiring.py`; behavioral proof: `tests/unit/test_1310_auth_consolidation.py`; allowlist + future-scope proof: `tests/unit/test_293_admin_gate_rejects_agent_keys.py`. +8. **Auth Pattern: `Depends(get_current_user)` + `AuthorizedAgent`** — Every authenticated endpoint uses FastAPI `Depends()` for auth. Agent-scoped endpoints use `AuthorizedAgent` or `OwnedAgentByName` for access control. Role-gated endpoints use `require_role("creator")` or `require_admin` (ROLE-001). `internal.py` is the only exception (no auth, for agent-to-backend calls). **Enumeration-safety (self-uniform, #186):** an agent-access handler must be *self-uniform* — it must **never** return an existence-`404` followed by an access-`403` in the same function (that differential lets any caller enumerate which agents exist). The four dependency helpers (`get_authorized_agent`/`get_owned_agent`/`…_by_name`, `dependencies.py`) now return a **uniform 404** for both a non-existent AND an inaccessible/unowned agent — they evaluate existence and access **before** branching (equal query-count → equal timing) and run `_enforce_connector_scope` first. Access-first inline handlers (`can_user_access/share` → 403 before any existence lookup) are already self-uniform and stay **403**. When adding an agent endpoint: route through a dependency, or if checking inline, check access first — do **not** reintroduce a 404-then-403 split. The MCP third surface mirrors this (Invariant #13): `chat.ts checkAgentAccess` returns one uniform reason and never discloses the owner username. Guarded by `tests/unit/test_186_enumeration_uniformity.py`. **Shared imperative-guard family (#1310):** the inline access/owner/admin checks are consolidated behind five leaf helpers in `dependencies.py` — `assert_admin` (403; `_reject_connector_principal` + `reject_agent_principal` + `role != "admin"`), `assert_agent_access` (403; `_enforce_connector_scope` + `can_user_access_agent`), `assert_agent_owner` (403; `_enforce_connector_scope(owner_op=True)` + `can_user_share_agent`), `assert_owns_or_admin` (403; `id != owner AND role != "admin"`), `assert_owns` (403; `id != owner`, **no admin bypass**). All raise **403** (access-first → self-uniform, above). **An admin gate is never agent-callable (ent#293/ent#297):** `get_current_user` resolves an agent-scoped MCP key to its owner **carrying the owner's role**, so on a default admin-owned install any non-ephemeral agent's injected `TRINITY_MCP_API_KEY` satisfied every admin gate. Five occurrences of that one class — trinity-ops-agent#232, #1644 (retention acknowledge), #1816 (system-agent restart), ent#236 and ent#293 (skills-library repointing) — were each closed by bolting `reject_agent_principal` onto one more endpoint, 18 of them against 114 admin-gated call sites. Five occurrences meant the **gate** was wrong, not the endpoints, so **`require_admin` and `assert_admin` now reject agent principals themselves** (#1890): a route added tomorrow inherits the protection without anyone remembering to ask, and the per-endpoint `reject_agent_principal` calls are belt-and-braces rather than the mechanism. Safe by construction and verified, not assumed — the agent-key flows that must keep working (heartbeat, structured reports, the #1083 result callback) authorize on `current_user.agent_name` self-checks and never touch an admin gate, and `User.agent_name` is populated only for `scope == "agent"`, so `trinity-system` still passes. This is the grant-vs-use line: the endpoint that **uses** a capability may be agent-callable; the endpoint that **grants** one is human-only. Two admin-gated reads that were *uses*, not grants, were re-opened rather than left half-working — `POST /api/monitoring/agents/{name}/check` drops to `get_current_user` (the admin gate was redundant with `AuthorizedAgentByName`) and `GET /api/subscriptions` is **owner-scoped, not un-gated** (un-gating would hand any `role=user` a fleet-wide `owner_email` + agent-name enumeration oracle — the Invariant #8 disclosure class, reintroduced by a fix for a different disclosure). **Never write `require_role("admin")`** — it is a third spelling that rejects connector but *not* agent principals, and `require_role` stays deliberately permissive because agent-spawned agent creation runs through `require_role("creator")` (ent#69 Part 2), so a blanket rejection there would break ghost spawning. Use `require_admin` (equivalent — `admin` is last in `ROLE_HIERARCHY` — and it rejects agent principals); an AST guard in `tests/unit/test_293_admin_gate_rejects_agent_keys.py` fails the build if the spelling reappears. The trigger to revisit an existing gate is a change in what the endpoint **does** — escalating a handler's destructiveness silently re-prices every principal that could already reach it. **Imperative vs path-dependency:** agent name *in the path* → prefer the path-dependency (uniform-404); agent name *derived from a resolved resource* (session/notification/subscription/execution row) or a *composite* gate → use the imperative helper; both run `_enforce_connector_scope` first so the connector boundary is enforced identically. **`assert_agent_owner` ≠ delete-authorization** — it wraps the owner-or-admin `can_user_share_agent`, NOT the `is_system`-guarded `can_user_delete_agent`; delete paths keep the delete predicate. Permanent exceptions (own intentional-404 helpers, enumeration-safe by construction): `nevermined._require_read_access`/`_require_write_access`, `reports.get_report`, `sessions._session_or_404` (compound 404). **The admin gate is an ALLOWLIST, not two named enemies (#2323).** ent#293/#297 closed it against `agent` and `connector` **by name**, which is a denylist over a free-text column: `mcp_api_keys.scope` carries no CHECK constraint, and any scope setting neither `agent_name` nor `connector_agent` walks both rejections and inherits the owner's role across ~163 admin-gated sites. `models.User.mcp_scope`'s own docstring predicted it — *"fail-closed against a sixth scope a future PR invents"* — and #2323's `ops` scope was that sixth. Both gates now additionally require `mcp_scope ∈ ADMIN_GATE_SCOPES = {None, "user", "system"}`: `None` is the JWT human the role check was written for, `user` legitimately drives admin endpoints today (ops dashboards, `trinity-ops-agent`) so removing it would be a behaviour change, and `system` is `trinity-system`. Zero behaviour change over the live domain except `portal_delegate`, which passed both named guards and was contained **only** by its route fence — it now has a second layer, unreachable in normal operation and firing only if that fence is holed. A principal object lacking `mcp_scope` entirely fails **closed** via a dedicated sentinel — never `getattr(..., None)`, which would make the absent attribute the *privileged* JWT value (the documented getattr-discriminator trap); `models.User` always declares the field, so the only objects this rejects are stand-ins that do not match the real principal. `assert_admin` takes `allow_scopes=` so an individual endpoint can opt a bounded scope in (`allow_scopes={"ops"}` on the ops reads) — the grant that makes a machine credential authorized by **what it is** rather than by who owns it, and which flips the failure direction so a new ops route is inaccessible until opted in rather than silently reachable. `require_role` is deliberately **not** consulted (agent-spawned creation through `require_role("creator")` is supported ent#69 behaviour). Static guard: `tests/unit/test_1310_auth_wiring.py`; behavioral proof: `tests/unit/test_1310_auth_consolidation.py`; allowlist + future-scope proof: `tests/unit/test_293_admin_gate_rejects_agent_keys.py`. **Owner-tier grant routes take `require_person` / `require_interactive` (#2996)** — the owner gates carry legitimate agent traffic, so the human-only rule is per route, and `tests/unit/test_2996_human_only_routes.py` fails any new route (any method) that is neither gated nor classified (shrink-only baseline; see `architecture/security.md` §6). 9. **Channel Adapter ABC** — External messaging (Slack, Telegram, WhatsApp/Twilio) follows `adapters/base.py` → `ChannelAdapter` ABC with `NormalizedMessage` and `ChannelResponse`. New channels must implement this interface. diff --git a/docs/memory/architecture/security.md b/docs/memory/architecture/security.md index f4ea3e6f65..0877f534eb 100644 --- a/docs/memory/architecture/security.md +++ b/docs/memory/architecture/security.md @@ -57,6 +57,8 @@ Each agent gets an auto-generated agent-scoped key (`scope='agent'`, `agent_name Enforced at the **MCP server layer** (`src/mcp-server/src/tools/`), not the backend REST API: `list_agents` returns only permitted agents + self; `chat_with_agent` blocks non-permitted targets. The backend resolves agent-scoped keys to the owner user and applies standard ownership/sharing checks (`current_user.agent_name` is used only by notifications and event subscriptions). **Restrictive default**: new agents start with zero permissions; grants are explicit via the Permissions tab (`agent_permissions` table). +**Unexposed at the MCP layer is not unreachable (#2996).** Every agent has `TRINITY_BACKEND_URL`, so a `# mcp: none` router is still reachable over raw REST with the agent's own key. Autonomy and the agent-config toggles (`routers/agent_config.py`) and the self-service credential/identity routes (`routers/users.py` email and GitHub PAT) are therefore fenced **at the backend** as human-only (§6, requirements `auth.md` §2.8), not only left out of the tool set. + **Residual, stated plainly (#1854).** The enforcement is `scope === "agent"`-conditional, so it holds only while the container's `.mcp.json` actually carries the agent's own key. A **user-scoped** key pasted into that file authenticates as the owner and bypasses the matrix silently — the platform accepts it. #1854 **detects** this (the config-truth probe above, verdicts `foreign_user_key` / `foreign_agent_key` / `shadow_entry`) and **repairs** it (drift predicate + rotation); it does not **prevent** it. Prevention needs a trustworthy request-origin signal, which does not exist on the MCP path today — the MCP server forwards no origin marker, and client IP cannot substitute (port 8080 is published on all interfaces and the frontend also sits on `trinity-agent-network`). Deferred as Part 2b/3; if built it must be an explicit allowlist over **all five** scopes, since "reject non-agent" breaks the system agent, the connector and portal_delegate. **Source-agent attribution is an allowlist over identity (ent#614).** `X-Source-Agent` is a raw client header. Every router that reads it — `/chat`, `/task`, `/fan-out`, loops, reminders, the manual schedule trigger — resolves it through `dependencies.resolve_source_agent` (the #1310 imperative family) before anything consumes it, and the three dispatch routers **rebind** the parameter so the raw value is unreachable downstream. The helper honours the header only when the principal can prove it names itself: an **agent-scoped** key naming its own agent (`User.agent_name`, the SELF-EXEC-001 rule that previously guarded `/task` alone) or the EVT-001 **event loopback**, whose `User.vouched_source_agent` the backend derived itself. A mismatch under either is a 403; **every other principal** — a JWT human, a user / system / ops / connector / portal-delegate key, any scope a later PR invents — is refused with a named 403, never trusted and never silently ignored. Before #614 a permitted user could set the header on `/chat` and forge an `actor_type='agent'` SEC-001 audit row (the human dropped, the hash chain certifying it), a `triggered_by='agent'` execution, and an `AGENT_COLLABORATION` activity plus a WebSocket edge on an agent they could not access — the third occurrence of the "header-keyed auth arm" class after #1672 and ent#265, which is why the fix is one gate rather than three more endpoint patches. The attribution-only routers read `current_user.agent_name or resolve_source_agent(...)`: the validated key first, because the MCP client sends no header on those paths. On the audit sites the agent branch now also carries the key owner's `actor_email` and the presented credential, so an agent row joins back to a human. The MCP server sends the header only for `scope === "agent"` on every tool (the schedules tool was the one exception, aligned in #614). The loopback token is a SECRET_KEY-signed JWT with `scope=event_loopback`, fenced in `get_current_user` to `POST /api/agents/{name}/task` and carrying a `source_agent` claim only for an agent-originated event — `emit_event` writes a JWT caller's USERNAME into `agent_events.source_agent`, which must never be certified as an agent. One stated residual, and one thing deliberately NOT vouched: `emit_event_for_agent` is `AuthorizedAgent`-gated (read access, not ownership), so any accessor of `{name}` — including a sibling agent's key, which resolves to the owner — may emit an event whose `agent_events.source_agent` is `{name}`; that is the endpoint's own contract and is tracked in the debt inbox, but the loopback **does not certify it** (`agent_originated=current_user.agent_name == name`), so such an event wakes the subscriber as an ordinary MCP-triggered execution and cannot mint an `actor_type='agent'` row or a collaboration edge on `{name}`. Separately, the enterprise skill runner deliberately lets a human act as an *accessible* agent via `assert_agent_access` — a different semantic, not this rule. Guard: `tests/unit/test_ent614_source_agent_attribution.py` walks the OSS tree and fails any function that reads `x_source_agent` without routing every use through the helper, plus a literal scan for the header name. @@ -111,6 +113,8 @@ onto the same falsy value a genuine durable agent gives. The same reasoning governs the backend side. `scope` is a free-text column with **no CHECK constraint**, so this table is a snapshot of live values, not a closed set. Guards over it must be **allowlists**: `reject_agent_principal` + `_reject_connector_principal` between them cover only `agent` and `connector`, and a `scope='system'` key sets neither field, so both are no-ops for it while it still resolves to the key owner carrying the owner's role. `reject_non_interactive_principal` (#1854) inverts this — it passes only when `User.mcp_scope is None`, i.e. the caller came through the JWT branch — and gates the credential-lifecycle routes (agent MCP-key read/verify/rotate). Key revoke/delete (`/api/mcp/keys/*`) and the connector key mint additionally run the agent+connector pair, because `db.revoke_mcp_api_key`/`delete_mcp_api_key` skip the ownership check entirely for admins and an agent key inherits its owner's role (#1854). +**Two human-only rules, one primitive family (#2996 / ent#711).** The owner tier cannot refuse agent keys inside `can_user_share_agent` / `assert_agent_owner` the way #1890 did at the admin tier — agents legitimately drive owner-gated routes (MCP schedule tools, file writes) — so the rule is applied per route, from `dependencies.py`: **PERSON** (`assert_person` / `Depends(require_person)` over `is_person_principal`: JWT or the person's own `user` key) for routes that grant or change what an agent may do, and **INTERACTIVE** (`Depends(require_interactive)`: JWT only) for routes that mint, bind or rotate a credential or sign-in identity, including `POST /api/mcp/keys` and `ensure-default` — a minter must be stricter than the class it mints or PERSON is circular. Both are allowlists, fail closed on a missing `mcp_scope`, and also refuse `vouched_source_agent` (the loopback JWT). **The inheritance mechanism is a route census**, not a gate change: `tests/unit/test_2996_human_only_routes.py` classifies every OSS HTTP route (all methods, `rglob` over `src/backend/`, `enterprise/` excluded) into exactly one policy class — gated, admin tier, widened admin, portal, or a reviewed `agent_callable` / `own_auth` / `delegated` entry with its `METHOD /path` pinned — and freezes the rest in a shrink-only baseline (`tests/unit/fixtures/human_only_route_baseline.json`, exact count), so a new route of any method fails CI until it chooses a side. A runtime sibling (`test_2996_route_census_runtime.py`) imports the real app in a subprocess and cross-checks the stored paths. + ### 7. External Credentials (Agent → External Services) CRED-002 file-injection model (Invariant #12): `.env` (KEY=VALUE source of truth) + `.mcp.json` edited directly; encrypted backup `.credentials.enc` (AES-256-GCM, safe for git); auto-import on startup if `.credentials.enc` exists without `.env`. Flow: Quick Inject writes `.env` → Export encrypts to `.credentials.enc` → agent start decrypts and writes files. OAuth providers for agent credentials: Google, Slack, GitHub (PAT), Notion. Common MCP servers inside agents: google-workspace, slack, notion, github, n8n-mcp. diff --git a/docs/memory/requirements/auth.md b/docs/memory/requirements/auth.md index 2133097701..68c43b2f9d 100644 --- a/docs/memory/requirements/auth.md +++ b/docs/memory/requirements/auth.md @@ -60,6 +60,8 @@ | `assert_agent_owner(user, agent_name, *, detail=…)` | `_enforce_connector_scope(owner_op=True)` + `db.can_user_share_agent` | 403 | inline `can_user_share_agent` → 403. **NOT delete-authorization** (see below) | | `assert_owns_or_admin(user, owner_id, *, detail="Not authorized")` | `user.id != owner_id AND role != "admin"` | 403 | strict-self-**or-admin** session gates (voice/chat) | | `assert_owns(user, owner_id, *, detail=…)` | `user.id != owner_id` (id-only, **no admin bypass**) | 403 | strict-self session gate (`public.py` public-link session detail) | + | `assert_person(user)` / `Depends(require_person)` | `is_person_principal` (JWT or the person's own `user` key) + no `vouched_source_agent` | 403 `HUMAN_ONLY_DETAIL` | owner-tier grant routes (§2.8, #2996) | + | `Depends(require_interactive)` | `reject_non_interactive_principal` (JWT only) + no `vouched_source_agent` | 403 | credential / sign-in identity mint, bind, rotate (§2.8) | - **Preserve-403 (not 404).** All five raise **403** — access-first inline handlers are already *self-uniform* per INV-8 (they check access before any existence lookup, so there is no 404-then-403 enumeration oracle). The platform precedent is `schedules.py` `create_schedule` (#1445: "Access-check FIRST … no 404-vs-403 name-enumeration oracle"). The clean `{agent_name}`-path sites *could* have adopted the uniform-404 path-dependencies, but were consciously kept 403 to minimize frontend blast-radius and hold **one** imperative convention. - **Imperative vs path-dependency (when to use which).** Agent name **in the path** → prefer the path-dependency (`AuthorizedAgent[ByName]`/`OwnedAgent[ByName]`, uniform-404). Agent name **derived from a resolved resource** (notification/session/subscription/execution row) or a **composite** gate (owner-or-initiator, resource-404-then-access) → use the imperative helper. Both fences run `_enforce_connector_scope` first, so the two conventions enforce the connector boundary identically. @@ -70,4 +72,33 @@ - **Behavioral proof**: `tests/unit/test_1310_auth_consolidation.py` (real-DB `db_harness`) locks status + detail + admitted-principal set per migrated site; `test_186_enumeration_uniformity.py` extended for the five helpers. - **Flow**: `docs/memory/feature-flows/role-model.md` +### 2.8 Human-Only Grant Surfaces (INV-8, #2996 / trinity-enterprise#711) +- **Status**: ✅ Implemented (2026-09-28) +- **GitHub Issues**: #2996 (autonomy toggle), trinity-enterprise#711 (sign-in email rebind) +- **Problem**: `get_current_user` resolves an agent-scoped or system-scoped MCP key to its **owner, carrying the owner's role**. #1890 made the admin gates refuse agent keys; the **owner** tier (`can_user_share_agent`, `assert_agent_owner`, `OwnedAgentByName`) and the self-service routes (`get_current_user` alone) kept admitting them. The owner gates cannot simply refuse agent keys the way the admin gates do, because agents legitimately use owner-gated routes (MCP schedule tools, file writes). So the rule is applied per route, and a route census makes the next route choose a side. +- **Two principal rules** — both **allowlists** over `User.mcp_scope`, fail-closed on a principal without the attribute (sentinel, never `getattr(..., None)`), and both refuse a principal carrying `vouched_source_agent` (the event-loopback JWT, `mcp_scope=None`): + + | Rule | Admits | Refuses | Primitive | + |---|---|---|---| + | **PERSON** | JWT session; the person's own `user`-scoped key | agent, system, connector, portal_delegate, ops, any future scope, a missing `mcp_scope`, a `user` principal carrying an agent/connector/delegate identity | `assert_person` / `Depends(require_person)` (predicate `is_person_principal`, ent#611) | + | **INTERACTIVE** | JWT session only | every key | `Depends(require_interactive)` (predicate `is_interactive_principal`, #1854) | + + They are **credential-class** rules, not proof of human origin: a `user` key is the person's delegated credential, and a JWT counts as interactive only because non-human JWTs are fenced at `get_current_user`. +- **Assignment rule**: a route that **grants or changes what an agent may do** takes PERSON; a route that **mints, binds or rotates a credential or sign-in identity** takes INTERACTIVE — a minter must be stricter than the principal class it produces, or the PERSON rule is circular. + + | Route | Rule | + |---|---| + | `PUT /api/agents/{name}/autonomy` (#2996 — decides whether **unattended cron** fires; a manual `trigger_schedule` is not gated by autonomy) | PERSON | + | `PUT /api/agents/{name}/{api-key-setting, read-only, resources, capabilities, capacity, timeout, public-channel-model, guardrails}` | PERSON | + | `PUT /api/users/me/email` (ent#711) | INTERACTIVE | + | `PUT` / `DELETE /api/users/me/github-pat` (a credential future agent creations inherit, ent#162) | INTERACTIVE | + | `POST /api/mcp/keys` (every scope), `POST /api/mcp/keys/ensure-default` | INTERACTIVE (security.md §20.10) | + +- **Refusal**: 403. PERSON routes return `HUMAN_ONLY_DETAIL` (`{"code": "person_required", ...}`, the same machine code as ent#611's ask endings, whose own detail is unchanged); INTERACTIVE routes return `reject_non_interactive_principal`'s existing detail. The refusal depends only on the principal, never on whether the addressed agent exists, so answering 403 before a path's 404 is not an enumeration oracle (#186). +- **Still working**: the owner's `user` key and ops tooling on a `user` key keep the PERSON routes; the UI and CLI use JWTs everywhere; heartbeat, the result callback, reports and notifications do not use these gates; `trinity-system` keeps `require_admin` (#2323) and has no caller of the routes above. +- **Route census (the inheritance mechanism)**: `tests/unit/test_2996_human_only_routes.py` walks every `*.py` under `src/backend/` (`rglob`, `enterprise/` excluded — the private tree carries its own), every HTTP method (GETs included), both `@.(...)` and `add_api_route(...)`. Each route must resolve to exactly one policy class: `person` / `interactive` (the Depends form, or the imperative call as the handler's **first** statement on its principal argument, with the name imported from `dependencies`), `admin_tier` (`require_admin` / `assert_admin`, counted and printed), `admin_widened` (an `allow_scopes` opt-in, listed per route), `portal` (`get_portal_principal`), `agent_callable` / `own_auth` / `delegated` (listed per route with the `METHOD /path` pinned and a reason), or `unclassified_at_freeze` — the frozen baseline `tests/unit/fixtures/human_only_route_baseline.json`. The baseline is **shrink-only**: exact count literal, stale entries fail, an entry that becomes gated must be removed. WebSocket routes are pinned as a literal set. `tests/unit/test_2996_route_census_runtime.py` imports the real app in a subprocess (fails, never skips) and checks every registered route maps to a census key and every stored `METHOD /path` matches. +- **Not closed here**: the rest of the owner-tier grant routes the census lists as `unclassified_at_freeze` — each needs a per-route grant-vs-use ruling because several carry live agent traffic. Tracked in a follow-up issue; the baseline makes the remainder visible and only lets it shrink. +- **Tests**: `tests/unit/test_human_only_principals.py` (primitives), `tests/unit/test_mcp_key_creation_requires_session.py`, `tests/unit/test_2996_human_only_routes.py`, `tests/unit/test_2996_route_census_runtime.py`, `tests/unit/test_2996_owner_config_person_only.py`, `tests/unit/test_2996_self_service_session_only.py`. +- **Flows**: `feature-flows/autonomy-mode.md`, `feature-flows/email-authentication.md`, `feature-flows/mcp-api-keys.md` + --- From 89fc6e2a51dc0e7c9d94f0afe4ce9662771d3d32 Mon Sep 17 00:00:00 2001 From: Andrii Pasternak <44377756+AndriiPasternak31@users.noreply.github.com> Date: Mon, 28 Sep 2026 01:20:58 +0100 Subject: [PATCH 07/23] fix(autonomy): only a person can change an agent's autonomy (#2996) PUT /api/agents/{name}/autonomy takes Depends(require_person): a JWT session or the person's own user-scoped key. Agent-scoped keys (on their own agent or any other), the system key and every other scope get a 403 with the human-only detail, before the owner check, so the refusal discloses nothing about whether the agent exists. Autonomy decides whether an agent's cron schedules fire unattended; it is a grant, so the agent's own key must not be able to switch it on. Tests go through the real get_current_user with seeded key rows and assert the stored autonomy flag before and after. Refs #2996 --- src/backend/routers/agent_config.py | 14 +- .../test_2996_owner_config_person_only.py | 263 ++++++++++++++++++ 2 files changed, 275 insertions(+), 2 deletions(-) create mode 100644 tests/unit/test_2996_owner_config_person_only.py diff --git a/src/backend/routers/agent_config.py b/src/backend/routers/agent_config.py index 88d7b6ecb1..16a1d8e5fd 100644 --- a/src/backend/routers/agent_config.py +++ b/src/backend/routers/agent_config.py @@ -4,7 +4,13 @@ from models import User, AgentCapacityUpdate, PublicChannelModelUpdate from database import db -from dependencies import get_current_user, AuthorizedAgentByName, assert_agent_access, assert_agent_owner +from dependencies import ( + get_current_user, + AuthorizedAgentByName, + assert_agent_access, + assert_agent_owner, + require_person, +) from services import settings_service from services.docker_service import get_agent_container from services.agent_service import ( @@ -61,11 +67,15 @@ async def set_agent_autonomy_status( agent_name: str, body: dict, request: Request, - current_user: User = Depends(get_current_user) + current_user: User = Depends(require_person) ): """ Set the autonomy status for an agent. + Person-only (#2996): autonomy decides whether the agent's cron schedules + fire unattended — a grant, so an agent- or system-scoped key is refused + before the owner check, whatever agent it addresses. + Body: - enabled: True to enable autonomy, False to disable diff --git a/tests/unit/test_2996_owner_config_person_only.py b/tests/unit/test_2996_owner_config_person_only.py new file mode 100644 index 0000000000..c539483276 --- /dev/null +++ b/tests/unit/test_2996_owner_config_person_only.py @@ -0,0 +1,263 @@ +"""#2996 — autonomy and the agent-config toggles are person-only. + +`routers/agent_config.py` is the owner configuration surface: autonomy decides +whether an agent's cron schedules fire unattended, and the other toggles set its +guardrails, resources, capabilities and model. Every write there takes +`Depends(require_person)` — a JWT session or the person's own `user`-scoped key; +an agent-scoped key (on its own agent or any other), the system key, and every +other scope get a 403 with `HUMAN_ONLY_DETAIL`. + +Two harnesses, on purpose: + +* The REAL `get_current_user` with key rows seeded in the real schema + (`tests/db_harness.py`) and presented as bearers — agent key, system key, the + owner's user key, JWTs. A dependency override would skip the `scope or "user"` + coercion and the entry-point fences, so the principal kinds that matter most + go through the real path. +* An override of `get_current_user` with a stated principal (every field set, + never a `MagicMock`) for the kinds the real path fences before the route + (ops, portal_delegate) or cannot mint (an unknown future scope, the loopback + JWT, a `user` principal carrying an agent identity) — so the ROUTE's gate is + what refuses them. + +Every refusal asserts the stored value did not move, from a non-ambient seed; +every admission asserts it did. + +Related: docs/memory/requirements/auth.md §2.8, feature-flows/autonomy-mode.md. +""" + +from __future__ import annotations + +import os +import sys +from dataclasses import dataclass +from pathlib import Path +from types import SimpleNamespace +from typing import Optional + +import pytest + +os.environ.setdefault("REDIS_URL", "redis://u:p@localhost:6379") +os.environ.setdefault("SECRET_KEY", "test-secret") + +_BACKEND = Path(__file__).resolve().parents[2] / "src" / "backend" +if str(_BACKEND) not in sys.path: + sys.path.insert(0, str(_BACKEND)) + +from fastapi import FastAPI # noqa: E402 +from fastapi.testclient import TestClient # noqa: E402 + +from db_harness import db_backend, run as _hrun # noqa: E402,F401 + +import dependencies # noqa: E402 +import routers.agent_config as _CFG # noqa: E402 +import services.agent_service.api_key as _APIKEY # noqa: E402 +import services.agent_service.autonomy as _AUTONOMY # noqa: E402 +import services.agent_service.read_only as _READONLY # noqa: E402 +from database import db # noqa: E402 +from db_models import McpApiKeyCreate, UserCreate # noqa: E402 + +pytestmark = pytest.mark.unit + +OWNER = "cfg2996-owner" +ADMIN = "cfg2996-admin" +AGENT = "cfg2996-agent" +SYSTEM = "cfg2996-system" +GHOST = "cfg2996-no-such-agent" + + +@dataclass +class _Principal: + """Every field `models.User` carries, stated — never a MagicMock.""" + + id: int + username: str + email: Optional[str] + role: str + agent_name: Optional[str] = None + connector_agent: Optional[str] = None + portal_delegate: bool = False + mcp_scope: Optional[str] = None + mcp_key_id: Optional[str] = None + mcp_key_name: Optional[str] = None + vouched_source_agent: Optional[str] = None + + +_STOPPED = SimpleNamespace(status="exited", attrs={"Config": {"Labels": {}}}) + + +@pytest.fixture +def world(db_backend, monkeypatch): + monkeypatch.setattr(dependencies, "get_breaker_redis", lambda: None) + for mod in (_CFG, _AUTONOMY, _APIKEY, _READONLY): + monkeypatch.setattr(mod, "get_agent_container", lambda _n: _STOPPED) + db.create_user( + UserCreate(username=OWNER, role="user", email="cfg2996-owner@example.com") + ) + db.create_user( + UserCreate(username=ADMIN, role="admin", email="cfg2996-admin@example.com") + ) + db.register_agent_owner(AGENT, OWNER) + owner_id = db.get_user_by_username(OWNER)["id"] + + app = FastAPI() + app.include_router(_CFG.router) + client = TestClient(app) + + def as_(**fields): + base = { + "id": owner_id, + "username": OWNER, + "email": "cfg2996-owner@example.com", + "role": "user", + } + base.update(fields) + app.dependency_overrides[dependencies.get_current_user] = lambda: _Principal( + **base + ) + + yield SimpleNamespace(client=client, app=app, as_=as_) + app.dependency_overrides.clear() + + +def _auth(token: str) -> dict: + return {"Authorization": f"Bearer {token}"} + + +def _jwt(username: str) -> dict: + return _auth(dependencies.create_access_token({"sub": username})) + + +def _agent_key(agent: str = AGENT) -> dict: + return _auth(db.create_agent_mcp_api_key(agent, OWNER).api_key) + + +def _system_key() -> dict: + """Created the way the platform creates it: an agent key under the admin, + then its scope column set to `system`.""" + key = db.create_agent_mcp_api_key(SYSTEM, ADMIN) + _hrun("UPDATE mcp_api_keys SET scope = 'system' WHERE id = :i", i=key.id) + return _auth(key.api_key) + + +def _owner_user_key() -> dict: + return _auth( + db.create_mcp_api_key(OWNER, McpApiKeyCreate(name="owner key")).api_key + ) + + +# Principal kinds refused by the ROUTE (the real path fences ops/portal_delegate +# earlier, and the rest cannot be minted as a key row). +OVERRIDE_REFUSED = [ + pytest.param({"mcp_scope": "ops"}, id="ops-key"), + pytest.param( + {"mcp_scope": "portal_delegate", "portal_delegate": True}, + id="portal-delegate-key", + ), + pytest.param( + {"mcp_scope": "connector", "connector_agent": AGENT}, id="connector-key" + ), + pytest.param({"mcp_scope": "a-scope-shipped-tomorrow"}, id="unknown-future-scope"), + pytest.param( + {"mcp_scope": "user", "agent_name": AGENT}, id="user-scope-carrying-an-agent" + ), + pytest.param({"vouched_source_agent": AGENT}, id="loopback-jwt"), +] + + +def _is_human_only_refusal(res) -> bool: + detail = res.json().get("detail") + return ( + res.status_code == 403 + and isinstance(detail, dict) + and detail.get("code") == "person_required" + ) + + +# =========================================================================== +# PUT /api/agents/{name}/autonomy +# =========================================================================== + +AUTONOMY = f"/api/agents/{AGENT}/autonomy" + + +def _autonomy() -> bool: + return bool(db.get_autonomy_enabled(AGENT)) + + +class TestAutonomyRealKeys: + @pytest.fixture(autouse=True) + def _seed_off(self, world): + db.set_autonomy_enabled( + AGENT, False + ) # non-ambient start; every request asks for True + assert _autonomy() is False + + @pytest.mark.parametrize( + "headers", + [ + pytest.param(lambda: _agent_key(), id="agent-key-own-agent"), + pytest.param(lambda: _system_key(), id="system-key"), + ], + ) + def test_a_machine_key_is_refused_and_autonomy_stays_off(self, world, headers): + res = world.client.put(AUTONOMY, json={"enabled": True}, headers=headers()) + assert _is_human_only_refusal(res), res.text + assert _autonomy() is False + + def test_an_agent_key_is_refused_on_a_sibling_too(self, world): + db.register_agent_owner("cfg2996-sibling", OWNER) + res = world.client.put( + AUTONOMY, json={"enabled": True}, headers=_agent_key("cfg2996-sibling") + ) + assert _is_human_only_refusal(res), res.text + assert _autonomy() is False + + def test_the_refusal_does_not_disclose_whether_the_agent_exists(self, world): + res = world.client.put( + f"/api/agents/{GHOST}/autonomy", + json={"enabled": True}, + headers=_agent_key(), + ) + assert _is_human_only_refusal(res), res.text + + @pytest.mark.parametrize( + "headers", + [ + pytest.param(lambda: _jwt(OWNER), id="owner-jwt"), + pytest.param(lambda: _jwt(ADMIN), id="admin-jwt-not-owner"), + pytest.param(lambda: _owner_user_key(), id="owner-user-key"), + ], + ) + def test_a_person_turns_autonomy_on(self, world, headers): + res = world.client.put(AUTONOMY, json={"enabled": True}, headers=headers()) + assert res.status_code == 200, res.text + assert _autonomy() is True + + +class TestAutonomyEveryOtherPrincipal: + @pytest.fixture(autouse=True) + def _seed_off(self, world): + db.set_autonomy_enabled(AGENT, False) + + @pytest.mark.parametrize("principal", OVERRIDE_REFUSED) + def test_refused_by_the_route_gate(self, world, principal): + world.as_(**principal) + res = world.client.put(AUTONOMY, json={"enabled": True}) + assert _is_human_only_refusal(res), res.text + assert _autonomy() is False + + def test_a_principal_without_mcp_scope_fails_closed(self, world): + world.app.dependency_overrides[dependencies.get_current_user] = ( + lambda: SimpleNamespace( + id=1, username=OWNER, email="cfg2996-owner@example.com", role="user" + ) + ) + res = world.client.put(AUTONOMY, json={"enabled": True}) + assert res.status_code == 403, res.text + assert _autonomy() is False + + def test_the_read_stays_open_to_an_agent_key(self, world): + """GET /autonomy is a read at access level — unchanged.""" + res = world.client.get(AUTONOMY, headers=_agent_key()) + assert res.status_code == 200, res.text From f6bec5b36254e5b6fcc626a2612d87e7c071ea24 Mon Sep 17 00:00:00 2001 From: Andrii Pasternak <44377756+AndriiPasternak31@users.noreply.github.com> Date: Mon, 28 Sep 2026 01:22:57 +0100 Subject: [PATCH 08/23] fix(agent-config): the other agent-config writes are person-only too (#2996) PUT api-key-setting, read-only, resources, capabilities, capacity, timeout, public-channel-model and guardrails take Depends(require_person), the same rule as autonomy: the same file, the same owner configuration surface, and no agent or system caller. Each write is tested from its own non-ambient stored value: machine keys through the real get_current_user leave it unchanged, a person changes it. A router-level check pins that these and autonomy are every PUT in the file. Refs #2996 --- src/backend/routers/agent_config.py | 16 +- .../test_2996_owner_config_person_only.py | 139 ++++++++++++++++++ 2 files changed, 147 insertions(+), 8 deletions(-) diff --git a/src/backend/routers/agent_config.py b/src/backend/routers/agent_config.py index 16a1d8e5fd..26becb6924 100644 --- a/src/backend/routers/agent_config.py +++ b/src/backend/routers/agent_config.py @@ -43,7 +43,7 @@ async def update_agent_api_key_setting( agent_name: str, request: Request, body: dict, - current_user: User = Depends(get_current_user) + current_user: User = Depends(require_person) ): """Update the API key setting for an agent.""" return await update_agent_api_key_setting_logic(agent_name, body, current_user, request) @@ -124,7 +124,7 @@ async def get_agent_read_only_status( async def set_agent_read_only_status( agent_name: str, body: dict, - current_user: User = Depends(get_current_user) + current_user: User = Depends(require_person) ): """ Set the read-only mode status for an agent. @@ -186,7 +186,7 @@ async def set_agent_resources( agent_name: str, body: dict, request: Request, - current_user: User = Depends(get_current_user) + current_user: User = Depends(require_person) ): """ Set the resource limits for an agent. @@ -315,7 +315,7 @@ async def get_agent_capabilities( async def set_agent_capabilities( agent_name: str, body: dict, - current_user: User = Depends(get_current_user) + current_user: User = Depends(require_person) ): """ Set the capabilities for an agent. @@ -434,7 +434,7 @@ async def get_agent_capacity( async def set_agent_capacity( agent_name: str, body: AgentCapacityUpdate, - current_user: User = Depends(get_current_user) + current_user: User = Depends(require_person) ): """ Set the parallel execution capacity for an agent. @@ -514,7 +514,7 @@ async def get_agent_timeout( async def set_agent_timeout( agent_name: str, body: dict, - current_user: User = Depends(get_current_user) + current_user: User = Depends(require_person) ): """ Set the execution timeout for an agent. @@ -622,7 +622,7 @@ async def set_public_channel_model( agent_name: str, body: PublicChannelModelUpdate, request: Request, - current_user: User = Depends(get_current_user), + current_user: User = Depends(require_person), ): """Set or clear the per-agent public-channel model override (owner-only, #894). @@ -756,7 +756,7 @@ async def get_agent_guardrails( async def set_agent_guardrails( agent_name: str, body: dict, - current_user: User = Depends(get_current_user), + current_user: User = Depends(require_person), ): """Set per-agent guardrails overrides. Owner-only. Requires container recreation to take effect (the runtime config file is written during diff --git a/tests/unit/test_2996_owner_config_person_only.py b/tests/unit/test_2996_owner_config_person_only.py index c539483276..bdd6eb22c0 100644 --- a/tests/unit/test_2996_owner_config_person_only.py +++ b/tests/unit/test_2996_owner_config_person_only.py @@ -261,3 +261,142 @@ def test_the_read_stays_open_to_an_agent_key(self, world): """GET /autonomy is a read at access level — unchanged.""" res = world.client.get(AUTONOMY, headers=_agent_key()) assert res.status_code == 200, res.text + + +# =========================================================================== +# The other eight agent-config writes — same file, same class, same rule +# =========================================================================== + + +@dataclass(frozen=True) +class _ConfigWrite: + """One owner-config write: how to seed a non-ambient stored value, the body + that changes it to a different non-ambient value, and how to read it back.""" + + path: str + seed: object + body: dict + read: object + expected_after: object + + +_MODEL = "claude-sonnet-4-6" + +CONFIG_WRITES = { + "api-key-setting": _ConfigWrite( + "api-key-setting", + seed=lambda: db.set_use_platform_api_key(AGENT, False), + body={"use_platform_api_key": True}, + read=lambda: db.get_use_platform_api_key(AGENT), + expected_after=True, + ), + "read-only": _ConfigWrite( + "read-only", + seed=lambda: db.set_read_only_mode(AGENT, False, {"blocked_patterns": ["seed/*"], "allowed_patterns": []}), + body={"enabled": True, "config": {"blocked_patterns": ["*.md"], "allowed_patterns": []}}, + read=lambda: db.get_read_only_mode(AGENT)["enabled"], + expected_after=True, + ), + "resources": _ConfigWrite( + "resources", + seed=lambda: db.set_resource_limits(AGENT, memory="2g", cpu="1"), + body={"memory": "8g", "cpu": "4"}, + read=lambda: db.get_resource_limits(AGENT), + expected_after={"memory": "8g", "cpu": "4"}, + ), + "capabilities": _ConfigWrite( + "capabilities", + seed=lambda: db.set_full_capabilities(AGENT, False), + body={"full_capabilities": True}, + read=lambda: db.get_full_capabilities(AGENT), + expected_after=True, + ), + "capacity": _ConfigWrite( + "capacity", + seed=lambda: db.set_max_parallel_tasks(AGENT, 2), + body={"max_parallel_tasks": 5}, + read=lambda: db.get_max_parallel_tasks(AGENT), + expected_after=5, + ), + "timeout": _ConfigWrite( + "timeout", + seed=lambda: db.set_execution_timeout(AGENT, 900), + body={"execution_timeout_seconds": 1500}, + read=lambda: db.get_execution_timeout(AGENT), + expected_after=1500, + ), + "public-channel-model": _ConfigWrite( + "public-channel-model", + seed=lambda: db.set_public_channel_model(AGENT, "claude-haiku-4-5-20251001"), + body={"model": _MODEL}, + read=lambda: db.get_public_channel_model(AGENT), + expected_after=_MODEL, + ), + "guardrails": _ConfigWrite( + "guardrails", + seed=lambda: db.set_guardrails_config(AGENT, {"max_turns_chat": 7}), + body={"max_turns_chat": 42}, + read=lambda: db.get_guardrails_config(AGENT), + expected_after={"max_turns_chat": 42}, + ), +} + +WRITE_IDS = sorted(CONFIG_WRITES) + + +def _seeded(name: str): + w = CONFIG_WRITES[name] + w.seed() + before = w.read() + assert before != w.expected_after, f"{name}: the seed must differ from the target" + return w, before + + +def _put(world, w: _ConfigWrite, headers=None): + return world.client.put(f"/api/agents/{AGENT}/{w.path}", json=w.body, headers=headers or {}) + + +class TestConfigWritesRealKeys: + @pytest.mark.parametrize("name", WRITE_IDS) + @pytest.mark.parametrize("headers", [ + pytest.param(lambda: _agent_key(), id="agent-key-own-agent"), + pytest.param(lambda: _system_key(), id="system-key"), + ]) + def test_a_machine_key_is_refused_and_the_value_does_not_move(self, world, name, headers): + w, before = _seeded(name) + res = _put(world, w, headers()) + assert _is_human_only_refusal(res), res.text + assert w.read() == before + + @pytest.mark.parametrize("name", WRITE_IDS) + @pytest.mark.parametrize("headers", [ + pytest.param(lambda: _jwt(OWNER), id="owner-jwt"), + pytest.param(lambda: _owner_user_key(), id="owner-user-key"), + ]) + def test_a_person_changes_the_stored_value(self, world, name, headers): + w, before = _seeded(name) + res = _put(world, w, headers()) + assert 200 <= res.status_code < 300, res.text + assert w.read() == w.expected_after + + +class TestConfigWritesEveryOtherPrincipal: + @pytest.mark.parametrize("name", WRITE_IDS) + @pytest.mark.parametrize("principal", OVERRIDE_REFUSED) + def test_refused_by_the_route_gate(self, world, name, principal): + w, before = _seeded(name) + world.as_(**principal) + res = _put(world, w) + assert _is_human_only_refusal(res), res.text + assert w.read() == before + + +def test_every_write_in_the_file_is_person_gated(): + """The eight writes above plus autonomy are every PUT in agent_config.py — + a ninth would need a row here (and the route census names it too).""" + puts = sorted( + r.path.rsplit("/", 1)[-1] + for r in _CFG.router.routes + if "PUT" in getattr(r, "methods", set()) + ) + assert puts == sorted(WRITE_IDS + ["autonomy"]) From bdb3b59ee51fc7d6d7b0e2577670adaec39f5624 Mon Sep 17 00:00:00 2001 From: Andrii Pasternak <44377756+AndriiPasternak31@users.noreply.github.com> Date: Mon, 28 Sep 2026 01:24:32 +0100 Subject: [PATCH 09/23] fix(users): the sign-in email and personal GitHub PAT change only in a signed-in session PUT /api/users/me/email and PUT/DELETE /api/users/me/github-pat take Depends(require_interactive). The email is the account's sign-in identity and the PAT is the credential future agent creations inherit; both are credential/identity bindings, so no MCP key may change them, the person's own user key included. Tests: every key kind through the real get_current_user, with the stored email and encrypted PAT checked before and after; the session path (400 on a bad shape, 409 on a taken address, set and clear) unchanged; and a record that email sign-in resolves the account by the email column alone. Refs trinity-enterprise#711 --- src/backend/routers/users.py | 21 +- .../test_2996_self_service_session_only.py | 288 ++++++++++++++++++ 2 files changed, 305 insertions(+), 4 deletions(-) create mode 100644 tests/unit/test_2996_self_service_session_only.py diff --git a/src/backend/routers/users.py b/src/backend/routers/users.py index 5761baa977..05350641da 100644 --- a/src/backend/routers/users.py +++ b/src/backend/routers/users.py @@ -21,7 +21,12 @@ UserPreferencesResponse, ) from database import db -from dependencies import require_admin, get_current_user, reject_non_interactive_principal +from dependencies import ( + require_admin, + get_current_user, + reject_non_interactive_principal, + require_interactive, +) from services import user_preferences_service from services.user_preferences_service import PreferenceConflict, PreferenceError @@ -37,10 +42,14 @@ @router.put("/me/email") async def update_my_email( body: UpdateMyEmailRequest, - current_user: User = Depends(get_current_user), + current_user: User = Depends(require_interactive), ): """Bind a sign-in email to the current account (#82 Phase 1 transition). + Signed-in session only (trinity-enterprise#711): email sign-in resolves the + account by this column, so binding it is a sign-in identity change and no + MCP key may make it. + The migration path for an existing admin created before #82 — whose stored email is still the placeholder 'admin' — to register a real email and then sign in with email + password, exactly like a fresh install captures at @@ -93,10 +102,12 @@ async def get_my_github_pat_status(current_user: User = Depends(get_current_user @router.put("/me/github-pat") async def set_my_github_pat( body: GitHubPATRequest, - current_user: User = Depends(get_current_user), + current_user: User = Depends(require_interactive), ): """Store the caller's personal GitHub PAT (validated + encrypted at rest). + Signed-in session only: future agent creations inherit this credential. + Honest validation (ent#162): a token GitHub *rejects* is a 400; a token we simply could not verify because GitHub was unreachable is a 503 — we do not tell the user their token is bad when we never got an answer. @@ -130,9 +141,11 @@ async def set_my_github_pat( @router.delete("/me/github-pat") -async def clear_my_github_pat(current_user: User = Depends(get_current_user)): +async def clear_my_github_pat(current_user: User = Depends(require_interactive)): """Clear the caller's personal GitHub PAT — reverts them to the global PAT. + Signed-in session only, like setting it. + Agents already created under it keep their own persisted per-agent copy (#347) and are unaffected; only future creations fall back to the platform token (ent#162 AC #10). diff --git a/tests/unit/test_2996_self_service_session_only.py b/tests/unit/test_2996_self_service_session_only.py new file mode 100644 index 0000000000..bc71b832c3 --- /dev/null +++ b/tests/unit/test_2996_self_service_session_only.py @@ -0,0 +1,288 @@ +"""The sign-in email and the personal GitHub PAT change only in a signed-in session. + +`PUT /api/users/me/email` binds the account's sign-in identity +(trinity-enterprise#711), and `PUT` / `DELETE /api/users/me/github-pat` set the +credential future agent creations inherit (ent#162). All three take +`Depends(require_interactive)`: a JWT session only. Every MCP key — agent, +system, and the person's own `user` key — gets a 403 and the stored value does +not move. + +The machine keys go through the REAL `get_current_user` with key rows seeded in +the real schema; the kinds the entry point fences earlier or that cannot be +minted as a row go through an override with a stated principal, so the route's +own gate is what refuses them. + +The last class records how email sign-in resolves an account — by the email +column alone — which is why binding the sign-in email is a session-only act. + +Related: docs/memory/requirements/auth.md §2.8, +feature-flows/email-authentication.md. +""" + +from __future__ import annotations + +import os +import sys +from dataclasses import dataclass +from pathlib import Path +from types import SimpleNamespace +from typing import Optional + +import pytest + +os.environ.setdefault("REDIS_URL", "redis://u:p@localhost:6379") +os.environ.setdefault("SECRET_KEY", "test-secret") + +_BACKEND = Path(__file__).resolve().parents[2] / "src" / "backend" +if str(_BACKEND) not in sys.path: + sys.path.insert(0, str(_BACKEND)) + +from fastapi import FastAPI # noqa: E402 +from fastapi.testclient import TestClient # noqa: E402 + +from db_harness import db_backend, run as _hrun, scalar as _hscalar # noqa: E402,F401 + +import dependencies # noqa: E402 +import routers.users as _USERS # noqa: E402 +import services.github_service as _GH # noqa: E402 +from database import db # noqa: E402 +from db_models import McpApiKeyCreate, UserCreate # noqa: E402 + +pytestmark = pytest.mark.unit + +OWNER = "self2996-owner" +ADMIN = "self2996-admin" +AGENT = "self2996-agent" +SYSTEM = "self2996-system" +OWNER_EMAIL = ( + "self2996-owner@example.com" # non-ambient: every request asks for another +) +NEW_EMAIL = "self2996-new@example.com" +_ENC_KEY = "ab" * 32 + + +@dataclass +class _Principal: + """Every field `models.User` carries, stated — never a MagicMock.""" + + id: int + username: str + email: Optional[str] + role: str + agent_name: Optional[str] = None + connector_agent: Optional[str] = None + portal_delegate: bool = False + mcp_scope: Optional[str] = None + mcp_key_id: Optional[str] = None + mcp_key_name: Optional[str] = None + vouched_source_agent: Optional[str] = None + + +@pytest.fixture +def world(db_backend, monkeypatch): + monkeypatch.setattr(dependencies, "get_breaker_redis", lambda: None) + monkeypatch.setenv("CREDENTIAL_ENCRYPTION_KEY", _ENC_KEY) + + async def _valid(self): + return "valid", "octocat" + + monkeypatch.setattr(_GH.GitHubService, "validate_token_detailed", _valid) + db.create_user(UserCreate(username=OWNER, role="admin", email=OWNER_EMAIL)) + db.create_user( + UserCreate(username=ADMIN, role="admin", email="self2996-admin@example.com") + ) + db.register_agent_owner(AGENT, OWNER) + owner_id = db.get_user_by_username(OWNER)["id"] + assert db.set_user_github_pat(owner_id, "ghp_seededSeededSeeded0001") + + app = FastAPI() + app.include_router(_USERS.router) + + def as_(**fields): + base = { + "id": owner_id, + "username": OWNER, + "email": OWNER_EMAIL, + "role": "admin", + } + base.update(fields) + app.dependency_overrides[dependencies.get_current_user] = lambda: _Principal( + **base + ) + + yield SimpleNamespace(client=TestClient(app), app=app, as_=as_, owner_id=owner_id) + app.dependency_overrides.clear() + + +def _auth(token: str) -> dict: + return {"Authorization": f"Bearer {token}"} + + +def _jwt(username: str = OWNER) -> dict: + return _auth(dependencies.create_access_token({"sub": username})) + + +def _agent_key() -> dict: + return _auth(db.create_agent_mcp_api_key(AGENT, OWNER).api_key) + + +def _system_key() -> dict: + key = db.create_agent_mcp_api_key(SYSTEM, OWNER) + _hrun("UPDATE mcp_api_keys SET scope = 'system' WHERE id = :i", i=key.id) + return _auth(key.api_key) + + +def _owner_user_key() -> dict: + return _auth( + db.create_mcp_api_key(OWNER, McpApiKeyCreate(name="owner key")).api_key + ) + + +KEY_KINDS = [ + pytest.param(_agent_key, id="agent-key"), + pytest.param(_system_key, id="system-key"), + pytest.param(_owner_user_key, id="owner-user-key"), +] + +OVERRIDE_REFUSED = [ + pytest.param({"mcp_scope": "ops"}, id="ops-key"), + pytest.param( + {"mcp_scope": "portal_delegate", "portal_delegate": True}, + id="portal-delegate-key", + ), + pytest.param( + {"mcp_scope": "connector", "connector_agent": AGENT}, id="connector-key" + ), + pytest.param({"mcp_scope": "a-scope-shipped-tomorrow"}, id="unknown-future-scope"), + pytest.param({"vouched_source_agent": AGENT}, id="loopback-jwt"), +] + + +def _stored_email() -> str: + return _hscalar("SELECT email FROM users WHERE username = :u", u=OWNER) + + +def _stored_pat() -> Optional[str]: + return _hscalar( + "SELECT github_pat_encrypted FROM users WHERE username = :u", u=OWNER + ) + + +# (method, path, json body) +WRITES = { + "email": ("PUT", "/api/users/me/email", {"email": NEW_EMAIL}), + "github-pat-set": ( + "PUT", + "/api/users/me/github-pat", + {"pat": "ghp_replacementReplacement02"}, + ), + "github-pat-clear": ("DELETE", "/api/users/me/github-pat", None), +} + + +def _call(world, name, headers=None): + method, path, body = WRITES[name] + return world.client.request(method, path, json=body, headers=headers or {}) + + +def _snapshot(): + email, pat = _stored_email(), _stored_pat() + assert email == OWNER_EMAIL and pat, "seed missing" + return email, pat + + +class TestKeysAreRefused: + @pytest.mark.parametrize("name", sorted(WRITES)) + @pytest.mark.parametrize("key", KEY_KINDS) + def test_a_key_is_refused_and_nothing_is_stored(self, world, name, key): + before = _snapshot() + res = _call(world, name, key()) + assert res.status_code == 403, res.text + assert (_stored_email(), _stored_pat()) == before + + @pytest.mark.parametrize("name", sorted(WRITES)) + @pytest.mark.parametrize("principal", OVERRIDE_REFUSED) + def test_refused_by_the_route_gate(self, world, name, principal): + before = _snapshot() + world.as_(**principal) + res = _call(world, name) + assert res.status_code == 403, res.text + assert (_stored_email(), _stored_pat()) == before + + +class TestSessionPathUnchanged: + def test_a_session_binds_a_new_email(self, world): + res = _call(world, "email", _jwt()) + assert res.status_code == 200, res.text + assert _stored_email() == NEW_EMAIL + + def test_a_malformed_email_is_still_400(self, world): + res = world.client.put( + "/api/users/me/email", json={"email": "not-an-email"}, headers=_jwt() + ) + assert res.status_code == 400, res.text + assert _stored_email() == OWNER_EMAIL + + def test_another_accounts_email_is_still_409(self, world): + res = world.client.put( + "/api/users/me/email", + json={"email": "self2996-admin@example.com"}, + headers=_jwt(), + ) + assert res.status_code == 409, res.text + assert _stored_email() == OWNER_EMAIL + + def test_a_session_replaces_the_pat(self, world): + before = _stored_pat() + res = _call(world, "github-pat-set", _jwt()) + assert res.status_code == 200, res.text + after = _stored_pat() + assert after and after != before + + def test_a_session_clears_the_pat(self, world): + res = _call(world, "github-pat-clear", _jwt()) + assert res.status_code == 200, res.text + assert not _stored_pat() + assert db.has_user_github_pat(world.owner_id) is False + + def test_the_pat_status_read_is_unchanged(self, world): + """GET /me/github-pat returns a flag, never the token — left as it was.""" + res = world.client.get("/api/users/me/github-pat", headers=_owner_user_key()) + assert res.status_code == 200, res.text + assert res.json()["configured"] is True + + +class TestEmailSignInResolvesByEmailAlone: + """Email sign-in finds the account by `users.email`, nothing else + (`db/email_auth.py::get_or_create_email_user`). So the account a mailbox + signs in to is whichever row carries that address — which is why binding + the sign-in email is a session-only act.""" + + def test_after_a_rebind_the_old_address_resolves_to_a_new_account(self, world): + db.add_to_whitelist( + OWNER_EMAIL, added_by=ADMIN, source="manual", default_role="user" + ) + assert _call(world, "email", _jwt()).status_code == 200 + + resolved = db.get_or_create_email_user(OWNER_EMAIL) + assert resolved["username"] == OWNER_EMAIL + assert resolved["id"] != world.owner_id + assert _stored_email() == NEW_EMAIL + + def test_after_a_rebind_an_email_named_account_cannot_be_recreated(self, world): + """An account whose username IS its address: the new row collides with + it on the username key, so the lookup raises rather than resolving.""" + db.create_user( + UserCreate( + username="self2996-r@example.com", + role="user", + email="self2996-r@example.com", + ) + ) + _hrun( + "UPDATE users SET email = :e WHERE username = :u", + e="self2996-elsewhere@example.com", + u="self2996-r@example.com", + ) + with pytest.raises(Exception): + db.get_or_create_email_user("self2996-r@example.com") From 6dbcd144f28a9fd3bd525ce8355f26f35a97483c Mon Sep 17 00:00:00 2001 From: Andrii Pasternak <44377756+AndriiPasternak31@users.noreply.github.com> Date: Mon, 28 Sep 2026 11:20:32 +0100 Subject: [PATCH 10/23] =?UTF-8?q?test(auth):=20route=20census=20=E2=80=94?= =?UTF-8?q?=20every=20HTTP=20route=20chooses=20a=20policy=20class=20(#2996?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every OSS backend route, every method, must resolve to exactly one class: gated in code (interactive / person / admin tier / widened admin / portal), listed with a reason (agent-callable / own-auth / delegated), or in the frozen baseline, which only shrinks. A new route that is neither gated nor classified fails the build, so the next human-only route inherits the rule instead of relying on someone remembering it. The AST side is import-free (rglob over src/backend, minus the private submodule). The runtime side imports main in a subprocess and checks every stored METHOD /path against the live table; it fails, never skips. --- tests/registry.json | 24 + tests/unit/_route_census.py | 880 ++++++++++++++++++ tests/unit/fixtures/README.md | 14 + .../fixtures/human_only_route_baseline.json | 363 ++++++++ tests/unit/test_2996_human_only_routes.py | 638 +++++++++++++ tests/unit/test_2996_route_census_runtime.py | 66 ++ 6 files changed, 1985 insertions(+) create mode 100644 tests/unit/_route_census.py create mode 100644 tests/unit/fixtures/human_only_route_baseline.json create mode 100644 tests/unit/test_2996_human_only_routes.py create mode 100644 tests/unit/test_2996_route_census_runtime.py diff --git a/tests/registry.json b/tests/registry.json index d0e6ec7bff..8d5cdd0ccf 100644 --- a/tests/registry.json +++ b/tests/registry.json @@ -1,5 +1,29 @@ { "test_files": [ + { + "file": "unit/test_2996_human_only_routes.py", + "feature": "#2996", + "added": "2026-09-28", + "categories": [ + "backend", + "auth", + "security", + "guard" + ], + "description": "Route census (Invariant #8): every OSS HTTP route, every method, resolves to exactly one policy class (interactive / person / admin_widened / admin_tier / portal from code; agent_callable / own_auth / delegated from reviewed tables; or the frozen human_only_route_baseline.json). Ratchet rules: unclassified fails, stale entry fails, gated baseline entry fails, exact FROZEN_BASELINE_COUNT, key in two lists fails, duplicate key fails. Gates count only as Depends or as the handler's first statement on its principal, imported unaliased from dependencies. Websocket routes pinned as a literal set. Self-tests plant sources in a temp tree to show each rule bites. Walker: tests/unit/_route_census.py (pure-stdlib AST, rglob over src/backend minus enterprise/)." + }, + { + "file": "unit/test_2996_route_census_runtime.py", + "feature": "#2996", + "added": "2026-09-28", + "categories": [ + "backend", + "auth", + "security", + "guard" + ], + "description": "Route census, live side: imports main in a SUBPROCESS and checks every registered non-enterprise APIRoute maps to a key the AST census found, and every stored METHOD /full/path (baseline + reviewed tables) is what the app registers (rule 7). Fails, never skips, on a failed import, unparseable output or zero routes." + }, { "file": "unit/test_human_only_principals.py", "feature": "#2996", diff --git a/tests/unit/_route_census.py b/tests/unit/_route_census.py new file mode 100644 index 0000000000..d63f55c790 --- /dev/null +++ b/tests/unit/_route_census.py @@ -0,0 +1,880 @@ +"""The route census walker and its reviewed tables (#2996, Invariant #8). + +Every HTTP route in the OSS backend — every method, GETs included — must resolve +to exactly one policy class. This module is the one place that answers "which +class is this route in"; `test_2996_human_only_routes.py` (the AST side) and +`test_2996_route_census_runtime.py` (the live route table) both read through it. + +Pure stdlib, imports nothing from `src/` (read source, never import it — the +#762 stub-leak class cannot reach an AST walk). Underscore-prefixed so pytest +never collects it. + +Identity of a route is `"::"`, relative to +`src/backend/`: stable under prefix and line drift. `add_api_route` targets are +resolved to the file that DEFINES the handler, so the key matches the runtime +`endpoint.__module__` + `__qualname__`. + +Policy classes +-------------- +Recognised from code (a gate name counts only when imported, unaliased, from +`dependencies` — or `get_portal_principal` from `client_portal.portal_auth` — +and not redefined in the file): + +* ``interactive`` / ``person`` — ``Depends(require_interactive|require_person)`` + in the signature or the decorator's ``dependencies=[...]``; or + ``reject_non_interactive_principal`` / ``assert_person`` / + ``reject_non_person_principal`` as the handler's FIRST statement (after the + docstring), called on its principal parameter (one whose default is + ``Depends(...)``). A call nested in ``if``/``try``, placed after another + statement, or made on another name does not count. +* ``admin_tier`` — the same recognition for ``require_admin`` and for + ``assert_admin`` without ``allow_scopes=``. Counted and printed, not listed: + the admin tier's own policy (which admits `trinity-system` by #2323 design) is + guarded by test_293 / test_2323. +* ``admin_widened`` — ``Depends(require_admin_allowing(...))`` or + ``assert_admin(..., allow_scopes=...)``. Each needs an ``ADMIN_WIDENED`` entry: + a widening grants a scope and is reviewed per route. +* ``portal`` — ``Depends(get_portal_principal)``; the portal principal's own + policy (#2198) owns it. + +A route carrying several recognised gates takes the strictest +(``interactive > person > admin_widened > admin_tier > portal``). + +Listed here, per route, with the registered ``"METHOD /full/path"`` pinned and +checked against the live app by the runtime test: + +* ``agent_callable`` — a reviewed use an agent key may make, with a reason. +* ``own_auth`` — authenticates itself (internal secret, webhook token, payment, + inline MCP auth) or is unauthenticated by design. +* ``delegated`` — the check lives in ``services/``; names the behavioural test + that proves it, and the census asserts that test exists. + +Everything else must sit in the frozen baseline +(``fixtures/human_only_route_baseline.json``) — ``unclassified_at_freeze``. The +baseline is written ONCE by hand (``python tests/unit/_route_census.py +--freeze``) and only ever shrinks; the test never regenerates it. + +Router-level ``APIRouter(dependencies=...)`` / ``include_router(dependencies=...)`` +are NOT recognised (none exist today): such routes stay unclassified, which fails +closed. WebSocket routes are outside the HTTP census but pinned as a literal set. +""" + +from __future__ import annotations + +import ast +import json +import sys +from dataclasses import dataclass, field +from pathlib import Path +from typing import Dict, Iterable, List, Optional, Set, Tuple + +REPO = Path(__file__).resolve().parents[2] +BACKEND = REPO / "src" / "backend" +BASELINE_PATH = ( + Path(__file__).resolve().parent / "fixtures" / "human_only_route_baseline.json" +) + +# Top-level directories under src/backend/ the census does not walk. +# `enterprise/` is the private submodule and carries its own census (#1677). +EXCLUDED_TOP = frozenset({"enterprise", "tests", "__pycache__"}) + +HTTP_DECORATORS = { + "get": ("GET",), + "post": ("POST",), + "put": ("PUT",), + "patch": ("PATCH",), + "delete": ("DELETE",), + "head": ("HEAD",), + "options": ("OPTIONS",), + "api_route": None, # methods from the `methods=` keyword +} + +CODE_CLASSES = ( + "interactive", + "person", + "admin_widened", + "admin_tier", + "portal", +) # strictest first +LISTED_CLASSES = ("agent_callable", "own_auth", "delegated", "unclassified_at_freeze") + +# Gate names and the module each must be imported from. +_DEPENDS_GATES = { + ("dependencies", "require_interactive"): "interactive", + ("dependencies", "require_person"): "person", + ("dependencies", "require_admin"): "admin_tier", + ("client_portal.portal_auth", "get_portal_principal"): "portal", +} +_DEPENDS_FACTORIES = {("dependencies", "require_admin_allowing"): "admin_widened"} +_IMPERATIVE_GATES = { + ("dependencies", "reject_non_interactive_principal"): "interactive", + ("dependencies", "assert_person"): "person", + ("dependencies", "reject_non_person_principal"): "person", + ( + "dependencies", + "assert_admin", + ): "admin_tier", # admin_widened when allow_scopes= is passed +} + + +@dataclass +class Route: + key: str + relpath: str + qualname: str + lineno: int + methods: Tuple[str, ...] + gates: Set[str] = field(default_factory=set) + problems: List[str] = field(default_factory=list) + + @property + def code_class(self) -> Optional[str]: + for cls in CODE_CLASSES: + if cls in self.gates: + return cls + return None + + +@dataclass +class Census: + routes: Dict[str, Route] + websockets: Set[str] + problems: List[str] # walker-level problems (duplicates, unresolvable targets) + + +# --------------------------------------------------------------------------- # +# Source walking +# --------------------------------------------------------------------------- # + + +def backend_files(root: Path = BACKEND) -> List[Path]: + """Every `*.py` under `root` via rglob (a new package is walked with no + edit here — the Invariant #1 `glob` blindness), minus EXCLUDED_TOP.""" + out = [] + for p in sorted(root.rglob("*.py")): + rel = p.relative_to(root) + if rel.parts[0] in EXCLUDED_TOP or "__pycache__" in rel.parts: + continue + out.append(p) + return out + + +def _module_of(relpath: str) -> str: + parts = relpath[:-3].split("/") + if parts[-1] == "__init__": + parts = parts[:-1] + return ".".join(parts) + + +def _package_of(relpath: str) -> str: + mod = _module_of(relpath) + if relpath.endswith("__init__.py"): + return mod + return mod.rpartition(".")[0] + + +def _resolve_relative(relpath: str, module: Optional[str], level: int) -> str: + if level == 0: + return module or "" + pkg = _package_of(relpath).split(".") if _package_of(relpath) else [] + if level > 1: + pkg = pkg[: len(pkg) - (level - 1)] + base = ".".join(pkg) + if module: + return f"{base}.{module}" if base else module + return base + + +class _FileIndex: + """Module-level imports and definitions of one source file.""" + + def __init__(self, relpath: str, tree: ast.Module): + self.relpath = relpath + self.tree = tree + self.names: Dict[str, Tuple[str, str]] = {} # local -> (module, original) + self.modules: Dict[str, str] = {} # local -> dotted module + self.local_defs: Set[str] = set() + for node in tree.body: + if isinstance(node, ast.ImportFrom): + mod = _resolve_relative(relpath, node.module, node.level) + for alias in node.names: + local = alias.asname or alias.name + # `from pkg import sub` may name a submodule; record both. + self.names[local] = (mod, alias.name) + self.modules[local] = f"{mod}.{alias.name}" if mod else alias.name + elif isinstance(node, ast.Import): + for alias in node.names: + self.modules[alias.asname or alias.name.split(".")[0]] = ( + alias.name if alias.asname else alias.name.split(".")[0] + ) + elif isinstance( + node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef) + ): + self.local_defs.add(node.name) + elif isinstance(node, (ast.Assign, ast.AnnAssign)): + targets = ( + node.targets if isinstance(node, ast.Assign) else [node.target] + ) + for t in targets: + if isinstance(t, ast.Name): + self.local_defs.add(t.id) + + def resolve(self, expr: ast.AST) -> Optional[Tuple[str, str]]: + """(module, name) an expression refers to, if it is an unaliased, + un-shadowed imported name or an attribute of an imported module.""" + if isinstance(expr, ast.Name): + if expr.id in self.local_defs or expr.id not in self.names: + return None + mod, orig = self.names[expr.id] + if orig != expr.id: # aliased (`import x as require_person`) + return None + return mod, orig + if isinstance(expr, ast.Attribute) and isinstance(expr.value, ast.Name): + mod = self.modules.get(expr.value.id) + if mod and expr.value.id not in self.local_defs: + return mod, expr.attr + return None + + +def _is_depends(call: ast.AST) -> bool: + if not isinstance(call, ast.Call) or not call.args: + return False + f = call.func + name = ( + f.id + if isinstance(f, ast.Name) + else (f.attr if isinstance(f, ast.Attribute) else None) + ) + return name in ("Depends", "Security") + + +def _depends_gate(idx: _FileIndex, call: ast.Call) -> Optional[str]: + target = call.args[0] + if isinstance(target, ast.Call): + ref = idx.resolve(target.func) + return _DEPENDS_FACTORIES.get(ref) if ref else None + ref = idx.resolve(target) + return _DEPENDS_GATES.get(ref) if ref else None + + +def _principal_params(fn: ast.AST) -> Set[str]: + args = fn.args + positional = args.posonlyargs + args.args + out = set() + for a, d in zip(positional[len(positional) - len(args.defaults) :], args.defaults): + if _is_depends(d): + out.add(a.arg) + for a, d in zip(args.kwonlyargs, args.kw_defaults): + if d is not None and _is_depends(d): + out.add(a.arg) + return out + + +def _signature_depends(fn: ast.AST) -> Iterable[ast.Call]: + args = fn.args + for d in list(args.defaults) + [d for d in args.kw_defaults if d is not None]: + if _is_depends(d): + yield d + + +def _first_statement(fn: ast.AST) -> Optional[ast.stmt]: + body = list(fn.body) + if ( + body + and isinstance(body[0], ast.Expr) + and isinstance(getattr(body[0], "value", None), ast.Constant) + and isinstance(body[0].value.value, str) + ): + body = body[1:] + return body[0] if body else None + + +def _imperative_gate(idx: _FileIndex, fn: ast.AST) -> Optional[str]: + stmt = _first_statement(fn) + if not (isinstance(stmt, ast.Expr) and isinstance(stmt.value, ast.Call)): + return None + call = stmt.value + ref = idx.resolve(call.func) + if ref not in _IMPERATIVE_GATES: + return None + principals = _principal_params(fn) + if not ( + call.args + and isinstance(call.args[0], ast.Name) + and call.args[0].id in principals + ): + return None + cls = _IMPERATIVE_GATES[ref] + if cls == "admin_tier" and any(k.arg == "allow_scopes" for k in call.keywords): + return "admin_widened" + return cls + + +def _route_decorators( + fn: ast.AST, +) -> List[Tuple[ast.Call, Optional[Tuple[str, ...]], bool]]: + """(decorator call, methods, is_websocket) for each route decorator.""" + out = [] + for dec in fn.decorator_list: + if not (isinstance(dec, ast.Call) and isinstance(dec.func, ast.Attribute)): + continue + attr = dec.func.attr + if attr == "websocket": + out.append((dec, None, True)) + elif attr in HTTP_DECORATORS: + methods = HTTP_DECORATORS[attr] + if methods is None: + methods = tuple(sorted(_methods_kw(dec) or ("GET",))) + out.append((dec, methods, False)) + return out + + +def _methods_kw(call: ast.Call) -> Optional[Tuple[str, ...]]: + for k in call.keywords: + if k.arg == "methods" and isinstance(k.value, (ast.List, ast.Tuple, ast.Set)): + return tuple( + e.value.upper() + for e in k.value.elts + if isinstance(e, ast.Constant) and isinstance(e.value, str) + ) + return None + + +def _gates_for( + idx: _FileIndex, fn: ast.AST, decorators: Iterable[ast.Call] +) -> Set[str]: + gates: Set[str] = set() + for dep in _signature_depends(fn): + g = _depends_gate(idx, dep) + if g: + gates.add(g) + for dec in decorators: + for k in dec.keywords: + if k.arg == "dependencies" and isinstance(k.value, (ast.List, ast.Tuple)): + for e in k.value.elts: + if _is_depends(e): + g = _depends_gate(idx, e) + if g: + gates.add(g) + imp = _imperative_gate(idx, fn) + if imp: + gates.add(imp) + return gates + + +def _walk_defs(tree: ast.Module): + """Yield (qualname, function node) for every function, with runtime-style + qualnames (`outer..inner`, `Class.method`).""" + + def visit(node, prefix): + for child in ast.iter_child_nodes(node): + if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef)): + q = f"{prefix}{child.name}" + yield q, child + yield from visit(child, f"{q}..") + elif isinstance(child, ast.ClassDef): + yield from visit(child, f"{prefix}{child.name}.") + else: + yield from visit(child, prefix) + + yield from visit(tree, "") + + +def _module_to_relpath(module: str, root: Path) -> Optional[str]: + base = module.replace(".", "/") + for cand in (f"{base}.py", f"{base}/__init__.py"): + if (root / cand).is_file(): + return cand + return None + + +def walk(root: Path = BACKEND, files: Optional[List[Path]] = None) -> Census: + files = files if files is not None else backend_files(root) + indexes: Dict[str, _FileIndex] = {} + ordered: Dict[str, List[Tuple[str, ast.AST]]] = {} + defs: Dict[str, Dict[str, ast.AST]] = {} + for p in files: + rel = p.relative_to(root).as_posix() + tree = ast.parse(p.read_text(encoding="utf-8"), filename=str(p)) + indexes[rel] = _FileIndex(rel, tree) + ordered[rel] = list(_walk_defs(tree)) # a redefinition stays visible + defs[rel] = dict(ordered[rel]) + + routes: Dict[str, Route] = {} + websockets: Set[str] = set() + problems: List[str] = [] + + def add(rel: str, qual: str, fn: ast.AST, methods: Tuple[str, ...], decorators): + key = f"{rel}::{qual}" + if key in routes: + existing = routes[key] + if existing.lineno != fn.lineno or existing.relpath != rel: + problems.append(f"duplicate census key {key}") + existing.methods = tuple(sorted(set(existing.methods) | set(methods))) + return + routes[key] = Route( + key=key, + relpath=rel, + qualname=qual, + lineno=fn.lineno, + methods=tuple(sorted(methods)), + gates=_gates_for(indexes[rel], fn, decorators), + ) + + for rel, idx in indexes.items(): + for qual, fn in ordered[rel]: + decs = _route_decorators(fn) + http = [(d, m) for d, m, ws in decs if not ws] + if any(ws for _, _, ws in decs): + websockets.add(f"{rel}::{qual}") + if http: + add( + rel, + qual, + fn, + tuple(sorted({m for _, ms in http for m in ms})), + [d for d, _ in http], + ) + + # `.add_api_route(path, endpoint, methods=[...])` + for node in ast.walk(idx.tree): + if not ( + isinstance(node, ast.Call) + and isinstance(node.func, ast.Attribute) + and node.func.attr == "add_api_route" + ): + continue + target = ( + node.args[1] + if len(node.args) > 1 + else next((k.value for k in node.keywords if k.arg == "endpoint"), None) + ) + methods = _methods_kw(node) or ("GET",) + resolved = _resolve_endpoint(idx, target, root) + if resolved is None: + problems.append( + f"{rel}:{node.lineno}: add_api_route target cannot be resolved to its definition" + ) + continue + def_rel, name = resolved + fn = defs.get(def_rel, {}).get(name) + if fn is None: + problems.append( + f"{rel}:{node.lineno}: add_api_route target {def_rel}::{name} not found" + ) + continue + add(def_rel, name, fn, tuple(sorted(methods)), []) + + return Census(routes=routes, websockets=websockets, problems=problems) + + +def _resolve_endpoint( + idx: _FileIndex, target: Optional[ast.AST], root: Path +) -> Optional[Tuple[str, str]]: + if isinstance(target, ast.Name): + if target.id in idx.local_defs: + return idx.relpath, target.id + if target.id in idx.names: + mod, orig = idx.names[target.id] + rel = _module_to_relpath(mod, root) + return (rel, orig) if rel else None + return None + if isinstance(target, ast.Attribute) and isinstance(target.value, ast.Name): + mod = idx.modules.get(target.value.id) + rel = _module_to_relpath(mod, root) if mod else None + return (rel, target.attr) if rel else None + return None + + +# --------------------------------------------------------------------------- # +# The reviewed tables +# --------------------------------------------------------------------------- # + +# Widenings of the admin gate to another scope (#2323). Grants, reviewed per route. +# key -> ("METHOD /full/path", scopes, reason) +ADMIN_WIDENED: Dict[str, Tuple[str, Tuple[str, ...], str]] = { + "routers/ops.py::get_fleet_status": ("GET /api/ops/fleet/status", ("ops",), "#2323 ops read fence: an ops key reads fleet state; role == admin still enforced"), + "routers/ops.py::get_fleet_health": ("GET /api/ops/fleet/health", ("ops",), "#2323 ops read fence: an ops key reads fleet state; role == admin still enforced"), + "routers/ops.py::list_all_schedules": ("GET /api/ops/schedules", ("ops",), "#2323 ops read fence: an ops key reads fleet state; role == admin still enforced"), + "routers/ops.py::list_alerts": ("GET /api/ops/alerts", ("ops",), "#2323 ops read fence: an ops key reads fleet state; role == admin still enforced"), + "routers/ops.py::get_ops_costs": ("GET /api/ops/costs", ("ops",), "#2323 ops read fence: an ops key reads fleet state; role == admin still enforced"), + "routers/ops.py::get_auth_report": ("GET /api/ops/auth-report", ("ops",), "#2323 ops read fence: an ops key reads fleet state; role == admin still enforced"), + "routers/subscriptions.py::get_subscription_usage": ( + "GET /api/subscriptions/{subscription_id}/usage", ("ops",), "#2323 ops read fence: an ops key reads fleet state; role == admin still enforced (subscription pressure, #2389)"), +} + +# Reviewed uses an agent key may make. key -> ("METHOD /full/path", reason) +AGENT_CALLABLE: Dict[str, Tuple[str, str]] = { + # The agent's own runtime surface (#2996 §3.4 regression set). + "routers/agents.py::agent_heartbeat": ( + "POST /api/agents/{agent_name}/heartbeat", + "liveness; authenticates the agent's own agent-scoped key outside get_current_user (#307)"), + "routers/agents.py::agent_execution_result": ( + "POST /api/agents/{agent_name}/executions/{execution_id}/result", + "terminal result delivery; authenticates the agent's own key and path self-match (#1083)"), + "routers/reports.py::create_report": ( + "POST /api/agents/{name}/reports", "structured output an agent publishes about itself (self-check)"), + "routers/notifications.py::create_notification": ( + "POST /api/notifications", "an agent raises a notification; agent name comes from its key (NOTIF-001)"), + # MCP schedule tools — the agent's own use; autonomy (person-only) bounds cron firing. + "routers/schedules.py::create_schedule": ( + "POST /api/agents/{name}/schedules", "MCP create_schedule; unattended firing is gated by autonomy"), + "routers/schedules.py::update_schedule": ( + "PUT /api/agents/{name}/schedules/{schedule_id}", "MCP update_schedule"), + "routers/schedules.py::delete_schedule": ( + "DELETE /api/agents/{name}/schedules/{schedule_id}", "MCP delete_schedule"), + "routers/schedules.py::enable_schedule": ( + "POST /api/agents/{name}/schedules/{schedule_id}/enable", "MCP enable_schedule; cron still gated by autonomy"), + "routers/schedules.py::disable_schedule": ( + "POST /api/agents/{name}/schedules/{schedule_id}/disable", "MCP disable_schedule"), + "routers/schedules.py::trigger_schedule": ( + "POST /api/agents/{name}/schedules/{schedule_id}/trigger", "MCP trigger_schedule (a manual run)"), + # Reads of the settings whose writes #2996 made person-only. + "routers/agent_config.py::get_agent_api_key_setting": ("GET /api/agents/{agent_name}/api-key-setting", "a non-sensitive read of this agent's own setting (access-level)"), + "routers/agent_config.py::get_agent_autonomy_status": ("GET /api/agents/{agent_name}/autonomy", "a non-sensitive read of this agent's own setting (access-level)"), + "routers/agent_config.py::get_agent_read_only_status": ("GET /api/agents/{agent_name}/read-only", "a non-sensitive read of this agent's own setting (access-level)"), + "routers/agent_config.py::get_agent_resources": ("GET /api/agents/{agent_name}/resources", "a non-sensitive read of this agent's own setting (access-level)"), + "routers/agent_config.py::get_agent_capabilities": ("GET /api/agents/{agent_name}/capabilities", "a non-sensitive read of this agent's own setting (access-level)"), + "routers/agent_config.py::get_agent_capacity": ("GET /api/agents/{agent_name}/capacity", "a non-sensitive read of this agent's own setting (access-level)"), + "routers/agent_config.py::get_agent_timeout": ("GET /api/agents/{agent_name}/timeout", "a non-sensitive read of this agent's own setting (access-level)"), + "routers/agent_config.py::get_public_channel_model": ("GET /api/agents/{agent_name}/public-channel-model", "a non-sensitive read of this agent's own setting (access-level)"), + "routers/agent_config.py::get_agent_guardrails": ("GET /api/agents/{agent_name}/guardrails", "a non-sensitive read of this agent's own setting (access-level)"), + "routers/users.py::get_my_github_pat_status": ( + "GET /api/users/me/github-pat", "configured flags only, never the token (ent#162)"), +} + +# Authenticates itself, or unauthenticated by design. key -> ("METHOD /full/path", reason) +_SECRET = "internal shared secret (router-level verify_internal_secret, C-003)" +_PUBLIC_LINK = "public-link surface; the link token in the path is the credential" +_PULL = "pull seam: internal secret OR the agent's own scoped key (#1081)" +_INLINE = "internal secret + inline-auth flag (#848)" + +OWN_AUTH: Dict[str, Tuple[str, str]] = { + "routers/internal.py::agent_files_share": ("POST /api/internal/agent-files/share", _SECRET), + "routers/internal.py::complete_activity": ("POST /api/internal/activities/{activity_id}/complete", _SECRET), + "routers/internal.py::execute_task_internal": ("POST /api/internal/execute-task", _SECRET), + "routers/internal.py::internal_agent_brief_readiness": ("GET /api/internal/agents/{agent_name}/brief-readiness", _SECRET), + "routers/internal.py::internal_agent_pre_check": ("POST /api/internal/agents/{agent_name}/pre-check", _SECRET), + "routers/internal.py::internal_agent_sync_health": ("GET /api/internal/agents/{agent_name}/sync-health-status", _SECRET), + "routers/internal.py::internal_health": ("GET /api/internal/health", _SECRET), + "routers/internal.py::internal_next_task": ("GET /api/internal/next-task", _PULL), + "routers/internal.py::internal_task_result": ("POST /api/internal/tasks/{execution_id}/result", _PULL), + "routers/internal.py::log_audit_entry": ("POST /api/internal/audit", _SECRET), + "routers/internal.py::mcp_exposed_agents": ("GET /api/internal/mcp-exposed-agents", _SECRET), + "routers/internal.py::track_activity": ("POST /api/internal/activities/track", _SECRET), + "routers/internal.py::validate_execution": ("POST /api/internal/validate-execution", _SECRET), + "routers/setup.py::get_setup_status": ("GET /api/setup/status", "unauthenticated by design (first-run status)"), + "routers/setup.py::set_admin_password": ("POST /api/setup/admin-password", "unauthenticated by design until setup completes, then refused"), + "routers/webhooks.py::trigger_webhook": ("POST /api/webhooks/{webhook_token}", "the webhook token in the path is the credential"), + "routers/paid.py::get_paid_agent_info": ("GET /api/paid/{agent_name}/info", "public paid-agent listing (x402)"), + "routers/paid.py::paid_chat": ("POST /api/paid/{agent_name}/chat", "x402 payment is the credential"), + "routers/mcp_auth.py::inline_chat": ("POST /api/internal/mcp-auth/chat", _INLINE), + "routers/mcp_auth.py::inline_playbooks": ("POST /api/internal/mcp-auth/playbooks", _INLINE), + "routers/mcp_auth.py::request_inline_login": ("POST /api/internal/mcp-auth/request", _INLINE), + "routers/mcp_auth.py::verify_inline_login": ("POST /api/internal/mcp-auth/verify", _INLINE), + "routers/auth.py::get_auth_mode": ("GET /api/auth/mode", "unauthenticated by design (login page)"), + "routers/auth.py::login": ("POST /token", "password sign-in"), + "routers/auth.py::login_api": ("POST /api/token", "password sign-in"), + "routers/auth.py::request_access": ("POST /api/access/request", "unauthenticated by design (access request)"), + "routers/auth.py::request_email_login_code": ("POST /api/auth/email/request", "email sign-in, step 1"), + "routers/auth.py::verify_email_login_code": ("POST /api/auth/email/verify", "email sign-in, step 2"), + "routers/auth.py::validate_token": ("GET /api/auth/validate", "validates the presented token itself"), + "routers/public.py::clear_public_session": ("DELETE /api/public/session/{token}", _PUBLIC_LINK), + "routers/public.py::confirm_verification_code": ("POST /api/public/verify/confirm", "public-link surface; the link token in the path is the credential (email verification)"), + "routers/public.py::get_agent_intro": ("GET /api/public/intro/{token}", _PUBLIC_LINK), + "routers/public.py::get_public_chat_history": ("GET /api/public/history/{token}", _PUBLIC_LINK), + "routers/public.py::get_public_link_info": ("GET /api/public/link/{token}", _PUBLIC_LINK), + "routers/public.py::get_public_playbooks": ("GET /api/public/playbooks/{token}", _PUBLIC_LINK), + "routers/public.py::public_chat": ("POST /api/public/chat/{token}", _PUBLIC_LINK), + "routers/public.py::public_execution_status": ("GET /api/public/executions/{token}/{execution_id}/status", _PUBLIC_LINK), + "routers/public.py::public_stream_execution": ("GET /api/public/executions/{token}/{execution_id}/stream", _PUBLIC_LINK), + "routers/public.py::public_terminate_execution": ("POST /api/public/executions/{token}/{execution_id}/terminate", _PUBLIC_LINK), + "routers/public.py::request_verification_code": ("POST /api/public/verify/request", "public-link surface; the link token in the path is the credential (email verification)"), + "routers/public.py::tls_allowed": ("GET /api/public/tls-allowed", "unauthenticated by design (reverse-proxy on-demand TLS probe)"), + "routers/a2a.py::a2a_well_known_card": ("GET /a2a/{agent_name}/.well-known/agent-card.json", "unauthenticated by design (A2A discovery card)"), + "routers/mcp_keys.py::validate_mcp_api_key_http_endpoint": ("POST /api/mcp/validate", "validates the presented MCP key itself (MCP server auth)"), + "main.py::health_check": ("GET /health", "unauthenticated by design (health probe)"), + "routers/slack.py::handle_slack_event": ("POST /api/public/slack/events", "Slack request signature"), + "routers/slack.py::slack_oauth_callback": ("GET /api/public/slack/oauth/callback", "OAuth state parameter"), + "routers/telegram.py::handle_telegram_webhook": ("POST /api/telegram/webhook/{webhook_secret}", "per-binding webhook secret"), + "routers/whatsapp.py::handle_twilio_webhook": ("POST /api/whatsapp/webhook/{webhook_secret}", "per-binding webhook secret + Twilio signature"), + "client_portal/router.py::portal_auth_request": ("POST /api/enterprise/client-portal/auth/request", "portal sign-in, step 1"), + "client_portal/router.py::portal_auth_verify": ("POST /api/enterprise/client-portal/auth/verify", "portal sign-in, step 2"), + "routers/files.py::download_shared_file": ("GET /api/files/{file_id}", "the share signature is the credential (FILES-001)"), + "routers/files.py::head_shared_file": ("HEAD /api/files/{file_id}", "the share signature is the credential (FILES-001)"), + "routers/avatar.py::get_avatar": ("GET /api/agents/{agent_name}/avatar", "unauthenticated by design (avatars are public assets)"), + "routers/avatar.py::get_avatar_emotion": ("GET /api/agents/{agent_name}/avatar/emotion/{emotion}", "unauthenticated by design (public asset)"), + "routers/avatar.py::get_avatar_emotions": ("GET /api/agents/{agent_name}/avatar/emotions", "unauthenticated by design (public asset)"), + "routers/avatar.py::get_avatar_reference": ("GET /api/agents/{agent_name}/avatar/reference", "unauthenticated by design (public asset)"), +} + +# Check lives in services/. key -> ("METHOD /full/path", service function, behavioural test id) +DELEGATED: Dict[str, Tuple[str, str, str]] = {} + +# WebSocket routes — out of the HTTP census, pinned so a new one is a visible edit. +WEBSOCKET_ROUTES = frozenset({ + "main.py::websocket_endpoint", + "main.py::websocket_events_endpoint", + "routers/agents.py::agent_terminal", + "routers/system_agent.py::system_agent_terminal", + "routers/voice.py::voice_websocket", + "routers/voip.py::voip_media_stream", +}) + +# The exact size of the frozen baseline. Lower it in the same change that +# removes an entry; it never goes up. +FROZEN_BASELINE_COUNT = 361 + + +def load_baseline(path: Path = BASELINE_PATH) -> Dict[str, str]: + return json.loads(path.read_text(encoding="utf-8")) + + +# --------------------------------------------------------------------------- # +# Classification +# --------------------------------------------------------------------------- # + + +# Failure tags — one per ratchet rule, so each rule is its own test. +UNCLASSIFIED = "rule1-unclassified" +STALE = "rule2-stale-entry" +GATED_IN_BASELINE = "rule3-gated-baseline-entry" +COUNT = "rule4-baseline-count" +TWO_LISTS = "rule5-key-in-two-lists" +DUPLICATE = "rule6-duplicate-key" +PATH_MISMATCH = "rule7-runtime-path-mismatch" +WIDENED_UNLISTED = "admin-widened-unlisted" +DELEGATED_TEST = "delegated-test-missing" + + +def classify( + census: Census, + baseline: Dict[str, str], + *, + admin_widened=None, + agent_callable=None, + own_auth=None, + delegated=None, +) -> Tuple[Dict[str, str], List[Tuple[str, str]]]: + """Return ({key: class}, [(rule tag, message)]).""" + admin_widened = ADMIN_WIDENED if admin_widened is None else admin_widened + agent_callable = AGENT_CALLABLE if agent_callable is None else agent_callable + own_auth = OWN_AUTH if own_auth is None else own_auth + delegated = DELEGATED if delegated is None else delegated + lists = { + "agent_callable": agent_callable, + "own_auth": own_auth, + "delegated": delegated, + "unclassified_at_freeze": baseline, + } + out: Dict[str, str] = {} + failures: List[Tuple[str, str]] = [(DUPLICATE, p) for p in census.problems if p.startswith("duplicate")] + failures += [(UNCLASSIFIED, p) for p in census.problems if not p.startswith("duplicate")] + + for name, table in list(lists.items()) + [("admin_widened", admin_widened)]: + for key in table: + if key not in census.routes: + failures.append((STALE, f"stale {name} entry {key}: no such route — remove it")) + + for key, route in sorted(census.routes.items()): + code = route.code_class + member = [n for n, t in lists.items() if key in t] + if code is not None: + if key in baseline: + failures.append((GATED_IN_BASELINE, ( + f"{key} is now gated ({code}) — remove it from the baseline and lower " + f"FROZEN_BASELINE_COUNT (the baseline only shrinks)"))) + for n in member: + if n != "unclassified_at_freeze": + failures.append((STALE, f"{key} is gated ({code}) — its {n} entry is stale, remove it")) + if code == "admin_widened" and key not in admin_widened: + failures.append((WIDENED_UNLISTED, ( + f"{key} widens the admin gate — add an ADMIN_WIDENED entry with its scopes and reason"))) + out[key] = code + continue + if key in admin_widened: + failures.append((STALE, f"{key} is listed in ADMIN_WIDENED but no longer widens the admin gate — remove it")) + if len(member) > 1: + failures.append((TWO_LISTS, f"{key} is in more than one list: {member}")) + if not member: + failures.append((UNCLASSIFIED, ( + f"{key} ({','.join(route.methods)}, line {route.lineno}) is unclassified. Gate it " + f"(Depends(require_person|require_interactive|require_admin), or the imperative " + f"call as the handler's first statement on its principal), or list it in " + f"AGENT_CALLABLE / OWN_AUTH / DELEGATED in tests/unit/_route_census.py with a " + f"reason. Never add it to the baseline."))) + continue + out[key] = member[0] + + for key, (_mp, _fn, test_id) in delegated.items(): + if not _test_exists(test_id): + failures.append((DELEGATED_TEST, f"DELEGATED {key} names a behavioural test that does not exist: {test_id}")) + return out, failures + + +def count_failures(baseline: Dict[str, str], frozen: int) -> List[Tuple[str, str]]: + """Rule 4: the baseline is exactly FROZEN_BASELINE_COUNT entries.""" + if len(baseline) == frozen: + return [] + return [(COUNT, ( + f"baseline has {len(baseline)} entries but FROZEN_BASELINE_COUNT is {frozen}. Gating a " + f"route removes its entry AND lowers the literal in the same change; the baseline never grows")), + ] + + +def path_failures( + universe: Iterable[str], + paths: Dict[str, List[str]], + stored: Dict[str, str], +) -> List[Tuple[str, str]]: + """Rule 7 plus the registration cross-check: every live route maps to a + census key the AST found, and every stored "METHOD /path" is what the live + app registers for that key (so an exemption cannot silently follow a + function onto another path or method).""" + universe = set(universe) + failures: List[Tuple[str, str]] = [] + for key in sorted(paths): + if key not in universe: + failures.append((UNCLASSIFIED, ( + f"live route {key} ({stored_value(paths[key])}) is not in the AST census — a " + f"registration shape the walker does not see; teach the walker"))) + for key, value in sorted(stored.items()): + live = stored_value(paths.get(key, [])) + if value != live: + failures.append((PATH_MISMATCH, f"{key}: stored {value!r} but the app registers {live!r}")) + return failures + + +def _test_exists(test_id: str) -> bool: + path_s, _, name = test_id.partition("::") + name = name.split("::")[-1].split("[")[0] + path = REPO / path_s + if not path.is_file() or not name: + return False + tree = ast.parse(path.read_text(encoding="utf-8")) + return any( + isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef)) and n.name == name + for n in ast.walk(tree) + ) + + +# --------------------------------------------------------------------------- # +# Runtime dump (run in a subprocess by the runtime test and by --freeze) +# --------------------------------------------------------------------------- # + +RUNTIME_DUMP = r""" +import json, os, sys, tempfile +from pathlib import Path +backend = sys.argv[1] +td = tempfile.mkdtemp(prefix="trinity-route-census-") +for k, v in { + "REDIS_URL": "redis://test:test@redis:6379", "REDIS_PASSWORD": "test", + "REDIS_BACKEND_PASSWORD": "test", "AGENT_AUTH_SECRET": "0" * 64, + "SECRET_KEY": "x" * 32, "INTERNAL_API_SECRET": "y" * 32, +}.items(): + os.environ.setdefault(k, v) +os.environ["TRINITY_DB_PATH"] = str(Path(td) / "census.db") +os.environ["LOG_ARCHIVE_PATH"] = str(Path(td) / "logs") +sys.path.insert(0, backend) +import main +from fastapi.routing import APIRoute +flat = [] +for entry in main.app.routes: + if type(entry).__name__ == "_IncludedRouter": + flat.extend(r for r in entry.original_router.routes if isinstance(r, APIRoute)) + elif isinstance(entry, APIRoute): + flat.append(entry) +rows = [ + {"module": r.endpoint.__module__, "qualname": r.endpoint.__qualname__, + "methods": sorted(r.methods or []), "path": r.path} + for r in flat +] +sys.stdout.write("\n__CENSUS__" + json.dumps(rows)) +""" + + +def runtime_routes( + python: str = sys.executable, backend: Path = BACKEND, timeout: int = 300 +): + """Import the real app in a SUBPROCESS and return its routes. Raises + (never skips) on a failed import, unparseable output or zero routes.""" + import subprocess + + proc = subprocess.run( + [python, "-c", RUNTIME_DUMP, str(backend)], + capture_output=True, + text=True, + timeout=timeout, + cwd=str(REPO), + ) + if proc.returncode != 0: + raise RuntimeError( + f"`import main` failed in the census subprocess (exit {proc.returncode}):\n{proc.stderr[-4000:]}" + ) + marker = proc.stdout.rfind("__CENSUS__") + if marker < 0: + raise RuntimeError( + f"census subprocess printed no route dump:\n{proc.stdout[-2000:]}" + ) + rows = json.loads(proc.stdout[marker + len("__CENSUS__") :]) + if not rows: + raise RuntimeError("census subprocess found zero routes") + return rows + + +def runtime_key(module: str, qualname: str, root: Path = BACKEND) -> Optional[str]: + rel = _module_to_relpath(module, root) + return f"{rel}::{qualname}" if rel else None + + +def is_enterprise_module(module: str) -> bool: + return module == "enterprise" or module.startswith("enterprise.") + + +def runtime_paths(rows) -> Dict[str, List[str]]: + """{census key: sorted ["METHOD /path", ...]} over the non-enterprise routes.""" + out: Dict[str, Set[str]] = {} + for r in rows: + if is_enterprise_module(r["module"]): + continue + key = ( + runtime_key(r["module"], r["qualname"]) + or f"::{r['qualname']}" + ) + for m in r["methods"]: + out.setdefault(key, set()).add(f"{m} {r['path']}") + return {k: sorted(v) for k, v in out.items()} + + +NOT_REGISTERED = "(not registered)" + + +def stored_value(paths: List[str]) -> str: + """The baseline / table value for a key: its registered "METHOD /path" + entries, or NOT_REGISTERED for a handler no app includes.""" + return " | ".join(paths) if paths else NOT_REGISTERED + + +def freeze() -> None: # pragma: no cover - run by hand, once + census = walk() + rows = runtime_routes() + paths = runtime_paths(rows) + if census.problems: + raise SystemExit(f"fix the walker problems first: {census.problems}") + listed = set(AGENT_CALLABLE) | set(OWN_AUTH) | set(DELEGATED) + baseline = { + key: stored_value(paths.get(key, [])) + for key, route in sorted(census.routes.items()) + if route.code_class is None and key not in listed + } + BASELINE_PATH.write_text( + json.dumps(baseline, indent=2, sort_keys=True) + "\n", encoding="utf-8" + ) + print(f"wrote {len(baseline)} entries to {BASELINE_PATH}") + + +if __name__ == "__main__": # pragma: no cover + if sys.argv[1:] == ["--freeze"]: + freeze() + else: + print("usage: python tests/unit/_route_census.py --freeze") diff --git a/tests/unit/fixtures/README.md b/tests/unit/fixtures/README.md index b8c6150939..4cfa82d4c1 100644 --- a/tests/unit/fixtures/README.md +++ b/tests/unit/fixtures/README.md @@ -45,3 +45,17 @@ Note that the kept answer is *itself* a checkpoint ("…when it reports back I'l ledger each created draft…"), not a finished deliverable. That is not incidental: it is precisely the partial-recovery risk the `#1870` recovery notice (`_RECOVERY_NOTICE`) exists to flag to an operator. + +## `human_only_route_baseline.json` + +The frozen baseline of the #2996 route census (`tests/unit/_route_census.py`): +every OSS HTTP route that, at freeze, was neither gated by a recognised principal +rule nor listed with a reason in `AGENT_CALLABLE` / `OWN_AUTH` / `DELEGATED`. +Keys are `::`; values are the +registered `"METHOD /full/path"`, checked against the live app by +`test_2996_route_census_runtime.py`. + +It is written **once, by hand** (`python tests/unit/_route_census.py --freeze`) +and only ever shrinks: gating or classifying a route removes its entry and lowers +`FROZEN_BASELINE_COUNT` in the same change. A new route never goes here — it is +gated or classified instead. Being listed is a to-do marker, not a judgement. diff --git a/tests/unit/fixtures/human_only_route_baseline.json b/tests/unit/fixtures/human_only_route_baseline.json new file mode 100644 index 0000000000..df91f6a5a1 --- /dev/null +++ b/tests/unit/fixtures/human_only_route_baseline.json @@ -0,0 +1,363 @@ +{ + "client_portal/router.py::block_agent_client": "POST /api/enterprise/client-portal/agents/{agent_name}/clients/{email}/block", + "client_portal/router.py::list_agent_clients": "GET /api/enterprise/client-portal/agents/{agent_name}/clients", + "client_portal/router.py::logout_agent_client": "POST /api/enterprise/client-portal/agents/{agent_name}/clients/{email}/logout", + "client_portal/router.py::portal_auth_exchange": "POST /api/enterprise/client-portal/auth/exchange", + "client_portal/router.py::unblock_agent_client": "DELETE /api/enterprise/client-portal/agents/{agent_name}/clients/{email}/block", + "main.py::get_current_user_info": "GET /api/users/me", + "main.py::get_version": "GET /api/version", + "routers/a2a.py::a2a_jsonrpc": "POST /a2a/{agent_name}", + "routers/a2a.py::call_a2a_agent": "POST /api/agents/{agent_name}/a2a/call", + "routers/a2a.py::get_a2a_task": "POST /api/agents/{agent_name}/a2a/task", + "routers/a2a.py::get_agent_card": "GET /api/agents/{agent_name}/a2a/agent-card", + "routers/activities.py::get_activity_timeline": "GET /api/activities/timeline", + "routers/agent_brain_orb.py::get_brain_orb_actions": "GET /api/agents/{agent_name}/brain-orb/actions", + "routers/agent_brain_orb.py::get_brain_orb_data": "GET /api/agents/{agent_name}/brain-orb/data", + "routers/agent_brain_orb.py::get_brain_orb_postprocess": "GET /api/agents/{agent_name}/brain-orb/postprocess", + "routers/agent_brain_orb.py::get_brain_orb_scopes": "GET /api/agents/{agent_name}/brain-orb/scopes", + "routers/agent_brain_orb.py::post_brain_orb_action": "POST /api/agents/{agent_name}/brain-orb/action", + "routers/agent_brain_orb.py::post_brain_orb_refresh": "POST /api/agents/{agent_name}/brain-orb/refresh", + "routers/agent_brain_orb.py::post_brain_orb_scope": "POST /api/agents/{agent_name}/brain-orb/scope", + "routers/agent_brain_orb.py::post_brain_orb_tool": "POST /api/agents/{agent_name}/brain-orb/tool", + "routers/agent_brain_orb.py::post_brain_orb_voice_token": "POST /api/agents/{agent_name}/brain-orb/voice-token", + "routers/agent_brain_orb.py::put_brain_orb_postprocess": "PUT /api/agents/{agent_name}/brain-orb/postprocess", + "routers/agent_dashboard.py::check_dashboard_exists": "GET /api/agent-dashboard/{agent_name}/exists", + "routers/agent_dashboard.py::get_agent_dashboard": "GET /api/agent-dashboard/{name}", + "routers/agent_data.py::export_agent_data": "POST /api/agents/{agent_name}/data/export", + "routers/agent_data.py::import_agent_data": "POST /api/agents/{agent_name}/data/import", + "routers/agent_files.py::add_agent_permission": "POST /api/agents/{agent_name}/permissions/{target_agent}", + "routers/agent_files.py::create_agent_folder_endpoint": "POST /api/agents/{agent_name}/files/mkdir", + "routers/agent_files.py::delete_agent_file_endpoint": "DELETE /api/agents/{agent_name}/files", + "routers/agent_files.py::download_agent_file_endpoint": "GET /api/agents/{agent_name}/files/download", + "routers/agent_files.py::get_agent_file_sharing": "GET /api/agents/{agent_name}/file-sharing", + "routers/agent_files.py::get_agent_folders": "GET /api/agents/{agent_name}/folders", + "routers/agent_files.py::get_agent_info_endpoint": "GET /api/agents/{agent_name}/info", + "routers/agent_files.py::get_agent_metric_definitions": "GET /api/agents/{agent_name}/metrics/definitions", + "routers/agent_files.py::get_agent_metrics": "GET /api/agents/{agent_name}/metrics", + "routers/agent_files.py::get_agent_objectives": "GET /api/agents/{agent_name}/objectives", + "routers/agent_files.py::get_agent_permissions": "GET /api/agents/{agent_name}/permissions", + "routers/agent_files.py::get_agent_playbooks_endpoint": "GET /api/agents/{agent_name}/playbooks", + "routers/agent_files.py::get_available_shared_folders": "GET /api/agents/{agent_name}/folders/available", + "routers/agent_files.py::get_folder_consumers": "GET /api/agents/{agent_name}/folders/consumers", + "routers/agent_files.py::list_agent_files_endpoint": "GET /api/agents/{agent_name}/files", + "routers/agent_files.py::list_agent_shared_files": "GET /api/agents/{agent_name}/shared-files", + "routers/agent_files.py::preview_agent_file_endpoint": "GET /api/agents/{agent_name}/files/preview", + "routers/agent_files.py::refresh_agent_metric_definitions": "POST /api/agents/{agent_name}/metrics/definitions/refresh", + "routers/agent_files.py::remove_agent_permission": "DELETE /api/agents/{agent_name}/permissions/{target_agent}", + "routers/agent_files.py::revoke_agent_shared_file": "DELETE /api/agents/{agent_name}/shared-files/{file_id}", + "routers/agent_files.py::set_agent_file_sharing": "PUT /api/agents/{agent_name}/file-sharing", + "routers/agent_files.py::set_agent_permissions": "PUT /api/agents/{agent_name}/permissions", + "routers/agent_files.py::share_agent_file": "POST /api/agents/{agent_name}/shared-files", + "routers/agent_files.py::update_agent_file_endpoint": "PUT /api/agents/{agent_name}/files", + "routers/agent_files.py::update_agent_folders": "PUT /api/agents/{agent_name}/folders", + "routers/agent_rename.py::rename_agent_endpoint": "PUT /api/agents/{agent_name}/rename", + "routers/agents.py::clear_agent_queue": "POST /api/agents/{agent_name}/queue/clear", + "routers/agents.py::create_agent_endpoint": "POST /api/agents", + "routers/agents.py::delete_agent_endpoint": "DELETE /api/agents/{agent_name}", + "routers/agents.py::deploy_local_agent": "POST /api/agents/deploy-local", + "routers/agents.py::force_release_agent": "POST /api/agents/{agent_name}/queue/release", + "routers/agents.py::get_activity_timeline": "GET /api/agents/activities/timeline", + "routers/agents.py::get_agent_activities": "GET /api/agents/{agent_name}/activities", + "routers/agents.py::get_agent_endpoint": "GET /api/agents/{agent_name}", + "routers/agents.py::get_agent_label_endpoint": "GET /api/agents/{agent_name}/label", + "routers/agents.py::get_agent_logs_endpoint": "GET /api/agents/{agent_name}/logs", + "routers/agents.py::get_agent_queue_status": "GET /api/agents/{agent_name}/queue", + "routers/agents.py::get_agent_stats_endpoint": "GET /api/agents/{agent_name}/stats", + "routers/agents.py::get_agent_token_stats": "GET /api/agents/{agent_name}/token-stats", + "routers/agents.py::get_agents_context_stats": "GET /api/agents/context-stats", + "routers/agents.py::get_agents_execution_stats": "GET /api/agents/execution-stats", + "routers/agents.py::get_all_agent_slots": "GET /api/agents/slots", + "routers/agents.py::get_all_autonomy_status": "GET /api/agents/autonomy-status", + "routers/agents.py::get_all_permission_edges": "GET /api/agents/permissions-edges", + "routers/agents.py::get_all_sync_health": "GET /api/agents/sync-health", + "routers/agents.py::get_circuit_breaker_endpoint": "GET /api/agents/{agent_name}/circuit-breaker", + "routers/agents.py::get_mcp_exposed_endpoint": "GET /api/agents/{agent_name}/mcp-exposed", + "routers/agents.py::get_operator_resume_endpoint": "GET /api/agents/{agent_name}/operator-resume", + "routers/agents.py::get_subscription_pressure": "GET /api/agents/subscription-pressure", + "routers/agents.py::get_voice_replies_endpoint": "GET /api/agents/{agent_name}/voice-replies", + "routers/agents.py::list_agents_endpoint": "GET /api/agents", + "routers/agents.py::send_voice_reply_endpoint": "POST /api/agents/{agent_name}/voice-reply", + "routers/agents.py::set_agent_label_endpoint": "PUT /api/agents/{agent_name}/label", + "routers/agents.py::set_circuit_breaker_endpoint": "PUT /api/agents/{agent_name}/circuit-breaker", + "routers/agents.py::set_mcp_exposed_endpoint": "PUT /api/agents/{agent_name}/mcp-exposed", + "routers/agents.py::set_operator_resume_endpoint": "PUT /api/agents/{agent_name}/operator-resume", + "routers/agents.py::set_voice_replies_endpoint": "PUT /api/agents/{agent_name}/voice-replies", + "routers/agents.py::start_agent_endpoint": "POST /api/agents/{agent_name}/start", + "routers/agents.py::stop_agent_endpoint": "POST /api/agents/{agent_name}/stop", + "routers/analytics.py::get_agent_analytics": "GET /api/agents/{name}/analytics", + "routers/auth.py::logout": "POST /api/auth/logout", + "routers/avatar.py::delete_avatar": "DELETE /api/agents/{agent_name}/avatar", + "routers/avatar.py::generate_avatar": "POST /api/agents/{agent_name}/avatar/generate", + "routers/avatar.py::get_avatar_identity": "GET /api/agents/{agent_name}/avatar/identity", + "routers/avatar.py::regenerate_avatar": "POST /api/agents/{agent_name}/avatar/regenerate", + "routers/canvas.py::bulk_delete_canvases": "POST /api/agents/{name}/canvas/bulk-delete", + "routers/canvas.py::canvas_context": "GET /api/agents/{name}/canvas/context", + "routers/canvas.py::clear_canvas": "DELETE /api/agents/{name}/canvas/{canvas_id}", + "routers/canvas.py::create_canvas_share": "POST /api/agents/{name}/canvas/{canvas_id}/share", + "routers/canvas.py::get_canvas": "GET /api/agents/{name}/canvas/{canvas_id}", + "routers/canvas.py::list_canvas_shares": "GET /api/agents/{name}/canvas/shares", + "routers/canvas.py::list_canvases": "GET /api/agents/{name}/canvas", + "routers/canvas.py::patch_canvas": "PATCH /api/agents/{name}/canvas/{canvas_id}", + "routers/canvas.py::pin_canvas": "PUT /api/agents/{name}/canvas/{canvas_id}/pin", + "routers/canvas.py::revoke_canvas_share": "DELETE /api/agents/{name}/canvas/shares/{share_id}", + "routers/canvas.py::write_canvas": "PUT /api/agents/{name}/canvas/{canvas_id}", + "routers/chat.py::chat_with_agent": "POST /api/agents/{name}/chat", + "routers/chat.py::clear_agent_activity": "DELETE /api/agents/{name}/activity", + "routers/chat.py::close_chat_session": "POST /api/agents/{name}/chat/sessions/{session_id}/close", + "routers/chat.py::execute_parallel_task": "POST /api/agents/{name}/task", + "routers/chat.py::get_agent_activity": "GET /api/agents/{name}/activity", + "routers/chat.py::get_agent_activity_detail": "GET /api/agents/{name}/activity/{tool_id}", + "routers/chat.py::get_agent_chat_history": "GET /api/agents/{name}/chat/history", + "routers/chat.py::get_agent_chat_session": "GET /api/agents/{name}/chat/session", + "routers/chat.py::get_agent_chat_sessions": "GET /api/agents/{name}/chat/sessions", + "routers/chat.py::get_agent_model": "GET /api/agents/{name}/model", + "routers/chat.py::get_agent_running_executions": "GET /api/agents/{name}/executions/running", + "routers/chat.py::get_chat_session_detail": "GET /api/agents/{name}/chat/sessions/{session_id}", + "routers/chat.py::get_persistent_chat_history": "GET /api/agents/{name}/chat/history/persistent", + "routers/chat.py::reset_agent_chat_history": "DELETE /api/agents/{name}/chat/history", + "routers/chat.py::set_agent_model": "PUT /api/agents/{name}/model", + "routers/chat.py::stream_execution_log": "GET /api/agents/{name}/executions/{execution_id}/stream", + "routers/chat.py::terminate_agent_execution": "POST /api/agents/{name}/executions/{execution_id}/terminate", + "routers/compatibility.py::fix_compatibility": "POST /api/agents/{agent_name}/compatibility/fix", + "routers/compatibility.py::get_compatibility": "GET /api/agents/{agent_name}/compatibility", + "routers/connector.py::get_connector": "GET /api/agents/{agent_name}/connector", + "routers/connector.py::list_connector_playbooks": "GET /api/agents/{agent_name}/connector/playbooks", + "routers/connector.py::regenerate_connector_key": "POST /api/agents/{agent_name}/connector/key", + "routers/connector.py::revoke_connector_key": "DELETE /api/agents/{agent_name}/connector/key", + "routers/connector.py::update_connector": "PUT /api/agents/{agent_name}/connector", + "routers/credentials.py::export_credentials": "POST /api/agents/{agent_name}/credentials/export", + "routers/credentials.py::get_agent_credential_requirements": "GET /api/agents/{agent_name}/credential-requirements", + "routers/credentials.py::get_agent_credentials_status": "GET /api/agents/{agent_name}/credentials/status", + "routers/credentials.py::get_agent_env_drift": "GET /api/agents/{agent_name}/credentials/env-drift", + "routers/credentials.py::import_credentials": "POST /api/agents/{agent_name}/credentials/import", + "routers/credentials.py::init_oauth": "POST /api/oauth/{provider}/init", + "routers/credentials.py::inject_credentials": "POST /api/agents/{agent_name}/credentials/inject", + "routers/credentials.py::list_oauth_providers": "GET /api/oauth/providers", + "routers/docs.py::get_doc_content": "(not registered)", + "routers/docs.py::get_docs_index": "(not registered)", + "routers/docs.py::list_docs": "(not registered)", + "routers/evaluations.py::get_evaluation": "GET /api/evaluations/{eval_id}", + "routers/evaluations.py::list_agent_evaluations": "GET /api/agents/{agent_name}/evaluations", + "routers/evaluations.py::list_fleet_evaluations": "GET /api/evaluations", + "routers/event_subscriptions.py::create_event_subscription": "POST /api/agents/{name}/event-subscriptions", + "routers/event_subscriptions.py::delete_event_subscription": "DELETE /api/event-subscriptions/{subscription_id}", + "routers/event_subscriptions.py::emit_event": "POST /api/events", + "routers/event_subscriptions.py::emit_event_for_agent": "POST /api/agents/{name}/emit-event", + "routers/event_subscriptions.py::get_event_subscription": "GET /api/event-subscriptions/{subscription_id}", + "routers/event_subscriptions.py::list_agent_events": "GET /api/agents/{name}/events", + "routers/event_subscriptions.py::list_all_events": "GET /api/events", + "routers/event_subscriptions.py::list_event_subscriptions": "GET /api/agents/{name}/event-subscriptions", + "routers/event_subscriptions.py::update_event_subscription": "PUT /api/event-subscriptions/{subscription_id}", + "routers/executions.py::get_fleet_execution_stats": "GET /api/executions/stats", + "routers/executions.py::get_fleet_execution_timeline": "GET /api/executions/timeline", + "routers/executions.py::list_fleet_executions": "GET /api/executions", + "routers/fan_out.py::fan_out": "POST /api/agents/{name}/fan-out", + "routers/fan_out.py::get_fan_out_status": "GET /api/agents/{name}/fan-out/{fan_out_id}", + "routers/fleet.py::fleet_sync_audit": "GET /api/fleet/sync-audit", + "routers/git.py::bind_agent_to_own_repo": "POST /api/agents/{agent_name}/git/bind-to-own-repo", + "routers/git.py::clear_agent_github_pat": "DELETE /api/agents/{agent_name}/github-pat", + "routers/git.py::get_agent_github_pat_status": "GET /api/agents/{agent_name}/github-pat", + "routers/git.py::get_agent_sync_state": "GET /api/agents/{agent_name}/git/sync-state", + "routers/git.py::get_auto_sync_config": "GET /api/agents/{agent_name}/git/auto-sync", + "routers/git.py::get_bind_to_own_repo_status": "GET /api/agents/{agent_name}/git/bind-to-own-repo/status", + "routers/git.py::get_freeze_schedules_config": "GET /api/agents/{agent_name}/git/freeze-schedules-if-failing", + "routers/git.py::get_git_config": "GET /api/agents/{agent_name}/git/config", + "routers/git.py::get_git_log": "GET /api/agents/{agent_name}/git/log", + "routers/git.py::get_git_status": "GET /api/agents/{agent_name}/git/status", + "routers/git.py::initialize_github_sync": "POST /api/agents/{agent_name}/git/initialize", + "routers/git.py::pull_from_github": "POST /api/agents/{agent_name}/git/pull", + "routers/git.py::reset_to_main_preserve_state": "POST /api/agents/{agent_name}/git/reset-to-main-preserve-state", + "routers/git.py::set_agent_github_pat": "PUT /api/agents/{agent_name}/github-pat", + "routers/git.py::set_auto_sync_config": "PUT /api/agents/{agent_name}/git/auto-sync", + "routers/git.py::set_freeze_schedules_config": "PUT /api/agents/{agent_name}/git/freeze-schedules-if-failing", + "routers/git.py::sync_to_github": "POST /api/agents/{agent_name}/git/sync", + "routers/image_generation.py::generate_image": "POST /api/images/generate", + "routers/image_generation.py::get_models": "GET /api/images/models", + "routers/loops.py::get_loop_status": "GET /api/loops/{loop_id}", + "routers/loops.py::list_loops": "GET /api/agents/{name}/loops", + "routers/loops.py::start_loop": "POST /api/agents/{name}/loops", + "routers/loops.py::stop_loop": "POST /api/loops/{loop_id}/stop", + "routers/mcp_keys.py::delete_mcp_api_key_endpoint": "DELETE /api/mcp/keys/{key_id}", + "routers/mcp_keys.py::get_mcp_api_key_endpoint": "GET /api/mcp/keys/{key_id}", + "routers/mcp_keys.py::list_mcp_api_keys_endpoint": "GET /api/mcp/keys", + "routers/mcp_keys.py::revoke_mcp_api_key_endpoint": "POST /api/mcp/keys/{key_id}/revoke", + "routers/messages.py::get_proactive_shares": "GET /api/agents/{agent_name}/shares/proactive", + "routers/messages.py::send_proactive_message": "POST /api/agents/{agent_name}/messages", + "routers/messages.py::update_proactive_setting": "PUT /api/agents/{agent_name}/shares/proactive", + "routers/metric_points.py::record_metric_points": "POST /api/agents/{name}/metrics/points", + "routers/monitoring.py::get_agent_health": "GET /api/monitoring/agents/{agent_name}", + "routers/monitoring.py::get_agent_health_history": "GET /api/monitoring/agents/{agent_name}/history", + "routers/monitoring.py::get_fleet_status": "GET /api/monitoring/status", + "routers/monitoring.py::trigger_health_check": "POST /api/monitoring/agents/{agent_name}/check", + "routers/nevermined.py::configure_nevermined": "POST /api/nevermined/agents/{name}/config", + "routers/nevermined.py::delete_nevermined_config": "DELETE /api/nevermined/agents/{name}/config", + "routers/nevermined.py::get_nevermined_config": "GET /api/nevermined/agents/{name}/config", + "routers/nevermined.py::get_payment_history": "GET /api/nevermined/agents/{name}/payments", + "routers/nevermined.py::retry_settlement": "POST /api/nevermined/retry-settlement/{log_id}", + "routers/nevermined.py::toggle_nevermined": "PUT /api/nevermined/agents/{name}/config/toggle", + "routers/notifications.py::acknowledge_notification": "POST /api/notifications/{notification_id}/acknowledge", + "routers/notifications.py::count_agent_notifications": "GET /api/agents/{name}/notifications/count", + "routers/notifications.py::count_notifications": "GET /api/notifications/count", + "routers/notifications.py::dismiss_all_notifications": "POST /api/notifications/dismiss-all", + "routers/notifications.py::dismiss_notification": "POST /api/notifications/{notification_id}/dismiss", + "routers/notifications.py::get_agent_notifications": "GET /api/agents/{name}/notifications", + "routers/notifications.py::get_notification": "GET /api/notifications/{notification_id}", + "routers/notifications.py::list_notifications": "GET /api/notifications", + "routers/observability.py::get_observability_metrics": "GET /api/observability/metrics", + "routers/observability.py::get_observability_status": "GET /api/observability/status", + "routers/onboarding.py::get_first_run": "GET /api/onboarding/first-run", + "routers/operator_queue.py::clear_resolved_queue_items": "POST /api/operator-queue/clear-resolved", + "routers/operator_queue.py::get_agent_queue_items": "GET /api/operator-queue/agents/{agent_name}", + "routers/operator_queue.py::get_my_ask": "GET /api/agents/{name}/operator-queue/{request_id}", + "routers/operator_queue.py::get_queue_item": "GET /api/operator-queue/{item_id}", + "routers/operator_queue.py::get_queue_stats": "GET /api/operator-queue/stats", + "routers/operator_queue.py::list_queue_items": "GET /api/operator-queue", + "routers/product_events.py::record_product_event": "POST /api/product-events", + "routers/public.py::get_public_link_session_detail": "GET /api/public/sessions/{token}/{session_id}", + "routers/public.py::get_public_link_sessions": "GET /api/public/sessions/{token}", + "routers/public.py::get_shared_canvas": "GET /api/public/canvas/{token}", + "routers/public_links.py::create_public_link": "POST /api/agents/{agent_name}/public-links", + "routers/public_links.py::delete_public_link": "DELETE /api/agents/{agent_name}/public-links/{link_id}", + "routers/public_links.py::get_public_link": "GET /api/agents/{agent_name}/public-links/{link_id}", + "routers/public_links.py::list_public_links": "GET /api/agents/{agent_name}/public-links", + "routers/public_links.py::update_public_link": "PUT /api/agents/{agent_name}/public-links/{link_id}", + "routers/public_memory.py::write_user_memory": "POST /api/agents/{agent_name}/user-memory", + "routers/reminders.py::cancel_reminder_endpoint": "POST /api/agents/{name}/reminders/{reminder_id}/cancel", + "routers/reminders.py::create_reminder_endpoint": "POST /api/agents/{name}/reminders", + "routers/reminders.py::list_reminders_endpoint": "GET /api/agents/{name}/reminders", + "routers/reports.py::delete_report": "DELETE /api/agents/{name}/reports/{report_id}", + "routers/reports.py::export_report": "GET /api/reports/{report_id}/export", + "routers/reports.py::get_fleet_report_stats": "GET /api/reports/stats", + "routers/reports.py::get_report": "GET /api/reports/{report_id}", + "routers/reports.py::get_report_rows": "GET /api/reports/{report_id}/rows", + "routers/reports.py::list_agent_reports": "GET /api/agents/{name}/reports", + "routers/reports.py::list_fleet_reports": "GET /api/reports", + "routers/schedules.py::disable_webhook_secret": "DELETE /api/agents/{name}/schedules/{schedule_id}/webhook/secret", + "routers/schedules.py::generate_webhook": "POST /api/agents/{name}/schedules/{schedule_id}/webhook", + "routers/schedules.py::generate_webhook_secret": "POST /api/agents/{name}/schedules/{schedule_id}/webhook/secret", + "routers/schedules.py::get_agent_executions": "GET /api/agents/{name}/executions", + "routers/schedules.py::get_agent_schedules_summary": "GET /api/agents/{name}/schedules/analytics-summary", + "routers/schedules.py::get_execution": "GET /api/agents/{name}/executions/{execution_id}", + "routers/schedules.py::get_execution_log": "GET /api/agents/{name}/executions/{execution_id}/log", + "routers/schedules.py::get_schedule": "GET /api/agents/{name}/schedules/{schedule_id}", + "routers/schedules.py::get_schedule_analytics": "GET /api/agents/{name}/schedules/{schedule_id}/analytics", + "routers/schedules.py::get_schedule_executions": "GET /api/agents/{name}/schedules/{schedule_id}/executions", + "routers/schedules.py::get_webhook_status": "GET /api/agents/{name}/schedules/{schedule_id}/webhook", + "routers/schedules.py::list_agent_schedules": "GET /api/agents/{name}/schedules", + "routers/schedules.py::revoke_webhook": "DELETE /api/agents/{name}/schedules/{schedule_id}/webhook", + "routers/seat_decisions.py::list_seat_decisions": "GET /api/agents/{agent_name}/decisions", + "routers/seat_decisions.py::record_seat_decision": "POST /api/agents/{agent_name}/decisions", + "routers/sessions.py::create_session": "POST /api/agents/{name}/session", + "routers/sessions.py::delete_session": "DELETE /api/agents/{name}/sessions/{session_id}", + "routers/sessions.py::get_session_with_messages": "GET /api/agents/{name}/sessions/{session_id}", + "routers/sessions.py::list_sessions": "GET /api/agents/{name}/sessions", + "routers/sessions.py::reset_session_memory": "POST /api/agents/{name}/sessions/{session_id}/reset", + "routers/sessions.py::send_session_message": "POST /api/agents/{name}/sessions/{session_id}/message", + "routers/settings/flags.py::dismiss_telemetry_ask": "POST /api/settings/telemetry-sharing/ask/dismiss", + "routers/settings/flags.py::get_public_feature_flags": "GET /api/settings/feature-flags", + "routers/settings/flags.py::get_telemetry_sharing": "GET /api/settings/telemetry-sharing", + "routers/settings/flags.py::set_operator_intake": "PUT /api/settings/operator-intake", + "routers/settings/flags.py::set_telemetry_sharing": "PUT /api/settings/telemetry-sharing", + "routers/settings/integrations.py::list_a2a_outbound_endpoints": "GET /api/settings/a2a-endpoints", + "routers/settings/integrations.py::remove_a2a_outbound_endpoint": "DELETE /api/settings/a2a-endpoints/{ref}", + "routers/settings/integrations.py::update_skills_library_automation_setting": "PUT /api/settings/skills-library", + "routers/settings/integrations.py::upsert_a2a_outbound_endpoint": "PUT /api/settings/a2a-endpoints", + "routers/settings/mcp_url.py::get_mcp_url": "GET /api/settings/mcp-url", + "routers/settings/retention.py::acknowledge_retention_prune": "POST /api/settings/retention/acknowledge", + "routers/settings/templates.py::delete_template_registry": "DELETE /api/settings/template-registry", + "routers/settings/templates.py::update_template_registry": "PUT /api/settings/template-registry", + "routers/settings/whitelist.py::add_email_to_whitelist": "POST /api/settings/email-whitelist", + "routers/sharing.py::decide_access_request_endpoint": "POST /api/agents/{agent_name}/access-requests/{request_id}/decide", + "routers/sharing.py::get_access_policy_endpoint": "GET /api/agents/{agent_name}/access-policy", + "routers/sharing.py::get_agent_access_endpoint": "GET /api/agents/{agent_name}/access", + "routers/sharing.py::get_agent_shares_endpoint": "GET /api/agents/{agent_name}/shares", + "routers/sharing.py::get_client_roster_endpoint": "GET /api/agents/{agent_name}/clients", + "routers/sharing.py::get_public_channel_prompt_endpoint": "GET /api/agents/{agent_name}/public-prompt", + "routers/sharing.py::list_access_requests_endpoint": "GET /api/agents/{agent_name}/access-requests", + "routers/sharing.py::set_public_channel_prompt_endpoint": "PUT /api/agents/{agent_name}/public-prompt", + "routers/sharing.py::share_agent_endpoint": "POST /api/agents/{agent_name}/share", + "routers/sharing.py::unshare_agent_endpoint": "DELETE /api/agents/{agent_name}/share/{email}", + "routers/sharing.py::update_access_policy_endpoint": "PUT /api/agents/{agent_name}/access-policy", + "routers/skills.py::assign_skill": "POST /api/agents/{agent_name}/skills/{skill_name}", + "routers/skills.py::get_agent_skills": "GET /api/agents/{agent_name}/skills", + "routers/skills.py::get_library_status": "GET /api/skills/library/status", + "routers/skills.py::get_skill": "GET /api/skills/library/{skill_name}", + "routers/skills.py::get_skill_assignments": "GET /api/skills/assignments", + "routers/skills.py::inject_skills": "POST /api/agents/{agent_name}/skills/inject", + "routers/skills.py::list_skills": "GET /api/skills/library", + "routers/skills.py::unassign_skill": "DELETE /api/agents/{agent_name}/skills/{skill_name}", + "routers/skills.py::update_agent_skills": "PUT /api/agents/{agent_name}/skills", + "routers/slack.py::connect_slack": "POST /api/agents/{name}/public-links/{link_id}/slack/connect", + "routers/slack.py::create_agent_slack_channel": "POST /api/agents/{name}/slack/channel", + "routers/slack.py::delete_agent_slack_channel": "DELETE /api/agents/{name}/slack/channel", + "routers/slack.py::disconnect_slack": "DELETE /api/agents/{name}/public-links/{link_id}/slack", + "routers/slack.py::get_agent_slack_channel": "GET /api/agents/{name}/slack/channel", + "routers/slack.py::get_slack_connection_status": "GET /api/agents/{name}/public-links/{link_id}/slack", + "routers/slack.py::list_agent_slack_channels": "GET /api/agents/{name}/slack/channels", + "routers/slack.py::send_agent_slack_channel_message": "POST /api/agents/{name}/slack/channels/{channel_id}/messages", + "routers/slack.py::set_agent_as_slack_dm_default": "PUT /api/agents/{name}/slack/channel/dm-default", + "routers/slack.py::set_slack_channel_proactive": "PUT /api/agents/{name}/slack/channels/{channel_id}/proactive", + "routers/slack.py::update_slack_connection": "PUT /api/agents/{name}/public-links/{link_id}/slack", + "routers/subscriptions.py::assign_subscription_to_agent": "PUT /api/subscriptions/agents/{agent_name}", + "routers/subscriptions.py::clear_agent_subscription": "DELETE /api/subscriptions/agents/{agent_name}", + "routers/subscriptions.py::get_agent_auth_status": "GET /api/subscriptions/agents/{agent_name}/auth", + "routers/subscriptions.py::list_subscriptions": "GET /api/subscriptions", + "routers/system_agent.py::reinitialize_system_agent": "POST /api/system-agent/reinitialize", + "routers/system_agent.py::restart_system_agent": "POST /api/system-agent/restart", + "routers/system_views.py::create_system_view": "POST /api/system-views", + "routers/system_views.py::delete_system_view": "DELETE /api/system-views/{view_id}", + "routers/system_views.py::get_system_view": "GET /api/system-views/{view_id}", + "routers/system_views.py::list_system_views": "GET /api/system-views", + "routers/system_views.py::update_system_view": "PUT /api/system-views/{view_id}", + "routers/systems.py::deploy_system": "POST /api/systems/deploy", + "routers/systems.py::get_manifest": "GET /api/systems/manifests/{manifest_id}", + "routers/systems.py::get_system": "GET /api/systems/{system_name}", + "routers/systems.py::get_system_manifest": "GET /api/systems/{system_name}/manifest", + "routers/systems.py::list_manifests": "GET /api/systems/manifests", + "routers/systems.py::list_systems": "GET /api/systems", + "routers/systems.py::restart_system": "POST /api/systems/{system_name}/restart", + "routers/tags.py::add_agent_tag": "POST /api/agents/{name}/tags/{tag}", + "routers/tags.py::get_agent_tags": "GET /api/agents/{name}/tags", + "routers/tags.py::list_all_tags": "GET /api/tags", + "routers/tags.py::remove_agent_tag": "DELETE /api/agents/{name}/tags/{tag}", + "routers/tags.py::set_agent_tags": "PUT /api/agents/{name}/tags", + "routers/telegram.py::configure_telegram_bot": "PUT /api/agents/{agent_name}/telegram", + "routers/telegram.py::delete_telegram_binding": "DELETE /api/agents/{agent_name}/telegram", + "routers/telegram.py::delete_telegram_group": "DELETE /api/agents/{agent_name}/telegram/groups/{group_config_id}", + "routers/telegram.py::get_telegram_binding": "GET /api/agents/{agent_name}/telegram", + "routers/telegram.py::list_telegram_groups": "GET /api/agents/{agent_name}/telegram/groups", + "routers/telegram.py::send_telegram_group_message": "POST /api/agents/{agent_name}/telegram/groups/{chat_id}/messages", + "routers/telegram.py::set_telegram_progress_indicator": "PUT /api/agents/{agent_name}/telegram/progress-indicator", + "routers/telegram.py::test_telegram_bot": "POST /api/agents/{agent_name}/telegram/test", + "routers/telegram.py::update_telegram_group": "PUT /api/agents/{agent_name}/telegram/groups/{group_config_id}", + "routers/telemetry.py::get_container_stats": "GET /api/telemetry/containers", + "routers/telemetry.py::get_host_stats": "GET /api/telemetry/host", + "routers/templates.py::get_template": "GET /api/templates/{template_id:path}", + "routers/templates.py::list_templates": "GET /api/templates", + "routers/voice.py::get_voice_name": "GET /api/agents/{name}/voice/name", + "routers/voice.py::get_voice_panel": "GET /api/agents/{name}/voice/{session_id}/panel", + "routers/voice.py::get_voice_prompt": "GET /api/agents/{name}/voice/prompt", + "routers/voice.py::set_voice_name": "PUT /api/agents/{name}/voice/name", + "routers/voice.py::set_voice_prompt": "PUT /api/agents/{name}/voice/prompt", + "routers/voice.py::voice_start": "POST /api/agents/{name}/voice/start", + "routers/voice.py::voice_status": "GET /api/agents/{name}/voice/status", + "routers/voice.py::voice_stop": "POST /api/agents/{name}/voice/stop", + "routers/voip.py::configure_voip_binding": "PUT /api/agents/{agent_name}/voip", + "routers/voip.py::delete_voip_binding": "DELETE /api/agents/{agent_name}/voip", + "routers/voip.py::get_voip_binding": "GET /api/agents/{agent_name}/voip", + "routers/voip.py::place_voip_call": "POST /api/agents/{agent_name}/voip/call", + "routers/voip.py::set_voip_enabled": "PUT /api/agents/{agent_name}/voip/enabled", + "routers/whatsapp.py::configure_whatsapp_binding": "PUT /api/agents/{agent_name}/whatsapp", + "routers/whatsapp.py::delete_whatsapp_binding_endpoint": "DELETE /api/agents/{agent_name}/whatsapp", + "routers/whatsapp.py::get_whatsapp_binding": "GET /api/agents/{agent_name}/whatsapp", + "routers/whatsapp.py::test_whatsapp_binding": "POST /api/agents/{agent_name}/whatsapp/test", + "routers/ws_tickets.py::create_ws_ticket": "POST /api/ws/ticket", + "shared_sessions/router.py::add_participant": "POST /api/rooms/{room_id}/participants", + "shared_sessions/router.py::close_room": "POST /api/rooms/{room_id}/close", + "shared_sessions/router.py::create_room": "POST /api/rooms", + "shared_sessions/router.py::get_room": "GET /api/rooms/{room_id}", + "shared_sessions/router.py::list_rooms": "GET /api/rooms", + "shared_sessions/router.py::post_message": "POST /api/rooms/{room_id}/messages", + "shared_sessions/router.py::remove_participant": "DELETE /api/rooms/{room_id}/participants/{agent_name}", + "shared_sessions/router.py::rename_room": "PATCH /api/rooms/{room_id}" +} diff --git a/tests/unit/test_2996_human_only_routes.py b/tests/unit/test_2996_human_only_routes.py new file mode 100644 index 0000000000..f31f5a79d3 --- /dev/null +++ b/tests/unit/test_2996_human_only_routes.py @@ -0,0 +1,638 @@ +"""#2996 — the route census: every OSS HTTP route chooses a side. + +`get_current_user` resolves an agent- or system-scoped key to its owner carrying +the owner's role, and the owner tier cannot refuse agent keys wholesale (agents +legitimately drive owner-gated routes). So a human-only rule lives per route +(`require_person` / `require_interactive`), and THIS guard is what makes the next +route inherit it: every route, every method (GETs included), must resolve to +exactly one policy class, or the build fails. + +The walker and the reviewed tables live in `tests/unit/_route_census.py` +(pure-stdlib AST, imports nothing from `src/`). The live route table is checked +by the sibling `test_2996_route_census_runtime.py`, in its own file so its +subprocess can never take these tests down with it. + +Ratchet rules, one test each: (1) an unclassified route fails, naming the key +and the ways out; (2) a stale entry in any list fails; (3) a baseline entry that +is now gated fails — the baseline only shrinks; (4) the baseline is exactly +`FROZEN_BASELINE_COUNT`; (5) a key in two lists fails; (6) a duplicate key fails; +(7, runtime file) a stored `METHOD /path` that the app does not register fails. + +The self-tests plant sources in a temp tree and run them through the same walker +— each rule is shown to bite on a deliberately broken input. + +Related: docs/memory/requirements/auth.md §2.8, architecture/security.md §6. +""" + +from __future__ import annotations + +import sys +import textwrap +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import _route_census as rc # noqa: E402 + +pytestmark = pytest.mark.unit + + +@pytest.fixture(scope="module") +def census(): + return rc.walk() + + +@pytest.fixture(scope="module") +def baseline(): + return rc.load_baseline() + + +@pytest.fixture(scope="module") +def classified(census, baseline): + return rc.classify(census, baseline) + + +def _of(failures, tag): + return [msg for t, msg in failures if t == tag] + + +# =========================================================================== +# The live census +# =========================================================================== + + +class TestRouteCensus: + def test_the_walker_sees_the_route_table(self, census): + """Not vacuous: hundreds of routes, from every registration shape.""" + assert len(census.routes) > 600 + keys = set(census.routes) + assert "main.py::health_check" in keys # @app. + assert ( + "routers/agent_config.py::set_agent_autonomy_status" in keys + ) # @router. + assert ( + "routers/settings/generic.py::get_all_settings" in keys + ) # add_api_route, definition file + assert any(k.startswith("shared_sessions/") for k in keys) # outside routers/ + assert any(k.startswith("client_portal/") for k in keys) + assert not any(k.startswith("enterprise/") for k in keys) + + def test_rule1_every_route_is_classified(self, classified): + _, failures = classified + assert not _of(failures, rc.UNCLASSIFIED), "\n".join( + _of(failures, rc.UNCLASSIFIED) + ) + + def test_rule2_no_stale_entry(self, classified): + _, failures = classified + assert not _of(failures, rc.STALE), "\n".join(_of(failures, rc.STALE)) + + def test_rule3_a_gated_route_is_not_in_the_baseline(self, classified): + _, failures = classified + assert not _of(failures, rc.GATED_IN_BASELINE), "\n".join( + _of(failures, rc.GATED_IN_BASELINE) + ) + + def test_rule4_the_baseline_count_is_frozen(self, baseline): + failures = rc.count_failures(baseline, rc.FROZEN_BASELINE_COUNT) + assert not failures, failures[0][1] + + def test_rule5_no_key_is_in_two_lists(self, classified): + _, failures = classified + assert not _of(failures, rc.TWO_LISTS), "\n".join(_of(failures, rc.TWO_LISTS)) + tables = [rc.AGENT_CALLABLE, rc.OWN_AUTH, rc.DELEGATED, rc.ADMIN_WIDENED] + seen = set() + for t in tables: + assert not (seen & set(t)), seen & set(t) + seen |= set(t) + + def test_rule6_no_duplicate_keys(self, classified): + _, failures = classified + assert not _of(failures, rc.DUPLICATE), "\n".join(_of(failures, rc.DUPLICATE)) + + def test_every_widening_is_listed_and_every_delegated_test_exists(self, classified): + _, failures = classified + for tag in (rc.WIDENED_UNLISTED, rc.DELEGATED_TEST): + assert not _of(failures, tag), "\n".join(_of(failures, tag)) + + def test_websocket_routes_are_pinned(self, census): + """Out of the HTTP census but not silent: a new websocket is a visible edit.""" + assert census.websockets == rc.WEBSOCKET_ROUTES + + def test_every_listed_entry_carries_a_method_path_and_a_reason(self): + for table in (rc.AGENT_CALLABLE, rc.OWN_AUTH): + for key, (mp, reason) in table.items(): + assert mp.split(" ", 1)[0] in { + "GET", + "POST", + "PUT", + "PATCH", + "DELETE", + "HEAD", + }, key + assert mp.split(" ", 1)[1].startswith("/"), key + assert reason.strip(), key + for key, (mp, scopes, reason) in rc.ADMIN_WIDENED.items(): + assert ( + mp.startswith(("GET ", "POST ", "PUT ", "PATCH ", "DELETE ")) + and scopes + and reason + ), key + + def test_the_policy_classes_are_counted(self, classified): + """The auto-classified classes are named and counted, not silent.""" + classes, _ = classified + counts = { + c: sum(1 for v in classes.values() if v == c) + for c in rc.CODE_CLASSES + rc.LISTED_CLASSES + } + print( + "\nroute census:", + ", ".join(f"{c}={n}" for c, n in counts.items()), + "(admin_tier: system admitted by #2323 design)", + ) + assert counts["admin_tier"] > 0 and counts["portal"] > 0 + assert sum(counts.values()) == len(classes) + + +class TestTheRoutesThisChangeGates: + PERSON = [ + "routers/agent_config.py::set_agent_autonomy_status", + "routers/agent_config.py::update_agent_api_key_setting", + "routers/agent_config.py::set_agent_read_only_status", + "routers/agent_config.py::set_agent_resources", + "routers/agent_config.py::set_agent_capabilities", + "routers/agent_config.py::set_agent_capacity", + "routers/agent_config.py::set_agent_timeout", + "routers/agent_config.py::set_public_channel_model", + "routers/agent_config.py::set_agent_guardrails", + ] + INTERACTIVE = [ + "routers/users.py::update_my_email", + "routers/users.py::set_my_github_pat", + "routers/users.py::clear_my_github_pat", + "routers/mcp_keys.py::create_mcp_api_key_endpoint", + "routers/mcp_keys.py::ensure_default_mcp_api_key", + ] + + @pytest.mark.parametrize("key", PERSON) + def test_person(self, classified, key): + assert classified[0][key] == "person" + + @pytest.mark.parametrize("key", INTERACTIVE) + def test_interactive(self, classified, key): + assert classified[0][key] == "interactive" + + @pytest.mark.parametrize( + "key", + [ + "routers/agents.py::agent_heartbeat", + "routers/agents.py::agent_execution_result", + "routers/reports.py::create_report", + "routers/notifications.py::create_notification", + "routers/schedules.py::create_schedule", + "routers/schedules.py::enable_schedule", + "routers/schedules.py::disable_schedule", + ], + ) + def test_the_agent_runtime_surface_stays_agent_callable(self, classified, key): + assert classified[0][key] == "agent_callable" + + +# =========================================================================== +# Self-tests: planted sources through the same walker +# =========================================================================== + +_IMPORTS = """\ +from fastapi import APIRouter, Depends +from dependencies import ( + get_current_user, require_person, require_interactive, require_admin, + require_admin_allowing, assert_person, assert_admin, reject_non_interactive_principal, +) +router = APIRouter() +""" + + +def _plant(root: Path, rel: str, body: str, *, imports: bool = True) -> None: + path = root / rel + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text((_IMPORTS if imports else "") + textwrap.dedent(body)) + + +def _run(root: Path, baseline=None, **tables): + census = rc.walk(root=root) + tables = { + k: tables.get(k, {}) + for k in ("admin_widened", "agent_callable", "own_auth", "delegated") + } + classes, failures = rc.classify(census, baseline or {}, **tables) + return census, classes, failures + + +def _one(root, rel, body, **kw): + _plant(root, rel, body, **kw) + return _run(root) + + +class TestSelfTests: + def test_a_depends_gate_classifies(self, tmp_path): + _, classes, failures = _one( + tmp_path, + "routers/a.py", + """ + @router.put("/x") + async def h(current_user=Depends(require_person)): + return 1 + """, + ) + assert classes == {"routers/a.py::h": "person"} and not failures + + def test_a_decorator_dependencies_gate_classifies(self, tmp_path): + _, classes, failures = _one( + tmp_path, + "routers/a.py", + """ + @router.post("/x", dependencies=[Depends(require_interactive)]) + async def h(): + return 1 + """, + ) + assert classes == {"routers/a.py::h": "interactive"} and not failures + + def test_a_first_statement_gate_classifies(self, tmp_path): + _, classes, failures = _one( + tmp_path, + "routers/a.py", + ''' + @router.put("/x") + async def h(current_user=Depends(get_current_user)): + """Docstring first is fine.""" + assert_person(current_user) + return 1 + ''', + ) + assert classes == {"routers/a.py::h": "person"} and not failures + + def test_a_conditional_gate_does_not_count(self, tmp_path): + _, classes, failures = _one( + tmp_path, + "routers/a.py", + """ + @router.put("/x") + async def h(flag: bool, current_user=Depends(get_current_user)): + if flag: + assert_person(current_user) + return 1 + """, + ) + assert _of(failures, rc.UNCLASSIFIED) and not classes + + def test_a_gate_after_a_write_does_not_count(self, tmp_path): + _, classes, failures = _one( + tmp_path, + "routers/a.py", + """ + @router.put("/x") + async def h(current_user=Depends(get_current_user)): + db.write(1) + reject_non_interactive_principal(current_user) + """, + ) + assert _of(failures, rc.UNCLASSIFIED) and not classes + + def test_a_gate_on_the_wrong_name_does_not_count(self, tmp_path): + _, classes, failures = _one( + tmp_path, + "routers/a.py", + """ + @router.put("/x") + async def h(someone, current_user=Depends(get_current_user)): + assert_person(someone) + """, + ) + assert _of(failures, rc.UNCLASSIFIED) and not classes + + def test_a_locally_defined_gate_does_not_count(self, tmp_path): + _, classes, failures = _one( + tmp_path, + "routers/a.py", + """ + def require_person(): + return None + + @router.put("/x") + async def h(current_user=Depends(require_person)): + return 1 + """, + ) + assert _of(failures, rc.UNCLASSIFIED) and not classes + + def test_an_aliased_gate_does_not_count(self, tmp_path): + _, classes, failures = _one( + tmp_path, + "routers/a.py", + """ + from fastapi import APIRouter, Depends + from dependencies import get_current_user as require_person + router = APIRouter() + + @router.put("/x") + async def h(current_user=Depends(require_person)): + return 1 + """, + imports=False, + ) + assert _of(failures, rc.UNCLASSIFIED) and not classes + + def test_an_annotated_alias_is_not_recognised(self, tmp_path): + _, classes, failures = _one( + tmp_path, + "routers/a.py", + """ + from typing import Annotated + Person = Annotated[object, Depends(require_person)] + + @router.put("/x") + async def h(current_user: Person): + return 1 + """, + ) + assert _of(failures, rc.UNCLASSIFIED) and not classes + + def test_a_router_level_dependency_is_not_recognised(self, tmp_path): + _, classes, failures = _one( + tmp_path, + "routers/a.py", + """ + gated = APIRouter(dependencies=[Depends(require_person)]) + + @gated.put("/x") + async def h(): + return 1 + """, + ) + assert _of(failures, rc.UNCLASSIFIED) and not classes + + def test_an_admin_depends_is_admin_tier(self, tmp_path): + _, classes, failures = _one( + tmp_path, + "routers/a.py", + """ + @router.get("/x") + async def h(current_user=Depends(require_admin)): + return 1 + """, + ) + assert classes == {"routers/a.py::h": "admin_tier"} and not failures + + def test_a_widened_admin_gate_needs_an_entry(self, tmp_path): + _plant( + tmp_path, + "routers/a.py", + """ + @router.get("/x") + async def h(current_user=Depends(require_admin_allowing("ops"))): + return 1 + + @router.get("/y") + async def i(current_user=Depends(get_current_user)): + assert_admin(current_user, allow_scopes={"ops"}) + """, + ) + _, classes, failures = _run(tmp_path) + assert len(_of(failures, rc.WIDENED_UNLISTED)) == 2 + entries = { + "routers/a.py::h": ("GET /x", ("ops",), "r"), + "routers/a.py::i": ("GET /y", ("ops",), "r"), + } + _, classes, failures = _run(tmp_path, admin_widened=entries) + assert set(classes.values()) == {"admin_widened"} and not failures + + def test_get_portal_principal_is_portal(self, tmp_path): + _plant( + tmp_path, + "client_portal/portal_auth.py", + "def get_portal_principal():\n pass\n", + imports=False, + ) + _, classes, failures = _one( + tmp_path, + "client_portal/r.py", + """ + from fastapi import APIRouter, Depends + from .portal_auth import get_portal_principal + router = APIRouter() + + @router.get("/x") + async def h(p=Depends(get_portal_principal)): + return 1 + """, + imports=False, + ) + assert classes == {"client_portal/r.py::h": "portal"} and not failures + + @pytest.mark.parametrize("verb", ["get", "post", "delete", "patch"]) + def test_an_unlisted_route_of_any_method_fails(self, tmp_path, verb): + _, _, failures = _one( + tmp_path, + "routers/a.py", + f""" + @router.{verb}("/x") + async def h(current_user=Depends(get_current_user)): + return 1 + """, + ) + msgs = _of(failures, rc.UNCLASSIFIED) + assert msgs and "routers/a.py::h" in msgs[0] and "AGENT_CALLABLE" in msgs[0] + + def test_a_listed_route_passes(self, tmp_path): + _plant( + tmp_path, + "routers/a.py", + """ + @router.get("/x") + async def h(current_user=Depends(get_current_user)): + return 1 + """, + ) + _, classes, failures = _run( + tmp_path, agent_callable={"routers/a.py::h": ("GET /x", "a use")} + ) + assert classes == {"routers/a.py::h": "agent_callable"} and not failures + + def test_add_api_route_resolves_to_the_definition_file(self, tmp_path): + _plant( + tmp_path, + "routers/pkg/impl.py", + """ + async def handler(current_user=Depends(require_person)): + return 1 + """, + ) + _plant( + tmp_path, + "routers/pkg/__init__.py", + """ + from fastapi import APIRouter + from . import impl + router = APIRouter() + router.add_api_route("", impl.handler, methods=["POST"]) + """, + imports=False, + ) + census, classes, failures = _run(tmp_path) + assert classes == {"routers/pkg/impl.py::handler": "person"} and not failures + assert census.routes["routers/pkg/impl.py::handler"].methods == ("POST",) + + def test_an_unresolvable_add_api_route_target_fails(self, tmp_path): + _plant( + tmp_path, + "routers/a.py", + """ + router.add_api_route("/x", make_handler(), methods=["GET"]) + """, + ) + _, _, failures = _run(tmp_path) + assert any("cannot be resolved" in m for m in _of(failures, rc.UNCLASSIFIED)) + + def test_a_route_in_a_new_nested_package_is_found(self, tmp_path): + _, _, failures = _one( + tmp_path, + "brand_new/deep/pkg/router.py", + """ + @router.post("/x") + async def h(current_user=Depends(get_current_user)): + return 1 + """, + ) + assert any( + "brand_new/deep/pkg/router.py::h" in m + for m in _of(failures, rc.UNCLASSIFIED) + ) + + def test_the_enterprise_tree_is_excluded(self, tmp_path): + _, classes, failures = _one( + tmp_path, + "enterprise/backend/r.py", + """ + @router.post("/x") + async def h(): + return 1 + """, + ) + assert not classes and not failures + + def test_a_seventh_websocket_is_visible(self, tmp_path): + _plant( + tmp_path, + "routers/a.py", + """ + @router.websocket("/ws/new") + async def sock(ws): + return 1 + """, + ) + census = rc.walk(root=tmp_path) + assert census.websockets == {"routers/a.py::sock"} + assert census.websockets != rc.WEBSOCKET_ROUTES + + def test_rule2_a_stale_baseline_entry_fails(self, tmp_path): + _plant(tmp_path, "routers/a.py", "") + _, _, failures = _run(tmp_path, baseline={"routers/a.py::gone": "GET /gone"}) + assert _of(failures, rc.STALE) + + def test_rule2_a_stale_exemption_fails(self, tmp_path): + _plant(tmp_path, "routers/a.py", "") + _, _, failures = _run( + tmp_path, own_auth={"routers/a.py::gone": ("GET /gone", "r")} + ) + assert _of(failures, rc.STALE) + + def test_rule3_a_gated_baseline_entry_fails(self, tmp_path): + _plant( + tmp_path, + "routers/a.py", + """ + @router.put("/x") + async def h(current_user=Depends(require_person)): + return 1 + """, + ) + _, _, failures = _run(tmp_path, baseline={"routers/a.py::h": "PUT /x"}) + assert _of(failures, rc.GATED_IN_BASELINE) + + def test_rule4_a_count_that_does_not_match_fails(self): + assert rc.count_failures({"a": "GET /a"}, 1) == [] + assert rc.count_failures({"a": "GET /a"}, 2)[0][0] == rc.COUNT # headroom + assert ( + rc.count_failures({"a": "GET /a", "b": "GET /b"}, 1)[0][0] == rc.COUNT + ) # growth + + def test_rule5_a_key_in_two_lists_fails(self, tmp_path): + _plant( + tmp_path, + "routers/a.py", + """ + @router.get("/x") + async def h(current_user=Depends(get_current_user)): + return 1 + """, + ) + _, _, failures = _run( + tmp_path, + baseline={"routers/a.py::h": "GET /x"}, + agent_callable={"routers/a.py::h": ("GET /x", "r")}, + ) + assert _of(failures, rc.TWO_LISTS) + + def test_rule6_a_duplicate_key_fails(self, tmp_path): + _, _, failures = _one( + tmp_path, + "routers/a.py", + """ + @router.get("/x") + async def h(current_user=Depends(require_person)): + return 1 + + @router.get("/y") + async def h(current_user=Depends(require_person)): + return 2 + """, + ) + assert _of(failures, rc.DUPLICATE) + + def test_a_delegated_entry_needs_its_behavioural_test(self, tmp_path): + _plant( + tmp_path, + "routers/a.py", + """ + @router.put("/x") + async def h(current_user=Depends(get_current_user)): + return service(current_user) + """, + ) + real = "tests/unit/test_2996_human_only_routes.py::TestSelfTests::test_a_delegated_entry_needs_its_behavioural_test" + _, classes, failures = _run( + tmp_path, delegated={"routers/a.py::h": ("PUT /x", "svc.fn", real)} + ) + assert classes == {"routers/a.py::h": "delegated"} and not failures + missing = "tests/unit/test_2996_human_only_routes.py::test_no_such_test" + _, _, failures = _run( + tmp_path, delegated={"routers/a.py::h": ("PUT /x", "svc.fn", missing)} + ) + assert _of(failures, rc.DELEGATED_TEST) + + def test_rule7_a_moved_path_or_method_fails(self): + live = {"routers/a.py::h": ["GET /api/x"]} + assert rc.path_failures(live, live, {"routers/a.py::h": "GET /api/x"}) == [] + moved = rc.path_failures(live, live, {"routers/a.py::h": "GET /api/old"}) + assert moved and moved[0][0] == rc.PATH_MISMATCH + method = rc.path_failures(live, live, {"routers/a.py::h": "POST /api/x"}) + assert method and method[0][0] == rc.PATH_MISMATCH + unregistered = rc.path_failures(live, {}, {"routers/a.py::h": "GET /api/x"}) + assert unregistered[0][1].endswith(repr(rc.NOT_REGISTERED)) + + def test_a_live_route_the_walker_missed_fails(self): + failures = rc.path_failures(set(), {"routers/a.py::h": ["GET /x"]}, {}) + assert failures and failures[0][0] == rc.UNCLASSIFIED diff --git a/tests/unit/test_2996_route_census_runtime.py b/tests/unit/test_2996_route_census_runtime.py new file mode 100644 index 0000000000..dfebb35434 --- /dev/null +++ b/tests/unit/test_2996_route_census_runtime.py @@ -0,0 +1,66 @@ +"""#2996 — the route census, checked against the LIVE route table. + +The AST census (`test_2996_human_only_routes.py`) is import-free and keys each +route by `::`. This file imports the real app — in +a SUBPROCESS, so no module state leaks into the unit island — and checks: + +* every registered non-enterprise `APIRoute` maps to a key the AST walker found + (a registration shape the walker cannot see fails here), and +* every stored `"METHOD /full/path"` — in the baseline and in the reviewed + tables — is exactly what the app registers for that key (ratchet rule 7), so + an exemption cannot silently follow a function onto another path or method. + +It FAILS, never skips, when the subprocess cannot import `main`, prints nothing +parseable, or finds zero routes: `test_1483_route_order.py`'s module-level skip +hid its check for months (#2080), and it lives in its own file so a failure here +cannot take the AST tests with it. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import _route_census as rc # noqa: E402 + +pytestmark = pytest.mark.unit + + +@pytest.fixture(scope="module") +def live_paths(): + return rc.runtime_paths(rc.runtime_routes()) + + +def _stored() -> dict: + stored = dict(rc.load_baseline()) + for table in (rc.AGENT_CALLABLE, rc.OWN_AUTH): + stored.update({k: v[0] for k, v in table.items()}) + stored.update({k: v[0] for k, v in rc.ADMIN_WIDENED.items()}) + stored.update({k: v[0] for k, v in rc.DELEGATED.items()}) + return stored + + +def test_every_live_route_is_in_the_census_and_every_stored_path_matches(live_paths): + failures = rc.path_failures(rc.walk().routes, live_paths, _stored()) + assert not failures, "\n".join(msg for _, msg in failures) + + +def test_the_live_table_is_not_vacuous(live_paths): + assert len(live_paths) > 600 + assert live_paths["routers/agent_config.py::set_agent_autonomy_status"] == [ + "PUT /api/agents/{agent_name}/autonomy" + ] + # add_api_route: the key is the DEFINITION file, as the AST side computes it. + assert live_paths["routers/settings/generic.py::get_all_settings"] == [ + "GET /api/settings" + ] + + +def test_a_failed_import_fails_loudly(tmp_path): + """No `main` to import: the dump raises — it never returns an empty table + a caller could read as 'nothing to check'.""" + with pytest.raises(RuntimeError, match="import main"): + rc.runtime_routes(backend=tmp_path, timeout=120) From 0f2caf909af85affac99ef928975c99387d5c562 Mon Sep 17 00:00:00 2001 From: Andrii Pasternak <44377756+AndriiPasternak31@users.noreply.github.com> Date: Mon, 28 Sep 2026 11:21:15 +0100 Subject: [PATCH 11/23] test(schedules): an agent's own key still manages its schedules (#2996) The human-only rule covers the autonomy grant, not the schedule tools it bounds. Pins create/enable/disable with the agent's own key through the real principal resolution, asserting the stored state moves. --- tests/registry.json | 12 +++ ...est_2996_agent_schedule_tools_unchanged.py | 85 +++++++++++++++++++ 2 files changed, 97 insertions(+) create mode 100644 tests/unit/test_2996_agent_schedule_tools_unchanged.py diff --git a/tests/registry.json b/tests/registry.json index 8d5cdd0ccf..1ec85490e0 100644 --- a/tests/registry.json +++ b/tests/registry.json @@ -1,5 +1,17 @@ { "test_files": [ + { + "file": "unit/test_2996_agent_schedule_tools_unchanged.py", + "feature": "#2996", + "added": "2026-09-28", + "categories": [ + "backend", + "auth", + "schedules", + "regression" + ], + "description": "Regression pin for the human-only rule's boundary: an agent's own key, through the REAL get_current_user with a seeded agent key row, still creates (201), disables and enables (200) its own schedule — the MCP create/enable/disable_schedule tools are the agent's use, bounded by the person-only autonomy grant. Asserts the stored schedule state moves on each call." + }, { "file": "unit/test_2996_human_only_routes.py", "feature": "#2996", diff --git a/tests/unit/test_2996_agent_schedule_tools_unchanged.py b/tests/unit/test_2996_agent_schedule_tools_unchanged.py new file mode 100644 index 0000000000..fef314bfba --- /dev/null +++ b/tests/unit/test_2996_agent_schedule_tools_unchanged.py @@ -0,0 +1,85 @@ +"""#2996 — an agent's own schedule tools keep working. + +Autonomy became person-only because it is the grant that decides whether cron +fires unattended. The schedule routes behind the MCP `create_schedule` / +`enable_schedule` / `disable_schedule` tools are the agent's own *use*, bounded +by that grant, and must stay reachable with the agent's own key. This pins that +the human-only rule did not spread to them. + +The request goes through the REAL `get_current_user` with the agent's key row +seeded in the real schema (`tests/db_harness.py`), so the `scope` resolution and +the entry-point fences are the production ones. Each call asserts the stored +state moved, not just the status code. + +Related: docs/memory/requirements/auth.md §2.8, `AGENT_CALLABLE` in +tests/unit/_route_census.py. +""" + +from __future__ import annotations + +import os +import sys +from pathlib import Path + +import pytest + +os.environ.setdefault("REDIS_URL", "redis://u:p@localhost:6379") +os.environ.setdefault("SECRET_KEY", "test-secret") + +_BACKEND = Path(__file__).resolve().parents[2] / "src" / "backend" +if str(_BACKEND) not in sys.path: + sys.path.insert(0, str(_BACKEND)) + +from fastapi import FastAPI # noqa: E402 +from fastapi.testclient import TestClient # noqa: E402 + +from db_harness import db_backend # noqa: E402,F401 + +import dependencies # noqa: E402 +import routers.schedules as _SCHED # noqa: E402 +from database import db # noqa: E402 +from db_models import UserCreate # noqa: E402 + +pytestmark = pytest.mark.unit + +OWNER = "sch2996-owner" +AGENT = "sch2996-agent" + + +@pytest.fixture +def client(db_backend, monkeypatch): + monkeypatch.setattr(dependencies, "get_breaker_redis", lambda: None) + db.create_user( + UserCreate(username=OWNER, role="user", email="sch2996-owner@example.com") + ) + db.register_agent_owner(AGENT, OWNER) + app = FastAPI() + app.include_router(_SCHED.router) + return TestClient(app) + + +def _agent_key() -> dict: + key = db.create_agent_mcp_api_key(AGENT, OWNER).api_key + return {"Authorization": f"Bearer {key}"} + + +def test_the_agent_key_creates_enables_and_disables_its_own_schedule(client): + headers = _agent_key() + base = f"/api/agents/{AGENT}/schedules" + + res = client.post( + base, + json={"name": "nightly", "cron_expression": "0 3 * * *", "message": "run"}, + headers=headers, + ) + assert res.status_code == 201, res.text + schedule_id = res.json()["id"] + assert db.get_schedule(schedule_id).agent_name == AGENT + + res = client.post(f"{base}/{schedule_id}/disable", headers=headers) + assert res.status_code == 200, res.text + assert db.get_schedule(schedule_id).enabled is False + + res = client.post(f"{base}/{schedule_id}/enable", headers=headers) + assert res.status_code == 200, res.text + assert db.get_schedule(schedule_id).enabled is True From 4ee74d65e0ef402c4cd68d5d98d2cd583fbb2e89 Mon Sep 17 00:00:00 2001 From: Andrii Pasternak <44377756+AndriiPasternak31@users.noreply.github.com> Date: Mon, 28 Sep 2026 11:22:05 +0100 Subject: [PATCH 12/23] test(registry): register the #2996 agent-config and self-service test files --- tests/registry.json | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/tests/registry.json b/tests/registry.json index 1ec85490e0..60edb734f7 100644 --- a/tests/registry.json +++ b/tests/registry.json @@ -1,5 +1,29 @@ { "test_files": [ + { + "file": "unit/test_2996_owner_config_person_only.py", + "feature": "#2996", + "added": "2026-09-28", + "categories": [ + "backend", + "auth", + "security", + "agent-config" + ], + "description": "PUT /autonomy and the 8 other agent_config writes (api-key-setting, read-only, resources, capabilities, capacity, timeout, public-channel-model, guardrails) take Depends(require_person). Real get_current_user with seeded key rows: agent key (own agent and a sibling) and system key -> 403 person_required with the stored value unchanged from a non-ambient seed; owner JWT, admin JWT and the owner's user key -> 2xx with the stored value changed. Override with an explicit principal dataclass (never MagicMock) for ops, portal_delegate, connector, an unknown future scope, a user scope carrying an agent identity, the loopback JWT, and a principal missing mcp_scope. Refusal does not disclose agent existence; reads stay open; every write in the file is person-gated." + }, + { + "file": "unit/test_2996_self_service_session_only.py", + "feature": "ent#711", + "added": "2026-09-28", + "categories": [ + "backend", + "auth", + "security", + "users" + ], + "description": "PUT /api/users/me/email and PUT/DELETE /api/users/me/github-pat take Depends(require_interactive): agent, system and the person's own user key -> 403 with users.email and the encrypted PAT column unchanged; override kinds refused by the route gate; JWT path unchanged (bind, 400 malformed, 409 taken, PAT set/clear, status read). Records that email sign-in resolves the account by the email column alone (get_or_create_email_user): after a rebind the old address resolves to a new account, and an email-named account collides." + }, { "file": "unit/test_2996_agent_schedule_tools_unchanged.py", "feature": "#2996", From de3a284f1350929ab8f12637989affae46a9a7bf Mon Sep 17 00:00:00 2001 From: Andrii Pasternak <44377756+AndriiPasternak31@users.noreply.github.com> Date: Mon, 28 Sep 2026 11:25:07 +0100 Subject: [PATCH 13/23] docs(flows): human-only grant surfaces in the affected flow docs (#2996) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Autonomy, read-only, resources, capabilities, capacity, guardrails, public-channel model and the API-key setting flows note the person-only rule on their writes; email-authentication, first-time-setup and github-sync note the session-only rule on the sign-in email and the personal GitHub PAT. Index rows, a learnings fragment, and the schedule regression test in auth.md §2.8. Refs #2996, trinity-enterprise#711 --- docs/memory/feature-flows.md | 5 +++-- docs/memory/feature-flows/agent-guardrails.md | 2 +- docs/memory/feature-flows/agent-resource-allocation.md | 6 ++++-- docs/memory/feature-flows/agent-terminal.md | 2 ++ docs/memory/feature-flows/autonomy-mode.md | 4 +++- docs/memory/feature-flows/container-capabilities.md | 4 +++- docs/memory/feature-flows/email-authentication.md | 4 ++++ docs/memory/feature-flows/first-time-setup.md | 3 ++- docs/memory/feature-flows/github-sync.md | 2 +- docs/memory/feature-flows/parallel-capacity.md | 4 ++-- docs/memory/feature-flows/public-channel-model.md | 2 +- docs/memory/feature-flows/read-only-mode.md | 2 +- .../2026-09-28-the-owner-tier-is-not-the-admin-tier.md | 4 ++++ docs/memory/requirements/auth.md | 2 +- 14 files changed, 32 insertions(+), 14 deletions(-) create mode 100644 docs/memory/learnings/2026-09-28-the-owner-tier-is-not-the-admin-tier.md diff --git a/docs/memory/feature-flows.md b/docs/memory/feature-flows.md index 4a89987cfb..c01af35240 100644 --- a/docs/memory/feature-flows.md +++ b/docs/memory/feature-flows.md @@ -23,6 +23,7 @@ | Date | ID | Change | Flow | |------|-----|--------|------| +| 2026-09-28 | #2996 | fix(auth): **human-only grant surfaces** — `PUT /autonomy` and the other agent-config writes are person-only (`require_person`); the sign-in email and personal GitHub PAT change only in a signed-in session (`require_interactive`, ent#711); a route census makes every new route choose a policy class | [autonomy-mode.md](feature-flows/autonomy-mode.md) | | 2026-09-22 | #2958 | fix(chat): **a `/chat` turn resumes only the agent's own chat session** (`--resume `, never `--continue`, which took a scheduled run's persisted JSONL and paid its auto-compaction); a model change starts fresh; one cold retry on a reaped JSONL; the id rides a marker file into the reaper's keep set; chat rows now carry `compact_metadata` and `execution.compaction`, and `get_execution_result` returns it | [execution-queue.md](feature-flows/execution-queue.md), [parallel-headless-execution.md](feature-flows/parallel-headless-execution.md), [mcp-orchestration.md](feature-flows/mcp-orchestration.md), [authenticated-chat-tab.md](feature-flows/authenticated-chat-tab.md), [file-sharing-outbound.md](feature-flows/file-sharing-outbound.md), [system-wide-trinity-prompt.md](feature-flows/system-wide-trinity-prompt.md) | | 2026-09-16 | ent#625 | feat(workspace): **a theme switch (light / dark / system) at the top right of the central column**. The Workspace followed the global theme with no control (no NavBar), so a platform user could not change it from the Workspace and an external client could not change it at all. One switch, one store: `PortalThemeSwitch` drives the same `useThemeStore`/`trinity-theme` key the NavBar does, shows the RESOLVED state honestly under `system` ("System · dark"), sits in a `#header-end` slot of both column headers, and collapses to an icon below `sm`. The NavBar's inline picker is replaced by the shared `ThemeChoice` primitive (its raw-colour entry shrinks 35 → 23). First MOUNTED component test in the frontend suite (`@vue/test-utils` + per-file jsdom) — the click reaches `setTheme`. | [dark-mode-theme.md](feature-flows/dark-mode-theme.md) | | 2026-09-16 | ent#620 | feat(workspace): **the Work card says what the agent is doing — one fixed-height line that slides up, for the chat's own turn AND delegated/scheduled/room runs; the chat scrolls to the card on your own send**. The card's step slot was blank because its handler matched `evt.type === 'tool_use'`, a shape raw stream-json never carries. One vocabulary (`utils/workActivity.js`, shared with the Chat tab and Agent Detail), two feeds: the SSE frames for the own turn, the agent's heartbeat — now carrying a bounded per-execution `executions[]` from a per-execution activity slot — for everything else, folded onto live rostered rows by the Work read through the title sanitiser + roster mask, plus a Redis-only `/work/activity` poll at 2.5 s while live. ≥700 ms per line, bursts collapse, identical lines never re-animate, aged lines drop, terminal clears. Scroll only on the person's own send (#2624 holds). | [workspace-work.md](feature-flows/workspace-work.md) | @@ -165,7 +166,7 @@ | Flow | Document | Description | |------|----------|-------------| -| Email Authentication | [email-authentication.md](feature-flows/email-authentication.md) | Passwordless email login | +| Email Authentication | [email-authentication.md](feature-flows/email-authentication.md) | Passwordless email login; binding the sign-in email is signed-in-session only (ent#711) | | Admin Login | [admin-login.md](feature-flows/admin-login.md) | Password-based admin auth | | First-Time Setup | [first-time-setup.md](feature-flows/first-time-setup.md) | Admin password wizard | | First-Run Overlay | [onboarding-wizard.md](feature-flows/onboarding-wizard.md) | The blocking post-login setup sequence — step registry, derived completion, re-run (ent#581; formerly the ent#52 wizard) | @@ -258,7 +259,7 @@ | Flow | Document | Description | |------|----------|-------------| | Public-Channel Model | [public-channel-model.md](feature-flows/public-channel-model.md) | Per-agent model override for public-facing channels (#894) | -| Autonomy Mode | [autonomy-mode.md](feature-flows/autonomy-mode.md) | Agent autonomous operation toggle | +| Autonomy Mode | [autonomy-mode.md](feature-flows/autonomy-mode.md) | Agent autonomous operation toggle — person-only (#2996) | | AutonomyToggle Component | [autonomy-toggle-component.md](feature-flows/autonomy-toggle-component.md) | Reusable Vue toggle component | | Read-Only Mode | [read-only-mode.md](feature-flows/read-only-mode.md) | Code protection via hooks (CFG-007) | | Agent Guardrails | [agent-guardrails.md](feature-flows/agent-guardrails.md) | Baseline bash/path deny-lists, credential output scanner, turn/timeout/tool budgets; owner-only narrow overrides (GUARD-001/002/003) | diff --git a/docs/memory/feature-flows/agent-guardrails.md b/docs/memory/feature-flows/agent-guardrails.md index 691b335c5d..e957f76306 100644 --- a/docs/memory/feature-flows/agent-guardrails.md +++ b/docs/memory/feature-flows/agent-guardrails.md @@ -39,7 +39,7 @@ platform's baseline protections. - **UI (Agent Detail)**: owner-only **Settings** tab (renamed from "Guardrails", #1108/#1122) — `GuardrailsPanel.vue` rendered unchanged as **section #1** inside `components/settings/SettingsPanel.vue`, wired in `AgentDetail.vue:187` (tab pushed at `647`, gated on `agent.can_share`). Old `?tab=guardrails` deep links resolve via `TAB_ALIASES` (`guardrails → settings`). Exposes `max_turns_chat` / `max_turns_task` only (#967 / #992). - **API (read)**: `GET /api/agents/{name}/guardrails` — any authenticated user; 404 if no container. -- **API (write)**: `PUT /api/agents/{name}/guardrails` — **owner-only**; empty body clears overrides. Requires agent restart to apply. +- **API (write)**: `PUT /api/agents/{name}/guardrails` — **owner-only** and **person-only** (`Depends(require_person)`, #2996: agent- and system-scoped keys get 403 `person_required`); empty body clears overrides. Requires agent restart to apply. - **Agent creation**: overrides serialized into the `AGENT_GUARDRAILS` env var (`crud.py`). - **Agent restart**: env var re-serialized from DB (`lifecycle.py`); runtime config regenerated at boot (`startup.sh` → `write-runtime-config.py`). - **Tool-call time**: Claude Code `PreToolUse` / `PostToolUse` hooks fire on every `Bash` / file-write tool call. diff --git a/docs/memory/feature-flows/agent-resource-allocation.md b/docs/memory/feature-flows/agent-resource-allocation.md index 8859cef916..ed8d2d9949 100644 --- a/docs/memory/feature-flows/agent-resource-allocation.md +++ b/docs/memory/feature-flows/agent-resource-allocation.md @@ -249,14 +249,16 @@ async def get_agent_resources( } ``` -#### PUT /api/agents/{name}/resources (Lines 841-904) +#### PUT /api/agents/{name}/resources + +**Person-only (#2996)**: the route takes `Depends(require_person)` — a JWT session or the owner's own `user`-scoped key. Agent- and system-scoped keys (and every other key scope) get 403 `person_required` before the owner check, whatever agent they address. See requirements `auth.md` §2.8. ```python @router.put("/{agent_name}/resources") async def set_agent_resources( agent_name: str, body: dict, - current_user: User = Depends(get_current_user) + current_user: User = Depends(require_person) ): # Only owners can change resources if not db.can_user_share_agent(current_user.username, agent_name): diff --git a/docs/memory/feature-flows/agent-terminal.md b/docs/memory/feature-flows/agent-terminal.md index 9b396e7b04..3dccb2650f 100644 --- a/docs/memory/feature-flows/agent-terminal.md +++ b/docs/memory/feature-flows/agent-terminal.md @@ -238,6 +238,8 @@ Updates the API key setting. **Owner-only endpoint** - only the agent owner can **Authentication**: Must be agent owner (checked via `db.can_user_share_agent()`) +**Person-only (#2996)**: the route takes `Depends(require_person)` — a JWT session or the owner's own `user`-scoped key. Agent- and system-scoped keys (and every other key scope) get 403 `person_required` before the owner check, whatever agent they address. See requirements `auth.md` §2.8. + **Service Logic** (`api_key.py:49-84`): ```python async def update_agent_api_key_setting_logic(agent_name: str, body: dict, current_user: User, request: Request) -> dict: diff --git a/docs/memory/feature-flows/autonomy-mode.md b/docs/memory/feature-flows/autonomy-mode.md index ee4a375166..0d5f87de56 100644 --- a/docs/memory/feature-flows/autonomy-mode.md +++ b/docs/memory/feature-flows/autonomy-mode.md @@ -43,7 +43,7 @@ As an agent owner, I want to toggle autonomous operation for my agent so that I ### API - `GET /api/agents/autonomy-status` - Bulk status for dashboard - `GET /api/agents/{name}/autonomy` - Per-agent status with schedule counts -- `PUT /api/agents/{name}/autonomy` - Toggle autonomy on/off +- `PUT /api/agents/{name}/autonomy` - Toggle autonomy on/off (person-only, #2996 — see Security Considerations) --- @@ -376,6 +376,7 @@ async def set_autonomy_status_logic( current_user: User ) -> dict: """Set the autonomy status for an agent.""" + # The route already refused non-person principals (Depends(require_person), #2996). # Only owner can modify autonomy if not db.can_user_share_agent(current_user.username, agent_name): raise HTTPException(status_code=403, detail="Only the owner can modify autonomy settings") @@ -618,6 +619,7 @@ logger.info( 1. **Access Control**: Only users with access to an agent can view its autonomy status 2. **Owner-Only Toggle**: Only the agent owner (or admin) can modify autonomy settings (uses `can_user_share_agent` permission) +2a. **Person-only (#2996)**: the route takes `Depends(require_person)` — a JWT session or the owner's own `user`-scoped key. Agent- and system-scoped keys (and every other key scope) get 403 `person_required` before the owner check, whatever agent they address. See requirements `auth.md` §2.8. Autonomy decides whether **unattended cron** fires (`scheduler/service.py` gates `triggered_by == "schedule"`); a manual `trigger_schedule` is not gated by it, and the agent's own schedule tools (create / enable / disable) stay agent-callable. 3. **System Agent Protection**: System agents are excluded from autonomy controls entirely 4. **Dashboard Filtering**: Bulk status endpoint only returns agents the user can access, excluding system agents diff --git a/docs/memory/feature-flows/container-capabilities.md b/docs/memory/feature-flows/container-capabilities.md index 754b8b3521..e184e22ba4 100644 --- a/docs/memory/feature-flows/container-capabilities.md +++ b/docs/memory/feature-flows/container-capabilities.md @@ -82,12 +82,14 @@ async def get_agent_capabilities( #### PUT /api/agents/{name}/capabilities +**Person-only (#2996)**: the route takes `Depends(require_person)` — a JWT session or the owner's own `user`-scoped key. Agent- and system-scoped keys (and every other key scope) get 403 `person_required` before the owner check, whatever agent they address. See requirements `auth.md` §2.8. + ```python @router.put("/{agent_name}/capabilities") async def set_agent_capabilities( agent_name: str, body: dict, - current_user: User = Depends(get_current_user) + current_user: User = Depends(require_person) ): """ Body: {"full_capabilities": true|false} diff --git a/docs/memory/feature-flows/email-authentication.md b/docs/memory/feature-flows/email-authentication.md index 13e9d09906..a096aebb14 100644 --- a/docs/memory/feature-flows/email-authentication.md +++ b/docs/memory/feature-flows/email-authentication.md @@ -865,6 +865,10 @@ class EmailLoginResponse(BaseModel): ## Security Considerations +### Binding the sign-in email is session-only (trinity-enterprise#711) + +Email sign-in resolves the account by the `users.email` column alone (`db/email_auth.py::get_or_create_email_user`), so the address an account signs in with is its sign-in identity. `PUT /api/users/me/email` (`routers/users.py`) therefore takes `Depends(require_interactive)`: a signed-in (JWT) session only; every MCP key, the person's own `user` key included, gets 403 and the column does not move. `PUT` / `DELETE /api/users/me/github-pat` take the same rule. See requirements `auth.md` §2.8. + ### Email Enumeration Prevention The system prevents attackers from discovering which emails are registered: diff --git a/docs/memory/feature-flows/first-time-setup.md b/docs/memory/feature-flows/first-time-setup.md index 44f4a1b6d0..263d419275 100644 --- a/docs/memory/feature-flows/first-time-setup.md +++ b/docs/memory/feature-flows/first-time-setup.md @@ -943,7 +943,8 @@ that rather than blocking on it: still runs, so only an account *with* a password hash (the admin) authenticates — email-code-only users (no password) never can. - **`PUT /api/users/me/email`** (`routers/users.py`) — own-account scoped; 409 if - the email belongs to another account; no verification email. + the email belongs to another account; no verification email. Signed-in session + only (`Depends(require_interactive)`, trinity-enterprise#711): every MCP key gets 403. ### Hosted intake endpoint (out-of-repo Cloudflare Worker) diff --git a/docs/memory/feature-flows/github-sync.md b/docs/memory/feature-flows/github-sync.md index 9a31cecf33..8ade80982c 100644 --- a/docs/memory/feature-flows/github-sync.md +++ b/docs/memory/feature-flows/github-sync.md @@ -834,7 +834,7 @@ def get_github_pat_for_agent(agent_name: str) -> str: return get_github_pat() # platform fallback: DB then GITHUB_PAT env var ``` -**Agent creation — 3-tier with provenance** (`resolve_github_pat(agent_name, owner_id)`, per-agent → owner's per-user → global). Called at both `github:` create sites in `services/agent_service/crud.py`; it returns `(pat, tier)` so the creator's own token (`users.github_pat_encrypted`, self-service `/api/users/me/github-pat`) is preferred over the shared admin PAT — a non-admin is no longer confined to the admin's repo scope. The resolved PAT is persisted as the #347 per-agent PAT **only** when `tier ∈ {per_user, fork}`, never `global`: a global-fallback agent keeps `github_pat_encrypted` NULL so `github_pat_propagation_service` still reaches it on admin rotation. Resolution keys on `owner_id` (the creator/owner) only — never a sharee — so a shared agent's git identity can't be hijacked. When NO tier resolves a token (`("", "none")`), the create is no longer an automatic hard-fail: `crud.py::_gate_tokenless_request` admits source-mode public-template creates tokenless and rejects working-branch mode with 400 — see [Tokenless (Anonymous) Clone of Public Templates](#tokenless-anonymous-clone-of-public-templates-ent123) (ent#123). +**Agent creation — 3-tier with provenance** (`resolve_github_pat(agent_name, owner_id)`, per-agent → owner's per-user → global). Called at both `github:` create sites in `services/agent_service/crud.py`; it returns `(pat, tier)` so the creator's own token (`users.github_pat_encrypted`, self-service `/api/users/me/github-pat` — its `PUT`/`DELETE` take `Depends(require_interactive)`, a signed-in session only, #2996) is preferred over the shared admin PAT — a non-admin is no longer confined to the admin's repo scope. The resolved PAT is persisted as the #347 per-agent PAT **only** when `tier ∈ {per_user, fork}`, never `global`: a global-fallback agent keeps `github_pat_encrypted` NULL so `github_pat_propagation_service` still reaches it on admin rotation. Resolution keys on `owner_id` (the creator/owner) only — never a sharee — so a shared agent's git identity can't be hijacked. When NO tier resolves a token (`("", "none")`), the create is no longer an automatic hard-fail: `crud.py::_gate_tokenless_request` admits source-mode public-template creates tokenless and rejects working-branch mode with 400 — see [Tokenless (Anonymous) Clone of Public Templates](#tokenless-anonymous-clone-of-public-templates-ent123) (ent#123). **Platform PAT** (`src/backend/services/settings_service.py`): ```python diff --git a/docs/memory/feature-flows/parallel-capacity.md b/docs/memory/feature-flows/parallel-capacity.md index f9896eef8d..e7190a2202 100644 --- a/docs/memory/feature-flows/parallel-capacity.md +++ b/docs/memory/feature-flows/parallel-capacity.md @@ -444,8 +444,8 @@ Both the agents store (for Agents page) and network store (for Dashboard) fetch ### PUT /api/agents/{name}/capacity -**Authentication**: Required (JWT) -**Authorization**: Agent owner only +**Authentication**: Required (JWT, or the owner's own `user`-scoped key) +**Authorization**: Agent owner only. **Person-only (#2996)**: the route takes `Depends(require_person)` — a JWT session or the owner's own `user`-scoped key. Agent- and system-scoped keys (and every other key scope) get 403 `person_required` before the owner check, whatever agent they address. See requirements `auth.md` §2.8. **Request**: ```json diff --git a/docs/memory/feature-flows/public-channel-model.md b/docs/memory/feature-flows/public-channel-model.md index f22cb4ddfe..4a5ee4144f 100644 --- a/docs/memory/feature-flows/public-channel-model.md +++ b/docs/memory/feature-flows/public-channel-model.md @@ -37,7 +37,7 @@ the agent behaves in my own chats. ## Backend - `GET /api/agents/{name}/public-channel-model` (any authenticated accessor) → raw override, `resolved_model`, `is_overridden`, `platform_default`, `available_models`. -- `PUT /api/agents/{name}/public-channel-model` (owner-only via `can_user_share_agent`) → +- `PUT /api/agents/{name}/public-channel-model` (owner-only via `can_user_share_agent`; person-only via `Depends(require_person)`, #2996 — agent- and system-scoped keys get 403 `person_required`) → set/clear; 422 on an off-whitelist value; audit `CONFIGURATION:update_public_channel_model`. - Both in `routers/agent_config.py`; request model `PublicChannelModelUpdate` (`models.py`). diff --git a/docs/memory/feature-flows/read-only-mode.md b/docs/memory/feature-flows/read-only-mode.md index 720c9df0ab..3dabdde803 100644 --- a/docs/memory/feature-flows/read-only-mode.md +++ b/docs/memory/feature-flows/read-only-mode.md @@ -12,7 +12,7 @@ As an agent owner, I want to enable read-only mode so that the agent cannot modi - **UI (Agent Detail)**: `src/frontend/src/components/AgentHeader.vue:128-136` - ReadOnlyToggle component - **UI (Dashboard List)**: `src/frontend/src/components/AgentListPanel.vue` - ReadOnlyToggle in the row controls (ent#260 — replaces the retired Agents page; rows read `agent.read_only_enabled` off the fleet payload, the per-agent GET N+1 is gone) -- **API**: `GET/PUT /api/agents/{name}/read-only` +- **API**: `GET/PUT /api/agents/{name}/read-only` — the `PUT` is person-only (`Depends(require_person)`, #2996: agent- and system-scoped keys get 403 `person_required`) --- diff --git a/docs/memory/learnings/2026-09-28-the-owner-tier-is-not-the-admin-tier.md b/docs/memory/learnings/2026-09-28-the-owner-tier-is-not-the-admin-tier.md new file mode 100644 index 0000000000..a580b48c20 --- /dev/null +++ b/docs/memory/learnings/2026-09-28-the-owner-tier-is-not-the-admin-tier.md @@ -0,0 +1,4 @@ +## 2026-09-28 — architecture — The owner tier is not the admin tier: a human-only rule there is per route, and the inheritance mechanism is a classified route ledger + +**Context**: #2996 / trinity-enterprise#711. #1890 moved the agent-principal refusal INTO `require_admin` / `assert_admin`, because no legitimate agent flow needs an admin gate. The owner-tier gates (`can_user_share_agent`, `assert_agent_owner`, `OwnedAgentByName`) cannot take the same move: agents legitimately drive owner-gated routes (the MCP schedule tools, file writes), so refusing agent keys inside the gate is a fleet-wide behaviour change. +**Lesson**: When the shared gate carries legitimate machine traffic, put the human-only rule on the route (`Depends(require_person)` for grants, `Depends(require_interactive)` for credential and sign-in-identity writes), and make inheritance come from a guard, not from memory: `tests/unit/test_2996_human_only_routes.py` requires every route, every method, to resolve to one policy class or sit in a shrink-only baseline. A gate only counts in the Depends form or as the handler's first statement on its principal — a check that lives in `services/` is invisible to a router-level scan, which is how the autonomy toggle's check hid. diff --git a/docs/memory/requirements/auth.md b/docs/memory/requirements/auth.md index 68c43b2f9d..a78f39c7f0 100644 --- a/docs/memory/requirements/auth.md +++ b/docs/memory/requirements/auth.md @@ -98,7 +98,7 @@ - **Still working**: the owner's `user` key and ops tooling on a `user` key keep the PERSON routes; the UI and CLI use JWTs everywhere; heartbeat, the result callback, reports and notifications do not use these gates; `trinity-system` keeps `require_admin` (#2323) and has no caller of the routes above. - **Route census (the inheritance mechanism)**: `tests/unit/test_2996_human_only_routes.py` walks every `*.py` under `src/backend/` (`rglob`, `enterprise/` excluded — the private tree carries its own), every HTTP method (GETs included), both `@.(...)` and `add_api_route(...)`. Each route must resolve to exactly one policy class: `person` / `interactive` (the Depends form, or the imperative call as the handler's **first** statement on its principal argument, with the name imported from `dependencies`), `admin_tier` (`require_admin` / `assert_admin`, counted and printed), `admin_widened` (an `allow_scopes` opt-in, listed per route), `portal` (`get_portal_principal`), `agent_callable` / `own_auth` / `delegated` (listed per route with the `METHOD /path` pinned and a reason), or `unclassified_at_freeze` — the frozen baseline `tests/unit/fixtures/human_only_route_baseline.json`. The baseline is **shrink-only**: exact count literal, stale entries fail, an entry that becomes gated must be removed. WebSocket routes are pinned as a literal set. `tests/unit/test_2996_route_census_runtime.py` imports the real app in a subprocess (fails, never skips) and checks every registered route maps to a census key and every stored `METHOD /path` matches. - **Not closed here**: the rest of the owner-tier grant routes the census lists as `unclassified_at_freeze` — each needs a per-route grant-vs-use ruling because several carry live agent traffic. Tracked in a follow-up issue; the baseline makes the remainder visible and only lets it shrink. -- **Tests**: `tests/unit/test_human_only_principals.py` (primitives), `tests/unit/test_mcp_key_creation_requires_session.py`, `tests/unit/test_2996_human_only_routes.py`, `tests/unit/test_2996_route_census_runtime.py`, `tests/unit/test_2996_owner_config_person_only.py`, `tests/unit/test_2996_self_service_session_only.py`. +- **Tests**: `tests/unit/test_human_only_principals.py` (primitives), `tests/unit/test_mcp_key_creation_requires_session.py`, `tests/unit/test_2996_human_only_routes.py`, `tests/unit/test_2996_route_census_runtime.py`, `tests/unit/test_2996_owner_config_person_only.py`, `tests/unit/test_2996_self_service_session_only.py`, `tests/unit/test_2996_agent_schedule_tools_unchanged.py` (the agent's own schedule tools stay agent-callable). - **Flows**: `feature-flows/autonomy-mode.md`, `feature-flows/email-authentication.md`, `feature-flows/mcp-api-keys.md` --- From 925c5c97b6d197f513775e9a01a127a7a701338b Mon Sep 17 00:00:00 2001 From: Andrii Pasternak <44377756+AndriiPasternak31@users.noreply.github.com> Date: Mon, 28 Sep 2026 11:59:02 +0100 Subject: [PATCH 14/23] test(agent-config): patch the container lookup on the modules the routes call (#2996) The read-only route imports its logic lazily from sys.modules, and a sibling unit file evicts that entry at import, so under some orders the route ran an unpatched copy and 404'd. The fixture now resolves and pins the real module, and patches the bound logic through the globals of the functions the router holds. --- .../test_2996_owner_config_person_only.py | 36 +++++++++++++++---- 1 file changed, 30 insertions(+), 6 deletions(-) diff --git a/tests/unit/test_2996_owner_config_person_only.py b/tests/unit/test_2996_owner_config_person_only.py index bdd6eb22c0..55983e1388 100644 --- a/tests/unit/test_2996_owner_config_person_only.py +++ b/tests/unit/test_2996_owner_config_person_only.py @@ -28,11 +28,12 @@ from __future__ import annotations +import importlib import os import sys from dataclasses import dataclass from pathlib import Path -from types import SimpleNamespace +from types import ModuleType, SimpleNamespace from typing import Optional import pytest @@ -51,9 +52,6 @@ import dependencies # noqa: E402 import routers.agent_config as _CFG # noqa: E402 -import services.agent_service.api_key as _APIKEY # noqa: E402 -import services.agent_service.autonomy as _AUTONOMY # noqa: E402 -import services.agent_service.read_only as _READONLY # noqa: E402 from database import db # noqa: E402 from db_models import McpApiKeyCreate, UserCreate # noqa: E402 @@ -86,11 +84,37 @@ class _Principal: _STOPPED = SimpleNamespace(status="exited", attrs={"Config": {"Labels": {}}}) +_READONLY_NAME = "services.agent_service.read_only" + + +def _stub_containers(monkeypatch): + """Point every container lookup the routes make at a stopped container — + on the module objects the routes will ACTUALLY call, not the ones this file + happened to import. + + The router and the autonomy / api-key logic bind at import, so their lookup + is patched through the globals of the functions the router holds. The + read-only logic is imported lazily inside the route, from `sys.modules` + at call time, and sibling unit files evict or stub that entry + (`test_validate_runtime.py` pops it at import); so the real module is + resolved here, pinned into `sys.modules` for this test, and patched. + """ + stub = lambda _n: _STOPPED # noqa: E731 + monkeypatch.setattr(_CFG, "get_agent_container", stub) + for fn in (_CFG.set_autonomy_status_logic, _CFG.update_agent_api_key_setting_logic): + monkeypatch.setitem(fn.__globals__, "get_agent_container", stub) + mod = sys.modules.get(_READONLY_NAME) + if not (isinstance(mod, ModuleType) and (getattr(mod, "__file__", "") or "").endswith("read_only.py")): + monkeypatch.delitem(sys.modules, _READONLY_NAME, raising=False) + mod = importlib.import_module(_READONLY_NAME) + monkeypatch.setitem(sys.modules, _READONLY_NAME, mod) + monkeypatch.setattr(mod, "get_agent_container", stub) + + @pytest.fixture def world(db_backend, monkeypatch): monkeypatch.setattr(dependencies, "get_breaker_redis", lambda: None) - for mod in (_CFG, _AUTONOMY, _APIKEY, _READONLY): - monkeypatch.setattr(mod, "get_agent_container", lambda _n: _STOPPED) + _stub_containers(monkeypatch) db.create_user( UserCreate(username=OWNER, role="user", email="cfg2996-owner@example.com") ) From 2ab1f902b90b0b2bd79610584f59e1384fcf52f2 Mon Sep 17 00:00:00 2001 From: Andrii Pasternak <44377756+AndriiPasternak31@users.noreply.github.com> Date: Mon, 28 Sep 2026 12:54:20 +0100 Subject: [PATCH 15/23] docs(auth): describe the human-only rule, not the before-state (#2996) Drop the sign-in resolution test class and its registry note: they pinned behaviour outside this change. Reword the census and MCP-boundary notes neutrally; the baseline is a to-do marker, not a judgement. --- docs/memory/architecture/security.md | 2 +- .../feature-flows/email-authentication.md | 2 +- docs/memory/requirements/auth.md | 2 +- tests/registry.json | 2 +- .../test_2996_self_service_session_only.py | 39 ------------------- 5 files changed, 4 insertions(+), 43 deletions(-) diff --git a/docs/memory/architecture/security.md b/docs/memory/architecture/security.md index 0877f534eb..d664fded26 100644 --- a/docs/memory/architecture/security.md +++ b/docs/memory/architecture/security.md @@ -57,7 +57,7 @@ Each agent gets an auto-generated agent-scoped key (`scope='agent'`, `agent_name Enforced at the **MCP server layer** (`src/mcp-server/src/tools/`), not the backend REST API: `list_agents` returns only permitted agents + self; `chat_with_agent` blocks non-permitted targets. The backend resolves agent-scoped keys to the owner user and applies standard ownership/sharing checks (`current_user.agent_name` is used only by notifications and event subscriptions). **Restrictive default**: new agents start with zero permissions; grants are explicit via the Permissions tab (`agent_permissions` table). -**Unexposed at the MCP layer is not unreachable (#2996).** Every agent has `TRINITY_BACKEND_URL`, so a `# mcp: none` router is still reachable over raw REST with the agent's own key. Autonomy and the agent-config toggles (`routers/agent_config.py`) and the self-service credential/identity routes (`routers/users.py` email and GitHub PAT) are therefore fenced **at the backend** as human-only (§6, requirements `auth.md` §2.8), not only left out of the tool set. +**The MCP tool set is not an authorization boundary (#2996).** A `# mcp: none` header records what the MCP server exposes, not who the backend admits, so a human-only route must be fenced in the backend itself. Autonomy and the agent-config toggles (`routers/agent_config.py`) and the self-service credential/identity routes (`routers/users.py` email and GitHub PAT) are therefore fenced **at the backend** as human-only (§6, requirements `auth.md` §2.8), not only left out of the tool set. **Residual, stated plainly (#1854).** The enforcement is `scope === "agent"`-conditional, so it holds only while the container's `.mcp.json` actually carries the agent's own key. A **user-scoped** key pasted into that file authenticates as the owner and bypasses the matrix silently — the platform accepts it. #1854 **detects** this (the config-truth probe above, verdicts `foreign_user_key` / `foreign_agent_key` / `shadow_entry`) and **repairs** it (drift predicate + rotation); it does not **prevent** it. Prevention needs a trustworthy request-origin signal, which does not exist on the MCP path today — the MCP server forwards no origin marker, and client IP cannot substitute (port 8080 is published on all interfaces and the frontend also sits on `trinity-agent-network`). Deferred as Part 2b/3; if built it must be an explicit allowlist over **all five** scopes, since "reject non-agent" breaks the system agent, the connector and portal_delegate. diff --git a/docs/memory/feature-flows/email-authentication.md b/docs/memory/feature-flows/email-authentication.md index a096aebb14..e4f90a427a 100644 --- a/docs/memory/feature-flows/email-authentication.md +++ b/docs/memory/feature-flows/email-authentication.md @@ -867,7 +867,7 @@ class EmailLoginResponse(BaseModel): ### Binding the sign-in email is session-only (trinity-enterprise#711) -Email sign-in resolves the account by the `users.email` column alone (`db/email_auth.py::get_or_create_email_user`), so the address an account signs in with is its sign-in identity. `PUT /api/users/me/email` (`routers/users.py`) therefore takes `Depends(require_interactive)`: a signed-in (JWT) session only; every MCP key, the person's own `user` key included, gets 403 and the column does not move. `PUT` / `DELETE /api/users/me/github-pat` take the same rule. See requirements `auth.md` §2.8. +The address an account signs in with is its sign-in identity, so changing it is a human, signed-in act. `PUT /api/users/me/email` (`routers/users.py`) therefore takes `Depends(require_interactive)`: a signed-in (JWT) session only; every MCP key, the person's own `user` key included, gets 403 and the column does not move. `PUT` / `DELETE /api/users/me/github-pat` take the same rule. See requirements `auth.md` §2.8. ### Email Enumeration Prevention diff --git a/docs/memory/requirements/auth.md b/docs/memory/requirements/auth.md index a78f39c7f0..1a633b28ea 100644 --- a/docs/memory/requirements/auth.md +++ b/docs/memory/requirements/auth.md @@ -97,7 +97,7 @@ - **Refusal**: 403. PERSON routes return `HUMAN_ONLY_DETAIL` (`{"code": "person_required", ...}`, the same machine code as ent#611's ask endings, whose own detail is unchanged); INTERACTIVE routes return `reject_non_interactive_principal`'s existing detail. The refusal depends only on the principal, never on whether the addressed agent exists, so answering 403 before a path's 404 is not an enumeration oracle (#186). - **Still working**: the owner's `user` key and ops tooling on a `user` key keep the PERSON routes; the UI and CLI use JWTs everywhere; heartbeat, the result callback, reports and notifications do not use these gates; `trinity-system` keeps `require_admin` (#2323) and has no caller of the routes above. - **Route census (the inheritance mechanism)**: `tests/unit/test_2996_human_only_routes.py` walks every `*.py` under `src/backend/` (`rglob`, `enterprise/` excluded — the private tree carries its own), every HTTP method (GETs included), both `@.(...)` and `add_api_route(...)`. Each route must resolve to exactly one policy class: `person` / `interactive` (the Depends form, or the imperative call as the handler's **first** statement on its principal argument, with the name imported from `dependencies`), `admin_tier` (`require_admin` / `assert_admin`, counted and printed), `admin_widened` (an `allow_scopes` opt-in, listed per route), `portal` (`get_portal_principal`), `agent_callable` / `own_auth` / `delegated` (listed per route with the `METHOD /path` pinned and a reason), or `unclassified_at_freeze` — the frozen baseline `tests/unit/fixtures/human_only_route_baseline.json`. The baseline is **shrink-only**: exact count literal, stale entries fail, an entry that becomes gated must be removed. WebSocket routes are pinned as a literal set. `tests/unit/test_2996_route_census_runtime.py` imports the real app in a subprocess (fails, never skips) and checks every registered route maps to a census key and every stored `METHOD /path` matches. -- **Not closed here**: the rest of the owner-tier grant routes the census lists as `unclassified_at_freeze` — each needs a per-route grant-vs-use ruling because several carry live agent traffic. Tracked in a follow-up issue; the baseline makes the remainder visible and only lets it shrink. +- **Not closed here**: the census does not classify the routes it freezes as `unclassified_at_freeze` — the baseline is a to-do marker, not a judgement (`tests/unit/fixtures/README.md`). Each entry leaves it through a per-route ruling, and the baseline only lets it shrink. - **Tests**: `tests/unit/test_human_only_principals.py` (primitives), `tests/unit/test_mcp_key_creation_requires_session.py`, `tests/unit/test_2996_human_only_routes.py`, `tests/unit/test_2996_route_census_runtime.py`, `tests/unit/test_2996_owner_config_person_only.py`, `tests/unit/test_2996_self_service_session_only.py`, `tests/unit/test_2996_agent_schedule_tools_unchanged.py` (the agent's own schedule tools stay agent-callable). - **Flows**: `feature-flows/autonomy-mode.md`, `feature-flows/email-authentication.md`, `feature-flows/mcp-api-keys.md` diff --git a/tests/registry.json b/tests/registry.json index 60edb734f7..e63efb4ebc 100644 --- a/tests/registry.json +++ b/tests/registry.json @@ -22,7 +22,7 @@ "security", "users" ], - "description": "PUT /api/users/me/email and PUT/DELETE /api/users/me/github-pat take Depends(require_interactive): agent, system and the person's own user key -> 403 with users.email and the encrypted PAT column unchanged; override kinds refused by the route gate; JWT path unchanged (bind, 400 malformed, 409 taken, PAT set/clear, status read). Records that email sign-in resolves the account by the email column alone (get_or_create_email_user): after a rebind the old address resolves to a new account, and an email-named account collides." + "description": "PUT /api/users/me/email and PUT/DELETE /api/users/me/github-pat take Depends(require_interactive): agent, system and the person's own user key -> 403 with users.email and the encrypted PAT column unchanged; override kinds refused by the route gate; JWT path unchanged (bind, 400 malformed, 409 taken, PAT set/clear, status read)." }, { "file": "unit/test_2996_agent_schedule_tools_unchanged.py", diff --git a/tests/unit/test_2996_self_service_session_only.py b/tests/unit/test_2996_self_service_session_only.py index bc71b832c3..3baa99d92e 100644 --- a/tests/unit/test_2996_self_service_session_only.py +++ b/tests/unit/test_2996_self_service_session_only.py @@ -12,9 +12,6 @@ minted as a row go through an override with a stated principal, so the route's own gate is what refuses them. -The last class records how email sign-in resolves an account — by the email -column alone — which is why binding the sign-in email is a session-only act. - Related: docs/memory/requirements/auth.md §2.8, feature-flows/email-authentication.md. """ @@ -250,39 +247,3 @@ def test_the_pat_status_read_is_unchanged(self, world): res = world.client.get("/api/users/me/github-pat", headers=_owner_user_key()) assert res.status_code == 200, res.text assert res.json()["configured"] is True - - -class TestEmailSignInResolvesByEmailAlone: - """Email sign-in finds the account by `users.email`, nothing else - (`db/email_auth.py::get_or_create_email_user`). So the account a mailbox - signs in to is whichever row carries that address — which is why binding - the sign-in email is a session-only act.""" - - def test_after_a_rebind_the_old_address_resolves_to_a_new_account(self, world): - db.add_to_whitelist( - OWNER_EMAIL, added_by=ADMIN, source="manual", default_role="user" - ) - assert _call(world, "email", _jwt()).status_code == 200 - - resolved = db.get_or_create_email_user(OWNER_EMAIL) - assert resolved["username"] == OWNER_EMAIL - assert resolved["id"] != world.owner_id - assert _stored_email() == NEW_EMAIL - - def test_after_a_rebind_an_email_named_account_cannot_be_recreated(self, world): - """An account whose username IS its address: the new row collides with - it on the username key, so the lookup raises rather than resolving.""" - db.create_user( - UserCreate( - username="self2996-r@example.com", - role="user", - email="self2996-r@example.com", - ) - ) - _hrun( - "UPDATE users SET email = :e WHERE username = :u", - e="self2996-elsewhere@example.com", - u="self2996-r@example.com", - ) - with pytest.raises(Exception): - db.get_or_create_email_user("self2996-r@example.com") From 25d0e6943c290c75b6d9b8563b0a0470f1cb3cba Mon Sep 17 00:00:00 2001 From: Andrii Pasternak <44377756+AndriiPasternak31@users.noreply.github.com> Date: Mon, 28 Sep 2026 13:08:35 +0100 Subject: [PATCH 16/23] docs(auth): point the census remainder at its tracking issue (#2996) --- docs/memory/requirements/auth.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/memory/requirements/auth.md b/docs/memory/requirements/auth.md index 1a633b28ea..766db949f9 100644 --- a/docs/memory/requirements/auth.md +++ b/docs/memory/requirements/auth.md @@ -97,7 +97,7 @@ - **Refusal**: 403. PERSON routes return `HUMAN_ONLY_DETAIL` (`{"code": "person_required", ...}`, the same machine code as ent#611's ask endings, whose own detail is unchanged); INTERACTIVE routes return `reject_non_interactive_principal`'s existing detail. The refusal depends only on the principal, never on whether the addressed agent exists, so answering 403 before a path's 404 is not an enumeration oracle (#186). - **Still working**: the owner's `user` key and ops tooling on a `user` key keep the PERSON routes; the UI and CLI use JWTs everywhere; heartbeat, the result callback, reports and notifications do not use these gates; `trinity-system` keeps `require_admin` (#2323) and has no caller of the routes above. - **Route census (the inheritance mechanism)**: `tests/unit/test_2996_human_only_routes.py` walks every `*.py` under `src/backend/` (`rglob`, `enterprise/` excluded — the private tree carries its own), every HTTP method (GETs included), both `@.(...)` and `add_api_route(...)`. Each route must resolve to exactly one policy class: `person` / `interactive` (the Depends form, or the imperative call as the handler's **first** statement on its principal argument, with the name imported from `dependencies`), `admin_tier` (`require_admin` / `assert_admin`, counted and printed), `admin_widened` (an `allow_scopes` opt-in, listed per route), `portal` (`get_portal_principal`), `agent_callable` / `own_auth` / `delegated` (listed per route with the `METHOD /path` pinned and a reason), or `unclassified_at_freeze` — the frozen baseline `tests/unit/fixtures/human_only_route_baseline.json`. The baseline is **shrink-only**: exact count literal, stale entries fail, an entry that becomes gated must be removed. WebSocket routes are pinned as a literal set. `tests/unit/test_2996_route_census_runtime.py` imports the real app in a subprocess (fails, never skips) and checks every registered route maps to a census key and every stored `METHOD /path` matches. -- **Not closed here**: the census does not classify the routes it freezes as `unclassified_at_freeze` — the baseline is a to-do marker, not a judgement (`tests/unit/fixtures/README.md`). Each entry leaves it through a per-route ruling, and the baseline only lets it shrink. +- **Not closed here**: the census does not classify the routes it freezes as `unclassified_at_freeze` — the baseline is a to-do marker, not a judgement (`tests/unit/fixtures/README.md`). Each entry leaves it through a per-route ruling (tracked in trinity-enterprise#719), and the baseline only lets it shrink. - **Tests**: `tests/unit/test_human_only_principals.py` (primitives), `tests/unit/test_mcp_key_creation_requires_session.py`, `tests/unit/test_2996_human_only_routes.py`, `tests/unit/test_2996_route_census_runtime.py`, `tests/unit/test_2996_owner_config_person_only.py`, `tests/unit/test_2996_self_service_session_only.py`, `tests/unit/test_2996_agent_schedule_tools_unchanged.py` (the agent's own schedule tools stay agent-callable). - **Flows**: `feature-flows/autonomy-mode.md`, `feature-flows/email-authentication.md`, `feature-flows/mcp-api-keys.md` From 1b51710fcae7d71170f1184364b8f46de63e6790 Mon Sep 17 00:00:00 2001 From: Andrii Pasternak <44377756+AndriiPasternak31@users.noreply.github.com> Date: Mon, 28 Sep 2026 13:13:12 +0100 Subject: [PATCH 17/23] test(auth): the runtime census imports main from its backend argument only (#2996) An inherited PYTHONPATH (the verify-local unit stage sets one) let the subprocess resolve `main` from another tree, so the fail-loud check on a missing app could not fail. --- tests/unit/_route_census.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/unit/_route_census.py b/tests/unit/_route_census.py index d63f55c790..543d31132d 100644 --- a/tests/unit/_route_census.py +++ b/tests/unit/_route_census.py @@ -798,14 +798,19 @@ def runtime_routes( ): """Import the real app in a SUBPROCESS and return its routes. Raises (never skips) on a failed import, unparseable output or zero routes.""" + import os import subprocess + # `main` must resolve from `backend` alone: an inherited PYTHONPATH (the + # verify-local unit stage sets one) would import some other tree's app. + env = {k: v for k, v in os.environ.items() if k != "PYTHONPATH"} proc = subprocess.run( [python, "-c", RUNTIME_DUMP, str(backend)], capture_output=True, text=True, timeout=timeout, cwd=str(REPO), + env=env, ) if proc.returncode != 0: raise RuntimeError( From df3c7ac47f4452469c7c171fe6c51c577ee6b4c0 Mon Sep 17 00:00:00 2001 From: Andrii Pasternak <44377756+AndriiPasternak31@users.noreply.github.com> Date: Mon, 28 Sep 2026 11:27:10 +0100 Subject: [PATCH 18/23] fix(mcp-keys): listing keys requires a signed-in session GET /api/mcp/keys now takes the interactive rule: only a JWT session may list keys. The Settings tab is unchanged; a signed-in admin still sees every key with its owner's email, and a signed-in user sees their own. Refs trinity-enterprise#712 --- src/backend/routers/mcp_keys.py | 7 +- tests/registry.json | 12 + .../test_mcp_key_listing_requires_session.py | 230 ++++++++++++++++++ 3 files changed, 248 insertions(+), 1 deletion(-) create mode 100644 tests/unit/test_mcp_key_listing_requires_session.py diff --git a/src/backend/routers/mcp_keys.py b/src/backend/routers/mcp_keys.py index c17d238f88..68595d33bc 100644 --- a/src/backend/routers/mcp_keys.py +++ b/src/backend/routers/mcp_keys.py @@ -99,7 +99,12 @@ async def list_mcp_api_keys_endpoint( request: Request, current_user: User = Depends(get_current_user) ): - """List all MCP API keys for the current user.""" + """List MCP API keys: the caller's own, or every key for an admin. + + Signed-in session only: the inventory has no machine consumer, so every MCP + key (the person's own `user` key included) gets a 403. + """ + reject_non_interactive_principal(current_user) try: if current_user.role == "admin": keys = db.list_all_mcp_api_keys() diff --git a/tests/registry.json b/tests/registry.json index e63efb4ebc..c29269cb3c 100644 --- a/tests/registry.json +++ b/tests/registry.json @@ -83,6 +83,18 @@ ], "description": "Creating an MCP key requires a signed-in session: POST /api/mcp/keys (every scope) and POST /api/mcp/keys/ensure-default through the REAL get_current_user with seeded agent/system/connector/user key rows -> 403 and mcp_api_keys row count unchanged, no key_create audit row; JWT -> 200 with the secret and one key_create audit row (ensure-default included, target = new key id); admin JWT still creates ops/portal_delegate; non-admin JWT portal_delegate still 403; a user-scoped key asking an admin scope 403; existing user key -> ensure-default returns null, creates and audits nothing; the created key validates as its owner." }, + { + "file": "unit/test_mcp_key_listing_requires_session.py", + "feature": "ent#712", + "added": "2026-09-28", + "categories": [ + "backend", + "auth", + "security", + "mcp-keys" + ], + "description": "GET /api/mcp/keys requires a signed-in session (INTERACTIVE, reject_non_interactive_principal as the handler's first statement): agent/system/connector/user keys through the REAL get_current_user with seeded key rows, and ops/portal_delegate/unknown-future-scope/missing-mcp_scope via an explicit principal dataclass -> 403. Signed-in admin -> every key with user_email (never the secret); signed-in user -> own keys only. Every route in routers/mcp_keys.py read off the router object and listed with its rule; a single-key read stays owner-scoped; /validate needs no session." + }, { "file": "unit/test_ent600_telegram_group_context.py", "feature": "ent#600", diff --git a/tests/unit/test_mcp_key_listing_requires_session.py b/tests/unit/test_mcp_key_listing_requires_session.py new file mode 100644 index 0000000000..3a9799ebcb --- /dev/null +++ b/tests/unit/test_mcp_key_listing_requires_session.py @@ -0,0 +1,230 @@ +"""Listing MCP keys requires a signed-in session. + +`GET /api/mcp/keys` is the key inventory the Settings → MCP Keys tab renders. +It has no machine consumer (the CLI and the MCP server never call it with a +key), so it takes the INTERACTIVE rule: `reject_non_interactive_principal` as +the handler's first statement — a JWT session only. Every MCP key, the person's +own `user` key included, gets a 403. A signed-in admin still sees the full +listing with each key's `user_email` (the tab renders it on every row); a +signed-in non-admin sees only their own keys. + +Key-authenticated callers go through the REAL `get_current_user` with a key row +seeded in the real schema (`tests/db_harness.py`). Kinds the entry point fences +earlier, or that cannot be seeded as a row, go through a dependency override +with a stated principal — every `models.User` field set, `mcp_scope` included, +never a `MagicMock` — so the route's own gate is what refuses them. + +Related: docs/memory/feature-flows/mcp-api-keys.md. +""" + +from __future__ import annotations + +import os +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Optional + +import pytest + +os.environ.setdefault("REDIS_URL", "redis://u:p@localhost:6379") +os.environ.setdefault("SECRET_KEY", "test-secret") + +_BACKEND = Path(__file__).resolve().parents[2] / "src" / "backend" +if str(_BACKEND) not in sys.path: + sys.path.insert(0, str(_BACKEND)) + +from fastapi import FastAPI # noqa: E402 +from fastapi.routing import APIRoute # noqa: E402 +from fastapi.testclient import TestClient # noqa: E402 + +from db_harness import db_backend, run as _hrun # noqa: E402,F401 + +import dependencies # noqa: E402 +import routers.mcp_keys as _KEYS # noqa: E402 +from database import db # noqa: E402 +from db_models import McpApiKeyCreate, UserCreate # noqa: E402 + +pytestmark = pytest.mark.unit + +ADMIN = "keylist-admin" +PLAIN = "keylist-plain" +AGENT = "keylist-agent" +SYSTEM = "keylist-system" +ADMIN_EMAIL = "keylist-admin@example.com" +PLAIN_EMAIL = "keylist-plain@example.com" + +LIST = "/api/mcp/keys" + + +@dataclass +class _Principal: + """Every field `models.User` carries, stated — never a MagicMock.""" + + id: int + username: str + email: Optional[str] + role: str + agent_name: Optional[str] = None + connector_agent: Optional[str] = None + portal_delegate: bool = False + mcp_scope: Optional[str] = None + mcp_key_id: Optional[str] = None + mcp_key_name: Optional[str] = None + vouched_source_agent: Optional[str] = None + + +@dataclass +class _PrincipalWithoutScope: + """Deliberately lacks `mcp_scope`: a stand-in that does not match the real + principal must fail closed, never read as the JWT value.""" + + id: int + username: str + email: Optional[str] + role: str + agent_name: Optional[str] = None + connector_agent: Optional[str] = None + portal_delegate: bool = False + vouched_source_agent: Optional[str] = None + + +@pytest.fixture +def world(db_backend, monkeypatch): + # JWT revocation is a Redis read; no Redis here. + monkeypatch.setattr(dependencies, "get_breaker_redis", lambda: None) + db.create_user(UserCreate(username=ADMIN, role="admin", email=ADMIN_EMAIL)) + db.create_user(UserCreate(username=PLAIN, role="user", email=PLAIN_EMAIL)) + db.register_agent_owner(AGENT, ADMIN) + # One key per account, so "full listing" and "own only" are distinguishable. + db.create_mcp_api_key(ADMIN, McpApiKeyCreate(name="admin laptop")) + db.create_mcp_api_key(PLAIN, McpApiKeyCreate(name="plain laptop")) + app = FastAPI() + app.include_router(_KEYS.router) + yield app, TestClient(app) + app.dependency_overrides.clear() + + +def _auth(token: str) -> dict: + return {"Authorization": f"Bearer {token}"} + + +def _jwt(username: str) -> dict: + return _auth(dependencies.create_access_token({"sub": username})) + + +def _agent_key() -> dict: + return _auth(db.create_agent_mcp_api_key(AGENT, ADMIN).api_key) + + +def _rescoped_key(scope: str, agent_name: str) -> dict: + """A key row carrying `scope`, created the way the platform creates the + system and connector keys: an agent key, then its scope column set.""" + key = db.create_agent_mcp_api_key(agent_name, ADMIN) + _hrun("UPDATE mcp_api_keys SET scope = :s WHERE id = :i", s=scope, i=key.id) + return _auth(key.api_key) + + +def _admin_user_key() -> dict: + return _auth( + db.create_mcp_api_key(ADMIN, McpApiKeyCreate(name="admin own key")).api_key + ) + + +REAL_KEY_KINDS = { + "agent-key": _agent_key, + "system-key": lambda: _rescoped_key("system", SYSTEM), + "connector-key": lambda: _rescoped_key("connector", AGENT), + "user-key": _admin_user_key, +} + + +def _admin_principal(**fields): + base = {"id": 1, "username": ADMIN, "email": ADMIN_EMAIL, "role": "admin"} + base.update(fields) + return _Principal(**base) + + +OVERRIDE_KINDS = { + "ops-key": lambda: _admin_principal(mcp_scope="ops"), + "portal-delegate-key": lambda: _admin_principal( + mcp_scope="portal_delegate", portal_delegate=True + ), + "unknown-future-scope": lambda: _admin_principal(mcp_scope="a-scope-shipped-tomorrow"), + "missing-mcp-scope": lambda: _PrincipalWithoutScope( + id=1, username=ADMIN, email=ADMIN_EMAIL, role="admin" + ), +} + + +class TestKeyListingRequiresSession: + @pytest.mark.parametrize("kind", sorted(REAL_KEY_KINDS)) + def test_key_listing_requires_session(self, world, kind): + _, client = world + res = client.get(LIST, headers=REAL_KEY_KINDS[kind]()) + assert res.status_code == 403, res.text + + @pytest.mark.parametrize("kind", sorted(OVERRIDE_KINDS)) + def test_refused_by_the_route_gate(self, world, kind): + app, client = world + principal = OVERRIDE_KINDS[kind]() + app.dependency_overrides[dependencies.get_current_user] = lambda: principal + res = client.get(LIST) + assert res.status_code == 403, res.text + + +class TestSessionListingUnchanged: + def test_a_signed_in_admin_sees_every_key_with_its_owner_email(self, world): + _, client = world + res = client.get(LIST, headers=_jwt(ADMIN)) + assert res.status_code == 200, res.text + rows = {r["name"]: r for r in res.json()} + assert {"admin laptop", "plain laptop"} <= set(rows) + assert rows["plain laptop"]["user_email"] == PLAIN_EMAIL + assert "api_key" not in rows["plain laptop"] # never the secret + + def test_a_signed_in_user_sees_only_their_own_keys(self, world): + _, client = world + res = client.get(LIST, headers=_jwt(PLAIN)) + assert res.status_code == 200, res.text + assert [r["name"] for r in res.json()] == ["plain laptop"] + + +# Every route in routers/mcp_keys.py, read off the router object. A route added +# to the file fails here until it is listed with the rule it follows and a test +# that pins that rule. +MCP_KEY_ROUTES = { + ("POST", "/api/mcp/keys"): "create — its session rule has its own tests", + ("GET", "/api/mcp/keys"): "interactive — this file", + ("POST", "/api/mcp/keys/ensure-default"): "create — its session rule has its own tests", + ("GET", "/api/mcp/keys/{key_id}"): "owner-scoped read — this file", + ("POST", "/api/mcp/keys/{key_id}/revoke"): "agent + connector refused — test_1854_agent_mcp_key.py", + ("DELETE", "/api/mcp/keys/{key_id}"): "agent + connector refused — test_1854_agent_mcp_key.py", + ("POST", "/api/mcp/validate"): "unauthenticated by design: validates the presented key itself", +} + + +def test_every_route_in_the_file_is_listed_with_its_rule(): + live = { + (method, route.path) + for route in _KEYS.router.routes + if isinstance(route, APIRoute) + for method in route.methods + } + assert live == set(MCP_KEY_ROUTES), ( + "routers/mcp_keys.py routes changed — list each with its rule: " + f"added {sorted(live - set(MCP_KEY_ROUTES))}, removed {sorted(set(MCP_KEY_ROUTES) - live)}" + ) + + +def test_a_single_key_read_stays_owner_scoped(world): + _, client = world + other = db.create_mcp_api_key(ADMIN, McpApiKeyCreate(name="admin second")) + res = client.get(f"{LIST}/{other.id}", headers=_jwt(PLAIN)) + assert res.status_code == 404, res.text + + +def test_validate_needs_no_session(world): + _, client = world + res = client.post("/api/mcp/validate") + assert res.status_code == 401 # the missing key, not a missing session From fe1205f23f245faf7d4b08d36882f22b2f0bb88f Mon Sep 17 00:00:00 2001 From: Andrii Pasternak <44377756+AndriiPasternak31@users.noreply.github.com> Date: Mon, 28 Sep 2026 11:27:29 +0100 Subject: [PATCH 19/23] docs(mcp-keys): listing keys is signed-in-session only Refs trinity-enterprise#712 --- docs/memory/feature-flows.md | 2 +- docs/memory/feature-flows/mcp-api-keys.md | 14 +++++++++++--- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/docs/memory/feature-flows.md b/docs/memory/feature-flows.md index c01af35240..6bb4c8d334 100644 --- a/docs/memory/feature-flows.md +++ b/docs/memory/feature-flows.md @@ -170,7 +170,7 @@ | Admin Login | [admin-login.md](feature-flows/admin-login.md) | Password-based admin auth | | First-Time Setup | [first-time-setup.md](feature-flows/first-time-setup.md) | Admin password wizard | | First-Run Overlay | [onboarding-wizard.md](feature-flows/onboarding-wizard.md) | The blocking post-login setup sequence — step registry, derived completion, re-run (ent#581; formerly the ent#52 wizard) | -| MCP API Keys | [mcp-api-keys.md](feature-flows/mcp-api-keys.md) | API key management — creating a key (`POST /keys`, `ensure-default`) requires a signed-in session; every creation is audited as `key_create` | +| MCP API Keys | [mcp-api-keys.md](feature-flows/mcp-api-keys.md) | API key management — creating (`POST /keys`, `ensure-default`) and listing keys require a signed-in session; every creation is audited as `key_create` | | Execution Origin Tracking | [AUDIT-001-execution-origin-tracking.md](feature-flows/AUDIT-001-execution-origin-tracking.md) | Track who triggered executions | | Agent-Server Authentication | [agent-server-authentication.md](feature-flows/agent-server-authentication.md) | Per-agent inbound auth for the in-container agent server (`:8000`) — derived `X-Trinity-Agent-Token` (HMAC over `AGENT_AUTH_SECRET`) enforced by a pure-ASGI middleware on every HTTP/WS route (#1159) | | 4-Tier Role Model | [role-model.md](feature-flows/role-model.md) | user < operator < creator < admin role hierarchy (ROLE-001) | diff --git a/docs/memory/feature-flows/mcp-api-keys.md b/docs/memory/feature-flows/mcp-api-keys.md index 37c83e4bf2..32631a6ac9 100644 --- a/docs/memory/feature-flows/mcp-api-keys.md +++ b/docs/memory/feature-flows/mcp-api-keys.md @@ -242,7 +242,7 @@ app.include_router(mcp_keys_router) | Method | Endpoint | Handler | Line | Description | |--------|----------|---------|------|-------------| | `POST` | `/api/mcp/keys` | `create_mcp_api_key_endpoint()` | `mcp_keys.py:25` | Create new API key — **signed-in session only** (`Depends(require_interactive)`, every scope) | -| `GET` | `/api/mcp/keys` | `list_mcp_api_keys_endpoint()` | `mcp_keys.py:37-51` | List user's keys (admin sees all) | +| `GET` | `/api/mcp/keys` | `list_mcp_api_keys_endpoint()` | `mcp_keys.py:97` | List user's keys (admin sees all) — **signed-in session only** (`Depends(require_interactive)`) | | `POST` | `/api/mcp/keys/ensure-default` | `ensure_default_mcp_api_key()` | `mcp_keys.py:114` | Auto-create default key — **signed-in session only**; audited as `key_create` | | `GET` | `/api/mcp/keys/{key_id}` | `get_mcp_api_key_endpoint()` | `mcp_keys.py:99-111` | Get single key details | | `POST` | `/api/mcp/keys/{key_id}/revoke` | `revoke_mcp_api_key_endpoint()` | `mcp_keys.py:114-126` | Revoke (deactivate) key | @@ -275,13 +275,21 @@ strict as the principal it produces. The UI (`McpKeysTab.vue`) and the CLI (`trinity login`) both create keys on the login JWT. #### List API Keys (GET /api/mcp/keys) + +Signed-in session only (trinity-enterprise#712): `reject_non_interactive_principal` +is the handler's first statement, so every MCP key — the person's own `user` key +included — gets a 403. The inventory has no machine consumer; the Settings → MCP +Keys tab calls it with the JWT. A signed-in admin still gets every key with its +owner's `user_email` (the tab renders it per row); nothing returns the secret. + ```python -# mcp_keys.py:37-51 +# mcp_keys.py:93-111 @router.get("/keys", response_model=List[McpApiKey]) async def list_mcp_api_keys_endpoint( request: Request, current_user: User = Depends(get_current_user) ): + reject_non_interactive_principal(current_user) # JWT session only if current_user.role == "admin": keys = db.list_all_mcp_api_keys() # Admin sees all else: @@ -677,7 +685,7 @@ class User(BaseModel): curl -X POST http://localhost:8000/api/mcp/validate \ -H "Authorization: Bearer trinity_mcp_YOUR_KEY_HERE" -# List keys (requires JWT) +# List keys (requires a JWT; an MCP key gets 403) curl http://localhost:8000/api/mcp/keys \ -H "Authorization: Bearer YOUR_JWT_TOKEN" ``` From 68a879cf3324b9f314e91eb3b873ef897672df99 Mon Sep 17 00:00:00 2001 From: Andrii Pasternak <44377756+AndriiPasternak31@users.noreply.github.com> Date: Mon, 28 Sep 2026 12:54:20 +0100 Subject: [PATCH 20/23] test(mcp-keys): the route rule refuses a connector principal itself The real connector key is refused at the entry point, so add a principal that reaches the route. Route labels no longer depend on other changes. Refs trinity-enterprise#712 --- tests/unit/test_mcp_key_listing_requires_session.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/tests/unit/test_mcp_key_listing_requires_session.py b/tests/unit/test_mcp_key_listing_requires_session.py index 3a9799ebcb..d676efd096 100644 --- a/tests/unit/test_mcp_key_listing_requires_session.py +++ b/tests/unit/test_mcp_key_listing_requires_session.py @@ -146,6 +146,11 @@ def _admin_principal(**fields): OVERRIDE_KINDS = { + # The real connector key is refused earlier, at the entry point; this one + # reaches the route, so the route's own rule is what refuses it. + "connector-principal": lambda: _admin_principal( + mcp_scope="connector", connector_agent=AGENT + ), "ops-key": lambda: _admin_principal(mcp_scope="ops"), "portal-delegate-key": lambda: _admin_principal( mcp_scope="portal_delegate", portal_delegate=True @@ -194,9 +199,9 @@ def test_a_signed_in_user_sees_only_their_own_keys(self, world): # to the file fails here until it is listed with the rule it follows and a test # that pins that rule. MCP_KEY_ROUTES = { - ("POST", "/api/mcp/keys"): "create — its session rule has its own tests", + ("POST", "/api/mcp/keys"): "create — not changed here", ("GET", "/api/mcp/keys"): "interactive — this file", - ("POST", "/api/mcp/keys/ensure-default"): "create — its session rule has its own tests", + ("POST", "/api/mcp/keys/ensure-default"): "create — not changed here", ("GET", "/api/mcp/keys/{key_id}"): "owner-scoped read — this file", ("POST", "/api/mcp/keys/{key_id}/revoke"): "agent + connector refused — test_1854_agent_mcp_key.py", ("DELETE", "/api/mcp/keys/{key_id}"): "agent + connector refused — test_1854_agent_mcp_key.py", From d7cd0aad6272923db5a9bf8e3a7b790215d5de3b Mon Sep 17 00:00:00 2001 From: Andrii Pasternak <44377756+AndriiPasternak31@users.noreply.github.com> Date: Mon, 28 Sep 2026 13:13:36 +0100 Subject: [PATCH 21/23] fix(mcp-keys): listing keys takes the same session rule as creating one Switch GET /api/mcp/keys to Depends(require_interactive), like its POST siblings, and take it out of the route-census baseline (count 360) now that it is classified. Refs trinity-enterprise#712 --- docs/memory/feature-flows/mcp-api-keys.md | 9 ++++----- docs/memory/requirements/auth.md | 1 + src/backend/routers/mcp_keys.py | 7 +++---- tests/registry.json | 2 +- tests/unit/_route_census.py | 2 +- tests/unit/fixtures/human_only_route_baseline.json | 1 - tests/unit/test_2996_human_only_routes.py | 1 + tests/unit/test_mcp_key_listing_requires_session.py | 10 ++++++---- 8 files changed, 17 insertions(+), 16 deletions(-) diff --git a/docs/memory/feature-flows/mcp-api-keys.md b/docs/memory/feature-flows/mcp-api-keys.md index 32631a6ac9..a9b82d6db2 100644 --- a/docs/memory/feature-flows/mcp-api-keys.md +++ b/docs/memory/feature-flows/mcp-api-keys.md @@ -276,20 +276,19 @@ strict as the principal it produces. The UI (`McpKeysTab.vue`) and the CLI #### List API Keys (GET /api/mcp/keys) -Signed-in session only (trinity-enterprise#712): `reject_non_interactive_principal` -is the handler's first statement, so every MCP key — the person's own `user` key +Signed-in session only (trinity-enterprise#712): `Depends(require_interactive)`, +the same rule as creating a key, so every MCP key — the person's own `user` key included — gets a 403. The inventory has no machine consumer; the Settings → MCP Keys tab calls it with the JWT. A signed-in admin still gets every key with its owner's `user_email` (the tab renders it per row); nothing returns the secret. ```python -# mcp_keys.py:93-111 +# mcp_keys.py:97 @router.get("/keys", response_model=List[McpApiKey]) async def list_mcp_api_keys_endpoint( request: Request, - current_user: User = Depends(get_current_user) + current_user: User = Depends(require_interactive) # JWT session only ): - reject_non_interactive_principal(current_user) # JWT session only if current_user.role == "admin": keys = db.list_all_mcp_api_keys() # Admin sees all else: diff --git a/docs/memory/requirements/auth.md b/docs/memory/requirements/auth.md index 766db949f9..ff10ac0890 100644 --- a/docs/memory/requirements/auth.md +++ b/docs/memory/requirements/auth.md @@ -93,6 +93,7 @@ | `PUT /api/users/me/email` (ent#711) | INTERACTIVE | | `PUT` / `DELETE /api/users/me/github-pat` (a credential future agent creations inherit, ent#162) | INTERACTIVE | | `POST /api/mcp/keys` (every scope), `POST /api/mcp/keys/ensure-default` | INTERACTIVE (security.md §20.10) | + | `GET /api/mcp/keys` (trinity-enterprise#712 — the key inventory; no machine consumer) | INTERACTIVE | - **Refusal**: 403. PERSON routes return `HUMAN_ONLY_DETAIL` (`{"code": "person_required", ...}`, the same machine code as ent#611's ask endings, whose own detail is unchanged); INTERACTIVE routes return `reject_non_interactive_principal`'s existing detail. The refusal depends only on the principal, never on whether the addressed agent exists, so answering 403 before a path's 404 is not an enumeration oracle (#186). - **Still working**: the owner's `user` key and ops tooling on a `user` key keep the PERSON routes; the UI and CLI use JWTs everywhere; heartbeat, the result callback, reports and notifications do not use these gates; `trinity-system` keeps `require_admin` (#2323) and has no caller of the routes above. diff --git a/src/backend/routers/mcp_keys.py b/src/backend/routers/mcp_keys.py index 68595d33bc..587fef0fa1 100644 --- a/src/backend/routers/mcp_keys.py +++ b/src/backend/routers/mcp_keys.py @@ -97,14 +97,13 @@ async def create_mcp_api_key_endpoint( @router.get("/keys", response_model=List[McpApiKey]) async def list_mcp_api_keys_endpoint( request: Request, - current_user: User = Depends(get_current_user) + current_user: User = Depends(require_interactive) ): """List MCP API keys: the caller's own, or every key for an admin. - Signed-in session only: the inventory has no machine consumer, so every MCP - key (the person's own `user` key included) gets a 403. + Signed-in session only, like creating one: the inventory has no machine + consumer, so every MCP key (the person's own `user` key included) gets a 403. """ - reject_non_interactive_principal(current_user) try: if current_user.role == "admin": keys = db.list_all_mcp_api_keys() diff --git a/tests/registry.json b/tests/registry.json index c29269cb3c..bb813d5f2d 100644 --- a/tests/registry.json +++ b/tests/registry.json @@ -93,7 +93,7 @@ "security", "mcp-keys" ], - "description": "GET /api/mcp/keys requires a signed-in session (INTERACTIVE, reject_non_interactive_principal as the handler's first statement): agent/system/connector/user keys through the REAL get_current_user with seeded key rows, and ops/portal_delegate/unknown-future-scope/missing-mcp_scope via an explicit principal dataclass -> 403. Signed-in admin -> every key with user_email (never the secret); signed-in user -> own keys only. Every route in routers/mcp_keys.py read off the router object and listed with its rule; a single-key read stays owner-scoped; /validate needs no session." + "description": "GET /api/mcp/keys requires a signed-in session (INTERACTIVE, Depends(require_interactive)): agent/system/connector/user keys through the REAL get_current_user with seeded key rows, and connector/loopback-JWT/ops/portal_delegate/unknown-future-scope/missing-mcp_scope via an explicit principal dataclass -> 403. Signed-in admin -> every key with user_email (never the secret); signed-in user -> own keys only. Every route in routers/mcp_keys.py read off the router object and listed with its rule; a single-key read stays owner-scoped; /validate needs no session." }, { "file": "unit/test_ent600_telegram_group_context.py", diff --git a/tests/unit/_route_census.py b/tests/unit/_route_census.py index 543d31132d..76482ea260 100644 --- a/tests/unit/_route_census.py +++ b/tests/unit/_route_census.py @@ -623,7 +623,7 @@ def _resolve_endpoint( # The exact size of the frozen baseline. Lower it in the same change that # removes an entry; it never goes up. -FROZEN_BASELINE_COUNT = 361 +FROZEN_BASELINE_COUNT = 360 def load_baseline(path: Path = BASELINE_PATH) -> Dict[str, str]: diff --git a/tests/unit/fixtures/human_only_route_baseline.json b/tests/unit/fixtures/human_only_route_baseline.json index df91f6a5a1..8e370251f9 100644 --- a/tests/unit/fixtures/human_only_route_baseline.json +++ b/tests/unit/fixtures/human_only_route_baseline.json @@ -179,7 +179,6 @@ "routers/loops.py::stop_loop": "POST /api/loops/{loop_id}/stop", "routers/mcp_keys.py::delete_mcp_api_key_endpoint": "DELETE /api/mcp/keys/{key_id}", "routers/mcp_keys.py::get_mcp_api_key_endpoint": "GET /api/mcp/keys/{key_id}", - "routers/mcp_keys.py::list_mcp_api_keys_endpoint": "GET /api/mcp/keys", "routers/mcp_keys.py::revoke_mcp_api_key_endpoint": "POST /api/mcp/keys/{key_id}/revoke", "routers/messages.py::get_proactive_shares": "GET /api/agents/{agent_name}/shares/proactive", "routers/messages.py::send_proactive_message": "POST /api/agents/{agent_name}/messages", diff --git a/tests/unit/test_2996_human_only_routes.py b/tests/unit/test_2996_human_only_routes.py index f31f5a79d3..dd954a1cd6 100644 --- a/tests/unit/test_2996_human_only_routes.py +++ b/tests/unit/test_2996_human_only_routes.py @@ -174,6 +174,7 @@ class TestTheRoutesThisChangeGates: "routers/users.py::clear_my_github_pat", "routers/mcp_keys.py::create_mcp_api_key_endpoint", "routers/mcp_keys.py::ensure_default_mcp_api_key", + "routers/mcp_keys.py::list_mcp_api_keys_endpoint", ] @pytest.mark.parametrize("key", PERSON) diff --git a/tests/unit/test_mcp_key_listing_requires_session.py b/tests/unit/test_mcp_key_listing_requires_session.py index d676efd096..d72617c75c 100644 --- a/tests/unit/test_mcp_key_listing_requires_session.py +++ b/tests/unit/test_mcp_key_listing_requires_session.py @@ -2,8 +2,8 @@ `GET /api/mcp/keys` is the key inventory the Settings → MCP Keys tab renders. It has no machine consumer (the CLI and the MCP server never call it with a -key), so it takes the INTERACTIVE rule: `reject_non_interactive_principal` as -the handler's first statement — a JWT session only. Every MCP key, the person's +key), so it takes the INTERACTIVE rule, `Depends(require_interactive)` like its +POST siblings — a JWT session only. Every MCP key, the person's own `user` key included, gets a 403. A signed-in admin still sees the full listing with each key's `user_email` (the tab renders it on every row); a signed-in non-admin sees only their own keys. @@ -151,6 +151,8 @@ def _admin_principal(**fields): "connector-principal": lambda: _admin_principal( mcp_scope="connector", connector_agent=AGENT ), + # A JWT that the event loopback vouched for an agent is not a person's session. + "loopback-jwt": lambda: _admin_principal(mcp_scope=None, vouched_source_agent=AGENT), "ops-key": lambda: _admin_principal(mcp_scope="ops"), "portal-delegate-key": lambda: _admin_principal( mcp_scope="portal_delegate", portal_delegate=True @@ -199,9 +201,9 @@ def test_a_signed_in_user_sees_only_their_own_keys(self, world): # to the file fails here until it is listed with the rule it follows and a test # that pins that rule. MCP_KEY_ROUTES = { - ("POST", "/api/mcp/keys"): "create — not changed here", + ("POST", "/api/mcp/keys"): "interactive — test_mcp_key_creation_requires_session.py", ("GET", "/api/mcp/keys"): "interactive — this file", - ("POST", "/api/mcp/keys/ensure-default"): "create — not changed here", + ("POST", "/api/mcp/keys/ensure-default"): "interactive — test_mcp_key_creation_requires_session.py", ("GET", "/api/mcp/keys/{key_id}"): "owner-scoped read — this file", ("POST", "/api/mcp/keys/{key_id}/revoke"): "agent + connector refused — test_1854_agent_mcp_key.py", ("DELETE", "/api/mcp/keys/{key_id}"): "agent + connector refused — test_1854_agent_mcp_key.py", From 9dfb3d3ae76259d1121113c1285763fa78df95eb Mon Sep 17 00:00:00 2001 From: Andrii Pasternak <44377756+AndriiPasternak31@users.noreply.github.com> Date: Tue, 29 Sep 2026 18:06:48 +0100 Subject: [PATCH 22/23] test(auth): classify the skill-set and raise_my_ask routes in the census (#2996) Routes that landed on dev after the branch was cut were unclassified. Skill-set assign/unassign are the USE of the ent#596 skills-manage capability (fenced by get_skill_managed_agent_by_name; the grant is the admin+interactive /skill-manager route), the two skill-set reads are open reads, and raise_my_ask is MCP ask_operator, self-only via get_self_acting_agent. All five go in AGENT_CALLABLE with a reason. --- tests/unit/_route_census.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/unit/_route_census.py b/tests/unit/_route_census.py index 543d31132d..2be6a3c897 100644 --- a/tests/unit/_route_census.py +++ b/tests/unit/_route_census.py @@ -541,6 +541,21 @@ def _resolve_endpoint( "routers/agent_config.py::get_agent_guardrails": ("GET /api/agents/{agent_name}/guardrails", "a non-sensitive read of this agent's own setting (access-level)"), "routers/users.py::get_my_github_pat_status": ( "GET /api/users/me/github-pat", "configured flags only, never the token (ent#162)"), + # Skill sets (ent#530). The writes are the USE of the skills-manage capability, fenced like + # the single-skill writes by get_skill_managed_agent_by_name (capability, then owner); the + # GRANT is PUT /agents/{name}/skill-manager, admin + interactive (ent#596). Not a setting. + "routers/skills.py::list_skill_sets": ( + "GET /api/skills/library/sets", "library set metadata (names, members, schedules), open like the skills listing"), + "routers/skills.py::get_agent_skill_sets": ( + "GET /api/agents/{agent_name}/skill-sets", "a read of this agent's sets; ?probe= honoured only under the ent#596 fence (#3052)"), + "routers/skills.py::assign_skill_set": ( + "POST /api/agents/{agent_name}/skill-sets/{set_name}", "ent#596 skills-manage USE: capability holder + owner fence; grant is admin-interactive"), + "routers/skills.py::unassign_skill_set": ( + "DELETE /api/agents/{agent_name}/skill-sets/{set_name}", "ent#596 skills-manage USE: capability holder + owner fence; grant is admin-interactive"), + # The agent's own ask (ent#611): what MCP ask_operator calls; get_self_acting_agent admits + # only the agent's own key (or trinity-system as itself) and refuses every person. + "routers/operator_queue.py::raise_my_ask": ( + "POST /api/agents/{name}/operator-queue", "MCP ask_operator; the agent raises its own ask, self-only (get_self_acting_agent)"), } # Authenticates itself, or unauthenticated by design. key -> ("METHOD /full/path", reason) From b0e06eaeef760c7aa08e71f93864ac7c6cc0a586 Mon Sep 17 00:00:00 2001 From: Andrii Pasternak <44377756+AndriiPasternak31@users.noreply.github.com> Date: Tue, 29 Sep 2026 18:06:48 +0100 Subject: [PATCH 23/23] fix(schedules): say who can raise the agent timeout cap (#2996) An agent key can no longer PUT /api/agents/{name}/timeout, so the cap refusal on schedule and loop timeouts now says the owner sets it. --- src/backend/routers/loops.py | 3 ++- src/backend/routers/schedules.py | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/backend/routers/loops.py b/src/backend/routers/loops.py index 37cedcaae1..ce60a0b57e 100644 --- a/src/backend/routers/loops.py +++ b/src/backend/routers/loops.py @@ -158,7 +158,8 @@ def _reject_timeout_above_cap(agent_name: str, requested_seconds: Optional[int]) "message": ( f"Loop timeout_per_run {requested_seconds}s exceeds agent " f"execution_timeout_seconds {cap}s. Raise the agent cap " - f"first via PUT /api/agents/{agent_name}/timeout." + f"first: the agent's owner sets it via PUT /api/agents/{agent_name}/timeout " + "(an agent key cannot)." ), "agent_cap_seconds": cap, "requested_seconds": requested_seconds, diff --git a/src/backend/routers/schedules.py b/src/backend/routers/schedules.py index 6fea889353..fffb74c6ac 100644 --- a/src/backend/routers/schedules.py +++ b/src/backend/routers/schedules.py @@ -119,7 +119,8 @@ def _enforce_timeout_below_agent_cap(agent_name: str, requested_seconds: int) -> "message": ( f"Schedule timeout {requested_seconds}s exceeds agent " f"execution_timeout_seconds {cap}s. Raise the agent cap " - f"first via PUT /api/agents/{agent_name}/timeout." + f"first: the agent's owner sets it via PUT /api/agents/{agent_name}/timeout " + "(an agent key cannot)." ), "agent_cap_seconds": cap, "requested_seconds": requested_seconds,