Skip to content

Instantly share code, notes, and snippets.

@lukehinds
Last active February 20, 2026 13:23
Show Gist options
  • Select an option

  • Save lukehinds/9346514519b5c7a3047de5a0b0e083ae to your computer and use it in GitHub Desktop.

Select an option

Save lukehinds/9346514519b5c7a3047de5a0b0e083ae to your computer and use it in GitHub Desktop.

Design: Network Filtering Proxy and Credential Management

Status: Proposed Date: 2026-02-17

Problem

The current network control is binary: --allow-net grants full internet access, --block-net (default) blocks everything. This is insufficient for AI agents that need controlled network access:

Primary problem - No host filtering: An agent with --allow-net can:

  • Connect to arbitrary hosts for data exfiltration
  • Access internal network services (169.254.x.x, 10.x.x.x, corporate endpoints)
  • Make requests to unintended APIs
  • Phone home to attacker-controlled servers

Secondary problem - Credential exposure: When agents do need API access:

  • API keys must be in their environment (visible in /proc/<pid>/environ)
  • Keys are in child process memory (dumpable)
  • No audit trail of which endpoints were called
  • Keys can be used for unintended purposes

Solution: Filtering Proxy

A micro HTTP proxy in the supervisor that provides allowlist-based network filtering with optional credential injection:

Sandboxed Agent (child)                    Supervisor (parent, unsandboxed)
┌─────────────────────────┐               ┌─────────────────────────────────┐
│                         │               │                                 │
│  Network: blocked       │               │  Proxy Server (localhost:PORT)  │
│  except localhost:PORT  │               │                                 │
│                         │               │  1. Receive CONNECT or HTTP req │
│  HTTP Client            │               │                                 │
│    │                    │               │  2. Check host against allowlist│
│    ▼                    │──────────────▶│     ├─ api.openai.com ✓         │
│  CONNECT api.openai.com │  localhost    │     ├─ api.anthropic.com ✓      │
│       or                │    only       │     ├─ evil.com ✗ BLOCKED       │
│  GET /openai/v1/chat    │               │     └─ 10.0.0.1 ✗ BLOCKED       │
│                         │               │                                 │
│                         │               │  3. (Optional) Inject credential│
│                         │               │     from keyring                │
│                         │               │                                 │
│                         │               │  4. Forward to upstream         │
│                         │◀──────────────│                                 │
│  Response               │               │  5. Audit log                   │
└─────────────────────────┘               └─────────────────────────────────┘

Key Properties

  1. Host allowlist enforcement: Only explicitly allowed hosts are reachable. Everything else is blocked at the proxy.

  2. Network blocked except localhost proxy: Sandbox allows only 127.0.0.1:<proxy-port>. No direct internet access bypasses the proxy.

  3. Optional credential injection: For configured services, the proxy can load credentials from the system keyring and inject them into requests. The agent never sees real API keys.

  4. Audit trail: Every request is logged with timestamp, target host, path, and outcome (allowed/blocked).

  5. Defense in depth: Even if an agent somehow bypasses the proxy token validation, network-level sandbox rules prevent direct outbound connections.

Architecture

Integration with Existing Supervisor

The proxy is a natural extension of the Supervised execution strategy. The parent is already:

  • Unsandboxed (can access keyring, make network calls)
  • Running an event loop (poll-based for seccomp-notify on Linux)
  • Managing IPC with the child (supervisor socket)

The proxy adds:

  • An HTTP/HTTPS proxy on 127.0.0.1:<random-port> in the supervisor
  • Host allowlist configuration
  • Optional credential mapping for reverse-proxy mode

Proxy Modes

The proxy supports two modes to handle different use cases:

Mode 1: CONNECT Proxy (Host Filtering Only)

For filtering without credential injection. Uses standard HTTP CONNECT tunneling:

Agent → CONNECT api.openai.com:443 → Proxy
                                       ↓
                              Check host against allowlist:
                                api.openai.com ✓ allowed
                                       ↓
                              Establish TCP tunnel
                                       ↓
Agent ←─────── TLS tunnel ────────────→ api.openai.com

How it works:

  1. Agent sets HTTP_PROXY=http://localhost:PORT / HTTPS_PROXY=http://localhost:PORT
  2. Agent makes normal HTTPS request to https://api.openai.com/v1/chat
  3. HTTP client sends CONNECT api.openai.com:443 to proxy
  4. Proxy checks host against allowlist
  5. If allowed: establish TCP tunnel, agent does TLS handshake directly with server
  6. If blocked: return 403 Forbidden with explanation

Properties:

  • Agent uses real URLs (no rewriting)
  • Works with any HTTP client that respects proxy env vars
  • End-to-end TLS (proxy cannot see request content)
  • Host-level filtering only (cannot filter by path)
  • Cannot inject credentials (opaque tunnel)

Mode 2: Reverse Proxy (Filtering + Credentials)

For services where we want to inject credentials from the keyring:

Agent → POST http://localhost:PORT/openai/v1/chat → Proxy
            X-Nono-Token: <session-token>            ↓
                                              Validate session token
                                                     ↓
                                              Parse prefix: "openai"
                                                     ↓
                                              Load credential from keyring
                                                     ↓
                                              POST https://api.openai.com/v1/chat
                                                   Authorization: Bearer sk-real-key
                                                     ↓
                                              Return response to agent

How it works:

  1. Agent sets OPENAI_BASE_URL=http://localhost:PORT/openai
  2. Agent includes session token in X-Nono-Token header
  3. Proxy validates token, extracts service prefix
  4. Proxy loads real credential from keyring, injects into upstream request
  5. Proxy forwards to real API, returns response

Properties:

  • Agent uses rewritten URLs (http://localhost/openai/...)
  • Credential never in agent environment or memory
  • Proxy can filter by path if needed
  • Session token prevents unauthorized use of proxy

Mode 3: External Proxy Passthrough (Enterprise)

For enterprises with existing proxy infrastructure (Squid, Cisco WSA, Juniper SWG, Zscaler, etc.) where filtering, logging, DLP, and certificate management are already handled by the corporate proxy.

Sandboxed Agent (child)                    Supervisor (parent, unsandboxed)
┌─────────────────────────┐               ┌─────────────────────────────────┐
│                         │               │                                 │
│  Network: blocked       │               │  Nono Proxy (localhost:PORT)    │
│  except localhost:PORT  │               │                                 │
│                         │               │  1. Receive CONNECT from agent  │
│  HTTP Client            │               │                                 │
│    │                    │               │  2. Apply default_deny (SSRF)   │
│    ▼                    │──────────────▶│     ├─ 169.254.169.254 ✗        │
│  CONNECT api.openai.com │  localhost    │     └─ 10.0.0.0/8 ✗             │
│                         │    only       │                                 │
│                         │               │  3. Forward CONNECT to upstream │
│                         │               │     enterprise proxy            │
│                         │               │     (squid.corp.internal:3128)  │
│                         │               │                                 │
│                         │◀──────────────│  4. Audit log (local)           │
│  Response               │               │                                 │
└─────────────────────────┘               └─────────────────────────────────┘
                                                        │
                                                        ▼
                                          Enterprise Proxy (Squid/Cisco/etc)
                                          ┌─────────────────────────────────┐
                                          │  Corporate filtering rules      │
                                          │  DLP inspection                 │
                                          │  Certificate management         │
                                          │  Compliance logging             │
                                          └─────────────────────────────────┘

How it works:

  1. Nono's sandbox still restricts the child to localhost:PORT only (same as Mode 1/2)
  2. Nono's proxy receives the CONNECT request from the agent
  3. Nono applies default_deny checks (cloud metadata, RFC1918) as a security floor
  4. Instead of connecting directly to the upstream host, nono forwards the CONNECT to the enterprise proxy
  5. The enterprise proxy handles allow/deny decisions, TLS inspection, logging, etc.
  6. Nono logs the request locally for its own audit trail

Properties:

  • Enterprise retains full control over network policy (their proxy, their rules)
  • Nono's default_deny still protects against SSRF to cloud metadata and private networks
  • Agent is still sandboxed to localhost-only (cannot bypass nono to reach the enterprise proxy directly)
  • Enterprise proxy sees normal CONNECT traffic - no special integration required
  • Credential injection (Mode 2) can coexist: nono handles credential routes locally, everything else chains to the enterprise proxy

Why nono still sits in the middle: Even in passthrough mode, nono's proxy provides value:

  • The sandbox enforcement (child cannot reach the network directly) is the primary security boundary
  • default_deny catches SSRF vectors that the enterprise proxy may not block (cloud metadata endpoints are often allowed through corporate proxies)
  • Local audit trail independent of enterprise proxy logs
  • Consistent agent environment (HTTP_PROXY always points to localhost regardless of mode)

Hybrid Operation

All three modes run simultaneously on the same port:

async fn handle_connection(stream: TcpStream) {
    let req = parse_request(&stream).await;

    match req.method() {
        Method::CONNECT => {
            // Always apply default_deny first (SSRF protection)
            if self.default_deny.is_blocked(req.uri().host()) {
                return send_response(&stream, 403, "Blocked by default deny").await;
            }

            if let Some(upstream_proxy) = &self.external_proxy {
                // Mode 3: Chain CONNECT to enterprise proxy
                handle_external_proxy(req, stream, upstream_proxy).await
            } else {
                // Mode 1: CONNECT proxy (host filtering via nono allowlist)
                handle_connect_tunnel(req, stream).await
            }
        }
        _ => {
            // Mode 2: Reverse proxy (path-based routing + credentials)
            // Credential routes are always handled locally, even with external proxy
            handle_reverse_proxy(req, stream).await
        }
    }
}

This allows:

  • CONNECT for services that don't need credential injection (Mode 1) or chained to enterprise proxy (Mode 3)
  • Reverse proxy for services where credentials should be isolated (Mode 2, always local)
  • Mode 2 and Mode 3 coexist: credential-injected routes go through nono's reverse proxy, everything else chains to the enterprise proxy

Configuration

CLI Flags

# Filtering only - allow specific hosts
nono run --supervised \
    --proxy-allow api.openai.com \
    --proxy-allow api.anthropic.com \
    --proxy-allow api.github.com \
    -- claude

# Filtering + credential injection
nono run --supervised \
    --proxy-allow api.openai.com \
    --proxy-credential openai=OPENAI_API_KEY:https://api.openai.com \
    -- claude

# Wildcard subdomain matching
nono run --supervised \
    --proxy-allow "*.googleapis.com" \
    --proxy-allow "*.openai.com" \
    -- claude

Network Policy Configuration

Network filtering uses a group-based JSON policy file (network-policy.json), following the same composable pattern as policy.json for filesystem sandboxing. This is a separate file because network policy is orthogonal to filesystem policy and has different composition semantics.

Design principle: A strict default deny baseline ships built-in. Users compose groups to expand access for their use case. Groups are additive - you cannot weaken the built-in deny list, only extend the allow list.

{
  "meta": {
    "version": 1,
    "schema_version": "1.0"
  },

  "default_deny": {
    "description": "Hardcoded deny list. Cannot be overridden by any group.",
    "hosts": [
      "169.254.169.254",
      "metadata.google.internal",
      "metadata.azure.internal"
    ],
    "cidrs": [
      "10.0.0.0/8",
      "172.16.0.0/12",
      "192.168.0.0/16",
      "169.254.0.0/16",
      "127.0.0.0/8",
      "::1/128",
      "fc00::/7",
      "fe80::/10"
    ]
  },

  "groups": {
    "llm_apis": {
      "description": "LLM provider API endpoints",
      "allow": [
        "api.openai.com",
        "api.anthropic.com",
        "generativelanguage.googleapis.com",
        "*.aiplatform.googleapis.com"
      ]
    },

    "package_registries": {
      "description": "Language package registries for dependency resolution",
      "allow": [
        "pypi.org",
        "files.pythonhosted.org",
        "*.npmjs.org",
        "registry.npmjs.org",
        "crates.io",
        "static.crates.io",
        "index.crates.io"
      ]
    },

    "github": {
      "description": "GitHub API and content delivery",
      "allow": [
        "api.github.com",
        "github.com",
        "raw.githubusercontent.com",
        "objects.githubusercontent.com"
      ]
    },

    "documentation": {
      "description": "Common documentation and reference sites",
      "allow": [
        "docs.rs",
        "doc.rust-lang.org",
        "docs.python.org",
        "developer.mozilla.org",
        "*.readthedocs.io"
      ]
    }
  },

  "credentials": {
    "openai": {
      "keyring": "openai_api_key",
      "upstream": "https://api.openai.com",
      "header": "Authorization",
      "format": "Bearer {}"
    },
    "anthropic": {
      "keyring": "anthropic_api_key",
      "upstream": "https://api.anthropic.com",
      "header": "x-api-key",
      "format": "{}"
    },
    "github": {
      "keyring": "github_token",
      "upstream": "https://api.github.com",
      "header": "Authorization",
      "format": "token {}"
    }
  },

  "profiles": {
    "claude-code": {
      "description": "Network profile for Claude Code agent sessions",
      "groups": ["llm_apis", "package_registries", "github", "documentation"],
      "credentials": ["anthropic", "openai"]
    },
    "minimal": {
      "description": "Strict baseline  -  LLM API access only",
      "groups": ["llm_apis"],
      "credentials": ["anthropic"]
    },
    "enterprise-passthrough": {
      "description": "Route all traffic through corporate proxy, credentials handled locally",
      "external_proxy": "http://squid.corp.internal:3128",
      "credentials": ["anthropic", "openai"]
    }
  }
}

Resolution order (mirrors filesystem policy):

  1. default_deny - always applied, not overridable
  2. Groups referenced by profile are merged (union of all allow entries)
  3. CLI --proxy-allow / --proxy-deny flags applied last as overrides

User-supplied overrides: Users can place a network-policy.json in their nono config directory (~/.config/nono/network-policy.json). User groups are merged additively with built-in groups. User groups cannot remove entries from default_deny. If a user group name collides with a built-in group, the user group extends (not replaces) the built-in allow list.

Profile selection: The active network profile is selected via the sandbox profile's security section:

{
  "security": {
    "groups": ["deny_credentials", "system_read_macos", "..."],
    "network_profile": "claude-code"
  }
}

Or via CLI flag: nono run --network-profile claude-code -- claude

For enterprise passthrough specifically, there is also a direct CLI flag:

# Route all non-credential traffic through corporate proxy
nono run --supervised \
    --external-proxy http://squid.corp.internal:3128 \
    --proxy-credential anthropic=anthropic_api_key:https://api.anthropic.com \
    -- claude

When --external-proxy is set, nono's group-based allowlist is bypassed for CONNECT requests - the enterprise proxy makes all allow/deny decisions. Nono's default_deny still applies as a floor.

Environment Variables to Agent

The supervisor sets these in the child's environment:

# Always set - enables CONNECT proxy for filtering
HTTP_PROXY=http://127.0.0.1:54321
HTTPS_PROXY=http://127.0.0.1:54321
NO_PROXY=localhost,127.0.0.1

# Session token for reverse proxy mode
NONO_PROXY_TOKEN=abc123...  # session-scoped, random

# SDK-specific base URLs for credential injection
OPENAI_BASE_URL=http://127.0.0.1:54321/openai
ANTHROPIC_BASE_URL=http://127.0.0.1:54321/anthropic

The agent's HTTP clients will automatically use the CONNECT proxy for outbound requests. For services with credential injection, the SDK-specific *_BASE_URL variables route through reverse proxy mode.

Session Token Generation

fn generate_session_token() -> String {
    // 32 bytes of random, hex-encoded = 64 chars
    let mut bytes = [0u8; 32];
    getrandom::getrandom(&mut bytes).expect("RNG failure");
    hex::encode(bytes)
}

The token is:

  • Generated fresh each session
  • Never persisted to disk
  • Stored only in supervisor memory
  • Required only for reverse proxy mode (credential injection)

Request Flow: CONNECT Mode (Filtering)

async fn handle_connect(req: Request, stream: TcpStream) -> Result<()> {
    // CONNECT api.openai.com:443 HTTP/1.1
    let target_host = req.uri().authority()?.host();
    let target_port = req.uri().authority()?.port_u16().unwrap_or(443);

    // 1. Check against deny list (always blocked)
    if self.deny_list.matches(target_host) {
        self.audit_log(AuditEntry::blocked(target_host, "deny_list"));
        return send_response(&stream, 403, "Forbidden: host in deny list").await;
    }

    // 2. Check against allow list
    if !self.allow_list.matches(target_host) {
        self.audit_log(AuditEntry::blocked(target_host, "not_in_allowlist"));
        return send_response(&stream, 403, "Forbidden: host not in allowlist").await;
    }

    // 3. Establish upstream connection
    let upstream = TcpStream::connect((target_host, target_port)).await?;

    // 4. Send 200 Connection Established
    send_response(&stream, 200, "Connection Established").await?;

    // 5. Bidirectional tunnel (proxy just passes bytes)
    tokio::io::copy_bidirectional(&mut stream, &mut upstream).await?;

    // 6. Audit log
    self.audit_log(AuditEntry::allowed(target_host, target_port));

    Ok(())
}

Request Flow: Reverse Proxy Mode (Credentials)

async fn handle_reverse_proxy(req: Request) -> Response {
    // 1. Validate session token (required for credential injection)
    let token = req.headers().get("X-Nono-Token");
    if !constant_time_eq(token, &self.session_token) {
        return Response::unauthorized("Invalid session token");
    }

    // 2. Extract service prefix from path
    // /openai/v1/chat/completions → service="openai", path="/v1/chat/completions"
    let (service, path) = parse_service_prefix(req.uri().path())?;

    // 3. Lookup credential mapping
    let mapping = match self.credentials.get(&service) {
        Some(m) => m,
        None => return Response::not_found("Unknown service prefix"),
    };

    // 4. Check upstream host is in allowlist (defense in depth)
    if !self.allow_list.matches(&mapping.upstream.host()) {
        return Response::forbidden("Upstream not in allowlist");
    }

    // 5. Build upstream request
    let upstream_url = format!("{}{}", mapping.upstream, path);
    let mut upstream_req = Request::builder()
        .method(req.method())
        .uri(&upstream_url);

    // 6. Copy headers, skip token and host
    for (name, value) in req.headers() {
        if name == "X-Nono-Token" || name == "Host" {
            continue;
        }
        upstream_req = upstream_req.header(name, value);
    }

    // 7. Inject real credential from keyring
    upstream_req = upstream_req.header(
        &mapping.header_name,
        &mapping.format_credential()
    );

    // 8. Forward request
    let response = self.client.request(upstream_req.body(req.into_body())?).await?;

    // 9. Audit log
    self.audit_log(AuditEntry {
        timestamp: SystemTime::now(),
        service: service.clone(),
        method: req.method().to_string(),
        path: path.to_string(),
        status: response.status().as_u16(),
        mode: ProxyMode::ReverseProxy,
    });

    response
}

Request Flow: External Proxy Mode (Enterprise Passthrough)

async fn handle_external_proxy(
    req: Request,
    stream: TcpStream,
    upstream_proxy: &ProxyConfig,
) -> Result<()> {
    let target_host = req.uri().authority()?.host();
    let target_port = req.uri().authority()?.port_u16().unwrap_or(443);

    // 1. default_deny already checked in handle_connection() dispatcher

    // 2. NO allowlist check  -  enterprise proxy owns filtering decisions

    // 3. Connect to enterprise proxy
    let mut proxy_stream = TcpStream::connect(&upstream_proxy.address).await?;

    // 4. Send CONNECT to enterprise proxy (chain the tunnel)
    let connect_req = format!(
        "CONNECT {}:{} HTTP/1.1\r\nHost: {}:{}\r\n",
        target_host, target_port, target_host, target_port
    );

    // 5. If enterprise proxy requires authentication, inject credentials
    let auth_header = match &upstream_proxy.auth {
        Some(ProxyAuth::Basic { username, password }) => {
            let encoded = base64::encode(format!("{}:{}", username, password));
            format!("Proxy-Authorization: Basic {}\r\n", encoded)
        }
        None => String::new(),
    };

    proxy_stream.write_all(
        format!("{}{}\r\n", connect_req, auth_header).as_bytes()
    ).await?;

    // 6. Read enterprise proxy response
    let proxy_response = read_http_response(&mut proxy_stream).await?;
    if proxy_response.status() != 200 {
        // Enterprise proxy rejected the connection  -  relay its response to agent
        self.audit_log(AuditEntry::external_proxy_rejected(
            target_host, proxy_response.status()
        ));
        return send_response(&stream, proxy_response.status(), "Blocked by upstream proxy").await;
    }

    // 7. Send 200 Connection Established to agent
    send_response(&stream, 200, "Connection Established").await?;

    // 8. Bidirectional tunnel: agent <-> enterprise proxy <-> upstream
    tokio::io::copy_bidirectional(&mut stream, &mut proxy_stream).await?;

    // 9. Audit log
    self.audit_log(AuditEntry {
        timestamp: SystemTime::now(),
        host: target_host.to_string(),
        port: target_port,
        mode: ProxyMode::ExternalProxy,
        upstream_proxy: upstream_proxy.address.clone(),
    });

    Ok(())
}

Enterprise proxy authentication: The external_proxy configuration supports optional Proxy-Authorization for proxies that require it. Credentials for the enterprise proxy itself are loaded from the keyring (separate from service API credentials):

{
  "profiles": {
    "enterprise-passthrough": {
      "external_proxy": "http://squid.corp.internal:3128",
      "external_proxy_auth": {
        "keyring": "corp_proxy_credentials",
        "scheme": "basic"
      },
      "credentials": ["anthropic", "openai"]
    }
  }
}

Host Allowlist Implementation

The allowlist supports exact matches, wildcard subdomains, and CIDR blocks:

pub struct HostFilter {
    exact: HashSet<String>,           // "api.openai.com"
    wildcard: Vec<String>,            // "*.googleapis.com" → ".googleapis.com"
    cidr_deny: Vec<IpNet>,            // 10.0.0.0/8, 192.168.0.0/16
}

impl HostFilter {
    pub fn matches(&self, host: &str) -> bool {
        // Exact match
        if self.exact.contains(host) {
            return true;
        }

        // Wildcard subdomain match
        // "*.googleapis.com" matches "storage.googleapis.com"
        // but NOT "googleapis.com" itself
        for suffix in &self.wildcard {
            if host.ends_with(suffix) && host.len() > suffix.len() {
                return true;
            }
        }

        false
    }

    pub fn is_denied(&self, host: &str) -> bool {
        // Check if host resolves to a denied CIDR
        // This prevents DNS rebinding attacks
        if let Ok(addrs) = resolve_host(host) {
            for addr in addrs {
                for cidr in &self.cidr_deny {
                    if cidr.contains(&addr) {
                        return true;
                    }
                }
            }
        }
        false
    }
}

Default Deny List

The proxy includes a default deny list that cannot be overridden:

const DEFAULT_DENY: &[&str] = &[
    // Cloud metadata endpoints (SSRF targets)
    "169.254.169.254",           // AWS/Azure/GCP metadata
    "metadata.google.internal",
    "metadata.azure.internal",

    // RFC1918 private networks
    "10.0.0.0/8",
    "172.16.0.0/12",
    "192.168.0.0/16",

    // Link-local
    "169.254.0.0/16",

    // Loopback (except our proxy port)
    "127.0.0.0/8",  // We'll carve out exception for proxy

    // IPv6 equivalents
    "::1/128",
    "fc00::/7",
    "fe80::/10",
];

Sandbox Configuration

When proxy mode is enabled, the sandbox allows ONLY localhost connections to the proxy port:

// In capability.rs
pub struct NetworkCapability {
    pub mode: NetworkMode,
}

pub enum NetworkMode {
    Blocked,                              // Current default
    AllowAll,                             // --allow-net
    ProxyOnly { port: u16 },              // Nono proxy: only localhost:port
    ExternalProxy { port: u16 },          // Enterprise proxy: same sandbox, chain upstream
}

Linux Network Filtering (Landlock ABI v4+)

// In sandbox/linux.rs
match caps.network_mode() {
    NetworkMode::Blocked => {
        // Block all TCP (current behavior)
        ruleset = ruleset.handle_access(AccessNet::from_all(TARGET_ABI))?;
    }
    NetworkMode::AllowAll => {
        // Don't add network to handled access (permit all)
    }
    NetworkMode::ProxyOnly { port } => {
        // Handle all network access (deny by default)
        ruleset = ruleset.handle_access(AccessNet::from_all(TARGET_ABI))?;

        // Allow only TCP connect to localhost:port
        // Note: Landlock v4 doesn't filter by IP, only by port
        // The proxy itself enforces localhost-only binding
        ruleset = ruleset.add_rule(
            NetPort::new(Port::Tcp(*port), AccessNet::ConnectTcp)
        )?;
    }
}

Landlock limitation: Landlock v4 network rules filter by port, not by destination IP. The sandbox allows connecting to port on any IP. The proxy binds to 127.0.0.1 only, so external connections to that port will fail at the TCP level (no listener). This is defense in depth, not the primary enforcement.

macOS Network Filtering (Seatbelt)

Seatbelt can filter by both IP and port:

;; In generated SBPL profile
(deny network*)

;; Allow only localhost connections to proxy port
(allow network-outbound
  (remote tcp "localhost:54321"))

This is tighter than Landlock - macOS enforces both destination IP and port.

Credential Storage and Backends

Credential loading is abstracted behind the library's existing keystore module, which provides a CredentialBackend trait. The proxy calls through this trait - it never assumes the system keyring is the only source.

/// Library trait (crates/nono/src/keystore.rs)
pub trait CredentialBackend: Send + Sync {
    fn load(&self, account: &str) -> Result<Zeroizing<String>, KeystoreError>;
}

The default backend is the OS system keyring (macOS Keychain, Linux Secret Service / libsecret). Enterprise deployments can swap in alternative backends:

Backend Use Case
System keyring (default) Individual developer workstations
HashiCorp Vault Centralized secret management, dynamic credentials, lease-based rotation
AWS Secrets Manager / KMS AWS-native deployments, IAM-based access control
Azure Key Vault Azure-native deployments, managed identity integration
GCP Secret Manager GCP-native deployments, workload identity
PKCS#11 / Hardware HSM Air-gapped environments, hardware-bound keys, compliance requirements
Environment variable CI/CD pipelines where secrets are injected by the runner
File-based (encrypted) Offline / self-hosted environments without a secret manager

The backend is selected in the network policy credential configuration:

{
  "credentials": {
    "openai": {
      "backend": "keyring",
      "account": "openai_api_key",
      "upstream": "https://api.openai.com",
      "header": "Authorization",
      "format": "Bearer {}"
    },
    "anthropic_vault": {
      "backend": "vault",
      "path": "secret/data/ai/anthropic",
      "field": "api_key",
      "upstream": "https://api.anthropic.com",
      "header": "x-api-key",
      "format": "{}"
    },
    "github_aws": {
      "backend": "aws-secrets-manager",
      "secret_id": "prod/github-token",
      "upstream": "https://api.github.com",
      "header": "Authorization",
      "format": "token {}"
    }
  }
}

When "backend" is omitted, it defaults to "keyring" for backward compatibility. The account/path/secret_id field names vary by backend - each backend defines its own required fields.

Credential lifecycle: Backends that support lease-based credentials (Vault, cloud KMS) can return a TTL alongside the secret. The proxy tracks expiry and re-fetches before the credential expires, without restarting the session.

struct ProxyCredential {
    service_name: String,
    backend: Box<dyn CredentialBackend>,
    backend_key: String,                // "openai_api_key", "secret/data/ai/anthropic", etc.
    upstream: Url,
    header_name: String,
    header_format: String,
    credential: Zeroizing<String>,      // Current credential, zeroized on drop
    expires_at: Option<SystemTime>,     // TTL from backend, if applicable
}

impl ProxyCredential {
    fn credential_header(&self) -> String {
        self.header_format.replace("{}", &self.credential)
    }

    fn is_expired(&self) -> bool {
        self.expires_at
            .map(|t| SystemTime::now() >= t)
            .unwrap_or(false)
    }

    fn refresh(&mut self) -> Result<(), ProxyError> {
        self.credential = self.backend.load(&self.backend_key)?;
        // Re-zeroize handled by Zeroizing<String> drop on old value
        Ok(())
    }
}

Security Considerations

Proxy Token Validation

Every request must be validated:

fn validate_proxy_token(&self, req: &Request) -> Result<(), ProxyError> {
    let token = self.extract_token(req)?;

    // Constant-time comparison to prevent timing attacks
    if !constant_time_eq(token.as_bytes(), self.session_token.as_bytes()) {
        return Err(ProxyError::InvalidToken);
    }

    Ok(())
}

Rate Limiting

The proxy should enforce rate limits to prevent abuse:

struct RateLimiter {
    requests_per_second: f64,
    burst: usize,
    tokens: AtomicU64,
    last_update: AtomicU64,
}

Rate limits can be per-service or global.

Request/Response Logging

Audit all requests without logging sensitive data:

struct AuditEntry {
    timestamp: SystemTime,
    service: String,
    method: String,
    path: String,           // Path only, no query params (may contain secrets)
    status: u16,
    duration_ms: u64,
    request_size: usize,
    response_size: usize,
}

Never log:

  • Authorization headers (real or proxy token)
  • Query parameters (may contain API keys in some services)
  • Request/response bodies

Host Header Injection

The proxy must not trust the Host header for routing decisions. Route only by URL path prefix:

// WRONG: Attacker controls Host header
let upstream = match req.headers().get("Host") { ... }

// CORRECT: Route by path prefix only
let (service, _) = parse_service_prefix(req.uri().path())?;
let upstream = self.credentials.get(&service)?.upstream;

Upstream TLS Verification

The proxy's HTTP client must verify TLS certificates:

let client = reqwest::Client::builder()
    .danger_accept_invalid_certs(false)  // NEVER set to true
    .min_tls_version(reqwest::tls::Version::TLS_1_2)
    .build()?;

Memory Protection

Real credentials are kept in Zeroizing<String> and never logged or serialized.

Process Model Integration

The proxy runs in the supervisor's event loop alongside existing functionality:

Supervisor (parent, unsandboxed)
  │
  ├── Proxy HTTP server (tokio/hyper)
  │     - Listens on 127.0.0.1:<random>
  │     - Validates proxy tokens
  │     - Swaps credentials
  │     - Forwards to upstream
  │
  ├── Seccomp-notify handler (Linux)
  │     - fd injection for file access
  │     - approval prompts
  │
  ├── Filesystem watcher (if --undo)
  │     - inotify/FSEvents
  │     - incremental snapshots
  │
  └── waitpid() for child
        - signal forwarding
        - diagnostic footer on error

Async Runtime Considerations

The proxy requires async I/O for HTTP handling. Options:

Option A: Dedicated proxy thread with tokio runtime

let proxy_handle = std::thread::spawn(|| {
    let rt = tokio::runtime::Builder::new_current_thread()
        .enable_all()
        .build()?;
    rt.block_on(run_proxy_server(config))
});

Option B: Blocking proxy with thread pool

// Use std::net::TcpListener + thread pool
// Simpler but less efficient

Recommendation: Option A. The proxy handles potentially long-lived HTTP requests (streaming responses from LLMs), so async is appropriate. The supervisor's existing poll(2) loop can coexist with a separate tokio thread.

Implementation Phases

Phase 1: CONNECT Proxy (Filtering Only)

Primary goal: host-based network filtering without any credential handling.

  1. Add NetworkMode::ProxyOnly { port } to library CapabilitySet
  2. Implement CONNECT proxy handler with host allowlist
  3. Default deny list for private networks and metadata endpoints
  4. CLI flags: --proxy-allow <host>, --proxy-deny <host>
  5. Platform sandbox rules for localhost-only
    • Linux: Landlock TCP port rule
    • macOS: Seatbelt (allow network-outbound (remote tcp "localhost:PORT"))
  6. Inject HTTP_PROXY / HTTPS_PROXY env vars to child
  7. Audit logging for allowed/blocked connections

Deliverable: Agents can only reach explicitly allowed hosts. No credential handling yet.

Phase 2: Reverse Proxy Mode (Credential Injection)

Secondary goal: isolate credentials from agent environment.

  1. Add reverse proxy handler for path-prefix routing
  2. Session token generation and validation
  3. Credential loading from keyring via existing keystore module
  4. Header injection logic
  5. CLI flags: --proxy-credential <service>=<keyring-account>:<upstream>
  6. Network policy credentials section in network-policy.json
  7. Inject SDK-specific base URL env vars (OPENAI_BASE_URL, etc.)

Deliverable: Credentials loaded from keyring, never exposed to agent.

Phase 3: Production Hardening

  1. Streaming response support (SSE, chunked transfer)
  2. WebSocket upgrade handling
  3. Rate limiting (per-host or global)
  4. Request/response size limits
  5. Connection pooling for upstream
  6. Graceful shutdown (drain connections)

Phase 4: Documentation and Testing

  1. Integration tests with real API clients
  2. Claude Code profile update for proxy mode
  3. Documentation for agent configuration
  4. Error messages when proxy unreachable or host blocked

Alternative Considered: Environment Variable Isolation

Instead of a proxy, use process-level environment isolation:

# Parent reads from keyring, sets in child env
OPENAI_API_KEY=sk-real-key claude

Why rejected:

  • Credentials visible in /proc/<pid>/environ
  • Credentials in child's memory can be dumped
  • No audit trail of API usage
  • No rate limiting or scope restriction
  • Child can use credentials for any purpose

The proxy approach provides defense in depth: even if the agent is compromised, it cannot extract the real credentials or use them outside the proxy's controlled routing.

Transport Security: Unix Socket vs TCP + Session Token

The proxy transport must prevent unauthorized local processes from accessing it. Two approaches were evaluated.

Option A: Unix Domain Socket

The supervisor already uses UnixStream::pair() for IPC (see exec_strategy.rs). The API proxy could use the same mechanism: bind a UnixListener to a temporary path, pass the path or fd to the child. Unix sockets support SO_PEERCRED/LOCAL_PEERPID for peer authentication -- the proxy can verify that connecting processes are the sandboxed child or its descendants.

Advantages:

  • PID-based authentication (same mechanism as supervisor socket)
  • No port to manage or discover
  • Filesystem permissions for access control
  • No bearer token needed

Problem: SDK compatibility. The http+unix:// URL scheme is non-standard. Major LLM SDKs do not support it:

SDK HTTP Library Unix Socket Support
Anthropic Python httpx Only via explicit transport config, not URL scheme
OpenAI Python httpx Same -- transport-level only
Node.js SDKs fetch/node-fetch No URL-based support; http.Agent has socketPath but SDKs don't expose it
curl libcurl --unix-socket flag, not URL scheme

Setting ANTHROPIC_BASE_URL=http+unix:///path/to/sock would fail URL validation in every major SDK. A TCP-to-Unix bridge in front defeats the purpose -- it reintroduces the TCP listener as attack surface.

Option B: Localhost TCP + Cryptographic Session Token (Selected)

The supervisor binds to 127.0.0.1:0 (OS-assigned ephemeral port), generates a cryptographic session token, and passes both to the child via environment variables.

ANTHROPIC_BASE_URL=http://127.0.0.1:<port>/anthropic
NONO_PROXY_TOKEN=<64-char-hex>

Every SDK accepts standard HTTP URLs. The session token is validated on every request via constant-time comparison. The token is:

  • 32 bytes of cryptographic randomness (256 bits of entropy)
  • Generated fresh per session, never persisted to disk
  • Stored only in supervisor memory and child environment
  • Required for reverse proxy mode (credential injection)

Threat model: An attacker on the same machine would need to discover both the random ephemeral port AND the session token. The token is only in the child's environment -- on Linux, /proc/*/environ access is restricted by the sandbox. On macOS, the process environment is not world-readable.

Future: Unix Socket as Opt-In

Unix socket transport is supported as an opt-in for clients that can handle it (custom agents, curl scripts, future SDK versions). The proxy listens on both transports simultaneously when configured:

This is configured in the network policy profile:

{
  "profiles": {
    "claude-code": {
      "groups": ["llm_apis", "package_registries", "github"],
      "credentials": ["anthropic", "openai"],
      "transport": "tcp"
    }
  }
}

Valid values: "tcp" (default, session token required), "unix" (Unix socket only), "both" (both transports, Unix preferred).

If SDK support for Unix socket HTTP becomes common (Mozilla Bug 1688774, httpx already has transport-level plumbing), the default can be switched. The proxy implementation is transport-agnostic -- this is a one-line config change.

DNS Rebinding Protection

A malicious agent could try to bypass host filtering via DNS rebinding:

  1. Agent requests CONNECT attacker.com:443
  2. Proxy checks allowlist: attacker.com not allowed, but...
  3. Attacker's DNS returns 169.254.169.254 (metadata endpoint)
  4. Proxy establishes connection to metadata endpoint

Mitigation: The proxy resolves DNS and checks the resolved IP against the deny list before establishing the upstream connection:

async fn handle_connect(req: Request) -> Result<()> {
    let host = req.uri().host()?;

    // 1. Check hostname against allowlist
    if !self.allow_list.matches(host) {
        return Err(ProxyError::HostNotAllowed);
    }

    // 2. Resolve DNS
    let addrs: Vec<IpAddr> = tokio::net::lookup_host(host).await?
        .map(|a| a.ip())
        .collect();

    // 3. Check ALL resolved IPs against deny list
    for addr in &addrs {
        if self.deny_list.contains(addr) {
            self.audit_log(AuditEntry::dns_rebind_blocked(host, addr));
            return Err(ProxyError::DnsRebindingBlocked);
        }
    }

    // 4. Connect to first allowed address
    // ...
}

DNS Leakage

Even with the proxy, the agent process may perform DNS lookups directly (before deciding to use the proxy). This could leak information about what hosts the agent is trying to reach.

Options:

  1. Accept the leak: DNS is metadata only, actual connections are filtered
  2. Block DNS in sandbox: Complex, may break other things
  3. Force DNS through proxy: Some proxy protocols support this (SOCKS5), HTTP CONNECT does not

Recommendation: Accept DNS leakage for v1. The security goal is preventing data exfiltration and unauthorized API access, not preventing DNS metadata leakage.

Open Questions

  1. Streaming responses: LLM APIs often use SSE/streaming. The proxy must handle streaming responses efficiently (chunked transfer, not buffering entire response).

  2. WebSocket support: Some APIs (Anthropic realtime?) may use WebSockets. The proxy would need CONNECT upgrade handling.

  3. Request body limits: Should the proxy enforce request body size limits? Large bodies (file uploads) may need special handling.

  4. Multiple credentials per service: What if an agent needs two different OpenAI keys (different orgs)? Path could be /openai-org1/... and /openai-org2/....

  5. Non-HTTP protocols: What about gRPC, or raw TCP to databases? CONNECT mode only works for TLS-wrapped protocols. Agents needing raw TCP would require additional work.

  6. Certificate pinning: Some SDKs pin certificates. Would they work through the proxy? CONNECT mode should be fine (end-to-end TLS). Reverse proxy mode would fail certificate checks.

  7. Enterprise proxy TLS inspection: Corporate proxies often perform TLS interception (MITM) with a corporate CA. In external proxy mode, the agent's TLS stack would need to trust the corporate CA certificate. Should nono provide a mechanism to inject the corporate CA bundle into the child's environment (SSL_CERT_FILE, REQUESTS_CA_BUNDLE)?

  8. External proxy health checks: If the enterprise proxy is unreachable at startup, should nono fail hard (refuse to start) or fall back to blocked mode? Fail-hard is safer - silent degradation could leave agents with no network when they expect it.

  9. PAC file support: Some enterprises use Proxy Auto-Configuration (PAC) files. Should nono support reading a PAC file to determine the upstream proxy? This adds complexity but is common in enterprise environments.

  10. Backend discovery and registration: Should credential backends be statically compiled in, or should there be a plugin mechanism (shared library / subprocess) for custom backends? Static is simpler and more secure; plugin introduces a trust boundary.

References

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment