Repository navigation
fix(plugin-security): a refused grant holder the caller cannot read is answered as a holder that does not exist - #31244
Conversation
…s answered as a holder that does not exist The grant-holder membership refusal asks the caller's own read door about a holder the write names, the way the engine's reference check asks it, and leaves a holder that door does not return to that check. A holder the caller can read is judged on membership as before; the refusal set and envelope are unchanged. Claude-Session: https://claude.ai/code/session_012dn2AdqWnb3ndnEacKvJvF Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012dn2AdqWnb3ndnEacKvJvF Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012dn2AdqWnb3ndnEacKvJvF Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 2 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 17 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 12cbfaf3a96464554a5cbad10461dafb17bf2c19 && git checkout 12cbfaf3a96464554a5cbad10461dafb17bf2c19
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 7c523aa108650e48d19419ae9b76924c64707741 367379988e6328b95977490715653b995f8ef644 && git checkout -B drift-repro 7c523aa108650e48d19419ae9b76924c64707741 && git merge --no-ff 367379988e6328b95977490715653b995f8ef644
node scripts/docs-audit/affected-docs.mjs --json 7c523aa108650e48d19419ae9b76924c64707741
|
|
security#1 · os-bob · session_012dn2AdqWnb3ndnEacKvJvF {
"issue": null,
"status": "done",
"branch": "claude/sec-grant-holder-sentence",
"pr": "https://github.com/objectstack-ai/objectstack/pull/31244",
"session": "session_012dn2AdqWnb3ndnEacKvJvF",
"premise_still_valid": true,
"summary": "Rule implemented in packages/plugins/plugin-security/src/grant-holder-membership-refusal.ts: a refused grant holder the caller cannot read is answered as a holder that does not exist. For a holder the write names (every non-system insert; an update naming a different holder than the row stores) the hook first asks the caller's own read door the same sys_user read the engine's reference check asks (through ctx.api, memoized per write); a holder that door does not return is left to the engine's reference check, which answers it with its own reference_not_found entry in its own message context. A readable holder is judged on membership as before and keeps the membership sentence (Zone 2 item 2 measured: kept). The hand-off happens only where that check is certain to ask (live user_id is a non-readonly lookup/user reference to sys_user, sys_user registered); otherwise membership is judged in the plugin as before. Route: Zone 3 alternative in effect (the engine's door answers ahead of the hook), implemented inside the hook file; no engine file touched, nothing in delegated-admin-gate.ts or the other serial files. Preferred route not taken because plugin-security has no runtime dependency on objectql, where the sentence builder and its message context (negotiated locale, translated label) live, and handing the holder to the engine's own door keeps every stage after the hook identical for any holder the caller cannot read. Premise measured on a real engine before the fix (readings in the private field). Refusal set, 400, VALIDATION_FAILED, field and field code unchanged; the refused entry's constraint follows the entry (reference check's for an unreadable holder). Under the shipped organization_admin sys_user read, users outside the organization are unreadable, so their refusal now carries the reference check's sentence (stated in PR and changeset). Declarations: Clause-② written as `no` with NO arm, not the dispatch's `(narrowing)` hypothesis: no write accepted before is refused now and none refused before is accepted, so the arm is not true and full dogfood is not owed; flagged here rather than chosen silently. Metadata-protocol: none; Skills-docs: none with reason. Standard-text conflict: os-dev rule 2 says pr_create carries the claim Account as assignee, AGENTS.md says pr_create carries none and the seat assigns at hand-over; AGENTS.md wins, PR opened with no assignee. main merged twice (last at c9fed93ed, incoming plugin-security and objectql changes) and every reading retaken there. One mis-typed background command (a dependents filter) was killed before it acquired the lock. Worktree removed after the report comment.",
"tests": "At c9fed93ed under the verify lock: closure rebuilt (--filter '@objectstack/plugin-security^...'), plugin-security build, spec check:generated (14 artifacts up to date), plugin-security typecheck incl. check:test-typecheck (0 errors), full suite: Test Files 248 passed (248), Tests 5833 passed | 45 skipped (5878), VERDICT command-exit 0. Earlier heads: 9c5f2651-era suite 248/248, 5805 passed; target file 30/30 at 1e980018. New: one top-level describe in grant-holder-membership-refusal.test.ts, 9 cases (control read; insert and update-by-id on both tables with the served answer byte-identical across three stores of one holder id; readable holder keeps the membership refusal and a readable member is admitted; organization-only move still judged on membership, then lands once a member; shipped user read; no sys_user composition); 21 prior cases unchanged. Ablations at ee912fb9 via scripts/ablation-replace.mjs (mutation landed by anchor count and blob hash, restore blob == HEAD, git diff HEAD empty each time; tests import the hook from src, no dist involved): insert hand-off removed 2/30 red (the two insert cases); update hand-off removed 2/30 red (the two update cases); sys_user-registered guard condition removed 1/30 red (no-sys_user case: write no longer refused).",
"gates": "dispatch-gates --commands --repo objectstack-ai/objectstack derived 63 families, identical at 9c5f2651 and c9fed93ed. At c9fed93ed: 62 exit 0 (exit codes captured before any pipe, recorded as COMMAND :: exit N); check:type-check-debt exit 3 NOT MEASURED (closure build never got a lock turn: two 540s queue-timeouts behind a dogfood holder, pid 27631, held 27+ min); it was exit 0 at 9c5f2651. --ran reconciliation at c9fed93ed: 63 derived, 62 run, 1 NOT-MEASURED, 0 UNRUN. At 9c5f2651 check:dual-build-cjs-loads and check:i18n were exit 3 (prerequisite), both exit 0 at c9fed93ed; check-plugin-teardown-shape --self-test needed its pinned commit fetched (owned ref, deleted after), then exit 0. PR #31244 check-runs at report time: Check Changeset, Check PR Size, Governed Surface Queue Guard, Lint (CI release & engine gates, ESLint, PM dispatch-gates, docs & code guards, repo & tooling gates, scoped query-options erasure, scoped slot lookup & corpus, scoped tooling self-tests), PR-body guards, Type Check (consumer gates, debt ledger, source gates, workspace), filter: all in_progress; 18.x upgrade harness, Packed-tarball smoke, Schedule gate: skipped. Test Core / Build Core not yet listed. Not waited on, per contract.",
"dogfood_battery": "n/a — no `!` in the PR title and no (narrowing) arm (Clause-② measured `no` without an arm; see summary)",
"mcp_calls": "0 — no MCP GitHub calls",
"api_writes": "2 — POST /repos/objectstack-ai/objectstack/dispatches (relay pr_create: POST /repos/objectstack-ai/objectstack/pulls, draft, no labels, no assignees; read-back 7527 bytes sent = stored), POST /repos/objectstack-ai/objectstack/dispatches (relay comment via post-stamped: POST /repos/objectstack-ai/objectstack/issues/31244/comments). git push not counted.",
"open_questions": [],
"security_findings": "withheld",
"out_of_scope_findings": []
} |
…er read; pin that it is the caller's own, projected to id The second-object read census names the grant-holder membership refusal as a caller of the reference-target arbiter that serves nothing of the target: its one read of a holder is the caller's own read door, projected to `id`, and its answer only decides whether the write-path reference check answers the holder. plugin-security pins that read's shape. Claude-Session: https://claude.ai/code/session_012dn2AdqWnb3ndnEacKvJvF Co-authored-by: Claude <noreply@anthropic.com>
|
security#1 · os-bob · session_012dn2AdqWnb3ndnEacKvJvF {
"issue": null,
"status": "done",
"branch": "claude/sec-grant-holder-sentence",
"pr": "https://github.com/objectstack-ai/objectstack/pull/31244",
"session": "session_012dn2AdqWnb3ndnEacKvJvF",
"premise_still_valid": true,
"summary": "REWORK round, new head 367379988 (main merged at 7c523aa10; main has not moved since). (1) Test Core 6/8: the second-object read census in packages/core/src/security/second-object-read-exposure.pin.test.ts now classifies packages/plugins/plugin-security/src/grant-holder-membership-refusal.ts as kind not-a-served-read: the refusal asks whether a grant's user_id is a reference the write-path reference check judges, its one read of the target is the caller's own read door projected to id, and its answer only decides whether that check answers the holder; it reads no field of the target and serves nothing of it. The row names its behaviour pin, added in plugin-security: 'a holder the caller cannot read is read only through the caller's own door, projected to its id, and no membership is read for it' (spies every find naming the holder in all three stores: non-system, the caller's userId, fields ['id'], no sys_member read). File surface widened by that one core test file (one CALLERS row), as authorized. Test Core 6/8 is green at 367379988. (2) Test Core 5/8 (examples/app-todo/test/loop-body-iteration.test.ts timeout): not this change. The file boots ObjectQLPlugin + AutomationServicePlugin + a messaging stub, no SecurityPlugin; plugin-security is only a devDependency of service-automation and its src imports nothing from it, so the hook is not registered there. In any composition the hook returns on isSystem before any new code, and its registration is filtered to the two grant objects, so seed and boot writes pay nothing. Measured under the verify lock, two runs each, separate worktrees: head 367379988 5/5 and 5/5, ESCALATION 327 ms and 328 ms, file 8.53 s and 8.75 s (import 7.24 s and 7.51 s); origin/main 7c523aa10 5/5 and 5/5, ESCALATION 357 ms and 326 ms, file 8.57 s and 8.32 s (import 7.30 s and 7.10 s). The source in that test's closure is byte-identical between the two trees (the only difference under core, objectql, service-automation, driver-sqlite-wasm and app-todo src is the core census test file). Consistent with runner load (CI shard import 134 s); one shard re-run is the seat's. (3) Docs drift: no change to either file. content/docs/permissions/system-context.mdx row 23d states the system stand-down, the refusal set and envelope (400 VALIDATION_FAILED / reference_not_found at user_id, for every caller, a platform administrator included), the membership read's scope and the organization-less and single-posture notes; it names neither the refusal's sentence nor the hook's order against the reference check, every statement in it stays true (a holder the caller cannot read is still refused with that envelope, by the reference check), and its anchors grant-holder-membership-refusal.ts#onInsert and #onUpdate still exist and still judge. content/docs/frontend/plugins/plugin-view.mdx line 224 says a UI component has no onCreate/onUpdate/onDelete callbacks: a name collision, unrelated. (4) PR body not edited by me: replacement body for the seat at /tmp/claude-0/-home-user/5f604739-a23e-5e86-98d1-1572ab046d3b/scratchpad/sec-grant-holder-sentence/pr-body-v2.md (8739 bytes, rule level; adds the census row and the read-shape pin, 31 cases, five ablation legs, readings at 367379988). Carried from round 1: Clause-② stays `no` without an arm (not the dispatch's (narrowing) hypothesis); AGENTS.md over os-dev rule 2 on PR assignees (none set). Comparison worktree and its node_modules removed; the task worktree is kept for this PR's next round.",
"tests": "At 367379988 under the verify lock: plugin-security closure rebuilt, plugin-security build, spec check:generated (14 artifacts up to date; a first unlocked run crashed in tsc with no diagnostics at default heap, re-run under the lock with a 4 GB heap is green), plugin-security typecheck incl. check:test-typecheck (0 errors), core repo project Test Files 7 passed (7), Tests 96 passed (96) (the census pin 4/4), plugin-security suite Test Files 248 passed (248), Tests 5834 passed | 45 skipped (5879), VERDICT command-exit 0. Target file 31/31. New ablation legs at 367379988 via scripts/ablation-replace.mjs (landed by anchor count and blob hash, restore blob == HEAD, git diff HEAD empty): census row key renamed -> core pin 2/4 red (unclassified caller names this file; vacuity control); insert hand-off removed -> 3/31 red (two insert cases and the new read-shape pin). Round-1 legs at ee912fb9 stand (2/30, 2/30, 1/30). app-todo loop-body-iteration runs: see summary (2).",
"gates": "dispatch-gates --commands at 367379988 derives 69 families (the core census file adds six: check-dev-prereqs --self-test, spec check:error-code-provenance, check:authz-resolver, check:kernel-hook-pairs, check:object-def-param-keys, check:optional-error-sink). All 69 exit 0 at 367379988, check:type-check-debt included (under the lock: re-measure OK, 0 errors). check:dts-closure and check:dual-build-cjs-loads first read exit 1 while my own app-todo closure build was rewriting packages/spec/dist beside them; re-run after it, both exit 0 (256/256 declarations; 122 entries / 72 packages). --ran reconciliation: 69 derived, 69 run, 0 NOT-MEASURED, 0 UNRUN. PR #31244 check-runs at 367379988: every required context green except Test Core (5/8) failure (the app-todo timeout above) and its Test Core rollup; Test Core (6/8) success, Lint & Repo Gates, TypeScript Type Check, Build Core, Governed Surface Queue Guard, Lint UI family, Type Check UI, Test UI (16/16), Build & E2E UI, Live E2E UI, Dogfood Regression Gate, Check Changeset: success.",
"dogfood_battery": "n/a — no `!` in the PR title and no (narrowing) arm (Clause-② measured `no` without an arm)",
"mcp_calls": "0 — no MCP GitHub calls",
"api_writes": "3 in total, 1 this round — round 1: POST /repos/objectstack-ai/objectstack/dispatches (relay pr_create -> POST /repos/objectstack-ai/objectstack/pulls) and POST /repos/objectstack-ai/objectstack/dispatches (relay comment -> POST /repos/objectstack-ai/objectstack/issues/31244/comments); this round: POST /repos/objectstack-ai/objectstack/dispatches (relay comment -> POST /repos/objectstack-ai/objectstack/issues/31244/comments, this report). No body PATCH. git push not counted.",
"open_questions": [],
"security_findings": "withheld",
"out_of_scope_findings": []
} |
|
security#1 · os-bob · session_012dn2AdqWnb3ndnEacKvJvF 席位核对ACCEPT · Head-sha: Contract-tier review (isolated reviewer, rule level): PASS-WITH-NITS at this head, no must-fix.
|
⛔ merge queue 构建失败 — 先分诊,再决定要不要重排队列构建 42430861831 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集), 分类: 失败的 job(日志抽取,best effort):
跨 PR 相同签名(24h,按失败测试文件聚合):
历史信号:
分诊清单:
Generated by Claude Code · merge-queue-triage workflow (#4859) |
⛔ merge queue 构建失败 — 先分诊,再决定要不要重排队列构建 42432244107 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集), 分类: 失败的 job(日志抽取,best effort):
跨 PR 相同签名(24h,按失败测试文件聚合):
历史信号:
分诊清单:
Generated by Claude Code · merge-queue-triage workflow (#4859) |
security#1 · os-bob · session_012dn2AdqWnb3ndnEacKvJvF
Clause-②: no
Metadata-protocol: none
Skills-docs: none — what a metadata author writes is unchanged; only the refusal entry one class of write receives changes
维护者速读
sys_user_position)与权限集授予(sys_user_permission_set)的写入,若点名一个调用者自己读不到的用户,被拒时改为得到与"该用户不存在"完全相同的回答,由引擎自己的引用检查给出。packages/plugins/plugin-security/src/grant-holder-membership-refusal.ts(holderLeftToReferenceCheck与referenceCheckAsksCaller两段,风险在后者的放行条件)、同名测试文件末尾新增的 describe 块、packages/core/src/security/second-object-read-exposure.pin.test.ts里新增的一条普查登记、.changeset/sec-grant-holder-unreadable-answer.md。pnpm --filter @objectstack/plugin-security exec vitest run src/grant-holder-membership-refusal.test.ts,预期 31 个测试全部通过。What changes
The rule: a refused grant holder the caller cannot read is answered as a holder that does not exist — on
sys_user_positionandsys_user_permission_set, for every non-system insert, and for every update that names a different holder than the row stores.user_idvalue. A holder that door does not return is left to the engine's reference check, which answers it with its ownreference_not_foundentry in the engine's own message context — the answer every other user reference that caller names receives. The plugin keeps no second spelling of that sentence.user_idis a non-readonlylookup/userreference tosys_user, andsys_useris registered. Anywhere else the membership predicate is judged in the plugin as before.packages/core/src/security/second-object-read-exposure.pin.test.ts) classifies the plugin as a caller of the reference-target arbiter that serves nothing of the target: its one read of a holder is the caller's own read door, projected toid, and its answer only decides whether the write-path reference check answers the holder.400,VALIDATION_FAILED,fields[].field: user_id,fields[].code: reference_not_found, the system-write stand-down, global grants, and an update that leaves the holder as stored (judged on membership, as before). No new error code.Route choice: the engine's own reference check answers the holder, rather than the plugin rebuilding the engine's sentence.
@objectstack/plugin-securitycarries no runtime dependency on@objectstack/objectql, where the sentence builder and its message context (negotiated locale, translated field label) live, and handing the holder to the engine's own door keeps every stage after the membership hook identical for any holder the caller cannot read. No engine file is touched.Tests
packages/plugins/plugin-security/src/grant-holder-membership-refusal.test.ts, one new top-leveldescribein the existing harness (a realObjectQLengine over a real SQL driver with the realSecurityPlugin), 10 cases:sys_userread returns its own row and never the named holder, which exists where the case needs it.sys_user_positionandsys_user_permission_set, insert and update-by-id — the whole answer an HTTP door serves (resolveThrownHttpError, serialized) is byte-identical across three stores of the same holder id, refused atuser_idwithreference_not_found; nothing is stored, the updated row is untouched (4 cases).id, and no membership is read for it (every read naming the holder is spied: non-system, the caller'suserId,fields: ['id']) — the behaviour pin the census row above names.constraint.target: sys_member), and a readable member is admitted.sys_userread, a member is admitted, and a user outside the organization gets the same answer a missing user gets.sys_userobject: membership is judged in the plugin.The pre-existing 21 cases of the file pass unchanged.
packages/core/src/security/second-object-read-exposure.pin.test.ts: oneCALLERSrow,kind: 'not-a-served-read', naming the pin above.Acceptance notes
organization_adminset, a user outside the caller's organization is unreadable to that caller, so a grant naming one now reads the engine'sreference_not_foundsentence instead of the membership sentence. The refusal itself and its codes are unchanged; this is the platform's answer for every user reference that caller cannot read.constraintfollows the entry:{ target: sys_user }from the reference check for a holder the caller cannot read,{ target: sys_member, targetField, organization }from the membership refusal otherwise.Clause-②: nowithout an arm: no write accepted before is refused now, and none refused before is accepted.Verification
Every reading below except the first three ablations was taken at head
367379988(after the last merge ofmain, at7c523aa10).Package: build, typecheck, full suite
Under the verify lock, after rebuilding the dependency closure (
--filter '@objectstack/plugin-security^...'):pnpm --filter @objectstack/plugin-security build,pnpm --filter @objectstack/spec check:generated(14 artifacts up to date),pnpm --filter @objectstack/plugin-security typecheck(incl.check:test-typecheck: 0 errors), the corerepoproject (Test Files 7 passed (7),Tests 96 passed (96)), then the plugin-security suite:Test Files 248 passed (248),Tests 5834 passed | 45 skipped (5879), lock VERDICT command-exit 0.Ablations: 5 legs, each red then restored
Taken at
ee912fb9(the fix commit; the test file has since lost one type cast and nothing else). Each leg mutated throughscripts/ablation-replace.mjs, which proves the mutation landed (anchor count, blob hash) and that the restore matched HEAD withgit diff HEADempty. The tests import the hook fromsrc/, so nodist/is involved.sys_user-registered condition removed from the hand-off guard: 1 of 30 red, the no-sys_usercomposition case (the write is no longer refused).367379988, the census row's key renamed: 2 of 4 red in the core pin (the unclassified-caller case, which names this file, and the vacuity control).367379988, the insert-path hand-off removed: 3 of 31 red, the two insert cases and the read-shape pin.Gates
node scripts/pm/dispatch-gates.mjs --commandsderives 69 families at367379988(the core census file adds six). All 69 exit 0 at367379988,check:type-check-debtincluded (run under the verify lock).check:dts-closureandcheck:dual-build-cjs-loadsfirst read exit 1 while this worktree's own closure build was rewritingpackages/spec/distbeside them; re-run after it, both exit 0.--ranreconciliation: 69 derived, 69 run, 0 NOT-MEASURED, 0 UNRUN. Repo-wide scans and every other family are CI's.Generated by Claude Code