diff --git a/crates/openshell-supervisor-network/src/proxy.rs b/crates/openshell-supervisor-network/src/proxy.rs index ab2313b89..f6372099d 100644 --- a/crates/openshell-supervisor-network/src/proxy.rs +++ b/crates/openshell-supervisor-network/src/proxy.rs @@ -825,11 +825,12 @@ async fn handle_tcp_connection( emit_activity(&activity_tx, true, "connect_policy"); respond( &mut client, - &build_json_error_response( + &build_json_error_response_with_reason( 403, "Forbidden", "policy_denied", &format!("CONNECT {host_lc}:{port} not permitted by policy"), + &deny_reason, ), ) .await?; @@ -4878,6 +4879,34 @@ fn build_json_error_response(status: u16, status_text: &str, error: &str, detail .into_bytes() } +fn build_json_error_response_with_reason( + status: u16, + status_text: &str, + error: &str, + detail: &str, + reason: &str, +) -> Vec { + let mut body = serde_json::json!({ + "error": error, + "detail": detail, + }); + if !reason.is_empty() { + body["reason"] = serde_json::json!(reason); + } + let body_str = body.to_string(); + format!( + "HTTP/1.1 {status} {status_text}\r\n\ + Content-Type: application/json\r\n\ + Content-Length: {}\r\n\ + Connection: close\r\n\ + \r\n\ + {}", + body_str.len(), + body_str, + ) + .into_bytes() +} + fn build_middleware_deny_response( policy_name: &str, denial: &openshell_supervisor_middleware::MiddlewareDenial, @@ -5127,6 +5156,48 @@ network_policies: {} assert!(body.get("agent_guidance").is_none()); } + #[test] + fn policy_deny_response_includes_reason() { + let response = build_json_error_response_with_reason( + 403, + "Forbidden", + "policy_denied", + "CONNECT api.example.com:443 not permitted by policy", + "binary '/usr/bin/node' not allowed in policy 'allow_api' (ancestors: [/usr/local/bin/claude])", + ); + let response = String::from_utf8(response).expect("UTF-8 error response"); + assert!(response.starts_with("HTTP/1.1 403 Forbidden")); + let (_, body) = response.split_once("\r\n\r\n").expect("HTTP response"); + let body: serde_json::Value = serde_json::from_str(body).expect("JSON response"); + + assert_eq!(body["error"], "policy_denied"); + assert_eq!( + body["detail"], + "CONNECT api.example.com:443 not permitted by policy" + ); + assert_eq!( + body["reason"], + "binary '/usr/bin/node' not allowed in policy 'allow_api' (ancestors: [/usr/local/bin/claude])" + ); + } + + #[test] + fn policy_deny_response_omits_empty_reason() { + let response = build_json_error_response_with_reason( + 403, + "Forbidden", + "policy_denied", + "CONNECT api.example.com:443 not permitted by policy", + "", + ); + let response = String::from_utf8(response).expect("UTF-8 error response"); + let (_, body) = response.split_once("\r\n\r\n").expect("HTTP response"); + let body: serde_json::Value = serde_json::from_str(body).expect("JSON response"); + + assert_eq!(body["error"], "policy_denied"); + assert!(body.get("reason").is_none()); + } + #[test] fn endpoint_only_opa_allows_declared_endpoint_without_process_identity() { let policy = include_str!("../data/sandbox-policy.rego"); diff --git a/docs/observability/logging.mdx b/docs/observability/logging.mdx index 7a13ffea0..1ab0b5bfe 100644 --- a/docs/observability/logging.mdx +++ b/docs/observability/logging.mdx @@ -192,10 +192,13 @@ A denied CONNECT returns `403 Forbidden`: ```json { "error": "policy_denied", - "detail": "CONNECT api.example.com:443 not permitted by policy" + "detail": "CONNECT api.example.com:443 not permitted by policy", + "reason": "binary '/usr/bin/node' not allowed in policy 'allow_api' (ancestors: [/usr/local/bin/claude])" } ``` +The `reason` field is included when the policy engine provides a specific denial reason (for example, which binary or rule caused the rejection). It is omitted when no additional detail is available. + An upstream that the proxy cannot reach returns `502 Bad Gateway`: ```json @@ -205,7 +208,7 @@ An upstream that the proxy cannot reach returns `502 Bad Gateway`: } ``` -The `error` field is a short machine-readable code (`policy_denied`, `middleware_denied`, `middleware_failed`, `ssrf_denied`, `upstream_unreachable`). The `detail` field is a human-readable explanation suitable for display in an agent transcript. +The `error` field is a short machine-readable code (`policy_denied`, `middleware_denied`, `middleware_failed`, `ssrf_denied`, `upstream_unreachable`). The `detail` field is a human-readable explanation suitable for display in an agent transcript. The optional `reason` field, when present, provides the specific denial cause from the policy engine (for example, which binary was not allowed or which rule was missing). For L7 REST policy denials, the body also includes structured policy fields such as `method`, `path`, `rule_missing`, and `next_steps`. When policy advisor is enabled, it also includes `agent_guidance`, a short plain-language instruction telling the agent to read `/etc/openshell/skills/policy_advisor.md`, propose the narrowest rule through `http://policy.local/v1/proposals`, wait for `policy_reloaded: true`, and retry. A middleware denial instead identifies the policy-local config in `middleware` and can include a validated `reason_code`. A fail-closed runtime failure uses `middleware_failed` with platform-owned text. Both middleware responses omit `rule_missing`, `next_steps`, and `agent_guidance` because no policy rule is missing.