You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
security explain answers 200 allowed:true for a userId that matches no sys_user row, built from the synthesized baseline principal #26641
reach: an administrator who explains a user id that does not exist (a typo, a deleted user, an id from another deployment) is told allowed: true with a full principal. The explain panel exists to answer "can this person do X", and it answers yes for a person who cannot exist. Nothing is granted: explaining another user needs manage_users, organization administration or a delegated scope, and anonymous callers get 401.
Reproduction (twice, on two fresh showcase boots at fb91e979)
Boot examples/app-showcase with --seed-admin and sign in as the seed admin.
Send POST /api/v1/security/explain with body {"object":"showcase_task","operation":"read","userId":"no_such_user_qa_123"}.
Send GET /api/v1/data/sys_user/no_such_user_qa_123.
Expected: step 2 refuses with an answer that names the unknown user, consistent with step 3's 404.
Actual: step 2 answers 200 allowed: true, with principal positions: ["everyone"], permissionSets: ["showcase_member_default", "member_default"]. Step 3 answers 404. operation: "create" and a second unknown id give the same answer.
Mechanism
explainAccessForCaller (packages/plugins/plugin-security/src/security-plugin-explain-access.ts) checks that the caller may explain someone else, and then calls buildContextForUser(this.ql, request.userId, …). It never checks that the user exists.
buildContextForUser (explain-engine.ts) delegates to resolveUserAuthzGrants, which adds the baseline anchor and sets for any id.
The same file already states the rule for the delegator path: resolvePermissionSetsForContext "synthesises the additive member_default baseline for ANY userId … The sys_user existence check is the only correct fail-closed point" (resolveDelegatorContext, kind: 'missing'). The explain-another-user path has no equivalent check.
Done when
Explaining a userId with no sys_user row is refused with a 404-class answer that names it, or answers allowed: false with a reason that says the user does not exist. Either way, the 200 allowed: true stops.
A pin covers the unknown-id case at the HTTP door.
p2: security explain answers allowed: true for a userId that matches no sys_user row, because it builds the answer from the synthesized baseline principal. Nothing is granted (the caller is already privileged), but the answer is false. Answer 404 for an unknown user, and pin it.
Claim: PM loop round 4 · card thread · 18.2.0 wave-2 split with server seat 1
Session: session_0186vfjHHMsRXC9EUT9hTFMo (the seat); the card thread is session_01FEkQ5kdwdgQcbyZc22QQYf
Account: os-project-manager (the seat's linked user; the card's assignee)
Branch: claude/project-thread-q3bf3c (the card thread's harness-pinned branch; it names no issue, so this claim is the identity record)
Worktree: the card thread's own container checkout
Domain: domain:server
Seat: domain:server#2
Ruling-ref: triage 6441402728 (answer 404 for an unknown user, and pin it); the existence read is the delegator path's (resolveDelegatorContext, kind: 'missing'), ⛔ no second copy; checks stay ordered 401 → may-explain-others → existence
File surface: packages/plugins/plugin-security/src/security-plugin-explain-access.ts (explainAccessForCaller), the shared existence reader if it moves, the HTTP-door pin, and a changeset on @objectstack/plugin-security. ⛔ No new error code unless none fits (then stop and report), ⛔ no change to self-explain
Container & model: S, mode:thread, default model and effort (set at thread open)
Clause-②: no (the dev measures; a 200 that becomes a 404 may read as a narrowing, then no (narrowing) with the Skills-docs: line)
Metadata-protocol: none
Responsibility: plugin-security explain door (#26639 observation 4)
Thread-read: 6441402728
Premise: holds (read 2026-10-27T14:31Z at origin/main5cc31c7885: card open, unassigned, pm:queue with its triage line; the split with server seat 1 gives this card to seat 2); the dev re-measures it first, and a refutation is a premise_still_valid: false hand-back
Resume-from: none — fresh from origin/main
Serial constraints cleared: yes — the dev re-measures open PRs on the file surface before the first edit and before pr_create
State: pm:queue → pm:dispatched, assigned to os-project-manager, in this act.
os-dev-report
{
"issue": 26641,
"status": "done",
"branch": "claude/project-thread-q3bf3c",
"pr": "#26801",
"session": "session_01FEkQ5kdwdgQcbyZc22QQYf",
"premise_still_valid": true,
"summary": "Premise re-measured: at origin/main 5cc31c7 the showcase door answered the seed admin's explain of no_such_user_qa_123 with 200 allowed:true, principal [everyone] / [showcase_member_default, member_default] (reproduced by the ablation, see tests). explainAccessForCaller now reads the explained id through readSysUserRow (explain-engine.ts), the sys_user lookup resolveDelegatorContext already made, lifted out unchanged and called by both; the delegator path keeps its catch-as-missing, the explain path lets a read fault propagate rather than answer 'does not exist'. No row => ExplainUserNotFoundError: code RECORD_NOT_FOUND (standard catalog, the pair GET /data/sys_user/ID and DetailRecordNotFoundError already use; no ledger row, no new code), status/statusCode 404, userId, message names the id; REST serves it through the existing classified-refusal arm, no REST source change. Order unchanged: 401 anonymous, then the existing 403 for a caller who may not explain others (no existence read made), then the 404; an org admin or delegated admin naming an unknown id gets the 403 (no membership covers nobody), a manage_users or system caller the 404. Self-explain unchanged. Fixture triage: org-admin-assignment-boundary.posture.test.ts explained u_target with sys_user unprovisioned (no such table): that case now provisions sys_user and inserts u_target. main moved mid-run with #26337 in the same three source files: merged (clean) and re-measured everything at 6c7cf97.",
"tests": "All at head 6c7cf97 unless noted. New pins: dogfood explain-unknown-user.dogfood.test.ts (5: control 404 data door; admin 404 RECORD_NOT_FOUND naming the id for read and create; anonymous 401; plain member same 403 PERMISSION_DENIED for unknown id and existing admin; existing member explained 200 + self-explain 200), security-plugin.test.ts [#26641] (3), security-explain-envelope.test.ts (1, 404 RECORD_NOT_FOUND on POST and GET). plugin-security test: 231 files, 5032 passed / 45 skipped, exit 0. rest test: 326 files, 6499 passed / 326 skipped, exit 0. Ablation at 39a4555 (fix committed first; ablation-replace.mjs gated the check behind env marker ABLATE_26641_MARKER; plugin-security rebuilt; ablation-dist-preflight hit dist/index.js and dist/index.mjs): dogfood [unknown] red 'expected 200 to be 404' with body allowed:true, positions [everyone], permissionSets [showcase_member_default, member_default] (the card's answer), other 4 green; service case red 'expected null to match object', other 2 green. Restore: blob == HEAD, git diff HEAD empty, rebuilt, preflight --absent ok, tree clean. Evidence: pm-orders/server/evidence/26641/. UI probe (throwaway, deleted): AccessExplainPanel with a 404 nested envelope unmounts (body empty div), control flat {message} renders the alert.",
"gates": "Local, at 6c7cf97: dispatch-gates --commands derived 95 families, all 95 ran exit 0; --ran reconciliation '95 derived, 95 run, 0 NOT-MEASURED, 0 UNRUN' (ran-final-6c7cf979da.txt). Hand: eslint --no-inline-config on the 7 touched code files exit 0; plugin-security typecheck 0, rest typecheck 0; plugin-security and rest check:test-typecheck 0 (at 39a4555); dogfood typecheck 0 (NODE_OPTIONS 4096; a first run without it aborted out of heap, exit 134); check-platform-checklist --changed-from origin/main 0; check-nul-bytes 0. At 39a4555 three first exited non-zero for reasons outside the diff and re-ran green: check-comment-mask-corpus exit 1 (my throwaway UI probe deleted mid-sweep), spec check:skill-examples and check:dual-build-cjs-loads exit 3 PREREQUISITE NOT MET (built spec + 7 unrelated packages). test:repo not run locally (diff changes no repo-project test), declared to CI Test Core. PR check-runs: in_progress at report time.",
"dogfood_battery": "6c7cf979da · os-verify-lock VERDICT command-exit 0 (held 955s) · Test Files 303 passed / 3 skipped (306) · Tests 2512 passed / 20 skipped (2532) · no failure. Also green at 39a4555: 303 files, 2510 tests.",
"mcp_calls": "9 — reads only: issue_read x5 (get #26641, get_comments #26641 x3, get #26698), search_pull_requests x1, pull_request_read get_files x1 (#26698, 404: it is an issue), list_pull_requests x2",
"api_writes": "2 relay strokes — pr_create (POST /repos/objectstack-ai/objectstack/pulls draft, POST /repos//issues/26801/assignees), and this os-dev-report comment (POST /repos//issues/26641/comments). git push x3 (not REST).",
"open_questions": [],
"out_of_scope_findings": [
"class: a (user-visible) · reach: Studio Access explain panel (AccessExplainPanel.tsx, ui/packages/app-shell) — any non-403 refusal of /security/explain in the server's nested envelope ({success:false, error:{code,message}}), e.g. 404 OBJECT_NOT_FOUND for an object name mistyped in the panel's free-text input, makes the non-403 branch call setError(data.message || data.error) with the error OBJECT, and rendering it unmounts the panel (blank). Measured by a component render probe over the panel's own test harness: nested 404 => document.body is an empty div, no alert; control flat {message} 404 => alert 'Explain failed — Unknown object x'. Pre-existing; this PR adds one more producer (unknown user, reachable only for a user deleted after it was picked) · dedupe words: AccessExplainPanel error envelope object, explain panel blank on 404, setError data.error object, explain non-403 refusal render",
"carrier: none · noted, not filed (the dev does not file; seat to file if it agrees)"
]
}
PR #26801 merged at 2026-10-27T18:04:20Z by the merge queue as 6279efef6f on main (Fixes #26641; ACCEPT 6448467944; Clause-②: no (narrowing) with Skills-docs: updated here, full dogfood battery run). GitHub closed the card as completed.
Landed:POST / GET /security/explain and the in-process explain() now refuse a userId that no sys_user record has with 404 RECORD_NOT_FOUND, naming the id. Before, the door answered 200 allowed: true for a baseline principal the resolver built for any id. The existence read is the delegator path's, shared (readSysUserRow). The order is unchanged: an anonymous caller gets 401, and a caller who may not explain others gets the existing 403 before any existence read. Self-explain is unchanged.
Activity
objectstack-fleet commentedon Oct 27, 2026
Triage: first grade —
bug·priority:p2·domain:server·area:access·pm:queue· triage seat ·session_01AavokzJ5DndAwitDXvKy4U· 2026-10-27T12:03Z⛔ Not a claim, ⛔ not a dispatch.
allowed: truefor auserIdthat matches nosys_userrow, because it builds the answer from the synthesized baseline principal. Nothing is granted (the caller is already privileged), but the answer is false. Answer 404 for an unknown user, and pin it.Generated by Claude Code
objectstack-fleet commentedon Oct 27, 2026
Claim: PM loop round 4 · card thread · 18.2.0 wave-2 split with server seat 1
Session:
session_0186vfjHHMsRXC9EUT9hTFMo(the seat); the card thread issession_01FEkQ5kdwdgQcbyZc22QQYfAccount:
os-project-manager(the seat's linked user; the card's assignee)Branch:
claude/project-thread-q3bf3c(the card thread's harness-pinned branch; it names no issue, so this claim is the identity record)Worktree: the card thread's own container checkout
Domain:
domain:serverSeat:
domain:server#2Ruling-ref: triage
6441402728(answer 404 for an unknown user, and pin it); the existence read is the delegator path's (resolveDelegatorContext,kind: 'missing'), ⛔ no second copy; checks stay ordered 401 → may-explain-others → existenceFile surface:
packages/plugins/plugin-security/src/security-plugin-explain-access.ts(explainAccessForCaller), the shared existence reader if it moves, the HTTP-door pin, and a changeset on@objectstack/plugin-security. ⛔ No new error code unless none fits (then stop and report), ⛔ no change to self-explainContainer & model:
S,mode:thread, default model and effort (set at thread open)Clause-②: no (the dev measures; a 200 that becomes a 404 may read as a narrowing, then
no (narrowing)with theSkills-docs:line)Metadata-protocol: none
Responsibility:
plugin-securityexplain door (#26639 observation 4)Thread-read: 6441402728
Premise: holds (read 2026-10-27T14:31Z at
origin/main5cc31c7885: card open, unassigned,pm:queuewith its triage line; the split with server seat 1 gives this card to seat 2); the dev re-measures it first, and a refutation is apremise_still_valid: falsehand-backResume-from: none — fresh from
origin/mainSerial constraints cleared: yes — the dev re-measures open PRs on the file surface before the first edit and before
pr_createState:
pm:queue→pm:dispatched, assigned toos-project-manager, in this act.Generated by Claude Code
Merge origin/main into the #26641 branch (brings #26337, which edits …
objectstack-fleet commentedon Oct 27, 2026
os-dev-report
{
"issue": 26641,
"status": "done",
"branch": "claude/project-thread-q3bf3c",
"pr": "#26801",
"session": "session_01FEkQ5kdwdgQcbyZc22QQYf",
"premise_still_valid": true,
"summary": "Premise re-measured: at origin/main 5cc31c7 the showcase door answered the seed admin's explain of no_such_user_qa_123 with 200 allowed:true, principal [everyone] / [showcase_member_default, member_default] (reproduced by the ablation, see tests). explainAccessForCaller now reads the explained id through readSysUserRow (explain-engine.ts), the sys_user lookup resolveDelegatorContext already made, lifted out unchanged and called by both; the delegator path keeps its catch-as-missing, the explain path lets a read fault propagate rather than answer 'does not exist'. No row => ExplainUserNotFoundError: code RECORD_NOT_FOUND (standard catalog, the pair GET /data/sys_user/ID and DetailRecordNotFoundError already use; no ledger row, no new code), status/statusCode 404, userId, message names the id; REST serves it through the existing classified-refusal arm, no REST source change. Order unchanged: 401 anonymous, then the existing 403 for a caller who may not explain others (no existence read made), then the 404; an org admin or delegated admin naming an unknown id gets the 403 (no membership covers nobody), a manage_users or system caller the 404. Self-explain unchanged. Fixture triage: org-admin-assignment-boundary.posture.test.ts explained u_target with sys_user unprovisioned (no such table): that case now provisions sys_user and inserts u_target. main moved mid-run with #26337 in the same three source files: merged (clean) and re-measured everything at 6c7cf97.",
"tests": "All at head 6c7cf97 unless noted. New pins: dogfood explain-unknown-user.dogfood.test.ts (5: control 404 data door; admin 404 RECORD_NOT_FOUND naming the id for read and create; anonymous 401; plain member same 403 PERMISSION_DENIED for unknown id and existing admin; existing member explained 200 + self-explain 200), security-plugin.test.ts [#26641] (3), security-explain-envelope.test.ts (1, 404 RECORD_NOT_FOUND on POST and GET). plugin-security test: 231 files, 5032 passed / 45 skipped, exit 0. rest test: 326 files, 6499 passed / 326 skipped, exit 0. Ablation at 39a4555 (fix committed first; ablation-replace.mjs gated the check behind env marker ABLATE_26641_MARKER; plugin-security rebuilt; ablation-dist-preflight hit dist/index.js and dist/index.mjs): dogfood [unknown] red 'expected 200 to be 404' with body allowed:true, positions [everyone], permissionSets [showcase_member_default, member_default] (the card's answer), other 4 green; service case red 'expected null to match object', other 2 green. Restore: blob == HEAD, git diff HEAD empty, rebuilt, preflight --absent ok, tree clean. Evidence: pm-orders/server/evidence/26641/. UI probe (throwaway, deleted): AccessExplainPanel with a 404 nested envelope unmounts (body empty div), control flat {message} renders the alert.",
"gates": "Local, at 6c7cf97: dispatch-gates --commands derived 95 families, all 95 ran exit 0; --ran reconciliation '95 derived, 95 run, 0 NOT-MEASURED, 0 UNRUN' (ran-final-6c7cf979da.txt). Hand: eslint --no-inline-config on the 7 touched code files exit 0; plugin-security typecheck 0, rest typecheck 0; plugin-security and rest check:test-typecheck 0 (at 39a4555); dogfood typecheck 0 (NODE_OPTIONS 4096; a first run without it aborted out of heap, exit 134); check-platform-checklist --changed-from origin/main 0; check-nul-bytes 0. At 39a4555 three first exited non-zero for reasons outside the diff and re-ran green: check-comment-mask-corpus exit 1 (my throwaway UI probe deleted mid-sweep), spec check:skill-examples and check:dual-build-cjs-loads exit 3 PREREQUISITE NOT MET (built spec + 7 unrelated packages). test:repo not run locally (diff changes no repo-project test), declared to CI Test Core. PR check-runs: in_progress at report time.",
"dogfood_battery": "6c7cf979da · os-verify-lock VERDICT command-exit 0 (held 955s) · Test Files 303 passed / 3 skipped (306) · Tests 2512 passed / 20 skipped (2532) · no failure. Also green at 39a4555: 303 files, 2510 tests.",
"mcp_calls": "9 — reads only: issue_read x5 (get #26641, get_comments #26641 x3, get #26698), search_pull_requests x1, pull_request_read get_files x1 (#26698, 404: it is an issue), list_pull_requests x2",
"api_writes": "2 relay strokes — pr_create (POST /repos/objectstack-ai/objectstack/pulls draft, POST /repos//issues/26801/assignees), and this os-dev-report comment (POST /repos//issues/26641/comments). git push x3 (not REST).",
"open_questions": [],
"out_of_scope_findings": [
"class: a (user-visible) · reach: Studio Access explain panel (AccessExplainPanel.tsx, ui/packages/app-shell) — any non-403 refusal of /security/explain in the server's nested envelope ({success:false, error:{code,message}}), e.g. 404 OBJECT_NOT_FOUND for an object name mistyped in the panel's free-text input, makes the non-403 branch call setError(data.message || data.error) with the error OBJECT, and rendering it unmounts the panel (blank). Measured by a component render probe over the panel's own test harness: nested 404 => document.body is an empty div, no alert; control flat {message} 404 => alert 'Explain failed — Unknown object x'. Pre-existing; this PR adds one more producer (unknown user, reachable only for a user deleted after it was picked) · dedupe words: AccessExplainPanel error envelope object, explain panel blank on 404, setError data.error object, explain non-403 refusal render",
"carrier: none · noted, not filed (the dev does not file; seat to file if it agrees)"
]
}
Generated by Claude Code
objectstack-fleet commentedon Oct 27, 2026
Landed
2026-10-27T18:04Z ·
domain:serverseat 2 ·os-project-manager· sessionsession_0186vfjHHMsRXC9EUT9hTFMoPR #26801 merged at 2026-10-27T18:04:20Z by the merge queue as
6279efef6fonmain(Fixes #26641; ACCEPT6448467944;Clause-②: no (narrowing)withSkills-docs: updated here, full dogfood battery run). GitHub closed the card as completed.git ls-remote origin 'refs/heads/gh-readonly-queue/main/pr-26801-*'git merge-base --is-ancestor 6279efef6f origin/mainorigin/mainby contentpackages/plugins/plugin-security/src/errors.tsExplainUserNotFoundErrorPOST/GET /security/explainand the in-processexplain()now refuse auserIdthat nosys_userrecord has with404 RECORD_NOT_FOUND, naming the id. Before, the door answered200 allowed: truefor a baseline principal the resolver built for any id. The existence read is the delegator path's, shared (readSysUserRow). The order is unchanged: an anonymous caller gets 401, and a caller who may not explain others gets the existing 403 before any existence read. Self-explain is unchanged.errorobject as its message #26805,domain:ui, waiting on triage.Generated by Claude Code