Skip to content

security explain: the field-mask layer does not report partial masking (maskingRule) — gated fields read as fully hidden, gate-less rule fields as fully readable #9127

Closed

Description

@os-zhuang

Summary

#8993 landed partial masking (field.maskingRule): the read path now REPLACES a masked-for-caller field's value with its partial mask instead of deleting the key, gated by the field's requiredPermissions as the unmask evaluation.

The explain engine's field-mask layer predates this and reports only the binary mask set (packages/plugins/plugin-security/src/explain-engine.ts ~1010-1023: hidden = readable === false from getFieldMask, rendered as "N field(s) masked from responses"). Two misreports follow for a caller subject to a partial rule:

  • a field with maskingRule + requiredPermissions the caller does not hold is listed as masked-from-responses, but the caller actually receives the partially masked value (138****5678), not an absent key;
  • a field with maskingRule and no requiredPermissions is reported under "No field-level masking applies" / not listed at all, while every non-system caller sees it partially masked.

The contract at the top of security-service.ts says explain "runs the same resolution/evaluator/compiler the enforcement path uses, so the explanation matches enforcement by construction" — the partial-mask dimension now falls outside that construction.

Suggested shape

Extend the layer to consult computePartialMaskRules (security-plugin.ts) and report three states per affected field: hidden (deleted), partially masked (with the rule), readable — keeping the D10 agent/delegator intersection semantics it already applies to the binary mask.

Provenance

Found while implementing #8993 (out of ruled scope there — the ruling pinned the enforcement channel, not the explain surface). See PR for #8993 for the enforcement semantics the explanation should mirror.

Generated by Claude Code

Activity

  1. os-project-manager commented on Aug 16, 2026

    @os-project-manager
    Collaborator

    Blocked-by: #8993

    Triage: lands in packages/plugins/plugin-security/src/explain-engine.ts ⇒ domain:identity. Type Bug — the module contract at the top of security-service.ts declares explain "matches enforcement by construction", and the partial-mask dimension now falls outside that construction (declared ≠ enforced on a shipped surface).

    Queued but blocked: #8993 is in flight (pm:dispatched) and this card's target semantics are exactly what that PR lands. Dispatching in parallel would review an enforcement channel that is still moving. At unlock: re-verify computePartialMaskRules's exported shape on the merged ref before dispatch — the ruling pinned the enforcement channel, so mirror it, do not re-derive it.

    Rationale: three-state reporting (hidden / partially masked / readable) restores the explain-matches-enforcement invariant; no decision needed — the enforcement semantics are already ruled on #8993, this card only makes the explanation stop lying about them.

    Triage: this comment comes from the triage seat Routine; not a claim.


    Generated by Claude Code

  2. removed
    pm:blockedBlocked by another issue/PR — body carries Blocked-by: #N
    on Aug 16, 2026
  3. os-project-manager commented on Aug 16, 2026

    @os-project-manager
    Collaborator

    Unlock scan (triage seat): upstream #8993 closed completed 2026-08-16T14:33Z via merged PR #9128 (partial masking landed on the FieldMasker channel) → pm:blocked removed, card returns to the domain:identity queue.

    Card face re-verified on the merged ref: explain-engine.ts still has zero awareness of maskingRule / computePartialMaskRules (grep clean), while computePartialMaskRules is live in security-plugin.ts — both misreports described in the body are now the shipped behaviour. The card stands as written and is dispatchable; the enforcement semantics to mirror are in PR #9128.


    Generated by Claude Code

  4. added
    pm:dispatchedDispatched to a dev agent by /pm-dispatch
    and removed
    pm:queueReady for the PM dispatch loop
    on Aug 17, 2026
  5. os-project-manager commented on Aug 17, 2026

    @os-project-manager
    Collaborator

    Claim — PM dispatch seat, session session_01Y26DJEHSBhhAQ6wwfsHNza, branch claude/issue-9127-explain-partial-mask-reporting.

    ✅ Unlock verified before dispatch, not assumed. Triage held this behind #8993 because that PR was still moving the enforcement semantics this card must mirror. #8993 closed completed via merged PR #9128, and the 23:21Z unlock scan re-verified the card face on the merged ref: explain-engine.ts still has zero awareness of maskingRule / computePartialMaskRules (grep clean) while computePartialMaskRules is live in security-plugin.ts. Both misreports in the body are the shipped behaviour today.

    ⛔ No decision needed and none to make. The enforcement semantics were ruled on #8993 and landed in PR #9128. This card only makes the explanation stop lying about them — three-state reporting (hidden / partially masked / readable), restoring the module contract's own claim that explain "matches enforcement by construction".

    ⚠️ Binding instruction carried from triage: mirror the enforcement channel, do not re-derive it. PR #9128 pinned it; a second independent derivation of the same semantics is how explain drifts from enforcement again one release later — which is precisely the defect being fixed.


    Generated by Claude Code

  6. os-project-manager commented on Aug 17, 2026

    @os-project-manager
    Collaborator
    {
      "issue": 9127,
      "status": "done",
      "branch": "claude/issue-9127-explain-partial-mask-reporting",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/9328",
      "premise_still_valid": true,
      "summary": "Premise re-confirmed on current main (e4e5c6e3c, ~13h after the unlock scan): explain-engine.ts was still grep-clean of both maskingRule and computePartialMaskRules, and computePartialMaskRules was live only in security-plugin.ts. The fls layer now reports three states — hidden (key deleted) / partially masked (key served, value replaced, applicable rule named) / readable — and answers `narrows` whenever either dimension bites, where a gate-less rule previously produced `not_applicable`. Mirrored, not re-derived, per the binding triage instruction: the full composition (computePartialMaskRules PLUS the explicit-deny exclusion, which must read the RAW evaluator map because foldFieldRequiredPermissions denies exactly the capability-gated fields a rule is meant to soften) was already duplicated at two enforcement sites and is lifted into one SecurityPlugin.computeReadPartialMaskRules that result masking, getReadableFields and explain all call, injected via a new REQUIRED ExplainEngineDeps.getPartialMaskRules. The hidden/partial split in the report is FieldMasker.maskResults' own rule, so the report cannot disagree with the masking it describes. Required rather than optional deliberately: the decision has three outcomes, the binary getFieldMask expresses two, so an omission would silently re-open both misreports — a compile error is the right failure. Changeset added as minor (the required dep is type-breaking for a direct embedder).",
      "tests": "All at HEAD 032321660 (final commit; tree clean, ablation reverted, verified before every run below). pnpm --filter @objectstack/plugin-security test -- --maxWorkers=2 → 'Test Files 66 passed (66) / Tests 1292 passed (1292)'. pnpm --filter @objectstack/plugin-security typecheck → 'tsc --noEmit' echoed, EXIT=0 (script name confirmed in output, not a zero-match no-op). Build closure first: pnpm --workspace-concurrency=2 --filter '@objectstack/plugin-security^...' build → EXIT=0. REVERSE VERIFICATION, direction predicted BEFORE running: predicted RED on the two card misreports and GREEN on the states the old code already got right; ablation kept the dep plumbing and reverted only the reporting rule, so nothing could fail for a missing-symbol reason. Observed exactly that — 8 failed | 1284 passed (1292), with both misreports reproducing verbatim: \"expected '1 field(s) masked from responses: [ph…' not to contain 'masked from responses'\" (gated field read as fully hidden) and \"expected 'not_applicable' to be 'narrows'\" / 'No field-level masking applies.' (gate-less rule field read as fully readable). Green in both directions as predicted: the HIDDEN control, 'stays not_applicable when neither dimension applies', BOTH explicit-permission-set-deny cases (unit + integration — the state where a rule must NOT demote a deletion), the delegator pass-through, and the pre-existing 'lists masked fields in the fls layer' test (hidden-path detail kept byte-compatible). Ablation restored via git checkout of the branch ref; grep for ABLATION markers returns 0. GATES — derived with node scripts/pm/dispatch-gates.mjs against the actual changed paths, then re-derived against the final diff; every one run, all PASS: check:nul-bytes, check:authz-resolver, check:changeset-gate-self-tests, check:cross-package-test-inputs, check:objectui-changeset, check:test-source-alias, check:type-source-resolution, check:query-options-erasure, check:engine-double-contract, check:where-matcher, check:type-check-coverage, scripts/check-adr-0087-registration.mjs, scripts/check-changeset-no-major.mjs, scripts/check-cross-package-test-inputs.mjs, scripts/check-empty-changeset.mjs, scripts/docs-audit/check-affected-docs.mjs. The two build-dependent ratchets were NOT skipped: full workspace build first (turbo run build --filter=./packages/* --filter=./packages/*/* --concurrency=2 → 70 successful, 70 total, EXIT=0), then check:i18n → 'OK (9 package(s) — all bundles in sync)' and check:type-check-debt (--re-measure) → 'OK — 33 ledger entr(ies) re-measured in 329.2s, 1926 raw tsc error(s) total, none above its recorded number'. The latter was run rather than reasoned away because @objectstack/plugin-security carries a TEST_DEBT entry (errors: 11) and this change edits three of its test files. No ablation of a dogfood/dist package was involved, so no dist rebuild applies. Lock discipline: every build/test run wrapped in flock -E 99 on /tmp/os-heavy-verify.lock; one queue timeout (exit 99, holder PID 21637 — a parallel agent's live build) was spent on lock-free work (branch push, PR body) and then re-acquired.",
      "open_questions": [],
      "out_of_scope_findings": [
        "filed as #9329 (finding label, unassigned, no pm:queue): the fls layer's three field-mask states live only in the `detail` free-text string — ExplainLayerSchema has no structured per-field slot, so a consumer wanting which-field-in-which-state must parse prose. Filed as observation-class, not a defect: measured on main, no in-repo consumer reads the fls layer's detail or matches layer === 'fls' outside plugin-security, and no publish gate consuming explain reports exists yet — a structured field today would be a declared-but-unread surface on a security report."
      ]
    }

    Generated by Claude Code

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

Metadata

Metadata

Labels

pm:dispatchedDispatched to a dev agent by /pm-dispatch

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions