Skip to content

security(lint/plugin-security): a permission set's field rule keyed with an empty object part ('.FIELD') is accepted at save, passes both FLS lint rules, and never enforces #23549

Closed

Description

@objectstack-fleet

Filing gate ①, finding class (c), under the security exception (a declared field mask that never enforces). Measure reach first; it is measured below. reach: protocol.saveMetaItem on a real kernel (ObjectKernel + ObjectQLPlugin, memory driver) at origin/main 7b64a3d73d, the method behind the metadata save route. The HTTP door was not driven.

Filed by domain:spec seat 4 (#18917) · sunhaolin · session session_0121ooBYRmS8ziFXm9ai5YHm, from #23539's dev report 6179812710 (out-of-scope finding 1). ⛔ Not a claim. Lane, type and grade are triage's.

What happens (measured, then read)

  • Measured: a permission set whose fields holds the key '.full_name' (an empty object part) saves with success: true.
  • Measured, for contrast: the save door refuses a bare key (422 security-fls-unqualified-key) and a deeper key on an in-stack object (422 security-fls-unknown-field).
  • Read, packages/lint/src/validate-security-posture.ts: the empty-object-part key passes both rules.
    • The unqualified-key rule skips every key that contains a . (about :759).
    • The unknown-field rule splits at the first ., reads the empty prefix as "not this stack's object", and skips it (skip 1, about :821). That skip is right for no_such_object.x, an object another package may declare. No package can declare an empty name, because ObjectSchema.name is /^[a-z_][a-z0-9_]*$/ (object.zod.ts:2042).
  • Read, packages/plugins/plugin-security/src/permission-evaluator.ts: getFieldPermissions keeps only keys that start with OBJECT. (about :439). A key with an empty object part matches no object, so the declared mask is never applied in any deployment.

This is the declared-≠-enforced class that the unqualified-key rule's own comment calls the worst one (ADR-0049): an author believes a field is hidden or read-only, and it is not.

What done looks like

  1. A fields key whose object part is empty is refused at authoring with a prescription naming the OBJECT.FIELD form. It is refused by the lint the save door runs, so os validate and the save door answer alike.
  2. no_such_object.x (another package's object) keeps its current answer.
  3. Pin it at the save door on a real kernel, with the bare-key and deeper-key controls unchanged.

Reader who acts: triage routes it. The fix lands in packages/lint/src/validate-security-posture.ts, which is domain:spec per the lane table (packages/lint). The family is #23539, whose dev measured the split rule (the text before the first ., as getFieldPermissions and rewriteFieldRuleKeys read it).

Dedupe

MCP search_issues on objectstack-ai/objectstack, closed included, query permission set fields key empty object part leading dot accepted FLS never enforced security-fls-unknown-field skip: 13 hits, all closed. None is this.

Dedupe words: FLS key empty object part; fields key leading dot accepted; security-fls-unknown-field skip empty object.

Activity

  1. objectstack-fleet commented on Oct 14, 2026

    @objectstack-fleet
    ContributorAuthor

    Triage: first grade · bug · security · priority:p1 + target:v18 · domain:spec · pm:queue · triage seat · session_01AavokzJ5DndAwitDXvKy4U · 2026-10-14T22:55Z

    Read fresh. A permission set's fields key with an empty object part ('.full_name') saves with success: true (measured on a real kernel). Neither FLS lint rule catches it (read), and getFieldPermissions never matches it (read). An author believes a field is hidden or read-only, and in every deployment it is not. This is ADR-0049's declared ≠ enforced class, on a permission surface.

  2. objectstack-fleet commented on Oct 14, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 1 (#23549: a permission set's fields key with an empty object part ('.FIELD') is refused at authoring by the lint the save door runs, with a prescription naming the OBJECT.FIELD form, so os validate and the save door answer alike. no_such_object.x keeps its answer. Pinned at the save door on a real kernel beside the bare-key and deeper-key controls; triage 6180310991, p1 + target:v18, protocol-first class) · 2026-10-14T23:02Z
    Session: session_01YRp5z4kK6vgYkrdH97ztsy
    Account: huangyiirene (the seat's linked user as GET /user answers it; always the card's assignee)
    Branch: claude/issue-23549-fls-empty-object-part
    Worktree: objectstack-issue-23549
    Domain: domain:spec
    Seat: domain:spec#1 (seat post #6017)
    File surface (at origin/main 8226755ee or later; stop on a breach and explain it in the report):


    Generated by Claude Code

  3. objectstack-fleet commented on Oct 15, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report
    {
    "issue": 23549,
    "status": "done",
    "branch": "claude/issue-23549-fls-empty-object-part",
    "pr": "#23560",
    "session": "session_01YRp5z4kK6vgYkrdH97ztsy (the dispatching PM session's id; this run is its subagent)",
    "premise_still_valid": true,
    "summary": "security-fls-unqualified-key now treats a fields key as object-qualified only when it has a non-empty object part before its first '.', so '.FIELD' (and '.', '..x', '.x.y') is refused at error with a message naming the empty object part and a fix line naming the OBJECT.FIELD form (spelled with angle-bracket placeholders in the runtime text; an example is given only when the remainder is one plain name). security-fls-unknown-field gains the matching dot === 0 skip, so each key earns exactly one finding; one findings.push site serves both shapes, and no rule id, error code, export, schema or migration entry is added (Clause-2 stays no (narrowing)). H2 reading implemented: an empty object part IS an unqualified key (id, consequence and remedy all fit; no second consumer branch). H1 confirmed pre-fix (new pins red on 8226755 source: 0 findings, and the save door stored '.full_name'); H3 edges each answer one rule; H4: no load or boot path runs the lint (CLI doors judge source config; the runtime gate runs only from saveMetaItem and promoteDraftForPublish), and on a real kernel a stored ACTIVE set holding '.full_name' survives a reboot and reads back, while its re-save is refused 422 and a draft re-save lands; H5: 0 such keys in the corpus at 8226755. no_such_object.x and OBJECT.FIELD unchanged. PR #23560 is draft, base main, assignee huangyiirene, head 8e4f65c (branch merged with origin/main 086fa46 before opening).",
    "tests": "Pre-fix (H1, tests-only commit 1e6a4a3 on 8226755 source): lint file 'Tests 6 failed | 8 passed | 152 skipped' (AssertionError: expected [] to deeply equal [ 'security-fls-unqualified-key' ]); save-door pin 'Tests 1 failed | 4 passed (5)' (the save of fields key ".full_name" must be refused: expected undefined to be defined). Final head 8e4f65c, under os-verify-lock: pnpm --filter @objectstack/lint test 'Test Files 139 passed (139) / Tests 6755 passed (6755)', lint typecheck exit 0; objectql src/protocol-save-permission-fls-key-forms.test.ts --project local 'Tests 6 passed (6)' (refusal: code INVALID_METADATA, status 422, issues ['security-fls-unqualified-key'], hint names the form, nothing stored; draft publish refused the same way, only the draft row remains; controls: bare key 422 unqualified-key, deeper key flskey_customer.full_name.x 422 unknown-field; negative controls flskey_customer.full_name and no_such_object.x saved and stored as written); objectql typecheck exit 0; @objectstack/cli --project unit test/data-model-rules.test.ts + test/explain-rule-id.test.ts 'Tests 70 passed (70)'; packages/qa/dogfood test/showcase-permission-zoo.dogfood.test.ts 'Tests 10 passed (10)' (run once on the final head). Pre-merge head 57db599 (same diff; the merge brought nothing into these packages): @objectstack/metadata-protocol 'Test Files 236 passed | 3 skipped (239) / Tests 28409 passed | 19 skipped'; @objectstack/plugin-security 'Test Files 185 passed (185) / Tests 3690 passed | 45 skipped'; @objectstack/objectql --project local 'Test Files 420 passed (420) / Tests 8119 passed (8119)'. Ablation (one, on the widened guard; fix committed first at 0b318c1): scripts/ablation-replace.mjs anchor 'if (dot > 0) continue; // a non-empty object part' x1 -> x0, replacement carrying marker ABLATION_23549 x0 -> x1, blob c25d7b17c299 -> 0995d0684e9d; lint rebuilt; ablation-dist-preflight: marker present in 4 built files (index.js/.cjs, runtime.js/.cjs). Mutated: lint file 'Tests 6 failed | 160 passed (166)' (exactly the six new empty-object-part cases), save-door pin 'Tests 1 failed | 4 passed (5)'. Restore: git checkout HEAD, blob == HEAD c25d7b17c299, git diff HEAD empty; rebuilt; preflight --absent: marker absent from all 20 built files, tree clean; lint file 166 passed, pin 5 passed. Direction observed: turns red (the expected direction). Own outer trap on EXIT INT TERM restoring the absolute path, plus the tool's wrap-mode trap.",
    "gates": "Local, at final head 8e4f65c: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack (merge base 086fa46, 4 paths) derived 65 commands, identical to the PM lead pm-gates.txt; all 65 ran, each exit captured before any pipe, all exit 0; --ran reconciliation: 'Run reconciliation - 65 derived, 65 run, 0 NOT-MEASURED, 0 UNRUN' (exit 0). On this diff: check:adr-0087-registration exit 0 ('1 declared-breaking changeset(s), each carrying an ADR-0087 disposition ... not-required (no-migration-prescription)'), check:empty-changeset exit 0, check:changeset-no-major exit 0, check:test-source-alias exit 0, check:objectql-double-limit exit 0, check:type-check-coverage exit 0, check:type-check-debt exit 0, check:nul-bytes exit 0. Spec moved on the main side of the merge: pnpm --filter @objectstack/spec check:generated after a fresh turbo build (74/74 tasks) 'All 14 generated artifacts are up to date' (exit 0). First pass at 57db599: 64 exit 0 and check:dual-build-cjs-loads exit 3 (PREREQUISITE NOT MET: packages without dist) until check:type-check-debt's own turbo build populated them; re-run exit 0 and reconciled 65/65/0/0. CI (PR #23560 check-runs on 8e4f65c, read once right after opening, not waited on): completed success - Check Documentation Links, Check PR Size, No other open PR may claim the same issue, No other open PR may claim the same single-writer path, Part-of PR must not also close its card, The card this PR closes must claim this branch, filter; in_progress - Build Core, Test Core (1/6..6/6), Governed Surface Queue Guard, Check Changeset, Lint · ESLint, Lint · CI release and engine gates, Lint · PM dispatch-gates, Lint · docs and code guards, Lint · repo and tooling gates, Lint · scoped (query-options erasure, slot lookup and corpus, tooling self-tests), Type Check · workspace, Type Check · consumer gates, Type Check · source gates, Type Check · debt ledger, Dogfood Regression Gate, Auto Label, Flag docs affected by code changes; skipped - Build Docs, Console Pin Gate, Dogfood Regression Gate (matrix), Dogfood Verify CLI, Packed-tarball smoke, Temporal Conformance.",
    "mcp_calls": "0 - no MCP GitHub tool was called",
    "api_writes": "2 relay strokes from this session (POST /repos/objectstack-ai/objectstack/dispatches, each executed by the fleet-write workflow as objectstack-fleet[bot]): (1) pr_create = POST /repos/objectstack-ai/objectstack/pulls (draft) + POST /repos//issues/23560/assignees ['huangyiirene'], read back byte-identical (10996 bytes); (2) this os-dev-report comment = POST /repos//issues/23549/comments. label-write: 0 writes (the dispatch names no PR label and the diff publishes, so skip-changeset does not apply; the label set is empty). git push is not a REST write.",
    "open_questions": [],
    "out_of_scope_findings": [
    "carrier: #22161 (its census slices own packages/lint/src/rule-explanations.ts serially) · noted, not filed: security-fls-unqualified-key has no os explain entry, so its full reasoning (and now the empty-object-part shape) lives only in the verdict and the code comment. Polish, not a defect class."
    ],
    "line_budget": "n/a (no skills/** in the diff)",
    "deviations": [
    "The changeset states the before and the now for '.FIELD' in words, not under a literal FROM -> TO label: check:adr-0087-registration reads a label-positioned FROM/TO placeholder as a migration prescription, which contradicts the not-required (no-migration-prescription) disposition that fits here (os migrate meta cannot derive which object an empty object part meant, and the dispatch rules out a migration entry). Same shape as the lint-only save-door narrowing precedent .changeset/pre/22042-nested-validation-predicate-verdict.md. Gate green. Flagged as a dispatch-text vs gate tension, not silently resolved.",
    "Merged origin/main (8226755..086fa46, 4 commits, none touching packages/lint or the new pin) into the branch before opening the PR, per AGENTS.md Multi-agent section 10. Consequence: the metadata-protocol, plugin-security and objectql-local full-suite counts are from the pre-merge head 57db599 (same diff); the targeted pins, lint, cli, dogfood and all 65 gates were re-run on the final head 8e4f65c.",
    "The save-door pin carries a sixth case beyond the dispatch's suggested pins (draft save lands, its publish is refused 422 with the same rule, no active row written), covering the second gated door; same file.",
    "Two in-surface test-file edits beyond the new cases: 'remainder' joined the receiver meta-test's PLUMBING set (a string local, as flsField is), and the skip-1 test comment that recorded '.description' as a deliberate non-judgment was rewritten, since this card reverses that disposition (the matching source comment too).",
    "H4 was measured with a temporary test file in packages/objectql/src, run once and deleted; never committed.",
    "The harness attribution reminder asked for a model-named Co-Authored-By trailer and a different PR footer; commits carry the model-free pair (Claude-Session + Co-authored-by: Claude) and the PR body carries the session-URL footer, per AGENTS.md, which the reminder says takes precedence. The merge commit 8e4f65c carries git's default message with no trailers.",
    "Reading #23539's resume branch fetched it into a private ref refs/os-dev-23549/b23539 (deleted afterwards); the same fetch refreshed the shared remote-tracking ref origin/claude/issue-23539-references-permission-fields-keys."
    ],
    "files_changed": [
    ".changeset/23549-fls-empty-object-part.md",
    "packages/lint/src/validate-security-posture.ts",
    "packages/lint/src/validate-security-posture.test.ts",
    "packages/objectql/src/protocol-save-permission-fls-key-forms.test.ts"
    ]
    }


    Generated by Claude Code

  4. objectstack-fleet commented on Oct 15, 2026

    @objectstack-fleet
    ContributorAuthor

    ACCEPT → PR #23560 comment 6181855822 · contract review PASS 6181842017 on d72dd9639 (after FAIL 6181725039 on 8e4f65c2c, a prose-only repair) · domain:spec seat 1 (#6017) · session session_01YRp5z4kK6vgYkrdH97ztsy · 2026-10-15T00:54Z


    Generated by Claude Code

  5. objectstack-fleet commented on Oct 15, 2026

    @objectstack-fleet
    ContributorAuthor

    Landed: PR #23560 → 30b0fc241 (Fixes #23549) · 2026-10-15T02:09Z

    domain:spec seat 1 (#6017) · huangyiirene · session session_01YRp5z4kK6vgYkrdH97ztsy · holder of claim 6180412113. Records:

    • CONTRACT_REVIEW_TIER review FAIL 6181725039 on 8e4f65c2c, on a published sentence;
    • a prose-only repair, then PASS 6181842017 on d72dd9639;
    • ACCEPT ## 席位核对 6181855822 on d72dd9639.
    Reading Probe Answer
    The PR GET /pulls/23560 merged: true at 02:09:08Z, merge_commit_sha 30b0fc241
    The queue branch GET /git/matching-refs/heads/gh-readonly-queue/main/pr-23560 none left: the merge group was consumed
    origin/main git merge-base --is-ancestor 30b0fc241 origin/main yes. 30b0fc241 is a squash on f3fd209b3; the tip 249130fb3 sits one commit above it
    The card GET /issues/23549 closed / completed at 02:09:10Z, on the merge

    Carried: security-fls-unqualified-key has no os explain entry, and its message now has two shapes. carrier: #22161 (its census slices own rule-explanations.ts).

    State change: this act removes pm:dispatched.


    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

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions