[v0.1/P0] Enforce outbound frame and operation-specific payload limits #16
Labels
No labels
priority/P0
priority/P1
priority/P2
release/v0.1.0
status/blocked
status/planned
type/bug
type/design
type/test-gap
type/tracker
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
natsukium/felis#16
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Parent: #12 (P0.1). Related to #5 and #10.
Why
Receivers enforce
DEFAULT_MAX_BODY, but senders narrowusizetou32without preserving that limit. Treating the 64 MiB framing cap as every operation's policy also leaves ordinary inputs and descriptors unnecessarily large.Scope
Acceptance criteria
DEFAULT_MAX_BODYor truncate its length.Triage plan (2026-09-03)
Source-grounded triage against
mainat69076d42, reviewed through seven rounds of an independent reviewer (pisol/luna) until it passed with no findings. The dependency order that supersedes the tracker's is posted on #12. Where a "Review amendments" section below conflicts with an earlier section, the amendment is the decision.Claim check
Accurate against HEAD.
crates/felis-protocol/src/frame.rs:62-68Frame::encode_to:let body_len = self.body.len() as u32;andcrates/felis-transport/src/framing.rs:165-182FrameWriter::write_frame:let len = LEN_OVERHEAD + frame.body.len() as u32;on the large-body path. A body between 64 MiB and 4 GiB writes a header the receiver rejects (BodyTooLarge, tearing down the connection with the receiver blamed); a body ≥ 4 GiB wraps and writes a corrupt length.codec::encode/encode_correlated(codec.rs:26-28,:83-91) andsend/send_unflushed/send_correlated(framing.rs:186-215) are all infallible on size. The client writer task (felis-client/src/main.rs:1735-1746) callswrite_framedirectly.DEFAULT_MAX_BODY = 64 MiB(frame.rs:19), enforced atFrameReader(framing.rs:64) anddecode_with_ceiling; REQ-105 (spec.md:58) documents only the receive side.MAX_ENV_BASE_ENTRIES/_BYTES(session.rs;ipc.md:703-707),MAX_SESSION_TAGS/MAX_TAG_BYTES(ops.rs:267-270),MAX_CARRIER_*(preface.rs:290-295),MAX_IMAGE_CHUNK_PAYLOAD(image.rs:46),LinkText::CAP. Absent:InputMsg::KeyBytes(Vec<u8>)/Paste(Vec<u8>)(input.rs:85-88),SearchMsg::Query.query: String(search.rs:19-24),SpawnArgs.command/args/cwd/env(session.rs:117-129),RetargetTargetstrings (ops.rs:299, endpoint path / SSH destination and args), bridge request lines (cli_bridge.rs:118BufReader::lines()has no length cap; that surface is #21's).Verdict
accept. No principle question; this is the "strictness at the edge" the design values claim. One refinement on placement: the limits must live in
felis-protocol(below daemon/client policy), which is also what #49 requires for receiver-side limits, so create the module once and let #49 extend it.Approach
felis-protocol
frame.rs:Frame::encode_to(&self, out) -> Result<(), FrameError>andencode()likewise, returningFrameError::BodyTooLarge { body_len, ceiling }whenself.body.len() > DEFAULT_MAX_BODY as usize(checked before theu32narrowing, so the ≥ 4 GiB case is caught by the same branch). KeepFramea plain view; the check is at encode time, not construction, so the daemon's fan-out of one body to many peers pays it once per write, which is where the header is formed.messages/limits.rs(re-exported frommessages.rs), onepub constper surface with a doc line citing the reason for the number:MAX_KEY_BYTES = 4 KiB(one key event or IME commit; kitty keyboard encodings are tens of bytes; an IME commit of a paragraph is ~1 KiB).MAX_PASTE_BYTES = 16 MiB(the largest paste a clipboard realistically holds; must be ≤ #15'sPTY_INPUT_BUDGETor split; pick the budget after #15 settles, and pin the relation with aconst _: () = assert!(...)).MAX_SEARCH_PATTERN_BYTES = 4 KiB(theregexcrate compiles under its own size limit; this bounds the request, not the automaton).MAX_SPAWN_ARGV_ENTRIES = 4096,MAX_SPAWN_ARGV_BYTES = 1 MiB(mirrors the env caps; LinuxARG_MAXis 2 MiB for argv+env together),MAX_SPAWN_PATH_BYTES = 4 KiBforcommandandcwd(PATH_MAX),MAX_SPAWN_ENV_*reuse the env-base caps.MAX_RETARGET_DESCRIPTOR_BYTES = 64 KiBacross the carrier's strings (an SSH destination plus--ssh-args).MAX_BRIDGE_LINE_BYTES = 32 MiB(defined here so #21 and the CLI share it; ≥ a JSON-escapedMAX_PASTE_BYTESplus envelope).WireError::OverLimit { field: &'static str, len: usize, cap: usize }raised from theTryFrom<v1::*>conversions (convert/*.rs) for every field above, so a daemon or client decoding an over-limit body gets a typed, connection-local error before any use. The same constants are checked on the send side by afn validate(&self) -> Result<(), WireError>on each domain enum, called fromFrameWriter::send*and by the CLI/bridge before it builds the request. Encoders stay infallible (to_wire); the check precedes them.felis-transport:
write_framereturnsTransportError::BodyTooLargefrom the newFrameErroron both the scratch and large-body paths, before any header byte is written (so a refused frame leaves the stream consistent and the connection usable).send*propagateWireError::OverLimitasTransportError::Wire.felis-daemon: the outbound path (
write_event_frame,serve.rs:1382-1398) treatsBodyTooLargeas a daemon bug: log aterrorwith the event's kind and length, evict the subscriber (same path as the gauge eviction), never panic. Audit the largest emitted bodies against the cap: aRowDeltabatch at 2048×2048 with a pathological row codec expansion, and aRegionMsgreply; add a chunking guard where a batch can approach 64 MiB. Inbound:OverLimitonSearch→Conn::Error { InvalidRequest }on the stream; onCreate/Retarget→AttachFailure::SpawnFailed/ ops error reply with the field name indetail; onInput(fire-and-forget, no reply channel) → connection-local close withConnError::Wire, since a conforming client checks before sending.felis-client-core / felis-cli:
sessions send,sessions spawn,search, andswitch/retarget checkvalidate()before dialing and report the CLIinvalid_requestmachine error kind (already in the vocabulary, CHANGELOG Unreleased), naming the limit; the bridge emits the same kind as an error object on the request id (error_object,cli_bridge.rs:194-197), so machine consumers keep a parseable line. The GUI's paste path clamps toMAX_PASTE_BYTESand shows the existing confirm overlay's label path for "paste truncated" or refuses; choose refuse (explicit over silent truncation, principle 4's spirit).Tests
frame.rs: body of exactlyDEFAULT_MAX_BODYencodes;DEFAULT_MAX_BODY + 1returnsBodyTooLarge; the same pair onFrameWriter::write_framethrough aVec<u8>sink asserting zero bytes written on refusal (a 64 MiB zeroedVecis fine for nextest; mark the test#[ignore]-free but single-threaded if memory-bound on CI).limits.rs: at-limit / one-past for each constant throughcodec::encode+decoderound trip (send-sidevalidateand receive-sideTryFromboth), plusconst _assertions that every semantic limit is belowDEFAULT_MAX_BODYandMAX_PASTE_BYTES ≤ PTY_INPUT_BUDGET.felis sessions sendwith an over-limit payload exits1with{"error":{"kind":"invalid_request",...}}; bridge golden line.just fuzz*).Docs cascade
docs/reference/ipc.md: new "Semantic limits" table directly under "Frame layer" (limit, value, applies to, checked by sender / receiver, outcome), stating explicitly that the frame cap is a framing backstop and these are the operation policies; "Session" and "Search" sections reference it.docs/reference/spec.md: REQ-105 gains "enforced on encode as well, before integer narrowing"; new REQ-105a listing the semantic limits.docs/reference/cli.md: per-verb limit and theinvalid_requestmapping;docs/reference/testing.md"Security tests" → "Frame length ceiling" extended to the encode path.docs/explanation/security-model.md"Daemon IPC": one bullet on why per-operation limits exist beside the framing cap (rejected alternative: a single cap per family).CHANGELOG.md: new limits (user-affecting: paste size, argv size, search pattern),invalid_requeston over-limit.skills/felis/SKILL.md: the limits a script must respect forsessions send,spawn,search, and the bridge line cap.Dependencies
limits.rs(land #16 first); #15 fixesPTY_INPUT_BUDGETrelative toMAX_PASTE_BYTES; #21 consumesMAX_BRIDGE_LINE_BYTES; #23 owns the machine error-kind vocabulary (invalid_requestalready exists, so no new kind is needed unless #23 renames). #12's order holds.Risk/effort
M. Mechanical across four crates; the judgment is in the numbers. Main risk: a cap that breaks a real workflow (a 20 MiB paste, a 5000-entry argv); keep the numbers generous and record the rationale beside each constant so a later change is a one-line decision, not a re-investigation.
Labels
Keep
priority/P0,release/v0.1.0: the limits become part of the frozen wire contract (#12 "Contract freeze boundary"), so they must be chosen before the tag even though theu32truncation itself is a low-likelihood bug.Review amendments (round 2)
MAX_PASTE_BYTES = 16 MiBand #15'sPTY_INPUT_BUDGET = 16 MiB, related by a compile-time assertMAX_PASTE_BYTES <= PTY_INPUT_BUDGETin this module. "Or split" is withdrawn; there is no chunking.