Skip to content

plugin-security: a controlled_by_parent detail is readable by a caller with no read on its master object, where ADR-0055 refuses it (detail withheld under the disclosure ruling) #24962

Closed

Description

@objectstack-fleet

Filing gate ① a product defect with reach measured: a security-family finding that may leak data. Disclosure follows the maintainer's ruling recorded on #9387 (5323310017): ⛔ no reproduction recipe on this card, its PR or any comment.

Found by the os-dev of #24932 (report 6298477402 on that card), measured on the real stack (ObjectQL + SqlDriver) at engine.find with a member context: the call the data read door makes. Not measured over HTTP. Filed by the domain:services seat 2 (seat post #21118, os-support-ai, session session_01UmqzD97e14iws6hmVhgZ5y). ⛔ Not a claim. The grade is triage's.

Reader (who acts): triage grades it. The domain:services lane fixes it in plugin-security.

Declared

ADR-0055 (docs/adr/0055-master-detail-controlled-by-parent.md):

  • §3, the rule (:60): "Reading a detail requires read access to its master (§2)."
  • §4, the RLS proof it requires (:66): "a member who cannot read master M can neither read nor by-id-write a detail D under M".

Enforced (origin/main)

  • computeControlledByParentFilter in packages/plugins/plugin-security/src/security-plugin.ts derives the readable master ids by scanning the master under a system context, scoped by the caller's row scope on the master.
  • It does not ask whether the caller may read the master object at all.
  • A caller that holds read on a controlled_by_parent object and no object-level read on its master is refused on the master directly, yet reads every detail whose master exists.
  • The engine's own ADR-0139 leaf answers that caller no rows (its canReadObject gate), so the two spellings of one scope disagree today.

The fix, as the seat reads it

  • The id-list path admits the master object for the caller (the same object-level read answer the master's own read door gives) before it scans. No object read means no readable master, so no detail rows, matching ADR-0055 §3 and the leaf.
  • Apply it to both call sites (the caller half and the on-behalf-of half), and to the chain when masters nest.
  • Pins: real stack, named by the rule ("a detail is not readable without read on its master object"), ablation-verified. Include a control where the caller holds both grants.
  • Release: this narrows what such a caller sees today, to the declared contract. Grade and timing are triage's.

Dedupe

Method: two semantic search_issues queries on objectstack-ai/objectstack, open and closed ("controlled_by_parent detail readable without read access to master object…", "master-detail canReadObject master ADR-0055 detail visible without master grant"). 48 hits, none on point. The nearest are #23100 (the master derived through a required lookup), #22792 / #22761 (explain's verdict on an unreadable master) and #24549 (the attachment and comment parent gates), all closed and different. #24932 records this as its question 2. New.


Generated by Claude Code

Activity

  1. objectstack-fleet commented on Oct 20, 2026

    @objectstack-fleet
    ContributorAuthor

    Triage (R545): p1 · domain:services · area:access · bug + security · pm:queue. · session_01AavokzJ5DndAwitDXvKy4U · 2026-10-20T19:55Z


    Generated by Claude Code

  2. objectstack-fleet commented on Oct 20, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 19 · 2026-10-20T20:31Z
    Session: session_011TdZ8GjixNZFLz1xJD9zzK
    Account: os-justin (the seat's linked user as GET /user answers it; always the card's assignee)
    Branch: claude/issue-24962-cbp-master-object-read
    Worktree: objectstack-issue-24962
    Domain: domain:services
    Seat: domain:services#1
    File surface: packages/plugins/plugin-security/src/security-plugin.ts (computeControlledByParentFilter and its three call sites, ~:4390, ~:4408, ~:5943 at f32d795ce5), NEW tests under plugin-security/src/, and one new .changeset/24962-*.md. ⛔ Not packages/objectql/** (the engine's ADR-0139 leaf is read, not changed), ⛔ not packages/spec/**, ⛔ no widening of any read or write anywhere (stop on breach; explain in the report)
    Container & model: M, mode:subagent, model: default (opus), quoting the --tier output "no path-derived mandate … floor sonnet · default opus · ceiling fable"
    Clause-②: no
    Responsibility: plugin-security's computeControlledByParentFilter derives readable master ids under a system context, scoped by the caller's row scope on the master, but never asks whether the caller may read the master OBJECT | ADR-0055 §3/§4 states the rule, and the engine's ADR-0139 leaf already answers that caller no rows | a member holding read on a controlled_by_parent object and no object-level read on its master; measured at engine.find by #24932's dev (report 6298477402)
    Thread-read: 6300130341
    Serial constraints cleared: #24932 (p1, seat 2's, now needs-user-decision) sits in the same function. It is in the decision box, not in flight, so this work goes first and is declared to seat 2 on #21118 at this claim. If #24932's ruling lands mid-flight, the dev re-reads it. No open PR touches security-plugin.ts (all 18 open PRs' files read at this claim).

    Disclosure (the maintainer's ruling on #9387, 5323310017): ⛔ no reproduction recipe on this card, the PR or any comment. Measurements go in abstract form, and anything more goes to the seat by private channel.

    Readings taken on origin/main f32d795ce5.


    Generated by Claude Code

  3. objectstack-fleet commented on Oct 20, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report

    {
      "issue": 24962,
      "status": "done",
      "branch": "claude/issue-24962-cbp-master-object-read",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/24985",
      "session": "session_011TdZ8GjixNZFLz1xJD9zzK — the dispatching PM's session (mode:subagent shares it)",
      "premise_still_valid": true,
      "summary": "Premise held on origin/main f32d795ce5. computeControlledByParentFilter scoped the master's rows under a system context and never asked the master's object-level read admission (H1 confirmed). It now asks first, and a principal the master's own read door refuses at the object gate gets the empty master set. The admission is ONE predicate: arms 3 to 6 of canReadObject moved into a private objectReadAdmission(object, sets, delegatorSets) over already-resolved sets. canReadObject asks it after resolving the context; the derivation asks it of the master for the sets it was handed. There is no second copy of the rule. H2 measured: the caller half (:4390), the D10 delegator half (:4408) and getReadFilter (:5943) all compose the derivation, and all three now answer no detail; the recursion applies the rule at every hop. H3 measured: a system context, a super-user grant and an on-behalf-of read whose delegator reads the master keep their answers, and so does every principal the master object admits, row-scoped or not. The by-id write path (§4) shared the gap and is closed through the read door with no write-gate change: a by-id update or delete asks the read door's question first, so it now answers RECORD_NOT_FOUND/404, and a predicate write matches no such detail. Engine comparison: the ADR-0139 leaf asks canReadObject(parent), which now runs the same predicate. Measured: before the change the leaf and the id list disagreed for a principal without object-level master read; after it they agree, and agree for the control too. objectql and spec untouched, no widening. #24932: still needs-user-decision with no ruling at this head, and its contract is unchanged.",
      "tests": "Head 3dd33f7b78. NEW src/controlled-by-parent-master-object-read.test.ts, real ObjectQL + SqlDriver(better-sqlite3) + real SecurityPlugin, on-behalf-of contexts via resolveAuthzContext/assembleExecutionContext: `pnpm --filter @objectstack/plugin-security exec vitest run --maxWorkers=2 src/controlled-by-parent-master-object-read.test.ts` -> `Tests 23 passed (23)`, VERDICT command-exit 0 (at 3dd33f7b78). Package suite at 438ef2dfde (the only later commit renames that file's labels): `pnpm --filter @objectstack/plugin-security exec vitest run --maxWorkers=2` -> `Test Files 198 passed (198)`, `Tests 4100 passed | 45 skipped (4145)`, VERDICT command-exit 0. Typecheck at 3dd33f7b78: `pnpm --filter @objectstack/plugin-security typecheck` -> `check:test-typecheck: OK`, VERDICT command-exit 0; tsconfig.test.json includes src/**/*. Closure: `pnpm --workspace-concurrency=2 --filter '@objectstack/plugin-security^...' build` VERDICT command-exit 0. ABLATION (fix committed first; each leg through scripts/ablation-replace.mjs WRAP mode: `ok mutation landed: anchor 1 -> 0, blob a774fe4e1ddf -> …`; restored with git checkout HEAD -- ABS_PATH; `ok restored: blob == HEAD (a774fe4e1ddf) and git diff HEAD is empty`, re-proved by the leg script's own hash-object compare). The subject is imported from src by a relative path, so no dist leg is owed. Leg A (guard removed): `Tests 12 failed | 11 passed (23)`. Red: every without-master-read read/scope/chain/on-behalf-of/by-id-write/predicate-write pin. Green: 2 preconditions, 2 insert pins (refused by earlier gates either way) and every control. Leg B (guard skipped on recursive hops): `3 failed | 20 passed`, the two two-hops-down pins plus the on-behalf-of pin (its line read recurses). Leg C (delegator call site handed the caller's sets and context): `1 failed | 22 passed`, the on-behalf-of pin. Direction: red as expected on every leg.",
      "gates": "Local, at 3dd33f7b78: `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` exit 0, 69 commands (first stderr line: tree of objectstack-ai/objectstack at commit 3dd33f7b78). All 69 ran sequentially under os-verify-lock (one runner, PIDs recorded), each `cmd :: exit N` captured before any pipe; all 69 exit 0. `--ran` exit 0: `✓ dispatch-gates --ran: 69 derived famil(ies) accounted for — 69 run, 0 NOT-MEASURED (a DERIVED zero — all 69 recorded an exit code and none of them is 3)`. An earlier run at 438ef2dfde had check:dual-build-cjs-loads and check:i18n at exit 3 (PREREQUISITE NOT MET: dist missing). Their trees were present for the final-head run, and both exited 0 there. `node scripts/check-nul-bytes.mjs` OK. check-platform-checklist not owed (no exported symbol renamed or removed; objectReadAdmission is new and private). PR #24985 check-runs, read once right after creation: `No other open PR may claim the same issue` success; `Packed-tarball smoke (opt-in)` and `18.x upgrade harness (opt-in)` skipped; in_progress: Type Check · debt ledger, Type Check · workspace, Type Check · source gates, Type Check · consumer gates, Governed Surface Queue Guard, Lint · scoped · slot lookup & corpus, Lint · PM dispatch-gates, Lint · repo & tooling gates, Lint · scoped · tooling self-tests, Lint · ESLint, Lint · scoped · query-options erasure, Lint · docs & code guards, Lint · CI, release & engine gates, Check Changeset, The card this PR closes must claim this branch, Check Documentation Links, filter, Check PR Size, Part-of PR must not also close its card, No other open PR may claim the same single-writer path, Flag docs affected by code changes. Test Core and Build Core had not appeared yet. CI convergence is the PM's.",
      "mcp_calls": "0",
      "api_writes": "2 relay dispatches, each `POST /repos/objectstack-ai/objectstack/dispatches` from this session and executed by the fleet-write relay as objectstack-fleet[bot]: (1) pr_create = POST /repos/objectstack-ai/objectstack/pulls (draft) + POST /repos/objectstack-ai/objectstack/issues/24985/assignees [os-justin]. The relay read it back: 6885 bytes sent, 6885 stored, identical, assignee listed. (2) this report = POST /repos/objectstack-ai/objectstack/issues/24962/comments via scripts/pm/post-stamped.mjs. Not REST: `git push` of claude/issue-24962-cbp-master-object-read five times (the empty-branch probe at f32d795ce5, then 8e1a07e512, 01b5f0def9, 438ef2dfde, 3dd33f7b78), all fast-forward.",
      "deviations": [
        "A container restart stopped the run once, before any code. On resume: worktree clean at f32d795ce5, remote branch at the same sha, no process of mine alive, verify lock free. The #24932 state and the newest Claim were re-read and are unchanged.",
        "The first package-suite run was spelled `pnpm --filter @objectstack/plugin-security test -- --maxWorkers=2`. Through the bare `--` the worker flag was dropped and the run used default workers. The whole package was the intended scope. The re-run at 438ef2dfde, the one cited in tests, uses `exec vitest run --maxWorkers=2`.",
        "The dispatch put every gate run under os-verify-lock. os-dev.md describes check:* gates as not taking it. The dispatch order was followed: one runner held the lock for 15m04s, then 13m01s, and the lock printed its holder-side starvation warning both times.",
        "origin/main b811c73696 (3 commits, disjoint) was merged before the PR opened, as AGENTS.md §10 asks. The incoming packages/spec change is liveness data only, and this branch changes nothing under packages/spec, so `pnpm --filter @objectstack/spec build && check:generated` was not re-run. Main has since moved to ba69b168be and that is not merged.",
        "A measurement harness was written into the worktree and run (packages/plugins/plugin-security/src/zz-measure-24962.test.ts). It was never committed and was deleted before commit. To probe the engine leaf it bound the engine's security-service slot by hand. A copy is in the scratchpad.",
        "Commit trailers use the model-free pair AGENTS.md prescribes, and the PR body ends with AGENTS.md's session-URL footer, not the harness reminder's spellings.",
        "The pin labels were reworded once after the first package run, to keep grant-shape wording off a public surface under the #9387 ruling. The pin file and the typecheck were re-run at the new head, and so were all 69 gate families."
      ],
      "files_changed": [
        "packages/plugins/plugin-security/src/security-plugin.ts (+112/-35)",
        "packages/plugins/plugin-security/src/controlled-by-parent-master-object-read.test.ts (new, +437)",
        ".changeset/24962-cbp-master-object-read.md (new, @objectstack/plugin-security patch, Clause-②: no)"
      ],
      "open_questions": [],
      "out_of_scope_findings": [
        "carrier: none · noted, not filed · one unmeasured source reading on the controlled_by_parent detail WRITE gate's master-edit leg, inside the same security family. Reach was not established: on every by-id write measured here, the read question and the reference check stand ahead of it. Detail is withheld under the #9387 ruling and given to the seat privately · dedupe words withheld by the seat under the #9387 ruling (seat edit, see its next note)"
      ]
    }

    Generated by Claude Code

  4. objectstack-fleet commented on Oct 20, 2026

    @objectstack-fleet
    ContributorAuthor

    Seat note · domain:services seat 1 · session_011TdZ8GjixNZFLz1xJD9zzK · 2026-10-20T21:43Z, under the #9387 disclosure ruling (5323310017).


    Generated by Claude Code

  5. objectstack-fleet commented on Oct 20, 2026

    @objectstack-fleet
    ContributorAuthor

    ACCEPT → PR #24985 comment 6302706192 · domain:services seat 1 · session_011TdZ8GjixNZFLz1xJD9zzK · 2026-10-20T22:01Z


    Generated by Claude Code

  6. objectstack-fleet commented on Oct 20, 2026

    @objectstack-fleet
    ContributorAuthor

    Landed · domain:services seat 1 · session_011TdZ8GjixNZFLz1xJD9zzK · 2026-10-20T22:33Z · ⛔ The #9387 disclosure rule applies.

    reading result
    PR #24985 timeline added_to_merge_queue 2026-10-20T22:03Z → merged 2026-10-20T22:30Z
    origin/main by content squash 9532e27886 (one parent, 7adfaec2a1), 3 files. security-plugin.ts carries the private objectReadAdmission, which computeControlledByParentFilter now asks of the master
    closing references the PR body closes #24962 only

    State change in this stroke: pm:dispatched off and the assignee cleared. The card closed completed on the merge.

    What changes: a controlled_by_parent detail's read scope now requires object-level read on its master (ADR-0055 §3). A by-id write of such a detail answers the read door's not-found (§4). A principal the master admits is unchanged (ACCEPT 6302706192).

    Follow-up: #24986, the write gate's master-edit leg. It is redacted, measure-first, and dispatched by this seat.


    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 / M2security

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions