VYPR
Medium severity6.1GHSA Advisory· Published May 28, 2026· Updated May 28, 2026

nono: Sandbox escape on Linux via D-Bus: `systemd-run --user`

CVE-2026-47128

Description

Summary

The nono Landlock/seccomp policies allow access to local Unix domain sockets (concrete and abstract). This allows an easy sandbox escape by talking to the per-user systemd dbus socket.

Threat scenario: Running Aider, Claude Code, OpenCode or similar tools with "allow bash" policy so that it can invoke arbitrary host tools like make, gcc, etc. to write code.

Reproducer

Here, instead of running a tool like opencode or claude one can just invoke systemd-run, but this is something an agent could be tricked into doing:

$ cd ~/src/myproject
$ nono run -s --allow-cwd --profile claude-code -- \
      systemd-run --user -q --wait --collect \
      /bin/sh -c "echo oops > ~/Documents/escaped.txt"
$ cat /var/home/test/Documents/escaped.txt
oops
$

Impact

Complete sandbox escape. The unsandboxed sibling process can write anywhere the user can write, spawn arbitrary processes with network access, etc.

Maintainer

Context

This issue allows a process running inside the sandbox to escape confinement by interacting with local user-scoped IPC mechanisms and regain the authority already held by the invoking user or service account.

The issue impacts the sandbox’s confinement and blast-radius reduction guarantees for agents and sandboxed tooling. However, exploitation does not provide privilege escalation, cross-user access, or host compromise beyond the permissions already available to the launcher outside the sandbox.

This issue affects the CLI policy layer and bundled sandbox profiles. The underlying core library nono does not ship with policy definitions or agent-facing confinement profiles by default, nor do the language SDKs.

This is considered a serious issue because an AI agent or untrusted command stream operating within the sandbox could abuse the bypass to perform unauthorized or destructive actions using the delegated authority of the launching user.

The root cause was incomplete mediation of local Unix domain socket access within affected sandbox policies. Support for restricting this behavior has since been added and the fix is available in the repository pending release.

CVSS rationale: exploitation requires execution within a locally launched sandboxed process using the authority already delegated by the invoking user or service account (AV:L/PR:L). The issue allows reliable bypass of sandbox confinement and policy guarantees, resulting in high integrity impact (I:H) and limited availability impact (A:L) through destructive actions within the launcher’s existing permissions. However, the issue does not provide privilege escalation, cross-user access, or a change in security scope (S:U).

AI Insight

LLM-synthesized narrative grounded in this CVE's description and references.

Sandbox escape in nono's Landlock/seccomp policies allows Linux sandboxed processes to escape via systemd D-Bus Unix domain sockets.

Vulnerability

The nono CLI tool's Landlock/seccomp policies (including the claude-code profile) permit access to both concrete and abstract local Unix domain sockets. This allows a process confined by these policies to communicate with the per-user systemd D-Bus socket, enabling a sandbox escape. The issue affects the CLI policy layer and bundled sandbox profiles, not the core nono library. [1][2]

Exploitation

An attacker with code execution inside the sandbox (for example, an AI agent granted bash access via the --allow-bash flag) can run systemd-run --user to spawn an unsandboxed sibling process. The attacker does not require elevated privileges or network access; they only need the ability to invoke systemd utilities within the sandbox. As shown in the reproducer, the command systemd-run --user -q --wait --collect /bin/sh -c '...' executes arbitrary commands outside the sandbox. [1][2]

Impact

Successful exploitation results in a complete sandbox escape. The unsandboxed sibling process inherits the full user-level authority of the launching user, allowing it to read/write arbitrary files, spawn processes, and access the network. The attacker does not gain privilege escalation, cross-user access, or host compromise beyond what the user already has, but the sandbox's confinement and blast-radius reduction guarantees are entirely broken. [1][2]

Mitigation

The root cause was incomplete mediation of local Unix domain socket access within affected sandbox policies. Support for restricting this behavior has since been added in the nono project. Users should update to the latest version of nono and apply the updated policy configurations. No specific fixed version number has been disclosed in the available references. [1][2]

AI Insight generated on May 28, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.

Affected products

3

Patches

8
a0222be24e1d

feat(linux): implement af_unix pathname mediation

https://github.com/always-further/nonoLuke HindsMay 13, 2026Fixed in 0.55.0via llm-release-walk
21 files changed · +903 70
  • crates/nono-cli/data/nono-profile.schema.json+23 0 modified
    @@ -45,6 +45,10 @@
           "$ref": "#/$defs/NetworkConfig",
           "description": "Network access configuration including proxy settings, credential injection, and port allowlists."
         },
    +    "linux": {
    +      "$ref": "#/$defs/LinuxConfig",
    +      "description": "Linux-specific hardening controls."
    +    },
         "env_credentials": {
           "$ref": "#/$defs/SecretsConfig",
           "description": "Maps keystore account names (or op://, apple-password://, env:// URIs) to environment variable names. Secrets are loaded from the system keystore at startup."
    @@ -195,6 +199,25 @@
           "enum": ["error", "insecure_proxy"],
           "description": "WSL2 proxy fallback policy. 'error' (default) refuses to run if proxy-only mode cannot be kernel-enforced. 'insecure_proxy' allows degraded execution where the credential proxy injects credentials but the sandboxed process is not prevented from bypassing the proxy."
         },
    +    "LinuxConfig": {
    +      "type": "object",
    +      "description": "Linux-specific hardening controls.",
    +      "additionalProperties": false,
    +      "properties": {
    +        "af_unix_mediation": {
    +          "oneOf": [
    +            { "$ref": "#/$defs/LinuxAfUnixMediation" },
    +            { "type": "null" }
    +          ],
    +          "description": "Opt-in pathname AF_UNIX mediation. 'off' preserves compatibility. 'pathname' requires explicit filesystem.unix_socket* grants for pathname Unix socket connect/bind."
    +        }
    +      }
    +    },
    +    "LinuxAfUnixMediation": {
    +      "type": "string",
    +      "enum": ["off", "pathname"],
    +      "description": "Linux pathname AF_UNIX mediation mode."
    +    },
         "ProfileSignalMode": {
           "type": "string",
           "enum": ["isolated", "allow_same_sandbox", "allow_all"],
    
  • crates/nono-cli/src/command_runtime.rs+11 0 modified
    @@ -144,6 +144,8 @@ pub(crate) fn run_shell(args: ShellArgs, silent: bool) -> Result<()> {
                 capability_elevation: prepared.capability_elevation,
                 #[cfg(target_os = "linux")]
                 wsl2_proxy_policy: prepared.wsl2_proxy_policy,
    +            #[cfg(target_os = "linux")]
    +            af_unix_mediation: prepared.af_unix_mediation,
                 bypass_protection_paths: prepared.bypass_protection_paths,
                 ignored_denial_paths: prepared.ignored_denial_paths,
                 allowed_env_vars: prepared.allowed_env_vars,
    @@ -201,6 +203,15 @@ pub(crate) fn run_wrap(wrap_args: WrapArgs, silent: bool) -> Result<()> {
             ));
         }
     
    +    #[cfg(target_os = "linux")]
    +    if prepared.af_unix_mediation.is_pathname() {
    +        return Err(NonoError::ConfigParse(
    +            "nono wrap does not support linux.af_unix_mediation = \"pathname\" because direct \
    +             exec cannot run the seccomp supervisor. Use `nono run` instead."
    +                .to_string(),
    +        ));
    +    }
    +
         if prepared.allow_launch_services_active {
             print_allow_launch_services_warning(silent);
         }
    
  • crates/nono-cli/src/exec_strategy.rs+161 43 modified
    @@ -232,6 +232,9 @@ pub struct ExecConfig<'a> {
         /// sends the notify fd; parent expects to receive it.
         #[cfg(target_os = "linux")]
         pub seccomp_proxy_fallback: bool,
    +    /// Linux pathname AF_UNIX mediation requested by profile.
    +    #[cfg(target_os = "linux")]
    +    pub af_unix_mediation: crate::profile::LinuxAfUnixMediation,
         /// Allow-list of environment variable names. When set, only variables
         /// matching an exact name or prefix pattern (e.g. `"AWS_*"`) are
         /// passed to the child. Nono-injected credentials always bypass this.
    @@ -274,6 +277,9 @@ pub struct SupervisorConfig<'a> {
         pub open_url_allow_localhost: bool,
         /// Optional append-only audit recorder for supervisor events.
         pub audit_recorder: Option<&'a Mutex<crate::audit_integrity::AuditRecorder>>,
    +    /// Optional in-memory network/IPC audit events persisted into session metadata.
    +    #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
    +    pub network_audit_events: Option<&'a Mutex<Vec<nono::undo::NetworkAuditEvent>>>,
         /// Redaction policy for command context in diagnostics.
         pub redaction_policy: &'a nono::ScrubPolicy,
         /// Whether direct LaunchServices opening is enabled for this session.
    @@ -288,6 +294,18 @@ pub struct SupervisorConfig<'a> {
         /// Pathname AF_UNIX socket grants allowed for seccomp proxy-only fallback.
         #[cfg(target_os = "linux")]
         pub unix_socket_allowlist: &'a [nono::UnixSocketCapability],
    +    /// Linux connect/bind seccomp notify policy mode.
    +    #[cfg(target_os = "linux")]
    +    pub linux_network_notify_mode: LinuxNetworkNotifyMode,
    +}
    +
    +#[cfg(target_os = "linux")]
    +#[derive(Debug, Clone, Copy, PartialEq, Eq)]
    +pub enum LinuxNetworkNotifyMode {
    +    /// V<4 proxy fallback: mediate TCP proxy ports and AF_UNIX sockets.
    +    ProxyOnly,
    +    /// V4+ opt-in: mediate pathname AF_UNIX only; let TCP continue.
    +    AfUnixOnly,
     }
     
     #[cfg(target_os = "macos")]
    @@ -296,11 +314,8 @@ fn should_install_macos_open_shim(supervisor: Option<&SupervisorConfig<'_>>) ->
     }
     
     #[cfg(target_os = "linux")]
    -const fn linux_child_requires_dumpable(
    -    capability_elevation: bool,
    -    seccomp_proxy_fallback: bool,
    -) -> bool {
    -    capability_elevation || seccomp_proxy_fallback
    +const fn linux_child_requires_dumpable(capability_elevation: bool, network_notify: bool) -> bool {
    +    capability_elevation || network_notify
     }
     
     /// Execute a command using the Direct strategy (exec, nono disappears).
    @@ -433,6 +448,7 @@ pub fn execute_supervised(
         let needs_child_ipc = supervisor.is_some()
             && (config.capability_elevation
                 || config.seccomp_proxy_fallback
    +            || config.af_unix_mediation.is_pathname()
                 || trust_interceptor.is_some());
     
         #[cfg(not(target_os = "linux"))]
    @@ -808,12 +824,15 @@ pub fn execute_supervised(
                         }
                     }
     
    -                // If the parent determined that seccomp proxy fallback is needed
    -                // (Landlock ABI lacks AccessNet + ProxyOnly mode), install the
    -                // proxy filter and send its notify fd to the parent.
    -                // On WSL2 this flag should already be false (guarded in main.rs),
    -                // but check again to avoid EBUSY / _exit(126).
    -                if config.seccomp_proxy_fallback && nono::sandbox::is_wsl2() {
    +                // If the parent determined that network seccomp-notify is
    +                // needed, install exactly one connect/bind notify filter and
    +                // send its fd to the parent. Proxy fallback uses the stricter
    +                // proxy filter; V4+ AF_UNIX mediation uses an AF_UNIX-only
    +                // policy filter that lets non-AF_UNIX traffic continue to the
    +                // existing Landlock/network policy.
    +                let install_network_notify =
    +                    config.seccomp_proxy_fallback || config.af_unix_mediation.is_pathname();
    +                if install_network_notify && nono::sandbox::is_wsl2() {
                         let msg = b"nono: WSL2 detected, skipping seccomp proxy filter (proxy network filtering unavailable)\n";
                         unsafe {
                             libc::write(
    @@ -822,13 +841,21 @@ pub fn execute_supervised(
                                 msg.len(),
                             );
                         }
    -                } else if config.seccomp_proxy_fallback {
    -                    let has_bind = match effective_caps.network_mode() {
    -                        nono::NetworkMode::ProxyOnly { bind_ports, .. } => !bind_ports.is_empty(),
    -                        _ => false,
    -                    };
    +                } else if install_network_notify {
                         if let Some(fd) = child_sock_fd {
    -                        match nono::sandbox::install_seccomp_proxy_filter(has_bind) {
    +                        let notify_result = if config.seccomp_proxy_fallback {
    +                            let has_bind = match effective_caps.network_mode() {
    +                                nono::NetworkMode::ProxyOnly { bind_ports, .. } => {
    +                                    !bind_ports.is_empty()
    +                                }
    +                                _ => false,
    +                            };
    +                            nono::sandbox::install_seccomp_proxy_filter(has_bind)
    +                        } else {
    +                            nono::sandbox::install_seccomp_af_unix_filter()
    +                        };
    +
    +                        match notify_result {
                                 Ok(proxy_notify_fd) => {
                                     if let Err(e) = nono::supervisor::socket::send_fd_via_socket(
                                         fd,
    @@ -868,7 +895,7 @@ pub fn execute_supervised(
     
                     if !linux_child_requires_dumpable(
                         config.capability_elevation,
    -                    config.seccomp_proxy_fallback,
    +                    config.seccomp_proxy_fallback || config.af_unix_mediation.is_pathname(),
                     ) {
                         use nix::sys::prctl;
     
    @@ -1034,24 +1061,25 @@ pub fn execute_supervised(
                 // receive the proxy notify fd from the child. Only attempt recv when
                 // we know the child will send it (both sides use the same flag).
                 #[cfg(target_os = "linux")]
    -            let proxy_notify_fd: Option<OwnedFd> = if config.seccomp_proxy_fallback {
    -                if let Some(ref sup_sock) = supervisor_sock {
    -                    match sup_sock.recv_fd() {
    -                        Ok(fd) => {
    -                            debug!("Received proxy seccomp notify fd from child");
    -                            Some(fd)
    -                        }
    -                        Err(e) => {
    -                            warn!("Failed to receive proxy seccomp notify fd: {}", e);
    -                            None
    +            let proxy_notify_fd: Option<OwnedFd> =
    +                if config.seccomp_proxy_fallback || config.af_unix_mediation.is_pathname() {
    +                    if let Some(ref sup_sock) = supervisor_sock {
    +                        match sup_sock.recv_fd() {
    +                            Ok(fd) => {
    +                                debug!("Received proxy seccomp notify fd from child");
    +                                Some(fd)
    +                            }
    +                            Err(e) => {
    +                                warn!("Failed to receive proxy seccomp notify fd: {}", e);
    +                                None
    +                            }
                             }
    +                    } else {
    +                        None
                         }
                     } else {
                         None
    -                }
    -            } else {
    -                None
    -            };
    +                };
     
                 // Set up signal forwarding.
                 setup_signal_forwarding(child, pty_proxy.as_ref().map(|p| p.poll_fds().0));
    @@ -1096,11 +1124,11 @@ pub fn execute_supervised(
                         .collect()
                 };
     
    -            let (status, denials) =
    +            let (status, denials, ipc_denials) =
                     if let (Some(sup_cfg), Some(mut sup_sock)) = (supervisor, supervisor_sock) {
                         #[cfg(target_os = "linux")]
                         {
    -                        run_supervisor_loop(
    +                        let (status, denials, ipc_denials) = run_supervisor_loop(
                                 child,
                                 &mut sup_sock,
                                 sup_cfg,
    @@ -1110,23 +1138,25 @@ pub fn execute_supervised(
                                 &initial_caps,
                                 trust_interceptor,
                                 pty_proxy.as_mut(),
    -                        )?
    +                        )?;
    +                        (status, denials, ipc_denials)
                         }
                         #[cfg(not(target_os = "linux"))]
                         {
    -                        run_supervisor_loop(
    +                        let (status, denials) = run_supervisor_loop(
                                 child,
                                 &mut sup_sock,
                                 sup_cfg,
                                 config.startup_timeout,
                                 trust_interceptor,
                                 pty_proxy.as_mut(),
    -                        )?
    +                        )?;
    +                        (status, denials, Vec::new())
                         }
                     } else {
                         let status =
                             wait_for_child_with_pty(child, pty_proxy.as_mut(), config.startup_timeout)?;
    -                    (status, Vec::new())
    +                    (status, Vec::new(), Vec::new())
                     };
     
                 // Close the attach listener immediately so no new attach
    @@ -1208,6 +1238,7 @@ pub fn execute_supervised(
                     config.no_diagnostics,
                     exit_code,
                     &denials,
    +                &ipc_denials,
                     &sandbox_violations,
                     &error_observation,
                 );
    @@ -1234,6 +1265,7 @@ pub fn execute_supervised(
                     let mut formatter = DiagnosticFormatter::new(config.caps)
                         .with_mode(mode)
                         .with_denials(&denials)
    +                    .with_ipc_denials(&ipc_denials)
                         .with_sandbox_violations(&sandbox_violations)
                         .with_protected_paths(config.protected_paths)
                         .with_error_observation(error_observation)
    @@ -1403,12 +1435,14 @@ fn should_print_diagnostic_footer(
         no_diagnostics: bool,
         exit_code: i32,
         denials: &[nono::diagnostic::DenialRecord],
    +    ipc_denials: &[nono::diagnostic::IpcDenialRecord],
         sandbox_violations: &[nono::SandboxViolation],
         error_observation: &nono::diagnostic::ErrorObservation,
     ) -> bool {
         !no_diagnostics
             && (exit_code != 0
                 || !denials.is_empty()
    +            || !ipc_denials.is_empty()
                 || !sandbox_violations.is_empty()
                 || error_observation.has_findings())
     }
    @@ -2086,12 +2120,17 @@ fn run_supervisor_loop(
         initial_caps: &[supervisor_linux::InitialCapability],
         mut trust_interceptor: Option<crate::trust_intercept::TrustInterceptor>,
         mut pty: Option<&mut crate::pty_proxy::PtyProxy>,
    -) -> Result<(WaitStatus, Vec<DenialRecord>)> {
    +) -> Result<(
    +    WaitStatus,
    +    Vec<DenialRecord>,
    +    Vec<nono::diagnostic::IpcDenialRecord>,
    +)> {
         let sock_fd = sock.as_raw_fd();
         let notify_raw_fd = seccomp_fd.map(|fd| fd.as_raw_fd());
         let proxy_notify_raw_fd = proxy_seccomp_fd.map(|fd| fd.as_raw_fd());
         let mut rate_limiter = supervisor_linux::RateLimiter::new(10, 5);
         let mut denials = Vec::new();
    +    let mut ipc_denials = Vec::new();
         let mut seen_request_ids = HashSet::new();
         let mut sock_fd_active = true;
         let startup_deadline = startup_timeout.map(|cfg| (Instant::now() + cfg.timeout, cfg));
    @@ -2209,6 +2248,8 @@ fn run_supervisor_loop(
                             pfd,
                             config,
                             &mut rate_limiter,
    +                        &mut denials,
    +                        &mut ipc_denials,
                         )
                     {
                         debug!("Error handling proxy seccomp notification: {}", e);
    @@ -2264,7 +2305,7 @@ fn run_supervisor_loop(
                             );
                             if terminate {
                                 let _ = signal::kill(child, Signal::SIGTERM);
    -                            return Ok((wait_for_child(child)?, denials));
    +                            return Ok((wait_for_child(child)?, denials, ipc_denials));
                             }
                         }
                     }
    @@ -2278,11 +2319,27 @@ fn run_supervisor_loop(
                     debug!("Child continued, keeping supervisor alive");
                     continue;
                 }
    -            Ok(status) => return Ok((status, denials)),
    +            Ok(status) => {
    +                drain_pending_network_notifications(
    +                    proxy_notify_raw_fd,
    +                    config,
    +                    &mut rate_limiter,
    +                    &mut denials,
    +                    &mut ipc_denials,
    +                );
    +                return Ok((status, denials, ipc_denials));
    +            }
                 Err(nix::errno::Errno::EINTR) => continue,
                 Err(nix::errno::Errno::ECHILD) => {
                     warn!("Child already reaped in supervisor loop");
    -                return Ok((WaitStatus::Exited(child, 1), denials));
    +                drain_pending_network_notifications(
    +                    proxy_notify_raw_fd,
    +                    config,
    +                    &mut rate_limiter,
    +                    &mut denials,
    +                    &mut ipc_denials,
    +                );
    +                return Ok((WaitStatus::Exited(child, 1), denials, ipc_denials));
                 }
                 Err(e) => {
                     return Err(NonoError::SandboxInit(format!(
    @@ -2294,7 +2351,42 @@ fn run_supervisor_loop(
         }
     
         let status = wait_for_child(child)?;
    -    Ok((status, denials))
    +    Ok((status, denials, ipc_denials))
    +}
    +
    +#[cfg(target_os = "linux")]
    +fn drain_pending_network_notifications(
    +    proxy_notify_raw_fd: Option<std::os::fd::RawFd>,
    +    config: &SupervisorConfig<'_>,
    +    rate_limiter: &mut supervisor_linux::RateLimiter,
    +    denials: &mut Vec<DenialRecord>,
    +    ipc_denials: &mut Vec<nono::diagnostic::IpcDenialRecord>,
    +) {
    +    let Some(fd) = proxy_notify_raw_fd else {
    +        return;
    +    };
    +
    +    loop {
    +        let mut pfd = libc::pollfd {
    +            fd,
    +            events: libc::POLLIN,
    +            revents: 0,
    +        };
    +        let ret = unsafe { libc::poll(&mut pfd, 1, 0) };
    +        if ret <= 0 || pfd.revents & libc::POLLIN == 0 {
    +            return;
    +        }
    +        if let Err(err) = supervisor_linux::handle_network_notification(
    +            fd,
    +            config,
    +            rate_limiter,
    +            denials,
    +            ipc_denials,
    +        ) {
    +            debug!("Error draining pending proxy seccomp notification: {}", err);
    +            return;
    +        }
    +    }
     }
     
     /// Handle a single supervisor IPC message.
    @@ -3340,13 +3432,15 @@ mod tests {
                 false,
                 0,
                 &denials,
    +            &[],
                 &violations,
                 &observation,
             ));
             assert!(!should_print_diagnostic_footer(
                 true,
                 0,
                 &denials,
    +            &[],
                 &violations,
                 &observation,
             ));
    @@ -3702,6 +3796,7 @@ mod tests {
                 open_url_origins: &[],
                 open_url_allow_localhost: false,
                 audit_recorder: None,
    +            network_audit_events: None,
                 redaction_policy: &nono::ScrubPolicy::secure_default(),
                 allow_launch_services_active: false,
                 #[cfg(target_os = "linux")]
    @@ -3710,6 +3805,8 @@ mod tests {
                 proxy_bind_ports: Vec::new(),
                 #[cfg(target_os = "linux")]
                 unix_socket_allowlist: &[],
    +            #[cfg(target_os = "linux")]
    +            linux_network_notify_mode: LinuxNetworkNotifyMode::ProxyOnly,
             };
     
             // Fork a child that closes its socket end and exits immediately.
    @@ -3804,6 +3901,7 @@ mod tests {
                 open_url_origins: &[],
                 open_url_allow_localhost: false,
                 audit_recorder: None,
    +            network_audit_events: None,
                 redaction_policy: &nono::ScrubPolicy::secure_default(),
                 allow_launch_services_active: false,
                 #[cfg(target_os = "linux")]
    @@ -3812,6 +3910,8 @@ mod tests {
                 proxy_bind_ports: Vec::new(),
                 #[cfg(target_os = "linux")]
                 unix_socket_allowlist: &[],
    +            #[cfg(target_os = "linux")]
    +            linux_network_notify_mode: LinuxNetworkNotifyMode::ProxyOnly,
             };
     
             match unsafe { fork() } {
    @@ -3882,6 +3982,7 @@ mod tests {
                 open_url_origins: &origins,
                 open_url_allow_localhost: false,
                 audit_recorder: None,
    +            network_audit_events: None,
                 redaction_policy: &nono::ScrubPolicy::secure_default(),
                 allow_launch_services_active: false,
                 #[cfg(target_os = "linux")]
    @@ -3890,6 +3991,8 @@ mod tests {
                 proxy_bind_ports: Vec::new(),
                 #[cfg(target_os = "linux")]
                 unix_socket_allowlist: &[],
    +            #[cfg(target_os = "linux")]
    +            linux_network_notify_mode: LinuxNetworkNotifyMode::ProxyOnly,
             };
     
             // Allowed origin: validation passes
    @@ -3920,6 +4023,7 @@ mod tests {
                 open_url_origins: &[],
                 open_url_allow_localhost: false,
                 audit_recorder: None,
    +            network_audit_events: None,
                 redaction_policy: &nono::ScrubPolicy::secure_default(),
                 allow_launch_services_active: false,
                 #[cfg(target_os = "linux")]
    @@ -3928,6 +4032,8 @@ mod tests {
                 proxy_bind_ports: Vec::new(),
                 #[cfg(target_os = "linux")]
                 unix_socket_allowlist: &[],
    +            #[cfg(target_os = "linux")]
    +            linux_network_notify_mode: LinuxNetworkNotifyMode::ProxyOnly,
             };
     
             let result = validate_url("file:///etc/passwd", &config);
    @@ -3956,6 +4062,7 @@ mod tests {
                 open_url_origins: &[],
                 open_url_allow_localhost: true,
                 audit_recorder: None,
    +            network_audit_events: None,
                 redaction_policy: &nono::ScrubPolicy::secure_default(),
                 allow_launch_services_active: false,
                 #[cfg(target_os = "linux")]
    @@ -3964,6 +4071,8 @@ mod tests {
                 proxy_bind_ports: Vec::new(),
                 #[cfg(target_os = "linux")]
                 unix_socket_allowlist: &[],
    +            #[cfg(target_os = "linux")]
    +            linux_network_notify_mode: LinuxNetworkNotifyMode::ProxyOnly,
             };
             let config_deny = SupervisorConfig {
                 protected_roots: &[],
    @@ -3974,6 +4083,7 @@ mod tests {
                 open_url_origins: &[],
                 open_url_allow_localhost: false,
                 audit_recorder: None,
    +            network_audit_events: None,
                 redaction_policy: &nono::ScrubPolicy::secure_default(),
                 allow_launch_services_active: false,
                 #[cfg(target_os = "linux")]
    @@ -3982,6 +4092,8 @@ mod tests {
                 proxy_bind_ports: Vec::new(),
                 #[cfg(target_os = "linux")]
                 unix_socket_allowlist: &[],
    +            #[cfg(target_os = "linux")]
    +            linux_network_notify_mode: LinuxNetworkNotifyMode::ProxyOnly,
             };
     
             // Localhost denied when not allowed
    @@ -4015,6 +4127,7 @@ mod tests {
                 open_url_origins: &[],
                 open_url_allow_localhost: false,
                 audit_recorder: None,
    +            network_audit_events: None,
                 redaction_policy: &nono::ScrubPolicy::secure_default(),
                 allow_launch_services_active: false,
                 #[cfg(target_os = "linux")]
    @@ -4023,6 +4136,8 @@ mod tests {
                 proxy_bind_ports: Vec::new(),
                 #[cfg(target_os = "linux")]
                 unix_socket_allowlist: &[],
    +            #[cfg(target_os = "linux")]
    +            linux_network_notify_mode: LinuxNetworkNotifyMode::ProxyOnly,
             };
     
             let long_url = format!("https://example.com/{}", "a".repeat(MAX_URL_LENGTH));
    @@ -4159,6 +4274,7 @@ mod tests {
                 open_url_origins: &[],
                 open_url_allow_localhost: false,
                 audit_recorder: None,
    +            network_audit_events: None,
                 redaction_policy: &nono::ScrubPolicy::secure_default(),
                 allow_launch_services_active: true,
                 #[cfg(target_os = "linux")]
    @@ -4167,6 +4283,8 @@ mod tests {
                 proxy_bind_ports: Vec::new(),
                 #[cfg(target_os = "linux")]
                 unix_socket_allowlist: &[],
    +            #[cfg(target_os = "linux")]
    +            linux_network_notify_mode: LinuxNetworkNotifyMode::ProxyOnly,
             };
     
             assert!(
    
  • crates/nono-cli/src/exec_strategy/supervisor_linux.rs+262 6 modified
    @@ -538,8 +538,7 @@ pub(super) fn handle_seccomp_notification(
     /// `Allow` to `continue_notif(…)` and `Deny` to `respond_notif_errno(…, EACCES)`.
     #[derive(Debug, Clone, Copy, PartialEq, Eq)]
     pub(super) enum NetworkDecision {
    -    /// Let the kernel proceed with the already-copied sockaddr
    -    /// (`SECCOMP_USER_NOTIF_FLAG_CONTINUE`).
    +    /// Allow the operation.
         Allow,
         /// Fail the syscall with `EACCES`.
         Deny,
    @@ -601,6 +600,17 @@ pub(super) fn decide_network_notification(
             }
         }
     
    +    if matches!(
    +        config.linux_network_notify_mode,
    +        LinuxNetworkNotifyMode::AfUnixOnly
    +    ) {
    +        debug!(
    +            "AF_UNIX-only seccomp mediation: allowing non-AF_UNIX syscall family={} nr={}",
    +            sockaddr.family, syscall
    +        );
    +        return NetworkDecision::Allow;
    +    }
    +
         match syscall {
             SYS_CONNECT => {
                 // Allow connect only to loopback + proxy port
    @@ -790,12 +800,16 @@ fn canonicalize_unix_socket_bind_path(
     /// the sockaddr from the child's memory and delegates the allow/deny
     /// decision to [`decide_network_notification`].
     ///
    -/// Uses SECCOMP_USER_NOTIF_FLAG_CONTINUE on approval (safe for connect/bind
    -/// because the kernel has already copied sockaddr into kernel memory).
    +/// Denials return `EACCES` directly. Approvals currently use
    +/// `SECCOMP_USER_NOTIF_FLAG_CONTINUE`, which preserves platform compatibility
    +/// but carries the documented userspace-pointer TOCTOU limitation described
    +/// by `read_notif_sockaddr`.
     pub(super) fn handle_network_notification(
         notify_fd: std::os::fd::RawFd,
         config: &SupervisorConfig<'_>,
         rate_limiter: &mut RateLimiter,
    +    denials: &mut Vec<DenialRecord>,
    +    ipc_denials: &mut Vec<nono::diagnostic::IpcDenialRecord>,
     ) -> nono::error::Result<()> {
         use nono::sandbox::{
             continue_notif, deny_notif, notif_id_valid, read_notif_sockaddr, recv_notif,
    @@ -829,8 +843,6 @@ pub(super) fn handle_network_notification(
     
         match decide_network_notification(notif.pid, notif.data.nr, &sockaddr, config) {
             NetworkDecision::Allow => {
    -            // SECCOMP_USER_NOTIF_FLAG_CONTINUE: let the kernel proceed with its
    -            // already-copied sockaddr. Safe for connect/bind (move_addr_to_kernel).
                 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
    @@ -839,13 +851,232 @@ pub(super) fn handle_network_notification(
                 }
             }
             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);
    +            }
             }
         }
     
         Ok(())
     }
     
    +fn record_af_unix_ipc_denial(
    +    sockaddr: &nono::sandbox::SockaddrInfo,
    +    child_pid: u32,
    +    syscall: i32,
    +    denials: &mut Vec<DenialRecord>,
    +    ipc_denials: &mut Vec<nono::diagnostic::IpcDenialRecord>,
    +) {
    +    if sockaddr.family != libc::AF_UNIX as u16 {
    +        return;
    +    }
    +
    +    let op = unix_socket_op_for_syscall(syscall);
    +    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);
    +
    +    ipc_denials.push(nono::diagnostic::IpcDenialRecord {
    +        target,
    +        operation,
    +        reason,
    +        suggested_flag,
    +    });
    +
    +    let Some((display_path, op)) = path_record else {
    +        return;
    +    };
    +    let access = match op {
    +        UnixSocketOp::Connect => AccessMode::Read,
    +        UnixSocketOp::Bind => AccessMode::ReadWrite,
    +    };
    +
    +    record_denial(
    +        denials,
    +        DenialRecord {
    +            path: display_path,
    +            access,
    +            reason: DenialReason::UnixSocketDenied,
    +        },
    +    );
    +}
    +
    +type PathIpcDenial = Option<(std::path::PathBuf, UnixSocketOp)>;
    +
    +fn ipc_denial_details(
    +    sockaddr: &nono::sandbox::SockaddrInfo,
    +    child_pid: u32,
    +    op: Option<UnixSocketOp>,
    +) -> (String, String, Option<String>, PathIpcDenial) {
    +    match sockaddr.unix_kind {
    +        Some(nono::sandbox::UnixSocketKind::Pathname) => {
    +            let Some(path) = sockaddr.unix_path.as_deref() else {
    +                return (
    +                    "unix:<unparsed-pathname>".to_string(),
    +                    "pathname not parsed".to_string(),
    +                    None,
    +                    None,
    +                );
    +            };
    +            let Some(op) = op else {
    +                return (
    +                    path.display().to_string(),
    +                    "unexpected syscall".to_string(),
    +                    None,
    +                    None,
    +                );
    +            };
    +            let resolved = resolve_af_unix_sockaddr_path(child_pid, path)
    +                .unwrap_or_else(|_| path.to_path_buf());
    +            let display_path = match op {
    +                UnixSocketOp::Connect => match resolved.canonicalize() {
    +                    Ok(path) => path,
    +                    Err(err) => {
    +                        return (
    +                            resolved.display().to_string(),
    +                            format!("canonicalize failed: {err}"),
    +                            None,
    +                            None,
    +                        );
    +                    }
    +                },
    +                UnixSocketOp::Bind => match canonicalize_unix_socket_bind_path(&resolved) {
    +                    Ok(path) => path,
    +                    Err(err) => {
    +                        return (
    +                            resolved.display().to_string(),
    +                            format!("canonicalize failed: {err}"),
    +                            None,
    +                            None,
    +                        );
    +                    }
    +                },
    +            };
    +            let flag = match op {
    +                UnixSocketOp::Connect => "--allow-unix-socket",
    +                UnixSocketOp::Bind => "--allow-unix-socket-bind",
    +            };
    +            (
    +                display_path.display().to_string(),
    +                "no matching unix_socket capability".to_string(),
    +                Some(format!("{flag} {}", display_path.display())),
    +                Some((display_path, op)),
    +            )
    +        }
    +        Some(nono::sandbox::UnixSocketKind::Abstract) => (
    +            "unix:<abstract>".to_string(),
    +            "abstract namespace is not covered by pathname capabilities".to_string(),
    +            None,
    +            None,
    +        ),
    +        Some(nono::sandbox::UnixSocketKind::Unnamed) | None => (
    +            "unix:<unnamed>".to_string(),
    +            "no pathname to authorize".to_string(),
    +            None,
    +            None,
    +        ),
    +    }
    +}
    +
    +fn record_network_audit_denial(
    +    config: &SupervisorConfig<'_>,
    +    sockaddr: &nono::sandbox::SockaddrInfo,
    +    syscall: i32,
    +) -> nono::Result<()> {
    +    let target = network_audit_target(sockaddr);
    +    let reason = network_audit_denial_reason(sockaddr, syscall);
    +    let event = nono::undo::NetworkAuditEvent {
    +        timestamp_unix_ms: current_unix_millis(),
    +        mode: nono::undo::NetworkAuditMode::Connect,
    +        decision: nono::undo::NetworkAuditDecision::Deny,
    +        route_id: None,
    +        auth_mechanism: None,
    +        auth_outcome: None,
    +        managed_credential_active: None,
    +        injection_mode: None,
    +        denial_category: Some(nono::undo::NetworkAuditDenialCategory::HostDenied),
    +        target,
    +        port: if sockaddr.port == 0 {
    +            None
    +        } else {
    +            Some(sockaddr.port)
    +        },
    +        method: None,
    +        path: None,
    +        status: None,
    +        reason: Some(reason),
    +    };
    +
    +    if let Some(events_mutex) = config.network_audit_events {
    +        let mut events = events_mutex
    +            .lock()
    +            .map_err(|_| NonoError::Snapshot("Network audit event lock poisoned".to_string()))?;
    +        events.push(event.clone());
    +    }
    +
    +    if let Some(recorder_mutex) = config.audit_recorder {
    +        let mut recorder = recorder_mutex
    +            .lock()
    +            .map_err(|_| NonoError::Snapshot("Audit recorder lock poisoned".to_string()))?;
    +        recorder.record_network_event(event)?;
    +    }
    +
    +    Ok(())
    +}
    +
    +fn network_audit_target(sockaddr: &nono::sandbox::SockaddrInfo) -> String {
    +    if sockaddr.family == libc::AF_UNIX as u16 {
    +        return match sockaddr.unix_kind {
    +            Some(nono::sandbox::UnixSocketKind::Pathname) => sockaddr
    +                .unix_path
    +                .as_ref()
    +                .map(|path| format!("unix:{}", path.display()))
    +                .unwrap_or_else(|| "unix:<unparsed>".to_string()),
    +            Some(nono::sandbox::UnixSocketKind::Abstract) => "unix:<abstract>".to_string(),
    +            Some(nono::sandbox::UnixSocketKind::Unnamed) | None => "unix:<unnamed>".to_string(),
    +        };
    +    }
    +
    +    format!(
    +        "family={} loopback={}",
    +        sockaddr.family, sockaddr.is_loopback
    +    )
    +}
    +
    +fn network_audit_denial_reason(sockaddr: &nono::sandbox::SockaddrInfo, syscall: i32) -> String {
    +    if sockaddr.family == libc::AF_UNIX as u16 {
    +        let op = unix_socket_op_for_syscall(syscall)
    +            .map(|op| op.to_string())
    +            .unwrap_or_else(|| format!("syscall {syscall}"));
    +        return match sockaddr.unix_kind {
    +            Some(nono::sandbox::UnixSocketKind::Pathname) => {
    +                format!("pathname AF_UNIX {op} denied: no matching unix_socket capability")
    +            }
    +            Some(nono::sandbox::UnixSocketKind::Abstract) => {
    +                format!("abstract AF_UNIX {op} denied: not covered by pathname capabilities")
    +            }
    +            Some(nono::sandbox::UnixSocketKind::Unnamed) | None => {
    +                format!("unnamed AF_UNIX {op} denied: no pathname to authorize")
    +            }
    +        };
    +    }
    +
    +    format!(
    +        "network syscall {syscall} denied for family={} port={}",
    +        sockaddr.family, sockaddr.port
    +    )
    +}
    +
    +fn current_unix_millis() -> u64 {
    +    std::time::SystemTime::now()
    +        .duration_since(std::time::UNIX_EPOCH)
    +        .map(|duration| duration.as_millis() as u64)
    +        .unwrap_or(0)
    +}
    +
     /// Check if a path matches any capability in the initial set.
     ///
     /// Prefers the most specific capability. If the path is covered but the
    @@ -1120,11 +1351,13 @@ mod tests {
                     open_url_origins: &[],
                     open_url_allow_localhost: false,
                     audit_recorder: None,
    +                network_audit_events: None,
                     redaction_policy: &REDACTION_POLICY,
                     allow_launch_services_active: false,
                     proxy_port,
                     proxy_bind_ports,
                     unix_socket_allowlist,
    +                linux_network_notify_mode: LinuxNetworkNotifyMode::ProxyOnly,
                 }
             }
     
    @@ -1188,6 +1421,15 @@ mod tests {
                 std::process::id()
             }
     
    +        fn make_af_unix_only_config<'a>(
    +            backend: &'a DenyAllBackend,
    +            unix_socket_allowlist: &'a [UnixSocketCapability],
    +        ) -> SupervisorConfig<'a> {
    +            let mut config = make_config(backend, 0, Vec::new(), unix_socket_allowlist);
    +            config.linux_network_notify_mode = LinuxNetworkNotifyMode::AfUnixOnly;
    +            config
    +        }
    +
             /// Pathname `bind(AF_UNIX, "/tmp/…")` is mediated by explicit
             /// Unix-socket grants, not TCP bind ports.
             #[test]
    @@ -1378,5 +1620,19 @@ mod tests {
                     NetworkDecision::Deny
                 );
             }
    +
    +        #[test]
    +        fn af_unix_only_mode_allows_non_af_unix_to_continue() {
    +            let backend = DenyAllBackend;
    +            let config = make_af_unix_only_config(&backend, &[]);
    +            assert_eq!(
    +                decide_network_notification(test_pid(), SYS_CONNECT, &inet_external(8080), &config),
    +                NetworkDecision::Allow
    +            );
    +            assert_eq!(
    +                decide_network_notification(test_pid(), SYS_BIND, &inet_loopback(4000), &config),
    +                NetworkDecision::Allow
    +            );
    +        }
         }
     }
    
  • crates/nono-cli/src/execution_runtime.rs+12 0 modified
    @@ -303,6 +303,16 @@ pub(crate) fn execute_sandboxed(plan: LaunchPlan) -> Result<()> {
             }
         };
     
    +    #[cfg(target_os = "linux")]
    +    if flags.af_unix_mediation.is_pathname() && nono::sandbox::is_wsl2() {
    +        return Err(NonoError::SandboxInit(
    +            "WSL2: linux.af_unix_mediation = \"pathname\" requires seccomp user notification, \
    +             but WSL2 reports EBUSY for seccomp notify listeners. Disable AF_UNIX mediation or \
    +             run on native Linux."
    +                .to_string(),
    +        ));
    +    }
    +
         let config = exec_strategy::ExecConfig {
             command: &command,
             resolved_program: &resolved_program,
    @@ -331,6 +341,8 @@ pub(crate) fn execute_sandboxed(plan: LaunchPlan) -> Result<()> {
             capability_elevation: flags.capability_elevation,
             #[cfg(target_os = "linux")]
             seccomp_proxy_fallback,
    +        #[cfg(target_os = "linux")]
    +        af_unix_mediation: flags.af_unix_mediation,
             allowed_env_vars: flags.allowed_env_vars,
             denied_env_vars: flags.denied_env_vars,
         };
    
  • crates/nono-cli/src/launch_runtime.rs+6 0 modified
    @@ -95,6 +95,8 @@ pub(crate) struct ExecutionFlags {
         pub(crate) capability_elevation: bool,
         #[cfg(target_os = "linux")]
         pub(crate) wsl2_proxy_policy: crate::profile::Wsl2ProxyPolicy,
    +    #[cfg(target_os = "linux")]
    +    pub(crate) af_unix_mediation: crate::profile::LinuxAfUnixMediation,
         pub(crate) bypass_protection_paths: Vec<PathBuf>,
         pub(crate) ignored_denial_paths: Vec<PathBuf>,
         pub(crate) session: SessionLaunchOptions,
    @@ -117,6 +119,8 @@ impl ExecutionFlags {
                 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,
                 bypass_protection_paths: Vec::new(),
                 ignored_denial_paths: Vec::new(),
                 session: SessionLaunchOptions::default(),
    @@ -237,6 +241,8 @@ pub(crate) fn prepare_run_launch_plan(
                 capability_elevation: prepared.capability_elevation,
                 #[cfg(target_os = "linux")]
                 wsl2_proxy_policy: prepared.wsl2_proxy_policy,
    +            #[cfg(target_os = "linux")]
    +            af_unix_mediation: prepared.af_unix_mediation,
                 bypass_protection_paths: prepared.bypass_protection_paths,
                 ignored_denial_paths: prepared.ignored_denial_paths,
                 session: SessionLaunchOptions {
    
  • crates/nono-cli/src/main.rs+4 0 modified
    @@ -267,6 +267,8 @@ mod tests {
                 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(),
    @@ -312,6 +314,8 @@ mod tests {
                 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(),
    
  • crates/nono-cli/src/policy.rs+1 0 modified
    @@ -163,6 +163,7 @@ impl ProfileDef {
                 commands: self.commands.clone(),
                 filesystem: self.filesystem.clone(),
                 network: self.network.clone(),
    +            linux: profile::LinuxConfig::default(),
                 env_credentials: self.env_credentials.clone(),
                 environment: None,
                 workdir: self.workdir.clone(),
    
  • crates/nono-cli/src/profile_cmd.rs+23 0 modified
    @@ -923,6 +923,13 @@ pub(crate) fn cmd_show(args: ProfileShowArgs) -> Result<()> {
                 theme::fg(&format!("{policy:?}"), t.text)
             );
         }
    +    if let Some(mode) = profile.linux.af_unix_mediation {
    +        println!(
    +            "  {} {}",
    +            theme::fg("Linux AF_UNIX mediation:", t.subtext),
    +            theme::fg(&format!("{mode:?}"), t.text)
    +        );
    +    }
     
         // Filesystem
         let fs = &profile.filesystem;
    @@ -1159,6 +1166,9 @@ fn profile_to_json(
             security.insert("wsl2_proxy_policy".into(), serde_json::json!(v));
         }
         val["security"] = serde_json::Value::Object(security);
    +    if let Some(v) = profile.linux.af_unix_mediation {
    +        val["linux"] = serde_json::json!({ "af_unix_mediation": v });
    +    }
     
         // Filesystem (canonical schema — `allow`/`read`/`write`/`*_file`/`deny`/
         // `bypass_protection`). Legacy keys deserialize into these fields via
    @@ -1389,6 +1399,12 @@ pub(crate) fn cmd_diff(args: ProfileDiffArgs) -> Result<()> {
             &p2.security.wsl2_proxy_policy.map(|v| format!("{v:?}")),
             t,
         );
    +    any_diff |= diff_scalar_option(
    +        "linux.af_unix_mediation",
    +        &p1.linux.af_unix_mediation.map(|v| format!("{v:?}")),
    +        &p2.linux.af_unix_mediation.map(|v| format!("{v:?}")),
    +        t,
    +    );
         any_diff |= diff_scalar_option(
             "signal_mode",
             &p1.security.signal_mode.map(|v| format!("{v:?}")),
    @@ -1900,6 +1916,13 @@ fn diff_to_json(name1: &str, name2: &str, p1: &Profile, p2: &Profile) -> serde_j
                 "profile2": p2.security.wsl2_proxy_policy,
                 "changed": p1.security.wsl2_proxy_policy != p2.security.wsl2_proxy_policy,
             },
    +        "linux": {
    +            "af_unix_mediation": {
    +                "profile1": p1.linux.af_unix_mediation,
    +                "profile2": p2.linux.af_unix_mediation,
    +                "changed": p1.linux.af_unix_mediation != p2.linux.af_unix_mediation,
    +            }
    +        },
             "filesystem": diff_fs_json(&p1.filesystem, &p2.filesystem),
             "workdir": {
                 "profile1": p1.workdir.access,
    
  • crates/nono-cli/src/profile/mod.rs+71 0 modified
    @@ -1232,6 +1232,40 @@ pub enum Wsl2ProxyPolicy {
         InsecureProxy,
     }
     
    +/// Linux pathname AF_UNIX seccomp mediation mode.
    +///
    +/// When set to `pathname`, pathname Unix socket `connect(2)` and `bind(2)`
    +/// calls are mediated by the supervisor and must match explicit
    +/// `filesystem.unix_socket*` grants. The default `off` mode preserves
    +/// compatibility: filesystem grants may still make pathname sockets reachable
    +/// on Landlock V4+.
    +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
    +#[serde(rename_all = "lowercase")]
    +pub enum LinuxAfUnixMediation {
    +    /// Do not install the V4+ AF_UNIX-only seccomp mediation filter.
    +    #[default]
    +    Off,
    +    /// Mediate pathname AF_UNIX sockets through explicit socket grants.
    +    Pathname,
    +}
    +
    +impl LinuxAfUnixMediation {
    +    #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
    +    #[must_use]
    +    pub fn is_pathname(self) -> bool {
    +        matches!(self, LinuxAfUnixMediation::Pathname)
    +    }
    +}
    +
    +/// Linux-specific profile controls.
    +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
    +#[serde(deny_unknown_fields)]
    +pub struct LinuxConfig {
    +    /// Opt-in pathname AF_UNIX mediation mode.
    +    #[serde(default)]
    +    pub af_unix_mediation: Option<LinuxAfUnixMediation>,
    +}
    +
     #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
     #[serde(rename_all = "lowercase")]
     pub enum WorkdirAccess {
    @@ -1419,6 +1453,8 @@ pub struct Profile {
         pub filesystem: FilesystemConfig,
         #[serde(default)]
         pub network: NetworkConfig,
    +    #[serde(default)]
    +    pub linux: LinuxConfig,
         /// ALIAS(canonical="env_credentials", introduced="v0.0.0", remove_by="indefinite", issue="#143")
         #[serde(default, alias = "secrets")]
         pub env_credentials: SecretsConfig,
    @@ -1505,6 +1541,8 @@ struct ProfileDeserialize {
         policy: crate::deprecated_schema::LegacyPolicyPatch,
         #[serde(default)]
         network: NetworkConfig,
    +    #[serde(default)]
    +    linux: LinuxConfig,
         /// ALIAS(canonical="env_credentials", introduced="v0.0.0", remove_by="indefinite", issue="#143")
         #[serde(default, alias = "secrets")]
         env_credentials: SecretsConfig,
    @@ -1553,6 +1591,7 @@ impl From<ProfileDeserialize> for Profile {
                 commands: raw.commands,
                 filesystem: raw.filesystem,
                 network: raw.network,
    +            linux: raw.linux,
                 env_credentials: raw.env_credentials,
                 environment: raw.environment,
                 workdir: raw.workdir,
    @@ -2410,6 +2449,12 @@ fn merge_profiles(base: Profile, child: Profile) -> Profile {
                     &child.network.upstream_bypass,
                 ),
             },
    +        linux: LinuxConfig {
    +            af_unix_mediation: child
    +                .linux
    +                .af_unix_mediation
    +                .or(base.linux.af_unix_mediation),
    +        },
             env_credentials: SecretsConfig {
                 mappings: {
                     let mut merged = base.env_credentials.mappings;
    @@ -4258,6 +4303,7 @@ mod tests {
                     upstream_proxy: None,
                     upstream_bypass: Vec::new(),
                 },
    +            linux: LinuxConfig::default(),
                 env_credentials: SecretsConfig {
                     mappings: {
                         let mut m = HashMap::new();
    @@ -4335,6 +4381,7 @@ mod tests {
                     upstream_proxy: None,
                     upstream_bypass: Vec::new(),
                 },
    +            linux: LinuxConfig::default(),
                 env_credentials: SecretsConfig {
                     mappings: {
                         let mut m = HashMap::new();
    @@ -4391,6 +4438,30 @@ mod tests {
             assert_eq!(merged.network.open_port, vec![3000, 5000]);
         }
     
    +    #[test]
    +    fn test_profile_parses_linux_af_unix_mediation() {
    +        let json = r#"{
    +            "meta": {"name": "linux-ipc", "version": "1.0"},
    +            "linux": {"af_unix_mediation": "pathname"}
    +        }"#;
    +        let profile: Profile = serde_json::from_str(json).expect("parse profile");
    +        assert_eq!(
    +            profile.linux.af_unix_mediation,
    +            Some(LinuxAfUnixMediation::Pathname)
    +        );
    +    }
    +
    +    #[test]
    +    fn test_merge_profiles_inherits_linux_af_unix_mediation() {
    +        let mut base = base_profile();
    +        base.linux.af_unix_mediation = Some(LinuxAfUnixMediation::Pathname);
    +        let merged = merge_profiles(base, child_profile());
    +        assert_eq!(
    +            merged.linux.af_unix_mediation,
    +            Some(LinuxAfUnixMediation::Pathname)
    +        );
    +    }
    +
         #[test]
         fn test_merge_profiles_appends_security_groups() {
             let merged = merge_profiles(base_profile(), child_profile());
    
  • crates/nono-cli/src/profile_runtime.rs+7 0 modified
    @@ -9,6 +9,8 @@ pub(crate) struct PreparedProfile {
         pub(crate) capability_elevation: bool,
         #[cfg(target_os = "linux")]
         pub(crate) wsl2_proxy_policy: profile::Wsl2ProxyPolicy,
    +    #[cfg(target_os = "linux")]
    +    pub(crate) af_unix_mediation: profile::LinuxAfUnixMediation,
         pub(crate) workdir_access: Option<profile::WorkdirAccess>,
         pub(crate) rollback_exclude_patterns: Vec<String>,
         pub(crate) rollback_exclude_globs: Vec<String>,
    @@ -352,6 +354,11 @@ fn prepare_profile_with_options(
                 .as_ref()
                 .and_then(|profile| profile.security.wsl2_proxy_policy)
                 .unwrap_or_default(),
    +        #[cfg(target_os = "linux")]
    +        af_unix_mediation: loaded_profile
    +            .as_ref()
    +            .and_then(|profile| profile.linux.af_unix_mediation)
    +            .unwrap_or_default(),
             workdir_access: loaded_profile
                 .as_ref()
                 .map(|profile| profile.workdir.access.clone()),
    
  • crates/nono-cli/src/rollback_runtime.rs+9 0 modified
    @@ -36,6 +36,8 @@ pub(crate) struct RollbackExitContext<'a> {
         pub(crate) audit_snapshot_state: Option<AuditSnapshotState>,
         pub(crate) audit_tracked_paths: Vec<PathBuf>,
         pub(crate) audit_recorder: Option<&'a Mutex<AuditRecorder>>,
    +    pub(crate) supervisor_network_audit_events:
    +        Option<&'a Mutex<Vec<nono::undo::NetworkAuditEvent>>>,
         pub(crate) audit_integrity_enabled: bool,
         pub(crate) proxy_handle: Option<&'a nono_proxy::server::ProxyHandle>,
         pub(crate) executable_identity: Option<&'a ExecutableIdentity>,
    @@ -460,6 +462,7 @@ pub(crate) fn finalize_supervised_exit(ctx: RollbackExitContext<'_>) -> Result<(
             audit_snapshot_state,
             audit_tracked_paths,
             audit_recorder,
    +        supervisor_network_audit_events,
             audit_integrity_enabled,
             proxy_handle,
             executable_identity,
    @@ -477,6 +480,12 @@ pub(crate) fn finalize_supervised_exit(ctx: RollbackExitContext<'_>) -> Result<(
             Vec::new,
             nono_proxy::server::ProxyHandle::drain_audit_events,
         );
    +    if let Some(events_mutex) = supervisor_network_audit_events {
    +        let mut supervisor_events = events_mutex.lock().map_err(|_| {
    +            nono::NonoError::Snapshot("Network audit event lock poisoned".to_string())
    +        })?;
    +        network_events.extend(supervisor_events.drain(..));
    +    }
         let (audit_event_count, audit_integrity) = if let Some(recorder_mutex) = audit_recorder {
             let mut recorder = recorder_mutex
                 .lock()
    
  • crates/nono-cli/src/sandbox_prepare.rs+8 0 modified
    @@ -429,6 +429,8 @@ pub(crate) struct PreparedSandbox {
         pub(crate) capability_elevation: bool,
         #[cfg(target_os = "linux")]
         pub(crate) wsl2_proxy_policy: crate::profile::Wsl2ProxyPolicy,
    +    #[cfg(target_os = "linux")]
    +    pub(crate) af_unix_mediation: crate::profile::LinuxAfUnixMediation,
         pub(crate) allow_launch_services_active: bool,
         pub(crate) allow_gpu_active: bool,
         pub(crate) open_url_origins: Vec<String>,
    @@ -1015,6 +1017,8 @@ pub(crate) fn prepare_sandbox(args: &SandboxArgs, silent: bool) -> Result<Prepar
                     capability_elevation: false,
                     #[cfg(target_os = "linux")]
                     wsl2_proxy_policy: crate::profile::Wsl2ProxyPolicy::default(),
    +                #[cfg(target_os = "linux")]
    +                af_unix_mediation: crate::profile::LinuxAfUnixMediation::default(),
                     allow_launch_services_active: false,
                     allow_gpu_active: false,
                     open_url_origins: Vec::new(),
    @@ -1035,6 +1039,8 @@ pub(crate) fn prepare_sandbox(args: &SandboxArgs, silent: bool) -> Result<Prepar
             capability_elevation,
             #[cfg(target_os = "linux")]
             wsl2_proxy_policy,
    +        #[cfg(target_os = "linux")]
    +        af_unix_mediation,
             workdir_access: profile_workdir_access,
             rollback_exclude_patterns: profile_rollback_patterns,
             rollback_exclude_globs: profile_rollback_globs,
    @@ -1284,6 +1290,8 @@ pub(crate) fn prepare_sandbox(args: &SandboxArgs, silent: bool) -> Result<Prepar
                 capability_elevation,
                 #[cfg(target_os = "linux")]
                 wsl2_proxy_policy,
    +            #[cfg(target_os = "linux")]
    +            af_unix_mediation,
                 allow_launch_services_active,
                 allow_gpu_active,
                 open_url_origins,
    
  • crates/nono-cli/src/setup.rs+7 0 modified
    @@ -201,6 +201,13 @@ impl SetupRunner {
                 );
             }
     
    +        println!("  * Linux AF_UNIX mediation: off by default");
    +        println!("    - For stricter IPC isolation, set linux.af_unix_mediation = \"pathname\"");
    +        println!("    - Then grant required pathname sockets with filesystem.unix_socket entries");
    +        println!(
    +            "    - In public-facing or privacy-sensitive deployments that keep it off, run nono inside a stronger outer boundary such as a MicroVM"
    +        );
    +
             println!("  * Filesystem ruleset creation verified");
     
             // WSL2 environment detection and feature matrix
    
  • crates/nono-cli/src/supervised_runtime.rs+11 0 modified
    @@ -213,6 +213,9 @@ pub(crate) fn execute_supervised_runtime(ctx: SupervisedRuntimeContext<'_>) -> R
         } else {
             None
         };
    +    let supervisor_network_audit_events = audit_state
    +        .as_ref()
    +        .map(|_| std::sync::Mutex::new(Vec::new()));
         if let Some(recorder_mutex) = audit_recorder.as_ref() {
             let mut recorder = recorder_mutex
                 .lock()
    @@ -232,6 +235,7 @@ pub(crate) fn execute_supervised_runtime(ctx: SupervisedRuntimeContext<'_>) -> R
             open_url_origins: &proxy.open_url_origins,
             open_url_allow_localhost: proxy.open_url_allow_localhost,
             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,
             #[cfg(target_os = "linux")]
    @@ -246,6 +250,12 @@ pub(crate) fn execute_supervised_runtime(ctx: SupervisedRuntimeContext<'_>) -> R
             },
             #[cfg(target_os = "linux")]
             unix_socket_allowlist: caps.unix_socket_capabilities(),
    +        #[cfg(target_os = "linux")]
    +        linux_network_notify_mode: if config.seccomp_proxy_fallback {
    +            exec_strategy::LinuxNetworkNotifyMode::ProxyOnly
    +        } else {
    +            exec_strategy::LinuxNetworkNotifyMode::AfUnixOnly
    +        },
         };
     
         let exit_code = {
    @@ -273,6 +283,7 @@ pub(crate) fn execute_supervised_runtime(ctx: SupervisedRuntimeContext<'_>) -> R
             audit_snapshot_state,
             audit_tracked_paths,
             audit_recorder: audit_recorder.as_ref(),
    +        supervisor_network_audit_events: supervisor_network_audit_events.as_ref(),
             audit_integrity_enabled: !rollback.no_audit_integrity,
             proxy_handle,
             executable_identity,
    
  • crates/nono-cli/tests/schema_shape.rs+17 0 modified
    @@ -33,6 +33,23 @@ fn test_schema_has_canonical_top_level_commands() {
         );
     }
     
    +#[test]
    +fn test_schema_has_linux_af_unix_mediation() {
    +    let schema = load_schema();
    +    assert!(
    +        schema.pointer("/properties/linux").is_some(),
    +        "schema is missing canonical /properties/linux"
    +    );
    +    let props = schema
    +        .pointer("/$defs/LinuxConfig/properties")
    +        .and_then(Value::as_object)
    +        .expect("LinuxConfig.properties is an object");
    +    assert!(
    +        props.contains_key("af_unix_mediation"),
    +        "LinuxConfig.af_unix_mediation missing from canonical schema"
    +    );
    +}
    +
     #[test]
     fn test_schema_groups_has_include_and_exclude() {
         let schema = load_schema();
    
  • crates/nono/src/diagnostic.rs+129 6 modified
    @@ -31,6 +31,8 @@ pub enum DenialReason {
         RateLimited,
         /// Approval backend returned an error
         BackendError,
    +    /// Pathname Unix socket was denied by IPC mediation
    +    UnixSocketDenied,
     }
     
     /// Record of a denied access attempt during a supervised session.
    @@ -44,6 +46,19 @@ pub struct DenialRecord {
         pub reason: DenialReason,
     }
     
    +/// Record of a denied IPC attempt during a supervised session.
    +#[derive(Debug, Clone)]
    +pub struct IpcDenialRecord {
    +    /// IPC resource that was denied, e.g. `/run/user/1000/bus` or `unix:<abstract>`.
    +    pub target: String,
    +    /// Operation attempted, e.g. `connect` or `bind`.
    +    pub operation: String,
    +    /// Why it was denied.
    +    pub reason: String,
    +    /// Suggested CLI flag when this denial can be fixed by an explicit grant.
    +    pub suggested_flag: Option<String>,
    +}
    +
     /// Best-effort sandbox violation recovered from OS-native logging.
     ///
     /// On macOS, Seatbelt does not stream deny events back to the supervisor like
    @@ -616,6 +631,7 @@ 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],
    @@ -647,6 +663,7 @@ impl<'a> DiagnosticFormatter<'a> {
                 caps,
                 mode: DiagnosticMode::Standard,
                 denials: &[],
    +            ipc_denials: &[],
                 sandbox_violations: &[],
                 protected_paths: &[],
                 primary_verdict: None,
    @@ -675,6 +692,13 @@ impl<'a> DiagnosticFormatter<'a> {
             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 {
    @@ -1069,14 +1093,17 @@ impl<'a> DiagnosticFormatter<'a> {
             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() {
    +        } 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));
    @@ -1097,7 +1124,14 @@ impl<'a> DiagnosticFormatter<'a> {
                 .collect();
             all_denials.extend(self.observed_denials_matching_logged_paths(&all_denials));
     
    -        if all_denials.is_empty() {
    +        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.) —
    @@ -1121,7 +1155,7 @@ impl<'a> DiagnosticFormatter<'a> {
                 self.format_grant_help(&mut lines);
                 lines.push("[nono]".to_string());
                 self.format_follow_up_guidance(&mut lines, None);
    -        } else {
    +        } 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.
    @@ -1350,11 +1384,40 @@ impl<'a> DiagnosticFormatter<'a> {
         ) {
             const MAX_INLINE_LIST: usize = 10;
     
    -        let total = denials.len();
    +        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<String> = unix_socket_denials
    +                .iter()
    +                .map(|d| self.suggested_flag_for_denial(d))
    +                .collect();
    +            lines.push(format!("[nono] Fix: {}", 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 denials {
    +        for denial in &path_denials {
                 if self.is_denial_policy_blocked(denial) {
                     policy_blocked.push(denial);
                 } else {
    @@ -1368,7 +1431,7 @@ impl<'a> DiagnosticFormatter<'a> {
                 total, plural_s
             ));
     
    -        for (idx, denial) in denials.iter().enumerate() {
    +        for (idx, denial) in path_denials.iter().enumerate() {
                 if idx >= MAX_INLINE_LIST {
                     lines.push(format!("[nono]   … and {} more", total - idx));
                     break;
    @@ -1414,6 +1477,36 @@ impl<'a> DiagnosticFormatter<'a> {
             }
         }
     
    +    fn format_ipc_denial_guidance(&self, lines: &mut Vec<String>) {
    +        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.join(" ")));
    +        }
    +    }
    +
         /// 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`.
    @@ -1434,6 +1527,15 @@ impl<'a> DiagnosticFormatter<'a> {
         /// 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()
    @@ -1971,6 +2073,7 @@ 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,
    @@ -3113,6 +3216,26 @@ mod tests {
             assert!(!output.contains("--allow <path>"));
         }
     
    +    #[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: --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();
    
  • crates/nono/src/lib.rs+1 1 modified
    @@ -68,7 +68,7 @@ pub use capability::{
     };
     pub use diagnostic::{
         CommandContext, DenialReason, DenialRecord, DiagnosticFormatter, DiagnosticMode,
    -    SandboxViolation,
    +    IpcDenialRecord, SandboxViolation,
     };
     pub use error::{NonoError, Result};
     pub use keystore::{
    
  • crates/nono/src/sandbox/linux.rs+87 11 modified
    @@ -1557,8 +1557,9 @@ pub fn respond_notif_errno(notify_fd: std::os::fd::RawFd, notif_id: u64, errno:
     
     /// Continue a seccomp notification, letting the child's original syscall run.
     ///
    -/// This preserves the original syscall semantics exactly. It is safe only when
    -/// the syscall is already authorized by the sandbox's allow-list.
    +/// This resumes the original syscall with its original userspace arguments.
    +/// Do not use it after making an authorization decision from child-controlled
    +/// pointer memory unless the policy accepts the resulting TOCTOU window.
     pub fn continue_notif(notify_fd: std::os::fd::RawFd, notif_id: u64) -> Result<()> {
         let resp = SeccompNotifResp {
             id: notif_id,
    @@ -1929,6 +1930,55 @@ fn build_seccomp_proxy_filter(_has_bind_ports: bool) -> Vec<SockFilterInsn> {
         ]
     }
     
    +/// 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.
    +///
    +/// 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
    +/// ```
    +fn build_seccomp_af_unix_filter() -> Vec<SockFilterInsn> {
    +    vec![
    +        SockFilterInsn {
    +            code: BPF_LD | BPF_W | BPF_ABS,
    +            jt: 0,
    +            jf: 0,
    +            k: SECCOMP_DATA_NR_OFFSET,
    +        },
    +        SockFilterInsn {
    +            code: BPF_JMP | BPF_JEQ | BPF_K,
    +            jt: 2,
    +            jf: 0,
    +            k: SYS_CONNECT as u32,
    +        },
    +        SockFilterInsn {
    +            code: BPF_JMP | BPF_JEQ | BPF_K,
    +            jt: 1,
    +            jf: 0,
    +            k: SYS_BIND as u32,
    +        },
    +        SockFilterInsn {
    +            code: BPF_RET | BPF_K,
    +            jt: 0,
    +            jf: 0,
    +            k: SECCOMP_RET_ALLOW,
    +        },
    +        SockFilterInsn {
    +            code: BPF_RET | BPF_K,
    +            jt: 0,
    +            jf: 0,
    +            k: SECCOMP_RET_USER_NOTIF,
    +        },
    +    ]
    +}
    +
     /// Install a seccomp-notify BPF filter for proxy-only network mode.
     ///
     /// Returns the notify fd that the supervisor must poll for connect/bind
    @@ -1941,9 +1991,23 @@ fn build_seccomp_proxy_filter(_has_bind_ports: bool) -> Vec<SockFilterInsn> {
     ///
     /// Returns an error if the seccomp syscall fails.
     pub fn install_seccomp_proxy_filter(has_bind_ports: bool) -> Result<std::os::fd::OwnedFd> {
    -    use std::os::fd::FromRawFd;
    +    install_seccomp_notify_filter(&build_seccomp_proxy_filter(has_bind_ports), "proxy filter")
    +}
     
    -    let filter = build_seccomp_proxy_filter(has_bind_ports);
    +/// Install a seccomp-notify BPF filter for pathname AF_UNIX mediation.
    +///
    +/// # Errors
    +///
    +/// Returns an error if the seccomp syscall fails.
    +pub fn install_seccomp_af_unix_filter() -> Result<std::os::fd::OwnedFd> {
    +    install_seccomp_notify_filter(&build_seccomp_af_unix_filter(), "AF_UNIX mediation filter")
    +}
    +
    +fn install_seccomp_notify_filter(
    +    filter: &[SockFilterInsn],
    +    label: &str,
    +) -> Result<std::os::fd::OwnedFd> {
    +    use std::os::fd::FromRawFd;
     
         let prog = SockFprog {
             len: filter.len() as u16,
    @@ -1990,8 +2054,9 @@ pub fn install_seccomp_proxy_filter(has_bind_ports: bool) -> Result<std::os::fd:
     
             if ret < 0 {
                 return Err(NonoError::SandboxInit(format!(
    -                "seccomp(SECCOMP_SET_MODE_FILTER) for proxy filter failed: {}. \
    +                "seccomp(SECCOMP_SET_MODE_FILTER) for {} failed: {}. \
                      Requires kernel >= 5.0 with SECCOMP_FILTER_FLAG_NEW_LISTENER.",
    +                label,
                     std::io::Error::last_os_error()
                 )));
             }
    @@ -2012,12 +2077,11 @@ pub fn install_seccomp_proxy_filter(has_bind_ports: bool) -> Result<std::os::fd:
     ///
     /// # TOCTOU Warning
     ///
    -/// For connect/bind, the kernel copies sockaddr into kernel memory via
    -/// `move_addr_to_kernel()` before the seccomp filter runs. The userspace
    -/// copy we read here may differ from what the kernel uses, but we use
    -/// `SECCOMP_USER_NOTIF_FLAG_CONTINUE` which lets the kernel proceed with
    -/// its already-copied data. The userspace read is only used for the
    -/// allow/deny decision.
    +/// Seccomp notification happens at syscall entry, before `connect(2)` or
    +/// `bind(2)` copies the sockaddr into kernel memory. The userspace memory
    +/// read here is therefore child-controlled and can race with another thread.
    +/// Callers must not use `SECCOMP_USER_NOTIF_FLAG_CONTINUE` after authorizing
    +/// pointer-derived data unless that TOCTOU window is explicitly acceptable.
     ///
     /// Always call `notif_id_valid()` after reading to verify the notification
     /// is still pending.
    @@ -3235,6 +3299,18 @@ mod tests {
             );
         }
     
    +    #[test]
    +    fn test_build_seccomp_af_unix_filter_notifies_connect_bind_only() {
    +        let filter = build_seccomp_af_unix_filter();
    +        assert_eq!(filter.len(), 5);
    +        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);
    +    }
    +
         #[test]
         fn test_sockaddr_info_ipv4_loopback() {
             let info = SockaddrInfo {
    
  • crates/nono/src/sandbox/mod.rs+4 3 modified
    @@ -31,9 +31,10 @@ pub use linux::is_wsl2;
     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_notify, install_seccomp_proxy_filter,
    -    notif_id_valid, probe_seccomp_block_network_support, read_notif_path, read_notif_sockaddr,
    -    read_open_how, recv_notif, resolve_notif_path, respond_notif_errno, validate_openat2_size,
    +    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_notif_path, read_notif_sockaddr, read_open_how, recv_notif, resolve_notif_path,
    +    respond_notif_errno, validate_openat2_size,
     };
     
     /// Information about sandbox support on this platform
    
  • docs/cli/internals/landlock.mdx+49 0 modified
    @@ -130,6 +130,55 @@ nono why --scope signal --profile claude-code
     nono why --scope abstract-unix-socket --profile claude-code
     ```
     
    +## Pathname Unix Socket Mediation
    +
    +Landlock filesystem rules and pathname Unix socket IPC are separate security
    +surfaces. On Linux, a process that can reach a filesystem-backed Unix socket
    +path may be able to communicate with a user-session service through that
    +socket unless nono separately mediates AF_UNIX `connect(2)` and `bind(2)`.
    +
    +For compatibility, this mediation is off by default on Landlock V4+ kernels.
    +Profiles that need stricter IPC isolation can opt in:
    +
    +```json
    +{
    +  "linux": {
    +    "af_unix_mediation": "pathname"
    +  },
    +  "filesystem": {
    +    "unix_socket": ["$XDG_RUNTIME_DIR/bus"],
    +    "unix_socket_dir_bind": ["$TMPDIR/my-tool"]
    +  }
    +}
    +```
    +
    +When enabled, pathname AF_UNIX sockets are default-deny and must match explicit
    +`filesystem.unix_socket*` grants. Broad filesystem read/write grants no longer
    +imply permission to talk to every socket under those paths.
    +
    +Allowed pathname sockets are resumed through Linux seccomp user notification.
    +Because seccomp receives the syscall before the kernel copies `sockaddr_un`
    +from userspace, a multi-threaded sandboxed process can race an allowed
    +`connect(2)` or `bind(2)` by mutating that userspace address before the kernel
    +continues the syscall. Denied sockets do not have this race because nono returns
    +`EACCES` directly and the kernel never runs the original syscall. Treat this
    +mode as a strong default-deny IPC hardening layer; avoid broad socket grants for
    +untrusted multi-threaded workloads.
    +
    +High-risk socket categories include user service managers, session buses,
    +keyring and credential agents, SSH/GPG agents, container runtimes, and package
    +manager daemons. Hardened profiles should grant only the specific sockets they
    +need.
    +
    +<Warning>
    +  If you run with `linux.af_unix_mediation` off in a public-facing,
    +  multi-tenant, privacy-sensitive, or otherwise high-risk deployment, run nono
    +  inside a stronger outer isolation boundary such as a MicroVM. Firecracker
    +  style MicroVMs are a good fit for service deployments where the VM boundary
    +  can isolate host user-session services and credentials from the sandboxed
    +  agent.
    +</Warning>
    +
     ## Enforcement Status
     
     nono reports the enforcement status after applying the sandbox:
    
1e9385a748bc

feat(sandbox): add explicit allowlist for pathname af_unix sockets

https://github.com/always-further/nonoLuke HindsMay 13, 2026Fixed in 0.55.0via llm-release-walk
4 files changed · +324 67
  • crates/nono-cli/src/exec_strategy.rs+19 0 modified
    @@ -285,6 +285,9 @@ pub struct SupervisorConfig<'a> {
         /// Bind ports allowed for seccomp proxy-only fallback.
         #[cfg(target_os = "linux")]
         pub proxy_bind_ports: Vec<u16>,
    +    /// Pathname AF_UNIX socket grants allowed for seccomp proxy-only fallback.
    +    #[cfg(target_os = "linux")]
    +    pub unix_socket_allowlist: &'a [nono::UnixSocketCapability],
     }
     
     #[cfg(target_os = "macos")]
    @@ -3705,6 +3708,8 @@ mod tests {
                 proxy_port: 0,
                 #[cfg(target_os = "linux")]
                 proxy_bind_ports: Vec::new(),
    +            #[cfg(target_os = "linux")]
    +            unix_socket_allowlist: &[],
             };
     
             // Fork a child that closes its socket end and exits immediately.
    @@ -3805,6 +3810,8 @@ mod tests {
                 proxy_port: 8080,
                 #[cfg(target_os = "linux")]
                 proxy_bind_ports: Vec::new(),
    +            #[cfg(target_os = "linux")]
    +            unix_socket_allowlist: &[],
             };
     
             match unsafe { fork() } {
    @@ -3881,6 +3888,8 @@ mod tests {
                 proxy_port: 0,
                 #[cfg(target_os = "linux")]
                 proxy_bind_ports: Vec::new(),
    +            #[cfg(target_os = "linux")]
    +            unix_socket_allowlist: &[],
             };
     
             // Allowed origin: validation passes
    @@ -3917,6 +3926,8 @@ mod tests {
                 proxy_port: 0,
                 #[cfg(target_os = "linux")]
                 proxy_bind_ports: Vec::new(),
    +            #[cfg(target_os = "linux")]
    +            unix_socket_allowlist: &[],
             };
     
             let result = validate_url("file:///etc/passwd", &config);
    @@ -3951,6 +3962,8 @@ mod tests {
                 proxy_port: 0,
                 #[cfg(target_os = "linux")]
                 proxy_bind_ports: Vec::new(),
    +            #[cfg(target_os = "linux")]
    +            unix_socket_allowlist: &[],
             };
             let config_deny = SupervisorConfig {
                 protected_roots: &[],
    @@ -3967,6 +3980,8 @@ mod tests {
                 proxy_port: 0,
                 #[cfg(target_os = "linux")]
                 proxy_bind_ports: Vec::new(),
    +            #[cfg(target_os = "linux")]
    +            unix_socket_allowlist: &[],
             };
     
             // Localhost denied when not allowed
    @@ -4006,6 +4021,8 @@ mod tests {
                 proxy_port: 0,
                 #[cfg(target_os = "linux")]
                 proxy_bind_ports: Vec::new(),
    +            #[cfg(target_os = "linux")]
    +            unix_socket_allowlist: &[],
             };
     
             let long_url = format!("https://example.com/{}", "a".repeat(MAX_URL_LENGTH));
    @@ -4148,6 +4165,8 @@ mod tests {
                 proxy_port: 0,
                 #[cfg(target_os = "linux")]
                 proxy_bind_ports: Vec::new(),
    +            #[cfg(target_os = "linux")]
    +            unix_socket_allowlist: &[],
             };
     
             assert!(
    
  • crates/nono-cli/src/exec_strategy/supervisor_linux.rs+252 52 modified
    @@ -11,7 +11,7 @@
     
     use super::*;
     use crate::trust_intercept::TrustInterceptor;
    -use nono::{AccessMode, try_canonicalize};
    +use nono::{AccessMode, UnixSocketCapability, UnixSocketOp, try_canonicalize};
     
     #[derive(Debug, Clone, PartialEq, Eq)]
     pub(super) struct InitialCapability {
    @@ -553,19 +553,15 @@ pub(super) enum NetworkDecision {
     ///
     /// Policy:
     ///
    -/// 1. **Pathname `AF_UNIX` is allowed** (issue #685). Filesystem-backed Unix
    -///    sockets like `/tmp/test.sock` are IPC bound to a real path, so
    -///    Landlock's filesystem rules decide access: a bind/connect succeeds
    -///    only if the path is inside an allowed grant, and fails otherwise.
    -///    This restores parity with Landlock V4+, where `LANDLOCK_ACCESS_NET_*`
    -///    only scopes TCP and pathname `AF_UNIX` is governed by fs rules.
    +/// 1. **Pathname `AF_UNIX` is allowlist-mediated.** Filesystem-backed Unix
    +///    sockets like `/tmp/test.sock` are IPC bound to a real path, so the
    +///    supervisor canonicalizes that path and checks it against explicit
    +///    [`UnixSocketCapability`] grants.
     ///
     ///    **Abstract and unnamed `AF_UNIX` are denied.** The abstract namespace
    -///    (`sun_path[0] == '\0'`) lives outside the filesystem, so Landlock has
    -///    no way to mediate it — a blanket allow would open a covert IPC
    -///    channel that bypasses the sandbox. Unnamed sockets (addrlen == 2)
    -///    have no path to check and no use case that motivated this fix.
    -///    Future work (#696) may add an explicit allowlist for abstract paths.
    +///    (`sun_path[0] == '\0'`) lives outside the filesystem, so pathname
    +///    capabilities cannot mediate it. Unnamed sockets (addrlen == 2) have
    +///    no path to check.
     ///
     /// 2. For `AF_INET`/`AF_INET6`:
     ///    - `connect()` is allowed only to `127.0.0.1:proxy_port` (the nono proxy).
    @@ -578,23 +574,18 @@ pub(super) fn decide_network_notification(
     ) -> NetworkDecision {
         use nono::sandbox::{SYS_BIND, SYS_CONNECT, UnixSocketKind};
     
    -    // AF_UNIX: allow only filesystem-backed (pathname) sockets — Landlock's
    -    // filesystem rules will then decide whether the specific path is
    -    // reachable. Abstract/unnamed sockets bypass fs rules, so deny them.
    +    // AF_UNIX: allow only filesystem-backed (pathname) sockets that match an
    +    // explicit socket capability. Abstract/unnamed sockets bypass pathname
    +    // mediation, so deny them.
         if sockaddr.family == libc::AF_UNIX as u16 {
             match sockaddr.unix_kind {
                 Some(UnixSocketKind::Pathname) => {
    -                debug!(
    -                    "Proxy seccomp: allowing AF_UNIX pathname syscall (nr={}); \
    -                     governed by Landlock fs rules",
    -                    syscall
    -                );
    -                return NetworkDecision::Allow;
    +                return decide_af_unix_pathname(syscall, sockaddr, config);
                 }
                 Some(UnixSocketKind::Abstract) => {
                     debug!(
                         "Proxy seccomp: denying AF_UNIX abstract-namespace syscall (nr={}); \
    -                     not mediated by Landlock fs rules",
    +                     not mediated by pathname socket capabilities",
                         syscall
                     );
                     return NetworkDecision::Deny;
    @@ -649,6 +640,124 @@ pub(super) fn decide_network_notification(
         }
     }
     
    +fn decide_af_unix_pathname(
    +    syscall: i32,
    +    sockaddr: &nono::sandbox::SockaddrInfo,
    +    config: &SupervisorConfig<'_>,
    +) -> NetworkDecision {
    +    let Some(path) = sockaddr.unix_path.as_deref() else {
    +        debug!(
    +            "Proxy seccomp: denying AF_UNIX pathname syscall (nr={}) without parsed path",
    +            syscall
    +        );
    +        return NetworkDecision::Deny;
    +    };
    +
    +    let Some(op) = unix_socket_op_for_syscall(syscall) else {
    +        warn!(
    +            "Unexpected AF_UNIX syscall {} in proxy seccomp handler, denying",
    +            syscall
    +        );
    +        return NetworkDecision::Deny;
    +    };
    +
    +    let canonical = match op {
    +        UnixSocketOp::Connect => match path.canonicalize() {
    +            Ok(path) => path,
    +            Err(err) => {
    +                debug!(
    +                    "Proxy seccomp: denying AF_UNIX connect to {}: canonicalize failed: {}",
    +                    path.display(),
    +                    err
    +                );
    +                return NetworkDecision::Deny;
    +            }
    +        },
    +        UnixSocketOp::Bind => match canonicalize_unix_socket_bind_path(path) {
    +            Ok(path) => path,
    +            Err(err) => {
    +                debug!(
    +                    "Proxy seccomp: denying AF_UNIX bind to {}: canonicalize failed: {}",
    +                    path.display(),
    +                    err
    +                );
    +                return NetworkDecision::Deny;
    +            }
    +        },
    +    };
    +
    +    if unix_socket_allowlist_allows(config.unix_socket_allowlist, canonical.as_path(), op) {
    +        debug!(
    +            "Proxy seccomp: allowing AF_UNIX {} on {}",
    +            op,
    +            canonical.display()
    +        );
    +        NetworkDecision::Allow
    +    } else {
    +        debug!(
    +            "Proxy seccomp: denying AF_UNIX {} on {}: no matching capability",
    +            op,
    +            canonical.display()
    +        );
    +        NetworkDecision::Deny
    +    }
    +}
    +
    +fn unix_socket_op_for_syscall(syscall: i32) -> Option<UnixSocketOp> {
    +    use nono::sandbox::{SYS_BIND, SYS_CONNECT};
    +
    +    match syscall {
    +        SYS_CONNECT => Some(UnixSocketOp::Connect),
    +        SYS_BIND => Some(UnixSocketOp::Bind),
    +        _ => None,
    +    }
    +}
    +
    +fn unix_socket_allowlist_allows(
    +    allowlist: &[UnixSocketCapability],
    +    path: &std::path::Path,
    +    op: UnixSocketOp,
    +) -> bool {
    +    allowlist.iter().any(|cap| {
    +        cap.covers(path)
    +            && match op {
    +                UnixSocketOp::Connect => true,
    +                UnixSocketOp::Bind => cap.mode.permits_bind(),
    +            }
    +    })
    +}
    +
    +fn canonicalize_unix_socket_bind_path(
    +    path: &std::path::Path,
    +) -> std::io::Result<std::path::PathBuf> {
    +    match path.canonicalize() {
    +        Ok(path) => Ok(path),
    +        Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
    +            let parent = path.parent().ok_or_else(|| {
    +                std::io::Error::new(
    +                    std::io::ErrorKind::InvalidInput,
    +                    "socket path has no parent directory",
    +                )
    +            })?;
    +            let file_name = path.file_name().ok_or_else(|| {
    +                std::io::Error::new(
    +                    std::io::ErrorKind::InvalidInput,
    +                    "socket path has no final component",
    +                )
    +            })?;
    +            let resolved_parent = parent.canonicalize()?;
    +            if !resolved_parent.is_dir() {
    +                return Err(std::io::Error::new(
    +                    std::io::ErrorKind::InvalidInput,
    +                    "socket parent is not a directory",
    +                ));
    +            }
    +            Ok(resolved_parent.join(file_name))
    +        }
    +        Err(err) => Err(err),
    +    }
    +}
    +
     /// Handle a seccomp notification for connect() or bind() syscalls.
     ///
     /// This is the proxy-only fallback for kernels without Landlock AccessNet.
    @@ -941,16 +1050,18 @@ mod tests {
         // --- decide_network_notification tests (issue #685) ---------------------
         //
         // These exercise the proxy-only seccomp fallback path that runs on
    -    // Landlock < V4 kernels. The key invariant: `AF_UNIX` must be allowed
    -    // through so Landlock's filesystem rules decide access, matching V4+
    -    // behavior where `LANDLOCK_ACCESS_NET_*` only scopes TCP.
    +    // Landlock < V4 kernels. The key invariant: pathname `AF_UNIX` must be
    +    // checked against the explicit Unix-socket allowlist instead of being
    +    // decided by TCP proxy ports.
     
         mod network_decision {
             use super::super::{NetworkDecision, SupervisorConfig, decide_network_notification};
             use nix::libc;
    -        use nono::ApprovalBackend;
             use nono::sandbox::{SYS_BIND, SYS_CONNECT, SockaddrInfo, UnixSocketKind};
             use nono::supervisor::{ApprovalDecision, CapabilityRequest};
    +        use nono::{ApprovalBackend, UnixSocketCapability, UnixSocketMode};
    +        use std::os::unix::net::UnixListener;
    +        use std::path::{Path, PathBuf};
     
             struct DenyAllBackend;
             impl ApprovalBackend for DenyAllBackend {
    @@ -971,6 +1082,7 @@ mod tests {
                 backend: &'a DenyAllBackend,
                 proxy_port: u16,
                 proxy_bind_ports: Vec<u16>,
    +            unix_socket_allowlist: &'a [UnixSocketCapability],
             ) -> SupervisorConfig<'a> {
                 static REDACTION_POLICY: std::sync::LazyLock<nono::ScrubPolicy> =
                     std::sync::LazyLock::new(nono::ScrubPolicy::secure_default);
    @@ -987,17 +1099,19 @@ mod tests {
                     allow_launch_services_active: false,
                     proxy_port,
                     proxy_bind_ports,
    +                unix_socket_allowlist,
                 }
             }
     
    -        fn unix_pathname() -> SockaddrInfo {
    +        fn unix_pathname(path: &Path) -> SockaddrInfo {
                 // Matches what read_notif_sockaddr() produces for a
                 // filesystem-backed AF_UNIX socket (e.g. /tmp/test.sock).
                 SockaddrInfo {
                     family: libc::AF_UNIX as u16,
                     port: 0,
                     is_loopback: true,
                     unix_kind: Some(UnixSocketKind::Pathname),
    +                unix_path: Some(path.to_path_buf()),
                 }
             }
     
    @@ -1007,6 +1121,7 @@ mod tests {
                     port: 0,
                     is_loopback: true,
                     unix_kind: Some(UnixSocketKind::Abstract),
    +                unix_path: None,
                 }
             }
     
    @@ -1016,6 +1131,7 @@ mod tests {
                     port: 0,
                     is_loopback: true,
                     unix_kind: Some(UnixSocketKind::Unnamed),
    +                unix_path: None,
                 }
             }
     
    @@ -1025,6 +1141,7 @@ mod tests {
                     port,
                     is_loopback: true,
                     unix_kind: None,
    +                unix_path: None,
                 }
             }
     
    @@ -1034,50 +1151,134 @@ mod tests {
                     port,
                     is_loopback: false,
                     unix_kind: None,
    +                unix_path: None,
                 }
             }
     
    -        /// Regression test for #685: pathname `bind(AF_UNIX, "/tmp/…")` was
    -        /// being denied because `SockaddrInfo.port` is 0 for unix sockets
    -        /// and port 0 is never in `proxy_bind_ports`. It must now allow so
    -        /// Landlock's filesystem rules decide whether the path is reachable.
    +        fn socket_path(dir: &tempfile::TempDir, name: &str) -> PathBuf {
    +            dir.path().join(name)
    +        }
    +
    +        /// Pathname `bind(AF_UNIX, "/tmp/…")` is mediated by explicit
    +        /// Unix-socket grants, not TCP bind ports.
             #[test]
    -        fn af_unix_pathname_bind_is_allowed() {
    +        fn af_unix_pathname_bind_is_allowed_by_connect_bind_grant() {
                 let backend = DenyAllBackend;
    -            let config = make_config(&backend, 0, Vec::new());
    +            let dir = tempfile::tempdir().expect("tempdir");
    +            let path = socket_path(&dir, "test.sock");
    +            let allowlist = vec![
    +                UnixSocketCapability::new_file(&path, UnixSocketMode::ConnectBind)
    +                    .expect("socket grant"),
    +            ];
    +            let config = make_config(&backend, 0, Vec::new(), &allowlist);
                 assert_eq!(
    -                decide_network_notification(SYS_BIND, &unix_pathname(), &config),
    +                decide_network_notification(SYS_BIND, &unix_pathname(&path), &config),
                     NetworkDecision::Allow,
    -                "pathname AF_UNIX bind must be allowed so Landlock fs rules govern access"
    +                "pathname AF_UNIX bind must be allowed when a connect+bind grant covers it"
                 );
             }
     
    -        /// Regression test for #685: pathname `connect(AF_UNIX, "/tmp/…")`
    -        /// was the failure mode for `tsx`'s IPC pipe and other runtimes.
    +        /// Pathname `connect(AF_UNIX, "/tmp/…")` is allowed only when the
    +        /// canonical socket path matches the explicit allowlist.
             #[test]
    -        fn af_unix_pathname_connect_is_allowed() {
    +        fn af_unix_pathname_connect_is_allowed_by_grant() {
                 let backend = DenyAllBackend;
    -            let config = make_config(&backend, 8080, Vec::new());
    +            let dir = tempfile::tempdir().expect("tempdir");
    +            let path = socket_path(&dir, "test.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(SYS_CONNECT, &unix_pathname(), &config),
    +                decide_network_notification(SYS_CONNECT, &unix_pathname(&path), &config),
                     NetworkDecision::Allow,
    -                "pathname AF_UNIX connect must be allowed independent of proxy_port"
    +                "pathname AF_UNIX connect must be allowed when a connect grant covers it"
    +            );
    +        }
    +
    +        #[test]
    +        fn af_unix_pathname_connect_without_grant_is_denied() {
    +            let backend = DenyAllBackend;
    +            let dir = tempfile::tempdir().expect("tempdir");
    +            let path = socket_path(&dir, "test.sock");
    +            let _listener = UnixListener::bind(&path).expect("bind unix listener");
    +            let config = make_config(&backend, 8080, Vec::new(), &[]);
    +            assert_eq!(
    +                decide_network_notification(SYS_CONNECT, &unix_pathname(&path), &config),
    +                NetworkDecision::Deny
    +            );
    +        }
    +
    +        #[test]
    +        fn af_unix_pathname_bind_requires_connect_bind_grant() {
    +            let backend = DenyAllBackend;
    +            let dir = tempfile::tempdir().expect("tempdir");
    +            let path = socket_path(&dir, "test.sock");
    +            let allowlist = vec![
    +                UnixSocketCapability::new_file(&path, UnixSocketMode::Connect)
    +                    .expect("socket grant"),
    +            ];
    +            let config = make_config(&backend, 0, Vec::new(), &allowlist);
    +            assert_eq!(
    +                decide_network_notification(SYS_BIND, &unix_pathname(&path), &config),
    +                NetworkDecision::Deny
    +            );
    +        }
    +
    +        #[test]
    +        fn af_unix_dir_children_does_not_allow_nested_path() {
    +            let backend = DenyAllBackend;
    +            let dir = tempfile::tempdir().expect("tempdir");
    +            let nested = dir.path().join("nested");
    +            std::fs::create_dir(&nested).expect("create nested dir");
    +            let direct_path = socket_path(&dir, "direct.sock");
    +            let nested_path = nested.join("nested.sock");
    +            let allowlist = vec![
    +                UnixSocketCapability::new_dir(dir.path(), UnixSocketMode::ConnectBind)
    +                    .expect("socket dir grant"),
    +            ];
    +            let config = make_config(&backend, 0, Vec::new(), &allowlist);
    +            assert_eq!(
    +                decide_network_notification(SYS_BIND, &unix_pathname(&direct_path), &config),
    +                NetworkDecision::Allow
    +            );
    +            assert_eq!(
    +                decide_network_notification(SYS_BIND, &unix_pathname(&nested_path), &config),
    +                NetworkDecision::Deny
    +            );
    +        }
    +
    +        #[test]
    +        fn af_unix_dir_subtree_allows_nested_path() {
    +            let backend = DenyAllBackend;
    +            let dir = tempfile::tempdir().expect("tempdir");
    +            let nested = dir.path().join("nested");
    +            std::fs::create_dir(&nested).expect("create nested dir");
    +            let nested_path = nested.join("nested.sock");
    +            let allowlist = vec![
    +                UnixSocketCapability::new_dir_subtree(dir.path(), UnixSocketMode::ConnectBind)
    +                    .expect("socket subtree grant"),
    +            ];
    +            let config = make_config(&backend, 0, Vec::new(), &allowlist);
    +            assert_eq!(
    +                decide_network_notification(SYS_BIND, &unix_pathname(&nested_path), &config),
    +                NetworkDecision::Allow
                 );
             }
     
             /// Scope-limit test: abstract-namespace AF_UNIX (`sun_path[0] == 0`)
    -        /// is *not* governed by Landlock filesystem rules, so a blanket
    -        /// allow would open a covert IPC channel that bypasses the sandbox.
    -        /// #685 is explicitly about filesystem-path sockets; abstract stays
    -        /// denied pending #696 (explicit allowlist).
    +        /// is not covered by pathname socket capabilities, so it stays
    +        /// denied.
             #[test]
             fn af_unix_abstract_is_denied() {
                 let backend = DenyAllBackend;
    -            let config = make_config(&backend, 0, Vec::new());
    +            let config = make_config(&backend, 0, Vec::new(), &[]);
                 assert_eq!(
                     decide_network_notification(SYS_BIND, &unix_abstract(), &config),
                     NetworkDecision::Deny,
    -                "abstract AF_UNIX must be denied — Landlock fs rules do not reach it"
    +                "abstract AF_UNIX must be denied because pathname grants do not cover it"
                 );
                 assert_eq!(
                     decide_network_notification(SYS_CONNECT, &unix_abstract(), &config),
    @@ -1086,12 +1287,11 @@ mod tests {
             }
     
             /// Unnamed AF_UNIX (`addrlen == 2`) has no path to check, so fail
    -        /// closed — consistent with abstract handling and outside #685's
    -        /// scope.
    +        /// closed — consistent with abstract handling.
             #[test]
             fn af_unix_unnamed_is_denied() {
                 let backend = DenyAllBackend;
    -            let config = make_config(&backend, 0, Vec::new());
    +            let config = make_config(&backend, 0, Vec::new(), &[]);
                 assert_eq!(
                     decide_network_notification(SYS_BIND, &unix_unnamed(), &config),
                     NetworkDecision::Deny
    @@ -1105,7 +1305,7 @@ mod tests {
             #[test]
             fn af_inet_connect_to_external_host_denied() {
                 let backend = DenyAllBackend;
    -            let config = make_config(&backend, 8080, Vec::new());
    +            let config = make_config(&backend, 8080, Vec::new(), &[]);
                 assert_eq!(
                     decide_network_notification(SYS_CONNECT, &inet_external(8080), &config),
                     NetworkDecision::Deny
    @@ -1117,7 +1317,7 @@ mod tests {
             #[test]
             fn af_inet_bind_on_disallowed_port_denied() {
                 let backend = DenyAllBackend;
    -            let config = make_config(&backend, 0, vec![3000]);
    +            let config = make_config(&backend, 0, vec![3000], &[]);
                 assert_eq!(
                     decide_network_notification(SYS_BIND, &inet_loopback(4000), &config),
                     NetworkDecision::Deny
    
  • crates/nono-cli/src/supervised_runtime.rs+2 0 modified
    @@ -244,6 +244,8 @@ pub(crate) fn execute_supervised_runtime(ctx: SupervisedRuntimeContext<'_>) -> R
                 nono::NetworkMode::ProxyOnly { bind_ports, .. } => bind_ports.clone(),
                 _ => Vec::new(),
             },
    +        #[cfg(target_os = "linux")]
    +        unix_socket_allowlist: caps.unix_socket_capabilities(),
         };
     
         let exit_code = {
    
  • crates/nono/src/sandbox/linux.rs+51 15 modified
    @@ -7,7 +7,7 @@ use landlock::{
         ABI, Access, AccessFs, AccessNet, BitFlags, CompatLevel, Compatible, NetPort, PathBeneath,
         PathFd, Ruleset, RulesetAttr, RulesetCreatedAttr, Scope,
     };
    -use std::path::Path;
    +use std::path::{Path, PathBuf};
     use std::sync::OnceLock;
     use tracing::{debug, info, warn};
     
    @@ -1608,15 +1608,14 @@ pub fn deny_notif(notify_fd: std::os::fd::RawFd, notif_id: u64) -> Result<()> {
     /// Kind of AF_UNIX socket, determined from `sun_path` and `addrlen`.
     ///
     /// See `unix(7)`. This distinction matters for policy because only
    -/// [`UnixSocketKind::Pathname`] sockets are governed by filesystem rules.
    -/// Abstract and unnamed sockets live in a separate namespace that Landlock's
    -/// filesystem rules cannot reach, so the supervisor must decide them
    -/// explicitly.
    +/// [`UnixSocketKind::Pathname`] sockets can be matched against filesystem
    +/// paths. Abstract and unnamed sockets live in a separate namespace, so the
    +/// supervisor must decide them explicitly.
     #[derive(Debug, Clone, Copy, PartialEq, Eq)]
     pub enum UnixSocketKind {
         /// Filesystem-backed: `sun_path` is a null-terminated filesystem path
         /// (e.g. `/tmp/test.sock`). Access is governed by filesystem permissions
    -    /// and — in our case — by Landlock's filesystem rules.
    +    /// and nono's pathname socket capability allowlist.
         Pathname,
         /// Linux abstract namespace: `sun_path[0] == '\0'` and bytes `[1..]` form
         /// the abstract name. Not backed by any filesystem, so Landlock's
    @@ -1662,6 +1661,9 @@ pub struct SockaddrInfo {
         /// For `AF_UNIX`: kind of socket (pathname / abstract / unnamed). `None`
         /// for non-UNIX address families.
         pub unix_kind: Option<UnixSocketKind>,
    +    /// For pathname `AF_UNIX`: filesystem path from `sockaddr_un.sun_path`.
    +    /// `None` for non-UNIX, abstract, unnamed, or unclassified sockets.
    +    pub unix_path: Option<PathBuf>,
     }
     
     /// Seccomp network fallback mode determined during sandbox apply.
    @@ -1719,8 +1721,8 @@ pub fn seccomp_network_fallback_mode(caps: &CapabilitySet) -> SeccompNetFallback
     ///
     /// - `AF_INET`/`AF_INET6`: allow connect to `localhost:proxy_port`;
     ///   allow bind on ports in the configured bind-ports list; deny others.
    -/// - pathname `AF_UNIX` (#685): allow both connect and bind. Landlock's
    -///   filesystem rules are the upstream gate on which paths are reachable.
    +/// - 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`).
     ///
     /// `has_bind_ports` is retained for API compatibility but no longer
    @@ -1759,9 +1761,9 @@ fn build_seccomp_proxy_filter(_has_bind_ports: bool) -> Vec<SockFilterInsn> {
         let errno_ret = SECCOMP_RET_ERRNO | (libc::EACCES as u32);
     
         // bind() always routes to USER_NOTIF so the supervisor can make the
    -    // per-family decision (deny AF_INET to non-allowed TCP ports; allow
    -    // pathname AF_UNIX per issue #685). The previous variant took a
    -    // has_bind_ports short-circuit to ERRNO when no TCP bind ports were
    +    // per-family decision (deny AF_INET to non-allowed TCP ports; check
    +    // pathname AF_UNIX against the explicit socket allowlist). The previous
    +    // variant took a has_bind_ports short-circuit to ERRNO when no TCP bind ports were
         // configured, which unconditionally failed AF_UNIX bind — a real
         // regression that only manifested on Landlock V2 kernels (where this
         // seccomp fallback fires). The has_bind_ports parameter is retained
    @@ -2026,6 +2028,7 @@ pub fn install_seccomp_proxy_filter(has_bind_ports: bool) -> Result<std::os::fd:
     /// too small to parse.
     pub fn read_notif_sockaddr(pid: u32, addr_ptr: u64, addrlen: u64) -> Result<SockaddrInfo> {
         use std::io::Read;
    +    use std::os::unix::ffi::OsStringExt;
     
         // Minimum size: sa_family (2 bytes)
         if addrlen < 2 {
    @@ -2041,9 +2044,13 @@ pub fn read_notif_sockaddr(pid: u32, addr_ptr: u64, addrlen: u64) -> Result<Sock
         std::io::Seek::seek(&mut file, std::io::SeekFrom::Start(addr_ptr))
             .map_err(|e| NonoError::SandboxInit(format!("Failed to seek in {}: {}", mem_path, e)))?;
     
    -    // Read up to sizeof(sockaddr_in6) = 28 bytes. This covers both IPv4 (16) and IPv6 (28).
    -    let read_len = std::cmp::min(addrlen as usize, 28);
    -    let mut buf = [0u8; 28];
    +    let sockaddr_un_len = std::mem::size_of::<libc::sockaddr_un>();
    +    let max_sockaddr_len = sockaddr_un_len.max(28);
    +    let requested_len = usize::try_from(addrlen).map_err(|_| {
    +        NonoError::SandboxInit(format!("sockaddr length too large to parse: {addrlen}"))
    +    })?;
    +    let read_len = std::cmp::min(requested_len, max_sockaddr_len);
    +    let mut buf = vec![0u8; read_len];
         let n = file.read(&mut buf[..read_len]).map_err(|e| {
             NonoError::SandboxInit(format!("Failed to read sockaddr from {}: {}", mem_path, e))
         })?;
    @@ -2076,6 +2083,7 @@ pub fn read_notif_sockaddr(pid: u32, addr_ptr: u64, addrlen: u64) -> Result<Sock
                     port,
                     is_loopback,
                     unix_kind: None,
    +                unix_path: None,
                 })
             }
             libc::AF_INET6 => {
    @@ -2100,24 +2108,48 @@ pub fn read_notif_sockaddr(pid: u32, addr_ptr: u64, addrlen: u64) -> Result<Sock
                     port,
                     is_loopback: is_loopback || is_v4_mapped_loopback,
                     unix_kind: None,
    +                unix_path: None,
                 })
             }
             libc::AF_UNIX => {
    +            if requested_len > sockaddr_un_len {
    +                return Err(NonoError::SandboxInit(format!(
    +                    "sockaddr_un length {} exceeds maximum {}",
    +                    requested_len, sockaddr_un_len
    +                )));
    +            }
                 // sun_path starts at offset 2. buf has `n` bytes total; we need
                 // addrlen to distinguish unnamed from path-bearing sockets.
                 let sun_path_first_byte = if n >= 3 { Some(buf[2]) } else { None };
    +            let unix_kind = classify_af_unix(addrlen, sun_path_first_byte);
    +            let unix_path = if matches!(unix_kind, UnixSocketKind::Pathname) {
    +                let path_bytes = &buf[2..n];
    +                let nul_pos = path_bytes
    +                    .iter()
    +                    .position(|byte| *byte == 0)
    +                    .unwrap_or(path_bytes.len());
    +                if nul_pos == 0 {
    +                    None
    +                } else {
    +                    Some(std::ffi::OsString::from_vec(path_bytes[..nul_pos].to_vec()).into())
    +                }
    +            } else {
    +                None
    +            };
                 Ok(SockaddrInfo {
                     family,
                     port: 0,
                     is_loopback: true, // Unix sockets are always local
    -                unix_kind: Some(classify_af_unix(addrlen, sun_path_first_byte)),
    +                unix_kind: Some(unix_kind),
    +                unix_path,
                 })
             }
             _ => Ok(SockaddrInfo {
                 family,
                 port: 0,
                 is_loopback: false,
                 unix_kind: None,
    +            unix_path: None,
             }),
         }
     }
    @@ -3203,6 +3235,7 @@ mod tests {
                 port: 8080,
                 is_loopback: true,
                 unix_kind: None,
    +            unix_path: None,
             };
             assert!(info.is_loopback);
             assert_eq!(info.port, 8080);
    @@ -3215,6 +3248,7 @@ mod tests {
                 port: 443,
                 is_loopback: true,
                 unix_kind: None,
    +            unix_path: None,
             };
             assert!(info.is_loopback);
         }
    @@ -3226,6 +3260,7 @@ mod tests {
                 port: 80,
                 is_loopback: false,
                 unix_kind: None,
    +            unix_path: None,
             };
             assert!(!info.is_loopback);
         }
    @@ -3237,6 +3272,7 @@ mod tests {
                 port: 0,
                 is_loopback: true,
                 unix_kind: Some(UnixSocketKind::Pathname),
    +            unix_path: Some(PathBuf::from("/tmp/test.sock")),
             };
             assert!(info.is_loopback);
             assert_eq!(info.port, 0);
    
c2c6f2caacaf

feat(landlock): add landlock v6 signal and abstract unix socket scoping

https://github.com/always-further/nonoLuke HindsMay 13, 2026Fixed in 0.55.0via llm-release-walk
14 files changed · +787 29
  • crates/nono-cli/src/cli.rs+22 0 modified
    @@ -1761,6 +1761,10 @@ pub struct WhyArgs {
         #[arg(long, help_heading = "QUERY")]
         pub host: Option<String>,
     
    +    /// Landlock scope to check
    +    #[arg(long, value_enum, value_name = "SCOPE", help_heading = "QUERY")]
    +    pub scope: Option<WhyScope>,
    +
         /// Network port (default 443)
         #[arg(long, default_value = "443", help_heading = "QUERY")]
         pub port: u16,
    @@ -1868,6 +1872,16 @@ pub enum WhyOp {
         ReadWrite,
     }
     
    +/// Landlock scope type for why command
    +#[derive(Clone, Debug, ValueEnum)]
    +pub enum WhyScope {
    +    /// Signal scoping
    +    Signal,
    +    /// Abstract UNIX socket scoping
    +    #[value(name = "abstract-unix-socket")]
    +    AbstractUnixSocket,
    +}
    +
     #[derive(Parser, Debug)]
     #[command(disable_help_flag = true)]
     pub struct RollbackArgs {
    @@ -3201,6 +3215,14 @@ mod tests {
                 }
                 _ => panic!("Expected Why command"),
             }
    +
    +        let cli = Cli::parse_from(["nono", "why", "--scope", "abstract-unix-socket"]);
    +        match cli.command {
    +            Commands::Why(args) => {
    +                assert!(matches!(args.scope, Some(WhyScope::AbstractUnixSocket)));
    +            }
    +            _ => panic!("Expected Why command"),
    +        }
         }
     
         #[test]
    
  • crates/nono-cli/src/exec_strategy.rs+3 1 modified
    @@ -1360,7 +1360,9 @@ fn build_policy_explanations(
                     // Path is actually allowed by policy — the denial came from
                     // a different layer (e.g. Landlock timing). Skip.
                 }
    -            Ok(crate::query_ext::QueryResult::NotSandboxed { .. }) | Err(_) => {}
    +            Ok(crate::query_ext::QueryResult::NotSandboxed { .. })
    +            | Ok(crate::query_ext::QueryResult::Scope { .. })
    +            | Err(_) => {}
             }
         }
     
    
  • crates/nono-cli/src/output.rs+83 0 modified
    @@ -323,6 +323,89 @@ pub fn print_abi_info(silent: bool) {
         }
     }
     
    +/// Print the Landlock scope policy derived from the current capabilities.
    +#[cfg(target_os = "linux")]
    +pub fn print_landlock_scope_policy(caps: &CapabilitySet, verbose: u8, silent: bool) {
    +    if silent || verbose == 0 {
    +        return;
    +    }
    +
    +    let t = theme::current();
    +    match nono::landlock_scope_policy(caps) {
    +        Ok(policy) => {
    +            eprintln!(
    +                "  {} {}",
    +                badge(" scope ", t.blue, BADGE_FG_DARK),
    +                fg(
    +                    &format!("Landlock {} detected", policy.abi_version),
    +                    t.subtext,
    +                )
    +            );
    +            eprintln!(
    +                "          {} {}",
    +                fg("signal:", t.subtext),
    +                fg(
    +                    &format_scope_status(
    +                        policy.signal_requested,
    +                        policy.signal_enforced,
    +                        policy.scoping_supported,
    +                    ),
    +                    scope_status_color(
    +                        policy.signal_requested,
    +                        policy.signal_enforced,
    +                        policy.scoping_supported,
    +                        t,
    +                    ),
    +                )
    +            );
    +            eprintln!(
    +                "          {} {}",
    +                fg("abstract-unix-socket:", t.subtext),
    +                fg(
    +                    &format_scope_status(
    +                        policy.abstract_unix_socket_requested,
    +                        policy.abstract_unix_socket_enforced,
    +                        policy.scoping_supported,
    +                    ),
    +                    scope_status_color(
    +                        policy.abstract_unix_socket_requested,
    +                        policy.abstract_unix_socket_enforced,
    +                        policy.scoping_supported,
    +                        t,
    +                    ),
    +                )
    +            );
    +        }
    +        Err(err) => {
    +            eprintln!(
    +                "  {} {}",
    +                badge(" scope ", t.red, BADGE_FG_DARK),
    +                fg(&format!("Landlock scope policy unavailable: {err}"), t.red),
    +            );
    +        }
    +    }
    +}
    +
    +#[cfg(target_os = "linux")]
    +fn format_scope_status(requested: bool, enforced: bool, supported: bool) -> String {
    +    match (requested, enforced, supported) {
    +        (true, true, _) => "requested, enforced".to_string(),
    +        (true, false, false) => "requested, unsupported by detected ABI".to_string(),
    +        (true, false, true) => "requested, not enforced".to_string(),
    +        (false, _, true) => "not requested".to_string(),
    +        (false, _, false) => "not requested; detected ABI has no scope support".to_string(),
    +    }
    +}
    +
    +#[cfg(target_os = "linux")]
    +fn scope_status_color(requested: bool, enforced: bool, supported: bool, t: &theme::Theme) -> Rgb {
    +    match (requested, enforced, supported) {
    +        (true, true, _) => t.green,
    +        (true, false, _) => t.yellow,
    +        (false, _, _) => t.subtext,
    +    }
    +}
    +
     // ---------------------------------------------------------------------------
     // Status messages
     // ---------------------------------------------------------------------------
    
  • crates/nono-cli/src/query_ext.rs+158 0 modified
    @@ -21,6 +21,24 @@ pub struct CapabilityMatch {
         pub source: String,
     }
     
    +/// Scope type for Landlock scope policy queries.
    +#[derive(Debug, Clone, Copy, PartialEq, Eq)]
    +pub enum ScopeQuery {
    +    /// `LANDLOCK_SCOPE_SIGNAL`.
    +    Signal,
    +    /// `LANDLOCK_SCOPE_ABSTRACT_UNIX_SOCKET`.
    +    AbstractUnixSocket,
    +}
    +
    +impl ScopeQuery {
    +    fn as_str(self) -> &'static str {
    +        match self {
    +            ScopeQuery::Signal => "signal",
    +            ScopeQuery::AbstractUnixSocket => "abstract-unix-socket",
    +        }
    +    }
    +}
    +
     /// Result of querying whether an operation is permitted
     #[derive(Debug, Clone, Serialize, Deserialize)]
     #[serde(tag = "status")]
    @@ -52,6 +70,19 @@ pub enum QueryResult {
         /// Not running inside a sandbox
         #[serde(rename = "not_sandboxed")]
         NotSandboxed { message: String },
    +    /// Landlock scope policy status.
    +    #[serde(rename = "scope")]
    +    Scope {
    +        scope: String,
    +        state: String,
    +        requested: bool,
    +        enforced: bool,
    +        supported: bool,
    +        #[serde(skip_serializing_if = "Option::is_none")]
    +        kernel_abi: Option<String>,
    +        #[serde(skip_serializing_if = "Option::is_none")]
    +        details: Option<String>,
    +    },
     }
     
     /// Query whether a path operation is permitted
    @@ -238,6 +269,91 @@ pub fn query_network(
         }
     }
     
    +/// Query whether a Landlock scope is requested and enforced.
    +#[cfg(target_os = "linux")]
    +pub fn query_scope(scope: ScopeQuery, caps: &CapabilitySet) -> QueryResult {
    +    match nono::landlock_scope_policy(caps) {
    +        Ok(policy) => {
    +            let (requested, enforced) = match scope {
    +                ScopeQuery::Signal => (policy.signal_requested, policy.signal_enforced),
    +                ScopeQuery::AbstractUnixSocket => (
    +                    policy.abstract_unix_socket_requested,
    +                    policy.abstract_unix_socket_enforced,
    +                ),
    +            };
    +            QueryResult::Scope {
    +                scope: scope.as_str().to_string(),
    +                state: scope_state(requested, enforced, policy.scoping_supported).to_string(),
    +                requested,
    +                enforced,
    +                supported: policy.scoping_supported,
    +                kernel_abi: Some(policy.abi_version.to_string()),
    +                details: Some(scope_details(
    +                    scope,
    +                    requested,
    +                    enforced,
    +                    policy.scoping_supported,
    +                )),
    +            }
    +        }
    +        Err(err) => QueryResult::Scope {
    +            scope: scope.as_str().to_string(),
    +            state: "unavailable".to_string(),
    +            requested: false,
    +            enforced: false,
    +            supported: false,
    +            kernel_abi: None,
    +            details: Some(format!(
    +                "Landlock scope policy could not be resolved: {err}"
    +            )),
    +        },
    +    }
    +}
    +
    +/// Query whether a Landlock scope is requested and enforced.
    +#[cfg(not(target_os = "linux"))]
    +pub fn query_scope(scope: ScopeQuery, _caps: &CapabilitySet) -> QueryResult {
    +    QueryResult::Scope {
    +        scope: scope.as_str().to_string(),
    +        state: "not_applicable".to_string(),
    +        requested: false,
    +        enforced: false,
    +        supported: false,
    +        kernel_abi: None,
    +        details: Some("Landlock scope queries are only available on Linux.".to_string()),
    +    }
    +}
    +
    +#[cfg(target_os = "linux")]
    +fn scope_state(requested: bool, enforced: bool, supported: bool) -> &'static str {
    +    match (requested, enforced, supported) {
    +        (true, true, _) => "enforced",
    +        (true, false, false) => "unsupported",
    +        (true, false, true) => "not_enforced",
    +        (false, _, _) => "not_requested",
    +    }
    +}
    +
    +#[cfg(target_os = "linux")]
    +fn scope_details(scope: ScopeQuery, requested: bool, enforced: bool, supported: bool) -> String {
    +    let label = scope.as_str();
    +    match (requested, enforced, supported) {
    +        (true, true, _) => {
    +            format!("{label} scope is requested by the capability set and enforced.")
    +        }
    +        (true, false, false) => {
    +            format!("{label} scope is requested, but this Landlock ABI does not support scoping.")
    +        }
    +        (true, false, true) => {
    +            format!("{label} scope is requested, but it is not enforced.")
    +        }
    +        (false, _, true) => format!("{label} scope is not requested by the capability set."),
    +        (false, _, false) => {
    +            format!("{label} scope is not requested; this Landlock ABI has no scope support.")
    +        }
    +    }
    +}
    +
     /// Print a query result in human-readable format
     pub fn print_result(result: &QueryResult) {
         match result {
    @@ -288,6 +404,28 @@ pub fn print_result(result: &QueryResult) {
                 println!("{}", "NOT SANDBOXED".yellow().bold());
                 println!("  {}", message);
             }
    +        QueryResult::Scope {
    +            scope,
    +            state,
    +            requested,
    +            enforced,
    +            supported,
    +            kernel_abi,
    +            details,
    +        } => {
    +            println!("{}", "SCOPE".blue().bold());
    +            println!("  Scope: {}", scope);
    +            println!("  State: {}", state);
    +            println!("  Requested: {}", requested);
    +            println!("  Enforced: {}", enforced);
    +            println!("  Supported: {}", supported);
    +            if let Some(abi) = kernel_abi {
    +                println!("  Kernel ABI: {}", abi);
    +            }
    +            if let Some(detail) = details {
    +                println!("  Details: {}", detail);
    +            }
    +        }
         }
     }
     
    @@ -525,6 +663,26 @@ mod tests {
             assert!(matches!(result, QueryResult::Denied { .. }));
         }
     
    +    #[test]
    +    fn test_query_scope_returns_structured_result() {
    +        let caps = CapabilitySet::new();
    +        let result = query_scope(ScopeQuery::AbstractUnixSocket, &caps);
    +        match result {
    +            QueryResult::Scope {
    +                scope,
    +                state,
    +                requested,
    +                enforced,
    +                ..
    +            } => {
    +                assert_eq!(scope, "abstract-unix-socket");
    +                assert!(!state.is_empty());
    +                assert!(!enforced || requested);
    +            }
    +            _ => panic!("expected scope result"),
    +        }
    +    }
    +
         #[test]
         fn test_query_network_proxy_domain_filtering() {
             let caps = CapabilitySet::new().set_network_mode(nono::NetworkMode::ProxyOnly {
    
  • crates/nono-cli/src/sandbox_prepare.rs+2 0 modified
    @@ -542,6 +542,8 @@ fn finalize_prepared_sandbox(
     
         #[cfg(target_os = "linux")]
         output::print_abi_info(silent);
    +    #[cfg(target_os = "linux")]
    +    output::print_landlock_scope_policy(&prepared.caps, args.verbose, silent);
     
         if !Sandbox::is_supported() {
             return Err(NonoError::SandboxInit(Sandbox::support_info().details));
    
  • crates/nono-cli/src/setup.rs+7 0 modified
    @@ -193,6 +193,13 @@ impl SetupRunner {
             for feature in detected.feature_names() {
                 println!("      - {}", feature);
             }
    +        if detected.has_scoping() {
    +            println!("  * Landlock scoping policy:");
    +            println!("    - Signal scoping: enforced for same-sandbox signal isolation modes");
    +            println!(
    +                "    - Abstract UNIX socket scoping: enforced for shared-memory-only IPC mode"
    +            );
    +        }
     
             println!("  * Filesystem ruleset creation verified");
     
    
  • crates/nono-cli/src/why_runtime.rs+13 3 modified
    @@ -1,5 +1,6 @@
     use crate::capability_ext::CapabilitySetExt;
    -use crate::cli::{SandboxArgs, WhyArgs, WhyOp};
    +use crate::cli::{SandboxArgs, WhyArgs, WhyOp, WhyScope};
    +use crate::query_ext::ScopeQuery;
     use crate::{network_policy, policy, profile, query_ext, sandbox_state};
     use nono::{AccessMode, CapabilitySet, NonoError, Result};
     
    @@ -42,7 +43,7 @@ fn resolve_allowed_domains(profile: &profile::Profile) -> Vec<String> {
     }
     
     pub(crate) fn run_why(args: WhyArgs) -> Result<()> {
    -    use query_ext::{QueryResult, print_result, query_network, query_path};
    +    use query_ext::{QueryResult, print_result, query_network, query_path, query_scope};
         use sandbox_state::load_sandbox_state;
     
         let ctx: WhyContext = if args.self_query {
    @@ -149,9 +150,11 @@ pub(crate) fn run_why(args: WhyArgs) -> Result<()> {
             query_path(path, op, &ctx.caps, &ctx.overridden_paths)?
         } else if let Some(ref host) = args.host {
             query_network(host, args.port, &ctx.caps, &ctx.allowed_domains)
    +    } else if let Some(ref scope) = args.scope {
    +        query_scope(scope_query(scope), &ctx.caps)
         } else {
             return Err(NonoError::ConfigParse(
    -            "--path or --host is required".to_string(),
    +            "--path, --host, or --scope is required".to_string(),
             ));
         };
     
    @@ -165,3 +168,10 @@ pub(crate) fn run_why(args: WhyArgs) -> Result<()> {
     
         Ok(())
     }
    +
    +fn scope_query(scope: &WhyScope) -> ScopeQuery {
    +    match scope {
    +        WhyScope::Signal => ScopeQuery::Signal,
    +        WhyScope::AbstractUnixSocket => ScopeQuery::AbstractUnixSocket,
    +    }
    +}
    
  • crates/nono/src/capability.rs+8 6 modified
    @@ -656,18 +656,19 @@ pub enum ProcessInfoMode {
     
     /// IPC mode for the sandbox.
     ///
    -/// Controls whether the sandboxed process can use POSIX IPC primitives
    -/// (semaphores) beyond shared memory. Shared memory (`shm_open`) is always
    -/// allowed; this mode gates semaphore operations needed by multiprocessing
    -/// runtimes (e.g., Python `multiprocessing`, Ruby `parallel`).
    +/// Controls whether the sandboxed process can use IPC primitives beyond
    +/// shared memory. Shared memory (`shm_open`) is always allowed; this mode gates
    +/// semaphore operations needed by multiprocessing runtimes (e.g., Python
    +/// `multiprocessing`, Ruby `parallel`) and Linux abstract UNIX socket access.
     #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
     pub enum IpcMode {
         /// POSIX shared memory only (default). Semaphore operations are denied.
         ///
         /// On macOS: only `ipc-posix-shm-*` rules emitted. `sem_open()` etc.
         /// are blocked by the `(deny default)` baseline.
         ///
    -    /// On Linux: no-op (Landlock does not restrict IPC primitives).
    +    /// On Linux: requests Landlock V6 abstract UNIX socket scoping when
    +    /// available. Older kernels cannot enforce this and continue without it.
         #[default]
         SharedMemoryOnly,
         /// Full POSIX IPC: shared memory + semaphores.
    @@ -676,7 +677,8 @@ pub enum IpcMode {
         /// Required for Python `multiprocessing`, Node `worker_threads` with
         /// shared memory, and similar multiprocess coordination.
         ///
    -    /// On Linux: no-op (Landlock does not restrict IPC primitives).
    +    /// On Linux: does not request abstract UNIX socket scoping, preserving
    +    /// compatibility with runtimes that use external abstract socket IPC.
         Full,
     }
     
    
  • crates/nono/src/lib.rs+1 1 modified
    @@ -81,7 +81,7 @@ pub use keystore::{
     pub use net_filter::{FilterResult, HostFilter};
     pub use path::try_canonicalize;
     #[cfg(target_os = "linux")]
    -pub use sandbox::{DetectedAbi, detect_abi, is_wsl2};
    +pub use sandbox::{DetectedAbi, LandlockScopePolicy, detect_abi, is_wsl2, landlock_scope_policy};
     pub use sandbox::{Sandbox, SupportInfo};
     pub use scrub::{
         ScrubPolicy, ScrubPolicyDiff, scrub_argv, scrub_argv_with_policy, scrub_header,
    
  • crates/nono/src/sandbox/linux.rs+406 15 modified
    @@ -1,6 +1,6 @@
     //! Linux sandbox implementation using Landlock LSM
     
    -use crate::capability::{AccessMode, CapabilitySet, NetworkMode, SignalMode};
    +use crate::capability::{AccessMode, CapabilitySet, IpcMode, NetworkMode, SignalMode};
     use crate::error::{NonoError, Result};
     use crate::sandbox::SupportInfo;
     use landlock::{
    @@ -21,6 +21,23 @@ pub struct DetectedAbi {
         pub abi: ABI,
     }
     
    +/// Landlock scope policy derived from a capability set and kernel ABI.
    +#[derive(Debug, Clone, PartialEq, Eq)]
    +pub struct LandlockScopePolicy {
    +    /// Detected Landlock ABI version string.
    +    pub abi_version: &'static str,
    +    /// Whether this kernel supports Landlock scope flags.
    +    pub scoping_supported: bool,
    +    /// Whether signal scoping was requested by the capability set.
    +    pub signal_requested: bool,
    +    /// Whether signal scoping will be enforced.
    +    pub signal_enforced: bool,
    +    /// Whether abstract UNIX socket scoping was requested by the capability set.
    +    pub abstract_unix_socket_requested: bool,
    +    /// Whether abstract UNIX socket scoping will be enforced.
    +    pub abstract_unix_socket_enforced: bool,
    +}
    +
     impl DetectedAbi {
         /// Create a new `DetectedAbi` from a raw `landlock::ABI`.
         #[must_use]
    @@ -52,7 +69,7 @@ impl DetectedAbi {
             AccessFs::from_all(self.abi).contains(AccessFs::IoctlDev)
         }
     
    -    /// Whether process scoping (signals and abstract UNIX sockets) is supported (V6+).
    +    /// Whether scoped signals and abstract UNIX sockets are supported (V6+).
         #[must_use]
         pub fn has_scoping(&self) -> bool {
             !Scope::from_all(self.abi).is_empty()
    @@ -92,12 +109,44 @@ impl DetectedAbi {
                 features.push("Device ioctl filtering".to_string());
             }
             if self.has_scoping() {
    -            features.push("Process scoping".to_string());
    +            features.push("Signal and abstract UNIX socket scoping".to_string());
             }
             features
         }
     }
     
    +/// Report the Landlock scope policy that would be applied for these capabilities.
    +///
    +/// # Errors
    +///
    +/// Returns an error if ABI detection fails, or if the capability set requests a
    +/// mandatory scope that the detected ABI cannot enforce.
    +pub fn landlock_scope_policy(caps: &CapabilitySet) -> Result<LandlockScopePolicy> {
    +    let detected = detect_abi()?;
    +    landlock_scope_policy_with_abi(caps, &detected)
    +}
    +
    +/// Report the Landlock scope policy for an already-detected ABI.
    +///
    +/// # Errors
    +///
    +/// Returns an error if the capability set requests a mandatory scope that this
    +/// ABI cannot enforce.
    +pub fn landlock_scope_policy_with_abi(
    +    caps: &CapabilitySet,
    +    abi: &DetectedAbi,
    +) -> Result<LandlockScopePolicy> {
    +    let scopes = requested_scopes(caps, abi)?;
    +    Ok(LandlockScopePolicy {
    +        abi_version: abi.version_string(),
    +        scoping_supported: abi.has_scoping(),
    +        signal_requested: !matches!(caps.signal_mode(), SignalMode::AllowAll),
    +        signal_enforced: scopes.contains(Scope::Signal),
    +        abstract_unix_socket_requested: caps.ipc_mode() == IpcMode::SharedMemoryOnly,
    +        abstract_unix_socket_enforced: scopes.contains(Scope::AbstractUnixSocket),
    +    })
    +}
    +
     impl std::fmt::Display for DetectedAbi {
         fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
             write!(f, "Landlock {}", self.version_string())
    @@ -363,17 +412,21 @@ fn is_device_directory(path: &Path) -> bool {
     
     /// Determine which Landlock scopes must be enabled for these capabilities.
     ///
    -/// Only `SignalMode::AllowSameSandbox` has an exact Landlock mapping today.
    -/// `SignalMode::Isolated` cannot be represented because Landlock scopes to the
    -/// sandbox domain, not to the calling process alone.
    +/// `SignalMode::AllowSameSandbox` has an exact Landlock mapping.
    +/// `SignalMode::Isolated` cannot be represented exactly because Landlock scopes
    +/// to the sandbox domain, not to the calling process alone.
    +///
    +/// Landlock's abstract UNIX socket scope is all-or-nothing. `IpcMode::Full`
    +/// therefore leaves abstract sockets unscoped as the explicit compatibility
    +/// mode, while `IpcMode::SharedMemoryOnly` requests the V6 IPC hardening.
     fn requested_scopes(caps: &CapabilitySet, abi: &DetectedAbi) -> Result<BitFlags<Scope>> {
    +    let mut scopes = BitFlags::EMPTY;
    +
         match caps.signal_mode() {
    -        SignalMode::AllowAll => Ok(BitFlags::EMPTY),
    +        SignalMode::AllowAll => {}
             SignalMode::Isolated => {
                 if abi.has_scoping() {
    -                Ok(Scope::Signal.into())
    -            } else {
    -                Ok(BitFlags::EMPTY)
    +                scopes |= BitFlags::from(Scope::Signal);
                 }
             }
             SignalMode::AllowSameSandbox => {
    @@ -384,9 +437,15 @@ fn requested_scopes(caps: &CapabilitySet, abi: &DetectedAbi) -> Result<BitFlags<
                             .to_string(),
                     ));
                 }
    -            Ok(Scope::Signal.into())
    +            scopes |= BitFlags::from(Scope::Signal);
             }
         }
    +
    +    if caps.ipc_mode() == IpcMode::SharedMemoryOnly && abi.has_scoping() {
    +        scopes |= BitFlags::from(Scope::AbstractUnixSocket);
    +    }
    +
    +    Ok(scopes)
     }
     
     /// Apply Landlock sandbox with the given capabilities, auto-detecting ABI.
    @@ -504,7 +563,7 @@ pub fn apply_with_abi(caps: &CapabilitySet, abi: &DetectedAbi) -> Result<Seccomp
                 .scope(scopes)
                 .map_err(|e| {
                     NonoError::SandboxInit(format!(
    -                    "Signal scoping requested but unsupported by this kernel: {}",
    +                    "Landlock scoping requested but unsupported by this kernel: {}",
                         e
                     ))
                 })?
    @@ -2214,14 +2273,18 @@ mod tests {
     
         #[test]
         fn test_requested_scopes_allow_all_is_empty() {
    -        let caps = CapabilitySet::new().set_signal_mode(SignalMode::AllowAll);
    +        let caps = CapabilitySet::new()
    +            .set_signal_mode(SignalMode::AllowAll)
    +            .set_ipc_mode(IpcMode::Full);
             let scopes = requested_scopes(&caps, &DetectedAbi::new(ABI::V6));
             assert!(matches!(scopes, Ok(actual) if actual.is_empty()));
         }
     
         #[test]
         fn test_requested_scopes_isolated_uses_signal_scope_on_v6() {
    -        let caps = CapabilitySet::new().set_signal_mode(SignalMode::Isolated);
    +        let caps = CapabilitySet::new()
    +            .set_signal_mode(SignalMode::Isolated)
    +            .set_ipc_mode(IpcMode::Full);
             let scopes = requested_scopes(&caps, &DetectedAbi::new(ABI::V6));
             assert!(matches!(scopes, Ok(actual) if actual == BitFlags::from(Scope::Signal)));
         }
    @@ -2244,11 +2307,291 @@ mod tests {
     
         #[test]
         fn test_requested_scopes_allow_same_sandbox_uses_signal_scope() {
    -        let caps = CapabilitySet::new().set_signal_mode(SignalMode::AllowSameSandbox);
    +        let caps = CapabilitySet::new()
    +            .set_signal_mode(SignalMode::AllowSameSandbox)
    +            .set_ipc_mode(IpcMode::Full);
             let scopes = requested_scopes(&caps, &DetectedAbi::new(ABI::V6));
             assert!(matches!(scopes, Ok(actual) if actual == BitFlags::from(Scope::Signal)));
         }
     
    +    #[test]
    +    fn test_requested_scopes_shared_memory_only_uses_abstract_socket_scope_on_v6() {
    +        let caps = CapabilitySet::new()
    +            .set_signal_mode(SignalMode::AllowAll)
    +            .set_ipc_mode(IpcMode::SharedMemoryOnly);
    +        let scopes = requested_scopes(&caps, &DetectedAbi::new(ABI::V6));
    +        assert!(
    +            matches!(scopes, Ok(actual) if actual == BitFlags::from(Scope::AbstractUnixSocket))
    +        );
    +    }
    +
    +    #[test]
    +    fn test_requested_scopes_full_ipc_does_not_use_abstract_socket_scope() {
    +        let caps = CapabilitySet::new()
    +            .set_signal_mode(SignalMode::AllowAll)
    +            .set_ipc_mode(IpcMode::Full);
    +        let scopes = requested_scopes(&caps, &DetectedAbi::new(ABI::V6));
    +        assert!(matches!(scopes, Ok(actual) if actual.is_empty()));
    +    }
    +
    +    #[test]
    +    fn test_requested_scopes_combines_signal_and_abstract_socket_scopes() {
    +        let caps = CapabilitySet::new()
    +            .set_signal_mode(SignalMode::AllowSameSandbox)
    +            .set_ipc_mode(IpcMode::SharedMemoryOnly);
    +        let scopes = requested_scopes(&caps, &DetectedAbi::new(ABI::V6));
    +        let expected = BitFlags::from(Scope::Signal) | BitFlags::from(Scope::AbstractUnixSocket);
    +        assert!(matches!(scopes, Ok(actual) if actual == expected));
    +    }
    +
    +    #[test]
    +    fn test_requested_scopes_shared_memory_only_degrades_without_v6() {
    +        let caps = CapabilitySet::new()
    +            .set_signal_mode(SignalMode::AllowAll)
    +            .set_ipc_mode(IpcMode::SharedMemoryOnly);
    +        let scopes = requested_scopes(&caps, &DetectedAbi::new(ABI::V5));
    +        assert!(matches!(scopes, Ok(actual) if actual.is_empty()));
    +    }
    +
    +    #[test]
    +    fn test_landlock_scope_policy_reports_requested_and_enforced_scopes() {
    +        let caps = CapabilitySet::new()
    +            .set_signal_mode(SignalMode::AllowSameSandbox)
    +            .set_ipc_mode(IpcMode::SharedMemoryOnly);
    +        let policy = match landlock_scope_policy_with_abi(&caps, &DetectedAbi::new(ABI::V6)) {
    +            Ok(policy) => policy,
    +            Err(err) => panic!("V6 should support requested scopes: {err}"),
    +        };
    +
    +        assert_eq!(policy.abi_version, "V6");
    +        assert!(policy.scoping_supported);
    +        assert!(policy.signal_requested);
    +        assert!(policy.signal_enforced);
    +        assert!(policy.abstract_unix_socket_requested);
    +        assert!(policy.abstract_unix_socket_enforced);
    +    }
    +
    +    #[test]
    +    fn test_landlock_scope_policy_reports_unsupported_abstract_socket_scope() {
    +        let caps = CapabilitySet::new()
    +            .set_signal_mode(SignalMode::AllowAll)
    +            .set_ipc_mode(IpcMode::SharedMemoryOnly);
    +        let policy = match landlock_scope_policy_with_abi(&caps, &DetectedAbi::new(ABI::V5)) {
    +            Ok(policy) => policy,
    +            Err(err) => panic!("SharedMemoryOnly should degrade without V6: {err}"),
    +        };
    +
    +        assert_eq!(policy.abi_version, "V5");
    +        assert!(!policy.scoping_supported);
    +        assert!(!policy.signal_requested);
    +        assert!(!policy.signal_enforced);
    +        assert!(policy.abstract_unix_socket_requested);
    +        assert!(!policy.abstract_unix_socket_enforced);
    +    }
    +
    +    #[cfg(target_os = "linux")]
    +    struct FdGuard(libc::c_int);
    +
    +    #[cfg(target_os = "linux")]
    +    impl Drop for FdGuard {
    +        fn drop(&mut self) {
    +            if self.0 >= 0 {
    +                // SAFETY: fd ownership is held by FdGuard and close is safe for a valid fd.
    +                unsafe {
    +                    libc::close(self.0);
    +                }
    +            }
    +        }
    +    }
    +
    +    #[cfg(target_os = "linux")]
    +    fn abstract_sockaddr(name: &[u8]) -> Option<(libc::sockaddr_un, libc::socklen_t)> {
    +        // SAFETY: sockaddr_un is a plain C struct; zero initialization is valid.
    +        let mut addr: libc::sockaddr_un = unsafe { std::mem::zeroed() };
    +        if name.len().saturating_add(1) > addr.sun_path.len() {
    +            return None;
    +        }
    +
    +        addr.sun_family = libc::AF_UNIX as libc::sa_family_t;
    +        addr.sun_path[0] = 0;
    +        for (index, byte) in name.iter().enumerate() {
    +            addr.sun_path[index + 1] = *byte as libc::c_char;
    +        }
    +
    +        let addr_len = std::mem::size_of::<libc::sa_family_t>() + 1 + name.len();
    +        Some((addr, addr_len as libc::socklen_t))
    +    }
    +
    +    #[cfg(target_os = "linux")]
    +    fn bind_abstract_listener(name: &[u8]) -> FdGuard {
    +        // SAFETY: socket is called with constant domain/type/protocol values.
    +        let fd = unsafe { libc::socket(libc::AF_UNIX, libc::SOCK_STREAM | libc::SOCK_CLOEXEC, 0) };
    +        assert!(fd >= 0, "socket(AF_UNIX) failed");
    +        let guard = FdGuard(fd);
    +
    +        let (addr, addr_len) = match abstract_sockaddr(name) {
    +            Some(sockaddr) => sockaddr,
    +            None => {
    +                panic!("abstract socket name is too long");
    +            }
    +        };
    +
    +        // SAFETY: addr points to a valid sockaddr_un and addr_len covers initialized bytes.
    +        let bind_result = unsafe {
    +            libc::bind(
    +                guard.0,
    +                (&addr as *const libc::sockaddr_un).cast::<libc::sockaddr>(),
    +                addr_len,
    +            )
    +        };
    +        assert_eq!(bind_result, 0, "bind(AF_UNIX abstract) failed");
    +
    +        // SAFETY: guard.0 is a valid stream socket created above.
    +        let listen_result = unsafe { libc::listen(guard.0, 4) };
    +        assert_eq!(listen_result, 0, "listen(AF_UNIX abstract) failed");
    +        guard
    +    }
    +
    +    #[cfg(target_os = "linux")]
    +    fn connect_abstract_socket(name: &[u8]) -> (bool, i32) {
    +        // SAFETY: socket is called with constant domain/type/protocol values.
    +        let fd = unsafe { libc::socket(libc::AF_UNIX, libc::SOCK_STREAM | libc::SOCK_CLOEXEC, 0) };
    +        if fd < 0 {
    +            let errno = std::io::Error::last_os_error()
    +                .raw_os_error()
    +                .unwrap_or(255);
    +            return (false, errno);
    +        }
    +        let guard = FdGuard(fd);
    +
    +        let (addr, addr_len) = match abstract_sockaddr(name) {
    +            Some(sockaddr) => sockaddr,
    +            None => return (false, libc::EINVAL),
    +        };
    +
    +        // SAFETY: addr points to a valid sockaddr_un and addr_len covers initialized bytes.
    +        let connect_result = unsafe {
    +            libc::connect(
    +                guard.0,
    +                (&addr as *const libc::sockaddr_un).cast::<libc::sockaddr>(),
    +                addr_len,
    +            )
    +        };
    +        if connect_result == 0 {
    +            (true, 0)
    +        } else {
    +            (
    +                false,
    +                std::io::Error::last_os_error()
    +                    .raw_os_error()
    +                    .unwrap_or(255),
    +            )
    +        }
    +    }
    +
    +    #[cfg(target_os = "linux")]
    +    fn errno_to_u8(errno: i32) -> u8 {
    +        match u8::try_from(errno) {
    +            Ok(value) => value,
    +            Err(_) => u8::MAX,
    +        }
    +    }
    +
    +    #[cfg(target_os = "linux")]
    +    fn run_abstract_connect_probe(
    +        name: &[u8],
    +        listener_fd: libc::c_int,
    +        caps: CapabilitySet,
    +        detected: DetectedAbi,
    +    ) -> [u8; 2] {
    +        let mut report_pipe = [0; 2];
    +        // SAFETY: report_pipe points to two writable file descriptor slots.
    +        let pipe_result = unsafe { libc::pipe(report_pipe.as_mut_ptr()) };
    +        assert_eq!(pipe_result, 0, "pipe() failed");
    +
    +        // SAFETY: fork is used in a test helper; child exits via _exit.
    +        let child_pid = unsafe { libc::fork() };
    +        assert!(child_pid >= 0, "fork() for abstract socket probe failed");
    +
    +        if child_pid == 0 {
    +            let mut payload = [0_u8; 2];
    +            // SAFETY: these are inherited file descriptors in the child process.
    +            unsafe {
    +                libc::close(report_pipe[0]);
    +                libc::close(listener_fd);
    +            }
    +
    +            match apply_with_abi(&caps, &detected) {
    +                Ok(_) => {
    +                    let (connected, errno) = connect_abstract_socket(name);
    +                    payload[0] = if connected { 0 } else { 1 };
    +                    payload[1] = errno_to_u8(errno);
    +                }
    +                Err(_) => {
    +                    payload[0] = 2;
    +                    payload[1] = 0;
    +                }
    +            }
    +
    +            let write_len = payload.len();
    +            // SAFETY: payload is a valid buffer and report_pipe[1] is the pipe write end.
    +            let wrote = unsafe {
    +                libc::write(
    +                    report_pipe[1],
    +                    payload.as_ptr().cast::<libc::c_void>(),
    +                    write_len,
    +                )
    +            };
    +            let expected_write = isize::try_from(write_len).unwrap_or(-1);
    +            let exit_code = if wrote == expected_write { 0 } else { 3 };
    +            // SAFETY: close operates on the inherited pipe fd; _exit terminates the child.
    +            unsafe {
    +                libc::close(report_pipe[1]);
    +                libc::_exit(exit_code);
    +            }
    +        }
    +
    +        // SAFETY: parent no longer writes to the pipe.
    +        unsafe {
    +            libc::close(report_pipe[1]);
    +        }
    +
    +        let mut child_status = 0;
    +        // SAFETY: child_pid is the pid returned by fork in the parent.
    +        let waited = unsafe { libc::waitpid(child_pid, &mut child_status, 0) };
    +        assert_eq!(waited, child_pid, "waitpid() for probe child failed");
    +        assert!(
    +            libc::WIFEXITED(child_status),
    +            "abstract socket probe child did not exit normally"
    +        );
    +        assert_eq!(
    +            libc::WEXITSTATUS(child_status),
    +            0,
    +            "abstract socket probe child returned failure"
    +        );
    +
    +        let mut payload = [0_u8; 2];
    +        let read_len = payload.len();
    +        // SAFETY: payload is a valid writable buffer and report_pipe[0] is the read end.
    +        let read_result = unsafe {
    +            libc::read(
    +                report_pipe[0],
    +                payload.as_mut_ptr().cast::<libc::c_void>(),
    +                read_len,
    +            )
    +        };
    +        // SAFETY: parent is done reading from the pipe.
    +        unsafe {
    +            libc::close(report_pipe[0]);
    +        }
    +        assert_eq!(
    +            read_result,
    +            isize::try_from(read_len).unwrap_or(-1),
    +            "failed to read abstract socket probe report"
    +        );
    +        payload
    +    }
    +
         #[cfg(target_os = "linux")]
         #[test]
         fn test_signal_scope_blocks_external_kill_on_v6() {
    @@ -2402,6 +2745,46 @@ mod tests {
             cleanup.target_pid = None;
         }
     
    +    #[cfg(target_os = "linux")]
    +    #[test]
    +    fn test_abstract_unix_socket_scope_blocks_external_connect_on_v6() {
    +        let detected = match detect_abi() {
    +            Ok(detected) => detected,
    +            Err(_) => return,
    +        };
    +
    +        if !detected.has_scoping() {
    +            return;
    +        }
    +
    +        let socket_name = format!(
    +            "nono-landlock-v6-abstract-{}-{}",
    +            std::process::id(),
    +            // SAFETY: getpid has no preconditions.
    +            unsafe { libc::getpid() }
    +        );
    +        let listener = bind_abstract_listener(socket_name.as_bytes());
    +
    +        let scoped_caps = CapabilitySet::new()
    +            .set_signal_mode(SignalMode::AllowAll)
    +            .set_ipc_mode(IpcMode::SharedMemoryOnly);
    +        let scoped_report =
    +            run_abstract_connect_probe(socket_name.as_bytes(), listener.0, scoped_caps, detected);
    +        assert_eq!(scoped_report[0], 1, "scoped abstract connect succeeded");
    +        assert_eq!(
    +            i32::from(scoped_report[1]),
    +            libc::EPERM,
    +            "scoped abstract connect should fail with EPERM"
    +        );
    +
    +        let full_ipc_caps = CapabilitySet::new()
    +            .set_signal_mode(SignalMode::AllowAll)
    +            .set_ipc_mode(IpcMode::Full);
    +        let full_report =
    +            run_abstract_connect_probe(socket_name.as_bytes(), listener.0, full_ipc_caps, detected);
    +        assert_eq!(full_report[0], 0, "IpcMode::Full connect was denied");
    +    }
    +
         #[test]
         fn test_detected_abi_version_string() {
             assert_eq!(DetectedAbi::new(ABI::V1).version_string(), "V1");
    @@ -2431,6 +2814,14 @@ mod tests {
                     .any(|n| n == "File rename across directories (Refer)")
             );
             assert!(names.iter().any(|n| n == "File truncation (Truncate)"));
    +
    +        let v6 = DetectedAbi::new(ABI::V6);
    +        let names = v6.feature_names();
    +        assert!(
    +            names
    +                .iter()
    +                .any(|n| n == "Signal and abstract UNIX socket scoping")
    +        );
         }
     
         #[test]
    
  • crates/nono/src/sandbox/mod.rs+2 2 modified
    @@ -18,9 +18,9 @@ mod macos;
     #[cfg(target_os = "macos")]
     pub use macos::{extension_consume, extension_issue_file, extension_release};
     
    -// Re-export Linux Landlock ABI detection
    +// Re-export Linux Landlock ABI detection and scope policy reporting
     #[cfg(target_os = "linux")]
    -pub use linux::{DetectedAbi, detect_abi};
    +pub use linux::{DetectedAbi, LandlockScopePolicy, detect_abi, landlock_scope_policy};
     
     // Re-export Linux WSL2 detection
     #[cfg(target_os = "linux")]
    
  • docs/cli/features/profile-authoring.mdx+27 0 modified
    @@ -237,6 +237,33 @@ Groups are referenced by name in the `groups.include` field. See [Profiles & Gro
       The `groups.include` key was renamed from its former location under `security` in issue #594. The legacy key still deserializes with a deprecation warning; see `nono profile guide` for the full migration table. Legacy keys will be removed in v1.0.0.
     </Note>
     
    +## Process and IPC Isolation
    +
    +The `security` section controls process-level isolation knobs that are not filesystem path grants:
    +
    +```json
    +{
    +  "security": {
    +    "signal_mode": "isolated",
    +    "process_info_mode": "isolated",
    +    "ipc_mode": "shared_memory_only"
    +  }
    +}
    +```
    +
    +| Field | Values | Description |
    +|-------|--------|-------------|
    +| `signal_mode` | `isolated`, `allow_same_sandbox`, `allow_all` | Controls which processes the sandboxed command may signal. On Linux V6, restricted modes use Landlock signal scoping when available. |
    +| `process_info_mode` | `isolated`, `allow_same_sandbox`, `allow_all` | Controls visibility of other process metadata. |
    +| `ipc_mode` | `shared_memory_only`, `full` | Controls IPC compatibility. On Linux V6, `shared_memory_only` requests abstract UNIX socket scoping; `full` leaves abstract UNIX sockets unscoped for runtimes that require broader IPC compatibility. |
    +
    +Use `nono why --scope` to inspect the effective scope policy for a profile:
    +
    +```bash
    +nono why --scope signal --profile my-agent
    +nono why --scope abstract-unix-socket --profile my-agent
    +```
    +
     ## Common Patterns
     
     ### Agent with API Credentials
    
  • docs/cli/internals/landlock.mdx+37 1 modified
    @@ -43,7 +43,8 @@ Landlock capabilities have evolved across kernel versions:
     | 5.19+ | v2 | `REFER` - rename/link across directories |
     | 6.2+ | v3 | `TRUNCATE` - file truncation |
     | 6.7+ | v4 | TCP `bind` and `connect` filtering |
    -| 6.10+ | v5 | `IOCTL_DEV`, signal/socket scoping |
    +| 6.10+ | v5 | `IOCTL_DEV` - device ioctl filtering |
    +| 6.12+ | v6 | Signal and abstract UNIX socket scoping |
     
     nono automatically detects the highest available ABI and uses it. On older kernels, some features are unavailable but core filesystem sandboxing still works.
     
    @@ -95,6 +96,40 @@ AccessNet::ConnectTcp // Control outbound connections
       Network filtering requires kernel 6.7+. On older kernels, nono cannot enforce network restrictions via Landlock and will warn you.
     </Warning>
     
    +## Signal and IPC Scoping
    +
    +Landlock ABI v6 added scope restrictions for process signals and abstract UNIX sockets:
    +
    +```rust
    +Scope::Signal
    +Scope::AbstractUnixSocket
    +```
    +
    +nono derives these scopes from the effective capability set:
    +
    +| Profile / capability setting | Linux V6 behavior |
    +|------------------------------|-------------------|
    +| `security.signal_mode: "allow_same_sandbox"` | Requests signal scoping and fails closed if the kernel cannot enforce it |
    +| `security.signal_mode: "isolated"` | Requests signal scoping when available |
    +| `security.signal_mode: "allow_all"` | Does not request signal scoping |
    +| `security.ipc_mode: "shared_memory_only"` | Requests abstract UNIX socket scoping when available |
    +| `security.ipc_mode: "full"` | Does not request abstract UNIX socket scoping |
    +
    +`shared_memory_only` is the default IPC mode. On V6 kernels it prevents connecting to abstract UNIX sockets created outside the current Landlock domain. Profiles that need broader runtime IPC compatibility can opt into `ipc_mode: "full"`.
    +
    +Use verbose dry-run output to see what would be requested and enforced:
    +
    +```bash
    +nono run --dry-run -v --profile claude-code -- 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
    +```
    +
     ## Enforcement Status
     
     nono reports the enforcement status after applying the sandbox:
    @@ -176,6 +211,7 @@ If a command fails with permission errors:
     - Basic sandboxing requires kernel 5.13+
     - Full filesystem control requires kernel 6.2+
     - Network filtering requires kernel 6.7+
    +- Signal and abstract UNIX socket scoping requires Landlock ABI v6
     
     ### No Network Filtering on Older Kernels
     
    
  • docs/cli/usage/flags.mdx+18 0 modified
    @@ -107,6 +107,7 @@ Check why a path or network operation would be allowed or denied. Designed for b
     ```bash
     nono why --path <PATH> --op <OP> [OPTIONS]
     nono why --host <HOST> [--port <PORT>] [OPTIONS]
    +nono why --scope <SCOPE> [OPTIONS]
     nono why --self --path <PATH> --op <OP> [OPTIONS]  # Inside sandbox
     ```
     
    @@ -1042,6 +1043,12 @@ Capabilities that would be granted:
     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
    +```
    +
     #### `--verbose`, `-v`
     
     Increase logging verbosity. Can be specified multiple times.
    @@ -1128,6 +1135,17 @@ Network port (default: 443). Used with `--host`.
     nono why --host example.com --port 8080
     ```
     
    +### `--scope`
    +
    +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
    +```
    +
    +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`.
    +
     ### `--json`
     
     Output JSON instead of human-readable format. Useful for programmatic use by AI agents.
    
d146001ba3d1

fix(sandbox): correctly resolve af_unix socket paths for seccomp

https://github.com/always-further/nonoLuke HindsMay 13, 2026Fixed in 0.55.0via llm-release-walk
2 files changed · +80 19
  • crates/nono-cli/src/exec_strategy/supervisor_linux.rs+72 18 modified
    @@ -568,6 +568,7 @@ pub(super) enum NetworkDecision {
     ///    - `bind()` is allowed only on ports in `proxy_bind_ports`.
     ///    - Everything else is denied.
     pub(super) fn decide_network_notification(
    +    child_pid: u32,
         syscall: i32,
         sockaddr: &nono::sandbox::SockaddrInfo,
         config: &SupervisorConfig<'_>,
    @@ -580,7 +581,7 @@ pub(super) fn decide_network_notification(
         if sockaddr.family == libc::AF_UNIX as u16 {
             match sockaddr.unix_kind {
                 Some(UnixSocketKind::Pathname) => {
    -                return decide_af_unix_pathname(syscall, sockaddr, config);
    +                return decide_af_unix_pathname(child_pid, syscall, sockaddr, config);
                 }
                 Some(UnixSocketKind::Abstract) => {
                     debug!(
    @@ -641,6 +642,7 @@ pub(super) fn decide_network_notification(
     }
     
     fn decide_af_unix_pathname(
    +    child_pid: u32,
         syscall: i32,
         sockaddr: &nono::sandbox::SockaddrInfo,
         config: &SupervisorConfig<'_>,
    @@ -661,24 +663,37 @@ fn decide_af_unix_pathname(
             return NetworkDecision::Deny;
         };
     
    +    let resolved_path = match resolve_af_unix_sockaddr_path(child_pid, path) {
    +        Ok(path) => path,
    +        Err(err) => {
    +            debug!(
    +                "Proxy seccomp: denying AF_UNIX {} on {}: child-relative resolution failed: {}",
    +                op,
    +                path.display(),
    +                err
    +            );
    +            return NetworkDecision::Deny;
    +        }
    +    };
    +
         let canonical = match op {
    -        UnixSocketOp::Connect => match path.canonicalize() {
    +        UnixSocketOp::Connect => match resolved_path.canonicalize() {
                 Ok(path) => path,
                 Err(err) => {
                     debug!(
                         "Proxy seccomp: denying AF_UNIX connect to {}: canonicalize failed: {}",
    -                    path.display(),
    +                    resolved_path.display(),
                         err
                     );
                     return NetworkDecision::Deny;
                 }
             },
    -        UnixSocketOp::Bind => match canonicalize_unix_socket_bind_path(path) {
    +        UnixSocketOp::Bind => match canonicalize_unix_socket_bind_path(&resolved_path) {
                 Ok(path) => path,
                 Err(err) => {
                     debug!(
                         "Proxy seccomp: denying AF_UNIX bind to {}: canonicalize failed: {}",
    -                    path.display(),
    +                    resolved_path.display(),
                         err
                     );
                     return NetworkDecision::Deny;
    @@ -703,6 +718,16 @@ fn decide_af_unix_pathname(
         }
     }
     
    +fn resolve_af_unix_sockaddr_path(
    +    child_pid: u32,
    +    path: &std::path::Path,
    +) -> nono::Result<std::path::PathBuf> {
    +    use nono::sandbox::resolve_notif_path;
    +
    +    let at_fdcwd = libc::AT_FDCWD as i64 as u64;
    +    resolve_notif_path(child_pid, at_fdcwd, path)
    +}
    +
     fn unix_socket_op_for_syscall(syscall: i32) -> Option<UnixSocketOp> {
         use nono::sandbox::{SYS_BIND, SYS_CONNECT};
     
    @@ -802,7 +827,7 @@ pub(super) fn handle_network_notification(
             return Ok(());
         }
     
    -    match decide_network_notification(notif.data.nr, &sockaddr, config) {
    +    match decide_network_notification(notif.pid, notif.data.nr, &sockaddr, config) {
             NetworkDecision::Allow => {
                 // SECCOMP_USER_NOTIF_FLAG_CONTINUE: let the kernel proceed with its
                 // already-copied sockaddr. Safe for connect/bind (move_addr_to_kernel).
    @@ -1159,6 +1184,10 @@ mod tests {
                 dir.path().join(name)
             }
     
    +        fn test_pid() -> u32 {
    +            std::process::id()
    +        }
    +
             /// Pathname `bind(AF_UNIX, "/tmp/…")` is mediated by explicit
             /// Unix-socket grants, not TCP bind ports.
             #[test]
    @@ -1172,7 +1201,7 @@ mod tests {
                 ];
                 let config = make_config(&backend, 0, Vec::new(), &allowlist);
                 assert_eq!(
    -                decide_network_notification(SYS_BIND, &unix_pathname(&path), &config),
    +                decide_network_notification(test_pid(), SYS_BIND, &unix_pathname(&path), &config),
                     NetworkDecision::Allow,
                     "pathname AF_UNIX bind must be allowed when a connect+bind grant covers it"
                 );
    @@ -1192,7 +1221,12 @@ mod tests {
                 ];
                 let config = make_config(&backend, 8080, Vec::new(), &allowlist);
                 assert_eq!(
    -                decide_network_notification(SYS_CONNECT, &unix_pathname(&path), &config),
    +                decide_network_notification(
    +                    test_pid(),
    +                    SYS_CONNECT,
    +                    &unix_pathname(&path),
    +                    &config,
    +                ),
                     NetworkDecision::Allow,
                     "pathname AF_UNIX connect must be allowed when a connect grant covers it"
                 );
    @@ -1206,7 +1240,12 @@ mod tests {
                 let _listener = UnixListener::bind(&path).expect("bind unix listener");
                 let config = make_config(&backend, 8080, Vec::new(), &[]);
                 assert_eq!(
    -                decide_network_notification(SYS_CONNECT, &unix_pathname(&path), &config),
    +                decide_network_notification(
    +                    test_pid(),
    +                    SYS_CONNECT,
    +                    &unix_pathname(&path),
    +                    &config,
    +                ),
                     NetworkDecision::Deny
                 );
             }
    @@ -1223,7 +1262,7 @@ mod tests {
                 ];
                 let config = make_config(&backend, 0, Vec::new(), &allowlist);
                 assert_eq!(
    -                decide_network_notification(SYS_BIND, &unix_pathname(&path), &config),
    +                decide_network_notification(test_pid(), SYS_BIND, &unix_pathname(&path), &config),
                     NetworkDecision::Deny
                 );
             }
    @@ -1242,11 +1281,21 @@ mod tests {
                 ];
                 let config = make_config(&backend, 0, Vec::new(), &allowlist);
                 assert_eq!(
    -                decide_network_notification(SYS_BIND, &unix_pathname(&direct_path), &config),
    +                decide_network_notification(
    +                    test_pid(),
    +                    SYS_BIND,
    +                    &unix_pathname(&direct_path),
    +                    &config,
    +                ),
                     NetworkDecision::Allow
                 );
                 assert_eq!(
    -                decide_network_notification(SYS_BIND, &unix_pathname(&nested_path), &config),
    +                decide_network_notification(
    +                    test_pid(),
    +                    SYS_BIND,
    +                    &unix_pathname(&nested_path),
    +                    &config,
    +                ),
                     NetworkDecision::Deny
                 );
             }
    @@ -1264,7 +1313,12 @@ mod tests {
                 ];
                 let config = make_config(&backend, 0, Vec::new(), &allowlist);
                 assert_eq!(
    -                decide_network_notification(SYS_BIND, &unix_pathname(&nested_path), &config),
    +                decide_network_notification(
    +                    test_pid(),
    +                    SYS_BIND,
    +                    &unix_pathname(&nested_path),
    +                    &config,
    +                ),
                     NetworkDecision::Allow
                 );
             }
    @@ -1277,12 +1331,12 @@ mod tests {
                 let backend = DenyAllBackend;
                 let config = make_config(&backend, 0, Vec::new(), &[]);
                 assert_eq!(
    -                decide_network_notification(SYS_BIND, &unix_abstract(), &config),
    +                decide_network_notification(test_pid(), SYS_BIND, &unix_abstract(), &config),
                     NetworkDecision::Deny,
                     "abstract AF_UNIX must be denied because pathname grants do not cover it"
                 );
                 assert_eq!(
    -                decide_network_notification(SYS_CONNECT, &unix_abstract(), &config),
    +                decide_network_notification(test_pid(), SYS_CONNECT, &unix_abstract(), &config),
                     NetworkDecision::Deny,
                 );
             }
    @@ -1294,7 +1348,7 @@ mod tests {
                 let backend = DenyAllBackend;
                 let config = make_config(&backend, 0, Vec::new(), &[]);
                 assert_eq!(
    -                decide_network_notification(SYS_BIND, &unix_unnamed(), &config),
    +                decide_network_notification(test_pid(), SYS_BIND, &unix_unnamed(), &config),
                     NetworkDecision::Deny
                 );
             }
    @@ -1308,7 +1362,7 @@ mod tests {
                 let backend = DenyAllBackend;
                 let config = make_config(&backend, 8080, Vec::new(), &[]);
                 assert_eq!(
    -                decide_network_notification(SYS_CONNECT, &inet_external(8080), &config),
    +                decide_network_notification(test_pid(), SYS_CONNECT, &inet_external(8080), &config),
                     NetworkDecision::Deny
                 );
             }
    @@ -1320,7 +1374,7 @@ mod tests {
                 let backend = DenyAllBackend;
                 let config = make_config(&backend, 0, vec![3000], &[]);
                 assert_eq!(
    -                decide_network_notification(SYS_BIND, &inet_loopback(4000), &config),
    +                decide_network_notification(test_pid(), SYS_BIND, &inet_loopback(4000), &config),
                     NetworkDecision::Deny
                 );
             }
    
  • crates/nono/src/sandbox/linux.rs+8 1 modified
    @@ -2049,8 +2049,15 @@ pub fn read_notif_sockaddr(pid: u32, addr_ptr: u64, addrlen: u64) -> Result<Sock
         let requested_len = usize::try_from(addrlen).map_err(|_| {
             NonoError::SandboxInit(format!("sockaddr length too large to parse: {addrlen}"))
         })?;
    +    const SOCKADDR_STACK_LEN: usize = 128;
    +    if max_sockaddr_len > SOCKADDR_STACK_LEN {
    +        return Err(NonoError::SandboxInit(format!(
    +            "maximum sockaddr length {} exceeds stack buffer {}",
    +            max_sockaddr_len, SOCKADDR_STACK_LEN
    +        )));
    +    }
         let read_len = std::cmp::min(requested_len, max_sockaddr_len);
    -    let mut buf = vec![0u8; read_len];
    +    let mut buf = [0u8; SOCKADDR_STACK_LEN];
         let n = file.read(&mut buf[..read_len]).map_err(|e| {
             NonoError::SandboxInit(format!("Failed to read sockaddr from {}: {}", mem_path, e))
         })?;
    
98f8cb182d1f

test(supervisor-linux): add unix listener for connect capability test

https://github.com/always-further/nonoLuke HindsMay 13, 2026Fixed in 0.55.0via ghsa-release-walk
1 file changed · +1 0
  • crates/nono-cli/src/exec_strategy/supervisor_linux.rs+1 0 modified
    @@ -1216,6 +1216,7 @@ mod tests {
                 let backend = DenyAllBackend;
                 let dir = tempfile::tempdir().expect("tempdir");
                 let path = socket_path(&dir, "test.sock");
    +            let _listener = UnixListener::bind(&path).expect("bind unix listener");
                 let allowlist = vec![
                     UnixSocketCapability::new_file(&path, UnixSocketMode::Connect)
                         .expect("socket grant"),
    
bbc652a0c31f

feat(unix-socket): record explicit scope for grants

https://github.com/always-further/nonoLuke HindsMay 13, 2026Fixed in 0.55.0via llm-release-walk
7 files changed · +116 49
  • CHANGELOG.md+8 0 modified
    @@ -1,5 +1,13 @@
     # Changelog
     
    +## Unreleased
    +
    +### Notes
    +
    +- Socket grant state now records explicit socket scope. New subtree socket
    +  grants require this metadata; rolling back to older nono builds may read
    +  those state entries as file-scoped grants.
    +
     ## [0.54.0] - 2026-05-13
     
     ### Bug Fixes
    
  • crates/nono-cli/src/capability_ext.rs+61 38 modified
    @@ -8,7 +8,7 @@ use crate::policy;
     use crate::profile::{Profile, expand_vars};
     use crate::protected_paths::{self, ProtectedRoots};
     use nono::{
    -    AccessMode, CapabilitySet, CapabilitySource, FsCapability, NonoError, Result,
    +    AccessMode, CapabilitySet, CapabilitySource, FsCapability, NonoError, Result, SocketScope,
         UnixSocketCapability, UnixSocketMode,
     };
     use std::path::{Path, PathBuf};
    @@ -62,29 +62,23 @@ fn try_new_unix_socket_file(
         }
     }
     
    -/// Try to create a directory-scoped AF_UNIX socket capability.
    -fn try_new_unix_socket_dir(
    +/// Try to create a directory-backed AF_UNIX socket capability.
    +fn try_new_unix_socket_dir_scoped(
         path: &Path,
         mode: UnixSocketMode,
    +    scope: SocketScope,
         label: &str,
     ) -> Result<Option<UnixSocketCapability>> {
    -    match UnixSocketCapability::new_dir(path, mode) {
    -        Ok(cap) => Ok(Some(cap)),
    -        Err(NonoError::PathNotFound(_)) => {
    -            info!("{}: {}", label, path.display());
    -            Ok(None)
    +    let result = match scope {
    +        SocketScope::DirChildren => UnixSocketCapability::new_dir(path, mode),
    +        SocketScope::DirSubtree => UnixSocketCapability::new_dir_subtree(path, mode),
    +        SocketScope::File => {
    +            return Err(NonoError::SandboxInit(
    +                "unix socket directory grant requires a directory scope".to_string(),
    +            ));
             }
    -        Err(e) => Err(e),
    -    }
    -}
    -
    -/// Try to create a subtree-scoped AF_UNIX socket capability.
    -fn try_new_unix_socket_subtree(
    -    path: &Path,
    -    mode: UnixSocketMode,
    -    label: &str,
    -) -> Result<Option<UnixSocketCapability>> {
    -    match UnixSocketCapability::new_dir_subtree(path, mode) {
    +    };
    +    match result {
             Ok(cap) => Ok(Some(cap)),
             Err(NonoError::PathNotFound(_)) => {
                 info!("{}: {}", label, path.display());
    @@ -188,7 +182,12 @@ fn add_cli_unix_socket_caps(
     
         for path in &args.allow_unix_socket_dir {
             validate_requested_dir(path, "CLI", protected_roots, allow_parent_of_protected)?;
    -        let sock_cap = try_new_unix_socket_dir(path, UnixSocketMode::Connect, LBL_SOCK_DIR)?;
    +        let sock_cap = try_new_unix_socket_dir_scoped(
    +            path,
    +            UnixSocketMode::Connect,
    +            SocketScope::DirChildren,
    +            LBL_SOCK_DIR,
    +        )?;
             if let Some(cap) = sock_cap {
                 caps.add_unix_socket(cap);
                 if let Some(cap) = try_new_dir(path, AccessMode::Read, LBL_FS_DIR_IMPLIED)? {
    @@ -199,8 +198,12 @@ fn add_cli_unix_socket_caps(
     
         for path in &args.allow_unix_socket_dir_bind {
             validate_requested_dir(path, "CLI", protected_roots, allow_parent_of_protected)?;
    -        let sock_cap =
    -            try_new_unix_socket_dir(path, UnixSocketMode::ConnectBind, LBL_SOCK_DIR_BIND)?;
    +        let sock_cap = try_new_unix_socket_dir_scoped(
    +            path,
    +            UnixSocketMode::ConnectBind,
    +            SocketScope::DirChildren,
    +            LBL_SOCK_DIR_BIND,
    +        )?;
             if let Some(cap) = sock_cap {
                 caps.add_unix_socket(cap);
                 if let Some(cap) = try_new_dir(path, AccessMode::ReadWrite, LBL_FS_DIR_IMPLIED)? {
    @@ -211,8 +214,12 @@ fn add_cli_unix_socket_caps(
     
         for path in &args.allow_unix_socket_subtree {
             validate_requested_dir(path, "CLI", protected_roots, allow_parent_of_protected)?;
    -        let sock_cap =
    -            try_new_unix_socket_subtree(path, UnixSocketMode::Connect, LBL_SOCK_SUBTREE)?;
    +        let sock_cap = try_new_unix_socket_dir_scoped(
    +            path,
    +            UnixSocketMode::Connect,
    +            SocketScope::DirSubtree,
    +            LBL_SOCK_SUBTREE,
    +        )?;
             if let Some(cap) = sock_cap {
                 caps.add_unix_socket(cap);
                 if let Some(cap) = try_new_dir(path, AccessMode::Read, LBL_FS_DIR_IMPLIED)? {
    @@ -223,8 +230,12 @@ fn add_cli_unix_socket_caps(
     
         for path in &args.allow_unix_socket_subtree_bind {
             validate_requested_dir(path, "CLI", protected_roots, allow_parent_of_protected)?;
    -        let sock_cap =
    -            try_new_unix_socket_subtree(path, UnixSocketMode::ConnectBind, LBL_SOCK_SUBTREE_BIND)?;
    +        let sock_cap = try_new_unix_socket_dir_scoped(
    +            path,
    +            UnixSocketMode::ConnectBind,
    +            SocketScope::DirSubtree,
    +            LBL_SOCK_SUBTREE_BIND,
    +        )?;
             if let Some(cap) = sock_cap {
                 caps.add_unix_socket(cap);
                 if let Some(cap) = try_new_dir(path, AccessMode::ReadWrite, LBL_FS_DIR_IMPLIED)? {
    @@ -826,8 +837,12 @@ impl CapabilitySetExt for CapabilitySet {
                     "Profile unix socket dir '{}' does not exist, skipping",
                     path_template
                 );
    -            if let Some(mut cap) = try_new_unix_socket_dir(&path, UnixSocketMode::Connect, &label)?
    -            {
    +            if let Some(mut cap) = try_new_unix_socket_dir_scoped(
    +                &path,
    +                UnixSocketMode::Connect,
    +                SocketScope::DirChildren,
    +                &label,
    +            )? {
                     cap.source = CapabilitySource::Profile;
                     caps.add_unix_socket(cap);
                     if let Some(mut cap) = try_new_dir(&path, AccessMode::Read, &label)? {
    @@ -849,9 +864,12 @@ impl CapabilitySetExt for CapabilitySet {
                     "Profile unix socket dir '{}' does not exist, skipping",
                     path_template
                 );
    -            if let Some(mut cap) =
    -                try_new_unix_socket_dir(&path, UnixSocketMode::ConnectBind, &label)?
    -            {
    +            if let Some(mut cap) = try_new_unix_socket_dir_scoped(
    +                &path,
    +                UnixSocketMode::ConnectBind,
    +                SocketScope::DirChildren,
    +                &label,
    +            )? {
                     cap.source = CapabilitySource::Profile;
                     caps.add_unix_socket(cap);
                     if let Some(mut cap) = try_new_dir(&path, AccessMode::ReadWrite, &label)? {
    @@ -873,9 +891,12 @@ impl CapabilitySetExt for CapabilitySet {
                     "Profile unix socket subtree '{}' does not exist, skipping",
                     path_template
                 );
    -            if let Some(mut cap) =
    -                try_new_unix_socket_subtree(&path, UnixSocketMode::Connect, &label)?
    -            {
    +            if let Some(mut cap) = try_new_unix_socket_dir_scoped(
    +                &path,
    +                UnixSocketMode::Connect,
    +                SocketScope::DirSubtree,
    +                &label,
    +            )? {
                     cap.source = CapabilitySource::Profile;
                     caps.add_unix_socket(cap);
                     if let Some(mut cap) = try_new_dir(&path, AccessMode::Read, &label)? {
    @@ -897,9 +918,12 @@ impl CapabilitySetExt for CapabilitySet {
                     "Profile unix socket subtree '{}' does not exist, skipping",
                     path_template
                 );
    -            if let Some(mut cap) =
    -                try_new_unix_socket_subtree(&path, UnixSocketMode::ConnectBind, &label)?
    -            {
    +            if let Some(mut cap) = try_new_unix_socket_dir_scoped(
    +                &path,
    +                UnixSocketMode::ConnectBind,
    +                SocketScope::DirSubtree,
    +                &label,
    +            )? {
                     cap.source = CapabilitySource::Profile;
                     caps.add_unix_socket(cap);
                     if let Some(mut cap) = try_new_dir(&path, AccessMode::ReadWrite, &label)? {
    @@ -2904,7 +2928,6 @@ mod tests {
             assert_eq!(socks[0].mode, UnixSocketMode::Connect);
             assert!(socks[0].is_directory());
             assert_eq!(socks[0].scope, SocketScope::DirChildren);
    -        assert_eq!(socks[0].scope, SocketScope::DirChildren);
     
             let fs_match = caps
                 .fs_capabilities()
    
  • crates/nono-cli/src/cli.rs+12 8 modified
    @@ -1068,14 +1068,16 @@ pub struct SandboxArgs {
         #[arg(long, value_name = "SOCKET", help_heading = "FILESYSTEM")]
         pub allow_unix_socket_bind: Vec<PathBuf>,
     
    -    /// Allow connect() to any AF_UNIX socket directly within this directory
    -    /// (non-recursive; implies --read)
    +    /// Allow connect() to any AF_UNIX socket directly within this directory.
    +    /// Non-recursive on macOS and future Linux AF_UNIX mediation; current
    +    /// Linux Landlock filesystem fallback is recursive.
         #[arg(long, value_name = "DIR", help_heading = "FILESYSTEM")]
         pub allow_unix_socket_dir: Vec<PathBuf>,
     
         /// Allow connect() and bind() on any AF_UNIX socket directly within this
    -    /// directory (non-recursive; implies --allow). Use for runtime-generated
    -    /// socket filenames (PID-derived paths, etc.).
    +    /// directory. Non-recursive on macOS and future Linux AF_UNIX mediation;
    +    /// current Linux Landlock filesystem fallback is recursive. Use for
    +    /// runtime-generated socket filenames (PID-derived paths, etc.).
         #[arg(long, value_name = "DIR", help_heading = "FILESYSTEM")]
         pub allow_unix_socket_dir_bind: Vec<PathBuf>,
     
    @@ -1386,14 +1388,16 @@ pub struct WrapSandboxArgs {
         #[arg(long, value_name = "SOCKET", help_heading = "FILESYSTEM")]
         pub allow_unix_socket_bind: Vec<PathBuf>,
     
    -    /// Allow connect() to any AF_UNIX socket directly within this directory
    -    /// (non-recursive; implies --read)
    +    /// Allow connect() to any AF_UNIX socket directly within this directory.
    +    /// Non-recursive on macOS and future Linux AF_UNIX mediation; current
    +    /// Linux Landlock filesystem fallback is recursive.
         #[arg(long, value_name = "DIR", help_heading = "FILESYSTEM")]
         pub allow_unix_socket_dir: Vec<PathBuf>,
     
         /// Allow connect() and bind() on any AF_UNIX socket directly within this
    -    /// directory (non-recursive; implies --allow). Use for runtime-generated
    -    /// socket filenames (PID-derived paths, etc.).
    +    /// directory. Non-recursive on macOS and future Linux AF_UNIX mediation;
    +    /// current Linux Landlock filesystem fallback is recursive. Use for
    +    /// runtime-generated socket filenames (PID-derived paths, etc.).
         #[arg(long, value_name = "DIR", help_heading = "FILESYSTEM")]
         pub allow_unix_socket_dir_bind: Vec<PathBuf>,
     
    
  • crates/nono/src/sandbox/linux.rs+11 3 modified
    @@ -664,9 +664,17 @@ pub fn apply_with_abi(caps: &CapabilitySet, abi: &DetectedAbi) -> Result<Seccomp
             }
         }
     
    -    // Add rules for each filesystem capability
    -    // These MUST succeed - caller explicitly requested these capabilities
    -    // Failing silently would violate the principle of least surprise and fail-secure design
    +    // Add rules for each filesystem capability.
    +    //
    +    // These MUST succeed - caller explicitly requested these capabilities.
    +    // Failing silently would violate the principle of least surprise and
    +    // fail-secure design.
    +    //
    +    // Pathname AF_UNIX socket grants currently enter Linux enforcement only
    +    // through their implied FsCapability. Landlock PathBeneath is recursive for
    +    // directory grants, so SocketScope::DirChildren and SocketScope::DirSubtree
    +    // are not distinguishable on this Linux path until the seccomp AF_UNIX
    +    // allowlist work enforces UnixSocketCapability::covers().
         let ioctl_dev_available = AccessFs::from_all(target_abi).contains(AccessFs::IoctlDev);
     
         for cap in caps.fs_capabilities() {
    
  • crates/nono/src/sandbox/macos.rs+11 0 modified
    @@ -1523,6 +1523,13 @@ mod tests {
         #[test]
         fn test_generate_profile_unix_socket_subtree_emits_subpath() {
             let mut caps = CapabilitySet::new().proxy_only(54321);
    +        caps.add_fs(FsCapability {
    +            original: PathBuf::from("/tmp/mydir"),
    +            resolved: PathBuf::from("/private/tmp/mydir"),
    +            access: AccessMode::Read,
    +            is_file: false,
    +            source: CapabilitySource::User,
    +        });
             caps.add_unix_socket(crate::UnixSocketCapability {
                 original: PathBuf::from("/tmp/mydir"),
                 resolved: PathBuf::from("/private/tmp/mydir"),
    @@ -1537,6 +1544,10 @@ mod tests {
                 profile.contains("(allow network-outbound (subpath \"/private/tmp/mydir\"))"),
                 "subtree unix socket grants must emit recursive subpath: {profile}"
             );
    +        assert!(
    +            profile.contains("(allow file-read* (subpath \"/private/tmp/mydir\"))"),
    +            "subtree unix socket implied filesystem grant must allow recursive traversal: {profile}"
    +        );
             assert!(
                 profile.contains("(allow network-outbound (subpath \"/tmp/mydir\"))"),
                 "symlinked original must also emit subpath form"
    
  • docs/cli/features/profiles-groups.mdx+5 0 modified
    @@ -106,6 +106,11 @@ subdirectories. The implied filesystem grant is recursive (Landlock's
     only expressible granularity), so socket scope is enforced separately by
     the supervisor (Linux) or Seatbelt path emission (macOS).
     
    +Today, macOS enforces the direct-child versus subtree distinction in Seatbelt.
    +Linux V4+ currently relies on Landlock filesystem rules for pathname AF_UNIX,
    +so directory socket grants are recursive there until the seccomp AF_UNIX
    +allowlist mediation path is enabled.
    +
     Under restricted network modes (`--block-net` or `--network-profile`),
     `connect(2)` to a Unix socket requires an explicit `unix_socket*` grant
     — a plain `allow_file`/`allow` grant no longer implicitly permits it.
    
  • docs/cli/usage/flags.mdx+8 0 modified
    @@ -365,6 +365,14 @@ directory. Non-recursive. Use for runtime-generated socket filenames.
     nono run --block-net --allow-unix-socket-dir-bind $TMPDIR/tsx-$UID -- tsx app.ts
     ```
     
    +<Note>
    +  On macOS, `--allow-unix-socket-dir*` is enforced as direct-child-only socket
    +  access. On current Linux, pathname AF_UNIX grants are enforced through the
    +  implied Landlock filesystem directory grant, which is recursive. The
    +  direct-child distinction becomes enforceable on Linux when the seccomp
    +  AF_UNIX allowlist path is active.
    +</Note>
    +
     #### `--allow-unix-socket-subtree`
     
     Allow `connect(2)` to any AF_UNIX socket within this directory subtree
    
858ad0096cbd

feat(cli): add recursive unix socket directory grants

https://github.com/always-further/nonoLuke HindsMay 13, 2026Fixed in 0.55.0via llm-release-walk
11 files changed · +624 76
  • crates/nono-cli/src/capability_ext.rs+197 9 modified
    @@ -78,7 +78,23 @@ fn try_new_unix_socket_dir(
         }
     }
     
    -/// Apply all four `--allow-unix-socket*` flag groups to `caps`.
    +/// Try to create a subtree-scoped AF_UNIX socket capability.
    +fn try_new_unix_socket_subtree(
    +    path: &Path,
    +    mode: UnixSocketMode,
    +    label: &str,
    +) -> Result<Option<UnixSocketCapability>> {
    +    match UnixSocketCapability::new_dir_subtree(path, mode) {
    +        Ok(cap) => Ok(Some(cap)),
    +        Err(NonoError::PathNotFound(_)) => {
    +            info!("{}: {}", label, path.display());
    +            Ok(None)
    +        }
    +        Err(e) => Err(e),
    +    }
    +}
    +
    +/// Apply all `--allow-unix-socket*` flag groups to `caps`.
     ///
     /// Each flag adds a [`UnixSocketCapability`] and auto-registers the
     /// implied [`FsCapability`] (CLI-side sugar per #696). The socket-level
    @@ -99,6 +115,9 @@ fn add_cli_unix_socket_caps(
         const LBL_FS_FILE_IMPLIED: &str = "Skipping implied fs grant for non-existent unix socket";
         const LBL_FS_DIR_IMPLIED: &str =
             "Skipping implied fs grant for non-existent unix socket directory";
    +    const LBL_SOCK_SUBTREE: &str = "Skipping non-existent unix socket subtree (connect grant)";
    +    const LBL_SOCK_SUBTREE_BIND: &str =
    +        "Skipping non-existent unix socket subtree (connect+bind grant)";
         const LBL_FS_DIR_IMPLIED_BIND_PARENT: &str =
             "Skipping implied fs grant on parent of pending unix socket bind path";
     
    @@ -190,6 +209,30 @@ fn add_cli_unix_socket_caps(
             }
         }
     
    +    for path in &args.allow_unix_socket_subtree {
    +        validate_requested_dir(path, "CLI", protected_roots, allow_parent_of_protected)?;
    +        let sock_cap =
    +            try_new_unix_socket_subtree(path, UnixSocketMode::Connect, LBL_SOCK_SUBTREE)?;
    +        if let Some(cap) = sock_cap {
    +            caps.add_unix_socket(cap);
    +            if let Some(cap) = try_new_dir(path, AccessMode::Read, LBL_FS_DIR_IMPLIED)? {
    +                caps.add_fs(cap);
    +            }
    +        }
    +    }
    +
    +    for path in &args.allow_unix_socket_subtree_bind {
    +        validate_requested_dir(path, "CLI", protected_roots, allow_parent_of_protected)?;
    +        let sock_cap =
    +            try_new_unix_socket_subtree(path, UnixSocketMode::ConnectBind, LBL_SOCK_SUBTREE_BIND)?;
    +        if let Some(cap) = sock_cap {
    +            caps.add_unix_socket(cap);
    +            if let Some(cap) = try_new_dir(path, AccessMode::ReadWrite, LBL_FS_DIR_IMPLIED)? {
    +                caps.add_fs(cap);
    +            }
    +        }
    +    }
    +
         Ok(())
     }
     
    @@ -818,6 +861,54 @@ impl CapabilitySetExt for CapabilitySet {
                 }
             }
     
    +        for path_template in &fs.unix_socket_subtree {
    +            let path = expand_vars(path_template, workdir)?;
    +            validate_requested_dir(
    +                &path,
    +                "Profile",
    +                &protected_roots,
    +                allow_parent_of_protected,
    +            )?;
    +            let label = format!(
    +                "Profile unix socket subtree '{}' does not exist, skipping",
    +                path_template
    +            );
    +            if let Some(mut cap) =
    +                try_new_unix_socket_subtree(&path, UnixSocketMode::Connect, &label)?
    +            {
    +                cap.source = CapabilitySource::Profile;
    +                caps.add_unix_socket(cap);
    +                if let Some(mut cap) = try_new_dir(&path, AccessMode::Read, &label)? {
    +                    cap.source = CapabilitySource::Profile;
    +                    caps.add_fs(cap);
    +                }
    +            }
    +        }
    +
    +        for path_template in &fs.unix_socket_subtree_bind {
    +            let path = expand_vars(path_template, workdir)?;
    +            validate_requested_dir(
    +                &path,
    +                "Profile",
    +                &protected_roots,
    +                allow_parent_of_protected,
    +            )?;
    +            let label = format!(
    +                "Profile unix socket subtree '{}' does not exist, skipping",
    +                path_template
    +            );
    +            if let Some(mut cap) =
    +                try_new_unix_socket_subtree(&path, UnixSocketMode::ConnectBind, &label)?
    +            {
    +                cap.source = CapabilitySource::Profile;
    +                caps.add_unix_socket(cap);
    +                if let Some(mut cap) = try_new_dir(&path, AccessMode::ReadWrite, &label)? {
    +                    cap.source = CapabilitySource::Profile;
    +                    caps.add_fs(cap);
    +                }
    +            }
    +        }
    +
             // Additional profile filesystem grants (canonical fields
             // `filesystem.deny` / `filesystem.bypass_protection` + historical
             // drain sources `filesystem.allow` / `.read` / `.write`). The core
    @@ -1112,6 +1203,7 @@ fn add_cli_overrides(
     #[cfg(test)]
     mod tests {
         use super::*;
    +    use nono::SocketScope;
         use tempfile::tempdir;
     
         fn with_env_lock<T>(f: impl FnOnce() -> T) -> T {
    @@ -2641,7 +2733,7 @@ mod tests {
             let socks = caps.unix_socket_capabilities();
             assert_eq!(socks.len(), 1);
             assert_eq!(socks[0].mode, UnixSocketMode::Connect);
    -        assert!(!socks[0].is_directory);
    +        assert!(!socks[0].is_directory());
     
             // Exactly one implied FsCapability at Read.
             let fs_matches: Vec<_> = caps
    @@ -2730,7 +2822,7 @@ mod tests {
             let socks = caps.unix_socket_capabilities();
             assert_eq!(socks.len(), 1);
             assert_eq!(socks[0].mode, UnixSocketMode::ConnectBind);
    -        assert!(!socks[0].is_directory);
    +        assert!(!socks[0].is_directory());
     
             // Implied fs grant should cover the parent dir with ReadWrite.
             let canonical_parent = dir.path().canonicalize().expect("canonicalize dir");
    @@ -2783,7 +2875,8 @@ mod tests {
             let socks = caps.unix_socket_capabilities();
             assert_eq!(socks.len(), 1);
             assert_eq!(socks[0].mode, UnixSocketMode::ConnectBind);
    -        assert!(socks[0].is_directory);
    +        assert!(socks[0].is_directory());
    +        assert_eq!(socks[0].scope, SocketScope::DirChildren);
     
             let fs_match = caps
                 .fs_capabilities()
    @@ -2809,7 +2902,9 @@ mod tests {
             let socks = caps.unix_socket_capabilities();
             assert_eq!(socks.len(), 1);
             assert_eq!(socks[0].mode, UnixSocketMode::Connect);
    -        assert!(socks[0].is_directory);
    +        assert!(socks[0].is_directory());
    +        assert_eq!(socks[0].scope, SocketScope::DirChildren);
    +        assert_eq!(socks[0].scope, SocketScope::DirChildren);
     
             let fs_match = caps
                 .fs_capabilities()
    @@ -2821,6 +2916,58 @@ mod tests {
             assert_eq!(fs_match.access, AccessMode::Read);
         }
     
    +    #[test]
    +    fn test_allow_unix_socket_subtree_implies_read_fs_grant() {
    +        let dir = tempdir().expect("tempdir");
    +
    +        let args = SandboxArgs {
    +            allow_unix_socket_subtree: vec![dir.path().to_path_buf()],
    +            ..sandbox_args()
    +        };
    +
    +        let (caps, _) = from_args_locked(&args).expect("from_args");
    +
    +        let socks = caps.unix_socket_capabilities();
    +        assert_eq!(socks.len(), 1);
    +        assert_eq!(socks[0].mode, UnixSocketMode::Connect);
    +        assert_eq!(socks[0].scope, SocketScope::DirSubtree);
    +
    +        let fs_match = caps
    +            .fs_capabilities()
    +            .iter()
    +            .find(|c| {
    +                !c.is_file && c.resolved == dir.path().canonicalize().expect("canonicalize dir")
    +            })
    +            .expect("implied fs dir cap not found");
    +        assert_eq!(fs_match.access, AccessMode::Read);
    +    }
    +
    +    #[test]
    +    fn test_allow_unix_socket_subtree_bind_implies_readwrite_fs_grant() {
    +        let dir = tempdir().expect("tempdir");
    +
    +        let args = SandboxArgs {
    +            allow_unix_socket_subtree_bind: vec![dir.path().to_path_buf()],
    +            ..sandbox_args()
    +        };
    +
    +        let (caps, _) = from_args_locked(&args).expect("from_args");
    +
    +        let socks = caps.unix_socket_capabilities();
    +        assert_eq!(socks.len(), 1);
    +        assert_eq!(socks[0].mode, UnixSocketMode::ConnectBind);
    +        assert_eq!(socks[0].scope, SocketScope::DirSubtree);
    +
    +        let fs_match = caps
    +            .fs_capabilities()
    +            .iter()
    +            .find(|c| {
    +                !c.is_file && c.resolved == dir.path().canonicalize().expect("canonicalize dir")
    +            })
    +            .expect("implied fs dir cap not found");
    +        assert_eq!(fs_match.access, AccessMode::ReadWrite);
    +    }
    +
         /// Build a minimal profile JSON with a single filesystem field set,
         /// then parse it into a [`crate::profile::Profile`].
         fn profile_with_fs_field(field: &str, value: &str) -> crate::profile::Profile {
    @@ -2851,7 +2998,7 @@ mod tests {
                 .collect();
             assert_eq!(socks.len(), 1);
             assert_eq!(socks[0].mode, UnixSocketMode::Connect);
    -        assert!(!socks[0].is_directory);
    +        assert!(!socks[0].is_directory());
         }
     
         #[test]
    @@ -2871,7 +3018,7 @@ mod tests {
                 .collect();
             assert_eq!(socks.len(), 1);
             assert_eq!(socks[0].mode, UnixSocketMode::ConnectBind);
    -        assert!(!socks[0].is_directory);
    +        assert!(!socks[0].is_directory());
         }
     
         #[test]
    @@ -2889,7 +3036,7 @@ mod tests {
                 .collect();
             assert_eq!(socks.len(), 1);
             assert_eq!(socks[0].mode, UnixSocketMode::Connect);
    -        assert!(socks[0].is_directory);
    +        assert!(socks[0].is_directory());
         }
     
         #[test]
    @@ -2909,6 +3056,47 @@ mod tests {
                 .collect();
             assert_eq!(socks.len(), 1);
             assert_eq!(socks[0].mode, UnixSocketMode::ConnectBind);
    -        assert!(socks[0].is_directory);
    +        assert!(socks[0].is_directory());
    +        assert_eq!(socks[0].scope, SocketScope::DirChildren);
    +    }
    +
    +    #[test]
    +    fn test_profile_unix_socket_field_connect_subtree() {
    +        let dir = tempdir().expect("tempdir");
    +        let profile =
    +            profile_with_fs_field("unix_socket_subtree", &dir.path().display().to_string());
    +
    +        let (caps, _) =
    +            from_profile_locked(&profile, dir.path(), &sandbox_args()).expect("from_profile");
    +
    +        let socks: Vec<_> = caps
    +            .unix_socket_capabilities()
    +            .iter()
    +            .filter(|c| c.source == CapabilitySource::Profile)
    +            .collect();
    +        assert_eq!(socks.len(), 1);
    +        assert_eq!(socks[0].mode, UnixSocketMode::Connect);
    +        assert_eq!(socks[0].scope, SocketScope::DirSubtree);
    +    }
    +
    +    #[test]
    +    fn test_profile_unix_socket_field_connect_bind_subtree() {
    +        let dir = tempdir().expect("tempdir");
    +        let profile = profile_with_fs_field(
    +            "unix_socket_subtree_bind",
    +            &dir.path().display().to_string(),
    +        );
    +
    +        let (caps, _) =
    +            from_profile_locked(&profile, dir.path(), &sandbox_args()).expect("from_profile");
    +
    +        let socks: Vec<_> = caps
    +            .unix_socket_capabilities()
    +            .iter()
    +            .filter(|c| c.source == CapabilitySource::Profile)
    +            .collect();
    +        assert_eq!(socks.len(), 1);
    +        assert_eq!(socks[0].mode, UnixSocketMode::ConnectBind);
    +        assert_eq!(socks[0].scope, SocketScope::DirSubtree);
         }
     }
    
  • crates/nono-cli/src/cli.rs+50 0 modified
    @@ -1079,6 +1079,16 @@ pub struct SandboxArgs {
         #[arg(long, value_name = "DIR", help_heading = "FILESYSTEM")]
         pub allow_unix_socket_dir_bind: Vec<PathBuf>,
     
    +    /// Allow connect() to any AF_UNIX socket within this directory subtree
    +    /// (recursive; implies --read)
    +    #[arg(long, value_name = "DIR", help_heading = "FILESYSTEM")]
    +    pub allow_unix_socket_subtree: Vec<PathBuf>,
    +
    +    /// Allow connect() and bind() on any AF_UNIX socket within this directory
    +    /// subtree (recursive; implies --allow).
    +    #[arg(long, value_name = "DIR", help_heading = "FILESYSTEM")]
    +    pub allow_unix_socket_subtree_bind: Vec<PathBuf>,
    +
         /// Override a deny rule for a path. Pair with --allow/--read/--write grant
         /// ALIAS(canonical="--bypass-protection", introduced="v0.41.0", remove_by="v1.0.0", issue="#594")
         #[arg(
    @@ -1299,6 +1309,7 @@ pub struct SandboxArgs {
                 "allow", "read", "write", "allow_file", "read_file", "write_file",
                 "allow_unix_socket", "allow_unix_socket_bind",
                 "allow_unix_socket_dir", "allow_unix_socket_dir_bind",
    +            "allow_unix_socket_subtree", "allow_unix_socket_subtree_bind",
                 "profile", "bypass_protection", "suppress_save_prompt", "allow_cwd",
                 "block_net", "allow_net", "network_profile", "allow_proxy",
                 "allow_bind", "allow_port", "allow_connect_port", "external_proxy", "proxy_port",
    @@ -1386,6 +1397,16 @@ pub struct WrapSandboxArgs {
         #[arg(long, value_name = "DIR", help_heading = "FILESYSTEM")]
         pub allow_unix_socket_dir_bind: Vec<PathBuf>,
     
    +    /// Allow connect() to any AF_UNIX socket within this directory subtree
    +    /// (recursive; implies --read)
    +    #[arg(long, value_name = "DIR", help_heading = "FILESYSTEM")]
    +    pub allow_unix_socket_subtree: Vec<PathBuf>,
    +
    +    /// Allow connect() and bind() on any AF_UNIX socket within this directory
    +    /// subtree (recursive; implies --allow).
    +    #[arg(long, value_name = "DIR", help_heading = "FILESYSTEM")]
    +    pub allow_unix_socket_subtree_bind: Vec<PathBuf>,
    +
         /// Override a deny rule for a path. Pair with --allow/--read/--write grant
         /// ALIAS(canonical="--bypass-protection", introduced="v0.41.0", remove_by="v1.0.0", issue="#594")
         #[arg(
    @@ -1513,6 +1534,7 @@ pub struct WrapSandboxArgs {
                 "allow", "read", "write", "allow_file", "read_file", "write_file",
                 "allow_unix_socket", "allow_unix_socket_bind",
                 "allow_unix_socket_dir", "allow_unix_socket_dir_bind",
    +            "allow_unix_socket_subtree", "allow_unix_socket_subtree_bind",
                 "profile", "bypass_protection", "suppress_save_prompt", "allow_cwd",
                 "block_net", "allow_bind", "allow_port", "allow_connect_port",
                 "env_credential", "env_credential_map",
    @@ -1544,6 +1566,8 @@ impl From<WrapSandboxArgs> for SandboxArgs {
                 allow_unix_socket_bind: args.allow_unix_socket_bind,
                 allow_unix_socket_dir: args.allow_unix_socket_dir,
                 allow_unix_socket_dir_bind: args.allow_unix_socket_dir_bind,
    +            allow_unix_socket_subtree: args.allow_unix_socket_subtree,
    +            allow_unix_socket_subtree_bind: args.allow_unix_socket_subtree_bind,
                 bypass_protection: args.bypass_protection,
                 suppress_save_prompt: args.suppress_save_prompt,
                 allow_cwd: args.allow_cwd,
    @@ -3225,6 +3249,32 @@ mod tests {
             }
         }
     
    +    #[test]
    +    fn test_unix_socket_subtree_flags_parse() {
    +        let cli = Cli::parse_from([
    +            "nono",
    +            "run",
    +            "--allow-unix-socket-subtree",
    +            "/tmp/nx",
    +            "--allow-unix-socket-subtree-bind",
    +            "/tmp/nx-bind",
    +            "echo",
    +        ]);
    +        match cli.command {
    +            Commands::Run(args) => {
    +                assert_eq!(
    +                    args.sandbox.allow_unix_socket_subtree,
    +                    vec![PathBuf::from("/tmp/nx")]
    +                );
    +                assert_eq!(
    +                    args.sandbox.allow_unix_socket_subtree_bind,
    +                    vec![PathBuf::from("/tmp/nx-bind")]
    +                );
    +            }
    +            _ => panic!("Expected Run command"),
    +        }
    +    }
    +
         #[test]
         fn test_allow_endpoint_flag_parses() {
             let cli = Cli::parse_from([
    
  • crates/nono-cli/src/output.rs+4 4 modified
    @@ -127,10 +127,10 @@ pub fn print_capabilities(caps: &CapabilitySet, verbose: u8, silent: bool) {
     
             for cap in &user_caps {
                 let mode_badge = format_unix_socket_mode_badge(cap.mode);
    -            let scope_suffix = if cap.is_directory {
    -                "  (directory grant — sockets inside only, non-recursive)"
    -            } else {
    -                ""
    +            let scope_suffix = match cap.scope {
    +                nono::SocketScope::File => "",
    +                nono::SocketScope::DirChildren => "  (directory grant — direct child sockets only)",
    +                nono::SocketScope::DirSubtree => "  (subtree grant — recursive socket paths)",
                 };
                 if verbose > 0 {
                     let source_str = format!("{}", cap.source);
    
  • crates/nono-cli/src/profile/mod.rs+20 0 modified
    @@ -152,6 +152,14 @@ pub struct FilesystemConfig {
         /// or bound. Non-recursive. Implies read+write access on the directory.
         #[serde(default, deserialize_with = "deserialize_conditional_path_vec")]
         pub unix_socket_dir_bind: Vec<String>,
    +    /// Directories where any descendant AF_UNIX socket may be connected to.
    +    /// Recursive. Implies read access on the directory.
    +    #[serde(default, deserialize_with = "deserialize_conditional_path_vec")]
    +    pub unix_socket_subtree: Vec<String>,
    +    /// Directories where any descendant AF_UNIX socket may be connected to or
    +    /// bound. Recursive. Implies read+write access on the directory.
    +    #[serde(default, deserialize_with = "deserialize_conditional_path_vec")]
    +    pub unix_socket_subtree_bind: Vec<String>,
         /// Paths denied filesystem access. Canonical location for deny entries
         /// in the #594 schema; the legacy deny-access key drains here via
         /// `deprecated_schema::LegacyPolicyPatch`.
    @@ -2345,6 +2353,14 @@ fn merge_profiles(base: Profile, child: Profile) -> Profile {
                     &base.filesystem.unix_socket_dir_bind,
                     &child.filesystem.unix_socket_dir_bind,
                 ),
    +            unix_socket_subtree: dedup_append(
    +                &base.filesystem.unix_socket_subtree,
    +                &child.filesystem.unix_socket_subtree,
    +            ),
    +            unix_socket_subtree_bind: dedup_append(
    +                &base.filesystem.unix_socket_subtree_bind,
    +                &child.filesystem.unix_socket_subtree_bind,
    +            ),
                 deny: dedup_append(&base.filesystem.deny, &child.filesystem.deny),
                 bypass_protection: dedup_append(
                     &base.filesystem.bypass_protection,
    @@ -4224,6 +4240,8 @@ mod tests {
                     unix_socket_bind: vec![],
                     unix_socket_dir: vec![],
                     unix_socket_dir_bind: vec![],
    +                unix_socket_subtree: vec![],
    +                unix_socket_subtree_bind: vec![],
                     deny: vec!["/base/policy-deny".to_string()],
                     bypass_protection: vec!["/base/override-deny".to_string()],
                     suppress_save_prompt: vec!["/base/no-prompt".to_string()],
    @@ -4299,6 +4317,8 @@ mod tests {
                     unix_socket_bind: vec![],
                     unix_socket_dir: vec![],
                     unix_socket_dir_bind: vec![],
    +                unix_socket_subtree: vec![],
    +                unix_socket_subtree_bind: vec![],
                     deny: vec!["/child/policy-deny".to_string()],
                     bypass_protection: vec!["/child/override-deny".to_string()],
                     suppress_save_prompt: vec!["/child/no-prompt".to_string()],
    
  • crates/nono/src/capability.rs+203 27 modified
    @@ -232,6 +232,43 @@ impl std::fmt::Display for UnixSocketOp {
         }
     }
     
    +/// Path matching scope for a pathname AF_UNIX socket capability.
    +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
    +#[serde(rename_all = "snake_case")]
    +pub enum SocketScope {
    +    /// A single socket path; matches only the canonical file path.
    +    #[default]
    +    File,
    +    /// Any direct child of a directory; does not match grandchildren.
    +    DirChildren,
    +    /// Any descendant of a directory subtree.
    +    DirSubtree,
    +}
    +
    +impl SocketScope {
    +    /// Human-readable label for summaries and diagnostics.
    +    #[must_use]
    +    pub fn label(self) -> &'static str {
    +        match self {
    +            SocketScope::File => "file",
    +            SocketScope::DirChildren => "dir-children",
    +            SocketScope::DirSubtree => "dir-subtree",
    +        }
    +    }
    +
    +    /// Whether this scope is directory-backed.
    +    #[must_use]
    +    pub fn is_directory(self) -> bool {
    +        matches!(self, SocketScope::DirChildren | SocketScope::DirSubtree)
    +    }
    +}
    +
    +impl std::fmt::Display for SocketScope {
    +    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
    +        f.write_str(self.label())
    +    }
    +}
    +
     /// A capability granting AF_UNIX socket access on a filesystem path.
     ///
     /// Only pathname sockets (filesystem-backed) are grantable through this
    @@ -247,25 +284,59 @@ impl std::fmt::Display for UnixSocketOp {
     ///   socket file).
     /// - `lib-policy-free`: this is a pure data type. Policy coupling (e.g.
     ///   auto-granting an implied `FsCapability`) lives in `nono-cli`.
    -#[derive(Debug, Clone, Serialize, Deserialize)]
    +#[derive(Debug, Clone, Serialize)]
     pub struct UnixSocketCapability {
         /// Original path as specified by the caller, pre-canonicalisation.
         /// Retained for diagnostic output and for macOS dual-path emission
         /// (`/tmp/foo.sock` vs `/private/tmp/foo.sock`).
         pub original: PathBuf,
         /// Canonical absolute path.
         pub resolved: PathBuf,
    -    /// If `true`, the grant covers any pathname socket *directly* within
    -    /// `resolved` (non-recursive: children only, not grandchildren).
    -    /// If `false`, the grant is file-scoped and matches only `resolved`.
    -    pub is_directory: bool,
    +    /// Path matching scope for this grant.
    +    pub scope: SocketScope,
         /// Which socket operations are permitted.
         pub mode: UnixSocketMode,
         /// Where this capability originated.
         #[serde(default)]
         pub source: CapabilitySource,
     }
     
    +impl<'de> Deserialize<'de> for UnixSocketCapability {
    +    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
    +    where
    +        D: serde::Deserializer<'de>,
    +    {
    +        #[derive(Deserialize)]
    +        struct Wire {
    +            original: PathBuf,
    +            resolved: PathBuf,
    +            #[serde(default)]
    +            scope: Option<SocketScope>,
    +            #[serde(default)]
    +            is_directory: Option<bool>,
    +            mode: UnixSocketMode,
    +            #[serde(default)]
    +            source: CapabilitySource,
    +        }
    +
    +        let wire = Wire::deserialize(deserializer)?;
    +        let scope = wire.scope.unwrap_or_else(|| {
    +            if wire.is_directory.unwrap_or(false) {
    +                SocketScope::DirChildren
    +            } else {
    +                SocketScope::File
    +            }
    +        });
    +        Ok(Self {
    +            original: wire.original,
    +            resolved: wire.resolved,
    +            scope,
    +            mode: wire.mode,
    +            source: wire.source,
    +        })
    +    }
    +}
    +
     impl UnixSocketCapability {
         /// Grant for a single socket file.
         ///
    @@ -335,7 +406,7 @@ impl UnixSocketCapability {
             Ok(Self {
                 original: path.to_path_buf(),
                 resolved,
    -            is_directory: false,
    +            scope: SocketScope::File,
                 mode,
                 source: CapabilitySource::User,
             })
    @@ -351,7 +422,28 @@ impl UnixSocketCapability {
         /// (cf. [`validate_platform_rule`]'s rejection of root-level subpath
         /// grants for filesystem rules). Use explicit subdirectory paths.
         pub fn new_dir(path: impl AsRef<Path>, mode: UnixSocketMode) -> Result<Self> {
    +        Self::new_dir_with_scope(path, mode, SocketScope::DirChildren)
    +    }
    +
    +    /// Grant for any pathname socket within a directory subtree.
    +    ///
    +    /// Recursive: sockets in nested subdirectories are covered. The directory
    +    /// itself must already exist.
    +    pub fn new_dir_subtree(path: impl AsRef<Path>, mode: UnixSocketMode) -> Result<Self> {
    +        Self::new_dir_with_scope(path, mode, SocketScope::DirSubtree)
    +    }
    +
    +    fn new_dir_with_scope(
    +        path: impl AsRef<Path>,
    +        mode: UnixSocketMode,
    +        scope: SocketScope,
    +    ) -> Result<Self> {
             let path = path.as_ref();
    +        if !scope.is_directory() {
    +            return Err(NonoError::SandboxInit(
    +                "unix socket directory constructor requires a directory scope".to_string(),
    +            ));
    +        }
     
             let resolved = path.canonicalize().map_err(|e| {
                 if e.kind() == std::io::ErrorKind::NotFound {
    @@ -377,7 +469,7 @@ impl UnixSocketCapability {
             Ok(Self {
                 original: path.to_path_buf(),
                 resolved,
    -            is_directory: true,
    +            scope,
                 mode,
                 source: CapabilitySource::User,
             })
    @@ -386,25 +478,41 @@ impl UnixSocketCapability {
         /// True if `sockaddr_path` is covered by this grant.
         ///
         /// - File grants: `sockaddr_path == resolved` exactly.
    -    /// - Directory grants: `sockaddr_path`'s parent equals `resolved`,
    -    ///   component-wise (non-recursive). Subdirectories are not covered.
    +    /// - Direct-child directory grants: `sockaddr_path`'s parent equals
    +    ///   `resolved`, component-wise (non-recursive).
    +    /// - Subtree directory grants: `sockaddr_path` starts with `resolved`,
    +    ///   component-wise.
         ///
         /// Uses `Path` component semantics; never string prefix
         /// (`path-component-compare` invariant).
         #[must_use]
         pub fn covers(&self, sockaddr_path: &Path) -> bool {
    -        if self.is_directory {
    -            sockaddr_path.parent() == Some(self.resolved.as_path())
    -        } else {
    -            sockaddr_path == self.resolved.as_path()
    +        match self.scope {
    +            SocketScope::File => sockaddr_path == self.resolved.as_path(),
    +            SocketScope::DirChildren => sockaddr_path.parent() == Some(self.resolved.as_path()),
    +            SocketScope::DirSubtree => {
    +                sockaddr_path != self.resolved.as_path()
    +                    && sockaddr_path.starts_with(&self.resolved)
    +            }
             }
         }
    +
    +    /// Whether this grant is directory-backed.
    +    #[must_use]
    +    pub fn is_directory(&self) -> bool {
    +        self.scope.is_directory()
    +    }
     }
     
     impl std::fmt::Display for UnixSocketCapability {
         fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
    -        let scope = if self.is_directory { "dir " } else { "" };
    -        write!(f, "{}{} ({})", scope, self.resolved.display(), self.mode)
    +        write!(
    +            f,
    +            "{} {} ({})",
    +            self.scope.label(),
    +            self.resolved.display(),
    +            self.mode
    +        )
         }
     }
     
    @@ -870,6 +978,20 @@ impl CapabilitySet {
             Ok(self)
         }
     
    +    /// Add a subtree-scoped AF_UNIX socket capability (builder pattern).
    +    ///
    +    /// Grants cover any pathname socket below the directory recursively. The
    +    /// directory must exist at grant time.
    +    pub fn allow_unix_socket_subtree(
    +        mut self,
    +        path: impl AsRef<Path>,
    +        mode: UnixSocketMode,
    +    ) -> Result<Self> {
    +        let cap = UnixSocketCapability::new_dir_subtree(path, mode)?;
    +        self.unix_sockets.push(cap);
    +        Ok(self)
    +    }
    +
         /// Block network access (builder pattern)
         ///
         /// By default, network access is allowed. Call this to block all network.
    @@ -1473,7 +1595,7 @@ impl CapabilitySet {
     
         /// Deduplicate [`UnixSocketCapability`] entries in-place.
         ///
    -    /// Two entries collide when they share `(resolved, is_directory)`.
    +    /// Two entries collide when they share `(resolved, scope)`.
         /// Merge rules match [`Self::deduplicate`]'s user-intent policy:
         ///
         /// - **User-intent beats system/group.** When a user- or profile-
    @@ -1491,13 +1613,13 @@ impl CapabilitySet {
         fn deduplicate_unix_sockets(&mut self) {
             use std::collections::HashMap;
     
    -        let mut seen: HashMap<(PathBuf, bool), usize> = HashMap::new();
    +        let mut seen: HashMap<(PathBuf, SocketScope), usize> = HashMap::new();
             let mut to_remove: Vec<usize> = Vec::new();
             let mut mode_upgrades: Vec<(usize, UnixSocketMode)> = Vec::new();
             let mut original_updates: Vec<(usize, PathBuf)> = Vec::new();
     
             for (i, cap) in self.unix_sockets.iter().enumerate() {
    -            let key = (cap.resolved.clone(), cap.is_directory);
    +            let key = (cap.resolved.clone(), cap.scope);
                 if let Some(&existing_idx) = seen.get(&key) {
                     let existing = &self.unix_sockets[existing_idx];
     
    @@ -1610,12 +1732,11 @@ impl CapabilitySet {
             if !self.unix_sockets.is_empty() {
                 lines.push("Unix sockets:".to_string());
                 for cap in &self.unix_sockets {
    -                let scope = if cap.is_directory { "dir" } else { "file" };
                     lines.push(format!(
                         "  {} [{}] ({})",
                         cap.resolved.display(),
                         cap.mode,
    -                    scope
    +                    cap.scope
                     ));
                 }
             }
    @@ -2835,7 +2956,7 @@ mod tests {
     
             let cap = UnixSocketCapability::new_file(&path, UnixSocketMode::Connect).unwrap();
             assert_eq!(cap.mode, UnixSocketMode::Connect);
    -        assert!(!cap.is_directory);
    +        assert!(!cap.is_directory());
             assert!(cap.resolved.is_absolute());
         }
     
    @@ -2848,7 +2969,7 @@ mod tests {
     
             let cap = UnixSocketCapability::new_file(&missing, UnixSocketMode::ConnectBind).unwrap();
             assert_eq!(cap.mode, UnixSocketMode::ConnectBind);
    -        assert!(!cap.is_directory);
    +        assert!(!cap.is_directory());
             // Resolved path is canonical-parent + final component
             assert_eq!(cap.resolved.file_name().unwrap(), "pending.sock");
             assert!(cap.resolved.parent().unwrap().is_absolute());
    @@ -2882,7 +3003,19 @@ mod tests {
             let dir = tempdir().unwrap();
     
             let cap = UnixSocketCapability::new_dir(dir.path(), UnixSocketMode::Connect).unwrap();
    -        assert!(cap.is_directory);
    +        assert!(cap.is_directory());
    +        assert_eq!(cap.scope, SocketScope::DirChildren);
    +        assert!(cap.resolved.is_absolute());
    +    }
    +
    +    #[test]
    +    fn test_unix_socket_subtree_on_existing_directory() {
    +        let dir = tempdir().unwrap();
    +
    +        let cap =
    +            UnixSocketCapability::new_dir_subtree(dir.path(), UnixSocketMode::Connect).unwrap();
    +        assert!(cap.is_directory());
    +        assert_eq!(cap.scope, SocketScope::DirSubtree);
             assert!(cap.resolved.is_absolute());
         }
     
    @@ -2948,6 +3081,24 @@ mod tests {
             assert!(!cap.covers(&cap.resolved));
         }
     
    +    #[test]
    +    fn test_unix_socket_covers_directory_subtree() {
    +        let dir = tempdir().unwrap();
    +        let cap =
    +            UnixSocketCapability::new_dir_subtree(dir.path(), UnixSocketMode::Connect).unwrap();
    +
    +        let child = cap.resolved.join("x.sock");
    +        assert!(cap.covers(&child), "direct child should be covered");
    +
    +        let grandchild = cap.resolved.join("sub").join("x.sock");
    +        assert!(cap.covers(&grandchild), "grandchild should be covered");
    +
    +        assert!(
    +            !cap.covers(&cap.resolved),
    +            "directory itself is not a socket"
    +        );
    +    }
    +
         #[test]
         fn test_unix_socket_covers_does_not_string_prefix() {
             // Regression: a directory grant for /tmp/foo must NOT cover
    @@ -2972,13 +3123,18 @@ mod tests {
             let file_cap = UnixSocketCapability::new_file(&path, UnixSocketMode::Connect).unwrap();
             let rendered = format!("{file_cap}");
             assert!(rendered.contains("connect"));
    -        assert!(!rendered.starts_with("dir"));
    +        assert!(rendered.starts_with("file "));
     
             let dir_cap =
                 UnixSocketCapability::new_dir(dir.path(), UnixSocketMode::ConnectBind).unwrap();
             let rendered = format!("{dir_cap}");
             assert!(rendered.contains("connect+bind"));
    -        assert!(rendered.starts_with("dir "));
    +        assert!(rendered.starts_with("dir-children "));
    +
    +        let subtree_cap =
    +            UnixSocketCapability::new_dir_subtree(dir.path(), UnixSocketMode::Connect).unwrap();
    +        let rendered = format!("{subtree_cap}");
    +        assert!(rendered.starts_with("dir-subtree "));
         }
     
         #[test]
    @@ -3056,6 +3212,26 @@ mod tests {
             assert!(!caps.unix_socket_allowed(&direct_child, UnixSocketOp::Bind));
         }
     
    +    #[test]
    +    fn test_capability_set_unix_socket_allowed_subtree_grant() {
    +        let dir = tempdir().unwrap();
    +        let caps = CapabilitySet::new()
    +            .allow_unix_socket_subtree(dir.path(), UnixSocketMode::Connect)
    +            .unwrap();
    +
    +        let direct_child = dir.path().canonicalize().unwrap().join("x.sock");
    +        let grandchild = dir
    +            .path()
    +            .canonicalize()
    +            .unwrap()
    +            .join("nested")
    +            .join("x.sock");
    +
    +        assert!(caps.unix_socket_allowed(&direct_child, UnixSocketOp::Connect));
    +        assert!(caps.unix_socket_allowed(&grandchild, UnixSocketOp::Connect));
    +        assert!(!caps.unix_socket_allowed(&direct_child, UnixSocketOp::Bind));
    +    }
    +
         #[test]
         fn test_deduplicate_unix_sockets_merges_identical_grants() {
             let dir = tempdir().unwrap();
    @@ -3105,14 +3281,14 @@ mod tests {
             let group_cap = UnixSocketCapability {
                 original: sock.clone(),
                 resolved: sock.canonicalize().unwrap(),
    -            is_directory: false,
    +            scope: SocketScope::File,
                 mode: UnixSocketMode::ConnectBind,
                 source: CapabilitySource::Group("example_group".to_string()),
             };
             let user_cap = UnixSocketCapability {
                 original: sock.clone(),
                 resolved: sock.canonicalize().unwrap(),
    -            is_directory: false,
    +            scope: SocketScope::File,
                 mode: UnixSocketMode::Connect,
                 source: CapabilitySource::User,
             };
    
  • crates/nono/src/lib.rs+1 1 modified
    @@ -64,7 +64,7 @@ pub mod undo;
     // Re-exports for convenience
     pub use capability::{
         AccessMode, CapabilitySet, CapabilitySource, FsCapability, IpcMode, NetworkMode,
    -    ProcessInfoMode, SignalMode, UnixSocketCapability, UnixSocketMode, UnixSocketOp,
    +    ProcessInfoMode, SignalMode, SocketScope, UnixSocketCapability, UnixSocketMode, UnixSocketOp,
     };
     pub use diagnostic::{
         CommandContext, DenialReason, DenialRecord, DiagnosticFormatter, DiagnosticMode,
    
  • crates/nono/src/sandbox/macos.rs+65 21 modified
    @@ -420,24 +420,37 @@ fn emit_unix_socket_rules(profile: &mut String, caps: &CapabilitySet) -> Result<
                 &["network-outbound"]
             };
     
    -        if cap.is_directory {
    -            let escaped = regex_escape_path_for_seatbelt(resolved_str)?;
    -            let escaped_orig = original_str
    -                .map(regex_escape_path_for_seatbelt)
    -                .transpose()?;
    -            for op in operations {
    -                profile.push_str(&format!("(allow {} (regex \"^{}/[^/]+$\"))\n", op, escaped));
    -                if let Some(ref e) = escaped_orig {
    -                    profile.push_str(&format!("(allow {} (regex \"^{}/[^/]+$\"))\n", op, e));
    +        match cap.scope {
    +            crate::SocketScope::File => {
    +                let escaped = escape_path(resolved_str)?;
    +                let escaped_orig = original_str.map(escape_path).transpose()?;
    +                for op in operations {
    +                    profile.push_str(&format!("(allow {} (path \"{}\"))\n", op, escaped));
    +                    if let Some(ref e) = escaped_orig {
    +                        profile.push_str(&format!("(allow {} (path \"{}\"))\n", op, e));
    +                    }
                     }
                 }
    -        } else {
    -            let escaped = escape_path(resolved_str)?;
    -            let escaped_orig = original_str.map(escape_path).transpose()?;
    -            for op in operations {
    -                profile.push_str(&format!("(allow {} (path \"{}\"))\n", op, escaped));
    -                if let Some(ref e) = escaped_orig {
    -                    profile.push_str(&format!("(allow {} (path \"{}\"))\n", op, e));
    +            crate::SocketScope::DirChildren => {
    +                let escaped = regex_escape_path_for_seatbelt(resolved_str)?;
    +                let escaped_orig = original_str
    +                    .map(regex_escape_path_for_seatbelt)
    +                    .transpose()?;
    +                for op in operations {
    +                    profile.push_str(&format!("(allow {} (regex \"^{}/[^/]+$\"))\n", op, escaped));
    +                    if let Some(ref e) = escaped_orig {
    +                        profile.push_str(&format!("(allow {} (regex \"^{}/[^/]+$\"))\n", op, e));
    +                    }
    +                }
    +            }
    +            crate::SocketScope::DirSubtree => {
    +                let escaped = escape_path(resolved_str)?;
    +                let escaped_orig = original_str.map(escape_path).transpose()?;
    +                for op in operations {
    +                    profile.push_str(&format!("(allow {} (subpath \"{}\"))\n", op, escaped));
    +                    if let Some(ref e) = escaped_orig {
    +                        profile.push_str(&format!("(allow {} (subpath \"{}\"))\n", op, e));
    +                    }
                     }
                 }
             }
    @@ -1391,7 +1404,7 @@ mod tests {
             caps.add_unix_socket(crate::UnixSocketCapability {
                 original: PathBuf::from("/tmp/test.sock"),
                 resolved: PathBuf::from("/private/tmp/test.sock"),
    -            is_directory: false,
    +            scope: crate::SocketScope::File,
                 mode: crate::UnixSocketMode::Connect,
                 source: CapabilitySource::User,
             });
    @@ -1416,7 +1429,7 @@ mod tests {
             caps.add_unix_socket(crate::UnixSocketCapability {
                 original: PathBuf::from("/var/run/app.sock"),
                 resolved: PathBuf::from("/private/var/run/app.sock"),
    -            is_directory: false,
    +            scope: crate::SocketScope::File,
                 mode: crate::UnixSocketMode::ConnectBind,
                 source: CapabilitySource::User,
             });
    @@ -1455,7 +1468,7 @@ mod tests {
             caps.add_unix_socket(crate::UnixSocketCapability {
                 original: PathBuf::from("/var/run/client.sock"),
                 resolved: PathBuf::from("/private/var/run/client.sock"),
    -            is_directory: false,
    +            scope: crate::SocketScope::File,
                 mode: crate::UnixSocketMode::Connect,
                 source: CapabilitySource::User,
             });
    @@ -1481,7 +1494,7 @@ mod tests {
             caps.add_unix_socket(crate::UnixSocketCapability {
                 original: PathBuf::from("/tmp/mydir"),
                 resolved: PathBuf::from("/private/tmp/mydir"),
    -            is_directory: true,
    +            scope: crate::SocketScope::DirChildren,
                 mode: crate::UnixSocketMode::ConnectBind,
                 source: CapabilitySource::User,
             });
    @@ -1507,6 +1520,37 @@ mod tests {
             );
         }
     
    +    #[test]
    +    fn test_generate_profile_unix_socket_subtree_emits_subpath() {
    +        let mut caps = CapabilitySet::new().proxy_only(54321);
    +        caps.add_unix_socket(crate::UnixSocketCapability {
    +            original: PathBuf::from("/tmp/mydir"),
    +            resolved: PathBuf::from("/private/tmp/mydir"),
    +            scope: crate::SocketScope::DirSubtree,
    +            mode: crate::UnixSocketMode::ConnectBind,
    +            source: CapabilitySource::User,
    +        });
    +
    +        let profile = generate_profile(&caps).unwrap();
    +
    +        assert!(
    +            profile.contains("(allow network-outbound (subpath \"/private/tmp/mydir\"))"),
    +            "subtree unix socket grants must emit recursive subpath: {profile}"
    +        );
    +        assert!(
    +            profile.contains("(allow network-outbound (subpath \"/tmp/mydir\"))"),
    +            "symlinked original must also emit subpath form"
    +        );
    +        assert!(
    +            profile.contains("(allow network-bind (subpath \"/private/tmp/mydir\"))"),
    +            "ConnectBind subtree grant must emit network-bind subpath"
    +        );
    +        assert!(
    +            !profile.contains("(regex \"^/private/tmp/mydir/[^/]+$\")"),
    +            "subtree grant must not use direct-child regex"
    +        );
    +    }
    +
         /// #696 core contract: a plain `FsCapability` grant on a socket path
         /// must NOT trigger any `network-outbound` or `network-bind` rule.
         /// Only explicit `UnixSocketCapability` grants do.
    @@ -1580,7 +1624,7 @@ mod tests {
             caps.add_unix_socket(crate::UnixSocketCapability {
                 original: PathBuf::from("/tmp/test.sock"),
                 resolved: PathBuf::from("/private/tmp/test.sock"),
    -            is_directory: false,
    +            scope: crate::SocketScope::File,
                 mode: crate::UnixSocketMode::Connect,
                 source: CapabilitySource::User,
             });
    
  • crates/nono/src/state.rs+49 9 modified
    @@ -3,7 +3,7 @@
     //! This module provides serialization of capability state for diagnostic purposes.
     
     use crate::capability::{
    -    AccessMode, CapabilitySet, FsCapability, UnixSocketCapability, UnixSocketMode,
    +    AccessMode, CapabilitySet, FsCapability, SocketScope, UnixSocketCapability, UnixSocketMode,
     };
     use serde::{Deserialize, Serialize};
     use std::path::PathBuf;
    @@ -41,8 +41,12 @@ pub struct UnixSocketCapState {
         pub original: PathBuf,
         /// Resolved canonical path
         pub resolved: PathBuf,
    -    /// Whether the grant is directory-scoped (non-recursive)
    -    pub is_directory: bool,
    +    /// Path matching scope for this socket grant.
    +    #[serde(default, skip_serializing_if = "Option::is_none")]
    +    pub scope: Option<SocketScope>,
    +    /// Legacy state field from before `SocketScope`.
    +    #[serde(default, skip_serializing_if = "Option::is_none")]
    +    pub is_directory: Option<bool>,
         /// Mode string: "connect" or "connect+bind"
         pub mode: String,
     }
    @@ -68,7 +72,8 @@ impl SandboxState {
                     .map(|cap| UnixSocketCapState {
                         original: cap.original.clone(),
                         resolved: cap.resolved.clone(),
    -                    is_directory: cap.is_directory,
    +                    scope: Some(cap.scope),
    +                    is_directory: None,
                         mode: cap.mode.to_string(),
                     })
                     .collect(),
    @@ -131,10 +136,20 @@ impl SandboxState {
                 // - Crafted JSON smuggling: attacker sets an evil `original`
                 //   and legit `resolved`; the reconstructed cap's actual
                 //   resolved won't match the crafted one, so we reject.
    -            let cap = if sock.is_directory {
    -                UnixSocketCapability::new_dir(&sock.original, mode)?
    -            } else {
    -                UnixSocketCapability::new_file(&sock.original, mode)?
    +            let scope = sock.scope.unwrap_or_else(|| {
    +                if sock.is_directory.unwrap_or(false) {
    +                    SocketScope::DirChildren
    +                } else {
    +                    SocketScope::File
    +                }
    +            });
    +
    +            let cap = match scope {
    +                SocketScope::File => UnixSocketCapability::new_file(&sock.original, mode)?,
    +                SocketScope::DirChildren => UnixSocketCapability::new_dir(&sock.original, mode)?,
    +                SocketScope::DirSubtree => {
    +                    UnixSocketCapability::new_dir_subtree(&sock.original, mode)?
    +                }
                 };
                 if cap.resolved != sock.resolved {
                     return Err(crate::error::NonoError::ConfigParse(format!(
    @@ -238,7 +253,32 @@ mod tests {
             assert_eq!(after.resolved, before.resolved);
             assert_eq!(after.original, before.original);
             assert_eq!(after.mode, before.mode);
    -        assert_eq!(after.is_directory, before.is_directory);
    +        assert_eq!(after.scope, before.scope);
    +    }
    +
    +    #[test]
    +    fn test_unix_socket_state_legacy_is_directory_maps_to_dir_children() {
    +        use tempfile::tempdir;
    +        let dir = tempdir().expect("tempdir");
    +        let json = format!(
    +            r#"{{
    +            "fs": [],
    +            "unix_sockets": [{{
    +                "original": "{}",
    +                "resolved": "{}",
    +                "is_directory": true,
    +                "mode": "connect"
    +            }}],
    +            "net_blocked": false
    +        }}"#,
    +            dir.path().display(),
    +            dir.path().canonicalize().expect("canonicalize").display()
    +        );
    +        let state = SandboxState::from_json(&json).expect("state json");
    +        let caps = state.to_caps().expect("to_caps");
    +        let sockets = caps.unix_socket_capabilities();
    +        assert_eq!(sockets.len(), 1);
    +        assert_eq!(sockets[0].scope, SocketScope::DirChildren);
         }
     
         #[test]
    
  • docs/cli/features/profile-authoring.mdx+6 0 modified
    @@ -85,6 +85,12 @@ With `--full`, additional sections are included as empty stubs for all additive
         "allow_file": [],
         "read_file": [],
         "write_file": [],
    +    "unix_socket": [],
    +    "unix_socket_bind": [],
    +    "unix_socket_dir": [],
    +    "unix_socket_dir_bind": [],
    +    "unix_socket_subtree": [],
    +    "unix_socket_subtree_bind": [],
         "deny": [],
         "bypass_protection": [],
         "suppress_save_prompt": []
    
  • docs/cli/features/profiles-groups.mdx+10 5 modified
    @@ -74,7 +74,9 @@ Profiles use JSON format:
         "unix_socket": [],
         "unix_socket_bind": [],
         "unix_socket_dir": [],
    -    "unix_socket_dir_bind": []
    +    "unix_socket_dir_bind": [],
    +    "unix_socket_subtree": [],
    +    "unix_socket_subtree_bind": []
       },
       "network": {
         "block": false
    @@ -94,12 +96,15 @@ sockets are never grantable — only filesystem-backed socket paths.
     | `unix_socket_bind` | `connect` + `bind`, single socket file | ReadWrite on the file (exists) or on the parent directory (pending — bind will create the file) |
     | `unix_socket_dir` | `connect` only, any direct child of a directory | Read on the directory (recursive) |
     | `unix_socket_dir_bind` | `connect` + `bind`, any direct child of a directory | ReadWrite on the directory (recursive) |
    +| `unix_socket_subtree` | `connect` only, any descendant of a directory | Read on the directory (recursive) |
    +| `unix_socket_subtree_bind` | `connect` + `bind`, any descendant of a directory | ReadWrite on the directory (recursive) |
     
    -Directory forms are **non-recursive at the socket layer**: only sockets
    -directly inside the named directory are covered, not those in
    +`unix_socket_dir*` forms are **non-recursive at the socket layer**: only
    +sockets directly inside the named directory are covered. Use
    +`unix_socket_subtree*` when a tool creates sockets below nested
     subdirectories. The implied filesystem grant is recursive (Landlock's
    -only expressible granularity), so the non-recursion is enforced
    -separately by the supervisor (Linux) or Seatbelt regex emission (macOS).
    +only expressible granularity), so socket scope is enforced separately by
    +the supervisor (Linux) or Seatbelt path emission (macOS).
     
     Under restricted network modes (`--block-net` or `--network-profile`),
     `connect(2)` to a Unix socket requires an explicit `unix_socket*` grant
    
  • docs/cli/usage/flags.mdx+19 0 modified
    @@ -365,6 +365,25 @@ directory. Non-recursive. Use for runtime-generated socket filenames.
     nono run --block-net --allow-unix-socket-dir-bind $TMPDIR/tsx-$UID -- tsx app.ts
     ```
     
    +#### `--allow-unix-socket-subtree`
    +
    +Allow `connect(2)` to any AF_UNIX socket within this directory subtree
    +(recursive — nested subdirectories are covered). Implies read access on the
    +directory.
    +
    +```bash
    +nono run --block-net --allow-unix-socket-subtree $TMPDIR/nx -- nx serve
    +```
    +
    +#### `--allow-unix-socket-subtree-bind`
    +
    +Allow `connect(2)` and `bind(2)` on any AF_UNIX socket within this directory
    +subtree. Implies read/write access on the directory.
    +
    +```bash
    +nono run --block-net --allow-unix-socket-subtree-bind $TMPDIR/nx -- nx serve
    +```
    +
     ### Network Control
     
     #### `--block-net`
    
35f9fea2b823

chore: release v0.55.0

https://github.com/always-further/nonoLuke HindsMay 17, 2026Fixed in 0.55.0via release-tag
6 files changed · +107 10
  • bindings/c/Cargo.toml+1 1 modified
    @@ -15,7 +15,7 @@ name = "nono_ffi"
     crate-type = ["cdylib", "staticlib"]
     
     [dependencies]
    -nono = { version = "0.54.0", path = "../../crates/nono", default-features = false }
    +nono = { version = "0.55.0", path = "../../crates/nono", default-features = false }
     
     [build-dependencies]
     cbindgen.workspace = true
    
  • Cargo.lock+3 3 modified
    @@ -1643,7 +1643,7 @@ dependencies = [
     
     [[package]]
     name = "nono"
    -version = "0.54.0"
    +version = "0.55.0"
     dependencies = [
      "der",
      "getrandom 0.4.2",
    @@ -1673,7 +1673,7 @@ dependencies = [
     
     [[package]]
     name = "nono-cli"
    -version = "0.54.0"
    +version = "0.55.0"
     dependencies = [
      "aws-lc-rs",
      "chrono",
    @@ -1724,7 +1724,7 @@ dependencies = [
     
     [[package]]
     name = "nono-proxy"
    -version = "0.54.0"
    +version = "0.55.0"
     dependencies = [
      "base64",
      "getrandom 0.4.2",
    
  • CHANGELOG.md+97 0 modified
    @@ -1,5 +1,102 @@
     # Changelog
     
    +## [0.55.0] - 2026-05-17
    +
    +
    +### Security
    +- Sandbox escape on Linux via D-Bus ([GHSA-27vp-2mmc-vmh3](https://github.com/always-further/nono/security/advisories/GHSA-27vp-2mmc-vmh3)) — reported by @cgwalters
    +
    +GHSA-27vp-2mmc-vmh3 
    +
    +### Bug Fixes
    +
    +- *(cli)* Unify macOS exact-path grant restore
    +
    +- *(cli)* Preserve macOS future-file grants in why --self
    +
    +- *(pty)* Forward bare ESC immediately in filter_client_input
    +
    +- *(docker)* Pin Alpine version and add --platform to musl Dockerfiles
    +
    +- *(musl)* Use as _ for TIOCSCTTY ioctl cast to support all platforms
    +
    +- *(musl)* Fix libc::Ioctl type mismatches for x86_64-unknown-linux-musl target
    +
    +- Code review
    +
    +- *(proxy)* Honor explicit credential_format on custom inject headers
    +
    +- *(profile-verification)* Strengthen profile and pack verification checks
    +
    +- *(sandbox)* Correctly resolve af_unix socket paths for seccomp
    +
    +- Preserve child output without trailing newline (#881)
    +
    +
    +### Dependencies
    +
    +- *(deps)* Bump clap_complete from 4.6.3 to 4.6.5
    +
    +
    +### Documentation
    +
    +- *(cli-security-model)* Correct typo in nono description
    +
    +- *(cli)* Correct grammar in security model doc
    +
    +- *(cli-security)* Add isolation scope and deployment model
    +
    +- *(installation)* Add makepkg instructions and Note disclaimer
    +
    +- *(installation)* Add Arch Linux (AUR) section
    +
    +- *(capability)* Clarify linux signal mode behavior with landlock
    +
    +
    +### Features
    +
    +- *(macos)* Treat open_port 0 as localhost:* outbound
    +
    +- *(package)* Prevent artifact install path conflicts
    +
    +- *(profile)* Ensure source pack is included for verification
    +
    +- *(profiles)* Verify pack signer identities
    +
    +- *(linux)* Implement af_unix pathname mediation
    +
    +- *(sandbox)* Add explicit allowlist for pathname af_unix sockets
    +
    +- *(unix-socket)* Record explicit scope for grants
    +
    +- *(cli)* Add recursive unix socket directory grants
    +
    +- *(landlock)* Add landlock v6 signal and abstract unix socket scoping
    +
    +
    +### Miscellaneous
    +
    +- Drop changelog update for issue 943
    +
    +
    +### Refactoring
    +
    +- *(package)* Base installs on package manifest
    +
    +- *(supervisor)* Refine ipc denial reporting and audit timestamps
    +
    +
    +### Testing
    +
    +- *(integration-tests)* Use CARGO_TARGET_DIR in runner
    +
    +- *(supervisor-linux)* Add unix listener for connect capability test
    +
    +
    +### Cli
    +
    +- Quiet Landlock deny-overlap diagnostics on Linux
    +
     ## Unreleased
     
     ### Bug Fixes
    
  • crates/nono/Cargo.toml+1 1 modified
    @@ -1,6 +1,6 @@
     [package]
     name = "nono"
    -version = "0.54.0"
    +version = "0.55.0"
     edition.workspace = true
     rust-version.workspace = true
     authors.workspace = true
    
  • crates/nono-cli/Cargo.toml+3 3 modified
    @@ -1,6 +1,6 @@
     [package]
     name = "nono-cli"
    -version = "0.54.0"
    +version = "0.55.0"
     edition.workspace = true
     rust-version.workspace = true
     authors.workspace = true
    @@ -40,8 +40,8 @@ system-keyring = ["dep:keyring", "nono/system-keyring", "nono-proxy/system-keyri
     test-trust-overrides = []
     
     [dependencies]
    -nono = { version = "0.54.0", path = "../nono", default-features = false }
    -nono-proxy = { version = "0.54.0", path = "../nono-proxy", default-features = false }
    +nono = { version = "0.55.0", path = "../nono", default-features = false }
    +nono-proxy = { version = "0.55.0", path = "../nono-proxy", default-features = false }
     clap = { version = "4", features = ["derive", "env"] }
     clap_complete = "4"
     colored = "3"
    
  • crates/nono-proxy/Cargo.toml+2 2 modified
    @@ -1,6 +1,6 @@
     [package]
     name = "nono-proxy"
    -version = "0.54.0"
    +version = "0.55.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.54.0", path = "../nono", default-features = false }
    +nono = { version = "0.55.0", path = "../nono", default-features = false }
     tokio.workspace = true
     hyper.workspace = true
     hyper-util.workspace = true
    

Vulnerability mechanics

No source-code context for this CVE — mechanics is only generated when we can read the actual fix diff. Without that, the four sections (root cause, attack vector, affected code, fix) would be speculation rather than analysis.

References

2

News mentions

0

No linked articles in our index yet.