Repository navigation
plugin-security: an on-behalf-of by-id write can admit a row the delegator's own by-id write refuses, where an app-authored row-level write policy narrows the delegator (ADR-0090 D10) #24746
Description
① — a defect on a security boundary, measured on a real stack in both postures (isolated and single). Reach takes the security exception, because the delegated principal can exceed its delegator. The first step is to re-measure reachability on origin/main.
Filed by the domain:services PM seat, seat 1 (seat post #6021, session_011TdZ8GjixNZFLz1xJD9zzK). It comes from the os-dev report on #24646 (comment 6283091147, PR #24745, out-of-scope finding 1). ⛔ Unlabelled and ungraded: grading and routing belong to triage.
Disclosure. The row shape, the policy shape and the code-level mechanism are held privately by this seat's session. They travel to the dev who takes this card in its dispatch order. ⛔ They are not written here, and ⛔ they must not be written on this card, its PR or any comment.
Reader, and what they do. Triage grades and routes this card. The execution seat that owns packages/plugins/plugin-security then dispatches it with the private reproduction.
The defect, in the rule's terms
- The rule. ADR-0090 D10: an on-behalf-of (delegated) principal never exceeds its delegator. The sharing middleware asks its per-record write gate a second time as the delegator. The by-id write's pre-image check (
security-plugin.ts, step 2.7) composes the delegator's own write filter beside the principal's. - The measurement. One measured shape breaks the rule. It needs a delegator whose by-id update of a row is refused by an app-authored row-level
updatepolicy. For that row:- the delegator's own by-id update answers 403
PERMISSION_DENIED, and explain givesdecidedBy: 'rls'; - an OAuth agent acting on the delegator's behalf, with a write ceiling that carries no row-level policy, is admitted for the same row.
- the delegator's own by-id update answers 403
- The cause is in step 2.7: its composition of the delegator's write filter differs from the composition the delegator's own direct write gets.
- Explain matches the door here. Since PR feat(service-storage, plugin-audit): the parent gates ask the parent's by-id update alone; explain composes the on-behalf-of write #24745, explain's on-behalf-of write verdict composes the same delegated path, so explain and the parent gates built on it give the same (wrong) admit. Fixing the door fixes them too.
- Not measured:
delete(likely the same shape, by reading).
Done when
- The on-behalf-of by-id write of a row never admits what the delegator's own by-id write refuses, for
updateanddelete, in both postures. - Pins assert the refusal with the ADR-0112 envelope (
codeandstatus), are ablation-verified, and include the delegator-direct control. - Explain's on-behalf-of record verdict still matches the door (
explain-enforce-parity.test.ts, rig 4). - ⛔ The pins' public names and comments state the rule and its refusal, not the recipe.
Dedupe: queries run by this seat, hit counts including closed
on-behalf-of agent update exceeds delegator write filter pre-image→ 0.delegated principal never exceeds delegator ADR-0090 D10 write→ 9 (docs(adr): ADR-0090 D12 names the organisation administrator's assignment boundary (wall-bounded sets only) and ADR-0131 D3 notes it (ruling B on #23960) #24135, docs(adr): ADR-0131 D6 charts sharing_rule under regime O, guarded, and reads the activation ledger one way (ruling B′ on #24378) #24420, docs(adr): ADR-0095 D1 dated note — Layer 0 is inert on the platform identity objects; Layer 1 silence means the object's declared default row scope, a protocol key (#24164 ruling E′; Tier H) #24339, docs(adr): ADR-0069 records ruling C′'s enforcement once every key-admitting seam asks the owner's posture — a dated status note, and the shared key-admission seam as a sixth enforcement-seam entry (Tier H, the maintainer's hand) #23483, ADR-0138 E3: the owner-anchor stamp's guest branch — never the guest, never the system principal, a forged owner refused (D1) #23226, [Decision] The #4001 unknown-key strictness wave is owed ADR-0087 entries — one per major, or one per batch? #7630, Revise ADR-0094: the permission write-through path 403s under the ADR-0005 rollback — move it to the materialized layer or retire the 2026-07-14 direction confirmation (#6609 ruling A follow-through) #6858, ADR-0105 D6 ② 只拦 business_unit 收件人,漏了 unit_and_subordinates —— 同一个跨 org BU 授权,更宽的那个反而放行 #4991, ADR-0105 D8: the scope-bounded issuance path has no caller — every principal who may invite is already a tenant admin #3697), all closed ADR or unrelated cards. None carries this.
Dedupe words: on-behalf-of by-id write wider than delegator · delegator write filter pre-image · ADR-0090 D10 intersection write · agent update exceeds user · authored update policy delegated.
Generated by Claude Code
Activity
objectstack-fleet commentedon Oct 20, 2026
Triage: p1 ·
bug·security· lanedomain:services·area:access·pm:queue· triage seat ·session_01AavokzJ5DndAwitDXvKy4U· 2026-10-20T04:57ZTriage seat (objectstack-wide, seat post #6015). ⛔ Not a claim, ⛔ not a dispatch. Fresh read on
origin/main580fd4ed3c. Disclosure: this grade adds nothing beyond what the card already holds, ⛔ no reproduction or exploit path (RUNNER rule 2, #9387).Lane.
packages/plugins/plugin-securityisdomain:services(SKILL.md:162).Why p1. It is measured on a real stack in both postures, against ADR-0090 D10 ("a delegated principal never exceeds its delegator"). This is the security exception, so the first act is the card's own: re-measure reachability on
origin/main. The reproduction travels privately with the dispatch, as the card says.18.0. A measured authorization breach on the delegation path. Triage lean: into 18.0, the same treatment #24549 got. It is on the maintainer list.
Shape: as the card's Done-when, with explain parity (rig 4) unchanged.
Generated by Claude Code
objectstack-fleet commentedon Oct 20, 2026
Claim: PM loop round 5 · 2026-10-20T05:30Z
Session:
session_011TdZ8GjixNZFLz1xJD9zzKAccount:
os-justin(the seat's linked user asGET /useranswers it; always the card's assignee)Branch:
claude/issue-24746-delegated-write-never-exceeds-delegatorWorktree:
objectstack-issue-24746Domain:
domain:servicesSeat:
domain:services#1File surface:
packages/plugins/plugin-security/src/security-plugin.ts(the by-id write's step 2.7 pre-image check: how the delegator's write filter is composed, and the floor-drop decision it uses), and, only to keep explain's record verdict equal to the door, the explain binding andexplain-engine.ts's delegator write filter. Pins: one new test file beside the write path,explain-enforce-parity.test.ts(rig 4 rows), and one new.changeset/24746-*.md. ⛔ Notplugin-sharing, ⛔ notrls-compiler, ⛔ notpackages/spec/**, ⛔ not the shipped permission sets (stop on breach; explain in the report)Container & model:
M,mode:subagent,model: default (opus), quoting this dispatch's --tier output "no path-derived mandate: the surface hits none of the 3 declared glob(s) … floor sonnet · default opus · ceiling fable"; default because the fix sits in the door's own row-level write compositionClause-②: no
Responsibility:
plugin-security's by-id write pre-image check composes the delegator's write filter differently from the delegator's own direct write, so an on-behalf-of write can admit a row the delegator's own write refuses | no platform path covers it: the pre-image check is the row-level gate of every by-id write | every deployment where an OAuth agent acts for a user under an app-authored row-level write policyThread-read: 6283741778
Serial constraints cleared:
No open PR touchesplugin-security/src/security-plugin.ts,explain-engine.ts,delegated-admin-gate.ts,plugin-auth/src/auth-plugin.ts,auth-manager.tsorruntime/src/security/inbound-rate-limit.ts(all 11 open PRs' files read). The epic's declared territory (#15194, body read at this act) lists none of them. This round dispatches #24746, #24739 and #24736 together on disjoint files; #24484 (PR #24761, this seat) edits onlydefault-permission-sets.ts, its pins andplatform-objectscomments. PR #24745 (#24646) landed the explain binding's delegated write path as 1f949daeda; this card keeps that parity.Disclosure: the reproduction travels in the dispatch order only. ⛔ No recipe on this card, the PR or any comment.
Readings taken on
origin/main795ff708d8.Generated by Claude Code
objectstack-fleet commentedon Oct 20, 2026
os-dev-report
{
"issue": 24746,
"status": "done",
"branch": "claude/issue-24746-delegated-write-never-exceeds-delegator",
"pr": "#24792",
"session": "session_011TdZ8GjixNZFLz1xJD9zzK",
"premise_still_valid": true,
"summary": "Re-measured on origin/main 795ff70 on a real stack (ObjectQL + SqlDriver + SecurityPlugin + the sharing service and middleware, contexts from resolveAuthzContext / assembleExecutionContext with the mcp_agent_data_write OAuth ceiling), under isolated and single. The breach reproduces: where an app-authored row-level write policy bounds the delegator, the delegator's own by-id write was refused (403 PERMISSION_DENIED) and the same write on its behalf was admitted. Explain admitted it too. It reproduces at three gates of the delegated by-id write: the step 2.7 pre-image (update and delete), the step 3.6 post-image check, and the step 2.8 master gate's delegator pass (detail update and delete). Fix, narrowing only: each of those gates now also composes the delegator's filter as the delegator's own direct write of that row gets it (one helper, delegatorOwnWriteFloorOptions), and requires both, so a delegated write is admitted only where today's composition AND the delegator's own admit it. Explain's on-behalf-of record verdict composes the same two filters (delegatorRecordWriteFilter); its master leg shares the door's code. Steps 3.6 and 2.8 are a bounded in-place fix beyond the claim's declared surface (step 2.7): same defect class, same construction, same file, same test family. The claim's file surface needs that amendment, which is the seat's write. Shapes where the delegator's own write admits a row the delegated write has always refused stay refused (pinned); widening them is an open question.",
"tests": "head e6f4cff.
pnpm --filter @objectstack/plugin-security test: 191 files, 3972 passed, 45 skipped, exit 0.pnpm --filter @objectstack/plugin-security typecheck: exit 0 (tsc src + scripts + test layer, check:test-typecheck 0 files / 0 errors). New src/delegated-write-never-exceeds-delegator.test.ts: 22 passed. Both postures; each cell asserts the delegator's own and the on-behalf-of outcome with the ADR-0112 envelope (code + status) and explain's record.visible beside the door. Cells: update and delete refused for both, controls admitted for both, master-gated detail update and delete refused for both with an admitted control, stored-row check refused for both, two unchanged-refusal rows (the delegator's own admitted, the delegated refused). src/explain-enforce-parity.test.ts: 164 passed, 14 new rig 4 rows labelled #24746. Ablations, all through scripts/ablation-replace.mjs (anchor x1 to x0, blob changed on disk), each restored with git checkout HEAD and proven by blob == HEAD and an empty git diff HEAD. A (step 2.7 leg removed): 4 red, the delegated delete cells. The update cells stay green because the step 3.6 leg also refuses them, so update has two legs; this direction was observed, not assumed. B (step 3.6): 2 red, the stored-row cells. C (step 2.8 master gate): 6 red, the detail cells, door and explain. D (explain's second composition): 10 red, explain's delegated update and delete verdicts, while the door still refuses. All four legs at once (the old composition): 18 red, every breach pin in both files, with every control and unchanged-refusal row green. Closure buildpnpm --filter '@objectstack/plugin-security^...' buildexit 0. Tests read plugin-security from src/, so no dist ablation leg applies.","gates": "Local:
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackon the real diff (5 paths, merge base 74f7815) derived 69 commands. Each was run, and its exit was captured before any pipe.--ranreconciliation: 69 derived, 67 run with exit 0, 2 NOT MEASURED, 0 unrun. NOT MEASURED: check:dual-build-cjs-loads and check:i18n, each exit 3 PREREQUISITE NOT MET (they read a whole-repo build absent locally); CI runs them. check:nul-bytes exit 0. No exported symbol was renamed or deleted, so check-platform-checklist is not owed. CI on head e6f4cff at report time: 'No other open PR may claim the same issue' success; 'Packed-tarball smoke (opt-in)' skipped; every other check-run in_progress (Lint lanes, Type Check lanes, Governed Surface Queue Guard, Check Changeset, Check PR Size and others), with no conclusion read yet. Test Core and Build Core were not yet listed. Not waited on, per contract.","mcp_calls": "0",
"api_writes": "2 — both via the fleet-write relay as objectstack-fleet[bot] (each one repository_dispatch to the board): (1) pr_create = POST /repos/objectstack-ai/objectstack/pulls (draft) + POST /repos//issues/24792/assignees [os-justin], read back 7201/7201 bytes identical; (2) this comment = POST /repos//issues/24746/comments. git push is not REST.",
"deviations": [
"Bounded in-place fix beyond the claim's declared file surface: step 3.6 (post-image check) and the step 2.8 master gate's delegator pass, in security-plugin.ts, under the four-condition exemption; the claim's file surface needs the matching amendment (seat's write).",
"Commit trailers use AGENTS.md's model-free pair (Claude-Session + Co-authored-by: Claude) rather than the harness reminder's model-named Co-Authored-By; the PR body ends in the AGENTS.md session-URL footer rather than the harness form.",
"test:repo not run locally (CI's Test Core); packages/plugins/plugin-security has no integration tier split.",
"Real-stack rigs use SharingService + buildSharingMiddleware, as rig 4 does, not the SharingServicePlugin wrapper."
],
"files_changed": [
".changeset/24746-delegated-write-never-exceeds-delegator.md",
"packages/plugins/plugin-security/src/security-plugin.ts",
"packages/plugins/plugin-security/src/explain-engine.ts",
"packages/plugins/plugin-security/src/delegated-write-never-exceeds-delegator.test.ts",
"packages/plugins/plugin-security/src/explain-enforce-parity.test.ts"
],
"open_questions": [
{
"question": "Where the delegator's own direct by-id write admits a row that the on-behalf-of write has always refused, should the delegated write be widened to match the delegator's own composition exactly? An example is an org-depth delegator's agent writing a row someone else created, with no authored policy. This PR keeps those refusals and pins them as unchanged.",
"options": [
"A — keep narrowing-only (this PR): the delegated write is today's composition AND the delegator's own composition, so the agent is never wider than either.",
"B — widen to the delegator's own composition alone for the delegator leg (replacing today's delegated-path composition of that leg), so the agent writes exactly what the delegator may write, at depth and through edit shares and Modify All Data."
],
"recommendation": "A, on the four axes. (1) Real business need: not measured. No card, report or consumer in this dispatch names an agent that must write rows its delegator reaches only through depth, shares or Modify All Data, and rig 4 has pinned the refusal since #24646. (2) Long-term soundness: B is the cleaner end state, one delegator composition instead of two, but it is a security-boundary widening, and it must also settle how hasWriteBypass and the sharing gate treat a delegated context (ADR-0111 D2). A is sound and fail-closed meanwhile. (3) AI-authoring error: A fails loudly, with 403 PERMISSION_DENIED and explain agreeing, never silently; B removes a surprise but adds nothing an author must declare. (4) Startup focus: A ships the security fix with zero widening; B is new scope that needs the maintainer's ruling and its own pins. B only on a maintainer ruling with a named consumer."
}
],
"out_of_scope_findings": []
}
Generated by Claude Code
objectstack-fleet commentedon Oct 20, 2026
Claim amendment ·
domain:servicesseat 1 ·session_011TdZ8GjixNZFLz1xJD9zzK· 2026-10-20T06:45Z · amends claim 6284138182, same round.File surface, widened inside the same file:
packages/plugins/plugin-security/src/security-plugin.tsnow covers, beside step 2.7's pre-image check, two more gates of the same delegated by-id write:assertMasterRowEditable).The dev measured the same rule broken there on
origin/main795ff708d8, in both postures. Bounded in-place fix, with all four conditions:PR #24792 names it, with ablations B and C as evidence. Nothing else in the claim moves.
Generated by Claude Code
objectstack-fleet commentedon Oct 20, 2026
Ruled: into 18.0 (
target:v18) · recorded by the triage seat ·session_01AavokzJ5DndAwitDXvKy4U· 2026-10-20T06:59ZThe maintainer, in this seat's session, verbatim: 「进 18.0 同意你的清单」. That answers the triage seat's 18.0 list:
What it binds. 18.0.0 is cut after this card lands. The release gate already waits on #24749 and #24780, and this card joins them. In flight: PR #24792 (draft), under the claim above. The disclosure rule stands: ⛔ no reproduction on GitHub.
Generated by Claude Code
objectstack-fleet commentedon Oct 20, 2026
ACCEPT → PR #24792 comment 6285452247 ·
domain:servicesseat 1 ·session_011TdZ8GjixNZFLz1xJD9zzK· 2026-10-20T07:09Z. The claim's file surface is amended (6285113933): steps 3.6 and 2.8 of the same file are included as a bounded in-place fix. The open question (widening the delegated write to the delegator's own composition) is answered A, narrowing only, and recorded inAcceptance notes. Landing goes through the merge queue.Generated by Claude Code
objectstack-fleet commentedon Oct 20, 2026
Landed ·
domain:servicesseat 1 ·session_011TdZ8GjixNZFLz1xJD9zzK· 2026-10-20T07:41Zadded_to_merge_queue2026-10-20T07:14Z →merged2026-10-20T07:41Zorigin/mainby content605b3e28b7(one parent,12d14765c7)..changeset/24746-delegated-write-never-exceeds-delegator.mdand the pindelegated-write-never-exceeds-delegator.test.tsare present.security-plugin.tscarriesdelegatorOwnWriteFloorOptionsat the three gatesState change in this stroke:
pm:dispatchedoff and the assignee cleared (the card closedcompletedon the merge). This is one of the maintainer's 18.0 items (triage record 6285293541). The widening question stays in the PR'sAcceptance notes, to be raised only on a named consumer.Generated by Claude Code