feat(security): ADR-0090 D10 — agent/service intersection runtime#2838
Merged
Conversation
When a request's principal acts `onBehalfOf` a user (an AI agent or a service acting for a person), the effective permission is now the INTERSECTION of the principal's own grants and the delegator's grants — never the union. Confused-deputy prevention: an over-privileged agent may never see or touch anything the user it stands in for could not, and vice-versa. Previously `principalKind:'agent'` / `onBehalfOf` was a P1 context shape the evaluator did not read. Applied at every axis, gated on the delegation link so the non-delegated path is byte-identical: - plugin-security: delegator sets reconstructed once (fail-CLOSED on a dangling link — a deleted delegator is denied, not resolved to the additive baseline) and AND-composed into the capability gate, object CRUD, FLS (read mask + write forbid + predicate guard), row-level `using` pre-image, `check` post-image, and RLS read injection. View/Modify-All survives only when BOTH principals hold it. - plugin-sharing: the OWD/record-share owner-match is identity-scoped, so it re-runs the visibility filter and canEdit under the delegator's own identity + depth and AND-s it in — an agent with View-All acting for a plain member sees exactly that member's rows, not everyone's. - explain engine: every layer reports the narrower verdict for a delegated principal; a dangling delegator reads as a fail-closed deny. Delegator resolution + the pure combinators (intersectFieldMasks, narrowerScope) are single-sourced in explain-engine.ts and reused by both the enforcement middleware and the explanation, so they can't drift. Tests: 11 unit (security-plugin.test.ts) exercising the real middleware + a served-engine dogfood (showcase-agent-intersection) proving the intersection strips View-All on a real private-OWD object yet still surfaces the delegator's own rows. Full suites green: plugin-security 297, plugin-sharing 76, permission/OWD/sharing dogfood unchanged. First cut: one delegation hop (a safe lower bound on multi-hop); tenant-scoped substitution bags inherited from the live principal, person-specific membership bags left unresolved (narrows, never widens). The agent grant-ceiling lint (D10 rule 2) is a follow-up — the runtime intersection already caps the agent, and the lint needs an agent-set designation convention that does not yet exist. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Contributor
📓 Docs Drift CheckThis PR changes 3 package(s): 13 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
os-zhuang
added a commit
that referenced
this pull request
Jul 11, 2026
…e ceiling) (#2843) Wires the PRODUCER side of the D10 intersection that shipped in #2838, so it stops being dormant. An MCP request authenticated with an OAuth access token is now resolved as an AI agent acting ON BEHALF OF the human `sub`, and its effective permission is the intersection of a scope-derived capability ceiling AND the user's own grants. - resolve-execution-context (producer): a verified MCP OAuth token that names an authorized client (`azp`) → principalKind:'agent', onBehalfOf:{userId}, and the agent's OWN grants become the scope-derived ceiling (data:read→read-only, data:write→CRUD, neither→no data). userId stays the human (owner/RLS scope); user systemPermissions cleared so a cap-gated action can't ride the user's capabilities. No client → human. - plugin-security: three built-in ceiling sets (mcp_agent_data_read / _write / _restricted) — pure CRUD bits, NO row-level security (all row/owner/tenant narrowing comes from the delegating user on the other side of the intersection). An agent skips the additive human baseline (member_default) and its fallback is the restricted (no-object-access) set, so a mis-resolved agent fails CLOSED, never open. - spec: MCP_AGENT_PERMISSION_SET_* + scopesToAgentPermissionSets(), single-sourced beside the OAuth scope constants; api-surface regenerated. Behaviour change (a security tightening): an MCP OAuth request previously executed with the FULL authority of the logged-in user (scopes narrowed only the tool surface). Now the scope is also a real data-layer ceiling — a data:read token can never write ANY record, even via a crafted call, regardless of what the user could do. Strictly consistent with "a scope can never grant more than the user could do" (the intersection only narrows), closing the confused/compromised-agent gap. Tests: producer mapping unit-tested in runtime (resolve-execution-context, 491 green incl. mcp-oauth); enforcement dogfooded against the served engine (showcase-agent-scope-ceiling — data:read reads but cannot write/create; data:write for the same user can). plugin-security 298, plugin-sharing 76, D10/OWD/permission dogfood unchanged. api-surface check + tsc clean. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
os-zhuang
added a commit
that referenced
this pull request
Jul 12, 2026
) The agent docs described MCP OAuth as "runs as you / scopes narrow tool families" — inaccurate after the ADR-0090 D10 agent work shipped (#2838/#2843/#2845). An OAuth client is now an agent acting ON BEHALF OF the user, bounded by the INTERSECTION of the consent scopes and the user's own permissions/RLS, and scopes are a real data-layer ceiling (a data:read token can never write, even where the user could) — not just a tool-family filter. - packages/mcp/src/skill.ts (SKILL.md served to every connecting agent): fix "runs as you" → agent-on-behalf + scope-ceiling intersection. - content/docs/ai/agents.mdx: same correction, user-facing. - docs/design/permission-model.md: mark the overlap rule *enforced* (#2838/#2843/#2845); honestly mark the three still-PLANNED agent guardrails — the grant-ceiling *lint* (runtime intersection already caps it; the bind-time lint is #2849), destructive-action co-sign, and the double-signature audit provenance (writer stamps only the delegator today). - docs/adr/0090: status note that D10 evaluation semantics landed. mcp suite 66 green. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
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.
What
Implements the ADR-0090 D10 "agent intersection" runtime. When a request's principal acts
onBehalfOfa user (an AI agent, or a service acting for a person), the effective permission is now the intersection of the principal's own grants and the delegator's grants — never the union. Confused-deputy prevention: an over-privileged agent can never see or touch anything the user it stands in for could not, and vice-versa.Until now
principalKind:'agent'/onBehalfOfwas a P1 context shape the evaluator never read — this wires the semantics in.How
The intersection is applied at every enforcement axis, gated on the presence of the delegation link so the ordinary (non-delegated) path is byte-identical —
delegatorSetsstaysnulland every combine reduces to today's expression.plugin-securitymiddleware — the delegator's effective permission sets are reconstructed once (via the sharedbuildContextForUser) and AND-composed into:usingpre-image on by-id writes (target row must satisfy both filters)checkpost-image (new/changed row must satisfy both checks)where)Fail-closed on a dangling link: a deleted delegator is denied, not resolved — the additive
member_defaultbaseline would otherwise resurrect access for a non-existent user, so thesys_userexistence check is the only correct fail-closed point.plugin-sharingmiddleware — the OWD / record-share owner-match is identity-scoped, so it re-runs the visibility filter (andcanEdit) under the delegator's own identity + depth and AND-s it in. This is the crux the dogfood surfaced: narrowing the depth stash alone would wrongly scope the agent's identity to the delegator's depth (owner_id = agentId), hiding the very rows the delegator legitimately owns. Now an agent with View-All acting for a plain member sees exactly that member's rows — not everyone's, and not nothing.explain engine — every layer reports the narrower verdict when
onBehalfOfis set (so the D6 access explanation stays truthful for delegated principals); a dangling delegator reads as a fail-closed deny.Delegator resolution + the pure combinators (
resolveDelegatorContext,intersectFieldMasks,narrowerScope) are single-sourced inexplain-engine.tsand reused by both the enforcement middleware and the explanation, so enforcement and its explanation cannot drift.Tests
security-plugin.test.ts→ "ADR-0090 D10 agent intersection") driving the real registered middleware with in-memoryql/metadata: CRUD both-ways + symmetry, capability gate, read-filter AND, non-delegated byte-identity, View-All stripped, FLS mask intersection, per-identity depth stash, fail-closed dangling link, explain attribution.showcase-agent-intersection.dogfood.test.ts) against the real RLS compiler + SQLite: an agent holdingshowcase_auditor(View-All) reads another user's private note alone, but acting on behalf of a plain member the intersection strips View-All (hidden) yet still surfaces the delegator's own note — plus fail-closed + explain.Green:
plugin-security297,plugin-sharing76, and the permission/OWD/sharing dogfood suites unchanged.First-cut scope (documented in code)
onBehalfOfshape carries a single delegator; any single-hop intersection is a safe lower bound on a true multi-hop chain (each hop only narrows), so this never escalates.tenantId,org_user_ids,email) are inherited from the live principal; person-specific membership bags left unresolved narrow, never widen.onBehalfOfin production yet, so this lands dormant until the MCP/agent dispatch path populates it. The change is safe to ship ahead of the producer.🤖 Generated with Claude Code