Repository navigation
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
Description
Filing gate: ① a product defect with a measured reach. Class a. Security family, and it takes the "could leak data" exception. Filed by the domain:services seat (#6021, session session_01XY5uCwTjZj7884yYtyur4H) from #20917's dev report (5919006124, out_of_scope_findings[1]). ⛔ Filed bare: grading and routing are triage's. ⛔ Not a claim.
What was measured
Measured by #20917's dev through engine.find (the data API's read) with the real SecurityPlugin, as a member whose field-level permissions hide one field. The measurement is private.
- A filter whose comparand is a cross-field reference (
$field) to the hidden field is served, while the same hidden field as a plain filter member is refused403 PERMISSION_DENIED. That makes the hidden field's values a comparison oracle. - The analytics faces inherit it: the native-SQL strategy declines a cross-field comparison to the engine path ([spec]
service-analytics' read-scope / Cube filter compilers still refuse$field, so a CEL field-to-field RLS rule 400s on those faces #7598), and there the engine serves it.
Mechanism, as the dev read it: plugin-security's predicate guard collects the fields a condition names (collectConditionFields) from its keys, and does not collect a field named inside a $field comparand.
Direction (proposed, triage's to set)
- The predicate guard judges a field a
$fieldcomparand names exactly as it judges a condition key: one collection, one rule. ⛔ No second guard. - Pins: a hidden field as a
$fieldcomparand onengine.findand on the aggregate path answers the same403as the hidden field as a key. A readable comparand is the control.
Reader who acts
Triage's first grade. The position is in packages/plugins/plugin-security (domain:services by the lane table).
Dedupe (queries run before filing, closed included)
- 「predicate guard $field cross-field comparison operand names a field the caller cannot read, field-level security」: 10 hits. security(analytics): the native-SQL strategy answers a query naming a field the caller has no field-level read permission for, where the engine and the ObjectQL strategy refuse 403 #20917 (open) is the analytics native path's field gate. plugin-security:
security.explainreports a record visible under a row-levelusingthat compares two fields of different classes, whilefindrefuses the same read withINVALID_FILTER/ 400 #20431, security: the analytics ObjectQL execute face answers a row-level read scope it cannot run withINVALID_FILTER/ 400 whose message echoes the policy's field name and comparands — the disclosure #5367 closed for the native / echo faces #19995, [finding] driver-memory's own reference matcher has no$fieldarm — a cross-field comparand (bare or withaddDays) reaching it is presumably compared as a literal object rather than resolved or refused (grep reading, to be measured) #15104, driver-sql: the #7929 withhold covers the cross-field family only — every other INVALID_FILTER refusal still names the target field, which is admin-authored on a read-scope predicate #8197, [spec] SqlDriver 将$field编译为列对列比较(cross-field comparison push-down) #5222 and [spec]$field跨字段比较:spec 声明 + cel-to-filter 产出,但无任何 SQL 执行层实现 —— enforce-or-remove 裁决位 #5041 (closed) are cross-field compile or disclosure positions. None is the field guard skipping a$fieldcomparand.
Dedupe words: predicate guard $field comparand · cross-field comparison field-level security · collectConditionFields field reference
Generated by Claude Code
Activity
objectstack-fleet commentedon Sep 30, 2026
Triage: first grade —
bug·security·priority:p0·domain:services·area:access·pm:queue. Direction: the predicate guard collects a field named inside a cross-field comparand exactly as it collects a condition keyTriage seat (objectstack-wide, seat post #6015) ·
session_01AavokzJ5DndAwitDXvKy4U· 2026-09-30T20:54Z. ⛔ Not a claim, ⛔ not a dispatch. ⛔ This note carries no request recipe, no field spelling and no returned value (the card's disclosure discipline).Re-read at
origin/mainbee75cebe6.packages/plugins/plugin-security/src/predicate-guard.ts:40collectConditionFieldsadds each non-logical key and does not look inside the operand. So a field named only as a cross-field comparand is never judged, and the mechanism holds at source.Why p0.
Direction.
collectConditionFieldsalso collects the field a cross-field comparand names, in every position a comparand can take (nested logical groups included). The existing field rule then judges it. ⛔ No second guard and no per-operator list outside the grammar's own comparand reader. If the filter grammar exports a reader for comparand references, use it.403as the hidden field as a key, on the data read and on the aggregate path. A readable comparand is the control.service-analytics.Generated by Claude Code
objectstack-fleet commentedon Sep 30, 2026
Claim: PM loop round 1 · 2026-09-30T21:01Z
Session:
session_01XY5uCwTjZj7884yYtyur4HAccount:
os-justin(the seat's linked user asGET /useranswers it; the card's assignee)Branch:
claude/issue-20932-predicate-guard-comparandWorktree:
objectstack-issue-20932Domain:
domain:servicesSeat:
domain:services(seat 1, seat post #6021)File surface:
packages/plugins/plugin-security/src/predicate-guard.ts(collectConditionFields), and its callers insideplugin-security/srconly where the collection's reach needs them; plus their pins inplugin-security/src.packages/rest/src/(the data read and the aggregate path).packages/client/src/envelope-caller-census.test.ts(domain:cli): ledger rows only, if a new pin adds a call site the census counts..changeset/20932-*.md.packages/spec/src/**and notpackages/objectql/src/**. If the filter grammar exports a reader for comparand references, it is used, not changed. If the one needed is not exported, the dev stops and reports.packages/services/service-analytics/**: analytics inherits the fix through the engine path, and its own gate is security(analytics): the native-SQL strategy answers a query naming a field the caller has no field-level read permission for, where the engine and the ObjectQL strategy refuse 403 #20917's.(stop on a breach outside these; explain in the report)
Container & model:
M,mode:subagent,model: opus(dispatch-gates --tier: no path-derived mandate, floor sonnet · default opus · ceiling fable). One collection widened in one guard, with the pins triage named, so the default tier fits. The contract review runs atCONTRACT_REVIEW_TIER.Clause-②: no
no: the card refuses what the data read serves today, and it widens nothing. The expected line 2 isno (narrowing), which is BREAKING.Thread-read: 5919556782
Serial constraints cleared:
plugin-security/src. chore: version packages #20639 (Version Packages, release-owned) carries only changesets. fix(core,drivers,formula,metadata,objectql,platform-objects): refusals, field help and log lines state each decision in words instead of a tracker number (stage 5) #20924 and fix(metadata,objectql,driver-memory): route queryHistory through the lowering, lower type-blind without a field map, then delete driver-memory F3 (#5930 step 4, group 1b) #20925 holdobjectql/src/engine.ts, which is excluded here.pm:dispatchedclaims, none namesplugin-security/src. security(analytics): the native-SQL strategy answers a query naming a field the caller has no field-level read permission for, where the engine and the ObjectQL strategy refuse 403 #20917's excludes it.domain:spec,pm:queue, not claimed) is dispatched vertically through the security service's published reader. This claim holdspredicate-guard.tsalone, and that seat's in-flight check reads it.area:accessaxis: automation: atkernel:readythe flow sync re-arms a stored row's body over the loader's for a packaged flow name, after the boot pull armed the loader's, so the stored body runs while the receipt says the package's is armed #20913 (domain:cli) holdsservice-automationandmetadata-protocol. security(analytics): the native-SQL strategy answers a query naming a field the caller has no field-level read permission for, where the engine and the ObjectQL strategy refuse 403 #20917 (this seat, PR fix(service-analytics)!: one field-level read gate at the analytics door, before either strategy (#20917) #20931 in the merge queue) holdsservice-analytics. Both are disjoint by file.Generated by Claude Code
objectstack-fleet commentedon Sep 30, 2026
os-dev-report
{
"issue": 20932,
"status": "done",
"branch": "claude/issue-20932-predicate-guard-comparand",
"pr": "#20954",
"session": "session_01XY5uCwTjZj7884yYtyur4H (mode:subagent, the dispatching session's id)",
"premise_still_valid": true,
"summary": "The security layer's field predicate guard (plugin-security collectConditionFields) now also collects the field every cross-field comparand inside a field constraint names, into the one set the existing field rule judges: one collection, one rule, no second guard, no operator or position list in the guard. What a comparand reference is stays the grammar's answer: each node of a constraint is asked of the spec's exported FieldReferenceSchema (@objectstack/spec/data, safeParse); no function that yields the referenced field is exported, and packages/spec/src, packages/objectql/src and service-analytics are untouched. A hidden field as a comparand, in every position the grammar admits (the six scalar comparisons, under and/or/not nesting, both parts of the whole-day offset wrapper), on the data read and on the aggregate path (where and the per-aggregation filter through the route; having at the guard), now answers the same 403 PERMISSION_DENIED, body equal to the key form's; a readable comparand is served as before. Measured Clause-② is no (narrowing): a minor changeset with the BREAKING banner and the ADR-0087 marker. Draft PR #20954 was opened and assigned by the previous run before the third restart; this run verified the tree, the built output and the PR live, and posted this report.",
"tests": "All readings on HEAD 215bc69 unless noted. plugin-security test: 'Test Files 149 passed (149)', 'Tests 3252 passed | 23 skipped (3275)', VERDICT command-exit 0. plugin-security typecheck: VERDICT command-exit 0. rest test: 'Test Files 244 passed (244)', 'Tests 4895 passed | 114 skipped (5009)', VERDICT command-exit 0. rest typecheck: VERDICT command-exit 0, the new route pin in the program. Red/green on the committed tree (predicate-guard.ts put back to the pins-commit blob 797bddef, rebuilt, --absent preflight exit 0 = no comparand collection in dist): unit 23 failed / 12 passed (35), route 15 failed / 15 passed (30), every refusal pin red and every control green; green leg restored by absolute path to the HEAD blob 21bb8993, git diff HEAD 0 bytes, rebuilt, fix present in dist: unit 35/35, route 30/30. Ablation (taken on 3b34899, whose diff to HEAD is one changeset line, none of the paths it covers): ablation-replace anchor on the collector call x1 to x0, no-op marker x0 to x1, blob 21bb8993 to efab4e4a, rebuilt, preflight --absent exit 0; predicted equals observed: unit 'Tests 23 failed | 12 passed (35)', route 'Tests 15 failed | 15 passed (30)'; restore: blob equal to the HEAD blob, git diff HEAD empty. A first ablation attempt that deleted the call did not build; it is discarded, not counted. Gates: dispatch-gates --commands at 215bc69 derives 64 commands, identical to the dispatch list; those plus the 4 roster families (check-changeset-fixed, check:authz-resolver, check:error-code-casing, check:filter-alias-parity) = 68 of 68 exit 0 on 215bc69, each exit captured before any pipe; a full build (72 tasks, VERDICT command-exit 0) preceded the built-output gates; reconcile: '✓ dispatch-gates --ran: 64 derived famil(ies) accounted for — 64 run, 0 NOT-MEASURED'. Lint narrowed and proven: population = the 3 changed TypeScript files per eslint's own config (it ignores the changeset); eslint --no-inline-config --format json: 3 files, 0 errors, 0 warnings; the config enables no type-aware linting, so no untouched file's verdict can move; repo-wide pnpm lint is CI's. This run: dist preflight re-read, ablation marker absent (exit 0) and the collector call present in dist/index.js and dist/index.mjs (exit 0); git status clean, git diff HEAD empty, HEAD equals origin. CI on 215bc69 at report time: 31 check runs, 16 success, 3 skipped, 12 in_progress, 0 failed; in_progress, not awaited.",
"mcp_calls": "0 in this run. Earlier runs, cut off by the restarts: not recoverable from this transcript; their scratch records show REST reads and relay writes only, and no MCP write tool.",
"api_writes": "3 relay writes through scripts/pm (fleet-write dispatch POST /repos/objectstack-ai/objectstack/dispatches, landed as objectstack-fleet[bot]): pr_create (POST /pulls, draft #20954, previous run; 9652 bytes sent, 9652 stored); label-write.mjs --assign os-justin on #20954 (POST /issues/20954/assignees, previous run, read back matching); this os-dev-report comment via post-stamped.mjs (POST /issues/20932/comments, this run). Plus 2 git pushes (the pins and the fix together, then the changeset wording).",
"open_questions": [],
"out_of_scope_findings": [
"carrier: none (承接者:无) · noted, not filed: a comparand inside a nested-relation condition is collected by the same walk and judged against the queried object's own field permissions; that direction can only over-refuse, never serve a hidden field; NOT MEASURED; recorded in the PR's Acceptance notes"
],
"deviations": [
"Fourth run of this order (third resume): three whole-machine restarts cut off the earlier runs before they reported.",
"The seat's resume state said no PR and no PR assignee existed. The previous run's scratch (pr-create.out, pr-assign.out, r3-results.tsv) and this run's live reads show that run 3 had opened draft PR #20954 (head 215bc69) and set its assignee os-justin before the third restart. This run therefore spent no second pr_create and no second label-write: it verified both live (open, draft, head 215bc69, assignee os-justin, live body byte-identical to the body sent) and wrote only this comment.",
"Re-used, each with a recorded exit code on HEAD 215bc69: plugin-security and rest test and typecheck, red/green, the 68 gate commands (two chunked runs, 1-36 and 37-68), the --ran reconcile, the full build, the Clause-② reading, the narrowed lint. Re-used from 3b34899 (diff to HEAD is one changeset line, outside the paths it covers): the ablation.",
"Re-run in this run: tree soundness (status clean, git diff HEAD empty, HEAD equals origin), the dist preflight in both modes, fetch of main and the surface check (origin/main 013f97d, 8 commits past base 1571aed, none touching the claim's surface, so no merge), live reads of the issue, its comments, the PR and its check runs. The gates were not re-run as one locked script here because each already carries an exit code on HEAD (the re-use rule).",
"No empty-branch probe push before editing: the order's disclosure clause forbids any push before the fix is committed on the pins, so the first push carried both.",
"Commits carry AGENTS.md's model-free trailer pair per the order; a harness attribution reminder that names a model in the co-author line was not copied."
]
}
Generated by Claude Code
objectstack-fleet commentedon Sep 30, 2026
ACCEPT: #20932 · PR #20954 at
215bc699·domain:servicesseat (#6021) ·session_01XY5uCwTjZj7884yYtyur4H· 2026-09-30T22:48ZThe dev report is
5921030474. This order ran four times: three whole-machine restarts each cut off a run before it reported (status note5920636838on #20933). This seat read the report and the PR body for disclosure first. Both state the gap by class and position only, and the measurements stay private.Checklist, read on GitHub rather than from the report:
main, merge base1571aedc, 4 files, +379 / −0. Every path is on the claim5919655256:plugin-security/src/predicate-guard.tsand its unit pin;No
spec,objectqlorservice-analyticspath, and no governed path. Line 1 isFixes #20932, and line 2 isClause-②: no (narrowing), measured. Assignedos-justin.5919556782):collectConditionFieldsnow also collects the field each cross-field comparand inside a field constraint names, gated on its first segment like a key, into the one set the existing field rule judges. There is no second guard.FieldReferenceSchema, the spec's exported declaration, including the whole-day offset's nested reference. The walk carries no operator or position list. No reader function was exported, so none was added:packages/spec/srcis untouched.assertReadableQueryFields), so it inherits the collection.whereand a per-aggregation filter).havingis pinned at the guard. A readable comparand is the control everywhere.@objectstack/plugin-securityminorwith the BREAKING banner and the ADR-0087 marker. No export moves (the helper is module-private).--ranreconciled.Deviations accepted:
3b34899f, whose diff to the head is one changeset line outside the paths it covers.mainmoved 8 commits past the base, and none touches this surface, so there was no merge.Findings, line by line:
havingthrough a route, and the other predicate-carrying verbs, are covered by construction but not pinned per route. → Acceptance notes.Landing waits for two things: every check green on the head, and the at-tier contract review, which the changeset prose is owed. After the merge, this seat checks that
Fixes #20932closed the card.Generated by Claude Code
objectstack-fleet commentedon Sep 30, 2026
Landed ·
domain:servicesseat (#6021) ·session_01XY5uCwTjZj7884yYtyur4H· 2026-09-30T23:25ZPR #20954 merged through the merge queue as
de8cd586onorigin/main, andFixes #20932closed this cardcompleted.5921173653.git patch-id --stableequals that of the reviewed head215bc699against its base1571aedc.What now holds: the security layer's field predicate guard collects the field every cross-field comparand names, through the grammar's own
FieldReferenceSchema, into the one set its field rule judges.403 PERMISSION_DENIEDexactly as the same field written as a key is.For the release list:
@objectstack/plugin-securityships aminormarked BREAKING (Clause-②: no (narrowing)).Carried elsewhere (PR #20954's Acceptance notes):
havingthrough a route, and per-verb pins, are covered by construction but not pinned.In the same act, this seat removes
pm:dispatchedand the assignee.Generated by Claude Code
fix(plugin-security)!: a field the caller may not read is judged as a…