Skip to content

[finding] validateSecurityPosture's CLI_ONLY surfaceReason claims coverage the ADR-0094 object authoring gate does not give it — it covers 1 of the block's 13 rules #7576

Description

@os-help

Found while implementing #7503 (PR #7574, the controlled_by_parent-without-relation lint rule). Filed rather than fixed: the fix is a one-field change that would move all 13 rules of the block onto a new surface at once, which is not a rider on a new-rule PR. The claim seat's dispatch instruction for #7503 was explicit — measure the gate, land the rule CLI_ONLY, file the gap separately.

The claim

validateSecurityPosture is registered once, as a block, in packages/lint/src/authoring-rules.ts (:1059-1071 on origin/main 9051802) — there is no per-rule surfaces entry, so every rule in the file inherits the block's surfaces: CLI_ONLY. Its surfaceReason, verbatim:

Already gated at this surface by a DIFFERENT mechanism: plugin-security registers an ADR-0094 authoring gate on object (registerAuthoringGate) that enforces the same OWD posture rules on every runtime write. Running the linter here as well would double-report one refusal in two vocabularies. Consolidating the two onto this table is P2 (#4463), and is a merge, not a hole.

That is a claim about coverage. It is not a reading of the gate.

What the gate actually does

packages/plugins/plugin-security/src/object-posture-gate.ts — the whole gate is 127 lines, and objectPostureGate implements exactly two rules, both stated in its own header:

  • R1 — env-tighten-only (ADR-0086 D1): an environment overlay over an artifact-backed object may not widen sharingModel / externalSharingModel past the packaged declaration.
  • R2 — external ≤ internal (ADR-0090 D11).

It reads exactly two keys off the body — sharingModel and externalSharingModel — and orders them through a local OWD_WIDTH map. It never reads fields, actions, permissions, books or data. controlled_by_parent is deliberately absent from its OWD_WIDTH (not locally orderable), so any comparison involving it is skipped by design.

The measurement

Mapping the gate against the 13 rule ids the block now carries (12 on main, 13 with #7503's):

rule id judges covered by the object authoring gate?
security-external-wider-than-internal object yes — this is R2
security-owd-unset object ❌ the gate never requires sharingModel to be set
security-owd-alias object widthOf() returns undefined for a non-canonical value, so the comparison is skipped, not refused
security-controlled-by-parent-no-relation (#7503) object ❌ the gate never reads fields
security-master-detail-ungranted object × permission sets ❌ cross-collection; the gate sees one object body
security-private-no-readscope permission set × object ❌ same
security-wildcard-vama permission set ❌ not an object body at all
security-anchor-high-privilege permission set ❌ same
security-fls-unqualified-key permission set ❌ same
security-role-word objects, fields, actions, permission sets, positions, apps, books ❌ the gate reads none of these
security-book-audience-unknown-set book ❌ not an object body
security-grant-expired-at-authoring seed data[] ❌ same
security-delegation-missing-reason seed data[] ❌ same

1 of 13. The R1 half of the gate corresponds to no lint rule at all, so it is not coverage in the other direction either.

To be precise about what this finding does not claim: some of the other twelve may be independently refused at the metadata write path by a different mechanism (e.g. object.zod.ts's sharingModel enum would reject the retired aliases security-owd-alias reports). The claim here is narrow and about the stated reason: the registerAuthoringGate mechanism the surfaceReason names does not enforce "the same OWD posture rules", it enforces two of them, one of which has a lint counterpart.

Why it matters, and the evidence it is not theoretical

#7503 is the proof. If the ADR-0094 gate already caught controlled_by_parent-without-relation, #7474 would not have needed to add a runtime write refusal for that shape, and #7503 would not exist. The new rule lands on a surface whose "already covered" justification is false for it specifically — so under CLI_ONLY it does not fire on the runtime-publish path, which is the exact path #7503 names as mattering most: "it matters most for AI-authored metadata", and an agent authoring metadata publishes, it does not run os lint.

This is the ㊼ shape — in a registry of self-describing entries, the self-describing field is the least trustworthy one — and it is the same axis #7220 / PR #7479 moved six rule ids across.

Suggested shape (not a decision, and deliberately not taken in #7574)

The mechanical fix is to give the block surfaces: CLI_AND_RUNTIME with runtimeTypes naming the metadata types it judges. Two reasons that is its own card:

  1. It changes the surface of all 13 rules in one field, including the eight that judge permission sets / books / seed data and would need runtimeTypes entries — or a split of the block into per-collection registrations — before that is even correct. A block registered for object only would still not run the eight.
  2. packages/lint/src/authoring-rule-wiring.test.ts polices this field directly (the "runtime publish surface" describe block), so the change has to be made deliberately, with the surfaceReason rewritten to whatever survives.

⚠️ Related: #7443 is on hold and this finding does not restart it. That card generalises the surfaces axis itself to N surfaces; this one is about a single block's factual surfaceReason inside the existing two-value axis. AUTHORING_SURFACES and CLI_AND_RUNTIME need no change for this.

Pointers

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