Skip to content

An upload is claimed against the message sender, so it must go out under the bound bot (comms-qpup) - #219

Draft
GraemeF wants to merge 1 commit into
mainfrom
fix/upload-file-binds-comms-qpup
Draft

An upload is claimed against the message sender, so it must go out under the bound bot (comms-qpup)#219
GraemeF wants to merge 1 commit into
mainfrom
fix/upload-file-binds-comms-qpup

Conversation

@GraemeF

@GraemeF GraemeF commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Closes comms-qpup.

What was broken

uploadFile posted /api/v1/user_uploads through minterHttp. post went out
through boundHttp(). So the account that owned the file and the account that
sent the message referencing it were different accounts.

Zulip's do_claim_attachments validates each attachment against the message
sender
, not the uploader and not the reader. On a mismatch it logs a warning
and continues. The message sends, the markdown link renders, and the row that
grants read access is never written. Every reader then fails all three tests in
validate_attachment_request, so the link is dead for everyone — including the
realm owner, who gets a 403 on his own realm.

Reported by cc-homelab-cbb5b9c3 with realm evidence: 11 commy uploads, all
orphaned; 13 web-client uploads, none orphaned.

The fix, and what it pulled after it

uploadFile runs through boundHttp(). That put it on the mint seam with the
other attribution-producing verbs, which took four more changes:

BotHttp had no uploadRaw. It was Pick<ZulipHttp, 'get' | 'post' | 'patch' | 'delete'>, so the bound client could not upload at all. Widened by
that one member and passed through wrapBotHttp untouched.

Widening a security-adjacent type is the move that gets accepted once and cited
for the next member, so the reasoning is pinned in the code rather than left
here: the wall that wrapper enforces is a recipient rule on POST /messages,
and an upload addresses no recipient. bot-dm-guard.test.ts now asserts the
same wrapped client that just uploaded still refuses a bot-to-bot DM.

upload_file became a binding tool. It takes the host-supplied
session_id, gained cwd, and joined the PreToolUse matcher. Without this a
seat whose first commy call is an upload gets UnboundEphemeralSession instead
of a file — ensureBoundFor(undefined, …) returns the unbound stub.

The guard test could not see it. hooks-manifest.test.ts traced a tool's
bound path through adapter.publisher.* and adapter.inbox.* only.
upload_file reaches the adapter through a dep closure server.ts hands it, so
the rule "every tool whose adapter path reaches boundHttp receives
session_id" would have stayed green while being blind to the newest member —
the comms-65nj failure that file's own header names. The trace now resolves
the const upload = deps.upload alias and covers the attachment receiver.

A live test is the proof. Bot A uploads a four-byte file and posts it. A
second freshly-minted bot — owns nothing, subscribed to nothing — reads it back
through its own credential.

Measured, both ways

uploadFile on second bot's read
minterHttp (before) HTTP 403
boundHttp() (after) bytes come back

The reader is a second bot rather than adapter.downloadFile on purpose.
downloadFile reads through the minter, and the minter is subscribed to every
channel on this realm, so its read succeeds on a ground this change does not
touch. That instrument cannot tell a claimed attachment from an unclaimed one. A
fresh bot fails the ownership test and the UserMessage test, leaving
is_realm_public as its only route — and that flag is stamped onto the
Attachment row only when the claim succeeds.

The before-side run minted one orphaned attachment by design. It was deleted;
the realm's orphan count is unchanged.

What downloadFile rests on now

It stays on minterHttp, so a seat that only ever reads still never binds. With
the minter no longer owning the files it reads, that rests on two pieces of
realm state this repo does not control:

  1. No invite-only channels, so is_realm_public is stamped at claim time.
  2. The minter's blanket subscription, so it holds a UserMessage for the
    referencing message.

Neither is established by a discriminating test. They overlap completely on
this realm, so no download run against it can say which one admitted the reader.
What would discriminate: an invite-only channel the minter is not subscribed to,
carrying an attachment posted by another account. Building that mutates the
realm, so it is the operator's call. The call-site comment records all of this,
and names the fix if either ground goes away.

Out of scope

  • Making a dropped claim loud at the call site — comms-l5ci, blocked on this.
  • Re-uploading the 11 existing orphans. Not code work.

One thing to fix separately

ZULIP_LIVE_CHANNEL_NAME=agent-comms-test names a channel that does not exist
on the realm — 1204 streams, no match. Every channel-gated live test fails with
UnknownChannel on plain main, including the pre-existing resolve-then-post
suite. This is not caused by this branch. The measurements above were taken with
ZULIP_LIVE_CHANNEL_NAME=test (stream id 4, public).

…der the bound bot (comms-qpup)

`uploadFile` posted `/api/v1/user_uploads` through `minterHttp` while `post`
went out through `boundHttp()`. Zulip's `do_claim_attachments` validates each
attachment against the MESSAGE SENDER, so the claim was skipped, the row that
grants read access was never written, and the message sent anyway. The link
rendered and nobody could open it — including the realm owner.

`uploadFile` now runs through `boundHttp()`, which put it on the mint seam with
the other attribution-producing verbs and pulled four things after it:

- `BotHttp` had no `uploadRaw`, so the bound client could not upload at all.
  Widened by that one member, passed through `wrapBotHttp` untouched. The wall
  that wrapper enforces is a recipient rule on `POST /messages`, and an upload
  addresses no recipient. `bot-dm-guard.test.ts` now asserts the same wrapped
  client that just uploaded still refuses a bot-to-bot direct message.
- `upload_file` became a binding tool, so it takes the host-supplied
  `session_id` and joins the PreToolUse matcher. Without it a seat whose first
  commy call is an upload gets `UnboundEphemeralSession` instead of a file.
- `hooks-manifest.test.ts` traced a tool's bound path through
  `adapter.publisher.*` and `adapter.inbox.*` only. `upload_file` reaches the
  adapter through a dep closure, so the guard would have reported coverage it
  did not have. It now resolves the dep alias and covers the attachment
  receiver too.
- A live test in `realm.live.test.ts` is the proof: bot A uploads and posts a
  four-byte file; a second freshly-minted bot — owns nothing, subscribed to
  nothing — reads it back. Measured 403 before the change, bytes after.

The reader is a second bot rather than `adapter.downloadFile` because
`downloadFile` reads through the minter, and the minter is subscribed to every
channel on this realm. Its read succeeds on a ground this change does not
touch, so it cannot tell a claimed attachment from an unclaimed one.

`downloadFile` stays on `minterHttp`, so a seat that only ever reads still
never binds. That now rests on two pieces of realm state this repo does not
control — no invite-only channels, and the minter's blanket subscription — and
the call site records both, says plainly that neither is established by a
discriminating test, and names what would discriminate.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant