From 57b82937d59e32308290fa4184c17fc4fb5ad2a7 Mon Sep 17 00:00:00 2001 From: Luke Hinds Date: Sat, 25 Apr 2026 10:12:46 +0100 Subject: [PATCH 01/91] feat: add interactive network approval and domain rejection - Deny/Approve buttons with Once/Session/Always duration dropdown - Blacklist (reject_domain) hosts auto-denied without prompting - Once approval bypasses runtime filter re-check - Deny(Session/Always) adds to runtime deny filter and persists - Tests for filter blacklist, deny scope, approval scope --- CHANGELOG.md | 4 + bindings/c/src/lib.rs | 1 + crates/nono-cli/Cargo.toml | 4 +- .../nono-cli/data/profile-authoring-guide.md | 16 + crates/nono-cli/src/cli.rs | 27 + crates/nono-cli/src/config/user.rs | 34 + crates/nono-cli/src/exec_strategy.rs | 22 + crates/nono-cli/src/execution_runtime.rs | 4 + crates/nono-cli/src/launch_runtime.rs | 3 + crates/nono-cli/src/main.rs | 10 + crates/nono-cli/src/network_approval.rs | 950 +++++++ crates/nono-cli/src/network_policy.rs | 9 +- crates/nono-cli/src/notification/linux.rs | 62 + crates/nono-cli/src/notification/macos.rs | 180 ++ crates/nono-cli/src/notification/mod.rs | 155 ++ crates/nono-cli/src/notification/windows.rs | 131 + crates/nono-cli/src/policy_cmd.rs | 2443 +++++++++++++++++ crates/nono-cli/src/profile/mod.rs | 57 +- crates/nono-cli/src/profile_runtime.rs | 13 + crates/nono-cli/src/proxy_runtime.rs | 130 +- crates/nono-cli/src/sandbox_prepare.rs | 12 + crates/nono-cli/src/supervised_runtime.rs | 15 +- crates/nono-proxy/Cargo.toml | 2 +- crates/nono-proxy/src/config.rs | 7 + crates/nono-proxy/src/connect.rs | 310 ++- crates/nono-proxy/src/filter.rs | 189 ++ crates/nono-proxy/src/lib.rs | 3 +- crates/nono-proxy/src/server.rs | 91 +- crates/nono/src/error.rs | 3 + crates/nono/src/lib.rs | 6 +- crates/nono/src/net_filter.rs | 328 ++- crates/nono/src/runtime_filter.rs | 218 ++ crates/nono/src/supervisor/mod.rs | 28 +- crates/nono/src/supervisor/types.rs | 68 + 34 files changed, 5475 insertions(+), 60 deletions(-) create mode 100644 crates/nono-cli/src/network_approval.rs create mode 100644 crates/nono-cli/src/notification/linux.rs create mode 100644 crates/nono-cli/src/notification/macos.rs create mode 100644 crates/nono-cli/src/notification/mod.rs create mode 100644 crates/nono-cli/src/notification/windows.rs create mode 100644 crates/nono-cli/src/policy_cmd.rs create mode 100644 crates/nono/src/runtime_filter.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 695b94ea5..a80224d3a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -733,6 +733,10 @@ GHSA-27vp-2mmc-vmh3 - *(network)* Support GitLab developer domains +- *(network)* Add interactive network approval with Deny/Approve and duration (Once/Session/Always) + +- *(network)* Auto-deny blacklisted (reject_domain) hosts without prompting + ### Testing diff --git a/bindings/c/src/lib.rs b/bindings/c/src/lib.rs index 4fc671076..7437dd71a 100644 --- a/bindings/c/src/lib.rs +++ b/bindings/c/src/lib.rs @@ -128,6 +128,7 @@ pub(crate) fn map_error(e: &nono::NonoError) -> types::NonoErrorCode { // see it through the FFI in normal use, but if they do, surface // as an invalid-arg style error code rather than a real fault. nono::NonoError::Cancelled(_) => NonoErrorCode::ErrInvalidArg, + nono::NonoError::InvalidConfig { .. } => NonoErrorCode::ErrConfigParse, } } diff --git a/crates/nono-cli/Cargo.toml b/crates/nono-cli/Cargo.toml index 09851cf8c..7c65c46f9 100644 --- a/crates/nono-cli/Cargo.toml +++ b/crates/nono-cli/Cargo.toml @@ -67,7 +67,7 @@ keyring = { version = "3", optional = true, default-features = false } # Keyless (Sigstore/Fulcio/Rekor) signing for instruction file attestation sigstore-sign = "0.8.0" -tokio = { version = "1", features = ["rt"] } +tokio = { version = "1", features = ["rt", "sync", "time", "rt-multi-thread"] } ureq = { version = "3", features = ["platform-verifier"] } semver = "1" @@ -95,6 +95,7 @@ dns-lookup = "2" # Pure-Rust Secret Service backend (zbus) — no libdbus link. Keep in sync with # the keyring features in crates/nono/Cargo.toml. keyring = { version = "3", features = ["async-secret-service", "crypto-rust", "async-io"], optional = true, default-features = false } +notify-rust = "4" [target.'cfg(target_os = "macos")'.dependencies] nix = { workspace = true, features = ["process", "signal", "fs", "user", "term"] } @@ -110,6 +111,7 @@ serde.workspace = true [dev-dependencies] jsonschema = "0.46" proptest = "1" +futures = "0.3" [lints] workspace = true diff --git a/crates/nono-cli/data/profile-authoring-guide.md b/crates/nono-cli/data/profile-authoring-guide.md index b4fdef11b..1d90e0d56 100644 --- a/crates/nono-cli/data/profile-authoring-guide.md +++ b/crates/nono-cli/data/profile-authoring-guide.md @@ -118,12 +118,28 @@ All path fields support variable expansion (see Section 6). | `block` | boolean | `false` | Block all network access. | | `network_profile` | string or null | inherit | Name from `network-policy.json` for proxy filtering. Set to `null` to clear inherited value. | | `allow_domain` | array of string or object | `[]` | Additional domains to allow through the proxy. Entries can be plain strings (CONNECT tunnel) or objects with endpoint rules (TLS-intercepted L7 filtering). Aliases: `proxy_allow`, `allow_proxy`. | +| `reject_domain` | array of string | `[]` | Domains to always reject, even if they appear in `allow_domain`. Supports `*.` wildcard suffixes (e.g. `*.tracker.com`). | | `credentials` | array of string | `[]` | Credential services to enable via reverse proxy. Alias: `proxy_credentials`. | | `open_port` | array of integer | `[]` | Localhost TCP IPC. Aliases: `port_allow`, `allow_port`. Port **0**: macOS only (`localhost:*` outbound); Linux: explicit ports. | | `listen_port` | array of integer | `[]` | TCP ports the sandboxed child may listen on. | | `custom_credentials` | map of string to credential def | `{}` | Custom credential route definitions (see below). | | `upstream_proxy` | string | `null` | Enterprise proxy address (`host:port`). Alias: `external_proxy`. | | `upstream_bypass` | array of string | `[]` | Hosts to bypass the upstream proxy. Supports `*.` wildcard suffixes. Alias: `external_proxy_bypass`. | +| `approval_mode` | string | `"off"` | How to handle requests to blocked hosts: `"off"` (deny immediately), `"ask"` (OS notification with action buttons). | +| `approval_timeout_secs` | integer | `60` | Seconds to wait for user approval before denying. Only applies when `approval_mode` is not `"off"`. | + +Example — allow API access but block known trackers, with runtime approval for unknown hosts: + +```json +{ + "network": { + "allow_domain": ["api.openai.com", "*.googleapis.com"], + "reject_domain": ["*.tracker.io", "ads.example.com"], + "approval_mode": "ask", + "approval_timeout_secs": 30 + } +} +``` #### allow_domain with endpoint restrictions diff --git a/crates/nono-cli/src/cli.rs b/crates/nono-cli/src/cli.rs index 3438f593f..ef6e6e0a8 100644 --- a/crates/nono-cli/src/cli.rs +++ b/crates/nono-cli/src/cli.rs @@ -879,6 +879,18 @@ pub struct CompletionsArgs { // / `ProfileShowArgs` / `ProfileDiffArgs` / `ProfileValidateArgs` via // `pub use` aliases so there is no parallel set of types to keep in sync. +#[derive(clap::ValueEnum, Clone, Debug, PartialEq, Eq)] +pub enum PolicyShowFormat { + Profile, + Manifest, +} + +#[derive(clap::ValueEnum, Clone, Debug, PartialEq, Eq)] +pub enum NetworkApprovalArg { + /// Prompt via OS notification/dialog with action buttons + Ask, +} + #[derive(Parser, Debug)] #[command(disable_help_flag = true)] pub struct ProfileCmdArgs { @@ -1179,6 +1191,20 @@ pub struct SandboxArgs { )] pub network_profile: Option, + /// Prompt to approve blocked network hosts at runtime + /// + /// When set to "ask", an OS notification/dialog appears when the + /// sandboxed process requests a host not on the allowlist. The user + /// can choose: Allow once, Always allow (persists to profile), + /// Deny once, or Always deny (adds to reject_domain in profile). + #[arg( + long, + value_name = "MODE", + env = "NONO_NETWORK_APPROVAL", + help_heading = "NETWORK" + )] + pub network_approval: Option, + /// Add a domain to the proxy allowlist (repeatable). /// Use a plain hostname for unrestricted access, or a URL with a path glob /// to restrict to specific endpoints (e.g., https://github.com/org/**) @@ -1613,6 +1639,7 @@ impl From for SandboxArgs { block_net: args.block_net, allow_net: false, network_profile: None, + network_approval: None, allow_proxy: Vec::new(), allow_bind: args.allow_bind, allow_port: args.allow_port, diff --git a/crates/nono-cli/src/config/user.rs b/crates/nono-cli/src/config/user.rs index ea6102527..e3e97cdfd 100644 --- a/crates/nono-cli/src/config/user.rs +++ b/crates/nono-cli/src/config/user.rs @@ -33,6 +33,20 @@ pub struct UserConfig { pub ui: UiSettings, #[serde(default)] pub redaction: RedactionSettings, + #[serde(default)] + pub network: NetworkApprovalConfig, +} + +/// Network approval configuration +#[derive(Debug, Clone, Default, Deserialize)] +pub struct NetworkApprovalConfig { + /// Whether to prompt the user when a blocked host is requested. + /// Values: "off" (default), "ask" + #[serde(default)] + pub approval_mode: Option, + /// Seconds to wait for user approval before denying (default: 60) + #[serde(default)] + pub approval_timeout_secs: Option, } /// UI display settings @@ -528,4 +542,24 @@ detach_sequence = "ctrl-]" .unwrap_or_default(); assert!(err.contains("detach sequence must contain at least two key presses")); } + + #[test] + fn test_network_approval_config_defaults() { + let toml = ""; + let config: UserConfig = toml::from_str(toml).expect("Failed to parse"); + assert!(config.network.approval_mode.is_none()); + assert!(config.network.approval_timeout_secs.is_none()); + } + + #[test] + fn test_network_approval_config_custom() { + let toml = r#" +[network] +approval_mode = "ask" +approval_timeout_secs = 120 +"#; + let config: UserConfig = toml::from_str(toml).expect("Failed to parse"); + assert_eq!(config.network.approval_mode.as_deref(), Some("ask")); + assert_eq!(config.network.approval_timeout_secs, Some(120)); + } } diff --git a/crates/nono-cli/src/exec_strategy.rs b/crates/nono-cli/src/exec_strategy.rs index c457c8cc6..618d7e881 100644 --- a/crates/nono-cli/src/exec_strategy.rs +++ b/crates/nono-cli/src/exec_strategy.rs @@ -2738,6 +2738,25 @@ fn handle_supervisor_message( recorder.record_open_url(url_request, success, error)?; } } + SupervisorMessage::NetworkApproval(request) => { + let request_id = request.request_id.clone(); + + let decision = match config.approval_backend.request_network_approval(&request) { + Ok(d) => d, + Err(e) => { + warn!("Network approval backend error, denying: {e}"); + nono::NetworkApprovalDecision::Denied { + reason: format!("Approval backend error: {e}"), + } + } + }; + + let response = SupervisorResponse::NetworkDecision { + request_id, + decision, + }; + sock.send_response(&response)?; + } } Ok(()) @@ -2817,6 +2836,9 @@ fn response_decision(response: &SupervisorResponse) -> ApprovalDecision { SupervisorResponse::UrlOpened { .. } => ApprovalDecision::Denied { reason: "invalid supervisor response type for capability decision".to_string(), }, + SupervisorResponse::NetworkDecision { .. } => ApprovalDecision::Denied { + reason: "invalid supervisor response type for capability decision".to_string(), + }, } } diff --git a/crates/nono-cli/src/execution_runtime.rs b/crates/nono-cli/src/execution_runtime.rs index 2b1a95627..bd92d0b6d 100644 --- a/crates/nono-cli/src/execution_runtime.rs +++ b/crates/nono-cli/src/execution_runtime.rs @@ -420,6 +420,10 @@ pub(crate) fn execute_sandboxed(plan: LaunchPlan) -> Result<()> { audit_signer: audit_signer.as_ref(), redaction_policy: &flags.redaction_policy, silent: flags.silent, + approval_backend: active_proxy + .approval_backend + .as_ref() + .map(|a| a.as_ref() as &dyn nono::ApprovalBackend), })?; // ---- After-hook execution (Unix-only) ---- diff --git a/crates/nono-cli/src/launch_runtime.rs b/crates/nono-cli/src/launch_runtime.rs index e7e7f9436..c5ff8439a 100644 --- a/crates/nono-cli/src/launch_runtime.rs +++ b/crates/nono-cli/src/launch_runtime.rs @@ -75,6 +75,7 @@ pub(crate) struct ProxyLaunchOptions { pub(crate) active: bool, pub(crate) network_profile: Option, pub(crate) allow_domain: Vec, + pub(crate) reject_domain: Vec, pub(crate) credentials: Vec, pub(crate) custom_credentials: HashMap, pub(crate) upstream_proxy: Option, @@ -87,6 +88,8 @@ pub(crate) struct ProxyLaunchOptions { #[cfg(target_os = "macos")] pub(crate) trust_proxy_ca: bool, pub(crate) proxy_ca_validity: Option, + pub(crate) network_approval_mode: crate::network_approval::NetworkApprovalMode, + pub(crate) network_approval_timeout_secs: u64, } #[derive(Clone)] diff --git a/crates/nono-cli/src/main.rs b/crates/nono-cli/src/main.rs index 61e1ecd1d..0fc610acd 100644 --- a/crates/nono-cli/src/main.rs +++ b/crates/nono-cli/src/main.rs @@ -33,7 +33,9 @@ mod legacy_cleanup; #[cfg(target_os = "macos")] mod macos_trust; mod migration; +mod network_approval; mod network_policy; +mod notification; mod open_url_runtime; mod output; mod pack_update_hint; @@ -268,6 +270,7 @@ mod tests { allow_domain: vec![profile::AllowDomainEntry::Plain( "docs.python.org".to_string(), )], + reject_domain: vec![], credentials: vec!["github".to_string()], custom_credentials: std::collections::HashMap::new(), upstream_proxy: None, @@ -286,6 +289,8 @@ mod tests { ignored_denial_paths: Vec::new(), allowed_env_vars: None, denied_env_vars: None, + profile_network_approval_mode: None, + profile_network_approval_timeout_secs: None, }; let effective = resolve_effective_proxy_settings(&args, &prepared); @@ -295,6 +300,7 @@ mod tests { EffectiveProxySettings { network_profile: None, allow_domain: Vec::new(), + reject_domain: Vec::new(), credentials: Vec::new(), } ); @@ -318,6 +324,7 @@ mod tests { allow_domain: vec![profile::AllowDomainEntry::Plain( "docs.python.org".to_string(), )], + reject_domain: vec![], credentials: vec!["github".to_string()], custom_credentials: std::collections::HashMap::new(), upstream_proxy: None, @@ -336,6 +343,8 @@ mod tests { ignored_denial_paths: Vec::new(), allowed_env_vars: None, denied_env_vars: None, + profile_network_approval_mode: None, + profile_network_approval_timeout_secs: None, }; let effective = resolve_effective_proxy_settings(&args, &prepared); @@ -348,6 +357,7 @@ mod tests { profile::AllowDomainEntry::Plain("docs.python.org".to_string()), profile::AllowDomainEntry::Plain("example.com".to_string()), ], + reject_domain: vec![], credentials: vec!["github".to_string(), "openai".to_string()], } ); diff --git a/crates/nono-cli/src/network_approval.rs b/crates/nono-cli/src/network_approval.rs new file mode 100644 index 000000000..5c18b863c --- /dev/null +++ b/crates/nono-cli/src/network_approval.rs @@ -0,0 +1,950 @@ +//! Network approval backend using OS notifications/dialogs. +//! +//! When `--network-approval ask` is enabled, this backend shows an +//! OS-level notification when the proxy blocks a request to an unknown +//! host. The user can approve (session-only or persistent) or deny +//! directly from the notification. +//! +//! Concurrent requests to the same host share a single notification +//! via the `pending` map + `Notify` pattern. + +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +use nono::{ + ApprovalBackend, ApprovalDecision, ApprovalScope, CapabilityRequest, NetworkApprovalDecision, + NetworkApprovalRequest, Result, RuntimeHostFilter, +}; + +use crate::notification::{self, ApprovalDuration, NotificationResult}; + +/// Shared tokio runtime for the synchronous `request_network_approval` +/// trait method. Created once on first use, not per-call. +static SYNC_RUNTIME: std::sync::OnceLock = std::sync::OnceLock::new(); + +fn sync_runtime() -> &'static tokio::runtime::Runtime { + SYNC_RUNTIME.get_or_init(|| { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("Failed to create shared approval runtime") + }) +} + +/// Mode controlling how network approval requests are handled. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum NetworkApprovalMode { + /// Deny unknown hosts immediately (default, current behavior) + #[default] + Off, + /// Prompt user via OS notification/dialog with action buttons + Ask, +} + +/// Network approval backend using OS notifications. +/// +/// Shows an OS-level notification when a blocked host is requested. +/// Handles deduplication of concurrent requests to the same host +/// and supports both session-only and persistent approval. +pub struct NetworkApprovalBackend { + mode: NetworkApprovalMode, + runtime_filter: RuntimeHostFilter, + timeout_secs: u64, + pending: Arc>>>, + config_writer: Option, +} + +/// Writes approved hosts directly to the active profile file for persistence. +/// +/// Uses [`crate::profile::resolve_profile_path`] to find the actual file +/// on disk, then modifies its `network.allow_domain` array in place. +/// Changes are immediately visible to the user. +/// +/// If the profile is a built-in (no file on disk), a new user profile file +/// is created at `~/.config/nono/profiles/.json` with an `extends` +/// field referencing the built-in, so the approved host is layered on top +/// of the built-in policy. +#[derive(Debug, Clone)] +pub struct ConfigWriter { + profile_path: std::path::PathBuf, + profile_name: String, +} + +impl ConfigWriter { + /// Create a new config writer for a profile name or direct file path. + /// + /// Resolves the actual file path using the same logic as + /// [`crate::profile::load_profile`]. If the profile is built-in (no + /// file on disk), the writer targets + /// `~/.config/nono/profiles/.json` and will create the file + /// on first persistent approval. + pub fn new(profile_name_or_path: &str) -> Self { + let profile_name = profile_name_or_path.to_string(); + let profile_path = crate::profile::resolve_profile_path(profile_name_or_path) + .or_else(|| crate::profile::get_user_profile_path(&profile_name).ok()) + .unwrap_or_else(|| { + let fallback = dirs::config_dir() + .unwrap_or_else(|| std::path::PathBuf::from("/tmp")) + .join("nono") + .join("profiles") + .join(format!("{profile_name}.json")); + tracing::warn!( + "ConfigWriter: cannot resolve profile path for '{profile_name_or_path}' — \ + using fallback {}", + fallback.display() + ); + fallback + }); + Self { + profile_path, + profile_name, + } + } + + /// Append a host to the `network.allow_domain` array in the profile file. + /// + /// Reads the current file, adds the host (skipping duplicates), and + /// writes it back. Creates the file with an `extends` reference if it + /// doesn't exist yet. + /// + /// Best-effort: if the write fails, the host is still approved for this + /// session (degraded to session-only). + pub fn persist_host(&self, host: &str) -> Result<()> { + self.persist_domain("allow_domain", host) + } + + /// Append a host to the `network.reject_domain` array in the profile file. + /// + /// Reads the current file, adds the host (skipping duplicates), and + /// writes it back. Creates the file with an `extends` reference if it + /// doesn't exist yet. + /// + /// Best-effort: if the write fails, the host is still denied for this + /// session. + pub fn persist_deny(&self, host: &str) -> Result<()> { + self.persist_domain("reject_domain", host) + } + + /// Shared implementation for persisting a domain to a network config array. + /// + /// `field` is either `"allow_domain"` or `"reject_domain"`. + fn persist_domain(&self, field: &str, host: &str) -> Result<()> { + let content = match std::fs::read_to_string(&self.profile_path) { + Ok(c) => c, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + let mut fields = serde_json::Map::new(); + fields.insert(field.to_string(), serde_json::json!([host])); + let value = serde_json::json!({ + "extends": self.profile_name, + "network": fields + }); + self.write_profile(&value)?; + tracing::info!( + "Created new profile {} with {field} host {host}", + self.profile_path.display() + ); + return Ok(()); + } + Err(e) => { + return Err(nono::NonoError::ConfigWrite { + path: self.profile_path.clone(), + source: e, + }); + } + }; + + let mut value: serde_json::Value = if content.trim().is_empty() { + let mut fields = serde_json::Map::new(); + fields.insert(field.to_string(), serde_json::json!([])); + serde_json::Value::Object(serde_json::Map::from_iter([( + "network".to_string(), + serde_json::Value::Object(fields), + )])) + } else { + match serde_json::from_str(&content) { + Ok(v) => v, + Err(e) => { + return Err(nono::NonoError::InvalidConfig { + reason: format!( + "Failed to parse profile file {}: {e}", + self.profile_path.display() + ), + }); + } + } + }; + + if !value.is_object() { + value = serde_json::Value::Object(serde_json::Map::new()); + } + + let pointer = format!("/network/{field}"); + if value.pointer(&pointer).is_none() { + if value.get("network").is_none() { + let mut fields = serde_json::Map::new(); + fields.insert(field.to_string(), serde_json::json!([])); + value["network"] = serde_json::Value::Object(fields); + } else if value["network"].get(field).is_none() { + value["network"][field] = serde_json::json!([]); + } + } + + let domain_list = value.pointer_mut(&pointer).expect("field just ensured"); + + if let Some(arr) = domain_list.as_array() { + if arr.iter().any(|v| v.as_str() == Some(host)) { + return Ok(()); + } + } + + if let Some(arr) = domain_list.as_array_mut() { + arr.push(serde_json::Value::String(host.to_string())); + } + + self.write_profile(&value)?; + + tracing::info!( + "{field} host {host} persisted to {}", + self.profile_path.display() + ); + Ok(()) + } + + /// Write a JSON value to the profile file, creating parent dirs as needed. + fn write_profile(&self, value: &serde_json::Value) -> Result<()> { + let updated = + serde_json::to_string_pretty(value).map_err(|e| nono::NonoError::InvalidConfig { + reason: format!("Failed to serialize profile JSON: {e}"), + })?; + + if let Some(parent) = self.profile_path.parent() { + std::fs::create_dir_all(parent).map_err(|e| nono::NonoError::ConfigWrite { + path: parent.to_path_buf(), + source: e, + })?; + } + + std::fs::write(&self.profile_path, &updated).map_err(|e| nono::NonoError::ConfigWrite { + path: self.profile_path.clone(), + source: e, + }) + } +} + +impl NetworkApprovalBackend { + /// Create a new network approval backend. + pub fn new( + mode: NetworkApprovalMode, + runtime_filter: RuntimeHostFilter, + timeout_secs: u64, + config_writer: Option, + ) -> Self { + Self { + mode, + runtime_filter, + timeout_secs, + pending: Arc::new(Mutex::new(HashMap::new())), + config_writer, + } + } + + /// Process a network approval request from within an async context. + /// + /// Unlike the `ApprovalBackend` trait method (which creates its own + /// tokio runtime for sync callers), this method uses `spawn_blocking` + /// directly and must be called from within an existing tokio runtime. + pub async fn request_network_approval_async( + &self, + request: &NetworkApprovalRequest, + ) -> NetworkApprovalDecision { + let host = request.host.clone(); + tracing::info!("Network approval requested for host: {host}"); + + let (notify, is_first) = { + let mut pending = self.pending.lock().expect("pending lock poisoned"); + if let Some(existing) = pending.get(&host) { + tracing::info!("Host {host} has pending approval — waiting on existing dialog"); + (existing.clone(), false) + } else { + let n = Arc::new(tokio::sync::Notify::new()); + pending.insert(host.clone(), n.clone()); + (n, true) + } + }; + + if !is_first { + let _ = tokio::time::timeout( + std::time::Duration::from_secs(self.timeout_secs), + notify.notified(), + ) + .await; + + let pending = self.pending.lock().expect("pending lock poisoned"); + return if pending.contains_key(&host) { + NetworkApprovalDecision::Denied { + reason: "Approval timed out".to_string(), + } + } else { + NetworkApprovalDecision::Granted(ApprovalScope::Session) + }; + } + + let decision = match self.mode { + NetworkApprovalMode::Off => NetworkApprovalDecision::Denied { + reason: "Network approval not configured".to_string(), + }, + NetworkApprovalMode::Ask => { + let host_clone = host.clone(); + let timeout = self.timeout_secs; + let result = tokio::task::spawn_blocking(move || { + notification::show_approval_dialog(&host_clone, timeout) + }) + .await + .unwrap_or(NotificationResult::Dismissed); + + match result { + NotificationResult::Approve(duration) => match duration { + ApprovalDuration::Once => { + NetworkApprovalDecision::Granted(ApprovalScope::Once) + } + ApprovalDuration::Session => { + if let Err(e) = self.runtime_filter.add_host(&host) { + tracing::warn!("Failed to add host to runtime filter: {e}"); + } + NetworkApprovalDecision::Granted(ApprovalScope::Session) + } + ApprovalDuration::Always => { + if let Err(e) = self.runtime_filter.add_host(&host) { + tracing::warn!("Failed to add host to runtime filter: {e}"); + } + if let Some(ref writer) = self.config_writer { + if let Err(e) = writer.persist_host(&host) { + tracing::warn!( + "Failed to persist host to config (session-only fallback): {e}" + ); + } + } + NetworkApprovalDecision::Granted(ApprovalScope::Persistent) + } + }, + NotificationResult::Deny(duration) => match duration { + ApprovalDuration::Once => NetworkApprovalDecision::Denied { + reason: "User denied the request".to_string(), + }, + ApprovalDuration::Session => { + if let Err(e) = self.runtime_filter.add_deny_host(&host) { + tracing::warn!("Failed to add deny host to runtime filter: {e}"); + } + NetworkApprovalDecision::Denied { + reason: "User denied the request for this session".to_string(), + } + } + ApprovalDuration::Always => { + if let Err(e) = self.runtime_filter.add_deny_host(&host) { + tracing::warn!("Failed to add deny host to runtime filter: {e}"); + } + if let Some(ref writer) = self.config_writer { + if let Err(e) = writer.persist_deny(&host) { + tracing::warn!( + "Failed to persist deny to config (session-only fallback): {e}" + ); + } + } + NetworkApprovalDecision::Denied { + reason: "User permanently denied the host".to_string(), + } + } + }, + NotificationResult::Dismissed => NetworkApprovalDecision::Denied { + reason: "Notification dismissed or timed out".to_string(), + }, + } + } + }; + + self.pending + .lock() + .expect("pending lock poisoned") + .remove(&host); + notify.notify_waiters(); + + tracing::info!("Network approval decision for {host}: {:?}", decision); + decision + } +} + +impl ApprovalBackend for NetworkApprovalBackend { + fn request_capability(&self, _request: &CapabilityRequest) -> Result { + Ok(ApprovalDecision::Denied { + reason: "NetworkApprovalBackend does not handle capability requests".to_string(), + }) + } + + fn request_network_approval( + &self, + request: &NetworkApprovalRequest, + ) -> Result { + let host = request.host.clone(); + + let (notify, is_first) = { + let mut pending = self.pending.lock().expect("pending lock poisoned"); + if let Some(existing) = pending.get(&host) { + (existing.clone(), false) + } else { + let n = Arc::new(tokio::sync::Notify::new()); + pending.insert(host.clone(), n.clone()); + (n, true) + } + }; + + if !is_first { + sync_runtime().block_on(async { + let _ = tokio::time::timeout( + std::time::Duration::from_secs(self.timeout_secs), + notify.notified(), + ) + .await; + }); + + let pending = self.pending.lock().expect("pending lock poisoned"); + return if pending.contains_key(&host) { + Ok(NetworkApprovalDecision::Denied { + reason: "Approval timed out".to_string(), + }) + } else { + Ok(NetworkApprovalDecision::Granted(ApprovalScope::Session)) + }; + } + + let decision = match self.mode { + NetworkApprovalMode::Off => NetworkApprovalDecision::Denied { + reason: "Network approval not configured".to_string(), + }, + NetworkApprovalMode::Ask => { + let host_clone = host.clone(); + let timeout = self.timeout_secs; + let result = sync_runtime().block_on(async { + tokio::task::spawn_blocking(move || { + notification::show_approval_dialog(&host_clone, timeout) + }) + .await + .unwrap_or(NotificationResult::Dismissed) + }); + + match result { + NotificationResult::Approve(duration) => match duration { + ApprovalDuration::Once => { + NetworkApprovalDecision::Granted(ApprovalScope::Once) + } + ApprovalDuration::Session => { + if let Err(e) = self.runtime_filter.add_host(&host) { + tracing::warn!("Failed to add host to runtime filter: {e}"); + } + NetworkApprovalDecision::Granted(ApprovalScope::Session) + } + ApprovalDuration::Always => { + if let Err(e) = self.runtime_filter.add_host(&host) { + tracing::warn!("Failed to add host to runtime filter: {e}"); + } + if let Some(ref writer) = self.config_writer { + if let Err(e) = writer.persist_host(&host) { + tracing::warn!( + "Failed to persist host to config (session-only fallback): {e}" + ); + } + } + NetworkApprovalDecision::Granted(ApprovalScope::Persistent) + } + }, + NotificationResult::Deny(duration) => match duration { + ApprovalDuration::Once => NetworkApprovalDecision::Denied { + reason: "User denied the request".to_string(), + }, + ApprovalDuration::Session => { + if let Err(e) = self.runtime_filter.add_deny_host(&host) { + tracing::warn!("Failed to add deny host to runtime filter: {e}"); + } + NetworkApprovalDecision::Denied { + reason: "User denied the request for this session".to_string(), + } + } + ApprovalDuration::Always => { + if let Err(e) = self.runtime_filter.add_deny_host(&host) { + tracing::warn!("Failed to add deny host to runtime filter: {e}"); + } + if let Some(ref writer) = self.config_writer { + if let Err(e) = writer.persist_deny(&host) { + tracing::warn!( + "Failed to persist deny to config (session-only fallback): {e}" + ); + } + } + NetworkApprovalDecision::Denied { + reason: "User permanently denied the host".to_string(), + } + } + }, + NotificationResult::Dismissed => NetworkApprovalDecision::Denied { + reason: "Notification dismissed or timed out".to_string(), + }, + } + } + }; + + self.pending + .lock() + .expect("pending lock poisoned") + .remove(&host); + notify.notify_waiters(); + + Ok(decision) + } + + fn backend_name(&self) -> &str { + match self.mode { + NetworkApprovalMode::Off => "network-off", + NetworkApprovalMode::Ask => "network-notification", + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use nono::HostFilter; + + fn make_request(host: &str) -> NetworkApprovalRequest { + NetworkApprovalRequest { + request_id: "test".to_string(), + host: host.to_string(), + port: Some(443), + reason: None, + child_pid: 0, + session_id: "test".to_string(), + } + } + + fn deny_default_filter() -> RuntimeHostFilter { + RuntimeHostFilter::new(HostFilter::new(&["__sentinel__.invalid".to_string()])) + } + + #[test] + fn test_config_writer_named_profile_no_file() { + let writer = ConfigWriter::new("nonexistent-profile-xyz"); + assert!( + writer + .profile_path + .to_string_lossy() + .contains("nonexistent-profile-xyz"), + "should have a fallback path: {}", + writer.profile_path.display() + ); + } + + #[test] + fn test_config_writer_direct_path() { + let dir = tempfile::tempdir().expect("tempdir"); + let profile_path = dir.path().join("myconfig.json"); + std::fs::write(&profile_path, "{}").expect("write"); + let writer = ConfigWriter::new(profile_path.to_str().expect("valid utf-8 path")); + assert_eq!(writer.profile_path, profile_path); + } + + #[test] + fn test_persist_host_creates_allow_domain() { + let dir = tempfile::tempdir().expect("tempdir"); + let profile_path = dir.path().join("test-profile.json"); + std::fs::write(&profile_path, r#"{"meta":{"name":"test"}}"#).expect("write"); + let writer = ConfigWriter::new(profile_path.to_str().expect("valid utf-8 path")); + + writer.persist_host("example.com").expect("persist"); + let content = std::fs::read_to_string(&profile_path).expect("read"); + let parsed: serde_json::Value = serde_json::from_str(&content).expect("parse"); + assert_eq!(parsed["meta"]["name"], "test"); + assert_eq!(parsed["network"]["allow_domain"][0], "example.com"); + } + + #[test] + fn test_persist_host_appends_to_existing() { + let dir = tempfile::tempdir().expect("tempdir"); + let profile_path = dir.path().join("test-profile.json"); + std::fs::write(&profile_path, r#"{"network":{"allow_domain":["a.com"]}}"#).expect("write"); + let writer = ConfigWriter::new(profile_path.to_str().expect("valid utf-8 path")); + + writer.persist_host("b.com").expect("persist b"); + let content = std::fs::read_to_string(&profile_path).expect("read"); + let parsed: serde_json::Value = serde_json::from_str(&content).expect("parse"); + let domains = parsed["network"]["allow_domain"].as_array().expect("array"); + assert!(domains.iter().any(|v| v.as_str() == Some("a.com"))); + assert!(domains.iter().any(|v| v.as_str() == Some("b.com"))); + } + + #[test] + fn test_persist_host_no_duplicate() { + let dir = tempfile::tempdir().expect("tempdir"); + let profile_path = dir.path().join("test-profile.json"); + std::fs::write( + &profile_path, + r#"{"network":{"allow_domain":["example.com"]}}"#, + ) + .expect("write"); + let writer = ConfigWriter::new(profile_path.to_str().expect("valid utf-8 path")); + + writer.persist_host("example.com").expect("persist 2"); + let content = std::fs::read_to_string(&profile_path).expect("read"); + let parsed: serde_json::Value = serde_json::from_str(&content).expect("parse"); + let count = parsed["network"]["allow_domain"] + .as_array() + .expect("array") + .iter() + .filter(|v| v.as_str() == Some("example.com")) + .count(); + assert_eq!(count, 1); + } + + #[test] + fn test_persist_host_creates_file_for_missing_profile() { + let dir = tempfile::tempdir().expect("tempdir"); + let profile_path = dir.path().join("new-profile.json"); + let writer = ConfigWriter { + profile_path: profile_path.clone(), + profile_name: "test-profile".to_string(), + }; + + writer.persist_host("example.com").expect("persist"); + assert!(profile_path.exists(), "profile file should be created"); + let content = std::fs::read_to_string(&profile_path).expect("read"); + let parsed: serde_json::Value = serde_json::from_str(&content).expect("parse"); + assert_eq!(parsed["extends"], "test-profile"); + assert_eq!(parsed["network"]["allow_domain"][0], "example.com"); + } + + #[test] + fn test_persist_deny_creates_reject_domain() { + let dir = tempfile::tempdir().expect("tempdir"); + let profile_path = dir.path().join("test-profile.json"); + std::fs::write(&profile_path, r#"{"meta":{"name":"test"}}"#).expect("write"); + let writer = ConfigWriter::new(profile_path.to_str().expect("valid utf-8 path")); + + writer.persist_deny("evil.com").expect("persist_deny"); + let content = std::fs::read_to_string(&profile_path).expect("read"); + let parsed: serde_json::Value = serde_json::from_str(&content).expect("parse"); + assert_eq!(parsed["meta"]["name"], "test"); + assert_eq!(parsed["network"]["reject_domain"][0], "evil.com"); + } + + #[test] + fn test_persist_deny_no_duplicate() { + let dir = tempfile::tempdir().expect("tempdir"); + let profile_path = dir.path().join("test-profile.json"); + std::fs::write( + &profile_path, + r#"{"network":{"reject_domain":["evil.com"]}}"#, + ) + .expect("write"); + let writer = ConfigWriter::new(profile_path.to_str().expect("valid utf-8 path")); + + writer.persist_deny("evil.com").expect("persist_deny 2"); + let content = std::fs::read_to_string(&profile_path).expect("read"); + let parsed: serde_json::Value = serde_json::from_str(&content).expect("parse"); + let count = parsed["network"]["reject_domain"] + .as_array() + .expect("array") + .iter() + .filter(|v| v.as_str() == Some("evil.com")) + .count(); + assert_eq!(count, 1); + } + + #[test] + fn test_persist_deny_creates_file_for_missing_profile() { + let dir = tempfile::tempdir().expect("tempdir"); + let profile_path = dir.path().join("new-profile.json"); + let writer = ConfigWriter { + profile_path: profile_path.clone(), + profile_name: "test-profile".to_string(), + }; + + writer.persist_deny("evil.com").expect("persist_deny"); + assert!(profile_path.exists(), "profile file should be created"); + let content = std::fs::read_to_string(&profile_path).expect("read"); + let parsed: serde_json::Value = serde_json::from_str(&content).expect("parse"); + assert_eq!(parsed["extends"], "test-profile"); + assert_eq!(parsed["network"]["reject_domain"][0], "evil.com"); + } + + #[test] + fn test_runtime_filter_add_deny_host() { + let runtime_filter = deny_default_filter(); + + runtime_filter.add_host("approved.com").expect("add_host"); + runtime_filter + .add_deny_host("approved.com") + .expect("add_deny_host"); + + assert!( + !runtime_filter.check_host("approved.com", &[]).is_allowed(), + "deny should override allow" + ); + } + + #[test] + fn test_network_approval_mode_default_is_off() { + assert_eq!(NetworkApprovalMode::default(), NetworkApprovalMode::Off); + } + + #[tokio::test] + async fn test_approval_adds_host_to_runtime_filter() { + let runtime_filter = deny_default_filter(); + let filter_clone = runtime_filter.clone(); + + assert!( + !runtime_filter.check_host("example.com", &[]).is_allowed(), + "host should be denied before approval" + ); + + let backend = + NetworkApprovalBackend::new(NetworkApprovalMode::Off, runtime_filter, 5, None); + + let decision = backend + .request_network_approval_async(&make_request("example.com")) + .await; + + assert!(decision.is_denied(), "Off mode should deny: {:?}", decision); + assert!( + !filter_clone.check_host("example.com", &[]).is_allowed(), + "host should still be denied after Off-mode denial" + ); + } + + #[tokio::test] + async fn test_dedup_concurrent_requests_same_host() { + let runtime_filter = deny_default_filter(); + + let backend = + NetworkApprovalBackend::new(NetworkApprovalMode::Off, runtime_filter, 2, None); + + let backend = Arc::new(backend); + + let mut handles = Vec::new(); + for _ in 0..5 { + let b = Arc::clone(&backend); + handles.push(tokio::spawn(async move { + b.request_network_approval_async(&make_request("same-host.com")) + .await + })); + } + + let results: Vec<_> = futures::future::join_all(handles).await; + + let denied_count = results + .iter() + .filter(|r| r.as_ref().is_ok_and(|d| d.is_denied())) + .count(); + + assert_eq!(denied_count, 5, "all requests should be denied in Off mode"); + + let pending = backend.pending.lock().expect("pending lock poisoned"); + assert!( + pending.is_empty(), + "pending map should be empty after all decisions: {:?}", + pending.keys().collect::>() + ); + } + + #[test] + fn test_runtime_filter_shared_between_backend_and_proxy() { + let runtime_filter = deny_default_filter(); + let proxy_filter = nono_proxy::filter::RuntimeProxyFilter::new(runtime_filter.clone()); + + assert!( + !runtime_filter.check_host("newhost.com", &[]).is_allowed(), + "host should be denied initially" + ); + + runtime_filter + .add_host("newhost.com") + .expect("add host to backend filter"); + + assert!( + runtime_filter.check_host("newhost.com", &[]).is_allowed(), + "backend filter should allow after add_host" + ); + + assert!( + proxy_filter + .check_host_with_ips("newhost.com", &[]) + .is_allowed(), + "proxy filter should see the same update (shared Arc)" + ); + } + + #[test] + fn test_runtime_filter_persistent_approval_updates_proxy() { + let runtime_filter = deny_default_filter(); + let proxy_filter = nono_proxy::filter::RuntimeProxyFilter::new(runtime_filter.clone()); + + let dir = tempfile::tempdir().expect("tempdir"); + let profile_path = dir.path().join("test-profile.json"); + std::fs::write( + &profile_path, + r#"{"network":{"allow_domain":["existing.com"]}}"#, + ) + .expect("write"); + + let writer = ConfigWriter::new(profile_path.to_str().expect("valid utf-8 path")); + + runtime_filter.add_host("newhost.com").expect("add host"); + writer.persist_host("newhost.com").expect("persist"); + + assert!( + proxy_filter + .check_host_with_ips("newhost.com", &[]) + .is_allowed(), + "proxy should allow host after backend approval" + ); + + let content = std::fs::read_to_string(&profile_path).expect("read"); + let parsed: serde_json::Value = serde_json::from_str(&content).expect("parse"); + let domains = parsed["network"]["allow_domain"].as_array().expect("array"); + assert!( + domains.iter().any(|v| v.as_str() == Some("newhost.com")), + "host should be persisted in profile file" + ); + assert!( + domains.iter().any(|v| v.as_str() == Some("existing.com")), + "existing hosts should be preserved" + ); + } + + #[test] + fn test_approve_once_does_not_add_to_runtime_filter() { + let runtime_filter = deny_default_filter(); + let proxy_filter = nono_proxy::filter::RuntimeProxyFilter::new(runtime_filter.clone()); + + // Simulate Approve(Once): host should NOT be in runtime filter + // The connect.rs code bypasses the runtime filter check for Once scope + assert!( + !runtime_filter.check_host("once-host.com", &[]).is_allowed(), + "host should not be in runtime filter after Once approval" + ); + assert!( + !proxy_filter + .check_host_with_ips("once-host.com", &[]) + .is_allowed(), + "proxy filter should also not allow (Once = single request only)" + ); + } + + #[test] + fn test_approve_session_adds_to_runtime_filter() { + let runtime_filter = deny_default_filter(); + let proxy_filter = nono_proxy::filter::RuntimeProxyFilter::new(runtime_filter.clone()); + + // Simulate Approve(Session): add_host to runtime filter (what the backend does) + runtime_filter + .add_host("session-host.com") + .expect("add host"); + + assert!( + runtime_filter + .check_host("session-host.com", &[]) + .is_allowed(), + "host should be allowed in runtime filter after Session approval" + ); + assert!( + proxy_filter + .check_host_with_ips("session-host.com", &[]) + .is_allowed(), + "proxy filter should allow after Session approval" + ); + } + + #[test] + fn test_deny_session_adds_to_deny_filter() { + let runtime_filter = deny_default_filter(); + let proxy_filter = nono_proxy::filter::RuntimeProxyFilter::new(runtime_filter.clone()); + + // Simulate Deny(Session): add_deny_host (what the backend does) + runtime_filter + .add_deny_host("denied-session.com") + .expect("add deny host"); + + assert!( + !runtime_filter + .check_host("denied-session.com", &[]) + .is_allowed(), + "host should be denied in runtime filter after Deny(Session)" + ); + assert!( + !proxy_filter + .check_host_with_ips("denied-session.com", &[]) + .is_allowed(), + "proxy filter should deny after Deny(Session)" + ); + } + + #[test] + fn test_deny_always_adds_to_deny_filter_and_persists() { + let runtime_filter = deny_default_filter(); + let proxy_filter = nono_proxy::filter::RuntimeProxyFilter::new(runtime_filter.clone()); + + let dir = tempfile::tempdir().expect("tempdir"); + let profile_path = dir.path().join("test-profile.json"); + std::fs::write( + &profile_path, + r#"{"network":{"reject_domain":["existing-evil.com"]}}"#, + ) + .expect("write"); + let writer = ConfigWriter::new(profile_path.to_str().expect("valid utf-8 path")); + + // Simulate Deny(Always): add_deny_host + persist_deny + runtime_filter + .add_deny_host("always-denied.com") + .expect("add deny host"); + writer + .persist_deny("always-denied.com") + .expect("persist deny"); + + assert!( + !proxy_filter + .check_host_with_ips("always-denied.com", &[]) + .is_allowed(), + "proxy filter should deny after Deny(Always)" + ); + + let content = std::fs::read_to_string(&profile_path).expect("read"); + let parsed: serde_json::Value = serde_json::from_str(&content).expect("parse"); + let domains = parsed["network"]["reject_domain"] + .as_array() + .expect("array"); + assert!( + domains + .iter() + .any(|v| v.as_str() == Some("always-denied.com")), + "denied host should be persisted in profile" + ); + assert!( + domains + .iter() + .any(|v| v.as_str() == Some("existing-evil.com")), + "existing denied hosts should be preserved" + ); + } + + #[test] + fn test_deny_once_does_not_add_to_deny_filter() { + let runtime_filter = deny_default_filter(); + + // Simulate Deny(Once): host NOT added to deny filter + // Next request to same host would prompt again + assert!( + !runtime_filter + .check_host("once-denied.com", &[]) + .is_allowed(), + "host denied by default (no allowlist entry)" + ); + // But it's not explicitly in the deny list either — + // the next request would go through the same approval flow + } +} diff --git a/crates/nono-cli/src/network_policy.rs b/crates/nono-cli/src/network_policy.rs index 9f830a5d1..90c0846f0 100644 --- a/crates/nono-cli/src/network_policy.rs +++ b/crates/nono-cli/src/network_policy.rs @@ -300,7 +300,11 @@ pub fn resolve_credentials( /// /// Combines resolved hosts/suffixes with credential routes and optional /// CLI overrides (extra hosts). -pub fn build_proxy_config(resolved: &ResolvedNetworkPolicy, extra_hosts: &[String]) -> ProxyConfig { +pub fn build_proxy_config( + resolved: &ResolvedNetworkPolicy, + extra_hosts: &[String], + rejected_hosts: &[String], +) -> ProxyConfig { let mut allowed_hosts = resolved.hosts.clone(); // Convert suffixes to wildcard format for the proxy filter for suffix in &resolved.suffixes { @@ -316,6 +320,7 @@ pub fn build_proxy_config(resolved: &ResolvedNetworkPolicy, extra_hosts: &[Strin ProxyConfig { allowed_hosts, + rejected_hosts: rejected_hosts.to_vec(), routes: resolved.routes.clone(), ..Default::default() } @@ -709,7 +714,7 @@ mod tests { routes: vec![], profile_credentials: vec![], }; - let config = build_proxy_config(&resolved, &["extra.example.com".to_string()]); + let config = build_proxy_config(&resolved, &["extra.example.com".to_string()], &[]); assert!(config.allowed_hosts.contains(&"api.openai.com".to_string())); assert!( config diff --git a/crates/nono-cli/src/notification/linux.rs b/crates/nono-cli/src/notification/linux.rs new file mode 100644 index 000000000..1841ad6e7 --- /dev/null +++ b/crates/nono-cli/src/notification/linux.rs @@ -0,0 +1,62 @@ +//! Linux notification backend using `notify-rust` with XDG action buttons. +//! +//! On Linux, `notify-rust` supports interactive action buttons via the +//! XDG Desktop Notification specification. The user can click one of +//! six actions directly in the notification: Approve/Deny combined with +//! Once/Session/Always duration. + +use super::{ApprovalDuration, NotificationResult}; + +/// Show a Linux desktop notification with action buttons for network approval. +/// +/// Uses `notify-rust` to create an XDG notification with action +/// buttons. Blocks until the user clicks a button or the notification +/// is closed/dismissed. +pub fn show_linux_notification(host: &str, timeout_secs: u64) -> NotificationResult { + use notify_rust::{Hint, Notification, Timeout}; + + let result = Notification::new() + .appname("nono") + .summary("nono: Network access blocked") + .body(&format!( + "Request to {host} was blocked.\n\ + Allow this host to access the network?" + )) + .action("approve_once", "Approve \u{2013} Once") + .action("approve_session", "Approve \u{2013} Session") + .action("approve_always", "Approve \u{2013} Always") + .action("deny_once", "Deny \u{2013} Once") + .action("deny_session", "Deny \u{2013} Session") + .action("deny_always", "Deny \u{2013} Always") + .hint(Hint::Resident(true)) + .timeout(Timeout::Milliseconds( + (timeout_secs * 1000).try_into().unwrap_or(u32::MAX), + )) + .show(); + + match result { + Ok(handle) => { + let mut action_result = NotificationResult::Dismissed; + handle.wait_for_action(|action| { + action_result = match action { + "approve_once" => NotificationResult::Approve(ApprovalDuration::Once), + "approve_session" => NotificationResult::Approve(ApprovalDuration::Session), + "approve_always" => NotificationResult::Approve(ApprovalDuration::Always), + "deny_once" => NotificationResult::Deny(ApprovalDuration::Once), + "deny_session" => NotificationResult::Deny(ApprovalDuration::Session), + "deny_always" => NotificationResult::Deny(ApprovalDuration::Always), + "__closed" => NotificationResult::Dismissed, + _ => { + tracing::warn!("Unknown Linux notification action: {action}"); + NotificationResult::Dismissed + } + }; + }); + action_result + } + Err(e) => { + tracing::warn!("Failed to show Linux notification: {e}"); + NotificationResult::Dismissed + } + } +} diff --git a/crates/nono-cli/src/notification/macos.rs b/crates/nono-cli/src/notification/macos.rs new file mode 100644 index 000000000..b56ffb085 --- /dev/null +++ b/crates/nono-cli/src/notification/macos.rs @@ -0,0 +1,180 @@ +//! macOS notification backend using `osascript`. +//! +//! macOS `display dialog` only supports up to 3 buttons, so we use +//! `choose from list` for the approval dialog. The dialog presents +//! six options: Approve/Deny x Once/Session/Always. +//! +//! This approach: +//! - Works without an app bundle +//! - Gives a real modal dialog (not a fleeting banner) +//! - Supports timeout by killing the osascript process after N seconds +//! - Returns which option was selected + +use super::{ApprovalDuration, NotificationResult}; + +/// Show a macOS dialog asking the user to approve a network request. +/// +/// Uses `osascript` with `choose from list` to present six options: +/// Approve/Deny combined with Once/Session/Always duration. +/// If the user cancels the dialog (presses Cancel or it times out), +/// returns `Dismissed`. +pub fn show_macos_dialog(host: &str, timeout_secs: u64) -> NotificationResult { + let script = format!( + "try\n\ + \tset theChoice to choose from list {{\"Approve \u{2013} Once\", \"Approve \u{2013} Session\", \"Approve \u{2013} Always\", \"Deny \u{2013} Once\", \"Deny \u{2013} Session\", \"Deny \u{2013} Always\"}} \ + with title \"nono: Network access blocked\" \ + with prompt \"Host: {host}\" \ + default items {{\"Approve \u{2013} Session\"}}\n\ + \tif theChoice is false then\n\ + \t\treturn \"Dismissed\"\n\ + \telse\n\ + \t\treturn item 1 of theChoice\n\ + \tend if\n\ + on error number -128\n\ + \treturn \"Dismissed\"\n\ + end try" + ); + + let mut child = match std::process::Command::new("osascript") + .arg("-e") + .arg(&script) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::null()) + .spawn() + { + Ok(c) => c, + Err(e) => { + tracing::warn!("Failed to run osascript for network approval: {e}"); + return NotificationResult::Dismissed; + } + }; + + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(timeout_secs); + loop { + match child.try_wait() { + Ok(Some(status)) => { + if !status.success() { + return NotificationResult::Dismissed; + } + let stdout = child.stdout.take().map_or(String::new(), |mut out| { + let mut buf = String::new(); + let _ = std::io::Read::read_to_string(&mut out, &mut buf); + buf + }); + return parse_response(&stdout); + } + Ok(None) => { + if std::time::Instant::now() >= deadline { + let _ = child.kill(); + let _ = child.wait(); + return NotificationResult::Dismissed; + } + std::thread::sleep(std::time::Duration::from_millis(200)); + } + Err(e) => { + tracing::warn!("osascript wait error: {e}"); + let _ = child.kill(); + return NotificationResult::Dismissed; + } + } + } +} + +const EN_DASH: &str = "\u{2013}"; + +fn parse_response(stdout: &str) -> NotificationResult { + let trimmed = stdout.trim(); + if let Some(duration_str) = trimmed.strip_prefix(&format!("Approve {EN_DASH} ")) { + match duration_str.parse::() { + Ok(d) => NotificationResult::Approve(d), + Err(_) => { + tracing::warn!("Unknown approval duration in osascript response: {trimmed}"); + NotificationResult::Dismissed + } + } + } else if let Some(duration_str) = trimmed.strip_prefix(&format!("Deny {EN_DASH} ")) { + match duration_str.parse::() { + Ok(d) => NotificationResult::Deny(d), + Err(_) => { + tracing::warn!("Unknown deny duration in osascript response: {trimmed}"); + NotificationResult::Dismissed + } + } + } else { + match trimmed { + "Dismissed" | "" => NotificationResult::Dismissed, + _ => { + tracing::warn!("Unknown osascript response: {trimmed}"); + NotificationResult::Dismissed + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_approve_once() { + assert_eq!( + parse_response("Approve \u{2013} Once"), + NotificationResult::Approve(ApprovalDuration::Once) + ); + } + + #[test] + fn test_parse_approve_session() { + assert_eq!( + parse_response("Approve \u{2013} Session"), + NotificationResult::Approve(ApprovalDuration::Session) + ); + } + + #[test] + fn test_parse_approve_always() { + assert_eq!( + parse_response("Approve \u{2013} Always"), + NotificationResult::Approve(ApprovalDuration::Always) + ); + } + + #[test] + fn test_parse_deny_once() { + assert_eq!( + parse_response("Deny \u{2013} Once"), + NotificationResult::Deny(ApprovalDuration::Once) + ); + } + + #[test] + fn test_parse_deny_session() { + assert_eq!( + parse_response("Deny \u{2013} Session"), + NotificationResult::Deny(ApprovalDuration::Session) + ); + } + + #[test] + fn test_parse_deny_always() { + assert_eq!( + parse_response("Deny \u{2013} Always"), + NotificationResult::Deny(ApprovalDuration::Always) + ); + } + + #[test] + fn test_parse_dismissed() { + assert_eq!(parse_response("Dismissed"), NotificationResult::Dismissed); + } + + #[test] + fn test_parse_empty() { + assert_eq!(parse_response(""), NotificationResult::Dismissed); + } + + #[test] + fn test_parse_unknown() { + assert_eq!(parse_response("Something"), NotificationResult::Dismissed); + } +} diff --git a/crates/nono-cli/src/notification/mod.rs b/crates/nono-cli/src/notification/mod.rs new file mode 100644 index 000000000..184066de2 --- /dev/null +++ b/crates/nono-cli/src/notification/mod.rs @@ -0,0 +1,155 @@ +//! Cross-platform OS notification dispatch for network approval. +//! +//! Shows an interactive notification/dialog when the sandbox proxy +//! blocks a request to an unknown host and `--network-approval ask` +//! is enabled. The user can approve or deny directly from the +//! notification. +//! +//! Platform implementations: +//! - **Linux**: `notify-rust` with XDG action buttons +//! - **macOS**: `osascript display dialog` (native modal) +//! - **Windows**: PowerShell WPF dialog (native modal) + +#[cfg(target_os = "macos")] +mod macos; + +#[cfg(target_os = "linux")] +mod linux; + +#[cfg(target_os = "windows")] +mod windows; + +/// How long an approval decision should last. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ApprovalDuration { + /// Approve only this single request; next request to the same host will prompt again. + Once, + /// Approve for the remainder of this session. + Session, + /// Approve and persist so future sessions also allow the host. + Always, +} + +impl std::str::FromStr for ApprovalDuration { + type Err = String; + + fn from_str(s: &str) -> Result { + match s { + "Once" => Ok(Self::Once), + "Session" => Ok(Self::Session), + "Always" => Ok(Self::Always), + _ => Err(format!("invalid approval duration: {s}")), + } + } +} + +/// Result of a user interaction with a network approval notification. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum NotificationResult { + /// User approved the request with the given duration. + Approve(ApprovalDuration), + /// User denied the request with the given duration. + Deny(ApprovalDuration), + /// Notification was dismissed / timed out without user action. + Dismissed, +} + +/// Show an OS-level approval dialog for a blocked network request. +/// +/// This is a **blocking** call — it waits until the user responds +/// or the timeout expires. Callers should wrap this in +/// `tokio::task::spawn_blocking()` to avoid blocking the async runtime. +/// +/// # Arguments +/// +/// * `host` - The hostname that was blocked (sanitized before display) +/// * `timeout_secs` - How long to wait before returning `Dismissed` +/// +/// # Security +/// +/// The `host` string is sanitized before being interpolated into any +/// platform-specific command to prevent injection attacks. +pub fn show_approval_dialog(host: &str, timeout_secs: u64) -> NotificationResult { + let sanitized = sanitize_host(host); + dispatch_notification(&sanitized, timeout_secs) +} + +#[cfg(target_os = "linux")] +fn dispatch_notification(host: &str, timeout_secs: u64) -> NotificationResult { + linux::show_linux_notification(host, timeout_secs) +} + +#[cfg(target_os = "macos")] +fn dispatch_notification(host: &str, timeout_secs: u64) -> NotificationResult { + macos::show_macos_dialog(host, timeout_secs) +} + +#[cfg(target_os = "windows")] +fn dispatch_notification(host: &str, timeout_secs: u64) -> NotificationResult { + windows::show_windows_notification(host, timeout_secs) +} + +/// Sanitize a hostname for safe display in notifications. +/// +/// Strips control characters and characters that could be used for +/// shell/AppleScript injection. Hostnames are restricted to alphanumeric +/// characters, hyphens, dots, and underscores by RFC 952/1123. +fn sanitize_host(host: &str) -> String { + host.chars() + .map(|c| { + if c.is_ascii_alphanumeric() || c == '-' || c == '.' || c == '_' || c == ':' { + c + } else { + '?' + } + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_sanitize_host_clean() { + assert_eq!(sanitize_host("example.com"), "example.com"); + } + + #[test] + fn test_sanitize_host_with_port() { + assert_eq!(sanitize_host("example.com:443"), "example.com:443"); + } + + #[test] + fn test_sanitize_host_strips_injection() { + let malicious = "evil.com\"; rm -rf /"; + let sanitized = sanitize_host(malicious); + assert!(!sanitized.contains('"')); + assert!(!sanitized.contains(';')); + assert!(!sanitized.contains(' ')); + } + + #[test] + fn test_sanitize_host_applescript_injection() { + let malicious = "host\\\" display dialog \\\"pwned\\\""; + let sanitized = sanitize_host(malicious); + assert!(!sanitized.contains('\\')); + assert!(!sanitized.contains('"')); + } + + #[test] + fn test_sanitize_host_shell_injection() { + let malicious = "host$(evil)"; + let sanitized = sanitize_host(malicious); + assert!(!sanitized.contains('$')); + assert!(!sanitized.contains('(')); + assert!(!sanitized.contains(')')); + } + + #[test] + fn test_sanitize_host_backtick_injection() { + let malicious = "host`evil`"; + let sanitized = sanitize_host(malicious); + assert!(!sanitized.contains('`')); + } +} diff --git a/crates/nono-cli/src/notification/windows.rs b/crates/nono-cli/src/notification/windows.rs new file mode 100644 index 000000000..8a3fe0f33 --- /dev/null +++ b/crates/nono-cli/src/notification/windows.rs @@ -0,0 +1,131 @@ +//! Windows notification backend using PowerShell WPF dialog. +//! +//! On Windows, CLI tools cannot easily show toast notifications with +//! action buttons (requires an app bundle with AUMID). Instead, we +//! use a PowerShell script to show a WPF dialog with a Deny button, +//! an Approve button, and a ComboBox dropdown for duration selection +//! (Once / Session / Always). +//! +//! This approach: +//! - Works without an app bundle or AUMID registration +//! - Gives a real modal dialog (not a fleeting toast) +//! - Supports timeout by closing the window after N seconds +//! - Returns which button was clicked and the selected duration + +use super::{ApprovalDuration, NotificationResult}; + +/// Show a Windows modal dialog asking the user to approve a network request. +/// +/// Uses PowerShell with WPF to display a dialog with a Deny button, +/// an Approve button, and a ComboBox for duration (Once / Session / Always). +/// +/// The dialog auto-closes after `timeout_secs` seconds, +/// returning `Dismissed`. +pub fn show_windows_notification(host: &str, timeout_secs: u64) -> NotificationResult { + let script = format!( + r#" +Add-Type -AssemblyName PresentationFramework +Add-Type -AssemblyName System.Windows.Forms + +$window = New-Object System.Windows.Window +$window.Title = 'nono: Network access blocked' +$window.SizeToContent = 'WidthAndHeight' +$window.WindowStartupLocation = 'CenterScreen' +$window.ResizeMode = 'NoResize' +$window.Topmost = $true + +$stack = New-Object System.Windows.Controls.StackPanel +$stack.Margin = '20' + +$text = New-Object System.Windows.Controls.TextBlock +$text.Text = "Host: {host}`nAllow this host to access the network?" +$text.Margin = '0,0,0,15' +$text.FontSize = 14 +$stack.Children.Add($text) + +$btnPanel = New-Object System.Windows.Controls.StackPanel +$btnPanel.Orientation = 'Horizontal' +$btnPanel.HorizontalAlignment = 'Center' + +$script:result = 'Dismissed' + +$combo = New-Object System.Windows.Controls.ComboBox +$combo.Width = 100 +$combo.Margin = '5,0' +$combo.IsEditable = $false +$combo.IsReadOnly = $true +[void]$combo.Items.Add('Once') +[void]$combo.Items.Add('Session') +[void]$combo.Items.Add('Always') +$combo.SelectedIndex = 1 + +$btnDeny = New-Object System.Windows.Controls.Button +$btnDeny.Content = 'Deny' +$btnDeny.Margin = '5,0' +$btnDeny.Padding = '10,5' +$btnDeny.Add_Click({{ $dur = $combo.SelectedItem; $script:result = "Deny|$dur"; $window.Close() }}) + +$btnApprove = New-Object System.Windows.Controls.Button +$btnApprove.Content = 'Approve' +$btnApprove.Margin = '5,0' +$btnApprove.Padding = '10,5' +$btnApprove.IsDefault = $true +$btnApprove.Add_Click({{ $dur = $combo.SelectedItem; $script:result = "Approve|$dur"; $window.Close() }}) + +$btnPanel.Children.Add($btnDeny) +$btnPanel.Children.Add($combo) +$btnPanel.Children.Add($btnApprove) +$stack.Children.Add($btnPanel) +$window.Content = $stack + +$timer = New-Object System.Windows.Threading.DispatcherTimer +$timer.Interval = [TimeSpan]::FromSeconds({timeout_secs}) +$timer.Add_Tick({{ $script:result = 'Dismissed'; $window.Close() }}) +$timer.Start() + +$window.Add_Closing({{ $timer.Stop() }}) + +$window.ShowDialog() | Out-Null +Write-Output $script:result +"# + ); + + let output = match std::process::Command::new("powershell") + .args(["-NoProfile", "-NonInteractive", "-Command", &script]) + .output() + { + Ok(o) => o, + Err(e) => { + tracing::warn!("Failed to run PowerShell for network approval: {e}"); + return NotificationResult::Dismissed; + } + }; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + tracing::warn!("PowerShell returned error: {stderr}"); + return NotificationResult::Dismissed; + } + + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if let Some(duration_str) = stdout.strip_prefix("Approve|") { + match duration_str.parse::() { + Ok(d) => NotificationResult::Approve(d), + Err(_) => { + tracing::warn!("Unknown approval duration in PowerShell response: {stdout}"); + NotificationResult::Dismissed + } + } + } else if let Some(duration_str) = stdout.strip_prefix("Deny|") { + match duration_str.parse::() { + Ok(d) => NotificationResult::Deny(d), + Err(_) => { + tracing::warn!("Unknown deny duration in PowerShell response: {stdout}"); + NotificationResult::Dismissed + } + } + } else { + tracing::warn!("Unknown PowerShell dialog response: {stdout}"); + NotificationResult::Dismissed + } +} diff --git a/crates/nono-cli/src/policy_cmd.rs b/crates/nono-cli/src/policy_cmd.rs new file mode 100644 index 000000000..d1fe3fc61 --- /dev/null +++ b/crates/nono-cli/src/policy_cmd.rs @@ -0,0 +1,2443 @@ +//! Policy introspection subcommand implementations +//! +//! Handles `nono policy groups|profiles|show|diff|validate` for inspecting +//! the group-based policy system, profiles, and security rules. + +use crate::cli::{ + PolicyArgs, PolicyCommands, PolicyDiffArgs, PolicyGroupsArgs, PolicyProfilesArgs, + PolicyShowArgs, PolicyValidateArgs, +}; +use crate::policy::{self, AllowOps, DenyOps, Group}; +use crate::profile::{self, Profile, WorkdirAccess}; +use crate::theme; +use colored::Colorize; +use nono::{NonoError, Result}; +use std::collections::BTreeSet; + +/// Serialize a value to pretty-printed JSON, propagating serialization errors. +fn to_json(val: &serde_json::Value) -> Result { + serde_json::to_string_pretty(val) + .map_err(|e| NonoError::ProfileParse(format!("JSON serialization failed: {e}"))) +} + +/// Prefix used for all policy command output +fn prefix() -> colored::ColoredString { + let t = theme::current(); + theme::fg("nono policy", t.brand).bold() +} + +/// Dispatch to the appropriate policy subcommand. +pub fn run_policy(args: PolicyArgs) -> Result<()> { + match args.command { + PolicyCommands::Groups(args) => cmd_groups(args), + PolicyCommands::Profiles(args) => cmd_profiles(args), + PolicyCommands::Show(args) => cmd_show(args), + PolicyCommands::Diff(args) => cmd_diff(args), + PolicyCommands::Validate(args) => cmd_validate(args), + } +} + +// --------------------------------------------------------------------------- +// nono policy groups +// --------------------------------------------------------------------------- + +fn cmd_groups(args: PolicyGroupsArgs) -> Result<()> { + let pol = policy::load_embedded_policy()?; + + match args.name { + Some(name) => cmd_groups_detail(&pol, &name, args.json), + None => cmd_groups_list(&pol, args.json, args.all_platforms), + } +} + +fn cmd_groups_list(pol: &policy::Policy, json: bool, all_platforms: bool) -> Result<()> { + let mut groups: Vec<(&String, &Group)> = pol.groups.iter().collect(); + groups.sort_by_key(|(name, _)| name.as_str()); + + if !all_platforms { + groups.retain(|(_, g)| policy::group_matches_platform(g)); + } + + if json { + let arr: Vec = groups + .iter() + .map(|(name, g)| { + serde_json::json!({ + "name": name, + "description": g.description, + "platform": g.platform.as_deref().unwrap_or("cross-platform"), + "required": g.required, + "allow": count_allow(&g.allow), + "deny": count_deny(&g.deny), + }) + }) + .collect(); + println!("{}", to_json(&serde_json::Value::Array(arr))?); + return Ok(()); + } + + let t = theme::current(); + println!( + "{}: {} groups{}", + prefix(), + groups.len(), + if all_platforms { + " (all platforms)" + } else { + "" + } + ); + println!(); + + for (name, group) in &groups { + let platform = group.platform.as_deref().unwrap_or("cross-platform"); + let required = if group.required { " required" } else { "" }; + println!( + " {:<36} {:<42} {}{}", + theme::fg(name, t.text).bold(), + theme::fg(&group.description, t.subtext), + theme::fg(platform, t.overlay), + theme::fg(required, t.yellow), + ); + } + + Ok(()) +} + +fn cmd_groups_detail(pol: &policy::Policy, name: &str, json: bool) -> Result<()> { + let group = pol.groups.get(name).ok_or_else(|| { + NonoError::ProfileParse(format!( + "group '{}' not found in policy.json. Use 'nono policy groups' to list available groups", + name + )) + })?; + + if json { + let val = group_to_json(name, group); + println!("{}", to_json(&val)?); + return Ok(()); + } + + let t = theme::current(); + println!("{}: group '{}'", prefix(), theme::fg(name, t.text).bold()); + println!(); + println!( + " {} {}", + theme::fg("Description:", t.subtext), + theme::fg(&group.description, t.text) + ); + println!( + " {} {}", + theme::fg("Platform:", t.subtext), + theme::fg( + group.platform.as_deref().unwrap_or("cross-platform"), + t.text + ) + ); + println!( + " {} {}", + theme::fg("Required:", t.subtext), + theme::fg(if group.required { "yes" } else { "no" }, t.text) + ); + + if let Some(ref allow) = group.allow { + print_path_section("allow.read", &allow.read, t); + print_path_section("allow.write", &allow.write, t); + print_path_section("allow.readwrite", &allow.readwrite, t); + } + + if let Some(ref deny) = group.deny { + print_path_section("deny.access", &deny.access, t); + if deny.unlink { + println!(); + println!(" {}", theme::fg("deny.unlink:", t.red).bold()); + println!(" {}", theme::fg("enabled", t.red)); + } + if !deny.commands.is_empty() { + println!(); + println!(" {}", theme::fg("deny.commands:", t.red).bold()); + for cmd in &deny.commands { + println!(" {}", theme::fg(cmd, t.text)); + } + } + } + + if let Some(ref pairs) = group.symlink_pairs { + if !pairs.is_empty() { + println!(); + println!(" {}", theme::fg("symlink_pairs:", t.subtext).bold()); + let mut sorted: Vec<(&String, &String)> = pairs.iter().collect(); + sorted.sort_by_key(|(k, _)| k.as_str()); + for (from, to) in sorted { + println!( + " {} -> {}", + theme::fg(from, t.text), + theme::fg(to, t.subtext) + ); + } + } + } + + Ok(()) +} + +fn print_path_section(label: &str, paths: &[String], t: &theme::Theme) { + if paths.is_empty() { + return; + } + let color = if label.starts_with("deny") { + t.red + } else { + t.green + }; + println!(); + println!(" {}", theme::fg(&format!("{label}:"), color).bold()); + for raw in paths { + match policy::expand_path(raw) { + Ok(expanded) => { + let exp_str = expanded.display().to_string(); + if exp_str == *raw { + println!(" {}", theme::fg(raw, t.text)); + } else { + println!( + " {:<36} -> {}", + theme::fg(raw, t.text), + theme::fg(&exp_str, t.subtext) + ); + } + } + Err(_) => { + println!( + " {:<36} -> {}", + theme::fg(raw, t.text), + theme::fg("", t.red) + ); + } + } + } +} + +fn count_allow(allow: &Option) -> serde_json::Value { + match allow { + Some(a) => serde_json::json!({ + "read": a.read.len(), + "write": a.write.len(), + "readwrite": a.readwrite.len(), + }), + None => serde_json::json!({}), + } +} + +fn count_deny(deny: &Option) -> serde_json::Value { + match deny { + Some(d) => serde_json::json!({ + "access": d.access.len(), + "commands": d.commands.len(), + "unlink": d.unlink, + }), + None => serde_json::json!({}), + } +} + +fn group_to_json(name: &str, group: &Group) -> serde_json::Value { + let mut val = serde_json::json!({ + "name": name, + "description": group.description, + "platform": group.platform.as_deref().unwrap_or("cross-platform"), + "required": group.required, + }); + + if let Some(ref allow) = group.allow { + let mut allow_val = serde_json::Map::new(); + if !allow.read.is_empty() { + allow_val.insert("read".into(), expand_paths_json(&allow.read)); + } + if !allow.write.is_empty() { + allow_val.insert("write".into(), expand_paths_json(&allow.write)); + } + if !allow.readwrite.is_empty() { + allow_val.insert("readwrite".into(), expand_paths_json(&allow.readwrite)); + } + val["allow"] = serde_json::Value::Object(allow_val); + } + + if let Some(ref deny) = group.deny { + let mut deny_val = serde_json::Map::new(); + if !deny.access.is_empty() { + deny_val.insert("access".into(), expand_paths_json(&deny.access)); + } + if !deny.commands.is_empty() { + deny_val.insert("commands".into(), serde_json::json!(deny.commands)); + } + if deny.unlink { + deny_val.insert("unlink".into(), serde_json::json!(true)); + } + val["deny"] = serde_json::Value::Object(deny_val); + } + + if let Some(ref pairs) = group.symlink_pairs { + if !pairs.is_empty() { + val["symlink_pairs"] = serde_json::json!(pairs); + } + } + + val +} + +fn expand_paths_json(paths: &[String]) -> serde_json::Value { + let arr: Vec = paths + .iter() + .map(|raw| { + let expanded = policy::expand_path(raw) + .map(|p| p.display().to_string()) + .unwrap_or_else(|_| "".to_string()); + serde_json::json!({ + "raw": raw, + "expanded": expanded, + }) + }) + .collect(); + serde_json::Value::Array(arr) +} + +// --------------------------------------------------------------------------- +// nono policy profiles +// --------------------------------------------------------------------------- + +/// Determine the actual source of a loaded profile. +/// +/// Load precedence is user-first (profile/mod.rs), so a user file with a +/// built-in name shadows the built-in. We must check the filesystem to +/// report the real source accurately. +fn profile_source(name: &str) -> &'static str { + let builtin_names = profile::builtin::list_builtin(); + if profile::is_user_override(name) { + if builtin_names.contains(&name.to_string()) { + "user (overrides built-in)" + } else { + "user" + } + } else if builtin_names.contains(&name.to_string()) { + "built-in" + } else { + "user" + } +} + +fn cmd_profiles(args: PolicyProfilesArgs) -> Result<()> { + let builtin_names = profile::builtin::list_builtin(); + let all_names = profile::list_profiles(); + + let mut builtin_profiles: Vec<(String, Result)> = Vec::new(); + let mut user_profiles: Vec<(String, Result)> = Vec::new(); + + for name in &all_names { + let p = profile::load_profile(name); + // Categorize by actual source: user overrides of built-in names + // go under user section to make shadowing visible. + if builtin_names.contains(name) && !profile::is_user_override(name) { + builtin_profiles.push((name.clone(), p)); + } else { + user_profiles.push((name.clone(), p)); + } + } + + if args.json { + let format_entry = |name: &str, result: &Result| { + let source = profile_source(name); + let extends = profile::load_profile_extends(name).unwrap_or_default(); + match result { + Ok(p) => serde_json::json!({ + "name": name, + "source": source, + "description": p.meta.description.as_deref().unwrap_or(""), + "extends": extends, + }), + Err(e) => serde_json::json!({ + "name": name, + "source": source, + "error": format!("{}", e), + }), + } + }; + + let arr: Vec = builtin_profiles + .iter() + .map(|(n, p)| format_entry(n, p)) + .chain(user_profiles.iter().map(|(n, p)| format_entry(n, p))) + .collect(); + println!("{}", to_json(&serde_json::Value::Array(arr))?); + return Ok(()); + } + + let t = theme::current(); + let total = builtin_profiles.len() + user_profiles.len(); + println!("{}: {} profiles", prefix(), total); + + if !builtin_profiles.is_empty() { + println!(); + println!(" {}", theme::fg("Built-in:", t.subtext).bold()); + for (name, result) in &builtin_profiles { + print_profile_line(name, result, t); + } + } + + if !user_profiles.is_empty() { + println!(); + println!( + " {}", + theme::fg("User (~/.config/nono/profiles/):", t.subtext).bold() + ); + for (name, result) in &user_profiles { + print_profile_line(name, result, t); + } + } + + Ok(()) +} + +fn print_profile_line(name: &str, result: &Result, t: &theme::Theme) { + match result { + Ok(p) => { + let desc = p.meta.description.as_deref().unwrap_or("").to_string(); + let extends = profile::load_profile_extends(name) + .map(|v| format!("extends {}", v.join(", "))) + .unwrap_or_default(); + println!( + " {:<16} {:<42} {}", + theme::fg(name, t.text).bold(), + theme::fg(&desc, t.subtext), + theme::fg(&extends, t.overlay), + ); + } + Err(e) => { + println!( + " {:<16} {}", + theme::fg(name, t.text).bold(), + theme::fg(&format!("[error: {}]", e), t.red), + ); + } + } +} + +// --------------------------------------------------------------------------- +// nono policy show +// --------------------------------------------------------------------------- + +fn cmd_show(args: PolicyShowArgs) -> Result<()> { + let raw_extends = profile::load_profile_extends(&args.profile); + let profile = profile::load_profile(&args.profile)?; + + if matches!(args.format, Some(crate::cli::PolicyShowFormat::Manifest)) { + let workdir = std::env::current_dir().map_err(|e| { + NonoError::ConfigParse(format!("cannot determine working directory: {e}")) + })?; + let manifest = resolve_to_manifest(&profile, &workdir)?; + let json = manifest.to_json()?; + println!("{json}"); + return Ok(()); + } + + if args.json { + let val = profile_to_json(&args.profile, &profile, &raw_extends); + println!("{}", to_json(&val)?); + return Ok(()); + } + + let t = theme::current(); + println!( + "{}: profile '{}'", + prefix(), + theme::fg(&args.profile, t.text).bold() + ); + + // Meta + if let Some(ref desc) = profile.meta.description { + println!(); + println!( + " {} {}", + theme::fg("Description:", t.subtext), + theme::fg(desc, t.text) + ); + } + if let Some(ref extends) = raw_extends { + println!( + " {} {}", + theme::fg("Extends:", t.subtext), + theme::fg(&extends.join(", "), t.text) + ); + } + + // Security groups + if !profile.security.groups.is_empty() { + println!(); + println!(" {}", theme::fg("Security groups:", t.subtext).bold()); + for g in &profile.security.groups { + println!(" {}", theme::fg(g, t.text)); + } + } + + if !profile.security.allowed_commands.is_empty() { + println!(); + println!( + " {}", + theme::fg("Allowed commands (deprecated, startup-only):", t.subtext).bold() + ); + for cmd in &profile.security.allowed_commands { + println!(" {}", theme::fg(cmd, t.text)); + } + } + + if let Some(mode) = &profile.security.signal_mode { + println!(" {} {:?}", theme::fg("Signal mode:", t.subtext), mode); + } + + if let Some(mode) = &profile.security.process_info_mode { + println!(" {} {:?}", theme::fg("Process info:", t.subtext), mode); + } + + if let Some(mode) = &profile.security.ipc_mode { + println!(" {} {:?}", theme::fg("IPC mode:", t.subtext), mode); + } + + if let Some(elev) = profile.security.capability_elevation { + println!( + " {} {}", + theme::fg("Capability elevation:", t.subtext), + theme::fg(if elev { "enabled" } else { "disabled" }, t.text) + ); + } + if let Some(policy) = profile.security.wsl2_proxy_policy { + println!( + " {} {}", + theme::fg("WSL2 proxy policy:", t.subtext), + theme::fg(&format!("{policy:?}"), t.text) + ); + } + + // Filesystem + let fs = &profile.filesystem; + let has_fs = !fs.allow.is_empty() + || !fs.read.is_empty() + || !fs.write.is_empty() + || !fs.allow_file.is_empty() + || !fs.read_file.is_empty() + || !fs.write_file.is_empty(); + + if has_fs { + println!(); + println!(" {}", theme::fg("Filesystem:", t.subtext).bold()); + print_fs_paths("allow (r+w)", &fs.allow, t, args.raw); + print_fs_paths("read", &fs.read, t, args.raw); + print_fs_paths("write", &fs.write, t, args.raw); + print_fs_paths("allow_file (r+w)", &fs.allow_file, t, args.raw); + print_fs_paths("read_file", &fs.read_file, t, args.raw); + print_fs_paths("write_file", &fs.write_file, t, args.raw); + } + + // Policy patches + let pp = &profile.policy; + let has_policy = !pp.exclude_groups.is_empty() + || !pp.add_allow_read.is_empty() + || !pp.add_allow_write.is_empty() + || !pp.add_allow_readwrite.is_empty() + || !pp.add_deny_access.is_empty() + || !pp.add_deny_commands.is_empty() + || !pp.override_deny.is_empty(); + + if has_policy { + println!(); + println!(" {}", theme::fg("Policy patches:", t.subtext).bold()); + if !pp.exclude_groups.is_empty() { + println!( + " {}: {}", + theme::fg("exclude_groups", t.yellow), + pp.exclude_groups.join(", ") + ); + } + print_fs_paths("add_allow_read", &pp.add_allow_read, t, args.raw); + print_fs_paths("add_allow_write", &pp.add_allow_write, t, args.raw); + print_fs_paths("add_allow_readwrite", &pp.add_allow_readwrite, t, args.raw); + print_fs_paths("add_deny_access", &pp.add_deny_access, t, args.raw); + if !pp.add_deny_commands.is_empty() { + println!( + " {}: {}", + theme::fg("add_deny_commands (deprecated, startup-only)", t.yellow), + pp.add_deny_commands.join(", ") + ); + } + if !pp.override_deny.is_empty() { + println!( + " {}: {}", + theme::fg("override_deny", t.yellow), + pp.override_deny.join(", ") + ); + } + } + + // Network + let net = &profile.network; + let has_net = net.block + || net.resolved_network_profile().is_some() + || !net.allow_domain.is_empty() + || !net.resolved_credentials().is_empty() + || !net.open_port.is_empty() + || !net.listen_port.is_empty() + || net.upstream_proxy.is_some() + || !net.upstream_bypass.is_empty(); + + if has_net { + println!(); + println!(" {}", theme::fg("Network:", t.subtext).bold()); + if net.block { + println!(" {}", theme::fg("network blocked", t.red)); + } + if let Some(np) = net.resolved_network_profile() { + println!( + " {}: {}", + theme::fg("network_profile", t.subtext), + theme::fg(np, t.text) + ); + } + if !net.allow_domain.is_empty() { + println!( + " {}: {}", + theme::fg("allow_domain", t.subtext), + net.allow_domain.join(", ") + ); + } + if !net.resolved_credentials().is_empty() { + println!( + " {}: {}", + theme::fg("credentials", t.subtext), + net.resolved_credentials().join(", ") + ); + } + if !net.open_port.is_empty() { + let ports: Vec = net.open_port.iter().map(|p| p.to_string()).collect(); + println!( + " {}: {}", + theme::fg("open_port", t.subtext), + ports.join(", ") + ); + } + if !net.listen_port.is_empty() { + let ports: Vec = net.listen_port.iter().map(|p| p.to_string()).collect(); + println!( + " {}: {}", + theme::fg("listen_port", t.subtext), + ports.join(", ") + ); + } + if let Some(ref ep) = net.upstream_proxy { + println!( + " {}: {}", + theme::fg("upstream_proxy", t.subtext), + theme::fg(ep, t.text) + ); + } + if !net.upstream_bypass.is_empty() { + println!( + " {}: {}", + theme::fg("upstream_bypass", t.subtext), + net.upstream_bypass.join(", ") + ); + } + } + + // Workdir + if profile.workdir.access != WorkdirAccess::None { + println!(); + println!( + " {} {:?}", + theme::fg("Workdir access:", t.subtext).bold(), + profile.workdir.access + ); + } + + // Rollback + let rb = &profile.rollback; + if !rb.exclude_patterns.is_empty() || !rb.exclude_globs.is_empty() { + println!(); + println!(" {}", theme::fg("Rollback exclusions:", t.subtext).bold()); + for p in &rb.exclude_patterns { + println!(" {}", theme::fg(p, t.text)); + } + for g in &rb.exclude_globs { + println!( + " {} {}", + theme::fg("glob:", t.overlay), + theme::fg(g, t.text) + ); + } + } + + // Open URLs + if let Some(ref urls) = profile.open_urls { + println!(); + println!(" {}", theme::fg("Open URLs:", t.subtext).bold()); + if urls.allow_localhost { + println!(" {}", theme::fg("localhost allowed", t.text)); + } + for origin in &urls.allow_origins { + println!(" {}", theme::fg(origin, t.text)); + } + } + + // Raw Seatbelt rules — surfaced prominently so it is obvious a profile uses them. + // Shown on all platforms so cross-platform auditing is possible. + if !profile.unsafe_macos_seatbelt_rules.is_empty() { + println!(); + println!( + " {}", + theme::fg( + "Raw Seatbelt rules (unsafe_macos_seatbelt_rules):", + t.yellow + ) + .bold() + ); + for rule in &profile.unsafe_macos_seatbelt_rules { + println!(" {}", theme::fg(rule, t.text)); + } + } + + Ok(()) +} + +fn print_fs_paths(label: &str, paths: &[String], t: &theme::Theme, raw: bool) { + if paths.is_empty() { + return; + } + println!(" {}:", theme::fg(label, t.subtext)); + for p in paths { + if raw { + println!(" {}", theme::fg(p, t.text)); + } else { + match policy::expand_path(p) { + Ok(expanded) => { + let exp_str = expanded.display().to_string(); + if exp_str == *p { + println!(" {}", theme::fg(p, t.text)); + } else { + println!( + " {:<36} -> {}", + theme::fg(p, t.text), + theme::fg(&exp_str, t.subtext) + ); + } + } + Err(_) => { + println!(" {}", theme::fg(p, t.text)); + } + } + } + } +} + +fn profile_to_json( + name: &str, + profile: &Profile, + raw_extends: &Option>, +) -> serde_json::Value { + let mut val = serde_json::json!({ + "name": name, + "description": profile.meta.description.as_deref().unwrap_or(""), + "extends": raw_extends.as_ref().map(|v| serde_json::json!(v)).unwrap_or(serde_json::Value::Null), + }); + + // Security + val["security"] = serde_json::json!({ + "groups": profile.security.groups, + "allowed_commands": profile.security.allowed_commands, + "signal_mode": format!("{:?}", profile.security.signal_mode), + "process_info_mode": format!("{:?}", profile.security.process_info_mode), + "ipc_mode": format!("{:?}", profile.security.ipc_mode), + "capability_elevation": profile.security.capability_elevation, + "wsl2_proxy_policy": format!("{:?}", profile.security.wsl2_proxy_policy), + }); + + // Filesystem + val["filesystem"] = serde_json::json!({ + "allow": profile.filesystem.allow, + "read": profile.filesystem.read, + "write": profile.filesystem.write, + "allow_file": profile.filesystem.allow_file, + "read_file": profile.filesystem.read_file, + "write_file": profile.filesystem.write_file, + }); + + // Policy patches + val["policy"] = serde_json::json!({ + "exclude_groups": profile.policy.exclude_groups, + "add_allow_read": profile.policy.add_allow_read, + "add_allow_write": profile.policy.add_allow_write, + "add_allow_readwrite": profile.policy.add_allow_readwrite, + "add_deny_access": profile.policy.add_deny_access, + "add_deny_commands": profile.policy.add_deny_commands, + "override_deny": profile.policy.override_deny, + }); + + // Network + val["network"] = serde_json::json!({ + "block": profile.network.block, + "network_profile": profile.network.resolved_network_profile(), + "allow_domain": profile.network.allow_domain, + "reject_domain": profile.network.reject_domain, + "credentials": profile.network.resolved_credentials(), + "open_port": profile.network.open_port, + "listen_port": profile.network.listen_port, + "upstream_proxy": profile.network.upstream_proxy, + "upstream_bypass": profile.network.upstream_bypass, + }); + + // Workdir + val["workdir"] = serde_json::json!({ + "access": format!("{:?}", profile.workdir.access), + }); + + // Rollback + val["rollback"] = serde_json::json!({ + "exclude_patterns": profile.rollback.exclude_patterns, + "exclude_globs": profile.rollback.exclude_globs, + }); + + // Env credentials + if !profile.env_credentials.mappings.is_empty() { + val["env_credentials"] = serde_json::json!(profile.env_credentials.mappings); + } + + // Hooks + if !profile.hooks.hooks.is_empty() { + let hooks: serde_json::Map = profile + .hooks + .hooks + .iter() + .map(|(k, v)| { + ( + k.clone(), + serde_json::json!({ + "event": v.event, + "matcher": v.matcher, + "script": v.script, + }), + ) + }) + .collect(); + val["hooks"] = serde_json::Value::Object(hooks); + } + + // Open URLs + if let Some(ref urls) = profile.open_urls { + val["open_urls"] = serde_json::json!({ + "allow_origins": urls.allow_origins, + "allow_localhost": urls.allow_localhost, + }); + } + + // Allow launch services + if let Some(als) = profile.allow_launch_services { + val["allow_launch_services"] = serde_json::json!(als); + } + + if let Some(ag) = profile.allow_gpu { + val["allow_gpu"] = serde_json::json!(ag); + } + + if !profile.unsafe_macos_seatbelt_rules.is_empty() { + val["unsafe_macos_seatbelt_rules"] = serde_json::json!(profile.unsafe_macos_seatbelt_rules); + } + + val +} + +// --------------------------------------------------------------------------- +// nono policy diff +// --------------------------------------------------------------------------- + +fn cmd_diff(args: PolicyDiffArgs) -> Result<()> { + let p1 = profile::load_profile(&args.profile1)?; + let p2 = profile::load_profile(&args.profile2)?; + + if args.json { + let val = diff_to_json(&args.profile1, &args.profile2, &p1, &p2); + println!("{}", to_json(&val)?); + return Ok(()); + } + + let t = theme::current(); + println!( + "{}: diff '{}' vs '{}'", + prefix(), + theme::fg(&args.profile1, t.text).bold(), + theme::fg(&args.profile2, t.text).bold() + ); + + let mut any_diff = false; + + // Groups + let g1: BTreeSet<&str> = p1.security.groups.iter().map(|s| s.as_str()).collect(); + let g2: BTreeSet<&str> = p2.security.groups.iter().map(|s| s.as_str()).collect(); + let added_groups: BTreeSet<&&str> = g2.difference(&g1).collect(); + let removed_groups: BTreeSet<&&str> = g1.difference(&g2).collect(); + + if !added_groups.is_empty() || !removed_groups.is_empty() { + any_diff = true; + println!(); + println!(" {}:", theme::fg("Groups", t.subtext).bold()); + for g in &removed_groups { + println!(" {} {}", theme::fg("-", t.red), theme::fg(g, t.red)); + } + for g in &added_groups { + println!(" {} {}", theme::fg("+", t.green), theme::fg(g, t.green)); + } + } + + // Filesystem + let fs_diffs = diff_string_vecs(&[ + ("allow", &p1.filesystem.allow, &p2.filesystem.allow), + ("read", &p1.filesystem.read, &p2.filesystem.read), + ("write", &p1.filesystem.write, &p2.filesystem.write), + ( + "allow_file", + &p1.filesystem.allow_file, + &p2.filesystem.allow_file, + ), + ( + "read_file", + &p1.filesystem.read_file, + &p2.filesystem.read_file, + ), + ( + "write_file", + &p1.filesystem.write_file, + &p2.filesystem.write_file, + ), + ]); + + if !fs_diffs.is_empty() { + any_diff = true; + println!(); + println!(" {}:", theme::fg("Filesystem", t.subtext).bold()); + for (label, sign, path) in &fs_diffs { + let color = if *sign == "+" { t.green } else { t.red }; + println!( + " {} {} {}", + theme::fg(sign, color), + theme::fg(label, t.subtext), + theme::fg(path, color) + ); + } + } + + // Policy patches + let pp_diffs = diff_string_vecs(&[ + ( + "exclude_groups", + &p1.policy.exclude_groups, + &p2.policy.exclude_groups, + ), + ( + "add_allow_read", + &p1.policy.add_allow_read, + &p2.policy.add_allow_read, + ), + ( + "add_allow_write", + &p1.policy.add_allow_write, + &p2.policy.add_allow_write, + ), + ( + "add_allow_readwrite", + &p1.policy.add_allow_readwrite, + &p2.policy.add_allow_readwrite, + ), + ( + "add_deny_access", + &p1.policy.add_deny_access, + &p2.policy.add_deny_access, + ), + ( + "add_deny_commands", + &p1.policy.add_deny_commands, + &p2.policy.add_deny_commands, + ), + ( + "override_deny", + &p1.policy.override_deny, + &p2.policy.override_deny, + ), + ]); + + if !pp_diffs.is_empty() { + any_diff = true; + println!(); + println!(" {}:", theme::fg("Policy patches", t.subtext).bold()); + for (label, sign, val) in &pp_diffs { + let color = if *sign == "+" { t.green } else { t.red }; + println!( + " {} {} {}", + theme::fg(sign, color), + theme::fg(label, t.subtext), + theme::fg(val, color) + ); + } + } + + // Security scalar fields + any_diff |= diff_scalar_option( + "capability_elevation", + &p1.security.capability_elevation.map(|v| format!("{v}")), + &p2.security.capability_elevation.map(|v| format!("{v}")), + t, + ); + any_diff |= diff_scalar_option( + "wsl2_proxy_policy", + &p1.security.wsl2_proxy_policy.map(|v| format!("{v:?}")), + &p2.security.wsl2_proxy_policy.map(|v| format!("{v:?}")), + t, + ); + any_diff |= diff_scalar_option( + "signal_mode", + &p1.security.signal_mode.map(|v| format!("{v:?}")), + &p2.security.signal_mode.map(|v| format!("{v:?}")), + t, + ); + any_diff |= diff_scalar_option( + "process_info_mode", + &p1.security.process_info_mode.map(|v| format!("{v:?}")), + &p2.security.process_info_mode.map(|v| format!("{v:?}")), + t, + ); + any_diff |= diff_scalar_option( + "ipc_mode", + &p1.security.ipc_mode.map(|v| format!("{v:?}")), + &p2.security.ipc_mode.map(|v| format!("{v:?}")), + t, + ); + + // Network + let mut net_diffs: Vec<(String, String)> = Vec::new(); + if p1.network.block != p2.network.block { + net_diffs.push(( + format!("- block: {}", p1.network.block), + format!("+ block: {}", p2.network.block), + )); + } + let np1 = p1.network.resolved_network_profile().unwrap_or(""); + let np2 = p2.network.resolved_network_profile().unwrap_or(""); + if np1 != np2 { + if !np1.is_empty() { + net_diffs.push((format!("- network_profile: {np1}"), String::new())); + } + if !np2.is_empty() { + net_diffs.push((String::new(), format!("+ network_profile: {np2}"))); + } + } + + let net_vec_diffs = diff_string_vecs(&[ + ( + "allow_domain", + &p1.network.allow_domain, + &p2.network.allow_domain, + ), + ( + "credentials", + p1.network.resolved_credentials(), + p2.network.resolved_credentials(), + ), + ( + "upstream_bypass", + &p1.network.upstream_bypass, + &p2.network.upstream_bypass, + ), + ]); + + let port1: Vec = p1.network.open_port.iter().map(|p| p.to_string()).collect(); + let port2: Vec = p2.network.open_port.iter().map(|p| p.to_string()).collect(); + let port_diffs = diff_string_vecs(&[("open_port", &port1, &port2)]); + let listen1: Vec = p1 + .network + .listen_port + .iter() + .map(|p| p.to_string()) + .collect(); + let listen2: Vec = p2 + .network + .listen_port + .iter() + .map(|p| p.to_string()) + .collect(); + let listen_diffs = diff_string_vecs(&[("listen_port", &listen1, &listen2)]); + + if !net_diffs.is_empty() + || !net_vec_diffs.is_empty() + || !port_diffs.is_empty() + || !listen_diffs.is_empty() + { + any_diff = true; + println!(); + println!(" {}:", theme::fg("Network", t.subtext).bold()); + for (rem, add) in &net_diffs { + if !rem.is_empty() { + println!(" {}", theme::fg(rem, t.red)); + } + if !add.is_empty() { + println!(" {}", theme::fg(add, t.green)); + } + } + for (label, sign, val) in net_vec_diffs + .iter() + .chain(port_diffs.iter()) + .chain(listen_diffs.iter()) + { + let color = if *sign == "+" { t.green } else { t.red }; + println!( + " {} {} {}", + theme::fg(sign, color), + theme::fg(label, t.subtext), + theme::fg(val, color) + ); + } + } + + any_diff |= diff_scalar_option( + "upstream_proxy", + &p1.network.upstream_proxy, + &p2.network.upstream_proxy, + t, + ); + + // Workdir + if p1.workdir.access != p2.workdir.access { + any_diff = true; + println!(); + println!(" {}:", theme::fg("Workdir", t.subtext).bold()); + println!( + " {}", + theme::fg(&format!("- access: {:?}", p1.workdir.access), t.red) + ); + println!( + " {}", + theme::fg(&format!("+ access: {:?}", p2.workdir.access), t.green) + ); + } + + // Allowed commands + let cmd1: BTreeSet<&str> = p1 + .security + .allowed_commands + .iter() + .map(|s| s.as_str()) + .collect(); + let cmd2: BTreeSet<&str> = p2 + .security + .allowed_commands + .iter() + .map(|s| s.as_str()) + .collect(); + let added_cmds: BTreeSet<&&str> = cmd2.difference(&cmd1).collect(); + let removed_cmds: BTreeSet<&&str> = cmd1.difference(&cmd2).collect(); + + if !added_cmds.is_empty() || !removed_cmds.is_empty() { + any_diff = true; + println!(); + println!(" {}:", theme::fg("Allowed commands", t.subtext).bold()); + for c in &removed_cmds { + println!(" {} {}", theme::fg("-", t.red), theme::fg(c, t.red)); + } + for c in &added_cmds { + println!(" {} {}", theme::fg("+", t.green), theme::fg(c, t.green)); + } + } + + // Rollback + let rb_diffs = diff_string_vecs(&[ + ( + "exclude_patterns", + &p1.rollback.exclude_patterns, + &p2.rollback.exclude_patterns, + ), + ( + "exclude_globs", + &p1.rollback.exclude_globs, + &p2.rollback.exclude_globs, + ), + ]); + if !rb_diffs.is_empty() { + any_diff = true; + println!(); + println!(" {}:", theme::fg("Rollback", t.subtext).bold()); + for (label, sign, val) in &rb_diffs { + let color = if *sign == "+" { t.green } else { t.red }; + println!( + " {} {} {}", + theme::fg(sign, color), + theme::fg(label, t.subtext), + theme::fg(val, color) + ); + } + } + + // Open URLs + let ou1_origins: Vec = p1 + .open_urls + .as_ref() + .map(|u| u.allow_origins.clone()) + .unwrap_or_default(); + let ou2_origins: Vec = p2 + .open_urls + .as_ref() + .map(|u| u.allow_origins.clone()) + .unwrap_or_default(); + let ou_diffs = diff_string_vecs(&[("allow_origins", &ou1_origins, &ou2_origins)]); + let ou1_localhost = p1.open_urls.as_ref().is_some_and(|u| u.allow_localhost); + let ou2_localhost = p2.open_urls.as_ref().is_some_and(|u| u.allow_localhost); + + if !ou_diffs.is_empty() || ou1_localhost != ou2_localhost { + any_diff = true; + println!(); + println!(" {}:", theme::fg("Open URLs", t.subtext).bold()); + for (label, sign, val) in &ou_diffs { + let color = if *sign == "+" { t.green } else { t.red }; + println!( + " {} {} {}", + theme::fg(sign, color), + theme::fg(label, t.subtext), + theme::fg(val, color) + ); + } + if ou1_localhost != ou2_localhost { + println!( + " {}", + theme::fg(&format!("- allow_localhost: {ou1_localhost}"), t.red) + ); + println!( + " {}", + theme::fg(&format!("+ allow_localhost: {ou2_localhost}"), t.green) + ); + } + } + + // Allow launch services + any_diff |= diff_scalar_option( + "allow_launch_services", + &p1.allow_launch_services.map(|v| format!("{v}")), + &p2.allow_launch_services.map(|v| format!("{v}")), + t, + ); + + any_diff |= diff_scalar_option( + "allow_gpu", + &p1.allow_gpu.map(|v| format!("{v}")), + &p2.allow_gpu.map(|v| format!("{v}")), + t, + ); + + // Env credentials + let ec1: BTreeSet<(&String, &String)> = p1.env_credentials.mappings.iter().collect(); + let ec2: BTreeSet<(&String, &String)> = p2.env_credentials.mappings.iter().collect(); + let ec_added: BTreeSet<&(&String, &String)> = ec2.difference(&ec1).collect(); + let ec_removed: BTreeSet<&(&String, &String)> = ec1.difference(&ec2).collect(); + if !ec_added.is_empty() || !ec_removed.is_empty() { + any_diff = true; + println!(); + println!(" {}:", theme::fg("Env credentials", t.subtext).bold()); + for (k, v) in &ec_removed { + println!( + " {} {} -> {}", + theme::fg("-", t.red), + theme::fg(k, t.red), + theme::fg(v, t.red) + ); + } + for (k, v) in &ec_added { + println!( + " {} {} -> {}", + theme::fg("+", t.green), + theme::fg(k, t.green), + theme::fg(v, t.green) + ); + } + } + + // Hooks + let h1: BTreeSet<&String> = p1.hooks.hooks.keys().collect(); + let h2: BTreeSet<&String> = p2.hooks.hooks.keys().collect(); + let hooks_added: BTreeSet<&&String> = h2.difference(&h1).collect(); + let hooks_removed: BTreeSet<&&String> = h1.difference(&h2).collect(); + // Check for hooks present in both but with different config + let hooks_changed: Vec<&String> = h1 + .intersection(&h2) + .filter(|k| { + let a = &p1.hooks.hooks[**k]; + let b = &p2.hooks.hooks[**k]; + a.event != b.event || a.matcher != b.matcher || a.script != b.script + }) + .copied() + .collect(); + if !hooks_added.is_empty() || !hooks_removed.is_empty() || !hooks_changed.is_empty() { + any_diff = true; + println!(); + println!(" {}:", theme::fg("Hooks", t.subtext).bold()); + for h in &hooks_removed { + println!(" {} {}", theme::fg("-", t.red), theme::fg(h, t.red)); + } + for h in &hooks_added { + println!(" {} {}", theme::fg("+", t.green), theme::fg(h, t.green)); + } + for h in &hooks_changed { + println!( + " {} {} (changed)", + theme::fg("~", t.yellow), + theme::fg(h, t.yellow) + ); + } + } + + // Custom credentials + let cc1: BTreeSet<&String> = p1.network.custom_credentials.keys().collect(); + let cc2: BTreeSet<&String> = p2.network.custom_credentials.keys().collect(); + let cc_added: BTreeSet<&&String> = cc2.difference(&cc1).collect(); + let cc_removed: BTreeSet<&&String> = cc1.difference(&cc2).collect(); + let cc_changed: Vec<&String> = cc1 + .intersection(&cc2) + .filter(|k| p1.network.custom_credentials[**k] != p2.network.custom_credentials[**k]) + .copied() + .collect(); + if !cc_added.is_empty() || !cc_removed.is_empty() || !cc_changed.is_empty() { + any_diff = true; + println!(); + println!(" {}:", theme::fg("Custom credentials", t.subtext).bold()); + for c in &cc_removed { + println!(" {} {}", theme::fg("-", t.red), theme::fg(c, t.red)); + } + for c in &cc_added { + println!(" {} {}", theme::fg("+", t.green), theme::fg(c, t.green)); + } + for c in &cc_changed { + let old = &p1.network.custom_credentials[*c]; + let new = &p2.network.custom_credentials[*c]; + println!( + " {} {} (changed)", + theme::fg("~", t.yellow), + theme::fg(c, t.yellow) + ); + if old.upstream != new.upstream { + println!( + " {} upstream: {}", + theme::fg("-", t.red), + theme::fg(&old.upstream, t.red) + ); + println!( + " {} upstream: {}", + theme::fg("+", t.green), + theme::fg(&new.upstream, t.green) + ); + } + if old.credential_key != new.credential_key { + let old_key = old.credential_key.as_deref().unwrap_or(""); + let new_key = new.credential_key.as_deref().unwrap_or(""); + println!( + " {} credential_key: {}", + theme::fg("-", t.red), + theme::fg(old_key, t.red) + ); + println!( + " {} credential_key: {}", + theme::fg("+", t.green), + theme::fg(new_key, t.green) + ); + } + if old.inject_mode != new.inject_mode { + println!( + " {} inject_mode: {:?}", + theme::fg("-", t.red), + old.inject_mode + ); + println!( + " {} inject_mode: {:?}", + theme::fg("+", t.green), + new.inject_mode + ); + } + if old.inject_header != new.inject_header { + println!( + " {} inject_header: {}", + theme::fg("-", t.red), + theme::fg(&old.inject_header, t.red) + ); + println!( + " {} inject_header: {}", + theme::fg("+", t.green), + theme::fg(&new.inject_header, t.green) + ); + } + if old.credential_format != new.credential_format { + println!( + " {} credential_format: {}", + theme::fg("-", t.red), + theme::fg(&old.credential_format, t.red) + ); + println!( + " {} credential_format: {}", + theme::fg("+", t.green), + theme::fg(&new.credential_format, t.green) + ); + } + if old.path_pattern != new.path_pattern { + println!( + " {} path_pattern: {:?}", + theme::fg("-", t.red), + old.path_pattern + ); + println!( + " {} path_pattern: {:?}", + theme::fg("+", t.green), + new.path_pattern + ); + } + if old.path_replacement != new.path_replacement { + println!( + " {} path_replacement: {:?}", + theme::fg("-", t.red), + old.path_replacement + ); + println!( + " {} path_replacement: {:?}", + theme::fg("+", t.green), + new.path_replacement + ); + } + if old.query_param_name != new.query_param_name { + println!( + " {} query_param_name: {:?}", + theme::fg("-", t.red), + old.query_param_name + ); + println!( + " {} query_param_name: {:?}", + theme::fg("+", t.green), + new.query_param_name + ); + } + if old.env_var != new.env_var { + println!(" {} env_var: {:?}", theme::fg("-", t.red), old.env_var); + println!( + " {} env_var: {:?}", + theme::fg("+", t.green), + new.env_var + ); + } + } + } + + if !any_diff { + println!(); + println!(" {}", theme::fg("(no differences)", t.subtext)); + } + + Ok(()) +} + +/// Print a diff for an optional scalar field. Returns true if there was a difference. +fn diff_scalar_option( + label: &str, + v1: &Option, + v2: &Option, + t: &theme::Theme, +) -> bool { + if v1 == v2 { + return false; + } + println!(); + println!(" {}:", theme::fg(label, t.subtext).bold()); + if let Some(ref old) = v1 { + println!(" {}", theme::fg(&format!("- {old}"), t.red)); + } + if let Some(ref new) = v2 { + println!(" {}", theme::fg(&format!("+ {new}"), t.green)); + } + true +} + +fn diff_string_vecs<'a>( + pairs: &[(&'a str, &[String], &[String])], +) -> Vec<(&'a str, &'static str, String)> { + let mut result = Vec::new(); + for (label, v1, v2) in pairs { + let s1: BTreeSet<&str> = v1.iter().map(|s| s.as_str()).collect(); + let s2: BTreeSet<&str> = v2.iter().map(|s| s.as_str()).collect(); + for removed in s1.difference(&s2) { + result.push((*label, "-", removed.to_string())); + } + for added in s2.difference(&s1) { + result.push((*label, "+", added.to_string())); + } + } + result +} + +fn diff_to_json(name1: &str, name2: &str, p1: &Profile, p2: &Profile) -> serde_json::Value { + let g1: BTreeSet<&str> = p1.security.groups.iter().map(|s| s.as_str()).collect(); + let g2: BTreeSet<&str> = p2.security.groups.iter().map(|s| s.as_str()).collect(); + + let groups_added: Vec<&str> = g2.difference(&g1).copied().collect(); + let groups_removed: Vec<&str> = g1.difference(&g2).copied().collect(); + + let diff_vec = |v1: &[String], v2: &[String]| -> serde_json::Value { + let s1: BTreeSet<&str> = v1.iter().map(|s| s.as_str()).collect(); + let s2: BTreeSet<&str> = v2.iter().map(|s| s.as_str()).collect(); + let added: Vec<&str> = s2.difference(&s1).copied().collect(); + let removed: Vec<&str> = s1.difference(&s2).copied().collect(); + serde_json::json!({ "added": added, "removed": removed }) + }; + + let ou1 = p1.open_urls.as_ref(); + let ou2 = p2.open_urls.as_ref(); + + serde_json::json!({ + "profile1": name1, + "profile2": name2, + "groups": { + "added": groups_added, + "removed": groups_removed, + }, + "allowed_commands": diff_vec( + &p1.security.allowed_commands, + &p2.security.allowed_commands, + ), + "capability_elevation": { + "profile1": p1.security.capability_elevation, + "profile2": p2.security.capability_elevation, + "changed": p1.security.capability_elevation != p2.security.capability_elevation, + }, + "wsl2_proxy_policy": { + "profile1": format!("{:?}", p1.security.wsl2_proxy_policy), + "profile2": format!("{:?}", p2.security.wsl2_proxy_policy), + "changed": p1.security.wsl2_proxy_policy != p2.security.wsl2_proxy_policy, + }, + "filesystem": diff_fs_json(&p1.filesystem, &p2.filesystem), + "workdir": { + "profile1": format!("{:?}", p1.workdir.access), + "profile2": format!("{:?}", p2.workdir.access), + "changed": p1.workdir.access != p2.workdir.access, + }, + "network": { + "block": { + "profile1": p1.network.block, + "profile2": p2.network.block, + "changed": p1.network.block != p2.network.block, + }, + "network_profile": { + "profile1": p1.network.resolved_network_profile(), + "profile2": p2.network.resolved_network_profile(), + "changed": p1.network.resolved_network_profile() != p2.network.resolved_network_profile(), + }, + "allow_domain": diff_vec(&p1.network.allow_domain, &p2.network.allow_domain), + "reject_domain": diff_vec(&p1.network.reject_domain, &p2.network.reject_domain), + "credentials": diff_vec(p1.network.resolved_credentials(), p2.network.resolved_credentials()), + "open_port": { + "profile1": p1.network.open_port, + "profile2": p2.network.open_port, + "changed": p1.network.open_port != p2.network.open_port, + }, + "listen_port": { + "profile1": p1.network.listen_port, + "profile2": p2.network.listen_port, + "changed": p1.network.listen_port != p2.network.listen_port, + }, + "upstream_proxy": { + "profile1": p1.network.upstream_proxy, + "profile2": p2.network.upstream_proxy, + "changed": p1.network.upstream_proxy != p2.network.upstream_proxy, + }, + "upstream_bypass": diff_vec( + &p1.network.upstream_bypass, + &p2.network.upstream_bypass, + ), + "custom_credentials": diff_custom_credentials_json( + &p1.network.custom_credentials, + &p2.network.custom_credentials, + ), + }, + "env_credentials": { + "profile1": p1.env_credentials.mappings, + "profile2": p2.env_credentials.mappings, + "changed": p1.env_credentials.mappings != p2.env_credentials.mappings, + }, + "hooks": diff_hooks_json(&p1.hooks.hooks, &p2.hooks.hooks), + "rollback": { + "exclude_patterns": diff_vec(&p1.rollback.exclude_patterns, &p2.rollback.exclude_patterns), + "exclude_globs": diff_vec(&p1.rollback.exclude_globs, &p2.rollback.exclude_globs), + }, + "open_urls": { + "allow_origins": diff_vec( + &ou1.map(|u| u.allow_origins.clone()).unwrap_or_default(), + &ou2.map(|u| u.allow_origins.clone()).unwrap_or_default(), + ), + "allow_localhost": { + "profile1": ou1.is_some_and(|u| u.allow_localhost), + "profile2": ou2.is_some_and(|u| u.allow_localhost), + "changed": ou1.is_some_and(|u| u.allow_localhost) != ou2.is_some_and(|u| u.allow_localhost), + }, + }, + "allow_launch_services": { + "profile1": p1.allow_launch_services, + "profile2": p2.allow_launch_services, + "changed": p1.allow_launch_services != p2.allow_launch_services, + }, + "allow_gpu": { + "profile1": p1.allow_gpu, + "profile2": p2.allow_gpu, + "changed": p1.allow_gpu != p2.allow_gpu, + }, + }) +} + +fn diff_fs_json( + fs1: &profile::FilesystemConfig, + fs2: &profile::FilesystemConfig, +) -> serde_json::Value { + let diff_vec = |v1: &[String], v2: &[String]| -> serde_json::Value { + let s1: BTreeSet<&str> = v1.iter().map(|s| s.as_str()).collect(); + let s2: BTreeSet<&str> = v2.iter().map(|s| s.as_str()).collect(); + let added: Vec<&str> = s2.difference(&s1).copied().collect(); + let removed: Vec<&str> = s1.difference(&s2).copied().collect(); + serde_json::json!({ "added": added, "removed": removed }) + }; + + serde_json::json!({ + "allow": diff_vec(&fs1.allow, &fs2.allow), + "read": diff_vec(&fs1.read, &fs2.read), + "write": diff_vec(&fs1.write, &fs2.write), + "allow_file": diff_vec(&fs1.allow_file, &fs2.allow_file), + "read_file": diff_vec(&fs1.read_file, &fs2.read_file), + "write_file": diff_vec(&fs1.write_file, &fs2.write_file), + }) +} + +fn diff_hooks_json( + h1: &std::collections::HashMap, + h2: &std::collections::HashMap, +) -> serde_json::Value { + let added: Vec<&String> = h2.keys().filter(|k| !h1.contains_key(*k)).collect(); + let removed: Vec<&String> = h1.keys().filter(|k| !h2.contains_key(*k)).collect(); + let changed: Vec<&String> = h1 + .keys() + .filter(|k| { + h2.get(*k).is_some_and(|v2| { + let v1 = &h1[*k]; + v1.event != v2.event || v1.matcher != v2.matcher || v1.script != v2.script + }) + }) + .collect(); + + let mut changed_details = serde_json::Map::new(); + for k in &changed { + let old = &h1[*k]; + let new = &h2[*k]; + let mut detail = serde_json::Map::new(); + if old.event != new.event { + detail.insert( + "event".into(), + serde_json::json!({"profile1": old.event, "profile2": new.event}), + ); + } + if old.matcher != new.matcher { + detail.insert( + "matcher".into(), + serde_json::json!({"profile1": old.matcher, "profile2": new.matcher}), + ); + } + if old.script != new.script { + detail.insert( + "script".into(), + serde_json::json!({"profile1": old.script, "profile2": new.script}), + ); + } + changed_details.insert((*k).clone(), serde_json::Value::Object(detail)); + } + + serde_json::json!({ + "added": added, + "removed": removed, + "changed": changed_details, + }) +} + +fn diff_custom_credentials_json( + cc1: &std::collections::HashMap, + cc2: &std::collections::HashMap, +) -> serde_json::Value { + let added: Vec<&String> = cc2.keys().filter(|k| !cc1.contains_key(*k)).collect(); + let removed: Vec<&String> = cc1.keys().filter(|k| !cc2.contains_key(*k)).collect(); + let changed: Vec<&String> = cc1 + .keys() + .filter(|k| cc2.get(*k).is_some_and(|v2| cc1[*k] != *v2)) + .collect(); + + let mut changed_details = serde_json::Map::new(); + for k in &changed { + let old = &cc1[*k]; + let new = &cc2[*k]; + let mut detail = serde_json::Map::new(); + if old.upstream != new.upstream { + detail.insert( + "upstream".into(), + serde_json::json!({"profile1": old.upstream, "profile2": new.upstream}), + ); + } + if old.credential_key != new.credential_key { + detail.insert( + "credential_key".into(), + serde_json::json!({"profile1": old.credential_key, "profile2": new.credential_key}), + ); + } + if old.inject_mode != new.inject_mode { + detail.insert( + "inject_mode".into(), + serde_json::json!({"profile1": format!("{:?}", old.inject_mode), "profile2": format!("{:?}", new.inject_mode)}), + ); + } + if old.inject_header != new.inject_header { + detail.insert( + "inject_header".into(), + serde_json::json!({"profile1": old.inject_header, "profile2": new.inject_header}), + ); + } + if old.credential_format != new.credential_format { + detail.insert( + "credential_format".into(), + serde_json::json!({"profile1": old.credential_format, "profile2": new.credential_format}), + ); + } + if old.path_pattern != new.path_pattern { + detail.insert( + "path_pattern".into(), + serde_json::json!({"profile1": old.path_pattern, "profile2": new.path_pattern}), + ); + } + if old.path_replacement != new.path_replacement { + detail.insert( + "path_replacement".into(), + serde_json::json!({"profile1": old.path_replacement, "profile2": new.path_replacement}), + ); + } + if old.query_param_name != new.query_param_name { + detail.insert( + "query_param_name".into(), + serde_json::json!({"profile1": old.query_param_name, "profile2": new.query_param_name}), + ); + } + if old.env_var != new.env_var { + detail.insert( + "env_var".into(), + serde_json::json!({"profile1": old.env_var, "profile2": new.env_var}), + ); + } + changed_details.insert((*k).clone(), serde_json::Value::Object(detail)); + } + + serde_json::json!({ + "added": added, + "removed": removed, + "changed": changed_details, + }) +} + +// --------------------------------------------------------------------------- +// nono policy validate +// --------------------------------------------------------------------------- + +fn classify_profile_error(e: &NonoError) -> &'static str { + match e { + NonoError::ProfileParse(msg) + if msg.starts_with("expected") + || msg.contains("line ") + || msg.contains("column ") + || msg.contains("EOF") => + { + "JSON syntax error" + } + NonoError::ProfileParse(_) => "Profile error", + NonoError::ProfileRead { .. } => "File read error", + NonoError::ProfileInheritance(_) => "Inheritance error", + NonoError::ProfileNotFound(_) => "Profile not found", + _ => "Error", + } +} + +fn cmd_validate(args: PolicyValidateArgs) -> Result<()> { + let pol = policy::load_embedded_policy()?; + let mut errors: Vec = Vec::new(); + let mut warnings: Vec = Vec::new(); + + // Step 1: Load profile (parse JSON + resolve inheritance) + let profile = match profile::load_profile_from_path(&args.file) { + Ok(p) => Some(p), + Err(e) => { + let label = classify_profile_error(&e); + errors.push(format!("{}: {}", label, e)); + None + } + }; + + if let Some(ref profile) = profile { + // Step 2: Check group references + for group_name in &profile.security.groups { + if !pol.groups.contains_key(group_name) { + errors.push(format!("Group '{}' not found in policy.json", group_name)); + } + } + + // Step 3: Check exclude_groups + for excl in &profile.policy.exclude_groups { + if let Some(group) = pol.groups.get(excl) { + if group.required { + errors.push(format!("Cannot exclude required group '{}'", excl)); + } + } else { + warnings.push(format!( + "Excluded group '{}' not found in policy.json", + excl + )); + } + } + + // Step 5: Check for empty paths + let check_paths = |paths: &[String], label: &str, w: &mut Vec| { + for p in paths { + if p.trim().is_empty() { + w.push(format!("Empty path in {}", label)); + } + } + }; + check_paths(&profile.filesystem.allow, "filesystem.allow", &mut warnings); + check_paths(&profile.filesystem.read, "filesystem.read", &mut warnings); + check_paths(&profile.filesystem.write, "filesystem.write", &mut warnings); + } + + if args.json { + let val = serde_json::json!({ + "file": args.file.display().to_string(), + "valid": errors.is_empty(), + "errors": errors, + "warnings": warnings, + }); + println!("{}", to_json(&val)?); + if !errors.is_empty() { + return Err(NonoError::ProfileParse("validation failed".into())); + } + return Ok(()); + } + + let t = theme::current(); + println!( + "{}: validating {}", + prefix(), + theme::fg(&args.file.display().to_string(), t.text) + ); + println!(); + + if profile.is_some() { + println!(" {} JSON syntax valid", theme::fg("[ok]", t.green)); + } + + if let Some(ref profile) = profile { + let valid_groups = profile + .security + .groups + .iter() + .filter(|g| pol.groups.contains_key(g.as_str())) + .count(); + let total_groups = profile.security.groups.len(); + if valid_groups == total_groups && total_groups > 0 { + println!( + " {} All {} group references valid", + theme::fg("[ok]", t.green), + total_groups + ); + } + } + + for w in &warnings { + println!( + " {} {}", + theme::fg("[warn]", t.yellow), + theme::fg(w, t.yellow) + ); + } + + for e in &errors { + println!(" {} {}", theme::fg("[err]", t.red), theme::fg(e, t.red)); + } + + println!(); + if errors.is_empty() { + let suffix = if warnings.is_empty() { + String::new() + } else { + format!( + " ({} warning{})", + warnings.len(), + if warnings.len() == 1 { "" } else { "s" } + ) + }; + println!( + " Result: {}{}", + theme::fg("valid", t.green).bold(), + theme::fg(&suffix, t.yellow) + ); + Ok(()) + } else { + println!( + " Result: {} ({} error{})", + theme::fg("invalid", t.red).bold(), + errors.len(), + if errors.len() == 1 { "" } else { "s" } + ); + Err(NonoError::ProfileParse("validation failed".into())) + } +} + +// --------------------------------------------------------------------------- +// Profile → Manifest compilation +// --------------------------------------------------------------------------- + +/// Compile a resolved profile into a capability manifest. +/// +/// This produces a fully-resolved, portable manifest with absolute paths. +/// Environment variables (`~`, `$HOME`, `$TMPDIR`, etc.) are expanded. +fn resolve_to_manifest( + prof: &Profile, + workdir: &std::path::Path, +) -> Result { + use nono::manifest; + + // Helper: expand a path template and convert to string for the manifest + let expand = |tmpl: &str| -> Result { + let expanded = profile::expand_vars(tmpl, workdir)?; + Ok(expanded.to_string_lossy().into_owned()) + }; + + // Filesystem + let mut grants = Vec::new(); + let mut deny = Vec::new(); + + let fs_sources: &[(&[String], manifest::AccessMode, bool)] = &[ + ( + &prof.filesystem.allow, + manifest::AccessMode::Readwrite, + false, + ), + (&prof.filesystem.read, manifest::AccessMode::Read, false), + (&prof.filesystem.write, manifest::AccessMode::Write, false), + ( + &prof.filesystem.allow_file, + manifest::AccessMode::Readwrite, + true, + ), + (&prof.filesystem.read_file, manifest::AccessMode::Read, true), + ( + &prof.filesystem.write_file, + manifest::AccessMode::Write, + true, + ), + ( + &prof.policy.add_allow_read, + manifest::AccessMode::Read, + false, + ), + ( + &prof.policy.add_allow_write, + manifest::AccessMode::Write, + false, + ), + ( + &prof.policy.add_allow_readwrite, + manifest::AccessMode::Readwrite, + false, + ), + ]; + + for (paths, access, is_file) in fs_sources { + for p in *paths { + grants.push(make_fs_grant(&expand(p)?, *access, *is_file)?); + } + } + // Deny paths from policy patches + for p in &prof.policy.add_deny_access { + let expanded = expand(p)?; + deny.push(manifest::FsDeny { + path: expanded + .parse() + .map_err(|e| NonoError::ConfigParse(format!("invalid deny path: {e}")))?, + }); + } + + // Resolve security.groups → filesystem grants, deny paths, and blocked commands. + // Groups are the primary source of system read paths, deny rules, and dangerous + // command blocks. Without this, the exported manifest is weaker than the profile. + let loaded_policy = policy::load_embedded_policy()?; + let mut scratch_caps = nono::CapabilitySet::new(); + let resolved_groups = + policy::resolve_groups(&loaded_policy, &prof.security.groups, &mut scratch_caps)?; + + // Add filesystem grants from resolved groups + for cap in scratch_caps.fs_capabilities() { + let access = match cap.access { + nono::AccessMode::Read => manifest::AccessMode::Read, + nono::AccessMode::Write => manifest::AccessMode::Write, + nono::AccessMode::ReadWrite => manifest::AccessMode::Readwrite, + }; + let path_str = cap.resolved.to_string_lossy().into_owned(); + grants.push(make_fs_grant(&path_str, access, cap.is_file)?); + } + + // Expand override_deny paths so we can filter them out of the deny list. + // The manifest is the fully-resolved output — overridden denies must not + // appear, otherwise the manifest re-applies restrictions the profile relaxed. + let override_deny_expanded: Vec = prof + .policy + .override_deny + .iter() + .filter_map(|tmpl| profile::expand_vars(tmpl, workdir).ok()) + .map(|p| { + if p.exists() { + p.canonicalize().unwrap_or(p) + } else { + p + } + }) + .collect(); + + // Add deny paths from resolved groups, filtering out overridden paths. + for deny_path in resolved_groups + .deny_paths + .iter() + .filter(|dp| !override_deny_expanded.iter().any(|ovr| dp.starts_with(ovr))) + { + let path_str = deny_path.to_string_lossy().into_owned(); + deny.push(manifest::FsDeny { + path: path_str + .parse() + .map_err(|e| NonoError::ConfigParse(format!("invalid deny path: {e}")))?, + }); + } + + // Add blocked commands from resolved groups + let group_blocked_commands: Vec = scratch_caps.blocked_commands().to_vec(); + + // Add workdir access as a filesystem grant + let workdir_str = workdir.to_string_lossy().into_owned(); + match prof.workdir.access { + WorkdirAccess::ReadWrite => { + grants.push(make_fs_grant( + &workdir_str, + manifest::AccessMode::Readwrite, + false, + )?); + } + WorkdirAccess::Read => { + grants.push(make_fs_grant( + &workdir_str, + manifest::AccessMode::Read, + false, + )?); + } + WorkdirAccess::Write => { + grants.push(make_fs_grant( + &workdir_str, + manifest::AccessMode::Write, + false, + )?); + } + WorkdirAccess::None => {} // no grant + } + + // Deduplicate grants: if the same path appears from both filesystem.allow + // and workdir (or groups), keep the highest-access-mode entry. + grants.sort_by(|a, b| a.path.as_str().cmp(b.path.as_str())); + grants.dedup_by(|a, b| { + if a.path.as_str() == b.path.as_str() && a.type_ == b.type_ { + // Keep the broader access mode in `b` (the survivor of dedup_by) + b.access = wider_access(a.access, b.access); + true + } else { + false + } + }); + + // Deduplicate deny entries by path + deny.sort_by(|a, b| a.path.as_str().cmp(b.path.as_str())); + deny.dedup_by(|a, b| a.path.as_str() == b.path.as_str()); + + let filesystem = if grants.is_empty() && deny.is_empty() { + None + } else { + Some(manifest::Filesystem { grants, deny }) + }; + + // Network + let network_mode = if prof.network.block { + manifest::NetworkMode::Blocked + } else if prof.network.resolved_network_profile().is_some() + || !prof.network.allow_domain.is_empty() + || !prof.network.resolved_credentials().is_empty() + || !prof.network.custom_credentials.is_empty() + { + manifest::NetworkMode::Proxy + } else { + manifest::NetworkMode::Unrestricted + }; + + let network = Some(manifest::Network { + mode: network_mode, + allow_domains: prof.network.allow_domain.clone(), + endpoints: Vec::new(), + dns: true, + ports: if prof.network.listen_port.is_empty() && prof.network.open_port.is_empty() { + None + } else { + Some(manifest::PortConfig { + connect: Vec::new(), + bind: prof + .network + .listen_port + .iter() + .filter_map(|p| std::num::NonZeroU64::new(u64::from(*p))) + .collect(), + localhost: prof + .network + .open_port + .iter() + .filter_map(|p| std::num::NonZeroU64::new(u64::from(*p))) + .collect(), + }) + }, + }); + + // Process + let signal_mode = match prof.security.signal_mode { + Some(profile::ProfileSignalMode::Isolated) | None => manifest::SignalMode::Isolated, + Some(profile::ProfileSignalMode::AllowSameSandbox) => { + manifest::SignalMode::AllowSameSandbox + } + Some(profile::ProfileSignalMode::AllowAll) => manifest::SignalMode::AllowAll, + }; + let process_info_mode = match prof.security.process_info_mode { + Some(profile::ProfileProcessInfoMode::Isolated) | None => { + manifest::ProcessInfoMode::Isolated + } + Some(profile::ProfileProcessInfoMode::AllowSameSandbox) => { + manifest::ProcessInfoMode::AllowSameSandbox + } + Some(profile::ProfileProcessInfoMode::AllowAll) => manifest::ProcessInfoMode::AllowAll, + }; + let ipc_mode = match prof.security.ipc_mode { + Some(profile::ProfileIpcMode::SharedMemoryOnly) | None => { + manifest::IpcMode::SharedMemoryOnly + } + Some(profile::ProfileIpcMode::Full) => manifest::IpcMode::Full, + }; + + let process = Some(manifest::Process { + allowed_commands: prof.security.allowed_commands.clone(), + blocked_commands: { + let mut cmds = group_blocked_commands; + cmds.extend(prof.policy.add_deny_commands.clone()); + cmds.sort(); + cmds.dedup(); + cmds + }, + exec_strategy: if !prof.rollback.exclude_patterns.is_empty() + || !prof.rollback.exclude_globs.is_empty() + { + manifest::ExecStrategy::Supervised + } else { + manifest::ExecStrategy::Monitor + }, + signal_mode, + process_info_mode, + ipc_mode, + }); + + // Rollback + let rollback = + if prof.rollback.exclude_patterns.is_empty() && prof.rollback.exclude_globs.is_empty() { + None + } else { + Some(manifest::Rollback { + enabled: false, + exclude_patterns: prof.rollback.exclude_patterns.clone(), + exclude_globs: prof.rollback.exclude_globs.clone(), + }) + }; + + // Credentials (custom_credentials from profile → manifest credentials) + // OAuth2 credentials (auth field) are not yet representable in the manifest + // schema, so only static-key credentials are exported. + let mut credentials = Vec::new(); + for (name, cred) in &prof.network.custom_credentials { + let inject_mode = match cred.inject_mode { + profile::InjectMode::Header => manifest::InjectMode::Header, + profile::InjectMode::UrlPath => manifest::InjectMode::UrlPath, + profile::InjectMode::QueryParam => manifest::InjectMode::QueryParam, + profile::InjectMode::BasicAuth => manifest::InjectMode::BasicAuth, + }; + + let endpoint_rules: Vec = cred + .endpoint_rules + .iter() + .map(|r| { + let method = r.method.parse().map_err(|e| { + NonoError::ConfigParse(format!( + "invalid endpoint rule method '{}': {e}", + r.method + )) + })?; + let path = r.path.parse().map_err(|e| { + NonoError::ConfigParse(format!("invalid endpoint rule path '{}': {e}", r.path)) + })?; + Ok(manifest::EndpointRule { method, path }) + }) + .collect::>>()?; + + credentials.push(manifest::Credential { + name: name + .parse() + .map_err(|e| NonoError::ConfigParse(format!("invalid credential name: {e}")))?, + upstream: cred + .upstream + .parse() + .map_err(|e| NonoError::ConfigParse(format!("invalid credential upstream: {e}")))?, + source: match cred.credential_key.as_ref() { + Some(key) => key.parse().map_err(|e| { + NonoError::ConfigParse(format!("invalid credential source: {e}")) + })?, + None => continue, + }, + inject: Some(manifest::CredentialInject { + mode: inject_mode, + header: cred.inject_header.clone(), + format: cred.credential_format.clone(), + path_pattern: cred.path_pattern.clone(), + path_replacement: cred.path_replacement.clone(), + query_param_name: cred.query_param_name.clone(), + }), + env_var: cred + .env_var + .as_ref() + .map(|v| { + v.parse() + .map_err(|e| NonoError::ConfigParse(format!("invalid env_var: {e}"))) + }) + .transpose()?, + endpoint_rules, + }); + } + + let version = "0.1.0" + .parse() + .map_err(|e| NonoError::ConfigParse(format!("version parse error: {e}")))?; + + Ok(manifest::CapabilityManifest { + version, + schema: Some("https://nono.dev/schemas/capability-manifest.schema.json".to_string()), + filesystem, + network, + process, + rollback, + credentials, + }) +} + +/// Return the broader of two access modes (Read + Write → Readwrite). +fn wider_access( + a: nono::manifest::AccessMode, + b: nono::manifest::AccessMode, +) -> nono::manifest::AccessMode { + use nono::manifest::AccessMode::{Read, Readwrite, Write}; + match (a, b) { + (Readwrite, _) | (_, Readwrite) => Readwrite, + (Read, Write) | (Write, Read) => Readwrite, + (Read, Read) => Read, + (Write, Write) => Write, + } +} + +/// Helper to construct an `FsGrant` from an expanded path string. +fn make_fs_grant( + path: &str, + access: nono::manifest::AccessMode, + is_file: bool, +) -> Result { + Ok(nono::manifest::FsGrant { + path: path + .parse() + .map_err(|e| NonoError::ConfigParse(format!("invalid grant path: {e}")))?, + access, + type_: if is_file { + nono::manifest::FsEntryType::File + } else { + nono::manifest::FsEntryType::Directory + }, + }) +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_groups_lists_all() { + let pol = policy::load_embedded_policy().expect("should load policy"); + assert!( + pol.groups.len() > 10, + "expected many groups, got {}", + pol.groups.len() + ); + assert!( + pol.groups.contains_key("deny_credentials"), + "expected deny_credentials group" + ); + } + + #[test] + fn test_groups_specific_known() { + let pol = policy::load_embedded_policy().expect("should load policy"); + let group = pol + .groups + .get("deny_credentials") + .expect("deny_credentials should exist"); + assert!(!group.description.is_empty()); + assert!(group.required); + if let Some(ref deny) = group.deny { + let all_paths = deny.access.join(" "); + assert!(all_paths.contains(".ssh"), "expected .ssh in deny paths"); + assert!(all_paths.contains(".aws"), "expected .aws in deny paths"); + } else { + panic!("deny_credentials should have deny rules"); + } + } + + #[test] + fn test_groups_unknown_errors() { + let pol = policy::load_embedded_policy().expect("should load policy"); + let result = cmd_groups_detail(&pol, "nonexistent_group_xyz", false); + assert!(result.is_err()); + } + + #[test] + fn test_profiles_includes_builtins() { + let profiles = profile::list_profiles(); + assert!( + profiles.contains(&"default".to_string()), + "expected 'default' in profiles" + ); + assert!( + profiles.contains(&"claude-code".to_string()), + "expected 'claude-code' in profiles" + ); + } + + #[test] + fn test_show_resolves_inheritance() { + let profile = + profile::load_profile("claude-code").expect("claude-code profile should load"); + assert!( + !profile.security.groups.is_empty(), + "claude-code should have security groups" + ); + // claude-code extends default, so it should have default's base groups + let has_deny = profile.security.groups.iter().any(|g| g.contains("deny")); + assert!(has_deny, "claude-code should inherit deny groups"); + } + + #[test] + fn test_diff_shows_differences() { + let p1 = profile::load_profile("default").expect("default should load"); + let p2 = profile::load_profile("claude-code").expect("claude-code should load"); + + let g1: BTreeSet<&str> = p1.security.groups.iter().map(|s| s.as_str()).collect(); + let g2: BTreeSet<&str> = p2.security.groups.iter().map(|s| s.as_str()).collect(); + + let added: BTreeSet<&&str> = g2.difference(&g1).collect(); + assert!( + !added.is_empty(), + "claude-code should have additional groups over default" + ); + } + + #[test] + fn test_validate_valid_profile() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("test-profile.json"); + std::fs::write( + &path, + r#"{ + "meta": { "name": "test", "description": "test profile" }, + "security": { "groups": ["deny_credentials"] }, + "workdir": { "access": "readwrite" } + }"#, + ) + .expect("write"); + + let args = PolicyValidateArgs { + file: path, + json: false, + }; + let result = cmd_validate(args); + assert!(result.is_ok(), "valid profile should pass validation"); + } + + #[test] + fn test_validate_invalid_group() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("bad-profile.json"); + std::fs::write( + &path, + r#"{ + "meta": { "name": "test" }, + "security": { "groups": ["nonexistent_group_xyz"] } + }"#, + ) + .expect("write"); + + let args = PolicyValidateArgs { + file: path, + json: false, + }; + let result = cmd_validate(args); + assert!(result.is_err(), "invalid group should fail validation"); + } + + #[test] + fn test_validate_exclude_required() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("bad-exclude.json"); + std::fs::write( + &path, + r#"{ + "meta": { "name": "test" }, + "security": { "groups": [] }, + "policy": { "exclude_groups": ["deny_credentials"] } + }"#, + ) + .expect("write"); + + let args = PolicyValidateArgs { + file: path, + json: false, + }; + let result = cmd_validate(args); + assert!( + result.is_err(), + "excluding required group should fail validation" + ); + } +} diff --git a/crates/nono-cli/src/profile/mod.rs b/crates/nono-cli/src/profile/mod.rs index fa1c694e1..39eab8a0e 100644 --- a/crates/nono-cli/src/profile/mod.rs +++ b/crates/nono-cli/src/profile/mod.rs @@ -1023,6 +1023,11 @@ pub struct NetworkConfig { alias = "allow_proxy" )] pub allow_domain: Vec, + /// Domains to always reject, even if they appear in `allow_domain`. + /// Supports wildcards (`*.evil.com`). Canonical profile key: + /// `reject_domain`. + #[serde(default, rename = "reject_domain")] + pub reject_domain: Vec, /// Credential services to enable via reverse proxy. /// Canonical profile key: `credentials` (legacy `proxy_credentials` accepted). /// @@ -1071,6 +1076,15 @@ pub struct NetworkConfig { /// ALIAS(canonical="upstream_bypass", introduced="v0.0.0", remove_by="indefinite", issue="#415") #[serde(default, rename = "upstream_bypass", alias = "external_proxy_bypass")] pub upstream_bypass: Vec, + /// How to handle requests to blocked hosts at runtime. + /// `"off"` (default) denies immediately; `"ask"` shows an OS notification + /// with action buttons (Deny / Allow once / Always allow). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub approval_mode: Option, + /// Seconds to wait for user approval before denying (default: 60). + /// Only applies when `approval_mode` is not `"off"`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub approval_timeout_secs: Option, } impl NetworkConfig { @@ -1793,7 +1807,32 @@ pub fn load_profile_extends(name_or_path: &str) -> Option> { None } -/// Load a profile by name or file path +/// Resolve the file path for a profile name or direct path. +/// +/// Returns the resolved filesystem path if the profile is a user file, +/// or `None` for built-in profiles or invalid names. +/// +/// This is the same resolution logic used by [`load_profile`]. +pub fn resolve_profile_path(name_or_path: &str) -> Option { + if name_or_path.contains('/') || name_or_path.ends_with(".json") { + let path = Path::new(name_or_path); + if path.exists() { + return Some(path.to_path_buf()); + } + return None; + } + + if !is_valid_profile_name(name_or_path) { + return None; + } + + match get_user_profile_path(name_or_path) { + Ok(path) if path.exists() => Some(path), + _ => None, + } +} + +/// Load a profile by name or file path. /// /// If `name_or_path` contains a path separator or ends with `.json`, it is /// treated as a direct file path. Otherwise it is resolved as a profile name. @@ -2579,6 +2618,7 @@ fn merge_profiles(base: Profile, child: Profile) -> Profile { &base.network.allow_domain, &child.network.allow_domain, ), + reject_domain: dedup_append(&base.network.reject_domain, &child.network.reject_domain), open_port: dedup_append(&base.network.open_port, &child.network.open_port), listen_port: dedup_append(&base.network.listen_port, &child.network.listen_port), connect_port: dedup_append(&base.network.connect_port, &child.network.connect_port), @@ -2610,6 +2650,15 @@ fn merge_profiles(base: Profile, child: Profile) -> Profile { &base.network.upstream_bypass, &child.network.upstream_bypass, ), + approval_mode: child + .network + .approval_mode + .clone() + .or(base.network.approval_mode.clone()), + approval_timeout_secs: child + .network + .approval_timeout_secs + .or(base.network.approval_timeout_secs), }, linux: LinuxConfig { af_unix_mediation: child @@ -4601,6 +4650,7 @@ mod tests { block: false, network_profile: InheritableValue::Set("base-net".to_string()), allow_domain: vec![AllowDomainEntry::Plain("base.example.com".to_string())], + reject_domain: vec![], open_port: vec![3000], listen_port: vec![4000], connect_port: vec![], @@ -4608,6 +4658,8 @@ mod tests { custom_credentials: HashMap::new(), upstream_proxy: None, upstream_bypass: Vec::new(), + approval_mode: None, + approval_timeout_secs: None, }, linux: LinuxConfig::default(), env_credentials: SecretsConfig { @@ -4681,6 +4733,7 @@ mod tests { block: false, network_profile: InheritableValue::Inherit, allow_domain: vec![AllowDomainEntry::Plain("child.example.com".to_string())], + reject_domain: vec![], open_port: vec![3000, 5000], listen_port: vec![4000, 6000], connect_port: vec![], @@ -4688,6 +4741,8 @@ mod tests { custom_credentials: HashMap::new(), upstream_proxy: None, upstream_bypass: Vec::new(), + approval_mode: None, + approval_timeout_secs: None, }, linux: LinuxConfig::default(), env_credentials: SecretsConfig { diff --git a/crates/nono-cli/src/profile_runtime.rs b/crates/nono-cli/src/profile_runtime.rs index c10c726e2..acdce4794 100644 --- a/crates/nono-cli/src/profile_runtime.rs +++ b/crates/nono-cli/src/profile_runtime.rs @@ -16,6 +16,7 @@ pub(crate) struct PreparedProfile { pub(crate) rollback_exclude_globs: Vec, pub(crate) network_profile: Option, pub(crate) allow_domain: Vec, + pub(crate) reject_domain: Vec, pub(crate) credentials: Vec, pub(crate) custom_credentials: HashMap, pub(crate) upstream_proxy: Option, @@ -30,6 +31,8 @@ pub(crate) struct PreparedProfile { pub(crate) ignored_denial_paths: Vec, pub(crate) allowed_env_vars: Option>, pub(crate) denied_env_vars: Option>, + pub(crate) network_approval_mode: Option, + pub(crate) network_approval_timeout_secs: Option, } #[derive(Clone, Copy)] @@ -517,6 +520,10 @@ fn prepare_profile_with_options( .as_ref() .map(|profile| profile.network.allow_domain.clone()) .unwrap_or_default(), + reject_domain: loaded_profile + .as_ref() + .map(|profile| profile.network.reject_domain.clone()) + .unwrap_or_default(), credentials: loaded_profile .as_ref() .and_then(|profile| profile.network.credentials.clone()) @@ -593,6 +600,12 @@ fn prepare_profile_with_options( Some(env_config.deny_vars.clone()) }) }), + network_approval_mode: loaded_profile + .as_ref() + .and_then(|profile| profile.network.approval_mode.clone()), + network_approval_timeout_secs: loaded_profile + .as_ref() + .and_then(|profile| profile.network.approval_timeout_secs), loaded_profile, }) } diff --git a/crates/nono-cli/src/proxy_runtime.rs b/crates/nono-cli/src/proxy_runtime.rs index 4e8b18dd4..39af4ec71 100644 --- a/crates/nono-cli/src/proxy_runtime.rs +++ b/crates/nono-cli/src/proxy_runtime.rs @@ -1,22 +1,27 @@ +use std::time::Duration; + use crate::cli::SandboxArgs; use crate::launch_runtime::ProxyLaunchOptions; +use crate::network_approval::{NetworkApprovalBackend, NetworkApprovalMode}; use crate::network_policy; use crate::sandbox_prepare::{PreparedSandbox, validate_external_proxy_bypass}; #[cfg(not(target_os = "macos"))] use nono::AccessMode; -use nono::{CapabilitySet, NonoError, Result}; +use nono::{CapabilitySet, HostFilter, NonoError, Result, RuntimeHostFilter}; use std::path::{Path, PathBuf}; use tracing::{debug, info, warn}; pub(crate) struct ActiveProxyRuntime { pub(crate) env_vars: Vec<(String, String)>, pub(crate) handle: Option, + pub(crate) approval_backend: Option>, } #[derive(Debug, PartialEq, Eq)] pub(crate) struct EffectiveProxySettings { pub(crate) network_profile: Option, pub(crate) allow_domain: Vec, + pub(crate) reject_domain: Vec, pub(crate) credentials: Vec, } @@ -30,6 +35,7 @@ pub(crate) fn prepare_proxy_launch_options( let effective_proxy = resolve_effective_proxy_settings(args, prepared); let network_profile = effective_proxy.network_profile; let allow_domain = effective_proxy.allow_domain; + let reject_domain = effective_proxy.reject_domain; let credentials = effective_proxy.credentials; let allow_bind_ports = merge_dedup_ports(&prepared.listen_ports, &args.allow_bind); @@ -79,10 +85,14 @@ pub(crate) fn prepare_proxy_launch_options( || upstream_proxy.is_some() }; + let approval_mode = + resolve_network_approval_mode(args, prepared.profile_network_approval_mode.as_deref()); + Ok(ProxyLaunchOptions { active, network_profile, allow_domain, + reject_domain, credentials, custom_credentials: prepared.custom_credentials.clone(), upstream_proxy, @@ -97,6 +107,10 @@ pub(crate) fn prepare_proxy_launch_options( proxy_ca_validity: args .proxy_ca_validity .map(|days| std::time::Duration::from_secs(u64::from(days) * 24 * 60 * 60)), + network_approval_mode: approval_mode, + network_approval_timeout_secs: resolve_approval_timeout_secs( + prepared.profile_network_approval_timeout_secs, + ), }) } @@ -108,6 +122,7 @@ pub(crate) fn resolve_effective_proxy_settings( return EffectiveProxySettings { network_profile: None, allow_domain: Vec::new(), + reject_domain: Vec::new(), credentials: Vec::new(), }; } @@ -118,12 +133,14 @@ pub(crate) fn resolve_effective_proxy_settings( .or_else(|| prepared.network_profile.clone()); let mut allow_domain = prepared.allow_domain.clone(); allow_domain.extend(args.allow_proxy.iter().map(|s| parse_allow_domain_arg(s))); + let reject_domain = prepared.reject_domain.clone(); let mut credentials = prepared.credentials.clone(); credentials.extend(args.proxy_credential.clone()); EffectiveProxySettings { network_profile, allow_domain, + reject_domain, credentials, } } @@ -162,6 +179,60 @@ pub(crate) fn merge_dedup_ports(a: &[u16], b: &[u16]) -> Vec { ports } +fn resolve_network_approval_mode( + args: &SandboxArgs, + profile_approval_mode: Option<&str>, +) -> NetworkApprovalMode { + use crate::cli::NetworkApprovalArg; + + if let Some(ref mode) = args.network_approval { + match mode { + NetworkApprovalArg::Ask => NetworkApprovalMode::Ask, + } + } else if let Ok(val) = std::env::var("NONO_NETWORK_APPROVAL") { + match val.to_lowercase().as_str() { + "ask" => NetworkApprovalMode::Ask, + _ => NetworkApprovalMode::Off, + } + } else if let Some(mode) = profile_approval_mode { + match mode.to_lowercase().as_str() { + "ask" => NetworkApprovalMode::Ask, + _ => NetworkApprovalMode::Off, + } + } else if let Ok(Some(config)) = crate::config::user::load_user_config() { + match config + .network + .approval_mode + .as_deref() + .unwrap_or("off") + .to_lowercase() + .as_str() + { + "ask" => NetworkApprovalMode::Ask, + _ => NetworkApprovalMode::Off, + } + } else { + NetworkApprovalMode::Off + } +} + +fn resolve_approval_timeout_secs(profile_timeout: Option) -> u64 { + if let Ok(val) = std::env::var("NONO_NETWORK_APPROVAL_TIMEOUT") { + if let Ok(secs) = val.parse::() { + return secs.clamp(5, 300); + } + } + if let Some(secs) = profile_timeout { + return secs.clamp(5, 300); + } + if let Ok(Some(config)) = crate::config::user::load_user_config() { + if let Some(secs) = config.network.approval_timeout_secs { + return secs.clamp(5, 300); + } + } + 60 +} + pub(crate) fn build_proxy_config_from_flags( proxy: &ProxyLaunchOptions, ) -> Result { @@ -207,7 +278,10 @@ pub(crate) fn build_proxy_config_from_flags( routes.extend(endpoint_routes); resolved.routes = routes; - let mut proxy_config = network_policy::build_proxy_config(&resolved, &plain_hosts); + let expanded_allow_domain = + network_policy::expand_proxy_allow(&net_policy, &proxy.allow_domain.iter().map(|e| e.domain().to_string()).collect::>()); + let mut proxy_config = + network_policy::build_proxy_config(&resolved, &expanded_allow_domain, &proxy.reject_domain); if let Some(ref addr) = proxy.upstream_proxy { proxy_config.external_proxy = Some(nono_proxy::config::ExternalProxyConfig { @@ -234,6 +308,7 @@ pub(crate) fn start_proxy_runtime( return Ok(ActiveProxyRuntime { env_vars: Vec::new(), handle: None, + approval_backend: None, }); } @@ -267,8 +342,56 @@ pub(crate) fn start_proxy_runtime( .enable_all() .build() .map_err(|e| NonoError::SandboxInit(format!("Failed to start proxy runtime: {}", e)))?; + + let (approval_backend, approval_tx, runtime_filter) = match proxy.network_approval_mode { + NetworkApprovalMode::Off => (None, None, None), + NetworkApprovalMode::Ask => { + let host_filter = HostFilter::deny_all(); + let runtime_filter = RuntimeHostFilter::new(host_filter); + let proxy_runtime_filter = + nono_proxy::filter::RuntimeProxyFilter::new(runtime_filter.clone()); + + let (tx, mut rx) = tokio::sync::mpsc::channel::(16); + + let config_writer = proxy + .network_profile + .as_deref() + .map(crate::network_approval::ConfigWriter::new); + + let backend = NetworkApprovalBackend::new( + proxy.network_approval_mode, + runtime_filter, + proxy.network_approval_timeout_secs, + config_writer, + ); + let backend_arc = std::sync::Arc::new(backend); + + let backend_clone = std::sync::Arc::clone(&backend_arc); + rt.spawn(async move { + while let Some(req) = rx.recv().await { + let decision = backend_clone + .request_network_approval_async(&req.request) + .await; + let _ = req.response_tx.send(decision); + } + }); + + (Some(backend_arc), Some(tx), Some(proxy_runtime_filter)) + } + }; + let handle = rt - .block_on(async { nono_proxy::server::start(proxy_config.clone()).await }) + .block_on(async { + nono_proxy::server::start_with_approval( + proxy_config.clone(), + runtime_filter, + approval_tx, + std::process::id(), + &format!("nono-{}", std::process::id()), + Duration::from_secs(proxy.network_approval_timeout_secs), + ) + .await + }) .map_err(|e| NonoError::SandboxInit(format!("Failed to start proxy: {}", e)))?; let port = handle.port; @@ -363,6 +486,7 @@ pub(crate) fn start_proxy_runtime( Ok(ActiveProxyRuntime { env_vars, handle: Some(handle), + approval_backend, }) } diff --git a/crates/nono-cli/src/sandbox_prepare.rs b/crates/nono-cli/src/sandbox_prepare.rs index 21ac72e8b..d86b9e281 100644 --- a/crates/nono-cli/src/sandbox_prepare.rs +++ b/crates/nono-cli/src/sandbox_prepare.rs @@ -422,6 +422,7 @@ pub(crate) struct PreparedSandbox { pub(crate) rollback_exclude_globs: Vec, pub(crate) network_profile: Option, pub(crate) allow_domain: Vec, + pub(crate) reject_domain: Vec, pub(crate) credentials: Vec, pub(crate) custom_credentials: HashMap, pub(crate) upstream_proxy: Option, @@ -440,6 +441,8 @@ pub(crate) struct PreparedSandbox { pub(crate) ignored_denial_paths: Vec, pub(crate) allowed_env_vars: Option>, pub(crate) denied_env_vars: Option>, + pub(crate) profile_network_approval_mode: Option, + pub(crate) profile_network_approval_timeout_secs: Option, } fn resolved_workdir(args: &SandboxArgs) -> PathBuf { @@ -1050,6 +1053,7 @@ pub(crate) fn prepare_sandbox(args: &SandboxArgs, silent: bool) -> Result Result Result Result Result Result { pub(crate) audit_signer: Option<&'a AuditSigner>, pub(crate) redaction_policy: &'a nono::ScrubPolicy, pub(crate) silent: bool, + pub(crate) approval_backend: Option<&'a dyn ApprovalBackend>, } fn build_supervisor_session_id(audit_state: Option<&AuditState>) -> String { @@ -164,6 +165,7 @@ pub(crate) fn execute_supervised_runtime(ctx: SupervisedRuntimeContext<'_>) -> R audit_signer, redaction_policy, silent, + approval_backend, } = ctx; output::print_applying_sandbox(silent); @@ -171,12 +173,6 @@ pub(crate) fn execute_supervised_runtime(ctx: SupervisedRuntimeContext<'_>) -> R let audit_state = create_audit_state(rollback.audit_disabled, rollback.destination.as_ref())?; warn_if_rollback_flags_ignored(rollback, silent); - // Create the session guard (writes session file) and PTY pair BEFORE - // rollback initialization. Rollback's baseline snapshot can take many - // seconds on large repos. In detached mode the launcher is polling for - // the session file and attach socket — if we delay session registration - // until after the baseline walk, the 30-second startup timeout can fire - // before the session becomes attachable. let trust_interceptor = create_trust_interceptor(trust); let session_runtime = create_session_runtime_state( command, @@ -224,11 +220,12 @@ pub(crate) fn execute_supervised_runtime(ctx: SupervisedRuntimeContext<'_>) -> R } let protected_roots = protected_paths::ProtectedRoots::from_defaults()?; - let approval_backend = terminal_approval::TerminalApproval; + let terminal_fallback = terminal_approval::TerminalApproval; + let backend: &dyn ApprovalBackend = approval_backend.unwrap_or(&terminal_fallback); let supervisor_session_id = build_supervisor_session_id(audit_state.as_ref()); let supervisor_cfg = exec_strategy::SupervisorConfig { protected_roots: protected_roots.as_paths(), - approval_backend: &approval_backend, + approval_backend: backend, session_id: &supervisor_session_id, attach_initial_client: !session.detached_start, detach_sequence: session.detach_sequence.as_deref(), diff --git a/crates/nono-proxy/Cargo.toml b/crates/nono-proxy/Cargo.toml index dedc3c087..1717b8e3c 100644 --- a/crates/nono-proxy/Cargo.toml +++ b/crates/nono-proxy/Cargo.toml @@ -19,7 +19,7 @@ system-keyring = ["nono/system-keyring"] [dependencies] nono = { version = "0.60.0", path = "../nono", default-features = false } -tokio.workspace = true +tokio = { workspace = true, features = ["sync", "time"] } hyper.workspace = true hyper-util.workspace = true http-body-util.workspace = true diff --git a/crates/nono-proxy/src/config.rs b/crates/nono-proxy/src/config.rs index daad2de73..d2e9bcc32 100644 --- a/crates/nono-proxy/src/config.rs +++ b/crates/nono-proxy/src/config.rs @@ -40,6 +40,12 @@ pub struct ProxyConfig { #[serde(default)] pub allowed_hosts: Vec, + /// Rejected hosts for CONNECT mode (exact match + wildcards). + /// Takes priority over `allowed_hosts`. Supports wildcards + /// (`*.evil.com`). + #[serde(default)] + pub rejected_hosts: Vec, + /// Reverse proxy credential routes. #[serde(default)] pub routes: Vec, @@ -144,6 +150,7 @@ impl Default for ProxyConfig { bind_addr: default_bind_addr(), bind_port: 0, allowed_hosts: Vec::new(), + rejected_hosts: Vec::new(), routes: Vec::new(), external_proxy: None, direct_connect_ports: Vec::new(), diff --git a/crates/nono-proxy/src/connect.rs b/crates/nono-proxy/src/connect.rs index 6b3a19ec6..af3c410ed 100644 --- a/crates/nono-proxy/src/connect.rs +++ b/crates/nono-proxy/src/connect.rs @@ -12,18 +12,326 @@ use crate::audit; use crate::error::{ProxyError, Result}; -use crate::filter::ProxyFilter; +use crate::filter::{ProxyFilter, RuntimeProxyFilter}; use crate::token; +use nono::{ApprovalScope, NetworkApprovalDecision, NetworkApprovalRequest}; use std::net::SocketAddr; use std::time::Duration; use tokio::io::{AsyncWrite, AsyncWriteExt}; use tokio::net::TcpStream; +use tokio::sync::oneshot; use tracing::debug; use zeroize::Zeroizing; /// Timeout for upstream TCP connect. const UPSTREAM_CONNECT_TIMEOUT: Duration = Duration::from_secs(30); +/// A request sent from the proxy to the approval backend. +pub struct ApprovalChannelRequest { + /// The network approval request data. + pub request: NetworkApprovalRequest, + /// One-shot channel to send the decision back. + pub response_tx: oneshot::Sender, +} + +/// Context for approval-enabled CONNECT handling. +pub struct ApprovalContext<'a> { + /// Original (immutable) proxy filter — checked first for allowlisted hosts, + /// route-matched hosts, credential hosts, etc. + pub primary_filter: &'a ProxyFilter, + /// Runtime-mutable proxy filter — updated when the user approves a host + pub runtime_filter: &'a RuntimeProxyFilter, + /// Session token for proxy auth + pub session_token: &'a Zeroizing, + /// Shared audit log + pub audit_log: Option<&'a audit::SharedAuditLog>, + /// Channel to send approval requests to the backend + pub approval_tx: &'a tokio::sync::mpsc::Sender, + /// PID of the sandboxed child process + pub child_pid: u32, + /// Session ID for correlating approval requests + pub session_id: &'a str, + /// Timeout for waiting on a network approval response + pub approval_timeout: Duration, +} + +/// Handle an HTTP CONNECT request with approval fallback. +/// +/// Checks the primary (immutable) filter first. If the host is allowed, +/// proceeds normally. If denied AND an approval channel is available, +/// sends an approval request and awaits the user's decision. If approved, +/// the host is added to the runtime filter and the request proceeds. +pub async fn handle_connect_with_approval( + first_line: &str, + stream: &mut TcpStream, + remaining_header: &[u8], + ctx: &ApprovalContext<'_>, +) -> Result<()> { + let (host, port) = parse_connect_target(first_line)?; + debug!("CONNECT request to {}:{}", host, port); + + if let Err(e) = validate_proxy_auth(remaining_header, ctx.session_token) { + debug!("CONNECT auth skipped: {}", e); + } + + let primary_check = ctx.primary_filter.check_host(&host, port).await?; + debug!( + "CONNECT {}:{} primary_filter result: {:?}", + host, port, primary_check.result + ); + if primary_check.result.is_allowed() { + let resolved = &primary_check.resolved_addrs; + if resolved.is_empty() { + let reason = "DNS resolution returned no addresses".to_string(); + audit::log_denied( + ctx.audit_log, + audit::ProxyMode::Connect, + &host, + port, + &reason, + ); + send_response(stream, 502, "DNS resolution failed").await?; + return Err(ProxyError::UpstreamConnect { host, reason }); + } + + let mut upstream = connect_to_resolved(resolved, &host).await?; + send_response(stream, 200, "Connection Established").await?; + audit::log_allowed( + ctx.audit_log, + audit::ProxyMode::Connect, + &host, + port, + "CONNECT", + ); + let result = tokio::io::copy_bidirectional(stream, &mut upstream).await; + debug!("CONNECT tunnel closed for {}:{}: {:?}", host, port, result); + return Ok(()); + } + + if matches!( + primary_check.result, + nono::net_filter::FilterResult::DenyHost { .. } + ) { + let reason = primary_check.result.reason(); + debug!( + "CONNECT {}:{} explicitly denied by primary filter: {}", + host, port, reason + ); + audit::log_denied( + ctx.audit_log, + audit::ProxyMode::Connect, + &host, + port, + &reason, + ); + send_response(stream, 403, &format!("Host denied: {reason}")).await?; + return Ok(()); + } + + let runtime_check = ctx.runtime_filter.check_host(&host, port).await?; + debug!( + "CONNECT {}:{} runtime_filter result: {:?}", + host, port, runtime_check.result + ); + if runtime_check.result.is_allowed() { + let resolved = &runtime_check.resolved_addrs; + if resolved.is_empty() { + let reason = "DNS resolution returned no addresses".to_string(); + audit::log_denied( + ctx.audit_log, + audit::ProxyMode::Connect, + &host, + port, + &reason, + ); + send_response(stream, 502, "DNS resolution failed").await?; + return Err(ProxyError::UpstreamConnect { host, reason }); + } + + let mut upstream = connect_to_resolved(resolved, &host).await?; + send_response(stream, 200, "Connection Established").await?; + audit::log_allowed( + ctx.audit_log, + audit::ProxyMode::Connect, + &host, + port, + "CONNECT (runtime)", + ); + let result = tokio::io::copy_bidirectional(stream, &mut upstream).await; + debug!("CONNECT tunnel closed for {}:{}: {:?}", host, port, result); + return Ok(()); + } + + if matches!( + runtime_check.result, + nono::net_filter::FilterResult::DenyHost { .. } + ) { + let reason = runtime_check.result.reason(); + debug!( + "CONNECT {}:{} explicitly denied by runtime filter: {}", + host, port, reason + ); + audit::log_denied( + ctx.audit_log, + audit::ProxyMode::Connect, + &host, + port, + &reason, + ); + send_response(stream, 403, &format!("Host denied: {reason}")).await?; + return Ok(()); + } + + let request_id = generate_request_id(); + let request = NetworkApprovalRequest { + request_id, + host: host.clone(), + port: Some(port), + reason: Some("Blocked by host filter".to_string()), + child_pid: ctx.child_pid, + session_id: ctx.session_id.to_string(), + }; + + let (response_tx, response_rx) = oneshot::channel(); + let approval_req = ApprovalChannelRequest { + request, + response_tx, + }; + + if ctx.approval_tx.send(approval_req).await.is_err() { + let reason = "Approval channel closed"; + audit::log_denied( + ctx.audit_log, + audit::ProxyMode::Connect, + &host, + port, + reason, + ); + send_response(stream, 403, &format!("Forbidden: {}", reason)).await?; + return Err(ProxyError::HostDenied { + host, + reason: reason.to_string(), + }); + } + + let decision = match tokio::time::timeout(ctx.approval_timeout, response_rx).await { + Ok(Ok(d)) => d, + Ok(Err(_)) => { + let reason = "Approval response channel dropped"; + audit::log_denied( + ctx.audit_log, + audit::ProxyMode::Connect, + &host, + port, + reason, + ); + send_response(stream, 403, &format!("Forbidden: {}", reason)).await?; + return Err(ProxyError::HostDenied { + host, + reason: reason.to_string(), + }); + } + Err(_) => NetworkApprovalDecision::Timeout, + }; + + match decision { + NetworkApprovalDecision::Granted(scope) => { + debug!( + "Host {} approved ({})", + host, + match scope { + ApprovalScope::Once => "once", + ApprovalScope::Session => "session", + ApprovalScope::Persistent => "persistent", + } + ); + + let resolved = if scope == ApprovalScope::Once { + let addrs = ctx.runtime_filter.resolve_host(&host, port).await?; + if addrs.is_empty() { + let reason = "Could not resolve host after approval"; + audit::log_denied( + ctx.audit_log, + audit::ProxyMode::Connect, + &host, + port, + reason, + ); + send_response(stream, 403, &format!("Forbidden: {}", reason)).await?; + return Err(ProxyError::HostDenied { + host, + reason: reason.to_string(), + }); + } + addrs + } else { + let runtime_check = ctx.runtime_filter.check_host(&host, port).await?; + let resolved = &runtime_check.resolved_addrs; + if !runtime_check.result.is_allowed() || resolved.is_empty() { + let reason = "Host still denied after approval"; + audit::log_denied( + ctx.audit_log, + audit::ProxyMode::Connect, + &host, + port, + reason, + ); + send_response(stream, 403, &format!("Forbidden: {}", reason)).await?; + return Err(ProxyError::HostDenied { + host, + reason: reason.to_string(), + }); + } + runtime_check.resolved_addrs + }; + + let mut upstream = connect_to_resolved(&resolved, &host).await?; + send_response(stream, 200, "Connection Established").await?; + audit::log_allowed( + ctx.audit_log, + audit::ProxyMode::Connect, + &host, + port, + "CONNECT (approved)", + ); + let result = tokio::io::copy_bidirectional(stream, &mut upstream).await; + debug!("CONNECT tunnel closed for {}:{}: {:?}", host, port, result); + Ok(()) + } + NetworkApprovalDecision::Denied { reason: _ } | NetworkApprovalDecision::Timeout => { + let display_reason = match &decision { + NetworkApprovalDecision::Timeout => "Approval timed out", + NetworkApprovalDecision::Denied { reason } => reason, + _ => "unreachable", + }; + audit::log_denied( + ctx.audit_log, + audit::ProxyMode::Connect, + &host, + port, + display_reason, + ); + send_response(stream, 403, &format!("Forbidden: {}", display_reason)).await?; + Err(ProxyError::HostDenied { + host, + reason: display_reason.to_string(), + }) + } + } +} + +fn generate_request_id() -> String { + use std::fmt::Write; + let mut buf = [0u8; 16]; + getrandom::fill(&mut buf).unwrap_or_else(|_| { + buf = [0; 16]; + }); + buf.iter().fold(String::with_capacity(32), |mut s, b| { + write!(s, "{b:02x}").unwrap_or_default(); + s + }) +} + /// Handle an HTTP CONNECT request. /// /// `first_line` is the already-read CONNECT line (e.g., "CONNECT api.openai.com:443 HTTP/1.1"). diff --git a/crates/nono-proxy/src/filter.rs b/crates/nono-proxy/src/filter.rs index 82203ba76..3bc4233f7 100644 --- a/crates/nono-proxy/src/filter.rs +++ b/crates/nono-proxy/src/filter.rs @@ -6,9 +6,12 @@ use crate::error::Result; use nono::net_filter::{FilterResult, HostFilter}; +use nono::RuntimeHostFilter; use std::net::{IpAddr, SocketAddr}; use tracing::debug; +pub const NO_IPS: &[IpAddr] = &[]; + /// Result of a filter check including resolved socket addresses. /// /// When the filter allows a host, `resolved_addrs` contains the DNS-resolved @@ -36,6 +39,14 @@ impl ProxyFilter { } } + /// Create a new proxy filter with both allowed and rejected hosts. + #[must_use] + pub fn new_with_reject(allowed_hosts: &[String], rejected_hosts: &[String]) -> Self { + Self { + inner: HostFilter::new_with_reject(allowed_hosts, rejected_hosts), + } + } + /// Create a filter that allows all hosts (except cloud metadata). #[must_use] pub fn allow_all() -> Self { @@ -96,6 +107,104 @@ impl ProxyFilter { } } +/// Async wrapper around [`RuntimeHostFilter`] for runtime-mutable filtering. +/// +/// Like [`ProxyFilter`] but backed by a [`RuntimeHostFilter`] that can be +/// extended at runtime (e.g., when the user approves a new host via +/// an OS notification). DNS resolution is still performed on each check. +#[derive(Debug, Clone)] +pub struct RuntimeProxyFilter { + inner: RuntimeHostFilter, +} + +impl RuntimeProxyFilter { + /// Create a new runtime proxy filter from a [`RuntimeHostFilter`]. + #[must_use] + pub fn new(inner: RuntimeHostFilter) -> Self { + Self { inner } + } + + /// Check a host against the filter with async DNS resolution. + pub async fn check_host(&self, host: &str, port: u16) -> Result { + let addr_str = format!("{}:{}", host, port); + let resolved: Vec = match tokio::net::lookup_host(&addr_str).await { + Ok(addrs) => addrs.collect(), + Err(e) => { + debug!("DNS resolution failed for {}: {}", host, e); + Vec::new() + } + }; + + let resolved_ips: Vec = resolved.iter().map(|a| a.ip()).collect(); + let result = self.inner.check_host(host, &resolved_ips); + + let addrs = if result.is_allowed() { + resolved + } else { + Vec::new() + }; + + Ok(CheckResult { + result, + resolved_addrs: addrs, + }) + } + + /// Resolve a hostname to socket addresses via DNS without filter check. + /// + /// Used for "once" approvals where the host is not in the runtime filter + /// but we still need resolved addresses to connect. + pub async fn resolve_host(&self, host: &str, port: u16) -> Result> { + let addr_str = format!("{}:{}", host, port); + let result = tokio::net::lookup_host(&addr_str).await; + match result { + Ok(addrs) => Ok(addrs.collect()), + Err(e) => { + debug!("DNS resolution failed for {}: {}", host, e); + Ok(Vec::new()) + } + } + } + + /// Check a host with pre-resolved IPs (no DNS lookup). + #[must_use] + pub fn check_host_with_ips(&self, host: &str, resolved_ips: &[IpAddr]) -> FilterResult { + self.inner.check_host(host, resolved_ips) + } + + /// Add a host to the runtime allowlist. + pub fn add_host(&self, host: &str) -> nono::Result<()> { + self.inner.add_host(host) + } + + /// Add a wildcard suffix to the runtime allowlist. + pub fn add_suffix(&self, suffix: &str) -> nono::Result<()> { + self.inner.add_suffix(suffix) + } + + /// Add a host to the runtime deny list. + /// + /// Once denied, the host cannot pass the filter regardless of the + /// allowlist. Used for "always deny" decisions. + pub fn add_deny_host(&self, host: &str) -> nono::Result<()> { + self.inner.add_deny_host(host) + } + + /// Add a wildcard suffix to the runtime deny list. + /// + /// Once denied, any subdomain matching the suffix cannot pass the + /// filter regardless of the allowlist. + pub fn add_deny_suffix(&self, suffix: &str) -> nono::Result<()> { + self.inner.add_deny_suffix(suffix) + } + + /// Get a reference to the underlying [`RuntimeHostFilter`]. + #[must_use] + pub fn inner(&self) -> &RuntimeHostFilter { + &self.inner + } +} + #[cfg(test)] #[allow(clippy::unwrap_used)] mod tests { @@ -137,4 +246,84 @@ mod tests { let result = filter.check_host_with_ips("evil.com", &link_local); assert!(!result.is_allowed()); } + + #[test] + fn test_proxy_filter_rejected_hosts_deny_even_if_allow_all() { + let filter = ProxyFilter::new_with_reject(&[], &["evil.com".to_string()]); + let public_ip = vec![IpAddr::V4(Ipv4Addr::new(1, 2, 3, 4))]; + let result = filter.check_host_with_ips("evil.com", &public_ip); + assert!(matches!(result, FilterResult::DenyHost { .. })); + } + + #[test] + fn test_proxy_filter_rejected_hosts_take_priority_over_allowed() { + let filter = ProxyFilter::new_with_reject( + &["api.openai.com".to_string(), "evil.com".to_string()], + &["evil.com".to_string()], + ); + let public_ip = vec![IpAddr::V4(Ipv4Addr::new(1, 2, 3, 4))]; + let result = filter.check_host_with_ips("evil.com", &public_ip); + assert!(matches!(result, FilterResult::DenyHost { .. })); + + let result = filter.check_host_with_ips("api.openai.com", &public_ip); + assert!(result.is_allowed()); + } + + #[test] + fn test_proxy_filter_rejected_hosts_allow_non_blacklisted() { + let filter = ProxyFilter::new_with_reject(&[], &["evil.com".to_string()]); + let public_ip = vec![IpAddr::V4(Ipv4Addr::new(1, 2, 3, 4))]; + let result = filter.check_host_with_ips("safe.com", &public_ip); + assert!(result.is_allowed()); + } + + #[test] + fn test_runtime_proxy_filter_add_deny_host_overrides_allow() { + let inner = RuntimeHostFilter::new(HostFilter::new(&[ + "api.openai.com".to_string(), + "evil.com".to_string(), + ])); + let filter = RuntimeProxyFilter::new(inner); + let public_ip = vec![IpAddr::V4(Ipv4Addr::new(1, 2, 3, 4))]; + + let result = filter.check_host_with_ips("evil.com", &public_ip); + assert!(result.is_allowed()); + + filter.add_deny_host("evil.com").unwrap(); + + let result = filter.check_host_with_ips("evil.com", &public_ip); + assert!(matches!(result, FilterResult::DenyHost { .. })); + + let result = filter.check_host_with_ips("api.openai.com", &public_ip); + assert!(result.is_allowed()); + } + + #[test] + fn test_runtime_proxy_filter_deny_suffix_blocks_subdomains() { + let inner = RuntimeHostFilter::new(HostFilter::allow_all()); + let filter = RuntimeProxyFilter::new(inner); + let public_ip = vec![IpAddr::V4(Ipv4Addr::new(1, 2, 3, 4))]; + + filter.add_deny_suffix(".cloud2.influxdata.com").unwrap(); + + let result = + filter.check_host_with_ips("eu-central-1-1.aws.cloud2.influxdata.com", &public_ip); + assert!(matches!(result, FilterResult::DenyHost { .. })); + + let result = filter.check_host_with_ips("influxdata.com", &public_ip); + assert!(result.is_allowed()); + } + + #[test] + fn test_runtime_proxy_filter_once_scope_host_not_in_filter() { + let inner = RuntimeHostFilter::new(HostFilter::new(&["allowed.com".to_string()])); + let filter = RuntimeProxyFilter::new(inner); + let public_ip = vec![IpAddr::V4(Ipv4Addr::new(1, 2, 3, 4))]; + + let result = filter.check_host_with_ips("once-approved.com", &public_ip); + assert!(!result.is_allowed()); + + let result = filter.check_host_with_ips("allowed.com", &public_ip); + assert!(result.is_allowed()); + } } diff --git a/crates/nono-proxy/src/lib.rs b/crates/nono-proxy/src/lib.rs index acb41dd9e..bf0d8192b 100644 --- a/crates/nono-proxy/src/lib.rs +++ b/crates/nono-proxy/src/lib.rs @@ -33,5 +33,6 @@ pub mod tls_intercept; pub mod token; pub use config::ProxyConfig; +pub use connect::ApprovalChannelRequest; pub use error::{ProxyError, Result}; -pub use server::{ProxyHandle, start}; +pub use server::{start, start_with_approval, ProxyHandle}; diff --git a/crates/nono-proxy/src/server.rs b/crates/nono-proxy/src/server.rs index 44e9e32d5..7accb52d2 100644 --- a/crates/nono-proxy/src/server.rs +++ b/crates/nono-proxy/src/server.rs @@ -13,7 +13,7 @@ use crate::connect; use crate::credential::CredentialStore; use crate::error::{ProxyError, Result}; use crate::external; -use crate::filter::ProxyFilter; +use crate::filter::{ProxyFilter, RuntimeProxyFilter}; use crate::reverse; use crate::route::RouteStore; use crate::tls_intercept::{self, CertCache, EphemeralCa}; @@ -22,6 +22,7 @@ use std::net::SocketAddr; use std::path::PathBuf; use std::sync::Arc; use std::sync::atomic::{AtomicUsize, Ordering}; +use std::time::Duration; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::net::TcpListener; use tokio::sync::watch; @@ -303,6 +304,9 @@ impl Drop for ProxyHandle { /// Shared state for the proxy server. struct ProxyState { filter: ProxyFilter, + runtime_filter: Option, + approval_tx: Option>, + approval_timeout: Duration, session_token: Zeroizing, /// Route-level configuration (upstream, L7 filtering, custom TLS CA) for all routes. route_store: RouteStore, @@ -324,6 +328,10 @@ struct ProxyState { /// CONNECT branch (CONNECTs fall through to the existing 403/tunnel /// dispatch even for routes that would otherwise require L7). cert_cache: Option>, + /// PID of the sandboxed child process (for approval requests). + child_pid: u32, + /// Session ID for correlating approval requests. + session_id: String, } /// Start the proxy server. @@ -334,6 +342,22 @@ struct ProxyState { /// Returns a `ProxyHandle` with the assigned port and session token. /// The server runs until the handle is dropped or `shutdown()` is called. pub async fn start(config: ProxyConfig) -> Result { + start_with_approval(config, None, None, 0, "", Duration::from_secs(60)).await +} + +/// Start the proxy server with optional network approval support. +/// +/// When `runtime_filter` and `approval_tx` are provided, the proxy +/// will request user approval for blocked hosts instead of immediately +/// returning 403. +pub async fn start_with_approval( + config: ProxyConfig, + runtime_filter: Option, + approval_tx: Option>, + child_pid: u32, + session_id: &str, + approval_timeout: Duration, +) -> Result { // Generate session token let session_token = token::generate_session_token()?; @@ -361,6 +385,7 @@ pub async fn start(config: ProxyConfig) -> Result { } else { RouteStore::load(&config.routes)? }; + // Build shared TLS connector (root cert store is expensive to construct). // Use the ring provider explicitly to avoid ambiguity when multiple // crypto providers are in the dependency tree. @@ -402,10 +427,10 @@ pub async fn start(config: ProxyConfig) -> Result { let loaded_routes = credential_store.loaded_prefixes(); // Build filter - let filter = if config.allowed_hosts.is_empty() { + let filter = if config.allowed_hosts.is_empty() && config.rejected_hosts.is_empty() { ProxyFilter::allow_all() } else { - ProxyFilter::new(&config.allowed_hosts) + ProxyFilter::new_with_reject(&config.allowed_hosts, &config.rejected_hosts) }; // Build bypass matcher from external proxy config (once, not per-request) @@ -537,6 +562,9 @@ pub async fn start(config: ProxyConfig) -> Result { let state = Arc::new(ProxyState { filter, + runtime_filter, + approval_tx, + approval_timeout, session_token: session_token.clone(), route_store, credential_store, @@ -546,6 +574,8 @@ pub async fn start(config: ProxyConfig) -> Result { audit_log: Arc::clone(&audit_log), bypass_matcher, cert_cache, + child_pid, + session_id: session_id.to_string(), }); // Spawn accept loop as a task within the current runtime. @@ -823,18 +853,57 @@ async fn handle_connection(mut stream: tokio::net::TcpStream, state: &ProxyState ) .await } else if state.config.external_proxy.is_some() { - // Bypass route: enforce strict session token validation before - // routing direct. Without this, bypassed hosts would inherit - // connect::handle_connect()'s lenient auth (which tolerates - // missing Proxy-Authorization for Node.js undici compat). token::validate_proxy_auth(&header_bytes, &state.session_token)?; - connect::handle_connect( + + if let (Some(rf), Some(tx)) = + (state.runtime_filter.as_ref(), state.approval_tx.as_ref()) + { + let approval_ctx = connect::ApprovalContext { + primary_filter: &state.filter, + runtime_filter: rf, + session_token: &state.session_token, + audit_log: Some(&state.audit_log), + approval_tx: tx, + child_pid: state.child_pid, + session_id: &state.session_id, + approval_timeout: state.approval_timeout, + }; + connect::handle_connect_with_approval( + first_line, + &mut stream, + &header_bytes, + &approval_ctx, + ) + .await + } else { + connect::handle_connect( + first_line, + &mut stream, + &state.filter, + &state.session_token, + &header_bytes, + Some(&state.audit_log), + ) + .await + } + } else if let (Some(rf), Some(tx)) = + (state.runtime_filter.as_ref(), state.approval_tx.as_ref()) + { + let approval_ctx = connect::ApprovalContext { + primary_filter: &state.filter, + runtime_filter: rf, + session_token: &state.session_token, + audit_log: Some(&state.audit_log), + approval_tx: tx, + child_pid: state.child_pid, + session_id: &state.session_id, + approval_timeout: state.approval_timeout, + }; + connect::handle_connect_with_approval( first_line, &mut stream, - &state.filter, - &state.session_token, &header_bytes, - Some(&state.audit_log), + &approval_ctx, ) .await } else { diff --git a/crates/nono/src/error.rs b/crates/nono/src/error.rs index bbfa475de..eed78f98b 100644 --- a/crates/nono/src/error.rs +++ b/crates/nono/src/error.rs @@ -162,6 +162,9 @@ pub enum NonoError { #[error("Instruction file denied: {path}: {reason}")] InstructionFileDenied { path: String, reason: String }, + #[error("Invalid configuration: {reason}")] + InvalidConfig { reason: String }, + #[error("Package install error: {0}")] PackageInstall(String), diff --git a/crates/nono/src/lib.rs b/crates/nono/src/lib.rs index 3a99fa7f8..a1580fe7c 100644 --- a/crates/nono/src/lib.rs +++ b/crates/nono/src/lib.rs @@ -54,6 +54,7 @@ pub mod manifest_convert; pub mod net_filter; pub mod path; pub mod query; +pub mod runtime_filter; pub mod sandbox; pub mod scrub; pub mod state; @@ -80,6 +81,7 @@ pub use keystore::{ }; pub use net_filter::{FilterResult, HostFilter}; pub use path::try_canonicalize; +pub use runtime_filter::RuntimeHostFilter; #[cfg(target_os = "linux")] pub use sandbox::{DetectedAbi, LandlockScopePolicy, detect_abi, is_wsl2, landlock_scope_policy}; pub use sandbox::{Sandbox, SupportInfo}; @@ -89,8 +91,8 @@ pub use scrub::{ }; pub use state::SandboxState; pub use supervisor::{ - ApprovalBackend, ApprovalDecision, CapabilityRequest, SupervisorListener, SupervisorSocket, - UrlOpenRequest, + ApprovalBackend, ApprovalDecision, ApprovalScope, CapabilityRequest, NetworkApprovalDecision, + NetworkApprovalRequest, SupervisorSocket, UrlOpenRequest, }; pub use trust::{ Enforcement, IncludePatterns, Publisher, SignerIdentity, TrustPolicy, VerificationOutcome, diff --git a/crates/nono/src/net_filter.rs b/crates/nono/src/net_filter.rs index d0703ba0d..fe576ef7b 100644 --- a/crates/nono/src/net_filter.rs +++ b/crates/nono/src/net_filter.rs @@ -103,47 +103,78 @@ const DENY_HOSTS: &[&str] = &[ /// A filter for host-based network access control. /// -/// Supports exact domain match and wildcard subdomains (`*.googleapis.com`). +/// Supports exact domain match and wildcard subdomains (`*.googleapis.com`) +/// for both allow and deny lists. /// /// Cloud metadata endpoints are always denied and cannot be overridden. /// The allowlist determines which hosts are permitted; everything else -/// is denied by default. +/// is denied by default. The deny list (including user-specified +/// `reject_domain` entries) takes priority over the allowlist. #[derive(Debug, Clone)] pub struct HostFilter { /// Allowed exact hosts (lowercased) allowed_hosts: Vec, /// Allowed wildcard suffixes (e.g., ".googleapis.com", lowercased) allowed_suffixes: Vec, - /// Hostnames that are always denied + /// Hostnames that are always denied (cloud metadata + user-specified) deny_hosts: Vec, + /// Wildcard suffixes that are always denied (e.g., ".evil.com") + deny_suffixes: Vec, } impl HostFilter { - /// Create a new host filter with the given allowed hosts. - /// - /// Cloud metadata endpoints are automatically denied and cannot be removed. + /// Separate a list of domain patterns into exact hosts and wildcard suffixes. /// - /// Hosts starting with `*.` are treated as wildcard subdomain patterns. - /// All other entries are exact matches. Matching is case-insensitive. - #[must_use] - pub fn new(allowed_hosts: &[String]) -> Self { + /// Entries starting with `*.` are treated as wildcard subdomain patterns + /// (the `*` is stripped, producing `.example.com`). All other entries are + /// exact matches. All values are lowercased. + fn split_patterns(patterns: &[String]) -> (Vec, Vec) { let mut exact = Vec::new(); let mut suffixes = Vec::new(); - for host in allowed_hosts { - let lower = host.to_lowercase(); + for pattern in patterns { + let lower = pattern.to_lowercase(); if let Some(suffix) = lower.strip_prefix('*') { - // *.example.com -> .example.com suffixes.push(suffix.to_string()); } else { exact.push(lower); } } + (exact, suffixes) + } + + /// Create a new host filter with the given allowed hosts. + /// + /// Cloud metadata endpoints are automatically denied and cannot be removed. + /// + /// Hosts starting with `*.` are treated as wildcard subdomain patterns. + /// All other entries are exact matches. Matching is case-insensitive. + #[must_use] + pub fn new(allowed_hosts: &[String]) -> Self { + Self::new_with_reject(allowed_hosts, &[]) + } + + /// Create a new host filter with both allowed and rejected hosts. + /// + /// Like [`new()`](Self::new), but also accepts a list of rejected domain + /// patterns. Rejected hosts are always denied, even if they appear in the + /// allow list. Supports wildcards (`*.evil.com`) just like the allow list. + /// + /// Cloud metadata endpoints are automatically included in the deny list. + #[must_use] + pub fn new_with_reject(allowed_hosts: &[String], rejected_hosts: &[String]) -> Self { + let (exact_allowed, allowed_suffixes) = Self::split_patterns(allowed_hosts); + let (exact_denied, deny_suffixes) = Self::split_patterns(rejected_hosts); + + let mut deny_hosts: Vec = DENY_HOSTS.iter().map(|s| s.to_lowercase()).collect(); + deny_hosts.extend(exact_denied); + Self { - allowed_hosts: exact, - allowed_suffixes: suffixes, - deny_hosts: DENY_HOSTS.iter().map(|s| s.to_lowercase()).collect(), + allowed_hosts: exact_allowed, + allowed_suffixes, + deny_hosts, + deny_suffixes, } } @@ -156,6 +187,26 @@ impl HostFilter { allowed_hosts: Vec::new(), allowed_suffixes: Vec::new(), deny_hosts: DENY_HOSTS.iter().map(|s| s.to_lowercase()).collect(), + deny_suffixes: Vec::new(), + } + } + + /// Create a host filter that denies everything by default. + /// + /// Unlike [`allow_all()`](Self::allow_all), no host is permitted unless + /// explicitly added via [`add_host()`](Self::add_host) or + /// [`add_suffix()`](Self::add_suffix). Cloud metadata endpoints remain + /// unconditionally denied. + /// + /// This is used for runtime approval filters where the filter starts + /// empty and only approved hosts are added dynamically. + #[must_use] + pub fn deny_all() -> Self { + Self { + allowed_hosts: vec!["__deny_all_sentinel__.invalid".to_string()], + allowed_suffixes: Vec::new(), + deny_hosts: DENY_HOSTS.iter().map(|s| s.to_lowercase()).collect(), + deny_suffixes: Vec::new(), } } @@ -168,46 +219,56 @@ impl HostFilter { /// /// # Check Order /// - /// 1. Deny hosts (exact match against cloud metadata hostnames) - /// 2. Link-local IP check (resolved IPs in 169.254.0.0/16 or fe80::/10) - /// 3. Allowlist (exact host match, then wildcard subdomain match) - /// 4. Default deny (if not in allowlist and allowlist is non-empty) + /// 1. Deny hosts (exact match against deny list) + /// 2. Deny suffixes (wildcard match, e.g. `*.evil.com`) + /// 3. Link-local IP check (resolved IPs in 169.254.0.0/16 or fe80::/10) + /// 4. Allowlist (exact host match, then wildcard subdomain match) + /// 5. Default deny (if not in allowlist and allowlist is non-empty) #[must_use] pub fn check_host(&self, host: &str, resolved_ips: &[IpAddr]) -> FilterResult { let lower_host = host.to_lowercase(); - // 1. Check deny hosts + // 1. Check deny hosts (exact match) if self.deny_hosts.contains(&lower_host) { return FilterResult::DenyHost { host: host.to_string(), }; } - // 2. Check resolved IPs for link-local addresses (cloud metadata protection) + // 2. Check deny suffixes (wildcard match) + for suffix in &self.deny_suffixes { + if lower_host.ends_with(suffix.as_str()) && lower_host.len() > suffix.len() { + return FilterResult::DenyHost { + host: host.to_string(), + }; + } + } + + // 3. Check resolved IPs for link-local addresses (cloud metadata protection) for ip in resolved_ips { if is_link_local(ip) { return FilterResult::DenyLinkLocal { ip: *ip }; } } - // 3. If no allowlist is configured (allow_all mode), allow + // 4. If no allowlist is configured (allow_all mode), allow if self.allowed_hosts.is_empty() && self.allowed_suffixes.is_empty() { return FilterResult::Allow; } - // 4. Check exact host match + // 5. Check exact host match if self.allowed_hosts.contains(&lower_host) { return FilterResult::Allow; } - // 5. Check wildcard subdomain match + // 6. Check wildcard subdomain match for suffix in &self.allowed_suffixes { if lower_host.ends_with(suffix.as_str()) && lower_host.len() > suffix.len() { return FilterResult::Allow; } } - // 6. Not in allowlist + // 7. Not in allowlist FilterResult::DenyNotAllowed { host: host.to_string(), } @@ -220,6 +281,120 @@ impl HostFilter { .len() .saturating_add(self.allowed_suffixes.len()) } + + /// Add an exact host to the allowlist at runtime. + /// + /// The host is lowercased before insertion. If it already exists, + /// this is a no-op. Cloud metadata hosts in the deny list are + /// rejected — they can never be allowed. + /// + /// # Errors + /// + /// Returns [`NonoError`](crate::NonoError) if the host is empty + /// or is a cloud metadata endpoint. + pub fn add_host(&mut self, host: &str) -> crate::Result<()> { + let lower = host.to_lowercase(); + if lower.is_empty() { + return Err(crate::NonoError::InvalidConfig { + reason: "host must not be empty".to_string(), + }); + } + if self.deny_hosts.contains(&lower) { + return Err(crate::NonoError::InvalidConfig { + reason: format!("host {host} is a cloud metadata endpoint and cannot be allowed"), + }); + } + if !self.allowed_hosts.contains(&lower) { + self.allowed_hosts.push(lower); + } + Ok(()) + } + + /// Add a wildcard subdomain suffix to the allowlist at runtime. + /// + /// The suffix should be in the form `.example.com` (leading dot). + /// If it starts with `*`, the `*` is stripped. If it already exists, + /// this is a no-op. + /// + /// # Errors + /// + /// Returns [`NonoError`](crate::NonoError) if the suffix is empty. + pub fn add_suffix(&mut self, suffix: &str) -> crate::Result<()> { + let lower = suffix.to_lowercase(); + let normalized = lower.strip_prefix('*').unwrap_or(&lower); + if normalized.is_empty() { + return Err(crate::NonoError::InvalidConfig { + reason: "suffix must not be empty".to_string(), + }); + } + let suffix_str = if normalized.starts_with('.') { + normalized.to_string() + } else { + format!(".{normalized}") + }; + if !self.allowed_suffixes.contains(&suffix_str) { + self.allowed_suffixes.push(suffix_str); + } + Ok(()) + } + + /// Add a host to the deny list at runtime. + /// + /// The host is lowercased before insertion. If it already exists + /// in the deny list, this is a no-op. Cloud metadata hosts are + /// always present and cannot be removed — adding them again is + /// a no-op. + /// + /// Once denied, a host cannot pass the filter regardless of the + /// allowlist. This is useful for "always deny" decisions from the + /// network approval flow. + /// + /// # Errors + /// + /// Returns [`NonoError`](crate::NonoError) if the host is empty. + pub fn add_deny_host(&mut self, host: &str) -> crate::Result<()> { + let lower = host.to_lowercase(); + if lower.is_empty() { + return Err(crate::NonoError::InvalidConfig { + reason: "host must not be empty".to_string(), + }); + } + if !self.deny_hosts.contains(&lower) { + self.deny_hosts.push(lower); + } + Ok(()) + } + + /// Add a wildcard subdomain suffix to the deny list at runtime. + /// + /// The suffix should be in the form `.example.com` (leading dot). + /// If it starts with `*`, the `*` is stripped. If it already exists, + /// this is a no-op. + /// + /// Once denied, any subdomain matching the suffix cannot pass the + /// filter regardless of the allowlist. + /// + /// # Errors + /// + /// Returns [`NonoError`](crate::NonoError) if the suffix is empty. + pub fn add_deny_suffix(&mut self, suffix: &str) -> crate::Result<()> { + let lower = suffix.to_lowercase(); + let normalized = lower.strip_prefix('*').unwrap_or(&lower); + if normalized.is_empty() { + return Err(crate::NonoError::InvalidConfig { + reason: "suffix must not be empty".to_string(), + }); + } + let suffix_str = if normalized.starts_with('.') { + normalized.to_string() + } else { + format!(".{normalized}") + }; + if !self.deny_suffixes.contains(&suffix_str) { + self.deny_suffixes.push(suffix_str); + } + Ok(()) + } } #[cfg(test)] @@ -301,6 +476,30 @@ mod tests { assert!(result.is_allowed()); } + #[test] + fn test_deny_all_mode() { + let filter = HostFilter::deny_all(); + let result = filter.check_host("any-host.example.com", &public_ip()); + assert!(!result.is_allowed()); + assert!(matches!(result, FilterResult::DenyNotAllowed { .. })); + } + + #[test] + fn test_deny_all_then_add_host() { + let mut filter = HostFilter::deny_all(); + assert!(!filter + .check_host("approved.example.com", &public_ip()) + .is_allowed()); + + filter.add_host("approved.example.com").expect("add_host"); + assert!(filter + .check_host("approved.example.com", &public_ip()) + .is_allowed()); + assert!(!filter + .check_host("other.example.com", &public_ip()) + .is_allowed()); + } + #[test] fn test_allow_all_allows_private_networks() { let filter = HostFilter::allow_all(); @@ -434,4 +633,81 @@ mod tests { }; assert!(link_local.reason().contains("link-local")); } + + #[test] + fn test_new_with_reject_exact() { + let filter = + HostFilter::new_with_reject(&["api.openai.com".to_string()], &["evil.com".to_string()]); + assert!(filter + .check_host("api.openai.com", &public_ip()) + .is_allowed()); + assert!(!filter.check_host("evil.com", &public_ip()).is_allowed()); + assert!(matches!( + filter.check_host("evil.com", &public_ip()), + FilterResult::DenyHost { .. } + )); + } + + #[test] + fn test_reject_overrides_allow() { + let filter = + HostFilter::new_with_reject(&["evil.com".to_string()], &["evil.com".to_string()]); + assert!( + !filter.check_host("evil.com", &public_ip()).is_allowed(), + "deny should override allow for same host" + ); + } + + #[test] + fn test_reject_wildcard_overrides_allow() { + let filter = + HostFilter::new_with_reject(&["api.evil.com".to_string()], &["*.evil.com".to_string()]); + assert!( + !filter.check_host("api.evil.com", &public_ip()).is_allowed(), + "deny wildcard should override allow for subdomain" + ); + } + + #[test] + fn test_add_deny_host() { + let mut filter = HostFilter::new(&["api.openai.com".to_string(), "evil.com".to_string()]); + filter.add_deny_host("evil.com").expect("add_deny_host"); + assert!( + !filter.check_host("evil.com", &public_ip()).is_allowed(), + "deny should override allow" + ); + } + + #[test] + fn test_add_deny_suffix() { + let mut filter = + HostFilter::new(&["api.example.com".to_string(), "track.evil.com".to_string()]); + filter + .add_deny_suffix("*.evil.com") + .expect("add_deny_suffix"); + assert!( + !filter + .check_host("track.evil.com", &public_ip()) + .is_allowed(), + "deny suffix should override allow" + ); + assert!( + !filter + .check_host("other.evil.com", &public_ip()) + .is_allowed(), + "deny suffix should match other subdomains" + ); + } + + #[test] + fn test_deny_suffix_does_not_match_bare_domain() { + let mut filter = HostFilter::new(&["evil.com".to_string()]); + filter + .add_deny_suffix("*.evil.com") + .expect("add_deny_suffix"); + assert!( + filter.check_host("evil.com", &public_ip()).is_allowed(), + "deny suffix should not match bare domain" + ); + } } diff --git a/crates/nono/src/runtime_filter.rs b/crates/nono/src/runtime_filter.rs new file mode 100644 index 000000000..bc3f57f6f --- /dev/null +++ b/crates/nono/src/runtime_filter.rs @@ -0,0 +1,218 @@ +//! Runtime-mutable host filter for dynamic whitelist extension. +//! +//! [`RuntimeHostFilter`] wraps a [`HostFilter`] in a [`std::sync::RwLock`], +//! allowing the allowlist to be extended at runtime (e.g., when the user +//! approves a new host via an OS notification). +//! +//! The core `HostFilter` remains immutable-by-default; this module adds +//! the mutation layer needed for interactive approval workflows. + +use std::sync::{Arc, RwLock, RwLockReadGuard, RwLockWriteGuard}; + +use crate::net_filter::{FilterResult, HostFilter}; +use crate::Result; + +/// Thread-safe, runtime-mutable host filter. +/// +/// Unlike [`HostFilter`] (which is immutable after construction), +/// `RuntimeHostFilter` allows hosts to be added while the proxy +/// is serving requests. Reads and writes are protected by an +/// [`RwLock`], so concurrent checks do not block each other. +/// +/// # Example +/// +/// ```rust +/// use nono::{HostFilter, RuntimeHostFilter}; +/// +/// let filter = RuntimeHostFilter::new(HostFilter::new(&["allowed.com".to_string()])); +/// assert!(filter.check_host("allowed.com", &[]).is_allowed()); +/// assert!(!filter.check_host("denied.com", &[]).is_allowed()); +/// +/// filter.add_host("denied.com").expect("add host"); +/// assert!(filter.check_host("denied.com", &[]).is_allowed()); +/// ``` +#[derive(Debug, Clone)] +pub struct RuntimeHostFilter { + inner: Arc>, +} + +impl RuntimeHostFilter { + /// Create a new runtime filter wrapping the given [`HostFilter`]. + #[must_use] + pub fn new(filter: HostFilter) -> Self { + Self { + inner: Arc::new(RwLock::new(filter)), + } + } + + /// Check whether a host is allowed. + /// + /// Takes a read lock — multiple concurrent checks do not block each other. + /// The `resolved_ips` parameter is forwarded to [`HostFilter::check_host`]. + #[must_use] + pub fn check_host(&self, host: &str, resolved_ips: &[std::net::IpAddr]) -> FilterResult { + let guard = self.read_lock(); + guard.check_host(host, resolved_ips) + } + + /// Add an exact host to the allowlist at runtime. + /// + /// Takes a write lock — blocks concurrent reads and writes. + /// + /// # Errors + /// + /// Returns [`NonoError`](crate::NonoError) if the host is empty + /// or is a cloud metadata endpoint. + pub fn add_host(&self, host: &str) -> Result<()> { + let mut guard = self.write_lock(); + guard.add_host(host) + } + + /// Add a wildcard subdomain suffix to the allowlist at runtime. + /// + /// Takes a write lock — blocks concurrent reads and writes. + /// + /// # Errors + /// + /// Returns [`NonoError`](crate::NonoError) if the suffix is empty. + pub fn add_suffix(&self, suffix: &str) -> Result<()> { + let mut guard = self.write_lock(); + guard.add_suffix(suffix) + } + + /// Add a host to the deny list at runtime. + /// + /// Takes a write lock — blocks concurrent reads and writes. + /// Once denied, the host cannot pass the filter regardless of the + /// allowlist. This is used for "always deny" decisions. + /// + /// # Errors + /// + /// Returns [`NonoError`](crate::NonoError) if the host is empty. + pub fn add_deny_host(&self, host: &str) -> Result<()> { + let mut guard = self.write_lock(); + guard.add_deny_host(host) + } + + /// Add a wildcard subdomain suffix to the deny list at runtime. + /// + /// Takes a write lock — blocks concurrent reads and writes. + /// Once denied, any subdomain matching the suffix cannot pass the + /// filter regardless of the allowlist. + /// + /// # Errors + /// + /// Returns [`NonoError`](crate::NonoError) if the suffix is empty. + pub fn add_deny_suffix(&self, suffix: &str) -> Result<()> { + let mut guard = self.write_lock(); + guard.add_deny_suffix(suffix) + } + + /// Snapshot the current [`HostFilter`] state. + /// + /// Useful for auditing or persisting the runtime allowlist. + #[must_use] + pub fn snapshot(&self) -> HostFilter { + let guard = self.read_lock(); + guard.clone() + } + + fn read_lock(&self) -> RwLockReadGuard<'_, HostFilter> { + self.inner.read().expect("runtime filter lock poisoned") + } + + fn write_lock(&self) -> RwLockWriteGuard<'_, HostFilter> { + self.inner.write().expect("runtime filter lock poisoned") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const NO_IPS: &[std::net::IpAddr] = &[]; + + fn deny_default_filter() -> HostFilter { + HostFilter::new(&["__deny_default_sentinel__.invalid".to_string()]) + } + + #[test] + fn test_add_host_makes_host_allowed() { + let filter = RuntimeHostFilter::new(deny_default_filter()); + assert!(!filter.check_host("example.com", NO_IPS).is_allowed()); + + filter.add_host("example.com").expect("add host"); + assert!(filter.check_host("example.com", NO_IPS).is_allowed()); + } + + #[test] + fn test_add_suffix_makes_subdomain_allowed() { + let filter = RuntimeHostFilter::new(deny_default_filter()); + assert!(!filter.check_host("sub.example.com", NO_IPS).is_allowed()); + + filter.add_suffix(".example.com").expect("add suffix"); + assert!(filter.check_host("sub.example.com", NO_IPS).is_allowed()); + } + + #[test] + fn test_add_host_idempotent() { + let filter = RuntimeHostFilter::new(deny_default_filter()); + filter.add_host("example.com").expect("add host 1"); + filter.add_host("example.com").expect("add host 2"); + let snap = filter.snapshot(); + assert_eq!(snap.allowed_count(), 2); + } + + #[test] + fn test_add_host_rejects_empty() { + let filter = RuntimeHostFilter::new(deny_default_filter()); + let result = filter.add_host(""); + assert!(result.is_err()); + } + + #[test] + fn test_add_host_rejects_cloud_metadata() { + let filter = RuntimeHostFilter::new(deny_default_filter()); + let result = filter.add_host("169.254.169.254"); + assert!(result.is_err()); + } + + #[test] + fn test_snapshot_captures_runtime_state() { + let filter = RuntimeHostFilter::new(HostFilter::new(&["initial.com".to_string()])); + filter.add_host("added.com").expect("add host"); + + let snap = filter.snapshot(); + assert!(snap.check_host("initial.com", NO_IPS).is_allowed()); + assert!(snap.check_host("added.com", NO_IPS).is_allowed()); + } + + #[test] + fn test_concurrent_reads() { + let filter = RuntimeHostFilter::new(HostFilter::new(&["example.com".to_string()])); + let f1 = filter.clone(); + let f2 = filter.clone(); + + let h1 = std::thread::spawn(move || f1.check_host("example.com", NO_IPS)); + let h2 = std::thread::spawn(move || f2.check_host("example.com", NO_IPS)); + + assert!(h1.join().expect("thread 1").is_allowed()); + assert!(h2.join().expect("thread 2").is_allowed()); + } + + #[test] + fn test_concurrent_read_write() { + let filter = RuntimeHostFilter::new(deny_default_filter()); + let f_read = filter.clone(); + let f_write = filter.clone(); + + let writer = std::thread::spawn(move || { + f_write.add_host("example.com").expect("add host"); + }); + + writer.join().expect("writer thread"); + + let reader = std::thread::spawn(move || f_read.check_host("example.com", NO_IPS)); + assert!(reader.join().expect("reader thread").is_allowed()); + } +} diff --git a/crates/nono/src/supervisor/mod.rs b/crates/nono/src/supervisor/mod.rs index 608b30b78..3a508190b 100644 --- a/crates/nono/src/supervisor/mod.rs +++ b/crates/nono/src/supervisor/mod.rs @@ -31,8 +31,8 @@ pub mod types; pub use socket::{SupervisorListener, SupervisorSocket}; pub use types::{ - ApprovalDecision, AuditEntry, CapabilityRequest, SupervisorMessage, SupervisorResponse, - UrlOpenRequest, + ApprovalDecision, ApprovalScope, AuditEntry, CapabilityRequest, NetworkApprovalDecision, + NetworkApprovalRequest, SupervisorMessage, SupervisorResponse, UrlOpenRequest, }; use crate::error::Result; @@ -89,6 +89,30 @@ pub trait ApprovalBackend: Send + Sync { /// or internal error. The supervisor should treat errors as denials. fn request_capability(&self, request: &CapabilityRequest) -> Result; + /// Decide whether to grant or deny a network access request. + /// + /// Called when the proxy intercepts a request to a host not on the + /// allowlist and interactive approval is enabled. The backend may + /// show an OS notification, terminal prompt, or other UI to ask + /// the user. + /// + /// The default implementation denies all network requests, which + /// preserves the existing behavior when approval is not configured. + /// + /// # Errors + /// + /// Returns an error if the backend encounters a communication failure + /// or internal error. The supervisor should treat errors as denials. + fn request_network_approval( + &self, + request: &NetworkApprovalRequest, + ) -> Result { + let _ = request; + Ok(NetworkApprovalDecision::Denied { + reason: "network approval not configured".to_string(), + }) + } + /// Human-readable name for this backend (used in audit logs). fn backend_name(&self) -> &str; } diff --git a/crates/nono/src/supervisor/types.rs b/crates/nono/src/supervisor/types.rs index ca6f334b2..3b2a409dd 100644 --- a/crates/nono/src/supervisor/types.rs +++ b/crates/nono/src/supervisor/types.rs @@ -100,6 +100,8 @@ pub enum SupervisorMessage { Request(CapabilityRequest), /// A request to open a URL in the user's browser (e.g., OAuth2 login) OpenUrl(UrlOpenRequest), + /// A request to approve network access to a blocked host + NetworkApproval(NetworkApprovalRequest), } /// IPC message envelope sent from supervisor to child. @@ -121,4 +123,70 @@ pub enum SupervisorResponse { /// Error message if the open failed error: Option, }, + /// Response to a network approval request + NetworkDecision { + /// The request_id this responds to + request_id: String, + /// The network approval decision + decision: NetworkApprovalDecision, + }, +} + +/// Scope of a network approval — affects whether the host is persisted. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub enum ApprovalScope { + /// Approved for a single request only; next request to the same host will prompt again. + Once, + /// Approved for this sandbox session only (in-memory) + Session, + /// Approved and persisted to config for future sessions + Persistent, +} + +/// A request to approve network access to a host that is not on the allowlist. +/// +/// Sent when the proxy intercepts a request to a blocked host and +/// interactive approval is enabled (`--network-approval ask`). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NetworkApprovalRequest { + /// Unique identifier for this request + pub request_id: String, + /// The hostname being requested + pub host: String, + /// The port being requested (if known) + pub port: Option, + /// Human-readable reason for the request + pub reason: Option, + /// PID of the requesting child process + pub child_pid: u32, + /// Session identifier for correlating requests within a single run + pub session_id: String, +} + +/// The supervisor's response to a [`NetworkApprovalRequest`]. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub enum NetworkApprovalDecision { + /// Access was granted with the given scope. + Granted(ApprovalScope), + /// Access was denied with a reason. + Denied { + /// Why the request was denied + reason: String, + }, + /// The approval request timed out without a decision. + Timeout, +} + +impl NetworkApprovalDecision { + /// Returns true if access was granted (in any scope). + #[must_use] + pub fn is_granted(&self) -> bool { + matches!(self, NetworkApprovalDecision::Granted(_)) + } + + /// Returns true if access was denied. + #[must_use] + pub fn is_denied(&self) -> bool { + matches!(self, NetworkApprovalDecision::Denied { .. }) + } } From 2a744e08d28adbddd0f7348069d7bdb23716bbbd Mon Sep 17 00:00:00 2001 From: Matthias Klass Date: Sun, 31 May 2026 10:41:37 +0200 Subject: [PATCH 02/91] fix: propagate approval decision to dedup waiters in network approval When multiple concurrent requests arrive for the same host, the dedup mechanism in NetworkApprovalBackend used a Notify-only pending map. After the first request's dialog was resolved, waiting requests would always return Granted(Session) regardless of the actual decision, because they only checked whether the pending entry was removed. Replace the pending map value from Arc to Arc which stores both the Notify and the actual decision. The first request writes its decision into the slot before notifying waiters, and waiters read the actual decision instead of assuming Granted. This fixes both: - Repeated approval popups after denying a host (denied waiters still got Granted, causing the next request to trigger a new dialog) - A security bug where denied hosts were approved for concurrent waiting requests Signed-off-by: klassm --- crates/nono-cli/src/network_approval.rs | 170 +++++++++++++++++++++--- crates/nono-proxy/src/connect.rs | 12 ++ crates/nono/src/lib.rs | 2 +- 3 files changed, 167 insertions(+), 17 deletions(-) diff --git a/crates/nono-cli/src/network_approval.rs b/crates/nono-cli/src/network_approval.rs index 5c18b863c..9bb032e30 100644 --- a/crates/nono-cli/src/network_approval.rs +++ b/crates/nono-cli/src/network_approval.rs @@ -11,6 +11,11 @@ use std::collections::HashMap; use std::sync::{Arc, Mutex}; +struct PendingEntry { + notify: Arc, + decision: Mutex>, +} + use nono::{ ApprovalBackend, ApprovalDecision, ApprovalScope, CapabilityRequest, NetworkApprovalDecision, NetworkApprovalRequest, Result, RuntimeHostFilter, @@ -50,7 +55,7 @@ pub struct NetworkApprovalBackend { mode: NetworkApprovalMode, runtime_filter: RuntimeHostFilter, timeout_secs: u64, - pending: Arc>>>, + pending: Arc>>>, config_writer: Option, } @@ -260,22 +265,25 @@ impl NetworkApprovalBackend { let host = request.host.clone(); tracing::info!("Network approval requested for host: {host}"); - let (notify, is_first) = { + let (entry, is_first) = { let mut pending = self.pending.lock().expect("pending lock poisoned"); if let Some(existing) = pending.get(&host) { tracing::info!("Host {host} has pending approval — waiting on existing dialog"); (existing.clone(), false) } else { - let n = Arc::new(tokio::sync::Notify::new()); - pending.insert(host.clone(), n.clone()); - (n, true) + let entry = Arc::new(PendingEntry { + notify: Arc::new(tokio::sync::Notify::new()), + decision: Mutex::new(None), + }); + pending.insert(host.clone(), entry.clone()); + (entry, true) } }; if !is_first { let _ = tokio::time::timeout( std::time::Duration::from_secs(self.timeout_secs), - notify.notified(), + entry.notify.notified(), ) .await; @@ -285,7 +293,14 @@ impl NetworkApprovalBackend { reason: "Approval timed out".to_string(), } } else { - NetworkApprovalDecision::Granted(ApprovalScope::Session) + let decision = entry + .decision + .lock() + .expect("decision lock poisoned") + .clone(); + decision.unwrap_or_else(|| NetworkApprovalDecision::Denied { + reason: "Approval result unavailable".to_string(), + }) }; } @@ -362,11 +377,15 @@ impl NetworkApprovalBackend { } }; + { + let mut slot = entry.decision.lock().expect("decision lock poisoned"); + *slot = Some(decision.clone()); + } self.pending .lock() .expect("pending lock poisoned") .remove(&host); - notify.notify_waiters(); + entry.notify.notify_waiters(); tracing::info!("Network approval decision for {host}: {:?}", decision); decision @@ -386,14 +405,17 @@ impl ApprovalBackend for NetworkApprovalBackend { ) -> Result { let host = request.host.clone(); - let (notify, is_first) = { + let (entry, is_first) = { let mut pending = self.pending.lock().expect("pending lock poisoned"); if let Some(existing) = pending.get(&host) { (existing.clone(), false) } else { - let n = Arc::new(tokio::sync::Notify::new()); - pending.insert(host.clone(), n.clone()); - (n, true) + let entry = Arc::new(PendingEntry { + notify: Arc::new(tokio::sync::Notify::new()), + decision: Mutex::new(None), + }); + pending.insert(host.clone(), entry.clone()); + (entry, true) } }; @@ -401,7 +423,7 @@ impl ApprovalBackend for NetworkApprovalBackend { sync_runtime().block_on(async { let _ = tokio::time::timeout( std::time::Duration::from_secs(self.timeout_secs), - notify.notified(), + entry.notify.notified(), ) .await; }); @@ -412,7 +434,14 @@ impl ApprovalBackend for NetworkApprovalBackend { reason: "Approval timed out".to_string(), }) } else { - Ok(NetworkApprovalDecision::Granted(ApprovalScope::Session)) + let decision = entry + .decision + .lock() + .expect("decision lock poisoned") + .clone(); + Ok(decision.unwrap_or_else(|| NetworkApprovalDecision::Denied { + reason: "Approval result unavailable".to_string(), + })) }; } @@ -491,12 +520,17 @@ impl ApprovalBackend for NetworkApprovalBackend { } }; + { + let mut slot = entry.decision.lock().expect("decision lock poisoned"); + *slot = Some(decision.clone()); + } self.pending .lock() .expect("pending lock poisoned") .remove(&host); - notify.notify_waiters(); + entry.notify.notify_waiters(); + tracing::info!("Network approval decision for {host}: {:?}", decision); Ok(decision) } @@ -937,7 +971,7 @@ mod tests { let runtime_filter = deny_default_filter(); // Simulate Deny(Once): host NOT added to deny filter - // Next request to same host would prompt again + // Next request would prompt again assert!( !runtime_filter .check_host("once-denied.com", &[]) @@ -947,4 +981,108 @@ mod tests { // But it's not explicitly in the deny list either — // the next request would go through the same approval flow } + + #[tokio::test] + async fn test_dedup_waiters_receive_deny_when_first_request_is_denied() { + let runtime_filter = deny_default_filter(); + let backend = NetworkApprovalBackend::new( + NetworkApprovalMode::Ask, + runtime_filter, + 5, + None, + ); + let backend = Arc::new(backend); + let host = "dedup-deny-test.com"; + + let entry = Arc::new(PendingEntry { + notify: Arc::new(tokio::sync::Notify::new()), + decision: Mutex::new(None), + }); + { + let mut pending = backend.pending.lock().expect("pending lock poisoned"); + pending.insert(host.to_string(), Arc::clone(&entry)); + } + + let request_b = make_request(host); + let backend_b = Arc::clone(&backend); + + let handle_b = tokio::spawn(async move { + backend_b + .request_network_approval_async(&request_b) + .await + }); + + tokio::task::yield_now().await; + + { + let mut slot = entry.decision.lock().expect("decision lock poisoned"); + *slot = Some(NetworkApprovalDecision::Denied { + reason: "User denied the request".to_string(), + }); + } + { + let mut pending = backend.pending.lock().expect("pending lock poisoned"); + pending.remove(host); + } + entry.notify.notify_waiters(); + + let decision_b = handle_b.await.expect("handle_b panicked"); + + assert!( + decision_b.is_denied(), + "waiting request should be denied when the first request was denied, but got: {:?}", + decision_b + ); + } + + #[tokio::test] + async fn test_dedup_waiters_receive_grant_when_first_request_is_approved() { + let runtime_filter = deny_default_filter(); + let backend = NetworkApprovalBackend::new( + NetworkApprovalMode::Ask, + runtime_filter, + 5, + None, + ); + let backend = Arc::new(backend); + let host = "dedup-approve-test.com"; + + let entry = Arc::new(PendingEntry { + notify: Arc::new(tokio::sync::Notify::new()), + decision: Mutex::new(None), + }); + { + let mut pending = backend.pending.lock().expect("pending lock poisoned"); + pending.insert(host.to_string(), Arc::clone(&entry)); + } + + let request_b = make_request(host); + let backend_b = Arc::clone(&backend); + + let handle_b = tokio::spawn(async move { + backend_b + .request_network_approval_async(&request_b) + .await + }); + + tokio::task::yield_now().await; + + { + let mut slot = entry.decision.lock().expect("decision lock poisoned"); + *slot = Some(NetworkApprovalDecision::Granted(ApprovalScope::Session)); + } + { + let mut pending = backend.pending.lock().expect("pending lock poisoned"); + pending.remove(host); + } + entry.notify.notify_waiters(); + + let decision_b = handle_b.await.expect("handle_b panicked"); + + assert!( + matches!(decision_b, NetworkApprovalDecision::Granted(ApprovalScope::Session)), + "waiting request should be granted when the first request was approved, but got: {:?}", + decision_b + ); + } } diff --git a/crates/nono-proxy/src/connect.rs b/crates/nono-proxy/src/connect.rs index af3c410ed..41c618ed4 100644 --- a/crates/nono-proxy/src/connect.rs +++ b/crates/nono-proxy/src/connect.rs @@ -86,6 +86,7 @@ pub async fn handle_connect_with_approval( audit::log_denied( ctx.audit_log, audit::ProxyMode::Connect, + &audit::EventContext::default(), &host, port, &reason, @@ -99,6 +100,7 @@ pub async fn handle_connect_with_approval( audit::log_allowed( ctx.audit_log, audit::ProxyMode::Connect, + &audit::EventContext::default(), &host, port, "CONNECT", @@ -120,6 +122,7 @@ pub async fn handle_connect_with_approval( audit::log_denied( ctx.audit_log, audit::ProxyMode::Connect, + &audit::EventContext::default(), &host, port, &reason, @@ -140,6 +143,7 @@ pub async fn handle_connect_with_approval( audit::log_denied( ctx.audit_log, audit::ProxyMode::Connect, + &audit::EventContext::default(), &host, port, &reason, @@ -153,6 +157,7 @@ pub async fn handle_connect_with_approval( audit::log_allowed( ctx.audit_log, audit::ProxyMode::Connect, + &audit::EventContext::default(), &host, port, "CONNECT (runtime)", @@ -174,6 +179,7 @@ pub async fn handle_connect_with_approval( audit::log_denied( ctx.audit_log, audit::ProxyMode::Connect, + &audit::EventContext::default(), &host, port, &reason, @@ -203,6 +209,7 @@ pub async fn handle_connect_with_approval( audit::log_denied( ctx.audit_log, audit::ProxyMode::Connect, + &audit::EventContext::default(), &host, port, reason, @@ -221,6 +228,7 @@ pub async fn handle_connect_with_approval( audit::log_denied( ctx.audit_log, audit::ProxyMode::Connect, + &audit::EventContext::default(), &host, port, reason, @@ -253,6 +261,7 @@ pub async fn handle_connect_with_approval( audit::log_denied( ctx.audit_log, audit::ProxyMode::Connect, + &audit::EventContext::default(), &host, port, reason, @@ -272,6 +281,7 @@ pub async fn handle_connect_with_approval( audit::log_denied( ctx.audit_log, audit::ProxyMode::Connect, + &audit::EventContext::default(), &host, port, reason, @@ -290,6 +300,7 @@ pub async fn handle_connect_with_approval( audit::log_allowed( ctx.audit_log, audit::ProxyMode::Connect, + &audit::EventContext::default(), &host, port, "CONNECT (approved)", @@ -307,6 +318,7 @@ pub async fn handle_connect_with_approval( audit::log_denied( ctx.audit_log, audit::ProxyMode::Connect, + &audit::EventContext::default(), &host, port, display_reason, diff --git a/crates/nono/src/lib.rs b/crates/nono/src/lib.rs index a1580fe7c..c34a4940e 100644 --- a/crates/nono/src/lib.rs +++ b/crates/nono/src/lib.rs @@ -92,7 +92,7 @@ pub use scrub::{ pub use state::SandboxState; pub use supervisor::{ ApprovalBackend, ApprovalDecision, ApprovalScope, CapabilityRequest, NetworkApprovalDecision, - NetworkApprovalRequest, SupervisorSocket, UrlOpenRequest, + NetworkApprovalRequest, SupervisorListener, SupervisorSocket, UrlOpenRequest, }; pub use trust::{ Enforcement, IncludePatterns, Publisher, SignerIdentity, TrustPolicy, VerificationOutcome, From fe656d9e7645fc03a50699d710f1f10e4e22ff46 Mon Sep 17 00:00:00 2001 From: Matthias Klass Date: Mon, 1 Jun 2026 20:39:24 +0200 Subject: [PATCH 03/91] fix: always create ConfigWriter so Approve Always persists When network_profile was None (the default case), config_writer was None, causing 'Approve Always' to silently degrade to session-only. The host was only added to the in-memory runtime filter and never written to the profile JSON, so the approval popup reappeared on every session. Now always create a ConfigWriter, falling back to the 'default' profile name when no network_profile is explicitly configured. Signed-off-by: klassm --- crates/nono-cli/src/proxy_runtime.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/nono-cli/src/proxy_runtime.rs b/crates/nono-cli/src/proxy_runtime.rs index 39af4ec71..706364e4c 100644 --- a/crates/nono-cli/src/proxy_runtime.rs +++ b/crates/nono-cli/src/proxy_runtime.rs @@ -353,10 +353,10 @@ pub(crate) fn start_proxy_runtime( let (tx, mut rx) = tokio::sync::mpsc::channel::(16); - let config_writer = proxy - .network_profile - .as_deref() - .map(crate::network_approval::ConfigWriter::new); + let config_writer = match proxy.network_profile.as_deref() { + Some(name) => Some(crate::network_approval::ConfigWriter::new(name)), + None => Some(crate::network_approval::ConfigWriter::new("default")), + }; let backend = NetworkApprovalBackend::new( proxy.network_approval_mode, From 0c7a235bcbbca5bbb7b120df1189ad9694bd84b6 Mon Sep 17 00:00:00 2001 From: Matthias Klass Date: Thu, 4 Jun 2026 10:00:51 +0200 Subject: [PATCH 04/91] fix: check runtime filter before showing approval dialog The approval channel receiver loop processes requests sequentially. When multiple CONNECT requests for the same host queued in the channel, each one would show a separate approval dialog because the approval backend never re-checked the runtime filter before entering the dialog path. After the first request was approved (adding the host to the runtime filter), subsequent queued requests should have been granted immediately without a dialog. Now request_network_approval_async checks the runtime filter first: - If the host is already allowed, returns Granted(Session) immediately - If the host is explicitly denied (DenyHost/DenyLinkLocal), returns Denied immediately - Otherwise, proceeds to the dialog/dedup path as before Signed-off-by: klassm --- crates/nono-cli/src/network_approval.rs | 53 +++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/crates/nono-cli/src/network_approval.rs b/crates/nono-cli/src/network_approval.rs index 9bb032e30..85cb67cfc 100644 --- a/crates/nono-cli/src/network_approval.rs +++ b/crates/nono-cli/src/network_approval.rs @@ -265,6 +265,22 @@ impl NetworkApprovalBackend { let host = request.host.clone(); tracing::info!("Network approval requested for host: {host}"); + if self.runtime_filter.check_host(&host, &[]).is_allowed() { + tracing::info!("Host {host} already allowed by runtime filter — skipping dialog"); + return NetworkApprovalDecision::Granted(ApprovalScope::Session); + } + + if matches!( + self.runtime_filter.check_host(&host, &[]), + nono::net_filter::FilterResult::DenyHost { .. } + | nono::net_filter::FilterResult::DenyLinkLocal { .. } + ) { + tracing::info!("Host {host} explicitly denied by runtime filter"); + return NetworkApprovalDecision::Denied { + reason: "Host denied by session policy".to_string(), + }; + } + let (entry, is_first) = { let mut pending = self.pending.lock().expect("pending lock poisoned"); if let Some(existing) = pending.get(&host) { @@ -405,6 +421,22 @@ impl ApprovalBackend for NetworkApprovalBackend { ) -> Result { let host = request.host.clone(); + if self.runtime_filter.check_host(&host, &[]).is_allowed() { + tracing::info!("Host {host} already allowed by runtime filter — skipping dialog"); + return Ok(NetworkApprovalDecision::Granted(ApprovalScope::Session)); + } + + if matches!( + self.runtime_filter.check_host(&host, &[]), + nono::net_filter::FilterResult::DenyHost { .. } + | nono::net_filter::FilterResult::DenyLinkLocal { .. } + ) { + tracing::info!("Host {host} explicitly denied by runtime filter"); + return Ok(NetworkApprovalDecision::Denied { + reason: "Host denied by session policy".to_string(), + }); + } + let (entry, is_first) = { let mut pending = self.pending.lock().expect("pending lock poisoned"); if let Some(existing) = pending.get(&host) { @@ -1085,4 +1117,25 @@ mod tests { decision_b ); } + + #[tokio::test] + async fn test_already_approved_host_skips_dialog() { + let runtime_filter = deny_default_filter(); + runtime_filter.add_host("pre-approved.com").unwrap(); + let backend = NetworkApprovalBackend::new( + NetworkApprovalMode::Ask, + runtime_filter, + 5, + None, + ); + + let request = make_request("pre-approved.com"); + let decision = backend.request_network_approval_async(&request).await; + + assert!( + matches!(decision, NetworkApprovalDecision::Granted(ApprovalScope::Session)), + "host already in runtime filter should be granted without dialog, but got: {:?}", + decision + ); + } } From 7c766832932e00f896df2e1454fa0f65f1deb511 Mon Sep 17 00:00:00 2001 From: Matthias Klass Date: Thu, 4 Jun 2026 10:14:16 +0200 Subject: [PATCH 05/91] =?UTF-8?q?fix:=20network=20approval=20hardening=20?= =?UTF-8?q?=E2=80=94=20link-local,=20DNS=20rebinding,=20external=20proxy,?= =?UTF-8?q?=20CRLF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug 4 (HIGH): External proxy path bypassed network approval entirely. When an external proxy was configured and the host was not bypassed, handle_external_proxy was called without checking the runtime filter or sending approval requests. Now routes through handle_connect_with_approval when approval context is available. Bug 9 (HIGH): DNS rebinding vulnerability on Once-approved connections. resolve_host did DNS resolution without checking link-local IPs. A malicious domain could resolve to 169.254.169.254 on the second lookup, accessing cloud metadata. Now uses check_host which validates link-local IPs, and denies the connection if the check fails. Bug 17 (HIGH): DenyLinkLocal not blocked before approval channel. handle_connect_with_approval only short-circuited on DenyHost, not DenyLinkLocal. A link-local IP could reach the approval dialog. Now both DenyHost and DenyLinkLocal are blocked before the approval channel in both primary and runtime filter checks. Bug 2 (MEDIUM): Double check_host call in request_network_approval_async. The runtime filter was checked twice consecutively (once for is_allowed, once for DenyHost/DenyLinkLocal match). Now checks once and branches on the stored result. Bug 1 (LOW): is_denied() on NetworkApprovalDecision did not consider Timeout variant. A timeout is semantically a denial, so Timeout is now included in is_denied(). Bug 12 (LOW): external.rs send_response did not sanitize CRLF in reason strings, inconsistent with connect.rs. Now sanitizes like connect.rs does. Signed-off-by: klassm --- crates/nono-cli/src/network_approval.rs | 10 +++--- crates/nono-proxy/src/connect.rs | 21 +++++++++++-- crates/nono-proxy/src/external.rs | 3 +- crates/nono-proxy/src/server.rs | 42 +++++++++++++++++++------ crates/nono/src/supervisor/types.rs | 5 ++- 5 files changed, 62 insertions(+), 19 deletions(-) diff --git a/crates/nono-cli/src/network_approval.rs b/crates/nono-cli/src/network_approval.rs index 85cb67cfc..1a4bbe404 100644 --- a/crates/nono-cli/src/network_approval.rs +++ b/crates/nono-cli/src/network_approval.rs @@ -265,13 +265,14 @@ impl NetworkApprovalBackend { let host = request.host.clone(); tracing::info!("Network approval requested for host: {host}"); - if self.runtime_filter.check_host(&host, &[]).is_allowed() { + let filter_result = self.runtime_filter.check_host(&host, &[]); + if filter_result.is_allowed() { tracing::info!("Host {host} already allowed by runtime filter — skipping dialog"); return NetworkApprovalDecision::Granted(ApprovalScope::Session); } if matches!( - self.runtime_filter.check_host(&host, &[]), + filter_result, nono::net_filter::FilterResult::DenyHost { .. } | nono::net_filter::FilterResult::DenyLinkLocal { .. } ) { @@ -421,13 +422,14 @@ impl ApprovalBackend for NetworkApprovalBackend { ) -> Result { let host = request.host.clone(); - if self.runtime_filter.check_host(&host, &[]).is_allowed() { + let filter_result = self.runtime_filter.check_host(&host, &[]); + if filter_result.is_allowed() { tracing::info!("Host {host} already allowed by runtime filter — skipping dialog"); return Ok(NetworkApprovalDecision::Granted(ApprovalScope::Session)); } if matches!( - self.runtime_filter.check_host(&host, &[]), + filter_result, nono::net_filter::FilterResult::DenyHost { .. } | nono::net_filter::FilterResult::DenyLinkLocal { .. } ) { diff --git a/crates/nono-proxy/src/connect.rs b/crates/nono-proxy/src/connect.rs index 41c618ed4..066429c59 100644 --- a/crates/nono-proxy/src/connect.rs +++ b/crates/nono-proxy/src/connect.rs @@ -113,6 +113,7 @@ pub async fn handle_connect_with_approval( if matches!( primary_check.result, nono::net_filter::FilterResult::DenyHost { .. } + | nono::net_filter::FilterResult::DenyLinkLocal { .. } ) { let reason = primary_check.result.reason(); debug!( @@ -170,6 +171,7 @@ pub async fn handle_connect_with_approval( if matches!( runtime_check.result, nono::net_filter::FilterResult::DenyHost { .. } + | nono::net_filter::FilterResult::DenyLinkLocal { .. } ) { let reason = runtime_check.result.reason(); debug!( @@ -255,8 +257,21 @@ pub async fn handle_connect_with_approval( ); let resolved = if scope == ApprovalScope::Once { - let addrs = ctx.runtime_filter.resolve_host(&host, port).await?; - if addrs.is_empty() { + let once_check = ctx.runtime_filter.check_host(&host, port).await?; + if !once_check.result.is_allowed() { + let reason = once_check.result.reason(); + audit::log_denied( + ctx.audit_log, + audit::ProxyMode::Connect, + &audit::EventContext::default(), + &host, + port, + &reason, + ); + send_response(stream, 403, &format!("Forbidden: {}", reason)).await?; + return Err(ProxyError::HostDenied { host, reason }); + } + if once_check.resolved_addrs.is_empty() { let reason = "Could not resolve host after approval"; audit::log_denied( ctx.audit_log, @@ -272,7 +287,7 @@ pub async fn handle_connect_with_approval( reason: reason.to_string(), }); } - addrs + once_check.resolved_addrs } else { let runtime_check = ctx.runtime_filter.check_host(&host, port).await?; let resolved = &runtime_check.resolved_addrs; diff --git a/crates/nono-proxy/src/external.rs b/crates/nono-proxy/src/external.rs index 88d6ca51f..f7591e73a 100644 --- a/crates/nono-proxy/src/external.rs +++ b/crates/nono-proxy/src/external.rs @@ -340,7 +340,8 @@ fn parse_status_code(line: &str) -> Result { /// Send an HTTP response line. async fn send_response(stream: &mut TcpStream, status: u16, reason: &str) -> Result<()> { - let response = format!("HTTP/1.1 {} {}\r\n\r\n", status, reason); + let sanitised_reason = reason.replace(['\r', '\n'], " "); + let response = format!("HTTP/1.1 {} {}\r\n\r\n", status, sanitised_reason); stream.write_all(response.as_bytes()).await?; stream.flush().await?; Ok(()) diff --git a/crates/nono-proxy/src/server.rs b/crates/nono-proxy/src/server.rs index 7accb52d2..372809d14 100644 --- a/crates/nono-proxy/src/server.rs +++ b/crates/nono-proxy/src/server.rs @@ -842,16 +842,38 @@ async fn handle_connection(mut stream: tokio::net::TcpStream, state: &ProxyState }; if let Some(ext_config) = use_external { - external::handle_external_proxy( - first_line, - &mut stream, - &header_bytes, - &state.filter, - &state.session_token, - ext_config, - Some(&state.audit_log), - ) - .await + if let (Some(rf), Some(tx)) = + (state.runtime_filter.as_ref(), state.approval_tx.as_ref()) + { + let approval_ctx = connect::ApprovalContext { + primary_filter: &state.filter, + runtime_filter: rf, + session_token: &state.session_token, + audit_log: Some(&state.audit_log), + approval_tx: tx, + child_pid: state.child_pid, + session_id: &state.session_id, + approval_timeout: state.approval_timeout, + }; + connect::handle_connect_with_approval( + first_line, + &mut stream, + &header_bytes, + &approval_ctx, + ) + .await + } else { + external::handle_external_proxy( + first_line, + &mut stream, + &header_bytes, + &state.filter, + &state.session_token, + ext_config, + Some(&state.audit_log), + ) + .await + } } else if state.config.external_proxy.is_some() { token::validate_proxy_auth(&header_bytes, &state.session_token)?; diff --git a/crates/nono/src/supervisor/types.rs b/crates/nono/src/supervisor/types.rs index 3b2a409dd..40170de6f 100644 --- a/crates/nono/src/supervisor/types.rs +++ b/crates/nono/src/supervisor/types.rs @@ -187,6 +187,9 @@ impl NetworkApprovalDecision { /// Returns true if access was denied. #[must_use] pub fn is_denied(&self) -> bool { - matches!(self, NetworkApprovalDecision::Denied { .. }) + matches!( + self, + NetworkApprovalDecision::Denied { .. } | NetworkApprovalDecision::Timeout + ) } } From b2eda7e260e6b8f6fe3b24f7381ac0e70356845f Mon Sep 17 00:00:00 2001 From: Luke Hinds Date: Mon, 1 Jun 2026 18:39:27 +0100 Subject: [PATCH 06/91] refactor(network-policy): do not enable credentials by default in profiles The embedded network policy profiles (`google-ai`, `developer`, `codex`, `claude-code`) no longer include `credentials` by default. This change ensures that network profiles start with an empty set of implicitly enabled credential routes. Users should explicitly define which credentials are required for their specific use cases, enhancing security and clarity. - Removed `credentials` arrays from `network-policy.json` for affected profiles. - Updated and added tests to verify that embedded profiles do not enable credentials by default. Signed-off-by: Luke Hinds --- crates/nono-cli/data/network-policy.json | 12 ++--- crates/nono-cli/src/network_policy.rs | 56 ++++++++++++------------ 2 files changed, 33 insertions(+), 35 deletions(-) diff --git a/crates/nono-cli/data/network-policy.json b/crates/nono-cli/data/network-policy.json index 45a8dcbf0..169a49673 100644 --- a/crates/nono-cli/data/network-policy.json +++ b/crates/nono-cli/data/network-policy.json @@ -118,8 +118,7 @@ "gitlab", "sigstore", "documentation" - ], - "credentials": ["google-ai", "github", "gitlab"] + ] }, "developer": { "groups": [ @@ -129,8 +128,7 @@ "gitlab", "sigstore", "documentation" - ], - "credentials": ["github", "gitlab"] + ] }, "codex": { "groups": [ @@ -140,8 +138,7 @@ "gitlab", "sigstore", "documentation" - ], - "credentials": ["openai", "github", "gitlab"] + ] }, "claude-code": { "groups": [ @@ -151,8 +148,7 @@ "gitlab", "sigstore", "documentation" - ], - "credentials": ["anthropic", "github", "gitlab"] + ] }, "minimal": { "groups": [ diff --git a/crates/nono-cli/src/network_policy.rs b/crates/nono-cli/src/network_policy.rs index 90c0846f0..6022194ce 100644 --- a/crates/nono-cli/src/network_policy.rs +++ b/crates/nono-cli/src/network_policy.rs @@ -965,42 +965,27 @@ mod tests { } #[test] - fn test_claude_code_profile_includes_git_provider_credential() { + fn test_claude_code_profile_does_not_enable_credentials_by_default() { let json = embedded_network_policy_json(); let policy = load_network_policy(json).expect("policy should load"); let resolved = resolve_network_profile(&policy, "claude-code").expect("should resolve"); assert!( - resolved.profile_credentials.contains(&"github".to_string()), - "claude-code profile should include github credential, got: {:?}", - resolved.profile_credentials - ); - assert!( - resolved.profile_credentials.contains(&"gitlab".to_string()), - "claude-code profile should include gitlab credential, got: {:?}", + resolved.profile_credentials.is_empty(), + "network profiles should not implicitly enable credential routes, got: {:?}", resolved.profile_credentials ); } #[test] - fn test_codex_profile_includes_openai_and_git_provider_credentials() { + fn test_codex_profile_does_not_enable_credentials_by_default() { let json = embedded_network_policy_json(); let policy = load_network_policy(json).expect("policy should load"); let resolved = resolve_network_profile(&policy, "codex").expect("should resolve"); assert!( - resolved.profile_credentials.contains(&"openai".to_string()), - "codex profile should include openai credential, got: {:?}", - resolved.profile_credentials - ); - assert!( - resolved.profile_credentials.contains(&"github".to_string()), - "codex profile should include github credential, got: {:?}", - resolved.profile_credentials - ); - assert!( - resolved.profile_credentials.contains(&"gitlab".to_string()), - "codex profile should include gitlab credential, got: {:?}", + resolved.profile_credentials.is_empty(), + "network profiles should not implicitly enable credential routes, got: {:?}", resolved.profile_credentials ); } @@ -1045,31 +1030,48 @@ mod tests { } #[test] - fn test_developer_profile_includes_github_credential() { + fn test_developer_profile_does_not_enable_github_credential_by_default() { let json = embedded_network_policy_json(); let policy = load_network_policy(json).expect("policy should load"); let resolved = resolve_network_profile(&policy, "developer").expect("should resolve"); assert!( - resolved.profile_credentials.contains(&"github".to_string()), - "developer profile should include github credential, got: {:?}", + !resolved.profile_credentials.contains(&"github".to_string()), + "developer profile should not include github credential by default, got: {:?}", resolved.profile_credentials ); } #[test] - fn test_developer_profile_includes_gitlab_credential() { + fn test_developer_profile_does_not_enable_gitlab_credential_by_default() { let json = embedded_network_policy_json(); let policy = load_network_policy(json).expect("policy should load"); let resolved = resolve_network_profile(&policy, "developer").expect("should resolve"); assert!( - resolved.profile_credentials.contains(&"gitlab".to_string()), - "developer profile should include gitlab credential, got: {:?}", + !resolved.profile_credentials.contains(&"gitlab".to_string()), + "developer profile should not include gitlab credential by default, got: {:?}", resolved.profile_credentials ); } + #[test] + fn test_embedded_network_profiles_do_not_enable_credentials_by_default() { + let json = embedded_network_policy_json(); + let policy = load_network_policy(json).expect("policy should load"); + + for profile_name in policy.profiles.keys() { + let resolved = + resolve_network_profile(&policy, profile_name).expect("profile should resolve"); + assert!( + resolved.profile_credentials.is_empty(), + "network profile '{}' should not implicitly enable credential routes, got: {:?}", + profile_name, + resolved.profile_credentials + ); + } + } + #[test] fn test_resolve_credentials_with_oauth2_auth() { use crate::profile::CustomCredentialDef; From 6d1e714dd30dbd4f31b3687ea95994f58f669a71 Mon Sep 17 00:00:00 2001 From: Luke Hinds Date: Tue, 2 Jun 2026 06:10:32 +0100 Subject: [PATCH 07/91] feat(diagnostic): add profile option to suppress system service diagnostics (#1059) * feat(diagnostic): add profile option to suppress system service diagnostics Introduce `diagnostics.suppress_system_services` in the profile to filter out noisy, non-actionable system service denials from the diagnostic footer. - This new profile field accepts an array of strings representing non-filesystem sandbox operation names (e.g., `forbidden-exec-sugid`, `mach-lookup`). - Violations matching these operations will be hidden from the post-run diagnostic footer. - This suppression *only* affects the diagnostic reporting; the sandbox still enforces the underlying denials. - Suppressed violations will also be excluded from policy explanations and the profile save prompt. Signed-off-by: Luke Hinds * fix(cli): do not offer profile save for suppressed violations The `should_offer_profile_save` function is now called with `visible_sandbox_violations`, ensuring that only unsuppressed violations can trigger a profile save offer. A new test `test_suppressed_system_service_violations_do_not_offer_profile_save` was added to validate this behavior. Signed-off-by: Luke Hinds --------- Signed-off-by: Luke Hinds --- crates/nono-cli/data/nono-profile.schema.json | 16 ++++++ crates/nono-cli/src/command_runtime.rs | 2 + crates/nono-cli/src/exec_strategy.rs | 56 +++++++++++++++++-- crates/nono-cli/src/execution_runtime.rs | 1 + crates/nono-cli/src/launch_runtime.rs | 3 + crates/nono-cli/src/main.rs | 2 + crates/nono-cli/src/policy.rs | 1 + crates/nono-cli/src/profile/mod.rs | 49 ++++++++++++++++ crates/nono-cli/src/profile_runtime.rs | 5 ++ crates/nono-cli/src/sandbox_prepare.rs | 4 ++ crates/nono/src/diagnostic.rs | 44 ++++++++++++++- 11 files changed, 178 insertions(+), 5 deletions(-) diff --git a/crates/nono-cli/data/nono-profile.schema.json b/crates/nono-cli/data/nono-profile.schema.json index 1c94aba27..8ced1cd27 100644 --- a/crates/nono-cli/data/nono-profile.schema.json +++ b/crates/nono-cli/data/nono-profile.schema.json @@ -45,6 +45,10 @@ "$ref": "#/$defs/NetworkConfig", "description": "Network access configuration including proxy settings, credential injection, and port allowlists." }, + "diagnostics": { + "$ref": "#/$defs/DiagnosticsConfig", + "description": "Diagnostic output controls. These settings do not change sandbox enforcement." + }, "linux": { "$ref": "#/$defs/LinuxConfig", "description": "Linux-specific hardening controls." @@ -749,6 +753,18 @@ } } }, + "DiagnosticsConfig": { + "type": "object", + "description": "Diagnostic output controls. Suppressions only affect nono's footer; the sandbox still denies the underlying operations.", + "additionalProperties": false, + "properties": { + "suppress_system_services": { + "type": "array", + "items": { "type": "string" }, + "description": "Non-filesystem sandbox operation names to hide from diagnostic footers, such as forbidden-exec-sugid." + } + } + }, "WorkdirAccess": { "type": "string", "enum": ["none", "read", "write", "readwrite"], diff --git a/crates/nono-cli/src/command_runtime.rs b/crates/nono-cli/src/command_runtime.rs index 9a3847316..ed512a083 100644 --- a/crates/nono-cli/src/command_runtime.rs +++ b/crates/nono-cli/src/command_runtime.rs @@ -210,6 +210,7 @@ pub(crate) fn run_shell(args: ShellArgs, silent: bool) -> Result<()> { af_unix_mediation: prepared.af_unix_mediation, bypass_protection_paths: prepared.bypass_protection_paths, ignored_denial_paths: prepared.ignored_denial_paths, + suppressed_system_service_operations: prepared.suppressed_system_service_operations, allowed_env_vars: prepared.allowed_env_vars, denied_env_vars: prepared.denied_env_vars, startup_timeout_secs: args.startup_timeout_secs, @@ -293,6 +294,7 @@ pub(crate) fn run_wrap(wrap_args: WrapArgs, silent: bool) -> Result<()> { no_diagnostics, bypass_protection_paths: prepared.bypass_protection_paths, ignored_denial_paths: prepared.ignored_denial_paths, + suppressed_system_service_operations: prepared.suppressed_system_service_operations, allowed_env_vars: prepared.allowed_env_vars, denied_env_vars: prepared.denied_env_vars, ..ExecutionFlags::defaults(silent)? diff --git a/crates/nono-cli/src/exec_strategy.rs b/crates/nono-cli/src/exec_strategy.rs index 618d7e881..bb67c1a60 100644 --- a/crates/nono-cli/src/exec_strategy.rs +++ b/crates/nono-cli/src/exec_strategy.rs @@ -216,6 +216,8 @@ pub struct ExecConfig<'a> { pub profile_save_base: Option<&'a str>, /// Denied paths that should not be offered in the save-profile prompt. pub ignored_denial_paths: &'a [std::path::PathBuf], + /// Non-filesystem sandbox operations suppressed from diagnostic footers. + pub suppressed_system_service_operations: &'a [String], /// Optional startup timeout for known interactive CLIs that were launched /// without their recommended built-in profile. pub startup_timeout: Option>, @@ -1255,12 +1257,16 @@ pub fn execute_supervised( }; #[cfg(not(target_os = "macos"))] let sandbox_violations = Vec::new(); + let visible_sandbox_violations = filter_suppressed_system_service_violations( + &sandbox_violations, + config.suppressed_system_service_operations, + ); // Resolve policy explanations for denied paths so the diagnostic // can show group names and fix guidance inline. On macOS this is // also the source for the run-time profile save prompt. let policy_explanations = - build_policy_explanations(&denials, &sandbox_violations, config.caps); + build_policy_explanations(&denials, &visible_sandbox_violations, config.caps); let prompt_policy_explanations = policy_explanations.clone(); let prompt_error_observation = error_observation.clone(); @@ -1270,7 +1276,7 @@ pub fn execute_supervised( exit_code, &denials, &ipc_denials, - &sandbox_violations, + &visible_sandbox_violations, &error_observation, ); @@ -1308,6 +1314,9 @@ pub fn execute_supervised( .with_session_id(diag_session_id) .with_policy_explanations(policy_explanations) .with_suppressed_paths(config.ignored_denial_paths) + .with_suppressed_system_service_operations( + config.suppressed_system_service_operations, + ) .with_canonical_denial_paths(canonical_denial_paths); if let Some(program) = config.command.first() { formatter = formatter.with_command(nono::diagnostic::CommandContext { @@ -1325,7 +1334,7 @@ pub fn execute_supervised( exit_code, &prompt_policy_explanations, &prompt_error_observation, - &sandbox_violations, + &visible_sandbox_violations, ) { // Clear the forwarding target before prompting. The child is // already dead; keeping CHILD_PID set would cause forward_signal @@ -1339,7 +1348,7 @@ pub fn execute_supervised( caps: config.caps, command: config.command, compared_profile: config.profile_save_base, - sandbox_violations: &sandbox_violations, + sandbox_violations: &visible_sandbox_violations, ignored_denial_paths: config.ignored_denial_paths, }, )?; @@ -1484,6 +1493,24 @@ fn should_print_diagnostic_footer( || error_observation.has_findings()) } +fn filter_suppressed_system_service_violations( + sandbox_violations: &[nono::SandboxViolation], + suppressed_operations: &[String], +) -> Vec { + if suppressed_operations.is_empty() { + return sandbox_violations.to_vec(); + } + + sandbox_violations + .iter() + .filter(|violation| { + nono::diagnostic::seatbelt_operation_to_access(&violation.operation).is_some() + || !suppressed_operations.contains(&violation.operation) + }) + .cloned() + .collect() +} + fn should_offer_profile_save( no_diagnostics: bool, exit_code: i32, @@ -3656,6 +3683,27 @@ mod tests { )); } + #[test] + fn test_suppressed_system_service_violations_do_not_offer_profile_save() { + let explanations = Vec::new(); + let observation = nono::diagnostic::ErrorObservation::default(); + let violations = vec![nono::SandboxViolation { + operation: "forbidden-exec-sugid".to_string(), + target: None, + }]; + let suppressed = vec!["forbidden-exec-sugid".to_string()]; + let visible = filter_suppressed_system_service_violations(&violations, &suppressed); + + assert!(visible.is_empty()); + assert!(!should_offer_profile_save( + false, + 0, + &explanations, + &observation, + &visible, + )); + } + #[test] fn test_keychain_mach_violation_adds_profile_save_explanation() { let _env_lock = crate::test_env::ENV_LOCK.lock().expect("env lock"); diff --git a/crates/nono-cli/src/execution_runtime.rs b/crates/nono-cli/src/execution_runtime.rs index bd92d0b6d..3916e1297 100644 --- a/crates/nono-cli/src/execution_runtime.rs +++ b/crates/nono-cli/src/execution_runtime.rs @@ -384,6 +384,7 @@ pub(crate) fn execute_sandboxed(plan: LaunchPlan) -> Result<()> { .as_deref() .or(recommended_profile), ignored_denial_paths: &flags.ignored_denial_paths, + suppressed_system_service_operations: &flags.suppressed_system_service_operations, startup_timeout: flags .startup_timeout_secs .filter(|&secs| secs > 0) diff --git a/crates/nono-cli/src/launch_runtime.rs b/crates/nono-cli/src/launch_runtime.rs index c5ff8439a..57434a51b 100644 --- a/crates/nono-cli/src/launch_runtime.rs +++ b/crates/nono-cli/src/launch_runtime.rs @@ -105,6 +105,7 @@ pub(crate) struct ExecutionFlags { pub(crate) af_unix_mediation: crate::profile::LinuxAfUnixMediation, pub(crate) bypass_protection_paths: Vec, pub(crate) ignored_denial_paths: Vec, + pub(crate) suppressed_system_service_operations: Vec, pub(crate) session: SessionLaunchOptions, pub(crate) rollback: RollbackLaunchOptions, pub(crate) trust: TrustLaunchOptions, @@ -131,6 +132,7 @@ impl ExecutionFlags { af_unix_mediation: crate::profile::LinuxAfUnixMediation::Off, bypass_protection_paths: Vec::new(), ignored_denial_paths: Vec::new(), + suppressed_system_service_operations: Vec::new(), session: SessionLaunchOptions::default(), rollback: RollbackLaunchOptions::default(), trust: TrustLaunchOptions { @@ -256,6 +258,7 @@ pub(crate) fn prepare_run_launch_plan( af_unix_mediation: prepared.af_unix_mediation, bypass_protection_paths: prepared.bypass_protection_paths, ignored_denial_paths: prepared.ignored_denial_paths, + suppressed_system_service_operations: prepared.suppressed_system_service_operations, session: SessionLaunchOptions { detached_start: run_args.detached, session_name: run_args.name, diff --git a/crates/nono-cli/src/main.rs b/crates/nono-cli/src/main.rs index 0fc610acd..8d0a7a947 100644 --- a/crates/nono-cli/src/main.rs +++ b/crates/nono-cli/src/main.rs @@ -287,6 +287,7 @@ mod tests { open_url_allow_localhost: false, bypass_protection_paths: Vec::new(), ignored_denial_paths: Vec::new(), + suppressed_system_service_operations: Vec::new(), allowed_env_vars: None, denied_env_vars: None, profile_network_approval_mode: None, @@ -341,6 +342,7 @@ mod tests { open_url_allow_localhost: false, bypass_protection_paths: Vec::new(), ignored_denial_paths: Vec::new(), + suppressed_system_service_operations: Vec::new(), allowed_env_vars: None, denied_env_vars: None, profile_network_approval_mode: None, diff --git a/crates/nono-cli/src/policy.rs b/crates/nono-cli/src/policy.rs index da46b343d..ebff9585d 100644 --- a/crates/nono-cli/src/policy.rs +++ b/crates/nono-cli/src/policy.rs @@ -163,6 +163,7 @@ impl ProfileDef { commands: self.commands.clone(), filesystem: self.filesystem.clone(), network: self.network.clone(), + diagnostics: profile::DiagnosticsConfig::default(), linux: profile::LinuxConfig::default(), env_credentials: self.env_credentials.clone(), environment: None, diff --git a/crates/nono-cli/src/profile/mod.rs b/crates/nono-cli/src/profile/mod.rs index 39eab8a0e..40d224fa7 100644 --- a/crates/nono-cli/src/profile/mod.rs +++ b/crates/nono-cli/src/profile/mod.rs @@ -1353,6 +1353,19 @@ impl LinuxAfUnixMediation { } } +/// Diagnostic output controls. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct DiagnosticsConfig { + /// Non-filesystem sandbox operations to suppress from diagnostic footers. + /// + /// This does not grant access and does not change sandbox enforcement. It + /// only hides recurring non-actionable system-service diagnostics, such as + /// `forbidden-exec-sugid`, from post-run output. + #[serde(default)] + pub suppress_system_services: Vec, +} + /// Linux-specific profile controls. #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] @@ -1550,6 +1563,8 @@ pub struct Profile { #[serde(default)] pub network: NetworkConfig, #[serde(default)] + pub diagnostics: DiagnosticsConfig, + #[serde(default)] pub linux: LinuxConfig, /// ALIAS(canonical="env_credentials", introduced="v0.0.0", remove_by="indefinite", issue="#143") #[serde(default, alias = "secrets")] @@ -1648,6 +1663,8 @@ struct ProfileDeserialize { #[serde(default)] network: NetworkConfig, #[serde(default)] + diagnostics: DiagnosticsConfig, + #[serde(default)] linux: LinuxConfig, /// ALIAS(canonical="env_credentials", introduced="v0.0.0", remove_by="indefinite", issue="#143") #[serde(default, alias = "secrets")] @@ -1701,6 +1718,7 @@ impl From for Profile { commands: raw.commands, filesystem: raw.filesystem, network: raw.network, + diagnostics: raw.diagnostics, linux: raw.linux, env_credentials: raw.env_credentials, environment: raw.environment, @@ -2666,6 +2684,12 @@ fn merge_profiles(base: Profile, child: Profile) -> Profile { .af_unix_mediation .or(base.linux.af_unix_mediation), }, + diagnostics: DiagnosticsConfig { + suppress_system_services: dedup_append( + &base.diagnostics.suppress_system_services, + &child.diagnostics.suppress_system_services, + ), + }, env_credentials: SecretsConfig { mappings: { let mut merged = base.env_credentials.mappings; @@ -4661,6 +4685,7 @@ mod tests { approval_mode: None, approval_timeout_secs: None, }, + diagnostics: DiagnosticsConfig::default(), linux: LinuxConfig::default(), env_credentials: SecretsConfig { mappings: { @@ -4744,6 +4769,7 @@ mod tests { approval_mode: None, approval_timeout_secs: None, }, + diagnostics: DiagnosticsConfig::default(), linux: LinuxConfig::default(), env_credentials: SecretsConfig { mappings: { @@ -5702,6 +5728,29 @@ mod tests { assert!(creds.contains(&"child_cred".to_string())); } + #[test] + fn test_merge_profiles_diagnostics_suppressions_append() { + let mut base = base_profile(); + base.diagnostics.suppress_system_services = vec![ + "forbidden-exec-sugid".to_string(), + "mach-lookup".to_string(), + ]; + let mut child = child_profile(); + child.diagnostics.suppress_system_services = + vec!["forbidden-exec-sugid".to_string(), "signal".to_string()]; + + let merged = merge_profiles(base, child); + + assert_eq!( + merged.diagnostics.suppress_system_services, + vec![ + "forbidden-exec-sugid".to_string(), + "mach-lookup".to_string(), + "signal".to_string(), + ] + ); + } + #[test] fn test_credentials_none_does_not_activate_proxy() { let mut config = NetworkConfig::default(); diff --git a/crates/nono-cli/src/profile_runtime.rs b/crates/nono-cli/src/profile_runtime.rs index acdce4794..107882468 100644 --- a/crates/nono-cli/src/profile_runtime.rs +++ b/crates/nono-cli/src/profile_runtime.rs @@ -29,6 +29,7 @@ pub(crate) struct PreparedProfile { pub(crate) allow_parent_of_protected: bool, pub(crate) bypass_protection_paths: Vec, pub(crate) ignored_denial_paths: Vec, + pub(crate) suppressed_system_service_operations: Vec, pub(crate) allowed_env_vars: Option>, pub(crate) denied_env_vars: Option>, pub(crate) network_approval_mode: Option, @@ -575,6 +576,10 @@ fn prepare_profile_with_options( &args.suppress_save_prompt, workdir, ), + suppressed_system_service_operations: loaded_profile + .as_ref() + .map(|profile| profile.diagnostics.suppress_system_services.clone()) + .unwrap_or_default(), allowed_env_vars: loaded_profile.as_ref().and_then(|profile| { profile.environment.as_ref().map(|env_config| { if let Some(err) = crate::exec_strategy::validate_env_var_patterns( diff --git a/crates/nono-cli/src/sandbox_prepare.rs b/crates/nono-cli/src/sandbox_prepare.rs index d86b9e281..570455b3c 100644 --- a/crates/nono-cli/src/sandbox_prepare.rs +++ b/crates/nono-cli/src/sandbox_prepare.rs @@ -439,6 +439,7 @@ pub(crate) struct PreparedSandbox { pub(crate) open_url_allow_localhost: bool, pub(crate) bypass_protection_paths: Vec, pub(crate) ignored_denial_paths: Vec, + pub(crate) suppressed_system_service_operations: Vec, pub(crate) allowed_env_vars: Option>, pub(crate) denied_env_vars: Option>, pub(crate) profile_network_approval_mode: Option, @@ -1070,6 +1071,7 @@ pub(crate) fn prepare_sandbox(args: &SandboxArgs, silent: bool) -> Result Result Result { /// denied paths with `[save skipped]` so the diagnostic footer is /// self-explanatory without requiring the user to cross-reference their profile. suppressed_paths: &'a [PathBuf], + /// Non-filesystem sandbox operations suppressed from the diagnostic footer. + /// The sandbox still denies these operations; this only controls reporting. + suppressed_system_service_operations: &'a [String], /// Canonicalized forms of the denied paths, parallel to `denials`. When /// provided, used in place of on-demand `try_canonicalize` calls inside the /// render loop so filesystem I/O is done once (by the caller, after the @@ -686,6 +689,7 @@ impl<'a> DiagnosticFormatter<'a> { session_id: None, policy_explanations: Vec::new(), suppressed_paths: &[], + suppressed_system_service_operations: &[], canonical_denial_paths: Vec::new(), } } @@ -711,6 +715,13 @@ impl<'a> DiagnosticFormatter<'a> { self } + /// Set non-filesystem sandbox operations suppressed from the diagnostic footer. + #[must_use] + pub fn with_suppressed_system_service_operations(mut self, operations: &'a [String]) -> Self { + self.suppressed_system_service_operations = operations; + self + } + /// Set pre-canonicalized forms of the denied paths to avoid repeated /// calling of `try_canonicalize` at render time. #[must_use] @@ -1139,7 +1150,13 @@ impl<'a> DiagnosticFormatter<'a> { // Convert macOS Seatbelt violations (operation + target) into // DenialRecords so the same rendering logic handles both platforms. - let (violation_denials, non_fs_violations) = violations_to_denials(self.sandbox_violations); + let (violation_denials, mut non_fs_violations) = + violations_to_denials(self.sandbox_violations); + non_fs_violations.retain(|violation| { + !self + .suppressed_system_service_operations + .contains(&violation.operation) + }); // Merge supervisor denials (Linux seccomp) with violation-derived // denials (macOS Seatbelt) into a single unified list. @@ -3258,6 +3275,31 @@ mod tests { assert!(!output.contains("unsafe_macos_seatbelt_rules")); } + #[test] + fn test_suppressed_system_service_violation_is_hidden_from_footer() { + let caps = make_test_caps(); + let violations = vec![ + SandboxViolation { + operation: "forbidden-exec-sugid".to_string(), + target: None, + }, + SandboxViolation { + operation: "mach-lookup".to_string(), + target: Some("com.apple.logd".to_string()), + }, + ]; + let suppressed = vec!["forbidden-exec-sugid".to_string()]; + let formatter = DiagnosticFormatter::new(&caps) + .with_mode(DiagnosticMode::Supervised) + .with_sandbox_violations(&violations) + .with_suppressed_system_service_operations(&suppressed); + let output = formatter.format_footer(1); + + assert!(!output.contains("forbidden-exec-sugid")); + assert!(!output.contains("Setuid/setgid executable blocked")); + assert!(output.contains("mach-lookup (com.apple.logd)")); + } + #[test] fn test_supervised_policy_blocked_denial() { let caps = make_test_caps(); From 051a75154b13617817342e05f8c692412608c934 Mon Sep 17 00:00:00 2001 From: Luke Hinds Date: Tue, 2 Jun 2026 06:12:09 +0100 Subject: [PATCH 08/91] chore: release v0.61.0 --- CHANGELOG.md | 11 +++++++++++ Cargo.lock | 6 +++--- bindings/c/Cargo.toml | 2 +- crates/nono-cli/Cargo.toml | 6 +++--- crates/nono-proxy/Cargo.toml | 4 ++-- crates/nono/Cargo.toml | 2 +- 6 files changed, 21 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a80224d3a..b24fa7ba5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## [0.61.0] - 2026-06-02 + +### Features + +- *(diagnostic)* Add profile option to suppress system service diagnostics (#1059) ([#1059](https://github.com/always-further/nono/pull/1059)) + + +### Refactoring + +- *(network-policy)* Do not enable credentials by default in profiles + ## [0.60.0] - 2026-06-01 ### Bug Fixes diff --git a/Cargo.lock b/Cargo.lock index 25b3516c7..7593fe185 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2161,7 +2161,7 @@ dependencies = [ [[package]] name = "nono" -version = "0.60.0" +version = "0.61.0" dependencies = [ "der", "getrandom 0.4.2", @@ -2191,7 +2191,7 @@ dependencies = [ [[package]] name = "nono-cli" -version = "0.60.0" +version = "0.61.0" dependencies = [ "aws-lc-rs", "chrono", @@ -2247,7 +2247,7 @@ dependencies = [ [[package]] name = "nono-proxy" -version = "0.60.0" +version = "0.61.0" dependencies = [ "base64", "getrandom 0.4.2", diff --git a/bindings/c/Cargo.toml b/bindings/c/Cargo.toml index 7336dff20..6864c176e 100644 --- a/bindings/c/Cargo.toml +++ b/bindings/c/Cargo.toml @@ -15,7 +15,7 @@ name = "nono_ffi" crate-type = ["cdylib", "staticlib"] [dependencies] -nono = { version = "0.60.0", path = "../../crates/nono", default-features = false } +nono = { version = "0.61.0", path = "../../crates/nono", default-features = false } [build-dependencies] cbindgen.workspace = true diff --git a/crates/nono-cli/Cargo.toml b/crates/nono-cli/Cargo.toml index 7c65c46f9..6c69ba121 100644 --- a/crates/nono-cli/Cargo.toml +++ b/crates/nono-cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "nono-cli" -version = "0.60.0" +version = "0.61.0" edition.workspace = true rust-version.workspace = true authors.workspace = true @@ -41,8 +41,8 @@ system-keyring = ["dep:keyring", "nono/system-keyring", "nono-proxy/system-keyri test-trust-overrides = [] [dependencies] -nono = { version = "0.60.0", path = "../nono", default-features = false } -nono-proxy = { version = "0.60.0", path = "../nono-proxy", default-features = false } +nono = { version = "0.61.0", path = "../nono", default-features = false } +nono-proxy = { version = "0.61.0", path = "../nono-proxy", default-features = false } clap = { version = "4", features = ["derive", "env"] } clap_complete = "4" colored = "3" diff --git a/crates/nono-proxy/Cargo.toml b/crates/nono-proxy/Cargo.toml index 1717b8e3c..48c6c94e6 100644 --- a/crates/nono-proxy/Cargo.toml +++ b/crates/nono-proxy/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "nono-proxy" -version = "0.60.0" +version = "0.61.0" edition.workspace = true rust-version.workspace = true authors.workspace = true @@ -18,7 +18,7 @@ default = ["system-keyring"] system-keyring = ["nono/system-keyring"] [dependencies] -nono = { version = "0.60.0", path = "../nono", default-features = false } +nono = { version = "0.61.0", path = "../nono", default-features = false } tokio = { workspace = true, features = ["sync", "time"] } hyper.workspace = true hyper-util.workspace = true diff --git a/crates/nono/Cargo.toml b/crates/nono/Cargo.toml index 768ac2ad0..cbbaf0bce 100644 --- a/crates/nono/Cargo.toml +++ b/crates/nono/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "nono" -version = "0.60.0" +version = "0.61.0" edition.workspace = true rust-version.workspace = true authors.workspace = true From 90c0c32516ae5573cffd517be32d0043c630c916 Mon Sep 17 00:00:00 2001 From: Luke Hinds Date: Tue, 2 Jun 2026 10:31:20 +0100 Subject: [PATCH 09/91] feat(profile): allow registry refs in profile extends (#1061) * feat(profile): allow registry refs in profile extends When saving a profile that extends from a registry reference, the `extends` field was previously filtered out if it wasn't a local profile name. This change updates the `prepare_profile_save_from_patch` function to properly recognize and preserve registry references (e.g., "always-further/claude" or "always-further/claude@1.2.0"). New tests ensure that: - Registry references are correctly preserved in the `extends` field. - Versioned registry references are also preserved. - Self-referencing profiles are still avoided. Signed-off-by: Luke Hinds * test(sandbox): anchor temporary directories for stability Replace `tempdir()` with `tempdir_in("/tmp")` in `sandbox_state` tests to improve their reliability. This prevents parallel tests from interfering with each other by modifying the `$TMPDIR` environment variable. When `$TMPDIR` is altered, a `tempdir()` call in one test might create its directory in a location that another test's `TempDir` drop operation subsequently removes. This could lead to `ENOENT` errors during `canonicalize` before the intended test assertion is reached. By explicitly anchoring these temporary directories under `/tmp`, the tests become immune to `$TMPDIR` changes by other parallel tests, enhancing their robustness and stability. Signed-off-by: Luke Hinds --------- Signed-off-by: Luke Hinds --- crates/nono-cli/src/profile_save_runtime.rs | 94 ++++++++++++++++++++- crates/nono-cli/src/sandbox_state.rs | 14 +-- 2 files changed, 102 insertions(+), 6 deletions(-) diff --git a/crates/nono-cli/src/profile_save_runtime.rs b/crates/nono-cli/src/profile_save_runtime.rs index 2f3248dd5..e64b45559 100644 --- a/crates/nono-cli/src/profile_save_runtime.rs +++ b/crates/nono-cli/src/profile_save_runtime.rs @@ -1364,7 +1364,10 @@ pub(crate) fn prepare_profile_save_from_patch( let profile_path = profile::get_user_profile_path(profile_name)?; let mut new_profile = patch.clone(); let extends = compared_profile - .filter(|name| profile::is_valid_profile_name(name) && *name != profile_name) + .filter(|name| { + (profile::is_valid_profile_name(name) || profile::is_registry_ref(name)) + && *name != profile_name + }) .map(|name| vec![name.to_string()]); let has_base = extends.is_some(); let suppression_only = patch_is_suppression_only(patch); @@ -2167,6 +2170,95 @@ mod tests { assert!(prepared.profile.filesystem.read_file.is_empty()); } + #[test] + fn prepare_profile_save_from_patch_preserves_registry_ref_as_extends() { + let _env_lock = ENV_LOCK.lock().expect("env lock"); + let temp_home = TempDir::new().expect("temp home"); + let temp_config = TempDir::new().expect("temp config"); + let _env = EnvVarGuard::set_all(&[ + ("HOME", temp_home.path().to_str().expect("home path")), + ( + "XDG_CONFIG_HOME", + temp_config.path().to_str().expect("config path"), + ), + ]); + + let patch = profile::Profile { + filesystem: profile::FilesystemConfig { + suppress_save_prompt: vec!["~/.copilot/settings.json".to_string()], + ..Default::default() + }, + ..Default::default() + }; + + let prepared = prepare_profile_save_from_patch( + &patch, + "claude", + "claude-test", + Some("always-further/claude"), + ) + .expect("prepare"); + + assert!(matches!(prepared.action, SaveAction::Created)); + assert_eq!( + prepared.profile.extends, + Some(vec!["always-further/claude".to_string()]) + ); + } + + #[test] + fn prepare_profile_save_from_patch_preserves_versioned_registry_ref() { + let _env_lock = ENV_LOCK.lock().expect("env lock"); + let temp_home = TempDir::new().expect("temp home"); + let temp_config = TempDir::new().expect("temp config"); + let _env = EnvVarGuard::set_all(&[ + ("HOME", temp_home.path().to_str().expect("home path")), + ( + "XDG_CONFIG_HOME", + temp_config.path().to_str().expect("config path"), + ), + ]); + + let mut patch = profile::Profile::default(); + patch.filesystem.read = vec!["~/workspace".to_string()]; + + let prepared = prepare_profile_save_from_patch( + &patch, + "claude", + "claude-test", + Some("always-further/claude@1.2.0"), + ) + .expect("prepare"); + + assert_eq!( + prepared.profile.extends, + Some(vec!["always-further/claude@1.2.0".to_string()]) + ); + } + + #[test] + fn prepare_profile_save_from_patch_still_avoids_self_reference() { + let _env_lock = ENV_LOCK.lock().expect("env lock"); + let temp_home = TempDir::new().expect("temp home"); + let temp_config = TempDir::new().expect("temp config"); + let _env = EnvVarGuard::set_all(&[ + ("HOME", temp_home.path().to_str().expect("home path")), + ( + "XDG_CONFIG_HOME", + temp_config.path().to_str().expect("config path"), + ), + ]); + + let mut patch = profile::Profile::default(); + patch.filesystem.read = vec!["~/workspace".to_string()]; + + let prepared = + prepare_profile_save_from_patch(&patch, "claude", "my-profile", Some("my-profile")) + .expect("prepare"); + + assert!(prepared.profile.extends.is_none()); + } + #[test] fn would_shadow_existing_profile_flags_known_builtin_names() { let _env_lock = ENV_LOCK.lock().expect("env lock"); diff --git a/crates/nono-cli/src/sandbox_state.rs b/crates/nono-cli/src/sandbox_state.rs index 0757bf29c..11447ac61 100644 --- a/crates/nono-cli/src/sandbox_state.rs +++ b/crates/nono-cli/src/sandbox_state.rs @@ -675,7 +675,6 @@ mod tests { mod cap_file_validation_tests { use super::*; use crate::test_env::{ENV_LOCK, EnvVarGuard}; - use tempfile::tempdir; /// Write a valid `.nono-.json` capability file into `dir`. fn write_cap_file(dir: &Path) -> PathBuf { @@ -718,7 +717,7 @@ mod cap_file_validation_tests { #[test] fn test_validate_accepts_file_in_tempdir() { - let dir = tempdir().expect("tempdir"); + let dir = tempfile::tempdir_in("/tmp").expect("tempdir"); let path = write_cap_file(dir.path()); let path_str = path.to_str().expect("utf8 path"); @@ -766,7 +765,7 @@ mod cap_file_validation_tests { #[test] fn test_validate_rejects_nonexistent_file() { - let dir = tempdir().expect("tempdir"); + let dir = tempfile::tempdir_in("/tmp").expect("tempdir"); let missing = dir.path().join(".nono-0000000000000000.json"); let err = validate_cap_file_path(missing.to_str().expect("utf8")) .expect_err("nonexistent file rejected"); @@ -791,7 +790,12 @@ mod cap_file_validation_tests { #[test] fn test_validate_rejects_wrong_filename_pattern() { - let dir = tempdir().expect("tempdir"); + // Anchor under `/tmp` so the test is immune to parallel tests that + // modify `$TMPDIR` — a bare `tempdir()` would inherit the altered + // `$TMPDIR` and the backing directory may be deleted by the other + // test's TempDir drop, causing `canonicalize` to fail with ENOENT + // before the filename-pattern check is reached. + let dir = tempfile::tempdir_in("/tmp").expect("tempdir"); let caps = CapabilitySet::new().block_network(); let state = SandboxState::from_caps(&caps, &[], &[], &[]); let path = dir.path().join("not-a-nono-file.json"); @@ -807,7 +811,7 @@ mod cap_file_validation_tests { #[test] fn test_validate_rejects_directory() { - let dir = tempdir().expect("tempdir"); + let dir = tempfile::tempdir_in("/tmp").expect("tempdir"); let err = validate_cap_file_path(dir.path().to_str().expect("utf8")) .expect_err("directory rejected"); // A directory in a temp root fails the filename-pattern check before the From de9b9a142acba061d3d492628d50257e98833dcb Mon Sep 17 00:00:00 2001 From: Luke Hinds Date: Tue, 2 Jun 2026 10:32:16 +0100 Subject: [PATCH 10/91] chore: release v0.61.1 --- CHANGELOG.md | 6 ++++++ Cargo.lock | 6 +++--- bindings/c/Cargo.toml | 2 +- crates/nono-cli/Cargo.toml | 6 +++--- crates/nono-proxy/Cargo.toml | 4 ++-- crates/nono/Cargo.toml | 2 +- 6 files changed, 16 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b24fa7ba5..9ff151c59 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## [0.61.1] - 2026-06-02 + +### Features + +- *(profile)* Allow registry refs in profile extends (#1061) ([#1061](https://github.com/always-further/nono/pull/1061)) + ## [0.61.0] - 2026-06-02 ### Features diff --git a/Cargo.lock b/Cargo.lock index 7593fe185..7b2c83b49 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2161,7 +2161,7 @@ dependencies = [ [[package]] name = "nono" -version = "0.61.0" +version = "0.61.1" dependencies = [ "der", "getrandom 0.4.2", @@ -2191,7 +2191,7 @@ dependencies = [ [[package]] name = "nono-cli" -version = "0.61.0" +version = "0.61.1" dependencies = [ "aws-lc-rs", "chrono", @@ -2247,7 +2247,7 @@ dependencies = [ [[package]] name = "nono-proxy" -version = "0.61.0" +version = "0.61.1" dependencies = [ "base64", "getrandom 0.4.2", diff --git a/bindings/c/Cargo.toml b/bindings/c/Cargo.toml index 6864c176e..1a9cc669e 100644 --- a/bindings/c/Cargo.toml +++ b/bindings/c/Cargo.toml @@ -15,7 +15,7 @@ name = "nono_ffi" crate-type = ["cdylib", "staticlib"] [dependencies] -nono = { version = "0.61.0", path = "../../crates/nono", default-features = false } +nono = { version = "0.61.1", path = "../../crates/nono", default-features = false } [build-dependencies] cbindgen.workspace = true diff --git a/crates/nono-cli/Cargo.toml b/crates/nono-cli/Cargo.toml index 6c69ba121..1d0e70110 100644 --- a/crates/nono-cli/Cargo.toml +++ b/crates/nono-cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "nono-cli" -version = "0.61.0" +version = "0.61.1" edition.workspace = true rust-version.workspace = true authors.workspace = true @@ -41,8 +41,8 @@ system-keyring = ["dep:keyring", "nono/system-keyring", "nono-proxy/system-keyri test-trust-overrides = [] [dependencies] -nono = { version = "0.61.0", path = "../nono", default-features = false } -nono-proxy = { version = "0.61.0", path = "../nono-proxy", default-features = false } +nono = { version = "0.61.1", path = "../nono", default-features = false } +nono-proxy = { version = "0.61.1", path = "../nono-proxy", default-features = false } clap = { version = "4", features = ["derive", "env"] } clap_complete = "4" colored = "3" diff --git a/crates/nono-proxy/Cargo.toml b/crates/nono-proxy/Cargo.toml index 48c6c94e6..ef7e62a3d 100644 --- a/crates/nono-proxy/Cargo.toml +++ b/crates/nono-proxy/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "nono-proxy" -version = "0.61.0" +version = "0.61.1" edition.workspace = true rust-version.workspace = true authors.workspace = true @@ -18,7 +18,7 @@ default = ["system-keyring"] system-keyring = ["nono/system-keyring"] [dependencies] -nono = { version = "0.61.0", path = "../nono", default-features = false } +nono = { version = "0.61.1", path = "../nono", default-features = false } tokio = { workspace = true, features = ["sync", "time"] } hyper.workspace = true hyper-util.workspace = true diff --git a/crates/nono/Cargo.toml b/crates/nono/Cargo.toml index cbbaf0bce..bdb9755aa 100644 --- a/crates/nono/Cargo.toml +++ b/crates/nono/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "nono" -version = "0.61.0" +version = "0.61.1" edition.workspace = true rust-version.workspace = true authors.workspace = true From b3a4997e2a51a48146dbe647fcc5cf0bd7ef655e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 2 Jun 2026 19:43:30 +0000 Subject: [PATCH 11/91] chore(deps): bump rustls-native-certs from 0.8.3 to 0.8.4 Bumps [rustls-native-certs](https://github.com/rustls/rustls-native-certs) from 0.8.3 to 0.8.4. - [Release notes](https://github.com/rustls/rustls-native-certs/releases) - [Commits](https://github.com/rustls/rustls-native-certs/compare/v/0.8.3...v/0.8.4) --- updated-dependencies: - dependency-name: rustls-native-certs dependency-version: 0.8.4 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- Cargo.lock | 34 +++++++++++++++++----------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7b2c83b49..da32f9bf7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -107,7 +107,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -118,7 +118,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -707,7 +707,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -1012,7 +1012,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -1099,7 +1099,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -1809,7 +1809,7 @@ dependencies = [ "portable-atomic", "portable-atomic-util", "serde_core", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -2283,7 +2283,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3062,7 +3062,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -3083,9 +3083,9 @@ dependencies = [ [[package]] name = "rustls-native-certs" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" dependencies = [ "openssl-probe", "rustls-pki-types", @@ -3121,7 +3121,7 @@ dependencies = [ "security-framework 3.7.0", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -3142,7 +3142,7 @@ dependencies = [ "security-framework 3.7.0", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -3816,7 +3816,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -3891,10 +3891,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.2", "once_cell", "rustix", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -4368,7 +4368,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -4750,7 +4750,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] From 56ee0ee914d55cdf273ef324bff08405d873f7a4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 2 Jun 2026 19:42:51 +0000 Subject: [PATCH 12/91] chore(deps): bump jsonschema from 0.46.4 to 0.46.5 Bumps [jsonschema](https://github.com/Stranger6667/jsonschema) from 0.46.4 to 0.46.5. - [Release notes](https://github.com/Stranger6667/jsonschema/releases) - [Changelog](https://github.com/Stranger6667/jsonschema/blob/master/CHANGELOG.md) - [Commits](https://github.com/Stranger6667/jsonschema/compare/ruby-v0.46.4...ruby-v0.46.5) --- updated-dependencies: - dependency-name: jsonschema dependency-version: 0.46.5 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- Cargo.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index da32f9bf7..dd1659341 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1945,9 +1945,9 @@ dependencies = [ [[package]] name = "jsonschema" -version = "0.46.4" +version = "0.46.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc59d2432e047d6090ba1d83c782d0128bd6203857978218f5614dbd3287281f" +checksum = "6a5fe5206f06e589caf25e79fc05ccdf91fca745685fe9fe1a13bbdfb479a631" dependencies = [ "ahash", "bytecount", @@ -2878,9 +2878,9 @@ dependencies = [ [[package]] name = "referencing" -version = "0.46.4" +version = "0.46.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb674900ca31acd75c4aaf63f48e43e719631c0539ea5a9e64163d1296bcb730" +checksum = "69e4e17ef386c5383591d07623d3de49cbc601156e7582973e6db98d66a57de2" dependencies = [ "ahash", "fluent-uri", From 67262e9974ed88ca9581acc8090796386a01d9a7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 2 Jun 2026 19:42:25 +0000 Subject: [PATCH 13/91] chore(deps): bump docker/setup-qemu-action from 4.0.0 to 4.1.0 Bumps [docker/setup-qemu-action](https://github.com/docker/setup-qemu-action) from 4.0.0 to 4.1.0. - [Release notes](https://github.com/docker/setup-qemu-action/releases) - [Commits](https://github.com/docker/setup-qemu-action/compare/ce360397dd3f832beb865e1373c09c0e9f86d70a...06116385d9baf250c9f4dcb4858b16962ea869c3) --- updated-dependencies: - dependency-name: docker/setup-qemu-action dependency-version: 4.1.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- .github/workflows/image-build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/image-build.yml b/.github/workflows/image-build.yml index ee25feaed..bdfcc90cd 100644 --- a/.github/workflows/image-build.yml +++ b/.github/workflows/image-build.yml @@ -187,7 +187,7 @@ jobs: echo "tags=$TAGS" >> "$GITHUB_OUTPUT" - name: Set up QEMU - uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0 + uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 # v4.1.0 - name: Set up Docker Buildx uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 From ceae4e9f548d7a1017d30ca182a17bb015cbc09e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 2 Jun 2026 19:42:32 +0000 Subject: [PATCH 14/91] chore(deps): bump actions/checkout from 6.0.2 to 6.0.3 Bumps [actions/checkout](https://github.com/actions/checkout) from 6.0.2 to 6.0.3. - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/de0fac2e4500dabe0009e67214ff5f5447ce83dd...df4cb1c069e1874edd31b4311f1884172cec0e10) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: 6.0.3 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- .github/workflows/ci.yml | 16 ++++++++-------- .github/workflows/image-build.yml | 4 ++-- .github/workflows/release.yml | 6 +++--- .github/workflows/sign-instruction-files.yml | 2 +- 4 files changed, 14 insertions(+), 14 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c4611c8c6..d51150d8f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,7 +21,7 @@ jobs: run_code_jobs: ${{ steps.classify.outputs.run_code_jobs }} run_docs_checks: ${{ steps.classify.outputs.run_docs_checks }} steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: fetch-depth: 0 @@ -96,7 +96,7 @@ jobs: matrix: os: [ubuntu-latest, macos-latest] steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Install Rust toolchain uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable @@ -141,7 +141,7 @@ jobs: if: ${{ needs.changes.outputs.run_code_jobs == 'true' }} runs-on: ubuntu-latest steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Install Rust toolchain uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable @@ -158,7 +158,7 @@ jobs: if: ${{ !startsWith(github.head_ref, 'dependabot/github_actions/') && needs.changes.outputs.run_code_jobs == 'true' }} runs-on: ubuntu-latest steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Install Rust toolchain uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable @@ -186,7 +186,7 @@ jobs: matrix: os: [ubuntu-latest, macos-latest] steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Install Rust toolchain uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable @@ -203,7 +203,7 @@ jobs: if: ${{ !startsWith(github.head_ref, 'dependabot/github_actions/') && needs.changes.outputs.run_code_jobs == 'true' }} runs-on: macos-latest steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Install Rust toolchain uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable @@ -238,7 +238,7 @@ jobs: if: ${{ needs.changes.outputs.run_code_jobs == 'true' }} runs-on: ubuntu-latest steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Install Rust toolchain uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable @@ -257,7 +257,7 @@ jobs: if: ${{ needs.changes.outputs.run_docs_checks == 'true' }} runs-on: ubuntu-latest steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Verify docs routes are canonical run: | diff --git a/.github/workflows/image-build.yml b/.github/workflows/image-build.yml index bdfcc90cd..457a9570f 100644 --- a/.github/workflows/image-build.yml +++ b/.github/workflows/image-build.yml @@ -83,7 +83,7 @@ jobs: - target: aarch64-unknown-linux-gnu output_name: nono-arm64 steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: ref: ${{ needs.resolve.outputs.ref }} @@ -124,7 +124,7 @@ jobs: packages: write id-token: write steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: ref: ${{ needs.resolve.outputs.ref }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6701d29c0..85f1a67b5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -48,7 +48,7 @@ jobs: artifact: nono steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: ref: ${{ env.RELEASE_TAG }} @@ -180,7 +180,7 @@ jobs: permissions: contents: write steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: ref: ${{ env.RELEASE_TAG }} @@ -217,7 +217,7 @@ jobs: # Only publish stable releases to crates.io if: ${{ !contains(github.event.inputs.tag || github.ref_name, 'alpha') && !contains(github.event.inputs.tag || github.ref_name, 'beta') && !contains(github.event.inputs.tag || github.ref_name, 'rc') }} steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: ref: ${{ env.RELEASE_TAG }} diff --git a/.github/workflows/sign-instruction-files.yml b/.github/workflows/sign-instruction-files.yml index 14218f2c8..a74ac8787 100644 --- a/.github/workflows/sign-instruction-files.yml +++ b/.github/workflows/sign-instruction-files.yml @@ -30,5 +30,5 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: always-further/agent-sign@c9426f1a00f823dd53e7b9068d910a68b43e49e9 # v0.0.11 From d748f7f09f5faef4d8537942dcf2ab24be5893dc Mon Sep 17 00:00:00 2001 From: Caio Silva Date: Fri, 5 Jun 2026 09:28:12 +0100 Subject: [PATCH 15/91] fix(proxy): deny-by-default when network.block is set (#1082) When the user requested network.block (or --block-net) but the proxy must still run to service explicit credential routes, the host filter was falling back to allow-all for any host not in allowed_hosts. This let HTTPS traffic to arbitrary hosts (e.g. https://example.com) succeed even though the user asked to block everything. Add a strict mode to the host filter that denies on empty allowlist, opt-in via ProxyConfig.strict_filter. Thread the user's block intent from PreparedSandbox -> ProxyLaunchOptions -> ProxyConfig so the proxy filter honors it. Backward compatible: HostFilter::new() and ProxyConfig.strict_filter default to the historical behavior. Only flips when block was requested. Closes #1080 Signed-off-by: Caio Silva --- Cargo.lock | 350 +++++++++++++++++++++++-- crates/nono-cli/src/launch_runtime.rs | 4 + crates/nono-cli/src/main.rs | 2 + crates/nono-cli/src/proxy_runtime.rs | 31 +++ crates/nono-cli/src/sandbox_prepare.rs | 14 + crates/nono-proxy/src/config.rs | 8 +- crates/nono-proxy/src/filter.rs | 8 + crates/nono-proxy/src/server.rs | 49 +++- crates/nono/src/net_filter.rs | 44 +++- 9 files changed, 486 insertions(+), 24 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index dd1659341..ef6c662fe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -473,6 +473,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + [[package]] name = "blocking" version = "1.6.2" @@ -600,7 +609,7 @@ dependencies = [ "num-traits", "serde", "wasm-bindgen", - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -1015,6 +1024,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags", + "objc2", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -1591,7 +1610,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core", + "windows-core 0.62.2", ] [[package]] @@ -1868,7 +1887,7 @@ dependencies = [ "simd_cesu8", "thiserror 2.0.18", "walkdir", - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -1983,7 +2002,7 @@ dependencies = [ "secret-service", "security-framework 2.11.1", "security-framework 3.7.0", - "zbus", + "zbus 4.4.0", "zeroize", ] @@ -2067,6 +2086,20 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +[[package]] +name = "mac-notification-sys" +version = "0.6.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd604973958ddcc11b561193c0fb96ba146506ef2f231ef2e7c35fd2cbc9beca" +dependencies = [ + "cc", + "log", + "objc2", + "objc2-foundation", + "time", + "uuid", +] + [[package]] name = "matchers" version = "0.2.0" @@ -2200,6 +2233,7 @@ dependencies = [ "colored", "dirs", "dns-lookup", + "futures", "globset", "ignore", "jsonc-parser", @@ -2209,6 +2243,7 @@ dependencies = [ "nix 0.31.3", "nono", "nono-proxy", + "notify-rust", "proptest", "rand 0.10.1", "security-framework 3.7.0", @@ -2277,6 +2312,20 @@ dependencies = [ "zeroize", ] +[[package]] +name = "notify-rust" +version = "4.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5b4c1b4f2aa9f25f63a7a49d3dd0ed567b3670da15330a66b29434be899b891" +dependencies = [ + "futures-lite", + "log", + "mac-notification-sys", + "serde", + "tauri-winrt-notification", + "zbus 5.16.0", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -2371,6 +2420,45 @@ dependencies = [ "autocfg", ] +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags", + "block2", + "libc", + "objc2", + "objc2-core-foundation", +] + [[package]] name = "oid-registry" version = "0.7.1" @@ -2477,7 +2565,7 @@ dependencies = [ "libc", "redox_syscall", "smallvec", - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -3266,7 +3354,7 @@ dependencies = [ "rand 0.8.6", "serde", "sha2 0.10.9", - "zbus", + "zbus 4.4.0", ] [[package]] @@ -3884,6 +3972,17 @@ dependencies = [ "syn", ] +[[package]] +name = "tauri-winrt-notification" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed071c670382e85fc2f48ae706492d8c338f4f89bf72520d32f8abfe880aade" +dependencies = [ + "thiserror 2.0.18", + "windows", + "windows-version", +] + [[package]] name = "tempfile" version = "3.27.0" @@ -4494,6 +4593,18 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "uuid" +version = "1.23.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "serde_core", + "wasm-bindgen", +] + [[package]] name = "uuid-simd" version = "0.8.0" @@ -4753,6 +4864,41 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "windows" +version = "0.61.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +dependencies = [ + "windows-collections", + "windows-core 0.61.2", + "windows-future", + "windows-link 0.1.3", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +dependencies = [ + "windows-core 0.61.2", +] + +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + [[package]] name = "windows-core" version = "0.62.2" @@ -4761,9 +4907,20 @@ checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ "windows-implement", "windows-interface", - "windows-link", - "windows-result", - "windows-strings", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-future" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", + "windows-threading", ] [[package]] @@ -4788,19 +4945,53 @@ dependencies = [ "syn", ] +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-numerics" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", +] + [[package]] name = "windows-result" version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" dependencies = [ - "windows-link", + "windows-link 0.2.1", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", ] [[package]] @@ -4809,7 +5000,7 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" dependencies = [ - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -4854,7 +5045,7 @@ version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -4894,7 +5085,7 @@ version = "0.53.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" dependencies = [ - "windows-link", + "windows-link 0.2.1", "windows_aarch64_gnullvm 0.53.1", "windows_aarch64_msvc 0.53.1", "windows_i686_gnu 0.53.1", @@ -4905,6 +5096,24 @@ dependencies = [ "windows_x86_64_msvc 0.53.1", ] +[[package]] +name = "windows-threading" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-version" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4060a1da109b9d0326b7262c8e12c84df67cc0dbc9e33cf49e01ccc2eb63631" +dependencies = [ + "windows-link 0.2.1", +] + [[package]] name = "windows_aarch64_gnullvm" version = "0.42.2" @@ -5294,9 +5503,44 @@ dependencies = [ "uds_windows", "windows-sys 0.52.0", "xdg-home", - "zbus_macros", - "zbus_names", - "zvariant", + "zbus_macros 4.4.0", + "zbus_names 3.0.0", + "zvariant 4.2.0", +] + +[[package]] +name = "zbus" +version = "5.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eee682d202a77e4a9f3b2c2bdf48a7b28af5c08c34ddf66f98c93e5e39464285" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow 1.0.2", + "zbus_macros 5.16.0", + "zbus_names 4.3.2", + "zvariant 5.12.0", ] [[package]] @@ -5309,7 +5553,22 @@ dependencies = [ "proc-macro2", "quote", "syn", - "zvariant_utils", + "zvariant_utils 2.1.0", +] + +[[package]] +name = "zbus_macros" +version = "5.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adf1bd45a81a103745b1757754762a26e8cd01e4532e4d6c8ec431624b80d1d6" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", + "zbus_names 4.3.2", + "zvariant 5.12.0", + "zvariant_utils 3.4.0", ] [[package]] @@ -5320,7 +5579,18 @@ checksum = "4b9b1fef7d021261cc16cba64c351d291b715febe0fa10dc3a443ac5a5022e6c" dependencies = [ "serde", "static_assertions", - "zvariant", + "zvariant 4.2.0", +] + +[[package]] +name = "zbus_names" +version = "4.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7074f3e50b894eac91750142016d30d0a89be8e67dbfd9704fb875825760e52d" +dependencies = [ + "serde", + "winnow 1.0.2", + "zvariant 5.12.0", ] [[package]] @@ -5434,7 +5704,21 @@ dependencies = [ "enumflags2", "serde", "static_assertions", - "zvariant_derive", + "zvariant_derive 4.2.0", +] + +[[package]] +name = "zvariant" +version = "5.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a192a0bde63360d77a7523c833d4b4ce6070a927e2c53246e4c540b1a3e27be0" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow 1.0.2", + "zvariant_derive 5.12.0", + "zvariant_utils 3.4.0", ] [[package]] @@ -5447,7 +5731,20 @@ dependencies = [ "proc-macro2", "quote", "syn", - "zvariant_utils", + "zvariant_utils 2.1.0", +] + +[[package]] +name = "zvariant_derive" +version = "5.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90bc6cde9c01c511074be97f7ccb6c19d0da89e3f8662e812e999dcfd4638737" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", + "zvariant_utils 3.4.0", ] [[package]] @@ -5460,3 +5757,16 @@ dependencies = [ "quote", "syn", ] + +[[package]] +name = "zvariant_utils" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e8535915cfa75547e559d8c68e8139909a4aeee076831e4ef7fc59d8172c4d6" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn", + "winnow 1.0.2", +] diff --git a/crates/nono-cli/src/launch_runtime.rs b/crates/nono-cli/src/launch_runtime.rs index 57434a51b..9cfa35c2d 100644 --- a/crates/nono-cli/src/launch_runtime.rs +++ b/crates/nono-cli/src/launch_runtime.rs @@ -90,6 +90,10 @@ pub(crate) struct ProxyLaunchOptions { pub(crate) proxy_ca_validity: Option, pub(crate) network_approval_mode: crate::network_approval::NetworkApprovalMode, pub(crate) network_approval_timeout_secs: u64, + /// True when the user requested `network.block` or `--block-net`. + /// Propagated to `ProxyConfig.strict_filter` so the filter denies + /// unlisted hosts instead of falling back to allow-all. + pub(crate) network_block: bool, } #[derive(Clone)] diff --git a/crates/nono-cli/src/main.rs b/crates/nono-cli/src/main.rs index 8d0a7a947..ae6793927 100644 --- a/crates/nono-cli/src/main.rs +++ b/crates/nono-cli/src/main.rs @@ -292,6 +292,7 @@ mod tests { denied_env_vars: None, profile_network_approval_mode: None, profile_network_approval_timeout_secs: None, + network_block_requested: false, }; let effective = resolve_effective_proxy_settings(&args, &prepared); @@ -347,6 +348,7 @@ mod tests { denied_env_vars: None, profile_network_approval_mode: None, profile_network_approval_timeout_secs: None, + network_block_requested: false, }; let effective = resolve_effective_proxy_settings(&args, &prepared); diff --git a/crates/nono-cli/src/proxy_runtime.rs b/crates/nono-cli/src/proxy_runtime.rs index 706364e4c..34f703499 100644 --- a/crates/nono-cli/src/proxy_runtime.rs +++ b/crates/nono-cli/src/proxy_runtime.rs @@ -111,6 +111,7 @@ pub(crate) fn prepare_proxy_launch_options( network_approval_timeout_secs: resolve_approval_timeout_secs( prepared.profile_network_approval_timeout_secs, ), + network_block: prepared.network_block_requested, }) } @@ -282,6 +283,7 @@ pub(crate) fn build_proxy_config_from_flags( network_policy::expand_proxy_allow(&net_policy, &proxy.allow_domain.iter().map(|e| e.domain().to_string()).collect::>()); let mut proxy_config = network_policy::build_proxy_config(&resolved, &expanded_allow_domain, &proxy.reject_domain); + proxy_config.strict_filter = proxy.network_block; if let Some(ref addr) = proxy.upstream_proxy { proxy_config.external_proxy = Some(nono_proxy::config::ExternalProxyConfig { @@ -655,4 +657,33 @@ mod tests { _ => panic!("expected WithEndpoints"), } } + + /// `network_block: true` must set `strict_filter` on the generated `ProxyConfig`. + #[test] + fn test_build_proxy_config_propagates_network_block_to_strict_filter() { + let proxy = ProxyLaunchOptions { + active: true, + network_block: true, + ..ProxyLaunchOptions::default() + }; + let config = build_proxy_config_from_flags(&proxy).expect("build_proxy_config_from_flags"); + assert!( + config.strict_filter, + "network_block: true must set strict_filter on ProxyConfig" + ); + } + + #[test] + fn test_build_proxy_config_strict_filter_off_when_no_block() { + let proxy = ProxyLaunchOptions { + active: true, + network_block: false, + ..ProxyLaunchOptions::default() + }; + let config = build_proxy_config_from_flags(&proxy).expect("build_proxy_config_from_flags"); + assert!( + !config.strict_filter, + "strict_filter must default off when network_block is false" + ); + } } diff --git a/crates/nono-cli/src/sandbox_prepare.rs b/crates/nono-cli/src/sandbox_prepare.rs index 570455b3c..b7286b1c8 100644 --- a/crates/nono-cli/src/sandbox_prepare.rs +++ b/crates/nono-cli/src/sandbox_prepare.rs @@ -444,6 +444,10 @@ pub(crate) struct PreparedSandbox { pub(crate) denied_env_vars: Option>, pub(crate) profile_network_approval_mode: Option, pub(crate) profile_network_approval_timeout_secs: Option, + /// True when the profile or CLI requested `network.block`. Carried + /// through because a CLI proxy flag (e.g. `--credential`) may later + /// override `caps` to `ProxyOnly`, losing the original intent. + pub(crate) network_block_requested: bool, } fn resolved_workdir(args: &SandboxArgs) -> PathBuf { @@ -1076,6 +1080,7 @@ pub(crate) fn prepare_sandbox(args: &SandboxArgs, silent: bool) -> Result Result Result, @@ -45,6 +46,10 @@ pub struct ProxyConfig { /// (`*.evil.com`). #[serde(default)] pub rejected_hosts: Vec, + /// When `true`, an empty `allowed_hosts` denies every host instead of + /// falling back to allow-all. + #[serde(default)] + pub strict_filter: bool, /// Reverse proxy credential routes. #[serde(default)] @@ -151,6 +156,7 @@ impl Default for ProxyConfig { bind_port: 0, allowed_hosts: Vec::new(), rejected_hosts: Vec::new(), + strict_filter: false, routes: Vec::new(), external_proxy: None, direct_connect_ports: Vec::new(), diff --git a/crates/nono-proxy/src/filter.rs b/crates/nono-proxy/src/filter.rs index 3bc4233f7..720f638e9 100644 --- a/crates/nono-proxy/src/filter.rs +++ b/crates/nono-proxy/src/filter.rs @@ -47,6 +47,14 @@ impl ProxyFilter { } } + /// Create a strict proxy filter: an empty allowlist denies every host. + #[must_use] + pub fn new_strict(allowed_hosts: &[String]) -> Self { + Self { + inner: HostFilter::new_strict(allowed_hosts), + } + } + /// Create a filter that allows all hosts (except cloud metadata). #[must_use] pub fn allow_all() -> Self { diff --git a/crates/nono-proxy/src/server.rs b/crates/nono-proxy/src/server.rs index 372809d14..1be8e6f63 100644 --- a/crates/nono-proxy/src/server.rs +++ b/crates/nono-proxy/src/server.rs @@ -426,8 +426,10 @@ pub async fn start_with_approval( }; let loaded_routes = credential_store.loaded_prefixes(); - // Build filter - let filter = if config.allowed_hosts.is_empty() && config.rejected_hosts.is_empty() { + // Build filter. Strict mode treats an empty allowlist as deny-all. + let filter = if config.strict_filter { + ProxyFilter::new_strict(&config.allowed_hosts) + } else if config.allowed_hosts.is_empty() && config.rejected_hosts.is_empty() { ProxyFilter::allow_all() } else { ProxyFilter::new_with_reject(&config.allowed_hosts, &config.rejected_hosts) @@ -1737,4 +1739,47 @@ mod tests { handle.shutdown(); } + + /// Regression test: when `strict_filter` is true and `allowed_hosts` is + /// empty, the proxy must deny CONNECT instead of falling back to allow-all. + #[tokio::test] + async fn test_strict_filter_with_empty_allowlist_denies_connect() { + use tokio::io::AsyncReadExt; + use tokio::net::TcpStream; + + let config = ProxyConfig { + strict_filter: true, + allowed_hosts: Vec::new(), + ..ProxyConfig::default() + }; + let handle = start(config).await.unwrap(); + let addr = format!("127.0.0.1:{}", handle.port); + + let mut stream = TcpStream::connect(&addr).await.unwrap(); + let request = b"CONNECT example.com:443 HTTP/1.1\r\nHost: example.com:443\r\n\r\n"; + tokio::io::AsyncWriteExt::write_all(&mut stream, request) + .await + .unwrap(); + + let mut response = Vec::new(); + stream.read_to_end(&mut response).await.unwrap(); + let response_str = String::from_utf8_lossy(&response); + assert!( + response_str.starts_with("HTTP/1.1 403"), + "strict filter with empty allowlist must deny CONNECT, got: {}", + response_str + ); + + let events = handle.drain_audit_events(); + assert!( + events + .iter() + .any(|e| e.decision == nono::undo::NetworkAuditDecision::Deny + && e.target == "example.com"), + "expected a Deny audit event for example.com, got: {:?}", + events + ); + + handle.shutdown(); + } } diff --git a/crates/nono/src/net_filter.rs b/crates/nono/src/net_filter.rs index fe576ef7b..9114335d0 100644 --- a/crates/nono/src/net_filter.rs +++ b/crates/nono/src/net_filter.rs @@ -120,6 +120,8 @@ pub struct HostFilter { deny_hosts: Vec, /// Wildcard suffixes that are always denied (e.g., ".evil.com") deny_suffixes: Vec, + /// When true, an empty allowlist denies instead of allowing. + strict: bool, } impl HostFilter { @@ -175,9 +177,18 @@ impl HostFilter { allowed_suffixes, deny_hosts, deny_suffixes, + strict: false, } } + /// Create a strict host filter: empty allowlist denies everything. + #[must_use] + pub fn new_strict(allowed_hosts: &[String]) -> Self { + let mut filter = Self::new(allowed_hosts); + filter.strict = true; + filter + } + /// Create a host filter that allows everything (no filtering). /// /// Cloud metadata endpoints are still blocked. @@ -188,6 +199,7 @@ impl HostFilter { allowed_suffixes: Vec::new(), deny_hosts: DENY_HOSTS.iter().map(|s| s.to_lowercase()).collect(), deny_suffixes: Vec::new(), + strict: false, } } @@ -207,6 +219,7 @@ impl HostFilter { allowed_suffixes: Vec::new(), deny_hosts: DENY_HOSTS.iter().map(|s| s.to_lowercase()).collect(), deny_suffixes: Vec::new(), + strict: false, } } @@ -251,8 +264,13 @@ impl HostFilter { } } - // 4. If no allowlist is configured (allow_all mode), allow + // 4. Empty allowlist: deny when strict, allow otherwise. if self.allowed_hosts.is_empty() && self.allowed_suffixes.is_empty() { + if self.strict { + return FilterResult::DenyNotAllowed { + host: host.to_string(), + }; + } return FilterResult::Allow; } @@ -710,4 +728,28 @@ mod tests { "deny suffix should not match bare domain" ); } + + #[test] + fn test_strict_filter_empty_allowlist_denies() { + let filter = HostFilter::new_strict(&[]); + let result = filter.check_host("example.com", &public_ip()); + assert!(matches!(result, FilterResult::DenyNotAllowed { .. })); + } + + #[test] + fn test_strict_filter_respects_explicit_allowlist() { + let filter = HostFilter::new_strict(&["api.openai.com".to_string()]); + let allowed = filter.check_host("api.openai.com", &public_ip()); + assert!(matches!(allowed, FilterResult::Allow)); + + let denied = filter.check_host("evil.com", &public_ip()); + assert!(matches!(denied, FilterResult::DenyNotAllowed { .. })); + } + + #[test] + fn test_non_strict_empty_allowlist_allows() { + let filter = HostFilter::new(&[]); + let result = filter.check_host("example.com", &public_ip()); + assert!(matches!(result, FilterResult::Allow)); + } } From 85a30065b6c88d9cac356d420dc8403c80be9dfa Mon Sep 17 00:00:00 2001 From: Luke Hinds Date: Fri, 5 Jun 2026 09:29:07 +0100 Subject: [PATCH 16/91] chore: release v0.61.2 --- CHANGELOG.md | 17 +++++++++++++++++ Cargo.lock | 6 +++--- bindings/c/Cargo.toml | 2 +- crates/nono-cli/Cargo.toml | 6 +++--- crates/nono-proxy/Cargo.toml | 4 ++-- crates/nono/Cargo.toml | 2 +- 6 files changed, 27 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ff151c59..cfe328f6b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,22 @@ # Changelog +## [0.61.2] - 2026-06-05 + +### Bug Fixes + +- *(proxy)* Deny-by-default when network.block is set (#1082) ([#1082](https://github.com/always-further/nono/pull/1082)) + + +### Dependencies + +- *(deps)* Bump actions/checkout from 6.0.2 to 6.0.3 + +- *(deps)* Bump docker/setup-qemu-action from 4.0.0 to 4.1.0 + +- *(deps)* Bump jsonschema from 0.46.4 to 0.46.5 + +- *(deps)* Bump rustls-native-certs from 0.8.3 to 0.8.4 + ## [0.61.1] - 2026-06-02 ### Features diff --git a/Cargo.lock b/Cargo.lock index ef6c662fe..e3e4a145e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2194,7 +2194,7 @@ dependencies = [ [[package]] name = "nono" -version = "0.61.1" +version = "0.61.2" dependencies = [ "der", "getrandom 0.4.2", @@ -2224,7 +2224,7 @@ dependencies = [ [[package]] name = "nono-cli" -version = "0.61.1" +version = "0.61.2" dependencies = [ "aws-lc-rs", "chrono", @@ -2282,7 +2282,7 @@ dependencies = [ [[package]] name = "nono-proxy" -version = "0.61.1" +version = "0.61.2" dependencies = [ "base64", "getrandom 0.4.2", diff --git a/bindings/c/Cargo.toml b/bindings/c/Cargo.toml index 1a9cc669e..9968a6518 100644 --- a/bindings/c/Cargo.toml +++ b/bindings/c/Cargo.toml @@ -15,7 +15,7 @@ name = "nono_ffi" crate-type = ["cdylib", "staticlib"] [dependencies] -nono = { version = "0.61.1", path = "../../crates/nono", default-features = false } +nono = { version = "0.61.2", path = "../../crates/nono", default-features = false } [build-dependencies] cbindgen.workspace = true diff --git a/crates/nono-cli/Cargo.toml b/crates/nono-cli/Cargo.toml index 1d0e70110..d7a4d823f 100644 --- a/crates/nono-cli/Cargo.toml +++ b/crates/nono-cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "nono-cli" -version = "0.61.1" +version = "0.61.2" edition.workspace = true rust-version.workspace = true authors.workspace = true @@ -41,8 +41,8 @@ system-keyring = ["dep:keyring", "nono/system-keyring", "nono-proxy/system-keyri test-trust-overrides = [] [dependencies] -nono = { version = "0.61.1", path = "../nono", default-features = false } -nono-proxy = { version = "0.61.1", path = "../nono-proxy", default-features = false } +nono = { version = "0.61.2", path = "../nono", default-features = false } +nono-proxy = { version = "0.61.2", path = "../nono-proxy", default-features = false } clap = { version = "4", features = ["derive", "env"] } clap_complete = "4" colored = "3" diff --git a/crates/nono-proxy/Cargo.toml b/crates/nono-proxy/Cargo.toml index ef7e62a3d..74b2310f7 100644 --- a/crates/nono-proxy/Cargo.toml +++ b/crates/nono-proxy/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "nono-proxy" -version = "0.61.1" +version = "0.61.2" edition.workspace = true rust-version.workspace = true authors.workspace = true @@ -18,7 +18,7 @@ default = ["system-keyring"] system-keyring = ["nono/system-keyring"] [dependencies] -nono = { version = "0.61.1", path = "../nono", default-features = false } +nono = { version = "0.61.2", path = "../nono", default-features = false } tokio = { workspace = true, features = ["sync", "time"] } hyper.workspace = true hyper-util.workspace = true diff --git a/crates/nono/Cargo.toml b/crates/nono/Cargo.toml index bdb9755aa..e4d31a9bb 100644 --- a/crates/nono/Cargo.toml +++ b/crates/nono/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "nono" -version = "0.61.1" +version = "0.61.2" edition.workspace = true rust-version.workspace = true authors.workspace = true From c18a83c391461848b00c074a8d45638f9bee5c39 Mon Sep 17 00:00:00 2001 From: Saro Vindigni Date: Sat, 6 Jun 2026 20:30:36 +0200 Subject: [PATCH 17/91] feat(packaging): add automated AUR package publishing (#917) (#1083) Move the nono-ai-bin AUR packaging in-tree and publish it automatically on each release: - packaging/aur/PKGBUILD: package template, adapted from the original at sarovin/nono-ai-bin. Drops dbus from depends (the release binary does not link libdbus; the Secret Service optdepends pull it in) with a pkgrel bump to 2. - packaging/aur/update.sh: version bump and checksum refresh script - .github/workflows/aur-publish.yml: publishes to the AUR after each successful Release run (workflow_run) or manually via workflow_dispatch. AUR host keys are verified against pinned fingerprints before any git operation; pushes happen only when the generated files differ from the current AUR state. - release.yml: document that the artifact naming is consumed by the AUR PKGBUILD - docs: mark the AUR package as officially maintained Signed-off-by: Saro Vindigni Co-authored-by: Luke Hinds --- .github/workflows/aur-publish.yml | 168 ++++++++++++++++++++++ .github/workflows/release.yml | 4 +- docs/cli/getting_started/installation.mdx | 6 +- packaging/aur/PKGBUILD | 46 ++++++ packaging/aur/README.md | 85 +++++++++++ packaging/aur/update.sh | 36 +++++ 6 files changed, 338 insertions(+), 7 deletions(-) create mode 100644 .github/workflows/aur-publish.yml create mode 100644 packaging/aur/PKGBUILD create mode 100644 packaging/aur/README.md create mode 100755 packaging/aur/update.sh diff --git a/.github/workflows/aur-publish.yml b/.github/workflows/aur-publish.yml new file mode 100644 index 000000000..4871601a3 --- /dev/null +++ b/.github/workflows/aur-publish.yml @@ -0,0 +1,168 @@ +name: AUR Publish + +on: + workflow_run: + workflows: ["Release"] + types: + - completed + workflow_dispatch: + inputs: + tag: + description: 'Release tag to publish to the AUR (e.g., v0.61.1)' + required: true + type: string + +permissions: + contents: read + +# Serialize publishes so two close releases cannot race the AUR repository. +concurrency: + group: aur-publish + cancel-in-progress: false + +jobs: + resolve: + name: Resolve publish parameters + runs-on: ubuntu-latest + if: >- + github.repository == 'always-further/nono' && + ((github.event_name == 'workflow_dispatch') || + (github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success')) + outputs: + tag: ${{ steps.params.outputs.tag }} + publish: ${{ steps.params.outputs.publish }} + steps: + - name: Compute parameters + id: params + env: + EVENT_NAME: ${{ github.event_name }} + WR_HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }} + WD_TAG: ${{ inputs.tag }} + run: | + set -euo pipefail + if [ "$EVENT_NAME" = "workflow_run" ]; then + TAG="$WR_HEAD_BRANCH" + else + TAG="$WD_TAG" + fi + + # Only stable vX.Y.Z release tags are published to the AUR. This + # rejects prereleases (alpha/beta/rc) and workflow_run events for + # Release runs started from a branch, where head_branch is the + # branch name instead of a tag. + if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "Not a stable release tag: '$TAG' — skipping AUR publish." + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + echo "publish=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + echo "publish=true" >> "$GITHUB_OUTPUT" + + publish: + name: Publish to AUR + needs: resolve + if: ${{ needs.resolve.outputs.publish == 'true' }} + runs-on: ubuntu-latest + container: + image: archlinux:latest + steps: + # git must be installed before actions/checkout so it produces a real + # git checkout; base-devel provides makepkg and sudo, pacman-contrib + # provides updpkgsums. + - name: Install dependencies + run: pacman -Syu --noconfirm --needed base-devel git pacman-contrib openssh + + # The PKGBUILD template and update.sh are always taken from the branch + # the workflow runs on (the default branch), not from the release tag: + # packaging fixes must flow into the next publish, and tags that predate + # this workflow have no packaging/aur directory at all. + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + + - name: Create build user + # makepkg and updpkgsums refuse to run as root. The workspace is + # handed to an unprivileged user; safe.directory keeps the + # actions/checkout post-job cleanup (running as root) working on it. + run: | + set -euo pipefail + useradd -m builder + git config --global --add safe.directory '*' + chown -R builder:builder . + + - name: Verify AUR host keys and configure SSH + env: + AUR_SSH_PRIVATE_KEY: ${{ secrets.AUR_SSH_PRIVATE_KEY }} + run: | + set -euo pipefail + # Official AUR SSH host key fingerprints, published on + # https://aur.archlinux.org/ ("The following SSH fingerprints are + # used for the AUR"). Every key returned by ssh-keyscan must match + # one of these pinned fingerprints or the job fails (no trust on + # first use). If Arch Linux rotates its keys, update these values + # from that page (see packaging/aur/README.md). + expected='SHA256:RFzBCUItH9LZS0cKB5UE6ceAYhBD5C8GeOBip8Z11+4 SHA256:uTa/0PndEgPZTf76e1DFqXKJEXKsn7m9ivhLQtzGOCI SHA256:5s5cIyReIfNNVGRFdDbe3hdYiI5OelHGpw2rOUud3Q8' + + install -d -m 700 -o builder -g builder /home/builder/.ssh + + ssh-keyscan -t ed25519,ecdsa,rsa aur.archlinux.org > /home/builder/.ssh/scanned + if [ ! -s /home/builder/.ssh/scanned ]; then + echo "::error::ssh-keyscan returned no host keys for aur.archlinux.org" + exit 1 + fi + + while IFS= read -r hostkey; do + fp="$(printf '%s\n' "$hostkey" | ssh-keygen -lf - | awk '{print $2}')" + if [[ " $expected " != *" $fp "* ]]; then + echo "::error::Unexpected SSH host key fingerprint for aur.archlinux.org: $fp" + exit 1 + fi + echo "Verified host key: $fp" + done < /home/builder/.ssh/scanned + + mv /home/builder/.ssh/scanned /home/builder/.ssh/known_hosts + + # Private key comes from the repository secret; it is written + # straight to disk and never echoed to the log. + printf '%s\n' "$AUR_SSH_PRIVATE_KEY" > /home/builder/.ssh/aur + + cat > /home/builder/.ssh/config <<'EOF' + Host aur.archlinux.org + User aur + IdentityFile ~/.ssh/aur + IdentitiesOnly yes + StrictHostKeyChecking yes + EOF + + chmod 600 /home/builder/.ssh/aur /home/builder/.ssh/config /home/builder/.ssh/known_hosts + chown -R builder:builder /home/builder/.ssh + + - name: Clone AUR repository + run: sudo -u builder git clone ssh://aur@aur.archlinux.org/nono-ai-bin.git /home/builder/aur + + - name: Generate PKGBUILD and .SRCINFO + env: + RELEASE_TAG: ${{ needs.resolve.outputs.tag }} + run: sudo -u builder ./packaging/aur/update.sh "$RELEASE_TAG" + + - name: Push to AUR if changed + run: | + set -euo pipefail + install -m 644 -o builder -g builder \ + packaging/aur/PKGBUILD packaging/aur/.SRCINFO /home/builder/aur/ + # The version comes from the freshly generated PKGBUILD rather than + # from an environment variable: sudo's default policy resets the + # environment, and the PKGBUILD value is what actually gets published. + sudo -u builder bash -euo pipefail <<'EOS' + cd /home/builder/aur + ver="$(awk -F= '/^pkgver=/{print $2; exit}' PKGBUILD)" + if [ -z "$(git status --porcelain)" ]; then + echo "AUR is already up to date with v${ver}; nothing to push." + exit 0 + fi + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add PKGBUILD .SRCINFO + git commit -m "Update to ${ver}" + git push origin master + EOS diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 85f1a67b5..5cbff54ba 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,11 +3,11 @@ name: Release on: push: tags: - - 'v*' + - "v*" workflow_dispatch: inputs: tag: - description: 'Tag to release (e.g., v0.2.3)' + description: "Tag to release (e.g., v0.2.3)" required: true type: string diff --git a/docs/cli/getting_started/installation.mdx b/docs/cli/getting_started/installation.mdx index a1bb60cef..9901d58e6 100644 --- a/docs/cli/getting_started/installation.mdx +++ b/docs/cli/getting_started/installation.mdx @@ -55,7 +55,7 @@ nix-shell -p nono ### Arch Linux (AUR) -nono is available on the [Arch User Repository](https://aur.archlinux.org/packages/nono-ai-bin) as `nono-ai-bin`, a community-maintained binary package that tracks official GitHub releases and verifies SHA256 checksums. It supports `x86_64` and `aarch64`. +nono is available on the [Arch User Repository](https://aur.archlinux.org/packages/nono-ai-bin) as `nono-ai-bin`, an officially maintained binary package that tracks official GitHub releases and verifies SHA256 checksums. It supports `x86_64` and `aarch64`. ```bash # Using yay @@ -70,10 +70,6 @@ cd nono-ai-bin makepkg -si ``` - - This package is not maintained by the nono team. For issues with the AUR package itself, please report them on the [AUR page](https://aur.archlinux.org/packages/nono-ai-bin). - - Other distributions are in the process of being packaged. In the meantime, you can use the prebuilt binaries or build from source. ## Building from Source diff --git a/packaging/aur/PKGBUILD b/packaging/aur/PKGBUILD new file mode 100644 index 000000000..378956b5f --- /dev/null +++ b/packaging/aur/PKGBUILD @@ -0,0 +1,46 @@ +# Maintainer: sarovin86 +# Maintainer: Luke Hinds (lukehinds) +# +# This PKGBUILD is the canonical source for the nono-ai-bin AUR package. +# It is published to the AUR automatically by .github/workflows/aur-publish.yml +# on each upstream release: pkgver and the sha256sums arrays are rewritten at +# publish time by packaging/aur/update.sh, so the values committed here may lag +# behind the latest release. The committed values always correspond to a real +# release, so this file remains locally buildable with makepkg. + +pkgname=nono-ai-bin +_pkgname=nono +pkgver=0.61.1 +pkgrel=2 +pkgdesc='Secure, kernel-enforced sandbox for AI agents, MCP servers and LLM workloads using Landlock (pre-built binary)' +arch=('x86_64' 'aarch64') +url='https://github.com/always-further/nono' +license=('Apache-2.0') +# dbus is intentionally not a hard dependency: the release binary does not +# link libdbus (pure-Rust zbus keyring backend, verified in release.yml). +# D-Bus is only needed at runtime for the optional Secret Service keyring, +# and both optdepends below already pull it in. +depends=('glibc' 'libgcc') +optdepends=( + 'gnome-keyring: Secret Service daemon for credential storage' + 'keepassxc: alternative Secret Service daemon' +) +provides=('nono-ai') +conflicts=("${_pkgname}" 'nono-ai' 'nono-ai-git') +options=('!strip' '!debug') +source=( + "LICENSE-${pkgver}::https://raw.githubusercontent.com/always-further/nono/v${pkgver}/LICENSE" + "README-${pkgver}.md::https://raw.githubusercontent.com/always-further/nono/v${pkgver}/README.md" +) +source_x86_64=("${_pkgname}-${pkgver}-x86_64.tar.gz::https://github.com/always-further/nono/releases/download/v${pkgver}/nono-v${pkgver}-x86_64-unknown-linux-gnu.tar.gz") +source_aarch64=("${_pkgname}-${pkgver}-aarch64.tar.gz::https://github.com/always-further/nono/releases/download/v${pkgver}/nono-v${pkgver}-aarch64-unknown-linux-gnu.tar.gz") +sha256sums=('7310e9389f298b89bb2f90ac4b6081ed5b6a1c4a7b8547df5d52966a57cb0929' + 'd0b350c764dfea1bb43ad9f1a1e77f9292869d361a431e4a9d889dc56a86f0f5') +sha256sums_x86_64=('9186323e1deda74bda886ab57ae3cff820bc85861a9dd691b898a4d309bffa93') +sha256sums_aarch64=('27b05d415169a60686994517fcc86a7f24103c5f8cb10d1ccf1627e57ae867e1') + +package() { + install -Dm755 "${srcdir}/${_pkgname}" "${pkgdir}/usr/bin/${_pkgname}" + install -Dm644 "${srcdir}/LICENSE-${pkgver}" "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE" + install -Dm644 "${srcdir}/README-${pkgver}.md" "${pkgdir}/usr/share/doc/${pkgname}/README.md" +} diff --git a/packaging/aur/README.md b/packaging/aur/README.md new file mode 100644 index 000000000..df29d9de6 --- /dev/null +++ b/packaging/aur/README.md @@ -0,0 +1,85 @@ +# AUR packaging for nono + +This directory contains the canonical source for the [`nono-ai-bin`](https://aur.archlinux.org/packages/nono-ai-bin) +Arch User Repository package, a pre-built binary package that tracks official +GitHub releases. + +## Why `nono-ai-bin`? + +The name `nono` is already taken in the official Arch repositories by an +unrelated emulator, and the AUR already hosts the source-based variants +`nono-ai` and `nono-ai-git`. Following AUR conventions, the `-bin` suffix marks +this as the package that installs pre-built release binaries instead of +compiling from source, which makes installs and updates fast while still +integrating with pacman and AUR helpers. + +## Files + +| File | Purpose | +|------|---------| +| `PKGBUILD` | Package build recipe. `pkgver` and the `sha256sums` arrays are rewritten by CI at publish time; the committed values correspond to a real release so the file stays locally buildable. | +| `update.sh` | Updates `PKGBUILD` to a given release, recomputes checksums with `updpkgsums`, and regenerates `.SRCINFO`. | + +`.SRCINFO` is intentionally not tracked here: it is generated from the +`PKGBUILD` at publish time. + +## How publishing works + +The `.github/workflows/aur-publish.yml` workflow publishes this package to the +AUR. In short: + +- It runs automatically after each successful **Release** (or manually via + `workflow_dispatch` with a tag input); prereleases and non-tag refs are + skipped. +- It regenerates `PKGBUILD`/`.SRCINFO` for the released version and pushes to + the AUR **only if they differ** from the current AUR state, so re-runs are + safe no-ops. +- It never builds nono itself: the package only references release artifacts + that are already published. + +The step-by-step mechanics are documented inline in the workflow file. + +## Credentials + +| What | Where | +|------|-------| +| AUR account | `lukehinds` (co-maintainer of `nono-ai-bin`, alongside `sarovin86`) | +| SSH private key | `AUR_SSH_PRIVATE_KEY` repository secret (Actions) | + +The SSH key never appears in logs; it is written to a file readable only by the +build user inside the job container. + +## SSH host key verification + +The workflow refuses to talk to anything that does not present the official +`aur.archlinux.org` host keys. The expected key fingerprints are pinned in the +workflow and checked against the output of `ssh-keyscan` before any git +operation (`StrictHostKeyChecking yes`). + +If Arch Linux rotates its SSH host keys, the workflow fails loudly instead of +trusting the new keys (fail secure). To update the pinned fingerprints: + +1. Get the current fingerprints from the footer of + ("The following SSH fingerprints are used for the AUR"). +2. Replace the values in the `Verify AUR host keys and configure SSH` step of + `.github/workflows/aur-publish.yml`. +3. Open a PR with the change. + +## Testing locally + +On an Arch Linux system or an `archlinux:latest` container with `base-devel` +and `pacman-contrib` installed: + +```bash +cd packaging/aur +./update.sh v0.61.1 # or any released tag +makepkg -f # downloads the artifacts, verifies checksums, builds the package +``` + +## History and attribution + +The `PKGBUILD` and `update.sh` were originally written and maintained by +[sarovin86](https://aur.archlinux.org/account/sarovin86) in the +[sarovin/nono-ai-bin](https://github.com/sarovin/nono-ai-bin) repository, and +were moved here (with small adaptations for CI integration) as part of +[#917](https://github.com/always-further/nono/issues/917). diff --git a/packaging/aur/update.sh b/packaging/aur/update.sh new file mode 100755 index 000000000..ef44a0fd5 --- /dev/null +++ b/packaging/aur/update.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# Update the nono-ai-bin AUR packaging files to an upstream release. +# +# Usage: ./update.sh +# The version (e.g. "v0.61.1" or "0.61.1") is required and always gets the +# full update, even if pkgver already matches. The CI workflow +# (.github/workflows/aur-publish.yml) relies on this to regenerate the +# sha256sums and .SRCINFO deterministically from the release artifacts. +# +# Requires: pacman-contrib (provides updpkgsums), makepkg + +set -euo pipefail + +cd "$(dirname "$0")" +command -v updpkgsums >/dev/null || { echo "updpkgsums not found; install pacman-contrib" >&2; exit 1; } + +[[ $# -ge 1 ]] || { echo "Usage: $0 (e.g. v0.61.1)" >&2; exit 1; } +ver="${1#v}" +# Same stable-release format the CI gate (aur-publish.yml, job "resolve") enforces. +[[ "$ver" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || { echo "Unexpected version: '${ver}'" >&2; exit 1; } + +cur="$(awk -F= '/^pkgver=/{print $2; exit}' PKGBUILD)" + +if [[ "$cur" == "$ver" ]]; then + # Same version: refresh checksums and .SRCINFO without touching pkgrel, + # so packaging-only fixes (manual pkgrel bumps) are preserved. + echo "Refreshing v${ver} (checksums and .SRCINFO)" +else + echo "Bumping ${cur} -> ${ver}" + sed -i -e "s/^pkgver=.*/pkgver=${ver}/" -e "s/^pkgrel=.*/pkgrel=1/" PKGBUILD +fi + +updpkgsums +makepkg --printsrcinfo > .SRCINFO + +echo "Updated PKGBUILD and .SRCINFO to v${ver}." From 9379a2e748ddf071252f0a073236eda243b6131f Mon Sep 17 00:00:00 2001 From: Luke Hinds Date: Sun, 7 Jun 2026 08:21:19 +0100 Subject: [PATCH 18/91] Merge commit from fork This commit introduces a `--force` option to `nono pull` to enable recovery from scenarios where local package metadata (e.g., lockfile entries, trust bundles) is missing or corrupted. - The package verification logic now explicitly ensures a lockfile entry and `.nono-trust.bundle` exist for each locked package, prompting a re-pull if either is missing. - Wiring execution, when forced, allows `write_file` directives to adopt existing unmanaged files if their content exactly matches the pack's source. This avoids errors when a file already exists with correct content but its management metadata is lost. This makes `nono pull` more robust, allowing easier recovery from local filesystem issues or accidental metadata deletion. Signed-off-by: Luke Hinds --- crates/nono-cli/src/package_cmd.rs | 15 +- crates/nono-cli/src/profile_runtime.rs | 240 +++++++++++++++++++------ crates/nono-cli/src/wiring.rs | 106 ++++++++++- 3 files changed, 294 insertions(+), 67 deletions(-) diff --git a/crates/nono-cli/src/package_cmd.rs b/crates/nono-cli/src/package_cmd.rs index 6e342e4f5..9eab4d6e3 100644 --- a/crates/nono-cli/src/package_cmd.rs +++ b/crates/nono-cli/src/package_cmd.rs @@ -92,6 +92,7 @@ pub fn run_pull(args: PullArgs) -> Result<()> { &manifest, &downloads, args.init, + args.force, &pack_owned_files, )?; update_lockfile( @@ -769,6 +770,7 @@ fn install_package( manifest: &PackageManifest, downloads: &VerifiedDownloads, init: bool, + force: bool, pack_owned_files: &HashMap, ) -> Result { let staging_parent = package::package_store_dir()? @@ -831,7 +833,18 @@ fn install_package( namespace: package_ref.namespace.clone(), pack_name: package_ref.name.clone(), }; - let report = crate::wiring::execute(&manifest.wiring, &ctx, pack_owned_files)?; + let report = if force { + crate::wiring::execute_with_options( + &manifest.wiring, + &ctx, + pack_owned_files, + crate::wiring::ExecuteOptions { + allow_unmanaged_identical_write_files: true, + }, + )? + } else { + crate::wiring::execute(&manifest.wiring, &ctx, pack_owned_files)? + }; for conflict in &report.conflicts { eprintln!(" warning: {conflict}"); } diff --git a/crates/nono-cli/src/profile_runtime.rs b/crates/nono-cli/src/profile_runtime.rs index 107882468..761c51c27 100644 --- a/crates/nono-cli/src/profile_runtime.rs +++ b/crates/nono-cli/src/profile_runtime.rs @@ -95,71 +95,70 @@ fn verify_profile_packs(packs: &[String]) -> crate::Result<()> { continue; } - let locked = lockfile.packages.get(pack_ref); - if let Some(locked_pkg) = locked { - for (artifact_name, locked_artifact) in &locked_pkg.artifacts { - let artifact_path = install_dir.join(artifact_name); - if !artifact_path.exists() { - return Err(nono::NonoError::PackageInstall(format!( - "pack '{}' is missing artifact '{}'. Reinstall with: nono pull {} --force", - pack_ref, artifact_name, pack_ref - ))); - } + let locked_pkg = lockfile.packages.get(pack_ref).ok_or_else(|| { + nono::NonoError::PackageVerification { + package: pack_ref.clone(), + reason: format!( + "pack '{}' has no lockfile entry - reinstall with: nono pull {} --force", + pack_ref, pack_ref + ), + } + })?; - let bytes = std::fs::read(&artifact_path).map_err(|e| { - nono::NonoError::PackageInstall(format!( - "failed to read artifact '{}' in pack '{}': {}", - artifact_name, pack_ref, e - )) - })?; - let digest = Sha256::digest(&bytes); - let hash = digest - .iter() - .map(|b| format!("{b:02x}")) - .collect::(); - if hash != locked_artifact.sha256 { - return Err(nono::NonoError::PackageInstall(format!( - "pack '{}' artifact '{}' has been tampered with.\n\ - Expected: {}\n\ - Found: {}\n\ - Reinstall with: nono pull {} --force", - pack_ref, artifact_name, locked_artifact.sha256, hash, pack_ref - ))); - } + for (artifact_name, locked_artifact) in &locked_pkg.artifacts { + let artifact_path = install_dir.join(artifact_name); + if !artifact_path.exists() { + return Err(nono::NonoError::PackageInstall(format!( + "pack '{}' is missing artifact '{}'. Reinstall with: nono pull {} --force", + pack_ref, artifact_name, pack_ref + ))); + } + + let bytes = std::fs::read(&artifact_path).map_err(|e| { + nono::NonoError::PackageInstall(format!( + "failed to read artifact '{}' in pack '{}': {}", + artifact_name, pack_ref, e + )) + })?; + let digest = Sha256::digest(&bytes); + let hash = digest + .iter() + .map(|b| format!("{b:02x}")) + .collect::(); + if hash != locked_artifact.sha256 { + return Err(nono::NonoError::PackageInstall(format!( + "pack '{}' artifact '{}' has been tampered with.\n\ + Expected: {}\n\ + Found: {}\n\ + Reinstall with: nono pull {} --force", + pack_ref, artifact_name, locked_artifact.sha256, hash, pack_ref + ))); } } let bundle_path = install_dir.join(".nono-trust.bundle"); - if bundle_path.exists() { - // A trust bundle without a lockfile provenance record means we - // cannot verify who signed it. Fail hard rather than silently - // accepting any valid Sigstore signer. - let pinned_signer = match locked { - None => { - return Err(nono::NonoError::PackageVerification { - package: pack_ref.clone(), - reason: format!( - "pack '{}' has a trust bundle but no lockfile entry — \ - reinstall with: nono pull {} --force", - pack_ref, pack_ref - ), - }); - } - Some(pkg) => pkg - .provenance - .as_ref() - .map(|p| p.signer_identity.as_str()) - .ok_or_else(|| nono::NonoError::PackageVerification { - package: pack_ref.clone(), - reason: format!( - "pack '{}' has a trust bundle but no signer identity in the \ - lockfile — reinstall with: nono pull {} --force", - pack_ref, pack_ref - ), - })?, - }; - verify_stored_bundles(&install_dir, &bundle_path, pack_ref, Some(pinned_signer))?; + if !bundle_path.exists() { + return Err(nono::NonoError::PackageVerification { + package: pack_ref.clone(), + reason: format!( + "pack '{}' is missing .nono-trust.bundle - reinstall with: nono pull {} --force", + pack_ref, pack_ref + ), + }); } + + let pinned_signer = locked_pkg + .provenance + .as_ref() + .map(|p| p.signer_identity.as_str()) + .ok_or_else(|| nono::NonoError::PackageVerification { + package: pack_ref.clone(), + reason: format!( + "pack '{}' has no signer identity in the lockfile - reinstall with: nono pull {} --force", + pack_ref, pack_ref + ), + })?; + verify_stored_bundles(&install_dir, &bundle_path, pack_ref, Some(pinned_signer))?; } Ok(()) @@ -650,6 +649,129 @@ mod tests { use std::fs; use tempfile::tempdir; + fn with_config_env(f: F) -> R + where + F: FnOnce(&Path) -> R, + { + let _guard = match crate::test_env::ENV_LOCK.lock() { + Ok(guard) => guard, + Err(poisoned) => poisoned.into_inner(), + }; + let tmp = match tempdir() { + Ok(dir) => dir, + Err(err) => panic!("failed to create tempdir: {err}"), + }; + let config_dir = match tmp.path().canonicalize() { + Ok(path) => path, + Err(err) => panic!("failed to canonicalize tempdir: {err}"), + }; + let config_str = match config_dir.to_str() { + Some(value) => value, + None => panic!("tempdir path is not valid UTF-8"), + }; + let _env = crate::test_env::EnvVarGuard::set_all(&[("XDG_CONFIG_HOME", config_str)]); + f(&config_dir) + } + + fn create_pack_dir(config_dir: &Path, namespace: &str, name: &str) -> PathBuf { + let install_dir = config_dir + .join("nono") + .join("packages") + .join(namespace) + .join(name); + if let Err(err) = fs::create_dir_all(&install_dir) { + panic!("failed to create pack dir: {err}"); + } + install_dir + } + + fn write_lockfile_with_artifact(pack_ref: &str, artifact_name: &str, artifact_bytes: &[u8]) { + let digest = Sha256::digest(artifact_bytes); + let sha256 = digest + .iter() + .map(|b| format!("{b:02x}")) + .collect::(); + + let mut artifacts = std::collections::BTreeMap::new(); + artifacts.insert( + artifact_name.to_string(), + package::LockedArtifact { + sha256, + artifact_type: package::ArtifactType::Profile, + }, + ); + + let mut packages = std::collections::BTreeMap::new(); + packages.insert( + pack_ref.to_string(), + package::LockedPackage { + version: "1.0.0".to_string(), + installed_at: "2026-01-01T00:00:00Z".to_string(), + pinned: false, + provenance: Some(package::PackageProvenance { + signer_identity: "https://github.com/acme/repo/.github/workflows/release.yml@refs/tags/v1.0.0" + .to_string(), + repository: "acme/repo".to_string(), + workflow: ".github/workflows/release.yml".to_string(), + git_ref: "refs/tags/v1.0.0".to_string(), + rekor_log_index: 1, + signed_at: "2026-01-01T00:00:00Z".to_string(), + }), + artifacts, + wiring_record: Vec::new(), + }, + ); + + let lockfile = package::Lockfile { + lockfile_version: package::LOCKFILE_VERSION, + registry: "https://registry.example.test".to_string(), + packages, + }; + if let Err(err) = package::write_lockfile(&lockfile) { + panic!("failed to write lockfile: {err}"); + } + } + + #[test] + fn verify_profile_packs_requires_lockfile_entry_for_installed_pack() { + let result = with_config_env(|config_dir| { + create_pack_dir(config_dir, "acme", "widget"); + verify_profile_packs(&["acme/widget".to_string()]) + }); + + let err = match result { + Ok(()) => panic!("installed pack without lockfile entry must fail verification"), + Err(err) => err, + }; + assert!( + err.to_string().contains("no lockfile entry"), + "unexpected error: {err}" + ); + } + + #[test] + fn verify_profile_packs_requires_trust_bundle_for_locked_pack() { + let result = with_config_env(|config_dir| { + let install_dir = create_pack_dir(config_dir, "acme", "widget"); + let artifact_bytes = br#"{"meta":{"name":"widget"}}"#; + if let Err(err) = fs::write(install_dir.join("package.json"), artifact_bytes) { + panic!("failed to write package artifact: {err}"); + } + write_lockfile_with_artifact("acme/widget", "package.json", artifact_bytes); + + verify_profile_packs(&["acme/widget".to_string()]) + }); + + let err = match result { + Ok(()) => panic!("locked pack without trust bundle must fail verification"), + Err(err) => err, + }; + assert!( + err.to_string().contains("missing .nono-trust.bundle"), + "unexpected error: {err}" + ); + } + #[test] fn prepare_profile_for_preflight_matches_runtime_resolution() { let workdir = match tempdir() { diff --git a/crates/nono-cli/src/wiring.rs b/crates/nono-cli/src/wiring.rs index c4a82a9ca..4f5f3af78 100644 --- a/crates/nono-cli/src/wiring.rs +++ b/crates/nono-cli/src/wiring.rs @@ -311,6 +311,14 @@ pub struct WiringReport { pub changed: bool, } +#[derive(Clone, Copy, Debug, Default)] +pub struct ExecuteOptions { + /// Allows `WriteFile` to claim an existing unmanaged destination only when + /// its content already exactly matches the pack source. Used for + /// `nono pull --force` recovery after local package metadata loss. + pub allow_unmanaged_identical_write_files: bool, +} + /// Execute a list of directives in order. Stops on hard errors; soft /// conflicts (a real file where we'd symlink) are recorded and /// execution continues with the next directive. @@ -327,10 +335,19 @@ pub fn execute( directives: &[WiringDirective], ctx: &WiringContext, pack_owned_files: &HashMap, +) -> Result { + execute_with_options(directives, ctx, pack_owned_files, ExecuteOptions::default()) +} + +pub fn execute_with_options( + directives: &[WiringDirective], + ctx: &WiringContext, + pack_owned_files: &HashMap, + options: ExecuteOptions, ) -> Result { let mut report = WiringReport::default(); for directive in directives { - execute_one(directive, ctx, pack_owned_files, &mut report)?; + execute_one(directive, ctx, pack_owned_files, options, &mut report)?; } Ok(report) } @@ -339,6 +356,7 @@ fn execute_one( directive: &WiringDirective, ctx: &WiringContext, pack_owned_files: &HashMap, + options: ExecuteOptions, report: &mut WiringReport, ) -> Result<()> { match directive { @@ -384,12 +402,20 @@ fn execute_one( }; match recorded_hash { None => { - return Err(NonoError::PackageInstall(format!( - "write_file: refusing to overwrite '{}' — \ - file already exists and was not written by a \ - managed pack. Move/remove it manually then re-pull.", - dest_path.display() - ))); + let source_hash = match fs::read(&source_path) { + Ok(bytes) => hash_bytes(&bytes), + Err(e) => return Err(NonoError::Io(e)), + }; + if !options.allow_unmanaged_identical_write_files + || source_hash != current_hash + { + return Err(NonoError::PackageInstall(format!( + "write_file: refusing to overwrite '{}' — \ + file already exists and was not written by a \ + managed pack. Move/remove it manually then re-pull.", + dest_path.display() + ))); + } } Some(prior) if prior != ¤t_hash => { return Err(NonoError::PackageInstall(format!( @@ -2076,6 +2102,72 @@ mod tests { }); } + #[test] + fn write_file_force_recovery_accepts_identical_unmanaged_file() { + with_fake_home(|home| { + let pack = home.join("pack"); + fs::create_dir_all(&pack).expect("mkdir pack"); + fs::write(pack.join("file"), "from-pack").expect("seed source"); + fs::write(home.join("dest"), "from-pack").expect("seed matching dest"); + + let ctx = ctx_in(home, pack); + let directives = vec![WiringDirective::WriteFile { + source: "file".to_string(), + dest: "$HOME/dest".to_string(), + }]; + let report = execute_with_options( + &directives, + &ctx, + &HashMap::new(), + ExecuteOptions { + allow_unmanaged_identical_write_files: true, + }, + ) + .expect("identical unmanaged file should be adopted during force recovery"); + + assert!( + !report.changed, + "identical destination should not be rewritten" + ); + assert_eq!(report.records.len(), 1); + assert_eq!( + fs::read_to_string(home.join("dest")).expect("read"), + "from-pack" + ); + }); + } + + #[test] + fn write_file_force_recovery_refuses_mismatched_unmanaged_file() { + with_fake_home(|home| { + let pack = home.join("pack"); + fs::create_dir_all(&pack).expect("mkdir pack"); + fs::write(pack.join("file"), "from-pack").expect("seed source"); + fs::write(home.join("dest"), "user content").expect("seed user file"); + + let ctx = ctx_in(home, pack); + let directives = vec![WiringDirective::WriteFile { + source: "file".to_string(), + dest: "$HOME/dest".to_string(), + }]; + let err = execute_with_options( + &directives, + &ctx, + &HashMap::new(), + ExecuteOptions { + allow_unmanaged_identical_write_files: true, + }, + ) + .expect_err("mismatched unmanaged file must still be refused"); + + assert!(err.to_string().contains("refusing to overwrite")); + assert_eq!( + fs::read_to_string(home.join("dest")).expect("read"), + "user content" + ); + }); + } + #[test] fn write_file_refuses_to_overwrite_edited_file_on_repull() { // Security review (round 2): re-pull must NOT clobber a file From b665f54cae209722260f6fbb51bccff7bf49e1cb Mon Sep 17 00:00:00 2001 From: Luke Hinds Date: Sun, 7 Jun 2026 08:23:31 +0100 Subject: [PATCH 19/91] chore: release v0.62.0 --- CHANGELOG.md | 27 +++++++++++++++++++++++++++ Cargo.lock | 6 +++--- bindings/c/Cargo.toml | 2 +- crates/nono-cli/Cargo.toml | 6 +++--- crates/nono-proxy/Cargo.toml | 4 ++-- crates/nono/Cargo.toml | 2 +- 6 files changed, 37 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cfe328f6b..9bdee9d6f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,32 @@ # Changelog +## [0.62.0] - 2026-06-07 + +### Bug Fixes + +- *(proxy)* Deny-by-default when network.block is set (#1082) ([#1082](https://github.com/always-further/nono/pull/1082)) + + +### Dependencies + +- *(deps)* Bump actions/checkout from 6.0.2 to 6.0.3 + +- *(deps)* Bump docker/setup-qemu-action from 4.0.0 to 4.1.0 + +- *(deps)* Bump jsonschema from 0.46.4 to 0.46.5 + +- *(deps)* Bump rustls-native-certs from 0.8.3 to 0.8.4 + + +### Features + +- *(packaging)* Add automated AUR package publishing (#917) (#1083) ([#1083](https://github.com/always-further/nono/pull/1083)) + + +### Miscellaneous + +- Release v0.61.2 + ## [0.61.2] - 2026-06-05 ### Bug Fixes diff --git a/Cargo.lock b/Cargo.lock index e3e4a145e..d9082c8be 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2194,7 +2194,7 @@ dependencies = [ [[package]] name = "nono" -version = "0.61.2" +version = "0.62.0" dependencies = [ "der", "getrandom 0.4.2", @@ -2224,7 +2224,7 @@ dependencies = [ [[package]] name = "nono-cli" -version = "0.61.2" +version = "0.62.0" dependencies = [ "aws-lc-rs", "chrono", @@ -2282,7 +2282,7 @@ dependencies = [ [[package]] name = "nono-proxy" -version = "0.61.2" +version = "0.62.0" dependencies = [ "base64", "getrandom 0.4.2", diff --git a/bindings/c/Cargo.toml b/bindings/c/Cargo.toml index 9968a6518..7fd87b076 100644 --- a/bindings/c/Cargo.toml +++ b/bindings/c/Cargo.toml @@ -15,7 +15,7 @@ name = "nono_ffi" crate-type = ["cdylib", "staticlib"] [dependencies] -nono = { version = "0.61.2", path = "../../crates/nono", default-features = false } +nono = { version = "0.62.0", path = "../../crates/nono", default-features = false } [build-dependencies] cbindgen.workspace = true diff --git a/crates/nono-cli/Cargo.toml b/crates/nono-cli/Cargo.toml index d7a4d823f..664f25769 100644 --- a/crates/nono-cli/Cargo.toml +++ b/crates/nono-cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "nono-cli" -version = "0.61.2" +version = "0.62.0" edition.workspace = true rust-version.workspace = true authors.workspace = true @@ -41,8 +41,8 @@ system-keyring = ["dep:keyring", "nono/system-keyring", "nono-proxy/system-keyri test-trust-overrides = [] [dependencies] -nono = { version = "0.61.2", path = "../nono", default-features = false } -nono-proxy = { version = "0.61.2", path = "../nono-proxy", default-features = false } +nono = { version = "0.62.0", path = "../nono", default-features = false } +nono-proxy = { version = "0.62.0", path = "../nono-proxy", default-features = false } clap = { version = "4", features = ["derive", "env"] } clap_complete = "4" colored = "3" diff --git a/crates/nono-proxy/Cargo.toml b/crates/nono-proxy/Cargo.toml index 74b2310f7..8d2ce6e41 100644 --- a/crates/nono-proxy/Cargo.toml +++ b/crates/nono-proxy/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "nono-proxy" -version = "0.61.2" +version = "0.62.0" edition.workspace = true rust-version.workspace = true authors.workspace = true @@ -18,7 +18,7 @@ default = ["system-keyring"] system-keyring = ["nono/system-keyring"] [dependencies] -nono = { version = "0.61.2", path = "../nono", default-features = false } +nono = { version = "0.62.0", path = "../nono", default-features = false } tokio = { workspace = true, features = ["sync", "time"] } hyper.workspace = true hyper-util.workspace = true diff --git a/crates/nono/Cargo.toml b/crates/nono/Cargo.toml index e4d31a9bb..8b74d121f 100644 --- a/crates/nono/Cargo.toml +++ b/crates/nono/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "nono" -version = "0.61.2" +version = "0.62.0" edition.workspace = true rust-version.workspace = true authors.workspace = true From c092b8a4cbb325815f793a8fef9033ce0e7c958c Mon Sep 17 00:00:00 2001 From: Saro Vindigni Date: Sun, 7 Jun 2026 10:37:40 +0200 Subject: [PATCH 20/91] fix(aur): skip ssh-keyscan banner lines in host key check ssh-keyscan (OpenSSH 10+) prints a "# host SSH-2.0-..." banner to stdout for each key. The check piped every line to `ssh-keygen -lf -`, so a banner line failed with "(stdin) is not a public key file." and the publish job exited 255. Pass the whole file to `ssh-keygen -lf` instead, which ignores comments and prints one fingerprint per key. The pinned-fingerprint allow-list check is unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Saro Vindigni --- .github/workflows/aur-publish.yml | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/.github/workflows/aur-publish.yml b/.github/workflows/aur-publish.yml index 4871601a3..501a9c67e 100644 --- a/.github/workflows/aur-publish.yml +++ b/.github/workflows/aur-publish.yml @@ -111,14 +111,23 @@ jobs: exit 1 fi - while IFS= read -r hostkey; do - fp="$(printf '%s\n' "$hostkey" | ssh-keygen -lf - | awk '{print $2}')" + # ssh-keyscan (OpenSSH 10+) writes an informational "# host SSH-2.0-..." + # banner line to stdout for each scanned key. Feeding the whole file to + # ssh-keygen -lf (rather than each line to stdin) lets ssh-keygen skip + # those comments natively and emit one fingerprint per host key. + fingerprints="$(ssh-keygen -lf /home/builder/.ssh/scanned | awk '{print $2}')" + if [ -z "$fingerprints" ]; then + echo "::error::ssh-keygen produced no fingerprints for aur.archlinux.org" + exit 1 + fi + + while IFS= read -r fp; do if [[ " $expected " != *" $fp "* ]]; then echo "::error::Unexpected SSH host key fingerprint for aur.archlinux.org: $fp" exit 1 fi echo "Verified host key: $fp" - done < /home/builder/.ssh/scanned + done <<< "$fingerprints" mv /home/builder/.ssh/scanned /home/builder/.ssh/known_hosts From 1cc2b94a1a4675579d2def97056246c2df1239b2 Mon Sep 17 00:00:00 2001 From: Luke Hinds Date: Sun, 7 Jun 2026 11:44:05 +0100 Subject: [PATCH 21/91] feat(pack-verification): skip pack verification on dry runs `--dry-run` invocations now explicitly skip pack verification. This improves the utility of `--dry-run`, allowing users to inspect a pack's resolved profile and capabilities even if the pack has not yet completed its full verification lifecycle (e.g., after `nono pull` or during metadata recovery). For real (non-dry-run) invocations, pack verification remains a critical security boundary. The system will continue to reject packs that lack proper verification metadata (lockfile digest and signed trust bundle) before any pack-shipped code can execute or its profile is applied. Integration tests were updated to: - Clearly distinguish between pack resolution and verification. - Explicitly confirm that `--dry-run` skips verification. - Explicitly confirm that a real run of an unverified, hand-installed pack is rejected by the verification gate. Signed-off-by: Luke Hinds --- crates/nono-cli/src/profile_runtime.rs | 24 ++++++++++-- tests/integration/test_pack_resolution.sh | 48 +++++++++++++++-------- 2 files changed, 53 insertions(+), 19 deletions(-) diff --git a/crates/nono-cli/src/profile_runtime.rs b/crates/nono-cli/src/profile_runtime.rs index 761c51c27..9d868be24 100644 --- a/crates/nono-cli/src/profile_runtime.rs +++ b/crates/nono-cli/src/profile_runtime.rs @@ -470,10 +470,28 @@ fn prepare_profile_with_options( } } - verify_profile_packs(&packs_to_verify)?; + // `--dry-run` resolves the profile and prints the capabilities it + // *would* apply, then exits without ever building the sandbox or + // executing the target command (see command_runtime::run_command). + // Pack verification (lockfile digest + signed trust bundle) gates the + // execution of pack-shipped code; a preview executes nothing, so it is + // not a verification boundary. Skipping it here keeps `--dry-run` + // usable to inspect a pack's profile before a managed `nono pull` + // completes (or while recovering missing metadata). A real run still + // hits verify_profile_packs below and is rejected if unverified. + if args.dry_run { + if !packs_to_verify.is_empty() && !options.hook_output_silent { + eprintln!( + " Skipping pack verification on --dry-run ({} pack(s)); a real run verifies them", + packs_to_verify.len() + ); + } + } else { + verify_profile_packs(&packs_to_verify)?; - if !packs_to_verify.is_empty() && !options.hook_output_silent { - eprintln!(" Verified {} pack(s)", packs_to_verify.len()); + if !packs_to_verify.is_empty() && !options.hook_output_silent { + eprintln!(" Verified {} pack(s)", packs_to_verify.len()); + } } if options.install_hooks { diff --git a/tests/integration/test_pack_resolution.sh b/tests/integration/test_pack_resolution.sh index 3eae53ec4..36f668b00 100755 --- a/tests/integration/test_pack_resolution.sh +++ b/tests/integration/test_pack_resolution.sh @@ -6,7 +6,9 @@ # # - `--profile ` resolves through the pack-store branch of # load_profile (the same branch the pull/migration flow uses). -# - Sandbox enforcement honours the resolved pack-shipped profile. +# - A real (non-dry-run) invocation of an unverified hand-installed pack +# is rejected by pack verification — it has no lockfile entry / signed +# trust bundle, so it must not execute. # - Cleanup removes the pack-store entry without leaving lockfile state. # # Why hand-install rather than `nono pull`: pull goes through Sigstore @@ -17,9 +19,21 @@ # - migration.rs unit tests # - end-to-end smoke tests in nono-packs CI # +# Resolution vs. verification: `nono run --profile ` first *resolves* +# the profile (find_pack_store_profile) and then *verifies* every pack it +# declares (verify_profile_packs) before building the sandbox. Verification +# requires a lockfile entry and a signed `.nono-trust.bundle`, neither of +# which a hand-installed fixture has — and the trust bundle is pinned to the +# production trusted root, so it cannot be faked here. `--dry-run` exits +# after printing capabilities without ever executing, so it skips +# verification; that is the branch this suite uses to exercise the resolver +# in isolation. Enforcement of a pack-shipped profile during a *real* run is +# only meaningful for a properly pulled/signed pack and is covered by the +# nono-packs end-to-end CI, not here. +# # This suite is specifically about: when a pack IS installed, does -# `--profile ` find it and apply it correctly through the -# user-facing CLI. +# `--profile ` find it (resolver), and is an unverified pack correctly +# refused on a real run (verification gate)? SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" source "$SCRIPT_DIR/../lib/test_helpers.sh" @@ -32,7 +46,6 @@ if ! require_working_sandbox "pack resolution suite"; then print_summary exit 0 fi -NONO_BIN_ABS="$(cd "$(dirname "$NONO_BIN")" && pwd)/$(basename "$NONO_BIN")" FIXTURE_DIR="$(cd "$SCRIPT_DIR/../fixtures/synthetic-pack" && pwd)" if [[ ! -f "$FIXTURE_DIR/package.json" ]]; then @@ -84,21 +97,24 @@ expect_failure "unknown pack-name profile fails to resolve" \ "$NONO_BIN" run --profile pack-name-that-doesnt-exist --dry-run -- echo "test" # ============================================================================= -# Enforcement +# Verification gate (real runs require a verified pack) # ============================================================================= echo "" -echo "--- Pack-Profile Enforcement ---" - -# The synthetic profile grants $WORKDIR. Reading a file inside should work. -expect_success "synthetic profile grants workdir read" \ - bash -lc "cd \"$TMPDIR/workdir\" && \"$NONO_BIN_ABS\" run --profile synthetic --allow-cwd -- cat data.txt" - -# Outside-workdir paths should NOT be granted by the synthetic profile. -# Picking a path that's never in any embedded baseline group (so the -# failure is unambiguous). -expect_failure "synthetic profile denies arbitrary outside-workdir path" \ - "$NONO_BIN" run --profile synthetic --workdir "$TMPDIR/workdir" --allow-cwd -- cat /etc/shadow +echo "--- Pack Verification Gate ---" + +# A *real* (non-dry-run) invocation must verify every pack the profile +# declares before building the sandbox. The synthetic pack is hand-installed +# with no lockfile entry and no signed .nono-trust.bundle, so verification +# must reject it — an unverified pack's profile must never reach execution. +# (Contrast with the dry-run resolution tests above, which skip verification +# because they execute nothing.) +expect_failure "real run of unverified hand-installed pack is rejected" \ + "$NONO_BIN" run --profile synthetic --workdir "$TMPDIR/workdir" --allow-cwd -- cat data.txt + +# The rejection must be the verification gate, not some unrelated failure. +expect_output_contains "rejection cites missing pack verification metadata" "test-ns/synthetic" \ + "$NONO_BIN" run --profile synthetic --workdir "$TMPDIR/workdir" --allow-cwd -- cat data.txt # ============================================================================= # Cleanup behaviour (resolver no longer finds it after removal) From 09a56a447b86f00f602d2ab0b3fa26a652ec6997 Mon Sep 17 00:00:00 2001 From: Arnaud <1903124+Doooooo0o@users.noreply.github.com> Date: Sun, 7 Jun 2026 12:44:30 +0200 Subject: [PATCH 22/91] build: add copr source rpm packaging (#1075) Add source RPM packaging support for Fedora COPR. The new helper builds an SRPM from the current repository contents, vendors Cargo dependencies, and configures Cargo for offline builds inside COPR. Closes #1074 Signed-off-by: Doooooo0o <1903124+Doooooo0o@users.noreply.github.com> --- packaging/rpm/README.md | 47 +++++++++++++++++++ packaging/rpm/nono-cli.spec.in | 38 ++++++++++++++++ scripts/build-srpm.sh | 82 ++++++++++++++++++++++++++++++++++ 3 files changed, 167 insertions(+) create mode 100644 packaging/rpm/README.md create mode 100644 packaging/rpm/nono-cli.spec.in create mode 100755 scripts/build-srpm.sh diff --git a/packaging/rpm/README.md b/packaging/rpm/README.md new file mode 100644 index 000000000..e4b258e7c --- /dev/null +++ b/packaging/rpm/README.md @@ -0,0 +1,47 @@ +# RPM source packaging + +This directory contains the RPM source package template used to build `nono-cli` +from source for Fedora COPR. + +The GitHub Release RPM workflow uses `scripts/build-rpm.sh`, which packages an +already-built release binary. COPR should instead build from a source RPM, using +the helper below. + +## Build an SRPM + +```bash +scripts/build-srpm.sh +``` + +To build an SRPM for a specific version or tag value: + +```bash +scripts/build-srpm.sh 0.61.1 +scripts/build-srpm.sh v0.61.1 +``` + +The generated SRPM is written under: + +```text +target/rpm/copr/SRPMS/ +``` + +The helper vendors Cargo dependencies into the source tarball and writes a local +Cargo source override, so COPR can build with `cargo --offline`. + +## Submit to COPR + +After creating the COPR project, submit the generated SRPM: + +```bash +copr-cli build always-further/nono target/rpm/copr/SRPMS/*.src.rpm +``` + +Replace `always-further/nono` with the actual COPR owner/project name if the +official project uses a COPR group namespace. + +## Build requirements + +The spec expects chroots with Rust and Cargo 1.95 or newer, matching the +workspace `rust-version`. It also requires the native C/C++ toolchain and CMake +for vendored Rust dependencies that compile native code. diff --git a/packaging/rpm/nono-cli.spec.in b/packaging/rpm/nono-cli.spec.in new file mode 100644 index 000000000..9e397a639 --- /dev/null +++ b/packaging/rpm/nono-cli.spec.in @@ -0,0 +1,38 @@ +Name: nono-cli +Version: @RPM_VERSION@ +Release: @RPM_RELEASE@ +Summary: CLI for nono capability-based sandbox + +License: Apache-2.0 +URL: https://github.com/always-further/nono +Source0: nono-%{version}.tar.gz + +# This spec has no changelog; avoid distro macros trying to derive +# SOURCE_DATE_EPOCH from one. +%global source_date_epoch_from_changelog 0 + +BuildRequires: cargo >= 1.95 +BuildRequires: rust >= 1.95 +BuildRequires: gcc +BuildRequires: gcc-c++ +BuildRequires: cmake +BuildRequires: make +BuildRequires: perl + +%description +nono is a capability-based sandboxing system for running untrusted AI agents +with OS-enforced isolation. + +%prep +%autosetup -n nono-%{version} + +%build +cargo build --release --locked --offline -p nono-cli + +%install +install -Dm0755 target/release/nono %{buildroot}%{_bindir}/nono + +%files +%{_bindir}/nono +%doc README.md +%license LICENSE diff --git a/scripts/build-srpm.sh b/scripts/build-srpm.sh new file mode 100755 index 000000000..15a77bdf5 --- /dev/null +++ b/scripts/build-srpm.sh @@ -0,0 +1,82 @@ +#!/usr/bin/env bash +# Build a source RPM suitable for Fedora COPR. +# Usage: scripts/build-srpm.sh [version] + +set -euo pipefail + +usage() { + echo "Usage: $0 [version]" >&2 +} + +if [[ $# -gt 1 ]]; then + usage + exit 2 +fi + +for tool in cargo git rpmbuild tar; do + if ! command -v "${tool}" >/dev/null 2>&1; then + echo "Error: ${tool} is required to build the source RPM" >&2 + exit 1 + fi +done + +ROOT="$(git rev-parse --show-toplevel)" +if ! git -C "${ROOT}" diff-index --quiet HEAD --; then + echo "Warning: You have uncommitted changes. 'git archive' will only package committed changes from HEAD." >&2 +fi +PACKAGE_NAME="nono-cli" +VERSION="${1:-$(sed -n 's/^version = "\([^"]*\)"/\1/p' "${ROOT}/crates/nono-cli/Cargo.toml" | head -n 1)}" +VERSION="${VERSION#v}" + +if [[ -z "${VERSION}" ]]; then + echo "Error: could not determine nono-cli version" >&2 + exit 1 +fi + +RPM_VERSION="${VERSION}" +RPM_RELEASE="1%{?dist}" +if [[ "${VERSION}" == *-* ]]; then + RPM_VERSION="${VERSION%%-*}" + RPM_PRERELEASE="${VERSION#*-}" + RPM_PRERELEASE="${RPM_PRERELEASE//[^[:alnum:].]/.}" + RPM_RELEASE="0.${RPM_PRERELEASE}%{?dist}" +fi + +WORKDIR="${ROOT}/target/rpm/copr" +SOURCES_DIR="${WORKDIR}/SOURCES" +SPECS_DIR="${WORKDIR}/SPECS" +SOURCE_PARENT="${WORKDIR}/source" +SOURCE_NAME="nono-${RPM_VERSION}" +SOURCE_ROOT="${SOURCE_PARENT}/${SOURCE_NAME}" +SPEC_TEMPLATE="${ROOT}/packaging/rpm/${PACKAGE_NAME}.spec.in" +SPEC_FILE="${SPECS_DIR}/${PACKAGE_NAME}.spec" + +if [[ ! -f "${SPEC_TEMPLATE}" ]]; then + echo "Error: spec template not found: ${SPEC_TEMPLATE}" >&2 + exit 1 +fi + +rm -rf "${WORKDIR}" +mkdir -p "${SOURCES_DIR}" "${SPECS_DIR}" "${SOURCE_PARENT}" + +git -C "${ROOT}" archive --format=tar --prefix="${SOURCE_NAME}/" HEAD | tar -C "${SOURCE_PARENT}" -xf - + +mkdir -p "${SOURCE_ROOT}/.cargo" +( + cd "${SOURCE_ROOT}" + cargo vendor --quiet --locked --versioned-dirs vendor >> .cargo/config.toml +) + +sed \ + -e "s|@RPM_VERSION@|${RPM_VERSION}|g" \ + -e "s|@RPM_RELEASE@|${RPM_RELEASE}|g" \ + "${SPEC_TEMPLATE}" > "${SPEC_FILE}" + +tar -C "${SOURCE_PARENT}" -czf "${SOURCES_DIR}/${SOURCE_NAME}.tar.gz" "${SOURCE_NAME}" + +rpmbuild \ + --define "_topdir ${WORKDIR}" \ + --define "dist %{nil}" \ + -bs "${SPEC_FILE}" + +find "${WORKDIR}/SRPMS" -name '*.src.rpm' -print From 3800f77ba84b4dffb2db8ca35c09059746b416a9 Mon Sep 17 00:00:00 2001 From: Anil Kulkarni Date: Wed, 3 Jun 2026 17:03:41 -0700 Subject: [PATCH 23/91] Remove env_clear from the session_hook subprocess The session hooks already run on the host, outside of the sandbox. Therefore, there's no security boundary for restricting the environment, as the hook can source the same files (or just start a shell and export the vars) Clearing the environment, however, makes it difficult to do proper operations without resorting to these workarounds, such as setting an environment variable derived from HOME, or using PATH etc. The fix here is simply to remove env_clear, allowing the session hooks to easily have access to the same context that nono has Signed-off-by: Anil Kulkarni --- crates/nono-cli/src/hook_runtime.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/crates/nono-cli/src/hook_runtime.rs b/crates/nono-cli/src/hook_runtime.rs index 2485590ec..25db1a34c 100644 --- a/crates/nono-cli/src/hook_runtime.rs +++ b/crates/nono-cli/src/hook_runtime.rs @@ -11,7 +11,7 @@ //! //! - Script paths are validated before every execution //! (absolute, canonical, regular file, executable, owned by user, not world-writable) -//! - Hooks run as subprocesses with minimal environment +//! - Hooks run as subprocesses //! - Process group isolation for timeout-based killing //! - NONO_ENV_FILE is used for env var export (not stdout parsing) //! - Dangerous env vars are filtered before injection @@ -168,7 +168,6 @@ fn build_hook_command( kind: &HookKind<'_>, ) -> Command { let mut cmd = Command::new(script); - cmd.env_clear(); cmd.env("NONO_SESSION_ID", session_id); cmd.env("NONO_WORKDIR", workdir); cmd.env("NONO_HOOK_TYPE", kind.type_env()); From b8e5758af5a3f3d4e7a4a595375f3d20264478e4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 7 Jun 2026 07:25:54 +0000 Subject: [PATCH 24/91] chore(deps): bump hyper from 1.9.0 to 1.10.1 Bumps [hyper](https://github.com/hyperium/hyper) from 1.9.0 to 1.10.1. - [Release notes](https://github.com/hyperium/hyper/releases) - [Changelog](https://github.com/hyperium/hyper/blob/master/CHANGELOG.md) - [Commits](https://github.com/hyperium/hyper/compare/v1.9.0...v1.10.1) --- updated-dependencies: - dependency-name: hyper dependency-version: 1.10.1 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- Cargo.lock | 36 ++++++++++++++++++------------------ 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d9082c8be..be1925076 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -107,7 +107,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -118,7 +118,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -716,7 +716,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -1021,7 +1021,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -1118,7 +1118,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -1402,9 +1402,9 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.13" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" dependencies = [ "atomic-waker", "bytes", @@ -1537,9 +1537,9 @@ dependencies = [ [[package]] name = "hyper" -version = "1.9.0" +version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" dependencies = [ "atomic-waker", "bytes", @@ -1828,7 +1828,7 @@ dependencies = [ "portable-atomic", "portable-atomic-util", "serde_core", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -2332,7 +2332,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -3150,7 +3150,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -3209,7 +3209,7 @@ dependencies = [ "security-framework 3.7.0", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -3230,7 +3230,7 @@ dependencies = [ "security-framework 3.7.0", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -3904,7 +3904,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -3993,7 +3993,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -4467,7 +4467,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -4861,7 +4861,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] From a26d3b79d0608c58ebbf2693feb9d1840061d888 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 7 Jun 2026 07:25:57 +0000 Subject: [PATCH 25/91] chore(deps): bump cbindgen from 0.29.2 to 0.29.3 Bumps [cbindgen](https://github.com/mozilla/cbindgen) from 0.29.2 to 0.29.3. - [Release notes](https://github.com/mozilla/cbindgen/releases) - [Changelog](https://github.com/mozilla/cbindgen/blob/main/CHANGES) - [Commits](https://github.com/mozilla/cbindgen/compare/0.29.2...v0.29.3) --- updated-dependencies: - dependency-name: cbindgen dependency-version: 0.29.3 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- Cargo.lock | 364 ++++------------------------------------------------- 1 file changed, 27 insertions(+), 337 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index be1925076..71df16b2a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -473,15 +473,6 @@ dependencies = [ "generic-array", ] -[[package]] -name = "block2" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" -dependencies = [ - "objc2", -] - [[package]] name = "blocking" version = "1.6.2" @@ -540,9 +531,9 @@ dependencies = [ [[package]] name = "cbindgen" -version = "0.29.2" +version = "0.29.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "befbfd072a8e81c02f8c507aefce431fe5e7d051f83d48a23ffc9b9fe5a11799" +checksum = "c95537b45400390270fae69ac098d057c8f5399001cde9d04f700c105ddfff2d" dependencies = [ "clap", "heck", @@ -609,7 +600,7 @@ dependencies = [ "num-traits", "serde", "wasm-bindgen", - "windows-link 0.2.1", + "windows-link", ] [[package]] @@ -1024,16 +1015,6 @@ dependencies = [ "windows-sys 0.59.0", ] -[[package]] -name = "dispatch2" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" -dependencies = [ - "bitflags", - "objc2", -] - [[package]] name = "displaydoc" version = "0.2.5" @@ -1402,9 +1383,9 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.14" +version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" +checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" dependencies = [ "atomic-waker", "bytes", @@ -1537,9 +1518,9 @@ dependencies = [ [[package]] name = "hyper" -version = "1.10.1" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" dependencies = [ "atomic-waker", "bytes", @@ -1610,7 +1591,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.62.2", + "windows-core", ] [[package]] @@ -1887,7 +1868,7 @@ dependencies = [ "simd_cesu8", "thiserror 2.0.18", "walkdir", - "windows-link 0.2.1", + "windows-link", ] [[package]] @@ -2002,7 +1983,7 @@ dependencies = [ "secret-service", "security-framework 2.11.1", "security-framework 3.7.0", - "zbus 4.4.0", + "zbus", "zeroize", ] @@ -2086,20 +2067,6 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" -[[package]] -name = "mac-notification-sys" -version = "0.6.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd604973958ddcc11b561193c0fb96ba146506ef2f231ef2e7c35fd2cbc9beca" -dependencies = [ - "cc", - "log", - "objc2", - "objc2-foundation", - "time", - "uuid", -] - [[package]] name = "matchers" version = "0.2.0" @@ -2233,7 +2200,6 @@ dependencies = [ "colored", "dirs", "dns-lookup", - "futures", "globset", "ignore", "jsonc-parser", @@ -2243,7 +2209,6 @@ dependencies = [ "nix 0.31.3", "nono", "nono-proxy", - "notify-rust", "proptest", "rand 0.10.1", "security-framework 3.7.0", @@ -2312,20 +2277,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "notify-rust" -version = "4.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5b4c1b4f2aa9f25f63a7a49d3dd0ed567b3670da15330a66b29434be899b891" -dependencies = [ - "futures-lite", - "log", - "mac-notification-sys", - "serde", - "tauri-winrt-notification", - "zbus 5.16.0", -] - [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -2420,45 +2371,6 @@ dependencies = [ "autocfg", ] -[[package]] -name = "objc2" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" -dependencies = [ - "objc2-encode", -] - -[[package]] -name = "objc2-core-foundation" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" -dependencies = [ - "bitflags", - "dispatch2", - "objc2", -] - -[[package]] -name = "objc2-encode" -version = "4.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" - -[[package]] -name = "objc2-foundation" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" -dependencies = [ - "bitflags", - "block2", - "libc", - "objc2", - "objc2-core-foundation", -] - [[package]] name = "oid-registry" version = "0.7.1" @@ -2565,7 +2477,7 @@ dependencies = [ "libc", "redox_syscall", "smallvec", - "windows-link 0.2.1", + "windows-link", ] [[package]] @@ -3354,7 +3266,7 @@ dependencies = [ "rand 0.8.6", "serde", "sha2 0.10.9", - "zbus 4.4.0", + "zbus", ] [[package]] @@ -3972,17 +3884,6 @@ dependencies = [ "syn", ] -[[package]] -name = "tauri-winrt-notification" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed071c670382e85fc2f48ae706492d8c338f4f89bf72520d32f8abfe880aade" -dependencies = [ - "thiserror 2.0.18", - "windows", - "windows-version", -] - [[package]] name = "tempfile" version = "3.27.0" @@ -3990,7 +3891,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.2", + "getrandom 0.3.4", "once_cell", "rustix", "windows-sys 0.52.0", @@ -4593,18 +4494,6 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" -[[package]] -name = "uuid" -version = "1.23.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53" -dependencies = [ - "getrandom 0.4.2", - "js-sys", - "serde_core", - "wasm-bindgen", -] - [[package]] name = "uuid-simd" version = "0.8.0" @@ -4864,41 +4753,6 @@ dependencies = [ "windows-sys 0.52.0", ] -[[package]] -name = "windows" -version = "0.61.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" -dependencies = [ - "windows-collections", - "windows-core 0.61.2", - "windows-future", - "windows-link 0.1.3", - "windows-numerics", -] - -[[package]] -name = "windows-collections" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" -dependencies = [ - "windows-core 0.61.2", -] - -[[package]] -name = "windows-core" -version = "0.61.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" -dependencies = [ - "windows-implement", - "windows-interface", - "windows-link 0.1.3", - "windows-result 0.3.4", - "windows-strings 0.4.2", -] - [[package]] name = "windows-core" version = "0.62.2" @@ -4907,20 +4761,9 @@ checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ "windows-implement", "windows-interface", - "windows-link 0.2.1", - "windows-result 0.4.1", - "windows-strings 0.5.1", -] - -[[package]] -name = "windows-future" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" -dependencies = [ - "windows-core 0.61.2", - "windows-link 0.1.3", - "windows-threading", + "windows-link", + "windows-result", + "windows-strings", ] [[package]] @@ -4945,53 +4788,19 @@ dependencies = [ "syn", ] -[[package]] -name = "windows-link" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" - [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" -[[package]] -name = "windows-numerics" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" -dependencies = [ - "windows-core 0.61.2", - "windows-link 0.1.3", -] - -[[package]] -name = "windows-result" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" -dependencies = [ - "windows-link 0.1.3", -] - [[package]] name = "windows-result" version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" dependencies = [ - "windows-link 0.2.1", -] - -[[package]] -name = "windows-strings" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" -dependencies = [ - "windows-link 0.1.3", + "windows-link", ] [[package]] @@ -5000,7 +4809,7 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" dependencies = [ - "windows-link 0.2.1", + "windows-link", ] [[package]] @@ -5045,7 +4854,7 @@ version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ - "windows-link 0.2.1", + "windows-link", ] [[package]] @@ -5085,7 +4894,7 @@ version = "0.53.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" dependencies = [ - "windows-link 0.2.1", + "windows-link", "windows_aarch64_gnullvm 0.53.1", "windows_aarch64_msvc 0.53.1", "windows_i686_gnu 0.53.1", @@ -5096,24 +4905,6 @@ dependencies = [ "windows_x86_64_msvc 0.53.1", ] -[[package]] -name = "windows-threading" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" -dependencies = [ - "windows-link 0.1.3", -] - -[[package]] -name = "windows-version" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4060a1da109b9d0326b7262c8e12c84df67cc0dbc9e33cf49e01ccc2eb63631" -dependencies = [ - "windows-link 0.2.1", -] - [[package]] name = "windows_aarch64_gnullvm" version = "0.42.2" @@ -5503,44 +5294,9 @@ dependencies = [ "uds_windows", "windows-sys 0.52.0", "xdg-home", - "zbus_macros 4.4.0", - "zbus_names 3.0.0", - "zvariant 4.2.0", -] - -[[package]] -name = "zbus" -version = "5.16.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eee682d202a77e4a9f3b2c2bdf48a7b28af5c08c34ddf66f98c93e5e39464285" -dependencies = [ - "async-broadcast", - "async-executor", - "async-io", - "async-lock", - "async-process", - "async-recursion", - "async-task", - "async-trait", - "blocking", - "enumflags2", - "event-listener", - "futures-core", - "futures-lite", - "hex", - "libc", - "ordered-stream", - "rustix", - "serde", - "serde_repr", - "tracing", - "uds_windows", - "uuid", - "windows-sys 0.61.2", - "winnow 1.0.2", - "zbus_macros 5.16.0", - "zbus_names 4.3.2", - "zvariant 5.12.0", + "zbus_macros", + "zbus_names", + "zvariant", ] [[package]] @@ -5553,22 +5309,7 @@ dependencies = [ "proc-macro2", "quote", "syn", - "zvariant_utils 2.1.0", -] - -[[package]] -name = "zbus_macros" -version = "5.16.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "adf1bd45a81a103745b1757754762a26e8cd01e4532e4d6c8ec431624b80d1d6" -dependencies = [ - "proc-macro-crate", - "proc-macro2", - "quote", - "syn", - "zbus_names 4.3.2", - "zvariant 5.12.0", - "zvariant_utils 3.4.0", + "zvariant_utils", ] [[package]] @@ -5579,18 +5320,7 @@ checksum = "4b9b1fef7d021261cc16cba64c351d291b715febe0fa10dc3a443ac5a5022e6c" dependencies = [ "serde", "static_assertions", - "zvariant 4.2.0", -] - -[[package]] -name = "zbus_names" -version = "4.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7074f3e50b894eac91750142016d30d0a89be8e67dbfd9704fb875825760e52d" -dependencies = [ - "serde", - "winnow 1.0.2", - "zvariant 5.12.0", + "zvariant", ] [[package]] @@ -5704,21 +5434,7 @@ dependencies = [ "enumflags2", "serde", "static_assertions", - "zvariant_derive 4.2.0", -] - -[[package]] -name = "zvariant" -version = "5.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a192a0bde63360d77a7523c833d4b4ce6070a927e2c53246e4c540b1a3e27be0" -dependencies = [ - "endi", - "enumflags2", - "serde", - "winnow 1.0.2", - "zvariant_derive 5.12.0", - "zvariant_utils 3.4.0", + "zvariant_derive", ] [[package]] @@ -5731,20 +5447,7 @@ dependencies = [ "proc-macro2", "quote", "syn", - "zvariant_utils 2.1.0", -] - -[[package]] -name = "zvariant_derive" -version = "5.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90bc6cde9c01c511074be97f7ccb6c19d0da89e3f8662e812e999dcfd4638737" -dependencies = [ - "proc-macro-crate", - "proc-macro2", - "quote", - "syn", - "zvariant_utils 3.4.0", + "zvariant_utils", ] [[package]] @@ -5757,16 +5460,3 @@ dependencies = [ "quote", "syn", ] - -[[package]] -name = "zvariant_utils" -version = "3.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e8535915cfa75547e559d8c68e8139909a4aeee076831e4ef7fc59d8172c4d6" -dependencies = [ - "proc-macro2", - "quote", - "serde", - "syn", - "winnow 1.0.2", -] From 0b6e87ec14e0fa2edcd293f208d619ec875b912e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 2 Jun 2026 19:43:01 +0000 Subject: [PATCH 26/91] chore(deps): bump x509-parser from 0.16.0 to 0.18.1 Bumps [x509-parser](https://github.com/rusticata/x509-parser) from 0.16.0 to 0.18.1. - [Changelog](https://github.com/rusticata/x509-parser/blob/master/CHANGELOG.md) - [Commits](https://github.com/rusticata/x509-parser/commits) --- updated-dependencies: - dependency-name: x509-parser dependency-version: 0.18.1 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- Cargo.lock | 86 ++++-------------------------------- crates/nono-cli/Cargo.toml | 2 +- crates/nono-proxy/Cargo.toml | 2 +- 3 files changed, 11 insertions(+), 79 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 71df16b2a..0342fc897 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -133,29 +133,13 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" -[[package]] -name = "asn1-rs" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5493c3bedbacf7fd7382c6346bbd66687d12bbaad3a89a2d2c303ee6cf20b048" -dependencies = [ - "asn1-rs-derive 0.5.1", - "asn1-rs-impl", - "displaydoc", - "nom", - "num-traits", - "rusticata-macros", - "thiserror 1.0.69", - "time", -] - [[package]] name = "asn1-rs" version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8" dependencies = [ - "asn1-rs-derive 0.6.0", + "asn1-rs-derive", "asn1-rs-impl", "displaydoc", "nom", @@ -165,18 +149,6 @@ dependencies = [ "time", ] -[[package]] -name = "asn1-rs-derive" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "965c2d33e53cb6b267e148a4cb0760bc01f4904c1cd4bb4002a085bb016d1490" -dependencies = [ - "proc-macro2", - "quote", - "syn", - "synstructure", -] - [[package]] name = "asn1-rs-derive" version = "0.6.0" @@ -915,27 +887,13 @@ dependencies = [ "zeroize", ] -[[package]] -name = "der-parser" -version = "9.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5cd0a5c643689626bec213c4d8bd4d96acc8ffdb4ad4bb6bc16abf27d5f4b553" -dependencies = [ - "asn1-rs 0.6.2", - "displaydoc", - "nom", - "num-bigint", - "num-traits", - "rusticata-macros", -] - [[package]] name = "der-parser" version = "10.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6" dependencies = [ - "asn1-rs 0.7.2", + "asn1-rs", "displaydoc", "nom", "num-bigint", @@ -2232,7 +2190,7 @@ dependencies = [ "vt100", "walkdir", "which", - "x509-parser 0.16.0", + "x509-parser", "xdg-home", "zeroize", ] @@ -2273,7 +2231,7 @@ dependencies = [ "url", "urlencoding", "webpki-roots", - "x509-parser 0.16.0", + "x509-parser", "zeroize", ] @@ -2371,22 +2329,13 @@ dependencies = [ "autocfg", ] -[[package]] -name = "oid-registry" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8d8034d9489cdaf79228eb9f6a3b8d7bb32ba00d6645ebd48eef4077ceb5bd9" -dependencies = [ - "asn1-rs 0.6.2", -] - [[package]] name = "oid-registry" version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7" dependencies = [ - "asn1-rs 0.7.2", + "asn1-rs", ] [[package]] @@ -2832,7 +2781,7 @@ dependencies = [ "ring", "rustls-pki-types", "time", - "x509-parser 0.18.1", + "x509-parser", "yasna", ] @@ -5172,35 +5121,18 @@ dependencies = [ "tls_codec", ] -[[package]] -name = "x509-parser" -version = "0.16.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fcbc162f30700d6f3f82a24bf7cc62ffe7caea42c0b2cba8bf7f3ae50cf51f69" -dependencies = [ - "asn1-rs 0.6.2", - "data-encoding", - "der-parser 9.0.0", - "lazy_static", - "nom", - "oid-registry 0.7.1", - "rusticata-macros", - "thiserror 1.0.69", - "time", -] - [[package]] name = "x509-parser" version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202" dependencies = [ - "asn1-rs 0.7.2", + "asn1-rs", "data-encoding", - "der-parser 10.0.0", + "der-parser", "lazy_static", "nom", - "oid-registry 0.8.1", + "oid-registry", "ring", "rusticata-macros", "thiserror 2.0.18", diff --git a/crates/nono-cli/Cargo.toml b/crates/nono-cli/Cargo.toml index 664f25769..df11d8178 100644 --- a/crates/nono-cli/Cargo.toml +++ b/crates/nono-cli/Cargo.toml @@ -102,7 +102,7 @@ nix = { workspace = true, features = ["process", "signal", "fs", "user", "term"] dns-lookup = "2" keyring = { version = "3", features = ["apple-native"], optional = true, default-features = false } security-framework = "3" -x509-parser = "0.16" +x509-parser = "0.18" [build-dependencies] toml.workspace = true diff --git a/crates/nono-proxy/Cargo.toml b/crates/nono-proxy/Cargo.toml index 8d2ce6e41..99499ea8e 100644 --- a/crates/nono-proxy/Cargo.toml +++ b/crates/nono-proxy/Cargo.toml @@ -47,7 +47,7 @@ webpki-roots = "1" # the rustls crypto provider used elsewhere; `aws_lc_rs` is disabled to avoid # a second, redundant crypto stack in the dependency tree. rcgen = { version = "0.14", default-features = false, features = ["pem", "ring", "x509-parser"] } -x509-parser = "0.16" +x509-parser = "0.18" # Read the host's system trust store (used to compose the trust bundle that # the sandboxed child consumes via SSL_CERT_FILE / REQUESTS_CA_BUNDLE / etc.). # We need full DER certs for PEM emission; webpki-roots only ships parsed From 1521d4733d40b18f236293f0f0206a536cbeef90 Mon Sep 17 00:00:00 2001 From: Doooooo0o <1903124+Doooooo0o@users.noreply.github.com> Date: Mon, 8 Jun 2026 10:54:24 +0200 Subject: [PATCH 27/91] fix: write cargo vendor config for copr srpms Write the Cargo source replacement config explicitly after vendoring COPR SRPM dependencies. With `cargo vendor --quiet`, Cargo does not emit the config snippet to stdout, which left `.cargo/config.toml` empty and caused COPR offline builds to fail resolving vendored crates. Closes #1103 Signed-off-by: Doooooo0o <1903124+Doooooo0o@users.noreply.github.com> --- scripts/build-srpm.sh | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/scripts/build-srpm.sh b/scripts/build-srpm.sh index 15a77bdf5..e4a548631 100755 --- a/scripts/build-srpm.sh +++ b/scripts/build-srpm.sh @@ -64,8 +64,15 @@ git -C "${ROOT}" archive --format=tar --prefix="${SOURCE_NAME}/" HEAD | tar -C " mkdir -p "${SOURCE_ROOT}/.cargo" ( cd "${SOURCE_ROOT}" - cargo vendor --quiet --locked --versioned-dirs vendor >> .cargo/config.toml + cargo vendor --quiet --locked --versioned-dirs vendor >/dev/null ) +cat > "${SOURCE_ROOT}/.cargo/config.toml" <<'EOF' +[source.crates-io] +replace-with = "vendored-sources" + +[source.vendored-sources] +directory = "vendor" +EOF sed \ -e "s|@RPM_VERSION@|${RPM_VERSION}|g" \ From 54fff0bf4c12d081ac0dc9387857653b25d51769 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 8 Jun 2026 14:34:48 +0000 Subject: [PATCH 28/91] chore(deps): bump actions/checkout from 6.0.2 to 6.0.3 Bumps [actions/checkout](https://github.com/actions/checkout) from 6.0.2 to 6.0.3. - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v6.0.2...df4cb1c069e1874edd31b4311f1884172cec0e10) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: 6.0.3 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- .github/workflows/aur-publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/aur-publish.yml b/.github/workflows/aur-publish.yml index 501a9c67e..c2754083a 100644 --- a/.github/workflows/aur-publish.yml +++ b/.github/workflows/aur-publish.yml @@ -78,7 +78,7 @@ jobs: # the workflow runs on (the default branch), not from the release tag: # packaging fixes must flow into the next publish, and tags that predate # this workflow have no packaging/aur directory at all. - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - name: Create build user # makepkg and updpkgsums refuse to run as root. The workspace is From 0255af475a4ee845308f9c73c6e00f8db761d37f Mon Sep 17 00:00:00 2001 From: Doooooo0o <1903124+Doooooo0o@users.noreply.github.com> Date: Tue, 9 Jun 2026 07:52:02 +0200 Subject: [PATCH 29/91] docs: add copr installation instructions Signed-off-by: Doooooo0o <1903124+Doooooo0o@users.noreply.github.com> --- docs/cli/getting_started/installation.mdx | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/docs/cli/getting_started/installation.mdx b/docs/cli/getting_started/installation.mdx index 9901d58e6..f3201f991 100644 --- a/docs/cli/getting_started/installation.mdx +++ b/docs/cli/getting_started/installation.mdx @@ -22,7 +22,17 @@ wget https://github.com/always-further/nono/releases/download/v${VERSION}/nono-c sudo dpkg -i nono-cli_${VERSION}_${ARCH}.deb ``` -### Fedora/RHEL/openSUSE +### Fedora (COPR) + +nono is available from the official [COPR repository](https://copr.fedorainfracloud.org/coprs/always-further/nono/): + +```bash +sudo dnf install 'dnf-command(copr)' +sudo dnf copr enable always-further/nono +sudo dnf install nono-cli +``` + +### Manual RPM (RHEL/openSUSE and fallback) Download the `.rpm` package from [GitHub Releases](https://github.com/always-further/nono/releases): From 75c516004104a3c333a80480c556fdd0be1e1b06 Mon Sep 17 00:00:00 2001 From: Luke Hinds Date: Tue, 9 Jun 2026 21:17:42 +0100 Subject: [PATCH 30/91] refactor(audit): move audit integrity logic to nono crate Relocates the core audit recording and verification functionality from the `nono-cli` crate into a new `audit` module in the `nono` library. - moves `AuditRecorder`, `verify_audit_log`, and related types/constants from `crates/nono-cli/src/audit_integrity.rs` - creates new module `crates/nono/src/audit.rs` - `nono-cli` now imports these items from `nono::audit` This change makes the audit integrity logic a central library concern, improving code organization and enabling its reuse outside of the CLI. Signed-off-by: Luke Hinds --- crates/nono-cli/src/audit_integrity.rs | 400 +------------ crates/nono/src/audit.rs | 793 +++++++++++++++++++++++++ crates/nono/src/lib.rs | 1 + 3 files changed, 798 insertions(+), 396 deletions(-) create mode 100644 crates/nono/src/audit.rs diff --git a/crates/nono-cli/src/audit_integrity.rs b/crates/nono-cli/src/audit_integrity.rs index 17837f771..1ee006a70 100644 --- a/crates/nono-cli/src/audit_integrity.rs +++ b/crates/nono-cli/src/audit_integrity.rs @@ -1,399 +1,7 @@ -use nono::supervisor::{AuditEntry, UrlOpenRequest}; -use nono::undo::{AuditIntegritySummary, ContentHash, NetworkAuditEvent}; -use nono::{NonoError, Result}; -use serde::{Deserialize, Serialize}; -use sha2::{Digest, Sha256}; -use std::fs::{File, OpenOptions}; -use std::io::{BufRead, BufReader, Write}; -use std::path::{Path, PathBuf}; +pub(crate) use nono::audit::{AuditRecorder, verify_audit_log}; -pub(crate) const AUDIT_EVENTS_FILENAME: &str = "audit-events.ndjson"; -const EVENT_DOMAIN: &[u8] = b"nono.audit.event.alpha\n"; -const CHAIN_DOMAIN: &[u8] = b"nono.audit.chain.alpha\n"; -const MERKLE_NODE_DOMAIN_ALPHA: &[u8] = b"nono.audit.merkle.alpha\n"; -const MERKLE_SCHEME_LABEL: &str = "alpha"; -const HASH_ALGORITHM: &str = "sha256"; - -#[derive(Clone, Serialize, Deserialize)] -#[serde(tag = "type", rename_all = "snake_case")] -pub(crate) enum AuditEventPayload { - SessionStarted { - started: String, - command: Vec, - #[serde(default, skip_serializing_if = "Option::is_none")] - redaction_policy: Option, - }, - SessionEnded { - ended: String, - exit_code: i32, - }, - CapabilityDecision { - entry: AuditEntry, - }, - UrlOpen { - request: UrlOpenRequest, - success: bool, - error: Option, - }, - Network { - event: NetworkAuditEvent, - }, -} - -#[derive(Clone, Serialize, Deserialize)] -pub(crate) struct AuditEventRecord { - pub(crate) sequence: u64, - pub(crate) prev_chain: Option, - pub(crate) leaf_hash: ContentHash, - pub(crate) chain_hash: ContentHash, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub(crate) event_json: Option, - pub(crate) event: AuditEventPayload, -} - -#[derive(Serialize)] -pub(crate) struct AuditVerificationResult { - pub(crate) hash_algorithm: String, - pub(crate) merkle_scheme: String, - pub(crate) event_count: u64, - pub(crate) computed_chain_head: Option, - pub(crate) computed_merkle_root: Option, - pub(crate) stored_event_count: Option, - pub(crate) stored_chain_head: Option, - pub(crate) stored_merkle_root: Option, - pub(crate) event_count_matches: bool, - pub(crate) records_verified: bool, -} - -pub(crate) struct AuditRecorder { - file: File, - next_sequence: u64, - previous_chain: Option, - leaf_hashes: Vec, - redaction_policy: nono::ScrubPolicy, -} - -impl AuditRecorder { - #[cfg(test)] - pub(crate) fn new(session_dir: PathBuf) -> Result { - Self::new_with_policy(session_dir, nono::ScrubPolicy::secure_default()) - } - - pub(crate) fn new_with_policy( - session_dir: PathBuf, - redaction_policy: nono::ScrubPolicy, - ) -> Result { - let path = session_dir.join(AUDIT_EVENTS_FILENAME); - let file = OpenOptions::new() - .create(true) - .append(true) - .open(&path) - .map_err(|e| { - NonoError::Snapshot(format!( - "Failed to open audit event log {}: {e}", - path.display() - )) - })?; - Ok(Self { - file, - next_sequence: 0, - previous_chain: None, - leaf_hashes: Vec::new(), - redaction_policy, - }) - } - - pub(crate) fn record_session_started( - &mut self, - started: String, - command: Vec, - ) -> Result<()> { - self.append_event(AuditEventPayload::SessionStarted { - started, - command: nono::scrub_argv_with_policy(&command, &self.redaction_policy), - redaction_policy: self - .redaction_policy - .diff_from_secure_default() - .into_option(), - }) - } - - pub(crate) fn record_session_ended(&mut self, ended: String, exit_code: i32) -> Result<()> { - self.append_event(AuditEventPayload::SessionEnded { ended, exit_code }) - } - - pub(crate) fn record_capability_decision(&mut self, entry: AuditEntry) -> Result<()> { - self.append_event(AuditEventPayload::CapabilityDecision { entry }) - } - - pub(crate) fn record_open_url( - &mut self, - request: UrlOpenRequest, - success: bool, - error: Option, - ) -> Result<()> { - self.append_event(AuditEventPayload::UrlOpen { - request, - success, - error, - }) - } - - pub(crate) fn record_network_event(&mut self, event: NetworkAuditEvent) -> Result<()> { - self.append_event(AuditEventPayload::Network { event }) - } - - pub(crate) fn event_count(&self) -> u64 { - self.leaf_hashes.len() as u64 - } - - pub(crate) fn finalize(&self) -> Option { - let chain_head = self.previous_chain?; - let merkle_root = merkle_root(&self.leaf_hashes); - Some(AuditIntegritySummary { - hash_algorithm: HASH_ALGORITHM.to_string(), - event_count: self.event_count(), - chain_head, - merkle_root, - }) - } - - fn append_event(&mut self, event: AuditEventPayload) -> Result<()> { - let event_bytes = serde_json::to_vec(&event) - .map_err(|e| NonoError::Snapshot(format!("Failed to serialize audit event: {e}")))?; - let leaf_hash = hash_event(&event_bytes); - let chain_hash = hash_chain(self.previous_chain.as_ref(), &leaf_hash); - let record = AuditEventRecord { - sequence: self.next_sequence, - prev_chain: self.previous_chain, - leaf_hash, - chain_hash, - event_json: Some(String::from_utf8(event_bytes.clone()).map_err(|e| { - NonoError::Snapshot(format!( - "Failed to encode canonical audit event JSON as UTF-8: {e}" - )) - })?), - event, - }; - let line = serde_json::to_vec(&record) - .map_err(|e| NonoError::Snapshot(format!("Failed to serialize audit record: {e}")))?; - self.file - .write_all(&line) - .and_then(|_| self.file.write_all(b"\n")) - .and_then(|_| self.file.flush()) - .map_err(|e| NonoError::Snapshot(format!("Failed to append audit record: {e}")))?; - self.next_sequence = self.next_sequence.saturating_add(1); - self.previous_chain = Some(chain_hash); - self.leaf_hashes.push(leaf_hash); - Ok(()) - } -} - -fn hash_event(event_bytes: &[u8]) -> ContentHash { - let mut hasher = Sha256::new(); - hasher.update(EVENT_DOMAIN); - hasher.update(event_bytes); - ContentHash::from_bytes(hasher.finalize().into()) -} - -fn hash_chain(previous: Option<&ContentHash>, leaf_hash: &ContentHash) -> ContentHash { - let mut hasher = Sha256::new(); - hasher.update(CHAIN_DOMAIN); - if let Some(prev) = previous { - hasher.update(prev.as_bytes()); - } else { - hasher.update([0u8; 32]); - } - hasher.update(leaf_hash.as_bytes()); - ContentHash::from_bytes(hasher.finalize().into()) -} - -fn merkle_root(leaves: &[ContentHash]) -> ContentHash { - if leaves.is_empty() { - return ContentHash::from_bytes(Sha256::digest(b"").into()); - } - - let mut level: Vec<[u8; 32]> = leaves.iter().map(|leaf| *leaf.as_bytes()).collect(); - while level.len() > 1 { - let mut next = Vec::with_capacity(level.len().div_ceil(2)); - for pair in level.chunks(2) { - let left = pair[0]; - if pair.len() == 1 { - next.push(left); - continue; - } - - let right = pair[1]; - let mut hasher = Sha256::new(); - hasher.update(MERKLE_NODE_DOMAIN_ALPHA); - hasher.update(left); - hasher.update(right); - next.push(hasher.finalize().into()); - } - level = next; - } - ContentHash::from_bytes(level[0]) -} - -pub(crate) fn verify_audit_log( - session_dir: &Path, - stored: Option<&AuditIntegritySummary>, -) -> Result { - let path = session_dir.join(AUDIT_EVENTS_FILENAME); - let file = File::open(&path).map_err(|e| { - NonoError::Snapshot(format!( - "Failed to open audit event log {}: {e}", - path.display() - )) - })?; - - let reader = BufReader::new(file); - let mut previous_chain: Option = None; - let mut leaf_hashes = Vec::new(); - let mut computed_chain_head: Option = None; - let mut missing_canonical_event_json = false; - - for (index, line) in reader.lines().enumerate() { - let line = line.map_err(|e| { - NonoError::Snapshot(format!( - "Failed to read audit event log {}: {e}", - path.display() - )) - })?; - if line.trim().is_empty() { - continue; - } - - let record: AuditEventRecord = serde_json::from_str(&line).map_err(|e| { - NonoError::Snapshot(format!( - "Failed to parse audit event record {} line {}: {e}", - path.display(), - index.saturating_add(1) - )) - })?; - - let expected_sequence = leaf_hashes.len() as u64; - if record.sequence != expected_sequence { - return Err(NonoError::Snapshot(format!( - "Audit event record sequence mismatch at line {}: expected {}, got {}", - index.saturating_add(1), - expected_sequence, - record.sequence - ))); - } - - if record.prev_chain != previous_chain { - return Err(NonoError::Snapshot(format!( - "Audit event record prev_chain mismatch at line {}", - index.saturating_add(1) - ))); - } - - let event_bytes = if let Some(raw) = record.event_json.as_ref() { - let reparsed: AuditEventPayload = serde_json::from_str(raw).map_err(|e| { - NonoError::Snapshot(format!( - "Failed to parse canonical audit event JSON at line {}: {e}", - index.saturating_add(1) - )) - })?; - let reparsed_value = serde_json::to_value(&reparsed).map_err(|e| { - NonoError::Snapshot(format!( - "Failed to normalize canonical audit event JSON at line {}: {e}", - index.saturating_add(1) - )) - })?; - let record_value = serde_json::to_value(&record.event).map_err(|e| { - NonoError::Snapshot(format!( - "Failed to normalize audit event payload at line {}: {e}", - index.saturating_add(1) - )) - })?; - if reparsed_value != record_value { - return Err(NonoError::Snapshot(format!( - "Audit event JSON mismatch at line {}", - index.saturating_add(1) - ))); - } - raw.as_bytes().to_vec() - } else { - missing_canonical_event_json = true; - serde_json::to_vec(&record.event).map_err(|e| { - NonoError::Snapshot(format!( - "Failed to serialize audit event for verification at line {}: {e}", - index.saturating_add(1) - )) - })? - }; - let leaf_hash = hash_event(&event_bytes); - if record.leaf_hash != leaf_hash { - return Err(NonoError::Snapshot(format!( - "Audit event leaf hash mismatch at line {}", - index.saturating_add(1) - ))); - } - - let chain_hash = hash_chain(previous_chain.as_ref(), &leaf_hash); - if record.chain_hash != chain_hash { - return Err(NonoError::Snapshot(format!( - "Audit event chain hash mismatch at line {}", - index.saturating_add(1) - ))); - } - - previous_chain = Some(chain_hash); - computed_chain_head = Some(chain_hash); - leaf_hashes.push(leaf_hash); - } - - let computed_merkle_root = if leaf_hashes.is_empty() { - None - } else { - Some(merkle_root(&leaf_hashes)) - }; - - if stored.is_some() && !leaf_hashes.is_empty() && missing_canonical_event_json { - return Err(NonoError::Snapshot( - "Alpha audit log is missing canonical event_json bytes".to_string(), - )); - } - - let stored_event_count = stored.map(|s| s.event_count); - let stored_chain_head = stored.map(|s| s.chain_head); - let stored_merkle_root = stored.map(|s| s.merkle_root); - let event_count = leaf_hashes.len() as u64; - let event_count_matches = stored_event_count - .map(|count| count == event_count) - .unwrap_or(true); - - if let Some(stored_head) = stored_chain_head - && Some(stored_head) != computed_chain_head - { - return Err(NonoError::Snapshot( - "Alpha audit log chain head mismatch".to_string(), - )); - } - - if let Some(stored_root) = stored_merkle_root - && Some(stored_root) != computed_merkle_root - { - return Err(NonoError::Snapshot( - "Alpha audit log Merkle root mismatch".to_string(), - )); - } - - Ok(AuditVerificationResult { - hash_algorithm: HASH_ALGORITHM.to_string(), - merkle_scheme: MERKLE_SCHEME_LABEL.to_string(), - event_count, - computed_chain_head, - computed_merkle_root, - stored_event_count, - stored_chain_head, - stored_merkle_root, - event_count_matches, - records_verified: true, - }) -} +#[cfg(test)] +pub(crate) use nono::audit::{AUDIT_EVENTS_FILENAME, AUDIT_HASH_ALGORITHM, AuditEventRecord}; #[cfg(test)] #[allow(clippy::unwrap_used)] @@ -418,7 +26,7 @@ mod tests { let summary = recorder.finalize().unwrap(); assert_eq!(summary.event_count, 2); - assert_eq!(summary.hash_algorithm, HASH_ALGORITHM); + assert_eq!(summary.hash_algorithm, AUDIT_HASH_ALGORITHM); } #[test] diff --git a/crates/nono/src/audit.rs b/crates/nono/src/audit.rs new file mode 100644 index 000000000..5caf60b82 --- /dev/null +++ b/crates/nono/src/audit.rs @@ -0,0 +1,793 @@ +//! Append-only audit log primitives. +//! +//! The alpha scheme records each event as an NDJSON envelope containing a +//! monotonic sequence number, a rolling chain hash, and a Merkle leaf hash. +//! A final [`AuditIntegritySummary`] commits to the event count, chain head, +//! and Merkle root. + +use crate::supervisor::{AuditEntry, UrlOpenRequest}; +use crate::undo::{AuditIntegritySummary, ContentHash, NetworkAuditEvent}; +use crate::{NonoError, Result}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::fs::{File, OpenOptions}; +use std::io::{BufRead, BufReader, Write}; +use std::path::{Path, PathBuf}; + +/// Filename used for per-session audit event logs. +pub const AUDIT_EVENTS_FILENAME: &str = "audit-events.ndjson"; + +/// Domain separator for alpha event leaf hashes. +pub const EVENT_DOMAIN_ALPHA: &[u8] = b"nono.audit.event.alpha\n"; +/// Domain separator for alpha rolling chain hashes. +pub const CHAIN_DOMAIN_ALPHA: &[u8] = b"nono.audit.chain.alpha\n"; +/// Domain separator for alpha Merkle internal-node hashes. +pub const MERKLE_NODE_DOMAIN_ALPHA: &[u8] = b"nono.audit.merkle.alpha\n"; +/// Merkle scheme label emitted by alpha verification. +pub const MERKLE_SCHEME_ALPHA: &str = "alpha"; +/// Hash algorithm label emitted by alpha verification. +pub const AUDIT_HASH_ALGORITHM: &str = "sha256"; + +/// Event payloads written into the alpha audit log. +#[derive(Clone, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum AuditEventPayload { + /// Session start event. + SessionStarted { + /// ISO-8601 start timestamp. + started: String, + /// Redacted command line. + command: Vec, + /// Redaction policy delta from the secure default, when configured. + #[serde(default, skip_serializing_if = "Option::is_none")] + redaction_policy: Option, + }, + /// Session end event. + SessionEnded { + /// ISO-8601 end timestamp. + ended: String, + /// Child process exit code. + exit_code: i32, + }, + /// Capability approval decision. + CapabilityDecision { + /// Supervisor audit entry. + entry: AuditEntry, + }, + /// URL-open request result. + UrlOpen { + /// URL-open request. + request: UrlOpenRequest, + /// Whether the request succeeded. + success: bool, + /// Error message, when the request failed. + error: Option, + }, + /// Network audit event. + Network { + /// Network audit event emitted by the proxy or sandbox supervisor. + event: NetworkAuditEvent, + }, +} + +/// One line of `audit-events.ndjson`. +#[derive(Clone, Serialize, Deserialize)] +pub struct AuditEventRecord { + /// Monotonic sequence number, starting at 0. + pub sequence: u64, + /// Previous record's chain hash, or `None` for the first record. + pub prev_chain: Option, + /// Hash of the canonical event JSON bytes. + pub leaf_hash: ContentHash, + /// Rolling chain hash over the previous chain hash and this leaf. + pub chain_hash: ContentHash, + /// Canonical event JSON bytes used to derive `leaf_hash`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub event_json: Option, + /// Parsed event payload. + pub event: AuditEventPayload, +} + +/// Result of verifying an alpha audit log. +#[derive(Serialize)] +pub struct AuditVerificationResult { + /// Hash algorithm used for event leaves and chain/root derivation. + pub hash_algorithm: String, + /// Merkle scheme label. + pub merkle_scheme: String, + /// Number of verified events. + pub event_count: u64, + /// Recomputed rolling chain head. + pub computed_chain_head: Option, + /// Recomputed Merkle root over ordered event leaves. + pub computed_merkle_root: Option, + /// Stored event count from session metadata, when supplied. + pub stored_event_count: Option, + /// Stored chain head from session metadata, when supplied. + pub stored_chain_head: Option, + /// Stored Merkle root from session metadata, when supplied. + pub stored_merkle_root: Option, + /// Whether the stored event count matches the recomputed count. + pub event_count_matches: bool, + /// True when all record-level checks passed. + pub records_verified: bool, +} + +/// Position of a sibling hash in an audit Merkle inclusion proof. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AuditProofDirection { + /// The sibling hash is the left input to this Merkle node. + Left, + /// The sibling hash is the right input to this Merkle node. + Right, +} + +/// One sibling step in an audit Merkle inclusion proof. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AuditProofNode { + /// Which side of the current hash this sibling occupies. + pub direction: AuditProofDirection, + /// Sibling hash. + pub hash: ContentHash, +} + +/// Compact proof that one audit leaf is included in an alpha Merkle root. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AuditInclusionProof { + /// Zero-based leaf index. + pub leaf_index: u64, + /// Total number of leaves in the tree. + pub leaf_count: u64, + /// Included audit leaf hash. + pub leaf_hash: ContentHash, + /// Claimed alpha Merkle root. + pub merkle_root: ContentHash, + /// Sibling path from leaf to root. + pub siblings: Vec, +} + +/// Stateful writer for alpha-scheme audit records. +pub struct AuditRecorder { + file: File, + next_sequence: u64, + previous_chain: Option, + leaf_hashes: Vec, + redaction_policy: crate::ScrubPolicy, +} + +impl AuditRecorder { + /// Create a recorder with the secure default redaction policy. + pub fn new(session_dir: PathBuf) -> Result { + Self::new_with_policy(session_dir, crate::ScrubPolicy::secure_default()) + } + + /// Create a recorder using a caller-supplied redaction policy. + pub fn new_with_policy( + session_dir: PathBuf, + redaction_policy: crate::ScrubPolicy, + ) -> Result { + let path = session_dir.join(AUDIT_EVENTS_FILENAME); + let file = OpenOptions::new() + .create(true) + .append(true) + .open(&path) + .map_err(|e| { + NonoError::Snapshot(format!( + "Failed to open audit event log {}: {e}", + path.display() + )) + })?; + Ok(Self { + file, + next_sequence: 0, + previous_chain: None, + leaf_hashes: Vec::new(), + redaction_policy, + }) + } + + /// Record a session start event. + pub fn record_session_started(&mut self, started: String, command: Vec) -> Result<()> { + self.append_event(AuditEventPayload::SessionStarted { + started, + command: crate::scrub_argv_with_policy(&command, &self.redaction_policy), + redaction_policy: self + .redaction_policy + .diff_from_secure_default() + .into_option(), + }) + } + + /// Record a session end event. + pub fn record_session_ended(&mut self, ended: String, exit_code: i32) -> Result<()> { + self.append_event(AuditEventPayload::SessionEnded { ended, exit_code }) + } + + /// Record a capability approval decision. + pub fn record_capability_decision(&mut self, entry: AuditEntry) -> Result<()> { + self.append_event(AuditEventPayload::CapabilityDecision { entry }) + } + + /// Record a URL-open request result. + pub fn record_open_url( + &mut self, + request: UrlOpenRequest, + success: bool, + error: Option, + ) -> Result<()> { + self.append_event(AuditEventPayload::UrlOpen { + request, + success, + error, + }) + } + + /// Record a network event. + pub fn record_network_event(&mut self, event: NetworkAuditEvent) -> Result<()> { + self.append_event(AuditEventPayload::Network { event }) + } + + /// Number of events appended by this recorder. + #[must_use] + pub fn event_count(&self) -> u64 { + self.leaf_hashes.len() as u64 + } + + /// Final integrity summary for the current log, if at least one event exists. + #[must_use] + pub fn finalize(&self) -> Option { + let chain_head = self.previous_chain?; + let merkle_root = merkle_root(&self.leaf_hashes); + Some(AuditIntegritySummary { + hash_algorithm: AUDIT_HASH_ALGORITHM.to_string(), + event_count: self.event_count(), + chain_head, + merkle_root, + }) + } + + fn append_event(&mut self, event: AuditEventPayload) -> Result<()> { + let event_bytes = serde_json::to_vec(&event) + .map_err(|e| NonoError::Snapshot(format!("Failed to serialize audit event: {e}")))?; + let leaf_hash = hash_event(&event_bytes); + let chain_hash = hash_chain(self.previous_chain.as_ref(), &leaf_hash); + let record = AuditEventRecord { + sequence: self.next_sequence, + prev_chain: self.previous_chain, + leaf_hash, + chain_hash, + event_json: Some(String::from_utf8(event_bytes.clone()).map_err(|e| { + NonoError::Snapshot(format!( + "Failed to encode canonical audit event JSON as UTF-8: {e}" + )) + })?), + event, + }; + let line = serde_json::to_vec(&record) + .map_err(|e| NonoError::Snapshot(format!("Failed to serialize audit record: {e}")))?; + self.file + .write_all(&line) + .and_then(|_| self.file.write_all(b"\n")) + .and_then(|_| self.file.flush()) + .map_err(|e| NonoError::Snapshot(format!("Failed to append audit record: {e}")))?; + self.next_sequence = self.next_sequence.saturating_add(1); + self.previous_chain = Some(chain_hash); + self.leaf_hashes.push(leaf_hash); + Ok(()) + } +} + +/// Hash canonical event JSON bytes into an alpha event leaf. +#[must_use] +pub fn hash_event(event_bytes: &[u8]) -> ContentHash { + let mut hasher = Sha256::new(); + hasher.update(EVENT_DOMAIN_ALPHA); + hasher.update(event_bytes); + ContentHash::from_bytes(hasher.finalize().into()) +} + +/// Hash one alpha rolling-chain link. +#[must_use] +pub fn hash_chain(previous: Option<&ContentHash>, leaf_hash: &ContentHash) -> ContentHash { + let mut hasher = Sha256::new(); + hasher.update(CHAIN_DOMAIN_ALPHA); + if let Some(prev) = previous { + hasher.update(prev.as_bytes()); + } else { + hasher.update([0u8; 32]); + } + hasher.update(leaf_hash.as_bytes()); + ContentHash::from_bytes(hasher.finalize().into()) +} + +/// Compute the alpha Merkle root over ordered leaves. +#[must_use] +pub fn merkle_root(leaves: &[ContentHash]) -> ContentHash { + if leaves.is_empty() { + return ContentHash::from_bytes(Sha256::digest(b"").into()); + } + + let mut level: Vec<[u8; 32]> = leaves.iter().map(|leaf| *leaf.as_bytes()).collect(); + while level.len() > 1 { + let mut next = Vec::with_capacity(level.len().div_ceil(2)); + for pair in level.chunks(2) { + let left = pair[0]; + if pair.len() == 1 { + next.push(left); + continue; + } + + let right = pair[1]; + next.push(hash_merkle_node(left, right)); + } + level = next; + } + ContentHash::from_bytes(level[0]) +} + +/// Build an alpha Merkle inclusion proof for one audit leaf. +pub fn build_inclusion_proof( + leaves: &[ContentHash], + leaf_index: usize, +) -> Result { + if leaves.is_empty() { + return Err(NonoError::Snapshot( + "Cannot build an audit inclusion proof for an empty log".to_string(), + )); + } + if leaf_index >= leaves.len() { + return Err(NonoError::Snapshot(format!( + "Audit inclusion proof leaf index {} is out of range for {} leaves", + leaf_index, + leaves.len() + ))); + } + + let mut siblings = Vec::new(); + let mut index = leaf_index; + let mut level: Vec<[u8; 32]> = leaves.iter().map(|leaf| *leaf.as_bytes()).collect(); + while level.len() > 1 { + let sibling_index = if index % 2 == 0 { + index.saturating_add(1) + } else { + index.saturating_sub(1) + }; + if let Some(sibling) = level.get(sibling_index) { + siblings.push(AuditProofNode { + direction: if sibling_index < index { + AuditProofDirection::Left + } else { + AuditProofDirection::Right + }, + hash: ContentHash::from_bytes(*sibling), + }); + } + + let mut next = Vec::with_capacity(level.len().div_ceil(2)); + for pair in level.chunks(2) { + let left = pair[0]; + if pair.len() == 1 { + next.push(left); + continue; + } + next.push(hash_merkle_node(left, pair[1])); + } + index /= 2; + level = next; + } + + Ok(AuditInclusionProof { + leaf_index: leaf_index as u64, + leaf_count: leaves.len() as u64, + leaf_hash: leaves[leaf_index], + merkle_root: ContentHash::from_bytes(level[0]), + siblings, + }) +} + +/// Verify an alpha Merkle inclusion proof. +#[must_use] +pub fn verify_inclusion_proof(proof: &AuditInclusionProof) -> bool { + if proof.leaf_count == 0 || proof.leaf_index >= proof.leaf_count { + return false; + } + + let mut computed = *proof.leaf_hash.as_bytes(); + let mut index = proof.leaf_index; + let mut width = proof.leaf_count; + let mut siblings = proof.siblings.iter(); + + while width > 1 { + let expected_direction = if index % 2 == 0 { + if index + 1 < width { + Some(AuditProofDirection::Right) + } else { + None + } + } else { + Some(AuditProofDirection::Left) + }; + + if let Some(direction) = expected_direction { + let Some(node) = siblings.next() else { + return false; + }; + if node.direction != direction { + return false; + } + computed = match node.direction { + AuditProofDirection::Left => hash_merkle_node(*node.hash.as_bytes(), computed), + AuditProofDirection::Right => hash_merkle_node(computed, *node.hash.as_bytes()), + }; + } + + index /= 2; + width = width.div_ceil(2); + } + + if siblings.next().is_some() { + return false; + } + + computed == *proof.merkle_root.as_bytes() +} + +fn hash_merkle_node(left: [u8; 32], right: [u8; 32]) -> [u8; 32] { + let mut hasher = Sha256::new(); + hasher.update(MERKLE_NODE_DOMAIN_ALPHA); + hasher.update(left); + hasher.update(right); + hasher.finalize().into() +} + +/// Verify an alpha audit log and optionally cross-check stored metadata. +pub fn verify_audit_log( + session_dir: &Path, + stored: Option<&AuditIntegritySummary>, +) -> Result { + let path = session_dir.join(AUDIT_EVENTS_FILENAME); + let file = File::open(&path).map_err(|e| { + NonoError::Snapshot(format!( + "Failed to open audit event log {}: {e}", + path.display() + )) + })?; + + let reader = BufReader::new(file); + let mut previous_chain: Option = None; + let mut leaf_hashes = Vec::new(); + let mut computed_chain_head: Option = None; + let mut missing_canonical_event_json = false; + + for (index, line) in reader.lines().enumerate() { + let line = line.map_err(|e| { + NonoError::Snapshot(format!( + "Failed to read audit event log {}: {e}", + path.display() + )) + })?; + if line.trim().is_empty() { + continue; + } + + let record: AuditEventRecord = serde_json::from_str(&line).map_err(|e| { + NonoError::Snapshot(format!( + "Failed to parse audit event record {} line {}: {e}", + path.display(), + index.saturating_add(1) + )) + })?; + + let expected_sequence = leaf_hashes.len() as u64; + if record.sequence != expected_sequence { + return Err(NonoError::Snapshot(format!( + "Audit event record sequence mismatch at line {}: expected {}, got {}", + index.saturating_add(1), + expected_sequence, + record.sequence + ))); + } + + if record.prev_chain != previous_chain { + return Err(NonoError::Snapshot(format!( + "Audit event record prev_chain mismatch at line {}", + index.saturating_add(1) + ))); + } + + let event_bytes = if let Some(raw) = record.event_json.as_ref() { + let reparsed: AuditEventPayload = serde_json::from_str(raw).map_err(|e| { + NonoError::Snapshot(format!( + "Failed to parse canonical audit event JSON at line {}: {e}", + index.saturating_add(1) + )) + })?; + let reparsed_value = serde_json::to_value(&reparsed).map_err(|e| { + NonoError::Snapshot(format!( + "Failed to normalize canonical audit event JSON at line {}: {e}", + index.saturating_add(1) + )) + })?; + let record_value = serde_json::to_value(&record.event).map_err(|e| { + NonoError::Snapshot(format!( + "Failed to normalize audit event payload at line {}: {e}", + index.saturating_add(1) + )) + })?; + if reparsed_value != record_value { + return Err(NonoError::Snapshot(format!( + "Audit event JSON mismatch at line {}", + index.saturating_add(1) + ))); + } + raw.as_bytes().to_vec() + } else { + missing_canonical_event_json = true; + serde_json::to_vec(&record.event).map_err(|e| { + NonoError::Snapshot(format!( + "Failed to serialize audit event for verification at line {}: {e}", + index.saturating_add(1) + )) + })? + }; + let leaf_hash = hash_event(&event_bytes); + if record.leaf_hash != leaf_hash { + return Err(NonoError::Snapshot(format!( + "Audit event leaf hash mismatch at line {}", + index.saturating_add(1) + ))); + } + + let chain_hash = hash_chain(previous_chain.as_ref(), &leaf_hash); + if record.chain_hash != chain_hash { + return Err(NonoError::Snapshot(format!( + "Audit event chain hash mismatch at line {}", + index.saturating_add(1) + ))); + } + + previous_chain = Some(chain_hash); + computed_chain_head = Some(chain_hash); + leaf_hashes.push(leaf_hash); + } + + let computed_merkle_root = if leaf_hashes.is_empty() { + None + } else { + Some(merkle_root(&leaf_hashes)) + }; + + if stored.is_some() && !leaf_hashes.is_empty() && missing_canonical_event_json { + return Err(NonoError::Snapshot( + "Alpha audit log is missing canonical event_json bytes".to_string(), + )); + } + + let stored_event_count = stored.map(|s| s.event_count); + let stored_chain_head = stored.map(|s| s.chain_head); + let stored_merkle_root = stored.map(|s| s.merkle_root); + let event_count = leaf_hashes.len() as u64; + let event_count_matches = stored_event_count + .map(|count| count == event_count) + .unwrap_or(true); + + if let Some(stored_head) = stored_chain_head + && Some(stored_head) != computed_chain_head + { + return Err(NonoError::Snapshot( + "Alpha audit log chain head mismatch".to_string(), + )); + } + + if let Some(stored_root) = stored_merkle_root + && Some(stored_root) != computed_merkle_root + { + return Err(NonoError::Snapshot( + "Alpha audit log Merkle root mismatch".to_string(), + )); + } + + Ok(AuditVerificationResult { + hash_algorithm: AUDIT_HASH_ALGORITHM.to_string(), + merkle_scheme: MERKLE_SCHEME_ALPHA.to_string(), + event_count, + computed_chain_head, + computed_merkle_root, + stored_event_count, + stored_chain_head, + stored_merkle_root, + event_count_matches, + records_verified: true, + }) +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + use crate::AccessMode; + use crate::supervisor::{ApprovalDecision, CapabilityRequest}; + use crate::undo::{NetworkAuditDecision, NetworkAuditMode}; + use std::time::{Duration, UNIX_EPOCH}; + + #[test] + fn recorder_produces_integrity_summary() { + let dir = tempfile::tempdir().unwrap(); + let mut recorder = AuditRecorder::new(dir.path().to_path_buf()).unwrap(); + recorder + .record_session_started("2026-04-21T00:00:00Z".to_string(), vec!["pwd".to_string()]) + .unwrap(); + recorder + .record_session_ended("2026-04-21T00:00:01Z".to_string(), 0) + .unwrap(); + + let summary = recorder.finalize().unwrap(); + assert_eq!(summary.event_count, 2); + assert_eq!(summary.hash_algorithm, AUDIT_HASH_ALGORITHM); + } + + #[test] + fn record_session_started_scrubs_command_secrets() { + let dir = tempfile::tempdir().unwrap(); + let mut recorder = AuditRecorder::new(dir.path().to_path_buf()).unwrap(); + recorder + .record_session_started( + "2026-04-21T00:00:00Z".to_string(), + vec![ + "curl".to_string(), + "--password".to_string(), + "real-password".to_string(), + "-H".to_string(), + "Authorization: Bearer real-token".to_string(), + "https://example.com/api?token=query-secret".to_string(), + ], + ) + .unwrap(); + + let contents = std::fs::read_to_string(dir.path().join(AUDIT_EVENTS_FILENAME)).unwrap(); + + assert!(contents.contains("[REDACTED]")); + assert!(!contents.contains("real-password")); + assert!(!contents.contains("real-token")); + assert!(!contents.contains("query-secret")); + } + + #[test] + fn verifier_round_trips_all_current_audit_event_payload_variants() { + let dir = tempfile::tempdir().unwrap(); + let mut recorder = AuditRecorder::new(dir.path().to_path_buf()).unwrap(); + recorder + .record_session_started( + "2026-04-21T00:00:00Z".to_string(), + vec!["claude".to_string(), "--debug".to_string()], + ) + .unwrap(); + recorder + .record_capability_decision(AuditEntry { + timestamp: UNIX_EPOCH + Duration::from_secs(5), + request: CapabilityRequest { + request_id: "req-1".to_string(), + path: PathBuf::from("/tmp/example"), + access: AccessMode::ReadWrite, + reason: Some("need scratch space".to_string()), + child_pid: 42, + session_id: "sess-1".to_string(), + }, + decision: ApprovalDecision::Denied { + reason: "outside policy".to_string(), + }, + backend: "terminal".to_string(), + duration_ms: 12, + }) + .unwrap(); + recorder + .record_open_url( + UrlOpenRequest { + request_id: "open-1".to_string(), + url: "https://example.com/callback".to_string(), + child_pid: 42, + session_id: "sess-1".to_string(), + }, + false, + Some("blocked".to_string()), + ) + .unwrap(); + recorder + .record_network_event(NetworkAuditEvent { + timestamp_unix_ms: 123, + mode: NetworkAuditMode::Reverse, + decision: NetworkAuditDecision::Deny, + route_id: None, + auth_mechanism: None, + auth_outcome: None, + managed_credential_active: None, + injection_mode: None, + denial_category: None, + target: "api.example.com".to_string(), + port: Some(443), + method: Some("POST".to_string()), + path: Some("/v1/chat".to_string()), + status: Some(403), + reason: Some("policy".to_string()), + }) + .unwrap(); + recorder + .record_session_ended("2026-04-21T00:00:01Z".to_string(), 7) + .unwrap(); + + let summary = recorder.finalize().unwrap(); + let verified = verify_audit_log(dir.path(), Some(&summary)).unwrap(); + assert_eq!(verified.event_count, 5); + assert_eq!(verified.merkle_scheme, "alpha"); + assert!(verified.records_verified); + } + + #[test] + fn verifier_rejects_alpha_records_missing_event_json() { + let dir = tempfile::tempdir().unwrap(); + let mut recorder = AuditRecorder::new(dir.path().to_path_buf()).unwrap(); + recorder + .record_session_started("2026-04-21T00:00:00Z".to_string(), vec!["pwd".to_string()]) + .unwrap(); + recorder + .record_session_ended("2026-04-21T00:00:01Z".to_string(), 0) + .unwrap(); + + let path = dir.path().join(AUDIT_EVENTS_FILENAME); + let contents = std::fs::read_to_string(&path).unwrap(); + let rewritten = contents + .lines() + .filter(|line| !line.trim().is_empty()) + .map(|line| { + let mut record: AuditEventRecord = serde_json::from_str(line).unwrap(); + record.event_json = None; + serde_json::to_string(&record).unwrap() + }) + .collect::>() + .join("\n"); + std::fs::write(&path, format!("{rewritten}\n")).unwrap(); + + let summary = recorder.finalize().unwrap(); + let err = match verify_audit_log(dir.path(), Some(&summary)) { + Ok(_) => panic!("alpha verification should reject records missing event_json"), + Err(err) => err, + }; + assert!( + err.to_string() + .contains("missing canonical event_json bytes") + ); + } + + #[test] + fn inclusion_proof_round_trips_each_leaf() { + let leaves = vec![ + ContentHash::from_bytes([1; 32]), + ContentHash::from_bytes([2; 32]), + ContentHash::from_bytes([3; 32]), + ContentHash::from_bytes([4; 32]), + ContentHash::from_bytes([5; 32]), + ]; + let root = merkle_root(&leaves); + + for index in 0..leaves.len() { + let proof = build_inclusion_proof(&leaves, index).unwrap(); + assert_eq!(proof.merkle_root, root); + assert_eq!(proof.leaf_hash, leaves[index]); + assert!(verify_inclusion_proof(&proof)); + } + } + + #[test] + fn inclusion_proof_rejects_tampered_leaf() { + let leaves = vec![ + ContentHash::from_bytes([1; 32]), + ContentHash::from_bytes([2; 32]), + ContentHash::from_bytes([3; 32]), + ]; + let mut proof = build_inclusion_proof(&leaves, 1).unwrap(); + proof.leaf_hash = ContentHash::from_bytes([9; 32]); + + assert!(!verify_inclusion_proof(&proof)); + } +} diff --git a/crates/nono/src/lib.rs b/crates/nono/src/lib.rs index c34a4940e..ae7e4eaae 100644 --- a/crates/nono/src/lib.rs +++ b/crates/nono/src/lib.rs @@ -45,6 +45,7 @@ //! - **macOS**: Uses Seatbelt sandbox //! - **Other platforms**: Returns `UnsupportedPlatform` error +pub mod audit; pub mod capability; pub mod diagnostic; pub mod error; From 3a7fb52dc2a8d4e6e99215b27c5ade6c3376a58f Mon Sep 17 00:00:00 2001 From: Luke Hinds Date: Tue, 9 Jun 2026 21:56:29 +0100 Subject: [PATCH 31/91] refactor(audit-ledger): move audit ledger logic to library crate Move core audit ledger implementation from `nono-cli` to the `nono` library crate. This extracts shared ledger logic, making it reusable by other consumers of the `nono` library and providing a clearer separation of concerns. Key components moved include: - `LedgerRecord`, `LedgerVerificationResult`, and related structs - `compute_session_digest` and session payload serialization - `validate_ledger_session_id` - `append_session_to_ledger_file` and `verify_session_in_ledger_reader` - Ledger chain hashing logic (`hash_ledger_link`) The `nono-cli` now uses the newly exposed public API from `nono::audit` to manage the audit ledger. New domain separators were introduced in `nono` for session digests and ledger chain links, and dedicated unit tests for the ledger API were added to the `nono` crate. Resolves: #1114 Signed-off-by: Luke Hinds --- crates/nono-cli/src/audit_ledger.rs | 300 +---------------- crates/nono/src/audit.rs | 501 +++++++++++++++++++++++++++- 2 files changed, 507 insertions(+), 294 deletions(-) diff --git a/crates/nono-cli/src/audit_ledger.rs b/crates/nono-cli/src/audit_ledger.rs index a34940d86..6b75b8f6c 100644 --- a/crates/nono-cli/src/audit_ledger.rs +++ b/crates/nono-cli/src/audit_ledger.rs @@ -1,118 +1,18 @@ use crate::audit_session::audit_root; use nix::fcntl::{Flock, FlockArg}; -use nono::undo::{ - AuditAttestationSummary, AuditIntegritySummary, ContentHash, NetworkAuditEvent, SessionMetadata, +use nono::audit::{ + LedgerRecord, LedgerVerificationResult, append_session_to_ledger_file, + missing_ledger_verification_result, validate_ledger_session_id, + verify_session_in_ledger_reader, }; +use nono::undo::SessionMetadata; use nono::{NonoError, Result}; -use serde::{Deserialize, Serialize}; -use sha2::{Digest, Sha256}; use std::fs::OpenOptions; -use std::io::{BufRead, BufReader, Read, Seek, SeekFrom, Write}; -#[cfg(unix)] -use std::os::unix::ffi::OsStrExt; +use std::io::BufReader; use std::path::PathBuf; const AUDIT_LEDGER_FILENAME: &str = "ledger.ndjson"; const AUDIT_LEDGER_LOCK_FILENAME: &str = "ledger.lock"; -const SESSION_DIGEST_DOMAIN: &[u8] = b"nono.audit.session-digest.alpha\n"; -const LEDGER_CHAIN_DOMAIN: &[u8] = b"nono.audit.ledger.chain.alpha\n"; -const LEDGER_HASH_ALGORITHM: &str = "sha256"; - -#[derive(Serialize)] -struct SessionDigestPayload<'a> { - session_id: &'a str, - started: &'a str, - ended: &'a Option, - command: &'a [String], - executable_identity: Option, - tracked_paths: Vec>, - snapshot_count: u32, - exit_code: &'a Option, - merkle_roots: &'a [ContentHash], - network_events: &'a [NetworkAuditEvent], - audit_event_count: u64, - audit_integrity: &'a Option, - audit_attestation: &'a Option, -} - -#[derive(Serialize)] -struct ExecutableIdentityDigestPayload { - resolved_path: Vec, - sha256: ContentHash, -} - -#[derive(Clone, Serialize, Deserialize)] -pub(crate) struct LedgerRecord { - pub(crate) sequence: u64, - pub(crate) prev_chain: Option, - pub(crate) session_id: String, - pub(crate) session_digest: ContentHash, - pub(crate) completed_at: String, - pub(crate) chain_hash: ContentHash, -} - -#[derive(Serialize)] -struct LedgerLinkPayload<'a> { - sequence: u64, - session_id: &'a str, - session_digest: ContentHash, - completed_at: &'a str, -} - -#[derive(Serialize)] -pub(crate) struct LedgerVerificationResult { - pub(crate) hash_algorithm: String, - pub(crate) entry_count: u64, - pub(crate) session_digest: ContentHash, - pub(crate) session_found: bool, - pub(crate) session_digest_matches: bool, - pub(crate) ledger_chain_verified: bool, - pub(crate) ledger_head: Option, -} - -pub(crate) fn compute_session_digest(metadata: &SessionMetadata) -> Result { - let payload = SessionDigestPayload { - session_id: &metadata.session_id, - started: &metadata.started, - ended: &metadata.ended, - command: &metadata.command, - executable_identity: metadata.executable_identity.as_ref().map(|identity| { - ExecutableIdentityDigestPayload { - resolved_path: path_bytes(&identity.resolved_path), - sha256: identity.sha256, - } - }), - tracked_paths: metadata - .tracked_paths - .iter() - .map(|path| path_bytes(path)) - .collect(), - snapshot_count: metadata.snapshot_count, - exit_code: &metadata.exit_code, - merkle_roots: &metadata.merkle_roots, - network_events: &metadata.network_events, - audit_event_count: metadata.audit_event_count, - audit_integrity: &metadata.audit_integrity, - audit_attestation: &metadata.audit_attestation, - }; - let bytes = serde_json::to_vec(&payload).map_err(|e| { - NonoError::Snapshot(format!("Failed to serialize session digest payload: {e}")) - })?; - let mut hasher = Sha256::new(); - hasher.update(SESSION_DIGEST_DOMAIN); - hasher.update(bytes); - Ok(ContentHash::from_bytes(hasher.finalize().into())) -} - -#[cfg(unix)] -fn path_bytes(path: &std::path::Path) -> Vec { - path.as_os_str().as_bytes().to_vec() -} - -#[cfg(not(unix))] -fn path_bytes(path: &std::path::Path) -> Vec { - path.to_string_lossy().into_owned().into_bytes() -} pub(crate) fn append_session(metadata: &SessionMetadata) -> Result { validate_ledger_session_id(&metadata.session_id)?; @@ -139,79 +39,7 @@ pub(crate) fn append_session(metadata: &SessionMetadata) -> Result path.display() )) })?; - append_locked(&mut file, metadata) -} - -fn validate_ledger_session_id(session_id: &str) -> Result<()> { - let valid = !session_id.is_empty() - && session_id.len() <= 64 - && session_id - .bytes() - .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_')); - if valid { - Ok(()) - } else { - Err(NonoError::ConfigParse(format!( - "invalid audit session id: {session_id}" - ))) - } -} - -fn append_locked(file: &mut std::fs::File, metadata: &SessionMetadata) -> Result { - let mut contents = String::new(); - file.seek(SeekFrom::Start(0)) - .map_err(|e| NonoError::Snapshot(format!("Failed to seek audit ledger: {e}")))?; - file.read_to_string(&mut contents) - .map_err(|e| NonoError::Snapshot(format!("Failed to read audit ledger: {e}")))?; - - let mut previous_chain = None; - let mut next_sequence = 0u64; - for (index, line) in contents.lines().enumerate() { - if line.trim().is_empty() { - continue; - } - let record: LedgerRecord = serde_json::from_str(line).map_err(|e| { - NonoError::Snapshot(format!( - "Failed to parse audit ledger line {}: {e}", - index.saturating_add(1) - )) - })?; - previous_chain = Some(record.chain_hash); - next_sequence = record.sequence.saturating_add(1); - } - - let session_digest = compute_session_digest(metadata)?; - let completed_at = metadata - .ended - .clone() - .unwrap_or_else(|| metadata.started.clone()); - let chain_hash = hash_ledger_link( - previous_chain.as_ref(), - next_sequence, - &metadata.session_id, - &session_digest, - &completed_at, - )?; - let record = LedgerRecord { - sequence: next_sequence, - prev_chain: previous_chain, - session_id: metadata.session_id.clone(), - session_digest, - completed_at, - chain_hash, - }; - - file.seek(SeekFrom::End(0)) - .map_err(|e| NonoError::Snapshot(format!("Failed to seek audit ledger for append: {e}")))?; - let line = serde_json::to_vec(&record).map_err(|e| { - NonoError::Snapshot(format!("Failed to serialize audit ledger record: {e}")) - })?; - file.write_all(&line) - .and_then(|_| file.write_all(b"\n")) - .and_then(|_| file.sync_data()) - .map_err(|e| NonoError::Snapshot(format!("Failed to append audit ledger record: {e}")))?; - - Ok(record) + append_session_to_ledger_file(&mut file, metadata) } pub(crate) fn verify_session_in_ledger( @@ -220,15 +48,7 @@ pub(crate) fn verify_session_in_ledger( let root = audit_root()?; let path = root.join(AUDIT_LEDGER_FILENAME); if !path.exists() { - return Ok(LedgerVerificationResult { - hash_algorithm: LEDGER_HASH_ALGORITHM.to_string(), - entry_count: 0, - session_digest: compute_session_digest(metadata)?, - session_found: false, - session_digest_matches: false, - ledger_chain_verified: false, - ledger_head: None, - }); + return missing_ledger_verification_result(metadata); } let file = OpenOptions::new().read(true).open(&path).map_err(|e| { @@ -237,76 +57,7 @@ pub(crate) fn verify_session_in_ledger( path.display() )) })?; - let reader = BufReader::new(file); - let expected_digest = compute_session_digest(metadata)?; - - let mut previous_chain = None; - let mut entry_count = 0u64; - let mut ledger_head = None; - let mut session_found = false; - let mut session_digest_matches = false; - - for (index, line) in reader.lines().enumerate() { - let line = line.map_err(|e| { - NonoError::Snapshot(format!( - "Failed to read audit ledger {}: {e}", - path.display() - )) - })?; - if line.trim().is_empty() { - continue; - } - let record: LedgerRecord = serde_json::from_str(&line).map_err(|e| { - NonoError::Snapshot(format!( - "Failed to parse audit ledger line {}: {e}", - index.saturating_add(1) - )) - })?; - if record.sequence != entry_count { - return Err(NonoError::Snapshot(format!( - "Audit ledger sequence mismatch at line {}", - index.saturating_add(1) - ))); - } - if record.prev_chain != previous_chain { - return Err(NonoError::Snapshot(format!( - "Audit ledger prev_chain mismatch at line {}", - index.saturating_add(1) - ))); - } - let chain_hash = hash_ledger_link( - previous_chain.as_ref(), - record.sequence, - &record.session_id, - &record.session_digest, - &record.completed_at, - )?; - if chain_hash != record.chain_hash { - return Err(NonoError::Snapshot(format!( - "Audit ledger chain hash mismatch at line {}", - index.saturating_add(1) - ))); - } - - if record.session_id == metadata.session_id { - session_found = true; - session_digest_matches = record.session_digest == expected_digest; - } - - previous_chain = Some(record.chain_hash); - ledger_head = Some(record.chain_hash); - entry_count = entry_count.saturating_add(1); - } - - Ok(LedgerVerificationResult { - hash_algorithm: LEDGER_HASH_ALGORITHM.to_string(), - entry_count, - session_digest: expected_digest, - session_found, - session_digest_matches, - ledger_chain_verified: true, - ledger_head, - }) + verify_session_in_ledger_reader(BufReader::new(file), metadata) } struct LedgerLock { @@ -337,42 +88,15 @@ impl LedgerLock { } } -fn hash_ledger_link( - previous: Option<&ContentHash>, - sequence: u64, - session_id: &str, - session_digest: &ContentHash, - completed_at: &str, -) -> Result { - let payload = LedgerLinkPayload { - sequence, - session_id, - session_digest: *session_digest, - completed_at, - }; - let payload_bytes = serde_json::to_vec(&payload).map_err(|e| { - NonoError::Snapshot(format!( - "Failed to serialize audit ledger link payload: {e}" - )) - })?; - let mut hasher = Sha256::new(); - hasher.update(LEDGER_CHAIN_DOMAIN); - if let Some(prev) = previous { - hasher.update(prev.as_bytes()); - } else { - hasher.update([0u8; 32]); - } - hasher.update(payload_bytes); - Ok(ContentHash::from_bytes(hasher.finalize().into())) -} - #[cfg(test)] #[allow(clippy::unwrap_used)] mod tests { use super::*; use crate::test_env::{ENV_LOCK, EnvVarGuard}; + use nono::audit::compute_session_digest; use nono::undo::{ - AuditAttestationSummary, ExecutableIdentity, NetworkAuditDecision, NetworkAuditMode, + AuditAttestationSummary, AuditIntegritySummary, ContentHash, ExecutableIdentity, + NetworkAuditDecision, NetworkAuditEvent, NetworkAuditMode, }; #[cfg(unix)] use std::ffi::OsString; diff --git a/crates/nono/src/audit.rs b/crates/nono/src/audit.rs index 5caf60b82..6f9ede828 100644 --- a/crates/nono/src/audit.rs +++ b/crates/nono/src/audit.rs @@ -6,12 +6,16 @@ //! and Merkle root. use crate::supervisor::{AuditEntry, UrlOpenRequest}; -use crate::undo::{AuditIntegritySummary, ContentHash, NetworkAuditEvent}; +use crate::undo::{ + AuditAttestationSummary, AuditIntegritySummary, ContentHash, NetworkAuditEvent, SessionMetadata, +}; use crate::{NonoError, Result}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use std::fs::{File, OpenOptions}; -use std::io::{BufRead, BufReader, Write}; +use std::io::{BufRead, BufReader, Read, Seek, SeekFrom, Write}; +#[cfg(unix)] +use std::os::unix::ffi::OsStrExt; use std::path::{Path, PathBuf}; /// Filename used for per-session audit event logs. @@ -27,6 +31,10 @@ pub const MERKLE_NODE_DOMAIN_ALPHA: &[u8] = b"nono.audit.merkle.alpha\n"; pub const MERKLE_SCHEME_ALPHA: &str = "alpha"; /// Hash algorithm label emitted by alpha verification. pub const AUDIT_HASH_ALGORITHM: &str = "sha256"; +/// Domain separator for alpha session digests. +pub const SESSION_DIGEST_DOMAIN_ALPHA: &[u8] = b"nono.audit.session-digest.alpha\n"; +/// Domain separator for alpha ledger chain links. +pub const LEDGER_CHAIN_DOMAIN_ALPHA: &[u8] = b"nono.audit.ledger.chain.alpha\n"; /// Event payloads written into the alpha audit log. #[derive(Clone, Serialize, Deserialize)] @@ -113,6 +121,73 @@ pub struct AuditVerificationResult { pub records_verified: bool, } +#[derive(Serialize)] +struct SessionDigestPayload<'a> { + session_id: &'a str, + started: &'a str, + ended: &'a Option, + command: &'a [String], + executable_identity: Option, + tracked_paths: Vec>, + snapshot_count: u32, + exit_code: &'a Option, + merkle_roots: &'a [ContentHash], + network_events: &'a [NetworkAuditEvent], + audit_event_count: u64, + audit_integrity: &'a Option, + audit_attestation: &'a Option, +} + +#[derive(Serialize)] +struct ExecutableIdentityDigestPayload { + resolved_path: Vec, + sha256: ContentHash, +} + +/// One line of the append-only session ledger. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LedgerRecord { + /// Monotonic ledger sequence number. + pub sequence: u64, + /// Previous ledger record's chain hash, or `None` for the first record. + pub prev_chain: Option, + /// Session ID committed by this ledger entry. + pub session_id: String, + /// Digest over protected session metadata fields. + pub session_digest: ContentHash, + /// Session completion timestamp used in the ledger link payload. + pub completed_at: String, + /// Rolling ledger chain hash. + pub chain_hash: ContentHash, +} + +#[derive(Serialize)] +struct LedgerLinkPayload<'a> { + sequence: u64, + session_id: &'a str, + session_digest: ContentHash, + completed_at: &'a str, +} + +/// Result of checking a session against an append-only ledger. +#[derive(Debug, Clone, Serialize)] +pub struct LedgerVerificationResult { + /// Hash algorithm used by the ledger. + pub hash_algorithm: String, + /// Number of verified ledger entries. + pub entry_count: u64, + /// Expected digest for the provided session metadata. + pub session_digest: ContentHash, + /// Whether the session ID was present in the ledger. + pub session_found: bool, + /// Whether the ledger digest matched the current session metadata digest. + pub session_digest_matches: bool, + /// Whether every ledger chain link verified. + pub ledger_chain_verified: bool, + /// Final ledger chain head. + pub ledger_head: Option, +} + /// Position of a sibling hash in an audit Merkle inclusion proof. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] @@ -348,7 +423,7 @@ pub fn build_inclusion_proof( let mut index = leaf_index; let mut level: Vec<[u8; 32]> = leaves.iter().map(|leaf| *leaf.as_bytes()).collect(); while level.len() > 1 { - let sibling_index = if index % 2 == 0 { + let sibling_index = if index.is_multiple_of(2) { index.saturating_add(1) } else { index.saturating_sub(1) @@ -399,8 +474,8 @@ pub fn verify_inclusion_proof(proof: &AuditInclusionProof) -> bool { let mut siblings = proof.siblings.iter(); while width > 1 { - let expected_direction = if index % 2 == 0 { - if index + 1 < width { + let expected_direction = if index.is_multiple_of(2) { + if index.saturating_add(1) < width { Some(AuditProofDirection::Right) } else { None @@ -441,6 +516,249 @@ fn hash_merkle_node(left: [u8; 32], right: [u8; 32]) -> [u8; 32] { hasher.finalize().into() } +/// Compute the alpha session digest used by the append-only ledger. +pub fn compute_session_digest(metadata: &SessionMetadata) -> Result { + let payload = SessionDigestPayload { + session_id: &metadata.session_id, + started: &metadata.started, + ended: &metadata.ended, + command: &metadata.command, + executable_identity: metadata.executable_identity.as_ref().map(|identity| { + ExecutableIdentityDigestPayload { + resolved_path: path_bytes(&identity.resolved_path), + sha256: identity.sha256, + } + }), + tracked_paths: metadata + .tracked_paths + .iter() + .map(|path| path_bytes(path)) + .collect(), + snapshot_count: metadata.snapshot_count, + exit_code: &metadata.exit_code, + merkle_roots: &metadata.merkle_roots, + network_events: &metadata.network_events, + audit_event_count: metadata.audit_event_count, + audit_integrity: &metadata.audit_integrity, + audit_attestation: &metadata.audit_attestation, + }; + let bytes = serde_json::to_vec(&payload).map_err(|e| { + NonoError::Snapshot(format!("Failed to serialize session digest payload: {e}")) + })?; + let mut hasher = Sha256::new(); + hasher.update(SESSION_DIGEST_DOMAIN_ALPHA); + hasher.update(bytes); + Ok(ContentHash::from_bytes(hasher.finalize().into())) +} + +#[cfg(unix)] +fn path_bytes(path: &std::path::Path) -> Vec { + path.as_os_str().as_bytes().to_vec() +} + +#[cfg(not(unix))] +fn path_bytes(path: &std::path::Path) -> Vec { + path.to_string_lossy().into_owned().into_bytes() +} + +/// Validate a session ID before committing it to the global audit ledger. +pub fn validate_ledger_session_id(session_id: &str) -> Result<()> { + let valid = !session_id.is_empty() + && session_id.len() <= 64 + && session_id + .bytes() + .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_')); + if valid { + Ok(()) + } else { + Err(NonoError::ConfigParse(format!( + "invalid audit session id: {session_id}" + ))) + } +} + +/// Append one session to an already opened and locked ledger file. +/// +/// The caller owns storage decisions: where the ledger lives, whether the +/// file is locked, and how the parent directory is created. +pub fn append_session_to_ledger_file( + file: &mut std::fs::File, + metadata: &SessionMetadata, +) -> Result { + validate_ledger_session_id(&metadata.session_id)?; + + let mut contents = String::new(); + file.seek(SeekFrom::Start(0)) + .map_err(|e| NonoError::Snapshot(format!("Failed to seek audit ledger: {e}")))?; + file.read_to_string(&mut contents) + .map_err(|e| NonoError::Snapshot(format!("Failed to read audit ledger: {e}")))?; + + let mut previous_chain = None; + let mut next_sequence = 0u64; + for (index, line) in contents.lines().enumerate() { + if line.trim().is_empty() { + continue; + } + let record: LedgerRecord = serde_json::from_str(line).map_err(|e| { + NonoError::Snapshot(format!( + "Failed to parse audit ledger line {}: {e}", + index.saturating_add(1) + )) + })?; + previous_chain = Some(record.chain_hash); + next_sequence = record.sequence.saturating_add(1); + } + + let session_digest = compute_session_digest(metadata)?; + let completed_at = metadata + .ended + .clone() + .unwrap_or_else(|| metadata.started.clone()); + let chain_hash = hash_ledger_link( + previous_chain.as_ref(), + next_sequence, + &metadata.session_id, + &session_digest, + &completed_at, + )?; + let record = LedgerRecord { + sequence: next_sequence, + prev_chain: previous_chain, + session_id: metadata.session_id.clone(), + session_digest, + completed_at, + chain_hash, + }; + + file.seek(SeekFrom::End(0)) + .map_err(|e| NonoError::Snapshot(format!("Failed to seek audit ledger for append: {e}")))?; + let line = serde_json::to_vec(&record).map_err(|e| { + NonoError::Snapshot(format!("Failed to serialize audit ledger record: {e}")) + })?; + file.write_all(&line) + .and_then(|_| file.write_all(b"\n")) + .and_then(|_| file.sync_data()) + .map_err(|e| NonoError::Snapshot(format!("Failed to append audit ledger record: {e}")))?; + + Ok(record) +} + +/// Verification result for a missing ledger file. +pub fn missing_ledger_verification_result( + metadata: &SessionMetadata, +) -> Result { + Ok(LedgerVerificationResult { + hash_algorithm: AUDIT_HASH_ALGORITHM.to_string(), + entry_count: 0, + session_digest: compute_session_digest(metadata)?, + session_found: false, + session_digest_matches: false, + ledger_chain_verified: false, + ledger_head: None, + }) +} + +/// Verify an opened ledger reader and check whether it contains `metadata`. +pub fn verify_session_in_ledger_reader( + reader: R, + metadata: &SessionMetadata, +) -> Result { + let expected_digest = compute_session_digest(metadata)?; + + let mut previous_chain = None; + let mut entry_count = 0u64; + let mut ledger_head = None; + let mut session_found = false; + let mut session_digest_matches = false; + + for (index, line) in reader.lines().enumerate() { + let line = + line.map_err(|e| NonoError::Snapshot(format!("Failed to read audit ledger: {e}")))?; + if line.trim().is_empty() { + continue; + } + let record: LedgerRecord = serde_json::from_str(&line).map_err(|e| { + NonoError::Snapshot(format!( + "Failed to parse audit ledger line {}: {e}", + index.saturating_add(1) + )) + })?; + if record.sequence != entry_count { + return Err(NonoError::Snapshot(format!( + "Audit ledger sequence mismatch at line {}", + index.saturating_add(1) + ))); + } + if record.prev_chain != previous_chain { + return Err(NonoError::Snapshot(format!( + "Audit ledger prev_chain mismatch at line {}", + index.saturating_add(1) + ))); + } + let chain_hash = hash_ledger_link( + previous_chain.as_ref(), + record.sequence, + &record.session_id, + &record.session_digest, + &record.completed_at, + )?; + if chain_hash != record.chain_hash { + return Err(NonoError::Snapshot(format!( + "Audit ledger chain hash mismatch at line {}", + index.saturating_add(1) + ))); + } + + if record.session_id == metadata.session_id { + session_found = true; + session_digest_matches = record.session_digest == expected_digest; + } + + previous_chain = Some(record.chain_hash); + ledger_head = Some(record.chain_hash); + entry_count = entry_count.saturating_add(1); + } + + Ok(LedgerVerificationResult { + hash_algorithm: AUDIT_HASH_ALGORITHM.to_string(), + entry_count, + session_digest: expected_digest, + session_found, + session_digest_matches, + ledger_chain_verified: true, + ledger_head, + }) +} + +fn hash_ledger_link( + previous: Option<&ContentHash>, + sequence: u64, + session_id: &str, + session_digest: &ContentHash, + completed_at: &str, +) -> Result { + let payload = LedgerLinkPayload { + sequence, + session_id, + session_digest: *session_digest, + completed_at, + }; + let payload_bytes = serde_json::to_vec(&payload).map_err(|e| { + NonoError::Snapshot(format!( + "Failed to serialize audit ledger link payload: {e}" + )) + })?; + let mut hasher = Sha256::new(); + hasher.update(LEDGER_CHAIN_DOMAIN_ALPHA); + if let Some(prev) = previous { + hasher.update(prev.as_bytes()); + } else { + hasher.update([0u8; 32]); + } + hasher.update(payload_bytes); + Ok(ContentHash::from_bytes(hasher.finalize().into())) +} + /// Verify an alpha audit log and optionally cross-check stored metadata. pub fn verify_audit_log( session_dir: &Path, @@ -608,7 +926,8 @@ mod tests { use super::*; use crate::AccessMode; use crate::supervisor::{ApprovalDecision, CapabilityRequest}; - use crate::undo::{NetworkAuditDecision, NetworkAuditMode}; + use crate::undo::{ExecutableIdentity, NetworkAuditDecision, NetworkAuditMode}; + use std::io::BufReader; use std::time::{Duration, UNIX_EPOCH}; #[test] @@ -790,4 +1109,174 @@ mod tests { assert!(!verify_inclusion_proof(&proof)); } + + fn sample_metadata(id: &str) -> SessionMetadata { + SessionMetadata { + session_id: id.to_string(), + started: "2026-04-21T20:00:00Z".to_string(), + ended: Some("2026-04-21T20:00:01Z".to_string()), + command: vec!["/bin/pwd".to_string()], + executable_identity: None, + tracked_paths: vec![PathBuf::from("/tmp/work")], + snapshot_count: 0, + exit_code: Some(0), + merkle_roots: Vec::new(), + network_events: Vec::new(), + audit_event_count: 2, + audit_integrity: None, + audit_attestation: None, + } + } + + #[test] + fn ledger_appends_and_verifies_session_digest() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("ledger.ndjson"); + let mut file = OpenOptions::new() + .create(true) + .read(true) + .write(true) + .truncate(false) + .open(&path) + .unwrap(); + + let meta = sample_metadata("20260421-200000-11111"); + append_session_to_ledger_file(&mut file, &meta).unwrap(); + + let reader = BufReader::new(std::fs::File::open(&path).unwrap()); + let verified = verify_session_in_ledger_reader(reader, &meta).unwrap(); + assert!(verified.session_found); + assert!(verified.session_digest_matches); + assert!(verified.ledger_chain_verified); + assert_eq!(verified.entry_count, 1); + } + + #[test] + fn ledger_rejects_malformed_session_id() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("ledger.ndjson"); + let mut file = OpenOptions::new() + .create(true) + .read(true) + .write(true) + .truncate(false) + .open(&path) + .unwrap(); + let meta = sample_metadata("real-token\\|real-key"); + + let err = match append_session_to_ledger_file(&mut file, &meta) { + Ok(_) => panic!("malformed session id should be rejected"), + Err(err) => err, + }; + + assert!(err.to_string().contains("invalid audit session id")); + } + + #[test] + fn session_digest_changes_when_protected_fields_change() { + let base = SessionMetadata { + session_id: "20260421-200000-11111".to_string(), + started: "2026-04-21T20:00:00Z".to_string(), + ended: Some("2026-04-21T20:00:01Z".to_string()), + command: vec!["/bin/pwd".to_string()], + executable_identity: Some(ExecutableIdentity { + resolved_path: PathBuf::from("/bin/pwd"), + sha256: ContentHash::from_bytes([9; 32]), + }), + tracked_paths: vec![PathBuf::from("/tmp/work")], + snapshot_count: 3, + exit_code: Some(7), + merkle_roots: vec![ContentHash::from_bytes([1; 32])], + network_events: vec![NetworkAuditEvent { + timestamp_unix_ms: 5, + mode: NetworkAuditMode::Connect, + decision: NetworkAuditDecision::Allow, + route_id: None, + auth_mechanism: None, + auth_outcome: None, + managed_credential_active: None, + injection_mode: None, + denial_category: None, + target: "example.com".to_string(), + port: Some(443), + method: Some("GET".to_string()), + path: Some("/".to_string()), + status: Some(200), + reason: None, + }], + audit_event_count: 9, + audit_integrity: Some(AuditIntegritySummary { + hash_algorithm: "sha256".to_string(), + event_count: 9, + chain_head: ContentHash::from_bytes([2; 32]), + merkle_root: ContentHash::from_bytes([3; 32]), + }), + audit_attestation: None, + }; + let base_digest = compute_session_digest(&base).unwrap(); + + let mut changed = base.clone(); + changed.session_id.push('x'); + assert_ne!(base_digest, compute_session_digest(&changed).unwrap()); + + let mut changed = base.clone(); + changed.network_events[0].target = "other.example.com".to_string(); + assert_ne!(base_digest, compute_session_digest(&changed).unwrap()); + + let mut changed = base.clone(); + changed.audit_integrity = Some(AuditIntegritySummary { + hash_algorithm: "sha256".to_string(), + event_count: 9, + chain_head: ContentHash::from_bytes([8; 32]), + merkle_root: ContentHash::from_bytes([3; 32]), + }); + assert_ne!(base_digest, compute_session_digest(&changed).unwrap()); + } + + /// Golden vectors shared with the Python port in + /// nono-py/tests/test_audit.py (TestRustGoldenVectors keeps the same + /// values). If this test fails, the wire format diverged across + /// language bindings — fix the divergence, never the vector. + #[test] + fn rust_compatibility_golden_vectors() { + let meta = sample_metadata("20260421-200000-11111"); + assert_eq!( + compute_session_digest(&meta).unwrap().to_string(), + "3a1ed53d426d6ea2544cec6cf6b95ccdc31fda4570d86931239ee0f7d7d39012" + ); + + let dir = tempfile::tempdir().unwrap(); + let mut file = OpenOptions::new() + .create(true) + .read(true) + .write(true) + .truncate(false) + .open(dir.path().join("ledger.ndjson")) + .unwrap(); + let record = append_session_to_ledger_file(&mut file, &meta).unwrap(); + assert_eq!( + record.chain_hash.to_string(), + "8b6dbc155d44df05e6b5e9948fb8fff142222b4b41fb37284fb0d1217000e9bb" + ); + + let leaves = vec![ + ContentHash::from_bytes([1; 32]), + ContentHash::from_bytes([2; 32]), + ContentHash::from_bytes([3; 32]), + ContentHash::from_bytes([4; 32]), + ContentHash::from_bytes([5; 32]), + ]; + let proof = build_inclusion_proof(&leaves, 2).unwrap(); + assert_eq!( + serde_json::to_string(&proof).unwrap(), + concat!( + r#"{"leaf_index":2,"leaf_count":5,"#, + r#""leaf_hash":"0303030303030303030303030303030303030303030303030303030303030303","#, + r#""merkle_root":"87f9319b8dbb3d3fd55d419aabf3c218aafd2dfd82d5e30fb22e8e89c10c0160","#, + r#""siblings":[{"direction":"right","hash":"0404040404040404040404040404040404040404040404040404040404040404"},"#, + r#"{"direction":"left","hash":"85fb11ff61817c3aa118af30f054a3ea63c042902722cf8ae35e704fff9624fe"},"#, + r#"{"direction":"right","hash":"0505050505050505050505050505050505050505050505050505050505050505"}]}"# + ) + ); + } } From 5b6819bb0163c1779a00dff7a34501c119fa625a Mon Sep 17 00:00:00 2001 From: Luke Hinds Date: Wed, 10 Jun 2026 19:07:44 +0100 Subject: [PATCH 32/91] fix(audit): address ledger review and clippy Signed-off-by: Luke Hinds --- crates/nono/src/audit.rs | 46 ++++++++++++++++++---------------------- 1 file changed, 21 insertions(+), 25 deletions(-) diff --git a/crates/nono/src/audit.rs b/crates/nono/src/audit.rs index 6f9ede828..260597e48 100644 --- a/crates/nono/src/audit.rs +++ b/crates/nono/src/audit.rs @@ -13,7 +13,7 @@ use crate::{NonoError, Result}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use std::fs::{File, OpenOptions}; -use std::io::{BufRead, BufReader, Read, Seek, SeekFrom, Write}; +use std::io::{BufRead, BufReader, Seek, SeekFrom, Write}; #[cfg(unix)] use std::os::unix::ffi::OsStrExt; use std::path::{Path, PathBuf}; @@ -587,26 +587,28 @@ pub fn append_session_to_ledger_file( ) -> Result { validate_ledger_session_id(&metadata.session_id)?; - let mut contents = String::new(); file.seek(SeekFrom::Start(0)) .map_err(|e| NonoError::Snapshot(format!("Failed to seek audit ledger: {e}")))?; - file.read_to_string(&mut contents) - .map_err(|e| NonoError::Snapshot(format!("Failed to read audit ledger: {e}")))?; let mut previous_chain = None; let mut next_sequence = 0u64; - for (index, line) in contents.lines().enumerate() { - if line.trim().is_empty() { - continue; + { + let reader = BufReader::new(&mut *file); + for (index, line) in reader.lines().enumerate() { + let line = + line.map_err(|e| NonoError::Snapshot(format!("Failed to read audit ledger: {e}")))?; + if line.trim().is_empty() { + continue; + } + let record: LedgerRecord = serde_json::from_str(&line).map_err(|e| { + NonoError::Snapshot(format!( + "Failed to parse audit ledger line {}: {e}", + index.saturating_add(1) + )) + })?; + previous_chain = Some(record.chain_hash); + next_sequence = record.sequence.saturating_add(1); } - let record: LedgerRecord = serde_json::from_str(line).map_err(|e| { - NonoError::Snapshot(format!( - "Failed to parse audit ledger line {}: {e}", - index.saturating_add(1) - )) - })?; - previous_chain = Some(record.chain_hash); - next_sequence = record.sequence.saturating_add(1); } let session_digest = compute_session_digest(metadata)?; @@ -815,25 +817,19 @@ pub fn verify_audit_log( } let event_bytes = if let Some(raw) = record.event_json.as_ref() { - let reparsed: AuditEventPayload = serde_json::from_str(raw).map_err(|e| { + serde_json::from_str::(raw).map_err(|e| { NonoError::Snapshot(format!( "Failed to parse canonical audit event JSON at line {}: {e}", index.saturating_add(1) )) })?; - let reparsed_value = serde_json::to_value(&reparsed).map_err(|e| { - NonoError::Snapshot(format!( - "Failed to normalize canonical audit event JSON at line {}: {e}", - index.saturating_add(1) - )) - })?; - let record_value = serde_json::to_value(&record.event).map_err(|e| { + let canonical_event_bytes = serde_json::to_vec(&record.event).map_err(|e| { NonoError::Snapshot(format!( - "Failed to normalize audit event payload at line {}: {e}", + "Failed to serialize audit event payload at line {}: {e}", index.saturating_add(1) )) })?; - if reparsed_value != record_value { + if raw.as_bytes() != canonical_event_bytes.as_slice() { return Err(NonoError::Snapshot(format!( "Audit event JSON mismatch at line {}", index.saturating_add(1) From 8e92e87ce544547ae186cabcad1f4001dfafeb71 Mon Sep 17 00:00:00 2001 From: Aleksy Siek Date: Wed, 10 Jun 2026 12:14:43 +0100 Subject: [PATCH 33/91] ci: use actions/attest Signed-off-by: Aleksy Siek --- .github/workflows/attest-release.yml | 2 +- .github/workflows/release.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/attest-release.yml b/.github/workflows/attest-release.yml index 760897075..9d7a92e9e 100644 --- a/.github/workflows/attest-release.yml +++ b/.github/workflows/attest-release.yml @@ -35,7 +35,7 @@ jobs: --dir artifacts - name: Attest build provenance - uses: actions/attest-build-provenance@e8998f949152b193b063cb0ec769d69d929409be # v2 + uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0 with: subject-path: | artifacts/*.tar.gz diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5cbff54ba..306f3d494 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -166,7 +166,7 @@ jobs: merge-multiple: true - name: Attest build provenance - uses: actions/attest-build-provenance@e8998f949152b193b063cb0ec769d69d929409be # v2 + uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0 with: subject-path: | artifacts/**/*.tar.gz From 9c03906c9cb2a6185401192213b5c357066b1e80 Mon Sep 17 00:00:00 2001 From: Stephen Parkinson Date: Mon, 8 Jun 2026 18:19:26 +0100 Subject: [PATCH 34/91] fix: guard deduplicate() against inheriting procfs-remap originals (#1064) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In --detached mode on Linux, nono sets stdin/stdout to /dev/null. system_read_linux_core then adds both an explicit /dev/null file entry and a /dev/stdin symlink entry — both resolve to the same inode, so deduplicate() collapses them and (via original_updates) renames the surviving entry's original from /dev/null to /dev/stdin. remap_procfs_self_references() later acts on that original, rewrites resolved to /proc/{child_pid}/fd/0 (the PTY slave), and the Landlock rule ends up attached to the wrong inode. The sandboxed process is then denied access to /dev/null with EACCES. Fix: add is_procfs_remap_original() and guard both original_updates push sites in deduplicate() so that /dev/stdin, /dev/stdout, /dev/stderr, and /dev/fd originals are never inherited into a surviving direct entry. Add regression test remap_preserves_dev_null_when_deduped_with_dev_stdin to the procfs_remap_tests module. Resolves: #1064 Signed-off-by: Stephen Parkinson --- crates/nono/src/capability.rs | 92 ++++++++++++++++++++++++++++++++++- 1 file changed, 90 insertions(+), 2 deletions(-) diff --git a/crates/nono/src/capability.rs b/crates/nono/src/capability.rs index e786b7d83..4f00de815 100644 --- a/crates/nono/src/capability.rs +++ b/crates/nono/src/capability.rs @@ -1546,8 +1546,16 @@ impl CapabilitySet { seen.insert(key, i); // On Linux: preserve symlink original from the removed // entry into the kept entry so `original` stays meaningful. + // Guard: skip if the symlink original is one of the four + // /dev aliases that remap_procfs_self_references() rewrites + // to /proc/{pid}/fd/N — inheriting such an original would + // cause a direct entry (e.g. /dev/null) to be misdirected + // to a PTY slave inode when the remap runs in the child. #[cfg(target_os = "linux")] - if cap.original == cap.resolved && existing.original != existing.resolved { + if cap.original == cap.resolved + && existing.original != existing.resolved + && !is_procfs_remap_original(&existing.original) + { original_updates.push((i, existing.original.clone())); } // Apply merged access to the new (kept) entry @@ -1557,8 +1565,15 @@ impl CapabilitySet { } else { // On Linux: inherit symlink original from the entry // being discarded into the surviving entry. + // Guard: skip if the discarded entry's original is one of + // the four /dev aliases that remap_procfs_self_references() + // rewrites to /proc/{pid}/fd/N — see the keep_new branch + // above for the rationale. #[cfg(target_os = "linux")] - if existing.original == existing.resolved && cap.original != cap.resolved { + if existing.original == existing.resolved + && cap.original != cap.resolved + && !is_procfs_remap_original(&cap.original) + { original_updates.push((existing_idx, cap.original.clone())); } to_remove.push(i); @@ -1764,6 +1779,19 @@ impl CapabilitySet { } } +/// Returns `true` if `path` is any path that [`rewrite_procfs_self_reference`] +/// would rewrite — i.e. inheriting it as an `original` in +/// [`CapabilitySet::deduplicate`] would cause a subsequent +/// `remap_procfs_self_references` call to misdirect the resolved inode. +/// +/// Implemented by delegating to [`rewrite_procfs_self_reference`] so the two +/// functions are always in sync: any path added to the rewriter is +/// automatically covered here without a separate update. +#[cfg(target_os = "linux")] +fn is_procfs_remap_original(path: &Path) -> bool { + rewrite_procfs_self_reference(path, 0, None).is_some() +} + fn rewrite_procfs_self_reference( original: &Path, process_pid: u32, @@ -1877,6 +1905,66 @@ mod procfs_remap_tests { PathBuf::from("/proc/4242/fd/1") ); } + + /// Regression test for the --detached /dev/null denial bug (issue #1064). + /// + /// When nono runs in detached mode, stdin/stdout are both /dev/null. + /// `system_read_linux_core` therefore adds two capabilities whose + /// `resolved` path is `/dev/null`: one with `original = /dev/null` + /// (explicit entry) and one with `original = /dev/stdin` (symlink entry + /// whose canonicalised target is also `/dev/null`). + /// + /// Without the fix, `deduplicate()` would update the surviving entry's + /// `original` from `/dev/null` to `/dev/stdin`, causing + /// `remap_procfs_self_references()` to rewrite `resolved` to + /// `/proc/{pid}/fd/0` (the PTY slave), leaving no Landlock rule for + /// `/dev/null` itself. + /// + /// With the fix, the guard in `deduplicate()` prevents a `/dev/stdin` + /// original from being inherited, so `original` stays `/dev/null` and + /// `remap_procfs_self_references()` leaves `resolved` unchanged. + #[cfg(target_os = "linux")] + #[test] + fn remap_preserves_dev_null_when_deduped_with_dev_stdin() { + let dev_null = PathBuf::from("/dev/null"); + + let mut caps = CapabilitySet::new(); + // Explicit /dev/null entry (direct — original == resolved). + caps.add_fs(FsCapability { + original: dev_null.clone(), + resolved: dev_null.clone(), + access: AccessMode::Read, + is_file: true, + source: CapabilitySource::Group("system_read_linux_core".to_string()), + }); + // /dev/stdin entry whose canonicalised target is also /dev/null + // (happens in detached mode where stdin is redirected to /dev/null). + caps.add_fs(FsCapability { + original: PathBuf::from("/dev/stdin"), + resolved: dev_null.clone(), + access: AccessMode::Read, + is_file: true, + source: CapabilitySource::Group("system_read_linux_core".to_string()), + }); + + // deduplicate() must NOT update original to /dev/stdin. + caps.deduplicate(); + assert_eq!(caps.fs_capabilities().len(), 1); + assert_eq!( + caps.fs_capabilities()[0].original, + dev_null, + "deduplicate must not rename /dev/null original to /dev/stdin" + ); + + // remap_procfs_self_references() must leave resolved as /dev/null, + // not rewrite it to /proc/4242/fd/0. + caps.remap_procfs_self_references(4242, None); + assert_eq!( + caps.fs_capabilities()[0].resolved, + dev_null, + "resolved must remain /dev/null after remap; was misdirected to PTY slave inode" + ); + } } #[cfg(test)] From 4581a04fad1e55b4c7f0a0369fdc8aed30d4cb94 Mon Sep 17 00:00:00 2001 From: Stephen Parkinson Date: Mon, 8 Jun 2026 10:47:59 +0100 Subject: [PATCH 35/91] test(wsl2): fix has_landlock_network V4+ detection The probe grepped the `nono run` banner for "TCP network filtering", which that banner never prints, so Landlock V4+ kernels (incl. V6 on WSL2) were misclassified as pre-V4 and ran the obsolete fail-secure proxy-policy assertions. Probe `nono setup --check-only` for "TCP network rule support verified" instead, and capture its output to a variable before grepping: piping into `grep -q` lets grep close the pipe early, SIGPIPE-ing nono, which under `set -o pipefail` fails the pipeline spuriously (only under bash, not zsh). Signed-off-by: Stephen Parkinson Co-Authored-By: Claude Opus 4.8 --- tests/integration/test_wsl2.sh | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/tests/integration/test_wsl2.sh b/tests/integration/test_wsl2.sh index e9c691072..4cd9eb990 100755 --- a/tests/integration/test_wsl2.sh +++ b/tests/integration/test_wsl2.sh @@ -13,9 +13,15 @@ verify_nono_binary # Detect whether Landlock has native TCP filtering (V4+). # With V4+, per-port network filtering and proxy enforcement work natively. -# Uses --dry-run to avoid sandbox application or forking. +# Probes `setup --check-only`, which performs a real TCP-rule support probe +# without applying a sandbox or forking. The `nono run` banner does not print +# the feature list, so grepping it misclassifies V4+ kernels as pre-V4. +# Output is captured first (not piped into `grep -q`): under `set -o pipefail` +# grep's early exit would SIGPIPE nono and make the pipeline fail spuriously. has_landlock_network() { - "$NONO_BIN" run --dry-run --allow /tmp -- true &1 | grep -q "TCP network filtering" + local _setup_out + _setup_out="$("$NONO_BIN" setup --check-only 2>&1)" + grep -q "TCP network rule support verified" <<<"$_setup_out" } echo "" From 58bf860a96cb93a13f08dc51678106114f9cb6c6 Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Wed, 10 Jun 2026 06:44:10 -0700 Subject: [PATCH 36/91] fix: replace stale nono.dev schema domains with nono.sh Fixes #1084 Signed-off-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> --- crates/nono-cli/src/profile/mod.rs | 2 +- crates/nono-cli/src/profile_cmd.rs | 2 +- crates/nono/schema/capability-manifest.schema.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/nono-cli/src/profile/mod.rs b/crates/nono-cli/src/profile/mod.rs index 40d224fa7..8dd71a9b7 100644 --- a/crates/nono-cli/src/profile/mod.rs +++ b/crates/nono-cli/src/profile/mod.rs @@ -6117,7 +6117,7 @@ mod tests { fn test_top_level_schema_field_allowed_in_profile() { let profile: Profile = serde_json::from_str( r#"{ - "$schema": "https://nono.dev/schemas/nono-profile.schema.json", + "$schema": "https://nono.sh/schemas/nono-profile.schema.json", "meta": { "name": "schema-ok" } }"#, ) diff --git a/crates/nono-cli/src/profile_cmd.rs b/crates/nono-cli/src/profile_cmd.rs index 348c0e912..a4452f3f6 100644 --- a/crates/nono-cli/src/profile_cmd.rs +++ b/crates/nono-cli/src/profile_cmd.rs @@ -3154,7 +3154,7 @@ fn resolve_to_manifest( Ok(manifest::CapabilityManifest { version, - schema: Some("https://nono.dev/schemas/capability-manifest.schema.json".to_string()), + schema: Some("https://nono.sh/schemas/capability-manifest.schema.json".to_string()), filesystem, network, process, diff --git a/crates/nono/schema/capability-manifest.schema.json b/crates/nono/schema/capability-manifest.schema.json index 85451b524..fe5f02521 100644 --- a/crates/nono/schema/capability-manifest.schema.json +++ b/crates/nono/schema/capability-manifest.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://nono.dev/schemas/capability-manifest.schema.json", + "$id": "https://nono.sh/schemas/capability-manifest.schema.json", "title": "nono Capability Manifest", "description": "A fully-resolved, portable capability manifest describing what a nono sandbox enforces. Manifests are the machine-consumable contract for sandbox configuration — they contain no inheritance, no legacy aliases, and no composition. Profiles compile down to manifests. NOTE: additionalProperties is true during schema development to allow forward-compatible evolution without breaking existing consumers. Switch to false once the schema stabilizes (post-1.0).", "type": "object", From 3190a5181c6074b263b67bfce3d9d75a1b50a55a Mon Sep 17 00:00:00 2001 From: Luke Hinds Date: Sat, 6 Jun 2026 08:02:24 +0100 Subject: [PATCH 37/91] docs(cli-quickstart): add profile usage to quickstart guide Expand the CLI quickstart guide to provide more comprehensive information on using and managing profiles for running commands. - Clarify the purpose and recommendation for the `--` separator. - Introduce pre-built profiles, demonstrating usage with fully qualified names. - Guide users on how to build their own custom profiles using `nono profile init`. - Provide a call to action for users to request or contribute new agent profiles. - Update internal links and navigation to relevant profile documentation. Signed-off-by: Luke Hinds --- docs/cli/getting_started/quickstart.mdx | 41 ++++++++++++++++++++----- 1 file changed, 34 insertions(+), 7 deletions(-) diff --git a/docs/cli/getting_started/quickstart.mdx b/docs/cli/getting_started/quickstart.mdx index 07ecfb8cb..3731eebdd 100644 --- a/docs/cli/getting_started/quickstart.mdx +++ b/docs/cli/getting_started/quickstart.mdx @@ -12,22 +12,49 @@ nono run --allow . -- [ARGS...] ``` -The `--` separator is recommended. Everything after it is the command to run. +The `--` separator is for the reader to visually separate nono flags from the command and its arguments. It's not strictly required if there is no ambiguity, but it's a good habit to get into to avoid mistakes as you add more flags. +Anything which is a process, can be run inside a nono sandbox - CLI tools, scripts, even interactive shells. The sandbox is applied to the entire process tree, so any child processes will also be restricted by the same permissions. + +## Pre-Built Profiles + +To get you going quickly, we provide pre-built profiles for popular AI agents and tools. These profiles bundle the right permissions for each tool, so you can get up and running with a single command. + +Just search for your favorite coding agent from the registry + ```bash -# Grant read+write access to current directory, run claude -nono run --allow . -- claude +nono search opencode +always-further/opencode - Official Always Further Opencode Plugin ``` -Or use a profile that bundles the right permissions for a known tool: +And then run.. ```bash -nono run --profile claude-code -- claude -nono run --profile codex -- codex +nono run --profile always-further/claude -- claude +nono run --profile always-further/codex -- codex +nono run --profile always-further/pi -- pi ``` -See [Profiles & Groups](/cli/features/profiles-groups) for all available profiles and how to create your own. + + If the agent you want to use cannot be found, create an [issue](https://github.com/always-further/nono/issues) to request consideration for adding it to the registry, or better yet, fork an existing profile and submit a PR with the new agent profile! See [Profiles & Groups](/cli/features/package-publishing) for details on how to create your own profiles. + + +### Build your own profile + +It's likely that you are going to want to customize the pre-built profiles or create your own for different tools and needs. You can do this easily with `nono profile init` which will create a new profile based on an existing one, with the option to customize it interactively. + +```bash +nono profile init claude --extends always-further/claude --full + +nono profile Created profile at /Users/jdoe/.config/nono/profiles/claude.json +nono profile Validate with: nono profile validate claude +nono profile For editor autocomplete: nono profile schema -o nono-profile.schema.json +``` + +You can now call your own profile with `--profile claude` just like the pre-built ones. + +From here, move to [Profiles & Groups](/cli/features/profiles-groups) for all available profiles and how to create your own. ## Commands From c1d222498baf65a3b0f82e58d40127a6e2b0ea23 Mon Sep 17 00:00:00 2001 From: Luke Hinds Date: Wed, 10 Jun 2026 16:35:50 +0100 Subject: [PATCH 38/91] refactor(profiles): standardize profile names with namespace The `claude-code`, `codex`, and `opencode` profiles are now consistently referenced with their fully qualified names, e.g., `always-further/claude`. This aligns the `--profile` argument syntax with the `namespace/name` convention used for `nono pull` and `nono remove`, enhancing consistency and clarity. This change impacts: - All CLI examples in documentation, READMEs, and help text. - Client-specific and feature documentation across the board. - Internal comments, migration logic, and profile resolution paths. - Shell scripts for testing and cleanup. Signed-off-by: Luke Hinds --- .github/ISSUE_TEMPLATE/onboarding_issue.yml | 4 +- crates/nono-cli/README.md | 12 +- crates/nono-cli/src/cli.rs | 6 +- crates/nono-cli/src/migration.rs | 2 +- crates/nono-cli/src/package_cmd.rs | 2 +- crates/nono-cli/src/profile/mod.rs | 4 +- crates/nono-cli/src/pull_ui.rs | 2 +- crates/nono-cli/src/setup.rs | 4 +- docs/cli/clients/claude-code.mdx | 36 ++--- docs/cli/clients/codex.mdx | 20 +-- docs/cli/clients/opencode.mdx | 12 +- docs/cli/clients/quickstart.mdx | 6 +- docs/cli/features/atomic-rollbacks.mdx | 2 +- docs/cli/features/audit.mdx | 2 +- docs/cli/features/profiles-groups.mdx | 16 +-- docs/cli/features/session-lifecycle.mdx | 10 +- docs/cli/features/supervisor.mdx | 4 +- docs/cli/features/trust.mdx | 4 +- docs/cli/getting_started/quickstart.mdx | 148 +------------------- docs/cli/internals/landlock.mdx | 6 +- docs/cli/usage/developer-workflows.mdx | 24 ++-- docs/cli/usage/examples.mdx | 10 +- docs/cli/usage/flags.mdx | 30 ++-- docs/cli/usage/troubleshooting.mdx | 2 +- scripts/claude-clear-nono.sh | 2 +- scripts/codex-clear-nono.sh | 2 +- scripts/diagnose-macos-browser-open.sh | 8 +- scripts/probe-claude-open-path.sh | 2 +- scripts/test-linux-container.sh | 2 +- scripts/test-linux.sh | 2 +- 30 files changed, 120 insertions(+), 266 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/onboarding_issue.yml b/.github/ISSUE_TEMPLATE/onboarding_issue.yml index b069a9ec7..12cbd3efd 100644 --- a/.github/ISSUE_TEMPLATE/onboarding_issue.yml +++ b/.github/ISSUE_TEMPLATE/onboarding_issue.yml @@ -18,7 +18,7 @@ body: - Installation (Homebrew / cargo / binary download) - First run (`nono run` returned an error immediately) - Writing or loading a profile (JSON) - - Using a built-in profile (e.g. `--profile claude-code`) + - Using a built-in profile (e.g. `--profile always-further/claude`) - nono ran but didn't seem to enforce anything - Something else (describe below) validations: @@ -29,7 +29,7 @@ body: attributes: label: What happened? description: What did you try, and what went wrong? - placeholder: "I followed the quickstart and ran `nono run --profile claude-code -- claude` but got..." + placeholder: "I followed the quickstart and ran `nono run --profile always-further/claude -- claude` but got..." validations: required: true diff --git a/crates/nono-cli/README.md b/crates/nono-cli/README.md index b52af2c13..aa9dafb2a 100644 --- a/crates/nono-cli/README.md +++ b/crates/nono-cli/README.md @@ -40,13 +40,13 @@ nono run --allow ./project-a --allow ./project-b -- command nono run --allow-cwd --block-net -- command # Use a pack profile (requires: nono pull always-further/claude) -nono run --profile claude-code -- claude +nono run --profile always-further/claude -- claude # Use a built-in profile -nono run --profile opencode -- opencode +nono run --profile always-further/opencode -- opencode # Keep a profile but temporarily allow unrestricted network -nono run --profile claude-code --allow-net -- claude +nono run --profile always-further/claude --allow-net -- claude # Start an interactive shell inside the sandbox nono shell --allow . @@ -85,14 +85,14 @@ Precedence is: CLI flag, then `NONO_THEME`, then config file, then the default ` | Profile | Install | Command | |---------|---------|---------| -| Claude Code | `nono pull always-further/claude` | `nono run --profile claude-code -- claude` | -| Codex | `nono pull always-further/codex` | `nono run --profile codex -- codex` | +| Claude Code | `nono pull always-further/claude` | `nono run --profile always-further/claude -- claude` | +| Codex | `nono pull always-further/codex` | `nono run --profile always-further/codex -- codex` | ### Built-in profiles | Profile | Command | |---------|---------| -| OpenCode | `nono run --profile opencode -- opencode` | +| OpenCode | `nono run --profile always-further/opencode -- opencode` | | OpenClaw | `nono run --profile openclaw -- openclaw gateway` | | Swival | `nono run --profile swival -- swival` | diff --git a/crates/nono-cli/src/cli.rs b/crates/nono-cli/src/cli.rs index ef6e6e0a8..f0830b402 100644 --- a/crates/nono-cli/src/cli.rs +++ b/crates/nono-cli/src/cli.rs @@ -133,8 +133,8 @@ pub enum Commands { {after-help}")] #[command(after_help = "\x1b[1mEXAMPLES\x1b[0m nono run --allow . claude # Read/write current dir, run claude - nono run --profile claude-code claude # Use a profile - nono run --profile claude-code --allow-domain api.openai.com claude + nono run --profile always-further/claude claude # Use a profile + nono run --profile always-further/claude --allow-domain api.openai.com claude # Restrict outbound access to listed domains nono run --read ./src --write ./output cargo build # Separate read/write permissions @@ -155,7 +155,7 @@ pub enum Commands { {after-help}")] #[command(after_help = "\x1b[1mEXAMPLES\x1b[0m nono shell --allow . # Shell with read/write to current dir - nono shell --profile claude-code # Use a named profile + nono shell --profile always-further/claude # Use a named profile nono shell --allow . --shell /bin/zsh # Override shell binary ")] Shell(Box), diff --git a/crates/nono-cli/src/migration.rs b/crates/nono-cli/src/migration.rs index 17f8ba9f9..d30d9a104 100644 --- a/crates/nono-cli/src/migration.rs +++ b/crates/nono-cli/src/migration.rs @@ -52,7 +52,7 @@ const OFFICIAL_PACKS: &[OfficialPack] = &[ // renamed to `claude` so it matches the pack name. Once the pack // is installed, the resolver also accepts `claude-code` via the // artifact's `aliases` field. This entry only matters for users - // who type `--profile claude-code` *before* the pack is installed. + // who type `--profile always-further/claude` *before* the pack is installed. OfficialPack { profile_name: "claude-code", namespace: "always-further", diff --git a/crates/nono-cli/src/package_cmd.rs b/crates/nono-cli/src/package_cmd.rs index 9eab4d6e3..9f7f138fc 100644 --- a/crates/nono-cli/src/package_cmd.rs +++ b/crates/nono-cli/src/package_cmd.rs @@ -118,7 +118,7 @@ pub fn run_pull(args: PullArgs) -> Result<()> { // pack (here, not via `migration::check_and_run`), also offer to // strip pre-0.43 inbuilt-hook leftovers. Idempotent — silent no-op // on a clean install. Mirrors the cleanup hook in `check_and_run` - // so power users who skip `--profile claude-code` don't end up with + // so power users who skip `--profile always-further/claude` don't end up with // both legacy and pack hooks firing. if package_ref.namespace == "always-further" && package_ref.name == "claude" { crate::legacy_cleanup::check_and_offer_cleanup()?; diff --git a/crates/nono-cli/src/profile/mod.rs b/crates/nono-cli/src/profile/mod.rs index 8dd71a9b7..27fba960b 100644 --- a/crates/nono-cli/src/profile/mod.rs +++ b/crates/nono-cli/src/profile/mod.rs @@ -2458,7 +2458,7 @@ enum ResolvedBase { /// This handles the v0.42 → v0.43 upgrade case where a user profile /// `extends: ["claude-code"]` and the inbuilt `claude-code` is gone: /// instead of an inscrutable "base profile not found" error, the user -/// sees the same install prompt that `--profile claude-code` would +/// sees the same install prompt that `--profile always-further/claude` would /// produce, with the chain still resolving cleanly on accept. fn load_base_profile_raw( name: &str, @@ -3028,7 +3028,7 @@ pub fn list_profiles() -> Vec { } // Add pack-store profiles — names exposed by installed packs via - // `install_as`. Without this, `--profile claude-code` works (the + // `install_as`. Without this, `--profile always-further/claude` works (the // resolver finds it) but `nono profile list` doesn't surface it, // confusing users who expect a one-stop catalogue. for (name, _pack_ref) in list_pack_store_profiles() { diff --git a/crates/nono-cli/src/pull_ui.rs b/crates/nono-cli/src/pull_ui.rs index b326505c1..7b8163448 100644 --- a/crates/nono-cli/src/pull_ui.rs +++ b/crates/nono-cli/src/pull_ui.rs @@ -2,7 +2,7 @@ //! happens, then renders the Sigstore provenance and install summary as //! a coherent trust-chain block. Same output for the explicit //! `nono pull ` command and the auto-pull path triggered by -//! `--profile claude-code`. +//! `--profile always-further/claude`. //! //! Design rules (do not relax without thinking): //! - No spinners, no in-place line rewrites — output stays readable in diff --git a/crates/nono-cli/src/setup.rs b/crates/nono-cli/src/setup.rs index ae71b61cc..eacc48bfe 100644 --- a/crates/nono-cli/src/setup.rs +++ b/crates/nono-cli/src/setup.rs @@ -374,7 +374,7 @@ impl SetupRunner { println!(" You can add these aliases to {}:", shell_rc); println!(); - println!(" alias nono-claude='nono run --profile claude-code -- claude'"); + println!(" alias nono-claude='nono run --profile always-further/claude -- claude'"); println!(" alias nono-safe='nono run --allow-cwd --block-net --'"); println!(); } @@ -393,7 +393,7 @@ impl SetupRunner { println!("Quick start examples:"); println!(); println!(" # Run Claude Code with built-in profile (recommended)"); - println!(" nono run --profile claude-code -- claude"); + println!(" nono run --profile always-further/claude -- claude"); println!(); println!(" # Run any command with current directory access"); println!(" nono run --allow-cwd -- "); diff --git a/docs/cli/clients/claude-code.mdx b/docs/cli/clients/claude-code.mdx index 11de3880e..a10dd3aa8 100644 --- a/docs/cli/clients/claude-code.mdx +++ b/docs/cli/clients/claude-code.mdx @@ -16,14 +16,14 @@ Claude Code includes its own sandboxing, but Anthropic documents an intentional ## Quick Start ```bash -nono run --profile claude-code -- claude +nono run --profile always-further/claude -- claude ``` **macOS users:** Claude Code needs LaunchServices access when a session will need browser login. `nono` now auto-enables this for the `claude-code` profile when it does not detect refresh-capable local auth. You can also pass the flag explicitly for first use or `claude /login`: ```bash -nono run --profile claude-code --allow-launch-services -- claude +nono run --profile always-further/claude --allow-launch-services -- claude ``` After login completes, rerun without `--allow-launch-services` if you no longer need browser-opening help for that session. See [OAuth2 Login](#oauth2-login) for details. @@ -84,12 +84,12 @@ If you want to inherit the Claude Code profile but remove its network filtering, Then run: ```bash -nono run --profile claude-code-netopen -- claude +nono run --profile always-further/claude-netopen -- claude ``` **Usage:** ```bash -nono run --profile claude-code -- claude +nono run --profile always-further/claude -- claude ``` @@ -103,7 +103,7 @@ If you use multiple Claude Code configurations via the `CLAUDE_CONFIG_DIR` envir ```bash export CLAUDE_CONFIG_DIR=~/.claude-work -nono run --profile claude-code \ +nono run --profile always-further/claude \ --allow ~/.claude-work \ --allow ~/.claude-work.lock \ -- claude @@ -147,7 +147,7 @@ secret-tool store --label="nono: anthropic_api_key" service nono username anthro Then run with secrets: ```bash -nono run --profile claude-code --env-credential anthropic_api_key -- claude +nono run --profile always-further/claude --env-credential anthropic_api_key -- claude ``` See [Credential Injection](/cli/features/credential-injection) for full documentation. @@ -173,7 +173,7 @@ nono run --read . --read ~/.claude --allow-file ~/.claude.json -- claude If you want to prevent any outbound connections (e.g., for reviewing local code without API calls): ```bash -nono run --profile claude-code --block-net -- claude +nono run --profile always-further/claude --block-net -- claude ``` ### Add Extra Domains @@ -181,7 +181,7 @@ nono run --profile claude-code --block-net -- claude If Claude Code needs to reach a domain not in the built-in network profile: ```bash -nono run --profile claude-code --allow-domain my-internal-api.example.com -- claude +nono run --profile always-further/claude --allow-domain my-internal-api.example.com -- claude ``` ## OAuth2 Login @@ -196,13 +196,13 @@ The `claude-code` profile includes an origin allowlist that controls which URLs On Linux, no additional flags are needed: ```bash -nono run --profile claude-code -- claude +nono run --profile always-further/claude -- claude ``` On macOS, `nono` auto-enables LaunchServices for the `claude-code` profile when no refresh-capable local auth is detected. You can also enable it explicitly for the login session: ```bash -nono run --profile claude-code --allow-launch-services -- claude +nono run --profile always-further/claude --allow-launch-services -- claude ``` After login completes, rerun without `--allow-launch-services` unless you still need browser-opening help for that session. @@ -264,7 +264,7 @@ echo "$PATH" | tr ':' '\n' Grant read access to any `~/` entry that appears before the directory containing your LSP binary: ```bash -nono run --profile claude-code \ +nono run --profile always-further/claude \ --read ~/.cargo/bin \ --read ~/.local/bin \ -- claude @@ -309,7 +309,7 @@ Save as `~/.config/nono/profiles/claude-code-secretive.json`: **Usage:** ```bash -nono run --profile claude-code-secretive --allow-cwd -- claude +nono run --profile always-further/claude-secretive --allow-cwd -- claude ``` The profile extends the standard Claude Code profile with: @@ -326,13 +326,13 @@ CLI flags always take precedence over profile settings: ```bash # Use profile but add extra directory access -nono run --profile claude-code --allow ~/other-project -- claude +nono run --profile always-further/claude --allow ~/other-project -- claude # Use profile but block network -nono run --profile claude-code --block-net -- claude +nono run --profile always-further/claude --block-net -- claude # Use profile but add a custom domain -nono run --profile claude-code --allow-domain custom-api.example.com -- claude +nono run --profile always-further/claude --allow-domain custom-api.example.com -- claude ``` See [Security Profiles](/cli/features/profiles-groups) for details on profile format and precedence rules. @@ -341,7 +341,7 @@ See [Security Profiles](/cli/features/profiles-groups) for details on profile fo As of v0.43, the `claude-code` profile is no longer compiled into the CLI. It's distributed as a signed registry pack at `always-further/claude` along with a Claude Code plugin that provides sandbox-aware diagnostics. -The first time you run `nono run --profile claude-code -- claude`, nono prompts to install the pack: +The first time you run `nono run --profile always-further/claude -- claude`, nono prompts to install the pack: ``` ⊕ Install always-further/claude? @@ -375,7 +375,7 @@ The pack store is the single source of truth — every Claude-side path is a sym ### Subsequent Runs -Once installed, `nono run --profile claude-code -- claude` runs silently — no prompt. nono's profile resolver finds `claude-code` in the pack store and re-asserts the marketplace wiring on every run, so deleting any of the symlinks above is recovered transparently on the next `nono run`. +Once installed, `nono run --profile always-further/claude -- claude` runs silently — no prompt. nono's profile resolver finds `claude-code` in the pack store and re-asserts the marketplace wiring on every run, so deleting any of the symlinks above is recovered transparently on the next `nono run`. If a newer version of the pack is available, nono prints a one-line hint after the capabilities block: @@ -468,4 +468,4 @@ These commands are read-only diagnostics — none of them modify state — so it nono remove always-further/claude ``` -This removes the pack store directory, the marketplace dir, the cache subtree, the entries in `known_marketplaces.json` / `installed_plugins.json`, and the `enabledPlugins["nono@always-further"]` toggle. Claude Code's view of the world is restored to what it was before the install. Re-running `nono run --profile claude-code` will prompt to re-install. +This removes the pack store directory, the marketplace dir, the cache subtree, the entries in `known_marketplaces.json` / `installed_plugins.json`, and the `enabledPlugins["nono@always-further"]` toggle. Claude Code's view of the world is restored to what it was before the install. Re-running `nono run --profile always-further/claude` will prompt to re-install. diff --git a/docs/cli/clients/codex.mdx b/docs/cli/clients/codex.mdx index 6beaf051a..edbcf2bda 100644 --- a/docs/cli/clients/codex.mdx +++ b/docs/cli/clients/codex.mdx @@ -11,7 +11,7 @@ When you run Codex under `nono`, prefer a single sandbox layer: let `nono` enfor Recommended invocation: ```bash -nono run --profile codex -- codex --sandbox danger-full-access --ask-for-approval on-request +nono run --profile always-further/codex -- codex --sandbox danger-full-access --ask-for-approval on-request ``` This keeps Codex's approval flow while avoiding nested sandboxes that can make denials harder to diagnose. See OpenAI's [agent approvals and security docs](https://developers.openai.com/codex/agent-approvals-security). @@ -30,7 +30,7 @@ nono makes those boundaries kernel-enforced instead of advisory. ## Quick Start ```bash -nono run --profile codex -- codex --sandbox danger-full-access --ask-for-approval on-request +nono run --profile always-further/codex -- codex --sandbox danger-full-access --ask-for-approval on-request ``` If the registry pack `always-further/codex` isn't installed yet, nono prompts to pull it (see [Automatic Pack Install and Plugin Wiring](#automatic-pack-install-and-plugin-wiring) below). @@ -48,7 +48,7 @@ The pack profile provides: The `codex` profile is distributed as a signed registry pack at `always-further/codex` along with a Codex plugin that provides sandbox-aware diagnostic hooks and a skill for guiding the user through denial-recovery. -The first time you run `nono run --profile codex -- codex`, nono prompts to install the pack: +The first time you run `nono run --profile always-further/codex -- codex`, nono prompts to install the pack: ``` ⊕ Install always-further/codex? @@ -105,7 +105,7 @@ note: Codex hooks need this in ~/.codex/config.toml to fire — add it once and ### Subsequent runs -Once installed, `nono run --profile codex -- codex` runs silently — no prompt. nono's profile resolver finds `codex` in the pack store and re-asserts the marketplace + cache wiring on every run, so deleting any of the entries above is recovered transparently on the next `nono run`. +Once installed, `nono run --profile always-further/codex -- codex` runs silently — no prompt. nono's profile resolver finds `codex` in the pack store and re-asserts the marketplace + cache wiring on every run, so deleting any of the entries above is recovered transparently on the next `nono run`. If a newer version of the pack is available, nono prints a one-line hint after the capabilities block: @@ -180,13 +180,13 @@ The profile includes the required browser-open allowlist: On Linux, no additional flags are needed: ```bash -nono run --profile codex -- codex --sandbox danger-full-access --ask-for-approval on-request +nono run --profile always-further/codex -- codex --sandbox danger-full-access --ask-for-approval on-request ``` On macOS, enable LaunchServices temporarily for the login session: ```bash -nono run --profile codex --allow-launch-services -- codex --sandbox danger-full-access --ask-for-approval on-request +nono run --profile always-further/codex --allow-launch-services -- codex --sandbox danger-full-access --ask-for-approval on-request ``` After login completes, exit and rerun without `--allow-launch-services`. @@ -200,7 +200,7 @@ After login completes, exit and rerun without `--allow-launch-services`. The profile grants access to the directory you run Codex from. To pin access to a specific repository: ```bash -nono run --profile codex --workdir ~/projects/my-app -- codex --sandbox danger-full-access --ask-for-approval on-request +nono run --profile always-further/codex --workdir ~/projects/my-app -- codex --sandbox danger-full-access --ask-for-approval on-request ``` ### Read-Only Workspace @@ -216,7 +216,7 @@ nono run --read . --allow ~/.codex -- codex --sandbox danger-full-access --ask-f If you want to inspect local code without allowing outbound access: ```bash -nono run --profile codex --block-net -- codex --sandbox danger-full-access --ask-for-approval on-request +nono run --profile always-further/codex --block-net -- codex --sandbox danger-full-access --ask-for-approval on-request ``` ### Use Network Filtering @@ -224,7 +224,7 @@ nono run --profile codex --block-net -- codex --sandbox danger-full-access --ask If you want Codex limited to the built-in host allowlist for coding workflows: ```bash -nono run --profile codex --network-profile codex -- codex --sandbox danger-full-access --ask-for-approval on-request +nono run --profile always-further/codex --network-profile codex -- codex --sandbox danger-full-access --ask-for-approval on-request ``` ### Additional Home-Directory Tools @@ -234,7 +234,7 @@ The profile covers common Rust, Node.js, Python, and Nix runtime locations under If a tool exists on your `PATH` but Codex cannot launch it inside the sandbox, grant read access to the specific path entry: ```bash -nono run --profile codex \ +nono run --profile always-further/codex \ --read ~/.bun/bin \ --read ~/go/bin \ -- codex --sandbox danger-full-access --ask-for-approval on-request diff --git a/docs/cli/clients/opencode.mdx b/docs/cli/clients/opencode.mdx index 798dad22e..8dc39beeb 100644 --- a/docs/cli/clients/opencode.mdx +++ b/docs/cli/clients/opencode.mdx @@ -18,7 +18,7 @@ nono prevents all of this at the kernel level. ## Quick Start ```bash -nono run --profile opencode -- opencode +nono run --profile always-further/opencode -- opencode ``` The profile provides: @@ -67,7 +67,7 @@ Create `~/.config/nono/profiles/opencode.json` for different permissions: **Usage:** ```bash -nono run --profile opencode -- opencode +nono run --profile always-further/opencode -- opencode ``` ## Security Tips @@ -88,7 +88,7 @@ secret-tool store --label="nono: openai_api_key" service nono username openai_ap Then run: ```bash -nono run --profile opencode --env-credential openai_api_key -- opencode +nono run --profile always-further/opencode --env-credential openai_api_key -- opencode ``` See [Credential Injection](/cli/features/credential-injection) for full documentation. @@ -113,10 +113,10 @@ CLI flags always take precedence: ```bash # Add extra directory access -nono run --profile opencode --allow ~/shared-libs -- opencode +nono run --profile always-further/opencode --allow ~/shared-libs -- opencode # Block network -nono run --profile opencode --block-net -- opencode +nono run --profile always-further/opencode --block-net -- opencode ``` See [Security Profiles](/cli/features/profiles-groups) for details on profile format and precedence rules. @@ -130,5 +130,5 @@ OpenCode routes Google Gemini requests directly to the Google API rather than th Use `--env-credential` instead of proxy injection when working with Gemini: ```bash -nono run --profile opencode --env-credential gemini_api_key -- opencode +nono run --profile always-further/opencode --env-credential gemini_api_key -- opencode ``` diff --git a/docs/cli/clients/quickstart.mdx b/docs/cli/clients/quickstart.mdx index 1ca0b8097..4ae6e7ec7 100644 --- a/docs/cli/clients/quickstart.mdx +++ b/docs/cli/clients/quickstart.mdx @@ -26,13 +26,13 @@ For example: ```bash # Claude Code -nono run --profile claude-code -- claude +nono run --profile always-further/claude -- claude # Codex -nono run --profile codex -- codex +nono run --profile always-further/codex -- codex # OpenCode -nono run --profile opencode -- opencode +nono run --profile always-further/opencode -- opencode # OpenClaw gateway nono run --profile openclaw -- openclaw gateway diff --git a/docs/cli/features/atomic-rollbacks.mdx b/docs/cli/features/atomic-rollbacks.mdx index 08a758727..8c64d8020 100644 --- a/docs/cli/features/atomic-rollbacks.mdx +++ b/docs/cli/features/atomic-rollbacks.mdx @@ -18,7 +18,7 @@ When you run a command with `--rollback`, nono: ```bash # Enable rollback snapshots -nono run --rollback --profile claude-code -- claude +nono run --rollback --profile always-further/claude -- claude ``` diff --git a/docs/cli/features/audit.mdx b/docs/cli/features/audit.mdx index 0a4adf94d..ef0927826 100644 --- a/docs/cli/features/audit.mdx +++ b/docs/cli/features/audit.mdx @@ -156,7 +156,7 @@ To have the supervisor sign the completed session audit record, use `--audit-sig nono run --audit-sign-key default --allow-cwd -- my-agent # Sign with an explicit secret backend reference -nono run --audit-sign-key op://Development/Nono/audit-key --profile claude-code -- claude +nono run --audit-sign-key op://Development/Nono/audit-key --profile always-further/claude -- claude ``` The signing key is loaded by the trusted supervisor before sandbox activation. After the session ends, the resulting keyed DSSE bundle is written into the audit session directory as `audit-attestation.bundle`, and a summary is stored in `session.json`. diff --git a/docs/cli/features/profiles-groups.mdx b/docs/cli/features/profiles-groups.mdx index ae87e2190..28f5ca56e 100644 --- a/docs/cli/features/profiles-groups.mdx +++ b/docs/cli/features/profiles-groups.mdx @@ -24,7 +24,7 @@ Profiles simplify this: ```bash # With profiles - concise and auditable -nono run --profile claude-code -- claude +nono run --profile always-further/claude -- claude ``` ### Profile Sources @@ -306,7 +306,7 @@ This allows access to `~/.docker` even though `deny_credentials` blocks it by de This is equivalent to the CLI flag `--bypass-protection`: ```bash -nono run --profile opencode --allow ~/.docker --bypass-protection ~/.docker -- opencode +nono run --profile always-further/opencode --allow ~/.docker --bypass-protection ~/.docker -- opencode ``` #### Suppressing Save Suggestions Without Granting Access @@ -489,13 +489,13 @@ CLI flags always take precedence over profile settings: ```bash # Use claude-code profile but block network -nono run --profile claude-code --block-net -- claude +nono run --profile always-further/claude --block-net -- claude # Use claude-code profile but add a custom domain -nono run --profile claude-code --allow-domain custom-api.example.com -- claude +nono run --profile always-further/claude --allow-domain custom-api.example.com -- claude # Add extra directory access -nono run --profile claude-code --allow ~/other-project -- claude +nono run --profile always-further/claude --allow ~/other-project -- claude ``` To keep a base profile but clear an inherited network profile, set `network.network_profile` to `null` in the child: @@ -662,7 +662,7 @@ The base profile that all other profiles extend. Provides system path access, de ### claude-code ```bash -nono run --profile claude-code -- claude +nono run --profile always-further/claude -- claude ``` **Groups:** `claude_code_macos`, `claude_code_linux`, `user_caches_macos`, `claude_cache_linux`, `node_runtime`, `rust_runtime`, `python_runtime`, `vscode_macos`, `vscode_linux`, `nix_runtime`, `git_config`, `unlink_protection` (plus `default` groups) @@ -678,7 +678,7 @@ nono run --profile claude-code -- claude ### codex ```bash -nono run --profile codex -- codex +nono run --profile always-further/codex -- codex ``` **Groups:** `codex_macos`, `node_runtime`, `rust_runtime`, `python_runtime`, `nix_runtime`, `git_config`, `unlink_protection` (plus `default` groups) @@ -694,7 +694,7 @@ nono run --profile codex -- codex ### opencode ```bash -nono run --profile opencode -- opencode +nono run --profile always-further/opencode -- opencode ``` **Groups:** `user_caches_macos`, `user_caches_linux`, `node_runtime`, `opencode_linux`, `git_config`, `unlink_protection` (plus `default` groups) diff --git a/docs/cli/features/session-lifecycle.mdx b/docs/cli/features/session-lifecycle.mdx index e9a9395c2..0fd3de216 100644 --- a/docs/cli/features/session-lifecycle.mdx +++ b/docs/cli/features/session-lifecycle.mdx @@ -46,7 +46,7 @@ In normal day-to-day usage, the common states are: ### Attached Start ```bash -nono run --profile claude-code --allow-cwd -- claude +nono run --profile always-further/claude --allow-cwd -- claude ``` This starts the session attached to your current terminal. You interact with the agent directly from the start. @@ -54,7 +54,7 @@ This starts the session attached to your current terminal. You interact with the ### Detached Start ```bash -nono run --detached --profile claude-code --allow-cwd -- claude +nono run --detached --profile always-further/claude --allow-cwd -- claude ``` This starts the session with no client attached, but the session keeps running under supervisor and PTY management. @@ -210,7 +210,7 @@ nono prune --keep 20 Use this when you want an agent to keep running while you do something else: ```bash -nono run --detached --rollback --profile claude-code --allow-cwd -- claude +nono run --detached --rollback --profile always-further/claude --allow-cwd -- claude nono ps nono attach ``` @@ -222,7 +222,7 @@ This is the recommended workflow for long-running coding or migration sessions. Use this when you want to watch the first phase of the run, then leave it working: ```bash -nono run --rollback --profile claude-code --allow-cwd -- claude +nono run --rollback --profile always-further/claude --allow-cwd -- claude ``` Then detach with: @@ -267,7 +267,7 @@ Prefer `nono stop` over killing the child directly, because the supervisor is re Detached sessions are especially useful when combined with rollback: ```bash -nono run --detached --rollback --profile claude-code --allow-cwd -- claude +nono run --detached --rollback --profile always-further/claude --allow-cwd -- claude ``` This gives you two safety properties at once: diff --git a/docs/cli/features/supervisor.mdx b/docs/cli/features/supervisor.mdx index 8e3f2001d..b8e937aae 100644 --- a/docs/cli/features/supervisor.mdx +++ b/docs/cli/features/supervisor.mdx @@ -11,10 +11,10 @@ Supervised execution is the default for `nono run` and `nono shell`. No extra fl ```bash # Supervised by default -nono run --profile claude-code -- claude +nono run --profile always-further/claude -- claude # Enable rollback snapshots -nono run --rollback --profile claude-code -- claude +nono run --rollback --profile always-further/claude -- claude ``` The execution model: diff --git a/docs/cli/features/trust.mdx b/docs/cli/features/trust.mdx index f2a11e0f9..bcf02aaa6 100644 --- a/docs/cli/features/trust.mdx +++ b/docs/cli/features/trust.mdx @@ -178,7 +178,7 @@ nono trust sign-policy --keyref file:///path/to/key.pem # file-backed key For development and testing, disable attestation enforcement: ```bash -nono run --profile claude-code --trust-override -- claude +nono run --profile always-further/claude --trust-override -- claude ``` Verification still runs and results are logged, but failures produce warnings instead of hard denies. This flag is CLI-only — it cannot be set in a profile or trust policy. @@ -515,7 +515,7 @@ When `nono run` launches a command, it scans the working directory for files mat To keep startup latency bounded in large repositories, the scan prunes a small built-in set of regenerable heavy directories such as `.git`, `node_modules`, `target`, `dist`, and common cache directories. Hidden directories are otherwise still scanned. You can extend the skip set for a session with `--skip-dir ` or persist it in a profile with `skipdirs`. ``` -nono run --profile claude-code -- claude +nono run --profile always-further/claude -- claude -> Scan CWD for files matching includes patterns -> For each match: -> Compute SHA-256 digest diff --git a/docs/cli/getting_started/quickstart.mdx b/docs/cli/getting_started/quickstart.mdx index 3731eebdd..4dead0be0 100644 --- a/docs/cli/getting_started/quickstart.mdx +++ b/docs/cli/getting_started/quickstart.mdx @@ -54,155 +54,9 @@ nono profile For editor autocomplete: nono profile schema -o nono-profile.schema You can now call your own profile with `--profile claude` just like the pre-built ones. -From here, move to [Profiles & Groups](/cli/features/profiles-groups) for all available profiles and how to create your own. - -## Commands - -| Command | Description | -|---------|-------------| -| `nono run` | Run a command inside the sandbox (supervised, default) | -| `nono shell` | Start an interactive shell inside the sandbox | -| `nono wrap` | Apply sandbox and exec into command (no parent process, minimal overhead) | -| `nono ps` / `attach` / `detach` / `stop` / `inspect` / `prune` | [Manage runtime sessions](/cli/features/session-lifecycle) - discover, reattach, inspect, stop, and clean up long-lived sessions | -| `nono why` | Check why a path/network operation would be allowed or denied | -| `nono rollback` | [Manage rollback sessions](/cli/features/atomic-rollbacks) - list, show, restore, verify, cleanup snapshots | -| `nono audit` | [View audit trail](/cli/features/audit) - list and inspect past supervised sessions, including optional integrity metadata | -| `nono trust` | [Manage instruction file trust](/cli/features/trust) - sign, verify, and manage attestation | -| `nono setup` | System setup and verification - generate profiles, check shell integration | - -## Permissions - -nono provides three levels of filesystem access: - -| Flag | Access Level | Use Case | -|------|--------------|----------| -| `--allow` / `-a` | Read + Write | Working directories, project folders | -| `--read` / `-r` | Read Only | Source code, configuration | -| `--write` / `-w` | Write Only | Output directories, logs | - -**Directory flags** (`--allow`, `--read`, `--write`) grant recursive access. **File flags** (`--allow-file`, `--read-file`, `--write-file`) grant access to a single file. - -```bash -# Recursive access to entire directory -nono run --allow ./project -- command - -# Access to single config file only -nono run --read-file ./config.toml -- command -``` - -## Network Access - -Network is **allowed by default**. Use `--block-net` to disable outbound connections: - -```bash -nono run --allow . --block-net -- cargo build -``` - -For granular control, use `--network-profile` for host-level filtering or `--open-port` for localhost IPC between sandboxes: - -```bash -# Host-level filtering via proxy -nono run --allow . --network-profile claude-code -- my-agent - -# Allow specific domains through the proxy -nono run --allow . --allow-domain api.openai.com -- my-agent - -# Localhost IPC (e.g., MCP server on port 3000) -nono run --block-net --open-port 3000 --allow . -- my-agent -``` - -See [Networking](/cli/features/networking) and [CLI Reference](/cli/usage/flags) for details. - -## Interactive Shell (`nono shell`) - -Start a shell with the same sandbox permissions as `nono run`: - -```bash -# Shell with access only to current directory -nono shell --allow . - -# Shell with a named profile -nono shell --profile claude-code - -# Override the shell binary -nono shell --allow-cwd --shell /bin/zsh -``` - -Exit the shell with `Ctrl-D` or `exit`. - -## Checking Path Access (`nono why`) - -The `why` command checks if a path or network operation would be allowed or denied. It's designed for both human debugging and programmatic use by AI agents. - -```bash -# Check if a sensitive path would be blocked -nono why --path ~/.ssh/id_rsa --op read -# Output: DENIED - sensitive_path (SSH keys and config) - -# Check with capability context -nono why --path ./src --op write --allow . -# Output: ALLOWED - Granted by: --allow . - -# JSON output for AI agents -nono why --json --path ~/.aws --op read -# {"status":"denied","reason":"sensitive_path","category":"AWS credentials",...} - -# From inside a sandbox, query own capabilities -nono run --allow-cwd -- nono why --self --path /tmp --op write --json -``` - -| Flag | Description | -|------|-------------| -| `--path` | Filesystem path to check | -| `--op` | Operation: `read`, `write`, or `readwrite` (default: `read`) | -| `--host` | Network host to check (instead of `--path`) | -| `--port` | Network port (default: 443) | -| `--json` | Output JSON for programmatic use | -| `--self` | Query current sandbox state (inside sandbox) | - -This is particularly useful for AI agents - when an operation fails, the agent can call `nono why --self` to get a structured JSON response explaining why and how to fix it. - -## What Happens at Runtime - -1. **Parse** — nono parses your capability flags -2. **Canonicalize** — All paths are resolved to absolute paths (prevents symlink escapes) -3. **Apply Sandbox** — Kernel sandbox is initialized (irreversible) -4. **Fork & Execute** — nono forks a sandboxed child process and runs your command inside it. The unsandboxed parent stays alive for audit recording, rollback, and diagnostics. -5. **Enforce** — Kernel blocks any unauthorized access attempts - -## Sensitive Paths - -The following paths are always blocked by default to protect credentials: - -- `~/.ssh` - SSH keys -- `~/.aws`, `~/.gcloud`, `~/.azure` - Cloud credentials -- `~/.gnupg` - GPG keys -- `~/.kube`, `~/.docker` - Container credentials -- `~/.zshrc`, `~/.bashrc`, `~/.profile` - Shell configs (often contain secrets) -- `~/.npmrc`, `~/.git-credentials` - Package manager tokens - -Use `nono why --path --op read` to check if a specific path is blocked and why. See [Profiles & Groups](/cli/features/profiles-groups) for the full list and how group policy controls these. - -## Agent Integration - -For setting up nono with a specific AI agent: - -- [Claude Code](/cli/clients/claude-code) -- [Codex](/cli/clients/codex) -- [OpenCode](/cli/clients/opencode) -- [OpenClaw](/cli/clients/openclaw) - - - If there is an Agent you want supported please open an issue or PR to add it! - +From here, move to [Profiles & Groups](/cli/features/profiles-groups) for more indepth details on how to create and manage profiles, groups, and the policy engine. ## Next Steps -- [Developer Workflows](/cli/usage/developer-workflows) - Recommended day-to-day patterns for coding agents - [CLI Reference](/cli/usage/flags) - Complete flag documentation - [Examples](/cli/usage/examples) - Common usage patterns -- [Session Lifecycle](/cli/features/session-lifecycle) - Live detached sessions, attach/detach, inspection, and stop flows -- [Profiles & Groups](/cli/features/profiles-groups) - Pre-configured capability sets and composable security groups -- [Credential Injection](/cli/features/credential-injection) - Secure API key loading from system keystore -- [Undo & Snapshots](/cli/features/atomic-rollbacks) - Filesystem snapshots with integrity verification -- [Troubleshooting](/cli/usage/troubleshooting) - Common issues and solutions diff --git a/docs/cli/internals/landlock.mdx b/docs/cli/internals/landlock.mdx index 9ca462048..c927da703 100644 --- a/docs/cli/internals/landlock.mdx +++ b/docs/cli/internals/landlock.mdx @@ -120,14 +120,14 @@ nono derives these scopes from the effective capability set: Use verbose dry-run output to see what would be requested and enforced: ```bash -nono run --dry-run -v --profile claude-code -- claude +nono run --dry-run -v --profile always-further/claude -- claude ``` Use `nono why` for a focused scope query: ```bash -nono why --scope signal --profile claude-code -nono why --scope abstract-unix-socket --profile claude-code +nono why --scope signal --profile always-further/claude +nono why --scope abstract-unix-socket --profile always-further/claude ``` ## Pathname Unix Socket Mediation diff --git a/docs/cli/usage/developer-workflows.mdx b/docs/cli/usage/developer-workflows.mdx index e6139077e..add4bc347 100644 --- a/docs/cli/usage/developer-workflows.mdx +++ b/docs/cli/usage/developer-workflows.mdx @@ -28,7 +28,7 @@ This does not mean turning off the agent's own approval UX. It means avoiding a Recommended pattern: ```bash -nono run --profile claude-code --allow-cwd -- claude +nono run --profile always-further/claude --allow-cwd -- claude ``` When running Claude Code under `nono`, prefer using `nono` as the real sandbox boundary and keep Claude Code's native sandbox disabled, or at minimum configured not to fall back to unsandboxed execution. @@ -45,7 +45,7 @@ See [Claude Code](/cli/clients/claude-code) for the agent-specific details. Recommended pattern: ```bash -nono run --profile codex --allow-cwd -- codex --sandbox danger-full-access --ask-for-approval on-request +nono run --profile always-further/codex --allow-cwd -- codex --sandbox danger-full-access --ask-for-approval on-request ``` The important part is the same: let `nono` enforce the filesystem and network boundary, and avoid layering another independent sandbox on top unless you have a specific reason. @@ -75,7 +75,7 @@ The difference is that the session is still running inside the `nono` runtime mo ### Start Detached ```bash -nono run --detached --profile claude-code --allow-cwd -- claude "Do some long-running work" +nono run --detached --profile always-further/claude --allow-cwd -- claude "Do some long-running work" ``` Then later: @@ -88,7 +88,7 @@ nono attach ### Start Attached, Then Detach ```bash -nono run --profile claude-code --allow-cwd -- claude +nono run --profile always-further/claude --allow-cwd -- claude ``` Then detach with: @@ -139,7 +139,7 @@ See [Session Lifecycle](/cli/features/session-lifecycle) for the full command re Use this when you expect to stay attached the whole time: ```bash -nono run --profile claude-code --allow-cwd -- claude +nono run --profile always-further/claude --allow-cwd -- claude ``` Good for: @@ -153,7 +153,7 @@ Good for: Use this when the agent may run for a while and you do not want to babysit the terminal: ```bash -nono run --detached --profile claude-code --allow-cwd -- claude +nono run --detached --profile always-further/claude --allow-cwd -- claude nono ps nono attach ``` @@ -170,7 +170,7 @@ Good for: This is the safest default for agents that may modify a lot of code: ```bash -nono run --detached --rollback --profile claude-code --allow-cwd -- claude +nono run --detached --rollback --profile always-further/claude --allow-cwd -- claude ``` This combines: @@ -214,8 +214,8 @@ See [Session Lifecycle](/cli/features/session-lifecycle) for the full runtime co Use preset, pack, or custom profiles for repeatable workflows: ```bash -nono run --profile claude-code -- claude -nono run --profile codex -- codex --sandbox danger-full-access --ask-for-approval on-request +nono run --profile always-further/claude -- claude +nono run --profile always-further/codex -- codex --sandbox danger-full-access --ask-for-approval on-request ``` Profiles make your workflow easier to repeat, share, and audit. @@ -231,7 +231,7 @@ That is often the right choice for coding agents, but treat it as an explicit wo Some flows, especially browser-based login on macOS, need temporary extra permissions: ```bash -nono run --profile claude-code --allow-launch-services -- claude +nono run --profile always-further/claude --allow-launch-services -- claude ``` Use those only for setup, then exit and rerun without them. @@ -251,7 +251,7 @@ That preserves runtime cleanup, terminal cleanup, and rollback finalization. If you are going to let an agent run while detached, `--rollback` is usually worth the extra bookkeeping: ```bash -nono run --detached --rollback --profile codex --allow-cwd -- codex --sandbox danger-full-access --ask-for-approval on-request +nono run --detached --rollback --profile always-further/codex --allow-cwd -- codex --sandbox danger-full-access --ask-for-approval on-request ``` This is the safer operational default for: @@ -266,7 +266,7 @@ This is the safer operational default for: If you want one strong default workflow for powerful coding agents, use this: ```bash -nono run --detached --rollback --profile claude-code --allow-cwd -- claude +nono run --detached --rollback --profile always-further/claude --allow-cwd -- claude ``` That gives you: diff --git a/docs/cli/usage/examples.mdx b/docs/cli/usage/examples.mdx index 8260bf028..776ba5e1a 100644 --- a/docs/cli/usage/examples.mdx +++ b/docs/cli/usage/examples.mdx @@ -67,7 +67,7 @@ nono why --path ./src --op write --allow . # Output: ALLOWED - Granted by: --allow . # Check against a profile -nono why --path ./src --op read --profile claude-code +nono why --path ./src --op read --profile always-further/claude ``` ### Query from inside a sandbox @@ -237,7 +237,7 @@ nono run --allow . \ ```bash # Claude Code profile -nono run --profile claude-code -- claude +nono run --profile always-further/claude -- claude # OpenClaw profile nono run --profile openclaw -- openclaw gateway @@ -246,19 +246,19 @@ nono run --profile openclaw -- openclaw gateway ### Profile with Extra Permissions ```bash -nono run --profile claude-code --read /tmp/extra -- claude +nono run --profile always-further/claude --read /tmp/extra -- claude ``` ### Profile with Custom Workdir ```bash -nono run --profile claude-code --workdir ./my-project -- claude +nono run --profile always-further/claude --workdir ./my-project -- claude ``` ### Restrict Profile to Specific Domains ```bash -nono run --profile claude-code --allow-domain api.openai.com -- claude +nono run --profile always-further/claude --allow-domain api.openai.com -- claude ``` ## Real-World Scenarios diff --git a/docs/cli/usage/flags.mdx b/docs/cli/usage/flags.mdx index e18a88288..a3825242b 100644 --- a/docs/cli/usage/flags.mdx +++ b/docs/cli/usage/flags.mdx @@ -175,7 +175,7 @@ Subcommands: Compare against an existing profile to show only missing paths. ```bash -nono learn --profile opencode -- opencode +nono learn --profile always-further/opencode -- opencode ``` ### `--json` @@ -295,7 +295,7 @@ not grant access and does not remove the diagnostic footer; it only prevents the same denied path from being offered as a profile addition repeatedly. ```bash -nono run --profile claude-code \ +nono run --profile always-further/claude \ --suppress-save-prompt ~/.copilot/settings.json \ -- claude ``` @@ -802,7 +802,7 @@ See [Credential Injection](/cli/features/credential-injection) for full document Use a named profile (from installed packs or `~/.config/nono/profiles/`). ```bash -nono run --profile claude-code -- claude +nono run --profile always-further/claude -- claude nono run -p openclaw -- openclaw gateway ``` @@ -811,7 +811,7 @@ nono run -p openclaw -- openclaw gateway Working directory for `$WORKDIR` expansion in profiles (defaults to current directory). ```bash -nono run --profile claude-code --workdir ./my-project -- claude +nono run --profile always-further/claude --workdir ./my-project -- claude ``` #### `--allow-cwd` @@ -820,10 +820,10 @@ Allow access to the current working directory without prompting. By default, non ```bash # Non-interactive mode (e.g., CI/CD) -nono run --profile claude-code --allow-cwd -- claude +nono run --profile always-further/claude --allow-cwd -- claude # Silent mode requires --allow-cwd (cannot prompt) -nono -s run --profile claude-code --allow-cwd -- claude +nono -s run --profile always-further/claude --allow-cwd -- claude ``` #### `--allow-launch-services` @@ -831,7 +831,7 @@ nono -s run --profile claude-code --allow-cwd -- claude Allow direct LaunchServices opens on macOS for this session. This is intended for temporary login or setup flows that need to open a browser from inside the sandbox. ```bash -nono run --profile claude-code --allow-launch-services -- claude +nono run --profile always-further/claude --allow-launch-services -- claude ``` @@ -861,7 +861,7 @@ Start the supervised session without attaching the current terminal. The command ```bash # Start in the background, then attach later -nono run --detached --profile claude-code --allow-cwd -- claude +nono run --detached --profile always-further/claude --allow-cwd -- claude # Detached launch with an explicit session name nono run --detached --name gold-core --allow-cwd -- my-agent @@ -904,7 +904,7 @@ Session names must be unique among live sessions. If you omit `--name`, nono gen Enable [atomic rollback snapshots](/cli/features/atomic-rollbacks) for the session. Takes content-addressable snapshots of writable directories so you can restore to the pre-session state after the command exits. Automatically selects supervised execution. ```bash -nono run --rollback --profile claude-code -- claude +nono run --rollback --profile always-further/claude -- claude nono run --rollback --allow-cwd -- my-agent ``` @@ -979,7 +979,7 @@ nono run --no-audit-integrity --allow-cwd -- my-agent nono run --audit-integrity --allow-cwd -- my-agent # Strongest mode: audit + filesystem integrity + restoreable rollback snapshots -nono run --audit-integrity --rollback --profile claude-code -- claude +nono run --audit-integrity --rollback --profile always-further/claude -- claude ``` @@ -1018,7 +1018,7 @@ nono run --audit-sign-key default --allow-cwd -- my-agent nono run --audit-sign-key file:///tmp/nono-audit-key.pem --allow-cwd -- my-agent # Sign with a 1Password-backed key -nono run --audit-sign-key op://Development/Nono/audit-key --profile claude-code -- claude +nono run --audit-sign-key op://Development/Nono/audit-key --profile always-further/claude -- claude ``` The corresponding attestation can be pinned during verification: @@ -1157,7 +1157,7 @@ Would execute: my-agent On Linux, combine `--dry-run` with `-v` to include detected Landlock ABI details and requested/enforced scope state: ```bash -nono run --dry-run -v --profile claude-code -- claude +nono run --dry-run -v --profile always-further/claude -- claude ``` #### `--verbose`, `-v` @@ -1252,8 +1252,8 @@ nono why --host example.com --port 8080 Landlock scope to check. Supported values are `signal` and `abstract-unix-socket`. ```bash -nono why --scope signal --profile claude-code -nono why --scope abstract-unix-socket --profile claude-code +nono why --scope signal --profile always-further/claude +nono why --scope abstract-unix-socket --profile always-further/claude ``` On Linux, scope queries report whether the effective capability set requested the scope, whether the detected Landlock ABI can support it, and whether nono will enforce it. On non-Linux platforms, scope queries return `not_applicable`. @@ -1285,7 +1285,7 @@ When checking paths outside a sandbox, you can simulate a capability context: nono why --path ./src --op write --allow . # Check against a profile -nono why --path ~/.config --op read --profile claude-code +nono why --path ~/.config --op read --profile always-further/claude ``` Available context flags: diff --git a/docs/cli/usage/troubleshooting.mdx b/docs/cli/usage/troubleshooting.mdx index 7ea65c2ba..b14bc4a79 100644 --- a/docs/cli/usage/troubleshooting.mdx +++ b/docs/cli/usage/troubleshooting.mdx @@ -342,7 +342,7 @@ nono run --profile my-profile -- my-app 1. Run the command to discover paths: ```bash - nono run --profile opencode -- opencode + nono run --profile always-further/opencode -- opencode ``` 2. Review the output to see which paths are needed: diff --git a/scripts/claude-clear-nono.sh b/scripts/claude-clear-nono.sh index 995203e19..16652a446 100755 --- a/scripts/claude-clear-nono.sh +++ b/scripts/claude-clear-nono.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # Clean up nono-managed Claude Code state for a fresh test of -# `nono run --profile claude-code -- claude`. Removes: +# `nono run --profile always-further/claude -- claude`. Removes: # - the pulled pack at ~/.config/nono/packages/always-further/claude # - any leftover legacy symlink at ~/.config/nono/profiles/claude-code.json # - the bare pre-marketplace symlink at ~/.claude/plugins/nono diff --git a/scripts/codex-clear-nono.sh b/scripts/codex-clear-nono.sh index 6cd98833c..94d43bded 100755 --- a/scripts/codex-clear-nono.sh +++ b/scripts/codex-clear-nono.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # Clean up nono-managed Codex state for a fresh test of -# `nono run --profile codex -- codex`. Removes: +# `nono run --profile always-further/codex -- codex`. Removes: # - the pulled pack at ~/.config/nono/packages/always-further/codex # - the cache subtree at ~/.codex/plugins/cache/always-further # - the nono-managed fenced block in ~/.codex/config.toml (between diff --git a/scripts/diagnose-macos-browser-open.sh b/scripts/diagnose-macos-browser-open.sh index 8ca6c1c40..5ed737bd0 100755 --- a/scripts/diagnose-macos-browser-open.sh +++ b/scripts/diagnose-macos-browser-open.sh @@ -91,20 +91,20 @@ run_step \ run_step \ "nono claude-profile absolute-open" \ - "$NONO_BIN" run --profile claude-code --allow-cwd -- /usr/bin/open -g "$URL" + "$NONO_BIN" run --profile always-further/claude --allow-cwd -- /usr/bin/open -g "$URL" run_step \ "nono claude-profile path-open" \ - "$NONO_BIN" run --profile claude-code --allow-cwd -- open -g "$URL" + "$NONO_BIN" run --profile always-further/claude --allow-cwd -- open -g "$URL" run_step \ "nono claude-profile absolute-open with lsopen" \ - "$NONO_BIN" run --profile claude-code --allow-cwd --allow-launch-services -- \ + "$NONO_BIN" run --profile always-further/claude --allow-cwd --allow-launch-services -- \ /usr/bin/open -g "$URL" run_step \ "nono claude-profile path-open with lsopen" \ - "$NONO_BIN" run --profile claude-code --allow-cwd --allow-launch-services -- \ + "$NONO_BIN" run --profile always-further/claude --allow-cwd --allow-launch-services -- \ open -g "$URL" log "Completed diagnostics. Full log: $LOG_FILE" diff --git a/scripts/probe-claude-open-path.sh b/scripts/probe-claude-open-path.sh index 59014d093..eaf3b0269 100755 --- a/scripts/probe-claude-open-path.sh +++ b/scripts/probe-claude-open-path.sh @@ -66,6 +66,6 @@ echo env \ PATH="$PROBE_DIR:$PATH" \ NONO_OPEN_WRAPPER_LOG="$WRAPPER_LOG" \ - "$NONO_BIN" run --profile claude-code --allow-cwd --allow-launch-services \ + "$NONO_BIN" run --profile always-further/claude --allow-cwd --allow-launch-services \ --read-file "$CLAUDE_BIN" -- \ "$CLAUDE_BIN" diff --git a/scripts/test-linux-container.sh b/scripts/test-linux-container.sh index b75b4af8d..e65b061db 100755 --- a/scripts/test-linux-container.sh +++ b/scripts/test-linux-container.sh @@ -16,7 +16,7 @@ usage() { Usage: ./scripts/test-linux-container.sh ./scripts/test-linux-container.sh cargo test -p nono-cli - ./scripts/test-linux-container.sh bash -c 'cargo run -q -p nono-cli -- run --profile claude-code --dry-run -- echo ok' + ./scripts/test-linux-container.sh bash -c 'cargo run -q -p nono-cli -- run --profile always-further/claude --dry-run -- echo ok' Behavior: - Builds a reusable Linux dev image with required system packages diff --git a/scripts/test-linux.sh b/scripts/test-linux.sh index 24d4a3189..4b036cb05 100755 --- a/scripts/test-linux.sh +++ b/scripts/test-linux.sh @@ -301,7 +301,7 @@ fi print_header "Claude Code Profile" # Test: Profile should load (dry-run to avoid modifying system) -PROFILE_OUTPUT=$(echo "n" | $NONO run --profile claude-code --allow-cwd --dry-run -- echo test 2>&1 || true) +PROFILE_OUTPUT=$(echo "n" | $NONO run --profile always-further/claude --allow-cwd --dry-run -- echo test 2>&1 || true) if echo "$PROFILE_OUTPUT" | grep -qiE "claude|profile|hook"; then pass "Claude Code profile loads" else From 5149587bcddb0ff40322a86d9563e63562835f00 Mon Sep 17 00:00:00 2001 From: Leonardo Zanivan Date: Sat, 13 Jun 2026 04:33:35 -0300 Subject: [PATCH 39/91] feat(environment): add set_vars for static env injection (#1134) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(environment): add set_vars for static env injection Add `environment.set_vars`, a string->string map that injects static environment variables into the sandboxed child. Values are injected after host-environment allow/deny filtering and before credential injection, so nono-injected credentials win on conflict and a set_vars value overrides any inherited value. Values support the same variable expansion as profile paths ($HOME, ~, $WORKDIR, $TMPDIR, $XDG_*, $NONO_PACKAGES); keys are never expanded. PATH and any NONO_* key are reserved and rejected at parse time. Unlike inherited host variables, set_vars keys are intentionally NOT subject to the dangerous-variable blocklist (LD_PRELOAD, NODE_OPTIONS, ...): setting one is an explicit, auditable operator decision in the profile rather than injection from an untrusted parent shell. The map merges across profile inheritance with child values winning on key conflict. Injection is wired through both the Direct and Supervised execution strategies. Includes JSON schema, feature docs, the embedded `nono profile guide`, and unit/parse/expansion tests. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Leonardo Zanivan * fix(environment): dedupe set_vars in supervised execve env In execute_supervised the environment is built as a raw Vec passed directly to execve, which (unlike Command::env in execute_direct) does not collapse duplicate keys. A set_vars key that also existed as an inherited host variable, or that was also present in env_vars (credentials), was pushed twice. Duplicate keys in an execve environment are platform-dependent and a potential dynamic-linker-bypass vector. Extract the set_vars merge into push_set_vars(), which skips keys overridden by env_vars and removes any prior entry with the same key before pushing — keeping the same precedence as execute_direct (env_vars > set_vars > host) with no duplicates. Includes unit tests covering override, dedup, env_vars precedence, and prefix-collision safety (PA must not match PATH). Addresses review feedback on #1134. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Leonardo Zanivan --------- Signed-off-by: Leonardo Zanivan Co-authored-by: Claude Opus 4.8 (1M context) --- Cargo.lock | 380 ++++++++++++++++-- crates/nono-cli/data/nono-profile.schema.json | 5 + .../nono-cli/data/profile-authoring-guide.md | 12 +- crates/nono-cli/src/command_runtime.rs | 2 + crates/nono-cli/src/exec_strategy.rs | 121 ++++++ .../src/exec_strategy/env_sanitization.rs | 111 +++++ crates/nono-cli/src/execution_runtime.rs | 1 + crates/nono-cli/src/launch_runtime.rs | 4 + crates/nono-cli/src/main.rs | 2 + crates/nono-cli/src/profile/mod.rs | 65 +++ crates/nono-cli/src/profile_runtime.rs | 79 ++++ crates/nono-cli/src/sandbox_prepare.rs | 5 + docs/cli/features/environment.mdx | 28 ++ docs/cli/features/profile-authoring.mdx | 4 +- 14 files changed, 779 insertions(+), 40 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0342fc897..b3da99920 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -107,7 +107,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -118,7 +118,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -445,6 +445,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + [[package]] name = "blocking" version = "1.6.2" @@ -572,7 +581,7 @@ dependencies = [ "num-traits", "serde", "wasm-bindgen", - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -679,7 +688,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -970,7 +979,17 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.59.0", + "windows-sys 0.61.2", +] + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags", + "objc2", ] [[package]] @@ -1057,7 +1076,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -1549,7 +1568,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core", + "windows-core 0.62.2", ] [[package]] @@ -1767,7 +1786,7 @@ dependencies = [ "portable-atomic", "portable-atomic-util", "serde_core", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -1826,7 +1845,7 @@ dependencies = [ "simd_cesu8", "thiserror 2.0.18", "walkdir", - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -1941,7 +1960,7 @@ dependencies = [ "secret-service", "security-framework 2.11.1", "security-framework 3.7.0", - "zbus", + "zbus 4.4.0", "zeroize", ] @@ -2025,6 +2044,20 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +[[package]] +name = "mac-notification-sys" +version = "0.6.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd604973958ddcc11b561193c0fb96ba146506ef2f231ef2e7c35fd2cbc9beca" +dependencies = [ + "cc", + "log", + "objc2", + "objc2-foundation", + "time", + "uuid", +] + [[package]] name = "matchers" version = "0.2.0" @@ -2158,6 +2191,7 @@ dependencies = [ "colored", "dirs", "dns-lookup", + "futures", "globset", "ignore", "jsonc-parser", @@ -2167,6 +2201,7 @@ dependencies = [ "nix 0.31.3", "nono", "nono-proxy", + "notify-rust", "proptest", "rand 0.10.1", "security-framework 3.7.0", @@ -2235,13 +2270,27 @@ dependencies = [ "zeroize", ] +[[package]] +name = "notify-rust" +version = "4.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5b4c1b4f2aa9f25f63a7a49d3dd0ed567b3670da15330a66b29434be899b891" +dependencies = [ + "futures-lite", + "log", + "mac-notification-sys", + "serde", + "tauri-winrt-notification", + "zbus 5.16.0", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2329,6 +2378,45 @@ dependencies = [ "autocfg", ] +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags", + "block2", + "libc", + "objc2", + "objc2-core-foundation", +] + [[package]] name = "oid-registry" version = "0.8.1" @@ -2426,7 +2514,7 @@ dependencies = [ "libc", "redox_syscall", "smallvec", - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -3011,7 +3099,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -3070,7 +3158,7 @@ dependencies = [ "security-framework 3.7.0", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -3091,7 +3179,7 @@ dependencies = [ "security-framework 3.7.0", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -3215,7 +3303,7 @@ dependencies = [ "rand 0.8.6", "serde", "sha2 0.10.9", - "zbus", + "zbus 4.4.0", ] [[package]] @@ -3765,7 +3853,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -3833,6 +3921,17 @@ dependencies = [ "syn", ] +[[package]] +name = "tauri-winrt-notification" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed071c670382e85fc2f48ae706492d8c338f4f89bf72520d32f8abfe880aade" +dependencies = [ + "thiserror 2.0.18", + "windows", + "windows-version", +] + [[package]] name = "tempfile" version = "3.27.0" @@ -3840,10 +3939,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.2", "once_cell", "rustix", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -4317,7 +4416,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -4443,6 +4542,18 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "uuid" +version = "1.23.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "serde_core", + "wasm-bindgen", +] + [[package]] name = "uuid-simd" version = "0.8.0" @@ -4699,7 +4810,42 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", +] + +[[package]] +name = "windows" +version = "0.61.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +dependencies = [ + "windows-collections", + "windows-core 0.61.2", + "windows-future", + "windows-link 0.1.3", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +dependencies = [ + "windows-core 0.61.2", +] + +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", ] [[package]] @@ -4710,9 +4856,20 @@ checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ "windows-implement", "windows-interface", - "windows-link", - "windows-result", - "windows-strings", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-future" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", + "windows-threading", ] [[package]] @@ -4737,19 +4894,53 @@ dependencies = [ "syn", ] +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-numerics" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", +] + [[package]] name = "windows-result" version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" dependencies = [ - "windows-link", + "windows-link 0.2.1", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", ] [[package]] @@ -4758,7 +4949,7 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" dependencies = [ - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -4803,7 +4994,7 @@ version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -4843,7 +5034,7 @@ version = "0.53.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" dependencies = [ - "windows-link", + "windows-link 0.2.1", "windows_aarch64_gnullvm 0.53.1", "windows_aarch64_msvc 0.53.1", "windows_i686_gnu 0.53.1", @@ -4854,6 +5045,24 @@ dependencies = [ "windows_x86_64_msvc 0.53.1", ] +[[package]] +name = "windows-threading" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-version" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4060a1da109b9d0326b7262c8e12c84df67cc0dbc9e33cf49e01ccc2eb63631" +dependencies = [ + "windows-link 0.2.1", +] + [[package]] name = "windows_aarch64_gnullvm" version = "0.42.2" @@ -5226,9 +5435,44 @@ dependencies = [ "uds_windows", "windows-sys 0.52.0", "xdg-home", - "zbus_macros", - "zbus_names", - "zvariant", + "zbus_macros 4.4.0", + "zbus_names 3.0.0", + "zvariant 4.2.0", +] + +[[package]] +name = "zbus" +version = "5.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eee682d202a77e4a9f3b2c2bdf48a7b28af5c08c34ddf66f98c93e5e39464285" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow 1.0.2", + "zbus_macros 5.16.0", + "zbus_names 4.3.2", + "zvariant 5.12.0", ] [[package]] @@ -5241,7 +5485,22 @@ dependencies = [ "proc-macro2", "quote", "syn", - "zvariant_utils", + "zvariant_utils 2.1.0", +] + +[[package]] +name = "zbus_macros" +version = "5.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adf1bd45a81a103745b1757754762a26e8cd01e4532e4d6c8ec431624b80d1d6" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", + "zbus_names 4.3.2", + "zvariant 5.12.0", + "zvariant_utils 3.4.0", ] [[package]] @@ -5252,7 +5511,18 @@ checksum = "4b9b1fef7d021261cc16cba64c351d291b715febe0fa10dc3a443ac5a5022e6c" dependencies = [ "serde", "static_assertions", - "zvariant", + "zvariant 4.2.0", +] + +[[package]] +name = "zbus_names" +version = "4.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7074f3e50b894eac91750142016d30d0a89be8e67dbfd9704fb875825760e52d" +dependencies = [ + "serde", + "winnow 1.0.2", + "zvariant 5.12.0", ] [[package]] @@ -5366,7 +5636,21 @@ dependencies = [ "enumflags2", "serde", "static_assertions", - "zvariant_derive", + "zvariant_derive 4.2.0", +] + +[[package]] +name = "zvariant" +version = "5.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a192a0bde63360d77a7523c833d4b4ce6070a927e2c53246e4c540b1a3e27be0" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow 1.0.2", + "zvariant_derive 5.12.0", + "zvariant_utils 3.4.0", ] [[package]] @@ -5379,7 +5663,20 @@ dependencies = [ "proc-macro2", "quote", "syn", - "zvariant_utils", + "zvariant_utils 2.1.0", +] + +[[package]] +name = "zvariant_derive" +version = "5.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90bc6cde9c01c511074be97f7ccb6c19d0da89e3f8662e812e999dcfd4638737" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", + "zvariant_utils 3.4.0", ] [[package]] @@ -5392,3 +5689,16 @@ dependencies = [ "quote", "syn", ] + +[[package]] +name = "zvariant_utils" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e8535915cfa75547e559d8c68e8139909a4aeee076831e4ef7fc59d8172c4d6" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn", + "winnow 1.0.2", +] diff --git a/crates/nono-cli/data/nono-profile.schema.json b/crates/nono-cli/data/nono-profile.schema.json index 8ced1cd27..a74e6d9d6 100644 --- a/crates/nono-cli/data/nono-profile.schema.json +++ b/crates/nono-cli/data/nono-profile.schema.json @@ -738,6 +738,11 @@ "type": "array", "items": { "type": "string", "pattern": "^([^*]+\\*?|\\*)$" }, "description": "Deny-list of environment variable names stripped from the sandboxed process. Supports exact names (\"GH_TOKEN\") and prefix patterns ending with * (\"GITHUB_*\"). Denied vars are stripped even if they also appear in allow_vars." + }, + "set_vars": { + "type": "object", + "additionalProperties": { "type": "string" }, + "description": "Static environment variables injected after environment filtering and before credential injection. PATH and NONO_* are reserved. Values support variable expansion ($HOME, $WORKDIR, $TMPDIR, $XDG_*, $NONO_PACKAGES)." } } }, diff --git a/crates/nono-cli/data/profile-authoring-guide.md b/crates/nono-cli/data/profile-authoring-guide.md index 1d90e0d56..9e57f617e 100644 --- a/crates/nono-cli/data/profile-authoring-guide.md +++ b/crates/nono-cli/data/profile-authoring-guide.md @@ -229,12 +229,14 @@ Supported key formats: ### environment -Controls which environment variables are passed to the sandboxed process. When `allow_vars` is set, only the listed variables (and nono-injected credentials) are passed through. +Controls which environment variables are passed to the sandboxed process. When `allow_vars` is set, only the listed variables (and nono-injected credentials) are passed through. `set_vars` injects explicit values regardless of filtering. ```json { "environment": { - "allow_vars": ["PATH", "HOME", "TERM", "AWS_*"] + "allow_vars": ["PATH", "HOME", "TERM", "AWS_*"], + "deny_vars": ["GH_TOKEN"], + "set_vars": { "RUST_LOG": "debug", "XDG_CONFIG_HOME": "$HOME/.config" } } } ``` @@ -242,8 +244,10 @@ Controls which environment variables are passed to the sandboxed process. When ` | Field | Type | Default | Description | |---------------|-----------------|---------|-------------| | `allow_vars` | array of string | `[]` | Allow-list of environment variable names. Supports exact names (`"PATH"`) and prefix patterns ending with `*` (`"AWS_*"` matches `AWS_REGION`, `AWS_SECRET_ACCESS_KEY`, etc.). The `*` wildcard is only valid as a trailing suffix. When the `environment` section is omitted entirely, all variables are allowed. When present with an empty array, no inherited variables are passed (only nono-injected credentials). Nono-injected credentials always bypass this list. | +| `deny_vars` | array of string | `[]` | Deny-list of environment variable names stripped from the child. Same pattern syntax as `allow_vars` (exact names and trailing `*`). Denied vars are stripped even if they also match `allow_vars`. | +| `set_vars` | object (string→string) | `{}` | Static environment variables injected after allow/deny filtering and before credential injection (injected credentials win on conflict). Values support the same expansion as profile paths (`$HOME`, `~`, `$WORKDIR`, `$TMPDIR`, `$XDG_*`, `$NONO_PACKAGES`); keys are not expanded. `PATH` and any `NONO_*` key are reserved and rejected at load time. Unlike inherited host vars, keys here are NOT subject to the dangerous-variable blocklist (`LD_PRELOAD`, `NODE_OPTIONS`, …) — setting one is an explicit operator decision. | -Inheritance: child `allow_vars` are appended to base values and deduplicated. +Inheritance: child `allow_vars` and `deny_vars` are appended to base values and deduplicated; `set_vars` merges as a map, with the child's value winning on key conflict. ### hooks @@ -545,7 +549,7 @@ nono profile diff # Compare two profiles ## 6. Variable Expansion -The following variables are expanded in all path fields (`filesystem.*`, including `filesystem.allow`, `filesystem.read`, `filesystem.write`, `filesystem.deny`, `filesystem.bypass_protection`, and `filesystem.suppress_save_prompt`). +The following variables are expanded in all path fields (`filesystem.*`, including `filesystem.allow`, `filesystem.read`, `filesystem.write`, `filesystem.deny`, `filesystem.bypass_protection`, and `filesystem.suppress_save_prompt`), in `command_args`, and in the values of `environment.set_vars`. | Variable | Expands to | |--------------------|------------| diff --git a/crates/nono-cli/src/command_runtime.rs b/crates/nono-cli/src/command_runtime.rs index ed512a083..1049da687 100644 --- a/crates/nono-cli/src/command_runtime.rs +++ b/crates/nono-cli/src/command_runtime.rs @@ -213,6 +213,7 @@ pub(crate) fn run_shell(args: ShellArgs, silent: bool) -> Result<()> { suppressed_system_service_operations: prepared.suppressed_system_service_operations, allowed_env_vars: prepared.allowed_env_vars, denied_env_vars: prepared.denied_env_vars, + set_vars: prepared.set_vars, startup_timeout_secs: args.startup_timeout_secs, proxy, redaction_policy: load_configured_redaction_policy()?, @@ -297,6 +298,7 @@ pub(crate) fn run_wrap(wrap_args: WrapArgs, silent: bool) -> Result<()> { suppressed_system_service_operations: prepared.suppressed_system_service_operations, allowed_env_vars: prepared.allowed_env_vars, denied_env_vars: prepared.denied_env_vars, + set_vars: prepared.set_vars, ..ExecutionFlags::defaults(silent)? }, }) diff --git a/crates/nono-cli/src/exec_strategy.rs b/crates/nono-cli/src/exec_strategy.rs index bb67c1a60..ee871e341 100644 --- a/crates/nono-cli/src/exec_strategy.rs +++ b/crates/nono-cli/src/exec_strategy.rs @@ -41,6 +41,7 @@ use tracing::{debug, info, warn}; pub(crate) use env_sanitization::is_dangerous_env_var; use env_sanitization::should_skip_env_var; pub(crate) use env_sanitization::validate_env_var_patterns; +pub(crate) use env_sanitization::validate_set_vars; /// Resolve a program name to its absolute path. /// @@ -244,6 +245,10 @@ pub struct ExecConfig<'a> { /// name or prefix pattern (e.g. `"GITHUB_*"`) are stripped even if they /// also appear in `allowed_env_vars`. Nono-injected credentials bypass this. pub denied_env_vars: Option>, + /// Static environment variables (`environment.set_vars`) injected after host + /// env filtering and before `env_vars` (credentials/proxy/hooks). Values are + /// already variable-expanded. Bypasses allow/deny filtering by design. + pub set_vars: Vec<(String, String)>, } #[derive(Clone, Copy)] @@ -366,6 +371,12 @@ pub fn execute_direct(config: &ExecConfig<'_>) -> Result<()> { cmd.args(cmd_args).env("NONO_CAP_FILE", config.cap_file); + // Static profile vars (set_vars): after host filtering, before credentials + // so injected credentials win on conflict. + for (key, value) in &config.set_vars { + cmd.env(key, value); + } + for (key, value) in &config.env_vars { cmd.env(key, value); } @@ -410,6 +421,43 @@ pub fn execute_direct(config: &ExecConfig<'_>) -> Result<()> { /// parent can capture terminal output for diagnostics while the child still sees /// a TTY. Otherwise the child inherits the parent's terminal directly. /// The parent prints diagnostics and rollback UI after the child exits. +/// +/// Append `set_vars` to a raw `execve` environment vector, deduplicating by key. +/// +/// `env_c` is a raw `KEY=VALUE` vector passed straight to `execve` — unlike +/// [`std::process::Command::env`] it does not collapse duplicate keys, so we +/// must dedupe by hand. Passing duplicate keys to `execve` is +/// platform-dependent and a potential dynamic-linker-bypass vector. +/// +/// Precedence matches `execute_direct`: credentials (`env_vars`) win over +/// `set_vars`, which win over inherited host vars. A `set_vars` key already +/// destined to be set by `env_vars` is skipped; any earlier entry (e.g. an +/// inherited host var) with the same key is removed before the new value is +/// pushed. +fn push_set_vars( + env_c: &mut Vec, + set_vars: &[(String, String)], + env_vars: &[(&str, &str)], +) { + for (key, value) in set_vars { + if env_vars.iter().any(|(ek, _)| ek == key) { + continue; + } + let mut prefix = Vec::with_capacity(key.len() + 1); + prefix.extend_from_slice(key.as_bytes()); + prefix.push(b'='); + env_c.retain(|cstr| !cstr.as_bytes().starts_with(&prefix)); + + let mut kv = Vec::with_capacity(key.len() + 1 + value.len() + 1); + kv.extend_from_slice(key.as_bytes()); + kv.push(b'='); + kv.extend_from_slice(value.as_bytes()); + if let Ok(cstr) = CString::new(kv) { + env_c.push(cstr); + } + } +} + pub fn execute_supervised( config: &ExecConfig<'_>, supervisor: Option<&SupervisorConfig<'_>>, @@ -509,6 +557,10 @@ pub fn execute_supervised( env_c.push(cstr); } + // Static profile vars (set_vars): after host filtering, before credentials + // so injected credentials win on conflict. + push_set_vars(&mut env_c, &config.set_vars, &config.env_vars); + // Add user-specified environment variables (secrets, etc.) for (key, value) in &config.env_vars { let mut kv = Vec::with_capacity(key.len() + 1 + value.len() + 1); @@ -3561,6 +3613,75 @@ mod tests { ControlFlags, InputFlags, LocalFlags, OutputFlags, SpecialCharacterIndices, }; + fn env_strings(env_c: &[CString]) -> Vec { + env_c + .iter() + .map(|c| c.to_string_lossy().into_owned()) + .collect() + } + + #[test] + fn push_set_vars_appends_new_keys() { + let mut env_c = vec![CString::new("HOME=/home/x").expect("cstring")]; + let set_vars = vec![("RUST_LOG".to_string(), "debug".to_string())]; + push_set_vars(&mut env_c, &set_vars, &[]); + assert_eq!(env_strings(&env_c), vec!["HOME=/home/x", "RUST_LOG=debug"]); + } + + #[test] + fn push_set_vars_overrides_inherited_host_var_without_duplicating() { + // An inherited host var with the same key must be replaced, not duplicated. + let mut env_c = vec![ + CString::new("PRE=1").expect("cstring"), + CString::new("RUST_LOG=info").expect("cstring"), + CString::new("POST=2").expect("cstring"), + ]; + let set_vars = vec![("RUST_LOG".to_string(), "debug".to_string())]; + push_set_vars(&mut env_c, &set_vars, &[]); + // Exactly one RUST_LOG entry, carrying the set_vars value. + let entries = env_strings(&env_c); + assert_eq!( + entries + .iter() + .filter(|e| e.starts_with("RUST_LOG=")) + .count(), + 1 + ); + assert!(entries.contains(&"RUST_LOG=debug".to_string())); + assert!(entries.contains(&"PRE=1".to_string())); + assert!(entries.contains(&"POST=2".to_string())); + } + + #[test] + fn push_set_vars_skips_keys_overridden_by_env_vars() { + // A key also set via env_vars (credentials) is skipped entirely, so + // env_vars wins and there is no duplicate. The inherited host entry is + // left for env_vars (appended later) to override. + let mut env_c = vec![CString::new("TOKEN=host").expect("cstring")]; + let set_vars = vec![("TOKEN".to_string(), "from_set_vars".to_string())]; + let env_vars = vec![("TOKEN", "from_credentials")]; + push_set_vars(&mut env_c, &set_vars, &env_vars); + // set_vars did not push its value... + let entries = env_strings(&env_c); + assert!(!entries.contains(&"TOKEN=from_set_vars".to_string())); + // ...and did not duplicate the key. + assert_eq!( + entries.iter().filter(|e| e.starts_with("TOKEN=")).count(), + 1 + ); + } + + #[test] + fn push_set_vars_does_not_substring_match_other_keys() { + // PATH must not be removed when set_vars sets PA (prefix-collision guard). + let mut env_c = vec![CString::new("PATH=/usr/bin").expect("cstring")]; + let set_vars = vec![("PA".to_string(), "x".to_string())]; + push_set_vars(&mut env_c, &set_vars, &[]); + let entries = env_strings(&env_c); + assert!(entries.contains(&"PATH=/usr/bin".to_string())); + assert!(entries.contains(&"PA=x".to_string())); + } + #[cfg(target_os = "linux")] #[test] fn test_linux_child_requires_dumpable_only_for_seccomp_driven_features() { diff --git a/crates/nono-cli/src/exec_strategy/env_sanitization.rs b/crates/nono-cli/src/exec_strategy/env_sanitization.rs index 730cb07c1..4806eacfa 100644 --- a/crates/nono-cli/src/exec_strategy/env_sanitization.rs +++ b/crates/nono-cli/src/exec_strategy/env_sanitization.rs @@ -112,6 +112,57 @@ pub(crate) fn validate_env_var_patterns(patterns: &[String], field_name: &str) - None } +/// Returns true if `name` is a valid POSIX environment variable name: +/// a non-empty `[A-Za-z_][A-Za-z0-9_]*` with no `=` or NUL. +fn is_valid_env_var_name(name: &str) -> bool { + let mut chars = name.chars(); + match chars.next() { + Some(c) if c.is_ascii_alphabetic() || c == '_' => {} + _ => return false, + } + chars.all(|c| c.is_ascii_alphanumeric() || c == '_') +} + +/// Validates `environment.set_vars` keys before they are injected into the +/// sandboxed child. Returns an error message describing the first invalid key, +/// or `None` if all keys are acceptable. +/// +/// Rejected keys: +/// - `PATH` (reserved; controlled via allow/deny filtering, not injection) +/// - any `NONO_*` key (reserved for nono's own injected variables) +/// - empty or syntactically invalid names (must match `[A-Za-z_][A-Za-z0-9_]*`) +/// +/// The dangerous-variable blocklist (`LD_PRELOAD`, `NODE_OPTIONS`, …) is +/// intentionally NOT applied here: that defense targets injection from an +/// untrusted parent shell, whereas `set_vars` is explicit operator intent. +pub(crate) fn validate_set_vars( + set_vars: &std::collections::HashMap, +) -> Option { + for key in set_vars.keys() { + if key == "PATH" { + return Some( + "Invalid set_vars key 'PATH': PATH is reserved; use allow_vars/deny_vars to \ + control it" + .to_string(), + ); + } + if key.starts_with("NONO_") { + return Some(format!( + "Invalid set_vars key '{}': the NONO_* prefix is reserved", + key + )); + } + if !is_valid_env_var_name(key) { + return Some(format!( + "Invalid set_vars key '{}': environment variable names must match \ + [A-Za-z_][A-Za-z0-9_]*", + key + )); + } + } + None +} + /// Decide whether an inherited env var should be dropped for sandbox execution. pub(super) fn should_skip_env_var( key: &str, @@ -343,4 +394,64 @@ mod tests { assert!(is_env_var_allowed("GH_TOKEN", &allowed)); // In exec path, deny is checked before allow, so GH_TOKEN is stripped } + + // ============================================================================ + // set_vars validation + // ============================================================================ + + fn set_vars_from(pairs: &[(&str, &str)]) -> std::collections::HashMap { + pairs + .iter() + .map(|(k, v)| ((*k).to_string(), (*v).to_string())) + .collect() + } + + #[test] + fn test_set_vars_accepts_normal_keys() { + let set_vars = set_vars_from(&[("RUST_LOG", "debug"), ("MY_VAR", "value")]); + assert_eq!(validate_set_vars(&set_vars), None); + } + + #[test] + fn test_set_vars_accepts_dangerous_keys() { + // set_vars is explicit operator intent: dangerous keys are NOT blocked. + let set_vars = set_vars_from(&[("LD_PRELOAD", "/tmp/x.so")]); + assert_eq!(validate_set_vars(&set_vars), None); + let set_vars = set_vars_from(&[("NODE_OPTIONS", "--max-old-space-size=4096")]); + assert_eq!(validate_set_vars(&set_vars), None); + let set_vars = set_vars_from(&[("DYLD_INSERT_LIBRARIES", "/tmp/x.dylib")]); + assert_eq!(validate_set_vars(&set_vars), None); + } + + #[test] + fn test_set_vars_rejects_path() { + let set_vars = set_vars_from(&[("PATH", "/usr/bin")]); + assert!(validate_set_vars(&set_vars).is_some()); + } + + #[test] + fn test_set_vars_rejects_nono_prefix() { + let set_vars = set_vars_from(&[("NONO_FOO", "bar")]); + assert!(validate_set_vars(&set_vars).is_some()); + let set_vars = set_vars_from(&[("NONO_CAP_FILE", "/tmp/cap")]); + assert!(validate_set_vars(&set_vars).is_some()); + } + + #[test] + fn test_set_vars_rejects_invalid_names() { + // empty name + assert!(validate_set_vars(&set_vars_from(&[("", "v")])).is_some()); + // leading digit + assert!(validate_set_vars(&set_vars_from(&[("1FOO", "v")])).is_some()); + // contains '=' + assert!(validate_set_vars(&set_vars_from(&[("A=B", "v")])).is_some()); + // contains a dash + assert!(validate_set_vars(&set_vars_from(&[("MY-VAR", "v")])).is_some()); + } + + #[test] + fn test_set_vars_empty_is_ok() { + let set_vars: std::collections::HashMap = std::collections::HashMap::new(); + assert_eq!(validate_set_vars(&set_vars), None); + } } diff --git a/crates/nono-cli/src/execution_runtime.rs b/crates/nono-cli/src/execution_runtime.rs index 3916e1297..fb5d1e3c2 100644 --- a/crates/nono-cli/src/execution_runtime.rs +++ b/crates/nono-cli/src/execution_runtime.rs @@ -400,6 +400,7 @@ pub(crate) fn execute_sandboxed(plan: LaunchPlan) -> Result<()> { af_unix_mediation: flags.af_unix_mediation, allowed_env_vars: flags.allowed_env_vars, denied_env_vars: flags.denied_env_vars, + set_vars: flags.set_vars.unwrap_or_default(), }; match strategy { diff --git a/crates/nono-cli/src/launch_runtime.rs b/crates/nono-cli/src/launch_runtime.rs index 9cfa35c2d..f7a19d1c3 100644 --- a/crates/nono-cli/src/launch_runtime.rs +++ b/crates/nono-cli/src/launch_runtime.rs @@ -118,6 +118,8 @@ pub(crate) struct ExecutionFlags { pub(crate) session_hooks: profile::SessionHooks, pub(crate) allowed_env_vars: Option>, pub(crate) denied_env_vars: Option>, + /// Expanded `environment.set_vars` (key, expanded-value), `None` if absent. + pub(crate) set_vars: Option>, pub(crate) startup_timeout_secs: Option, } @@ -149,6 +151,7 @@ impl ExecutionFlags { session_hooks: profile::SessionHooks::default(), allowed_env_vars: None, denied_env_vars: None, + set_vars: None, startup_timeout_secs: None, }) } @@ -286,6 +289,7 @@ pub(crate) fn prepare_run_launch_plan( session_hooks: prepared.session_hooks, allowed_env_vars: prepared.allowed_env_vars, denied_env_vars: prepared.denied_env_vars, + set_vars: prepared.set_vars, startup_timeout_secs, }, }) diff --git a/crates/nono-cli/src/main.rs b/crates/nono-cli/src/main.rs index ae6793927..7117236e1 100644 --- a/crates/nono-cli/src/main.rs +++ b/crates/nono-cli/src/main.rs @@ -292,6 +292,7 @@ mod tests { denied_env_vars: None, profile_network_approval_mode: None, profile_network_approval_timeout_secs: None, + set_vars: None, network_block_requested: false, }; @@ -348,6 +349,7 @@ mod tests { denied_env_vars: None, profile_network_approval_mode: None, profile_network_approval_timeout_secs: None, + set_vars: None, network_block_requested: false, }; diff --git a/crates/nono-cli/src/profile/mod.rs b/crates/nono-cli/src/profile/mod.rs index 27fba960b..d35cb5736 100644 --- a/crates/nono-cli/src/profile/mod.rs +++ b/crates/nono-cli/src/profile/mod.rs @@ -1488,6 +1488,20 @@ pub struct EnvironmentConfig { /// Use this to strip specific secrets while keeping everything else inherited. #[serde(default)] pub deny_vars: Vec, + + /// Static environment variables injected into the sandboxed process. + /// + /// Maps variable names to values, set after allow/deny filtering and before + /// credential injection (so injected credentials win on conflict). Values + /// support the same expansion as profile paths (`$HOME`, `~`, `$WORKDIR`, + /// `$TMPDIR`, `$XDG_*`, `$NONO_PACKAGES`). + /// + /// `PATH` and any `NONO_*` key are reserved and rejected at parse time. + /// Unlike inherited host variables, keys here are NOT subject to the + /// dangerous-variable blocklist: setting one explicitly is a deliberate, + /// auditable operator decision. + #[serde(default)] + pub set_vars: std::collections::HashMap, } /// Configuration for supervisor-delegated URL opening. @@ -2335,6 +2349,14 @@ pub(crate) fn parse_profile_bytes(content: &[u8]) -> Result { // Validate env_credentials keys (URI entries need structural validation) validate_env_credential_keys(&profile)?; + // Validate environment.set_vars keys (reserved/invalid keys are fatal) + if let Some(env_config) = profile.environment.as_ref() + && !env_config.set_vars.is_empty() + && let Some(err) = crate::exec_strategy::validate_set_vars(&env_config.set_vars) + { + return Err(NonoError::ProfileParse(err)); + } + Ok(profile) } @@ -2704,6 +2726,11 @@ fn merge_profiles(base: Profile, child: Profile) -> Profile { (Some(base_env), Some(child_env)) => Some(EnvironmentConfig { allow_vars: dedup_append(&base_env.allow_vars, &child_env.allow_vars), deny_vars: dedup_append(&base_env.deny_vars, &child_env.deny_vars), + set_vars: { + let mut merged = base_env.set_vars.clone(); + merged.extend(child_env.set_vars.clone()); + merged + }, }), }, // NOTE: WorkdirAccess::None serves as both "not specified" and "explicitly no access". @@ -3543,6 +3570,7 @@ mod tests { environment: Some(EnvironmentConfig { allow_vars: vec![], deny_vars: vec!["GH_TOKEN".into()], + set_vars: Default::default(), }), ..Default::default() }; @@ -3550,6 +3578,7 @@ mod tests { environment: Some(EnvironmentConfig { allow_vars: vec![], deny_vars: vec!["ANTHROPIC_API_KEY".into()], + set_vars: Default::default(), }), ..Default::default() }; @@ -3566,6 +3595,7 @@ mod tests { environment: Some(EnvironmentConfig { allow_vars: vec![], deny_vars: vec!["GH_TOKEN".into(), "ANTHROPIC_API_KEY".into()], + set_vars: Default::default(), }), ..Default::default() }; @@ -3573,6 +3603,7 @@ mod tests { environment: Some(EnvironmentConfig { allow_vars: vec![], deny_vars: vec!["ANTHROPIC_API_KEY".into()], + set_vars: Default::default(), }), ..Default::default() }; @@ -6966,6 +6997,40 @@ mod tests { ); } + #[test] + fn set_vars_parses_valid_keys() { + let json = br#"{ + "meta": { "name": "set-vars-valid" }, + "environment": { + "set_vars": { "RUST_LOG": "debug", "FOO": "$HOME/.config" } + } + }"#; + let profile = parse_profile_bytes(json).expect("valid set_vars should parse"); + let env = profile.environment.expect("environment present"); + assert_eq!(env.set_vars.get("RUST_LOG"), Some(&"debug".to_string())); + assert_eq!(env.set_vars.get("FOO"), Some(&"$HOME/.config".to_string())); + } + + #[test] + fn set_vars_rejects_reserved_path_key() { + let json = br#"{ + "meta": { "name": "set-vars-path" }, + "environment": { "set_vars": { "PATH": "/usr/bin" } } + }"#; + let err = parse_profile_bytes(json).expect_err("PATH in set_vars must be rejected"); + assert!(err.to_string().contains("PATH"), "unexpected error: {err}"); + } + + #[test] + fn set_vars_rejects_reserved_nono_prefix() { + let json = br#"{ + "meta": { "name": "set-vars-nono" }, + "environment": { "set_vars": { "NONO_FOO": "bar" } } + }"#; + let err = parse_profile_bytes(json).expect_err("NONO_* in set_vars must be rejected"); + assert!(err.to_string().contains("NONO_"), "unexpected error: {err}"); + } + #[test] fn jsonc_comments_and_trailing_commas() { let jsonc = br#"{ diff --git a/crates/nono-cli/src/profile_runtime.rs b/crates/nono-cli/src/profile_runtime.rs index 9d868be24..8eae2c66b 100644 --- a/crates/nono-cli/src/profile_runtime.rs +++ b/crates/nono-cli/src/profile_runtime.rs @@ -34,6 +34,10 @@ pub(crate) struct PreparedProfile { pub(crate) denied_env_vars: Option>, pub(crate) network_approval_mode: Option, pub(crate) network_approval_timeout_secs: Option, + /// Expanded `environment.set_vars` entries (key, expanded-value). `None` + /// when the profile has no `set_vars`. Values are expanded with + /// [`profile::expand_vars`] at prepare time. + pub(crate) set_vars: Option>, } #[derive(Clone, Copy)] @@ -391,6 +395,39 @@ fn expand_ignored_denial_path(path: &Path, workdir: &Path) -> PathBuf { nono::try_canonicalize(&expanded) } +/// Expand the values of `environment.set_vars` using the same variable +/// substitution as profile paths (`$HOME`, `~`, `$WORKDIR`, `$TMPDIR`, +/// `$XDG_*`, `$NONO_PACKAGES`). Keys are preserved verbatim. Returns `None` +/// when the profile has no `set_vars`. Expansion errors are fatal so a +/// misconfigured value never silently reaches the child. +fn expand_profile_set_vars( + loaded_profile: Option<&profile::Profile>, + workdir: &Path, +) -> crate::Result>> { + let Some(env_config) = loaded_profile.and_then(|profile| profile.environment.as_ref()) else { + return Ok(None); + }; + if env_config.set_vars.is_empty() { + return Ok(None); + } + + // Sort keys for deterministic ordering (HashMap iteration order is random). + let mut keys: Vec<&String> = env_config.set_vars.keys().collect(); + keys.sort(); + + let mut expanded = Vec::with_capacity(keys.len()); + for key in keys { + let Some(value) = env_config.set_vars.get(key) else { + continue; + }; + let expanded_value = profile::expand_vars(value, workdir)? + .to_string_lossy() + .into_owned(); + expanded.push((key.clone(), expanded_value)); + } + Ok(Some(expanded)) +} + fn collect_ignored_denial_paths( loaded_profile: Option<&profile::Profile>, cli_ignored_denials: &[PathBuf], @@ -628,6 +665,7 @@ fn prepare_profile_with_options( network_approval_timeout_secs: loaded_profile .as_ref() .and_then(|profile| profile.network.approval_timeout_secs), + set_vars: expand_profile_set_vars(loaded_profile.as_ref(), workdir)?, loaded_profile, }) } @@ -691,6 +729,47 @@ mod tests { f(&config_dir) } + #[test] + fn expand_profile_set_vars_expands_home() { + let _guard = match crate::test_env::ENV_LOCK.lock() { + Ok(guard) => guard, + Err(poisoned) => poisoned.into_inner(), + }; + let _env = crate::test_env::EnvVarGuard::set_all(&[("HOME", "/home/tester")]); + + let mut profile = profile::Profile::default(); + let mut set_vars = std::collections::HashMap::new(); + set_vars.insert("RUST_LOG".to_string(), "debug".to_string()); + set_vars.insert("CFG".to_string(), "$HOME/.config".to_string()); + profile.environment = Some(profile::EnvironmentConfig { + allow_vars: vec![], + deny_vars: vec![], + set_vars, + }); + + let workdir = Path::new("/tmp/work"); + let expanded = expand_profile_set_vars(Some(&profile), workdir) + .expect("expansion should succeed") + .expect("set_vars should be present"); + + // Keys are sorted for determinism: CFG before RUST_LOG. + assert_eq!( + expanded, + vec![ + ("CFG".to_string(), "/home/tester/.config".to_string()), + ("RUST_LOG".to_string(), "debug".to_string()), + ] + ); + } + + #[test] + fn expand_profile_set_vars_none_when_absent() { + let profile = profile::Profile::default(); + let result = expand_profile_set_vars(Some(&profile), Path::new("/tmp/work")) + .expect("expansion should succeed"); + assert!(result.is_none()); + } + fn create_pack_dir(config_dir: &Path, namespace: &str, name: &str) -> PathBuf { let install_dir = config_dir .join("nono") diff --git a/crates/nono-cli/src/sandbox_prepare.rs b/crates/nono-cli/src/sandbox_prepare.rs index b7286b1c8..b66b65dc3 100644 --- a/crates/nono-cli/src/sandbox_prepare.rs +++ b/crates/nono-cli/src/sandbox_prepare.rs @@ -444,6 +444,8 @@ pub(crate) struct PreparedSandbox { pub(crate) denied_env_vars: Option>, pub(crate) profile_network_approval_mode: Option, pub(crate) profile_network_approval_timeout_secs: Option, + /// Expanded `environment.set_vars` (key, expanded-value), `None` if absent. + pub(crate) set_vars: Option>, /// True when the profile or CLI requested `network.block`. Carried /// through because a CLI proxy flag (e.g. `--credential`) may later /// override `caps` to `ProxyOnly`, losing the original intent. @@ -1080,6 +1082,7 @@ pub(crate) fn prepare_sandbox(args: &SandboxArgs, silent: bool) -> Result Result Result + Unlike inherited host variables, `set_vars` keys are **not** subject to the built-in dangerous-variable blocklist. Setting `LD_PRELOAD`, `NODE_OPTIONS`, or similar via `set_vars` is allowed — it is an explicit, auditable operator decision in the profile, not injection from an untrusted parent shell. Use this deliberately. + + +**Inheritance:** `set_vars` merges across profile inheritance like a map — a child profile's entries are added to the base's, and on key conflict the child's value wins. + ## Interaction with the Built-in Deny-List nono maintains a built-in deny-list of dangerous environment variables (e.g., `LD_PRELOAD`, `DYLD_INSERT_LIBRARIES`, `PYTHONPATH`, `NODE_OPTIONS`). These variables are **always blocked**, even if they appear in `allow_vars`. This prevents a compromised profile from disabling sandbox protections. @@ -147,6 +174,7 @@ nono maintains a built-in deny-list of dangerous environment variables (e.g., `L - **Empty allow-list restricts all**: `"allow_vars": []` passes zero inherited variables — only nono-injected credentials reach the child - **Explicit allow-list**: When `allow_vars` has entries, only matching variables reach the child process - **Operator deny-list**: `deny_vars` strips named variables even when `allow_vars` would otherwise pass them through +- **Static values via `set_vars`**: explicit variables injected after filtering and before credentials; `PATH` and `NONO_*` are reserved, but the dangerous-variable blocklist does not apply (explicit operator intent) - **Injected credentials bypass both lists**: `env_credentials` variables always pass through, regardless of `allow_vars` or `deny_vars` - **Built-in deny-list is non-overridable**: Dangerous variables like `LD_PRELOAD` are always blocked and cannot be re-allowed - **Precedence**: built-in blocklist > `deny_vars` > `allow_vars` diff --git a/docs/cli/features/profile-authoring.mdx b/docs/cli/features/profile-authoring.mdx index 95cf1661a..76ada273b 100644 --- a/docs/cli/features/profile-authoring.mdx +++ b/docs/cli/features/profile-authoring.mdx @@ -129,7 +129,9 @@ With `--full`, additional sections are included as empty stubs for all additive }, "env_credentials": {}, "environment": { - "allow_vars": [] + "allow_vars": [], + "deny_vars": [], + "set_vars": {} }, "hooks": {}, "rollback": { From f8e07497dc216e0958686f8a91dd7ec5ab5d24ad Mon Sep 17 00:00:00 2001 From: Luke Hinds Date: Sat, 13 Jun 2026 09:42:59 +0100 Subject: [PATCH 40/91] Improve readme, which is underselling and not showing value (#1141) Signed-off-by: Luke Hinds --- README.md | 70 ++++++++++++++++++++++++------------------------------- 1 file changed, 31 insertions(+), 39 deletions(-) diff --git a/README.md b/README.md index d21605b05..affee4e0d 100644 --- a/README.md +++ b/README.md @@ -31,72 +31,64 @@ > [!NOTE] > In the lead-up to a 1.0 release, APIs are stabilizing. API changes may still occur where necessary, but will be kept to a minimum. -nono is a capability-based, policy-governed runtime for AI agents. +**Run AI agents in a zero latency sandbox in seconds and with zero setup** — *Claude Code, Codex, Pi, CoPilot, Hermes, OpenCode, OpenClaw* and more — nono gets you up and running within seconds, with no daemon, no container, no VM, and no disk space usage. Out of the box, nono enforces a least-privilege sandbox and supports macOS, Linux, and Windows (WSL2). -It gives a process narrowly scoped access to the host resources it actually needs: specific paths, network destinations, sockets, environment variables, credentials, and operations. Policies are explicit, composable, auditable, and enforced by kernel primitives. +From here **fork the config**, tweak it, theme it, make it your own, and share it with your team or the community via the [nono registry](https://registry.nono.sh). -nono fits the space between "run the agent directly on my machine with full access to keys and files" and "seal it inside a separate guest OS." Agents work inside real development environments, with host resources modeled as explicit capabilities. +**Want to operationalise and run at scale or within your team?** Engineers at some of the largest tech companies in the world use nono as part of their workflows or to run AI agents in production. -A profile states what the agent may touch, and nono applies it. The core library is policy-free: it applies only the capabilities a caller provides. The CLI, profiles, and registry packages carry the policy - and all inbuilt policy can be extended or overridden, since policy is fully composable. +**The OG of agent sandboxing** — copied by many, nono continues to lead the way with new features and innovations: credential protection, L7 filtering, supply chain security, rollback, multiplexing, audit and more. -For organizations, that means policy can be reviewed, versioned, distributed, and reused. A team can ship a standard profile for a class of agents, collect supervised audit records, preserve rollback evidence, and keep sensitive credentials in a trusted proxy path instead of injecting them directly into the agent process. +--- +## Quickstart ---- +#### macOS / Linux (Homebrew) +```bash +brew install nono +``` -## Installation +**Other platforms** — Debian/Ubuntu, Fedora, Arch, RHEL, openSUSE, WSL2, and Nix: [see install instructions](https://docs.nono.sh/docs/installation). -**Platform support:** macOS, Linux, and [WSL2](https://nono.sh/docs/cli/internals/wsl2). +## Run it! + +Find an agent in the registry and run it: -**Install:** ```bash -brew install nono +nono search opencode +nono run --profile always-further/opencode -- opencode ``` -Other options in the [Installation Guide](https://docs.nono.sh/cli/getting_started/installation). +That's it. `opencode` now runs with read/write access to the current directory and **nothing else** — your SSH keys, your cloud credentials, the rest of your disk are invisible to it. ---- +Profiles for all the popular agents live at [registry.nono.sh](https://registry.nono.sh), secured and ready to pull. Each one bundles the right filesystem scope, network allowlist, hooks, skills and more. -## Quick Start +## Make it your own! -`nono pull` agent packages from the [registry](https://registry.nono.sh) for all popular agents — Claude Code, Codex, Pi, Hermes, OpenCode, OpenClaw, and more — or [build your own](https://nono.sh/docs/cli/features/package-publishing) and securely share plugins, SKILLS, and hooks with the community or your team. +Outgrow the defaults? Scaffold a profile and tweak it — same command you already know: ```bash -nono run --profile always-further/claude -- claude +nono profile init opencode --extends always-further/opencode +nono run --profile opencode -- opencode ``` -## Libraries and Bindings +Are you an agent developer and want to publish your own agent package? We would love to have you! [See the docs](https://docs.nono.sh/docs/registry). -The core is a Rust library that can be embedded into any application. Policy-free - it applies only what clients explicitly request. +## Ready to go deep? -```rust -use nono::{CapabilitySet, Sandbox}; +nono provides credentials injection, L7 filtering, supply chain security, rollback, multiplexing, audit and more. These features have since been copied by many other sandboxes, and nono continues to innovate. -let mut caps = CapabilitySet::new(); -caps.allow_read("/data/models")?; -caps.allow_write("/tmp/workspace")?; +## Built by security experts -Sandbox::apply(&caps)?; // Irreversible -- kernel-enforced from here on -``` +There are many sandboxes out there, often vibe coded in a few evenings, but nono is built by security experts with decades of experience in the field. The team behind nono has been responsible for securing some of the largest software supply chains in the world, and they bring that expertise to every aspect of nono's design and implementation. -Also available as [Python](https://github.com/always-further/nono-py) , [TypeScript](https://github.com/always-further/nono-ts), [Go](https://github.com/always-further/nono-go) bindings. +If you plan on using nono in production, you can rest assured that it has been designed and implemented by a team with experience of building secure, stable, and scalable systems. -## Key Features +## Library support -| Feature | Description | -|---------|-------------| -| **Kernel sandbox** | Landlock (Linux) + Seatbelt (macOS). Irreversible, inherited by child processes. | -| **Credential injection** | Proxy mode keeps API keys outside the sandbox entirely. Supports keystore, 1Password, Apple Passwords. | -| **Attestation** | Sigstore-based signing and verification of instruction files (SKILLS.md, CLAUDE.md, etc.). | -| **Network filtering** | Allowlist-based host and endpoint filtering via local proxy. Cloud metadata endpoints hard-denied. | -| **Snapshots** | Content-addressable rollback with SHA-256 dedup and Merkle tree integrity. | -| **Policy profiles** | Pre-built profiles for popular agents and use cases. Custom profile builder for your own needs. | -| **Audit logs** | Default event audit for supervised runs, optional append-only integrity hashing, and optional rollback-backed filesystem evidence. | -| **Cross-platform** | Support for macOS, Linux, and WSL2. Native Windows support in planning. | -| **Multiplexing** | Run multiple agents in parallel with separate sandboxes. Attach/detach to long-running agents. | -| **Runs anywhere** | Local CLI, CI pipelines, Containers / Kubernetes, cloud VMs, microVMs. | +nono provides FFI bindings for Rust, Python, TypeScript, and Go. -See the [full documentation](https://docs.nono.sh) for details and configuration. +Also available as [Python](https://github.com/always-further/nono-py), [TypeScript](https://github.com/always-further/nono-ts), and [Go](https://github.com/always-further/nono-go) bindings. ## Contributing From d8cadd9b556611f80da3548e150eba6939e1ca96 Mon Sep 17 00:00:00 2001 From: Luke Hinds Date: Sat, 13 Jun 2026 09:49:35 +0100 Subject: [PATCH 41/91] docs(readme): update agent package publishing link (#1142) Update the link for agent developers interested in publishing their packages to point to the new, specific documentation page for CLI package features. Also adds a note about promoting published work. Signed-off-by: Luke Hinds --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index affee4e0d..70a3a73d4 100644 --- a/README.md +++ b/README.md @@ -72,7 +72,7 @@ nono profile init opencode --extends always-further/opencode nono run --profile opencode -- opencode ``` -Are you an agent developer and want to publish your own agent package? We would love to have you! [See the docs](https://docs.nono.sh/docs/registry). +Are you an agent developer and want to publish your own agent package? We would love to have you and promote your work! [See the docs](https://nono.sh/docs/cli/features/package-publishing). ## Ready to go deep? From 0f66fa060a30c86fe81f10d410535f5b67da9467 Mon Sep 17 00:00:00 2001 From: Luke Hinds Date: Sat, 13 Jun 2026 10:00:03 +0100 Subject: [PATCH 42/91] docs(readme): refine project description and history (#1143) - Update project introduction to reflect team ownership. - Revise description of agent sandboxing to highlight nono's pioneering role in zero-latency, zero-setup solutions. Signed-off-by: Luke Hinds --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 70a3a73d4..b1cf774ff 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ nono logo

- From the creator of + Built by the team that brought you Sigstore
The standard for secure software attestation, used by PyPI, npm, brew, and Maven Central @@ -37,7 +37,7 @@ From here **fork the config**, tweak it, theme it, make it your own, and share i **Want to operationalise and run at scale or within your team?** Engineers at some of the largest tech companies in the world use nono as part of their workflows or to run AI agents in production. -**The OG of agent sandboxing** — copied by many, nono continues to lead the way with new features and innovations: credential protection, L7 filtering, supply chain security, rollback, multiplexing, audit and more. +**Copied by many** — nono pioneered the zero-latency, zero-setup agent sandbox, and continues to innovate and lead the way in agent sandboxing. --- From ef0192d89a1f8743d31d0a1133d60bc0fc9353d2 Mon Sep 17 00:00:00 2001 From: Caio Silva Date: Sat, 13 Jun 2026 12:56:33 +0100 Subject: [PATCH 43/91] feat(keyring): add NONO_KEYRING_TIMEOUT_SECS for keychain access (#977) * feat(keyring): add configurable timeout for keyring access Adds NONO_KEYRING_TIMEOUT_SECS to bound how long nono blocks on the system keyring (macOS Keychain / Linux Secret Service / KeePassXC). Default 120s covers a real keychain-unlock workflow; 0 preserves the prior "wait forever" behavior. Invalid values fall back to the default with a warning. Also surfaces credential-load failures on the proxy path with eprintln! next to the existing warn!, since a locked keychain previously caused the proxy to start silently without the credential. Closes #967 Signed-off-by: Caio Silva * chore: remove unused import Signed-off-by: Caio Silva * fix(proxy): remove eprintln! from credential.rs, use warn! per review The nono-proxy crate is a library used by language SDKs, not a CLI. Libraries should not print directly to stderr. Use the existing NonoError match pattern with warn! instead, consistent with the rest of credential.rs. The timeout hint is folded into the warn! message so the information is not lost. Signed-off-by: Caio Silva * fix(proxy): add bw:// to redact_credential_ref Signed-off-by: Caio Silva --------- Signed-off-by: Caio Silva Co-authored-by: Luke Hinds --- crates/nono-proxy/src/credential.rs | 66 ++++-- crates/nono/src/keystore.rs | 301 +++++++++++++++++++++++----- 2 files changed, 298 insertions(+), 69 deletions(-) diff --git a/crates/nono-proxy/src/credential.rs b/crates/nono-proxy/src/credential.rs index bff76bdac..84f071b66 100644 --- a/crates/nono-proxy/src/credential.rs +++ b/crates/nono-proxy/src/credential.rs @@ -136,10 +136,12 @@ impl CredentialStore { continue; } Err(nono::NonoError::KeystoreAccess(msg)) => { + let redacted = redact_credential_ref(key); warn!( "Credential '{}' not available for route '{}': {}. \ - Managed-credential requests on this route will be denied until the credential is available.", - key, normalized_prefix, msg + Managed-credential requests on this route will be denied until the credential is available. \ + Set NONO_KEYRING_TIMEOUT_SECS=N (default 120) to wait longer for keychain unlock; 0 disables the timeout.", + redacted, normalized_prefix, msg ); continue; } @@ -205,20 +207,24 @@ impl CredentialStore { route.prefix ); - let client_id = - match nono::keystore::load_secret_by_ref(KEYRING_SERVICE, &oauth2.client_id) { - Ok(s) => s, - Err(nono::NonoError::SecretNotFound(msg)) - | Err(nono::NonoError::KeystoreAccess(msg)) => { - warn!( - "OAuth2 client_id not available for route '{}': {}. \ - Managed-credential requests on this route will be denied.", - route.prefix, msg - ); - continue; - } - Err(e) => return Err(ProxyError::Credential(e.to_string())), - }; + let client_id = match nono::keystore::load_secret_by_ref( + KEYRING_SERVICE, + &oauth2.client_id, + ) { + Ok(s) => s, + Err(nono::NonoError::SecretNotFound(msg)) + | Err(nono::NonoError::KeystoreAccess(msg)) => { + let redacted = redact_credential_ref(&oauth2.client_id); + warn!( + "OAuth2 client_id '{}' not available for route '{}': {}. \ + Managed-credential requests on this route will be denied. \ + Set NONO_KEYRING_TIMEOUT_SECS=N (default 120) to wait longer for keychain unlock; 0 disables the timeout.", + redacted, route.prefix, msg + ); + continue; + } + Err(e) => return Err(ProxyError::Credential(e.to_string())), + }; let client_secret = match nono::keystore::load_secret_by_ref( KEYRING_SERVICE, @@ -227,10 +233,12 @@ impl CredentialStore { Ok(s) => s, Err(nono::NonoError::SecretNotFound(msg)) | Err(nono::NonoError::KeystoreAccess(msg)) => { + let redacted = redact_credential_ref(&oauth2.client_secret); warn!( - "OAuth2 client_secret not available for route '{}': {}. \ - Managed-credential requests on this route will be denied.", - route.prefix, msg + "OAuth2 client_secret '{}' not available for route '{}': {}. \ + Managed-credential requests on this route will be denied. \ + Set NONO_KEYRING_TIMEOUT_SECS=N (default 120) to wait longer for keychain unlock; 0 disables the timeout.", + redacted, route.prefix, msg ); continue; } @@ -321,6 +329,26 @@ impl CredentialStore { /// Uses the same constant as `nono::keystore::DEFAULT_SERVICE` to ensure consistency. const KEYRING_SERVICE: &str = nono::keystore::DEFAULT_SERVICE; +/// Redact a credential reference for safe display in warnings. +/// +/// Delegates to the appropriate URI-specific redaction helper so that +/// secrets (account names, file paths, field names) are never echoed raw. +fn redact_credential_ref(key: &str) -> String { + if nono::keystore::is_op_uri(key) { + nono::keystore::redact_op_uri(key) + } else if nono::keystore::is_apple_password_uri(key) { + nono::keystore::redact_apple_password_uri(key) + } else if nono::keystore::is_keyring_uri(key) { + nono::keystore::redact_keyring_uri(key) + } else if nono::keystore::is_bw_uri(key) { + nono::keystore::redact_bw_uri(key) + } else if nono::keystore::is_file_uri(key) { + nono::keystore::redact_file_uri(key) + } else { + key.to_string() + } +} + /// Build a hint for the credential-not-found warning that probes other /// credential sources for the same name. /// diff --git a/crates/nono/src/keystore.rs b/crates/nono/src/keystore.rs index f2d041bda..64c7a111d 100644 --- a/crates/nono/src/keystore.rs +++ b/crates/nono/src/keystore.rs @@ -24,6 +24,8 @@ use std::collections::HashMap; use std::io::Write; use std::path::Path; use std::process::{Command, Stdio}; +#[cfg(feature = "system-keyring")] +use std::sync::mpsc; use std::time::Duration; use zeroize::Zeroizing; @@ -32,6 +34,86 @@ use zeroize::Zeroizing; /// Generous enough to allow biometric prompts in password manager CLIs. const SECRET_MANAGER_TIMEOUT: Duration = Duration::from_secs(30); +/// Default timeout for system keyring calls (seconds). +/// +/// 120 s covers the realistic worst-case keychain unlock workflow: +/// notice the prompt, switch windows, type a master password, click approve. +/// Rationale for choosing 120 over a shorter value: issue #967 reports that +/// a shorter timeout fired before the user could complete the KeePassXC +/// unlock sequence on Linux. We go materially longer while still bounding +/// an unattended hang. +/// +/// Override with `NONO_KEYRING_TIMEOUT_SECS`. Set to `0` to disable the +/// timeout entirely and restore the old "block forever" behaviour. +#[cfg(feature = "system-keyring")] +const KEYRING_TIMEOUT_SECS: u64 = 120; + +/// Return the effective keyring timeout. +/// +/// - `NONO_KEYRING_TIMEOUT_SECS` unset → `Some(120 s)` (the default). +/// - `NONO_KEYRING_TIMEOUT_SECS=0` → `None` (wait forever). +/// - `NONO_KEYRING_TIMEOUT_SECS=N` (N > 0) → `Some(N s)`. +/// - Invalid value → logs a warning and returns `Some(120 s)`. +#[cfg(feature = "system-keyring")] +fn keyring_timeout() -> Option { + match std::env::var("NONO_KEYRING_TIMEOUT_SECS") { + Err(_) => Some(Duration::from_secs(KEYRING_TIMEOUT_SECS)), + Ok(s) => match s.trim().parse::() { + Ok(0) => None, + Ok(n) => Some(Duration::from_secs(n)), + Err(_) => { + tracing::warn!( + "NONO_KEYRING_TIMEOUT_SECS='{}' is not a valid integer; \ + using default {}s", + s.trim(), + KEYRING_TIMEOUT_SECS + ); + Some(Duration::from_secs(KEYRING_TIMEOUT_SECS)) + } + }, + } +} + +/// Call a blocking keyring function with an optional timeout. +/// +/// When `timeout` is `None`, the closure is called inline on the current +/// thread — no extra thread is spawned. +/// +/// When `timeout` is `Some(d)`, the closure is moved onto a new thread and +/// the result is sent back through an `mpsc` channel. If the channel +/// `recv_timeout` fires, a `KeystoreAccess` error is returned. +/// +/// **macOS note**: if the timeout fires while `Security.framework` is +/// displaying a keychain unlock dialog, the spawned thread stays alive and +/// blocked until the user responds or the process exits. Rust cannot cancel +/// a blocking syscall; this is the expected behaviour. No secret material +/// leaks because the `Zeroizing` result is dropped on the thread +/// side once the channel receiver is gone. +#[cfg(feature = "system-keyring")] +fn call_with_keyring_timeout(timeout: Option, label: &str, f: F) -> Result +where + F: FnOnce() -> Result + Send + 'static, + T: Send + 'static, +{ + let Some(dur) = timeout else { + return f(); + }; + + let (tx, rx) = mpsc::channel(); + std::thread::spawn(move || { + let _ = tx.send(f()); + }); + + rx.recv_timeout(dur).unwrap_or_else(|_| { + Err(NonoError::KeystoreAccess(format!( + "{} timed out after {}s waiting for keychain access. \ + Set NONO_KEYRING_TIMEOUT_SECS=N to adjust (0 = wait forever).", + label, + dur.as_secs() + ))) + }) +} + /// A credential loaded from the keystore pub struct LoadedSecret { /// The environment variable name to set @@ -980,32 +1062,36 @@ pub fn store_secret_file(path: &Path, secret: &str) -> Result<()> { /// keyring crate that we cannot address from the caller side. #[cfg(feature = "system-keyring")] fn load_single_secret(service: &str, account: &str) -> Result> { - let entry = keyring::Entry::new(service, account).map_err(|e| { - NonoError::KeystoreAccess(format!( - "Failed to access keystore for '{}': {}", - account, e - )) - })?; + let timeout = keyring_timeout(); + let service = service.to_string(); + let account = account.to_string(); + let label = format!("keyring lookup for '{}'", account); + + call_with_keyring_timeout(timeout, &label, move || { + let entry = keyring::Entry::new(&service, &account).map_err(|e| { + NonoError::KeystoreAccess(format!( + "Failed to access keystore for '{}': {}", + account, e + )) + })?; - match entry.get_password() { - Ok(password) => { - // Immediately wrap in Zeroizing so the String's heap buffer is - // zeroed when the secret is dropped. The move does not copy the - // heap allocation - it transfers ownership of the same buffer. - tracing::debug!("Successfully loaded secret '{}'", account); - Ok(Zeroizing::new(password)) + match entry.get_password() { + Ok(password) => { + tracing::debug!("Successfully loaded secret '{}'", account); + Ok(Zeroizing::new(password)) + } + Err(keyring::Error::NoEntry) => Err(NonoError::SecretNotFound(account.to_string())), + Err(keyring::Error::Ambiguous(creds)) => Err(NonoError::KeystoreAccess(format!( + "Multiple entries ({}) found for '{}' - please resolve manually", + creds.len(), + account + ))), + Err(e) => Err(NonoError::KeystoreAccess(format!( + "Cannot access '{}': {}", + account, e + ))), } - Err(keyring::Error::NoEntry) => Err(NonoError::SecretNotFound(account.to_string())), - Err(keyring::Error::Ambiguous(creds)) => Err(NonoError::KeystoreAccess(format!( - "Multiple entries ({}) found for '{}' - please resolve manually", - creds.len(), - account - ))), - Err(e) => Err(NonoError::KeystoreAccess(format!( - "Cannot access '{}': {}", - account, e - ))), - } + }) } #[cfg(not(feature = "system-keyring"))] @@ -1354,34 +1440,43 @@ fn load_from_keyring_uri(uri: &str) -> Result> { let redacted = redact_keyring_uri(uri); tracing::debug!("Loading secret from system keyring: {}", redacted); - let entry = keyring::Entry::new(parts.service, parts.account).map_err(|e| { - NonoError::KeystoreAccess(format!( - "Failed to access keyring for '{}': {}", - redacted, e - )) - })?; + let timeout = keyring_timeout(); + let service = parts.service.to_string(); + let account = parts.account.to_string(); + let decode = parts.decode; + let redacted_clone = redacted.clone(); + let label = format!("keyring lookup for '{}'", redacted); - match entry.get_password() { - Ok(password) => { - tracing::debug!("Successfully loaded secret '{}'", redacted); - let decoded = apply_keyring_decode(password, parts.decode, &redacted)?; - Ok(decoded) + call_with_keyring_timeout(timeout, &label, move || { + let entry = keyring::Entry::new(&service, &account).map_err(|e| { + NonoError::KeystoreAccess(format!( + "Failed to access keyring for '{}': {}", + redacted_clone, e + )) + })?; + + match entry.get_password() { + Ok(password) => { + tracing::debug!("Successfully loaded secret '{}'", redacted_clone); + let decoded = apply_keyring_decode(password, decode, &redacted_clone)?; + Ok(decoded) + } + Err(keyring::Error::NoEntry) => Err(NonoError::SecretNotFound(format!( + "keyring entry not found: '{}'. \ + Verify the service and account match the stored credential.", + redacted_clone + ))), + Err(keyring::Error::Ambiguous(creds)) => Err(NonoError::KeystoreAccess(format!( + "Multiple entries ({}) found for '{}' - please resolve manually", + creds.len(), + redacted_clone + ))), + Err(e) => Err(NonoError::KeystoreAccess(format!( + "Cannot access '{}': {}", + redacted_clone, e + ))), } - Err(keyring::Error::NoEntry) => Err(NonoError::SecretNotFound(format!( - "keyring entry not found: '{}'. \ - Verify the service and account match the stored credential.", - redacted - ))), - Err(keyring::Error::Ambiguous(creds)) => Err(NonoError::KeystoreAccess(format!( - "Multiple entries ({}) found for '{}' - please resolve manually", - creds.len(), - redacted - ))), - Err(e) => Err(NonoError::KeystoreAccess(format!( - "Cannot access '{}': {}", - redacted, e - ))), - } + }) } #[cfg(not(feature = "system-keyring"))] @@ -3641,6 +3736,112 @@ mod tests { ); } + // ========================================================================= + // keyring_timeout() and call_with_keyring_timeout() tests + // ========================================================================= + + #[cfg(feature = "system-keyring")] + mod keyring_timeout_tests { + use super::*; + use std::sync::Mutex; + use std::time::Duration; + + static ENV_LOCK: Mutex<()> = Mutex::new(()); + + struct EnvGuard { + key: &'static str, + original: Option, + } + + impl EnvGuard { + #[allow(clippy::disallowed_methods)] + fn set(key: &'static str, val: &str) -> Self { + let original = std::env::var(key).ok(); + // SAFETY: serialised via ENV_LOCK + unsafe { std::env::set_var(key, val) }; + Self { key, original } + } + + #[allow(clippy::disallowed_methods)] + fn remove(key: &'static str) -> Self { + let original = std::env::var(key).ok(); + // SAFETY: serialised via ENV_LOCK + unsafe { std::env::remove_var(key) }; + Self { key, original } + } + } + + #[allow(clippy::disallowed_methods)] + impl Drop for EnvGuard { + fn drop(&mut self) { + match &self.original { + // SAFETY: serialised via ENV_LOCK + Some(v) => unsafe { std::env::set_var(self.key, v) }, + None => unsafe { std::env::remove_var(self.key) }, + } + } + } + + const KEY: &str = "NONO_KEYRING_TIMEOUT_SECS"; + + #[test] + fn keyring_timeout_unset_returns_default_120s() { + let _lock = ENV_LOCK.lock().unwrap(); + let _g = EnvGuard::remove(KEY); + assert_eq!(keyring_timeout(), Some(Duration::from_secs(120))); + } + + #[test] + fn keyring_timeout_zero_returns_none() { + let _lock = ENV_LOCK.lock().unwrap(); + let _g = EnvGuard::set(KEY, "0"); + assert_eq!(keyring_timeout(), None); + } + + #[test] + fn keyring_timeout_valid_value_returns_some() { + let _lock = ENV_LOCK.lock().unwrap(); + let _g = EnvGuard::set(KEY, "300"); + assert_eq!(keyring_timeout(), Some(Duration::from_secs(300))); + } + + #[test] + fn keyring_timeout_invalid_falls_back_to_default() { + let _lock = ENV_LOCK.lock().unwrap(); + let _g = EnvGuard::set(KEY, "banana"); + assert_eq!(keyring_timeout(), Some(Duration::from_secs(120))); + } + + #[test] + fn call_with_keyring_timeout_none_runs_inline() { + // None = no timeout; closure runs directly, no thread spawned + let result: Result = call_with_keyring_timeout(None, "test", || Ok(42_u32)); + assert_eq!(result.unwrap(), 42); + } + + #[test] + fn call_with_keyring_timeout_fast_call_returns_value() { + let result: Result = + call_with_keyring_timeout(Some(Duration::from_secs(5)), "test", || Ok(99_u32)); + assert_eq!(result.unwrap(), 99); + } + + #[test] + fn call_with_keyring_timeout_slow_call_fires() { + let result: Result = + call_with_keyring_timeout(Some(Duration::from_millis(50)), "slow-test", || { + std::thread::sleep(Duration::from_secs(10)); + Ok(0_u32) + }); + let err = result.unwrap_err().to_string(); + assert!( + err.contains("timed out") && err.contains("NONO_KEYRING_TIMEOUT_SECS"), + "got: {}", + err + ); + } + } + #[test] fn test_build_secret_mappings_explicit_pairs_take_precedence() { let mut profile = HashMap::new(); From 1514ed7d7429cd6b417c9d32e53068fac6ad3300 Mon Sep 17 00:00:00 2001 From: Luke Hinds Date: Sat, 13 Jun 2026 13:03:49 +0100 Subject: [PATCH 44/91] docs(readme): update agent commands and enhance feature descriptions (#1145) * Clarify agent commands and enhance feature descriptions Updated the README to clarify agent search and run commands, and enhanced the description of nono's features and expertise. * Update README.md Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --------- Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- README.md | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index b1cf774ff..f55fbc3a9 100644 --- a/README.md +++ b/README.md @@ -52,11 +52,13 @@ brew install nono ## Run it! -Find an agent in the registry and run it: +Search for an agent in the registry, then run it: ```bash -nono search opencode -nono run --profile always-further/opencode -- opencode +$ nono search opencode +always-further/opencode - Official Always Further Opencode Plugin + +$ nono run --profile always-further/opencode -- opencode ``` That's it. `opencode` now runs with read/write access to the current directory and **nothing else** — your SSH keys, your cloud credentials, the rest of your disk are invisible to it. @@ -76,13 +78,7 @@ Are you an agent developer and want to publish your own agent package? We would ## Ready to go deep? -nono provides credentials injection, L7 filtering, supply chain security, rollback, multiplexing, audit and more. These features have since been copied by many other sandboxes, and nono continues to innovate. - -## Built by security experts - -There are many sandboxes out there, often vibe coded in a few evenings, but nono is built by security experts with decades of experience in the field. The team behind nono has been responsible for securing some of the largest software supply chains in the world, and they bring that expertise to every aspect of nono's design and implementation. - -If you plan on using nono in production, you can rest assured that it has been designed and implemented by a team with experience of building secure, stable, and scalable systems. +Head over to the [docs](https://nono.sh/docs) and discover nono's rich composable policy system, credentials injection, L7 filtering, supply chain security, rollback, multiplexing, audit and more. ## Library support From 939c0c95b4efd8103cce712ce4eff7c0f9f3b847 Mon Sep 17 00:00:00 2001 From: Aleks <121458075+SequeI@users.noreply.github.com> Date: Sat, 13 Jun 2026 14:40:57 +0100 Subject: [PATCH 45/91] docs: document diagnostics.suppress_system_services for macOS (#1076) (#1138) * docs: document diagnostics.suppress_system_services for macOS (#1076) Signed-off-by: Aleksy Siek --- docs/cli/features/profile-authoring.mdx | 26 +++++++++++++++++++ docs/cli/features/profiles-groups.mdx | 33 ++++++++++++++++++++++++- docs/cli/usage/flags.mdx | 5 ++++ 3 files changed, 63 insertions(+), 1 deletion(-) diff --git a/docs/cli/features/profile-authoring.mdx b/docs/cli/features/profile-authoring.mdx index 76ada273b..40e317a87 100644 --- a/docs/cli/features/profile-authoring.mdx +++ b/docs/cli/features/profile-authoring.mdx @@ -137,6 +137,9 @@ With `--full`, additional sections are included as empty stubs for all additive "rollback": { "exclude_patterns": [], "exclude_globs": [] + }, + "diagnostics": { + "suppress_system_services": [] } } ``` @@ -377,6 +380,29 @@ The post-run save prompt offers the same behavior interactively: choose `suppress` to save all listed denied-path suggestions here instead of adding them as `read`, `read_file`, `allow`, or `allow_file` grants. +### Suppressing Expected macOS System-Service Diagnostics + +Use `diagnostics.suppress_system_services` for macOS Seatbelt operations you +intentionally deny but do not want to see in every diagnostic footer: + +```json +{ + "diagnostics": { + "suppress_system_services": ["user-preference-read"] + } +} +``` + +Common values include `user-preference-read` (CFPreferences probes from npm and +similar tools) and `forbidden-exec-sugid`. This does not grant access and does +not change sandbox enforcement — it only hides matching violations from +post-run output and save-profile prompts. Values merge additively across +profile inheritance, like other list fields. + +For path denials that should stay visible but skip save prompts, use +`filesystem.suppress_save_prompt` instead. To disable all diagnostic footers, +use `--no-diagnostics`. + ### Exclude Inherited Groups ```json diff --git a/docs/cli/features/profiles-groups.mdx b/docs/cli/features/profiles-groups.mdx index 28f5ca56e..3795f1737 100644 --- a/docs/cli/features/profiles-groups.mdx +++ b/docs/cli/features/profiles-groups.mdx @@ -197,6 +197,7 @@ This is the primary mechanism for surgically customizing inherited profiles with | `deny` | `commands` | Deprecated in v0.33.0. Startup-only command denylist extension. Not enforced for child processes; prefer `filesystem.deny` and narrower grants. | | `bypass_protection` | `filesystem` | Paths exempted from deny groups. **Does not grant access** — each path must also appear in `filesystem.allow`, `filesystem.read`, or `filesystem.write`. | | `suppress_save_prompt` | `filesystem` | Paths whose runtime denials should not be offered in save-profile prompts. **Does not grant access** and does not hide diagnostics. | +| `suppress_system_services` | `diagnostics` | macOS Seatbelt operation names to hide from diagnostic footers and save-profile prompts. **Does not grant access** and does not change enforcement. | **Renamed in issue #594.** The old top-level `policy` section was dissolved into `filesystem`, `groups`, and `commands` in the canonical schema. Legacy names still deserialize with a deprecation warning; see the migration table in `nono profile guide` for the full mapping. The legacy keys will be removed in v1.0.0. @@ -332,8 +333,34 @@ why the path does not appear in the save prompt. The interactive save prompt applies this to every listed path suggestion when you choose `suppress`; it does not create any allow/read/write grants. +#### Suppressing Expected macOS System-Service Diagnostics + +Use `diagnostics.suppress_system_services` when a macOS Seatbelt operation is +expected to be denied and you do not want nono to repeat the diagnostic footer +(or offer a save-profile prompt) on every run: + +```json +{ + "extends": "default", + "meta": { "name": "node-local", "version": "1.0.0" }, + "diagnostics": { + "suppress_system_services": ["user-preference-read"] + } +} +``` + +This is macOS-only. Values are Seatbelt operation names from the sandbox log, +such as `user-preference-read` (CFPreferences / NSUserDefaults probes from npm +and other Node tools) or `forbidden-exec-sugid`. Suppression is output-only: +the sandbox still denies the operation. + +Unlike `filesystem.suppress_save_prompt`, this hides matching violations from +the diagnostic footer entirely. There is no CLI flag equivalent yet; add the +operation name to your profile. To silence all post-run diagnostics instead, +use `--no-diagnostics`. + - `filesystem`, `groups`, and `commands` fields are additive across inheritance. A child profile's `filesystem.deny` is merged with the base profile's `filesystem.deny`, and `groups.exclude` from both levels are combined. + `filesystem`, `groups`, `commands`, and `diagnostics` fields are additive across inheritance. A child profile's `filesystem.deny` is merged with the base profile's `filesystem.deny`, `groups.exclude` from both levels are combined, and `diagnostics.suppress_system_services` entries from both profiles are appended and deduplicated. ### Network Configuration @@ -537,6 +564,10 @@ older `filesystem.ignore` and `--ignore-denied` forms are accepted as aliases, but new examples use the explicit suppress wording to avoid suggesting an access grant. +`profile.diagnostics.suppress_system_services` is also output-only (macOS). It +hides recurring non-filesystem Seatbelt violations from diagnostic footers and +save-profile prompts without granting the underlying operation. + Exclusions are applied after group addition. If the same group appears in both `groups.include` and `groups.exclude`, the exclusion wins. diff --git a/docs/cli/usage/flags.mdx b/docs/cli/usage/flags.mdx index a3825242b..0bd02ed8e 100644 --- a/docs/cli/usage/flags.mdx +++ b/docs/cli/usage/flags.mdx @@ -1118,6 +1118,11 @@ Suppress the diagnostic footer when the command exits non-zero. Useful for scrip nono run --no-diagnostics --allow-cwd -- my-command ``` +On macOS, to hide specific expected Seatbelt violations (such as +`user-preference-read` from npm) without silencing all diagnostics, add the +operation name to `diagnostics.suppress_system_services` in your profile +instead. See [Profiles and Groups](/cli/features/profiles-groups#suppressing-expected-macos-system-service-diagnostics). + #### `--capability-elevation` Enable runtime capability elevation for this session. When active, the sandbox installs a seccomp-notify filter (Linux) and a PTY multiplexer so that file access beyond the initial capability set can be approved interactively at runtime. From d4943b9f43e5c0dc6b7fa13034ed556ae7b79cee Mon Sep 17 00:00:00 2001 From: Luke Hinds Date: Sun, 14 Jun 2026 08:57:51 +0100 Subject: [PATCH 46/91] refactor(pull_ui): remove sigstore provenance display (#1144) The `nono pull` UI previously displayed detailed Sigstore provenance information after a successful pull. This information has been removed to simplify the user interface. The provenance details were often complex and might not have provided clear value to the end user in this specific context, potentially causing confusion or visual clutter. Future iterations might integrate provenance in a different, more digestible way if needed. This change removes: - The "Verified Sigstore cryptographic supply chain provenance" block. - Associated helper functions (`write_field`, `ref_label`, `strip_ref_prefix`, `is_sha_like`). - Corresponding unit tests. Signed-off-by: Luke Hinds --- crates/nono-cli/src/pull_ui.rs | 113 ++------------------------------- 1 file changed, 5 insertions(+), 108 deletions(-) diff --git a/crates/nono-cli/src/pull_ui.rs b/crates/nono-cli/src/pull_ui.rs index 7b8163448..7cff8bfb8 100644 --- a/crates/nono-cli/src/pull_ui.rs +++ b/crates/nono-cli/src/pull_ui.rs @@ -1,8 +1,7 @@ //! Sleek TUI for `nono pull`. Streams per-file download progress as it -//! happens, then renders the Sigstore provenance and install summary as -//! a coherent trust-chain block. Same output for the explicit -//! `nono pull ` command and the auto-pull path triggered by -//! `--profile always-further/claude`. +//! happens, then renders the install summary. Same output for the +//! explicit `nono pull ` command and the auto-pull path triggered +//! by `--profile always-further/claude`. //! //! Design rules (do not relax without thinking): //! - No spinners, no in-place line rewrites — output stays readable in @@ -11,8 +10,6 @@ //! terminals don't wrap awkwardly. //! - Color is decoration, not information: every line still parses //! when ANSI is stripped (NO_COLOR, dumb terminals). -//! - The provenance block explains *what* was verified — users should -//! leave with a clear mental model of "artifact ↔ source code". use crate::package::{PackageRef, PullResponse}; use colored::Colorize; @@ -76,8 +73,8 @@ impl ProgressPrinter { } } -/// Render the verified-and-installed summary. Called once after the -/// install completes successfully. +/// Render the install summary. Called once after the install +/// completes successfully. /// /// `install_dir` is the absolute path of the installed pack inside the /// package store. `installed_artifacts` is the count from the install @@ -100,44 +97,6 @@ pub fn render_summary( ); let _ = writeln!(err); - let prov = &pull.provenance; - let label_w = "workflow".len(); - - let _ = writeln!( - err, - " {label} {body}", - label = "Verified".bold(), - body = "Sigstore cryptographic supply chain provenance binds all".normal(), - ); - let _ = writeln!( - err, - " release artifacts to the source of origin '{}'", - prov.repository, - ); - let _ = writeln!(err); - - write_field(&mut err, "repo", &prov.repository, label_w); - write_field( - &mut err, - ref_label(&prov.git_ref), - &strip_ref_prefix(&prov.git_ref), - label_w, - ); - write_field(&mut err, "workflow", &prov.workflow, label_w); - if let Some(ts) = prov.signed_at { - write_field( - &mut err, - "signed", - &ts.format("%Y-%m-%d %H:%M:%S UTC").to_string(), - label_w, - ); - } - if let Some(idx) = prov.rekor_log_index { - let url = format!("https://search.sigstore.dev/?logIndex={idx}"); - write_field(&mut err, "rekor", &url, label_w); - } - - let _ = writeln!(err); let _ = writeln!( err, " {label} {body}", @@ -160,46 +119,6 @@ pub fn render_summary( let _ = writeln!(err); } -/// Field row in the provenance block. Two-space indent already applied -/// upstream; the inner formatting matches the "Verified …" header. -fn write_field(out: &mut W, label: &str, value: &str, label_w: usize) { - let _ = writeln!( - out, - " {label: &'static str { - if git_ref.starts_with("refs/tags/") { - "tag" - } else if git_ref.starts_with("refs/heads/") { - "branch" - } else if is_sha_like(git_ref) { - "commit" - } else { - "ref" - } -} - -fn strip_ref_prefix(git_ref: &str) -> String { - if let Some(rest) = git_ref.strip_prefix("refs/tags/") { - return rest.to_string(); - } - if let Some(rest) = git_ref.strip_prefix("refs/heads/") { - return rest.to_string(); - } - git_ref.to_string() -} - -fn is_sha_like(s: &str) -> bool { - s.len() >= 7 && s.len() <= 40 && s.chars().all(|c| c.is_ascii_hexdigit()) -} - /// "1.30 KB" / "412 B" / "2.10 MB" — three significant digits. Human /// readable; precision matched across rows by `ProgressPrinter`'s /// `size_width` calculation. @@ -235,26 +154,4 @@ mod tests { assert_eq!(format_size(1024 * 1024), "1.00 MB"); assert_eq!(format_size(1024 * 1024 * 1024), "1.00 GB"); } - - #[test] - fn ref_label_classification() { - assert_eq!(ref_label("refs/tags/v0.0.1"), "tag"); - assert_eq!(ref_label("refs/heads/main"), "branch"); - assert_eq!(ref_label("a1b2c3d4e5f6"), "commit"); - assert_eq!( - ref_label("a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"), - "commit" - ); - assert_eq!(ref_label("something-else"), "ref"); - } - - #[test] - fn strip_ref_prefix_strips_known_prefixes() { - assert_eq!( - strip_ref_prefix("refs/tags/claude-v0.0.11"), - "claude-v0.0.11" - ); - assert_eq!(strip_ref_prefix("refs/heads/main"), "main"); - assert_eq!(strip_ref_prefix("abc1234"), "abc1234"); - } } From 2dfc24026133d44539a5c3824891ef9334b202df Mon Sep 17 00:00:00 2001 From: Anil Kulkarni <6687139+intentionally-left-nil@users.noreply.github.com> Date: Sun, 14 Jun 2026 01:31:34 -0700 Subject: [PATCH 47/91] feat: add $PACK_DIR support to session_hooks for store pack support (#1073) * Add source_pack and replacement for registry packs Signed-off-by: Anil Kulkarni * TDD: Add failing test for extends case, refactor tests around guard pattern Signed-off-by: Anil Kulkarni * Add session hook expansion code for extended profiles as well Signed-off-by: Anil Kulkarni * Add tests for store extends store, and PACK_DIR case sensitivity Signed-off-by: Anil Kulkarni * Add tests for session_hook provenance Signed-off-by: Anil Kulkarni * Implement verification of the session_hook for store packs Signed-off-by: Anil Kulkarni * Refactor source_pack to be a PackageRef Signed-off-by: Anil Kulkarni * cargo clippy Signed-off-by: Anil Kulkarni * cargo fmt Signed-off-by: Anil Kulkarni * Update crates/nono-cli/src/profile_runtime.rs Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> Signed-off-by: Anil Kulkarni * Update crates/nono-cli/src/profile_runtime.rs Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> Signed-off-by: Anil Kulkarni * PR: Use loops to simplify setting the session_hook data Signed-off-by: Anil Kulkarni * Create failing tests to capture install_as and short-name packages Signed-off-by: Anil Kulkarni * Fix the load_profile_inner case Signed-off-by: Anil Kulkarni * DRY the test code Signed-off-by: Anil Kulkarni * cargo clippy Signed-off-by: Anil Kulkarni --------- Signed-off-by: Anil Kulkarni Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> Co-authored-by: Luke Hinds --- crates/nono-cli/src/hook_runtime.rs | 3 + crates/nono-cli/src/profile/mod.rs | 563 ++++++++++++++++++++++++- crates/nono-cli/src/profile_runtime.rs | 514 ++++++++++++++++++---- 3 files changed, 1002 insertions(+), 78 deletions(-) diff --git a/crates/nono-cli/src/hook_runtime.rs b/crates/nono-cli/src/hook_runtime.rs index 25db1a34c..71764a6d2 100644 --- a/crates/nono-cli/src/hook_runtime.rs +++ b/crates/nono-cli/src/hook_runtime.rs @@ -524,6 +524,7 @@ mod tests { let hook = profile::SessionHook { script: script.clone(), timeout_secs: Some(5), + source_pack: None, }; let result = execute_before_hook(&hook, "test-basic", Path::new("/tmp")).unwrap(); @@ -547,6 +548,7 @@ mod tests { let hook = profile::SessionHook { script, timeout_secs: Some(1), + source_pack: None, }; let start = std::time::Instant::now(); @@ -572,6 +574,7 @@ mod tests { let hook = profile::SessionHook { script, timeout_secs: Some(5), + source_pack: None, }; let result = execute_before_hook(&hook, "test-fail", Path::new("/tmp")); diff --git a/crates/nono-cli/src/profile/mod.rs b/crates/nono-cli/src/profile/mod.rs index d35cb5736..d45c3c948 100644 --- a/crates/nono-cli/src/profile/mod.rs +++ b/crates/nono-cli/src/profile/mod.rs @@ -15,6 +15,8 @@ use std::path::{Path, PathBuf}; // Re-export InjectMode and OAuth2Config from nono-proxy for use in profiles pub use nono_proxy::config::{InjectMode, OAuth2Config}; +use crate::package::PackageRef; + /// Profile metadata #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(deny_unknown_fields)] @@ -1212,6 +1214,14 @@ pub struct SessionHook { /// If absent, no timeout is enforced. #[serde(default, skip_serializing_if = "Option::is_none")] pub timeout_secs: Option, + + /// Internal state to track which pack (from a registry) the session hook originated + /// If set, the value is namespace/pack + /// If absent, the key was set by a local (non-registry based) pack + /// This needs to be tracked per-hook, because a profile extending another profile can override + /// a key. The originating source pack is used to track provenance and $PACK_DIR substitution + #[serde(skip)] + pub(crate) source_pack: Option, } /// Session lifecycle hooks for a profile. @@ -1995,7 +2005,9 @@ fn load_profile_inner(name_or_path: &str) -> Result> { "Loading pack-store profile from: {}", profile_path.display() ); - let mut profile = finalize_profile(load_from_file(&profile_path)?)?; + let mut profile = load_from_file(&profile_path)?; + resolve_store_pack_session_hooks(&mut profile, &pack_key)?; + let mut profile = finalize_profile(profile)?; // Inject the source pack ref so it's always present in the // verification list, even if the profile JSON doesn't declare it. if !profile.packs.contains(&pack_key) { @@ -2171,6 +2183,31 @@ pub(crate) fn is_file_path_ref(s: &str) -> bool { !is_registry_ref(s) && (s.contains('/') || s.ends_with(".json") || s.ends_with(".jsonc")) } +/// Stamp store provenance onto a profile's session hooks, and expand $PACK_DIR vars +/// This function adds provenance data for the session_hooks, so that as any profiles are extended, +/// the origination of the session hook can be traced back to the registry pack that added it +/// Additionally, if the hook path starts with $PACK_DIR, the prefix is replaced with the full path to the file +fn resolve_store_pack_session_hooks(profile: &mut Profile, pack_key: &str) -> Result<()> { + let pack_ref = crate::package::parse_package_ref(pack_key)?; + let install_dir = crate::package::package_install_dir(&pack_ref.namespace, &pack_ref.name)?; + + for hook in [ + &mut profile.session_hooks.before, + &mut profile.session_hooks.after, + ] + .into_iter() + .flatten() + { + if hook.source_pack.is_none() { + hook.source_pack = Some(pack_ref.clone()); + if let Ok(rest) = hook.script.strip_prefix("$PACK_DIR") { + hook.script = install_dir.join(rest); + } + } + } + Ok(()) +} + /// Load a profile from a registry pack. If the pack isn't installed locally, /// pull it first (Docker-style auto-pull with Sigstore verification). fn load_registry_profile(name_or_path: &str) -> Result { @@ -2228,7 +2265,9 @@ fn load_registry_profile(name_or_path: &str) -> Result { .join(format!("{install_name}.json")); if profile_path.exists() { tracing::info!("Loading registry profile from: {}", profile_path.display()); - return finalize_profile(load_from_file(&profile_path)?); + let mut profile = load_from_file(&profile_path)?; + resolve_store_pack_session_hooks(&mut profile, &package_ref.key())?; + return finalize_profile(profile); } } } @@ -2525,8 +2564,9 @@ fn load_base_profile_raw( // doesn't declare its own pack. if let Some((profile_path, pack_key)) = find_pack_store_profile(name) { let mut base = parse_profile_file(&profile_path)?; + resolve_store_pack_session_hooks(&mut base, &pack_key)?; if !base.packs.contains(&pack_key) { - base.packs.push(pack_key); + base.packs.push(pack_key.clone()); } return Ok(ResolvedBase::Global(base)); } @@ -2554,8 +2594,9 @@ fn load_base_profile_raw( if let Some((profile_path, pack_key)) = find_pack_store_profile(name) { let mut base = parse_profile_file(&profile_path)?; if !base.packs.contains(&pack_key) { - base.packs.push(pack_key); + base.packs.push(pack_key.clone()); } + resolve_store_pack_session_hooks(&mut base, &pack_key)?; return Ok(ResolvedBase::Global(base)); } } @@ -4994,10 +5035,12 @@ mod tests { before: Some(SessionHook { script: PathBuf::from("/base/before.sh"), timeout_secs: Some(5), + source_pack: None, }), after: Some(SessionHook { script: PathBuf::from("/base/after.sh"), timeout_secs: None, + source_pack: None, }), }; let mut child = child_profile(); @@ -5005,6 +5048,7 @@ mod tests { before: Some(SessionHook { script: PathBuf::from("/child/before.sh"), timeout_secs: None, + source_pack: None, }), after: None, }; @@ -5027,6 +5071,7 @@ mod tests { before: Some(SessionHook { script: PathBuf::from("/base/before.sh"), timeout_secs: Some(7), + source_pack: None, }), after: None, }; @@ -7089,6 +7134,516 @@ mod tests { ); } + // ============================================================================ + // Registry pack: $PACK_DIR expansion and source_pack assignment + // ============================================================================ + + /// Run `f` inside a temporary directory that is set as `XDG_CONFIG_HOME`. + /// + /// Acquires `ENV_LOCK`, creates a canonicalized temp dir, sets the env var, + /// and calls `f(config_dir)`. The lock and env guard are dropped *after* + /// `f` returns, so the caller can return owned values and assert outside + /// the locked region. + fn with_config_env(f: F) -> R + where + F: FnOnce(&Path) -> R, + { + let _guard = match crate::test_env::ENV_LOCK.lock() { + Ok(g) => g, + Err(p) => p.into_inner(), + }; + let tmp = tempdir().expect("tmpdir"); + let config_dir = tmp.path().canonicalize().expect("canonicalize"); + let _env = crate::test_env::EnvVarGuard::set_all(&[( + "XDG_CONFIG_HOME", + config_dir.to_str().expect("utf8"), + )]); + f(&config_dir) + } + + /// Build a minimal pack store under `config_dir` (used as XDG_CONFIG_HOME). + /// + /// Creates: + /// /nono/packages///package.json + /// /nono/packages///profiles/.json + /// /nono/packages///hooks/ (empty, for path validity) + /// + /// Returns the install directory path. + fn build_fake_pack_store( + config_dir: &Path, + ns: &str, + pack_name: &str, + install_as: &str, + profile_json: &str, + hook_file: Option<&str>, + ) -> PathBuf { + let install_dir = config_dir + .join("nono") + .join("packages") + .join(ns) + .join(pack_name); + std::fs::create_dir_all(install_dir.join("profiles")).expect("create profiles dir"); + if let Some(hf) = hook_file { + let hooks_dir = install_dir.join("hooks"); + std::fs::create_dir_all(&hooks_dir).expect("create hooks dir"); + std::fs::write(hooks_dir.join(hf), "#!/bin/sh\n").expect("write hook file"); + } + // Write package.json manifest + let manifest = format!( + r#"{{ + "schema_version": 1, + "name": "{pack_name}", + "artifacts": [{{ + "type": "profile", + "path": "profiles/{install_as}.json", + "install_as": "{install_as}" + }}] + }}"# + ); + std::fs::write(install_dir.join("package.json"), &manifest).expect("write package.json"); + // Write profile JSON + std::fs::write( + install_dir + .join("profiles") + .join(format!("{install_as}.json")), + profile_json, + ) + .expect("write profile json"); + install_dir + } + + /// Test 1: A hook script starting with `$PACK_DIR` in a registry-pack profile + /// is expanded to the full path `/`. + #[test] + fn test_registry_pack_pack_dir_in_hook_script_is_expanded() { + let (profile, install_dir) = with_config_env(|config_dir| { + let install_dir = build_fake_pack_store( + config_dir, + "test-ns", + "mypk", + "mypk", + r#"{ + "meta": { "name": "mypk" }, + "session_hooks": { + "before": { "script": "$PACK_DIR/hooks/setup.sh" }, + "after": { "script": "$PACK_DIR/hooks/teardown.sh" } + } + }"#, + None, + ); + let profile = load_profile("test-ns/mypk").expect("load registry profile"); + (profile, install_dir) + }); // lock released before assertions + + let before = profile.session_hooks.before.as_ref().expect("before hook"); + assert_eq!( + before.script, + install_dir.join("hooks").join("setup.sh"), + "before hook $PACK_DIR should be expanded to install_dir" + ); + + let after = profile.session_hooks.after.as_ref().expect("after hook"); + assert_eq!( + after.script, + install_dir.join("hooks").join("teardown.sh"), + "after hook $PACK_DIR should be expanded to install_dir" + ); + } + + /// Test 2: Both before and after hooks in a registry-pack profile have + /// `source_pack` set to `/`, even when the script does + /// not contain `$PACK_DIR`. + #[test] + fn test_registry_pack_hooks_have_source_pack_set() { + let profile = with_config_env(|config_dir| { + build_fake_pack_store( + config_dir, + "acme", + "widget", + "widget", + r#"{ + "meta": { "name": "widget" }, + "session_hooks": { + "before": { "script": "/absolute/path/before.sh" }, + "after": { "script": "/absolute/path/after.sh" } + } + }"#, + None, + ); + load_profile("acme/widget").expect("load registry profile") + }); // lock released before assertions + + let before = profile.session_hooks.before.as_ref().expect("before hook"); + assert_eq!( + before.source_pack.as_ref().map(PackageRef::key).as_deref(), + Some("acme/widget"), + "before hook source_pack should be set to the registry pack key" + ); + + let after = profile.session_hooks.after.as_ref().expect("after hook"); + assert_eq!( + after.source_pack.as_ref().map(PackageRef::key).as_deref(), + Some("acme/widget"), + "after hook source_pack should be set to the registry pack key" + ); + } + + /// Test 3: A profile loaded from a plain file path (non-registry) does NOT + /// set `source_pack` on either hook — `source_pack` is `None` for both. + #[test] + fn test_non_registry_pack_hooks_have_no_source_pack() { + let dir = tempdir().expect("tmpdir"); + let profile_path = dir.path().join("local.json"); + std::fs::write( + &profile_path, + r#"{ + "meta": { "name": "local" }, + "session_hooks": { + "before": { "script": "/usr/local/bin/setup.sh" }, + "after": { "script": "/usr/local/bin/cleanup.sh" } + } + }"#, + ) + .expect("write profile"); + + let profile = load_profile_from_path(&profile_path).expect("load profile from path"); + + let before = profile.session_hooks.before.as_ref().expect("before hook"); + assert!( + before.source_pack.is_none(), + "non-registry before hook must not have source_pack set" + ); + + let after = profile.session_hooks.after.as_ref().expect("after hook"); + assert!( + after.source_pack.is_none(), + "non-registry after hook must not have source_pack set" + ); + } + + /// Test: a local profile that `extends: "acme/widget"` (a registry pack). + /// + /// acme/widget has only a `before` hook (uses `$PACK_DIR`). + /// The local profile has only an `after` hook (plain absolute path). + /// + /// After resolution the merged profile must satisfy: + /// 1. `before` — inherited from acme/widget, `$PACK_DIR` expanded to the + /// pack install directory, `source_pack` == `"acme/widget"`. + /// 2. `after` — from the local profile, script unchanged (no `$PACK_DIR` + /// expansion), `source_pack` is `None`. + #[test] + fn test_extends_registry_pack_before_hook_pack_dir_and_source_pack_propagate() { + let (profile, install_dir) = with_config_env(|config_dir| { + // Install acme/widget into the fake pack store. + // It has a before hook using $PACK_DIR; no after hook. + let install_dir = build_fake_pack_store( + config_dir, + "acme", + "widget", + "widget", + r#"{ + "meta": { "name": "widget" }, + "session_hooks": { + "before": { "script": "$PACK_DIR/hooks/setup.sh" } + } + }"#, + None, + ); + + // Write the local profile that extends acme/widget and adds an after hook. + // The after script is a plain absolute path — no $PACK_DIR — to confirm + // that local profiles don't receive any $PACK_DIR expansion. + let local_profile_path = config_dir.join("local.json"); + std::fs::write( + &local_profile_path, + r#"{ + "meta": { "name": "local" }, + "extends": "acme/widget", + "session_hooks": { + "after": { "script": "/local/hooks/teardown.sh" } + } + }"#, + ) + .expect("write local profile"); + + let profile = load_profile_from_path(&local_profile_path).expect("load local profile"); + (profile, install_dir) + }); // lock released before assertions + + // 1. before hook: inherited from acme/widget + // - script: $PACK_DIR expanded to the pack install directory + // - source_pack: "acme/widget" + let before = profile + .session_hooks + .before + .as_ref() + .expect("before hook should be inherited from acme/widget"); + assert_eq!( + before.script, + install_dir.join("hooks").join("setup.sh"), + "before hook $PACK_DIR must be expanded to the pack install directory" + ); + assert_eq!( + before.source_pack.as_ref().map(PackageRef::key).as_deref(), + Some("acme/widget"), + "before hook source_pack must be set to the registry pack key" + ); + + // 2. after hook: defined locally + // - script: unchanged absolute path (no $PACK_DIR substitution) + // - source_pack: None (local profile, not a registry pack) + let after = profile + .session_hooks + .after + .as_ref() + .expect("after hook should come from the local profile"); + assert_eq!( + after.script, + PathBuf::from("/local/hooks/teardown.sh"), + "after hook script must be the local path unchanged" + ); + assert!( + after.source_pack.is_none(), + "after hook source_pack must be None for a local (non-registry) profile" + ); + } + + /// Test: a store pack (`acme/top`) that `extends: "acme/base"` (another store pack). + /// + /// `acme/base` has only a `before` hook (uses `$PACK_DIR`). + /// `acme/top` has only an `after` hook (uses `$PACK_DIR`). + /// + /// After loading `acme/top` the merged profile must satisfy: + /// 1. `before` — inherited from `acme/base`, `$PACK_DIR` expanded to base's + /// install directory, `source_pack` == `"acme/base"`. The `is_none()` + /// guard in `resolve_pack_store_session_hooks` must prevent `acme/top`'s + /// post-merge call from overwriting the already-correct provenance. + /// 2. `after` — defined directly in `acme/top`, `$PACK_DIR` expanded to + /// top's install directory, `source_pack` == `"acme/top"`. + #[test] + fn test_store_pack_extends_store_pack_hooks_carry_correct_source_pack() { + let (profile, base_install_dir, top_install_dir) = with_config_env(|config_dir| { + // acme/base: before hook only, uses $PACK_DIR + let base_install_dir = build_fake_pack_store( + config_dir, + "acme", + "base", + "base", + r#"{ + "meta": { "name": "base" }, + "session_hooks": { + "before": { "script": "$PACK_DIR/hooks/setup.sh" } + } + }"#, + None, + ); + + // acme/top: extends acme/base, after hook only, uses $PACK_DIR + let top_install_dir = build_fake_pack_store( + config_dir, + "acme", + "top", + "top", + r#"{ + "meta": { "name": "top" }, + "extends": "acme/base", + "session_hooks": { + "after": { "script": "$PACK_DIR/hooks/teardown.sh" } + } + }"#, + None, + ); + + let profile = load_profile("acme/top").expect("load acme/top"); + (profile, base_install_dir, top_install_dir) + }); // lock released before assertions + + // 1. before hook: inherited from acme/base + // - $PACK_DIR expanded to base's install dir (not top's) + // - source_pack: "acme/base" (not overwritten by acme/top) + let before = profile + .session_hooks + .before + .as_ref() + .expect("before hook should be inherited from acme/base"); + assert_eq!( + before.script, + base_install_dir.join("hooks").join("setup.sh"), + "before hook $PACK_DIR must expand to acme/base's install dir, not acme/top's" + ); + assert_eq!( + before.source_pack.as_ref().map(PackageRef::key).as_deref(), + Some("acme/base"), + "before hook source_pack must be acme/base, not overwritten by acme/top" + ); + + // 2. after hook: defined in acme/top + // - $PACK_DIR expanded to top's install dir + // - source_pack: "acme/top" + let after = profile + .session_hooks + .after + .as_ref() + .expect("after hook should come from acme/top"); + assert_eq!( + after.script, + top_install_dir.join("hooks").join("teardown.sh"), + "after hook $PACK_DIR must expand to acme/top's install dir" + ); + assert_eq!( + after.source_pack.as_ref().map(PackageRef::key).as_deref(), + Some("acme/top"), + "after hook source_pack must be acme/top" + ); + } + + /// Only the exact token `$PACK_DIR` followed by `/` is expanded. Variants + /// that are missing the separator, use all-lowercase, or use mixed case must + /// all be passed through as literal strings so authors can see and fix the + /// mistake at runtime rather than silently getting a wrong path. + #[test] + fn test_pack_dir_non_canonical_variants_are_not_expanded() { + for (label, script) in [ + ("no slash separator", "$PACK_DIRmyscript.sh"), + ("lowercase", "$pack_dir/hooks/setup.sh"), + ("mixed case", "$Pack_Dir/hooks/setup.sh"), + ] { + let profile = with_config_env(|config_dir| { + build_fake_pack_store( + config_dir, + "acme", + "widget", + "default", + &format!( + r#"{{ + "meta": {{ "name": "widget" }}, + "session_hooks": {{ + "before": {{ "script": "{script}" }} + }} + }}"# + ), + None, + ); + load_profile("acme/widget").expect("load acme/widget") + }); + + let before = profile + .session_hooks + .before + .as_ref() + .expect("before hook must be present"); + assert_eq!( + before.script.to_str().expect("utf8"), + script, + "{label}: non-canonical $PACK_DIR variant must not be expanded" + ); + } + } + + /// Loading a pack-store profile by its `install_as` short name (e.g. "claude-code") + /// must both expand `$PACK_DIR` in session hook scripts and stamp `source_pack` + /// provenance, just as loading by the full registry ref (`namespace/pack`) does. + /// + /// The code path under test is the `find_pack_store_profile` branch inside + /// `load_profile_inner`, reached when: + /// 1. The name contains no `/` → `is_registry_ref` is false + /// 2. The name contains no ext → `is_file_path_ref` is false + /// 3. No user-profile file exists → `resolve_user_profile_path` returns None + /// 4. A pack is installed whose manifest declares `install_as == name` + /// + /// This is exactly the user scenario for `--profile claude-code` or + /// `--profile widget` when those names are shipped by a registry pack. + /// + /// Two cases are verified in a loop: + /// - hooks using `$PACK_DIR` (expansion + provenance) + /// - hooks using absolute paths (provenance only — no `$PACK_DIR` expansion) + #[test] + fn test_pack_store_short_name_expands_pack_dir_and_sets_source_pack() { + // Case 1: $PACK_DIR hooks — expansion AND source_pack. + let (profile, install_dir) = with_config_env(|config_dir| { + // The pack registry key is "acme/my-tools" but its profile artifact + // is installed under the short name "claude-code". Loading by + // "claude-code" (no slash) bypasses load_registry_profile entirely + // and hits the find_pack_store_profile slow-path scanner in + // load_profile_inner. + let install_dir = build_fake_pack_store( + config_dir, + "acme", + "my-tools", + "claude-code", + r#"{ + "meta": { "name": "claude-code" }, + "session_hooks": { + "before": { "script": "$PACK_DIR/hooks/setup.sh" }, + "after": { "script": "$PACK_DIR/hooks/teardown.sh" } + } + }"#, + None, + ); + + // Load by the short install_as name, NOT the registry ref. + let profile = load_profile("claude-code").expect("load by short name"); + (profile, install_dir) + }); // lock released before assertions + + let before = profile.session_hooks.before.as_ref().expect("before hook"); + assert_eq!( + before.script, + install_dir.join("hooks").join("setup.sh"), + "before hook $PACK_DIR must be expanded when loading by install_as short name" + ); + assert_eq!( + before.source_pack.as_ref().map(PackageRef::key).as_deref(), + Some("acme/my-tools"), + "before hook source_pack must be set when loading by install_as short name" + ); + + let after = profile.session_hooks.after.as_ref().expect("after hook"); + assert_eq!( + after.script, + install_dir.join("hooks").join("teardown.sh"), + "after hook $PACK_DIR must be expanded when loading by install_as short name" + ); + assert_eq!( + after.source_pack.as_ref().map(PackageRef::key).as_deref(), + Some("acme/my-tools"), + "after hook source_pack must be set when loading by install_as short name" + ); + + // Case 2: absolute-path hooks — source_pack set even when strip_prefix("$PACK_DIR") + // returns Err (i.e. the expansion branch is not taken). + let profile = with_config_env(|config_dir| { + build_fake_pack_store( + config_dir, + "acme", + "widget-pack", + "widget", + r#"{ + "meta": { "name": "widget" }, + "session_hooks": { + "before": { "script": "/absolute/before.sh" }, + "after": { "script": "/absolute/after.sh" } + } + }"#, + None, + ); + load_profile("widget").expect("load by short name") + }); + + let before = profile.session_hooks.before.as_ref().expect("before hook"); + assert_eq!( + before.source_pack.as_ref().map(PackageRef::key).as_deref(), + Some("acme/widget-pack"), + "absolute-path before hook must still have source_pack set" + ); + let after = profile.session_hooks.after.as_ref().expect("after hook"); + assert_eq!( + after.source_pack.as_ref().map(PackageRef::key).as_deref(), + Some("acme/widget-pack"), + "absolute-path after hook must still have source_pack set" + ); + } + #[test] fn profile_binary_field_parses_and_inherits() { let base = br#"{ diff --git a/crates/nono-cli/src/profile_runtime.rs b/crates/nono-cli/src/profile_runtime.rs index 8eae2c66b..5aea04a49 100644 --- a/crates/nono-cli/src/profile_runtime.rs +++ b/crates/nono-cli/src/profile_runtime.rs @@ -71,7 +71,24 @@ fn install_profile_hooks(_profile_name: Option<&str>, profile: &profile::Profile /// 1. Check the pack directory exists /// 2. Verify artifact SHA-256 digests against the lockfile /// 3. Re-verify Sigstore bundles from the stored `.nono-trust.bundle` file -fn verify_profile_packs(packs: &[String]) -> crate::Result<()> { +fn verify_profile_packs(packs: &[String], profile: &profile::Profile) -> crate::Result<()> { + if let Some(hook) = [&profile.session_hooks.before, &profile.session_hooks.after] + .into_iter() + .flatten() + .find(|hook| { + hook.source_pack + .as_ref() + .is_some_and(|sp| !packs.contains(&sp.key())) + }) + { + // This indicates an internal logic error where the Profile was parsed, but the source_pack + // the session hook references is not present in packs_to check + return Err(nono::NonoError::PackageInstall(format!( + "session_hook {} unexpectedly is not part of the packs to verify", + hook.script.display() + ))); + } + if packs.is_empty() { return Ok(()); } @@ -138,6 +155,37 @@ fn verify_profile_packs(packs: &[String]) -> crate::Result<()> { pack_ref, artifact_name, locked_artifact.sha256, hash, pack_ref ))); } + for script_path in [&profile.session_hooks.before, &profile.session_hooks.after] + .into_iter() + .flatten() + .filter(|hook| { + hook.source_pack + .as_ref() + .is_some_and(|sp| sp.key() == *pack_ref) + }) + .map(|hook| hook.script.as_path()) + { + let relative_path = script_path + .strip_prefix(&install_dir) + .map_err(|_| { + nono::NonoError::PackageInstall(format!( + "session_hook with path {} is not within the pack", + script_path.display() + )) + })? + .to_str() + .ok_or_else(|| { + nono::NonoError::PackageInstall( + "Invalid script_path characters".to_string(), + ) + })?; + if !locked_pkg.artifacts.contains_key(relative_path) { + return Err(nono::NonoError::PackageInstall(format!( + "session_hook with path {} is not a declared artifact in the pack lockfile", + script_path.display() + ))); + } + } } let bundle_path = install_dir.join(".nono-trust.bundle"); @@ -524,7 +572,7 @@ fn prepare_profile_with_options( ); } } else { - verify_profile_packs(&packs_to_verify)?; + verify_profile_packs(&packs_to_verify, &profile)?; if !packs_to_verify.is_empty() && !options.hook_output_silent { eprintln!(" Verified {} pack(s)", packs_to_verify.len()); @@ -702,30 +750,36 @@ pub(crate) fn prepare_profile_for_preflight( #[cfg(test)] mod tests { use super::*; + use profile::{SessionHook, SessionHooks}; + use sha2::{Digest, Sha256}; + use std::collections::BTreeMap; use std::fs; use tempfile::tempdir; + // ------------------------------------------------------------------------- + // Test helpers + // ------------------------------------------------------------------------- + + /// Run `f` inside a temporary directory that is set as `XDG_CONFIG_HOME`. + /// + /// Acquires `ENV_LOCK`, creates a canonicalized temp dir, sets the env var, + /// and calls `f(config_dir)`. The lock and env guard are dropped *after* + /// `f` returns so the caller can return owned values and assert outside + /// the locked region. fn with_config_env(f: F) -> R where - F: FnOnce(&Path) -> R, + F: FnOnce(&std::path::Path) -> R, { let _guard = match crate::test_env::ENV_LOCK.lock() { - Ok(guard) => guard, - Err(poisoned) => poisoned.into_inner(), - }; - let tmp = match tempdir() { - Ok(dir) => dir, - Err(err) => panic!("failed to create tempdir: {err}"), - }; - let config_dir = match tmp.path().canonicalize() { - Ok(path) => path, - Err(err) => panic!("failed to canonicalize tempdir: {err}"), + Ok(g) => g, + Err(p) => p.into_inner(), }; - let config_str = match config_dir.to_str() { - Some(value) => value, - None => panic!("tempdir path is not valid UTF-8"), - }; - let _env = crate::test_env::EnvVarGuard::set_all(&[("XDG_CONFIG_HOME", config_str)]); + let tmp = tempdir().expect("tmpdir"); + let config_dir = tmp.path().canonicalize().expect("canonicalize"); + let _env = crate::test_env::EnvVarGuard::set_all(&[( + "XDG_CONFIG_HOME", + config_dir.to_str().expect("utf8"), + )]); f(&config_dir) } @@ -770,70 +824,372 @@ mod tests { assert!(result.is_none()); } - fn create_pack_dir(config_dir: &Path, namespace: &str, name: &str) -> PathBuf { + /// Build a minimal pack on disk under `/nono/packages///` + /// and return the install directory. + /// + /// `scripts` is a list of `(relative_path, content)` pairs. Each file is + /// written under the install directory and its SHA-256 is recorded in the + /// returned `BTreeMap` so the caller can + /// incorporate it into a lockfile entry. + fn build_pack_with_scripts( + config_dir: &std::path::Path, + ns: &str, + pack_name: &str, + scripts: &[(&str, &str)], + ) -> (PathBuf, BTreeMap) { let install_dir = config_dir .join("nono") .join("packages") - .join(namespace) - .join(name); - if let Err(err) = fs::create_dir_all(&install_dir) { - panic!("failed to create pack dir: {err}"); + .join(ns) + .join(pack_name); + + fs::create_dir_all(&install_dir).expect("create install dir"); + + let mut artifacts: BTreeMap = BTreeMap::new(); + + for (rel_path, content) in scripts { + let full_path = install_dir.join(rel_path); + if let Some(parent) = full_path.parent() { + fs::create_dir_all(parent).expect("create script dir"); + } + fs::write(&full_path, content.as_bytes()).expect("write script"); + + let digest = Sha256::digest(content.as_bytes()); + let sha256 = digest + .iter() + .map(|b| format!("{b:02x}")) + .collect::(); + + artifacts.insert( + rel_path.to_string(), + package::LockedArtifact { + sha256, + artifact_type: package::ArtifactType::Profile, + }, + ); + } + + (install_dir, artifacts) + } + + /// Write a lockfile at `/nono/packages/lockfile.json` containing + /// the given entries. Merges with any existing lockfile so multiple packs + /// can be added across calls. + fn write_test_lockfile( + config_dir: &std::path::Path, + entries: &[(&str, BTreeMap)], + ) { + let lockfile_path = config_dir + .join("nono") + .join("packages") + .join("lockfile.json"); + fs::create_dir_all(lockfile_path.parent().expect("parent")).expect("create packages dir"); + + let mut lockfile = if lockfile_path.exists() { + let content = fs::read_to_string(&lockfile_path).expect("read lockfile"); + serde_json::from_str::(&content).expect("parse lockfile") + } else { + package::Lockfile { + lockfile_version: package::LOCKFILE_VERSION, + registry: String::new(), + packages: BTreeMap::new(), + } + }; + + for (pack_ref, artifacts) in entries { + let pkg = package::LockedPackage { + artifacts: artifacts.clone(), + ..package::LockedPackage::default() + }; + lockfile.packages.insert(pack_ref.to_string(), pkg); } - install_dir + + let json = serde_json::to_string_pretty(&lockfile).expect("serialize lockfile"); + fs::write(&lockfile_path, format!("{json}\n")).expect("write lockfile"); } - fn write_lockfile_with_artifact(pack_ref: &str, artifact_name: &str, artifact_bytes: &[u8]) { - let digest = Sha256::digest(artifact_bytes); - let sha256 = digest - .iter() - .map(|b| format!("{b:02x}")) - .collect::(); - - let mut artifacts = std::collections::BTreeMap::new(); - artifacts.insert( - artifact_name.to_string(), - package::LockedArtifact { - sha256, - artifact_type: package::ArtifactType::Profile, - }, - ); + /// Construct a `SessionHook` with the given script path and optional + /// `source_pack`. Used to build `SessionHooks` directly in tests without + /// going through profile loading. + fn make_hook(script: PathBuf, source_pack: Option<&str>) -> SessionHook { + SessionHook { + script, + timeout_secs: None, + source_pack: source_pack + .map(|s| crate::package::parse_package_ref(s).expect("valid pack ref in test")), + } + } + + // ------------------------------------------------------------------------- + // Test 0: source_pack is set but not present in the packs list + // + // This guards against a future regression where a call site of + // resolve_store_pack_session_hooks forgets to push the pack key into + // profile.packs. verify_profile_packs must catch this and hard-error + // rather than silently skipping the containment check. + // ------------------------------------------------------------------------- + #[test] + fn test_verify_source_pack_not_in_packs_list_is_an_error() { + // No env/disk setup needed: the guard fires before the lockfile is read. + let hooks = SessionHooks { + before: Some(make_hook( + PathBuf::from("/some/path/script.sh"), + Some("acme/widget"), // source_pack set … + )), + after: None, + }; + let p = profile::Profile { + session_hooks: hooks, + ..profile::Profile::default() + }; - let mut packages = std::collections::BTreeMap::new(); - packages.insert( - pack_ref.to_string(), - package::LockedPackage { - version: "1.0.0".to_string(), - installed_at: "2026-01-01T00:00:00Z".to_string(), - pinned: false, - provenance: Some(package::PackageProvenance { - signer_identity: "https://github.com/acme/repo/.github/workflows/release.yml@refs/tags/v1.0.0" - .to_string(), - repository: "acme/repo".to_string(), - workflow: ".github/workflows/release.yml".to_string(), - git_ref: "refs/tags/v1.0.0".to_string(), - rekor_log_index: 1, - signed_at: "2026-01-01T00:00:00Z".to_string(), - }), - artifacts, - wiring_record: Vec::new(), - }, + // … but "acme/widget" is absent from the packs list. + let result = verify_profile_packs(&[], &p); + + assert!( + result.is_err(), + "source_pack not in packs list must be a hard error" + ); + let err = result.expect_err("expected an error from verify_profile_packs"); + let msg = err.to_string(); + assert!( + msg.contains("/some/path/script.sh"), + "error must reference the offending hook script: {msg}" ); + } - let lockfile = package::Lockfile { - lockfile_version: package::LOCKFILE_VERSION, - registry: "https://registry.example.test".to_string(), - packages, + // ------------------------------------------------------------------------- + // Test 1: local profile with an absolute-path hook + // + // A local (non-store) profile has source_pack = None on its hooks. + // packs_to_verify is empty so verify_profile_packs returns Ok(()) immediately + // without reading the lockfile. The hook is never checked — this is + // intentional: local hooks are validated at execution time by + // validate_hook_script, not here. + // ------------------------------------------------------------------------- + #[test] + fn test_verify_local_profile_hook_not_checked() { + // No env/disk setup needed: packs_to_verify is empty so + // verify_profile_packs returns Ok(()) before reading anything from disk. + let hooks = SessionHooks { + before: Some(make_hook( + PathBuf::from("/usr/local/bin/my-setup.sh"), + None, // source_pack = None → local hook + )), + after: None, }; - if let Err(err) = package::write_lockfile(&lockfile) { - panic!("failed to write lockfile: {err}"); - } + let p = profile::Profile { + session_hooks: hooks, + ..profile::Profile::default() + }; + + assert!( + verify_profile_packs(&[], &p).is_ok(), + "local profile hooks must not be checked by verify_profile_packs" + ); + } + + // ------------------------------------------------------------------------- + // Test 2: store pack whose hook script is a declared, locked artifact + // + // $PACK_DIR/scripts/before.sh was expanded at load time and appears in the + // lockfile artifacts. The hook-containment check must pass: the only + // remaining error is the absent trust bundle (a later, independent step). + // ------------------------------------------------------------------------- + #[test] + fn test_verify_store_pack_hook_in_artifacts_passes() { + let result = with_config_env(|config_dir| { + let (install_dir, artifacts) = build_pack_with_scripts( + config_dir, + "acme", + "widget", + &[("scripts/before.sh", "#!/bin/sh\necho before\n")], + ); + write_test_lockfile(config_dir, &[("acme/widget", artifacts)]); + + let hooks = SessionHooks { + before: Some(make_hook( + install_dir.join("scripts/before.sh"), + Some("acme/widget"), + )), + after: None, + }; + let p = profile::Profile { + session_hooks: hooks, + ..profile::Profile::default() + }; + verify_profile_packs(&["acme/widget".to_string()], &p) + }); + + // Artifact + hook containment passed; the only remaining blocker is the + // missing trust bundle (tested separately in test 7). + assert!( + matches!(result, Err(ref e) if e.to_string().contains(".nono-trust.bundle")), + "expected only a missing-trust-bundle error after hook containment passed, got: {result:?}" + ); + } + + // ------------------------------------------------------------------------- + // Test 3: store pack hook script exists on disk but is NOT in artifacts + // + // An attacker (or a mistaken author) places a script inside the pack + // directory that was never declared in the lockfile. verify_profile_packs + // must reject this with an error. + // ------------------------------------------------------------------------- + #[test] + fn test_verify_store_pack_hook_not_in_artifacts_fails() { + let result = with_config_env(|config_dir| { + // Lockfile only declares "scripts/real.sh"; the profile hook + // points at "scripts/non-existing.sh" which is not locked. + let (install_dir, artifacts) = build_pack_with_scripts( + config_dir, + "acme", + "widget", + &[("scripts/real.sh", "#!/bin/sh\necho real\n")], + ); + // Also write the unlocked file on disk to confirm presence alone + // is not sufficient for the check to pass. + let unlocked = install_dir.join("scripts/non-existing.sh"); + fs::write(&unlocked, "#!/bin/sh\necho unlocked\n").expect("write unlocked"); + + write_test_lockfile(config_dir, &[("acme/widget", artifacts)]); + + let hooks = SessionHooks { + before: Some(make_hook( + install_dir.join("scripts/non-existing.sh"), + Some("acme/widget"), + )), + after: None, + }; + let p = profile::Profile { + session_hooks: hooks, + ..profile::Profile::default() + }; + verify_profile_packs(&["acme/widget".to_string()], &p) + }); + + assert!( + result.is_err(), + "hook script not in lockfile artifacts must be rejected" + ); + } + + // ------------------------------------------------------------------------- + // Test 4: store-extends-store — each hook from its own pack's artifacts + // + // acme/base provides the before hook; acme/top provides the after hook. + // Both scripts are in their respective packs' lockfile artifacts. + // The hook-containment check must pass for both packs: the only remaining + // error is the absent trust bundle (a later, independent step). + // ------------------------------------------------------------------------- + #[test] + fn test_verify_store_extends_store_hooks_in_correct_packs_passes() { + let result = with_config_env(|config_dir| { + let (base_install_dir, base_artifacts) = build_pack_with_scripts( + config_dir, + "acme", + "base", + &[("hooks/setup.sh", "#!/bin/sh\necho setup\n")], + ); + let (top_install_dir, top_artifacts) = build_pack_with_scripts( + config_dir, + "acme", + "top", + &[("hooks/teardown.sh", "#!/bin/sh\necho teardown\n")], + ); + write_test_lockfile( + config_dir, + &[("acme/base", base_artifacts), ("acme/top", top_artifacts)], + ); + + let hooks = SessionHooks { + before: Some(make_hook( + base_install_dir.join("hooks/setup.sh"), + Some("acme/base"), + )), + after: Some(make_hook( + top_install_dir.join("hooks/teardown.sh"), + Some("acme/top"), + )), + }; + let p = profile::Profile { + session_hooks: hooks, + ..profile::Profile::default() + }; + verify_profile_packs(&["acme/base".to_string(), "acme/top".to_string()], &p) + }); + + // Artifact + hook containment passed for both packs; the only remaining + // blocker is the missing trust bundle (tested separately in test 7). + assert!( + matches!(result, Err(ref e) if e.to_string().contains(".nono-trust.bundle")), + "expected only a missing-trust-bundle error after hook containment passed, got: {result:?}" + ); + } + + // ------------------------------------------------------------------------- + // Test 5: pack confusion — hook source_pack does not match the pack that + // owns the script on disk + // + // The before hook has source_pack = "acme/top" but its script path lives + // inside acme/base's install directory (i.e. not in acme/top's artifacts). + // verify_profile_packs must reject this. + // ------------------------------------------------------------------------- + #[test] + fn test_verify_store_extends_store_pack_confusion_fails() { + let result = with_config_env(|config_dir| { + let (base_install_dir, base_artifacts) = build_pack_with_scripts( + config_dir, + "acme", + "base", + &[("hooks/setup.sh", "#!/bin/sh\necho setup\n")], + ); + let (_top_install_dir, top_artifacts) = build_pack_with_scripts( + config_dir, + "acme", + "top", + &[("hooks/teardown.sh", "#!/bin/sh\necho teardown\n")], + ); + write_test_lockfile( + config_dir, + &[("acme/base", base_artifacts), ("acme/top", top_artifacts)], + ); + + // Confusion: the script lives in acme/base but source_pack claims + // acme/top. acme/top's artifacts do not include hooks/setup.sh. + let hooks = SessionHooks { + before: Some(make_hook( + base_install_dir.join("hooks/setup.sh"), + Some("acme/top"), // wrong pack + )), + after: None, + }; + let p = profile::Profile { + session_hooks: hooks, + ..profile::Profile::default() + }; + verify_profile_packs(&["acme/base".to_string(), "acme/top".to_string()], &p) + }); + + assert!( + result.is_err(), + "hook script not in the claimed pack's artifacts must be rejected" + ); } + // ------------------------------------------------------------------------- + // Test 6: installed pack with no lockfile entry must be rejected + // + // If a pack directory exists on disk but there is no corresponding entry in + // the lockfile, verify_profile_packs must return an error rather than + // silently treating the pack as uninstalled. + // ------------------------------------------------------------------------- #[test] fn verify_profile_packs_requires_lockfile_entry_for_installed_pack() { let result = with_config_env(|config_dir| { - create_pack_dir(config_dir, "acme", "widget"); - verify_profile_packs(&["acme/widget".to_string()]) + // Create the pack directory without writing any lockfile entry. + let (_, _empty_artifacts) = build_pack_with_scripts(config_dir, "acme", "widget", &[]); + verify_profile_packs(&["acme/widget".to_string()], &profile::Profile::default()) }); let err = match result { @@ -846,17 +1202,27 @@ mod tests { ); } + // ------------------------------------------------------------------------- + // Test 7: pack with a lockfile entry but no trust bundle must be rejected + // + // Artifact digest verification passes (the file is on disk and its hash + // matches the lockfile), but the absence of `.nono-trust.bundle` means the + // Sigstore provenance chain cannot be re-verified — this must be a hard + // error. + // ------------------------------------------------------------------------- #[test] fn verify_profile_packs_requires_trust_bundle_for_locked_pack() { let result = with_config_env(|config_dir| { - let install_dir = create_pack_dir(config_dir, "acme", "widget"); - let artifact_bytes = br#"{"meta":{"name":"widget"}}"#; - if let Err(err) = fs::write(install_dir.join("package.json"), artifact_bytes) { - panic!("failed to write package artifact: {err}"); - } - write_lockfile_with_artifact("acme/widget", "package.json", artifact_bytes); + let artifact_content = r#"{"meta":{"name":"widget"}}"#; + let (_, artifacts) = build_pack_with_scripts( + config_dir, + "acme", + "widget", + &[("package.json", artifact_content)], + ); + write_test_lockfile(config_dir, &[("acme/widget", artifacts)]); - verify_profile_packs(&["acme/widget".to_string()]) + verify_profile_packs(&["acme/widget".to_string()], &profile::Profile::default()) }); let err = match result { From 46ff63fa5356693a1807b312405ff6edfa161137 Mon Sep 17 00:00:00 2001 From: Aleks <121458075+SequeI@users.noreply.github.com> Date: Sun, 14 Jun 2026 10:36:57 +0100 Subject: [PATCH 48/91] fix(cli): accept truthy env values for bool flags (#1136) Use BoolishValueParser for --trust-proxy-ca, --trust-override, and --capability-elevation, and wire NONO_TRUST_OVERRIDE. Fixes #1129. Signed-off-by: Aleksy Siek Co-authored-by: Luke Hinds --- crates/nono-cli/src/cli.rs | 65 +++++++++++++++++++++++++++++-- crates/nono-cli/tests/env_vars.rs | 46 ++++++++++++++++++++++ 2 files changed, 108 insertions(+), 3 deletions(-) diff --git a/crates/nono-cli/src/cli.rs b/crates/nono-cli/src/cli.rs index f0830b402..143d25e93 100644 --- a/crates/nono-cli/src/cli.rs +++ b/crates/nono-cli/src/cli.rs @@ -1277,7 +1277,13 @@ pub struct SandboxArgs { /// Add the proxy CA to the macOS user trust store (enables Go CLI tools). /// Shares the CA across sessions via Keychain; regenerates daily. #[cfg(target_os = "macos")] - #[arg(long, env = "NONO_TRUST_PROXY_CA", help_heading = "NETWORK")] + #[arg( + long, + env = "NONO_TRUST_PROXY_CA", + value_parser = clap::builder::BoolishValueParser::new(), + action = clap::ArgAction::SetTrue, + help_heading = "NETWORK" + )] pub trust_proxy_ca: bool, /// Proxy CA certificate validity in days (1–365, default: 1). @@ -1775,7 +1781,13 @@ pub struct RunArgs { pub audit_sign_key: Option, /// Disable trust verification (not recommended for production) - #[arg(long, help_heading = "OPTIONS")] + #[arg( + long, + env = "NONO_TRUST_OVERRIDE", + value_parser = clap::builder::BoolishValueParser::new(), + action = clap::ArgAction::SetTrue, + help_heading = "OPTIONS" + )] pub trust_override: bool, /// Name for this session (shown in `nono ps`) @@ -1786,7 +1798,13 @@ pub struct RunArgs { /// Overrides the profile's capability_elevation setting. /// When enabled, the supervisor can grant access to paths not in the /// initial capability set via interactive prompts. - #[arg(long, env = "NONO_CAPABILITY_ELEVATION", help_heading = "OPTIONS")] + #[arg( + long, + env = "NONO_CAPABILITY_ELEVATION", + value_parser = clap::builder::BoolishValueParser::new(), + action = clap::ArgAction::SetTrue, + help_heading = "OPTIONS" + )] pub capability_elevation: bool, /// Command to run inside the sandbox (optional if profile specifies `binary`) @@ -3561,6 +3579,47 @@ mod tests { } } + #[test] + fn test_capability_elevation_flag_sets_true() { + let cli = Cli::parse_from([ + "nono", + "run", + "--allow", + ".", + "--capability-elevation", + "echo", + ]); + match cli.command { + Commands::Run(args) => { + assert!(args.capability_elevation); + } + _ => panic!("Expected Run command"), + } + } + + #[test] + fn test_trust_override_flag_sets_true() { + let cli = Cli::parse_from(["nono", "run", "--allow", ".", "--trust-override", "echo"]); + match cli.command { + Commands::Run(args) => { + assert!(args.trust_override); + } + _ => panic!("Expected Run command"), + } + } + + #[cfg(target_os = "macos")] + #[test] + fn test_trust_proxy_ca_flag_sets_true() { + let cli = Cli::parse_from(["nono", "run", "--allow", ".", "--trust-proxy-ca", "echo"]); + match cli.command { + Commands::Run(args) => { + assert!(args.sandbox.trust_proxy_ca); + } + _ => panic!("Expected Run command"), + } + } + #[test] fn test_allow_port_parsing() { let cli = Cli::parse_from([ diff --git a/crates/nono-cli/tests/env_vars.rs b/crates/nono-cli/tests/env_vars.rs index 94ea664c8..64c3dd81b 100644 --- a/crates/nono-cli/tests/env_vars.rs +++ b/crates/nono-cli/tests/env_vars.rs @@ -75,6 +75,52 @@ fn env_nono_block_net_accepts_true() { ); } +#[test] +fn env_nono_capability_elevation_accepts_truthy() { + let output = nono_bin() + .env("NONO_CAPABILITY_ELEVATION", "1") + .args(["run", "--allow", "/tmp", "--dry-run", "echo"]) + .output() + .expect("failed to run nono"); + + assert!( + output.status.success(), + "NONO_CAPABILITY_ELEVATION=1 should be accepted, stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); +} + +#[test] +fn env_nono_trust_override_accepts_truthy() { + let output = nono_bin() + .env("NONO_TRUST_OVERRIDE", "1") + .args(["run", "--allow", "/tmp", "--dry-run", "echo"]) + .output() + .expect("failed to run nono"); + + assert!( + output.status.success(), + "NONO_TRUST_OVERRIDE=1 should be accepted, stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); +} + +#[cfg(target_os = "macos")] +#[test] +fn env_nono_trust_proxy_ca_accepts_truthy() { + let output = nono_bin() + .env("NONO_TRUST_PROXY_CA", "1") + .args(["run", "--allow", "/tmp", "--dry-run", "echo"]) + .output() + .expect("failed to run nono"); + + assert!( + output.status.success(), + "NONO_TRUST_PROXY_CA=1 should be accepted, stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); +} + #[test] fn legacy_env_nono_net_block_still_works() { let output = nono_bin() From e6846cd548d84c170520649e8ff8e8c9f220ae78 Mon Sep 17 00:00:00 2001 From: Caio Silva Date: Sun, 14 Jun 2026 19:42:58 +0100 Subject: [PATCH 49/91] fix(linux): trap sendto/sendmsg to prevent AF_UNIX datagram bypass (#1096) * fix(linux): trap sendto/sendmsg to prevent AF_UNIX datagram bypass (#1089) The seccomp BPF filters only trapped connect(2) and bind(2), so a sandboxed process could reach any pathname SOCK_DGRAM socket via sendto(2) or sendmsg(2) with no allowlist check. The unix-socket allowlist was completely bypassed for datagram IPC. Changes: - Add SYS_SENDTO and SYS_SENDMSG syscall constants to the library - Add read_msghdr_dest() helper to extract msg_name/msg_namelen from a child process's struct msghdr (for sendmsg handling) - Update both BPF filters to trap sendto (with args[4] NULL check for connected-socket fast-path) and sendmsg (unconditional USER_NOTIF since BPF cannot dereference msghdr.msg_name) - Add UnixSocketOp::Send variant to represent datagram send operations; covered by existing Connect grants - Update handle_network_notification() to dispatch on syscall type before reading sockaddr (connect/bind: args[1,2], sendto: args[4,5], sendmsg: msghdr indirection with NULL msg_name fast-path) - Update decide_network_notification() to treat sendto/sendmsg same as connect for both AF_UNIX and AF_INET policy - Update denial details: Send suggests --allow-unix-socket - Add tests for new BPF filter structure and sendto/sendmsg decision logic Signed-off-by: Caio Silva * fix(linux): use read_exact and u32 parsing in read_msghdr_dest read_exact guarantees the full 12 byte read instead of relying on a manual short read check. Parse msg_namelen as u32 first then cast to u64 for correct behavior on both endiannesses. Signed-off-by: Caio Silva * fix(linux): close datagram send mediation gaps --------- Signed-off-by: Caio Silva Co-authored-by: Luke Hinds --- .../src/exec_strategy/supervisor_linux.rs | 415 ++++++++++++++++-- crates/nono/src/capability.rs | 47 +- crates/nono/src/sandbox/linux.rs | 351 +++++++++++---- crates/nono/src/sandbox/mod.rs | 9 +- 4 files changed, 684 insertions(+), 138 deletions(-) diff --git a/crates/nono-cli/src/exec_strategy/supervisor_linux.rs b/crates/nono-cli/src/exec_strategy/supervisor_linux.rs index 3f723a31a..3d822da57 100644 --- a/crates/nono-cli/src/exec_strategy/supervisor_linux.rs +++ b/crates/nono-cli/src/exec_strategy/supervisor_linux.rs @@ -572,7 +572,9 @@ pub(super) fn decide_network_notification( sockaddr: &nono::sandbox::SockaddrInfo, config: &SupervisorConfig<'_>, ) -> NetworkDecision { - use nono::sandbox::{SYS_BIND, SYS_CONNECT, UnixSocketKind}; + use nono::sandbox::{ + SYS_BIND, SYS_CONNECT, SYS_SENDMMSG, SYS_SENDMSG, SYS_SENDTO, UnixSocketKind, + }; // AF_UNIX: allow only filesystem-backed (pathname) sockets that match an // explicit socket capability. Abstract/unnamed sockets bypass pathname @@ -612,18 +614,20 @@ pub(super) fn decide_network_notification( } match syscall { - SYS_CONNECT => { - // Allow connect only to loopback + proxy port + SYS_CONNECT | SYS_SENDTO | SYS_SENDMSG | SYS_SENDMMSG => { + // Allow connect/sendto/sendmsg/sendmmsg only to loopback + proxy port. + // sendto/sendmsg/sendmmsg with a destination address is semantically + // equivalent to connect for network reach-out (issue #1089). if sockaddr.is_loopback && sockaddr.port == config.proxy_port { debug!( - "Proxy seccomp: allowing connect to loopback:{}", - sockaddr.port + "Proxy seccomp: allowing network syscall nr={} to loopback:{}", + syscall, sockaddr.port ); NetworkDecision::Allow } else { debug!( - "Proxy seccomp: denying connect to family={} port={} loopback={}", - sockaddr.family, sockaddr.port, sockaddr.is_loopback + "Proxy seccomp: denying network syscall nr={} to family={} port={} loopback={}", + syscall, sockaddr.family, sockaddr.port, sockaddr.is_loopback ); NetworkDecision::Deny } @@ -687,11 +691,12 @@ fn decide_af_unix_pathname( }; let canonical = match op { - UnixSocketOp::Connect => match resolved_path.canonicalize() { + UnixSocketOp::Connect | UnixSocketOp::Send => match resolved_path.canonicalize() { Ok(path) => path, Err(err) => { debug!( - "Proxy seccomp: denying AF_UNIX connect to {}: canonicalize failed: {}", + "Proxy seccomp: denying AF_UNIX {} on {}: canonicalize failed: {}", + op, resolved_path.display(), err ); @@ -739,11 +744,12 @@ fn resolve_af_unix_sockaddr_path( } fn unix_socket_op_for_syscall(syscall: i32) -> Option { - use nono::sandbox::{SYS_BIND, SYS_CONNECT}; + use nono::sandbox::{SYS_BIND, SYS_CONNECT, SYS_SENDMMSG, SYS_SENDMSG, SYS_SENDTO}; match syscall { SYS_CONNECT => Some(UnixSocketOp::Connect), SYS_BIND => Some(UnixSocketOp::Bind), + SYS_SENDTO | SYS_SENDMSG | SYS_SENDMMSG => Some(UnixSocketOp::Send), _ => None, } } @@ -756,7 +762,7 @@ fn unix_socket_allowlist_allows( allowlist.iter().any(|cap| { cap.covers(path) && match op { - UnixSocketOp::Connect => true, + UnixSocketOp::Connect | UnixSocketOp::Send => true, UnixSocketOp::Bind => cap.mode.permits_bind(), } }) @@ -812,7 +818,8 @@ pub(super) fn handle_network_notification( ipc_denials: &mut Vec, ) -> nono::error::Result<()> { use nono::sandbox::{ - continue_notif, deny_notif, notif_id_valid, read_notif_sockaddr, recv_notif, + SYS_BIND, SYS_CONNECT, SYS_SENDMMSG, SYS_SENDMSG, SYS_SENDTO, continue_notif, deny_notif, + notif_id_valid, read_mmsghdr_dests, read_msghdr_dest, read_notif_sockaddr, recv_notif, respond_notif_errno, }; @@ -825,11 +832,135 @@ pub(super) fn handle_network_notification( return Ok(()); } - // Read sockaddr from child's memory: args[1] = sockaddr*, args[2] = addrlen - let sockaddr = match read_notif_sockaddr(notif.pid, notif.data.args[1], notif.data.args[2]) { - Ok(info) => info, - Err(e) => { - debug!("Failed to read sockaddr from seccomp notification: {}", e); + // Read sockaddr from child's memory. The location depends on the syscall: + // connect(fd, sockaddr*, addrlen): args[1] = sockaddr*, args[2] = addrlen + // bind(fd, sockaddr*, addrlen): args[1] = sockaddr*, args[2] = addrlen + // sendto(fd, buf, len, flags, sockaddr*, addrlen): + // args[4] = sockaddr*, args[5] = addrlen + // sendmsg(fd, msghdr*, flags): args[1] = msghdr*; + // sockaddr lives inside msghdr.msg_name / msg_namelen + // sendmmsg(fd, mmsghdr*, vlen, flags): args[1] = mmsghdr*; + // each mmsghdr starts with an msghdr that may carry a destination + let sockaddrs = match notif.data.nr { + SYS_CONNECT | SYS_BIND => { + match read_notif_sockaddr(notif.pid, notif.data.args[1], notif.data.args[2]) { + Ok(info) => vec![info], + Err(e) => { + debug!( + "Failed to read sockaddr from seccomp notification (nr={}): {}", + notif.data.nr, e + ); + let _ = deny_notif(notify_fd, notif.id); + return Ok(()); + } + } + } + SYS_SENDTO => { + // args[4] = dest_addr pointer, args[5] = addrlen + if notif.data.args[4] == 0 { + // Full-width NULL check happens here rather than in BPF. + // No per-call destination is present. + if let Err(e) = continue_notif(notify_fd, notif.id) { + debug!("continue_notif failed for sendto NULL dest_addr: {}", e); + return deny_notif(notify_fd, notif.id); + } + return Ok(()); + } + match read_notif_sockaddr(notif.pid, notif.data.args[4], notif.data.args[5]) { + Ok(info) => vec![info], + Err(e) => { + debug!( + "Failed to read sendto sockaddr from seccomp notification: {}", + e + ); + let _ = deny_notif(notify_fd, notif.id); + return Ok(()); + } + } + } + SYS_SENDMSG => { + // args[1] = msghdr*. Read the msghdr to find msg_name / msg_namelen, + // then read the sockaddr from msg_name. + match read_msghdr_dest(notif.pid, notif.data.args[1]) { + Ok(Some((addr_ptr, addrlen))) => { + match read_notif_sockaddr(notif.pid, addr_ptr, addrlen) { + Ok(info) => vec![info], + Err(e) => { + debug!( + "Failed to read sendmsg sockaddr from seccomp notification: {}", + e + ); + let _ = deny_notif(notify_fd, notif.id); + return Ok(()); + } + } + } + Ok(None) => { + // msg_name is NULL: no per-message destination to + // mediate. Allow immediately. + if let Err(e) = continue_notif(notify_fd, notif.id) { + debug!("continue_notif failed for sendmsg NULL msg_name: {}", e); + return deny_notif(notify_fd, notif.id); + } + return Ok(()); + } + Err(e) => { + debug!( + "Failed to read msghdr from sendmsg seccomp notification: {}", + e + ); + let _ = deny_notif(notify_fd, notif.id); + return Ok(()); + } + } + } + SYS_SENDMMSG => { + let dests = match read_mmsghdr_dests(notif.pid, notif.data.args[1], notif.data.args[2]) + { + Ok(dests) => dests, + Err(e) => { + debug!( + "Failed to read sendmmsg message vector from seccomp notification: {}", + e + ); + let _ = deny_notif(notify_fd, notif.id); + return Ok(()); + } + }; + + let mut sockaddrs = Vec::new(); + for (idx, dest) in dests.into_iter().enumerate() { + let Some((addr_ptr, addrlen)) = dest else { + continue; + }; + match read_notif_sockaddr(notif.pid, addr_ptr, addrlen) { + Ok(info) => sockaddrs.push(info), + Err(e) => { + debug!("Failed to read sendmmsg sockaddr at message {}: {}", idx, e); + let _ = deny_notif(notify_fd, notif.id); + return Ok(()); + } + } + } + + if sockaddrs.is_empty() { + if let Err(e) = continue_notif(notify_fd, notif.id) { + debug!( + "continue_notif failed for sendmmsg without destinations: {}", + e + ); + return deny_notif(notify_fd, notif.id); + } + return Ok(()); + } + + sockaddrs + } + other => { + warn!( + "Unexpected syscall {} in network seccomp handler, denying", + other + ); let _ = deny_notif(notify_fd, notif.id); return Ok(()); } @@ -841,24 +972,27 @@ pub(super) fn handle_network_notification( return Ok(()); } - match decide_network_notification(notif.pid, notif.data.nr, &sockaddr, config) { - NetworkDecision::Allow => { - if let Err(e) = continue_notif(notify_fd, notif.id) { - debug!("continue_notif failed for network notification: {}", e); - // Must respond to avoid leaving the child blocked. Propagate if - // deny also fails — the notification is orphaned. - return deny_notif(notify_fd, notif.id); - } - } - NetworkDecision::Deny => { - record_af_unix_ipc_denial(&sockaddr, notif.pid, notif.data.nr, denials, ipc_denials); - respond_notif_errno(notify_fd, notif.id, libc::EACCES)?; - if let Err(err) = record_network_audit_denial(config, &sockaddr, notif.data.nr) { - warn!("Failed to record network denial audit event: {}", err); + for sockaddr in &sockaddrs { + match decide_network_notification(notif.pid, notif.data.nr, sockaddr, config) { + NetworkDecision::Allow => {} + NetworkDecision::Deny => { + record_af_unix_ipc_denial(sockaddr, notif.pid, notif.data.nr, denials, ipc_denials); + respond_notif_errno(notify_fd, notif.id, libc::EACCES)?; + if let Err(err) = record_network_audit_denial(config, sockaddr, notif.data.nr) { + warn!("Failed to record network denial audit event: {}", err); + } + return Ok(()); } } } + if let Err(e) = continue_notif(notify_fd, notif.id) { + debug!("continue_notif failed for network notification: {}", e); + // Must respond to avoid leaving the child blocked. Propagate if + // deny also fails -- the notification is orphaned. + return deny_notif(notify_fd, notif.id); + } + Ok(()) } @@ -890,7 +1024,7 @@ fn record_af_unix_ipc_denial( return; }; let access = match op { - UnixSocketOp::Connect => AccessMode::Read, + UnixSocketOp::Connect | UnixSocketOp::Send => AccessMode::Read, UnixSocketOp::Bind => AccessMode::ReadWrite, }; @@ -932,7 +1066,7 @@ fn ipc_denial_details( let resolved = resolve_af_unix_sockaddr_path(child_pid, path) .unwrap_or_else(|_| path.to_path_buf()); let canonical = match op { - UnixSocketOp::Connect => resolved.canonicalize(), + UnixSocketOp::Connect | UnixSocketOp::Send => resolved.canonicalize(), UnixSocketOp::Bind => canonicalize_unix_socket_bind_path(&resolved), }; let Ok(display_path) = canonical else { @@ -945,7 +1079,7 @@ fn ipc_denial_details( ); }; let flag = match op { - UnixSocketOp::Connect => "--allow-unix-socket", + UnixSocketOp::Connect | UnixSocketOp::Send => "--allow-unix-socket", UnixSocketOp::Bind => "--allow-unix-socket-bind", }; ( @@ -1320,7 +1454,10 @@ mod tests { LinuxNetworkNotifyMode, NetworkDecision, SupervisorConfig, decide_network_notification, }; use nix::libc; - use nono::sandbox::{SYS_BIND, SYS_CONNECT, SockaddrInfo, UnixSocketKind}; + use nono::sandbox::{ + SYS_BIND, SYS_CONNECT, SYS_SENDMMSG, SYS_SENDMSG, SYS_SENDTO, SockaddrInfo, + UnixSocketKind, + }; use nono::supervisor::{ApprovalDecision, CapabilityRequest}; use nono::{ApprovalBackend, UnixSocketCapability, UnixSocketMode}; use std::os::unix::net::UnixListener; @@ -1641,5 +1778,213 @@ mod tests { NetworkDecision::Allow ); } + + // --- sendto/sendmsg/sendmmsg tests (issue #1089) ------------------- + // + // Datagram AF_UNIX sockets use sendto/sendmsg/sendmmsg instead of + // connect. These must be mediated against the same pathname allowlist. + + /// Pathname `sendto(AF_UNIX, "/tmp/...")` is allowed when a connect + /// grant covers the path. Connect grants cover both connect and send. + #[test] + fn af_unix_pathname_sendto_is_allowed_by_connect_grant() { + let backend = DenyAllBackend; + let dir = tempfile::tempdir().expect("tempdir"); + let path = socket_path(&dir, "dgram.sock"); + let _listener = UnixListener::bind(&path).expect("bind unix listener"); + let allowlist = vec![ + UnixSocketCapability::new_file(&path, UnixSocketMode::Connect) + .expect("socket grant"), + ]; + let config = make_config(&backend, 8080, Vec::new(), &allowlist); + assert_eq!( + decide_network_notification(test_pid(), SYS_SENDTO, &unix_pathname(&path), &config,), + NetworkDecision::Allow, + "pathname AF_UNIX sendto must be allowed when a connect grant covers it" + ); + } + + /// Pathname `sendmsg(AF_UNIX, "/tmp/...")` is allowed when a connect + /// grant covers the path. Same as sendto. + #[test] + fn af_unix_pathname_sendmsg_is_allowed_by_connect_grant() { + let backend = DenyAllBackend; + let dir = tempfile::tempdir().expect("tempdir"); + let path = socket_path(&dir, "dgram.sock"); + let _listener = UnixListener::bind(&path).expect("bind unix listener"); + let allowlist = vec![ + UnixSocketCapability::new_file(&path, UnixSocketMode::Connect) + .expect("socket grant"), + ]; + let config = make_config(&backend, 8080, Vec::new(), &allowlist); + assert_eq!( + decide_network_notification( + test_pid(), + SYS_SENDMSG, + &unix_pathname(&path), + &config, + ), + NetworkDecision::Allow, + "pathname AF_UNIX sendmsg must be allowed when a connect grant covers it" + ); + } + + /// Pathname `sendmmsg(AF_UNIX, "/tmp/...")` is allowed when a connect + /// grant covers the path. Same as sendmsg, but batched. + #[test] + fn af_unix_pathname_sendmmsg_is_allowed_by_connect_grant() { + let backend = DenyAllBackend; + let dir = tempfile::tempdir().expect("tempdir"); + let path = socket_path(&dir, "dgram.sock"); + let _listener = UnixListener::bind(&path).expect("bind unix listener"); + let allowlist = vec![ + UnixSocketCapability::new_file(&path, UnixSocketMode::Connect) + .expect("socket grant"), + ]; + let config = make_config(&backend, 8080, Vec::new(), &allowlist); + assert_eq!( + decide_network_notification( + test_pid(), + SYS_SENDMMSG, + &unix_pathname(&path), + &config, + ), + NetworkDecision::Allow, + "pathname AF_UNIX sendmmsg must be allowed when a connect grant covers it" + ); + } + + /// `sendto(AF_UNIX)` without a matching grant is denied. + #[test] + fn af_unix_pathname_sendto_without_grant_is_denied() { + let backend = DenyAllBackend; + let dir = tempfile::tempdir().expect("tempdir"); + let path = socket_path(&dir, "dgram.sock"); + let _listener = UnixListener::bind(&path).expect("bind unix listener"); + let config = make_config(&backend, 8080, Vec::new(), &[]); + assert_eq!( + decide_network_notification(test_pid(), SYS_SENDTO, &unix_pathname(&path), &config,), + NetworkDecision::Deny, + "pathname AF_UNIX sendto without grant must be denied" + ); + } + + /// `sendmsg(AF_UNIX)` without a matching grant is denied. + #[test] + fn af_unix_pathname_sendmsg_without_grant_is_denied() { + let backend = DenyAllBackend; + let dir = tempfile::tempdir().expect("tempdir"); + let path = socket_path(&dir, "dgram.sock"); + let _listener = UnixListener::bind(&path).expect("bind unix listener"); + let config = make_config(&backend, 8080, Vec::new(), &[]); + assert_eq!( + decide_network_notification( + test_pid(), + SYS_SENDMSG, + &unix_pathname(&path), + &config, + ), + NetworkDecision::Deny, + "pathname AF_UNIX sendmsg without grant must be denied" + ); + } + + /// `sendmmsg(AF_UNIX)` without a matching grant is denied. + #[test] + fn af_unix_pathname_sendmmsg_without_grant_is_denied() { + let backend = DenyAllBackend; + let dir = tempfile::tempdir().expect("tempdir"); + let path = socket_path(&dir, "dgram.sock"); + let _listener = UnixListener::bind(&path).expect("bind unix listener"); + let config = make_config(&backend, 8080, Vec::new(), &[]); + assert_eq!( + decide_network_notification( + test_pid(), + SYS_SENDMMSG, + &unix_pathname(&path), + &config, + ), + NetworkDecision::Deny, + "pathname AF_UNIX sendmmsg without grant must be denied" + ); + } + + /// Abstract AF_UNIX `sendto` is denied (same as connect). + #[test] + fn af_unix_abstract_sendto_is_denied() { + let backend = DenyAllBackend; + let config = make_config(&backend, 0, Vec::new(), &[]); + assert_eq!( + decide_network_notification(test_pid(), SYS_SENDTO, &unix_abstract(), &config), + NetworkDecision::Deny, + "abstract AF_UNIX sendto must be denied" + ); + } + + /// AF_INET `sendto` to an external host is denied (same as connect). + #[test] + fn af_inet_sendto_to_external_host_denied() { + let backend = DenyAllBackend; + let config = make_config(&backend, 8080, Vec::new(), &[]); + assert_eq!( + decide_network_notification(test_pid(), SYS_SENDTO, &inet_external(8080), &config), + NetworkDecision::Deny, + "AF_INET sendto to external host must be denied" + ); + } + + /// AF_INET `sendto` to loopback proxy port is allowed (same as connect). + #[test] + fn af_inet_sendto_to_proxy_port_allowed() { + let backend = DenyAllBackend; + let config = make_config(&backend, 8080, Vec::new(), &[]); + assert_eq!( + decide_network_notification(test_pid(), SYS_SENDTO, &inet_loopback(8080), &config), + NetworkDecision::Allow, + "AF_INET sendto to loopback proxy port must be allowed" + ); + } + + /// AF_INET `sendmsg` to an external host is denied (same as connect). + #[test] + fn af_inet_sendmsg_to_external_host_denied() { + let backend = DenyAllBackend; + let config = make_config(&backend, 8080, Vec::new(), &[]); + assert_eq!( + decide_network_notification(test_pid(), SYS_SENDMSG, &inet_external(8080), &config), + NetworkDecision::Deny, + "AF_INET sendmsg to external host must be denied" + ); + } + + /// AF_INET `sendmmsg` to an external host is denied (same as connect). + #[test] + fn af_inet_sendmmsg_to_external_host_denied() { + let backend = DenyAllBackend; + let config = make_config(&backend, 8080, Vec::new(), &[]); + assert_eq!( + decide_network_notification( + test_pid(), + SYS_SENDMMSG, + &inet_external(8080), + &config, + ), + NetworkDecision::Deny, + "AF_INET sendmmsg to external host must be denied" + ); + } + + /// In AF_UNIX-only mode, non-AF_UNIX sendto is allowed (Landlock + /// handles TCP). + #[test] + fn af_unix_only_mode_allows_non_af_unix_sendto() { + let backend = DenyAllBackend; + let config = make_af_unix_only_config(&backend, &[]); + assert_eq!( + decide_network_notification(test_pid(), SYS_SENDTO, &inet_external(8080), &config), + NetworkDecision::Allow, + "AF_UNIX-only mode must allow non-AF_UNIX sendto" + ); + } } } diff --git a/crates/nono/src/capability.rs b/crates/nono/src/capability.rs index 4f00de815..6b79341a8 100644 --- a/crates/nono/src/capability.rs +++ b/crates/nono/src/capability.rs @@ -214,13 +214,21 @@ impl std::fmt::Display for UnixSocketMode { /// Kept distinct from [`UnixSocketMode`] so the grant-side (what a /// capability permits) and the query-side (what the caller is about to /// do) are not conflated. The supervisor's seccomp-notify handler maps -/// `SYS_CONNECT` → `Connect`, `SYS_BIND` → `Bind`. +/// `SYS_CONNECT` -> `Connect`, `SYS_BIND` -> `Bind`, +/// `SYS_SENDTO`/`SYS_SENDMSG`/`SYS_SENDMMSG` -> `Send`. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum UnixSocketOp { /// About to call `connect(2)`. Connect, /// About to call `bind(2)`. Bind, + /// About to call `sendto(2)`, `sendmsg(2)`, or `sendmmsg(2)` with a + /// destination address. + /// + /// Datagram AF_UNIX sockets use `sendto`/`sendmsg`/`sendmmsg` to specify the + /// target per-message instead of calling `connect()` first. This + /// variant covers those datagram sends (issue #1089). + Send, } impl std::fmt::Display for UnixSocketOp { @@ -228,6 +236,7 @@ impl std::fmt::Display for UnixSocketOp { match self { UnixSocketOp::Connect => write!(f, "connect"), UnixSocketOp::Bind => write!(f, "bind"), + UnixSocketOp::Send => write!(f, "send"), } } } @@ -1295,14 +1304,15 @@ impl CapabilitySet { /// permits `op` on it. /// /// Used by the Linux supervisor's seccomp-notify handler: - /// `SYS_CONNECT` → [`UnixSocketOp::Connect`], `SYS_BIND` - /// → [`UnixSocketOp::Bind`]. + /// `SYS_CONNECT` -> [`UnixSocketOp::Connect`], `SYS_BIND` + /// -> [`UnixSocketOp::Bind`], `SYS_SENDTO`/`SYS_SENDMSG`/`SYS_SENDMMSG` + /// -> [`UnixSocketOp::Send`]. #[must_use] pub fn unix_socket_allowed(&self, sockaddr_path: &Path, op: UnixSocketOp) -> bool { self.unix_sockets.iter().any(|cap| { cap.covers(sockaddr_path) && match op { - UnixSocketOp::Connect => true, // any grant allows connect + UnixSocketOp::Connect | UnixSocketOp::Send => true, // any grant allows connect/send UnixSocketOp::Bind => cap.mode.permits_bind(), } }) @@ -3024,6 +3034,13 @@ mod tests { assert!(UnixSocketMode::ConnectBind.permits_bind()); } + #[test] + fn test_unix_socket_op_display() { + assert_eq!(UnixSocketOp::Connect.to_string(), "connect"); + assert_eq!(UnixSocketOp::Bind.to_string(), "bind"); + assert_eq!(UnixSocketOp::Send.to_string(), "send"); + } + #[test] fn test_unix_socket_connect_requires_existing_path() { let dir = tempdir().unwrap(); @@ -3320,6 +3337,28 @@ mod tests { assert!(!caps.unix_socket_allowed(&direct_child, UnixSocketOp::Bind)); } + /// Send (sendto/sendmsg/sendmmsg) is covered by Connect grants (issue #1089). + /// A Connect-mode grant allows both connect and datagram send operations. + #[test] + fn test_capability_set_unix_socket_send_covered_by_connect_grant() { + let dir = tempdir().unwrap(); + let sock = dir.path().join("dgram.sock"); + fs::write(&sock, b"").unwrap(); + + let caps = CapabilitySet::new() + .allow_unix_socket(&sock, UnixSocketMode::Connect) + .unwrap(); + + let resolved = sock.canonicalize().unwrap(); + + // Connect grant covers both connect and send + assert!(caps.unix_socket_allowed(&resolved, UnixSocketOp::Connect)); + assert!(caps.unix_socket_allowed(&resolved, UnixSocketOp::Send)); + + // Connect-only grant does not cover bind + assert!(!caps.unix_socket_allowed(&resolved, UnixSocketOp::Bind)); + } + #[test] fn test_deduplicate_unix_sockets_merges_identical_grants() { let dir = tempdir().unwrap(); diff --git a/crates/nono/src/sandbox/linux.rs b/crates/nono/src/sandbox/linux.rs index a6c98ea48..970256700 100644 --- a/crates/nono/src/sandbox/linux.rs +++ b/crates/nono/src/sandbox/linux.rs @@ -901,6 +901,15 @@ pub const SYS_CONNECT: i32 = libc::SYS_connect as i32; #[cfg(target_os = "linux")] pub const SYS_BIND: i32 = libc::SYS_bind as i32; +// Syscall numbers for sendto/sendmsg/sendmmsg (public for CLI supervisor handler) +// Needed to mediate AF_UNIX datagram sends (issue #1089). +#[cfg(target_os = "linux")] +pub const SYS_SENDTO: i32 = libc::SYS_sendto as i32; +#[cfg(target_os = "linux")] +pub const SYS_SENDMSG: i32 = libc::SYS_sendmsg as i32; +#[cfg(target_os = "linux")] +pub const SYS_SENDMMSG: i32 = libc::SYS_sendmmsg as i32; + /// struct open_how from /// /// Used by openat2() syscall. args[2] is a pointer to this struct, NOT the flags integer. @@ -1727,11 +1736,13 @@ pub fn seccomp_network_fallback_mode(caps: &CapabilitySet) -> SeccompNetFallback /// Build a BPF filter for proxy-only network mode. /// -/// Routes `connect()` and `bind()` to `SECCOMP_RET_USER_NOTIF` so the +/// Routes `connect()`, `bind()`, `sendto()`, `sendmsg()`, and `sendmmsg()` to +/// `SECCOMP_RET_USER_NOTIF` so the /// supervisor can inspect the sockaddr and make a per-family decision: /// -/// - `AF_INET`/`AF_INET6`: allow connect to `localhost:proxy_port`; -/// allow bind on ports in the configured bind-ports list; deny others. +/// - `AF_INET`/`AF_INET6`: allow connect/send destinations to +/// `localhost:proxy_port`; allow bind on ports in the configured bind-ports +/// list; deny others. /// - pathname `AF_UNIX`: route to the supervisor, which checks the explicit /// Unix socket capability allowlist against the requested path. /// - abstract/unnamed `AF_UNIX`: deny (see `decide_network_notification`). @@ -1745,29 +1756,6 @@ pub fn seccomp_network_fallback_mode(caps: &CapabilitySet) -> SeccompNetFallback /// `socket()` is allowed only for `AF_UNIX`, `AF_INET`, `AF_INET6`. /// `socketpair()` is allowed only for `AF_UNIX`. /// `io_uring_setup()` is denied. -/// -/// Instruction layout (19 instructions, jt = jump offset from next insn): -/// ```text -/// 0: ld [nr] -/// 1: jeq SYS_SOCKET jt=+6 (-> 8: load socket family) -/// 2: jeq SYS_CONNECT jt=+13 (-> 16: notify) -/// 3: jeq SYS_BIND jt=+13 (-> 17: notify) -/// 4: jeq SYS_SOCKETPAIR jt=+8 (-> 13: load socketpair family) -/// 5: jeq SYS_IO_URING jt=+1 (-> 7: errno) -/// 6: ret ALLOW -/// 7: ret ERRNO(EACCES) -/// 8: ld [args[0]] ; socket() family -/// 9: jeq AF_UNIX jt=+8 (-> 18: allow) -/// 10: jeq AF_INET jt=+7 (-> 18: allow) -/// 11: jeq AF_INET6 jt=+6 (-> 18: allow) -/// 12: ret ERRNO(EACCES) ; bad socket family -/// 13: ld [args[0]] ; socketpair() family -/// 14: jeq AF_UNIX jt=+3 (-> 18: allow) -/// 15: ret ERRNO(EACCES) ; bad socketpair family -/// 16: ret USER_NOTIF ; connect -/// 17: ret USER_NOTIF ; bind -/// 18: ret ALLOW ; allowed socket/socketpair -/// ``` fn build_seccomp_proxy_filter(_has_bind_ports: bool) -> Vec { let errno_ret = SECCOMP_RET_ERRNO | (libc::EACCES as u32); @@ -1782,26 +1770,34 @@ fn build_seccomp_proxy_filter(_has_bind_ports: bool) -> Vec { // supervisor's `decide_network_notification` is the sole arbiter. let bind_action = SECCOMP_RET_USER_NOTIF; + // sendto(), sendmsg(), and sendmmsg() route unconditionally to USER_NOTIF. + // The supervisor does the full-width NULL destination checks and inspects + // each sendmmsg vector entry. + // // Target instruction index table (jt/jf are offsets from next insn): // 0: ld [nr] - // 1: jeq SOCKET jt=6 -> insn 8 - // 2: jeq CONNECT jt=13 -> insn 16 - // 3: jeq BIND jt=13 -> insn 17 - // 4: jeq SOCKETPAIR jt=8 -> insn 13 - // 5: jeq IO_URING jt=1 -> insn 7 - // 6: ret ALLOW - // 7: ret ERRNO - // 8: ld [args[0]] - // 9: jeq AF_UNIX jt=8 -> insn 18 - // 10: jeq AF_INET jt=7 -> insn 18 - // 11: jeq AF_INET6 jt=6 -> insn 18 - // 12: ret ERRNO (bad socket family) - // 13: ld [args[0]] - // 14: jeq AF_UNIX jt=3 -> insn 18 - // 15: ret ERRNO (bad socketpair family) - // 16: ret USER_NOTIF (connect) - // 17: ret bind_action (bind) - // 18: ret ALLOW (good socket/socketpair) + // 1: jeq SOCKET jt=9 -> insn 11 + // 2: jeq CONNECT jt=16 -> insn 19 + // 3: jeq BIND jt=16 -> insn 20 + // 4: jeq SOCKETPAIR jt=11 -> insn 16 + // 5: jeq SENDTO jt=15 -> insn 21 + // 6: jeq SENDMSG jt=14 -> insn 21 + // 7: jeq SENDMMSG jt=13 -> insn 21 + // 8: jeq IO_URING jt=1 -> insn 10 + // 9: ret ALLOW + // 10: ret ERRNO + // 11: ld [args[0]] + // 12: jeq AF_UNIX jt=9 -> insn 22 + // 13: jeq AF_INET jt=8 -> insn 22 + // 14: jeq AF_INET6 jt=7 -> insn 22 + // 15: ret ERRNO (bad socket family) + // 16: ld [args[0]] + // 17: jeq AF_UNIX jt=4 -> insn 22 + // 18: ret ERRNO (bad socketpair family) + // 19: ret USER_NOTIF (connect) + // 20: ret bind_action (bind) + // 21: ret USER_NOTIF (sendto/sendmsg/sendmmsg) + // 22: ret ALLOW (good socket/socketpair) vec![ // 0: ld [nr] @@ -1811,126 +1807,154 @@ fn build_seccomp_proxy_filter(_has_bind_ports: bool) -> Vec { jf: 0, k: SECCOMP_DATA_NR_OFFSET, }, - // 1: jeq SYS_SOCKET -> 8 (jt = 8-1-1 = 6) + // 1: jeq SYS_SOCKET -> 11 (jt = 11-1-1 = 9) SockFilterInsn { code: BPF_JMP | BPF_JEQ | BPF_K, - jt: 6, + jt: 9, jf: 0, k: SYS_SOCKET as u32, }, - // 2: jeq SYS_CONNECT -> 16 (jt = 16-2-1 = 13) + // 2: jeq SYS_CONNECT -> 19 (jt = 19-2-1 = 16) SockFilterInsn { code: BPF_JMP | BPF_JEQ | BPF_K, - jt: 13, + jt: 16, jf: 0, k: SYS_CONNECT as u32, }, - // 3: jeq SYS_BIND -> 17 (jt = 17-3-1 = 13) + // 3: jeq SYS_BIND -> 20 (jt = 20-3-1 = 16) SockFilterInsn { code: BPF_JMP | BPF_JEQ | BPF_K, - jt: 13, + jt: 16, jf: 0, k: SYS_BIND as u32, }, - // 4: jeq SYS_SOCKETPAIR -> 13 (jt = 13-4-1 = 8) + // 4: jeq SYS_SOCKETPAIR -> 16 (jt = 16-4-1 = 11) SockFilterInsn { code: BPF_JMP | BPF_JEQ | BPF_K, - jt: 8, + jt: 11, jf: 0, k: SYS_SOCKETPAIR as u32, }, - // 5: jeq SYS_IO_URING_SETUP -> 7 (jt = 7-5-1 = 1) + // 5: jeq SYS_SENDTO -> 21 (jt = 21-5-1 = 15) + SockFilterInsn { + code: BPF_JMP | BPF_JEQ | BPF_K, + jt: 15, + jf: 0, + k: SYS_SENDTO as u32, + }, + // 6: jeq SYS_SENDMSG -> 21 (jt = 21-6-1 = 14) + SockFilterInsn { + code: BPF_JMP | BPF_JEQ | BPF_K, + jt: 14, + jf: 0, + k: SYS_SENDMSG as u32, + }, + // 7: jeq SYS_SENDMMSG -> 21 (jt = 21-7-1 = 13) + SockFilterInsn { + code: BPF_JMP | BPF_JEQ | BPF_K, + jt: 13, + jf: 0, + k: SYS_SENDMMSG as u32, + }, + // 8: jeq SYS_IO_URING_SETUP -> 10 (jt = 10-8-1 = 1) SockFilterInsn { code: BPF_JMP | BPF_JEQ | BPF_K, jt: 1, jf: 0, k: SYS_IO_URING_SETUP as u32, }, - // 6: ret ALLOW + // 9: ret ALLOW SockFilterInsn { code: BPF_RET | BPF_K, jt: 0, jf: 0, k: SECCOMP_RET_ALLOW, }, - // 7: ret ERRNO(EACCES) + // 10: ret ERRNO(EACCES) SockFilterInsn { code: BPF_RET | BPF_K, jt: 0, jf: 0, k: errno_ret, }, - // 8: ld [args[0]] — socket() family + // 11: ld [args[0]] -- socket() family SockFilterInsn { code: BPF_LD | BPF_W | BPF_ABS, jt: 0, jf: 0, k: SECCOMP_DATA_ARG0_OFFSET, }, - // 9: jeq AF_UNIX -> 18 (jt = 18-9-1 = 8) + // 12: jeq AF_UNIX -> 22 (jt = 22-12-1 = 9) SockFilterInsn { code: BPF_JMP | BPF_JEQ | BPF_K, - jt: 8, + jt: 9, jf: 0, k: libc::AF_UNIX as u32, }, - // 10: jeq AF_INET -> 18 (jt = 18-10-1 = 7) + // 13: jeq AF_INET -> 22 (jt = 22-13-1 = 8) SockFilterInsn { code: BPF_JMP | BPF_JEQ | BPF_K, - jt: 7, + jt: 8, jf: 0, k: libc::AF_INET as u32, }, - // 11: jeq AF_INET6 -> 18 (jt = 18-11-1 = 6) + // 14: jeq AF_INET6 -> 22 (jt = 22-14-1 = 7) SockFilterInsn { code: BPF_JMP | BPF_JEQ | BPF_K, - jt: 6, + jt: 7, jf: 0, k: libc::AF_INET6 as u32, }, - // 12: ret ERRNO(EACCES) — bad socket family + // 15: ret ERRNO(EACCES) -- bad socket family SockFilterInsn { code: BPF_RET | BPF_K, jt: 0, jf: 0, k: errno_ret, }, - // 13: ld [args[0]] — socketpair() family + // 16: ld [args[0]] -- socketpair() family SockFilterInsn { code: BPF_LD | BPF_W | BPF_ABS, jt: 0, jf: 0, k: SECCOMP_DATA_ARG0_OFFSET, }, - // 14: jeq AF_UNIX -> 18 (jt = 18-14-1 = 3) + // 17: jeq AF_UNIX -> 22 (jt = 22-17-1 = 4) SockFilterInsn { code: BPF_JMP | BPF_JEQ | BPF_K, - jt: 3, + jt: 4, jf: 0, k: libc::AF_UNIX as u32, }, - // 15: ret ERRNO(EACCES) — bad socketpair family + // 18: ret ERRNO(EACCES) -- bad socketpair family SockFilterInsn { code: BPF_RET | BPF_K, jt: 0, jf: 0, k: errno_ret, }, - // 16: ret USER_NOTIF — connect() + // 19: ret USER_NOTIF -- connect() SockFilterInsn { code: BPF_RET | BPF_K, jt: 0, jf: 0, k: SECCOMP_RET_USER_NOTIF, }, - // 17: ret bind_action — bind() + // 20: ret bind_action -- bind() SockFilterInsn { code: BPF_RET | BPF_K, jt: 0, jf: 0, k: bind_action, }, - // 18: ret ALLOW — good socket/socketpair family + // 21: ret USER_NOTIF -- sendto/sendmsg/sendmmsg + SockFilterInsn { + code: BPF_RET | BPF_K, + jt: 0, + jf: 0, + k: SECCOMP_RET_USER_NOTIF, + }, + // 22: ret ALLOW -- good socket/socketpair family SockFilterInsn { code: BPF_RET | BPF_K, jt: 0, @@ -1942,44 +1966,78 @@ fn build_seccomp_proxy_filter(_has_bind_ports: bool) -> Vec { /// Build a BPF filter for opt-in pathname AF_UNIX mediation. /// -/// The filter routes `connect()` and `bind()` to the supervisor so it can -/// inspect `sockaddr_un` paths. Everything else is allowed by this filter: -/// TCP policy remains Landlock's job on V4+ kernels. +/// The filter routes `connect()`, `bind()`, `sendto()`, `sendmsg()`, and `sendmmsg()` +/// to the supervisor so it can inspect `sockaddr_un` paths. Everything +/// else is allowed by this filter: TCP policy remains Landlock's job on +/// V4+ kernels. +/// +/// The send syscalls route unconditionally. BPF cannot dereference +/// `msghdr`/`mmsghdr`, and checking only half of a 64-bit `sendto` pointer +/// is not a reliable NULL test. /// /// Instruction layout: /// ```text /// 0: ld [nr] -/// 1: jeq SYS_CONNECT jt=+2 (-> 4: notify) -/// 2: jeq SYS_BIND jt=+1 (-> 4: notify) -/// 3: ret ALLOW -/// 4: ret USER_NOTIF +/// 1: jeq SYS_CONNECT jt=+5 (-> 7: notify) +/// 2: jeq SYS_BIND jt=+4 (-> 7: notify) +/// 3: jeq SYS_SENDTO jt=+3 (-> 7: notify) +/// 4: jeq SYS_SENDMSG jt=+2 (-> 7: notify) +/// 5: jeq SYS_SENDMMSG jt=+1 (-> 7: notify) +/// 6: ret ALLOW +/// 7: ret USER_NOTIF /// ``` fn build_seccomp_af_unix_filter() -> Vec { vec![ + // 0: ld [nr] SockFilterInsn { code: BPF_LD | BPF_W | BPF_ABS, jt: 0, jf: 0, k: SECCOMP_DATA_NR_OFFSET, }, + // 1: jeq SYS_CONNECT -> 7 (jt = 7-1-1 = 5) SockFilterInsn { code: BPF_JMP | BPF_JEQ | BPF_K, - jt: 2, + jt: 5, jf: 0, k: SYS_CONNECT as u32, }, + // 2: jeq SYS_BIND -> 7 (jt = 7-2-1 = 4) SockFilterInsn { code: BPF_JMP | BPF_JEQ | BPF_K, - jt: 1, + jt: 4, jf: 0, k: SYS_BIND as u32, }, + // 3: jeq SYS_SENDTO -> 7 (jt = 7-3-1 = 3) + SockFilterInsn { + code: BPF_JMP | BPF_JEQ | BPF_K, + jt: 3, + jf: 0, + k: SYS_SENDTO as u32, + }, + // 4: jeq SYS_SENDMSG -> 7 (jt = 7-4-1 = 2) + SockFilterInsn { + code: BPF_JMP | BPF_JEQ | BPF_K, + jt: 2, + jf: 0, + k: SYS_SENDMSG as u32, + }, + // 5: jeq SYS_SENDMMSG -> 7 (jt = 7-5-1 = 1) + SockFilterInsn { + code: BPF_JMP | BPF_JEQ | BPF_K, + jt: 1, + jf: 0, + k: SYS_SENDMMSG as u32, + }, + // 6: ret ALLOW SockFilterInsn { code: BPF_RET | BPF_K, jt: 0, jf: 0, k: SECCOMP_RET_ALLOW, }, + // 7: ret USER_NOTIF SockFilterInsn { code: BPF_RET | BPF_K, jt: 0, @@ -2235,6 +2293,102 @@ pub fn read_notif_sockaddr(pid: u32, addr_ptr: u64, addrlen: u64) -> Result Result> { + use std::io::Read; + + // struct msghdr (x86_64 Linux): + // void *msg_name; // offset 0, 8 bytes + // socklen_t msg_namelen; // offset 8, 4 bytes + // + // We only need the first 12 bytes. + const MSGHDR_MIN_READ: usize = 12; + + let mem_path = format!("/proc/{}/mem", pid); + let mut file = std::fs::File::open(&mem_path) + .map_err(|e| NonoError::SandboxInit(format!("Failed to open {}: {}", mem_path, e)))?; + + std::io::Seek::seek(&mut file, std::io::SeekFrom::Start(msghdr_ptr)) + .map_err(|e| NonoError::SandboxInit(format!("Failed to seek in {}: {}", mem_path, e)))?; + + let mut buf = [0u8; MSGHDR_MIN_READ]; + file.read_exact(&mut buf).map_err(|e| { + NonoError::SandboxInit(format!("Failed to read msghdr from {}: {}", mem_path, e)) + })?; + + // msg_name is a pointer (8 bytes, native endian on Linux) + let msg_name = u64::from_ne_bytes([ + buf[0], buf[1], buf[2], buf[3], buf[4], buf[5], buf[6], buf[7], + ]); + // msg_namelen is socklen_t (4 bytes, native endian), cast to u64 for read_notif_sockaddr + let msg_namelen = u32::from_ne_bytes([buf[8], buf[9], buf[10], buf[11]]) as u64; + + if msg_name == 0 { + // No destination address: the socket is connected, sendmsg just + // sends data to the already-connected peer. No mediation needed. + return Ok(None); + } + + Ok(Some((msg_name, msg_namelen))) +} + +/// Read every destination sockaddr pointer and length from a `sendmmsg(2)` +/// message vector in the child process's memory. +/// +/// `sendmmsg(fd, msgvec, vlen, flags)` uses an array of `struct mmsghdr`, +/// each of which starts with a `struct msghdr`. Each `msghdr.msg_name` may +/// independently specify a destination address. +/// +/// Returns `None` entries for messages whose `msg_name` is NULL. +/// +/// # Errors +/// +/// Returns an error if `vlen` is unreasonably large, if pointer arithmetic +/// overflows, or if any message header cannot be read. +pub fn read_mmsghdr_dests(pid: u32, msgvec_ptr: u64, vlen: u64) -> Result>> { + const MAX_MMSGHDRS: u64 = 1024; + + if vlen > MAX_MMSGHDRS { + return Err(NonoError::SandboxInit(format!( + "sendmmsg vector length too large to inspect: {vlen}" + ))); + } + + let count = usize::try_from(vlen) + .map_err(|_| NonoError::SandboxInit(format!("sendmmsg vector length too large: {vlen}")))?; + let stride = u64::try_from(std::mem::size_of::()) + .map_err(|_| NonoError::SandboxInit("mmsghdr size does not fit in u64".to_string()))?; + + let mut dests = Vec::with_capacity(count); + for idx in 0..vlen { + let offset = idx.checked_mul(stride).ok_or_else(|| { + NonoError::SandboxInit(format!("sendmmsg vector offset overflow at index {idx}")) + })?; + let msghdr_ptr = msgvec_ptr.checked_add(offset).ok_or_else(|| { + NonoError::SandboxInit(format!("sendmmsg vector pointer overflow at index {idx}")) + })?; + dests.push(read_msghdr_dest(pid, msghdr_ptr)?); + } + + Ok(dests) +} + #[cfg(test)] mod tests { use super::*; @@ -3285,20 +3439,24 @@ mod tests { #[test] fn test_build_seccomp_proxy_filter_with_bind() { let filter = build_seccomp_proxy_filter(true); - // 19 instructions - assert_eq!(filter.len(), 19); + // 23 instructions + assert_eq!(filter.len(), 23); // Instruction 0 should be ld [nr] assert_eq!(filter[0].code, BPF_LD | BPF_W | BPF_ABS); assert_eq!(filter[0].k, SECCOMP_DATA_NR_OFFSET); - // Instruction 16 should be USER_NOTIF (connect) - assert_eq!(filter[16].code, BPF_RET | BPF_K); - assert_eq!(filter[16].k, SECCOMP_RET_USER_NOTIF); + // Instruction 19 should be USER_NOTIF (connect) + assert_eq!(filter[19].code, BPF_RET | BPF_K); + assert_eq!(filter[19].k, SECCOMP_RET_USER_NOTIF); + + // Instruction 20 should be USER_NOTIF (bind; supervisor decides). + assert_eq!(filter[20].code, BPF_RET | BPF_K); + assert_eq!(filter[20].k, SECCOMP_RET_USER_NOTIF); - // Instruction 17 should be USER_NOTIF (bind; supervisor decides). - assert_eq!(filter[17].code, BPF_RET | BPF_K); - assert_eq!(filter[17].k, SECCOMP_RET_USER_NOTIF); + // Instruction 21 should be USER_NOTIF (sendto/sendmsg/sendmmsg) + assert_eq!(filter[21].code, BPF_RET | BPF_K); + assert_eq!(filter[21].k, SECCOMP_RET_USER_NOTIF); } /// Regression test for the Landlock V2 + `has_bind_ports=false` @@ -3309,29 +3467,32 @@ mod tests { #[test] fn test_build_seccomp_proxy_filter_without_bind() { let filter = build_seccomp_proxy_filter(false); - assert_eq!(filter.len(), 19); + assert_eq!(filter.len(), 23); - // Instruction 17 (bind) must ALSO route to USER_NOTIF — the + // Instruction 20 (bind) must ALSO route to USER_NOTIF -- the // supervisor is the sole gate. This is the fix: previously this // emitted ERRNO, which skipped the supervisor entirely. - assert_eq!(filter[17].code, BPF_RET | BPF_K); + assert_eq!(filter[20].code, BPF_RET | BPF_K); assert_eq!( - filter[17].k, SECCOMP_RET_USER_NOTIF, + filter[20].k, SECCOMP_RET_USER_NOTIF, "bind must route to USER_NOTIF regardless of has_bind_ports so \ the supervisor can permit AF_UNIX pathname bind (#685)" ); } #[test] - fn test_build_seccomp_af_unix_filter_notifies_connect_bind_only() { + fn test_build_seccomp_af_unix_filter_notifies_connect_bind_sendto_sendmsg_sendmmsg() { let filter = build_seccomp_af_unix_filter(); - assert_eq!(filter.len(), 5); + assert_eq!(filter.len(), 8); assert_eq!(filter[0].code, BPF_LD | BPF_W | BPF_ABS); assert_eq!(filter[0].k, SECCOMP_DATA_NR_OFFSET); assert_eq!(filter[1].k, SYS_CONNECT as u32); assert_eq!(filter[2].k, SYS_BIND as u32); - assert_eq!(filter[3].k, SECCOMP_RET_ALLOW); - assert_eq!(filter[4].k, SECCOMP_RET_USER_NOTIF); + assert_eq!(filter[3].k, SYS_SENDTO as u32); + assert_eq!(filter[4].k, SYS_SENDMSG as u32); + assert_eq!(filter[5].k, SYS_SENDMMSG as u32); + assert_eq!(filter[6].k, SECCOMP_RET_ALLOW); + assert_eq!(filter[7].k, SECCOMP_RET_USER_NOTIF); } #[test] diff --git a/crates/nono/src/sandbox/mod.rs b/crates/nono/src/sandbox/mod.rs index 3a947d0d7..a761d30dc 100644 --- a/crates/nono/src/sandbox/mod.rs +++ b/crates/nono/src/sandbox/mod.rs @@ -29,10 +29,11 @@ pub use linux::is_wsl2; // Re-export Linux seccomp-notify primitives for supervisor use #[cfg(target_os = "linux")] pub use linux::{ - OpenHow, SYS_BIND, SYS_CONNECT, SYS_OPENAT, SYS_OPENAT2, SeccompData, SeccompNetFallback, - SeccompNotif, SockaddrInfo, UnixSocketKind, classify_access_from_flags, classify_af_unix, - continue_notif, deny_notif, inject_fd, install_seccomp_af_unix_filter, install_seccomp_notify, - install_seccomp_proxy_filter, notif_id_valid, probe_seccomp_block_network_support, + OpenHow, SYS_BIND, SYS_CONNECT, SYS_OPENAT, SYS_OPENAT2, SYS_SENDMMSG, SYS_SENDMSG, SYS_SENDTO, + SeccompData, SeccompNetFallback, SeccompNotif, SockaddrInfo, UnixSocketKind, + classify_access_from_flags, classify_af_unix, continue_notif, deny_notif, inject_fd, + install_seccomp_af_unix_filter, install_seccomp_notify, install_seccomp_proxy_filter, + notif_id_valid, probe_seccomp_block_network_support, read_mmsghdr_dests, read_msghdr_dest, read_notif_path, read_notif_sockaddr, read_open_how, recv_notif, resolve_notif_path, respond_notif_errno, validate_openat2_size, }; From f280824d1ec2b996018db58d5cbf4cd6503a782f Mon Sep 17 00:00:00 2001 From: Luke Hinds Date: Mon, 15 Jun 2026 06:42:54 +0100 Subject: [PATCH 50/91] chore(project): add new issue template for agent package requests (#1081) * Add new issue template for agent package requests This template allows users to request a nono package for unsupported AI agents, detailing necessary information for package creation. * Fix typo in REQUEST-AGENT-PACKAGE.md filename --- .../ISSUE_TEMPLATE/REQUEST-AGENT-PACKAGE.md | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/REQUEST-AGENT-PACKAGE.md diff --git a/.github/ISSUE_TEMPLATE/REQUEST-AGENT-PACKAGE.md b/.github/ISSUE_TEMPLATE/REQUEST-AGENT-PACKAGE.md new file mode 100644 index 000000000..2758f39c6 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/REQUEST-AGENT-PACKAGE.md @@ -0,0 +1,59 @@ +name: New Agent Package Request +description: Request a nono package for an AI agent that isn't supported yet +labels: ["agent-package"] +body: + - type: markdown + attributes: + value: | + Thanks for helping us grow nono's agent coverage! + Use this template to request a nono package for an AI agent that isn't supported yet. The more detail you can give us about the agent — especially the hooks and customization points it exposes — the easier it is for us to build a solid, secure package. + **Please note:** opening this request is not a guarantee that we'll build the package. We prioritize based on demand, so the more popular and widely-used the agent, the more likely support will land. Either way, we'll do our best. + + - type: input + id: agent-name + attributes: + label: Agent name + description: The name of the agent you'd like a nono package for. + placeholder: "e.g. Aider, Cline, OpenHands" + validations: + required: true + + - type: input + id: docs-url + attributes: + label: Documentation URL + description: Link to the agent's official documentation. + placeholder: "https://docs.example.com" + validations: + required: true + + - type: input + id: github-repo + attributes: + label: GitHub repository + description: Link to the agent's source repository. + placeholder: "https://github.com/org/agent" + validations: + required: true + + - type: textarea + id: hooks-customization + attributes: + label: Hooks and customization + description: | + What hooks, plugins, lifecycle events, config files, or other customization points does the agent provide that a nono package could leverage? For example: pre/post-command hooks, tool-call interception, a settings/config file, SKILL files, environment variables, or an extension API. Link to the relevant docs or source where you can. + placeholder: "The agent supports a pre-tool-use hook defined in ~/.agent/config.yaml that runs a command before each shell action..." + validations: + required: true + + - type: textarea + id: popularity + attributes: + label: Adoption and popularity + description: Help us gauge demand — GitHub stars, download counts, community size, or where the agent is being used. This directly informs prioritization. + + - type: textarea + id: context + attributes: + label: Additional context + description: Anything else that would help us build the package — known security concerns, platform support (Linux/macOS), how you intend to use the agent under nono, or related requests. From 4d583025622fc1850a7dd779aead215dc2183333 Mon Sep 17 00:00:00 2001 From: Matt Van Horn Date: Mon, 15 Jun 2026 02:44:19 -0700 Subject: [PATCH 51/91] fix: report the actual blocked operation instead of the readable target path in sandbox denial diagnostics (#1150) Signed-off-by: Matt Van Horn --- .../src/exec_strategy/supervisor_linux.rs | 8 +- crates/nono-cli/src/sandbox_log.rs | 92 +++++++++++++++---- crates/nono/src/diagnostic.rs | 68 ++++++++++++++ 3 files changed, 144 insertions(+), 24 deletions(-) diff --git a/crates/nono-cli/src/exec_strategy/supervisor_linux.rs b/crates/nono-cli/src/exec_strategy/supervisor_linux.rs index 3d822da57..5716c1cb6 100644 --- a/crates/nono-cli/src/exec_strategy/supervisor_linux.rs +++ b/crates/nono-cli/src/exec_strategy/supervisor_linux.rs @@ -398,7 +398,7 @@ pub(super) fn handle_seccomp_notification( record_denial( denials, DenialRecord { - path: path.clone(), + path: canonicalized.clone(), access, reason: DenialReason::RateLimited, }, @@ -433,7 +433,7 @@ pub(super) fn handle_seccomp_notification( record_denial( denials, DenialRecord { - path: path.clone(), + path: canonicalized.clone(), access, reason: DenialReason::PolicyBlocked, }, @@ -462,7 +462,7 @@ pub(super) fn handle_seccomp_notification( record_denial( denials, DenialRecord { - path: path.clone(), + path: canonicalized.clone(), access, reason: DenialReason::UserDenied, }, @@ -475,7 +475,7 @@ pub(super) fn handle_seccomp_notification( record_denial( denials, DenialRecord { - path: path.clone(), + path: canonicalized.clone(), access, reason: DenialReason::BackendError, }, diff --git a/crates/nono-cli/src/sandbox_log.rs b/crates/nono-cli/src/sandbox_log.rs index 4be68856f..c0ad92492 100644 --- a/crates/nono-cli/src/sandbox_log.rs +++ b/crates/nono-cli/src/sandbox_log.rs @@ -297,24 +297,6 @@ fn parse_violation_line(filter: &ViolationFilter, line: &str) -> Option Option { - if let Some(message) = value.get("eventMessage").and_then(Value::as_str) - && let Some(violation) = parse_event_message(filter, message) - { - return Some(violation); - } - - let metadata = value.get("eventMessage").and_then(|event_message| { - event_message - .as_str() - .and_then(|text| serde_json::from_str::(text).ok()) - }); - if let Some(violation) = metadata - .as_ref() - .and_then(|metadata| parse_metadata_violation(filter, metadata)) - { - return Some(violation); - } - let metadata = value .get("metadata") .or_else(|| value.get("MetaData")) @@ -329,9 +311,49 @@ fn parse_violation_value(filter: &ViolationFilter, value: &Value) -> Option(text).ok()) + }); + let event_metadata_violation = event_metadata + .as_ref() + .and_then(|metadata| parse_metadata_violation(filter, metadata)); + if event_metadata_violation .as_ref() - .and_then(|metadata| parse_metadata_violation(filter, metadata)) + .is_some_and(|violation| violation.target.is_some()) + { + return event_metadata_violation; + } + + let event_violation = value + .get("eventMessage") + .and_then(Value::as_str) + .and_then(|message| parse_event_message(filter, message)); + + // None of the candidates exposed a structured target above. Prefer any + // remaining candidate that still carries a target (e.g. an `eventMessage` + // that names the denied path) over a target-less metadata record, so a + // file denial is never downgraded to a non-filesystem violation and lose + // its actionable path. + [ + metadata_violation, + event_metadata_violation, + event_violation, + ] + .into_iter() + .flatten() + .min_by_key(|violation| violation.target.is_none()) } #[cfg(target_os = "macos")] @@ -449,6 +471,36 @@ mod tests { assert_eq!(violation.target.as_deref(), Some("/Users/test/.ssh/id_rsa")); } + #[test] + fn prefers_structured_metadata_over_event_message() { + let line = r#"{"eventMessage":"Sandbox: nl(1234) deny(1) file-read-data /Users/test/workspace/readable.rs","subsystem":"com.apple.sandbox.reporting","category":"violation","MetaData":{"operation":"file-write-create","pid":1234,"target":"/Users/test/Library/Caches/nl/state"}}"#; + let violation = match parse_violation_line(&pid_filter(1234), line) { + Some(violation) => violation, + None => panic!("metadata violation should parse"), + }; + + assert_eq!(violation.operation, "file-write-create"); + assert_eq!( + violation.target.as_deref(), + Some("/Users/test/Library/Caches/nl/state") + ); + } + + #[test] + fn falls_back_to_event_message_target_when_metadata_has_no_target() { + // Metadata matches the pid/operation but carries no target; the + // eventMessage names the denied path. The path must survive so the + // denial stays an actionable filesystem violation. + let line = r#"{"eventMessage":"Sandbox: cat(1234) deny(1) file-read-data /Users/test/.ssh/id_rsa","subsystem":"com.apple.sandbox.reporting","category":"violation","MetaData":{"operation":"file-read-data","pid":1234}}"#; + let violation = match parse_violation_line(&pid_filter(1234), line) { + Some(violation) => violation, + None => panic!("event message violation should parse"), + }; + + assert_eq!(violation.operation, "file-read-data"); + assert_eq!(violation.target.as_deref(), Some("/Users/test/.ssh/id_rsa")); + } + #[test] fn ignores_other_pids_when_pid_filtered() { let msg = "Sandbox: cat(9999) deny(1) file-read-data /tmp/x"; diff --git a/crates/nono/src/diagnostic.rs b/crates/nono/src/diagnostic.rs index 21af30ee6..5be17c88a 100644 --- a/crates/nono/src/diagnostic.rs +++ b/crates/nono/src/diagnostic.rs @@ -3150,6 +3150,74 @@ mod tests { assert!(output.contains("System logging")); } + #[test] + fn test_violation_denial_keeps_file_read_target() { + let requested = PathBuf::from("/Users/alice/workspace/readable.rs"); + let violations = vec![SandboxViolation { + operation: "file-read-data".to_string(), + target: Some(requested.display().to_string()), + }]; + + let (denials, non_fs) = violations_to_denials(&violations); + + assert!(non_fs.is_empty()); + assert_eq!(denials.len(), 1); + assert_eq!(denials[0].path, requested); + assert_eq!(denials[0].access, AccessMode::Read); + } + + #[test] + fn test_logged_violation_target_wins_over_observed_requested_path() { + let caps = make_test_caps(); + let requested = PathBuf::from("/Users/alice/workspace/readable.rs"); + let actual = PathBuf::from("/Users/alice/Library/Caches/nl/state"); + let violations = vec![SandboxViolation { + operation: "file-write-create".to_string(), + target: Some(actual.display().to_string()), + }]; + let formatter = DiagnosticFormatter::new(&caps) + .with_mode(DiagnosticMode::Supervised) + .with_sandbox_violations(&violations) + .with_error_observation(ErrorObservation { + primary_verdict: Some(ErrorVerdict::LikelySandbox(ObservedPathHint { + path: requested.clone(), + access: AccessMode::Read, + })), + blocked_protected_file: None, + path_hints: vec![ObservedPathHint { + path: requested.clone(), + access: AccessMode::Read, + }], + missing_paths: Vec::new(), + non_sandbox_failure: None, + }); + + let output = formatter.format_footer(1); + + assert!(output.contains(&format!("{} (write)", actual.display()))); + assert!(!output.contains(&requested.display().to_string())); + } + + #[test] + fn test_sandbox_violation_preserves_workspace_prefix() { + let caps = make_test_caps(); + let actual = PathBuf::from( + "/Users/alice/workspace/flutter_photo_manager/lib/src/internal/plugin.dart", + ); + let violations = vec![SandboxViolation { + operation: "file-read-data".to_string(), + target: Some(actual.display().to_string()), + }]; + let formatter = DiagnosticFormatter::new(&caps) + .with_mode(DiagnosticMode::Supervised) + .with_sandbox_violations(&violations); + + let output = formatter.format_footer(1); + + assert!(output.contains(&format!("{} (read)", actual.display()))); + assert!(!output.contains("[nono] /src/internal/plugin.dart (read)")); + } + #[test] fn test_supervised_merges_mkdir_error_hint_with_logged_read_denial() { let temp = tempdir().expect("tempdir should be created"); From 34062dfb1f9455d7eb315f54477c033a2ad3c205 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 15 Jun 2026 15:53:18 +0100 Subject: [PATCH 52/91] chore(deps): bump typify from 0.6.2 to 0.7.0 (#1156) Bumps [typify](https://github.com/oxidecomputer/typify) from 0.6.2 to 0.7.0. - [Changelog](https://github.com/oxidecomputer/typify/blob/main/CHANGELOG.adoc) - [Commits](https://github.com/oxidecomputer/typify/compare/v0.6.2...v0.7.0) --- updated-dependencies: - dependency-name: typify dependency-version: 0.7.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Cargo.lock | 374 ++++------------------------------------- crates/nono/Cargo.toml | 2 +- 2 files changed, 33 insertions(+), 343 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b3da99920..6e6c8653a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -445,15 +445,6 @@ dependencies = [ "generic-array", ] -[[package]] -name = "block2" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" -dependencies = [ - "objc2", -] - [[package]] name = "blocking" version = "1.6.2" @@ -581,7 +572,7 @@ dependencies = [ "num-traits", "serde", "wasm-bindgen", - "windows-link 0.2.1", + "windows-link", ] [[package]] @@ -982,16 +973,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "dispatch2" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" -dependencies = [ - "bitflags", - "objc2", -] - [[package]] name = "displaydoc" version = "0.2.5" @@ -1360,9 +1341,9 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.13" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" dependencies = [ "atomic-waker", "bytes", @@ -1495,9 +1476,9 @@ dependencies = [ [[package]] name = "hyper" -version = "1.9.0" +version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" dependencies = [ "atomic-waker", "bytes", @@ -1568,7 +1549,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.62.2", + "windows-core", ] [[package]] @@ -1845,7 +1826,7 @@ dependencies = [ "simd_cesu8", "thiserror 2.0.18", "walkdir", - "windows-link 0.2.1", + "windows-link", ] [[package]] @@ -1960,7 +1941,7 @@ dependencies = [ "secret-service", "security-framework 2.11.1", "security-framework 3.7.0", - "zbus 4.4.0", + "zbus", "zeroize", ] @@ -2034,9 +2015,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.29" +version = "0.4.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" [[package]] name = "lru-slab" @@ -2044,20 +2025,6 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" -[[package]] -name = "mac-notification-sys" -version = "0.6.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd604973958ddcc11b561193c0fb96ba146506ef2f231ef2e7c35fd2cbc9beca" -dependencies = [ - "cc", - "log", - "objc2", - "objc2-foundation", - "time", - "uuid", -] - [[package]] name = "matchers" version = "0.2.0" @@ -2191,7 +2158,6 @@ dependencies = [ "colored", "dirs", "dns-lookup", - "futures", "globset", "ignore", "jsonc-parser", @@ -2201,7 +2167,6 @@ dependencies = [ "nix 0.31.3", "nono", "nono-proxy", - "notify-rust", "proptest", "rand 0.10.1", "security-framework 3.7.0", @@ -2270,20 +2235,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "notify-rust" -version = "4.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5b4c1b4f2aa9f25f63a7a49d3dd0ed567b3670da15330a66b29434be899b891" -dependencies = [ - "futures-lite", - "log", - "mac-notification-sys", - "serde", - "tauri-winrt-notification", - "zbus 5.16.0", -] - [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -2378,45 +2329,6 @@ dependencies = [ "autocfg", ] -[[package]] -name = "objc2" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" -dependencies = [ - "objc2-encode", -] - -[[package]] -name = "objc2-core-foundation" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" -dependencies = [ - "bitflags", - "dispatch2", - "objc2", -] - -[[package]] -name = "objc2-encode" -version = "4.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" - -[[package]] -name = "objc2-foundation" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" -dependencies = [ - "bitflags", - "block2", - "libc", - "objc2", - "objc2-core-foundation", -] - [[package]] name = "oid-registry" version = "0.8.1" @@ -2514,7 +2426,7 @@ dependencies = [ "libc", "redox_syscall", "smallvec", - "windows-link 0.2.1", + "windows-link", ] [[package]] @@ -3303,7 +3215,7 @@ dependencies = [ "rand 0.8.6", "serde", "sha2 0.10.9", - "zbus 4.4.0", + "zbus", ] [[package]] @@ -3921,17 +3833,6 @@ dependencies = [ "syn", ] -[[package]] -name = "tauri-winrt-notification" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed071c670382e85fc2f48ae706492d8c338f4f89bf72520d32f8abfe880aade" -dependencies = [ - "thiserror 2.0.18", - "windows", - "windows-version", -] - [[package]] name = "tempfile" version = "3.27.0" @@ -4363,9 +4264,9 @@ checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" [[package]] name = "typify" -version = "0.6.2" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc0b89f47309feaeb23c4509c15c9a04234f7deccef6f96c3bfe95319819a304" +checksum = "8cdc2e612ea322c6e232d46a0b34607c8eb28978fd6060ecfb139f2a50db8d5f" dependencies = [ "typify-impl", "typify-macro", @@ -4373,9 +4274,9 @@ dependencies = [ [[package]] name = "typify-impl" -version = "0.6.2" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7b026f540b148b81043c720889dbb942b08659aa8a43f624ac4f04dbfc1861" +checksum = "691591f49550c0d371bc441d019c30ce241d4116aee7d68df7a9840d6c70bf8f" dependencies = [ "heck", "log", @@ -4393,9 +4294,9 @@ dependencies = [ [[package]] name = "typify-macro" -version = "0.6.2" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39ed96c57f06ae0839416b986921a98f18b220da63bbb243a8570a00c8492183" +checksum = "d41aea893c49cf95661389207b8af0c6254ff48b2c3ae1e4f3704777dbdfcf03" dependencies = [ "proc-macro2", "quote", @@ -4542,18 +4443,6 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" -[[package]] -name = "uuid" -version = "1.23.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53" -dependencies = [ - "getrandom 0.4.2", - "js-sys", - "serde_core", - "wasm-bindgen", -] - [[package]] name = "uuid-simd" version = "0.8.0" @@ -4813,41 +4702,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "windows" -version = "0.61.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" -dependencies = [ - "windows-collections", - "windows-core 0.61.2", - "windows-future", - "windows-link 0.1.3", - "windows-numerics", -] - -[[package]] -name = "windows-collections" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" -dependencies = [ - "windows-core 0.61.2", -] - -[[package]] -name = "windows-core" -version = "0.61.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" -dependencies = [ - "windows-implement", - "windows-interface", - "windows-link 0.1.3", - "windows-result 0.3.4", - "windows-strings 0.4.2", -] - [[package]] name = "windows-core" version = "0.62.2" @@ -4856,20 +4710,9 @@ checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ "windows-implement", "windows-interface", - "windows-link 0.2.1", - "windows-result 0.4.1", - "windows-strings 0.5.1", -] - -[[package]] -name = "windows-future" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" -dependencies = [ - "windows-core 0.61.2", - "windows-link 0.1.3", - "windows-threading", + "windows-link", + "windows-result", + "windows-strings", ] [[package]] @@ -4894,53 +4737,19 @@ dependencies = [ "syn", ] -[[package]] -name = "windows-link" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" - [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" -[[package]] -name = "windows-numerics" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" -dependencies = [ - "windows-core 0.61.2", - "windows-link 0.1.3", -] - -[[package]] -name = "windows-result" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" -dependencies = [ - "windows-link 0.1.3", -] - [[package]] name = "windows-result" version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" dependencies = [ - "windows-link 0.2.1", -] - -[[package]] -name = "windows-strings" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" -dependencies = [ - "windows-link 0.1.3", + "windows-link", ] [[package]] @@ -4949,7 +4758,7 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" dependencies = [ - "windows-link 0.2.1", + "windows-link", ] [[package]] @@ -4994,7 +4803,7 @@ version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ - "windows-link 0.2.1", + "windows-link", ] [[package]] @@ -5034,7 +4843,7 @@ version = "0.53.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" dependencies = [ - "windows-link 0.2.1", + "windows-link", "windows_aarch64_gnullvm 0.53.1", "windows_aarch64_msvc 0.53.1", "windows_i686_gnu 0.53.1", @@ -5045,24 +4854,6 @@ dependencies = [ "windows_x86_64_msvc 0.53.1", ] -[[package]] -name = "windows-threading" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" -dependencies = [ - "windows-link 0.1.3", -] - -[[package]] -name = "windows-version" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4060a1da109b9d0326b7262c8e12c84df67cc0dbc9e33cf49e01ccc2eb63631" -dependencies = [ - "windows-link 0.2.1", -] - [[package]] name = "windows_aarch64_gnullvm" version = "0.42.2" @@ -5435,44 +5226,9 @@ dependencies = [ "uds_windows", "windows-sys 0.52.0", "xdg-home", - "zbus_macros 4.4.0", - "zbus_names 3.0.0", - "zvariant 4.2.0", -] - -[[package]] -name = "zbus" -version = "5.16.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eee682d202a77e4a9f3b2c2bdf48a7b28af5c08c34ddf66f98c93e5e39464285" -dependencies = [ - "async-broadcast", - "async-executor", - "async-io", - "async-lock", - "async-process", - "async-recursion", - "async-task", - "async-trait", - "blocking", - "enumflags2", - "event-listener", - "futures-core", - "futures-lite", - "hex", - "libc", - "ordered-stream", - "rustix", - "serde", - "serde_repr", - "tracing", - "uds_windows", - "uuid", - "windows-sys 0.61.2", - "winnow 1.0.2", - "zbus_macros 5.16.0", - "zbus_names 4.3.2", - "zvariant 5.12.0", + "zbus_macros", + "zbus_names", + "zvariant", ] [[package]] @@ -5485,22 +5241,7 @@ dependencies = [ "proc-macro2", "quote", "syn", - "zvariant_utils 2.1.0", -] - -[[package]] -name = "zbus_macros" -version = "5.16.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "adf1bd45a81a103745b1757754762a26e8cd01e4532e4d6c8ec431624b80d1d6" -dependencies = [ - "proc-macro-crate", - "proc-macro2", - "quote", - "syn", - "zbus_names 4.3.2", - "zvariant 5.12.0", - "zvariant_utils 3.4.0", + "zvariant_utils", ] [[package]] @@ -5511,18 +5252,7 @@ checksum = "4b9b1fef7d021261cc16cba64c351d291b715febe0fa10dc3a443ac5a5022e6c" dependencies = [ "serde", "static_assertions", - "zvariant 4.2.0", -] - -[[package]] -name = "zbus_names" -version = "4.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7074f3e50b894eac91750142016d30d0a89be8e67dbfd9704fb875825760e52d" -dependencies = [ - "serde", - "winnow 1.0.2", - "zvariant 5.12.0", + "zvariant", ] [[package]] @@ -5636,21 +5366,7 @@ dependencies = [ "enumflags2", "serde", "static_assertions", - "zvariant_derive 4.2.0", -] - -[[package]] -name = "zvariant" -version = "5.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a192a0bde63360d77a7523c833d4b4ce6070a927e2c53246e4c540b1a3e27be0" -dependencies = [ - "endi", - "enumflags2", - "serde", - "winnow 1.0.2", - "zvariant_derive 5.12.0", - "zvariant_utils 3.4.0", + "zvariant_derive", ] [[package]] @@ -5663,20 +5379,7 @@ dependencies = [ "proc-macro2", "quote", "syn", - "zvariant_utils 2.1.0", -] - -[[package]] -name = "zvariant_derive" -version = "5.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90bc6cde9c01c511074be97f7ccb6c19d0da89e3f8662e812e999dcfd4638737" -dependencies = [ - "proc-macro-crate", - "proc-macro2", - "quote", - "syn", - "zvariant_utils 3.4.0", + "zvariant_utils", ] [[package]] @@ -5689,16 +5392,3 @@ dependencies = [ "quote", "syn", ] - -[[package]] -name = "zvariant_utils" -version = "3.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e8535915cfa75547e559d8c68e8139909a4aeee076831e4ef7fc59d8172c4d6" -dependencies = [ - "proc-macro2", - "quote", - "serde", - "syn", - "winnow 1.0.2", -] diff --git a/crates/nono/Cargo.toml b/crates/nono/Cargo.toml index 8b74d121f..ac707d3c4 100644 --- a/crates/nono/Cargo.toml +++ b/crates/nono/Cargo.toml @@ -69,7 +69,7 @@ nix = { workspace = true, features = ["fs", "user"] } keyring = { version = "3", features = ["apple-native"], optional = true, default-features = false } [build-dependencies] -typify = "0.6" +typify = "0.7" serde_json.workspace = true syn = "2" prettyplease = "0.2" From f49f45aff3d9dc9eb34adce9499143e0e92d2402 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 15 Jun 2026 16:11:59 +0100 Subject: [PATCH 53/91] chore(deps): bump zeroize from 1.8.2 to 1.9.0 (#1157) Bumps [zeroize](https://github.com/RustCrypto/utils) from 1.8.2 to 1.9.0. - [Commits](https://github.com/RustCrypto/utils/compare/zeroize-v1.8.2...zeroize-v1.9.0) --- updated-dependencies: - dependency-name: zeroize dependency-version: 1.9.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Aleks <121458075+SequeI@users.noreply.github.com> --- Cargo.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6e6c8653a..26cfe61a3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5298,9 +5298,9 @@ dependencies = [ [[package]] name = "zeroize" -version = "1.8.2" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" dependencies = [ "serde", "zeroize_derive", @@ -5308,9 +5308,9 @@ dependencies = [ [[package]] name = "zeroize_derive" -version = "1.4.3" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" dependencies = [ "proc-macro2", "quote", From d4d125a2f178ff43f4ff7c44e6ab9b6ba905845d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 15 Jun 2026 16:22:38 +0100 Subject: [PATCH 54/91] chore(deps): bump time from 0.3.47 to 0.3.49 (#1158) Bumps [time](https://github.com/time-rs/time) from 0.3.47 to 0.3.49. - [Release notes](https://github.com/time-rs/time/releases) - [Changelog](https://github.com/time-rs/time/blob/main/CHANGELOG.md) - [Commits](https://github.com/time-rs/time/compare/v0.3.47...v0.3.49) --- updated-dependencies: - dependency-name: time dependency-version: 0.3.49 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Aleks <121458075+SequeI@users.noreply.github.com> --- Cargo.lock | 20 ++++++++------------ 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 26cfe61a3..8168e426b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -917,9 +917,6 @@ name = "deranged" version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" -dependencies = [ - "powerfmt", -] [[package]] name = "digest" @@ -2285,9 +2282,9 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" [[package]] name = "num-integer" @@ -3897,12 +3894,11 @@ dependencies = [ [[package]] name = "time" -version = "0.3.47" +version = "0.3.49" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +checksum = "711a53c2d47bbd818258c498c8dbfe186a2526c631495cfe7e078567f86b8469" dependencies = [ "deranged", - "itoa", "num-conv", "powerfmt", "serde_core", @@ -3912,15 +3908,15 @@ dependencies = [ [[package]] name = "time-core" -version = "0.1.8" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" [[package]] name = "time-macros" -version = "0.2.27" +version = "0.2.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +checksum = "71c652a3727a9cbb9a02f707f530b618ce00d0ccd762009c8c23bd191df3c17d" dependencies = [ "num-conv", "time-core", From 578838216cfc55b4de832a1dbd945adac6723e41 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 15 Jun 2026 16:35:26 +0100 Subject: [PATCH 55/91] chore(deps): bump chrono from 0.4.44 to 0.4.45 (#1159) Bumps [chrono](https://github.com/chronotope/chrono) from 0.4.44 to 0.4.45. - [Release notes](https://github.com/chronotope/chrono/releases) - [Changelog](https://github.com/chronotope/chrono/blob/main/CHANGELOG.md) - [Commits](https://github.com/chronotope/chrono/compare/v0.4.44...v0.4.45) --- updated-dependencies: - dependency-name: chrono dependency-version: 0.4.45 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Aleks <121458075+SequeI@users.noreply.github.com> --- Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8168e426b..0661913d2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -563,9 +563,9 @@ dependencies = [ [[package]] name = "chrono" -version = "0.4.44" +version = "0.4.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" dependencies = [ "iana-time-zone", "js-sys", From 14cf6901d86b62ae27519d990bc7245136780843 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 15 Jun 2026 16:36:08 +0100 Subject: [PATCH 56/91] chore(deps): bump ignore from 0.4.25 to 0.4.26 (#1160) Bumps [ignore](https://github.com/BurntSushi/ripgrep) from 0.4.25 to 0.4.26. - [Release notes](https://github.com/BurntSushi/ripgrep/releases) - [Changelog](https://github.com/BurntSushi/ripgrep/blob/master/CHANGELOG.md) - [Commits](https://github.com/BurntSushi/ripgrep/compare/ignore-0.4.25...ignore-0.4.26) --- updated-dependencies: - dependency-name: ignore dependency-version: 0.4.26 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Cargo.lock | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0661913d2..b7a63ceb4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -107,7 +107,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -118,7 +118,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -679,7 +679,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -967,7 +967,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -1054,7 +1054,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -1669,9 +1669,9 @@ dependencies = [ [[package]] name = "ignore" -version = "0.4.25" +version = "0.4.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3d782a365a015e0f5c04902246139249abf769125006fbe7649e2ee88169b4a" +checksum = "b915661dd01db3f05050265b2477bcc6527b3792388e2749b41623cc592be67d" dependencies = [ "crossbeam-deque", "globset", @@ -1764,7 +1764,7 @@ dependencies = [ "portable-atomic", "portable-atomic-util", "serde_core", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -2238,7 +2238,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -3008,7 +3008,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -3067,7 +3067,7 @@ dependencies = [ "security-framework 3.7.0", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -3088,7 +3088,7 @@ dependencies = [ "security-framework 3.7.0", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -3762,7 +3762,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -3840,7 +3840,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -4313,7 +4313,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -4695,7 +4695,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] From 2c0e1350b625ce4b8c3e6ac6f780ef670c3ecc03 Mon Sep 17 00:00:00 2001 From: Anugrah Singhal Date: Mon, 15 Jun 2026 22:46:28 +0530 Subject: [PATCH 57/91] fix(proxy): keep connection open for reactive proxy auth on CONNECT (#1151) * fix(proxy): keep connection open for reactive proxy auth on CONNECT The intercept CONNECT path replied 407 then returned, dropping the socket, so clients that authenticate reactively (CONNECT -> 407 -> retry with Proxy-Authorization on the same connection) failed on the retry. Keep the connection open across the 407 and re-read the retried CONNECT, re-validating on the same socket. The retry must target the same authority; client disconnects and oversized heads still close the connection. Signed-off-by: Anugrah Singhal * fix(proxy): normalize retried CONNECT authority before comparison Hostnames are case-insensitive and the CONNECT port may be implicit (RFC 9110), so comparing the retried authority raw against the original raw authority wrongly rejected legitimate retries that differed only in hostname casing or explicit/implicit :443 (e.g. API.OPENAI.COM:443 vs api.openai.com). Normalize the retried authority via the shared normalize_authority helper and compare against the already-normalized host_port, matching the initial parse. Addresses the review comment on PR #1151. Signed-off-by: Anugrah Singhal --------- Signed-off-by: Anugrah Singhal Co-authored-by: Aleks <121458075+SequeI@users.noreply.github.com> --- crates/nono-proxy/src/server.rs | 252 ++++++++++++++++++++++++++------ 1 file changed, 209 insertions(+), 43 deletions(-) diff --git a/crates/nono-proxy/src/server.rs b/crates/nono-proxy/src/server.rs index 1be8e6f63..71fa9808c 100644 --- a/crates/nono-proxy/src/server.rs +++ b/crates/nono-proxy/src/server.rs @@ -644,6 +644,23 @@ async fn accept_loop( } } +/// Normalise a CONNECT authority to lowercase `host:port`, defaulting the port +/// to 443 when absent. Handles IPv6 brackets: `[::1]:443` already has a port, +/// `[::1]` needs the default, `host:443` has a port. +fn normalize_authority(authority: &str) -> String { + if authority.starts_with('[') { + if authority.contains("]:") { + authority.to_lowercase() + } else { + format!("{}:443", authority.to_lowercase()) + } + } else if authority.contains(':') { + authority.to_lowercase() + } else { + format!("{}:443", authority.to_lowercase()) + } +} + /// Handle a single client connection. /// /// Reads the first HTTP line to determine the proxy mode: @@ -707,19 +724,7 @@ async fn handle_connection(mut stream: tokio::net::TcpStream, state: &ProxyState if !state.route_store.is_empty() && let Some(authority) = first_line.split_whitespace().nth(1) { - // Normalise authority to host:port. Handle IPv6 brackets: - // "[::1]:443" already has port, "[::1]" needs default, "host:443" has port. - let host_port = if authority.starts_with('[') { - if authority.contains("]:") { - authority.to_lowercase() - } else { - format!("{}:443", authority.to_lowercase()) - } - } else if authority.contains(':') { - authority.to_lowercase() - } else { - format!("{}:443", authority.to_lowercase()) - }; + let host_port = normalize_authority(authority); if state.route_store.is_route_upstream(&host_port) { let route_id = state @@ -740,36 +745,89 @@ async fn handle_connection(mut stream: tokio::net::TcpStream, state: &ProxyState // (we mint a leaf cert and decrypt traffic), so // unlike the lenient transparent-tunnel path we // require Proxy-Authorization here. - if let Err(e) = - token::validate_proxy_auth(&header_bytes, &state.session_token) - { - debug!( - "tls_intercept: rejecting CONNECT to {}:{} — {}", - host, port, e - ); - audit::log_denied( - Some(&state.audit_log), - audit::ProxyMode::ConnectIntercept, - &audit::EventContext { - route_id, - auth_mechanism: Some( - nono::undo::NetworkAuditAuthMechanism::ProxyAuthorization, - ), - auth_outcome: Some( - nono::undo::NetworkAuditAuthOutcome::Failed, - ), - denial_category: Some( - nono::undo::NetworkAuditDenialCategory::AuthenticationFailed, - ), - ..audit::EventContext::default() - }, - &host, - port, - "proxy auth missing or invalid", - ); - let response = "HTTP/1.1 407 Proxy Authentication Required\r\nProxy-Authenticate: Basic realm=\"nono\"\r\nContent-Length: 0\r\n\r\n"; - stream.write_all(response.as_bytes()).await?; - return Ok(()); + // Reactive proxy auth (RFC 7235 / RFC 9110 §15.5.8): a + // client may send the first CONNECT without credentials, + // receive the 407 challenge, then retry the CONNECT with + // Proxy-Authorization on the SAME connection. Keep the + // connection open across the 407 and re-read the retried + // request head rather than dropping the socket — closing + // it breaks reactive clients (Apache HttpClient, Java's + // HttpClient, Maven's native resolver). + let mut current_headers = header_bytes; + loop { + match token::validate_proxy_auth(¤t_headers, &state.session_token) + { + Ok(()) => break, + Err(e) => { + debug!( + "tls_intercept: CONNECT to {}:{} missing/invalid proxy auth — {}", + host, port, e + ); + audit::log_denied( + Some(&state.audit_log), + audit::ProxyMode::ConnectIntercept, + &audit::EventContext { + route_id, + auth_mechanism: Some( + nono::undo::NetworkAuditAuthMechanism::ProxyAuthorization, + ), + auth_outcome: Some( + nono::undo::NetworkAuditAuthOutcome::Failed, + ), + denial_category: Some( + nono::undo::NetworkAuditDenialCategory::AuthenticationFailed, + ), + ..audit::EventContext::default() + }, + &host, + port, + "proxy auth missing or invalid", + ); + let response = "HTTP/1.1 407 Proxy Authentication Required\r\nProxy-Authenticate: Basic realm=\"nono\"\r\nContent-Length: 0\r\n\r\n"; + stream.write_all(response.as_bytes()).await?; + + // Read the client's retried request head on + // the same connection. + let mut buf_reader = BufReader::new(&mut stream); + let mut retry_line = String::new(); + buf_reader.read_line(&mut retry_line).await?; + if retry_line.is_empty() { + return Ok(()); // client disconnected + } + let mut retry_headers = Vec::new(); + loop { + let mut line = String::new(); + let n = buf_reader.read_line(&mut line).await?; + if n == 0 || line.trim().is_empty() { + break; + } + retry_headers.extend_from_slice(line.as_bytes()); + if retry_headers.len() > MAX_HEADER_SIZE { + drop(buf_reader); + let too_large = "HTTP/1.1 431 Request Header Fields Too Large\r\n\r\n"; + stream.write_all(too_large.as_bytes()).await?; + return Ok(()); + } + } + drop(buf_reader); + + // host/port/route are reused from the first + // CONNECT, so the retry must target the same + // authority; anything else (or a non-CONNECT + // request) would desync routing. + let same_authority = retry_line + .trim_end() + .strip_prefix("CONNECT ") + .and_then(|rest| rest.split_whitespace().next()) + .map(normalize_authority) + .as_deref() + == Some(host_port.as_str()); + if !same_authority { + return Ok(()); + } + current_headers = retry_headers; + } + } } let ctx = tls_intercept::InterceptCtx { @@ -965,6 +1023,26 @@ async fn handle_connection(mut stream: tokio::net::TcpStream, state: &ProxyState mod tests { use super::*; + #[test] + fn normalize_authority_normalises_case_and_default_port() { + assert_eq!(normalize_authority("API.OpenAI.com"), "api.openai.com:443"); + assert_eq!( + normalize_authority("api.openai.com:443"), + "api.openai.com:443" + ); + assert_eq!( + normalize_authority("api.openai.com:8443"), + "api.openai.com:8443" + ); + assert_eq!(normalize_authority("[::1]"), "[::1]:443"); + assert_eq!(normalize_authority("[::1]:8443"), "[::1]:8443"); + // case- and port-insensitive equality is the point of the retry guard + assert_eq!( + normalize_authority("API.OPENAI.COM:443"), + normalize_authority("api.openai.com") + ); + } + #[tokio::test] async fn test_proxy_starts_and_binds() { let config = ProxyConfig::default(); @@ -1782,4 +1860,92 @@ mod tests { handle.shutdown(); } + + /// Regression test for reactive proxy auth on the intercept CONNECT path. + /// After a 407 the proxy must keep the connection open and answer the + /// client's credentialed retry on the same socket, rather than closing it + /// (which breaks reactive clients such as Apache HttpClient / Maven's + /// native resolver). + #[tokio::test] + async fn reactive_proxy_auth_retry_answered_after_407() { + use base64::Engine; + use std::time::Duration; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + use tokio::net::TcpStream; + + let dir = tempfile::tempdir().unwrap(); + let config = ProxyConfig { + routes: vec![crate::config::RouteConfig { + prefix: "openai".to_string(), + upstream: "https://api.openai.com".to_string(), + credential_key: Some("env://NONO_TEST_TOTALLY_MISSING".to_string()), + inject_mode: Default::default(), + inject_header: "Authorization".to_string(), + credential_format: Some("Bearer {}".to_string()), + path_pattern: None, + path_replacement: None, + query_param_name: None, + proxy: None, + env_var: None, + endpoint_rules: vec![], + tls_ca: None, + tls_client_cert: None, + tls_client_key: None, + oauth2: None, + }], + intercept_ca_dir: Some(dir.path().to_path_buf()), + ..Default::default() + }; + let handle = start(config).await.unwrap(); + assert!( + handle.intercept_ca_path().is_some(), + "precondition: interception must be active so the 407 path is reached" + ); + let port = handle.port; + let token = handle.token.to_string(); + + let mut sock = TcpStream::connect(("127.0.0.1", port)).await.unwrap(); + + // 1) Unauthenticated CONNECT -> expect a 407 challenge. + sock.write_all(b"CONNECT api.openai.com:443 HTTP/1.1\r\nHost: api.openai.com:443\r\n\r\n") + .await + .unwrap(); + sock.flush().await.unwrap(); + + let mut buf = [0u8; 4096]; + let n = sock.read(&mut buf).await.unwrap(); + let response = String::from_utf8_lossy(&buf[..n]); + assert!( + response.starts_with("HTTP/1.1 407 "), + "expected 407 challenge, got: {:?}", + response + ); + + // 2) Reactive retry WITH valid credentials on the SAME socket. + let creds = base64::engine::general_purpose::STANDARD.encode(format!("nono:{}", token)); + let retry = format!( + "CONNECT api.openai.com:443 HTTP/1.1\r\nHost: api.openai.com:443\r\nProxy-Authorization: Basic {}\r\n\r\n", + creds + ); + sock.write_all(retry.as_bytes()).await.unwrap(); + sock.flush().await.unwrap(); + + // 3) The proxy must answer the retried CONNECT on the same socket + // instead of returning EOF. (The upstream connect to api.openai.com + // may fail in the test env, so we require a response, not a 200.) + let mut retry_buf = [0u8; 4096]; + let read_result = + tokio::time::timeout(Duration::from_secs(5), sock.read(&mut retry_buf)).await; + match read_result { + Ok(Ok(0)) => panic!( + "regression: proxy closed the socket after the 407 instead of \ + answering the reactive retry" + ), + Ok(Ok(_)) => {} // answered -> reactive auth handled + Ok(Err(e)) => panic!("retry read errored: {e}"), + Err(_) => panic!("retry read timed out — proxy did not answer the retry"), + } + + handle.shutdown(); + } } From 63c05cd8794466f36ae1ba074864b6f475eff9db Mon Sep 17 00:00:00 2001 From: Aleks <121458075+SequeI@users.noreply.github.com> Date: Mon, 15 Jun 2026 18:28:27 +0100 Subject: [PATCH 58/91] feat(cli): move runtime state to XDG state dirs (#1152) * feat(cli): move runtime state to XDG state dirs Store audit, session registry, and rollback data under /nono/ (default ~/.local/state/nono/) on all platforms. Keep read fallback and one-time deprecation warnings for ~/.nono/ until v1.0.0. Signed-off-by: Aleksy Siek * fix: review changes Signed-off-by: Aleksy Siek --------- Signed-off-by: Aleksy Siek --- crates/nono-cli/src/audit_ledger.rs | 38 +- crates/nono-cli/src/audit_session.rs | 138 +++++- crates/nono-cli/src/cli.rs | 2 +- crates/nono-cli/src/config/mod.rs | 7 +- crates/nono-cli/src/launch_runtime.rs | 2 +- crates/nono-cli/src/main.rs | 1 + crates/nono-cli/src/protected_paths.rs | 9 +- crates/nono-cli/src/proxy_runtime.rs | 18 +- crates/nono-cli/src/rollback_commands.rs | 6 +- crates/nono-cli/src/rollback_session.rs | 230 ++++++---- crates/nono-cli/src/session.rs | 192 ++++---- crates/nono-cli/src/state_paths.rs | 422 ++++++++++++++++++ crates/nono-cli/tests/audit_attestation.rs | 40 +- docs/cli/features/atomic-rollbacks.mdx | 6 +- docs/cli/features/audit.mdx | 2 +- docs/cli/features/supervisor.mdx | 2 +- docs/cli/internals/security-model.mdx | 2 +- docs/cli/usage/flags.mdx | 2 +- tests/integration/test_audit.sh | 6 +- .../test_rollback_dest_edge_cases.sh | 6 +- tests/run_integration_tests.sh | 8 +- 21 files changed, 876 insertions(+), 263 deletions(-) create mode 100644 crates/nono-cli/src/state_paths.rs diff --git a/crates/nono-cli/src/audit_ledger.rs b/crates/nono-cli/src/audit_ledger.rs index 6b75b8f6c..43601f4b9 100644 --- a/crates/nono-cli/src/audit_ledger.rs +++ b/crates/nono-cli/src/audit_ledger.rs @@ -1,4 +1,5 @@ use crate::audit_session::audit_root; +use crate::state_paths; use nix::fcntl::{Flock, FlockArg}; use nono::audit::{ LedgerRecord, LedgerVerificationResult, append_session_to_ledger_file, @@ -9,7 +10,7 @@ use nono::undo::SessionMetadata; use nono::{NonoError, Result}; use std::fs::OpenOptions; use std::io::BufReader; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; const AUDIT_LEDGER_FILENAME: &str = "ledger.ndjson"; const AUDIT_LEDGER_LOCK_FILENAME: &str = "ledger.lock"; @@ -27,6 +28,7 @@ pub(crate) fn append_session(metadata: &SessionMetadata) -> Result let path = root.join(AUDIT_LEDGER_FILENAME); let _lock = LedgerLock::acquire(root.join(AUDIT_LEDGER_LOCK_FILENAME))?; + state_paths::maybe_migrate_legacy_audit_ledger()?; let mut file = OpenOptions::new() .create(true) .read(true) @@ -45,7 +47,29 @@ pub(crate) fn append_session(metadata: &SessionMetadata) -> Result pub(crate) fn verify_session_in_ledger( metadata: &SessionMetadata, ) -> Result { - let root = audit_root()?; + let primary = audit_root()?; + let result = verify_session_in_ledger_at_root(&primary, metadata)?; + if result.session_found { + return Ok(result); + } + + if let Ok(legacy) = state_paths::legacy_audit_root() + && legacy != primary + { + let legacy_ledger = legacy.join(AUDIT_LEDGER_FILENAME); + if legacy_ledger.exists() { + state_paths::warn_legacy_audit_path(&legacy); + return verify_session_in_ledger_at_root(&legacy, metadata); + } + } + + Ok(result) +} + +fn verify_session_in_ledger_at_root( + root: &Path, + metadata: &SessionMetadata, +) -> Result { let path = root.join(AUDIT_LEDGER_FILENAME); if !path.exists() { return missing_ledger_verification_result(metadata); @@ -125,8 +149,11 @@ mod tests { fn ledger_appends_and_verifies_session_digest() { let _env_lock = ENV_LOCK.lock().unwrap(); let tmp = tempfile::tempdir().unwrap(); + let state = tmp.path().join("state"); + std::fs::create_dir_all(&state).unwrap(); let home = tmp.path().to_string_lossy().to_string(); - let _env = EnvVarGuard::set_all(&[("HOME", &home)]); + let state_str = state.to_string_lossy().to_string(); + let _env = EnvVarGuard::set_all(&[("HOME", &home), ("XDG_STATE_HOME", &state_str)]); let meta = sample_metadata("20260421-200000-11111"); append_session(&meta).unwrap(); @@ -142,8 +169,11 @@ mod tests { fn ledger_rejects_malformed_session_id() { let _env_lock = ENV_LOCK.lock().unwrap(); let tmp = tempfile::tempdir().unwrap(); + let state = tmp.path().join("state"); + std::fs::create_dir_all(&state).unwrap(); let home = tmp.path().to_string_lossy().to_string(); - let _env = EnvVarGuard::set_all(&[("HOME", &home)]); + let state_str = state.to_string_lossy().to_string(); + let _env = EnvVarGuard::set_all(&[("HOME", &home), ("XDG_STATE_HOME", &state_str)]); let meta = sample_metadata("real-token\\|real-key"); let err = match append_session(&meta) { diff --git a/crates/nono-cli/src/audit_session.rs b/crates/nono-cli/src/audit_session.rs index 8a65f7b76..6b06aabbe 100644 --- a/crates/nono-cli/src/audit_session.rs +++ b/crates/nono-cli/src/audit_session.rs @@ -1,10 +1,12 @@ //! Session discovery and management for the audit system. //! -//! Audit sessions are stored in `~/.nono/audit/`. For backwards -//! compatibility, the audit commands also read legacy audit metadata from -//! `~/.nono/rollbacks/` when no migrated audit entry exists yet. +//! Audit sessions are stored under `$XDG_STATE_HOME/nono/audit/` (default +//! `~/.local/state/nono/audit/`). For backwards compatibility, reads also +//! check `~/.nono/audit/` until v1.0.0, and legacy audit metadata under +//! `~/.nono/rollbacks/` (or the canonical rollback root) when no migrated +//! audit entry exists yet. -use crate::rollback_session; +use crate::state_paths; use nono::undo::{SessionMetadata, SnapshotManager}; use nono::{NonoError, Result}; use std::collections::BTreeSet; @@ -27,10 +29,9 @@ pub struct SessionInfo { pub is_stale: bool, } -/// Get the audit root directory (`~/.nono/audit/`) +/// Get the canonical audit root directory (`$XDG_STATE_HOME/nono/audit/`). pub fn audit_root() -> Result { - let home = dirs::home_dir().ok_or(NonoError::HomeNotFound)?; - Ok(home.join(".nono").join("audit")) + state_paths::audit_root() } /// Discover all audit sessions. @@ -42,14 +43,12 @@ pub fn discover_sessions() -> Result> { let mut sessions = Vec::new(); let mut seen_ids = BTreeSet::new(); let primary_root = audit_root()?; + let legacy_roots = state_paths::LegacyRootSet::resolve()?; - for root in [ - Some(primary_root.clone()), - rollback_session::rollback_root().ok(), - ] { - let Some(root) = root else { - continue; - }; + let mut roots: Vec = state_paths::audit_discovery_roots()?; + roots.extend(state_paths::rollback_discovery_roots()?); + + for root in roots { if !root.exists() { continue; } @@ -86,6 +85,7 @@ pub fn discover_sessions() -> Result> { continue; } + legacy_roots.warn_if_legacy_audit_data_read(&dir); sessions.push(build_session_info(dir, metadata)); } } @@ -98,12 +98,11 @@ pub fn discover_sessions() -> Result> { pub fn load_session(session_id: &str) -> Result { validate_session_id(session_id)?; let primary_root = audit_root()?; - let roots = [ - Some(primary_root.clone()), - rollback_session::rollback_root().ok(), - ]; + let legacy_roots = state_paths::LegacyRootSet::resolve()?; + let mut roots: Vec = state_paths::audit_discovery_roots()?; + roots.extend(state_paths::rollback_discovery_roots()?); - for root in roots.into_iter().flatten() { + for root in roots { let dir = root.join(session_id); if !dir.exists() { continue; @@ -129,6 +128,7 @@ pub fn load_session(session_id: &str) -> Result { continue; } + legacy_roots.warn_if_legacy_audit_data_read(&dir); return Ok(build_session_info(dir, metadata)); } @@ -242,8 +242,11 @@ mod tests { fn discover_sessions_excludes_rollback_backed_entries() { let _env_lock = ENV_LOCK.lock().unwrap(); let tmp = tempfile::tempdir().unwrap(); + let state = tmp.path().join("state"); + fs::create_dir_all(&state).unwrap(); let home = tmp.path().to_string_lossy().to_string(); - let _env = EnvVarGuard::set_all(&[("HOME", &home)]); + let state_str = state.to_string_lossy().to_string(); + let _env = EnvVarGuard::set_all(&[("HOME", &home), ("XDG_STATE_HOME", &state_str)]); let audit_dir = audit_root().unwrap().join("20260421-111111-10001"); fs::create_dir_all(&audit_dir).unwrap(); @@ -267,7 +270,7 @@ mod tests { ) .unwrap(); - let legacy_audit_dir = rollback_session::rollback_root() + let legacy_audit_dir = state_paths::legacy_rollback_root() .unwrap() .join("20260421-111111-10002"); fs::create_dir_all(&legacy_audit_dir).unwrap(); @@ -291,7 +294,7 @@ mod tests { ) .unwrap(); - let rollback_dir = rollback_session::rollback_root() + let rollback_dir = state_paths::legacy_rollback_root() .unwrap() .join("20260421-111111-10003"); fs::create_dir_all(&rollback_dir).unwrap(); @@ -325,4 +328,95 @@ mod tests { assert!(ids.contains(&"20260421-111111-10002")); assert!(!ids.contains(&"20260421-111111-10003")); } + + #[test] + fn discover_sessions_reads_legacy_audit_root() { + let _env_lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let tmp = tempfile::tempdir().unwrap(); + let state = tmp.path().join("state"); + fs::create_dir_all(&state).unwrap(); + let home = tmp.path().to_string_lossy().to_string(); + let state_str = state.to_string_lossy().to_string(); + let _env = EnvVarGuard::set_all(&[("HOME", &home), ("XDG_STATE_HOME", &state_str)]); + + let legacy_audit_dir = state_paths::legacy_audit_root() + .unwrap() + .join("20260421-111111-20001"); + fs::create_dir_all(&legacy_audit_dir).unwrap(); + SnapshotManager::write_session_metadata( + &legacy_audit_dir, + &SessionMetadata { + session_id: "20260421-111111-20001".to_string(), + started: "2026-04-21T11:11:11+01:00".to_string(), + ended: Some("2026-04-21T11:11:12+01:00".to_string()), + command: vec!["/bin/echo".to_string()], + executable_identity: None, + tracked_paths: vec![PathBuf::from("/tmp/work")], + snapshot_count: 0, + exit_code: Some(0), + merkle_roots: Vec::new(), + network_events: Vec::new(), + audit_event_count: 1, + audit_integrity: None, + audit_attestation: None, + }, + ) + .unwrap(); + + let sessions = discover_sessions().unwrap(); + let ids: Vec<_> = sessions + .iter() + .map(|s| s.metadata.session_id.as_str()) + .collect(); + assert!(ids.contains(&"20260421-111111-20001")); + assert!(is_legacy_audit_only_session( + sessions + .iter() + .find(|s| s.metadata.session_id == "20260421-111111-20001") + .expect("legacy session") + )); + } + + #[test] + fn discover_sessions_does_not_warn_when_legacy_audit_root_is_empty() { + let _env_lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let tmp = tempfile::tempdir().unwrap(); + let state = tmp.path().join("state"); + fs::create_dir_all(&state).unwrap(); + let home = tmp.path().to_string_lossy().to_string(); + let state_str = state.to_string_lossy().to_string(); + let _env = EnvVarGuard::set_all(&[("HOME", &home), ("XDG_STATE_HOME", &state_str)]); + + let legacy_root = state_paths::legacy_audit_root().unwrap(); + fs::create_dir_all(&legacy_root).unwrap(); + fs::write(legacy_root.join("ledger.ndjson"), b"{}\n").unwrap(); + + let canonical_dir = state_paths::audit_root() + .unwrap() + .join("20260421-111111-30001"); + fs::create_dir_all(&canonical_dir).unwrap(); + SnapshotManager::write_session_metadata( + &canonical_dir, + &SessionMetadata { + session_id: "20260421-111111-30001".to_string(), + started: "2026-04-21T11:11:11+01:00".to_string(), + ended: Some("2026-04-21T11:11:12+01:00".to_string()), + command: vec!["/bin/echo".to_string()], + executable_identity: None, + tracked_paths: vec![PathBuf::from("/tmp/work")], + snapshot_count: 0, + exit_code: Some(0), + merkle_roots: Vec::new(), + network_events: Vec::new(), + audit_event_count: 1, + audit_integrity: None, + audit_attestation: None, + }, + ) + .unwrap(); + + let sessions = discover_sessions().unwrap(); + assert_eq!(sessions.len(), 1); + assert!(is_primary_audit_session(&sessions[0].dir)); + } } diff --git a/crates/nono-cli/src/cli.rs b/crates/nono-cli/src/cli.rs index 143d25e93..bb70e6e5f 100644 --- a/crates/nono-cli/src/cli.rs +++ b/crates/nono-cli/src/cli.rs @@ -1726,7 +1726,7 @@ pub struct RunArgs { pub skip_dir: Vec, /// Override the rollback snapshot destination directory. - /// By default, snapshots are stored in ~/.nono/rollbacks/. + /// By default, snapshots are stored in $XDG_STATE_HOME/nono/rollbacks/. /// The destination must be within a path already granted write access /// by --allow (or profile); nono will fail with a clear error if not. /// Useful for Docker volume mounts or shared storage paths. diff --git a/crates/nono-cli/src/config/mod.rs b/crates/nono-cli/src/config/mod.rs index ee53eb0b5..53b73f3d1 100644 --- a/crates/nono-cli/src/config/mod.rs +++ b/crates/nono-cli/src/config/mod.rs @@ -63,12 +63,9 @@ pub fn user_config_dir() -> Option { dirs::config_dir().map(|p| p.join("nono")) } -/// Get the user state directory path (for version tracking) -#[allow(dead_code)] +/// Get the user state directory path (for version tracking and runtime state) pub fn user_state_dir() -> Option { - dirs::state_dir() - .or_else(dirs::data_local_dir) - .map(|p| p.join("nono")) + crate::state_paths::user_state_dir().ok() } // ============================================================================ diff --git a/crates/nono-cli/src/launch_runtime.rs b/crates/nono-cli/src/launch_runtime.rs index f7a19d1c3..0839073ed 100644 --- a/crates/nono-cli/src/launch_runtime.rs +++ b/crates/nono-cli/src/launch_runtime.rs @@ -435,7 +435,7 @@ fn validate_rollback_destination( Err(NonoError::ConfigParse(format!( "--rollback-dest '{}' is not covered by sandbox write permissions. \ - Add --allow {} to grant access, or omit --rollback-dest to use the default path (~/.nono/rollbacks/).", + Add --allow {} to grant access, or omit --rollback-dest to use the default path ($XDG_STATE_HOME/nono/rollbacks/).", dest.display(), dest.display() ))) diff --git a/crates/nono-cli/src/main.rs b/crates/nono-cli/src/main.rs index 7117236e1..b0fd09b3b 100644 --- a/crates/nono-cli/src/main.rs +++ b/crates/nono-cli/src/main.rs @@ -67,6 +67,7 @@ mod session_commands; mod setup; mod startup_prompt; mod startup_runtime; +mod state_paths; mod supervised_runtime; mod terminal_approval; mod theme; diff --git a/crates/nono-cli/src/protected_paths.rs b/crates/nono-cli/src/protected_paths.rs index bfa5074de..270eddfbd 100644 --- a/crates/nono-cli/src/protected_paths.rs +++ b/crates/nono-cli/src/protected_paths.rs @@ -1,7 +1,7 @@ //! Protection for nono's own state paths. //! //! These checks enforce a hard fail if initial sandbox capabilities overlap -//! with internal CLI state roots (currently `~/.nono`). +//! with internal CLI state roots (`~/.nono` and `$XDG_STATE_HOME/nono`). use nono::{CapabilitySet, NonoError, Result, try_canonicalize}; use std::path::{Path, PathBuf}; @@ -17,12 +17,11 @@ pub struct ProtectedRoots { impl ProtectedRoots { /// Build protected roots from current defaults. /// - /// Today this protects the full `~/.nono` subtree. + /// Protects both the legacy `~/.nono` tree and the canonical `$XDG_STATE_HOME/nono` + /// runtime state directory (audit, sessions, rollbacks). pub fn from_defaults() -> Result { - let home = dirs::home_dir().ok_or(NonoError::HomeNotFound)?; - let state_root = try_canonicalize(&home.join(".nono")); Ok(Self { - roots: vec![state_root], + roots: crate::state_paths::protected_state_roots()?, }) } diff --git a/crates/nono-cli/src/proxy_runtime.rs b/crates/nono-cli/src/proxy_runtime.rs index 34f703499..e4a4ba983 100644 --- a/crates/nono-cli/src/proxy_runtime.rs +++ b/crates/nono-cli/src/proxy_runtime.rs @@ -493,20 +493,17 @@ pub(crate) fn start_proxy_runtime( } /// Choose the directory the proxy will write the TLS-intercept trust bundle -/// into. Conventionally `~/.nono/sessions//`, kept owner-only. +/// into. Conventionally `$XDG_STATE_HOME/nono/sessions//`, kept owner-only. /// /// Returns `Ok(None)` if no `HOME` is set (rare edge cases like CI). We log /// a warning rather than failing because TLS interception is opt-in: a /// missing directory just means CONNECTs to L7-bearing routes will get the /// usual 403, which is a coherent fallback rather than a hard error. fn prepare_intercept_ca_dir() -> Result> { - let home = match dirs::home_dir() { - Some(h) => h, - None => { - warn!( - "no $HOME found; skipping TLS-intercept setup (CONNECTs to L7-bearing routes \ - will be denied with 403)" - ); + let dir = match crate::session::ensure_sessions_dir() { + Ok(base) => base, + Err(e) => { + warn!("cannot resolve session registry for TLS-intercept setup: {e}; skipping"); return Ok(None); } }; @@ -520,10 +517,7 @@ fn prepare_intercept_ca_dir() -> Result> { .map(|d| d.subsec_nanos()) .unwrap_or(0); let suffix = format!("{}-{:09}", pid, nanos); - let dir = home - .join(".nono") - .join("sessions") - .join(format!("intercept-{}", suffix)); + let dir = dir.join(format!("intercept-{suffix}")); if let Err(e) = std::fs::create_dir_all(&dir) { warn!( "failed to create TLS-intercept dir '{}': {}; skipping interception", diff --git a/crates/nono-cli/src/rollback_commands.rs b/crates/nono-cli/src/rollback_commands.rs index 2be3e4b8f..ea4cdf900 100644 --- a/crates/nono-cli/src/rollback_commands.rs +++ b/crates/nono-cli/src/rollback_commands.rs @@ -10,8 +10,9 @@ use crate::command_display::{format_command_line, truncate_chars}; use crate::config::user::load_user_config; use crate::rollback_base_exclusions; use crate::rollback_session::{ - SessionInfo, discover_sessions, format_bytes, load_session, remove_session, rollback_root, + SessionInfo, discover_sessions, format_bytes, load_session, remove_session, }; +use crate::state_paths; use crate::theme; use colored::Colorize; use nono::undo::{MerkleTree, ObjectStore, SnapshotManager}; @@ -921,8 +922,7 @@ fn cmd_cleanup(args: RollbackCleanupArgs) -> Result<()> { } fn cleanup_all(dry_run: bool) -> Result<()> { - let root = rollback_root()?; - if !root.exists() { + if !state_paths::any_rollback_root_exists()? { eprintln!("{} No rollback directory found.", prefix()); return Ok(()); } diff --git a/crates/nono-cli/src/rollback_session.rs b/crates/nono-cli/src/rollback_session.rs index 6249c07d3..b0af216fc 100644 --- a/crates/nono-cli/src/rollback_session.rs +++ b/crates/nono-cli/src/rollback_session.rs @@ -1,11 +1,13 @@ //! Session discovery and management for the rollback system //! -//! Provides functions to discover, load, and manage rollback sessions -//! stored in `~/.nono/rollbacks/`. This is a CLI concern — the library -//! provides primitives, the CLI provides session lifecycle management. +//! Provides functions to discover, load, and manage rollback sessions stored +//! under `$XDG_STATE_HOME/nono/rollbacks/` (default `~/.local/state/nono/rollbacks/`). +//! Reads also check `~/.nono/rollbacks/` until v1.0.0. +use crate::state_paths; use nono::undo::{SessionMetadata, SnapshotManager}; use nono::{NonoError, Result}; +use std::collections::BTreeSet; use std::fs; use std::path::{Path, PathBuf}; use walkdir::WalkDir; @@ -25,66 +27,60 @@ pub struct SessionInfo { pub is_stale: bool, } -/// Get the rollback root directory (`~/.nono/rollbacks/`) +/// Get the canonical rollback root directory (`$XDG_STATE_HOME/nono/rollbacks/`). pub fn rollback_root() -> Result { - let home = dirs::home_dir().ok_or(NonoError::HomeNotFound)?; - Ok(home.join(".nono").join("rollbacks")) + state_paths::rollback_root() } -/// Discover all rollback sessions in `~/.nono/rollbacks/`. +/// Discover all rollback sessions across canonical and legacy roots. /// -/// Scans the rollback root directory, loads session metadata from each +/// Scans rollback root directories, loads session metadata from each /// subdirectory, and enriches with derived data (disk size, alive status). -/// Sessions with missing or corrupt metadata are skipped. +/// Sessions with missing or corrupt metadata are skipped. When the same +/// session ID exists in multiple roots, the canonical root wins. pub fn discover_sessions() -> Result> { - let root = rollback_root()?; - if !root.exists() { - return Ok(Vec::new()); - } - let mut sessions = Vec::new(); + let mut seen_ids = BTreeSet::new(); + let legacy_roots = state_paths::LegacyRootSet::resolve()?; - let entries = fs::read_dir(&root).map_err(|e| { - NonoError::Snapshot(format!( - "Failed to read rollback directory {}: {e}", - root.display() - )) - })?; - - for entry in entries { - let entry = match entry { - Ok(e) => e, - Err(_) => continue, - }; - - let dir = entry.path(); - if !dir.is_dir() { + for root in state_paths::rollback_discovery_roots()? { + if !root.exists() { continue; } - // Try to load session metadata - let metadata = match SnapshotManager::load_session_metadata(&dir) { - Ok(m) => m, - Err(_) => continue, // Skip corrupt or incomplete sessions - }; - - let pid = parse_pid_from_session_id(&metadata.session_id); - let is_alive = pid.map(is_process_alive).unwrap_or(false); - let is_stale = metadata.ended.is_none() && !is_alive; - let disk_size = calculate_dir_size(&dir); - - sessions.push(SessionInfo { - metadata, - dir, - disk_size, - is_alive, - is_stale, - }); + let entries = fs::read_dir(&root).map_err(|e| { + NonoError::Snapshot(format!( + "Failed to read rollback directory {}: {e}", + root.display() + )) + })?; + + for entry in entries { + let entry = match entry { + Ok(e) => e, + Err(_) => continue, + }; + + let dir = entry.path(); + if !dir.is_dir() { + continue; + } + + let metadata = match SnapshotManager::load_session_metadata(&dir) { + Ok(m) => m, + Err(_) => continue, + }; + + if !seen_ids.insert(metadata.session_id.clone()) { + continue; + } + + legacy_roots.warn_if_legacy_rollback_data_read(&dir); + sessions.push(build_session_info(dir, metadata)); + } } - // Sort by start time, newest first sessions.sort_by(|a, b| b.metadata.started.cmp(&a.metadata.started)); - Ok(sessions) } @@ -92,56 +88,50 @@ pub fn discover_sessions() -> Result> { /// /// The session_id is validated to prevent path traversal — it must not /// contain path separators or `..` components. The resolved path is -/// verified to be within the rollback root directory. +/// verified to be within a rollback root directory. pub fn load_session(session_id: &str) -> Result { validate_session_id(session_id)?; - let root = rollback_root()?; - let dir = root.join(session_id); - - // Defense in depth: verify the resolved path is within rollback root. - // Both canonicalizations must succeed -- fail closed if either cannot - // be resolved (prevents bypassing the traversal check). - let canonical_root = root.canonicalize().map_err(|e| { - NonoError::SessionNotFound(format!( - "Cannot canonicalize rollback root {}: {}", - root.display(), - e - )) - })?; - let canonical_dir = dir.canonicalize().map_err(|_| { - // Don't leak path details in error -- session simply doesn't exist - NonoError::SessionNotFound(session_id.to_string()) - })?; - if !canonical_dir.starts_with(&canonical_root) { - return Err(NonoError::SessionNotFound(session_id.to_string())); - } + let legacy_roots = state_paths::LegacyRootSet::resolve()?; - if !dir.exists() { - return Err(NonoError::SessionNotFound(session_id.to_string())); - } + for root in state_paths::rollback_discovery_roots()? { + let dir = root.join(session_id); + if !dir.exists() { + continue; + } - let metadata = SnapshotManager::load_session_metadata(&dir)?; - let pid = parse_pid_from_session_id(&metadata.session_id); - let is_alive = pid.map(is_process_alive).unwrap_or(false); - let is_stale = metadata.ended.is_none() && !is_alive; - let disk_size = calculate_dir_size(&dir); + let canonical_root = root.canonicalize().map_err(|e| { + NonoError::SessionNotFound(format!( + "Cannot canonicalize rollback root {}: {}", + root.display(), + e + )) + })?; + let canonical_dir = dir + .canonicalize() + .map_err(|_| NonoError::SessionNotFound(session_id.to_string()))?; + if !canonical_dir.starts_with(&canonical_root) { + continue; + } - Ok(SessionInfo { - metadata, - dir, - disk_size, - is_alive, - is_stale, - }) + let metadata = SnapshotManager::load_session_metadata(&dir)?; + legacy_roots.warn_if_legacy_rollback_data_read(&dir); + return Ok(build_session_info(dir, metadata)); + } + + Err(NonoError::SessionNotFound(session_id.to_string())) } -/// Calculate the total disk usage of all sessions. +/// Calculate the total disk usage of all sessions across rollback roots. pub fn total_storage_bytes() -> Result { - let root = rollback_root()?; - if !root.exists() { - return Ok(0); + let mut total: u64 = 0; + let mut seen_roots = BTreeSet::new(); + for root in state_paths::rollback_discovery_roots()? { + if !seen_roots.insert(root.clone()) || !root.exists() { + continue; + } + total = total.saturating_add(calculate_dir_size(&root)); } - Ok(calculate_dir_size(&root)) + Ok(total) } /// Remove a session directory. @@ -154,6 +144,21 @@ pub fn remove_session(dir: &Path) -> Result<()> { }) } +fn build_session_info(dir: PathBuf, metadata: SessionMetadata) -> SessionInfo { + let pid = parse_pid_from_session_id(&metadata.session_id); + let is_alive = pid.map(is_process_alive).unwrap_or(false); + let is_stale = metadata.ended.is_none() && !is_alive; + let disk_size = calculate_dir_size(&dir); + + SessionInfo { + metadata, + dir, + disk_size, + is_alive, + is_stale, + } +} + /// Validate a session ID to prevent path traversal. /// /// Session IDs must match the format `YYYYMMDD-HHMMSS-` and must not @@ -218,6 +223,7 @@ pub fn format_bytes(bytes: u64) -> String { #[cfg(test)] mod tests { use super::*; + use crate::test_env::{ENV_LOCK, EnvVarGuard}; #[test] fn validate_session_id_rejects_traversal() { @@ -260,7 +266,6 @@ mod tests { #[test] fn discover_sessions_empty_dir() { let dir = tempfile::TempDir::new().expect("tempdir"); - // Override undo_root by testing calculate_dir_size directly let size = calculate_dir_size(dir.path()); assert_eq!(size, 0); } @@ -281,7 +286,48 @@ mod tests { #[test] fn dead_process_not_alive() { - // PID 99999999 is very unlikely to exist assert!(!is_process_alive(99_999_999)); } + + #[test] + fn discover_sessions_reads_legacy_rollback_root() { + let _env_lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let tmp = tempfile::tempdir().expect("tempdir"); + let state = tmp.path().join("state"); + fs::create_dir_all(&state).expect("mkdir state"); + let home = tmp.path().to_string_lossy().to_string(); + let state_str = state.to_string_lossy().to_string(); + let _env = EnvVarGuard::set_all(&[("HOME", &home), ("XDG_STATE_HOME", &state_str)]); + + let legacy_dir = state_paths::legacy_rollback_root() + .expect("legacy rollback root") + .join("20260421-111111-30001"); + fs::create_dir_all(&legacy_dir).expect("mkdir legacy rollback session"); + SnapshotManager::write_session_metadata( + &legacy_dir, + &SessionMetadata { + session_id: "20260421-111111-30001".to_string(), + started: "2026-04-21T11:11:11+01:00".to_string(), + ended: Some("2026-04-21T11:11:12+01:00".to_string()), + command: vec!["/bin/true".to_string()], + executable_identity: None, + tracked_paths: vec![PathBuf::from("/tmp/work")], + snapshot_count: 2, + exit_code: Some(0), + merkle_roots: Vec::new(), + network_events: Vec::new(), + audit_event_count: 0, + audit_integrity: None, + audit_attestation: None, + }, + ) + .expect("write metadata"); + + let sessions = discover_sessions().expect("discover"); + let ids: Vec<_> = sessions + .iter() + .map(|s| s.metadata.session_id.as_str()) + .collect(); + assert!(ids.contains(&"20260421-111111-30001")); + } } diff --git a/crates/nono-cli/src/session.rs b/crates/nono-cli/src/session.rs index c29655a19..e21277c24 100644 --- a/crates/nono-cli/src/session.rs +++ b/crates/nono-cli/src/session.rs @@ -1,11 +1,13 @@ //! Session registry for the nono capability runtime. //! //! Each `nono run` or `nono shell` invocation in supervised mode creates a session -//! file at `~/.nono/sessions/{session_id}.json`. This enables `nono ps`, `nono stop`, +//! file at `$XDG_STATE_HOME/nono/sessions/{session_id}.json` (default +//! `~/.local/state/nono/sessions/`). This enables `nono ps`, `nono stop`, //! `nono logs`, and `nono inspect` to discover and manage running sandboxes. use nono::{NonoError, Result}; use serde::{Deserialize, Serialize}; +use std::collections::BTreeSet; use std::fs::File; use std::fs::OpenOptions; use std::io::Write; @@ -17,7 +19,7 @@ use std::os::unix::fs::OpenOptionsExt; #[cfg(unix)] use std::os::unix::fs::{FileTypeExt, MetadataExt, PermissionsExt}; -/// Session state persisted to `~/.nono/sessions/{session_id}.json`. +/// Session state persisted to `$XDG_STATE_HOME/nono/sessions/{session_id}.json`. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SessionRecord { pub session_id: String, @@ -205,37 +207,43 @@ fn load_reconciled_session_file(path: &Path) -> Result { Ok(record) } -/// Returns `~/.nono/sessions/` without creating it. +/// Returns the canonical session registry without creating it. /// /// Use [`ensure_sessions_dir()`] when writing session files. pub fn sessions_dir() -> Result { - let home = dirs::home_dir().ok_or_else(|| { - NonoError::ConfigParse("Cannot determine home directory for session registry".to_string()) - })?; - Ok(home.join(".nono").join("sessions")) + crate::state_paths::sessions_dir() } -/// Returns `~/.nono/sessions/`, creating with mode 0o700 if needed. +/// Returns the canonical session registry, creating with mode 0o700 if needed. pub(crate) fn ensure_sessions_dir() -> Result { let dir = sessions_dir()?; + ensure_private_dir(&dir) +} + +fn ensure_private_dir(dir: &Path) -> Result { if dir.exists() { - validate_sessions_dir(&dir)?; - return Ok(dir); + validate_sessions_dir(dir)?; + return Ok(dir.to_path_buf()); } - std::fs::create_dir_all(&dir).map_err(|e| NonoError::ConfigWrite { - path: dir.clone(), - source: e, - })?; #[cfg(unix)] { - use std::os::unix::fs::PermissionsExt; - let perms = std::fs::Permissions::from_mode(0o700); - std::fs::set_permissions(&dir, perms).map_err(|e| NonoError::ConfigWrite { - path: dir.clone(), + use std::fs::DirBuilder; + use std::os::unix::fs::DirBuilderExt; + let mut builder = DirBuilder::new(); + builder.recursive(true).mode(0o700); + builder.create(dir).map_err(|e| NonoError::ConfigWrite { + path: dir.to_path_buf(), source: e, })?; } - Ok(dir) + #[cfg(not(unix))] + { + std::fs::create_dir_all(dir).map_err(|e| NonoError::ConfigWrite { + path: dir.to_path_buf(), + source: e, + })?; + } + Ok(dir.to_path_buf()) } fn validate_sessions_dir(dir: &Path) -> Result<()> { @@ -313,47 +321,48 @@ pub fn generate_random_name() -> String { /// /// Returns sessions sorted by start time (newest first). pub fn list_sessions() -> Result> { - let dir = match sessions_dir() { - Ok(d) => d, - Err(_) => return Ok(Vec::new()), - }; - - if !dir.exists() { - return Ok(Vec::new()); - } - validate_sessions_dir(&dir)?; - let mut sessions = Vec::new(); - let entries = std::fs::read_dir(&dir).map_err(|e| NonoError::ConfigWrite { - path: dir.clone(), - source: e, - })?; + let mut seen_ids = BTreeSet::new(); + let legacy_roots = crate::state_paths::LegacyRootSet::resolve()?; - for entry in entries.flatten() { - let path = entry.path(); - if path.extension().and_then(|e| e.to_str()) != Some("json") { - continue; - } - // Skip event log files - if path - .file_name() - .and_then(|n| n.to_str()) - .is_some_and(|n| n.ends_with(".events.json")) - { + for dir in crate::state_paths::session_registry_dirs_for_read()? { + if !dir.exists() { continue; } + validate_sessions_dir(&dir)?; - match load_reconciled_session_file(&path) { - Ok(record) => { - sessions.push(record); + let entries = std::fs::read_dir(&dir).map_err(|e| NonoError::ConfigWrite { + path: dir.clone(), + source: e, + })?; + + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) != Some("json") { + continue; } - Err(e) => { - debug!("Skipping corrupt session file {}: {}", path.display(), e); + if path + .file_name() + .and_then(|n| n.to_str()) + .is_some_and(|n| n.ends_with(".events.json")) + { + continue; + } + + match load_reconciled_session_file(&path) { + Ok(record) => { + if seen_ids.insert(record.session_id.clone()) { + legacy_roots.warn_if_legacy_session_file_read(&path); + sessions.push(record); + } + } + Err(e) => { + debug!("Skipping corrupt session file {}: {}", path.display(), e); + } } } } - // Sort newest first sessions.sort_by(|a, b| b.started.cmp(&a.started)); Ok(sessions) } @@ -364,56 +373,65 @@ pub fn list_sessions() -> Result> { /// tries matching against session names. Returns an error if no match or /// multiple matches are found. pub fn load_session(query: &str) -> Result { - let dir = sessions_dir()?; - if !dir.exists() { - return Err(NonoError::SessionNotFound(query.to_string())); - } - validate_sessions_dir(&dir)?; - let entries = std::fs::read_dir(&dir).map_err(|e| NonoError::ConfigWrite { - path: dir.clone(), - source: e, - })?; - let mut id_matches = Vec::new(); let mut name_matches = Vec::new(); + let legacy_roots = crate::state_paths::LegacyRootSet::resolve()?; - for entry in entries.flatten() { - let path = entry.path(); - if path.extension().and_then(|e| e.to_str()) != Some("json") { - continue; - } - // Skip event log files - if path - .file_name() - .and_then(|n| n.to_str()) - .is_some_and(|n| n.ends_with(".events.json")) - { + for dir in crate::state_paths::session_registry_dirs_for_read()? { + if !dir.exists() { continue; } - let file_name = match path.file_stem().and_then(|n| n.to_str()) { - Some(n) => n.to_string(), - None => continue, - }; + validate_sessions_dir(&dir)?; + let entries = std::fs::read_dir(&dir).map_err(|e| NonoError::ConfigWrite { + path: dir.clone(), + source: e, + })?; - if file_name.starts_with(query) { - match load_reconciled_session_file(&path) { - Ok(record) => id_matches.push(record), - Err(e) => debug!("Skipping corrupt session file {}: {}", path.display(), e), + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) != Some("json") { + continue; } - } else { - // Try name match (only load file if ID didn't match) - match load_reconciled_session_file(&path) { - Ok(record) => { - if record.name.as_deref() == Some(query) { - name_matches.push(record); + if path + .file_name() + .and_then(|n| n.to_str()) + .is_some_and(|n| n.ends_with(".events.json")) + { + continue; + } + let file_name = match path.file_stem().and_then(|n| n.to_str()) { + Some(n) => n.to_string(), + None => continue, + }; + + if file_name.starts_with(query) { + match load_reconciled_session_file(&path) { + Ok(record) => { + legacy_roots.warn_if_legacy_session_file_read(&path); + id_matches.push(record); + } + Err(e) => debug!("Skipping corrupt session file {}: {}", path.display(), e), + } + } else { + match load_reconciled_session_file(&path) { + Ok(record) => { + if record.name.as_deref() == Some(query) { + legacy_roots.warn_if_legacy_session_file_read(&path); + name_matches.push(record); + } } + Err(e) => debug!("Skipping corrupt session file {}: {}", path.display(), e), } - Err(e) => debug!("Skipping corrupt session file {}: {}", path.display(), e), } } } - // Prefer ID matches over name matches + if id_matches.is_empty() && name_matches.is_empty() { + return Err(NonoError::SessionNotFound(query.to_string())); + } + + // Prefer ID matches over name matches; canonical dir is scanned first so + // duplicates resolve to the XDG registry entry. let matches = if !id_matches.is_empty() { id_matches } else { diff --git a/crates/nono-cli/src/state_paths.rs b/crates/nono-cli/src/state_paths.rs new file mode 100644 index 000000000..43f4c4462 --- /dev/null +++ b/crates/nono-cli/src/state_paths.rs @@ -0,0 +1,422 @@ +//! XDG-based paths for nono runtime state (audit trails, session registry, rollbacks). +//! +//! Canonical storage lives under `$XDG_STATE_HOME/nono/` (default +//! `~/.local/state/nono/`). Until v1.0.0, reads also fall back to legacy +//! `~/.nono/{audit,sessions,rollbacks}/` trees with a one-time deprecation warning. + +use nono::{NonoError, Result, try_canonicalize}; +use std::cell::Cell; +use std::path::{Path, PathBuf}; + +const LEGACY_HOME_SUBDIR: &str = ".nono"; +const LEGACY_REMOVE_BY: &str = "v1.0.0"; +const AUDIT_LEDGER_FILENAME: &str = "ledger.ndjson"; + +thread_local! { + static LEGACY_AUDIT_WARNED: Cell = const { Cell::new(false) }; + static LEGACY_SESSIONS_WARNED: Cell = const { Cell::new(false) }; + static LEGACY_ROLLBACK_WARNED: Cell = const { Cell::new(false) }; +} + +/// Resolve the XDG state base directory (`$XDG_STATE_HOME`, default `~/.local/state`). +/// +/// When `$XDG_STATE_HOME` is unset, nono uses `$HOME/.local/state` on every platform +/// (same convention as `gh`, Claude Code, and profile `$XDG_STATE_HOME` expansion), +/// not macOS `~/Library/Application Support`. +fn resolve_xdg_state_base() -> Result { + if let Ok(raw) = std::env::var("XDG_STATE_HOME") { + let path = PathBuf::from(&raw); + if path.is_absolute() { + return Ok(path); + } + tracing::warn!( + "Ignoring invalid XDG_STATE_HOME='{}' (must be absolute), falling back to default state dir", + raw + ); + } + + let home = PathBuf::from(crate::config::validated_home()?); + Ok(home.join(".local").join("state")) +} + +/// Resolve `$XDG_STATE_HOME/nono` (default `~/.local/state/nono`). +pub fn user_state_dir() -> Result { + Ok(resolve_xdg_state_base()?.join("nono")) +} + +/// Legacy `~/.nono` root (pre-XDG audit, session, and rollback data). +pub fn legacy_home_state_root() -> Result { + let home = PathBuf::from(crate::config::validated_home()?); + Ok(home.join(LEGACY_HOME_SUBDIR)) +} + +/// Primary audit root: `$XDG_STATE_HOME/nono/audit/`. +pub fn audit_root() -> Result { + Ok(user_state_dir()?.join("audit")) +} + +/// Legacy audit root: `~/.nono/audit/` (read fallback until v1.0.0). +pub fn legacy_audit_root() -> Result { + Ok(legacy_home_state_root()?.join("audit")) +} + +/// Primary session registry: `$XDG_STATE_HOME/nono/sessions/`. +pub fn sessions_dir() -> Result { + Ok(user_state_dir()?.join("sessions")) +} + +/// Legacy session registry: `~/.nono/sessions/` (read fallback until v1.0.0). +pub fn legacy_sessions_dir() -> Result { + Ok(legacy_home_state_root()?.join("sessions")) +} + +/// Primary rollback root: `$XDG_STATE_HOME/nono/rollbacks/`. +pub fn rollback_root() -> Result { + Ok(user_state_dir()?.join("rollbacks")) +} + +/// Legacy rollback root: `~/.nono/rollbacks/` (read fallback until v1.0.0). +pub fn legacy_rollback_root() -> Result { + Ok(legacy_home_state_root()?.join("rollbacks")) +} + +/// Audit roots to scan when discovering or loading sessions (primary first). +pub fn audit_discovery_roots() -> Result> { + let primary = audit_root()?; + let mut roots = vec![primary.clone()]; + if let Ok(legacy) = legacy_audit_root() + && legacy != primary + { + roots.push(legacy); + } + Ok(roots) +} + +/// Session registry directories to scan when listing or loading (primary first). +pub fn session_registry_dirs_for_read() -> Result> { + let primary = sessions_dir()?; + let mut dirs = vec![primary.clone()]; + if let Ok(legacy) = legacy_sessions_dir() + && legacy != primary + { + dirs.push(legacy); + } + Ok(dirs) +} + +/// Rollback roots to scan when discovering or loading sessions (primary first). +pub fn rollback_discovery_roots() -> Result> { + let primary = rollback_root()?; + let mut roots = vec![primary.clone()]; + if let Ok(legacy) = legacy_rollback_root() + && legacy != primary + { + roots.push(legacy); + } + Ok(roots) +} + +/// Returns true when any rollback root directory exists. +pub fn any_rollback_root_exists() -> Result { + Ok(rollback_discovery_roots()?.iter().any(|root| root.exists())) +} + +/// Protected state roots that must not be grantable to sandboxed children. +pub fn protected_state_roots() -> Result> { + let mut roots = vec![ + try_canonicalize(&legacy_home_state_root()?), + try_canonicalize(&user_state_dir()?), + ]; + roots.sort(); + roots.dedup(); + Ok(roots) +} + +/// Emit a one-time warning when legacy audit data is read. +pub(crate) fn warn_legacy_audit_path(path: &Path) { + LEGACY_AUDIT_WARNED.with(|warned| { + if warned.get() { + return; + } + warned.set(true); + eprintln!( + "warning: reading audit data from deprecated path {} (will be removed in {LEGACY_REMOVE_BY}); \ + new audit data is stored under $XDG_STATE_HOME/nono/audit/ (default ~/.local/state/nono/audit/)", + path.display(), + ); + }); +} + +fn warn_legacy_sessions_path(path: &Path) { + LEGACY_SESSIONS_WARNED.with(|warned| { + if warned.get() { + return; + } + warned.set(true); + eprintln!( + "warning: reading session registry from deprecated path {} (will be removed in {LEGACY_REMOVE_BY}); \ + new session files are stored under $XDG_STATE_HOME/nono/sessions/ (default ~/.local/state/nono/sessions/)", + path.display(), + ); + }); +} + +fn warn_legacy_rollback_path(path: &Path) { + LEGACY_ROLLBACK_WARNED.with(|warned| { + if warned.get() { + return; + } + warned.set(true); + eprintln!( + "warning: reading rollback data from deprecated path {} (will be removed in {LEGACY_REMOVE_BY}); \ + new rollback data is stored under $XDG_STATE_HOME/nono/rollbacks/ (default ~/.local/state/nono/rollbacks/)", + path.display(), + ); + }); +} + +/// Pre-canonicalized primary and legacy roots for legacy-path detection. +/// +/// Resolve once before iterating session directories to avoid repeated env lookups +/// and to compare paths consistently on macOS (/Users vs /private/Users). +pub struct LegacyRootSet { + primary_audit: PathBuf, + legacy_audit: PathBuf, + primary_rollback: PathBuf, + legacy_rollback: PathBuf, + primary_sessions: PathBuf, + legacy_sessions: PathBuf, +} + +impl LegacyRootSet { + pub fn resolve() -> Result { + Ok(Self { + primary_audit: try_canonicalize(&audit_root()?), + legacy_audit: try_canonicalize(&legacy_audit_root()?), + primary_rollback: try_canonicalize(&rollback_root()?), + legacy_rollback: try_canonicalize(&legacy_rollback_root()?), + primary_sessions: try_canonicalize(&sessions_dir()?), + legacy_sessions: try_canonicalize(&legacy_sessions_dir()?), + }) + } + + fn is_under_legacy(path: &Path, legacy: &Path, primary: &Path) -> bool { + if legacy == primary { + return false; + } + let path = try_canonicalize(path); + path.starts_with(legacy) && !path.starts_with(primary) + } + + /// Warn once after successfully reading audit metadata from a legacy tree. + pub fn warn_if_legacy_audit_data_read(&self, session_dir: &Path) { + if Self::is_under_legacy(session_dir, &self.legacy_audit, &self.primary_audit) { + warn_legacy_audit_path(&self.legacy_audit); + return; + } + if Self::is_under_legacy(session_dir, &self.legacy_rollback, &self.primary_rollback) { + warn_legacy_rollback_path(&self.legacy_rollback); + } + } + + /// Warn once after successfully reading rollback metadata from a legacy tree. + pub fn warn_if_legacy_rollback_data_read(&self, session_dir: &Path) { + if Self::is_under_legacy(session_dir, &self.legacy_rollback, &self.primary_rollback) { + warn_legacy_rollback_path(&self.legacy_rollback); + } + } + + /// Warn once after successfully reading a session registry file from a legacy tree. + pub fn warn_if_legacy_session_file_read(&self, session_file: &Path) { + if Self::is_under_legacy(session_file, &self.legacy_sessions, &self.primary_sessions) { + warn_legacy_sessions_path(&self.legacy_sessions); + } + } +} + +/// Copy a legacy audit ledger into the canonical root on first write, if needed. +/// +/// Caller must hold the audit ledger lock before calling this function. +pub fn maybe_migrate_legacy_audit_ledger() -> Result<()> { + let primary = audit_root()?; + let legacy = legacy_audit_root()?; + if primary == legacy { + return Ok(()); + } + + let new_ledger = primary.join(AUDIT_LEDGER_FILENAME); + if new_ledger.exists() { + return Ok(()); + } + + let legacy_ledger = legacy.join(AUDIT_LEDGER_FILENAME); + if !legacy_ledger.exists() { + return Ok(()); + } + + std::fs::create_dir_all(&primary).map_err(|e| { + NonoError::Snapshot(format!( + "Failed to create audit root {}: {e}", + primary.display() + )) + })?; + + let tmp_ledger = primary.join(format!("{AUDIT_LEDGER_FILENAME}.tmp")); + if tmp_ledger.exists() { + std::fs::remove_file(&tmp_ledger).map_err(|e| { + NonoError::Snapshot(format!( + "Failed to remove stale audit ledger migration temp file {}: {e}", + tmp_ledger.display() + )) + })?; + } + + std::fs::copy(&legacy_ledger, &tmp_ledger).map_err(|e| { + let _ = std::fs::remove_file(&tmp_ledger); + NonoError::Snapshot(format!( + "Failed to copy legacy audit ledger to temporary file {}: {e}", + tmp_ledger.display() + )) + })?; + + std::fs::rename(&tmp_ledger, &new_ledger).map_err(|e| { + let _ = std::fs::remove_file(&tmp_ledger); + NonoError::Snapshot(format!( + "Failed to rename temporary audit ledger to {}: {e}", + new_ledger.display() + )) + })?; + + Ok(()) +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + use crate::test_env::{ENV_LOCK, EnvVarGuard}; + use std::fs; + + fn isolated_env(base: &Path) -> (EnvVarGuard, PathBuf) { + let home = base.join("home"); + let state = base.join("state"); + fs::create_dir_all(&home).unwrap(); + fs::create_dir_all(&state).unwrap(); + let home_str = home.to_string_lossy().to_string(); + let state_str = state.to_string_lossy().to_string(); + let guard = EnvVarGuard::set_all(&[("HOME", &home_str), ("XDG_STATE_HOME", &state_str)]); + (guard, home) + } + + #[test] + fn default_state_base_uses_local_state_without_xdg_env() { + let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let tmp = tempfile::tempdir().unwrap(); + let home = tmp.path().join("home"); + fs::create_dir_all(&home).unwrap(); + let home_str = home.to_string_lossy().to_string(); + let _env = EnvVarGuard::set_all(&[("HOME", &home_str)]); + assert_eq!( + user_state_dir().unwrap(), + home.join(".local").join("state").join("nono") + ); + } + + #[test] + fn canonical_paths_use_xdg_state_home() { + let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let tmp = tempfile::tempdir().unwrap(); + let (_env, home) = isolated_env(tmp.path()); + + assert_eq!( + audit_root().unwrap(), + tmp.path().join("state").join("nono").join("audit") + ); + assert_eq!( + sessions_dir().unwrap(), + tmp.path().join("state").join("nono").join("sessions") + ); + assert_eq!( + legacy_audit_root().unwrap(), + home.join(".nono").join("audit") + ); + assert_eq!( + legacy_sessions_dir().unwrap(), + home.join(".nono").join("sessions") + ); + assert_eq!( + rollback_root().unwrap(), + tmp.path().join("state").join("nono").join("rollbacks") + ); + assert_eq!( + legacy_rollback_root().unwrap(), + home.join(".nono").join("rollbacks") + ); + } + + #[test] + fn protected_roots_include_both_legacy_and_xdg() { + let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let tmp = tempfile::tempdir().unwrap(); + let (_env, home) = isolated_env(tmp.path()); + + let roots = protected_state_roots().unwrap(); + assert_eq!(roots.len(), 2); + assert!(roots.iter().any(|p| p.ends_with(".nono"))); + assert!( + roots + .iter() + .any(|p| p.ends_with("nono") && !p.ends_with(".nono")) + ); + let _ = home; + } + + #[test] + fn audit_discovery_roots_lists_primary_before_legacy() { + let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let tmp = tempfile::tempdir().unwrap(); + let (_env, _home) = isolated_env(tmp.path()); + + let roots = audit_discovery_roots().unwrap(); + assert_eq!(roots.len(), 2); + assert!(roots[0].ends_with("nono/audit")); + assert!(roots[1].ends_with(".nono/audit")); + } + + #[test] + fn rollback_discovery_roots_lists_primary_before_legacy() { + let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let tmp = tempfile::tempdir().unwrap(); + let (_env, _home) = isolated_env(tmp.path()); + + let roots = rollback_discovery_roots().unwrap(); + assert_eq!(roots.len(), 2); + assert!(roots[0].ends_with("nono/rollbacks")); + assert!(roots[1].ends_with(".nono/rollbacks")); + } + + #[test] + fn maybe_migrate_legacy_audit_ledger_copies_via_temp_rename() { + let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let tmp = tempfile::tempdir().unwrap(); + let (_env, home) = isolated_env(tmp.path()); + + let legacy_ledger = legacy_audit_root().unwrap().join(AUDIT_LEDGER_FILENAME); + fs::create_dir_all(legacy_ledger.parent().unwrap()).unwrap(); + fs::write(&legacy_ledger, b"{\"sequence\":0}\n").unwrap(); + + maybe_migrate_legacy_audit_ledger().unwrap(); + + let migrated = audit_root().unwrap().join(AUDIT_LEDGER_FILENAME); + assert!(migrated.exists()); + assert!( + !audit_root() + .unwrap() + .join(format!("{AUDIT_LEDGER_FILENAME}.tmp")) + .exists() + ); + let content = fs::read_to_string(&migrated).unwrap(); + assert_eq!(content, "{\"sequence\":0}\n"); + let _ = home; + } +} diff --git a/crates/nono-cli/tests/audit_attestation.rs b/crates/nono-cli/tests/audit_attestation.rs index 87e95a8b0..6f5fa3970 100644 --- a/crates/nono-cli/tests/audit_attestation.rs +++ b/crates/nono-cli/tests/audit_attestation.rs @@ -9,11 +9,12 @@ fn nono_bin() -> Command { Command::new(env!("CARGO_BIN_EXE_nono")) } -fn run_nono(args: &[&str], home: &Path, cwd: &Path) -> Output { +fn run_nono(args: &[&str], home: &Path, state: &Path, cwd: &Path) -> Output { nono_bin() .args(args) .env("HOME", home) .env("XDG_CONFIG_HOME", home.join(".config")) + .env("XDG_STATE_HOME", state) .current_dir(cwd) .output() .expect("failed to run nono") @@ -28,7 +29,7 @@ fn assert_success(output: &Output) { ); } -fn setup_isolated_home() -> (tempfile::TempDir, PathBuf, PathBuf) { +fn setup_isolated_home() -> (tempfile::TempDir, PathBuf, PathBuf, PathBuf) { let temp_root = std::env::current_dir() .expect("cwd") .join("target") @@ -39,10 +40,16 @@ fn setup_isolated_home() -> (tempfile::TempDir, PathBuf, PathBuf) { .tempdir_in(&temp_root) .expect("tempdir"); let home = tmp.path().join("home"); + let state = tmp.path().join("state"); let workspace = tmp.path().join("workspace"); fs::create_dir_all(home.join(".config")).expect("create config dir"); + fs::create_dir_all(&state).expect("create state dir"); fs::create_dir_all(&workspace).expect("create workspace dir"); - (tmp, home, workspace) + (tmp, home, state, workspace) +} + +fn audit_root(state: &Path) -> PathBuf { + state.join("nono").join("audit") } fn key_path(home: &Path) -> PathBuf { @@ -55,12 +62,13 @@ fn pub_key_path_for_file(private_key_path: &Path) -> PathBuf { PathBuf::from(pub_path) } -fn generate_file_signing_key(home: &Path, cwd: &Path) -> PathBuf { +fn generate_file_signing_key(home: &Path, state: &Path, cwd: &Path) -> PathBuf { let key_path = key_path(home); let keyref = format!("file://{}", key_path.display()); let output = run_nono( &["trust", "keygen", "--force", "--keyref", &keyref], home, + state, cwd, ); assert_success(&output); @@ -72,8 +80,8 @@ fn generate_file_signing_key(home: &Path, cwd: &Path) -> PathBuf { key_path } -fn only_audit_session_id(home: &Path) -> String { - let audit_root = home.join(".nono").join("audit"); +fn only_audit_session_id(state: &Path) -> String { + let audit_root = audit_root(state); let mut session_ids: Vec = fs::read_dir(&audit_root) .expect("read audit root") .filter_map(|entry| entry.ok()) @@ -92,8 +100,8 @@ fn only_audit_session_id(home: &Path) -> String { #[test] fn audit_verify_reports_signed_attestation_with_pinned_public_key() { - let (_tmp, home, workspace) = setup_isolated_home(); - let key_path = generate_file_signing_key(&home, &workspace); + let (_tmp, home, state, workspace) = setup_isolated_home(); + let key_path = generate_file_signing_key(&home, &state, &workspace); let keyref = format!("file://{}", key_path.display()); let run_output = run_nono( @@ -106,11 +114,12 @@ fn audit_verify_reports_signed_attestation_with_pinned_public_key() { "/bin/pwd", ], &home, + &state, &workspace, ); assert_success(&run_output); - let session_id = only_audit_session_id(&home); + let session_id = only_audit_session_id(&state); let pub_key_path = format!("{}", pub_key_path_for_file(&key_path).display()); let verify_output = run_nono( &[ @@ -122,6 +131,7 @@ fn audit_verify_reports_signed_attestation_with_pinned_public_key() { "--json", ], &home, + &state, &workspace, ); assert_success(&verify_output); @@ -139,9 +149,9 @@ fn audit_verify_reports_signed_attestation_with_pinned_public_key() { #[test] fn rollback_signed_session_verifies_from_audit_dir_bundle() { - let (_tmp, home, workspace) = setup_isolated_home(); + let (_tmp, home, state, workspace) = setup_isolated_home(); fs::write(workspace.join("tracked.txt"), "before\n").expect("write tracked file"); - let key_path = generate_file_signing_key(&home, &workspace); + let key_path = generate_file_signing_key(&home, &state, &workspace); let keyref = format!("file://{}", key_path.display()); let run_output = run_nono( @@ -156,13 +166,14 @@ fn rollback_signed_session_verifies_from_audit_dir_bundle() { "/bin/pwd", ], &home, + &state, &workspace, ); assert_success(&run_output); - let session_id = only_audit_session_id(&home); - let audit_dir = home.join(".nono").join("audit").join(&session_id); - let rollback_dir = home.join(".nono").join("rollbacks").join(&session_id); + let session_id = only_audit_session_id(&state); + let audit_dir = audit_root(&state).join(&session_id); + let rollback_dir = state.join("nono").join("rollbacks").join(&session_id); assert!( audit_dir.join("audit-attestation.bundle").exists(), "bundle should live in audit dir" @@ -175,6 +186,7 @@ fn rollback_signed_session_verifies_from_audit_dir_bundle() { let verify_output = run_nono( &["audit", "verify", &session_id, "--json"], &home, + &state, &workspace, ); assert_success(&verify_output); diff --git a/docs/cli/features/atomic-rollbacks.mdx b/docs/cli/features/atomic-rollbacks.mdx index 8c64d8020..441c8e233 100644 --- a/docs/cli/features/atomic-rollbacks.mdx +++ b/docs/cli/features/atomic-rollbacks.mdx @@ -22,7 +22,7 @@ nono run --rollback --profile always-further/claude -- claude ``` -`--rollback` automatically selects supervised execution because the parent process needs to remain unsandboxed to write snapshots to `~/.nono/rollbacks/`. +`--rollback` automatically selects supervised execution because the parent process needs to remain unsandboxed to write snapshots to `$XDG_STATE_HOME/nono/rollbacks/` (default `~/.local/state/nono/rollbacks/`). ## Snapshot Architecture @@ -41,10 +41,10 @@ Each snapshot builds a Merkle tree over all tracked files. The root hash provide ### Session Structure -Sessions are stored in `~/.nono/rollbacks/` with the following layout: +Sessions are stored in `$XDG_STATE_HOME/nono/rollbacks/` (default `~/.local/state/nono/rollbacks/`). Older installs may still have data under `~/.nono/rollbacks/`; nono reads that path with a deprecation warning until v1.0.0. Layout: ``` -~/.nono/rollbacks/ +$XDG_STATE_HOME/nono/rollbacks/ / manifest.json # Session metadata, Merkle roots, timestamps objects/ # Content-addressable file store diff --git a/docs/cli/features/audit.mdx b/docs/cli/features/audit.mdx index ef0927826..d64f96621 100644 --- a/docs/cli/features/audit.mdx +++ b/docs/cli/features/audit.mdx @@ -350,4 +350,4 @@ The audit trail is intentionally narrow in what it claims to prove. ## Storage -Audit sessions are stored in `~/.nono/audit/`. Audit-only sessions are small (`session.json` and `audit-events.ndjson`). Signed sessions also include `audit-attestation.bundle`. Clean them up with `nono audit cleanup`. +Audit sessions are stored in `$XDG_STATE_HOME/nono/audit/` (default `~/.local/state/nono/audit/`). Older installs may still have data under `~/.nono/audit/`; nono reads that path with a deprecation warning until v1.0.0. Audit-only sessions are small (`session.json` and `audit-events.ndjson`). Signed sessions also include `audit-attestation.bundle`. Clean them up with `nono audit cleanup`. diff --git a/docs/cli/features/supervisor.mdx b/docs/cli/features/supervisor.mdx index b8e937aae..4e6915e31 100644 --- a/docs/cli/features/supervisor.mdx +++ b/docs/cli/features/supervisor.mdx @@ -67,7 +67,7 @@ On macOS, supervised mode provides rollback snapshots and the diagnostic footer, ## Protected State -The supervisor always blocks access to nono's own protected state roots, such as `~/.nono`, before consulting the approval backend. This prevents dynamic grants from exposing rollback data, audit state, or other internal nono files. +The supervisor always blocks access to nono's own protected state roots, such as `~/.nono` and `$XDG_STATE_HOME/nono`, before consulting the approval backend. This prevents dynamic grants from exposing rollback data, audit state, or other internal nono files. ## Audit Responsibilities diff --git a/docs/cli/internals/security-model.mdx b/docs/cli/internals/security-model.mdx index 8993dd363..1a0aed9ad 100644 --- a/docs/cli/internals/security-model.mdx +++ b/docs/cli/internals/security-model.mdx @@ -99,7 +99,7 @@ nono's trust boundary is agent containment — restricting what the sandboxed ch ### Session metadata on disk -Session JSON files in `~/.nono/sessions/` contain supervisor and child PIDs, the command line, profile name, working directory, and network mode. These files are protected by directory permissions (`0o700`) and file permissions (`0o600`), but are readable by any process running as the same user. +Session JSON files in `$XDG_STATE_HOME/nono/sessions/` (default `~/.local/state/nono/sessions/`) contain supervisor and child PIDs, the command line, profile name, working directory, and network mode. These files are protected by directory permissions (`0o700`) and file permissions (`0o600`), but are readable by any process running as the same user. nono applies best-effort redaction before persisting command arguments in session metadata, audit records, rollback metadata, and audit attestations. It scrubs common secret-bearing forms such as `--token VALUE`, `--api-key=VALUE`, sensitive HTTP headers, URL userinfo, and sensitive query parameters. diff --git a/docs/cli/usage/flags.mdx b/docs/cli/usage/flags.mdx index 0bd02ed8e..febe6e852 100644 --- a/docs/cli/usage/flags.mdx +++ b/docs/cli/usage/flags.mdx @@ -926,7 +926,7 @@ nono run --no-rollback --allow . -- npm test #### `--no-audit` -Disable the audit trail for this session. By default, every supervised execution records session metadata and audit events (command, timestamps, exit code, capability decisions, URL opens, network events) to `~/.nono/audit/`. Use this flag to suppress audit recording entirely. +Disable the audit trail for this session. By default, every supervised execution records session metadata and audit events (command, timestamps, exit code, capability decisions, URL opens, network events) to `$XDG_STATE_HOME/nono/audit/` (default `~/.local/state/nono/audit/`). Use this flag to suppress audit recording entirely. ```bash nono run --no-audit --allow-cwd -- my-command diff --git a/tests/integration/test_audit.sh b/tests/integration/test_audit.sh index 6f45bbe42..d909554b3 100755 --- a/tests/integration/test_audit.sh +++ b/tests/integration/test_audit.sh @@ -21,9 +21,9 @@ fi TMPDIR=$(setup_test_dir) trap 'cleanup_test_dir "$TMPDIR"' EXIT -# Use the real audit and rollback roots (same as nono uses via dirs::home_dir) -AUDIT_ROOT="$HOME/.nono/audit" -ROLLBACK_ROOT="$HOME/.nono/rollbacks" +# Use the real audit and rollback roots (same as nono uses via XDG state + legacy rollback) +AUDIT_ROOT="${XDG_STATE_HOME:-$HOME/.local/state}/nono/audit" +ROLLBACK_ROOT="${XDG_STATE_HOME:-$HOME/.local/state}/nono/rollbacks" mkdir -p "$AUDIT_ROOT" "$ROLLBACK_ROOT" # Helper: find the session.json for a specific nono PID. diff --git a/tests/integration/test_rollback_dest_edge_cases.sh b/tests/integration/test_rollback_dest_edge_cases.sh index 9a48c72e8..736127c1f 100755 --- a/tests/integration/test_rollback_dest_edge_cases.sh +++ b/tests/integration/test_rollback_dest_edge_cases.sh @@ -136,7 +136,7 @@ run_test "session created under nonexistent dest after creation" 0 \ bash -c "ls '$NONEXISTENT_DEST' | grep -qE '[0-9]{8}-[0-9]{6}-[0-9]+'" # ============================================================================= -# 6. Session is isolated to custom dest (not written to default ~/.nono/rollbacks) +# 6. Session is isolated to custom dest (not written to default rollback root) # ============================================================================= echo "" echo "--- Isolation: Custom Dest Does Not Pollute Default ---" @@ -145,7 +145,7 @@ ISOLATED_DEST="$TMPDIR/isolated_rollbacks" mkdir -p "$ISOLATED_DEST" # Count sessions in default rollback root before -default_root="$HOME/.nono/rollbacks" +default_root="${XDG_STATE_HOME:-$HOME/.local/state}/nono/rollbacks" before_count=$(ls "$default_root" 2>/dev/null | grep -cE '[0-9]{8}-[0-9]{6}-[0-9]+' || true) expect_success "rollback with --rollback-dest runs successfully" \ @@ -157,7 +157,7 @@ expect_success "rollback with --rollback-dest runs successfully" \ after_count=$(ls "$default_root" 2>/dev/null | grep -cE '[0-9]{8}-[0-9]{6}-[0-9]+' || true) if [ "$before_count" -eq "$after_count" ]; then - echo -e " ${GREEN}PASS${NC}: default ~/.nono/rollbacks not polluted" + echo -e " ${GREEN}PASS${NC}: default rollback root not polluted" TESTS_PASSED=$((TESTS_PASSED + 1)) else echo -e " ${RED}FAIL${NC}: unexpected new session in default rollback root" diff --git a/tests/run_integration_tests.sh b/tests/run_integration_tests.sh index 045a77eb4..24edfa39a 100755 --- a/tests/run_integration_tests.sh +++ b/tests/run_integration_tests.sh @@ -76,14 +76,14 @@ export NONO_NO_MIGRATE=1 export NONO_NO_SAVE_PROMPT=1 # Audit is on by default, so every test invocation that does not pass -# --no-audit writes a session under ~/.nono/audit/ (and ~/.nono/rollbacks/ -# for rollback tests), and appends to ~/.nono/audit/ledger.ndjson. There is +# --no-audit writes a session under $XDG_STATE_HOME/nono/audit/ (and $XDG_STATE_HOME/nono/rollbacks/ +# for rollback tests), and appends to the audit ledger there. There is # no env-var override for the audit root, so snapshot the pre-run state and # restore it on exit. This removes only artefacts created during the run; # pre-existing user sessions and ledger entries are preserved. # Set NONO_TEST_KEEP_AUDIT=1 to skip cleanup for debugging. -NONO_AUDIT_ROOT="$HOME/.nono/audit" -NONO_ROLLBACK_ROOT="$HOME/.nono/rollbacks" +NONO_AUDIT_ROOT="${XDG_STATE_HOME:-$HOME/.local/state}/nono/audit" +NONO_ROLLBACK_ROOT="${XDG_STATE_HOME:-$HOME/.local/state}/nono/rollbacks" AUDIT_SNAPSHOT_DIR="$TEST_ENV_DIR/audit-snapshot" mkdir -p "$AUDIT_SNAPSHOT_DIR" From e6b150e11154a98bf8e1fa3588470b8b7f4dfcdc Mon Sep 17 00:00:00 2001 From: Aleks <121458075+SequeI@users.noreply.github.com> Date: Mon, 15 Jun 2026 18:44:15 +0100 Subject: [PATCH 59/91] refactor(diagnostic): move diagnostic UX out of core nono crate (#1155) Relocate DiagnosticFormatter and all user-facing diagnostic rendering from the core 'nono' crate to 'nono-cli', restoring the library/CLI boundary documented in AGENTS.md: the library stays a pure sandbox primitive and the CLI owns policy, footer text, flag suggestions, and stderr heuristics. Core 'nono::diagnostic' now exposes only structured, policy-free facts: DenialReason, DenialRecord, IpcDenialRecord, SandboxViolation, and the seatbelt_operation_to_access mapping. Everything else (DiagnosticFormatter, PolicyExplanation, ErrorObservation, analyze_error_output, and the footer rendering with 'nono why'/'nono learn' guidance and CLI flag suggestions) moves to crates/nono-cli/src/diagnostic/. The bulk of this change is a near-verbatim move; the only behavioral edits: - Remove dead code surfaced by the move (previously masked by pub visibility in the library): PolicyExplanation.details/policy_source fields (written but never read), and the unused DiagnosticFormatter methods with_blocked_protected_file, detect_protected_file_in_error, and format_summary. - Deduplicate suggested_flag_parts: the formatter now uses the single query_ext implementation, which guards against suggesting access to the root filesystem. This fixes an edge case where a nonexistent path directly under '/' could yield a '--allow /' suggestion. No change to diagnostic footer output in normal cases; the moved unit tests are the rendering contract and pass unchanged. Signed-off-by: Aleksy Siek --- crates/nono-cli/src/diagnostic/formatter.rs | 3826 ++++++++++++++++++ crates/nono-cli/src/diagnostic/mod.rs | 13 + crates/nono-cli/src/exec_strategy.rs | 42 +- crates/nono-cli/src/main.rs | 1 + crates/nono-cli/src/profile_save_runtime.rs | 14 +- crates/nono-cli/src/query_ext.rs | 2 +- crates/nono/src/diagnostic.rs | 3951 +------------------ crates/nono/src/lib.rs | 5 +- 8 files changed, 3910 insertions(+), 3944 deletions(-) create mode 100644 crates/nono-cli/src/diagnostic/formatter.rs create mode 100644 crates/nono-cli/src/diagnostic/mod.rs diff --git a/crates/nono-cli/src/diagnostic/formatter.rs b/crates/nono-cli/src/diagnostic/formatter.rs new file mode 100644 index 000000000..568ab9cd4 --- /dev/null +++ b/crates/nono-cli/src/diagnostic/formatter.rs @@ -0,0 +1,3826 @@ +//! Diagnostic output formatter for sandbox policy (CLI-owned). +//! +//! This module provides human and agent-readable diagnostic output +//! when sandboxed commands fail. The output helps identify whether +//! the failure was due to sandbox restrictions. +//! +//! The structured, policy-free denial records live in the core +//! `nono::diagnostic` module; everything here is CLI/product UX: footer +//! rendering, CLI flag suggestions, `nono why`/`nono learn` guidance, policy +//! explanations, and stderr heuristics. +//! +//! # Design Principles +//! +//! - **Unmistakable boundary**: Diagnostics render as a dedicated `nono diagnostic` +//! block so they remain easy to distinguish from command output +//! - **May vs was**: Phrased as "may be due to" not "was caused by" +//! because the non-zero exit could be unrelated to the sandbox +//! - **Actionable**: Provides specific flags to grant additional access +//! - **Mode-aware**: Different guidance for supervised vs standard mode + +use nono::diagnostic::{ + DenialReason, DenialRecord, IpcDenialRecord, SandboxViolation, seatbelt_operation_to_access, +}; +use nono::try_canonicalize; +use nono::{AccessMode, CapabilitySet, CapabilitySource}; +use std::path::{Path, PathBuf}; + +/// Policy explanation for a denied path, resolved from `nono why` logic. +/// +/// This carries the enriched query result so the diagnostic can show +/// group names, policy details, and suggested fixes inline rather than +/// asking the user to run `nono why` separately. +#[derive(Debug, Clone)] +pub struct PolicyExplanation { + /// The denied path. + pub path: PathBuf, + /// Access mode that was denied. + pub access: AccessMode, + /// Why it was denied: "sensitive_path", "insufficient_access", or "path_not_granted". + pub reason: String, + /// Suggested CLI flag to fix (e.g. "--read ~/.ssh/id_rsa"). + pub suggested_flag: Option, +} + +/// Path-level hint extracted from a command's own error output. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ObservedPathHint { + /// The path mentioned in the error output. + pub path: PathBuf, + /// Best-effort access mode inferred from the error text. + pub access: AccessMode, +} + +/// Primary classification derived from a command's own error output. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ErrorVerdict { + /// The command likely hit a sandbox-relevant path access issue. + LikelySandbox(ObservedPathHint), + /// The command reported a missing path, which is not itself a sandbox denial. + MissingPath(PathBuf), + /// The command reported an application-level failure unrelated to permissions. + NonSandboxFailure(String), +} + +/// Best-effort observations extracted from a command's stderr output. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ErrorObservation { + /// Primary diagnosis extracted from the command output. + pub primary_verdict: Option, + /// Name of a protected file referenced in the error output, if any. + pub blocked_protected_file: Option, + /// Paths that look like sandbox-denied accesses from stderr. + pub path_hints: Vec, + /// Paths that look missing according to stderr output. + pub missing_paths: Vec, + /// Error text that strongly suggests a non-sandbox application failure. + pub non_sandbox_failure: Option, +} + +impl ErrorObservation { + #[must_use] + pub fn has_findings(&self) -> bool { + self.primary_verdict.is_some() + || self.blocked_protected_file.is_some() + || !self.path_hints.is_empty() + || !self.missing_paths.is_empty() + || self.non_sandbox_failure.is_some() + } +} + +/// Execution mode for diagnostic context. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DiagnosticMode { + /// Standard mode: suggest --allow flags for re-run + Standard, + /// Supervised mode: interactive expansion available, show denials + Supervised, +} + +/// Context about the command that was executed. +/// +/// Used to generate more specific diagnostic messages when a +/// sandboxed command fails. +#[derive(Debug, Clone)] +pub struct CommandContext { + /// The program name as the user typed it (e.g. "ps", "./script.sh") + pub program: String, + /// The resolved absolute path to the binary + pub resolved_path: PathBuf, + /// Original argv passed to the top-level command + pub args: Vec, +} + +/// Strip control characters and ANSI escape sequences from a string. +/// +/// Prevents terminal injection from attacker-controlled program names +/// or paths appearing in diagnostic output. +fn sanitize_for_diagnostic(s: &str) -> String { + let mut result = String::with_capacity(s.len()); + let mut chars = s.chars(); + while let Some(c) = chars.next() { + if c == '\x1b' { + // Skip ESC and the entire escape sequence + if let Some(next) = chars.next() + && next == '[' + { + for seq_char in chars.by_ref() { + if seq_char.is_ascii_alphabetic() { + break; + } + } + } + } else if c.is_control() { + // Strip all control characters + } else { + result.push(c); + } + } + result +} + +/// Parse best-effort denial hints from a command's stderr output. +#[must_use] +pub fn analyze_error_output( + error_output: &str, + protected_paths: &[PathBuf], + current_dir: Option<&Path>, +) -> ErrorObservation { + let mut blocked_protected_file = None; + let mut observed = std::collections::BTreeMap::::new(); + let mut missing = std::collections::BTreeSet::::new(); + let mut pending_relative_write: Option = None; + let mut pending_structured_access_denial = false; + let mut pending_structured_access: Option = None; + let mut non_sandbox_failure = None; + + for line in error_output.lines() { + if blocked_protected_file.is_none() { + blocked_protected_file = detect_protected_file_in_error_line(protected_paths, line); + } + + if non_sandbox_failure.is_none() { + non_sandbox_failure = detect_non_sandbox_failure_line(line); + } + + if let Some(path) = + current_dir.and_then(|cwd| extract_relative_write_path_from_line(line, cwd)) + { + pending_relative_write = Some(path); + } + + if looks_like_structured_access_denial_code(line) { + pending_structured_access_denial = true; + } + + if pending_structured_access_denial { + if let Some(access) = infer_access_from_structured_syscall_line(line) { + pending_structured_access = Some(access); + } + + if let (Some(path), Some(access)) = ( + extract_structured_path_property(line), + pending_structured_access, + ) { + observed + .entry(path) + .and_modify(|existing| *existing = merge_access_modes(*existing, access)) + .or_insert(access); + pending_structured_access_denial = false; + pending_structured_access = None; + continue; + } + } + + if looks_like_missing_path(line) { + if let Some(path) = extract_denied_path_from_error_line(line) { + missing.insert(path); + } + continue; + } + + if !looks_like_access_denial(line) { + continue; + } + + let Some(path) = + extract_denied_path_from_error_line(line).or_else(|| pending_relative_write.clone()) + else { + continue; + }; + let access = if extract_denied_path_from_error_line(line).is_some() { + infer_access_from_error_line(line, &path) + } else { + AccessMode::Write + }; + + observed + .entry(path) + .and_modify(|existing| *existing = merge_access_modes(*existing, access)) + .or_insert(access); + pending_relative_write = None; + } + + let path_hints = observed + .into_iter() + .map(|(path, access)| ObservedPathHint { path, access }) + .collect::>(); + let primary_verdict = missing + .iter() + .next() + .cloned() + .map(ErrorVerdict::MissingPath) + .or_else(|| { + non_sandbox_failure + .clone() + .map(ErrorVerdict::NonSandboxFailure) + }) + .or_else(|| path_hints.first().cloned().map(ErrorVerdict::LikelySandbox)); + + ErrorObservation { + primary_verdict, + blocked_protected_file, + path_hints, + missing_paths: missing.into_iter().collect(), + non_sandbox_failure, + } +} + +fn detect_non_sandbox_failure_line(line: &str) -> Option { + let trimmed = line.trim(); + if trimmed.is_empty() { + return None; + } + + let lower = trimmed.to_ascii_lowercase(); + if lower.contains("eexist") + || lower.contains("file already exists") + || lower.contains("already exists") + { + return Some(trimmed.to_string()); + } + + // Version requirement errors are never sandbox-related + if lower.contains("version must be at least") + || lower.contains("requires version") + || lower.contains("minimum version") + || lower.contains("upgrade your") + { + return Some(trimmed.to_string()); + } + + None +} + +fn detect_protected_file_in_error_line( + protected_paths: &[PathBuf], + error_line: &str, +) -> Option { + for path in protected_paths { + if let Some(name) = path.file_name().and_then(|n| n.to_str()) + && error_line.contains(name) + { + return Some(name.to_string()); + } + } + None +} + +fn looks_like_access_denial(line: &str) -> bool { + let lower = line.to_ascii_lowercase(); + lower.contains("operation not permitted") + || lower.contains("permission denied") + || lower.contains("read-only file system") +} + +fn looks_like_structured_access_denial_code(line: &str) -> bool { + let lower = line.to_ascii_lowercase(); + (lower.contains("eperm") || lower.contains("eacces")) && looks_like_access_denial(line) +} + +fn looks_like_missing_path(line: &str) -> bool { + line.to_ascii_lowercase() + .contains("no such file or directory") +} + +fn render_diagnostic_block(body: &str) -> String { + let mut lines = Vec::new(); + + for line in body.lines() { + if line == "[nono]" { + lines.push(String::new()); + } else if let Some(stripped) = line.strip_prefix("[nono] ") { + lines.push(stripped.to_string()); + } else if let Some(stripped) = line.strip_prefix("[nono]") { + lines.push(stripped.to_string()); + } else { + lines.push(line.to_string()); + } + } + + lines.join("\n") +} + +fn format_command_failed_line(exit_code: i32) -> String { + format!("[nono] Command exited with code {}.", exit_code) +} + +fn format_command_failed_not_sandbox_line(exit_code: i32) -> String { + format!( + "[nono] The command failed, but this does not look like a sandbox denial. (exit code {})", + exit_code + ) +} + +fn format_command_succeeded_with_stderr_line() -> String { + "[nono] The command succeeded, but stderr showed a likely sandbox-related access issue." + .to_string() +} + +fn extract_denied_path_from_error_line(line: &str) -> Option { + if let Some(path) = extract_path_after_syscall_word(line) { + return Some(path); + } + + let denial_markers = [ + "Operation not permitted", + "Permission denied", + "Read-only file system", + ]; + + let prefix = denial_markers + .iter() + .find_map(|marker| line.find(marker).map(|idx| &line[..idx])) + .unwrap_or(line); + + for segment in prefix.rsplit(':') { + if let Some(path) = extract_path_from_segment(segment) { + return Some(path); + } + } + + extract_path_from_segment(prefix).or_else(|| extract_path_from_segment(line)) +} + +fn extract_path_after_syscall_word(line: &str) -> Option { + const MARKERS: &[&str] = &["mkdir", "mkdtemp", "open", "copyfile", "rename", "unlink"]; + + let lower = line.to_ascii_lowercase(); + for marker in MARKERS { + let needle = format!("{marker} "); + let Some(idx) = lower.find(&needle) else { + continue; + }; + let segment = line.get(idx + needle.len()..)?; + if let Some(path) = extract_path_from_segment(segment) { + return Some(path); + } + } + + None +} + +fn infer_access_from_structured_syscall_line(line: &str) -> Option { + let syscall = extract_structured_string_property(line, "syscall")?; + Some(match syscall.to_ascii_lowercase().as_str() { + "mkdir" | "mkdtemp" | "rmdir" | "unlink" | "rename" | "write" | "copyfile" | "chmod" + | "chown" | "utimes" => AccessMode::Write, + _ => AccessMode::ReadWrite, + }) +} + +fn extract_structured_path_property(line: &str) -> Option { + extract_structured_string_property(line, "path").map(PathBuf::from) +} + +fn extract_structured_string_property(line: &str, key: &str) -> Option { + let trimmed = line.trim(); + let after_key = trimmed + .strip_prefix(key) + .or_else(|| trimmed.strip_prefix(&format!("\"{key}\""))) + .or_else(|| trimmed.strip_prefix(&format!("'{key}'")))?; + let after_colon = after_key.trim_start().strip_prefix(':')?.trim_start(); + let quote = after_colon.chars().next()?; + if quote != '\'' && quote != '"' { + return None; + } + let after_quote = after_colon.get(quote.len_utf8()..)?; + let mut value = String::new(); + let mut escaped = false; + let mut found_end = false; + + for ch in after_quote.chars() { + if escaped { + if ch == quote || ch == '\\' { + value.push(ch); + } else { + value.push('\\'); + value.push(ch); + } + escaped = false; + continue; + } + + if ch == '\\' { + escaped = true; + continue; + } + + if ch == quote { + found_end = true; + break; + } + + value.push(ch); + } + + if !found_end { + return None; + } + + let value = value.trim(); + if value.is_empty() || value.chars().any(char::is_control) { + return None; + } + Some(value.to_string()) +} + +fn extract_relative_write_path_from_line(line: &str, current_dir: &Path) -> Option { + let lower = line.to_ascii_lowercase(); + let markers = ["creating empty ", "creating ", "create ", "writing "]; + + let marker = markers.iter().find(|marker| lower.contains(**marker))?; + let start = lower.find(marker)? + marker.len(); + let candidate = line.get(start..)?.split_whitespace().next()?; + let candidate = candidate + .trim_matches(|c: char| { + matches!( + c, + '\'' | '"' | '`' | ',' | ':' | ';' | '(' | ')' | '[' | ']' + ) + }) + .trim_end_matches('.') + .trim(); + + if candidate.is_empty() + || candidate.starts_with('/') + || candidate.starts_with('~') + || candidate.starts_with('-') + || candidate.chars().any(char::is_control) + { + return None; + } + + Some(current_dir.join(candidate)) +} + +fn extract_path_from_segment(segment: &str) -> Option { + let trimmed = segment.trim(); + if trimmed.is_empty() { + return None; + } + + // Strip a leading quote if the path is quoted (e.g. '/bin/ls' or "/bin/ls") + let (unquoted, closing_quote) = if trimmed.starts_with('\'') || trimmed.starts_with('"') { + let quote = trimmed.as_bytes()[0] as char; + (&trimmed[1..], Some(quote)) + } else { + (trimmed, None) + }; + + let tilde_idx = unquoted.find("~/"); + let slash_idx = unquoted.find('/'); + let start = match (tilde_idx, slash_idx) { + (Some(a), Some(b)) => Some(std::cmp::min(a, b)), + (Some(a), None) => Some(a), + (None, Some(b)) => Some(b), + (None, None) => None, + }?; + + let after_start = &unquoted[start..]; + + // Terminate the path at the closing quote (if we stripped an opening one) + // or at any character that cannot appear in a filesystem path. + let end = if let Some(q) = closing_quote { + after_start.find(q).unwrap_or(after_start.len()) + } else { + after_start + .find(['\'', '"', '`', ')', '(', '<', '>']) + .unwrap_or(after_start.len()) + }; + + let candidate = after_start[..end].trim(); + if candidate.is_empty() || candidate.chars().any(char::is_control) { + return None; + } + + Some(PathBuf::from(candidate)) +} + +fn infer_access_from_error_line(line: &str, path: &Path) -> AccessMode { + let lower = line.to_ascii_lowercase(); + + if let Some(name) = path.file_name().and_then(|n| n.to_str()) + && matches!( + name, + ".profile" | ".bash_profile" | ".bashrc" | ".zprofile" | ".zshrc" | ".zlogin" + ) + { + return AccessMode::Read; + } + + if lower.contains("cannot create") + || lower.contains("can't create") + || lower.contains("write error") + || lower.contains("read-only file system") + || lower.contains("operation not permitted, mkdir ") + || lower.contains("permission denied, mkdir ") + || lower.contains("eperm") && lower.contains("mkdir ") + || lower.contains("eacces") && lower.contains("mkdir ") + || lower.starts_with("tee:") + || lower.starts_with("touch:") + || lower.starts_with("mkdir:") + || lower.starts_with("mktemp:") + || lower.starts_with("install:") + || lower.starts_with("cp:") + || lower.starts_with("mv:") + || lower.starts_with("rm:") + || lower.starts_with("ln:") + || lower.starts_with("chmod:") + || lower.starts_with("chown:") + || lower.starts_with("truncate:") + { + return AccessMode::Write; + } + + if lower.contains("cannot open") + || lower.contains("can't open") + || lower.starts_with("cat:") + || lower.starts_with("grep:") + || lower.starts_with("sed:") + || lower.starts_with("awk:") + || lower.starts_with("head:") + || lower.starts_with("tail:") + || lower.starts_with("less:") + || lower.starts_with("more:") + || lower.starts_with("find:") + || lower.starts_with("ls:") + { + return AccessMode::Read; + } + + AccessMode::ReadWrite +} + +/// Formats diagnostic information about sandbox policy. +/// +/// This is library code that can be used by any parent process +/// that wants to explain sandbox denials to users or AI agents. +pub struct DiagnosticFormatter<'a> { + caps: &'a CapabilitySet, + mode: DiagnosticMode, + denials: &'a [DenialRecord], + ipc_denials: &'a [IpcDenialRecord], + sandbox_violations: &'a [SandboxViolation], + /// Paths that are write-protected due to trust verification + protected_paths: &'a [PathBuf], + /// Primary verdict extracted from the command output. + primary_verdict: Option, + /// Name of a protected file that was detected in the error output + blocked_protected_file: Option, + /// Best-effort path hints extracted from the command's own error output. + observed_path_hints: Vec, + /// Best-effort missing path hints extracted from the command's own error output. + missing_path_hints: Vec, + /// Error text that strongly suggests a non-sandbox application failure. + non_sandbox_failure: Option, + /// Command that was executed (for context-aware diagnostics) + command: Option, + /// Directory the child process started in. + current_dir: Option<&'a Path>, + /// Session ID for `nono grant` suggestions in supervised mode. + session_id: Option, + /// Policy explanations for denied paths, resolved from `query_path`. + policy_explanations: Vec, + /// Paths suppressed from the save-profile prompt (from `suppress_save_prompt` + /// profile field or `--suppress-save-prompt` CLI flag). Used to annotate + /// denied paths with `[save skipped]` so the diagnostic footer is + /// self-explanatory without requiring the user to cross-reference their profile. + suppressed_paths: &'a [PathBuf], + /// Non-filesystem sandbox operations suppressed from the diagnostic footer. + /// The sandbox still denies these operations; this only controls reporting. + suppressed_system_service_operations: &'a [String], + /// Canonicalized forms of the denied paths, parallel to `denials`. When + /// provided, used in place of on-demand `try_canonicalize` calls inside the + /// render loop so filesystem I/O is done once (by the caller, after the + /// child exits) rather than once per denial per render method. + canonical_denial_paths: Vec, +} + +impl<'a> DiagnosticFormatter<'a> { + /// Create a new formatter for the given capability set. + #[must_use] + pub fn new(caps: &'a CapabilitySet) -> Self { + Self { + caps, + mode: DiagnosticMode::Standard, + denials: &[], + ipc_denials: &[], + sandbox_violations: &[], + protected_paths: &[], + primary_verdict: None, + blocked_protected_file: None, + observed_path_hints: Vec::new(), + missing_path_hints: Vec::new(), + non_sandbox_failure: None, + command: None, + current_dir: None, + session_id: None, + policy_explanations: Vec::new(), + suppressed_paths: &[], + suppressed_system_service_operations: &[], + canonical_denial_paths: Vec::new(), + } + } + + /// Set the diagnostic mode (standard or supervised). + #[must_use] + pub fn with_mode(mut self, mode: DiagnosticMode) -> Self { + self.mode = mode; + self + } + + /// Add denial records from a supervised session. + #[must_use] + pub fn with_denials(mut self, denials: &'a [DenialRecord]) -> Self { + self.denials = denials; + self + } + + /// Set paths suppressed from the save-profile prompt. + #[must_use] + pub fn with_suppressed_paths(mut self, paths: &'a [PathBuf]) -> Self { + self.suppressed_paths = paths; + self + } + + /// Set non-filesystem sandbox operations suppressed from the diagnostic footer. + #[must_use] + pub fn with_suppressed_system_service_operations(mut self, operations: &'a [String]) -> Self { + self.suppressed_system_service_operations = operations; + self + } + + /// Set pre-canonicalized forms of the denied paths to avoid repeated + /// calling of `try_canonicalize` at render time. + #[must_use] + pub fn with_canonical_denial_paths(mut self, paths: Vec) -> Self { + self.canonical_denial_paths = paths; + self + } + + /// Add IPC denial records from a supervised session. + #[must_use] + pub fn with_ipc_denials(mut self, denials: &'a [IpcDenialRecord]) -> Self { + self.ipc_denials = denials; + self + } + + /// Add OS-native sandbox violation records. + #[must_use] + pub fn with_sandbox_violations(mut self, violations: &'a [SandboxViolation]) -> Self { + self.sandbox_violations = violations; + self + } + + /// Add paths that are write-protected due to trust verification. + /// + /// These are signed instruction files that the sandbox protects from + /// modification even when the parent directory has write access. + #[must_use] + pub fn with_protected_paths(mut self, paths: &'a [PathBuf]) -> Self { + self.protected_paths = paths; + self + } + + /// Set best-effort observations extracted from the command's stderr output. + #[must_use] + pub fn with_error_observation(mut self, observation: ErrorObservation) -> Self { + self.primary_verdict = observation.primary_verdict; + self.blocked_protected_file = observation.blocked_protected_file; + self.observed_path_hints = observation.path_hints; + self.missing_path_hints = observation.missing_paths; + self.non_sandbox_failure = observation.non_sandbox_failure; + self + } + + /// Set command context for more specific diagnostics. + #[must_use] + pub fn with_command(mut self, command: CommandContext) -> Self { + self.command = Some(command); + self + } + + /// Set the child process working directory for cwd-relative diagnostics. + #[must_use] + pub fn with_current_dir(mut self, current_dir: &'a Path) -> Self { + self.current_dir = Some(current_dir); + self + } + + /// Set the session ID for `nono grant` suggestions in supervised mode. + #[must_use] + pub fn with_session_id(mut self, session_id: Option) -> Self { + self.session_id = session_id; + self + } + + /// Add policy explanations for denied paths. + /// + /// These are resolved from `query_path` in the CLI layer and provide + /// group names, policy details, and suggested fixes so the diagnostic + /// can show them inline. + #[must_use] + pub fn with_policy_explanations(mut self, explanations: Vec) -> Self { + self.policy_explanations = explanations; + self + } + + /// Format the diagnostic footer for a failed command. + /// + /// Returns a multi-line string formatted as a dedicated diagnostic block. + /// The output is designed to be printed to stderr. + #[must_use] + pub fn format_footer(&self, exit_code: i32) -> String { + let body = match self.mode { + DiagnosticMode::Standard => self.format_standard_footer(exit_code), + DiagnosticMode::Supervised => self.format_supervised_footer(exit_code), + }; + render_diagnostic_block(&body) + } + + /// Check whether the resolved binary path falls under any allowed read path. + fn is_binary_path_readable(&self) -> bool { + let cmd = match &self.command { + Some(c) => c, + None => return true, // no context, assume readable + }; + let binary_path = &cmd.resolved_path; + for cap in self.caps.fs_capabilities() { + if cap.access == AccessMode::Read || cap.access == AccessMode::ReadWrite { + if cap.is_file { + if *binary_path == cap.resolved { + return true; + } + } else if binary_path.starts_with(&cap.resolved) { + return true; + } + } + } + false + } + + /// Check whether the binary's parent directory is readable in the sandbox. + fn is_binary_dir_readable(&self) -> bool { + let cmd = match &self.command { + Some(c) => c, + None => return true, + }; + let binary_dir = match cmd.resolved_path.parent() { + Some(d) => d, + None => return false, + }; + for cap in self.caps.fs_capabilities() { + if !cap.is_file + && (cap.access == AccessMode::Read || cap.access == AccessMode::ReadWrite) + && binary_dir.starts_with(&cap.resolved) + { + return true; + } + } + false + } + + /// Format context-aware explanation for the exit code. + /// + /// Returns a vec of diagnostic lines explaining what likely + /// happened and what the user can do about it. + fn format_exit_explanation(&self, exit_code: i32) -> Vec { + let mut lines = Vec::new(); + + match exit_code { + 127 => { + // 127 = command not found (shell convention) or execve failed. + // When we resolved the program path, prefer the broader wording. + let headline = if self.command.is_some() { + "[nono] Failed to execute command (exit code 127)." + } else { + "[nono] Command not found (exit code 127)." + }; + lines.push(headline.to_string()); + lines.push("[nono]".to_string()); + + if let Some(ref cmd) = self.command { + let program = sanitize_for_diagnostic(&cmd.program); + let path = sanitize_for_diagnostic(&cmd.resolved_path.display().to_string()); + if !self.is_binary_path_readable() { + // The binary exists (we resolved it) but the sandbox + // can't read it. + lines.push(format!( + "[nono] The executable '{}' was resolved at:", + program, + )); + lines.push(format!("[nono] {}", path)); + lines.push( + "[nono] but its directory is not readable inside the sandbox." + .to_string(), + ); + lines.push("[nono]".to_string()); + + if let Some(parent) = cmd.resolved_path.parent() { + let parent_path = + sanitize_for_diagnostic(&parent.display().to_string()); + lines.push( + "[nono] Fix: grant read access to the binary's directory:" + .to_string(), + ); + lines.push(format!("[nono] nono run --read {} ...", parent_path,)); + } + } else if !self.is_binary_dir_readable() { + // Binary itself is allowed but its directory isn't + // (unlikely but possible with file-level grants) + lines.push(format!( + "[nono] '{}' resolved to {} but the directory", + program, path, + )); + lines.push( + "[nono] may not be accessible. The sandbox needs read access to" + .to_string(), + ); + lines.push("[nono] the directory containing the binary.".to_string()); + } else { + // Binary path is readable — the command may depend on + // a dynamic linker, shared libraries, or shell that + // isn't accessible. + lines.push(format!( + "[nono] '{}' resolved to {} and is readable,", + program, path, + )); + lines.push("[nono] but execution still failed. Common causes:".to_string()); + lines.push( + "[nono] - A shared library or dynamic linker path is not accessible" + .to_string(), + ); + lines.push( + "[nono] - The binary is a script whose interpreter is not accessible" + .to_string(), + ); + lines.push( + "[nono] - The binary depends on a path not in the sandbox" + .to_string(), + ); + lines.push("[nono]".to_string()); + lines.push( + "[nono] Run with -v to see all allowed paths and check if".to_string(), + ); + lines.push("[nono] required system directories are included.".to_string()); + } + } else { + lines.push( + "[nono] The command binary could not be found or executed inside" + .to_string(), + ); + lines.push( + "[nono] the sandbox. Ensure the binary's directory is readable." + .to_string(), + ); + } + } + 126 => { + // 126 = command found but not executable + lines.push("[nono] Permission denied (exit code 126).".to_string()); + lines.push("[nono]".to_string()); + + if let Some(ref cmd) = self.command { + let program = sanitize_for_diagnostic(&cmd.program); + let path = sanitize_for_diagnostic(&cmd.resolved_path.display().to_string()); + lines.push(format!( + "[nono] '{}' was found at {} but could not be executed.", + program, path, + )); + lines.push( + "[nono] The file may not have execute permission, or the sandbox" + .to_string(), + ); + lines.push( + "[nono] may be blocking execution of binaries in that directory." + .to_string(), + ); + } else { + lines.push( + "[nono] The command was found but could not be executed.".to_string(), + ); + lines.push( + "[nono] Check file permissions and sandbox access to the binary's directory." + .to_string(), + ); + } + } + code if (129..=192).contains(&code) => { + // Signal-based exit: 128 + signal number + let sig = code - 128; + // SIGSYS is platform-dependent: 31 on Linux, 12 on macOS + let sigsys: i32 = nix::libc::SIGSYS; + let sig_name = match sig { + 1 => "SIGHUP", + 2 => "SIGINT", + 4 => "SIGILL", + 6 => "SIGABRT", + 9 => "SIGKILL", + 11 => "SIGSEGV", + 13 => "SIGPIPE", + 15 => "SIGTERM", + s if s == sigsys => "SIGSYS", + _ => "", + }; + + if sig == sigsys { + // SIGSYS = seccomp/sandbox killed it + lines.push(format!( + "[nono] Command killed by {} (exit code {}).", + sig_name, code, + )); + lines.push("[nono]".to_string()); + lines.push( + "[nono] SIGSYS typically means a blocked system call. The command tried" + .to_string(), + ); + lines.push("[nono] an operation that the sandbox does not permit.".to_string()); + } else if sig == 9 { + lines.push(format!( + "[nono] Command killed by {} (exit code {}).", + sig_name, code, + )); + lines.push("[nono]".to_string()); + lines.push( + "[nono] The process was forcefully terminated. This is usually not" + .to_string(), + ); + lines.push("[nono] caused by sandbox restrictions.".to_string()); + } else if !sig_name.is_empty() { + lines.push(format!( + "[nono] Command killed by signal {} / {} (exit code {}).", + sig, sig_name, code, + )); + } else { + lines.push(format!( + "[nono] Command killed by signal {} (exit code {}).", + sig, code, + )); + } + } + code => { + lines.push(format_command_failed_line(code)); + } + } + + lines + } + + /// Standard mode footer: concise policy summary with --allow suggestions. + fn format_standard_footer(&self, exit_code: i32) -> String { + let mut lines = Vec::new(); + let observed_hints = self.actionable_observed_path_hints(); + let primary_verdict = self.primary_observation_verdict(); + let has_observation = self.has_error_observation(); + + // Check if this was a protected file write attempt + if let Some(ref blocked_file) = self.blocked_protected_file { + lines.push(format!( + "[nono] Write to '{}' blocked: file is a signed instruction file.", + blocked_file + )); + lines.push( + "[nono] Signed instruction files are write-protected to prevent tampering." + .to_string(), + ); + lines.push("[nono]".to_string()); + lines.push(format!( + "[nono] The command failed. (exit code {})", + exit_code + )); + } else if matches!( + primary_verdict.as_ref(), + Some(ErrorVerdict::MissingPath(_)) | Some(ErrorVerdict::NonSandboxFailure(_)) + ) { + lines.push(format_command_failed_not_sandbox_line(exit_code)); + } else if exit_code == 0 && has_observation { + lines.push(format_command_succeeded_with_stderr_line()); + } else { + lines.extend(self.format_exit_explanation(exit_code)); + } + lines.push("[nono]".to_string()); + + if self.blocked_protected_file.is_none() + && let Some(verdict) = primary_verdict.as_ref() + { + self.format_primary_verdict_guidance(&mut lines, verdict); + lines.push("[nono]".to_string()); + } + + // Concise policy summary: show user paths, summarize system/group paths + lines.push("[nono] Sandbox policy:".to_string()); + + self.format_allowed_paths_concise(&mut lines); + self.format_network_status(&mut lines); + self.format_protected_paths(&mut lines); + let additional_hints = if observed_hints.len() > 1 { + &observed_hints[1..] + } else { + &[] + }; + self.format_observed_path_hints(&mut lines, additional_hints); + + // Help section (skip if the failure was specifically due to protected file) + if self.blocked_protected_file.is_none() + && observed_hints.is_empty() + && primary_verdict.is_none() + { + lines.push("[nono]".to_string()); + self.format_grant_help(&mut lines); + lines.push("[nono]".to_string()); + self.format_follow_up_guidance(&mut lines, None); + } + + lines.join("\n") + } + + /// Supervised mode footer: show denials and mode-specific guidance. + fn format_supervised_footer(&self, exit_code: i32) -> String { + let mut lines = Vec::new(); + let primary_verdict = self.primary_observation_verdict(); + let has_observation = self.has_error_observation(); + + let has_ipc_denials = !self.ipc_denials.is_empty(); + + if self.denials.is_empty() + && !has_ipc_denials + && matches!( + primary_verdict.as_ref(), + Some(ErrorVerdict::MissingPath(_)) | Some(ErrorVerdict::NonSandboxFailure(_)) + ) + { + lines.push(format_command_failed_not_sandbox_line(exit_code)); + } else if exit_code == 0 && has_observation && self.denials.is_empty() && !has_ipc_denials { + lines.push(format_command_succeeded_with_stderr_line()); + } else { + lines.extend(self.format_exit_explanation(exit_code)); + } + lines.push("[nono]".to_string()); + + // Convert macOS Seatbelt violations (operation + target) into + // DenialRecords so the same rendering logic handles both platforms. + let (violation_denials, mut non_fs_violations) = + violations_to_denials(self.sandbox_violations); + non_fs_violations.retain(|violation| { + !self + .suppressed_system_service_operations + .contains(&violation.operation) + }); + + // Merge supervisor denials (Linux seccomp) with violation-derived + // denials (macOS Seatbelt) into a single unified list. + let mut all_denials: Vec = self + .denials + .iter() + .cloned() + .chain(violation_denials) + .collect(); + all_denials.extend(self.observed_denials_matching_logged_paths(&all_denials)); + + if !self.ipc_denials.is_empty() { + self.format_ipc_denial_guidance(&mut lines); + if !all_denials.is_empty() || !non_fs_violations.is_empty() { + lines.push("[nono]".to_string()); + } + } + + if all_denials.is_empty() && self.ipc_denials.is_empty() { + // No denials from either source. + if !non_fs_violations.is_empty() { + // Non-filesystem violations (mach-lookup, signal, etc.) — + // show them with human-readable descriptions. + lines.push("[nono] Sandbox blocked system services:".to_string()); + format_non_fs_violations(&mut lines, &non_fs_violations); + lines.push("[nono]".to_string()); + format_non_fs_guidance(&mut lines, &non_fs_violations); + } else { + // Genuinely no denials observed. + if let Some(verdict) = primary_verdict.as_ref() { + self.format_primary_verdict_guidance(&mut lines, verdict); + lines.push("[nono]".to_string()); + } + lines.push("[nono] No path denials were observed during this session.".to_string()); + lines.push( + "[nono] The failure may be unrelated to sandbox restrictions.".to_string(), + ); + } + lines.push("[nono]".to_string()); + self.format_grant_help(&mut lines); + lines.push("[nono]".to_string()); + self.format_follow_up_guidance(&mut lines, None); + } else if !all_denials.is_empty() { + // Deduplicate by path, merging access modes. Classification into + // actionable vs. policy-blocked is done by the consolidated + // formatter using policy_explanations when available. + let deduped = dedupe_denials(&all_denials); + self.format_consolidated_denial_guidance(&mut lines, &deduped); + + // Show non-filesystem violations (mach-lookup, etc.) if any + if !non_fs_violations.is_empty() { + lines.push("[nono]".to_string()); + lines.push("[nono] Also blocked (system services):".to_string()); + format_non_fs_violations(&mut lines, &non_fs_violations); + lines.push("[nono]".to_string()); + format_non_fs_guidance(&mut lines, &non_fs_violations); + } + + // Note: `nono grant` suggestions are shown via desktop + // notifications during the session, not in the post-exit footer. + } + + lines.join("\n") + } + + fn actionable_observed_path_hints(&self) -> Vec { + self.observed_path_hints + .iter() + .filter_map(|hint| { + self.actionable_observed_access(&hint.path, hint.access) + .map(|access| ObservedPathHint { + path: hint.path.clone(), + access, + }) + }) + .collect() + } + + fn observed_denials_matching_logged_paths( + &self, + denials: &[DenialRecord], + ) -> Vec { + if denials.is_empty() { + return Vec::new(); + } + + let logged_paths = denials + .iter() + .map(|denial| denial.path.clone()) + .collect::>(); + + self.actionable_observed_path_hints() + .into_iter() + .filter(|hint| logged_paths.contains(&hint.path)) + .map(|hint| DenialRecord { + path: hint.path, + access: hint.access, + reason: DenialReason::InsufficientAccess, + }) + .collect() + } + + fn primary_observation_verdict(&self) -> Option { + self.missing_path_hints + .first() + .cloned() + .map(ErrorVerdict::MissingPath) + .or_else(|| { + self.non_sandbox_failure + .clone() + .map(ErrorVerdict::NonSandboxFailure) + }) + .or_else(|| { + self.actionable_observed_path_hints() + .first() + .cloned() + .map(ErrorVerdict::LikelySandbox) + }) + } + + fn has_error_observation(&self) -> bool { + self.primary_verdict.is_some() + || self.blocked_protected_file.is_some() + || !self.observed_path_hints.is_empty() + || !self.missing_path_hints.is_empty() + || self.non_sandbox_failure.is_some() + } + + fn actionable_observed_access(&self, path: &Path, inferred: AccessMode) -> Option { + let Some(cap) = self.closest_covering_capability_any(path) else { + return Some(inferred); + }; + + if cap.access.contains(inferred) { + return None; + } + + match (cap.access, inferred) { + (AccessMode::Read, AccessMode::ReadWrite) => Some(AccessMode::Write), + (AccessMode::Write, AccessMode::ReadWrite) => Some(AccessMode::Read), + _ => Some(inferred), + } + } + + fn closest_covering_capability_any( + &self, + path: &Path, + ) -> Option<&nono::capability::FsCapability> { + let canonical = try_canonicalize(path); + let mut best_covering: Option<&nono::capability::FsCapability> = None; + let mut best_covering_score = 0usize; + + for cap in self.caps.fs_capabilities() { + let covers = if cap.is_file { + cap.resolved == canonical + } else { + canonical.starts_with(&cap.resolved) + }; + + if !covers { + continue; + } + + let score = cap.resolved.as_os_str().len(); + if score >= best_covering_score { + best_covering = Some(cap); + best_covering_score = score; + } + } + + best_covering + } + + fn format_follow_up_guidance( + &self, + lines: &mut Vec, + _hint: Option<(&Path, AccessMode)>, + ) { + lines.push("[nono] Next steps:".to_string()); + if let Some(command) = self.format_command_for_learn() { + lines.push(format!( + "[nono] Discover paths: nono learn -- {}", + command + )); + } else { + lines.push("[nono] Discover paths: nono learn -- ".to_string()); + } + lines.push( + "[nono] Query policy: nono why --path --op ".to_string(), + ); + } + + fn format_primary_observed_guidance(&self, lines: &mut Vec, hint: &ObservedPathHint) { + lines.push("[nono] Sandbox denial:".to_string()); + if self.observed_hint_points_to_read_only_cwd(hint) { + lines.push( + "[nono] The command appears to be writing inside the current working directory," + .to_string(), + ); + lines.push( + "[nono] but the current working directory is read-only in this sandbox." + .to_string(), + ); + } + lines.push(format!( + "[nono] {} ({})", + hint.path.display(), + access_str(hint.access), + )); + lines.push(format!( + "[nono] Try: {}", + self.suggested_flag_for_hint(&hint.path, hint.access) + )); + } + + fn format_primary_verdict_guidance(&self, lines: &mut Vec, verdict: &ErrorVerdict) { + match verdict { + ErrorVerdict::LikelySandbox(hint) => { + self.format_primary_observed_guidance(lines, hint); + } + ErrorVerdict::MissingPath(path) => { + self.format_primary_missing_path_guidance(lines, path); + } + ErrorVerdict::NonSandboxFailure(failure) => { + self.format_non_sandbox_failure_guidance(lines, failure); + } + } + } + + fn format_primary_missing_path_guidance(&self, lines: &mut Vec, path: &Path) { + lines.push("[nono] Missing path:".to_string()); + lines.push(format!("[nono] {}", path.display())); + lines.push("[nono] The command reported \"No such file or directory\".".to_string()); + lines.push( + "[nono] Path flags only apply to paths that already exist when nono starts." + .to_string(), + ); + lines.push( + "[nono] Create the path first, or grant an existing parent directory if the command needs to create it." + .to_string(), + ); + } + + fn format_non_sandbox_failure_guidance(&self, lines: &mut Vec, failure: &str) { + lines.push("[nono] Application error:".to_string()); + lines.push(format!("[nono] {}", sanitize_for_diagnostic(failure))); + lines.push( + "[nono] The command's own output suggests this failure is unrelated to sandbox permissions." + .to_string(), + ); + } + + /// Render the consolidated denial block. + /// + /// Shows every denied path (truncated past `MAX_INLINE_LIST` entries) with + /// a `[permanently restricted]` marker for paths that are blocked by the + /// sensitive-path policy, and emits a single `Fix flags:` line combining the + /// `--read`/`--write`/`--allow` flags for all actionable denials. + /// + /// Classification: if a policy explanation with `reason == "sensitive_path"` + /// exists for a path, it is treated as policy-blocked and cannot be fixed + /// via flags. Everything else is actionable, including macOS Seatbelt + /// denials whose `DenialReason` defaults to `PolicyBlocked` (that reason + /// is over-broad on macOS — we trust the query_path result instead). + fn format_consolidated_denial_guidance( + &self, + lines: &mut Vec, + denials: &[DenialRecord], + ) { + const MAX_INLINE_LIST: usize = 10; + + let (unix_socket_denials, path_denials): (Vec<&DenialRecord>, Vec<&DenialRecord>) = denials + .iter() + .partition(|denial| denial.reason == DenialReason::UnixSocketDenied); + + if !unix_socket_denials.is_empty() { + let total = unix_socket_denials.len(); + let plural_s = if total == 1 { "" } else { "s" }; + lines.push(format!( + "[nono] IPC denial: {} pathname Unix socket{} blocked.", + total, plural_s + )); + for (idx, denial) in unix_socket_denials.iter().enumerate() { + if idx >= MAX_INLINE_LIST { + lines.push(format!("[nono] ... and {} more", total - idx)); + break; + } + lines.push(format!("[nono] {}", denial.path.display())); + } + let flags: Vec = unix_socket_denials + .iter() + .map(|d| self.suggested_flag_for_denial(d)) + .collect(); + lines.push(format!("[nono] Fix flags: {}", flags.join(" "))); + } + + if path_denials.is_empty() { + return; + } + + let total = path_denials.len(); + let mut actionable: Vec<&DenialRecord> = Vec::new(); + let mut policy_blocked: Vec<&DenialRecord> = Vec::new(); + + for denial in &path_denials { + if self.is_denial_policy_blocked(denial) { + policy_blocked.push(denial); + } else { + actionable.push(denial); + } + } + + let plural_s = if total == 1 { "" } else { "s" }; + lines.push(format!( + "[nono] Sandbox denial: {} path{} blocked.", + total, plural_s + )); + + for (idx, denial) in path_denials.iter().enumerate() { + if idx >= MAX_INLINE_LIST { + lines.push(format!("[nono] … and {} more", total - idx)); + break; + } + let mut labels: Vec<&str> = Vec::new(); + if self.is_denial_policy_blocked(denial) { + labels.push("permanently restricted"); + } + if self.is_denial_suppressed(denial) { + labels.push("save skipped"); + } + let suffix = if labels.is_empty() { + String::new() + } else { + format!(" [{}]", labels.join(", ")) + }; + lines.push(format!( + "[nono] {} ({}){}", + denial.path.display(), + access_str(denial.access), + suffix, + )); + } + + if !actionable.is_empty() { + let flags: Vec = actionable + .iter() + .map(|d| self.suggested_flag_for_denial(d)) + .collect(); + lines.push(format!("[nono] Fix flags: {}", flags.join(" "))); + } + + if !policy_blocked.is_empty() { + let n = policy_blocked.len(); + let (subject, verb) = if n == 1 { + ("1 path is", "") + } else { + ("paths are", "") + }; + let count_prefix = if n == 1 { + String::from(subject) + } else { + format!("{} {}", n, subject) + }; + lines.push("[nono]".to_string()); + lines.push(format!( + "[nono] {}{} permanently restricted — override via a user profile with filesystem.bypass_protection.", + count_prefix, verb, + )); + } + } + + fn format_ipc_denial_guidance(&self, lines: &mut Vec) { + const MAX_INLINE_LIST: usize = 10; + + let total = self.ipc_denials.len(); + let plural_s = if total == 1 { "" } else { "s" }; + lines.push(format!( + "[nono] IPC denial: {} Unix socket operation{} blocked.", + total, plural_s + )); + for (idx, denial) in self.ipc_denials.iter().enumerate() { + if idx >= MAX_INLINE_LIST { + lines.push(format!("[nono] ... and {} more", total - idx)); + break; + } + lines.push(format!( + "[nono] {} {} ({})", + denial.operation, denial.target, denial.reason + )); + } + + let flags: Vec<&str> = self + .ipc_denials + .iter() + .filter_map(|denial| denial.suggested_flag.as_deref()) + .collect(); + if !flags.is_empty() { + lines.push(format!("[nono] Fix flags: {}", flags.join(" "))); + } + } + + /// Return the canonical form of `denial.path`, using the pre-computed + /// parallel vec when available to avoid repeated filesystem I/O. + fn canonical_for_denial<'b>(&'b self, denial: &DenialRecord) -> std::borrow::Cow<'b, Path> { + if let Some(canonical) = self + .denials + .iter() + .position(|d| d.path == denial.path) + .and_then(|i| self.canonical_denial_paths.get(i)) + { + return std::borrow::Cow::Borrowed(canonical.as_path()); + } + std::borrow::Cow::Owned(nono::try_canonicalize(&denial.path)) + } + + /// Return true when the denied path is in the caller-supplied suppression + /// list (i.e. `suppress_save_prompt` entries). Such paths are still denied + /// by the sandbox — this only controls whether they appear in the save + /// prompt and how they are labelled in the diagnostic footer. + fn is_denial_suppressed(&self, denial: &DenialRecord) -> bool { + if self.suppressed_paths.is_empty() { + return false; + } + let canonical = self.canonical_for_denial(denial); + self.suppressed_paths + .iter() + .any(|suppressed| canonical.starts_with(suppressed)) + } + + /// Return true when the denial cannot be fixed by a path flag alone — + /// i.e. the path is blocked by the sensitive-path policy and requires a + /// profile with `filesystem.bypass_protection`. + fn is_denial_policy_blocked(&self, denial: &DenialRecord) -> bool { + if let Some(expl) = self + .policy_explanations + .iter() + .find(|e| e.path == denial.path) + { + return expl.reason == "sensitive_path"; + } + // No explanation (e.g. Linux seccomp path without a matching lookup): + // trust the DenialRecord reason. + denial.reason == DenialReason::PolicyBlocked + } + + /// Build the CLI flag suggestion for a single denial. Prefers the + /// explanation's `suggested_flag` (which knows about parent-directory + /// canonicalization) and falls back to a local computation otherwise. + fn suggested_flag_for_denial(&self, denial: &DenialRecord) -> String { + if denial.reason == DenialReason::UnixSocketDenied { + let flag = if denial.access.contains(AccessMode::Write) { + "--allow-unix-socket-bind" + } else { + "--allow-unix-socket" + }; + return format!("{} {}", flag, denial.path.display()); + } + + if let Some(flag) = self + .policy_explanations + .iter() + .find(|e| e.path == denial.path && e.access == denial.access) + .and_then(|e| e.suggested_flag.clone()) + { + // explanations' suggested_flag is of the form "--read /path". + // Strip any leading fix label that callers may have prepended. + return flag + .strip_prefix("Fix: ") + .or_else(|| flag.strip_prefix("Fix flags: ")) + .map(str::to_string) + .unwrap_or(flag); + } + crate::query_ext::suggested_flag_for_path(&denial.path, denial.access) + } + + fn format_grant_help(&self, lines: &mut Vec) { + lines.push("[nono] To grant additional access, re-run with:".to_string()); + lines.push("[nono] --allow read+write access to directory".to_string()); + lines.push("[nono] --read read-only access to directory".to_string()); + lines.push("[nono] --write write-only access to directory".to_string()); + + if self.caps.is_network_blocked() { + lines.push( + "[nono] --allow-net unrestricted network for this session".to_string(), + ); + } + } + + fn format_command_for_learn(&self) -> Option { + let command = self.command.as_ref()?; + if command.args.is_empty() { + return None; + } + + Some( + command + .args + .iter() + .map(|arg| shell_quote(arg)) + .collect::>() + .join(" "), + ) + } + + fn suggested_flag_for_hint(&self, path: &Path, requested: AccessMode) -> String { + if let Some(flag) = self.suggested_upgrade_flag_for_existing_capability(path, requested) { + flag + } else if self.observed_hint_points_to_ungranted_cwd(path) { + "--allow-cwd".to_string() + } else { + crate::query_ext::suggested_flag_for_path(path, requested) + } + } + + fn observed_hint_points_to_read_only_cwd(&self, hint: &ObservedPathHint) -> bool { + let Some(current_dir) = self.current_dir else { + return false; + }; + + hint.path.starts_with(current_dir) + && self + .suggested_upgrade_flag_for_existing_capability(&hint.path, hint.access) + .is_some() + } + + fn suggested_upgrade_flag_for_existing_capability( + &self, + path: &Path, + requested: AccessMode, + ) -> Option { + let cap = self.closest_covering_capability_any(path)?; + if cap.access.contains(requested) { + return None; + } + + let target = cap.resolved.clone(); + + let requested = match (cap.access, requested) { + (AccessMode::Read, AccessMode::ReadWrite) => AccessMode::Write, + (AccessMode::Write, AccessMode::ReadWrite) => AccessMode::Read, + _ => requested, + }; + + Some(suggested_flag_for_existing_target( + &target, + cap.is_file, + requested, + )) + } + + fn observed_hint_points_to_ungranted_cwd(&self, path: &Path) -> bool { + let Some(current_dir) = self.current_dir else { + return false; + }; + + if !path.starts_with(current_dir) { + return false; + } + + self.closest_covering_capability_any(current_dir).is_none() + } + + /// Format allowed paths concisely: show user/profile paths explicitly, + /// summarize group/system paths with a count. + fn format_allowed_paths_concise(&self, lines: &mut Vec) { + let caps = self.caps.fs_capabilities(); + if caps.is_empty() { + lines.push("[nono] Allowed paths: (none)".to_string()); + return; + } + + let mut user_paths = Vec::new(); + let mut group_count: usize = 0; + + for cap in caps { + match &cap.source { + CapabilitySource::User | CapabilitySource::Profile => { + let kind = if cap.is_file { "file" } else { "dir" }; + user_paths.push(format!( + "[nono] {} ({}, {})", + cap.resolved.display(), + access_str(cap.access), + kind, + )); + } + CapabilitySource::Group(_) | CapabilitySource::System => { + group_count += 1; + } + } + } + + if user_paths.is_empty() && group_count == 0 { + lines.push("[nono] Allowed paths: (none)".to_string()); + } else { + lines.push("[nono] Allowed paths:".to_string()); + for p in &user_paths { + lines.push(p.clone()); + } + if group_count > 0 { + lines.push(format!("[nono] + {} system/group path(s)", group_count)); + } + } + } + + fn format_observed_path_hints(&self, lines: &mut Vec, hints: &[ObservedPathHint]) { + if hints.is_empty() { + return; + } + + lines.push("[nono] Likely blocked paths seen in the command output:".to_string()); + for hint in hints { + lines.push(format!( + "[nono] {} ({})", + hint.path.display(), + access_str(hint.access), + )); + } + } + + /// Format the network status. + fn format_network_status(&self, lines: &mut Vec) { + use nono::NetworkMode; + match self.caps.network_mode() { + NetworkMode::Blocked => { + lines.push("[nono] Network: blocked".to_string()); + } + NetworkMode::ProxyOnly { port, bind_ports } => { + if bind_ports.is_empty() { + lines.push(format!("[nono] Network: proxy (localhost:{})", port)); + } else { + let ports_str: Vec = bind_ports.iter().map(|p| p.to_string()).collect(); + lines.push(format!( + "[nono] Network: proxy (localhost:{}), bind: {}", + port, + ports_str.join(", ") + )); + } + } + NetworkMode::AllowAll => { + lines.push("[nono] Network: allowed".to_string()); + } + } + } + + /// Format write-protected paths (signed instruction files). + fn format_protected_paths(&self, lines: &mut Vec) { + if self.protected_paths.is_empty() { + return; + } + + lines.push("[nono] Write-protected (signed instruction files):".to_string()); + for path in self.protected_paths { + // Show just the filename for brevity + let name = path + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_else(|| path.display().to_string()); + lines.push(format!("[nono] {}", name)); + } + } +} + +/// Catalog of observed non-filesystem macOS sandbox denials. +/// +/// Apple's public documentation covers APIs such as CFPreferences, but not a +/// complete stable taxonomy of Seatbelt operation names. Keep this table +/// evidence-based: add entries only when they are observed in sandbox logs or +/// backed by a known framework/daemon mapping. +#[derive(Debug, Clone, Copy)] +struct SystemServiceDiagnostic { + operation: &'static str, + target: SystemServiceTarget, + description: &'static str, + guidance: Option, +} + +#[derive(Debug, Clone, Copy)] +enum SystemServiceTarget { + Any, + Exact(&'static str), + Prefix(&'static str), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum SystemServiceGuidance { + Keychain, + SetuidExec, + UserPreferences, +} + +const SYSTEM_SERVICE_DIAGNOSTICS: &[SystemServiceDiagnostic] = &[ + SystemServiceDiagnostic::exact( + "mach-lookup", + "com.apple.SecurityServer", + "Keychain / Security framework", + Some(SystemServiceGuidance::Keychain), + ), + SystemServiceDiagnostic::exact( + "mach-lookup", + "com.apple.securityd", + "Keychain / Security framework", + Some(SystemServiceGuidance::Keychain), + ), + SystemServiceDiagnostic::exact( + "mach-lookup", + "com.apple.security.keychaind", + "Keychain / Security framework", + Some(SystemServiceGuidance::Keychain), + ), + SystemServiceDiagnostic::exact( + "mach-lookup", + "com.apple.secd", + "Keychain / Security framework", + Some(SystemServiceGuidance::Keychain), + ), + SystemServiceDiagnostic::exact( + "mach-lookup", + "com.apple.security.agent", + "Keychain authorization agent", + Some(SystemServiceGuidance::Keychain), + ), + SystemServiceDiagnostic::exact("mach-lookup", "com.apple.logd", "System logging", None), + SystemServiceDiagnostic::exact( + "mach-lookup", + "com.apple.system.notification_center", + "Distributed notifications", + None, + ), + SystemServiceDiagnostic::exact( + "mach-lookup", + "com.apple.distributed_notifications", + "Distributed notifications", + None, + ), + SystemServiceDiagnostic::exact( + "mach-lookup", + "com.apple.CoreServices.coreservicesd", + "Launch Services", + None, + ), + SystemServiceDiagnostic::exact( + "mach-lookup", + "com.apple.lsd.mapdb", + "Launch Services", + None, + ), + SystemServiceDiagnostic::prefix( + "mach-lookup", + "com.apple.windowserver", + "Window Server / GUI", + None, + ), + SystemServiceDiagnostic::prefix( + "mach-lookup", + "com.apple.cfprefsd", + "Preferences (CFPreferences / NSUserDefaults)", + None, + ), + SystemServiceDiagnostic::prefix( + "mach-lookup", + "com.apple.pasteboard", + "Pasteboard / clipboard", + None, + ), + SystemServiceDiagnostic::prefix( + "mach-lookup", + "com.apple.coreservices", + "Core Services", + None, + ), + SystemServiceDiagnostic::exact( + "user-preference-read", + "kcfpreferencesanyapplication", + "Global preferences (CFPreferences any-application domain)", + Some(SystemServiceGuidance::UserPreferences), + ), + SystemServiceDiagnostic::prefix( + "user-preference-read", + "kcfpreferences", + "Preferences (CFPreferences / NSUserDefaults)", + Some(SystemServiceGuidance::UserPreferences), + ), + SystemServiceDiagnostic::any( + "forbidden-exec-sugid", + "Setuid/setgid executable blocked", + Some(SystemServiceGuidance::SetuidExec), + ), +]; + +impl SystemServiceDiagnostic { + const fn any( + operation: &'static str, + description: &'static str, + guidance: Option, + ) -> Self { + Self { + operation, + target: SystemServiceTarget::Any, + description, + guidance, + } + } + + const fn exact( + operation: &'static str, + target: &'static str, + description: &'static str, + guidance: Option, + ) -> Self { + Self { + operation, + target: SystemServiceTarget::Exact(target), + description, + guidance, + } + } + + const fn prefix( + operation: &'static str, + target_prefix: &'static str, + description: &'static str, + guidance: Option, + ) -> Self { + Self { + operation, + target: SystemServiceTarget::Prefix(target_prefix), + description, + guidance, + } + } + + fn matches(&self, violation: &SandboxViolation) -> bool { + if violation.operation != self.operation { + return false; + } + match self.target { + SystemServiceTarget::Any => true, + _ => violation + .target + .as_deref() + .is_some_and(|target| self.target.matches(target)), + } + } +} + +impl SystemServiceTarget { + fn matches(self, target: &str) -> bool { + match self { + Self::Any => true, + Self::Exact(expected) => target.eq_ignore_ascii_case(expected), + Self::Prefix(prefix) => target + .get(..prefix.len()) + .is_some_and(|candidate| candidate.eq_ignore_ascii_case(prefix)), + } + } +} + +fn system_service_diagnostic_for( + violation: &SandboxViolation, +) -> Option<&'static SystemServiceDiagnostic> { + SYSTEM_SERVICE_DIAGNOSTICS + .iter() + .find(|diagnostic| diagnostic.matches(violation)) +} + +/// Format non-filesystem violations with human-readable service descriptions. +fn format_non_fs_violations(lines: &mut Vec, violations: &[&SandboxViolation]) { + for v in violations { + let desc = system_service_diagnostic_for(v).map(|diagnostic| diagnostic.description); + match (&v.target, desc) { + (Some(target), Some(description)) => { + lines.push(format!( + "[nono] {} ({}) — {}", + v.operation, target, description + )); + } + (Some(target), None) => { + lines.push(format!("[nono] {} ({})", v.operation, target)); + } + (None, Some(description)) => { + lines.push(format!("[nono] {} — {}", v.operation, description)); + } + (None, None) => { + lines.push(format!("[nono] {}", v.operation)); + } + } + } +} + +/// Generate actionable guidance for non-filesystem violations. +fn format_non_fs_guidance(lines: &mut Vec, violations: &[&SandboxViolation]) { + let has_guidance = |guidance| { + violations.iter().any(|violation| { + system_service_diagnostic_for(violation).and_then(|diagnostic| diagnostic.guidance) + == Some(guidance) + }) + }; + + if has_guidance(SystemServiceGuidance::Keychain) { + lines.push("[nono] Keychain access requires granting the login keychain path:".to_string()); + lines.push(keychain_login_grant_guidance()); + } + + if has_guidance(SystemServiceGuidance::UserPreferences) { + lines.push("[nono] Preference reads use macOS CFPreferences / NSUserDefaults.".to_string()); + lines.push( + "[nono] They are platform operations, not filesystem paths; saving them writes a raw macOS Seatbelt rule.".to_string(), + ); + lines.push( + "[nono] If the tool requires this, accept the profile prompt or add a reviewed user profile rule:".to_string(), + ); + lines.push( + "[nono] \"unsafe_macos_seatbelt_rules\": [\"(allow user-preference-read)\"]" + .to_string(), + ); + } + + if has_guidance(SystemServiceGuidance::SetuidExec) { + lines.push( + "[nono] A sandboxed process tried to execute a setuid/setgid binary.".to_string(), + ); + lines.push( + "[nono] macOS blocks privilege-changing execs inside this sandbox; this is not a path grant.".to_string(), + ); + lines.push( + "[nono] nono does not save this automatically. Prefer a non-setuid helper, or run the privileged helper outside nono after review.".to_string(), + ); + } +} + +fn keychain_login_grant_guidance() -> String { + const DISPLAY_PATH: &str = "~/Library/Keychains/login.keychain-db"; + let Some(home) = std::env::var_os("HOME") else { + return format!("[nono] --read-file {DISPLAY_PATH}"); + }; + let path = PathBuf::from(home).join("Library/Keychains/login.keychain-db"); + keychain_grant_guidance_for_path(&path, DISPLAY_PATH) +} + +fn keychain_grant_guidance_for_path(path: &Path, display_path: &str) -> String { + let flag = match std::fs::metadata(path).map(|metadata| metadata.file_type()) { + Ok(file_type) if file_type.is_dir() => "--read", + _ => "--read-file", + }; + format!("[nono] {flag} {display_path}") +} + +/// Deduplicate denials by path, merging access modes. When the same path +/// appears with multiple reasons, the most restrictive reason wins +/// (`PolicyBlocked` > `InsufficientAccess` > `UserDenied` > `RateLimited` > +/// `BackendError`). Output is sorted by path for stable rendering. +fn dedupe_denials(denials: &[DenialRecord]) -> Vec { + let mut by_path = std::collections::BTreeMap::::new(); + + for denial in denials { + by_path + .entry(denial.path.clone()) + .and_modify(|(access, reason)| { + *access = merge_access_modes(*access, denial.access); + *reason = stricter_reason(reason.clone(), denial.reason.clone()); + }) + .or_insert_with(|| (denial.access, denial.reason.clone())); + } + + by_path + .into_iter() + .map(|(path, (access, reason))| DenialRecord { + path, + access, + reason, + }) + .collect() +} + +fn stricter_reason(a: DenialReason, b: DenialReason) -> DenialReason { + fn rank(r: &DenialReason) -> u8 { + match r { + DenialReason::PolicyBlocked => 5, + DenialReason::UnixSocketDenied => 5, + DenialReason::InsufficientAccess => 4, + DenialReason::UserDenied => 3, + DenialReason::RateLimited => 2, + DenialReason::BackendError => 1, + } + } + if rank(&a) >= rank(&b) { a } else { b } +} + +/// Convert `SandboxViolation`s with filesystem targets into `DenialRecord`s. +/// +/// Non-filesystem violations (mach-lookup, signal, etc.) are returned +/// separately since they can't be expressed as path grants. +fn violations_to_denials( + violations: &[SandboxViolation], +) -> (Vec, Vec<&SandboxViolation>) { + let mut denials = Vec::new(); + let mut non_fs = Vec::new(); + // Deduplicate: multiple operations on the same path merge into one denial + let mut seen = std::collections::BTreeMap::::new(); + + for v in violations { + if let (Some(access), Some(target)) = + (seatbelt_operation_to_access(&v.operation), &v.target) + { + let path = PathBuf::from(target); + seen.entry(path) + .and_modify(|existing| *existing = merge_access_modes(*existing, access)) + .or_insert(access); + } else { + non_fs.push(v); + } + } + + for (path, access) in seen { + denials.push(DenialRecord { + path, + access, + reason: DenialReason::PolicyBlocked, + }); + } + + (denials, non_fs) +} + +fn access_str(access: AccessMode) -> &'static str { + match access { + AccessMode::Read => "read", + AccessMode::Write => "write", + AccessMode::ReadWrite => "read+write", + } +} + +fn merge_access_modes(existing: AccessMode, new: AccessMode) -> AccessMode { + if existing == new { + existing + } else { + AccessMode::ReadWrite + } +} + +fn suggested_flag_for_existing_target( + target: &Path, + is_file: bool, + requested: AccessMode, +) -> String { + let flag = if is_file { + match requested { + AccessMode::Read => "--read-file", + AccessMode::Write => "--write-file", + AccessMode::ReadWrite => "--allow-file", + } + } else { + match requested { + AccessMode::Read => "--read", + AccessMode::Write => "--write", + AccessMode::ReadWrite => "--allow", + } + }; + + format!("{flag} {}", target.display()) +} + +fn shell_quote(s: &str) -> String { + if !s.is_empty() + && s.bytes() + .all(|b| b.is_ascii_alphanumeric() || b"/-_.".contains(&b)) + { + return s.to_string(); + } + + let mut quoted = String::with_capacity(s.len() + 2); + quoted.push('\''); + for ch in s.chars() { + if ch == '\'' { + quoted.push_str("'\\''"); + } else { + quoted.push(ch); + } + } + quoted.push('\''); + quoted +} + +#[cfg(test)] +mod tests { + use super::*; + use nono::capability::FsCapability; + use tempfile::tempdir; + + fn make_test_caps() -> CapabilitySet { + let mut caps = CapabilitySet::new().block_network(); + caps.add_fs(FsCapability { + original: PathBuf::from("/test/project"), + resolved: PathBuf::from("/test/project"), + access: AccessMode::ReadWrite, + is_file: false, + source: CapabilitySource::User, + }); + caps + } + + fn make_mixed_caps() -> CapabilitySet { + let mut caps = CapabilitySet::new(); + caps.add_fs(FsCapability { + original: PathBuf::from("/home/user/project"), + resolved: PathBuf::from("/home/user/project"), + access: AccessMode::ReadWrite, + is_file: false, + source: CapabilitySource::User, + }); + caps.add_fs(FsCapability { + original: PathBuf::from("/usr/bin"), + resolved: PathBuf::from("/usr/bin"), + access: AccessMode::Read, + is_file: false, + source: CapabilitySource::Group("base_read".to_string()), + }); + caps.add_fs(FsCapability { + original: PathBuf::from("/usr/lib"), + resolved: PathBuf::from("/usr/lib"), + access: AccessMode::Read, + is_file: false, + source: CapabilitySource::Group("base_read".to_string()), + }); + caps.add_fs(FsCapability { + original: PathBuf::from("/tmp"), + resolved: PathBuf::from("/tmp"), + access: AccessMode::ReadWrite, + is_file: false, + source: CapabilitySource::System, + }); + caps + } + + // --- Standard mode tests --- + + #[test] + fn test_standard_footer_contains_exit_code() { + let caps = make_test_caps(); + let formatter = DiagnosticFormatter::new(&caps); + let output = formatter.format_footer(1); + + assert!(output.contains("Command exited with code 1.")); + } + + #[test] + fn test_standard_footer_uses_may_not_was() { + let caps = make_test_caps(); + let formatter = DiagnosticFormatter::new(&caps); + let output = formatter.format_footer(1); + + assert!(!output.contains("may be due to sandbox restrictions")); + assert!(!output.contains("was caused by")); + } + + #[test] + fn test_standard_footer_has_block_header() { + let caps = make_test_caps(); + let formatter = DiagnosticFormatter::new(&caps); + let output = formatter.format_footer(1); + + assert!(!output.starts_with("nono diagnostic")); + assert!(!output.contains("[nono]")); + } + + #[test] + fn test_standard_footer_shows_user_paths() { + let caps = make_test_caps(); + let formatter = DiagnosticFormatter::new(&caps); + let output = formatter.format_footer(1); + + assert!(output.contains("/test/project")); + assert!(output.contains("read+write")); + } + + #[test] + fn test_standard_footer_summarizes_group_paths() { + let caps = make_mixed_caps(); + let formatter = DiagnosticFormatter::new(&caps); + let output = formatter.format_footer(1); + + // User path shown explicitly + assert!(output.contains("/home/user/project")); + // Group/system paths summarized, not listed individually + assert!(output.contains("3 system/group path(s)")); + assert!(!output.contains("/usr/bin")); + assert!(!output.contains("/usr/lib")); + } + + #[test] + fn test_standard_footer_shows_network_blocked() { + let caps = make_test_caps(); + let formatter = DiagnosticFormatter::new(&caps); + let output = formatter.format_footer(1); + + assert!(output.contains("Network: blocked")); + } + + #[test] + fn test_standard_footer_shows_network_allowed() { + let mut caps = CapabilitySet::new(); + caps.add_fs(FsCapability { + original: PathBuf::from("/test/project"), + resolved: PathBuf::from("/test/project"), + access: AccessMode::ReadWrite, + is_file: false, + source: CapabilitySource::User, + }); + let formatter = DiagnosticFormatter::new(&caps); + let output = formatter.format_footer(1); + + assert!(output.contains("Network: allowed")); + } + + #[test] + fn test_standard_footer_shows_network_proxy() { + use nono::NetworkMode; + let mut caps = CapabilitySet::new().block_network(); + caps.set_network_mode_mut(NetworkMode::ProxyOnly { + port: 12345, + bind_ports: vec![], + }); + caps.add_fs(FsCapability { + original: PathBuf::from("/test/project"), + resolved: PathBuf::from("/test/project"), + access: AccessMode::ReadWrite, + is_file: false, + source: CapabilitySource::User, + }); + let formatter = DiagnosticFormatter::new(&caps); + let output = formatter.format_footer(1); + + assert!(output.contains("Network: proxy (localhost:12345)")); + } + + #[test] + fn test_standard_footer_shows_help() { + let caps = make_test_caps(); + let formatter = DiagnosticFormatter::new(&caps); + let output = formatter.format_footer(1); + + assert!(output.contains("--allow ")); + assert!(output.contains("--read ")); + assert!(output.contains("--write ")); + } + + #[test] + fn test_standard_footer_shows_network_help_when_blocked() { + let caps = make_test_caps(); + let formatter = DiagnosticFormatter::new(&caps); + let output = formatter.format_footer(1); + + assert!(output.contains("--allow-net")); + } + + #[test] + fn test_standard_footer_no_network_help_when_allowed() { + let mut caps = CapabilitySet::new(); + caps.add_fs(FsCapability { + original: PathBuf::from("/test/project"), + resolved: PathBuf::from("/test/project"), + access: AccessMode::ReadWrite, + is_file: false, + source: CapabilitySource::User, + }); + let formatter = DiagnosticFormatter::new(&caps); + let output = formatter.format_footer(1); + + assert!(!output.contains("--allow-net")); + } + + #[test] + fn test_analyze_error_output_detects_read_path() { + let observation = analyze_error_output( + "/bin/sh: /Users/alice/.profile: Operation not permitted\n", + &[], + None, + ); + + assert_eq!( + observation.path_hints, + vec![ObservedPathHint { + path: PathBuf::from("/Users/alice/.profile"), + access: AccessMode::Read, + }] + ); + } + + #[test] + fn test_analyze_error_output_detects_write_path_with_spaces() { + let observation = analyze_error_output( + "sh: cannot create '/tmp/file with spaces.txt': Operation not permitted\n", + &[], + None, + ); + + assert_eq!( + observation.path_hints, + vec![ObservedPathHint { + path: PathBuf::from("/tmp/file with spaces.txt"), + access: AccessMode::Write, + }] + ); + } + + #[test] + fn test_analyze_error_output_detects_node_eperm_mkdir_as_write() { + let observation = analyze_error_output( + "Failed to extract bundled package: Error: EPERM: operation not permitted, mkdir '/Users/luke/Library/Caches/copilot/pkg/darwin-arm64'\n", + &[], + None, + ); + + let hint = ObservedPathHint { + path: PathBuf::from("/Users/luke/Library/Caches/copilot/pkg/darwin-arm64"), + access: AccessMode::Write, + }; + assert_eq!(observation.path_hints, vec![hint.clone()]); + assert_eq!( + observation.primary_verdict, + Some(ErrorVerdict::LikelySandbox(hint)) + ); + } + + #[test] + fn test_analyze_error_output_detects_structured_node_eperm_mkdir_path() { + let observation = analyze_error_output( + "Error: EPERM: operation not permitted\n code: 'EPERM',\n syscall: 'mkdir',\n path: '/Users/luke/Library/Caches/copilot/pkg/darwin-arm64'\n", + &[], + None, + ); + + assert_eq!( + observation.path_hints, + vec![ObservedPathHint { + path: PathBuf::from("/Users/luke/Library/Caches/copilot/pkg/darwin-arm64"), + access: AccessMode::Write, + }] + ); + } + + #[test] + fn test_analyze_error_output_detects_structured_path_with_escaped_quote() { + let observation = analyze_error_output( + "Error: EPERM: operation not permitted\n code: 'EPERM',\n syscall: 'mkdir',\n path: '/Users/luke/Library/Caches/it\\'s/pkg'\n", + &[], + None, + ); + + assert_eq!( + observation.path_hints, + vec![ObservedPathHint { + path: PathBuf::from("/Users/luke/Library/Caches/it's/pkg"), + access: AccessMode::Write, + }] + ); + } + + #[test] + fn test_analyze_error_output_merges_access_modes() { + let observation = analyze_error_output( + "cat: /tmp/shared.txt: Permission denied\ntee: /tmp/shared.txt: Operation not permitted\n", + &[], + None, + ); + + assert_eq!( + observation.path_hints, + vec![ObservedPathHint { + path: PathBuf::from("/tmp/shared.txt"), + access: AccessMode::ReadWrite, + }] + ); + } + + #[test] + fn test_analyze_error_output_detects_missing_path() { + let observation = analyze_error_output( + "sh: /tmp/missing/file.txt: No such file or directory\n", + &[], + None, + ); + + assert_eq!(observation.path_hints, Vec::::new()); + assert_eq!( + observation.missing_paths, + vec![PathBuf::from("/tmp/missing/file.txt")] + ); + } + + #[test] + fn test_analyze_error_output_handles_quoted_execvp_path() { + // Regression: "sandbox-exec: execvp() of '/bin/ls' failed: Permission denied" + // must extract /bin/ls, not "/bin/ls' failed". + let observation = analyze_error_output( + "sandbox-exec: execvp() of '/bin/ls' failed: Permission denied\n", + &[], + None, + ); + + assert_eq!( + observation.path_hints, + vec![ObservedPathHint { + path: PathBuf::from("/bin/ls"), + access: AccessMode::ReadWrite, + }] + ); + } + + #[test] + fn test_analyze_error_output_handles_double_quoted_path() { + let observation = analyze_error_output( + "error: cannot open \"/etc/shadow\" for reading: Permission denied\n", + &[], + None, + ); + + assert_eq!( + observation.path_hints, + vec![ObservedPathHint { + path: PathBuf::from("/etc/shadow"), + access: AccessMode::Read, + }] + ); + } + + #[test] + fn test_analyze_error_output_infers_relative_write_path_from_cwd() { + let cwd = Path::new("/Users/luke/project"); + let observation = analyze_error_output( + "Creating empty tessl.json...\nPermission denied. Please check file permissions and try again.\n", + &[], + Some(cwd), + ); + + assert_eq!( + observation.path_hints, + vec![ObservedPathHint { + path: PathBuf::from("/Users/luke/project/tessl.json"), + access: AccessMode::Write, + }] + ); + assert_eq!( + observation.primary_verdict, + Some(ErrorVerdict::LikelySandbox(ObservedPathHint { + path: PathBuf::from("/Users/luke/project/tessl.json"), + access: AccessMode::Write, + })) + ); + } + + #[test] + fn test_analyze_error_output_detects_non_sandbox_failure() { + let observation = analyze_error_output( + "EEXIST: file already exists, mkdir '/Users/luke/.local/share/opencode'\n", + &[], + None, + ); + + assert_eq!( + observation.non_sandbox_failure.as_deref(), + Some("EEXIST: file already exists, mkdir '/Users/luke/.local/share/opencode'") + ); + assert_eq!( + observation.primary_verdict, + Some(ErrorVerdict::NonSandboxFailure( + "EEXIST: file already exists, mkdir '/Users/luke/.local/share/opencode'" + .to_string(), + )) + ); + assert!(observation.path_hints.is_empty()); + assert!(observation.missing_paths.is_empty()); + } + + #[test] + fn test_standard_footer_empty_caps() { + let caps = CapabilitySet::new(); + let formatter = DiagnosticFormatter::new(&caps); + let output = formatter.format_footer(1); + + assert!(output.contains("(none)")); + } + + #[test] + fn test_standard_footer_file_vs_dir() { + let mut caps = CapabilitySet::new(); + caps.add_fs(FsCapability { + original: PathBuf::from("/test/file.txt"), + resolved: PathBuf::from("/test/file.txt"), + access: AccessMode::Read, + is_file: true, + source: CapabilitySource::User, + }); + caps.add_fs(FsCapability { + original: PathBuf::from("/test/dir"), + resolved: PathBuf::from("/test/dir"), + access: AccessMode::Write, + is_file: false, + source: CapabilitySource::User, + }); + + let formatter = DiagnosticFormatter::new(&caps); + let output = formatter.format_footer(1); + + assert!(output.contains("file.txt (read, file)")); + assert!(output.contains("dir (write, dir)")); + } + + #[test] + fn test_standard_footer_shows_observed_path_hint_suggestions() { + let temp = match tempdir() { + Ok(dir) => dir, + Err(e) => panic!("tempdir failed: {e}"), + }; + let denied = temp.path().join("denied.txt"); + if let Err(e) = std::fs::write(&denied, "secret") { + panic!("write failed: {e}"); + } + let caps = make_test_caps(); + + let formatter = DiagnosticFormatter::new(&caps).with_error_observation(ErrorObservation { + primary_verdict: Some(ErrorVerdict::LikelySandbox(ObservedPathHint { + path: denied.clone(), + access: AccessMode::Read, + })), + blocked_protected_file: None, + path_hints: vec![ObservedPathHint { + path: denied.clone(), + access: AccessMode::Read, + }], + missing_paths: Vec::new(), + non_sandbox_failure: None, + }); + let output = formatter.format_footer(1); + + assert!(output.contains("Sandbox denial:")); + assert!(output.contains(&denied.display().to_string())); + assert!(output.contains(&format!("Try: --read-file {}", denied.display()))); + assert!(output.contains("Sandbox policy:")); + } + + #[test] + fn test_standard_footer_exit_zero_with_observed_hint_still_surfaces_diagnostic() { + let denied = PathBuf::from("/Users/alice/.profile"); + let caps = make_test_caps(); + let formatter = DiagnosticFormatter::new(&caps).with_error_observation(ErrorObservation { + primary_verdict: Some(ErrorVerdict::LikelySandbox(ObservedPathHint { + path: denied.clone(), + access: AccessMode::Read, + })), + blocked_protected_file: None, + path_hints: vec![ObservedPathHint { + path: denied.clone(), + access: AccessMode::Read, + }], + missing_paths: Vec::new(), + non_sandbox_failure: None, + }); + let output = formatter.format_footer(0); + + assert!(output.contains( + "The command succeeded, but stderr showed a likely sandbox-related access issue." + )); + assert!(output.contains("Sandbox denial:")); + assert!(output.contains(&denied.display().to_string())); + } + + #[test] + fn test_standard_footer_surfaces_missing_path_before_policy() { + let caps = make_test_caps(); + let missing = PathBuf::from("/tmp/missing/file.txt"); + let formatter = DiagnosticFormatter::new(&caps).with_error_observation(ErrorObservation { + primary_verdict: Some(ErrorVerdict::MissingPath(missing.clone())), + blocked_protected_file: None, + path_hints: Vec::new(), + missing_paths: vec![missing.clone()], + non_sandbox_failure: None, + }); + let output = formatter.format_footer(1); + let missing_idx = match output.find("Missing path:") { + Some(idx) => idx, + None => panic!("missing path block missing: {output}"), + }; + let policy_idx = match output.find("Sandbox policy:") { + Some(idx) => idx, + None => panic!("policy block missing: {output}"), + }; + + assert!( + output.contains("The command failed, but this does not look like a sandbox denial.") + ); + assert!(output.contains(&missing.display().to_string())); + assert!(output.contains("Path flags only apply to paths that already exist")); + assert!(missing_idx < policy_idx); + assert!(!output.contains("To grant additional access, re-run with:")); + assert!(!output.contains("Why: nono why")); + } + + #[test] + fn test_standard_footer_surfaces_non_sandbox_failure_before_policy() { + let caps = make_test_caps(); + let formatter = DiagnosticFormatter::new(&caps).with_error_observation(ErrorObservation { + primary_verdict: Some(ErrorVerdict::NonSandboxFailure( + "EEXIST: file already exists, mkdir '/Users/luke/.local/share/opencode'" + .to_string(), + )), + blocked_protected_file: None, + path_hints: Vec::new(), + missing_paths: Vec::new(), + non_sandbox_failure: Some( + "EEXIST: file already exists, mkdir '/Users/luke/.local/share/opencode'" + .to_string(), + ), + }); + let output = formatter.format_footer(1); + + assert!( + output.contains("The command failed, but this does not look like a sandbox denial.") + ); + assert!(output.contains("Application error:")); + assert!(output.contains("EEXIST: file already exists")); + assert!(!output.contains("To grant additional access, re-run with:")); + assert!(!output.contains("Why: nono why")); + } + + #[test] + fn test_standard_footer_observed_hint_narrows_to_missing_write_access() { + let temp = match tempdir() { + Ok(dir) => dir, + Err(e) => panic!("tempdir failed: {e}"), + }; + let denied = temp.path().join("denied.txt"); + if let Err(e) = std::fs::write(&denied, "secret") { + panic!("write failed: {e}"); + } + + let canonical_temp = match temp.path().canonicalize() { + Ok(path) => path, + Err(e) => panic!("canonicalize failed: {e}"), + }; + + let mut caps = CapabilitySet::new(); + caps.add_fs(FsCapability { + original: temp.path().to_path_buf(), + resolved: canonical_temp.clone(), + access: AccessMode::Read, + is_file: false, + source: CapabilitySource::User, + }); + + let formatter = DiagnosticFormatter::new(&caps).with_error_observation(ErrorObservation { + primary_verdict: Some(ErrorVerdict::LikelySandbox(ObservedPathHint { + path: denied.clone(), + access: AccessMode::ReadWrite, + })), + blocked_protected_file: None, + path_hints: vec![ObservedPathHint { + path: denied.clone(), + access: AccessMode::ReadWrite, + }], + missing_paths: Vec::new(), + non_sandbox_failure: None, + }); + let output = formatter.format_footer(1); + + assert!(output.contains(&format!("{} (write)", denied.display()))); + assert!(output.contains(&format!("--write {}", canonical_temp.display()))); + assert!(!output.contains(&format!("--allow-file {}", denied.display()))); + } + + #[test] + fn test_standard_footer_prefers_explicit_write_upgrade_for_read_only_cwd_write() { + let cwd = PathBuf::from("/Users/luke/project"); + let denied = cwd.join("tessl.json"); + let mut caps = CapabilitySet::new(); + caps.add_fs(FsCapability { + original: cwd.clone(), + resolved: cwd.clone(), + access: AccessMode::Read, + is_file: false, + source: CapabilitySource::User, + }); + + let formatter = DiagnosticFormatter::new(&caps) + .with_current_dir(&cwd) + .with_error_observation(ErrorObservation { + primary_verdict: Some(ErrorVerdict::LikelySandbox(ObservedPathHint { + path: denied.clone(), + access: AccessMode::Write, + })), + blocked_protected_file: None, + path_hints: vec![ObservedPathHint { + path: denied.clone(), + access: AccessMode::Write, + }], + missing_paths: Vec::new(), + non_sandbox_failure: None, + }); + let output = formatter.format_footer(1); + + assert!(output.contains("current working directory is read-only")); + assert!(output.contains(&format!("Try: --write {}", cwd.display()))); + assert!(!output.contains("Try: --allow-cwd")); + } + + #[test] + fn test_standard_footer_skips_observed_hint_already_covered() { + let temp = match tempdir() { + Ok(dir) => dir, + Err(e) => panic!("tempdir failed: {e}"), + }; + let denied = temp.path().join("denied.txt"); + if let Err(e) = std::fs::write(&denied, "secret") { + panic!("write failed: {e}"); + } + + let mut caps = CapabilitySet::new(); + caps.add_fs(FsCapability { + original: temp.path().to_path_buf(), + resolved: match temp.path().canonicalize() { + Ok(path) => path, + Err(e) => panic!("canonicalize failed: {e}"), + }, + access: AccessMode::ReadWrite, + is_file: false, + source: CapabilitySource::User, + }); + + let formatter = DiagnosticFormatter::new(&caps).with_error_observation(ErrorObservation { + primary_verdict: Some(ErrorVerdict::LikelySandbox(ObservedPathHint { + path: denied.clone(), + access: AccessMode::Read, + })), + blocked_protected_file: None, + path_hints: vec![ObservedPathHint { + path: denied.clone(), + access: AccessMode::Read, + }], + missing_paths: Vec::new(), + non_sandbox_failure: None, + }); + let output = formatter.format_footer(1); + + assert!(!output.contains("Likely blocked paths seen in the command output")); + assert!(!output.contains(&denied.display().to_string())); + assert!(!output.contains("--read-file")); + } + + // --- Supervised mode tests --- + + #[test] + fn test_supervised_no_denials_no_extensions() { + let caps = make_test_caps(); // extensions_enabled defaults to false + let formatter = DiagnosticFormatter::new(&caps).with_mode(DiagnosticMode::Supervised); + let output = formatter.format_footer(1); + + assert!(output.contains("No path denials were observed during this session.")); + assert!(output.contains("The failure may be unrelated to sandbox restrictions.")); + assert!(output.contains("To grant additional access, re-run with:")); + assert!(output.contains("--allow ")); + assert!(!output.contains("Sandbox policy:")); + } + + #[test] + fn test_supervised_no_denials_no_extensions_uses_observed_hints() { + let temp = match tempdir() { + Ok(dir) => dir, + Err(e) => panic!("tempdir failed: {e}"), + }; + let denied = temp.path().join("startup.txt"); + if let Err(e) = std::fs::write(&denied, "secret") { + panic!("write failed: {e}"); + } + let caps = make_test_caps(); + + let formatter = DiagnosticFormatter::new(&caps) + .with_mode(DiagnosticMode::Supervised) + .with_error_observation(ErrorObservation { + primary_verdict: Some(ErrorVerdict::LikelySandbox(ObservedPathHint { + path: denied.clone(), + access: AccessMode::Read, + })), + blocked_protected_file: None, + path_hints: vec![ObservedPathHint { + path: denied.clone(), + access: AccessMode::Read, + }], + missing_paths: Vec::new(), + non_sandbox_failure: None, + }); + let output = formatter.format_footer(1); + + assert!(output.contains("Sandbox denial:")); + assert!(output.contains(&format!("Try: --read-file {}", denied.display()))); + assert!(output.contains("No path denials were observed during this session.")); + assert!(output.contains("Discover paths: nono learn -- ")); + assert!(!output.contains("Sandbox policy:")); + } + + #[test] + fn test_supervised_exit_zero_with_observed_hint_still_surfaces_diagnostic() { + let denied = PathBuf::from("/Users/alice/.profile"); + let caps = make_test_caps(); + let formatter = DiagnosticFormatter::new(&caps) + .with_mode(DiagnosticMode::Supervised) + .with_error_observation(ErrorObservation { + primary_verdict: Some(ErrorVerdict::LikelySandbox(ObservedPathHint { + path: denied.clone(), + access: AccessMode::Read, + })), + blocked_protected_file: None, + path_hints: vec![ObservedPathHint { + path: denied.clone(), + access: AccessMode::Read, + }], + missing_paths: Vec::new(), + non_sandbox_failure: None, + }); + let output = formatter.format_footer(0); + + assert!(output.contains( + "The command succeeded, but stderr showed a likely sandbox-related access issue." + )); + assert!(output.contains("Sandbox denial:")); + assert!(output.contains(&denied.display().to_string())); + assert!(output.contains("Discover paths: nono learn -- ")); + } + + #[test] + fn test_supervised_no_denials_no_extensions_surfaces_missing_path() { + let caps = make_test_caps(); + let missing = PathBuf::from("/tmp/missing/file.txt"); + let formatter = DiagnosticFormatter::new(&caps) + .with_mode(DiagnosticMode::Supervised) + .with_error_observation(ErrorObservation { + primary_verdict: Some(ErrorVerdict::MissingPath(missing.clone())), + blocked_protected_file: None, + path_hints: Vec::new(), + missing_paths: vec![missing.clone()], + non_sandbox_failure: None, + }); + let output = formatter.format_footer(1); + + assert!( + output.contains("The command failed, but this does not look like a sandbox denial.") + ); + assert!(output.contains(&missing.display().to_string())); + assert!(output.contains("To grant additional access, re-run with:")); + assert!( + output.contains("Query policy: nono why --path --op ") + ); + } + + #[test] + fn test_supervised_no_denials_no_extensions_surfaces_non_sandbox_failure() { + let caps = make_test_caps(); + let formatter = DiagnosticFormatter::new(&caps) + .with_mode(DiagnosticMode::Supervised) + .with_error_observation(ErrorObservation { + primary_verdict: Some(ErrorVerdict::NonSandboxFailure( + "EEXIST: file already exists, mkdir '/Users/luke/.local/share/opencode'" + .to_string(), + )), + blocked_protected_file: None, + path_hints: Vec::new(), + missing_paths: Vec::new(), + non_sandbox_failure: Some( + "EEXIST: file already exists, mkdir '/Users/luke/.local/share/opencode'" + .to_string(), + ), + }); + let output = formatter.format_footer(1); + + assert!( + output.contains("The command failed, but this does not look like a sandbox denial.") + ); + assert!(output.contains("Application error:")); + assert!(output.contains("EEXIST: file already exists")); + assert!(output.contains("To grant additional access, re-run with:")); + assert!(output.contains("Discover paths: nono learn -- ")); + } + + #[test] + fn test_supervised_no_denials_extensions_active() { + let mut caps = make_test_caps(); + caps.set_extensions_enabled(true); + let formatter = DiagnosticFormatter::new(&caps).with_mode(DiagnosticMode::Supervised); + let output = formatter.format_footer(1); + + assert!(output.contains("No path denials were observed during this session.")); + assert!(output.contains("may be unrelated")); + assert!(output.contains("--allow ")); + } + + #[test] + fn test_supervised_uses_sandbox_violations_when_available() { + let caps = make_test_caps(); + let violations = vec![ + SandboxViolation { + operation: "file-read-data".to_string(), + target: Some("/Users/alice/.ssh/id_rsa".to_string()), + }, + SandboxViolation { + operation: "mach-lookup".to_string(), + target: Some("com.apple.logd".to_string()), + }, + ]; + let formatter = DiagnosticFormatter::new(&caps) + .with_mode(DiagnosticMode::Supervised) + .with_sandbox_violations(&violations); + let output = formatter.format_footer(1); + + assert!(output.contains("Sandbox denial:")); + assert!(output.contains("/Users/alice/.ssh/id_rsa (read)")); + assert!(output.contains("Also blocked (system services):")); + assert!(output.contains("mach-lookup (com.apple.logd)")); + assert!(output.contains("System logging")); + } + + #[test] + fn test_violation_denial_keeps_file_read_target() { + let requested = PathBuf::from("/Users/alice/workspace/readable.rs"); + let violations = vec![SandboxViolation { + operation: "file-read-data".to_string(), + target: Some(requested.display().to_string()), + }]; + + let (denials, non_fs) = violations_to_denials(&violations); + + assert!(non_fs.is_empty()); + assert_eq!(denials.len(), 1); + assert_eq!(denials[0].path, requested); + assert_eq!(denials[0].access, AccessMode::Read); + } + + #[test] + fn test_logged_violation_target_wins_over_observed_requested_path() { + let caps = make_test_caps(); + let requested = PathBuf::from("/Users/alice/workspace/readable.rs"); + let actual = PathBuf::from("/Users/alice/Library/Caches/nl/state"); + let violations = vec![SandboxViolation { + operation: "file-write-create".to_string(), + target: Some(actual.display().to_string()), + }]; + let formatter = DiagnosticFormatter::new(&caps) + .with_mode(DiagnosticMode::Supervised) + .with_sandbox_violations(&violations) + .with_error_observation(ErrorObservation { + primary_verdict: Some(ErrorVerdict::LikelySandbox(ObservedPathHint { + path: requested.clone(), + access: AccessMode::Read, + })), + blocked_protected_file: None, + path_hints: vec![ObservedPathHint { + path: requested.clone(), + access: AccessMode::Read, + }], + missing_paths: Vec::new(), + non_sandbox_failure: None, + }); + + let output = formatter.format_footer(1); + + assert!(output.contains(&format!("{} (write)", actual.display()))); + assert!(!output.contains(&requested.display().to_string())); + } + + #[test] + fn test_sandbox_violation_preserves_workspace_prefix() { + let caps = make_test_caps(); + let actual = PathBuf::from( + "/Users/alice/workspace/flutter_photo_manager/lib/src/internal/plugin.dart", + ); + let violations = vec![SandboxViolation { + operation: "file-read-data".to_string(), + target: Some(actual.display().to_string()), + }]; + let formatter = DiagnosticFormatter::new(&caps) + .with_mode(DiagnosticMode::Supervised) + .with_sandbox_violations(&violations); + + let output = formatter.format_footer(1); + + assert!(output.contains(&format!("{} (read)", actual.display()))); + assert!(!output.contains("[nono] /src/internal/plugin.dart (read)")); + } + + #[test] + fn test_supervised_merges_mkdir_error_hint_with_logged_read_denial() { + let temp = tempdir().expect("tempdir should be created"); + let pkg = temp.path().join("Library/Caches/copilot/pkg"); + std::fs::create_dir_all(&pkg).expect("pkg fixture should be created"); + let denied = pkg.join("darwin-arm64"); + + let caps = CapabilitySet::new(); + let violations = vec![SandboxViolation { + operation: "file-read-data".to_string(), + target: Some(denied.display().to_string()), + }]; + let formatter = DiagnosticFormatter::new(&caps) + .with_mode(DiagnosticMode::Supervised) + .with_sandbox_violations(&violations) + .with_error_observation(ErrorObservation { + primary_verdict: Some(ErrorVerdict::LikelySandbox(ObservedPathHint { + path: denied.clone(), + access: AccessMode::Write, + })), + blocked_protected_file: None, + path_hints: vec![ObservedPathHint { + path: denied.clone(), + access: AccessMode::Write, + }], + missing_paths: Vec::new(), + non_sandbox_failure: None, + }) + .with_policy_explanations(vec![PolicyExplanation { + path: denied.clone(), + access: AccessMode::Read, + reason: "path_not_granted".to_string(), + suggested_flag: Some(format!("--read {}", denied.display())), + }]); + let output = formatter.format_footer(1); + + assert!(output.contains(&format!("{} (read+write)", denied.display()))); + assert!(output.contains(&format!("Fix flags: --allow {}", pkg.display()))); + assert!(!output.contains(&format!("Fix flags: --read {}", denied.display()))); + } + + #[test] + fn test_keychain_guidance_uses_file_flag_for_file_targets() { + let dir = tempdir().expect("tempdir should be created"); + let keychain = dir.path().join("login.keychain-db"); + std::fs::write(&keychain, "db").expect("keychain fixture should be written"); + + let guidance = + keychain_grant_guidance_for_path(&keychain, "~/Library/Keychains/login.keychain-db"); + + assert_eq!( + guidance, + "[nono] --read-file ~/Library/Keychains/login.keychain-db" + ); + } + + #[test] + fn test_keychain_guidance_uses_directory_flag_for_directory_targets() { + let dir = tempdir().expect("tempdir should be created"); + + let guidance = + keychain_grant_guidance_for_path(dir.path(), "~/Library/Keychains/login.keychain-db"); + + assert_eq!( + guidance, + "[nono] --read ~/Library/Keychains/login.keychain-db" + ); + } + + #[test] + fn test_keychain_guidance_recognizes_keychain_mach_services() { + let caps = make_test_caps(); + let violations = vec![SandboxViolation { + operation: "mach-lookup".to_string(), + target: Some("com.apple.secd".to_string()), + }]; + let formatter = DiagnosticFormatter::new(&caps) + .with_mode(DiagnosticMode::Supervised) + .with_sandbox_violations(&violations); + let output = formatter.format_footer(1); + + assert!(output.contains("Keychain access requires granting the login keychain path:")); + assert!(output.contains("--read-file ~/Library/Keychains/login.keychain-db")); + } + + #[test] + fn test_preference_guidance_recognizes_any_application_domain() { + let caps = make_test_caps(); + let violations = vec![SandboxViolation { + operation: "user-preference-read".to_string(), + target: Some("kcfpreferencesanyapplication".to_string()), + }]; + let formatter = DiagnosticFormatter::new(&caps) + .with_mode(DiagnosticMode::Supervised) + .with_sandbox_violations(&violations); + let output = formatter.format_footer(1); + + assert!(output.contains("user-preference-read (kcfpreferencesanyapplication)")); + assert!(output.contains("Global preferences")); + assert!(output.contains("CFPreferences / NSUserDefaults")); + assert!(output.contains("unsafe_macos_seatbelt_rules")); + assert!(output.contains("(allow user-preference-read)")); + } + + #[test] + fn test_forbidden_exec_sugid_guidance_is_not_saveable() { + let caps = make_test_caps(); + let violations = vec![SandboxViolation { + operation: "forbidden-exec-sugid".to_string(), + target: None, + }]; + let formatter = DiagnosticFormatter::new(&caps) + .with_mode(DiagnosticMode::Supervised) + .with_sandbox_violations(&violations); + let output = formatter.format_footer(0); + + assert!(output.contains("forbidden-exec-sugid")); + assert!(output.contains("Setuid/setgid executable blocked")); + assert!(output.contains("not a path grant")); + assert!(output.contains("does not save this automatically")); + assert!(!output.contains("unsafe_macos_seatbelt_rules")); + } + + #[test] + fn test_suppressed_system_service_violation_is_hidden_from_footer() { + let caps = make_test_caps(); + let violations = vec![ + SandboxViolation { + operation: "forbidden-exec-sugid".to_string(), + target: None, + }, + SandboxViolation { + operation: "mach-lookup".to_string(), + target: Some("com.apple.logd".to_string()), + }, + ]; + let suppressed = vec!["forbidden-exec-sugid".to_string()]; + let formatter = DiagnosticFormatter::new(&caps) + .with_mode(DiagnosticMode::Supervised) + .with_sandbox_violations(&violations) + .with_suppressed_system_service_operations(&suppressed); + let output = formatter.format_footer(1); + + assert!(!output.contains("forbidden-exec-sugid")); + assert!(!output.contains("Setuid/setgid executable blocked")); + assert!(output.contains("mach-lookup (com.apple.logd)")); + } + + #[test] + fn test_supervised_policy_blocked_denial() { + let caps = make_test_caps(); + let denials = vec![DenialRecord { + path: PathBuf::from("/etc/shadow"), + access: AccessMode::Read, + reason: DenialReason::PolicyBlocked, + }]; + let formatter = DiagnosticFormatter::new(&caps) + .with_mode(DiagnosticMode::Supervised) + .with_denials(&denials); + let output = formatter.format_footer(1); + + assert!(output.contains("Sandbox denial: 1 path blocked.")); + assert!(output.contains("/etc/shadow (read) [permanently restricted]")); + assert!(output.contains("permanently restricted — override via a user profile")); + // Policy-blocked paths cannot be fixed with a path flag. + assert!(!output.contains("Fix flags: --read /etc/shadow")); + assert!(!output.contains("--allow ")); + } + + #[test] + fn test_supervised_unix_socket_denial_uses_ipc_guidance() { + let caps = make_test_caps(); + let denials = vec![DenialRecord { + path: PathBuf::from("/run/user/1000/bus"), + access: AccessMode::Read, + reason: DenialReason::UnixSocketDenied, + }]; + let formatter = DiagnosticFormatter::new(&caps) + .with_mode(DiagnosticMode::Supervised) + .with_denials(&denials); + let output = formatter.format_footer(1); + + assert!(output.contains("IPC denial: 1 pathname Unix socket blocked.")); + assert!(output.contains("/run/user/1000/bus")); + assert!(output.contains("Fix flags: --allow-unix-socket /run/user/1000/bus")); + assert!(!output.contains("No path denials were observed")); + assert!(!output.contains("--read /run/user/1000/bus")); + } + + #[test] + fn test_supervised_user_denied() { + let caps = make_test_caps(); + let dir = tempdir().expect("tempdir should be created"); + let denied_path = dir.path().join("secret.txt"); + std::fs::write(&denied_path, "secret").expect("denied file should be created"); + let denials = vec![DenialRecord { + path: denied_path.clone(), + access: AccessMode::Read, + reason: DenialReason::UserDenied, + }]; + let formatter = DiagnosticFormatter::new(&caps) + .with_mode(DiagnosticMode::Supervised) + .with_denials(&denials); + let output = formatter.format_footer(1); + + assert!(output.contains("Sandbox denial: 1 path blocked.")); + assert!(output.contains(&denied_path.display().to_string())); + assert!(output.contains(&format!("Fix flags: --read-file {}", denied_path.display()))); + // User-denied paths are actionable, not policy-blocked. + assert!(!output.contains("[permanently restricted]")); + } + + #[test] + fn test_supervised_mixed_denials() { + let caps = make_test_caps(); + let denials = vec![ + DenialRecord { + path: PathBuf::from("/etc/shadow"), + access: AccessMode::Read, + reason: DenialReason::PolicyBlocked, + }, + DenialRecord { + path: PathBuf::from("/home/user/data.txt"), + access: AccessMode::Read, + reason: DenialReason::UserDenied, + }, + ]; + let formatter = DiagnosticFormatter::new(&caps) + .with_mode(DiagnosticMode::Supervised) + .with_denials(&denials); + let output = formatter.format_footer(1); + + assert!(output.contains("Sandbox denial: 2 paths blocked.")); + // Policy-blocked path gets the marker. + assert!(output.contains("/etc/shadow (read) [permanently restricted]")); + // Actionable path is listed without the marker. + assert!(output.contains("/home/user/data.txt (read)")); + assert!(!output.contains("/home/user/data.txt (read) [permanently restricted]")); + // Consolidated Fix line covers only the actionable path. The suggested + // target falls back to the nearest existing parent directory since the + // path itself doesn't exist in the test environment. + assert!(output.contains("Fix flags: --read ")); + assert!(!output.contains("Fix flags: --read /etc/shadow")); + // The permanent-restriction note appears once for the policy-blocked path. + assert!(output.contains("1 path is permanently restricted")); + } + + #[test] + fn test_supervised_deduplicates_paths() { + let caps = make_test_caps(); + let denials = vec![ + DenialRecord { + path: PathBuf::from("/etc/shadow"), + access: AccessMode::Read, + reason: DenialReason::PolicyBlocked, + }, + DenialRecord { + path: PathBuf::from("/etc/shadow"), + access: AccessMode::Read, + reason: DenialReason::PolicyBlocked, + }, + ]; + let formatter = DiagnosticFormatter::new(&caps) + .with_mode(DiagnosticMode::Supervised) + .with_denials(&denials); + let output = formatter.format_footer(1); + + let count = output.matches("/etc/shadow").count(); + assert_eq!(count, 1, "Path should be deduplicated"); + assert!(!output.contains("Denied paths during this session:")); + } + + #[test] + fn test_supervised_consolidated_fix_combines_all_actionable() { + let caps = make_test_caps(); + let dir = tempdir().expect("tempdir should be created"); + let a = dir.path().join("a.txt"); + let b = dir.path().join("b.txt"); + std::fs::write(&a, "a").expect("write a"); + std::fs::write(&b, "b").expect("write b"); + let denials = vec![ + DenialRecord { + path: a.clone(), + access: AccessMode::Read, + reason: DenialReason::UserDenied, + }, + DenialRecord { + path: b.clone(), + access: AccessMode::Write, + reason: DenialReason::InsufficientAccess, + }, + ]; + let formatter = DiagnosticFormatter::new(&caps) + .with_mode(DiagnosticMode::Supervised) + .with_denials(&denials); + let output = formatter.format_footer(1); + + // Single Fix line covers both paths. + let fix_lines: Vec<&str> = output + .lines() + .filter(|line| line.contains("Fix flags: ")) + .collect(); + assert_eq!( + fix_lines.len(), + 1, + "expected one consolidated Fix line: {output}" + ); + assert!(fix_lines[0].contains(&format!("--read-file {}", a.display()))); + assert!(fix_lines[0].contains(&format!("--write-file {}", b.display()))); + assert!(!output.contains("[permanently restricted]")); + } + + #[test] + fn test_supervised_consolidated_list_truncates_beyond_cap() { + // Zero-pad the index so paths sort in numeric order. + let caps = make_test_caps(); + let denials: Vec = (0..15) + .map(|i| DenialRecord { + path: PathBuf::from(format!("/tmp/denied-{i:02}")), + access: AccessMode::Read, + reason: DenialReason::UserDenied, + }) + .collect(); + let formatter = DiagnosticFormatter::new(&caps) + .with_mode(DiagnosticMode::Supervised) + .with_denials(&denials); + let output = formatter.format_footer(1); + + assert!(output.contains("Sandbox denial: 15 paths blocked.")); + // First 10 paths listed, remaining 5 collapsed. + assert!(output.contains("/tmp/denied-00 ")); + assert!(output.contains("/tmp/denied-09 ")); + assert!(!output.contains("/tmp/denied-10 ")); + assert!(output.contains("… and 5 more")); + // Fix line still covers all 15 paths. + assert_eq!( + output.lines().filter(|l| l.contains("Fix flags: ")).count(), + 1, + "expected one consolidated Fix flags line" + ); + } + + #[test] + fn test_supervised_has_block_header() { + let caps = make_test_caps(); + let denials = vec![DenialRecord { + path: PathBuf::from("/etc/shadow"), + access: AccessMode::Read, + reason: DenialReason::PolicyBlocked, + }]; + let formatter = DiagnosticFormatter::new(&caps) + .with_mode(DiagnosticMode::Supervised) + .with_denials(&denials); + let output = formatter.format_footer(1); + + assert!(!output.starts_with("nono diagnostic")); + assert!(!output.contains("[nono]")); + } + + #[test] + fn test_supervised_rate_limited_denial() { + let caps = make_test_caps(); + let denials = vec![DenialRecord { + path: PathBuf::from("/tmp/flood"), + access: AccessMode::Read, + reason: DenialReason::RateLimited, + }]; + let formatter = DiagnosticFormatter::new(&caps) + .with_mode(DiagnosticMode::Supervised) + .with_denials(&denials); + let output = formatter.format_footer(1); + + assert!(output.contains("Sandbox denial: 1 path blocked.")); + assert!(output.contains("/tmp/flood (read)")); + // Rate-limited denials are still actionable via a path flag. The + // suggested target falls back to the nearest existing parent since + // /tmp/flood itself doesn't exist. + assert!(output.contains("Fix flags: --read ")); + assert!(!output.contains("[permanently restricted]")); + } + + #[test] + fn test_supervised_insufficient_access_shows_closest_grant_and_fix() { + let dir = tempdir().expect("tempdir should be created"); + let denied_path = dir.path().join("output.txt"); + std::fs::write(&denied_path, "output").expect("output file should be created"); + let dir_path = dir + .path() + .canonicalize() + .expect("tempdir should canonicalize"); + + let mut caps = CapabilitySet::new(); + caps.add_fs(FsCapability { + original: dir.path().to_path_buf(), + resolved: dir_path.clone(), + access: AccessMode::Read, + is_file: false, + source: CapabilitySource::Group("project_read".to_string()), + }); + + let denials = vec![DenialRecord { + path: denied_path.clone(), + access: AccessMode::Write, + reason: DenialReason::InsufficientAccess, + }]; + let formatter = DiagnosticFormatter::new(&caps) + .with_mode(DiagnosticMode::Supervised) + .with_denials(&denials); + let output = formatter.format_footer(1); + + // The "Closest grant" hint moved out of the consolidated footer; + // users can recover it with `nono why` if they want the detail. + assert!(!output.contains("Closest grant:")); + assert!(output.contains(&format!( + "Fix flags: --write-file {}", + denied_path.display() + ))); + assert!(!output.contains("Denied paths during this session:")); + } + + // --- Protected paths tests --- + + #[test] + fn test_protected_paths_shown_in_footer() { + let caps = make_test_caps(); + let protected = vec![ + PathBuf::from("/project/SKILLS.md"), + PathBuf::from("/project/helper.py"), + ]; + let formatter = DiagnosticFormatter::new(&caps).with_protected_paths(&protected); + let output = formatter.format_footer(1); + + assert!(output.contains("Write-protected")); + assert!(output.contains("SKILLS.md")); + assert!(output.contains("helper.py")); + } + + #[test] + fn test_protected_paths_empty_no_section() { + let caps = make_test_caps(); + let formatter = DiagnosticFormatter::new(&caps).with_protected_paths(&[]); + let output = formatter.format_footer(1); + + assert!(!output.contains("Write-protected")); + } + + #[test] + fn test_protected_paths_shown_in_supervised_macos_fallback() { + let caps = make_test_caps(); // extensions_enabled defaults to false + let protected = vec![PathBuf::from("/project/config.json")]; + let formatter = DiagnosticFormatter::new(&caps) + .with_mode(DiagnosticMode::Supervised) + .with_protected_paths(&protected); + let output = formatter.format_footer(1); + + assert!(!output.contains("Write-protected")); + } + + // --- Exit code explanation tests --- + + fn make_command_context(program: &str, path: &str) -> CommandContext { + CommandContext { + program: program.to_string(), + resolved_path: PathBuf::from(path), + args: vec![program.to_string()], + } + } + + #[test] + fn test_exit_127_binary_not_readable() { + // Binary resolved to /opt/bin/foo but sandbox has no read access there + let caps = make_test_caps(); // only /test/project + let cmd = make_command_context("foo", "/opt/bin/foo"); + let formatter = DiagnosticFormatter::new(&caps).with_command(cmd); + let output = formatter.format_footer(127); + + assert!(output.contains("Failed to execute command (exit code 127)")); + assert!(output.contains("The executable 'foo' was resolved at:")); + assert!(output.contains("/opt/bin/foo")); + assert!(output.contains("not readable inside the sandbox")); + assert!(output.contains("nono run --read /opt/bin")); + } + + #[test] + fn test_exit_127_binary_readable_but_exec_fails() { + // Binary at /usr/bin/ps, sandbox has /usr/bin readable + let mut caps = CapabilitySet::new(); + caps.add_fs(FsCapability { + original: PathBuf::from("/usr/bin"), + resolved: PathBuf::from("/usr/bin"), + access: AccessMode::Read, + is_file: false, + source: CapabilitySource::Group("system_read".to_string()), + }); + let cmd = make_command_context("ps", "/usr/bin/ps"); + let formatter = DiagnosticFormatter::new(&caps).with_command(cmd); + let output = formatter.format_footer(127); + + assert!(output.contains("'ps' resolved to /usr/bin/ps and is readable")); + assert!(output.contains("execution still failed. Common causes:")); + assert!(output.contains("shared library")); + assert!(output.contains("Run with -v")); + } + + #[test] + fn test_exit_127_file_level_grant_dir_not_readable() { + // Binary granted as a file-level read, but parent dir not readable + let mut caps = CapabilitySet::new(); + caps.add_fs(FsCapability { + original: PathBuf::from("/opt/custom/mybin"), + resolved: PathBuf::from("/opt/custom/mybin"), + access: AccessMode::Read, + is_file: true, + source: CapabilitySource::User, + }); + let cmd = make_command_context("mybin", "/opt/custom/mybin"); + let formatter = DiagnosticFormatter::new(&caps).with_command(cmd); + let output = formatter.format_footer(127); + + // is_binary_path_readable returns true (file-level match) + // is_binary_dir_readable returns false (/opt/custom not granted) + assert!(output.contains("'mybin' resolved to /opt/custom/mybin but the directory")); + assert!(output.contains("read access to")); + } + + #[test] + fn test_exit_127_no_command_context() { + let caps = make_test_caps(); + let formatter = DiagnosticFormatter::new(&caps); + let output = formatter.format_footer(127); + + assert!(output.contains("Command not found (exit code 127)")); + assert!(output.contains("could not be found or executed")); + } + + #[test] + fn test_exit_126_permission_denied() { + let caps = make_test_caps(); + let cmd = make_command_context("script.sh", "/test/project/script.sh"); + let formatter = DiagnosticFormatter::new(&caps).with_command(cmd); + let output = formatter.format_footer(126); + + assert!(output.contains("Permission denied (exit code 126)")); + assert!(output.contains("'script.sh' was found at /test/project/script.sh")); + assert!(output.contains("execute permission")); + } + + #[test] + fn test_exit_126_no_command_context() { + let caps = make_test_caps(); + let formatter = DiagnosticFormatter::new(&caps); + let output = formatter.format_footer(126); + + assert!(output.contains("Permission denied (exit code 126)")); + assert!(output.contains("found but could not be executed")); + } + + #[test] + fn test_exit_1_generic() { + let caps = make_test_caps(); + let formatter = DiagnosticFormatter::new(&caps); + let output = formatter.format_footer(1); + + assert!(output.contains("Command exited with code 1.")); + } + + #[test] + fn test_exit_sigkill() { + let caps = make_test_caps(); + let formatter = DiagnosticFormatter::new(&caps); + let output = formatter.format_footer(128 + 9); + + assert!(output.contains("SIGKILL")); + assert!(output.contains("forcefully terminated")); + assert!(output.contains("usually not")); + } + + #[test] + fn test_exit_sigsys_platform_correct() { + let caps = make_test_caps(); + let formatter = DiagnosticFormatter::new(&caps); + let output = formatter.format_footer(128 + nix::libc::SIGSYS); + + assert!(output.contains("SIGSYS")); + assert!(output.contains("blocked system call")); + } + + #[test] + fn test_exit_sigterm() { + let caps = make_test_caps(); + let formatter = DiagnosticFormatter::new(&caps); + let output = formatter.format_footer(128 + 15); + + assert!(output.contains("SIGTERM")); + // SIGTERM gets the generic signal line, not a special explanation + assert!(!output.contains("blocked system call")); + assert!(!output.contains("forcefully terminated")); + } + + #[test] + fn test_exit_unknown_signal() { + let caps = make_test_caps(); + let formatter = DiagnosticFormatter::new(&caps); + let output = formatter.format_footer(128 + 33); + + assert!(output.contains("killed by signal 33")); + assert!(!output.contains("SIGKILL")); + assert!(!output.contains("SIGSYS")); + } + + #[test] + fn test_exit_other_code() { + let caps = make_test_caps(); + let formatter = DiagnosticFormatter::new(&caps); + let output = formatter.format_footer(42); + + assert!(output.contains("Command exited with code 42.")); + } + + #[test] + fn suppressed_denial_annotated_with_save_skipped() { + let caps = make_test_caps(); + let denied = PathBuf::from("/tmp/suppressed-file"); + let other = PathBuf::from("/tmp/other-file"); + let suppressed = nono::try_canonicalize(&denied); + + let denials = vec![ + DenialRecord { + path: denied.clone(), + access: AccessMode::Read, + reason: DenialReason::RateLimited, + }, + DenialRecord { + path: other.clone(), + access: AccessMode::Read, + reason: DenialReason::RateLimited, + }, + ]; + let formatter = DiagnosticFormatter::new(&caps) + .with_mode(DiagnosticMode::Supervised) + .with_denials(&denials) + .with_suppressed_paths(std::slice::from_ref(&suppressed)); + let output = formatter.format_footer(1); + + // The suppressed path gets the [save skipped] annotation. + assert!( + output.contains("[save skipped]"), + "expected [save skipped] in:\n{output}" + ); + // The non-suppressed path does not. + let other_line = output + .lines() + .find(|l| l.contains("other-file")) + .unwrap_or(""); + assert!( + !other_line.contains("[save skipped]"), + "non-suppressed path should not have [save skipped]: {other_line}" + ); + } + + #[test] + fn suppressed_denial_without_suppressed_paths_has_no_annotation() { + let caps = make_test_caps(); + let denied = PathBuf::from("/tmp/some-file"); + let denials = vec![DenialRecord { + path: denied, + access: AccessMode::Read, + reason: DenialReason::RateLimited, + }]; + let formatter = DiagnosticFormatter::new(&caps) + .with_mode(DiagnosticMode::Supervised) + .with_denials(&denials); + let output = formatter.format_footer(1); + + assert!( + !output.contains("[save skipped]"), + "no suppressed paths set — [save skipped] should not appear:\n{output}" + ); + } + + #[test] + fn permanently_restricted_and_suppressed_shows_both_labels() { + let caps = make_test_caps(); + let denied = PathBuf::from("/tmp/restricted-and-suppressed"); + let suppressed = nono::try_canonicalize(&denied); + + // PolicyBlocked reason + a policy explanation with reason "sensitive_path" + // causes is_denial_policy_blocked() to return true. + let explanation = PolicyExplanation { + path: denied.clone(), + access: AccessMode::Read, + reason: "sensitive_path".to_string(), + suggested_flag: None, + }; + let denials = vec![DenialRecord { + path: denied, + access: AccessMode::Read, + reason: DenialReason::PolicyBlocked, + }]; + let formatter = DiagnosticFormatter::new(&caps) + .with_mode(DiagnosticMode::Supervised) + .with_denials(&denials) + .with_policy_explanations(vec![explanation]) + .with_suppressed_paths(std::slice::from_ref(&suppressed)); + let output = formatter.format_footer(1); + + let line = output + .lines() + .find(|l| l.contains("restricted-and-suppressed")) + .unwrap_or(""); + assert!( + line.contains("permanently restricted"), + "expected 'permanently restricted' in: {line}" + ); + assert!( + line.contains("save skipped"), + "expected 'save skipped' in: {line}" + ); + } + + #[test] + fn suppressed_denial_uses_precomputed_canonical_path() { + // Verify that when `with_canonical_denial_paths` is supplied the + // pre-computed value is used for suppression matching instead of the + // raw denial path. We supply a canonical path that differs from the + // raw path (simulating symlink resolution) and assert that suppression + // is triggered against the canonical form. + let caps = make_test_caps(); + let raw_path = PathBuf::from("/tmp/link-to-suppressed"); + let canonical_path = PathBuf::from("/tmp/real-suppressed-target"); + + let denials = vec![DenialRecord { + path: raw_path.clone(), + access: AccessMode::Read, + reason: DenialReason::RateLimited, + }]; + // Suppress the canonical path, not the raw symlink path. + let formatter = DiagnosticFormatter::new(&caps) + .with_mode(DiagnosticMode::Supervised) + .with_denials(&denials) + .with_suppressed_paths(std::slice::from_ref(&canonical_path)) + .with_canonical_denial_paths(vec![canonical_path.clone()]); + let output = formatter.format_footer(1); + + assert!( + output.contains("[save skipped]"), + "suppression via precomputed canonical path should annotate [save skipped]:\n{output}" + ); + } +} diff --git a/crates/nono-cli/src/diagnostic/mod.rs b/crates/nono-cli/src/diagnostic/mod.rs new file mode 100644 index 000000000..40b8ebf7e --- /dev/null +++ b/crates/nono-cli/src/diagnostic/mod.rs @@ -0,0 +1,13 @@ +//! CLI-owned diagnostic rendering and stderr heuristics. +//! +//! The core `nono::diagnostic` module owns the structured denial records; this +//! module owns all user-facing diagnostic UX: the `nono diagnostic` footer, +//! CLI flag suggestions, policy explanations, and best-effort parsing of a +//! command's own error output. + +mod formatter; + +pub use formatter::{ + CommandContext, DiagnosticFormatter, DiagnosticMode, ErrorObservation, PolicyExplanation, + analyze_error_output, +}; diff --git a/crates/nono-cli/src/exec_strategy.rs b/crates/nono-cli/src/exec_strategy.rs index ee871e341..61977760a 100644 --- a/crates/nono-cli/src/exec_strategy.rs +++ b/crates/nono-cli/src/exec_strategy.rs @@ -14,6 +14,7 @@ mod env_sanitization; #[cfg(target_os = "linux")] mod supervisor_linux; +use crate::diagnostic::{DiagnosticFormatter, DiagnosticMode}; use crate::startup_prompt::{notify_startup_termination_for_child, print_terminal_safe_stderr}; use crate::{DETACHED_CWD_PROMPT_RESPONSE_ENV, DETACHED_LAUNCH_ENV, DETACHED_SESSION_ID_ENV}; use nix::libc; @@ -22,9 +23,8 @@ use nix::sys::wait::{WaitPidFlag, WaitStatus, waitpid}; use nix::unistd::{ForkResult, Pid, fork}; use nono::supervisor::{ApprovalDecision, AuditEntry, SupervisorMessage, SupervisorResponse}; use nono::{ - ApprovalBackend, CapabilitySet, DenialReason, DenialRecord, DiagnosticFormatter, - DiagnosticMode, NonoError, Result, Sandbox, SupervisorListener, SupervisorSocket, - UnixSocketCapability, UnixSocketMode, + ApprovalBackend, CapabilitySet, DenialReason, DenialRecord, NonoError, Result, Sandbox, + SupervisorListener, SupervisorSocket, UnixSocketCapability, UnixSocketMode, }; use std::collections::HashSet; use std::ffi::{CString, OsStr}; @@ -74,8 +74,8 @@ const MAX_TRACKED_REQUEST_IDS: usize = 4096; use crate::timeouts; struct ProfileSaveOffer<'a> { - policy_explanations: &'a [nono::diagnostic::PolicyExplanation], - error_observation: &'a nono::diagnostic::ErrorObservation, + policy_explanations: &'a [crate::diagnostic::PolicyExplanation], + error_observation: &'a crate::diagnostic::ErrorObservation, caps: &'a CapabilitySet, command: &'a [String], compared_profile: Option<&'a str>, @@ -1281,7 +1281,7 @@ pub fn execute_supervised( let error_observation = pty_proxy .as_ref() .map(|p| { - nono::diagnostic::analyze_error_output( + crate::diagnostic::analyze_error_output( &p.screen_plaintext(), config.protected_paths, Some(config.current_dir), @@ -1371,7 +1371,7 @@ pub fn execute_supervised( ) .with_canonical_denial_paths(canonical_denial_paths); if let Some(program) = config.command.first() { - formatter = formatter.with_command(nono::diagnostic::CommandContext { + formatter = formatter.with_command(crate::diagnostic::CommandContext { program: program.clone(), resolved_path: config.resolved_program.to_path_buf(), args: nono::scrub_argv_with_policy(config.command, redaction_policy), @@ -1421,9 +1421,9 @@ fn build_policy_explanations( denials: &[nono::diagnostic::DenialRecord], sandbox_violations: &[nono::SandboxViolation], caps: &nono::CapabilitySet, -) -> Vec { +) -> Vec { + use crate::diagnostic::PolicyExplanation; use nono::AccessMode; - use nono::diagnostic::PolicyExplanation; use std::collections::BTreeMap; // Merge access modes per path so a path denied for both Read and Write @@ -1475,8 +1475,6 @@ fn build_policy_explanations( match crate::query_ext::query_path(&path, access, caps, &[]) { Ok(crate::query_ext::QueryResult::Denied { reason, - details, - policy_source, suggested_flag, .. }) => { @@ -1484,8 +1482,6 @@ fn build_policy_explanations( path, access, reason, - details, - policy_source, suggested_flag, }); } @@ -1535,7 +1531,7 @@ fn should_print_diagnostic_footer( denials: &[nono::diagnostic::DenialRecord], ipc_denials: &[nono::diagnostic::IpcDenialRecord], sandbox_violations: &[nono::SandboxViolation], - error_observation: &nono::diagnostic::ErrorObservation, + error_observation: &crate::diagnostic::ErrorObservation, ) -> bool { !no_diagnostics && (exit_code != 0 @@ -1566,8 +1562,8 @@ fn filter_suppressed_system_service_violations( fn should_offer_profile_save( no_diagnostics: bool, exit_code: i32, - policy_explanations: &[nono::diagnostic::PolicyExplanation], - error_observation: &nono::diagnostic::ErrorObservation, + policy_explanations: &[crate::diagnostic::PolicyExplanation], + error_observation: &crate::diagnostic::ErrorObservation, sandbox_violations: &[nono::SandboxViolation], ) -> bool { !no_diagnostics @@ -3745,7 +3741,7 @@ mod tests { target: Some("/tmp/secret.txt".to_string()), }]; let denials = Vec::new(); - let observation = nono::diagnostic::ErrorObservation::default(); + let observation = crate::diagnostic::ErrorObservation::default(); assert!(should_print_diagnostic_footer( false, @@ -3767,15 +3763,13 @@ mod tests { #[test] fn test_profile_save_prompt_triggers_on_policy_explanation_with_zero_exit() { - let explanations = vec![nono::diagnostic::PolicyExplanation { + let explanations = vec![crate::diagnostic::PolicyExplanation { path: PathBuf::from("/tmp/secret.txt"), access: nono::AccessMode::Read, reason: "path_not_granted".to_string(), - details: None, - policy_source: None, suggested_flag: Some("--read-file /tmp/secret.txt".to_string()), }]; - let observation = nono::diagnostic::ErrorObservation::default(); + let observation = crate::diagnostic::ErrorObservation::default(); assert!(should_offer_profile_save( false, @@ -3789,7 +3783,7 @@ mod tests { #[test] fn test_profile_save_prompt_triggers_on_user_preferences_violation_with_zero_exit() { let explanations = Vec::new(); - let observation = nono::diagnostic::ErrorObservation::default(); + let observation = crate::diagnostic::ErrorObservation::default(); let violations = vec![nono::SandboxViolation { operation: "user-preference-read".to_string(), target: Some("kcfpreferencesanyapplication".to_string()), @@ -3807,7 +3801,7 @@ mod tests { #[test] fn test_suppressed_system_service_violations_do_not_offer_profile_save() { let explanations = Vec::new(); - let observation = nono::diagnostic::ErrorObservation::default(); + let observation = crate::diagnostic::ErrorObservation::default(); let violations = vec![nono::SandboxViolation { operation: "forbidden-exec-sugid".to_string(), target: None, @@ -3855,7 +3849,7 @@ mod tests { #[test] fn test_profile_save_prompt_preserves_nonzero_exit_behavior() { let explanations = Vec::new(); - let observation = nono::diagnostic::ErrorObservation::default(); + let observation = crate::diagnostic::ErrorObservation::default(); assert!(should_offer_profile_save( false, diff --git a/crates/nono-cli/src/main.rs b/crates/nono-cli/src/main.rs index b0fd09b3b..f1295cc55 100644 --- a/crates/nono-cli/src/main.rs +++ b/crates/nono-cli/src/main.rs @@ -20,6 +20,7 @@ mod credential_runtime; mod deprecated_policy; mod deprecated_schema; mod deprecation_warnings; +mod diagnostic; mod exec_strategy; mod execution_runtime; #[cfg(unix)] diff --git a/crates/nono-cli/src/profile_save_runtime.rs b/crates/nono-cli/src/profile_save_runtime.rs index e64b45559..abc49efe4 100644 --- a/crates/nono-cli/src/profile_save_runtime.rs +++ b/crates/nono-cli/src/profile_save_runtime.rs @@ -1,9 +1,9 @@ use crate::command_display::format_command_line; +use crate::diagnostic::{ErrorObservation, PolicyExplanation}; use crate::theme; use crate::{profile, query_ext}; use colored::Colorize; use nono::SandboxViolation; -use nono::diagnostic::{ErrorObservation, PolicyExplanation}; use nono::{AccessMode, CapabilitySet, NonoError, Result}; use std::collections::{BTreeMap, BTreeSet}; use std::io::{BufRead, IsTerminal, Write}; @@ -1675,8 +1675,6 @@ mod tests { path: target, access: AccessMode::Read, reason: "sensitive_path".to_string(), - details: None, - policy_source: None, suggested_flag: None, }; @@ -1710,16 +1708,12 @@ mod tests { path: target.clone(), access: AccessMode::Read, reason: "path_not_granted".to_string(), - details: None, - policy_source: None, suggested_flag: Some(format!("--read-file {}", target.display())), }; let write = PolicyExplanation { path: target, access: AccessMode::Write, reason: "insufficient_access".to_string(), - details: None, - policy_source: None, suggested_flag: None, }; @@ -1754,16 +1748,12 @@ mod tests { path: ignored.clone(), access: AccessMode::Read, reason: "path_not_granted".to_string(), - details: None, - policy_source: None, suggested_flag: None, }; let saved_explanation = PolicyExplanation { path: saved, access: AccessMode::Read, reason: "path_not_granted".to_string(), - details: None, - policy_source: None, suggested_flag: None, }; @@ -1794,8 +1784,6 @@ mod tests { path: target.clone(), access: AccessMode::Read, reason: "path_not_granted".to_string(), - details: None, - policy_source: None, suggested_flag: None, }; diff --git a/crates/nono-cli/src/query_ext.rs b/crates/nono-cli/src/query_ext.rs index dcc125855..8375e30af 100644 --- a/crates/nono-cli/src/query_ext.rs +++ b/crates/nono-cli/src/query_ext.rs @@ -560,7 +560,7 @@ pub fn print_result(result: &QueryResult) { } } -fn suggested_flag_for_path(path: &Path, requested: AccessMode) -> String { +pub(crate) fn suggested_flag_for_path(path: &Path, requested: AccessMode) -> String { let (flag, target) = suggested_flag_parts(path, requested); format!("{flag} {}", target.display()) } diff --git a/crates/nono/src/diagnostic.rs b/crates/nono/src/diagnostic.rs index 5be17c88a..0476c482e 100644 --- a/crates/nono/src/diagnostic.rs +++ b/crates/nono/src/diagnostic.rs @@ -1,22 +1,18 @@ -//! Diagnostic output formatter for sandbox policy. +//! Structured sandbox diagnostic records. //! -//! This module provides human and agent-readable diagnostic output -//! when sandboxed commands fail. The output helps identify whether -//! the failure was due to sandbox restrictions. +//! This module holds the structured, policy-free facts that the sandbox and +//! its supervisors produce when access is denied: denial records, IPC denial +//! records, OS-native sandbox violations, and the platform mapping from a +//! Seatbelt operation name to an [`AccessMode`]. //! -//! # Design Principles -//! -//! - **Unmistakable boundary**: Diagnostics render as a dedicated `nono diagnostic` -//! block so they remain easy to distinguish from command output -//! - **May vs was**: Phrased as "may be due to" not "was caused by" -//! because the non-zero exit could be unrelated to the sandbox -//! - **Actionable**: Provides specific flags to grant additional access -//! - **Mode-aware**: Different guidance for supervised vs standard mode -//! - **Library code**: No process management, no CLI assumptions +//! All user-facing diagnostic rendering (footer text, CLI flag suggestions, +//! `nono why`/`nono learn` guidance, policy explanations, and stderr +//! heuristics) lives in the `nono-cli` crate. The core library stays a pure +//! sandbox primitive and only exposes these records as a stable API for +//! clients that want to render or inspect denials themselves. -use crate::capability::{AccessMode, CapabilitySet, CapabilitySource}; -use crate::path::try_canonicalize; -use std::path::{Path, PathBuf}; +use crate::capability::AccessMode; +use std::path::PathBuf; /// Why a path access was denied during a supervised session. #[derive(Debug, Clone, PartialEq, Eq)] @@ -72,3903 +68,54 @@ pub struct SandboxViolation { pub target: Option, } -/// Policy explanation for a denied path, resolved from `nono why` logic. -/// -/// This carries the enriched query result so the diagnostic can show -/// group names, policy details, and suggested fixes inline rather than -/// asking the user to run `nono why` separately. -#[derive(Debug, Clone)] -pub struct PolicyExplanation { - /// The denied path. - pub path: PathBuf, - /// Access mode that was denied. - pub access: AccessMode, - /// Why it was denied: "sensitive_path", "insufficient_access", or "path_not_granted". - pub reason: String, - /// Human-readable explanation (e.g. "blocked by group 'ssh' (SSH keys and config)"). - pub details: Option, - /// Policy source identifier (e.g. "group:ssh"). - pub policy_source: Option, - /// Suggested CLI flag to fix (e.g. "--read ~/.ssh/id_rsa"). - pub suggested_flag: Option, -} - -/// Path-level hint extracted from a command's own error output. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ObservedPathHint { - /// The path mentioned in the error output. - pub path: PathBuf, - /// Best-effort access mode inferred from the error text. - pub access: AccessMode, -} - -/// Primary classification derived from a command's own error output. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum ErrorVerdict { - /// The command likely hit a sandbox-relevant path access issue. - LikelySandbox(ObservedPathHint), - /// The command reported a missing path, which is not itself a sandbox denial. - MissingPath(PathBuf), - /// The command reported an application-level failure unrelated to permissions. - NonSandboxFailure(String), -} - -/// Best-effort observations extracted from a command's stderr output. -#[derive(Debug, Clone, Default, PartialEq, Eq)] -pub struct ErrorObservation { - /// Primary diagnosis extracted from the command output. - pub primary_verdict: Option, - /// Name of a protected file referenced in the error output, if any. - pub blocked_protected_file: Option, - /// Paths that look like sandbox-denied accesses from stderr. - pub path_hints: Vec, - /// Paths that look missing according to stderr output. - pub missing_paths: Vec, - /// Error text that strongly suggests a non-sandbox application failure. - pub non_sandbox_failure: Option, -} - -impl ErrorObservation { - #[must_use] - pub fn has_findings(&self) -> bool { - self.primary_verdict.is_some() - || self.blocked_protected_file.is_some() - || !self.path_hints.is_empty() - || !self.missing_paths.is_empty() - || self.non_sandbox_failure.is_some() - } -} - -/// Execution mode for diagnostic context. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum DiagnosticMode { - /// Standard mode: suggest --allow flags for re-run - Standard, - /// Supervised mode: interactive expansion available, show denials - Supervised, -} - -/// Context about the command that was executed. -/// -/// Used to generate more specific diagnostic messages when a -/// sandboxed command fails. -#[derive(Debug, Clone)] -pub struct CommandContext { - /// The program name as the user typed it (e.g. "ps", "./script.sh") - pub program: String, - /// The resolved absolute path to the binary - pub resolved_path: PathBuf, - /// Original argv passed to the top-level command - pub args: Vec, -} - -/// Strip control characters and ANSI escape sequences from a string. +/// Map a Seatbelt operation name to an `AccessMode`. /// -/// Prevents terminal injection from attacker-controlled program names -/// or paths appearing in diagnostic output. -fn sanitize_for_diagnostic(s: &str) -> String { - let mut result = String::with_capacity(s.len()); - let mut chars = s.chars(); - while let Some(c) = chars.next() { - if c == '\x1b' { - // Skip ESC and the entire escape sequence - if let Some(next) = chars.next() - && next == '[' - { - for seq_char in chars.by_ref() { - if seq_char.is_ascii_alphabetic() { - break; - } - } - } - } else if c.is_control() { - // Strip all control characters - } else { - result.push(c); - } - } - result -} - -/// Parse best-effort denial hints from a command's stderr output. +/// Returns `None` for non-filesystem operations (e.g. `mach-lookup`, +/// `signal`, `process-exec`) that cannot be expressed as path grants. #[must_use] -pub fn analyze_error_output( - error_output: &str, - protected_paths: &[PathBuf], - current_dir: Option<&Path>, -) -> ErrorObservation { - let mut blocked_protected_file = None; - let mut observed = std::collections::BTreeMap::::new(); - let mut missing = std::collections::BTreeSet::::new(); - let mut pending_relative_write: Option = None; - let mut pending_structured_access_denial = false; - let mut pending_structured_access: Option = None; - let mut non_sandbox_failure = None; - - for line in error_output.lines() { - if blocked_protected_file.is_none() { - blocked_protected_file = detect_protected_file_in_error_line(protected_paths, line); - } - - if non_sandbox_failure.is_none() { - non_sandbox_failure = detect_non_sandbox_failure_line(line); - } - - if let Some(path) = - current_dir.and_then(|cwd| extract_relative_write_path_from_line(line, cwd)) - { - pending_relative_write = Some(path); - } - - if looks_like_structured_access_denial_code(line) { - pending_structured_access_denial = true; - } - - if pending_structured_access_denial { - if let Some(access) = infer_access_from_structured_syscall_line(line) { - pending_structured_access = Some(access); - } - - if let (Some(path), Some(access)) = ( - extract_structured_path_property(line), - pending_structured_access, - ) { - observed - .entry(path) - .and_modify(|existing| *existing = merge_access_modes(*existing, access)) - .or_insert(access); - pending_structured_access_denial = false; - pending_structured_access = None; - continue; - } - } - - if looks_like_missing_path(line) { - if let Some(path) = extract_denied_path_from_error_line(line) { - missing.insert(path); - } - continue; - } - - if !looks_like_access_denial(line) { - continue; - } - - let Some(path) = - extract_denied_path_from_error_line(line).or_else(|| pending_relative_write.clone()) - else { - continue; - }; - let access = if extract_denied_path_from_error_line(line).is_some() { - infer_access_from_error_line(line, &path) - } else { - AccessMode::Write - }; - - observed - .entry(path) - .and_modify(|existing| *existing = merge_access_modes(*existing, access)) - .or_insert(access); - pending_relative_write = None; - } - - let path_hints = observed - .into_iter() - .map(|(path, access)| ObservedPathHint { path, access }) - .collect::>(); - let primary_verdict = missing - .iter() - .next() - .cloned() - .map(ErrorVerdict::MissingPath) - .or_else(|| { - non_sandbox_failure - .clone() - .map(ErrorVerdict::NonSandboxFailure) - }) - .or_else(|| path_hints.first().cloned().map(ErrorVerdict::LikelySandbox)); - - ErrorObservation { - primary_verdict, - blocked_protected_file, - path_hints, - missing_paths: missing.into_iter().collect(), - non_sandbox_failure, - } -} - -fn detect_non_sandbox_failure_line(line: &str) -> Option { - let trimmed = line.trim(); - if trimmed.is_empty() { - return None; - } - - let lower = trimmed.to_ascii_lowercase(); - if lower.contains("eexist") - || lower.contains("file already exists") - || lower.contains("already exists") - { - return Some(trimmed.to_string()); - } - - // Version requirement errors are never sandbox-related - if lower.contains("version must be at least") - || lower.contains("requires version") - || lower.contains("minimum version") - || lower.contains("upgrade your") - { - return Some(trimmed.to_string()); - } - - None -} - -fn detect_protected_file_in_error_line( - protected_paths: &[PathBuf], - error_line: &str, -) -> Option { - for path in protected_paths { - if let Some(name) = path.file_name().and_then(|n| n.to_str()) - && error_line.contains(name) - { - return Some(name.to_string()); - } - } - None -} - -fn looks_like_access_denial(line: &str) -> bool { - let lower = line.to_ascii_lowercase(); - lower.contains("operation not permitted") - || lower.contains("permission denied") - || lower.contains("read-only file system") -} - -fn looks_like_structured_access_denial_code(line: &str) -> bool { - let lower = line.to_ascii_lowercase(); - (lower.contains("eperm") || lower.contains("eacces")) && looks_like_access_denial(line) -} - -fn looks_like_missing_path(line: &str) -> bool { - line.to_ascii_lowercase() - .contains("no such file or directory") -} - -fn render_diagnostic_block(body: &str) -> String { - let mut lines = Vec::new(); - - for line in body.lines() { - if line == "[nono]" { - lines.push(String::new()); - } else if let Some(stripped) = line.strip_prefix("[nono] ") { - lines.push(stripped.to_string()); - } else if let Some(stripped) = line.strip_prefix("[nono]") { - lines.push(stripped.to_string()); - } else { - lines.push(line.to_string()); - } - } - - lines.join("\n") -} - -fn format_command_failed_line(exit_code: i32) -> String { - format!("[nono] Command exited with code {}.", exit_code) -} - -fn format_command_failed_not_sandbox_line(exit_code: i32) -> String { - format!( - "[nono] The command failed, but this does not look like a sandbox denial. (exit code {})", - exit_code - ) -} - -fn format_command_succeeded_with_stderr_line() -> String { - "[nono] The command succeeded, but stderr showed a likely sandbox-related access issue." - .to_string() -} - -fn extract_denied_path_from_error_line(line: &str) -> Option { - if let Some(path) = extract_path_after_syscall_word(line) { - return Some(path); - } - - let denial_markers = [ - "Operation not permitted", - "Permission denied", - "Read-only file system", - ]; - - let prefix = denial_markers - .iter() - .find_map(|marker| line.find(marker).map(|idx| &line[..idx])) - .unwrap_or(line); - - for segment in prefix.rsplit(':') { - if let Some(path) = extract_path_from_segment(segment) { - return Some(path); - } - } - - extract_path_from_segment(prefix).or_else(|| extract_path_from_segment(line)) -} - -fn extract_path_after_syscall_word(line: &str) -> Option { - const MARKERS: &[&str] = &["mkdir", "mkdtemp", "open", "copyfile", "rename", "unlink"]; - - let lower = line.to_ascii_lowercase(); - for marker in MARKERS { - let needle = format!("{marker} "); - let Some(idx) = lower.find(&needle) else { - continue; - }; - let segment = line.get(idx + needle.len()..)?; - if let Some(path) = extract_path_from_segment(segment) { - return Some(path); - } - } - - None -} - -fn infer_access_from_structured_syscall_line(line: &str) -> Option { - let syscall = extract_structured_string_property(line, "syscall")?; - Some(match syscall.to_ascii_lowercase().as_str() { - "mkdir" | "mkdtemp" | "rmdir" | "unlink" | "rename" | "write" | "copyfile" | "chmod" - | "chown" | "utimes" => AccessMode::Write, - _ => AccessMode::ReadWrite, - }) -} - -fn extract_structured_path_property(line: &str) -> Option { - extract_structured_string_property(line, "path").map(PathBuf::from) -} - -fn extract_structured_string_property(line: &str, key: &str) -> Option { - let trimmed = line.trim(); - let after_key = trimmed - .strip_prefix(key) - .or_else(|| trimmed.strip_prefix(&format!("\"{key}\""))) - .or_else(|| trimmed.strip_prefix(&format!("'{key}'")))?; - let after_colon = after_key.trim_start().strip_prefix(':')?.trim_start(); - let quote = after_colon.chars().next()?; - if quote != '\'' && quote != '"' { - return None; - } - let after_quote = after_colon.get(quote.len_utf8()..)?; - let mut value = String::new(); - let mut escaped = false; - let mut found_end = false; - - for ch in after_quote.chars() { - if escaped { - if ch == quote || ch == '\\' { - value.push(ch); - } else { - value.push('\\'); - value.push(ch); - } - escaped = false; - continue; - } - - if ch == '\\' { - escaped = true; - continue; - } - - if ch == quote { - found_end = true; - break; +pub fn seatbelt_operation_to_access(operation: &str) -> Option { + match operation { + "file-read-data" | "file-read-metadata" | "file-read-xattr" => Some(AccessMode::Read), + "file-write-data" | "file-write-create" | "file-write-unlink" | "file-write-flags" + | "file-write-mode" | "file-write-owner" | "file-write-times" | "file-write-xattr" => { + Some(AccessMode::Write) } - - value.push(ch); - } - - if !found_end { - return None; - } - - let value = value.trim(); - if value.is_empty() || value.chars().any(char::is_control) { - return None; - } - Some(value.to_string()) -} - -fn extract_relative_write_path_from_line(line: &str, current_dir: &Path) -> Option { - let lower = line.to_ascii_lowercase(); - let markers = ["creating empty ", "creating ", "create ", "writing "]; - - let marker = markers.iter().find(|marker| lower.contains(**marker))?; - let start = lower.find(marker)? + marker.len(); - let candidate = line.get(start..)?.split_whitespace().next()?; - let candidate = candidate - .trim_matches(|c: char| { - matches!( - c, - '\'' | '"' | '`' | ',' | ':' | ';' | '(' | ')' | '[' | ']' - ) - }) - .trim_end_matches('.') - .trim(); - - if candidate.is_empty() - || candidate.starts_with('/') - || candidate.starts_with('~') - || candidate.starts_with('-') - || candidate.chars().any(char::is_control) - { - return None; - } - - Some(current_dir.join(candidate)) -} - -fn extract_path_from_segment(segment: &str) -> Option { - let trimmed = segment.trim(); - if trimmed.is_empty() { - return None; - } - - // Strip a leading quote if the path is quoted (e.g. '/bin/ls' or "/bin/ls") - let (unquoted, closing_quote) = if trimmed.starts_with('\'') || trimmed.starts_with('"') { - let quote = trimmed.as_bytes()[0] as char; - (&trimmed[1..], Some(quote)) - } else { - (trimmed, None) - }; - - let tilde_idx = unquoted.find("~/"); - let slash_idx = unquoted.find('/'); - let start = match (tilde_idx, slash_idx) { - (Some(a), Some(b)) => Some(std::cmp::min(a, b)), - (Some(a), None) => Some(a), - (None, Some(b)) => Some(b), - (None, None) => None, - }?; - - let after_start = &unquoted[start..]; - - // Terminate the path at the closing quote (if we stripped an opening one) - // or at any character that cannot appear in a filesystem path. - let end = if let Some(q) = closing_quote { - after_start.find(q).unwrap_or(after_start.len()) - } else { - after_start - .find(['\'', '"', '`', ')', '(', '<', '>']) - .unwrap_or(after_start.len()) - }; - - let candidate = after_start[..end].trim(); - if candidate.is_empty() || candidate.chars().any(char::is_control) { - return None; - } - - Some(PathBuf::from(candidate)) -} - -fn infer_access_from_error_line(line: &str, path: &Path) -> AccessMode { - let lower = line.to_ascii_lowercase(); - - if let Some(name) = path.file_name().and_then(|n| n.to_str()) - && matches!( - name, - ".profile" | ".bash_profile" | ".bashrc" | ".zprofile" | ".zshrc" | ".zlogin" - ) - { - return AccessMode::Read; - } - - if lower.contains("cannot create") - || lower.contains("can't create") - || lower.contains("write error") - || lower.contains("read-only file system") - || lower.contains("operation not permitted, mkdir ") - || lower.contains("permission denied, mkdir ") - || lower.contains("eperm") && lower.contains("mkdir ") - || lower.contains("eacces") && lower.contains("mkdir ") - || lower.starts_with("tee:") - || lower.starts_with("touch:") - || lower.starts_with("mkdir:") - || lower.starts_with("mktemp:") - || lower.starts_with("install:") - || lower.starts_with("cp:") - || lower.starts_with("mv:") - || lower.starts_with("rm:") - || lower.starts_with("ln:") - || lower.starts_with("chmod:") - || lower.starts_with("chown:") - || lower.starts_with("truncate:") - { - return AccessMode::Write; - } - - if lower.contains("cannot open") - || lower.contains("can't open") - || lower.starts_with("cat:") - || lower.starts_with("grep:") - || lower.starts_with("sed:") - || lower.starts_with("awk:") - || lower.starts_with("head:") - || lower.starts_with("tail:") - || lower.starts_with("less:") - || lower.starts_with("more:") - || lower.starts_with("find:") - || lower.starts_with("ls:") - { - return AccessMode::Read; + _ => None, } - - AccessMode::ReadWrite -} - -/// Formats diagnostic information about sandbox policy. -/// -/// This is library code that can be used by any parent process -/// that wants to explain sandbox denials to users or AI agents. -pub struct DiagnosticFormatter<'a> { - caps: &'a CapabilitySet, - mode: DiagnosticMode, - denials: &'a [DenialRecord], - ipc_denials: &'a [IpcDenialRecord], - sandbox_violations: &'a [SandboxViolation], - /// Paths that are write-protected due to trust verification - protected_paths: &'a [PathBuf], - /// Primary verdict extracted from the command output. - primary_verdict: Option, - /// Name of a protected file that was detected in the error output - blocked_protected_file: Option, - /// Best-effort path hints extracted from the command's own error output. - observed_path_hints: Vec, - /// Best-effort missing path hints extracted from the command's own error output. - missing_path_hints: Vec, - /// Error text that strongly suggests a non-sandbox application failure. - non_sandbox_failure: Option, - /// Command that was executed (for context-aware diagnostics) - command: Option, - /// Directory the child process started in. - current_dir: Option<&'a Path>, - /// Session ID for `nono grant` suggestions in supervised mode. - session_id: Option, - /// Policy explanations for denied paths, resolved from `query_path`. - policy_explanations: Vec, - /// Paths suppressed from the save-profile prompt (from `suppress_save_prompt` - /// profile field or `--suppress-save-prompt` CLI flag). Used to annotate - /// denied paths with `[save skipped]` so the diagnostic footer is - /// self-explanatory without requiring the user to cross-reference their profile. - suppressed_paths: &'a [PathBuf], - /// Non-filesystem sandbox operations suppressed from the diagnostic footer. - /// The sandbox still denies these operations; this only controls reporting. - suppressed_system_service_operations: &'a [String], - /// Canonicalized forms of the denied paths, parallel to `denials`. When - /// provided, used in place of on-demand `try_canonicalize` calls inside the - /// render loop so filesystem I/O is done once (by the caller, after the - /// child exits) rather than once per denial per render method. - canonical_denial_paths: Vec, } -impl<'a> DiagnosticFormatter<'a> { - /// Create a new formatter for the given capability set. - #[must_use] - pub fn new(caps: &'a CapabilitySet) -> Self { - Self { - caps, - mode: DiagnosticMode::Standard, - denials: &[], - ipc_denials: &[], - sandbox_violations: &[], - protected_paths: &[], - primary_verdict: None, - blocked_protected_file: None, - observed_path_hints: Vec::new(), - missing_path_hints: Vec::new(), - non_sandbox_failure: None, - command: None, - current_dir: None, - session_id: None, - policy_explanations: Vec::new(), - suppressed_paths: &[], - suppressed_system_service_operations: &[], - canonical_denial_paths: Vec::new(), - } - } - - /// Set the diagnostic mode (standard or supervised). - #[must_use] - pub fn with_mode(mut self, mode: DiagnosticMode) -> Self { - self.mode = mode; - self - } - - /// Add denial records from a supervised session. - #[must_use] - pub fn with_denials(mut self, denials: &'a [DenialRecord]) -> Self { - self.denials = denials; - self - } - - /// Set paths suppressed from the save-profile prompt. - #[must_use] - pub fn with_suppressed_paths(mut self, paths: &'a [PathBuf]) -> Self { - self.suppressed_paths = paths; - self - } - - /// Set non-filesystem sandbox operations suppressed from the diagnostic footer. - #[must_use] - pub fn with_suppressed_system_service_operations(mut self, operations: &'a [String]) -> Self { - self.suppressed_system_service_operations = operations; - self - } - - /// Set pre-canonicalized forms of the denied paths to avoid repeated - /// calling of `try_canonicalize` at render time. - #[must_use] - pub fn with_canonical_denial_paths(mut self, paths: Vec) -> Self { - self.canonical_denial_paths = paths; - self - } - - /// Add IPC denial records from a supervised session. - #[must_use] - pub fn with_ipc_denials(mut self, denials: &'a [IpcDenialRecord]) -> Self { - self.ipc_denials = denials; - self - } - - /// Add OS-native sandbox violation records. - #[must_use] - pub fn with_sandbox_violations(mut self, violations: &'a [SandboxViolation]) -> Self { - self.sandbox_violations = violations; - self - } - - /// Add paths that are write-protected due to trust verification. - /// - /// These are signed instruction files that the sandbox protects from - /// modification even when the parent directory has write access. - #[must_use] - pub fn with_protected_paths(mut self, paths: &'a [PathBuf]) -> Self { - self.protected_paths = paths; - self - } - - /// Set the name of a protected file that was detected in the error output. - /// - /// When set, the diagnostic will highlight that a write to a signed - /// instruction file was blocked. - #[must_use] - pub fn with_blocked_protected_file(mut self, name: Option) -> Self { - self.blocked_protected_file = name; - self - } - - /// Set best-effort observations extracted from the command's stderr output. - #[must_use] - pub fn with_error_observation(mut self, observation: ErrorObservation) -> Self { - self.primary_verdict = observation.primary_verdict; - self.blocked_protected_file = observation.blocked_protected_file; - self.observed_path_hints = observation.path_hints; - self.missing_path_hints = observation.missing_paths; - self.non_sandbox_failure = observation.non_sandbox_failure; - self - } - - /// Set command context for more specific diagnostics. - #[must_use] - pub fn with_command(mut self, command: CommandContext) -> Self { - self.command = Some(command); - self - } - - /// Set the child process working directory for cwd-relative diagnostics. - #[must_use] - pub fn with_current_dir(mut self, current_dir: &'a Path) -> Self { - self.current_dir = Some(current_dir); - self - } - - /// Set the session ID for `nono grant` suggestions in supervised mode. - #[must_use] - pub fn with_session_id(mut self, session_id: Option) -> Self { - self.session_id = session_id; - self - } - - /// Add policy explanations for denied paths. - /// - /// These are resolved from `query_path` in the CLI layer and provide - /// group names, policy details, and suggested fixes so the diagnostic - /// can show them inline. - #[must_use] - pub fn with_policy_explanations(mut self, explanations: Vec) -> Self { - self.policy_explanations = explanations; - self - } - - /// Check if an error line mentions any protected file and return the filename. - /// - /// This is used by the output processor to detect when a permission error - /// is specifically due to a signed instruction file being write-protected. - #[must_use] - pub fn detect_protected_file_in_error(&self, error_line: &str) -> Option { - detect_protected_file_in_error_line(self.protected_paths, error_line) - } - - /// Format the diagnostic footer for a failed command. - /// - /// Returns a multi-line string formatted as a dedicated diagnostic block. - /// The output is designed to be printed to stderr. - #[must_use] - pub fn format_footer(&self, exit_code: i32) -> String { - let body = match self.mode { - DiagnosticMode::Standard => self.format_standard_footer(exit_code), - DiagnosticMode::Supervised => self.format_supervised_footer(exit_code), - }; - render_diagnostic_block(&body) - } - - /// Check whether the resolved binary path falls under any allowed read path. - fn is_binary_path_readable(&self) -> bool { - let cmd = match &self.command { - Some(c) => c, - None => return true, // no context, assume readable - }; - let binary_path = &cmd.resolved_path; - for cap in self.caps.fs_capabilities() { - if cap.access == AccessMode::Read || cap.access == AccessMode::ReadWrite { - if cap.is_file { - if *binary_path == cap.resolved { - return true; - } - } else if binary_path.starts_with(&cap.resolved) { - return true; - } - } - } - false - } - - /// Check whether the binary's parent directory is readable in the sandbox. - fn is_binary_dir_readable(&self) -> bool { - let cmd = match &self.command { - Some(c) => c, - None => return true, - }; - let binary_dir = match cmd.resolved_path.parent() { - Some(d) => d, - None => return false, - }; - for cap in self.caps.fs_capabilities() { - if !cap.is_file - && (cap.access == AccessMode::Read || cap.access == AccessMode::ReadWrite) - && binary_dir.starts_with(&cap.resolved) - { - return true; - } - } - false - } - - /// Format context-aware explanation for the exit code. - /// - /// Returns a vec of diagnostic lines explaining what likely - /// happened and what the user can do about it. - fn format_exit_explanation(&self, exit_code: i32) -> Vec { - let mut lines = Vec::new(); - - match exit_code { - 127 => { - // 127 = command not found (shell convention) or execve failed. - // When we resolved the program path, prefer the broader wording. - let headline = if self.command.is_some() { - "[nono] Failed to execute command (exit code 127)." - } else { - "[nono] Command not found (exit code 127)." - }; - lines.push(headline.to_string()); - lines.push("[nono]".to_string()); - - if let Some(ref cmd) = self.command { - let program = sanitize_for_diagnostic(&cmd.program); - let path = sanitize_for_diagnostic(&cmd.resolved_path.display().to_string()); - if !self.is_binary_path_readable() { - // The binary exists (we resolved it) but the sandbox - // can't read it. - lines.push(format!( - "[nono] The executable '{}' was resolved at:", - program, - )); - lines.push(format!("[nono] {}", path)); - lines.push( - "[nono] but its directory is not readable inside the sandbox." - .to_string(), - ); - lines.push("[nono]".to_string()); - - if let Some(parent) = cmd.resolved_path.parent() { - let parent_path = - sanitize_for_diagnostic(&parent.display().to_string()); - lines.push( - "[nono] Fix: grant read access to the binary's directory:" - .to_string(), - ); - lines.push(format!("[nono] nono run --read {} ...", parent_path,)); - } - } else if !self.is_binary_dir_readable() { - // Binary itself is allowed but its directory isn't - // (unlikely but possible with file-level grants) - lines.push(format!( - "[nono] '{}' resolved to {} but the directory", - program, path, - )); - lines.push( - "[nono] may not be accessible. The sandbox needs read access to" - .to_string(), - ); - lines.push("[nono] the directory containing the binary.".to_string()); - } else { - // Binary path is readable — the command may depend on - // a dynamic linker, shared libraries, or shell that - // isn't accessible. - lines.push(format!( - "[nono] '{}' resolved to {} and is readable,", - program, path, - )); - lines.push("[nono] but execution still failed. Common causes:".to_string()); - lines.push( - "[nono] - A shared library or dynamic linker path is not accessible" - .to_string(), - ); - lines.push( - "[nono] - The binary is a script whose interpreter is not accessible" - .to_string(), - ); - lines.push( - "[nono] - The binary depends on a path not in the sandbox" - .to_string(), - ); - lines.push("[nono]".to_string()); - lines.push( - "[nono] Run with -v to see all allowed paths and check if".to_string(), - ); - lines.push("[nono] required system directories are included.".to_string()); - } - } else { - lines.push( - "[nono] The command binary could not be found or executed inside" - .to_string(), - ); - lines.push( - "[nono] the sandbox. Ensure the binary's directory is readable." - .to_string(), - ); - } - } - 126 => { - // 126 = command found but not executable - lines.push("[nono] Permission denied (exit code 126).".to_string()); - lines.push("[nono]".to_string()); - - if let Some(ref cmd) = self.command { - let program = sanitize_for_diagnostic(&cmd.program); - let path = sanitize_for_diagnostic(&cmd.resolved_path.display().to_string()); - lines.push(format!( - "[nono] '{}' was found at {} but could not be executed.", - program, path, - )); - lines.push( - "[nono] The file may not have execute permission, or the sandbox" - .to_string(), - ); - lines.push( - "[nono] may be blocking execution of binaries in that directory." - .to_string(), - ); - } else { - lines.push( - "[nono] The command was found but could not be executed.".to_string(), - ); - lines.push( - "[nono] Check file permissions and sandbox access to the binary's directory." - .to_string(), - ); - } - } - code if (129..=192).contains(&code) => { - // Signal-based exit: 128 + signal number - let sig = code - 128; - // SIGSYS is platform-dependent: 31 on Linux, 12 on macOS - let sigsys: i32 = libc::SIGSYS; - let sig_name = match sig { - 1 => "SIGHUP", - 2 => "SIGINT", - 4 => "SIGILL", - 6 => "SIGABRT", - 9 => "SIGKILL", - 11 => "SIGSEGV", - 13 => "SIGPIPE", - 15 => "SIGTERM", - s if s == sigsys => "SIGSYS", - _ => "", - }; - - if sig == sigsys { - // SIGSYS = seccomp/sandbox killed it - lines.push(format!( - "[nono] Command killed by {} (exit code {}).", - sig_name, code, - )); - lines.push("[nono]".to_string()); - lines.push( - "[nono] SIGSYS typically means a blocked system call. The command tried" - .to_string(), - ); - lines.push("[nono] an operation that the sandbox does not permit.".to_string()); - } else if sig == 9 { - lines.push(format!( - "[nono] Command killed by {} (exit code {}).", - sig_name, code, - )); - lines.push("[nono]".to_string()); - lines.push( - "[nono] The process was forcefully terminated. This is usually not" - .to_string(), - ); - lines.push("[nono] caused by sandbox restrictions.".to_string()); - } else if !sig_name.is_empty() { - lines.push(format!( - "[nono] Command killed by signal {} / {} (exit code {}).", - sig, sig_name, code, - )); - } else { - lines.push(format!( - "[nono] Command killed by signal {} (exit code {}).", - sig, code, - )); - } - } - code => { - lines.push(format_command_failed_line(code)); - } - } - - lines - } - - /// Standard mode footer: concise policy summary with --allow suggestions. - fn format_standard_footer(&self, exit_code: i32) -> String { - let mut lines = Vec::new(); - let observed_hints = self.actionable_observed_path_hints(); - let primary_verdict = self.primary_observation_verdict(); - let has_observation = self.has_error_observation(); - - // Check if this was a protected file write attempt - if let Some(ref blocked_file) = self.blocked_protected_file { - lines.push(format!( - "[nono] Write to '{}' blocked: file is a signed instruction file.", - blocked_file - )); - lines.push( - "[nono] Signed instruction files are write-protected to prevent tampering." - .to_string(), - ); - lines.push("[nono]".to_string()); - lines.push(format!( - "[nono] The command failed. (exit code {})", - exit_code - )); - } else if matches!( - primary_verdict.as_ref(), - Some(ErrorVerdict::MissingPath(_)) | Some(ErrorVerdict::NonSandboxFailure(_)) - ) { - lines.push(format_command_failed_not_sandbox_line(exit_code)); - } else if exit_code == 0 && has_observation { - lines.push(format_command_succeeded_with_stderr_line()); - } else { - lines.extend(self.format_exit_explanation(exit_code)); - } - lines.push("[nono]".to_string()); - - if self.blocked_protected_file.is_none() - && let Some(verdict) = primary_verdict.as_ref() - { - self.format_primary_verdict_guidance(&mut lines, verdict); - lines.push("[nono]".to_string()); - } - - // Concise policy summary: show user paths, summarize system/group paths - lines.push("[nono] Sandbox policy:".to_string()); - - self.format_allowed_paths_concise(&mut lines); - self.format_network_status(&mut lines); - self.format_protected_paths(&mut lines); - let additional_hints = if observed_hints.len() > 1 { - &observed_hints[1..] - } else { - &[] - }; - self.format_observed_path_hints(&mut lines, additional_hints); - - // Help section (skip if the failure was specifically due to protected file) - if self.blocked_protected_file.is_none() - && observed_hints.is_empty() - && primary_verdict.is_none() - { - lines.push("[nono]".to_string()); - self.format_grant_help(&mut lines); - lines.push("[nono]".to_string()); - self.format_follow_up_guidance(&mut lines, None); - } - - lines.join("\n") - } - - /// Supervised mode footer: show denials and mode-specific guidance. - fn format_supervised_footer(&self, exit_code: i32) -> String { - let mut lines = Vec::new(); - let primary_verdict = self.primary_observation_verdict(); - let has_observation = self.has_error_observation(); - - let has_ipc_denials = !self.ipc_denials.is_empty(); - - if self.denials.is_empty() - && !has_ipc_denials - && matches!( - primary_verdict.as_ref(), - Some(ErrorVerdict::MissingPath(_)) | Some(ErrorVerdict::NonSandboxFailure(_)) - ) - { - lines.push(format_command_failed_not_sandbox_line(exit_code)); - } else if exit_code == 0 && has_observation && self.denials.is_empty() && !has_ipc_denials { - lines.push(format_command_succeeded_with_stderr_line()); - } else { - lines.extend(self.format_exit_explanation(exit_code)); - } - lines.push("[nono]".to_string()); - - // Convert macOS Seatbelt violations (operation + target) into - // DenialRecords so the same rendering logic handles both platforms. - let (violation_denials, mut non_fs_violations) = - violations_to_denials(self.sandbox_violations); - non_fs_violations.retain(|violation| { - !self - .suppressed_system_service_operations - .contains(&violation.operation) - }); - - // Merge supervisor denials (Linux seccomp) with violation-derived - // denials (macOS Seatbelt) into a single unified list. - let mut all_denials: Vec = self - .denials - .iter() - .cloned() - .chain(violation_denials) - .collect(); - all_denials.extend(self.observed_denials_matching_logged_paths(&all_denials)); - - if !self.ipc_denials.is_empty() { - self.format_ipc_denial_guidance(&mut lines); - if !all_denials.is_empty() || !non_fs_violations.is_empty() { - lines.push("[nono]".to_string()); - } - } - - if all_denials.is_empty() && self.ipc_denials.is_empty() { - // No denials from either source. - if !non_fs_violations.is_empty() { - // Non-filesystem violations (mach-lookup, signal, etc.) — - // show them with human-readable descriptions. - lines.push("[nono] Sandbox blocked system services:".to_string()); - format_non_fs_violations(&mut lines, &non_fs_violations); - lines.push("[nono]".to_string()); - format_non_fs_guidance(&mut lines, &non_fs_violations); - } else { - // Genuinely no denials observed. - if let Some(verdict) = primary_verdict.as_ref() { - self.format_primary_verdict_guidance(&mut lines, verdict); - lines.push("[nono]".to_string()); - } - lines.push("[nono] No path denials were observed during this session.".to_string()); - lines.push( - "[nono] The failure may be unrelated to sandbox restrictions.".to_string(), - ); - } - lines.push("[nono]".to_string()); - self.format_grant_help(&mut lines); - lines.push("[nono]".to_string()); - self.format_follow_up_guidance(&mut lines, None); - } else if !all_denials.is_empty() { - // Deduplicate by path, merging access modes. Classification into - // actionable vs. policy-blocked is done by the consolidated - // formatter using policy_explanations when available. - let deduped = dedupe_denials(&all_denials); - self.format_consolidated_denial_guidance(&mut lines, &deduped); - - // Show non-filesystem violations (mach-lookup, etc.) if any - if !non_fs_violations.is_empty() { - lines.push("[nono]".to_string()); - lines.push("[nono] Also blocked (system services):".to_string()); - format_non_fs_violations(&mut lines, &non_fs_violations); - lines.push("[nono]".to_string()); - format_non_fs_guidance(&mut lines, &non_fs_violations); - } - - // Note: `nono grant` suggestions are shown via desktop - // notifications during the session, not in the post-exit footer. - } - - lines.join("\n") - } - - fn actionable_observed_path_hints(&self) -> Vec { - self.observed_path_hints - .iter() - .filter_map(|hint| { - self.actionable_observed_access(&hint.path, hint.access) - .map(|access| ObservedPathHint { - path: hint.path.clone(), - access, - }) - }) - .collect() - } - - fn observed_denials_matching_logged_paths( - &self, - denials: &[DenialRecord], - ) -> Vec { - if denials.is_empty() { - return Vec::new(); - } - - let logged_paths = denials - .iter() - .map(|denial| denial.path.clone()) - .collect::>(); - - self.actionable_observed_path_hints() - .into_iter() - .filter(|hint| logged_paths.contains(&hint.path)) - .map(|hint| DenialRecord { - path: hint.path, - access: hint.access, - reason: DenialReason::InsufficientAccess, - }) - .collect() - } - - fn primary_observation_verdict(&self) -> Option { - self.missing_path_hints - .first() - .cloned() - .map(ErrorVerdict::MissingPath) - .or_else(|| { - self.non_sandbox_failure - .clone() - .map(ErrorVerdict::NonSandboxFailure) - }) - .or_else(|| { - self.actionable_observed_path_hints() - .first() - .cloned() - .map(ErrorVerdict::LikelySandbox) - }) - } +#[cfg(test)] +mod tests { + use super::*; - fn has_error_observation(&self) -> bool { - self.primary_verdict.is_some() - || self.blocked_protected_file.is_some() - || !self.observed_path_hints.is_empty() - || !self.missing_path_hints.is_empty() - || self.non_sandbox_failure.is_some() + #[test] + fn seatbelt_read_operations_map_to_read() { + assert_eq!( + seatbelt_operation_to_access("file-read-data"), + Some(AccessMode::Read) + ); + assert_eq!( + seatbelt_operation_to_access("file-read-metadata"), + Some(AccessMode::Read) + ); } - fn actionable_observed_access(&self, path: &Path, inferred: AccessMode) -> Option { - let Some(cap) = self.closest_covering_capability_any(path) else { - return Some(inferred); - }; - - if cap.access.contains(inferred) { - return None; - } - - match (cap.access, inferred) { - (AccessMode::Read, AccessMode::ReadWrite) => Some(AccessMode::Write), - (AccessMode::Write, AccessMode::ReadWrite) => Some(AccessMode::Read), - _ => Some(inferred), - } - } - - fn closest_covering_capability_any( - &self, - path: &Path, - ) -> Option<&crate::capability::FsCapability> { - let canonical = try_canonicalize(path); - let mut best_covering: Option<&crate::capability::FsCapability> = None; - let mut best_covering_score = 0usize; - - for cap in self.caps.fs_capabilities() { - let covers = if cap.is_file { - cap.resolved == canonical - } else { - canonical.starts_with(&cap.resolved) - }; - - if !covers { - continue; - } - - let score = cap.resolved.as_os_str().len(); - if score >= best_covering_score { - best_covering = Some(cap); - best_covering_score = score; - } - } - - best_covering - } - - fn format_follow_up_guidance( - &self, - lines: &mut Vec, - _hint: Option<(&Path, AccessMode)>, - ) { - lines.push("[nono] Next steps:".to_string()); - if let Some(command) = self.format_command_for_learn() { - lines.push(format!( - "[nono] Discover paths: nono learn -- {}", - command - )); - } else { - lines.push("[nono] Discover paths: nono learn -- ".to_string()); - } - lines.push( - "[nono] Query policy: nono why --path --op ".to_string(), - ); - } - - fn format_primary_observed_guidance(&self, lines: &mut Vec, hint: &ObservedPathHint) { - lines.push("[nono] Sandbox denial:".to_string()); - if self.observed_hint_points_to_read_only_cwd(hint) { - lines.push( - "[nono] The command appears to be writing inside the current working directory," - .to_string(), - ); - lines.push( - "[nono] but the current working directory is read-only in this sandbox." - .to_string(), - ); - } - lines.push(format!( - "[nono] {} ({})", - hint.path.display(), - access_str(hint.access), - )); - lines.push(format!( - "[nono] Try: {}", - self.suggested_flag_for_hint(&hint.path, hint.access) - )); - } - - fn format_primary_verdict_guidance(&self, lines: &mut Vec, verdict: &ErrorVerdict) { - match verdict { - ErrorVerdict::LikelySandbox(hint) => { - self.format_primary_observed_guidance(lines, hint); - } - ErrorVerdict::MissingPath(path) => { - self.format_primary_missing_path_guidance(lines, path); - } - ErrorVerdict::NonSandboxFailure(failure) => { - self.format_non_sandbox_failure_guidance(lines, failure); - } - } - } - - fn format_primary_missing_path_guidance(&self, lines: &mut Vec, path: &Path) { - lines.push("[nono] Missing path:".to_string()); - lines.push(format!("[nono] {}", path.display())); - lines.push("[nono] The command reported \"No such file or directory\".".to_string()); - lines.push( - "[nono] Path flags only apply to paths that already exist when nono starts." - .to_string(), - ); - lines.push( - "[nono] Create the path first, or grant an existing parent directory if the command needs to create it." - .to_string(), - ); - } - - fn format_non_sandbox_failure_guidance(&self, lines: &mut Vec, failure: &str) { - lines.push("[nono] Application error:".to_string()); - lines.push(format!("[nono] {}", sanitize_for_diagnostic(failure))); - lines.push( - "[nono] The command's own output suggests this failure is unrelated to sandbox permissions." - .to_string(), - ); - } - - /// Render the consolidated denial block. - /// - /// Shows every denied path (truncated past `MAX_INLINE_LIST` entries) with - /// a `[permanently restricted]` marker for paths that are blocked by the - /// sensitive-path policy, and emits a single `Fix flags:` line combining the - /// `--read`/`--write`/`--allow` flags for all actionable denials. - /// - /// Classification: if a policy explanation with `reason == "sensitive_path"` - /// exists for a path, it is treated as policy-blocked and cannot be fixed - /// via flags. Everything else is actionable, including macOS Seatbelt - /// denials whose `DenialReason` defaults to `PolicyBlocked` (that reason - /// is over-broad on macOS — we trust the query_path result instead). - fn format_consolidated_denial_guidance( - &self, - lines: &mut Vec, - denials: &[DenialRecord], - ) { - const MAX_INLINE_LIST: usize = 10; - - let (unix_socket_denials, path_denials): (Vec<&DenialRecord>, Vec<&DenialRecord>) = denials - .iter() - .partition(|denial| denial.reason == DenialReason::UnixSocketDenied); - - if !unix_socket_denials.is_empty() { - let total = unix_socket_denials.len(); - let plural_s = if total == 1 { "" } else { "s" }; - lines.push(format!( - "[nono] IPC denial: {} pathname Unix socket{} blocked.", - total, plural_s - )); - for (idx, denial) in unix_socket_denials.iter().enumerate() { - if idx >= MAX_INLINE_LIST { - lines.push(format!("[nono] ... and {} more", total - idx)); - break; - } - lines.push(format!("[nono] {}", denial.path.display())); - } - let flags: Vec = unix_socket_denials - .iter() - .map(|d| self.suggested_flag_for_denial(d)) - .collect(); - lines.push(format!("[nono] Fix flags: {}", flags.join(" "))); - } - - if path_denials.is_empty() { - return; - } - - let total = path_denials.len(); - let mut actionable: Vec<&DenialRecord> = Vec::new(); - let mut policy_blocked: Vec<&DenialRecord> = Vec::new(); - - for denial in &path_denials { - if self.is_denial_policy_blocked(denial) { - policy_blocked.push(denial); - } else { - actionable.push(denial); - } - } - - let plural_s = if total == 1 { "" } else { "s" }; - lines.push(format!( - "[nono] Sandbox denial: {} path{} blocked.", - total, plural_s - )); - - for (idx, denial) in path_denials.iter().enumerate() { - if idx >= MAX_INLINE_LIST { - lines.push(format!("[nono] … and {} more", total - idx)); - break; - } - let mut labels: Vec<&str> = Vec::new(); - if self.is_denial_policy_blocked(denial) { - labels.push("permanently restricted"); - } - if self.is_denial_suppressed(denial) { - labels.push("save skipped"); - } - let suffix = if labels.is_empty() { - String::new() - } else { - format!(" [{}]", labels.join(", ")) - }; - lines.push(format!( - "[nono] {} ({}){}", - denial.path.display(), - access_str(denial.access), - suffix, - )); - } - - if !actionable.is_empty() { - let flags: Vec = actionable - .iter() - .map(|d| self.suggested_flag_for_denial(d)) - .collect(); - lines.push(format!("[nono] Fix flags: {}", flags.join(" "))); - } - - if !policy_blocked.is_empty() { - let n = policy_blocked.len(); - let (subject, verb) = if n == 1 { - ("1 path is", "") - } else { - ("paths are", "") - }; - let count_prefix = if n == 1 { - String::from(subject) - } else { - format!("{} {}", n, subject) - }; - lines.push("[nono]".to_string()); - lines.push(format!( - "[nono] {}{} permanently restricted — override via a user profile with filesystem.bypass_protection.", - count_prefix, verb, - )); - } - } - - fn format_ipc_denial_guidance(&self, lines: &mut Vec) { - const MAX_INLINE_LIST: usize = 10; - - let total = self.ipc_denials.len(); - let plural_s = if total == 1 { "" } else { "s" }; - lines.push(format!( - "[nono] IPC denial: {} Unix socket operation{} blocked.", - total, plural_s - )); - for (idx, denial) in self.ipc_denials.iter().enumerate() { - if idx >= MAX_INLINE_LIST { - lines.push(format!("[nono] ... and {} more", total - idx)); - break; - } - lines.push(format!( - "[nono] {} {} ({})", - denial.operation, denial.target, denial.reason - )); - } - - let flags: Vec<&str> = self - .ipc_denials - .iter() - .filter_map(|denial| denial.suggested_flag.as_deref()) - .collect(); - if !flags.is_empty() { - lines.push(format!("[nono] Fix flags: {}", flags.join(" "))); - } - } - - /// Return the canonical form of `denial.path`, using the pre-computed - /// parallel vec when available to avoid repeated filesystem I/O. - fn canonical_for_denial<'b>(&'b self, denial: &DenialRecord) -> std::borrow::Cow<'b, Path> { - if let Some(canonical) = self - .denials - .iter() - .position(|d| d.path == denial.path) - .and_then(|i| self.canonical_denial_paths.get(i)) - { - return std::borrow::Cow::Borrowed(canonical.as_path()); - } - std::borrow::Cow::Owned(crate::try_canonicalize(&denial.path)) - } - - /// Return true when the denied path is in the caller-supplied suppression - /// list (i.e. `suppress_save_prompt` entries). Such paths are still denied - /// by the sandbox — this only controls whether they appear in the save - /// prompt and how they are labelled in the diagnostic footer. - fn is_denial_suppressed(&self, denial: &DenialRecord) -> bool { - if self.suppressed_paths.is_empty() { - return false; - } - let canonical = self.canonical_for_denial(denial); - self.suppressed_paths - .iter() - .any(|suppressed| canonical.starts_with(suppressed)) - } - - /// Return true when the denial cannot be fixed by a path flag alone — - /// i.e. the path is blocked by the sensitive-path policy and requires a - /// profile with `filesystem.bypass_protection`. - fn is_denial_policy_blocked(&self, denial: &DenialRecord) -> bool { - if let Some(expl) = self - .policy_explanations - .iter() - .find(|e| e.path == denial.path) - { - return expl.reason == "sensitive_path"; - } - // No explanation (e.g. Linux seccomp path without a matching lookup): - // trust the DenialRecord reason. - denial.reason == DenialReason::PolicyBlocked - } - - /// Build the CLI flag suggestion for a single denial. Prefers the - /// explanation's `suggested_flag` (which knows about parent-directory - /// canonicalization) and falls back to a local computation otherwise. - fn suggested_flag_for_denial(&self, denial: &DenialRecord) -> String { - if denial.reason == DenialReason::UnixSocketDenied { - let flag = if denial.access.contains(AccessMode::Write) { - "--allow-unix-socket-bind" - } else { - "--allow-unix-socket" - }; - return format!("{} {}", flag, denial.path.display()); - } - - if let Some(flag) = self - .policy_explanations - .iter() - .find(|e| e.path == denial.path && e.access == denial.access) - .and_then(|e| e.suggested_flag.clone()) - { - // explanations' suggested_flag is of the form "--read /path". - // Strip any leading fix label that callers may have prepended. - return flag - .strip_prefix("Fix: ") - .or_else(|| flag.strip_prefix("Fix flags: ")) - .map(str::to_string) - .unwrap_or(flag); - } - suggested_flag_for_path(&denial.path, denial.access) - } - - fn format_grant_help(&self, lines: &mut Vec) { - lines.push("[nono] To grant additional access, re-run with:".to_string()); - lines.push("[nono] --allow read+write access to directory".to_string()); - lines.push("[nono] --read read-only access to directory".to_string()); - lines.push("[nono] --write write-only access to directory".to_string()); - - if self.caps.is_network_blocked() { - lines.push( - "[nono] --allow-net unrestricted network for this session".to_string(), - ); - } - } - - fn format_command_for_learn(&self) -> Option { - let command = self.command.as_ref()?; - if command.args.is_empty() { - return None; - } - - Some( - command - .args - .iter() - .map(|arg| shell_quote(arg)) - .collect::>() - .join(" "), - ) - } - - fn suggested_flag_for_hint(&self, path: &Path, requested: AccessMode) -> String { - if let Some(flag) = self.suggested_upgrade_flag_for_existing_capability(path, requested) { - flag - } else if self.observed_hint_points_to_ungranted_cwd(path) { - "--allow-cwd".to_string() - } else { - suggested_flag_for_path(path, requested) - } - } - - fn observed_hint_points_to_read_only_cwd(&self, hint: &ObservedPathHint) -> bool { - let Some(current_dir) = self.current_dir else { - return false; - }; - - hint.path.starts_with(current_dir) - && self - .suggested_upgrade_flag_for_existing_capability(&hint.path, hint.access) - .is_some() - } - - fn suggested_upgrade_flag_for_existing_capability( - &self, - path: &Path, - requested: AccessMode, - ) -> Option { - let cap = self.closest_covering_capability_any(path)?; - if cap.access.contains(requested) { - return None; - } - - let target = cap.resolved.clone(); - - let requested = match (cap.access, requested) { - (AccessMode::Read, AccessMode::ReadWrite) => AccessMode::Write, - (AccessMode::Write, AccessMode::ReadWrite) => AccessMode::Read, - _ => requested, - }; - - Some(suggested_flag_for_existing_target( - &target, - cap.is_file, - requested, - )) - } - - fn observed_hint_points_to_ungranted_cwd(&self, path: &Path) -> bool { - let Some(current_dir) = self.current_dir else { - return false; - }; - - if !path.starts_with(current_dir) { - return false; - } - - self.closest_covering_capability_any(current_dir).is_none() - } - - /// Format allowed paths concisely: show user/profile paths explicitly, - /// summarize group/system paths with a count. - fn format_allowed_paths_concise(&self, lines: &mut Vec) { - let caps = self.caps.fs_capabilities(); - if caps.is_empty() { - lines.push("[nono] Allowed paths: (none)".to_string()); - return; - } - - let mut user_paths = Vec::new(); - let mut group_count: usize = 0; - - for cap in caps { - match &cap.source { - CapabilitySource::User | CapabilitySource::Profile => { - let kind = if cap.is_file { "file" } else { "dir" }; - user_paths.push(format!( - "[nono] {} ({}, {})", - cap.resolved.display(), - access_str(cap.access), - kind, - )); - } - CapabilitySource::Group(_) | CapabilitySource::System => { - group_count += 1; - } - } - } - - if user_paths.is_empty() && group_count == 0 { - lines.push("[nono] Allowed paths: (none)".to_string()); - } else { - lines.push("[nono] Allowed paths:".to_string()); - for p in &user_paths { - lines.push(p.clone()); - } - if group_count > 0 { - lines.push(format!("[nono] + {} system/group path(s)", group_count)); - } - } - } - - fn format_observed_path_hints(&self, lines: &mut Vec, hints: &[ObservedPathHint]) { - if hints.is_empty() { - return; - } - - lines.push("[nono] Likely blocked paths seen in the command output:".to_string()); - for hint in hints { - lines.push(format!( - "[nono] {} ({})", - hint.path.display(), - access_str(hint.access), - )); - } - } - - /// Format the network status. - fn format_network_status(&self, lines: &mut Vec) { - use crate::NetworkMode; - match self.caps.network_mode() { - NetworkMode::Blocked => { - lines.push("[nono] Network: blocked".to_string()); - } - NetworkMode::ProxyOnly { port, bind_ports } => { - if bind_ports.is_empty() { - lines.push(format!("[nono] Network: proxy (localhost:{})", port)); - } else { - let ports_str: Vec = bind_ports.iter().map(|p| p.to_string()).collect(); - lines.push(format!( - "[nono] Network: proxy (localhost:{}), bind: {}", - port, - ports_str.join(", ") - )); - } - } - NetworkMode::AllowAll => { - lines.push("[nono] Network: allowed".to_string()); - } - } - } - - /// Format write-protected paths (signed instruction files). - fn format_protected_paths(&self, lines: &mut Vec) { - if self.protected_paths.is_empty() { - return; - } - - lines.push("[nono] Write-protected (signed instruction files):".to_string()); - for path in self.protected_paths { - // Show just the filename for brevity - let name = path - .file_name() - .map(|n| n.to_string_lossy().to_string()) - .unwrap_or_else(|| path.display().to_string()); - lines.push(format!("[nono] {}", name)); - } - } - - /// Format a concise single-line summary of the policy. - /// - /// Useful for logging or brief status messages. - #[must_use] - pub fn format_summary(&self) -> String { - let path_count = self.caps.fs_capabilities().len(); - let network_status = if self.caps.is_network_blocked() { - "blocked" - } else { - "allowed" - }; - - format!( - "[nono] Policy: {} path(s), network {}", - path_count, network_status - ) - } -} - -/// Catalog of observed non-filesystem macOS sandbox denials. -/// -/// Apple's public documentation covers APIs such as CFPreferences, but not a -/// complete stable taxonomy of Seatbelt operation names. Keep this table -/// evidence-based: add entries only when they are observed in sandbox logs or -/// backed by a known framework/daemon mapping. -#[derive(Debug, Clone, Copy)] -struct SystemServiceDiagnostic { - operation: &'static str, - target: SystemServiceTarget, - description: &'static str, - guidance: Option, -} - -#[derive(Debug, Clone, Copy)] -enum SystemServiceTarget { - Any, - Exact(&'static str), - Prefix(&'static str), -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum SystemServiceGuidance { - Keychain, - SetuidExec, - UserPreferences, -} - -const SYSTEM_SERVICE_DIAGNOSTICS: &[SystemServiceDiagnostic] = &[ - SystemServiceDiagnostic::exact( - "mach-lookup", - "com.apple.SecurityServer", - "Keychain / Security framework", - Some(SystemServiceGuidance::Keychain), - ), - SystemServiceDiagnostic::exact( - "mach-lookup", - "com.apple.securityd", - "Keychain / Security framework", - Some(SystemServiceGuidance::Keychain), - ), - SystemServiceDiagnostic::exact( - "mach-lookup", - "com.apple.security.keychaind", - "Keychain / Security framework", - Some(SystemServiceGuidance::Keychain), - ), - SystemServiceDiagnostic::exact( - "mach-lookup", - "com.apple.secd", - "Keychain / Security framework", - Some(SystemServiceGuidance::Keychain), - ), - SystemServiceDiagnostic::exact( - "mach-lookup", - "com.apple.security.agent", - "Keychain authorization agent", - Some(SystemServiceGuidance::Keychain), - ), - SystemServiceDiagnostic::exact("mach-lookup", "com.apple.logd", "System logging", None), - SystemServiceDiagnostic::exact( - "mach-lookup", - "com.apple.system.notification_center", - "Distributed notifications", - None, - ), - SystemServiceDiagnostic::exact( - "mach-lookup", - "com.apple.distributed_notifications", - "Distributed notifications", - None, - ), - SystemServiceDiagnostic::exact( - "mach-lookup", - "com.apple.CoreServices.coreservicesd", - "Launch Services", - None, - ), - SystemServiceDiagnostic::exact( - "mach-lookup", - "com.apple.lsd.mapdb", - "Launch Services", - None, - ), - SystemServiceDiagnostic::prefix( - "mach-lookup", - "com.apple.windowserver", - "Window Server / GUI", - None, - ), - SystemServiceDiagnostic::prefix( - "mach-lookup", - "com.apple.cfprefsd", - "Preferences (CFPreferences / NSUserDefaults)", - None, - ), - SystemServiceDiagnostic::prefix( - "mach-lookup", - "com.apple.pasteboard", - "Pasteboard / clipboard", - None, - ), - SystemServiceDiagnostic::prefix( - "mach-lookup", - "com.apple.coreservices", - "Core Services", - None, - ), - SystemServiceDiagnostic::exact( - "user-preference-read", - "kcfpreferencesanyapplication", - "Global preferences (CFPreferences any-application domain)", - Some(SystemServiceGuidance::UserPreferences), - ), - SystemServiceDiagnostic::prefix( - "user-preference-read", - "kcfpreferences", - "Preferences (CFPreferences / NSUserDefaults)", - Some(SystemServiceGuidance::UserPreferences), - ), - SystemServiceDiagnostic::any( - "forbidden-exec-sugid", - "Setuid/setgid executable blocked", - Some(SystemServiceGuidance::SetuidExec), - ), -]; - -impl SystemServiceDiagnostic { - const fn any( - operation: &'static str, - description: &'static str, - guidance: Option, - ) -> Self { - Self { - operation, - target: SystemServiceTarget::Any, - description, - guidance, - } - } - - const fn exact( - operation: &'static str, - target: &'static str, - description: &'static str, - guidance: Option, - ) -> Self { - Self { - operation, - target: SystemServiceTarget::Exact(target), - description, - guidance, - } - } - - const fn prefix( - operation: &'static str, - target_prefix: &'static str, - description: &'static str, - guidance: Option, - ) -> Self { - Self { - operation, - target: SystemServiceTarget::Prefix(target_prefix), - description, - guidance, - } - } - - fn matches(&self, violation: &SandboxViolation) -> bool { - if violation.operation != self.operation { - return false; - } - match self.target { - SystemServiceTarget::Any => true, - _ => violation - .target - .as_deref() - .is_some_and(|target| self.target.matches(target)), - } - } -} - -impl SystemServiceTarget { - fn matches(self, target: &str) -> bool { - match self { - Self::Any => true, - Self::Exact(expected) => target.eq_ignore_ascii_case(expected), - Self::Prefix(prefix) => target - .get(..prefix.len()) - .is_some_and(|candidate| candidate.eq_ignore_ascii_case(prefix)), - } - } -} - -fn system_service_diagnostic_for( - violation: &SandboxViolation, -) -> Option<&'static SystemServiceDiagnostic> { - SYSTEM_SERVICE_DIAGNOSTICS - .iter() - .find(|diagnostic| diagnostic.matches(violation)) -} - -/// Format non-filesystem violations with human-readable service descriptions. -fn format_non_fs_violations(lines: &mut Vec, violations: &[&SandboxViolation]) { - for v in violations { - let desc = system_service_diagnostic_for(v).map(|diagnostic| diagnostic.description); - match (&v.target, desc) { - (Some(target), Some(description)) => { - lines.push(format!( - "[nono] {} ({}) — {}", - v.operation, target, description - )); - } - (Some(target), None) => { - lines.push(format!("[nono] {} ({})", v.operation, target)); - } - (None, Some(description)) => { - lines.push(format!("[nono] {} — {}", v.operation, description)); - } - (None, None) => { - lines.push(format!("[nono] {}", v.operation)); - } - } - } -} - -/// Generate actionable guidance for non-filesystem violations. -fn format_non_fs_guidance(lines: &mut Vec, violations: &[&SandboxViolation]) { - let has_guidance = |guidance| { - violations.iter().any(|violation| { - system_service_diagnostic_for(violation).and_then(|diagnostic| diagnostic.guidance) - == Some(guidance) - }) - }; - - if has_guidance(SystemServiceGuidance::Keychain) { - lines.push("[nono] Keychain access requires granting the login keychain path:".to_string()); - lines.push(keychain_login_grant_guidance()); - } - - if has_guidance(SystemServiceGuidance::UserPreferences) { - lines.push("[nono] Preference reads use macOS CFPreferences / NSUserDefaults.".to_string()); - lines.push( - "[nono] They are platform operations, not filesystem paths; saving them writes a raw macOS Seatbelt rule.".to_string(), - ); - lines.push( - "[nono] If the tool requires this, accept the profile prompt or add a reviewed user profile rule:".to_string(), - ); - lines.push( - "[nono] \"unsafe_macos_seatbelt_rules\": [\"(allow user-preference-read)\"]" - .to_string(), - ); - } - - if has_guidance(SystemServiceGuidance::SetuidExec) { - lines.push( - "[nono] A sandboxed process tried to execute a setuid/setgid binary.".to_string(), - ); - lines.push( - "[nono] macOS blocks privilege-changing execs inside this sandbox; this is not a path grant.".to_string(), - ); - lines.push( - "[nono] nono does not save this automatically. Prefer a non-setuid helper, or run the privileged helper outside nono after review.".to_string(), - ); - } -} - -fn keychain_login_grant_guidance() -> String { - const DISPLAY_PATH: &str = "~/Library/Keychains/login.keychain-db"; - let Some(home) = std::env::var_os("HOME") else { - return format!("[nono] --read-file {DISPLAY_PATH}"); - }; - let path = PathBuf::from(home).join("Library/Keychains/login.keychain-db"); - keychain_grant_guidance_for_path(&path, DISPLAY_PATH) -} - -fn keychain_grant_guidance_for_path(path: &Path, display_path: &str) -> String { - let flag = match std::fs::metadata(path).map(|metadata| metadata.file_type()) { - Ok(file_type) if file_type.is_dir() => "--read", - _ => "--read-file", - }; - format!("[nono] {flag} {display_path}") -} - -/// Deduplicate denials by path, merging access modes. When the same path -/// appears with multiple reasons, the most restrictive reason wins -/// (`PolicyBlocked` > `InsufficientAccess` > `UserDenied` > `RateLimited` > -/// `BackendError`). Output is sorted by path for stable rendering. -fn dedupe_denials(denials: &[DenialRecord]) -> Vec { - let mut by_path = std::collections::BTreeMap::::new(); - - for denial in denials { - by_path - .entry(denial.path.clone()) - .and_modify(|(access, reason)| { - *access = merge_access_modes(*access, denial.access); - *reason = stricter_reason(reason.clone(), denial.reason.clone()); - }) - .or_insert_with(|| (denial.access, denial.reason.clone())); - } - - by_path - .into_iter() - .map(|(path, (access, reason))| DenialRecord { - path, - access, - reason, - }) - .collect() -} - -fn stricter_reason(a: DenialReason, b: DenialReason) -> DenialReason { - fn rank(r: &DenialReason) -> u8 { - match r { - DenialReason::PolicyBlocked => 5, - DenialReason::UnixSocketDenied => 5, - DenialReason::InsufficientAccess => 4, - DenialReason::UserDenied => 3, - DenialReason::RateLimited => 2, - DenialReason::BackendError => 1, - } - } - if rank(&a) >= rank(&b) { a } else { b } -} - -/// Map a Seatbelt operation name to an `AccessMode`. -/// -/// Returns `None` for non-filesystem operations (e.g. `mach-lookup`, -/// `signal`, `process-exec`) that cannot be expressed as path grants. -pub fn seatbelt_operation_to_access(operation: &str) -> Option { - match operation { - "file-read-data" | "file-read-metadata" | "file-read-xattr" => Some(AccessMode::Read), - "file-write-data" | "file-write-create" | "file-write-unlink" | "file-write-flags" - | "file-write-mode" | "file-write-owner" | "file-write-times" | "file-write-xattr" => { - Some(AccessMode::Write) - } - _ => None, - } -} - -/// Convert `SandboxViolation`s with filesystem targets into `DenialRecord`s. -/// -/// Non-filesystem violations (mach-lookup, signal, etc.) are returned -/// separately since they can't be expressed as path grants. -fn violations_to_denials( - violations: &[SandboxViolation], -) -> (Vec, Vec<&SandboxViolation>) { - let mut denials = Vec::new(); - let mut non_fs = Vec::new(); - // Deduplicate: multiple operations on the same path merge into one denial - let mut seen = std::collections::BTreeMap::::new(); - - for v in violations { - if let (Some(access), Some(target)) = - (seatbelt_operation_to_access(&v.operation), &v.target) - { - let path = PathBuf::from(target); - seen.entry(path) - .and_modify(|existing| *existing = merge_access_modes(*existing, access)) - .or_insert(access); - } else { - non_fs.push(v); - } - } - - for (path, access) in seen { - denials.push(DenialRecord { - path, - access, - reason: DenialReason::PolicyBlocked, - }); - } - - (denials, non_fs) -} - -fn access_str(access: AccessMode) -> &'static str { - match access { - AccessMode::Read => "read", - AccessMode::Write => "write", - AccessMode::ReadWrite => "read+write", - } -} - -fn merge_access_modes(existing: AccessMode, new: AccessMode) -> AccessMode { - if existing == new { - existing - } else { - AccessMode::ReadWrite - } -} - -fn suggested_flag_for_path(path: &Path, requested: AccessMode) -> String { - let (flag, target) = suggested_flag_parts(path, requested); - format!("{flag} {}", target.display()) -} - -fn suggested_flag_for_existing_target( - target: &Path, - is_file: bool, - requested: AccessMode, -) -> String { - let flag = if is_file { - match requested { - AccessMode::Read => "--read-file", - AccessMode::Write => "--write-file", - AccessMode::ReadWrite => "--allow-file", - } - } else { - match requested { - AccessMode::Read => "--read", - AccessMode::Write => "--write", - AccessMode::ReadWrite => "--allow", - } - }; - - format!("{flag} {}", target.display()) -} - -fn suggested_flag_parts(path: &Path, requested: AccessMode) -> (&'static str, PathBuf) { - let flag = if path.is_file() { - match requested { - AccessMode::Read => "--read-file", - AccessMode::Write => "--write-file", - AccessMode::ReadWrite => "--allow-file", - } - } else { - match requested { - AccessMode::Read => "--read", - AccessMode::Write => "--write", - AccessMode::ReadWrite => "--allow", - } - }; - - let target = if path.exists() || path.is_dir() || path.parent().is_none() { - path.to_path_buf() - } else if let Some(parent) = path.parent() { - parent.to_path_buf() - } else { - path.to_path_buf() - }; - - (flag, target) -} - -fn shell_quote(s: &str) -> String { - if !s.is_empty() - && s.bytes() - .all(|b| b.is_ascii_alphanumeric() || b"/-_.".contains(&b)) - { - return s.to_string(); - } - - let mut quoted = String::with_capacity(s.len() + 2); - quoted.push('\''); - for ch in s.chars() { - if ch == '\'' { - quoted.push_str("'\\''"); - } else { - quoted.push(ch); - } - } - quoted.push('\''); - quoted -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::capability::{CapabilitySource, FsCapability}; - use tempfile::tempdir; - - fn make_test_caps() -> CapabilitySet { - let mut caps = CapabilitySet::new().block_network(); - caps.add_fs(FsCapability { - original: PathBuf::from("/test/project"), - resolved: PathBuf::from("/test/project"), - access: AccessMode::ReadWrite, - is_file: false, - source: CapabilitySource::User, - }); - caps - } - - fn make_mixed_caps() -> CapabilitySet { - let mut caps = CapabilitySet::new(); - caps.add_fs(FsCapability { - original: PathBuf::from("/home/user/project"), - resolved: PathBuf::from("/home/user/project"), - access: AccessMode::ReadWrite, - is_file: false, - source: CapabilitySource::User, - }); - caps.add_fs(FsCapability { - original: PathBuf::from("/usr/bin"), - resolved: PathBuf::from("/usr/bin"), - access: AccessMode::Read, - is_file: false, - source: CapabilitySource::Group("base_read".to_string()), - }); - caps.add_fs(FsCapability { - original: PathBuf::from("/usr/lib"), - resolved: PathBuf::from("/usr/lib"), - access: AccessMode::Read, - is_file: false, - source: CapabilitySource::Group("base_read".to_string()), - }); - caps.add_fs(FsCapability { - original: PathBuf::from("/tmp"), - resolved: PathBuf::from("/tmp"), - access: AccessMode::ReadWrite, - is_file: false, - source: CapabilitySource::System, - }); - caps - } - - // --- Standard mode tests --- - - #[test] - fn test_standard_footer_contains_exit_code() { - let caps = make_test_caps(); - let formatter = DiagnosticFormatter::new(&caps); - let output = formatter.format_footer(1); - - assert!(output.contains("Command exited with code 1.")); - } - - #[test] - fn test_standard_footer_uses_may_not_was() { - let caps = make_test_caps(); - let formatter = DiagnosticFormatter::new(&caps); - let output = formatter.format_footer(1); - - assert!(!output.contains("may be due to sandbox restrictions")); - assert!(!output.contains("was caused by")); - } - - #[test] - fn test_standard_footer_has_block_header() { - let caps = make_test_caps(); - let formatter = DiagnosticFormatter::new(&caps); - let output = formatter.format_footer(1); - - assert!(!output.starts_with("nono diagnostic")); - assert!(!output.contains("[nono]")); - } - - #[test] - fn test_standard_footer_shows_user_paths() { - let caps = make_test_caps(); - let formatter = DiagnosticFormatter::new(&caps); - let output = formatter.format_footer(1); - - assert!(output.contains("/test/project")); - assert!(output.contains("read+write")); - } - - #[test] - fn test_standard_footer_summarizes_group_paths() { - let caps = make_mixed_caps(); - let formatter = DiagnosticFormatter::new(&caps); - let output = formatter.format_footer(1); - - // User path shown explicitly - assert!(output.contains("/home/user/project")); - // Group/system paths summarized, not listed individually - assert!(output.contains("3 system/group path(s)")); - assert!(!output.contains("/usr/bin")); - assert!(!output.contains("/usr/lib")); - } - - #[test] - fn test_standard_footer_shows_network_blocked() { - let caps = make_test_caps(); - let formatter = DiagnosticFormatter::new(&caps); - let output = formatter.format_footer(1); - - assert!(output.contains("Network: blocked")); - } - - #[test] - fn test_standard_footer_shows_network_allowed() { - let mut caps = CapabilitySet::new(); - caps.add_fs(FsCapability { - original: PathBuf::from("/test/project"), - resolved: PathBuf::from("/test/project"), - access: AccessMode::ReadWrite, - is_file: false, - source: CapabilitySource::User, - }); - let formatter = DiagnosticFormatter::new(&caps); - let output = formatter.format_footer(1); - - assert!(output.contains("Network: allowed")); - } - - #[test] - fn test_standard_footer_shows_network_proxy() { - use crate::NetworkMode; - let mut caps = CapabilitySet::new().block_network(); - caps.set_network_mode_mut(NetworkMode::ProxyOnly { - port: 12345, - bind_ports: vec![], - }); - caps.add_fs(FsCapability { - original: PathBuf::from("/test/project"), - resolved: PathBuf::from("/test/project"), - access: AccessMode::ReadWrite, - is_file: false, - source: CapabilitySource::User, - }); - let formatter = DiagnosticFormatter::new(&caps); - let output = formatter.format_footer(1); - - assert!(output.contains("Network: proxy (localhost:12345)")); - } - - #[test] - fn test_standard_footer_shows_help() { - let caps = make_test_caps(); - let formatter = DiagnosticFormatter::new(&caps); - let output = formatter.format_footer(1); - - assert!(output.contains("--allow ")); - assert!(output.contains("--read ")); - assert!(output.contains("--write ")); - } - - #[test] - fn test_standard_footer_shows_network_help_when_blocked() { - let caps = make_test_caps(); - let formatter = DiagnosticFormatter::new(&caps); - let output = formatter.format_footer(1); - - assert!(output.contains("--allow-net")); - } - - #[test] - fn test_standard_footer_no_network_help_when_allowed() { - let mut caps = CapabilitySet::new(); - caps.add_fs(FsCapability { - original: PathBuf::from("/test/project"), - resolved: PathBuf::from("/test/project"), - access: AccessMode::ReadWrite, - is_file: false, - source: CapabilitySource::User, - }); - let formatter = DiagnosticFormatter::new(&caps); - let output = formatter.format_footer(1); - - assert!(!output.contains("--allow-net")); - } - - #[test] - fn test_analyze_error_output_detects_read_path() { - let observation = analyze_error_output( - "/bin/sh: /Users/alice/.profile: Operation not permitted\n", - &[], - None, - ); - - assert_eq!( - observation.path_hints, - vec![ObservedPathHint { - path: PathBuf::from("/Users/alice/.profile"), - access: AccessMode::Read, - }] - ); - } - - #[test] - fn test_analyze_error_output_detects_write_path_with_spaces() { - let observation = analyze_error_output( - "sh: cannot create '/tmp/file with spaces.txt': Operation not permitted\n", - &[], - None, - ); - - assert_eq!( - observation.path_hints, - vec![ObservedPathHint { - path: PathBuf::from("/tmp/file with spaces.txt"), - access: AccessMode::Write, - }] - ); - } - - #[test] - fn test_analyze_error_output_detects_node_eperm_mkdir_as_write() { - let observation = analyze_error_output( - "Failed to extract bundled package: Error: EPERM: operation not permitted, mkdir '/Users/luke/Library/Caches/copilot/pkg/darwin-arm64'\n", - &[], - None, - ); - - let hint = ObservedPathHint { - path: PathBuf::from("/Users/luke/Library/Caches/copilot/pkg/darwin-arm64"), - access: AccessMode::Write, - }; - assert_eq!(observation.path_hints, vec![hint.clone()]); - assert_eq!( - observation.primary_verdict, - Some(ErrorVerdict::LikelySandbox(hint)) - ); - } - - #[test] - fn test_analyze_error_output_detects_structured_node_eperm_mkdir_path() { - let observation = analyze_error_output( - "Error: EPERM: operation not permitted\n code: 'EPERM',\n syscall: 'mkdir',\n path: '/Users/luke/Library/Caches/copilot/pkg/darwin-arm64'\n", - &[], - None, - ); - - assert_eq!( - observation.path_hints, - vec![ObservedPathHint { - path: PathBuf::from("/Users/luke/Library/Caches/copilot/pkg/darwin-arm64"), - access: AccessMode::Write, - }] - ); - } - - #[test] - fn test_analyze_error_output_detects_structured_path_with_escaped_quote() { - let observation = analyze_error_output( - "Error: EPERM: operation not permitted\n code: 'EPERM',\n syscall: 'mkdir',\n path: '/Users/luke/Library/Caches/it\\'s/pkg'\n", - &[], - None, - ); - - assert_eq!( - observation.path_hints, - vec![ObservedPathHint { - path: PathBuf::from("/Users/luke/Library/Caches/it's/pkg"), - access: AccessMode::Write, - }] - ); - } - - #[test] - fn test_analyze_error_output_merges_access_modes() { - let observation = analyze_error_output( - "cat: /tmp/shared.txt: Permission denied\ntee: /tmp/shared.txt: Operation not permitted\n", - &[], - None, - ); - - assert_eq!( - observation.path_hints, - vec![ObservedPathHint { - path: PathBuf::from("/tmp/shared.txt"), - access: AccessMode::ReadWrite, - }] - ); - } - - #[test] - fn test_analyze_error_output_detects_missing_path() { - let observation = analyze_error_output( - "sh: /tmp/missing/file.txt: No such file or directory\n", - &[], - None, - ); - - assert_eq!(observation.path_hints, Vec::::new()); - assert_eq!( - observation.missing_paths, - vec![PathBuf::from("/tmp/missing/file.txt")] - ); - } - - #[test] - fn test_analyze_error_output_handles_quoted_execvp_path() { - // Regression: "sandbox-exec: execvp() of '/bin/ls' failed: Permission denied" - // must extract /bin/ls, not "/bin/ls' failed". - let observation = analyze_error_output( - "sandbox-exec: execvp() of '/bin/ls' failed: Permission denied\n", - &[], - None, - ); - - assert_eq!( - observation.path_hints, - vec![ObservedPathHint { - path: PathBuf::from("/bin/ls"), - access: AccessMode::ReadWrite, - }] - ); - } - - #[test] - fn test_analyze_error_output_handles_double_quoted_path() { - let observation = analyze_error_output( - "error: cannot open \"/etc/shadow\" for reading: Permission denied\n", - &[], - None, - ); - - assert_eq!( - observation.path_hints, - vec![ObservedPathHint { - path: PathBuf::from("/etc/shadow"), - access: AccessMode::Read, - }] - ); - } - - #[test] - fn test_analyze_error_output_infers_relative_write_path_from_cwd() { - let cwd = Path::new("/Users/luke/project"); - let observation = analyze_error_output( - "Creating empty tessl.json...\nPermission denied. Please check file permissions and try again.\n", - &[], - Some(cwd), - ); - - assert_eq!( - observation.path_hints, - vec![ObservedPathHint { - path: PathBuf::from("/Users/luke/project/tessl.json"), - access: AccessMode::Write, - }] - ); - assert_eq!( - observation.primary_verdict, - Some(ErrorVerdict::LikelySandbox(ObservedPathHint { - path: PathBuf::from("/Users/luke/project/tessl.json"), - access: AccessMode::Write, - })) - ); - } - - #[test] - fn test_analyze_error_output_detects_non_sandbox_failure() { - let observation = analyze_error_output( - "EEXIST: file already exists, mkdir '/Users/luke/.local/share/opencode'\n", - &[], - None, - ); - - assert_eq!( - observation.non_sandbox_failure.as_deref(), - Some("EEXIST: file already exists, mkdir '/Users/luke/.local/share/opencode'") - ); - assert_eq!( - observation.primary_verdict, - Some(ErrorVerdict::NonSandboxFailure( - "EEXIST: file already exists, mkdir '/Users/luke/.local/share/opencode'" - .to_string(), - )) - ); - assert!(observation.path_hints.is_empty()); - assert!(observation.missing_paths.is_empty()); - } - - #[test] - fn test_standard_footer_empty_caps() { - let caps = CapabilitySet::new(); - let formatter = DiagnosticFormatter::new(&caps); - let output = formatter.format_footer(1); - - assert!(output.contains("(none)")); - } - - #[test] - fn test_standard_footer_file_vs_dir() { - let mut caps = CapabilitySet::new(); - caps.add_fs(FsCapability { - original: PathBuf::from("/test/file.txt"), - resolved: PathBuf::from("/test/file.txt"), - access: AccessMode::Read, - is_file: true, - source: CapabilitySource::User, - }); - caps.add_fs(FsCapability { - original: PathBuf::from("/test/dir"), - resolved: PathBuf::from("/test/dir"), - access: AccessMode::Write, - is_file: false, - source: CapabilitySource::User, - }); - - let formatter = DiagnosticFormatter::new(&caps); - let output = formatter.format_footer(1); - - assert!(output.contains("file.txt (read, file)")); - assert!(output.contains("dir (write, dir)")); - } - - #[test] - fn test_format_summary() { - let caps = make_test_caps(); - let formatter = DiagnosticFormatter::new(&caps); - let summary = formatter.format_summary(); - - assert!(summary.contains("1 path(s)")); - assert!(summary.contains("network blocked")); - } - - #[test] - fn test_standard_footer_shows_observed_path_hint_suggestions() { - let temp = match tempdir() { - Ok(dir) => dir, - Err(e) => panic!("tempdir failed: {e}"), - }; - let denied = temp.path().join("denied.txt"); - if let Err(e) = std::fs::write(&denied, "secret") { - panic!("write failed: {e}"); - } - let caps = make_test_caps(); - - let formatter = DiagnosticFormatter::new(&caps).with_error_observation(ErrorObservation { - primary_verdict: Some(ErrorVerdict::LikelySandbox(ObservedPathHint { - path: denied.clone(), - access: AccessMode::Read, - })), - blocked_protected_file: None, - path_hints: vec![ObservedPathHint { - path: denied.clone(), - access: AccessMode::Read, - }], - missing_paths: Vec::new(), - non_sandbox_failure: None, - }); - let output = formatter.format_footer(1); - - assert!(output.contains("Sandbox denial:")); - assert!(output.contains(&denied.display().to_string())); - assert!(output.contains(&format!("Try: --read-file {}", denied.display()))); - assert!(output.contains("Sandbox policy:")); - } - - #[test] - fn test_standard_footer_exit_zero_with_observed_hint_still_surfaces_diagnostic() { - let denied = PathBuf::from("/Users/alice/.profile"); - let caps = make_test_caps(); - let formatter = DiagnosticFormatter::new(&caps).with_error_observation(ErrorObservation { - primary_verdict: Some(ErrorVerdict::LikelySandbox(ObservedPathHint { - path: denied.clone(), - access: AccessMode::Read, - })), - blocked_protected_file: None, - path_hints: vec![ObservedPathHint { - path: denied.clone(), - access: AccessMode::Read, - }], - missing_paths: Vec::new(), - non_sandbox_failure: None, - }); - let output = formatter.format_footer(0); - - assert!(output.contains( - "The command succeeded, but stderr showed a likely sandbox-related access issue." - )); - assert!(output.contains("Sandbox denial:")); - assert!(output.contains(&denied.display().to_string())); - } - - #[test] - fn test_standard_footer_surfaces_missing_path_before_policy() { - let caps = make_test_caps(); - let missing = PathBuf::from("/tmp/missing/file.txt"); - let formatter = DiagnosticFormatter::new(&caps).with_error_observation(ErrorObservation { - primary_verdict: Some(ErrorVerdict::MissingPath(missing.clone())), - blocked_protected_file: None, - path_hints: Vec::new(), - missing_paths: vec![missing.clone()], - non_sandbox_failure: None, - }); - let output = formatter.format_footer(1); - let missing_idx = match output.find("Missing path:") { - Some(idx) => idx, - None => panic!("missing path block missing: {output}"), - }; - let policy_idx = match output.find("Sandbox policy:") { - Some(idx) => idx, - None => panic!("policy block missing: {output}"), - }; - - assert!( - output.contains("The command failed, but this does not look like a sandbox denial.") - ); - assert!(output.contains(&missing.display().to_string())); - assert!(output.contains("Path flags only apply to paths that already exist")); - assert!(missing_idx < policy_idx); - assert!(!output.contains("To grant additional access, re-run with:")); - assert!(!output.contains("Why: nono why")); - } - - #[test] - fn test_standard_footer_surfaces_non_sandbox_failure_before_policy() { - let caps = make_test_caps(); - let formatter = DiagnosticFormatter::new(&caps).with_error_observation(ErrorObservation { - primary_verdict: Some(ErrorVerdict::NonSandboxFailure( - "EEXIST: file already exists, mkdir '/Users/luke/.local/share/opencode'" - .to_string(), - )), - blocked_protected_file: None, - path_hints: Vec::new(), - missing_paths: Vec::new(), - non_sandbox_failure: Some( - "EEXIST: file already exists, mkdir '/Users/luke/.local/share/opencode'" - .to_string(), - ), - }); - let output = formatter.format_footer(1); - - assert!( - output.contains("The command failed, but this does not look like a sandbox denial.") - ); - assert!(output.contains("Application error:")); - assert!(output.contains("EEXIST: file already exists")); - assert!(!output.contains("To grant additional access, re-run with:")); - assert!(!output.contains("Why: nono why")); - } - - #[test] - fn test_standard_footer_observed_hint_narrows_to_missing_write_access() { - let temp = match tempdir() { - Ok(dir) => dir, - Err(e) => panic!("tempdir failed: {e}"), - }; - let denied = temp.path().join("denied.txt"); - if let Err(e) = std::fs::write(&denied, "secret") { - panic!("write failed: {e}"); - } - - let canonical_temp = match temp.path().canonicalize() { - Ok(path) => path, - Err(e) => panic!("canonicalize failed: {e}"), - }; - - let mut caps = CapabilitySet::new(); - caps.add_fs(FsCapability { - original: temp.path().to_path_buf(), - resolved: canonical_temp.clone(), - access: AccessMode::Read, - is_file: false, - source: CapabilitySource::User, - }); - - let formatter = DiagnosticFormatter::new(&caps).with_error_observation(ErrorObservation { - primary_verdict: Some(ErrorVerdict::LikelySandbox(ObservedPathHint { - path: denied.clone(), - access: AccessMode::ReadWrite, - })), - blocked_protected_file: None, - path_hints: vec![ObservedPathHint { - path: denied.clone(), - access: AccessMode::ReadWrite, - }], - missing_paths: Vec::new(), - non_sandbox_failure: None, - }); - let output = formatter.format_footer(1); - - assert!(output.contains(&format!("{} (write)", denied.display()))); - assert!(output.contains(&format!("--write {}", canonical_temp.display()))); - assert!(!output.contains(&format!("--allow-file {}", denied.display()))); - } - - #[test] - fn test_standard_footer_prefers_explicit_write_upgrade_for_read_only_cwd_write() { - let cwd = PathBuf::from("/Users/luke/project"); - let denied = cwd.join("tessl.json"); - let mut caps = CapabilitySet::new(); - caps.add_fs(FsCapability { - original: cwd.clone(), - resolved: cwd.clone(), - access: AccessMode::Read, - is_file: false, - source: CapabilitySource::User, - }); - - let formatter = DiagnosticFormatter::new(&caps) - .with_current_dir(&cwd) - .with_error_observation(ErrorObservation { - primary_verdict: Some(ErrorVerdict::LikelySandbox(ObservedPathHint { - path: denied.clone(), - access: AccessMode::Write, - })), - blocked_protected_file: None, - path_hints: vec![ObservedPathHint { - path: denied.clone(), - access: AccessMode::Write, - }], - missing_paths: Vec::new(), - non_sandbox_failure: None, - }); - let output = formatter.format_footer(1); - - assert!(output.contains("current working directory is read-only")); - assert!(output.contains(&format!("Try: --write {}", cwd.display()))); - assert!(!output.contains("Try: --allow-cwd")); - } - - #[test] - fn test_standard_footer_skips_observed_hint_already_covered() { - let temp = match tempdir() { - Ok(dir) => dir, - Err(e) => panic!("tempdir failed: {e}"), - }; - let denied = temp.path().join("denied.txt"); - if let Err(e) = std::fs::write(&denied, "secret") { - panic!("write failed: {e}"); - } - - let mut caps = CapabilitySet::new(); - caps.add_fs(FsCapability { - original: temp.path().to_path_buf(), - resolved: match temp.path().canonicalize() { - Ok(path) => path, - Err(e) => panic!("canonicalize failed: {e}"), - }, - access: AccessMode::ReadWrite, - is_file: false, - source: CapabilitySource::User, - }); - - let formatter = DiagnosticFormatter::new(&caps).with_error_observation(ErrorObservation { - primary_verdict: Some(ErrorVerdict::LikelySandbox(ObservedPathHint { - path: denied.clone(), - access: AccessMode::Read, - })), - blocked_protected_file: None, - path_hints: vec![ObservedPathHint { - path: denied.clone(), - access: AccessMode::Read, - }], - missing_paths: Vec::new(), - non_sandbox_failure: None, - }); - let output = formatter.format_footer(1); - - assert!(!output.contains("Likely blocked paths seen in the command output")); - assert!(!output.contains(&denied.display().to_string())); - assert!(!output.contains("--read-file")); - } - - // --- Supervised mode tests --- - - #[test] - fn test_supervised_no_denials_no_extensions() { - let caps = make_test_caps(); // extensions_enabled defaults to false - let formatter = DiagnosticFormatter::new(&caps).with_mode(DiagnosticMode::Supervised); - let output = formatter.format_footer(1); - - assert!(output.contains("No path denials were observed during this session.")); - assert!(output.contains("The failure may be unrelated to sandbox restrictions.")); - assert!(output.contains("To grant additional access, re-run with:")); - assert!(output.contains("--allow ")); - assert!(!output.contains("Sandbox policy:")); - } - - #[test] - fn test_supervised_no_denials_no_extensions_uses_observed_hints() { - let temp = match tempdir() { - Ok(dir) => dir, - Err(e) => panic!("tempdir failed: {e}"), - }; - let denied = temp.path().join("startup.txt"); - if let Err(e) = std::fs::write(&denied, "secret") { - panic!("write failed: {e}"); - } - let caps = make_test_caps(); - - let formatter = DiagnosticFormatter::new(&caps) - .with_mode(DiagnosticMode::Supervised) - .with_error_observation(ErrorObservation { - primary_verdict: Some(ErrorVerdict::LikelySandbox(ObservedPathHint { - path: denied.clone(), - access: AccessMode::Read, - })), - blocked_protected_file: None, - path_hints: vec![ObservedPathHint { - path: denied.clone(), - access: AccessMode::Read, - }], - missing_paths: Vec::new(), - non_sandbox_failure: None, - }); - let output = formatter.format_footer(1); - - assert!(output.contains("Sandbox denial:")); - assert!(output.contains(&format!("Try: --read-file {}", denied.display()))); - assert!(output.contains("No path denials were observed during this session.")); - assert!(output.contains("Discover paths: nono learn -- ")); - assert!(!output.contains("Sandbox policy:")); - } - - #[test] - fn test_supervised_exit_zero_with_observed_hint_still_surfaces_diagnostic() { - let denied = PathBuf::from("/Users/alice/.profile"); - let caps = make_test_caps(); - let formatter = DiagnosticFormatter::new(&caps) - .with_mode(DiagnosticMode::Supervised) - .with_error_observation(ErrorObservation { - primary_verdict: Some(ErrorVerdict::LikelySandbox(ObservedPathHint { - path: denied.clone(), - access: AccessMode::Read, - })), - blocked_protected_file: None, - path_hints: vec![ObservedPathHint { - path: denied.clone(), - access: AccessMode::Read, - }], - missing_paths: Vec::new(), - non_sandbox_failure: None, - }); - let output = formatter.format_footer(0); - - assert!(output.contains( - "The command succeeded, but stderr showed a likely sandbox-related access issue." - )); - assert!(output.contains("Sandbox denial:")); - assert!(output.contains(&denied.display().to_string())); - assert!(output.contains("Discover paths: nono learn -- ")); - } - - #[test] - fn test_supervised_no_denials_no_extensions_surfaces_missing_path() { - let caps = make_test_caps(); - let missing = PathBuf::from("/tmp/missing/file.txt"); - let formatter = DiagnosticFormatter::new(&caps) - .with_mode(DiagnosticMode::Supervised) - .with_error_observation(ErrorObservation { - primary_verdict: Some(ErrorVerdict::MissingPath(missing.clone())), - blocked_protected_file: None, - path_hints: Vec::new(), - missing_paths: vec![missing.clone()], - non_sandbox_failure: None, - }); - let output = formatter.format_footer(1); - - assert!( - output.contains("The command failed, but this does not look like a sandbox denial.") - ); - assert!(output.contains(&missing.display().to_string())); - assert!(output.contains("To grant additional access, re-run with:")); - assert!( - output.contains("Query policy: nono why --path --op ") - ); - } - - #[test] - fn test_supervised_no_denials_no_extensions_surfaces_non_sandbox_failure() { - let caps = make_test_caps(); - let formatter = DiagnosticFormatter::new(&caps) - .with_mode(DiagnosticMode::Supervised) - .with_error_observation(ErrorObservation { - primary_verdict: Some(ErrorVerdict::NonSandboxFailure( - "EEXIST: file already exists, mkdir '/Users/luke/.local/share/opencode'" - .to_string(), - )), - blocked_protected_file: None, - path_hints: Vec::new(), - missing_paths: Vec::new(), - non_sandbox_failure: Some( - "EEXIST: file already exists, mkdir '/Users/luke/.local/share/opencode'" - .to_string(), - ), - }); - let output = formatter.format_footer(1); - - assert!( - output.contains("The command failed, but this does not look like a sandbox denial.") - ); - assert!(output.contains("Application error:")); - assert!(output.contains("EEXIST: file already exists")); - assert!(output.contains("To grant additional access, re-run with:")); - assert!(output.contains("Discover paths: nono learn -- ")); - } - - #[test] - fn test_supervised_no_denials_extensions_active() { - let mut caps = make_test_caps(); - caps.set_extensions_enabled(true); - let formatter = DiagnosticFormatter::new(&caps).with_mode(DiagnosticMode::Supervised); - let output = formatter.format_footer(1); - - assert!(output.contains("No path denials were observed during this session.")); - assert!(output.contains("may be unrelated")); - assert!(output.contains("--allow ")); - } - - #[test] - fn test_supervised_uses_sandbox_violations_when_available() { - let caps = make_test_caps(); - let violations = vec![ - SandboxViolation { - operation: "file-read-data".to_string(), - target: Some("/Users/alice/.ssh/id_rsa".to_string()), - }, - SandboxViolation { - operation: "mach-lookup".to_string(), - target: Some("com.apple.logd".to_string()), - }, - ]; - let formatter = DiagnosticFormatter::new(&caps) - .with_mode(DiagnosticMode::Supervised) - .with_sandbox_violations(&violations); - let output = formatter.format_footer(1); - - assert!(output.contains("Sandbox denial:")); - assert!(output.contains("/Users/alice/.ssh/id_rsa (read)")); - assert!(output.contains("Also blocked (system services):")); - assert!(output.contains("mach-lookup (com.apple.logd)")); - assert!(output.contains("System logging")); - } - - #[test] - fn test_violation_denial_keeps_file_read_target() { - let requested = PathBuf::from("/Users/alice/workspace/readable.rs"); - let violations = vec![SandboxViolation { - operation: "file-read-data".to_string(), - target: Some(requested.display().to_string()), - }]; - - let (denials, non_fs) = violations_to_denials(&violations); - - assert!(non_fs.is_empty()); - assert_eq!(denials.len(), 1); - assert_eq!(denials[0].path, requested); - assert_eq!(denials[0].access, AccessMode::Read); - } - - #[test] - fn test_logged_violation_target_wins_over_observed_requested_path() { - let caps = make_test_caps(); - let requested = PathBuf::from("/Users/alice/workspace/readable.rs"); - let actual = PathBuf::from("/Users/alice/Library/Caches/nl/state"); - let violations = vec![SandboxViolation { - operation: "file-write-create".to_string(), - target: Some(actual.display().to_string()), - }]; - let formatter = DiagnosticFormatter::new(&caps) - .with_mode(DiagnosticMode::Supervised) - .with_sandbox_violations(&violations) - .with_error_observation(ErrorObservation { - primary_verdict: Some(ErrorVerdict::LikelySandbox(ObservedPathHint { - path: requested.clone(), - access: AccessMode::Read, - })), - blocked_protected_file: None, - path_hints: vec![ObservedPathHint { - path: requested.clone(), - access: AccessMode::Read, - }], - missing_paths: Vec::new(), - non_sandbox_failure: None, - }); - - let output = formatter.format_footer(1); - - assert!(output.contains(&format!("{} (write)", actual.display()))); - assert!(!output.contains(&requested.display().to_string())); - } - - #[test] - fn test_sandbox_violation_preserves_workspace_prefix() { - let caps = make_test_caps(); - let actual = PathBuf::from( - "/Users/alice/workspace/flutter_photo_manager/lib/src/internal/plugin.dart", - ); - let violations = vec![SandboxViolation { - operation: "file-read-data".to_string(), - target: Some(actual.display().to_string()), - }]; - let formatter = DiagnosticFormatter::new(&caps) - .with_mode(DiagnosticMode::Supervised) - .with_sandbox_violations(&violations); - - let output = formatter.format_footer(1); - - assert!(output.contains(&format!("{} (read)", actual.display()))); - assert!(!output.contains("[nono] /src/internal/plugin.dart (read)")); - } - - #[test] - fn test_supervised_merges_mkdir_error_hint_with_logged_read_denial() { - let temp = tempdir().expect("tempdir should be created"); - let pkg = temp.path().join("Library/Caches/copilot/pkg"); - std::fs::create_dir_all(&pkg).expect("pkg fixture should be created"); - let denied = pkg.join("darwin-arm64"); - - let caps = CapabilitySet::new(); - let violations = vec![SandboxViolation { - operation: "file-read-data".to_string(), - target: Some(denied.display().to_string()), - }]; - let formatter = DiagnosticFormatter::new(&caps) - .with_mode(DiagnosticMode::Supervised) - .with_sandbox_violations(&violations) - .with_error_observation(ErrorObservation { - primary_verdict: Some(ErrorVerdict::LikelySandbox(ObservedPathHint { - path: denied.clone(), - access: AccessMode::Write, - })), - blocked_protected_file: None, - path_hints: vec![ObservedPathHint { - path: denied.clone(), - access: AccessMode::Write, - }], - missing_paths: Vec::new(), - non_sandbox_failure: None, - }) - .with_policy_explanations(vec![PolicyExplanation { - path: denied.clone(), - access: AccessMode::Read, - reason: "path_not_granted".to_string(), - details: None, - policy_source: None, - suggested_flag: Some(format!("--read {}", denied.display())), - }]); - let output = formatter.format_footer(1); - - assert!(output.contains(&format!("{} (read+write)", denied.display()))); - assert!(output.contains(&format!("Fix flags: --allow {}", pkg.display()))); - assert!(!output.contains(&format!("Fix flags: --read {}", denied.display()))); - } - - #[test] - fn test_keychain_guidance_uses_file_flag_for_file_targets() { - let dir = tempdir().expect("tempdir should be created"); - let keychain = dir.path().join("login.keychain-db"); - std::fs::write(&keychain, "db").expect("keychain fixture should be written"); - - let guidance = - keychain_grant_guidance_for_path(&keychain, "~/Library/Keychains/login.keychain-db"); - - assert_eq!( - guidance, - "[nono] --read-file ~/Library/Keychains/login.keychain-db" - ); - } - - #[test] - fn test_keychain_guidance_uses_directory_flag_for_directory_targets() { - let dir = tempdir().expect("tempdir should be created"); - - let guidance = - keychain_grant_guidance_for_path(dir.path(), "~/Library/Keychains/login.keychain-db"); - - assert_eq!( - guidance, - "[nono] --read ~/Library/Keychains/login.keychain-db" - ); - } - - #[test] - fn test_keychain_guidance_recognizes_keychain_mach_services() { - let caps = make_test_caps(); - let violations = vec![SandboxViolation { - operation: "mach-lookup".to_string(), - target: Some("com.apple.secd".to_string()), - }]; - let formatter = DiagnosticFormatter::new(&caps) - .with_mode(DiagnosticMode::Supervised) - .with_sandbox_violations(&violations); - let output = formatter.format_footer(1); - - assert!(output.contains("Keychain access requires granting the login keychain path:")); - assert!(output.contains("--read-file ~/Library/Keychains/login.keychain-db")); - } - - #[test] - fn test_preference_guidance_recognizes_any_application_domain() { - let caps = make_test_caps(); - let violations = vec![SandboxViolation { - operation: "user-preference-read".to_string(), - target: Some("kcfpreferencesanyapplication".to_string()), - }]; - let formatter = DiagnosticFormatter::new(&caps) - .with_mode(DiagnosticMode::Supervised) - .with_sandbox_violations(&violations); - let output = formatter.format_footer(1); - - assert!(output.contains("user-preference-read (kcfpreferencesanyapplication)")); - assert!(output.contains("Global preferences")); - assert!(output.contains("CFPreferences / NSUserDefaults")); - assert!(output.contains("unsafe_macos_seatbelt_rules")); - assert!(output.contains("(allow user-preference-read)")); - } - - #[test] - fn test_forbidden_exec_sugid_guidance_is_not_saveable() { - let caps = make_test_caps(); - let violations = vec![SandboxViolation { - operation: "forbidden-exec-sugid".to_string(), - target: None, - }]; - let formatter = DiagnosticFormatter::new(&caps) - .with_mode(DiagnosticMode::Supervised) - .with_sandbox_violations(&violations); - let output = formatter.format_footer(0); - - assert!(output.contains("forbidden-exec-sugid")); - assert!(output.contains("Setuid/setgid executable blocked")); - assert!(output.contains("not a path grant")); - assert!(output.contains("does not save this automatically")); - assert!(!output.contains("unsafe_macos_seatbelt_rules")); - } - - #[test] - fn test_suppressed_system_service_violation_is_hidden_from_footer() { - let caps = make_test_caps(); - let violations = vec![ - SandboxViolation { - operation: "forbidden-exec-sugid".to_string(), - target: None, - }, - SandboxViolation { - operation: "mach-lookup".to_string(), - target: Some("com.apple.logd".to_string()), - }, - ]; - let suppressed = vec!["forbidden-exec-sugid".to_string()]; - let formatter = DiagnosticFormatter::new(&caps) - .with_mode(DiagnosticMode::Supervised) - .with_sandbox_violations(&violations) - .with_suppressed_system_service_operations(&suppressed); - let output = formatter.format_footer(1); - - assert!(!output.contains("forbidden-exec-sugid")); - assert!(!output.contains("Setuid/setgid executable blocked")); - assert!(output.contains("mach-lookup (com.apple.logd)")); - } - - #[test] - fn test_supervised_policy_blocked_denial() { - let caps = make_test_caps(); - let denials = vec![DenialRecord { - path: PathBuf::from("/etc/shadow"), - access: AccessMode::Read, - reason: DenialReason::PolicyBlocked, - }]; - let formatter = DiagnosticFormatter::new(&caps) - .with_mode(DiagnosticMode::Supervised) - .with_denials(&denials); - let output = formatter.format_footer(1); - - assert!(output.contains("Sandbox denial: 1 path blocked.")); - assert!(output.contains("/etc/shadow (read) [permanently restricted]")); - assert!(output.contains("permanently restricted — override via a user profile")); - // Policy-blocked paths cannot be fixed with a path flag. - assert!(!output.contains("Fix flags: --read /etc/shadow")); - assert!(!output.contains("--allow ")); - } - - #[test] - fn test_supervised_unix_socket_denial_uses_ipc_guidance() { - let caps = make_test_caps(); - let denials = vec![DenialRecord { - path: PathBuf::from("/run/user/1000/bus"), - access: AccessMode::Read, - reason: DenialReason::UnixSocketDenied, - }]; - let formatter = DiagnosticFormatter::new(&caps) - .with_mode(DiagnosticMode::Supervised) - .with_denials(&denials); - let output = formatter.format_footer(1); - - assert!(output.contains("IPC denial: 1 pathname Unix socket blocked.")); - assert!(output.contains("/run/user/1000/bus")); - assert!(output.contains("Fix flags: --allow-unix-socket /run/user/1000/bus")); - assert!(!output.contains("No path denials were observed")); - assert!(!output.contains("--read /run/user/1000/bus")); - } - - #[test] - fn test_supervised_user_denied() { - let caps = make_test_caps(); - let dir = tempdir().expect("tempdir should be created"); - let denied_path = dir.path().join("secret.txt"); - std::fs::write(&denied_path, "secret").expect("denied file should be created"); - let denials = vec![DenialRecord { - path: denied_path.clone(), - access: AccessMode::Read, - reason: DenialReason::UserDenied, - }]; - let formatter = DiagnosticFormatter::new(&caps) - .with_mode(DiagnosticMode::Supervised) - .with_denials(&denials); - let output = formatter.format_footer(1); - - assert!(output.contains("Sandbox denial: 1 path blocked.")); - assert!(output.contains(&denied_path.display().to_string())); - assert!(output.contains(&format!("Fix flags: --read-file {}", denied_path.display()))); - // User-denied paths are actionable, not policy-blocked. - assert!(!output.contains("[permanently restricted]")); - } - - #[test] - fn test_supervised_mixed_denials() { - let caps = make_test_caps(); - let denials = vec![ - DenialRecord { - path: PathBuf::from("/etc/shadow"), - access: AccessMode::Read, - reason: DenialReason::PolicyBlocked, - }, - DenialRecord { - path: PathBuf::from("/home/user/data.txt"), - access: AccessMode::Read, - reason: DenialReason::UserDenied, - }, - ]; - let formatter = DiagnosticFormatter::new(&caps) - .with_mode(DiagnosticMode::Supervised) - .with_denials(&denials); - let output = formatter.format_footer(1); - - assert!(output.contains("Sandbox denial: 2 paths blocked.")); - // Policy-blocked path gets the marker. - assert!(output.contains("/etc/shadow (read) [permanently restricted]")); - // Actionable path is listed without the marker. - assert!(output.contains("/home/user/data.txt (read)")); - assert!(!output.contains("/home/user/data.txt (read) [permanently restricted]")); - // Consolidated Fix line covers only the actionable path. The suggested - // target falls back to the nearest existing parent directory since the - // path itself doesn't exist in the test environment. - assert!(output.contains("Fix flags: --read ")); - assert!(!output.contains("Fix flags: --read /etc/shadow")); - // The permanent-restriction note appears once for the policy-blocked path. - assert!(output.contains("1 path is permanently restricted")); - } - - #[test] - fn test_supervised_deduplicates_paths() { - let caps = make_test_caps(); - let denials = vec![ - DenialRecord { - path: PathBuf::from("/etc/shadow"), - access: AccessMode::Read, - reason: DenialReason::PolicyBlocked, - }, - DenialRecord { - path: PathBuf::from("/etc/shadow"), - access: AccessMode::Read, - reason: DenialReason::PolicyBlocked, - }, - ]; - let formatter = DiagnosticFormatter::new(&caps) - .with_mode(DiagnosticMode::Supervised) - .with_denials(&denials); - let output = formatter.format_footer(1); - - let count = output.matches("/etc/shadow").count(); - assert_eq!(count, 1, "Path should be deduplicated"); - assert!(!output.contains("Denied paths during this session:")); - } - - #[test] - fn test_supervised_consolidated_fix_combines_all_actionable() { - let caps = make_test_caps(); - let dir = tempdir().expect("tempdir should be created"); - let a = dir.path().join("a.txt"); - let b = dir.path().join("b.txt"); - std::fs::write(&a, "a").expect("write a"); - std::fs::write(&b, "b").expect("write b"); - let denials = vec![ - DenialRecord { - path: a.clone(), - access: AccessMode::Read, - reason: DenialReason::UserDenied, - }, - DenialRecord { - path: b.clone(), - access: AccessMode::Write, - reason: DenialReason::InsufficientAccess, - }, - ]; - let formatter = DiagnosticFormatter::new(&caps) - .with_mode(DiagnosticMode::Supervised) - .with_denials(&denials); - let output = formatter.format_footer(1); - - // Single Fix line covers both paths. - let fix_lines: Vec<&str> = output - .lines() - .filter(|line| line.contains("Fix flags: ")) - .collect(); - assert_eq!( - fix_lines.len(), - 1, - "expected one consolidated Fix line: {output}" - ); - assert!(fix_lines[0].contains(&format!("--read-file {}", a.display()))); - assert!(fix_lines[0].contains(&format!("--write-file {}", b.display()))); - assert!(!output.contains("[permanently restricted]")); - } - - #[test] - fn test_supervised_consolidated_list_truncates_beyond_cap() { - // Zero-pad the index so paths sort in numeric order. - let caps = make_test_caps(); - let denials: Vec = (0..15) - .map(|i| DenialRecord { - path: PathBuf::from(format!("/tmp/denied-{i:02}")), - access: AccessMode::Read, - reason: DenialReason::UserDenied, - }) - .collect(); - let formatter = DiagnosticFormatter::new(&caps) - .with_mode(DiagnosticMode::Supervised) - .with_denials(&denials); - let output = formatter.format_footer(1); - - assert!(output.contains("Sandbox denial: 15 paths blocked.")); - // First 10 paths listed, remaining 5 collapsed. - assert!(output.contains("/tmp/denied-00 ")); - assert!(output.contains("/tmp/denied-09 ")); - assert!(!output.contains("/tmp/denied-10 ")); - assert!(output.contains("… and 5 more")); - // Fix line still covers all 15 paths. - assert_eq!( - output.lines().filter(|l| l.contains("Fix flags: ")).count(), - 1, - "expected one consolidated Fix flags line" - ); - } - - #[test] - fn test_supervised_has_block_header() { - let caps = make_test_caps(); - let denials = vec![DenialRecord { - path: PathBuf::from("/etc/shadow"), - access: AccessMode::Read, - reason: DenialReason::PolicyBlocked, - }]; - let formatter = DiagnosticFormatter::new(&caps) - .with_mode(DiagnosticMode::Supervised) - .with_denials(&denials); - let output = formatter.format_footer(1); - - assert!(!output.starts_with("nono diagnostic")); - assert!(!output.contains("[nono]")); - } - - #[test] - fn test_supervised_rate_limited_denial() { - let caps = make_test_caps(); - let denials = vec![DenialRecord { - path: PathBuf::from("/tmp/flood"), - access: AccessMode::Read, - reason: DenialReason::RateLimited, - }]; - let formatter = DiagnosticFormatter::new(&caps) - .with_mode(DiagnosticMode::Supervised) - .with_denials(&denials); - let output = formatter.format_footer(1); - - assert!(output.contains("Sandbox denial: 1 path blocked.")); - assert!(output.contains("/tmp/flood (read)")); - // Rate-limited denials are still actionable via a path flag. The - // suggested target falls back to the nearest existing parent since - // /tmp/flood itself doesn't exist. - assert!(output.contains("Fix flags: --read ")); - assert!(!output.contains("[permanently restricted]")); - } - - #[test] - fn test_supervised_insufficient_access_shows_closest_grant_and_fix() { - let dir = tempdir().expect("tempdir should be created"); - let denied_path = dir.path().join("output.txt"); - std::fs::write(&denied_path, "output").expect("output file should be created"); - let dir_path = dir - .path() - .canonicalize() - .expect("tempdir should canonicalize"); - - let mut caps = CapabilitySet::new(); - caps.add_fs(FsCapability { - original: dir.path().to_path_buf(), - resolved: dir_path.clone(), - access: AccessMode::Read, - is_file: false, - source: CapabilitySource::Group("project_read".to_string()), - }); - - let denials = vec![DenialRecord { - path: denied_path.clone(), - access: AccessMode::Write, - reason: DenialReason::InsufficientAccess, - }]; - let formatter = DiagnosticFormatter::new(&caps) - .with_mode(DiagnosticMode::Supervised) - .with_denials(&denials); - let output = formatter.format_footer(1); - - // The "Closest grant" hint moved out of the consolidated footer; - // users can recover it with `nono why` if they want the detail. - assert!(!output.contains("Closest grant:")); - assert!(output.contains(&format!( - "Fix flags: --write-file {}", - denied_path.display() - ))); - assert!(!output.contains("Denied paths during this session:")); - } - - // --- Protected paths tests --- - - #[test] - fn test_protected_paths_shown_in_footer() { - let caps = make_test_caps(); - let protected = vec![ - PathBuf::from("/project/SKILLS.md"), - PathBuf::from("/project/helper.py"), - ]; - let formatter = DiagnosticFormatter::new(&caps).with_protected_paths(&protected); - let output = formatter.format_footer(1); - - assert!(output.contains("Write-protected")); - assert!(output.contains("SKILLS.md")); - assert!(output.contains("helper.py")); - } - - #[test] - fn test_protected_paths_empty_no_section() { - let caps = make_test_caps(); - let formatter = DiagnosticFormatter::new(&caps).with_protected_paths(&[]); - let output = formatter.format_footer(1); - - assert!(!output.contains("Write-protected")); - } - - #[test] - fn test_protected_paths_shown_in_supervised_macos_fallback() { - let caps = make_test_caps(); // extensions_enabled defaults to false - let protected = vec![PathBuf::from("/project/config.json")]; - let formatter = DiagnosticFormatter::new(&caps) - .with_mode(DiagnosticMode::Supervised) - .with_protected_paths(&protected); - let output = formatter.format_footer(1); - - assert!(!output.contains("Write-protected")); - } - - // --- Exit code explanation tests --- - - fn make_command_context(program: &str, path: &str) -> CommandContext { - CommandContext { - program: program.to_string(), - resolved_path: PathBuf::from(path), - args: vec![program.to_string()], - } - } - - #[test] - fn test_exit_127_binary_not_readable() { - // Binary resolved to /opt/bin/foo but sandbox has no read access there - let caps = make_test_caps(); // only /test/project - let cmd = make_command_context("foo", "/opt/bin/foo"); - let formatter = DiagnosticFormatter::new(&caps).with_command(cmd); - let output = formatter.format_footer(127); - - assert!(output.contains("Failed to execute command (exit code 127)")); - assert!(output.contains("The executable 'foo' was resolved at:")); - assert!(output.contains("/opt/bin/foo")); - assert!(output.contains("not readable inside the sandbox")); - assert!(output.contains("nono run --read /opt/bin")); - } - - #[test] - fn test_exit_127_binary_readable_but_exec_fails() { - // Binary at /usr/bin/ps, sandbox has /usr/bin readable - let mut caps = CapabilitySet::new(); - caps.add_fs(FsCapability { - original: PathBuf::from("/usr/bin"), - resolved: PathBuf::from("/usr/bin"), - access: AccessMode::Read, - is_file: false, - source: CapabilitySource::Group("system_read".to_string()), - }); - let cmd = make_command_context("ps", "/usr/bin/ps"); - let formatter = DiagnosticFormatter::new(&caps).with_command(cmd); - let output = formatter.format_footer(127); - - assert!(output.contains("'ps' resolved to /usr/bin/ps and is readable")); - assert!(output.contains("execution still failed. Common causes:")); - assert!(output.contains("shared library")); - assert!(output.contains("Run with -v")); - } - - #[test] - fn test_exit_127_file_level_grant_dir_not_readable() { - // Binary granted as a file-level read, but parent dir not readable - let mut caps = CapabilitySet::new(); - caps.add_fs(FsCapability { - original: PathBuf::from("/opt/custom/mybin"), - resolved: PathBuf::from("/opt/custom/mybin"), - access: AccessMode::Read, - is_file: true, - source: CapabilitySource::User, - }); - let cmd = make_command_context("mybin", "/opt/custom/mybin"); - let formatter = DiagnosticFormatter::new(&caps).with_command(cmd); - let output = formatter.format_footer(127); - - // is_binary_path_readable returns true (file-level match) - // is_binary_dir_readable returns false (/opt/custom not granted) - assert!(output.contains("'mybin' resolved to /opt/custom/mybin but the directory")); - assert!(output.contains("read access to")); - } - - #[test] - fn test_exit_127_no_command_context() { - let caps = make_test_caps(); - let formatter = DiagnosticFormatter::new(&caps); - let output = formatter.format_footer(127); - - assert!(output.contains("Command not found (exit code 127)")); - assert!(output.contains("could not be found or executed")); - } - - #[test] - fn test_exit_126_permission_denied() { - let caps = make_test_caps(); - let cmd = make_command_context("script.sh", "/test/project/script.sh"); - let formatter = DiagnosticFormatter::new(&caps).with_command(cmd); - let output = formatter.format_footer(126); - - assert!(output.contains("Permission denied (exit code 126)")); - assert!(output.contains("'script.sh' was found at /test/project/script.sh")); - assert!(output.contains("execute permission")); - } - - #[test] - fn test_exit_126_no_command_context() { - let caps = make_test_caps(); - let formatter = DiagnosticFormatter::new(&caps); - let output = formatter.format_footer(126); - - assert!(output.contains("Permission denied (exit code 126)")); - assert!(output.contains("found but could not be executed")); - } - - #[test] - fn test_exit_1_generic() { - let caps = make_test_caps(); - let formatter = DiagnosticFormatter::new(&caps); - let output = formatter.format_footer(1); - - assert!(output.contains("Command exited with code 1.")); - } - - #[test] - fn test_exit_sigkill() { - let caps = make_test_caps(); - let formatter = DiagnosticFormatter::new(&caps); - let output = formatter.format_footer(128 + 9); - - assert!(output.contains("SIGKILL")); - assert!(output.contains("forcefully terminated")); - assert!(output.contains("usually not")); - } - - #[test] - fn test_exit_sigsys_platform_correct() { - let caps = make_test_caps(); - let formatter = DiagnosticFormatter::new(&caps); - let output = formatter.format_footer(128 + libc::SIGSYS); - - assert!(output.contains("SIGSYS")); - assert!(output.contains("blocked system call")); - } - - #[test] - fn test_exit_sigterm() { - let caps = make_test_caps(); - let formatter = DiagnosticFormatter::new(&caps); - let output = formatter.format_footer(128 + 15); - - assert!(output.contains("SIGTERM")); - // SIGTERM gets the generic signal line, not a special explanation - assert!(!output.contains("blocked system call")); - assert!(!output.contains("forcefully terminated")); - } - - #[test] - fn test_exit_unknown_signal() { - let caps = make_test_caps(); - let formatter = DiagnosticFormatter::new(&caps); - let output = formatter.format_footer(128 + 33); - - assert!(output.contains("killed by signal 33")); - assert!(!output.contains("SIGKILL")); - assert!(!output.contains("SIGSYS")); - } - - #[test] - fn test_exit_other_code() { - let caps = make_test_caps(); - let formatter = DiagnosticFormatter::new(&caps); - let output = formatter.format_footer(42); - - assert!(output.contains("Command exited with code 42.")); - } - - #[test] - fn suppressed_denial_annotated_with_save_skipped() { - let caps = make_test_caps(); - let denied = PathBuf::from("/tmp/suppressed-file"); - let other = PathBuf::from("/tmp/other-file"); - let suppressed = crate::try_canonicalize(&denied); - - let denials = vec![ - DenialRecord { - path: denied.clone(), - access: AccessMode::Read, - reason: DenialReason::RateLimited, - }, - DenialRecord { - path: other.clone(), - access: AccessMode::Read, - reason: DenialReason::RateLimited, - }, - ]; - let formatter = DiagnosticFormatter::new(&caps) - .with_mode(DiagnosticMode::Supervised) - .with_denials(&denials) - .with_suppressed_paths(std::slice::from_ref(&suppressed)); - let output = formatter.format_footer(1); - - // The suppressed path gets the [save skipped] annotation. - assert!( - output.contains("[save skipped]"), - "expected [save skipped] in:\n{output}" - ); - // The non-suppressed path does not. - let other_line = output - .lines() - .find(|l| l.contains("other-file")) - .unwrap_or(""); - assert!( - !other_line.contains("[save skipped]"), - "non-suppressed path should not have [save skipped]: {other_line}" - ); - } - - #[test] - fn suppressed_denial_without_suppressed_paths_has_no_annotation() { - let caps = make_test_caps(); - let denied = PathBuf::from("/tmp/some-file"); - let denials = vec![DenialRecord { - path: denied, - access: AccessMode::Read, - reason: DenialReason::RateLimited, - }]; - let formatter = DiagnosticFormatter::new(&caps) - .with_mode(DiagnosticMode::Supervised) - .with_denials(&denials); - let output = formatter.format_footer(1); - - assert!( - !output.contains("[save skipped]"), - "no suppressed paths set — [save skipped] should not appear:\n{output}" - ); - } - - #[test] - fn permanently_restricted_and_suppressed_shows_both_labels() { - let caps = make_test_caps(); - let denied = PathBuf::from("/tmp/restricted-and-suppressed"); - let suppressed = crate::try_canonicalize(&denied); - - // PolicyBlocked reason + a policy explanation with reason "sensitive_path" - // causes is_denial_policy_blocked() to return true. - let explanation = PolicyExplanation { - path: denied.clone(), - access: AccessMode::Read, - reason: "sensitive_path".to_string(), - details: None, - policy_source: None, - suggested_flag: None, - }; - let denials = vec![DenialRecord { - path: denied, - access: AccessMode::Read, - reason: DenialReason::PolicyBlocked, - }]; - let formatter = DiagnosticFormatter::new(&caps) - .with_mode(DiagnosticMode::Supervised) - .with_denials(&denials) - .with_policy_explanations(vec![explanation]) - .with_suppressed_paths(std::slice::from_ref(&suppressed)); - let output = formatter.format_footer(1); - - let line = output - .lines() - .find(|l| l.contains("restricted-and-suppressed")) - .unwrap_or(""); - assert!( - line.contains("permanently restricted"), - "expected 'permanently restricted' in: {line}" - ); - assert!( - line.contains("save skipped"), - "expected 'save skipped' in: {line}" - ); + #[test] + fn seatbelt_write_operations_map_to_write() { + assert_eq!( + seatbelt_operation_to_access("file-write-data"), + Some(AccessMode::Write) + ); + assert_eq!( + seatbelt_operation_to_access("file-write-create"), + Some(AccessMode::Write) + ); } #[test] - fn suppressed_denial_uses_precomputed_canonical_path() { - // Verify that when `with_canonical_denial_paths` is supplied the - // pre-computed value is used for suppression matching instead of the - // raw denial path. We supply a canonical path that differs from the - // raw path (simulating symlink resolution) and assert that suppression - // is triggered against the canonical form. - let caps = make_test_caps(); - let raw_path = PathBuf::from("/tmp/link-to-suppressed"); - let canonical_path = PathBuf::from("/tmp/real-suppressed-target"); - - let denials = vec![DenialRecord { - path: raw_path.clone(), - access: AccessMode::Read, - reason: DenialReason::RateLimited, - }]; - // Suppress the canonical path, not the raw symlink path. - let formatter = DiagnosticFormatter::new(&caps) - .with_mode(DiagnosticMode::Supervised) - .with_denials(&denials) - .with_suppressed_paths(std::slice::from_ref(&canonical_path)) - .with_canonical_denial_paths(vec![canonical_path.clone()]); - let output = formatter.format_footer(1); - - assert!( - output.contains("[save skipped]"), - "suppression via precomputed canonical path should annotate [save skipped]:\n{output}" - ); + fn seatbelt_non_filesystem_operations_map_to_none() { + assert_eq!(seatbelt_operation_to_access("mach-lookup"), None); + assert_eq!(seatbelt_operation_to_access("signal"), None); + assert_eq!(seatbelt_operation_to_access("network-outbound"), None); } } diff --git a/crates/nono/src/lib.rs b/crates/nono/src/lib.rs index ae7e4eaae..5d7280b5e 100644 --- a/crates/nono/src/lib.rs +++ b/crates/nono/src/lib.rs @@ -68,10 +68,7 @@ pub use capability::{ AccessMode, CapabilitySet, CapabilitySource, FsCapability, IpcMode, NetworkMode, ProcessInfoMode, SignalMode, SocketScope, UnixSocketCapability, UnixSocketMode, UnixSocketOp, }; -pub use diagnostic::{ - CommandContext, DenialReason, DenialRecord, DiagnosticFormatter, DiagnosticMode, - IpcDenialRecord, SandboxViolation, -}; +pub use diagnostic::{DenialReason, DenialRecord, IpcDenialRecord, SandboxViolation}; pub use error::{NonoError, Result}; pub use keystore::{ LoadedSecret, is_apple_password_uri, is_bw_uri, is_env_uri, is_file_uri, is_keyring_uri, From b1c321a555a38f4d0564c59d0a1230a5bbfd1533 Mon Sep 17 00:00:00 2001 From: Aleks <121458075+SequeI@users.noreply.github.com> Date: Mon, 15 Jun 2026 19:15:58 +0100 Subject: [PATCH 60/91] chore: release v0.63.0 (#1161) Signed-off-by: Aleksy Siek --- CHANGELOG.md | 102 +++++++++++++++++++++++++++++++++++ Cargo.lock | 6 +-- bindings/c/Cargo.toml | 2 +- crates/nono-cli/Cargo.toml | 6 +-- crates/nono-proxy/Cargo.toml | 4 +- crates/nono/Cargo.toml | 2 +- 6 files changed, 112 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9bdee9d6f..538423b59 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,107 @@ # Changelog +## [0.63.0] - 2026-06-15 + +### Bug Fixes + +- *(proxy)* Keep connection open for reactive proxy auth on CONNECT (#1151) ([#1151](https://github.com/always-further/nono/pull/1151)) + +- Report the actual blocked operation instead of the readable target path in sandbox denial diagnostics (#1150) ([#1150](https://github.com/always-further/nono/pull/1150)) + +- *(linux)* Trap sendto/sendmsg to prevent AF_UNIX datagram bypass (#1096) ([#1096](https://github.com/always-further/nono/pull/1096)) + +- *(cli)* Accept truthy env values for bool flags (#1136) ([#1136](https://github.com/always-further/nono/pull/1136)) + +- Replace stale nono.dev schema domains with nono.sh + +- *(audit)* Address ledger review and clippy + +- Write cargo vendor config for copr srpms + +- *(aur)* Skip ssh-keyscan banner lines in host key check + + +### Build + +- Add copr source rpm packaging (#1075) ([#1075](https://github.com/always-further/nono/pull/1075)) + + +### CI/CD + +- Use actions/attest + + +### Dependencies + +- *(deps)* Bump ignore from 0.4.25 to 0.4.26 (#1160) ([#1160](https://github.com/always-further/nono/pull/1160)) + +- *(deps)* Bump chrono from 0.4.44 to 0.4.45 (#1159) ([#1159](https://github.com/always-further/nono/pull/1159)) + +- *(deps)* Bump time from 0.3.47 to 0.3.49 (#1158) ([#1158](https://github.com/always-further/nono/pull/1158)) + +- *(deps)* Bump zeroize from 1.8.2 to 1.9.0 (#1157) ([#1157](https://github.com/always-further/nono/pull/1157)) + +- *(deps)* Bump typify from 0.6.2 to 0.7.0 (#1156) ([#1156](https://github.com/always-further/nono/pull/1156)) + +- *(deps)* Bump actions/checkout from 6.0.2 to 6.0.3 + +- *(deps)* Bump x509-parser from 0.16.0 to 0.18.1 + +- *(deps)* Bump cbindgen from 0.29.2 to 0.29.3 + +- *(deps)* Bump hyper from 1.9.0 to 1.10.1 + + +### Documentation + +- Document diagnostics.suppress_system_services for macOS (#1076) (#1138) ([#1138](https://github.com/always-further/nono/pull/1138)) + +- *(readme)* Update agent commands and enhance feature descriptions (#1145) ([#1145](https://github.com/always-further/nono/pull/1145)) + +- *(readme)* Refine project description and history (#1143) ([#1143](https://github.com/always-further/nono/pull/1143)) + +- *(readme)* Update agent package publishing link (#1142) ([#1142](https://github.com/always-further/nono/pull/1142)) + +- *(cli-quickstart)* Add profile usage to quickstart guide + +- Add copr installation instructions + + +### Features + +- *(cli)* Move runtime state to XDG state dirs (#1152) ([#1152](https://github.com/always-further/nono/pull/1152)) + +- Add $PACK_DIR support to session_hooks for store pack support (#1073) ([#1073](https://github.com/always-further/nono/pull/1073)) + +- *(keyring)* Add NONO_KEYRING_TIMEOUT_SECS for keychain access (#977) ([#977](https://github.com/always-further/nono/pull/977)) + +- *(environment)* Add set_vars for static env injection (#1134) ([#1134](https://github.com/always-further/nono/pull/1134)) + +- *(pack-verification)* Skip pack verification on dry runs + + +### Miscellaneous + +- *(project)* Add new issue template for agent package requests (#1081) ([#1081](https://github.com/always-further/nono/pull/1081)) + + +### Refactoring + +- *(diagnostic)* Move diagnostic UX out of core nono crate (#1155) ([#1155](https://github.com/always-further/nono/pull/1155)) + +- *(pull_ui)* Remove sigstore provenance display (#1144) ([#1144](https://github.com/always-further/nono/pull/1144)) + +- *(profiles)* Standardize profile names with namespace + +- *(audit-ledger)* Move audit ledger logic to library crate + +- *(audit)* Move audit integrity logic to nono crate + + +### Testing + +- *(wsl2)* Fix has_landlock_network V4+ detection + ## [0.62.0] - 2026-06-07 ### Bug Fixes diff --git a/Cargo.lock b/Cargo.lock index b7a63ceb4..83e1d9996 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2116,7 +2116,7 @@ dependencies = [ [[package]] name = "nono" -version = "0.62.0" +version = "0.63.0" dependencies = [ "der", "getrandom 0.4.2", @@ -2146,7 +2146,7 @@ dependencies = [ [[package]] name = "nono-cli" -version = "0.62.0" +version = "0.63.0" dependencies = [ "aws-lc-rs", "chrono", @@ -2202,7 +2202,7 @@ dependencies = [ [[package]] name = "nono-proxy" -version = "0.62.0" +version = "0.63.0" dependencies = [ "base64", "getrandom 0.4.2", diff --git a/bindings/c/Cargo.toml b/bindings/c/Cargo.toml index 7fd87b076..c2fb441bc 100644 --- a/bindings/c/Cargo.toml +++ b/bindings/c/Cargo.toml @@ -15,7 +15,7 @@ name = "nono_ffi" crate-type = ["cdylib", "staticlib"] [dependencies] -nono = { version = "0.62.0", path = "../../crates/nono", default-features = false } +nono = { version = "0.63.0", path = "../../crates/nono", default-features = false } [build-dependencies] cbindgen.workspace = true diff --git a/crates/nono-cli/Cargo.toml b/crates/nono-cli/Cargo.toml index df11d8178..ef42ab859 100644 --- a/crates/nono-cli/Cargo.toml +++ b/crates/nono-cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "nono-cli" -version = "0.62.0" +version = "0.63.0" edition.workspace = true rust-version.workspace = true authors.workspace = true @@ -41,8 +41,8 @@ system-keyring = ["dep:keyring", "nono/system-keyring", "nono-proxy/system-keyri test-trust-overrides = [] [dependencies] -nono = { version = "0.62.0", path = "../nono", default-features = false } -nono-proxy = { version = "0.62.0", path = "../nono-proxy", default-features = false } +nono = { version = "0.63.0", path = "../nono", default-features = false } +nono-proxy = { version = "0.63.0", path = "../nono-proxy", default-features = false } clap = { version = "4", features = ["derive", "env"] } clap_complete = "4" colored = "3" diff --git a/crates/nono-proxy/Cargo.toml b/crates/nono-proxy/Cargo.toml index 99499ea8e..e725ce984 100644 --- a/crates/nono-proxy/Cargo.toml +++ b/crates/nono-proxy/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "nono-proxy" -version = "0.62.0" +version = "0.63.0" edition.workspace = true rust-version.workspace = true authors.workspace = true @@ -18,7 +18,7 @@ default = ["system-keyring"] system-keyring = ["nono/system-keyring"] [dependencies] -nono = { version = "0.62.0", path = "../nono", default-features = false } +nono = { version = "0.63.0", path = "../nono", default-features = false } tokio = { workspace = true, features = ["sync", "time"] } hyper.workspace = true hyper-util.workspace = true diff --git a/crates/nono/Cargo.toml b/crates/nono/Cargo.toml index ac707d3c4..a274239de 100644 --- a/crates/nono/Cargo.toml +++ b/crates/nono/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "nono" -version = "0.62.0" +version = "0.63.0" edition.workspace = true rust-version.workspace = true authors.workspace = true From d4627f77c370bf3a72e6088c0f6a8353027aa095 Mon Sep 17 00:00:00 2001 From: Aleks <121458075+SequeI@users.noreply.github.com> Date: Mon, 15 Jun 2026 19:17:17 +0100 Subject: [PATCH 61/91] chore: import agents.md inside claude.md (#1153) Signed-off-by: Aleksy Siek --- CLAUDE.md | 205 +----------------------------------------------------- 1 file changed, 2 insertions(+), 203 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 6855e715d..bc4fefa54 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,204 +1,3 @@ -# nono - Development Guide +# Project instructions live in AGENTS.md (imported below for Claude Code). -## Project Overview - -nono is a capability-based sandboxing system for running untrusted AI agents with OS-enforced isolation. It uses Landlock (Linux) and Seatbelt (macOS) to create sandboxes, and then layers on top a policy system, diagnostic tools, and a rollback mechanism for recovery. The library is designed to be a pure sandbox primitive with no built-in policy, while the CLI implements all security policy and UX. - -The project is a Cargo workspace with three members: -- **nono** (`crates/nono/`) - Core library. Pure sandbox primitive with no built-in security policy. -- **nono-cli** (`crates/nono-cli/`) - CLI binary. Owns all security policy, profiles, hooks, and UX. -- **nono-ffi** (`bindings/c/`) - C FFI bindings. Exposes the library via `extern "C"` functions and auto-generated `nono.h` header. -- **nono-proxy** - Proxy that provides network filtering and credential injection - -### Library vs CLI Boundary - -The library is a **pure sandbox primitive**. It applies ONLY what clients explicitly add to `CapabilitySet`: - -| In Library | In CLI | -|------------|--------| -| `CapabilitySet` builder | Policy groups (deny rules, dangerous commands, system paths) | -| `Sandbox::apply()` | Group resolver (`policy.rs`) and platform-aware deny handling | -| `SandboxState` | `ExecStrategy` (Direct/Monitor/Supervised) | -| `DiagnosticFormatter` | Profile loading and hooks | -| `QueryContext` | All output and UX | -| `keystore` | `learn` mode | -| `undo` module (ObjectStore, SnapshotManager, MerkleTree, ExclusionFilter) | Rollback lifecycle, exclusion policy, rollback UI | - -## Build & Test - -After every session, run these commands to verify correctness: - -```bash -# Build everything -make build - -# Run all tests -make test - -# Full CI check (clippy + fmt + tests) -make ci -``` - -Individual targets: -```bash -make build-lib # Library only -make build-cli # CLI only -make test-lib # Library tests only -make test-cli # CLI tests only -make test-doc # Doc tests only -make clippy # Lint (strict: -D warnings -D clippy::unwrap_used) -make fmt-check # Format check -make fmt # Auto-format -``` - -## Coding Standards - -- **Error Handling**: Use `NonoError` for all errors; propagation via `?` only. -- **Unwrap Policy**: Strictly forbid `.unwrap()` and `.expect()`; enforced by `clippy::unwrap_used`. -- **Libraries should almost never panic**: Panics are for unrecoverable bugs, not expected error conditions. Use `Result` instead. -- **Unsafe Code**: Restrict to FFI; must be wrapped in safe APIs with `// SAFETY:` docs. -- **Path Security**: Validate and canonicalize all paths before applying capabilities. -- **Arithmetic**: Use `checked_`, `saturating_`, or `overflowing_` methods for security-critical math. -- **Memory**: Use the `zeroize` crate for sensitive data (keys/passwords) in memory. -- **Testing**: Write unit tests for all new capability types and sandbox logic. -- **Environment variables in tests**: Tests that modify `HOME`, `TMPDIR`, `XDG_CONFIG_HOME`, or other env vars must save and restore the original value. Rust runs unit tests in parallel within the same process, so an unrestored env var causes flaky failures in unrelated tests (e.g. `config::check_sensitive_path` fails when another test temporarily sets `HOME` to a fake path). Always use save/restore pattern and keep the modified window as short as possible. -- **Attributes**: Apply `#[must_use]` to functions returning critical Results. -- **Lazy use of dead code**: Avoid `#[allow(dead_code)]`. If code is unused, either remove it or write tests that use it. -- **Commits**: All commits must include a DCO sign-off line (`Signed-off-by: Name `). - -## Key Design Decisions - -1. **No escape hatch**: Once sandbox is applied via `restrict_self()` (Landlock) or `sandbox_init()` (Seatbelt), there is no API to expand permissions. - -2. **Fork+wait process model**: nono stays alive as a parent process. On child failure, prints a diagnostic footer to stderr. Three execution strategies: `Direct` (exec, backward compat), `Monitor` (sandbox-then-fork, default), `Supervised` (fork-then-sandbox, for rollbacks/expansion). - -3. **Capability resolution**: All paths are canonicalized at grant time to prevent symlink escapes. - -4. **Library is policy-free**: The library applies ONLY what's in `CapabilitySet`. No built-in sensitive paths, dangerous commands, or system paths. Clients define all policy. - -## Platform-Specific Notes - -### macOS (Seatbelt) -- Uses `sandbox_init()` FFI with raw profile strings -- Profile is Scheme-like DSL: `(allow file-read* (subpath "/path"))` -- Network denied by default with `(deny network*)` - -### Linux (Landlock) -- Uses landlock crate for safe Rust bindings -- Detects highest available ABI (v1-v5) -- ABI v4+ includes TCP network filtering -- Strictly allow-list: cannot express deny-within-allow. `deny.access`, `deny.unlink`, and `symlink_pairs` are macOS-only. Avoid broad allow groups that cover deny paths. - -## Security Considerations - -**SECURITY IS NON-NEGOTIABLE.** This is a security-critical codebase. Every change must be evaluated through a security lens first. When in doubt, choose the more restrictive option. - -### Core Principles -- **Principle of Least Privilege**: Only grant the minimum necessary capabilities. -- **Defense in Depth**: Combine OS-level sandboxing with application-level checks. -- **Fail Secure**: On any error, deny access. Never silently degrade to a less secure state. -- **Explicit Over Implicit**: Security-relevant behavior must be explicit and auditable. - -### Path Handling (CRITICAL) -- Always use path component comparison, not string operations. String `starts_with()` on paths is a vulnerability. -- Canonicalize paths at the enforcement boundary. Be aware of TOCTOU race conditions with symlinks. -- Validate environment variables before use. Never assume `HOME`, `TMPDIR`, etc. are trustworthy. -- Escape and validate all data used in Seatbelt profile generation. - -### Permission Scope (CRITICAL) -- Never grant access to entire directories when specific paths suffice. -- Separate read and write permissions explicitly. -- Configuration load failures must be fatal. If security lists fail to load, abort. - -### Common Footguns -1. **String comparison for paths**: `path.starts_with("/home")` matches `/homeevil`. Use `Path::starts_with()`. -2. **Silent fallbacks**: `unwrap_or_default()` on security config returns empty permissions = no protection. -3. **Trusting resolved paths**: Symlinks can change between resolution and use. -4. **Platform differences**: macOS `/etc` is a symlink to `/private/etc`. Both must be considered. -5. **Overly broad permissions**: Granting `/tmp` read/write when only `/tmp/specific-file` is needed. -6. **Solving for one architecture**: Linux and macOS have different capabilities and threat models. Design must account for both. Develop abstractions that can be implemented securely on both platforms. Test on both platforms regularly to catch divergences. - -## Coding Agent Contribution Policy - -These rules are mandatory for any automated or AI-assisted contribution. - -### 1. Hard stop conditions - -An agent **must not** open or submit a pull request if any of the following are true: - -- An issue does not already exist for the proposed change. -- The change does not fully comply with this document and all relevant repository rules. -- The agent is an OpenClaw agent operating as part of a contributor-presence campaign. - -If any hard stop condition is met, the agent must **stop immediately** and make no code changes, no pull request, and no contribution attempt beyond explaining why it stopped. - -### 2. Required workflow - -Before making any code change, the agent must complete this workflow in order: - -1. Read this document and all repository contribution, security, and coding-standard documents relevant to the affected area. -2. Search for an existing issue covering the work. -3. If no issue exists, create one before making changes. -4. In the issue discussion, disclose: - - the exact intent of the change - - the planned implementation approach - - any expected risks, tradeoffs, or limitations -5. Wait for project guidance or confirmation if the repository requires maintainer approval before implementation. -6. Only then prepare a change. - -### 3. Attribution and license compliance - -When referencing, adapting, or extending existing code, the agent must: - -- identify the original authors where required by project policy -- link to the relevant files, functions, sections, commits, or discussions -- clearly distinguish: - - existing project code - - adapted logic - - newly written logic - -Failure to provide required attribution is a policy violation and may also violate the project license, DCO requirements, or both. - -### 4. Pull request requirements - -A pull request may be opened only if all of the following are true: - -- an issue already exists -- the proposed change matches the issue discussion -- attribution requirements have been satisfied -- the code complies with all mandatory repository rules -- the agent is not prohibited under the hard stop conditions above - -The pull request description must include: - -- link to the issue -- statement that the contributor is an agent -- summary of the approach taken -- references to relevant files or sections consulted -- explicit confirmation that the contribution complies with repository coding and security requirements - -### 6. Special prohibition: OpenClaw contributor-presence campaigns - -If the agent is an OpenClaw or Pi Coding agent acting as part of a campaign to build contributor presence, it is strictly prohibited from making any changes to the codebase, opening pull requests, or attempting to contribute. - -### 7. Default behavior on uncertainty - -If the agent is uncertain whether an action is permitted, compliant, properly attributed, or secure, it must treat that uncertainty as a failure condition and stop. - ---- - -## Agent Compliance Check (required before opening a PR) - -The agent must include this checklist in the pull request and truthfully complete it: - -- [ ] I am not prohibited from contributing under this policy -- [ ] An issue already exists -- [ ] I described my intent and approach in the issue discussion -- [ ] I reviewed repository coding and security rules for the affected area -- [ ] I provided required attribution for reused or adapted code -- [ ] I did not use forbidden patterns such as unwrap/expect -- [ ] I used NonoError where required -- [ ] I validated and canonicalized all relevant paths -- [ ] This PR matches the approved or disclosed issue scope - -If any item cannot be truthfully checked, the agent must not open a pull request. Instead, it must stop and report the issue. +@AGENTS.md From 0d5d435db9c6361feaa591a76d2b15c21e541e87 Mon Sep 17 00:00:00 2001 From: Caio Silva Date: Tue, 16 Jun 2026 09:47:41 +0100 Subject: [PATCH 62/91] fix(proxy): return 403 + audit for denied non-CONNECT requests (#1077) * fix(proxy): return 403 + audit for denied non-CONNECT requests Previously, plain HTTP requests through the proxy (e.g. curl http://google.com) returned 'HTTP/1.1 400 Bad Request' with no audit entry when no routes were configured. The CONNECT (HTTPS) path correctly returned 403 and audited via filter.check_host() -> audit::log_denied(). Fix the no-routes else branch in handle_connection to mirror the CONNECT path: - Parse host:port from the request URL via a new parse_non_connect_target helper using the existing url crate. - Run filter.check_host() on the parsed target. - On deny: emit audit::log_denied() and return 403 Forbidden with the filter reason (matches connect::handle_connect behavior). - On allow but no route to forward: return 502 Bad Gateway. Reuses ProxyMode::Connect for the audit event to avoid a public API change to nono::undo::NetworkAuditMode. The mode label is slightly imprecise but the deny entry now appears where users expect per the issue report. Tests: 3 unit tests for the URL parser plus an end-to-end regression that starts the proxy, sends a denied plain-HTTP request, and asserts both the 403 response and the audit event. Closes #1062 Signed-off-by: Caio Silva * refactor(proxy): use iterator in parse_non_connect_target Avoids allocating a Vec for split_whitespace parts; uses next() directly to extract the URL. Behavior and error messages unchanged. Addresses review feedback on PR #1077. Signed-off-by: Caio Silva --------- Signed-off-by: Caio Silva Co-authored-by: Aleks <121458075+SequeI@users.noreply.github.com> --- crates/nono-proxy/src/server.rs | 110 +++++++++++++++++++++++++++++++- 1 file changed, 107 insertions(+), 3 deletions(-) diff --git a/crates/nono-proxy/src/server.rs b/crates/nono-proxy/src/server.rs index 71fa9808c..ee82105f5 100644 --- a/crates/nono-proxy/src/server.rs +++ b/crates/nono-proxy/src/server.rs @@ -27,12 +27,33 @@ use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::net::TcpListener; use tokio::sync::watch; use tracing::{debug, info, warn}; +use url::Url; use zeroize::Zeroizing; /// Maximum total size of HTTP headers (64 KiB). Prevents OOM from /// malicious clients sending unbounded header data. const MAX_HEADER_SIZE: usize = 64 * 1024; +/// Parse host and port from a non-CONNECT proxy request line. +/// +/// Example: `GET http://google.com/ HTTP/1.1` -> ("google.com", 80) +/// `GET http://google.com:8080/path HTTP/1.1` -> ("google.com", 8080) +fn parse_non_connect_target(line: &str) -> Result<(String, u16)> { + let mut parts = line.split_whitespace(); + let _method = parts.next(); + let url = parts + .next() + .ok_or_else(|| ProxyError::HttpParse(format!("malformed request line: {}", line)))?; + let parsed = Url::parse(url) + .map_err(|e| ProxyError::HttpParse(format!("invalid URL in request: {}: {}", url, e)))?; + let host = parsed + .host_str() + .ok_or_else(|| ProxyError::HttpParse(format!("no host in URL: {}", url)))? + .to_string(); + let port = parsed.port_or_known_default().unwrap_or(80); + Ok((host, port)) +} + /// Handle returned when the proxy server starts. /// /// Contains the assigned port, session token, and a shutdown channel. @@ -1011,9 +1032,30 @@ async fn handle_connection(mut stream: tokio::net::TcpStream, state: &ProxyState }; reverse::handle_reverse_proxy(first_line, &mut stream, &header_bytes, &ctx, &buffered).await } else { - // No routes configured, reject non-CONNECT requests - let response = "HTTP/1.1 400 Bad Request\r\n\r\n"; - stream.write_all(response.as_bytes()).await?; + // No routes configured: filter, audit, and respond inline. + let (host, port) = parse_non_connect_target(first_line)?; + let check = state.filter.check_host(&host, port).await?; + if !check.result.is_allowed() { + let reason = check.result.reason(); + audit::log_denied( + Some(&state.audit_log), + audit::ProxyMode::Connect, + &audit::EventContext { + denial_category: Some(nono::undo::NetworkAuditDenialCategory::HostDenied), + ..audit::EventContext::default() + }, + &host, + port, + &reason, + ); + let sanitised = reason.replace(['\r', '\n'], " "); + let response = format!("HTTP/1.1 403 Forbidden: {}\r\n\r\n", sanitised); + stream.write_all(response.as_bytes()).await?; + } else { + stream + .write_all(b"HTTP/1.1 502 Bad Gateway\r\n\r\n") + .await?; + } Ok(()) } } @@ -1948,4 +1990,66 @@ mod tests { handle.shutdown(); } + + #[test] + fn test_parse_non_connect_target_default_port_80() { + let (host, port) = parse_non_connect_target("GET http://google.com/ HTTP/1.1").unwrap(); + assert_eq!(host, "google.com"); + assert_eq!(port, 80); + } + + #[test] + fn test_parse_non_connect_target_parses_url_with_port() { + let (host, port) = + parse_non_connect_target("GET http://google.com:8080/path HTTP/1.1").unwrap(); + assert_eq!(host, "google.com"); + assert_eq!(port, 8080); + } + + #[test] + fn test_parse_non_connect_target_rejects_malformed_line() { + let err = parse_non_connect_target("garbage").unwrap_err(); + assert!(err.to_string().contains("malformed request line")); + } + + /// Regression for #1062: a denied non-CONNECT request must return 403 + /// (not 400) and produce a `http` audit deny event. + #[tokio::test] + async fn test_denied_non_connect_returns_403_and_audits() { + use tokio::io::AsyncReadExt; + use tokio::net::TcpStream; + + // allowed_hosts = ["example.com"] -> google.com is denied + let config = ProxyConfig { + allowed_hosts: vec!["example.com".to_string()], + ..ProxyConfig::default() + }; + let handle = start(config).await.unwrap(); + let addr = format!("127.0.0.1:{}", handle.port); + + let mut stream = TcpStream::connect(&addr).await.unwrap(); + let request = b"GET http://google.com/ HTTP/1.1\r\nHost: google.com\r\n\r\n"; + tokio::io::AsyncWriteExt::write_all(&mut stream, request) + .await + .unwrap(); + + let mut response = Vec::new(); + stream.read_to_end(&mut response).await.unwrap(); + let response_str = String::from_utf8_lossy(&response); + assert!( + response_str.starts_with("HTTP/1.1 403"), + "expected 403 status, got: {}", + response_str + ); + + let events = handle.drain_audit_events(); + assert_eq!(events.len(), 1, "expected one audit event"); + let event = &events[0]; + assert_eq!(event.mode, nono::undo::NetworkAuditMode::Connect); + assert_eq!(event.decision, nono::undo::NetworkAuditDecision::Deny); + assert_eq!(event.target, "google.com"); + assert_eq!(event.port, Some(80)); + + handle.shutdown(); + } } From 60a803ef7cfae364a597548b89f7c795a37ccb1d Mon Sep 17 00:00:00 2001 From: connrg Date: Tue, 16 Jun 2026 10:20:37 +0100 Subject: [PATCH 63/91] docs(quickstart): fix profiles link (#1168) Signed-off-by: connrg --- docs/cli/getting_started/quickstart.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/cli/getting_started/quickstart.mdx b/docs/cli/getting_started/quickstart.mdx index 4dead0be0..f4a328489 100644 --- a/docs/cli/getting_started/quickstart.mdx +++ b/docs/cli/getting_started/quickstart.mdx @@ -37,7 +37,7 @@ nono run --profile always-further/pi -- pi ``` - If the agent you want to use cannot be found, create an [issue](https://github.com/always-further/nono/issues) to request consideration for adding it to the registry, or better yet, fork an existing profile and submit a PR with the new agent profile! See [Profiles & Groups](/cli/features/package-publishing) for details on how to create your own profiles. + If the agent you want to use cannot be found, create an [issue](https://github.com/always-further/nono/issues) to request consideration for adding it to the registry, or better yet, fork an existing profile and submit a PR with the new agent profile! See [Profiles & Groups](/cli/features/profiles-groups) for details on how to create your own profiles. ### Build your own profile From 68348ccf739811d75734b62e32aad8f1ab8241d2 Mon Sep 17 00:00:00 2001 From: connrg Date: Tue, 16 Jun 2026 10:53:14 +0100 Subject: [PATCH 64/91] docs(install): add version check and COPR fallback note (#1169) Signed-off-by: connrg --- docs/cli/getting_started/installation.mdx | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/cli/getting_started/installation.mdx b/docs/cli/getting_started/installation.mdx index f3201f991..2aff0b29d 100644 --- a/docs/cli/getting_started/installation.mdx +++ b/docs/cli/getting_started/installation.mdx @@ -7,6 +7,7 @@ description: How to install nono on your system ```bash brew install nono +nono --version ``` ## Linux Package Managers @@ -20,6 +21,7 @@ VERSION=$(curl -sI https://github.com/always-further/nono/releases/latest | grep ARCH=$(dpkg --print-architecture) wget https://github.com/always-further/nono/releases/download/v${VERSION}/nono-cli_${VERSION}_${ARCH}.deb sudo dpkg -i nono-cli_${VERSION}_${ARCH}.deb +nono --version ``` ### Fedora (COPR) @@ -30,8 +32,13 @@ nono is available from the official [COPR repository](https://copr.fedorainfracl sudo dnf install 'dnf-command(copr)' sudo dnf copr enable always-further/nono sudo dnf install nono-cli +nono --version ``` + + If your Fedora version isn't available in COPR, use the manual RPM install below. + + ### Manual RPM (RHEL/openSUSE and fallback) Download the `.rpm` package from [GitHub Releases](https://github.com/always-further/nono/releases): @@ -41,6 +48,7 @@ VERSION=$(curl -sI https://github.com/always-further/nono/releases/latest | grep ARCH=$(uname -m) wget https://github.com/always-further/nono/releases/download/v${VERSION}/nono-cli-${VERSION}-1.${ARCH}.rpm sudo dnf install ./nono-cli-${VERSION}-1.${ARCH}.rpm +nono --version ``` For openSUSE, use `sudo zypper install ./nono-cli-${VERSION}-1.${ARCH}.rpm` instead. From 31303ab3773bdf2137364560bfdfc322dfc4be7f Mon Sep 17 00:00:00 2001 From: connrg Date: Tue, 16 Jun 2026 12:50:04 +0100 Subject: [PATCH 65/91] fix(diagnostic): replace deprecated nono learn with nono run (#1170) Signed-off-by: connrg --- crates/nono-cli/src/diagnostic/formatter.rs | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/crates/nono-cli/src/diagnostic/formatter.rs b/crates/nono-cli/src/diagnostic/formatter.rs index 568ab9cd4..ae721d56a 100644 --- a/crates/nono-cli/src/diagnostic/formatter.rs +++ b/crates/nono-cli/src/diagnostic/formatter.rs @@ -6,7 +6,7 @@ //! //! The structured, policy-free denial records live in the core //! `nono::diagnostic` module; everything here is CLI/product UX: footer -//! rendering, CLI flag suggestions, `nono why`/`nono learn` guidance, policy +//! rendering, CLI flag suggestions, `nono why`/`nono run` guidance, policy //! explanations, and stderr heuristics. //! //! # Design Principles @@ -1268,11 +1268,13 @@ impl<'a> DiagnosticFormatter<'a> { lines.push("[nono] Next steps:".to_string()); if let Some(command) = self.format_command_for_learn() { lines.push(format!( - "[nono] Discover paths: nono learn -- {}", + "[nono] Add permissions: nono run --allow -- {}", command )); } else { - lines.push("[nono] Discover paths: nono learn -- ".to_string()); + lines.push( + "[nono] Add permissions: nono run --allow -- ".to_string(), + ); } lines.push( "[nono] Query policy: nono why --path --op ".to_string(), @@ -2882,7 +2884,7 @@ mod tests { assert!(output.contains("Sandbox denial:")); assert!(output.contains(&format!("Try: --read-file {}", denied.display()))); assert!(output.contains("No path denials were observed during this session.")); - assert!(output.contains("Discover paths: nono learn -- ")); + assert!(output.contains("Add permissions: nono run --allow -- ")); assert!(!output.contains("Sandbox policy:")); } @@ -2912,7 +2914,7 @@ mod tests { )); assert!(output.contains("Sandbox denial:")); assert!(output.contains(&denied.display().to_string())); - assert!(output.contains("Discover paths: nono learn -- ")); + assert!(output.contains("Add permissions: nono run --allow -- ")); } #[test] @@ -2966,7 +2968,7 @@ mod tests { assert!(output.contains("Application error:")); assert!(output.contains("EEXIST: file already exists")); assert!(output.contains("To grant additional access, re-run with:")); - assert!(output.contains("Discover paths: nono learn -- ")); + assert!(output.contains("Add permissions: nono run --allow -- ")); } #[test] From 2551d1cc1621d3266d714324e3c2cc0fcfeacd02 Mon Sep 17 00:00:00 2001 From: connrg Date: Tue, 16 Jun 2026 16:21:27 +0100 Subject: [PATCH 66/91] docs(networking): lead with the common cases (#1174) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The page opened straight on the proxy modes table, so the commands most people actually want — allow a domain, block the net, inject a key — were buried halfway down. Pull them up into a short "Common cases" block at the top. Signed-off-by: connrg --- docs/cli/features/networking.mdx | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/docs/cli/features/networking.mdx b/docs/cli/features/networking.mdx index a809df075..903a4133d 100644 --- a/docs/cli/features/networking.mdx +++ b/docs/cli/features/networking.mdx @@ -5,6 +5,19 @@ description: Network access control — proxy modes, domain filtering, credentia By default the sandboxed process has unrestricted network access. When you restrict networking, nono routes traffic through an HTTP proxy running in the unsandboxed supervisor process. The sandboxed child can only reach `localhost:` — all other outbound TCP is blocked at the kernel level. +## Common cases + +```bash +# Allow only specific domains — everything else is blocked +nono run --allow-cwd --allow-domain api.openai.com -- my-agent + +# Block all outbound network +nono run --allow-cwd --block-net -- my-agent + +# Inject an API key the sandbox never sees +nono run --allow-cwd --credential openai -- my-agent +``` + ## Proxy Modes The proxy supports three modes that can be combined in a single session: From 9ac6bd8b9f98c92c45e55aace83d874799afd280 Mon Sep 17 00:00:00 2001 From: connrg Date: Tue, 16 Jun 2026 20:04:07 +0100 Subject: [PATCH 67/91] docs(credential-injection): fix broken Proxy Overrides anchor (#1177) The custom_credentials schema table linked the `proxy` field to #proxy-side-overrides, but the section heading is "Proxy Overrides" (#proxy-overrides). The link resolved to nothing. Point it at the correct anchor and match the link text to the heading. Signed-off-by: connrg --- docs/cli/features/credential-injection.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/cli/features/credential-injection.mdx b/docs/cli/features/credential-injection.mdx index 012e4ddce..e96741a16 100644 --- a/docs/cli/features/credential-injection.mdx +++ b/docs/cli/features/credential-injection.mdx @@ -395,7 +395,7 @@ For APIs not covered by the built-in services, you can define custom credentials | `path_pattern` | Conditional | - | Required for `url_path` mode. URL path pattern with `{}` placeholder (e.g., `/bot{}/`) | | `path_replacement` | No | - | Optional replacement pattern for `url_path` mode (e.g., `/v2/bot{}/`) | | `query_param_name` | Conditional | - | Required for `query_param` mode. Query parameter name for credential injection (e.g., `key` or `api_key`) | -| `proxy` | No | - | Optional proxy overrides for phantom token parsing. Omitted fields inherit from top-level values. See [Proxy-Side Overrides](#proxy-side-overrides). | +| `proxy` | No | - | Optional proxy overrides for phantom token parsing. Omitted fields inherit from top-level values. See [Proxy Overrides](#proxy-overrides). | | `env_var` | Conditional | - | Explicit environment variable name for the phantom token. Required when `credential_key` is `op://`, `bw://`, `apple-password://`, or `file://`. Optional for `env://` (derived from the URI) | **Important:** Use underscores, not hyphens, in credential names (the keys in `custom_credentials`). The credential name is used to generate environment variables like `TELEGRAM_BASE_URL`. Shell variable names cannot contain hyphens, so `my-api` would create `MY-API_BASE_URL` which cannot be referenced as `$MY-API_BASE_URL` in shell scripts. Use `my_api` instead. From b50f4d12dd264af67cca50afd81ff02c2ec69d38 Mon Sep 17 00:00:00 2001 From: Aleks <121458075+SequeI@users.noreply.github.com> Date: Wed, 17 Jun 2026 09:32:53 +0100 Subject: [PATCH 68/91] chore(deps): add 3-day Dependabot cooldown for cargo and github-actions (#1163) Delay version-update PRs until dependencies are at least three days old, reducing the risk of pulling a freshly compromised release into a tagged build. Security updates are unaffected and still bypass cooldown. Signed-off-by: Aleksy Siek --- .github/dependabot.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index eef072c90..31753d930 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,12 +1,18 @@ version: 2 +# Delay version-update PRs so freshly published packages are not pulled into +# releases immediately. Security updates bypass cooldown automatically. updates: - package-ecosystem: "github-actions" directory: "/" schedule: interval: "weekly" + cooldown: + default-days: 3 - package-ecosystem: "cargo" directory: "/" schedule: interval: "weekly" + cooldown: + default-days: 3 From 08e57e234ea6d6bc0b4fdbec78ef641a1d67c1e1 Mon Sep 17 00:00:00 2001 From: connrg Date: Wed, 17 Jun 2026 09:36:14 +0100 Subject: [PATCH 69/91] docs(allow-cwd): clarify access level is profile-driven (#1180) --allow-cwd doesn't always grant read+write. The access level comes from the active profile's workdir setting, defaulting to read-only when no profile sets one, and granting nothing under the default profile (workdir "none"). The Developer Workflows guide claimed it always grants read+write, which only holds for readwrite profiles and contradicts the flag reference. Align the guide with it and note the none case on the Working Directory section. Signed-off-by: connrg --- docs/cli/features/profiles-groups.mdx | 2 +- docs/cli/usage/developer-workflows.mdx | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/cli/features/profiles-groups.mdx b/docs/cli/features/profiles-groups.mdx index 3795f1737..57f96549e 100644 --- a/docs/cli/features/profiles-groups.mdx +++ b/docs/cli/features/profiles-groups.mdx @@ -174,7 +174,7 @@ The `workdir` section controls whether and how the current working directory is | `"write"` | Write-only access to CWD | | `"readwrite"` | Full read+write access to CWD | -When a profile specifies a workdir access level, nono will prompt the user to confirm CWD sharing (unless `--allow-cwd` is used to skip the prompt). +When a profile specifies a workdir access level, nono will prompt the user to confirm CWD sharing (unless `--allow-cwd` is used to skip the prompt). `--allow-cwd` grants access to the directory at that profile's workdir level — so with `"none"` (the `default` profile) it grants nothing. Pair `--allow-cwd` with a profile that sets a workdir level, or grant the directory explicitly with `--allow`/`--read`. ### Filesystem Overrides and Group Composition diff --git a/docs/cli/usage/developer-workflows.mdx b/docs/cli/usage/developer-workflows.mdx index add4bc347..1ff5c5bc9 100644 --- a/docs/cli/usage/developer-workflows.mdx +++ b/docs/cli/usage/developer-workflows.mdx @@ -222,9 +222,9 @@ Profiles make your workflow easier to repeat, share, and audit. ### Use `--allow-cwd` Deliberately -`--allow-cwd` is convenient, but it means “grant the current repository read+write access”. +`--allow-cwd` shares the current directory at the access level the active profile's [`workdir`](/cli/features/profiles-groups#working-directory) config declares: read+write for the coding profiles used here (`always-further/claude`, `always-further/codex`), read-only when no profile is specified, and nothing at all under the conservative `default` profile (`workdir: "none"`). -That is often the right choice for coding agents, but treat it as an explicit workspace grant, not as boilerplate. +That read+write grant is often the right choice for coding agents, but treat it as an explicit workspace grant, not as boilerplate. ### Keep Login/Bootstrap Permissions Temporary From 7b7844f982e0e9a4e2e243391a672be1f393b2b0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 17 Jun 2026 09:40:52 +0100 Subject: [PATCH 70/91] chore(deps): bump which from 8.0.2 to 8.0.3 (#1181) Bumps [which](https://github.com/harryfei/which-rs) from 8.0.2 to 8.0.3. - [Release notes](https://github.com/harryfei/which-rs/releases) - [Changelog](https://github.com/harryfei/which-rs/blob/master/CHANGELOG.md) - [Commits](https://github.com/harryfei/which-rs/compare/8.0.2...8.0.3) --- updated-dependencies: - dependency-name: which dependency-version: 8.0.3 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Cargo.lock | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 83e1d9996..657e8eaf2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -107,7 +107,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -118,7 +118,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -679,7 +679,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -967,7 +967,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -1054,7 +1054,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -1764,7 +1764,7 @@ dependencies = [ "portable-atomic", "portable-atomic-util", "serde_core", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -2238,7 +2238,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3008,7 +3008,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -3067,7 +3067,7 @@ dependencies = [ "security-framework 3.7.0", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -3088,7 +3088,7 @@ dependencies = [ "security-framework 3.7.0", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -3762,7 +3762,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -3840,7 +3840,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -4313,7 +4313,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -4682,9 +4682,9 @@ dependencies = [ [[package]] name = "which" -version = "8.0.2" +version = "8.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81995fafaaaf6ae47a7d0cc83c67caf92aeb7e5331650ae6ff856f7c0c60c459" +checksum = "c789537cf2f7f55be8e6192f92e464174ee55f91af622777f7f1ceb0dbccd03e" dependencies = [ "libc", ] @@ -4695,7 +4695,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] From 9efb1de5061944661742ff938bcddc88b61cb6db Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 17 Jun 2026 09:50:25 +0100 Subject: [PATCH 71/91] chore(deps): bump cbindgen from 0.29.3 to 0.29.4 (#1182) Bumps [cbindgen](https://github.com/mozilla/cbindgen) from 0.29.3 to 0.29.4. - [Release notes](https://github.com/mozilla/cbindgen/releases) - [Changelog](https://github.com/mozilla/cbindgen/blob/main/CHANGES) - [Commits](https://github.com/mozilla/cbindgen/compare/0.29.3...0.29.4) --- updated-dependencies: - dependency-name: cbindgen dependency-version: 0.29.4 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Cargo.lock | 34 +++++++++++++++++----------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 657e8eaf2..3744c09e8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -107,7 +107,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -118,7 +118,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -503,9 +503,9 @@ dependencies = [ [[package]] name = "cbindgen" -version = "0.29.3" +version = "0.29.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c95537b45400390270fae69ac098d057c8f5399001cde9d04f700c105ddfff2d" +checksum = "2ecb53484c9c167ba674026b656d8a27d7657a58e6066aa902bfb1a4aa00ae20" dependencies = [ "clap", "heck", @@ -679,7 +679,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -967,7 +967,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -1054,7 +1054,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -1764,7 +1764,7 @@ dependencies = [ "portable-atomic", "portable-atomic-util", "serde_core", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -2238,7 +2238,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -3008,7 +3008,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -3067,7 +3067,7 @@ dependencies = [ "security-framework 3.7.0", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -3088,7 +3088,7 @@ dependencies = [ "security-framework 3.7.0", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -3762,7 +3762,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -3837,10 +3837,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.2", + "getrandom 0.3.4", "once_cell", "rustix", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -4313,7 +4313,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -4695,7 +4695,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] From f2d003b715db5c7bef0b039d9bead1082aa836d6 Mon Sep 17 00:00:00 2001 From: Raphael Nestler Date: Wed, 17 Jun 2026 11:41:56 +0200 Subject: [PATCH 72/91] fix(policy): allow go_runtime to readwrite go-build cache (#1173) * fix(policy) Allow go_runtime to readwrite go-build cache Signed-off-by: Raphael Nestler * Also add macOS go build cache Signed-off-by: Raphael Nestler * fix(policy): split go_runtime cache into platform-specific groups Extract the go build cache paths from go_runtime into separate go_runtime_linux and go_runtime_macos groups, each gated by their respective platform field. This ensures only the correct cache path (~/.cache/go-build vs ~/Library/Caches/go-build) is granted per OS. Signed-off-by: Aleksy Siek --------- Signed-off-by: Raphael Nestler Signed-off-by: Aleksy Siek Co-authored-by: Aleksy Siek --- crates/nono-cli/data/policy.json | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/crates/nono-cli/data/policy.json b/crates/nono-cli/data/policy.json index 11e720260..536d57b25 100644 --- a/crates/nono-cli/data/policy.json +++ b/crates/nono-cli/data/policy.json @@ -413,6 +413,24 @@ ] } }, + "go_runtime_linux": { + "description": "Go build cache on Linux", + "platform": "linux", + "allow": { + "readwrite": [ + "~/.cache/go-build" + ] + } + }, + "go_runtime_macos": { + "description": "Go build cache on macOS", + "platform": "macos", + "allow": { + "readwrite": [ + "~/Library/Caches/go-build" + ] + } + }, "java_runtime": { "description": "Java runtime and build tool paths (SDKMAN, Maven, Gradle)", "allow": { @@ -744,7 +762,7 @@ "author": "always-further" }, "groups": { - "include": ["go_runtime"] + "include": ["go_runtime", "go_runtime_linux", "go_runtime_macos"] }, "security": { "signal_mode": "isolated" From 0143a88925ff206ed0371b8e526e404d4fb6f0ee Mon Sep 17 00:00:00 2001 From: Caio Silva Date: Wed, 17 Jun 2026 12:01:48 +0100 Subject: [PATCH 73/91] fix(proxy): respect upstream_proxy in TLS CONNECT intercept path (#1048) (#1091) The TLS intercept upstream leg (used for credential injection) always connected directly to the target host, ignoring the configured upstream_proxy / external_proxy. In enterprise environments where the target is only reachable through a corporate proxy, this caused the intercepted request to hang or fail. - Added InterceptUpstreamProxy struct and upstream_proxy field to InterceptCtx so the intercept code can select UpstreamStrategy::ExternalProxy when a proxy is configured. - server.rs now checks the bypass matcher and passes the resolved proxy info into InterceptCtx, mirroring the existing transparent CONNECT path. - Auth-not-implemented check (config.auth.is_some()) fails early and consistently with the external proxy handler. - 3 unit tests for strategy selection (ExternalProxy vs Direct). Signed-off-by: Caio Silva Co-authored-by: Luke Hinds Co-authored-by: Aleks <121458075+SequeI@users.noreply.github.com> --- crates/nono-proxy/src/server.rs | 45 ++++++++ crates/nono-proxy/src/tls_intercept/handle.rs | 106 +++++++++++++++++- crates/nono-proxy/src/tls_intercept/mod.rs | 2 +- 3 files changed, 149 insertions(+), 4 deletions(-) diff --git a/crates/nono-proxy/src/server.rs b/crates/nono-proxy/src/server.rs index ee82105f5..1dae50960 100644 --- a/crates/nono-proxy/src/server.rs +++ b/crates/nono-proxy/src/server.rs @@ -851,6 +851,50 @@ async fn handle_connection(mut stream: tokio::net::TcpStream, state: &ProxyState } } + // Decide whether the upstream leg should chain through + // the corporate proxy. Mirrors the bypass logic used for + // transparent CONNECT below. + let upstream_proxy = + if let Some(ref ext_config) = state.config.external_proxy { + let bypassed = !state.bypass_matcher.is_empty() + && state.bypass_matcher.matches(&host); + if bypassed { + debug!("tls_intercept: bypassing upstream proxy for {}", host); + None + } else if ext_config.auth.is_some() { + // Auth is configured but not yet implemented. + // Fail loudly rather than silently connecting + // without auth — the corporate proxy would + // reject anyway. + let msg = "external proxy authentication is configured \ + but not yet implemented; remove the auth \ + section from the external proxy config or \ + wait for a future release"; + audit::log_denied( + Some(&state.audit_log), + audit::ProxyMode::ConnectIntercept, + &audit::EventContext { + route_id, + ..audit::EventContext::default() + }, + &host, + port, + msg, + ); + let response = + "HTTP/1.1 502 Bad Gateway\r\nContent-Length: 0\r\n\r\n"; + stream.write_all(response.as_bytes()).await?; + return Err(ProxyError::ExternalProxy(msg.to_string())); + } else { + Some(tls_intercept::InterceptUpstreamProxy { + proxy_addr: &ext_config.address, + proxy_auth_header: None, + }) + } + } else { + None + }; + let ctx = tls_intercept::InterceptCtx { route_id, host: &host, @@ -862,6 +906,7 @@ async fn handle_connection(mut stream: tokio::net::TcpStream, state: &ProxyState tls_connector: &state.tls_connector, filter: &state.filter, audit_log: Some(&state.audit_log), + upstream_proxy, }; return tls_intercept::handle_intercept_connect(&mut stream, ctx).await; } diff --git a/crates/nono-proxy/src/tls_intercept/handle.rs b/crates/nono-proxy/src/tls_intercept/handle.rs index f10fdfbdb..ac66db123 100644 --- a/crates/nono-proxy/src/tls_intercept/handle.rs +++ b/crates/nono-proxy/src/tls_intercept/handle.rs @@ -32,6 +32,41 @@ use zeroize::Zeroizing; /// memory ceiling consistent. const MAX_HEADER_SIZE: usize = 64 * 1024; +/// Resolved upstream proxy for the intercept path. +/// +/// When `Some`, the upstream leg of the intercepted request must chain +/// through the corporate proxy via CONNECT instead of connecting directly. +/// The caller ([`crate::server::handle_connection`]) is responsible for +/// deciding whether the target host should use the upstream proxy or route +/// direct (based on the bypass list). +pub struct InterceptUpstreamProxy<'a> { + /// `host:port` of the corporate proxy (e.g. `"proxy.corporate.com:80"`). + pub proxy_addr: &'a str, + /// Literal value for `Proxy-Authorization` sent to the corporate proxy, + /// or `None` for unauthenticated proxies. + pub proxy_auth_header: Option<&'a str>, +} + +/// Select the upstream strategy based on whether an upstream proxy is +/// configured for this intercepted request. +/// +/// When `upstream_proxy` is `Some`, returns #[`UpstreamStrategy::ExternalProxy`] +/// to chain through the corporate proxy. Otherwise returns +/// [`UpstreamStrategy::Direct`] with the caller-provided resolved addresses. +pub fn select_upstream_strategy<'a>( + upstream_proxy: &'a Option>, + resolved_addrs: &'a [std::net::SocketAddr], +) -> UpstreamStrategy<'a> { + if let Some(proxy) = upstream_proxy { + UpstreamStrategy::ExternalProxy { + proxy_addr: proxy.proxy_addr, + proxy_auth_header: proxy.proxy_auth_header, + } + } else { + UpstreamStrategy::Direct { resolved_addrs } + } +} + /// Per-connection context passed to [`handle_intercept_connect`]. pub struct InterceptCtx<'a> { pub route_id: Option<&'a str>, @@ -44,6 +79,9 @@ pub struct InterceptCtx<'a> { pub tls_connector: &'a tokio_rustls::TlsConnector, pub filter: &'a ProxyFilter, pub audit_log: Option<&'a audit::SharedAuditLog>, + /// When `Some`, the upstream leg chains through an enterprise proxy + /// instead of connecting directly to the target. + pub upstream_proxy: Option>, } /// Handle a CONNECT request that matched a route requiring L7 visibility. @@ -395,13 +433,12 @@ where let connector = route .and_then(|r| r.tls_connector.as_ref()) .unwrap_or(ctx.tls_connector); + let strategy = select_upstream_strategy(&ctx.upstream_proxy, &check.resolved_addrs); let upstream_spec = UpstreamSpec { scheme: UpstreamScheme::Https, host: ctx.host, port: ctx.port, - strategy: UpstreamStrategy::Direct { - resolved_addrs: &check.resolved_addrs, - }, + strategy, tls_connector: connector, }; let audit_ctx = AuditCtx { @@ -507,4 +544,67 @@ mod tests { assert!(parse_request_line("malformed").is_err()); assert!(parse_request_line("").is_err()); } + + #[test] + fn upstream_strategy_selects_external_proxy_when_configured() { + // When InterceptUpstreamProxy is set, the strategy must be + // ExternalProxy, not Direct. Regression test for #1048. + let proxy = InterceptUpstreamProxy { + proxy_addr: "proxy.corp:80", + proxy_auth_header: None, + }; + let some_proxy = Some(proxy); + let strategy = select_upstream_strategy(&some_proxy, &[]); + match strategy { + UpstreamStrategy::ExternalProxy { + proxy_addr, + proxy_auth_header, + } => { + assert_eq!(proxy_addr, "proxy.corp:80"); + assert!(proxy_auth_header.is_none()); + } + UpstreamStrategy::Direct { .. } => { + panic!("expected ExternalProxy strategy, got Direct"); + } + } + } + + #[test] + fn upstream_strategy_selects_direct_when_no_proxy() { + // When upstream_proxy is None, the strategy must fall back to + // Direct (pre-existing behaviour). + let addrs: Vec = vec![]; + let strategy = select_upstream_strategy(&None, &addrs); + match strategy { + UpstreamStrategy::Direct { resolved_addrs } => { + assert!(resolved_addrs.is_empty()); + } + UpstreamStrategy::ExternalProxy { .. } => { + panic!("expected Direct strategy, got ExternalProxy"); + } + } + } + + #[test] + fn upstream_strategy_external_proxy_with_auth_header() { + // When auth header is provided, it must be carried through. + let proxy = InterceptUpstreamProxy { + proxy_addr: "proxy.corp:3128", + proxy_auth_header: Some("Basic dXNlcjpwYXNz"), + }; + let some_proxy = Some(proxy); + let strategy = select_upstream_strategy(&some_proxy, &[]); + match strategy { + UpstreamStrategy::ExternalProxy { + proxy_addr, + proxy_auth_header, + } => { + assert_eq!(proxy_addr, "proxy.corp:3128"); + assert_eq!(proxy_auth_header, Some("Basic dXNlcjpwYXNz")); + } + UpstreamStrategy::Direct { .. } => { + panic!("expected ExternalProxy strategy, got Direct"); + } + } + } } diff --git a/crates/nono-proxy/src/tls_intercept/mod.rs b/crates/nono-proxy/src/tls_intercept/mod.rs index 2eea91831..bd806bd6f 100644 --- a/crates/nono-proxy/src/tls_intercept/mod.rs +++ b/crates/nono-proxy/src/tls_intercept/mod.rs @@ -40,4 +40,4 @@ pub use acceptor::build_server_config; pub use bundle::{BundleInputs, write_bundle}; pub use ca::EphemeralCa; pub use cert_cache::CertCache; -pub use handle::{InterceptCtx, handle_intercept_connect}; +pub use handle::{InterceptCtx, InterceptUpstreamProxy, handle_intercept_connect}; From c95a0eb0da44778f06c830196c4385e730235ac6 Mon Sep 17 00:00:00 2001 From: Leonardo Zanivan Date: Wed, 17 Jun 2026 09:04:23 -0300 Subject: [PATCH 74/91] fix(proxy): stop allow_domain endpoint route from shadowing credential catch-all (#1132) * fix(proxy): stop _ep_ route from shadowing credential catch-all A credential catch-all (custom_credentials with no endpoint_rules) was shadowed by a credential-less _ep_ endpoint-authorization route on the same upstream: on a path the _ep_ route authorized, selection picked the credential-less _ep_ match ahead of the catch-all and forwarded the request without injecting the managed credential. Partition candidate routes into four buckets (matched_cred, matched_passthrough, catchall_cred, catchall_passthrough). The active credential layer is matched_cred when any credential route matched, otherwise catchall_cred, so a credential catch-all stays in play when only credential-less _ep_ routes matched. Selection then prefers, in order: (1) the single credential route from the active layer, (2) a matched credential-less route (bare endpoint authorization), (3) a credential-less catch-all (passthrough). This injects the token on authorized paths instead of dropping it, and also makes two credential catch-alls for one upstream ambiguous (403) rather than a silent pick. The _ep_ hard-deny gate is unchanged, so non-matching paths are still hard-denied (default-deny preserved) and the host gate is untouched. Extract the selection logic (gate + ambiguity + priority) from tls_intercept/handle.rs into select_route + RouteSelection in route.rs so the decision has a single source of truth shared with its unit tests; the prior test was a hand-copied mirror that did not model the _ep_ gate. Derive Default on RouteConfig so test fixtures can use ..Default::default() (no serde change: prefix/upstream remain required on deserialize). Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Leonardo Zanivan * chore: trigger CI Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Leonardo Zanivan --------- Signed-off-by: Leonardo Zanivan Co-authored-by: Claude Opus 4.8 (1M context) Co-authored-by: Luke Hinds Co-authored-by: Aleks <121458075+SequeI@users.noreply.github.com> --- crates/nono-proxy/src/config.rs | 2 +- crates/nono-proxy/src/route.rs | 230 +++++++++++++++--- crates/nono-proxy/src/tls_intercept/handle.rs | 133 ++++------ 3 files changed, 246 insertions(+), 119 deletions(-) diff --git a/crates/nono-proxy/src/config.rs b/crates/nono-proxy/src/config.rs index bc121ad5e..576356731 100644 --- a/crates/nono-proxy/src/config.rs +++ b/crates/nono-proxy/src/config.rs @@ -174,7 +174,7 @@ fn default_bind_addr() -> IpAddr { } /// Configuration for a reverse proxy credential route. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct RouteConfig { /// Path prefix for routing (e.g., "openai"). /// Must NOT include leading or trailing slashes — it is a bare service name, not a URL path. diff --git a/crates/nono-proxy/src/route.rs b/crates/nono-proxy/src/route.rs index 20067f818..11832b4e7 100644 --- a/crates/nono-proxy/src/route.rs +++ b/crates/nono-proxy/src/route.rs @@ -307,6 +307,101 @@ impl RouteStore { } } +/// Outcome of route selection for an intercepted request, shared by +/// `tls_intercept::handle` and tests so the decision has a single source of +/// truth (the caller maps each variant to its HTTP/audit response). +#[derive(Debug)] +pub(crate) enum RouteSelection<'a> { + /// An `_ep_` endpoint-authorization route exists for the upstream but no + /// rule matched: hard-deny (403). Gates access before credential selection. + EndpointDenied, + /// More than one credential route matched the request: ambiguous (403). + /// Carries the matched route prefixes for the diagnostic. + Ambiguous(Vec<&'a str>), + /// A route was selected (`Some`) or the request is an un-credentialed + /// passthrough (`None`). + Selected(Option<(&'a str, &'a LoadedRoute)>), +} + +/// Select the route for an intercepted request from `candidates` sharing one +/// upstream, applying the endpoint-authorization gate, the ambiguity check, and +/// credential-first priority. +/// +/// Candidates are partitioned into four buckets by whether their endpoint rules +/// matched this request and whether they carry a managed credential: +/// * `matched_cred` / `matched_passthrough` — endpoint rules matched, +/// * `catchall_cred` / `catchall_passthrough` — no endpoint rules (every path). +/// +/// The *credential layer* is `matched_cred` when any credential route matched, +/// otherwise `catchall_cred` (so a credential catch-all is still in play when +/// only credential-less `_ep_` routes matched). Two credential routes in the +/// active layer are ambiguous (the proxy must not silently pick one). Otherwise +/// selection prefers, in order: +/// 1. the single credential route from the active layer — a credential catch-all +/// thus wins over a credential-less `_ep_` match so the managed token is +/// injected rather than silently dropped, +/// 2. a matched credential-less route (bare endpoint authorization), +/// 3. a credential-less catch-all (un-credentialed passthrough). +#[must_use] +pub(crate) fn select_route<'a>( + candidates: &'a [(&'a str, &'a LoadedRoute)], + method: &str, + path: &str, +) -> RouteSelection<'a> { + let mut matched_cred: Vec<(&str, &LoadedRoute)> = Vec::new(); + let mut matched_passthrough: Vec<(&str, &LoadedRoute)> = Vec::new(); + let mut catchall_cred: Vec<(&str, &LoadedRoute)> = Vec::new(); + let mut catchall_passthrough: Vec<(&str, &LoadedRoute)> = Vec::new(); + let mut has_endpoint_only_route = false; + let mut endpoint_authorized = false; + for (prefix, route) in candidates { + if route.endpoint_rules.is_empty() { + if route.requires_managed_credential { + catchall_cred.push((prefix, route)); + } else { + catchall_passthrough.push((prefix, route)); + } + } else if route.endpoint_rules.is_allowed(method, path) { + if route.requires_managed_credential { + matched_cred.push((prefix, route)); + } else { + matched_passthrough.push((prefix, route)); + endpoint_authorized = true; + } + } else if !route.requires_managed_credential { + has_endpoint_only_route = true; + } + } + + // Endpoint-only authorization layer: a credential catch-all cannot bypass + // endpoint restrictions imposed by `_ep_` routes. + if has_endpoint_only_route && !endpoint_authorized { + return RouteSelection::EndpointDenied; + } + + // Ambiguity applies only to credential-injection routes within the active + // layer; multiple endpoint-only authorization routes matching is fine (they + // all just allow). This catches both overlapping endpoint credential routes + // and overlapping credential catch-alls (e.g. two equally-specific wildcard + // upstreams). + let credential_layer: &[(&str, &LoadedRoute)] = if matched_cred.is_empty() { + &catchall_cred + } else { + &matched_cred + }; + if credential_layer.len() > 1 { + let names = credential_layer.iter().map(|(p, _)| *p).collect(); + return RouteSelection::Ambiguous(names); + } + + let selected = credential_layer + .first() + .copied() + .or_else(|| matched_passthrough.first().copied()) + .or_else(|| catchall_passthrough.first().copied()); + RouteSelection::Selected(selected) +} + impl LoadedRoute { /// Whether this route is configured to require a proxy-managed credential /// but the credential material is currently unavailable. @@ -951,8 +1046,30 @@ mod tests { assert!(store.lookup_all_by_upstream("other.com:443").is_empty()); } - /// Models a real multi-org GitHub profile. Mirrors the selection - /// loop in `tls_intercept::handle`: + #[derive(Debug, PartialEq)] + enum Selection<'a> { + Route(&'a str), + Passthrough, + Ambiguous(Vec<&'a str>), + EndpointDenied, + } + + /// Thin adapter over the real `select_route` so tests exercise the shipping + /// decision rather than a mirror, flattening its outcome to a comparable enum. + fn select<'a>( + candidates: &'a [(&'a str, &'a LoadedRoute)], + method: &str, + path: &str, + ) -> Selection<'a> { + match select_route(candidates, method, path) { + RouteSelection::EndpointDenied => Selection::EndpointDenied, + RouteSelection::Ambiguous(names) => Selection::Ambiguous(names), + RouteSelection::Selected(Some((svc, _))) => Selection::Route(svc), + RouteSelection::Selected(None) => Selection::Passthrough, + } + } + + /// Models a real multi-org GitHub profile. Exercises `select_route`: /// 1 match → inject that route's credential /// 0 matches → passthrough (no credential injected) /// 2+ matches → ambiguous (hard-deny 403) @@ -983,38 +1100,6 @@ mod tests { } } - #[derive(Debug, PartialEq)] - enum Selection<'a> { - Route(&'a str), - Passthrough, - Ambiguous(Vec<&'a str>), - } - - fn select<'a>( - candidates: &'a [(&'a str, &'a LoadedRoute)], - method: &str, - path: &str, - ) -> Selection<'a> { - let mut matches: Vec<&str> = Vec::new(); - let mut catch_all: Option<&str> = None; - for (prefix, route) in candidates { - if route.endpoint_rules.is_empty() { - if catch_all.is_none() { - catch_all = Some(*prefix); - } - } else if route.endpoint_rules.is_allowed(method, path) { - matches.push(prefix); - } - } - if matches.len() > 1 { - Selection::Ambiguous(matches) - } else if let Some(svc) = matches.into_iter().next().or(catch_all) { - Selection::Route(svc) - } else { - Selection::Passthrough - } - } - // --- Profile: two org-scoped routes, no catch-all --- let routes = vec![ gh_route("github_https_org_a", "GH_TOKEN_A", "/org-a/**"), @@ -1071,6 +1156,83 @@ mod tests { ); } + /// A credential-less `_ep_` authorization route (from `allow_domain` with + /// endpoints) must not shadow a credential catch-all on a path the `_ep_` + /// route authorizes — the token has to be injected, not silently dropped. + #[test] + fn test_route_selection_credential_catchall_not_shadowed() { + // `_ep_` endpoint-authorization route: no credential, scoped to /org/**. + let ep_route = RouteConfig { + prefix: "_ep_github.com".to_string(), + upstream: "https://github.com".to_string(), + endpoint_rules: vec![crate::config::EndpointRule { + method: "*".to_string(), + path: "/org/**".to_string(), + }], + ..Default::default() + }; + // Credential catch-all: carries a token, no endpoint rules. + let cred_route = RouteConfig { + prefix: "github_api".to_string(), + upstream: "https://github.com".to_string(), + credential_key: Some("env://GH_TOKEN".to_string()), + credential_format: Some("Bearer {}".to_string()), + env_var: Some("GH_TOKEN".to_string()), + ..Default::default() + }; + + let store = RouteStore::load(&[ep_route, cred_route]).unwrap(); + let candidates = store.lookup_all_by_upstream("github.com:443"); + assert_eq!(candidates.len(), 2); + + // Authorized path (matches the _ep_ rule): the credential catch-all must + // win so the token is injected — not the credential-less _ep_ match. + assert_eq!( + select(&candidates, "GET", "/org/repo"), + Selection::Route("github_api"), + "credential catch-all must be selected on the _ep_-authorized path" + ); + // Non-matching path: the _ep_ gate hard-denies before the catch-all is + // reached, so the catch-all cannot bypass endpoint restrictions. + assert_eq!( + select(&candidates, "GET", "/other/repo"), + Selection::EndpointDenied + ); + } + + /// Two credential catch-alls for the same upstream are ambiguous: the proxy + /// must not silently pick one to inject, just as with overlapping endpoint + /// credential routes. + #[test] + fn test_route_selection_dual_credential_catchall_is_ambiguous() { + let cred_a = RouteConfig { + prefix: "github_a".to_string(), + upstream: "https://github.com".to_string(), + credential_key: Some("env://GH_TOKEN_A".to_string()), + credential_format: Some("Bearer {}".to_string()), + env_var: Some("GH_TOKEN_A".to_string()), + ..Default::default() + }; + let cred_b = RouteConfig { + prefix: "github_b".to_string(), + upstream: "https://github.com".to_string(), + credential_key: Some("env://GH_TOKEN_B".to_string()), + credential_format: Some("Bearer {}".to_string()), + env_var: Some("GH_TOKEN_B".to_string()), + ..Default::default() + }; + + let store = RouteStore::load(&[cred_a, cred_b]).unwrap(); + let candidates = store.lookup_all_by_upstream("github.com:443"); + assert_eq!(candidates.len(), 2); + + // Both credential catch-alls cover every path → ambiguous, not a silent pick. + assert_eq!( + select(&candidates, "GET", "/any/path"), + Selection::Ambiguous(vec!["github_a", "github_b"]) + ); + } + /// Self-signed CA for testing. Generated with: /// openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 \ /// -keyout /dev/null -nodes -days 36500 -subj '/CN=nono-test-ca' -out - diff --git a/crates/nono-proxy/src/tls_intercept/handle.rs b/crates/nono-proxy/src/tls_intercept/handle.rs index ac66db123..24f1b7cc2 100644 --- a/crates/nono-proxy/src/tls_intercept/handle.rs +++ b/crates/nono-proxy/src/tls_intercept/handle.rs @@ -214,91 +214,56 @@ where return Ok(()); } - let mut matches: Vec<(&str, &crate::route::LoadedRoute)> = Vec::new(); - let mut catch_all: Option<(&str, &crate::route::LoadedRoute)> = None; - let mut has_endpoint_only_route = false; - let mut endpoint_authorized = false; - for (prefix, route) in &candidates { - if route.endpoint_rules.is_empty() { - if catch_all.is_none() { - catch_all = Some((prefix, route)); - } - } else if route.endpoint_rules.is_allowed(&method, &path) { - matches.push((prefix, route)); - if !route.requires_managed_credential { - endpoint_authorized = true; - } - } else if !route.requires_managed_credential { - has_endpoint_only_route = true; + // Route selection (endpoint-authorization gate, ambiguity check, and + // credential-first priority) lives in `route::select_route` so it has a + // single source of truth shared with its unit tests. + let selected = match crate::route::select_route(&candidates, &method, &path) { + crate::route::RouteSelection::EndpointDenied => { + let reason = format!( + "endpoint rules denied {} {}: no rule matched on {}:{}", + method, path, ctx.host, ctx.port + ); + warn!("tls_intercept: {}", reason); + audit::log_denied( + ctx.audit_log, + audit::ProxyMode::ConnectIntercept, + &audit::EventContext { + denial_category: Some(nono::undo::NetworkAuditDenialCategory::EndpointPolicy), + ..audit::EventContext::default() + }, + ctx.host, + ctx.port, + &reason, + ); + reverse::send_error_generic(tls_stream, 403, "Forbidden").await?; + return Ok(()); } - } - - // Endpoint-only authorization layer (from allow_domain with endpoints): - // if any _ep_ route exists for this upstream, the request must match at - // least one of their endpoint rules. This gates access BEFORE credential - // selection — a credential catch-all cannot bypass endpoint restrictions. - if has_endpoint_only_route && !endpoint_authorized { - let reason = format!( - "endpoint rules denied {} {}: no rule matched on {}:{}", - method, path, ctx.host, ctx.port - ); - warn!("tls_intercept: {}", reason); - audit::log_denied( - ctx.audit_log, - audit::ProxyMode::ConnectIntercept, - &audit::EventContext { - denial_category: Some(nono::undo::NetworkAuditDenialCategory::EndpointPolicy), - ..audit::EventContext::default() - }, - ctx.host, - ctx.port, - &reason, - ); - reverse::send_error_generic(tls_stream, 403, "Forbidden").await?; - return Ok(()); - } - - // Ambiguous route check only applies to credential-injection routes. - // Multiple endpoint-only authorization routes matching the same request - // is fine (they all just allow it); ambiguity is a problem only when the - // proxy must choose which credential to inject. - let credential_matches: Vec<_> = matches - .iter() - .filter(|(_, route)| route.requires_managed_credential) - .collect(); - if credential_matches.len() > 1 { - let names: Vec<_> = credential_matches.iter().map(|(p, _)| *p).collect(); - let reason = format!( - "ambiguous route: {} {} matched {} credential routes: {:?}. \ - Narrow endpoint_rules so each request matches exactly one route.", - method, - path, - credential_matches.len(), - names - ); - warn!("tls_intercept: {}", reason); - audit::log_denied( - ctx.audit_log, - audit::ProxyMode::ConnectIntercept, - &audit::EventContext { - denial_category: Some(nono::undo::NetworkAuditDenialCategory::EndpointPolicy), - ..audit::EventContext::default() - }, - ctx.host, - ctx.port, - &reason, - ); - reverse::send_error_generic(tls_stream, 403, "Forbidden").await?; - return Ok(()); - } - - // Prefer the credential route over endpoint-only authorization routes. - let selected = matches - .iter() - .find(|(_, route)| route.requires_managed_credential) - .or(matches.first()) - .copied() - .or(catch_all); + crate::route::RouteSelection::Ambiguous(names) => { + let reason = format!( + "ambiguous route: {} {} matched {} credential routes: {:?}. \ + Narrow endpoint_rules so each request matches exactly one route.", + method, + path, + names.len(), + names + ); + warn!("tls_intercept: {}", reason); + audit::log_denied( + ctx.audit_log, + audit::ProxyMode::ConnectIntercept, + &audit::EventContext { + denial_category: Some(nono::undo::NetworkAuditDenialCategory::EndpointPolicy), + ..audit::EventContext::default() + }, + ctx.host, + ctx.port, + &reason, + ); + reverse::send_error_generic(tls_stream, 403, "Forbidden").await?; + return Ok(()); + } + crate::route::RouteSelection::Selected(selected) => selected, + }; let service: Option<&str> = selected.map(|(s, _)| s); let route: Option<&crate::route::LoadedRoute> = selected.map(|(_, r)| r); match service { From 6f52d3fe99651f7bf239a6f4cc65362b45e3755b Mon Sep 17 00:00:00 2001 From: Luke Hinds Date: Wed, 17 Jun 2026 16:35:23 +0100 Subject: [PATCH 75/91] refactor(audit): move attestation logic to core library (#1148) * refactor(audit): move attestation logic to core library Move audit attestation signing and verification implementation from `nono-cli` into the `nono` core library. - Consolidate `AuditAttestationVerificationResult` struct, `AuditAttestationPredicate` structs, and related constants. - Extract `write_audit_attestation` and `verify_audit_attestation` implementations into new `sign_audit_attestation_bundle` and `verify_audit_attestation_bundle` functions in `nono::audit`. - `nono-cli` now serves as a thin wrapper around these core functions. This change centralizes the core logic, making it reusable and improving the separation of concerns between the CLI and library. Signed-off-by: Luke Hinds * refactor(audit): improve attestation statement parsing Streamlines the `extract_audit_attestation_statement` function by directly accessing the DSSE envelope from the Sigstore bundle's `content` field. This eliminates redundant JSON serialization and deserialization steps, making the extraction process more efficient and leveraging the `SignatureContent` enum. The `zeroize::Zeroizing` wrapper is also removed from `scrubbed_command`. Command arguments are not treated as sensitive secrets requiring zeroization. Signed-off-by: Luke Hinds --------- Signed-off-by: Luke Hinds Co-authored-by: Aleks <121458075+SequeI@users.noreply.github.com> --- crates/nono-cli/src/audit_attestation.rs | 309 ++------------ crates/nono/src/audit.rs | 495 +++++++++++++++++++++++ 2 files changed, 531 insertions(+), 273 deletions(-) diff --git a/crates/nono-cli/src/audit_attestation.rs b/crates/nono-cli/src/audit_attestation.rs index a45b74089..5d5fd3b0a 100644 --- a/crates/nono-cli/src/audit_attestation.rs +++ b/crates/nono-cli/src/audit_attestation.rs @@ -1,15 +1,12 @@ use crate::trust_cmd; use nono::trust; -use nono::undo::{AuditAttestationSummary, ContentHash, SessionMetadata}; +use nono::undo::{AuditAttestationSummary, SessionMetadata}; use nono::{NonoError, Result}; -use serde::Serialize; use std::fs; use std::path::Path; use zeroize::Zeroizing; -pub(crate) const AUDIT_ATTESTATION_BUNDLE_FILENAME: &str = "audit-attestation.bundle"; -pub(crate) const AUDIT_ATTESTATION_PREDICATE_TYPE_ALPHA: &str = - "https://nono.sh/attestation/audit-session/alpha"; +pub(crate) use nono::audit::AUDIT_ATTESTATION_BUNDLE_FILENAME; const KEYSTORE_URI_PREFIX: &str = "keystore://"; pub(crate) struct AuditSigner { @@ -29,45 +26,8 @@ pub(crate) fn signer_from_key_pair(key_pair: trust::KeyPair) -> Result, - pub(crate) key_id: Option, - pub(crate) key_id_matches: bool, - pub(crate) signature_verified: bool, - pub(crate) merkle_root_matches: bool, - pub(crate) session_id_matches: bool, - pub(crate) expected_public_key_matches: Option, - pub(crate) verification_error: Option, -} - -#[derive(Serialize)] -struct AuditAttestationPredicate<'a> { - version: u32, - session_id: &'a str, - started: &'a str, - ended: &'a Option, - command: &'a [String], - #[serde(skip_serializing_if = "Option::is_none")] - redaction_policy: Option, - audit_log: AuditLogPredicate<'a>, - signer: AuditSignerPredicate<'a>, -} - -#[derive(Serialize)] -struct AuditLogPredicate<'a> { - hash_algorithm: &'a str, - event_count: u64, - chain_head: &'a ContentHash, - merkle_root: &'a ContentHash, -} - -#[derive(Serialize)] -struct AuditSignerPredicate<'a> { - kind: &'static str, - key_id: &'a str, -} +pub(crate) type AuditAttestationVerificationResult = + nono::audit::AuditAttestationVerificationResult; pub(crate) fn prepare_audit_signer(secret_ref: Option<&str>) -> Result> { let Some(secret_ref) = secret_ref.filter(|value| !value.trim().is_empty()) else { @@ -101,57 +61,20 @@ pub(crate) fn write_audit_attestation( signer: &AuditSigner, redaction_policy: &nono::ScrubPolicy, ) -> Result { - let integrity = metadata - .audit_integrity - .as_ref() - .ok_or_else(|| NonoError::TrustSigning { - path: session_dir.display().to_string(), - reason: "audit attestation requires audit integrity to be enabled".to_string(), - })?; - - let scrubbed_command = nono::scrub_argv_with_policy(&metadata.command, redaction_policy); - let predicate = serde_json::to_value(AuditAttestationPredicate { - version: 1, - session_id: &metadata.session_id, - started: &metadata.started, - ended: &metadata.ended, - command: &scrubbed_command, - redaction_policy: redaction_policy.diff_from_secure_default().into_option(), - audit_log: AuditLogPredicate { - hash_algorithm: &integrity.hash_algorithm, - event_count: integrity.event_count, - chain_head: &integrity.chain_head, - merkle_root: &integrity.merkle_root, - }, - signer: AuditSignerPredicate { - kind: "keyed", - key_id: &signer.key_id, - }, - }) - .map_err(|e| NonoError::TrustSigning { - path: session_dir.display().to_string(), - reason: format!("failed to serialize audit attestation predicate: {e}"), - })?; - - let statement = trust::new_statement( - &format!("audit-session:{}", metadata.session_id), - &integrity.merkle_root.to_string(), - predicate, - AUDIT_ATTESTATION_PREDICATE_TYPE_ALPHA, - ); - let bundle_json = trust::sign_statement_bundle(&statement, &signer.key_pair)?; + let (bundle_json, summary) = nono::audit::sign_audit_attestation_bundle( + metadata, + &signer.key_pair, + &signer.key_id, + &signer.public_key_b64, + redaction_policy, + )?; let bundle_path = session_dir.join(AUDIT_ATTESTATION_BUNDLE_FILENAME); fs::write(&bundle_path, bundle_json).map_err(|e| NonoError::TrustSigning { path: bundle_path.display().to_string(), reason: format!("failed to write audit attestation bundle: {e}"), })?; - Ok(AuditAttestationSummary { - predicate_type: AUDIT_ATTESTATION_PREDICATE_TYPE_ALPHA.to_string(), - key_id: signer.key_id.clone(), - public_key: signer.public_key_b64.clone(), - bundle_filename: AUDIT_ATTESTATION_BUNDLE_FILENAME.to_string(), - }) + Ok(summary) } pub(crate) fn verify_audit_attestation( @@ -160,7 +83,7 @@ pub(crate) fn verify_audit_attestation( expected_public_key_file: Option<&Path>, ) -> Result { let Some(summary) = metadata.audit_attestation.as_ref() else { - return Ok(AuditAttestationVerificationResult { + return Ok(nono::audit::AuditAttestationVerificationResult { present: false, predicate_type: None, key_id: None, @@ -177,194 +100,33 @@ pub(crate) fn verify_audit_attestation( }), }); }; - - let Some(integrity) = metadata.audit_integrity.as_ref() else { - return Ok(attestation_failure( - summary, - expected_public_key_file.map(|_| true), - "session has audit attestation metadata but no audit integrity summary".to_string(), - )); - }; let bundle_path = session_dir.join(&summary.bundle_filename); let bundle = match trust::load_bundle(&bundle_path) { Ok(bundle) => bundle, Err(err) => { - return Ok(attestation_failure( - summary, - expected_public_key_file.map(|_| true), - err.to_string(), - )); - } - }; - let predicate_type = match trust::extract_predicate_type(&bundle, &bundle_path) { - Ok(predicate_type) => predicate_type, - Err(err) => { - return Ok(attestation_failure( - summary, - expected_public_key_file.map(|_| true), - err.to_string(), - )); - } - }; - if predicate_type != AUDIT_ATTESTATION_PREDICATE_TYPE_ALPHA { - return Ok(attestation_failure( - summary, - expected_public_key_file.map(|_| true), - format!( - "wrong bundle type: expected {}, got {}", - AUDIT_ATTESTATION_PREDICATE_TYPE_ALPHA, predicate_type - ), - )); - } - - let signer_identity = match trust::extract_signer_identity(&bundle, &bundle_path) { - Ok(identity) => identity, - Err(err) => { - return Ok(attestation_failure( - summary, - expected_public_key_file.map(|_| true), - err.to_string(), - )); - } - }; - let signer_key_id = match signer_identity { - trust::SignerIdentity::Keyed { key_id } => key_id, - trust::SignerIdentity::Keyless { .. } => { - return Ok(attestation_failure( - summary, - expected_public_key_file.map(|_| true), - "audit attestation must be keyed".to_string(), - )); - } - }; - let public_key_der = match trust::base64::base64_decode(&summary.public_key) { - Ok(public_key_der) => public_key_der, - Err(err) => { - return Ok(attestation_failure( - summary, - expected_public_key_file.map(|_| true), - format!("invalid attested public key encoding: {err}"), - )); + return Ok(nono::audit::AuditAttestationVerificationResult { + present: true, + predicate_type: Some(summary.predicate_type.clone()), + key_id: Some(summary.key_id.clone()), + key_id_matches: false, + signature_verified: false, + merkle_root_matches: false, + session_id_matches: false, + expected_public_key_matches: None, + verification_error: Some(err.to_string()), + }); } }; - let recomputed_key_id = trust::public_key_id_hex(&public_key_der); - if recomputed_key_id != summary.key_id { - return Ok(attestation_failure( - summary, - expected_public_key_file.map(|_| true), - format!( - "audit attestation metadata key mismatch: expected {}, got {}", - summary.key_id, recomputed_key_id - ), - )); - } - if signer_key_id != summary.key_id { - return Ok(attestation_failure( - summary, - expected_public_key_file.map(|_| true), - format!( - "audit attestation signer key mismatch: expected {}, got {}", - summary.key_id, signer_key_id - ), - )); - } - if let Some(public_key_file) = expected_public_key_file { - let expected_public_key = load_public_key_file(public_key_file)?; - if expected_public_key != public_key_der { - return Ok(attestation_failure( - summary, - Some(false), - "provided public key does not match the attested signer key".to_string(), - )); - } - } - if let Err(err) = trust::verify_keyed_signature(&bundle, &public_key_der, &bundle_path) { - return Ok(attestation_failure( - summary, - expected_public_key_file.map(|_| true), - err.to_string(), - )); - } - - let attested_root = match trust::extract_bundle_digest(&bundle, &bundle_path) { - Ok(attested_root) => attested_root, - Err(err) => { - return Ok(attestation_failure( - summary, - expected_public_key_file.map(|_| true), - err.to_string(), - )); - } - }; - if attested_root != integrity.merkle_root.to_string() { - return Ok(attestation_failure( - summary, - expected_public_key_file.map(|_| true), - "audit attestation Merkle root does not match session integrity summary".to_string(), - )); - } - - let statement = match extract_statement(&bundle) { - Ok(statement) => statement, - Err(err) => { - return Ok(attestation_failure( - summary, - expected_public_key_file.map(|_| true), - err.to_string(), - )); - } - }; - let Some(statement_session_id) = statement - .predicate - .get("session_id") - .and_then(|value| value.as_str()) - else { - return Ok(attestation_failure( - summary, - expected_public_key_file.map(|_| true), - "audit attestation predicate missing session_id".to_string(), - )); - }; - if statement_session_id != metadata.session_id { - return Ok(attestation_failure( - summary, - expected_public_key_file.map(|_| true), - format!( - "audit attestation session_id mismatch: expected {}, got {}", - metadata.session_id, statement_session_id - ), - )); - } - - Ok(AuditAttestationVerificationResult { - present: true, - predicate_type: Some(predicate_type), - key_id: Some(summary.key_id.clone()), - key_id_matches: true, - signature_verified: true, - merkle_root_matches: true, - session_id_matches: true, - expected_public_key_matches: expected_public_key_file.map(|_| true), - verification_error: None, - }) -} - -fn attestation_failure( - summary: &AuditAttestationSummary, - expected_public_key_matches: Option, - verification_error: String, -) -> AuditAttestationVerificationResult { - AuditAttestationVerificationResult { - present: true, - predicate_type: Some(summary.predicate_type.clone()), - key_id: Some(summary.key_id.clone()), - key_id_matches: false, - signature_verified: false, - merkle_root_matches: false, - session_id_matches: false, - expected_public_key_matches, - verification_error: Some(verification_error), - } + let expected_public_key = expected_public_key_file + .map(load_public_key_file) + .transpose()?; + + nono::audit::verify_audit_attestation_bundle( + &bundle, + &bundle_path, + metadata, + expected_public_key.as_deref(), + ) } fn normalize_signing_secret_ref(secret_ref: &str) -> String { @@ -374,6 +136,7 @@ fn normalize_signing_secret_ref(secret_ref: &str) -> String { .to_string() } +#[cfg(test)] fn extract_statement(bundle: &trust::Bundle) -> Result { let bundle_json = bundle.to_json().map_err(|e| NonoError::TrustVerification { path: String::new(), @@ -428,7 +191,7 @@ fn load_public_key_file(path: &Path) -> Result> { #[allow(clippy::unwrap_used)] mod tests { use super::*; - use nono::undo::AuditIntegritySummary; + use nono::undo::{AuditIntegritySummary, ContentHash}; use std::path::PathBuf; const TEST_SIGNING_KEY_PEM: &str = "\ diff --git a/crates/nono/src/audit.rs b/crates/nono/src/audit.rs index 260597e48..dd240ca36 100644 --- a/crates/nono/src/audit.rs +++ b/crates/nono/src/audit.rs @@ -6,12 +6,14 @@ //! and Merkle root. use crate::supervisor::{AuditEntry, UrlOpenRequest}; +use crate::trust; use crate::undo::{ AuditAttestationSummary, AuditIntegritySummary, ContentHash, NetworkAuditEvent, SessionMetadata, }; use crate::{NonoError, Result}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; +use sigstore_verify::types::bundle::SignatureContent; use std::fs::{File, OpenOptions}; use std::io::{BufRead, BufReader, Seek, SeekFrom, Write}; #[cfg(unix)] @@ -35,6 +37,11 @@ pub const AUDIT_HASH_ALGORITHM: &str = "sha256"; pub const SESSION_DIGEST_DOMAIN_ALPHA: &[u8] = b"nono.audit.session-digest.alpha\n"; /// Domain separator for alpha ledger chain links. pub const LEDGER_CHAIN_DOMAIN_ALPHA: &[u8] = b"nono.audit.ledger.chain.alpha\n"; +/// Default filename used for audit attestation bundles in session directories. +pub const AUDIT_ATTESTATION_BUNDLE_FILENAME: &str = "audit-attestation.bundle"; +/// Predicate type for alpha audit session attestations. +pub const AUDIT_ATTESTATION_PREDICATE_TYPE_ALPHA: &str = + "https://nono.sh/attestation/audit-session/alpha"; /// Event payloads written into the alpha audit log. #[derive(Clone, Serialize, Deserialize)] @@ -188,6 +195,56 @@ pub struct LedgerVerificationResult { pub ledger_head: Option, } +/// Result of checking a signed audit attestation bundle against session metadata. +#[derive(Debug, Clone, Serialize)] +pub struct AuditAttestationVerificationResult { + /// Whether session metadata referenced an audit attestation. + pub present: bool, + /// Predicate type recorded in metadata or the verified bundle. + pub predicate_type: Option, + /// Signer key identifier from the attestation metadata. + pub key_id: Option, + /// Whether metadata, bundle signer identity, and public key digest agree. + pub key_id_matches: bool, + /// Whether the DSSE signature verified with the attested public key. + pub signature_verified: bool, + /// Whether the signed Merkle root matches the session integrity summary. + pub merkle_root_matches: bool, + /// Whether the signed predicate session ID matches the session metadata. + pub session_id_matches: bool, + /// Whether an externally provided public key matches the attested public key. + pub expected_public_key_matches: Option, + /// Human-readable verification failure, if verification did not succeed. + pub verification_error: Option, +} + +#[derive(Serialize)] +struct AuditAttestationPredicate<'a> { + version: u32, + session_id: &'a str, + started: &'a str, + ended: &'a Option, + command: &'a [String], + #[serde(skip_serializing_if = "Option::is_none")] + redaction_policy: Option, + audit_log: AuditLogPredicate<'a>, + signer: AuditSignerPredicate<'a>, +} + +#[derive(Serialize)] +struct AuditLogPredicate<'a> { + hash_algorithm: &'a str, + event_count: u64, + chain_head: &'a ContentHash, + merkle_root: &'a ContentHash, +} + +#[derive(Serialize)] +struct AuditSignerPredicate<'a> { + kind: &'static str, + key_id: &'a str, +} + /// Position of a sibling hash in an audit Merkle inclusion proof. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] @@ -732,6 +789,358 @@ pub fn verify_session_in_ledger_reader( }) } +/// Build and sign an alpha audit attestation bundle for a completed session. +/// +/// The caller owns key loading and bundle storage. This primitive commits to +/// the audit Merkle root, rolling chain head, event count, session identity, +/// and scrubbed command context, then signs the in-toto statement as DSSE. +pub fn sign_audit_attestation_bundle( + metadata: &SessionMetadata, + key_pair: &trust::KeyPair, + key_id: &str, + public_key_b64: &str, + redaction_policy: &crate::ScrubPolicy, +) -> Result<(String, AuditAttestationSummary)> { + let integrity = metadata + .audit_integrity + .as_ref() + .ok_or_else(|| NonoError::TrustSigning { + path: metadata.session_id.clone(), + reason: "audit attestation requires audit integrity to be enabled".to_string(), + })?; + + let scrubbed_command = crate::scrub_argv_with_policy(&metadata.command, redaction_policy); + let predicate = serde_json::to_value(AuditAttestationPredicate { + version: 1, + session_id: &metadata.session_id, + started: &metadata.started, + ended: &metadata.ended, + command: &scrubbed_command, + redaction_policy: redaction_policy.diff_from_secure_default().into_option(), + audit_log: AuditLogPredicate { + hash_algorithm: &integrity.hash_algorithm, + event_count: integrity.event_count, + chain_head: &integrity.chain_head, + merkle_root: &integrity.merkle_root, + }, + signer: AuditSignerPredicate { + kind: "keyed", + key_id, + }, + }) + .map_err(|e| NonoError::TrustSigning { + path: metadata.session_id.clone(), + reason: format!("failed to serialize audit attestation predicate: {e}"), + })?; + + let statement = trust::new_statement( + &format!("audit-session:{}", metadata.session_id), + &integrity.merkle_root.to_string(), + predicate, + AUDIT_ATTESTATION_PREDICATE_TYPE_ALPHA, + ); + let bundle_json = trust::sign_statement_bundle(&statement, key_pair)?; + + Ok(( + bundle_json, + AuditAttestationSummary { + predicate_type: AUDIT_ATTESTATION_PREDICATE_TYPE_ALPHA.to_string(), + key_id: key_id.to_string(), + public_key: public_key_b64.to_string(), + bundle_filename: AUDIT_ATTESTATION_BUNDLE_FILENAME.to_string(), + }, + )) +} + +/// Verify an alpha audit attestation bundle against session metadata. +/// +/// The caller is responsible for loading the bundle and any externally pinned +/// public key. This function validates the keyed DSSE signature, key identity, +/// signed Merkle root, and signed session ID. Supplying `expected_public_key` +/// is what gives the result an external trust anchor; without it, verification +/// proves only that the bundle, metadata summary, and embedded public key are +/// internally self-consistent. +pub fn verify_audit_attestation_bundle( + bundle: &trust::Bundle, + bundle_path: &Path, + metadata: &SessionMetadata, + expected_public_key: Option<&[u8]>, +) -> Result { + let Some(summary) = metadata.audit_attestation.as_ref() else { + return Ok(AuditAttestationVerificationResult { + present: false, + predicate_type: None, + key_id: None, + key_id_matches: false, + signature_verified: false, + merkle_root_matches: false, + session_id_matches: false, + expected_public_key_matches: expected_public_key.map(|_| false), + verification_error: expected_public_key.map(|_| { + "session has no audit attestation to verify against provided public key".to_string() + }), + }); + }; + + let mut expected_public_key_matches = None; + + let Some(integrity) = metadata.audit_integrity.as_ref() else { + return Ok(attestation_failure( + summary, + expected_public_key_matches, + "session has audit attestation metadata but no audit integrity summary".to_string(), + )); + }; + + let predicate_type = match trust::extract_predicate_type(bundle, bundle_path) { + Ok(predicate_type) => predicate_type, + Err(err) => { + return Ok(attestation_failure( + summary, + expected_public_key_matches, + err.to_string(), + )); + } + }; + if predicate_type != AUDIT_ATTESTATION_PREDICATE_TYPE_ALPHA { + return Ok(attestation_failure( + summary, + expected_public_key_matches, + format!( + "wrong bundle type: expected {}, got {}", + AUDIT_ATTESTATION_PREDICATE_TYPE_ALPHA, predicate_type + ), + )); + } + + let signer_identity = match trust::extract_signer_identity(bundle, bundle_path) { + Ok(identity) => identity, + Err(err) => { + return Ok(attestation_failure( + summary, + expected_public_key_matches, + err.to_string(), + )); + } + }; + let signer_key_id = match signer_identity { + trust::SignerIdentity::Keyed { key_id } => key_id, + trust::SignerIdentity::Keyless { .. } => { + return Ok(attestation_failure( + summary, + expected_public_key_matches, + "audit attestation must be keyed".to_string(), + )); + } + }; + let public_key_der = match trust::base64::base64_decode(&summary.public_key) { + Ok(public_key_der) => public_key_der, + Err(err) => { + return Ok(attestation_failure( + summary, + expected_public_key_matches, + format!("invalid attested public key encoding: {err}"), + )); + } + }; + let recomputed_key_id = trust::public_key_id_hex(&public_key_der); + if recomputed_key_id != summary.key_id { + return Ok(attestation_failure( + summary, + expected_public_key_matches, + format!( + "audit attestation metadata key mismatch: expected {}, got {}", + summary.key_id, recomputed_key_id + ), + )); + } + if signer_key_id != summary.key_id { + return Ok(attestation_failure( + summary, + expected_public_key_matches, + format!( + "audit attestation signer key mismatch: expected {}, got {}", + summary.key_id, signer_key_id + ), + )); + } + if let Some(expected_public_key) = expected_public_key + && expected_public_key != public_key_der.as_slice() + { + return Ok(attestation_failure( + summary, + Some(false), + "provided public key does not match the attested signer key".to_string(), + )); + } + if expected_public_key.is_some() { + expected_public_key_matches = Some(true); + } + if let Err(err) = trust::verify_keyed_signature(bundle, &public_key_der, bundle_path) { + return Ok(attestation_failure( + summary, + expected_public_key_matches, + err.to_string(), + )); + } + + let attested_root = match trust::extract_bundle_digest(bundle, bundle_path) { + Ok(attested_root) => attested_root, + Err(err) => { + return Ok(attestation_failure( + summary, + expected_public_key_matches, + err.to_string(), + )); + } + }; + if attested_root != integrity.merkle_root.to_string() { + return Ok(attestation_failure( + summary, + expected_public_key_matches, + "audit attestation Merkle root does not match session integrity summary".to_string(), + )); + } + + let statement = match extract_audit_attestation_statement(bundle) { + Ok(statement) => statement, + Err(err) => { + return Ok(attestation_failure( + summary, + expected_public_key_matches, + err.to_string(), + )); + } + }; + let Some(statement_session_id) = statement + .predicate + .get("session_id") + .and_then(|value| value.as_str()) + else { + return Ok(attestation_failure( + summary, + expected_public_key_matches, + "audit attestation predicate missing session_id".to_string(), + )); + }; + if statement_session_id != metadata.session_id { + return Ok(attestation_failure( + summary, + expected_public_key_matches, + format!( + "audit attestation session_id mismatch: expected {}, got {}", + metadata.session_id, statement_session_id + ), + )); + } + + let Some(audit_log) = statement + .predicate + .get("audit_log") + .and_then(|value| value.as_object()) + else { + return Ok(attestation_failure( + summary, + expected_public_key_matches, + "audit attestation predicate missing audit_log".to_string(), + )); + }; + if audit_log + .get("hash_algorithm") + .and_then(|value| value.as_str()) + != Some(integrity.hash_algorithm.as_str()) + { + return Ok(attestation_failure( + summary, + expected_public_key_matches, + "audit attestation hash_algorithm does not match session integrity summary".to_string(), + )); + } + if audit_log + .get("event_count") + .and_then(|value| value.as_u64()) + != Some(integrity.event_count) + { + return Ok(attestation_failure( + summary, + expected_public_key_matches, + "audit attestation event_count does not match session integrity summary".to_string(), + )); + } + let chain_head = integrity.chain_head.to_string(); + if audit_log.get("chain_head").and_then(|value| value.as_str()) != Some(chain_head.as_str()) { + return Ok(attestation_failure( + summary, + expected_public_key_matches, + "audit attestation chain_head does not match session integrity summary".to_string(), + )); + } + if statement + .predicate + .get("started") + .and_then(|value| value.as_str()) + != Some(metadata.started.as_str()) + || statement + .predicate + .get("ended") + .and_then(|value| value.as_str()) + != metadata.ended.as_deref() + { + return Ok(attestation_failure( + summary, + expected_public_key_matches, + "audit attestation timestamps do not match session metadata".to_string(), + )); + } + + Ok(AuditAttestationVerificationResult { + present: true, + predicate_type: Some(predicate_type), + key_id: Some(summary.key_id.clone()), + key_id_matches: true, + signature_verified: true, + merkle_root_matches: true, + session_id_matches: true, + expected_public_key_matches, + verification_error: None, + }) +} + +fn attestation_failure( + summary: &AuditAttestationSummary, + expected_public_key_matches: Option, + verification_error: String, +) -> AuditAttestationVerificationResult { + AuditAttestationVerificationResult { + present: true, + predicate_type: Some(summary.predicate_type.clone()), + key_id: Some(summary.key_id.clone()), + key_id_matches: false, + signature_verified: false, + merkle_root_matches: false, + session_id_matches: false, + expected_public_key_matches, + verification_error: Some(verification_error), + } +} + +fn extract_audit_attestation_statement(bundle: &trust::Bundle) -> Result { + let envelope = match &bundle.content { + SignatureContent::DsseEnvelope(envelope) => envelope, + _ => { + return Err(NonoError::TrustVerification { + path: String::new(), + reason: "audit attestation bundle missing dsseEnvelope".to_string(), + }); + } + }; + + serde_json::from_slice(envelope.payload.as_bytes()).map_err(|e| NonoError::TrustVerification { + path: String::new(), + reason: format!("invalid audit attestation statement JSON: {e}"), + }) +} + fn hash_ledger_link( previous: Option<&ContentHash>, sequence: u64, @@ -1229,6 +1638,92 @@ mod tests { assert_ne!(base_digest, compute_session_digest(&changed).unwrap()); } + #[test] + fn audit_attestation_bundle_round_trips_in_core() { + let key_pair = crate::trust::generate_signing_key().unwrap(); + let key_id = crate::trust::key_id_hex(&key_pair).unwrap(); + let public_key = crate::trust::export_public_key(&key_pair).unwrap(); + let public_key_b64 = crate::trust::base64::base64_encode(public_key.as_bytes()); + + let mut meta = sample_metadata("20260421-200000-11111"); + meta.audit_integrity = Some(AuditIntegritySummary { + hash_algorithm: AUDIT_HASH_ALGORITHM.to_string(), + event_count: 2, + chain_head: ContentHash::from_bytes([0x11; 32]), + merkle_root: ContentHash::from_bytes([0x22; 32]), + }); + + let (bundle_json, summary) = sign_audit_attestation_bundle( + &meta, + &key_pair, + &key_id, + &public_key_b64, + &crate::ScrubPolicy::secure_default(), + ) + .unwrap(); + meta.audit_attestation = Some(summary); + + let bundle_path = Path::new("audit-attestation.bundle"); + let bundle = crate::trust::load_bundle_from_str(&bundle_json, bundle_path).unwrap(); + let verified = verify_audit_attestation_bundle( + &bundle, + bundle_path, + &meta, + Some(public_key.as_bytes()), + ) + .unwrap(); + + assert!(verified.present); + assert!(verified.key_id_matches); + assert!(verified.signature_verified); + assert!(verified.merkle_root_matches); + assert!(verified.session_id_matches); + assert_eq!(verified.expected_public_key_matches, Some(true)); + assert!(verified.verification_error.is_none()); + + let mut tampered_bundle_value: serde_json::Value = + serde_json::from_str(&bundle_json).unwrap(); + tampered_bundle_value["dsseEnvelope"]["payload"] = + serde_json::Value::String(crate::trust::base64::base64_encode(b"tampered")); + let tampered_bundle = crate::trust::load_bundle_from_str( + &serde_json::to_string(&tampered_bundle_value).unwrap(), + bundle_path, + ) + .unwrap(); + let verified = verify_audit_attestation_bundle( + &tampered_bundle, + bundle_path, + &meta, + Some(public_key.as_bytes()), + ) + .unwrap(); + assert!(!verified.signature_verified); + assert_eq!(verified.expected_public_key_matches, None); + + let mut changed = meta.clone(); + changed.audit_integrity = Some(AuditIntegritySummary { + hash_algorithm: AUDIT_HASH_ALGORITHM.to_string(), + event_count: 3, + chain_head: ContentHash::from_bytes([0x11; 32]), + merkle_root: ContentHash::from_bytes([0x22; 32]), + }); + let verified = verify_audit_attestation_bundle( + &bundle, + bundle_path, + &changed, + Some(public_key.as_bytes()), + ) + .unwrap(); + assert!(!verified.signature_verified); + assert_eq!(verified.expected_public_key_matches, Some(true)); + assert!( + verified + .verification_error + .as_deref() + .is_some_and(|err| err.contains("event_count")) + ); + } + /// Golden vectors shared with the Python port in /// nono-py/tests/test_audit.py (TestRustGoldenVectors keeps the same /// values). If this test fails, the wire format diverged across From 6248df5b1494ee93fc5af062f9e6a1b24c52d7b8 Mon Sep 17 00:00:00 2001 From: Luke Hinds Date: Wed, 17 Jun 2026 19:16:32 +0100 Subject: [PATCH 76/91] feat(output): show blocked macos grants in capability summary (#1178) * feat(output): show blocked macos grants in capability summary Previously, macOS user-granted paths that were silently blocked by deny groups were logged as individual warnings. This could lead to a large number of warnings when a broad user grant (e.g., `~/Library`) overlapped with multiple deny groups. This commit refactors the logic to collect these blocked grants and present them in the capability summary. The new display is collapsed into a single line by default, indicating the number of paths blocked. Using the `-v` (verbose) flag expands the summary to list each specific path and the deny rule that blocks it, along with a hint about how to use `--bypass-protection`. This significantly improves readability and user experience by folding multiple related warnings into a concise summary. Signed-off-by: Luke Hinds * refactor(output): improve path display and string allocations - Use `Path::to_string_lossy` for displaying paths in `print_blocked_grants`. This provides more robust handling of non-UTF-8 characters and avoids unnecessary `String` allocations compared to `Path::display().to_string()`. - Optimize string handling when grouping blocked grants by deferring `.to_string()` calls until group names are inserted into the `groups` vector, reducing temporary allocations. - Reorder test module imports in `output.rs` for minor style consistency. Signed-off-by: Luke Hinds --------- Signed-off-by: Luke Hinds --- crates/nono-cli/src/capability_ext.rs | 40 ++++----- crates/nono-cli/src/output.rs | 110 +++++++++++++++++++++++-- crates/nono-cli/src/sandbox_prepare.rs | 8 +- crates/nono-cli/src/startup_runtime.rs | 4 + 4 files changed, 136 insertions(+), 26 deletions(-) diff --git a/crates/nono-cli/src/capability_ext.rs b/crates/nono-cli/src/capability_ext.rs index a063117a2..e2abd6d5f 100644 --- a/crates/nono-cli/src/capability_ext.rs +++ b/crates/nono-cli/src/capability_ext.rs @@ -519,6 +519,10 @@ pub struct PreparedCaps { pub caps: CapabilitySet, pub needs_unlink_overrides: bool, pub deny_paths: Vec, + /// User-granted paths (macOS) that a deny group silently blocks, paired + /// with the deny group that blocks each. Surfaced as a single folded line + /// in the capability summary; `(path, Some(group_name))`. + pub blocked_grants: Vec<(PathBuf, Option)>, } /// Extension trait for CapabilitySet to add CLI-specific construction methods. @@ -624,12 +628,13 @@ impl CapabilitySetExt for CapabilitySet { caps.add_blocked_command(cmd); } - finalize_caps(&mut caps, &mut resolved, &loaded_policy, args, &[])?; + let blocked_grants = finalize_caps(&mut caps, &mut resolved, &loaded_policy, args, &[])?; Ok(PreparedCaps { caps, needs_unlink_overrides: resolved.needs_unlink_overrides, deny_paths: resolved.deny_paths, + blocked_grants, }) } @@ -1062,7 +1067,7 @@ impl CapabilitySetExt for CapabilitySet { } } - finalize_caps( + let blocked_grants = finalize_caps( &mut caps, &mut resolved, &loaded_policy, @@ -1074,6 +1079,7 @@ impl CapabilitySetExt for CapabilitySet { caps, needs_unlink_overrides: resolved.needs_unlink_overrides, deny_paths: resolved.deny_paths, + blocked_grants, }) } } @@ -1089,7 +1095,7 @@ fn finalize_caps( loaded_policy: &policy::Policy, args: &SandboxArgs, profile_bypass_protection: &[PathBuf], -) -> Result<()> { +) -> Result)>> { // Apply profile-level deny overrides first, then CLI overrides. // Profile overrides come from `filesystem.bypass_protection` in the // profile JSON. CLI `--bypass-protection` flags are applied on top. @@ -1104,22 +1110,16 @@ fn finalize_caps( // Validate deny/allow overlaps (hard-fail on Linux where Landlock cannot enforce denies) policy::validate_deny_overlaps(&resolved.deny_paths, caps)?; - // On macOS, warn when user-granted paths are silently blocked by deny rules. - // Seatbelt deny rules override earlier allow rules for content access, so the - // user's --allow/--read/--write has no effect without --bypass-protection. - if cfg!(target_os = "macos") { - for (path, group) in - policy::find_denied_user_grants(&resolved.deny_paths, caps, loaded_policy) - { - let source = group.as_deref().unwrap_or("a deny rule"); - warn!( - "'{}' is blocked by '{}'; use --bypass-protection {} to allow access", - path.display(), - source, - path.display(), - ); - } - } + // On macOS, collect user-granted paths that are silently blocked by deny + // rules so the caller can surface them as one folded line in the capability + // summary (rather than a wall of per-path warnings). Seatbelt deny rules + // override earlier allow rules for content access, so the user's + // --allow/--read/--write has no effect without --bypass-protection. + let blocked_grants = if cfg!(target_os = "macos") { + policy::find_denied_user_grants(&resolved.deny_paths, caps, loaded_policy) + } else { + Vec::new() + }; // Keep broad keychain deny groups active, but allow explicit // keychain DB read grants (profile/CLI) on macOS. @@ -1128,7 +1128,7 @@ fn finalize_caps( // Deduplicate capabilities caps.deduplicate(); - Ok(()) + Ok(blocked_grants) } fn apply_cli_network_mode(caps: &mut CapabilitySet, args: &SandboxArgs) { diff --git a/crates/nono-cli/src/output.rs b/crates/nono-cli/src/output.rs index 4a33d14ef..623767512 100644 --- a/crates/nono-cli/src/output.rs +++ b/crates/nono-cli/src/output.rs @@ -52,7 +52,12 @@ pub fn print_banner(silent: bool) { /// When `verbose` is 0, only user-specified capabilities are shown (CLI flags /// and profile filesystem entries). System paths and group-resolved paths are /// hidden to reduce noise. Use `-v` to show all capabilities. -pub fn print_capabilities(caps: &CapabilitySet, verbose: u8, silent: bool) { +pub fn print_capabilities( + caps: &CapabilitySet, + blocked_grants: &[(std::path::PathBuf, Option)], + verbose: u8, + silent: bool, +) { if silent { return; } @@ -110,6 +115,11 @@ pub fn print_capabilities(caps: &CapabilitySet, verbose: u8, silent: bool) { } } + // Protected paths kept blocked despite a user grant (macOS deny groups). + // Folded into one row by default so a broad grant (e.g. ~/Library) that + // overlaps several deny groups does not produce a wall of warnings. + print_blocked_grants(blocked_grants, verbose, t); + // AF_UNIX socket capabilities (issue #685 / #696) let unix_caps = caps.unix_socket_capabilities(); if !unix_caps.is_empty() { @@ -216,6 +226,69 @@ pub fn print_capabilities(caps: &CapabilitySet, verbose: u8, silent: bool) { } /// Format an access mode as a fixed-width colored badge +/// Render the paths that a deny group keeps blocked despite a user grant. +/// +/// Collapsed by default to a single row (a broad grant such as `~/Library` +/// overlaps many deny groups and would otherwise emit one warning per path). +/// `-v` expands to the full paths grouped by the deny rule that blocks them, +/// with the `--bypass-protection` escape hatch shown once. +fn print_blocked_grants( + blocked: &[(std::path::PathBuf, Option)], + verbose: u8, + t: &theme::Theme, +) { + if blocked.is_empty() { + return; + } + + let badge = theme::badge("deny ", t.yellow, BADGE_FG_DARK); + + if verbose == 0 { + let n = blocked.len(); + let noun = if n == 1 { "path" } else { "paths" }; + eprintln!( + " {} {}", + badge, + theme::fg( + &format!("{n} sensitive {noun} kept blocked inside your grants (-v to show)"), + t.subtext, + ), + ); + return; + } + + eprintln!( + " {} {}", + badge, + theme::fg("sensitive paths kept blocked despite your grants:", t.text), + ); + + // Group by the deny rule that blocks each path, preserving first-seen order. + let mut groups: Vec<(String, Vec<&std::path::Path>)> = Vec::new(); + for (path, group) in blocked { + let group_name = group.as_deref().unwrap_or("a deny rule"); + match groups.iter_mut().find(|(name, _)| name == group_name) { + Some((_, paths)) => paths.push(path.as_path()), + None => groups.push((group_name.to_string(), vec![path.as_path()])), + } + } + + for (name, paths) in &groups { + eprintln!(" {}", theme::fg(name, t.subtext)); + for path in paths { + eprintln!(" {}", theme::fg(&path.to_string_lossy(), t.text)); + } + } + + eprintln!( + " {}", + theme::fg( + "use --bypass-protection to allow a specific path", + t.subtext, + ), + ); +} + fn format_access_badge(access: &AccessMode) -> String { let t = theme::current(); match access { @@ -913,9 +986,11 @@ pub fn print_profile_hint(program: &str, profile: &str, silent: bool) { #[cfg(test)] mod tests { + use super::theme; use super::{ - dry_run_command_line, format_unix_socket_mode_badge, print_capabilities, - print_profile_hint, render_diagnostic_footer, render_terminal_block_for_tty, + dry_run_command_line, format_unix_socket_mode_badge, print_blocked_grants, + print_capabilities, print_profile_hint, render_diagnostic_footer, + render_terminal_block_for_tty, }; use nono::{CapabilitySet, UnixSocketMode}; use std::ffi::{OsStr, OsString}; @@ -1018,7 +1093,32 @@ mod tests { .allow_unix_socket_dir(dir.path(), UnixSocketMode::ConnectBind) .expect("bind dir grant"); - print_capabilities(&caps, 0, true); - print_capabilities(&caps, 1, true); + print_capabilities(&caps, &[], 0, true); + print_capabilities(&caps, &[], 1, true); + } + + #[test] + fn print_blocked_grants_collapsed_and_verbose_do_not_panic() { + // Blocked grants render as one folded row by default and expand under + // -v; both paths (and the empty case) must render without panicking. + let t = theme::current(); + let blocked = vec![ + ( + std::path::PathBuf::from("/Users/x/Library/Application Support/Google/Chrome"), + Some("deny_browser_data_macos".to_string()), + ), + ( + std::path::PathBuf::from("/Users/x/Library/Application Support/1Password"), + Some("deny_keychains_macos".to_string()), + ), + ( + std::path::PathBuf::from("/Users/x/Library/Application Support/Unknown"), + None, + ), + ]; + + print_blocked_grants(&blocked, 0, t); + print_blocked_grants(&blocked, 1, t); + print_blocked_grants(&[], 0, t); } } diff --git a/crates/nono-cli/src/sandbox_prepare.rs b/crates/nono-cli/src/sandbox_prepare.rs index b66b65dc3..16f13d012 100644 --- a/crates/nono-cli/src/sandbox_prepare.rs +++ b/crates/nono-cli/src/sandbox_prepare.rs @@ -574,11 +574,12 @@ pub(crate) fn resolve_detached_cwd_prompt_response( fn finalize_prepared_sandbox( prepared: PreparedSandbox, + blocked_grants: &[(PathBuf, Option)], args: &SandboxArgs, silent: bool, ) -> Result { output::print_skipped_requested_paths(&collect_missing_cli_requested_paths(args), silent); - output::print_capabilities(&prepared.caps, args.verbose, silent); + output::print_capabilities(&prepared.caps, blocked_grants, args.verbose, silent); if let Some(ref profile_name) = args.profile { crate::pack_update_hint::show_pack_update_hints(profile_name, silent); @@ -1085,6 +1086,7 @@ pub(crate) fn prepare_sandbox(args: &SandboxArgs, silent: bool) -> Result Result Result bool { return true; } + if trimmed.starts_with("deny") && trimmed.contains("kept blocked") { + return true; + } + if trimmed == "outbound allowed" || trimmed == "outbound blocked" || trimmed.starts_with("proxy localhost:") From 2c3bf784b467c55b0a706173ce1818549b5bfd91 Mon Sep 17 00:00:00 2001 From: Aleks <121458075+SequeI@users.noreply.github.com> Date: Wed, 17 Jun 2026 19:19:19 +0100 Subject: [PATCH 77/91] ci: run integration tests on ubuntu runner (#1185) Signed-off-by: Aleksy Siek --- .github/workflows/ci.yml | 11 ++--- tests/integration/test_audit.sh | 10 +++-- tests/integration/test_bypass_protection.sh | 2 +- tests/integration/test_edge_cases.sh | 2 +- tests/integration/test_network.sh | 50 ++++++++++++--------- tests/integration/test_profiles.sh | 13 +++++- tests/integration/test_silent_output.sh | 22 ++++++--- tests/integration/test_system_paths.sh | 34 ++++++++------ tests/lib/test_helpers.sh | 6 +-- tests/run_integration_tests.sh | 4 +- 10 files changed, 94 insertions(+), 60 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d51150d8f..d1733e9ff 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -198,10 +198,14 @@ jobs: run: cargo clippy --workspace --all-targets --all-features -- -D warnings -D clippy::unwrap_used integration: - name: Integration Tests + name: Integration (${{ matrix.os }}) needs: changes if: ${{ !startsWith(github.head_ref, 'dependabot/github_actions/') && needs.changes.outputs.run_code_jobs == 'true' }} - runs-on: macos-latest + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest-m, macos-latest] steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 @@ -221,9 +225,6 @@ jobs: restore-keys: | ${{ runner.os }}-cargo- - - name: Build release binary - run: cargo build --release - - name: Run integration tests run: | if [[ -f ./tests/run_integration_tests.sh ]]; then diff --git a/tests/integration/test_audit.sh b/tests/integration/test_audit.sh index d909554b3..164e7bd72 100755 --- a/tests/integration/test_audit.sh +++ b/tests/integration/test_audit.sh @@ -133,15 +133,17 @@ expect_failure "--no-audit --rollback is rejected" \ echo "" echo "--- Audit with Rollback ---" -# Test 5: --rollback with read-only paths still creates an audit session +# Test 5: --rollback with a writable path creates an audit session. +# We use --allow (not --read) because on Linux Landlock, a purely read-only +# rollback session has nothing to snapshot and may not create a session file. TESTS_RUN=$((TESTS_RUN + 1)) -run_nono run --silent --rollback --no-rollback-prompt --allow-cwd --read "$TMPDIR" -- echo "readonly rollback audit" +run_nono run --silent --rollback --no-rollback-prompt --allow-cwd --allow "$TMPDIR" -- echo "rollback audit" session_file=$(find_rollback_session_for_pid "$LAST_NONO_PID") if [[ -n "$session_file" && -f "$session_file" ]]; then - echo -e " ${GREEN}PASS${NC}: rollback read-only session creates audit" + echo -e " ${GREEN}PASS${NC}: rollback session creates audit" TESTS_PASSED=$((TESTS_PASSED + 1)) else - echo -e " ${RED}FAIL${NC}: rollback read-only session creates audit" + echo -e " ${RED}FAIL${NC}: rollback session creates audit" echo " PID: $LAST_NONO_PID, session_file: ${session_file:-not found}" TESTS_FAILED=$((TESTS_FAILED + 1)) fi diff --git a/tests/integration/test_bypass_protection.sh b/tests/integration/test_bypass_protection.sh index d1cb6259a..059b37d88 100755 --- a/tests/integration/test_bypass_protection.sh +++ b/tests/integration/test_bypass_protection.sh @@ -186,7 +186,7 @@ EOF "meta": { "name": "nono-test-docker-child", "version": "1.0.0" }, "extends": "nono-test-docker-base", "filesystem": { - "read": ["\$HOME/.config"] + "read": ["\$HOME/.cache"] } } EOF diff --git a/tests/integration/test_edge_cases.sh b/tests/integration/test_edge_cases.sh index c9f00bbd9..bd256c618 100755 --- a/tests/integration/test_edge_cases.sh +++ b/tests/integration/test_edge_cases.sh @@ -137,7 +137,7 @@ if is_macos; then expect_output_not_contains "grant non-existent file does not warn on macOS" "Skipping non-existent file" \ "$NONO_BIN" run --read-file /nonexistent/file.txt -- echo "should run" else - expect_output_contains "grant non-existent file is skipped with warning" "Skipping non-existent file" \ + expect_output_contains "grant non-existent file is skipped with warning" "some requested sandbox grants were skipped because the path does not exist" \ "$NONO_BIN" run --read-file /nonexistent/file.txt -- echo "should run" fi diff --git a/tests/integration/test_network.sh b/tests/integration/test_network.sh index dcddf8019..deffe242e 100755 --- a/tests/integration/test_network.sh +++ b/tests/integration/test_network.sh @@ -30,30 +30,29 @@ echo "--- Network Blocked ---" if command_exists curl; then expect_failure "curl blocked with --block-net" \ - "$NONO_BIN" run --block-net --allow "$TMPDIR" -- curl -s --max-time 5 https://example.com + timeout 15 "$NONO_BIN" run --block-net --allow "$TMPDIR" -- curl -s --max-time 5 https://example.com else skip_test "curl blocked" "curl not installed" fi if command_exists wget; then expect_failure "wget blocked with --block-net" \ - "$NONO_BIN" run --block-net --allow "$TMPDIR" -- wget -q --timeout=5 -O - https://example.com + timeout 15 "$NONO_BIN" run --block-net --allow "$TMPDIR" -- wget -q --timeout=5 -O /dev/null https://example.com else skip_test "wget blocked" "wget not installed" fi # Note: ping requires special privileges, may not work in all environments if command_exists ping; then - # Use timeout to avoid hanging expect_failure "ping blocked with --block-net" \ - timeout 5 "$NONO_BIN" run --block-net --allow "$TMPDIR" -- ping -c 1 -W 2 8.8.8.8 2>/dev/null || true + timeout 10 "$NONO_BIN" run --block-net --allow "$TMPDIR" -- ping -c 1 -W 2 8.8.8.8 else skip_test "ping blocked" "ping not installed" fi if command_exists nc; then expect_failure "nc (netcat) blocked with --block-net" \ - "$NONO_BIN" run --block-net --allow "$TMPDIR" -- nc -z -w 2 example.com 80 + timeout 10 "$NONO_BIN" run --block-net --allow "$TMPDIR" -- nc -z -w 2 example.com 80 else skip_test "nc blocked" "nc not installed" fi @@ -61,7 +60,7 @@ fi # Test that even local network is blocked if command_exists nc; then expect_failure "localhost connection blocked with --block-net" \ - "$NONO_BIN" run --block-net --allow "$TMPDIR" -- nc -z -w 1 127.0.0.1 22 2>/dev/null || true + timeout 10 "$NONO_BIN" run --block-net --allow "$TMPDIR" -- nc -z -w 1 127.0.0.1 22 fi # ============================================================================= @@ -73,22 +72,29 @@ echo "--- Network Allowed (Default) ---" if command_exists curl; then expect_success "curl works by default" \ - "$NONO_BIN" run --allow "$TMPDIR" -- curl -s --max-time 10 https://example.com >/dev/null - - expect_failure "proxy mode blocks direct curl bypass with --noproxy" \ - "$NONO_BIN" run --allow "$TMPDIR" --allow-domain api.openai.com -- curl -s --noproxy '*' --max-time 10 https://example.com >/dev/null - - # Keep this assertion on a profile that still bundles a network_profile. - # claude-code used to carry a profile-level proxy allowlist, but the current - # built-in profile no longer sets network.network_profile in policy.json, so - # expecting it to block example.com is stale. python-dev still embeds the - # developer network profile, which makes it the right built-in fixture for - # validating "profile enables proxy filtering" plus the --allow-net override. - expect_failure "python-dev profile blocks hosts outside developer allowlist" \ - "$NONO_BIN" run --profile python-dev --allow-cwd -- curl -s --max-time 10 https://example.com >/dev/null - - expect_success "python-dev profile allows unrestricted network with --allow-net" \ - "$NONO_BIN" run --profile python-dev --allow-cwd --allow-net -- curl -s --max-time 10 https://example.com >/dev/null + "$NONO_BIN" run --allow "$TMPDIR" -- bash -c 'curl -s --max-time 10 https://example.com >/dev/null' + + # Proxy-based domain filtering requires either Landlock TCP (Linux ≥ 6.7, ABI v4) + # or Seatbelt network rules (macOS). On Linux CI runners with older kernels, + # direct connections bypass the proxy and these tests would spuriously pass. + if is_macos; then + expect_failure "proxy mode blocks direct curl bypass with --noproxy" \ + "$NONO_BIN" run --allow "$TMPDIR" --allow-domain api.openai.com -- bash -c 'curl -s --noproxy "*" --max-time 10 https://example.com >/dev/null' + + # Keep this assertion on a profile that still bundles a network_profile. + # python-dev still embeds the developer network profile, which makes it the + # right built-in fixture for validating "profile enables proxy filtering" + # plus the --allow-net override. + expect_failure "python-dev profile blocks hosts outside developer allowlist" \ + "$NONO_BIN" run --profile python-dev --allow-cwd -- bash -c 'curl -s --max-time 10 https://example.com >/dev/null' + + expect_success "python-dev profile allows unrestricted network with --allow-net" \ + "$NONO_BIN" run --profile python-dev --allow-cwd --allow-net -- bash -c 'curl -s --max-time 10 https://example.com >/dev/null' + else + skip_test "proxy mode blocks direct curl bypass with --noproxy" "proxy TCP blocking requires Landlock v4 (Linux ≥ 6.7); not guaranteed in CI" + skip_test "python-dev profile blocks hosts outside developer allowlist" "proxy TCP blocking requires Landlock v4 (Linux ≥ 6.7); not guaranteed in CI" + skip_test "python-dev profile allows unrestricted network with --allow-net" "dependent on proxy filtering test above" + fi else skip_test "curl works by default" "curl not installed" fi diff --git a/tests/integration/test_profiles.sh b/tests/integration/test_profiles.sh index ac1ac4448..93a11e1e6 100755 --- a/tests/integration/test_profiles.sh +++ b/tests/integration/test_profiles.sh @@ -59,8 +59,17 @@ expect_output_contains "dry-run output shows Capabilities section" "Capabilities # node-dev pulls the `node_runtime` capability group (paths like # ~/.npm, ~/.nvm). Those live inside the collapsed system/group # block in the default dry-run output and only show with -v. -expect_output_contains "node-dev profile lists Node runtime paths in dry-run -v" ".npm" \ - "$NONO_BIN" run -v --profile node-dev --dry-run -- echo "test" +# On Linux CI runners ~/.npm may not exist; use /usr/local/lib/node_modules +# (present when node is installed) as the assertion anchor instead. +if is_linux && [[ -d /usr/local/lib/node_modules ]]; then + expect_output_contains "node-dev profile lists Node runtime paths in dry-run -v" "node_modules" \ + "$NONO_BIN" run -v --profile node-dev --dry-run -- echo "test" +elif is_linux; then + skip_test "node-dev profile lists Node runtime paths in dry-run -v" "no node_runtime paths present on this runner" +else + expect_output_contains "node-dev profile lists Node runtime paths in dry-run -v" ".npm" \ + "$NONO_BIN" run -v --profile node-dev --dry-run -- echo "test" +fi # ============================================================================= # Profile Enforcement diff --git a/tests/integration/test_silent_output.sh b/tests/integration/test_silent_output.sh index b8cc094bf..97cd7ce3d 100755 --- a/tests/integration/test_silent_output.sh +++ b/tests/integration/test_silent_output.sh @@ -40,7 +40,7 @@ expect_output_empty() { echo " Actual output: ${stripped:0:2000}" fi TESTS_FAILED=$((TESTS_FAILED + 1)) - return 1 + return 0 } # node-dev is an embedded profile that lists `$HOME/Library/pnpm` — @@ -49,14 +49,24 @@ expect_output_empty() { # `-v`. The previous version used `claude-code` and asserted on a # macOS Keychain path; that profile now ships as a registry pack so # the assertion no longer applies in this suite. -expect_output_empty \ +# Note: --dry-run prints capability output by default; we assert the +# missing-path warning is suppressed, not that output is empty. +expect_output_not_contains \ "node-dev dry-run hides missing profile warnings by default" \ + "does not exist, skipping" \ "$NONO_BIN" run --profile node-dev --allow-cwd --dry-run -- echo ok -expect_output_contains \ - "node-dev dry-run shows missing profile warnings with -v" \ - "Profile path '\$HOME/Library/pnpm' does not exist, skipping" \ - "$NONO_BIN" run -v --profile node-dev --allow-cwd --dry-run -- echo ok +# $HOME/Library/pnpm is a macOS-only path in the node_runtime group. +# On macOS, nono warns about it with -v; on Linux it is silently dropped. +if is_macos; then + expect_output_contains \ + "node-dev dry-run shows missing profile warnings with -v" \ + "Library/pnpm' does not exist, skipping" \ + "$NONO_BIN" run -v --profile node-dev --allow-cwd --dry-run -- echo ok +else + skip_test "node-dev dry-run shows missing profile warnings with -v" \ + "macOS-only path warning; Linux drops missing paths silently" +fi expect_output_empty \ "silent dry-run suppresses tracing warnings and CLI status output" \ diff --git a/tests/integration/test_system_paths.sh b/tests/integration/test_system_paths.sh index 4f0555583..927e97a61 100755 --- a/tests/integration/test_system_paths.sh +++ b/tests/integration/test_system_paths.sh @@ -29,19 +29,19 @@ echo "" echo "--- System Paths Readable ---" expect_success "can list /usr/bin (system executables)" \ - "$NONO_BIN" run --allow "$TMPDIR" -- ls /usr/bin/ >/dev/null + "$NONO_BIN" run --allow "$TMPDIR" -- bash -c 'ls /usr/bin/ >/dev/null' expect_success "can execute /bin/echo" \ "$NONO_BIN" run --allow "$TMPDIR" -- /bin/echo "test" if [[ -d /usr/lib ]]; then expect_success "can list /usr/lib" \ - "$NONO_BIN" run --allow "$TMPDIR" -- ls /usr/lib/ >/dev/null + "$NONO_BIN" run --allow "$TMPDIR" -- bash -c 'ls /usr/lib/ >/dev/null' fi if [[ -d /etc ]]; then expect_success "can read /etc/hosts" \ - "$NONO_BIN" run --allow "$TMPDIR" -- cat /etc/hosts >/dev/null + "$NONO_BIN" run --allow "$TMPDIR" -- bash -c 'cat /etc/hosts >/dev/null' fi # ============================================================================= @@ -69,20 +69,20 @@ echo "--- macOS System Paths ---" if is_macos; then expect_success "can read /System/Library" \ - "$NONO_BIN" run --allow "$TMPDIR" -- ls /System/Library/ >/dev/null + "$NONO_BIN" run --allow "$TMPDIR" -- bash -c 'ls /System/Library/ >/dev/null' expect_failure "cannot write to /System/Library" \ "$NONO_BIN" run --allow "$TMPDIR" -- sh -c "echo x > /System/Library/evil-$$" expect_success "can read /Library" \ - "$NONO_BIN" run --allow "$TMPDIR" -- ls /Library/ >/dev/null + "$NONO_BIN" run --allow "$TMPDIR" -- bash -c 'ls /Library/ >/dev/null' expect_failure "cannot write to /Library" \ "$NONO_BIN" run --allow "$TMPDIR" -- sh -c "echo x > /Library/evil-$$" if [[ -d /Applications ]]; then expect_success "can read /Applications" \ - "$NONO_BIN" run --allow "$TMPDIR" -- ls /Applications/ >/dev/null + "$NONO_BIN" run --allow "$TMPDIR" -- bash -c 'ls /Applications/ >/dev/null' fi else skip_test "/System/Library readable" "not macOS" @@ -101,7 +101,7 @@ echo "--- Linux System Paths ---" if is_linux; then if [[ -d /lib ]]; then expect_success "can read /lib" \ - "$NONO_BIN" run --allow "$TMPDIR" -- ls /lib/ >/dev/null + "$NONO_BIN" run --allow "$TMPDIR" -- bash -c 'ls /lib/ >/dev/null' expect_failure "cannot write to /lib" \ "$NONO_BIN" run --allow "$TMPDIR" -- sh -c "echo x > /lib/evil-$$.so" @@ -109,18 +109,25 @@ if is_linux; then if [[ -d /lib64 ]]; then expect_success "can read /lib64" \ - "$NONO_BIN" run --allow "$TMPDIR" -- ls /lib64/ >/dev/null + "$NONO_BIN" run --allow "$TMPDIR" -- bash -c 'ls /lib64/ >/dev/null' fi if [[ -d /proc ]]; then expect_success "can read /proc/self/status" \ - "$NONO_BIN" run --allow "$TMPDIR" -- cat /proc/self/status >/dev/null + "$NONO_BIN" run --allow "$TMPDIR" -- bash -c 'cat /proc/self/status >/dev/null' expect_success "can read /proc/self/maps" \ - "$NONO_BIN" run --allow "$TMPDIR" -- cat /proc/self/maps >/dev/null + "$NONO_BIN" run --allow "$TMPDIR" -- bash -c 'cat /proc/self/maps >/dev/null' - expect_failure "cannot read foreign /proc/1/maps" \ - "$NONO_BIN" run --allow "$TMPDIR" -- cat /proc/1/maps >/dev/null + # /proc/1/maps is readable by root and in some privileged CI containers, + # even inside the sandbox. Skip rather than assert a property that depends + # on the host privilege level. + if [[ "$(id -u)" -eq 0 ]] || cat /proc/1/maps >/dev/null 2>&1; then + skip_test "cannot read foreign /proc/1/maps" "process has access to /proc/1/maps (privileged container or root)" + else + expect_failure "cannot read foreign /proc/1/maps" \ + "$NONO_BIN" run --allow "$TMPDIR" -- bash -c 'cat /proc/1/maps >/dev/null 2>&1' + fi # Regression test for issue #602: grandchild proc/self access. # When bun (or any runtime) is launched via `sh -c`, it is a grandchild @@ -137,8 +144,7 @@ if is_linux; then fi if [[ -d /sys ]]; then - expect_success "can read /sys" \ - "$NONO_BIN" run --allow "$TMPDIR" -- ls /sys/ >/dev/null + skip_test "can read /sys" "sysfs not in nono system read grants on Linux" fi else skip_test "/lib readable" "not Linux" diff --git a/tests/lib/test_helpers.sh b/tests/lib/test_helpers.sh index bea96b479..320361a36 100755 --- a/tests/lib/test_helpers.sh +++ b/tests/lib/test_helpers.sh @@ -104,7 +104,7 @@ expect_failure() { echo " Output: ${stripped:0:2000}" fi TESTS_FAILED=$((TESTS_FAILED + 1)) - return 1 + return 0 fi } @@ -137,7 +137,7 @@ expect_output_contains() { echo " Actual output: ${stripped:0:2000}" fi TESTS_FAILED=$((TESTS_FAILED + 1)) - return 1 + return 0 fi } @@ -157,7 +157,7 @@ expect_output_not_contains() { echo -e " ${RED}FAIL${NC}: $name" echo " Output should NOT contain: '$unexpected_str'" TESTS_FAILED=$((TESTS_FAILED + 1)) - return 1 + return 0 else echo -e " ${GREEN}PASS${NC}: $name" TESTS_PASSED=$((TESTS_PASSED + 1)) diff --git a/tests/run_integration_tests.sh b/tests/run_integration_tests.sh index 24edfa39a..408ff3dd0 100755 --- a/tests/run_integration_tests.sh +++ b/tests/run_integration_tests.sh @@ -52,8 +52,8 @@ echo -e "Platform: $(uname -s) $(uname -m)" echo "" # Make test scripts executable -chmod +x "$SCRIPT_DIR"/integration/*.sh -chmod +x "$SCRIPT_DIR"/lib/*.sh +chmod +x "$SCRIPT_DIR"/integration/*.sh 2>/dev/null || true +chmod +x "$SCRIPT_DIR"/lib/*.sh 2>/dev/null || true # ============================================================================= # Run Test Suites in Parallel (with concurrency limit) From 53a4acf492cd23d95947d1a58fa0d295b9940cb9 Mon Sep 17 00:00:00 2001 From: Aleks <121458075+SequeI@users.noreply.github.com> Date: Wed, 17 Jun 2026 19:29:58 +0100 Subject: [PATCH 78/91] fix(cli): use XDG config paths consistently (#1179) * fix(cli): use XDG config paths consistently Route user config (config.toml, trusted-keys cache) through resolve_user_config_dir() instead of dirs::config_dir(), so macOS matches ~/.config/nono like profiles and packages already did. Add $NONO_CONFIG profile expansion, display path helpers for CLI output, and update schema/docs/scripts to prefer XDG variables in machine-readable paths while keeping ~/.config/nono in user-facing docs and --help. Signed-off-by: Aleksy Siek * fix Signed-off-by: Aleksy Siek --------- Signed-off-by: Aleksy Siek --- crates/nono-cli/data/nono-profile.schema.json | 4 +- .../nono-cli/data/profile-authoring-guide.md | 7 +- crates/nono-cli/src/config/mod.rs | 44 +++++++++- crates/nono-cli/src/config/user.rs | 6 +- crates/nono-cli/src/pack_update_hint.rs | 2 +- crates/nono-cli/src/profile/mod.rs | 83 +++++++++++++++++-- crates/nono-cli/src/profile_cmd.rs | 6 +- crates/nono-cli/src/profile_save_runtime.rs | 2 +- crates/nono-cli/src/setup.rs | 9 +- crates/nono-cli/src/trust_cmd.rs | 8 +- crates/nono-cli/src/trust_scan.rs | 2 +- crates/nono-cli/src/update_check.rs | 2 +- crates/nono-cli/src/wiring.rs | 56 ++++++++++++- crates/nono-cli/tests/deprecated_schema.rs | 2 +- docs/cli/features/environment.mdx | 2 +- docs/cli/features/profiles-groups.mdx | 12 ++- docs/cli/features/trust.mdx | 2 +- docs/cli/internals/wsl2-feature-matrix.mdx | 2 +- scripts/claude-clear-nono.sh | 14 ++-- scripts/codex-clear-nono.sh | 12 +-- tests/integration/test_bypass_protection.sh | 4 +- tests/integration/test_pack_resolution.sh | 2 +- 22 files changed, 228 insertions(+), 55 deletions(-) diff --git a/crates/nono-cli/data/nono-profile.schema.json b/crates/nono-cli/data/nono-profile.schema.json index a74e6d9d6..8a683a999 100644 --- a/crates/nono-cli/data/nono-profile.schema.json +++ b/crates/nono-cli/data/nono-profile.schema.json @@ -122,7 +122,7 @@ "command_args": { "type": "array", "items": { "type": "string" }, - "description": "Extra arguments appended to the child command at launch. Supports variable expansion (e.g. $NONO_PACKAGES)." + "description": "Extra arguments appended to the child command at launch. Supports variable expansion (e.g. $NONO_CONFIG, $NONO_PACKAGES)." }, "unsafe_macos_seatbelt_rules": { "type": "array", @@ -742,7 +742,7 @@ "set_vars": { "type": "object", "additionalProperties": { "type": "string" }, - "description": "Static environment variables injected after environment filtering and before credential injection. PATH and NONO_* are reserved. Values support variable expansion ($HOME, $WORKDIR, $TMPDIR, $XDG_*, $NONO_PACKAGES)." + "description": "Static environment variables injected after environment filtering and before credential injection. PATH and NONO_* are reserved. Values support variable expansion ($HOME, $WORKDIR, $TMPDIR, $XDG_*, $NONO_CONFIG, $NONO_PACKAGES)." } } }, diff --git a/crates/nono-cli/data/profile-authoring-guide.md b/crates/nono-cli/data/profile-authoring-guide.md index 9e57f617e..512dd4c36 100644 --- a/crates/nono-cli/data/profile-authoring-guide.md +++ b/crates/nono-cli/data/profile-authoring-guide.md @@ -4,7 +4,10 @@ This guide is designed for LLM agents helping users create custom nono profiles. ## 1. Profile File Location -User profiles live at `~/.config/nono/profiles/.json`. +User profiles live at **`~/.config/nono/profiles/.json`** by default. If +`XDG_CONFIG_HOME` is set, nono uses **`$XDG_CONFIG_HOME/nono/profiles/.json`** +instead. In profile JSON path grants, prefer `$XDG_CONFIG_HOME`, `$NONO_CONFIG`, or +`$NONO_PACKAGES` over hardcoded `$HOME/.config/...`. Profile names must be alphanumeric with hyphens only. No leading or trailing hyphens. @@ -245,7 +248,7 @@ Controls which environment variables are passed to the sandboxed process. When ` |---------------|-----------------|---------|-------------| | `allow_vars` | array of string | `[]` | Allow-list of environment variable names. Supports exact names (`"PATH"`) and prefix patterns ending with `*` (`"AWS_*"` matches `AWS_REGION`, `AWS_SECRET_ACCESS_KEY`, etc.). The `*` wildcard is only valid as a trailing suffix. When the `environment` section is omitted entirely, all variables are allowed. When present with an empty array, no inherited variables are passed (only nono-injected credentials). Nono-injected credentials always bypass this list. | | `deny_vars` | array of string | `[]` | Deny-list of environment variable names stripped from the child. Same pattern syntax as `allow_vars` (exact names and trailing `*`). Denied vars are stripped even if they also match `allow_vars`. | -| `set_vars` | object (string→string) | `{}` | Static environment variables injected after allow/deny filtering and before credential injection (injected credentials win on conflict). Values support the same expansion as profile paths (`$HOME`, `~`, `$WORKDIR`, `$TMPDIR`, `$XDG_*`, `$NONO_PACKAGES`); keys are not expanded. `PATH` and any `NONO_*` key are reserved and rejected at load time. Unlike inherited host vars, keys here are NOT subject to the dangerous-variable blocklist (`LD_PRELOAD`, `NODE_OPTIONS`, …) — setting one is an explicit operator decision. | +| `set_vars` | object (string→string) | `{}` | Static environment variables injected after allow/deny filtering and before credential injection (injected credentials win on conflict). Values support the same expansion as profile paths (`$HOME`, `~`, `$WORKDIR`, `$TMPDIR`, `$XDG_*`, `$NONO_CONFIG`, `$NONO_PACKAGES`); keys are not expanded. `PATH` and any `NONO_*` key are reserved and rejected at load time. Unlike inherited host vars, keys here are NOT subject to the dangerous-variable blocklist (`LD_PRELOAD`, `NODE_OPTIONS`, …) — setting one is an explicit operator decision. | Inheritance: child `allow_vars` and `deny_vars` are appended to base values and deduplicated; `set_vars` merges as a map, with the child's value winning on key conflict. diff --git a/crates/nono-cli/src/config/mod.rs b/crates/nono-cli/src/config/mod.rs index 53b73f3d1..f7af1c4bf 100644 --- a/crates/nono-cli/src/config/mod.rs +++ b/crates/nono-cli/src/config/mod.rs @@ -2,7 +2,7 @@ //! //! This module handles loading and merging configuration from multiple sources: //! - Embedded policy.json (composable security groups, single source of truth) -//! - User-level config at ~/.config/nono/ (overrides with acknowledgment) +//! - User-level config at `$XDG_CONFIG_HOME/nono/` (default `~/.config/nono/`) //! - CLI flags (highest precedence) pub mod embedded; @@ -57,10 +57,14 @@ pub fn validated_tmpdir() -> Result { } } -/// Get the user config directory path -#[allow(dead_code)] +/// User-level nono config root (`$XDG_CONFIG_HOME/nono`, default `~/.config/nono`). +/// +/// Uses the same XDG resolution as profiles and packages (`resolve_user_config_dir`), +/// not platform-specific dirs such as macOS `~/Library/Application Support`. pub fn user_config_dir() -> Option { - dirs::config_dir().map(|p| p.join("nono")) + crate::profile::resolve_user_config_dir() + .ok() + .map(|dir| dir.join("nono")) } /// Get the user state directory path (for version tracking and runtime state) @@ -206,6 +210,11 @@ mod tests { #[test] fn test_check_sensitive_path() { + let _guard = match crate::test_env::ENV_LOCK.lock() { + Ok(g) => g, + Err(p) => p.into_inner(), + }; + assert!( check_sensitive_path("~/.ssh") .expect("should not fail") @@ -235,8 +244,35 @@ mod tests { ); } + #[test] + fn test_user_config_dir_uses_xdg_fallback() { + let _guard = match crate::test_env::ENV_LOCK.lock() { + Ok(g) => g, + Err(p) => p.into_inner(), + }; + let tmp = tempfile::tempdir().expect("tempdir"); + let home = tmp.path().to_str().expect("temp path"); + let _env = crate::test_env::EnvVarGuard::set_all(&[ + ("HOME", home), + ("XDG_CONFIG_HOME", "__placeholder__"), + ]); + _env.remove("XDG_CONFIG_HOME"); + + let dir = user_config_dir().expect("user config dir"); + let expected = tmp.path().join(".config").join("nono"); + assert_eq!( + nono::try_canonicalize(&dir), + nono::try_canonicalize(&expected) + ); + } + #[test] fn test_check_sensitive_path_component_wise() { + let _guard = match crate::test_env::ENV_LOCK.lock() { + Ok(g) => g, + Err(p) => p.into_inner(), + }; + // ~/.sshevil must NOT match ~/.ssh (component-wise comparison) let home = validated_home().expect("HOME must be set"); let evil_path = format!("{}/.sshevil", home); diff --git a/crates/nono-cli/src/config/user.rs b/crates/nono-cli/src/config/user.rs index e3e97cdfd..f53163947 100644 --- a/crates/nono-cli/src/config/user.rs +++ b/crates/nono-cli/src/config/user.rs @@ -1,6 +1,7 @@ //! User configuration loading //! -//! Loads user-level configuration from ~/.config/nono/config.toml +//! Loads user-level configuration from `$XDG_CONFIG_HOME/nono/config.toml` +//! (default `~/.config/nono/config.toml`). #![allow(dead_code)] @@ -322,7 +323,8 @@ impl Default for RollbackSettings { } } -/// Load user configuration from ~/.config/nono/config.toml +/// Load user configuration from `$XDG_CONFIG_HOME/nono/config.toml` +/// (default `~/.config/nono/config.toml`). /// /// Returns None if the config file doesn't exist. /// Returns Err if the file exists but is malformed. diff --git a/crates/nono-cli/src/pack_update_hint.rs b/crates/nono-cli/src/pack_update_hint.rs index 60878f35b..cf9998f78 100644 --- a/crates/nono-cli/src/pack_update_hint.rs +++ b/crates/nono-cli/src/pack_update_hint.rs @@ -8,7 +8,7 @@ //! //! Respects `NONO_NO_PACK_UPDATE_HINTS=1`, plus the same opt-out as the CLI //! update check: `NONO_NO_UPDATE_CHECK=1` or `[updates] check = false` in -//! `~/.config/nono/config.toml`. +//! `$XDG_CONFIG_HOME/nono/config.toml` (default `~/.config/nono/config.toml`). use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; diff --git a/crates/nono-cli/src/profile/mod.rs b/crates/nono-cli/src/profile/mod.rs index d45c3c948..a8be75184 100644 --- a/crates/nono-cli/src/profile/mod.rs +++ b/crates/nono-cli/src/profile/mod.rs @@ -2,7 +2,7 @@ //! //! Profiles provide named configurations for common applications like //! claude-code, openclaw, and opencode. They can be built-in (compiled -//! into the binary) or user-defined (in ~/.config/nono/profiles/). +//! into the binary) or user-defined (in `$XDG_CONFIG_HOME/nono/profiles/`). pub(crate) mod builtin; @@ -1504,7 +1504,7 @@ pub struct EnvironmentConfig { /// Maps variable names to values, set after allow/deny filtering and before /// credential injection (so injected credentials win on conflict). Values /// support the same expansion as profile paths (`$HOME`, `~`, `$WORKDIR`, - /// `$TMPDIR`, `$XDG_*`, `$NONO_PACKAGES`). + /// `$TMPDIR`, `$XDG_*`, `$NONO_CONFIG`, `$NONO_PACKAGES`). /// /// `PATH` and any `NONO_*` key are reserved and rejected at parse time. /// Unlike inherited host variables, keys here are NOT subject to the @@ -1785,7 +1785,7 @@ impl<'de> Deserialize<'de> for Profile { /// Check whether a profile name is loaded from a user file rather than the built-in set. /// -/// Returns `true` when a user profile file exists at `~/.config/nono/profiles/.json`, +/// Returns `true` when a user profile file exists at `$XDG_CONFIG_HOME/nono/profiles/.json`, /// which means the user has overridden or shadowed any built-in profile of the same name. pub fn is_user_override(name: &str) -> bool { if !is_valid_profile_name(name) { @@ -1880,7 +1880,7 @@ pub fn resolve_profile_path(name_or_path: &str) -> Option { /// treated as a direct file path. Otherwise it is resolved as a profile name. /// /// Name loading precedence: -/// 1. User profiles from `~/.config/nono/profiles/.json` — never written +/// 1. User profiles from `$XDG_CONFIG_HOME/nono/profiles/.json` — never written /// by nono. Users (and Claude's "Option B" guidance) own this directory. /// 2. Pack-store scan — any installed pack with a profile artifact whose /// `install_as` matches the requested name. Self-heals Claude Code plugin @@ -2897,13 +2897,30 @@ pub(crate) fn resolve_user_profile_path(name: &str) -> Result { } pub(crate) fn user_profile_dir() -> Result { - Ok(resolve_user_config_dir()?.join("nono").join("profiles")) + Ok(crate::package::nono_config_dir()?.join("profiles")) +} + +/// Display hint for `$XDG_CONFIG_HOME/nono` when runtime resolution fails. +pub const NONO_CONFIG_DIR_HINT: &str = "$XDG_CONFIG_HOME/nono (default ~/.config/nono)"; + +/// Resolved user profile directory for user-facing output. +#[must_use] +pub fn display_user_profiles_dir() -> String { + user_profile_dir() + .map(|p| p.display().to_string()) + .unwrap_or_else(|_| format!("{NONO_CONFIG_DIR_HINT}/profiles")) +} + +/// Resolved user trust-policy path for user-facing output. +#[must_use] +pub fn display_trust_policy_path() -> String { + crate::package::nono_config_dir() + .map(|p| p.join("trust-policy.json").display().to_string()) + .unwrap_or_else(|_| format!("{NONO_CONFIG_DIR_HINT}/trust-policy.json")) } pub(crate) fn user_profile_draft_dir() -> Result { - Ok(resolve_user_config_dir()? - .join("nono") - .join("profile-drafts")) + Ok(crate::package::nono_config_dir()?.join("profile-drafts")) } pub(crate) fn get_user_profile_draft_path(name: &str) -> Result { @@ -2978,6 +2995,7 @@ pub(crate) fn is_valid_profile_name(name: &str) -> bool { /// - $WORKDIR: Working directory (--workdir or cwd) /// - $HOME: User's home directory /// - $XDG_CONFIG_HOME: XDG config directory +/// - $NONO_CONFIG: nono config root (`$XDG_CONFIG_HOME/nono`) /// - $XDG_DATA_HOME: XDG data directory /// - $XDG_STATE_HOME: XDG state directory /// - $XDG_CACHE_HOME: XDG cache directory @@ -3062,7 +3080,11 @@ pub fn expand_vars(path: &str, workdir: &Path) -> Result { expanded = expanded.replace("$XDG_RUNTIME_DIR", rt); } - // Expand $NONO_PACKAGES to the package store directory + // Expand $NONO_CONFIG / $NONO_PACKAGES to resolved nono config paths + if expanded.contains("$NONO_CONFIG") { + let config_dir = crate::package::nono_config_dir()?; + expanded = expanded.replace("$NONO_CONFIG", &config_dir.to_string_lossy()); + } if expanded.contains("$NONO_PACKAGES") { let packages_dir = crate::package::package_store_dir()?; expanded = expanded.replace("$NONO_PACKAGES", &packages_dir.to_string_lossy()); @@ -3336,6 +3358,49 @@ mod tests { assert_eq!(expanded, PathBuf::from("/home/user/.local/state/history")); } + #[test] + fn test_expand_vars_xdg_config_home() { + let _guard = match crate::test_env::ENV_LOCK.lock() { + Ok(g) => g, + Err(p) => p.into_inner(), + }; + let _env = crate::test_env::EnvVarGuard::set_all(&[ + ("HOME", "/home/user"), + ("XDG_CONFIG_HOME", "/custom/config"), + ]); + + let workdir = PathBuf::from("/projects/myapp"); + let expanded = expand_vars("$XDG_CONFIG_HOME/nono/profiles", &workdir).expect("valid env"); + assert_eq!(expanded, PathBuf::from("/custom/config/nono/profiles")); + + _env.remove("XDG_CONFIG_HOME"); + let expanded = expand_vars("$XDG_CONFIG_HOME/nono/profiles", &workdir).expect("valid env"); + assert_eq!(expanded, PathBuf::from("/home/user/.config/nono/profiles")); + } + + #[test] + fn test_expand_vars_nono_config() { + let _guard = match crate::test_env::ENV_LOCK.lock() { + Ok(g) => g, + Err(p) => p.into_inner(), + }; + let tmp = tempfile::tempdir().expect("tempdir"); + let config_home = tmp.path().join("config"); + std::fs::create_dir_all(&config_home).expect("create config home"); + let config_home_str = config_home.to_string_lossy().to_string(); + let _env = crate::test_env::EnvVarGuard::set_all(&[ + ("HOME", "/home/user"), + ("XDG_CONFIG_HOME", &config_home_str), + ]); + + let workdir = PathBuf::from("/projects/myapp"); + let expanded = expand_vars("$NONO_CONFIG/profiles", &workdir).expect("valid env"); + assert_eq!( + nono::try_canonicalize(&expanded), + nono::try_canonicalize(&config_home.join("nono").join("profiles")) + ); + } + #[test] fn test_expand_vars_xdg_cache_home() { let _guard = match crate::test_env::ENV_LOCK.lock() { diff --git a/crates/nono-cli/src/profile_cmd.rs b/crates/nono-cli/src/profile_cmd.rs index a4452f3f6..d7b3e1298 100644 --- a/crates/nono-cli/src/profile_cmd.rs +++ b/crates/nono-cli/src/profile_cmd.rs @@ -787,7 +787,11 @@ pub(crate) fn cmd_list(args: ProfileListArgs) -> Result<()> { println!(); println!( " {}", - theme::fg("User (~/.config/nono/profiles/):", t.subtext).bold() + theme::fg( + &format!("User ({}):", profile::display_user_profiles_dir()), + t.subtext + ) + .bold() ); for (name, result) in &user_profiles { print_profile_line(name, result, t); diff --git a/crates/nono-cli/src/profile_save_runtime.rs b/crates/nono-cli/src/profile_save_runtime.rs index abc49efe4..af27d4cb6 100644 --- a/crates/nono-cli/src/profile_save_runtime.rs +++ b/crates/nono-cli/src/profile_save_runtime.rs @@ -571,7 +571,7 @@ fn profile_name_from_command(cmd_name: &str) -> Option { } } -/// Return true when writing `~/.config/nono/profiles/.json` would shadow +/// Return true when writing `$XDG_CONFIG_HOME/nono/profiles/.json` would shadow /// a built-in or installed pack profile of the same name. User files are loaded /// in preference to built-ins and pack-store profiles, so saving under an /// existing profile's name silently reroutes all future `--profile ` diff --git a/crates/nono-cli/src/setup.rs b/crates/nono-cli/src/setup.rs index eacc48bfe..63d4a31d2 100644 --- a/crates/nono-cli/src/setup.rs +++ b/crates/nono-cli/src/setup.rs @@ -404,11 +404,10 @@ impl SetupRunner { if self.generate_profiles { println!("Custom profiles:"); - let profile_dir = crate::profile::resolve_user_config_dir() - .map(|p| p.join("nono").join("profiles")) - .map(|p| p.display().to_string()) - .unwrap_or_else(|_| "~/.config/nono/profiles".to_string()); - println!(" Edit example profiles in: {}", profile_dir); + println!( + " Edit example profiles in: {}", + crate::profile::display_user_profiles_dir() + ); println!(); } diff --git a/crates/nono-cli/src/trust_cmd.rs b/crates/nono-cli/src/trust_cmd.rs index f28bac2a3..f484f6106 100644 --- a/crates/nono-cli/src/trust_cmd.rs +++ b/crates/nono-cli/src/trust_cmd.rs @@ -704,7 +704,7 @@ fn run_sign_policy(args: TrustSignPolicyArgs) -> Result<()> { None if args.user => { let user_path = user_trust_policy_path().ok_or_else(|| nono::NonoError::TrustSigning { - path: "~/.config/nono/trust-policy.json".to_string(), + path: crate::profile::display_trust_policy_path(), reason: "could not resolve user config directory".to_string(), })?; if !user_path.exists() { @@ -1362,7 +1362,7 @@ fn load_trust_policy(explicit_path: Option<&Path>) -> Result } let user_path = user_trust_policy_path() .map(|p| p.display().to_string()) - .unwrap_or_else(|| "~/.config/nono/trust-policy.json".to_string()); + .unwrap_or_else(crate::profile::display_trust_policy_path); eprintln!( " {}", "Warning: project-level trust-policy.json found but no user-level policy exists." @@ -1436,9 +1436,9 @@ pub(crate) fn user_trust_policy_path() -> Option { } } - crate::profile::resolve_user_config_dir() + crate::package::nono_config_dir() .ok() - .map(|d| d.join("nono").join("trust-policy.json")) + .map(|d| d.join("trust-policy.json")) } // --------------------------------------------------------------------------- diff --git a/crates/nono-cli/src/trust_scan.rs b/crates/nono-cli/src/trust_scan.rs index cba6f2723..a6f3ea8ff 100644 --- a/crates/nono-cli/src/trust_scan.rs +++ b/crates/nono-cli/src/trust_scan.rs @@ -69,7 +69,7 @@ pub fn load_scan_policy( let policy_path = user_path .as_deref() .map(|p| p.display().to_string()) - .unwrap_or_else(|| "~/.config/nono/trust-policy.json".to_string()); + .unwrap_or_else(crate::profile::display_trust_policy_path); eprintln!( " {}", format!("Create a signed policy at {policy_path} to enforce verification.") diff --git a/crates/nono-cli/src/update_check.rs b/crates/nono-cli/src/update_check.rs index 45a1d86b1..76407be10 100644 --- a/crates/nono-cli/src/update_check.rs +++ b/crates/nono-cli/src/update_check.rs @@ -10,7 +10,7 @@ //! No IP addresses are logged by the update service. //! //! To disable the update check, set `NONO_NO_UPDATE_CHECK=1` or add -//! `[updates] check = false` to `~/.config/nono/config.toml`. +//! `[updates] check = false` to `$XDG_CONFIG_HOME/nono/config.toml` (default `~/.config/nono/config.toml`). use chrono::{DateTime, Utc}; use nono::Result; diff --git a/crates/nono-cli/src/wiring.rs b/crates/nono-cli/src/wiring.rs index 4f5f3af78..b5cb281cc 100644 --- a/crates/nono-cli/src/wiring.rs +++ b/crates/nono-cli/src/wiring.rs @@ -15,8 +15,9 @@ //! reverse — the install plan never has to be re-derived. //! - Variables expanded at execution time: `$PACK_DIR`, `$NS` //! (pack namespace), `$PLUGIN` (pack name, the second segment -//! of `/`), `$HOME`, `$XDG_CONFIG_HOME`. No shell -//! evaluation, no user-controlled inputs flow in. +//! of `/`), `$HOME`, `$XDG_CONFIG_HOME`, `$NONO_CONFIG`, +//! `$NONO_PACKAGES`. No shell evaluation, no user-controlled inputs +//! flow in. //! - Idempotent: re-running a directive with the same inputs is a //! no-op and reports `wiring_changed = false`. @@ -679,10 +680,16 @@ fn expand_vars(template: &str, ctx: &WiringContext) -> Result { .filter(|v| !v.is_empty()) .map(PathBuf::from) .unwrap_or_else(|| home.join(".config")); + let nono_config = crate::package::nono_config_dir() + .map_err(|e| NonoError::PackageInstall(format!("failed to resolve $NONO_CONFIG: {e}")))?; + let nono_packages = crate::package::package_store_dir() + .map_err(|e| NonoError::PackageInstall(format!("failed to resolve $NONO_PACKAGES: {e}")))?; let pack_dir = ctx.pack_dir.to_string_lossy().into_owned(); let home_str = home.to_string_lossy().into_owned(); let xdg_str = xdg_config_home.to_string_lossy().into_owned(); + let nono_config_str = nono_config.to_string_lossy().into_owned(); + let nono_packages_str = nono_packages.to_string_lossy().into_owned(); // `$` is a variable sigil only when followed by an ASCII uppercase // letter or underscore — i.e. the start of an identifier from the @@ -719,6 +726,8 @@ fn expand_vars(template: &str, ctx: &WiringContext) -> Result { "PLUGIN" => ctx.pack_name.clone(), "HOME" => home_str.clone(), "XDG_CONFIG_HOME" => xdg_str.clone(), + "NONO_CONFIG" => nono_config_str.clone(), + "NONO_PACKAGES" => nono_packages_str.clone(), // Install-time UTC timestamp (RFC3339, milliseconds), for // agents that require a `lastUpdated`-style field on their // config entries (Claude Code's marketplace registry being @@ -1601,13 +1610,54 @@ mod tests { Ok(g) => g, Err(p) => p.into_inner(), }; - let _env = EnvVarGuard::set_all(&[("HOME", "/h")]); + let _env = EnvVarGuard::set_all(&[("HOME", "/h"), ("XDG_CONFIG_HOME", "__placeholder__")]); + _env.remove("XDG_CONFIG_HOME"); assert_eq!(expand_vars("$PACK_DIR/x", &ctx).expect("expand"), "/p/x"); assert_eq!(expand_vars("$NS/$PLUGIN", &ctx).expect("expand"), "ns/name"); assert_eq!( expand_vars("$HOME/.config", &ctx).expect("expand"), "/h/.config" ); + assert_eq!( + expand_vars("$NONO_CONFIG/profile-drafts", &ctx).expect("expand"), + "/h/.config/nono/profile-drafts" + ); + assert_eq!( + expand_vars("$NONO_PACKAGES/always-further/claude", &ctx).expect("expand"), + "/h/.config/nono/packages/always-further/claude" + ); + } + + #[test] + fn expand_vars_nono_config_respects_xdg_config_home() { + let _g = match ENV_LOCK.lock() { + Ok(g) => g, + Err(p) => p.into_inner(), + }; + let home = TempDir::new().expect("home tempdir"); + let config = TempDir::new().expect("config tempdir"); + let home_str = home.path().to_str().expect("home path"); + let config_str = config.path().to_str().expect("config path"); + let ctx = WiringContext { + pack_dir: PathBuf::from("/p"), + namespace: "always-further".to_string(), + pack_name: "claude".to_string(), + }; + let _env = EnvVarGuard::set_all(&[("HOME", home_str), ("XDG_CONFIG_HOME", config_str)]); + let expected_config = format!("{config_str}/nono/profile-drafts"); + let expected_packages = format!("{config_str}/nono/packages/always-further/claude"); + assert_eq!( + nono::try_canonicalize(Path::new( + &expand_vars("$NONO_CONFIG/profile-drafts", &ctx).expect("expand") + )), + nono::try_canonicalize(Path::new(&expected_config)) + ); + assert_eq!( + nono::try_canonicalize(Path::new( + &expand_vars("$NONO_PACKAGES/always-further/claude", &ctx).expect("expand") + )), + nono::try_canonicalize(Path::new(&expected_packages)) + ); } #[test] diff --git a/crates/nono-cli/tests/deprecated_schema.rs b/crates/nono-cli/tests/deprecated_schema.rs index 63248597b..3ee1c02ee 100644 --- a/crates/nono-cli/tests/deprecated_schema.rs +++ b/crates/nono-cli/tests/deprecated_schema.rs @@ -727,7 +727,7 @@ fn legacy_keys_in_extends_parent_emit_warnings_once_via_child() { // WarningSuppressionGuard fixes). // // `extends` resolves by profile name through the user-profiles dir - // (`$HOME/.config/nono/profiles/`) or built-ins, so we override HOME + // (`$XDG_CONFIG_HOME/nono/profiles/`) or built-ins, so we override HOME // to a tempdir for the spawned `nono` invocation and write the // parent there. The child references the parent by name. let dir = tempfile::tempdir().expect("tempdir"); diff --git a/docs/cli/features/environment.mdx b/docs/cli/features/environment.mdx index 595d6437a..8cc661bc6 100644 --- a/docs/cli/features/environment.mdx +++ b/docs/cli/features/environment.mdx @@ -144,7 +144,7 @@ Use `set_vars` to inject environment variables with specific values into the san **Ordering:** `set_vars` is applied **after** allow/deny filtering and **before** credential injection. If a key in `set_vars` collides with a variable injected via `env_credentials`, the injected credential wins. A `set_vars` value overrides any inherited value with the same name. -**Variable expansion:** values support the same expansion as profile paths — `$HOME`, `~`, `$WORKDIR`, `$TMPDIR`, `$UID`, `$XDG_CONFIG_HOME`, `$XDG_DATA_HOME`, `$XDG_STATE_HOME`, `$XDG_CACHE_HOME`, `$XDG_RUNTIME_DIR`, and `$NONO_PACKAGES`. For example, `"$HOME/.config"` expands to the user's config directory. Keys are never expanded. +**Variable expansion:** values support the same expansion as profile paths — `$HOME`, `~`, `$WORKDIR`, `$TMPDIR`, `$UID`, `$XDG_CONFIG_HOME`, `$XDG_DATA_HOME`, `$XDG_STATE_HOME`, `$XDG_CACHE_HOME`, `$XDG_RUNTIME_DIR`, `$NONO_CONFIG`, and `$NONO_PACKAGES`. When `XDG_CONFIG_HOME` is unset — typical on macOS — `$XDG_CONFIG_HOME` expands to `$HOME/.config`, so `"$XDG_CONFIG_HOME/nono"` is the same as `~/.config/nono`. Keys are never expanded. **Reserved keys:** `PATH` and any key starting with `NONO_` are reserved and rejected at profile load time (`PATH` is controlled via `allow_vars`/`deny_vars`; the `NONO_*` prefix is reserved for nono's own injected variables). Keys must be valid environment variable names (`[A-Za-z_][A-Za-z0-9_]*`). diff --git a/docs/cli/features/profiles-groups.mdx b/docs/cli/features/profiles-groups.mdx index 57f96549e..6b832e227 100644 --- a/docs/cli/features/profiles-groups.mdx +++ b/docs/cli/features/profiles-groups.mdx @@ -39,6 +39,14 @@ Profiles can come from three sources, in order of precedence: CLI flags always override profile settings. + + nono resolves config paths via `$XDG_CONFIG_HOME/nono/` on every platform. If + you have not set `XDG_CONFIG_HOME` — typical on macOS — that is the same as + **`~/.config/nono/`** shown above. Set `XDG_CONFIG_HOME` only when you want + config elsewhere; profile JSON path grants should use `$XDG_CONFIG_HOME`, + `$NONO_CONFIG`, or `$NONO_PACKAGES` rather than hardcoded `$HOME/.config/...`. + + ### Profile Format Profiles use JSON format: @@ -479,7 +487,9 @@ Profiles support these environment variables in path values: |----------|------------| | `$WORKDIR` | Current working directory (from `--workdir` or cwd) | | `$HOME` | User's home directory | -| `$XDG_CONFIG_HOME` | XDG config directory (default: `~/.config`) | +| `$XDG_CONFIG_HOME` | XDG config directory (default: `~/.config`; used for `~/.config/nono/` when unset) | +| `$NONO_CONFIG` | nono config root (`$XDG_CONFIG_HOME/nono`, default `~/.config/nono`) | +| `$NONO_PACKAGES` | Installed pack store (`$NONO_CONFIG/packages`) | | `$XDG_DATA_HOME` | XDG data directory (default: `~/.local/share`) | | `$XDG_STATE_HOME` | XDG state directory (default: `~/.local/state`) | | `$XDG_CACHE_HOME` | XDG cache directory (default: `~/.cache`) | diff --git a/docs/cli/features/trust.mdx b/docs/cli/features/trust.mdx index bcf02aaa6..0fce09677 100644 --- a/docs/cli/features/trust.mdx +++ b/docs/cli/features/trust.mdx @@ -451,7 +451,7 @@ Trust policies compose across three levels: | Level | Location | Purpose | |-------|----------|---------| | Embedded | Built into nono binary | Baseline include patterns | -| User | `$XDG_CONFIG_HOME/nono/trust-policy.json` | Personal trusted publishers | +| User | `~/.config/nono/trust-policy.json` | Personal trusted publishers | | Project | `/trust-policy.json` | Project-specific publishers | Merging rules: diff --git a/docs/cli/internals/wsl2-feature-matrix.mdx b/docs/cli/internals/wsl2-feature-matrix.mdx index 28759e011..30e8719b7 100644 --- a/docs/cli/internals/wsl2-feature-matrix.mdx +++ b/docs/cli/internals/wsl2-feature-matrix.mdx @@ -190,7 +190,7 @@ Legend: **Full** = identical to native Linux | **Blocked (default)** = fails sec |---------|--------|-------| | Environment sanitization | Full | | | `NONO_*` env var support | Full | | -| User config (~/.config/nono/) | Full | | +| User config (`~/.config/nono/`) | Full | | | Embedded policy (policy.json) | Full | | ## Summary diff --git a/scripts/claude-clear-nono.sh b/scripts/claude-clear-nono.sh index 16652a446..518e8e324 100755 --- a/scripts/claude-clear-nono.sh +++ b/scripts/claude-clear-nono.sh @@ -1,13 +1,13 @@ #!/usr/bin/env bash # Clean up nono-managed Claude Code state for a fresh test of # `nono run --profile always-further/claude -- claude`. Removes: -# - the pulled pack at ~/.config/nono/packages/always-further/claude -# - any leftover legacy symlink at ~/.config/nono/profiles/claude-code.json +# - the pulled pack at $XDG_CONFIG_HOME/nono/packages/always-further/claude +# - any leftover legacy symlink at $XDG_CONFIG_HOME/nono/profiles/claude-code.json # - the bare pre-marketplace symlink at ~/.claude/plugins/nono # - the synthesised marketplace at ~/.claude/plugins/marketplaces/always-further # - the cache dir at ~/.claude/plugins/cache/always-further # - the `always-further/claude` entry from -# ~/.config/nono/packages/lockfile.json (so `nono pull` re-installs +# $XDG_CONFIG_HOME/nono/packages/lockfile.json (so `nono pull` re-installs # instead of short-circuiting on "already up to date") # - the `nono@always-further` and bare `nono` entries in # ~/.claude/plugins/installed_plugins.json, @@ -16,8 +16,10 @@ set -euo pipefail -rm -f "$HOME/.config/nono/profiles/claude-code.json" 2>/dev/null || true -rm -rf "$HOME/.config/nono/packages/always-further/claude" 2>/dev/null || true +NONO_CONFIG="${XDG_CONFIG_HOME:-$HOME/.config}/nono" + +rm -f "$NONO_CONFIG/profiles/claude-code.json" 2>/dev/null || true +rm -rf "$NONO_CONFIG/packages/always-further/claude" 2>/dev/null || true rm -rf "$HOME/.claude/plugins/nono" 2>/dev/null || true rm -rf "$HOME/.claude/plugins/marketplaces/always-further" 2>/dev/null || true rm -rf "$HOME/.claude/plugins/cache/always-further" 2>/dev/null || true @@ -50,7 +52,7 @@ strip_with_jq "$HOME/.claude/plugins/installed_plugins.json" \ 'del(.plugins["nono@always-further"])' strip_with_jq "$HOME/.claude/plugins/known_marketplaces.json" \ 'del(."always-further")' -strip_with_jq "$HOME/.config/nono/packages/lockfile.json" \ +strip_with_jq "$NONO_CONFIG/packages/lockfile.json" \ 'del(.packages["always-further/claude"])' echo "cleared nono-managed Claude Code state." diff --git a/scripts/codex-clear-nono.sh b/scripts/codex-clear-nono.sh index 94d43bded..5fd660712 100755 --- a/scripts/codex-clear-nono.sh +++ b/scripts/codex-clear-nono.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash # Clean up nono-managed Codex state for a fresh test of # `nono run --profile always-further/codex -- codex`. Removes: -# - the pulled pack at ~/.config/nono/packages/always-further/codex +# - the pulled pack at $XDG_CONFIG_HOME/nono/packages/always-further/codex # - the cache subtree at ~/.codex/plugins/cache/always-further # - the nono-managed fenced block in ~/.codex/config.toml (between # the `# >>> nono-managed (do not edit) >>>` markers — registers @@ -9,7 +9,7 @@ # - hook entries in ~/.codex/hooks.json whose command path points # into the nono pack store # - the `always-further/codex` entry from -# ~/.config/nono/packages/lockfile.json (so `nono pull` re-installs +# $XDG_CONFIG_HOME/nono/packages/lockfile.json (so `nono pull` re-installs # instead of short-circuiting on "already up to date") # # Does NOT touch: @@ -17,14 +17,16 @@ # (so `[features] codex_hooks = true`, your model + project trust # settings, etc. all stay) # - ~/.codex/auth.json or ~/.codex/sessions/* -# - ~/.config/nono/profiles/ (your own profiles) +# - $XDG_CONFIG_HOME/nono/profiles/ (your own profiles) set -euo pipefail -PACK_STORE="$HOME/.config/nono/packages/always-further/codex" +NONO_CONFIG="${XDG_CONFIG_HOME:-$HOME/.config}/nono" + +PACK_STORE="$NONO_CONFIG/packages/always-further/codex" CONFIG_TOML="$HOME/.codex/config.toml" HOOKS_JSON="$HOME/.codex/hooks.json" -LOCKFILE="$HOME/.config/nono/packages/lockfile.json" +LOCKFILE="$NONO_CONFIG/packages/lockfile.json" rm -rf "$PACK_STORE" 2>/dev/null || true rm -rf "$HOME/.codex/plugins/cache/always-further" 2>/dev/null || true diff --git a/tests/integration/test_bypass_protection.sh b/tests/integration/test_bypass_protection.sh index 059b37d88..953627574 100755 --- a/tests/integration/test_bypass_protection.sh +++ b/tests/integration/test_bypass_protection.sh @@ -162,8 +162,8 @@ echo "--- Profile Inheritance ---" if [[ -d "$DOCKER_DIR" ]]; then # Child profile inherits bypass_protection from parent via user profiles directory. - # The extends field resolves by name from ~/.config/nono/profiles/. - USER_PROFILES_DIR="$HOME/.config/nono/profiles" + # The extends field resolves by name from $XDG_CONFIG_HOME/nono/profiles/. + USER_PROFILES_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/nono/profiles" CREATED_USER_PROFILES=0 if [[ ! -d "$USER_PROFILES_DIR" ]]; then mkdir -p "$USER_PROFILES_DIR" diff --git a/tests/integration/test_pack_resolution.sh b/tests/integration/test_pack_resolution.sh index 36f668b00..86a504ab9 100755 --- a/tests/integration/test_pack_resolution.sh +++ b/tests/integration/test_pack_resolution.sh @@ -54,7 +54,7 @@ if [[ ! -f "$FIXTURE_DIR/package.json" ]]; then fi # Use a per-run XDG_CONFIG_HOME so the test pack-store install never -# touches the user's real ~/.config/nono. The pack-store resolver +# touches the user's real $XDG_CONFIG_HOME/nono. The pack-store resolver # walks $XDG_CONFIG_HOME/nono/packages/, so by isolating that root # we get clean install + cleanup for free. TMPDIR=$(setup_test_dir) From 9b7412d7e6ca862a165173de55a9800d904edab2 Mon Sep 17 00:00:00 2001 From: Anil Kulkarni <6687139+intentionally-left-nil@users.noreply.github.com> Date: Wed, 17 Jun 2026 13:34:30 -0700 Subject: [PATCH 79/91] feat: [aws] implement aws_auth config (#1166) * AWS: Implement aws_auth config This adds the aws_auth struct to customCredentials, with optional subkeys: profile, region, and service. If the aws_auth subkey is present at all, the route will be signed with AWS credentials in the future The subkey is mutually exclusive with credential_key and auth. The routes with aws_auth are wired through to the proxy, where a 501 not implemented is returned for any route using the config Signed-off-by: Anil Kulkarni * PR feedback Signed-off-by: Anil Kulkarni --------- Signed-off-by: Anil Kulkarni --- crates/nono-cli/data/nono-profile.schema.json | 119 +++-- crates/nono-cli/src/network_policy.rs | 14 + crates/nono-cli/src/profile/mod.rs | 502 +++++++++++++++++- crates/nono-proxy/src/config.rs | 132 +++++ crates/nono-proxy/src/credential.rs | 40 +- crates/nono-proxy/src/reverse.rs | 15 +- crates/nono-proxy/src/route.rs | 40 +- crates/nono-proxy/src/server.rs | 15 + crates/nono-proxy/src/tls_intercept/handle.rs | 15 +- 9 files changed, 833 insertions(+), 59 deletions(-) diff --git a/crates/nono-cli/data/nono-profile.schema.json b/crates/nono-cli/data/nono-profile.schema.json index 8a683a999..74d5b8d93 100644 --- a/crates/nono-cli/data/nono-profile.schema.json +++ b/crates/nono-cli/data/nono-profile.schema.json @@ -486,24 +486,31 @@ } } }, - "CustomCredentialDef": { - "type": "object", - "description": "Custom credential route definition for the reverse proxy. Defines how to route requests and inject credentials for a service. Must have either credential_key or auth (mutually exclusive).", - "additionalProperties": false, - "required": ["upstream"], - "properties": { - "upstream": { - "type": "string", - "description": "Upstream URL to proxy requests to (e.g., \"https://api.telegram.org\"). Must use HTTPS, or HTTP only for loopback addresses." - }, - "credential_key": { - "type": "string", - "description": "Keystore account name for the credential (e.g., \"telegram_bot_token\"), or a 1Password op:// URI, Bitwarden bw:// URI, apple-password:// URI, file:// URI, or env:// URI (e.g., \"env://MY_TOKEN\"). Mutually exclusive with auth." - }, - "auth": { - "$ref": "#/$defs/OAuth2Config", - "description": "OAuth2 client_credentials configuration. When present, the proxy handles token exchange automatically. Mutually exclusive with credential_key." - }, + "CustomCredentialDef": { + "type": "object", + "description": "Custom credential route definition for the reverse proxy. Defines how to route requests and inject credentials for a service. Must have exactly one of: credential_key, auth, or aws_auth (mutually exclusive).", + "additionalProperties": false, + "required": ["upstream"], + "properties": { + "upstream": { + "type": "string", + "description": "Upstream URL to proxy requests to (e.g., \"https://api.telegram.org\"). Must use HTTPS, or HTTP only for loopback addresses." + }, + "credential_key": { + "type": "string", + "description": "Keystore account name for the credential (e.g., \"telegram_bot_token\"), or a 1Password op:// URI, Bitwarden bw:// URI, apple-password:// URI, file:// URI, or env:// URI (e.g., \"env://MY_TOKEN\"). Mutually exclusive with auth and aws_auth." + }, + "auth": { + "$ref": "#/$defs/OAuth2Config", + "description": "OAuth2 client_credentials configuration. When present, the proxy handles token exchange automatically. Mutually exclusive with credential_key and aws_auth." + }, + "aws_auth": { + "oneOf": [ + { "$ref": "#/$defs/AwsAuthConfig" }, + { "type": "null" } + ], + "description": "AWS SigV4 signing configuration. When present, the proxy signs outbound requests with AWS credentials. Mutually exclusive with credential_key and auth." + }, "inject_mode": { "$ref": "#/$defs/InjectMode", "description": "Credential injection mode. Determines how the credential is inserted into outbound requests.", @@ -676,30 +683,58 @@ } }, "OAuth2Config": { - "type": "object", - "description": "OAuth2 client_credentials configuration for automatic token exchange. The proxy exchanges client_id + client_secret for an access_token, caches it, and refreshes automatically before expiry.", - "additionalProperties": false, - "required": ["token_url", "client_id", "client_secret"], - "properties": { - "token_url": { - "type": "string", - "description": "Token endpoint URL (e.g., \"https://auth.example.com/oauth/token\"). Must use HTTPS, or HTTP only for loopback addresses." - }, - "client_id": { - "type": "string", - "description": "Client ID — plain value or credential reference (env://, file://, op://)." - }, - "client_secret": { - "type": "string", - "description": "Client secret — credential reference (env://, file://, op://)." - }, - "scope": { - "type": "string", - "description": "OAuth2 scopes (space-separated). Empty or omitted means no scope parameter is sent.", - "default": "" - } - } - }, + "type": "object", + "description": "OAuth2 client_credentials configuration for automatic token exchange. The proxy exchanges client_id + client_secret for an access_token, caches it, and refreshes automatically before expiry.", + "additionalProperties": false, + "required": ["token_url", "client_id", "client_secret"], + "properties": { + "token_url": { + "type": "string", + "description": "Token endpoint URL (e.g., \"https://auth.example.com/oauth/token\"). Must use HTTPS, or HTTP only for loopback addresses." + }, + "client_id": { + "type": "string", + "description": "Client ID — plain value or credential reference (env://, file://, op://)." + }, + "client_secret": { + "type": "string", + "description": "Client secret — credential reference (env://, file://, op://)." + }, + "scope": { + "type": "string", + "description": "OAuth2 scopes (space-separated). Empty or omitted means no scope parameter is sent.", + "default": "" + } + } + }, + "AwsAuthConfig": { + "type": "object", + "description": "AWS SigV4 signing configuration for a credential route. All fields are optional: an empty {} block uses the default credential chain with region and service auto-detected from the upstream URL.", + "additionalProperties": false, + "properties": { + "profile": { + "oneOf": [ + { "type": "string" }, + { "type": "null" } + ], + "description": "AWS profile name to use for credentials. If omitted, the default credential chain is used (environment variables → AWS_PROFILE → default profile → instance metadata). Must be non-empty if set." + }, + "region": { + "oneOf": [ + { "type": "string" }, + { "type": "null" } + ], + "description": "Explicit SigV4 signing region (e.g., \"us-east-1\"). If omitted, auto-detected from the upstream URL. Must be non-empty and contain no whitespace if set." + }, + "service": { + "oneOf": [ + { "type": "string" }, + { "type": "null" } + ], + "description": "Explicit SigV4 service name (e.g., \"bedrock\", \"s3\"). If omitted, auto-detected from the upstream URL. Must be non-empty and contain no whitespace if set." + } + } + }, "SecretsConfig": { "type": "object", "description": "Maps keystore account names (or op://, bw://, apple-password://, env:// URIs) to environment variable names. Secrets are loaded from the system keystore under the service name \"nono\".", diff --git a/crates/nono-cli/src/network_policy.rs b/crates/nono-cli/src/network_policy.rs index 6022194ce..d0df6c063 100644 --- a/crates/nono-cli/src/network_policy.rs +++ b/crates/nono-cli/src/network_policy.rs @@ -257,6 +257,7 @@ pub fn resolve_credentials( }) .transpose()?, oauth2, + aws_auth: cred.aws_auth.clone(), }); } else if let Some(cred) = policy.credentials.get(name) { // Validate env_var against dangerous variable blocklist @@ -288,6 +289,7 @@ pub fn resolve_credentials( tls_client_cert: None, tls_client_key: None, oauth2: None, + aws_auth: None, }); } // We already validated existence above, so this else branch won't be hit @@ -419,6 +421,7 @@ pub fn partition_allow_domain( tls_client_cert: None, tls_client_key: None, oauth2: None, + aws_auth: None, }); } } @@ -572,6 +575,7 @@ mod tests { tls_ca: None, tls_client_cert: None, tls_client_key: None, + aws_auth: None, }, ); @@ -612,6 +616,7 @@ mod tests { tls_ca: None, tls_client_cert: None, tls_client_key: None, + aws_auth: None, }, ); @@ -648,6 +653,7 @@ mod tests { tls_ca: None, tls_client_cert: None, tls_client_key: None, + aws_auth: None, }, ); @@ -694,6 +700,7 @@ mod tests { tls_ca: None, tls_client_cert: None, tls_client_key: None, + aws_auth: None, }, ); @@ -780,6 +787,7 @@ mod tests { tls_ca: None, tls_client_cert: None, tls_client_key: None, + aws_auth: None, }, ); @@ -813,6 +821,7 @@ mod tests { tls_ca: None, tls_client_cert: None, tls_client_key: None, + aws_auth: None, }, ); @@ -846,6 +855,7 @@ mod tests { tls_ca: None, tls_client_cert: None, tls_client_key: None, + aws_auth: None, }, ); @@ -884,6 +894,7 @@ mod tests { tls_ca: None, tls_client_cert: None, tls_client_key: None, + aws_auth: None, }, ); @@ -1016,6 +1027,7 @@ mod tests { tls_ca: None, tls_client_cert: None, tls_client_key: None, + aws_auth: None, }, ); @@ -1104,6 +1116,7 @@ mod tests { tls_ca: None, tls_client_cert: None, tls_client_key: None, + aws_auth: None, }, ); @@ -1152,6 +1165,7 @@ mod tests { tls_ca: None, tls_client_cert: None, tls_client_key: None, + aws_auth: None, }, ); diff --git a/crates/nono-cli/src/profile/mod.rs b/crates/nono-cli/src/profile/mod.rs index a8be75184..2995504e0 100644 --- a/crates/nono-cli/src/profile/mod.rs +++ b/crates/nono-cli/src/profile/mod.rs @@ -340,6 +340,14 @@ pub struct CustomCredentialDef { /// to the certificate in `tls_client_cert`. #[serde(default)] pub tls_client_key: Option, + + /// Optional AWS SigV4 signing configuration. + /// + /// When present, the proxy will sign outbound requests with AWS SigV4 + /// credentials resolved from the configured profile (or the default + /// credential chain). Mutually exclusive with `credential_key` and `auth`. + #[serde(default)] + pub aws_auth: Option, } fn default_inject_header() -> String { @@ -446,6 +454,15 @@ fn validate_credential_key(context_name: &str, key: &str) -> Result<()> { /// - `query_param`: query_param_name required, valid query param name /// - `basic_auth`: no additional required fields fn validate_custom_credential(name: &str, cred: &CustomCredentialDef) -> Result<()> { + // Mutual exclusion: aws_auth is incompatible with credential_key and auth. + if cred.aws_auth.is_some() && (cred.credential_key.is_some() || cred.auth.is_some()) { + return Err(NonoError::ProfileParse(format!( + "custom credential '{}' has 'aws_auth' set together with 'credential_key' or 'auth'; \ + aws_auth is mutually exclusive with both — remove the other auth field", + name + ))); + } + // Mutual exclusion: credential_key and auth cannot both be set if cred.credential_key.is_some() && cred.auth.is_some() { return Err(NonoError::ProfileParse(format!( @@ -455,10 +472,10 @@ fn validate_custom_credential(name: &str, cred: &CustomCredentialDef) -> Result< ))); } - // At least one of credential_key or auth must be set - if cred.credential_key.is_none() && cred.auth.is_none() { + // At least one of credential_key, auth, or aws_auth must be set + if cred.credential_key.is_none() && cred.auth.is_none() && cred.aws_auth.is_none() { return Err(NonoError::ProfileParse(format!( - "custom credential '{}' must have either 'credential_key' or 'auth' set", + "custom credential '{}' must have either 'credential_key', 'auth', or 'aws_auth' set", name ))); } @@ -468,6 +485,11 @@ fn validate_custom_credential(name: &str, cred: &CustomCredentialDef) -> Result< validate_oauth2_auth(name, auth)?; } + // Validate aws_auth if present + if let Some(ref aws) = cred.aws_auth { + validate_aws_auth(name, aws)?; + } + // Validate credential_key if present if let Some(ref key) = cred.credential_key { validate_credential_key(name, key)?; @@ -687,6 +709,51 @@ fn validate_oauth2_auth(name: &str, auth: &OAuth2Config) -> Result<()> { Ok(()) } +/// Validate AWS SigV4 signing configuration subfields. +/// +/// - `profile`: non-empty, no whitespace (whitespace breaks the AWS INI config +/// parser; see aws/aws-cli#2806). Mixed case is allowed — profile names are +/// case-sensitive. +/// - `region` / `service`: non-empty, lowercase, no whitespace. The SigV4 +/// credential scope requires lowercase region and service codes. +fn validate_aws_auth(name: &str, aws: &nono_proxy::config::AwsAuthConfig) -> Result<()> { + if let Some(ref profile) = aws.profile + && (profile.is_empty() || profile.contains(char::is_whitespace)) + { + return Err(NonoError::ProfileParse(format!( + "aws_auth.profile for custom credential '{}' must be a non-empty string \ + with no whitespace; omit the field to use the default credential chain", + name + ))); + } + + if let Some(ref region) = aws.region + && (region.is_empty() + || region.contains(char::is_whitespace) + || region.chars().any(|c| c.is_uppercase())) + { + return Err(NonoError::ProfileParse(format!( + "aws_auth.region for custom credential '{}' must be a non-empty, \ + lowercase string with no whitespace (e.g., \"us-east-1\")", + name + ))); + } + + if let Some(ref service) = aws.service + && (service.is_empty() + || service.contains(char::is_whitespace) + || service.chars().any(|c| c.is_uppercase())) + { + return Err(NonoError::ProfileParse(format!( + "aws_auth.service for custom credential '{}' must be a non-empty, \ + lowercase string with no whitespace (e.g., \"bedrock\", \"s3\")", + name + ))); + } + + Ok(()) +} + /// Validate header injection mode fields. fn validate_header_mode(name: &str, cred: &CustomCredentialDef) -> Result<()> { // Validate inject_header (RFC 7230 token) @@ -4047,6 +4114,7 @@ mod tests { tls_ca: None, tls_client_cert: None, tls_client_key: None, + aws_auth: None, } } @@ -4211,6 +4279,7 @@ mod tests { tls_ca: None, tls_client_cert: None, tls_client_key: None, + aws_auth: None, }; assert!(validate_custom_credential("telegram", &cred).is_ok()); } @@ -4233,6 +4302,7 @@ mod tests { tls_ca: None, tls_client_cert: None, tls_client_key: None, + aws_auth: None, }; let result = validate_custom_credential("telegram", &cred); let err = result.expect_err("missing path_pattern should be rejected"); @@ -4257,6 +4327,7 @@ mod tests { tls_ca: None, tls_client_cert: None, tls_client_key: None, + aws_auth: None, }; let result = validate_custom_credential("telegram", &cred); let err = result.expect_err("pattern without {} should be rejected"); @@ -4281,6 +4352,7 @@ mod tests { tls_ca: None, tls_client_cert: None, tls_client_key: None, + aws_auth: None, }; assert!(validate_custom_credential("telegram", &cred).is_ok()); } @@ -4303,6 +4375,7 @@ mod tests { tls_ca: None, tls_client_cert: None, tls_client_key: None, + aws_auth: None, }; let result = validate_custom_credential("telegram", &cred); let err = result.expect_err("replacement without {} should be rejected"); @@ -4327,6 +4400,7 @@ mod tests { tls_ca: None, tls_client_cert: None, tls_client_key: None, + aws_auth: None, }; assert!(validate_custom_credential("google_maps", &cred).is_ok()); } @@ -4349,6 +4423,7 @@ mod tests { tls_ca: None, tls_client_cert: None, tls_client_key: None, + aws_auth: None, }; let result = validate_custom_credential("google_maps", &cred); let err = result.expect_err("missing query_param_name should be rejected"); @@ -4373,6 +4448,7 @@ mod tests { tls_ca: None, tls_client_cert: None, tls_client_key: None, + aws_auth: None, }; let result = validate_custom_credential("google_maps", &cred); let err = result.expect_err("empty query_param_name should be rejected"); @@ -4397,6 +4473,7 @@ mod tests { tls_ca: None, tls_client_cert: None, tls_client_key: None, + aws_auth: None, }; // BasicAuth mode doesn't require additional fields // Credential value is expected to be "username:password" format @@ -4598,6 +4675,7 @@ mod tests { tls_ca: None, tls_client_cert: None, tls_client_key: None, + aws_auth: None, } } @@ -5168,6 +5246,7 @@ mod tests { tls_ca: None, tls_client_cert: None, tls_client_key: None, + aws_auth: None, }, ); @@ -5190,6 +5269,7 @@ mod tests { tls_ca: None, tls_client_cert: None, tls_client_key: None, + aws_auth: None, }, ); @@ -5330,6 +5410,7 @@ mod tests { tls_ca: None, tls_client_cert: None, tls_client_key: None, + aws_auth: None, }, ); @@ -5352,6 +5433,7 @@ mod tests { tls_ca: None, tls_client_cert: None, tls_client_key: None, + aws_auth: None, }, ); @@ -6817,6 +6899,7 @@ mod tests { tls_ca: None, tls_client_cert: None, tls_client_key: None, + aws_auth: None, }; assert!( validate_custom_credential("example", &cred).is_ok(), @@ -6842,6 +6925,7 @@ mod tests { tls_ca: None, tls_client_cert: None, tls_client_key: None, + aws_auth: None, }; let result = validate_custom_credential("example", &cred); let err = result.expect_err("file:// URI without env_var should be rejected"); @@ -6870,6 +6954,7 @@ mod tests { tls_ca: None, tls_client_cert: None, tls_client_key: None, + aws_auth: None, }; let result = validate_custom_credential("example", &cred); let err = result.expect_err("file:// URI with relative path should be rejected"); @@ -6898,6 +6983,7 @@ mod tests { tls_ca: None, tls_client_cert: None, tls_client_key: None, + aws_auth: None, }; let result = validate_custom_credential("example", &cred); assert!( @@ -6958,6 +7044,7 @@ mod tests { tls_ca: None, tls_client_cert: None, tls_client_key: None, + aws_auth: None, }; assert!(validate_custom_credential("example", &cred).is_ok()); } @@ -6980,6 +7067,7 @@ mod tests { tls_ca: None, tls_client_cert: None, tls_client_key: None, + aws_auth: None, }; let result = validate_custom_credential("example", &cred); assert!(result.is_err(), "env://LD_PRELOAD should be rejected"); @@ -7746,4 +7834,412 @@ mod tests { "child without binary should inherit from base" ); } + + // ======================================================================== + // aws_auth validation tests + // ======================================================================== + + fn aws_auth_cred_builder() -> CustomCredentialDef { + CustomCredentialDef { + upstream: "https://bedrock-runtime.us-east-1.amazonaws.com".to_string(), + credential_key: None, + auth: None, + aws_auth: Some(nono_proxy::config::AwsAuthConfig { + profile: None, + region: None, + service: None, + }), + inject_mode: InjectMode::Header, + inject_header: "Authorization".to_string(), + credential_format: None, + path_pattern: None, + path_replacement: None, + query_param_name: None, + proxy: None, + env_var: None, + endpoint_rules: vec![], + tls_ca: None, + tls_client_cert: None, + tls_client_key: None, + } + } + + #[test] + fn test_aws_auth_minimal_valid() { + let cred = aws_auth_cred_builder(); + assert!(validate_custom_credential("bedrock", &cred).is_ok()); + } + + #[test] + fn test_aws_auth_all_fields_valid() { + let cred = CustomCredentialDef { + aws_auth: Some(nono_proxy::config::AwsAuthConfig { + profile: Some("my-aws-profile".to_string()), + region: Some("us-east-1".to_string()), + service: Some("bedrock".to_string()), + }), + ..aws_auth_cred_builder() + }; + assert!(validate_custom_credential("bedrock", &cred).is_ok()); + } + + #[test] + fn test_aws_auth_with_http_upstream_rejected() { + // AWS routes require HTTPS just like any other credential type. + let cred = CustomCredentialDef { + upstream: "http://bedrock-runtime.us-east-1.amazonaws.com".to_string(), + ..aws_auth_cred_builder() + }; + let result = validate_custom_credential("bedrock", &cred); + assert!(result.is_err()); + let msg = result.unwrap_err().to_string(); + assert!( + msg.contains("https") || msg.contains("HTTPS"), + "error should mention https requirement, got: {}", + msg + ); + } + + #[test] + fn test_aws_auth_mutually_exclusive_with_credential_key() { + let cred = CustomCredentialDef { + credential_key: Some("my_aws_key".to_string()), + ..aws_auth_cred_builder() + }; + let result = validate_custom_credential("bedrock", &cred); + assert!(result.is_err()); + let msg = result.unwrap_err().to_string(); + assert!( + msg.contains("aws_auth"), + "error should mention aws_auth, got: {}", + msg + ); + } + + #[test] + fn test_aws_auth_mutually_exclusive_with_auth_oauth2() { + let cred = CustomCredentialDef { + auth: Some(OAuth2Config { + token_url: "https://auth.example.com/token".to_string(), + client_id: "cid".to_string(), + client_secret: "env://SECRET".to_string(), + scope: String::new(), + }), + ..aws_auth_cred_builder() + }; + let result = validate_custom_credential("bedrock", &cred); + assert!(result.is_err()); + let msg = result.unwrap_err().to_string(); + assert!( + msg.contains("aws_auth"), + "error should mention aws_auth, got: {}", + msg + ); + } + + #[test] + fn test_aws_auth_none_of_three_rejected() { + let cred = CustomCredentialDef { + credential_key: None, + auth: None, + aws_auth: None, + ..aws_auth_cred_builder() + }; + let result = validate_custom_credential("bedrock", &cred); + assert!(result.is_err()); + let msg = result.unwrap_err().to_string(); + assert!( + msg.contains("credential_key") || msg.contains("auth") || msg.contains("aws_auth"), + "error should mention the missing auth fields, got: {}", + msg + ); + } + + #[test] + fn test_aws_auth_empty_profile_rejected() { + let cred = CustomCredentialDef { + aws_auth: Some(nono_proxy::config::AwsAuthConfig { + profile: Some(String::new()), + region: None, + service: None, + }), + ..aws_auth_cred_builder() + }; + let result = validate_custom_credential("bedrock", &cred); + assert!(result.is_err()); + let msg = result.unwrap_err().to_string(); + assert!( + msg.contains("profile"), + "error should mention profile, got: {}", + msg + ); + } + + #[test] + fn test_aws_auth_empty_region_rejected() { + let cred = CustomCredentialDef { + aws_auth: Some(nono_proxy::config::AwsAuthConfig { + profile: None, + region: Some(String::new()), + service: None, + }), + ..aws_auth_cred_builder() + }; + let result = validate_custom_credential("bedrock", &cred); + assert!(result.is_err()); + let msg = result.unwrap_err().to_string(); + assert!( + msg.contains("region"), + "error should mention region, got: {}", + msg + ); + } + + #[test] + fn test_aws_auth_empty_service_rejected() { + let cred = CustomCredentialDef { + aws_auth: Some(nono_proxy::config::AwsAuthConfig { + profile: None, + region: None, + service: Some(String::new()), + }), + ..aws_auth_cred_builder() + }; + let result = validate_custom_credential("bedrock", &cred); + assert!(result.is_err()); + let msg = result.unwrap_err().to_string(); + assert!( + msg.contains("service"), + "error should mention service, got: {}", + msg + ); + } + + #[test] + fn test_aws_auth_region_with_whitespace_rejected() { + let cred = CustomCredentialDef { + aws_auth: Some(nono_proxy::config::AwsAuthConfig { + profile: None, + region: Some("us east-1".to_string()), + service: None, + }), + ..aws_auth_cred_builder() + }; + let result = validate_custom_credential("bedrock", &cred); + assert!(result.is_err()); + let msg = result.unwrap_err().to_string(); + assert!( + msg.contains("region"), + "error should mention region, got: {}", + msg + ); + } + + #[test] + fn test_aws_auth_service_with_whitespace_rejected() { + let cred = CustomCredentialDef { + aws_auth: Some(nono_proxy::config::AwsAuthConfig { + profile: None, + region: None, + service: Some("bed rock".to_string()), + }), + ..aws_auth_cred_builder() + }; + let result = validate_custom_credential("bedrock", &cred); + assert!(result.is_err()); + let msg = result.unwrap_err().to_string(); + assert!( + msg.contains("service"), + "error should mention service, got: {}", + msg + ); + } + + #[test] + fn test_aws_auth_serde_roundtrip_in_custom_cred_def() { + let json = r#"{ + "upstream": "https://bedrock-runtime.us-east-1.amazonaws.com", + "aws_auth": { + "profile": "my-aws-profile", + "region": "us-east-1", + "service": "bedrock" + } + }"#; + let cred: CustomCredentialDef = serde_json::from_str(json).unwrap(); + let aws = cred.aws_auth.as_ref().unwrap(); + assert_eq!(aws.profile.as_deref(), Some("my-aws-profile")); + assert_eq!(aws.region.as_deref(), Some("us-east-1")); + assert_eq!(aws.service.as_deref(), Some("bedrock")); + + // Roundtrip + let serialized = serde_json::to_string(&cred).unwrap(); + let deserialized: CustomCredentialDef = serde_json::from_str(&serialized).unwrap(); + let aws2 = deserialized.aws_auth.unwrap(); + assert_eq!(aws2.profile.as_deref(), Some("my-aws-profile")); + } + + // --- profile whitespace rejection --- + + #[test] + fn test_aws_auth_profile_with_space_rejected() { + let cred = CustomCredentialDef { + aws_auth: Some(nono_proxy::config::AwsAuthConfig { + profile: Some("my profile".to_string()), + region: None, + service: None, + }), + ..aws_auth_cred_builder() + }; + let result = validate_custom_credential("bedrock", &cred); + assert!(result.is_err()); + let msg = result.unwrap_err().to_string(); + assert!( + msg.contains("profile"), + "error should mention profile, got: {}", + msg + ); + } + + #[test] + fn test_aws_auth_profile_with_tab_rejected() { + let cred = CustomCredentialDef { + aws_auth: Some(nono_proxy::config::AwsAuthConfig { + profile: Some("my\tprofile".to_string()), + region: None, + service: None, + }), + ..aws_auth_cred_builder() + }; + let result = validate_custom_credential("bedrock", &cred); + assert!(result.is_err()); + let msg = result.unwrap_err().to_string(); + assert!( + msg.contains("profile"), + "error should mention profile, got: {}", + msg + ); + } + + #[test] + fn test_aws_auth_profile_mixed_case_valid() { + let cred = CustomCredentialDef { + aws_auth: Some(nono_proxy::config::AwsAuthConfig { + profile: Some("MyCompany-Prod".to_string()), + region: None, + service: None, + }), + ..aws_auth_cred_builder() + }; + assert!(validate_custom_credential("bedrock", &cred).is_ok()); + } + + // --- region lowercase enforcement --- + + #[test] + fn test_aws_auth_region_uppercase_rejected() { + let cred = CustomCredentialDef { + aws_auth: Some(nono_proxy::config::AwsAuthConfig { + profile: None, + region: Some("US-EAST-1".to_string()), + service: None, + }), + ..aws_auth_cred_builder() + }; + let result = validate_custom_credential("bedrock", &cred); + assert!(result.is_err()); + let msg = result.unwrap_err().to_string(); + assert!( + msg.contains("region"), + "error should mention region, got: {}", + msg + ); + } + + #[test] + fn test_aws_auth_region_mixed_case_rejected() { + let cred = CustomCredentialDef { + aws_auth: Some(nono_proxy::config::AwsAuthConfig { + profile: None, + region: Some("Us-East-1".to_string()), + service: None, + }), + ..aws_auth_cred_builder() + }; + let result = validate_custom_credential("bedrock", &cred); + assert!(result.is_err()); + let msg = result.unwrap_err().to_string(); + assert!( + msg.contains("region"), + "error should mention region, got: {}", + msg + ); + } + + // --- service lowercase enforcement --- + + #[test] + fn test_aws_auth_service_uppercase_rejected() { + let cred = CustomCredentialDef { + aws_auth: Some(nono_proxy::config::AwsAuthConfig { + profile: None, + region: None, + service: Some("Bedrock".to_string()), + }), + ..aws_auth_cred_builder() + }; + let result = validate_custom_credential("bedrock", &cred); + assert!(result.is_err()); + let msg = result.unwrap_err().to_string(); + assert!( + msg.contains("service"), + "error should mention service, got: {}", + msg + ); + } + + #[test] + fn test_aws_auth_service_all_caps_rejected() { + let cred = CustomCredentialDef { + aws_auth: Some(nono_proxy::config::AwsAuthConfig { + profile: None, + region: None, + service: Some("S3".to_string()), + }), + ..aws_auth_cred_builder() + }; + let result = validate_custom_credential("bedrock", &cred); + assert!(result.is_err()); + let msg = result.unwrap_err().to_string(); + assert!( + msg.contains("service"), + "error should mention service, got: {}", + msg + ); + } + + #[test] + fn test_aws_auth_service_with_hyphen_valid() { + let cred = CustomCredentialDef { + aws_auth: Some(nono_proxy::config::AwsAuthConfig { + profile: None, + region: None, + service: Some("workers-ai".to_string()), + }), + ..aws_auth_cred_builder() + }; + assert!(validate_custom_credential("cf", &cred).is_ok()); + } + + #[test] + fn test_aws_auth_execute_api_service_valid() { + let cred = CustomCredentialDef { + aws_auth: Some(nono_proxy::config::AwsAuthConfig { + profile: None, + region: Some("ap-southeast-2".to_string()), + service: Some("execute-api".to_string()), + }), + ..aws_auth_cred_builder() + }; + assert!(validate_custom_credential("apigw", &cred).is_ok()); + } } diff --git a/crates/nono-proxy/src/config.rs b/crates/nono-proxy/src/config.rs index 576356731..2605cfb66 100644 --- a/crates/nono-proxy/src/config.rs +++ b/crates/nono-proxy/src/config.rs @@ -280,6 +280,13 @@ pub struct RouteConfig { /// Mutually exclusive with `credential_key` — use one or the other. #[serde(default)] pub oauth2: Option, + + /// Optional AWS SigV4 signing configuration. + /// + /// When present, the proxy will sign outbound requests with AWS SigV4 + /// credentials. Mutually exclusive with `credential_key` and `oauth2`. + #[serde(default)] + pub aws_auth: Option, } /// Optional proxy-side overrides for credential injection shape. @@ -507,6 +514,39 @@ pub struct OAuth2Config { pub scope: String, } +/// AWS SigV4 signing configuration for a credential route. +/// +/// When present on a route, the proxy will sign outbound requests using AWS +/// SigV4. All fields are optional: an empty `aws_auth: {}` block is valid and +/// uses the default credential chain with region and service auto-detected from +/// the upstream URL. +/// +/// Mutually exclusive with `credential_key` and `oauth2`. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] +#[serde(deny_unknown_fields)] +pub struct AwsAuthConfig { + /// AWS profile name to use for credentials. + /// If omitted, the default credential chain is used. + /// Must be non-empty with no whitespace if provided (whitespace breaks the + /// AWS INI config parser; profile names are case-sensitive). + #[serde(default)] + pub profile: Option, + + /// Explicit SigV4 signing region (e.g., `"us-east-1"`). + /// If omitted, auto-detected from the upstream URL. + /// Must be non-empty and lowercase if provided (SigV4 credential scope + /// requires lowercase region codes). + #[serde(default)] + pub region: Option, + + /// Explicit SigV4 service name (e.g., `"bedrock"`, `"s3"`, `"execute-api"`). + /// If omitted, auto-detected from the upstream URL. + /// Must be non-empty and lowercase if provided (SigV4 credential scope + /// requires lowercase service codes). + #[serde(default)] + pub service: Option, +} + #[cfg(test)] #[allow(clippy::unwrap_used)] mod tests { @@ -941,4 +981,96 @@ mod tests { ); } } + + // ======================================================================== + // AwsAuthConfig tests + // ======================================================================== + + #[test] + fn test_aws_auth_config_minimal_deserializes() { + let json = r#"{}"#; + let aws: AwsAuthConfig = serde_json::from_str(json).unwrap(); + assert!(aws.profile.is_none()); + assert!(aws.region.is_none()); + assert!(aws.service.is_none()); + } + + #[test] + fn test_aws_auth_config_all_fields_roundtrip() { + let original = AwsAuthConfig { + profile: Some("my-aws-profile".to_string()), + region: Some("us-east-1".to_string()), + service: Some("bedrock".to_string()), + }; + let json = serde_json::to_string(&original).unwrap(); + let deserialized: AwsAuthConfig = serde_json::from_str(&json).unwrap(); + assert_eq!(deserialized.profile.as_deref(), Some("my-aws-profile")); + assert_eq!(deserialized.region.as_deref(), Some("us-east-1")); + assert_eq!(deserialized.service.as_deref(), Some("bedrock")); + } + + #[test] + fn test_aws_auth_field_absent_is_none() { + let json = r#"{"prefix": "bedrock", "upstream": "https://bedrock-runtime.us-east-1.amazonaws.com"}"#; + let route: RouteConfig = serde_json::from_str(json).unwrap(); + assert!(route.aws_auth.is_none()); + } + + #[test] + fn test_aws_auth_config_unknown_field_rejected() { + let json = r#"{"profile": "foo", "unknown_field": "bar"}"#; + let result: std::result::Result = serde_json::from_str(json); + assert!( + result.is_err(), + "unknown fields must be rejected by deny_unknown_fields" + ); + } + + #[test] + fn test_route_config_with_aws_auth_deserializes() { + let json = r#"{ + "prefix": "bedrock", + "upstream": "https://bedrock-runtime.us-east-1.amazonaws.com", + "aws_auth": { + "profile": "my-aws-profile" + } + }"#; + let route: RouteConfig = serde_json::from_str(json).unwrap(); + let aws = route.aws_auth.unwrap(); + assert_eq!(aws.profile.as_deref(), Some("my-aws-profile")); + assert!(aws.region.is_none()); + assert!(aws.service.is_none()); + } + + #[test] + fn test_route_config_with_full_aws_auth_deserializes() { + let json = r#"{ + "prefix": "bedrock", + "upstream": "https://bedrock-runtime.us-east-1.amazonaws.com", + "aws_auth": { + "profile": "my-aws-profile", + "region": "us-west-2", + "service": "bedrock" + } + }"#; + let route: RouteConfig = serde_json::from_str(json).unwrap(); + let aws = route.aws_auth.unwrap(); + assert_eq!(aws.profile.as_deref(), Some("my-aws-profile")); + assert_eq!(aws.region.as_deref(), Some("us-west-2")); + assert_eq!(aws.service.as_deref(), Some("bedrock")); + } + + #[test] + fn test_aws_auth_empty_object_sets_all_none() { + let json = r#"{ + "prefix": "bedrock", + "upstream": "https://bedrock-runtime.us-east-1.amazonaws.com", + "aws_auth": {} + }"#; + let route: RouteConfig = serde_json::from_str(json).unwrap(); + let aws = route.aws_auth.unwrap(); + assert!(aws.profile.is_none()); + assert!(aws.region.is_none()); + assert!(aws.service.is_none()); + } } diff --git a/crates/nono-proxy/src/credential.rs b/crates/nono-proxy/src/credential.rs index 84f071b66..dff05324c 100644 --- a/crates/nono-proxy/src/credential.rs +++ b/crates/nono-proxy/src/credential.rs @@ -90,6 +90,10 @@ pub struct CredentialStore { credentials: HashMap, /// Map from route prefix to OAuth2 route (token cache + upstream) oauth2_routes: HashMap, + /// Map from route prefix to AWS SigV4 route (placeholder until full + /// SigV4 signing is implemented; value is () because no runtime state + /// is needed yet). + aws_routes: HashMap, } impl CredentialStore { @@ -113,6 +117,7 @@ impl CredentialStore { pub fn load(routes: &[RouteConfig], tls_connector: &TlsConnector) -> Result { let mut credentials = HashMap::new(); let mut oauth2_routes = HashMap::new(); + let mut aws_routes = HashMap::new(); for route in routes { // Normalize prefix: strip leading/trailing slashes so it matches @@ -271,12 +276,20 @@ impl CredentialStore { continue; } } + } else if route.aws_auth.is_some() { + // AWS SigV4 path — no credentials to load yet. Register the + // prefix so get_aws() returns true and the proxy can return + // 501 Not Implemented. The () value is a placeholder; the + // real AwsRoute struct will replace it when SigV4 signing is + // implemented. + aws_routes.insert(normalized_prefix.clone(), ()); } } Ok(Self { credentials, oauth2_routes, + aws_routes, }) } @@ -286,6 +299,7 @@ impl CredentialStore { Self { credentials: HashMap::new(), oauth2_routes: HashMap::new(), + aws_routes: HashMap::new(), } } @@ -301,25 +315,35 @@ impl CredentialStore { self.oauth2_routes.get(prefix) } - /// Check if any credentials (static or OAuth2) are loaded. + /// Returns `Some(())` if an AWS SigV4 route is configured for the given + /// prefix, `None` otherwise. The `Option<&()>` return mirrors `get_oauth2` + /// so call sites can use `.is_some()` uniformly. The value will become + /// `Option<&AwsRoute>` when SigV4 signing is implemented. + #[must_use] + pub fn get_aws(&self, prefix: &str) -> Option<&()> { + self.aws_routes.get(prefix) + } + + /// Check if any credentials (static, OAuth2, or AWS) are loaded. #[must_use] pub fn is_empty(&self) -> bool { - self.credentials.is_empty() && self.oauth2_routes.is_empty() + self.credentials.is_empty() && self.oauth2_routes.is_empty() && self.aws_routes.is_empty() } - /// Number of loaded credentials (static + OAuth2). + /// Number of loaded credentials (static + OAuth2 + AWS). #[must_use] pub fn len(&self) -> usize { - self.credentials.len() + self.oauth2_routes.len() + self.credentials.len() + self.oauth2_routes.len() + self.aws_routes.len() } /// Returns the set of route prefixes that have loaded credentials - /// (both static keystore and OAuth2 routes). + /// (static keystore, OAuth2, and AWS routes). #[must_use] pub fn loaded_prefixes(&self) -> std::collections::HashSet { self.credentials .keys() .chain(self.oauth2_routes.keys()) + .chain(self.aws_routes.keys()) .cloned() .collect() } @@ -575,6 +599,7 @@ mod tests { tls_client_cert: None, tls_client_key: None, oauth2: None, + aws_auth: None, }]; let store = CredentialStore::load(&routes, &tls); assert!(store.is_ok()); @@ -609,6 +634,7 @@ mod tests { let store = CredentialStore { credentials: HashMap::new(), oauth2_routes, + aws_routes: HashMap::new(), }; assert!( @@ -637,6 +663,7 @@ mod tests { let store = CredentialStore { credentials: HashMap::new(), oauth2_routes, + aws_routes: HashMap::new(), }; let prefixes = store.loaded_prefixes(); @@ -665,6 +692,7 @@ mod tests { tls_client_cert: None, tls_client_key: None, oauth2: None, + aws_auth: None, }]; let store = CredentialStore::load(&routes, &tls).expect("credential load"); let cred = store.get("litellm").expect("route should be loaded"); @@ -694,6 +722,7 @@ mod tests { tls_client_cert: None, tls_client_key: None, oauth2: None, + aws_auth: None, }]; let store = CredentialStore::load(&routes, &tls).expect("credential load"); let cred = store.get("api").expect("route should be loaded"); @@ -734,6 +763,7 @@ mod tests { client_secret: "env://TEST_OAUTH2_CLIENT_SECRET".to_string(), scope: String::new(), }), + aws_auth: None, }]; let store = CredentialStore::load(&routes, &tls); diff --git a/crates/nono-proxy/src/reverse.rs b/crates/nono-proxy/src/reverse.rs index 13147e12a..56c2e9db0 100644 --- a/crates/nono-proxy/src/reverse.rs +++ b/crates/nono-proxy/src/reverse.rs @@ -130,6 +130,7 @@ pub async fn handle_reverse_proxy( })?; let static_cred = ctx.credential_store.get(&service); let oauth2_route = ctx.credential_store.get_oauth2(&service); + let aws_route = ctx.credential_store.get_aws(&service); let managed_ctx = static_cred.map(|cred| { managed_credential_event_ctx( &service, @@ -153,7 +154,11 @@ pub async fn handle_reverse_proxy( ..audit::EventContext::default() }); - if route.missing_managed_credential(static_cred.is_some(), oauth2_route.is_some()) { + if route.missing_managed_credential( + static_cred.is_some(), + oauth2_route.is_some(), + aws_route.is_some(), + ) { let reason = format!( "managed credential unavailable for service '{}': route is configured for proxy-supplied auth", service @@ -221,6 +226,14 @@ pub async fn handle_reverse_proxy( .await; } + // AWS SigV4 signing is not yet implemented. Return 501 so the caller + // knows the route exists but is not functional. This branch will be + // replaced with real SigV4 signing in a follow-up. + if aws_route.is_some() { + send_error(stream, 501, "Not Implemented").await?; + return Ok(()); + } + let cred = static_cred; // Authenticate the request. Every reverse proxy request must prove diff --git a/crates/nono-proxy/src/route.rs b/crates/nono-proxy/src/route.rs index 11832b4e7..bff8d4209 100644 --- a/crates/nono-proxy/src/route.rs +++ b/crates/nono-proxy/src/route.rs @@ -88,6 +88,11 @@ fn auth_mechanism_for_route(route: &RouteConfig) -> Option Option NetworkAuditInjectionMode::Header, @@ -183,8 +193,9 @@ impl RouteStore { // they exist to provide a `*_BASE_URL` env var or appear in // `route_upstream_hosts()` — and CONNECT to those still gets // blocked with 403 (the "force SDK cooperation" path). - let requires_managed_credential = - route.credential_key.is_some() || route.oauth2.is_some(); + let requires_managed_credential = route.credential_key.is_some() + || route.oauth2.is_some() + || route.aws_auth.is_some(); let requires_intercept = requires_managed_credential || !route.endpoint_rules.is_empty(); let managed_auth_mechanism = auth_mechanism_for_route(route); @@ -410,8 +421,9 @@ impl LoadedRoute { &self, has_static_credential: bool, has_oauth2: bool, + has_aws: bool, ) -> bool { - self.requires_managed_credential && !has_static_credential && !has_oauth2 + self.requires_managed_credential && !has_static_credential && !has_oauth2 && !has_aws } } @@ -653,6 +665,7 @@ mod tests { tls_client_cert: None, tls_client_key: None, oauth2: None, + aws_auth: None, }]; let store = RouteStore::load(&routes).unwrap(); @@ -692,6 +705,7 @@ mod tests { tls_client_cert: None, tls_client_key: None, oauth2: None, + aws_auth: None, }]; let store = RouteStore::load(&routes).unwrap(); @@ -718,6 +732,7 @@ mod tests { tls_client_cert: None, tls_client_key: None, oauth2: None, + aws_auth: None, }]; let store = RouteStore::load(&routes).unwrap(); @@ -745,6 +760,7 @@ mod tests { tls_client_cert: None, tls_client_key: None, oauth2: None, + aws_auth: None, }, RouteConfig { prefix: "anthropic".to_string(), @@ -763,6 +779,7 @@ mod tests { tls_client_cert: None, tls_client_key: None, oauth2: None, + aws_auth: None, }, ]; @@ -845,6 +862,7 @@ mod tests { tls_client_cert: None, tls_client_key: None, oauth2: None, + aws_auth: None, }]; let store = RouteStore::load(&routes).unwrap(); let hit = store.lookup_by_upstream("api.openai.com:443").unwrap(); @@ -885,6 +903,7 @@ mod tests { tls_client_cert: None, tls_client_key: None, oauth2: None, + aws_auth: None, }]; let store = RouteStore::load(&routes).unwrap(); let hit = store @@ -915,6 +934,7 @@ mod tests { tls_client_cert: None, tls_client_key: None, oauth2: None, + aws_auth: None, }]; let store = RouteStore::load(&routes).unwrap(); assert!(store.is_route_upstream("aliased.example.com:443")); @@ -933,9 +953,10 @@ mod tests { managed_auth_mechanism: Some(NetworkAuditAuthMechanism::PhantomHeader), managed_injection_mode: Some(NetworkAuditInjectionMode::Header), }; - assert!(managed.missing_managed_credential(false, false)); - assert!(!managed.missing_managed_credential(true, false)); - assert!(!managed.missing_managed_credential(false, true)); + assert!(managed.missing_managed_credential(false, false, false)); + assert!(!managed.missing_managed_credential(true, false, false)); + assert!(!managed.missing_managed_credential(false, true, false)); + assert!(!managed.missing_managed_credential(false, false, true)); let l7_only = LoadedRoute { upstream: "https://internal.example.com".to_string(), @@ -947,7 +968,7 @@ mod tests { managed_auth_mechanism: None, managed_injection_mode: None, }; - assert!(!l7_only.missing_managed_credential(false, false)); + assert!(!l7_only.missing_managed_credential(false, false, false)); } #[test] @@ -969,6 +990,7 @@ mod tests { tls_client_cert: None, tls_client_key: None, oauth2: None, + aws_auth: None, }]; let store = RouteStore::load(&routes).unwrap(); let hit = store.lookup_by_upstream("api.openai.com:443").unwrap(); @@ -1001,6 +1023,7 @@ mod tests { tls_client_cert: None, tls_client_key: None, oauth2: None, + aws_auth: None, }, RouteConfig { prefix: "github_org_b".to_string(), @@ -1022,6 +1045,7 @@ mod tests { tls_client_cert: None, tls_client_key: None, oauth2: None, + aws_auth: None, }, ]; let store = RouteStore::load(&routes).unwrap(); @@ -1097,6 +1121,7 @@ mod tests { tls_client_cert: None, tls_client_key: None, oauth2: None, + aws_auth: None, } } @@ -1509,6 +1534,7 @@ h56ZLEEqHfVWFhJWIKRSabtxYPV/VJyMv+lo3L0QwSKsouHs3dtF1zVQ tls_client_cert: Some(cert_path.to_str().unwrap().to_string()), tls_client_key: Some(key_path.to_str().unwrap().to_string()), oauth2: None, + aws_auth: None, }]; let store = RouteStore::load(&routes).expect("should load mTLS route"); diff --git a/crates/nono-proxy/src/server.rs b/crates/nono-proxy/src/server.rs index 1dae50960..5ac5e5813 100644 --- a/crates/nono-proxy/src/server.rs +++ b/crates/nono-proxy/src/server.rs @@ -1175,6 +1175,7 @@ mod tests { tls_client_cert: None, tls_client_key: None, oauth2: None, + aws_auth: None, }], intercept_ca_dir: Some(dir.path().to_path_buf()), ..Default::default() @@ -1239,6 +1240,7 @@ mod tests { tls_client_cert: None, tls_client_key: None, oauth2: None, + aws_auth: None, }], intercept_ca_dir: Some(dir.path().to_path_buf()), ..Default::default() @@ -1285,6 +1287,7 @@ mod tests { tls_client_cert: None, tls_client_key: None, oauth2: None, + aws_auth: None, }], intercept_ca_dir: Some(missing_dir), ..Default::default() @@ -1331,6 +1334,7 @@ mod tests { tls_client_cert: None, tls_client_key: None, oauth2: None, + aws_auth: None, }, crate::config::RouteConfig { prefix: "alias".to_string(), @@ -1349,6 +1353,7 @@ mod tests { tls_client_cert: None, tls_client_key: None, oauth2: None, + aws_auth: None, }, ], intercept_ca_dir: Some(dir.path().to_path_buf()), @@ -1422,6 +1427,7 @@ mod tests { tls_client_cert: None, tls_client_key: None, oauth2: None, + aws_auth: None, }], ..Default::default() }; @@ -1468,6 +1474,7 @@ mod tests { tls_client_cert: None, tls_client_key: None, oauth2: None, + aws_auth: None, }], ..Default::default() }; @@ -1523,6 +1530,7 @@ mod tests { tls_client_cert: None, tls_client_key: None, oauth2: None, + aws_auth: None, }], ..Default::default() }; @@ -1584,6 +1592,7 @@ mod tests { tls_client_cert: None, tls_client_key: None, oauth2: None, + aws_auth: None, }, crate::config::RouteConfig { prefix: "github".to_string(), @@ -1602,6 +1611,7 @@ mod tests { tls_client_cert: None, tls_client_key: None, oauth2: None, + aws_auth: None, }, ], ..Default::default() @@ -1665,6 +1675,7 @@ mod tests { tls_client_cert: None, tls_client_key: None, oauth2: None, + aws_auth: None, }], ..Default::default() }; @@ -1699,6 +1710,7 @@ mod tests { tls_client_cert: None, tls_client_key: None, oauth2: None, + aws_auth: None, }], ..Default::default() }; @@ -1751,6 +1763,7 @@ mod tests { tls_client_cert: None, tls_client_key: None, oauth2: None, + aws_auth: None, }], ..Default::default() }; @@ -1792,6 +1805,7 @@ mod tests { tls_client_cert: None, tls_client_key: None, oauth2: None, + aws_auth: None, }], ..Default::default() }; @@ -1979,6 +1993,7 @@ mod tests { tls_client_cert: None, tls_client_key: None, oauth2: None, + aws_auth: None, }], intercept_ca_dir: Some(dir.path().to_path_buf()), ..Default::default() diff --git a/crates/nono-proxy/src/tls_intercept/handle.rs b/crates/nono-proxy/src/tls_intercept/handle.rs index 24f1b7cc2..d195a3947 100644 --- a/crates/nono-proxy/src/tls_intercept/handle.rs +++ b/crates/nono-proxy/src/tls_intercept/handle.rs @@ -279,9 +279,14 @@ where let cred = service.and_then(|s| ctx.credential_store.get(s)); let oauth2_route = service.and_then(|s| ctx.credential_store.get_oauth2(s)); + let aws_route = service.and_then(|s| ctx.credential_store.get_aws(s)); if let Some(rt) = route - && rt.missing_managed_credential(cred.is_some(), oauth2_route.is_some()) + && rt.missing_managed_credential( + cred.is_some(), + oauth2_route.is_some(), + aws_route.is_some(), + ) { let svc = service.unwrap_or("unknown"); let reason = format!( @@ -310,6 +315,14 @@ where return Ok(()); } + // AWS SigV4 signing is not yet implemented. Return 501 so the caller + // knows the route exists but is not functional. This branch will be + // replaced with real SigV4 signing in a follow-up. + if aws_route.is_some() { + reverse::send_error_generic(tls_stream, 501, "Not Implemented").await?; + return Ok(()); + } + // --- Path / credential transformation --- let transformed_path = if let Some(cred) = cred { let cleaned = reverse::strip_proxy_artifacts( From b06733c18f40a671bf335b3a10b6411aa9ec0cc2 Mon Sep 17 00:00:00 2001 From: Anil Kulkarni <6687139+intentionally-left-nil@users.noreply.github.com> Date: Thu, 18 Jun 2026 01:16:43 -0700 Subject: [PATCH 80/91] Refactor forward_inner_request (#1192) This function does many different things, from parsing the body headers, choosing what config matches the route, handling the implementation, and logging/auditing. Splitting this into helper functions is better for readability, scope of purpose, and will make the future aws auth implementation easier to implement Signed-off-by: Anil Kulkarni --- crates/nono-proxy/src/tls_intercept/handle.rs | 225 +++++++++++------- 1 file changed, 137 insertions(+), 88 deletions(-) diff --git a/crates/nono-proxy/src/tls_intercept/handle.rs b/crates/nono-proxy/src/tls_intercept/handle.rs index d195a3947..0e96b7860 100644 --- a/crates/nono-proxy/src/tls_intercept/handle.rs +++ b/crates/nono-proxy/src/tls_intercept/handle.rs @@ -152,7 +152,7 @@ pub async fn handle_intercept_connect(stream: &mut TcpStream, ctx: InterceptCtx< "CONNECT", ); - if let Err(e) = forward_inner_request(&mut tls_stream, &ctx).await { + if let Err(e) = handle_inner_request(&mut tls_stream, &ctx).await { debug!( "tls_intercept: inner-request handling failed for {}:{}: {}", ctx.host, ctx.port, e @@ -161,18 +161,70 @@ pub async fn handle_intercept_connect(stream: &mut TcpStream, ctx: InterceptCtx< Ok(()) } -/// Read one inner HTTP/1.1 request, select the matching route, inject -/// credentials if matched, and forward upstream. -async fn forward_inner_request(tls_stream: &mut S, ctx: &InterceptCtx<'_>) -> Result<()> +/// The parts of an inner HTTP/1.1 request that have been read off the wire +/// but not yet acted on. Produced by [`parse_inner_request`] and consumed by +/// [`handle_inner_request`]. +struct ParsedRequest { + method: String, + path: String, + version: String, + /// Raw header lines (excluding the request line and the blank terminator). + header_bytes: Vec, + /// Bytes already pulled into the `BufReader` buffer beyond the headers. + buffered: Vec, +} + +/// Calls [`ProxyFilter::check_host`] and handles the denial path. +/// +/// On success returns the resolved addresses for use in [`select_upstream_strategy`]. +/// On denial writes the 403, emits the audit event, and returns `Ok(None)` so +/// the caller can `return Ok(())` without duplicating the send/log boilerplate. +async fn resolve_upstream_or_deny( + stream: &mut S, + ctx: &InterceptCtx<'_>, + deny_event_ctx: audit::EventContext<'_>, +) -> Result>> +where + S: tokio::io::AsyncWrite + Unpin, +{ + let check = ctx.filter.check_host(ctx.host, ctx.port).await?; + if !check.result.is_allowed() { + let reason = check.result.reason(); + warn!("tls_intercept: upstream host denied by filter: {}", reason); + audit::log_denied( + ctx.audit_log, + audit::ProxyMode::ConnectIntercept, + &audit::EventContext { + denial_category: Some(nono::undo::NetworkAuditDenialCategory::HostDenied), + ..deny_event_ctx + }, + ctx.host, + ctx.port, + &reason, + ); + reverse::send_error_generic(stream, 403, "Forbidden").await?; + return Ok(None); + } + Ok(Some(check.resolved_addrs)) +} + +/// Read and parse one inner HTTP/1.1 request from `stream`, returning the +/// request line components and raw header bytes as a [`ParsedRequest`]. +/// +/// Returns `Ok(None)` in two terminal-but-non-error cases that the caller +/// should treat as "nothing to do": +/// - The connection closed before a request line arrived (clean EOF). +/// - The headers exceeded [`MAX_HEADER_SIZE`]; a 431 has been sent and the +/// connection should be dropped. +async fn parse_inner_request(stream: &mut S) -> Result> where S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin, { - // --- Parse the inner request line + headers --- - let mut buf_reader = BufReader::new(&mut *tls_stream); + let mut buf_reader = BufReader::new(&mut *stream); let mut first_line = String::new(); buf_reader.read_line(&mut first_line).await?; if first_line.is_empty() { - return Ok(()); + return Ok(None); } let mut header_bytes = Vec::new(); @@ -186,13 +238,11 @@ where if header_bytes.len() > MAX_HEADER_SIZE { // Mirror the outer proxy's behaviour. We have to write into the // BufReader's inner stream — release it first. - let buffered = buf_reader.buffer().to_vec(); drop(buf_reader); - tls_stream + stream .write_all(b"HTTP/1.1 431 Request Header Fields Too Large\r\n\r\n") .await?; - let _ = buffered; - return Ok(()); + return Ok(None); } } let buffered = buf_reader.buffer().to_vec(); @@ -200,7 +250,26 @@ where let first_line = first_line.trim_end(); let (method, path, version) = parse_request_line(first_line)?; - debug!("tls_intercept: inner request {} {}", method, path); + Ok(Some(ParsedRequest { + method, + path, + version, + header_bytes, + buffered, + })) +} + +/// Read one inner HTTP/1.1 request, select the matching route, inject +/// credentials if matched, and forward upstream. +async fn handle_inner_request(tls_stream: &mut S, ctx: &InterceptCtx<'_>) -> Result<()> +where + S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin, +{ + let req = match parse_inner_request(tls_stream).await? { + Some(r) => r, + None => return Ok(()), + }; + debug!("tls_intercept: inner request {} {}", req.method, req.path); // Route selection: 1 match → cred, 0 → passthrough, 2+ → 403. let host_port = format!("{}:{}", ctx.host.to_lowercase(), ctx.port); @@ -217,11 +286,11 @@ where // Route selection (endpoint-authorization gate, ambiguity check, and // credential-first priority) lives in `route::select_route` so it has a // single source of truth shared with its unit tests. - let selected = match crate::route::select_route(&candidates, &method, &path) { + let selected = match crate::route::select_route(&candidates, &req.method, &req.path) { crate::route::RouteSelection::EndpointDenied => { let reason = format!( "endpoint rules denied {} {}: no rule matched on {}:{}", - method, path, ctx.host, ctx.port + req.method, req.path, ctx.host, ctx.port ); warn!("tls_intercept: {}", reason); audit::log_denied( @@ -242,8 +311,8 @@ where let reason = format!( "ambiguous route: {} {} matched {} credential routes: {:?}. \ Narrow endpoint_rules so each request matches exactly one route.", - method, - path, + req.method, + req.path, names.len(), names ); @@ -269,11 +338,11 @@ where match service { Some(svc) => debug!( "tls_intercept: selected route '{}' for {} {}", - svc, method, path + svc, req.method, req.path ), None => debug!( "tls_intercept: no endpoint_rules matched {} {}, forwarding without credentials", - method, path + req.method, req.path ), } @@ -326,7 +395,7 @@ where // --- Path / credential transformation --- let transformed_path = if let Some(cred) = cred { let cleaned = reverse::strip_proxy_artifacts( - &path, + &req.path, &cred.proxy_inject_mode, &cred.inject_mode, cred.proxy_path_pattern.as_deref(), @@ -341,43 +410,37 @@ where &cred.raw_credential, )? } else { - path.clone() + req.path.clone() }; // --- Resolve upstream IPs (DNS-rebind-safe via filter) --- - let check = ctx.filter.check_host(ctx.host, ctx.port).await?; - if !check.result.is_allowed() { - let reason = check.result.reason(); - warn!("tls_intercept: upstream host denied by filter: {}", reason); - audit::log_denied( - ctx.audit_log, - audit::ProxyMode::ConnectIntercept, - &audit::EventContext { - route_id: service, - managed_credential_active: Some(cred.is_some() || oauth2_route.is_some()), - injection_mode: cred.map(|c| match c.inject_mode { - InjectMode::Header => nono::undo::NetworkAuditInjectionMode::Header, - InjectMode::UrlPath => nono::undo::NetworkAuditInjectionMode::UrlPath, - InjectMode::QueryParam => nono::undo::NetworkAuditInjectionMode::QueryParam, - InjectMode::BasicAuth => nono::undo::NetworkAuditInjectionMode::BasicAuth, - }), - denial_category: Some(nono::undo::NetworkAuditDenialCategory::HostDenied), - ..audit::EventContext::default() - }, - ctx.host, - ctx.port, - &reason, - ); - reverse::send_error_generic(tls_stream, 403, "Forbidden").await?; - return Ok(()); - } + let resolved_addrs = match resolve_upstream_or_deny( + tls_stream, + ctx, + audit::EventContext { + route_id: service, + managed_credential_active: Some(cred.is_some() || oauth2_route.is_some()), + injection_mode: cred.map(|c| match c.inject_mode { + InjectMode::Header => nono::undo::NetworkAuditInjectionMode::Header, + InjectMode::UrlPath => nono::undo::NetworkAuditInjectionMode::UrlPath, + InjectMode::QueryParam => nono::undo::NetworkAuditInjectionMode::QueryParam, + InjectMode::BasicAuth => nono::undo::NetworkAuditInjectionMode::BasicAuth, + }), + ..audit::EventContext::default() + }, + ) + .await? + { + Some(addrs) => addrs, + None => return Ok(()), + }; // --- Read body (Content-Length only; chunked is rare in API requests // and matches the existing reverse-proxy contract). --- let strip_header = cred.map(|c| c.proxy_header_name.as_str()).unwrap_or(""); - let filtered_headers = reverse::filter_headers(&header_bytes, strip_header); - let content_length = reverse::extract_content_length(&header_bytes); - let body = match reverse::read_request_body(tls_stream, content_length, &buffered).await? { + let filtered_headers = reverse::filter_headers(&req.header_bytes, strip_header); + let content_length = reverse::extract_content_length(&req.header_bytes); + let body = match reverse::read_request_body(tls_stream, content_length, &req.buffered).await? { Some(b) => b, None => return Ok(()), }; @@ -386,7 +449,7 @@ where let upstream_authority = reverse::format_host_header(UpstreamScheme::Https, ctx.host, ctx.port); let mut request = Zeroizing::new(format!( "{} {} {}\r\nHost: {}\r\n", - method, transformed_path, version, upstream_authority + req.method, transformed_path, req.version, upstream_authority )); if let Some(cred) = cred { reverse::inject_credential_for_mode(cred, &mut request); @@ -411,7 +474,7 @@ where let connector = route .and_then(|r| r.tls_connector.as_ref()) .unwrap_or(ctx.tls_connector); - let strategy = select_upstream_strategy(&ctx.upstream_proxy, &check.resolved_addrs); + let strategy = select_upstream_strategy(&ctx.upstream_proxy, &resolved_addrs); let upstream_spec = UpstreamSpec { scheme: UpstreamScheme::Https, host: ctx.host, @@ -419,31 +482,32 @@ where strategy, tls_connector: connector, }; + let event_ctx = audit::EventContext { + route_id: service, + auth_mechanism: cred.map(|c| match c.proxy_inject_mode { + InjectMode::Header | InjectMode::BasicAuth => { + nono::undo::NetworkAuditAuthMechanism::PhantomHeader + } + InjectMode::UrlPath => nono::undo::NetworkAuditAuthMechanism::PhantomPath, + InjectMode::QueryParam => nono::undo::NetworkAuditAuthMechanism::PhantomQuery, + }), + auth_outcome: cred.map(|_| nono::undo::NetworkAuditAuthOutcome::Succeeded), + managed_credential_active: Some(cred.is_some() || oauth2_route.is_some()), + injection_mode: cred.map(|c| match c.inject_mode { + InjectMode::Header => nono::undo::NetworkAuditInjectionMode::Header, + InjectMode::UrlPath => nono::undo::NetworkAuditInjectionMode::UrlPath, + InjectMode::QueryParam => nono::undo::NetworkAuditInjectionMode::QueryParam, + InjectMode::BasicAuth => nono::undo::NetworkAuditInjectionMode::BasicAuth, + }), + denial_category: None, + }; let audit_ctx = AuditCtx { log: ctx.audit_log, mode: audit::ProxyMode::ConnectIntercept, - event_ctx: audit::EventContext { - route_id: service, - auth_mechanism: cred.map(|c| match c.proxy_inject_mode { - InjectMode::Header | InjectMode::BasicAuth => { - nono::undo::NetworkAuditAuthMechanism::PhantomHeader - } - InjectMode::UrlPath => nono::undo::NetworkAuditAuthMechanism::PhantomPath, - InjectMode::QueryParam => nono::undo::NetworkAuditAuthMechanism::PhantomQuery, - }), - auth_outcome: cred.map(|_| nono::undo::NetworkAuditAuthOutcome::Succeeded), - managed_credential_active: Some(cred.is_some() || oauth2_route.is_some()), - injection_mode: cred.map(|c| match c.inject_mode { - InjectMode::Header => nono::undo::NetworkAuditInjectionMode::Header, - InjectMode::UrlPath => nono::undo::NetworkAuditInjectionMode::UrlPath, - InjectMode::QueryParam => nono::undo::NetworkAuditInjectionMode::QueryParam, - InjectMode::BasicAuth => nono::undo::NetworkAuditInjectionMode::BasicAuth, - }), - denial_category: None, - }, + event_ctx: event_ctx.clone(), target: ctx.host, - method: &method, - path: &path, + method: &req.method, + path: &req.path, }; if let Err(e) = forward::forward_request( tls_stream, @@ -459,25 +523,10 @@ where ctx.audit_log, audit::ProxyMode::ConnectIntercept, &audit::EventContext { - route_id: service, - auth_mechanism: cred.map(|c| match c.proxy_inject_mode { - InjectMode::Header | InjectMode::BasicAuth => { - nono::undo::NetworkAuditAuthMechanism::PhantomHeader - } - InjectMode::UrlPath => nono::undo::NetworkAuditAuthMechanism::PhantomPath, - InjectMode::QueryParam => nono::undo::NetworkAuditAuthMechanism::PhantomQuery, - }), - auth_outcome: cred.map(|_| nono::undo::NetworkAuditAuthOutcome::Succeeded), - managed_credential_active: Some(cred.is_some() || oauth2_route.is_some()), - injection_mode: cred.map(|c| match c.inject_mode { - InjectMode::Header => nono::undo::NetworkAuditInjectionMode::Header, - InjectMode::UrlPath => nono::undo::NetworkAuditInjectionMode::UrlPath, - InjectMode::QueryParam => nono::undo::NetworkAuditInjectionMode::QueryParam, - InjectMode::BasicAuth => nono::undo::NetworkAuditInjectionMode::BasicAuth, - }), denial_category: Some( nono::undo::NetworkAuditDenialCategory::UpstreamConnectFailed, ), + ..event_ctx }, ctx.host, ctx.port, From a40a3cab7dbcb4dc91f85011b699f390d2befd9f Mon Sep 17 00:00:00 2001 From: Anil Kulkarni <6687139+intentionally-left-nil@users.noreply.github.com> Date: Thu, 18 Jun 2026 02:41:17 -0700 Subject: [PATCH 81/91] fix: proxy should activate with customCredentials set (#1197) * Create failing tests for the proxy activating under the presence of customCredentials Signed-off-by: Anil Kulkarni * Ensure the proxy starts when customCredentials are passed in by itself Signed-off-by: Anil Kulkarni --------- Signed-off-by: Anil Kulkarni --- crates/nono-cli/src/network_policy.rs | 141 +++++++++++++++++++++++++- crates/nono-cli/src/proxy_runtime.rs | 79 +++++++++++++++ 2 files changed, 217 insertions(+), 3 deletions(-) diff --git a/crates/nono-cli/src/network_policy.rs b/crates/nono-cli/src/network_policy.rs index d0df6c063..d16de7db6 100644 --- a/crates/nono-cli/src/network_policy.rs +++ b/crates/nono-cli/src/network_policy.rs @@ -184,11 +184,22 @@ pub fn resolve_credentials( service_names: &[String], custom_credentials: &HashMap, ) -> Result> { - if service_names.is_empty() { + // Build the full set of names to resolve: explicitly requested names + // (from `credentials`) plus all custom_credentials keys. custom_credentials + // entries are always activated — they don't need to be listed in `credentials`. + let mut all_names: Vec = custom_credentials.keys().cloned().collect(); + for name in service_names { + if !all_names.contains(name) { + all_names.push(name.clone()); + } + } + + if all_names.is_empty() { return Ok(Vec::new()); } - // Validate all requested services exist in either custom or built-in + // Validate all explicitly requested services exist in either custom or built-in. + // custom_credentials keys are always valid by definition. for name in service_names { if !custom_credentials.contains_key(name) && !policy.credentials.contains_key(name) { let mut available: Vec<_> = policy.credentials.keys().cloned().collect(); @@ -204,7 +215,7 @@ pub fn resolve_credentials( let mut routes = Vec::new(); - for name in service_names { + for name in &all_names { // Custom credentials take precedence over built-in. // Note: Custom credentials are already validated at profile load time // in profile/mod.rs::validate_profile_custom_credentials(), so we don't @@ -1290,4 +1301,128 @@ mod tests { let result = partition_allow_domain(&policy, &entries); assert!(result.is_err()); } + + /// A profile with `custom_credentials` but no `credentials` list passes an + /// empty `service_names` slice. `resolve_credentials` should still return + /// a route for each entry in `custom_credentials`. + #[test] + fn test_resolve_credentials_custom_credentials_without_service_names() { + use crate::profile::CustomCredentialDef; + + let json = embedded_network_policy_json(); + let policy = load_network_policy(json).unwrap(); + + let mut custom = HashMap::new(); + custom.insert( + "mockhttp".to_string(), + CustomCredentialDef { + upstream: "https://mockhttp.org".to_string(), + credential_key: Some("env://MOCK_API_KEY".to_string()), + auth: None, + inject_mode: InjectMode::Header, + inject_header: "Authorization".to_string(), + credential_format: Some("Bearer {}".to_string()), + path_pattern: None, + path_replacement: None, + query_param_name: None, + proxy: None, + env_var: Some("MOCK_API_KEY".to_string()), + endpoint_rules: vec![], + tls_ca: None, + tls_client_cert: None, + tls_client_key: None, + aws_auth: None, + }, + ); + + let routes = resolve_credentials(&policy, &[], &custom).unwrap(); + assert_eq!( + routes.len(), + 1, + "expected one route for the custom credential, got {}", + routes.len() + ); + assert_eq!(routes[0].prefix, "mockhttp"); + assert_eq!(routes[0].upstream, "https://mockhttp.org"); + assert_eq!( + routes[0].env_var, + Some("MOCK_API_KEY".to_string()), + "env_var must be propagated from CustomCredentialDef" + ); + } + + /// When `service_names` is empty but multiple `custom_credentials` are + /// defined, `resolve_credentials` should return a route for every entry. + #[test] + fn test_resolve_credentials_all_custom_credentials_without_service_names() { + use crate::profile::CustomCredentialDef; + + let json = embedded_network_policy_json(); + let policy = load_network_policy(json).unwrap(); + + let mut custom = HashMap::new(); + custom.insert( + "svc_a".to_string(), + CustomCredentialDef { + upstream: "https://svc-a.example.com".to_string(), + credential_key: Some("env://SVC_A_KEY".to_string()), + auth: None, + inject_mode: InjectMode::Header, + inject_header: "Authorization".to_string(), + credential_format: Some("Bearer {}".to_string()), + path_pattern: None, + path_replacement: None, + query_param_name: None, + proxy: None, + env_var: Some("SVC_A_KEY".to_string()), + endpoint_rules: vec![], + tls_ca: None, + tls_client_cert: None, + tls_client_key: None, + aws_auth: None, + }, + ); + custom.insert( + "svc_b".to_string(), + CustomCredentialDef { + upstream: "https://svc-b.example.com".to_string(), + credential_key: Some("env://SVC_B_KEY".to_string()), + auth: None, + inject_mode: InjectMode::Header, + inject_header: "Authorization".to_string(), + credential_format: Some("Bearer {}".to_string()), + path_pattern: None, + path_replacement: None, + query_param_name: None, + proxy: None, + env_var: Some("SVC_B_KEY".to_string()), + endpoint_rules: vec![], + tls_ca: None, + tls_client_cert: None, + tls_client_key: None, + aws_auth: None, + }, + ); + + let routes = resolve_credentials(&policy, &[], &custom).unwrap(); + + assert_eq!( + routes.len(), + 2, + "expected one route per custom credential, got {}", + routes.len() + ); + + let prefixes: Vec<&str> = routes.iter().map(|r| r.prefix.as_str()).collect(); + assert!( + prefixes.contains(&"svc_a"), + "route for svc_a must be present; got: {:?}", + prefixes + ); + assert!( + prefixes.contains(&"svc_b"), + "route for svc_b must be present; got: {:?}", + prefixes + ); + } } diff --git a/crates/nono-cli/src/proxy_runtime.rs b/crates/nono-cli/src/proxy_runtime.rs index e4a4ba983..ef8781232 100644 --- a/crates/nono-cli/src/proxy_runtime.rs +++ b/crates/nono-cli/src/proxy_runtime.rs @@ -57,8 +57,11 @@ pub(crate) fn prepare_proxy_launch_options( bypass }; + let has_custom_credentials = !prepared.custom_credentials.is_empty(); + let active = if matches!(prepared.caps.network_mode(), nono::NetworkMode::Blocked) { if !credentials.is_empty() + || has_custom_credentials || network_profile.is_some() || !allow_domain.is_empty() || upstream_proxy.is_some() @@ -80,6 +83,7 @@ pub(crate) fn prepare_proxy_launch_options( prepared.caps.network_mode(), nono::NetworkMode::ProxyOnly { .. } ) || !credentials.is_empty() + || has_custom_credentials || network_profile.is_some() || !allow_domain.is_empty() || upstream_proxy.is_some() @@ -680,4 +684,79 @@ mod tests { "strict_filter must default off when network_block is false" ); } + + /// A profile with only `custom_credentials` set (no built-in `credentials`, + /// no `network_profile`, no `allow_domain`, no upstream proxy) should still + /// activate the proxy so that credential injection works. + #[test] + fn test_proxy_is_active_when_only_custom_credentials_are_set() { + use crate::profile::CustomCredentialDef; + use crate::sandbox_prepare::PreparedSandbox; + use nono::CapabilitySet; + use nono_proxy::config::InjectMode; + use std::collections::HashMap; + + let mut custom_credentials: HashMap = HashMap::new(); + custom_credentials.insert( + "mockhttp".to_string(), + CustomCredentialDef { + upstream: "https://mockhttp.org".to_string(), + credential_key: Some("env://MOCK_API_KEY".to_string()), + auth: None, + inject_mode: InjectMode::Header, + inject_header: "Authorization".to_string(), + credential_format: Some("Bearer {}".to_string()), + path_pattern: None, + path_replacement: None, + query_param_name: None, + proxy: None, + env_var: Some("MOCK_API_KEY".to_string()), + endpoint_rules: vec![], + tls_ca: None, + tls_client_cert: None, + tls_client_key: None, + aws_auth: None, + }, + ); + + let prepared = PreparedSandbox { + caps: CapabilitySet::new(), + secrets: Vec::new(), + session_hooks: crate::profile::SessionHooks::default(), + rollback_exclude_patterns: Vec::new(), + rollback_exclude_globs: Vec::new(), + network_profile: None, + allow_domain: Vec::new(), + credentials: Vec::new(), + custom_credentials, + upstream_proxy: None, + upstream_bypass: Vec::new(), + listen_ports: Vec::new(), + capability_elevation: false, + #[cfg(target_os = "linux")] + wsl2_proxy_policy: crate::profile::Wsl2ProxyPolicy::Error, + #[cfg(target_os = "linux")] + af_unix_mediation: crate::profile::LinuxAfUnixMediation::Off, + allow_launch_services_active: false, + allow_gpu_active: false, + open_url_origins: Vec::new(), + open_url_allow_localhost: false, + bypass_protection_paths: Vec::new(), + ignored_denial_paths: Vec::new(), + suppressed_system_service_operations: Vec::new(), + allowed_env_vars: None, + denied_env_vars: None, + set_vars: None, + network_block_requested: false, + }; + + let args = crate::cli::SandboxArgs::default(); + let opts = prepare_proxy_launch_options(&args, &prepared, true) + .expect("prepare_proxy_launch_options"); + + assert!( + opts.active, + "proxy must be active when custom_credentials is non-empty" + ); + } } From 266cef2d44bcdccbda28f063ceab887701949df0 Mon Sep 17 00:00:00 2001 From: Luke Hinds Date: Thu, 18 Jun 2026 14:24:09 +0100 Subject: [PATCH 82/91] feat(update-check): discover ci environments on update (#1113) * feat(update-check): discover ci environments on update The update check now includes a coarse classification of the CI environment. This helps us understand how nono well (or not) recent versions are used in automated workflows versus local development. - Detection relies solely on well-known environment variable names (e.g., GITHUB_ACTIONS, CI). - Raw environment variable values are never collected or transmitted. - The update service's privacy assurances remain unchanged: no personally identifiable information is collected. - Updated the `test-update-server.py` script to log the new CI fields. Signed-off-by: Luke Hinds * refactor(update_check): simplify ci environment detection The `CiEnvironment` struct has been removed to streamline the representation of CI detection. The `detect_ci_environment` function has been renamed to `detect_ci_provider` and now directly returns an `Option<&'static str>` representing the detected CI provider. This simplifies the API by removing the need for an explicit `is_ci` boolean, as its presence can be inferred from `ci_provider.is_some()`. New tests have been added to: - Ensure that environment variables with "falsey" values (e.g., empty string, "0") do not inadvertently trigger CI detection. - Validate that no CI is detected when no relevant environment variables are present. Signed-off-by: Luke Hinds --------- Signed-off-by: Luke Hinds Co-authored-by: Aleks <121458075+SequeI@users.noreply.github.com> --- crates/nono-cli/src/update_check.rs | 197 +++++++++++++++++++++++++++- tools/test-update-server.py | 4 +- 2 files changed, 197 insertions(+), 4 deletions(-) diff --git a/crates/nono-cli/src/update_check.rs b/crates/nono-cli/src/update_check.rs index 76407be10..0e5e15a98 100644 --- a/crates/nono-cli/src/update_check.rs +++ b/crates/nono-cli/src/update_check.rs @@ -4,9 +4,11 @@ //! background thread with a 3-second timeout and is throttled to once per 24 hours. //! //! Each request sends a randomly generated UUID (created on first run and stored -//! locally), the current nono version, the OS name, and the CPU architecture. -//! None of these values are derived from hardware identifiers or user accounts. -//! No personally identifiable information is collected or transmitted. +//! locally), the current nono version, the OS name, the CPU architecture, and a +//! coarse CI environment classification. The CI classification is derived only +//! from well-known environment variable names and never includes raw environment +//! values. None of these values are derived from hardware identifiers or user +//! accounts. No personally identifiable information is collected or transmitted. //! No IP addresses are logged by the update service. //! //! To disable the update check, set `NONO_NO_UPDATE_CHECK=1` or add @@ -62,8 +64,32 @@ struct UpdateCheckRequest { version: String, platform: String, arch: String, + ci: bool, + #[serde(skip_serializing_if = "Option::is_none")] + ci_provider: Option<&'static str>, } +const CI_PROVIDER_ENV_VARS: &[(&str, &str)] = &[ + ("GITHUB_ACTIONS", "github_actions"), + ("GITLAB_CI", "gitlab_ci"), + ("CIRCLECI", "circleci"), + ("BUILDKITE", "buildkite"), + ("TF_BUILD", "azure_pipelines"), + ("TRAVIS", "travis_ci"), + ("JENKINS_URL", "jenkins"), + ("JENKINS_HOME", "jenkins"), + ("BITBUCKET_BUILD_NUMBER", "bitbucket_pipelines"), + ("APPVEYOR", "appveyor"), + ("TEAMCITY_VERSION", "teamcity"), + ("DRONE", "drone"), + ("SEMAPHORE", "semaphore"), + ("CODESHIP", "codeship"), + ("WOODPECKER", "woodpecker"), + ("NETLIFY", "netlify"), + ("VERCEL", "vercel"), + ("RENDER", "render"), +]; + /// Handle for a background update check. pub struct UpdateCheckHandle { result: Arc>>, @@ -256,13 +282,38 @@ fn is_newer_version(current: &str, latest: &str) -> bool { } } +fn detect_ci_provider() -> Option<&'static str> { + for (env_var, provider) in CI_PROVIDER_ENV_VARS { + if env_marker_present(env_var) { + return Some(provider); + } + } + + if env_marker_present("CI") { + return Some("generic"); + } + + None +} + +fn env_marker_present(key: &str) -> bool { + std::env::var_os(key).is_some_and(|value| { + let value = value.to_string_lossy(); + let value = value.trim(); + !value.is_empty() && !value.eq_ignore_ascii_case("false") && value != "0" + }) +} + /// Perform the HTTP check against the update service fn perform_check(uuid: &str) -> Option { + let ci_provider = detect_ci_provider(); let request = UpdateCheckRequest { uuid: uuid.to_string(), version: env!("CARGO_PKG_VERSION").to_string(), platform: std::env::consts::OS.to_string(), arch: std::env::consts::ARCH.to_string(), + ci: ci_provider.is_some(), + ci_provider, }; let body = serde_json::to_string(&request).ok()?; @@ -381,6 +432,146 @@ mod tests { assert!(handle.is_none()); } + #[test] + fn test_detect_ci_environment_github_actions() { + let _lock = match crate::test_env::ENV_LOCK.lock() { + Ok(g) => g, + Err(p) => p.into_inner(), + }; + let _env = crate::test_env::EnvVarGuard::set_all(&[ + ("GITHUB_ACTIONS", "true"), + ("GITLAB_CI", ""), + ("CIRCLECI", ""), + ("BUILDKITE", ""), + ("TF_BUILD", ""), + ("TRAVIS", ""), + ("JENKINS_URL", ""), + ("JENKINS_HOME", ""), + ("BITBUCKET_BUILD_NUMBER", ""), + ("APPVEYOR", ""), + ("TEAMCITY_VERSION", ""), + ("DRONE", ""), + ("SEMAPHORE", ""), + ("CODESHIP", ""), + ("WOODPECKER", ""), + ("NETLIFY", ""), + ("VERCEL", ""), + ("RENDER", ""), + ("CI", ""), + ]); + + assert_eq!(detect_ci_provider(), Some("github_actions")); + } + + #[test] + fn test_detect_ci_environment_generic_ci() { + let _lock = match crate::test_env::ENV_LOCK.lock() { + Ok(g) => g, + Err(p) => p.into_inner(), + }; + let _env = crate::test_env::EnvVarGuard::set_all(&[ + ("GITHUB_ACTIONS", ""), + ("GITLAB_CI", ""), + ("CIRCLECI", ""), + ("BUILDKITE", ""), + ("TF_BUILD", ""), + ("TRAVIS", ""), + ("JENKINS_URL", ""), + ("JENKINS_HOME", ""), + ("BITBUCKET_BUILD_NUMBER", ""), + ("APPVEYOR", ""), + ("TEAMCITY_VERSION", ""), + ("DRONE", ""), + ("SEMAPHORE", ""), + ("CODESHIP", ""), + ("WOODPECKER", ""), + ("NETLIFY", ""), + ("VERCEL", ""), + ("RENDER", ""), + ("CI", "true"), + ]); + + assert_eq!(detect_ci_provider(), Some("generic")); + } + + #[test] + fn test_detect_ci_environment_falsey_markers_are_ignored() { + let _lock = match crate::test_env::ENV_LOCK.lock() { + Ok(g) => g, + Err(p) => p.into_inner(), + }; + let _env = crate::test_env::EnvVarGuard::set_all(&[ + ("GITHUB_ACTIONS", "false"), + ("GITLAB_CI", "0"), + ("CIRCLECI", ""), + ("BUILDKITE", ""), + ("TF_BUILD", ""), + ("TRAVIS", ""), + ("JENKINS_URL", ""), + ("JENKINS_HOME", ""), + ("BITBUCKET_BUILD_NUMBER", ""), + ("APPVEYOR", ""), + ("TEAMCITY_VERSION", ""), + ("DRONE", ""), + ("SEMAPHORE", ""), + ("CODESHIP", ""), + ("WOODPECKER", ""), + ("NETLIFY", ""), + ("VERCEL", ""), + ("RENDER", ""), + ("CI", "0"), + ]); + + assert_eq!(detect_ci_provider(), None); + } + + #[test] + fn test_detect_ci_environment_no_ci() { + let _lock = match crate::test_env::ENV_LOCK.lock() { + Ok(g) => g, + Err(p) => p.into_inner(), + }; + let _env = crate::test_env::EnvVarGuard::set_all(&[ + ("GITHUB_ACTIONS", ""), + ("GITLAB_CI", ""), + ("CIRCLECI", ""), + ("BUILDKITE", ""), + ("TF_BUILD", ""), + ("TRAVIS", ""), + ("JENKINS_URL", ""), + ("JENKINS_HOME", ""), + ("BITBUCKET_BUILD_NUMBER", ""), + ("APPVEYOR", ""), + ("TEAMCITY_VERSION", ""), + ("DRONE", ""), + ("SEMAPHORE", ""), + ("CODESHIP", ""), + ("WOODPECKER", ""), + ("NETLIFY", ""), + ("VERCEL", ""), + ("RENDER", ""), + ("CI", ""), + ]); + + assert_eq!(detect_ci_provider(), None); + } + + #[test] + fn test_update_request_serializes_ci_context() { + let request = UpdateCheckRequest { + uuid: "test-uuid".to_string(), + version: "1.2.3".to_string(), + platform: "linux".to_string(), + arch: "x86_64".to_string(), + ci: true, + ci_provider: Some("github_actions"), + }; + + let json = serde_json::to_value(&request).expect("serialize"); + assert_eq!(json["ci"], true); + assert_eq!(json["ci_provider"], "github_actions"); + } + #[test] fn test_is_newer_version() { // Strictly newer diff --git a/tools/test-update-server.py b/tools/test-update-server.py index 503ea8b4a..8d3899633 100644 --- a/tools/test-update-server.py +++ b/tools/test-update-server.py @@ -48,11 +48,13 @@ def _handle_check(self): version = request.get("version", "0.0.0") platform = request.get("platform", "unknown") arch = request.get("arch", "unknown") + ci = request.get("ci", False) + ci_provider = request.get("ci_provider", "none") print( f"[{datetime.now(timezone.utc).isoformat()}] " f"uuid={uuid[:8]}... version={version} " - f"platform={platform} arch={arch}" + f"platform={platform} arch={arch} ci={ci} ci_provider={ci_provider}" ) update_available = self._is_outdated(version) From 4b0f7dc95c135aa7c249d17a6b2e77dd72085009 Mon Sep 17 00:00:00 2001 From: Aleks <121458075+SequeI@users.noreply.github.com> Date: Thu, 18 Jun 2026 15:36:13 +0100 Subject: [PATCH 83/91] feat(diagnostics): expose structured diagnostics for library and FFI clients (#1171) * feat(diagnostics): expose structured diagnostics for library and FFI clients Add stable diagnostic codes, remediations, and session reports in nono and nono-proxy; render CLI footers from structured data with --diagnostics-json. Extend C FFI with diagnostic codes and JSON helpers. Refactor diagnostic module and ignore local diagnostics.json output. Signed-off-by: Aleksy Siek * fix: review fix + deduplicate oauth block Signed-off-by: Aleksy Siek * fix: review comments Signed-off-by: Aleksy Siek --------- Signed-off-by: Aleksy Siek --- .gitignore | 1 + Cargo.lock | 382 ++++++- bindings/c/Cargo.toml | 2 + bindings/c/include/nono.h | 69 ++ bindings/c/src/diagnostic.rs | 222 ++++ bindings/c/src/lib.rs | 29 + bindings/c/src/types.rs | 50 + crates/nono-cli/src/cli.rs | 4 + crates/nono-cli/src/diagnostic/formatter.rs | 997 ++++++++++++------ crates/nono-cli/src/diagnostic/mod.rs | 8 +- crates/nono-cli/src/exec_strategy.rs | 130 ++- .../src/exec_strategy/supervisor_linux.rs | 22 +- crates/nono-cli/src/execution_runtime.rs | 9 + crates/nono-cli/src/launch_runtime.rs | 4 + crates/nono-cli/src/output.rs | 48 + crates/nono-cli/src/profile_save_runtime.rs | 6 - crates/nono-cli/src/proxy_runtime.rs | 8 + crates/nono-cli/src/query_ext.rs | 55 + crates/nono-proxy/src/credential.rs | 405 +++++-- crates/nono-proxy/src/diagnostic.rs | 99 ++ crates/nono-proxy/src/lib.rs | 3 + crates/nono-proxy/src/server.rs | 97 +- crates/nono/src/diagnostic/codes.rs | 188 ++++ crates/nono/src/diagnostic/detail.rs | 38 + crates/nono/src/diagnostic/mod.rs | 22 + crates/nono/src/diagnostic/observation.rs | 199 ++++ .../{diagnostic.rs => diagnostic/records.rs} | 81 +- crates/nono/src/diagnostic/report.rs | 446 ++++++++ crates/nono/src/error.rs | 138 +++ crates/nono/src/lib.rs | 7 +- 30 files changed, 3256 insertions(+), 513 deletions(-) create mode 100644 bindings/c/src/diagnostic.rs create mode 100644 crates/nono-proxy/src/diagnostic.rs create mode 100644 crates/nono/src/diagnostic/codes.rs create mode 100644 crates/nono/src/diagnostic/detail.rs create mode 100644 crates/nono/src/diagnostic/mod.rs create mode 100644 crates/nono/src/diagnostic/observation.rs rename crates/nono/src/{diagnostic.rs => diagnostic/records.rs} (61%) create mode 100644 crates/nono/src/diagnostic/report.rs diff --git a/.gitignore b/.gitignore index 0bcd99577..844ffcf89 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ proj/ .worktrees .nono +diagnostics.json # Library tests diff --git a/Cargo.lock b/Cargo.lock index 3744c09e8..2c5b75c9c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -107,7 +107,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -118,7 +118,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -445,6 +445,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + [[package]] name = "blocking" version = "1.6.2" @@ -572,7 +581,7 @@ dependencies = [ "num-traits", "serde", "wasm-bindgen", - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -679,7 +688,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -967,7 +976,17 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.59.0", + "windows-sys 0.61.2", +] + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags", + "objc2", ] [[package]] @@ -1054,7 +1073,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -1546,7 +1565,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core", + "windows-core 0.62.2", ] [[package]] @@ -1764,7 +1783,7 @@ dependencies = [ "portable-atomic", "portable-atomic-util", "serde_core", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -1823,7 +1842,7 @@ dependencies = [ "simd_cesu8", "thiserror 2.0.18", "walkdir", - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -1938,7 +1957,7 @@ dependencies = [ "secret-service", "security-framework 2.11.1", "security-framework 3.7.0", - "zbus", + "zbus 4.4.0", "zeroize", ] @@ -2022,6 +2041,20 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +[[package]] +name = "mac-notification-sys" +version = "0.6.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd604973958ddcc11b561193c0fb96ba146506ef2f231ef2e7c35fd2cbc9beca" +dependencies = [ + "cc", + "log", + "objc2", + "objc2-foundation", + "time", + "uuid", +] + [[package]] name = "matchers" version = "0.2.0" @@ -2155,6 +2188,7 @@ dependencies = [ "colored", "dirs", "dns-lookup", + "futures", "globset", "ignore", "jsonc-parser", @@ -2164,6 +2198,7 @@ dependencies = [ "nix 0.31.3", "nono", "nono-proxy", + "notify-rust", "proptest", "rand 0.10.1", "security-framework 3.7.0", @@ -2198,6 +2233,8 @@ version = "0.1.0" dependencies = [ "cbindgen", "nono", + "serde", + "serde_json", ] [[package]] @@ -2232,13 +2269,27 @@ dependencies = [ "zeroize", ] +[[package]] +name = "notify-rust" +version = "4.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5b4c1b4f2aa9f25f63a7a49d3dd0ed567b3670da15330a66b29434be899b891" +dependencies = [ + "futures-lite", + "log", + "mac-notification-sys", + "serde", + "tauri-winrt-notification", + "zbus 5.16.0", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2326,6 +2377,45 @@ dependencies = [ "autocfg", ] +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags", + "block2", + "libc", + "objc2", + "objc2-core-foundation", +] + [[package]] name = "oid-registry" version = "0.8.1" @@ -2423,7 +2513,7 @@ dependencies = [ "libc", "redox_syscall", "smallvec", - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -3008,7 +3098,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -3067,7 +3157,7 @@ dependencies = [ "security-framework 3.7.0", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -3088,7 +3178,7 @@ dependencies = [ "security-framework 3.7.0", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -3212,7 +3302,7 @@ dependencies = [ "rand 0.8.6", "serde", "sha2 0.10.9", - "zbus", + "zbus 4.4.0", ] [[package]] @@ -3762,7 +3852,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -3830,6 +3920,17 @@ dependencies = [ "syn", ] +[[package]] +name = "tauri-winrt-notification" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed071c670382e85fc2f48ae706492d8c338f4f89bf72520d32f8abfe880aade" +dependencies = [ + "thiserror 2.0.18", + "windows", + "windows-version", +] + [[package]] name = "tempfile" version = "3.27.0" @@ -3837,10 +3938,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.2", "once_cell", "rustix", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -4313,7 +4414,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -4439,6 +4540,18 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "uuid" +version = "1.23.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "serde_core", + "wasm-bindgen", +] + [[package]] name = "uuid-simd" version = "0.8.0" @@ -4695,7 +4808,42 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", +] + +[[package]] +name = "windows" +version = "0.61.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +dependencies = [ + "windows-collections", + "windows-core 0.61.2", + "windows-future", + "windows-link 0.1.3", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +dependencies = [ + "windows-core 0.61.2", +] + +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", ] [[package]] @@ -4706,9 +4854,20 @@ checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ "windows-implement", "windows-interface", - "windows-link", - "windows-result", - "windows-strings", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-future" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", + "windows-threading", ] [[package]] @@ -4733,19 +4892,53 @@ dependencies = [ "syn", ] +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-numerics" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", +] + [[package]] name = "windows-result" version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" dependencies = [ - "windows-link", + "windows-link 0.2.1", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", ] [[package]] @@ -4754,7 +4947,7 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" dependencies = [ - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -4799,7 +4992,7 @@ version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -4839,7 +5032,7 @@ version = "0.53.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" dependencies = [ - "windows-link", + "windows-link 0.2.1", "windows_aarch64_gnullvm 0.53.1", "windows_aarch64_msvc 0.53.1", "windows_i686_gnu 0.53.1", @@ -4850,6 +5043,24 @@ dependencies = [ "windows_x86_64_msvc 0.53.1", ] +[[package]] +name = "windows-threading" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-version" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4060a1da109b9d0326b7262c8e12c84df67cc0dbc9e33cf49e01ccc2eb63631" +dependencies = [ + "windows-link 0.2.1", +] + [[package]] name = "windows_aarch64_gnullvm" version = "0.42.2" @@ -5222,9 +5433,44 @@ dependencies = [ "uds_windows", "windows-sys 0.52.0", "xdg-home", - "zbus_macros", - "zbus_names", - "zvariant", + "zbus_macros 4.4.0", + "zbus_names 3.0.0", + "zvariant 4.2.0", +] + +[[package]] +name = "zbus" +version = "5.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eee682d202a77e4a9f3b2c2bdf48a7b28af5c08c34ddf66f98c93e5e39464285" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow 1.0.2", + "zbus_macros 5.16.0", + "zbus_names 4.3.2", + "zvariant 5.12.0", ] [[package]] @@ -5237,7 +5483,22 @@ dependencies = [ "proc-macro2", "quote", "syn", - "zvariant_utils", + "zvariant_utils 2.1.0", +] + +[[package]] +name = "zbus_macros" +version = "5.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adf1bd45a81a103745b1757754762a26e8cd01e4532e4d6c8ec431624b80d1d6" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", + "zbus_names 4.3.2", + "zvariant 5.12.0", + "zvariant_utils 3.4.0", ] [[package]] @@ -5248,7 +5509,18 @@ checksum = "4b9b1fef7d021261cc16cba64c351d291b715febe0fa10dc3a443ac5a5022e6c" dependencies = [ "serde", "static_assertions", - "zvariant", + "zvariant 4.2.0", +] + +[[package]] +name = "zbus_names" +version = "4.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7074f3e50b894eac91750142016d30d0a89be8e67dbfd9704fb875825760e52d" +dependencies = [ + "serde", + "winnow 1.0.2", + "zvariant 5.12.0", ] [[package]] @@ -5362,7 +5634,21 @@ dependencies = [ "enumflags2", "serde", "static_assertions", - "zvariant_derive", + "zvariant_derive 4.2.0", +] + +[[package]] +name = "zvariant" +version = "5.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a192a0bde63360d77a7523c833d4b4ce6070a927e2c53246e4c540b1a3e27be0" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow 1.0.2", + "zvariant_derive 5.12.0", + "zvariant_utils 3.4.0", ] [[package]] @@ -5375,7 +5661,20 @@ dependencies = [ "proc-macro2", "quote", "syn", - "zvariant_utils", + "zvariant_utils 2.1.0", +] + +[[package]] +name = "zvariant_derive" +version = "5.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90bc6cde9c01c511074be97f7ccb6c19d0da89e3f8662e812e999dcfd4638737" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", + "zvariant_utils 3.4.0", ] [[package]] @@ -5388,3 +5687,16 @@ dependencies = [ "quote", "syn", ] + +[[package]] +name = "zvariant_utils" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e8535915cfa75547e559d8c68e8139909a4aeee076831e4ef7fc59d8172c4d6" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn", + "winnow 1.0.2", +] diff --git a/bindings/c/Cargo.toml b/bindings/c/Cargo.toml index c2fb441bc..978c22080 100644 --- a/bindings/c/Cargo.toml +++ b/bindings/c/Cargo.toml @@ -16,6 +16,8 @@ crate-type = ["cdylib", "staticlib"] [dependencies] nono = { version = "0.63.0", path = "../../crates/nono", default-features = false } +serde.workspace = true +serde_json.workspace = true [build-dependencies] cbindgen.workspace = true diff --git a/bindings/c/include/nono.h b/bindings/c/include/nono.h index 1a1125b65..85f3e9a2a 100644 --- a/bindings/c/include/nono.h +++ b/bindings/c/include/nono.h @@ -110,6 +110,28 @@ typedef enum NonoErrorCode { NONO_ERROR_CODE_ERR_UNKNOWN = -99, } NonoErrorCode; +/** + * Diagnostic code for sandbox and setup errors. + */ +typedef enum NonoDiagnosticCode { + NONO_DIAGNOSTIC_CODE_SANDBOX_DENIED_PATH = 0, + NONO_DIAGNOSTIC_CODE_SANDBOX_DENIED_NETWORK = 1, + NONO_DIAGNOSTIC_CODE_SANDBOX_DENIED_UNIX_SOCKET = 2, + NONO_DIAGNOSTIC_CODE_COMMAND_NOT_FOUND = 3, + NONO_DIAGNOSTIC_CODE_COMMAND_FAILED_LIKELY_SANDBOX = 4, + NONO_DIAGNOSTIC_CODE_COMMAND_FAILED_APPLICATION = 5, + NONO_DIAGNOSTIC_CODE_CREDENTIAL_NOT_FOUND = 6, + NONO_DIAGNOSTIC_CODE_CREDENTIAL_UNAVAILABLE = 7, + NONO_DIAGNOSTIC_CODE_UNSUPPORTED_PLATFORM_FEATURE = 8, + NONO_DIAGNOSTIC_CODE_ROLLBACK_BUDGET_EXCEEDED = 9, + NONO_DIAGNOSTIC_CODE_CWD_ACCESS_REQUIRED = 10, + NONO_DIAGNOSTIC_CODE_CONFIGURATION_ERROR = 11, + NONO_DIAGNOSTIC_CODE_TRUST_VERIFICATION_FAILED = 12, + NONO_DIAGNOSTIC_CODE_IO_ERROR = 13, + NONO_DIAGNOSTIC_CODE_CANCELLED = 14, + NONO_DIAGNOSTIC_CODE_OTHER = 99, +} NonoDiagnosticCode; + /** * Tag for capability source discriminant. */ @@ -447,6 +469,53 @@ bool nono_capability_set_is_network_blocked(const struct NonoCapabilitySet *caps */ char *nono_capability_set_summary(const struct NonoCapabilitySet *caps); +/** + * Return the diagnostic code for the most recently mapped error on this thread. + * + * Returns `NonoDiagnosticCode::Other` when no error has been mapped. + */ +enum NonoDiagnosticCode nono_last_diagnostic_code(void); + +/** + * Return JSON for the remediation attached to the most recently mapped error. + * + * Caller must free with `nono_string_free()`. Returns NULL when no remediation exists. + */ +char *nono_last_remediation_json(void); + +/** + * Build a session diagnostic report JSON object from serialized denial inputs. + * + * Each `*_json` argument may be NULL, an empty string, or a JSON array of + * denial, IPC denial, or violation records. + * + * # Safety + * + * Pointer arguments must be null or valid null-terminated UTF-8 for the + * duration of the call. Caller frees the returned string with `nono_string_free()`. + * Returns NULL on failure; call `nono_last_error()`. + */ +char *nono_session_diagnostic_report_to_json(int32_t exit_code, + const char *denials_json, + const char *ipc_denials_json, + const char *violations_json); + +/** + * Merge session report JSON with an optional proxy diagnostics JSON array. + * + * `session_json` must be a report object from `nono_session_diagnostic_report_to_json` + * or `SessionDiagnosticReport::to_json()`. `proxy_diagnostics_json` may be NULL + * or empty to return `{ "session": ... }` only. + * + * # Safety + * + * Pointer arguments must be null or valid null-terminated UTF-8 for the + * duration of the call. Caller frees the returned string with `nono_string_free()`. + * Returns NULL on failure; call `nono_last_error()`. + */ +char *nono_merge_diagnostic_report_json(const char *session_json, + const char *proxy_diagnostics_json); + /** * Get the number of filesystem capabilities in the set. * diff --git a/bindings/c/src/diagnostic.rs b/bindings/c/src/diagnostic.rs new file mode 100644 index 000000000..0d3ea1cd4 --- /dev/null +++ b/bindings/c/src/diagnostic.rs @@ -0,0 +1,222 @@ +//! C FFI for session diagnostics and error remediation. + +use crate::types::NonoDiagnosticCode; +use crate::{map_error, rust_string_to_c, set_last_error}; +use std::os::raw::c_char; + +/// Return the diagnostic code for the most recently mapped error on this thread. +/// +/// Returns `NonoDiagnosticCode::Other` when no error has been mapped. +#[unsafe(no_mangle)] +pub extern "C" fn nono_last_diagnostic_code() -> NonoDiagnosticCode { + crate::last_diagnostic_code() +} + +/// Return JSON for the remediation attached to the most recently mapped error. +/// +/// Caller must free with `nono_string_free()`. Returns NULL when no remediation exists. +#[unsafe(no_mangle)] +pub extern "C" fn nono_last_remediation_json() -> *mut c_char { + match crate::last_remediation_json() { + Some(json) => rust_string_to_c(json), + None => std::ptr::null_mut(), + } +} + +/// Build a session diagnostic report JSON object from serialized denial inputs. +/// +/// Each `*_json` argument may be NULL, an empty string, or a JSON array of +/// denial, IPC denial, or violation records. +/// +/// # Safety +/// +/// Pointer arguments must be null or valid null-terminated UTF-8 for the +/// duration of the call. Caller frees the returned string with `nono_string_free()`. +/// Returns NULL on failure; call `nono_last_error()`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn nono_session_diagnostic_report_to_json( + exit_code: i32, + denials_json: *const c_char, + ipc_denials_json: *const c_char, + violations_json: *const c_char, +) -> *mut c_char { + let denials = match parse_denials(denials_json) { + Ok(v) => v, + Err(e) => { + set_last_error(&e); + return std::ptr::null_mut(); + } + }; + let ipc_denials = match parse_ipc_denials(ipc_denials_json) { + Ok(v) => v, + Err(e) => { + set_last_error(&e); + return std::ptr::null_mut(); + } + }; + let violations = match parse_violations(violations_json) { + Ok(v) => v, + Err(e) => { + set_last_error(&e); + return std::ptr::null_mut(); + } + }; + + let report = nono::SessionDiagnosticReport::from_merged_session( + exit_code, + denials, + ipc_denials, + violations, + ); + match report.to_json() { + Ok(json) => rust_string_to_c(json), + Err(e) => { + map_error(&e); + std::ptr::null_mut() + } + } +} + +/// Merge session report JSON with an optional proxy diagnostics JSON array. +/// +/// `session_json` must be a report object from `nono_session_diagnostic_report_to_json` +/// or `SessionDiagnosticReport::to_json()`. `proxy_diagnostics_json` may be NULL +/// or empty to return `{ "session": ... }` only. +/// +/// # Safety +/// +/// Pointer arguments must be null or valid null-terminated UTF-8 for the +/// duration of the call. Caller frees the returned string with `nono_string_free()`. +/// Returns NULL on failure; call `nono_last_error()`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn nono_merge_diagnostic_report_json( + session_json: *const c_char, + proxy_diagnostics_json: *const c_char, +) -> *mut c_char { + if session_json.is_null() { + set_last_error("session_json is null"); + return std::ptr::null_mut(); + } + let Some(session_text) = (unsafe { crate::c_str_to_str(session_json) }) else { + set_last_error("invalid UTF-8 in session_json"); + return std::ptr::null_mut(); + }; + let proxy_text = if proxy_diagnostics_json.is_null() { + None + } else { + unsafe { crate::c_str_to_str(proxy_diagnostics_json) } + }; + match nono::SessionDiagnosticReport::merge_with_proxy_json(session_text, proxy_text) { + Ok(json) => rust_string_to_c(json), + Err(e) => { + map_error(&e); + std::ptr::null_mut() + } + } +} + +fn parse_json_array( + ptr: *const c_char, + label: &str, +) -> Result, String> { + if ptr.is_null() { + return Ok(Vec::new()); + } + let Some(text) = (unsafe { crate::c_str_to_str(ptr) }) else { + return Err(format!("invalid UTF-8 in {label}")); + }; + if text.is_empty() { + return Ok(Vec::new()); + } + serde_json::from_str(text).map_err(|e| format!("invalid {label} JSON array: {e}")) +} + +fn parse_denials(ptr: *const c_char) -> Result, String> { + parse_json_array(ptr, "denial") +} + +fn parse_ipc_denials(ptr: *const c_char) -> Result, String> { + parse_json_array(ptr, "IPC denial") +} + +fn parse_violations(ptr: *const c_char) -> Result, String> { + parse_json_array(ptr, "violation") +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::types::NonoDiagnosticCode; + use std::ffi::CStr; + + #[test] + fn last_diagnostic_code_defaults_to_other() { + assert_eq!(nono_last_diagnostic_code(), NonoDiagnosticCode::Other); + } + + #[test] + fn session_report_json_from_empty_arrays() { + let json_ptr = unsafe { + nono_session_diagnostic_report_to_json( + 1, + std::ptr::null(), + std::ptr::null(), + std::ptr::null(), + ) + }; + assert!(!json_ptr.is_null()); + // SAFETY: returned by nono_session_diagnostic_report_to_json in this test. + let json = unsafe { CStr::from_ptr(json_ptr) }.to_str().expect("utf8"); + assert!(json.contains("\"exit_code\":1")); + unsafe { crate::nono_string_free(json_ptr) }; + } + + #[test] + fn session_report_json_from_denial_array() { + let denials = r#"[{"path":"/tmp/x","access":"Read","reason":"PolicyBlocked"}]"#; + let denials_c = std::ffi::CString::new(denials).expect("cstr"); + let json_ptr = unsafe { + nono_session_diagnostic_report_to_json( + 2, + denials_c.as_ptr(), + std::ptr::null(), + std::ptr::null(), + ) + }; + assert!(!json_ptr.is_null()); + let json = unsafe { CStr::from_ptr(json_ptr) }.to_str().expect("utf8"); + assert!(json.contains("sandbox_denied_path")); + assert!(json.contains("grant_path")); + unsafe { crate::nono_string_free(json_ptr) }; + } + + #[test] + fn merge_diagnostic_report_json_rejects_null_session() { + let json_ptr = + unsafe { nono_merge_diagnostic_report_json(std::ptr::null(), std::ptr::null()) }; + assert!(json_ptr.is_null()); + let err = unsafe { CStr::from_ptr(crate::nono_last_error()) } + .to_str() + .expect("utf8"); + assert!(err.contains("session_json is null")); + } + + #[test] + fn merge_diagnostic_report_json_wraps_proxy_array() { + let session = std::ffi::CString::new( + r#"{"exit_code":1,"denials":[],"ipc_denials":[],"violations":[],"diagnostics":[]}"#, + ) + .expect("cstr"); + let proxy = std::ffi::CString::new( + r#"[{"code":"credential_not_found","severity":"warning","route_prefix":"openai","message":"missing"}]"#, + ) + .expect("cstr"); + let json_ptr = + unsafe { nono_merge_diagnostic_report_json(session.as_ptr(), proxy.as_ptr()) }; + assert!(!json_ptr.is_null()); + let json = unsafe { CStr::from_ptr(json_ptr) }.to_str().expect("utf8"); + assert!(json.contains("\"session\"")); + assert!(json.contains("credential_not_found")); + unsafe { crate::nono_string_free(json_ptr) }; + } +} diff --git a/bindings/c/src/lib.rs b/bindings/c/src/lib.rs index 7437dd71a..13e43f5a3 100644 --- a/bindings/c/src/lib.rs +++ b/bindings/c/src/lib.rs @@ -19,6 +19,7 @@ //! it needs. pub mod capability_set; +pub mod diagnostic; pub mod fs_capability; pub mod query; pub mod sandbox; @@ -31,6 +32,7 @@ use std::os::raw::c_char; // Re-export all public FFI symbols so they appear in the cdylib. pub use capability_set::*; +pub use diagnostic::*; pub use fs_capability::*; pub use query::*; pub use sandbox::*; @@ -43,6 +45,9 @@ pub use types::*; thread_local! { static LAST_ERROR: RefCell> = const { RefCell::new(None) }; + static LAST_DIAGNOSTIC_CODE: RefCell> = + const { RefCell::new(None) }; + static LAST_REMEDIATION_JSON: RefCell> = const { RefCell::new(None) }; } /// Store an error message for the current thread. @@ -64,6 +69,16 @@ pub(crate) fn set_last_error(msg: &str) { }); } +#[must_use] +pub(crate) fn last_diagnostic_code() -> types::NonoDiagnosticCode { + LAST_DIAGNOSTIC_CODE.with(|cell| cell.borrow().unwrap_or(types::NonoDiagnosticCode::Other)) +} + +#[must_use] +pub(crate) fn last_remediation_json() -> Option { + LAST_REMEDIATION_JSON.with(|cell| cell.borrow().clone()) +} + /// Map a `NonoError` to an error code and store the message. /// /// Every `NonoError` variant is matched explicitly so the compiler will flag @@ -72,6 +87,14 @@ pub(crate) fn set_last_error(msg: &str) { pub(crate) fn map_error(e: &nono::NonoError) -> types::NonoErrorCode { use types::NonoErrorCode; set_last_error(&e.to_string()); + LAST_DIAGNOSTIC_CODE.with(|cell| { + *cell.borrow_mut() = Some(types::NonoDiagnosticCode::from(e.diagnostic_code())); + }); + LAST_REMEDIATION_JSON.with(|cell| { + *cell.borrow_mut() = e + .remediation() + .and_then(|rem| serde_json::to_string(&rem).ok()); + }); match e { nono::NonoError::PathNotFound(_) => NonoErrorCode::ErrPathNotFound, nono::NonoError::ExpectedDirectory(_) => NonoErrorCode::ErrExpectedDirectory, @@ -204,6 +227,12 @@ pub extern "C" fn nono_clear_error() { LAST_ERROR.with(|cell| { *cell.borrow_mut() = None; }); + LAST_DIAGNOSTIC_CODE.with(|cell| { + *cell.borrow_mut() = None; + }); + LAST_REMEDIATION_JSON.with(|cell| { + *cell.borrow_mut() = None; + }); } /// Free a string previously returned by a nono FFI function. diff --git a/bindings/c/src/types.rs b/bindings/c/src/types.rs index 13207678e..ef18b3b5e 100644 --- a/bindings/c/src/types.rs +++ b/bindings/c/src/types.rs @@ -183,3 +183,53 @@ pub enum NonoErrorCode { /// Unknown or uncategorized error. ErrUnknown = -99, } + +/// Diagnostic code for sandbox and setup errors. +#[repr(C)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NonoDiagnosticCode { + SandboxDeniedPath = 0, + SandboxDeniedNetwork = 1, + SandboxDeniedUnixSocket = 2, + CommandNotFound = 3, + CommandFailedLikelySandbox = 4, + CommandFailedApplication = 5, + CredentialNotFound = 6, + CredentialUnavailable = 7, + UnsupportedPlatformFeature = 8, + RollbackBudgetExceeded = 9, + CwdAccessRequired = 10, + ConfigurationError = 11, + TrustVerificationFailed = 12, + IoError = 13, + Cancelled = 14, + Other = 99, +} + +impl From for NonoDiagnosticCode { + fn from(code: nono::NonoDiagnosticCode) -> Self { + match code { + nono::NonoDiagnosticCode::SandboxDeniedPath => Self::SandboxDeniedPath, + nono::NonoDiagnosticCode::SandboxDeniedNetwork => Self::SandboxDeniedNetwork, + nono::NonoDiagnosticCode::SandboxDeniedUnixSocket => Self::SandboxDeniedUnixSocket, + nono::NonoDiagnosticCode::CommandNotFound => Self::CommandNotFound, + nono::NonoDiagnosticCode::CommandFailedLikelySandbox => { + Self::CommandFailedLikelySandbox + } + nono::NonoDiagnosticCode::CommandFailedApplication => Self::CommandFailedApplication, + nono::NonoDiagnosticCode::CredentialNotFound => Self::CredentialNotFound, + nono::NonoDiagnosticCode::CredentialUnavailable => Self::CredentialUnavailable, + nono::NonoDiagnosticCode::UnsupportedPlatformFeature => { + Self::UnsupportedPlatformFeature + } + nono::NonoDiagnosticCode::RollbackBudgetExceeded => Self::RollbackBudgetExceeded, + nono::NonoDiagnosticCode::CwdAccessRequired => Self::CwdAccessRequired, + nono::NonoDiagnosticCode::ConfigurationError => Self::ConfigurationError, + nono::NonoDiagnosticCode::TrustVerificationFailed => Self::TrustVerificationFailed, + nono::NonoDiagnosticCode::IoError => Self::IoError, + nono::NonoDiagnosticCode::Cancelled => Self::Cancelled, + nono::NonoDiagnosticCode::Other => Self::Other, + _ => Self::Other, + } + } +} diff --git a/crates/nono-cli/src/cli.rs b/crates/nono-cli/src/cli.rs index bb70e6e5f..169368127 100644 --- a/crates/nono-cli/src/cli.rs +++ b/crates/nono-cli/src/cli.rs @@ -1743,6 +1743,10 @@ pub struct RunArgs { #[arg(long, help_heading = "OPTIONS")] pub no_diagnostics: bool, + /// After the run, print session diagnostics as JSON on stderr (merged with proxy diagnostics when present). + #[arg(long = "diagnostics-json", help_heading = "OPTIONS")] + pub diagnostics_json: bool, + /// Kill the process if it has not entered alt-screen mode after this many seconds. /// Startup banners and log lines do not count; only a full-screen TUI transition satisfies the check. /// Set to 0 to disable. Env: NONO_STARTUP_TIMEOUT. diff --git a/crates/nono-cli/src/diagnostic/formatter.rs b/crates/nono-cli/src/diagnostic/formatter.rs index ae721d56a..f953d9c81 100644 --- a/crates/nono-cli/src/diagnostic/formatter.rs +++ b/crates/nono-cli/src/diagnostic/formatter.rs @@ -1,4 +1,4 @@ -//! Diagnostic output formatter for sandbox policy (CLI-owned). +//! Diagnostic footer rendering for sandboxed command failures. //! //! This module provides human and agent-readable diagnostic output //! when sandboxed commands fail. The output helps identify whether @@ -18,8 +18,13 @@ //! - **Actionable**: Provides specific flags to grant additional access //! - **Mode-aware**: Different guidance for supervised vs standard mode +use nono::SessionDiagnosticReport; use nono::diagnostic::{ - DenialReason, DenialRecord, IpcDenialRecord, SandboxViolation, seatbelt_operation_to_access, + DenialReason, DenialRecord, IpcDenialRecord, NonoDiagnostic, NonoDiagnosticCode, + NonoDiagnosticDetail, NonoRemediation, SandboxViolation, dedupe_denials, + diagnostic_application_failure, diagnostic_likely_sandbox_path, diagnostic_missing_path, + diagnostic_network_blocked, diagnostic_protected_file_write, + filesystem_denials_from_violations, follow_up_diagnostics, }; use nono::try_canonicalize; use nono::{AccessMode, CapabilitySet, CapabilitySource}; @@ -38,8 +43,6 @@ pub struct PolicyExplanation { pub access: AccessMode, /// Why it was denied: "sensitive_path", "insufficient_access", or "path_not_granted". pub reason: String, - /// Suggested CLI flag to fix (e.g. "--read ~/.ssh/id_rsa"). - pub suggested_flag: Option, } /// Path-level hint extracted from a command's own error output. @@ -75,6 +78,8 @@ pub struct ErrorObservation { pub missing_paths: Vec, /// Error text that strongly suggests a non-sandbox application failure. pub non_sandbox_failure: Option, + /// Stderr contains a pattern that might indicate network access was blocked. + pub network_blocked_hint: bool, } impl ErrorObservation { @@ -85,6 +90,7 @@ impl ErrorObservation { || !self.path_hints.is_empty() || !self.missing_paths.is_empty() || self.non_sandbox_failure.is_some() + || self.network_blocked_hint } } @@ -153,8 +159,12 @@ pub fn analyze_error_output( let mut pending_structured_access_denial = false; let mut pending_structured_access: Option = None; let mut non_sandbox_failure = None; + let mut network_blocked_hint = false; for line in error_output.lines() { + if !network_blocked_hint && looks_like_network_denial(line) { + network_blocked_hint = true; + } if blocked_protected_file.is_none() { blocked_protected_file = detect_protected_file_in_error_line(protected_paths, line); } @@ -243,6 +253,7 @@ pub fn analyze_error_output( path_hints, missing_paths: missing.into_iter().collect(), non_sandbox_failure, + network_blocked_hint, } } @@ -286,6 +297,16 @@ fn detect_protected_file_in_error_line( None } +fn looks_like_network_denial(line: &str) -> bool { + let lower = line.to_ascii_lowercase(); + (lower.contains("network") || lower.contains("socket") || lower.contains("connect")) + && (lower.contains("not permitted") + || lower.contains("permission denied") + || lower.contains("operation not permitted") + || lower.contains("connection refused") + || lower.contains("unreachable")) +} + fn looks_like_access_denial(line: &str) -> bool { let lower = line.to_ascii_lowercase(); lower.contains("operation not permitted") @@ -572,10 +593,7 @@ fn infer_access_from_error_line(line: &str, path: &Path) -> AccessMode { AccessMode::ReadWrite } -/// Formats diagnostic information about sandbox policy. -/// -/// This is library code that can be used by any parent process -/// that wants to explain sandbox denials to users or AI agents. +/// Renders sandbox policy diagnostics for stderr output. pub struct DiagnosticFormatter<'a> { caps: &'a CapabilitySet, mode: DiagnosticMode, @@ -594,6 +612,8 @@ pub struct DiagnosticFormatter<'a> { missing_path_hints: Vec, /// Error text that strongly suggests a non-sandbox application failure. non_sandbox_failure: Option, + /// Stderr contains a pattern that might indicate network access was blocked. + network_blocked_hint: bool, /// Command that was executed (for context-aware diagnostics) command: Option, /// Directory the child process started in. @@ -615,6 +635,8 @@ pub struct DiagnosticFormatter<'a> { /// render loop so filesystem I/O is done once (by the caller, after the /// child exits) rather than once per denial per render method. canonical_denial_paths: Vec, + /// Pre-built diagnostics; when empty, [`Self::format_footer`] builds a report on demand. + session_diagnostics: &'a [nono::NonoDiagnostic], } impl<'a> DiagnosticFormatter<'a> { @@ -633,6 +655,7 @@ impl<'a> DiagnosticFormatter<'a> { observed_path_hints: Vec::new(), missing_path_hints: Vec::new(), non_sandbox_failure: None, + network_blocked_hint: false, command: None, current_dir: None, session_id: None, @@ -640,6 +663,7 @@ impl<'a> DiagnosticFormatter<'a> { suppressed_paths: &[], suppressed_system_service_operations: &[], canonical_denial_paths: Vec::new(), + session_diagnostics: &[], } } @@ -679,6 +703,99 @@ impl<'a> DiagnosticFormatter<'a> { self } + /// Diagnostics used for fix-flag rendering. + #[must_use] + pub fn with_session_diagnostics(mut self, diagnostics: &'a [nono::NonoDiagnostic]) -> Self { + self.session_diagnostics = diagnostics; + self + } + + /// Attach diagnostics from a pre-built session report. + /// + /// Prefer [`Self::build_session_report`] so the footer and `--diagnostics-json` + /// share the same merge path. + #[must_use] + pub fn with_session_report(self, report: &'a SessionDiagnosticReport) -> Self { + self.with_session_diagnostics(&report.diagnostics) + } + + /// Build a session report from this formatter's denial and observation inputs. + #[must_use] + pub fn build_session_report(&self, exit_code: i32) -> SessionDiagnosticReport { + let violations: Vec = self + .sandbox_violations + .iter() + .filter(|violation| { + !self + .suppressed_system_service_operations + .contains(&violation.operation) + }) + .cloned() + .collect(); + let mut all_denials: Vec = self.denials.to_vec(); + all_denials.extend(filesystem_denials_from_violations(&violations)); + all_denials.extend(self.observed_denials_matching_logged_paths(&all_denials)); + let deduped = dedupe_denials(&all_denials); + let mut report = SessionDiagnosticReport::from_merged_session( + exit_code, + deduped, + self.ipc_denials.to_vec(), + violations, + ); + self.append_observation_diagnostics(&mut report.diagnostics); + report.diagnostics.extend(follow_up_diagnostics()); + report + } + + fn append_observation_diagnostics(&self, diagnostics: &mut Vec) { + if let Some(ref file) = self.blocked_protected_file { + push_unique_diagnostic(diagnostics, diagnostic_protected_file_write(file.clone())); + } + for path in &self.missing_path_hints { + push_unique_diagnostic(diagnostics, diagnostic_missing_path(path.clone())); + } + if let Some(ref message) = self.non_sandbox_failure { + push_unique_diagnostic(diagnostics, diagnostic_application_failure(message.clone())); + } + if self.network_blocked_hint && self.caps.is_network_blocked() { + push_unique_diagnostic(diagnostics, diagnostic_network_blocked()); + } + for hint in self.actionable_observed_path_hints() { + if observation_path_already_logged(diagnostics, &hint.path) { + continue; + } + let remediation = self.remediation_for_observed_hint(&hint); + push_unique_diagnostic( + diagnostics, + diagnostic_likely_sandbox_path(hint.path, hint.access, remediation), + ); + } + } + + fn remediation_for_observed_hint(&self, hint: &ObservedPathHint) -> NonoRemediation { + if self.observed_hint_points_to_ungranted_cwd(&hint.path) { + return NonoRemediation::AllowCwd; + } + if let Some(cap) = self.closest_covering_capability_any(&hint.path) { + let access = match (cap.access, hint.access) { + (AccessMode::Read, AccessMode::ReadWrite) => AccessMode::Write, + (AccessMode::Write, AccessMode::ReadWrite) => AccessMode::Read, + _ => hint.access, + }; + return NonoRemediation::GrantPath { + is_file: cap.is_file, + path: cap.resolved.clone(), + access, + }; + } + NonoRemediation::GrantPath { + is_file: hint.path.is_file() + || hint.path.file_name().is_some_and(|_| !hint.path.is_dir()), + path: hint.path.clone(), + access: hint.access, + } + } + /// Add IPC denial records from a supervised session. #[must_use] pub fn with_ipc_denials(mut self, denials: &'a [IpcDenialRecord]) -> Self { @@ -711,6 +828,7 @@ impl<'a> DiagnosticFormatter<'a> { self.observed_path_hints = observation.path_hints; self.missing_path_hints = observation.missing_paths; self.non_sandbox_failure = observation.non_sandbox_failure; + self.network_blocked_hint = observation.network_blocked_hint; self } @@ -752,9 +870,20 @@ impl<'a> DiagnosticFormatter<'a> { /// The output is designed to be printed to stderr. #[must_use] pub fn format_footer(&self, exit_code: i32) -> String { + let report_storage; + let diagnostics = if self.session_diagnostics.is_empty() { + report_storage = self.build_session_report(exit_code); + &report_storage.diagnostics + } else { + self.session_diagnostics + }; let body = match self.mode { - DiagnosticMode::Standard => self.format_standard_footer(exit_code), - DiagnosticMode::Supervised => self.format_supervised_footer(exit_code), + DiagnosticMode::Standard => { + self.format_standard_footer_with_diagnostics(exit_code, diagnostics) + } + DiagnosticMode::Supervised => { + self.format_supervised_footer_with_diagnostics(exit_code, diagnostics) + } }; render_diagnostic_block(&body) } @@ -987,19 +1116,22 @@ impl<'a> DiagnosticFormatter<'a> { lines } - /// Standard mode footer: concise policy summary with --allow suggestions. - fn format_standard_footer(&self, exit_code: i32) -> String { + /// Standard-mode footer from session diagnostics. + fn format_standard_footer_with_diagnostics( + &self, + exit_code: i32, + diagnostics: &[NonoDiagnostic], + ) -> String { let mut lines = Vec::new(); - let observed_hints = self.actionable_observed_path_hints(); - let primary_verdict = self.primary_observation_verdict(); - let has_observation = self.has_error_observation(); - - // Check if this was a protected file write attempt - if let Some(ref blocked_file) = self.blocked_protected_file { - lines.push(format!( - "[nono] Write to '{}' blocked: file is a signed instruction file.", - blocked_file - )); + let stderr_likely = stderr_likely_sandbox_diagnostics(diagnostics); + let has_stderr_findings = !stderr_likely.is_empty() + || stderr_missing_path_diagnostic(diagnostics).is_some() + || stderr_application_failure_diagnostic(diagnostics).is_some() + || stderr_protected_file_diagnostic(diagnostics).is_some() + || stderr_network_diagnostic(diagnostics).is_some(); + + if let Some(diagnostic) = stderr_protected_file_diagnostic(diagnostics) { + lines.push(format!("[nono] {}", diagnostic.message)); lines.push( "[nono] Signed instruction files are write-protected to prevent tampering." .to_string(), @@ -1009,113 +1141,103 @@ impl<'a> DiagnosticFormatter<'a> { "[nono] The command failed. (exit code {})", exit_code )); - } else if matches!( - primary_verdict.as_ref(), - Some(ErrorVerdict::MissingPath(_)) | Some(ErrorVerdict::NonSandboxFailure(_)) - ) { + } else if let Some(diagnostic) = stderr_missing_path_diagnostic(diagnostics) { + lines.push(format_command_failed_not_sandbox_line(exit_code)); + lines.push("[nono]".to_string()); + self.format_missing_path_from_diagnostic(&mut lines, diagnostic); + } else if let Some(diagnostic) = stderr_application_failure_diagnostic(diagnostics) { lines.push(format_command_failed_not_sandbox_line(exit_code)); - } else if exit_code == 0 && has_observation { + lines.push("[nono]".to_string()); + self.format_application_failure_from_diagnostic(&mut lines, diagnostic); + } else if exit_code == 0 && has_stderr_findings { lines.push(format_command_succeeded_with_stderr_line()); } else { lines.extend(self.format_exit_explanation(exit_code)); } lines.push("[nono]".to_string()); - if self.blocked_protected_file.is_none() - && let Some(verdict) = primary_verdict.as_ref() - { - self.format_primary_verdict_guidance(&mut lines, verdict); - lines.push("[nono]".to_string()); + if self.blocked_protected_file.is_none() { + if let Some(diagnostic) = stderr_likely.first().copied() { + self.format_likely_sandbox_from_diagnostic(&mut lines, diagnostic); + lines.push("[nono]".to_string()); + } else if let Some(diagnostic) = stderr_network_diagnostic(diagnostics) { + self.format_network_denial_from_diagnostic(&mut lines, diagnostic); + lines.push("[nono]".to_string()); + } } - // Concise policy summary: show user paths, summarize system/group paths lines.push("[nono] Sandbox policy:".to_string()); - self.format_allowed_paths_concise(&mut lines); self.format_network_status(&mut lines); self.format_protected_paths(&mut lines); - let additional_hints = if observed_hints.len() > 1 { - &observed_hints[1..] + + let additional = if stderr_likely.len() > 1 { + &stderr_likely[1..] } else { &[] }; - self.format_observed_path_hints(&mut lines, additional_hints); + self.format_likely_sandbox_list(&mut lines, additional); - // Help section (skip if the failure was specifically due to protected file) - if self.blocked_protected_file.is_none() - && observed_hints.is_empty() - && primary_verdict.is_none() - { + if self.blocked_protected_file.is_none() && !has_stderr_findings { lines.push("[nono]".to_string()); self.format_grant_help(&mut lines); lines.push("[nono]".to_string()); - self.format_follow_up_guidance(&mut lines, None); + self.format_follow_up_from_diagnostics(&mut lines, diagnostics); } lines.join("\n") } - /// Supervised mode footer: show denials and mode-specific guidance. - fn format_supervised_footer(&self, exit_code: i32) -> String { + /// Supervised-mode footer from session diagnostics. + fn format_supervised_footer_with_diagnostics( + &self, + exit_code: i32, + diagnostics: &[NonoDiagnostic], + ) -> String { let mut lines = Vec::new(); let primary_verdict = self.primary_observation_verdict(); let has_observation = self.has_error_observation(); - let has_ipc_denials = !self.ipc_denials.is_empty(); + let ipc_diagnostics = self.ipc_diagnostics(diagnostics); + let pathname_unix_diagnostics = self.pathname_unix_socket_diagnostics(diagnostics); + let path_diagnostics = self.path_diagnostics(diagnostics); + let system_service_diagnostics = self.system_service_diagnostics(diagnostics); + let has_path_findings = + !path_diagnostics.is_empty() || !pathname_unix_diagnostics.is_empty(); - if self.denials.is_empty() - && !has_ipc_denials + if !has_path_findings + && ipc_diagnostics.is_empty() && matches!( primary_verdict.as_ref(), Some(ErrorVerdict::MissingPath(_)) | Some(ErrorVerdict::NonSandboxFailure(_)) ) { lines.push(format_command_failed_not_sandbox_line(exit_code)); - } else if exit_code == 0 && has_observation && self.denials.is_empty() && !has_ipc_denials { + } else if exit_code == 0 + && has_observation + && !has_path_findings + && ipc_diagnostics.is_empty() + { lines.push(format_command_succeeded_with_stderr_line()); } else { lines.extend(self.format_exit_explanation(exit_code)); } lines.push("[nono]".to_string()); - // Convert macOS Seatbelt violations (operation + target) into - // DenialRecords so the same rendering logic handles both platforms. - let (violation_denials, mut non_fs_violations) = - violations_to_denials(self.sandbox_violations); - non_fs_violations.retain(|violation| { - !self - .suppressed_system_service_operations - .contains(&violation.operation) - }); - - // Merge supervisor denials (Linux seccomp) with violation-derived - // denials (macOS Seatbelt) into a single unified list. - let mut all_denials: Vec = self - .denials - .iter() - .cloned() - .chain(violation_denials) - .collect(); - all_denials.extend(self.observed_denials_matching_logged_paths(&all_denials)); - - if !self.ipc_denials.is_empty() { - self.format_ipc_denial_guidance(&mut lines); - if !all_denials.is_empty() || !non_fs_violations.is_empty() { + if !ipc_diagnostics.is_empty() { + self.format_ipc_denial_guidance(&mut lines, &ipc_diagnostics, diagnostics); + if has_path_findings || !system_service_diagnostics.is_empty() { lines.push("[nono]".to_string()); } } - if all_denials.is_empty() && self.ipc_denials.is_empty() { - // No denials from either source. - if !non_fs_violations.is_empty() { - // Non-filesystem violations (mach-lookup, signal, etc.) — - // show them with human-readable descriptions. + if !has_path_findings && ipc_diagnostics.is_empty() { + if !system_service_diagnostics.is_empty() { lines.push("[nono] Sandbox blocked system services:".to_string()); - format_non_fs_violations(&mut lines, &non_fs_violations); + self.format_system_service_diagnostics(&mut lines, &system_service_diagnostics); lines.push("[nono]".to_string()); - format_non_fs_guidance(&mut lines, &non_fs_violations); + self.format_system_service_guidance(&mut lines, &system_service_diagnostics); } else { - // Genuinely no denials observed. if let Some(verdict) = primary_verdict.as_ref() { self.format_primary_verdict_guidance(&mut lines, verdict); lines.push("[nono]".to_string()); @@ -1128,25 +1250,22 @@ impl<'a> DiagnosticFormatter<'a> { lines.push("[nono]".to_string()); self.format_grant_help(&mut lines); lines.push("[nono]".to_string()); - self.format_follow_up_guidance(&mut lines, None); - } else if !all_denials.is_empty() { - // Deduplicate by path, merging access modes. Classification into - // actionable vs. policy-blocked is done by the consolidated - // formatter using policy_explanations when available. - let deduped = dedupe_denials(&all_denials); - self.format_consolidated_denial_guidance(&mut lines, &deduped); - - // Show non-filesystem violations (mach-lookup, etc.) if any - if !non_fs_violations.is_empty() { + self.format_follow_up_from_diagnostics(&mut lines, diagnostics); + } else if has_path_findings { + self.format_consolidated_denial_guidance( + &mut lines, + &pathname_unix_diagnostics, + &path_diagnostics, + diagnostics, + ); + + if !system_service_diagnostics.is_empty() { lines.push("[nono]".to_string()); lines.push("[nono] Also blocked (system services):".to_string()); - format_non_fs_violations(&mut lines, &non_fs_violations); + self.format_system_service_diagnostics(&mut lines, &system_service_diagnostics); lines.push("[nono]".to_string()); - format_non_fs_guidance(&mut lines, &non_fs_violations); + self.format_system_service_guidance(&mut lines, &system_service_diagnostics); } - - // Note: `nono grant` suggestions are shown via desktop - // notifications during the session, not in the post-exit footer. } lines.join("\n") @@ -1260,25 +1379,131 @@ impl<'a> DiagnosticFormatter<'a> { best_covering } - fn format_follow_up_guidance( + fn format_follow_up_from_diagnostics( &self, lines: &mut Vec, - _hint: Option<(&Path, AccessMode)>, + diagnostics: &[NonoDiagnostic], ) { lines.push("[nono] Next steps:".to_string()); - if let Some(command) = self.format_command_for_learn() { - lines.push(format!( - "[nono] Add permissions: nono run --allow -- {}", - command - )); - } else { + for diagnostic in diagnostics { + let Some(ref remediation) = diagnostic.remediation else { + continue; + }; + match remediation { + NonoRemediation::RunDiscovery => { + if let Some(command) = self.format_command_for_learn() { + lines.push(format!( + "[nono] Add permissions: nono run --allow -- {}", + command + )); + } else { + lines.push( + "[nono] Add permissions: nono run --allow -- " + .to_string(), + ); + } + } + NonoRemediation::CheckPolicy => { + lines.push( + "[nono] Query policy: nono why --path --op " + .to_string(), + ); + } + _ => {} + } + } + } + + fn format_likely_sandbox_from_diagnostic( + &self, + lines: &mut Vec, + diagnostic: &NonoDiagnostic, + ) { + let Some(path) = diagnostic.path.as_ref() else { + return; + }; + let access = diagnostic.access.unwrap_or(AccessMode::Read); + lines.push("[nono] Sandbox denial:".to_string()); + if self.observed_hint_points_to_read_only_cwd(&ObservedPathHint { + path: path.clone(), + access, + }) { lines.push( - "[nono] Add permissions: nono run --allow -- ".to_string(), + "[nono] The command appears to be writing inside the current working directory," + .to_string(), + ); + lines.push( + "[nono] but the current working directory is read-only in this sandbox." + .to_string(), ); } - lines.push( - "[nono] Query policy: nono why --path --op ".to_string(), - ); + lines.push(format!( + "[nono] {} ({})", + path.display(), + access_str(access), + )); + if let Some(ref remediation) = diagnostic.remediation + && let Some(flag) = crate::query_ext::suggested_flag_for_remediation(remediation) + { + lines.push(format!("[nono] Try: {flag}")); + } else if diagnostic.remediation.is_none() { + lines.push(format!( + "[nono] Try: {}", + self.suggested_flag_for_hint(path, access) + )); + } + } + + fn format_likely_sandbox_list(&self, lines: &mut Vec, diagnostics: &[&NonoDiagnostic]) { + if diagnostics.is_empty() { + return; + } + lines.push("[nono] Likely blocked paths seen in the command output:".to_string()); + for diagnostic in diagnostics { + if let (Some(path), Some(access)) = (&diagnostic.path, diagnostic.access) { + lines.push(format!( + "[nono] {} ({})", + path.display(), + access_str(access), + )); + } + } + } + + fn format_missing_path_from_diagnostic( + &self, + lines: &mut Vec, + diagnostic: &NonoDiagnostic, + ) { + if let Some(path) = &diagnostic.path { + self.format_primary_missing_path_guidance(lines, path); + } + } + + fn format_application_failure_from_diagnostic( + &self, + lines: &mut Vec, + diagnostic: &NonoDiagnostic, + ) { + let message = diagnostic + .message + .strip_prefix("command reported application error: ") + .unwrap_or(diagnostic.message.as_str()); + self.format_non_sandbox_failure_guidance(lines, message); + } + + fn format_network_denial_from_diagnostic( + &self, + lines: &mut Vec, + diagnostic: &NonoDiagnostic, + ) { + lines.push("[nono] Sandbox denial:".to_string()); + lines.push(format!("[nono] {}", diagnostic.message)); + if let Some(ref remediation) = diagnostic.remediation + && let Some(flag) = crate::query_ext::suggested_flag_for_remediation(remediation) + { + lines.push(format!("[nono] Try: {flag}")); + } } fn format_primary_observed_guidance(&self, lines: &mut Vec, hint: &ObservedPathHint) { @@ -1356,48 +1581,48 @@ impl<'a> DiagnosticFormatter<'a> { fn format_consolidated_denial_guidance( &self, lines: &mut Vec, - denials: &[DenialRecord], + pathname_unix_diagnostics: &[&NonoDiagnostic], + path_diagnostics: &[&NonoDiagnostic], + diagnostics: &[NonoDiagnostic], ) { const MAX_INLINE_LIST: usize = 10; - let (unix_socket_denials, path_denials): (Vec<&DenialRecord>, Vec<&DenialRecord>) = denials - .iter() - .partition(|denial| denial.reason == DenialReason::UnixSocketDenied); - - if !unix_socket_denials.is_empty() { - let total = unix_socket_denials.len(); + if !pathname_unix_diagnostics.is_empty() { + let total = pathname_unix_diagnostics.len(); let plural_s = if total == 1 { "" } else { "s" }; lines.push(format!( "[nono] IPC denial: {} pathname Unix socket{} blocked.", total, plural_s )); - for (idx, denial) in unix_socket_denials.iter().enumerate() { + for (idx, diagnostic) in pathname_unix_diagnostics.iter().enumerate() { if idx >= MAX_INLINE_LIST { lines.push(format!("[nono] ... and {} more", total - idx)); break; } - lines.push(format!("[nono] {}", denial.path.display())); + if let Some(path) = &diagnostic.path { + lines.push(format!("[nono] {}", path.display())); + } + } + let flags = self + .fix_flags_for_codes(diagnostics, &[NonoDiagnosticCode::SandboxDeniedUnixSocket]); + if !flags.is_empty() { + lines.push(format!("[nono] Fix flags: {}", flags.join(" "))); } - let flags: Vec = unix_socket_denials - .iter() - .map(|d| self.suggested_flag_for_denial(d)) - .collect(); - lines.push(format!("[nono] Fix flags: {}", flags.join(" "))); } - if path_denials.is_empty() { + if path_diagnostics.is_empty() { return; } - let total = path_denials.len(); - let mut actionable: Vec<&DenialRecord> = Vec::new(); - let mut policy_blocked: Vec<&DenialRecord> = Vec::new(); + let total = path_diagnostics.len(); + let mut actionable = 0usize; + let mut policy_blocked = 0usize; - for denial in &path_denials { - if self.is_denial_policy_blocked(denial) { - policy_blocked.push(denial); + for diagnostic in path_diagnostics { + if self.is_diagnostic_policy_blocked(diagnostic) { + policy_blocked += 1; } else { - actionable.push(denial); + actionable += 1; } } @@ -1407,16 +1632,16 @@ impl<'a> DiagnosticFormatter<'a> { total, plural_s )); - for (idx, denial) in path_denials.iter().enumerate() { + for (idx, diagnostic) in path_diagnostics.iter().enumerate() { if idx >= MAX_INLINE_LIST { lines.push(format!("[nono] … and {} more", total - idx)); break; } let mut labels: Vec<&str> = Vec::new(); - if self.is_denial_policy_blocked(denial) { + if self.is_diagnostic_policy_blocked(diagnostic) { labels.push("permanently restricted"); } - if self.is_denial_suppressed(denial) { + if self.is_diagnostic_suppressed(diagnostic) { labels.push("save skipped"); } let suffix = if labels.is_empty() { @@ -1424,24 +1649,23 @@ impl<'a> DiagnosticFormatter<'a> { } else { format!(" [{}]", labels.join(", ")) }; - lines.push(format!( - "[nono] {} ({}){}", - denial.path.display(), - access_str(denial.access), - suffix, - )); + let path = diagnostic.path.as_deref().map(Path::display); + let access = diagnostic.access.map(access_str).unwrap_or("unknown"); + if let Some(path) = path { + lines.push(format!("[nono] {path} ({access}){suffix}")); + } } - if !actionable.is_empty() { - let flags: Vec = actionable - .iter() - .map(|d| self.suggested_flag_for_denial(d)) - .collect(); - lines.push(format!("[nono] Fix flags: {}", flags.join(" "))); + if actionable > 0 { + let flags = + self.fix_flags_for_codes(diagnostics, &[NonoDiagnosticCode::SandboxDeniedPath]); + if !flags.is_empty() { + lines.push(format!("[nono] Fix flags: {}", flags.join(" "))); + } } - if !policy_blocked.is_empty() { - let n = policy_blocked.len(); + if policy_blocked > 0 { + let n = policy_blocked; let (subject, verb) = if n == 1 { ("1 path is", "") } else { @@ -1460,108 +1684,201 @@ impl<'a> DiagnosticFormatter<'a> { } } - fn format_ipc_denial_guidance(&self, lines: &mut Vec) { + fn format_ipc_denial_guidance( + &self, + lines: &mut Vec, + ipc_diagnostics: &[&NonoDiagnostic], + diagnostics: &[NonoDiagnostic], + ) { const MAX_INLINE_LIST: usize = 10; - let total = self.ipc_denials.len(); + let total = ipc_diagnostics.len(); let plural_s = if total == 1 { "" } else { "s" }; lines.push(format!( "[nono] IPC denial: {} Unix socket operation{} blocked.", total, plural_s )); - for (idx, denial) in self.ipc_denials.iter().enumerate() { + for (idx, diagnostic) in ipc_diagnostics.iter().enumerate() { if idx >= MAX_INLINE_LIST { lines.push(format!("[nono] ... and {} more", total - idx)); break; } - lines.push(format!( - "[nono] {} {} ({})", - denial.operation, denial.target, denial.reason - )); + if let Some(NonoDiagnosticDetail::IpcDenial { + operation, + target, + ipc_reason, + }) = &diagnostic.detail + { + lines.push(format!("[nono] {operation} {target} ({ipc_reason})")); + } } - let flags: Vec<&str> = self - .ipc_denials - .iter() - .filter_map(|denial| denial.suggested_flag.as_deref()) - .collect(); + let flags = + self.fix_flags_for_codes(diagnostics, &[NonoDiagnosticCode::SandboxDeniedUnixSocket]); if !flags.is_empty() { lines.push(format!("[nono] Fix flags: {}", flags.join(" "))); } } - /// Return the canonical form of `denial.path`, using the pre-computed - /// parallel vec when available to avoid repeated filesystem I/O. - fn canonical_for_denial<'b>(&'b self, denial: &DenialRecord) -> std::borrow::Cow<'b, Path> { - if let Some(canonical) = self - .denials + fn ipc_diagnostics<'diag>( + &self, + diagnostics: &'diag [NonoDiagnostic], + ) -> Vec<&'diag NonoDiagnostic> { + diagnostics .iter() - .position(|d| d.path == denial.path) - .and_then(|i| self.canonical_denial_paths.get(i)) - { - return std::borrow::Cow::Borrowed(canonical.as_path()); + .filter(|diagnostic| { + matches!( + diagnostic.detail, + Some(NonoDiagnosticDetail::IpcDenial { .. }) + ) + }) + .collect() + } + + fn pathname_unix_socket_diagnostics<'diag>( + &self, + diagnostics: &'diag [NonoDiagnostic], + ) -> Vec<&'diag NonoDiagnostic> { + diagnostics + .iter() + .filter(|diagnostic| { + matches!( + diagnostic.detail, + Some(NonoDiagnosticDetail::SupervisedDenial { + reason: DenialReason::UnixSocketDenied, + .. + }) + ) + }) + .collect() + } + + fn path_diagnostics<'diag>( + &self, + diagnostics: &'diag [NonoDiagnostic], + ) -> Vec<&'diag NonoDiagnostic> { + diagnostics + .iter() + .filter(|diagnostic| diagnostic.code == NonoDiagnosticCode::SandboxDeniedPath) + .collect() + } + + fn system_service_diagnostics<'diag>( + &self, + diagnostics: &'diag [NonoDiagnostic], + ) -> Vec<&'diag NonoDiagnostic> { + diagnostics + .iter() + .filter(|diagnostic| diagnostic.code == NonoDiagnosticCode::UnsupportedPlatformFeature) + .collect() + } + + fn is_diagnostic_policy_blocked(&self, diagnostic: &NonoDiagnostic) -> bool { + let Some(path) = &diagnostic.path else { + return false; + }; + if !self.is_path_policy_blocked(path) { + return false; } - std::borrow::Cow::Owned(nono::try_canonicalize(&denial.path)) + matches!( + diagnostic.detail, + Some(NonoDiagnosticDetail::SupervisedDenial { + reason: DenialReason::PolicyBlocked, + .. + }) + ) || self + .policy_explanations + .iter() + .any(|expl| expl.path == *path && expl.reason == "sensitive_path") } - /// Return true when the denied path is in the caller-supplied suppression - /// list (i.e. `suppress_save_prompt` entries). Such paths are still denied - /// by the sandbox — this only controls whether they appear in the save - /// prompt and how they are labelled in the diagnostic footer. - fn is_denial_suppressed(&self, denial: &DenialRecord) -> bool { + fn is_diagnostic_suppressed(&self, diagnostic: &NonoDiagnostic) -> bool { + let Some(path) = &diagnostic.path else { + return false; + }; if self.suppressed_paths.is_empty() { return false; } - let canonical = self.canonical_for_denial(denial); + let canonical = self.canonical_for_diagnostic_path(path); self.suppressed_paths .iter() .any(|suppressed| canonical.starts_with(suppressed)) } - /// Return true when the denial cannot be fixed by a path flag alone — - /// i.e. the path is blocked by the sensitive-path policy and requires a - /// profile with `filesystem.bypass_protection`. - fn is_denial_policy_blocked(&self, denial: &DenialRecord) -> bool { - if let Some(expl) = self - .policy_explanations - .iter() - .find(|e| e.path == denial.path) + fn canonical_for_diagnostic_path(&self, path: &Path) -> PathBuf { + if let Some(index) = self.denials.iter().position(|denial| denial.path == path) + && let Some(canonical) = self.canonical_denial_paths.get(index) { - return expl.reason == "sensitive_path"; + return canonical.clone(); } - // No explanation (e.g. Linux seccomp path without a matching lookup): - // trust the DenialRecord reason. - denial.reason == DenialReason::PolicyBlocked + nono::try_canonicalize(path) } - /// Build the CLI flag suggestion for a single denial. Prefers the - /// explanation's `suggested_flag` (which knows about parent-directory - /// canonicalization) and falls back to a local computation otherwise. - fn suggested_flag_for_denial(&self, denial: &DenialRecord) -> String { - if denial.reason == DenialReason::UnixSocketDenied { - let flag = if denial.access.contains(AccessMode::Write) { - "--allow-unix-socket-bind" - } else { - "--allow-unix-socket" + fn format_system_service_diagnostics( + &self, + lines: &mut Vec, + diagnostics: &[&NonoDiagnostic], + ) { + let violations: Vec = diagnostics + .iter() + .filter_map(|diagnostic| sandbox_violation_owned_from_diagnostic(diagnostic)) + .collect(); + let borrowed: Vec<&SandboxViolation> = violations.iter().collect(); + format_non_fs_violations(lines, &borrowed); + } + + fn format_system_service_guidance( + &self, + lines: &mut Vec, + diagnostics: &[&NonoDiagnostic], + ) { + let violations: Vec = diagnostics + .iter() + .filter_map(|diagnostic| sandbox_violation_owned_from_diagnostic(diagnostic)) + .collect(); + let borrowed: Vec<&SandboxViolation> = violations.iter().collect(); + format_non_fs_guidance(lines, &borrowed); + } + + /// Collect CLI fix flags from structured session diagnostics. + fn fix_flags_for_codes( + &self, + diagnostics: &[NonoDiagnostic], + codes: &[NonoDiagnosticCode], + ) -> Vec { + let mut flags = Vec::new(); + for diagnostic in diagnostics { + if !codes.contains(&diagnostic.code) { + continue; + } + if diagnostic.code == NonoDiagnosticCode::SandboxDeniedPath + && let Some(path) = &diagnostic.path + && self.is_path_policy_blocked(path) + { + continue; + } + let Some(ref remediation) = diagnostic.remediation else { + continue; }; - return format!("{} {}", flag, denial.path.display()); + let Some(flag) = crate::query_ext::suggested_flag_for_remediation(remediation) else { + continue; + }; + if !flags.iter().any(|existing| existing == &flag) { + flags.push(flag); + } } + flags + } - if let Some(flag) = self - .policy_explanations - .iter() - .find(|e| e.path == denial.path && e.access == denial.access) - .and_then(|e| e.suggested_flag.clone()) - { - // explanations' suggested_flag is of the form "--read /path". - // Strip any leading fix label that callers may have prepended. - return flag - .strip_prefix("Fix: ") - .or_else(|| flag.strip_prefix("Fix flags: ")) - .map(str::to_string) - .unwrap_or(flag); + /// Return true when a path is permanently restricted by sensitive-path policy. + fn is_path_policy_blocked(&self, path: &Path) -> bool { + if let Some(expl) = self.policy_explanations.iter().find(|e| e.path == path) { + return expl.reason == "sensitive_path"; } - crate::query_ext::suggested_flag_for_path(&denial.path, denial.access) + self.denials + .iter() + .find(|d| d.path == path) + .is_some_and(|d| d.reason == DenialReason::PolicyBlocked) } fn format_grant_help(&self, lines: &mut Vec) { @@ -1632,7 +1949,7 @@ impl<'a> DiagnosticFormatter<'a> { _ => requested, }; - Some(suggested_flag_for_existing_target( + Some(crate::query_ext::suggested_flag_for_existing_target( &target, cap.is_file, requested, @@ -1693,21 +2010,6 @@ impl<'a> DiagnosticFormatter<'a> { } } - fn format_observed_path_hints(&self, lines: &mut Vec, hints: &[ObservedPathHint]) { - if hints.is_empty() { - return; - } - - lines.push("[nono] Likely blocked paths seen in the command output:".to_string()); - for hint in hints { - lines.push(format!( - "[nono] {} ({})", - hint.path.display(), - access_str(hint.access), - )); - } - } - /// Format the network status. fn format_network_status(&self, lines: &mut Vec) { use nono::NetworkMode; @@ -1954,6 +2256,20 @@ fn system_service_diagnostic_for( .find(|diagnostic| diagnostic.matches(violation)) } +fn sandbox_violation_owned_from_diagnostic( + diagnostic: &NonoDiagnostic, +) -> Option { + match &diagnostic.detail { + Some(NonoDiagnosticDetail::SeatbeltViolation { operation, target }) => { + Some(SandboxViolation { + operation: operation.clone(), + target: target.clone(), + }) + } + _ => None, + } +} + /// Format non-filesystem violations with human-readable service descriptions. fn format_non_fs_violations(lines: &mut Vec, violations: &[&SandboxViolation]) { for v in violations { @@ -2036,89 +2352,81 @@ fn keychain_grant_guidance_for_path(path: &Path, display_path: &str) -> String { format!("[nono] {flag} {display_path}") } -/// Deduplicate denials by path, merging access modes. When the same path -/// appears with multiple reasons, the most restrictive reason wins -/// (`PolicyBlocked` > `InsufficientAccess` > `UserDenied` > `RateLimited` > -/// `BackendError`). Output is sorted by path for stable rendering. -fn dedupe_denials(denials: &[DenialRecord]) -> Vec { - let mut by_path = std::collections::BTreeMap::::new(); - - for denial in denials { - by_path - .entry(denial.path.clone()) - .and_modify(|(access, reason)| { - *access = merge_access_modes(*access, denial.access); - *reason = stricter_reason(reason.clone(), denial.reason.clone()); - }) - .or_insert_with(|| (denial.access, denial.reason.clone())); +fn access_str(access: AccessMode) -> &'static str { + match access { + AccessMode::Read => "read", + AccessMode::Write => "write", + AccessMode::ReadWrite => "read+write", } - - by_path - .into_iter() - .map(|(path, (access, reason))| DenialRecord { - path, - access, - reason, - }) - .collect() } -fn stricter_reason(a: DenialReason, b: DenialReason) -> DenialReason { - fn rank(r: &DenialReason) -> u8 { - match r { - DenialReason::PolicyBlocked => 5, - DenialReason::UnixSocketDenied => 5, - DenialReason::InsufficientAccess => 4, - DenialReason::UserDenied => 3, - DenialReason::RateLimited => 2, - DenialReason::BackendError => 1, - } +fn push_unique_diagnostic(diagnostics: &mut Vec, diagnostic: NonoDiagnostic) { + if diagnostics.iter().any(|existing| existing == &diagnostic) { + return; } - if rank(&a) >= rank(&b) { a } else { b } + diagnostics.push(diagnostic); } -/// Convert `SandboxViolation`s with filesystem targets into `DenialRecord`s. -/// -/// Non-filesystem violations (mach-lookup, signal, etc.) are returned -/// separately since they can't be expressed as path grants. -fn violations_to_denials( - violations: &[SandboxViolation], -) -> (Vec, Vec<&SandboxViolation>) { - let mut denials = Vec::new(); - let mut non_fs = Vec::new(); - // Deduplicate: multiple operations on the same path merge into one denial - let mut seen = std::collections::BTreeMap::::new(); +fn observation_path_already_logged(diagnostics: &[NonoDiagnostic], path: &Path) -> bool { + diagnostics + .iter() + .any(|diagnostic| diagnostic.path.as_deref() == Some(path)) +} - for v in violations { - if let (Some(access), Some(target)) = - (seatbelt_operation_to_access(&v.operation), &v.target) - { - let path = PathBuf::from(target); - seen.entry(path) - .and_modify(|existing| *existing = merge_access_modes(*existing, access)) - .or_insert(access); - } else { - non_fs.push(v); - } - } +fn stderr_likely_sandbox_diagnostics(diagnostics: &[NonoDiagnostic]) -> Vec<&NonoDiagnostic> { + diagnostics + .iter() + .filter(|diagnostic| diagnostic.code == NonoDiagnosticCode::CommandFailedLikelySandbox) + .filter(|diagnostic| { + matches!( + diagnostic.detail, + Some(NonoDiagnosticDetail::StderrObservation { + observation_kind: nono::StderrObservationKind::LikelySandboxPath, + }) + ) + }) + .collect() +} - for (path, access) in seen { - denials.push(DenialRecord { - path, - access, - reason: DenialReason::PolicyBlocked, - }); - } +fn stderr_missing_path_diagnostic(diagnostics: &[NonoDiagnostic]) -> Option<&NonoDiagnostic> { + diagnostics.iter().find(|diagnostic| { + matches!( + diagnostic.detail, + Some(NonoDiagnosticDetail::StderrObservation { + observation_kind: nono::StderrObservationKind::MissingPath, + }) + ) + }) +} - (denials, non_fs) +fn stderr_application_failure_diagnostic( + diagnostics: &[NonoDiagnostic], +) -> Option<&NonoDiagnostic> { + diagnostics.iter().find(|diagnostic| { + matches!( + diagnostic.detail, + Some(NonoDiagnosticDetail::StderrObservation { + observation_kind: nono::StderrObservationKind::ApplicationFailure, + }) + ) + }) } -fn access_str(access: AccessMode) -> &'static str { - match access { - AccessMode::Read => "read", - AccessMode::Write => "write", - AccessMode::ReadWrite => "read+write", - } +fn stderr_protected_file_diagnostic(diagnostics: &[NonoDiagnostic]) -> Option<&NonoDiagnostic> { + diagnostics.iter().find(|diagnostic| { + matches!( + diagnostic.detail, + Some(NonoDiagnosticDetail::StderrObservation { + observation_kind: nono::StderrObservationKind::ProtectedFileWrite, + }) + ) + }) +} + +fn stderr_network_diagnostic(diagnostics: &[NonoDiagnostic]) -> Option<&NonoDiagnostic> { + diagnostics + .iter() + .find(|diagnostic| diagnostic.code == NonoDiagnosticCode::SandboxDeniedNetwork) } fn merge_access_modes(existing: AccessMode, new: AccessMode) -> AccessMode { @@ -2129,28 +2437,6 @@ fn merge_access_modes(existing: AccessMode, new: AccessMode) -> AccessMode { } } -fn suggested_flag_for_existing_target( - target: &Path, - is_file: bool, - requested: AccessMode, -) -> String { - let flag = if is_file { - match requested { - AccessMode::Read => "--read-file", - AccessMode::Write => "--write-file", - AccessMode::ReadWrite => "--allow-file", - } - } else { - match requested { - AccessMode::Read => "--read", - AccessMode::Write => "--write", - AccessMode::ReadWrite => "--allow", - } - }; - - format!("{flag} {}", target.display()) -} - fn shell_quote(s: &str) -> String { if !s.is_empty() && s.bytes() @@ -2190,6 +2476,16 @@ mod tests { caps } + fn format_footer_with_session_report( + formatter: DiagnosticFormatter<'_>, + exit_code: i32, + ) -> String { + let report = formatter.build_session_report(exit_code); + formatter + .with_session_report(&report) + .format_footer(exit_code) + } + fn make_mixed_caps() -> CapabilitySet { let mut caps = CapabilitySet::new(); caps.add_fs(FsCapability { @@ -2621,6 +2917,7 @@ mod tests { }], missing_paths: Vec::new(), non_sandbox_failure: None, + network_blocked_hint: false, }); let output = formatter.format_footer(1); @@ -2646,6 +2943,7 @@ mod tests { }], missing_paths: Vec::new(), non_sandbox_failure: None, + network_blocked_hint: false, }); let output = formatter.format_footer(0); @@ -2666,6 +2964,7 @@ mod tests { path_hints: Vec::new(), missing_paths: vec![missing.clone()], non_sandbox_failure: None, + network_blocked_hint: false, }); let output = formatter.format_footer(1); let missing_idx = match output.find("Missing path:") { @@ -2702,6 +3001,7 @@ mod tests { "EEXIST: file already exists, mkdir '/Users/luke/.local/share/opencode'" .to_string(), ), + network_blocked_hint: false, }); let output = formatter.format_footer(1); @@ -2751,6 +3051,7 @@ mod tests { }], missing_paths: Vec::new(), non_sandbox_failure: None, + network_blocked_hint: false, }); let output = formatter.format_footer(1); @@ -2786,6 +3087,7 @@ mod tests { }], missing_paths: Vec::new(), non_sandbox_failure: None, + network_blocked_hint: false, }); let output = formatter.format_footer(1); @@ -2829,6 +3131,7 @@ mod tests { }], missing_paths: Vec::new(), non_sandbox_failure: None, + network_blocked_hint: false, }); let output = formatter.format_footer(1); @@ -2878,6 +3181,7 @@ mod tests { }], missing_paths: Vec::new(), non_sandbox_failure: None, + network_blocked_hint: false, }); let output = formatter.format_footer(1); @@ -2906,6 +3210,7 @@ mod tests { }], missing_paths: Vec::new(), non_sandbox_failure: None, + network_blocked_hint: false, }); let output = formatter.format_footer(0); @@ -2929,6 +3234,7 @@ mod tests { path_hints: Vec::new(), missing_paths: vec![missing.clone()], non_sandbox_failure: None, + network_blocked_hint: false, }); let output = formatter.format_footer(1); @@ -2959,6 +3265,7 @@ mod tests { "EEXIST: file already exists, mkdir '/Users/luke/.local/share/opencode'" .to_string(), ), + network_blocked_hint: false, }); let output = formatter.format_footer(1); @@ -3016,12 +3323,12 @@ mod tests { target: Some(requested.display().to_string()), }]; - let (denials, non_fs) = violations_to_denials(&violations); + let report = SessionDiagnosticReport::from_merged_session(1, vec![], vec![], violations); - assert!(non_fs.is_empty()); - assert_eq!(denials.len(), 1); - assert_eq!(denials[0].path, requested); - assert_eq!(denials[0].access, AccessMode::Read); + assert_eq!(report.diagnostics.len(), 1); + assert_eq!(report.denials.len(), 1); + assert_eq!(report.denials[0].path, requested); + assert_eq!(report.denials[0].access, AccessMode::Read); } #[test] @@ -3048,6 +3355,7 @@ mod tests { }], missing_paths: Vec::new(), non_sandbox_failure: None, + network_blocked_hint: false, }); let output = formatter.format_footer(1); @@ -3103,14 +3411,14 @@ mod tests { }], missing_paths: Vec::new(), non_sandbox_failure: None, + network_blocked_hint: false, }) .with_policy_explanations(vec![PolicyExplanation { path: denied.clone(), access: AccessMode::Read, reason: "path_not_granted".to_string(), - suggested_flag: Some(format!("--read {}", denied.display())), }]); - let output = formatter.format_footer(1); + let output = format_footer_with_session_report(formatter, 1); assert!(output.contains(&format!("{} (read+write)", denied.display()))); assert!(output.contains(&format!("Fix flags: --allow {}", pkg.display()))); @@ -3245,6 +3553,26 @@ mod tests { assert!(!output.contains("--allow ")); } + #[test] + fn test_ipc_denial_uses_remediation_for_flags() { + let caps = make_test_caps(); + let ipc_denials = vec![nono::IpcDenialRecord::new( + "/run/user/1000/bus".to_string(), + "connect".to_string(), + "no matching unix_socket capability".to_string(), + Some(nono::NonoRemediation::GrantUnixSocket { + path: PathBuf::from("/run/user/1000/bus"), + bind: false, + }), + )]; + let formatter = DiagnosticFormatter::new(&caps) + .with_mode(DiagnosticMode::Supervised) + .with_ipc_denials(&ipc_denials); + let output = format_footer_with_session_report(formatter, 1); + + assert!(output.contains("Fix flags: --allow-unix-socket /run/user/1000/bus")); + } + #[test] fn test_supervised_unix_socket_denial_uses_ipc_guidance() { let caps = make_test_caps(); @@ -3256,7 +3584,7 @@ mod tests { let formatter = DiagnosticFormatter::new(&caps) .with_mode(DiagnosticMode::Supervised) .with_denials(&denials); - let output = formatter.format_footer(1); + let output = format_footer_with_session_report(formatter, 1); assert!(output.contains("IPC denial: 1 pathname Unix socket blocked.")); assert!(output.contains("/run/user/1000/bus")); @@ -3279,7 +3607,7 @@ mod tests { let formatter = DiagnosticFormatter::new(&caps) .with_mode(DiagnosticMode::Supervised) .with_denials(&denials); - let output = formatter.format_footer(1); + let output = format_footer_with_session_report(formatter, 1); assert!(output.contains("Sandbox denial: 1 path blocked.")); assert!(output.contains(&denied_path.display().to_string())); @@ -3306,7 +3634,7 @@ mod tests { let formatter = DiagnosticFormatter::new(&caps) .with_mode(DiagnosticMode::Supervised) .with_denials(&denials); - let output = formatter.format_footer(1); + let output = format_footer_with_session_report(formatter, 1); assert!(output.contains("Sandbox denial: 2 paths blocked.")); // Policy-blocked path gets the marker. @@ -3371,7 +3699,7 @@ mod tests { let formatter = DiagnosticFormatter::new(&caps) .with_mode(DiagnosticMode::Supervised) .with_denials(&denials); - let output = formatter.format_footer(1); + let output = format_footer_with_session_report(formatter, 1); // Single Fix line covers both paths. let fix_lines: Vec<&str> = output @@ -3402,7 +3730,7 @@ mod tests { let formatter = DiagnosticFormatter::new(&caps) .with_mode(DiagnosticMode::Supervised) .with_denials(&denials); - let output = formatter.format_footer(1); + let output = format_footer_with_session_report(formatter, 1); assert!(output.contains("Sandbox denial: 15 paths blocked.")); // First 10 paths listed, remaining 5 collapsed. @@ -3446,7 +3774,7 @@ mod tests { let formatter = DiagnosticFormatter::new(&caps) .with_mode(DiagnosticMode::Supervised) .with_denials(&denials); - let output = formatter.format_footer(1); + let output = format_footer_with_session_report(formatter, 1); assert!(output.contains("Sandbox denial: 1 path blocked.")); assert!(output.contains("/tmp/flood (read)")); @@ -3484,7 +3812,7 @@ mod tests { let formatter = DiagnosticFormatter::new(&caps) .with_mode(DiagnosticMode::Supervised) .with_denials(&denials); - let output = formatter.format_footer(1); + let output = format_footer_with_session_report(formatter, 1); // The "Closest grant" hint moved out of the consolidated footer; // users can recover it with `nono why` if they want the detail. @@ -3768,7 +4096,6 @@ mod tests { path: denied.clone(), access: AccessMode::Read, reason: "sensitive_path".to_string(), - suggested_flag: None, }; let denials = vec![DenialRecord { path: denied, diff --git a/crates/nono-cli/src/diagnostic/mod.rs b/crates/nono-cli/src/diagnostic/mod.rs index 40b8ebf7e..cc02061d4 100644 --- a/crates/nono-cli/src/diagnostic/mod.rs +++ b/crates/nono-cli/src/diagnostic/mod.rs @@ -1,9 +1,7 @@ -//! CLI-owned diagnostic rendering and stderr heuristics. +//! CLI diagnostic footer and stderr parsing. //! -//! The core `nono::diagnostic` module owns the structured denial records; this -//! module owns all user-facing diagnostic UX: the `nono diagnostic` footer, -//! CLI flag suggestions, policy explanations, and best-effort parsing of a -//! command's own error output. +//! Structured denial records live in `nono::diagnostic`. This module renders +//! them and applies CLI-specific policy labels and flag formatting. mod formatter; diff --git a/crates/nono-cli/src/exec_strategy.rs b/crates/nono-cli/src/exec_strategy.rs index 61977760a..ac8f460ef 100644 --- a/crates/nono-cli/src/exec_strategy.rs +++ b/crates/nono-cli/src/exec_strategy.rs @@ -24,7 +24,8 @@ use nix::unistd::{ForkResult, Pid, fork}; use nono::supervisor::{ApprovalDecision, AuditEntry, SupervisorMessage, SupervisorResponse}; use nono::{ ApprovalBackend, CapabilitySet, DenialReason, DenialRecord, NonoError, Result, Sandbox, - SupervisorListener, SupervisorSocket, UnixSocketCapability, UnixSocketMode, + SessionDiagnosticReport, SupervisorListener, SupervisorSocket, UnixSocketCapability, + UnixSocketMode, }; use std::collections::HashSet; use std::ffi::{CString, OsStr}; @@ -209,6 +210,10 @@ pub struct ExecConfig<'a> { pub current_dir: &'a std::path::Path, /// Whether to suppress diagnostic output. pub no_diagnostics: bool, + /// Emit session diagnostics as JSON on stderr after the run. + pub diagnostics_json: bool, + /// Proxy startup diagnostics when a credential proxy is active. + pub proxy_diagnostics: Option<&'a [nono_proxy::ProxyDiagnostic]>, /// Threading context for fork safety validation. pub threading: ThreadingContext, /// Paths that are write-protected (signed instruction files). @@ -1334,7 +1339,7 @@ pub fn execute_supervised( // Print diagnostic footer on non-zero exit or when the PTY // output or OS sandbox logs show a likely sandbox-related issue. - if should_print_diagnostics { + if should_print_diagnostics || config.diagnostics_json { let diag_session_id = if supervisor.is_some() { pty_session_id .or_else(|| supervisor.map(|s| s.session_id)) @@ -1355,7 +1360,7 @@ pub fn execute_supervised( .iter() .map(|d| nono::try_canonicalize(&d.path)) .collect(); - let mut formatter = DiagnosticFormatter::new(config.caps) + let mut base_formatter = DiagnosticFormatter::new(config.caps) .with_mode(mode) .with_denials(&denials) .with_ipc_denials(&ipc_denials) @@ -1371,14 +1376,28 @@ pub fn execute_supervised( ) .with_canonical_denial_paths(canonical_denial_paths); if let Some(program) = config.command.first() { - formatter = formatter.with_command(crate::diagnostic::CommandContext { - program: program.clone(), - resolved_path: config.resolved_program.to_path_buf(), - args: nono::scrub_argv_with_policy(config.command, redaction_policy), - }); + base_formatter = + base_formatter.with_command(crate::diagnostic::CommandContext { + program: program.clone(), + resolved_path: config.resolved_program.to_path_buf(), + args: nono::scrub_argv_with_policy(config.command, redaction_policy), + }); + } + + let diagnostic_report = base_formatter.build_session_report(exit_code); + + if config.diagnostics_json + && let Err(e) = + emit_merged_diagnostics_json(&diagnostic_report, config.proxy_diagnostics) + { + warn!("Failed to emit diagnostics JSON: {e}"); + } + + if should_print_diagnostics { + let formatter = base_formatter.with_session_report(&diagnostic_report); + let footer = formatter.format_footer(exit_code); + crate::output::print_diagnostic_footer(&footer); } - let footer = formatter.format_footer(exit_code); - crate::output::print_diagnostic_footer(&footer); } if should_offer_profile_save( @@ -1473,16 +1492,11 @@ fn build_policy_explanations( let mut explanations = Vec::new(); for (path, access) in paths { match crate::query_ext::query_path(&path, access, caps, &[]) { - Ok(crate::query_ext::QueryResult::Denied { - reason, - suggested_flag, - .. - }) => { + Ok(crate::query_ext::QueryResult::Denied { reason, .. }) => { explanations.push(PolicyExplanation { path, access, reason, - suggested_flag, }); } Ok(crate::query_ext::QueryResult::Allowed { .. }) => { @@ -1525,6 +1539,28 @@ fn login_keychain_db_path() -> Option { .map(|home| PathBuf::from(home).join("Library/Keychains/login.keychain-db")) } +/// Write merged session and proxy diagnostics JSON to stderr. +fn emit_merged_diagnostics_json( + report: &SessionDiagnosticReport, + proxy_diagnostics: Option<&[nono_proxy::ProxyDiagnostic]>, +) -> Result<()> { + let proxy_json = proxy_diagnostics + .filter(|d| !d.is_empty()) + .map(|diagnostics| { + serde_json::to_string(diagnostics) + .map_err(|e| NonoError::ConfigParse(format!("proxy diagnostics JSON error: {e}"))) + }); + let proxy_json = match proxy_json { + Some(Ok(json)) => Some(json), + Some(Err(e)) => return Err(e), + None => None, + }; + let pretty = + SessionDiagnosticReport::merge_with_proxy_json(&report.to_json()?, proxy_json.as_deref())?; + print_terminal_safe_stderr(&pretty); + Ok(()) +} + fn should_print_diagnostic_footer( no_diagnostics: bool, exit_code: i32, @@ -3734,6 +3770,67 @@ mod tests { ); } + #[test] + fn test_session_diagnostic_report_matches_denial_sources() { + let denials = vec![DenialRecord { + path: PathBuf::from("/tmp/secret.txt"), + access: nono::AccessMode::Read, + reason: DenialReason::UserDenied, + }]; + let ipc_denials = vec![nono::IpcDenialRecord::new( + "/run/user/1000/bus".to_string(), + "connect".to_string(), + "no matching unix_socket capability".to_string(), + Some(nono::NonoRemediation::GrantUnixSocket { + path: PathBuf::from("/run/user/1000/bus"), + bind: false, + }), + )]; + let violations = vec![nono::SandboxViolation { + operation: "file-read-data".to_string(), + target: Some("/etc/hosts".to_string()), + }]; + + let report = SessionDiagnosticReport::from_session(1, denials, ipc_denials, violations); + + assert_eq!(report.exit_code, 1); + assert_eq!( + report.denials.len(), + 2, + "filesystem violations merge into denials" + ); + assert_eq!(report.ipc_denials.len(), 1); + assert_eq!(report.violations.len(), 1); + assert_eq!(report.diagnostics.len(), 3); + assert_eq!( + report + .diagnostics + .iter() + .filter(|d| d.code == nono::NonoDiagnosticCode::SandboxDeniedPath) + .count(), + 2, + "expected path diagnostics for logged denial and filesystem violation" + ); + assert!( + report + .diagnostics + .iter() + .any(|d| d.code == nono::NonoDiagnosticCode::SandboxDeniedUnixSocket) + ); + assert!( + report + .denials + .iter() + .any(|d| d.path == Path::new("/tmp/secret.txt")) + ); + assert!( + report + .denials + .iter() + .any(|d| d.path == Path::new("/etc/hosts")) + ); + } + #[test] fn test_diagnostic_footer_triggers_on_successful_sandbox_violation() { let violations = vec![nono::SandboxViolation { @@ -3767,7 +3864,6 @@ mod tests { path: PathBuf::from("/tmp/secret.txt"), access: nono::AccessMode::Read, reason: "path_not_granted".to_string(), - suggested_flag: Some("--read-file /tmp/secret.txt".to_string()), }]; let observation = crate::diagnostic::ErrorObservation::default(); diff --git a/crates/nono-cli/src/exec_strategy/supervisor_linux.rs b/crates/nono-cli/src/exec_strategy/supervisor_linux.rs index 5716c1cb6..b376084ca 100644 --- a/crates/nono-cli/src/exec_strategy/supervisor_linux.rs +++ b/crates/nono-cli/src/exec_strategy/supervisor_linux.rs @@ -11,7 +11,7 @@ use super::*; use crate::trust_intercept::TrustInterceptor; -use nono::{AccessMode, UnixSocketCapability, UnixSocketOp, try_canonicalize}; +use nono::{AccessMode, NonoRemediation, UnixSocketCapability, UnixSocketOp, try_canonicalize}; #[derive(Debug, Clone, PartialEq, Eq)] pub(super) struct InitialCapability { @@ -1011,14 +1011,14 @@ fn record_af_unix_ipc_denial( let operation = op .map(|op| op.to_string()) .unwrap_or_else(|| format!("syscall {syscall}")); - let (target, reason, suggested_flag, path_record) = ipc_denial_details(sockaddr, child_pid, op); + let (target, reason, remediation, path_record) = ipc_denial_details(sockaddr, child_pid, op); - ipc_denials.push(nono::diagnostic::IpcDenialRecord { + ipc_denials.push(nono::diagnostic::IpcDenialRecord::new( target, operation, reason, - suggested_flag, - }); + remediation, + )); let Some((display_path, op)) = path_record else { return; @@ -1044,7 +1044,7 @@ fn ipc_denial_details( sockaddr: &nono::sandbox::SockaddrInfo, child_pid: u32, op: Option, -) -> (String, String, Option, PathIpcDenial) { +) -> (String, String, Option, PathIpcDenial) { match sockaddr.unix_kind { Some(nono::sandbox::UnixSocketKind::Pathname) => { let Some(path) = sockaddr.unix_path.as_deref() else { @@ -1078,14 +1078,14 @@ fn ipc_denial_details( None, ); }; - let flag = match op { - UnixSocketOp::Connect | UnixSocketOp::Send => "--allow-unix-socket", - UnixSocketOp::Bind => "--allow-unix-socket-bind", - }; + let bind = matches!(op, UnixSocketOp::Bind); ( display_path.display().to_string(), "no matching unix_socket capability".to_string(), - Some(format!("{flag} {}", display_path.display())), + Some(NonoRemediation::GrantUnixSocket { + path: display_path.clone(), + bind, + }), Some((display_path, op)), ) } diff --git a/crates/nono-cli/src/execution_runtime.rs b/crates/nono-cli/src/execution_runtime.rs index fb5d1e3c2..2bb50c7cc 100644 --- a/crates/nono-cli/src/execution_runtime.rs +++ b/crates/nono-cli/src/execution_runtime.rs @@ -376,6 +376,15 @@ pub(crate) fn execute_sandboxed(plan: LaunchPlan) -> Result<()> { cap_file: &cap_file_path, current_dir: ¤t_dir, no_diagnostics: flags.no_diagnostics || flags.silent, + diagnostics_json: flags.diagnostics_json, + proxy_diagnostics: proxy_handle.as_ref().and_then(|handle| { + let diagnostics = handle.diagnostics(); + if diagnostics.is_empty() { + None + } else { + Some(diagnostics) + } + }), threading, protected_paths: &trust.protected_paths, profile_save_base: flags diff --git a/crates/nono-cli/src/launch_runtime.rs b/crates/nono-cli/src/launch_runtime.rs index 0839073ed..cc5854de6 100644 --- a/crates/nono-cli/src/launch_runtime.rs +++ b/crates/nono-cli/src/launch_runtime.rs @@ -101,6 +101,7 @@ pub(crate) struct ExecutionFlags { pub(crate) strategy: exec_strategy::ExecStrategy, pub(crate) workdir: PathBuf, pub(crate) no_diagnostics: bool, + pub(crate) diagnostics_json: bool, pub(crate) silent: bool, pub(crate) capability_elevation: bool, #[cfg(target_os = "linux")] @@ -130,6 +131,7 @@ impl ExecutionFlags { workdir: std::env::current_dir() .map_err(|e| NonoError::SandboxInit(format!("Failed to get cwd: {e}")))?, no_diagnostics: false, + diagnostics_json: false, silent, capability_elevation: false, #[cfg(target_os = "linux")] @@ -167,6 +169,7 @@ pub(crate) fn prepare_run_launch_plan( let redaction_policy = load_configured_redaction_policy()?; let args = run_args.sandbox; let no_diagnostics = run_args.no_diagnostics; + let diagnostics_json = run_args.diagnostics_json; let rollback = run_args.rollback; let no_rollback_prompt = run_args.no_rollback_prompt; let no_audit = run_args.no_audit; @@ -257,6 +260,7 @@ pub(crate) fn prepare_run_launch_plan( strategy, workdir: resolve_requested_workdir(args.workdir.as_ref()), no_diagnostics, + diagnostics_json, silent, capability_elevation: prepared.capability_elevation, #[cfg(target_os = "linux")] diff --git a/crates/nono-cli/src/output.rs b/crates/nono-cli/src/output.rs index 623767512..e73e1b0b3 100644 --- a/crates/nono-cli/src/output.rs +++ b/crates/nono-cli/src/output.rs @@ -520,6 +520,54 @@ pub fn print_warning(message: &str) { eprintln!(" {} {}", fg("warning:", t.red).bold(), fg(message, t.text),); } +/// Print proxy credential warnings collected at startup. +pub fn print_proxy_diagnostics(diagnostics: &[nono_proxy::ProxyDiagnostic]) { + if diagnostics.is_empty() { + return; + } + + let t = theme::current(); + eprintln!(); + eprintln!( + " {}", + theme::fg("Proxy credential warnings:", t.red).bold(), + ); + for diagnostic in diagnostics { + let code = diagnostic.code.as_str(); + eprintln!( + " {} /{} — {}", + theme::fg(code, t.subtext), + diagnostic.route_prefix, + fg(&diagnostic.message, t.text), + ); + if let Some(hint) = &diagnostic.hint { + eprintln!(" {}", theme::fg(hint, t.subtext)); + } else if let Some(action) = proxy_diagnostic_action(&diagnostic.code) { + eprintln!(" {}", theme::fg(action, t.subtext)); + } + } +} + +fn proxy_diagnostic_action(code: &nono_proxy::ProxyDiagnosticCode) -> Option<&'static str> { + use nono_proxy::ProxyDiagnosticCode; + match code { + ProxyDiagnosticCode::CredentialNotFound => Some( + "Configure a valid credential reference for this route, or use an explicit upstream credential.", + ), + ProxyDiagnosticCode::CredentialUnavailable => Some( + "Unlock the system keychain or authenticate with your credential provider (e.g. `op signin`).", + ), + ProxyDiagnosticCode::OAuthClientIdUnavailable + | ProxyDiagnosticCode::OAuthClientSecretUnavailable => { + Some("Provide OAuth client credentials via env/keystore configuration for this route.") + } + ProxyDiagnosticCode::OAuthTokenExchangeFailed => { + Some("Verify OAuth client credentials and provider availability, then retry.") + } + _ => None, + } +} + /// Format startup-blocked lines for writing to /dev/tty or stderr. /// Returns a Vec of lines ready to write (without trailing newline). pub fn format_startup_blocked( diff --git a/crates/nono-cli/src/profile_save_runtime.rs b/crates/nono-cli/src/profile_save_runtime.rs index af27d4cb6..26960b904 100644 --- a/crates/nono-cli/src/profile_save_runtime.rs +++ b/crates/nono-cli/src/profile_save_runtime.rs @@ -1675,7 +1675,6 @@ mod tests { path: target, access: AccessMode::Read, reason: "sensitive_path".to_string(), - suggested_flag: None, }; let patch = build_run_profile_patch( @@ -1708,13 +1707,11 @@ mod tests { path: target.clone(), access: AccessMode::Read, reason: "path_not_granted".to_string(), - suggested_flag: Some(format!("--read-file {}", target.display())), }; let write = PolicyExplanation { path: target, access: AccessMode::Write, reason: "insufficient_access".to_string(), - suggested_flag: None, }; let patch = build_run_profile_patch( @@ -1748,13 +1745,11 @@ mod tests { path: ignored.clone(), access: AccessMode::Read, reason: "path_not_granted".to_string(), - suggested_flag: None, }; let saved_explanation = PolicyExplanation { path: saved, access: AccessMode::Read, reason: "path_not_granted".to_string(), - suggested_flag: None, }; let patch = build_run_profile_patch( @@ -1784,7 +1779,6 @@ mod tests { path: target.clone(), access: AccessMode::Read, reason: "path_not_granted".to_string(), - suggested_flag: None, }; let patch = build_run_profile_patch( diff --git a/crates/nono-cli/src/proxy_runtime.rs b/crates/nono-cli/src/proxy_runtime.rs index ef8781232..5cc622c4c 100644 --- a/crates/nono-cli/src/proxy_runtime.rs +++ b/crates/nono-cli/src/proxy_runtime.rs @@ -430,6 +430,11 @@ pub(crate) fn start_proxy_runtime( ); } } + + let proxy_diagnostics = handle.diagnostics(); + if !proxy_diagnostics.is_empty() { + crate::output::print_proxy_diagnostics(proxy_diagnostics); + } caps.set_network_mode_mut(nono::NetworkMode::ProxyOnly { port, bind_ports: proxy.allow_bind_ports.clone(), @@ -727,6 +732,7 @@ mod tests { rollback_exclude_globs: Vec::new(), network_profile: None, allow_domain: Vec::new(), + reject_domain: Vec::new(), credentials: Vec::new(), custom_credentials, upstream_proxy: None, @@ -748,6 +754,8 @@ mod tests { denied_env_vars: None, set_vars: None, network_block_requested: false, + profile_network_approval_mode: None, + profile_network_approval_timeout_secs: None, }; let args = crate::cli::SandboxArgs::default(); diff --git a/crates/nono-cli/src/query_ext.rs b/crates/nono-cli/src/query_ext.rs index 8375e30af..3139e9ad9 100644 --- a/crates/nono-cli/src/query_ext.rs +++ b/crates/nono-cli/src/query_ext.rs @@ -560,6 +560,61 @@ pub fn print_result(result: &QueryResult) { } } +/// Format a flag for a known grant target (file vs directory). +pub(crate) fn suggested_flag_for_existing_target( + target: &Path, + is_file: bool, + requested: AccessMode, +) -> String { + let flag = if is_file { + match requested { + AccessMode::Read => "--read-file", + AccessMode::Write => "--write-file", + AccessMode::ReadWrite => "--allow-file", + } + } else { + match requested { + AccessMode::Read => "--read", + AccessMode::Write => "--write", + AccessMode::ReadWrite => "--allow", + } + }; + + format!("{flag} {}", target.display()) +} + +/// Map a remediation to a CLI flag string when one applies. +pub(crate) fn suggested_flag_for_remediation(rem: &nono::NonoRemediation) -> Option { + match rem { + nono::NonoRemediation::GrantPath { + path, + access, + is_file, + } => { + if *is_file && !path.exists() { + Some(suggested_flag_for_path(path, *access)) + } else { + Some(suggested_flag_for_existing_target(path, *is_file, *access)) + } + } + nono::NonoRemediation::GrantUnixSocket { path, bind } => { + let flag = if *bind { + "--allow-unix-socket-bind" + } else { + "--allow-unix-socket" + }; + Some(format!("{flag} {}", path.display())) + } + nono::NonoRemediation::AllowCwd => Some("--allow-cwd".to_string()), + nono::NonoRemediation::DisableRollback => Some("--no-rollback".to_string()), + nono::NonoRemediation::GrantNetwork => Some("--allow-net".to_string()), + nono::NonoRemediation::RunDiscovery + | nono::NonoRemediation::CheckPolicy + | nono::NonoRemediation::AuthenticateCredentialProvider { .. } + | nono::NonoRemediation::AdjustRollbackBudget { .. } => None, + } +} + pub(crate) fn suggested_flag_for_path(path: &Path, requested: AccessMode) -> String { let (flag, target) = suggested_flag_parts(path, requested); format!("{flag} {}", target.display()) diff --git a/crates/nono-proxy/src/credential.rs b/crates/nono-proxy/src/credential.rs index dff05324c..2a5a8aefa 100644 --- a/crates/nono-proxy/src/credential.rs +++ b/crates/nono-proxy/src/credential.rs @@ -10,6 +10,7 @@ //! credentials. This module handles only credential-specific concerns. use crate::config::{InjectMode, RouteConfig}; +use crate::diagnostic::{ProxyDiagnostic, ProxyDiagnosticCode}; use crate::error::{ProxyError, Result}; use crate::oauth2::{OAuth2ExchangeConfig, TokenCache}; use base64::Engine; @@ -83,6 +84,22 @@ pub struct OAuth2Route { pub upstream: String, } +/// Result of loading credentials at proxy startup. +#[derive(Debug)] +pub struct CredentialLoadOutcome { + /// Loaded store; may omit routes whose credentials were unavailable. + pub store: CredentialStore, + /// Per-route warnings for missing or unavailable credentials. + pub diagnostics: Vec, +} + +impl CredentialLoadOutcome { + #[must_use] + pub fn into_store(self) -> CredentialStore { + self.store + } +} + /// Credential store for all configured routes. #[derive(Debug)] pub struct CredentialStore { @@ -112,12 +129,16 @@ impl CredentialStore { /// The `tls_connector` is required for OAuth2 token exchange HTTPS calls. /// /// Returns an error only for hard failures (config parse errors, - /// non-UTF-8 values). Missing or inaccessible credentials are logged - /// as warnings and the route is skipped. - pub fn load(routes: &[RouteConfig], tls_connector: &TlsConnector) -> Result { + /// non-UTF-8 values). Missing credentials are logged, recorded in + /// `diagnostics`, and the route is skipped. + pub fn load_with_diagnostics( + routes: &[RouteConfig], + tls_connector: &TlsConnector, + ) -> Result { let mut credentials = HashMap::new(); let mut oauth2_routes = HashMap::new(); let mut aws_routes = HashMap::new(); + let mut diagnostics = Vec::new(); for route in routes { // Normalize prefix: strip leading/trailing slashes so it matches @@ -134,19 +155,33 @@ impl CredentialStore { Ok(s) => s, Err(nono::NonoError::SecretNotFound(_)) => { let hint = build_credential_miss_hint(key); - warn!( - "Credential '{}' not found for route '{}' — managed-credential requests on this route will be denied until the credential is available.{}", - key, normalized_prefix, hint + let redacted = redact_credential_ref(key); + let message = format!( + "Credential not found for route '{normalized_prefix}' — \ + managed-credential requests on this route will be denied until \ + the credential is available.{hint}" + ); + warn!("{message}"); + diagnostics.push( + ProxyDiagnostic::warning( + ProxyDiagnosticCode::CredentialNotFound, + &normalized_prefix, + message, + ) + .with_credential_ref(redacted) + .with_hint(strip_tip_prefix(&hint)), ); continue; } Err(nono::NonoError::KeystoreAccess(msg)) => { - let redacted = redact_credential_ref(key); - warn!( - "Credential '{}' not available for route '{}': {}. \ - Managed-credential requests on this route will be denied until the credential is available. \ - Set NONO_KEYRING_TIMEOUT_SECS=N (default 120) to wait longer for keychain unlock; 0 disables the timeout.", - redacted, normalized_prefix, msg + push_secret_unavailable_diagnostic( + &mut diagnostics, + ProxyDiagnosticCode::CredentialUnavailable, + &normalized_prefix, + key, + &msg, + "Credential", + true, ); continue; } @@ -212,42 +247,26 @@ impl CredentialStore { route.prefix ); - let client_id = match nono::keystore::load_secret_by_ref( - KEYRING_SERVICE, + let Some(client_id) = load_oauth_keystore_ref( + &mut diagnostics, + &route.prefix, &oauth2.client_id, - ) { - Ok(s) => s, - Err(nono::NonoError::SecretNotFound(msg)) - | Err(nono::NonoError::KeystoreAccess(msg)) => { - let redacted = redact_credential_ref(&oauth2.client_id); - warn!( - "OAuth2 client_id '{}' not available for route '{}': {}. \ - Managed-credential requests on this route will be denied. \ - Set NONO_KEYRING_TIMEOUT_SECS=N (default 120) to wait longer for keychain unlock; 0 disables the timeout.", - redacted, route.prefix, msg - ); - continue; - } - Err(e) => return Err(ProxyError::Credential(e.to_string())), + "OAuth2 client_id", + ProxyDiagnosticCode::OAuthClientIdUnavailable, + )? + else { + continue; }; - let client_secret = match nono::keystore::load_secret_by_ref( - KEYRING_SERVICE, + let Some(client_secret) = load_oauth_keystore_ref( + &mut diagnostics, + &route.prefix, &oauth2.client_secret, - ) { - Ok(s) => s, - Err(nono::NonoError::SecretNotFound(msg)) - | Err(nono::NonoError::KeystoreAccess(msg)) => { - let redacted = redact_credential_ref(&oauth2.client_secret); - warn!( - "OAuth2 client_secret '{}' not available for route '{}': {}. \ - Managed-credential requests on this route will be denied. \ - Set NONO_KEYRING_TIMEOUT_SECS=N (default 120) to wait longer for keychain unlock; 0 disables the timeout.", - redacted, route.prefix, msg - ); - continue; - } - Err(e) => return Err(ProxyError::Credential(e.to_string())), + "OAuth2 client_secret", + ProxyDiagnosticCode::OAuthClientSecretUnavailable, + )? + else { + continue; }; let config = OAuth2ExchangeConfig { @@ -268,11 +287,17 @@ impl CredentialStore { ); } Err(e) => { - warn!( - "OAuth2 token exchange failed for route '{}': {}. \ + let message = format!( + "OAuth2 token exchange failed for route '{}': {e}. \ Managed-credential requests on this route will be denied.", - route.prefix, e + route.prefix ); + warn!("{message}"); + diagnostics.push(ProxyDiagnostic::warning( + ProxyDiagnosticCode::OAuthTokenExchangeFailed, + &route.prefix, + message, + )); continue; } } @@ -286,13 +311,25 @@ impl CredentialStore { } } - Ok(Self { - credentials, - oauth2_routes, - aws_routes, + Ok(CredentialLoadOutcome { + store: Self { + credentials, + oauth2_routes, + aws_routes, + }, + diagnostics, }) } + /// Deprecated wrapper around [`Self::load_with_diagnostics`]. + #[deprecated( + since = "0.64.0", + note = "Use `load_with_diagnostics` instead. Will be removed in 1.0.0." + )] + pub fn load(routes: &[RouteConfig], tls_connector: &TlsConnector) -> Result { + Self::load_with_diagnostics(routes, tls_connector).map(|outcome| outcome.store) + } + /// Create an empty credential store (no credential injection). #[must_use] pub fn empty() -> Self { @@ -353,10 +390,8 @@ impl CredentialStore { /// Uses the same constant as `nono::keystore::DEFAULT_SERVICE` to ensure consistency. const KEYRING_SERVICE: &str = nono::keystore::DEFAULT_SERVICE; -/// Redact a credential reference for safe display in warnings. -/// -/// Delegates to the appropriate URI-specific redaction helper so that -/// secrets (account names, file paths, field names) are never echoed raw. +const KEYRING_TIMEOUT_HINT: &str = " Set NONO_KEYRING_TIMEOUT_SECS=N (default 120) to wait longer for keychain unlock; 0 disables the timeout."; + fn redact_credential_ref(key: &str) -> String { if nono::keystore::is_op_uri(key) { nono::keystore::redact_op_uri(key) @@ -373,6 +408,91 @@ fn redact_credential_ref(key: &str) -> String { } } +/// Redact a credential ref and any verbatim repeat of it in a keystore error. +fn keystore_error_detail(key: &str, msg: &str) -> (String, String) { + let redacted = redact_credential_ref(key); + let mut detail = msg.replace(key, &redacted); + // file:// errors quote the absolute path, not the full URI. + if nono::keystore::is_file_uri(key) + && let Some(path) = key.strip_prefix("file://") + && let Some(redacted_path) = redacted.strip_prefix("file://") + { + detail = detail.replace(path, redacted_path); + } + (redacted, detail) +} + +fn push_secret_unavailable_diagnostic( + diagnostics: &mut Vec, + code: ProxyDiagnosticCode, + route_prefix: &str, + key: &str, + msg: &str, + subject: &str, + keyring_hint: bool, +) { + let (redacted, detail) = keystore_error_detail(key, msg); + let timeout = if keyring_hint { + KEYRING_TIMEOUT_HINT + } else { + "" + }; + let denied = " Managed-credential requests on this route will be denied."; + let message = + format!("{subject} not available for route '{route_prefix}': {detail}.{denied}{timeout}"); + warn!( + "{subject} '{redacted}' not available for route '{route_prefix}': {detail}.{denied}{timeout}" + ); + diagnostics + .push(ProxyDiagnostic::warning(code, route_prefix, message).with_credential_ref(redacted)); +} + +fn load_oauth_keystore_ref( + diagnostics: &mut Vec, + route_prefix: &str, + key: &str, + subject: &str, + code: ProxyDiagnosticCode, +) -> Result>> { + match nono::keystore::load_secret_by_ref(KEYRING_SERVICE, key) { + Ok(s) => Ok(Some(s)), + Err(nono::NonoError::SecretNotFound(msg)) => { + push_secret_unavailable_diagnostic( + diagnostics, + code, + route_prefix, + key, + &msg, + subject, + false, + ); + Ok(None) + } + Err(nono::NonoError::KeystoreAccess(msg)) => { + push_secret_unavailable_diagnostic( + diagnostics, + code, + route_prefix, + key, + &msg, + subject, + true, + ); + Ok(None) + } + Err(e) => Err(ProxyError::Credential(e.to_string())), + } +} + +/// Remove the leading "Tip:" prefix from credential miss hints. +fn strip_tip_prefix(hint: &str) -> String { + hint.trim() + .strip_prefix("Tip:") + .map(str::trim) + .unwrap_or(hint.trim()) + .to_string() +} + /// Build a hint for the credential-not-found warning that probes other /// credential sources for the same name. /// @@ -579,6 +699,150 @@ mod tests { assert!(debug_output.contains("Authorization")); } + fn oauth2_route_with_refs( + prefix: &str, + client_id: &str, + client_secret: &str, + token_url: &str, + ) -> RouteConfig { + use crate::config::OAuth2Config; + + RouteConfig { + prefix: prefix.to_string(), + upstream: "https://api.example.com".to_string(), + credential_key: None, + inject_mode: InjectMode::Header, + inject_header: "Authorization".to_string(), + credential_format: Some("Bearer {}".to_string()), + path_pattern: None, + path_replacement: None, + query_param_name: None, + proxy: None, + env_var: Some("MY_API_KEY".to_string()), + endpoint_rules: vec![], + tls_ca: None, + tls_client_cert: None, + tls_client_key: None, + oauth2: Some(OAuth2Config { + token_url: token_url.to_string(), + client_id: client_id.to_string(), + client_secret: client_secret.to_string(), + scope: String::new(), + }), + aws_auth: None, + } + } + + #[test] + fn test_load_missing_env_credential_records_credential_not_found() { + let tls = test_tls_connector(); + let routes = vec![RouteConfig { + prefix: "preview-missing".to_string(), + upstream: "https://api.example.com".to_string(), + credential_key: Some("env://NONO_PROXY_TEST_MISSING_CRED".to_string()), + inject_mode: InjectMode::Header, + inject_header: "Authorization".to_string(), + credential_format: Some("Bearer {}".to_string()), + path_pattern: None, + path_replacement: None, + query_param_name: None, + proxy: None, + env_var: None, + endpoint_rules: vec![], + tls_ca: None, + tls_client_cert: None, + tls_client_key: None, + oauth2: None, + aws_auth: None, + }]; + let outcome = CredentialStore::load_with_diagnostics(&routes, &tls).expect("load"); + assert!(outcome.store.is_empty()); + assert_eq!(outcome.diagnostics.len(), 1); + assert_eq!( + outcome.diagnostics[0].code, + ProxyDiagnosticCode::CredentialNotFound + ); + assert_eq!( + outcome.diagnostics[0].credential_ref.as_deref(), + Some("env://NONO_PROXY_TEST_MISSING_CRED") + ); + } + + #[test] + fn test_redact_credential_ref_op_uri() { + assert_eq!( + redact_credential_ref("op://vault/item/secret"), + "op://vault/item/" + ); + } + + #[test] + fn test_keystore_error_detail_redacts_credential_ref_in_message() { + let cases = [ + ( + "op://Vault/Item/secret", + "1Password lookup failed for 'op://Vault/Item/secret': timed out", + "op://Vault/Item/", + "/secret", + ), + ( + "file:///run/secrets/api-token", + "failed to read credential file '/run/secrets/api-token'", + "/run/secrets/[REDACTED]", + "api-token", + ), + ]; + for (key, msg, want, leak) in cases { + let (_redacted, detail) = keystore_error_detail(key, msg); + assert!( + detail.contains(want), + "expected redacted fragment '{want}' in '{detail}'" + ); + assert!( + !detail.contains(leak), + "raw credential fragment '{leak}' leaked in '{detail}'" + ); + } + } + + #[test] + fn test_load_oauth2_missing_client_id_records_diagnostic() { + let tls = test_tls_connector(); + let routes = vec![oauth2_route_with_refs( + "my-api", + "env://NONO_PROXY_TEST_MISSING_CLIENT_ID", + "env://NONO_PROXY_TEST_CLIENT_SECRET", + "https://127.0.0.1:1/oauth/token", + )]; + let outcome = CredentialStore::load_with_diagnostics(&routes, &tls).expect("load"); + assert!(outcome.store.is_empty()); + assert_eq!(outcome.diagnostics.len(), 1); + assert_eq!( + outcome.diagnostics[0].code, + ProxyDiagnosticCode::OAuthClientIdUnavailable + ); + } + + #[test] + fn test_load_oauth2_missing_client_secret_records_diagnostic() { + let _lock = ENV_LOCK.lock().expect("env mutex poisoned"); + let _env = EnvVarGuard::set_all(&[("NONO_PROXY_TEST_CLIENT_ID", "test-client")]); + let tls = test_tls_connector(); + let routes = vec![oauth2_route_with_refs( + "my-api", + "env://NONO_PROXY_TEST_CLIENT_ID", + "env://NONO_PROXY_TEST_MISSING_CLIENT_SECRET", + "https://127.0.0.1:1/oauth/token", + )]; + let outcome = CredentialStore::load_with_diagnostics(&routes, &tls).expect("load"); + assert!(outcome.store.is_empty()); + assert_eq!(outcome.diagnostics.len(), 1); + assert_eq!( + outcome.diagnostics[0].code, + ProxyDiagnosticCode::OAuthClientSecretUnavailable + ); + } + #[test] fn test_load_no_credential_routes() { let tls = test_tls_connector(); @@ -601,9 +865,14 @@ mod tests { oauth2: None, aws_auth: None, }]; - let store = CredentialStore::load(&routes, &tls); - assert!(store.is_ok()); - let store = store.unwrap_or_else(|_| CredentialStore::empty()); + let outcome = CredentialStore::load_with_diagnostics(&routes, &tls); + assert!(outcome.is_ok()); + let store = outcome + .unwrap_or_else(|_| CredentialLoadOutcome { + store: CredentialStore::empty(), + diagnostics: Vec::new(), + }) + .store; assert!(store.is_empty()); } @@ -694,7 +963,9 @@ mod tests { oauth2: None, aws_auth: None, }]; - let store = CredentialStore::load(&routes, &tls).expect("credential load"); + let store = CredentialStore::load_with_diagnostics(&routes, &tls) + .expect("credential load") + .store; let cred = store.get("litellm").expect("route should be loaded"); assert_eq!(cred.header_name, "x-litellm-api-key"); assert_eq!(cred.header_value.as_str(), "Bearer sk-litellm-test"); @@ -724,7 +995,9 @@ mod tests { oauth2: None, aws_auth: None, }]; - let store = CredentialStore::load(&routes, &tls).expect("credential load"); + let store = CredentialStore::load_with_diagnostics(&routes, &tls) + .expect("credential load") + .store; let cred = store.get("api").expect("route should be loaded"); assert_eq!(cred.header_value.as_str(), "secret-key"); } @@ -766,14 +1039,15 @@ mod tests { aws_auth: None, }]; - let store = CredentialStore::load(&routes, &tls); + let outcome = CredentialStore::load_with_diagnostics(&routes, &tls); // load() should succeed (route skipped, not hard error) assert!( - store.is_ok(), + outcome.is_ok(), "load should not fail on unreachable OAuth2 endpoint" ); - let store = store.unwrap(); + let outcome = outcome.unwrap(); + let store = outcome.store; // The route should have been skipped (token exchange failed) assert!( @@ -781,6 +1055,11 @@ mod tests { "unreachable OAuth2 endpoint should result in skipped route" ); assert!(store.get_oauth2("my-api").is_none()); + assert_eq!(outcome.diagnostics.len(), 1); + assert_eq!( + outcome.diagnostics[0].code, + ProxyDiagnosticCode::OAuthTokenExchangeFailed + ); } /// Build a test `TokenCache` with a pre-populated token. diff --git a/crates/nono-proxy/src/diagnostic.rs b/crates/nono-proxy/src/diagnostic.rs new file mode 100644 index 000000000..5886a7dbd --- /dev/null +++ b/crates/nono-proxy/src/diagnostic.rs @@ -0,0 +1,99 @@ +//! Proxy startup diagnostics (credential load and OAuth exchange failures). + +use serde::{Deserialize, Serialize}; + +/// Severity of a proxy diagnostic. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ProxyDiagnosticSeverity { + Info, + Warning, + Error, +} + +/// Stable diagnostic code for proxy credential and route issues. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +#[non_exhaustive] +pub enum ProxyDiagnosticCode { + CredentialNotFound, + CredentialUnavailable, + OAuthClientIdUnavailable, + OAuthClientSecretUnavailable, + OAuthTokenExchangeFailed, +} + +impl ProxyDiagnosticCode { + /// Stable snake_case label matching JSON serialization. + #[must_use] + pub fn as_str(self) -> &'static str { + match self { + Self::CredentialNotFound => "credential_not_found", + Self::CredentialUnavailable => "credential_unavailable", + Self::OAuthClientIdUnavailable => "oauth_client_id_unavailable", + Self::OAuthClientSecretUnavailable => "oauth_client_secret_unavailable", + Self::OAuthTokenExchangeFailed => "oauth_token_exchange_failed", + } + } +} + +/// One proxy startup diagnostic. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ProxyDiagnostic { + pub code: ProxyDiagnosticCode, + pub severity: ProxyDiagnosticSeverity, + pub route_prefix: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub credential_ref: Option, + pub message: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub hint: Option, +} + +impl ProxyDiagnostic { + #[must_use] + pub fn warning( + code: ProxyDiagnosticCode, + route_prefix: impl Into, + message: impl Into, + ) -> Self { + Self { + code, + severity: ProxyDiagnosticSeverity::Warning, + route_prefix: route_prefix.into(), + credential_ref: None, + message: message.into(), + hint: None, + } + } + + #[must_use] + pub fn with_credential_ref(mut self, credential_ref: impl Into) -> Self { + self.credential_ref = Some(credential_ref.into()); + self + } + + #[must_use] + pub fn with_hint(mut self, hint: impl Into) -> Self { + self.hint = Some(hint.into()); + self + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn proxy_diagnostic_serializes_stable_code() { + let diagnostic = ProxyDiagnostic::warning( + ProxyDiagnosticCode::CredentialNotFound, + "openai", + "Credential not found", + ) + .with_credential_ref("op://vault/item/secret"); + let json = serde_json::to_string(&diagnostic).expect("json"); + assert!(json.contains("\"credential_not_found\"")); + assert!(json.contains("\"openai\"")); + } +} diff --git a/crates/nono-proxy/src/lib.rs b/crates/nono-proxy/src/lib.rs index bf0d8192b..cabe09bd9 100644 --- a/crates/nono-proxy/src/lib.rs +++ b/crates/nono-proxy/src/lib.rs @@ -21,6 +21,7 @@ pub mod audit; pub mod config; pub mod connect; pub mod credential; +pub mod diagnostic; pub mod error; pub mod external; pub mod filter; @@ -34,5 +35,7 @@ pub mod token; pub use config::ProxyConfig; pub use connect::ApprovalChannelRequest; +pub use credential::{CredentialLoadOutcome, CredentialStore}; +pub use diagnostic::{ProxyDiagnostic, ProxyDiagnosticCode, ProxyDiagnosticSeverity}; pub use error::{ProxyError, Result}; pub use server::{start, start_with_approval, ProxyHandle}; diff --git a/crates/nono-proxy/src/server.rs b/crates/nono-proxy/src/server.rs index 5ac5e5813..92e94a7bf 100644 --- a/crates/nono-proxy/src/server.rs +++ b/crates/nono-proxy/src/server.rs @@ -54,6 +54,11 @@ fn parse_non_connect_target(line: &str) -> Result<(String, u16)> { Ok((host, port)) } +#[must_use] +fn proxy_diagnostic_code_label(code: crate::diagnostic::ProxyDiagnosticCode) -> &'static str { + code.as_str() +} + /// Handle returned when the proxy server starts. /// /// Contains the assigned port, session token, and a shutdown channel. @@ -80,6 +85,8 @@ pub struct ProxyHandle { /// Seatbelt read capability on it. `None` when interception is not /// configured (no `intercept_ca_dir`) or no route requires L7 visibility. intercept_ca_path: Option, + /// Credential load warnings collected at startup. + diagnostics: Vec, } impl ProxyHandle { @@ -109,6 +116,22 @@ impl ProxyHandle { self.intercept_ca_path.as_deref() } + /// Startup diagnostics from credential loading. + #[must_use] + pub fn diagnostics(&self) -> &[crate::diagnostic::ProxyDiagnostic] { + &self.diagnostics + } + + /// Serialize startup diagnostics to JSON. + /// + /// # Errors + /// + /// Returns an error if JSON serialization fails. + pub fn diagnostics_json(&self) -> crate::Result { + serde_json::to_string(&self.diagnostics) + .map_err(|e| ProxyError::Config(format!("proxy diagnostics JSON error: {e}"))) + } + /// One-line-per-route diagnostic summary suitable for surfacing at /// session start. Returns `(prefix, summary)` pairs. /// @@ -125,23 +148,7 @@ impl ProxyHandle { let mut rows = Vec::with_capacity(config.routes.len()); for route in &config.routes { let prefix = route.prefix.trim_matches('/').to_string(); - let cred_summary = if let Some(ref key) = route.credential_key { - let resolved = self.loaded_routes.contains(&prefix); - if resolved { - format!("creds: {} ✓", key) - } else { - format!("creds: {} ✗ (not found)", key) - } - } else if route.oauth2.is_some() { - let resolved = self.loaded_routes.contains(&prefix); - if resolved { - "creds: oauth2 ✓".to_string() - } else { - "creds: oauth2 ✗ (token exchange failed)".to_string() - } - } else { - "creds: none".to_string() - }; + let cred_summary = self.credential_status_summary(&prefix, route); let intercept_summary = if self.intercept_ca_path.is_some() && (route.credential_key.is_some() @@ -163,6 +170,40 @@ impl ProxyHandle { rows } + fn credential_status_summary( + &self, + prefix: &str, + route: &crate::config::RouteConfig, + ) -> String { + if let Some(diagnostic) = self + .diagnostics + .iter() + .find(|entry| entry.route_prefix == prefix) + { + let code = proxy_diagnostic_code_label(diagnostic.code); + let cred_ref = diagnostic.credential_ref.as_deref().unwrap_or("credential"); + return format!("creds: {cred_ref} ✗ ({code})"); + } + + if let Some(ref key) = route.credential_key { + let resolved = self.loaded_routes.contains(prefix); + if resolved { + format!("creds: {} ✓", key) + } else { + format!("creds: {} ✗ (not found)", key) + } + } else if route.oauth2.is_some() { + let resolved = self.loaded_routes.contains(prefix); + if resolved { + "creds: oauth2 ✓".to_string() + } else { + "creds: oauth2 ✗ (token exchange failed)".to_string() + } + } else { + "creds: none".to_string() + } + } + /// Environment variables to inject into the child process. /// /// The proxy URL includes `nono:@` userinfo so that standard HTTP @@ -410,7 +451,7 @@ pub async fn start_with_approval( // Build shared TLS connector (root cert store is expensive to construct). // Use the ring provider explicitly to avoid ambiguity when multiple // crypto providers are in the dependency tree. - // Must be created before CredentialStore::load() because OAuth2 token + // Must be created before CredentialStore::load_with_diagnostics() because OAuth2 token // exchange needs TLS. let mut root_store = rustls::RootCertStore::empty(); root_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned()); @@ -440,10 +481,11 @@ pub async fn start_with_approval( let tls_connector = tokio_rustls::TlsConnector::from(Arc::new(tls_config)); // Load credentials for reverse proxy routes (static keystore + OAuth2) - let credential_store = if config.routes.is_empty() { - CredentialStore::empty() + let (credential_store, proxy_diagnostics) = if config.routes.is_empty() { + (CredentialStore::empty(), Vec::new()) } else { - CredentialStore::load(&config.routes, &tls_connector)? + let outcome = CredentialStore::load_with_diagnostics(&config.routes, &tls_connector)?; + (outcome.store, outcome.diagnostics) }; let loaded_routes = credential_store.loaded_prefixes(); @@ -614,6 +656,7 @@ pub async fn start_with_approval( loaded_routes, no_proxy_hosts, intercept_ca_path, + diagnostics: proxy_diagnostics, }) } @@ -1367,8 +1410,8 @@ mod tests { assert!(openai.1.contains("api.openai.com")); assert!(openai.1.contains("intercept: on")); assert!( - openai.1.contains("✗") || openai.1.contains("not found"), - "missing credential should show ✗, got: {}", + openai.1.contains("✗") || openai.1.contains("credential_not_found"), + "missing credential should show structured code, got: {}", openai.1 ); @@ -1455,6 +1498,7 @@ mod tests { loaded_routes: ["openai".to_string()].into_iter().collect(), no_proxy_hosts: Vec::new(), intercept_ca_path: None, + diagnostics: vec![], }; let config = ProxyConfig { routes: vec![crate::config::RouteConfig { @@ -1511,6 +1555,7 @@ mod tests { loaded_routes: ["openai".to_string()].into_iter().collect(), no_proxy_hosts: Vec::new(), intercept_ca_path: None, + diagnostics: vec![], }; let config = ProxyConfig { routes: vec![crate::config::RouteConfig { @@ -1572,6 +1617,7 @@ mod tests { loaded_routes: ["openai".to_string()].into_iter().collect(), no_proxy_hosts: Vec::new(), intercept_ca_path: None, + diagnostics: vec![], }; let config = ProxyConfig { routes: vec![ @@ -1654,6 +1700,7 @@ mod tests { loaded_routes: std::collections::HashSet::new(), no_proxy_hosts: Vec::new(), intercept_ca_path: None, + diagnostics: vec![], }; // Test leading slash @@ -1744,6 +1791,7 @@ mod tests { loaded_routes: ["anthropic".to_string()].into_iter().collect(), no_proxy_hosts: Vec::new(), intercept_ca_path: None, + diagnostics: vec![], }; let config_no_env_var = ProxyConfig { routes: vec![crate::config::RouteConfig { @@ -1786,6 +1834,7 @@ mod tests { loaded_routes: ["anthropic".to_string()].into_iter().collect(), no_proxy_hosts: Vec::new(), intercept_ca_path: None, + diagnostics: vec![], }; let config_fixed = ProxyConfig { routes: vec![crate::config::RouteConfig { @@ -1832,6 +1881,7 @@ mod tests { "opencode.internal:4096".to_string(), ], intercept_ca_path: None, + diagnostics: vec![], }; let vars = handle.env_vars(); @@ -1861,6 +1911,7 @@ mod tests { loaded_routes: std::collections::HashSet::new(), no_proxy_hosts: Vec::new(), intercept_ca_path: None, + diagnostics: vec![], }; let vars = handle.env_vars(); diff --git a/crates/nono/src/diagnostic/codes.rs b/crates/nono/src/diagnostic/codes.rs new file mode 100644 index 000000000..d3dc97090 --- /dev/null +++ b/crates/nono/src/diagnostic/codes.rs @@ -0,0 +1,188 @@ +//! Stable diagnostic codes and remediation types. + +use crate::capability::AccessMode; +use crate::diagnostic::NonoDiagnosticDetail; +use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; + +/// Severity of a structured diagnostic. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum NonoDiagnosticSeverity { + Info, + Warning, + Error, +} + +/// Stable diagnostic code suitable for Rust, C FFI, and language bindings. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +#[non_exhaustive] +pub enum NonoDiagnosticCode { + SandboxDeniedPath, + SandboxDeniedNetwork, + SandboxDeniedUnixSocket, + CommandNotFound, + CommandFailedLikelySandbox, + CommandFailedApplication, + CredentialNotFound, + CredentialUnavailable, + UnsupportedPlatformFeature, + RollbackBudgetExceeded, + CwdAccessRequired, + ConfigurationError, + TrustVerificationFailed, + IoError, + Cancelled, + Other, +} + +/// Remediation action; clients render this as flags or other UI text. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum NonoRemediation { + GrantPath { + path: PathBuf, + access: AccessMode, + is_file: bool, + }, + GrantUnixSocket { + path: PathBuf, + bind: bool, + }, + GrantNetwork, + RunDiscovery, + CheckPolicy, + AuthenticateCredentialProvider { + provider: String, + }, + AdjustRollbackBudget { + current_bytes: Option, + limit_bytes: Option, + }, + AllowCwd, + DisableRollback, +} + +/// Map structured remediation to the legacy CLI flag string shape. +/// +/// Kept for backwards compatibility with code that read +/// [`crate::IpcDenialRecord::suggested_flag`]. New code should use +/// [`NonoRemediation`] and render flags in the CLI/bindings layer. +#[deprecated( + since = "0.64.0", + note = "Use `NonoRemediation` instead. Legacy flag strings will be removed in 1.0.0." +)] +#[must_use] +pub fn suggested_flag_for_remediation(rem: &NonoRemediation) -> Option { + match rem { + NonoRemediation::GrantPath { + path, + access, + is_file, + } => { + let (flag, target) = suggested_flag_parts(path, *access, *is_file); + Some(format!("{flag} {}", target.display())) + } + NonoRemediation::GrantUnixSocket { path, bind } => { + let flag = if *bind { + "--allow-unix-socket-bind" + } else { + "--allow-unix-socket" + }; + Some(format!("{flag} {}", path.display())) + } + NonoRemediation::AllowCwd => Some("--allow-cwd".to_string()), + NonoRemediation::DisableRollback => Some("--no-rollback".to_string()), + NonoRemediation::GrantNetwork => Some("--allow-net".to_string()), + NonoRemediation::RunDiscovery + | NonoRemediation::CheckPolicy + | NonoRemediation::AuthenticateCredentialProvider { .. } + | NonoRemediation::AdjustRollbackBudget { .. } => None, + } +} + +fn suggested_flag_parts( + path: &Path, + requested: AccessMode, + is_file: bool, +) -> (&'static str, PathBuf) { + if is_file { + let flag = match requested { + AccessMode::Read => "--read-file", + AccessMode::Write => "--write-file", + AccessMode::ReadWrite => "--allow-file", + }; + return (flag, path.to_path_buf()); + } + + let flag = match requested { + AccessMode::Read => "--read", + AccessMode::Write => "--write", + AccessMode::ReadWrite => "--allow", + }; + (flag, path.to_path_buf()) +} + +/// One structured diagnostic entry. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct NonoDiagnostic { + pub code: NonoDiagnosticCode, + pub severity: NonoDiagnosticSeverity, + pub message: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub hint: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub remediation: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub path: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub access: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub detail: Option, +} + +impl NonoDiagnostic { + #[must_use] + pub fn new( + code: NonoDiagnosticCode, + severity: NonoDiagnosticSeverity, + message: impl Into, + ) -> Self { + Self { + code, + severity, + message: message.into(), + hint: None, + remediation: None, + path: None, + access: None, + detail: None, + } + } + + #[must_use] + pub fn with_hint(mut self, hint: impl Into) -> Self { + self.hint = Some(hint.into()); + self + } + + #[must_use] + pub fn with_remediation(mut self, remediation: NonoRemediation) -> Self { + self.remediation = Some(remediation); + self + } + + #[must_use] + pub fn with_path_access(mut self, path: PathBuf, access: AccessMode) -> Self { + self.path = Some(path); + self.access = Some(access); + self + } + + #[must_use] + pub fn with_detail(mut self, detail: NonoDiagnosticDetail) -> Self { + self.detail = Some(detail); + self + } +} diff --git a/crates/nono/src/diagnostic/detail.rs b/crates/nono/src/diagnostic/detail.rs new file mode 100644 index 000000000..b9f61a48f --- /dev/null +++ b/crates/nono/src/diagnostic/detail.rs @@ -0,0 +1,38 @@ +//! Structured context attached to session diagnostics. + +use super::records::DenialReason; +use serde::{Deserialize, Serialize}; + +/// Origin and structured context for a session diagnostic entry. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum NonoDiagnosticDetail { + /// Path denial recorded by the supervised session. + SupervisedDenial { reason: DenialReason }, + /// Unix socket denial recorded by IPC mediation. + IpcDenial { + operation: String, + target: String, + ipc_reason: String, + }, + /// macOS Seatbelt violation that is not expressible as a path grant. + SeatbeltViolation { + operation: String, + target: Option, + }, + /// From stderr parsing in `nono-cli`. + StderrObservation { + observation_kind: StderrObservationKind, + }, +} + +/// Kind of stderr-derived observation encoded as a structured diagnostic. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum StderrObservationKind { + LikelySandboxPath, + MissingPath, + ApplicationFailure, + ProtectedFileWrite, + NetworkBlocked, +} diff --git a/crates/nono/src/diagnostic/mod.rs b/crates/nono/src/diagnostic/mod.rs new file mode 100644 index 000000000..00c6afba6 --- /dev/null +++ b/crates/nono/src/diagnostic/mod.rs @@ -0,0 +1,22 @@ +//! Structured sandbox diagnostics for library and binding clients. +//! +//! Denial records, stable codes, remediations, and session reports. Footer +//! text and CLI flag formatting live in `nono-cli`. + +mod codes; +mod detail; +mod observation; +mod records; +mod report; + +pub use codes::{NonoDiagnostic, NonoDiagnosticCode, NonoDiagnosticSeverity, NonoRemediation}; +pub use detail::{NonoDiagnosticDetail, StderrObservationKind}; +pub use observation::{ + SessionObservationInput, diagnostic_application_failure, diagnostic_likely_sandbox_path, + diagnostic_missing_path, diagnostic_network_blocked, diagnostic_protected_file_write, + follow_up_diagnostics, +}; +pub use records::{ + DenialReason, DenialRecord, IpcDenialRecord, SandboxViolation, seatbelt_operation_to_access, +}; +pub use report::{SessionDiagnosticReport, dedupe_denials, filesystem_denials_from_violations}; diff --git a/crates/nono/src/diagnostic/observation.rs b/crates/nono/src/diagnostic/observation.rs new file mode 100644 index 000000000..fcffe1474 --- /dev/null +++ b/crates/nono/src/diagnostic/observation.rs @@ -0,0 +1,199 @@ +//! Stderr observation inputs converted to [`NonoDiagnostic`] records. +//! +//! Parsing heuristics live in `nono-cli`; this module defines the record shape. + +use crate::capability::AccessMode; +use crate::diagnostic::{ + NonoDiagnostic, NonoDiagnosticCode, NonoDiagnosticDetail, NonoDiagnosticSeverity, + NonoRemediation, StderrObservationKind, +}; +use std::path::PathBuf; + +/// Input for stderr-derived session diagnostics. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct SessionObservationInput { + /// Paths inferred from stderr that likely hit sandbox restrictions. + pub likely_sandbox_paths: Vec<(PathBuf, AccessMode)>, + /// Paths reported missing by the command output. + pub missing_paths: Vec, + /// Application-level failure text unrelated to sandbox permissions. + pub application_failure: Option, + /// Protected instruction file referenced in stderr. + pub blocked_protected_file: Option, + /// Stderr suggests a network operation was blocked. + pub network_blocked_hint: bool, +} + +impl SessionObservationInput { + /// Build structured diagnostics from observation inputs. + #[must_use] + pub fn into_diagnostics(self) -> Vec { + let mut diagnostics = Vec::new(); + + if let Some(file) = self.blocked_protected_file { + diagnostics.push(diagnostic_protected_file_write(file)); + } + + for path in self.missing_paths { + diagnostics.push(diagnostic_missing_path(path)); + } + + if let Some(message) = self.application_failure { + diagnostics.push(diagnostic_application_failure(message)); + } + + if self.network_blocked_hint { + diagnostics.push(diagnostic_network_blocked()); + } + + for (path, access) in self.likely_sandbox_paths { + let remediation = grant_path_remediation(path.clone(), access); + diagnostics.push(diagnostic_likely_sandbox_path(path, access, remediation)); + } + + diagnostics + } +} + +/// Discovery and policy-check hints for footers with no logged path denials. +#[must_use] +pub fn follow_up_diagnostics() -> Vec { + vec![ + NonoDiagnostic::new( + NonoDiagnosticCode::CommandFailedLikelySandbox, + NonoDiagnosticSeverity::Info, + "discover additional paths required by the command", + ) + .with_remediation(NonoRemediation::RunDiscovery), + NonoDiagnostic::new( + NonoDiagnosticCode::CommandFailedApplication, + NonoDiagnosticSeverity::Info, + "inspect sandbox policy for a specific path", + ) + .with_remediation(NonoRemediation::CheckPolicy), + ] +} + +#[must_use] +pub fn diagnostic_likely_sandbox_path( + path: PathBuf, + access: AccessMode, + remediation: NonoRemediation, +) -> NonoDiagnostic { + NonoDiagnostic::new( + NonoDiagnosticCode::CommandFailedLikelySandbox, + NonoDiagnosticSeverity::Warning, + format!( + "command output suggests {} ({access}) may be sandbox-related", + path.display() + ), + ) + .with_path_access(path, access) + .with_remediation(remediation) + .with_detail(NonoDiagnosticDetail::StderrObservation { + observation_kind: StderrObservationKind::LikelySandboxPath, + }) +} + +#[must_use] +pub fn diagnostic_missing_path(path: PathBuf) -> NonoDiagnostic { + NonoDiagnostic::new( + NonoDiagnosticCode::CommandFailedApplication, + NonoDiagnosticSeverity::Warning, + format!("command reported missing path {}", path.display()), + ) + .with_path_access(path.clone(), AccessMode::Read) + .with_detail(NonoDiagnosticDetail::StderrObservation { + observation_kind: StderrObservationKind::MissingPath, + }) +} + +#[must_use] +pub fn diagnostic_application_failure(message: String) -> NonoDiagnostic { + NonoDiagnostic::new( + NonoDiagnosticCode::CommandFailedApplication, + NonoDiagnosticSeverity::Warning, + format!("command reported application error: {message}"), + ) + .with_detail(NonoDiagnosticDetail::StderrObservation { + observation_kind: StderrObservationKind::ApplicationFailure, + }) +} + +#[must_use] +pub fn diagnostic_protected_file_write(file: String) -> NonoDiagnostic { + NonoDiagnostic::new( + NonoDiagnosticCode::TrustVerificationFailed, + NonoDiagnosticSeverity::Warning, + format!("Write to '{file}' blocked: file is a signed instruction file."), + ) + .with_detail(NonoDiagnosticDetail::StderrObservation { + observation_kind: StderrObservationKind::ProtectedFileWrite, + }) +} + +#[must_use] +pub fn diagnostic_network_blocked() -> NonoDiagnostic { + NonoDiagnostic::new( + NonoDiagnosticCode::SandboxDeniedNetwork, + NonoDiagnosticSeverity::Warning, + "command output contains a network error; if a required host is unreachable, check whether network access is blocked", + ) + .with_detail(NonoDiagnosticDetail::StderrObservation { + observation_kind: StderrObservationKind::NetworkBlocked, + }) +} + +#[must_use] +fn grant_path_remediation(path: PathBuf, access: AccessMode) -> NonoRemediation { + NonoRemediation::GrantPath { + is_file: grant_path_is_file(&path), + path, + access, + } +} + +#[must_use] +fn grant_path_is_file(path: &std::path::Path) -> bool { + if path.is_file() { + return true; + } + if path.is_dir() { + return false; + } + path.file_name().is_some() +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + #[test] + fn observation_input_builds_likely_sandbox_diagnostic() { + let input = SessionObservationInput { + likely_sandbox_paths: vec![(PathBuf::from("/tmp/x"), AccessMode::Read)], + ..SessionObservationInput::default() + }; + let diagnostics = input.into_diagnostics(); + assert_eq!(diagnostics.len(), 1); + assert_eq!( + diagnostics[0].code, + NonoDiagnosticCode::CommandFailedLikelySandbox + ); + } + + #[test] + fn network_hint_emits_sandbox_denied_network() { + let input = SessionObservationInput { + network_blocked_hint: true, + ..SessionObservationInput::default() + }; + let diagnostics = input.into_diagnostics(); + assert!( + diagnostics + .iter() + .any(|d| d.code == NonoDiagnosticCode::SandboxDeniedNetwork) + ); + } +} diff --git a/crates/nono/src/diagnostic.rs b/crates/nono/src/diagnostic/records.rs similarity index 61% rename from crates/nono/src/diagnostic.rs rename to crates/nono/src/diagnostic/records.rs index 0476c482e..5c6c4fed1 100644 --- a/crates/nono/src/diagnostic.rs +++ b/crates/nono/src/diagnostic/records.rs @@ -1,21 +1,12 @@ -//! Structured sandbox diagnostic records. -//! -//! This module holds the structured, policy-free facts that the sandbox and -//! its supervisors produce when access is denied: denial records, IPC denial -//! records, OS-native sandbox violations, and the platform mapping from a -//! Seatbelt operation name to an [`AccessMode`]. -//! -//! All user-facing diagnostic rendering (footer text, CLI flag suggestions, -//! `nono why`/`nono learn` guidance, policy explanations, and stderr -//! heuristics) lives in the `nono-cli` crate. The core library stays a pure -//! sandbox primitive and only exposes these records as a stable API for -//! clients that want to render or inspect denials themselves. +//! Structured sandbox diagnostic records produced at runtime. use crate::capability::AccessMode; +use crate::diagnostic::NonoRemediation; +use serde::{Deserialize, Serialize}; use std::path::PathBuf; /// Why a path access was denied during a supervised session. -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub enum DenialReason { /// Path is blocked by sandbox policy before approval is consulted PolicyBlocked, @@ -32,7 +23,7 @@ pub enum DenialReason { } /// Record of a denied access attempt during a supervised session. -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct DenialRecord { /// The path that was denied pub path: PathBuf, @@ -43,7 +34,7 @@ pub struct DenialRecord { } /// Record of a denied IPC attempt during a supervised session. -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct IpcDenialRecord { /// IPC resource that was denied, e.g. `/run/user/1000/bus` or `unix:`. pub target: String, @@ -51,16 +42,49 @@ pub struct IpcDenialRecord { pub operation: String, /// Why it was denied. pub reason: String, - /// Suggested CLI flag when this denial can be fixed by an explicit grant. + /// Structured remediation when this denial can be fixed by an explicit grant. + pub remediation: Option, + /// Legacy CLI flag suggestion retained for backwards compatibility. + #[deprecated( + since = "0.64.0", + note = "Use `remediation` instead. Will be removed in 1.0.0." + )] + #[serde(default, skip_serializing_if = "Option::is_none")] pub suggested_flag: Option, } +impl IpcDenialRecord { + /// Create an IPC denial record with structured remediation and legacy flag sync. + #[must_use] + pub fn new( + target: String, + operation: String, + reason: String, + remediation: Option, + ) -> Self { + let suggested_flag = remediation.as_ref().and_then(|rem| { + #[allow(deprecated)] + { + crate::diagnostic::codes::suggested_flag_for_remediation(rem) + } + }); + #[allow(deprecated)] + Self { + target, + operation, + reason, + remediation, + suggested_flag, + } + } +} + /// Best-effort sandbox violation recovered from OS-native logging. /// /// On macOS, Seatbelt does not stream deny events back to the supervisor like /// Linux seccomp-notify does, so diagnostics can supplement denials with /// unified-log records recovered from sandboxd. -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct SandboxViolation { /// Denied operation, such as `file-read-data` or `mach-lookup`. pub operation: String, @@ -87,6 +111,8 @@ pub fn seatbelt_operation_to_access(operation: &str) -> Option { #[cfg(test)] mod tests { use super::*; + use crate::capability::AccessMode; + use std::path::PathBuf; #[test] fn seatbelt_read_operations_map_to_read() { @@ -118,4 +144,25 @@ mod tests { assert_eq!(seatbelt_operation_to_access("signal"), None); assert_eq!(seatbelt_operation_to_access("network-outbound"), None); } + + #[test] + fn ipc_denial_record_keeps_legacy_suggested_flag_in_sync() { + let remediation = NonoRemediation::GrantUnixSocket { + path: PathBuf::from("/run/user/0/bus"), + bind: false, + }; + let record = IpcDenialRecord::new( + "/run/user/0/bus".to_string(), + "connect".to_string(), + "no matching unix_socket capability".to_string(), + Some(remediation), + ); + #[allow(deprecated)] + { + assert_eq!( + record.suggested_flag.as_deref(), + Some("--allow-unix-socket /run/user/0/bus") + ); + } + } } diff --git a/crates/nono/src/diagnostic/report.rs b/crates/nono/src/diagnostic/report.rs new file mode 100644 index 000000000..e677e0f79 --- /dev/null +++ b/crates/nono/src/diagnostic/report.rs @@ -0,0 +1,446 @@ +//! Session diagnostic reports for library and binding clients. + +use crate::capability::AccessMode; +use crate::diagnostic::{ + DenialReason, DenialRecord, IpcDenialRecord, NonoDiagnostic, NonoDiagnosticCode, + NonoDiagnosticDetail, NonoDiagnosticSeverity, NonoRemediation, SandboxViolation, + seatbelt_operation_to_access, +}; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +/// Structured report of sandbox-related diagnostics for a supervised session. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct SessionDiagnosticReport { + pub exit_code: i32, + pub denials: Vec, + pub ipc_denials: Vec, + pub violations: Vec, + pub diagnostics: Vec, +} + +impl SessionDiagnosticReport { + /// Build a report from runtime denial records and optional OS violations. + #[must_use] + pub fn from_session( + exit_code: i32, + denials: Vec, + ipc_denials: Vec, + violations: Vec, + ) -> Self { + Self::from_merged_session(exit_code, denials, ipc_denials, violations) + } + + /// Merge filesystem violations into denials, then build diagnostics. + /// + /// Filesystem Seatbelt violations become path denials. Other violations + /// become separate diagnostic entries. + #[must_use] + pub fn from_merged_session( + exit_code: i32, + denials: Vec, + ipc_denials: Vec, + violations: Vec, + ) -> Self { + let (violation_denials, non_fs_violations) = violations_to_denials(&violations); + let mut merged_denials = denials; + merged_denials.extend(violation_denials); + let deduped = dedupe_denials(&merged_denials); + + let mut diagnostics = Vec::new(); + for denial in &deduped { + if denial.reason == DenialReason::UnixSocketDenied && !ipc_denials.is_empty() { + continue; + } + diagnostics.push(diagnostic_from_denial(denial)); + } + for ipc in &ipc_denials { + diagnostics.push(diagnostic_from_ipc_denial(ipc)); + } + for violation in &non_fs_violations { + diagnostics.push(diagnostic_from_non_fs_violation(violation)); + } + + Self { + exit_code, + denials: deduped, + ipc_denials, + violations, + diagnostics, + } + } + + /// Serialize this report to JSON. + /// + /// # Errors + /// + /// Returns an error if JSON serialization fails. + pub fn to_json(&self) -> crate::Result { + serde_json::to_string(self).map_err(|e| { + crate::NonoError::ConfigParse(format!("session diagnostic JSON error: {e}")) + }) + } + + /// Wrap session report JSON with an optional proxy diagnostics array. + /// + /// `proxy_diagnostics_json` must be a JSON array when present (the shape emitted by + /// `nono_proxy::ProxyHandle::diagnostics_json()`). + /// + /// # Errors + /// + /// Returns an error if either JSON blob is malformed. + pub fn merge_with_proxy_json( + session_json: &str, + proxy_diagnostics_json: Option<&str>, + ) -> crate::Result { + let session_value: serde_json::Value = serde_json::from_str(session_json).map_err(|e| { + crate::NonoError::ConfigParse(format!("parse session diagnostic JSON: {e}")) + })?; + let output = if let Some(proxy_text) = proxy_diagnostics_json.filter(|s| !s.is_empty()) { + let proxy_value: serde_json::Value = serde_json::from_str(proxy_text).map_err(|e| { + crate::NonoError::ConfigParse(format!("parse proxy diagnostics JSON: {e}")) + })?; + serde_json::json!({ + "session": session_value, + "proxy": proxy_value, + }) + } else { + serde_json::json!({ "session": session_value }) + }; + serde_json::to_string_pretty(&output).map_err(|e| { + crate::NonoError::ConfigParse(format!("format merged diagnostic JSON: {e}")) + }) + } +} + +/// Deduplicate denials by path, merging access modes. +#[must_use] +pub fn dedupe_denials(denials: &[DenialRecord]) -> Vec { + let mut by_path = BTreeMap::::new(); + + for denial in denials { + by_path + .entry(denial.path.clone()) + .and_modify(|(access, reason)| { + *access = merge_access_modes(*access, denial.access); + *reason = stricter_reason(reason.clone(), denial.reason.clone()); + }) + .or_insert_with(|| (denial.access, denial.reason.clone())); + } + + by_path + .into_iter() + .map(|(path, (access, reason))| DenialRecord { + path, + access, + reason, + }) + .collect() +} + +#[must_use] +fn violations_to_denials( + violations: &[SandboxViolation], +) -> (Vec, Vec) { + let mut denials = Vec::new(); + let mut non_fs = Vec::new(); + let mut seen = BTreeMap::::new(); + + for violation in violations { + if let (Some(access), Some(target)) = ( + seatbelt_operation_to_access(&violation.operation), + &violation.target, + ) { + let path = PathBuf::from(target); + seen.entry(path) + .and_modify(|existing| *existing = merge_access_modes(*existing, access)) + .or_insert(access); + } else { + non_fs.push(violation.clone()); + } + } + + for (path, access) in seen { + denials.push(DenialRecord { + path, + access, + reason: DenialReason::PolicyBlocked, + }); + } + + (denials, non_fs) +} + +/// Filesystem violations converted into path denials for session merging. +#[must_use] +pub fn filesystem_denials_from_violations(violations: &[SandboxViolation]) -> Vec { + violations_to_denials(violations).0 +} + +#[must_use] +fn merge_access_modes(existing: AccessMode, new: AccessMode) -> AccessMode { + if existing == new { + existing + } else { + AccessMode::ReadWrite + } +} + +#[must_use] +fn stricter_reason(a: DenialReason, b: DenialReason) -> DenialReason { + fn rank(r: &DenialReason) -> u8 { + match r { + DenialReason::PolicyBlocked => 5, + DenialReason::UnixSocketDenied => 5, + DenialReason::InsufficientAccess => 4, + DenialReason::UserDenied => 3, + DenialReason::RateLimited => 2, + DenialReason::BackendError => 1, + } + } + if rank(&a) >= rank(&b) { a } else { b } +} + +#[must_use] +fn diagnostic_from_denial(denial: &DenialRecord) -> NonoDiagnostic { + let code = match denial.reason { + DenialReason::UnixSocketDenied => NonoDiagnosticCode::SandboxDeniedUnixSocket, + DenialReason::PolicyBlocked => NonoDiagnosticCode::SandboxDeniedPath, + _ => NonoDiagnosticCode::SandboxDeniedPath, + }; + let message = format!( + "access to {} ({}) denied: {:?}", + denial.path.display(), + denial.access, + denial.reason + ); + let mut diagnostic = NonoDiagnostic::new(code, NonoDiagnosticSeverity::Warning, message) + .with_path_access(denial.path.clone(), denial.access) + .with_detail(NonoDiagnosticDetail::SupervisedDenial { + reason: denial.reason.clone(), + }); + match denial.reason { + DenialReason::UnixSocketDenied => { + diagnostic = diagnostic.with_remediation(NonoRemediation::GrantUnixSocket { + path: denial.path.clone(), + bind: denial.access.contains(AccessMode::Write), + }); + } + DenialReason::InsufficientAccess + | DenialReason::PolicyBlocked + | DenialReason::UserDenied + | DenialReason::RateLimited => { + diagnostic = diagnostic + .with_remediation(grant_path_remediation(denial.path.clone(), denial.access)); + } + DenialReason::BackendError => {} + } + diagnostic +} + +#[must_use] +fn grant_path_remediation(path: PathBuf, access: AccessMode) -> NonoRemediation { + NonoRemediation::GrantPath { + is_file: grant_path_is_file(&path), + path, + access, + } +} + +#[must_use] +fn grant_path_is_file(path: &Path) -> bool { + if path.is_file() { + return true; + } + if path.is_dir() { + return false; + } + path.file_name().is_some() +} + +#[must_use] +fn diagnostic_from_ipc_denial(ipc: &IpcDenialRecord) -> NonoDiagnostic { + let mut diagnostic = NonoDiagnostic::new( + NonoDiagnosticCode::SandboxDeniedUnixSocket, + NonoDiagnosticSeverity::Warning, + format!("{} {} denied: {}", ipc.operation, ipc.target, ipc.reason), + ) + .with_detail(NonoDiagnosticDetail::IpcDenial { + operation: ipc.operation.clone(), + target: ipc.target.clone(), + ipc_reason: ipc.reason.clone(), + }); + if let Some(remediation) = ipc.remediation.clone() { + if let NonoRemediation::GrantUnixSocket { ref path, .. } = remediation { + diagnostic = diagnostic.with_path_access(path.clone(), AccessMode::Read); + } + diagnostic = diagnostic.with_remediation(remediation); + } + diagnostic +} + +#[must_use] +fn diagnostic_from_non_fs_violation(violation: &SandboxViolation) -> NonoDiagnostic { + let target = violation.target.as_deref().unwrap_or(""); + NonoDiagnostic::new( + NonoDiagnosticCode::UnsupportedPlatformFeature, + NonoDiagnosticSeverity::Warning, + format!( + "sandbox blocked system service: {} ({target})", + violation.operation + ), + ) + .with_detail(NonoDiagnosticDetail::SeatbeltViolation { + operation: violation.operation.clone(), + target: violation.target.clone(), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + #[test] + fn session_report_builds_diagnostics_from_denials() { + let denials = vec![DenialRecord { + path: PathBuf::from("/tmp/secret"), + access: AccessMode::Read, + reason: DenialReason::PolicyBlocked, + }]; + let report = SessionDiagnosticReport::from_session(1, denials, vec![], vec![]); + assert_eq!(report.diagnostics.len(), 1); + assert_eq!( + report.diagnostics[0].code, + NonoDiagnosticCode::SandboxDeniedPath + ); + assert!(matches!( + report.diagnostics[0].remediation, + Some(NonoRemediation::GrantPath { .. }) + )); + assert!(matches!( + report.diagnostics[0].detail, + Some(NonoDiagnosticDetail::SupervisedDenial { .. }) + )); + } + + #[test] + fn insufficient_access_includes_grant_path_remediation() { + let denials = vec![DenialRecord { + path: PathBuf::from("/project/src"), + access: AccessMode::Write, + reason: DenialReason::InsufficientAccess, + }]; + let report = SessionDiagnosticReport::from_session(1, denials, vec![], vec![]); + assert!(matches!( + report.diagnostics[0].remediation, + Some(NonoRemediation::GrantPath { .. }) + )); + } + + #[test] + fn unix_socket_path_denial_includes_grant_remediation() { + let denials = vec![DenialRecord { + path: PathBuf::from("/run/user/1000/bus"), + access: AccessMode::Read, + reason: DenialReason::UnixSocketDenied, + }]; + let report = SessionDiagnosticReport::from_session(1, denials, vec![], vec![]); + assert_eq!(report.diagnostics.len(), 1); + assert!(matches!( + report.diagnostics[0].remediation, + Some(NonoRemediation::GrantUnixSocket { bind: false, .. }) + )); + } + + #[test] + fn unix_socket_path_denial_skipped_when_ipc_denials_present() { + let denials = vec![DenialRecord { + path: PathBuf::from("/run/user/1000/bus"), + access: AccessMode::Read, + reason: DenialReason::UnixSocketDenied, + }]; + let ipc_denials = vec![IpcDenialRecord::new( + "/run/user/1000/bus".to_string(), + "connect".to_string(), + "no matching unix_socket capability".to_string(), + Some(NonoRemediation::GrantUnixSocket { + path: PathBuf::from("/run/user/1000/bus"), + bind: false, + }), + )]; + let report = SessionDiagnosticReport::from_session(1, denials, ipc_denials, vec![]); + assert_eq!(report.diagnostics.len(), 1); + assert!(matches!( + report.diagnostics[0].detail, + Some(NonoDiagnosticDetail::IpcDenial { .. }) + )); + } + + #[test] + fn non_fs_violation_becomes_system_service_diagnostic() { + let violations = vec![SandboxViolation { + operation: "mach-lookup".to_string(), + target: Some("com.apple.secd".to_string()), + }]; + let report = SessionDiagnosticReport::from_session(1, vec![], vec![], violations); + assert_eq!(report.diagnostics.len(), 1); + assert_eq!( + report.diagnostics[0].code, + NonoDiagnosticCode::UnsupportedPlatformFeature + ); + } + + #[test] + fn violation_includes_grant_path_remediation() { + let violations = vec![SandboxViolation { + operation: "file-read-data".to_string(), + target: Some("/Users/me/Desktop/secret.txt".to_string()), + }]; + let report = SessionDiagnosticReport::from_session(1, vec![], vec![], violations); + assert!(matches!( + report.diagnostics[0].remediation, + Some(NonoRemediation::GrantPath { is_file: true, .. }) + )); + } + + #[test] + fn session_report_json_roundtrip() { + let report = SessionDiagnosticReport::from_session(2, vec![], vec![], vec![]); + let json = report.to_json().expect("json"); + assert!(json.contains("\"exit_code\":2")); + } + + #[test] + fn merge_with_proxy_json_wraps_session_and_proxy_arrays() { + let session = SessionDiagnosticReport::from_session(1, vec![], vec![], vec![]); + let session_json = session.to_json().expect("session json"); + let proxy_json = r#"[{"code":"credential_not_found","severity":"warning","route_prefix":"openai","message":"missing"}]"#; + let merged = + SessionDiagnosticReport::merge_with_proxy_json(&session_json, Some(proxy_json)) + .expect("merged"); + assert!(merged.contains("\"session\"")); + assert!(merged.contains("\"proxy\"")); + assert!(merged.contains("credential_not_found")); + } + + #[test] + fn dedupe_denials_merges_access_modes() { + let denials = vec![ + DenialRecord { + path: PathBuf::from("/tmp/a"), + access: AccessMode::Read, + reason: DenialReason::UserDenied, + }, + DenialRecord { + path: PathBuf::from("/tmp/a"), + access: AccessMode::Write, + reason: DenialReason::InsufficientAccess, + }, + ]; + let deduped = dedupe_denials(&denials); + assert_eq!(deduped.len(), 1); + assert_eq!(deduped[0].access, AccessMode::ReadWrite); + } +} diff --git a/crates/nono/src/error.rs b/crates/nono/src/error.rs index eed78f98b..5531be049 100644 --- a/crates/nono/src/error.rs +++ b/crates/nono/src/error.rs @@ -198,3 +198,141 @@ pub enum NonoError { /// Result type alias for nono operations pub type Result = std::result::Result; + +impl NonoError { + /// Map this error to a [`NonoDiagnosticCode`]. + #[must_use] + pub fn diagnostic_code(&self) -> crate::diagnostic::NonoDiagnosticCode { + use crate::diagnostic::NonoDiagnosticCode; + match self { + Self::CwdPromptRequired => NonoDiagnosticCode::CwdAccessRequired, + Self::SecretNotFound(_) => NonoDiagnosticCode::CredentialNotFound, + Self::KeystoreAccess(_) => NonoDiagnosticCode::CredentialUnavailable, + Self::UnsupportedPlatform(_) | Self::NetworkFilterUnsupported { .. } => { + NonoDiagnosticCode::UnsupportedPlatformFeature + } + Self::SandboxInit(_) | Self::BlockedCommand { .. } => { + NonoDiagnosticCode::SandboxDeniedPath + } + Self::TrustVerification { .. } + | Self::TrustSigning { .. } + | Self::TrustPolicy(_) + | Self::BlocklistBlocked { .. } + | Self::InstructionFileDenied { .. } + | Self::PackageVerification { .. } => NonoDiagnosticCode::TrustVerificationFailed, + Self::Snapshot(msg) | Self::ObjectStore(msg) if msg.contains("budget exceeded") => { + NonoDiagnosticCode::RollbackBudgetExceeded + } + Self::Cancelled(_) => NonoDiagnosticCode::Cancelled, + Self::Io(_) | Self::CommandExecution(_) => NonoDiagnosticCode::IoError, + Self::ConfigParse(_) + | Self::ConfigWrite { .. } + | Self::ConfigRead { .. } + | Self::InvalidConfig { .. } + | Self::ProfileNotFound(_) + | Self::ProfileRead { .. } + | Self::ProfileParse(_) + | Self::ProfileInheritance(_) + | Self::HomeNotFound + | Self::Setup(_) + | Self::LearnError(_) + | Self::HookInstall(_) + | Self::EnvVarValidation { .. } + | Self::CapFileValidation { .. } + | Self::CapFileTooLarge { .. } + | Self::VersionDowngrade { .. } + | Self::PackageInstall(_) + | Self::ActionRequired(_) + | Self::RegistryError(_) + | Self::AttachBusy + | Self::SessionGone + | Self::NoCapabilities + | Self::NoCommand => NonoDiagnosticCode::ConfigurationError, + Self::PathNotFound(_) + | Self::ExpectedDirectory(_) + | Self::ExpectedFile(_) + | Self::PathCanonicalization { .. } + | Self::HashMismatch { .. } + | Self::SessionNotFound(_) + | Self::ObjectStore(_) + | Self::Snapshot(_) => NonoDiagnosticCode::Other, + #[cfg(target_os = "linux")] + Self::Landlock(_) | Self::LandlockPath(_) => NonoDiagnosticCode::SandboxDeniedPath, + } + } + + /// Remediation action when the library can suggest one without CLI context. + #[must_use] + pub fn remediation(&self) -> Option { + use crate::diagnostic::NonoRemediation; + match self { + Self::CwdPromptRequired => Some(NonoRemediation::AllowCwd), + Self::SecretNotFound(_) | Self::KeystoreAccess(_) => { + Some(NonoRemediation::AuthenticateCredentialProvider { + provider: "keystore".to_string(), + }) + } + Self::Snapshot(msg) | Self::ObjectStore(msg) if msg.contains("budget exceeded") => { + Some(NonoRemediation::AdjustRollbackBudget { + current_bytes: None, + limit_bytes: None, + }) + } + Self::Snapshot(msg) | Self::ObjectStore(msg) + if msg.contains("--no-rollback") || msg.contains("disable rollback") => + { + Some(NonoRemediation::DisableRollback) + } + Self::NetworkFilterUnsupported { .. } => Some(NonoRemediation::GrantNetwork), + Self::ProfileNotFound(_) + | Self::ProfileParse(_) + | Self::NoCapabilities + | Self::ConfigParse(_) => Some(NonoRemediation::CheckPolicy), + _ => None, + } + } +} + +#[cfg(test)] +mod diagnostic_tests { + use super::{NonoError, Result}; + use crate::diagnostic::{NonoDiagnosticCode, NonoRemediation}; + + #[test] + fn cwd_prompt_maps_to_structured_code_and_remediation() { + let err = NonoError::CwdPromptRequired; + assert_eq!(err.diagnostic_code(), NonoDiagnosticCode::CwdAccessRequired); + assert_eq!(err.remediation(), Some(NonoRemediation::AllowCwd)); + } + + #[test] + fn secret_not_found_maps_to_credential_not_found() { + let err = NonoError::SecretNotFound("missing".to_string()); + assert_eq!( + err.diagnostic_code(), + NonoDiagnosticCode::CredentialNotFound + ); + assert!(matches!( + err.remediation(), + Some(NonoRemediation::AuthenticateCredentialProvider { .. }) + )); + } + + #[test] + fn rollback_budget_error_maps_to_structured_code() -> Result<()> { + let err = NonoError::Snapshot( + "Rollback budget exceeded: 10 bytes tracked (limit: 5 bytes). \ + or disable rollback with --no-rollback." + .to_string(), + ); + assert_eq!( + err.diagnostic_code(), + NonoDiagnosticCode::RollbackBudgetExceeded + ); + assert!(matches!( + err.remediation(), + Some(NonoRemediation::AdjustRollbackBudget { .. }) + )); + Ok(()) + } +} diff --git a/crates/nono/src/lib.rs b/crates/nono/src/lib.rs index 5d7280b5e..a5f2cc1ad 100644 --- a/crates/nono/src/lib.rs +++ b/crates/nono/src/lib.rs @@ -68,7 +68,12 @@ pub use capability::{ AccessMode, CapabilitySet, CapabilitySource, FsCapability, IpcMode, NetworkMode, ProcessInfoMode, SignalMode, SocketScope, UnixSocketCapability, UnixSocketMode, UnixSocketOp, }; -pub use diagnostic::{DenialReason, DenialRecord, IpcDenialRecord, SandboxViolation}; +pub use diagnostic::{ + DenialReason, DenialRecord, IpcDenialRecord, NonoDiagnostic, NonoDiagnosticCode, + NonoDiagnosticDetail, NonoDiagnosticSeverity, NonoRemediation, SandboxViolation, + SessionDiagnosticReport, SessionObservationInput, StderrObservationKind, dedupe_denials, + filesystem_denials_from_violations, follow_up_diagnostics, +}; pub use error::{NonoError, Result}; pub use keystore::{ LoadedSecret, is_apple_password_uri, is_bw_uri, is_env_uri, is_file_uri, is_keyring_uri, From 2e8ea99811bef6deb0a2b291168640e13d6c765c Mon Sep 17 00:00:00 2001 From: Caio Silva Date: Thu, 18 Jun 2026 15:58:15 +0100 Subject: [PATCH 84/91] fix(pty): ctrl-z hangs when running with a PTY (#1135) * fix(pty): suspend Ctrl-Z manually in orphaned PTY sessions (#989) setsid() in setup_child_pty orphans the child process group, preventing kernel delivery of SIGTSTP. PtyProxy now intercepts Ctrl-Z (0x1A), sends uncatchable SIGSTOP to the child, restores terminal, then SIGTSTP-stops nono. On fg: raw mode restored, SIGCONT + SIGWINCH forwarded. Signed-off-by: Caio Silva * fix(pty): retry suspension waitpid on EINTR Signed-off-by: Caio Silva * fix(pty): send SIGWINCH to the foreground process group on resume Signed-off-by: Caio Silva * refactor(pty): expose PTY master fd through a getter Signed-off-by: Caio Silva * fix(pty): detect CSI-u Ctrl-Z and handle nested jobs TUI apps using the kitty keyboard protocol (opencode/opentui) send Ctrl-Z as a CSI-u escape (\x1b[122;5u or \x1b[90;5u) rather than raw 0x1A. Detect both for terminal clients by reusing the existing enhanced-key matcher (control_key_candidates(0x1a) is already [90, 122]); socket-client detach behavior is unchanged. Distinguish nested jobs from the orphaned direct child in handle_pty_suspension: a nested job (e.g. vim under bash -i) is not orphaned, so forward a plain SIGTSTP and let the inner shell drive suspend/resume; only the direct child gets the manual SIGSTOP + self-suspend dance. This fixes a hang where waitpid() blocked on a stopped process that was not nono's child. On resume, repaint the alternate screen from nono's captured vt100 state (mirroring the client re-attach replay) instead of emitting a bare alt-screen-enter and relying on SIGWINCH, which opencode ignores. Use the clearing terminal restore on suspend so repeated Ctrl-Z/fg cycles do not accumulate cursor drift on the normal screen. Signed-off-by: Caio Silva --------- Signed-off-by: Caio Silva Co-authored-by: Luke Hinds --- crates/nono-cli/src/exec_strategy.rs | 119 ++++++++++++++++++ crates/nono-cli/src/pty_proxy.rs | 179 +++++++++++++++++++++++++-- 2 files changed, 289 insertions(+), 9 deletions(-) diff --git a/crates/nono-cli/src/exec_strategy.rs b/crates/nono-cli/src/exec_strategy.rs index ac8f460ef..511603bd0 100644 --- a/crates/nono-cli/src/exec_strategy.rs +++ b/crates/nono-cli/src/exec_strategy.rs @@ -1709,6 +1709,7 @@ fn wait_for_child_with_pty( } let in_band_detach_requested = pty.take_detach_request(); handle_pty_detach_request(Some(pty), pause_requested, in_band_detach_requested); + handle_pty_suspension(Some(pty), child); match waitpid(child, Some(WaitPidFlag::WNOHANG)) { Ok(WaitStatus::StillAlive) => { @@ -2010,6 +2011,122 @@ fn handle_pty_detach_request( } } +/// Send `sig` to the PTY's foreground process group, falling back to `child`. +/// +/// A shell (bash) running a foreground job (vim) puts that job in its own +/// process group and makes it the PTY's foreground PG. Job-control signals must +/// reach that whole group, not just the immediate child, so we query it via +/// tcgetpgrp on the master fd and signal the negated PGID. On any error we fall +/// back to the bare child. +fn signal_pty_foreground_group(pty: &crate::pty_proxy::PtyProxy, child: Pid, sig: Signal) { + match nix::unistd::tcgetpgrp(pty.master_fd()) { + Ok(pgid) => { + let _ = signal::kill(Pid::from_raw(-pgid.as_raw()), sig); + } + Err(_) => { + let _ = signal::kill(child, sig); + } + } +} + +/// Handle a Ctrl-Z suspension request intercepted by the PtyProxy. +/// +/// The handling depends on whether the PTY foreground group is nono's direct +/// child. A nested job (e.g. vim under `bash -i`) is NOT orphaned — its parent +/// shell is in the same session — so the kernel delivers normal job-control +/// signals: we forward a plain SIGTSTP and let the inner shell suspend/resume +/// it. The direct child is orphaned (setsid() put it in a new session whose +/// parent, nono, is elsewhere), so the kernel drops SIGTSTP and we must drive +/// suspension manually: SIGSTOP, restore the terminal, raise(SIGTSTP) on nono +/// itself, then SIGCONT on resume. +fn handle_pty_suspension(pty: Option<&mut crate::pty_proxy::PtyProxy>, child: Pid) { + let pty = match pty { + Some(p) => p, + None => return, + }; + + if !pty.take_suspension_request() { + return; + } + + let fg_pgid = nix::unistd::tcgetpgrp(pty.master_fd()).ok(); + let child_is_foreground = match fg_pgid { + Some(pgid) => pgid.as_raw() == child.as_raw(), + None => true, + }; + + // Nested job: forward SIGTSTP and let the inner shell handle it. Do not + // waitpid() — the stopped job is not our child, and the inner shell is + // already blocked waiting on it, so waiting here would hang. + if !child_is_foreground { + if let Some(pgid) = fg_pgid { + let _ = signal::kill(Pid::from_raw(-pgid.as_raw()), Signal::SIGTSTP); + } + return; + } + + // Direct child (orphaned PG): SIGSTOP is uncatchable, unlike SIGTSTP which + // an interactive bash ignores, so it forces the stopped state immediately. + signal_pty_foreground_group(pty, child, Signal::SIGSTOP); + + loop { + match waitpid(child, Some(WaitPidFlag::WUNTRACED)) { + Ok(WaitStatus::Stopped(_, sig)) => { + debug!("Child stopped by signal {:?} for suspension", sig); + break; + } + Ok(WaitStatus::Exited(..) | WaitStatus::Signaled(..)) => { + return; + } + Err(nix::errno::Errno::EINTR) => continue, + Err(_) => return, + _ => {} + } + } + + // Save raw settings for restore-on-resume, and preserve the + // cooked settings for later detach (restore_terminal consumes them). + let raw_termios = nix::sys::termios::tcgetattr(std::io::stdin()).ok(); + let cooked_termios = pty.saved_termios.clone(); + + // Exit the alternate screen so the shell's "[1]+ Stopped" prompt shows on + // the normal screen, then restore cooked mode. + pty.leave_screen_for_suspension(); + pty.restore_terminal(); + + // Stop nono itself. The shell shows "[1]+ Stopped nono run ..." + // When user types 'fg', the shell sends SIGCONT and we resume here. + unsafe { + let _ = signal::signal(Signal::SIGTSTP, signal::SigHandler::SigDfl); + } + let _ = signal::raise(Signal::SIGTSTP); + + // --- Resumed by SIGCONT from fg --- + + // Restore raw mode for PTY I/O. + if let Some(termios) = raw_termios { + let _ = nix::sys::termios::tcsetattr( + std::io::stdin(), + nix::sys::termios::SetArg::TCSANOW, + &termios, + ); + } + // Restore cooked settings so detach works correctly later. + pty.saved_termios = cooked_termios; + + // Re-enter the alternate screen the child was using before resuming it. + pty.reenter_screen_for_resume(); + + signal_pty_foreground_group(pty, child, Signal::SIGCONT); + + // SIGSTOP doesn't give the child a chance to clean up its terminal state. + // When resumed, TUI apps (opencode, vim, htop) don't know they need to + // redraw because they missed the TSTP/CONT cycle they normally rely on. + // Sending SIGWINCH to the foreground group triggers a full redraw in both + // the shell and any nested TUI. + signal_pty_foreground_group(pty, child, Signal::SIGWINCH); +} + struct SignalForwardingGuard; impl Drop for SignalForwardingGuard { @@ -2209,6 +2326,7 @@ fn run_supervisor_loop( pause_requested, in_band_detach_requested, ); + handle_pty_suspension(pty.as_deref_mut(), child); match waitpid(child, Some(WaitPidFlag::WNOHANG)) { Ok(WaitStatus::StillAlive) => { @@ -2480,6 +2598,7 @@ fn run_supervisor_loop( pause_requested, in_band_detach_requested, ); + handle_pty_suspension(pty.as_deref_mut(), child); match waitpid(child, Some(WaitPidFlag::WNOHANG)) { Ok(WaitStatus::StillAlive) => { diff --git a/crates/nono-cli/src/pty_proxy.rs b/crates/nono-cli/src/pty_proxy.rs index 0a0a15aaa..a84699b49 100644 --- a/crates/nono-cli/src/pty_proxy.rs +++ b/crates/nono-cli/src/pty_proxy.rs @@ -223,7 +223,7 @@ pub struct PtyProxy { /// Resize updates from a reattached terminal client. resize_notifier: Option, /// Saved terminal settings (restored on detach) - saved_termios: Option, + pub(crate) saved_termios: Option, /// Recent PTY output replayed to newly attached clients. scrollback: VecDeque, /// Last visible screen state for attach restoration. @@ -236,6 +236,8 @@ pub struct PtyProxy { pending_detach_escape: Vec, /// In-band detach requested from the attached client. detach_requested: bool, + /// Ctrl-Z suspension requested from a terminal client. + suspension_requested: bool, } /// Open a PTY pair, inheriting the current terminal's window size. @@ -363,6 +365,7 @@ impl PtyProxy { pending_detach_match_len: 0, pending_detach_escape: Vec::new(), detach_requested: false, + suspension_requested: false, }) } @@ -440,6 +443,33 @@ impl PtyProxy { self.screen.alternate_screen_active() } + /// Before suspending, if the child is in the alternate screen buffer, exit + /// it so the shell's "[1]+ Stopped" prompt shows on the normal screen. Use + /// the clearing restore (same as detach) so the normal screen starts clean: + /// without the clear, the cursor lands at a stale position in the normal + /// buffer and the shell prompt mixes with leftover lines, drift that + /// accumulates across repeated Ctrl-Z/fg cycles. + pub(crate) fn leave_screen_for_suspension(&self) { + if self.screen.alternate_screen_active() { + let _ = write_all_fd(libc::STDOUT_FILENO, terminal_restore_escape(true)); + drain_terminal_output(libc::STDOUT_FILENO); + } + } + + /// On resume, re-enter the alternate screen and repaint it from nono's + /// captured screen state. Emitting only the alt-screen-enter sequence leaves + /// a blank buffer that TUIs which ignore SIGWINCH (e.g. opencode/opentui) + /// never repaint. Instead we reconstruct the screen the same way a + /// re-attaching client does: alt-screen enter + a full vt100 repaint of the + /// current contents (`ScreenState::render` -> `state_formatted`). + pub(crate) fn reenter_screen_for_resume(&self) { + if self.screen.alternate_screen_active() { + let _ = write_all_fd(libc::STDOUT_FILENO, ATTACH_SCREEN_ENTER_ESCAPE); + let _ = write_all_fd(libc::STDOUT_FILENO, &self.scrollback_snapshot()); + drain_terminal_output(libc::STDOUT_FILENO); + } + } + /// Shut down the attach listener so no new connections can be accepted. /// /// Removes the socket file. This prevents the kernel from accepting new @@ -619,7 +649,12 @@ impl PtyProxy { ) } - /// Proxy data from the PTY master to the attached client (child → user). + /// Borrow the PTY master fd for ioctl operations (e.g. tcgetpgrp). + pub(crate) fn master_fd(&self) -> &OwnedFd { + &self.master + } + + /// Proxy data from the PTY master to the attached client (child -> user). /// /// Returns false if the PTY master became unavailable. #[must_use = "false indicates the PTY master is no longer usable"] @@ -783,6 +818,10 @@ impl PtyProxy { std::mem::take(&mut self.detach_requested) } + pub fn take_suspension_request(&mut self) -> bool { + std::mem::take(&mut self.suspension_requested) + } + /// Temporarily restore the local terminal so the parent can prompt. /// /// Returns true when a terminal-backed client was paused and must later @@ -802,7 +841,7 @@ impl PtyProxy { } /// Restore terminal settings. - fn restore_terminal(&mut self) { + pub(crate) fn restore_terminal(&mut self) { if let Some(ref termios) = self.saved_termios { let _ = nix::sys::termios::tcsetattr( std::io::stdin(), @@ -1024,7 +1063,15 @@ impl PtyProxy { } fn filter_client_input(&mut self, bytes: &[u8]) -> Vec { let mut forwarded = Vec::with_capacity(bytes.len()); + let is_terminal = self + .client + .as_ref() + .is_some_and(AttachedClient::is_terminal); for (i, &byte) in bytes.iter().enumerate() { + if is_terminal && byte == 0x1A { + self.suspension_requested = true; + continue; + } if self.maybe_consume_enhanced_detach_byte(byte, &mut forwarded) { continue; } @@ -1072,12 +1119,25 @@ impl PtyProxy { } fn should_start_enhanced_detach_match(&self, byte: u8) -> bool { - byte == b'\x1b' - && self - .detach_sequence - .get(self.pending_detach_match_len) - .copied() - .is_some_and(detach_key_supports_enhanced_match) + if byte != b'\x1b' { + return false; + } + // Terminal clients may send Ctrl-Z as a kitty-protocol CSI-u sequence + // (\x1b[122;5u or \x1b[90;5u) instead of raw 0x1A. Buffer any ESC from + // a terminal so maybe_consume_enhanced_detach_byte can check it for + // suspension. Socket clients only buffer when the detach key itself + // supports enhanced (CSI-u) matching. + if self + .client + .as_ref() + .is_some_and(AttachedClient::is_terminal) + { + return true; + } + self.detach_sequence + .get(self.pending_detach_match_len) + .copied() + .is_some_and(detach_key_supports_enhanced_match) } fn maybe_consume_enhanced_detach_byte(&mut self, byte: u8, forwarded: &mut Vec) -> bool { @@ -1086,6 +1146,27 @@ impl PtyProxy { } self.pending_detach_escape.push(byte); + + // Check for a kitty-protocol Ctrl-Z (\x1b[122;5u or \x1b[90;5u) before + // the detach key. control_key_candidates(0x1a) is [90, 122], so the + // existing matcher recognizes both encodings. Only a completed match is + // intercepted (swallowed + suspension requested); Pending and Invalid + // fall through to the detach logic below, which keeps the buffer-length + // guard and still recognizes a detach CSI-u sequence. + if self + .client + .as_ref() + .is_some_and(AttachedClient::is_terminal) + && matches!( + match_enhanced_key_sequence(&self.pending_detach_escape, 0x1a), + EnhancedKeyMatch::Matched + ) + { + self.suspension_requested = true; + self.pending_detach_escape.clear(); + return true; + } + let Some(expected_key) = self .detach_sequence .get(self.pending_detach_match_len) @@ -2423,6 +2504,7 @@ mod tests { pending_detach_match_len: 0, pending_detach_escape: Vec::new(), detach_requested: false, + suspension_requested: false, } } @@ -2433,6 +2515,15 @@ mod tests { build_test_proxy_with_master(master, sequence) } + fn build_test_proxy_with_terminal(sequence: &[u8]) -> PtyProxy { + let mut proxy = build_test_proxy(sequence); + proxy.client = Some(AttachedClient::terminal( + libc::STDIN_FILENO, + libc::STDOUT_FILENO, + )); + proxy + } + #[test] fn terminal_restore_escape_disables_mouse_modes() { let esc = std::str::from_utf8(terminal_restore_escape(false)).unwrap_or(""); @@ -2918,4 +3009,74 @@ mod tests { assert!(proxy.proxy_master_to_client()); assert!(proxy.client.is_none()); } + + // --- Ctrl-Z suspension detection --- + + #[test] + fn filter_client_input_raw_ctrl_z_from_terminal_sets_suspension() { + let mut proxy = build_test_proxy_with_terminal(&DEFAULT_DETACH_SEQUENCE); + let forwarded = proxy.filter_client_input(b"\x1a"); + assert!(forwarded.is_empty()); + assert!(proxy.take_suspension_request()); + } + + #[test] + fn filter_client_input_csi_u_ctrl_z_lowercase_z_sets_suspension() { + // Kitty keyboard protocol: Ctrl-Z as \x1b[122;5u (codepoint 122 = 'z'). + let mut proxy = build_test_proxy_with_terminal(&DEFAULT_DETACH_SEQUENCE); + let forwarded = proxy.filter_client_input(b"\x1b[122;5u"); + assert!(forwarded.is_empty()); + assert!(proxy.take_suspension_request()); + } + + #[test] + fn filter_client_input_csi_u_ctrl_z_uppercase_z_sets_suspension() { + // Alternative encoding: \x1b[90;5u (codepoint 90 = 'Z'). + let mut proxy = build_test_proxy_with_terminal(&DEFAULT_DETACH_SEQUENCE); + let forwarded = proxy.filter_client_input(b"\x1b[90;5u"); + assert!(forwarded.is_empty()); + assert!(proxy.take_suspension_request()); + } + + #[test] + fn filter_client_input_csi_u_ctrl_z_chunked_across_reads_sets_suspension() { + let mut proxy = build_test_proxy_with_terminal(&DEFAULT_DETACH_SEQUENCE); + let forwarded = proxy.filter_client_input(b"\x1b[12"); + assert!(forwarded.is_empty()); + assert!(!proxy.take_suspension_request()); + + let forwarded = proxy.filter_client_input(b"2;5u"); + assert!(forwarded.is_empty()); + assert!(proxy.take_suspension_request()); + } + + #[test] + fn filter_client_input_unrelated_csi_u_does_not_set_suspension() { + // \x1b[97;5u = Ctrl-A (codepoint 97 = 'a'); must not suspend and must + // be forwarded unchanged. + let mut proxy = build_test_proxy_with_terminal(&DEFAULT_DETACH_SEQUENCE); + let forwarded = proxy.filter_client_input(b"\x1b[97;5u"); + assert_eq!(forwarded, b"\x1b[97;5u"); + assert!(!proxy.take_suspension_request()); + } + + #[test] + fn filter_client_input_raw_ctrl_z_from_non_terminal_is_forwarded() { + // Socket (non-terminal) clients do not suspend; raw 0x1A is data. + let mut proxy = build_test_proxy(&DEFAULT_DETACH_SEQUENCE); + let forwarded = proxy.filter_client_input(b"\x1a"); + assert_eq!(forwarded, b"\x1a"); + assert!(!proxy.take_suspension_request()); + } + + #[test] + fn filter_client_input_csi_u_detach_still_works_with_terminal_client() { + // Regression: a CSI-u detach key must still match for terminal clients. + // Default detach is [0x1d, 'd']; 0x1d = Ctrl-] encodes as \x1b[93;5u. + let mut proxy = build_test_proxy_with_terminal(&DEFAULT_DETACH_SEQUENCE); + let forwarded = proxy.filter_client_input(b"\x1b[93;5ud"); + assert!(forwarded.is_empty()); + assert!(!proxy.take_suspension_request()); + assert!(proxy.take_detach_request()); + } } From ffb6af577021803ac21dcd8e9a19111b36416ab8 Mon Sep 17 00:00:00 2001 From: Aleks <121458075+SequeI@users.noreply.github.com> Date: Thu, 18 Jun 2026 16:16:13 +0100 Subject: [PATCH 85/91] refactor(proxy): separate proxy intent from activation (#1199) * refactor(proxy): separate proxy intent from activation Replace the flat `ProxyLaunchOptions` struct with focused intent structs: `DomainFilterIntent`, `EndpointFilterIntent`, `CredentialProxyIntent`, `UpstreamProxyIntent`, `TlsInterceptIntent`, and `OpenUrlIntent`. Remove the stored `active: bool` field. Proxy activation is now derived via `is_active()`, which returns true when any activating intent struct is `Some`. `WithEndpoints` allow-domain entries are split from plain CONNECT-tunnel entries at prepare time, making the TLS-intercept requirement explicit in the type. Infra-only flags (`--proxy-port`, `--proxy-ca-validity`, `--trust-proxy-ca`) now return an error if used without an activating proxy feature instead of silently doing nothing. Signed-off-by: Aleksy Siek * fix: review comments Signed-off-by: Aleksy Siek * cargo fmt Signed-off-by: Aleksy Siek --------- Signed-off-by: Aleksy Siek --- crates/nono-cli/src/command_runtime.rs | 2 +- crates/nono-cli/src/execution_runtime.rs | 26 +- crates/nono-cli/src/launch_runtime.rs | 72 +++++- crates/nono-cli/src/proxy_runtime.rs | 290 +++++++++++++++++----- crates/nono-cli/src/supervised_runtime.rs | 18 +- 5 files changed, 324 insertions(+), 84 deletions(-) diff --git a/crates/nono-cli/src/command_runtime.rs b/crates/nono-cli/src/command_runtime.rs index 1049da687..54069f862 100644 --- a/crates/nono-cli/src/command_runtime.rs +++ b/crates/nono-cli/src/command_runtime.rs @@ -188,7 +188,7 @@ pub(crate) fn run_shell(args: ShellArgs, silent: bool) -> Result<()> { let proxy = prepare_proxy_launch_options(&args.sandbox, &prepared, silent)?; let strategy = select_exec_strategy( false, - proxy.active, + proxy.is_active(), prepared.capability_elevation, false, false, diff --git a/crates/nono-cli/src/execution_runtime.rs b/crates/nono-cli/src/execution_runtime.rs index 2bb50c7cc..a9793617e 100644 --- a/crates/nono-cli/src/execution_runtime.rs +++ b/crates/nono-cli/src/execution_runtime.rs @@ -182,15 +182,27 @@ pub(crate) fn execute_sandboxed(plan: LaunchPlan) -> Result<()> { if let Some(profile) = recommended_profile { output::print_profile_hint(recommended_program_name, profile, flags.silent); } - let allowed_domain_strs: Vec = flags + let plain_domain_entries = flags .proxy - .allow_domain + .domain_filter + .as_ref() + .map(|d| d.allow_domain.as_slice()) + .unwrap_or(&[]); + let endpoint_domain_entries = flags + .proxy + .endpoint_filter + .as_ref() + .map(|e| e.routes.as_slice()) + .unwrap_or(&[]); + let all_domain_entries: Vec<_> = plain_domain_entries + .iter() + .chain(endpoint_domain_entries.iter()) + .collect(); + let allowed_domain_strs: Vec = all_domain_entries .iter() .map(|e| e.domain().to_string()) .collect(); - let domain_endpoints: Vec = flags - .proxy - .allow_domain + let domain_endpoints: Vec = all_domain_entries .iter() .filter_map(|e| match e { crate::profile::AllowDomainEntry::WithEndpoints { domain, endpoints } @@ -231,7 +243,7 @@ pub(crate) fn execute_sandboxed(plan: LaunchPlan) -> Result<()> { let strategy = flags.strategy; if matches!(strategy, exec_strategy::ExecStrategy::Supervised) { - output::print_supervised_info(flags.silent, rollback.requested, proxy.active); + output::print_supervised_info(flags.silent, rollback.requested, proxy.is_active()); } let active_proxy = start_proxy_runtime(proxy, &mut caps)?; @@ -307,7 +319,7 @@ pub(crate) fn execute_sandboxed(plan: LaunchPlan) -> Result<()> { let threading = select_threading_context( !loaded_secrets.is_empty(), - proxy.active, + proxy.is_active(), trust.scan_performed, trust.interception_active, ); diff --git a/crates/nono-cli/src/launch_runtime.rs b/crates/nono-cli/src/launch_runtime.rs index cc5854de6..061b2f79b 100644 --- a/crates/nono-cli/src/launch_runtime.rs +++ b/crates/nono-cli/src/launch_runtime.rs @@ -70,32 +70,82 @@ pub(crate) struct TrustLaunchOptions { pub(crate) protected_paths: Vec, } +/// Plain CONNECT-tunnel domain allowlist entries and an optional network profile. #[derive(Clone, Default)] -pub(crate) struct ProxyLaunchOptions { - pub(crate) active: bool, +pub(crate) struct DomainFilterIntent { pub(crate) network_profile: Option, + /// Only `AllowDomainEntry::Plain` entries — endpoint-bearing entries live in + /// `EndpointFilterIntent`. pub(crate) allow_domain: Vec, pub(crate) reject_domain: Vec, +} + +/// `WithEndpoints` allow-domain entries that require TLS interception so the +/// proxy can inspect method and path before forwarding. +/// All entries must be `AllowDomainEntry::WithEndpoints` (enforced by `debug_assert` +/// at construction in `prepare_proxy_launch_options`). +#[derive(Clone, Default)] +pub(crate) struct EndpointFilterIntent { + pub(crate) routes: Vec, +} + +#[derive(Clone, Default)] +pub(crate) struct CredentialProxyIntent { pub(crate) credentials: Vec, pub(crate) custom_credentials: HashMap, - pub(crate) upstream_proxy: Option, - pub(crate) upstream_bypass: Vec, - pub(crate) allow_bind_ports: Vec, - pub(crate) proxy_port: Option, - pub(crate) open_url_origins: Vec, - pub(crate) open_url_allow_localhost: bool, - pub(crate) allow_launch_services_active: bool, +} + +#[derive(Clone)] +pub(crate) struct UpstreamProxyIntent { + pub(crate) address: String, + pub(crate) bypass: Vec, +} + +/// TLS interception configuration supplied by the user. Presence means the user +/// configured TLS intercept settings; it does not by itself activate the proxy. +#[derive(Clone, Default)] +pub(crate) struct TlsInterceptIntent { + /// macOS only: reuse a persistent CA bundle across sessions. #[cfg(target_os = "macos")] pub(crate) trust_proxy_ca: bool, - pub(crate) proxy_ca_validity: Option, + pub(crate) ca_validity: Option, +} + +#[derive(Clone, Default)] +pub(crate) struct OpenUrlIntent { + pub(crate) origins: Vec, + pub(crate) allow_localhost: bool, + pub(crate) allow_launch_services: bool, +} + +#[derive(Clone, Default)] +pub(crate) struct ProxyLaunchOptions { + pub(crate) domain_filter: Option, + pub(crate) endpoint_filter: Option, + pub(crate) credentials: Option, + pub(crate) upstream_proxy: Option, + pub(crate) tls_intercept: Option, + pub(crate) open_url: Option, + pub(crate) allow_bind_ports: Vec, + pub(crate) proxy_port: Option, pub(crate) network_approval_mode: crate::network_approval::NetworkApprovalMode, pub(crate) network_approval_timeout_secs: u64, + pub(crate) profile_name: Option, /// True when the user requested `network.block` or `--block-net`. /// Propagated to `ProxyConfig.strict_filter` so the filter denies /// unlisted hosts instead of falling back to allow-all. pub(crate) network_block: bool, } +impl ProxyLaunchOptions { + pub(crate) fn is_active(&self) -> bool { + self.domain_filter.is_some() + || self.endpoint_filter.is_some() + || self.credentials.is_some() + || self.upstream_proxy.is_some() + } +} + #[derive(Clone)] pub(crate) struct ExecutionFlags { pub(crate) strategy: exec_strategy::ExecStrategy, @@ -245,7 +295,7 @@ pub(crate) fn prepare_run_launch_plan( let strategy = select_exec_strategy( rollback, - proxy.active, + proxy.is_active(), prepared.capability_elevation, trust.interception_active, run_args.detached, diff --git a/crates/nono-cli/src/proxy_runtime.rs b/crates/nono-cli/src/proxy_runtime.rs index 5cc622c4c..5ce98ef48 100644 --- a/crates/nono-cli/src/proxy_runtime.rs +++ b/crates/nono-cli/src/proxy_runtime.rs @@ -1,7 +1,10 @@ use std::time::Duration; use crate::cli::SandboxArgs; -use crate::launch_runtime::ProxyLaunchOptions; +use crate::launch_runtime::{ + CredentialProxyIntent, DomainFilterIntent, EndpointFilterIntent, OpenUrlIntent, + ProxyLaunchOptions, TlsInterceptIntent, UpstreamProxyIntent, +}; use crate::network_approval::{NetworkApprovalBackend, NetworkApprovalMode}; use crate::network_policy; use crate::sandbox_prepare::{PreparedSandbox, validate_external_proxy_bypass}; @@ -39,7 +42,7 @@ pub(crate) fn prepare_proxy_launch_options( let credentials = effective_proxy.credentials; let allow_bind_ports = merge_dedup_ports(&prepared.listen_ports, &args.allow_bind); - let upstream_proxy = if args.allow_net { + let upstream_proxy_addr = if args.allow_net { None } else { args.external_proxy @@ -58,14 +61,12 @@ pub(crate) fn prepare_proxy_launch_options( }; let has_custom_credentials = !prepared.custom_credentials.is_empty(); + let has_domain_filter = network_profile.is_some() || !allow_domain.is_empty(); + let has_credentials = !credentials.is_empty() || has_custom_credentials; + let would_activate = has_domain_filter || has_credentials || upstream_proxy_addr.is_some(); - let active = if matches!(prepared.caps.network_mode(), nono::NetworkMode::Blocked) { - if !credentials.is_empty() - || has_custom_credentials - || network_profile.is_some() - || !allow_domain.is_empty() - || upstream_proxy.is_some() - { + if matches!(prepared.caps.network_mode(), nono::NetworkMode::Blocked) { + if would_activate { warn!( "--block-net is active; ignoring proxy configuration \ that would re-enable network access" @@ -77,46 +78,129 @@ pub(crate) fn prepare_proxy_launch_options( ); } } - false + return Ok(ProxyLaunchOptions { + allow_bind_ports, + network_block: prepared.network_block_requested, + ..ProxyLaunchOptions::default() + }); + } + + let (plain_entries, endpoint_entries): (Vec<_>, Vec<_>) = allow_domain + .into_iter() + .partition(|e| !matches!(e, crate::profile::AllowDomainEntry::WithEndpoints { endpoints, .. } if !endpoints.is_empty())); + + let domain_filter = if network_profile.is_some() || !plain_entries.is_empty() { + Some(DomainFilterIntent { + network_profile, + allow_domain: plain_entries, + reject_domain, + }) } else { - matches!( - prepared.caps.network_mode(), - nono::NetworkMode::ProxyOnly { .. } - ) || !credentials.is_empty() - || has_custom_credentials - || network_profile.is_some() - || !allow_domain.is_empty() - || upstream_proxy.is_some() + None }; let approval_mode = resolve_network_approval_mode(args, prepared.profile_network_approval_mode.as_deref()); - Ok(ProxyLaunchOptions { - active, - network_profile, - allow_domain, - reject_domain, - credentials, - custom_credentials: prepared.custom_credentials.clone(), + let endpoint_filter = if !endpoint_entries.is_empty() { + debug_assert!( + endpoint_entries + .iter() + .all(|e| matches!(e, crate::profile::AllowDomainEntry::WithEndpoints { endpoints, .. } if !endpoints.is_empty())), + "EndpointFilterIntent invariant violated: all entries must have non-empty endpoints" + ); + Some(EndpointFilterIntent { + routes: endpoint_entries, + }) + } else { + None + }; + + let credentials_intent = if has_credentials { + Some(CredentialProxyIntent { + credentials, + custom_credentials: prepared.custom_credentials.clone(), + }) + } else { + None + }; + + let upstream_proxy = upstream_proxy_addr.map(|address| UpstreamProxyIntent { + address, + bypass: upstream_bypass, + }); + + let proxy_ca_validity = args + .proxy_ca_validity + .map(|days| std::time::Duration::from_secs(u64::from(days) * 24 * 60 * 60)); + + #[cfg(target_os = "macos")] + let tls_intercept = if args.trust_proxy_ca || proxy_ca_validity.is_some() { + Some(TlsInterceptIntent { + trust_proxy_ca: args.trust_proxy_ca, + ca_validity: proxy_ca_validity, + }) + } else { + None + }; + #[cfg(not(target_os = "macos"))] + let tls_intercept = if proxy_ca_validity.is_some() { + Some(TlsInterceptIntent { + ca_validity: proxy_ca_validity, + }) + } else { + None + }; + + let open_url = if !prepared.open_url_origins.is_empty() + || prepared.open_url_allow_localhost + || prepared.allow_launch_services_active + { + Some(OpenUrlIntent { + origins: prepared.open_url_origins.clone(), + allow_localhost: prepared.open_url_allow_localhost, + allow_launch_services: prepared.allow_launch_services_active, + }) + } else { + None + }; + + let opts = ProxyLaunchOptions { + domain_filter, + endpoint_filter, + credentials: credentials_intent, upstream_proxy, - upstream_bypass, + tls_intercept, + open_url, allow_bind_ports, proxy_port: args.proxy_port, - open_url_origins: prepared.open_url_origins.clone(), - open_url_allow_localhost: prepared.open_url_allow_localhost, - allow_launch_services_active: prepared.allow_launch_services_active, - #[cfg(target_os = "macos")] - trust_proxy_ca: args.trust_proxy_ca, - proxy_ca_validity: args - .proxy_ca_validity - .map(|days| std::time::Duration::from_secs(u64::from(days) * 24 * 60 * 60)), network_approval_mode: approval_mode, network_approval_timeout_secs: resolve_approval_timeout_secs( prepared.profile_network_approval_timeout_secs, ), + profile_name: args.profile.clone(), network_block: prepared.network_block_requested, - }) + }; + + // Infra-only flags make no sense without an activating proxy feature. + if !opts.is_active() { + if opts.tls_intercept.is_some() { + return Err(NonoError::ConfigParse( + "--trust-proxy-ca / --proxy-ca-validity require a proxy feature \ + (--allow-domain, --credential, or --upstream-proxy)" + .to_string(), + )); + } + if args.proxy_port.is_some() { + return Err(NonoError::ConfigParse( + "--proxy-port requires a proxy feature (--allow-domain, --credential, \ + or --upstream-proxy)" + .to_string(), + )); + } + } + + Ok(opts) } pub(crate) fn resolve_effective_proxy_settings( @@ -244,7 +328,11 @@ pub(crate) fn build_proxy_config_from_flags( let net_policy_json = crate::config::embedded::embedded_network_policy_json(); let net_policy = network_policy::load_network_policy(net_policy_json)?; - let mut resolved = if let Some(ref profile_name) = proxy.network_profile { + let mut resolved = if let Some(profile_name) = proxy + .domain_filter + .as_ref() + .and_then(|d| d.network_profile.as_ref()) + { network_policy::resolve_network_profile(&net_policy, profile_name)? } else { network_policy::ResolvedNetworkPolicy { @@ -256,44 +344,73 @@ pub(crate) fn build_proxy_config_from_flags( }; let mut all_credentials = resolved.profile_credentials.clone(); - for cred in &proxy.credentials { - if !all_credentials.contains(cred) { - all_credentials.push(cred.clone()); + if let Some(ref creds) = proxy.credentials { + for cred in &creds.credentials { + if !all_credentials.contains(cred) { + all_credentials.push(cred.clone()); + } } } - let mut routes = network_policy::resolve_credentials( - &net_policy, - &all_credentials, - &proxy.custom_credentials, - )?; - - let (mut plain_hosts, endpoint_routes) = - network_policy::partition_allow_domain(&net_policy, &proxy.allow_domain)?; + let empty_custom_credentials = std::collections::HashMap::new(); + let custom_credentials = proxy + .credentials + .as_ref() + .map(|c| &c.custom_credentials) + .unwrap_or(&empty_custom_credentials); + + let mut routes = + network_policy::resolve_credentials(&net_policy, &all_credentials, custom_credentials)?; + + let plain_allow_domain = proxy + .domain_filter + .as_ref() + .map(|d| d.allow_domain.as_slice()) + .unwrap_or(&[]); + let (mut plain_hosts, _) = + network_policy::partition_allow_domain(&net_policy, plain_allow_domain)?; + + let endpoint_allow_domain = proxy + .endpoint_filter + .as_ref() + .map(|e| e.routes.as_slice()) + .unwrap_or(&[]); + let (_, endpoint_routes) = + network_policy::partition_allow_domain(&net_policy, endpoint_allow_domain)?; // Endpoint-restricted domains need filter allowlist access so the proxy // can reach upstream after TLS interception (h2 checks the filter at // connection setup, before per-stream route matching). for route in &endpoint_routes { - if let Some(ref hp) = route.upstream.strip_prefix("https://") { + if let Some(hp) = route.upstream.strip_prefix("https://") { plain_hosts.push(hp.to_string()); - } else if let Some(ref hp) = route.upstream.strip_prefix("http://") { + } else if let Some(hp) = route.upstream.strip_prefix("http://") { plain_hosts.push(hp.to_string()); } } routes.extend(endpoint_routes); resolved.routes = routes; + let allow_domain_flat: Vec = plain_allow_domain + .iter() + .chain(endpoint_allow_domain.iter()) + .map(|e| e.domain().to_string()) + .collect(); let expanded_allow_domain = - network_policy::expand_proxy_allow(&net_policy, &proxy.allow_domain.iter().map(|e| e.domain().to_string()).collect::>()); + network_policy::expand_proxy_allow(&net_policy, &allow_domain_flat); + let reject_domain = proxy + .domain_filter + .as_ref() + .map(|d| d.reject_domain.as_slice()) + .unwrap_or(&[]); let mut proxy_config = - network_policy::build_proxy_config(&resolved, &expanded_allow_domain, &proxy.reject_domain); + network_policy::build_proxy_config(&resolved, &expanded_allow_domain, reject_domain); proxy_config.strict_filter = proxy.network_block; - if let Some(ref addr) = proxy.upstream_proxy { + if let Some(ref upstream) = proxy.upstream_proxy { proxy_config.external_proxy = Some(nono_proxy::config::ExternalProxyConfig { - address: addr.clone(), + address: upstream.address.clone(), auth: None, - bypass_hosts: proxy.upstream_bypass.clone(), + bypass_hosts: upstream.bypass.clone(), }); } @@ -301,7 +418,7 @@ pub(crate) fn build_proxy_config_from_flags( proxy_config.bind_port = port; } - proxy_config.ca_validity = proxy.proxy_ca_validity; + proxy_config.ca_validity = proxy.tls_intercept.as_ref().and_then(|t| t.ca_validity); Ok(proxy_config) } @@ -310,7 +427,7 @@ pub(crate) fn start_proxy_runtime( proxy: &ProxyLaunchOptions, caps: &mut CapabilitySet, ) -> Result { - if !proxy.active { + if !proxy.is_active() { return Ok(ActiveProxyRuntime { env_vars: Vec::new(), handle: None, @@ -330,10 +447,16 @@ pub(crate) fn start_proxy_runtime( } #[cfg(target_os = "macos")] - if proxy.trust_proxy_ca { + if proxy + .tls_intercept + .as_ref() + .is_some_and(|t| t.trust_proxy_ca) + { if proxy_config.intercept_ca_dir.is_some() { let validity = proxy - .proxy_ca_validity + .tls_intercept + .as_ref() + .and_then(|t| t.ca_validity) .unwrap_or(nono_proxy::tls_intercept::ca::CA_VALIDITY_DEFAULT); proxy_config.preloaded_ca = crate::macos_trust::load_or_generate_proxy_ca(validity); } else { @@ -359,7 +482,12 @@ pub(crate) fn start_proxy_runtime( let (tx, mut rx) = tokio::sync::mpsc::channel::(16); - let config_writer = match proxy.network_profile.as_deref() { + let config_writer = match proxy + .domain_filter + .as_ref() + .and_then(|d| d.network_profile.as_deref()) + .or_else(|| proxy.profile_name.as_deref()) + { Some(name) => Some(crate::network_approval::ConfigWriter::new(name)), None => Some(crate::network_approval::ConfigWriter::new("default")), }; @@ -665,7 +793,6 @@ mod tests { #[test] fn test_build_proxy_config_propagates_network_block_to_strict_filter() { let proxy = ProxyLaunchOptions { - active: true, network_block: true, ..ProxyLaunchOptions::default() }; @@ -679,7 +806,6 @@ mod tests { #[test] fn test_build_proxy_config_strict_filter_off_when_no_block() { let proxy = ProxyLaunchOptions { - active: true, network_block: false, ..ProxyLaunchOptions::default() }; @@ -690,6 +816,46 @@ mod tests { ); } + /// `{ "domain": "cdn.example.com" }` (no `endpoints` key) deserializes via serde default + /// to `WithEndpoints { endpoints: [] }`, which is semantically identical to `Plain`. + /// The partition must route it to `plain_entries` — not `endpoint_entries` — or the + /// domain silently disappears from the allowlist. + #[test] + fn test_object_form_domain_with_no_endpoints_key_is_treated_as_plain() { + use crate::profile::AllowDomainEntry; + + // Mirrors exactly: { "network": { "allow_domain": [ { "domain": "cdn.example.com" } ] } } + let entries: Vec = serde_json::from_str(r#"[ + "plain.example.com", + { "domain": "object.example.com" }, + { "domain": "filtered.example.com", "endpoints": [{ "method": "GET", "path": "/v1/**" }] } + ]"#) + .expect("deserialize allow_domain entries"); + + let (plain, endpoint): (Vec<_>, Vec<_>) = entries + .into_iter() + .partition(|e| !matches!(e, AllowDomainEntry::WithEndpoints { endpoints, .. } if !endpoints.is_empty())); + + assert_eq!( + plain.len(), + 2, + "both Plain and no-endpoints-key object must land in plain bucket" + ); + assert_eq!( + endpoint.len(), + 1, + "only the entry with actual endpoint rules goes to endpoint bucket" + ); + + assert!( + plain + .iter() + .any(|e| matches!(e, AllowDomainEntry::Plain(d) if d == "plain.example.com")) + ); + assert!(plain.iter().any(|e| matches!(e, AllowDomainEntry::WithEndpoints { domain, .. } if domain == "object.example.com"))); + assert!(endpoint.iter().any(|e| matches!(e, AllowDomainEntry::WithEndpoints { domain, .. } if domain == "filtered.example.com"))); + } + /// A profile with only `custom_credentials` set (no built-in `credentials`, /// no `network_profile`, no `allow_domain`, no upstream proxy) should still /// activate the proxy so that credential injection works. @@ -763,7 +929,7 @@ mod tests { .expect("prepare_proxy_launch_options"); assert!( - opts.active, + opts.is_active(), "proxy must be active when custom_credentials is non-empty" ); } diff --git a/crates/nono-cli/src/supervised_runtime.rs b/crates/nono-cli/src/supervised_runtime.rs index bcc4f0c2c..c404ed950 100644 --- a/crates/nono-cli/src/supervised_runtime.rs +++ b/crates/nono-cli/src/supervised_runtime.rs @@ -229,12 +229,24 @@ pub(crate) fn execute_supervised_runtime(ctx: SupervisedRuntimeContext<'_>) -> R session_id: &supervisor_session_id, attach_initial_client: !session.detached_start, detach_sequence: session.detach_sequence.as_deref(), - open_url_origins: &proxy.open_url_origins, - open_url_allow_localhost: proxy.open_url_allow_localhost, + open_url_origins: proxy + .open_url + .as_ref() + .map(|o| o.origins.as_slice()) + .unwrap_or(&[]), + open_url_allow_localhost: proxy + .open_url + .as_ref() + .map(|o| o.allow_localhost) + .unwrap_or(false), audit_recorder: audit_recorder.as_ref(), network_audit_events: supervisor_network_audit_events.as_ref(), redaction_policy, - allow_launch_services_active: proxy.allow_launch_services_active, + allow_launch_services_active: proxy + .open_url + .as_ref() + .map(|o| o.allow_launch_services) + .unwrap_or(false), #[cfg(target_os = "linux")] proxy_port: match caps.network_mode() { nono::NetworkMode::ProxyOnly { port, .. } => *port, From b3cbdb0b3ee391dca533d551bb5f6753c1ec41c6 Mon Sep 17 00:00:00 2001 From: Aleks <121458075+SequeI@users.noreply.github.com> Date: Thu, 18 Jun 2026 17:08:57 +0100 Subject: [PATCH 86/91] chore: release v0.64.0 (#1201) Signed-off-by: Aleksy Siek --- CHANGELOG.md | 70 ++++++++++++++++++++++++++++++++++++ Cargo.lock | 6 ++-- bindings/c/Cargo.toml | 2 +- crates/nono-cli/Cargo.toml | 6 ++-- crates/nono-proxy/Cargo.toml | 4 +-- crates/nono/Cargo.toml | 2 +- 6 files changed, 80 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 538423b59..beeadb461 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,75 @@ # Changelog +## [0.64.0] - 2026-06-18 + +### Bug Fixes + +- *(pty)* Ctrl-z hangs when running with a PTY (#1135) ([#1135](https://github.com/always-further/nono/pull/1135)) + +- Proxy should activate with customCredentials set (#1197) ([#1197](https://github.com/always-further/nono/pull/1197)) + +- *(cli)* Use XDG config paths consistently (#1179) ([#1179](https://github.com/always-further/nono/pull/1179)) + +- *(proxy)* Stop allow_domain endpoint route from shadowing credential catch-all (#1132) ([#1132](https://github.com/always-further/nono/pull/1132)) + +- *(proxy)* Respect upstream_proxy in TLS CONNECT intercept path (#1048) (#1091) ([#1091](https://github.com/always-further/nono/pull/1091)) + +- *(policy)* Allow go_runtime to readwrite go-build cache (#1173) ([#1173](https://github.com/always-further/nono/pull/1173)) + +- *(diagnostic)* Replace deprecated nono learn with nono run (#1170) ([#1170](https://github.com/always-further/nono/pull/1170)) + +- *(proxy)* Return 403 + audit for denied non-CONNECT requests (#1077) ([#1077](https://github.com/always-further/nono/pull/1077)) + + +### CI/CD + +- Run integration tests on ubuntu runner (#1185) ([#1185](https://github.com/always-further/nono/pull/1185)) + + +### Dependencies + +- *(deps)* Bump cbindgen from 0.29.3 to 0.29.4 (#1182) ([#1182](https://github.com/always-further/nono/pull/1182)) + +- *(deps)* Bump which from 8.0.2 to 8.0.3 (#1181) ([#1181](https://github.com/always-further/nono/pull/1181)) + +- *(deps)* Add 3-day Dependabot cooldown for cargo and github-actions (#1163) ([#1163](https://github.com/always-further/nono/pull/1163)) + + +### Documentation + +- *(allow-cwd)* Clarify access level is profile-driven (#1180) ([#1180](https://github.com/always-further/nono/pull/1180)) + +- *(credential-injection)* Fix broken Proxy Overrides anchor (#1177) ([#1177](https://github.com/always-further/nono/pull/1177)) + +- *(networking)* Lead with the common cases (#1174) ([#1174](https://github.com/always-further/nono/pull/1174)) + +- *(install)* Add version check and COPR fallback note (#1169) ([#1169](https://github.com/always-further/nono/pull/1169)) + +- *(quickstart)* Fix profiles link (#1168) ([#1168](https://github.com/always-further/nono/pull/1168)) + + +### Features + +- *(diagnostics)* Expose structured diagnostics for library and FFI clients (#1171) ([#1171](https://github.com/always-further/nono/pull/1171)) + +- *(update-check)* Discover ci environments on update (#1113) ([#1113](https://github.com/always-further/nono/pull/1113)) + +- [aws] implement aws_auth config (#1166) ([#1166](https://github.com/always-further/nono/pull/1166)) + +- *(output)* Show blocked macos grants in capability summary (#1178) ([#1178](https://github.com/always-further/nono/pull/1178)) + + +### Miscellaneous + +- Import agents.md inside claude.md (#1153) ([#1153](https://github.com/always-further/nono/pull/1153)) + + +### Refactoring + +- *(proxy)* Separate proxy intent from activation (#1199) ([#1199](https://github.com/always-further/nono/pull/1199)) + +- *(audit)* Move attestation logic to core library (#1148) ([#1148](https://github.com/always-further/nono/pull/1148)) + ## [0.63.0] - 2026-06-15 ### Bug Fixes diff --git a/Cargo.lock b/Cargo.lock index 2c5b75c9c..46a922fbd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2149,7 +2149,7 @@ dependencies = [ [[package]] name = "nono" -version = "0.63.0" +version = "0.64.0" dependencies = [ "der", "getrandom 0.4.2", @@ -2179,7 +2179,7 @@ dependencies = [ [[package]] name = "nono-cli" -version = "0.63.0" +version = "0.64.0" dependencies = [ "aws-lc-rs", "chrono", @@ -2239,7 +2239,7 @@ dependencies = [ [[package]] name = "nono-proxy" -version = "0.63.0" +version = "0.64.0" dependencies = [ "base64", "getrandom 0.4.2", diff --git a/bindings/c/Cargo.toml b/bindings/c/Cargo.toml index 978c22080..5860d59f7 100644 --- a/bindings/c/Cargo.toml +++ b/bindings/c/Cargo.toml @@ -15,7 +15,7 @@ name = "nono_ffi" crate-type = ["cdylib", "staticlib"] [dependencies] -nono = { version = "0.63.0", path = "../../crates/nono", default-features = false } +nono = { version = "0.64.0", path = "../../crates/nono", default-features = false } serde.workspace = true serde_json.workspace = true diff --git a/crates/nono-cli/Cargo.toml b/crates/nono-cli/Cargo.toml index ef42ab859..4c704f107 100644 --- a/crates/nono-cli/Cargo.toml +++ b/crates/nono-cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "nono-cli" -version = "0.63.0" +version = "0.64.0" edition.workspace = true rust-version.workspace = true authors.workspace = true @@ -41,8 +41,8 @@ system-keyring = ["dep:keyring", "nono/system-keyring", "nono-proxy/system-keyri test-trust-overrides = [] [dependencies] -nono = { version = "0.63.0", path = "../nono", default-features = false } -nono-proxy = { version = "0.63.0", path = "../nono-proxy", default-features = false } +nono = { version = "0.64.0", path = "../nono", default-features = false } +nono-proxy = { version = "0.64.0", path = "../nono-proxy", default-features = false } clap = { version = "4", features = ["derive", "env"] } clap_complete = "4" colored = "3" diff --git a/crates/nono-proxy/Cargo.toml b/crates/nono-proxy/Cargo.toml index e725ce984..67d84a830 100644 --- a/crates/nono-proxy/Cargo.toml +++ b/crates/nono-proxy/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "nono-proxy" -version = "0.63.0" +version = "0.64.0" edition.workspace = true rust-version.workspace = true authors.workspace = true @@ -18,7 +18,7 @@ default = ["system-keyring"] system-keyring = ["nono/system-keyring"] [dependencies] -nono = { version = "0.63.0", path = "../nono", default-features = false } +nono = { version = "0.64.0", path = "../nono", default-features = false } tokio = { workspace = true, features = ["sync", "time"] } hyper.workspace = true hyper-util.workspace = true diff --git a/crates/nono/Cargo.toml b/crates/nono/Cargo.toml index a274239de..76b79b6a6 100644 --- a/crates/nono/Cargo.toml +++ b/crates/nono/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "nono" -version = "0.63.0" +version = "0.64.0" edition.workspace = true rust-version.workspace = true authors.workspace = true From fd46264ef47afd43177e08c83e59922aa8c63632 Mon Sep 17 00:00:00 2001 From: Luke Hinds Date: Sat, 20 Jun 2026 06:52:54 +0100 Subject: [PATCH 87/91] refactor(credentials): require explicit activation for custom credentials (#1215) * make custom credentials require explicit activation refactor(credentials): require explicit activation for custom credentials Previously, merely defining custom credentials in a profile would cause them to be automatically activated, leading to the creation of routes and the proxy becoming active even if no services explicitly requested credentials. This change modifies the behavior of `resolve_credentials` and `prepare_proxy_launch_options` to treat custom credentials as definitions or templates. They must now be explicitly listed in the `credentials` section of a policy to be enabled. This aligns their behavior with other credential types, ensuring that the proxy and routes are only activated when genuinely needed. - Custom credential definitions no longer implicitly activate routes. - The proxy will not activate solely due to the presence of custom credential definitions. Fixes: #1214 Signed-off-by: Luke Hinds * fix(proxy): correct proxy active state for custom credentials When defining custom credential sources for a proxy, the `is_active` method would previously evaluate to true if a `CredentialProxyIntent` was present, even if that intent contained an empty list of actual login credentials. This incorrectly suggested the proxy was "active" for traffic authentication. This change updates the `is_active` logic to only return true when explicit login credentials are provided. It also ensures that `CredentialProxyIntent` is always constructed and carried forward if custom credential definitions are specified, enabling network profile overrides even when no actual authentication credentials are required. - update `is_active` to check for non-empty credential list - ensure `CredentialProxyIntent` carries custom definitions regardless of login credentials - add test to confirm custom credentials are carried but do not activate proxy Signed-off-by: Luke Hinds --------- Signed-off-by: Luke Hinds --- crates/nono-cli/src/launch_runtime.rs | 5 ++- crates/nono-cli/src/network_policy.rs | 51 ++++++++------------------- crates/nono-cli/src/proxy_runtime.rs | 22 +++++++----- 3 files changed, 32 insertions(+), 46 deletions(-) diff --git a/crates/nono-cli/src/launch_runtime.rs b/crates/nono-cli/src/launch_runtime.rs index 061b2f79b..f3ad5baa7 100644 --- a/crates/nono-cli/src/launch_runtime.rs +++ b/crates/nono-cli/src/launch_runtime.rs @@ -141,7 +141,10 @@ impl ProxyLaunchOptions { pub(crate) fn is_active(&self) -> bool { self.domain_filter.is_some() || self.endpoint_filter.is_some() - || self.credentials.is_some() + || self + .credentials + .as_ref() + .is_some_and(|credentials| !credentials.credentials.is_empty()) || self.upstream_proxy.is_some() } } diff --git a/crates/nono-cli/src/network_policy.rs b/crates/nono-cli/src/network_policy.rs index d16de7db6..e54d74005 100644 --- a/crates/nono-cli/src/network_policy.rs +++ b/crates/nono-cli/src/network_policy.rs @@ -184,10 +184,10 @@ pub fn resolve_credentials( service_names: &[String], custom_credentials: &HashMap, ) -> Result> { - // Build the full set of names to resolve: explicitly requested names - // (from `credentials`) plus all custom_credentials keys. custom_credentials - // entries are always activated — they don't need to be listed in `credentials`. - let mut all_names: Vec = custom_credentials.keys().cloned().collect(); + // Build the full set of names to resolve from explicitly requested + // credentials. `custom_credentials` defines route templates, but does not + // enable them on its own. + let mut all_names: Vec = Vec::new(); for name in service_names { if !all_names.contains(name) { all_names.push(name.clone()); @@ -1303,10 +1303,10 @@ mod tests { } /// A profile with `custom_credentials` but no `credentials` list passes an - /// empty `service_names` slice. `resolve_credentials` should still return - /// a route for each entry in `custom_credentials`. + /// empty `service_names` slice. `resolve_credentials` should not activate + /// route definitions unless they are explicitly named. #[test] - fn test_resolve_credentials_custom_credentials_without_service_names() { + fn test_resolve_credentials_custom_credentials_without_service_names_returns_no_routes() { use crate::profile::CustomCredentialDef; let json = embedded_network_policy_json(); @@ -1336,25 +1336,17 @@ mod tests { ); let routes = resolve_credentials(&policy, &[], &custom).unwrap(); - assert_eq!( - routes.len(), - 1, - "expected one route for the custom credential, got {}", + assert!( + routes.is_empty(), + "custom credential definitions should remain disabled until explicitly requested, got {} route(s)", routes.len() ); - assert_eq!(routes[0].prefix, "mockhttp"); - assert_eq!(routes[0].upstream, "https://mockhttp.org"); - assert_eq!( - routes[0].env_var, - Some("MOCK_API_KEY".to_string()), - "env_var must be propagated from CustomCredentialDef" - ); } /// When `service_names` is empty but multiple `custom_credentials` are - /// defined, `resolve_credentials` should return a route for every entry. + /// defined, `resolve_credentials` should leave them all disabled. #[test] - fn test_resolve_credentials_all_custom_credentials_without_service_names() { + fn test_resolve_credentials_all_custom_credentials_without_service_names_returns_no_routes() { use crate::profile::CustomCredentialDef; let json = embedded_network_policy_json(); @@ -1406,23 +1398,10 @@ mod tests { let routes = resolve_credentials(&policy, &[], &custom).unwrap(); - assert_eq!( - routes.len(), - 2, - "expected one route per custom credential, got {}", - routes.len() - ); - - let prefixes: Vec<&str> = routes.iter().map(|r| r.prefix.as_str()).collect(); assert!( - prefixes.contains(&"svc_a"), - "route for svc_a must be present; got: {:?}", - prefixes - ); - assert!( - prefixes.contains(&"svc_b"), - "route for svc_b must be present; got: {:?}", - prefixes + routes.is_empty(), + "custom credential definitions should remain disabled until explicitly requested, got {} route(s)", + routes.len() ); } } diff --git a/crates/nono-cli/src/proxy_runtime.rs b/crates/nono-cli/src/proxy_runtime.rs index 5ce98ef48..0522e1757 100644 --- a/crates/nono-cli/src/proxy_runtime.rs +++ b/crates/nono-cli/src/proxy_runtime.rs @@ -60,9 +60,8 @@ pub(crate) fn prepare_proxy_launch_options( bypass }; - let has_custom_credentials = !prepared.custom_credentials.is_empty(); let has_domain_filter = network_profile.is_some() || !allow_domain.is_empty(); - let has_credentials = !credentials.is_empty() || has_custom_credentials; + let has_credentials = !credentials.is_empty(); let would_activate = has_domain_filter || has_credentials || upstream_proxy_addr.is_some(); if matches!(prepared.caps.network_mode(), nono::NetworkMode::Blocked) { @@ -116,7 +115,7 @@ pub(crate) fn prepare_proxy_launch_options( None }; - let credentials_intent = if has_credentials { + let credentials_intent = if has_credentials || !prepared.custom_credentials.is_empty() { Some(CredentialProxyIntent { credentials, custom_credentials: prepared.custom_credentials.clone(), @@ -856,11 +855,12 @@ mod tests { assert!(endpoint.iter().any(|e| matches!(e, AllowDomainEntry::WithEndpoints { domain, .. } if domain == "filtered.example.com"))); } - /// A profile with only `custom_credentials` set (no built-in `credentials`, - /// no `network_profile`, no `allow_domain`, no upstream proxy) should still - /// activate the proxy so that credential injection works. + /// A profile with only `custom_credentials` set (no enabled `credentials`, + /// no `network_profile`, no `allow_domain`, no upstream proxy) should not + /// activate the proxy. Custom credential entries are route definitions, not + /// enabled routes. #[test] - fn test_proxy_is_active_when_only_custom_credentials_are_set() { + fn test_proxy_is_inactive_when_only_custom_credentials_are_set() { use crate::profile::CustomCredentialDef; use crate::sandbox_prepare::PreparedSandbox; use nono::CapabilitySet; @@ -929,8 +929,12 @@ mod tests { .expect("prepare_proxy_launch_options"); assert!( - opts.is_active(), - "proxy must be active when custom_credentials is non-empty" + !opts.is_active(), + "proxy must stay inactive when only custom credential definitions are present" + ); + assert!( + opts.credentials.is_some(), + "custom credential definitions should still be carried for network profile overrides" ); } } From f4a9c638ff64d8c9eb843eb328db45daeb3194ca Mon Sep 17 00:00:00 2001 From: Luke Hinds Date: Sat, 20 Jun 2026 07:45:58 +0100 Subject: [PATCH 88/91] chore: release v0.64.1 (#1217) --- CHANGELOG.md | 6 ++++++ Cargo.lock | 6 +++--- bindings/c/Cargo.toml | 2 +- crates/nono-cli/Cargo.toml | 6 +++--- crates/nono-proxy/Cargo.toml | 4 ++-- crates/nono/Cargo.toml | 2 +- 6 files changed, 16 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index beeadb461..cfb5eefd5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## [0.64.1] - 2026-06-20 + +### Refactoring + +- *(credentials)* Require explicit activation for custom credentials (#1215) ([#1215](https://github.com/always-further/nono/pull/1215)) + ## [0.64.0] - 2026-06-18 ### Bug Fixes diff --git a/Cargo.lock b/Cargo.lock index 46a922fbd..4af940792 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2149,7 +2149,7 @@ dependencies = [ [[package]] name = "nono" -version = "0.64.0" +version = "0.64.1" dependencies = [ "der", "getrandom 0.4.2", @@ -2179,7 +2179,7 @@ dependencies = [ [[package]] name = "nono-cli" -version = "0.64.0" +version = "0.64.1" dependencies = [ "aws-lc-rs", "chrono", @@ -2239,7 +2239,7 @@ dependencies = [ [[package]] name = "nono-proxy" -version = "0.64.0" +version = "0.64.1" dependencies = [ "base64", "getrandom 0.4.2", diff --git a/bindings/c/Cargo.toml b/bindings/c/Cargo.toml index 5860d59f7..04cd3ca99 100644 --- a/bindings/c/Cargo.toml +++ b/bindings/c/Cargo.toml @@ -15,7 +15,7 @@ name = "nono_ffi" crate-type = ["cdylib", "staticlib"] [dependencies] -nono = { version = "0.64.0", path = "../../crates/nono", default-features = false } +nono = { version = "0.64.1", path = "../../crates/nono", default-features = false } serde.workspace = true serde_json.workspace = true diff --git a/crates/nono-cli/Cargo.toml b/crates/nono-cli/Cargo.toml index 4c704f107..c1d04dc77 100644 --- a/crates/nono-cli/Cargo.toml +++ b/crates/nono-cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "nono-cli" -version = "0.64.0" +version = "0.64.1" edition.workspace = true rust-version.workspace = true authors.workspace = true @@ -41,8 +41,8 @@ system-keyring = ["dep:keyring", "nono/system-keyring", "nono-proxy/system-keyri test-trust-overrides = [] [dependencies] -nono = { version = "0.64.0", path = "../nono", default-features = false } -nono-proxy = { version = "0.64.0", path = "../nono-proxy", default-features = false } +nono = { version = "0.64.1", path = "../nono", default-features = false } +nono-proxy = { version = "0.64.1", path = "../nono-proxy", default-features = false } clap = { version = "4", features = ["derive", "env"] } clap_complete = "4" colored = "3" diff --git a/crates/nono-proxy/Cargo.toml b/crates/nono-proxy/Cargo.toml index 67d84a830..48105521b 100644 --- a/crates/nono-proxy/Cargo.toml +++ b/crates/nono-proxy/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "nono-proxy" -version = "0.64.0" +version = "0.64.1" edition.workspace = true rust-version.workspace = true authors.workspace = true @@ -18,7 +18,7 @@ default = ["system-keyring"] system-keyring = ["nono/system-keyring"] [dependencies] -nono = { version = "0.64.0", path = "../nono", default-features = false } +nono = { version = "0.64.1", path = "../nono", default-features = false } tokio = { workspace = true, features = ["sync", "time"] } hyper.workspace = true hyper-util.workspace = true diff --git a/crates/nono/Cargo.toml b/crates/nono/Cargo.toml index 76b79b6a6..a1dba799f 100644 --- a/crates/nono/Cargo.toml +++ b/crates/nono/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "nono" -version = "0.64.0" +version = "0.64.1" edition.workspace = true rust-version.workspace = true authors.workspace = true From fddd8e5541bf651b215636d645cdef2bbcac249c Mon Sep 17 00:00:00 2001 From: Luke Hinds Date: Sat, 25 Apr 2026 10:12:46 +0100 Subject: [PATCH 89/91] feat: add interactive network approval and domain rejection - Deny/Approve buttons with Once/Session/Always duration dropdown - Blacklist (reject_domain) hosts auto-denied without prompting - Once approval bypasses runtime filter re-check - Deny(Session/Always) adds to runtime deny filter and persists - Tests for filter blacklist, deny scope, approval scope --- crates/nono-cli/src/launch_runtime.rs | 3 +- crates/nono-cli/src/main.rs | 4 + crates/nono-cli/src/network_approval.rs | 225 ++---------------------- crates/nono-cli/src/profile_runtime.rs | 8 + crates/nono-cli/src/proxy_runtime.rs | 25 ++- crates/nono-cli/src/sandbox_prepare.rs | 8 + crates/nono-proxy/src/config.rs | 7 + crates/nono-proxy/src/connect.rs | 33 +--- crates/nono-proxy/src/filter.rs | 9 + crates/nono-proxy/src/server.rs | 1 + crates/nono/src/lib.rs | 2 +- crates/nono/src/net_filter.rs | 101 +++++++++++ crates/nono/src/supervisor/types.rs | 5 +- 13 files changed, 177 insertions(+), 254 deletions(-) diff --git a/crates/nono-cli/src/launch_runtime.rs b/crates/nono-cli/src/launch_runtime.rs index f3ad5baa7..085846940 100644 --- a/crates/nono-cli/src/launch_runtime.rs +++ b/crates/nono-cli/src/launch_runtime.rs @@ -135,6 +135,8 @@ pub(crate) struct ProxyLaunchOptions { /// Propagated to `ProxyConfig.strict_filter` so the filter denies /// unlisted hosts instead of falling back to allow-all. pub(crate) network_block: bool, + pub(crate) network_approval_mode: crate::network_approval::NetworkApprovalMode, + pub(crate) network_approval_timeout_secs: u64, } impl ProxyLaunchOptions { @@ -147,7 +149,6 @@ impl ProxyLaunchOptions { .is_some_and(|credentials| !credentials.credentials.is_empty()) || self.upstream_proxy.is_some() } -} #[derive(Clone)] pub(crate) struct ExecutionFlags { diff --git a/crates/nono-cli/src/main.rs b/crates/nono-cli/src/main.rs index f1295cc55..f6b77b29a 100644 --- a/crates/nono-cli/src/main.rs +++ b/crates/nono-cli/src/main.rs @@ -296,6 +296,8 @@ mod tests { profile_network_approval_timeout_secs: None, set_vars: None, network_block_requested: false, + profile_network_approval_mode: None, + profile_network_approval_timeout_secs: None, }; let effective = resolve_effective_proxy_settings(&args, &prepared); @@ -353,6 +355,8 @@ mod tests { profile_network_approval_timeout_secs: None, set_vars: None, network_block_requested: false, + profile_network_approval_mode: None, + profile_network_approval_timeout_secs: None, }; let effective = resolve_effective_proxy_settings(&args, &prepared); diff --git a/crates/nono-cli/src/network_approval.rs b/crates/nono-cli/src/network_approval.rs index 1a4bbe404..5c18b863c 100644 --- a/crates/nono-cli/src/network_approval.rs +++ b/crates/nono-cli/src/network_approval.rs @@ -11,11 +11,6 @@ use std::collections::HashMap; use std::sync::{Arc, Mutex}; -struct PendingEntry { - notify: Arc, - decision: Mutex>, -} - use nono::{ ApprovalBackend, ApprovalDecision, ApprovalScope, CapabilityRequest, NetworkApprovalDecision, NetworkApprovalRequest, Result, RuntimeHostFilter, @@ -55,7 +50,7 @@ pub struct NetworkApprovalBackend { mode: NetworkApprovalMode, runtime_filter: RuntimeHostFilter, timeout_secs: u64, - pending: Arc>>>, + pending: Arc>>>, config_writer: Option, } @@ -265,42 +260,22 @@ impl NetworkApprovalBackend { let host = request.host.clone(); tracing::info!("Network approval requested for host: {host}"); - let filter_result = self.runtime_filter.check_host(&host, &[]); - if filter_result.is_allowed() { - tracing::info!("Host {host} already allowed by runtime filter — skipping dialog"); - return NetworkApprovalDecision::Granted(ApprovalScope::Session); - } - - if matches!( - filter_result, - nono::net_filter::FilterResult::DenyHost { .. } - | nono::net_filter::FilterResult::DenyLinkLocal { .. } - ) { - tracing::info!("Host {host} explicitly denied by runtime filter"); - return NetworkApprovalDecision::Denied { - reason: "Host denied by session policy".to_string(), - }; - } - - let (entry, is_first) = { + let (notify, is_first) = { let mut pending = self.pending.lock().expect("pending lock poisoned"); if let Some(existing) = pending.get(&host) { tracing::info!("Host {host} has pending approval — waiting on existing dialog"); (existing.clone(), false) } else { - let entry = Arc::new(PendingEntry { - notify: Arc::new(tokio::sync::Notify::new()), - decision: Mutex::new(None), - }); - pending.insert(host.clone(), entry.clone()); - (entry, true) + let n = Arc::new(tokio::sync::Notify::new()); + pending.insert(host.clone(), n.clone()); + (n, true) } }; if !is_first { let _ = tokio::time::timeout( std::time::Duration::from_secs(self.timeout_secs), - entry.notify.notified(), + notify.notified(), ) .await; @@ -310,14 +285,7 @@ impl NetworkApprovalBackend { reason: "Approval timed out".to_string(), } } else { - let decision = entry - .decision - .lock() - .expect("decision lock poisoned") - .clone(); - decision.unwrap_or_else(|| NetworkApprovalDecision::Denied { - reason: "Approval result unavailable".to_string(), - }) + NetworkApprovalDecision::Granted(ApprovalScope::Session) }; } @@ -394,15 +362,11 @@ impl NetworkApprovalBackend { } }; - { - let mut slot = entry.decision.lock().expect("decision lock poisoned"); - *slot = Some(decision.clone()); - } self.pending .lock() .expect("pending lock poisoned") .remove(&host); - entry.notify.notify_waiters(); + notify.notify_waiters(); tracing::info!("Network approval decision for {host}: {:?}", decision); decision @@ -422,34 +386,14 @@ impl ApprovalBackend for NetworkApprovalBackend { ) -> Result { let host = request.host.clone(); - let filter_result = self.runtime_filter.check_host(&host, &[]); - if filter_result.is_allowed() { - tracing::info!("Host {host} already allowed by runtime filter — skipping dialog"); - return Ok(NetworkApprovalDecision::Granted(ApprovalScope::Session)); - } - - if matches!( - filter_result, - nono::net_filter::FilterResult::DenyHost { .. } - | nono::net_filter::FilterResult::DenyLinkLocal { .. } - ) { - tracing::info!("Host {host} explicitly denied by runtime filter"); - return Ok(NetworkApprovalDecision::Denied { - reason: "Host denied by session policy".to_string(), - }); - } - - let (entry, is_first) = { + let (notify, is_first) = { let mut pending = self.pending.lock().expect("pending lock poisoned"); if let Some(existing) = pending.get(&host) { (existing.clone(), false) } else { - let entry = Arc::new(PendingEntry { - notify: Arc::new(tokio::sync::Notify::new()), - decision: Mutex::new(None), - }); - pending.insert(host.clone(), entry.clone()); - (entry, true) + let n = Arc::new(tokio::sync::Notify::new()); + pending.insert(host.clone(), n.clone()); + (n, true) } }; @@ -457,7 +401,7 @@ impl ApprovalBackend for NetworkApprovalBackend { sync_runtime().block_on(async { let _ = tokio::time::timeout( std::time::Duration::from_secs(self.timeout_secs), - entry.notify.notified(), + notify.notified(), ) .await; }); @@ -468,14 +412,7 @@ impl ApprovalBackend for NetworkApprovalBackend { reason: "Approval timed out".to_string(), }) } else { - let decision = entry - .decision - .lock() - .expect("decision lock poisoned") - .clone(); - Ok(decision.unwrap_or_else(|| NetworkApprovalDecision::Denied { - reason: "Approval result unavailable".to_string(), - })) + Ok(NetworkApprovalDecision::Granted(ApprovalScope::Session)) }; } @@ -554,17 +491,12 @@ impl ApprovalBackend for NetworkApprovalBackend { } }; - { - let mut slot = entry.decision.lock().expect("decision lock poisoned"); - *slot = Some(decision.clone()); - } self.pending .lock() .expect("pending lock poisoned") .remove(&host); - entry.notify.notify_waiters(); + notify.notify_waiters(); - tracing::info!("Network approval decision for {host}: {:?}", decision); Ok(decision) } @@ -1005,7 +937,7 @@ mod tests { let runtime_filter = deny_default_filter(); // Simulate Deny(Once): host NOT added to deny filter - // Next request would prompt again + // Next request to same host would prompt again assert!( !runtime_filter .check_host("once-denied.com", &[]) @@ -1015,129 +947,4 @@ mod tests { // But it's not explicitly in the deny list either — // the next request would go through the same approval flow } - - #[tokio::test] - async fn test_dedup_waiters_receive_deny_when_first_request_is_denied() { - let runtime_filter = deny_default_filter(); - let backend = NetworkApprovalBackend::new( - NetworkApprovalMode::Ask, - runtime_filter, - 5, - None, - ); - let backend = Arc::new(backend); - let host = "dedup-deny-test.com"; - - let entry = Arc::new(PendingEntry { - notify: Arc::new(tokio::sync::Notify::new()), - decision: Mutex::new(None), - }); - { - let mut pending = backend.pending.lock().expect("pending lock poisoned"); - pending.insert(host.to_string(), Arc::clone(&entry)); - } - - let request_b = make_request(host); - let backend_b = Arc::clone(&backend); - - let handle_b = tokio::spawn(async move { - backend_b - .request_network_approval_async(&request_b) - .await - }); - - tokio::task::yield_now().await; - - { - let mut slot = entry.decision.lock().expect("decision lock poisoned"); - *slot = Some(NetworkApprovalDecision::Denied { - reason: "User denied the request".to_string(), - }); - } - { - let mut pending = backend.pending.lock().expect("pending lock poisoned"); - pending.remove(host); - } - entry.notify.notify_waiters(); - - let decision_b = handle_b.await.expect("handle_b panicked"); - - assert!( - decision_b.is_denied(), - "waiting request should be denied when the first request was denied, but got: {:?}", - decision_b - ); - } - - #[tokio::test] - async fn test_dedup_waiters_receive_grant_when_first_request_is_approved() { - let runtime_filter = deny_default_filter(); - let backend = NetworkApprovalBackend::new( - NetworkApprovalMode::Ask, - runtime_filter, - 5, - None, - ); - let backend = Arc::new(backend); - let host = "dedup-approve-test.com"; - - let entry = Arc::new(PendingEntry { - notify: Arc::new(tokio::sync::Notify::new()), - decision: Mutex::new(None), - }); - { - let mut pending = backend.pending.lock().expect("pending lock poisoned"); - pending.insert(host.to_string(), Arc::clone(&entry)); - } - - let request_b = make_request(host); - let backend_b = Arc::clone(&backend); - - let handle_b = tokio::spawn(async move { - backend_b - .request_network_approval_async(&request_b) - .await - }); - - tokio::task::yield_now().await; - - { - let mut slot = entry.decision.lock().expect("decision lock poisoned"); - *slot = Some(NetworkApprovalDecision::Granted(ApprovalScope::Session)); - } - { - let mut pending = backend.pending.lock().expect("pending lock poisoned"); - pending.remove(host); - } - entry.notify.notify_waiters(); - - let decision_b = handle_b.await.expect("handle_b panicked"); - - assert!( - matches!(decision_b, NetworkApprovalDecision::Granted(ApprovalScope::Session)), - "waiting request should be granted when the first request was approved, but got: {:?}", - decision_b - ); - } - - #[tokio::test] - async fn test_already_approved_host_skips_dialog() { - let runtime_filter = deny_default_filter(); - runtime_filter.add_host("pre-approved.com").unwrap(); - let backend = NetworkApprovalBackend::new( - NetworkApprovalMode::Ask, - runtime_filter, - 5, - None, - ); - - let request = make_request("pre-approved.com"); - let decision = backend.request_network_approval_async(&request).await; - - assert!( - matches!(decision, NetworkApprovalDecision::Granted(ApprovalScope::Session)), - "host already in runtime filter should be granted without dialog, but got: {:?}", - decision - ); - } } diff --git a/crates/nono-cli/src/profile_runtime.rs b/crates/nono-cli/src/profile_runtime.rs index 5aea04a49..9886dadec 100644 --- a/crates/nono-cli/src/profile_runtime.rs +++ b/crates/nono-cli/src/profile_runtime.rs @@ -38,6 +38,8 @@ pub(crate) struct PreparedProfile { /// when the profile has no `set_vars`. Values are expanded with /// [`profile::expand_vars`] at prepare time. pub(crate) set_vars: Option>, + pub(crate) network_approval_mode: Option, + pub(crate) network_approval_timeout_secs: Option, } #[derive(Clone, Copy)] @@ -714,6 +716,12 @@ fn prepare_profile_with_options( .as_ref() .and_then(|profile| profile.network.approval_timeout_secs), set_vars: expand_profile_set_vars(loaded_profile.as_ref(), workdir)?, + network_approval_mode: loaded_profile + .as_ref() + .and_then(|profile| profile.network.approval_mode.clone()), + network_approval_timeout_secs: loaded_profile + .as_ref() + .and_then(|profile| profile.network.approval_timeout_secs), loaded_profile, }) } diff --git a/crates/nono-cli/src/proxy_runtime.rs b/crates/nono-cli/src/proxy_runtime.rs index 0522e1757..44b91246d 100644 --- a/crates/nono-cli/src/proxy_runtime.rs +++ b/crates/nono-cli/src/proxy_runtime.rs @@ -164,6 +164,9 @@ pub(crate) fn prepare_proxy_launch_options( None }; + let approval_mode = + resolve_network_approval_mode(args, prepared.profile_network_approval_mode.as_deref()); + let opts = ProxyLaunchOptions { domain_filter, endpoint_filter, @@ -179,6 +182,10 @@ pub(crate) fn prepare_proxy_launch_options( ), profile_name: args.profile.clone(), network_block: prepared.network_block_requested, + network_approval_mode: approval_mode, + network_approval_timeout_secs: resolve_approval_timeout_secs( + prepared.profile_network_approval_timeout_secs, + ), }; // Infra-only flags make no sense without an activating proxy feature. @@ -404,6 +411,11 @@ pub(crate) fn build_proxy_config_from_flags( let mut proxy_config = network_policy::build_proxy_config(&resolved, &expanded_allow_domain, reject_domain); proxy_config.strict_filter = proxy.network_block; + if let Some(ref domain_filter) = proxy.domain_filter { + if !domain_filter.reject_domain.is_empty() { + proxy_config.rejected_hosts = domain_filter.reject_domain.clone(); + } + } if let Some(ref upstream) = proxy.upstream_proxy { proxy_config.external_proxy = Some(nono_proxy::config::ExternalProxyConfig { @@ -481,15 +493,10 @@ pub(crate) fn start_proxy_runtime( let (tx, mut rx) = tokio::sync::mpsc::channel::(16); - let config_writer = match proxy - .domain_filter - .as_ref() - .and_then(|d| d.network_profile.as_deref()) - .or_else(|| proxy.profile_name.as_deref()) - { - Some(name) => Some(crate::network_approval::ConfigWriter::new(name)), - None => Some(crate::network_approval::ConfigWriter::new("default")), - }; + let config_writer = proxy + .network_profile + .as_deref() + .map(crate::network_approval::ConfigWriter::new); let backend = NetworkApprovalBackend::new( proxy.network_approval_mode, diff --git a/crates/nono-cli/src/sandbox_prepare.rs b/crates/nono-cli/src/sandbox_prepare.rs index 16f13d012..5f5c4ec9e 100644 --- a/crates/nono-cli/src/sandbox_prepare.rs +++ b/crates/nono-cli/src/sandbox_prepare.rs @@ -450,6 +450,8 @@ pub(crate) struct PreparedSandbox { /// through because a CLI proxy flag (e.g. `--credential`) may later /// override `caps` to `ProxyOnly`, losing the original intent. pub(crate) network_block_requested: bool, + pub(crate) profile_network_approval_mode: Option, + pub(crate) profile_network_approval_timeout_secs: Option, } fn resolved_workdir(args: &SandboxArgs) -> PathBuf { @@ -1085,6 +1087,8 @@ pub(crate) fn prepare_sandbox(args: &SandboxArgs, silent: bool) -> Result Result Result, + /// Reverse proxy credential routes. #[serde(default)] pub routes: Vec, @@ -157,6 +163,7 @@ impl Default for ProxyConfig { allowed_hosts: Vec::new(), rejected_hosts: Vec::new(), strict_filter: false, + rejected_hosts: Vec::new(), routes: Vec::new(), external_proxy: None, direct_connect_ports: Vec::new(), diff --git a/crates/nono-proxy/src/connect.rs b/crates/nono-proxy/src/connect.rs index 066429c59..af3c410ed 100644 --- a/crates/nono-proxy/src/connect.rs +++ b/crates/nono-proxy/src/connect.rs @@ -86,7 +86,6 @@ pub async fn handle_connect_with_approval( audit::log_denied( ctx.audit_log, audit::ProxyMode::Connect, - &audit::EventContext::default(), &host, port, &reason, @@ -100,7 +99,6 @@ pub async fn handle_connect_with_approval( audit::log_allowed( ctx.audit_log, audit::ProxyMode::Connect, - &audit::EventContext::default(), &host, port, "CONNECT", @@ -113,7 +111,6 @@ pub async fn handle_connect_with_approval( if matches!( primary_check.result, nono::net_filter::FilterResult::DenyHost { .. } - | nono::net_filter::FilterResult::DenyLinkLocal { .. } ) { let reason = primary_check.result.reason(); debug!( @@ -123,7 +120,6 @@ pub async fn handle_connect_with_approval( audit::log_denied( ctx.audit_log, audit::ProxyMode::Connect, - &audit::EventContext::default(), &host, port, &reason, @@ -144,7 +140,6 @@ pub async fn handle_connect_with_approval( audit::log_denied( ctx.audit_log, audit::ProxyMode::Connect, - &audit::EventContext::default(), &host, port, &reason, @@ -158,7 +153,6 @@ pub async fn handle_connect_with_approval( audit::log_allowed( ctx.audit_log, audit::ProxyMode::Connect, - &audit::EventContext::default(), &host, port, "CONNECT (runtime)", @@ -171,7 +165,6 @@ pub async fn handle_connect_with_approval( if matches!( runtime_check.result, nono::net_filter::FilterResult::DenyHost { .. } - | nono::net_filter::FilterResult::DenyLinkLocal { .. } ) { let reason = runtime_check.result.reason(); debug!( @@ -181,7 +174,6 @@ pub async fn handle_connect_with_approval( audit::log_denied( ctx.audit_log, audit::ProxyMode::Connect, - &audit::EventContext::default(), &host, port, &reason, @@ -211,7 +203,6 @@ pub async fn handle_connect_with_approval( audit::log_denied( ctx.audit_log, audit::ProxyMode::Connect, - &audit::EventContext::default(), &host, port, reason, @@ -230,7 +221,6 @@ pub async fn handle_connect_with_approval( audit::log_denied( ctx.audit_log, audit::ProxyMode::Connect, - &audit::EventContext::default(), &host, port, reason, @@ -257,26 +247,12 @@ pub async fn handle_connect_with_approval( ); let resolved = if scope == ApprovalScope::Once { - let once_check = ctx.runtime_filter.check_host(&host, port).await?; - if !once_check.result.is_allowed() { - let reason = once_check.result.reason(); - audit::log_denied( - ctx.audit_log, - audit::ProxyMode::Connect, - &audit::EventContext::default(), - &host, - port, - &reason, - ); - send_response(stream, 403, &format!("Forbidden: {}", reason)).await?; - return Err(ProxyError::HostDenied { host, reason }); - } - if once_check.resolved_addrs.is_empty() { + let addrs = ctx.runtime_filter.resolve_host(&host, port).await?; + if addrs.is_empty() { let reason = "Could not resolve host after approval"; audit::log_denied( ctx.audit_log, audit::ProxyMode::Connect, - &audit::EventContext::default(), &host, port, reason, @@ -287,7 +263,7 @@ pub async fn handle_connect_with_approval( reason: reason.to_string(), }); } - once_check.resolved_addrs + addrs } else { let runtime_check = ctx.runtime_filter.check_host(&host, port).await?; let resolved = &runtime_check.resolved_addrs; @@ -296,7 +272,6 @@ pub async fn handle_connect_with_approval( audit::log_denied( ctx.audit_log, audit::ProxyMode::Connect, - &audit::EventContext::default(), &host, port, reason, @@ -315,7 +290,6 @@ pub async fn handle_connect_with_approval( audit::log_allowed( ctx.audit_log, audit::ProxyMode::Connect, - &audit::EventContext::default(), &host, port, "CONNECT (approved)", @@ -333,7 +307,6 @@ pub async fn handle_connect_with_approval( audit::log_denied( ctx.audit_log, audit::ProxyMode::Connect, - &audit::EventContext::default(), &host, port, display_reason, diff --git a/crates/nono-proxy/src/filter.rs b/crates/nono-proxy/src/filter.rs index 720f638e9..98ecbe947 100644 --- a/crates/nono-proxy/src/filter.rs +++ b/crates/nono-proxy/src/filter.rs @@ -55,6 +55,15 @@ impl ProxyFilter { } } + /// Create a new proxy filter with both allowed and rejected hosts. + #[must_use] + pub fn new_with_reject(allowed_hosts: &[String], rejected_hosts: &[String]) -> Self { + Self { + inner: HostFilter::new_with_reject(allowed_hosts, rejected_hosts), + } + } + } + /// Create a filter that allows all hosts (except cloud metadata). #[must_use] pub fn allow_all() -> Self { diff --git a/crates/nono-proxy/src/server.rs b/crates/nono-proxy/src/server.rs index 92e94a7bf..804d37d05 100644 --- a/crates/nono-proxy/src/server.rs +++ b/crates/nono-proxy/src/server.rs @@ -490,6 +490,7 @@ pub async fn start_with_approval( let loaded_routes = credential_store.loaded_prefixes(); // Build filter. Strict mode treats an empty allowlist as deny-all. + // With rejected hosts, use new_with_reject for deny-list support. let filter = if config.strict_filter { ProxyFilter::new_strict(&config.allowed_hosts) } else if config.allowed_hosts.is_empty() && config.rejected_hosts.is_empty() { diff --git a/crates/nono/src/lib.rs b/crates/nono/src/lib.rs index a5f2cc1ad..ffbe4bc01 100644 --- a/crates/nono/src/lib.rs +++ b/crates/nono/src/lib.rs @@ -95,7 +95,7 @@ pub use scrub::{ pub use state::SandboxState; pub use supervisor::{ ApprovalBackend, ApprovalDecision, ApprovalScope, CapabilityRequest, NetworkApprovalDecision, - NetworkApprovalRequest, SupervisorListener, SupervisorSocket, UrlOpenRequest, + NetworkApprovalRequest, SupervisorSocket, UrlOpenRequest, }; pub use trust::{ Enforcement, IncludePatterns, Publisher, SignerIdentity, TrustPolicy, VerificationOutcome, diff --git a/crates/nono/src/net_filter.rs b/crates/nono/src/net_filter.rs index 9114335d0..ccc9a9ff5 100644 --- a/crates/nono/src/net_filter.rs +++ b/crates/nono/src/net_filter.rs @@ -122,6 +122,8 @@ pub struct HostFilter { deny_suffixes: Vec, /// When true, an empty allowlist denies instead of allowing. strict: bool, + /// Wildcard suffixes that are always denied (e.g., ".evil.com") + deny_suffixes: Vec, } impl HostFilter { @@ -220,6 +222,27 @@ impl HostFilter { deny_hosts: DENY_HOSTS.iter().map(|s| s.to_lowercase()).collect(), deny_suffixes: Vec::new(), strict: false, + deny_suffixes: Vec::new(), + } + } + + /// Create a host filter that denies everything by default. + /// + /// Unlike [`allow_all()`](Self::allow_all), no host is permitted unless + /// explicitly added via [`add_host()`](Self::add_host) or + /// [`add_suffix()`](Self::add_suffix). Cloud metadata endpoints remain + /// unconditionally denied. + /// + /// This is used for runtime approval filters where the filter starts + /// empty and only approved hosts are added dynamically. + #[must_use] + pub fn deny_all() -> Self { + Self { + allowed_hosts: vec!["__deny_all_sentinel__.invalid".to_string()], + allowed_suffixes: Vec::new(), + deny_hosts: DENY_HOSTS.iter().map(|s| s.to_lowercase()).collect(), + deny_suffixes: Vec::new(), + strict: true, } } @@ -265,6 +288,7 @@ impl HostFilter { } // 4. Empty allowlist: deny when strict, allow otherwise. + // If no allowlist is configured (allow_all mode), allow. if self.allowed_hosts.is_empty() && self.allowed_suffixes.is_empty() { if self.strict { return FilterResult::DenyNotAllowed { @@ -752,4 +776,81 @@ mod tests { let result = filter.check_host("example.com", &public_ip()); assert!(matches!(result, FilterResult::Allow)); } + + #[test] + fn test_new_with_reject_exact() { + let filter = + HostFilter::new_with_reject(&["api.openai.com".to_string()], &["evil.com".to_string()]); + assert!(filter + .check_host("api.openai.com", &public_ip()) + .is_allowed()); + assert!(!filter.check_host("evil.com", &public_ip()).is_allowed()); + assert!(matches!( + filter.check_host("evil.com", &public_ip()), + FilterResult::DenyHost { .. } + )); + } + + #[test] + fn test_reject_overrides_allow() { + let filter = + HostFilter::new_with_reject(&["evil.com".to_string()], &["evil.com".to_string()]); + assert!( + !filter.check_host("evil.com", &public_ip()).is_allowed(), + "deny should override allow for same host" + ); + } + + #[test] + fn test_reject_wildcard_overrides_allow() { + let filter = + HostFilter::new_with_reject(&["api.evil.com".to_string()], &["*.evil.com".to_string()]); + assert!( + !filter.check_host("api.evil.com", &public_ip()).is_allowed(), + "deny wildcard should override allow for subdomain" + ); + } + + #[test] + fn test_add_deny_host() { + let mut filter = HostFilter::new(&["api.openai.com".to_string(), "evil.com".to_string()]); + filter.add_deny_host("evil.com").expect("add_deny_host"); + assert!( + !filter.check_host("evil.com", &public_ip()).is_allowed(), + "deny should override allow" + ); + } + + #[test] + fn test_add_deny_suffix() { + let mut filter = + HostFilter::new(&["api.example.com".to_string(), "track.evil.com".to_string()]); + filter + .add_deny_suffix("*.evil.com") + .expect("add_deny_suffix"); + assert!( + !filter + .check_host("track.evil.com", &public_ip()) + .is_allowed(), + "deny suffix should override allow" + ); + assert!( + !filter + .check_host("other.evil.com", &public_ip()) + .is_allowed(), + "deny suffix should match other subdomains" + ); + } + + #[test] + fn test_deny_suffix_does_not_match_bare_domain() { + let mut filter = HostFilter::new(&["evil.com".to_string()]); + filter + .add_deny_suffix("*.evil.com") + .expect("add_deny_suffix"); + assert!( + filter.check_host("evil.com", &public_ip()).is_allowed(), + "deny suffix should not match bare domain" + ); + } } diff --git a/crates/nono/src/supervisor/types.rs b/crates/nono/src/supervisor/types.rs index 40170de6f..3b2a409dd 100644 --- a/crates/nono/src/supervisor/types.rs +++ b/crates/nono/src/supervisor/types.rs @@ -187,9 +187,6 @@ impl NetworkApprovalDecision { /// Returns true if access was denied. #[must_use] pub fn is_denied(&self) -> bool { - matches!( - self, - NetworkApprovalDecision::Denied { .. } | NetworkApprovalDecision::Timeout - ) + matches!(self, NetworkApprovalDecision::Denied { .. }) } } From 631255fb2ccd169d5be4dbb30dd8c0d4d9b5e1c9 Mon Sep 17 00:00:00 2001 From: Matthias Klass Date: Sun, 21 Jun 2026 11:21:56 +0200 Subject: [PATCH 90/91] fix: persist approved hosts to correct profile file across restarts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ConfigWriter was using network_profile (a network policy field) to determine which profile file to write persisted hosts to. When network_profile was None (the default), it fell back to writing to 'default' profile — but 'default' is never loaded on restart unless the user explicitly runs --profile default. Now ProxyLaunchOptions carries profile_name (from --profile CLI arg). ConfigWriter uses this to write to the correct user profile file (e.g., ~/.config/nono/profiles/claude-code.json) that will be loaded on the next restart. Signed-off-by: klassm --- crates/nono-cli/src/launch_runtime.rs | 3 +++ crates/nono-cli/src/proxy_runtime.rs | 9 +++++---- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/crates/nono-cli/src/launch_runtime.rs b/crates/nono-cli/src/launch_runtime.rs index 085846940..26a269e9b 100644 --- a/crates/nono-cli/src/launch_runtime.rs +++ b/crates/nono-cli/src/launch_runtime.rs @@ -137,6 +137,9 @@ pub(crate) struct ProxyLaunchOptions { pub(crate) network_block: bool, pub(crate) network_approval_mode: crate::network_approval::NetworkApprovalMode, pub(crate) network_approval_timeout_secs: u64, + /// The nono profile name (from `--profile`), used by ConfigWriter + /// to persist approved hosts to the correct user profile file. + pub(crate) profile_name: Option, } impl ProxyLaunchOptions { diff --git a/crates/nono-cli/src/proxy_runtime.rs b/crates/nono-cli/src/proxy_runtime.rs index 44b91246d..87c0d33a2 100644 --- a/crates/nono-cli/src/proxy_runtime.rs +++ b/crates/nono-cli/src/proxy_runtime.rs @@ -186,6 +186,7 @@ pub(crate) fn prepare_proxy_launch_options( network_approval_timeout_secs: resolve_approval_timeout_secs( prepared.profile_network_approval_timeout_secs, ), + profile_name: args.profile.clone(), }; // Infra-only flags make no sense without an activating proxy feature. @@ -493,10 +494,10 @@ pub(crate) fn start_proxy_runtime( let (tx, mut rx) = tokio::sync::mpsc::channel::(16); - let config_writer = proxy - .network_profile - .as_deref() - .map(crate::network_approval::ConfigWriter::new); + let config_writer = match proxy.profile_name.as_deref() { + Some(name) => Some(crate::network_approval::ConfigWriter::new(name)), + None => Some(crate::network_approval::ConfigWriter::new("default")), + }; let backend = NetworkApprovalBackend::new( proxy.network_approval_mode, From b283f5d4d2497e84c278cbd8e7d46ff24584938e Mon Sep 17 00:00:00 2001 From: Matthias Klass Date: Fri, 3 Jul 2026 10:41:08 +0200 Subject: [PATCH 91/91] fix: reapply DenyLinkLocal short-circuit and is_denied() Timeout after rebase The merge conflicts reverted two security fixes: 1. connect.rs: Both primary and runtime filter checks now short-circuit on DenyLinkLocal in addition to DenyHost, preventing cloud metadata SSRF via DNS rebinding from reaching the approval dialog. 2. types.rs: NetworkApprovalDecision::is_denied() now includes the Timeout variant, so timeout is correctly treated as a denial. Signed-off-by: klassm --- crates/nono-cli/src/exec_strategy.rs | 7 +- crates/nono-cli/src/launch_runtime.rs | 6 +- crates/nono-cli/src/main.rs | 4 - crates/nono-cli/src/profile_runtime.rs | 8 -- crates/nono-cli/src/proxy_runtime.rs | 8 -- crates/nono-cli/src/sandbox_prepare.rs | 8 -- crates/nono-proxy/src/config.rs | 7 -- crates/nono-proxy/src/connect.rs | 14 ++++ crates/nono-proxy/src/filter.rs | 9 --- crates/nono/src/net_filter.rs | 100 ------------------------- crates/nono/src/supervisor/types.rs | 5 +- 11 files changed, 23 insertions(+), 153 deletions(-) diff --git a/crates/nono-cli/src/exec_strategy.rs b/crates/nono-cli/src/exec_strategy.rs index 511603bd0..5eb603205 100644 --- a/crates/nono-cli/src/exec_strategy.rs +++ b/crates/nono-cli/src/exec_strategy.rs @@ -21,11 +21,12 @@ use nix::libc; use nix::sys::signal::{self, Signal}; use nix::sys::wait::{WaitPidFlag, WaitStatus, waitpid}; use nix::unistd::{ForkResult, Pid, fork}; -use nono::supervisor::{ApprovalDecision, AuditEntry, SupervisorMessage, SupervisorResponse}; +use nono::supervisor::{ + ApprovalDecision, AuditEntry, SupervisorListener, SupervisorMessage, SupervisorResponse, +}; use nono::{ ApprovalBackend, CapabilitySet, DenialReason, DenialRecord, NonoError, Result, Sandbox, - SessionDiagnosticReport, SupervisorListener, SupervisorSocket, UnixSocketCapability, - UnixSocketMode, + SessionDiagnosticReport, SupervisorSocket, UnixSocketCapability, UnixSocketMode, }; use std::collections::HashSet; use std::ffi::{CString, OsStr}; diff --git a/crates/nono-cli/src/launch_runtime.rs b/crates/nono-cli/src/launch_runtime.rs index 26a269e9b..f3ad5baa7 100644 --- a/crates/nono-cli/src/launch_runtime.rs +++ b/crates/nono-cli/src/launch_runtime.rs @@ -135,11 +135,6 @@ pub(crate) struct ProxyLaunchOptions { /// Propagated to `ProxyConfig.strict_filter` so the filter denies /// unlisted hosts instead of falling back to allow-all. pub(crate) network_block: bool, - pub(crate) network_approval_mode: crate::network_approval::NetworkApprovalMode, - pub(crate) network_approval_timeout_secs: u64, - /// The nono profile name (from `--profile`), used by ConfigWriter - /// to persist approved hosts to the correct user profile file. - pub(crate) profile_name: Option, } impl ProxyLaunchOptions { @@ -152,6 +147,7 @@ impl ProxyLaunchOptions { .is_some_and(|credentials| !credentials.credentials.is_empty()) || self.upstream_proxy.is_some() } +} #[derive(Clone)] pub(crate) struct ExecutionFlags { diff --git a/crates/nono-cli/src/main.rs b/crates/nono-cli/src/main.rs index f6b77b29a..f1295cc55 100644 --- a/crates/nono-cli/src/main.rs +++ b/crates/nono-cli/src/main.rs @@ -296,8 +296,6 @@ mod tests { profile_network_approval_timeout_secs: None, set_vars: None, network_block_requested: false, - profile_network_approval_mode: None, - profile_network_approval_timeout_secs: None, }; let effective = resolve_effective_proxy_settings(&args, &prepared); @@ -355,8 +353,6 @@ mod tests { profile_network_approval_timeout_secs: None, set_vars: None, network_block_requested: false, - profile_network_approval_mode: None, - profile_network_approval_timeout_secs: None, }; let effective = resolve_effective_proxy_settings(&args, &prepared); diff --git a/crates/nono-cli/src/profile_runtime.rs b/crates/nono-cli/src/profile_runtime.rs index 9886dadec..5aea04a49 100644 --- a/crates/nono-cli/src/profile_runtime.rs +++ b/crates/nono-cli/src/profile_runtime.rs @@ -38,8 +38,6 @@ pub(crate) struct PreparedProfile { /// when the profile has no `set_vars`. Values are expanded with /// [`profile::expand_vars`] at prepare time. pub(crate) set_vars: Option>, - pub(crate) network_approval_mode: Option, - pub(crate) network_approval_timeout_secs: Option, } #[derive(Clone, Copy)] @@ -716,12 +714,6 @@ fn prepare_profile_with_options( .as_ref() .and_then(|profile| profile.network.approval_timeout_secs), set_vars: expand_profile_set_vars(loaded_profile.as_ref(), workdir)?, - network_approval_mode: loaded_profile - .as_ref() - .and_then(|profile| profile.network.approval_mode.clone()), - network_approval_timeout_secs: loaded_profile - .as_ref() - .and_then(|profile| profile.network.approval_timeout_secs), loaded_profile, }) } diff --git a/crates/nono-cli/src/proxy_runtime.rs b/crates/nono-cli/src/proxy_runtime.rs index 87c0d33a2..8f3bf6d59 100644 --- a/crates/nono-cli/src/proxy_runtime.rs +++ b/crates/nono-cli/src/proxy_runtime.rs @@ -98,9 +98,6 @@ pub(crate) fn prepare_proxy_launch_options( None }; - let approval_mode = - resolve_network_approval_mode(args, prepared.profile_network_approval_mode.as_deref()); - let endpoint_filter = if !endpoint_entries.is_empty() { debug_assert!( endpoint_entries @@ -182,11 +179,6 @@ pub(crate) fn prepare_proxy_launch_options( ), profile_name: args.profile.clone(), network_block: prepared.network_block_requested, - network_approval_mode: approval_mode, - network_approval_timeout_secs: resolve_approval_timeout_secs( - prepared.profile_network_approval_timeout_secs, - ), - profile_name: args.profile.clone(), }; // Infra-only flags make no sense without an activating proxy feature. diff --git a/crates/nono-cli/src/sandbox_prepare.rs b/crates/nono-cli/src/sandbox_prepare.rs index 5f5c4ec9e..16f13d012 100644 --- a/crates/nono-cli/src/sandbox_prepare.rs +++ b/crates/nono-cli/src/sandbox_prepare.rs @@ -450,8 +450,6 @@ pub(crate) struct PreparedSandbox { /// through because a CLI proxy flag (e.g. `--credential`) may later /// override `caps` to `ProxyOnly`, losing the original intent. pub(crate) network_block_requested: bool, - pub(crate) profile_network_approval_mode: Option, - pub(crate) profile_network_approval_timeout_secs: Option, } fn resolved_workdir(args: &SandboxArgs) -> PathBuf { @@ -1087,8 +1085,6 @@ pub(crate) fn prepare_sandbox(args: &SandboxArgs, silent: bool) -> Result Result Result, - /// Reverse proxy credential routes. #[serde(default)] pub routes: Vec, @@ -163,7 +157,6 @@ impl Default for ProxyConfig { allowed_hosts: Vec::new(), rejected_hosts: Vec::new(), strict_filter: false, - rejected_hosts: Vec::new(), routes: Vec::new(), external_proxy: None, direct_connect_ports: Vec::new(), diff --git a/crates/nono-proxy/src/connect.rs b/crates/nono-proxy/src/connect.rs index af3c410ed..005e3bbe6 100644 --- a/crates/nono-proxy/src/connect.rs +++ b/crates/nono-proxy/src/connect.rs @@ -86,6 +86,7 @@ pub async fn handle_connect_with_approval( audit::log_denied( ctx.audit_log, audit::ProxyMode::Connect, + &audit::EventContext::default(), &host, port, &reason, @@ -99,6 +100,7 @@ pub async fn handle_connect_with_approval( audit::log_allowed( ctx.audit_log, audit::ProxyMode::Connect, + &audit::EventContext::default(), &host, port, "CONNECT", @@ -111,6 +113,7 @@ pub async fn handle_connect_with_approval( if matches!( primary_check.result, nono::net_filter::FilterResult::DenyHost { .. } + | nono::net_filter::FilterResult::DenyLinkLocal { .. } ) { let reason = primary_check.result.reason(); debug!( @@ -120,6 +123,7 @@ pub async fn handle_connect_with_approval( audit::log_denied( ctx.audit_log, audit::ProxyMode::Connect, + &audit::EventContext::default(), &host, port, &reason, @@ -140,6 +144,7 @@ pub async fn handle_connect_with_approval( audit::log_denied( ctx.audit_log, audit::ProxyMode::Connect, + &audit::EventContext::default(), &host, port, &reason, @@ -153,6 +158,7 @@ pub async fn handle_connect_with_approval( audit::log_allowed( ctx.audit_log, audit::ProxyMode::Connect, + &audit::EventContext::default(), &host, port, "CONNECT (runtime)", @@ -165,6 +171,7 @@ pub async fn handle_connect_with_approval( if matches!( runtime_check.result, nono::net_filter::FilterResult::DenyHost { .. } + | nono::net_filter::FilterResult::DenyLinkLocal { .. } ) { let reason = runtime_check.result.reason(); debug!( @@ -174,6 +181,7 @@ pub async fn handle_connect_with_approval( audit::log_denied( ctx.audit_log, audit::ProxyMode::Connect, + &audit::EventContext::default(), &host, port, &reason, @@ -203,6 +211,7 @@ pub async fn handle_connect_with_approval( audit::log_denied( ctx.audit_log, audit::ProxyMode::Connect, + &audit::EventContext::default(), &host, port, reason, @@ -221,6 +230,7 @@ pub async fn handle_connect_with_approval( audit::log_denied( ctx.audit_log, audit::ProxyMode::Connect, + &audit::EventContext::default(), &host, port, reason, @@ -253,6 +263,7 @@ pub async fn handle_connect_with_approval( audit::log_denied( ctx.audit_log, audit::ProxyMode::Connect, + &audit::EventContext::default(), &host, port, reason, @@ -272,6 +283,7 @@ pub async fn handle_connect_with_approval( audit::log_denied( ctx.audit_log, audit::ProxyMode::Connect, + &audit::EventContext::default(), &host, port, reason, @@ -290,6 +302,7 @@ pub async fn handle_connect_with_approval( audit::log_allowed( ctx.audit_log, audit::ProxyMode::Connect, + &audit::EventContext::default(), &host, port, "CONNECT (approved)", @@ -307,6 +320,7 @@ pub async fn handle_connect_with_approval( audit::log_denied( ctx.audit_log, audit::ProxyMode::Connect, + &audit::EventContext::default(), &host, port, display_reason, diff --git a/crates/nono-proxy/src/filter.rs b/crates/nono-proxy/src/filter.rs index 98ecbe947..720f638e9 100644 --- a/crates/nono-proxy/src/filter.rs +++ b/crates/nono-proxy/src/filter.rs @@ -55,15 +55,6 @@ impl ProxyFilter { } } - /// Create a new proxy filter with both allowed and rejected hosts. - #[must_use] - pub fn new_with_reject(allowed_hosts: &[String], rejected_hosts: &[String]) -> Self { - Self { - inner: HostFilter::new_with_reject(allowed_hosts, rejected_hosts), - } - } - } - /// Create a filter that allows all hosts (except cloud metadata). #[must_use] pub fn allow_all() -> Self { diff --git a/crates/nono/src/net_filter.rs b/crates/nono/src/net_filter.rs index ccc9a9ff5..f7810f546 100644 --- a/crates/nono/src/net_filter.rs +++ b/crates/nono/src/net_filter.rs @@ -122,8 +122,6 @@ pub struct HostFilter { deny_suffixes: Vec, /// When true, an empty allowlist denies instead of allowing. strict: bool, - /// Wildcard suffixes that are always denied (e.g., ".evil.com") - deny_suffixes: Vec, } impl HostFilter { @@ -222,27 +220,6 @@ impl HostFilter { deny_hosts: DENY_HOSTS.iter().map(|s| s.to_lowercase()).collect(), deny_suffixes: Vec::new(), strict: false, - deny_suffixes: Vec::new(), - } - } - - /// Create a host filter that denies everything by default. - /// - /// Unlike [`allow_all()`](Self::allow_all), no host is permitted unless - /// explicitly added via [`add_host()`](Self::add_host) or - /// [`add_suffix()`](Self::add_suffix). Cloud metadata endpoints remain - /// unconditionally denied. - /// - /// This is used for runtime approval filters where the filter starts - /// empty and only approved hosts are added dynamically. - #[must_use] - pub fn deny_all() -> Self { - Self { - allowed_hosts: vec!["__deny_all_sentinel__.invalid".to_string()], - allowed_suffixes: Vec::new(), - deny_hosts: DENY_HOSTS.iter().map(|s| s.to_lowercase()).collect(), - deny_suffixes: Vec::new(), - strict: true, } } @@ -776,81 +753,4 @@ mod tests { let result = filter.check_host("example.com", &public_ip()); assert!(matches!(result, FilterResult::Allow)); } - - #[test] - fn test_new_with_reject_exact() { - let filter = - HostFilter::new_with_reject(&["api.openai.com".to_string()], &["evil.com".to_string()]); - assert!(filter - .check_host("api.openai.com", &public_ip()) - .is_allowed()); - assert!(!filter.check_host("evil.com", &public_ip()).is_allowed()); - assert!(matches!( - filter.check_host("evil.com", &public_ip()), - FilterResult::DenyHost { .. } - )); - } - - #[test] - fn test_reject_overrides_allow() { - let filter = - HostFilter::new_with_reject(&["evil.com".to_string()], &["evil.com".to_string()]); - assert!( - !filter.check_host("evil.com", &public_ip()).is_allowed(), - "deny should override allow for same host" - ); - } - - #[test] - fn test_reject_wildcard_overrides_allow() { - let filter = - HostFilter::new_with_reject(&["api.evil.com".to_string()], &["*.evil.com".to_string()]); - assert!( - !filter.check_host("api.evil.com", &public_ip()).is_allowed(), - "deny wildcard should override allow for subdomain" - ); - } - - #[test] - fn test_add_deny_host() { - let mut filter = HostFilter::new(&["api.openai.com".to_string(), "evil.com".to_string()]); - filter.add_deny_host("evil.com").expect("add_deny_host"); - assert!( - !filter.check_host("evil.com", &public_ip()).is_allowed(), - "deny should override allow" - ); - } - - #[test] - fn test_add_deny_suffix() { - let mut filter = - HostFilter::new(&["api.example.com".to_string(), "track.evil.com".to_string()]); - filter - .add_deny_suffix("*.evil.com") - .expect("add_deny_suffix"); - assert!( - !filter - .check_host("track.evil.com", &public_ip()) - .is_allowed(), - "deny suffix should override allow" - ); - assert!( - !filter - .check_host("other.evil.com", &public_ip()) - .is_allowed(), - "deny suffix should match other subdomains" - ); - } - - #[test] - fn test_deny_suffix_does_not_match_bare_domain() { - let mut filter = HostFilter::new(&["evil.com".to_string()]); - filter - .add_deny_suffix("*.evil.com") - .expect("add_deny_suffix"); - assert!( - filter.check_host("evil.com", &public_ip()).is_allowed(), - "deny suffix should not match bare domain" - ); - } } diff --git a/crates/nono/src/supervisor/types.rs b/crates/nono/src/supervisor/types.rs index 3b2a409dd..40170de6f 100644 --- a/crates/nono/src/supervisor/types.rs +++ b/crates/nono/src/supervisor/types.rs @@ -187,6 +187,9 @@ impl NetworkApprovalDecision { /// Returns true if access was denied. #[must_use] pub fn is_denied(&self) -> bool { - matches!(self, NetworkApprovalDecision::Denied { .. }) + matches!( + self, + NetworkApprovalDecision::Denied { .. } | NetworkApprovalDecision::Timeout + ) } }