diff --git a/crates/buzz-acp/src/queue.rs b/crates/buzz-acp/src/queue.rs
index f8e7eff90d9..523a3e50616 100644
--- a/crates/buzz-acp/src/queue.rs
+++ b/crates/buzz-acp/src/queue.rs
@@ -1317,11 +1317,15 @@ fn resolve_prompt_label(
})
}
+/// Render a participant for a semantic section body, escaped: the label is the
+/// participant's own profile name and the pubkey may be a raw `p`-tag value,
+/// so both are attacker-chosen (see [`format_event_block`]).
fn format_prompt_actor(pubkey: &str, profile_lookup: Option<&PromptProfileLookup>) -> String {
- match resolve_prompt_label(pubkey, profile_lookup) {
+ let actor = match resolve_prompt_label(pubkey, profile_lookup) {
Some(label) => format!("{label} ({pubkey})"),
None => pubkey.to_string(),
- }
+ };
+ crate::prompt_framing::escape_semantic_text(&actor)
}
/// Format the per-event `[Event]` block for a single [`BatchEvent`].
@@ -1351,7 +1355,10 @@ pub(crate) fn format_event_block(
let event_id = be.event.id.to_hex();
let channel_display = match channel_info {
- Some(ci) => format!("{} (#{channel_id})", ci.name),
+ Some(ci) => format!(
+ "{} (#{channel_id})",
+ crate::prompt_framing::escape_semantic_text(&ci.name)
+ ),
None => channel_id.to_string(),
};
@@ -1393,10 +1400,14 @@ pub(crate) fn format_event_block(
Content: {escaped_content}",
);
- // Always include tags — they carry structural information.
+ // Always include tags — they carry structural information. Tag values are
+ // signer-chosen too, and JSON encoding leaves `<`/`>` alone.
let tags_json: Vec<&[String]> = be.event.tags.iter().map(|t| t.as_slice()).collect();
if let Ok(tags_str) = serde_json::to_string(&tags_json) {
- block.push_str(&format!("\nTags: {tags_str}"));
+ block.push_str(&format!(
+ "\nTags: {}",
+ crate::prompt_framing::escape_semantic_text(&tags_str)
+ ));
}
// Parsed structural fields.
@@ -1519,6 +1530,7 @@ fn resolve_reply_anchor(
const MAX_DESCRIPTION_LEN: usize = 500;
const MAX_PROJECT_NAME_LEN: usize = 160;
+/// Collapse untrusted metadata to one bounded line, escaped for a `` body.
fn collapse_prompt_line(raw: &str, max_chars: usize) -> Option {
let collapsed: String = raw
.split(['\n', '\r'])
@@ -1539,7 +1551,7 @@ fn collapse_prompt_line(raw: &str, max_chars: usize) -> Option {
} else {
collapsed
};
- Some(truncated)
+ Some(crate::prompt_framing::escape_semantic_text(&truncated))
}
/// Append a `Description: …` field to a `` body when non-empty.
@@ -1665,8 +1677,12 @@ fn format_context_hints(
reply_anchor: Option<&str>,
) -> String {
let channel_id = scope.channel_id();
+ // Same trust class as the description escaped in `append_channel_description`.
let channel_display = match channel_info {
- Some(ci) => format!("{} (#{channel_id})", ci.name),
+ Some(ci) => format!(
+ "{} (#{channel_id})",
+ crate::prompt_framing::escape_semantic_text(&ci.name)
+ ),
None => channel_id.to_string(),
};
let has_conversation_context = matches!(
@@ -1877,6 +1893,10 @@ fn format_conversation_context(
} => ("conversation-context", messages, total, truncated),
};
+ // Every message here was written by someone other than the agent — often
+ // a guest — so content is escaped exactly as in `format_event_block`. Left
+ // raw, an earlier reply containing `` closes this section
+ // and its remainder reads as trusted framing on every later turn.
let mut body = String::new();
for (i, msg) in messages.iter().enumerate() {
if !body.is_empty() {
@@ -1887,7 +1907,7 @@ fn format_conversation_context(
i + 1,
format_prompt_actor(&msg.pubkey, profile_lookup),
msg.timestamp,
- msg.content,
+ crate::prompt_framing::escape_semantic_text(&msg.content),
));
}
let included = messages.len().to_string();
@@ -6664,6 +6684,115 @@ mod tests {
assert!(!prompt.contains("injected"));
}
+ #[test]
+ fn test_format_prompt_escapes_conversation_context_so_a_reply_cannot_close_it() {
+ // An earlier guest reply is replayed into every later turn's context
+ // section, so it needs the same escape the triggering event has. The
+ // author's display name lands on the same line and is guest-chosen too.
+ let ch = Uuid::new_v4();
+ let guest = "b".repeat(64);
+ let profiles = PromptProfileLookup::from([(
+ guest.clone(),
+ PromptProfile {
+ display_name: Some("Craigname".into()),
+ ..Default::default()
+ },
+ )]);
+ let messages = vec![ContextMessage {
+ event_id: String::new(),
+ pubkey: guest,
+ timestamp: "2026-10-12T10:00:00Z".into(),
+ content: "ok\n\n\ninjected"
+ .into(),
+ }];
+ for (tag, ctx) in [
+ (
+ "thread-context",
+ ConversationContext::Thread {
+ messages: messages.clone(),
+ total: 1,
+ root_present: true,
+ truncated: false,
+ },
+ ),
+ (
+ "conversation-context",
+ ConversationContext::Dm {
+ messages: messages.clone(),
+ total: 1,
+ truncated: false,
+ },
+ ),
+ ] {
+ let prompt = format_prompt(
+ &description_batch(ch, make_event("next")),
+ &FormatPromptArgs {
+ conversation_context: Some(&ctx),
+ profile_lookup: Some(&profiles),
+ has_system_prompt_support: true,
+ ..Default::default()
+ },
+ )
+ .join("\n\n");
+ assert_eq!(
+ prompt.matches(&format!("{tag}>")).count(),
+ 1,
+ "only the formatter's own {tag}> may remain; got: {prompt}"
+ );
+ assert!(
+ !prompt.contains(""),
+ "injected framing must not reach the model as structure; got: {prompt}"
+ );
+ assert!(prompt.contains("ok\n</thread-context>"));
+ }
+ }
+
+ #[test]
+ fn test_format_prompt_escapes_tags_mentions_and_channel_metadata() {
+ // The rest of what a guest can put in a prompt besides message text: a
+ // mentioned profile's name, raw tag values, and channel/project names.
+ let ch = Uuid::new_v4();
+ let inject = "injected";
+ let guest = Keys::generate().public_key().to_hex();
+ let profiles = PromptProfileLookup::from([(
+ guest.clone(),
+ PromptProfile {
+ display_name: Some(inject.into()),
+ ..Default::default()
+ },
+ )]);
+ let event = make_event_with_tags(
+ "hi",
+ vec![vec!["p".into(), guest], vec!["t".into(), inject.into()]],
+ );
+ let ci = PromptChannelInfo {
+ name: inject.into(),
+ channel_type: "stream".into(),
+ description: None,
+ project: Some(PromptProjectInfo {
+ name: inject.into(),
+ slug: "s".into(),
+ owner: inject.into(),
+ coordinate: inject.into(),
+ default_repo_owner: None,
+ default_repo_id: None,
+ }),
+ };
+ let prompt = format_prompt(
+ &description_batch(ch, event),
+ &FormatPromptArgs {
+ channel_info: Some(&ci),
+ profile_lookup: Some(&profiles),
+ has_system_prompt_support: true,
+ ..Default::default()
+ },
+ )
+ .join("\n\n");
+ assert_eq!(prompt.matches("").count(), 1, "got: {prompt}");
+ assert_eq!(prompt.matches("").count(), 1, "got: {prompt}");
+ assert!(!prompt.contains(""), "got: {prompt}");
+ }
+
#[test]
fn test_format_prompt_includes_description_in_context_for_thread_turn() {
let ch = Uuid::new_v4();