diff --git a/architecture/policy-advisor.md b/architecture/policy-advisor.md index c70bfcbd3..e909317e6 100644 --- a/architecture/policy-advisor.md +++ b/architecture/policy-advisor.md @@ -44,7 +44,7 @@ Events are emitted at four denial points in the proxy: | FORWARD OPA deny | `forward` | `proxy.rs` | Forward proxy policy deny | | FORWARD SSRF deny | `ssrf` | `proxy.rs` | Forward proxy SSRF check failed | -L7 (per-request) denials from `l7/relay.rs` are captured via tracing in the current implementation, with structured channel support planned for issue #205. +L7 (per-request) denials from `l7/rest.rs` are surfaced as a structured 403 response body — the agent-readable contract specified in [RFC 0001](../rfc/0001-agent-driven-policy-management.md) and delivered against issue #1090. The body carries `layer`, `method`, `path`, `host`, `port`, `binary`, `rule_missing`, and `next_steps` fields. The LLM-powered enrichment that wraps these structured denials remains future work under issue #205. ### Mechanistic Mapper (Sandbox Side) diff --git a/crates/openshell-cli/src/run.rs b/crates/openshell-cli/src/run.rs index 2ad634cf2..6f98bfff2 100644 --- a/crates/openshell-cli/src/run.rs +++ b/crates/openshell-cli/src/run.rs @@ -5762,17 +5762,57 @@ pub async fn sandbox_draft_history(server: &str, name: &str, tls: &TlsOptions) - fn format_endpoints(rule: &openshell_core::proto::NetworkPolicyRule) -> String { rule.endpoints .iter() - .map(|e| { - if e.port > 0 { - format!("{}:{}", e.host, e.port) - } else { - e.host.clone() - } - }) + .map(format_endpoint) .collect::>() .join(", ") } +/// Render an endpoint as `host:port [layer, …allows…, …denies…]` so a reader +/// can tell L4-only access apart from a method/path-scoped L7 grant. The L7 +/// fields (`protocol: rest`, `rules`, `access`) materially change what gets +/// allowed; surfacing them in the default text output is what makes +/// `openshell rule get` useful for approval review. +fn format_endpoint(endpoint: &openshell_core::proto::NetworkEndpoint) -> String { + let host_port = if endpoint.port > 0 { + format!("{}:{}", endpoint.host, endpoint.port) + } else { + endpoint.host.clone() + }; + + let mut tags: Vec = Vec::new(); + let layer_tag = if endpoint.protocol.eq_ignore_ascii_case("rest") { + "L7 rest" + } else if endpoint.protocol.is_empty() { + "L4" + } else { + endpoint.protocol.as_str() + }; + tags.push(layer_tag.to_string()); + + if !endpoint.access.is_empty() { + tags.push(format!("access={}", endpoint.access)); + } + + for r in &endpoint.rules { + if let Some(allow) = &r.allow { + let method = non_empty_or(&allow.method, "*"); + let path = non_empty_or(&allow.path, "*"); + tags.push(format!("allow {method} {path}")); + } + } + for r in &endpoint.deny_rules { + let method = non_empty_or(&r.method, "*"); + let path = non_empty_or(&r.path, "*"); + tags.push(format!("deny {method} {path}")); + } + + format!("{host_port} [{}]", tags.join(", ")) +} + +fn non_empty_or<'a>(value: &'a str, fallback: &'a str) -> &'a str { + if value.is_empty() { fallback } else { value } +} + /// Format a millisecond timestamp into a readable string. fn format_timestamp_ms(ms: i64) -> String { if ms <= 0 { @@ -5793,9 +5833,9 @@ fn format_timestamp_ms(ms: i64) -> String { mod tests { use super::{ GatewayControlTarget, TlsOptions, dockerfile_sources_supported_for_gateway, - format_gateway_select_header, format_gateway_select_items, gateway_add, gateway_auth_label, - gateway_select_with, gateway_type_label, git_sync_files, http_health_check, - image_requests_gpu, inferred_provider_type, parse_cli_setting_value, + format_endpoint, format_gateway_select_header, format_gateway_select_items, gateway_add, + gateway_auth_label, gateway_select_with, gateway_type_label, git_sync_files, + http_health_check, image_requests_gpu, inferred_provider_type, parse_cli_setting_value, parse_credential_pairs, plaintext_gateway_is_remote, provisioning_timeout_message, ready_false_condition_message, resolve_from, resolve_gateway_control_target_from, sandbox_should_persist, shell_escape, source_requests_gpu, validate_gateway_name, @@ -6560,4 +6600,51 @@ mod tests { "should end with single-quote: {ssh_escaped}" ); } + + #[test] + fn format_endpoint_distinguishes_l4_from_l7_rest() { + use openshell_core::proto::{L7Allow, L7DenyRule, L7Rule, NetworkEndpoint}; + + let l4 = NetworkEndpoint { + host: "host.example.test".to_string(), + port: 443, + ..Default::default() + }; + assert_eq!(format_endpoint(&l4), "host.example.test:443 [L4]"); + + let l7_readonly = NetworkEndpoint { + host: "host.example.test".to_string(), + port: 443, + protocol: "rest".to_string(), + access: "read-only".to_string(), + ..Default::default() + }; + assert_eq!( + format_endpoint(&l7_readonly), + "host.example.test:443 [L7 rest, access=read-only]" + ); + + let l7_scoped = NetworkEndpoint { + host: "host.example.test".to_string(), + port: 443, + protocol: "rest".to_string(), + rules: vec![L7Rule { + allow: Some(L7Allow { + method: "PUT".to_string(), + path: "/v1/example/resource".to_string(), + ..Default::default() + }), + }], + deny_rules: vec![L7DenyRule { + method: "DELETE".to_string(), + path: "/v1/example/resource".to_string(), + ..Default::default() + }], + ..Default::default() + }; + assert_eq!( + format_endpoint(&l7_scoped), + "host.example.test:443 [L7 rest, allow PUT /v1/example/resource, deny DELETE /v1/example/resource]" + ); + } } diff --git a/crates/openshell-sandbox/src/grpc_client.rs b/crates/openshell-sandbox/src/grpc_client.rs index 44f372355..afe91495e 100644 --- a/crates/openshell-sandbox/src/grpc_client.rs +++ b/crates/openshell-sandbox/src/grpc_client.rs @@ -11,8 +11,8 @@ use miette::{IntoDiagnostic, Result, WrapErr}; use openshell_core::proto::{ DenialSummary, GetInferenceBundleRequest, GetInferenceBundleResponse, GetSandboxConfigRequest, GetSandboxProviderEnvironmentRequest, PolicySource, PolicyStatus, ReportPolicyStatusRequest, - SandboxPolicy as ProtoSandboxPolicy, SubmitPolicyAnalysisRequest, UpdateConfigRequest, - inference_client::InferenceClient, open_shell_client::OpenShellClient, + SandboxPolicy as ProtoSandboxPolicy, SubmitPolicyAnalysisRequest, SubmitPolicyAnalysisResponse, + UpdateConfigRequest, inference_client::InferenceClient, open_shell_client::OpenShellClient, }; use tonic::service::interceptor::InterceptedService; use tonic::transport::{Certificate, Channel, ClientTlsConfig, Endpoint, Identity}; @@ -318,15 +318,20 @@ impl CachedOpenShellClient { }) } - /// Submit denial summaries for policy analysis. + /// Submit denial summaries and/or agent-authored proposals for policy analysis. + /// + /// Returns the gateway response so callers can surface accepted/rejected + /// counts and rejection reasons (e.g., the `policy.local` API forwards + /// these to the in-sandbox agent). pub async fn submit_policy_analysis( &self, sandbox_name: &str, summaries: Vec, proposed_chunks: Vec, analysis_mode: &str, - ) -> Result<()> { - self.client + ) -> Result { + let response = self + .client .clone() .submit_policy_analysis(SubmitPolicyAnalysisRequest { name: sandbox_name.to_string(), @@ -337,7 +342,7 @@ impl CachedOpenShellClient { .await .into_diagnostic()?; - Ok(()) + Ok(response.into_inner()) } /// Report policy load status back to the server. diff --git a/crates/openshell-sandbox/src/l7/relay.rs b/crates/openshell-sandbox/src/l7/relay.rs index d0599ea99..f099c3558 100644 --- a/crates/openshell-sandbox/src/l7/relay.rs +++ b/crates/openshell-sandbox/src/l7/relay.rs @@ -305,6 +305,11 @@ where &reason, client, Some(&redacted_target), + Some(crate::l7::rest::DenyResponseContext { + host: Some(&ctx.host), + port: Some(ctx.port), + binary: Some(&ctx.binary_path), + }), ) .await?; return Ok(()); @@ -584,6 +589,11 @@ where &reason, client, Some(&redacted_target), + Some(crate::l7::rest::DenyResponseContext { + host: Some(&ctx.host), + port: Some(ctx.port), + binary: Some(&ctx.binary_path), + }), ) .await?; return Ok(()); @@ -789,6 +799,11 @@ where &reason, client, Some(&redacted_target), + Some(crate::l7::rest::DenyResponseContext { + host: Some(&ctx.host), + port: Some(ctx.port), + binary: Some(&ctx.binary_path), + }), ) .await?; return Ok(()); diff --git a/crates/openshell-sandbox/src/l7/rest.rs b/crates/openshell-sandbox/src/l7/rest.rs index 19acdbf32..0db017738 100644 --- a/crates/openshell-sandbox/src/l7/rest.rs +++ b/crates/openshell-sandbox/src/l7/rest.rs @@ -72,10 +72,19 @@ impl L7Provider for RestProvider { reason: &str, client: &mut C, ) -> Result<()> { - send_deny_response(req, policy_name, reason, client, None).await + send_deny_response(req, policy_name, reason, client, None, None).await } } +/// Extra sandbox-side context included in agent-readable deny responses when +/// the relay has it available. +#[derive(Debug, Clone, Copy, Default)] +pub(crate) struct DenyResponseContext<'a> { + pub(crate) host: Option<&'a str>, + pub(crate) port: Option, + pub(crate) binary: Option<&'a str>, +} + impl RestProvider { /// Deny with a redacted target for the response body. pub(crate) async fn deny_with_redacted_target( @@ -85,8 +94,9 @@ impl RestProvider { reason: &str, client: &mut C, redacted_target: Option<&str>, + context: Option>, ) -> Result<()> { - send_deny_response(req, policy_name, reason, client, redacted_target).await + send_deny_response(req, policy_name, reason, client, redacted_target, context).await } } @@ -452,14 +462,9 @@ async fn send_deny_response( reason: &str, client: &mut C, redacted_target: Option<&str>, + context: Option>, ) -> Result<()> { - let target = redacted_target.unwrap_or(&req.target); - let body = serde_json::json!({ - "error": "policy_denied", - "policy": policy_name, - "rule": format!("{} {}", req.action, target), - "detail": reason - }); + let body = deny_response_body(req, policy_name, reason, redacted_target, context); let body_bytes = body.to_string(); let response = format!( "HTTP/1.1 403 Forbidden\r\n\ @@ -481,6 +486,74 @@ async fn send_deny_response( Ok(()) } +fn deny_response_body( + req: &L7Request, + policy_name: &str, + reason: &str, + redacted_target: Option<&str>, + context: Option>, +) -> serde_json::Value { + let target = redacted_target.unwrap_or(&req.target); + let context = context.unwrap_or_default(); + let host = non_empty(context.host); + let binary = non_empty(context.binary); + + let mut rule_missing = serde_json::Map::new(); + rule_missing.insert("type".to_string(), serde_json::json!("rest_allow")); + rule_missing.insert("layer".to_string(), serde_json::json!("l7")); + rule_missing.insert("method".to_string(), serde_json::json!(req.action)); + rule_missing.insert("path".to_string(), serde_json::json!(target)); + if let Some(host) = host { + rule_missing.insert("host".to_string(), serde_json::json!(host)); + } + if let Some(port) = context.port { + rule_missing.insert("port".to_string(), serde_json::json!(port)); + } + if let Some(binary) = binary { + rule_missing.insert("binary".to_string(), serde_json::json!(binary)); + } + + let mut body = serde_json::Map::new(); + body.insert("error".to_string(), serde_json::json!("policy_denied")); + body.insert("policy".to_string(), serde_json::json!(policy_name)); + body.insert( + "rule".to_string(), + serde_json::json!(format!("{} {}", req.action, target)), + ); + body.insert("detail".to_string(), serde_json::json!(reason)); + body.insert("layer".to_string(), serde_json::json!("l7")); + body.insert("protocol".to_string(), serde_json::json!("rest")); + body.insert("method".to_string(), serde_json::json!(req.action)); + body.insert("path".to_string(), serde_json::json!(target)); + if let Some(host) = host { + body.insert("host".to_string(), serde_json::json!(host)); + } + if let Some(port) = context.port { + body.insert("port".to_string(), serde_json::json!(port)); + } + if let Some(binary) = binary { + body.insert("binary".to_string(), serde_json::json!(binary)); + } + body.insert( + "rule_missing".to_string(), + serde_json::Value::Object(rule_missing), + ); + // `next_steps` is generated by the policy_local module so the wire URLs + // and the on-disk skill path stay in sync with the route table. Adding + // or renaming a route only requires touching the constants in that + // module; this side picks up the change automatically. + body.insert( + "next_steps".to_string(), + crate::policy_local::agent_next_steps(), + ); + + serde_json::Value::Object(body) +} + +fn non_empty(value: Option<&str>) -> Option<&str> { + value.map(str::trim).filter(|value| !value.is_empty()) +} + /// Parse Content-Length or Transfer-Encoding from HTTP headers. /// /// Per RFC 7230 Section 3.3.3, rejects requests containing both @@ -977,6 +1050,109 @@ mod tests { const TEST_POLICY: &str = include_str!("../../data/sandbox-policy.rego"); + #[test] + fn deny_response_body_is_agent_readable_and_redacted() { + let req = L7Request { + action: "PUT".to_string(), + target: "/repos/NVIDIA/OpenShell/contents/README.md?access_token=secret-token" + .to_string(), + query_params: HashMap::new(), + raw_header: Vec::new(), + body_length: BodyLength::ContentLength(128), + }; + + let body = deny_response_body( + &req, + "github-readonly", + "no matching L7 allow rule", + Some("/repos/NVIDIA/OpenShell/contents/README.md"), + Some(DenyResponseContext { + host: Some("api.github.com"), + port: Some(443), + binary: Some("/usr/bin/gh"), + }), + ); + + assert_eq!(body["error"], "policy_denied"); + assert_eq!(body["policy"], "github-readonly"); + assert_eq!(body["layer"], "l7"); + assert_eq!(body["protocol"], "rest"); + assert_eq!(body["method"], "PUT"); + assert_eq!(body["host"], "api.github.com"); + assert_eq!(body["port"], 443); + assert_eq!(body["binary"], "/usr/bin/gh"); + assert_eq!(body["path"], "/repos/NVIDIA/OpenShell/contents/README.md"); + assert_eq!( + body["rule"], + "PUT /repos/NVIDIA/OpenShell/contents/README.md" + ); + assert_eq!(body["rule_missing"]["type"], "rest_allow"); + assert_eq!(body["rule_missing"]["layer"], "l7"); + assert_eq!(body["rule_missing"]["method"], "PUT"); + assert_eq!( + body["rule_missing"]["path"], + "/repos/NVIDIA/OpenShell/contents/README.md" + ); + assert_eq!(body["rule_missing"]["host"], "api.github.com"); + assert_eq!(body["rule_missing"]["port"], 443); + assert_eq!(body["rule_missing"]["binary"], "/usr/bin/gh"); + assert_eq!(body["next_steps"][0]["action"], "read_skill"); + assert_eq!( + body["next_steps"][0]["path"], + "/etc/openshell/skills/policy_advisor.md" + ); + assert_eq!(body["next_steps"][3]["body_type"], "PolicyMergeOperation"); + assert!( + !body.to_string().contains("secret-token"), + "deny body must not leak query params or credential values" + ); + } + + #[tokio::test] + async fn send_deny_response_writes_structured_json_403() { + let (mut client, mut server) = tokio::io::duplex(4096); + let send = tokio::spawn(async move { + let req = L7Request { + action: "POST".to_string(), + target: "/user/repos".to_string(), + query_params: HashMap::new(), + raw_header: Vec::new(), + body_length: BodyLength::ContentLength(64), + }; + send_deny_response( + &req, + "github-readonly", + "no matching L7 allow rule", + &mut server, + None, + Some(DenyResponseContext { + host: Some("api.github.com"), + port: Some(443), + binary: Some("/usr/bin/gh"), + }), + ) + .await + .unwrap(); + }); + + let mut received = Vec::new(); + client.read_to_end(&mut received).await.unwrap(); + send.await.unwrap(); + + let response = String::from_utf8(received).unwrap(); + assert!(response.starts_with("HTTP/1.1 403 Forbidden")); + assert!(response.contains("Content-Type: application/json")); + assert!(response.contains("X-OpenShell-Policy: github-readonly")); + + let (_, body) = response.split_once("\r\n\r\n").unwrap(); + let body: serde_json::Value = serde_json::from_str(body).unwrap(); + assert_eq!(body["error"], "policy_denied"); + assert_eq!(body["method"], "POST"); + assert_eq!(body["path"], "/user/repos"); + assert_eq!(body["rule_missing"]["host"], "api.github.com"); + assert_eq!(body["next_steps"][2]["action"], "inspect_recent_denials"); + } + #[test] fn parse_content_length() { let headers = "POST /api HTTP/1.1\r\nHost: example.com\r\nContent-Length: 42\r\n\r\n"; diff --git a/crates/openshell-sandbox/src/lib.rs b/crates/openshell-sandbox/src/lib.rs index 19424bd2b..5e2398c10 100644 --- a/crates/openshell-sandbox/src/lib.rs +++ b/crates/openshell-sandbox/src/lib.rs @@ -15,11 +15,13 @@ pub mod log_push; pub mod mechanistic_mapper; pub mod opa; mod policy; +mod policy_local; mod process; pub mod procfs; pub mod proxy; mod sandbox; mod secrets; +mod skills; mod ssh; mod supervisor_session; @@ -260,6 +262,11 @@ pub async fn run_sandbox( policy_data, ) .await?; + let policy_local_ctx = Arc::new(policy_local::PolicyLocalContext::new( + retained_proto.clone(), + openshell_endpoint.clone(), + sandbox_name_for_agg.clone().or_else(|| sandbox_id.clone()), + )); // Validate that the required "sandbox" user exists in this image. // All sandbox images must include this user for privilege dropping. @@ -314,6 +321,18 @@ pub async fn run_sandbox( // Prepare filesystem: create and chown read_write directories prepare_filesystem(&policy)?; + match skills::install_static_skills() { + Ok(installed) => { + info!( + path = %installed.policy_advisor.display(), + "Installed sandbox agent skill" + ); + } + Err(error) => { + warn!(error = %error, "Failed to install sandbox agent skill"); + } + } + // Generate ephemeral CA and TLS state for HTTPS L7 inspection. // The CA cert is written to disk so sandbox processes can trust it. let (tls_state, ca_file_paths) = if matches!(policy.network.mode, NetworkMode::Proxy) { @@ -480,6 +499,7 @@ pub async fn run_sandbox( entrypoint_pid.clone(), tls_state, inference_ctx, + Some(policy_local_ctx.clone()), secret_resolver.clone(), denial_tx, ) @@ -796,6 +816,7 @@ pub async fn run_sandbox( let poll_engine = engine.clone(); let poll_ocsf_enabled = ocsf_enabled.clone(); let poll_pid = entrypoint_pid.clone(); + let poll_policy_local = policy_local_ctx.clone(); let poll_interval_secs: u64 = std::env::var("OPENSHELL_POLICY_POLL_INTERVAL_SECS") .ok() .and_then(|v| v.parse().ok()) @@ -809,6 +830,7 @@ pub async fn run_sandbox( &poll_pid, poll_interval_secs, &poll_ocsf_enabled, + Some(poll_policy_local), ) .await { @@ -2152,6 +2174,7 @@ async fn run_policy_poll_loop( entrypoint_pid: &Arc, interval_secs: u64, ocsf_enabled: &std::sync::atomic::AtomicBool, + policy_local_ctx: Option>, ) -> Result<()> { use crate::grpc_client::CachedOpenShellClient; use openshell_core::proto::PolicySource; @@ -2233,6 +2256,9 @@ async fn run_policy_poll_loop( let pid = entrypoint_pid.load(Ordering::Acquire); match opa_engine.reload_from_proto_with_pid(policy, pid) { Ok(()) => { + if let Some(ctx) = policy_local_ctx.as_ref() { + ctx.set_current_policy(policy.clone()).await; + } if result.global_policy_version > 0 { ocsf_emit!(ConfigStateChangeBuilder::new(ocsf_ctx()) .severity(SeverityId::Informational) diff --git a/crates/openshell-sandbox/src/policy_local.rs b/crates/openshell-sandbox/src/policy_local.rs new file mode 100644 index 000000000..6684a56bc --- /dev/null +++ b/crates/openshell-sandbox/src/policy_local.rs @@ -0,0 +1,1073 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Sandbox-local policy advisor HTTP API. + +use miette::{IntoDiagnostic, Result}; +use openshell_core::proto::{ + L7Allow, L7DenyRule, L7Rule, NetworkBinary, NetworkEndpoint, NetworkPolicyRule, PolicyChunk, + SandboxPolicy as ProtoSandboxPolicy, +}; +use serde::Deserialize; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; +use tokio::sync::RwLock; + +pub const POLICY_LOCAL_HOST: &str = "policy.local"; + +/// Filesystem path of the static agent guidance bundle inside the sandbox. +/// Single source of truth: the skill installer writes here, the L7 deny body +/// references this path in `next_steps`, and the skill's own documentation +/// renders the same path. Changing the location is a one-line update here. +pub const SKILL_PATH: &str = "/etc/openshell/skills/policy_advisor.md"; + +/// Routes served by the in-sandbox policy advisor API. Held in one place so +/// the L7 deny `next_steps` array, the route dispatcher, the skill content, +/// and tests all stay in sync — change the wire path here and every caller +/// follows. See `agent_next_steps()` for the consumer that surfaces these +/// to the agent on a 403. +pub const ROUTE_POLICY_CURRENT: &str = "/v1/policy/current"; +pub const ROUTE_DENIALS: &str = "/v1/denials"; +pub const ROUTE_PROPOSALS: &str = "/v1/proposals"; + +const MAX_POLICY_LOCAL_BODY_BYTES: usize = 64 * 1024; +/// Hard ceiling on how long a single request body read can stall. Bounds a +/// slowloris-style upload from an in-sandbox process; the proxy listener only +/// accepts loopback connections, so practical impact is limited, but this is +/// cheap defense-in-depth. +const POLICY_LOCAL_BODY_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15); +const DEFAULT_DENIALS_LIMIT: usize = 10; +const MAX_DENIALS_LIMIT: usize = 100; +/// The shorthand rolling appender keeps three files (daily rotation); read the +/// most recent two so a request just past midnight still has yesterday's +/// denials. +const DENIAL_LOG_FILES_TO_SCAN: usize = 2; +const LOG_DIR: &str = "/var/log"; +/// Shorthand log filenames are `openshell.YYYY-MM-DD.log`. The trailing dot in +/// the prefix is intentional: it disambiguates from the OCSF JSONL appender's +/// `openshell-ocsf.YYYY-MM-DD.log`, which we never want to surface here (the +/// JSONL is opt-in via `ocsf_json_enabled` and not the source of truth for +/// `/v1/denials`). +const SHORTHAND_LOG_PREFIX: &str = "openshell."; +/// Defensive cap on per-line length returned to the agent so a pathological +/// log entry (very long URL path, etc.) cannot blow up the response. +const MAX_DENIAL_LINE_BYTES: usize = 4096; + +#[derive(Debug)] +pub struct PolicyLocalContext { + current_policy: Arc>>, + gateway_endpoint: Option, + sandbox_name: Option, + shorthand_log_dir: PathBuf, +} + +impl PolicyLocalContext { + pub fn new( + current_policy: Option, + gateway_endpoint: Option, + sandbox_name: Option, + ) -> Self { + Self::with_log_dir( + current_policy, + gateway_endpoint, + sandbox_name, + PathBuf::from(LOG_DIR), + ) + } + + fn with_log_dir( + current_policy: Option, + gateway_endpoint: Option, + sandbox_name: Option, + shorthand_log_dir: PathBuf, + ) -> Self { + Self { + current_policy: Arc::new(RwLock::new(current_policy)), + gateway_endpoint, + sandbox_name, + shorthand_log_dir, + } + } + + pub async fn set_current_policy(&self, policy: ProtoSandboxPolicy) { + *self.current_policy.write().await = Some(policy); + } +} + +pub async fn handle_forward_request( + ctx: &PolicyLocalContext, + method: &str, + path: &str, + initial_request: &[u8], + client: &mut S, +) -> Result<()> +where + S: AsyncRead + AsyncWrite + Unpin, +{ + let body = read_request_body(initial_request, client).await?; + let (status, payload) = route_request(ctx, method, path, &body).await; + write_json_response(client, status, payload).await +} + +async fn route_request( + ctx: &PolicyLocalContext, + method: &str, + path: &str, + body: &[u8], +) -> (u16, serde_json::Value) { + let (route, query) = path.split_once('?').map_or((path, ""), |(r, q)| (r, q)); + match (method, route) { + ("GET", ROUTE_POLICY_CURRENT) => current_policy_response(ctx).await, + ("GET", ROUTE_DENIALS) => recent_denials_response(ctx, query).await, + ("POST", ROUTE_PROPOSALS) => submit_proposal(ctx, body).await, + _ => ( + 404, + serde_json::json!({ + "error": "not_found", + "detail": format!("policy.local route not found: {method} {route}") + }), + ), + } +} + +/// Build the `next_steps` array embedded in the L7 deny body so the agent has +/// machine-readable pointers to this API. Centralizes the shape here to keep +/// the deny body and the actual route table from drifting — adding or +/// renaming a route only requires touching the route constants above. +#[must_use] +pub fn agent_next_steps() -> serde_json::Value { + let host = POLICY_LOCAL_HOST; + serde_json::json!([ + { + "action": "read_skill", + "path": SKILL_PATH, + }, + { + "action": "inspect_policy", + "method": "GET", + "url": format!("http://{host}{ROUTE_POLICY_CURRENT}"), + }, + { + "action": "inspect_recent_denials", + "method": "GET", + "url": format!("http://{host}{ROUTE_DENIALS}?last=5"), + }, + { + "action": "submit_proposal", + "method": "POST", + "url": format!("http://{host}{ROUTE_PROPOSALS}"), + "body_type": "PolicyMergeOperation", + }, + ]) +} + +async fn current_policy_response(ctx: &PolicyLocalContext) -> (u16, serde_json::Value) { + let Some(policy) = ctx.current_policy.read().await.clone() else { + return ( + 404, + serde_json::json!({ + "error": "policy_unavailable", + "detail": "no current sandbox policy is loaded" + }), + ); + }; + + match openshell_policy::serialize_sandbox_policy(&policy) { + Ok(policy_yaml) => ( + 200, + serde_json::json!({ + "format": "yaml", + "policy_yaml": policy_yaml + }), + ), + Err(error) => ( + 500, + serde_json::json!({ + "error": "policy_serialize_failed", + "detail": error.to_string() + }), + ), + } +} + +async fn recent_denials_response( + ctx: &PolicyLocalContext, + query: &str, +) -> (u16, serde_json::Value) { + let limit = parse_last_query(query).unwrap_or(DEFAULT_DENIALS_LIMIT); + let log_dir = ctx.shorthand_log_dir.clone(); + + // Distinguish "shorthand log exists and no denials happened" from "no log + // file yet, so we have nothing to read." Without this flag the agent sees + // `[]` in both cases and cannot tell the difference. The shorthand log is + // always-on (no setting gates it), so the only way `log_available=false` + // happens in practice is if the supervisor has not flushed any events to + // disk yet, or `/var/log` is not writable in this image. + let log_available = matches!( + collect_shorthand_log_files(&log_dir, 1), + Ok(files) if !files.is_empty() + ); + + let denials = tokio::task::spawn_blocking(move || read_recent_denial_lines(&log_dir, limit)) + .await + .unwrap_or_default(); + + let mut payload = serde_json::json!({ + "denials": denials, + "log_available": log_available, + }); + if !log_available { + payload["note"] = serde_json::json!( + "no shorthand log file is present yet at /var/log/openshell.YYYY-MM-DD.log; the supervisor may not have emitted any events to disk yet" + ); + } + + (200, payload) +} + +fn parse_last_query(query: &str) -> Option { + if query.is_empty() { + return None; + } + for pair in query.split('&') { + let Some((key, value)) = pair.split_once('=') else { + continue; + }; + if key == "last" { + return value + .parse::() + .ok() + .map(|n| n.clamp(1, MAX_DENIALS_LIMIT)); + } + } + None +} + +/// Walk the shorthand log files (most-recent first) and return up to `limit` +/// raw denial lines in newest-first order. The agent receives the same +/// human-readable text that `openshell logs` displays — no parsing back into +/// structured form. Updating the shorthand format adds fields automatically; +/// no schema rev required. +/// +/// Reads files synchronously and is intended to run inside `spawn_blocking`. +fn read_recent_denial_lines(log_dir: &Path, limit: usize) -> Vec { + let Ok(files) = collect_shorthand_log_files(log_dir, DENIAL_LOG_FILES_TO_SCAN) else { + return Vec::new(); + }; + + let mut lines: Vec = Vec::with_capacity(limit); + for path in files { + let Ok(contents) = std::fs::read_to_string(&path) else { + continue; + }; + // Walk lines newest-first. Within a single file, the last line written + // is the freshest event. + for line in contents.lines().rev() { + if !is_ocsf_denial_line(line) { + continue; + } + // Defense-in-depth: redact query strings before truncation. The + // FORWARD deny path in `proxy.rs` populates the OCSF `message` + // and URL with the raw request path including `?query=...`, which + // the shorthand layer then renders verbatim. Stripping queries + // here means the agent never sees the secret even if an upstream + // emit site forgets to redact (TODO: harden the emit sites in + // proxy.rs FORWARD path so the on-disk shorthand log itself is + // clean — tracked separately). Redact first so truncation cannot + // slice mid-secret. + let redacted = redact_query_strings(line); + let surfaced = truncate_at_char_boundary(&redacted, MAX_DENIAL_LINE_BYTES); + lines.push(surfaced); + if lines.len() >= limit { + return lines; + } + } + } + lines +} + +/// Replace any `?` substring with `?[redacted]` to keep query-string +/// secrets out of the agent's view. Walks per Unicode scalar value so multi-byte +/// content is safe. A query is everything from `?` until the next whitespace or +/// `]` (the shorthand format uses `[...]` for context tags). +fn redact_query_strings(line: &str) -> String { + let mut out = String::with_capacity(line.len()); + let mut chars = line.chars(); + while let Some(c) = chars.next() { + if c == '?' { + out.push('?'); + out.push_str("[redacted]"); + // Consume until whitespace or `]` (preserved as the next token's + // boundary by writing it back out). + for next in chars.by_ref() { + if next.is_whitespace() || next == ']' { + out.push(next); + break; + } + } + } else { + out.push(c); + } + } + out +} + +/// Truncate `s` at the largest UTF-8 char boundary <= `max_bytes`, appending a +/// `...[truncated]` suffix. Returning a `String` (not `&str`) avoids surprising +/// callers about lifetime relationships with `s`. +fn truncate_at_char_boundary(s: &str, max_bytes: usize) -> String { + if s.len() <= max_bytes { + return s.to_string(); + } + let mut end = max_bytes; + while end > 0 && !s.is_char_boundary(end) { + end -= 1; + } + let mut out = String::with_capacity(end + "...[truncated]".len()); + out.push_str(&s[..end]); + out.push_str("...[truncated]"); + out +} + +/// True for OCSF denial events as rendered by the shorthand layer. The format +/// is ` OCSF <[SEV]> ...`. The literal +/// ` OCSF ` substring identifies an OCSF event (vs. a non-OCSF tracing line); +/// ` DENIED ` is the OCSF action label uppercased and surrounded by spaces, so +/// matching it is safe against substring collisions in URLs or hostnames. +fn is_ocsf_denial_line(line: &str) -> bool { + line.contains(" OCSF ") && line.contains(" DENIED ") +} + +fn collect_shorthand_log_files( + log_dir: &Path, + max_files: usize, +) -> std::io::Result> { + let mut entries: Vec<(std::time::SystemTime, PathBuf)> = std::fs::read_dir(log_dir)? + .filter_map(std::result::Result::ok) + .filter_map(|entry| { + let path = entry.path(); + let name = entry.file_name(); + let name = name.to_string_lossy(); + // `openshell.YYYY-MM-DD.log` only — the trailing dot in the prefix + // disambiguates from `openshell-ocsf.YYYY-MM-DD.log`. + if !name.starts_with(SHORTHAND_LOG_PREFIX) || !name.ends_with(".log") { + return None; + } + let modified = entry.metadata().and_then(|m| m.modified()).ok()?; + Some((modified, path)) + }) + .collect(); + + entries.sort_by_key(|entry| std::cmp::Reverse(entry.0)); + Ok(entries + .into_iter() + .take(max_files) + .map(|(_, p)| p) + .collect()) +} + +async fn submit_proposal(ctx: &PolicyLocalContext, body: &[u8]) -> (u16, serde_json::Value) { + let Some(endpoint) = ctx.gateway_endpoint.as_deref() else { + return ( + 503, + serde_json::json!({ + "error": "gateway_unavailable", + "detail": "policy proposal submission requires a gateway-connected sandbox" + }), + ); + }; + let Some(sandbox_name) = ctx + .sandbox_name + .as_deref() + .map(str::trim) + .filter(|name| !name.is_empty()) + else { + return ( + 503, + serde_json::json!({ + "error": "sandbox_name_unavailable", + "detail": "policy proposal submission requires a sandbox name" + }), + ); + }; + + let chunks = match proposal_chunks_from_body(body) { + Ok(chunks) => chunks, + Err(error) => return (400, error_payload("invalid_proposal", error)), + }; + + let client = match crate::grpc_client::CachedOpenShellClient::connect(endpoint).await { + Ok(client) => client, + Err(error) => { + return ( + 502, + serde_json::json!({ + "error": "gateway_connect_failed", + "detail": error.to_string() + }), + ); + } + }; + + let response = match client + .submit_policy_analysis(sandbox_name, vec![], chunks, "agent_authored") + .await + { + Ok(response) => response, + Err(error) => { + return ( + 502, + serde_json::json!({ + "error": "proposal_submit_failed", + "detail": error.to_string() + }), + ); + } + }; + + ( + 202, + serde_json::json!({ + "status": "submitted", + "accepted_chunks": response.accepted_chunks, + "rejected_chunks": response.rejected_chunks, + "rejection_reasons": response.rejection_reasons, + }), + ) +} + +fn proposal_chunks_from_body(body: &[u8]) -> std::result::Result, String> { + let request: ProposalRequest = serde_json::from_slice(body).map_err(|e| e.to_string())?; + if request.operations.is_empty() { + return Err("proposal requires at least one operation".to_string()); + } + + let mut chunks = Vec::new(); + for operation in request.operations { + let Some(add_rule) = operation.get("addRule").cloned() else { + return Err( + "this MVP accepts `addRule` operations; submit a full narrow NetworkPolicyRule" + .to_string(), + ); + }; + let add_rule: AddNetworkRuleJson = + serde_json::from_value(add_rule).map_err(|e| e.to_string())?; + chunks.push(policy_chunk_from_add_rule( + add_rule, + request.intent_summary.as_deref().unwrap_or_default(), + )?); + } + + Ok(chunks) +} + +fn policy_chunk_from_add_rule( + add_rule: AddNetworkRuleJson, + intent_summary: &str, +) -> std::result::Result { + let mut rule = network_rule_from_json(add_rule.rule)?; + let rule_name = add_rule + .rule_name + .as_deref() + .map(str::trim) + .filter(|name| !name.is_empty()) + .map_or_else(|| rule.name.clone(), ToString::to_string); + if rule_name.trim().is_empty() { + return Err("addRule.ruleName or rule.name is required".to_string()); + } + if rule.name.trim().is_empty() { + rule.name.clone_from(&rule_name); + } + + let binary = rule + .binaries + .first() + .map(|binary| binary.path.clone()) + .unwrap_or_default(); + + Ok(PolicyChunk { + id: String::new(), + status: "pending".to_string(), + rule_name, + proposed_rule: Some(rule), + rationale: intent_summary.to_string(), + security_notes: String::new(), + confidence: 0.75, + denial_summary_ids: vec![], + created_at_ms: 0, + decided_at_ms: 0, + stage: "agent".to_string(), + supersedes_chunk_id: String::new(), + hit_count: 1, + first_seen_ms: 0, + last_seen_ms: 0, + binary, + }) +} + +fn network_rule_from_json( + rule: NetworkPolicyRuleJson, +) -> std::result::Result { + if rule.endpoints.is_empty() { + return Err("rule.endpoints must contain at least one endpoint".to_string()); + } + + let endpoints = rule + .endpoints + .into_iter() + .map(network_endpoint_from_json) + .collect::, _>>()?; + let binaries = rule + .binaries + .into_iter() + .map(|binary| NetworkBinary { + path: binary.path, + ..Default::default() + }) + .collect(); + + Ok(NetworkPolicyRule { + name: rule.name.unwrap_or_default(), + endpoints, + binaries, + }) +} + +fn network_endpoint_from_json( + endpoint: NetworkEndpointJson, +) -> std::result::Result { + if endpoint.host.trim().is_empty() { + return Err("endpoint.host is required".to_string()); + } + + let mut ports = endpoint.ports; + if ports.is_empty() && endpoint.port > 0 { + ports.push(endpoint.port); + } + if ports.is_empty() { + return Err("endpoint.port or endpoint.ports is required".to_string()); + } + if endpoint + .rules + .iter() + .any(|rule| rule.allow.path.contains('?')) + { + return Err("L7 allow paths must not include query strings".to_string()); + } + + let port = ports.first().copied().unwrap_or_default(); + let rules = endpoint + .rules + .into_iter() + .map(|rule| L7Rule { + allow: Some(L7Allow { + method: rule.allow.method, + path: rule.allow.path, + command: rule.allow.command, + query: HashMap::new(), + ..Default::default() + }), + }) + .collect(); + let deny_rules = endpoint + .deny_rules + .into_iter() + .map(|rule| L7DenyRule { + method: rule.method, + path: rule.path, + command: rule.command, + query: HashMap::new(), + ..Default::default() + }) + .collect(); + + Ok(NetworkEndpoint { + host: endpoint.host, + port, + protocol: endpoint.protocol, + tls: endpoint.tls, + enforcement: endpoint.enforcement, + access: endpoint.access, + rules, + allowed_ips: endpoint.allowed_ips, + ports, + deny_rules, + allow_encoded_slash: endpoint.allow_encoded_slash, + ..Default::default() + }) +} + +async fn read_request_body(initial_request: &[u8], client: &mut S) -> Result> +where + S: AsyncRead + Unpin, +{ + let Some(header_end) = find_header_end(initial_request) else { + return Ok(Vec::new()); + }; + let content_length = parse_content_length(&initial_request[..header_end])?; + if content_length > MAX_POLICY_LOCAL_BODY_BYTES { + return Err(miette::miette!( + "policy.local request body exceeds {MAX_POLICY_LOCAL_BODY_BYTES} bytes" + )); + } + + let mut body = initial_request[header_end..].to_vec(); + if body.len() > content_length { + body.truncate(content_length); + } + let read_loop = async { + while body.len() < content_length { + let remaining = content_length - body.len(); + let mut chunk = vec![0u8; remaining.min(8192)]; + let n = client.read(&mut chunk).await.into_diagnostic()?; + if n == 0 { + return Err(miette::miette!("policy.local request body ended early")); + } + body.extend_from_slice(&chunk[..n]); + } + Ok::<(), miette::Report>(()) + }; + tokio::time::timeout(POLICY_LOCAL_BODY_READ_TIMEOUT, read_loop) + .await + .map_err(|_| miette::miette!("policy.local request body read timed out"))??; + + Ok(body) +} + +fn parse_content_length(headers: &[u8]) -> Result { + let headers = String::from_utf8_lossy(headers); + for line in headers.lines().skip(1) { + if let Some((name, value)) = line.split_once(':') + && name.eq_ignore_ascii_case("content-length") + { + return value + .trim() + .parse::() + .into_diagnostic() + .map_err(|_| miette::miette!("invalid policy.local Content-Length")); + } + } + Ok(0) +} + +fn find_header_end(buf: &[u8]) -> Option { + buf.windows(4) + .position(|window| window == b"\r\n\r\n") + .map(|idx| idx + 4) +} + +async fn write_json_response( + client: &mut S, + status: u16, + payload: serde_json::Value, +) -> Result<()> +where + S: AsyncWrite + Unpin, +{ + let body = payload.to_string(); + let response = format!( + "HTTP/1.1 {status} {}\r\n\ + Content-Type: application/json\r\n\ + Content-Length: {}\r\n\ + Connection: close\r\n\ + \r\n\ + {}", + status_text(status), + body.len(), + body + ); + client + .write_all(response.as_bytes()) + .await + .into_diagnostic()?; + client.flush().await.into_diagnostic()?; + Ok(()) +} + +fn status_text(status: u16) -> &'static str { + match status { + 202 => "Accepted", + 400 => "Bad Request", + 404 => "Not Found", + 500 => "Internal Server Error", + 502 => "Bad Gateway", + 503 => "Service Unavailable", + _ => "OK", + } +} + +fn error_payload(error: &str, detail: String) -> serde_json::Value { + serde_json::json!({ + "error": error, + "detail": detail + }) +} + +#[derive(Debug, Deserialize)] +struct ProposalRequest { + #[serde(default)] + intent_summary: Option, + #[serde(default)] + operations: Vec, +} + +#[derive(Debug, Deserialize)] +struct AddNetworkRuleJson { + #[serde(default, rename = "ruleName")] + rule_name: Option, + rule: NetworkPolicyRuleJson, +} + +#[derive(Debug, Deserialize)] +struct NetworkPolicyRuleJson { + #[serde(default)] + name: Option, + #[serde(default)] + endpoints: Vec, + #[serde(default)] + binaries: Vec, +} + +#[derive(Debug, Deserialize)] +struct NetworkEndpointJson { + host: String, + #[serde(default)] + port: u32, + #[serde(default)] + ports: Vec, + #[serde(default)] + protocol: String, + #[serde(default)] + tls: String, + #[serde(default)] + enforcement: String, + #[serde(default)] + access: String, + #[serde(default)] + rules: Vec, + #[serde(default)] + allowed_ips: Vec, + #[serde(default)] + deny_rules: Vec, + #[serde(default)] + allow_encoded_slash: bool, +} + +#[derive(Debug, Deserialize)] +struct NetworkBinaryJson { + path: String, +} + +#[derive(Debug, Deserialize)] +struct L7RuleJson { + allow: L7AllowJson, +} + +#[derive(Debug, Deserialize)] +struct L7AllowJson { + #[serde(default)] + method: String, + #[serde(default)] + path: String, + #[serde(default)] + command: String, +} + +#[derive(Debug, Deserialize)] +struct L7DenyRuleJson { + #[serde(default)] + method: String, + #[serde(default)] + path: String, + #[serde(default)] + command: String, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn proposal_chunks_from_body_accepts_add_rule_operation() { + let body = br#"{ + "intent_summary": "Allow gh to create one repo.", + "operations": [ + { + "addRule": { + "ruleName": "github_api_repo_create", + "rule": { + "endpoints": [ + { + "host": "api.github.com", + "port": 443, + "protocol": "rest", + "tls": "terminate", + "enforcement": "enforce", + "rules": [ + { + "allow": { + "method": "POST", + "path": "/user/repos" + } + } + ] + } + ], + "binaries": [ + { + "path": "/usr/bin/gh" + } + ] + } + } + } + ] + }"#; + + let chunks = proposal_chunks_from_body(body).unwrap(); + + assert_eq!(chunks.len(), 1); + assert_eq!(chunks[0].rule_name, "github_api_repo_create"); + assert_eq!(chunks[0].rationale, "Allow gh to create one repo."); + assert_eq!(chunks[0].binary, "/usr/bin/gh"); + let rule = chunks[0].proposed_rule.as_ref().unwrap(); + assert_eq!(rule.name, "github_api_repo_create"); + assert_eq!(rule.endpoints[0].host, "api.github.com"); + assert_eq!(rule.endpoints[0].port, 443); + assert_eq!(rule.endpoints[0].ports, vec![443]); + assert_eq!(rule.endpoints[0].protocol, "rest"); + assert_eq!( + rule.endpoints[0].rules[0].allow.as_ref().unwrap().path, + "/user/repos" + ); + } + + #[test] + fn proposal_chunks_from_body_rejects_query_in_l7_path() { + let body = br#"{ + "operations": [ + { + "addRule": { + "ruleName": "bad", + "rule": { + "endpoints": [ + { + "host": "api.github.com", + "port": 443, + "rules": [ + { + "allow": { + "method": "GET", + "path": "/repos?token=secret" + } + } + ] + } + ] + } + } + } + ] + }"#; + + let error = proposal_chunks_from_body(body).unwrap_err(); + assert!(error.contains("query strings")); + assert!(!error.contains("secret")); + } + + #[test] + fn parse_last_query_clamps_to_max() { + assert_eq!(parse_last_query("last=5"), Some(5)); + assert_eq!(parse_last_query("foo=bar&last=20"), Some(20)); + assert_eq!(parse_last_query("last=999"), Some(MAX_DENIALS_LIMIT)); + assert_eq!(parse_last_query("last=0"), Some(1)); + assert_eq!(parse_last_query(""), None); + assert_eq!(parse_last_query("other=1"), None); + } + + #[test] + fn is_ocsf_denial_line_filters_correctly() { + // OCSF denial — match. + assert!(is_ocsf_denial_line( + "2026-05-06T17:02:00.000Z OCSF HTTP:PUT [MED] DENIED PUT http://api.github.com:443/x [policy:p engine:l7]" + )); + assert!(is_ocsf_denial_line( + "2026-05-06T17:02:00.000Z OCSF NET:OPEN [MED] DENIED curl(42) -> blocked.com:443 [policy:- engine:opa]" + )); + + // OCSF allowed — must not match. + assert!(!is_ocsf_denial_line( + "2026-05-06T17:02:00.000Z OCSF NET:OPEN [INFO] ALLOWED curl(42) -> api.example.com:443" + )); + + // Non-OCSF tracing line — must not match even if it contains the word DENIED. + assert!(!is_ocsf_denial_line( + "2026-05-06T17:02:00.000Z INFO some::module: request DENIED in upstream" + )); + + // Empty line — must not match. + assert!(!is_ocsf_denial_line("")); + } + + #[tokio::test] + async fn recent_denials_returns_newest_first_from_shorthand_lines() { + let dir = tempfile::tempdir().unwrap(); + let log_path = dir.path().join("openshell.2026-05-06.log"); + // Mixed file: allowed events, non-OCSF info lines, two denials. + // Lines are written in chronological order; reader walks newest-first. + let body = "\ +2026-05-06T17:02:00.000Z OCSF NET:OPEN [INFO] ALLOWED curl(10) -> api.example.com:443 [policy:default engine:opa] +2026-05-06T17:02:01.000Z INFO some::module: routine status check +2026-05-06T17:02:02.000Z OCSF HTTP:GET [MED] DENIED GET http://blocked.example/v1/data [policy:default-deny engine:l7] +2026-05-06T17:02:03.000Z OCSF NET:OPEN [INFO] ALLOWED curl(11) -> api.example.com:443 +2026-05-06T17:02:04.000Z OCSF HTTP:PUT [MED] DENIED PUT http://api.github.com:443/repos/x/y/contents/z [policy:gh_readonly engine:l7] +"; + std::fs::write(&log_path, body).unwrap(); + + let ctx = PolicyLocalContext::with_log_dir(None, None, None, dir.path().to_path_buf()); + let (status, payload) = recent_denials_response(&ctx, "last=10").await; + assert_eq!(status, 200); + assert_eq!(payload["log_available"], true); + let denials = payload["denials"].as_array().unwrap(); + assert_eq!(denials.len(), 2); + // Newest first. + assert!(denials[0].as_str().unwrap().contains("HTTP:PUT")); + assert!( + denials[0] + .as_str() + .unwrap() + .contains("/repos/x/y/contents/z") + ); + assert!(denials[1].as_str().unwrap().contains("HTTP:GET")); + assert!(denials[1].as_str().unwrap().contains("blocked.example")); + } + + #[tokio::test] + async fn recent_denials_skips_jsonl_log_files() { + // The shorthand reader must not surface `openshell-ocsf.*.log` content + // even if a deny-looking line is present, so the response stays + // independent of the JSONL appender's enabled state. + let dir = tempfile::tempdir().unwrap(); + let jsonl = dir.path().join("openshell-ocsf.2026-05-06.log"); + std::fs::write( + &jsonl, + r#"{"class_uid":4002,"action_id":2,"message":"DENIED","time":1}"#, + ) + .unwrap(); + + let ctx = PolicyLocalContext::with_log_dir(None, None, None, dir.path().to_path_buf()); + let (status, payload) = recent_denials_response(&ctx, "").await; + assert_eq!(status, 200); + assert_eq!(payload["log_available"], false); + assert_eq!(payload["denials"].as_array().unwrap().len(), 0); + } + + #[tokio::test] + async fn recent_denials_signals_when_log_is_missing() { + let dir = tempfile::tempdir().unwrap(); + let ctx = PolicyLocalContext::with_log_dir(None, None, None, dir.path().to_path_buf()); + let (status, payload) = recent_denials_response(&ctx, "").await; + assert_eq!(status, 200); + assert_eq!(payload["log_available"], false); + assert_eq!(payload["denials"].as_array().unwrap().len(), 0); + assert!( + payload["note"] + .as_str() + .unwrap() + .contains("/var/log/openshell.") + ); + } + + #[test] + fn redact_query_strings_removes_query_from_url_token() { + let line = "2026-05-06T17:02:00.000Z OCSF HTTP:PUT [MED] DENIED PUT http://api.github.com/x?access_token=secret-token-1234 [policy:p engine:l7]"; + let redacted = redact_query_strings(line); + assert!(!redacted.contains("secret-token-1234")); + assert!(!redacted.contains("access_token")); + assert!(redacted.contains("?[redacted]")); + // Bracketed tag after the URL preserved. + assert!(redacted.contains("[policy:p engine:l7]")); + } + + #[test] + fn redact_query_strings_removes_query_in_reason_tag() { + // The FORWARD deny path's `message` becomes `[reason:...]` and may + // include a path with query string lacking a `://` prefix. + let line = "2026-05-06T17:02:00.000Z OCSF HTTP:PUT [MED] DENIED PUT http://api.github.com/x [policy:p engine:opa] [reason:FORWARD denied PUT api.github.com:443/x?token=secret-456]"; + let redacted = redact_query_strings(line); + assert!(!redacted.contains("secret-456")); + assert!(!redacted.contains("token=secret")); + assert!(redacted.contains("?[redacted]]")); + } + + #[test] + fn redact_query_strings_handles_multibyte_chars() { + let line = "ÜLÅUTF8 ? secret-x [policy:p]"; + // No `?` here, so no redaction — but must not panic. + let _ = redact_query_strings(line); + } + + #[test] + fn truncate_at_char_boundary_does_not_panic_on_multibyte() { + // 4-byte emoji sequence so byte-naive slicing would panic. + let s = "🚀".repeat(2000); // 8000 bytes + let truncated = truncate_at_char_boundary(&s, 4096); + assert!(truncated.len() <= 4096 + "...[truncated]".len()); + assert!(truncated.ends_with("...[truncated]")); + // Result must be valid UTF-8 — implicit if we return without panic. + } + + #[tokio::test] + async fn recent_denials_truncates_pathological_lines() { + let dir = tempfile::tempdir().unwrap(); + let log_path = dir.path().join("openshell.2026-05-06.log"); + // A single OCSF denial line exceeding MAX_DENIAL_LINE_BYTES. + let huge_path = "/".to_string() + &"a".repeat(MAX_DENIAL_LINE_BYTES + 100); + let line = format!( + "2026-05-06T17:02:00.000Z OCSF HTTP:PUT [MED] DENIED PUT http://x{huge_path} [policy:p engine:l7]\n" + ); + std::fs::write(&log_path, line).unwrap(); + + let ctx = PolicyLocalContext::with_log_dir(None, None, None, dir.path().to_path_buf()); + let (_, payload) = recent_denials_response(&ctx, "last=1").await; + let denials = payload["denials"].as_array().unwrap(); + assert_eq!(denials.len(), 1); + let surfaced = denials[0].as_str().unwrap(); + assert!(surfaced.len() <= MAX_DENIAL_LINE_BYTES + "...[truncated]".len()); + assert!(surfaced.ends_with("...[truncated]")); + } + + #[tokio::test] + async fn current_policy_route_returns_yaml_envelope() { + let ctx = PolicyLocalContext::new( + Some(ProtoSandboxPolicy { + version: 1, + ..Default::default() + }), + None, + None, + ); + + let (mut client, mut server) = tokio::io::duplex(4096); + let request = + b"GET http://policy.local/v1/policy/current HTTP/1.1\r\nHost: policy.local\r\n\r\n"; + let task = tokio::spawn(async move { + handle_forward_request(&ctx, "GET", "/v1/policy/current", request, &mut server) + .await + .unwrap(); + }); + + let mut received = Vec::new(); + client.read_to_end(&mut received).await.unwrap(); + task.await.unwrap(); + + let response = String::from_utf8(received).unwrap(); + assert!(response.starts_with("HTTP/1.1 200 OK")); + let (_, body) = response.split_once("\r\n\r\n").unwrap(); + let body: serde_json::Value = serde_json::from_str(body).unwrap(); + assert_eq!(body["format"], "yaml"); + assert!(body["policy_yaml"].as_str().unwrap().contains("version: 1")); + } +} diff --git a/crates/openshell-sandbox/src/proxy.rs b/crates/openshell-sandbox/src/proxy.rs index 5344374ac..2bdcbcdcf 100644 --- a/crates/openshell-sandbox/src/proxy.rs +++ b/crates/openshell-sandbox/src/proxy.rs @@ -8,6 +8,7 @@ use crate::identity::BinaryIdentityCache; use crate::l7::tls::ProxyTlsState; use crate::opa::{NetworkAction, OpaEngine, PolicyGenerationGuard}; use crate::policy::ProxyPolicy; +use crate::policy_local::{POLICY_LOCAL_HOST, PolicyLocalContext}; use crate::secrets::{SecretResolver, rewrite_header_line}; use miette::{IntoDiagnostic, Result}; use openshell_core::net::{is_always_blocked_ip, is_internal_ip}; @@ -147,7 +148,7 @@ impl ProxyHandle { /// The proxy uses OPA for network decisions with process-identity binding /// via `/proc/net/tcp`. All connections are evaluated through OPA policy. #[allow(clippy::too_many_arguments)] - pub async fn start_with_bind_addr( + pub(crate) async fn start_with_bind_addr( policy: &ProxyPolicy, bind_addr: Option, opa_engine: Arc, @@ -155,6 +156,7 @@ impl ProxyHandle { entrypoint_pid: Arc, tls_state: Option>, inference_ctx: Option>, + policy_local_ctx: Option>, secret_resolver: Option>, denial_tx: Option>, ) -> Result { @@ -194,11 +196,20 @@ impl ProxyHandle { let spid = entrypoint_pid.clone(); let tls = tls_state.clone(); let inf = inference_ctx.clone(); + let policy_local = policy_local_ctx.clone(); let resolver = secret_resolver.clone(); let dtx = denial_tx.clone(); tokio::spawn(async move { if let Err(err) = handle_tcp_connection( - stream, opa, cache, spid, tls, inf, resolver, dtx, + stream, + opa, + cache, + spid, + tls, + inf, + policy_local, + resolver, + dtx, ) .await { @@ -313,6 +324,7 @@ async fn handle_tcp_connection( entrypoint_pid: Arc, tls_state: Option>, inference_ctx: Option>, + policy_local_ctx: Option>, secret_resolver: Option>, denial_tx: Option>, ) -> Result<()> { @@ -357,6 +369,7 @@ async fn handle_tcp_connection( opa_engine, identity_cache, entrypoint_pid, + policy_local_ctx, secret_resolver, denial_tx.as_ref(), ) @@ -2408,6 +2421,7 @@ async fn handle_forward_proxy( opa_engine: Arc, identity_cache: Arc, entrypoint_pid: Arc, + policy_local_ctx: Option>, secret_resolver: Option>, denial_tx: Option<&mpsc::UnboundedSender>, ) -> Result<()> { @@ -2431,6 +2445,38 @@ async fn handle_forward_proxy( }; let host_lc = host.to_ascii_lowercase(); + if host_lc == POLICY_LOCAL_HOST { + if scheme != "http" || port != 80 { + respond( + client, + &build_json_error_response( + 400, + "Bad Request", + "invalid_policy_local_scheme", + "Use http://policy.local only", + ), + ) + .await?; + return Ok(()); + } + if let Some(ctx) = policy_local_ctx { + return crate::policy_local::handle_forward_request( + &ctx, + method, + &path, + &buf[..used], + client, + ) + .await; + } + respond( + client, + b"HTTP/1.1 503 Service Unavailable\r\nContent-Length: 31\r\n\r\npolicy.local is not configured", + ) + .await?; + return Ok(()); + } + // 2. Reject HTTPS — must use CONNECT for TLS if scheme == "https" { { diff --git a/crates/openshell-sandbox/src/skills.rs b/crates/openshell-sandbox/src/skills.rs new file mode 100644 index 000000000..91654699f --- /dev/null +++ b/crates/openshell-sandbox/src/skills.rs @@ -0,0 +1,63 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Static agent guidance files exposed inside the sandbox. + +use miette::{IntoDiagnostic, Result}; +use std::path::{Path, PathBuf}; + +const SKILLS_RELATIVE_DIR: &str = "etc/openshell/skills"; +const POLICY_ADVISOR_FILE: &str = "policy_advisor.md"; +const POLICY_ADVISOR_CONTENT: &str = include_str!("skills/policy_advisor.md"); + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct InstalledSkills { + pub policy_advisor: PathBuf, +} + +pub fn install_static_skills() -> Result { + install_static_skills_at(Path::new("/")) +} + +fn install_static_skills_at(root: &Path) -> Result { + let skills_dir = root.join(SKILLS_RELATIVE_DIR); + std::fs::create_dir_all(&skills_dir).into_diagnostic()?; + + let policy_advisor = skills_dir.join(POLICY_ADVISOR_FILE); + std::fs::write(&policy_advisor, POLICY_ADVISOR_CONTENT).into_diagnostic()?; + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + + std::fs::set_permissions(&policy_advisor, std::fs::Permissions::from_mode(0o444)) + .into_diagnostic()?; + } + + Ok(InstalledSkills { policy_advisor }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn install_static_skills_at_writes_policy_advisor() { + let dir = tempfile::tempdir().unwrap(); + + let installed = install_static_skills_at(dir.path()).unwrap(); + + let expected = dir + .path() + .join("etc") + .join("openshell") + .join("skills") + .join("policy_advisor.md"); + assert_eq!(installed.policy_advisor, expected); + + let content = std::fs::read_to_string(expected).unwrap(); + assert!(content.contains("# OpenShell Policy Advisor")); + assert!(content.contains("policy.local")); + assert!(content.contains("addRule")); + } +} diff --git a/crates/openshell-sandbox/src/skills/policy_advisor.md b/crates/openshell-sandbox/src/skills/policy_advisor.md new file mode 100644 index 000000000..57546145c --- /dev/null +++ b/crates/openshell-sandbox/src/skills/policy_advisor.md @@ -0,0 +1,100 @@ +# OpenShell Policy Advisor + +Use this when OpenShell blocks a network request and the response or logs say +`policy_denied`. + +## Goal + +Draft the smallest policy proposal that allows the user's current task without +giving the sandbox broad new network access. The developer approves or rejects +the proposal; do not try to bypass policy. + +## Local API + +The sandbox-local policy API is reachable at `http://policy.local`: + +- `GET /v1/policy/current` — current effective policy as YAML. +- `GET /v1/denials?last=10` — most recent network/L7 denials seen by this + sandbox (newest first), returned as raw shorthand log lines. Each line + carries the timestamp, class, severity, action, host/port, binary, policy + name, and (for denied events) a short reason. Read the lines directly; you + do not need to parse them into structured fields. +- `POST /v1/proposals` — submit a proposal for developer approval. + +The proposal body takes an `intent_summary` and one or more `addRule` +operations. Each `addRule` carries a complete narrow `NetworkPolicyRule`. + +## Workflow + +1. Read the denial response body. Use `layer`, `method`, `path`, `host`, + `port`, `binary`, `rule_missing`, and `detail` as evidence. +2. Fetch the current policy from `/v1/policy/current`. +3. Fetch recent denials from `/v1/denials` if the response body is incomplete. +4. Prefer L7 REST rules for REST APIs. Use L4 only for non-REST protocols or + when the client tunnels opaque traffic that OpenShell cannot inspect. +5. Draft the narrowest rule: exact host, exact port, exact binary when known, + exact method, and the smallest safe path. +6. Submit the proposal, tell the developer what you proposed, and retry the + denied action only after approval. + +## Proposal shape + +A complete narrow REST-inspected rule looks like this: + +```json +{ + "intent_summary": "Allow gh to update repository contents in NVIDIA/OpenShell only.", + "operations": [ + { + "addRule": { + "ruleName": "github_api_repo_contents_write", + "rule": { + "name": "github_api_repo_contents_write", + "endpoints": [ + { + "host": "api.github.com", + "port": 443, + "protocol": "rest", + "enforcement": "enforce", + "rules": [ + { + "allow": { + "method": "PUT", + "path": "/repos/NVIDIA/OpenShell/contents/**" + } + } + ] + } + ], + "binaries": [ + { + "path": "/usr/bin/gh" + } + ] + } + } + } + ] +} +``` + +## Norms + +- Do not propose wildcard hosts such as `**` or `*.com`. +- Do not propose `access: full` to fix a single denied REST request. +- Do not include query strings, tokens, credentials, or secret values in + paths. +- Explain uncertainty in `intent_summary` instead of widening the rule. +- If pushing with `git` fails, that is a separate L4 or protocol-specific + path from GitHub REST API access. Propose it separately. + +## Local logs (read-only) + +Two local files complement the API and are useful when debugging policy +behavior: + +- `/var/log/openshell.YYYY-MM-DD.log` — shorthand log of sandbox activity. + This is what `/v1/denials` reads from. +- `/var/log/openshell-ocsf.YYYY-MM-DD.log` — full OCSF JSON events, only + written when the `ocsf_json_enabled` setting is on. Not used by + `/v1/denials`; useful for SIEM ingestion. diff --git a/e2e/policy-advisor/README.md b/e2e/policy-advisor/README.md new file mode 100644 index 000000000..b9796e1a6 --- /dev/null +++ b/e2e/policy-advisor/README.md @@ -0,0 +1,29 @@ + + + +# Policy Advisor end-to-end test + +Deterministic, no-LLM exercise of the agent-driven policy loop: + +1. Start a sandbox with a read-only GitHub L7 policy. +2. From inside the sandbox, attempt a GitHub contents PUT and assert OpenShell + returns a structured `policy_denied` 403. +3. Submit a narrow `addRule` proposal through `http://policy.local/v1/proposals`. +4. Approve the draft from the host and retry until the write succeeds. + +This proves the proxy, the structured deny body, the `policy.local` HTTP API, +the gateway proposal path, and the hot-reload of approved rules — without +involving an LLM. The user-facing demo (`examples/agent-driven-policy-management/`) +runs the same loop with Codex driving from inside the sandbox. + +## Run it + +```bash +DEMO_GITHUB_OWNER= \ +DEMO_GITHUB_REPO=openshell-policy-demo \ +bash e2e/policy-advisor/test.sh +``` + +Requires an active OpenShell gateway (`openshell gateway start`) and a GitHub +token with contents write on the repository (auto-resolved from `gh auth token`, +`GITHUB_TOKEN`, or `GH_TOKEN`). diff --git a/e2e/policy-advisor/policy.template.yaml b/e2e/policy-advisor/policy.template.yaml new file mode 100644 index 000000000..6452cb01c --- /dev/null +++ b/e2e/policy-advisor/policy.template.yaml @@ -0,0 +1,28 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +version: 1 + +filesystem_policy: + include_workdir: true + read_only: [/usr, /lib, /proc, /dev/urandom, /app, /etc, /var/log] + read_write: [/sandbox, /tmp, /dev/null] + +landlock: + compatibility: best_effort + +process: + run_as_user: sandbox + run_as_group: sandbox + +network_policies: + github_api_readonly: + name: github-api-readonly + endpoints: + - host: api.github.com + port: 443 + protocol: rest + enforcement: enforce + access: read-only + binaries: + - { path: /usr/bin/curl } diff --git a/e2e/policy-advisor/sandbox-runner.sh b/e2e/policy-advisor/sandbox-runner.sh new file mode 100755 index 000000000..780e85573 --- /dev/null +++ b/e2e/policy-advisor/sandbox-runner.sh @@ -0,0 +1,142 @@ +#!/usr/bin/env bash + +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +set -euo pipefail + +cmd="$1" +shift + +json_status_response() { + local status="$1" + local body="$2" + printf 'HTTP_STATUS=%s\n' "$status" + cat "$body" + printf '\n' +} + +case "$cmd" in + check-skill) + test -f /etc/openshell/skills/policy_advisor.md + sed -n '1,40p' /etc/openshell/skills/policy_advisor.md + ;; + + current-policy) + body="$(mktemp)" + status="$(curl -sS -o "$body" -w "%{http_code}" http://policy.local/v1/policy/current)" + json_status_response "$status" "$body" + ;; + + put-file) + owner="$1" + repo="$2" + branch="$3" + file_path="$4" + run_id="$5" + body="$(mktemp)" + payload="$(mktemp)" + + python3 - "$branch" "$run_id" > "$payload" <<'PY' +import base64 +import json +import sys + +branch, run_id = sys.argv[1:3] +content = f"""# OpenShell policy advisor demo + +Run id: {run_id} + +This file was written from inside an OpenShell sandbox after an agent-authored +policy proposal was approved. +""" + +payload = { + "message": f"docs: add OpenShell policy advisor demo note {run_id}", + "branch": branch, + "content": base64.b64encode(content.encode("utf-8")).decode("ascii"), +} +print(json.dumps(payload)) +PY + + status="$(curl -sS \ + -o "$body" \ + -w "%{http_code}" \ + -X PUT \ + -H "Accept: application/vnd.github+json" \ + -H "Authorization: Bearer ${GITHUB_TOKEN}" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + -H "Content-Type: application/json" \ + --data-binary "@${payload}" \ + "https://api.github.com/repos/${owner}/${repo}/contents/${file_path}")" + json_status_response "$status" "$body" + ;; + + submit-proposal) + owner="$1" + repo="$2" + file_path="$3" + body="$(mktemp)" + payload="$(mktemp)" + + python3 - "$owner" "$repo" "$file_path" > "$payload" <<'PY' +import json +import sys + +owner, repo, file_path = sys.argv[1:4] +rule_path = f"/repos/{owner}/{repo}/contents/{file_path}" +payload = { + "intent_summary": ( + "Allow curl to write the demo note to " + f"{owner}/{repo} at {file_path} only." + ), + "operations": [ + { + "addRule": { + "ruleName": "github_api_demo_contents_write", + "rule": { + "name": "github_api_demo_contents_write", + "endpoints": [ + { + "host": "api.github.com", + "port": 443, + "protocol": "rest", + "enforcement": "enforce", + "rules": [ + { + "allow": { + "method": "PUT", + "path": rule_path, + } + } + ], + } + ], + "binaries": [ + { + "path": "/usr/bin/curl", + } + ], + }, + } + } + ], +} +print(json.dumps(payload)) +PY + + status="$(curl -sS \ + -o "$body" \ + -w "%{http_code}" \ + -X POST \ + -H "Content-Type: application/json" \ + --data-binary "@${payload}" \ + http://policy.local/v1/proposals)" + json_status_response "$status" "$body" + ;; + + *) + echo "unknown command: $cmd" >&2 + exit 64 + ;; +esac diff --git a/e2e/policy-advisor/test.sh b/e2e/policy-advisor/test.sh new file mode 100755 index 000000000..8f956eb0e --- /dev/null +++ b/e2e/policy-advisor/test.sh @@ -0,0 +1,385 @@ +#!/usr/bin/env bash + +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" +POLICY_TEMPLATE="${SCRIPT_DIR}/policy.template.yaml" +RUNNER_SOURCE="${SCRIPT_DIR}/sandbox-runner.sh" + +if [[ -z "${OPENSHELL_BIN:-}" ]]; then + if [[ -x "${REPO_ROOT}/target/debug/openshell" ]]; then + OPENSHELL_BIN="${REPO_ROOT}/target/debug/openshell" + else + OPENSHELL_BIN="openshell" + fi +fi + +DEMO_BRANCH="${DEMO_BRANCH:-main}" +DEMO_RUN_ID="${DEMO_RUN_ID:-$(date +%Y%m%d-%H%M%S)}" +DEMO_FILE_DIR="${DEMO_FILE_DIR:-openshell-policy-advisor-validation}" +DEMO_FILE_PATH="${DEMO_FILE_PATH:-${DEMO_FILE_DIR}/${DEMO_RUN_ID}.md}" +DEMO_SANDBOX_NAME="${DEMO_SANDBOX_NAME:-policy-agent-validation-${DEMO_RUN_ID}}" +DEMO_GITHUB_PROVIDER_NAME="${DEMO_GITHUB_PROVIDER_NAME:-github-policy-validation-${DEMO_RUN_ID}}" +DEMO_KEEP_SANDBOX="${DEMO_KEEP_SANDBOX:-0}" +DEMO_RETRY_ATTEMPTS="${DEMO_RETRY_ATTEMPTS:-30}" +DEMO_RETRY_SLEEP="${DEMO_RETRY_SLEEP:-2}" + +TMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/openshell-agent-policy.XXXXXX")" +POLICY_FILE="${TMP_DIR}/policy.yaml" +SSH_CONFIG="${TMP_DIR}/ssh_config" +SSH_HOST="" + +BOLD='\033[1m' +DIM='\033[2m' +CYAN='\033[36m' +GREEN='\033[32m' +RED='\033[31m' +YELLOW='\033[33m' +RESET='\033[0m' + +step() { + printf "\n${BOLD}${CYAN}==> %s${RESET}\n\n" "$1" +} + +info() { + printf " %b\n" "$*" +} + +fail() { + printf "\n${RED}error:${RESET} %s\n" "$*" >&2 + exit 1 +} + +cleanup() { + local status=$? + + if [[ "$DEMO_KEEP_SANDBOX" != "1" ]]; then + "$OPENSHELL_BIN" sandbox delete "$DEMO_SANDBOX_NAME" >/dev/null 2>&1 || true + else + printf "\n${YELLOW}Keeping sandbox because DEMO_KEEP_SANDBOX=1: %s${RESET}\n" "$DEMO_SANDBOX_NAME" + fi + + "$OPENSHELL_BIN" provider delete "$DEMO_GITHUB_PROVIDER_NAME" >/dev/null 2>&1 || true + + if [[ $status -eq 0 ]]; then + rm -rf "$TMP_DIR" + else + printf "\n${YELLOW}Temporary files kept at: %s${RESET}\n" "$TMP_DIR" + fi +} +trap cleanup EXIT + +require_command() { + command -v "$1" >/dev/null 2>&1 || fail "missing required command: $1" +} + +validate_name() { + local label="$1" + local value="$2" + [[ "$value" =~ ^[A-Za-z0-9_.-]+$ ]] || fail "$label may contain only letters, numbers, '.', '_', and '-'" +} + +validate_path() { + local label="$1" + local value="$2" + [[ "$value" =~ ^[A-Za-z0-9._/-]+$ ]] || fail "$label may contain only letters, numbers, '.', '_', '-', and '/'" + [[ "$value" != /* ]] || fail "$label must be relative" + [[ "$value" != *..* ]] || fail "$label must not contain '..'" +} + +resolve_token() { + if [[ -z "${DEMO_GITHUB_TOKEN:-}" ]]; then + if [[ -n "${GITHUB_TOKEN:-}" ]]; then + DEMO_GITHUB_TOKEN="$GITHUB_TOKEN" + elif [[ -n "${GH_TOKEN:-}" ]]; then + DEMO_GITHUB_TOKEN="$GH_TOKEN" + elif command -v gh >/dev/null 2>&1; then + DEMO_GITHUB_TOKEN="$(gh auth token 2>/dev/null || true)" + fi + fi + + [[ -n "${DEMO_GITHUB_TOKEN:-}" ]] || fail "set DEMO_GITHUB_TOKEN, GITHUB_TOKEN, GH_TOKEN, or sign in with gh" + export GITHUB_TOKEN="$DEMO_GITHUB_TOKEN" +} + +validate_env() { + require_command curl + require_command jq + require_command ssh + require_command "$OPENSHELL_BIN" + + [[ -f "$RUNNER_SOURCE" ]] || fail "missing sandbox runner: $RUNNER_SOURCE" + [[ -n "${DEMO_GITHUB_OWNER:-}" ]] || fail "set DEMO_GITHUB_OWNER" + [[ -n "${DEMO_GITHUB_REPO:-}" ]] || fail "set DEMO_GITHUB_REPO" + [[ "$DEMO_RUN_ID" =~ ^[a-z0-9-]+$ ]] || fail "DEMO_RUN_ID may contain only lowercase letters, numbers, and '-'" + [[ "$DEMO_RETRY_ATTEMPTS" =~ ^[0-9]+$ ]] || fail "DEMO_RETRY_ATTEMPTS must be a number" + [[ "$DEMO_RETRY_SLEEP" =~ ^[0-9]+$ ]] || fail "DEMO_RETRY_SLEEP must be a number" + + validate_name "DEMO_GITHUB_OWNER" "$DEMO_GITHUB_OWNER" + validate_name "DEMO_GITHUB_REPO" "$DEMO_GITHUB_REPO" + validate_path "DEMO_BRANCH" "$DEMO_BRANCH" + validate_path "DEMO_FILE_PATH" "$DEMO_FILE_PATH" + + resolve_token +} + +github_api_status() { + local url="$1" + local body="$2" + curl -sS \ + -o "$body" \ + -w "%{http_code}" \ + -H "Accept: application/vnd.github+json" \ + -H "Authorization: Bearer ${DEMO_GITHUB_TOKEN}" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + "$url" +} + +check_gateway() { + step "Checking active OpenShell gateway" + if ! "$OPENSHELL_BIN" status >/dev/null 2>&1; then + fail "active OpenShell gateway is not reachable; start one separately, for example: mise run cluster" + fi + "$OPENSHELL_BIN" status | sed 's/^/ /' +} + +check_github_access() { + step "Checking GitHub repository access" + local body status branch branches_body branches_status branches + body="${TMP_DIR}/github-repo.json" + status="$(github_api_status "https://api.github.com/repos/${DEMO_GITHUB_OWNER}/${DEMO_GITHUB_REPO}" "$body")" + + if [[ "$status" != "200" ]]; then + printf '%s\n' "$(jq -r '.message // empty' "$body" 2>/dev/null)" | sed 's/^/ /' + fail "GitHub returned HTTP $status for ${DEMO_GITHUB_OWNER}/${DEMO_GITHUB_REPO}; check the repo name and token access" + fi + + if jq -e 'has("permissions") and (.permissions.push == false and .permissions.admin == false and .permissions.maintain == false)' "$body" >/dev/null; then + fail "GitHub token can read ${DEMO_GITHUB_OWNER}/${DEMO_GITHUB_REPO} but does not appear to have write access" + fi + + branch="$(jq -rn --arg v "$DEMO_BRANCH" '$v|@uri')" + body="${TMP_DIR}/github-branch.json" + status="$(github_api_status "https://api.github.com/repos/${DEMO_GITHUB_OWNER}/${DEMO_GITHUB_REPO}/branches/${branch}" "$body")" + if [[ "$status" != "200" ]]; then + branches_body="${TMP_DIR}/github-branches.json" + branches_status="$(github_api_status "https://api.github.com/repos/${DEMO_GITHUB_OWNER}/${DEMO_GITHUB_REPO}/branches?per_page=20" "$branches_body")" + if [[ "$branches_status" == "200" ]]; then + branches="$(jq -r 'map(.name) | join(", ")' "$branches_body")" + if [[ -z "$branches" ]]; then + fail "GitHub repo exists but has no branches yet; add an initial README or push ${DEMO_BRANCH} before running the demo" + fi + fail "GitHub returned HTTP $status for branch ${DEMO_BRANCH}; set DEMO_BRANCH to one of: ${branches}" + fi + fail "GitHub returned HTTP $status for branch ${DEMO_BRANCH}" + fi + + body="${TMP_DIR}/github-demo-file.json" + status="$(github_api_status "https://api.github.com/repos/${DEMO_GITHUB_OWNER}/${DEMO_GITHUB_REPO}/contents/${DEMO_FILE_PATH}?ref=${branch}" "$body")" + if [[ "$status" == "200" ]]; then + fail "validation output file already exists: ${DEMO_FILE_PATH}; choose a new DEMO_RUN_ID or DEMO_FILE_PATH" + fi + [[ "$status" == "404" ]] || fail "GitHub returned HTTP $status while checking demo output path ${DEMO_FILE_PATH}" + + info "${GREEN}GitHub repo, branch, and output path are safe for this run.${RESET}" +} + +create_provider() { + step "Creating temporary GitHub provider" + "$OPENSHELL_BIN" provider delete "$DEMO_GITHUB_PROVIDER_NAME" >/dev/null 2>&1 || true + "$OPENSHELL_BIN" provider create \ + --name "$DEMO_GITHUB_PROVIDER_NAME" \ + --type github \ + --credential GITHUB_TOKEN +} + +create_sandbox() { + step "Creating sandbox with read-only GitHub L7 policy" + cp "$POLICY_TEMPLATE" "$POLICY_FILE" + "$OPENSHELL_BIN" sandbox delete "$DEMO_SANDBOX_NAME" >/dev/null 2>&1 || true + "$OPENSHELL_BIN" sandbox create \ + --name "$DEMO_SANDBOX_NAME" \ + --provider "$DEMO_GITHUB_PROVIDER_NAME" \ + --policy "$POLICY_FILE" \ + --upload "${RUNNER_SOURCE}:/sandbox/policy-validation-runner.sh" \ + --no-git-ignore \ + --keep \ + --no-auto-providers \ + --no-tty \ + -- bash -lc "chmod +x /sandbox/policy-validation-runner.sh && echo sandbox ready" +} + +connect_ssh() { + step "Connecting to sandbox over SSH" + "$OPENSHELL_BIN" sandbox ssh-config "$DEMO_SANDBOX_NAME" > "$SSH_CONFIG" + SSH_HOST="$(awk '/^Host / { print $2; exit }' "$SSH_CONFIG")" + [[ -n "$SSH_HOST" ]] || fail "could not find Host entry in sandbox SSH config" + + local retries=30 + local i + for i in $(seq 1 "$retries"); do + if ssh -F "$SSH_CONFIG" "$SSH_HOST" true >/dev/null 2>&1; then + return + fi + sleep 2 + done + fail "SSH connection to sandbox timed out" +} + +sandbox_exec() { + ssh -F "$SSH_CONFIG" "$SSH_HOST" "$@" +} + +http_status() { + awk -F= '/^HTTP_STATUS=/ { print $2; exit }' +} + +http_body() { + sed '/^HTTP_STATUS=/d' +} + +run_policy_local_checks() { + step "Checking sandbox-local skill and policy.local" + sandbox_exec /sandbox/policy-validation-runner.sh check-skill >/dev/null + info "${GREEN}Skill installed:${RESET} /etc/openshell/skills/policy_advisor.md" + + local output + output="$(sandbox_exec /sandbox/policy-validation-runner.sh current-policy)" + local status + status="$(printf '%s\n' "$output" | http_status)" + [[ "$status" == "200" ]] || fail "policy.local current policy returned HTTP $status" + + info "${GREEN}policy.local returned the current sandbox policy.${RESET}" + info "Initial policy: read-only REST access to api.github.com for /usr/bin/curl" +} + +attempt_write() { + sandbox_exec /sandbox/policy-validation-runner.sh put-file \ + "$DEMO_GITHUB_OWNER" \ + "$DEMO_GITHUB_REPO" \ + "$DEMO_BRANCH" \ + "$DEMO_FILE_PATH" \ + "$DEMO_RUN_ID" +} + +submit_policy_proposal() { + sandbox_exec /sandbox/policy-validation-runner.sh submit-proposal \ + "$DEMO_GITHUB_OWNER" \ + "$DEMO_GITHUB_REPO" \ + "$DEMO_FILE_PATH" +} + +capture_initial_denial() { + step "Attempting GitHub contents write from inside sandbox" + local output + output="$(attempt_write)" + local status + local body + status="$(printf '%s\n' "$output" | http_status)" + body="$(printf '%s\n' "$output" | http_body)" + + [[ "$status" == "403" ]] || fail "expected OpenShell HTTP 403, got HTTP $status" + printf '%s\n' "$body" | jq -e '.error == "policy_denied"' >/dev/null \ + || fail "expected structured policy_denied body" + printf '%s\n' "$body" | jq -e '.layer == "l7" and .protocol == "rest" and .method == "PUT"' >/dev/null \ + || fail "expected structured L7 REST deny fields" + + printf '%s\n' "$body" | jq -r ' + "Denied: \(.method) \(.path)", + "Layer: \(.layer)/\(.protocol) host=\(.host):\(.port) binary=\(.binary)", + "Agent guidance: \(.next_steps | map(.action) | join(" -> "))" + ' | sed 's/^/ /' + info "${GREEN}Captured structured L7 policy denial.${RESET}" +} + +submit_and_approve() { + step "Submitting proposal through policy.local" + local output + output="$(submit_policy_proposal)" + local status + local body + status="$(printf '%s\n' "$output" | http_status)" + body="$(printf '%s\n' "$output" | http_body)" + + [[ "$status" == "202" ]] || fail "expected proposal submit HTTP 202, got HTTP $status" + [[ "$(printf '%s\n' "$body" | jq -r '.accepted_chunks // 0')" != "0" ]] \ + || fail "proposal was not accepted" + printf '%s\n' "$body" | jq -r '"Proposal submitted: \(.accepted_chunks) accepted, \(.rejected_chunks) rejected"' | sed 's/^/ /' + + step "Approving pending draft rule from outside the sandbox" + "$OPENSHELL_BIN" rule get "$DEMO_SANDBOX_NAME" --status pending | sed 's/^/ /' + "$OPENSHELL_BIN" rule approve-all "$DEMO_SANDBOX_NAME" | sed 's/^/ /' +} + +print_success_summary() { + jq '{ + path: .content.path, + html_url: .content.html_url, + commit: .commit.sha, + message: .commit.message + }' +} + +retry_until_allowed() { + step "Retrying GitHub contents write after approval" + local output status body attempt + + for attempt in $(seq 1 "$DEMO_RETRY_ATTEMPTS"); do + output="$(attempt_write)" + status="$(printf '%s\n' "$output" | http_status)" + body="$(printf '%s\n' "$output" | http_body)" + + if printf '%s\n' "$body" | jq -e '.error == "policy_denied"' >/dev/null 2>&1; then + info "${DIM}Attempt ${attempt}/${DEMO_RETRY_ATTEMPTS}: policy not loaded yet; retrying...${RESET}" + sleep "$DEMO_RETRY_SLEEP" + continue + fi + + if [[ "$status" == "200" || "$status" == "201" ]]; then + printf '%s\n' "$body" | print_success_summary | sed 's/^/ /' + info "${GREEN}GitHub write succeeded from inside the sandbox.${RESET}" + return + fi + + printf '%s\n' "$body" | jq . | sed 's/^/ /' + if [[ "$status" == "404" ]]; then + fail "policy allowed the request, but GitHub returned HTTP 404; check DEMO_GITHUB_OWNER, DEMO_GITHUB_REPO, and token access" + fi + fail "policy allowed the request, but GitHub returned HTTP $status" + done + + fail "timed out waiting for approved policy to load into the sandbox" +} + +show_logs() { + step "Policy decision trace" + "$OPENSHELL_BIN" logs "$DEMO_SANDBOX_NAME" --since 5m -n 50 2>&1 \ + | grep -E 'HTTP:PUT|CONFIG:LOADED|ReportPolicyStatus' \ + | tail -n 8 \ + | sed 's/^/ /' || true +} + +main() { + validate_env + check_gateway + check_github_access + create_provider + create_sandbox + connect_ssh + run_policy_local_checks + capture_initial_denial + submit_and_approve + retry_until_allowed + show_logs + + printf "\n${BOLD}${GREEN}✓ Validation complete.${RESET}\n\n" + printf " Sandbox: %s\n" "$DEMO_SANDBOX_NAME" + printf " Repository: https://github.com/%s/%s\n" "$DEMO_GITHUB_OWNER" "$DEMO_GITHUB_REPO" + printf " File: %s\n" "$DEMO_FILE_PATH" +} + +main "$@" diff --git a/examples/agent-driven-policy-management/README.md b/examples/agent-driven-policy-management/README.md new file mode 100644 index 000000000..7ff9a7780 --- /dev/null +++ b/examples/agent-driven-policy-management/README.md @@ -0,0 +1,87 @@ + + + +# Agent-Driven Policy Management Demo + +Run the full agent-driven policy loop end-to-end: + +1. A Codex agent inside an OpenShell sandbox tries to write a markdown file to + GitHub via the Contents API. +2. OpenShell denies the request with a structured `policy_denied` 403 because + the initial policy only allows read-only access to `api.github.com`. +3. The agent reads `/etc/openshell/skills/policy_advisor.md`, drafts the + narrowest rule needed, and submits it to `http://policy.local/v1/proposals`. +4. You approve the proposal from the host with one keystroke. +5. The sandbox hot-reloads the merged policy and the agent's retry succeeds. + +The whole loop usually finishes in under two minutes. + +## Prerequisites + +- An active OpenShell gateway (`openshell gateway start`). +- `gh auth login` (or a `GITHUB_TOKEN` env var with contents-write on a + scratch repo). +- `codex login` on the host. +- A scratch GitHub repository with at least one commit on the default branch. + If you don't have one yet: + + ```shell + gh repo create "$(gh api user --jq .login)/openshell-policy-demo" \ + --private --add-readme \ + --description "OpenShell policy advisor demo scratch repo" + ``` + +## Run it + +```shell +bash examples/agent-driven-policy-management/demo.sh +``` + +That's the whole thing. The demo resolves your GitHub handle from `gh`, picks +`openshell-policy-demo` as the repo, and writes one timestamped markdown file +under `openshell-policy-advisor-demo/` per run. + +## Overrides (all optional) + +| Env var | Default | +|---|---| +| `DEMO_GITHUB_OWNER` | `gh api user --jq .login` | +| `DEMO_GITHUB_REPO` | `openshell-policy-demo` | +| `DEMO_BRANCH` | `main` | +| `DEMO_RUN_ID` | timestamp | +| `DEMO_GITHUB_TOKEN` | falls back to `GITHUB_TOKEN`, `GH_TOKEN`, or `gh auth token` | +| `DEMO_KEEP_SANDBOX` | `0` (set `1` to inspect the sandbox after the demo) | +| `DEMO_APPROVAL_TIMEOUT_SECS` | `240` | +| `OPENSHELL_BIN` | `target/debug/openshell` if present, else `openshell` on `PATH` | + +## What the agent sees + +`policy.template.yaml` is the initial restrictive policy: a read-only L7 REST +rule for `api.github.com` plus the binary set Codex needs. The agent has to +ask for the additional `PUT /repos/.../contents/...` write itself — that's the +proposal you approve. + +## What gets approved (trust model) + +Every proposal lands in the gateway as a `PolicyChunk` — a structured object +with three parts, each with a different trust level: + +| Field | Source | Trust | +|---|---|---| +| `proposed_rule` (host, port, method, path, binary) | agent, schema-validated by the gateway | structured contract — *this* is what you're approving | +| `rationale` (free-form prose) | agent | hint only — a compromised agent can lie here | +| `validation_result` (prover output) | gateway-side prover | trust signal — but this surface is in progress (see [RFC 0001](../../rfc/0001-agent-driven-policy-management.md)) | + +The MVP today shows the structured rule plus the agent's rationale in +`openshell rule get` and the TUI inbox panel. The demo's `openshell rule +approve-all` auto-approves to keep the loop short — in a real session a +developer reviews the structured grant before pressing `a`. Prover-backed +validation badges, computed reachability deltas, and a richer "this is what +the rule actually permits" summary are the next phase. For now, **always +approve based on the structured rule, not the agent's rationale.** + +## Going further + +`e2e/policy-advisor/test.sh` runs the same loop deterministically without an +LLM (curl + the `policy.local` API directly). Use it to validate the proxy and +proposal pipeline when iterating on the sandbox or gateway code. diff --git a/examples/agent-driven-policy-management/agent-task.md b/examples/agent-driven-policy-management/agent-task.md new file mode 100644 index 000000000..6aa56b5a3 --- /dev/null +++ b/examples/agent-driven-policy-management/agent-task.md @@ -0,0 +1,48 @@ + + + +You are running inside an OpenShell sandbox. Your job is to write one +markdown file to GitHub via the GitHub Contents API. + +## Target + +- Repository: `{{OWNER}}/{{REPO}}` +- Branch: `{{BRANCH}}` +- File path: `{{FILE_PATH}}` +- Run id: `{{RUN_ID}}` + +## What to do + +1. PUT the file with `curl` against + `https://api.github.com/repos/{{OWNER}}/{{REPO}}/contents/{{FILE_PATH}}`. + Authenticate with the `GITHUB_TOKEN` environment variable. Do not echo the + token. Body content (base64-encoded into the JSON `content` field): + + ``` + # OpenShell policy advisor demo + + Run id: {{RUN_ID}} + + Written from inside an OpenShell sandbox after a narrowly-scoped policy + proposal was approved by the developer. + ``` + +2. The first attempt will fail with HTTP 403 and a JSON body containing + `"error": "policy_denied"`. Read the body — it tells you which layer denied + you (`l7`/`rest`), which method/path was missing, and where to learn more. + +3. Read `/etc/openshell/skills/policy_advisor.md` and follow it. Submit the + narrowest possible proposal to `http://policy.local/v1/proposals` — exact + host, exact port, exact method, exact path, binary `/usr/bin/curl`. Do not + include query strings. Do not propose wildcard hosts. + +4. After submitting, retry the PUT every few seconds for up to 120 seconds. + The developer is approving from outside the sandbox; once approved, the + sandbox hot-reloads policy and the same PUT will succeed. + +5. Stop as soon as the PUT returns HTTP 200 or 201. Print a short summary + showing whether it succeeded, plus `content.path` and `content.html_url` + from the GitHub response. Do not print the full response body. + +If anything is unclear, prefer making a narrower proposal and asking for +approval again over widening the rule. diff --git a/examples/agent-driven-policy-management/demo.sh b/examples/agent-driven-policy-management/demo.sh new file mode 100755 index 000000000..bbec37612 --- /dev/null +++ b/examples/agent-driven-policy-management/demo.sh @@ -0,0 +1,414 @@ +#!/usr/bin/env bash + +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Agent-driven policy management demo. +# +# Runs the full loop: a Codex agent inside a sandbox hits an OpenShell policy +# block, reads the policy advisor skill, drafts a narrow rule via policy.local, +# the developer approves from the host, and the agent retries successfully. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" +POLICY_TEMPLATE="${SCRIPT_DIR}/policy.template.yaml" +TASK_TEMPLATE="${SCRIPT_DIR}/agent-task.md" +SANDBOX_AGENT="${SCRIPT_DIR}/sandbox-agent.sh" + +OPENSHELL_BIN="${OPENSHELL_BIN:-}" +if [[ -z "$OPENSHELL_BIN" ]]; then + if [[ -x "${REPO_ROOT}/target/debug/openshell" ]]; then + OPENSHELL_BIN="${REPO_ROOT}/target/debug/openshell" + else + OPENSHELL_BIN="openshell" + fi +fi + +DEMO_GITHUB_OWNER="${DEMO_GITHUB_OWNER:-}" +DEMO_GITHUB_REPO="${DEMO_GITHUB_REPO:-openshell-policy-demo}" +DEMO_BRANCH="${DEMO_BRANCH:-main}" +DEMO_RUN_ID="${DEMO_RUN_ID:-$(date +%Y%m%d-%H%M%S)}" +DEMO_FILE_DIR="${DEMO_FILE_DIR:-openshell-policy-advisor-demo}" +DEMO_FILE_PATH="${DEMO_FILE_DIR}/${DEMO_RUN_ID}.md" +DEMO_SANDBOX_NAME="${DEMO_SANDBOX_NAME:-policy-demo-${DEMO_RUN_ID}}" +DEMO_CODEX_PROVIDER_NAME="${DEMO_CODEX_PROVIDER_NAME:-codex-policy-demo-${DEMO_RUN_ID}}" +DEMO_GITHUB_PROVIDER_NAME="${DEMO_GITHUB_PROVIDER_NAME:-github-policy-demo-${DEMO_RUN_ID}}" +DEMO_APPROVAL_TIMEOUT_SECS="${DEMO_APPROVAL_TIMEOUT_SECS:-240}" +DEMO_KEEP_SANDBOX="${DEMO_KEEP_SANDBOX:-0}" + +TMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/openshell-policy-demo.XXXXXX")" +PAYLOAD_DIR="${TMP_DIR}/payload" +POLICY_FILE="${TMP_DIR}/policy.yaml" +AGENT_LOG="${TMP_DIR}/agent.log" +mkdir -p "$PAYLOAD_DIR" + +# Use ANSI-C quoting so the variables hold the actual ESC byte rather than a +# literal backslash sequence. This lets `cat`, heredocs, and any non-printf +# emitter render colors correctly without per-call interpretation. +BOLD=$'\033[1m' +DIM=$'\033[2m' +CYAN=$'\033[36m' +GREEN=$'\033[32m' +RED=$'\033[31m' +YELLOW=$'\033[33m' +RESET=$'\033[0m' + +AGENT_PID="" + +step() { printf "\n${BOLD}${CYAN}==> %s${RESET}\n\n" "$1"; } +info() { printf " %b\n" "$*"; } + +# Redact host-side credentials from the agent log tail before printing on +# failure. Codex shouldn't echo the token, but a misbehaving tool call (e.g., +# `curl -v`) could leak it; sanitize before showing the log. +redact_log() { + local replacement='[redacted]' + sed \ + -e "s|${DEMO_GITHUB_TOKEN:-__no_github_token__}|${replacement}|g" \ + -e "s|${CODEX_AUTH_ACCESS_TOKEN:-__no_codex_access__}|${replacement}|g" \ + -e "s|${CODEX_AUTH_REFRESH_TOKEN:-__no_codex_refresh__}|${replacement}|g" \ + -e "s|${CODEX_AUTH_ACCOUNT_ID:-__no_codex_account__}|${replacement}|g" +} + +fail() { + printf "\n${RED}error:${RESET} %s\n" "$*" >&2 + if [[ -f "$AGENT_LOG" ]]; then + printf "\n${YELLOW}Agent log tail:${RESET}\n" >&2 + tail -n 80 "$AGENT_LOG" | redact_log | sed 's/^/ /' >&2 || true + fi + exit 1 +} + +cleanup() { + local status=$? + + if [[ -n "$AGENT_PID" ]] && kill -0 "$AGENT_PID" >/dev/null 2>&1; then + kill "$AGENT_PID" >/dev/null 2>&1 || true + wait "$AGENT_PID" 2>/dev/null || true + fi + + if [[ "$DEMO_KEEP_SANDBOX" != "1" ]]; then + "$OPENSHELL_BIN" sandbox delete "$DEMO_SANDBOX_NAME" >/dev/null 2>&1 || true + else + printf "\n${YELLOW}Keeping sandbox because DEMO_KEEP_SANDBOX=1: %s${RESET}\n" "$DEMO_SANDBOX_NAME" + fi + "$OPENSHELL_BIN" provider delete "$DEMO_CODEX_PROVIDER_NAME" >/dev/null 2>&1 || true + "$OPENSHELL_BIN" provider delete "$DEMO_GITHUB_PROVIDER_NAME" >/dev/null 2>&1 || true + + if [[ $status -eq 0 ]]; then + rm -rf "$TMP_DIR" + else + printf "\n${YELLOW}Temporary files kept at: %s${RESET}\n" "$TMP_DIR" + fi +} +trap cleanup EXIT + +require_command() { + command -v "$1" >/dev/null 2>&1 || fail "missing required command: $1" +} + +resolve_github_owner() { + if [[ -n "$DEMO_GITHUB_OWNER" ]]; then + return + fi + if command -v gh >/dev/null 2>&1; then + DEMO_GITHUB_OWNER="$(gh api user --jq .login 2>/dev/null || true)" + fi + [[ -n "$DEMO_GITHUB_OWNER" ]] || fail "set DEMO_GITHUB_OWNER, or sign in with: gh auth login" +} + +resolve_github_token() { + DEMO_GITHUB_TOKEN="${DEMO_GITHUB_TOKEN:-${GITHUB_TOKEN:-${GH_TOKEN:-}}}" + if [[ -z "$DEMO_GITHUB_TOKEN" ]] && command -v gh >/dev/null 2>&1; then + DEMO_GITHUB_TOKEN="$(gh auth token 2>/dev/null || true)" + fi + [[ -n "$DEMO_GITHUB_TOKEN" ]] || fail "set DEMO_GITHUB_TOKEN, GITHUB_TOKEN, GH_TOKEN, or sign in with: gh auth login" + export DEMO_GITHUB_TOKEN +} + +resolve_codex_auth() { + [[ -f "${HOME}/.codex/auth.json" ]] || fail "missing local Codex sign-in; run: codex login" + export CODEX_AUTH_ACCESS_TOKEN CODEX_AUTH_REFRESH_TOKEN CODEX_AUTH_ACCOUNT_ID + CODEX_AUTH_ACCESS_TOKEN="$(jq -r '.tokens.access_token // empty' "${HOME}/.codex/auth.json")" + CODEX_AUTH_REFRESH_TOKEN="$(jq -r '.tokens.refresh_token // empty' "${HOME}/.codex/auth.json")" + CODEX_AUTH_ACCOUNT_ID="$(jq -r '.tokens.account_id // empty' "${HOME}/.codex/auth.json")" + [[ -n "$CODEX_AUTH_ACCESS_TOKEN" ]] || fail "Codex sign-in is missing an access token; run: codex login" + [[ -n "$CODEX_AUTH_REFRESH_TOKEN" ]] || fail "Codex sign-in is missing a refresh token; run: codex login" + [[ -n "$CODEX_AUTH_ACCOUNT_ID" ]] || fail "Codex sign-in is missing an account id; run: codex login" +} + +validate_env() { + require_command curl + require_command jq + require_command "$OPENSHELL_BIN" + + [[ -f "$POLICY_TEMPLATE" ]] || fail "missing policy template: $POLICY_TEMPLATE" + [[ -f "$TASK_TEMPLATE" ]] || fail "missing agent task template: $TASK_TEMPLATE" + [[ -f "$SANDBOX_AGENT" ]] || fail "missing sandbox agent script: $SANDBOX_AGENT" + + [[ "$DEMO_GITHUB_REPO" =~ ^[A-Za-z0-9_.-]+$ ]] || fail "DEMO_GITHUB_REPO contains unsupported characters" + [[ "$DEMO_BRANCH" =~ ^[A-Za-z0-9._/-]+$ ]] || fail "DEMO_BRANCH contains unsupported characters" + [[ "$DEMO_RUN_ID" =~ ^[A-Za-z0-9_.-]+$ ]] || fail "DEMO_RUN_ID contains unsupported characters" + # DEMO_FILE_DIR is interpolated through `sed` with `|` as the delimiter + # when rendering the agent task; reject any character that would break + # the substitution or escape into a shell context. + [[ "$DEMO_FILE_DIR" =~ ^[A-Za-z0-9._/-]+$ ]] || fail "DEMO_FILE_DIR contains unsupported characters" + + resolve_github_owner + [[ "$DEMO_GITHUB_OWNER" =~ ^[A-Za-z0-9_.-]+$ ]] || fail "DEMO_GITHUB_OWNER contains unsupported characters" + + resolve_github_token + resolve_codex_auth +} + +github_api_status() { + local url="$1" body="$2" + curl -sS -o "$body" -w "%{http_code}" \ + -H "Accept: application/vnd.github+json" \ + -H "Authorization: Bearer ${DEMO_GITHUB_TOKEN}" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + "$url" +} + +check_gateway() { + local raw version + # `openshell status` colorizes labels with ANSI even when piped, so strip + # escapes before parsing. Use NO_COLOR as a belt-and-suspenders hint for + # libraries that respect it. + raw="$(NO_COLOR=1 "$OPENSHELL_BIN" status 2>/dev/null \ + | sed 's/\x1b\[[0-9;]*m//g')" + version="$(awk -F': *' '/Version:/ { print $2; exit }' <<<"$raw")" + [[ -n "$version" ]] \ + || fail "active OpenShell gateway is not reachable; start one with: openshell gateway start" + info "gateway: connected · ${version}" +} + +show_run_summary() { + step "Run summary" + printf " %-9s %s/%s\n" "repo:" "$DEMO_GITHUB_OWNER" "$DEMO_GITHUB_REPO" + printf " %-9s %s\n" "branch:" "$DEMO_BRANCH" + printf " %-9s %s\n" "target:" "$DEMO_FILE_PATH" + printf " %-9s %s\n" "sandbox:" "$DEMO_SANDBOX_NAME" +} + +check_github_access() { + local body status branch sha + body="${TMP_DIR}/github-repo.json" + status="$(github_api_status "https://api.github.com/repos/${DEMO_GITHUB_OWNER}/${DEMO_GITHUB_REPO}" "$body")" + if [[ "$status" != "200" ]]; then + info "${RED}Repo not found:${RESET} ${DEMO_GITHUB_OWNER}/${DEMO_GITHUB_REPO}" + info "Create a private scratch repo first, then re-run:" + info " ${DIM}gh repo create ${DEMO_GITHUB_OWNER}/${DEMO_GITHUB_REPO} --private --add-readme \\${RESET}" + info " ${DIM} --description 'OpenShell policy advisor demo scratch repo'${RESET}" + fail "GitHub returned HTTP $status for ${DEMO_GITHUB_OWNER}/${DEMO_GITHUB_REPO}" + fi + if jq -e '.permissions.push == false and .permissions.admin == false and .permissions.maintain == false' "$body" >/dev/null; then + fail "GitHub token does not have write access to ${DEMO_GITHUB_OWNER}/${DEMO_GITHUB_REPO}" + fi + + branch="$(jq -rn --arg v "$DEMO_BRANCH" '$v|@uri')" + body="${TMP_DIR}/github-branch.json" + status="$(github_api_status "https://api.github.com/repos/${DEMO_GITHUB_OWNER}/${DEMO_GITHUB_REPO}/branches/${branch}" "$body")" + [[ "$status" == "200" ]] || fail "GitHub returned HTTP $status for branch ${DEMO_BRANCH}" + sha="$(jq -r '.commit.sha[0:7]' "$body")" + info "github: ${DEMO_GITHUB_OWNER}/${DEMO_GITHUB_REPO} @ ${DEMO_BRANCH} (${sha})" + + body="${TMP_DIR}/github-target.json" + status="$(github_api_status "https://api.github.com/repos/${DEMO_GITHUB_OWNER}/${DEMO_GITHUB_REPO}/contents/${DEMO_FILE_PATH}?ref=${branch}" "$body")" + if [[ "$status" == "200" ]]; then + fail "demo output file already exists: ${DEMO_FILE_PATH}; choose a new DEMO_RUN_ID" + fi + [[ "$status" == "404" ]] || fail "GitHub returned HTTP $status while checking output path" +} + +render_payload() { + sed \ + -e "s|{{OWNER}}|${DEMO_GITHUB_OWNER}|g" \ + -e "s|{{REPO}}|${DEMO_GITHUB_REPO}|g" \ + -e "s|{{BRANCH}}|${DEMO_BRANCH}|g" \ + -e "s|{{FILE_PATH}}|${DEMO_FILE_PATH}|g" \ + -e "s|{{RUN_ID}}|${DEMO_RUN_ID}|g" \ + "$TASK_TEMPLATE" > "${PAYLOAD_DIR}/agent-task.md" + cp "$SANDBOX_AGENT" "${PAYLOAD_DIR}/sandbox-agent.sh" + cp "$POLICY_TEMPLATE" "$POLICY_FILE" +} + +create_providers() { + "$OPENSHELL_BIN" provider delete "$DEMO_CODEX_PROVIDER_NAME" >/dev/null 2>&1 || true + "$OPENSHELL_BIN" provider delete "$DEMO_GITHUB_PROVIDER_NAME" >/dev/null 2>&1 || true + + "$OPENSHELL_BIN" provider create \ + --name "$DEMO_CODEX_PROVIDER_NAME" \ + --type generic \ + --credential CODEX_AUTH_ACCESS_TOKEN \ + --credential CODEX_AUTH_REFRESH_TOKEN \ + --credential CODEX_AUTH_ACCOUNT_ID >/dev/null + + "$OPENSHELL_BIN" provider create \ + --name "$DEMO_GITHUB_PROVIDER_NAME" \ + --type generic \ + --credential DEMO_GITHUB_TOKEN >/dev/null + + info "providers created (codex, github) — credentials injected as env vars only" +} + +start_agent_sandbox() { + step "Launching sandbox; agent will hit a policy block and draft a proposal" + "$OPENSHELL_BIN" sandbox delete "$DEMO_SANDBOX_NAME" >/dev/null 2>&1 || true + + info "initial policy: read-only access to api.github.com (no PUT)" + info "agent task: PUT /repos/${DEMO_GITHUB_OWNER}/${DEMO_GITHUB_REPO}/contents/${DEMO_FILE_PATH}" + info "live log: ${AGENT_LOG}" + + # `--upload :/sandbox` preserves the source directory basename + # (matches `scp -r`/`cp -r`, see PRs #952 / #1028), so `${PAYLOAD_DIR}` + # (basename `payload`) lands at `/sandbox/payload/...`. `--upload` accepts + # a single value, so we ship both files in one directory. + ( + "$OPENSHELL_BIN" sandbox create \ + --name "$DEMO_SANDBOX_NAME" \ + --from base \ + --provider "$DEMO_CODEX_PROVIDER_NAME" \ + --provider "$DEMO_GITHUB_PROVIDER_NAME" \ + --policy "$POLICY_FILE" \ + --upload "${PAYLOAD_DIR}:/sandbox" \ + --no-git-ignore \ + --no-auto-providers \ + --no-tty \ + -- bash /sandbox/payload/sandbox-agent.sh + ) >"$AGENT_LOG" 2>&1 & + AGENT_PID="$!" +} + +# Strip the rule_get output down to the lines a developer needs to make an +# informed approve/reject decision: rationale, binary, endpoint. Filters the +# noisy fields (UUID, agent-generated rule_name, hardcoded confidence, +# duplicate Binaries) until `openshell rule get` learns to print L7 +# method/path itself (tracked separately). +# +# `openshell rule get` colorizes labels with ANSI escapes; strip them before +# parsing so the field-name match works in piped contexts. +summarize_pending() { + local pending="$1" + sed 's/\x1b\[[0-9;]*m//g' "$pending" \ + | awk ' + /Rationale:/ { sub(/^[[:space:]]*/, ""); print " " $0; next } + /Binary:/ { sub(/^[[:space:]]*/, ""); print " " $0; next } + /Endpoints:/ { sub(/^[[:space:]]*/, ""); print " " $0; next } + ' +} + +narrate_sandbox_workflow() { + info "Inside the sandbox right now:" + info "" + info " ${BOLD}[1]${RESET} agent: ${DIM}curl -X PUT https://api.github.com/repos/${DEMO_GITHUB_OWNER}/${DEMO_GITHUB_REPO}/contents/...${RESET}" + info " ${BOLD}[2]${RESET} L7 proxy denies the write and returns a structured 403 the" + info " agent can parse and act on:" + cat </dev/null 2>&1; then + wait "$AGENT_PID" || true + AGENT_PID="" + fail "agent exited before a pending proposal appeared" + fi + + if "$OPENSHELL_BIN" rule get "$DEMO_SANDBOX_NAME" --status pending >"$pending" 2>/dev/null \ + && grep -q "Chunk:" "$pending" && grep -q "pending" "$pending"; then + info "" + info "${GREEN}proposal received:${RESET}" + summarize_pending "$pending" + + step "Approving and waiting for the agent to retry" + "$OPENSHELL_BIN" rule approve-all "$DEMO_SANDBOX_NAME" \ + | awk '/approved/ { print " " $0 }' + return + fi + + now="$(date +%s)" + if (( now - start >= DEMO_APPROVAL_TIMEOUT_SECS )); then + fail "timed out waiting for the agent to submit a policy proposal" + fi + sleep 2 + done +} + +wait_for_agent() { + if ! wait "$AGENT_PID"; then + AGENT_PID="" + fail "agent run failed" + fi + AGENT_PID="" + info "agent retried after policy hot-reload — write succeeded" +} + +verify_github_write() { + step "Verifying GitHub write" + local body status branch + branch="$(jq -rn --arg v "$DEMO_BRANCH" '$v|@uri')" + body="${TMP_DIR}/github-result.json" + status="$(github_api_status "https://api.github.com/repos/${DEMO_GITHUB_OWNER}/${DEMO_GITHUB_REPO}/contents/${DEMO_FILE_PATH}?ref=${branch}" "$body")" + [[ "$status" == "200" ]] || fail "expected demo file to exist after agent run; GitHub returned HTTP $status" + jq -r '" file: \(.path)", " url: \(.html_url)"' "$body" +} + +# Print the OCSF JSONL trace, filtered to the three events that *are* the +# demo's story: the L7 PUT deny, the policy hot-reload, and the L7 PUT allow. +# The native OCSF shorthand is informative and consistent with the rest of +# OpenShell's logging — keep it as-is rather than re-formatting. +show_logs() { + step "Policy decision trace (OCSF)" + "$OPENSHELL_BIN" logs "$DEMO_SANDBOX_NAME" --since 10m -n 200 2>&1 \ + | grep -E 'HTTP:PUT.*(DENIED|ALLOWED)|CONFIG:LOADED.*Policy reloaded' \ + | sed 's/^/ /' || true +} + +main() { + validate_env + + step "Preflight" + check_gateway + check_github_access + render_payload + create_providers + + show_run_summary + + start_agent_sandbox + approve_when_pending + wait_for_agent + verify_github_write + show_logs + + printf "\n${BOLD}${GREEN}✓ Demo complete.${RESET}\n" +} + +main "$@" diff --git a/examples/agent-driven-policy-management/policy.template.yaml b/examples/agent-driven-policy-management/policy.template.yaml new file mode 100644 index 000000000..e920277b5 --- /dev/null +++ b/examples/agent-driven-policy-management/policy.template.yaml @@ -0,0 +1,69 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Initial sandbox policy for the agent-driven policy demo. +# +# The agent inside the sandbox can: +# - reach Codex's model and auth endpoints (codex) +# - clone Codex plugin repos read-only (codex_plugins) +# - read api.github.com via curl (github_api_readonly) +# +# The agent CANNOT write to GitHub yet. That's the proposal it has to draft +# and ask the developer to approve. + +version: 1 + +filesystem_policy: + include_workdir: true + read_only: [/usr, /lib, /proc, /dev/urandom, /app, /etc, /var/log] + read_write: [/sandbox, /tmp, /dev/null] + +landlock: + compatibility: best_effort + +process: + run_as_user: sandbox + run_as_group: sandbox + +network_policies: + codex: + name: codex + endpoints: + - { host: api.openai.com, port: 443, protocol: rest, enforcement: enforce, access: full } + - { host: auth.openai.com, port: 443, protocol: rest, enforcement: enforce, access: full } + - { host: chatgpt.com, port: 443, protocol: rest, enforcement: enforce, access: full } + - { host: ab.chatgpt.com, port: 443, protocol: rest, enforcement: enforce, access: full } + binaries: + - { path: /usr/bin/codex } + - { path: /usr/bin/node } + - { path: "/usr/lib/node_modules/@openai/**" } + + codex_plugins: + name: codex-plugins + endpoints: + - host: github.com + port: 443 + protocol: rest + enforcement: enforce + rules: + - allow: + method: GET + path: "/openai/plugins.git/info/refs*" + - allow: + method: POST + path: "/openai/plugins.git/git-upload-pack" + binaries: + - { path: /usr/bin/git } + - { path: /usr/lib/git-core/git-remote-http } + - { path: "/usr/lib/node_modules/@openai/**" } + + github_api_readonly: + name: github-api-readonly + endpoints: + - host: api.github.com + port: 443 + protocol: rest + enforcement: enforce + access: read-only + binaries: + - { path: /usr/bin/curl } diff --git a/examples/agent-driven-policy-management/sandbox-agent.sh b/examples/agent-driven-policy-management/sandbox-agent.sh new file mode 100755 index 000000000..052535c35 --- /dev/null +++ b/examples/agent-driven-policy-management/sandbox-agent.sh @@ -0,0 +1,82 @@ +#!/usr/bin/env bash + +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Runs inside the sandbox. Bootstraps Codex from the credentials injected by +# the openshell provider, then drives the agent-task prompt to completion. + +set -euo pipefail + +require_env() { + local name="$1" + [[ -n "${!name:-}" ]] || { echo "missing required env: $name" >&2; exit 1; } +} + +require_env CODEX_AUTH_ACCESS_TOKEN +require_env CODEX_AUTH_REFRESH_TOKEN +require_env CODEX_AUTH_ACCOUNT_ID +require_env DEMO_GITHUB_TOKEN + +# Make the GitHub token visible to Codex's tool loop under the conventional name. +export GITHUB_TOKEN="$DEMO_GITHUB_TOKEN" + +# Codex looks for ~/.codex/auth.json. The OpenShell provider only injects env +# vars, so we materialize the file Codex expects from those credentials. +mkdir -p "$HOME/.codex" +node - <<'NODE' +const fs = require("fs"); +const path = `${process.env.HOME}/.codex/auth.json`; +const b64u = (obj) => Buffer.from(JSON.stringify(obj)).toString("base64url"); +const now = Math.floor(Date.now() / 1000); +// Placeholder id_token is required by Codex but never validated against an +// upstream JWKS in this flow. +const idToken = [ + b64u({ alg: "none", typ: "JWT" }), + b64u({ + iss: "https://auth.openai.com", + aud: "codex", + sub: "openshell-policy-demo", + email: "demo@openshell.local", + iat: now, + exp: now + 3600, + }), + "placeholder", +].join("."); +fs.writeFileSync(path, JSON.stringify({ + auth_mode: "chatgpt", + OPENAI_API_KEY: null, + tokens: { + id_token: idToken, + access_token: process.env.CODEX_AUTH_ACCESS_TOKEN, + refresh_token: process.env.CODEX_AUTH_REFRESH_TOKEN, + account_id: process.env.CODEX_AUTH_ACCOUNT_ID, + }, + last_refresh: new Date().toISOString(), +}, null, 2)); +NODE +chmod 600 "$HOME/.codex/auth.json" + +# Codex needs a writable cwd; /sandbox is uploaded read-only-ish, so work in /tmp. +WORK="$(mktemp -d)" +cd "$WORK" + +# Disable Codex's internal bubblewrap sandbox — OpenShell is already the +# security boundary, and bwrap can't create nested user namespaces inside the +# OpenShell sandbox container without extra capabilities. The "danger" framing +# is from Codex's perspective on a developer host; here the OpenShell network +# policy and filesystem constraints are doing the actual containment. +# +# Cap Codex's reasoning effort at the lower end. The demo task is mechanical +# (one HTTP request, parse a structured 403, post a JSON proposal, retry); the +# default high-effort reasoning roughly doubles the demo's wall time without +# improving outcomes. Override with DEMO_CODEX_REASONING if you want to +# compare runs. +DEMO_CODEX_REASONING="${DEMO_CODEX_REASONING:-low}" + +exec codex exec \ + --skip-git-repo-check \ + --sandbox danger-full-access \ + --ephemeral \ + -c "model_reasoning_effort=\"${DEMO_CODEX_REASONING}\"" \ + "$(cat /sandbox/payload/agent-task.md)" diff --git a/rfc/0001-agent-driven-policy-management.md b/rfc/0001-agent-driven-policy-management.md new file mode 100644 index 000000000..07c9bb8a0 --- /dev/null +++ b/rfc/0001-agent-driven-policy-management.md @@ -0,0 +1,721 @@ +--- +authors: + - "@alwatson" +state: draft +links: + - https://github.com/NVIDIA/OpenShell/issues/1062 + - https://github.com/NVIDIA/OpenShell/blob/main/architecture/policy-advisor.md +--- + +# RFC 0001 - Agent-Driven Policy Management + + + +## Summary + +Evolve OpenShell's existing Policy Advisor into an agent-driven policy management system that lets agents inspect current sandbox policy, draft narrow policy changes, submit them for review, and apply approved updates without restarting the sandbox. The safety model stays the same: sandbox-side analysis, gateway-side validation and persistence, and explicit approval boundaries. The main change is the authoring and review experience: every sandbox should expose local policy guidance and APIs, and every developer surface should expose a responsive inbox for reviewing proposals. + +## Motivation + +OpenShell already has the core of a dynamic policy editing experience: + +- The sandbox proxy emits deny events. +- The sandbox-side `DenialAggregator` and mechanistic mapper convert those into draft `PolicyChunk` proposals. +- The gateway persists proposals and merges approved rules into the active policy. +- The TUI and CLI already provide review and approval flows. +- Running sandboxes already hot-reload dynamic policy updates. + +That is a strong foundation, but the current experience is still fundamentally operator-driven and network-centric. It is excellent for "observe a deny, approve a generated endpoint rule" but incomplete for the broader product promise: an agent should be able to understand what is blocked, discover what policy language is available, generate the narrowest valid policy change, and submit it to the developer with enough rationale and verification signal that approval is fast and trustworthy. + +This matters because: + +- developers should not need to learn policy syntax before becoming productive +- agents have the most task context and can often draft narrower changes than humans +- approvals should feel like reviewing a validated outcome, not guessing about a YAML diff +- the inbox experience must be fast and clear across TUI, CLI, and SDK surfaces +- organizations need a path from human approval to trusted bounded automation without losing auditability or least privilege + +This RFC proposes the next layer: make policy adaptation an intentional, agent-native workflow instead of a reactive operator convenience. + +## MVP implementation note + +The first implementation is tracked in [#1062](https://github.com/NVIDIA/OpenShell/issues/1062). It intentionally starts with the smallest agent-driven loop that can validate the product experience: + +- structured L7 REST deny responses for agent-readable failures +- a sandbox-local `policy.local` HTTP API backed by existing files, logs, and per-sandbox mTLS gateway calls +- static sandbox-local agent guidance in `/etc/openshell/skills/policy_advisor.md` +- agent-authored proposal provenance, validation status, and rejection guidance in the existing draft policy flow +- TUI/CLI review for a single sandbox, with polling as the MVP refresh path + +The MVP deliberately defers the supervisor Unix-socket API, server-streaming multi-sandbox inbox, Slack/web adapters, org ceilings, trusted auto-apply, and in-process prover optimization. Those remain aligned with the RFC direction, but they are not required to prove the initial loop. + +## Non-goals + +- Allowing an in-sandbox agent to self-approve or unilaterally apply its own policy changes. +- Moving proposal generation into the gateway. Sandbox-side analysis remains the architectural default. +- Solving every policy domain in the first release. Network policy is the initial scope because it is the only hot-reloadable policy domain in the current architecture; filesystem and process policy can follow later through a different lifecycle model. +- Replacing the existing mechanistic mapper. It remains the deterministic baseline and safety net. +- Making Rego authoring a direct end-user requirement. The system should expose policy semantics to agents and advanced users, not require hand-authored policy for common workflows. + +## Proposal + +### Enforcement model + +This RFC is not proposing a generic "policy update" system without specifying what gets enforced. The intended model is layered: + +- **L4 remains the universal baseline** + Every outbound connection is gated by host, port, and binary identity. +- **L7 is the preferred least-privilege model for supported application protocols** + Today this primarily means `protocol: rest` with per-method and per-path rules for HTTP APIs. +- **Protocol-aware or tool-aware policy layers may sit above L7 where useful** + MCP is a strong candidate for a future higher-level enforcement surface, but it should be modeled explicitly rather than implied. + +For the initial implementation of this RFC, dynamic policy management should be grounded in the enforcement model OpenShell already has in the codebase today: + +- L4 network policy for all outbound traffic +- L7 REST enforcement for HTTP APIs where `protocol: rest` is configured +- policy prover checks that can distinguish L4-only access from L7-enforced access + +This matters because "allow GitHub" is not a single thing: + +- `github.com:443` used by `git` may require L4-only allowance depending on the workflow and protocol behavior +- `api.github.com:443` used by `gh`, `curl`, or an SDK is often a great fit for L7 REST controls +- the best least-privilege design often splits those paths rather than treating GitHub as one broad capability + +This RFC is therefore network-first by design, not because other policy domains are unimportant, but because the current OpenShell architecture only supports live mutation of `network_policies`. Filesystem, Landlock, and process settings are applied at sandbox startup and are currently immutable for the lifetime of a sandbox. + +### Product direction + +Every OpenShell sandbox should be able to host an agent-capable policy workflow with four core affordances: + +1. A local capability description that teaches an agent how to inspect current policy state, understand the available policy language, and submit a proposal for review. +2. A sandbox-local or supervisor-adjacent API for reading effective policy, recent denials, sandbox-local activity logs, and proposal state. +3. A gateway-managed developer inbox for reviewing, editing, approving, rejecting, and auditing proposals in real time. +4. A validation pipeline that checks proposed policy changes before they are applied. + +The product bar is not just correctness. The interaction model itself must be good: + +- proposals should appear quickly after a deny or agent request +- review surfaces should be understandable without policy expertise +- the same proposal should look coherent in the TUI, CLI, and SDKs +- approval should take one action when the system has high confidence +- high-volume exploratory agent workflows should not drown the user in repetitive prompts + +### UX requirements and latency targets + +For the developer inbox experience: + +- OpenShell **must provide a push/subscription path** for proposal and decision updates to the TUI, CLI, and SDKs. +- Polling may exist as a fallback, but polling-only delivery is not sufficient for the intended UX. + +Target UX metrics: + +- **Proposal appearance latency** + From the time the gateway accepts a proposal or actionable deny-derived recommendation to the time it appears in a connected inbox client: + - target `p50 <= 2s` + - target `p95 <= 5s` +- **Decision propagation latency** + From approval, rejection, edit, or auto-apply at the gateway to the time all connected inbox clients reflect the new state: + - target `p50 <= 1s` + - target `p95 <= 3s` +- **Activation feedback latency** + From gateway receipt of sandbox policy status (`loaded` or `failed`) to visible client state update: + - target `p50 <= 1s` + - target `p95 <= 3s` + +If sandbox policy activation takes longer than these targets, the inbox should still update immediately with an intermediate state such as `pending_activation` rather than leaving the user uncertain. + +The desired user experience: + +1. An agent encounters a deny with a structured explanation from the sandbox supervisor. +2. The agent uses a local policy-management skill to inspect the current effective policy, denial context, and relevant policy primitives. +3. The agent produces a minimal proposed change and submits it through a stable proposal API. +4. The developer sees the proposal in the TUI, CLI, or SDK, reviews its rationale and validation results, and approves or rejects it. +5. OpenShell applies the approved change as a hot-reloaded policy update and preserves a durable audit trail. + +### What exists today + +The RFC explicitly builds on the current codebase rather than replacing it. + +Current implementation points: + +- `crates/openshell-sandbox/src/denial_aggregator.rs` + Sandbox-side aggregation of deny events. +- `crates/openshell-sandbox/src/mechanistic_mapper.rs` + Deterministic generation of draft `PolicyChunk` recommendations, including partial L7 support. +- `crates/openshell-server/src/grpc/policy.rs` + Persistence, approval, merge, rejection, edit, undo, and policy revision handling. +- `crates/openshell-tui/src/ui/sandbox_draft.rs` + TUI review and approval surface for network rules. +- `crates/openshell-cli/src/run.rs` + `openshell rule get|approve|reject|approve-all|history`. +- `architecture/policy-advisor.md` + Current sandbox-side recommendation design. + +Important capabilities that already exist and should be preserved: + +- sandbox-side proposal generation +- hot-reloadable policy updates +- proposal editing and undo RPCs in `proto/openshell.proto` +- durable draft chunk storage and approval history +- a deterministic, mechanistic proposal path that does not require an LLM +- a real distinction between L4-only and L7 REST enforcement + +### What is missing + +The current implementation lacks several parts required for the intended developer experience: + +- A standard in-sandbox skill or instruction bundle for local agents. +- A first-class proposal API that agents can use intentionally, not only through deny-triggered analysis. +- Rich proposal context beyond host/port/binary, especially for developer intent, repository/task context, and write operations. +- Validation outputs that explain what a proposal would permit before approval. +- A generalized "developer inbox" model that can power the TUI, CLI, SDK, and future Slack/web surfaces from the same backend abstraction. +- A clear separation between: + - observed deny events, + - agent-authored policy changes, + - validated approval-ready proposals, + - applied policy revisions. +- A trust model for non-human approvers, where a trusted external agent may apply policy changes automatically when those changes remain within an organization-defined maximum policy envelope. +- Explicit proposal semantics for whether a recommendation is: + - L4-only + - L7 REST + - a conversion from L4 to L7 + - a future protocol-aware policy type such as MCP-aware controls + +### Architecture + +```mermaid +flowchart LR + AGENT["Agent in sandbox"] --> SKILL["Local policy skill / instructions"] + SKILL --> API["Supervisor policy API"] + API --> STATE["Effective policy + deny history + schema help"] + AGENT --> PROPOSE["Submit proposal"] + PROPOSE --> GW["Gateway proposal service"] + DENY["Proxy deny + L7 deny"] --> AGG["Sandbox aggregator + mechanistic mapper"] + AGG --> GW + GW --> VALIDATE["Validation + simulation + prover"] + VALIDATE --> INBOX["Developer inbox"] + INBOX -->|approve| MERGE["Policy merge + revision"] + INBOX -->|reject/edit| GW + MERGE --> POLL["Sandbox policy poll / push"] + POLL --> API +``` + +The important architectural principle is that the current Policy Advisor pipeline becomes one producer of proposals, not the only producer. Agent-authored proposals and mechanistic proposals should land in the same gateway inbox and go through the same validation and approval machinery. + +The second architectural principle is that approval is policy-driven. Human approval is the default mode, but the same machinery must also support a trusted external control plane deciding that a proposal is safe to auto-apply because it fits under higher-level organizational constraints. + +The end-to-end interaction should look like this: + +```mermaid +sequenceDiagram + participant A as Agent in Sandbox + participant S as Local Skill + participant U as policy.local API + participant P as Local Prover Aid + participant G as Gateway Proposal Service + participant X as External Validator / Trusted Approver + participant I as Developer Inbox + + A->>S: read policy skill / instructions + A->>U: get-effective-policy, get-recent-denials + U-->>A: effective policy + deny context + A->>P: evaluate candidate policy change + P-->>A: advisory narrowing feedback + A->>U: submit proposal + U->>G: forward proposal + provenance + G->>X: validate against policy + org ceiling + X-->>G: human_review_required or eligible_for_automatic_apply + G-->>I: publish proposal / status update + alt human review + I->>G: approve / reject / reject_with_guidance + G-->>U: proposal result + U-->>A: optional structured feedback + else trusted external auto-apply + X->>G: apply approved change + G-->>I: auto-applied status + audit trail + end +``` + +### Core design concepts + +#### 1. Policy workspace inside every sandbox + +Every default sandbox should expose a stable local "policy workspace" that agents can rely on. This is not necessarily a mutable file checked into the user's repo; it is a capability surface available from inside the sandbox. + +The workspace should provide: + +- Effective sandbox policy, already resolved to the currently active version. +- Policy schema and examples relevant to the sandbox's supported policy features. +- A description of approval workflow semantics. +- Recent denials, sandbox-local activity logs, and related proposal history. +- Guidance for generating the narrowest change possible. + +The first implementation can be a static `/etc/openshell/skills/policy_advisor.md` plus a sandbox-local `policy.local` HTTP API. The long-term contract is the API; the skill is the ergonomic on-ramp. MCP can wrap this API later for agents that benefit from tool discovery, but it should not be the first load-bearing protocol or a separate implementation path. + +The sandbox-facing surface must also have an explicit information boundary: + +Visible to the in-sandbox agent: + +- the current effective sandbox policy +- deny reasons, sandbox-local logs, and local proposal history for that sandbox +- supported policy primitives and examples +- coarse proposal outcomes such as `needs_human_review`, `eligible_for_external_review`, or `rejected_for_safety` + +Not exposed directly to the in-sandbox agent: + +- the full org ceiling policy document +- trusted external approver policies or scoring heuristics +- hidden approval thresholds +- policy state for other sandboxes, users, or org workflows + +Ceiling evaluation, trust decisions, and automatic-apply eligibility should be computed outside the sandbox. + +OpenShell should also make prover-backed analysis available to agents as a proposal-authoring aid. This lets an agent test whether a candidate policy is broader than necessary and iterate toward a narrower proposal before submission. Inside the sandbox, prover output is advisory and used for drafting only. Approval and automatic-apply decisions must still rely on external validation in the gateway or trusted approver path. + +#### 2. Intentional proposal submission API + +Add a supervisor-facing proposal API so an agent can submit policy updates even before or after a deny aggregation cycle. + +Example agent actions: + +- "Show me the current policy affecting `git`." +- "Explain why this GitHub push was denied." +- "Draft the minimal rule to allow writes to `github.com` and `api.github.com` for `git` only." +- "Submit this proposal for human review." + +This proposal path should support two modes: + +- `draft_from_observation` + Builds on real deny history. +- `draft_from_agent_intent` + Allows an agent to proactively request a change based on planned work. + +Both should land in the same inbox with provenance captured. + +When multiple producers submit effectively the same proposal, the gateway should apply a deterministic merge policy: + +- mechanistic proposals establish the baseline proposal record +- richer agent-authored proposals for the same sandbox + endpoint + binary may upgrade the existing record's rationale, context, and proposed L7 refinement +- fallback observation updates may continue to bump hit counts and timestamps without discarding richer metadata + +The important product requirement is that a richer agent proposal must not be silently lost behind an earlier mechanistic proposal. + +#### 3. Proposal model evolution + +Extend the existing `PolicyChunk`/draft-chunk model into a more expressive proposal object while preserving backward compatibility for current rule review commands. + +Additional fields should include: + +- Proposal source: mechanistic, agent-authored, or hybrid. +- Requested capability summary in plain language. +- Validation status and findings summary. +- Diff against current effective policy. +- Enforcement layer for first-release proposal types: + - `l4` + - `l7_rest` +- Intended scope: + - endpoint-only + - L7 method/path + - binary restriction + - time-bounded or session-bounded, if supported later +- Optional task context: + - repo URL + - issue/RFC reference + - command or tool that triggered the need + +The inbox should make it obvious whether a proposal is an L4 tunnel, an L7 REST rule, or a conversion from broad access to narrower L7 controls. + +Future protocol-aware proposal kinds such as MCP-aware controls should extend the model later rather than forcing the first-release schema to generalize prematurely. + +#### 4. Validation before approval + +Approval should present validated consequences, not just a proposed rule. + +Validation stages: + +1. Schema and static safety validation. +2. Deterministic simulation: + - what new hosts, ports, methods, or binaries would become reachable + - whether the change overlaps or broadens an existing rule + - whether the proposal is L4-only or protected by L7 enforcement +3. Policy-specific safety checks: + - always-blocked destinations + - suspicious private IP overrides + - wildcard or full-access expansions + - binaries or protocols that bypass L7 inspection +4. Formal verification when supported: + - use the existing prover infrastructure to check that the proposal satisfies a declared intent and does not exceed it + +The validator should emit an approval summary such as: + +- "Allows `git` to `github.com:443` and `api.github.com:443`." +- "Does not grant access to other GitHub hosts." +- "Adds write-capable REST paths for repo push semantics." +- "Touches only dynamic network policy." +- "This change is L4-only and does not provide method/path restriction." +- "This change upgrades the endpoint to L7 REST enforcement." + +Validation should also support two decision modes: + +- `human_review_required` + The proposal is shown in the developer inbox for explicit approval. +- `eligible_for_automatic_apply` + The proposal remains within a trusted approval envelope and may be applied automatically by policy. + +For first release, the recommended automatic-apply scope is intentionally narrow: + +- trusted external approver only +- network policy only +- L7 REST preferred where supported +- ephemeral lease durability by default +- only when prover, validation, and org ceiling checks succeed without ambiguity + +#### 4a. Structured deny feedback + +Denied operations should not only appear in logs and inboxes. OpenShell should also provide a structured deny feedback path that helps the in-sandbox agent recover intelligently by returning: + +- a machine-readable explanation of what was denied +- the relevant enforcement layer (`l4` or `l7_rest`) +- the reason the current policy did not allow it +- a pointer to the local policy workspace/API for inspection and proposal drafting + +The delivery mechanism may vary, but the RFC requires this to be a first-class capability rather than only an operator-facing side effect. + +#### 5. Unified developer inbox + +The existing draft-chunk review surface should become a generalized developer inbox with: + +- Real-time updates from the gateway. +- Filterable by sandbox, status, source, severity, and validation state. +- Renderable in: + - TUI + - CLI + - SDK/API + - future Slack/web integrations +- Support for: + - approve + - reject + - reject with guidance + - edit + - bulk approve with safeguards + - undo + - audit/history inspection + +The current TUI "Network Rules" panel is the correct seed, but the mental model should shift from "network rules list" to "policy proposal inbox." + +To support the UX targets above, the inbox architecture should include a subscription mechanism from the gateway to clients, such as streaming gRPC, SSE, or an equivalent event feed. The exact transport can be implementation-specific, but the user-visible behavior should be push-first. + +Rejection should be part of a revise-and-resubmit loop rather than a dead end. Operators should be able to reject a proposal with explanation so the agent can draft a narrower or corrected follow-up without requiring the operator to hand-author the policy change themselves. + +#### 6. L7-first agent experience + +A major product requirement is enabling strong default sandboxes with granular approval flows, especially for APIs like GitHub: + +- The default sandbox permits read-only GitHub API access via L7 policy. +- An agent attempts a write operation. +- The sandbox returns a structured deny that tells the agent: + - what was blocked, + - what part of policy caused the denial, + - how to inspect current policy, + - how to submit a narrow proposal. +- The agent proposes the smallest change needed for the target repo/workflow. +- The developer reviews a proposal phrased in task terms, not raw YAML only. + +OpenShell should explicitly steer the system toward the narrowest viable enforcement level: + +- prefer L7 REST rules for HTTP APIs such as GitHub, LinkedIn, X, Slack, Jira, and similar services +- fall back to L4 only when the protocol or client behavior prevents meaningful L7 enforcement +- tell the developer when a proposal is broad because the workload itself is broad, not because the system failed to model it precisely + +### REST, L4, and MCP + +REST APIs are the clearest near-term least-privilege win because OpenShell already supports `protocol: rest`, access presets, explicit method/path rules, TLS termination, and prover logic that can distinguish L4-only access from L7 write exposure. L7 REST should therefore be the default recommendation path for HTTP APIs, while L4-only proposals remain available for non-HTTP or opaque clients and should be clearly marked as broader access. MCP remains strategically important, but it should not drive the first-release schema: remote MCP still rests on transport controls such as HTTP/SSE/WebSocket, while local stdio MCP does not map neatly to network enforcement. The near-term plan is simple: **Phase 1-4 focus on L4 + L7 REST policy management; MCP-aware controls land as a later dedicated track.** + +#### 7. Trusted external approvers and policy ceilings + +Human approval should remain the default, but the system should also support a second mode where a trusted agent outside the sandbox can approve and apply changes automatically on behalf of the user when: + +- the organization defines an immutable high-level policy ceiling +- the sandbox policy starts below that ceiling +- the agent proposes a narrower incremental change needed to complete a task +- the prover and policy validator can show that the change stays within the allowed envelope + +In this model: + +- the org-level ceiling acts as a non-bypassable maximum +- sandbox policy revisions can expand only within that ceiling +- a trusted external agent or control-plane service may auto-apply compliant changes +- every request, validation result, and applied revision is logged for audit + +This gives OpenShell a path to adaptive least privilege without forcing a human to approve every safe change in real time. + +### Trust and approval model + +OpenShell should support at least three approval modes: + +1. `human_in_the_loop` + Every proposal requires explicit user approval. +2. `trusted_agent_within_ceiling` + A trusted external agent may apply changes automatically when validation and prover checks confirm the proposal stays within an org or user-defined maximum. +3. `manual_only_locked_down` + No automatic apply; some proposals may be visible but categorically blocked from execution by policy. + +The RFC does not propose allowing an in-sandbox agent to self-approve its own policy requests. Trusted external auto-apply is **in scope**, but it is distinct from autonomous in-sandbox mutation. The minimum shippable baseline is still a strong human-in-the-loop workflow. + +### Organizational policy layering + +This RFC assumes policy layering rather than a single mutable document: + +- `org ceiling policy` + The maximum capability envelope defined by security or platform teams. +- `sandbox effective policy` + The currently active policy for a sandbox, always a subset of the org ceiling when one exists. +- `proposal diff` + The incremental change requested by an agent or generated from deny analysis. + +For a proposal to be auto-applied, it must satisfy all of: + +1. valid OpenShell policy schema and merge semantics +2. no violation of always-blocked destinations or other hard safety rules +3. no violation of org ceiling constraints +4. successful prover or simulation checks against declared assumptions +5. successful audit logging and attribution + +If any check fails, the proposal falls back to human review or outright rejection. + +### Durability model + +Policy changes should not all have the same lifecycle. This RFC proposes three durability classes: + +1. `ephemeral_lease` + A time-bounded grant that expires automatically unless renewed. This is the recommended default for automatically applied expansions. +2. `sandbox_durable` + A durable revision for a specific sandbox or long-lived workflow. Suitable for human-approved changes or explicit promotion from a lease. +3. `promoted_policy_artifact` + A reusable policy artifact intended for future sandboxes, templates, or org-managed defaults. + +Recommended defaults: + +- auto-applied trusted-agent changes should start as `ephemeral_lease` unless explicitly promoted +- human-approved changes may become `sandbox_durable` directly when the reviewer intends lasting behavior +- promotion into reusable artifacts should be a deliberate step + +### Reject with guidance + +Operators should be able to do more than approve or reject. The system should support a guided rejection path: + +- `approve` + Accept and apply the proposal. +- `reject` + Decline the proposal without expecting an immediate follow-up. +- `reject_with_guidance` + Decline the proposal while returning operator guidance that the agent can use to revise and resubmit. + +Guidance may include free-form explanation plus structured hints such as `too_broad`, `use_l7_not_l4`, `wrong_binary_scope`, `wrong_endpoint`, `needs_time_limit`, or `outside_org_ceiling`. + +### Example: trusted daily research workflow + +One motivating workflow is a recurring research task: search X and LinkedIn for posts about a topic, summarize the results, and email the summary to the user. In that flow, the sandbox may start with minimal permissions plus an email provider, then request new outbound access to X and LinkedIn. A trusted external policy agent can prefer L7 REST rules when possible and apply them automatically when they fit within the organization's permitted research ceiling. + +### API and component changes + +#### Sandbox supervisor + +Add a local policy interaction surface: + +- sandbox-local `policy.local` HTTP API +- optional future MCP wrapper backed by that API + +Representative operations: + +- read effective policy, recent denials, and sandbox-local activity logs +- inspect proposal guidance and current proposal state +- submit a policy proposal + +This surface must be readable by the agent but not self-approving. + +Phase 2 implementation decisions: + +- primary transport: sandbox-local HTTP JSON at `policy.local` +- ergonomic wrapper: defer MCP/CLI wrappers until the local API proves useful +- first trust model: the sandbox is treated as single-tenant, so local callers are part of the sandbox tenant; this does not grant approval rights +- first proposal format: reuse the `PolicyMergeOperation` shape behind `openshell policy update` inside a JSON request body; the supervisor/local service bundles those operations with intent, summary, and optional evidence references, sends them to the gateway over gRPC, and the gateway stores them as draft chunks for approval instead of applying them immediately + +#### Gateway / server + +Extend the gateway proposal service to support: + +- explicit agent-authored proposal submission +- richer proposal metadata +- validation result persistence +- inbox subscriptions for multiple frontends +- trusted approver identities and authorization policies +- automatic-apply decisions gated by org ceiling and validation outcomes +- enforcement-layer-aware summaries and diffing +- durability classes and lease expiration metadata +- rejection reasons and operator guidance that can feed follow-up proposals +- stronger audit records tying: + - deny event(s) + - proposal author/source + - approval decision + - resulting policy revision + +The existing gRPC policy service is the natural place to grow this. + +#### TUI, CLI, and SDK + +The TUI should evolve from the current rules panel into a richer inbox with proposal summaries, validation state, diff views, edit-before-approve flow, and a clear distinction between "awaiting you" and "already auto-applied within policy ceiling." The CLI should preserve `openshell rule` for compatibility while introducing clearer proposal-centric aliases, and CLI/SDK surfaces should expose the same approval metadata so integrators can build their own inboxes and automation. + +## Implementation plan + +### Phase 1: Productize the current Policy Advisor + +Goal: turn the existing network rule draft flow into a first-class, polished foundation. + +Deliverables: + +- Rename and frame the current draft-chunk system internally as a proposal inbox. +- Add proposal provenance fields and validation summary fields. +- Improve TUI and CLI language to emphasize reviewable proposals. +- Document the current approval loop as a stable workflow. +- Set explicit UX targets for proposal latency and review responsiveness. +- Add a push/subscription path for proposal and decision updates to inbox clients. +- Audit existing `PolicyChunk` and draft-chunk persistence fields, then either hydrate, deprecate, or remove hollow fields before extending the model further. + +This phase is mostly packaging and data-model hardening on top of existing code in: + +- `crates/openshell-sandbox` +- `crates/openshell-server` +- `crates/openshell-tui` +- `crates/openshell-cli` + +### Phase 2: Local agent skill and supervisor policy API + +Goal: let any agent in a sandbox intentionally inspect and draft policy changes. + +Deliverables: + +- Generated sandbox-local `policy_advisor.md` or equivalent instruction bundle. +- Supervisor read APIs for policy state, denials, local activity logs, and capabilities. +- Initial proposal submission API. +- Structured deny messages that point agents to the local policy workflow. +- Feedback path so agents can read operator rejection guidance and iterate on a proposal. + +This is the point where the feature becomes broadly useful to OpenClaw, Claude Code, Cursor, and other agents. + +### Phase 3: Validation and simulation + +Goal: make approval trustworthy and fast. + +Deliverables: + +- Policy diff generation. +- Consequence summaries for proposed changes. +- Integration with prover/simulation infrastructure where available. +- Clear validation statuses in TUI and CLI. +- Org ceiling checks and trusted-agent auto-apply eligibility. +- Clear reporting for L4-only versus L7-enforced proposals. +- Safety-aware redaction so sandbox-local introspection does not expose full ceiling internals. + +This phase is critical before broadening beyond simple endpoint approvals. + +### Phase 4: Rich L7 authoring and GitHub write flow + +Goal: demonstrate the full UX on a high-value developer workflow. + +Deliverables: + +- Structured GitHub write-policy proposals from agent intent. +- Support for method/path-level rule authoring via agent workflow. +- Validation tuned for common provider/API patterns. +- Demo and tutorial flows centered on repo write access. + +This phase should produce the canonical blocked-write upgrade experience. + +### Phase 5: Generalized inbox surfaces + +Goal: expose proposal review outside the TUI. + +Deliverables: + +- Stable SDK/API for proposal feeds and decisions. +- CLI parity for all proposal operations. +- Optional Slack/web notification adapters. + +### Phase 6: Trusted automation and recurring workflows + +Goal: support safe automatic policy evolution for approved automation patterns. + +Deliverables: + +- policy ceiling model for org or platform admins +- trusted external approver identity model +- automatic apply path when proposals stay within ceiling +- audit trail and reporting for auto-applied revisions +- lease-based durability for automatically applied changes +- reference workflow for recurring research-and-email automation + +### Future phase: protocol-aware policy adapters + +Goal: extend dynamic policy management beyond REST where higher-level semantics exist, including MCP-aware policy controls, richer SQL enforcement once enforce-mode support exists, and protocol-specific adapters for common tool ecosystems. + +## Migration and compatibility + +The intended rollout is additive-first. + +- Existing `openshell rule` commands should continue to work while proposal-centric APIs and UX are introduced. +- Existing mechanistic sandboxes should remain compatible with a newer gateway during the transition. +- Database and proto evolution should prefer additive fields and compatibility shims before any cleanup of legacy draft-chunk semantics. +- If proposal semantics outgrow the current draft-chunk schema, migration should preserve existing pending, approved, and rejected records rather than discarding inbox history. + +## Risks + +- Agent-authored proposals may overfit to task success and underweight least privilege. +- A local skill that teaches policy mutation could be abused if submission and approval boundaries are not crisp. +- Validation that is too weak will make approvals feel unsafe; validation that is too noisy will make the UX slow and frustrating. +- Expanding too quickly from network policy into filesystem/process policy could blur scope and delay a polished first release. +- Adding multiple proposal producers without a unified model could create duplicate or conflicting inbox entries. +- If the inbox UX is not excellent, developers may perceive OpenShell as secure but cumbersome and choose a less safe system with lower friction. +- Automatic apply under trusted-agent control could become a footgun if org ceiling semantics are vague or prover guarantees are misunderstood. + +## Alternatives + +### Keep the current Policy Advisor as-is + +This would preserve a useful feature, but it leaves the product short of the agent-native UX we want. Developers would still do too much translation work between denies, policy syntax, and human approval. + +### Rely only on a human-side coding agent outside the sandbox + +This is workable for expert users and is already partially demonstrated in tutorials, but it misses the core product insight: the in-sandbox agent has the best task context and should be the one drafting the narrowest possible change. + +### Let agents mutate policy directly without approval + +This would be faster, but it is not aligned with OpenShell's safety model and would erase the developer-control story that makes dynamic policy editing acceptable in the first place. + +### Require human approval for every policy change forever + +This is safer in a narrow sense, but it caps automation quality and makes some recurring workflows awkward or brittle. A trusted external approver model bounded by organizational ceilings provides a better long-term path. + +### Treat all network expansion as generic L4 access + +This would simplify the proposal model, but it would throw away one of OpenShell's strongest differentiators. For API-driven developer workflows, L7 REST enforcement is often the right least-privilege abstraction and should be surfaced directly in the RFC and UI. + +### Move proposal generation to the gateway + +This would centralize logic, but it weakens the current architecture. Sandbox-side analysis is the right default because it scales naturally and keeps task-local context near the source of truth. + +## Open questions + +- How should developer intent be declared for validation: + - free-form text + - a structured capability request + - both +- Do we want a single proposal inbox for all policy domains eventually, or separate inboxes that share infrastructure? +- How should org ceiling policy be authored and stored: OpenShell policy syntax, a separate constraint language, or both? +- Which identities are allowed to act as trusted external approvers, and how are those permissions delegated? +- How do we present auto-applied changes so users feel informed rather than surprised? +- When L7 policy is involved, how much raw request context can be safely shown to the developer without leaking sensitive request data? +- Should MCP-aware policy be modeled as network policy enrichment, a separate policy domain, or a capability layer above both?