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

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. 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

  3. 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

  4. 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

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions