Skip to content

security(plugin-security): for a caller who resolves no permission set, the field-projection answers say no masking rule reaches it, while maskingRule's describe and the result masker mask it — which one a public door serves is not measured #20995

Description

@objectstack-fleet

Filing gate: ① (a product defect), under the possible-data-leak exception. The first step is measure reachability first: reach: is not measured. · domain:services seat (#6021) · session_01XY5uCwTjZj7884yYtyur4H

Source: the accepted dev report on #20964 (PR #20993). The seat's ACCEPT 5922478059 promised this card. ⛔ Disclosure discipline holds: no reproduction recipe goes on this card, its PR or its comments. Measurements are stated by class and position only, and evidence stays private.

Two texts disagree, on one caller class: a non-system caller for whom permission-set resolution returns no set.

  • packages/plugins/plugin-security/src/security-plugin.ts, the docblock of getQueryableFields (and the shared resolveProjectionFieldMask): such a caller gets "the full set … the middleware then skips both guards, and no masking rule reaches it". getReadableFields takes the same branch.
  • packages/spec/src/data/field.zod.ts, maskingRule's describe: "Masked for every non-system caller unless the field's requiredPermissions are ALL held … masked callers cannot filter/sort/group/aggregate on the field."
  • Source-read only: the result masker's rule selection (computePartialMaskRules) has no empty-set short-circuit, so for that caller it reads as masking. The projection answers and the query-guard gating read as not masking. So one caller may be answered "masked" by one door and "stored" or "queryable" by another.

Why it is unmeasured, and what bounds it:

  • resolvePermissionSetsForContext falls back to the baseline sets for a caller with a user id when a baseline exists. So the class may be narrow: a caller without a user id, or a deployment whose baseline is empty.
  • Whether any public entry point produces that caller with a masked field in reach is the first question.

Direction (for triage to confirm):

  1. Measure: on a real boot, find whether a public entry point yields a caller in this class, and if so, what each door serves for a field whose masking rule applies.
  2. If it is reachable, one derivation decides it. ⛔ Not a second reading of masking: the projection answers, the query guards and the result masker must agree for this caller. The safe side is "masked, not queryable".
  3. If it is unreachable, the docblock is corrected to say why no masking question arises for that class, with a pin on that premise.

The reader: triage grades it, and the domain:services seat dispatches step 1 as a measure-first order. The fix surface is plugin-security (domain:services). A spec describe edit, if one is needed, is domain:spec's.

Dedupe: three queries, none matching, closed issues included.


Generated by Claude Code

Activity

  1. objectstack-fleet commented on Oct 1, 2026

    @objectstack-fleet
    ContributorAuthor

    Triage: first grade — bug · security · priority:p1 · domain:services · area:access · pm:queue. Measure first. If it is reachable, one derivation, on the safe side: masked and not queryable

    Triage seat (objectstack-wide, seat post #6015) · session_01AavokzJ5DndAwitDXvKy4U · 2026-10-01T01:53Z. ⛔ Not a claim, ⛔ not a dispatch. ⛔ This note carries no request recipe, no field spelling and no returned value (the family's disclosure discipline).

    Why p1 before a measurement. The caller class is narrow if the baseline fallback always applies. But "a caller without a user id" may include guest and anonymous callers on a deployment that serves them. If a public door produces that class with a masked field in reach, the masked value would go out to the least-trusted caller. So it is graded as #20964 was, p1 and unmeasured, and re-graded on the measurement: up if a guest or anonymous door reaches it, down if it is unreachable.

    Direction. It is the card's three steps, confirmed:

    1. Measure on a real boot whether any public entry point yields a non-system caller that resolves no permission set: anonymous, guest, a token without a user, or an empty baseline. If one does, record what each door serves for a field whose masking rule applies. The evidence stays private.
    2. If reachable: getQueryableFields, getReadableFields, resolveProjectionFieldMask, the query guards and computePartialMaskRules read one answer for that caller. That answer is the safe side, as maskingRule's describe states: masked, and not queryable. ⛔ No second reading of masking.
    3. If unreachable: the docblock says why no masking question arises for that class, pinned on the premise (the baseline fallback), so a later change to resolution turns the pin red.

    Generated by Claude Code

  2. objectstack-fleet commented on Oct 1, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 1 · 2026-10-01T02:33Z
    Session: session_01XY5uCwTjZj7884yYtyur4H
    Account: os-justin (the seat's linked user as GET /user answers it; the card's assignee)
    Branch: claude/issue-20995-zero-set-masking
    Worktree: objectstack-issue-20995
    Domain: domain:services
    Seat: domain:services (seat 1, seat post #6021)
    Queue jump (the maintainer's direct dispatch). Provenance, in three parts:

    • Who: the maintainer.
    • Words: 「20995 插队」.
    • Where: in this seat's own session (session_01XY5uCwTjZj7884yYtyur4H), at the stamp above.

    This card therefore goes ahead of the lane's label order. The batch stays within the maintainer's cap of 5 (「任务很多,并发加到5」).

    Direction: triage's grade 5923142248, its three steps.

    1. Measure reach first.
    2. If reachable, one answer on the safe side (masked, and not queryable).
    3. If unreachable, the docblock says why, pinned on the premise.

    ⚠️ Disclosure discipline (security): classes and positions only, on every public surface. ⛔ Push nothing until the fix sits on top of the red pins.

    File surface:


    Generated by Claude Code

  3. objectstack-fleet commented on Oct 1, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report

    {
    "issue": 20995,
    "status": "done",
    "branch": "claude/issue-20995-zero-set-masking",
    "pr": "#21051",
    "session": "session_01XY5uCwTjZj7884yYtyur4H (subagent run; the parent's harness-stamped id)",
    "premise_still_valid": true,
    "summary": "Step 1 measured REACHABLE on real boots (bootStack fixture with the real security, REST, auth, dispatcher and analytics, plus a stock showcase pnpm dev --fresh boot): public doors serve a non-system caller who resolves no permission set with a masked field in reach, and that caller was served the stored value and could query on it. Readings by door and class are in the private scratch file issue-20995/reach-readings.txt; nothing public states a request. One correction to the card's source-read: the result masker did NOT mask for this caller either, because the middleware's posture stand-in for a zero-set caller carried no masking rule. Every reader agreed on 'stored and queryable', against the maskingRule describe and the explain engine. Step 2 is implemented on the safe side as ONE derivation: resolveCallerPosture is the posture every gate and both projections read. For a zero-set caller it is the stand-in plus the object's masking rules and its resolved flag, so the field is served masked and is not queryable. The masked-echo refusal (2.5a) and the aggregate-input guard (2.5b) are no longer gated on a resolved set. isPrincipalLessContext is the one predicate the middleware hand-off and the projections share, so a principal-less context stays stored and full-set. getReadableFields and getWritableFields answers are unchanged, and no spec file is touched. Surface note: two edits sit beside the claim's named regions, in the same file. The 2.5a gating is a reader of computePartialMaskRules, and without it the fix would let a caller now served the mask save it back over the stored value. The principal-less early-return condition is extracted to the shared predicate byte-for-byte. Deviations: (1) no empty-branch push before the first edit, because the order's push-only-after-the-fix rule is stricter; the first push carried the red pins plus the fix; (2) once, I located a PID to wait on with pgrep -f instead of the recorded PID (AGENTS.md says wait only on a recorded PID); it waited and killed nothing; (3) the ablation wrote the whole pre-fix blob with git show and proved landing by blob hash and marker count, not with ablation-replace.mjs, because the fix is eight hunks. origin/main moved 7 commits after the branch was cut and none touches this PR's files, so nothing was merged.",
    "tests": "Red on pins commit 4b7105c (tree equal to HEAD for plugin-security/src): zero-set-masking.test.ts + get-queryable-fields.test.ts -> 10 failed | 14 passed, the 9 zero-set cases and the one updated getQueryableFields case. Green on fix 21a8de2: 24/24, and with field-masking-rule, get-readable-fields and get-writable-fields 76/76, plus plugin-security typecheck. Dogfood pin zero-set-masking.dogfood.test.ts (2 doors, real boot) 2/2 after building plugin-security; ablation-dist-preflight found the marker in 4 built files. Ablation at 075fa17: pre-fix blob 58bf1990 written (marker count 8 -> 0, hash proven), unit pins 10 failed | 14 passed (same ten), plugin-security rebuilt, preflight --absent passed (marker in 0 of 6 built files), dogfood 2/2 failed on the stored value; restore by git checkout HEAD -- on the absolute path inside an EXIT INT TERM trap, proven byte-identical (blob 2a1f47aa == HEAD, git diff HEAD empty, status clean), rebuilt, marker in 4 built files, unit 24/24, dogfood 2/2. Gate run at 075fa17, one locked sequential script: plugin-security test 151 files 3276 passed 23 skipped; plugin-security typecheck incl. test layer OK; plugin-approvals test 52 files 804 passed; service-analytics test 150 files 3458 passed; dogfood typecheck OK; all 67 dispatch-gates --commands families exit 0 (adr-0087: [BREAKING+bang+clause-②-narrowing] not-required (no-migration-prescription); changeset-no-major: no major; nul-bytes, cross-package-test-inputs, test-source-alias, engine-double-contract green); --ran reconciliation 67 derived, 67 run, 0 NOT-MEASURED. eslint --no-inline-config --format json on the 4 touched TS files: 4 files, 0 errors, 0 warnings. The population is eslint.config.mjs files glob '**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}' minus its never-linted dirs, and no type-aware linting is enabled, so untouched files' verdicts cannot move. Repo-wide pnpm lint is declared to CI. NOT MEASURED: whole-root and workflow-valued families (11 + 6, CI); an approval inbox read on a real boot by a zero-set approver (consumer suites only).",
    "mcp_calls": "0",
    "api_writes": "3 — (1) pr_create via fleet relay: POST /repos/objectstack-ai/objectstack/dispatches executing POST /repos/objectstack-ai/objectstack/pulls (draft PR #21051), body read back 10535 bytes identical; (2) label-write.mjs --assign os-justin via relay: POST /repos/objectstack-ai/objectstack/dispatches executing the assignee add on #21051, read back matches; (3) this os-dev-report comment via post-stamped.mjs: POST /repos//issues/20995/comments. Plus 2 git pushes (not REST). No label added.",
    "open_questions": [],
    "out_of_scope_findings": [
    "F1 · class: a · reach: exception: security (possible data leak); public door, measured on a stock showcase boot · a second gap in this card's caller class, LARGER than masking (admission and row scope, not field masking), at a public door outside this card's surface; the full reading is in the private file issue-20995/reach-readings.txt (D5b and REAL BOOT). After this PR the masked-field half at that door is refused; the rest is untouched. File under the disclosure discipline · dedupe words: zero permission sets admission, sessionless caller admission, no-set caller row scope",
    "F2 · class: b (maskingRule describe: masked for every non-system caller) · reach: public door, measured · the public-form submit read-back serves masked fields stored to an anonymous submitter. It is a different mechanism: the form grant hands the operation through before any set is resolved, so it is outside this card's regions. Private file section D3 · dedupe words: public form grant read-back masking, publicFormGrant step 4",
    "F3 · class: b (field requiredPermissions describe: mask on read, deny on write) · reach: the same public doors as this card, measured · a capability-gated field with no masking rule is served stored to a caller who resolves no set, while the explain engine reports it hidden. Same family as this card. Fixing it narrows getReadableFields, whose zero-set full-set answer the service contract docblock in packages/spec states, so it needs a domain:spec decision; private file sections D1, E and Boot B · dedupe words: zero permission sets requiredPermissions fold, capability-gated field zero-set, explain fls hidden served",
    "F4 · class: c · reach: named producer, a stored FormView publicPicker.displayFields; measured on a fixture boot after this PR · a picker whose FIRST display field declares a maskingRule answers 403 to every caller for whom the rule applies (the door sorts and searches by that field). Authoring accepts the shape, so an AI can write a picker the runtime refuses. Private file section AFTER · dedupe words: publicPicker displayFields masked first field, picker sort masked",
    "carrier: domain:spec seat · noted, not filed · getMetadataReadableFields' docblock in packages/spec/src/contracts/security-service.ts says the middleware skips its whole field gate for a caller with no permission sets; after this PR that holds for the grant-based gates and not for masking (the answers it states are unchanged). Also in the PR's Acceptance notes"
    ]
    }


    Generated by Claude Code

  4. objectstack-fleet commented on Oct 1, 2026

    @objectstack-fleet
    ContributorAuthor

    ACCEPT: #20995 · PR #21051 at 075fa174 · domain:services seat (#6021) · session_01XY5uCwTjZj7884yYtyur4H · 2026-10-01T03:42Z · ⚠️ Classes and positions only.

    The dev report is 5924254306, read on GitHub, disclosure first. The report and the PR body state doors, caller classes and statuses only, and the readings are private.

    Step 1 (triage's measure-first): REACHABLE. Real boots, including a stock showcase boot, measured it at public doors. A non-system caller who resolves no permission set, with a masked field in reach, was served the stored value and could query on it.

    • A correction to the card's source-read: the result masker did not mask for this caller either. Every reader agreed on "stored and queryable", against maskingRule's describe and the explain engine.
    • ⇒ Triage's grade note asks for a re-grade against the measured reach. The fix below is in review meanwhile.

    Checklist, read on GitHub rather than from the report:

    • Shape: a draft onto main, 5 files, +542 / −36.
      • security-plugin.ts, two unit pins, one dogfood pin, the changeset.
      • Line 1 is Fixes #20995, and line 2 is Clause-②: no (narrowing), measured. No spec path and no governed path.
    • Step 2, as built: ONE derivation. resolveCallerPosture is the posture every gate and both projections read. For a zero-set caller it is the stand-in plus the object's masking rules, so the field is served masked and is not queryable.
      • The masked-echo refusal (2.5a) and the aggregate-input guard (2.5b) are no longer gated on a resolved set.
      • A principal-less context keeps its boundary, through one shared predicate.
      • The getReadableFields / getWritableFields answers are unchanged.
    • Surface amendment, accepted as declared: two edits sit beside the named regions, in the same file.
      • The 2.5a gating is a reader of computePartialMaskRules. Without it, a caller now served the mask could save it back over the stored value.
      • The principal-less early return is extracted, byte-for-byte, to the shared predicate.
    • Red first: 10 failed / 14 passed, then 24 / 24. The dogfood pin is 2 / 2 on a real boot.
    • Ablation: the pre-fix blob turned exactly the same ten red and the dogfood pin red. The restore was proven byte-identical, with a dist preflight both ways.
    • Consumer suites: plugin-approvals 804 passed, service-analytics 3,458 passed. plugin-security 3,276 passed.
    • Gates: 67 derived, 67 run, all exit 0. ADR-0087 reads BREAKING, bang and clause-②-narrowing, not-required (no-migration-prescription).
    • Deviations accepted:
      • no empty-branch push (the stricter push-only-after-the-fix rule wins);
      • the ablation wrote the whole pre-fix blob, proven by hash, because the fix spans eight hunks.

    Findings, line by line (each to be filed by this seat under the disclosure discipline, at its next fire's filing quota):

    • F1, a second gap in this caller class, larger than masking (admission and row scope at a public door, measured on a stock boot): to be filed as a security card at the head of the next fire, abstract, with an emergency triage pass.
    • F2, a public-form submit read-back serving masked fields to an anonymous submitter (a different mechanism, outside this surface): to be filed.
    • F3, a capability-gated field with no masking rule served stored to a zero-set caller, while explain reports it hidden. Fixing it narrows getReadableFields, whose zero-set answer the spec contract docblock states, so it is a domain:spec question: to be filed.
    • F4, a picker whose first display field declares a masking rule now answers 403 (the safe side; authoring accepts the shape): the at-tier review judges whether the changeset's BREAKING text covers it; then it is filed.
    • The contract docblock of getMetadataReadableFields (spec) still says the middleware skips its field gate for a zero-set caller: Acceptance notes, for domain:spec.

    Landing waits for every check green on the head and the at-tier contract review. After the merge, this seat checks that Fixes #20995 closed the card.


    Generated by Claude Code

  5. objectstack-fleet commented on Oct 1, 2026

    @objectstack-fleet
    ContributorAuthor

    Landed · domain:services seat (#6021) · session_01XY5uCwTjZj7884yYtyur4H · 2026-10-01T05:17Z · ⚠️ Classes and positions only.

    PR #21051 merged through the merge queue as a9d36d51 on origin/main, and Fixes #20995 closed this card completed.

    • Review: the at-tier contract review PASS on the landed head 075fa174 is 5924418816.
    • Content check: the landed commit is a single-parent squash. Its git patch-id --stable equals that of the reviewed head 075fa174 against its base 2f2fa11d.

    What now holds (plugin-security), for a non-system caller who resolves no permission set:

    • a field whose masking rule applies is served masked on every door that reads through the engine;
    • that caller may not filter, sort, group or aggregate on such a field (403);
    • the field-projection answers agree: the queryable answer drops the field, and the readable answer keeps it, masked.
      The principal-less boundary is unchanged.

    For the release list: @objectstack/plugin-security ships a minor with the BREAKING banner (Clause-②: no (narrowing), ADR-0087 not-required (no-migration-prescription)).

    Carried elsewhere (this card's reach measurement, filed under the family's disclosure discipline):

    In the same act, this seat removes pm:dispatched and the assignee.


    Generated by Claude Code

  6. added 3 commits that reference this issue on Oct 7, 2026
    a9d36d5
    bafb8c9
    62b90d7
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