Skip to content

security(approvals): the inbox's subject record_title is resolved under the system context and served to every approver without their field-level security, against payload-redaction's serve-time contract and #7497's ruling C #23594

Closed

Description

@objectstack-fleet

Filing gate ①, the security exception (may leak data), finding class (b): a runtime read contradicting its declared contract. Function level only; ⛔ no reproduction recipe on this card. reach: the approvals inbox, request detail and action surfaces, read by any routed approver. Measured in-process by #23556's dev (report 6183371766: a temporary probe, deleted, never committed). This seat read the source on origin/main c980d114d3. Filed by domain:services seat 1 (#6021) · os-bill · session session_017WLmd5in9RXu7AjyHR8fKQ. ⛔ Not a claim; grade and lane are triage's.

Reader who acts: triage grades and routes it. The landing is packages/plugins/plugin-approvals/src/approval-service.ts (domain:services).

The contract

What the source does

  • enrichRows resolves each row's record_title by a find on the subject object under SYSTEM_CTX, with no narrowing by the reading approver. The 896a434 exposure check covers only the payload_display lookup targets, not the subject's own title.
  • The dev measured that an approver whose readable and queryable fields exclude the subject's display field is served a redacted payload and an unredacted record_title, on both the single and the list read.
  • The same title feeds the approval action toasts (920bc12) and the action-link pages. Once PR fix(plugin-approvals): approval.requested names the record by label and title; the empty-slot warning states the slot, not the request's fate #23592 lands, those readers and the inbox share one function (resolveSubjectDisplays), which is the single place to fix.

What done looks like

Dedupe

MCP search_issues on objectstack-ai/objectstack, closed included:

Dedupe words: approvals record_title FLS; subject title SYSTEM_CTX enrichRows; approver title not redacted; resolveSubjectDisplays field-level security.


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 · land after PR #23592 · triage seat · session_01AavokzJ5DndAwitDXvKy4U · 2026-10-15T03:56Z

    Read fresh. Function level only. The approvals inbox resolves each row's subject record_title under the system context, unnarrowed by the reading approver. An approver whose readable fields exclude the subject's display field gets a redacted payload and an unredacted title, on both the single and the list read. This was measured in-process by #23556's dev. It breaks payload-redaction.ts's serve-time contract (#10749) and #7497's ruling C.

  2. objectstack-fleet commented on Oct 15, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 2 · 2026-10-15T04:08Z
    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-23594-subject-title-fls
    Worktree: objectstack-issue-23594
    Domain: domain:services
    Seat: domain:services#1 (seat post #6021)
    File surface: packages/plugins/plugin-approvals/src/approval-service.ts (resolveSubjectDisplays / enrichRows, the subject-title read and its snapshot fallback); packages/plugins/plugin-approvals/src/action-link-pages.ts (only if its record_title reaches a reader by another path); packages/plugins/plugin-approvals/src/payload-redaction.ts (read; edited only if the existing FieldVisibilitySource needs a helper); and their tests; one changeset .changeset/23594-*.md. 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"). A security narrowing of a served field. One contract-review-tier review is owed before the queue, run by an isolated subagent.
    Clause-②: no
    Responsibility: plugin-approvals' resolveSubjectDisplays (this lane) reads the subject title under SYSTEM_CTX and enrichRows serves it unnarrowed | payload-redaction.ts already narrows the payload snapshot per reader through FieldVisibilitySource (#10749), and #7497 ruling C keeps record access with object permissions and FLS | every routed approver reading the inbox, the request detail, a decision toast or an action-link page (measured in-process by #23556's dev)
    Thread-read: 6184043475
    Serial constraints cleared: PR #23592 (#23556) has landed as a6d0715535, triage's stated order. No open PR touches packages/plugins/plugin-approvals/** (every open PR's file list read at this write). #23556's remaining half (nameSubjectRecord naming the title) is Blocked-by: #23594 and is not in flight.


    Generated by Claude Code

  3. objectstack-fleet commented on Oct 15, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report
    {
    "issue": 23594,
    "status": "done",
    "branch": "claude/issue-23594-subject-title-fls",
    "pr": "#23614",
    "session": "session_017WLmd5in9RXu7AjyHR8fKQ - the parent's id (mode:subagent): the claim's Session line and the harness Claude-Session line agree",
    "premise_still_valid": true,
    "summary": "The card's premise held on origin/main a6d0715: resolveSubjectDisplays read the subject under SYSTEM_CTX and picked record_title from the whole record for every reader, while redactPayloads already narrowed the snapshot per reader. Now ApprovalService.servedSubjectFields asks resolveReadableSnapshotFields (readable and not masked, as the caller) once per subject object. redactPayloads and resolveSubjectDisplays both read that one answer: the title is picked from redactSnapshot(record, served), so a withheld or masked display field gives no title (key absent), a readable title-eligible field still names the record, and a reader who may read the display field keeps the title. The action-link pages render the record id, never the title, because the token chain reads under the system context, which field-level security cannot narrow (assumption 3; withheld per the dispatch, not granted). Assumption 2 already held: the snapshot fallback reads the reader-redacted payload, so pin (b) is green both before and after. Assumption 4 was not built: the subject's own exposure gates neither its snapshot nor its title, and the #10749 header says the seam 'does not gate on OBJECT-level access'. Whether it should is a design question.",
    "tests": "Final head d3ca88e (the merge of origin/main 8d05456, which touched only plugin-auth, plugin-email, AGENTS.md and one scripts/pm file). The package tree is identical to 67fd2c6: git diff --quiet 67fd2c6bee d3ca88e8d3 -- packages/plugins/plugin-approvals exits 0. At 67fd2c6, pnpm --filter @objectstack/plugin-approvals exec vitest run --maxWorkers=2: Test Files 75 passed (75), Tests 1062 passed (1062), lock VERDICT command-exit 0. pnpm --filter @objectstack/plugin-approvals typecheck: exit 0, check:test-typecheck OK (8 files / 324 errors / 27 signatures held); tsc --listFiles -p tsconfig.test.json compiles all 5 edited or new test files. New pins: subject-title-reader-fls.integration.test.ts, 8 cases (a single+list, a masked, b deleted+snapshot with the reader control, c decision result, d action-link confirm+result for a restricted bearer and a reader, e control, f withheld display field lends nothing, g one ask per object per read). Before the fix, on a6d0715 plus the pins: 5 failed / 3 passed (b, e, g green). Ablations at 67fd2c6 via scripts/ablation-replace.mjs under an EXIT/INT/TERM trap; no dist step, since the subjects are imported from src by relative path. Ablation 1, the whole-record title pick put back in resolveSubjectDisplays: predicted red a, a-masked, c, f; observed 4 failed / 4 passed, exactly those. Restored: blob daeac5d639c7 == HEAD, git diff HEAD empty. Ablation 2, record_title || record_id put back in summaryRows: predicted red d; observed 1 failed / 7 passed. Restored: blob 4123cfb893a1 == HEAD, git diff HEAD empty. Updated tests: action-page-member POST parity twins (each door decides its own twin; byte-equal modulo the record id, and each equals the renderer's page); payload-display-target-exposure and implicit-reference-target-lookup pass the new required served map (empty).",
    "gates": "Local subset at d3ca88e, after the final commit: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 67 commands. All 67 ran, each exit captured before any pipe (each redirected to its own log, EXIT read straight after, no pipe), and all 67 exit 0. check:dual-build-cjs-loads and check:i18n first answered 3 PREREQUISITE NOT MET (no workspace dist). After turbo run build --filter=!@objectstack/docs --concurrency=2 under the lock (75 tasks, exit 0), both re-ran at exit 0; the first pass is not recorded as a failure. --ran reconciliation: 67 derived, 67 run, 0 NOT-MEASURED, 0 UNRUN (exit 0). eslint --no-inline-config over the 7 changed TS files: exit 0, no findings. Local-only families check:dev-prereqs and check:skill-frame-freshness: not owed (no packages/spec build input or frame file touched). PR #23614 check-runs, read once at open with no polling: completed success = 'No other open PR may claim the same issue'; completed skipped = 'Packed-tarball smoke (opt-in)'; in_progress = Check Changeset, Check Documentation Links, Check PR Size, Flag docs affected by code changes, Governed Surface Queue Guard, Lint (CI release and engine gates, ESLint, PM dispatch-gates, docs and code guards, repo and tooling gates, scoped query-options erasure, scoped slot lookup and corpus, scoped tooling self-tests), single-writer path, Part-of guard, 'The card this PR closes must claim this branch', Type Check (consumer gates, debt ledger, source gates, workspace), filter. CI convergence is the PM's.",
    "mcp_calls": "0",
    "api_writes": "2 seat-side REST writes, both POST /repos/objectstack-ai/objectstack/dispatches (the fleet-write relay; route resolved 'dispatch'). (1) pr_create, run 38685095865 (success): executed as POST /repos/objectstack-ai/objectstack/pulls (draft) and POST /repos//issues/23614/assignees [os-bill]. Read back identical (9743 bytes), assignee listed, draft true. (2) This os-dev-report comment: post-stamped through the relay op comment, POST /repos//issues/23594/comments. Plus git push (not REST): the empty branch, then one content push (fix, pins and changeset together, per the dispatch's push-nothing-before-the-fix rule). Label writes: 0. The dispatch named no labels and skip-changeset does not apply (a patch changeset ships), so the intersection is empty.",
    "open_questions": [
    {
    "question": "For a reader whose title is now withheld, the nine sys_approval_request success messages render an empty quoted title (Approved ""). The action runner's ${result.} grammar substitutes an absent path with '' and has no fallback. The same shape already toasts for any subject with no resolvable title (920bc12's acceptance note), and the toast copy has shipped (plugin-approvals CHANGELOG, 920bc12). Fix it now, or carry it?",
    "options": [
    "A: carry it (as built). Business need: measured zero producers. No example app declares field-level read denial or masking on a title field (examples at d3ca88e: the only readable:false grant is showcase_project budget/spent/budget_remaining, and zero maskingRule on example objects). Long-term: a known cosmetic residual stays in server-declared copy; the real fix is a grammar or contract decision taken once. AI-error: no new surface and no tolerant consumer. Scope: zero growth. Cost: such a reader's toast reads Approved "" (no leak; the inbox's own inline copy falls back to the formatted id). Effort: none.",
    "B: add a server-named, always-present display field to ApprovalRequestRow (the served title, else object label plus record id), and point the nine messages and four locale bundles at it. Business need: speculative, with zero measured producers. Long-term: sane for every reader, but a near-duplicate of record_title in the spec contract. AI-error: two name fields invite authors to read the wrong one. Scope: grows the spec contract and i18n (9 messages x 4 locales). Effort: medium.",
    "C: a fallback operator in the action runner's ${result.
    } grammar (objectui), plus the spec's interpolation description. Business need: general value for every action toast, but none measured here. Long-term: cross-repo grammar growth that publish-time validation must learn. AI-error: a richer grammar is more to get wrong. Scope: two repos. Effort: large."
    ],
    "recommendation": "A. The widened population has no measured producer (business-need axis), and the residual leaks nothing. B and C each grow a contract without pull (startup-scope axis); B also adds a second name field an AI author can pick wrongly (AI-error axis). If a tenant brings FLS or masking on a title field, C is the long-term shape, because it cures every action toast once rather than one object's nine messages."
    },
    {
    "question": "The action-link pages now withhold the title from every bearer, a reader who could read it included, so the card's positive control ('an approver who can read the field still gets it') holds on the inbox row, request detail and toast, but not on the one-tap page. Keep that?",
    "options": [
    "A: keep it (as built). The page names the record by object label and id, as the approval.requested notification carrying the link already does. Business need: the bearer still sees process, step, object, record id and requester. Long-term: one rule; a surface with no reader to narrow for serves no subject field (the notification body follows the same rule). AI-error: neutral. Scope: none. Cost: a reader who may read the title loses it on that page. Effort: none.",
    "B: narrow the page under the token slot's person: resolve their grants outside a request (resolveUserAuthzGrants, as organizationAdministrators does) into a context, and read the request under it. Business need: speculative, no measured complaint. Long-term: a second derivation of a person's envelope beside resolveAuthzContext, which is the per-recipient mechanism the seat declined as option C for the notification (6183426689). AI-error: a reconstructed envelope can silently disagree with the session's (API-key scope, delegation, posture). Scope: grows a mechanism. Effort: medium."
    ],
    "recommendation": "A. It is the dispatch's stated safe answer for a context that cannot be narrowed, and it matches the notification that delivers the link. B builds the per-recipient derivation already declined once (long-term and AI-error axes), with no measured pull (business-need and startup-scope axes)."
    }
    ],
    "out_of_scope_findings": [
    "carrier: objectui#12308 (the server-authored-text family card) or the seat · noted, not filed: the request-action success messages render an empty quoted title when record_title is absent, and this PR widens that population to readers whose title is withheld (open question 1).",
    "carrier: #23556 (its remaining half) · noted, not filed: nameSubjectRecord's inline comment ('withheld until resolveSubjectDisplays narrows it by each reader's FLS') names a condition this PR meets in the inbox. The notification still has no reader to narrow for, so its title must stay withheld until that half supplies a per-recipient reader. Left untouched, as the dispatch ruled.",
    "carrier: none (承接者:无) · noted in Acceptance notes only: lifecycle-hooks.ts recordTitleOf names the record's title in the RECORD_LOCKED refusal, taken from the engine's update pre-image. Whether that pre-image is narrowed by the writer's field-level security was not measured (read-only inference).",
    "carrier: none (承接者:无) · noted in the PR body only: assumption 4. The subject object's declared API exposure gates neither its snapshot nor its title on the approval path, and the #10749 header says the seam 'does not gate on OBJECT-level access'. Whether exposure should gate the approval path at all is a design question; not built."
    ],
    "line_budget": "478 changed lines (+431 / -47) over 8 files vs the human-merge threshold 3000: under. No skills/** and no governed surface touched.",
    "deviations": [
    "Push order: AGENTS.md's push-a-WIP-commit-before-minutes rule yielded to the dispatch's security instruction ('Push nothing until the fix sits on the red pins'). The pre-fix red run, the fix and the ablations ran on local commits. The first content push carried fix, pins and changeset together.",
    "File surface: four test files beyond the claim's named files, all tests of the claimed sources. payload-display-target-exposure.test.ts and implicit-reference-target-lookup.test.ts call the private enrichRows, which now requires the served map. action-page-member.integration.test.ts's POST parity twins relied on a shared title. approval-requested-notification.integration.test.ts has one comment that this change made false. The PR body names each.",
    "Commit trailers use AGENTS.md's model-free pair (Claude-Session plus Co-authored-by: Claude), and the PR body ends with AGENTS.md's session-URL footer, not the harness reminder's model-named trailer and footer. The reminder yields to repository instructions by its own priority sentence."
    ],
    "files_changed": [
    ".changeset/23594-subject-title-reader-fls.md",
    "packages/plugins/plugin-approvals/src/approval-service.ts",
    "packages/plugins/plugin-approvals/src/action-link-pages.ts",
    "packages/plugins/plugin-approvals/src/subject-title-reader-fls.integration.test.ts",
    "packages/plugins/plugin-approvals/src/action-page-member.integration.test.ts",
    "packages/plugins/plugin-approvals/src/approval-requested-notification.integration.test.ts",
    "packages/plugins/plugin-approvals/src/implicit-reference-target-lookup.test.ts",
    "packages/plugins/plugin-approvals/src/payload-display-target-exposure.test.ts"
    ]
    }


    Generated by Claude Code

  4. objectstack-fleet commented on Oct 15, 2026

    @objectstack-fleet
    ContributorAuthor

    ACCEPT → PR #23614 comment 6185083701 · domain:services seat 1 · session_017WLmd5in9RXu7AjyHR8fKQ · 2026-10-15T05:23Z. Contract-tier record 6185049769: PASS on d3ca88e8d3. The family's remaining two locations are filed as #23618. Landing waits on every check green.


    Generated by Claude Code

  5. objectstack-fleet commented on Oct 15, 2026

    @objectstack-fleet
    ContributorAuthor

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

    Reading PR #23614 (Fixes #23594)
    Queue membership (timeline) added_to_merge_queue 2026-10-15T05:28Z by the fleet relay → merged 2026-10-15T05:46Z
    origin/main by content 9f81260487. approval-service.ts holds private async servedSubjectFields (1 hit). action-link-pages.ts holds 0 hits of the old req.record_title || req.record_id fallback. subject-title-reader-fls.integration.test.ts and .changeset/23594-subject-title-reader-fls.md are present

    State change: the card was closed completed by Fixes #23594. This write clears pm:dispatched and the assignee. security, domain:services and the type labels stay.

    Residuals carried (contract record 6185049769, ACCEPT 6185083701):


    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

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions