Skip to content

feat(auth): ops_write — a bounded machine credential for four operational writes (#3180) - #4024

Open
AndriiPasternak31 wants to merge 32 commits into
devfrom
AndriiPasternak31/issue-3180-s1
Open

AndriiPasternak31 wants to merge 32 commits into
devfrom
AndriiPasternak31/issue-3180-s1

Conversation

@AndriiPasternak31

@AndriiPasternak31 AndriiPasternak31 commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

Part of #3180

This is slice S1 of two. Neither slice closes #3180: with both on dev, the issue's "optionally expiring" key is still missing, and that is #4094 (open). The audit slice, #4035, is where circuit-breaker reset and execution terminate write their audit rows, so it merges before this PR or in the same train; otherwise two of this key's four writes leave no trail. The two slices share only tests/registry.json.

Journey Impact: none: adds an admin-minted machine key scope that reaches four existing routes; no first-run, sign-in or end-user journey changes path

Gating: OSS-core, nothing behind the enterprise seam, by the ruling recorded on #2323 for the same problem: the credential is the edition-agnostic core primitive and the two-factor policy is the enterprise knob, the shape of users.suspended_at (#995) and the connector scope. The read tier (#2389) shipped that way on this same code, and #3180 was filed on the public tracker as its write tier. If any part is meant as a paid module, say so in review.

Browser sign-off (2026-10-30), done: a real Chrome, light and dark, against a local stack of this branch merged with dev 2d8b2c385. Settings → MCP Keys: the Ops (operational writes) choice in the create form and the Ops (writes) badge on two minted keys. An agent's Trinity access key panel, with its container's .mcp.json authenticating with an ops_write key: the verify verdict foreign_ops_write_key and its label, as a warning, with no "person's key" wording, also on re-verify.

What it does

  • A new key scope, ops_write, the write sibling of the read-only ops scope. It is admitted at the single auth entry point only when the (method, routed path) is one of four, the routed handler carries @ops_write_route("<why it is a use>"), and the key's owner is still an admin:
    POST /api/agents/{name}/circuit-breaker/reset, POST /api/agents/{name}/start, POST /api/agents/{name}/stop, POST /api/agents/{name}/executions/{id}/terminate. They are the measured, executed operational writes of the ops toolkit that are uses rather than grants.
  • Start and stop refuse the system agent and ephemeral agents for this scope (stop-then-start of the system agent is the human-only restart by another spelling; a drift recreate destroys a ghost's workspace), and fail closed when that lookup fails. Start also refuses any start that would mint the agent a key (below).
  • Reset opts the scope into its admin gate with require_admin_allowing("ops_write") (one ADMIN_WIDENED census row); role == "admin" still runs. ADMIN_GATE_SCOPES is unchanged and nothing says require_role("admin").
  • Dies with its owner on every route: demoted → 403, suspended → 401, a password change with "revoke keys" revokes it. Demotion suspends rather than revokes: re-promoting the owner restores the key, so revoke it to end it.
  • The read-only ops key now dies with its owner on every route too. The docs said demotion stops it, but the ops fence reads only the method and path, so the reads with no admin gate of their own (version, monitoring, telemetry, the agent roster, the execution reads, subscriptions: 10 of the 17 probed) kept answering a demoted owner's key. The resolver now checks the owner's role for ops ahead of the fence, as it does for ops_write (403 ops_owner_not_admin). This changes the shipped ops tier (feat: machine identities for admin/ops APIs — service credentials that survive enforced 2FA #2323): a key whose owner was already demoted stops working on upgrade, which is what its docs promise. A demoted owner could still read those same endpoints with their own session, so this was a docs-vs-behaviour gap, not an escalation. The upgrade note is docs/migrations/OPS_KEY_OWNER_ADMIN_2026-10.md: how to list the affected keys on SQLite and PostgreSQL, and the fix, which is to re-mint each one under a current admin and revoke the old one. The next release's upgrade notes link it.
  • A start never mints a key, so a person's containment (stop the agent, revoke or delete its key) cannot be undone by this scope. A start that would mint the agent a key (the feature: first-class agent-scoped MCP keys — scope visibility, regeneration, and validation/enforcement of agent-key usage from agent containers #1854 drift heal, reason: key_drift, on a stopped or running agent; the bug(recovery): soft-delete recovery dead-ends — recover tells you to start, but start 404s when the container is gone #1559 rebuild of a missing container, reason: container_missing) is refused with 409 ops_write_would_mint_key before any side effect, and the message says a person has to start the agent. A person's start is unchanged. A person's delete of the key cannot be told from the platform's own prune (both leave no row), so every minting start is refused, benign drift included. That costs availability only: a healthy agent's predicate matches, and a drifted agent needs one person's start.
  • Minting: admins, from a signed-in session only (the ops ladder). Settings → MCP Keys offers "Ops (operational writes)" and badges the key "Ops (writes)". No MCP tools, no event stream.
  • Docs: requirements/security.md §20.10, requirements/auth.md, architecture/security.md, the MCP keys feature flow and index, the user API authentication page, and the two user FAQ pages whose scope counts and lists this change made false (faq/mcp-and-api.md, faq/security.md). An operator note for the ops change on upgrade: docs/migrations/OPS_KEY_OWNER_ADMIN_2026-10.md. The audit-log mcp_scope filter's OpenAPI description lists the new value.
  • One existing test made order-independent (tests/unit/test_ent873_audit_client_ip.py, from dev). Its agent-refusal case overrode main.get_current_user. The unit conftest evicts dependencies after each test, so once this PR's suites have imported routers.agents before main, main holds a newer copy than the one the route was built with. The override was then never consulted, and the call answered 401 instead of 403. That made the case red whenever one of this PR's suites ran first on the same worker; it was green alone. It now takes the dependency from the route's own globals, and its assertion is unchanged.

Not delivered

  • Consumer writes that still need a person's session or a user-scoped admin key: fleet restart; retention acknowledge; schedule edit and toggle; schedule trigger; agent resources, capacity and timeout; every platform setting; subscriptions; user roles; the login whitelist; capability grants and skill managers; agent MCP-key regeneration; credential injection; creating and deleting agents; skill-library sources; chat and task dispatch; system-agent restart (and start/stop of the system agent); start/stop of ephemeral agents; emergency stop, fleet stop, schedule pause/resume; monitoring enable/disable; notification acknowledge/dismiss; health-check trigger. The ops toolkit's update and subscription flows therefore keep a user-scoped key.
  • Key expiry: MCP keys: optional expiry for the bounded machine scopes (ops, ops_write), the "optionally expiring" part of #3180 #4094.
  • Terminate's admission for the new scope relies on the route fence alone (it has no gate of its own that reads the scope), and the refusal of the owner-tier grant routes — key revoke/delete, credential inject/export/import, subscription assignment — to the new scope also relies on the route fence alone; key revoke/delete's own guards are a denylist the new scope passes. Measured: with the fence call removed, the key gets exactly what the owner's user key gets there. A second layer there, or a resolver that refuses any scope without a registered fence, is a follow-up: ops_write: a second layer on terminate and the owner-tier grant routes, beside the route fence #4095.
  • /api/mcp/validate reports the key as valid (introspection) even after its owner is demoted or suspended; it grants nothing.
  • Circuit-breaker reset and execution terminate write no audit row for any principal yet; the audit slice, feat(audit): record circuit-breaker resets and execution terminates in the audit log (#3180) #4035, adds them (start and stop rows already carry mcp_key_id and mcp_scope=ops_write).

Collisions

#3762 (controller scope, draft) and #3778 edit the same anchors (the resolver's fence block, _USER_CREATABLE_SCOPES, the mint scope check, McpKeysTab.vue, terminate_agent_execution, stop_agent_endpoint). Whichever lands second re-merges and re-runs both never-grantable sweeps: a sibling fence that returns early must not skip this one.

Dev's ent#875 viewer tier (#3777) is an allowlist that classified ops_write as "other", so terminate 404'd on any turn its owner had not started. Fixed in a4b07a715 by classifying ops_write as a key tier, pinned in test_ent875_viewer_tier.py.

The audit slice, #4035, shares one set of lines with this PR: tests/registry.json, where each slice prepends its own entry. Whichever merges second re-merges and keeps both entries. A local integration branch of both slices on origin/dev conflicted there and nowhere else, resolved by keeping both.

Validation: at 9d599ff, full backend unit tier as CI runs it (pytest unit/ -m "not slow" -n auto --dist loadfile -p randomly) on seeds 12345 / 67890 / 99999: 29,396 / 29,397 / 29,396 passed; every #3180 test green on all three. The reds are not this slice's: four test_ent679_payments_pin_parity cases (local venv package versions vs image pins; the same four red on pristine f754dfb) and test_2915 … test_mark_unconfirmed_sweeps_only_the_named_scope on two seeds (green alone, green alongside these suites in one process, on head and base; cause traced to xdist workers sharing one unit SQLite file, follow-up drafted). New suites: test_3180_ops_write_scope.py + test_3180_ops_write_route_census.py 68 passed (the census drives every live OSS route of the real main.app with real keys, 684 routes classified by credential dependency); the guard files (test_2323, test_293, the #2996 census and its runtime sibling, test_1310_auth_wiring/consolidation, test_3102, test_837 ×2, test_3825, test_163, test_mcp_key_creation_requires_session) 449 passed; frontend npm run test:unit 412 files / 6,446 tests incl. the raw-colour and loading-gate ratchets; MCP server 29 passed and tsc --noEmit clean; tests/lint_sys_modules.py no new violations; gitleaks over the branch: no leaks.

Re-run at e5c21b2 (after the fresh-review fixes): test_3180_ops_write_scope.py + test_3180_ops_write_route_census.py 68 passed; the audit-log suites 61 passed.

Then origin/dev 0f689db was merged into the branch, cleanly and as a merge rather than a rebase. Dev's enterprise submodule pointer bump (#3995) had made this branch's unchanged pointer read as a change. On the merged head 7ab23ea, 65 files ran under -n 4 --dist loadfile -p randomly on seeds 12345 and 67890: the two suites, the pinned guards, the terminate/reset neighbours, and dev's new sign-in and client-address files. Both seeds: 1,540 passed, 3 skipped. tests/lint_sys_modules.py: no new violations.

Batch: both slices of #3180 merged with --no-ff onto one integration branch off origin/dev f754dfb (the only conflict: tests/registry.json, both entries kept, 573 entries, no duplicate). Full backend unit tier (tests/unit -n 4 --dist loadfile -p randomly) on seeds 12345 / 67890 / 99999: 29,425 / 29,425 / 29,426 passed, 5 failed each; all 5 reds reproduce on pristine origin/dev with the same seed (four test_ent679_payments_pin_parity cases: local venv package versions vs the image pins; test_2850_skill_allowed_tools_forms::test_warning_truncates_the_offending_value: a log-line length that embeds the xdist tmp path), none introduced. tests/lint_sys_modules.py: no new violations. Frontend npm run test:unit: 412 files / 6,446 tests, ratchets included. MCP server: 1,002 passed, tsc --noEmit clean. The tier ran with this slice at 9d599ff; the four review-fix commits after it (docs, one OpenAPI string, two test files) re-merged cleanly and were re-run with both slices' suites, the guards and the terminate suites on the integration head: 1,158 passed, 2 skipped on seeds 12345 and 67890 (57 files under -n 4 --dist loadfile -p randomly; the three #3180 suites 38 / 30 / 34 and every pinned guard counted in the junit), tests/lint_sys_modules.py no new violations. The full tier, the frontend and the MCP suites were not re-run after the fixes: no frontend or TypeScript file changed, and the one behaviour change in the batch since the tier (the audit slice's container-arm CAS gate) is covered by the terminate suites of all three of its callers in that set. After dev moved to 0f689db, this branch's merged head was merged into the same integration branch, cleanly, giving dev plus both slices. The same targeted set plus the audit slice's suite (66 files) passed 1,574, with 3 skipped, on seeds 12345 and 67890. The three #3180 suites counted 38 / 30 / 34 in the junit and the ent#873 file 7, none failed.

Mutation: each gate removed in an isolated copy of HEAD, red-in-K/N of the named files, then the restored tree (byte-identical to HEAD) 198/198 green: fence call 15/68; path check 8/68; mark check 2/68; owner-admin check 4/68; reset's allow_scopes 3/157; the mark on reset 7/68, start 9/68, stop 12/68, terminate 7/68; target policy call 10/38, its fail-closed lookup 1/38, its is_system arm 2/38; _USER_CREATABLE_SCOPES entry 28/38; PERSON_KEY_SCOPES entry 2/38; mint rungs in isolation — in-handler interactive 1/38, agent 1/38, admin 3/38, router scope admission 30/38 (the route-level require_interactive on the mint route predates this slice and is pinned statically by test_2996_human_only_routes; a behavioural mutation of it goes red here only through the test's dependency override, so it is not counted); verify verdict 1/38 (re-run at e5c21b2 through the probe path: 1/38); fence reading request.url.path 3/62; the ADMIN_WIDENED row 2/89; an overbroad path entry 7/30; pull-seam key checks 1/30 and 1/30. UI: badge, radio and scope string each 1/3 red, restored 3/3. MCP: ops_write added to OPERATOR_SCOPES 2 red, restored 29/29.

Since the draft. origin/dev b900357 was merged (890d8bdb4; conflicts in dependencies.py, routers/agents.py, routers/chat.py and tests/registry.json, both sides kept; the ent#875 semantic conflict fixed in a4b07a715), the containment fix landed (d845cc57f docs, cba065883 fix, 45bd068cb test), and dev 2d8b2c385 was merged (0618ea368; only tests/registry.json, both sides kept). Then the ops owner check and the user-doc gaps (bf13b18e7): TestTheReadTierDiesWithItsOwnerToo drives the real resolver over a probe app that mounts every path the ops fence admits; before the change a demoted owner's key got 200 there, after it 403 on every one, and re-promotion restores it. The new refusal was red before the change: 7 failed, each a 200 with a re-minted key. Per-arm mutants, each alone: container_missing arm removed → 1 failed; key_drift arm removed → 6 failed; route always allow_key_mint=True → 7 failed; always False → 2 failed (a person is never refused); restored → 48/48. At 45bd068cb, the full unit tier (seed 12345): 30625 passed, 1 failed, a load-induced timeout in test_1898_sys_modules_isolation that passes alone in 3.46 s and touches no changed code. At 0618ea368, the 90 files that reach the changed code (the three #3180 suites, every start_agent_internal caller, the pinned auth guards, ent875, ent926, the terminate/reset neighbours; seed 12345): 2165 passed, 4 skipped, 0 failed; frontend mcpKeysOpsWriteScope + both ratchets: 23 passed. At bf13b18e7, the same set widened to every file naming the ops scope (115 files, seed 67890): 3533 passed, 4 skipped, 0 failed. Then dolho's review: the closing keyword moved to #4035 (L1), and the ops upgrade note landed in b07fa2917 (L2). Dev 0b11bd501 is merged (8ae68ae50; only tests/registry.json conflicted, both sides kept, 619 entries, no duplicate). On that head, the two #3180 suites, the files that read the route census (_route_census.py gained dev's retrieval-memory rows), the files that cover dev's routers/chat.py change (#3998, ent875), test_3102 and the registry's one reader (test_2338_journey_catalog), 10 files under -n 3 --dist loadfile: 526 passed, 0 failed (scope 51, census 30).

Fresh review: an independent reviewer (Claude Opus 5.5, extra-high effort, briefed with the slice's goal, the plan's proof list and the bar only) re-ran the gate mutations at 9d599ff and every one went red (fence call 15/68, path check 8/68, owner check 4/68, target policy 10/38, _USER_CREATABLE_SCOPES 28/38, among them). The code and tests held. Its verdict was REVISE for docs: one blocking and two should-fix findings, plus four nits. All seven are closed. The blocking one: the requirements doc said every audit row the key causes carries its id, but the start-time self-heal row is written as system; this is fixed in 4a11d88. The containment advice could not be followed, since an admin reaches every agent; it now says delete, revoke or demote, also in 4a11d88. The FAQ scope counts and lists are fixed in 891b3d3. Of the nits, the OpenAPI filter text is fixed in b1efd08. A /ws probe that could not fail is dropped, and the verdict test now goes through the probe path; both are in e5c21b2. The mint mutation that went red only through a test override is no longer counted (above).

Unverified: enterprise routes and any enterprise-only scope (submodule not mounted: the never-grantable proof is OSS-only); PostgreSQL (the suites ran on SQLite only, and they are not requires_postgres, so CI's PostgreSQL tier does not run them either); the four handlers against a real container (Docker faked; on start the config predicates other than the MCP-key one were pinned to 'matches'); the pinned FastAPI 0.115.6 / Starlette 0.41.3 for scope["route"] in the real app (the unit tier ran 0.115.14 / 0.46.2; the plan's probe covered the pins on a scratch app); the real MCP server's authenticate → /api/mcp/validate → session-scope path end to end (the refused-call proof boots a real FastMCP server with the real predicates); the consumer's measured write counts (regex counts over deploy records, 3–4 per route read by hand); whether any automation revokes keys or injects credentials with a system key; whether #3762 or #3778 merges first; whether any part is meant as a paid module (inferred from the #2323 ruling and the public filing, not asked separately); the inline sign-in paths' MFA/suspension gap (out of scope); whether a started agent's enabled schedules then fire on their own; the dev-tooling test catalog entry (lives in another repository).

)

A bounded write tier beside the read-only `ops` scope. An `ops_write` key is
admitted at the single auth entry point only when three predicates hold:
(method, routed path) full-matches `_OPS_WRITE_ALLOWED_ROUTES` (circuit-breaker
reset, agent start, agent stop, execution terminate); the routed handler carries
`@ops_write_route("<reason>")`, read from `scope["route"].endpoint`; and the key
owner's row is still `role == "admin"`, so the key dies with its owner on every
route, including the ones with no admin gate.

`enforce_agent_spawn_scope` gains the target policy for this scope: start and
stop refuse the system agent (by name or `is_system`) and ephemeral agents, and
fail closed when that lookup raises or cannot be proven.

`ADMIN_GATE_SCOPES` is unchanged; nothing is opted in by this commit, so the
scope reaches no route yet.
Marks circuit-breaker reset, agent start, agent stop and execution terminate
with `@ops_write_route`, below each route decorator. Reset's admin gate becomes
`require_admin_allowing("ops_write")`: the same gate with one bounded scope
admitted, `role == "admin"` still enforced afterwards. The now-unused
`require_admin` import in routers/agents.py is dropped.

Guard change, and why: the #2996 route census requires every widened admin
gate to have a reviewed `ADMIN_WIDENED` entry, so reset gains one row in
tests/unit/_route_census.py. The frozen baseline is not edited and its count
is unchanged; start, stop and terminate stay where they were.
…rite (#3180)

`POST /api/mcp/keys` accepts `scope: "ops_write"` behind the same ladder as
`ops`: route-level `require_interactive`, then
`reject_non_interactive_principal`, `reject_agent_principal` and `assert_admin`.
The DB layer adds it to `_USER_CREATABLE_SCOPES`; the row is written with
`agent_name` NULL as for every creatable scope.

`PERSON_KEY_SCOPES` gains the scope, so a password change with "revoke keys"
revokes an ops_write key after a suspected leak; a test pins it equal to the
mintable set. An ops_write key found in an agent's `.mcp.json` reports its own
verify verdict, `foreign_ops_write_key`, instead of "a personal key".
Admins get an "Ops (operational writes)" scope choice beside "Ops (read-only)",
whose hint names the four actions, the system/ephemeral refusal, the missing
reads and the owner binding. A minted ops_write key is badged "Ops (writes)"
so it never reads as a Standard key, and the agent key panel labels the
`foreign_ops_write_key` verdict. Semantic tokens only.

tests/unit/mcpKeysOpsWriteScope.mount.spec.js mounts the tab: the badge, the
radio beside the read-only one, the exact `scope: "ops_write"` it sends, and
no bounded scope offered to a non-admin.
`OPERATOR_SCOPES` stays {user, agent, system} and its pin is untouched. The
auth-context scope union gains "ops_write" (it is kept exhaustive against the
column), and the call-time gate suite boots a real FastMCP server for an
`ops_write` and an `ops` session: nothing is advertised, and calling the
operator tool by name is a not-found that never runs its body.
Real keys minted through POST /api/mcp/keys on the real schema, the real
resolver and routers; only Docker and Redis are faked, each named where it is
installed. Pins the four pass paths (start through the real
start_agent_internal, including the #1854 self-heal of a revoked baked key:
nothing reaches the caller and its reach is unchanged), start/stop audit
attribution, the target policy, demotion and suspension on all four routes,
password-change revoke and its parity with the mintable set, the mint matrix
(composed, and each redundant rung in isolation), the ops key refused on the
four writes, A2A answering 403 with no dispatch and no anonymous fallback,
enumeration safety, the verify verdict, and a crafted Host (#3102) through
the routers with the desync asserted.
)

The marked set is a frozen literal; marks are unaliased, carry a reason and
sit below their route decorator; nothing else references the mark or its
attribute; every marked handler authenticates through get_current_user; every
path entry resolves to exactly one live, marked route. The fence/gate
agreement pair from test_2323 is mirrored for reset.

_ops_write_sweep.py imports the real main.app in a subprocess and drives every
OSS route with real keys, classified by what its credential dependency does:
unlisted routes refuse, the four do not; with the path list holed the mark
still refuses every unmarked handler; with every handler marked the path list
still refuses; a demoted owner's key is refused and a suspended owner's key is
401 everywhere; the ops key reaches no write; the named grant families refuse,
and with the fence call removed each shows its own gate or, for the owner-tier
routes, that the fence is their only layer. Routes reading no Trinity
credential are listed by name. OSS routes only.
)

Guard change, and why: test_3102 drives every scope fence in the resolver with
a real Starlette request whose Host desyncs `url.path` from the routed path.
A new fence must join that list or the guard silently stops covering the
resolver. The new case passes the real marked start handler as the route, so
only the path decides, and the section heading now counts six fences.
requirements/security.md §20.10 gains the ops_write block (shape, the four
measured routes, the three admission predicates, the start/stop target policy,
start's self-heal as a use, the owner binding, the mint ladder, password-change
revoke, the allowlists that exclude it, attribution) and its "not delivered"
line now says the write tier covers four routes only and which refusals rely on
the route fence alone. The PERSON table, the architecture scope table and the
routed-path fence list name the scope; the MCP keys flow gains MCP-008 and the
write path; the user docs gain the scope row, the owner-binding paragraph, the
admin-gate and mint lines, the /ws/events row and the POST /api/mcp/keys row.
The target policy fails closed when the system/ephemeral lookup raises, and
the caller sees only the protected-target refusal. Log the cause, so a DB
fault that refuses a durable agent is diagnosable instead of silent.
Under the full unit tier a sibling test leaves CREDENTIAL_ENCRYPTION_KEY in
os.environ, so the subprocess inherited it, the admin's `user` key registered a
subscription, and the later "assign" family found it — a comparison against the
fence-removed run that depended on test order. The subprocess now gets only
PATH/HOME/TMPDIR/LANG, and the `user`-key run covers only the fence-only
families, so no gated family can change what a later one finds.
assert_admin runs the same agent rejection, so the isolated test for the
in-handler reject_agent_principal stayed green with that rung deleted.
Neutralize assert_admin too, so only the rung under test can refuse.
…are (#3180)

Two proofs in the ops_write sweep could not fail (independent review):

- the pull seams refuse a non-pilot agent before they read the key, so the
  ops_write 403 there said nothing about the key check. The sweep now makes the
  seeded agent a pilot and drives every self-validating door with the agent's
  own key too; that key must get past the check (200, or 404 for the missing
  execution), so the ops_write 403 is the key check's answer;
- the get_optional_user door was probed with a token matching no share, where
  every principal gets the same 404. The sweep now seeds a live `authorized`
  canvas share: the owner's key and session get the canvas, anonymous and an
  unknown key are asked to sign in, and with the fence call removed the
  ops_write key would get the canvas.
… says valid (#3180)

Introspection validates the key itself and checks neither the owner's role nor
suspension, so the universal "refused on every route" wording was not true of
it. Qualify it, and say plainly that a valid answer there is no evidence the
owner binding failed.
… demote (#3180)

Two security statements were false. §20.10 said every audit row an ops_write
key causes carries its mcp_key_id, but the #1854 self-heal a start may run
writes its agent_key_self_heal row as `system`, without the key, so an
operator scoping a leaked key by mcp_key_id would miss the one credential
mint and delete the key caused. And both §20.10 and the API authentication
page advised containing an agent by taking it "out of the key owner's
reach", which cannot be done: the fence admits the key only while its owner
is an admin, and an admin reaches every agent. The remedies are deleting
the agent, revoking the ops_write keys, or demoting their owner.
The FAQ said admins see two bounded scope choices and that every key
carries one of six scopes, listed the bounded scopes and the scope badges
without ops_write, and called portal_delegate "the other bounded scope".
With ops_write there are three admin choices, seven scopes and an
"Ops (writes)" badge.
…#3180)

The OpenAPI description of GET /api/audit-log's mcp_scope filter
enumerated the credential kinds without the new scope.
…gh the probe (#3180)

The sweep opened /ws?token=<key>, but /ws reads only `ticket`, so it
closed 4001 for any input and the assertion could not go red. The key's
door to /ws is POST /api/ws/ticket, which the families sweep already shows
refused; the socket test now asserts /ws/events only and says why.

The verify-verdict test called _entry_verdict on a row it had read itself.
It now goes through interpret_probe_payload with the stored key_hash as
the container's digest, so the hash lookup is in the path (verdict branch
removed: 1/38 red). A list-membership test named as if it checked a
refusal is renamed; the refusal is the next test's.
…ency (#3180)

test_an_authorization_refusal_records_the_client overrode
main.get_current_user. The unit conftest evicts `dependencies` after
every test, so when a test file has imported a router before `main` is
first imported (the ops_write suites import routers.agents at module
level), `main` binds a newer copy of get_current_user than the one the
POST /api/agents route was built with. The override was then never
consulted and the request answered 401 instead of 403: red whenever
one of those suites ran first on the same xdist worker, green alone.

Take the dependency from the route's own globals instead, through the
route_globals helper the file already uses for the sign-in tests. The
assertion is unchanged.
Comment thread tests/unit/test_3180_ops_write_scope.py Dismissed
@AndriiPasternak31 AndriiPasternak31 added the ui PR touches the frontend UI — triggers Playwright e2e tests label Oct 28, 2026

@webmixgamer webmixgamer 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.

Review via /review (pre-landing structural review) on the draft head 7ab23eac0.

/review Report

Branch: AndriiPasternak31/issue-3180-s1 → dev (merge-base 0f689db80)
Files Changed: 29 (+2190/−38): backend 10, frontend 3, MCP server 2, tests 7, docs 8
Scope: CLEAN. Every hunk serves the ops_write scope, apart from one test made order-independent (test_ent873_audit_client_ip.py, explained in the body).
Plan Completion (#3180 proposal): 5 done, 1 changed, 1 partial, 1 not done

  • DONE: an explicit fail-closed allowlist beside _OPS_ALLOWED_ROUTES (dependencies.py _OPS_WRITE_ALLOWED_ROUTES); never-grantable families refused (the fence + the 684-route live census); minted only from an interactive admin session (mcp_keys.py: require_interactive → reject_non_interactive_principal → reject_agent_principal → assert_admin); dies with its owner (_enforce_ops_write_key_fence owner check, suspension in the resolver, PERSON_KEY_SCOPES); no MCP tools (OPERATOR_SCOPES unchanged, pinned by tool-visibility.test.ts).
  • CHANGED: candidate routes cut to the four measured ones. Restart/recreate, fleet restart, emergency stop, cleanup sweeps, retention acknowledge and schedule toggle are listed under "Not delivered".
  • PARTIAL: attribution. Start and stop audit rows carry mcp_key_id / mcp_scope; reset and terminate rows land in #4035.
  • NOT DONE: "optionally expiring". Scope cut, low impact, and the follow-up is not filed yet (I2).

Execution coverage (Step 2.5)

The PR's own mutation list covers every gate: the fence call, path, mark, owner check, reset opt-in, the four marks, the target policy and its fail-closed arms, the mint rungs, _USER_CREATABLE_SCOPES / PERSON_KEY_SCOPES, the verify verdict, the UI and OPERATOR_SCOPES. I added three it does not list:

changed symbol / wiring executed by live consumer verdict
WS_EVENT_STREAM_SCOPES keeps ops_write out (N1: added it) test_3180_ops_write_route_census::test_the_event_stream_socket_refuses_the_key (red: 1) main.py /ws/events ✅ executed
_refuse_ops_write_protected_target name arm (N2: dropped target_agent == SYSTEM_AGENT_NAME) — (green) start/stop ✅ equivalent mutant: db.is_system_agent already returns True on the name (db/agents.py:696)
missing is_ephemeral key fails closed (N3: info.get(...)) TestTargetPolicy::test_an_unprovable_ghost_lookup_refuses[sqlite-no-flag] (red: 1) start/stop ✅ executed

No changed test asserts source text. The census drives the real main.app with real keys.

Critical Findings (block merge)

None found.

Informational Findings (review required)

[I1] Design / Product Quality: an ops_write start can undo a person's containment by re-minting the agent's key (Confidence: 7/10)
Files: services/agent_service/lifecycle.py (start path), docs/memory/requirements/security.md (§ops_write)
Evidence:

  • mcp_key_match = check_agent_mcp_key_matches(container, agent_name) … if not mcp_key_match: … mcp_env_overrides = await heal_agent_mcp_key_env(agent_name)
  • The PR's own requirement text: "Consequence: an agent stopped and key-revoked for containment can be started again by this key … to contain an agent, delete it, revoke the ops_write keys, or demote their owner."

Issue: the scope exists to be bounded, yet its one documented failure mode is the one that matters most: a person stops an agent and revokes its key, and an automation whose job is "start agents that are down" brings it back with a freshly minted credential. Every remedy offered is fleet-wide (revoke every ops_write key, demote the owner) or destructive (delete the agent). Minting a credential is the grant side of Invariant #8's grant-vs-use line, even when the platform mints it for the agent rather than for the caller.
Suggestion: for mcp_scope == "ops_write", refuse a start whose MCP-key predicate is unsatisfied with a named 409 (e.g. ops_write_would_mint_key, "a person must start this agent"). On a healthy agent the predicate matches, so automation loses nothing, and the containment path stays a person's decision. If that is declined, the requirement should say why that trade was made.

[I2] Process: the follow-ups the body says are "to be filed" are not filed (Confidence: 9/10)
Searching both trackers for 3180 returns only #3180 itself. Missing:

  • Key expiry: the issue's "optionally expiring".
  • The ops docs-vs-code claim: architecture/security.md's ops row says "demotion 403s it", while the body reports that a demoted-but-operator owner's ops key still reads the fence-only reads (from reading the resolver; I did not run it either). This is a security doc an operator would act on, so it is the most important of the four.
  • The second layer for terminate and for key revoke/delete (today only the fence holds them).
  • The incomplete user docs (FAQ admin-gate, event-stream and /ws answers, the mint row).

Suggestion: file them before leaving draft and link them here. Alternatively, fold the ops doc correction in, since it is diagnosed and doc-only.

Low confidence (appendix)

  • (5/10) "Dies with its owner" reads as permanent, but demotion only suspends the key: it stays is_active, and re-promoting the owner makes it work again. If that is intended, one sentence in §ops_write would stop an operator from treating demotion as revocation.

Clean Categories

  • 4.1 SQL: no new SQL. _USER_CREATABLE_SCOPES is a tuple check before the insert.
  • 4.2 Concurrency: the fence is pure (method, routed path, mark, an owner row already loaded); no shared state.
  • 4.3 Auth:
    • The fence runs inside resolve_platform_user_unfloored, so it covers get_current_user and the three Workspace doors.
    • Refusals precede the uniform-404 agent lookup and do not depend on agent existence (#186).
    • Reset keeps require_admin_allowing("ops_write"); nothing writes require_role("admin").
    • The side doors are allowlists that exclude the scope: /ws/events (WS_EVENT_STREAM_SCOPES, pinned by N1), heartbeat / #1083 callback / pull seams (authorize_heartbeat needs agent_name to match, and it is NULL on these keys), and MCP tools (OPERATOR_SCOPES).
    • Paths read request.scope["path"], never request.url (#3102; the fence is added to test_3102).
  • 4.4 Credentials: start returns credentials_result with file names and a count only (inject_assigned_credentials → {"credential_count", "files": list(files.keys())}), never values.
  • 4.5 Enterprise docs: the docs describe the scope and the OSS-core ruling only; no private module named.
  • 4.10 Frontend: the badge uses semantic status-warning-* tokens; the radio sits inside v-if="isAdmin"; the raw-color and loading-gate ratchets are green.
  • 4.12 Enum completeness: foreign_ops_write_key is handled in _entry_verdict, _verdict_message, AgentMcpKeyPanel.vue VERIFY_LABELS, and both model comments. The scope is in the types.ts union, the audit-log filter text, PERSON_KEY_SCOPES and _USER_CREATABLE_SCOPES (the last two pinned equal).

Tests run (head 7ab23eac0)

  • Backend, in a clean venv from tests/requirements-test.txt + CI's plugins (fastapi 0.115.14 / starlette 0.46.2, matching CI rather than the image pins). The 3 PR suites, test_3102, test_ent873 and 26 pinned auth guards (2323, 2389, 293, 2996 + runtime census, 1310 ×2, 837, 3825, 163, mcp-key-session, 186, 2094, ent629, 1890, 995, 1854): 947 passed on seeds 12345 and 67890 (-n auto --dist loadfile -p randomly).
  • Frontend: mcpKeysOpsWriteScope.mount.spec.js + the raw-color and loading-gate ratchets: 23 passed.
  • MCP server: tool-visibility.test.ts 27/29 on my host. The 2 failures are the new ops / ops_write cases, which fail only because my host has fastmcp 4.16.13 against the lockfile's 4.22.4 (listTools → -32601). CI's MCP Server Tests job passes on this PR. Not attributed.
  • Not run by me: PostgreSQL, the real container, and a real browser (still the draft gate).

Summary

  • Critical: none found
  • Informational: 2 (I1 is a design decision for the author; I2 is filing work before leaving draft)
  • Scope: clean

@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.

@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.

AndriiPasternak31 and others added 7 commits October 28, 2026 22:10
Brings the branch onto origin/dev b900357. Conflicts resolved by keeping
both sides:

- dependencies.py: the ops_write fence call stays where it was, ahead of
  dev's new `principal = User(...)` + `record_acting_turn` (ent#926).
- routers/agents.py: the dependencies import keeps ops_write_route and
  require_admin_allowing (require_admin is no longer used here) and adds
  dev's assert_not_share_viewer.
- routers/chat.py: the dependencies import keeps ops_write_route and adds
  dev's row_is_viewers and viewer_row_scope.
- tests/registry.json: both sides' new entries kept (596 entries, no
  duplicate file, every entry equal to its source side).

Dev's ent#875 viewer tier classifies ops_write as "other", which narrows
its terminate to rows its owner started; that semantic conflict is fixed
in the next commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HWna79vVmnezQdhpaGRKbw
Dev's ent#875 viewer tier is an allowlist: a scope it does not name is
"other" and is narrowed to the rows its principal started. ops_write
was not named, so after the dev merge its terminate refused every
execution its admin owner had not started (404 "Execution not found in
agent"), which is the stuck-turn case the scope exists for.

ops_write now classifies as "key", like ops. Its fence admits one
execution route (terminate), so nothing else widens. The ent#875
classification, tier and row-scope tests pin it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HWna79vVmnezQdhpaGRKbw
…ment (#3180)

Review finding I1 on #4024: the requirement said an agent stopped and
key-revoked for containment could be started again by an ops_write key,
because the start's #1854 self-heal re-mints the agent's key. The
decision (2026-10-28) reverses that: a start that would mint the agent a
key is refused for this scope with 409 ops_write_would_mint_key, the
message saying a person has to start the agent. A person's start is
unchanged.

The refusal does not try to tell a person's revoke from benign drift. A
revoke leaves an inactive row, but a person's delete leaves no row,
which is also what the platform's own prune leaves. Treating "no row" as
benign would leave delete as a containment a machine could undo. So
every minting start is refused: the drift heal (reason key_drift) and
the #1559 missing-container rebuild (reason container_missing). It costs
availability only: on a healthy agent the predicate matches, and a
drifted agent needs one person's start, after which the key starts it
again.

Updated where the consequence was documented: requirements/security.md
(including the attribution bullet, since the key no longer causes an
agent_key_self_heal row), the MCP keys feature flow, the user API
authentication page and the architecture/security.md scope row.

Appendix finding: demotion suspends the key rather than revoking it.
Re-promoting the owner makes it work again. Both security.md and the
user page now say so and say to revoke it to end it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HWna79vVmnezQdhpaGRKbw
Review finding I1 on #4024. When a person had revoked the agent's
platform key to contain it, a start made with an ops_write key ran the
#1854 self-heal and minted a fresh key, undoing the containment.

start_agent_internal takes allow_key_mint (default True, today's
behaviour). With False it raises 409 {code: ops_write_would_mint_key,
reason, message} before any side effect, so nothing is minted, pruned,
recreated, started or cleared. That happens in two places:
- reason key_drift: check_agent_mcp_key_matches is unsatisfied, which is
  exactly when the heal mints. That covers a revoked, deleted, missing or
  foreign baked key, on a stopped or a running agent.
- reason container_missing: the #1559 rebuild, which also mints.

The start route passes allow_key_mint=False for an ops_write principal.
A person's start is unchanged.

Tests: TestContainment drives the real resolver, route and
start_agent_internal with a key revoked, deleted, missing, URL-less or
foreign, and with no container. Each answers 409 and leaves the key
rows, container, breakers and audit log as they were. The same contained
agent, and a missing container, started by a person still heal and
rebuild. The old test that pinned the heal under ops_write is replaced:
a healthy agent's ops_write start keeps the pass path and the key
attribution. Without the change, the 7 refusal cases fail (200 with a
re-minted key).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HWna79vVmnezQdhpaGRKbw
… it (#3180)

Appendix finding on #4024: "dies with its owner" read as permanent. This
pins what the docs now say. A demoted owner's key is refused (403
ops_write_owner_not_admin) while its row stays active, and promoting the
owner back makes it work again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HWna79vVmnezQdhpaGRKbw
tests/registry.json: both sides add entries; kept both, this slice's three
first, dev's side unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HWna79vVmnezQdhpaGRKbw
…r docs name ops_write (#3180)

The docs say demoting an ops key's owner stops the key, but only the reads
behind an admin gate did: the `ops` fence reads the method and path only, so
the reads it admits with no gate of their own (version, monitoring, telemetry,
the agent roster, the execution reads, subscriptions; 10 of 17 probed) kept
answering a demoted owner's key. The resolver now checks the owner's role for
`ops` ahead of the fence, as it does for `ops_write` (403
`ops_owner_not_admin`). Not an escalation (a demoted owner could read the same
endpoints with their own session), but the documented control did not hold.
This changes the shipped `ops` tier (#2323): a key whose owner was already
demoted stops working on upgrade, as its docs promise.

`TestTheReadTierDiesWithItsOwnerToo` drives every path the fence admits
through the real resolver on a probe app: 200 with an admin owner, 403 after
demotion (red before the change), restored on re-promotion.

User docs: `ops_write` is named in the FAQ's `/ws/events` and admin-gate
answers and gets a row in the roles-and-permissions mint table (review I2).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HWna79vVmnezQdhpaGRKbw
@AndriiPasternak31

Copy link
Copy Markdown
Contributor Author

Thanks for the review, @webmixgamer. Here is what changed for each point. The PR is out of draft now.

I1 (an ops_write start can undo containment by re-minting the key). Fixed as you suggested, with one widening.

An ops_write start now never mints the agent a key. start_agent_internal takes allow_key_mint, and the start route passes False for this scope. A start that would mint answers 409 {"code": "ops_write_would_mint_key", "reason": "key_drift" | "container_missing", "message": "… a person has to start this agent."} before any side effect: nothing is minted, pruned, recreated or started, and no breaker is cleared.

A person's start is unchanged.

It does not try to tell a person's action from benign drift. A revoke is identifiable (an inactive row matching the container's key), but a person's DELETE of the key leaves no row, the same state as the platform's own prune. Healing "no row" would make delete a containment a machine can undo, so every minting start is refused. That costs availability only: on a healthy agent the predicate matches and the automation loses nothing, and a drifted agent needs one person's start, after which the key can start it again.

Tests (TestContainment, real resolver, route and start_agent_internal): revoked (stopped and running), deleted, no key, no URL, foreign key and missing container each give 409 with nothing changed. They were red without the change: 7 failed, each a 200 with a re-minted key. A person's start of the same contained agent still heals, and a missing container still rebuilds. A mutant of each arm and of the route wiring goes red. The requirement text, feature flow, user API page and architecture row now say this in place of the old "can be started again by this key". Commits: d845cc57f (docs) and cba065883 (fix).

Separately, merging dev surfaced an ent#875 interaction. The viewer tier is an allowlist, and ops_write fell into "other", so its terminate 404'd on any turn its owner had not started. ops_write is now classified as "key", like ops (a4b07a715), pinned in test_ent875_viewer_tier.py.

I2 (follow-ups not filed). Two are fixed here and two are filed.

Appendix (5/10, "dies with its owner" reads as permanent). Fixed in the docs. It is intended, and now it is said. §ops_write and the user authentication page state that demotion suspends the key (the row stays active, and re-promoting the owner restores it) and that you revoke it to end it. test_demotion_suspends_the_key_and_re_promotion_restores_it pins the behaviour (45bd068cb, d845cc57f).

Draft gate: the browser sign-off is done. A real Chrome, light and dark, against a local stack of this branch merged with current dev:

  • Settings → MCP Keys: the Ops (operational writes) choice and the Ops (writes) badge.
  • An agent's Trinity access key panel, with the container's .mcp.json authenticating with an ops_write key: verdict foreign_ops_write_key, shown as a warning, no "person's key" wording.

Dev 2d8b2c385 is merged (0618ea368; only tests/registry.json conflicted, both sides kept). On the final head bf13b18e7, the 115 unit files that reach the changed code (the three #3180 suites, every start_agent_internal caller, the pinned auth guards, ent875, ent926, the terminate/reset neighbours and every file that names the ops scope) gave 3533 passed, 4 skipped and 0 failed. The frontend spec plus both ratchets gave 23 passed. CI runs the full suite on this push. The rest of the evidence is in the body under "Since the draft".

@webmixgamer @dolho could you take another look? The I1 refusal is the behaviour change to check.

@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.

Reviewed at bf13b18e7.

I found nothing blocking. An ops_write key gets past the single auth entry point only when three things hold: the (method, routed path) is one of four, the routed handler carries @ops_write_route, and the key's owner is still an admin. Both places where a start mints the agent a key are now refused for this scope. Minting the key needs an interactive admin session. Agent, connector, portal_delegate and ops principals cannot mint one, because the route fence refuses them and the mint handler's own rungs refuse them again. Every gate I mutated turned at least one test red. Everything webmixgamer raised is either fixed in the code or filed as a follow-up. The CodeQL alert was dismissed correctly. The open items are known and tracked: reset and terminate write no audit row yet (#4035), terminate and the owner-tier grant routes have no second layer behind the fence (#4095), and keys cannot expire yet (#4094).

Earlier review (webmixgamer, 2026-10-28, at 7ab23eac0)

  • I1: an ops_write start could undo containment by minting the agent a new key. Fixed, and the fix is wider than the suggestion. start_agent_internal now takes allow_key_mint, and the route passes False for this scope (routers/agents.py:903: allow_key_mint=getattr(current_user, "mcp_scope", None) != OPS_WRITE_SCOPE,). A start refuses with 409 ops_write_would_mint_key before any side effect, on both paths that mint a key. One is the #1559 missing-container rebuild (lifecycle.py:414: _refuse_key_mint("container_missing")). The other is the #1854 drift heal (lifecycle.py:433: _refuse_key_mint("key_drift")). I checked for other mint sites: db.create_agent_mcp_api_key appears in the lifecycle module only inside recreate_missing_container (lifecycle.py:1782), and heal_agent_mcp_key_env only behind if not mcp_key_match. Both are now gated. Mutation M4 below turns 7 tests red.
  • I2: follow-ups not filed. Addressed.
    • The ops demotion docs-vs-code gap is fixed in code. dependencies.py:941 adds _enforce_ops_key_owner(user), which runs ahead of the ops fence. Mutation M3 turns 3 tests red.
    • The user-doc gaps (FAQ, /ws/events row, mint row) are fixed.
    • Key expiry is filed as #4094 (open).
    • The second layer is filed as #4095 (open).
  • Appendix (demotion suspends the key, it does not revoke it). The docs now say so. test_demotion_suspends_the_key_and_re_promotion_restores_it pins the behaviour.
  • CodeQL alert 460 (py/weak-sensitive-data-hashing, tests/unit/test_3180_ops_write_scope.py:435) is dismissed as a false positive, and I agree. The line is digest = hashlib.sha256(fresh.encode()).hexdigest(). It hashes a freshly minted MCP key to find its mcp_api_keys row, the same lookup production uses. It is not a password.

What I ran

All runs used a detached worktree at bf13b18e7, with tests/.venv and -p no:randomly. The merge-base is 2d8b2c385. git merge-tree against the current origin/dev (6c7818cb8) merges cleanly. The PR has no schema change, so neither migration track nor the Alembic head is involved.

  • unit/test_3180_ops_write_scope.py + unit/test_3180_ops_write_route_census.py: 81 passed (serial, 11 min).
  • Auth guards: test_293_admin_gate_rejects_agent_keys, test_1310_auth_consolidation, test_1310_auth_wiring, test_2323_machine_identities, test_2996_human_only_routes, test_2996_route_census_runtime, test_3102_host_header_fences, test_ent875_viewer_tier, test_186_enumeration_uniformity, test_mcp_key_creation_requires_session, test_ent873_audit_client_ip, test_2094_dependency_path_param_pairing: 407 passed (-n 8 --dist loadfile).
  • Frontend: mcpKeysOpsWriteScope.mount.spec.js with the raw-color and loading-gate ratchets: 23 passed. The spec mounts the component under jsdom. Its toContain assertions read rendered text, not source.
  • MCP server: src/tool-visibility.test.ts: 29 passed. This includes the new cases checking that an ops_write session and an ops session can neither list nor call an operator tool.

Mutations. Each was applied alone in a second worktree, which was restored after every run and is clean at the end.

# Mutation Suite Red
M1 drop the _enforce_ops_write_key_fence(request, user) call scope 6/51
M2 drop the owner role != "admin" check in that fence scope 3/51
M3 drop _enforce_ops_key_owner(user) (the new ops owner check) scope 3/51
M4 start always passes allow_key_mint=True scope 7/51
M5 reset back to require_admin_allowing() (no ops_write opt-in) scope 3/51
M6 drop the target-policy call in enforce_agent_spawn_scope scope 10/51
M7 viewer_principal_kind without OPS_WRITE_SCOPE (my addition) ent875 + scope 5/110
M8 the mark check in the fence never refuses scope 0/51; census + 3102 2/54

M8 survives the scope suite, which is expected: the census catches it, through test_with_the_path_list_holed_every_unmarked_handler_still_refuses and test_nothing_marks_a_handler_any_other_way.

Execution coverage

changed symbol executed by live consumer verdict
_enforce_ops_write_key_fence scope + census (real main.app, real keys) dependencies.py:946, in the resolver executed
_enforce_ops_key_owner TestTheReadTierDiesWithItsOwnerToo dependencies.py:941 executed
_refuse_ops_write_protected_target TestTargetPolicy dependencies.py:1781 (start/stop) executed
allow_key_mint / _refuse_key_mint TestContainment routers/agents.py:903 executed
require_admin_allowing("ops_write") on reset TestPassPaths::test_reset routers/agents.py:1151 executed
viewer_principal_kind (ops_write counted as a key) ent875 + terminate pass path routers/chat.py terminate executed
census inspect.getsource over the admitted handlers n/a the route table guard (paired with the live sweep)

Checks against the auth invariants

  • Reach beyond the four writes. The path list (_OPS_WRITE_ALLOWED_ROUTES) is anchored and fullmatched against request.scope["path"]. The mark is read from scope["route"].endpoint; FastAPI's APIRoute.matches sets child_scope["route"] = self. The doors that check a key themselves, not through the resolver, are allowlists that leave this scope out:
    • /ws/events: WS_EVENT_STREAM_SCOPES = frozenset({None, "user", "agent", "system"}).
    • heartbeat, the #1083 result callback and the pull seams: authorize_heartbeat requires a matching agent_name.
    • MCP tools: OPERATOR_SCOPES is unchanged.
  • Admin gates. ADMIN_GATE_SCOPES is unchanged. Reset is the only opt-in, and assert_admin still checks current_user.role != "admin". The diff adds no require_role("admin").
  • Mint is grant-side and human-only. POST /api/mcp/keys is Depends(require_interactive). For ops/ops_write the handler then calls reject_non_interactive_principal, reject_agent_principal and assert_admin. _USER_CREATABLE_SCOPES and PERSON_KEY_SCOPES both gain ops_write, and a test pins the two sets equal. Revoke and delete of a key are held only by the fence today; that is #4095.
  • Idempotency (Invariant #18). Not applicable: none of the four routes creates an execution.
  • # mcp: headers. No new router. The changed routers keep their existing headers.

Findings

Blocking

None.

Medium

None.

Low

L1. Two of the four writes are unattributed until #4035 lands (Confidence: 9/10)
In routers/agents.py, start (:912, event_action="start") and stop (:1007, event_action="stop") write audit rows. reset_circuit_breaker_endpoint and terminate_agent_execution write none, for any principal. #3180 asks for writes "Attributed in audit_log". So if this PR merges first and the Fixes #3180 keyword is left in place, the issue closes at the next release cut with two of the key's four writes leaving no mcp_key_id trail. The body already commits to moving the closing keyword. The fix is to do that before merge: either land #4035 first, or switch this PR's first line to Related to #3180 and let #4035 carry Fixes.

L2. The ops owner check changes a shipped tier and needs a release note (Confidence: 7/10)
dependencies.py:941, _enforce_ops_key_owner(user). After upgrade, an existing ops key whose owner is no longer an admin starts returning 403 ops_owner_not_admin on the 10 reads that the fence alone used to answer. That is the documented behaviour and not an escalation. But an operator whose monitoring runs on such a key loses it silently at upgrade, unless the release notes say so. Name it in the next release's notes, and say what to do: re-mint the key under a current admin.

AndriiPasternak31 and others added 2 commits October 31, 2026 12:06
tests/registry.json: both sides add entries; kept both, this slice's three
first, dev's side unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HWna79vVmnezQdhpaGRKbw
…in (#3180)

The ops owner check in the resolver changes the shipped ops tier (#2323):
after upgrade, an active ops key whose owner is no longer an admin gets
403 ops_owner_not_admin on the reads the route fence alone used to answer.
Operator note in docs/migrations, the form the release notes' upgrade
section links at the cut: what changes, how to list the affected keys on
SQLite and PostgreSQL, and the fix (re-mint under a current admin, then
revoke the old key).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HWna79vVmnezQdhpaGRKbw
@AndriiPasternak31

Copy link
Copy Markdown
Contributor Author

Thanks for the review, @dolho. Both Low items are addressed. Here is what changed for each.

L1 (two of the four writes are unattributed until #4035 lands). Fixed: no slice closes #3180 now, and the merge order is stated.

This PR's first line is now Part of #3180, and so is #4035's. I didn't move Fixes to #4035 either: with both slices on dev, #3180's "optionally expiring" key is still missing (#4094), so the issue stays open for that. Reset and terminate write their audit rows in #4035, so both bodies now say #4035 merges before this PR or in the same train. The two slices share only tests/registry.json, and the second to merge keeps both entries. No code changed for this.

L2 (the ops owner check changes a shipped tier and needs a release note). Added here, in the form the repo stages upgrade notes before a cut.

Release notes are written at the cut, and nothing in docs/releases/ holds notes for an unreleased version. What a PR does stage is an operator note in docs/migrations/, which the cut's "Behavior changes / upgrade notes" section then links: 0.9.5 linked the restart-policy, secret-settings and token-scrub notes, and 1.0.0 linked the Workspace-only role note. So this PR adds docs/migrations/OPS_KEY_OWNER_ADMIN_2026-10.md (b07fa2917). It says:

  • What changes: an active ops key whose owner is no longer an admin gets 403 ops_owner_not_admin on every route after upgrade, including the reads the route fence alone used to answer (version, monitoring, telemetry, the agent roster, the execution reads, subscriptions). It closes a gap between the docs and the behaviour, not an escalation.
  • Who is affected: one query, for SQLite and for PostgreSQL, lists the active ops keys whose owner is not an admin. No rows means nothing changes.
  • What to do: re-mint the key under a current admin (Settings → MCP Keys → Ops (read-only), from a signed-in session), put it in the consumer, and revoke the old one by id.
  • Re-promoting the owner restores the key, and rolling back the release restores the old behaviour. Nothing in the data changes.

The link from the release notes is still the cut's job. The bullet I would add under "Behaviour that changes by default" in the next docs/releases/<version>.md:

The query was checked against a scratch SQLite with the real column names. The PostgreSQL spelling is the same SQL, and is_active is an integer on both tracks. It has not been run against a live PostgreSQL.

Dev 0b11bd501 is merged (8ae68ae50; only tests/registry.json conflicted, both sides kept, 619 entries, no duplicate). On that head I ran 10 files under -n 3 --dist loadfile: the two #3180 suites, the files that read the route census, which gained dev's retrieval-memory rows (test_2996_route_census_runtime, test_2996_human_only_routes, test_1310_auth_wiring), the files covering dev's routers/chat.py change (test_3998_chat_setup_releases_admission, both ent875 files), test_3102 and the registry's one reader (test_2338_journey_catalog). Result: 526 passed, 0 failed, with scope 51 and census 30. CI runs the full suite on this push.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ui PR touches the frontend UI — triggers Playwright e2e tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants