Repository navigation
Chat content extension point: seal extension content at send time (limits, redaction, fencing, binary checks) #505
Copy link
Copy link
Labels
enhancementNew feature or requestNew feature or requestsecuritysecurity-reviewTouches security-critical code: elevated reviewTouches security-critical code: elevated review
Milestone
Description
Goal
Content that a chat content extension attaches to a message reaches the AI only in a form the core built itself: limits checked, text redacted and fenced as untrusted, binary blocks type-checked. No extension can bypass redaction, fencing or the size caps (ADR 0141, decisions 2–6, 8).
Context
- Every send to a provider (chat turn and compaction) already goes through
reapply_redaction_for_send(crates/app-logic/src/orchestration/chat_turn.rs:723,crates/app-logic/src/compaction.rs:764). Today it re-redactsAttachmentsviaredact_attachments; fencing happens inChatAttachment::to_model_text(crates/core/src/ai/attachments.rs). - The accept path today:
send_chat_message(attachment_ids)→resolve_chat_attachments→push_user_attachments(crates/app-shell/src/commands/chat.rs,crates/app-logic/src/orchestration/attachments.rs), limits invalidate_attachments(5 per message, 1 MiB on real bytes). - The previous issue added
MessageContent::Extension, theChatContentExtensiontrait and its registration; this issue wires the accept and send paths.
Requirements
- Accept (
send_chat_messagewithattachment_ids): for each id ask the registered extensions'take_pendingin order until one returnsSome; unknown id →ATTACHMENT_UNRESOLVED; ids but no extension →ATTACHMENTS_UNAVAILABLE; nothing is sent. Beforepersist, the core checks the generic limits, derivesmedia, checks binary types, redacts text and label intoRedactedBlocks (constructible only in the core), computesestimated_tokens, then callspersist; apersisterror sends nothing. Then text message, then oneExtensionpart per item into the history; untrusted flag set. No ids → behaviour byte-identical to before. Ids while a turn runs →ATTACHMENTS_WHILE_BUSY(unchanged rule). - Generic limits (core, hard caps): ≤ 5 parts per message, ≤ 4 blocks per part, ≤ 1 MiB per block on real bytes, text valid UTF-8, and new: ≤ 20 MiB binary bytes per request across the whole history (decided by maintainer). Checked on accept and on every send. Codes: reuse
ATTACHMENTS_TOO_MANY,ATTACHMENT_TOO_LARGE,ATTACHMENT_INVALID; one new stable code for the request cap. - Binary allowlist: only
image/png,image/jpeg,image/gif,image/webp,application/pdf, with leading bytes matching the declared type; anything else rejected before any provider call. Binary bytes are never decoded as text, redacted or logged. - Sealing at send: in
reapply_redaction_for_send, eachExtensionpart of the send copy is resolved and replaced byMessageContent::Sealed(SealedContent): private fields, single constructor in the core, notSerialize(#[serde(skip)]), explicitly rejected by the chat store. Text is redacted again, fenced with the tag of the extension'sExtensionSource(local_file), followed by the fixed security line; binaries become sealed blocks.resolvereturnsNoneor the extension is not registered → fixed core placeholder "content no longer available" with the label (decided by maintainer);resolvereturns an error → nothing is sent. - Token estimate from
estimated_tokens; compaction may replace a part by a summary as today. - Providers render
Sealedtext as today's attachment text and binaries via the existing image/PDF blocks until the generic block issue lands.
Acceptance Criteria
- No ids: request and history byte-identical to before (existing tests); ids without extension →
ATTACHMENTS_UNAVAILABLE, unknown id →ATTACHMENT_UNRESOLVED, no provider call (tests). - 6 parts, 5 blocks in one part, a 1 MiB + 1 byte block, invalid UTF-8 text, and > 20 MiB binary in one request are each rejected with a stable code and no provider call; the exact limits pass (tests).
- Bytes not matching the declared mime (e.g. text labelled
image/png) and a mime outside the allowlist are rejected before any provider call (tests). - Regression with counter-proof: a secret pattern in extension text and in the label never reaches the provider, also when the extension returns unredacted text from
resolve, and also after resume. - A fence-closing marker in text or label is neutralised; extension text never appears outside its fence (test on the built request).
-
SealedContentcannot be built outside the core and cannot be persisted (compile-fail doc test or visibility test; chat store returns an error for it). -
resolve→Noneand a missing extension yield the core placeholder;resolve→ error sends nothing (tests); compaction requests go through the same sealing (test). - Ids while a turn runs →
ATTACHMENTS_WHILE_BUSY(test). - Spec 0110 (§1–4, §8, §9) and Spec 0039 (extension content as untrusted local content) describe the behaviour.
- Relevant automated tests pass
Technical Notes
- Security-relevant: redaction and fencing (
crates/core/src/ai/), send path (crates/app-logic/src/orchestration/). Tighten only: the oldAttachmentspath keeps its checks until it is removed. - The fence tag comes only from the closed
ExtensionSourceenum; the security line is core text; extensions never supply prompt text outside a fence. untrusted_content_ingestedis set for every extension part, independent of the source.
Spec impact: update 0110, update 0039
Open questions
RedactedBlockvisibility: a private constructor inssh_manager_core::aineeds the redaction call inside the core crate, while the redactor is passed fromapp-logic. Options: (a) core functionseal_pending(blocks, &dyn OutputRedactor)returningRedactedBlocks, (b)pub(crate)constructor plus a re-export behind a sealed trait. Recommendation: (a).
Related Work
- Blocked by Chat content extension point: opaque extension part in chat messages, history display and removal callbacks #504
- Chat attachments: core message model, fencing, redaction and an attachment resolver slot #469, feat(core): chat attachments with fencing, redaction and resolver slot #475, Chat attachments: send image and PDF attachments as native content blocks, reject them for models that cannot read them #470, ADR 0034 (redact, then fence), ADR 0141, Spec 0110, Spec 0039
Reactions are currently unavailable
Metadata
Metadata
Assignees
Labels
enhancementNew feature or requestNew feature or requestsecuritysecurity-reviewTouches security-critical code: elevated reviewTouches security-critical code: elevated review
Type
Fields
Priority
P2 - Normal
Stage
Done
Role
None
Activity
smart-ssh-coder commentedon Oct 14, 2026
Implementation update
PR: #524
Implemented:
take_pending→ limits/allowlist/redaction in core →persist), send-time sealing in one place (chat turn and compaction),SealedContent/RedactedBlockcore-only, chat store rejectsSealed.ATTACHMENTS_REQUEST_TOO_LARGE(20 MiB binary per request). Specs 0110 and 0039 updated, changelog fragment added.Remaining / open:
Decisions:
seal_pendingis the only producer ofRedactedBlock. Other assumptions are listed in the PR.smart-ssh-reviewer commentedon Oct 14, 2026
Review result
Approved — see review on #524.
Findings:
Recommended next step: merge
feat(app-logic): seal chat extension content at accept and send (#505) (