Skip to content

feat(auth): bound, attribute and fail-closed the machine credential for admin/ops APIs (#2323) - #2389

Merged
obasilakis merged 7 commits into
devfrom
feature/2323-machine-identities
Aug 26, 2026
Merged

obasilakis merged 7 commits into
devfrom
feature/2323-machine-identities

Conversation

@obasilakis

Copy link
Copy Markdown
Contributor

The issue's premise is false, and the correction inverts its proposed shape

#2323 asks for a service credential that survives enforced 2FA, on the premise that none exists. One does — verified against a live instance with a scope='user' MCP key owned by admin:

200  GET /api/version           200  GET /api/users
200  GET /api/ops/fleet/status  200  GET /api/settings/retention
200  GET /api/ops/costs         200  GET /api/audit-log/stats
200  GET /api/ops/auth-report   200  GET /api/canary/status
200  GET /api/admin/soft-deleted/agents      200  GET /api/subscriptions
PUT /api/settings/max-parallel-tasks-ceiling → 200   (writes too)
Issue claim Verified on dev
No API-key path to admin/ops endpoints False — require_admin/assert_admin reject only agent and connector; a user-scoped key inherits the owner's role
MCP keys deliberately don't cover admin endpoints False — true of the MCP tool surface, not the backend bearer surface
GET /api/version is admin-bearer False — plain get_current_user
2FA exemption is work to do Already true — gate_login has 2 call sites, both login routes
Rotation needs building Already works — overlapping keys both 200
Audited distinctly Missing — the one real ask

The credential exists; it is unbounded, unattributable and non-expiring. So the only way to enable 2FA and keep a dashboard alive was to hand it a permanent, invisible, unlimited admin key — worse than the control it works around. The issue's "acceptable smaller shape" (a tier that unlocks admin) buys nothing, because user already does. The tier needed bounds it.

Full premise-correction and consumer evidence: comment, corrections.

Changes

1 — the admin gate becomes an allowlist. ent#293/#297 closed it against two scopes by name. scope is free-text with no CHECK constraint, so that is a denylist over an open space: anything setting neither agent_name nor connector_agent inherits the owner's role across ~163 sites. models.User's docstring predicted it; this PR's ops scope is that sixth scope. ADMIN_GATE_SCOPES = {None, "user", "system"} is exactly what passes today — zero behaviour change except portal_delegate, previously contained only by its route fence, which gains a second layer. An absent mcp_scope fails closed via a sentinel, never a None default that would make the missing attribute the privileged JWT value.

2 — attribution from the credential, not a header. validate_mcp_api_key always returned the key id/name; get_current_user discarded them. Deriving the three mcp_* audit columns from actor_user fixes ~70 call sites with no diff at any, plus mcp_key_id/mcp_scope query filters. actor_type stays user — the owner is accountable, is the only branch yielding an email, and the enterprise user-activity view matches on it.

X-MCP-Key-Id is removed from the audit path, not out-ranked: Header(None) on six routes, validated nowhere, persisted into the backlog replay blob — so honouring it let any authenticated caller forge the credential named in the platform's two highest-volume audit events, surfacing minutes later on queue drain.

3 — ops: read-only, route-fenced, self-authorizing. Admin-minted and human-only. Fenced at the single auth entry point. Every entry is a GET, asserted by importing the constant — without that belt a prefix entry admits a future POST /api/ops/*, and that is where emergency-stop lives. Kept out of the admin allowlist; ops reads opt in with allow_scopes={"ops"}, so authority comes from being an ops key rather than from the owner's role — it survives that admin being offboarded, and a new ops route is inaccessible until granted rather than silently reachable.

The fence set is the measured read set of Trinity Control, not the issue's wording (which named only /api/ops/* and would have shipped a credential unable to run the dashboard it exists for).

⚠️ This carries a security fix

routers/a2a.py::_a2a_idem_scope builds an idempotency scope from mcp_key_id and fell through to username because the field did not exist. Two agent-scoped keys of one owner therefore shared a peer-controlled messageId namespace — caller B received caller A's full response text and B's task never ran. Its own docstring describes the failure it was not preventing. Reachable on an entitled install with ≥2 a2a_exposed agents under one owner (default OFF, OSS unaffected).

The test asserting the distinction used a stub carrying a field the real principal lacked — green over a property production never had. Re-pointed at a real User.

Deploy note: the scope string moves from a2a:{agent}:{username} to a2a:{agent}:{key_id}, so a messageId replayed across the deploy re-executes instead of replaying. Bounded by the 24h TTL. Worth a release note.

Test plan

  • pytest tests/unit/test_2323_machine_identities.py — 41 passed, 0 skipped
  • pytest tests/unit/test_293_admin_gate_rejects_agent_keys.py — 25 passed
  • Full unit suite: 12313 passed, 21 failed — byte-identical to the failure set on an unmodified worktree at this branch point (IPv6/SSRF, environment-dependent). Zero regressions, confirmed by a full baseline run, not by inspection.
  • Mutation-tested three ways, each detected: add a write to the fence → method belt red; drop a route the consumer needs → admit-set red; widen the admin allowlist → closed-literal + future-scope red.
  • Live probe against a running instance for every premise above.

The route-parity guard initially skipped (importing the app needs the full stack). A guard that skips is not a guard, so it resolves against the router modules instead. It then caught two bugs in this PR's own code on its first real execution: a doubled router prefix, and a gate using direct attribute access where a principal might not carry the field.

Not delivered — stated, not implied

  • Key expiry. mcp_api_keys still has no expires_at. A leaked ops key is bounded in reach, not in time.
  • user stays unbounded. This adds a tier; narrowing the existing one would break the fleet.
  • No write-capable ops tier. The ops toolkit's 24 writes stay on password auth — the read fence does not retire the admin password.
  • Two costs inherited, not introduced: key validation writes per request, and fleet-status fans out per agent. The Observatory already polls exactly these endpoints on an admin JWT at the same cadence.
  • Honest bound: this narrows the API surface only. Ops tooling that mutates containers over SSH never touches the API; SSH remains the real privilege boundary there.

Note on 44 test doubles

38 principal stand-ins lacked mcp_scope; 6 stubs replaced the admin gate with a narrower signature. Every one claimed to stand for the real principal while not matching it — the same defect that hid both live bugs above. That is why the gate fails closed rather than defaulting, and the stand-ins were fixed rather than the gate softened.

Fixes #2323

Generated with Claude Code

…or admin/ops APIs (#2323)

The issue asks for a service credential that survives enforced 2FA, on the
premise that none exists. One does. Verified against a live instance: a
`scope='user'` MCP key owned by an admin returns 200 on every admin/ops surface
the issue lists, writes included; `mfa_gate.gate_login` has exactly two call
sites, both login routes, so key validation has never passed through it; keys
are revocable and rotate by minting a second one.

What that credential is NOT is bounded, attributable, or expiring. It carries the
owner's full role, `mcp_api_keys` has no expiry, and its use recorded
byte-identically to the owner clicking in a browser. So the only way to enable
2FA and keep a dashboard alive was to hand it a permanent, invisible, unlimited
admin key — a worse posture than the control it works around. The issue's
proposed shape (a tier that *unlocks* admin) is therefore inverted: `user`
already does that. The tier needed is one that *bounds* it.

Three changes, smallest blast radius first.

1. The admin gate becomes an ALLOWLIST.

ent#293/#297 closed it against `agent` and `connector` by NAME. `scope` is
free-text with no CHECK constraint, so that is a denylist over an open space: 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`'s own
docstring predicted it — "fail-closed against a sixth scope a future PR invents".
This PR's `ops` scope is that sixth.

`ADMIN_GATE_SCOPES = {None, "user", "system"}` is exactly the set that passes
today, so this is zero behaviour change with one exception: `portal_delegate`
passed both named guards and was contained *only* by its route fence, and now has
a second layer. A principal lacking `mcp_scope` fails CLOSED via a sentinel —
never `getattr(..., None)`, which would make an absent authorization
discriminator the privileged JWT value.

2. Attribution comes from the credential, not from a header.

`validate_mcp_api_key` has always returned `key_id`/`key_name`;
`get_current_user` discarded them. Carrying them on `User` and deriving the three
`mcp_*` audit columns from `actor_user` fixes ~70 call sites with no diff at any
of them, and adds `mcp_key_id`/`mcp_scope` filters so "what did that leaked key
touch?" is answerable. `actor_type` stays `user`: the owner is accountable, is
the only branch yielding an email, and the enterprise user-activity view matches
on it.

The `X-MCP-Key-Id` header is removed from the audit path rather than out-ranked.
It is `Header(None)` on six routes, validated nowhere, and persisted into the
backlog replay blob — so honouring it let any authenticated caller forge the
credential named in the two highest-volume audit events on the platform, with the
forgery surfacing minutes later on queue drain.

This half is a SECURITY FIX. `routers/a2a.py::_a2a_idem_scope` builds an
idempotency scope from `mcp_key_id` and fell through to `username` because the
field did not exist, so two agent-scoped keys of one owner shared a
peer-controlled `messageId` namespace: caller B received caller A's full response
text and B's task never ran. Its own docstring describes the failure it was not
preventing. Reachable on an entitled install with >=2 `a2a_exposed` agents under
one owner. The test asserting the distinction used a stub carrying a field the
real principal lacked — green over a property production never had — and is now
pointed at a real `User`.

DEPLOY NOTE: the scope string moves from `a2a:{agent}:{username}` to
`a2a:{agent}:{key_id}`, so a `messageId` replayed across the deploy re-executes
instead of replaying. Bounded by the 24h TTL.

3. The `ops` scope: read-only, route-fenced, self-authorizing.

Admin-minted and human-only (`reject_non_interactive_principal` — the guards used
for `portal_delegate` are both no-ops for an ops principal, so an ops key could
otherwise mint ops keys). Fenced at the single auth entry point beside the
connector/ephemeral/portal_delegate fences. Every entry is a GET, asserted by a
test that imports the constant: without that belt a prefix entry would admit a
future `POST /api/ops/*`, and that is where `emergency-stop` lives.

Kept OUT of `ADMIN_GATE_SCOPES`; ops reads opt in with
`assert_admin(..., allow_scopes={"ops"})`. Authority therefore comes from being
an ops key rather than from the owner's role — so it keeps working when that
admin is offboarded, and a new ops route is inaccessible until granted rather
than silently reachable.

The fence set is the MEASURED read set of the real consumer (Trinity Control),
not the issue's wording, which named only `/api/ops/*` and would have shipped a
credential unable to run the dashboard it exists for. Never carries an
`agent_name`: three sweeps find their work by filtering `scope IN
('agent','connector')`, so a non-agent scope holding one is invisible to all
three. Excluded from the MCP tool surface by construction — `OPERATOR_SCOPES` is
an allowlist pinned by its own test.

Guards, mutation-tested three ways (add a write to the fence, drop a route the
consumer needs, widen the admin allowlist — each detected):
- fence is all-GET and fully anchored, asserted by importing the constant, never
  by grep, which would pass on the prose;
- route parity resolved against live route objects, spanning both declaration
  families, plus the inverse direction (every non-allowlisted ops route denied),
  which is the half that catches a future destructive route;
- admin gate rejects `ops`, `portal_delegate` and an invented `scope_from_2027`,
  and still admits the three that passed before.

44 test doubles updated: 38 principal stand-ins that did not carry `mcp_scope`
and 6 stubs replacing the admin gate with a narrower signature. Every one claimed
to stand for the real principal while not matching it — the same defect that hid
both live bugs above, which is why the gate fails closed rather than defaulting.

NOT delivered, stated rather than implied: key expiry; narrowing the existing
`user` scope (would break the fleet); a write-capable ops tier — the ops
toolkit's 24 writes stay on password auth, so the read fence does not retire the
admin password. Two costs are inherited, not introduced: key validation writes
per request, and fleet-status fans out per agent. The Observatory already polls
exactly these endpoints on an admin JWT at the same cadence.

Honest bound: this 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.

Full unit suite: 12313 passed, 21 failed — byte-identical to the failure set on
an unmodified worktree at this branch point. Zero regressions.

Fixes #2323
@obasilakis
obasilakis changed the base branch from main to dev August 24, 2026 14:54
…d accent-blue

`accent-*` is a closed set of one (`accent-purple`); `check:tokens` caught the
invention in CI. An informational badge is what `status-info` is for, so the
right fix is the semantic token rather than widening the palette for one badge.
@dolho

dolho commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Code review — feature/2323-machine-identities

Reviewed the bounded ops MCP-key scope, the admin-gate allowlist, and the credential-derived audit attribution. Six findings; verified-clean list at the end.

High

1. src/backend/routers/subscriptions.py:166 — admitted by the fence, rejected by the gate.
GET /api/subscriptions/{id}/usage is in _OPS_ALLOWED_ROUTES and is asserted by test_ops_fence_admits_the_measured_consumer_read_set, but the handler still calls bare assert_admin(current_user) with no allow_scopes={"ops"}. ADMIN_GATE_SCOPES = {None, "user", "system"} excludes "ops", so _reject_scope_at_admin_gate raises 403.

Concretely: an ops key passes the route fence for /api/subscriptions/sub-1/usage and is then rejected at the admin gate — the "subscription pressure" read the fence was explicitly measured for cannot work. The admit-set test gives false assurance because it exercises only _enforce_ops_key_fence, never the endpoint's own gate. Every other admitted path is either get_current_user-only or got the opt-in; this is the one gap.

Fix: assert_admin(current_user, allow_scopes={"ops"}) there, and extend the test to drive the endpoint gate rather than just the fence.

Medium

2. src/backend/main.py:1284 — /ws/events bypasses the fence.
The handler calls db.validate_mcp_api_key(token) directly and never runs _enforce_ops_key_fence, so the fence's own docstring claim ("enforced HERE at the single auth entry point") is not true. An ops key can open wss://.../ws/events?token=trinity_mcp_* and receive the fleet-wide agent_activity / execution event stream, scoped by the owner's accessible agents (everything, for an admin owner) — a surface outside the declared read allowlist. The same hole already exists for connector and portal_delegate (pre-existing), but the fence is this PR's value proposition, so it's worth either closing or documenting explicitly.

3. src/backend/services/chat_execution_service.py:176 (also :1113, :1464) — forgeable header still reaches durable storage.
The PR removes X-MCP-Key-Id from the audit path but leaves source_mcp_key_id=x_mcp_key_id / source_mcp_key_name=x_mcp_key_name feeding db.create_task_execution, and backlog_service.py:109 still persists both into the replay blob.

Concretely: any authenticated caller sends X-MCP-Key-Id: <someone-else's-key> on POST /api/agents/{n}/chat; the audit row now correctly names the presented bearer while schedule_executions.source_mcp_key_id durably records the forged value. Two provenance surfaces for one request disagree, and the threat model this PR states ("validated nowhere") applies unchanged to the column that survives longest.

Low

4. src/backend/db/mcp_keys.py:139 — the _AGENTLESS_SCOPES guard can never fire.
It reads getattr(key_data, "agent_name", None), but McpApiKeyCreate (db_models.py:87) declares only name / description / scope, and Pydantic silently drops unknown fields. A caller POSTing {"scope":"ops","agent_name":"atlas"} never reaches the guard. test_ops_keys_must_never_carry_an_agent_name only asserts membership in the constant, not the behaviour — so it documents a protection that does not exist.

5. src/backend/services/dispatch_admission_service.py:124-125 — dead forgeable parameters.
admit_chat_request still declares x_mcp_key_id / x_mcp_key_name and routers/chat.py:172 still passes them, but nothing in the body reads them. This contradicts the rule stated one function below ("a parameter nothing reads is how a forgeable value quietly finds a new consumer later") — the same reason audit_idempotent_replay dropped them from its signature.

6. tests/unit/test_1310_auth_consolidation.py:172 — helper kwarg silently ignored.
_user() gained an mcp_scope=None keyword that is never forwarded to the User(...) construction on the next line, and no caller passes it. A future test writing _user("bob", mcp_scope="ops") silently gets mcp_scope=None — the privileged JWT value in ADMIN_GATE_SCOPES — and asserts a pass the real principal would not get. That's precisely the stand-in-does-not-match-production defect this PR exists to eliminate.

Verified clean

  • Every _OPS_ALLOWED_ROUTES entry resolves to a real registered GET route (/api/agents/execution-stats, /slots, /{n}/executions in schedules.py, /{n}/executions/{id}/stream in chat.py, both telemetry routes, /api/monitoring/status, both executions routes).
  • All six routers/ops.py opt-ins are on GET handlers; every write stays bare.
  • validate_mcp_api_key really returns key_id / key_name.
  • db.get_audit_entries / count_audit_entries are **filters passthroughs, so the two new query params can't TypeError.
  • require_admin's signature is unchanged, so all Depends sites are unaffected; all six assert_admin monkeypatches in the test tree were updated to lambda user, **kw.
  • The MCP server's OPERATOR_SCOPES is an allowlist, so "ops" gets no operator tools.
  • User is not a response model anywhere, so the new key fields don't leak to clients.
  • The a2a idempotency-scope fix lands correctly via the model change alone.

🤖 Generated with Claude Code

…ted, and no route accepts a credential name (#2389 review)

Six findings from review on #2323, all verified against the code before fixing.

**High — admitted by the fence, refused by the gate.** `GET /api/subscriptions/{id}/usage`
is in `_OPS_ALLOWED_ROUTES` but its handler called bare `assert_admin`, and
`ADMIN_GATE_SCOPES` excludes `ops` — so the subscription-pressure read the fence was
measured for could not work. The admit-set test passed because it exercised only
`_enforce_ops_key_fence`, never the endpoint's own gate. Fixed with
`allow_scopes={"ops"}`, and guarded as a CLASS: a live-handler scan resolves every
allowlisted GET from the router modules and reds if any calls `assert_admin` without
the opt-in.

**Medium — `/ws/events` was a second auth entry point.** That handler validates the key
itself and never runs `get_current_user`, so none of its fences reached it and the ops
fence's own docstring claim was false for exactly one surface — the broad one. The
stream carries fleet-wide activity and execution events scoped by the OWNER's accessible
agents. `WS_EVENT_STREAM_SCOPES = {None, "user", "agent", "system"}` gates it (close
4003), closing the same hole for `connector` and `portal_delegate` rather than
preserving it out of politeness.

**Medium — the forgeable header still reached durable storage.** #2323 removed
`X-MCP-Key-Id` from the audit path only. Five routers still declared it and wrote it
into durable provenance columns (`schedule_executions`, `agent_loops`,
`agent_reminders`, fan-out rows), and `backlog_service` still persisted both names into
`backlog_metadata` — the longest-lived copy of a request, the surface canary G-04 scans
and #1449 scrubs — where `_spawn_drain` never read either key back. One request produced
two provenance records that disagreed and the forged one outlived the honest one. Every
writer now derives from the validated bearer on `User`; NO route declares either header,
guarded by a router-tree scan so the class cannot return one endpoint at a time; the
blob no longer carries them (a pre-existing queued row drains unchanged — the drain
reads key-by-key with `.get()`). The dead parameters are removed from the whole
`chat_execution_service` / `capacity_manager` / `backlog_service` chain rather than
accepted-and-ignored, per the rule #2323 stated one function below the offending code.

**Low — a guard that could never fire.** `_AGENTLESS_SCOPES` read
`getattr(key_data, "agent_name", None)` on a `McpApiKeyCreate` that declares no such
field, so Pydantic made it permanently falsy: protection in appearance only. The guard
is removed and the invariant is now asserted where it is real — a test that drives the
creator and reads the written row back, instead of asserting a tuple contains a string.

**Low — dead forgeable parameters** on `admit_chat_request`, and a `_user()` helper in
test_1310 that accepted `mcp_scope` and dropped it, letting a future test assert a pass
the real principal would never get — the stand-in-does-not-match-production defect this
work exists to eliminate.

Verification: full unit suite 12383 passed / 21 failed, against a baseline run at the
branch point of 12373 passed / 21 failed — byte-identical failure set (IPv6/SSRF,
environment-dependent). Zero regressions, +10 new passing.
@obasilakis

Copy link
Copy Markdown
Contributor Author

All six findings fixed in b996f3c. Each was verified against the code before touching it; three of them were bigger than reported, so here is what actually changed.

1 (High) — admitted by the fence, refused by the gate ✅

assert_admin(current_user, allow_scopes={"ops"}) on GET /api/subscriptions/{id}/usage.

Your last line is the important one: the admit-set test exercised only _enforce_ops_key_fence, so it asserted one half of a two-gate path and gave false assurance. Rather than extend that one test, this guards the class — test_every_admin_gated_allowlisted_route_opts_ops_in resolves every allowlisted GET from the live router modules (inspect.getsource on the endpoint object, so it follows a route that moves) and reds if any of them calls assert_admin without the opt-in. It asserts a non-empty resolved set first, so it cannot pass vacuously.

2 (Medium) — /ws/events bypassed the fence ✅ closed, not documented

dependencies.WS_EVENT_STREAM_SCOPES = {None, "user", "agent", "system"}, checked right after validate_mcp_api_key in the handler; a bounded scope gets close code 4003.

Closed rather than documented for the reason you gave — the fence is this PR's value proposition, and a docstring claiming "enforced at the single auth entry point" while a second entry point exists is worse than no claim. The pre-existing connector / portal_delegate hole is closed by the same allowlist: both are fenced to one or two routes everywhere else, so nothing legitimate reaches this one, and an unknown future scope is refused by construction.

3 (Medium) — forgeable header reaching durable storage ✅ and it was wider than the three lines

You were right, and the surface was larger than chat_execution_service. X-MCP-Key-Id was still declared as Header(None) on five routers and written into durable provenance in four places:

Writer Column
routers/chat.py (/chat, /task) schedule_executions.source_mcp_key_id
routers/schedules.py (manual trigger) schedule_executions.source_mcp_key_id
routers/loops.py agent_loops.source_mcp_key_id
routers/reminders.py agent_reminders.source_mcp_key_id
routers/fan_out.py fan-out execution rows

plus backlog_service persisting both names into backlog_metadata — and _spawn_drain never reads either key back, so it was a forgeable value stored, never reconstructed, in the one blob canary G-04 scans and #1449 scrubs.

All five now derive from current_user.mcp_key_id/mcp_key_name, and the fix is asserted in the strongest available form: no router declares either header at all, checked by a scan over the whole router tree. Grepping for the use is what let this survive #2323; a value nothing declares cannot be forged, threaded, or picked up by a new consumer.

The dead parameters are removed from the entire chat_execution_service → capacity_manager → backlog_service chain (finding 5's rule, applied at scale — 17 signatures), not accepted-and-ignored. A pre-existing queued row still carrying the two blob keys drains unchanged, since the drain reads the blob key-by-key with .get().

The MCP server still sends the headers. Left alone deliberately: nothing on the backend reads them, and the value it sends is the same key the bearer already identifies — so removing it is an MCP rebuild for no behaviour change, and keeping it is one less rolling-deploy edge.

4 (Low) — the guard that could never fire ✅

Correct, and the right fix was deletion. getattr(key_data, "agent_name", None) on a model that declares no such field is permanently falsy — protection in appearance only, which is worse than none. The guard is gone; _AGENTLESS_SCOPES stays as the documented invariant, and test_ops_keys_must_never_carry_an_agent_name (which asserted a tuple contains a string) is replaced by a test that drives the real creator and SELECTs the written row.

5 (Low) — dead parameters on admit_chat_request ✅

Removed, along with the router's pass-through.

6 (Low) — _user(mcp_scope=...) swallowed ✅

Forwarded. Same defect in test_connector_auth.py::_user, fixed with it.


Verification: full unit suite 12383 passed / 21 failed, against a baseline run of the branch point in a clean worktree at 12373 passed / 21 failed — byte-identical failure set (IPv6/SSRF, environment-dependent). Zero regressions, +10 new passing. Docs updated in architecture.md (scope table + the /ws/events second-entry-point note) and requirements/security.md.

…ng the database singleton

Two shards of the last CI run (head, seeds 12345/67890) hit the 25-minute job
cap. The cause is runner-side, not code — per-test durations are unchanged
between base and head (JUnit sums: head 777s vs base 750/794/841s; the two
endemic stalls, `test_start_agent_skip_inject` at 2x60.5s and
`test_1083_result_callback::TestPersistResend` at ~90s, are identical on both
sides, and head seed 99999 finished in 810s). The failing shards lost 1050s and
959s to stalls *between* progress lines, on second-wave matrix runners.

But the new temp-DB test had a real ordering-dependent defect that the failure
made worth finding, and it is exactly the class this PR argues about — a test
double that does not match production:

- It set `TRINITY_DB_PATH` only. `db.engine.resolve_database_url()` prefers
  `DATABASE_URL`, so under any ordering where an earlier test left that set,
  this test silently created tables and INSERTed an admin user plus an ops key
  into someone else's database instead of its own temp file.
- It never disposed the URL-keyed engine cache, so it could both miss its own
  engine and leave one holding a handle on a temp file about to be deleted.
- It imported `database`, whose module-level singleton runs the entire SQLite
  migration chain inside `__init__` against whichever URL is active at first
  import — making the whole session's `database.db` a function of test order.

Now follows the repo's established shape (`test_918`, `test_idempotency`):
`DATABASE_URL` at the temp file, `dispose_engines()` on both sides, and
`McpKeyOperations(UserOperations())` constructed directly so no migration
chain runs at all. Same assertion, still reading the written row back.
@dolho

dolho commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Code review — re-review at 5bbec15

Traced the ops scope end to end: fence → admin gate → each allowlisted route's own gate → MCP-server scope handling → /ws/events → audit derivation → backlog payload drain.

The previous round's items are fixed — /api/subscriptions/{id}/usage now opts in (the fence-admits-gate-rejects gap), /ws/events is scope-allowlisted, and the forgeable X-MCP-Key-Id no longer reaches source_mcp_key_id. Verified rather than assumed: the fence is anchored and method-belted, every allowlisted route resolves to a real handler and is reachable by an ops key, the dropped PersistentTaskPayload fields are safe because _spawn_drain reads the blob with .get(), and _resolve_actor short-circuits on actor_user so the new mcp_scope derivation cannot flip actor_type across ~70 call sites.

Four findings.

Medium

1. dependencies.py:823 (and docs/memory/requirements/security.md:314) — the documented "survives the minting admin being offboarded" property is not implemented.

Both the code comment and the requirements doc claim authority comes from being an ops key, not from who owns it. It does not: assert_admin(..., allow_scopes={"ops"}) runs _reject_scope_at_admin_gate and then still enforces current_user.role != "admin", and get_current_user rejects the principal outright when the owner carries suspended_at.

So: admin Alice mints an ops key for the monitoring dashboard and later leaves. Offboarding demotes or suspends her account, and the ops key immediately 403s on all six /api/ops/* reads and on /api/subscriptions/{id}/usage — precisely the "every ops integration dies when that admin is offboarded" outcome the doc says this design prevents. An operator will act on that sentence during an offboarding. Either drop the role check for the opted-in scope, or correct both claims.

2. dependencies.py:1001 — require_admin has no allow_scopes, and the new CI guard cannot see it.

require_admin is the Depends form and the dominant admin-gate spelling platform-wide, but it gained no allow_scopes parameter — so an ops-allowlisted route gated that way is permanently dead to ops keys with no opt-in available. And tests/unit/test_2323_machine_identities.py:365 only regex-matches assert_admin\( inside handler source, so it cannot see Depends(require_admin) in a signature.

Someone converts /api/ops/fleet/status to Depends(require_admin) for consistency with the enterprise routers: the ops dashboard silently 403s, and CI stays green. That is exactly the two-gate false-assurance class the guard was written for after the /subscriptions/{id}/usage bug — the guard closed the instance and left the shape open. Either give require_admin a parameterised form, or have the guard reject any allowlisted handler whose source mentions require_admin.

Low

3. dependencies.py:879 — WS_EVENT_STREAM_SCOPES admits "agent" wholesale, including ephemeral ghost keys.

/ws/events authenticates the key itself and never calls get_current_user, so _enforce_ephemeral_key_fence — whose entire purpose is that a ghost's key on an untrusted workspace must not be a fleet skeleton key — does not reach it. A ghost's own TRINITY_MCP_API_KEY therefore opens the fleet-wide event stream, scoped by get_accessible_agent_names(owner_email, is_admin), i.e. everything on a default admin-owned install.

Pre-existing, but this PR is the one that enumerated the permitted scopes on that surface and closed the same hole for connector and portal_delegate; ghost keys are the one bounded principal left in. Gating "agent" on the row's is_ephemeral (the predicate the fence already uses) would finish it.

4. settings/McpKeysTab.vue:496 — the UI advertises the tier but cannot mint it. The tab renders an "Ops (read-only)" badge, but the create modal only ever sends scope: 'portal_delegate' — there is no ops option in newKey. An operator reads the release note, opens Settings → MCP Keys, and finds no way to create the credential the badge describes. The tier is API-only.

Minor, no action needed

  • The four provenance comments in routers/{fan_out,loops,reminders,schedules}.py each start with a doubled marker: # # #2389:.
  • db/mcp_keys.py::_AGENTLESS_SCOPES is defined and referenced nowhere — deliberate per its own comment, noted here only so the next reader does not mistake it for an enforced guard.
  • src/mcp-server/src/client.ts still sends the now-ignored X-MCP-Key-ID / X-MCP-Key-Name headers. Harmless, but dead.

🤖 Generated with Claude Code

Comment thread src/backend/dependencies.py Outdated
# `assert_admin(..., allow_scopes={"ops"})`.
#
# Layer 2 is what makes this a machine identity rather than a human's proxy:
# authority comes from being an ops key, not from who owns it. It also flips the

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.

MEDIUM — the documented "survives the minting admin being offboarded" property is not implemented.

Both this comment and docs/memory/requirements/security.md:314 claim authority comes from being an ops key, not from who owns it. It does not: assert_admin(..., allow_scopes={"ops"}) runs _reject_scope_at_admin_gate and then still enforces current_user.role != "admin", and get_current_user rejects the principal outright when the owner carries suspended_at.

So: admin Alice mints an ops key for the monitoring dashboard and later leaves. Offboarding demotes or suspends her account, and the ops key immediately 403s on all six /api/ops/* reads and on /api/subscriptions/{id}/usage — precisely the "every ops integration dies when that admin is offboarded" outcome the doc says this design prevents.

An operator will act on that sentence during an offboarding. Either drop the role check for the opted-in scope, or correct both claims.

_reject_scope_at_admin_gate(current_user, allow_scopes)
if current_user.role != "admin":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=detail)

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.

(anchored to the nearest line in this PR's diff; the finding is at src/backend/dependencies.py:1001)

MEDIUM — require_admin has no allow_scopes, and the new CI guard cannot see it.

require_admin is the Depends form and the dominant admin-gate spelling platform-wide, but it gained no allow_scopes parameter — so an ops-allowlisted route gated that way is permanently dead to ops keys with no opt-in available. And tests/unit/test_2323_machine_identities.py:365 only regex-matches assert_admin\( inside handler source, so it cannot see Depends(require_admin) in a signature.

Someone converts /api/ops/fleet/status to Depends(require_admin) for consistency with the enterprise routers: the ops dashboard silently 403s, and CI stays green.

That is exactly the two-gate false-assurance class the guard was written for after the /subscriptions/{id}/usage bug — the guard closed the instance and left the shape open. Either give require_admin a parameterised form, or have the guard reject any allowlisted handler whose source mentions require_admin.

# them everything else — this closes a hole that predates #2323 rather than
# preserving it out of politeness; both are fenced to one or two routes, so
# nothing legitimate reaches this one.
WS_EVENT_STREAM_SCOPES = frozenset({None, "user", "agent", "system"})

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.

LOW — WS_EVENT_STREAM_SCOPES admits "agent" wholesale, including ephemeral ghost keys.

/ws/events authenticates the key itself and never calls get_current_user, so _enforce_ephemeral_key_fence — whose entire purpose is that a ghost's key on an untrusted workspace must not be a fleet skeleton key — does not reach it. A ghost's own TRINITY_MCP_API_KEY therefore opens the fleet-wide event stream, scoped by get_accessible_agent_names(owner_email, is_admin), i.e. everything on a default admin-owned install.

Pre-existing, but this PR is the one that enumerated the permitted scopes on that surface and closed the same hole for connector and portal_delegate; ghost keys are the one bounded principal left in. Gating "agent" on the row's is_ephemeral (the predicate the fence already uses) would finish it.

</span>
<span v-else-if="key.scope === 'system'" class="ml-2 inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-status-urgent-100 dark:bg-status-urgent-900/50 text-status-urgent-800 dark:text-status-urgent-300">
System
</span>

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.

(anchored to the nearest line in this PR's diff; the finding is at src/frontend/src/components/settings/McpKeysTab.vue:496)

LOW — the UI advertises the tier but cannot mint it.

The tab renders an "Ops (read-only)" badge, but the create modal only ever sends scope: 'portal_delegate' — there is no ops option in newKey. An operator reads the release note, opens Settings → MCP Keys, and finds no way to create the credential the badge describes. The tier is API-only.

source_mcp_key_name=getattr(current_user, "mcp_key_name", None),
)
return StartLoopResponse(
loop_id=loop_row["id"],

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.

(anchored to the nearest line in this PR's diff; the finding is at src/backend/routers/loops.py:1)

MINOR — doubled comment marker.

The provenance comment here (and the matching ones in routers/fan_out.py, routers/reminders.py and routers/schedules.py) starts # # #2389:.

Comment thread src/backend/db/mcp_keys.py Outdated
# review; the constant stays as the documented invariant and
# `test_2323_machine_identities.py` asserts the WRITTEN ROW rather than
# membership in this tuple.
_AGENTLESS_SCOPES = ("user", "portal_delegate", "ops")

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.

MINOR, no action — _AGENTLESS_SCOPES is defined and referenced nowhere.

Deliberate per its own comment ("holds HERE BY CONSTRUCTION"). Noted only so the next reader does not mistake it for an enforced guard — a second minting path could write an agent_name for a non-agent scope with nothing to stop it.

…t-in form, and fence ghost keys off the event stream (#2389 re-review)

Four findings from the second review round.

1. The "survives the minting admin being offboarded" property was documented in
   two places and implemented in neither: `assert_admin(..., allow_scopes={"ops"})`
   admits the scope and then still enforces `role == "admin"`, and
   `get_current_user` rejects a principal whose owner carries `suspended_at`
   (#995) one layer above. An operator would have acted on that sentence during a
   real offboarding.

   Corrected rather than implemented. Dropping the role check for an opted-in
   scope would make this bounded tier harder to revoke than the unbounded
   `user`-scoped key it exists to displace — that one loses admin the moment its
   owner is demoted — and it still could not deliver the claim, because
   suspension kills the key before any of this runs. #2323 asked for a credential
   that survives enforced 2FA, which it does; not one that survives its owner.
   The operator consequence (mint under a service admin account; revoke-and-re-mint
   belongs in the offboarding runbook) is now stated where it will be read, and a
   test pins the behaviour so the claim cannot drift back.

2. `require_admin` — the dominant admin-gate spelling — took no `allow_scopes`, so
   an ops-allowlisted route gated that way was permanently dead to ops keys with
   no opt-in available, and the new CI guard regex-matched `assert_admin(` only.
   Adds `require_admin_allowing("ops")`, which delegates the whole ladder to
   `assert_admin` rather than restating it (two admin gates that must stay
   identical is how the `require_role("admin")` third spelling happened), plus a
   sibling guard that reds on bare `require_admin` in any allowlisted handler.
   This closes the shape; the earlier fix closed only the instance.

3. `/ws/events` admitted the `agent` scope wholesale, including an ephemeral
   ghost's own key. That handler never runs `get_current_user`, so it skips
   `_enforce_ephemeral_key_fence` — whose entire purpose is that a ghost on an
   untrusted workspace must not hold a fleet skeleton key — and the stream is
   scoped by the owner's accessible agents, i.e. everything on a default
   admin-owned install. The gate now takes the key's `agent_name` and refuses an
   `is_ephemeral` row, using the fence's own predicate.

   That sub-check fails closed, deliberately inverting the ephemeral fence's
   fail-open: that fence guards heartbeats and result callbacks where a DB blip
   must not take the fleet down, while losing this stream costs an observability
   client a reconnect. Presence of the `is_ephemeral` key is what makes the answer
   real — the accessor coalesces the column for every live row, so a dict without
   it is no row at all, and a bare `.get()` would map that onto the same falsy
   value a genuine durable agent gives.

4. The Settings tab rendered an "Ops (read-only)" badge with no way to mint the
   tier — the create modal only ever sent `portal_delegate`. Replaced the boolean
   with a three-option scope selector (mutually exclusive, because the column
   holds one value and a checkbox pair can express a state the backend cannot
   store). The shared chrome is hoisted into two scoped classes so the file's
   raw-color ratchet count stays flat at its baseline of 97.

Minor: the four doubled `# # #2389:` provenance markers are single again, and the
now-inert `X-MCP-Key-*` sends in the MCP client carry a note saying why they are
kept and that no backend reader may return.

Full unit suite: 12392 passed / 21 failed, against 12383 / 21 at the previous
commit — identical failure set (IPv6/SSRF, environment-dependent), +9 net new
passing tests.
@obasilakis

Copy link
Copy Markdown
Contributor Author

All four fixed in 1f4b3ea, plus the three minors. One of them I fixed by correcting the claim rather than implementing it — reasoning below, because it is a design decision and not a typo.

1 (Medium) — the offboarding property ✅ corrected, deliberately not implemented

You are right on the facts: assert_admin(..., allow_scopes={"ops"}) admits the scope and then still enforces role == "admin", and get_current_user rejects a suspended owner one layer above. The sentence was mine, it was wrong, and "an operator will act on it during an offboarding" is exactly the damage.

I took the second of your two options. Dropping the role check for the opted-in scope was considered and refused for two reasons:

  • It would make the bounded tier harder to revoke than the unbounded one it exists to displace. A user-scoped key loses admin the moment its owner is demoted. An ops key that ignored the owner's role would not — so the narrower credential would survive a demotion the wider one does not. Wrong direction for a tier whose whole pitch is bounds.
  • It still could not deliver the claim. Suspension is the usual offboarding action and it kills the key at the auth entry point (Enterprise: User & Organization Management (Org/Team + advanced RBAC) on the #847 seam #995), before any of this runs. So the change would have half-delivered a property while leaving the same misleading sentence approximately true.

What the opt-in actually buys stands unchanged and is now stated as such: the grant is per route, so a new ops route is inaccessible until someone adds it. The opt-in is an additional gate, never a substitute one — an ops key is a narrowing of its owner, not a decoupling from them.

The operator consequence is now written where it will be read (code comment, requirements/security.md, architecture.md scope table): an ops key is bound to the account that minted it — mint it under a service admin account that is not offboarded with people, and put revoke-and-re-mint in the offboarding runbook. test_an_opted_in_ops_key_still_needs_its_owner_to_be_admin pins both directions (demoted owner 403s; admin owner passes) so the claim cannot drift back into the docs.

Worth noting the scope of what #2323 asked for: a credential that survives enforced 2FA — which it does, key validation never passing through the MFA gate. Surviving its owner was an embellishment I added, not a requirement.

2 (Medium) — require_admin had no opt-in, and the guard could not see it ✅

Both halves, since the guard closing only the instance was your point:

  • require_admin_allowing("ops") is the Depends form. It delegates the entire ladder to assert_admin rather than restating it — two admin gates that must stay identical is precisely how the require_role("admin") third spelling happened — and it matches require_role's factory shape exactly.
  • test_no_allowlisted_route_uses_the_unparameterised_require_admin resolves the same live handler set as the existing guard and reds on require_admin(?!_allowing) in any allowlisted handler's source. Your /api/ops/fleet/status conversion scenario now fails CI instead of silently 403ing the dashboard.

3 (Low) — ghost keys on the event stream ✅ closed

scope_may_open_event_stream(scope, agent_name) now refuses an is_ephemeral row, using _enforce_ephemeral_key_fence's own predicate; /ws/events passes key_info["agent_name"].

Two things I want on the record because they are judgement calls, not mechanics:

  • The sub-check fails CLOSED, inverting the ephemeral fence's fail-open. That fence guards heartbeats and result callbacks, where a DB blip must not take the fleet down; losing this stream costs an observability client a reconnect. Different blast radius, different direction.
  • Presence of the is_ephemeral key is what makes the answer real. get_agent_ephemeral_info coalesces the column for every live row, so a dict without it is no row at all — and a bare .get() maps that onto the same falsy value a genuine durable agent gives. Tested with {} explicitly, alongside None, a non-dict, a raising lookup, and a nameless agent-scoped key. An ordinary agent key still opens the stream, so the gate is not satisfied by denying everyone.

4 (Low) — the UI advertised a tier it could not mint ✅

The create modal is now a three-option scope selector (Standard / Ops / Portal delegate), admin-only, with the ops copy stating the bound honestly — including that the key stops working if the account's admin role is removed or the account is suspended, per finding 1.

A radio group rather than a second checkbox: the scopes are mutually exclusive because the column holds one value, so independent booleans can express a state the backend cannot store. The shared chrome is hoisted into two scoped classes so the file's raw-color ratchet count stays flat at its baseline of 97 — adding a three-option control would otherwise have pushed it to 107, and the ratchet may only shrink.

Minors

  • The four # # #2389: markers are single again.
  • _AGENTLESS_SCOPES left as-is; its own comment already says it is a documented invariant and not an enforced guard, which is what you were guarding the next reader against.
  • The MCP client's X-MCP-Key-* sends stay, but each of the four sites now carries a note saying they are inert (no router declares the headers, asserted by a scan), why they are kept — the value sent is the same key the bearer already identifies, so dropping it is an MCP rebuild for zero behaviour change — and that a backend reader must never return. Dead-but-explained beats dead-and-puzzling.

Verification: full unit suite 12392 passed / 21 failed, against 12383 / 21 at 5bbec159. Identical failure set (IPv6/SSRF, environment-dependent); +9 net new passing (10 added, one parametrize case moved to its own tests). Docs updated in architecture.md (scope table + the /ws/events block) and requirements/security.md.

@dolho

dolho commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Code review — re-review at 1f4b3ea

The core holds up: the admin gate is an allowlist, audit attribution comes from the presented bearer rather than a forgeable header, and the ops fence is enforced at the single auth entry point. Verified rather than assumed — every fenced route resolves to a real handler; every allowlisted route's own gate now admits ops (the /api/subscriptions/{id}/usage opt-in was a real bug and the fix is right); the backlog drain reads its blob with .get(), so dropping the two payload fields is upgrade-safe; _resolve_actor short-circuits on actor_user, so actor_type genuinely stays user; and _AGENTLESS_SCOPES / _USER_CREATABLE_SCOPES cannot let ops carry an agent_name.

Six findings.

Medium-high

1. main.py:1294 — the new /ws/events allowlist still admits ephemeral ghost keys, bypassing the ent#69 fence.

WS_EVENT_STREAM_SCOPES includes "agent", and this handler never calls get_current_user, so _enforce_ephemeral_key_fence is not reached either. A prompt-injected ghost — whose entire containment story is that its key reaches heartbeat, result callback, reports and self-info and nothing else — can run ws://backend:8000/ws/events?token=$TRINITY_MCP_API_KEY and receive fleet-wide agent_activity / schedule_execution_completed / agent_collaboration events, scoped to its owner's accessible agents: everything, on a default admin-owned install.

This PR is the one that added a gate here, and its own comment argues that a bounded credential must not read fleet-wide activity through an unlisted surface. The ephemeral bound is exactly such a credential and is the one left out. Reject when the key's agent_name resolves to an is_ephemeral agent — the predicate _enforce_ephemeral_key_fence already uses — or route this handler through a shared fence helper.

Medium

3. settings/McpKeysTab.vue:496 — the ops scope is badged but cannot be created from the UI. createKey only ever sends { scope: 'portal_delegate' }, and the create modal has no ops control; the only new UI is the read-only badge at :95. An admin who wants the credential this PR exists for has to curl POST /api/mcp/keys -d '{"scope":"ops"}'. The verb the feature implies is not reachable end to end.

4. routers/mcp_keys.py:52 — portal_delegate still doesn't require an interactive session, while the strictly weaker ops now does.

The new comment argues correctly that reject_agent_principal + assert_admin are no-ops for a scope that sets neither agent_name nor connector_agent. That reasoning applies identically to portal_delegate — which is the more dangerous credential, since it impersonates any portal user. An admin's user-scoped MCP key can still mint one; an ops key cannot reach the route only because of the route fence, which is a different mechanism doing accidental duty. Moving reject_non_interactive_principal(current_user) above the if requested_scope == OPS_SCOPE branch covers both.

Low-medium

2. dependencies.py:1417 (and docs/memory/feature-flows/mcp-api-keys.md, the routers/ops.py comments) — the offboarding claim is still not what the code does.

"Authority comes from being an ops key … which is why it keeps working when the owning admin is offboarded." But assert_admin runs _reject_scope_at_admin_gate(..., allow_scopes={"ops"}) and then still if current_user.role != "admin": raise, and get_current_user separately rejects a suspended owner. Demoting or suspending the minting admin silently 403s every /api/ops/* read — the exact scenario the docs promise survives.

Raised last round and worth repeating because of who acts on it: an operator following the documented offboarding procedure takes the dashboard down. Either drop the role check for an opted-in bounded scope, or correct the claim in all three places.

Low

5. dependencies.py:898 — HEAD requests to allowlisted routes are refused. Starlette auto-registers HEAD for every GET route, so a HEAD /api/version — a common liveness-probe shape — reaches get_current_user, and method == allowed_method never matches because every entry is literally "GET". The same URL succeeds under GET, so this reads as a flaky fence rather than a policy. Accept HEAD wherever GET is allowed, or say so.

6. tests/unit/test_2323_machine_identities.py:365 — the parity guard still only inspects assert_admin(...) call text. require_admin takes no allow_scopes, so converting any allowlisted route to the Depends form — the more common spelling in this codebase — silently loses ops keys while test_every_admin_gated_allowlisted_route_opts_ops_in stays green. That is the same "guard that asserts only one half" failure this PR describes finding for the fence-vs-gate bug. Extend the scan to the route's dependant chain, or make require_admin refuse to be used on an allowlisted path.

Minor

  • db/mcp_keys.py:128 — _AGENTLESS_SCOPES is referenced by nothing. The comment says it holds by construction, but a reader will assume it is live.
  • src/mcp-server/src/client.ts:727,892,1069,1273 — the MCP server still sends X-MCP-Key-ID / X-MCP-Key-Name on four call paths the backend no longer declares or reads. Harmless today (the bearer is always authContext.mcpApiKey, so attribution matches), but it leaves the forgeable header alive on the third surface after the backend guard banning it landed OSS-side only.

🤖 Generated with Claude Code

@dolho

dolho commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Correction — my previous comment reviewed a stale tree

Apologies: my re-review above was produced against a checkout that predates 1f4b3ea, even though the PR head was already 1f4b3ea when it ran. Four of its six findings are things that commit fixes. I have since checked each one by hand against origin/feature/2323-machine-identities.

Withdrawn — already fixed at 1f4b3ea:

  • Ghost keys on /ws/events. Fixed. scope_may_open_event_stream(scope, agent_name) gates the agent arm on db.get_agent_ephemeral_info, and main.py:1299 passes key_info.get("agent_name") precisely because the missing get_current_user also skips _enforce_ephemeral_key_fence. The not isinstance(info, dict) or "is_ephemeral" not in info → False branch is the right call: it refuses to let a stand-in row read as a genuine non-ephemeral one.
  • require_admin has no allow_scopes. Fixed — require_admin_allowing(*allow_scopes) (dependencies.py:1113) is the Depends form.
  • The ops scope can't be minted from the UI. Fixed — McpKeysTab.vue:225 has the radio, with the account-binding caveat in the helper text.
  • The offboarding claim in dependencies.py. Fixed, and better than I asked for: :831-850 now states plainly that it does not survive offboarding, gives the reason (suspension kills the key via Enterprise: User & Organization Management (Org/Team + advanced RBAC) on the #847 seam #995), and prescribes minting under a dedicated service admin with revoke-and-re-mint in the runbook.

Still open, verified at 1f4b3ea:

  1. docs/memory/feature-flows/mcp-api-keys.md:40 still carries the retracted claim — "Authority comes from being an ops key — which is why it keeps working when the owning admin is offboarded". The code comment was corrected and the flow doc was not, so the two now contradict each other, and the doc is the surface an operator reads while writing an offboarding runbook. This is the one I would fix before merge; it is one sentence.

  2. The parity guard is now blind to the form this PR just added. test_2323_machine_identities.py:375 regexes assert_admin\(([^)]*)\) over handler source. require_admin_allowing appears in a signature, not a call in the body — so an allowlisted route written in the new Depends style passes the guard vacuously and would silently lose ops keys. That is the same one-half-asserted shape the guard exists to catch, and it got slightly more likely the moment the second spelling shipped.

  3. HEAD on an allowlisted route is refused (dependencies.py, _OPS_ALLOWED_ROUTES entries are literally "GET" and the loop is if method != allowed_method). Starlette auto-registers HEAD for every GET, so HEAD /api/version — a common liveness-probe shape — 403s while GET on the same URL succeeds. Low, but it reads as a flaky fence rather than a policy.

  4. Minor, unchanged: _AGENTLESS_SCOPES (db/mcp_keys.py:128) is referenced by nothing, and src/mcp-server/src/client.ts still sends X-MCP-Key-ID / X-MCP-Key-Name on four paths the backend no longer reads.

Sorry for the noise — the withdrawn four were real work already done, and the comment should not have implied otherwise.

🤖 Generated with Claude Code

… the last gate-spelling gap, drop the dead constant (#2389 re-review)

Three of the four still-open re-review items. The fourth is refuted below with
evidence rather than fixed.

**The flow doc still carried the retracted claim.** `dependencies.py` was
corrected to say plainly that an ops key does NOT survive its owner's
offboarding; `docs/memory/feature-flows/mcp-api-keys.md` still said it does, so
code and doc contradicted each other and the doc is the surface someone reads
while writing an offboarding runbook. It now states the same thing, with the
same two reasons (demotion 403s at the gate, `suspended_at` kills the key one
layer up per #995), why dropping the role check was refused, the 2FA-vs-owner
distinction, and the mint-under-a-service-admin consequence.

**One gate spelling was still unguarded.** `1f4b3ea` already added a sibling
test for bare `Depends(require_admin)`, so the reported blind spot was mostly
closed — but neither guard caught `require_admin_allowing("telemetry")`, i.e.
the opt-in form parameterised with the wrong scope. All three spellings now go
through one `_ops_gate_offences` predicate:

    assert_admin(user, allow_scopes={"ops"})      imperative
    Depends(require_admin_allowing("ops"))        declarative
    Depends(require_admin)                        declarative, no opt-in exists

The sibling test delegates to that predicate instead of carrying a second copy
of the regex — two guards over one policy, each with its own pattern, is how the
spellings diverged in the first place. `test_the_ops_gate_guard_detects_every_
spelling` pins each offending form AND each correct form, so the guard can
neither go blind again nor be satisfied by rejecting everything. Verified by
live mutation: reverting the `/api/subscriptions/{id}/usage` opt-in reds it by
name.

**`_AGENTLESS_SCOPES` is deleted, not left unreferenced.** Removing the guard
that read it left a tuple no code consults, which reads as enforcement to the
next person — the same defect one step smaller. The invariant it named holds by
construction (this creator writes the literal `agent_name=None`, and
`McpApiKeyCreate` declares no field for a caller to supply) and is stated in the
comment plus asserted against the written row.

**Not fixed, because it is not a defect: `HEAD` on an allowlisted route.** The
premise is Starlette's `Route`, which does add `HEAD` alongside `GET`; FastAPI's
`APIRoute` does not. Measured on this stack: `GET /api/version` -> 200,
`HEAD /api/version` -> **405**, for every principal including an admin JWT.
Routing rejects the method before dependencies are solved, so the ops fence is
never reached and cannot be the thing refusing it. Adding `HEAD` to
`_OPS_ALLOWED_ROUTES` would widen the method belt for a method the app does not
serve — and that belt is what stops a future `POST /api/ops/*`, where
`emergency-stop` lives.
@obasilakis

Copy link
Copy Markdown
Contributor Author

Thanks for going back and checking by hand — the correction is more useful than the review.

1dc0607a closes three of the four. The fourth is refuted with a measurement rather than fixed.

1 — the flow doc carried the retracted claim ✅

Right, and it was the worse half of the pair: the code comment was corrected and the doc is what someone reads while writing an offboarding runbook. docs/memory/feature-flows/mcp-api-keys.md now says the same thing as dependencies.py:831-850 — does not survive the owner, both reasons (demotion 403s at the gate; suspended_at kills the key one layer up per #995), why dropping the role check was refused, the 2FA-vs-owner distinction, and mint-under-a-service-admin as the operator consequence.

2 — the guard's blind spot ✅, though smaller than reported

1f4b3ea had already added test_no_allowlisted_route_uses_the_unparameterised_require_admin, which covers the Depends(require_admin) case you named — worth pointing out since your comment reads as though nothing did.

What neither guard caught was require_admin_allowing("telemetry") — the opt-in form, present, parameterised with the wrong scope. All three spellings now resolve through one _ops_gate_offences predicate:

assert_admin(user, allow_scopes={"ops"})      imperative
Depends(require_admin_allowing("ops"))        declarative
Depends(require_admin)                        declarative, no opt-in exists

The sibling test delegates to that predicate rather than keeping a second copy of the regex — two guards over one policy, each with its own pattern, is how the spellings diverged in the first place. test_the_ops_gate_guard_detects_every_spelling pins each offending form and each correct one, so it can neither go blind again nor be satisfied by rejecting everything. Live mutation check: reverting the /api/subscriptions/{id}/usage opt-in reds it by name.

3 — HEAD on an allowlisted route ❌ not a defect

The premise is Starlette's Route, which does add HEAD alongside GET. FastAPI's APIRoute does not. Measured on this stack:

GET  /api/version -> 200
HEAD /api/version -> 405

405 for every principal, admin JWT included. Routing rejects the method before dependencies are solved, so _enforce_ops_key_fence never runs and cannot be what refuses it — there is no ops-key-specific behaviour to be flaky. A liveness probe using HEAD is already broken against this API for everyone, which is a different (and pre-existing) question.

Adding "HEAD" to _OPS_ALLOWED_ROUTES would therefore fix nothing and widen the method belt for a method the app does not serve — and that belt is the thing standing between an allowlisted prefix and a future POST /api/ops/*, where emergency-stop and fleet/stop live. Left alone deliberately.

4 — the two minors

  • _AGENTLESS_SCOPES deleted. You were right that it is unreferenced, and the fix is deletion rather than a reader: removing the guard that consumed it left a tuple nothing consults, which reads as enforcement to the next person — the same defect one step smaller. The invariant holds by construction (this creator writes the literal agent_name=None; McpApiKeyCreate declares no field for a caller to supply) and is now carried by the comment plus the written-row assertion.
  • client.ts still sends X-MCP-Key-ID/-Name. Deliberate, and recorded in requirements/security.md rather than left implicit: nothing on the backend reads them, and the value it sends is the same key the bearer already identifies — so removing it is an MCP rebuild for zero behaviour change and one more rolling-deploy edge (MCP updated before backend would drop attribution on the old backend, which still reads the header). Happy to strip it if you'd rather not carry the dead field.

CI green on 1f4b3ea (all six pytest shards); re-running on 1dc0607a.

@obasilakis
obasilakis merged commit 124e51d into dev Aug 26, 2026
32 of 36 checks passed
@obasilakis
obasilakis deleted the feature/2323-machine-identities branch August 26, 2026 06:37
vybe pushed a commit that referenced this pull request Sep 13, 2026
…ack under its own cap

/sync-feature-flows over the last five dev commits (#2638, #2703, #2571,
two left the feature's *home* doc stale while the change was documented
elsewhere.

fan-out.md (FANOUT-001 home, untouched since April) — #2670 only landed
in mcp-orchestration.md, so the doc that owns routers/fan_out.py still
said "POST only". Now documents GET /api/agents/{name}/fan-out/{fan_out_id},
build_fan_out_batch_status status derivation, the on_started hook,
FanOutBatchTask/FanOutBatchStatus, get_fan_out_executions and its
dual-scope reason, the bounded client.ts::fanOut() + get_fan_out_result
MCP tool (receipt rationale summarized, pointer to mcp-orchestration.md).
Drift repaired while there: db/schedules.py:NNN paths -> the #1481
db/schedules/ package, migration ordinal #30 -> #33 (verified against
MIGRATIONS), tool registration via addAllTools, X-MCP-Key-* headers
documented as inert per #2389.

subscription-auto-switch.md — the #2638 prose was complete but its three
catalog tables were not reconciled: Files (subscription_headroom_service,
execution_envelope, client_portal/service, the #2638 test; the toggle
lives in SubscriptionsPanel.vue + stores/subscriptions.js, not
views/Settings.vue), System Setting (subscription_api_key_fallback),
API Endpoints (GET/PUT /settings/api-key-fallback).

feature-flows.md — 547 -> 444 lines. Recent Updates trimmed 137 -> 20
rows, matching its own "newest ~20" header (#1360); one-line rows added
for #2638, #2703, #2670, which had none. 17 flow docs had no Documented
Flows category row — subscription-auto-switch.md among them, reachable
only via a June Recent Updates row the trim would have removed — so
every one of the 190 flow docs now has a category row. Skill Injection
row refreshed for delivery-on-assign (#2703). Three links that were
already broken in HEAD (AUDIT-001-execution-origin-tracking, skills-crud,
mcp-skill-tools) are left as-is.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01168enK9QL4DNuVSN6taw2E
vybe pushed a commit that referenced this pull request Sep 14, 2026
…ack under its own cap (#2753)

/sync-feature-flows over the last five dev commits (#2638, #2703, #2571,
two left the feature's *home* doc stale while the change was documented
elsewhere.

fan-out.md (FANOUT-001 home, untouched since April) — #2670 only landed
in mcp-orchestration.md, so the doc that owns routers/fan_out.py still
said "POST only". Now documents GET /api/agents/{name}/fan-out/{fan_out_id},
build_fan_out_batch_status status derivation, the on_started hook,
FanOutBatchTask/FanOutBatchStatus, get_fan_out_executions and its
dual-scope reason, the bounded client.ts::fanOut() + get_fan_out_result
MCP tool (receipt rationale summarized, pointer to mcp-orchestration.md).
Drift repaired while there: db/schedules.py:NNN paths -> the #1481
db/schedules/ package, migration ordinal #30 -> #33 (verified against
MIGRATIONS), tool registration via addAllTools, X-MCP-Key-* headers
documented as inert per #2389.

subscription-auto-switch.md — the #2638 prose was complete but its three
catalog tables were not reconciled: Files (subscription_headroom_service,
execution_envelope, client_portal/service, the #2638 test; the toggle
lives in SubscriptionsPanel.vue + stores/subscriptions.js, not
views/Settings.vue), System Setting (subscription_api_key_fallback),
API Endpoints (GET/PUT /settings/api-key-fallback).

feature-flows.md — 547 -> 444 lines. Recent Updates trimmed 137 -> 20
rows, matching its own "newest ~20" header (#1360); one-line rows added
for #2638, #2703, #2670, which had none. 17 flow docs had no Documented
Flows category row — subscription-auto-switch.md among them, reachable
only via a June Recent Updates row the trim would have removed — so
every one of the 190 flow docs now has a category row. Skill Injection
row refreshed for delivery-on-assign (#2703). Three links that were
already broken in HEAD (AUDIT-001-execution-origin-tracking, skills-crud,
mcp-skill-tools) are left as-is.


Claude-Session: https://claude.ai/code/session_01168enK9QL4DNuVSN6taw2E

Co-authored-by: sim <sim@example.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
vybe pushed a commit that referenced this pull request Sep 16, 2026
…2487)

* refactor(settings): routers/settings.py becomes a ten-module package (#1028)

3,529 lines — the largest file in the backend, 4.4x the 800-line critical
threshold — split into ten domain modules composed onto ONE router, so the
mounted API is byte-identical and `from routers.settings import router` is
unchanged. Largest resulting module: credentials.py at 778 lines.

Inclusion order is load-bearing (Invariant #4): `generic` owns the
GET/PUT/DELETE /{key} catch-alls, which match any single segment, so it is
included LAST — before its siblings it would swallow /ops/config,
/brain-orb, /api-keys/anthropic and answer 'setting not found' for routes
that exist. test_1028_settings_package.py pins:

- the mounted route SET equals the pre-split module's, compared against the
  real blob out of git (60/60, none lost, none invented)
- no route is shadowed by an earlier registration (the property that
  actually matters — literal order is deliberately NOT pinned, since
  regrouping specific routes relative to each other is inert)
- the catch-all include stays last, named at the include line a human edits
- every module stays under the 800-line threshold
- the import surface callers depend on still resolves (resolve_mcp_url,
  the key sets, _REPO_PATTERN)

Collaborators (db, platform_audit_service, settings_service) are
deliberately NOT re-exported on the package __init__: ~20 tests patch them
as module attributes, and after a move such a patch would apply cleanly to
a module nobody reads — a test asserting nothing while hitting the real
accessor. Absent attributes make every stale patch raise AttributeError
instead, which is exactly how the 14 affected test files were found and
repointed to the modules that own their handlers.

GET '' (the root listing) is registered on the parent router because a
prefix-less sub-router cannot carry an empty path (FastAPI refuses).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NLfHNPtB5UCMk4LonZiJux

* refactor(git): services/git_service.py becomes a six-module package (#1028)

2,322 lines split by responsibility — conflicts, gitignore, remotes,
trinity_files, sync, provisioning — with the full public surface re-exported
from the package __init__, so `from services.git_service import
sync_to_github` and `git_service.<name>` callers are unchanged. Largest
resulting module: gitignore.py at 649 lines.

Cross-module calls go THROUGH the sibling module object
(`gitignore._detect_git_dir(...)`), never a from-import of the function: a
from-import freezes the binding, so a test patching the owning module would
silently stop reaching the caller. Pinned structurally by
test_1028_git_service_package.py, alongside the size threshold and the
import surface.

Private names are re-exported ONLY where another backend module imports them
or a test reads them as data. A private function mirrored on both the
package and its owning module can be monkeypatched on the wrong one and
silently detach — which is exactly what happened to test_2069's readiness
probes mid-split (the multiline setattr sites patched the package's
re-exported copies while merge_gitignore_after_clone read the module's own),
so the collaborator-shaped names are deliberately not mirrored: a stale
patch raises AttributeError instead of testing nothing.

~15 test files repointed to the modules that own their handlers, including
the three sys.modules-isolated file loaders and test_github_init_push, whose
exec fake must now land on every module binding the driven function awaits
through (provisioning + gitignore + remotes — patched via the loaded package
instance, since its harness purges and reloads the package). The #2069
merge-caller guard now walks the whole package and matches qualified calls,
so a caller cannot fall out of its census by moving between modules.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NLfHNPtB5UCMk4LonZiJux

* refactor(client): services/agent_client.py becomes a three-module package (#1028)

1,294 lines split by responsibility — circuit (the #631 transport breaker:
constants, Lua, CircuitState, dormant alerting, admin read/reset), http_pool
(the per-agent httpx pool + drop-grace stamps), client (AgentClient, typed
errors, get_agent_client) — public surface re-exported from the package
__init__, so every existing import is unchanged. Largest module: client.py
at 698 lines.

Same discipline as the git_service split, pinned by
test_1028_agent_client_package.py: cross-module calls go through the sibling
module object, collaborators are not mirrored on the package, and no module
may from-import a sibling's function (a frozen binding silently detaches
monkeypatches on the owning module).

test_circuit_breaker.py's direct file-load gains package plumbing
(submodule_search_locations + a sys.modules registration before exec — the
__init__'s relative imports cannot resolve their parent otherwise), and its
patches land on the owning modules. The #1677 caller-parity allowlist entry
for _emit_dormant_alert follows the file to
services/agent_client/circuit.py — that guard firing on the move is exactly
what it is for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NLfHNPtB5UCMk4LonZiJux

* refactor(ops,public): the heavyweight handlers move behind their routes (#1028)

The last two ACs. `public_chat` — 289 lines of session identity, access
gating, rate accounting, upload decoding, memory injection and dispatch,
inside routers/public.py — moves to services/public_chat_service.py in the
#1483 shape (service raises PublicChatError, the thin route maps it 1:1);
client-IP extraction, the per-IP limit and token resolution stay router-side
because they are HTTP concerns. agent_requires_email /
agent_allows_open_access move with it and the router re-imports them — one
definition, not a copy. public.py: 1,239 → 901 lines.

routers/ops.py's five heavyweights — fleet health, the #1860 locked fleet
restart, fleet stop, emergency stop, the cost rollup — move to
services/fleet_ops_service.py (704) and services/ops_costs_service.py (208).
The auth gates stay IN the router deliberately: the #2389 fence-vs-gate
scans read live handler source there, and a gate that moved with the body
would satisfy auth while blinding the scan. ops.py: 1,304 → 506 lines.

test_1028_extracted_services.py pins the thinness, the gates' location, the
size class — and an unresolved-module-scope-name walk, added because the
move surfaced exactly that class twice: PublicChatResponse was unresolved in
the chat service while 623 tests passed (nothing drives the sync-success
return), and utc_now_iso the same in the costs service. py_compile cannot
see this; the walk can.

test_1860 / test_1917 fixtures now hand back the SERVICE module with the
route entry points attached, so collaborator patches land on the bindings
the moved bodies actually read while the gate patch stays on the router.
The #894 override-wiring census follows public_chat's two execute_task call
sites to their new file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NLfHNPtB5UCMk4LonZiJux

* test(1028): repoint the settings source-guards at the package; satisfy the sys.modules lint

Four guards read routers/settings.py as SOURCE TEXT (the ent#12 consent
AuditEventType pin + generic-PUT block, the ent#434 catch-all window)
and went FileNotFoundError when the module became a package — repointed
at the package glob (or generic.py where the guard scopes a specific
handler window). The new test files' own sys.modules registrations move
onto monkeypatch.setitem / the _restore_sys_modules precedent, and the
lint baseline is regenerated DOWNWARD (140 across 49 files — the patch
migrations in the split commits removed ~66 stale entries).

Related to #1028

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NLfHNPtB5UCMk4LonZiJux

* test(1028): the size guard counts blank lines out, comments in

Self-review finding, and the more serious of the two: my previous commit
changed the guard's METRIC so that my own edit would pass. That is the
re-baselining this file exists to make hard, wearing a docstring as cover.

Measured rather than argued. `credentials.py`:

  at 6d8c93a  raw 778   non-blank 687
  after        raw 806   non-blank 687   (blank-line restoration only)
                         non-blank-non-comment 643

Excluding blanks is exactly invariant under the change that prompted it.
Excluding comments as well moves the calibration: these files carry 110-208
comment lines each, so a comment-blind count hands `credentials.py` ~160 lines
of headroom the 800 ceiling never gave it, and `generic.py` 208.

So the metric now excludes blank separators and nothing else. Restoring a
PEP-8 blank line between two defs still does not read as a module growing,
and the ceiling still means what it meant when these files were authored
against it.

Related to #1028

* test(1028): name the size metric for what it counts

Re-review of my own fix. The metric was corrected to exclude blank lines only,
and left named `_logical_lines` — which in Python means the opposite, since a
logical line excludes comments.

That is not a cosmetic mismatch. Reading the phrase "logical lines" in this
test's docstring is precisely what talked the previous pass into excluding
comments and re-baselining the guard by 160 lines. Leaving the name in place
leaves the same trap armed, one identifier along, for the next reader who
"corrects" the body to match it.

`_non_blank_lines`, and the AC's docstring says "800 non-blank lines" in its
own words rather than delegating the definition to a helper name.

Related to #1028

* fix(1028): make the split's own safety nets actually run

Addresses the `/validate-pr` CHANGES_REQUESTED on #2487. Every item is about
a guard that is present and inert, which is why the refactor landed green.

**C1 — the 60/60 route-set proof never ran in CI.** It read the pre-split
module out of git, and every checkout in `backend-unit-test.yml` is
`fetch-depth: 1`, so `git show dd91056:…` failed on every run and the test
skipped — leaving "no route lost or invented" across a 3,529 → 10 module split
proven nowhere. The fork-point set is now a frozen 60-tuple literal (a fork
point is a historical fact, so freezing it costs no maintenance; a route added
since goes in `_ADDED_SINCE_SPLIT`, one reviewed line at a time). The git read
survives as a separate test that re-derives the literal wherever history is
deep enough, so the transcription cannot drift. Its temp module is written
under `tmp_path`, not `src/backend/` — an interrupted run there left a
top-level module `Dockerfile:131` would bake into the image.

**C2 — the integration suite broke at collection**, in FOUR files, not three:
`test_monitoring_service.py` too. All of them `spec_from_file_location` on
`services/agent_client.py`, which is now a package, so they raised
FileNotFoundError before any test ran; this only stayed green because
integration runs nightly rather than per-PR. Replaced with plain imports: the
loaders existed to bypass `services/__init__.py`, and that has not been true
since the module started importing `services.agent_auth` at import time (it is
on `dev` too). Privates come from the module that owns them
(`circuit._CIRCUIT_HASH_PREFIX`, `http_pool._client_pool`), per the package's
own no-mirrored-collaborators rule, and the caplog assertions key on the
parent logger name so they still capture from every submodule. Collection is
back to 83 = `dev`'s 83.

**Two invariant guards went blind on 3,529 lines.** `routers/settings/` is the
first subdirectory ever created under `routers/`, and both
`test_1310_auth_wiring.py` (Invariant #8) and `test_models_centralized.py`
(Invariant #14) globbed one level deep — all ten modules escaped, and it fails
open, so nothing showed. `rglob`, keyed by path relative to `routers/` so two
packages cannot share an allowlist key (a top-level file's relative path is its
bare name, so neither allowlist changes). 73 → 84 files scanned.

**A dead constant with a live test guarding it.** `routers/public.py` still
declared `MAX_CHAT_MESSAGES_PER_IP`/`_PER_TOKEN` while enforcement reads
`public_chat_service`'s copy, so `test_ip_rate_limit_fix.py` was asserting a
constant nothing enforces — equal values today, so only the guard had broken.
Now re-exported from the enforcing module.

**Split-detachment in `public.py`.** The two #311 gates were from-imported
under private aliases while the service called its own module-locals: one
function, two monkeypatch targets. Now called through the sibling module
object, which is the rule both package `__init__` docstrings state; the two
tests that patch or read it are repointed, and the `files.py` guard now bans
both spellings so the retired alias cannot let a re-import through.

Docs: `architecture.md` Invariant #1 gains the package paragraph (re-export
the public surface only; reach siblings through the module object; guards use
`rglob`), and the four stale `.py` references in the shards are corrected.

Also: nine modules carried a duplicated module-level `logger`; and
`fleet_ops_service` renames the fleet-restart log channel from `routers.ops`,
which is now stated in the code rather than left for an operator to discover.

Verified: unit suite 6269 passed, 1 failure — the pre-existing `::ffff:`
IPv4-mapped parsing case (local 3.12 vs the repo's 3.13 target), byte-identical
on clean `dev`.

Related to #1028

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bd71qsYbFodvofba8P69eP

* refactor(1028): split gitignore.py three ways after the dev merge

The dev merge brought #2529's ~715 lines into
`services/git_service/gitignore.py`, taking it to 1305 raw lines — past
the 800-line threshold this PR exists to enforce, and its own guard
(`test_1028_git_service_package::test_every_module_is_under_the_critical_threshold`)
said so. Re-baselining the guard under cover of a fix is precisely what
the settings-test docstring in this PR warns against, so the module is
split instead:

  gitignore.py        686  the patterns, the regions, the command builders
  gitignore_sweep.py  418  what a sweep DID — tags, parse, alert, report
  gitignore_clone.py  273  the once-per-agent merge after clone

The seam is "what the file CONTAINS" vs "what did that just do" vs "the
one-shot at creation". Cross-module references go through the module
object (`gitignore.<name>`, `gitignore_sweep.<name>`), never a
from-import: a from-import freezes the binding and a monkeypatch then
lands on a detached copy — which is how test_2069's readiness probes
went dark mid-split.

Three real defects surfaced while wiring it and are fixed here, not
carried:

- `_gitignore_merge_semaphore` and `_inflight_gitignore_merge_tasks`
  were referenced bare in `gitignore_clone` with no such globals —
  a NameError on the live clone-time merge path. The five merge
  constants + the semaphore + the in-flight set now live in
  `gitignore_clone`, their sole consumer.
- `_shadowed_negations` read `_GITIGNORE_MANAGED_LINES` bare after the
  move. It now reads it off `gitignore` through a deliberately
  function-local import — `gitignore` imports this module at its top
  level, so a module-level one would close the cycle at import time.
- `datetime` was left behind by `_augment_commit_message`.

The sibling suites are repointed at the module that OWNS each name, so
every symbol still has exactly one monkeypatch target: test_2529's sweep
names to `gitignore_sweep` (new `_sweep()` accessor beside `_gs()`), and
test_2069's readiness/merge collaborators to `gitignore_clone`.

741 passed, 3 skipped across the git/gitignore/1028/1310/models families.

Related to #1028

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bd71qsYbFodvofba8P69eP

* test: dev's post-fork tests reach the split modules the way every earlier one does (#1028)

Five files landed on dev after the fork with patch targets and source reads
on the monoliths. Re-pointed the same way the split re-pointed the rest:

- test_ent582_platform_keys: `is_claude_auth_configured` /
  `connect_agents_to_first_credential` on `settings.credentials`,
  `platform_keys_service.check_resend_key` on `settings.provider_keys`
- test_2695_stt_capability_probe: `_elevenlabs_settings_state_with_capability`
  on `settings.integrations`
- test_2691_public_url_reachability: the save-path source read on
  `settings/generic.py`, the flag-surface read on `settings/flags.py`,
  `update_setting`/`db`/`platform_audit_service` on `generic`
- test_github_init_push: the exec recorder also installed on
  `git_service.token_scrub`, which the ent#615 seed now runs through
- routers/settings/generic.py: the #2572 hook reaches `credentials` through
  an absolute function-local import — `test_2216_backup_observability` and
  `test_2572` load this module in isolation via `spec_from_file_location`,
  where a module-level relative import raises at collection

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VpvcfgWkmQPD7DrDLmATTf

* test: dev's two new settings tests reach the split modules (#1028)

`test_workspace_flag_retired` patches `settings_service`, `telemetry_sharing_service`
and `db` on the module it calls `get_public_feature_flags` from, and
`test_2696_stt_provider_errors` calls `_elevenlabs_settings_state_with_capability`.
Both imported the flat `routers.settings`. Now they import the `flags` and
`integrations` submodules, like the earlier re-points in 9c34662, so the
patches land on the globals the handlers actually read.

Full unit suite on this tree: 16468 passed, 32 skipped, 0 failed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test: patch the modules that own the moved globals, and let #1917 follow the moved ops code (#1028)

merge-train validation findings. Collection was fixed earlier; these are the
runtime half.

- `tests/integration/test_circuit_breaker.py`: 19 `monkeypatch.setattr(agent_client, ...)`
  calls and 34 `agent_client.CIRCUIT_*` reads now target
  `services.agent_client.circuit`, which reads its own globals. Patching the
  package re-export changed nothing, and `_get_circuit_redis` is not
  re-exported, so it raised AttributeError. Against a fakeredis server:
  8 failed / 26 passed before, 34 passed after (dev: 34 passed).
- `tests/git_sync/test_s5_conflict_classifier.py` loads `git_service/conflicts.py`,
  because the flat `git_service.py` no longer exists.
- `tests/git_sync/test_s7_reserve_instance_id.py` patches
  `check_remote_branch_exists` and `db` on `git_service.provisioning`, where
  `reserve_and_generate_instance_id` looks them up. Both git_sync files:
  33 passed.
- `tests/unit/test_1917_stack_trace_exposure.py`: the raw `str(e)` ban now
  also scans `services/fleet_ops_service.py` and `services/ops_costs_service.py`,
  where the ops handler bodies moved. Neither file has any hits today.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: sim <sim@example.com>
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.

feat: machine identities for admin/ops APIs — service credentials that survive enforced 2FA

2 participants