Skip to content

fix(plugin-security)!: a delegate can neither capture nor evict a direct grant beyond its scope - #31634

Merged
objectstack-fleet[bot] merged 10 commits into
mainfrom
claude/sec-delegated-grant-repoint-both-holders
Nov 8, 2026
Merged

objectstack-fleet[bot] merged 10 commits into
mainfrom
claude/sec-delegated-grant-repoint-both-holders

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Nov 8, 2026 •

Copy link
Copy Markdown
Contributor

Clause-②: no (narrowing) — an edit that points a direct permission-set grant at another user is judged on both holders it touches, the user it takes the grant from and the user it gives the grant to, and an edit that re-names a stored grant's set is judged on the set it takes away as well as the set it grants, so a delegated administrator no longer moves a grant away from a holder outside its subtree or from a holder it cannot read, nor re-names a stored grant of a set outside its allowlist. Nothing is widened: no export, error code or status is added.
Metadata-protocol: none
Skills-docs: updated here — content/docs/permissions/delegated-administration.mdx states it in two rows ("Subtree anchoring" for the holders, "Allowlist, no self-escalation" for the set); no skills/** file teaches editing a direct grant (skills/objectstack-data/rules/security.md says a delegate writes a grant only "for that set and user", which stays true).

维护者速读

  • 改了什么:委派管理员(adminScope)编辑一条已存的直接授权(sys_user_permission_set)时,委派门现在判断这次编辑碰到的每个持有人和每个权限集:改指向另一个用户时,原持有人和新持有人两端都要在委派子树内且调用者读得到;改名为另一个权限集时,被拿走的原权限集也按「删除这条授权」的标准判断(白名单、安全目录、严格包含)。任一不过即拒。
  • 为什么改:落实规则「a delegate can neither capture nor evict a direct grant beyond its scope」(ADR-0090 D12),与同级的 sys_user_position 同时判断前像与新值的做法一致:删不掉的授权,也不能靠编辑挪走。属于安全加固,细节归 security#1 席。
  • 风险与回滚:收窄(BREAKING,patch 级,不新增错误码或状态):委派管理员不能再把子树外或读不到的持有人的授权改指给别人,也不能把白名单外权限集的授权改名为白名单内的;拒绝与对应的删除/越界答案相同(403 DELEGATION_SCOPE_VIOLATION)。租户级管理员照旧可改;组织管理员的权利本来就只有新建和删除、不含原地编辑,答案不变。回滚 = revert 本 PR。
  • 先看:① packages/plugins/plugin-security/src/delegated-admin-gate.ts 的 assertDirectGrantWrite(fromHolder 与 stored 两处判断及 refusedSet 的细节选取,风险 hunk)、directGrantSet / grantOwnSet;② delegated-admin-own-name-set.posture.test.ts 末尾两个三形态 describe;③ delegated-admin-gate.test.ts 末尾两个 describe。
  • 两分钟验证:pnpm --filter @objectstack/plugin-security exec vitest run --maxWorkers=2 src/delegated-admin-gate.test.ts src/delegated-admin-own-name-set.posture.test.ts,预期 Test Files 2 passed (2)、Tests 383 passed (383)。
  • 需要你做的:无,席位落地。

What changes

Rule. A delegate can neither capture nor evict a direct grant beyond its scope. A delegated write on a direct permission-set grant is judged on every holder and every set it touches (ADR-0090 D12, which scopes direct set grants to "inside the subtree" and allowlists what a delegate may hand out).

  • Holders — assertDirectGrantWrite (packages/plugins/plugin-security/src/delegated-admin-gate.ts): when an update changes the grant's holder, the stored holder is judged exactly as the holder the write names — through the caller's own read (targetUserUnits), inside the subtree only through one of its business units — with the same reason, class, sentence and details. Mirrors the sibling door, assertAssignmentWrite, whose docblock reads "Pre-image (update/delete) must also lie inside the subtree — a delegate can neither capture nor evict assignments beyond it." A stored grant that names no holder is moved only by a tenant-level administrator, as an unanchored assignment is modified only by one.
  • Sets — directGrantSet / the new grantOwnSet: when an update re-names the grant's permission_set, the set it takes away is judged exactly as the removal of that grant judges it — the allowlist, the security catalog and strict containment — with the same reasons, sentences and details (the refusal's permissionSet is the set it is about), beside the set it re-names the grant to, judged as before. directGrantSet's docblock says the stored set is judged too.
  • Each stored value is named in a refusal only when the caller's own read returned it at its stored value (ruling of record 6649064874, kept as written).
  • Unchanged: the judgment of the set an edit points a grant at; an edit that keeps both the holder and the set; an insert and a removal; tenant-level and organization administrators.
  • content/docs/permissions/delegated-administration.mdx: the "Subtree anchoring" row names both holders of a moved grant; the "Allowlist, no self-escalation" row names the set a re-name takes away.
  • .changeset/delegated-grant-move-judges-both-holders.md: patch, fix(plugin-security)!: a delegate can neither capture nor evict a direct grant beyond its scope, a rule-level BREAKING paragraph whose "Who moves" reads "A delegate can no longer move a direct grant away from a holder outside its subtree, nor re-name a stored grant of a set outside its allowlist.", and the ADR-0087 marker not-required (no-migration-prescription), spelled as in .changeset/delegated-unreadable-user-outside-subtree.md.

Docblocks of record touched — quoted. One sentence of judgesTargetUserThroughCallerRead's docblock ([ADR-0090 D12; ruling on #26059, Q3]) gains the new holder; directGrantSet's [ADR-0131 D4] docblock gains the stored set; the rest of every docblock is kept as written.

judgesTargetUserThroughCallerRead — before: "it holds for every target user the gate judges: the holder a direct-grant write names (an insert, an update pointing the grant at another user), the holder a stored grant keeps (a delete, an update that leaves user_id as stored), and the user the explain doors ask about"

After: "it holds for every target user the gate judges: the holder a direct-grant write names (an insert, an update pointing the grant at another user), the holder a stored grant keeps (a delete, an update that leaves user_id as stored), the holder an update pointing the grant at another user takes it from, and the user the explain doors ask about"

directGrantSet — added bullet: "The set an update re-names the grant AWAY from (stored) — the stored grant's own set, judged too, exactly as the removal of that grant judges it ({@link grantOwnSet}). [ADR-0090 D12] A re-name takes that set away from its holder, so a delegate can neither capture nor evict a direct grant beyond its scope: a grant it could not remove it cannot re-name either."

setNamed paragraph — before: "the grant's own name only when the caller's read returned it." After: "the grant's own name — the one a keep-set update or a removal judges, and the one a re-name takes away — only when the caller's read returned it."

Untouched and still governing the new branches: the inline [ADR-0090 D12; ruling on #26059, Q3] comment ("the refusal is one reason, one class, one sentence and one set of details for all of them") and [ruling of record 6649064874] Named only when the caller wrote or reads it.

Ruling #26059 Q3, the scope-violation class (ruling of record 6416233738, item 3, verbatim):

  1. server p2 — item 3, server half: the delegated-admin gate (delegated-admin-gate.ts:403–503, reasons :1136–1179) answers its own code (e.g. DELEGATION_SCOPE_VIOLATION, status 403, ADR-0112 ledger row), distinct from PERMISSION_DENIED.

A holder outside the subtree at the end an update takes the grant from is a target user outside the subtree, and a set an update takes away outside the allowlist is a set not in the allowlist, so both answer that class; a stored grant that names no holder, a stored set the catalog does not hold, and a stored set the caller's scope does not strictly contain are not the caller's own scope and keep PERMISSION_DENIED, as their removal does.

Tests

Named by the rule, on both rigs:

  • delegated-admin-gate.test.ts (fake engine), two new top-level describes. Holders (8 pins): a move FROM a holder outside TO one inside is refused for each of the four target states (no user, no membership, a unit outside, a unit inside the caller cannot read) with one answer; FROM an unreadable in-subtree holder; TO a holder outside, as before; the same answer whichever end is outside (class, code, status, message, details and the REST envelope); CONTROL: a move between two readable in-subtree holders is admitted at either end; both holders are asked of the caller's own read under the write's own context; a holderless stored grant is moved only by a tenant admin; CONTROL: a tenant-level administrator moves the grant whichever end is outside and asks the read nothing. Sets (6 pins): a re-name FROM a set outside the allowlist TO an allowlisted one is refused with the answer the removal of that grant gets; the removal's other refusals hold for a re-name (a set not strictly contained, a name the catalog does not hold); a caller that cannot read the stored set gets the same refusal without its name, as the removal does; a re-name TO a set outside, as before; CONTROL: a re-name between two allowlisted sets is admitted; CONTROL: a tenant-level administrator re-names freely.
  • delegated-admin-own-name-set.posture.test.ts (real ObjectQL + SQL driver + SecurityPlugin), in each of single, group, isolated. Holders (24 pins per posture): per subject state, on one rig the state boots once (every pin there is a refusal and re-reads both grants to show nothing moved) — a move FROM the subject is refused with the one literal answer, whole (status, code, class, message and details), at the gate and through the write door, and the grant stays; a move TO the subject, as before; the same answer whichever end; a delegate that cannot read the stored grant gets the same refusal without the stored holder's name (ruling of record 6649064874); CONTROL: an organization administrator gets the answer it always got at either end (its right creates and removes a grant, never edits one in place). Then, booting their own rigs: CONTROL moves between two readable in-subtree holders land at either end; CONTROL: a tenant-level administrator still moves the grant FROM and TO a holder outside, through the write door. Sets (5 pins per posture): a re-name FROM a set outside the allowlist is refused with the removal's answer, at the gate and through the write door, and the grant keeps its set; a delegate that cannot read the stored grant gets the same refusal without the set's name, as the removal does; a re-name TO a set outside, as before; CONTROL: a re-name between two allowlisted sets lands; CONTROL: a tenant-level administrator re-names FROM and TO a set outside the allowlist.

No posture pin boots more than two rigs; the heaviest one (eight boots) had timed out under CI load (Test timed out in 5000ms, Test Core 7/8 on 0dcdbb4712). No assertion was dropped: the cross-state "one answer" is each state's answer against the one literal answer, and the organization-administrator CONTROL now runs in all four states. The file's local wall fell from tests 37.93s to tests 26.32s with the set pins added.

Ablations (one behaviour each, committed first, node scripts/ablation-replace.mjs inside a trap restore, under the verify lock, on the final head 3890ac0699):

  • the stored-holder judgment switched off on disk (anchor 1 → 0, blob 3793552a → 1ff05ea9) → Tests 41 failed | 342 passed (383): exactly this rule's holder pins — 5 on the fake engine (FROM-end, unreadable holder, identity, both holders read, holderless) and 36 on the posture rig (FROM-end, identity, unnamed holder × 4 states × 3 postures); every CONTROL and every TO-end pin stayed green. Restored: blob == HEAD (3793552a), git diff HEAD empty.
  • the stored-set judgment switched off on disk (anchor 1 → 0, blob 3793552a → 1b94cfd1) → Tests 9 failed | 374 passed (383): exactly this rule's set pins — 3 on the fake engine (FROM-end, the removal's other refusals, unnamed set) and 6 on the posture rig (FROM-end, unnamed set × 3 postures); every CONTROL and every TO-end pin stayed green. Restored: blob == HEAD (3793552a), git diff HEAD empty.

Both suites reach the gate by relative imports from src/ (no dist/ on the path), so no rebuild sits between mutation and run.

Acceptance notes

  • Organization administrator control. The first-round order asked that an organization administrator can still re-point the grant. Measured: an organization administrator's right under ADR-0090 D12 (as amended 2026-10-17) creates and removes a grant and never edits one in place, so its update is refused before and after this change (403 PERMISSION_DENIED, "'update' is not part of the right"). The pins hold it unchanged; the tenant-level administrator moves and re-names as before.
  • Identity across ends. The answer is identical whichever end is outside when the caller reads the stored grant. A caller that cannot read it is not told the stored holder's id or the stored set's name (ruling of record 6649064874): class, code, status and details still match the other end or the removal; the sentence uses the unnamed form, as the keep-holder edit and the removal already do.
  • The set a re-name takes away is judged on all the removal judges, not the allowlist alone. "Exactly as the removal judges it" includes the catalog check (a name the catalog does not hold: only a tenant admin may change it) and strict containment (a set carrying an adminScope the caller's own does not strictly contain); both are pinned.
  • Holderless stored grant. user_id is required on sys_user_permission_set, so no write door stores one; the branch fails closed for a row a system write left so.
  • Concurrent work on plugin-security. feat(plugin-auth): a SCIM group push grants the ordinary permission set its group mapping names #31443 (the organization-boundary helper in delegated-admin-gate.ts) landed on main while this branch was in flight; this branch merges it cleanly.

Verification

Every reading below is on the final head 3890ac0699 (this branch merged with origin/main 69244406b7), unless named.

plugin-security test and typecheck
  • pnpm --filter @objectstack/plugin-security exec vitest run --maxWorkers=2 (whole package, under the verify lock): VERDICT command-exit 0, Test Files 250 passed (250), Tests 6025 passed | 45 skipped (6070).
  • pnpm --filter @objectstack/plugin-security run typecheck: VERDICT command-exit 0; check:test-typecheck: OK — … 0 file(s) / 0 error(s).
  • The two pinned files: delegated-admin-gate.test.ts Tests 190 passed (190); delegated-admin-own-name-set.posture.test.ts Tests 193 passed (193) (tests 30.33s on 3890ac0699, tests 26.32s on 38577d9ec8; it was tests 37.93s before the split).
Derived gate families (dispatch-gates --commands, reconciled with --ran)
  • 97 derived on 3890ac0699, after a full pnpm build ("Tasks: 119 successful, 119 total"); all 97 run, each exit code captured before any pipe: 97 × exit 0. check:type-check-debt first refused (exit 3: its closure build did not get a turn on the verify lock) and was re-run under the lock: "128/131 workspace packages type-checked", "--re-measure: OK — … 0 raw tsc error(s) total".
  • Changeset gates, all exit 0: check-adr-0087-registration --base origin/main (+ --self-test), check-changeset-no-major --base origin/main, check-empty-changeset --base origin/main (+ --self-test), check:changeset-gate-self-tests.
  • dispatch-gates --ran: "97 derived famil(ies) accounted for — 97 run, 0 NOT-MEASURED (a DERIVED zero — all 97 recorded an exit code and none of them is 3)", 0 UNRUN.
Dogfood battery (the narrowing arm's kit), on the final head
  • Full pnpm build, then pnpm --filter @objectstack/dogfood test under the verify lock, no shard and no path, head 3890ac0699: VERDICT command-exit 1, Test Files 1 failed | 439 passed | 3 skipped (443), Tests 1 failed | 3640 passed | 41 skipped (3682). The one failure: test/package-commit-revert-path-package.dogfood.test.ts › "an uninstalled package's commit is refused 404 through its own revert and rollback path, and writes nothing" — Test timed out in 5000ms (7127 ms), on a box running this union and other seats' builds beside it. Not this change's: the file exercises the package commit revert path (ADR-0067), which no line here reaches; it passed in the full battery on 38577d9ec8 (Test Files 440 passed | 3 skipped (443), Tests 3641 passed | 41 skipped (3682)), whose runtime source is byte-identical to 3890ac0699 (they differ in one plugin-security test file); and the same file re-run alone on 3890ac0699 under the lock passes, Tests 12 passed (12). CI's Dogfood Regression Gate on 3890ac0699 was satisfied by its path filter — no dogfood shard ran on this PR ("expected attestations: 0") — so the battery above is the only dogfood run on this head.
CI on the final head
  • 3890ac0699: 50 check-runs, 38 success, 12 skipped, none other — Lint & Repo Gates, TypeScript Type Check, Test Core (8/8 shards), Build Core, Governed Surface Queue Guard, Test UI, Live E2E UI, Dogfood Regression Gate (filter-satisfied; no shard ran), Check Changeset all success; the UI lint, type-check and build lanes skipped by their path filters.

…ect grant beyond its subtree

An update that points a direct permission-set grant at another user is
judged on both holders it touches: the user it gives the grant to and the
user it takes the grant from, each through the caller's own read, with the
same refusal (ADR-0090 D12).

Claude-Session: https://claude.ai/code/session_017xRBcT1w3zpekijraHJE4f
Co-authored-by: Claude <noreply@anthropic.com>
…ect grant beyond its subtree

Pins the rule on the fake engine and on the posture rig in the single,
group and isolated postures: a move of a stored direct grant is refused
when either holder is outside the subtree or unreadable to the caller,
with the same answer at either end; moves between two readable holders
inside the subtree, and a tenant-level administrator's moves, are
admitted; an organization administrator's answer is unchanged.

Claude-Session: https://claude.ai/code/session_017xRBcT1w3zpekijraHJE4f
Co-authored-by: Claude <noreply@anthropic.com>
…ect grant beyond its subtree

The changeset for the narrowing, and the delegated-administration page's
subtree-anchoring rule now names both holders of a moved direct grant.

Claude-Session: https://claude.ai/code/session_017xRBcT1w3zpekijraHJE4f
Co-authored-by: Claude <noreply@anthropic.com>
…grant-repoint-both-holders

Claude-Session: https://claude.ai/code/session_017xRBcT1w3zpekijraHJE4f
Co-authored-by: Claude <noreply@anthropic.com>
…grant-repoint-both-holders

Claude-Session: https://claude.ai/code/session_017xRBcT1w3zpekijraHJE4f
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet objectstack-fleet Bot added the domain:server Domain lane — seat card indexed by label:pm:seat label Nov 8, 2026
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface labels Nov 8, 2026
@github-actions

github-actions Bot commented Nov 8, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/plugin-security, touching 12 documentable anchor(s).

13 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/error-catalog.mdx (via user_id (literal, a string literal in storedHolderJudged))
  • content/docs/data-modeling/drivers.mdx (via user_id (literal, a string literal in storedHolderJudged))
  • content/docs/deployment/cli.mdx (via user_id (literal, a string literal in storedHolderJudged))
  • content/docs/deployment/validating-metadata.mdx (via permission_set (literal, a string literal in directGrantSet))
  • content/docs/kernel/runtime-services/audit-service.mdx (via user_id (literal, a string literal in storedHolderJudged))
  • content/docs/permissions/authentication.mdx (via user_id (literal, a string literal in storedHolderJudged))
  • content/docs/permissions/delegated-administration.mdx (via DelegatedAdminGate (symbol, a top-level class), setName (symbol, a field of interface GrantSetJudgment), user_id (literal, a string literal in storedHolderJudged))
  • content/docs/permissions/explain.mdx (via permission_set (literal, a string literal in directGrantSet))
  • content/docs/permissions/permission-sets.mdx (via permission_set (literal, a string literal in directGrantSet), user_id (literal, a string literal in storedHolderJudged))
  • content/docs/permissions/positions.mdx (via user_id (literal, a string literal in storedHolderJudged))
  • content/docs/permissions/record-view-auditing.mdx (via user_id (literal, a string literal in storedHolderJudged))
  • content/docs/permissions/system-context.mdx (via permission_set (literal, a string literal in directGrantSet), user_id (literal, a string literal in storedHolderJudged))
  • content/docs/protocol/kernel/config-resolution.mdx (via user_id (literal, a string literal in storedHolderJudged))

⛔ 3 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v13.mdx (via DelegatedAdminGate (symbol, a top-level class))
  • content/docs/releases/v17/17-5.mdx (via user_id (literal, a string literal in storedHolderJudged))
  • content/docs/releases/v18/18-0.mdx (via permission_set (literal, a string literal in directGrantSet))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 65 of 231 client-bound route-ledger rows — the other 166 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 166: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 59 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 107 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 17 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 06060c61c572f833f19497a6e84838c52a923166 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from c610d70032b04f5a3091e96bd39625e58e575df4 — the merge of head 3890ac0699464c3b5d72dfd7e19117e16e3a77be into base 06060c61c572f833f19497a6e84838c52a923166, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin c610d70032b04f5a3091e96bd39625e58e575df4 && git checkout c610d70032b04f5a3091e96bd39625e58e575df4
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 06060c61c572f833f19497a6e84838c52a923166 3890ac0699464c3b5d72dfd7e19117e16e3a77be && git checkout -B drift-repro 06060c61c572f833f19497a6e84838c52a923166 && git merge --no-ff 3890ac0699464c3b5d72dfd7e19117e16e3a77be

node scripts/docs-audit/affected-docs.mjs --json 06060c61c572f833f19497a6e84838c52a923166

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 06060c61c572f833f19497a6e84838c52a923166 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

…grant-repoint-both-holders

Claude-Session: https://claude.ai/code/session_017xRBcT1w3zpekijraHJE4f
Co-authored-by: Claude <noreply@anthropic.com>
…ect grant beyond its scope, on the set axis too

An update that re-names a stored direct grant's permission set is also
judged on the set it takes away, exactly as the removal of that grant
judges it: the same reasons, sentences and details, the stored set named
only when the caller reads it (ADR-0090 D12; ruling of record 6649064874).

Claude-Session: https://claude.ai/code/session_017xRBcT1w3zpekijraHJE4f
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/l and removed size/m labels Nov 8, 2026
…n the set it takes away, in three postures

Pins on the posture rig in the single, group and isolated postures: a
re-name FROM a set outside the allowlist is refused with the answer the
removal of that grant gets, at the gate and through the write door; a
delegate that cannot read the stored grant is told the same without the
set's name; a re-name TO a set outside is refused as before; re-names
between two allowlisted sets, and a tenant-level administrator's, land.

Claude-Session: https://claude.ai/code/session_017xRBcT1w3zpekijraHJE4f
Co-authored-by: Claude <noreply@anthropic.com>
…e, so no pin boots more than two

The refusal pins of the moved-grant describe move nothing, so each of
the four subject states boots one rig that serves them all (each pin
re-reads both grants); the CONTROLs that move a grant boot their own,
one end per pin. No assertion is dropped: the cross-state "one answer"
is now each state's answer against the one literal answer, and the
organization-administrator CONTROL runs in all four states. The heaviest
pin booted eight rigs and timed out under CI load.

The changeset and the delegated-administration page state the rule on
the set axis too: a delegate can neither capture nor evict a direct
grant beyond its scope.

Claude-Session: https://claude.ai/code/session_017xRBcT1w3zpekijraHJE4f
Co-authored-by: Claude <noreply@anthropic.com>
…nned whole, in every subject state

The FROM-end pin now holds the write door's answer to the one literal
answer in full (status, code, class, message and details), as the
earlier cross-state comparison held it, instead of three of its fields.

Claude-Session: https://claude.ai/code/session_017xRBcT1w3zpekijraHJE4f
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet objectstack-fleet Bot changed the title fix(plugin-security)!: a delegate can neither capture nor evict a direct grant beyond its subtree fix(plugin-security)!: a delegate can neither capture nor evict a direct grant beyond its scope Nov 8, 2026
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

security#1 · sunhaolin · session_017xRBcT1w3zpekijraHJE4f

席位核对

ACCEPT at Head-sha: 3890ac0699464c3b5d72dfd7e19117e16e3a77be (2026-11-08T21:54Z).

  • Shape: a no-card order from the security#1 seat, in two rounds. Round 2 folded the set axis of the same rule into the same function and split the slow posture pins. 5 files, +688 / −12, all inside the order's file surface. Not governed (check-governed-merges.mjs --pr 31634: 0 of 5 paths). Clause-②: no (narrowing), BREAKING, patch, ADR-0087 marker · Metadata-protocol: none · Skills-docs: updated here. The title carries !.
  • Rule: a delegate can neither capture nor evict a direct grant beyond its scope (ADR-0090 D12). It mirrors the sibling assignment door's pre-image judgment.
  • Premise measured:
    • In single, group and isolated, every edit that moves a stored grant or re-names its set answers what the matching removal or out-of-scope write answers. The answer is identical whichever end is outside, and it is unnamed when the caller cannot read the stored row (ruling of record 6649064874).
    • Controls are held: a readable in-subtree move, a re-name between two allowlisted sets, and a tenant-level administrator. The organization administrator's answer is unchanged.
    • Ablations: the stored-holder judgment reverted turns 41 tests red; the stored-set judgment reverted turns 9 red. Both restored, and the reviewer read both logs.
  • Gates:
    • All 50 check-runs on this head are success or skipped. The body-guard checks re-run after the seat's body patch.
    • Locally: 97 of 97 derived commands exit 0 (--ran: 0 NOT-MEASURED, 0 UNRUN).
    • Dogfood battery on the head: one unrelated load timeout. The same file re-runs green alone, and the full battery was green on the previous head, whose runtime source is byte-identical. CI's dogfood gate was path-filtered, and the body says so.
  • Review: the isolated contract-tier reviewer returned APPROVE-WITH-EDITS with no blocking objection. Its two body edits, both on the dogfood wording, are applied by the seat.
  • Accepted deviations:
    • The timeout fix is a split, not an explicit timeout, because no sibling posture suite sets one.
    • "Exactly as the removal judges it" is read as the removal's whole judgment.
    • The organization-administrator premise of round 1 is corrected in the body.
  • Landing: the seat lands it through the merge queue (pr_ready + automerge_enable).

Implemented-by: os-dev (security#1 order, session_017xRBcT1w3zpekijraHJE4f) · Reviewed-by: isolated contract-tier reviewer.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review November 8, 2026 21:54
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge November 8, 2026 21:54
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Nov 8, 2026
Merged via the queue into main with commit 18cdcb2 Nov 8, 2026
55 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/sec-delegated-grant-repoint-both-holders branch November 8, 2026 22:37
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 domain:server Domain lane — seat card indexed by label:pm:seat size/l tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants