Skip to content

feat(security): ADR-0090 D10 — agent/service intersection runtime - #2838

Merged
os-zhuang merged 1 commit into
mainfrom
feat/d10-agent-intersection
Jul 11, 2026
Merged

feat(security): ADR-0090 D10 — agent/service intersection runtime#2838
os-zhuang merged 1 commit into
mainfrom
feat/d10-agent-intersection

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

What

Implements the ADR-0090 D10 "agent intersection" runtime. 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 can never see or touch anything the user it stands in for could not, and vice-versa.

Until now principalKind:'agent' / onBehalfOf was 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-identicaldelegatorSets stays null and every combine reduces to today's expression.

plugin-security middleware — the delegator's effective permission sets are reconstructed once (via the shared buildContextForUser) and AND-composed into:

  • required-capability gate (both principals must hold every required cap)
  • object CRUD (both must grant the op)
  • FLS — read mask, write-forbid, and the predicate-oracle guard all use the intersected field map
  • row-level using pre-image on by-id writes (target row must satisfy both filters)
  • check post-image (new/changed row must satisfy both checks)
  • RLS read-filter injection (both narrow the where)
  • View/Modify-All survives only when both principals hold it

Fail-closed on a dangling link: a deleted delegator is denied, not resolved — the additive member_default baseline would otherwise resurrect access for a non-existent user, so the sys_user existence check is the only correct fail-closed point.

plugin-sharing middleware — 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. 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 onBehalfOf is 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 in explain-engine.ts and reused by both the enforcement middleware and the explanation, so enforcement and its explanation cannot drift.

Tests

  • 11 unit tests (security-plugin.test.ts → "ADR-0090 D10 agent intersection") driving the real registered middleware with in-memory ql/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.
  • served-engine dogfood (showcase-agent-intersection.dogfood.test.ts) against the real RLS compiler + SQLite: an agent holding showcase_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)

  • One delegation hop — the onBehalfOf shape 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.
  • Tenant-scoped substitution bags (tenantId, org_user_ids, email) are inherited from the live principal; person-specific membership bags left unresolved narrow, never widen.
  • The agent grant-ceiling lint (D10 rule 2) is a follow-up — the runtime intersection already caps the agent regardless of what its own sets carry, and the lint needs an agent-set designation convention that does not yet exist.
  • Producer seam: nothing sets onBehalfOf in 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

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>
@vercel

vercelBot commented Jul 11, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

ProjectDeploymentActionsUpdated (UTC)
specReadyReadyPreview, CommentJul 11, 2026 1:41pm

Request Review

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling size/l labels Jul 11, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 3 package(s): @objectstack/dogfood, @objectstack/plugin-security, @objectstack/plugin-sharing.

13 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/getting-started/cli.mdx(via @objectstack/plugin-security)
  • content/docs/permissions/access-recipes.mdx(via packages/plugins/plugin-security)
  • content/docs/permissions/authorization.mdx(via packages/dogfood, packages/plugins/plugin-security, packages/plugins/plugin-sharing)
  • content/docs/permissions/delegated-administration.mdx(via packages/dogfood)
  • content/docs/permissions/explain.mdx(via @objectstack/plugin-security)
  • content/docs/permissions/permissions-matrix.mdx(via packages/plugins/plugin-security, packages/plugins/plugin-sharing)
  • content/docs/permissions/sharing-rules.mdx(via @objectstack/plugin-security)
  • content/docs/plugins/index.mdx(via @objectstack/plugin-security)
  • content/docs/plugins/packages.mdx(via @objectstack/plugin-security, @objectstack/plugin-sharing)
  • content/docs/protocol/objectql/security.mdx(via packages/plugins/plugin-sharing)
  • content/docs/releases/implementation-status.mdx(via @objectstack/plugin-security)
  • content/docs/ui/audience-based-interfaces.mdx(via packages/plugins/plugin-security)
  • content/docs/ui/dashboards.mdx(via @objectstack/plugin-security)

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

@os-zhuang
os-zhuang merged commit f70eb2c into mainJul 11, 2026
17 checks passed
@os-zhuang
os-zhuang deleted the feat/d10-agent-intersection branch July 11, 2026 13:45
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>
@github-actionsgithub-actionsBot mentioned this pull request Jul 11, 2026
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>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@os-zhuang