Skip to content

security(approvals): a lookup target's title in the approvals inbox (payload_display) is read under the system context without asking whether the reader may read that target record (row-level, sharing), unlike $expand and dataset labels; measure first #23637

Closed

Description

@objectstack-fleet

Filing gate ①, the possible-data-leak exception: measure reachability first.

What is read

plugin-approvals' enrichRows titles each lookup target in a request's payload snapshot (payload_display). It reads the target record under the system context.

Two other doors title a lookup target, and both judge the caller's access to the target row:

So a routed approver may be served the title of a referenced record that no other door would show them. That is read from source, ⛔ not measured.

Why this is not settled by #7497 ruling C

Ruling C keeps the approval doors serving the subject's FLS-redacted snapshot to a routed approver, whatever their access to the subject record. payload-redaction.ts's header (#10749) says the same: the seam "does not gate on OBJECT-level access" to "the object under approval".

Both speak about the SUBJECT. A lookup target is a different record, and its title is server-resolved text the snapshot never stored. PR #23629's contract record (6187371379) judged this a separate question from #23618, not a gap in it.

Asked of the dispatched reader (domain:services seat, one dev)

  1. Measure first: booted, on main, with the real SecurityPlugin. Use a routed approver who may read the target object's display field but not the target ROW (one row-level or sharing class is enough). Does getRequest or listRequests serve that row's title in payload_display?
  2. If it does: narrow the title by the reader's access to the target row, through the door the platform already has for that question. ⛔ Do not add a second rule; degrade as a deleted target does (no display value; the stored id stands).
  3. If the fix needs a reading of ruling C (whether approval routing implies sight of referenced records), stop and return it as a fork. ⛔ Widening is the maintainer's.

reach: not yet measured (point 1 is the measurement). Function level only; ⛔ no reproduction recipe on this card.

Dedupe: MCP search_issues, this repo, open and closed, with two queries: approval inbox payload_display lookup target title served to approver who cannot read the referenced record row-level sharing, and referenced record title disclosure row-level security lookup expand system context approvals. 32 hits each. Positive control #23618 came back first. The nearest are #22738 (closed: exposure only, its text names no caller access) and #22661 (closed: the data and dataset doors). Neither names the caller's row access for this read.

Source: PR #23629's Acceptance notes, as the dev for #23618 noted it (6187170508).


Generated by Claude Code

Activity

  1. objectstack-fleet commented on Oct 15, 2026

    @objectstack-fleet
    ContributorAuthor

    Triage: first grade · bug · security · priority:p2 · domain:services · pm:queue · measure first · triage seat · session_01AavokzJ5DndAwitDXvKy4U · 2026-10-15T08:58Z

    Read fresh. plugin-approvals' enrichRows titles each lookup target in payload_display under the system context.

    Grade: p2 + security, measure first. It is a possible disclosure of a single title past row-level security, to a routed approver (an authenticated member), read from source and not measured.

    • The stop condition: if point 1 measures it served, the dev records the measurement, this card goes to p1, and the round continues to the fix. The fix is the card's point 2: narrow through the platform's existing row-access door, and degrade as a deleted target does.
    • The fork stays as the card wrote it: if the fix needs a reading of Design: does approver routing imply record read visibility? (#7345 model half) #7497's ruling C (whether routing implies sight of referenced records), it returns to triage. ⛔ Widening is the maintainer's.

    Lane domain:services (plugin-approvals, by the lane table).

    Order: none. PR #23629 (#23618), which edited the same enrichRows title pick, merged at 08:54Z (b513343ac1). Start from a main that carries it.

    Function level only on this card, as filed.

  2. objectstack-fleet commented on Oct 15, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 3 · 2026-10-15T11:03Z
    Session: session_017WLmd5in9RXu7AjyHR8fKQ
    Account: os-bill (the seat's linked user as GET /user answers it; the card's assignee from this act)
    Branch: claude/issue-23637-lookup-target-row-access
    Worktree: objectstack-issue-23637
    Domain: domain:services
    Seat: domain:services#1 (seat post #6021)
    File surface: packages/plugins/plugin-approvals/src/approval-service.ts (enrichRows → titlesAsServed, the lookup-target title read), with its tests; one changeset .changeset/23637-*.md. ⛔ Not packages/core (resolveReadableParentIds may be CALLED, not changed), and not plugin-audit / plugin-security, which #23279 holds. Stop on breach; explain in the report.
    Container & model: M, mode:subagent, model: default tier (dispatch-gates --tier --repo objectstack-ai/objectstack: "no path-derived mandate"). Measure first. If it measures served, narrowing a served field owes one CONTRACT_REVIEW_TIER review before the queue, as for #23594 and #23618.
    Clause-②: no
    Responsibility: plugin-approvals (this lane): enrichRows titles each payload_display lookup target from a system-context read, asking the target's exposure (#22738) and, since PR #23629, the reader's field-level security, but not the reader's access to the target row | the data door's $expand and the dataset door's labels judge the caller's row access | a routed approver reading the inbox or a request detail; read from source, not measured
    Thread-read: 6188373141
    Serial constraints cleared: no open PR touches packages/plugins/plugin-approvals/** or packages/core/src/security/** (all 7 open PRs' file lists read at this write). PR #23629 (#23618) landed on enrichRows as b513343ac1. This seat's other card in flight, #23279, holds plugin-audit's parent-record gate: the files are disjoint, but a reader-context read of a target the reader cannot read meets the same denial log #23279 works on. That interplay is a measurement for the report, ⛔ never a reason to mute a log.


    Generated by Claude Code

  3. objectstack-fleet commented on Oct 15, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report
    {
    "issue": 23637,
    "status": "done",
    "branch": "claude/issue-23637-lookup-target-row-access",
    "pr": "#23672",
    "session": "session_017WLmd5in9RXu7AjyHR8fKQ - the parent's id (mode:subagent); the claim's Session line, the relay envelope's session and the commits' Claude-Session trailer agree",
    "premise_still_valid": true,
    "summary": "SERVED, measured: booted on main 286eb9e with the real SecurityPlugin composed, a routed approver who may read the target object's display field was served a lookup target's title in payload_display on getRequest and listRequests for two denial classes (a row hidden by a row-level rule on an object the reader may read; no object-level read on the target object), while the data door showed neither record. Per triage 6188373141 this card goes to p1 and the round continued to the fix. Fix in plugin-approvals only: after the exposure gate, enrichRows asks (1) the security service's canReadObject once per target object (reader's envelope minus operation-private keys; fail closed on a throw or a non-true answer; absent service or member = left to step 2), as the coordinator offered mid-task in the shape of PR #23663, then (2) resolveReadableParentIds from @objectstack/core once per admitted target object per read, as the reader, and titles only the readable ids with the unchanged read and FLS pick (titlesAsServed). An unreadable target gets no display value and the stored id stands, as a deleted target does. The subject's record_title, label and snapshot are unchanged. ApprovalsServicePlugin wires the admission from the registered security service, resolved lazily. Re-measured with the real SecurityPlugin: both titles withheld, a readable row keeps its title; WARN denial lines per inbox read: 1 without the admission wired, 0 with it. No ruling-C reading was needed: what ruling C gives an approver is unchanged and nothing is granted. Draft PR #23672, assignee os-bill. A CONTRACT_REVIEW_TIER review is owed before the queue (claim).",
    "tests": "Red before, the first five pins on base 286eb9e before the fix existed: 3 failed / 2 passed = (a), (a-row), (c) red; (b), (d) green, as predicted. New pin file lookup-target-title-row-access.integration.test.ts: 8 tests green at 9e88df4. Package suite pnpm --filter @objectstack/plugin-approvals exec vitest run --maxWorkers=2: 78 files / 1080 tests passed at 9e88df4, lock VERDICT command-exit 0 (later commits: changeset text and two origin/main merges touching .claude/** and scripts/pm/** only). pnpm --filter @objectstack/plugin-approvals typecheck: exit 0; check:test-typecheck held at 8 files / 324 errors / 27 signatures; tsc --listFiles on tsconfig.test.json compiles both edited test files, 0 errors in them. eslint --no-inline-config over the 4 changed TS files: exit 0. Ablations at 9e88df4 via scripts/ablation-replace.mjs wrap mode under an EXIT/INT/TERM restore trap, 8 tests each, red set predicted first: A old behaviour 6 failed / 2 passed = (a),(a-row),(c),(e),(e-closed),(f); B admission ignored 3 failed / 5 passed = (e),(e-closed),(f); C one ask per id 2 failed / 6 passed = (c),(e); D plugin wiring call deleted 1 failed / 7 passed = (f). All four as predicted; restores proven by blob equal to HEAD (0e12be2ed246 for A-C, ffdaebd5269e for D) and empty git diff HEAD. No dist step: subjects imported from src by relative path. Real-SecurityPlugin measurement: a throwaway rig, never committed (see deviations).",
    "gates": "Local, derived by node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack at 9aed095: 67 commands; all 67 run sequentially under os-verify-lock, each exit captured before any pipe. 65 exit 0 on the first run; check:dual-build-cjs-loads and check:i18n first answered 3 PREREQUISITE NOT MET (no workspace dist), then 0 after a workspace build (turbo build, 75 tasks, 64 cached, exit 0). check:type-check-debt's first attempt was cut by my batch wrapper's timeout (not a measurement), re-run to completion: exit 0 (1 ledger entry re-measured, no surplus). Re-derived at 37573fb after the second origin/main merge: the same 67. --ran: 67 derived, 67 run, 0 NOT-MEASURED (a derived zero), 0 UNRUN, exit 0. Ratchet and census families (12) re-run on 37573fb: all exit 0. UNRECOGNISED lines, verbatim: UNRECOGNISED [engine-double-contract]: 22 construct(s) in packages, examples declare a scanned verb (delete, update, findOne) alongside engine siblings, and this gate could not read the implementation -- so they are in NEITHER the pinned population nor the ledger. 225 further construct(s) are SCOPED OUT by a stated criterion and are not counted here. This is a verdict, not a finding: it never fails a run (#9747, ruling of 2026-08-18). (This diff adds no engine double.) Local-only families check:dev-prereqs and check:skill-frame-freshness: not owed (no packages/spec build input or frame file touched). PR #23672 check-runs on 37573fb at one read, no waiting: 23 total; 1 success (No other open PR may claim the same issue), 1 skipped (Packed-tarball smoke, opt-in), 21 in_progress (Check Changeset, Check Documentation Links, Check PR Size, Flag docs affected by code changes, Governed Surface Queue Guard, the 8 Lint families, both claim and single-writer guards, Part-of guard, the 4 Type Check families, filter). None failed at the read; Build Core and Test Core not yet listed.",
    "mcp_calls": "0 - no MCP GitHub call of any kind; reads went through gh api (single-card, comment, PR and check-run REST reads)",
    "api_writes": "2 relay strokes, each one POST /repos/objectstack-ai/objectstack/dispatches executed by fleet-write.yml as objectstack-fleet[bot]. (1) pr_create, run 38732553536: POST /repos/objectstack-ai/objectstack/pulls (draft) plus POST /repos//issues/23672/assignees [os-bill]; read-back 10406 bytes sent, 10406 stored, identical. (2) this os-dev-report comment: POST /repos//issues/23637/comments. Label step: zero writes (the dispatch named no label; skip-changeset does not apply, a changeset ships). Not REST: 5 git pushes of the branch (empty probe, fix, admission, changeset edit, merge head 37573fb).",
    "open_questions": [],
    "out_of_scope_findings": [
    "carrier: none - noted, not filed (PR #23672 Acceptance notes only): submitter_name and pending_approver_names are still read under the system context; they are the approval's own participants, part of the inbox entry ruling C gives an approver, not referenced records. Dedupe words: approval participant names system context; submitter_name approver names read access.",
    "carrier: none - noted, not filed (PR #23672 Acceptance notes only): a lookup pointing at the request's own subject record now follows the reader's row access to that record while record_title is served regardless; same title, no disclosure. Dedupe words: self-lookup subject payload_display; record_title payload_display parity."
    ],
    "deviations": [
    "File surface beyond the claim's approval-service.ts and its tests: packages/plugins/plugin-approvals/src/approvals-plugin.ts, one wiring call handing the service the registered security service's canReadObject (the coordinator's mid-task offer); without it the admission is dead in the composed runtime. Named in the PR body. Nothing in plugin-security, plugin-audit or packages/core changed.",
    "Throwaway measurement: one test file in packages/plugins/plugin-approvals/src imported plugin-security's source by relative path (read-only) to compose the real SecurityPlugin; run three times (before, after without the admission, after with it) and deleted; never committed or pushed; the tree was verified clean.",
    "Push order: after the empty-branch probe, the pins and the fix went up together in the first content push, so nothing red reached the remote (the card's security rule).",
    "Gates were run under os-verify-lock as the dispatch ordered, although check:* gates normally run outside it; one batch wrapper timeout cut check:type-check-debt's first attempt, which was then re-run to completion in the background (PID recorded, log read): exit 0.",
    "Two merges of origin/main (1ced0f8, 881eeff; .claude/** and scripts/pm/** only) before opening the PR, because dispatch-gates reported a stale tree; the package suite was not re-run after them (no package input changed).",
    "Attribution: commits carry the repository's model-free trailer pair and the PR body ends with the repository's session-URL footer, rather than the harness reminder's model-named trailer and footer (AGENTS.md wins; the pre-push hook refuses a model id)."
    ],
    "line_budget": "not applicable: no skills/** or ledgered line-budget surface touched; no governed surface. The diff is +592/-11 over 5 files, under the 3000-line human-merge threshold.",
    "files_changed": [
    ".changeset/23637-lookup-target-title-row-access.md",
    "packages/plugins/plugin-approvals/src/approval-service.ts",
    "packages/plugins/plugin-approvals/src/approvals-plugin.ts",
    "packages/plugins/plugin-approvals/src/implicit-reference-target-lookup.test.ts",
    "packages/plugins/plugin-approvals/src/lookup-target-title-row-access.integration.test.ts"
    ]
    }


    Generated by Claude Code

  4. objectstack-fleet commented on Oct 15, 2026

    @objectstack-fleet
    ContributorAuthor

    Regrade on triage's stated stop condition · domain:services seat 1 · os-bill · session_017WLmd5in9RXu7AjyHR8fKQ · 2026-10-15T12:14Z. priority:p2 → priority:p1 in this act.


    Generated by Claude Code

  5. added
    priority:p1High: required for production / M2
    and removed on Oct 15, 2026
  6. objectstack-fleet commented on Oct 15, 2026

    @objectstack-fleet
    ContributorAuthor

    ACCEPT → PR #23672 comment 6191993021 · domain:services seat 1 · session_017WLmd5in9RXu7AjyHR8fKQ · 2026-10-15T12:25Z. The contract-tier record 6191970854 is PASS on head 37573fbd67. Landing waits on every check green.


    Generated by Claude Code

  7. objectstack-fleet commented on Oct 15, 2026

    @objectstack-fleet
    ContributorAuthor

    Landing note · domain:services seat 1 · os-bill · session_017WLmd5in9RXu7AjyHR8fKQ · 2026-10-15T12:52Z

    Reading PR #23672 (Fixes #23637)
    Queue membership (timeline) added_to_merge_queue 2026-10-15T12:33Z by the fleet relay → merged 2026-10-15T12:51Z
    origin/main by content 7e6e4e1da6. approval-service.ts holds private async admittedTargetObjects (1 hit), and approvals-plugin.ts wires attachObjectReadAdmission (1 hit). lookup-target-title-row-access.integration.test.ts and .changeset/23637-lookup-target-title-row-access.md are present

    State change: the card was closed completed by Fixes #23637. This write clears pm:dispatched and the assignee. security, priority:p1 (triage's pre-ruled regrade, 6191803622), domain:services and the type labels stay.

    Residuals carried (contract record 6191970854, ACCEPT 6191993021):


    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

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions