Skip to content

feat(service-storage): sys_attachment verifies the caller may open the referenced file - #27424

Merged
objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/harden-attachment-file-reference
Oct 29, 2026
Merged

objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/harden-attachment-file-reference

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Clause-②: no
Metadata-protocol: none

维护者速读

  • 改了什么:sys_attachment 的写入闸门现在会核验调用者能否打开 file_id 指向的文件(插入时,以及更新改动 file_id 时),与文件字段写入共用同一个判定。
  • 为什么改:附件是 attachments 范围文件经父记录变得可读的途径;引用文件的两扇写入门应当回答同一个问题,由同一个判定回答。
  • 风险与回滚:风险在于误拒合法的附件写入(上传者本人、或已能经其他记录打开该文件的人仍放行;系统写不判)。回滚即 revert 本 PR。
  • 先看:packages/services/service-storage/src/attachment-access-hooks.ts(installAttachmentFileReferenceGate)、packages/services/service-storage/src/referenceable-file.ts、packages/services/service-storage/src/storage-service-plugin.ts(接线)。
  • 两分钟验证:pnpm --filter @objectstack/dogfood exec vitest run test/attachments-permission-matrix.dogfood.test.ts -t 'names a file the caller may open',预期 3 个用例全绿。
  • 需要你做的:无,席位落地。

What changes

sys_attachment now verifies that the caller may open the file a row references.

  • One predicate, shared. findReferenceableFile, systemReadContext, the FileReferenceReadAuthorizer type and the refusal sentence move from file-reference-lifecycle.ts into a new module, referenceable-file.ts. The file-field door keeps using them unchanged (byte-identical refusal text); the attachment gate now uses the same ones.
  • The attachment write gate asks it. A new registration, installAttachmentFileReferenceGate (in attachment-access-hooks.ts, not exported from the package entry), adds a beforeInsert and a beforeUpdate hook on sys_attachment. Insert: the row's file_id must name a file the caller may open. Update: asked once when the payload names a file_id that differs from a matched row's. System-context and context-less writes are not judged, exactly as at the other gates in that module.
  • Same answer as the file-field door. A file the caller may not open, and an id that names no file the acting organization reaches, are answered alike: 400 VALIDATION_FAILED, one ADR-0114 invalid_value entry for file_id labelled with the field's declared label (File). Nothing is written.
  • Order. Installed right after installAttachmentAccessHooks (same-priority hooks run in registration order), so the parent rule and the row rule answer first and a caller they refuse keeps their refusal.
  • Wiring. storage-service-plugin.ts builds the download door's verdict (buildFileReferenceReadAuthorizer) once and hands the same binding to both doors. installAttachmentAccessHooks's exported signature is unchanged.
  • What keeps working: attaching your own upload (including attachFileAs, whose fresh upload is the caller's own), attaching a file you can already open through another record, admin attaching their own files, seeds and lifecycle sweeps.

Tests

  • Unit (attachment-access-hooks.test.ts, new describe "the file a row names must be one the caller may open", driven through the suite's existing engine double): insert refuses a file the caller cannot open (envelope: code + the file_id field entry, lookup carries the acting organization); an id naming no file gets the same answer; controls: an openable file is admitted, a system insert is not judged, a caller the parent rule refuses keeps ATTACHMENT_PARENT_ACCESS with no file judged, no file gate installed means no file judged; update refuses a file_id change to an unopenable file, admits an openable one, and judges nothing when file_id is unchanged or absent.
  • storage-service-plugin.test.ts: the hook-roster pin now lists the second beforeInsert/beforeUpdate pair, with a comment naming it.
  • Dogfood, HTTP in process (attachments-permission-matrix.dogfood.test.ts, new describe (h)): insert of a file the member cannot open answers 400 VALIDATION_FAILED with the file_id entry, no row lands, the member's download of that file still answers 403; control: the member's own upload attaches. Update re-pointing file_id at that file answers the same 400 and leaves the row unchanged; control: re-pointing at the member's second own upload answers 200. Control: the file's owner attaches it.

Acceptance notes

  • Clause-②: no: a runtime permission behaviour change, no contract surface widened (the new installer is module-level, not reachable from the package entry; installAttachmentAccessHooks keeps its signature).
  • The update hook judges the payload's file_id once per dispatch; the per-row dispatch binds input.id, and the unscoped multi-update shape is refused by the row rule before this hook runs.

Verification

All readings at head bc7274ef (objectstack-ai/objectstack).

Package tests, typecheck, lint
  • pnpm --filter @objectstack/service-storage test: Test Files 70 passed (70), Tests 1267 passed (1267).
  • pnpm --filter @objectstack/service-storage typecheck: exit 0.
  • eslint --no-inline-config packages/services/service-storage/src packages/qa/dogfood/test/attachments-permission-matrix.dogfood.test.ts: exit 0.
  • Lock verdict: os-verify-lock: VERDICT command-exit 0.
Dogfood
  • test/attachments-permission-matrix.dogfood.test.ts (dependency closure built first): Tests 22 passed, 1 skipped (23).
  • Every dogfood file that writes or reads sys_attachment or the storage file routes (21 files): Test Files 20 passed, 1 skipped (21); Tests 177 passed, 9 skipped (186).
Ablation (one-time proof, not kept)
  • Mutation: in storage-service-plugin.ts, the installAttachmentFileReferenceGate(...) call replaced through scripts/ablation-replace.mjs (anchor 1 to 0, blob 00a7d069 to 50b2de31), package rebuilt, ablation-dist-preflight.mjs --absent confirmed the call absent from all 4 built files.
  • Mutated run of the (h) cases: the two refusal cases failed on their 400 expectation, the owner control passed.
  • Restore: git checkout HEAD -- PATH, rebuild, preflight confirmed the call back in dist/, blob hash equals HEAD (00a7d069), working tree clean.
Gate families
  • node scripts/pm/dispatch-gates.mjs --commands (95 families) run locally; --ran reconciliation: 95 derived, 95 run, 0 UNRUN.
  • Exit 0 for all but two, both NOT MEASURED: check-plugin-teardown-shape --self-test (exit 3, its pinned fixture commit is outside this shallow checkout) and check:dual-build-cjs-loads (exit 3, PREREQUISITE NOT MET: unrelated packages have no dist/ here). CI runs both.
  • check:system-context-census and check:engine-double-contract were red on an earlier head and are green at this head (the register row names the new installer; the new unit cases reuse the suite's existing engine double).
  • node scripts/check-platform-checklist.mjs --changed-from origin/main: exit 0, 0 anchors lost.
  • check:nul-bytes: OK.

Dispatched by domain:devx#1 · sunhaolin · session_017xRBcScm27Q3R5mkYLPWdB.


Generated by Claude Code

…e referenced file

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017xRBcScm27Q3R5mkYLPWdB
…existing engine double

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017xRBcScm27Q3R5mkYLPWdB
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface labels Oct 29, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/service-storage, touching 16 documentable anchor(s).

46 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json c5d46341747746b180626ad2fcb918324341cd22.

⛔ 13 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 58 of 210 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 10 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json c5d46341747746b180626ad2fcb918324341cd22 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 6152e636830e3a4beb234645ef263dc57c3ddb00 — the merge of head bc7274ef53fb053cd7ebf5ba6872578ef17a40a7 into base c5d46341747746b180626ad2fcb918324341cd22, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 6152e636830e3a4beb234645ef263dc57c3ddb00 && git checkout 6152e636830e3a4beb234645ef263dc57c3ddb00
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin c5d46341747746b180626ad2fcb918324341cd22 bc7274ef53fb053cd7ebf5ba6872578ef17a40a7 && git checkout -B drift-repro c5d46341747746b180626ad2fcb918324341cd22 && git merge --no-ff bc7274ef53fb053cd7ebf5ba6872578ef17a40a7

node scripts/docs-audit/affected-docs.mjs --json c5d46341747746b180626ad2fcb918324341cd22

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs c5d46341747746b180626ad2fcb918324341cd22 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:devx#1 · sunhaolin · session_017xRBcScm27Q3R5mkYLPWdB

席位核对

Verdict: ACCEPT at head bc7274ef53fb053cd7ebf5ba6872578ef17a40a7, reviewed 2026-10-29T10:18Z.

Context: this PR was dispatched without a card, under the maintainer's 2026-10-29 disclosure ruling for security findings met in testing (dispatch only through pm-dispatch, no issue). So there is no Fixes/Part of line and no card to close; that is the accepted shape, not an omission.

check reading
Shape Draft, base main, no closing keyword anywhere in the body. Merges cleanly with origin/main (git merge-tree exit 0 against ded67bfb).
Path surface check-governed-merges --pr 27424: 0 of 9 paths governed, +462/-86 = 548 lines, under 3000, so ordinary queue landing applies.
Clause-② no, and correct: a runtime permission-behaviour change with no published contract widened. No packages/spec/src/** path is touched, the new installer is not exported from the package entry, and installAttachmentAccessHooks keeps its signature.
Changeset .changeset/attachment-file-reference-verdict.md, patch for @objectstack/service-storage. Each sentence was checked against the diff. Its words describe the hardening and carry no reproduction.
Docs content/docs/permissions/system-context.mdx row 44 and the register line name installAttachmentFileReferenceGate; both are accurate to the code.
Diff read One predicate (findReferenceableFile plus unreadableFileMessage) now serves both write doors from referenceable-file.ts. The file-field door's refusal text is byte-identical (the moved helper builds the same sentence). The new gate is installed after the access hooks, so the parent and row refusals keep precedence. System and context-less writes are not judged. An update judges file_id only when a matched row actually changes it.
Refusal assertions The dogfood (h) cases assert status 400, code VALIDATION_FAILED and the single file_id/invalid_value entry, check that no row lands or changes, and check that the download door still answers 403. Each refusal has its control.
CI on head All 50 check-runs on bc7274ef are completed. Lint & Repo Gates success, TypeScript Type Check success, every other run success or skipped. check-expected-skips --pr 27424, run from a main-tip checkout: OK, all 13 skips are in main's roster.
Seat's own run The dogfood shards are full-battery only (they skip on PR heads and queue builds), so I ran the new cases myself on a worktree at bc7274ef after turbo run build --filter=@objectstack/dogfood... (67/67 tasks). attachments-permission-matrix.dogfood.test.ts: 22 passed, 1 skipped. The (h) describe alone: 3 passed. service-storage attachment-access-hooks.test.ts, storage-service-plugin.test.ts and file-reference-lifecycle.test.ts: 248 passed.
Dev report mcp_calls 0; one relay write (pr_create); the gate-family reconciliation is 95 derived, 95 run. The two exit-3 gates are NOT MEASURED here for environment reasons, and CI ran them. The ablation shows the (h) refusal cases go red when the gate is not installed.

Accepted deviations:

  • No card reference, per the disclosure ruling.
  • Nits not worth a round: a stray blank line inside the installAttachmentAccessHooks doc block, and one blank line inside its beforeUpdate body.

Out-of-scope findings from the dev: none.

Landing: ready plus auto-merge through the relay, then followed to MERGED.


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:devx#1 · sunhaolin · session_017xRBcScm27Q3R5mkYLPWdB

Landing held at 2026-10-29T10:19Z: the seat's landing write was refused by this session's permission classifier, so the PR stays draft and nothing was retried.

  • Command: node scripts/pm/fleet-write/dispatch.mjs --repo objectstack-ai/objectstack --actions-file F, where F = [{"op":"pr_ready","pull":27424},{"op":"automerge_enable","pull":27424}].
  • Refusal: denied by the Claude Code auto-mode classifier, with no explanation given.
  • Needed from the maintainer: either allow that landing command for this seat, or mark the PR ready and enable auto-merge by hand.

The ACCEPT record above stands at head bc7274ef.


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 29, 2026 16:37
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 29, 2026
Merged via the queue into main with commit 1fd68ea Oct 29, 2026
51 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/harden-attachment-file-reference branch October 29, 2026 16:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant