fix: enforce agent ownership on GET /api/v1/external/invocations/{id} - #166
Open
kam-validmind wants to merge 1 commit into
Open
fix: enforce agent ownership on GET /api/v1/external/invocations/{id}#166kam-validmind wants to merge 1 commit into
kam-validmind wants to merge 1 commit into
Conversation
Service.Get had no ownership check, so any caller with a valid bearer token for one agent identity could read another agent's invocation record (tool input/output/error) by guessing or observing its id — the exact boundary RecordExecution already enforces on the write path. Extract the ownership check into a shared checkOwnership() helper used by both Get and RecordExecution. GET now maps ErrNotOwner to the same generic 404 as a missing invocation, so the response can't be used to confirm an id exists but belongs to someone else.
kam-validmind
marked this pull request as ready for review
July 29, 2026 23:42
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Pull Request Description
What and why?
GET /api/v1/external/invocations/{id}had no ownership check, so anyagent with a valid bearer token could read any other agent's invocation
record — tool name, input, output, and error — just by knowing or guessing its
invocation_id. There is no list-all endpoint for non-admins, so this wasn'tblind-enumerable, but it's a real gap in access control: an invocation record
can carry secrets or internal system output that was only ever meant for the
agent that submitted it.
The write side of this same route family (
PATCH .../invocations/{id}, handledby
RecordExecution) already enforces this — it checks that the caller'sauthenticated agent identity matches
inv.AgentIDbefore allowing an update.Service.Get(the read side) never got the equivalent check when it waswritten, even though it sits one method away in the same file.
Fix: Extracted the ownership check out of
RecordExecutioninto a sharedcheckOwnership(ctx, inv)helper ininternal/invocation/service.go, andapplied it to
Service.Gettoo. Same rules as before:(i.e. it went through the agent-runtime OAuth middleware). No-auth mode,
the operator/review UI routes, and the in-process managed-agents watcher
never carry an agent identity, so they're unaffected.
agent_id) are exempt — there's noowner to check against.
Before:
After:
We deliberately made the "not yours" case return the exact same 404 body as
"doesn't exist," rather than a 403. A 403 would confirm to Agent B that the ID
is real, just not theirs — that's its own small information leak (existence
disclosure). A 404 tells them nothing either way.
Sequence diagram — before the fix (vulnerable)
Agent B never submitted this invocation and has no relationship to it, but the
API hands over Agent A's data anyway because
Service.Getnever checks who'sasking.
sequenceDiagram actor A as Agent A (owner) actor B as Agent B (attacker) participant API as Atryum API participant Svc as invocation.Service participant DB as Database A->>API: POST /api/v1/external/invocations<br/>(bearer token: agent A) API->>Svc: Submit(ctx[agent=A], ...) Svc->>DB: INSERT invocation (agent_id = "A") DB-->>Svc: invocation_id = inv_123 Svc-->>API: inv_123 API-->>A: 200 OK { invocation_id: "inv_123" } Note over B: Agent B somehow learns/guesses "inv_123" B->>API: GET /api/v1/external/invocations/inv_123<br/>(bearer token: agent B) API->>Svc: Get(ctx[agent=B], "inv_123") Svc->>DB: SELECT invocation WHERE id = "inv_123" DB-->>Svc: invocation (agent_id = "A", input, output, error) Note over Svc: ❌ no check that ctx's agent ("B")<br/>matches invocation's agent_id ("A") Svc-->>API: full invocation record API-->>B: 200 OK — Agent A's tool input/output/errorSequence diagram — after the fix (protected)
Same request from Agent B now fails the ownership check inside
Service.Getbefore any data is returned, and gets back the same 404 shape a nonexistent ID
would.
sequenceDiagram actor A as Agent A (owner) actor B as Agent B (attacker) participant API as Atryum API participant Svc as invocation.Service participant DB as Database A->>API: POST /api/v1/external/invocations<br/>(bearer token: agent A) API->>Svc: Submit(ctx[agent=A], ...) Svc->>DB: INSERT invocation (agent_id = "A") DB-->>Svc: invocation_id = inv_123 Svc-->>API: inv_123 API-->>A: 200 OK { invocation_id: "inv_123" } Note over B: Agent B somehow learns/guesses "inv_123" B->>API: GET /api/v1/external/invocations/inv_123<br/>(bearer token: agent B) API->>Svc: Get(ctx[agent=B], "inv_123") Svc->>DB: SELECT invocation WHERE id = "inv_123" DB-->>Svc: invocation (agent_id = "A", input, output, error) Svc->>Svc: checkOwnership(ctx[agent=B], invocation) Note over Svc: ✅ "B" != "A" → return ErrNotOwner Svc-->>API: ErrNotOwner API-->>B: 404 Not Found { "error": "not found" }<br/>(same body as a nonexistent id) Note over A: Agent A can still GET inv_123 normally —<br/>ctx[agent=A] matches agent_id "A", check passesHow to test
Automated (this is what a reviewer should run):
go build ./... go vet ./... gofmt -l internal/invocation/service.go internal/invocation/record_execution_test.go \ internal/api/handlers.go internal/api/handlers_test.go go test ./internal/invocation/... ./internal/api/...New tests added:
internal/invocation/record_execution_test.go→TestGetEnforcesAgentOwnership(service-level, real in-memory DB): different agent rejected, owning agent
allowed, no-identity caller exempt, anonymous invocation exempt. Mirrors the
existing
TestRecordExecutionEnforcesAgentOwnershipfor the write path.internal/api/handlers_test.go→TestExternalInvocationGetErrorStatusMapping(handler-level): asserts
ErrNotOwnerand "missing invocation" both producestatus 404 with the byte-identical response body, so a future regression
that makes them distinguishable would fail the test.
Manual local check, if you want to see it end-to-end:
The stock local dev stack (
docker compose --profile dev up) shipsatryum.tomlwith every[[auth]]block commented out, so there's no OAuthto configure — Atryum's
noAuthAgentIDHintmiddleware (internal/api/handlers.go)lets an unauthenticated caller simulate an agent identity with a plain
?agent_id=query param instead. That's what the checks below use, and itexercises the exact same
auth.AgentIDFromContext(ctx)path a real bearertoken's
client_idclaim would populate — no need to stand up Keycloak.docker compose --profile dev up -d --wait --build atryum.If your environment does have real OAuth configured (an active
[[auth]]block pointed at Keycloak/Auth0/etc.), the same check applies but swap the
?agent_id=query param forAuthorization: Bearer <token>headers from twodifferent agents'
client_credentialstokens — the code path is identical,just sourced from the verified JWT's
agent_id_claiminstead of the query hint.To see it in a browser instead of a terminal: open any page already served by
Atryum (e.g.
http://localhost:8090/healthz) so DevTools' Console runssame-origin, then paste:
What needs special review?
whether hiding existence is the right call here vs. matching PATCH's 403.
We chose 404 because a read endpoint returning 403 would still leak "this ID
is real" to a caller who shouldn't know that.
checkOwnershipnow sits on both the read and write path. Double-checkthe extraction didn't change
RecordExecution's behavior — it's a straightlift of the same logic, but it's the write path for tool-execution results,
so it's worth confirming nothing shifted.
Dependencies, breaking changes, and deployment notes
GETan invocation belonging to a different agent identity will now get a 404
instead of 200. This should never have worked in the first place, so we
don't expect a legitimate integration depends on it, but flagging since it
is a visible response-code change on a public API route.
Data impact
Justification: This is an access-control check on an existing read endpoint.
No database schema, stored data, or business-process behavior changes — the
same invocation record is returned as before, just gated to its owning agent.
Release notes
Fixed an access-control gap where an agent could read another agent's
invocation details (tool name, input, output, error) through the external
invocations API if it obtained that invocation's ID. Reads are now scoped to
the invocation's owning agent, matching the access control already enforced
when reporting execution results.
Checklist
bugwhen opening the PR