diff --git a/Cargo.lock b/Cargo.lock index e6b6caf14421..f6fa1507540c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4572,6 +4572,7 @@ dependencies = [ "fs-err 3.3.0", "fs2", "futures", + "gethostname", "goose-acp-macros", "goose-mcp", "goose-providers", diff --git a/crates/goose/Cargo.toml b/crates/goose/Cargo.toml index b7846e7898f5..47f172bda99b 100644 --- a/crates/goose/Cargo.toml +++ b/crates/goose/Cargo.toml @@ -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 } diff --git a/crates/goose/src/agents/agent.rs b/crates/goose/src/agents/agent.rs index 540fe9edb003..441cd5c63f8b 100644 --- a/crates/goose/src/agents/agent.rs +++ b/crates/goose/src/agents/agent.rs @@ -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(|| { diff --git a/crates/goose/src/agents/tool_execution.rs b/crates/goose/src/agents/tool_execution.rs index 47c3d35bec86..603f5d57226c 100644 --- a/crates/goose/src/agents/tool_execution.rs +++ b/crates/goose/src/agents/tool_execution.rs @@ -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" ); } diff --git a/crates/goose/src/otel/otlp.rs b/crates/goose/src/otel/otlp.rs index e86479d06a5c..ae1c59390ee5 100644 --- a/crates/goose/src/otel/otlp.rs +++ b/crates/goose/src/otel/otlp.rs @@ -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 { }; 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")] diff --git a/crates/goose/src/security/adversary_inspector.rs b/crates/goose/src/security/adversary_inspector.rs index 9f59085f82fd..e7c6d320d3fc 100644 --- a/crates/goose/src/security/adversary_inspector.rs +++ b/crates/goose/src/security/adversary_inspector.rs @@ -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(), diff --git a/crates/goose/src/security/egress_inspector.rs b/crates/goose/src/security/egress_inspector.rs index bc5bbad51d42..e32d8198f4dd 100644 --- a/crates/goose/src/security/egress_inspector.rs +++ b/crates/goose/src/security/egress_inspector.rs @@ -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" ); } diff --git a/crates/goose/src/security/mod.rs b/crates/goose/src/security/mod.rs index 845af16c698d..773233dd0941 100644 --- a/crates/goose/src/security/mod.rs +++ b/crates/goose/src/security/mod.rs @@ -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" ); } } diff --git a/crates/goose/src/security/scanner.rs b/crates/goose/src/security/scanner.rs index d4d15da52201..bf02c88f210d 100644 --- a/crates/goose/src/security/scanner.rs +++ b/crates/goose/src/security/scanner.rs @@ -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 { diff --git a/crates/goose/src/session_context.rs b/crates/goose/src/session_context.rs index a7126ede0988..7bb7cd117a84 100644 --- a/crates/goose/src/session_context.rs +++ b/crates/goose/src/session_context.rs @@ -22,6 +22,20 @@ pub fn current_session_id() -> Option { 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::*; diff --git a/crates/goose/src/tool_inspection.rs b/crates/goose/src/tool_inspection.rs index 193773b500ea..1a7ef80125ad 100644 --- a/crates/goose/src/tool_inspection.rs +++ b/crates/goose/src/tool_inspection.rs @@ -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 {