Repository navigation
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
Description
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
fieldsholds the key'.full_name'(an empty object part) saves withsuccess: 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 (422security-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 forno_such_object.x, an object another package may declare. No package can declare an empty name, becauseObjectSchema.nameis/^[a-z_][a-z0-9_]*$/(object.zod.ts:2042).
- The unqualified-key rule skips every key that contains a
- Read,
packages/plugins/plugin-security/src/permission-evaluator.ts:getFieldPermissionskeeps only keys that start withOBJECT.(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
- A
fieldskey whose object part is empty is refused at authoring with a prescription naming theOBJECT.FIELDform. It is refused by the lint the save door runs, soos validateand the save door answer alike. no_such_object.x(another package's object) keeps its current answer.- 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.
- [finding] the object-schema FLS mask drops an action whose param names a field of ANOTHER object (
objectOverride), so a delegated_admin is served noinvite_userthough the invite door admits them #21884, security(spec): a capability-gated field (requiredPermissions, no maskingRule) is served stored to a caller who resolves no permission set, while explain reports it hidden; narrowing it moves getReadableFields' contract answer #21063, security(plugin-security): the engine's field guard does not judge a cross-field comparand that names a field the caller may not read, so a comparison against a hidden field is served instead of refused 403 #20932 and tests(access-security): pin the read side of FLS mask/strip and the persona×CRUD cell matrix — coverage gaps from QA run #9401 #9481 are other FLS defects (cross-object action params, capability-gated fields, cross-field comparands, read-side pins). - security(core, plugin-security): an unauthenticated principal never resolves the permission sets bound to the
guestanchor; ADR-0090 D9 is declared and seeded but not enforced #21158, security(plugin-security): a non-system caller who resolves no permission set is admitted to every object and read with no row scope; an empty set list grants by absence instead of answering the deny baseline #21079, security (P0 suspect): a non-system caller who resolves no permission set — an unauthenticated one included — is admitted to aggregate any object at an analytics door, object admission and row scope skipped #21061, security: with no active organization, resolvePermissionSetsForContext reads the principal's position names as permission-set names organization-less, and sys_permission_set.name has no reserved-identity guard (NOT MEASURED; from PR #20540's review) #20555 and security (P0 suspect): the managed-object blanket gives non-admin shipped sets an object-level read on twoaccess: privatecredential-bearing identity objects that the sets themselves declare deny-by-design #20027 are permission-set resolution defects. - security(data): records-forms.field-type-matrix clause 4 / encrypted-field-behavior N1 (credential-field handling on writes) fail at 316be321e — detail withheld pending maintainer #21787, security explain: the fls layer's three field-mask states are expressible only in the
detailfree-text string — no structured per-field slot onExplainLayer#9329, finding: the whole /automation read domain is gated only by "authenticated" — run-detail returns the triggering record's fields without that record's own FLS #7900 and 元数据面未做字段级权限掩码:/meta 返回完整 fields,导入模板列不受 FLS 约束,客户端 field.permissions 守卫是死代码 #3661 are other subjects.
Dedupe words: FLS key empty object part; fields key leading dot accepted; security-fls-unknown-field skip empty object.
Activity
objectstack-fleet commentedon Oct 14, 2026
Triage: first grade ·
bug·security·priority:p1+target:v18·domain:spec·pm:queue· triage seat ·session_01AavokzJ5DndAwitDXvKy4U· 2026-10-14T22:55ZRead fresh. A permission set's
fieldskey with an empty object part ('.full_name') saves withsuccess: true(measured on a real kernel). Neither FLS lint rule catches it (read), andgetFieldPermissionsnever 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./metasave accepts. So it is p1 +target:v18(filing-gate.md〈定级判据〉), and it lands inside the major.security: a declared field mask that never enforces.domain:spec(packages/lint/src/validate-security-posture.ts), in metadata-protocol: findReferencesToMeta ("Used by") misses a permission set that names an object only in itsfieldskeys (OBJECT.FIELD), so the panel can call an object safe to delete while field rules name it #23539's family.OBJECT.FIELDform.no_such_object.xkeeps its answer.os validateand the save door answer alike.objectstack-fleet commentedon Oct 14, 2026
Claim: PM loop round 1 (#23549: a permission set's
fieldskey with an empty object part ('.FIELD') is refused at authoring by the lint the save door runs, with a prescription naming theOBJECT.FIELDform, soos validateand the save door answer alike.no_such_object.xkeeps its answer. Pinned at the save door on a real kernel beside the bare-key and deeper-key controls; triage6180310991, p1 +target:v18, protocol-first class) · 2026-10-14T23:02ZSession:
session_01YRp5z4kK6vgYkrdH97ztsyAccount:
huangyiirene(the seat's linked user asGET /useranswers it; always the card's assignee)Branch:
claude/issue-23549-fls-empty-object-partWorktree:
objectstack-issue-23549Domain:
domain:specSeat:
domain:spec#1(seat post #6017)File surface (at
origin/main8226755eeor later; stop on a breach and explain it in the report):packages/lint/src/validate-security-posture.ts: the two FLS key rules only (security-fls-unqualified-key/security-fls-unknown-field, near:750–:830), and its unit test.packages/lint/src/rule-explanations.ts: the touched rule's explanation entry only, if its text names the guard..changeset/23549-*.md.packages/spec/**(PermissionSetSchema.fieldsstaysz.record(z.string(), …)). ⛔ Notplugin-security's evaluator. ⛔ Not any other lint rule.Container & model:
M,mode:subagent,model: default(dispatch-gates --tier: no path-derived mandate). Contract face: the save door's accept set NARROWS (triage's protocol-first class), so oneCONTRACT_REVIEW_TIERreview runs before the queue, as an isolated at-tier subagent.Clause-②: no (narrowing)
Responsibility:
@objectstack/lint's FLS key rules. The unqualified-key rule skips every key containing a., and the unknown-field rule reads an empty head as "not this stack's object", so'.FIELD'passes both. |getFieldPermissionskeeps only keys startingOBJECT., so the declared mask never enforces (ADR-0049 declared ≠ enforced, the class the rule's own comment calls the worst). | Any author or AI writing a field mask: they believe a field is hidden or read-only, and in every deployment it is not.Thread-read: 6180310991
Serial constraints cleared:
validate-security-posture.ts, its tests orrule-explanations.ts(PR feat(lint)!: the two page component rules run on the runtime publish door (ruling B) #23418 touches otherpackages/lintfiles; [maintainer] validate: thefield-no-consumerswarning is one 856-character line, printed by validate, build and dev alike — one-line verdict +rule:id + a pointer to the full reasoning (os explain, which today takes only schema names) #22161's census slices are not in flight).name, and a bare object's view item keeps itsOBJECT.VIEWKEYname #23538 (p2, this lane,duplicatePackage) waits behind this card: p1 first, batch 1.Clause-②: yes (narrowing); the dev measures that, and this claim is amended if so.Generated by Claude Code
objectstack-fleet commentedon Oct 15, 2026
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
objectstack-fleet commentedon Oct 15, 2026
ACCEPT → PR #23560 comment 6181855822 · contract review PASS
6181842017ond72dd9639(after FAIL6181725039on8e4f65c2c, a prose-only repair) ·domain:specseat 1 (#6017) · sessionsession_01YRp5z4kK6vgYkrdH97ztsy· 2026-10-15T00:54ZGenerated by Claude Code
objectstack-fleet commentedon Oct 15, 2026
Landed: PR #23560 →
30b0fc241(Fixes #23549) · 2026-10-15T02:09Zdomain:specseat 1 (#6017) ·huangyiirene· sessionsession_01YRp5z4kK6vgYkrdH97ztsy· holder of claim6180412113. Records:CONTRACT_REVIEW_TIERreview FAIL6181725039on8e4f65c2c, on a published sentence;6181842017ond72dd9639;## 席位核对6181855822ond72dd9639.GET /pulls/23560merged: trueat 02:09:08Z,merge_commit_sha30b0fc241GET /git/matching-refs/heads/gh-readonly-queue/main/pr-23560origin/maingit merge-base --is-ancestor 30b0fc241 origin/main30b0fc241is a squash onf3fd209b3; the tip249130fb3sits one commit above itGET /issues/23549closed/completedat 02:09:10Z, on the mergeCarried:
security-fls-unqualified-keyhas noos explainentry, and its message now has two shapes. carrier: #22161 (its census slices ownrule-explanations.ts).State change: this act removes
pm:dispatched.Generated by Claude Code