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:
Illia Polosukhin
2026-03-15 21:33:04 +00:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 3f874e73af
commit bde0b77a86
6 changed files with 143 additions and 51 deletions
+43 -1
View File
@@ -415,7 +415,19 @@ impl CreateJobTool {
// loop stops consuming from inject_tx the send will fail and the
// monitor terminates. No JoinHandle is retained.
if let (Some(etx), Some(itx)) = (&self.event_tx, &self.inject_tx) {
crate::agent::job_monitor::spawn_job_monitor(job_id, etx.subscribe(), itx.clone());
if let Some(route) = monitor_route_from_ctx(ctx) {
crate::agent::job_monitor::spawn_job_monitor(
job_id,
etx.subscribe(),
itx.clone(),
route,
);
} else {
tracing::debug!(
job_id = %job_id,
"Skipping job monitor injection due to missing route metadata"
);
}
}
let result = serde_json::json!({
@@ -680,6 +692,36 @@ fn resolve_project_dir(
Ok((canonical_dir, browse_id))
}
fn monitor_route_from_ctx(ctx: &JobContext) -> Option<crate::agent::job_monitor::JobMonitorRoute> {
// notify_channel is required — without it we don't know which channel to
// route the monitor output to, so return None to skip monitoring entirely.
let channel = ctx
.metadata
.get("notify_channel")
.and_then(|v| v.as_str())?
.to_string();
// notify_user is optional — fall back to the job's own user_id, which is
// always present. The channel is the routing decision; the user is just
// for attribution and can default safely.
let user_id = ctx
.metadata
.get("notify_user")
.and_then(|v| v.as_str())
.unwrap_or(&ctx.user_id)
.to_string();
let thread_id = ctx
.metadata
.get("notify_thread_id")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
Some(crate::agent::job_monitor::JobMonitorRoute {
channel,
user_id,
thread_id,
})
}
#[async_trait]
impl Tool for CreateJobTool {
fn name(&self) -> &str {