diff --git a/openless-all/app/crates/openless-core/src/prompts.rs b/openless-all/app/crates/openless-core/src/prompts.rs index 105d6b9af..a144d8c88 100644 --- a/openless-all/app/crates/openless-core/src/prompts.rs +++ b/openless-all/app/crates/openless-core/src/prompts.rs @@ -10,6 +10,25 @@ pub fn system_prompt(mode: PolishMode) -> String { crate::style_packs::default_style_system_prompt_for_mode(mode) } +/// Character cap for untrusted text inside an XML prompt envelope. +/// +/// The envelope truncates past this limit and appends a marker. Any flow that +/// writes the model result back over the original selection must refuse inputs +/// above the cap — the model never saw the tail, so a rewrite would delete it. +pub const MAX_XML_ENVELOPE_CHARS: usize = 16_000; + +/// Whether `raw` is longer than the XML envelope will show a model. +pub fn exceeds_xml_envelope_cap(raw: &str) -> bool { + raw.chars().count() > MAX_XML_ENVELOPE_CHARS +} + +/// Error text for a replacement that would be computed from a truncated view. +pub fn truncated_selection_replacement_message() -> String { + format!( + "selection is longer than {MAX_XML_ENVELOPE_CHARS} characters; refusing to replace it from a truncated model view" + ) +} + /// issue #609 F-02: unified hardening before untrusted text goes into an XML envelope. /// /// - **Neutralize both opening and closing tags** (not just ``): an attacker can @@ -17,20 +36,16 @@ pub fn system_prompt(mode: PolishMode) -> String { /// and be treated as instructions. Case and surrounding-whitespace variants are /// best-effort (`< /tag >` and the like). The LLM is not a security boundary; this /// is defense in depth, not a hard guarantee. -/// - **Length cap**: inputs beyond `MAX_ENVELOPE_CHARS` are truncated with a +/// - **Length cap**: inputs beyond [`MAX_XML_ENVELOPE_CHARS`] are truncated with a /// `…[truncated]` marker, preventing oversized input from drowning the system /// prompt's constraints in context (attention dilution). /// /// `tag` takes the tag name without angle brackets (e.g. `raw_transcript` / /// `selected_text`). pub fn sanitize_for_xml_envelope(raw: &str, tag: &str) -> String { - /// Character cap for envelope content. Truncates beyond it — prevents attention - /// dilution and saves tokens. - const MAX_ENVELOPE_CHARS: usize = 16_000; - // Length cap first (by char, not byte, so multibyte UTF-8 is not split). - let capped: std::borrow::Cow<'_, str> = if raw.chars().count() > MAX_ENVELOPE_CHARS { - let truncated: String = raw.chars().take(MAX_ENVELOPE_CHARS).collect(); + let capped: std::borrow::Cow<'_, str> = if exceeds_xml_envelope_cap(raw) { + let truncated: String = raw.chars().take(MAX_XML_ENVELOPE_CHARS).collect(); std::borrow::Cow::Owned(format!("{truncated}…[truncated]")) } else { std::borrow::Cow::Borrowed(raw) diff --git a/openless-all/app/crates/openless-core/src/selection_service.rs b/openless-all/app/crates/openless-core/src/selection_service.rs index 9d6bfae51..6d0c25e23 100644 --- a/openless-all/app/crates/openless-core/src/selection_service.rs +++ b/openless-all/app/crates/openless-core/src/selection_service.rs @@ -663,6 +663,15 @@ impl SelectionApi for SelectionService { let (mut context, output_mode, uses_llm) = inner .polish_context(&request, inner.source_app(session_id)?) .await?; + // The polish prompt truncates at the XML envelope cap, but DirectReplace + // writes the model output over the entire captured selection. Refuse + // before any provider call so the unseen tail cannot be deleted. + if uses_llm && crate::prompts::exceeds_xml_envelope_cap(&capture.text) { + return Err(BackendError::new( + BackendErrorCode::InvalidArgument, + crate::prompts::truncated_selection_replacement_message(), + )); + } context.polish.selection_input = true; let context = Arc::new(context); inner.set_context(session_id, Arc::clone(&context))?; diff --git a/openless-all/app/crates/openless-core/src/selection_voice_service.rs b/openless-all/app/crates/openless-core/src/selection_voice_service.rs index 6bc5271e0..fe362de39 100644 --- a/openless-all/app/crates/openless-core/src/selection_voice_service.rs +++ b/openless-all/app/crates/openless-core/src/selection_voice_service.rs @@ -501,6 +501,15 @@ impl SelectionVoiceWorkflow { draft: &str, instruction: &str, ) -> Result { + // Edit plans and translation rewrites are applied to the full draft, but + // the prompt envelope only shows the first MAX_XML_ENVELOPE_CHARS. A + // FullRewrite of that prefix would erase the rest in DirectReplace. + if crate::prompts::exceeds_xml_envelope_cap(draft) { + return Err(BackendError::new( + BackendErrorCode::InvalidArgument, + crate::prompts::truncated_selection_replacement_message(), + )); + } let preferences = self.preferences.get(); if selection_voice_instruction_looks_like_translation(instruction) { let target = infer_selection_voice_translation_target(instruction, &preferences); diff --git a/openless-all/app/crates/openless-core/tests/selection_contract.rs b/openless-all/app/crates/openless-core/tests/selection_contract.rs index d511a419b..209e66d9c 100644 --- a/openless-all/app/crates/openless-core/tests/selection_contract.rs +++ b/openless-all/app/crates/openless-core/tests/selection_contract.rs @@ -936,6 +936,64 @@ async fn completed_selection_applies_corrections_before_insert_history_and_activ let _ = std::fs::remove_dir_all(data_dir); } +#[tokio::test] +async fn oversized_selection_polish_refuses_before_replacing_the_document() { + let oversized = "字".repeat(openless_core::prompts::MAX_XML_ENVELOPE_CHARS + 1); + let runtime = RecordingSelectionRuntime::new("unused capture"); + let polisher = CountingTextPolisher::default(); + let (backend, data_dir) = backend_with_selection_parts( + runtime.clone(), + Arc::new(polisher.clone()), + Arc::new(UnsupportedCredentialStore), + ); + backend.start().await.expect("backend should start"); + let mut preferences = backend.get_preferences(); + preferences.selection_polish_output_mode = SelectionPolishOutputMode::DirectReplace; + write_preferences(&backend, preferences); + + let error = backend + .services() + .selection + .begin_polish(SelectionPolishRequest { + selected_text: Some(oversized), + mode: PolishMode::Formal, + instruction: None, + }) + .await + .expect_err("an oversized selection must not be replaced"); + + assert_eq!(error.code, BackendErrorCode::InvalidArgument); + assert!(error + .message + .contains(&openless_core::prompts::MAX_XML_ENVELOPE_CHARS.to_string())); + assert_eq!(polisher.call_count(), 0); + assert!( + runtime.applied().is_empty(), + "the original selection must stay untouched" + ); + assert_eq!( + backend.services().selection.snapshot().await.unwrap().phase, + SelectionPhase::Failed + ); + + let at_cap = "字".repeat(openless_core::prompts::MAX_XML_ENVELOPE_CHARS); + backend + .services() + .selection + .begin_polish(SelectionPolishRequest { + selected_text: Some(at_cap), + mode: PolishMode::Formal, + instruction: None, + }) + .await + .expect("a selection at the envelope cap still polishes"); + assert_eq!(polisher.call_count(), 1); + assert_eq!(runtime.applied().len(), 1); + + backend.shutdown().await.expect("backend should stop"); + let _ = std::fs::remove_dir_all(data_dir); +} + #[tokio::test] async fn default_raw_selection_is_a_true_passthrough_without_an_llm_call() { let runtime = RecordingSelectionRuntime::new("keep this exactly"); diff --git a/openless-all/app/crates/openless-core/tests/selection_voice_contract.rs b/openless-all/app/crates/openless-core/tests/selection_voice_contract.rs index e8b638a05..b9791c27b 100644 --- a/openless-all/app/crates/openless-core/tests/selection_voice_contract.rs +++ b/openless-all/app/crates/openless-core/tests/selection_voice_contract.rs @@ -359,6 +359,54 @@ async fn recording_fault_fails_only_the_current_selection_voice_session_and_rele let _ = std::fs::remove_dir_all(data_dir); } +#[tokio::test] +async fn oversized_voice_edit_does_not_apply_a_plan_from_a_truncated_draft() { + let preferences = UserPreferences { + selection_voice_intent_mode: SelectionVoiceIntentMode::Manual, + selection_voice_manual_intent: SelectionVoiceManualIntent::Edit, + selection_polish_output_mode: SelectionPolishOutputMode::DirectReplace, + ..UserPreferences::default() + }; + let polisher = Arc::new(ScriptedPolisher::successful([ + "short rewrite", + ])); + let (backend, data_dir) = backend_with_model(preferences, Arc::clone(&polisher)); + let voice = &backend.services().selection_voice; + let oversized = "字".repeat(openless_core::prompts::MAX_XML_ENVELOPE_CHARS + 1); + + let error = voice + .edit_preview(SelectionVoiceEditRequest { + owner_session_id: SessionId::new(), + capture: SelectionCapture { + text: oversized.clone(), + source_app: Some("Fixture Editor".to_string()), + }, + instruction: "润色全文".to_string(), + }) + .await + .expect_err("voice edit must not rewrite a draft the model cannot see"); + + assert_eq!(error.code, BackendErrorCode::InvalidArgument); + assert!(polisher.calls().is_empty()); + assert!(voice.preview(None).await.unwrap().is_none()); + + let translated = voice + .edit_preview(SelectionVoiceEditRequest { + owner_session_id: SessionId::new(), + capture: SelectionCapture { + text: oversized, + source_app: None, + }, + instruction: "翻译成英文".to_string(), + }) + .await + .expect_err("translation rewrite must not replace the unseen tail"); + assert_eq!(translated.code, BackendErrorCode::InvalidArgument); + assert!(polisher.calls().is_empty()); + + let _ = std::fs::remove_dir_all(data_dir); +} + #[tokio::test] async fn translation_edit_uses_the_core_translation_path_and_target() { let preferences = UserPreferences {