mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-09-01 09:09:19 +00:00
fix(security): prevent metadata spoofing of internal job monitor flag (#1195)
The `__internal_job_monitor` metadata key that bypassed the entire agent pipeline (hooks, safety checks, LLM processing) was spoofable by external channels — WASM channel plugins could inject arbitrary metadata including this key, causing attacker-controlled content to be forwarded directly as assistant responses. Replace the metadata-based check with a dedicated `is_internal` field on `IncomingMessage` that can only be set via `into_internal()` by trusted in-process code. Both the field and setter are `pub(crate)` to prevent external crates from spoofing the flag. Also remove `notify_metadata` forwarding (the monitor only needs channel/user/thread routing) and the unused `__job_monitor_job_id` metadata key. Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
3f874e73af
commit
bde0b77a86
@@ -750,6 +750,20 @@ impl Agent {
|
||||
"Message details"
|
||||
);
|
||||
|
||||
// Internal messages (e.g. job-monitor notifications) are already
|
||||
// rendered text and should be forwarded directly to the user without
|
||||
// entering the normal user-input pipeline (LLM/tool loop).
|
||||
// The `is_internal` field and `into_internal()` setter are pub(crate),
|
||||
// so external channels cannot spoof this flag.
|
||||
if message.is_internal {
|
||||
tracing::debug!(
|
||||
message_id = %message.id,
|
||||
channel = %message.channel,
|
||||
"Forwarding internal message"
|
||||
);
|
||||
return Ok(Some(message.content.clone()));
|
||||
}
|
||||
|
||||
// Set message tool context for this turn (current channel and target)
|
||||
// For Signal, use signal_target from metadata (group:ID or phone number),
|
||||
// otherwise fall back to user_id
|
||||
|
||||
@@ -143,6 +143,11 @@ impl Agent {
|
||||
JobContext::with_user(&message.user_id, "chat", "Interactive chat session");
|
||||
job_ctx.http_interceptor = self.deps.http_interceptor.clone();
|
||||
job_ctx.user_timezone = user_tz.name().to_string();
|
||||
job_ctx.metadata = serde_json::json!({
|
||||
"notify_channel": message.channel,
|
||||
"notify_user": message.user_id,
|
||||
"notify_thread_id": message.thread_id,
|
||||
});
|
||||
|
||||
// Build system prompts once for this turn. Two variants: with tools
|
||||
// (normal iterations) and without (force_text final iteration).
|
||||
|
||||
+65
-14
@@ -21,6 +21,14 @@ use uuid::Uuid;
|
||||
use crate::channels::IncomingMessage;
|
||||
use crate::channels::web::types::SseEvent;
|
||||
|
||||
/// Route context for forwarding job monitor events back to the user's channel.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct JobMonitorRoute {
|
||||
pub channel: String,
|
||||
pub user_id: String,
|
||||
pub thread_id: Option<String>,
|
||||
}
|
||||
|
||||
/// Spawn a background task that watches for events from a specific job and
|
||||
/// injects assistant messages into the agent loop.
|
||||
///
|
||||
@@ -35,6 +43,7 @@ pub fn spawn_job_monitor(
|
||||
job_id: Uuid,
|
||||
mut event_rx: broadcast::Receiver<(Uuid, SseEvent)>,
|
||||
inject_tx: mpsc::Sender<IncomingMessage>,
|
||||
route: JobMonitorRoute,
|
||||
) -> JoinHandle<()> {
|
||||
let short_id = job_id.to_string()[..8].to_string();
|
||||
|
||||
@@ -50,11 +59,15 @@ pub fn spawn_job_monitor(
|
||||
|
||||
match event {
|
||||
SseEvent::JobMessage { role, content, .. } if role == "assistant" => {
|
||||
let msg = IncomingMessage::new(
|
||||
"job_monitor",
|
||||
"system",
|
||||
let mut msg = IncomingMessage::new(
|
||||
route.channel.clone(),
|
||||
route.user_id.clone(),
|
||||
format!("[Job {}] Claude Code: {}", short_id, content),
|
||||
);
|
||||
)
|
||||
.into_internal();
|
||||
if let Some(ref thread_id) = route.thread_id {
|
||||
msg = msg.with_thread(thread_id.clone());
|
||||
}
|
||||
if inject_tx.send(msg).await.is_err() {
|
||||
tracing::debug!(
|
||||
job_id = %short_id,
|
||||
@@ -64,14 +77,18 @@ pub fn spawn_job_monitor(
|
||||
}
|
||||
}
|
||||
SseEvent::JobResult { status, .. } => {
|
||||
let msg = IncomingMessage::new(
|
||||
"job_monitor",
|
||||
"system",
|
||||
let mut msg = IncomingMessage::new(
|
||||
route.channel.clone(),
|
||||
route.user_id.clone(),
|
||||
format!(
|
||||
"[Job {}] Container finished (status: {})",
|
||||
short_id, status
|
||||
),
|
||||
);
|
||||
)
|
||||
.into_internal();
|
||||
if let Some(ref thread_id) = route.thread_id {
|
||||
msg = msg.with_thread(thread_id.clone());
|
||||
}
|
||||
let _ = inject_tx.send(msg).await;
|
||||
tracing::debug!(
|
||||
job_id = %short_id,
|
||||
@@ -108,13 +125,21 @@ pub fn spawn_job_monitor(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn test_route() -> JobMonitorRoute {
|
||||
JobMonitorRoute {
|
||||
channel: "cli".to_string(),
|
||||
user_id: "user-1".to_string(),
|
||||
thread_id: Some("thread-1".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_monitor_forwards_assistant_messages() {
|
||||
let (event_tx, _) = broadcast::channel::<(Uuid, SseEvent)>(16);
|
||||
let (inject_tx, mut inject_rx) = mpsc::channel::<IncomingMessage>(16);
|
||||
|
||||
let job_id = Uuid::new_v4();
|
||||
let _handle = spawn_job_monitor(job_id, event_tx.subscribe(), inject_tx);
|
||||
let _handle = spawn_job_monitor(job_id, event_tx.subscribe(), inject_tx, test_route());
|
||||
|
||||
// Send an assistant message
|
||||
event_tx
|
||||
@@ -133,9 +158,11 @@ mod tests {
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(msg.channel, "job_monitor");
|
||||
assert_eq!(msg.user_id, "system");
|
||||
assert_eq!(msg.channel, "cli");
|
||||
assert_eq!(msg.user_id, "user-1");
|
||||
assert_eq!(msg.thread_id, Some("thread-1".to_string()));
|
||||
assert!(msg.content.contains("I found a bug"));
|
||||
assert!(msg.is_internal, "monitor messages must be marked internal");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -145,7 +172,7 @@ mod tests {
|
||||
|
||||
let job_id = Uuid::new_v4();
|
||||
let other_job_id = Uuid::new_v4();
|
||||
let _handle = spawn_job_monitor(job_id, event_tx.subscribe(), inject_tx);
|
||||
let _handle = spawn_job_monitor(job_id, event_tx.subscribe(), inject_tx, test_route());
|
||||
|
||||
// Send a message for a different job
|
||||
event_tx
|
||||
@@ -174,7 +201,7 @@ mod tests {
|
||||
let (inject_tx, mut inject_rx) = mpsc::channel::<IncomingMessage>(16);
|
||||
|
||||
let job_id = Uuid::new_v4();
|
||||
let handle = spawn_job_monitor(job_id, event_tx.subscribe(), inject_tx);
|
||||
let handle = spawn_job_monitor(job_id, event_tx.subscribe(), inject_tx, test_route());
|
||||
|
||||
// Send a completion event
|
||||
event_tx
|
||||
@@ -208,7 +235,7 @@ mod tests {
|
||||
let (inject_tx, mut inject_rx) = mpsc::channel::<IncomingMessage>(16);
|
||||
|
||||
let job_id = Uuid::new_v4();
|
||||
let _handle = spawn_job_monitor(job_id, event_tx.subscribe(), inject_tx);
|
||||
let _handle = spawn_job_monitor(job_id, event_tx.subscribe(), inject_tx, test_route());
|
||||
|
||||
// Send tool use event (should be skipped)
|
||||
event_tx
|
||||
@@ -242,4 +269,28 @@ mod tests {
|
||||
"should have timed out, no message expected"
|
||||
);
|
||||
}
|
||||
|
||||
/// Regression test: external channels must not be able to spoof the
|
||||
/// `is_internal` flag via metadata keys. A message created through
|
||||
/// the normal `IncomingMessage::new` + `with_metadata` path must
|
||||
/// always have `is_internal == false`, regardless of metadata content.
|
||||
#[test]
|
||||
fn test_external_metadata_cannot_spoof_internal_flag() {
|
||||
let msg = IncomingMessage::new("wasm_channel", "attacker", "pwned").with_metadata(
|
||||
serde_json::json!({
|
||||
"__internal_job_monitor": true,
|
||||
"is_internal": true,
|
||||
}),
|
||||
);
|
||||
assert!(
|
||||
!msg.is_internal,
|
||||
"with_metadata must not set is_internal — only into_internal() can"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_into_internal_sets_flag() {
|
||||
let msg = IncomingMessage::new("monitor", "system", "test").into_internal();
|
||||
assert!(msg.is_internal);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user