feat(security): unified OTLP logging schema for cross-tool detection (#9713)

This commit is contained in:
dorien-koelemeijer
2026-06-12 14:32:37 +10:00
committed by GitHub
parent f23347fc30
commit 8b5a7b8f8e
11 changed files with 201 additions and 93 deletions
Generated
+1
View File
@@ -4572,6 +4572,7 @@ dependencies = [
"fs-err 3.3.0",
"fs2",
"futures",
"gethostname",
"goose-acp-macros",
"goose-mcp",
"goose-providers",
+1
View File
@@ -216,6 +216,7 @@ icu_locale = { version = "=2.1.1", default-features = false }
llama-cpp-sys-2 = { workspace = true, optional = true }
image = { version = "0.24.9", default-features = false, features = ["png", "jpeg", "gif", "webp"] }
subtle = { version = "2.5", default-features = false, features = ["std"] }
gethostname = "1.1.0"
[target.'cfg(target_os = "windows")'.dependencies]
winapi = { workspace = true }
+9 -1
View File
@@ -1719,7 +1719,15 @@ impl Agent {
.count();
let working_dir = session.working_dir.clone();
let reply_stream_span = tracing::info_span!(target: "goose::agents::agent", "reply_stream", trace_output = tracing::field::Empty, session.id = %session_config.id);
let reply_stream_span = tracing::info_span!(
target: "goose::agents::agent",
"reply_stream",
trace_output = tracing::field::Empty,
session.id = %session_config.id,
session.user = %crate::session_context::session_user(),
session.host = %crate::session_context::session_host(),
session.agent_type = "goose",
);
let inner = Box::pin(async_stream::try_stream! {
let mut turns_taken = 0u32;
let max_turns = session_config.max_turns.unwrap_or_else(|| {
+10 -4
View File
@@ -112,12 +112,18 @@ impl Agent {
.map_err(|_| anyhow::anyhow!("Confirmation channel closed for request {}", request.id))?;
if let Some(finding_id) = get_security_finding_id_from_results(&request.id, inspection_results) {
let action = match confirmation.permission {
Permission::AllowOnce | Permission::AlwaysAllow => "ALLOW",
_ => "BLOCK",
};
tracing::info!(
monotonic_counter.goose.prompt_injection_user_decisions = 1,
decision = ?confirmation.permission,
finding_id = %finding_id,
tool_request_id = %request.id,
"Prompt injection detection: user decision on command injection finding"
security.event_type = "user_decision",
security.action = action,
security.finding_id = %finding_id,
tool.request_id = %request.id,
user.decision = ?confirmation.permission,
"security finding: user decision"
);
}
+83 -40
View File
@@ -145,11 +145,15 @@ pub fn promote_config_to_env(config: &crate::config::Config) {
}
fn create_resource() -> Resource {
use crate::session_context::{session_host, session_user};
let mut builder = Resource::builder_empty()
.with_attributes([
KeyValue::new("service.name", "goose"),
KeyValue::new("service.version", env!("CARGO_PKG_VERSION")),
KeyValue::new("service.namespace", "goose"),
KeyValue::new("host.name", session_host()),
KeyValue::new("user.name", session_user()),
])
.with_detector(Box::new(EnvResourceDetector::new()))
.with_detector(Box::new(TelemetryResourceDetector));
@@ -304,7 +308,12 @@ fn create_otlp_logs_layer() -> OtlpResult<OtlpLogsLayer> {
};
let bridge = OpenTelemetryTracingBridge::builder(&logger_provider)
.with_tracing_span_attributes(TracingSpanAttributes::allowlist(["session.id"]))
.with_tracing_span_attributes(TracingSpanAttributes::allowlist([
"session.id",
"session.user",
"session.host",
"session.agent_type",
]))
.build();
*LOGGER_PROVIDER.lock().unwrap_or_else(|e| e.into_inner()) = Some(logger_provider);
@@ -598,45 +607,79 @@ mod tests {
shutdown_otlp();
}
#[test_case(
&[],
Resource::builder_empty()
.with_attributes([KeyValue::new("service.name", "goose"), KeyValue::new("service.version", env!("CARGO_PKG_VERSION")), KeyValue::new("service.namespace", "goose")])
.with_detector(Box::new(TelemetryResourceDetector))
.build();
"no env vars uses goose defaults"
)]
#[test_case(
&[("OTEL_SERVICE_NAME", "custom")],
Resource::builder_empty()
.with_attributes([KeyValue::new("service.name", "goose"), KeyValue::new("service.version", env!("CARGO_PKG_VERSION")), KeyValue::new("service.namespace", "goose")])
.with_detector(Box::new(TelemetryResourceDetector))
.with_service_name("custom")
.build();
"OTEL_SERVICE_NAME overrides service.name"
)]
#[test_case(
&[("OTEL_RESOURCE_ATTRIBUTES", "deployment.environment=prod")],
Resource::builder_empty()
.with_attributes([KeyValue::new("service.name", "goose"), KeyValue::new("service.version", env!("CARGO_PKG_VERSION")), KeyValue::new("service.namespace", "goose")])
.with_detector(Box::new(TelemetryResourceDetector))
.with_attribute(KeyValue::new("deployment.environment", "prod"))
.build();
"OTEL_RESOURCE_ATTRIBUTES adds custom attributes"
)]
#[test_case(
&[("OTEL_SERVICE_NAME", "custom"), ("OTEL_RESOURCE_ATTRIBUTES", "deployment.environment=prod")],
Resource::builder_empty()
.with_attributes([KeyValue::new("service.name", "goose"), KeyValue::new("service.version", env!("CARGO_PKG_VERSION")), KeyValue::new("service.namespace", "goose")])
.with_detector(Box::new(TelemetryResourceDetector))
.with_service_name("custom")
.with_attribute(KeyValue::new("deployment.environment", "prod"))
.build();
"OTEL_SERVICE_NAME and OTEL_RESOURCE_ATTRIBUTES combine"
)]
fn test_create_resource(env: &[(&'static str, &'static str)], expected: Resource) {
let _guard = clear_otel_env(env);
assert_eq!(create_resource(), expected);
#[test]
fn test_create_resource_defaults() {
let _guard = clear_otel_env(&[]);
let resource = create_resource();
let attrs: Vec<_> = resource.iter().collect();
let get = |key: &str| {
attrs
.iter()
.find(|(k, _)| k.as_str() == key)
.map(|(_, v)| v.to_string())
};
assert_eq!(get("service.name").as_deref(), Some("goose"));
assert_eq!(
get("service.version").as_deref(),
Some(env!("CARGO_PKG_VERSION"))
);
assert_eq!(get("service.namespace").as_deref(), Some("goose"));
assert!(get("host.name").is_some(), "host.name should be set");
assert!(get("user.name").is_some(), "user.name should be set");
}
#[test]
fn test_create_resource_otel_service_name_overrides() {
let _guard = clear_otel_env(&[("OTEL_SERVICE_NAME", "custom")]);
let resource = create_resource();
let attrs: Vec<_> = resource.iter().collect();
let get = |key: &str| {
attrs
.iter()
.find(|(k, _)| k.as_str() == key)
.map(|(_, v)| v.to_string())
};
assert_eq!(get("service.name").as_deref(), Some("custom"));
assert_eq!(get("service.namespace").as_deref(), Some("goose"));
}
#[test]
fn test_create_resource_otel_resource_attributes() {
let _guard = clear_otel_env(&[("OTEL_RESOURCE_ATTRIBUTES", "deployment.environment=prod")]);
let resource = create_resource();
let attrs: Vec<_> = resource.iter().collect();
let get = |key: &str| {
attrs
.iter()
.find(|(k, _)| k.as_str() == key)
.map(|(_, v)| v.to_string())
};
assert_eq!(get("service.name").as_deref(), Some("goose"));
assert_eq!(get("deployment.environment").as_deref(), Some("prod"));
}
#[test]
fn test_create_resource_combined() {
let _guard = clear_otel_env(&[
("OTEL_SERVICE_NAME", "custom"),
("OTEL_RESOURCE_ATTRIBUTES", "deployment.environment=prod"),
]);
let resource = create_resource();
let attrs: Vec<_> = resource.iter().collect();
let get = |key: &str| {
attrs
.iter()
.find(|(k, _)| k.as_str() == key)
.map(|(_, v)| v.to_string())
};
assert_eq!(get("service.name").as_deref(), Some("custom"));
assert_eq!(get("deployment.environment").as_deref(), Some("prod"));
assert!(get("host.name").is_some());
assert!(get("user.name").is_some());
}
#[test_case(&[("RUST_LOG", "")], Level::INFO; "default is info")]
@@ -380,6 +380,10 @@ impl ToolInspector for AdversaryInspector {
}
let tool_description = Self::format_tool_call(request);
let tool_call_name = match &request.tool_call {
Ok(tc) => tc.name.to_string(),
Err(_) => "unknown".to_string(),
};
tracing::debug!(
tool_request_id = %request.id,
@@ -397,9 +401,13 @@ impl ToolInspector for AdversaryInspector {
{
Ok((true, reason)) => {
tracing::debug!(
tool_request_id = %request.id,
reason = %reason,
"Adversary: ALLOW"
security.event_type = "adversary_detection",
security.action = "ALLOW",
security.confidence = 1.0_f32,
security.explanation = %reason,
tool.name = %tool_call_name,
tool.request_id = %request.id,
"adversary review: ALLOW"
);
results.push(InspectionResult {
tool_request_id: request.id.clone(),
@@ -412,9 +420,13 @@ impl ToolInspector for AdversaryInspector {
}
Ok((false, reason)) => {
tracing::warn!(
tool_request_id = %request.id,
reason = %reason,
"Adversary: BLOCK"
security.event_type = "adversary_detection",
security.action = "BLOCK",
security.confidence = 1.0_f32,
security.explanation = %reason,
tool.name = %tool_call_name,
tool.request_id = %request.id,
"adversary review: BLOCK"
);
results.push(InspectionResult {
tool_request_id: request.id.clone(),
@@ -427,9 +439,13 @@ impl ToolInspector for AdversaryInspector {
}
Err(e) => {
tracing::warn!(
tool_request_id = %request.id,
error = %e,
"Adversary inspector failed, allowing tool call (fail-open)"
security.event_type = "adversary_detection",
security.action = "ALLOW",
security.confidence = 0.0_f32,
security.explanation = %format!("error (fail-open): {}", e),
tool.name = %tool_call_name,
tool.request_id = %request.id,
"adversary review: error (fail-open)"
);
results.push(InspectionResult {
tool_request_id: request.id.clone(),
@@ -354,12 +354,15 @@ impl ToolInspector for EgressInspector {
for dest in &destinations {
tracing::info!(
egress_kind = dest.kind.as_str(),
domain = dest.domain.as_str(),
destination = dest.destination.as_str(),
direction = direction.as_str(),
tool_name = name,
"egress destination detected"
security.event_type = "egress",
security.action = "LOG",
security.threat_type = "data_exfiltration",
network.destination = dest.destination.as_str(),
network.domain = dest.domain.as_str(),
network.egress_kind = dest.kind.as_str(),
network.direction = direction.as_str(),
tool.name = name,
"network egress detected"
);
}
+25 -17
View File
@@ -162,22 +162,26 @@ impl SecurityManager {
let tool_call_json =
serde_json::to_string(&tool_call).unwrap_or_else(|_| "{}".to_string());
let action = if above_threshold { "BLOCK" } else { "LOG" };
tracing::warn!(
monotonic_counter.goose.prompt_injection_finding = 1,
threat_type = "command_injection",
above_threshold = above_threshold,
tool_name = %tool_call.name,
tool_request_id = %tool_request.id,
tool_call_json = %tool_call_json,
confidence = analysis_result.confidence,
explanation = %sanitized_explanation,
finding_id = %finding_id,
threshold = config_threshold,
security.event_type = "prompt_injection_scan",
security.action = action,
security.confidence = analysis_result.confidence,
security.threshold = config_threshold,
security.above_threshold = above_threshold,
security.threat_type = "command_injection",
security.finding_id = %finding_id,
security.explanation = %sanitized_explanation,
tool.name = %tool_call.name,
tool.request_id = %tool_request.id,
tool.call_json = %tool_call_json,
"{}",
if above_threshold {
"Prompt injection detection: Current tool call flagged as malicious after security analysis (above threshold)"
"prompt injection scan: finding above threshold"
} else {
"Prompt injection detection: Security finding below threshold (logged but not blocking execution)"
"prompt injection scan: finding below threshold"
}
);
if above_threshold {
@@ -196,12 +200,16 @@ impl SecurityManager {
tracing::info!(
monotonic_counter.goose.prompt_injection_tool_call_passed = 1,
tool_name = %tool_call.name,
tool_request_id = %tool_request.id,
tool_call_json = %tool_call_json,
confidence = analysis_result.confidence,
explanation = %sanitized_explanation,
"Current tool call passed security analysis"
security.event_type = "prompt_injection_scan",
security.action = "ALLOW",
security.confidence = analysis_result.confidence,
security.threshold = config_threshold,
security.above_threshold = false,
security.threat_type = "command_injection",
tool.name = %tool_call.name,
tool.request_id = %tool_request.id,
tool.call_json = %tool_call_json,
"prompt injection scan: tool call passed"
);
}
}
+10 -9
View File
@@ -170,15 +170,16 @@ impl PromptInjectionScanner {
self.combine_confidences(tool_result.confidence, context_result.ml_confidence);
tracing::info!(
tool_confidence = %tool_result.confidence,
context_confidence = ?context_result.ml_confidence,
final_confidence = %final_confidence,
used_command_ml = tool_result.ml_confidence.is_some(),
used_prompt_ml = context_result.ml_confidence.is_some(),
used_pattern_detection = tool_result.used_pattern_detection,
threshold = %threshold,
malicious = final_confidence >= threshold,
"Security analysis complete"
security.event_type = "prompt_injection_scan",
security.confidence = final_confidence,
security.threshold = threshold,
security.above_threshold = final_confidence >= threshold,
scanner.tool_confidence = tool_result.confidence,
scanner.context_confidence = ?context_result.ml_confidence,
scanner.used_command_ml = tool_result.ml_confidence.is_some(),
scanner.used_prompt_ml = context_result.ml_confidence.is_some(),
scanner.used_pattern_detection = tool_result.used_pattern_detection,
"prompt injection scan: analysis complete"
);
let final_result = DetailedScanResult {
+14
View File
@@ -22,6 +22,20 @@ pub fn current_session_id() -> Option<String> {
SESSION_ID.try_with(|id| id.clone()).ok().flatten()
}
/// Local OS user running goose, shared by the OTLP `user.name` resource
/// attribute and the `session.user` span attribute so the two never drift.
pub fn session_user() -> String {
std::env::var("USER")
.or_else(|_| std::env::var("LOGNAME"))
.unwrap_or_else(|_| "unknown".to_string())
}
/// Hostname of the machine running goose, shared by the OTLP `host.name`
/// resource attribute and the `session.host` span attribute.
pub fn session_host() -> String {
gethostname::gethostname().to_string_lossy().to_string()
}
#[cfg(test)]
mod tests {
use super::*;
+14 -7
View File
@@ -193,14 +193,21 @@ pub fn apply_inspection_results_to_permissions(
for result in inspection_results {
let request_id = &result.tool_request_id;
let action_str = match &result.action {
InspectionAction::Deny => "BLOCK",
InspectionAction::RequireApproval(_) => "ALERT",
InspectionAction::Allow => "ALLOW",
};
tracing::info!(
inspector_name = result.inspector_name,
tool_request_id = %request_id,
action = ?result.action,
confidence = result.confidence,
reason = %result.reason,
finding_id = ?result.finding_id,
"Applying inspection result"
security.event_type = "inspection_result",
security.action = action_str,
security.confidence = result.confidence,
security.finding_id = ?result.finding_id,
tool.request_id = %request_id,
inspector.name = result.inspector_name,
inspector.reason = %result.reason,
"inspection result applied"
);
match result.action {