Files
ironclaw/tests/e2e_attachments.rs
firat.sertgoz 22cd378461 fix(safety): add inbound secret scanning to engine v2 path (#2494)
* fix(safety): add inbound secret scanning to engine v2 path (#2491)

The v2 engine path (`handle_with_engine_inner` in `bridge/router.rs`)
forwarded user messages directly to the conversation manager without
any safety checks. This allowed secrets (API keys, Slack tokens, AWS
credentials, etc.) pasted in chat to reach the LLM and be permanently
stored in conversation history.

Add the same three safety checks that the v1 path (`thread_ops.rs`)
already enforces: `validate_input`, `check_policy`, and
`scan_inbound_for_secrets`. Messages containing detected secrets are
now rejected with a user-facing warning before reaching the engine.

Includes a regression test exercising Slack bot tokens and OpenAI keys
through the v2 code path.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* style(safety): fix rustfmt formatting in secret scan test

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(safety): fix OpenAI key test — payload too short for regex (#2494)

The mock OpenAI key `sk-abc123def456ghi789` had only 19 chars after
the prefix, but the leak detector regex requires 20+. Extended the
key and added a specific assertion matching the Slack token check.

Addresses gemini-code-assist review feedback.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore(deps): ignore RUSTSEC-2026-0099 webpki advisory

Wildcard name constraint bypass in rustls-webpki 0.102.8, pinned by
the libsql transitive dependency chain. Same root cause as the
already-ignored RUSTSEC-2026-0049.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: minor comment tweak to retrigger CI

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(ci): resolve clippy and fmt errors

Remove useless .into_iter() in catalog.rs and fix rustfmt style in e2e_attachments.rs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(bridge): use BridgeOutcome instead of Option<String> in safety checks

The inbound safety scanning code was written against the old
Option<String> return type, but handle_with_engine_inner now returns
BridgeOutcome. Replace Ok(Some(...)) with Ok(BridgeOutcome::Respond(...))
and update tests to match on the enum variants.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Illia Polosukhin <ilblackdragon@gmail.com>
2026-04-18 01:25:26 +09:00

211 lines
7.5 KiB
Rust

//! E2E tests for attachment processing in the LLM pipeline.
//!
//! Verifies that attachments on incoming messages are augmented into the user
//! text and (for images) passed as multimodal content parts to the LLM.
#[cfg(feature = "libsql")]
mod support;
#[cfg(feature = "libsql")]
mod attachment_tests {
use std::time::Duration;
use crate::support::test_rig::TestRigBuilder;
use crate::support::trace_llm::LlmTrace;
use ironclaw::channels::{AttachmentKind, IncomingAttachment, IncomingMessage};
use ironclaw::llm::ContentPart;
const FIXTURES: &str = concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/fixtures/llm_traces/spot"
);
const TIMEOUT: Duration = Duration::from_secs(15);
fn make_attachment(kind: AttachmentKind) -> IncomingAttachment {
IncomingAttachment {
id: "att-1".to_string(),
kind,
mime_type: "application/octet-stream".to_string(),
filename: None,
size_bytes: None,
source_url: None,
storage_key: None,
extracted_text: None,
data: vec![],
duration_secs: None,
}
}
/// Audio attachment with transcript reaches the LLM as augmented text.
#[tokio::test]
async fn attachment_audio_transcript_reaches_llm() {
let trace =
LlmTrace::from_file(format!("{FIXTURES}/attachment_audio_transcript.json")).unwrap();
let rig = TestRigBuilder::new()
.with_trace(trace.clone())
.build()
.await;
// Build a message with an audio attachment containing a transcript
let mut att = make_attachment(AttachmentKind::Audio);
att.filename = Some("voice.ogg".to_string());
att.mime_type = "audio/ogg".to_string();
att.extracted_text = Some("Hello, can you help me with my project?".to_string());
att.duration_secs = Some(5);
let mut msg = IncomingMessage::new("test", "test-user", "Check this voice note");
msg.attachments.push(att);
rig.send_incoming(msg).await;
let responses = rig.wait_for_responses(1, TIMEOUT).await;
// Verify the response was received
assert!(
!responses.is_empty(),
"should receive at least one response"
);
// Verify the augmented content reached the LLM
let requests = rig.captured_llm_requests();
assert!(!requests.is_empty(), "LLM should have been called");
let last_request = &requests[requests.len() - 1];
let last_user_msg = last_request
.iter()
.rev()
.find(|m| matches!(m.role, ironclaw::llm::Role::User))
.expect("should have a user message");
// The augmented text should contain the attachment tags and transcript
assert!(
last_user_msg.content.contains("<attachments>"),
"user message should contain <attachments> block, got: {}",
last_user_msg.content.chars().take(200).collect::<String>()
);
assert!(
last_user_msg
.content
.contains("Hello, can you help me with my project?"),
"user message should contain the transcript"
);
assert!(
last_user_msg.content.contains("duration=\"5s\""),
"user message should contain duration"
);
// Audio attachments should NOT produce image content parts
assert!(
last_user_msg.content_parts.is_empty(),
"audio attachments should not produce image content parts"
);
rig.verify_trace_expects(&trace, &responses);
rig.shutdown();
}
/// Image attachment with data reaches the LLM with multimodal content parts.
#[tokio::test]
async fn attachment_image_produces_content_parts() {
let trace = LlmTrace::from_file(format!("{FIXTURES}/attachment_image.json")).unwrap();
let rig = TestRigBuilder::new()
.with_trace(trace.clone())
.build()
.await;
// Build a message with an image attachment that has raw data
let mut att = make_attachment(AttachmentKind::Image);
att.filename = Some("screenshot.png".to_string());
att.mime_type = "image/png".to_string();
att.size_bytes = Some(1024);
att.data = vec![0x89, 0x50, 0x4E, 0x47]; // PNG magic bytes (fake)
let mut msg =
IncomingMessage::new("test", "test-user", "What do you see in this screenshot?");
msg.attachments.push(att);
rig.send_incoming(msg).await;
let responses = rig.wait_for_responses(1, TIMEOUT).await;
assert!(
!responses.is_empty(),
"should receive at least one response"
);
// Verify multimodal content parts reached the LLM
let requests = rig.captured_llm_requests();
assert!(!requests.is_empty(), "LLM should have been called");
let last_request = &requests[requests.len() - 1];
let last_user_msg = last_request
.iter()
.rev()
.find(|m| matches!(m.role, ironclaw::llm::Role::User))
.expect("should have a user message");
// Should have image content parts
assert_eq!(
last_user_msg.content_parts.len(),
1,
"should have exactly one image content part"
);
// Verify the content part is an ImageUrl with a data: URI
match &last_user_msg.content_parts[0] {
ContentPart::ImageUrl { image_url } => {
assert!(
image_url.url.starts_with("data:image/png;base64,"),
"image URL should be a base64 data URI, got: {}",
&image_url.url[..image_url.url.len().min(40)]
);
}
other => panic!("expected ImageUrl content part, got: {:?}", other),
}
// The text should note the image is sent as visual content
assert!(
last_user_msg.content.contains(
"[Image attached — you can already see this image directly in the conversation."
),
"augmented text should note image sent as visual content"
);
rig.verify_trace_expects(&trace, &responses);
rig.shutdown();
}
/// Message without attachments should have no content_parts and no augmentation.
#[tokio::test]
async fn no_attachments_no_augmentation() {
let trace = LlmTrace::from_file(format!("{FIXTURES}/smoke_greeting.json")).unwrap();
let rig = TestRigBuilder::new()
.with_trace(trace.clone())
.build()
.await;
rig.send_message("Hello! Introduce yourself briefly.").await;
let responses = rig.wait_for_responses(1, TIMEOUT).await;
let requests = rig.captured_llm_requests();
let last_request = &requests[requests.len() - 1];
let last_user_msg = last_request
.iter()
.rev()
.find(|m| matches!(m.role, ironclaw::llm::Role::User))
.expect("should have a user message");
// No attachments → no augmentation tags, no content parts
assert!(
!last_user_msg.content.contains("<attachments>"),
"plain message should NOT contain <attachments>"
);
assert!(
last_user_msg.content_parts.is_empty(),
"plain message should have no content parts"
);
rig.verify_trace_expects(&trace, &responses);
rig.shutdown();
}
}