Skip to content

fix(auth): agent-config and self-service identity routes are human-only, with a route census (#2996) - #3044

Merged
vybe merged 22 commits into
devfrom
AndriiPasternak31/issue-2996
Sep 29, 2026
Merged

vybe merged 22 commits into
devfrom
AndriiPasternak31/issue-2996

Conversation

@AndriiPasternak31

Copy link
Copy Markdown
Contributor

Fixes #2996
Refs trinity-enterprise#711

Summary

Owner-tier grant routes and self-service identity routes are now human-only, and a route census makes every new route pick a side.

  • PERSON (Depends(require_person): a JWT or the person's own user key): PUT /api/agents/{name}/autonomy and the other agent-config writes (api-key-setting, read-only, resources, capabilities, capacity, timeout, public-channel-model, guardrails).
  • INTERACTIVE (Depends(require_interactive): JWT only): PUT /api/users/me/email, and PUT / DELETE /api/users/me/github-pat.
  • The refusal depends only on the principal, never on whether the agent exists (Invariant security: implement safe tar extraction with symlink/hardlink validation #8 / SEC: User and Agent Enumeration via differential API responses #186).
  • Route census: tests/unit/test_2996_human_only_routes.py. Every OSS HTTP route, every method, resolves to one policy class, or sits in a shrink-only baseline (tests/unit/fixtures/human_only_route_baseline.json, exact count). test_2996_route_census_runtime.py checks it against the real app.

Why per route and not inside the owner gates: unlike the admin tier (#1890), agents legitimately use owner-gated routes (the MCP schedule tools, file writes). test_2996_agent_schedule_tools_unchanged.py pins that an agent key still manages its own schedules.

Merge order: M → A → B

This PR includes PR M's commits (the first 5) because it is stacked on it. Merge M first; I'll rebase this PR onto dev afterwards so only its own commits remain. PR B (ent#712) follows this one and updates the census baseline. See the PR comment for why these are three PRs.

Journey Impact: none: the UI and CLI call these routes with a JWT, and agent-used routes are unchanged

Type of Change

  • Bug fix

Testing

  • 7 new test files, 243 tests. With the neighbouring auth guards (test_1310_*, test_293_*, test_186_*, test_1854_*) the run is green on 3 seeds.
  • Mutation, red-in-N/243 with each gate removed: autonomy 13, all agent-config writes 85, email 10, PAT clear 10, the loopback refusal 5–10, an unclassified new route 1, system added to the person scopes 11.
  • Full tests/unit shows the same 50 failures on this branch as on dev, with none unique to the branch.
  • /review and /cso --diff both ran; their findings are fixed or filed privately.
  • The guard runs in backend-unit-test.yml (every PR to dev/main, not label-gated). None of the new tests is marked slow.

After merge

trinity-enterprise#711 is cross-repo, so close it by hand at the release cut.

🤖 Generated with Claude Code

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.
…person, require_interactive

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.
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.
…t 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.
…he route census

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
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
…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
…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
…2996)

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.
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.
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
…tes 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.
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.
… 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.
@AndriiPasternak31

Copy link
Copy Markdown
Contributor Author

Why three PRs, and the merge order: #3043 → #3044 → #3045

Why not one PR: #3045 is blocked on the operator step with no date, and #3043/#3044 are P1 fixes that #2984's autonomy "hard off" depends on. Squash-merge each; don't merge a later one first.

@AndriiPasternak31
AndriiPasternak31 marked this pull request as ready for review September 28, 2026 12:35

@dolho dolho left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Approving. I reviewed only this PR's own commits (pr-3043...pr-3044). The primitives it builds on are covered in the #3043 review.

Verification. The 5 new test files: 194 passed. The runtime census takes about 10s and can't skip.

What I checked

  • All 9 agent-config writes now use Depends(require_person). PUT /me/email and PUT/DELETE /me/github-pat now use require_interactive.
  • set_autonomy_status_logic is the only caller of db.set_autonomy_enabled, so there's no side door to toggle autonomy.
  • No gated route is called by the MCP server, the agent base image, the scheduler, the CLI or the trinity-system template. The UI calls them with a JWT. The canary-fleet runbook's capacity/timeout calls still work with a JWT or user key.
  • These routes are pinned as agent_callable: heartbeat, result callback, reports, notifications, and schedule create/enable/disable.
  • The 403 depends only on who the caller is, so it's enumeration-safe (Invariant #8 / #186).
  • The tests are strong: real keys resolved against the stored value, self-tests that plant broken source files to show each ratchet rule catches them, a check of the live route table, and a pin that an agent key can still manage its own schedules.

Findings (minor or nits)

  1. Minor. These routes are grants of the same kind this PR fences, but they stay agent-callable in the baseline:

    • routers/git.py:652 (PUT /api/agents/{name}/github-pat)
    • routers/agents.py:1095 (circuit-breaker)
    • routers/agents.py:1247 (mcp-exposed)

    The per-agent PAT bind is the sharpest. It is the per-agent version of /users/me/github-pat, which this PR makes interactive-only, and it has no reject_agent_principal at all. Suggest it goes first in the follow-up.

  2. Minor: tests/unit/_route_census.py:626 and tests/unit/fixtures/README.md. The exact-count, shrink-only baseline has no path for a pure rename or move, such as an Invariant #1 package split. The only way out is --freeze, which quietly re-baselines everything. Please document a rename rule (the same METHOD /path with a new key allowed in the same diff), or assert that a re-freeze never adds a METHOD /path that wasn't already in the baseline.

  3. Nit: _route_census.py:~507-612. Every new route now edits one shared table, so expect merge conflicts. Make the ratchet's failure message name the exact table to add the route to.

  4. Nit. Handlers already guarded by the imperative reject_agent_principal (for example agent_files.py::set_agent_permissions and connector.py::regenerate_connector_key) still count as unclassified_at_freeze. That's correct, since it's a denylist, but the fixtures README should say so, or readers may assume those routes are open.

@vybe

vybe commented Sep 29, 2026

Copy link
Copy Markdown
Contributor

merge-train (non-blocking, for a follow-up): routers/schedules.py:122 and routers/loops.py:161 still tell the caller to "Raise the agent cap first via PUT /api/agents/{name}/timeout" — an agent key can no longer do that after this PR, so for agent callers the advice should become "ask the agent's owner". Validated READY; rides train #3070 after #3043.

@vybe

vybe commented Sep 29, 2026

Copy link
Copy Markdown
Contributor

merge-train: ejected from train #3070 — rides the next train once fixed.

tests/unit/test_2996_human_only_routes.py::TestRouteCensus::test_rule1_every_route_is_classified fails on this PR merged with current dev (reproduced locally on origin/dev + this head alone). Four routes landed on dev after this branch was cut and are unclassified:

  • routers/skills.py::list_skill_sets (GET, :221)
  • routers/skills.py::get_agent_skill_sets (GET, :699)
  • routers/skills.py::assign_skill_set (POST, :716)
  • routers/skills.py::unassign_skill_set (DELETE, :749)

Also heads-up: #3028 (native asks) is riding this train and adds routers/operator_queue.py::raise_my_ask (POST) — once it lands, the census will need that one classified too (agent-callable by design; it's what MCP ask_operator calls).

Classifying these (gate vs AGENT_CALLABLE / OWN_AUTH / DELEGATED with a reason) is a grant-vs-use judgement, so it's left to you rather than done mechanically. The code itself validated READY; merge order after #3043 still stands.

@github-actions

Copy link
Copy Markdown

⚠️ Nightly unit-suite check skipped — merge conflict against dev.

Resolve by running git merge dev locally and pushing the result. The next nightly run will re-test once the conflict is gone.

@github-actions

Copy link
Copy Markdown

⚠️ Live-instance suite skipped — merge conflict against dev.

Resolve by merging dev locally and pushing the result; the next nightly re-tests.

AndriiPasternak31 and others added 3 commits September 29, 2026 18:06
…sus (#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.
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.
@vybe

vybe commented Sep 29, 2026

Copy link
Copy Markdown
Contributor

merge-train: merged origin/dev into this branch (a03f31fb4, mechanical). The only conflict was tests/registry.json, rebuilt from the git stages: dev's entries plus this PR's five test_2996_* entries, nothing else changed. The route census and the four #2996 suites pass on the merged tree (191 passed). The approval (06:25Z) predates the 17:04–17:06 pushes; those only classify the new routes and reword the timeout-cap error, so the train proceeds on it. The PR body's "merge M first" note is now moot — #3043 is on dev.

@vybe vybe left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

merge-train: batch validated on train/20260929-1807 (#3093)

@vybe
vybe merged commit 0afabfe into dev Sep 29, 2026
24 of 25 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants