feat(protocol): cap outbound bodies before they reach the wire #63

Merged
natsukium merged 8 commits from feat/outbound-limits-16 into main 2026-09-04 12:21:54 +09:00
Owner

Fixes #16.

Summary

Senders now enforce the limits receivers already apply, and each operation
carries a semantic cap instead of inheriting the 64 MiB framing ceiling.

  • felis-protocol gains messages/limits.rs: per-operation caps (bridge
    lines, input/paste bytes, argv/environment, search patterns, retarget
    descriptors) plus a Validate pass run before a body is narrowed to u32
    or a header is written. Frame encoding and writing are fallible.
  • felis-daemon holds every outbound event under the frame ceiling, so a
    large region reply or stream chunk is refused as a typed error rather than
    silently dropped mid-stream.
  • felis-cli refuses over-limit payloads before dialing, reading one byte
    past the cap so the boundary is decided without holding the payload.
  • felis-client / felis-client-core refuse before committing side effects:
    a search query is validated before its stream id is opened, a request is
    validated before its id is issued, and the pipe chord's paste sink runs
    the same admission (and one-shot visible notice) as the clipboard and
    file-drop paths — an over-limit paste is refused, never silently truncated
    or dropped.

Doc cascade

docs/reference/ipc.md (per-operation limit table beside the framing cap),
docs/reference/cli.md, docs/reference/spec.md,
docs/reference/testing.md, docs/explanation/security-model.md,
docs/explanation/input.md, docs/explanation/data-model/scrollback.md,
CHANGELOG.md, and the product-shipped skills/felis/SKILL.md.

Verified: just check green (fmt, clippy, nextest, deny), pi review PASS

Fixes #16. ## Summary Senders now enforce the limits receivers already apply, and each operation carries a semantic cap instead of inheriting the 64 MiB framing ceiling. - `felis-protocol` gains `messages/limits.rs`: per-operation caps (bridge lines, input/paste bytes, argv/environment, search patterns, retarget descriptors) plus a `Validate` pass run before a body is narrowed to `u32` or a header is written. Frame encoding and writing are fallible. - `felis-daemon` holds every outbound event under the frame ceiling, so a large region reply or stream chunk is refused as a typed error rather than silently dropped mid-stream. - `felis-cli` refuses over-limit payloads before dialing, reading one byte past the cap so the boundary is decided without holding the payload. - `felis-client` / `felis-client-core` refuse before committing side effects: a search query is validated before its stream id is opened, a request is validated before its id is issued, and the pipe chord's `paste` sink runs the same admission (and one-shot visible notice) as the clipboard and file-drop paths — an over-limit paste is refused, never silently truncated or dropped. ## Doc cascade `docs/reference/ipc.md` (per-operation limit table beside the framing cap), `docs/reference/cli.md`, `docs/reference/spec.md`, `docs/reference/testing.md`, `docs/explanation/security-model.md`, `docs/explanation/input.md`, `docs/explanation/data-model/scrollback.md`, `CHANGELOG.md`, and the product-shipped `skills/felis/SKILL.md`. Verified: just check green (fmt, clippy, nextest, deny), pi review PASS
Senders narrowed `usize` to `u32` without checking the 64 MiB framing
cap, so a body between 64 MiB and 4 GiB wrote a header the receiver
rejects (blaming the receiver for the teardown) and a body past 4 GiB
wrapped into a corrupt length. `Frame::encode`/`encode_to` and
`FrameWriter::write_frame` now refuse before the narrowing and before
any header byte is written, so a refused frame leaves the stream
consistent and the connection usable.

Treating that framing cap as every operation's policy was the second
half of the problem: nobody chose 64 MiB for a keystroke, a search
pattern, or an argv, so those surfaces had no bound at all. The new
`messages/limits.rs` names one per surface with its rationale beside
it, and both peers check the same constants -- senders through
`FrameWriter::send*`, receivers through `codec::decode` -- so a limit
is a property of the protocol rather than of whichever side is newer.
A single cap per message family was rejected: `SessionMsg` alone spans
a 4 KiB path and a 1 MiB argv, and one number for both is either
useless on the path or wrong on the argv.

The receive-side half narrows what fields that already shipped will
accept, which REQ-104b says is never a minor: a peer built before this
can send a body every earlier build carried and lose the connection
for it. It rides the pre-freeze rule instead -- the dev wire carries no
promise until the compatibility freeze -- which is exactly why the
numbers have to be settled before the first tag.

Refs #16

Assisted-by: Claude Code
A diff cycle ships every dirty row in one `RowDelta`, and at the
extreme geometry (2048x2048) a full-screen redraw of wide clusters can
approach the 64 MiB frame ceiling. Now that the writer refuses such a
body, an unsplit batch would cost the subscriber its connection, so a
batch past half the ceiling is emitted as consecutive frames: rows are
keyed by index and apply independently, so the reader reaches the same
screen either way. Half, not the whole ceiling, because the protobuf
field framing and the registry entries riding beside a batch cost
bytes the row-payload sum does not see.

A row batch is not the only body that grows without a bound of its
own: a `RegionMsg::Reply` stitches a whole region into one `Vec<u8>`,
and 10 000 scrollback rows of 2048 wide colored cells (REQ-605,
REQ-605a) serialize past the ceiling, so a `pipe` chord over a large
buffer would hit the same writer refusal and lose the window its
connection mid-action. It cannot split -- the reply is one blob by
contract -- so it is refused instead, as the request's own
`Conn::Error { InvalidRequest }` naming the size and the budget.
Truncating to fit was the alternative and is worse: a pager, a file
and the clipboard all show a fragment as if it were whole, and
`felis sessions capture` already reads the same region row by row with
no ceiling.

The remaining refusal path is a daemon bug -- nothing a subscriber
sends makes an event too large -- so it is logged at `error` with the
kind and length before the eviction, or the eviction reads as a client
fault in the log.

Refs #16

Assisted-by: Claude Code
A payload past its per-operation limit is a bad request, and letting
it reach the writer would surface as a torn-down connection rather
than an answer: `sessions send`, `spawn`, `search` and the retarget
verbs now check before they dial and report `invalid_request` at
exit 1, and `felis bridge` answers the same kind as an error object on
the request's id so a machine consumer keeps a parseable line. An
over-limit bridge request line is refused uncorrelated, since reading
its id means parsing the payload being refused.

The window refuses an over-limit paste rather than truncating it: a
silently shortened paste runs as a valid-looking wrong command with no
way to see where it was cut.

Refs #16

Assisted-by: Claude Code
REQ-105 documented only the receive side, and no page said what any
individual operation was allowed to carry. The reference now states
both directions and carries the limit table (ipc.md "Semantic
limits"), REQ-105a names the constants, cli.md gives each verb its
bound and the `invalid_request` mapping, and the security model argues
why a framing backstop is not a policy and why one cap per message
family was rejected. The shipped skill gains the table a script must
respect, since a script that exceeds one now gets a refusal instead of
a send.

Refs #16

Assisted-by: Claude Code
`send_control_correlated` drops a frame its message fails validation on,
but `commit_search_query` had already called `open_stream()` by then:
the client burns a stream id and records it Open while the daemon never
hears of it. The next open -- or the `Cancel` an Escape sends -- then
names an id the daemon classifies as never-opened, a correlation fault
it answers by dropping the connection. That is the teardown this branch
exists to prevent, reached by typing a >4 KiB pattern into the bar. The
query is now validated before the stream is opened, and the refusal
lands in `search.error`, where the daemon's own typed errors already
render.

The same silence made the paste refusal a non-event: over
`MAX_PASTE_BYTES` the chord only wrote a `warn!`, which from the
keyboard is indistinguishable from an unbound chord or an empty
clipboard -- the very complaint the "refused, not truncated" rationale
raises against a silent truncation. The window now paints a one-shot
notice on the confirmation bar's row (an armed question outranks it,
and the next keypress clears it) rather than opening a second surface
for a message nobody has to answer.

Refs #16

Assisted-by: Claude Code
The doc block sat on `collect_send_payload`, which only dispatches on a
bare `-`; every mechanism it describes -- the one-byte-past-`cap` read
and the length counted without being held -- lives in
`read_send_payload`, which carried no doc at all. A reader of the
dispatcher was told about internals it does not have while the code
those internals guard read as unexplained.

Refs #16

Assisted-by: Claude Code
`send_correlated` now refuses an over-limit body before a byte leaves,
which made `correlated_request`'s order wrong: the request id was
issued first, so a refused send returned with that id outstanding for
the rest of the connection's life. The driver never sees a reply for
it, `match_reply` keeps it in the set, and each further refusal burns
another id.

Validating the message before `issue_request` keeps the refusal as
recoverable as the transport promises: nothing was written, and
nothing was spent.

Refs #16

Assisted-by: Claude Code
fix(client): hold the pipe chord's paste sink to the paste limit
Some checks failed
bench / Criterion full-suite snapshot (pull_request) Has been skipped
fuzz / cargo fuzz nightly long-run (pull_request) Has been skipped
darwin / build felis (aarch64-darwin) (pull_request) Successful in 46s
bench / Criterion regression gate (pull_request) Failing after 2m7s
fuzz / cargo fuzz smoke (per target) (pull_request) Successful in 1m28s
pr / nix flake check (pull_request) Successful in 18s
pr / cargo build / clippy / test / deny (pull_request) Successful in 1m41s
pr / generated code is current (pull_request) Successful in 2s
pr / wire schema is compatible with the base (pull_request) Successful in 10s
pr / frontend smoke (x86_64-linux) (pull_request) Successful in 50s
pr / publish felis (x86_64-linux) (pull_request) Has been skipped
pr / cargo check (MSRV) (pull_request) Successful in 11s
windows / cargo clippy (Windows cross) (pull_request) Successful in 14s
windows / cargo test (Windows) (pull_request) Successful in 5m50s
windows / frontend smoke (Windows) (pull_request) Successful in 1m40s
windows / package felis (x86_64-pc-windows-msvc) (pull_request) Has been skipped
7b52877fd7
The `pipe` chord's `paste` sink builds an `InputMsg::Paste` out of a
region the daemon admits up to half the frame ceiling — twice what one
paste may carry. It skipped the admission the clipboard and file-drop
paths run, so a scrollback between 16 and 32 MiB reached
`send_control`, which drops an over-limit frame with nothing but a log
line: the chord appears to do nothing at all, which is exactly the
silent failure the visible notice exists to prevent.

The cap and its notice move into one admission every paste producer
calls, with the boundary itself in a free function so it is testable
without an `App`.

Refs #16

Assisted-by: Claude Code
natsukium deleted branch feat/outbound-limits-16 2026-09-04 12:21:54 +09:00
Sign in to join this conversation.
No description provided.