Files
pad/internal/mcp
xarmian 9eb1a35f16 feat(mcp): privacy-preserving available_workspaces filter on error envelopes (TASK-977) (#379)
* feat(mcp): privacy-preserving available_workspaces filter on error envelopes (TASK-977)

Closes the last open work item in PLAN-943. HTTPHandlerDispatcher's
unknown_workspace error envelope now populates available_workspaces
filtered by the OAuth token's consent allow-list (TASK-952), so an
agent never sees workspace slugs the user didn't explicitly grant.

## What changed

- `HTTPHandlerDispatcher` gains a `Lister WorkspaceLister` field.
  Production wires `mcpserver.NewOAuthWorkspaceLister(s)`; tests
  can supply mocks.
- `packageHTTPResponse` now takes a `lister` parameter and threads
  it down to `classifyHTTPStatus`. Both call sites in the package
  updated.
- New `oauthWorkspaceLister` reads three things from request context:
    - `server.CurrentUserFromContext` — the requesting user.
    - `server.TokenAllowedWorkspacesFromContext` — the consent
      allow-list (TASK-953 plumbing).
    - `s.GetUserWorkspaces(user.ID)` — the user's full set.
  Returns the intersection. Wildcard (`["*"]`) and nil (PAT auth)
  short-circuit to "no filter" — the user's full set is returned
  in those cases since the token doesn't constrain workspaces.
- `cmd/pad/main.go` wires the production lister.

## Privacy invariant

A token whose allow-list is `[alpha, beta]` MUST NOT see "gamma"
in the available_workspaces hint, even if the user is a member of
gamma. Tested explicitly via
TestUnknownWorkspace_AvailableWorkspaces_FilteredByAllowList —
the test fakes a 4-workspace user membership, sets allow-list to
2, and asserts exactly 2 slugs appear in the filtered envelope.

Without this filter, an attacker controlling an OAuth client could
hit any random workspace slug, get the unknown_workspace envelope,
and read OFF the user's full workspace list — defeating the whole
point of the consent UI's per-workspace selection.

## Tests (18 new)

8 envelope round-trip tests pin every documented HTTP status →
ErrorCode mapping (401 → auth_required, 403 → permission_denied,
404 generic → item_not_found, 404 workspace → unknown_workspace,
409 → conflict, 400/422 → validation_failed, 5xx → server_error,
418 → server_error fallback).

5 privacy-filter tests cover the allow-list shapes:
specific-list-filters, wildcard-no-filter, no-allow-list-no-filter,
no-user-empty-hints, store-error-empty-hints.

4 buildAllowSet unit tests for the helper.

1 end-to-end test through packageHTTPResponse.

* fix(mcp): use req.Context() when packaging HTTP response (Codex round 1)

Codex review #379 round 1 caught a real correctness issue: the
packageHTTPResponse calls in executeRequest + the prefetch path in
dispatchItemUpdate passed the dispatcher's outer ctx instead of
req.Context(). The lister reads CurrentUser + TokenAllowedWorkspaces
from context, and the canonical "everything attached" context is
the SYNTHESIZED request's context — buildHTTPRequest layers
WithCurrentUser + WithAPITokenAuth on it, and d.Apply (when wired)
attaches token state on top of req specifically.

In production this happened to work because MCPBearerAuth attaches
TokenAllowedWorkspaces on the inbound /mcp request's context, which
the dispatcher inherits as its outer ctx. But:

  - Tests driving executeRequest with context.Background() + a
    UserResolver-supplied user got empty available_workspaces
    because the outer ctx had no user.
  - Any future dispatcher attaching token state via Apply (rather
    than relying on inbound-ctx propagation) would also see the
    bug — the Apply hook is documented as the place for "TASK-953
    token-scope context" exactly.

Fix: pass req.Context() / prefetchReq.Context() to packageHTTPResponse.
Same dispatcher, same ServeHTTP — just feed the lister the canonical
post-Apply context.

Test: TestExecuteRequest_UsesRequestContext_NotOuterContext drives
executeRequest with an empty outer context + a UserResolver, asserts
the resulting unknown_workspace envelope has the user's full
workspace list. With the buggy version the test fails (lister sees
no user → empty hints).
2026-05-02 15:35:18 -04:00
..