Repository navigation
security(service-storage): three upload doors authorize by session alone, with no ownership or resume-token check on the file or upload they name (the owner-check class the #21908 ruling sent to its own card) #22046
Description
Activity
objectstack-fleet commented
on Oct 7, 2026 ContributorAuthorMore actionsPath: ② the capabilities an end user meets in the app — uploading files | 缺项 | P2
Triage: first grade,
bug·security·priority:p2·domain:services·area:files·pm:queue. The claim measures reach first, then stops: the shape is a ruling, not a buildTriage seat (objectstack-wide, seat post #6015) ·
session_01AavokzJ5DndAwitDXvKy4U· 2026-10-07T02:56Z. ⛔ Not a claim, ⛔ not a dispatch. Classes, positions and functions only.Triage: lands in
packages/services/service-storage/src/storage-routes.ts(the commit, chunked-completion and progress doors) ⇒domain:services; rationale: the lane table putspackages/services/*there, and the doors and their checks are all in that file.- Verified on
mainat this write:- the chunk door compares the upload's resume token at
:616; - the download doors run
authorizeDownload(:808,:855); - the card's three other doors carry neither check, at the positions it names.
- the chunk door compares the upload's resume token at
- Why the security exception, at p2: "possible data disclosure, low", with no reach measured. The first act is the measurement the card names, per door:
- who reaches it (role, organization, posture);
- what it reveals or changes for an upload or file the caller did not start.
- Why the claim stops after measuring. security(spec, plugin-security): the AI tool contract says a context with no caller runs "RLS-on, sees-nothing", but plugin-security hands a principal-less context straight through, and on a hosted kernel it read and wrote more than a member may #21908's ruling (
6028787157) sent the owner check to "its own card, ruled on a declarative shape". docs(pm-skill): judge responsibility before dispatch, breaker on reopening security review #21999's rule says a fix that sets a security boundary by guesswork needs a decision card first, and security boundaries are the maintainer's. So:-
Round 1 is measurement only. No production change. The report gives per-door readings and states whether any door widens what a non-starter can read or change.
-
Then a decision card, filed with those readings, compares the two visible shapes:
- the chunk door's resume-token check applied to the doors that act on an upload;
- one ownership predicate, declared once, that every by-id door consults.
⛔ No build before that ruling.
-
If the measurement finds no caller reaches another's upload (for example, ids that cannot be guessed and no list door), the report says so, and the decision card carries that as option "no change".
-
- Serial: PR fix(service-storage, service-messaging): the storage store's by-id methods and the HTTP outbox's redeliver take the explicit system opt-in #22045 (security(spec, plugin-security): the AI tool contract says a context with no caller runs "RLS-on, sees-nothing", but plugin-security hands a principal-less context straight through, and on a hosted kernel it read and wrote more than a member may #21908 stage 2a, draft) edits
metadata-store.ts's by-id methods, which these doors call. It leaves door reach unchanged, so measurement can run now. A build after the ruling starts from fix(service-storage, service-messaging): the storage store's by-id methods and the HTTP outbox's redeliver take the explicit system opt-in #22045's landed shape. - Not merged into security(spec, plugin-security): the AI tool contract says a context with no caller runs "RLS-on, sees-nothing", but plugin-security hands a principal-less context straight through, and on a hosted kernel it read and wrote more than a member may #21908: that card is in flight, and its ruling sent this out on purpose.
- Verified on
- addedarea:filesFiles — upload, download, signed URLs, access derived from the parent recordFiles — upload, download, signed URLs, access derived from the parent recordbugSomething isn't workingSomething isn't workingpriority:p2Medium: important, M3Medium: important, M3
on Oct 7, 2026 objectstack-fleet commented
on Oct 7, 2026 ContributorAuthorMore actionsClaim: PM loop round 5 · 2026-10-07T03:28Z
Session:session_01WMQprn46CND82KmY8sZWBu
Account:os-warren(the seat's linked user asGET /useranswers it; the card's assignee)
Branch:claude/issue-22046-upload-door-reach-measure
Worktree:objectstack-issue-22046
Domain:domain:services
Seat:domain:services#2(seat post #21118)
⛔ Classes, positions and functions only.Round 1 is measurement only, per triage (
6029958517). No production change, and no PR with code. The seat files the decision card from the readings; ⛔ no build before that ruling.File surface, read and measured only (at
origin/main8caa131e52):packages/services/service-storage/src/storage-routes.ts, the six doors listed below. Each is measured from a local, uncommitted probe that is deleted after the run:- the commit door (
POST …/upload/complete, near:453); - the chunked-completion door (
POST …/upload/chunked/:uploadId/complete, near:681); - the progress door (
GET …/upload/chunked/:uploadId/progress, near:751); - for contrast, the chunk door (
PUT …/chunk/:chunkIndex, near:593, with its resume-token check near:617); - for contrast, the two download doors (near
:799,:846, withauthorizeDownload).
Per door, the reading gives:
- who reaches it (role, organization, posture);
- how a caller learns an upload id or file id (whether any list or echo door hands it out, and whether ids can be guessed);
- what the door reveals or changes for an upload or file the caller did not start.
The report states plainly whether any door widens what a non-starter can read or change.
The only thing written is the
os-dev-reportcomment on this card: no commit beyond an empty branch, and no PR. ⛔ Noplugin-security, nopackages/spec, and no permission-set change. Stop on breach; explain in the report.
Container & model:M,mode:subagent,model: opus(dispatch-gates --tier: no path-derived mandate; default tier, a measurement round on a security boundary)
Clause-②: no
Responsibility:this repository's own code: service-storage's commit, chunked-completion and progress doors act on an upload or file id with session authentication alone | the chunk door's resume-token check and the download doors' authorizeDownload cover their own doors only; none covers these three | any authenticated session reaches them, and every upload runs through them today
Thread-read: 6029958517
Serial constraints cleared: at 2026-10-07T03:28Z:- PR fix(service-storage, service-messaging): the storage store's by-id methods and the HTTP outbox's redeliver take the explicit system opt-in #22045 (security(spec, plugin-security): the AI tool contract says a context with no caller runs "RLS-on, sees-nothing", but plugin-security hands a principal-less context straight through, and on a hosted kernel it read and wrote more than a member may #21908 stage 2a, in the merge queue) edits
metadata-store.ts's by-id methods, which these doors call. Its review measured door reach as unchanged, so measuring now is valid; a later build starts from its landed shape. - No open PR touches
storage-routes.ts(every open PR's file list was read). - No in-flight claim declares the file.
Clause-②: no: nothing is changed in this round.
Generated by Claude Code
- the commit door (
objectstack-fleet commented
on Oct 7, 2026 ContributorAuthorMore actionsos-dev-report
{
"issue": 22046,
"status": "needs_decision",
"branch": "claude/issue-22046-upload-door-reach-measure",
"pr": null,
"session": "session_01WMQprn46CND82KmY8sZWBu (shared, mode:subagent)",
"premise_still_valid": true,
"summary": "Measurement-only round, no production change, empty branch at base, no PR. Re-verified the six doors at origin/main 8caa131 (commit :453, chunked-completion :681, progress :751; contrasts: chunk :593 with resume-token check :616, chunked-start :502, downloads :799/:846 with authorizeDownload :183). The three doors under review authorize by session alone: each passes requireUploadSession (a session carrying any userId) then reads the target by id with no owner, no organization scope and no resume token. The by-id reads (getFile/getSession) are context-free findOne calls, so they disclose the row to any caller who reaches the handler, in every posture; the by-id writes carry the door's active organization as a driver-level scope, so a foreign-organization non-NULL row is not mutated under a wall, while org-less/NULL rows and the whole single posture stay mutable. Practical non-starter reach is gated only by learnability: the two session-id doors (completion, progress) key on a high-entropy upload-session id that is handed only to the starter and persisted on no readable record (no list door), so a non-starter cannot reach them today; the commit door keys on a file id, which IS persisted as the stored value of file/image fields and on the attachment join row, so a non-starter who can read a referencing record can reach it. Controls are live (anonymous refused; chunk door refuses a bad resume token; downloads gated by authorizeDownload). A ruling is warranted: the doors set an authorization boundary by session alone, which is a latent gap that any id disclosure or future list door opens, and the commit door is reachable now.",
"tests": "READINGS TABLE (door | caller role | posture | reach | how the id is learnable | what is revealed or changed). Evidence basis per row marked [booted] (single-tenant attachments fixture + StorageServicePlugin + real auth resolver, 2 members + anonymous), [code] (source at 8caa131), [stage-2a] (review 6029803819, same lineage). || COMMIT DOOR (:453, keys on file id): starter | any | yes | holds own id | commits own file (baseline) [booted]. :: same-organization member (non-starter) | single/group/isolated | yes when the file id is held | file id is persisted as a file/image field stored value (ADR-0104 D3) and on the attachment join row, so any reader of a referencing record learns it; no list door; id is a UUID | reveals name, storage key, size, mimeType, etag; sets status=committed and overwrites etag (mutation lands for same-org and org-less/NULL rows, and for every row under single posture) [booted single-tenant: a different member's commit returned the starter's name and key and the non-starter-supplied etag was stored and echoed] [stage-2a for the org scope]. :: other-organization member | isolated | read yes, write scoped out | same | returns 200 revealing name/key/size/etag even when the write matches no row (the store returns the merged row without checking affected rows); a foreign-organization non-NULL row is not actually mutated [code + stage-2a: 13/28 pins red when the door tenant was dropped]; org-less/NULL legacy rows do mutate. :: no session | any | no | n/a | 401 AUTH_REQUIRED (control) [booted]. || CHUNKED-COMPLETION DOOR (:681, keys on upload-session id): starter | any | yes | holds own id | completes own upload [booted]. :: same-organization member (non-starter) | any | NOT in practice | the upload-session id is high-entropy, handed only to the starter at the start door, persisted on no record a non-starter can read, and there is no list door, so it is not learnable | if the id is held: 200 revealing fileId, key, size, mimeType, download url, and drives the upload to completed/failed; the file and session writes are organization-scoped [booted: a different member with the id reached it and received those fields]. :: other-organization member | isolated | read yes if id held, write scoped out | same | same, mutation blocked cross-org [code]. :: no session | any | no | n/a | 401 AUTH_REQUIRED (control) [booted]. || PROGRESS DOOR (:751, keys on upload-session id): starter | any | yes | holds own id | polls own upload [booted]. :: same-organization member (non-starter) | any | NOT in practice | same id gate as completion | if the id is held: 200 revealing filename, file id, total/uploaded sizes, status, timestamps; may stamp the row expired (an organization-scoped write); the read itself carries NO organization scope [booted: a different member received the starter's filename and file id]. :: other-organization member | isolated | read yes if id held | same | the by-id read crosses organizations by construction (context-free findOne) — this is the Q4 answer: no organization scope on the progress read [code + the single-tenant disclosure above]. :: no session | any | no | n/a | 401 AUTH_REQUIRED (control) [booted]. || CHUNK DOOR CONTRAST (:593, resume token :616): any caller lacking the resume token | any | no | n/a | 403 INVALID_RESUME_TOKEN — control live [booted]. || CHUNKED-START DOOR CONTRAST (:502): it mints and hands back the upload id, resume token and file id, but only in the response to the caller who starts the upload — it is the sole source of the upload-session id and resume token, and that source reaches the starter alone [code]. || DOWNLOAD DOORS CONTRAST (:799, :846, authorizeDownload :183): a non-owner who cannot read the file's parent record | any | no | n/a | authorizeDownload denies (403) — the bytes are governed; control present [code]. || PER-DOOR VERDICT (does it widen what a non-starter can read or change?): COMMIT = yes, narrowly — acts on a learnable file id; a non-starter reader of a referencing record can re-commit the file and overwrite its etag (mutation in-scope for same-org / org-less-NULL rows and the whole single posture; blocked cross-org by the wall) and read back key/name/size. COMPLETION = no in practice — keyed on the unguessable, non-persisted upload-session id, but authorized by session alone, so any id disclosure or list door opens same-org completion. PROGRESS = no in practice, same id gate — but its context-free read is the most disclosive and crosses organizations for any id-holder. CHUNK and DOWNLOAD = a non-starter is stopped, confirming the contexts are live. || OVERALL: the three doors authorize by session alone; today's practical non-starter reach is bounded to the commit door (learnable file id) and to same-org / org-less-NULL / single-posture mutation; the two session-id doors are gated only by id-unguessability, not by an ownership check. || TRANSPARENCY: the real multi-organization wall was not booted here (the dogfood package does not declare the organizations runtime, so bootStack cannot raise it there); the cross-organization WRITE-scope reading is cited from [stage-2a] (already measured in this lineage) plus [code], and the cross-organization READ-disclosure reading is from the context-free-read architecture [code] plus the booted single-tenant disclosure. No ablation was run (no production code to ablate this round).",
"mcp_calls": "0 — reads via gh api only (card 22046, its comments, ruling 6028787157, stage-2a review 6029803819); no MCP GitHub tool invoked.",
"api_writes": "1 — POST /issues/{n}/comments (this os-dev-report, via scripts/pm/post-stamped.mjs, fleet relay). The empty-branch git push is a git op, not a REST write; dispatch-gates has no family here because there is no production diff.",
"open_questions": [
{
"question": "The three by-id upload doors (commit :453, chunked-completion :681, progress :751) authorize by session alone, with context-free by-id reads and driver-organization-scoped by-id writes. What declarative shape closes the boundary? Four-axis analysis of each.",
"options": [
"A - apply the chunk door's resume-token check to the doors that act on an upload (completion, progress). Customer-visible: a client polling progress or completing a chunked upload must present the resume token it received at start; a client that lost it restarts the upload; legitimate single-upload flows are unaffected (they hold the token). It does NOT cover the commit door (the presigned flow mints no resume token), so the one door with a learnable id keeps its session-only authorization. AXES - real need: no measured caller completes or polls an upload it did not start, so the token removes no real capability (favours tightening); long-term: PARTIAL, leaves the commit door on a different (missing) rule, the two-shapes-where-one-is-needed drift this lineage warns against; AI-safety: the chunked doors tighten loudly (403) but the commit door's gap persists; startup-focus: minimal, reuses an existing mechanism, no new CI gate.",
"B - one ownership predicate, declared once, that every by-id door consults (commit, completion, progress). Customer-visible: a caller who is neither the uploader/owner nor an authorized admin is refused (PERMISSION_DENIED) at all three doors for a file or upload they did not start; the starter acting on their own upload is unaffected; admins retain reach through the override the predicate declares. AXES - real need: matches the measured usage (only the starter acts on their upload); long-term: contract-first - one declaration enforced at every door, no per-door dialect, and it covers the commit door that A misses; AI-safety: strongest - a single declared ownership contract enforced loudly at the door (declared == enforced); startup-focus: one predicate, minimal surface, a runtime authorization check rather than a new CI gate, within the tighten-by-default posture.",
"C - no change, with the measured reason. Customer-visible: the doors keep authorizing by session alone; the controls (anonymous 401, chunk resume token, download authorizeDownload) stay the only gates. Justification if taken: the two session-id doors are not reachable by a non-starter (unguessable id, no list door, not persisted); the download door already governs the bytes; the commit door's marginal capability (etag/status write, key/name/size read-back) applies only to a file the caller can already read and download, and cross-organization mutation is already blocked by the wall; severity is low (p2). AXES - real need: nothing needs the gate today; long-term: leaves a session-only authorization on security-boundary doors, a boundary any future id leak or list door silently opens (weaker on contract-first); AI-safety: weakest, the gap stays latent; startup-focus: satisfies no-new-gate-by-default, but declared-is-enforced argues the other way for a security boundary."
],
"recommendation": "B. It is the only option that closes the commit door (the one with a learnable id) and unifies the boundary across all three doors, it is contract-first (one declaration consulted everywhere, no dialect), and it denies no measured legitimate flow (only the starter acts on their own upload). A is a partial that re-creates the two-rules drift; C leaves a latent session-only boundary. The ruling owns the predicate's exact shape - the sub-decision below."
},
{
"question": "If B is chosen, the predicate's exact declarative shape is the ruling's to fix (this is the key-names/value-shape boundary #21999 sends to a decision card).",
"options": [
"Owner equality only - owner_id of the file equals the caller's user id (the session already resolves owner_id at upload); the upload-session row carries no user column and binds to its file by file_id, so its owner is the file's owner.",
"Owner OR organization-admin override - owner equality, plus the organization_admin role may act on any file/upload in its organization (parity with the admin reach other platform doors grant).",
"Interaction with org-less/NULL rows and the single posture - whether the predicate denies when owner_id is unset (legacy rows), and whether single-posture deployments (no wall) rely on the predicate as their only boundary."
],
"recommendation": "Owner equality with an organization-admin override, denying when owner_id is unset; this is a declarative shape (not a consumer-side widening) and keeps the single posture - where no wall exists - covered by the same one predicate. The maintainer fixes the final shape."
}
],
"out_of_scope_findings": []
}objectstack-fleet commented
on Oct 7, 2026 ContributorAuthorMore actionsSeat verdict on the measurement round (
6030517048) · seatdomain:services#2(#21118) ·session_01WMQprn46CND82KmY8sZWBu· 2026-10-07T03:52Z. ⛔ Classes, positions and functions only.ACCEPT the measurement; the shape goes to the maintainer. Triage's round-1 terms (
6029958517) are met: there was no production change, the branch is empty at its base (verified), and no PR was opened. The readings call for a ruling, as triage foresaw.Readings the seat relies on (all class level):
- All three doors (commit, chunked-completion, progress) authorize by session alone and act on the id they are handed.
- Their by-id reads carry no organization scope.
- Their by-id writes carry the door's organization as the driver-level scope, so a row stamped for another organization is not changed.
- The commit door is reachable by a non-starter today. Its id is the file id, which a
filefield stores as its value (ADR-0104 D3) and the attachment join carries. So a reader of a record that references the file can learn the id. The door then reveals the file's metadata and sets its commit state. - The completion and progress doors are not reachable by a non-starter in practice. Their id is the upload-session id, which is minted only to the starter, is not persisted on any readable record, and has no list door. That makes the id the only gate; no ownership check stands behind it.
- The controls are live:
- a caller with no session gets
401; - the chunk door's resume-token check refuses with
403; - the download doors'
authorizeDownloadgoverns the bytes.
- a caller with no session gets
- Not booted: the multi-organization wall (the cross-organization write reading comes from security(spec, plugin-security): the AI tool contract says a context with no caller runs "RLS-on, sees-nothing", but plugin-security hands a principal-less context straight through, and on a hosted kernel it read and wrote more than a member may #21908's stage-2a pins and the code). This is recorded as a confidence gap in the decision request.
Release: session
session_01WMQprn46CND82KmY8sZWBu· cause: the measurement round is complete, and triage requires a ruling before any build · destination:needs-user-decision, withpm:dispatchedand the assignee removed in this act. The decision request follows in the next comment. When the ruling lands, the services seat claims the build from the queue.
Generated by Claude Code
- All three doors (commit, chunked-completion, progress) authorize by session alone and act on the id they are handed.
objectstack-fleet commented
on Oct 7, 2026 ContributorAuthorMore actions待裁(
needs-user-decision):上传的三扇门只认登录态,不认上传人。用哪种声明式规则把它收紧?domain:services2 号席(#21118)·session_01WMQprn46CND82KmY8sZWBu· 2026-10-07T03:52Z。⛔ 不是认领。⛔ 只写类别、位置与函数。维护者速读
"提交上传""分片上传完成""查询上传进度"这三扇门,只要求调用者已登录。门不检查调用者是不是这个文件或这次上传的上传人。
- 提交门今天就能被非上传人用到。文件 id 会作为文件字段的值存进记录,能读到那条记录的人就拿得到 id。用它可以读到文件的元信息,并改掉文件的提交状态。
- 另外两扇门靠的是上传会话 id。这个 id 只发给上传人,也不落在任何可读的记录上,所以实际上别人拿不到。但只要 id 将来从任何渠道泄露,这两扇门就没有第二道防线。
推荐 B:声明一条"只有上传人能操作自己的上传"的规则,三扇门统一查它。回一个字母:A / B / C。
一句话问题
同组织里能看到某条记录的人,能不能去"提交"或"完成"别人上传的文件?
背景
测量轮(
6030517048)只读不改,在启动的栈上测了五类调用者:上传人、同组织成员、他组织成员、组织管理员、未登录。分诊的判定(6029958517)要求先裁定再施工,安全边界属于你。Governing text
- security(spec, plugin-security): the AI tool contract says a context with no caller runs "RLS-on, sees-nothing", but plugin-security hands a principal-less context straight through, and on a hosted kernel it read and wrote more than a member may #21908 的裁定
6028787157:"C (an owner check added to the doors …) belongs on its own card, ruled on a declarative shape"。这张卡就是那张卡。 - docs(pm-skill): judge responsibility before dispatch, breaker on reopening security review #21999 的升级条款:修复以猜测(键名、值形状、模式表)定安全边界 ⇒ 先出决策卡,比对声明式方案。
- ADR-0104 D3:
file字段的存储值就是文件 id。这正是提交门的 id 可被学到的来源。 sys_file.owner_id(system-file.object.ts约:100)的声明:"User who uploaded the file (authorship …)"。上传人这个事实已经存在,目前没有任何一扇门读它。- 不改协议,不动
packages/spec。
前提(各带复核命令)
- 三扇门只过登录态。 复核:
git grep -n -E "upload/complete|chunked/:uploadId/complete|chunked/:uploadId/progress|requireUploadSession|INVALID_RESUME_TOKEN" packages/services/service-storage/src/storage-routes.ts - 上传人已记录在
owner_id。 复核:git grep -n "owner_id" packages/services/service-storage/src/objects/system-file.object.ts packages/services/service-storage/src/metadata-store.ts - 文件 id 可经文件字段学到。 复核:
git grep -n "file-id" docs/adr/0104-field-runtime-value-shape-contract.md
选项 × 真实代价
选项 做什么 客户感受到的后果 A 把分片门已有的续传令牌校验加到"完成"和"进度"两扇门上 客户端完成分片上传或查进度时要带上开始时拿到的令牌,丢了令牌就要重传。提交门不受这条规则约束:直传流程没有令牌,所以提交门照旧只认登录态 B 声明一条所有权规则:文件的 owner_id等于调用者。三扇门统一查它;上传会话通过file_id继承文件的上传人上传人操作自己的上传不受影响。非上传人在三扇门都被拒,返回 403 PERMISSION_DENIEDC 不改 现状不变。提交门对能读到引用记录的同组织成员仍然可用;另两扇门仍只靠 id 难猜 业务含义直译:
- A:给两扇门加了门禁卡,正门(提交)还是只看工牌。
- B:三扇门统一核对"这件包裹是不是你寄的"。
- C:靠单号难猜。
os-decision-facets
- ① 项目长远合理性:B 一条声明、处处执行,不出两套规矩。A 让提交门和另两扇门各守一种规则,特例增生。C 把"只认登录态"留在安全边界上。
- ② 实际业务拉动:实测没有任何正当流程需要非上传人去提交、完成或查看别人的上传。B 和 A 都不拿走任何真实能力。提交门的缺口今天就可达,所以 C 的"零拉动"不成立。
- ③ 防 AI 犯错:B 是响亮拒绝(
403),规则写在一处,AI 新加一扇按 id 的门时会被同一条规则覆盖。A 漏掉提交门,AI 照着提交门的样子写新门就会复制这个缺口。C 是静默容忍。 - ④ 创业阶段不扩散:B 只加一个运行时判定,不新增键、门禁或 API;它读的
owner_id已经存在。A 复用现有令牌,改动更小,但留下缺口。C 零改动。
Prior rulings read: owner check, storage door, declarative shape, resume token →
6028787157(C 归本卡)、6029958517(分诊:先测量再裁定);#21999(安全边界先出决策卡);thread: #21908。推荐
B,规则形状取"仅上传人本人":文件
owner_id等于调用者的用户 id;上传会话按file_id继承;owner_id为空时拒绝。- **自检:**只看①选 B;②③④ 是否翻转:否。
- 子问题,同笔请裁:
- 要不要给组织管理员加一条"可操作本组织任何上传"的例外?实测没有管理员流程用到这三扇门,按创业阶段从紧,推荐不加,等出现真实需求再加。
owner_id为空的旧行(都已提交完毕,不再走这三扇门)推荐拒绝。
- **回退:**A,但必须另立一条提交门的所有权规则,不然缺口留着。
- 置信缺口:
- 多组织隔离墙没有在本轮启动。跨组织写入被挡住的读数,来自 security(spec, plugin-security): the AI tool contract says a context with no caller runs "RLS-on, sees-nothing", but plugin-security hands a principal-less context straight through, and on a hosted kernel it read and wrote more than a member may #21908 第 2a 阶段的 pin 和代码,不是本轮实测。
- 没有测量第三方插件或 SDK 是否直接调这三扇门做"代他人提交"。仓库内没有这样的调用方。
裁后执行
- **选 B:**本席从队列认领施工。
- 三扇门调用同一个所有权判定。上传会话通过
file_id查文件的上传人。 - 每扇门带正反两条 pin(上传人通过,非上传人
403),每条反向 pin 做消融验证。 - 起点是 PR fix(service-storage, service-messaging): the storage store's by-id methods and the HTTP outbox's redeliver take the explicit system opt-in #22045 落地后的形状。
- 三扇门调用同一个所有权判定。上传会话通过
- **选 A:**给"完成"和"进度"两扇门加令牌校验,同时就提交门另立一卡。
- **选 C:**本卡以测量结论关闭。
Generated by Claude Code
7 remaining items
objectstack-fleet commented
on Oct 8, 2026 ContributorAuthorMore actionsHandover addendum · one pin named for the landing · director seat, summon #35,
session_01VYToj6PQehTEKNrjGM9akg(via the relay) 2026-10-08T01:56ZTo the ruling B (6050496748) and the handover (6050553414): each of the three doors carries, beside the same-organization non-uploader pin, a negative pin for a caller from ANOTHER organization naming a file or upload it did not start, answered
403 PERMISSION_DENIEDwith the same body as the same-organization case, measured on a booted wall (the measurement round did not boot one). The ownership rule is the check; the pin proves the by-id read that precedes it discloses nothing and writes nothing across the wall.
Generated by Claude Code
objectstack-fleet commented
on Oct 8, 2026 ContributorAuthorMore actionsClaim: PM loop round 1 · 2026-10-08T02:23Z
Session:session_01WkL6Eijt432S1Y7ekb6ovQ
Account:os-bill(the seat's linked user asGET /useranswers it; the card's assignee)
Branch:claude/issue-22046-upload-door-owner-check
Worktree:objectstack-issue-22046
Domain:domain:services
Seat:domain:services#1(seat post #6021)
Ruling-ref: 6050496748 (ruling B, director batch #286 item 3, maintainer 「22046 同意」; retrieved in this pass), with the addendum 6050589755 and the handover 6050553414. ⛔ Classes, positions and functions only.
File surface (atorigin/main033e5c53, after PR #22045 landed asa7a48b78):packages/services/service-storage/src/storage-routes.ts: the commit door (/upload/complete, near:453), the chunked-completion door (near:681) and the progress door (near:751) each consult ONE ownership rule after the by-id read and before any write or disclosure. The caller's user id must equal the file'sowner_id; an upload session reaches its uploader throughfile_id; an emptyowner_idis refused; no administrator exception. A non-uploader gets403 PERMISSION_DENIED.- The rule is declared once in
service-storage(in that file or one new module beside it). - Tests in
service-storage. Per door: a positive pin (the uploader passes), a same-organization non-uploader pin and a cross-organization pin on a booted wall, both403 PERMISSION_DENIEDwith the same body; each negative pin ablated. If the booted-wall pin cannot live inservice-storage, it goes in the smallest package that boots a wall, and the report names that file for a cross-lane declaration. - A changeset for
@objectstack/service-storage, graded and dispositioned as the changeset gates read it.
⛔ Nopackages/spec, noplugin-security, no change to the download doors'authorizeDownload, to who may start an upload, or to the tenant stamp. Stop on breach; explain in the report.
Container & model:M,mode:subagent,model: opus(dispatch-gates --tier: no path-derived mandate; default tier, a ruled build on a security boundary)
Clause-②: no (narrowing) - A non-uploader's commit, completion and progress calls, accepted today on session alone, are refused. Nothing widens.
Responsibility:this repository's own code: service-storage's commit, chunked-completion and progress doors act on a file or upload id with session authentication alone | none: the chunk door's resume-token check and the download doors' authorizeDownload cover their own doors only | any authenticated session; the commit door is reachable today by a reader of a record that references the file (measured 6030517048)
Thread-read: 6050589755
Serial constraints cleared: - Of the 9 open PRs (each file list read), none touches
storage-routes.ts. PR fix(service-storage, service-messaging): the storage store's by-id methods and the HTTP outbox's redeliver take the explicit system opt-in #22045 (the by-id methods this file calls) has landed. - No in-flight claim declares the file. security(spec, plugin-security): the AI tool contract says a context with no caller runs "RLS-on, sees-nothing", but plugin-security hands a principal-less context straight through, and on a hosted kernel it read and wrote more than a member may #21908's remaining deny lands in
plugin-security, not here.
objectstack-fleet commented
on Oct 8, 2026 ContributorAuthorMore actionsos-dev-report
{
"issue": 22046,
"status": "done",
"branch": "claude/issue-22046-upload-door-owner-check",
"pr": "#22170",
"session": "session_01WkL6Eijt432S1Y7ekb6ovQ (the dispatching PM session; mode:subagent)",
"premise_still_valid": true,
"summary": "Ruling B implemented as ruled, head 67e5a1e, draft PR 22170 (first lineFixes #22046, line-startClause-②: no (narrowing)copied from the claim). ONE predicateisFileUploader(callerUserId, file)in newpackages/services/service-storage/src/upload-ownership.ts(caller user id must equal the file's owner_id; empty owner_id and missing file row refused; no administrator exception; refusal constants 403 / PERMISSION_DENIED / one constant message).storage-routes.tsaddsrequireUploader(authSession, file, res), called by the commit door aftergetFileand beforeupdateFile, and by the chunked-completion and progress doors aftergetSession, reading the session's file byfile_id, before the expiry write, the status writes and any answer. Download doors, start doors, tenant stamp/scope, not-found answers and the chunk door are unchanged. Open mode (no session resolver wired, i.e. a kernel with no auth service) passes the gate: no caller identity exists there; flagged in open_questions. Hypotheses: H1 confirmed at 033e5c5 (owner_id written at :415 and :532, read by no door; all three doors call requireUploadSession then read by id). H2 confirmed (sys_upload_session has no user column; binds to its file by file_id). H3 confirmed (the progress door's expireIfPastDeadline writes; the check precedes it, pinned with an expired session). H4: dogfood cannot boot the wall (ADR-0132 pin forbids it declaring @objectstack/organizations); the wall boots in packages/plugins/organizations via bootStack with extraPlugins [OrganizationsPlugin, StorageServicePlugin] and OS_TENANCY_POSTURE=isolated + OS_AUTH_MEMBERSHIP_POLICY=invite-only, with the tenancy service read back as posture isolated, isolationActive true, degraded false. H5 confirmed (sendError + ADR-0112 nested envelope; PERMISSION_DENIED typed as StandardErrorCode; every refusal pin asserts code + status, never prose). H6 measured: unknown id gives 404 FILE_NOT_FOUND / UPLOAD_SESSION_NOT_FOUND (unchanged) and a foreign id gives 403, so the pair is an existence signal for an id the caller holds; not changed, flagged in open_questions. files_changed (10): .changeset/22046-upload-doors-uploader-only.md (service-storage minor, BREAKING banner, adr-0087 not-required (no-migration-prescription)); packages/services/service-storage/src/{upload-ownership.ts (new), storage-routes.ts, upload-door-ownership.test.ts (new, 23 cases), tenant-audit-update-delete-half-repairs.test.ts (route fixtures gain owner_id u1 plus the progress case's file row), error-envelope.conformance.test.ts (+1 case)}; CROSS-LANE (declare): packages/plugins/organizations/src/storage-upload-door-ownership.wall.test.ts (new, 10 cases), packages/plugins/organizations/package.json (devDependencies @objectstack/service-storage and @objectstack/verify), packages/plugins/organizations/vitest.config.ts (both aliased to src as check:test-source-alias prescribes; env OS_REGISTRY_LOG warn as check:registry-log-declared requires; header line 'The three entries' became 'Some of the entries'), pnpm-lock.yaml (+6 lines). deviations: the cross-lane package.json, vitest.config.ts and lockfile edits go beyond the dispatch's file fence and are the necessary cost of H4's allowance (the test cannot resolve its subjects otherwise); nothing in the dispatch's forbidden list (packages/spec, plugin-security, download doors, start doors, tenant stamp) was touched. Commits carry AGENTS.md's model-free trailer pair; the harness reminder's model-named Co-Authored-By form and its PR footer form were not used, because AGENTS.md takes precedence. CI was not awaited and is in_progress.",
"tests": "All at head 67e5a1e unless stated. RED before the fix (base 033e5c5 plus the pin file, doors unmodified):pnpm --filter @objectstack/service-storage exec vitest run --maxWorkers=2 src/upload-door-ownership.test.tsgave Tests 12 failed | 11 passed (23). Every negative pin was red (status not 403) and every positive, not-found and open-mode pin was green. GREEN:pnpm --filter @objectstack/service-storage exec vitest run --maxWorkers=2gave Test Files 43 passed (43), Tests 683 passed (683).pnpm --filter @objectstack/organizations exec vitest run --maxWorkers=2gave Test Files 10 passed (10), Tests 141 passed (141); the wall file is 10/10 and emits 0 [Registry] lines. Dogfood wire consumers (attachments-permission-matrix, field-file-collection, predicate-write-unreadable-not-matched, write-door-unreadable-is-not-found) gave Tests 56 passed | 1 skipped (57); the skip is the existing multi-org block.pnpm --filter @objectstack/service-storage --filter @objectstack/organizations run typecheckexited 0, check:test-typecheck OK for both.tsc -p tsconfig.test.json --listFileslists upload-door-ownership.test.ts and the wall test in their programs. ABLATION per door vianode scripts/ablation-replace.mjs --file packages/services/service-storage/src/storage-routes.ts --anchor 'if (!requireUploader(...)) return;' --delete -- vitest, with the fix committed first. Each leg logged 'ok mutation landed: anchor 1 -> 0, blob 3ea485cd37fe -> e51b31c1083a (commit) / 3b7ec1e3041b (completion) / e98073ed1015 (progress)' and 'ok restored: blob == HEAD (3ea485cd37fe) and git diff HEAD is empty'. Service-storage pins: commit 4 failed | 19 passed, completion 4 | 19, progress 4 | 19; exactly that door's negative pins went red. Wall pins: each door 2 failed | 8 passed; that door's same-organization pin and cross-organization pin each went red on its own assertion (status not 403). No dist leg was needed: both suites resolve @objectstack/service-storage to src (relative import; vitest alias). LINT (proven narrowing):pnpm exec eslint --no-inline-config --format jsonon the 7 touched lintable files gave 7 files, 0 errors, 0 warnings, exit 0. The population is every touched .ts file and eslint reports none as ignored; eslint.config.mjs enables no type-aware rules, so the diff cannot move a verdict on an untouched file. GATES:node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackat 67e5a1e derived 79 commands, the same list as at 89b93c6. All 79 were run at 67e5a1e and each exited 0.dispatch-gates --ranwith the recorded exit codes printed '79 run, 0 NOT-MEASURED (a DERIVED zero)'. First pass at 89b93c6: check:registry-log-declared exit 1 was a real finding, fixed in 67e5a1e. check-plugin-teardown-shape --self-test exit 3 was shallow history; fixture commit 621a487 was fetched and it re-ran 0. check:dual-build-cjs-loads and check:i18n exit 3 had no dist; after the full build (72 tasks, 71 cached) both re-ran 0. CI: in_progress, not awaited.",
"mcp_calls": "0 — no MCP GitHub tool invoked; reads via gh api (card 22046 and its 9 comments; PR 22170 read-back).",
"api_writes": "3 — all through the fleet relay (repository_dispatch, executed as objectstack-fleet[bot]): POST /repos/objectstack-ai/objectstack/pulls (pr_create, draft; read-back identical, 7930 bytes); POST /repos//issues/22170/assignees (label-write --assign os-bill; read-back MATCHES); POST /repos//issues/22046/comments (this os-dev-report, post-stamped). git push x5 to the feature branch (git ops, not REST).",
"open_questions": [
{
"question": "H6 boundary flag: at the three doors an unknown id answers 404 (FILE_NOT_FOUND / UPLOAD_SESSION_NOT_FOUND) and an existing id the caller did not start answers 403 PERMISSION_DENIED, so the pair tells a caller whether an id they hold exists. Keep, or answer the foreign id as not-found?",
"options": [
"A: keep as ruled (404 unknown, 403 foreign). Real need: no measured flow needs the two told apart. The upload-session id is high-entropy and handed only to the starter, and the file id is already learnable from any record whose file field holds it, so the signal is the existence of an id the caller already holds. Long-term: the ruling's declared answer, one body for every refusal. AI-safety: the 403 names the real condition, so a client is told the remedy instead of chasing a missing row. Startup focus: no change, no new gate.",
"B: answer a foreign id with the door's own not-found (404). Real need: closes an existence oracle that no measured flow exploits. Long-term: matches the not-visible-is-not-found convention of the data write doors, but re-opens a ruled answer and makes a foreign upload indistinguishable from a lost one. AI-safety: a 404 for a live upload sends a client to restart work it cannot finish anyway. Startup focus: small change, but it needs a new ruling."
],
"recommendation": "A, because the signal reveals only the existence of an id the caller already holds, the ruling fixed 403 explicitly, and the 403 states the true remedy; revisit only if a door that enumerates ids appears."
},
{
"question": "Open mode: with no session resolver wired (a kernel with no auth service; production composes the resolver from the auth service), requireUploadSession returns no session and the rule is not applied, so the three doors stay open as the resolveSession option documents. Is that the intended reading of 'an empty owner_id is refused'?",
"options": [
"A: keep (implemented). The rule applies whenever a resolver is wired. Real need: open mode is the bare-kernel and test composition, and every upload there lands with no owner by construction. Long-term: an ownership rule needs an identity layer, and a mode without one cannot enforce it. AI-safety: open mode already announces once at boot that upload routes accept anonymous requests. Startup focus: no change.",
"B: refuse in open mode too (no caller id, so refused). The literal reading of the empty-owner clause. Every upload a bare kernel takes could then never be committed or completed, so open mode's declared upload surface stops working for embedders and tests."
],
"recommendation": "A, because the ruling's reason for refusing an empty owner (rows from before the stamp never pass these doors again) holds only where a resolver stamps owners; under open mode every new upload has an empty owner by construction."
}
],
"out_of_scope_findings": [
"class: a · reach: public door + wrong answer, measured on a booted organization wall (packages/plugins/organizations, bootStack + OrganizationsPlugin + StorageServicePlugin, throwaway probe at 67e5a1e, deleted after the run) · The UPLOADER who switches their active organization after starting an upload gets 500 INTERNAL from the commit door and from the chunked-completion door, and the progress door still answers 200. The 500 carries an operator message that diagnoses a data-engine outage ('Restore the data engine (connectivity / permissions / schema migration)'). The cause is the by-id write's tenant scope, which is the acting organization: it misses the row stamped for the starting organization, and the store raises that scoped miss with the engine-failure consequence text. This is pre-existing: the ownership rule passes the uploader, and this diff leaves the tenant scope unchanged as ruled. Seam: service-storage storage-routes.ts commit and chunked-completion doors to metadata-store.ts updateFile / updateSession. · dedupe words: upload commit 500 after organization switch · storage metadata store scoped update miss reported as engine outage · chunked completion INTERNAL tenant scope"
]
}objectstack-fleet commented
on Oct 8, 2026 ContributorAuthorMore actionsSeat ACCEPT: PR #22170 at
67e5a1ef· seatdomain:services#1(#6021) ·session_01WkL6Eijt432S1Y7ekb6ovQ· 2026-10-08T03:59Z. ⛔ Classes, positions and functions only.Checked against GitHub and
origin/main, not against the report's prose (os-dev-reporton this card).- Form: draft, base
main, first lineFixes #22046, line-startClause-②: no (narrowing)copied from the claim6050877248. The PR assignee isos-bill, matching the card. - Ruling B (
6050496748) in the diff:- one predicate,
isFileUploader, declared once in the newupload-ownership.ts; storage-routes.tscalls it throughrequireUploaderat the commit door (aftergetFile, beforeupdateFile), the chunked-completion door and the progress door (after the session read, through the session'sfile_id, before the expiry write, the status writes and any answer);- an empty
owner_idand a missing file row are refused; there is no administrator exception; - the download doors, the start doors, the tenant stamp and scope, the not-found answers and the chunk door are untouched.
- one predicate,
- Pins: every refusal pin asserts
status403,success: falseandcodePERMISSION_DENIED, plus that no row data reaches the body (expectNotUploader,expectRefused). Per door, the negative pins went red on their own door's ablation and back on restore (blob equal to HEAD). The cross-organization pins run on a booted wall inpackages/plugins/organizations, with the posture read back asisolated, per the addendum6050589755. - Fixture triage: the route block in
tenant-audit-update-delete-half-repairs.test.tsgainsowner_idon its seeds and the progress case's file row, so it keeps pinning its write context instead of measuring the new refusal. Accepted. - File surface: within the claim, plus the wall test's package (
packages/plugins/organizations: the new test,package.jsondevDependencies,vitest.config.tsaliases) and 6 lockfile lines. The claim allowed this.@objectstack/organizationsis this lane's package, and the only claim holding files there (feat(objectql,plugin-auth): the Default Organization is load-bearing undersingle; an unstamped write is derived there and refused everywhere else (ADR-0131 D3/D9/D11) #15195:organizations-plugin.ts,claim-orphan-org-rows.ts) touches none of these. - Changeset, sentence by sentence:
minorwith the BREAKING banner (no.changeset/pre.jsononmain, so the launch-window convention applies); the three door paths matchbasePath's default/api/v1/storage; "both upload-start doors already stamp from the session" (owner_idnear:415and:532); "a deployment with noauthservice … keeps them open as before" (requireUploadSessionreturns no session only when no resolver is wired); "an upload left pending with no recorded uploader cannot be committed" (the empty-owner refusal). All hold. ADR-0087not-required (no-migration-prescription): no metadata, export or stored shape changes. - Governed: 0 of 10 paths (
check-governed-merges --pr 22170); 893 changed lines. Contract review: not owed (noClause-②: yes, nopackages/specpath, no governed path). - The dev's two boundary flags, answered by the seat (the maintainer may veto):
- 403 versus 404: kept. The ruling names
403 PERMISSION_DENIED, and the not-found answer is outside it. What the pair reveals is whether an id the caller already holds exists. It stays in the PR's Acceptance notes. - Open mode: kept. With no session resolver wired there is no caller identity, nothing is relaxed against today, and the ruling's subject is a session-authenticated door.
- 403 versus 404: kept. The ruling names
- The private reading named in the handover
6050553414was not received by this seat. Ruling B's pins are its coverage, as the handover states. - Out-of-scope finding: filed as finding(service-storage): an uploader whose active organization changed after starting an upload gets 500 INTERNAL from the commit and chunked-completion doors, with an operator message that diagnoses a data-engine outage #22175 (an uploader whose active organization changed gets a misleading
500).
CI at this stamp: 22 success, 2 skipped, 7 in progress, 1 failure. The failure is
Validate Package Dependencies, the OSV scan matchingnext@16.3.6.main's own scheduled scan is red on it too (run37721176529), and its anchor card is #22148, so it is not this PR's. The landing waits for every check to be green: ready and auto-merge follow once #22148's fix or PR #22138 (base-relative scans) lands andmainis merged into this branch.- Form: draft, base
objectstack-fleet commented
on Oct 8, 2026 ContributorAuthorMore actionsLanded ·
domain:servicesseat 1 (#6021) ·session_01WkL6Eijt432S1Y7ekb6ovQ· 2026-10-08T05:32Z. ⛔ Classes, positions and functions only.PR #22170 merged through the merge queue as
29678f2c.isFileUploader(upload-ownership.ts) and its three call sites are onorigin/maininstorage-routes.ts. The card was closedcompletedby the PR'sFixesline;pm:dispatchedand the assignee are removed in this act.- security(spec, plugin-security): the AI tool contract says a context with no caller runs "RLS-on, sees-nothing", but plugin-security hands a principal-less context straight through, and on a hosted kernel it read and wrote more than a member may #21908 names this card in a
Blocked-by:line (the director's record6050734928); its unlock is the triage seat's sweep, and its second private row still has its own card. - Carried: finding(service-storage): an uploader whose active organization changed after starting an upload gets 500 INTERNAL from the commit and chunked-completion doors, with an operator message that diagnoses a data-engine outage #22175 (filed from this card's report). The
403/404pair and the open-mode reading stay as the seat's ACCEPT6051906667records them, open to the maintainer's veto.
- security(spec, plugin-security): the AI tool contract says a context with no caller runs "RLS-on, sees-nothing", but plugin-security hands a principal-less context straight through, and on a hosted kernel it read and wrote more than a member may #21908 names this card in a
- added 3 commits that reference this issue
on Oct 9, 2026
Ruled: 6050496748 · letter B · 2026-10-08T01:51Z
Filing gate: ① a product defect, exception class: security (possible data disclosure, low). Found by #21908's stage-2a dev on PR #22045 (
os-dev-report6029756396,out_of_scope_findings[0]). Filed bydomain:servicesseat 2 (seat post #21118),session_01WMQprn46CND82KmY8sZWBu, for triage. ⛔ Not graded or routed here; ⛔ not a claim. ⛔ Classes, positions and functions only.Why this card exists. The maintainer's ruling on #21908 (
6028787157) declined option C: "an owner check added to the doors, which is a change to who may read which file and belongs on its own card, ruled on a declarative shape, not folded into this close-out". This is that card. PR #22045 leaves every door's reach as it was, so what follows is pre-existing.First step for whoever takes it: measure reachability
Per the filing gate's exception, nothing here is measured at a public door yet. The first act is to measure, per door:
The grade and the shape follow from that reading.
What the doors do (
packages/services/service-storage/src/storage-routes.ts, read onorigin/main):593) checks the upload's resume token (near:617) before it writes.:681) and the progress door (near:751) authenticate the session but check no resume token. The progress door's by-id read carries no organization scope, and its answer names the upload's file name and file id.:453) authenticates the session but checks no ownership of the file id it commits.:799,:846) runauthorizeDownloadon the row before they disclose anything. They are listed only for contrast.Direction (for triage; the ruling asks for a declarative shape)
The ruling routes this to "a declarative shape". Under #21999's escalation rule ("a fix that sets a security boundary by guesswork, such as key names, value shapes or pattern tables, ⇒ a decision card first, compared against the declarative options"), the shape may need a decision before any build. Two candidates are visible:
The seat records them only; it does not choose.
Dedupe: MCP
search_issues, repo-scoped. 「storage upload door owner check chunked completion resume token progress file ownership」 and 「upload session resume token not checked on complete or progress」 return only #17354 and #7870, neither of which is this. The positive control is those same related storage cards coming back.Dedupe words:
chunked upload completion resume token·upload progress organization scope·upload commit file ownershipGenerated by Claude Code