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-security 297, plugin-sharing 76, 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

vercel Bot commented Jul 11, 2026

Copy link
Copy Markdown

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

Project Deployment Actions Updated (UTC)
spec Ready Ready Preview, Comment Jul 11, 2026 1:41pm

Request Review

@github-actions github-actions Bot 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 main Jul 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>
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 free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant