Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions crates/nono-cli/data/nono-profile.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -1590,6 +1590,41 @@
}
}
},
"CommandUnixSocketScope": {
"type": "string",
"enum": ["file", "dir_children", "dir_subtree"],
"description": "AF_UNIX socket access scope for this command sandbox. file grants access only to the named socket file; dir_children grants access to direct children of the socket's parent directory; dir_subtree grants access to the entire ancestor file tree of the socket file."
},
"CommandUnixSocketMode": {
"type": "string",
"enum": ["connect", "connect_bind"],
"description": "AF_UNIX socket access mode. connect allows connecting out to the socket; connect_bind additionally allows binding a socket on the given path."
},
"CommandUnixSocketConfig": {
"type": "object",
"additionalProperties": false,
"required": ["path"],
"properties": {
"path": {
"type": "string",
"description": "Filesystem-visible path of the AF_UNIX socket. May contain exactly one $VAR environment reference (expandable at child-launch time); otherwise must be absolute."
},
"mode": {
"type": "string",
"enum": ["connect", "connect_bind"],
"description": "Access mode. connect allows connecting to the socket; connect_bind also allows binding at this path."
},
"scope": {
"type": "string",
"enum": ["file", "dir_children", "dir_subtree"],
"description": "Access scope. file (default) covers the socket file itself; dir_children covers direct children of its parent directory; dir_subtree covers the full ancestor file tree."
},
"path_env": {
"type": "string",
"description": "Explicit environment variable used to resolve the socket path at child-launch time, when the path is $VAR-prefixed."
}
}
},
"CommandSandboxConfig": {
"type": "object",
"additionalProperties": false,
Expand Down Expand Up @@ -1661,6 +1696,11 @@
"type": "array",
"items": { "type": "string" },
"description": "macOS-only. Expert escape hatch: raw Seatbelt S-expression rules appended to this command's child sandbox profile, emitted after the generated denies (including the exec gate's (deny process-exec*)), so a later (allow ...) wins under Seatbelt's last-matching-rule semantics. Scoped to a single command or per-intercept override. Ignored on Linux."
},
"unix_sockets": {
"type": "array",
"items": { "$ref": "#/$defs/CommandUnixSocketConfig" },
"description": "AF_UNIX socket access grants (connect and/or bind) for this command's child sandbox. Each entry carries a filesystem-visible socket path, a connect/bind mode, and a scope controlling whether the grant covers the socket file itself, its parent directory's direct children, or its full ancestor file tree. Omitted when empty."
}
}
},
Expand Down
149 changes: 149 additions & 0 deletions crates/nono-cli/src/command_policy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -801,6 +801,11 @@ pub struct CommandSandboxConfig {
/// tools like `git` that re-exec their own helpers by absolute path.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub exec_paths: Vec<String>,
/// AF_UNIX socket paths this command may open/connect/bind, scoped
/// per-command so only specific tool calls can reach privileged
/// sockets (e.g. `/var/run/docker.sock`).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub unix_sockets: Vec<CommandUnixSocketConfig>,
}

impl CommandSandboxConfig {
Expand Down Expand Up @@ -829,10 +834,68 @@ impl CommandSandboxConfig {
&child.unsafe_macos_seatbelt_rules,
),
exec_paths: dedup_append(&self.exec_paths, &child.exec_paths),
unix_sockets: dedup_append(&self.unix_sockets, &child.unix_sockets),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

🐛 [HIGH · bug] The types CommandUnixSocketConfig, CommandUnixSocketMode, and CommandUnixSocketScope do not derive Hash. This causes a compile-time failure when dedup_append (which requires T: Eq + std::hash::Hash + Clone) is called on unix_sockets in CommandSandboxConfig::merge_child. To fix this, add Hash to the #[derive(...)] attribute of all three types.

}
}
}


/// A sandbox grant for a Unix/abstract socket path accessible to a command.
///
/// Grants this command the ability to open (and, with `ConnectBind` mode, both connect
/// and bind) an AF_UNIX socket rooted at `path`.
///
/// This is the policy-level representation of an AF_UNIX socket capability (see `nono`'s
/// `UnixSocketCapability`). A command that must reach the Docker socket, for example,
/// declares `path = "/var/run/docker.sock"` (rootful) or `path = "$XDG_RUNTIME_DIR/docker.sock"`
/// (rootless) here. Mirror the [`CommandCredentialConfig`] doc-comment style.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "snake_case")]
pub enum CommandUnixSocketMode {
/// Connect to an existing socket endpoint.
#[default]
Connect,
/// Connect to and bind a socket endpoint.
ConnectBind,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "snake_case")]
pub enum CommandUnixSocketScope {
/// The grant covers just `path` itself.
#[default]
File,
/// The grant covers direct children of `path`, but not deeper descendants.
DirChildren,
/// The grant covers the entire subtree beneath `path`.
DirSubtree,
}

/// A per-command grant that lets a tool reach a Unix socket at `path`.
///
/// Grants this command the ability to open an AF_UNIX socket rooted at `path`, and in
/// `ConnectBind` mode to bind and connect it. The `path` is canonical and may contain
/// `$VAR` references (for example `$XDG_RUNTIME_DIR/docker.sock`), in which case
/// `path_env` should name the variable used so it can be resolved before the environment
/// is stripped.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CommandUnixSocketConfig {
/// Canonical socket path (e.g. `/var/run/docker.sock` for rootful Docker,
/// or `$XDG_RUNTIME_DIR/docker.sock` for rootless Docker).
pub path: String,
/// Access mode granted to the socket. Defaults to `Connect`.
#[serde(default)]
pub mode: CommandUnixSocketMode,
/// Scope of the grant relative to `path`. Defaults to `File`.
#[serde(default)]
pub scope: CommandUnixSocketScope,
/// Name of the env var referenced by `path` when it uses `$VAR` expansion.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub path_env: Option<String>,
}


#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct CommandStdioConfig {
Expand Down Expand Up @@ -1958,6 +2021,7 @@ fn validate_sandbox(
),
);
}
validate_unix_sockets(command_name, caller, &sandbox.unix_sockets, report);

if scope == CommandPolicyValidationScope::Resolved {
validate_sandbox_credentials(command_name, caller, sandbox, config, report);
Expand Down Expand Up @@ -2039,6 +2103,64 @@ fn validate_unsafe_seatbelt_rules(
);
}

fn validate_unix_sockets(
command_name: &str,
caller: &str,
unix_sockets: &[CommandUnixSocketConfig],
report: &mut CommandPolicyValidationReport,
) {
if unix_sockets.is_empty() {
return;
}
for cfg in unix_sockets {
let path = &cfg.path;
if path.is_empty() {
report.error(
"invalid_unix_socket",
format!("command '{command_name}' from.{caller} unix_sockets path is empty"),
);
continue;
}
if path.contains('\0') {
report.error(
"invalid_unix_socket",
format!("command '{command_name}' from.{caller} unix_sockets path contains NUL"),
);
continue;
}
if let Some(env_name) = &cfg.path_env {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Warning

🔒 [MEDIUM · security] In validate_unix_sockets, providing path_env bypasses absolute path validation on path even if path does not contain a $ environment reference and is relative (e.g. relative/docker.sock). At runtime, relative paths are canonicalized against the parent process's current working directory, which violates sandbox containment assumptions. Update the validation logic to always verify that path is either absolute or starts with $.

// `path_env` is set: `$VAR`-prefixed paths are allowed, but the env
// var name itself must still be a valid identifier.
if !is_valid_identifier(env_name) {
report.error(
"invalid_unix_socket",
format!(
"command '{command_name}' from.{caller} unix_sockets path_env '{env_name}' is not a valid identifier"
),
);
continue;
}
} else if !path.contains('$') {
// No env-var expansion involved, so the path must be absolute.
if !Path::new(path).is_absolute() {
report.error(
"invalid_unix_socket",
format!(
"command '{command_name}' from.{caller} unix_sockets path '{path}' is not absolute"
),
);
continue;
}
}
report.warning(
"unix_socket_access",
format!(
"command '{command_name}' from.{caller} will be granted AF_UNIX connect/bind access to socket '{path}'"
),
);
}
}

fn validate_invocation_policy(
command_name: &str,
caller: &str,
Expand Down Expand Up @@ -3714,6 +3836,15 @@ mod tests {
);
}

#[test]
fn command_sandbox_empty_unix_sockets_omitted_from_serialization() {
let value = serde_json::to_value(CommandSandboxConfig::default()).expect("serialize");
assert!(
value.get("unix_sockets").is_none(),
"empty unix_sockets should be omitted, got {value}"
);
}

#[test]
fn command_network_open_port_merge_child_dedup_appends() {
let base = CommandNetworkConfig {
Expand All @@ -3739,6 +3870,24 @@ mod tests {
assert!(cfg.open_port.is_empty());
}

#[test]
fn command_unix_socket_config_round_trips() {
let json = r#"{"path":"/var/run/docker.sock","mode":"connect"}"#;
let cfg: CommandUnixSocketConfig = serde_json::from_str(json).expect("parse");
assert_eq!(cfg.path, "/var/run/docker.sock");
assert_eq!(cfg.mode, CommandUnixSocketMode::Connect);
assert_eq!(cfg.scope, CommandUnixSocketScope::File);
assert_eq!(cfg.path_env, None);
}

#[test]
fn command_unix_socket_config_deny_unknown_fields() {
let json = r#"{"path":"/var/run/docker.sock","mode":"connect","bogus_key":true}"#;
let err = serde_json::from_str::<CommandUnixSocketConfig>(json).unwrap_err();
assert!(err.to_string().contains("unknown field"));
}


#[test]
fn command_sandbox_unsafe_seatbelt_rules_empty_rule_errors() {
let mut config = active_git_config();
Expand Down
31 changes: 30 additions & 1 deletion crates/nono-cli/src/tool-sandbox/platform/linux.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ use crate::audit_integrity::{
CommandPolicyStdioStreamAudit,
};
use crate::command_policy::{
CommandFromConfig, CommandPoliciesConfig, CommandSandboxConfig, ResolvedCommandBinaries,
CommandFromConfig, CommandPoliciesConfig, CommandSandboxConfig, CommandUnixSocketConfig, CommandUnixSocketMode, CommandUnixSocketScope, ResolvedCommandBinaries,
ResolvedCommandBinary, ResolvedExecutableKind, classify_executable_shape,
has_explicit_self_invocation_entry,
};
Expand Down Expand Up @@ -3481,6 +3481,7 @@ fn build_child_caps(
&state.deny_paths,
)?;
add_policy_network(&mut caps, policy)?;
add_policy_unix_sockets(&mut caps, policy)?;
add_policy_proxy_network(&mut caps, state, request, policy)?;
add_proxy_trust_bundle_caps(&mut caps, state, policy)?;
add_policy_credentials(&mut caps, state, policy)?;
Expand Down Expand Up @@ -3821,6 +3822,34 @@ fn add_policy_network(caps: &mut CapabilitySet, policy: &CommandSandboxConfig) -
}
Ok(())
}
fn add_policy_unix_sockets(caps: &mut CapabilitySet, policy: &CommandSandboxConfig) -> Result<()> {
for cfg in &policy.unix_sockets {
// A socket path that fails env expansion is a policy error: fail closed
// rather than launch the sandbox with a silently missing grant.
let expanded = crate::policy::expand_env_vars_strict(&cfg.path).map_err(|err| {
NonoError::SandboxInit(format!(
"command sandbox unix socket path {path} could not be expanded: {err}",
path = cfg.path
))
})?;
let unix_mode = match cfg.mode {
CommandUnixSocketMode::ConnectBind => UnixSocketMode::ConnectBind,
CommandUnixSocketMode::Connect => UnixSocketMode::Connect,
};
let capability = match cfg.scope {
CommandUnixSocketScope::DirSubtree => {
UnixSocketCapability::new_dir_subtree(&expanded, unix_mode)?
}
CommandUnixSocketScope::DirChildren => {
UnixSocketCapability::new_dir(&expanded, unix_mode)?
}
CommandUnixSocketScope::File => UnixSocketCapability::new_file(&expanded, unix_mode)?,
};
caps.add_unix_socket(capability);
}
Ok(())
}


fn add_policy_proxy_network(
caps: &mut CapabilitySet,
Expand Down
28 changes: 26 additions & 2 deletions crates/nono-cli/src/tool-sandbox/platform/macos.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ use crate::audit_integrity::{
CommandPolicyStdioStreamAudit,
};
use crate::command_policy::{
CommandPoliciesConfig, CommandSandboxConfig, InterceptActionConfig, ResolvedCommandBinaries,
ResolvedCommandBinary, has_explicit_self_invocation_entry,
CommandPoliciesConfig, CommandSandboxConfig, CommandUnixSocketMode, CommandUnixSocketScope,
InterceptActionConfig, ResolvedCommandBinaries, ResolvedCommandBinary, has_explicit_self_invocation_entry,
};
use crate::tool_sandbox::credentials::{ResolvedCredential, resolve_credentials};
use crate::tool_sandbox::env::{
Expand Down Expand Up @@ -3104,6 +3104,30 @@ fn add_policy_fs(
super::add_optional_write_file(caps, path)?;
}
}
for cfg in &policy.unix_sockets {
// A socket path that fails env expansion is a policy error: fail closed
// rather than launch the sandbox with a silently missing grant.
let expanded = crate::policy::expand_env_vars_strict(&cfg.path).map_err(|err| {
NonoError::SandboxInit(format!(
"command sandbox unix socket path {path} could not be expanded: {err}",
path = cfg.path
))
})?;
let unix_mode = match cfg.mode {
CommandUnixSocketMode::ConnectBind => UnixSocketMode::ConnectBind,
CommandUnixSocketMode::Connect => UnixSocketMode::Connect,
};
let capability = match cfg.scope {
CommandUnixSocketScope::DirSubtree => {
UnixSocketCapability::new_dir_subtree(&expanded, unix_mode)?
}
CommandUnixSocketScope::DirChildren => {
UnixSocketCapability::new_dir(&expanded, unix_mode)?
}
CommandUnixSocketScope::File => UnixSocketCapability::new_file(&expanded, unix_mode)?,
};
caps.add_unix_socket(capability);
}
Ok(())
}

Expand Down
1 change: 1 addition & 0 deletions crates/nono-cli/tests/schema_shape.rs
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,7 @@ fn test_schema_command_policies_match_tool_sandbox_guide_shape() {
"resources",
"stdio",
"unsafe_macos_seatbelt_rules",
"unix_sockets",
"use_credentials",
],
);
Expand Down
Loading