Skip to content

fix(auth): scope in-session agent MCP calls to the task owner#1937

Merged
jcfs merged 2 commits into
mainfrom
feature/scope-in-session-age-14g
Jul 25, 2026
Merged

fix(auth): scope in-session agent MCP calls to the task owner#1937
jcfs merged 2 commits into
mainfrom
feature/scope-in-session-age-14g

Conversation

@jcfs

@jcfs jcfs commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Follow-up to the #1930 review. #1930 has since merged, so this is rebased onto and targets main.

The gap

There are two ways an agent reaches kandev's MCP, and under per-user auth they behaved differently.

External /mcp endpoint — already correct, untouched. The agent authenticates with a PAT; the global auth middleware mirrors identity onto c.Request.Context(), gin.WrapH hands the request to mcp-go, and its transports derive the tool-handler context from r.Context(). Identity reaches the task service, so authorize* returns NotFound for another user's task.

In-session injected MCP — the leak. The tools an agent gets automatically inside its own session are served by agentctl and relayed back to the backend over the agent's WS stream. The backend dispatched them at agent.go dispatchMCPRequestmcpHandler.Dispatch(ctx, &msg) using the raw stream context, which carries no credential. Enforcement is gated on ctx identity (callerScope in service_access.go), and "no identity" is the service's signal for an internal caller (event bus, pollers, office schedulers) → unscoped, full access.

So an in-session agent that supplied another user's task_id/workflow_id was served that data — violating the explicit "knowing another user's ID must not grant access" requirement.

This was reproduced first, as a genuine red test. Against the pre-fix dispatch, TestInSessionMCPListTasksDeniesForeignWorkflow and TestInSessionMCPConversationDeniesForeignTask both failed with expected: "error", actual: "response" — user A's agent was served both user B's task list and user B's conversation message content.

The fix

New internal/mcp/scope resolves the stream's task → workspace → owner and attaches that user's real stored identity to the dispatch context, so the existing authorize* checks apply exactly as on the PAT path. Wired at the lifecycle/gateway seam (Manager.SetMCPIdentityScoper, applied in StreamManager.mcpHandlerFor), not inside the agentctl client package.

Tool handlers are unchanged.

Design points worth reviewing:

  • The owning task comes from the AgentExecution, never from the request payload. The payload's session_id/task_id are agent-controlled — trusting them would let an agent name another user's session and inherit their identity, turning this fix into a privilege escalation. Pinned by TestMCPHandlerForIgnoresPayloadSessionID and TestInSessionMCPIgnoresPayloadSessionForScoping.
  • Real identity, never synthetic. auth.Service.IdentityForUser resolves the owner's stored account (member/admin role, active-status checked). Synthetic reads as unscoped, so it would silently reinstate the bug.
  • Fails closed. If the owner cannot be resolved (DB error), Scope returns an error and the dispatch is denied — returning an unscoped context would grant every user's data. If the owner's account is missing/disabled, it still scopes to the owner ID but drops to the least-privileged role rather than falling back to unscoped.
  • Existing identity is never replaced or widened — guards the external /mcp path and any other credentialed caller.
  • Behavior preserved where it should be: auth disabled → unscoped (single-user instances unchanged); unclaimed pre-auth workspaces (empty owner_id) stay visible to everyone, matching the task service's own rule; internal non-user callers (pollers, event bus) never pass through the scoper, so they stay unscoped as intended.

Both agent-stream dispatch sites are covered — the ACP updates stream and the passthrough MCP stream.

Tests

  • internal/mcp/handlers/mcp_identity_scope_test.go — integration over the real handler→service path with two users on in-memory sqlite: A-cannot-read-B (list_tasks + get_task_conversation), A-can-still-read-own, payload-spoofing denied, auth-disabled stays unscoped.
  • internal/mcp/scope/scope_test.go — resolver units: real owner identity, auth-disabled no-op, unowned/no-workspace no-ops, existing identity preserved, fail-closed on both lookup errors, least-privilege fallback.
  • internal/agent/runtime/lifecycle/mcp_identity_test.go — wrapper scopes to the execution's task, ignores payload IDs, denies on scoping failure, passes through unwrapped without a scoper or task ID.
  • internal/auth/service_identity_test.goIdentityForUser: real non-synthetic identity, correct role, false when auth disabled / user unknown / user disabled.

Verification

  • golangci-lint run ./... --new-from-rev=919a39e49 (main, post-feat(auth): opt-in authentication & multi-user segregation #1930-merge) → 0 issues
  • go vet ./... → clean
  • go test on internal/mcp/... internal/auth/... internal/agent/runtime/... internal/backendapp/ internal/orchestrator/ internal/gateway/... → all pass
  • go test -race on ./internal/agent/runtime/lifecycle/... → pass, no goleak/race regressions

All of the above were re-run after the rebase onto main. The rebase replayed only this branch's own commit (git rebase --onto origin/main, since #1930 was squash-merged) and applied with zero conflicts.

internal/task/service has 8 failing tests (TestCreateDirectoryRejectsInvalidOrExistingChild, 7 × TestServiceInitializeLocalRepository*). These are pre-existing and unrelated — environment/filesystem related (all "parent directory cannot be accessed"), confirmed failing identically at the base commit in a separate worktree both before and after the rebase, and this commit has a zero-line diff in that package's test files.

Docs updated where they stated the old (leaky) contract: apps/backend/AGENTS.md, the auth ADR, and the service_access.go header comment all previously listed "MCP stream" among the unscoped internal callers.

🤖 Generated with Claude Code

@coderabbitai

coderabbitai Bot commented Jul 24, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

In-session MCP streams now resolve identity from the execution task’s workspace owner, attach that identity before dispatch, and fail closed when resolution fails. Authentication services, lifecycle wiring, backend registration, authorization tests, and guidance are updated accordingly.

Changes

MCP identity scoping

Layer / File(s) Summary
Task-owner identity resolver
apps/backend/internal/mcp/scope/*, apps/backend/internal/auth/service_credentials.go, apps/backend/internal/auth/service_identity_test.go
Resolves task/workspace ownership, attaches stored identities or a bounded unowned identity, and denies unresolved or inactive owners.
Scoped stream dispatch
apps/backend/internal/agent/runtime/lifecycle/*
Wraps MCP handlers using AgentExecution.TaskID, propagates the scoper through Manager, and returns MCP errors when scoping fails.
Production resolver wiring and authorization coverage
apps/backend/internal/backendapp/helpers.go, apps/backend/internal/mcp/handlers/mcp_identity_scope_test.go, apps/backend/internal/task/service/service_access.go
Registers task-owner scoping and tests ownership enforcement, payload-field isolation, unowned access bounds, inactive-owner denial, and disabled-mode pass-through.
Authentication and scoping guidance
apps/backend/AGENTS.md, docs/decisions/2026-07-24-opt-in-authentication.md
Documents identity-less internal callers and server-side MCP owner resolution from AgentExecution.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant AgentExecution
  participant StreamManager
  participant MCPResolver
  participant MCPHandler
  AgentExecution->>StreamManager: Open MCP stream with TaskID
  StreamManager->>MCPResolver: Resolve task owner identity
  MCPResolver-->>StreamManager: Return scoped context or error
  StreamManager->>MCPHandler: Dispatch request with scoped context
  MCPHandler-->>StreamManager: Return MCP response or authorization error
Loading

Poem

A rabbit hops through streams so bright,
Scoping tools by task-owner light.
Payload tricks are left behind,
Fail-closed paths keep borders lined.
“Sniff sniff!” says Bun, “the scope is right!”

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 52.63% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Description check ⚠️ Warning The description covers the change and testing, but it omits the required Checklist section from the template. Add the Checklist section from the template unchanged, and keep the existing items unchecked unless you explicitly completed them.
✅ Passed checks (3 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title accurately summarizes the core change: scoping in-session MCP calls to the task owner.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/scope-in-session-age-14g

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Base automatically changed from feature/opt-in-setup-authent-95z to main July 24, 2026 23:10
The MCP tools an agent gets automatically inside its own session are
relayed over the agent's WebSocket stream, which carries no credential.
The backend dispatched them on the raw stream context, so the task
service saw no identity — its signal for an internal caller (event bus,
pollers, office schedulers) — and served the request unscoped. An agent
that supplied another user's task_id or workflow_id was given their
data, violating the rule that knowing an ID must not grant access.

Resolve the owning user from the stream's own AgentExecution (task ->
workspace -> owner) and attach that real stored identity before
dispatch, so the existing authorize* checks apply exactly as they do on
the PAT-authenticated /mcp endpoint. The owning task never comes from
the agent-supplied payload: honoring a payload session_id would let an
agent name another user's session and inherit their identity.

Tool handlers are unchanged. Auth disabled stays unscoped, unclaimed
pre-auth workspaces stay visible to everyone, and a lookup failure
denies the dispatch rather than falling back to full access.

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

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f6d48dcb05

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread apps/backend/internal/mcp/scope/scope.go Outdated
Comment thread apps/backend/internal/mcp/scope/scope.go Outdated
@claude

claude Bot commented Jul 24, 2026

Copy link
Copy Markdown

Claude encountered an error —— View job


I'll analyze this and get back to you.

@jcfs
jcfs force-pushed the feature/scope-in-session-age-14g branch from f6d48dc to c6c4a95 Compare July 24, 2026 23:16
@claude

claude Bot commented Jul 24, 2026

Copy link
Copy Markdown

Claude finished @jcfs's task in 0s —— View job


I'll analyze this and get back to you.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/backend/internal/mcp/scope/scope.go`:
- Around line 95-103: The ownership-resolution flow in the relevant scope
resolver must fail closed when the task or workspace lookup returns nil without
an error: return an ownership-resolution error instead of the original context.
Preserve the no-op behavior only for an existing task with no workspace and an
existing workspace with no owner, and add tests covering missing task and
missing workspace rows.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 4bafe1d8-a2de-493a-bc16-8ce1a7c234b7

📥 Commits

Reviewing files that changed from the base of the PR and between 919a39e and c6c4a95.

📒 Files selected for processing (13)
  • apps/backend/AGENTS.md
  • apps/backend/internal/agent/runtime/lifecycle/manager.go
  • apps/backend/internal/agent/runtime/lifecycle/mcp_identity.go
  • apps/backend/internal/agent/runtime/lifecycle/mcp_identity_test.go
  • apps/backend/internal/agent/runtime/lifecycle/streams.go
  • apps/backend/internal/auth/service_credentials.go
  • apps/backend/internal/auth/service_identity_test.go
  • apps/backend/internal/backendapp/helpers.go
  • apps/backend/internal/mcp/handlers/mcp_identity_scope_test.go
  • apps/backend/internal/mcp/scope/scope.go
  • apps/backend/internal/mcp/scope/scope_test.go
  • apps/backend/internal/task/service/service_access.go
  • docs/decisions/2026-07-24-opt-in-authentication.md

Comment thread apps/backend/internal/mcp/scope/scope.go Outdated
Review follow-up. Three paths still returned an identity-free context
under enforced auth, and "no identity" is exactly what the task service
reads as an internal caller and serves unscoped:

- Unowned workspace. CreateWorkspace only stamps an owner for scoped
  callers, so internal callers keep producing owner_id='' rows after
  setup. Those streams got full cross-user access. Scope them to a
  sentinel user ID no account can hold: the service's "empty owner_id or
  my own ID" rule then limits them to the unowned rows that are public
  by the compatibility contract, and nothing else.
- Task with no workspace. Same treatment, same reasoning.
- Missing task or workspace row. A lookup that reports not-found as
  (nil, nil) fell through to unscoped; treat it as a resolution failure.

Also deny, rather than fabricate a member identity, when the owner's
account is gone or disabled. Disabling a user revokes their sessions and
PATs, so a still-running agent session was the one remaining way into
their workspace.

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

claude Bot commented Jul 24, 2026

Copy link
Copy Markdown

Claude finished @jcfs's task in 0s —— View job


I'll analyze this and get back to you.

@jcfs
jcfs merged commit 1038780 into main Jul 25, 2026
64 checks passed
@jcfs
jcfs deleted the feature/scope-in-session-age-14g branch July 25, 2026 08:56
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.

1 participant