Repository navigation
fix(lint): a permission set's field key on a field an objectExtensions entry adds is not refused as security-fls-unknown-field (#30303) - #30359
Conversation
… it, extension fields included security-fls-unknown-field read an object's declared fields only, so a permission set naming a field that an objectExtensions entry adds (a sibling package's in a multi-package artifact, or the stack's own) was refused, although the runtime serves the field and the key masks it. The FLS-key index is now built over objectsAsServed(stack): the shared extension fold in object-graph.ts, mirroring the objectql registry (fields by name, one entry per package per object, extend verbatim). The controlled-by-parent merge (#23210) and the membership-key check read the same fold instead of their own copies. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WGufzfKwxFVA5m6bAjSpxf
📓 Docs Drift CheckThis PR changes 1 package(s): 1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 4 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin a33c42c34ae447c2d92c3de199bb10426a9419bc && git checkout a33c42c34ae447c2d92c3de199bb10426a9419bc
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin fdad1786e892270ac3fcc161b3fd4d57c9c47748 8aeb321249e63c295e811b973421491e1871d3c9 && git checkout -B drift-repro fdad1786e892270ac3fcc161b3fd4d57c9c47748 && git merge --no-ff 8aeb321249e63c295e811b973421491e1871d3c9
node scripts/docs-audit/affected-docs.mjs --json fdad1786e892270ac3fcc161b3fd4d57c9c47748
|
|
domain:spec#3 · os-support-ai · session_01WGufzfgvGexKn9vLHsy89K Contract-review-claim: session_01WGufzfgvGexKn9vLHsy89K · head f85110d · 2026-11-05T07:47Z Full review of the first head at |
|
domain:spec#3 · os-support-ai · session_01WGufzfgvGexKn9vLHsy89K Contract reviewServed-tier: ① Derived judgments
② Semver level
③ Boundary flags
④ Design judgmentThe fix is at the right layer (lint's object index, not a consumer fallback) and in the right shape: one exported fold next to Implemented-by: VERDICT: PASS |
…not mirror the legacy-name fold objectExtensionsByTarget and objectsAsServed get the junk-input cases their neighbours have: a null stack, non-record packages entries and bodies, a non-array objectExtensions, non-record entries, junk fields and a string priority. The fold's docblock says it names the target verbatim and does not mirror the registry's short-name to legacy ns__name filing (#28858), so such an entry folds onto nothing and a key naming its field stays refused. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WGufzfKwxFVA5m6bAjSpxf
|
domain:spec#3 · os-support-ai · session_01WGufzfgvGexKn9vLHsy89K 维护者速读
席位意见:fable 契约复审 PASS,没有必须修的。四条建议都已采纳:补了 6 个异常输入的钉,注释写明不跟注册表做短名折叠(这样偏向拒绝,是安全方向),正文的普查也改正了。另有 12 条规则有同样的盲点,已另开卡 #30383。 席位核对 — ACCEPT · domain:spec#3 · head
|
domain:spec#3 · os-support-ai · session_01WGufzfgvGexKn9vLHsy89K
Requested by Yibo Wang · project thread
Fixes #30303
Clause-②: yes (widening: the FLS-key check accepts extension-added fields)
Metadata-protocol: none
Skills-docs: none owed — no skill, eval or doc teaches that a permission set may not name an extension field (
git grep -c "security-fls-unknown-field" -- skills content/docs ':!content/docs/releases': 0 hits;content/docs/data-modeling/object-extensions.mdxnames no permission or FLS key); the rule'sos explainentry is updated herePrior rulings read: security-fls-unknown-field,objectextensions,indexobjectgraph → 2 hits; ADR-0029 D3, ADR-0146 D10; thread: none; repo: objectstack-ai/objectstack
维护者速读
fields里的对象.字段键,现在按"注册表实际提供的对象"判断字段是否存在,即对象自己声明的字段加上同一制品中任何包通过objectExtensions加的字段;真不存在的字段照旧报security-fls-unknown-field,规则 id、级别、消息不变。crm_contract.o2c_probe_note配字段权限,pnpm build/objectstack dev都被这条规则拒绝,而运行时明明提供这个字段;hotcrm#2315 第 1 阶段因此卡住。extend按原名匹配)。另外把两份已有的扩展折叠(finding(security): anobjectExtensionsentry that adds a second requiredmaster_detailto acontrolled_by_parentobject passesdefineStackand the posture lint, then fails closed at runtime — neither door judges the merged shape #23210 的 controlled-by-parent 和 membership-key)收成一份共享实现,两边原有测试全绿。回滚即还原本 PR。packages/lint/src/object-graph.ts新增的objectExtensionsByTarget/objectsAsServed;validate-security-posture.ts里flsGraph那一行和 CBP 段改读共享折叠;新测试validate-security-posture-fls-extension.test.ts。pnpm --filter @objectstack/lint exec vitest run src/validate-security-posture-fls-extension.test.ts,预期 8 例全绿(含 per-package 和 union 两种 CLI 运行形态)。What changes
packages/lint/src/object-graph.ts: one shared extension fold.objectExtensionsByTarget(stack): everyobjectExtensionsentry by the object itextends, as the registry registers them. Withpackages[], every body's entries (carrier= body index, the artifact's registration order); without it, the stack's own. One entry per package per target (a later entry of the same package replaces an earlier one whole). Fold order: ascendingpriority(default 200), then package order.extendnames its target verbatim; the registry's short-name to legacyns__namefiling (objectql: a manifest objectExtensions entry or hydrated row whose extend folds to a legacy key opens a second, owner-less object entry #28858,SchemaRegistry.extensionTargetKey) is NOT mirrored, so such an entry folds onto nothing and a key naming its field stays refused (the safe direction), as the docblock says.objectsAsServed(stack): the stack'sobjectswith those entries'fieldsmerged by name (a later writer replacing the whole field), plus thetiedkeys two packages write at the deciding priority. Only fields move;fieldPatchesadd no field and are not read.validate-security-posture.ts: the FLS-key check reads the served objects.flsGraphis nowindexObjectGraph({ objects: objectsAsServed(stack).objects }). The three skips (an object this stack does not define, an object with no readable field map, a registry-injected column) are unchanged, and the injected-column plan is unchanged too:resolveInjectedSystemColumnsreads no field, as the registry plans it over the base layer. The finding's rule id, severity,where, path and message are unchanged; the hint's nearest name now also considers extension fields.objectExtensionsentry that adds a second requiredmaster_detailto acontrolled_by_parentobject passesdefineStackand the posture lint, then fails closed at runtime — neither door judges the merged shape #23210,pushExtensionMergedCbpFindings) iteratesobjectExtensionsByTargetinstead of its own collector; its target set (bodies' declarations, a target two bodies declare left unjudged) and its blame logic are unchanged;validate-membership-key-declarations.tsdrops its privatemergedObjectsOfforobjectsAsServed(same algorithm, sametiedoutput).rule-explanations.ts: thesecurity-fls-unknown-fieldentry says the columns are the object's as the registry serves it, extension fields from any package of the artifact included.validate-security-posture.test.tskey-read meta-test (validate-expressions / validate-security-posture 也有同形的 spec 不声明键的??别名读法(#5009 建议 3 的核对结果) #5017): the scan now also reads the shared fold's code, so the reads that moved out of this rule (stack.objectExtensions,body.objectExtensions,ext.extend/fields/priority) stay held to the same schema tables; the fold's own collections are tabled as plumbing..changeset/30303-fls-key-extension-fields.md:@objectstack/lintpatch.object-graph.test.ts(review round 1): junk-input pins forobjectExtensionsByTargetandobjectsAsServedbeside theindexObjectGraph/packagesOfcases: a null or non-record stack,packages: [null]and other non-record entries or bodies, a non-arrayobjectExtensions, non-record entries and a non-stringextend, junkfields(and the array form read by name), and a stringpriority(read as the default 200; the schema refuses it). Neither export throws; a malformedpackagesstayspackagesOf's refusal, met first byvalidateSecurityPosture.The runtime merge mirrored
ObjectQL.registerApp(packages/objectql/src/engine-registration.ts, step 2b) registers each manifestobjectExtensionsentry as anextendcontributor viaSchemaRegistry.registerObject(..., 'extend', ext.priority ?? 200)under the nameext.extendspells. Theextendarm removes that package's earlier contribution to the same object (contributors.findIndex(c => c.packageId === packageId && c.ownership === 'extend')), contributors sort by ascending priority, andfoldExtendersOntoDefinitionapplies each throughmergeObjectDefinitions, whosefieldsmerge is{ ...base.fields, ...extension.fields }.fieldPatches(ADR-0146 D10) patch only the owner's own fields. An extension'squeueor audit field is governed in the fold, but the name stays served (the injected column), so the served name set is unchanged by it. ADR-0029 D3 confirms any package mayextendanother's object; ADR-0146 D10's__creservation is refused at other doors (publish,os validate, install), not by the merge, so this check does not judge it.Extensions the registry does not merge, still refused (pinned)
fieldPatches;extend: 'crm__crm_contract').An extension of an object no body declares stays unjudged as before (skip 1: the object may be another installed package's).
Census: rules that resolve
object.fieldthrough the same indexgit grep -n "indexObjectGraph" packages/lint/src(non-test), at this head:validate-security-posture.ts(FLS keys)objectsAsServed(stack)validate-membership-key-declarations.tsobjectsAsServed(stack)validate-dataset-measure-aggregates.tsindexObjectGraph(stack)validate-dataset-references.tsindexObjectGraph(stack)validate-field-consumers.tsindexObjectGraph(stack)validate-list-view-field-refs.tsindexObjectGraph(stack)validate-object-field-refs.tsindexObjectGraph(stack)validate-page-field-bindings.tsindexObjectGraph(stack)validate-preset-comparands.tsindexObjectGraph(stack)validate-report-runtime-filter.tsindexObjectGraph(stack)validate-rls-predicate-enforceability.tsindexObjectGraph(cfg)validate-sharing-rule-enforceability.tsindexObjectGraph({ objects: [target] }), the declared anchorvalidate-thank-you-record-fields.tsindexObjectGraph(stack)validate-widget-bindings.tsindexObjectGraph(stack)The twelve "no" rows read the declared objects only, so a path naming an extension-added field reads as unknown there: the same blind spot, by reading (not reproduced per rule). The shared fold does not fix them for free: each would need its index input switched to
objectsAsServed, a widening of its own with its own pins. Carried, not changed here; the seat files or routes them.Two more rules read extension fields through private readers of their own rather than the index, and are carried with the twelve for consolidation onto the shared fold:
validate-related-list-refs.ts: the stack's top-levelobjectExtensionsonly (nopackages[]bodies);validate-mapping-target-fields.ts(extensionFieldsByTarget): the top level plus every body, additive (no one-entry-per-package replacement).Why
On a multi-package artifact (HotCRM, platform 18.3.0, hotcrm#2315), the app package owns
crm_contractand the permission sets, and a module package addso2c_probe_notetocrm_contractthroughobjectExtensions. The runtime serves the field (GET /api/v1/meta/object/crm_contractlists it), but every app-package permission set namingcrm_contract.o2c_probe_notewas refused withsecurity-fls-unknown-field, because the FLS-key index readstack.objectsonly. Both CLI runs saw it: the per-package run over the app body (packageBodyAsStack) and the union run (authoringRuleUnionStack). The maintainer's ruling of 2026-11-05 on hotcrm#2315 puts the module's field access in the app package's permission sets, so the refusal blocked the ruled design.Verification
Under
os-verify-lock,NODE_OPTIONS=--max-old-space-size=4096. Round 1 head8aeb3212(the review delta: junk-input pins and the fold's docblock); the ablations below were run atf85110db, whose code the delta does not change.pnpm --filter @objectstack/lint testat8aeb3212: Test Files 194 passed (194), Tests 8015 passed, 5 skipped (8009 atf85110db; +6 junk-input pins inobject-graph.test.ts, 47/47 in that file).pnpm --filter @objectstack/lint typecheck(tsc +check:test-typecheck): VERDICT command-exit 0.validate-security-posture-fls-extension.test.ts(8 tests). Every stack is parsed byObjectStackDefinitionSchemafirst, and each artifact is judged on both shapes the CLI hands the rule: the per-package run over the app body (packageBodyAsStack) and the union run (authoringRuleUnionStack).crm_contractand the set namingcrm_contract.o2c_probe_note; atype: 'module'body extendscrm_contractwith it): no finding; the same artifact without the extension: refused;security-fls-unknown-field,error, withwhereandmessagebyte-equal to a single-object control's;objectExtensions: no finding; without it: refused;validate-security-posture-extension-merge.test.ts(finding(security): anobjectExtensionsentry that adds a second requiredmaster_detailto acontrolled_by_parentobject passesdefineStackand the posture lint, then fails closed at runtime — neither door judges the merged shape #23210, parse and lint agree on every merged shape),validate-membership-key-declarations.test.ts(tied-priority pin included),validate-rls-predicate-enforceability.membership-keys.test.ts.Reverse verification (two mutations, each restored by scripts/ablation-replace.mjs to the HEAD blob)
flsGraphback toindexObjectGraph(stack)invalidate-security-posture.ts(anchor 1 → 0, blobcdfe1d906f6a→5f665455010e): the new file goes 5 failed | 3 passed (8). The five red are the four acceptance pins and the "same package's later entry" twin; the three still green are refusal pins, which hold either way. Restored: blob == HEADcdfe1d906f6a,git diff HEADempty..filter((other) => other.carrier !== carrier)→.filter(() => true), blob76abadb6999e→4f04096e54a2): 1 failed | 7 passed (8), the "earlier entry replaced" refusal pin. Restored: blob == HEAD76abadb6999e,git diff HEADempty.The lint tests import the rule from
src(relative imports), so no dist rebuild sat between mutation and run.Gates: dispatch-gates --commands, 59 derived at 8aeb321, 59 run, 0 NOT-MEASURED
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --ran gates-ran.list: "59 derived famil(ies) accounted for — 59 run, 0 NOT-MEASURED", exit 0. Record:/mnt/project-files/issue-30303/gates-ran.list(every lineexit 0),check-changeset-no-majorandcheck:nul-bytesincluded.8aeb3212): the derived set is the same 59 families as atf85110db; all 59 re-run after a fullturbo run build(VERDICT command-exit 0), every one exit 0 on the first pass.f85110db, first-pass exit 3 on four, each re-run green on the same head:check-plugin-teardown-shape --self-test(shallow clone; its pinned fixture commit fetched),check:docs-transcript-drift,check:dual-build-cjs-loads,check:lean-entry-closure(no dists; green after a full build).!in the title and no(narrowing)arm: no full dogfood run. CI owns the rest of the farm.Acceptance notes
objectExtensionsByTargetkeys a package by its rawpackages[]index; the old controlled-by-parent collector used its index among the record members (packagesOffilters non-records). They differ only on an artifact whosepackages[]holds a non-record member, which the schema refuses; the path then points at the real array position.mergedObjectsOfdocblock said a tied field is "left out of the merged map"; its code merged it and reported it intied. The shared fold keeps what the code did, and its docblock now says so; the membership rule's verdict on a tied field is still "not judged".Human Minimum
packages/specholds no runtime logic. (not touched)pnpm lintpasses (CI-owned run).main, one pull request.packages/spectouched: n/a.content/docs/references/,packages/*/CHANGELOG.mdorcontent/docs/releases/.🤖 Generated with Claude Code
https://claude.ai/code/session_01WGufzfKwxFVA5m6bAjSpxf