Skip to content

fix(security): CSO 2026-08-09 — terminal-tier HIGH + access-control + supply-chain hardening - #2081

Merged
vybe merged 1 commit into
devfrom
fix/cso-2026-08-09-terminal-tier-and-hardening
Aug 10, 2026
Merged

fix(security): CSO 2026-08-09 — terminal-tier HIGH + access-control + supply-chain hardening#2081
vybe merged 1 commit into
devfrom
fix/cso-2026-08-09-terminal-tier-and-hardening

Conversation

@trinity-ability

Copy link
Copy Markdown
Contributor

Summary

Fixes surfaced by the /cso full audit (docs/security-reports/cso-2026-08-09.md). One HIGH plus access-control and supply-chain hardening. The report + structured JSON are included in this PR.

HIGH

  • H1 — terminal WebSocket privilege escalation. services/agent_service/terminal.py gated a /bin/bash container shell at accessor tier (can_user_access_agent), so a chat-only shared collaborator could open the terminal against a running agent and dump CLAUDE_CODE_OAUTH_TOKEN / TRINITY_MCP_API_KEY / .env (RCE as the agent, lateral movement via the MCP key). Now gated at owner/admin tier (can_user_share_agent), matching the deliberately admin-only SSH endpoint. Confirmed prod-reachable + exploitable by two independent fresh-context refutations.

Access control

  • M1 — schedule enable/disable/trigger moved from AuthorizedAgentOwnedAgentByName (they were accessor-tier while update/delete required ownership; a shared user could flip owner-intent schedule state and burn LLM spend).
  • L1GET /event-subscriptions/{id} gains assert_agent_access (the owner check its PUT/DELETE siblings already enforce; bounded IDOR by ~96-bit id, but the asymmetry is real).

Supply chain

  • M3npm audit fix on mcp-server + helper-mcp + frontend clears the HIGH ip-address (SSRF) and undici (response-desync) advisories in the MCP prod tree; ip-address override floor raised to >=10.5.0. 0 vulnerabilities in all three trees.
  • M4npm installnpm ci in the mcp-server and frontend prod Dockerfiles, restoring the tracked lockfile as a control.
  • M5 (partial) — backend Dockerfile pins aiohttp==3.14.3, slackify-markdown==0.2.4 (were floating amid an otherwise exact-pinned set).

CI hygiene

  • M9 / L5 — least-privilege permissions: contents: read added to frontend-build.yml and deploy-dev.yml.

Verification

  • py_compile on all changed backend files; test_186_enumeration_uniformity (36) and test_subscription_bola pass.
  • npm audit0 vulnerabilities on mcp-server / helper-mcp / frontend; mcp-server tsc and frontend vite build both succeed; npm ci exits 0 (lockfiles in sync).
  • Backend pins read from the live running backend's own resolved versions.

Full integration verification of the H1/M1 auth changes (shared-user 403 vs owner 200) runs on CI.

Deliberately held (need a build/CI-verified pass — see report)

  • M5 base-image pip pins + Go-tarball SHA (needs a base-image rebuild; a wrong pin takes down every agent).
  • M4 dev frontend Dockerfile (deletes the lockfile for cross-arch native binaries; dev-only).
  • M8 SHA-pinning ~15 first-party actions (needs exact SHAs + a CI run).
  • M2 / M6 / M7 / L7 — policy/owner-decision items (backlog_metadata drain-null, git-history scrub, Google-scope narrowing, docs policy).

🤖 Generated with Claude Code

… supply-chain hardening

Addresses the /cso full audit (docs/security-reports/cso-2026-08-09.md).

- H1 (HIGH): gate the agent terminal WebSocket at owner/admin tier
  (can_user_share_agent), not accessor tier — a chat-only shared collaborator
  could open /bin/bash in the container and dump CLAUDE_CODE_OAUTH_TOKEN /
  TRINITY_MCP_API_KEY / .env. Mirrors the deliberately admin-only SSH endpoint.
- M1: move schedule enable/disable/trigger to OwnedAgentByName (matching
  update/delete) so shared users can't flip owner-intent schedule state.
- L1: add assert_agent_access to GET /event-subscriptions/{id}, the owner
  check its PUT/DELETE siblings already enforce.
- M3: npm audit fix across mcp-server/helper-mcp/frontend (clears HIGH
  ip-address SSRF + undici desync advisories); raise ip-address override
  floor to >=10.5.0. 0 vulnerabilities in all three; tsc + vite build pass.
- M4: npm install -> npm ci in mcp-server + frontend prod Dockerfiles,
  restoring the tracked lockfile as a supply-chain control.
- M5 (partial): pin backend aiohttp==3.14.3, slackify-markdown==0.2.4.
- M9/L5: least-privilege `permissions: contents: read` on frontend-build
  and deploy-dev workflows.

Held for a build/CI-verified pass (see report): base-image pip pins + Go
tarball SHA, first-party action SHA-pinning, dev frontend Dockerfile.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@vybe vybe left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Reviewed the full diff. Approving including M1.

H1 (terminal WebSocket privilege escalation) — this is the one that matters and the fix is right. Gating a /bin/bash container shell at accessor tier meant a chat-only shared collaborator could dump CLAUDE_CODE_OAUTH_TOKEN / TRINITY_MCP_API_KEY / .env — RCE as the agent plus lateral movement via the MCP key. Moving to can_user_share_agent matches the deliberately admin-only SSH endpoint, which is the correct precedent: a shell is not an accessor-tier capability.

M1 (schedule enable/disable/trigger → OwnedAgentByName) — merging as audited. This is a deliberate, user-visible tightening: shared collaborators lose the ability to flip schedule state. It's the right call because update/delete already required ownership, so accessor-tier enable/disable/trigger was an inconsistency that let a shared user override owner intent and burn LLM spend. Needs a release note — this is a behaviour change, not a pure fix.

L1GET /event-subscriptions/{id} gaining assert_agent_access simply matches the gate its PUT/DELETE siblings already enforce.

Queued behind #1976, which touches routers/schedules.py too.

One process note, not a blocker: this PR carries no closing keyword, so nothing auto-promotes. The committed docs/security-reports/cso-2026-08-09.{md,json} are the durable record, which is the right shape for an audit sweep spanning several unrelated findings.

@vybe
vybe merged commit 03cd05d into dev Aug 10, 2026
23 of 24 checks passed
dolho added a commit that referenced this pull request Aug 11, 2026
`POST /api/agents/{name}/schedules/{id}/enable|disable|trigger` returned 422 to
EVERY caller, owner included — breaking the REST API, the frontend Schedules tab
toggle and Run-now, and the MCP `toggle_agent_schedule` / `trigger_agent_schedule`
tools.

flip owner-intent schedule state. Right intent, wrong variant: `OwnedAgentByName`
resolves `get_owned_agent_by_name(agent_name: str = Path(...))`, and these routes
declare `{name}`. FastAPI can never satisfy that dependency, so it rejects the
request during validation, before the handler runs.

The two variants exist for exactly the two path-param spellings, so the fix keeps
M1's tier and changes only the spelling: `OwnedAgent` is the same owner/admin
check reading `name`.

VERIFIED AGAINST A RUNNING BACKEND, NOT JUST THE ANNOTATION

Two throwaway containers on the real Postgres/Redis — one on unpatched `dev`,
one on this branch — same request:

    dev (unpatched)  enable   422  [{"type":"missing","loc":["path","agent_name"]}]
    dev (unpatched)  disable  422  [{"type":"missing","loc":["path","agent_name"]}]
    dev (unpatched)  trigger  422  [{"type":"missing","loc":["path","agent_name"]}]
    fix              enable   404  'Agent not found'
    fix              disable  404  'Agent not found'
    fix              trigger  404  'Agent not found'

Byte-identical to the payload in the issue on the left; the uniform 404 of
Invariant #8 on the right. Both containers were torn down afterwards.

TWO GUARDS (AC4), because one is not enough

`test_2094_dependency_path_param_pairing.py` — static, covers all 146 uses of
the four aliases across every router. Static by design: `test_1483_route_order.py`
documents what importing `main` costs — it skipped ALWAYS, standalone included,
for months behind a message blaming the wrong cause, and a guard that can
silently skip is not a guard. It also asserts the scan found >100 pairings and
all four aliases, because a guard matching nothing certifies nothing.

`test_2094_schedule_control_routes_bind.py` — functional, drives the real router
through TestClient. A rule about annotations is a proxy for "the route works";
this is the thing itself. It also pins that the OWNER gate still resolves, so the
obvious wrong fix — drop to `AuthorizedAgent`, 422 disappears, #2081's hole
returns — fails here.

Mutation-tested: unpatched dev -> 7 failed; accessor-tier fix -> caught;
fixed -> 11 passed.

Two things the functional test had to survive, both recorded at the site: my
first draft mounted the router with a prefix it already carries, double-prefixing
every request into Starlette's generic 404 — which a loose `"not found"` substring
happily matched, so both tests passed against BROKEN code; it now asserts the
handler's exact `"Schedule not found"`. And dependency identity is compared by
qualified NAME, because `dependencies` is on conftest's #762 invariant-restore
list and a fresh import can hand back a different module object than
`routers.schedules` bound at its own import.

Not run: the 7 integration tests the issue names. They need `TEST_AGENT_NAME`
and skip rather than fail, and the isolated backend has no Docker socket so it
sees no agents. The live contrast above covers the same ground more directly.

Fixes #2094

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
dolho added a commit that referenced this pull request Aug 11, 2026
`POST /api/agents/{name}/schedules/{id}/enable|disable|trigger` returned 422 to
EVERY caller, owner included — breaking the REST API, the frontend Schedules tab
toggle and Run-now, and the MCP `toggle_agent_schedule` / `trigger_agent_schedule`
tools.

flip owner-intent schedule state. Right intent, wrong variant: `OwnedAgentByName`
resolves `get_owned_agent_by_name(agent_name: str = Path(...))`, and these routes
declare `{name}`. FastAPI can never satisfy that dependency, so it rejects the
request during validation, before the handler runs.

The two variants exist for exactly the two path-param spellings, so the fix keeps
M1's tier and changes only the spelling: `OwnedAgent` is the same owner/admin
check reading `name`.

VERIFIED AGAINST A RUNNING BACKEND, NOT JUST THE ANNOTATION

Two throwaway containers on the real Postgres/Redis — one on unpatched `dev`,
one on this branch — same request:

    dev (unpatched)  enable   422  [{"type":"missing","loc":["path","agent_name"]}]
    dev (unpatched)  disable  422  [{"type":"missing","loc":["path","agent_name"]}]
    dev (unpatched)  trigger  422  [{"type":"missing","loc":["path","agent_name"]}]
    fix              enable   404  'Agent not found'
    fix              disable  404  'Agent not found'
    fix              trigger  404  'Agent not found'

Byte-identical to the payload in the issue on the left; the uniform 404 of
Invariant #8 on the right. Both containers were torn down afterwards.

TWO GUARDS (AC4), because one is not enough

`test_2094_dependency_path_param_pairing.py` — static, covers all 146 uses of
the four aliases across every router. Static by design: `test_1483_route_order.py`
documents what importing `main` costs — it skipped ALWAYS, standalone included,
for months behind a message blaming the wrong cause, and a guard that can
silently skip is not a guard. It also asserts the scan found >100 pairings and
all four aliases, because a guard matching nothing certifies nothing.

`test_2094_schedule_control_routes_bind.py` — functional, drives the real router
through TestClient. A rule about annotations is a proxy for "the route works";
this is the thing itself. It also pins that the OWNER gate still resolves, so the
obvious wrong fix — drop to `AuthorizedAgent`, 422 disappears, #2081's hole
returns — fails here.

Mutation-tested: unpatched dev -> 7 failed; accessor-tier fix -> caught;
fixed -> 11 passed.

Two things the functional test had to survive, both recorded at the site: my
first draft mounted the router with a prefix it already carries, double-prefixing
every request into Starlette's generic 404 — which a loose `"not found"` substring
happily matched, so both tests passed against BROKEN code; it now asserts the
handler's exact `"Schedule not found"`. And dependency identity is compared by
qualified NAME, because `dependencies` is on conftest's #762 invariant-restore
list and a fresh import can hand back a different module object than
`routers.schedules` bound at its own import.

Not run: the 7 integration tests the issue names. They need `TEST_AGENT_NAME`
and skip rather than fail, and the isolated backend has no Docker socket so it
sees no agents. The live contrast above covers the same ground more directly.

Fixes #2094

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
dolho added a commit that referenced this pull request Aug 11, 2026
`POST /api/agents/{name}/schedules/{id}/enable|disable|trigger` returned 422 to
EVERY caller, owner included — breaking the REST API, the frontend Schedules tab
toggle and Run-now, and the MCP `toggle_agent_schedule` / `trigger_agent_schedule`
tools.

flip owner-intent schedule state. Right intent, wrong variant: `OwnedAgentByName`
resolves `get_owned_agent_by_name(agent_name: str = Path(...))`, and these routes
declare `{name}`. FastAPI can never satisfy that dependency, so it rejects the
request during validation, before the handler runs.

The two variants exist for exactly the two path-param spellings, so the fix keeps
M1's tier and changes only the spelling: `OwnedAgent` is the same owner/admin
check reading `name`.

VERIFIED AGAINST A RUNNING BACKEND, NOT JUST THE ANNOTATION

Two throwaway containers on the real Postgres/Redis — one on unpatched `dev`,
one on this branch — same request:

    dev (unpatched)  enable   422  [{"type":"missing","loc":["path","agent_name"]}]
    dev (unpatched)  disable  422  [{"type":"missing","loc":["path","agent_name"]}]
    dev (unpatched)  trigger  422  [{"type":"missing","loc":["path","agent_name"]}]
    fix              enable   404  'Agent not found'
    fix              disable  404  'Agent not found'
    fix              trigger  404  'Agent not found'

Byte-identical to the payload in the issue on the left; the uniform 404 of
Invariant #8 on the right. Both containers were torn down afterwards.

TWO GUARDS (AC4), because one is not enough

`test_2094_dependency_path_param_pairing.py` — static, covers all 146 uses of
the four aliases across every router. Static by design: `test_1483_route_order.py`
documents what importing `main` costs — it skipped ALWAYS, standalone included,
for months behind a message blaming the wrong cause, and a guard that can
silently skip is not a guard. It also asserts the scan found >100 pairings and
all four aliases, because a guard matching nothing certifies nothing.

`test_2094_schedule_control_routes_bind.py` — functional, drives the real router
through TestClient. A rule about annotations is a proxy for "the route works";
this is the thing itself. It also pins that the OWNER gate still resolves, so the
obvious wrong fix — drop to `AuthorizedAgent`, 422 disappears, #2081's hole
returns — fails here.

Mutation-tested: unpatched dev -> 7 failed; accessor-tier fix -> caught;
fixed -> 11 passed.

Two things the functional test had to survive, both recorded at the site: my
first draft mounted the router with a prefix it already carries, double-prefixing
every request into Starlette's generic 404 — which a loose `"not found"` substring
happily matched, so both tests passed against BROKEN code; it now asserts the
handler's exact `"Schedule not found"`. And dependency identity is compared by
qualified NAME, because `dependencies` is on conftest's #762 invariant-restore
list and a fresh import can hand back a different module object than
`routers.schedules` bound at its own import.

Not run: the 7 integration tests the issue names. They need `TEST_AGENT_NAME`
and skip rather than fail, and the isolated backend has no Docker socket so it
sees no agents. The live contrast above covers the same ground more directly.

Fixes #2094

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
vybe pushed a commit that referenced this pull request Aug 11, 2026
…) (#2095)

* fix(schedules): pair the owner gate with the path param it reads (#2094)

`POST /api/agents/{name}/schedules/{id}/enable|disable|trigger` returned 422 to
EVERY caller, owner included — breaking the REST API, the frontend Schedules tab
toggle and Run-now, and the MCP `toggle_agent_schedule` / `trigger_agent_schedule`
tools.

flip owner-intent schedule state. Right intent, wrong variant: `OwnedAgentByName`
resolves `get_owned_agent_by_name(agent_name: str = Path(...))`, and these routes
declare `{name}`. FastAPI can never satisfy that dependency, so it rejects the
request during validation, before the handler runs.

The two variants exist for exactly the two path-param spellings, so the fix keeps
M1's tier and changes only the spelling: `OwnedAgent` is the same owner/admin
check reading `name`.

VERIFIED AGAINST A RUNNING BACKEND, NOT JUST THE ANNOTATION

Two throwaway containers on the real Postgres/Redis — one on unpatched `dev`,
one on this branch — same request:

    dev (unpatched)  enable   422  [{"type":"missing","loc":["path","agent_name"]}]
    dev (unpatched)  disable  422  [{"type":"missing","loc":["path","agent_name"]}]
    dev (unpatched)  trigger  422  [{"type":"missing","loc":["path","agent_name"]}]
    fix              enable   404  'Agent not found'
    fix              disable  404  'Agent not found'
    fix              trigger  404  'Agent not found'

Byte-identical to the payload in the issue on the left; the uniform 404 of
Invariant #8 on the right. Both containers were torn down afterwards.

TWO GUARDS (AC4), because one is not enough

`test_2094_dependency_path_param_pairing.py` — static, covers all 146 uses of
the four aliases across every router. Static by design: `test_1483_route_order.py`
documents what importing `main` costs — it skipped ALWAYS, standalone included,
for months behind a message blaming the wrong cause, and a guard that can
silently skip is not a guard. It also asserts the scan found >100 pairings and
all four aliases, because a guard matching nothing certifies nothing.

`test_2094_schedule_control_routes_bind.py` — functional, drives the real router
through TestClient. A rule about annotations is a proxy for "the route works";
this is the thing itself. It also pins that the OWNER gate still resolves, so the
obvious wrong fix — drop to `AuthorizedAgent`, 422 disappears, #2081's hole
returns — fails here.

Mutation-tested: unpatched dev -> 7 failed; accessor-tier fix -> caught;
fixed -> 11 passed.

Two things the functional test had to survive, both recorded at the site: my
first draft mounted the router with a prefix it already carries, double-prefixing
every request into Starlette's generic 404 — which a loose `"not found"` substring
happily matched, so both tests passed against BROKEN code; it now asserts the
handler's exact `"Schedule not found"`. And dependency identity is compared by
qualified NAME, because `dependencies` is on conftest's #762 invariant-restore
list and a fresh import can hand back a different module object than
`routers.schedules` bound at its own import.

Not run: the 7 integration tests the issue names. They need `TEST_AGENT_NAME`
and skip rather than fail, and the isolated backend has no Docker socket so it
sees no agents. The live contrast above covers the same ground more directly.

Fixes #2094

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

* fix(tests): key the dependency overrides on what the ROUTES resolve (#2094)

The full unit tier failed 3 of my own new tests — standalone they passed:

    test_the_handler_actually_ran[enable|disable|trigger]
    AssertionError: .../enable returned 401, expected the handler's own 404;
                    body: {"detail":"Not authenticated"}

`dependency_overrides` is an identity-keyed dict. The fixture keyed it on
`dependencies.get_owned_agent` from a fresh `import dependencies` — but
`dependencies` is on conftest's #762 `_SYS_MODULES_INVARIANT_KEYS` list, so its
sys.modules entry is restored to a baseline between tests, and a re-import can
hand back a DIFFERENT module object with different function objects than
`routers.schedules` bound at its own import. The override then silently does not
apply, the real auth chain runs, and every request 401s.

Overrides are now keyed on the exact callables in each route's dependant graph,
which are by construction the objects FastAPI looks up at request time. Module
identity cannot matter because the module is never consulted.

Staged the divergence deliberately rather than hunting the predecessor (the same
search failed earlier today on a sibling case):

    OLD (import identity)  -> HTTP 401  {"detail":"Not authenticated"}
    NEW (route identity)   -> HTTP 404  {"detail":"Schedule not found"}

A `>= 2` assertion on the override count now fails in the fixture if the
name-matching ever stops matching, instead of surfacing as confusing 401s in
every test below.

Worth naming, because it is the third instance of one class today: the same
conftest restore produced the ent#356 portal-test failures and the qualname
comparison two commits ago. I fixed the ASSERTION identity in this very file and
left the OVERRIDE identity — the docstring even called object-keying a feature
("a future swap would leave the override unapplied"), which is precisely the
vulnerability. Any object-keyed registry over a module-level function is exposed;
`dependency_overrides` and `monkeypatch.setattr` are the two in this repo.

I also pushed the previous commit before this tier finished. It was not done.

Related to #2094

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

* fix(tests): the pairing guard missed 40 routes — the Depends() form (#2094)

Found reviewing my own PR, and it is the guard committing the error it exists to
catch.

These four gates are attached two ways, and both are in live use:

    name: OwnedAgent                              # 146 Annotated aliases
    name: str = Depends(get_owned_agent)          #  40 Depends defaults

The collector only read `ast.Name` annotations, so it scanned the first form and
was blind to the second — then reported a clean pass. Forty routes were
unguarded, every credential endpoint among them, while the test that exists to
prove the pairing is safe said it was.

None of the 40 is mispaired today, so no live bug. But #2094's AC asks that "the
next OwnedAgent<->OwnedAgentByName swap can't ship silently", and in the Depends
form it would have — silently, with a green guard, which is worse than no guard.

Coverage 146 -> 186. Mutation-tested on the newly covered form specifically,
because counting them proves only that they are counted:

    credentials.py:100  get_owned_agent_by_name -> get_owned_agent
    -> credentials.py:99 get_agent_env_drift() — route
       '/agents/{agent_name}/credentials/env-drift' uses Depends(get_owned_agent)
       (reads path param 'name') but declares ['{agent_name}']; every request 422s

The completeness test now requires BOTH families to be present, not just the four
aliases. Losing the Depends() scan again would fail there instead of quietly
halving the coverage — the same "a guard matching nothing certifies nothing"
reasoning the file already applied to the alias family, which I had applied to
one family and not the other.

Related to #2094

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

* docs(learnings): one-syntax guards, and identity-keyed registries (#2094)

Two durable classes from the #2094 review, both of which shipped green:
a static guard that scanned one of two equivalent syntaxes and reported a
clean pass over the 40 routes it could not see, and an identity-keyed
registry (dependency_overrides) defeated by conftest's module-object
restore. Written for the /autoplan reader.

Related to #2094

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

* test(1968): read trigger_schedule via AST, not a 5000-char window

`_backend_trigger_endpoint()` sliced a fixed 5000 characters from the
`async def trigger_schedule(` marker — a byte count standing in for a
function boundary. It broke the moment the handler grew: this branch adds
a four-line comment explaining why the route uses `OwnedAgent` rather than
`OwnedAgentByName` (#2094), which pushed the `execution_id` relay from
4835 to 5071 characters into the function. 71 past the cliff, and the
assertion started failing against code that is completely correct — the
relay is present, twice.

A test that fails when a COMMENT is added is measuring the wrong thing.
The property under test is "this handler relays execution_id", so read
the handler: resolve it by AST and assert over its full source. The
window is gone, so the guard no longer has an expiry date measured in
characters.

Verified load-bearing by mutation: deleting both relay lines from
routers/schedules.py still fails the test.

Related to #2094

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: trinity-ability <309458136+trinity-ability@users.noreply.github.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.

2 participants