Skip to content

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

Closed

Description

@objectstack-fleet

① — 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 update policy. For that row:
    • the delegator's own by-id update answers 403 PERMISSION_DENIED, and explain gives decidedBy: '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 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 update and delete, in both postures.
  • Pins assert the refusal with the ADR-0112 envelope (code and status), 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

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

  1. objectstack-fleet commented on Oct 20, 2026

    @objectstack-fleet
    ContributorAuthor

    Triage: p1 · bug · security · lane domain:services · area:access · pm:queue · triage seat · session_01AavokzJ5DndAwitDXvKy4U · 2026-10-20T04:57Z

    Triage seat (objectstack-wide, seat post #6015). ⛔ Not a claim, ⛔ not a dispatch. Fresh read on origin/main 580fd4ed3c. Disclosure: this grade adds nothing beyond what the card already holds, ⛔ no reproduction or exploit path (RUNNER rule 2, #9387).

    Lane. packages/plugins/plugin-security is domain: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

  2. objectstack-fleet commented on Oct 20, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 5 · 2026-10-20T05:30Z
    Session: session_011TdZ8GjixNZFLz1xJD9zzK
    Account: os-justin (the seat's linked user as GET /user answers it; always the card's assignee)
    Branch: claude/issue-24746-delegated-write-never-exceeds-delegator
    Worktree: objectstack-issue-24746
    Domain: domain:services
    Seat: domain:services#1
    File 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 and explain-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. ⛔ Not plugin-sharing, ⛔ not rls-compiler, ⛔ not packages/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 composition
    Clause-②: 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 policy
    Thread-read: 6283741778
    Serial constraints cleared: No open PR touches plugin-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 and platform-objects comments. 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/main 795ff708d8.


    Generated by Claude Code

  3. objectstack-fleet commented on Oct 20, 2026

    @objectstack-fleet
    ContributorAuthor

    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 build pnpm --filter '@objectstack/plugin-security^...' build exit 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/objectstack on the real diff (5 paths, merge base 74f7815) derived 69 commands. Each was run, and its exit was captured before any pipe. --ran reconciliation: 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

  4. objectstack-fleet commented on Oct 20, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim amendment · domain:services seat 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.ts now covers, beside step 2.7's pre-image check, two more gates of the same delegated by-id write:

    • step 3.6, the post-image check;
    • step 2.8, the master gate's delegator pass (assertMasterRowEditable).

    The dev measured the same rule broken there on origin/main 795ff708d8, in both postures. Bounded in-place fix, with all four conditions:

    • the same defect class;
    • one helper, the identical construction;
    • no other claimant on the file;
    • the same test family.

    PR #24792 names it, with ablations B and C as evidence. Nothing else in the claim moves.


    Generated by Claude Code

  5. objectstack-fleet commented on Oct 20, 2026

    @objectstack-fleet
    ContributorAuthor

    Ruled: into 18.0 (target:v18) · recorded by the triage seat · session_01AavokzJ5DndAwitDXvKy4U · 2026-10-20T06:59Z

    The 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

  6. objectstack-fleet commented on Oct 20, 2026

    @objectstack-fleet
    ContributorAuthor

    ACCEPT → PR #24792 comment 6285452247 · domain:services seat 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 in Acceptance notes. Landing goes through the merge queue.


    Generated by Claude Code

  7. objectstack-fleet commented on Oct 20, 2026

    @objectstack-fleet
    ContributorAuthor

    Landed · domain:services seat 1 · session_011TdZ8GjixNZFLz1xJD9zzK · 2026-10-20T07:41Z

    reading result
    PR #24792 timeline added_to_merge_queue 2026-10-20T07:14Z → merged 2026-10-20T07:41Z
    origin/main by content squash 605b3e28b7 (one parent, 12d14765c7). .changeset/24746-delegated-write-never-exceeds-delegator.md and the pin delegated-write-never-exceeds-delegator.test.ts are present. security-plugin.ts carries delegatorOwnWriteFloorOptions at the three gates
    closing references the PR body closes #24746 only

    State change in this stroke: pm:dispatched off and the assignee cleared (the card closed completed on the merge). This is one of the maintainer's 18.0 items (triage record 6285293541). The widening question stays in the PR's Acceptance notes, to be raised only on a named consumer.


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:accessPermissions that actually hold — RLS/FLS, sharing model, write-path guardsbugSomething isn't workingdomain:servicespriority:p1High: required for production / M2securitytarget:v18

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions