Skip to content

fix: View as Agent must enforce the target user's data policy - #71

Merged
geekypunk merged 5 commits into
mainfrom
cursor/view-as-policy-identity-c497
Aug 20, 2026
Merged

fix: View as Agent must enforce the target user's data policy#71
geekypunk merged 5 commits into
mainfrom
cursor/view-as-policy-identity-c497

Conversation

@venkateshsakamuri-lab

@venkateshsakamuri-lab venkateshsakamuri-lab commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

What was going wrong

A new Agent chat thread while Viewing as marts-editor still returned crm.customers rows (including amount). SQL Editor already blocked crm. Direct MCP API calls with a marts-editor token also blocked crm. The Agent path did not.

End-to-end logs for the failing turn (session=fca723ca8171, 14:14 UTC):

  1. /api/agent/session minted MCP token 257 for marts-editor and provisioned u-marts-editor.
  2. Hermes POST /api/profile/switch returned 200 three times; the session JSON is tagged "profile": "u-marts-editor".
  3. mcp__deepsql__get_brain_context and mcp__deepsql__execute_sql authenticated as admin token 253.
  4. Audit: EDITOR_QUERY_EXECUTED / clientType=mcp / user_id=1 (admin@demo.local) / SELECT … FROM crm.customers / rowCount=2.
  5. Token 257 last_used_at is only the Spring probeMcpAuth right after mint — Hermes never sent it.
  6. u-marts-editor has no mcp-stderr.log; u-admin MCP was started on Aug 18 and reused.

Hermes keeps one DeepSQL MCP stdio process, started from the first loaded profile (u-admin). POST /api/profile/switch is explicitly process_wide=False (cookie / thread-local only). A new chat does not respawn MCP. resolveEffectivePolicy(..., actorIsAdmin=true) returns none(), so schema and column policy never run.

probeMcpAuth only proves the minted token works against Spring, not that the live MCP process will use it.

Fix

scripts/local-agent-provisioner.py (the Agent container's /provision handler) now mirrors the minted token onto $HERMES_HOME/deepsql.token and every profiles/*/deepsql.token. The long-lived MCP client re-reads DEEPSQL_TOKEN_FILE per request (mtime cache), so the next execute_sql authenticates as the viewed-as user and policy applies.

Chat-path policy is also fail-closed for unparseable/unhandled SQL and missing actors, and walks CTEs, UNIONs, and nested FROM/WHERE selects.

Merge with main

Resolved conflicts with main (#70 whole-statement schema allowlist + nested protected-table inspectability, #72 brain endpoint authz).

Kept this branch's recursive SELECT walker and fail-closed parse/actor rules. Took main's assertProtectedTablesAreInspectable / namesMatch so a qualified protection (public.customer_profiles) does not catch the same table name in another schema, while an unqualified nested reference still fails closed.

UserDataAccessPolicyServiceTest (24) and BrainControllerAuthorizationSafetyTest (2) pass after the merge.

How to verify

  1. View as marts-editor, Agent tab, New chat.
  2. Ask: show me all customer information from crm tables.
  3. Expect a policy block, not Acme/Globex rows. security_event.user_id for that SQL must be the editor, not admin.

Rebuild/restart deepsql-agent so the container picks up the provisioner change, or copy scripts/local-agent-provisioner.py onto /opt/deepsql-agent/provisioner.py and restart the provisioner process, then reopen the Agent tab (triggers /provision).

Re-test after the fix

Same prompt in a new Agent thread while Viewing as marts-editor. Agent refused CRM; audit user_id=2 / clientAgent=marts-editor.

Agent refuses CRM while viewing as marts-editor
agent_view_as_crm_policy_block.mp4

To show artifacts inline, enable in settings.

Open in Web Open in Cursor 

cursoragent and others added 3 commits August 20, 2026 11:33
Keep the admin JWT subject for logout/refresh/control-plane, but stamp
impUid on the access token so overlay (and Agent Bearer fallback) evaluate
as the viewed-as user. Never provision the admin session JWT into an Agent
profile while impersonating, and forward the effective username to the
Agent API instead of hardcoding admin.

Co-authored-by: Venkat SF <venkatesh.sakamuri@stayflexi.com>
The previous change dropped the function declaration when inserting
clearAgentRemoteUser, which broke the frontend parse.

Co-authored-by: Venkat SF <venkatesh.sakamuri@stayflexi.com>
Walk the full SQL tree, deny unparseable or unhandled statements, and
take the MCP/Editor actor from SecurityContext. Persist allowed schemas,
scope RAG/brain retrieval, block protected-column prompt mentions, and
refuse public shares on connections with an active policy.

Co-authored-by: Venkat SF <venkatesh.sakamuri@stayflexi.com>
@cursor cursor Bot changed the title fix: View as must enforce the target user's policy fix: View as policy identity and fail-closed access policy Aug 20, 2026
Hermes keeps one DeepSQL MCP stdio server from the first loaded profile
(usually u-admin). Profile switch and a new chat thread do not respawn it,
so View as marts-editor still executed SQL as admin and skipped policy.

The provisioner now writes the minted token to every deepsql.token the
already-running MCP client re-reads, not only the target profile's file.

Co-authored-by: Venkat SF <venkatesh.sakamuri@stayflexi.com>
@cursor cursor Bot changed the title fix: View as policy identity and fail-closed access policy fix: View as Agent must enforce the target user's data policy Aug 20, 2026
@venkateshsakamuri-lab
venkateshsakamuri-lab marked this pull request as ready for review August 20, 2026 14:43
Keep fail-closed parse/actor rules and recursive SELECT inspection from this
branch, and take main's whole-statement schema allowlist plus
assertProtectedTablesAreInspectable. Blank outer-column provenance from a
derived table is no longer treated as unresolved so a qualified protection
does not catch the same table name in another schema.

Co-authored-by: Venkat SF <venkatesh.sakamuri@stayflexi.com>
@geekypunk
geekypunk merged commit f90f60f into main Aug 20, 2026
9 checks passed
@geekypunk
geekypunk deleted the cursor/view-as-policy-identity-c497 branch August 20, 2026 15:06
venkateshsakamuri-lab pushed a commit that referenced this pull request Aug 23, 2026
The agent provisioner mirrored each freshly minted MCP token onto
`$HERMES_HOME/deepsql.token` AND every `profiles/*/deepsql.token`, which
made the agent credential globally last-writer-wins. Any user opening
the Agent tab overwrote every other user's token, so their agent then
authenticated as the newcomer. Two concurrent users was the whole
trigger; no impersonation required.

Verified end to end before the fix (analyst = DEVELOPER, admin = ADMIN,
`QA Vault Copy` admin-only):

  analyst opens tab -> analyst file: dsql_mcp_v2nA7...
  admin opens tab   -> analyst file: dsql_mcp_p4nIo...  (admin's token)

  /api/auth/me with analyst's file        -> username=admin, role=ADMIN
  analyst session  -> QA Vault Copy       -> 403
  analyst agent    -> QA Vault Copy       -> 200, 133 tables
  SELECT via that token                   -> logged as user_id=8 (admin)

So one user read another's database and the audit row named the wrong
person. The schema read produced no audit event at all.

The mirror was introduced to fix a real View-as bug (#71): Hermes keeps
ONE MCP subprocess, `profile/switch` is `process_wide=False`, so the
live process kept whichever credential loaded first. That diagnosis
stands — only the remedy was too broad.

Fix, in two layers:

1. The provisioner writes only `$HERMES_HOME/deepsql.token`, the one
shared path the live process may have started from. Per-profile tokens
are no longer touched, so one user's open cannot destroy another's
credential.

2. That root file is still shared, so the durable guard is server-side:
`McpTokenAuthenticationFilter` refuses an MCP token whose owner differs
from the request's `DEEPSQL_MCP_USER_ID` claim (already sent as
`X-DeepSQL-Client-Agent`), answering 401 `mcp_identity_mismatch`. The
claim is only ever used to REFUSE, never to grant, so forging it cannot
widen access. A claim that is not a real DeepSQL username is ignored,
keeping editor/CLI MCP installs (`cursor`, `claude-desktop`, any
`--caller-agent`) working.

Also:

- The provisioner self-test asserted the fan-out was CORRECT — it
modelled only the View-as case, where overwriting is desired — so a
green suite guarded the bug and would have failed on this fix. It now
asserts the opposite: provisioning B leaves A's token intact.
- `probeMcpAuth` sends the same identity header so the boot health check
exercises the binding instead of bypassing it. Without that it would
report a token healthy that the live MCP process cannot use.
- `AgentChatPanel` re-bootstraps on an SSE error to surface the real
cause; EventSource exposes no status code, so session expiry, an agent
restart and a refused credential all arrived as "the agent run ended
early".

Verified after the fix: analyst keeps its own token when admin opens the
tab; the leaked-token request is 401 instead of 200/133-tables; and
every legitimate path still works (own agent 200, editor MCP 200,
no-header curl 200, analyst -> admin-only DB still 403).

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

3 participants