Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 22 additions & 7 deletions openless-all/app/crates/openless-core/src/prompts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,27 +10,42 @@ 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 `</tag>`): an attacker can
/// forge the envelope boundary with `<tag>` too, letting later text "escape" outside
/// 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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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))?;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -501,6 +501,15 @@ impl SelectionVoiceWorkflow {
draft: &str,
instruction: &str,
) -> Result<EditPlan, BackendError> {
// 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);
Expand Down
58 changes: 58 additions & 0 deletions openless-all/app/crates/openless-core/tests/selection_contract.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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([
"<edit_plan><full_rewrite><text>short rewrite</text></full_rewrite></edit_plan>",
]));
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 {
Expand Down
Loading