diff --git a/.github/ISSUE_TEMPLATE/onboarding_issue.yml b/.github/ISSUE_TEMPLATE/onboarding_issue.yml index e68454208..b069a9ec7 100644 --- a/.github/ISSUE_TEMPLATE/onboarding_issue.yml +++ b/.github/ISSUE_TEMPLATE/onboarding_issue.yml @@ -44,7 +44,7 @@ body: id: install-method attributes: label: How did you install nono? - placeholder: "e.g. Homebrew tap, cargo install, downloaded binary from GitHub releases..." + placeholder: "e.g. Homebrew, cargo install, downloaded binary from GitHub releases..." validations: required: true diff --git a/.github/workflows/nix-integration.yml b/.github/workflows/nix-integration.yml new file mode 100644 index 000000000..72ff85523 --- /dev/null +++ b/.github/workflows/nix-integration.yml @@ -0,0 +1,62 @@ +name: NixOS Integration Tests + +on: + pull_request: + branches: [main] + push: + branches: [main] + +permissions: + contents: read + +env: + CARGO_TERM_COLOR: always + RUSTFLAGS: -Dwarnings + +jobs: + nix-integration: + name: Nix Integration Tests + if: ${{ !startsWith(github.head_ref, 'dependabot/github_actions/') }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + + - name: Install system dependencies + run: sudo apt-get update && sudo apt-get install -y libdbus-1-dev pkg-config + + - name: Install Nix + uses: cachix/install-nix-action@2126ae7fc54c9df00dd18f7f18754393182c73cd # v31 + + - name: Pin nixpkgs channel + run: | + nix-channel --remove nixpkgs || true + nix-channel --add https://nixos.org/channels/nixos-24.11 nixpkgs + nix-channel --update nixpkgs + + - name: Install Nix test packages + run: | + nix-env -iA nixpkgs.coreutils + nix-env -iA nixpkgs.bash + nix-env -iA nixpkgs.python3 + nix-env -iA nixpkgs.nodejs + nix-env -iA nixpkgs.curl + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable + + - name: Cache cargo registry + uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: nix-cargo-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + nix-cargo- + + - name: Build release binary + run: cargo build --release + + - name: Run Nix integration tests + run: ./tests/run_nix_integration_tests.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f3ac32103..11ef2cd9e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -156,50 +156,17 @@ jobs: env: CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} - update-homebrew: - name: Update Homebrew Formula + update-homebrew-core: + name: Bump Homebrew Core Formula needs: release runs-on: ubuntu-latest - # Only update stable formula for non-prerelease versions if: ${{ !contains(github.ref_name, 'alpha') && !contains(github.ref_name, 'beta') && !contains(github.ref_name, 'rc') }} steps: - - name: Checkout homebrew-nono - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - name: Bump homebrew-core formula + uses: mislav/bump-homebrew-formula-action@56a283fa15557e9abaa4bdb63b8212abc68e655c # v3.6 with: - repository: always-further/homebrew-nono - token: ${{ secrets.HOMEBREW_TAP_TOKEN }} - path: homebrew-nono - - - name: Download macOS artifacts - uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0 - with: - pattern: nono-*-apple-darwin - path: artifacts - - - name: Update formula - run: | - VERSION="${{ env.RELEASE_TAG }}" - VERSION="${VERSION#v}" - - # Calculate SHA256 for both architectures - ARM64_SHA=$(sha256sum artifacts/nono-aarch64-apple-darwin/nono-*-aarch64-apple-darwin.tar.gz | cut -d' ' -f1) - X64_SHA=$(sha256sum artifacts/nono-x86_64-apple-darwin/nono-*-x86_64-apple-darwin.tar.gz | cut -d' ' -f1) - - # Update formula version and checksums - cd homebrew-nono - sed -i "s/version \".*\"/version \"${VERSION}\"/" Formula/nono.rb - # Update URLs (both directory path and filename) - sed -i "s|releases/download/v[^/]*/nono-v[^-]*-|releases/download/v${VERSION}/nono-v${VERSION}-|g" Formula/nono.rb - # Update ARM64 SHA (first sha256 in on_arm block) - sed -i "/on_arm/,/end/{s/sha256 \"[^\"]*\"/sha256 \"${ARM64_SHA}\"/}" Formula/nono.rb - # Update x64 SHA (sha256 in on_intel block) - sed -i "/on_intel/,/end/{s/sha256 \"[^\"]*\"/sha256 \"${X64_SHA}\"/}" Formula/nono.rb - - - name: Commit and push - run: | - cd homebrew-nono - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - git add Formula/nono.rb - git diff --staged --quiet || git commit -m "Update nono to ${{ env.RELEASE_TAG }}" - git push + formula-name: nono + tag-name: ${{ env.RELEASE_TAG }} + download-url: "https://github.com/always-further/nono/archive/refs/tags/${{ env.RELEASE_TAG }}.tar.gz" + env: + COMMITTER_TOKEN: ${{ secrets.HOMEBREW_CORE_TOKEN }} diff --git a/.github/workflows/sign-instruction-files.yml b/.github/workflows/sign-instruction-files.yml index 0064c6ff7..9012822e7 100644 --- a/.github/workflows/sign-instruction-files.yml +++ b/.github/workflows/sign-instruction-files.yml @@ -1,8 +1,8 @@ -# Sign instruction files with Sigstore keyless attestation. +# Sign instruction files with the official nono-attest GitHub Action. # -# Produces .bundle sidecar files containing DSSE envelopes with in-toto +# Produces Sigstore bundles containing DSSE envelopes with in-toto # statements that nono's trust pipeline can verify. Uses GitHub Actions -# OIDC for identity — Fulcio issues a short-lived certificate carrying +# OIDC for identity; Fulcio issues a short-lived certificate carrying # the repository, workflow, and ref claims. # # Consumer-side verification: nono's pre-exec trust scan validates bundles @@ -22,7 +22,7 @@ on: permissions: id-token: write # Sigstore keyless OIDC token - contents: write # Commit .bundle sidecars + contents: write # Commit generated bundle files jobs: sign: @@ -30,29 +30,4 @@ jobs: steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - - - name: Install nono - run: | - LATEST_TAG=$(gh release view --repo always-further/nono --json tagName -q .tagName) - curl -fsSL "https://github.com/always-further/nono/releases/download/${LATEST_TAG}/nono-${LATEST_TAG}-x86_64-unknown-linux-gnu.tar.gz" | tar xz - sudo mv nono /usr/local/bin/ - env: - GH_TOKEN: ${{ github.token }} - - - name: Sign instruction files - run: nono trust sign --keyless --all - - - name: Commit bundles - run: | - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - - git add -A '*.bundle' - - if git diff --cached --quiet; then - echo "No bundle changes to commit." - exit 0 - fi - - git commit -m "chore: update instruction file attestation bundles [skip ci]" - git push + - uses: always-further/nono-attest@6b5bd8fbf7e1946e8711338eff11646ecdff87d3 # v0.0.3 diff --git a/README.md b/README.md index aea88ddbc..da930510a 100644 --- a/README.md +++ b/README.md @@ -257,7 +257,6 @@ nono audit show 20260216-193311-20751 --json ### macOS ```bash -brew tap always-further/nono brew install nono ``` diff --git a/crates/nono-cli/README.md b/crates/nono-cli/README.md index 2dce2bfe9..57b255ed5 100644 --- a/crates/nono-cli/README.md +++ b/crates/nono-cli/README.md @@ -7,7 +7,6 @@ CLI for capability-based sandboxing using Landlock (Linux) and Seatbelt (macOS). ### Homebrew (macOS) ```bash -brew tap always-further/nono brew install nono ``` diff --git a/crates/nono-cli/data/policy.json b/crates/nono-cli/data/policy.json index 1a6453858..385193050 100644 --- a/crates/nono-cli/data/policy.json +++ b/crates/nono-cli/data/policy.json @@ -301,7 +301,6 @@ "/dev/zero", "/dev/full", "/dev/tty", - "/dev/pts", "$TMPDIR" ] } @@ -364,7 +363,9 @@ "~/.npm", "~/.node", "~/.local/share/fnm", - "/usr/local/lib/node_modules" + "/usr/local/lib/node_modules", + "~/Library/pnpm", + "~/.local/share/pnpm" ] } }, @@ -378,11 +379,12 @@ } }, "python_runtime": { - "description": "Python runtime paths", + "description": "Python runtime paths (pyenv, conda, uv)", "allow": { "read": [ "~/.pyenv", "~/.local/lib", + "~/.local/share/uv", "~/.conda" ] } diff --git a/crates/nono-cli/src/capability_ext.rs b/crates/nono-cli/src/capability_ext.rs index 2c2550968..8e94cc3c6 100644 --- a/crates/nono-cli/src/capability_ext.rs +++ b/crates/nono-cli/src/capability_ext.rs @@ -440,6 +440,7 @@ mod tests { proxy_allow: vec![], proxy_credential: vec![], external_proxy: None, + external_proxy_bypass: vec![], override_deny: vec![], allow_command: vec![], block_command: vec![], diff --git a/crates/nono-cli/src/cli.rs b/crates/nono-cli/src/cli.rs index 1b0122caf..86cacb6ed 100644 --- a/crates/nono-cli/src/cli.rs +++ b/crates/nono-cli/src/cli.rs @@ -277,6 +277,7 @@ pub struct SandboxArgs { "proxy_allow", "proxy_credential", "external_proxy", + "external_proxy_bypass", "proxy_port" ] )] @@ -318,9 +319,20 @@ pub struct SandboxArgs { /// Chain through an external (enterprise) proxy. /// Format: host:port (e.g., squid.corp.internal:3128) - #[arg(long, value_name = "HOST:PORT")] + #[arg(long, value_name = "HOST:PORT", env = "NONO_EXTERNAL_PROXY")] pub external_proxy: Option, + /// Domains to route directly instead of through the external proxy. + /// Supports exact hostnames and wildcards (e.g., *.internal.corp). + /// Can be specified multiple times. Requires --external-proxy (or profile equivalent). + #[arg( + long, + value_name = "HOST", + env = "NONO_EXTERNAL_PROXY_BYPASS", + value_delimiter = ',' + )] + pub external_proxy_bypass: Vec, + /// Fixed port for the credential injection proxy (default: OS-assigned). /// Use this when the sandboxed application requires a known proxy port /// (e.g., for base URL configuration that can't read environment variables). @@ -403,6 +415,7 @@ impl SandboxArgs { self.network_profile.is_some() || !self.proxy_allow.is_empty() || !self.proxy_credential.is_empty() + || self.external_proxy.is_some() } } diff --git a/crates/nono-cli/src/main.rs b/crates/nono-cli/src/main.rs index fd34e4d19..206b28fb5 100644 --- a/crates/nono-cli/src/main.rs +++ b/crates/nono-cli/src/main.rs @@ -346,6 +346,7 @@ fn run_why(args: WhyArgs) -> Result<()> { proxy_allow: vec![], proxy_credential: vec![], external_proxy: None, + external_proxy_bypass: vec![], override_deny: vec![], allow_command: vec![], block_command: vec![], @@ -382,6 +383,7 @@ fn run_why(args: WhyArgs) -> Result<()> { proxy_allow: vec![], proxy_credential: vec![], external_proxy: None, + external_proxy_bypass: vec![], override_deny: vec![], allow_command: vec![], block_command: vec![], @@ -456,6 +458,7 @@ fn run_sandbox(run_args: RunArgs, silent: bool) -> Result<()> { // Dry run mode - just show what would happen if args.dry_run { let prepared = prepare_sandbox(&args, silent)?; + validate_external_proxy_bypass(&args, &prepared)?; if !prepared.secrets.is_empty() && !silent { eprintln!( " Would inject {} credential(s) as environment variables", @@ -566,6 +569,32 @@ fn run_sandbox(run_args: RunArgs, silent: bool) -> Result<()> { let proxy_allow_hosts = effective_proxy.proxy_allow_hosts; let proxy_credentials = effective_proxy.proxy_credentials; + // Resolve effective external proxy: --net-allow clears it (same as other + // proxy settings), otherwise CLI overrides profile. + let effective_external_proxy = if args.net_allow { + None + } else { + args.external_proxy + .clone() + .or_else(|| prepared.external_proxy.clone()) + }; + + // Resolve effective bypass hosts: cleared by --net-allow, otherwise + // CLI --external-proxy wins (use CLI bypass only), otherwise merge + // profile + CLI bypass hosts. + let effective_bypass = if args.net_allow { + Vec::new() + } else if args.external_proxy.is_some() { + args.external_proxy_bypass.clone() + } else { + let mut bypass = prepared.external_proxy_bypass.clone(); + bypass.extend(args.external_proxy_bypass.clone()); + bypass + }; + + // Validate: bypass hosts require an external proxy (from CLI or profile) + validate_external_proxy_bypass(&args, &prepared)?; + // The proxy is needed when the network mode is ProxyOnly OR when there are // credential routes to inject. However, --net-block takes precedence: if // network is explicitly blocked, the proxy must NOT activate since that @@ -574,6 +603,7 @@ fn run_sandbox(run_args: RunArgs, silent: bool) -> Result<()> { if !proxy_credentials.is_empty() || network_profile.is_some() || !proxy_allow_hosts.is_empty() + || effective_external_proxy.is_some() { warn!( "--net-block is active; ignoring proxy configuration \ @@ -594,6 +624,7 @@ fn run_sandbox(run_args: RunArgs, silent: bool) -> Result<()> { ) || !proxy_credentials.is_empty() || network_profile.is_some() || !proxy_allow_hosts.is_empty() + || effective_external_proxy.is_some() }; // Split --rollback-exclude values: glob metacharacters route to filename @@ -647,7 +678,8 @@ fn run_sandbox(run_args: RunArgs, silent: bool) -> Result<()> { proxy_allow_hosts, proxy_credentials, custom_credentials: prepared.custom_credentials, - external_proxy: args.external_proxy.clone(), + external_proxy: effective_external_proxy, + external_proxy_bypass: effective_bypass, allow_bind_ports: args.allow_bind, proxy_port: args.proxy_port, }, @@ -727,10 +759,12 @@ fn run_wrap(wrap_args: WrapArgs, silent: bool) -> Result<()> { || !args.proxy_allow.is_empty() || !args.proxy_credential.is_empty() || args.external_proxy.is_some() + || !args.external_proxy_bypass.is_empty() { return Err(NonoError::ConfigParse( "nono wrap does not support proxy flags (--network-profile, --proxy-allow, \ - --proxy-credential, --external-proxy). Use `nono run` instead." + --proxy-credential, --external-proxy, --external-proxy-bypass). \ + Use `nono run` instead." .to_string(), )); } @@ -757,6 +791,22 @@ fn run_wrap(wrap_args: WrapArgs, silent: bool) -> Result<()> { let prepared = prepare_sandbox(&args, silent)?; + // Also reject proxy flags that came from the profile (not just CLI). + // Profile-provided external_proxy / network settings activate ProxyOnly + // mode, which requires a parent process that wrap doesn't provide. + if prepared.external_proxy.is_some() + || matches!( + prepared.caps.network_mode(), + nono::NetworkMode::ProxyOnly { .. } + ) + { + return Err(NonoError::ConfigParse( + "nono wrap does not support proxy mode (activated by profile network settings). \ + Use `nono run` instead." + .to_string(), + )); + } + execute_sandboxed( program, cmd_args, @@ -811,6 +861,8 @@ struct ExecutionFlags { custom_credentials: std::collections::HashMap, /// External proxy address (from --external-proxy) external_proxy: Option, + /// Hosts to bypass the external proxy (from --external-proxy-bypass) + external_proxy_bypass: Vec, /// Ports the sandboxed process is allowed to bind (from --allow-bind) allow_bind_ports: Vec, /// Fixed port for the credential proxy (from --proxy-port) @@ -846,6 +898,7 @@ impl ExecutionFlags { proxy_credentials: Vec::new(), custom_credentials: std::collections::HashMap::new(), external_proxy: None, + external_proxy_bypass: Vec::new(), allow_bind_ports: Vec::new(), proxy_port: None, }) @@ -891,6 +944,23 @@ fn resolve_effective_proxy_settings( } } +/// Validate that bypass hosts are not specified without an external proxy. +/// Called from both the dry-run and live execution paths. +fn validate_external_proxy_bypass(args: &SandboxArgs, prepared: &PreparedSandbox) -> Result<()> { + let has_bypass = + !args.external_proxy_bypass.is_empty() || !prepared.external_proxy_bypass.is_empty(); + let has_external_proxy = args.external_proxy.is_some() || prepared.external_proxy.is_some(); + + if has_bypass && !has_external_proxy { + return Err(NonoError::ConfigParse( + "--external-proxy-bypass requires --external-proxy \ + (or external_proxy in profile network config)" + .to_string(), + )); + } + Ok(()) +} + /// Apply sandbox pre-fork for Direct mode (both parent+child confined). fn apply_pre_fork_sandbox( strategy: exec_strategy::ExecStrategy, @@ -899,7 +969,20 @@ fn apply_pre_fork_sandbox( ) -> Result<()> { if matches!(strategy, exec_strategy::ExecStrategy::Direct) { output::print_applying_sandbox(silent); - Sandbox::apply(caps)?; + + // On Linux, use the ABI-aware path to avoid BestEffort flag masking. + #[cfg(target_os = "linux")] + { + let detected = Sandbox::detect_abi()?; + info!("Direct mode: detected {}", detected); + Sandbox::apply_with_abi(caps, &detected)?; + } + + #[cfg(not(target_os = "linux"))] + { + Sandbox::apply(caps)?; + } + output::print_sandbox_active(silent); } Ok(()) @@ -957,6 +1040,7 @@ fn build_proxy_config_from_flags( proxy_config.external_proxy = Some(nono_proxy::config::ExternalProxyConfig { address: addr.clone(), auth: None, + bypass_hosts: flags.external_proxy_bypass.clone(), }); } @@ -1508,6 +1592,10 @@ struct PreparedSandbox { proxy_credentials: Vec, /// Custom credential definitions from profile config custom_credentials: std::collections::HashMap, + /// External proxy address from profile config (if any) + external_proxy: Option, + /// Bypass hosts for external proxy from profile config + external_proxy_bypass: Vec, /// Whether the profile enables runtime capability elevation (seccomp-notify + PTY) capability_elevation: bool, } @@ -1631,6 +1719,13 @@ fn prepare_sandbox(args: &SandboxArgs, silent: bool) -> Result .as_ref() .map(|p| p.network.custom_credentials.clone()) .unwrap_or_default(); + let profile_external_proxy = loaded_profile + .as_ref() + .and_then(|p| p.network.external_proxy.clone()); + let profile_external_proxy_bypass = loaded_profile + .as_ref() + .map(|p| p.network.external_proxy_bypass.clone()) + .unwrap_or_default(); // On Linux, pre-create paths that the claude-code profile grants but // may not exist yet. Non-existent paths are skipped during capability @@ -1824,6 +1919,10 @@ fn prepare_sandbox(args: &SandboxArgs, silent: bool) -> Result // Print capability summary output::print_capabilities(&caps, args.verbose, silent); + // Print Landlock ABI info on Linux + #[cfg(target_os = "linux")] + output::print_abi_info(silent); + // Check platform support if !Sandbox::is_supported() { return Err(NonoError::SandboxInit(Sandbox::support_info().details)); @@ -1840,6 +1939,8 @@ fn prepare_sandbox(args: &SandboxArgs, silent: bool) -> Result proxy_allow_hosts: profile_proxy_allow, proxy_credentials: profile_proxy_credentials, custom_credentials: profile_custom_credentials, + external_proxy: profile_external_proxy, + external_proxy_bypass: profile_external_proxy_bypass, capability_elevation, }) } @@ -1975,6 +2076,7 @@ mod tests { proxy_allow: vec![], proxy_credential: vec![], external_proxy: None, + external_proxy_bypass: vec![], override_deny: vec![], allow_command: vec![], block_command: vec![], @@ -2084,6 +2186,8 @@ mod tests { proxy_allow_hosts: vec!["docs.python.org".to_string()], proxy_credentials: vec!["github".to_string()], custom_credentials: std::collections::HashMap::new(), + external_proxy: None, + external_proxy_bypass: Vec::new(), capability_elevation: false, }; @@ -2116,6 +2220,8 @@ mod tests { proxy_allow_hosts: vec!["docs.python.org".to_string()], proxy_credentials: vec!["github".to_string()], custom_credentials: std::collections::HashMap::new(), + external_proxy: None, + external_proxy_bypass: Vec::new(), capability_elevation: false, }; diff --git a/crates/nono-cli/src/output.rs b/crates/nono-cli/src/output.rs index 4e4abd995..766a6ab6f 100644 --- a/crates/nono-cli/src/output.rs +++ b/crates/nono-cli/src/output.rs @@ -159,6 +159,66 @@ pub fn print_capabilities(caps: &CapabilitySet, verbose: u8, silent: bool) { eprintln!(); } +/// Print Landlock ABI information (Linux only). +/// +/// Shows the detected ABI version and available features. When features +/// are degraded (ABI < V5), displays which features are unavailable. +#[cfg(target_os = "linux")] +pub fn print_abi_info(silent: bool) { + if silent { + return; + } + match nono::Sandbox::detect_abi() { + Ok(detected) => { + let features = detected.feature_names(); + let feature_summary: Vec<&str> = features.iter().skip(1).map(|s| s.as_str()).collect(); + if feature_summary.is_empty() { + eprintln!(" {} {}", "Sandbox:".white(), detected.to_string().green(),); + } else { + eprintln!( + " {} {} ({})", + "Sandbox:".white(), + detected.to_string().green(), + feature_summary.join(", ").truecolor(150, 150, 150), + ); + } + + // Show what's missing on degraded ABI versions + type AbiFeatureCheck = (&'static str, fn(&nono::DetectedAbi) -> bool); + const ALL_FEATURES: &[AbiFeatureCheck] = &[ + ("Refer", nono::DetectedAbi::has_refer), + ("Truncate", nono::DetectedAbi::has_truncate), + ("TCP filtering", nono::DetectedAbi::has_network), + ("IoctlDev", nono::DetectedAbi::has_ioctl_dev), + ("Scoping", nono::DetectedAbi::has_scoping), + ]; + + let missing: Vec<&str> = ALL_FEATURES + .iter() + .filter(|(_, check)| !check(&detected)) + .map(|(name, _)| *name) + .collect(); + if !missing.is_empty() { + eprintln!( + " {}", + format!( + "Degraded: {} (upgrade kernel for full support)", + missing.join(", ") + ) + .truecolor(180, 150, 50), + ); + } + } + Err(e) => { + eprintln!( + " {} {}", + "Sandbox:".white(), + format!("Landlock detection failed: {}", e).red(), + ); + } + } +} + /// Print supervised mode status pub fn print_supervised_info(silent: bool, rollback: bool, proxy_active: bool) { if silent { diff --git a/crates/nono-cli/src/profile/mod.rs b/crates/nono-cli/src/profile/mod.rs index 5d158fc04..22efef609 100644 --- a/crates/nono-cli/src/profile/mod.rs +++ b/crates/nono-cli/src/profile/mod.rs @@ -519,6 +519,13 @@ pub struct NetworkConfig { /// how to route and inject credentials for that service. #[serde(default)] pub custom_credentials: HashMap, + /// External proxy address (host:port) for enterprise proxy passthrough. + #[serde(default)] + pub external_proxy: Option, + /// Hosts to bypass the external proxy and route directly. + /// Supports exact hostnames and `*.` wildcard suffixes. + #[serde(default)] + pub external_proxy_bypass: Vec, } impl NetworkConfig { @@ -531,6 +538,7 @@ impl NetworkConfig { self.resolved_network_profile().is_some() || !self.proxy_allow.is_empty() || !self.proxy_credentials.is_empty() + || self.external_proxy.is_some() } } @@ -981,6 +989,12 @@ fn merge_profiles(base: Profile, child: Profile) -> Profile { merged.extend(child.network.custom_credentials); merged }, + // Child overrides base external proxy; if child has None, inherit base + external_proxy: child.network.external_proxy.or(base.network.external_proxy), + external_proxy_bypass: dedup_append( + &base.network.external_proxy_bypass, + &child.network.external_proxy_bypass, + ), }, env_credentials: SecretsConfig { mappings: { @@ -2014,6 +2028,8 @@ mod tests { port_allow: vec![3000], proxy_credentials: vec!["base_cred".to_string()], custom_credentials: HashMap::new(), + external_proxy: None, + external_proxy_bypass: Vec::new(), }, env_credentials: SecretsConfig { mappings: { @@ -2065,6 +2081,8 @@ mod tests { port_allow: vec![3000, 5000], proxy_credentials: vec![], custom_credentials: HashMap::new(), + external_proxy: None, + external_proxy_bypass: Vec::new(), }, env_credentials: SecretsConfig { mappings: { diff --git a/crates/nono-cli/src/setup.rs b/crates/nono-cli/src/setup.rs index 9216b3bc5..53945c72b 100644 --- a/crates/nono-cli/src/setup.rs +++ b/crates/nono-cli/src/setup.rs @@ -218,26 +218,25 @@ impl SetupRunner { println!(" * Landlock enabled in LSM list"); - let abi = probe_landlock_abi()?; + // Use the library's detect_abi() instead of local probing + let detected = nono::Sandbox::detect_abi() + .map_err(|e| NonoError::Setup(format!("Failed to detect Landlock ABI: {}", e)))?; - println!(" * Landlock ABI: {:?}", abi); + println!(" * {}", detected); println!(" * Available features:"); - for feature in landlock_feature_lines(abi) { + for feature in detected.feature_names() { println!(" - {}", feature); } - // Verify full ruleset creation for the detected ABI with hard requirements. - probe_landlock_abi_candidate(abi).map_err(|e| { - NonoError::Setup(format!( - "Failed to create Landlock ruleset for detected ABI {:?}: {}", - abi, e - )) - })?; println!(" * Filesystem ruleset creation verified"); - if verify_landlock_network_rule_support(abi)? { - println!(" * TCP network rule support verified"); + if detected.has_network() { + if verify_landlock_network_rule_support(detected.abi)? { + println!(" * TCP network rule support verified"); + } else { + println!(" * TCP network filtering: probe failed despite ABI support"); + } } else { println!(" * TCP network filtering: not supported by this ABI"); } @@ -425,83 +424,8 @@ impl SetupRunner { } } -#[cfg(target_os = "linux")] -fn landlock_abi_probe_order() -> [landlock::ABI; 6] { - [ - landlock::ABI::V6, - landlock::ABI::V5, - landlock::ABI::V4, - landlock::ABI::V3, - landlock::ABI::V2, - landlock::ABI::V1, - ] -} - -#[cfg(target_os = "linux")] -fn select_highest_supported_landlock_abi(mut is_supported: F) -> Option -where - F: FnMut(landlock::ABI) -> bool, -{ - landlock_abi_probe_order() - .into_iter() - .find(|&abi| is_supported(abi)) -} - -#[cfg(target_os = "linux")] -fn probe_landlock_abi() -> Result { - let mut last_error = None; - let detected = - select_highest_supported_landlock_abi(|abi| match probe_landlock_abi_candidate(abi) { - Ok(()) => true, - Err(err) => { - last_error = Some(format!("ABI {:?}: {}", abi, err)); - false - } - }); - - detected.ok_or_else(|| { - NonoError::Setup(format!( - "Failed to probe Landlock ABI with hard requirements{}", - last_error - .as_ref() - .map(|e| format!(" ({})", e)) - .unwrap_or_default() - )) - }) -} - -#[cfg(target_os = "linux")] -fn probe_landlock_abi_candidate(abi: landlock::ABI) -> std::result::Result<(), String> { - use landlock::{ - Access, AccessFs, AccessNet, CompatLevel, Compatible, Ruleset, RulesetAttr, Scope, - }; - - let mut ruleset = Ruleset::default().set_compatibility(CompatLevel::HardRequirement); - - ruleset = ruleset - .handle_access(AccessFs::from_all(abi)) - .map_err(|e| format!("filesystem access probe failed: {}", e))?; - - let handled_net = AccessNet::from_all(abi); - if !handled_net.is_empty() { - ruleset = ruleset - .handle_access(handled_net) - .map_err(|e| format!("network access probe failed: {}", e))?; - } - - let scopes = Scope::from_all(abi); - if !scopes.is_empty() { - ruleset = ruleset - .scope(scopes) - .map_err(|e| format!("scope probe failed: {}", e))?; - } - - ruleset - .create() - .map_err(|e| format!("ruleset creation probe failed: {}", e))?; - - Ok(()) -} +// ABI probing is now handled by the library's detect_abi(). +// Only network rule verification remains here as it tests actual rule addition. #[cfg(target_os = "linux")] fn verify_landlock_network_rule_support(abi: landlock::ABI) -> Result { @@ -532,31 +456,7 @@ fn verify_landlock_network_rule_support(abi: landlock::ABI) -> Result { Ok(true) } -#[cfg(target_os = "linux")] -fn landlock_feature_lines(abi: landlock::ABI) -> Vec<&'static str> { - use landlock::{Access, AccessFs, AccessNet, Scope}; - - let mut features = vec!["Basic filesystem access control"]; - let fs_access = AccessFs::from_all(abi); - - if fs_access.contains(AccessFs::Refer) { - features.push("File rename across directories"); - } - if fs_access.contains(AccessFs::Truncate) { - features.push("File truncation"); - } - if fs_access.contains(AccessFs::IoctlDev) { - features.push("Device ioctl filtering"); - } - if !AccessNet::from_all(abi).is_empty() { - features.push("TCP network filtering"); - } - if !Scope::from_all(abi).is_empty() { - features.push("Process scoping (signals and abstract UNIX sockets)"); - } - - features -} +// Feature lines are now provided by DetectedAbi::feature_names() in the library. // Profile templates const EXAMPLE_AGENT_PROFILE: &str = r#"{ @@ -661,25 +561,19 @@ mod tests { #[cfg(target_os = "linux")] #[test] - fn test_select_highest_supported_landlock_abi_prefers_highest() { - let detected = select_highest_supported_landlock_abi(|abi| { - matches!(abi, landlock::ABI::V1 | landlock::ABI::V4) - }); - - assert_eq!(detected, Some(landlock::ABI::V4)); - } - - #[cfg(target_os = "linux")] - #[test] - fn test_select_highest_supported_landlock_abi_none() { - let detected = select_highest_supported_landlock_abi(|_| false); - assert_eq!(detected, None); + fn test_library_detect_abi_returns_result() { + // Verify the library detection works (or returns an error without panicking) + let _ = nono::Sandbox::detect_abi(); } #[cfg(target_os = "linux")] #[test] - fn test_landlock_feature_lines_include_tcp_for_v4_plus() { - let features = landlock_feature_lines(landlock::ABI::V4); - assert!(features.contains(&"TCP network filtering")); + fn test_detected_abi_has_network_for_v4_plus() { + let detected = nono::DetectedAbi::new(landlock::ABI::V4); + assert!(detected.has_network()); + assert!(detected + .feature_names() + .iter() + .any(|n| n.starts_with("TCP network filtering"))); } } diff --git a/crates/nono-cli/tests/env_vars.rs b/crates/nono-cli/tests/env_vars.rs index ce91a039a..c81501102 100644 --- a/crates/nono-cli/tests/env_vars.rs +++ b/crates/nono-cli/tests/env_vars.rs @@ -115,6 +115,109 @@ fn cli_flag_overrides_env_var() { ); } +#[test] +fn env_nono_external_proxy() { + let output = nono_bin() + .env("NONO_EXTERNAL_PROXY", "squid.corp:3128") + .args(["run", "--allow", "/tmp", "--dry-run", "echo"]) + .output() + .expect("failed to run nono"); + + assert!( + output.status.success(), + "NONO_EXTERNAL_PROXY should be accepted, stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); +} + +#[test] +fn env_nono_external_proxy_bypass_comma_separated() { + let output = nono_bin() + .env("NONO_EXTERNAL_PROXY", "squid.corp:3128") + .env("NONO_EXTERNAL_PROXY_BYPASS", "internal.corp,*.private.net") + .args(["run", "--allow", "/tmp", "--dry-run", "echo"]) + .output() + .expect("failed to run nono"); + + assert!( + output.status.success(), + "NONO_EXTERNAL_PROXY_BYPASS should be accepted, stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); +} + +#[test] +fn env_nono_external_proxy_bypass_requires_external_proxy() { + // NONO_EXTERNAL_PROXY_BYPASS without NONO_EXTERNAL_PROXY should fail + let output = nono_bin() + .env("NONO_EXTERNAL_PROXY_BYPASS", "internal.corp") + .args(["run", "--allow", "/tmp", "--dry-run", "echo"]) + .output() + .expect("failed to run nono"); + + assert!( + !output.status.success(), + "NONO_EXTERNAL_PROXY_BYPASS without NONO_EXTERNAL_PROXY should fail" + ); +} + +#[test] +fn env_net_allow_conflicts_with_external_proxy() { + // NONO_NET_ALLOW + NONO_EXTERNAL_PROXY should conflict at the clap level. + let output = nono_bin() + .env("NONO_EXTERNAL_PROXY", "squid.corp:3128") + .env("NONO_NET_ALLOW", "true") + .args(["run", "--allow", "/tmp", "--dry-run", "echo"]) + .output() + .expect("failed to run nono"); + + assert!( + !output.status.success(), + "NONO_NET_ALLOW + NONO_EXTERNAL_PROXY should conflict" + ); +} + +#[test] +fn net_allow_overrides_profile_external_proxy() { + // A profile with external_proxy should be overridden by --net-allow, + // resulting in unrestricted network (no proxy mode activation). + let dir = tempfile::tempdir().expect("tmpdir"); + let profile_path = dir.path().join("ext-proxy-profile.json"); + std::fs::write( + &profile_path, + r#"{ + "meta": { "name": "ext-proxy-test" }, + "network": { "external_proxy": "squid.corp:3128" } + }"#, + ) + .expect("write profile"); + + let output = nono_bin() + .args([ + "run", + "--profile", + profile_path.to_str().expect("valid utf8"), + "--net-allow", + "--allow", + "/tmp", + "--dry-run", + "echo", + ]) + .output() + .expect("failed to run nono"); + + let text = combined_output(&output); + assert!( + output.status.success(), + "--net-allow should override profile external_proxy, stderr: {text}" + ); + // Should show "allowed" network, not proxy mode + assert!( + text.contains("allowed"), + "expected unrestricted network in dry-run output, got:\n{text}" + ); +} + #[test] fn env_conflict_net_allow_and_net_block() { let output = nono_bin() diff --git a/crates/nono-proxy/src/config.rs b/crates/nono-proxy/src/config.rs index 6003ae4da..cf12ca504 100644 --- a/crates/nono-proxy/src/config.rs +++ b/crates/nono-proxy/src/config.rs @@ -142,6 +142,12 @@ pub struct ExternalProxyConfig { /// Optional authentication for the external proxy. pub auth: Option, + + /// Hosts to bypass the external proxy and route directly. + /// Supports exact hostnames and `*.` wildcard suffixes (case-insensitive). + /// Empty = all traffic goes through the external proxy. + #[serde(default)] + pub bypass_hosts: Vec, } /// Authentication for an external proxy. @@ -184,4 +190,30 @@ mod tests { let deserialized: ProxyConfig = serde_json::from_str(&json).unwrap(); assert_eq!(deserialized.allowed_hosts, vec!["api.openai.com"]); } + + #[test] + fn test_external_proxy_config_with_bypass_hosts() { + let config = ProxyConfig { + external_proxy: Some(ExternalProxyConfig { + address: "squid.corp:3128".to_string(), + auth: None, + bypass_hosts: vec!["internal.corp".to_string(), "*.private.net".to_string()], + }), + ..Default::default() + }; + let json = serde_json::to_string(&config).unwrap(); + let deserialized: ProxyConfig = serde_json::from_str(&json).unwrap(); + let ext = deserialized.external_proxy.unwrap(); + assert_eq!(ext.address, "squid.corp:3128"); + assert_eq!(ext.bypass_hosts.len(), 2); + assert_eq!(ext.bypass_hosts[0], "internal.corp"); + assert_eq!(ext.bypass_hosts[1], "*.private.net"); + } + + #[test] + fn test_external_proxy_config_bypass_hosts_default_empty() { + let json = r#"{"address": "proxy:3128", "auth": null}"#; + let ext: ExternalProxyConfig = serde_json::from_str(json).unwrap(); + assert!(ext.bypass_hosts.is_empty()); + } } diff --git a/crates/nono-proxy/src/external.rs b/crates/nono-proxy/src/external.rs index 547f6c6ac..444ee02cf 100644 --- a/crates/nono-proxy/src/external.rs +++ b/crates/nono-proxy/src/external.rs @@ -14,6 +14,76 @@ use tokio::net::TcpStream; use tracing::debug; use zeroize::Zeroizing; +/// Matcher for hosts that should bypass the external proxy. +/// +/// Supports exact hostname match and `*.` wildcard suffix match, +/// both case-insensitive. Uses the same `*`-prefix parsing pattern +/// as `HostFilter::new()`. +#[derive(Debug, Clone)] +pub struct BypassMatcher { + /// Exact hostnames (lowercased) + exact: Vec, + /// Wildcard suffixes (e.g., ".internal.corp", lowercased) + suffixes: Vec, +} + +impl BypassMatcher { + /// Create a new bypass matcher from a list of host patterns. + /// + /// Entries starting with `*.` are wildcard patterns matching any subdomain. + /// All other entries are exact matches. Matching is case-insensitive. + /// + /// Only the `*.domain` form is accepted for wildcards. Bare `*` and + /// patterns like `*corp` (without the dot) are treated as exact hostnames + /// to prevent accidental over-broad matching. + #[must_use] + pub fn new(hosts: &[String]) -> Self { + let mut exact = Vec::new(); + let mut suffixes = Vec::new(); + + for host in hosts { + let lower = host.to_lowercase(); + if let Some(suffix) = lower.strip_prefix("*.") { + // *.example.com -> .example.com + if !suffix.is_empty() { + suffixes.push(format!(".{suffix}")); + } + // Bare "*." with nothing after is silently ignored (no valid domain) + } else { + exact.push(lower); + } + } + + Self { exact, suffixes } + } + + /// Check whether a host should bypass the external proxy. + #[must_use] + pub fn matches(&self, host: &str) -> bool { + let lower = host.to_lowercase(); + + // Exact match + if self.exact.contains(&lower) { + return true; + } + + // Wildcard suffix match + for suffix in &self.suffixes { + if lower.ends_with(suffix.as_str()) && lower.len() > suffix.len() { + return true; + } + } + + false + } + + /// Whether any bypass hosts are configured. + #[must_use] + pub fn is_empty(&self) -> bool { + self.exact.is_empty() && self.suffixes.is_empty() + } +} + /// Handle a CONNECT request by chaining it to an external proxy. /// /// 1. Validate session token @@ -236,4 +306,86 @@ mod tests { fn test_parse_status_code_malformed() { assert!(parse_status_code("garbage").is_err()); } + + #[test] + fn test_bypass_matcher_exact() { + let matcher = BypassMatcher::new(&["internal.corp".to_string()]); + assert!(matcher.matches("internal.corp")); + assert!(!matcher.matches("other.corp")); + } + + #[test] + fn test_bypass_matcher_case_insensitive() { + let matcher = BypassMatcher::new(&["Internal.Corp".to_string()]); + assert!(matcher.matches("internal.corp")); + assert!(matcher.matches("INTERNAL.CORP")); + } + + #[test] + fn test_bypass_matcher_wildcard() { + let matcher = BypassMatcher::new(&["*.internal.corp".to_string()]); + assert!(matcher.matches("app.internal.corp")); + assert!(matcher.matches("deep.sub.internal.corp")); + // Bare domain should NOT match wildcard + assert!(!matcher.matches("internal.corp")); + } + + #[test] + fn test_bypass_matcher_wildcard_case_insensitive() { + let matcher = BypassMatcher::new(&["*.Internal.Corp".to_string()]); + assert!(matcher.matches("APP.INTERNAL.CORP")); + } + + #[test] + fn test_bypass_matcher_no_match() { + let matcher = + BypassMatcher::new(&["internal.corp".to_string(), "*.private.net".to_string()]); + assert!(!matcher.matches("api.openai.com")); + assert!(!matcher.matches("evil.com")); + } + + #[test] + fn test_bypass_matcher_empty() { + let matcher = BypassMatcher::new(&[]); + assert!(matcher.is_empty()); + assert!(!matcher.matches("anything.com")); + } + + #[test] + fn test_bypass_matcher_mixed() { + let matcher = + BypassMatcher::new(&["exact.host.com".to_string(), "*.wildcard.com".to_string()]); + assert!(matcher.matches("exact.host.com")); + assert!(matcher.matches("sub.wildcard.com")); + assert!(!matcher.matches("wildcard.com")); + assert!(!matcher.matches("other.com")); + } + + #[test] + fn test_bypass_matcher_bare_star_is_not_wildcard() { + // Bare "*" must NOT bypass everything — it should be treated as + // a literal (non-matching) hostname, not a universal wildcard. + let matcher = BypassMatcher::new(&["*".to_string()]); + assert!(!matcher.matches("anything.com")); + assert!(!matcher.matches("internal.corp")); + } + + #[test] + fn test_bypass_matcher_star_without_dot_is_literal() { + // "*corp" (no dot) must NOT be treated as a wildcard suffix. + // Only "*.corp" is a valid wildcard pattern. + let matcher = BypassMatcher::new(&["*corp".to_string()]); + assert!(!matcher.matches("internal.corp")); + assert!(!matcher.matches("subcorp")); + // It's treated as the literal hostname "*corp" + assert!(matcher.matches("*corp")); + } + + #[test] + fn test_bypass_matcher_star_dot_only_is_ignored() { + // "*." with nothing after is not a valid domain pattern. + let matcher = BypassMatcher::new(&["*.".to_string()]); + assert!(matcher.is_empty()); + assert!(!matcher.matches("anything.com")); + } } diff --git a/crates/nono-proxy/src/server.rs b/crates/nono-proxy/src/server.rs index fd2ee1c88..1145750a2 100644 --- a/crates/nono-proxy/src/server.rs +++ b/crates/nono-proxy/src/server.rs @@ -141,6 +141,9 @@ struct ProxyState { active_connections: AtomicUsize, /// Shared network audit log for this proxy session. audit_log: audit::SharedAuditLog, + /// Matcher for hosts that bypass the external proxy and route direct. + /// Built once at startup from `ExternalProxyConfig.bypass_hosts`. + bypass_matcher: external::BypassMatcher, } /// Start the proxy server. @@ -200,6 +203,13 @@ pub async fn start(config: ProxyConfig) -> Result { .with_no_client_auth(); let tls_connector = tokio_rustls::TlsConnector::from(Arc::new(tls_config)); + // Build bypass matcher from external proxy config (once, not per-request) + let bypass_matcher = config + .external_proxy + .as_ref() + .map(|ext| external::BypassMatcher::new(&ext.bypass_hosts)) + .unwrap_or_else(|| external::BypassMatcher::new(&[])); + // Shutdown channel let (shutdown_tx, shutdown_rx) = watch::channel(false); let audit_log = audit::new_audit_log(); @@ -212,6 +222,7 @@ pub async fn start(config: ProxyConfig) -> Result { tls_connector, active_connections: AtomicUsize::new(0), audit_log: Arc::clone(&audit_log), + bypass_matcher, }); // Spawn accept loop as a task within the current runtime. @@ -321,8 +332,34 @@ async fn handle_connection(mut stream: tokio::net::TcpStream, state: &ProxyState // Dispatch by method if first_line.starts_with("CONNECT ") { - // Check if external proxy is configured - if let Some(ref ext_config) = state.config.external_proxy { + // Check if external proxy is configured and host is not bypassed + let use_external = if let Some(ref ext_config) = state.config.external_proxy { + if state.bypass_matcher.is_empty() { + Some(ext_config) + } else { + // Parse host from CONNECT line to check bypass + let host = first_line + .split_whitespace() + .nth(1) + .and_then(|authority| { + authority + .rsplit_once(':') + .map(|(h, _)| h) + .or(Some(authority)) + }) + .unwrap_or(""); + if state.bypass_matcher.matches(host) { + debug!("Bypassing external proxy for {}", host); + None + } else { + Some(ext_config) + } + } + } else { + None + }; + + if let Some(ext_config) = use_external { external::handle_external_proxy( first_line, &mut stream, @@ -333,6 +370,21 @@ async fn handle_connection(mut stream: tokio::net::TcpStream, state: &ProxyState Some(&state.audit_log), ) .await + } else if state.config.external_proxy.is_some() { + // Bypass route: enforce strict session token validation before + // routing direct. Without this, bypassed hosts would inherit + // connect::handle_connect()'s lenient auth (which tolerates + // missing Proxy-Authorization for Node.js undici compat). + token::validate_proxy_auth(&header_bytes, &state.session_token)?; + connect::handle_connect( + first_line, + &mut stream, + &state.filter, + &state.session_token, + &header_bytes, + Some(&state.audit_log), + ) + .await } else { connect::handle_connect( first_line, diff --git a/crates/nono/src/lib.rs b/crates/nono/src/lib.rs index 633227d10..5c115031c 100644 --- a/crates/nono/src/lib.rs +++ b/crates/nono/src/lib.rs @@ -72,6 +72,8 @@ pub use keystore::{ validate_destination_env_var, validate_env_uri, validate_op_uri, LoadedSecret, }; pub use net_filter::{FilterResult, HostFilter}; +#[cfg(target_os = "linux")] +pub use sandbox::{detect_abi, DetectedAbi}; pub use sandbox::{Sandbox, SupportInfo}; pub use state::SandboxState; pub use supervisor::{ diff --git a/crates/nono/src/sandbox/linux.rs b/crates/nono/src/sandbox/linux.rs index bdb3c5bec..7d43c5aff 100644 --- a/crates/nono/src/sandbox/linux.rs +++ b/crates/nono/src/sandbox/linux.rs @@ -1,39 +1,193 @@ //! Linux sandbox implementation using Landlock LSM -use crate::capability::{AccessMode, CapabilitySet, FsCapability, NetworkMode}; +use crate::capability::{AccessMode, CapabilitySet, NetworkMode}; use crate::error::{NonoError, Result}; use crate::sandbox::SupportInfo; use landlock::{ Access, AccessFs, AccessNet, BitFlags, CompatLevel, Compatible, NetPort, PathBeneath, PathFd, - Ruleset, RulesetAttr, RulesetCreatedAttr, ABI, + Ruleset, RulesetAttr, RulesetCreatedAttr, Scope, ABI, }; use std::path::Path; -use tracing::{debug, info}; +use tracing::{debug, info, warn}; -/// The target ABI version we support (highest we know about) -const TARGET_ABI: ABI = ABI::V5; +/// Detected Landlock ABI version with feature query methods. +/// +/// Wraps the `landlock::ABI` enum and provides methods to query which +/// features are available at the detected ABI level. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct DetectedAbi { + /// The detected ABI version + pub abi: ABI, +} + +impl DetectedAbi { + /// Create a new `DetectedAbi` from a raw `landlock::ABI`. + #[must_use] + pub fn new(abi: ABI) -> Self { + Self { abi } + } + + /// Whether file rename across directories is supported (V2+). + #[must_use] + pub fn has_refer(&self) -> bool { + AccessFs::from_all(self.abi).contains(AccessFs::Refer) + } + + /// Whether file truncation control is supported (V3+). + #[must_use] + pub fn has_truncate(&self) -> bool { + AccessFs::from_all(self.abi).contains(AccessFs::Truncate) + } + + /// Whether TCP network filtering is supported (V4+). + #[must_use] + pub fn has_network(&self) -> bool { + !AccessNet::from_all(self.abi).is_empty() + } + + /// Whether device ioctl filtering is supported (V5+). + #[must_use] + pub fn has_ioctl_dev(&self) -> bool { + AccessFs::from_all(self.abi).contains(AccessFs::IoctlDev) + } + + /// Whether process scoping (signals and abstract UNIX sockets) is supported (V6+). + #[must_use] + pub fn has_scoping(&self) -> bool { + !Scope::from_all(self.abi).is_empty() + } + + /// Return a human-readable version string (e.g., "V4"). + #[must_use] + pub fn version_string(&self) -> &'static str { + match self.abi { + ABI::V1 => "V1", + ABI::V2 => "V2", + ABI::V3 => "V3", + ABI::V4 => "V4", + ABI::V5 => "V5", + ABI::V6 => "V6", + _ => "unknown", + } + } + + /// Return a list of available feature names at this ABI level. + /// + /// Each feature includes the specific Landlock flags in parentheses + /// for consistency and debuggability. + #[must_use] + pub fn feature_names(&self) -> Vec { + let mut features = vec!["Basic filesystem access control".to_string()]; + if self.has_refer() { + features.push("File rename across directories (Refer)".to_string()); + } + if self.has_truncate() { + features.push("File truncation (Truncate)".to_string()); + } + if self.has_network() { + features.push(format!( + "TCP network filtering ({:?})", + AccessNet::from_all(self.abi) + )); + } + if self.has_ioctl_dev() { + features.push("Device ioctl filtering (IoctlDev)".to_string()); + } + if self.has_scoping() { + features.push(format!("Process scoping ({:?})", Scope::from_all(self.abi))); + } + features + } +} + +impl std::fmt::Display for DetectedAbi { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "Landlock {}", self.version_string()) + } +} + +/// ABI probe order: highest to lowest. +const ABI_PROBE_ORDER: [ABI; 6] = [ABI::V6, ABI::V5, ABI::V4, ABI::V3, ABI::V2, ABI::V1]; + +/// Detect the highest Landlock ABI supported by the running kernel. +/// +/// Probes from V6 down to V1 using `HardRequirement` compatibility mode. +/// Returns the highest ABI for which a full ruleset can be created. +/// +/// # Errors +/// +/// Returns an error if no ABI version is supported (Landlock not available). +pub fn detect_abi() -> Result { + let mut last_error = None; + + for &abi in &ABI_PROBE_ORDER { + match probe_abi_candidate(abi) { + Ok(()) => return Ok(DetectedAbi::new(abi)), + Err(err) => { + debug!("ABI {:?} probe failed: {}", abi, err); + last_error = Some(format!("ABI {:?}: {}", abi, err)); + } + } + } + + Err(NonoError::SandboxInit(format!( + "No supported Landlock ABI detected{}", + last_error + .as_ref() + .map(|e| format!(" (last error: {})", e)) + .unwrap_or_default() + ))) +} + +/// Probe whether a specific ABI version is supported using `HardRequirement`. +fn probe_abi_candidate(abi: ABI) -> std::result::Result<(), String> { + let mut ruleset = Ruleset::default().set_compatibility(CompatLevel::HardRequirement); + + ruleset = ruleset + .handle_access(AccessFs::from_all(abi)) + .map_err(|e| format!("filesystem access probe failed: {}", e))?; + + let handled_net = AccessNet::from_all(abi); + if !handled_net.is_empty() { + ruleset = ruleset + .handle_access(handled_net) + .map_err(|e| format!("network access probe failed: {}", e))?; + } + + let scopes = Scope::from_all(abi); + if !scopes.is_empty() { + ruleset = ruleset + .scope(scopes) + .map_err(|e| format!("scope probe failed: {}", e))?; + } + + ruleset + .create() + .map_err(|e| format!("ruleset creation probe failed: {}", e))?; + + Ok(()) +} /// Check if Landlock is supported on this system pub fn is_supported() -> bool { - // Try to create a minimal ruleset to check if Landlock is available - Ruleset::default() - .handle_access(AccessFs::from_all(TARGET_ABI)) - .and_then(|r| r.create()) - .is_ok() + detect_abi().is_ok() } /// Get information about Landlock support pub fn support_info() -> SupportInfo { - // Try to create a ruleset and check the status - match Ruleset::default() - .handle_access(AccessFs::from_all(TARGET_ABI)) - .and_then(|r| r.create()) - { - Ok(_) => SupportInfo { - is_supported: true, - platform: "linux", - details: format!("Landlock available (targeting ABI v{:?})", TARGET_ABI), - }, + match detect_abi() { + Ok(detected) => { + let features = detected.feature_names(); + SupportInfo { + is_supported: true, + platform: "linux", + details: format!( + "Landlock available ({}, features: {})", + detected, + features.join(", ") + ), + } + } Err(_) => SupportInfo { is_supported: false, platform: "linux", @@ -43,27 +197,35 @@ pub fn support_info() -> SupportInfo { } } -/// Convert AccessMode to Landlock AccessFs flags +/// Result of converting AccessMode to Landlock flags, including any dropped flags. +struct LandlockAccess { + /// Flags that will be applied (supported by this ABI). + effective: BitFlags, + /// Flags that were requested but not supported by this ABI. + dropped: BitFlags, +} + +/// Convert AccessMode to Landlock AccessFs flags, intersected with ABI support. +/// +/// Returns both the effective flags and any dropped flags so the caller can +/// emit warnings with path context. This prevents `BestEffort` from hiding +/// degradation. /// /// RemoveFile, RemoveDir, Truncate, and Refer are included to support atomic -/// writes (write to .tmp → rename to target), which is the standard pattern +/// writes (write to .tmp -> rename to target), which is the standard pattern /// used by most applications for safe config/build artifact updates. -/// Landlock requires `LANDLOCK_ACCESS_FS_REMOVE_DIR` on the source directory -/// for `rename()` operations involving directories (e.g., cargo build -/// incremental artifacts), so excluding it would cause spurious EACCES errors. -fn access_to_landlock(access: AccessMode, abi: ABI) -> BitFlags { - match access { +/// +/// IoctlDev is NOT included here — it is added selectively in `apply_with_abi()` +/// only for paths that are actual device files (char/block devices), detected +/// via `stat()` at rule-addition time. This avoids granting device ioctl access +/// to non-device paths. +fn access_to_landlock(access: AccessMode, abi: ABI) -> LandlockAccess { + let available = AccessFs::from_all(abi); + + let desired = match access { AccessMode::Read => AccessFs::ReadFile | AccessFs::ReadDir | AccessFs::Execute, AccessMode::Write => { - // Write access includes all operations needed for normal file manipulation: - // - WriteFile: modify file contents - // - MakeReg/MakeDir/etc: create new files/directories - // - RemoveFile: delete files (required for rename() in atomic writes) - // - RemoveDir: delete directories (required for rename() of directories, - // e.g., cargo build incremental artifacts) - // - Refer: rename/hard link operations (required for atomic writes) - // - Truncate: change file size (common write operation, ABI v3+) - let mut access = AccessFs::WriteFile + AccessFs::WriteFile | AccessFs::MakeChar | AccessFs::MakeDir | AccessFs::MakeReg @@ -73,63 +235,89 @@ fn access_to_landlock(access: AccessMode, abi: ABI) -> BitFlags { | AccessFs::MakeSym | AccessFs::RemoveFile | AccessFs::RemoveDir - | AccessFs::Refer; - - if AccessFs::from_all(abi).contains(AccessFs::Truncate) { - access |= AccessFs::Truncate; - } - - access + | AccessFs::Refer + | AccessFs::Truncate } AccessMode::ReadWrite => { - access_to_landlock(AccessMode::Read, abi) | access_to_landlock(AccessMode::Write, abi) + let read = access_to_landlock(AccessMode::Read, abi); + let write = access_to_landlock(AccessMode::Write, abi); + return LandlockAccess { + effective: read.effective | write.effective, + dropped: read.dropped | write.dropped, + }; } - } -} - -/// Landlock ABI v5+ restricts device ioctls when `IoctlDev` is handled. -/// -/// TTY-backed TUIs rely on ioctl operations like `TCSETS` to enter raw mode and -/// resize correctly. Limit the extra grant to terminal device capabilities so we -/// do not widen ioctl access for arbitrary read-write paths. -fn access_to_landlock_for_capability(cap: &FsCapability, abi: ABI) -> BitFlags { - let mut access = access_to_landlock(cap.access, abi); + }; - if should_grant_tty_ioctl(cap, abi) { - access |= AccessFs::IoctlDev; + LandlockAccess { + effective: desired & available, + dropped: desired & !available, } - - access } -fn should_grant_tty_ioctl(cap: &FsCapability, abi: ABI) -> bool { - AccessFs::from_all(abi).contains(AccessFs::IoctlDev) - && matches!(cap.access, AccessMode::Write | AccessMode::ReadWrite) - && is_tty_device_path(&cap.resolved) +/// Check if a path is a character or block device file. +/// +/// Used to selectively grant `IoctlDev` only for actual device files +/// (e.g., `/dev/tty`, `/dev/null`), not for regular files or directories. +fn is_device_path(path: &Path) -> bool { + use std::os::unix::fs::FileTypeExt; + std::fs::metadata(path) + .map(|m| { + let ft = m.file_type(); + ft.is_char_device() || ft.is_block_device() + }) + .unwrap_or(false) } -fn is_tty_device_path(path: &Path) -> bool { - path == Path::new("/dev/tty") || path.starts_with(Path::new("/dev/pts")) +/// Check if a path is a directory that contains device files (e.g., `/dev/pts`). +/// +/// For directories under `/dev`, we grant `IoctlDev` because Landlock's +/// `PathBeneath` applies to all files within the subtree, and those files +/// are device nodes that need ioctl access for terminal operations. +fn is_device_directory(path: &Path) -> bool { + // Only consider directories directly under /dev as device directories. + // This avoids granting IoctlDev to arbitrary directories. + path.starts_with("/dev") && path.is_dir() } -/// Apply Landlock sandbox with the given capabilities +/// Apply Landlock sandbox with the given capabilities, auto-detecting ABI. /// /// This is a pure primitive - it applies ONLY the capabilities provided. /// The caller is responsible for including all necessary paths (including /// system paths like /usr, /lib, /bin if executables need to run). pub fn apply(caps: &CapabilitySet) -> Result<()> { - info!("Using Landlock ABI {:?}", TARGET_ABI); + let detected = detect_abi()?; + apply_with_abi(caps, &detected) +} + +/// Apply Landlock sandbox with the given capabilities and a pre-detected ABI. +/// +/// This variant avoids re-probing the kernel ABI when the caller has already +/// detected it (e.g., the CLI probes once at startup). +/// +/// # Security +/// +/// The provided ABI is validated against the kernel: the ruleset is created +/// with `HardRequirement` for filesystem access rights. If the caller passes +/// an ABI higher than the kernel supports, `handle_access()` will fail rather +/// than silently dropping flags. +pub fn apply_with_abi(caps: &CapabilitySet, abi: &DetectedAbi) -> Result<()> { + let target_abi = abi.abi; + info!("Using Landlock ABI {:?}", target_abi); // Determine which access rights to handle based on ABI - let handled_fs = AccessFs::from_all(TARGET_ABI); + let handled_fs = AccessFs::from_all(target_abi); debug!("Handling filesystem access: {:?}", handled_fs); - // Create the ruleset (Ruleset::default() auto-probes kernel support) - // Start with filesystem access + // Create the ruleset with HardRequirement for filesystem access. + // This ensures that if the caller passes a stale or forged ABI higher + // than the kernel supports, handle_access() fails instead of silently + // dropping flags via BestEffort. let ruleset_builder = Ruleset::default() + .set_compatibility(CompatLevel::HardRequirement) .handle_access(handled_fs) - .map_err(|e| NonoError::SandboxInit(format!("Failed to handle fs access: {}", e)))?; + .map_err(|e| NonoError::SandboxInit(format!("Failed to handle fs access: {}", e)))? + .set_compatibility(CompatLevel::BestEffort); // Determine if we need network handling (any mode besides AllowAll) let needs_network_handling = !matches!(caps.network_mode(), NetworkMode::AllowAll) @@ -137,7 +325,7 @@ pub fn apply(caps: &CapabilitySet) -> Result<()> { || !caps.tcp_bind_ports().is_empty(); let ruleset_builder = if needs_network_handling { - let handled_net = AccessNet::from_all(TARGET_ABI); + let handled_net = AccessNet::from_all(target_abi); if !handled_net.is_empty() { debug!("Handling network access: {:?}", handled_net); ruleset_builder @@ -238,8 +426,37 @@ pub fn apply(caps: &CapabilitySet) -> Result<()> { // 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 + let ioctl_dev_available = AccessFs::from_all(target_abi).contains(AccessFs::IoctlDev); + for cap in caps.fs_capabilities() { - let access = access_to_landlock_for_capability(cap, TARGET_ABI); + let result = access_to_landlock(cap.access, target_abi); + let mut access = result.effective; + + if !result.dropped.is_empty() { + warn!( + "Landlock ABI {:?} does not support {:?} for path {} (requested for {:?})", + target_abi, + result.dropped, + cap.resolved.display(), + cap.access + ); + } + + // Grant IoctlDev only for device files and device directories (under /dev). + // Terminal ioctls (TCSETS, TIOCGWINSZ) require this flag on V5+ kernels. + // Without it, TUI programs fail with EACCES on /dev/tty and /dev/pts. + // We restrict this to actual devices to avoid granting ioctl access to + // regular files and non-device directories. + if ioctl_dev_available + && matches!(cap.access, AccessMode::Write | AccessMode::ReadWrite) + && (is_device_path(&cap.resolved) || is_device_directory(&cap.resolved)) + { + access |= AccessFs::IoctlDev; + debug!( + "Adding IoctlDev for device path: {}", + cap.resolved.display() + ); + } debug!( "Adding rule: {} with access {:?}", @@ -945,8 +1162,6 @@ pub fn deny_notif(notify_fd: std::os::fd::RawFd, notif_id: u64) -> Result<()> { #[cfg(test)] mod tests { use super::*; - use crate::capability::CapabilitySource; - use std::path::PathBuf; #[test] fn test_is_supported() { @@ -962,93 +1177,179 @@ mod tests { } #[test] - fn test_access_conversion() { + fn test_access_conversion_v3() { let abi = ABI::V3; let read = access_to_landlock(AccessMode::Read, abi); - assert!(read.contains(AccessFs::ReadFile)); - assert!(!read.contains(AccessFs::WriteFile)); + assert!(read.effective.contains(AccessFs::ReadFile)); + assert!(!read.effective.contains(AccessFs::WriteFile)); + assert!(read.dropped.is_empty()); let write = access_to_landlock(AccessMode::Write, abi); - assert!(write.contains(AccessFs::WriteFile)); - assert!(!write.contains(AccessFs::ReadFile)); - // Verify atomic write operations ARE included (RemoveFile, RemoveDir, Refer, Truncate) - assert!(write.contains(AccessFs::RemoveFile)); - assert!(write.contains(AccessFs::RemoveDir)); - assert!(write.contains(AccessFs::Refer)); - assert!(write.contains(AccessFs::Truncate)); + assert!(write.effective.contains(AccessFs::WriteFile)); + assert!(!write.effective.contains(AccessFs::ReadFile)); + // V3 supports Refer and Truncate but NOT IoctlDev + assert!(write.effective.contains(AccessFs::RemoveFile)); + assert!(write.effective.contains(AccessFs::RemoveDir)); + assert!(write.effective.contains(AccessFs::Refer)); + assert!(write.effective.contains(AccessFs::Truncate)); + assert!(!write.effective.contains(AccessFs::IoctlDev)); + assert!(write.dropped.is_empty()); let rw = access_to_landlock(AccessMode::ReadWrite, abi); - assert!(rw.contains(AccessFs::ReadFile)); - assert!(rw.contains(AccessFs::WriteFile)); - // Verify atomic write operations ARE included in ReadWrite too - assert!(rw.contains(AccessFs::RemoveFile)); - assert!(rw.contains(AccessFs::RemoveDir)); - assert!(rw.contains(AccessFs::Refer)); - assert!(rw.contains(AccessFs::Truncate)); + assert!(rw.effective.contains(AccessFs::ReadFile)); + assert!(rw.effective.contains(AccessFs::WriteFile)); + assert!(rw.effective.contains(AccessFs::RemoveFile)); + assert!(rw.effective.contains(AccessFs::RemoveDir)); + assert!(rw.effective.contains(AccessFs::Refer)); + assert!(rw.effective.contains(AccessFs::Truncate)); + assert!(rw.dropped.is_empty()); } #[test] - fn test_non_tty_paths_do_not_gain_ioctl_dev() { - let cap = FsCapability { - original: PathBuf::from("/tmp"), - resolved: PathBuf::from("/tmp"), - access: AccessMode::ReadWrite, - is_file: false, - source: CapabilitySource::User, - }; + fn test_access_conversion_v1_drops_refer_and_truncate() { + let abi = ABI::V1; - let access = access_to_landlock_for_capability(&cap, TARGET_ABI); + let write = access_to_landlock(AccessMode::Write, abi); + assert!(write.effective.contains(AccessFs::WriteFile)); + // V1 does NOT have Refer, Truncate, or IoctlDev + assert!(!write.effective.contains(AccessFs::Refer)); + assert!(!write.effective.contains(AccessFs::Truncate)); + assert!(!write.effective.contains(AccessFs::IoctlDev)); + // But basic write operations are still present + assert!(write.effective.contains(AccessFs::RemoveFile)); + assert!(write.effective.contains(AccessFs::RemoveDir)); + // Dropped flags should be reported + assert!(write.dropped.contains(AccessFs::Refer)); + assert!(write.dropped.contains(AccessFs::Truncate)); + } - assert!(!access.contains(AccessFs::IoctlDev)); + #[test] + fn test_access_conversion_v2_has_refer_but_not_truncate() { + let abi = ABI::V2; + + let write = access_to_landlock(AccessMode::Write, abi); + assert!(write.effective.contains(AccessFs::WriteFile)); + // V2 added Refer but NOT Truncate or IoctlDev + assert!(write.effective.contains(AccessFs::Refer)); + assert!(!write.effective.contains(AccessFs::Truncate)); + assert!(!write.effective.contains(AccessFs::IoctlDev)); + // Truncate should be in dropped + assert!(write.dropped.contains(AccessFs::Truncate)); + assert!(!write.dropped.contains(AccessFs::Refer)); } #[test] - fn test_tty_paths_gain_ioctl_dev_when_supported() { - let tty = FsCapability { - original: PathBuf::from("/dev/tty"), - resolved: PathBuf::from("/dev/tty"), - access: AccessMode::Write, - is_file: true, - source: CapabilitySource::User, - }; - let pts = FsCapability { - original: PathBuf::from("/dev/pts"), - resolved: PathBuf::from("/dev/pts"), - access: AccessMode::ReadWrite, - is_file: false, - source: CapabilitySource::User, - }; + fn test_access_conversion_v5_excludes_ioctl_dev_from_generic_flags() { + let abi = ABI::V5; + + // IoctlDev is NOT in the generic write flags — it is added selectively + // at rule-addition time only for device paths (char/block devices). + let write = access_to_landlock(AccessMode::Write, abi); + assert!(!write.effective.contains(AccessFs::IoctlDev)); - let tty_access = access_to_landlock_for_capability(&tty, TARGET_ABI); - let pts_access = access_to_landlock_for_capability(&pts, TARGET_ABI); + let rw = access_to_landlock(AccessMode::ReadWrite, abi); + assert!(!rw.effective.contains(AccessFs::IoctlDev)); - assert!(tty_access.contains(AccessFs::IoctlDev)); - assert!(pts_access.contains(AccessFs::IoctlDev)); + let read = access_to_landlock(AccessMode::Read, abi); + assert!(!read.effective.contains(AccessFs::IoctlDev)); } #[test] - fn test_read_only_tty_path_does_not_gain_ioctl_dev() { - let cap = FsCapability { - original: PathBuf::from("/dev/tty"), - resolved: PathBuf::from("/dev/tty"), - access: AccessMode::Read, - is_file: true, - source: CapabilitySource::User, - }; + fn test_is_device_path_dev_null() { + // /dev/null is a character device on all Unix systems + assert!(is_device_path(Path::new("/dev/null"))); + } - let access = access_to_landlock_for_capability(&cap, TARGET_ABI); + #[test] + fn test_is_device_path_regular_file() { + // A regular file should not be detected as a device + assert!(!is_device_path(Path::new("/etc/hosts"))); + } + + #[test] + fn test_is_device_path_nonexistent() { + assert!(!is_device_path(Path::new("/nonexistent/path/12345"))); + } - assert!(!access.contains(AccessFs::IoctlDev)); + #[test] + fn test_is_device_directory_dev_pts() { + // /dev/pts is a directory under /dev + if Path::new("/dev/pts").exists() { + assert!(is_device_directory(Path::new("/dev/pts"))); + } + } + + #[test] + fn test_is_device_directory_not_dev() { + // /tmp is a directory but not under /dev + assert!(!is_device_directory(Path::new("/tmp"))); + } + + #[test] + fn test_detected_abi_feature_methods() { + let v1 = DetectedAbi::new(ABI::V1); + assert!(!v1.has_refer()); + assert!(!v1.has_truncate()); + assert!(!v1.has_network()); + assert!(!v1.has_ioctl_dev()); + assert!(!v1.has_scoping()); + + let v2 = DetectedAbi::new(ABI::V2); + assert!(v2.has_refer()); + assert!(!v2.has_truncate()); + + let v3 = DetectedAbi::new(ABI::V3); + assert!(v3.has_refer()); + assert!(v3.has_truncate()); + assert!(!v3.has_network()); + + let v4 = DetectedAbi::new(ABI::V4); + assert!(v4.has_network()); + assert!(!v4.has_ioctl_dev()); + + let v5 = DetectedAbi::new(ABI::V5); + assert!(v5.has_ioctl_dev()); + assert!(!v5.has_scoping()); + + let v6 = DetectedAbi::new(ABI::V6); + assert!(v6.has_scoping()); + } + + #[test] + fn test_detected_abi_version_string() { + assert_eq!(DetectedAbi::new(ABI::V1).version_string(), "V1"); + assert_eq!(DetectedAbi::new(ABI::V4).version_string(), "V4"); + assert_eq!(DetectedAbi::new(ABI::V6).version_string(), "V6"); + } + + #[test] + fn test_detected_abi_display() { + let d = DetectedAbi::new(ABI::V4); + assert_eq!(format!("{}", d), "Landlock V4"); + } + + #[test] + fn test_detected_abi_feature_names() { + let v1 = DetectedAbi::new(ABI::V1); + let names = v1.feature_names(); + assert_eq!(names.len(), 1); + assert_eq!(names[0], "Basic filesystem access control"); + + let v4 = DetectedAbi::new(ABI::V4); + let names = v4.feature_names(); + assert!(names.iter().any(|n| n.starts_with("TCP network filtering"))); + assert!(names + .iter() + .any(|n| n == "File rename across directories (Refer)")); + assert!(names.iter().any(|n| n == "File truncation (Truncate)")); } #[test] - fn test_tty_device_path_detection() { - assert!(is_tty_device_path(Path::new("/dev/tty"))); - assert!(is_tty_device_path(Path::new("/dev/pts"))); - assert!(is_tty_device_path(Path::new("/dev/pts/3"))); - assert!(!is_tty_device_path(Path::new("/dev/null"))); - assert!(!is_tty_device_path(Path::new("/tmp"))); + fn test_detect_abi_returns_ok_on_supported_system() { + // On a system with Landlock, this should succeed + // On a system without it, it should return Err (not panic) + let _ = detect_abi(); } #[test] diff --git a/crates/nono/src/sandbox/mod.rs b/crates/nono/src/sandbox/mod.rs index d2aec8922..92e16fda8 100644 --- a/crates/nono/src/sandbox/mod.rs +++ b/crates/nono/src/sandbox/mod.rs @@ -18,6 +18,10 @@ mod macos; #[cfg(target_os = "macos")] pub use macos::{extension_consume, extension_issue_file, extension_release}; +// Re-export Linux Landlock ABI detection +#[cfg(target_os = "linux")] +pub use linux::{detect_abi, DetectedAbi}; + // Re-export Linux seccomp-notify primitives for supervisor use #[cfg(target_os = "linux")] pub use linux::{ @@ -62,12 +66,29 @@ pub struct SupportInfo { pub struct Sandbox; impl Sandbox { + /// Detect the Landlock ABI version supported by the running kernel. + /// + /// This is only available on Linux. Returns a `DetectedAbi` that can + /// be passed to `apply_with_abi()` to avoid re-probing. + /// + /// # Errors + /// + /// Returns an error if Landlock is not available. + #[cfg(target_os = "linux")] + #[must_use = "ABI detection result should be checked"] + pub fn detect_abi() -> Result { + linux::detect_abi() + } + /// Apply the sandbox with the given capabilities. /// /// This function applies OS-level restrictions that **cannot be undone**. /// After calling this, the current process (and all children) will /// only be able to access resources granted by the capabilities. /// + /// On Linux, this auto-detects the Landlock ABI. Use `apply_with_abi()` + /// to skip re-detection when the ABI is already known. + /// /// # Errors /// /// Returns an error if: @@ -101,6 +122,20 @@ impl Sandbox { } } + /// Apply the sandbox with a pre-detected Landlock ABI (Linux only). + /// + /// Avoids re-probing the kernel when the caller has already detected + /// the ABI (e.g., probed once at startup). + /// + /// # Errors + /// + /// Returns an error if sandbox initialization fails. + #[cfg(target_os = "linux")] + #[must_use = "sandbox application result should be checked"] + pub fn apply_with_abi(caps: &CapabilitySet, abi: &DetectedAbi) -> Result<()> { + linux::apply_with_abi(caps, abi) + } + /// Check if sandboxing is supported on this platform #[must_use] pub fn is_supported() -> bool { diff --git a/docs/cli/features/network-proxy.mdx b/docs/cli/features/network-proxy.mdx index cf310a77e..5c4bc7ba3 100644 --- a/docs/cli/features/network-proxy.mdx +++ b/docs/cli/features/network-proxy.mdx @@ -92,6 +92,39 @@ nono run --allow-cwd --network-profile enterprise --external-proxy squid.corp:31 CONNECT requests are chained through the corporate proxy. Cloud metadata endpoints are still denied. +#### Bypassing the External Proxy + +Some domains may need to bypass the enterprise proxy and connect directly (e.g., internal services that are not reachable through the proxy, or services that the proxy interferes with): + +```bash +nono run --allow-cwd --network-profile enterprise \ + --external-proxy squid.corp:3128 \ + --external-proxy-bypass git.internal.corp \ + --external-proxy-bypass "*.dev.local" \ + -- my-agent +``` + +Bypass patterns support exact hostnames and `*.` wildcard suffixes (case-insensitive). Matching hosts are routed directly via a CONNECT tunnel; everything else goes through the enterprise proxy. + +This can also be configured in a profile: + +```json +{ + "network": { + "external_proxy": "squid.corp:3128", + "external_proxy_bypass": ["git.internal.corp", "*.dev.local"] + } +} +``` + +Or via environment variables: + +```bash +export NONO_EXTERNAL_PROXY=squid.corp:3128 +export NONO_EXTERNAL_PROXY_BYPASS=git.internal.corp,*.dev.local +nono run --allow-cwd -- my-agent +``` + ## Network Profiles Network profiles are composable groups of allowed hosts, similar to filesystem policy groups. They're defined in `network-policy.json` (embedded in the binary). @@ -137,6 +170,22 @@ User profiles can specify a network profile in the `network` section: } ``` +Enterprise profiles can include external proxy and bypass configuration: + +```json +{ + "meta": { "name": "corp-agent" }, + "filesystem": { + "allow": ["$WORKDIR"] + }, + "network": { + "network_profile": "enterprise", + "external_proxy": "squid.corp:3128", + "external_proxy_bypass": ["git.internal.corp", "*.dev.local"] + } +} +``` + ## Localhost IPC Between Sandboxes When running multiple sandboxed processes that need to communicate (e.g., an MCP server in one sandbox, an AI agent in another), use `--allow-port` to open a specific localhost TCP port for bidirectional communication. diff --git a/docs/cli/features/profiles-groups.mdx b/docs/cli/features/profiles-groups.mdx index 39695920f..61a22d6cb 100644 --- a/docs/cli/features/profiles-groups.mdx +++ b/docs/cli/features/profiles-groups.mdx @@ -115,6 +115,8 @@ The `network` section controls network access and credential injection: "proxy_allow": ["my-internal-api.example.com"], "port_allow": [3000], "proxy_credentials": ["openai", "anthropic"], + "external_proxy": "squid.corp:3128", + "external_proxy_bypass": ["git.internal.corp", "*.dev.local"], "custom_credentials": { "telegram": { "upstream": "https://api.telegram.org", @@ -135,6 +137,8 @@ The `network` section controls network access and credential injection: | `port_allow` | Localhost TCP ports to allow bidirectional IPC (equivalent to `--allow-port`) | | `proxy_credentials` | Credential services to enable via reverse proxy (e.g., `openai`, `anthropic`) | | `custom_credentials` | Custom credential service definitions for APIs not in the built-in list | +| `external_proxy` | External (enterprise) proxy address, e.g., `squid.corp:3128` | +| `external_proxy_bypass` | Domains to bypass the external proxy (exact hostnames and `*.` wildcards) | #### Custom Credentials @@ -338,7 +342,7 @@ nono ships with 22 built-in groups: **Runtime groups** (language toolchain paths): - `node_runtime` - nvm, fnm, npm, volta - `rust_runtime` - rustup, cargo -- `python_runtime` - pyenv, conda, pip +- `python_runtime` - pyenv, conda, pip, uv - `user_tools` - Local bins, .desktop files, man pages, shell completions **Protection groups**: diff --git a/docs/cli/getting_started/installation.mdx b/docs/cli/getting_started/installation.mdx index 849596de2..a0a36e717 100644 --- a/docs/cli/getting_started/installation.mdx +++ b/docs/cli/getting_started/installation.mdx @@ -6,7 +6,6 @@ description: How to install nono on your system ## Homebrew (macOS) ```bash -brew tap always-further/nono brew install nono ``` diff --git a/docs/cli/usage/flags.mdx b/docs/cli/usage/flags.mdx index ac2d58aca..5bf4bf461 100644 --- a/docs/cli/usage/flags.mdx +++ b/docs/cli/usage/flags.mdx @@ -45,7 +45,7 @@ nono wrap [OPTIONS] -- [ARGS...] ``` - `nono wrap` does not support proxy flags (`--network-profile`, `--proxy-allow`, `--proxy-credential`, `--external-proxy`). The network proxy requires a parent process. Use `nono run` instead. + `nono wrap` does not support proxy flags (`--network-profile`, `--proxy-allow`, `--proxy-credential`, `--external-proxy`, `--external-proxy-bypass`). The network proxy requires a parent process. Use `nono run` instead. ### `nono why` @@ -225,7 +225,7 @@ nono run --profile claude-code --net-allow -- claude ``` - `--net-allow` disables proxy mode for that run, so it also disables proxy-based credential injection. It conflicts with `--net-block`, `--network-profile`, `--proxy-allow`, `--proxy-credential`, `--external-proxy`, and `--proxy-port`. + `--net-allow` disables proxy mode for that run, so it also disables proxy-based credential injection. It conflicts with `--net-block`, `--network-profile`, `--proxy-allow`, `--proxy-credential`, `--external-proxy`, `--external-proxy-bypass`, and `--proxy-port`. #### `--network-profile` @@ -298,6 +298,30 @@ Chain outbound connections through an external (enterprise) proxy. Cloud metadat nono run --allow-cwd --network-profile enterprise --external-proxy squid.corp:3128 -- my-agent ``` +#### `--external-proxy-bypass` + +Route specific domains directly instead of through the external proxy. Supports exact hostnames and `*.` wildcard suffixes (case-insensitive). Requires `--external-proxy`. + +```bash +# Bypass the enterprise proxy for internal services +nono run --allow-cwd --network-profile enterprise \ + --external-proxy squid.corp:3128 \ + --external-proxy-bypass internal.corp \ + --external-proxy-bypass "*.private.net" \ + -- my-agent + +# Multiple bypass patterns +nono run --allow-cwd \ + --external-proxy squid.corp:3128 \ + --external-proxy-bypass git.internal.corp \ + --external-proxy-bypass "*.dev.local" \ + -- my-agent +``` + +Bypass hosts are checked before routing. Matching hosts use a direct CONNECT tunnel (same as Mode 1); non-matching hosts chain through the external proxy. + +Can be specified multiple times. + #### `--proxy-port` Set a fixed port for the credential injection proxy (default: OS-assigned ephemeral port). Use this when the sandboxed application requires a known proxy port that can't be configured via environment variables. @@ -947,6 +971,8 @@ CLI flags always take precedence over environment variables. | `--network-profile` | `NONO_NETWORK_PROFILE` | `NONO_NETWORK_PROFILE=claude-code` | | `--env-credential` | `NONO_ENV_CREDENTIAL` | `NONO_ENV_CREDENTIAL=key1,key2` | | `--capability-elevation` | `NONO_CAPABILITY_ELEVATION` | `NONO_CAPABILITY_ELEVATION=true` | +| `--external-proxy` | `NONO_EXTERNAL_PROXY` | `NONO_EXTERNAL_PROXY=squid.corp:3128` | +| `--external-proxy-bypass` | `NONO_EXTERNAL_PROXY_BYPASS` | `NONO_EXTERNAL_PROXY_BYPASS=internal.corp,*.private.net` (comma-separated) | Boolean variables accept `true`, `false`, `yes`, `no`, `1`, `0`. diff --git a/tests/integration/test_nix_paths.sh b/tests/integration/test_nix_paths.sh new file mode 100755 index 000000000..c970fcf0d --- /dev/null +++ b/tests/integration/test_nix_paths.sh @@ -0,0 +1,219 @@ +#!/bin/bash +# Nix Path Integration Tests +# Tests that nono correctly handles Nix store paths, symlink chains, +# wrapper scripts, and dynamic library loading from /nix/store. +# +# Covers issues: #19, #76, #93, #205, #262, #287 + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +source "$SCRIPT_DIR/../lib/test_helpers.sh" + +echo "" +echo -e "${BLUE}=== Nix Path Tests ===${NC}" + +verify_nono_binary + +# Skip entire suite if Nix is not installed +if ! require_nix "nix paths suite"; then + print_summary + exit 0 +fi + +if ! require_working_sandbox "nix paths suite"; then + print_summary + exit 0 +fi + +# Create test fixtures +TMPDIR=$(setup_test_dir) +trap 'cleanup_test_dir "$TMPDIR"' EXIT + +echo "" +echo "Test directory: $TMPDIR" + +# Resolve Nix binary paths +NIX_ECHO=$(nix_realpath echo) +NIX_CAT=$(nix_realpath cat) +NIX_LS=$(nix_realpath ls) +NIX_BASH=$(nix_realpath bash) +NIX_PYTHON3=$(nix_realpath python3) +NIX_NODE=$(nix_realpath node) + +echo "Nix echo: $NIX_ECHO" +echo "Nix bash: $NIX_BASH" +echo "Nix python3: $NIX_PYTHON3" +echo "Nix node: $NIX_NODE" +echo "" + +# ============================================================================= +# Nix Store Binary Execution (covers #19) +# ============================================================================= + +echo "--- Nix Store Binary Execution ---" + +# Basic execution of binaries living in /nix/store +expect_output_contains "echo from nix store path" "hello from nix" \ + "$NONO_BIN" run --read /nix --allow "$TMPDIR" -- "$NIX_ECHO" "hello from nix" + +echo "test content" > "$TMPDIR/testfile.txt" +expect_output_contains "cat from nix store path" "test content" \ + "$NONO_BIN" run --read /nix --allow "$TMPDIR" -- "$NIX_CAT" "$TMPDIR/testfile.txt" + +expect_success "ls from nix store path" \ + "$NONO_BIN" run --read /nix --allow "$TMPDIR" -- "$NIX_LS" "$TMPDIR" + +expect_success "bash from nix store path runs command" \ + "$NONO_BIN" run --read /nix --allow "$TMPDIR" -- "$NIX_BASH" -c "echo ok" + +# Verify binaries accessed via symlink chain (e.g. ~/.nix-profile/bin/echo) +SYMLINK_ECHO=$(type -P -- echo 2>/dev/null || true) +if [[ -n "$SYMLINK_ECHO" && "$SYMLINK_ECHO" == *nix* ]]; then + expect_output_contains "echo via nix symlink chain" "symlink ok" \ + "$NONO_BIN" run --read /nix --allow "$TMPDIR" -- "$SYMLINK_ECHO" "symlink ok" +else + skip_test "echo via nix symlink chain" "echo not from nix" +fi + +# Python from nix store +expect_output_contains "python3 from nix store" "Python" \ + "$NONO_BIN" run --read /nix --allow "$TMPDIR" -- "$NIX_PYTHON3" --version + +# Node from nix store +expect_success "node from nix store" \ + "$NONO_BIN" run --read /nix --allow "$TMPDIR" -- "$NIX_NODE" -e "console.log('ok')" + +# ============================================================================= +# Dynamic Linker / Shared Library Loading (covers #205, #262) +# ============================================================================= + +echo "" +echo "--- Dynamic Linker / Shared Libraries ---" + +if is_linux; then + # Python importing ssl triggers shared library loading (libssl, libcrypto) + # which uses openat(dirfd, "relative") on NixOS + expect_output_contains "python3 ssl import (shared lib loading)" "OpenSSL" \ + "$NONO_BIN" run --read /nix --allow "$TMPDIR" -- \ + "$NIX_PYTHON3" -c "import ssl; print(ssl.OPENSSL_VERSION)" + + # Node.js triggers dynamic linker for V8, ICU, etc. + expect_output_contains "node shared lib loading" "nix-node-ok" \ + "$NONO_BIN" run --read /nix --allow "$TMPDIR" -- \ + "$NIX_NODE" -e "console.log('nix-node-ok')" + + # Python importing json + os (stdlib with C extensions) + expect_success "python3 C extension loading" \ + "$NONO_BIN" run --read /nix --allow "$TMPDIR" -- \ + "$NIX_PYTHON3" -c "import json, os, hashlib; print('ok')" +else + skip_test "python3 ssl import (shared lib loading)" "Linux only" + skip_test "node shared lib loading" "Linux only" + skip_test "python3 C extension loading" "Linux only" +fi + +# ============================================================================= +# Wrapper Script Resolution (covers #287) +# ============================================================================= + +echo "" +echo "--- Wrapper Script Resolution ---" + +# Nix python3 is often a wrapper script. Verify --version reports Python, not +# some other runtime (issue #287: opencode resolved to Bun). +PYTHON3_WHICH=$(type -P -- python3 2>/dev/null || true) +if [[ -n "$PYTHON3_WHICH" && "$PYTHON3_WHICH" == *nix* ]]; then + expect_output_contains "python3 wrapper resolves to Python" "Python" \ + "$NONO_BIN" run --read /nix --allow "$TMPDIR" -- "$PYTHON3_WHICH" --version + + # Verify the wrapper chain: which -> symlink -> ... -> /nix/store/.../python3 + PYTHON3_REAL=$(readlink -f "$PYTHON3_WHICH" 2>/dev/null || true) + if [[ -n "$PYTHON3_REAL" && "$PYTHON3_REAL" == /nix/store/* ]]; then + expect_output_contains "python3 real binary in nix store" "Python" \ + "$NONO_BIN" run --read /nix --allow "$TMPDIR" -- "$PYTHON3_REAL" --version + else + skip_test "python3 real binary in nix store" "could not resolve real path" + fi +else + skip_test "python3 wrapper resolves to Python" "python3 not from nix" + skip_test "python3 real binary in nix store" "python3 not from nix" +fi + +# Same for node +NODE_WHICH=$(type -P -- node 2>/dev/null || true) +if [[ -n "$NODE_WHICH" && "$NODE_WHICH" == *nix* ]]; then + expect_output_contains "node wrapper resolves to Node" "ok" \ + "$NONO_BIN" run --read /nix --allow "$TMPDIR" -- "$NODE_WHICH" -e "console.log('ok')" +else + skip_test "node wrapper resolves to Node" "node not from nix" +fi + +# ============================================================================= +# Symlink Chain Traversal +# ============================================================================= + +echo "" +echo "--- Symlink Chain Traversal ---" + +# Verify each directory in the symlink chain from ~/.nix-profile to /nix/store +# is accessible under sandbox with /nix read access +NIX_PROFILE="$HOME/.nix-profile" +if [[ -L "$NIX_PROFILE" || -d "$NIX_PROFILE" ]]; then + # The profile itself should be listable + expect_success "list ~/.nix-profile/bin" \ + "$NONO_BIN" run --read /nix --read "$HOME/.nix-profile" --read "$HOME/.local/state" --allow "$TMPDIR" -- \ + "$NIX_LS" "$NIX_PROFILE/bin" + + # Verify a binary in the profile is executable + if [[ -x "$NIX_PROFILE/bin/python3" ]]; then + expect_output_contains "python3 via ~/.nix-profile" "Python" \ + "$NONO_BIN" run --read /nix --read "$HOME/.nix-profile" --read "$HOME/.local/state" --allow "$TMPDIR" -- \ + "$NIX_PROFILE/bin/python3" --version + else + skip_test "python3 via ~/.nix-profile" "python3 not in ~/.nix-profile" + fi +else + skip_test "list ~/.nix-profile/bin" "~/.nix-profile does not exist" + skip_test "python3 via ~/.nix-profile" "~/.nix-profile does not exist" +fi + +# Verify /nix/store read access allows following deep store paths +if [[ -d "/nix/store" ]]; then + # Pick a store path from the resolved python3 binary + NIX_STORE_DIR=$(dirname "$NIX_PYTHON3") + expect_success "ls resolved nix store directory" \ + "$NONO_BIN" run --read /nix --allow "$TMPDIR" -- "$NIX_LS" "$NIX_STORE_DIR" +else + skip_test "ls resolved nix store directory" "/nix/store does not exist" +fi + +# ============================================================================= +# nix_runtime Policy Group (profile validation) +# ============================================================================= + +echo "" +echo "--- nix_runtime Policy Group ---" + +if is_linux; then + # The developer profile includes nix_runtime group which grants read access + # to ~/.nix-profile, ~/.nix-defexpr, /nix/var/nix/profiles, etc. + expect_success "developer profile dry-run with nix paths" \ + "$NONO_BIN" run --profile developer --dry-run -- echo "test" + + # Verify nix_runtime paths appear in dry-run output + expect_output_contains "developer profile includes /nix in capabilities" "/nix" \ + "$NONO_BIN" run --profile developer --dry-run -- echo "test" + + # Verify developer profile can run nix binaries + expect_output_contains "developer profile runs nix python3" "Python" \ + "$NONO_BIN" run --profile developer --allow "$TMPDIR" --allow-cwd -- "$NIX_PYTHON3" --version +else + skip_test "developer profile dry-run with nix paths" "Linux only" + skip_test "developer profile includes /nix in capabilities" "Linux only" + skip_test "developer profile runs nix python3" "Linux only" +fi + +# ============================================================================= +# Summary +# ============================================================================= + +print_summary diff --git a/tests/lib/test_helpers.sh b/tests/lib/test_helpers.sh index bd210f2aa..b068842d7 100755 --- a/tests/lib/test_helpers.sh +++ b/tests/lib/test_helpers.sh @@ -302,6 +302,35 @@ require_working_sandbox() { return 1 } +# Check if Nix package manager is installed +require_nix() { + local test_name="$1" + if ! command -v nix-env >/dev/null 2>&1; then + skip_test "$test_name" "nix not installed" + return 1 + fi + return 0 +} + +# Resolve a command to its /nix/store path (follows all symlinks) +nix_realpath() { + local cmd="$1" + local path="" + + # `command -v` returns shell builtins/functions for names like `echo`. + # Use `type -P` first so we only resolve real executables on disk. + path=$(type -P -- "$cmd" 2>/dev/null || true) + if [[ -z "$path" ]]; then + path=$(command -v -- "$cmd" 2>/dev/null || true) + fi + + if [[ -z "$path" || "$path" != */* || ! -e "$path" ]]; then + return 1 + fi + + readlink -f "$path" 2>/dev/null || realpath "$path" 2>/dev/null || echo "$path" +} + # Get the directory of the current script get_script_dir() { cd "$(dirname "${BASH_SOURCE[1]}")" && pwd diff --git a/tests/run_integration_tests.sh b/tests/run_integration_tests.sh index 1351dabd9..b3616cb50 100755 --- a/tests/run_integration_tests.sh +++ b/tests/run_integration_tests.sh @@ -81,6 +81,7 @@ SUITES=( "test_rollback.sh:Rollback" "test_setup.sh:Setup" "test_learn.sh:Learn Mode" + "test_nix_paths.sh:Nix Paths" ) TOTAL_SUITES=${#SUITES[@]} diff --git a/tests/run_nix_integration_tests.sh b/tests/run_nix_integration_tests.sh new file mode 100755 index 000000000..6170a9db3 --- /dev/null +++ b/tests/run_nix_integration_tests.sh @@ -0,0 +1,63 @@ +#!/bin/bash +# nono Nix Integration Test Runner +# Runs Nix-specific integration tests against Nix-installed programs + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +BLUE='\033[0;34m' +BOLD='\033[1m' +NC='\033[0m' + +echo "" +echo -e "${BOLD}======================================${NC}" +echo -e "${BOLD} nono Nix Integration Tests${NC}" +echo -e "${BOLD}======================================${NC}" +echo "" + +# Use pre-built binary (CI builds before running this script) +export NONO_BIN="${NONO_BIN:-$PROJECT_ROOT/target/release/nono}" +export PATH="$PROJECT_ROOT/target/release:$PATH" + +if [[ ! -x "$NONO_BIN" ]]; then + echo -e "${RED}ERROR: nono binary not found at $NONO_BIN${NC}" + echo "Run 'cargo build --release' first" + exit 1 +fi + +echo -e "Binary: ${GREEN}$NONO_BIN${NC}" +echo -e "Version: $("$NONO_BIN" --version 2>/dev/null || echo 'unknown')" +echo -e "Platform: $(uname -s) $(uname -m)" +echo "" + +# Check Nix is available +if ! command -v nix-env >/dev/null 2>&1; then + echo -e "${RED}ERROR: Nix is not installed${NC}" + exit 1 +fi + +echo -e "Nix: $(nix --version 2>/dev/null || echo 'unknown')" +echo "" + +chmod +x "$SCRIPT_DIR"/integration/test_nix_paths.sh +chmod +x "$SCRIPT_DIR"/lib/*.sh + +echo -e "${BLUE}Running Nix path tests...${NC}" +echo "" + +bash "$SCRIPT_DIR/integration/test_nix_paths.sh" +exit_code=$? + +echo "" +if [[ "$exit_code" -eq 0 ]]; then + echo -e "${GREEN}${BOLD}All Nix integration tests passed!${NC}" +else + echo -e "${RED}${BOLD}Nix integration tests failed.${NC}" +fi + +exit "$exit_code"