diff --git a/.codex/swarm/lanes.tsv b/.codex/swarm/lanes.tsv index 4c38157ec..cdf20de7e 100644 --- a/.codex/swarm/lanes.tsv +++ b/.codex/swarm/lanes.tsv @@ -1,10 +1,12 @@ lane worktree branch profile role brief_id description docs bootstrap -orch huntronomer-workspace-orch feature/huntronomer-workspace-orchestrator swarm-orchestrator workstream_orchestrator ORCH Workspace-shell orchestrator lane for metadata, shared shell wiring, merge sequencing, and capability policy docs/plans/clawdstrike/huntronomer/workspace-shell/swarm-plan.md,docs/plans/clawdstrike/huntronomer/workspace-shell/roadmap.md,docs/specs/17-huntronomer-workspace-services.md,docs/specs/18-huntronomer-shell-command-model.md cd apps/desktop && bun install --frozen-lockfile && cargo fetch --locked --manifest-path src-tauri/Cargo.toml -ws1 ws1-workspace-core feature/huntronomer-ws-core swarm-worker lane_worker WS1 Workspace-core lane for trusted roots, canonical paths, filesystem contracts, and settings persistence docs/plans/clawdstrike/huntronomer/workspace-shell/swarm-plan.md,docs/plans/clawdstrike/huntronomer/workspace-shell/target-architecture.md,docs/specs/17-huntronomer-workspace-services.md cd apps/desktop && bun install --frozen-lockfile && cargo fetch --locked --manifest-path src-tauri/Cargo.toml -ws2 ws2-search-watch feature/huntronomer-ws-search-watch swarm-worker lane_worker WS2 Watcher and search lane for notify, fd, rg, and allowlisted sidecar process management docs/plans/clawdstrike/huntronomer/workspace-shell/swarm-plan.md,docs/plans/clawdstrike/huntronomer/workspace-shell/roadmap.md,docs/specs/17-huntronomer-workspace-services.md cd apps/desktop && bun install --frozen-lockfile && cargo fetch --locked --manifest-path src-tauri/Cargo.toml -ws3 ws3-workspace-shell feature/huntronomer-ws-shell-ui swarm-worker lane_worker WS3 Workspace-shell UI lane for route scaffolding, tree layout, breadcrumbs, pane state, and command entry points docs/plans/clawdstrike/huntronomer/workspace-shell/swarm-plan.md,docs/plans/clawdstrike/huntronomer/workspace-shell/target-architecture.md,docs/specs/18-huntronomer-shell-command-model.md cd apps/desktop && bun install --frozen-lockfile -ws4 ws4-monaco-editor feature/huntronomer-ws-monaco swarm-worker lane_worker WS4 Monaco editor lane for buffer models, tab flows, save and reload behavior, and editor tests docs/plans/clawdstrike/huntronomer/workspace-shell/swarm-plan.md,docs/plans/clawdstrike/huntronomer/workspace-shell/roadmap.md,docs/specs/17-huntronomer-workspace-services.md cd apps/desktop && bun install --frozen-lockfile -ws5 ws5-terminal-pty feature/huntronomer-ws-terminal swarm-worker lane_worker WS5 Terminal lane for PTY session lifecycle, xterm integration, resize behavior, and task-versus-shell sessions docs/plans/clawdstrike/huntronomer/workspace-shell/swarm-plan.md,docs/plans/clawdstrike/huntronomer/workspace-shell/target-architecture.md,docs/specs/17-huntronomer-workspace-services.md cd apps/desktop && bun install --frozen-lockfile && cargo fetch --locked --manifest-path src-tauri/Cargo.toml -ws6 ws6-search-git-ui feature/huntronomer-ws-search-git-ui swarm-worker lane_worker WS6 Search and git UX lane for quick-open, content search, git status, diff summaries, and editor deep links docs/plans/clawdstrike/huntronomer/workspace-shell/swarm-plan.md,docs/plans/clawdstrike/huntronomer/workspace-shell/roadmap.md,docs/specs/17-huntronomer-workspace-services.md cd apps/desktop && bun install --frozen-lockfile -ws7 ws7-language-client feature/huntronomer-ws-language-client swarm-worker lane_worker WS7 Language-intelligence lane for monaco-languageclient, language-server supervision, diagnostics, and symbol navigation docs/plans/clawdstrike/huntronomer/workspace-shell/swarm-plan.md,docs/plans/clawdstrike/huntronomer/workspace-shell/roadmap.md,docs/specs/17-huntronomer-workspace-services.md,docs/specs/18-huntronomer-shell-command-model.md cd apps/desktop && bun install --frozen-lockfile && cargo fetch --locked --manifest-path src-tauri/Cargo.toml -ws8 ws8-release-verify feature/huntronomer-ws-release-verify swarm-worker lane_worker WS8 Persistence and release-hardening lane for session recall, packaging checks, smoke verification, and optional index evaluation docs/plans/clawdstrike/huntronomer/workspace-shell/swarm-plan.md,docs/plans/clawdstrike/huntronomer/workspace-shell/roadmap.md,docs/specs/17-huntronomer-workspace-services.md,docs/specs/18-huntronomer-shell-command-model.md cd apps/desktop && bun install --frozen-lockfile && cargo fetch --locked --manifest-path src-tauri/Cargo.toml +ORCH macos-es-ne-orch feature/macos-es-ne-orchestrator swarm-orchestrator workstream_orchestrator ORCH Orchestrator lane for the macOS EndpointSecurity and NetworkExtension implementation wave; owns shared metadata, architecture exceptions, merge order, and final consolidation docs/plans/clawdstrike/macos-es-ne/swarm-plan.md,docs/plans/clawdstrike/macos-es-ne/network-extension-provider-and-topology.md,docs/plans/clawdstrike/macos-es-ne/endpoint-security-auth-contract.md,docs/plans/clawdstrike/macos-es-ne/deployment-and-verification.md,docs/plans/multi-agent/codex-swarm-playbook.md cargo-fetch-locked +HOST macos-es-ne-host-foundation feature/macos-es-ne-host-foundation swarm-worker lane_worker HOST Containing-app foundation lane for apps/agent macOS host modules, combined-system-extension lifecycle hooks, and frozen local IPC contract docs/plans/clawdstrike/macos-es-ne/swarm-plan.md,docs/plans/clawdstrike/macos-es-ne/network-extension-provider-and-topology.md,docs/plans/clawdstrike/macos-es-ne/deployment-and-verification.md cargo-fetch-agent-locked +RHOST macos-es-ne-host-review feature/macos-es-ne-host-review swarm-review merge_reviewer RHOST Review lane for HOST focused on ownership violations, contract drift, degraded-state handling, and missing verification docs/plans/clawdstrike/macos-es-ne/swarm-plan.md,docs/plans/clawdstrike/macos-es-ne/deployment-and-verification.md cargo-fetch-agent-locked +POLAT macos-es-ne-policy-attest feature/macos-es-ne-policy-attest swarm-worker lane_worker POLAT Policy and attestation lane for the frozen macOS runtime contract, ES fail-open semantics, and receipt schema changes docs/plans/clawdstrike/macos-es-ne/swarm-plan.md,docs/plans/clawdstrike/macos-es-ne/network-extension-provider-and-topology.md,docs/plans/clawdstrike/macos-es-ne/endpoint-security-auth-contract.md,docs/plans/clawdstrike/macos-es-ne/deployment-and-verification.md,docs/nono-integration/04-policy-translation.md,docs/nono-integration/06-receipt-attestation.md cargo-fetch-locked +RPOLAT macos-es-ne-policy-review feature/macos-es-ne-policy-review swarm-review merge_reviewer RPOLAT Review lane for POLAT focused on contract integrity, degraded-state truthfulness, test coverage, and merge risk docs/plans/clawdstrike/macos-es-ne/swarm-plan.md,docs/plans/clawdstrike/macos-es-ne/endpoint-security-auth-contract.md,docs/plans/clawdstrike/macos-es-ne/deployment-and-verification.md,docs/nono-integration/04-policy-translation.md,docs/nono-integration/06-receipt-attestation.md cargo-fetch-locked +ESINT macos-es-ne-es-integration feature/macos-es-ne-es-integration swarm-worker lane_worker ESINT EndpointSecurity implementation lane for the combined-system-extension ES subtree; must drive host macOS status plus attestation provider_states and deadline counters truthfully docs/plans/clawdstrike/macos-es-ne/swarm-plan.md,docs/plans/clawdstrike/macos-es-ne/endpoint-security-auth-contract.md,docs/plans/clawdstrike/macos-es-ne/deployment-and-verification.md cargo-fetch-agent-locked +NEINT macos-es-ne-ne-integration feature/macos-es-ne-ne-integration swarm-worker lane_worker NEINT NetworkExtension implementation lane for the combined-system-extension NE subtree; must drive host and attestation provider state with the content-filter baseline while preserving actual backend reporting docs/plans/clawdstrike/macos-es-ne/swarm-plan.md,docs/plans/clawdstrike/macos-es-ne/network-extension-provider-and-topology.md,docs/plans/clawdstrike/macos-es-ne/deployment-and-verification.md,docs/nono-integration/04-policy-translation.md cargo-fetch-agent-locked +RESINT macos-es-ne-es-review feature/macos-es-ne-es-review swarm-review merge_reviewer RESINT Review lane for ESINT focused on contract adherence, deadline/fail-open semantics, host and receipt degraded-state truthfulness, and verification evidence docs/plans/clawdstrike/macos-es-ne/swarm-plan.md,docs/plans/clawdstrike/macos-es-ne/endpoint-security-auth-contract.md,docs/plans/clawdstrike/macos-es-ne/deployment-and-verification.md cargo-fetch-agent-locked +RNEINT macos-es-ne-ne-review feature/macos-es-ne-ne-review swarm-review merge_reviewer RNEINT Review lane for NEINT focused on provider choice, backend-truthful network reporting, degraded-state handling, and verification evidence docs/plans/clawdstrike/macos-es-ne/swarm-plan.md,docs/plans/clawdstrike/macos-es-ne/network-extension-provider-and-topology.md,docs/plans/clawdstrike/macos-es-ne/deployment-and-verification.md cargo-fetch-agent-locked +PKG macos-es-ne-pkg-sign feature/macos-es-ne-pkg-sign swarm-worker lane_worker PKG MacOS packaging lane for the combined system extension, entitlements, signing, notarization, and CI release wiring in apps/agent docs/plans/clawdstrike/macos-es-ne/swarm-plan.md,docs/plans/clawdstrike/macos-es-ne/network-extension-provider-and-topology.md,docs/plans/clawdstrike/macos-es-ne/deployment-and-verification.md cargo-fetch-agent-locked +RPKG macos-es-ne-pkg-review feature/macos-es-ne-pkg-review swarm-review merge_reviewer RPKG Review lane for PKG focused on deployment-model correctness, signing integrity, denied-path coverage, and release pipeline regressions docs/plans/clawdstrike/macos-es-ne/swarm-plan.md,docs/plans/clawdstrike/macos-es-ne/deployment-and-verification.md cargo-fetch-agent-locked diff --git a/.codex/swarm/waves.tsv b/.codex/swarm/waves.tsv index 2c78b5b41..5b57fd4a7 100644 --- a/.codex/swarm/waves.tsv +++ b/.codex/swarm/waves.tsv @@ -1,7 +1,11 @@ wave lanes -wave0 orch -wave1 ws1,ws3 -wave2 ws2,ws4 -wave3 ws5,ws6 -wave4 ws7 -wave5 ws8 +wave0 ORCH +wave1 HOST +wave2 RHOST +wave3 POLAT +wave4 RPOLAT +wave5 ESINT,NEINT +wave6 RESINT,RNEINT +wave7 PKG +wave8 RPKG +wave9 ORCH diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 35edd12b2..6bb4b1d7f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -245,8 +245,27 @@ jobs: - name: Check desktop Tauri crate run: cargo check --manifest-path apps/desktop/src-tauri/Cargo.toml - - name: Check agent Tauri crate - run: cargo check --manifest-path apps/agent/src-tauri/Cargo.toml + - name: Check agent macOS packaging sources + shell: bash + run: | + set -euo pipefail + + required=( + apps/agent/src-tauri/macos/system-extension/entitlements/agent-app.entitlements + apps/agent/src-tauri/macos/system-extension/entitlements/combined-system-extension.entitlements + apps/agent/src-tauri/macos/system-extension/plists/agent-packaging-template.plist + apps/agent/src-tauri/macos/system-extension/plists/combined-system-extension-template.plist + apps/agent/src-tauri/macos/system-extension/profiles/developer-id-profile-template.plist + ) + + for path in "${required[@]}"; do + [[ -f "$path" ]] || { + echo "missing macOS packaging asset: $path" >&2 + exit 1 + } + done + + CLAWDSTRIKE_VALIDATE_MACOS_PACKAGING=1 cargo check --manifest-path apps/agent/src-tauri/Cargo.toml desktop-frontend: name: Desktop Frontend diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 42740a407..b6c45270f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -19,7 +19,7 @@ env: CARGO_TERM_COLOR: always concurrency: - group: release-${{ github.ref_name || inputs.version }} + group: release-${{ github.workflow }}-${{ github.ref_name }} cancel-in-progress: false jobs: @@ -70,6 +70,36 @@ jobs: - name: Validate version consistency run: scripts/release-preflight.sh "${{ needs.resolve-version.outputs.version }}" + - name: Validate macOS packaging sources are release-capable + shell: bash + run: | + set -euo pipefail + + required=( + apps/agent/src-tauri/macos/system-extension/entitlements/agent-app.entitlements + apps/agent/src-tauri/macos/system-extension/entitlements/combined-system-extension.entitlements + apps/agent/src-tauri/macos/system-extension/plists/agent-packaging-template.plist + apps/agent/src-tauri/macos/system-extension/plists/combined-system-extension-template.plist + apps/agent/src-tauri/macos/system-extension/profiles/developer-id-profile-template.plist + ) + + for path in "${required[@]}"; do + [[ -f "$path" ]] || { + echo "missing macOS packaging asset: $path" >&2 + exit 1 + } + done + + if grep -R -nE "__[A-Z0-9_]+__" apps/agent/src-tauri/macos/system-extension; then + echo "macOS combined-system-extension packaging still contains placeholders; replace them before release." >&2 + exit 1 + fi + + if grep -R -n "scaffold_only" apps/agent/src-tauri/macos/system-extension; then + echo "macOS packaging sources still declare scaffold_only state; release requires concrete source metadata plus a real embedded system extension bundle." >&2 + exit 1 + fi + - name: Run tests run: cargo test --workspace @@ -710,6 +740,14 @@ jobs: name: Build Agent DMG runs-on: macos-latest needs: preflight + env: + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} + NOTARYTOOL_PROFILE: ${{ secrets.NOTARYTOOL_PROFILE }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} + CLAWDSTRIKE_REQUIRE_CONCRETE_MACOS_PACKAGING: "1" + NOTARIZE_OUT_DIR: ${{ github.workspace }}/.github-artifacts/clawdstrike-notarization steps: - uses: actions/checkout@v6 @@ -723,9 +761,8 @@ jobs: - name: Install tauri-cli run: cargo install tauri-cli --locked --version '^2' - - name: Build agent DMG bundle - working-directory: apps/agent - run: cargo tauri build --bundles dmg + - name: Build and notarize agent app bundle + run: bash scripts/notarize-agent-macos.sh - name: Upload agent DMG artifact uses: actions/upload-artifact@v6 @@ -734,6 +771,14 @@ jobs: path: apps/agent/src-tauri/target/release/bundle/dmg/*.dmg if-no-files-found: error + - name: Upload notarization evidence + if: always() + uses: actions/upload-artifact@v6 + with: + name: clawdstrike-agent-notarization + path: ${{ env.NOTARIZE_OUT_DIR }}/ + if-no-files-found: ignore + create-release: name: Create GitHub Release runs-on: ubuntu-latest diff --git a/apps/agent/src-tauri/build.rs b/apps/agent/src-tauri/build.rs index d860e1e6a..8cd312e15 100644 --- a/apps/agent/src-tauri/build.rs +++ b/apps/agent/src-tauri/build.rs @@ -1,3 +1,147 @@ +use std::{env, fs, path::PathBuf}; + +const REQUIRED_MACOS_PACKAGING_FILES: &[&str] = &[ + "macos/system-extension/entitlements/agent-app.entitlements", + "macos/system-extension/entitlements/combined-system-extension.entitlements", + "macos/system-extension/plists/agent-packaging-template.plist", + "macos/system-extension/plists/combined-system-extension-template.plist", + "macos/system-extension/profiles/developer-id-profile-template.plist", +]; + +const TAURI_CONFIG_PATH: &str = "tauri.conf.json"; +const SCAFFOLD_ONLY_MARKER: &str = "scaffold_only"; +const REQUIRED_TAURI_CONFIG_SNIPPETS: &[&str] = &[ + "\"minimumSystemVersion\": \"13.0\"", + "\"macos/system-extension/**/*\"", + "\"entitlements\": \"macos/system-extension/entitlements/agent-app.entitlements\"", +]; + fn main() { + println!("cargo:rerun-if-changed={TAURI_CONFIG_PATH}"); + for relative_path in REQUIRED_MACOS_PACKAGING_FILES { + println!("cargo:rerun-if-changed={relative_path}"); + } + + if should_validate_macos_packaging() { + validate_macos_packaging() + .unwrap_or_else(|error| panic!("macOS packaging validation failed: {error}")); + } + tauri_build::build() } + +fn should_validate_macos_packaging() -> bool { + env::var("TARGET") + .map(|target| target.contains("apple-darwin")) + .unwrap_or(false) + || env::var_os("CLAWDSTRIKE_VALIDATE_MACOS_PACKAGING").is_some() +} + +fn validate_macos_packaging() -> Result<(), String> { + let manifest_dir = manifest_dir()?; + + let mut missing_files = Vec::new(); + for relative_path in REQUIRED_MACOS_PACKAGING_FILES { + if !manifest_dir.join(relative_path).is_file() { + missing_files.push((*relative_path).to_string()); + } + } + if !missing_files.is_empty() { + return Err(format!( + "missing required packaging assets: {}", + missing_files.join(", ") + )); + } + + let tauri_config = fs::read_to_string(manifest_dir.join(TAURI_CONFIG_PATH)) + .map_err(|error| format!("failed to read {TAURI_CONFIG_PATH}: {error}"))?; + let missing_config = REQUIRED_TAURI_CONFIG_SNIPPETS + .iter() + .filter(|snippet| !tauri_config.contains(**snippet)) + .copied() + .collect::>(); + if !missing_config.is_empty() { + return Err(format!( + "tauri.conf.json is missing required macOS packaging entries: {}", + missing_config.join(", ") + )); + } + + if env::var_os("CLAWDSTRIKE_REQUIRE_CONCRETE_MACOS_PACKAGING").is_some() { + let files_with_placeholders = REQUIRED_MACOS_PACKAGING_FILES + .iter() + .filter_map(|relative_path| { + fs::read_to_string(manifest_dir.join(relative_path)) + .ok() + .filter(|contents| contains_release_placeholder(contents)) + .map(|_| (*relative_path).to_string()) + }) + .collect::>(); + if !files_with_placeholders.is_empty() { + return Err(format!( + "release-gated packaging placeholders remain in: {}", + files_with_placeholders.join(", ") + )); + } + + let files_with_scaffold_marker = REQUIRED_MACOS_PACKAGING_FILES + .iter() + .filter_map(|relative_path| { + fs::read_to_string(manifest_dir.join(relative_path)) + .ok() + .filter(|contents| contents.contains(SCAFFOLD_ONLY_MARKER)) + .map(|_| (*relative_path).to_string()) + }) + .collect::>(); + if !files_with_scaffold_marker.is_empty() { + return Err(format!( + "release-gated packaging sources still declare scaffold_only state: {}", + files_with_scaffold_marker.join(", ") + )); + } + } + + Ok(()) +} + +fn manifest_dir() -> Result { + env::var("CARGO_MANIFEST_DIR") + .map(PathBuf::from) + .map_err(|error| format!("missing CARGO_MANIFEST_DIR: {error}")) +} + +fn contains_release_placeholder(contents: &str) -> bool { + let bytes = contents.as_bytes(); + let mut start = 0usize; + + while start + 3 < bytes.len() { + if bytes[start] != b'_' || bytes[start + 1] != b'_' { + start += 1; + continue; + } + + let mut cursor = start + 2; + let mut saw_placeholder_body = false; + while cursor < bytes.len() { + match bytes[cursor] { + b'A'..=b'Z' | b'0'..=b'9' | b'_' => { + saw_placeholder_body = true; + cursor += 1; + } + _ => break, + } + } + + if saw_placeholder_body + && cursor + 1 < bytes.len() + && bytes[cursor] == b'_' + && bytes[cursor + 1] == b'_' + { + return true; + } + + start += 1; + } + + false +} diff --git a/apps/agent/src-tauri/macos/system-extension/endpoint-security/Package.swift b/apps/agent/src-tauri/macos/system-extension/endpoint-security/Package.swift new file mode 100644 index 000000000..a53dbe945 --- /dev/null +++ b/apps/agent/src-tauri/macos/system-extension/endpoint-security/Package.swift @@ -0,0 +1,32 @@ +// swift-tools-version: 5.9 +import PackageDescription + +let package = Package( + name: "EndpointSecurityExtension", + platforms: [ + .macOS(.v13) + ], + products: [ + .library( + name: "EndpointSecurityExtension", + targets: ["EndpointSecurityExtension"] + ), + .executable( + name: "endpoint-security-status-tool", + targets: ["EndpointSecurityStatusTool"] + ) + ], + targets: [ + .target( + name: "EndpointSecurityExtension" + ), + .executableTarget( + name: "EndpointSecurityStatusTool", + dependencies: ["EndpointSecurityExtension"] + ), + .testTarget( + name: "EndpointSecurityExtensionTests", + dependencies: ["EndpointSecurityExtension"] + ) + ] +) diff --git a/apps/agent/src-tauri/macos/system-extension/endpoint-security/Sources/EndpointSecurityExtension/Models.swift b/apps/agent/src-tauri/macos/system-extension/endpoint-security/Sources/EndpointSecurityExtension/Models.swift new file mode 100644 index 000000000..6493643c7 --- /dev/null +++ b/apps/agent/src-tauri/macos/system-extension/endpoint-security/Sources/EndpointSecurityExtension/Models.swift @@ -0,0 +1,318 @@ +import Foundation + +public enum SystemExtensionInstallState: String, Codable, Equatable { + case unknown + case notInstalled = "not_installed" + case installed +} + +public enum SystemExtensionApproval: String, Codable, Equatable { + case unknown + case approved + case approvalBlocked = "approval_blocked" +} + +public enum ProviderApprovalStatus: String, Codable, Equatable { + case notRequired = "not_required" + case approved + case blocked + case missing + case unknown +} + +public enum ProviderAvailability: String, Codable, Equatable { + case unavailable + case inactive + case active + case degraded +} + +public struct HostProviderStatus: Codable, Equatable { + public var runtime: HostProviderRuntimeState + + public init(runtime: HostProviderRuntimeState) { + self.runtime = runtime + } +} + +public enum HostProviderRuntimeState: Equatable { + case unknown + case inactive + case active + case degraded(reason: String) +} + +extension HostProviderRuntimeState: Codable { + enum CodingKeys: String, CodingKey { + case state + case reason + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let state = try container.decode(String.self, forKey: .state) + switch state { + case "unknown": + self = .unknown + case "inactive": + self = .inactive + case "active": + self = .active + case "degraded": + self = .degraded(reason: try container.decode(String.self, forKey: .reason)) + default: + throw DecodingError.dataCorruptedError( + forKey: .state, + in: container, + debugDescription: "unsupported provider runtime state: \(state)" + ) + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + switch self { + case .unknown: + try container.encode("unknown", forKey: .state) + case .inactive: + try container.encode("inactive", forKey: .state) + case .active: + try container.encode("active", forKey: .state) + case .degraded(let reason): + try container.encode("degraded", forKey: .state) + try container.encode(reason, forKey: .reason) + } + } +} + +public struct HostEndpointSecurityStatusPatch: Codable, Equatable { + public var installState: SystemExtensionInstallState + public var approval: SystemExtensionApproval + public var endpointSecurity: HostProviderStatus + + public init( + installState: SystemExtensionInstallState, + approval: SystemExtensionApproval, + endpointSecurity: HostProviderStatus + ) { + self.installState = installState + self.approval = approval + self.endpointSecurity = endpointSecurity + } + + enum CodingKeys: String, CodingKey { + case installState = "install_state" + case approval + case endpointSecurity = "endpoint_security" + } +} + +public struct AttestationProviderState: Codable, Equatable { + public var provider: String + public var installed: Bool + public var approvalStatus: ProviderApprovalStatus + public var active: Bool + public var healthy: Bool + public var availability: ProviderAvailability + public var degradedReasons: [String] + public var lastHealthyTimestamp: String? + + public init( + provider: String, + installed: Bool, + approvalStatus: ProviderApprovalStatus, + active: Bool, + healthy: Bool, + availability: ProviderAvailability, + degradedReasons: [String], + lastHealthyTimestamp: String? + ) { + self.provider = provider + self.installed = installed + self.approvalStatus = approvalStatus + self.active = active + self.healthy = healthy + self.availability = availability + self.degradedReasons = degradedReasons + self.lastHealthyTimestamp = lastHealthyTimestamp + } + + enum CodingKeys: String, CodingKey { + case provider + case installed + case approvalStatus = "approval_status" + case active + case healthy + case availability + case degradedReasons = "degraded_reasons" + case lastHealthyTimestamp = "last_healthy_timestamp" + } +} + +public struct EndpointSecurityCounters: Codable, Equatable { + public var authOpenAllowCount: UInt64 + public var authOpenDenyCount: UInt64 + public var notifyOpenCount: UInt64 + public var deadlineMissCount: UInt64 + public var droppedEventCount: UInt64 + + public init( + authOpenAllowCount: UInt64 = 0, + authOpenDenyCount: UInt64 = 0, + notifyOpenCount: UInt64 = 0, + deadlineMissCount: UInt64 = 0, + droppedEventCount: UInt64 = 0 + ) { + self.authOpenAllowCount = authOpenAllowCount + self.authOpenDenyCount = authOpenDenyCount + self.notifyOpenCount = notifyOpenCount + self.deadlineMissCount = deadlineMissCount + self.droppedEventCount = droppedEventCount + } + + enum CodingKeys: String, CodingKey { + case authOpenAllowCount = "auth_open_allow_count" + case authOpenDenyCount = "auth_open_deny_count" + case notifyOpenCount = "notify_open_count" + case deadlineMissCount = "deadline_miss_count" + case droppedEventCount = "dropped_event_count" + } +} + +public struct EvidenceArtifact: Codable, Equatable { + public var kind: String + public var path: String + public var detail: String + + public init(kind: String, path: String, detail: String) { + self.kind = kind + self.path = path + self.detail = detail + } +} + +public struct EndpointSecurityStatusReport: Codable, Equatable { + public var contract: String + public var authorizationModel: String + public var fdInjectionEquivalent: Bool + public var failOpenPossible: Bool + public var hostStatus: HostEndpointSecurityStatusPatch + public var providerState: AttestationProviderState + public var counters: EndpointSecurityCounters + public var degradedReasons: [String] + public var evidencePaths: [EvidenceArtifact] + + public init( + contract: String, + authorizationModel: String, + fdInjectionEquivalent: Bool, + failOpenPossible: Bool, + hostStatus: HostEndpointSecurityStatusPatch, + providerState: AttestationProviderState, + counters: EndpointSecurityCounters, + degradedReasons: [String], + evidencePaths: [EvidenceArtifact] + ) { + self.contract = contract + self.authorizationModel = authorizationModel + self.fdInjectionEquivalent = fdInjectionEquivalent + self.failOpenPossible = failOpenPossible + self.hostStatus = hostStatus + self.providerState = providerState + self.counters = counters + self.degradedReasons = degradedReasons + self.evidencePaths = evidencePaths + } + + enum CodingKeys: String, CodingKey { + case contract + case authorizationModel = "authorization_model" + case fdInjectionEquivalent = "fd_injection_equivalent" + case failOpenPossible = "fail_open_possible" + case hostStatus = "host_status" + case providerState = "provider_state" + case counters + case degradedReasons = "degraded_reasons" + case evidencePaths = "evidence_paths" + } +} + +public enum AuthorizationDecision: String, Codable, Equatable { + case allow + case deny +} + +public struct AuthorizationEvent: Codable, Equatable { + public var eventType: String + public var path: String + public var decision: AuthorizationDecision + public var latencyMs: UInt64 + public var deadlineMs: UInt64 + public var notifyObserved: Bool + public var observedAt: Date? + + public init( + eventType: String = "auth_open", + path: String, + decision: AuthorizationDecision, + latencyMs: UInt64, + deadlineMs: UInt64, + notifyObserved: Bool, + observedAt: Date? = nil + ) { + self.eventType = eventType + self.path = path + self.decision = decision + self.latencyMs = latencyMs + self.deadlineMs = deadlineMs + self.notifyObserved = notifyObserved + self.observedAt = observedAt + } + + public var exceededDeadline: Bool { + latencyMs > deadlineMs + } + + enum CodingKeys: String, CodingKey { + case eventType = "event_type" + case path + case decision + case latencyMs = "latency_ms" + case deadlineMs = "deadline_ms" + case notifyObserved = "notify_observed" + } +} + +public enum EndpointSecurityFixtureScenario: String { + case healthyAllow = "healthy-allow" + case denyDecision = "deny-decision" + case deadlineMiss = "deadline-miss" + case droppedEvents = "dropped-events" + case missingFullDiskAccess = "missing-full-disk-access" + case inactiveProvider = "inactive-provider" + case approvalBlocked = "approval-blocked" + + public static func resolve(commandLineArgument argument: String?) throws -> Self { + guard let argument else { + throw StatusToolScenarioError.missingScenario + } + guard let scenario = Self(rawValue: argument) else { + throw StatusToolScenarioError.unsupportedScenario(argument) + } + return scenario + } +} + +public enum StatusToolScenarioError: Error, Equatable, LocalizedError { + case missingScenario + case unsupportedScenario(String) + + public var errorDescription: String? { + switch self { + case .missingScenario: + return "missing endpoint-security fixture scenario" + case .unsupportedScenario(let value): + return "unsupported endpoint-security scenario: \(value)" + } + } +} diff --git a/apps/agent/src-tauri/macos/system-extension/endpoint-security/Sources/EndpointSecurityExtension/Monitor.swift b/apps/agent/src-tauri/macos/system-extension/endpoint-security/Sources/EndpointSecurityExtension/Monitor.swift new file mode 100644 index 000000000..0feb9e16c --- /dev/null +++ b/apps/agent/src-tauri/macos/system-extension/endpoint-security/Sources/EndpointSecurityExtension/Monitor.swift @@ -0,0 +1,339 @@ +import Foundation + +#if canImport(EndpointSecurity) +import EndpointSecurity +#endif + +public final class EndpointSecurityMonitor { + public static let endpointSecurityProvider = "endpoint_security" + public static let authorizationContract = "macos_endpoint_security_auth_contract" + public static let authorizationModel = "auth_open_point_in_time" + + private var installState: SystemExtensionInstallState + private var approval: SystemExtensionApproval + private var providerActive: Bool + private var fullDiskAccessGranted: Bool + private var counters: EndpointSecurityCounters + private var evidencePaths: [EvidenceArtifact] + private var lastHealthyTimestamp: String? + private var healthyObservationSeen: Bool + + public init( + installState: SystemExtensionInstallState = .unknown, + approval: SystemExtensionApproval = .unknown, + providerActive: Bool = false, + fullDiskAccessGranted: Bool = true + ) { + self.installState = installState + self.approval = approval + self.providerActive = providerActive + self.fullDiskAccessGranted = fullDiskAccessGranted + self.counters = EndpointSecurityCounters() + self.evidencePaths = [] + self.lastHealthyTimestamp = nil + self.healthyObservationSeen = false + } + + public func recordAuthorization(_ event: AuthorizationEvent) { + switch event.decision { + case .allow: + counters.authOpenAllowCount += 1 + case .deny: + counters.authOpenDenyCount += 1 + } + + if event.notifyObserved { + counters.notifyOpenCount += 1 + } + + if event.exceededDeadline { + counters.deadlineMissCount += 1 + } else { + healthyObservationSeen = true + recordHealthyObservation(at: event.observedAt ?? Date()) + } + } + + public func recordDroppedEvents( + count: UInt64, + evidencePath: String, + detail: String = "EndpointSecurity reported dropped enforcement events." + ) { + counters.droppedEventCount += count + addEvidence(kind: "dropped_events", path: evidencePath, detail: detail) + } + + public func setFullDiskAccessGranted( + _ granted: Bool, + evidencePath: String? = nil, + detail: String = "Full Disk Access is missing for the EndpointSecurity host." + ) { + fullDiskAccessGranted = granted + if !granted { + if let evidencePath { + addEvidence(kind: "missing_full_disk_access", path: evidencePath, detail: detail) + } + } + } + + public func setProviderActive( + _ active: Bool, + evidencePath: String? = nil, + detail: String = "EndpointSecurity provider is installed but inactive." + ) { + providerActive = active + if !active { + if let evidencePath { + addEvidence(kind: "inactive_provider", path: evidencePath, detail: detail) + } + } + } + + public func setInstallState(_ state: SystemExtensionInstallState) { + installState = state + } + + public func setApproval( + _ value: SystemExtensionApproval, + evidencePath: String? = nil, + detail: String = "System extension approval is blocked or missing." + ) { + approval = value + if value == .approvalBlocked { + if let evidencePath { + addEvidence(kind: "approval_blocked", path: evidencePath, detail: detail) + } + } + } + + public func snapshot() -> EndpointSecurityStatusReport { + let degradedReasons = currentDegradedReasons() + let providerState = currentProviderState() + let hostStatus = HostEndpointSecurityStatusPatch( + installState: installState, + approval: approval, + endpointSecurity: HostProviderStatus(runtime: currentHostRuntimeState()) + ) + + return EndpointSecurityStatusReport( + contract: Self.authorizationContract, + authorizationModel: Self.authorizationModel, + fdInjectionEquivalent: false, + failOpenPossible: true, + hostStatus: hostStatus, + providerState: providerState, + counters: counters, + degradedReasons: degradedReasons, + evidencePaths: evidencePaths + ) + } + + public static func liveReport() -> EndpointSecurityStatusReport { + EndpointSecurityMonitor().snapshot() + } + + public static func fixtureScenario(_ scenario: EndpointSecurityFixtureScenario) -> EndpointSecurityStatusReport { + let monitor = EndpointSecurityMonitor( + installState: .installed, + approval: .approved, + providerActive: true, + fullDiskAccessGranted: true + ) + switch scenario { + case .healthyAllow: + monitor.recordAuthorization( + AuthorizationEvent( + path: "/Applications/Notes.app/Contents/MacOS/Notes", + decision: .allow, + latencyMs: 12, + deadlineMs: 200, + notifyObserved: true, + observedAt: Date(timeIntervalSince1970: 1_778_824_800) + ) + ) + case .denyDecision: + monitor.recordAuthorization( + AuthorizationEvent( + path: "/private/tmp/blocked.txt", + decision: .deny, + latencyMs: 18, + deadlineMs: 200, + notifyObserved: false, + observedAt: Date(timeIntervalSince1970: 1_778_824_860) + ) + ) + case .deadlineMiss: + monitor.recordAuthorization( + AuthorizationEvent( + path: "/private/tmp/slow.txt", + decision: .deny, + latencyMs: 275, + deadlineMs: 200, + notifyObserved: false, + observedAt: Date(timeIntervalSince1970: 1_778_824_920) + ) + ) + monitor.addEvidence( + kind: "deadline_miss", + path: "fixtures/macos/endpoint-security/evidence/deadline-miss.json", + detail: "Synthetic over-deadline AUTH_OPEN path proving fail-open risk." + ) + case .droppedEvents: + monitor.recordAuthorization( + AuthorizationEvent( + path: "/private/tmp/allow.txt", + decision: .allow, + latencyMs: 16, + deadlineMs: 200, + notifyObserved: true, + observedAt: Date(timeIntervalSince1970: 1_778_824_980) + ) + ) + monitor.recordDroppedEvents( + count: 3, + evidencePath: "fixtures/macos/endpoint-security/evidence/dropped-events.json" + ) + case .missingFullDiskAccess: + monitor.setFullDiskAccessGranted( + false, + evidencePath: "fixtures/macos/endpoint-security/evidence/missing-full-disk-access.json" + ) + case .inactiveProvider: + monitor.setProviderActive( + false, + evidencePath: "fixtures/macos/endpoint-security/evidence/inactive-provider.json" + ) + case .approvalBlocked: + monitor.setApproval( + .approvalBlocked, + evidencePath: "fixtures/macos/endpoint-security/evidence/approval-blocked.json" + ) + } + return monitor.snapshot() + } + + private func currentProviderState() -> AttestationProviderState { + let hostRuntime = currentHostRuntimeState() + let degradedReasons = currentDegradedReasons() + let installed = installState == .installed + let approvalStatus: ProviderApprovalStatus = { + switch approval { + case .unknown: + return .unknown + case .approved: + return .approved + case .approvalBlocked: + return .blocked + } + }() + let active = installState == .installed && approval == .approved && providerActive + + let availability: ProviderAvailability = { + switch hostRuntime { + case .active: + return .active + case .inactive: + return .inactive + case .unknown: + return installed ? .inactive : .unavailable + case .degraded: + if !installed || approval == .approvalBlocked { + return .unavailable + } + if !providerActive { + return .inactive + } + return .degraded + } + }() + + let healthy = { + if case .active = hostRuntime { + return true + } + return false + }() + + return AttestationProviderState( + provider: Self.endpointSecurityProvider, + installed: installed, + approvalStatus: approvalStatus, + active: active, + healthy: healthy, + availability: availability, + degradedReasons: degradedReasons, + lastHealthyTimestamp: lastHealthyTimestamp + ) + } + + private func currentHostRuntimeState() -> HostProviderRuntimeState { + if installState == .unknown { + return .unknown + } + if installState == .notInstalled { + return .degraded(reason: "system_extension_not_installed") + } + if approval == .unknown { + return .unknown + } + if approval == .approvalBlocked { + return .degraded(reason: "system_extension_approval_blocked") + } + if !providerActive { + return .inactive + } + if !fullDiskAccessGranted { + return .degraded(reason: "missing_full_disk_access") + } + if counters.deadlineMissCount > 0 { + return .degraded(reason: "authorization_deadline_missed") + } + if counters.droppedEventCount > 0 { + return .degraded(reason: "dropped_enforcement_events") + } + if !healthyObservationSeen { + return .degraded(reason: "live_authorization_signal_missing") + } + return .active + } + + private func currentDegradedReasons() -> [String] { + var reasons: [String] = [] + if installState == .unknown || (installState == .installed && approval == .unknown) { + reasons.append("provider_state_unknown") + } + if installState == .notInstalled { + reasons.append("system_extension_not_installed") + } + if approval == .approvalBlocked { + reasons.append("system_extension_approval_blocked") + } + if installState == .installed && approval == .approved && !providerActive { + reasons.append("provider_inactive") + } + if installState == .installed && approval == .approved && !fullDiskAccessGranted { + reasons.append("missing_full_disk_access") + } + if counters.deadlineMissCount > 0 { + reasons.append("authorization_deadline_missed") + } + if counters.droppedEventCount > 0 { + reasons.append("dropped_enforcement_events") + } + if installState == .installed && approval == .approved && providerActive && fullDiskAccessGranted && !healthyObservationSeen { + reasons.append("live_authorization_signal_missing") + } + return reasons + } + + private func recordHealthyObservation(at date: Date) { + lastHealthyTimestamp = ISO8601DateFormatter().string(from: date) + } + + private func addEvidence(kind: String, path: String, detail: String) { + let artifact = EvidenceArtifact(kind: kind, path: path, detail: detail) + if !evidencePaths.contains(artifact) { + evidencePaths.append(artifact) + } + } +} diff --git a/apps/agent/src-tauri/macos/system-extension/endpoint-security/Sources/EndpointSecurityStatusTool/main.swift b/apps/agent/src-tauri/macos/system-extension/endpoint-security/Sources/EndpointSecurityStatusTool/main.swift new file mode 100644 index 000000000..8ea99903e --- /dev/null +++ b/apps/agent/src-tauri/macos/system-extension/endpoint-security/Sources/EndpointSecurityStatusTool/main.swift @@ -0,0 +1,62 @@ +import EndpointSecurityExtension +import Darwin +import Foundation + +enum StatusToolMode { + case live + case fixture(EndpointSecurityFixtureScenario) +} + +enum StatusToolInvocationError: Error, LocalizedError { + case missingMode + case unsupportedMode(String) + case missingFixtureScenario + + var errorDescription: String? { + switch self { + case .missingMode: + return "missing endpoint-security status-tool mode (use `live` or `fixture `)" + case .unsupportedMode(let mode): + return "unsupported endpoint-security status-tool mode: \(mode)" + case .missingFixtureScenario: + return "missing endpoint-security fixture scenario" + } + } +} + +func resolveMode(arguments: ArraySlice) throws -> StatusToolMode { + guard let mode = arguments.first else { + throw StatusToolInvocationError.missingMode + } + switch mode { + case "live": + return .live + case "fixture": + guard let scenarioArgument = arguments.dropFirst().first else { + throw StatusToolInvocationError.missingFixtureScenario + } + return .fixture(try EndpointSecurityFixtureScenario.resolve(commandLineArgument: scenarioArgument)) + default: + throw StatusToolInvocationError.unsupportedMode(mode) + } +} + +do { + let mode = try resolveMode(arguments: CommandLine.arguments.dropFirst()) + let report: EndpointSecurityStatusReport + switch mode { + case .live: + report = EndpointSecurityMonitor.liveReport() + case .fixture(let scenario): + report = EndpointSecurityMonitor.fixtureScenario(scenario) + } + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes] + + let data = try encoder.encode(report) + FileHandle.standardOutput.write(data) + FileHandle.standardOutput.write(Data([0x0A])) +} catch { + FileHandle.standardError.write(Data("\(error.localizedDescription)\n".utf8)) + exit(64) +} diff --git a/apps/agent/src-tauri/macos/system-extension/endpoint-security/Tests/EndpointSecurityExtensionTests/EndpointSecurityExtensionTests.swift b/apps/agent/src-tauri/macos/system-extension/endpoint-security/Tests/EndpointSecurityExtensionTests/EndpointSecurityExtensionTests.swift new file mode 100644 index 000000000..4ab25ea1f --- /dev/null +++ b/apps/agent/src-tauri/macos/system-extension/endpoint-security/Tests/EndpointSecurityExtensionTests/EndpointSecurityExtensionTests.swift @@ -0,0 +1,175 @@ +import EndpointSecurityExtension +import Foundation +import XCTest + +final class EndpointSecurityExtensionTests: XCTestCase { + func testHealthyAllowFixtureMatchesContract() throws { + let report = EndpointSecurityMonitor.fixtureScenario(.healthyAllow) + + XCTAssertEqual(report.fdInjectionEquivalent, false) + XCTAssertEqual(report.failOpenPossible, true) + XCTAssertEqual(report.contract, "macos_endpoint_security_auth_contract") + XCTAssertEqual(report.authorizationModel, "auth_open_point_in_time") + XCTAssertEqual(report.hostStatus.endpointSecurity.runtime, .active) + XCTAssertEqual(report.providerState.availability, .active) + XCTAssertEqual(report.counters.authOpenAllowCount, 1) + XCTAssertEqual(report.counters.notifyOpenCount, 1) + XCTAssertEqual(report.counters.deadlineMissCount, 0) + XCTAssertTrue(report.evidencePaths.isEmpty) + try assertFixture(report, named: "healthy-allow") + } + + func testDenyDecisionFixturePreservesNonFdInjectionSemantics() throws { + let report = EndpointSecurityMonitor.fixtureScenario(.denyDecision) + + XCTAssertEqual(report.fdInjectionEquivalent, false) + XCTAssertEqual(report.counters.authOpenDenyCount, 1) + XCTAssertEqual(report.counters.notifyOpenCount, 0) + XCTAssertEqual(report.hostStatus.endpointSecurity.runtime, .active) + try assertFixture(report, named: "deny-decision") + } + + func testDeadlineMissDegradesHostAndAttestationState() throws { + let report = EndpointSecurityMonitor.fixtureScenario(.deadlineMiss) + + XCTAssertEqual(report.counters.deadlineMissCount, 1) + XCTAssertTrue(report.degradedReasons.contains("authorization_deadline_missed")) + XCTAssertEqual(report.providerState.healthy, false) + XCTAssertEqual(report.providerState.availability, .degraded) + XCTAssertTrue(report.evidencePaths.contains(where: { $0.kind == "deadline_miss" })) + XCTAssertEqual( + report.hostStatus.endpointSecurity.runtime, + .degraded(reason: "authorization_deadline_missed") + ) + try assertFixture(report, named: "deadline-miss") + } + + func testDroppedEventsCarryEvidencePathAndDegradeProvider() throws { + let report = EndpointSecurityMonitor.fixtureScenario(.droppedEvents) + + XCTAssertEqual(report.counters.droppedEventCount, 3) + XCTAssertTrue(report.degradedReasons.contains("dropped_enforcement_events")) + XCTAssertTrue(report.evidencePaths.contains(where: { $0.path.hasSuffix("dropped-events.json") })) + XCTAssertEqual(report.providerState.availability, .degraded) + try assertFixture(report, named: "dropped-events") + } + + func testMissingFullDiskAccessSurfacesDegradedEvidence() throws { + let report = EndpointSecurityMonitor.fixtureScenario(.missingFullDiskAccess) + + XCTAssertTrue(report.degradedReasons.contains("missing_full_disk_access")) + XCTAssertEqual(report.providerState.availability, .degraded) + XCTAssertTrue(report.evidencePaths.contains(where: { $0.kind == "missing_full_disk_access" })) + XCTAssertEqual( + report.hostStatus.endpointSecurity.runtime, + .degraded(reason: "missing_full_disk_access") + ) + try assertFixture(report, named: "missing-full-disk-access") + } + + func testInactiveProviderStaysInactiveInsteadOfClaimingHealthyEnforcement() throws { + let report = EndpointSecurityMonitor.fixtureScenario(.inactiveProvider) + + XCTAssertEqual(report.providerState.active, false) + XCTAssertEqual(report.providerState.availability, .inactive) + XCTAssertEqual(report.providerState.healthy, false) + XCTAssertTrue(report.evidencePaths.contains(where: { $0.kind == "inactive_provider" })) + XCTAssertEqual(report.hostStatus.endpointSecurity.runtime, .inactive) + try assertFixture(report, named: "inactive-provider") + } + + func testApprovalBlockedProviderDoesNotClaimActiveEnforcement() throws { + let report = EndpointSecurityMonitor.fixtureScenario(.approvalBlocked) + + XCTAssertEqual(report.hostStatus.endpointSecurity.runtime, .degraded(reason: "system_extension_approval_blocked")) + XCTAssertEqual(report.providerState.active, false) + XCTAssertEqual(report.providerState.healthy, false) + XCTAssertEqual(report.providerState.availability, .unavailable) + XCTAssertEqual(report.providerState.approvalStatus, .blocked) + XCTAssertTrue(report.degradedReasons.contains("system_extension_approval_blocked")) + XCTAssertTrue(report.evidencePaths.contains(where: { $0.kind == "approval_blocked" })) + try assertFixture(report, named: "approval-blocked") + } + + func testProviderIsNotMarkedActiveWhenExtensionIsNotInstalled() { + let monitor = EndpointSecurityMonitor() + monitor.setInstallState(.notInstalled) + + let report = monitor.snapshot() + + XCTAssertEqual(report.hostStatus.endpointSecurity.runtime, .degraded(reason: "system_extension_not_installed")) + XCTAssertEqual(report.providerState.active, false) + XCTAssertEqual(report.providerState.healthy, false) + XCTAssertEqual(report.providerState.availability, .unavailable) + XCTAssertTrue(report.degradedReasons.contains("system_extension_not_installed")) + } + + func testProviderIsNotMarkedActiveWhenStateIsUnknown() { + let monitor = EndpointSecurityMonitor() + + let report = monitor.snapshot() + + XCTAssertEqual(report.hostStatus.endpointSecurity.runtime, .unknown) + XCTAssertEqual(report.providerState.active, false) + XCTAssertEqual(report.providerState.healthy, false) + XCTAssertEqual(report.providerState.availability, .unavailable) + XCTAssertTrue(report.degradedReasons.contains("provider_state_unknown")) + } + + func testLiveReportStartsUnknownUntilAHealthyObservationIsRecorded() { + let report = EndpointSecurityMonitor.liveReport() + + XCTAssertEqual(report.hostStatus.endpointSecurity.runtime, .unknown) + XCTAssertEqual(report.providerState.active, false) + XCTAssertEqual(report.providerState.healthy, false) + XCTAssertEqual(report.providerState.availability, .unavailable) + XCTAssertTrue(report.degradedReasons.contains("provider_state_unknown")) + } + + func testStatusToolRejectsUnsupportedScenarioInsteadOfFallingBackToHealthy() { + XCTAssertThrowsError( + try EndpointSecurityFixtureScenario.resolve(commandLineArgument: "definitely-not-real") + ) { error in + XCTAssertEqual( + error as? StatusToolScenarioError, + .unsupportedScenario("definitely-not-real") + ) + } + } + + func testStatusToolRejectsMissingFixtureScenario() { + XCTAssertThrowsError( + try EndpointSecurityFixtureScenario.resolve(commandLineArgument: nil) + ) { error in + XCTAssertEqual(error as? StatusToolScenarioError, .missingScenario) + } + } + + private func assertFixture(_ report: EndpointSecurityStatusReport, named name: String) throws { + let fixtureURL = fixturesRoot().appendingPathComponent("status/\(name).json") + let expected = try Data(contentsOf: fixtureURL) + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes] + let actual = try encoder.encode(report) + let expectedObject = try JSONSerialization.jsonObject(with: expected) + let actualObject = try JSONSerialization.jsonObject(with: actual) + XCTAssertEqual( + expectedObject as? NSDictionary, + actualObject as? NSDictionary + ) + } + + private func fixturesRoot() -> URL { + URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + .appendingPathComponent("fixtures/macos/endpoint-security", isDirectory: true) + } +} diff --git a/apps/agent/src-tauri/macos/system-extension/entitlements/agent-app.entitlements b/apps/agent/src-tauri/macos/system-extension/entitlements/agent-app.entitlements new file mode 100644 index 000000000..79b163918 --- /dev/null +++ b/apps/agent/src-tauri/macos/system-extension/entitlements/agent-app.entitlements @@ -0,0 +1,12 @@ + + + + + com.apple.developer.system-extension.install + + com.apple.developer.networking.networkextension + + content-filter-provider-systemextension + + + diff --git a/apps/agent/src-tauri/macos/system-extension/entitlements/combined-system-extension.entitlements b/apps/agent/src-tauri/macos/system-extension/entitlements/combined-system-extension.entitlements new file mode 100644 index 000000000..ec930bc5e --- /dev/null +++ b/apps/agent/src-tauri/macos/system-extension/entitlements/combined-system-extension.entitlements @@ -0,0 +1,12 @@ + + + + + com.apple.developer.endpoint-security.client + + com.apple.developer.networking.networkextension + + content-filter-provider-systemextension + + + diff --git a/apps/agent/src-tauri/macos/system-extension/network-extension/Package.swift b/apps/agent/src-tauri/macos/system-extension/network-extension/Package.swift new file mode 100644 index 000000000..6acf3cce3 --- /dev/null +++ b/apps/agent/src-tauri/macos/system-extension/network-extension/Package.swift @@ -0,0 +1,33 @@ +// swift-tools-version: 5.9 + +import PackageDescription + +let package = Package( + name: "ClawdStrikeNetworkExtension", + platforms: [ + .macOS(.v13), + ], + products: [ + .library( + name: "ClawdStrikeNetworkExtension", + targets: ["ClawdStrikeNetworkExtension"] + ), + .executable( + name: "network-extension-status-tool", + targets: ["NetworkExtensionStatusTool"] + ), + ], + targets: [ + .target( + name: "ClawdStrikeNetworkExtension" + ), + .executableTarget( + name: "NetworkExtensionStatusTool", + dependencies: ["ClawdStrikeNetworkExtension"] + ), + .testTarget( + name: "ClawdStrikeNetworkExtensionTests", + dependencies: ["ClawdStrikeNetworkExtension"] + ), + ] +) diff --git a/apps/agent/src-tauri/macos/system-extension/network-extension/Sources/ClawdStrikeNetworkExtension/ContentFilterProvider.swift b/apps/agent/src-tauri/macos/system-extension/network-extension/Sources/ClawdStrikeNetworkExtension/ContentFilterProvider.swift new file mode 100644 index 000000000..66e10820f --- /dev/null +++ b/apps/agent/src-tauri/macos/system-extension/network-extension/Sources/ClawdStrikeNetworkExtension/ContentFilterProvider.swift @@ -0,0 +1,140 @@ +import Foundation + +#if canImport(NetworkExtension) +import NetworkExtension + +public final class ClawdStrikeContentFilterDataProvider: NEFilterDataProvider { + private let lock = NSLock() + private var installed: SystemExtensionInstallState + private var approval: SystemExtensionApproval + private var backendHint: MediationBackendHint? + private var policySynced: Bool + private var counters: NetworkExtensionCounters + private var degradedReasons: [String] + private var running: Bool + private var lastHealthyAt: Date? + + public init( + installState: SystemExtensionInstallState = .installed, + approval: SystemExtensionApproval = .approved, + backendHint: MediationBackendHint? = nil, + policySynced: Bool = false + ) { + self.installed = installState + self.approval = approval + self.backendHint = backendHint + self.policySynced = policySynced + self.counters = NetworkExtensionCounters() + self.degradedReasons = [] + self.running = false + self.lastHealthyAt = nil + super.init() + } + + public override func startFilter(completionHandler: @escaping (Error?) -> Void) { + lock.lock() + running = true + lock.unlock() + completionHandler(nil) + } + + public override func stopFilter(with reason: NEProviderStopReason, completionHandler: @escaping () -> Void) { + lock.lock() + running = false + degradedReasons = [stopReasonString(reason)] + lock.unlock() + completionHandler() + } + + public override func handleNewFlow(_ flow: NEFilterFlow) -> NEFilterNewFlowVerdict { + lock.lock() + counters.flowsObserved += 1 + lock.unlock() + + // Baseline content-filter implementation: stay truthful about backend mediation + // by carrying the backend hint in state, but do not pivot the provider away + // from content filter here. + return .allow() + } + + public func markPolicySynced(_ synced: Bool) { + lock.lock() + policySynced = synced + lock.unlock() + } + + public func markDegraded(reason: String) { + lock.lock() + degradedReasons = [reason] + lock.unlock() + } + + public func recordDroppedVerdict() { + lock.lock() + counters.droppedVerdicts += 1 + lock.unlock() + } + + public func snapshot() -> NetworkExtensionProviderSnapshot { + lock.lock() + let inputs = NetworkExtensionProviderInputs( + installState: installed, + approval: approval, + providerKind: .contentFilter, + backendHint: backendHint, + filterRunning: running, + policySynced: policySynced, + degradedReasons: degradedReasons, + lastHealthyAt: lastHealthyAt, + counters: counters + ) + lock.unlock() + return NetworkExtensionStateProjector.snapshot(from: inputs) + } + + private func stopReasonString(_ reason: NEProviderStopReason) -> String { + switch reason { + case .none: + return "none" + case .authenticationCanceled: + return "authentication_canceled" + case .configurationDisabled: + return "configuration_disabled" + case .configurationFailed: + return "configuration_failed" + case .configurationRemoved: + return "configuration_removed" + case .connectionFailed: + return "connection_failed" + case .idleTimeout: + return "idle_timeout" + case .noNetworkAvailable: + return "no_network_available" + case .providerDisabled: + return "provider_disabled" + case .providerFailed: + return "provider_failed" + case .sleep: + return "sleep" + case .superceded: + return "superceded" + case .unrecoverableNetworkChange: + return "unrecoverable_network_change" + case .appUpdate: + return "app_update" + case .userInitiated: + return "user_initiated" + case .userLogout: + return "user_logout" + case .userSwitch: + return "user_switch" + @unknown default: + return "provider_stopped_unknown_reason" + } + } +} +#else +public final class ClawdStrikeContentFilterDataProvider { + public init() {} +} +#endif diff --git a/apps/agent/src-tauri/macos/system-extension/network-extension/Sources/ClawdStrikeNetworkExtension/ProviderState.swift b/apps/agent/src-tauri/macos/system-extension/network-extension/Sources/ClawdStrikeNetworkExtension/ProviderState.swift new file mode 100644 index 000000000..0ee369427 --- /dev/null +++ b/apps/agent/src-tauri/macos/system-extension/network-extension/Sources/ClawdStrikeNetworkExtension/ProviderState.swift @@ -0,0 +1,552 @@ +import Foundation + +public enum SystemExtensionInstallState: String, Codable, Equatable, Sendable { + case unknown + case notInstalled = "not_installed" + case installed +} + +public enum SystemExtensionApproval: String, Codable, Equatable, Sendable { + case unknown + case approved + case approvalBlocked = "approval_blocked" +} + +public enum ProviderRuntimeState: Codable, Equatable, Sendable { + case unknown + case inactive + case active + case degraded(reason: String) + + private enum CodingKeys: String, CodingKey { + case state + case reason + } + + private enum StateValue: String, Codable { + case unknown + case inactive + case active + case degraded + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + switch try container.decode(StateValue.self, forKey: .state) { + case .unknown: + self = .unknown + case .inactive: + self = .inactive + case .active: + self = .active + case .degraded: + self = .degraded(reason: try container.decode(String.self, forKey: .reason)) + } + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + switch self { + case .unknown: + try container.encode(StateValue.unknown, forKey: .state) + case .inactive: + try container.encode(StateValue.inactive, forKey: .state) + case .active: + try container.encode(StateValue.active, forKey: .state) + case .degraded(let reason): + try container.encode(StateValue.degraded, forKey: .state) + try container.encode(reason, forKey: .reason) + } + } +} + +public struct HostProviderStatus: Codable, Equatable, Sendable { + public var runtime: ProviderRuntimeState + + public init(runtime: ProviderRuntimeState) { + self.runtime = runtime + } +} + +public enum ProviderApprovalStatus: String, Codable, Equatable, Sendable { + case notRequired = "not_required" + case approved + case blocked + case missing + case unknown +} + +public enum ProviderAvailability: String, Codable, Equatable, Sendable { + case unavailable + case inactive + case active + case degraded +} + +public struct AttestationProviderState: Codable, Equatable, Sendable { + public var provider: String + public var installed: Bool + public var approvalStatus: ProviderApprovalStatus + public var active: Bool + public var healthy: Bool + public var availability: ProviderAvailability + public var degradedReasons: [String] + public var lastHealthyTimestamp: String? + + public init( + provider: String = "network_extension", + installed: Bool, + approvalStatus: ProviderApprovalStatus, + active: Bool, + healthy: Bool, + availability: ProviderAvailability, + degradedReasons: [String] = [], + lastHealthyTimestamp: String? = nil + ) { + self.provider = provider + self.installed = installed + self.approvalStatus = approvalStatus + self.active = active + self.healthy = healthy + self.availability = availability + self.degradedReasons = degradedReasons + self.lastHealthyTimestamp = lastHealthyTimestamp + } + + enum CodingKeys: String, CodingKey { + case provider + case installed + case approvalStatus = "approval_status" + case active + case healthy + case availability + case degradedReasons = "degraded_reasons" + case lastHealthyTimestamp = "last_healthy_timestamp" + } +} + +public enum NetworkExtensionProviderKind: String, Codable, Equatable, Sendable { + case contentFilter = "content_filter" + case transparentProxy = "transparent_proxy" +} + +public enum MediationBackendHint: String, Codable, Equatable, Sendable { + case legacyProxyOnlyRuntime = "legacy_proxy_only_runtime" +} + +public struct NetworkExtensionCounters: Codable, Equatable, Sendable { + public var flowsObserved: UInt64 + public var flowsBlocked: UInt64 + public var remediationRequests: UInt64 + public var droppedVerdicts: UInt64 + + public init( + flowsObserved: UInt64 = 0, + flowsBlocked: UInt64 = 0, + remediationRequests: UInt64 = 0, + droppedVerdicts: UInt64 = 0 + ) { + self.flowsObserved = flowsObserved + self.flowsBlocked = flowsBlocked + self.remediationRequests = remediationRequests + self.droppedVerdicts = droppedVerdicts + } + + enum CodingKeys: String, CodingKey { + case flowsObserved = "flows_observed" + case flowsBlocked = "flows_blocked" + case remediationRequests = "remediation_requests" + case droppedVerdicts = "dropped_verdicts" + } +} + +public struct ProviderSelectionEvidence: Codable, Equatable, Sendable { + public var requestedProvider: NetworkExtensionProviderKind + public var effectiveProvider: NetworkExtensionProviderKind + public var backendHint: MediationBackendHint? + public var exceptionRequired: Bool + + public init( + requestedProvider: NetworkExtensionProviderKind, + effectiveProvider: NetworkExtensionProviderKind, + backendHint: MediationBackendHint?, + exceptionRequired: Bool + ) { + self.requestedProvider = requestedProvider + self.effectiveProvider = effectiveProvider + self.backendHint = backendHint + self.exceptionRequired = exceptionRequired + } + + enum CodingKeys: String, CodingKey { + case requestedProvider = "requested_provider" + case effectiveProvider = "effective_provider" + case backendHint = "backend_hint" + case exceptionRequired = "exception_required" + } +} + +public struct NetworkExtensionProviderSnapshot: Codable, Equatable, Sendable { + public var installState: SystemExtensionInstallState + public var approval: SystemExtensionApproval + public var providerKind: NetworkExtensionProviderKind + public var backendHint: MediationBackendHint? + public var policySynced: Bool + public var hostStatus: HostProviderStatus + public var attestationState: AttestationProviderState + public var counters: NetworkExtensionCounters + public var selectionEvidence: ProviderSelectionEvidence + + public init( + installState: SystemExtensionInstallState, + approval: SystemExtensionApproval, + providerKind: NetworkExtensionProviderKind, + backendHint: MediationBackendHint?, + policySynced: Bool, + hostStatus: HostProviderStatus, + attestationState: AttestationProviderState, + counters: NetworkExtensionCounters, + selectionEvidence: ProviderSelectionEvidence + ) { + self.installState = installState + self.approval = approval + self.providerKind = providerKind + self.backendHint = backendHint + self.policySynced = policySynced + self.hostStatus = hostStatus + self.attestationState = attestationState + self.counters = counters + self.selectionEvidence = selectionEvidence + } + + enum CodingKeys: String, CodingKey { + case installState = "install_state" + case approval + case providerKind = "provider_kind" + case backendHint = "backend_hint" + case policySynced = "policy_synced" + case hostStatus = "host_status" + case attestationState = "attestation_state" + case counters + case selectionEvidence = "selection_evidence" + } +} + +public struct NetworkExtensionProviderInputs: Equatable, Sendable { + public var installState: SystemExtensionInstallState + public var approval: SystemExtensionApproval + public var providerKind: NetworkExtensionProviderKind + public var backendHint: MediationBackendHint? + public var filterRunning: Bool + public var policySynced: Bool + public var degradedReasons: [String] + public var lastHealthyAt: Date? + public var counters: NetworkExtensionCounters + + public init( + installState: SystemExtensionInstallState = .unknown, + approval: SystemExtensionApproval = .unknown, + providerKind: NetworkExtensionProviderKind = .contentFilter, + backendHint: MediationBackendHint? = nil, + filterRunning: Bool = false, + policySynced: Bool = false, + degradedReasons: [String] = [], + lastHealthyAt: Date? = nil, + counters: NetworkExtensionCounters = .init() + ) { + self.installState = installState + self.approval = approval + self.providerKind = providerKind + self.backendHint = backendHint + self.filterRunning = filterRunning + self.policySynced = policySynced + self.degradedReasons = degradedReasons + self.lastHealthyAt = lastHealthyAt + self.counters = counters + } +} + +public enum ProviderSelectionError: Error, Equatable, Sendable { + case transparentProxyExceptionRequired +} + +public enum NetworkExtensionFixtureScenario: String { + case selection + case inactive + case unavailable + case approvalBlocked = "approval-blocked" + + public static func resolve(argument: String?) throws -> Self { + guard let argument else { + throw NetworkExtensionStatusToolError.missingScenario + } + guard let scenario = Self(rawValue: argument) else { + throw NetworkExtensionStatusToolError.unsupportedScenario(argument) + } + return scenario + } +} + +public enum NetworkExtensionStatusToolError: Error, Equatable, LocalizedError { + case missingScenario + case unsupportedScenario(String) + + public var errorDescription: String? { + switch self { + case .missingScenario: + return "missing network-extension fixture scenario" + case .unsupportedScenario(let value): + return "unsupported network-extension scenario: \(value)" + } + } +} + +public enum NetworkExtensionStatusTool { + public static func liveSnapshot() -> NetworkExtensionProviderSnapshot { + NetworkExtensionStateProjector.snapshot(from: NetworkExtensionProviderInputs()) + } + + public static func fixtureSnapshot(_ scenario: NetworkExtensionFixtureScenario) -> NetworkExtensionProviderSnapshot { + switch scenario { + case .selection: + return NetworkExtensionStateProjector.snapshot( + from: NetworkExtensionProviderInputs( + installState: .installed, + approval: .approved, + providerKind: .contentFilter, + backendHint: .legacyProxyOnlyRuntime, + filterRunning: true, + policySynced: true, + degradedReasons: [], + lastHealthyAt: nil, + counters: NetworkExtensionCounters( + flowsObserved: 42, + flowsBlocked: 0, + remediationRequests: 0, + droppedVerdicts: 0 + ) + ) + ) + case .inactive: + return NetworkExtensionStateProjector.snapshot( + from: NetworkExtensionProviderInputs( + installState: .installed, + approval: .approved, + providerKind: .contentFilter, + backendHint: .legacyProxyOnlyRuntime, + filterRunning: false, + policySynced: false, + degradedReasons: ["provider_failed"], + lastHealthyAt: nil + ) + ) + case .unavailable: + return NetworkExtensionStateProjector.snapshot( + from: NetworkExtensionProviderInputs( + installState: .notInstalled, + approval: .unknown, + providerKind: .contentFilter, + backendHint: .legacyProxyOnlyRuntime, + filterRunning: false, + policySynced: false + ) + ) + case .approvalBlocked: + return NetworkExtensionStateProjector.snapshot( + from: NetworkExtensionProviderInputs( + installState: .installed, + approval: .approvalBlocked, + providerKind: .contentFilter, + backendHint: .legacyProxyOnlyRuntime, + filterRunning: false, + policySynced: false + ) + ) + } + } +} + +public enum NetworkExtensionStateProjector { + public static func snapshot(from inputs: NetworkExtensionProviderInputs) -> NetworkExtensionProviderSnapshot { + let health = deriveHealth(inputs) + return NetworkExtensionProviderSnapshot( + installState: inputs.installState, + approval: inputs.approval, + providerKind: inputs.providerKind, + backendHint: inputs.backendHint, + policySynced: inputs.policySynced, + hostStatus: HostProviderStatus(runtime: health.hostRuntime), + attestationState: AttestationProviderState( + installed: health.installed, + approvalStatus: health.approvalStatus, + active: health.active, + healthy: health.healthy, + availability: health.availability, + degradedReasons: health.degradedReasons, + lastHealthyTimestamp: health.lastHealthyTimestamp + ), + counters: inputs.counters, + selectionEvidence: ProviderSelectionEvidence( + requestedProvider: inputs.providerKind, + effectiveProvider: .contentFilter, + backendHint: inputs.backendHint, + exceptionRequired: inputs.providerKind != .contentFilter + ) + ) + } + + public static func requireContentFilterBaseline( + requestedProvider: NetworkExtensionProviderKind, + backendHint: MediationBackendHint? + ) throws -> ProviderSelectionEvidence { + let evidence = ProviderSelectionEvidence( + requestedProvider: requestedProvider, + effectiveProvider: .contentFilter, + backendHint: backendHint, + exceptionRequired: requestedProvider != .contentFilter + ) + guard requestedProvider == .contentFilter else { + throw ProviderSelectionError.transparentProxyExceptionRequired + } + return evidence + } + + private static func deriveHealth(_ inputs: NetworkExtensionProviderInputs) -> DerivedHealth { + let installed = inputs.installState == .installed + let approvalStatus = approvalStatus(for: inputs.approval) + let droppedVerdictsReason = inputs.counters.droppedVerdicts > 0 ? "dropped_verdicts" : nil + + let degradedReasons = uniqueReasons([inputs.degradedReasons, droppedVerdictsReason.map { [$0] } ?? []] + .flatMap { $0 }) + + if inputs.installState == .unknown && inputs.approval == .unknown { + return DerivedHealth( + installed: false, + approvalStatus: .unknown, + active: false, + healthy: false, + availability: .unavailable, + degradedReasons: ["provider_state_unknown"], + hostRuntime: .unknown, + lastHealthyTimestamp: nil + ) + } + + if !installed { + return DerivedHealth( + installed: false, + approvalStatus: approvalStatus, + active: false, + healthy: false, + availability: .unavailable, + degradedReasons: ["system_extension_not_installed"], + hostRuntime: .degraded(reason: "system_extension_not_installed"), + lastHealthyTimestamp: nil + ) + } + + if inputs.approval == .approvalBlocked { + return DerivedHealth( + installed: true, + approvalStatus: approvalStatus, + active: false, + healthy: false, + availability: .unavailable, + degradedReasons: ["approval_blocked"], + hostRuntime: .degraded(reason: "approval_blocked"), + lastHealthyTimestamp: nil + ) + } + + if !inputs.filterRunning { + let reasons = degradedReasons.isEmpty ? ["provider_inactive"] : degradedReasons + return DerivedHealth( + installed: true, + approvalStatus: approvalStatus, + active: false, + healthy: false, + availability: .inactive, + degradedReasons: reasons, + hostRuntime: .inactive, + lastHealthyTimestamp: lastHealthyTimestamp(from: inputs.lastHealthyAt) + ) + } + + if !inputs.policySynced { + let reasons = uniqueReasons(degradedReasons + ["policy_not_synced"]) + return DerivedHealth( + installed: true, + approvalStatus: approvalStatus, + active: true, + healthy: false, + availability: .degraded, + degradedReasons: reasons, + hostRuntime: .degraded(reason: "policy_not_synced"), + lastHealthyTimestamp: lastHealthyTimestamp(from: inputs.lastHealthyAt) + ) + } + + if let firstReason = degradedReasons.first { + return DerivedHealth( + installed: true, + approvalStatus: approvalStatus, + active: true, + healthy: false, + availability: .degraded, + degradedReasons: degradedReasons, + hostRuntime: .degraded(reason: firstReason), + lastHealthyTimestamp: lastHealthyTimestamp(from: inputs.lastHealthyAt) + ) + } + + let reasons = uniqueReasons(degradedReasons + ["non_enforcing_provider"]) + return DerivedHealth( + installed: true, + approvalStatus: approvalStatus, + active: true, + healthy: false, + availability: .degraded, + degradedReasons: reasons, + hostRuntime: .degraded(reason: reasons[0]), + lastHealthyTimestamp: lastHealthyTimestamp(from: inputs.lastHealthyAt) + ) + } + + private static func approvalStatus(for approval: SystemExtensionApproval) -> ProviderApprovalStatus { + switch approval { + case .unknown: + return .unknown + case .approved: + return .approved + case .approvalBlocked: + return .blocked + } + } + + private static func lastHealthyTimestamp(from date: Date?) -> String? { + guard let date else { + return nil + } + return ISO8601DateFormatter().string(from: date) + } + + private static func uniqueReasons(_ reasons: [String]) -> [String] { + var seen = Set() + var ordered: [String] = [] + for reason in reasons where seen.insert(reason).inserted { + ordered.append(reason) + } + return ordered + } +} + +private struct DerivedHealth { + var installed: Bool + var approvalStatus: ProviderApprovalStatus + var active: Bool + var healthy: Bool + var availability: ProviderAvailability + var degradedReasons: [String] + var hostRuntime: ProviderRuntimeState + var lastHealthyTimestamp: String? +} diff --git a/apps/agent/src-tauri/macos/system-extension/network-extension/Sources/NetworkExtensionStatusTool/main.swift b/apps/agent/src-tauri/macos/system-extension/network-extension/Sources/NetworkExtensionStatusTool/main.swift new file mode 100644 index 000000000..196d4c9a4 --- /dev/null +++ b/apps/agent/src-tauri/macos/system-extension/network-extension/Sources/NetworkExtensionStatusTool/main.swift @@ -0,0 +1,63 @@ +import ClawdStrikeNetworkExtension +import Darwin +import Foundation + +enum StatusToolMode { + case live + case fixture(NetworkExtensionFixtureScenario) +} + +enum StatusToolInvocationError: Error, LocalizedError { + case missingMode + case unsupportedMode(String) + case missingFixtureScenario + + var errorDescription: String? { + switch self { + case .missingMode: + return "missing network-extension status-tool mode (use `live` or `fixture `)" + case .unsupportedMode(let mode): + return "unsupported network-extension status-tool mode: \(mode)" + case .missingFixtureScenario: + return "missing network-extension fixture scenario" + } + } +} + +func resolveMode(arguments: ArraySlice) throws -> StatusToolMode { + guard let mode = arguments.first else { + throw StatusToolInvocationError.missingMode + } + switch mode { + case "live": + return .live + case "fixture": + guard let scenarioArgument = arguments.dropFirst().first else { + throw StatusToolInvocationError.missingFixtureScenario + } + return .fixture(try NetworkExtensionFixtureScenario.resolve(argument: scenarioArgument)) + default: + throw StatusToolInvocationError.unsupportedMode(mode) + } +} + +do { + let mode = try resolveMode(arguments: CommandLine.arguments.dropFirst()) + let snapshot: NetworkExtensionProviderSnapshot + switch mode { + case .live: + snapshot = NetworkExtensionStatusTool.liveSnapshot() + case .fixture(let scenario): + snapshot = NetworkExtensionStatusTool.fixtureSnapshot(scenario) + } + + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes] + + let data = try encoder.encode(snapshot) + FileHandle.standardOutput.write(data) + FileHandle.standardOutput.write(Data([0x0A])) +} catch { + FileHandle.standardError.write(Data("\(error.localizedDescription)\n".utf8)) + exit(64) +} diff --git a/apps/agent/src-tauri/macos/system-extension/network-extension/Tests/ClawdStrikeNetworkExtensionTests/ProviderStateTests.swift b/apps/agent/src-tauri/macos/system-extension/network-extension/Tests/ClawdStrikeNetworkExtensionTests/ProviderStateTests.swift new file mode 100644 index 000000000..cd847ba95 --- /dev/null +++ b/apps/agent/src-tauri/macos/system-extension/network-extension/Tests/ClawdStrikeNetworkExtensionTests/ProviderStateTests.swift @@ -0,0 +1,198 @@ +import Foundation +import XCTest + +@testable import ClawdStrikeNetworkExtension + +final class ProviderStateTests: XCTestCase { + func testProviderSelectionStaysOnContentFilterBaselineAndKeepsLegacyBackendHint() throws { + let evidence = try NetworkExtensionStateProjector.requireContentFilterBaseline( + requestedProvider: .contentFilter, + backendHint: .legacyProxyOnlyRuntime + ) + + XCTAssertEqual(evidence.requestedProvider, .contentFilter) + XCTAssertEqual(evidence.effectiveProvider, .contentFilter) + XCTAssertEqual(evidence.backendHint, .legacyProxyOnlyRuntime) + XCTAssertFalse(evidence.exceptionRequired) + } + + func testTransparentProxySelectionRequiresExplicitException() { + XCTAssertThrowsError( + try NetworkExtensionStateProjector.requireContentFilterBaseline( + requestedProvider: .transparentProxy, + backendHint: .legacyProxyOnlyRuntime + ) + ) { error in + XCTAssertEqual( + error as? ProviderSelectionError, + .transparentProxyExceptionRequired + ) + } + } + + func testUnavailableProviderProducesHostAndAttestationEvidence() { + let snapshot = NetworkExtensionStateProjector.snapshot( + from: NetworkExtensionProviderInputs( + installState: .installed, + approval: .approved, + providerKind: .contentFilter, + backendHint: .legacyProxyOnlyRuntime, + filterRunning: false, + policySynced: false + ) + ) + + XCTAssertEqual(snapshot.providerKind, .contentFilter) + XCTAssertEqual(snapshot.backendHint, .legacyProxyOnlyRuntime) + XCTAssertEqual(snapshot.selectionEvidence.effectiveProvider, .contentFilter) + XCTAssertEqual(snapshot.hostStatus.runtime, .inactive) + XCTAssertEqual(snapshot.attestationState.availability, .inactive) + XCTAssertEqual(snapshot.attestationState.degradedReasons, ["provider_inactive"]) + } + + func testApprovalBlockedProviderReportsUnavailableWithoutClaimingActiveEnforcement() { + let snapshot = NetworkExtensionStateProjector.snapshot( + from: NetworkExtensionProviderInputs( + installState: .installed, + approval: .approvalBlocked, + providerKind: .contentFilter, + backendHint: .legacyProxyOnlyRuntime, + filterRunning: false, + policySynced: false + ) + ) + + XCTAssertEqual(snapshot.hostStatus.runtime, .degraded(reason: "approval_blocked")) + XCTAssertEqual(snapshot.attestationState.active, false) + XCTAssertEqual(snapshot.attestationState.healthy, false) + XCTAssertEqual(snapshot.attestationState.availability, .unavailable) + XCTAssertEqual(snapshot.attestationState.degradedReasons, ["approval_blocked"]) + XCTAssertEqual(snapshot.attestationState.approvalStatus, .blocked) + } + + func testPolicySyncGapIsReportedAsDegradedWithoutRenamingBackendHint() { + let snapshot = NetworkExtensionStateProjector.snapshot( + from: NetworkExtensionProviderInputs( + installState: .installed, + approval: .approved, + providerKind: .contentFilter, + backendHint: .legacyProxyOnlyRuntime, + filterRunning: true, + policySynced: false, + degradedReasons: [], + lastHealthyAt: nil, + counters: NetworkExtensionCounters( + flowsObserved: 42, + flowsBlocked: 0, + remediationRequests: 0, + droppedVerdicts: 0 + ) + ) + ) + + XCTAssertEqual(snapshot.hostStatus.runtime, .degraded(reason: "policy_not_synced")) + XCTAssertEqual(snapshot.attestationState.availability, .degraded) + XCTAssertEqual(snapshot.attestationState.degradedReasons, ["policy_not_synced"]) + XCTAssertEqual(snapshot.backendHint, .legacyProxyOnlyRuntime) + } + + func testRunningSyncedAllowAllProviderStaysDegradedUntilItCanActuallyEnforce() { + let snapshot = NetworkExtensionStateProjector.snapshot( + from: NetworkExtensionProviderInputs( + installState: .installed, + approval: .approved, + providerKind: .contentFilter, + backendHint: .legacyProxyOnlyRuntime, + filterRunning: true, + policySynced: true + ) + ) + + XCTAssertEqual(snapshot.hostStatus.runtime, .degraded(reason: "non_enforcing_provider")) + XCTAssertEqual(snapshot.attestationState.active, true) + XCTAssertEqual(snapshot.attestationState.healthy, false) + XCTAssertEqual(snapshot.attestationState.availability, .degraded) + XCTAssertEqual(snapshot.attestationState.degradedReasons, ["non_enforcing_provider"]) + } + + func testFixtureEvidenceDecodesForSelectionInactiveUnavailableAndApprovalBlockedPaths() throws { + let selectionFixture = try loadFixture(named: "content-filter-provider-selection.json") + let inactiveFixture = try loadFixture(named: "content-filter-provider-inactive.json") + let unavailableFixture = try loadFixture(named: "content-filter-provider-unavailable.json") + let approvalBlockedFixture = try loadFixture(named: "content-filter-provider-approval-blocked.json") + + XCTAssertEqual(selectionFixture.providerKind, .contentFilter) + XCTAssertEqual(selectionFixture.selectionEvidence.effectiveProvider, .contentFilter) + XCTAssertEqual(selectionFixture.backendHint, .legacyProxyOnlyRuntime) + XCTAssertEqual(selectionFixture.hostStatus.runtime, .degraded(reason: "non_enforcing_provider")) + XCTAssertEqual(selectionFixture.attestationState.availability, .degraded) + XCTAssertEqual(inactiveFixture.hostStatus.runtime, .inactive) + XCTAssertEqual(inactiveFixture.attestationState.availability, .inactive) + XCTAssertEqual(inactiveFixture.attestationState.degradedReasons, ["provider_failed"]) + XCTAssertEqual(unavailableFixture.hostStatus.runtime, .degraded(reason: "system_extension_not_installed")) + XCTAssertEqual(unavailableFixture.attestationState.availability, .unavailable) + XCTAssertEqual(approvalBlockedFixture.hostStatus.runtime, .degraded(reason: "approval_blocked")) + XCTAssertEqual(approvalBlockedFixture.attestationState.availability, .unavailable) + XCTAssertEqual(approvalBlockedFixture.attestationState.approvalStatus, .blocked) + } + + func testRecoveredInputsStayDegradedUntilProviderCanActuallyEnforce() { + let degraded = NetworkExtensionStateProjector.snapshot( + from: NetworkExtensionProviderInputs( + installState: .installed, + approval: .approved, + providerKind: .contentFilter, + backendHint: .legacyProxyOnlyRuntime, + filterRunning: false, + policySynced: true, + degradedReasons: ["provider_failed"], + lastHealthyAt: nil + ) + ) + + XCTAssertEqual(degraded.hostStatus.runtime, .inactive) + XCTAssertEqual(degraded.attestationState.degradedReasons, ["provider_failed"]) + + let recovered = NetworkExtensionStateProjector.snapshot( + from: NetworkExtensionProviderInputs( + installState: .installed, + approval: .approved, + providerKind: .contentFilter, + backendHint: .legacyProxyOnlyRuntime, + filterRunning: true, + policySynced: true, + degradedReasons: [], + lastHealthyAt: nil + ) + ) + + XCTAssertEqual(recovered.hostStatus.runtime, .degraded(reason: "non_enforcing_provider")) + XCTAssertEqual(recovered.attestationState.degradedReasons, ["non_enforcing_provider"]) + XCTAssertEqual(recovered.attestationState.availability, .degraded) + } + + private func loadFixture(named name: String) throws -> NetworkExtensionProviderSnapshot { + let fixturesURL = try findFixturesDirectory(startingAt: URL(fileURLWithPath: #filePath)) + let data = try Data(contentsOf: fixturesURL.appendingPathComponent(name)) + return try JSONDecoder().decode(NetworkExtensionProviderSnapshot.self, from: data) + } + + private func findFixturesDirectory(startingAt start: URL) throws -> URL { + var current = start.deletingLastPathComponent() + for _ in 0..<16 { + let candidate = current + .appendingPathComponent("fixtures") + .appendingPathComponent("macos") + .appendingPathComponent("network-extension") + if FileManager.default.fileExists(atPath: candidate.path) { + return candidate + } + current.deleteLastPathComponent() + } + throw FixtureLookupError.notFound + } + + private enum FixtureLookupError: Error { + case notFound + } +} diff --git a/apps/agent/src-tauri/macos/system-extension/plists/agent-packaging-template.plist b/apps/agent/src-tauri/macos/system-extension/plists/agent-packaging-template.plist new file mode 100644 index 000000000..9667d5f69 --- /dev/null +++ b/apps/agent/src-tauri/macos/system-extension/plists/agent-packaging-template.plist @@ -0,0 +1,16 @@ + + + + + CFBundleIdentifier + dev.clawdstrike.agent + CFBundleDisplayName + ClawdStrike Agent + ClawdStrikeCombinedSystemExtensionBundleIdentifier + dev.clawdstrike.agent.system-extension + ClawdStrikePackagingState + source_assets_ready + LSMinimumSystemVersion + 13.0 + + diff --git a/apps/agent/src-tauri/macos/system-extension/plists/combined-system-extension-template.plist b/apps/agent/src-tauri/macos/system-extension/plists/combined-system-extension-template.plist new file mode 100644 index 000000000..de67eb3c5 --- /dev/null +++ b/apps/agent/src-tauri/macos/system-extension/plists/combined-system-extension-template.plist @@ -0,0 +1,25 @@ + + + + + CFBundleIdentifier + dev.clawdstrike.agent.system-extension + CFBundleDisplayName + ClawdStrike Security Extension + CFBundleName + ClawdStrike Security Extension + CFBundleShortVersionString + 0.2.5 + CFBundleVersion + 0.2.5 + ClawdStrikeExtensionPoints + + endpoint_security + network_extension_content_filter + + ClawdStrikePackagingState + source_assets_ready + LSMinimumSystemVersion + 13.0 + + diff --git a/apps/agent/src-tauri/macos/system-extension/profiles/developer-id-profile-template.plist b/apps/agent/src-tauri/macos/system-extension/profiles/developer-id-profile-template.plist new file mode 100644 index 000000000..3d33e1a30 --- /dev/null +++ b/apps/agent/src-tauri/macos/system-extension/profiles/developer-id-profile-template.plist @@ -0,0 +1,25 @@ + + + + + AppIDName + ClawdStrike Agent Developer ID Packaging + ApplicationIdentifierPrefix + + JB6682CJY9 + + ClawdStrikeExpectedBundleIdentifiers + + dev.clawdstrike.agent + dev.clawdstrike.agent.system-extension + + ClawdStrikeNotarytoolCredentialSource + signer_host_keychain_profile_or_apple_id + ClawdStrikePackagingState + source_assets_ready + TeamIdentifier + + JB6682CJY9 + + + diff --git a/apps/agent/src-tauri/src/api_server.rs b/apps/agent/src-tauri/src/api_server.rs index da2581ecc..91f5474d0 100644 --- a/apps/agent/src-tauri/src/api_server.rs +++ b/apps/agent/src-tauri/src/api_server.rs @@ -5,6 +5,11 @@ use crate::approval::{ ApprovalQueue, ApprovalRequestInput, ApprovalResolveInput, ApprovalStatusResponse, }; use crate::daemon::{AuditQueue, DaemonManager, DaemonStatus}; +use crate::macos::status::{ + CombinedSystemExtensionStatus, ProviderRuntimeState, SystemExtensionApproval, + SystemExtensionInstallState, +}; +use crate::macos::MacosHostService; use crate::openclaw::{ GatewayDiscoverInput, GatewayRequestInput, GatewayUpsertRequest, ImportGatewayRequest, OpenClawManager, @@ -77,6 +82,7 @@ pub struct AgentApiServerDeps { pub session_manager: Arc, pub approval_queue: Arc, pub audit_queue: Arc, + pub macos_host: Arc, pub openclaw: OpenClawManager, pub updater: Arc, pub auth_token: String, @@ -89,6 +95,7 @@ struct AgentApiState { session_manager: Arc, approval_queue: Arc, audit_queue: Arc, + macos_host: Arc, openclaw: OpenClawManager, updater: Arc, auth_token: Arc>, @@ -291,6 +298,7 @@ impl AgentApiServer { session_manager: deps.session_manager, approval_queue: deps.approval_queue, audit_queue: deps.audit_queue, + macos_host: deps.macos_host, openclaw: deps.openclaw, updater: deps.updater, auth_token: Arc::new(StdRwLock::new(deps.auth_token)), @@ -1359,6 +1367,7 @@ struct AgentHealthResponse { status: &'static str, daemon: DaemonStatus, session: crate::session::SessionState, + macos_host: CombinedSystemExtensionStatus, openclaw: serde_json::Value, runtime_agents: usize, last_policy_version: Option, @@ -1389,6 +1398,73 @@ struct DaemonEndpointStatus { drift: Option, } +fn macos_host_health_status(status: &CombinedSystemExtensionStatus) -> &'static str { + let endpoint_active = matches!( + status.endpoint_security.runtime, + ProviderRuntimeState::Active + ); + let network_active = matches!( + status.network_extension.runtime, + ProviderRuntimeState::Active + ); + let endpoint_unknown = matches!( + status.endpoint_security.runtime, + ProviderRuntimeState::Unknown + ); + let network_unknown = matches!( + status.network_extension.runtime, + ProviderRuntimeState::Unknown + ); + let endpoint_inactive = matches!( + status.endpoint_security.runtime, + ProviderRuntimeState::Inactive + ); + let network_inactive = matches!( + status.network_extension.runtime, + ProviderRuntimeState::Inactive + ); + let endpoint_degraded = matches!( + status.endpoint_security.runtime, + ProviderRuntimeState::Degraded { .. } + ); + let network_degraded = matches!( + status.network_extension.runtime, + ProviderRuntimeState::Degraded { .. } + ); + + if status.install_state == SystemExtensionInstallState::NotInstalled + || status.approval == SystemExtensionApproval::ApprovalBlocked + || endpoint_degraded + || network_degraded + { + "degraded" + } else if status.install_state == SystemExtensionInstallState::Unknown + || status.approval == SystemExtensionApproval::Unknown + || endpoint_unknown + || network_unknown + || endpoint_inactive + || network_inactive + { + "pending" + } else if status.install_state == SystemExtensionInstallState::Installed + && status.approval == SystemExtensionApproval::Approved + && endpoint_active + && network_active + { + "ok" + } else { + "degraded" + } +} + +fn agent_health_status(status: &CombinedSystemExtensionStatus) -> &'static str { + if cfg!(target_os = "macos") { + macos_host_health_status(status) + } else { + "ok" + } +} + #[derive(Debug, Deserialize)] struct DaemonEndpointDrift { #[serde(default)] @@ -1880,6 +1956,7 @@ async fn agent_health( let openclaw = state.openclaw.list_gateways().await; let runtime_agents = settings_snapshot.runtime_registry.runtimes.len(); let last_policy_version = cached_policy_version_for_health(&state).await; + let macos_host = state.macos_host.snapshot().await; let daemon_status = fetch_daemon_endpoint_status_for_health( &state, &settings_snapshot, @@ -1903,9 +1980,10 @@ async fn agent_health( }; Ok(Json(AgentHealthResponse { - status: "ok", + status: agent_health_status(&macos_host), daemon, session, + macos_host, openclaw: serde_json::to_value(openclaw) .unwrap_or_else(|_| serde_json::json!({"error":"serialize_failed"})), runtime_agents, @@ -3192,6 +3270,7 @@ mod tests { session_manager, approval_queue, audit_queue, + macos_host: Arc::new(MacosHostService::new()), openclaw, updater, auth_token: Arc::new(StdRwLock::new("test-token".to_string())), @@ -3244,6 +3323,66 @@ mod tests { assert!(cache.refresh_in_flight); } + #[test] + fn macos_host_health_status_is_pending_for_unknown_state() { + assert_eq!( + macos_host_health_status(&CombinedSystemExtensionStatus::default()), + "pending" + ); + } + + #[test] + fn macos_host_health_status_is_degraded_for_blocked_or_missing_extensions() { + let blocked = CombinedSystemExtensionStatus { + approval: SystemExtensionApproval::ApprovalBlocked, + ..CombinedSystemExtensionStatus::default() + }; + assert_eq!(macos_host_health_status(&blocked), "degraded"); + + let not_installed = CombinedSystemExtensionStatus { + install_state: SystemExtensionInstallState::NotInstalled, + ..CombinedSystemExtensionStatus::default() + }; + assert_eq!(macos_host_health_status(¬_installed), "degraded"); + } + + #[test] + fn macos_host_health_status_is_pending_for_inactive_extensions() { + let inactive = CombinedSystemExtensionStatus { + install_state: SystemExtensionInstallState::Installed, + approval: SystemExtensionApproval::Approved, + endpoint_security: crate::macos::status::ProviderStatus::inactive(), + network_extension: crate::macos::status::ProviderStatus::inactive(), + }; + assert_eq!(macos_host_health_status(&inactive), "pending"); + } + + #[test] + fn macos_host_health_status_is_ok_for_fully_active_extensions() { + let active = CombinedSystemExtensionStatus { + install_state: SystemExtensionInstallState::Installed, + approval: SystemExtensionApproval::Approved, + endpoint_security: crate::macos::status::ProviderStatus { + runtime: ProviderRuntimeState::Active, + }, + network_extension: crate::macos::status::ProviderStatus { + runtime: ProviderRuntimeState::Active, + }, + }; + + assert_eq!(macos_host_health_status(&active), "ok"); + } + + #[test] + fn agent_health_status_preserves_non_macos_ok_fallback() { + let status = CombinedSystemExtensionStatus::default(); + if cfg!(target_os = "macos") { + assert_eq!(agent_health_status(&status), "pending"); + } else { + assert_eq!(agent_health_status(&status), "ok"); + } + } + #[test] fn auth_accepts_bearer_token() { let state = test_state(); @@ -3536,6 +3675,48 @@ mod tests { assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); } + #[tokio::test] + async fn agent_health_route_reports_pending_host_state() { + let state = Arc::new(test_state()); + let app = Router::new() + .route("/api/v1/agent/health", get(agent_health)) + .with_state(state); + + let req = axum::http::Request::builder() + .method("GET") + .uri("/api/v1/agent/health") + .header(AUTHORIZATION, "Bearer test-token") + .body(axum::body::Body::empty()) + .unwrap_or_else(|e| panic!("failed to build request: {e}")); + let resp = app + .oneshot(req) + .await + .unwrap_or_else(|e| panic!("request failed: {e}")); + assert_eq!(resp.status(), StatusCode::OK); + + let body = axum::body::to_bytes(resp.into_body(), usize::MAX) + .await + .unwrap_or_else(|e| panic!("failed to read response body: {e}")); + let payload: serde_json::Value = serde_json::from_slice(&body) + .unwrap_or_else(|e| panic!("failed to decode response body: {e}")); + let expected_status = if cfg!(target_os = "macos") { + "pending" + } else { + "ok" + }; + assert_eq!(payload["status"], expected_status); + assert_eq!(payload["macos_host"]["install_state"], "unknown"); + assert_eq!(payload["macos_host"]["approval"], "unknown"); + assert_eq!( + payload["macos_host"]["endpoint_security"]["runtime"]["state"], + "unknown" + ); + assert_eq!( + payload["macos_host"]["network_extension"]["runtime"]["state"], + "unknown" + ); + } + #[test] fn validate_integrations_requires_api_key_for_datadog() { let mut integrations = IntegrationSettings::default(); diff --git a/apps/agent/src-tauri/src/macos/collector.rs b/apps/agent/src-tauri/src/macos/collector.rs new file mode 100644 index 000000000..7eaabd226 --- /dev/null +++ b/apps/agent/src-tauri/src/macos/collector.rs @@ -0,0 +1,519 @@ +use std::ffi::OsString; +use std::path::{Path, PathBuf}; +use std::process::Stdio; +use std::sync::Arc; +use std::time::Duration; + +use anyhow::{anyhow, Context, Result}; +use serde::Deserialize; +use tauri::path::BaseDirectory; +use tauri::{AppHandle, Manager, Runtime}; +use tokio::process::Command; +use tokio::sync::broadcast; +use tokio::time::timeout; + +use super::host::MacosHostService; +use super::status::{ + CombinedSystemExtensionStatus, ProviderStatus, SystemExtensionApproval, SystemExtensionInstallState, +}; + +const STATUS_POLL_INTERVAL: Duration = Duration::from_secs(60); +const STATUS_TOOL_TIMEOUT: Duration = Duration::from_secs(10); +const ENDPOINT_SECURITY_TOOL_ENV: &str = "CLAWDSTRIKE_ENDPOINT_SECURITY_STATUS_TOOL"; +const NETWORK_EXTENSION_TOOL_ENV: &str = "CLAWDSTRIKE_NETWORK_EXTENSION_STATUS_TOOL"; +const ENDPOINT_SECURITY_TOOL_NAME: &str = "endpoint-security-status-tool"; +const NETWORK_EXTENSION_TOOL_NAME: &str = "network-extension-status-tool"; + +#[derive(Debug, Clone)] +enum ToolInvocation { + Direct { + program: PathBuf, + args: Vec, + }, + SwiftRun { + package_path: PathBuf, + executable: &'static str, + }, +} + +impl ToolInvocation { + fn command(&self) -> Command { + match self { + Self::Direct { program, args } => { + let mut command = Command::new(program); + command.args(args); + command + } + Self::SwiftRun { + package_path, + executable, + } => { + let mut command = Command::new("swift"); + command + .arg("run") + .arg("--package-path") + .arg(package_path) + .arg(executable) + .arg("live"); + command + } + } + } + + fn display_name(&self) -> String { + match self { + Self::Direct { program, .. } => program.display().to_string(), + Self::SwiftRun { + package_path, + executable, + } => format!("swift run --package-path {} {}", package_path.display(), executable), + } + } +} + +#[derive(Debug, Deserialize)] +struct EndpointSecurityStatusSample { + host_status: EndpointSecurityHostStatus, +} + +#[derive(Debug, Deserialize)] +struct EndpointSecurityHostStatus { + install_state: SystemExtensionInstallState, + approval: SystemExtensionApproval, + endpoint_security: ProviderStatus, +} + +#[derive(Debug, Deserialize)] +struct NetworkExtensionStatusSample { + install_state: SystemExtensionInstallState, + approval: SystemExtensionApproval, + host_status: ProviderStatus, +} + +pub fn start_status_collector( + app: AppHandle, + macos_host: Arc, + mut shutdown: broadcast::Receiver<()>, +) { + let endpoint_tool = + resolve_status_tool(&app, ENDPOINT_SECURITY_TOOL_ENV, "macos/system-extension/endpoint-security", ENDPOINT_SECURITY_TOOL_NAME); + let network_tool = + resolve_status_tool(&app, NETWORK_EXTENSION_TOOL_ENV, "macos/system-extension/network-extension", NETWORK_EXTENSION_TOOL_NAME); + + if endpoint_tool.is_none() { + tracing::warn!( + "macOS endpoint-security status helper is unavailable; host health will remain unknown until the helper can be executed" + ); + } + if network_tool.is_none() { + tracing::warn!( + "macOS network-extension status helper is unavailable; host health will remain unknown until the helper can be executed" + ); + } + + tokio::spawn(async move { + macos_host.reset_unknown_state().await; + + loop { + let combined = collect_combined_status(endpoint_tool.as_ref(), network_tool.as_ref()).await; + macos_host.replace_status(combined).await; + + tokio::select! { + _ = shutdown.recv() => break, + _ = tokio::time::sleep(STATUS_POLL_INTERVAL) => {} + } + } + }); +} + +async fn collect_combined_status( + endpoint_tool: Option<&ToolInvocation>, + network_tool: Option<&ToolInvocation>, +) -> CombinedSystemExtensionStatus { + let endpoint_sample = match endpoint_tool { + Some(tool) => run_json_tool::(tool).await, + None => None, + }; + let network_sample = match network_tool { + Some(tool) => run_json_tool::(tool).await, + None => None, + }; + merge_samples(endpoint_sample, network_sample) +} + +async fn run_json_tool(tool: &ToolInvocation) -> Option +where + T: for<'de> Deserialize<'de>, +{ + match timeout(STATUS_TOOL_TIMEOUT, execute_tool(tool)).await { + Ok(Ok(stdout)) => match serde_json::from_slice::(&stdout) { + Ok(sample) => Some(sample), + Err(error) => { + tracing::warn!( + tool = %tool.display_name(), + error = %error, + "macOS status helper returned invalid JSON" + ); + None + } + }, + Ok(Err(error)) => { + tracing::warn!( + tool = %tool.display_name(), + error = %error, + "macOS status helper execution failed" + ); + None + } + Err(_) => { + tracing::warn!( + tool = %tool.display_name(), + timeout_secs = STATUS_TOOL_TIMEOUT.as_secs(), + "macOS status helper timed out" + ); + None + } + } +} + +async fn execute_tool(tool: &ToolInvocation) -> Result> { + let mut command = tool.command(); + command.kill_on_drop(true); + command.stdout(Stdio::piped()); + command.stderr(Stdio::piped()); + + let output = command + .output() + .await + .with_context(|| format!("spawn {}", tool.display_name()))?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + return Err(anyhow!( + "{} exited with status {}{}", + tool.display_name(), + output.status, + if stderr.is_empty() { + String::new() + } else { + format!(": {stderr}") + } + )); + } + + Ok(output.stdout) +} + +fn merge_samples( + endpoint_sample: Option, + network_sample: Option, +) -> CombinedSystemExtensionStatus { + let install_state = match (endpoint_sample.as_ref(), network_sample.as_ref()) { + (Some(endpoint_sample), Some(network_sample)) => merge_install_state( + endpoint_sample.host_status.install_state, + network_sample.install_state, + ), + _ => SystemExtensionInstallState::Unknown, + }; + let approval = match (endpoint_sample.as_ref(), network_sample.as_ref()) { + (Some(endpoint_sample), Some(network_sample)) => { + merge_approval_state(endpoint_sample.host_status.approval, network_sample.approval) + } + _ => SystemExtensionApproval::Unknown, + }; + + CombinedSystemExtensionStatus { + install_state, + approval, + endpoint_security: endpoint_sample + .map(|sample| sample.host_status.endpoint_security) + .unwrap_or_else(ProviderStatus::unknown), + network_extension: network_sample + .map(|sample| sample.host_status) + .unwrap_or_else(ProviderStatus::unknown), + } +} + +fn merge_install_state( + current: SystemExtensionInstallState, + candidate: SystemExtensionInstallState, +) -> SystemExtensionInstallState { + match (current, candidate) { + (SystemExtensionInstallState::NotInstalled, _) + | (_, SystemExtensionInstallState::NotInstalled) => SystemExtensionInstallState::NotInstalled, + (SystemExtensionInstallState::Installed, SystemExtensionInstallState::Installed) => { + SystemExtensionInstallState::Installed + } + _ => SystemExtensionInstallState::Unknown, + } +} + +fn merge_approval_state( + current: SystemExtensionApproval, + candidate: SystemExtensionApproval, +) -> SystemExtensionApproval { + match (current, candidate) { + (SystemExtensionApproval::ApprovalBlocked, _) + | (_, SystemExtensionApproval::ApprovalBlocked) => SystemExtensionApproval::ApprovalBlocked, + (SystemExtensionApproval::Approved, SystemExtensionApproval::Approved) => { + SystemExtensionApproval::Approved + } + _ => SystemExtensionApproval::Unknown, + } +} + +fn resolve_status_tool( + app: &AppHandle, + env_var: &str, + relative_package_path: &str, + executable: &'static str, +) -> Option { + if let Some(tool) = resolve_direct_tool_from_env(env_var) { + return Some(tool); + } + + if let Some(resource_package) = resolve_resource_package_path(app, relative_package_path) { + if let Some(tool) = resolve_direct_built_tool(&resource_package, executable) { + return Some(tool); + } + if resource_package.join("Package.swift").is_file() && which::which("swift").is_ok() { + return Some(ToolInvocation::SwiftRun { + package_path: resource_package, + executable, + }); + } + } + + let source_package = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(relative_package_path); + if let Some(tool) = resolve_direct_built_tool(&source_package, executable) { + return Some(tool); + } + if source_package.join("Package.swift").is_file() && which::which("swift").is_ok() { + return Some(ToolInvocation::SwiftRun { + package_path: source_package, + executable, + }); + } + + None +} + +fn resolve_direct_tool_from_env(env_var: &str) -> Option { + let path = std::env::var_os(env_var)?; + let program = PathBuf::from(path); + if !program.is_file() { + tracing::warn!( + path = %program.display(), + env = env_var, + "ignoring macOS status helper override because the path does not exist" + ); + return None; + } + Some(ToolInvocation::Direct { + program, + args: vec![OsString::from("live")], + }) +} + +fn resolve_resource_package_path( + app: &AppHandle, + relative_package_path: &str, +) -> Option { + app.path() + .resolve(relative_package_path, BaseDirectory::Resource) + .ok() + .filter(|path| path.exists()) +} + +fn resolve_direct_built_tool(package_path: &Path, executable: &'static str) -> Option { + for profile in ["release", "debug"] { + let candidate = package_path.join(".build").join(profile).join(executable); + if candidate.is_file() { + return Some(ToolInvocation::Direct { + program: candidate, + args: vec![OsString::from("live")], + }); + } + } + None +} + +#[cfg(test)] +mod tests { + #![allow(clippy::expect_used, clippy::unwrap_used)] + + use super::*; + use crate::macos::status::{ProviderRuntimeState, ProviderStatus}; + use std::fs; + use std::os::unix::fs::PermissionsExt; + use std::time::{SystemTime, UNIX_EPOCH}; + + fn temp_script_path(name: &str) -> PathBuf { + let millis = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system time should be after epoch") + .as_millis(); + std::env::temp_dir().join(format!("clawdstrike-{name}-{millis}-{}", std::process::id())) + } + + fn write_script(name: &str, body: &str) -> PathBuf { + let path = temp_script_path(name); + fs::write(&path, body).expect("write temp script"); + let mut permissions = fs::metadata(&path).expect("stat temp script").permissions(); + permissions.set_mode(0o755); + fs::set_permissions(&path, permissions).expect("chmod temp script"); + path + } + + #[tokio::test] + async fn invalid_helper_json_resets_provider_to_unknown() { + let script = write_script( + "invalid-json", + "#!/bin/sh\nprintf 'not-json'\n", + ); + let result = run_json_tool::(&ToolInvocation::Direct { + program: script.clone(), + args: vec![OsString::from("live")], + }) + .await; + let _ = fs::remove_file(script); + + assert!(result.is_none()); + } + + #[tokio::test] + async fn timing_out_helper_resets_provider_to_unknown() { + let script = write_script( + "slow-helper", + "#!/bin/sh\nsleep 30\n", + ); + let timeout_result = + timeout(Duration::from_millis(100), execute_tool(&ToolInvocation::Direct { + program: script.clone(), + args: vec![OsString::from("live")], + })) + .await; + let _ = fs::remove_file(script); + + assert!(timeout_result.is_err(), "helper should time out in the test harness"); + } + + #[test] + fn merge_samples_preserves_valid_provider_status_and_marks_missing_sample_unknown() { + let combined = merge_samples( + Some(EndpointSecurityStatusSample { + host_status: EndpointSecurityHostStatus { + install_state: SystemExtensionInstallState::Installed, + approval: SystemExtensionApproval::Approved, + endpoint_security: ProviderStatus { + runtime: ProviderRuntimeState::Active, + }, + }, + }), + None, + ); + + assert_eq!(combined.install_state, SystemExtensionInstallState::Unknown); + assert_eq!(combined.approval, SystemExtensionApproval::Unknown); + assert_eq!( + combined.endpoint_security, + ProviderStatus { + runtime: ProviderRuntimeState::Active, + } + ); + assert_eq!(combined.network_extension, ProviderStatus::unknown()); + } + + #[test] + fn merge_samples_promotes_consistent_install_and_approval_proof() { + let combined = merge_samples( + Some(EndpointSecurityStatusSample { + host_status: EndpointSecurityHostStatus { + install_state: SystemExtensionInstallState::Installed, + approval: SystemExtensionApproval::Approved, + endpoint_security: ProviderStatus { + runtime: ProviderRuntimeState::Active, + }, + }, + }), + Some(NetworkExtensionStatusSample { + install_state: SystemExtensionInstallState::Installed, + approval: SystemExtensionApproval::Approved, + host_status: ProviderStatus { + runtime: ProviderRuntimeState::Active, + }, + }), + ); + + assert_eq!(combined.install_state, SystemExtensionInstallState::Installed); + assert_eq!(combined.approval, SystemExtensionApproval::Approved); + assert_eq!( + combined.endpoint_security, + ProviderStatus { + runtime: ProviderRuntimeState::Active, + } + ); + assert_eq!( + combined.network_extension, + ProviderStatus { + runtime: ProviderRuntimeState::Active, + } + ); + } + + #[test] + fn merge_install_state_fails_closed_for_partial_installation() { + assert_eq!( + merge_install_state( + SystemExtensionInstallState::Installed, + SystemExtensionInstallState::NotInstalled, + ), + SystemExtensionInstallState::NotInstalled + ); + assert_eq!( + merge_install_state( + SystemExtensionInstallState::NotInstalled, + SystemExtensionInstallState::Installed, + ), + SystemExtensionInstallState::NotInstalled + ); + assert_eq!( + merge_install_state( + SystemExtensionInstallState::Installed, + SystemExtensionInstallState::Unknown, + ), + SystemExtensionInstallState::Unknown + ); + assert_eq!( + merge_install_state( + SystemExtensionInstallState::Unknown, + SystemExtensionInstallState::Installed, + ), + SystemExtensionInstallState::Unknown + ); + } + + #[test] + fn merge_approval_state_requires_consistent_approval_proof() { + assert_eq!( + merge_approval_state( + SystemExtensionApproval::Approved, + SystemExtensionApproval::Unknown, + ), + SystemExtensionApproval::Unknown + ); + assert_eq!( + merge_approval_state( + SystemExtensionApproval::Unknown, + SystemExtensionApproval::Approved, + ), + SystemExtensionApproval::Unknown + ); + assert_eq!( + merge_approval_state( + SystemExtensionApproval::Approved, + SystemExtensionApproval::Approved, + ), + SystemExtensionApproval::Approved + ); + } +} diff --git a/apps/agent/src-tauri/src/macos/host.rs b/apps/agent/src-tauri/src/macos/host.rs new file mode 100644 index 000000000..fb523a9ae --- /dev/null +++ b/apps/agent/src-tauri/src/macos/host.rs @@ -0,0 +1,95 @@ +use tokio::sync::RwLock; + +use super::status::CombinedSystemExtensionStatus; + +#[derive(Debug, Default)] +pub struct MacosHostService { + status: RwLock, +} + +impl MacosHostService { + pub fn new() -> Self { + Self { + status: RwLock::new(CombinedSystemExtensionStatus::default()), + } + } + + pub async fn snapshot(&self) -> CombinedSystemExtensionStatus { + self.status.read().await.clone() + } + + pub async fn reset_unknown_state(&self) { + self.replace_status(CombinedSystemExtensionStatus::default()) + .await; + } + + pub async fn bootstrap_placeholder_state(&self) { + self.reset_unknown_state().await; + } + + pub(crate) async fn replace_status(&self, status: CombinedSystemExtensionStatus) { + *self.status.write().await = status; + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::macos::status::{ + ProviderRuntimeState, ProviderStatus, SystemExtensionApproval, SystemExtensionInstallState, + }; + + #[tokio::test] + async fn service_exposes_default_snapshot() { + let service = MacosHostService::new(); + service.bootstrap_placeholder_state().await; + + assert_eq!( + service.snapshot().await, + CombinedSystemExtensionStatus::default() + ); + } + + #[tokio::test] + async fn service_updates_status_transitions() { + let service = MacosHostService::new(); + service.bootstrap_placeholder_state().await; + + service + .replace_status(CombinedSystemExtensionStatus { + install_state: SystemExtensionInstallState::Installed, + approval: SystemExtensionApproval::ApprovalBlocked, + endpoint_security: ProviderStatus { + runtime: ProviderRuntimeState::Degraded { + reason: "missing full disk access".to_string(), + }, + }, + network_extension: ProviderStatus { + runtime: ProviderRuntimeState::Active, + }, + }) + .await; + + let snapshot = service.snapshot().await; + assert_eq!( + snapshot.install_state, + SystemExtensionInstallState::Installed + ); + assert_eq!(snapshot.approval, SystemExtensionApproval::ApprovalBlocked); + assert_eq!( + snapshot.endpoint_security, + ProviderStatus { + runtime: ProviderRuntimeState::Degraded { + reason: "missing full disk access".to_string(), + }, + } + ); + assert_eq!( + snapshot.network_extension, + ProviderStatus { + runtime: ProviderRuntimeState::Active, + } + ); + assert!(snapshot.is_degraded()); + } +} diff --git a/apps/agent/src-tauri/src/macos/mod.rs b/apps/agent/src-tauri/src/macos/mod.rs new file mode 100644 index 000000000..8239b5308 --- /dev/null +++ b/apps/agent/src-tauri/src/macos/mod.rs @@ -0,0 +1,16 @@ +#[cfg(target_os = "macos")] +mod collector; +pub mod host; +pub mod status; + +#[cfg(target_os = "macos")] +pub use collector::start_status_collector; +pub use host::MacosHostService; + +#[cfg(not(target_os = "macos"))] +pub fn start_status_collector( + _app: tauri::AppHandle, + _macos_host: std::sync::Arc, + _shutdown: tokio::sync::broadcast::Receiver<()>, +) { +} diff --git a/apps/agent/src-tauri/src/macos/status.rs b/apps/agent/src-tauri/src/macos/status.rs new file mode 100644 index 000000000..c62c5205e --- /dev/null +++ b/apps/agent/src-tauri/src/macos/status.rs @@ -0,0 +1,147 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SystemExtensionInstallState { + Unknown, + NotInstalled, + Installed, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SystemExtensionApproval { + Unknown, + Approved, + ApprovalBlocked, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "state", rename_all = "snake_case")] +pub enum ProviderRuntimeState { + Unknown, + Inactive, + Active, + Degraded { reason: String }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ProviderStatus { + pub runtime: ProviderRuntimeState, +} + +impl ProviderStatus { + pub fn unknown() -> Self { + Self { + runtime: ProviderRuntimeState::Unknown, + } + } + + #[cfg(test)] + pub fn inactive() -> Self { + Self { + runtime: ProviderRuntimeState::Inactive, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CombinedSystemExtensionStatus { + pub install_state: SystemExtensionInstallState, + pub approval: SystemExtensionApproval, + pub endpoint_security: ProviderStatus, + pub network_extension: ProviderStatus, +} + +impl Default for CombinedSystemExtensionStatus { + fn default() -> Self { + Self { + install_state: SystemExtensionInstallState::Unknown, + approval: SystemExtensionApproval::Unknown, + endpoint_security: ProviderStatus::unknown(), + network_extension: ProviderStatus::unknown(), + } + } +} + +#[cfg(test)] +impl CombinedSystemExtensionStatus { + pub fn is_degraded(&self) -> bool { + matches!( + self.endpoint_security.runtime, + ProviderRuntimeState::Degraded { .. } + ) || matches!( + self.network_extension.runtime, + ProviderRuntimeState::Degraded { .. } + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn default_status_starts_unknown() { + let status = CombinedSystemExtensionStatus::default(); + + assert_eq!(status.install_state, SystemExtensionInstallState::Unknown); + assert_eq!(status.approval, SystemExtensionApproval::Unknown); + assert_eq!(status.endpoint_security, ProviderStatus::unknown()); + assert_eq!(status.network_extension, ProviderStatus::unknown()); + assert!(!status.is_degraded()); + } + + #[test] + fn degraded_state_is_detected_from_either_provider() { + let status = CombinedSystemExtensionStatus { + endpoint_security: ProviderStatus { + runtime: ProviderRuntimeState::Degraded { + reason: "approval blocked".to_string(), + }, + }, + ..CombinedSystemExtensionStatus::default() + }; + + assert!(status.is_degraded()); + } + + #[test] + fn status_serializes_explicit_host_readout_states() { + let status = CombinedSystemExtensionStatus { + install_state: SystemExtensionInstallState::Installed, + approval: SystemExtensionApproval::ApprovalBlocked, + endpoint_security: ProviderStatus::inactive(), + network_extension: ProviderStatus { + runtime: ProviderRuntimeState::Degraded { + reason: "provider handshake stalled".to_string(), + }, + }, + }; + + let value = match serde_json::to_value(status) { + Ok(value) => value, + Err(error) => panic!("status should serialize: {error}"), + }; + + assert_eq!( + value, + json!({ + "install_state": "installed", + "approval": "approval_blocked", + "endpoint_security": { + "runtime": { + "state": "inactive" + } + }, + "network_extension": { + "runtime": { + "state": "degraded", + "reason": "provider handshake stalled" + } + } + }) + ); + } +} diff --git a/apps/agent/src-tauri/src/main.rs b/apps/agent/src-tauri/src/main.rs index 49969e699..a6165fb40 100644 --- a/apps/agent/src-tauri/src/main.rs +++ b/apps/agent/src-tauri/src/main.rs @@ -17,6 +17,7 @@ mod decision; mod enrollment; mod events; mod integrations; +mod macos; mod nats_client; mod nats_subjects; mod notifications; @@ -41,6 +42,7 @@ use daemon::{ }; use events::EventManager; use integrations::{ClaudeCodeIntegration, McpServer, OpenClawPluginIntegration}; +use macos::{start_status_collector, MacosHostService}; use notifications::{ show_hooks_installed_notification, show_openclaw_plugin_installed_notification, show_policy_reload_notification, show_startup_notification, show_toggle_notification, @@ -72,6 +74,7 @@ struct AppState { policy_cache: Arc, audit_queue: Arc, updater: Arc, + macos_host: Arc, shutdown_tx: broadcast::Sender<()>, agent_api_token: String, shutdown_complete: Arc, @@ -205,6 +208,8 @@ fn main() { let policy_cache = Arc::new(PolicyCache::new()); let audit_queue = Arc::new(AuditQueue::new()); let updater = Arc::new(HushdUpdater::new(settings.clone(), daemon_manager.clone())); + let macos_host = Arc::new(MacosHostService::new()); + tauri::async_runtime::block_on(macos_host.bootstrap_placeholder_state()); let (shutdown_tx, _) = broadcast::channel::<()>(4); let shutdown_complete = Arc::new(ShutdownComplete::new()); @@ -218,6 +223,7 @@ fn main() { policy_cache, audit_queue, updater, + macos_host, shutdown_tx: shutdown_tx.clone(), agent_api_token, shutdown_complete: shutdown_complete.clone(), @@ -235,6 +241,7 @@ fn main() { .manage(app_state.policy_cache.clone()) .manage(app_state.audit_queue.clone()) .manage(app_state.updater.clone()) + .manage(app_state.macos_host.clone()) .manage(app_state.shutdown_tx.clone()) .manage(app_state.shutdown_complete.clone()) .manage(AgentApiAuthToken(app_state.agent_api_token.clone())) @@ -253,6 +260,7 @@ fn main() { let policy_cache = app_state.policy_cache.clone(); let audit_queue = app_state.audit_queue.clone(); let updater = app_state.updater.clone(); + let macos_host = app_state.macos_host.clone(); let settings = app_state.settings.clone(); let shutdown_tx = app_state.shutdown_tx.clone(); let agent_api_token = app_state.agent_api_token.clone(); @@ -269,6 +277,7 @@ fn main() { policy_cache, audit_queue, updater, + macos_host, tray_manager, settings, shutdown_tx, @@ -336,6 +345,7 @@ async fn run_agent( policy_cache: Arc, audit_queue: Arc, updater: Arc, + macos_host: Arc, tray_manager: Arc>, settings: Arc>, shutdown_tx: broadcast::Sender<()>, @@ -351,11 +361,13 @@ async fn run_agent( // current session ID from shared state each tick (so daemon reconnect replacements do not // require restarting the loop). session_manager.start_heartbeat(daemon_url.clone(), api_key.clone(), shutdown_tx.subscribe()); + start_status_collector(app.clone(), macos_host.clone(), shutdown_tx.subscribe()); { let settings_for_local_hb = settings.clone(); let session_for_local_hb = session_manager.clone(); let policy_cache_for_local_hb = policy_cache.clone(); let daemon_for_local_hb = daemon_manager.clone(); + let macos_host_for_local_hb = macos_host.clone(); let local_hb_shutdown = shutdown_tx.subscribe(); tokio::spawn(async move { local_heartbeat_loop( @@ -363,6 +375,7 @@ async fn run_agent( session_for_local_hb, policy_cache_for_local_hb, daemon_for_local_hb, + macos_host_for_local_hb, local_hb_shutdown, ) .await; @@ -437,7 +450,10 @@ async fn run_agent( rejected = outcome.rejected, "Flushed queued audit events on startup with rejected entries still queued" ), - Ok(outcome) => tracing::info!(count = outcome.accepted, "Flushed queued audit events on startup"), + Ok(outcome) => tracing::info!( + count = outcome.accepted, + "Flushed queued audit events on startup" + ), Err(err) => log_audit_flush_failure(&err, "Failed to flush queued audit events"), } } @@ -678,9 +694,15 @@ async fn run_agent( ) } Ok(outcome) => { - tracing::info!(count = outcome.accepted, "Flushed queued audit events after reconnect") + tracing::info!( + count = outcome.accepted, + "Flushed queued audit events after reconnect" + ) } - Err(err) => log_audit_flush_failure(&err, "Failed to flush audit queue after reconnect"), + Err(err) => log_audit_flush_failure( + &err, + "Failed to flush audit queue after reconnect", + ), } } if let Err(err) = policy_cache_for_daemon @@ -921,6 +943,7 @@ async fn run_agent( session_manager: session_manager.clone(), approval_queue: approval_queue.clone(), audit_queue: audit_queue.clone(), + macos_host: macos_host.clone(), openclaw: openclaw_manager.clone(), updater: updater.clone(), auth_token: agent_api_token, @@ -1133,6 +1156,7 @@ async fn local_heartbeat_loop( session_manager: Arc, policy_cache: Arc, daemon_manager: Arc, + macos_host: Arc, mut shutdown_rx: broadcast::Receiver<()>, ) { let client = reqwest::Client::builder() @@ -1176,6 +1200,7 @@ async fn local_heartbeat_loop( let session_state = session_manager.state().await; let daemon_status = daemon_manager.status().await; let policy_version = policy_cache.cached_policy_version().await; + let macos_host_status = macos_host.snapshot().await; let heartbeat_base = serde_json::json!({ "endpoint_agent_id": endpoint_agent_id, "timestamp": chrono::Utc::now().to_rfc3339(), @@ -1183,6 +1208,7 @@ async fn local_heartbeat_loop( "posture": session_state.posture, "policy_version": policy_version, "daemon_version": daemon_status.version, + "macos_host": macos_host_status, }); let send_heartbeat = |payload: serde_json::Value| { diff --git a/apps/agent/src-tauri/tauri.conf.json b/apps/agent/src-tauri/tauri.conf.json index 0b3a43676..596369cca 100644 --- a/apps/agent/src-tauri/tauri.conf.json +++ b/apps/agent/src-tauri/tauri.conf.json @@ -28,15 +28,16 @@ "resources": [ "resources/*", "resources/**/*", - "icons/*" + "icons/*", + "macos/system-extension/**/*" ], "macOS": { - "minimumSystemVersion": "10.15", + "minimumSystemVersion": "13.0", "frameworks": [], "exceptionDomain": "", "signingIdentity": null, "providerShortName": null, - "entitlements": null + "entitlements": "macos/system-extension/entitlements/agent-app.entitlements" }, "windows": { "certificateThumbprint": null, diff --git a/crates/libs/clawdstrike/src/sandbox/attestation.rs b/crates/libs/clawdstrike/src/sandbox/attestation.rs index d66f0a464..258cf634e 100644 --- a/crates/libs/clawdstrike/src/sandbox/attestation.rs +++ b/crates/libs/clawdstrike/src/sandbox/attestation.rs @@ -4,7 +4,9 @@ //! signed receipts. They are ClawdStrike-owned serializable types built //! from nono's CapabilitySet accessors. -use serde::{Deserialize, Serialize}; +use chrono::Utc; +use serde::ser::SerializeStruct; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; /// Complete sandbox attestation for receipt metadata. #[derive(Debug, Clone, Serialize, Deserialize)] @@ -29,6 +31,7 @@ pub enum EnforcementLevel { None, Kernel, KernelSupervised, + Degraded, } impl std::fmt::Display for EnforcementLevel { @@ -37,6 +40,7 @@ impl std::fmt::Display for EnforcementLevel { EnforcementLevel::None => write!(f, "none"), EnforcementLevel::Kernel => write!(f, "kernel"), EnforcementLevel::KernelSupervised => write!(f, "kernel_supervised"), + EnforcementLevel::Degraded => write!(f, "degraded"), } } } @@ -48,21 +52,201 @@ impl std::str::FromStr for EnforcementLevel { "none" => Ok(EnforcementLevel::None), "kernel" => Ok(EnforcementLevel::Kernel), "kernel_supervised" => Ok(EnforcementLevel::KernelSupervised), + "degraded" => Ok(EnforcementLevel::Degraded), _ => Err(format!("unknown enforcement level: {}", s)), } } } /// Platform sandbox information. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone)] pub struct PlatformInfo { pub name: String, - pub mechanism: String, - #[serde(skip_serializing_if = "Option::is_none")] + pub mechanisms: Vec, pub abi_version: Option, pub details: String, } +impl Serialize for PlatformInfo { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + let field_count = + 3 + usize::from(self.abi_version.is_some()) + usize::from(!self.mechanisms.is_empty()); + let mut state = serializer.serialize_struct("PlatformInfo", field_count)?; + state.serialize_field("name", &self.name)?; + if let Some(mechanism) = self.mechanisms.first() { + state.serialize_field("mechanism", mechanism)?; + } + state.serialize_field("mechanisms", &self.mechanisms)?; + if let Some(abi_version) = self.abi_version { + state.serialize_field("abi_version", &abi_version)?; + } + state.serialize_field("details", &self.details)?; + state.end() + } +} + +impl<'de> Deserialize<'de> for PlatformInfo { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + struct PlatformInfoFields { + name: String, + #[serde(default)] + mechanism: Option, + #[serde(default)] + mechanisms: Vec, + abi_version: Option, + details: String, + } + + let fields = PlatformInfoFields::deserialize(deserializer)?; + let mut mechanisms = fields.mechanisms; + if mechanisms.is_empty() { + if let Some(mechanism) = fields.mechanism { + mechanisms.push(mechanism); + } + } else if let Some(mechanism) = fields.mechanism { + if !mechanisms.iter().any(|value| value == &mechanism) { + mechanisms.insert(0, mechanism); + } + } + + Ok(Self { + name: fields.name, + mechanisms, + abi_version: fields.abi_version, + details: fields.details, + }) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ProviderApprovalStatus { + NotRequired, + Approved, + Blocked, + Missing, + Unknown, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ProviderAvailability { + Unavailable, + Inactive, + Active, + Degraded, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProviderState { + pub provider: String, + pub installed: bool, + pub approval_status: ProviderApprovalStatus, + pub active: bool, + pub healthy: bool, + pub availability: ProviderAvailability, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub degraded_reasons: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub last_healthy_timestamp: Option, +} + +impl ProviderState { + #[must_use] + pub fn active(provider: impl Into) -> Self { + Self { + provider: provider.into(), + installed: true, + approval_status: ProviderApprovalStatus::Approved, + active: true, + healthy: true, + availability: ProviderAvailability::Active, + degraded_reasons: Vec::new(), + last_healthy_timestamp: Some(Utc::now().to_rfc3339()), + } + } + + #[must_use] + pub fn active_without_approval(provider: impl Into) -> Self { + Self { + provider: provider.into(), + installed: true, + approval_status: ProviderApprovalStatus::NotRequired, + active: true, + healthy: true, + availability: ProviderAvailability::Active, + degraded_reasons: Vec::new(), + last_healthy_timestamp: Some(Utc::now().to_rfc3339()), + } + } + + #[must_use] + pub fn unavailable(provider: impl Into, reason: impl Into) -> Self { + Self { + provider: provider.into(), + installed: false, + approval_status: ProviderApprovalStatus::Unknown, + active: false, + healthy: false, + availability: ProviderAvailability::Unavailable, + degraded_reasons: vec![reason.into()], + last_healthy_timestamp: None, + } + } + + #[must_use] + pub fn unavailable_without_approval( + provider: impl Into, + reason: impl Into, + ) -> Self { + Self { + provider: provider.into(), + installed: false, + approval_status: ProviderApprovalStatus::NotRequired, + active: false, + healthy: false, + availability: ProviderAvailability::Unavailable, + degraded_reasons: vec![reason.into()], + last_healthy_timestamp: None, + } + } + + #[must_use] + pub fn unknown(provider: impl Into, reason: impl Into) -> Self { + Self { + provider: provider.into(), + installed: false, + approval_status: ProviderApprovalStatus::Unknown, + active: false, + healthy: false, + availability: ProviderAvailability::Unavailable, + degraded_reasons: vec![reason.into()], + last_healthy_timestamp: None, + } + } + + #[must_use] + pub fn degraded(provider: impl Into, reason: impl Into) -> Self { + Self { + provider: provider.into(), + installed: true, + approval_status: ProviderApprovalStatus::Approved, + active: true, + healthy: false, + availability: ProviderAvailability::Degraded, + degraded_reasons: vec![reason.into()], + last_healthy_timestamp: None, + } + } +} + /// Runtime enforcement state captured after execution completes. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SandboxRuntimeState { @@ -70,6 +254,22 @@ pub struct SandboxRuntimeState { pub applied: bool, pub supervised_requested: bool, pub supervised_active: bool, + #[serde(default = "legacy_contract_default")] + pub contract: String, + #[serde(default = "legacy_authorization_model_default")] + pub authorization_model: String, + #[serde(default)] + pub fd_injection_equivalent: bool, + #[serde(default)] + pub fail_open_possible: bool, + #[serde(default)] + pub deadline_miss_count: u64, + #[serde(default)] + pub dropped_event_count: u64, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub degraded_reasons: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub provider_states: Vec, #[serde(skip_serializing_if = "Option::is_none")] pub failure_reason: Option, } @@ -78,13 +278,27 @@ impl SandboxRuntimeState { #[must_use] pub fn static_mode(applied: bool, failure_reason: Option) -> Self { let support = nono::Sandbox::support_info(); - Self { + let mut state = Self { supported: support.is_supported, applied, supervised_requested: false, supervised_active: false, + contract: "static_kernel_sandbox".to_string(), + authorization_model: "none".to_string(), + fd_injection_equivalent: false, + fail_open_possible: false, + deadline_miss_count: 0, + dropped_event_count: 0, + degraded_reasons: Vec::new(), + provider_states: default_provider_states(applied), failure_reason, + }; + if !state.applied { + state + .degraded_reasons + .push("sandbox_apply_failed".to_string()); } + state } #[must_use] @@ -94,13 +308,107 @@ impl SandboxRuntimeState { failure_reason: Option, ) -> Self { let support = nono::Sandbox::support_info(); - Self { + let mut state = Self { supported: support.is_supported, applied, supervised_requested: true, supervised_active, + contract: supervised_contract_name().to_string(), + authorization_model: supervised_authorization_model().to_string(), + fd_injection_equivalent: cfg!(target_os = "linux"), + fail_open_possible: cfg!(target_os = "macos"), + deadline_miss_count: 0, + dropped_event_count: 0, + degraded_reasons: Vec::new(), + provider_states: default_provider_states(applied), failure_reason, + }; + + if !applied { + state + .degraded_reasons + .push("sandbox_apply_failed".to_string()); + } + if !supervised_active { + if cfg!(target_os = "macos") { + state + .degraded_reasons + .push("macos_authorization_contract_unavailable".to_string()); + } else { + state + .degraded_reasons + .push("supervised_interception_inactive".to_string()); + } + } + state + } + + #[must_use] + pub fn supervised_preflight_refused(failure_reason: impl Into) -> Self { + let support = nono::Sandbox::support_info(); + Self { + supported: support.is_supported, + applied: false, + supervised_requested: true, + supervised_active: false, + contract: supervised_contract_name().to_string(), + authorization_model: supervised_authorization_model().to_string(), + fd_injection_equivalent: cfg!(target_os = "linux"), + fail_open_possible: cfg!(target_os = "macos"), + deadline_miss_count: 0, + dropped_event_count: 0, + degraded_reasons: vec![ + "macos_authorization_contract_unavailable".to_string(), + "supervised_launch_refused_without_live_authorization_provider".to_string(), + ], + provider_states: default_provider_states(false), + failure_reason: Some(failure_reason.into()), + } + } + + #[must_use] + pub fn with_degraded_reason(mut self, reason: impl Into) -> Self { + self.degraded_reasons.push(reason.into()); + self + } + + #[must_use] + pub fn with_deadline_miss_count(mut self, deadline_miss_count: u64) -> Self { + self.deadline_miss_count = deadline_miss_count; + if deadline_miss_count > 0 { + self.degraded_reasons + .push("authorization_deadline_missed".to_string()); } + self + } + + #[must_use] + pub fn with_dropped_event_count(mut self, dropped_event_count: u64) -> Self { + self.dropped_event_count = dropped_event_count; + if dropped_event_count > 0 { + self.degraded_reasons + .push("dropped_enforcement_events".to_string()); + } + self + } + + pub fn set_dropped_event_count(&mut self, dropped_event_count: u64) { + self.dropped_event_count = dropped_event_count; + if dropped_event_count > 0 + && !self + .degraded_reasons + .iter() + .any(|reason| reason == "dropped_enforcement_events") + { + self.degraded_reasons + .push("dropped_enforcement_events".to_string()); + } + } + + #[must_use] + pub fn with_provider_states(mut self, provider_states: Vec) -> Self { + self.provider_states = provider_states; + self } } @@ -110,6 +418,8 @@ pub struct CapabilitySnapshot { pub fs: Vec, pub network_mode: String, #[serde(skip_serializing_if = "Option::is_none")] + pub mediation_backend_hint: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub proxy_port: Option, pub signal_mode: String, pub blocked_commands: Vec, @@ -142,6 +452,10 @@ pub struct SupervisorStats { pub requests_total: u64, pub requests_granted: u64, pub requests_denied: u64, + #[serde(default)] + pub deadline_miss_count: u64, + #[serde(default)] + pub dropped_event_count: u64, pub never_grant_blocks: u64, pub rate_limit_blocks: u64, } @@ -166,10 +480,7 @@ pub fn build_attestation( ) -> SandboxAttestation { let support = nono::Sandbox::support_info(); - let proxy_port = match caps.network_mode() { - nono::NetworkMode::ProxyOnly { port, .. } => Some(*port), - _ => None, - }; + let (network_mode, mediation_backend_hint, proxy_port) = describe_network_mode(caps); let cap_snapshot = CapabilitySnapshot { fs: caps @@ -182,36 +493,20 @@ pub fn build_attestation( is_file: c.is_file, }) .collect(), - network_mode: format!("{}", caps.network_mode()), + network_mode, + mediation_backend_hint, proxy_port, signal_mode: format!("{:?}", caps.signal_mode()), blocked_commands: caps.blocked_commands().to_vec(), extensions_enabled: caps.extensions_enabled(), }; - let mechanism = if cfg!(target_os = "macos") { - "seatbelt" - } else if cfg!(target_os = "linux") { - "landlock" - } else { - "none" - }; - - let enforced = runtime.applied && (!runtime.supervised_requested || runtime.supervised_active); - let enforcement_level = if !enforced { - EnforcementLevel::None - } else if runtime.supervised_active { - EnforcementLevel::KernelSupervised - } else { - EnforcementLevel::Kernel - }; - - SandboxAttestation { - enforced, - enforcement_level, + let mut attestation = SandboxAttestation { + enforced: false, + enforcement_level: EnforcementLevel::None, platform: PlatformInfo { name: support.platform.to_string(), - mechanism: mechanism.to_string(), + mechanisms: platform_mechanisms(&runtime), abi_version: None, details: support.details, }, @@ -220,6 +515,136 @@ pub fn build_attestation( supervisor: None, denials: vec![], audit: vec![], + }; + attestation.recompute_status(); + attestation +} + +impl SandboxAttestation { + pub fn recompute_status(&mut self) { + self.platform.mechanisms = platform_mechanisms(&self.runtime); + self.enforcement_level = effective_enforcement_level(&self.runtime); + self.enforced = matches!( + self.enforcement_level, + EnforcementLevel::Kernel | EnforcementLevel::KernelSupervised + ); + } +} + +fn legacy_contract_default() -> String { + "legacy_attestation".to_string() +} + +fn legacy_authorization_model_default() -> String { + "unknown".to_string() +} + +fn describe_network_mode(caps: &nono::CapabilitySet) -> (String, Option, Option) { + match caps.network_mode() { + nono::NetworkMode::ProxyOnly { port, .. } => ( + format!("{}", caps.network_mode()), + Some(if cfg!(target_os = "macos") { + "legacy_proxy_only_runtime".to_string() + } else { + "proxy_only_runtime".to_string() + }), + Some(*port), + ), + _ => (format!("{}", caps.network_mode()), None, None), + } +} + +fn platform_mechanisms(runtime: &SandboxRuntimeState) -> Vec { + if cfg!(target_os = "macos") { + let mut mechanisms = vec!["seatbelt".to_string()]; + for provider in runtime + .provider_states + .iter() + .filter(|provider| provider.active) + { + match provider.provider.as_str() { + "endpoint_security" => { + push_unique_mechanism(&mut mechanisms, "endpoint_security_contract") + } + "network_extension" => { + push_unique_mechanism(&mut mechanisms, "network_extension_contract") + } + _ => {} + } + } + mechanisms + } else if cfg!(target_os = "linux") { + let mut mechanisms = vec!["landlock".to_string()]; + if runtime.supervised_active { + mechanisms.push("seccomp_notify".to_string()); + } + mechanisms + } else { + vec!["none".to_string()] + } +} + +fn push_unique_mechanism(mechanisms: &mut Vec, mechanism: &str) { + if !mechanisms.iter().any(|existing| existing == mechanism) { + mechanisms.push(mechanism.to_string()); + } +} + +fn effective_enforcement_level(runtime: &SandboxRuntimeState) -> EnforcementLevel { + if !runtime.applied { + return EnforcementLevel::None; + } + + let has_degraded_runtime = !runtime.degraded_reasons.is_empty() + || runtime.deadline_miss_count > 0 + || runtime.dropped_event_count > 0; + let has_degraded_provider = runtime.provider_states.iter().any(|provider| { + !provider.active || !provider.healthy || !provider.degraded_reasons.is_empty() + }); + if has_degraded_runtime || has_degraded_provider { + return EnforcementLevel::Degraded; + } + + if runtime.supervised_requested && !runtime.supervised_active { + return EnforcementLevel::Degraded; + } + + if runtime.supervised_requested && runtime.supervised_active { + EnforcementLevel::KernelSupervised + } else { + EnforcementLevel::Kernel + } +} + +fn default_provider_states(applied: bool) -> Vec { + if cfg!(target_os = "macos") { + vec![if applied { + ProviderState::active_without_approval("seatbelt") + } else { + ProviderState::unavailable_without_approval("seatbelt", "sandbox_apply_failed") + }] + } else { + Vec::new() + } +} + +fn supervised_contract_name() -> &'static str { + if cfg!(target_os = "macos") { + "macos_endpoint_security_auth_contract" + } else if cfg!(target_os = "linux") { + "linux_seccomp_notify_fd_injection" + } else { + "supervised_contract_unavailable" + } +} + +fn supervised_authorization_model() -> &'static str { + if cfg!(target_os = "macos") { + "auth_open_point_in_time" + } else if cfg!(target_os = "linux") { + "fd_injection" + } else { + "none" } } @@ -246,6 +671,26 @@ mod tests { assert!(attestation.runtime.applied); } + #[test] + fn test_static_mode_with_default_provider_state_stays_kernel() { + let tmp = tempfile::TempDir::new().unwrap(); + let caps = CapabilitySet::new() + .allow_path(tmp.path(), AccessMode::Read) + .unwrap(); + + let attestation = build_attestation(&caps, SandboxRuntimeState::static_mode(true, None)); + + assert_eq!(attestation.enforcement_level, EnforcementLevel::Kernel); + assert!(attestation.enforced); + #[cfg(target_os = "macos")] + { + assert_eq!(attestation.runtime.provider_states.len(), 1); + assert_eq!(attestation.runtime.provider_states[0].provider, "seatbelt"); + assert!(attestation.runtime.provider_states[0].active); + assert!(attestation.runtime.provider_states[0].healthy); + } + } + #[test] fn test_build_attestation_supervised() { let tmp = tempfile::TempDir::new().unwrap(); @@ -294,11 +739,27 @@ mod tests { let attestation = build_attestation(&caps, SandboxRuntimeState::static_mode(true, None)); let json = serde_json::to_value(&attestation).unwrap(); + let expected_mechanism = if cfg!(target_os = "linux") { + "landlock" + } else if cfg!(target_os = "macos") { + "seatbelt" + } else { + "none" + }; assert!(json["enforced"].is_boolean()); assert!(json["runtime"]["applied"].is_boolean()); assert!(json["capabilities"]["proxy_port"].as_u64().is_some()); assert_eq!(json["capabilities"]["proxy_port"].as_u64().unwrap(), 8080); + assert_eq!( + json["platform"]["mechanism"].as_str(), + Some(expected_mechanism) + ); + assert!(json["platform"]["mechanisms"].is_array()); + assert!( + json.get("provider_states").is_none(), + "top-level provider_states should not be duplicated outside runtime" + ); assert!(!json["capabilities"]["blocked_commands"] .as_array() .unwrap() @@ -313,6 +774,37 @@ mod tests { assert_eq!(parsed, level); } + #[test] + fn test_platform_info_deserializes_legacy_mechanism_only() { + let parsed: PlatformInfo = serde_json::from_value(serde_json::json!({ + "name": "macos", + "mechanism": "seatbelt", + "details": "legacy seatbelt sandbox" + })) + .unwrap(); + + assert_eq!(parsed.mechanisms, vec!["seatbelt".to_string()]); + } + + #[test] + fn test_platform_info_inserts_legacy_mechanism_ahead_of_mechanism_list() { + let parsed: PlatformInfo = serde_json::from_value(serde_json::json!({ + "name": "macos", + "mechanism": "seatbelt", + "mechanisms": ["endpoint_security_contract"], + "details": "combined sandbox" + })) + .unwrap(); + + assert_eq!( + parsed.mechanisms, + vec![ + "seatbelt".to_string(), + "endpoint_security_contract".to_string() + ] + ); + } + #[test] fn test_attestation_metadata_path() { // Verify the structure matches what is_kernel_enforced() expects @@ -340,4 +832,163 @@ mod tests { .map(String::from); assert_eq!(level, Some("kernel".to_string())); } + + #[test] + fn test_dropped_events_degrade_attestation() { + let tmp = tempfile::TempDir::new().unwrap(); + let caps = CapabilitySet::new() + .allow_path(tmp.path(), AccessMode::Read) + .unwrap(); + + let mut attestation = + build_attestation(&caps, SandboxRuntimeState::static_mode(true, None)); + attestation.runtime.set_dropped_event_count(3); + attestation.recompute_status(); + + assert_eq!(attestation.enforcement_level, EnforcementLevel::Degraded); + assert!(!attestation.enforced); + assert_eq!(attestation.runtime.dropped_event_count, 3); + } + + #[test] + fn test_inactive_supervision_degrades_even_without_explicit_reason() { + let tmp = tempfile::TempDir::new().unwrap(); + let caps = CapabilitySet::new() + .allow_path(tmp.path(), AccessMode::Read) + .unwrap(); + let runtime = SandboxRuntimeState { + supported: true, + applied: true, + supervised_requested: true, + supervised_active: false, + contract: supervised_contract_name().to_string(), + authorization_model: supervised_authorization_model().to_string(), + fd_injection_equivalent: cfg!(target_os = "linux"), + fail_open_possible: cfg!(target_os = "macos"), + deadline_miss_count: 0, + dropped_event_count: 0, + degraded_reasons: Vec::new(), + provider_states: default_provider_states(true), + failure_reason: None, + }; + + let attestation = build_attestation(&caps, runtime); + + assert_eq!(attestation.enforcement_level, EnforcementLevel::Degraded); + assert!(!attestation.enforced); + } + + #[test] + fn test_macos_supervised_contract_is_not_fd_injection_equivalent() { + let runtime = SandboxRuntimeState::supervised_mode(true, false, None); + + if cfg!(target_os = "macos") { + assert_eq!(runtime.contract, "macos_endpoint_security_auth_contract"); + assert_eq!(runtime.authorization_model, "auth_open_point_in_time"); + assert!(!runtime.fd_injection_equivalent); + assert!(runtime.fail_open_possible); + assert!(runtime + .degraded_reasons + .iter() + .any(|reason| reason == "macos_authorization_contract_unavailable")); + } + } + + #[test] + fn test_legacy_attestation_shape_still_deserializes() { + let legacy = serde_json::json!({ + "enforced": true, + "enforcement_level": "kernel", + "platform": { + "name": "macos", + "mechanism": "seatbelt", + "abi_version": null, + "details": "legacy seatbelt sandbox" + }, + "runtime": { + "supported": true, + "applied": true, + "supervised_requested": false, + "supervised_active": false, + "failure_reason": null + }, + "capabilities": { + "fs": [], + "network_mode": "blocked", + "proxy_port": null, + "signal_mode": "Isolated", + "blocked_commands": [], + "extensions_enabled": false + } + }); + + let parsed: SandboxAttestation = + serde_json::from_value(legacy).expect("legacy attestation should deserialize"); + + assert_eq!(parsed.platform.mechanisms, vec!["seatbelt".to_string()]); + assert_eq!(parsed.runtime.contract, "legacy_attestation"); + assert_eq!(parsed.runtime.authorization_model, "unknown"); + assert_eq!(parsed.runtime.deadline_miss_count, 0); + assert_eq!(parsed.runtime.dropped_event_count, 0); + } + + #[test] + fn test_legacy_supervisor_stats_default_new_counters() { + let parsed: SupervisorStats = serde_json::from_value(serde_json::json!({ + "enabled": true, + "backend": "seccomp_notify", + "requests_total": 4, + "requests_granted": 3, + "requests_denied": 1, + "never_grant_blocks": 0, + "rate_limit_blocks": 0 + })) + .expect("legacy supervisor stats should deserialize"); + + assert_eq!(parsed.deadline_miss_count, 0); + assert_eq!(parsed.dropped_event_count, 0); + } + + #[test] + fn test_platform_mechanisms_only_include_active_supervised_contracts() { + let tmp = tempfile::TempDir::new().unwrap(); + let caps = CapabilitySet::new() + .allow_path(tmp.path(), AccessMode::Read) + .unwrap(); + let runtime = + SandboxRuntimeState::supervised_mode(true, true, None).with_provider_states(vec![ + ProviderState::active_without_approval("seatbelt"), + ProviderState::active("endpoint_security"), + ProviderState::unknown("network_extension", "provider_state_unknown"), + ]); + + let attestation = build_attestation(&caps, runtime); + let mechanisms = &attestation.platform.mechanisms; + + #[cfg(target_os = "macos")] + { + assert!(mechanisms.iter().any(|value| value == "seatbelt")); + assert_eq!( + mechanisms + .iter() + .filter(|value| *value == "seatbelt") + .count(), + 1 + ); + assert!(mechanisms + .iter() + .any(|value| value == "endpoint_security_contract")); + assert!(!mechanisms + .iter() + .any(|value| value == "network_extension_contract")); + } + + #[cfg(not(target_os = "macos"))] + { + assert_eq!( + mechanisms, + &vec!["landlock".to_string(), "seccomp_notify".to_string()] + ); + } + } } diff --git a/crates/libs/clawdstrike/src/sandbox/capability_builder.rs b/crates/libs/clawdstrike/src/sandbox/capability_builder.rs index f5a83b782..ba3fce154 100644 --- a/crates/libs/clawdstrike/src/sandbox/capability_builder.rs +++ b/crates/libs/clawdstrike/src/sandbox/capability_builder.rs @@ -224,6 +224,20 @@ impl CapabilityBuilder { // 7. EgressAllowlistGuard -> NetworkMode self.apply_network_mode(&mut caps); + #[cfg(target_os = "macos")] + { + if self.proxy_port.is_some() + && matches!(caps.network_mode(), NetworkMode::ProxyOnly { .. }) + { + warnings.push(TranslationWarning { + guard: "EgressAllowlistGuard".into(), + message: + "macOS uses a provider-agnostic mediated-egress contract; ProxyOnly is retained only as the current runtime backend hint until NetworkExtension content-filter integration lands" + .into(), + severity: WarningSeverity::Warning, + }); + } + } // 8. ShellCommandGuard -> blocked commands (defense in depth) self.apply_blocked_commands(&mut caps); @@ -1060,7 +1074,7 @@ mod tests { let builder = CapabilityBuilder::new(policy, tmp.path().to_path_buf()).with_proxy_port(8080); - let (caps, _) = builder.build_with_diagnostics().unwrap(); + let (caps, warnings) = builder.build_with_diagnostics().unwrap(); assert!( matches!( @@ -1069,6 +1083,30 @@ mod tests { ), "egress Block with proxy port should yield ProxyOnly mode" ); + + #[cfg(target_os = "macos")] + { + assert!( + warnings.iter().any(|warning| { + warning.guard == "EgressAllowlistGuard" + && warning.message.contains("provider-agnostic mediated-egress contract") + }), + "macOS should emit a diagnostic that ProxyOnly is only a legacy runtime backend hint" + ); + } + + #[cfg(not(target_os = "macos"))] + { + assert!( + !warnings.iter().any(|warning| { + warning.guard == "EgressAllowlistGuard" + && warning + .message + .contains("provider-agnostic mediated-egress contract") + }), + "non-macOS builds should not emit the macOS-only provider contract warning" + ); + } } #[test] diff --git a/crates/services/hush-cli/src/hush_run.rs b/crates/services/hush-cli/src/hush_run.rs index ec7a92259..42ca2beca 100644 --- a/crates/services/hush-cli/src/hush_run.rs +++ b/crates/services/hush-cli/src/hush_run.rs @@ -525,16 +525,13 @@ pub async fn cmd_run( match sandbox_nono::spawn_sandboxed_child(&caps, &command, &env_overrides) { Ok(result) => SandboxRunResult { child_status: exit_status_from_code(result.exit_code), - sandbox_attestation_json: serde_json::to_value( - clawdstrike::sandbox::build_attestation( - &caps, - clawdstrike::sandbox::SandboxRuntimeState::static_mode( - result.sandbox_applied, - result.sandbox_error.clone(), - ), + sandbox_attestation: Some(clawdstrike::sandbox::build_attestation( + &caps, + clawdstrike::sandbox::SandboxRuntimeState::static_mode( + result.sandbox_applied, + result.sandbox_error.clone(), ), - ) - .ok(), + )), sandbox_failure: result.sandbox_error, }, Err(e) => { @@ -571,32 +568,45 @@ pub async fn cmd_run( never_grant, ) { Ok(result) => { - let mut attestation = clawdstrike::sandbox::build_attestation( - &caps, - clawdstrike::sandbox::SandboxRuntimeState::supervised_mode( - result.sandbox_applied, - result.supervised_active, - result.sandbox_error.clone(), - ), - ); - if result.supervised_active { + let mut attestation = + clawdstrike::sandbox::build_attestation(&caps, result.runtime.clone()); + if !result.runtime.supervised_active + || matches!( + attestation.enforcement_level, + clawdstrike::sandbox::EnforcementLevel::Degraded + ) + { + let degraded_summary = if attestation.runtime.degraded_reasons.is_empty() { + "unknown reason".to_string() + } else { + attestation.runtime.degraded_reasons.join(", ") + }; + let _ = writeln!( + stderr, + "[nono] supervised contract unavailable or degraded: {}", + degraded_summary + ); + } + if result.runtime.supervised_active { attestation.supervisor = Some(result.stats.clone()); } if !result.denials.is_empty() { attestation.denials = result.denials.clone(); } - let _ = writeln!( - stderr, - "[nono] supervisor stats: {} requests ({} granted, {} denied, {} never-grant)", - result.stats.requests_total, - result.stats.requests_granted, - result.stats.requests_denied, - result.stats.never_grant_blocks, - ); + if result.stats.enabled || result.stats.requests_total > 0 { + let _ = writeln!( + stderr, + "[nono] supervisor stats: {} requests ({} granted, {} denied, {} never-grant)", + result.stats.requests_total, + result.stats.requests_granted, + result.stats.requests_denied, + result.stats.never_grant_blocks, + ); + } SandboxRunResult { child_status: exit_status_from_code(result.exit_code), - sandbox_attestation_json: serde_json::to_value(&attestation).ok(), - sandbox_failure: result.sandbox_error, + sandbox_attestation: Some(attestation), + sandbox_failure: result.runtime.failure_reason.clone(), } } Err(e) => { @@ -622,7 +632,7 @@ pub async fn cmd_run( { Ok(status) => SandboxRunResult { child_status: status, - sandbox_attestation_json: None, + sandbox_attestation: None, sandbox_failure: None, }, Err(e) => { @@ -648,7 +658,7 @@ pub async fn cmd_run( { Ok(status) => SandboxRunResult { child_status: status, - sandbox_attestation_json: None, + sandbox_attestation: None, sandbox_failure: None, }, Err(e) => { @@ -666,11 +676,22 @@ pub async fn cmd_run( let SandboxRunResult { child_status, - sandbox_attestation_json, - sandbox_failure, + sandbox_attestation, + mut sandbox_failure, } = sandbox_run; let child_exit_code = child_exit_code(child_status); + let dropped_events = event_emitter.dropped_count(); + let rejected_proxy_connections = proxy_rejected_connections + .as_ref() + .map(|count| count.load(Ordering::Relaxed)) + .unwrap_or(0); + let (effective_sandbox_note, effective_sandbox_failure) = finalize_sandbox_contract_status( + &sandbox_note, + sandbox_failure.clone(), + sandbox_attestation.as_ref(), + ); + sandbox_failure = effective_sandbox_failure; // Emit a best-effort session end marker. let mut extra = serde_json::Map::new(); @@ -684,21 +705,16 @@ pub async fn cmd_run( ); extra.insert( "sandbox".to_string(), - serde_json::Value::String(sandbox_note.clone()), + serde_json::Value::String(effective_sandbox_note.clone()), ); extra.insert( "proxy".to_string(), serde_json::Value::Bool(env_proxy_url.is_some()), ); - let dropped_events = event_emitter.dropped_count(); extra.insert( "droppedEventCount".to_string(), serde_json::Value::Number((dropped_events as u64).into()), ); - let rejected_proxy_connections = proxy_rejected_connections - .as_ref() - .map(|count| count.load(Ordering::Relaxed)) - .unwrap_or(0); extra.insert( "proxyRejectedConnections".to_string(), serde_json::Value::Number((rejected_proxy_connections as u64).into()), @@ -760,10 +776,12 @@ pub async fn cmd_run( "command": command, "events": events_out, "proxy": env_proxy_url, - "sandbox": sandbox_note, + "sandbox": effective_sandbox_note, "sandbox_failure": sandbox_failure.clone(), "child_exit_code": child_exit_code, "policy_exit_code": outcome.exit_code(), + "dropped_event_count": dropped_events, + "proxy_rejected_connections": rejected_proxy_connections, } })), Err(e) => { @@ -773,7 +791,9 @@ pub async fn cmd_run( }; // Merge sandbox attestation into receipt metadata - let receipt = if let Some(sandbox_json) = sandbox_attestation_json { + let receipt = if let Some(attestation) = sandbox_attestation { + let sandbox_json = serde_json::to_value(attestation) + .unwrap_or_else(|_| serde_json::json!({"serialization_error":"sandbox_attestation"})); receipt.merge_metadata(serde_json::json!({ "sandbox": sandbox_json })) } else { receipt @@ -840,7 +860,7 @@ pub async fn cmd_run( } else { let _ = writeln!(stdout, "Proxy: disabled"); } - let _ = writeln!(stdout, "Sandbox: {}", sandbox_note); + let _ = writeln!(stdout, "Sandbox: {}", effective_sandbox_note); // Exit behavior: // - Policy outcomes (warn/block) override child process exit. @@ -985,10 +1005,41 @@ struct SupervisedData { struct SandboxRunResult { child_status: std::process::ExitStatus, - sandbox_attestation_json: Option, + sandbox_attestation: Option, sandbox_failure: Option, } +fn finalize_sandbox_contract_status( + sandbox_note: &str, + sandbox_failure: Option, + sandbox_attestation: Option<&clawdstrike::sandbox::SandboxAttestation>, +) -> (String, Option) { + let Some(attestation) = sandbox_attestation else { + return (sandbox_note.to_string(), sandbox_failure); + }; + + let supervised_contract_degraded = attestation.runtime.supervised_requested + && (!attestation.runtime.supervised_active + || matches!( + attestation.enforcement_level, + clawdstrike::sandbox::EnforcementLevel::Degraded + | clawdstrike::sandbox::EnforcementLevel::None + )); + if !supervised_contract_degraded { + return (sandbox_note.to_string(), sandbox_failure); + } + + let failure = sandbox_failure.or_else(|| { + Some(if attestation.runtime.degraded_reasons.is_empty() { + "supervised_contract_degraded".to_string() + } else { + attestation.runtime.degraded_reasons.join(", ") + }) + }); + + (format!("{sandbox_note}-degraded"), failure) +} + /// Execution mode for the child process sandbox. enum SandboxExecution { /// Nono kernel-level sandbox @@ -1907,6 +1958,22 @@ mod tests { use super::*; use clawdstrike::Policy; + fn degraded_supervised_attestation() -> clawdstrike::sandbox::SandboxAttestation { + let caps = nono::CapabilitySet::new().block_network(); + clawdstrike::sandbox::build_attestation( + &caps, + clawdstrike::sandbox::SandboxRuntimeState::supervised_mode(true, false, None), + ) + } + + fn expected_supervised_degraded_reason() -> &'static str { + if cfg!(target_os = "macos") { + "macos_authorization_contract_unavailable" + } else { + "supervised_interception_inactive" + } + } + fn test_custom_event(id: usize) -> PolicyEvent { PolicyEvent { event_id: format!("event-{id}"), @@ -1943,6 +2010,31 @@ mod tests { assert!(profile.contains("(deny file-write* (subpath \"/Users/alice\"))")); } + #[test] + fn finalize_sandbox_contract_status_marks_degraded_supervised_runs() { + let attestation = degraded_supervised_attestation(); + let (note, failure) = + finalize_sandbox_contract_status("nono+supervised", None, Some(&attestation)); + + assert_eq!(note, "nono+supervised-degraded"); + assert_eq!( + failure.as_deref(), + Some(expected_supervised_degraded_reason()) + ); + } + + #[test] + fn finalize_sandbox_contract_status_preserves_non_supervised_runs() { + let attestation = clawdstrike::sandbox::build_attestation( + &nono::CapabilitySet::new().block_network(), + clawdstrike::sandbox::SandboxRuntimeState::static_mode(true, None), + ); + let (note, failure) = finalize_sandbox_contract_status("nono", None, Some(&attestation)); + + assert_eq!(note, "nono"); + assert!(failure.is_none()); + } + #[tokio::test] async fn sni_host_is_used_when_connect_target_is_ip() { use clawdstrike::Policy; diff --git a/crates/services/hush-cli/src/supervised_exec.rs b/crates/services/hush-cli/src/supervised_exec.rs index 2dbdaed9c..952bd2482 100644 --- a/crates/services/hush-cli/src/supervised_exec.rs +++ b/crates/services/hush-cli/src/supervised_exec.rs @@ -20,6 +20,9 @@ use std::collections::HashMap; use std::sync::Arc; +#[cfg(not(target_os = "linux"))] +use clawdstrike::sandbox::attestation::ProviderState; +use clawdstrike::sandbox::attestation::SandboxRuntimeState; use clawdstrike::sandbox::{SupervisorStats, TimestampedDenial}; use clawdstrike::{GuardContext, HushEngine}; use nono::{CapabilitySet, NeverGrantChecker}; @@ -41,15 +44,13 @@ use nono::sandbox::{ #[cfg(target_os = "linux")] use nono::{ApprovalBackend, ApprovalDecision, SupervisorSocket}; -#[cfg(target_os = "linux")] +#[cfg(any(target_os = "linux", doc))] use crate::sandbox_nono; /// Result of supervised execution. pub struct SupervisedResult { pub exit_code: i32, - pub sandbox_applied: bool, - pub supervised_active: bool, - pub sandbox_error: Option, + pub runtime: SandboxRuntimeState, pub stats: SupervisorStats, pub denials: Vec, } @@ -73,10 +74,23 @@ pub fn spawn_supervised_child( ) -> anyhow::Result { #[cfg(not(target_os = "linux"))] { - let _ = (caps, command, env_overrides, engine, context, never_grant); - anyhow::bail!( - "--supervised requires Linux seccomp user notifications; refusing to fall back to static sandboxing on this platform" - ); + let _ = (engine, context, never_grant); + let _ = (caps, command, env_overrides); + let runtime = SandboxRuntimeState::supervised_preflight_refused( + "macos_authorization_contract_unavailable", + ) + .with_provider_states(macos_provider_states_for_preflight_refusal()); + + Ok(SupervisedResult { + exit_code: 126, + runtime, + stats: SupervisorStats { + enabled: false, + backend: "macos_endpoint_security_auth_contract".to_string(), + ..Default::default() + }, + denials: Vec::new(), + }) } #[cfg(target_os = "linux")] @@ -229,9 +243,12 @@ pub fn spawn_supervised_child( Ok(SupervisedResult { exit_code: sandbox_nono::exit_code_from_status(status), - sandbox_applied, - supervised_active: sandbox_applied && notify_fd_received, - sandbox_error, + runtime: SandboxRuntimeState::supervised_mode( + sandbox_applied, + sandbox_applied && notify_fd_received, + sandbox_error, + ) + .with_deadline_miss_count(stats.deadline_miss_count), stats, denials, }) @@ -335,6 +352,7 @@ fn run_linux_supervisor_loop( let _ = deny_notif(notify_fd.as_raw_fd(), notif.id); } Ok(ApprovalDecision::Timeout) => { + stats.deadline_miss_count = stats.deadline_miss_count.saturating_add(1); continue_denial( &mut stats, &mut denials, @@ -362,6 +380,21 @@ fn run_linux_supervisor_loop( (stats, denials) } +#[cfg(not(target_os = "linux"))] +fn macos_provider_states_for_preflight_refusal() -> Vec { + vec![ + ProviderState::unavailable_without_approval( + "seatbelt", + "sandbox_not_invoked_due_to_supervised_preflight_refusal", + ), + ProviderState::unknown( + "endpoint_security", + "macos_authorization_contract_unavailable", + ), + ProviderState::unknown("network_extension", "provider_state_unknown"), + ] +} + #[cfg(target_os = "linux")] fn continue_denial( stats: &mut SupervisorStats, diff --git a/crates/services/hush-cli/tests/abuse_harness.rs b/crates/services/hush-cli/tests/abuse_harness.rs index 214cdeb40..7bcf48e6e 100644 --- a/crates/services/hush-cli/tests/abuse_harness.rs +++ b/crates/services/hush-cli/tests/abuse_harness.rs @@ -206,16 +206,15 @@ fn resolve_hush_binary() -> PathBuf { if let Ok(path) = std::env::var("CARGO_BIN_EXE_hush") { return PathBuf::from(path); } + if let Some(path) = resolve_hush_binary_from_current_target() { + return path; + } let candidate = workspace_root() .join("target") .join("debug") .join(if cfg!(windows) { "hush.exe" } else { "hush" }); - if candidate.exists() { - return candidate; - } - let status = Command::new("cargo") .current_dir(workspace_root()) .arg("build") @@ -232,6 +231,13 @@ fn resolve_hush_binary() -> PathBuf { candidate } +fn resolve_hush_binary_from_current_target() -> Option { + let current_exe = std::env::current_exe().ok()?; + let target_dir = current_exe.parent()?.parent()?; + let candidate = target_dir.join(if cfg!(windows) { "hush.exe" } else { "hush" }); + candidate.is_file().then_some(candidate) +} + fn create_temp_dir(prefix: &str) -> PathBuf { let seq = TEMP_SEQ.fetch_add(1, Ordering::Relaxed); let dir = std::env::temp_dir().join(format!("{}-{}-{}", prefix, std::process::id(), seq)); diff --git a/crates/services/hush-cli/tests/hunt_e2e.rs b/crates/services/hush-cli/tests/hunt_e2e.rs index b7abe6842..8715e36a1 100644 --- a/crates/services/hush-cli/tests/hunt_e2e.rs +++ b/crates/services/hush-cli/tests/hunt_e2e.rs @@ -66,16 +66,15 @@ fn resolve_hush_binary() -> PathBuf { if let Ok(path) = std::env::var("CARGO_BIN_EXE_hush") { return PathBuf::from(path); } + if let Some(path) = resolve_hush_binary_from_current_target() { + return path; + } let candidate = workspace_root() .join("target") .join("debug") .join(if cfg!(windows) { "hush.exe" } else { "hush" }); - if candidate.exists() { - return candidate; - } - let status = Command::new("cargo") .current_dir(workspace_root()) .arg("build") @@ -90,6 +89,13 @@ fn resolve_hush_binary() -> PathBuf { candidate } +fn resolve_hush_binary_from_current_target() -> Option { + let current_exe = std::env::current_exe().ok()?; + let target_dir = current_exe.parent()?.parent()?; + let candidate = target_dir.join(if cfg!(windows) { "hush.exe" } else { "hush" }); + candidate.is_file().then_some(candidate) +} + fn run_hush(args: &[String], timeout: Duration) -> CommandResult { let mut cmd = Command::new(resolve_hush_binary()); cmd.args(args).stdout(Stdio::piped()).stderr(Stdio::piped()); diff --git a/crates/services/hush-cli/tests/sandbox_nono_tests.rs b/crates/services/hush-cli/tests/sandbox_nono_tests.rs index 025877bf4..d6e2f8b21 100644 --- a/crates/services/hush-cli/tests/sandbox_nono_tests.rs +++ b/crates/services/hush-cli/tests/sandbox_nono_tests.rs @@ -28,16 +28,15 @@ fn resolve_hush_binary() -> PathBuf { if let Ok(path) = std::env::var("CARGO_BIN_EXE_hush") { return PathBuf::from(path); } + if let Some(path) = resolve_hush_binary_from_current_target() { + return path; + } let candidate = workspace_root() .join("target") .join("debug") .join(if cfg!(windows) { "hush.exe" } else { "hush" }); - if candidate.exists() { - return candidate; - } - let status = Command::new("cargo") .current_dir(workspace_root()) .arg("build") @@ -54,6 +53,13 @@ fn resolve_hush_binary() -> PathBuf { candidate } +fn resolve_hush_binary_from_current_target() -> Option { + let current_exe = std::env::current_exe().ok()?; + let target_dir = current_exe.parent()?.parent()?; + let candidate = target_dir.join(if cfg!(windows) { "hush.exe" } else { "hush" }); + candidate.is_file().then_some(candidate) +} + fn create_temp_dir(prefix: &str) -> PathBuf { let seq = TEMP_SEQ.fetch_add(1, Ordering::Relaxed); let dir = std::env::temp_dir().join(format!("{}-{}-{}", prefix, std::process::id(), seq)); diff --git a/crates/services/hush-cli/tests/supervisor_tests.rs b/crates/services/hush-cli/tests/supervisor_tests.rs index 600ded86e..03c0d099f 100644 --- a/crates/services/hush-cli/tests/supervisor_tests.rs +++ b/crates/services/hush-cli/tests/supervisor_tests.rs @@ -27,15 +27,15 @@ fn resolve_hush_binary() -> PathBuf { if let Ok(path) = std::env::var("CARGO_BIN_EXE_hush") { return PathBuf::from(path); } + if let Some(path) = resolve_hush_binary_from_current_target() { + return path; + } + let candidate = workspace_root() .join("target") .join("debug") .join(if cfg!(windows) { "hush.exe" } else { "hush" }); - if candidate.exists() { - return candidate; - } - let status = Command::new("cargo") .current_dir(workspace_root()) .arg("build") @@ -52,6 +52,13 @@ fn resolve_hush_binary() -> PathBuf { candidate } +fn resolve_hush_binary_from_current_target() -> Option { + let current_exe = std::env::current_exe().ok()?; + let target_dir = current_exe.parent()?.parent()?; + let candidate = target_dir.join(if cfg!(windows) { "hush.exe" } else { "hush" }); + candidate.is_file().then_some(candidate) +} + fn create_temp_dir(prefix: &str) -> PathBuf { let seq = TEMP_SEQ.fetch_add(1, Ordering::Relaxed); let dir = std::env::temp_dir().join(format!("{}-{}-{}", prefix, std::process::id(), seq)); @@ -222,7 +229,11 @@ fn receipt_contains_sandbox_attestation() { ); assert!( platform["mechanism"].is_string(), - "platform.mechanism should be a string" + "platform.mechanism should preserve the legacy singular field" + ); + assert!( + platform["mechanisms"].is_array(), + "platform.mechanisms should be an array" ); // Verify capabilities schema @@ -267,6 +278,119 @@ fn receipt_enforcement_level_is_kernel_for_static_sandbox() { ); } +#[test] +fn supervised_receipt_reports_outer_contract_truthfully() { + let echo = match which("echo") { + Some(p) => p, + None => return, + }; + let dir = create_temp_dir("hush-supervisor-degraded"); + let policy = write_policy(&dir); + let result = run_hush_with_receipt("nono", true, &policy, &[&echo, "test"]); + let _ = fs::remove_dir_all(&dir); + + if is_sandbox_unsupported(&result) { + return; + } + + let receipt = result + .receipt_json + .expect("receipt should exist after supervised run"); + let hush = &receipt["receipt"]["metadata"]["hush"]; + let sandbox = &receipt["receipt"]["metadata"]["sandbox"]; + let supervised_active = sandbox["runtime"]["supervised_active"] + .as_bool() + .expect("sandbox.runtime.supervised_active should be a bool"); + let expected_note = if supervised_active { + "nono+supervised" + } else { + "nono+supervised-degraded" + }; + + assert_eq!(hush["sandbox"].as_str(), Some(expected_note)); + assert!( + result.stdout.contains(&format!("Sandbox: {expected_note}")), + "stdout should disclose the effective supervised mode, got: {}", + result.stdout + ); + + if supervised_active { + assert_eq!( + receipt["receipt"]["verdict"]["passed"].as_bool(), + Some(true) + ); + } else { + assert_eq!( + receipt["receipt"]["verdict"]["passed"].as_bool(), + Some(false) + ); + let failure = hush["sandbox_failure"] + .as_str() + .expect("sandbox_failure should be present for degraded supervised runs"); + assert!( + !failure.is_empty(), + "degraded supervised runs must record a failure reason" + ); + assert!( + result + .stderr + .contains("supervised contract unavailable or degraded"), + "stderr should disclose degraded supervised contract, got: {}", + result.stderr + ); + if cfg!(target_os = "macos") { + assert_ne!( + result.exit_code, 0, + "supervised runs must fail closed when the authorization contract is unavailable" + ); + assert!( + failure.contains("macos_authorization_contract_unavailable"), + "expected degraded supervised failure detail, got: {failure}" + ); + } + } +} + +#[test] +fn supervised_preflight_refuses_to_exec_child_when_contract_is_unavailable() { + if cfg!(target_os = "linux") { + return; + } + + let shell = match which("sh") { + Some(path) => path, + None => return, + }; + let dir = create_temp_dir("hush-supervisor-preflight"); + let policy = write_policy(&dir); + let marker = dir.join("should-not-exist.txt"); + let marker_arg = marker.to_string_lossy().into_owned(); + let script = format!("printf touched > '{}'", marker_arg.replace('\'', "'\"'\"'")); + let result = run_hush_with_receipt("nono", true, &policy, &[&shell, "-c", &script]); + let marker_exists = marker.exists(); + let _ = fs::remove_dir_all(&dir); + + if is_sandbox_unsupported(&result) { + return; + } + + assert_ne!( + result.exit_code, 0, + "supervised preflight refusal must be non-zero" + ); + assert!( + !marker_exists, + "child command should not execute when supervised preflight is refused" + ); + assert!( + result + .stderr + .contains("supervised contract unavailable or degraded"), + "stderr should disclose the supervised preflight refusal, got: {}", + result.stderr + ); +} + #[test] fn receipt_no_sandbox_attestation_with_sandbox_none() { let dir = create_temp_dir("hush-supervisor-no-attest"); @@ -397,6 +521,8 @@ fn attestation_with_supervisor_stats_serializes_correctly() { requests_total: 47, requests_granted: 42, requests_denied: 5, + deadline_miss_count: 0, + dropped_event_count: 0, never_grant_blocks: 2, rate_limit_blocks: 0, }); @@ -434,10 +560,10 @@ fn attestation_with_supervisor_stats_serializes_correctly() { ); // Verify enforcement level - assert_eq!( - json["enforcement_level"].as_str().unwrap(), - "kernel_supervised" - ); + let enforcement_level = json["enforcement_level"] + .as_str() + .expect("enforcement_level should be a string"); + assert_eq!(enforcement_level, "kernel_supervised"); } #[test] diff --git a/docs/nono-integration/01-requirements.md b/docs/nono-integration/01-requirements.md new file mode 100644 index 000000000..c3072bb8a --- /dev/null +++ b/docs/nono-integration/01-requirements.md @@ -0,0 +1,96 @@ +# 01 - Requirements + +## Goals + +### G1: Kernel-Enforced Sandbox for `hush run` +Replace the ad-hoc `sandbox-exec`/`bwrap` wrappers with nono's cross-platform `CapabilitySet` + `Sandbox::apply()` API. Child processes spawned by `hush run` must be structurally constrained by the OS kernel. + +### G2: Policy-Driven Capability Construction +Derive nono `CapabilitySet` from ClawdStrike policy YAML. Guard configurations (forbidden paths, egress allowlists, command restrictions) should inform what capabilities are granted to the sandbox. A strict policy should produce a tighter sandbox. + +### G3: Attestation of Enforcement +Include nono sandbox state in signed receipts. Receipts must attest not just to what violations were *observed* but what restrictions were *enforced* at the kernel level. + +### G4: Dynamic Enforcement via Supervisor (Stretch) +Use nono's supervisor IPC (seccomp-notify on Linux, Seatbelt extensions on macOS) to route runtime capability requests through ClawdStrike guards. Every file access intercepted by the kernel triggers guard evaluation before the fd is injected. + +## Non-Goals + +### NG1: Replace Guards with Kernel Enforcement +Guards provide semantic, context-aware policy decisions (prompt injection, secret leak detection, jailbreak detection, MCP tool filtering). These cannot be expressed at the kernel level. Guards remain the primary policy engine; nono adds structural enforcement for what the kernel *can* express. + +### NG2: Full Glob Pattern Translation +ClawdStrike uses glob patterns like `**/.env*` that match anywhere in the filesystem tree. Nono requires fixed, canonical paths. Exhaustive enumeration of all possible glob matches is not feasible. Accept that kernel enforcement is coarser than guard-level enforcement. + +### NG3: Domain-Level Network Filtering in Kernel +Nono/Landlock filters by TCP port, not by hostname. Domain-level filtering continues to be handled by the HTTP CONNECT proxy. The kernel enforces `NetworkMode::ProxyOnly` to ensure traffic routes through the proxy. + +### NG4: Content Inspection in Kernel +Guards that inspect file content (SecretLeakGuard), command arguments (ShellCommandGuard regex patterns), or user input (JailbreakGuard, PromptInjectionGuard) remain application-level. The kernel sandbox does not inspect payloads. + +## Success Criteria + +| ID | Criterion | Measurement | +|----|-----------|-------------| +| SC1 | `hush run` on macOS uses Seatbelt via nono instead of `sandbox-exec` | Integration test: sandbox blocks access to `~/.ssh/id_rsa` | +| SC2 | `hush run` on Linux uses Landlock via nono instead of `bwrap` | Integration test: sandbox blocks access to `/etc/shadow` | +| SC3 | Policy YAML with `forbidden_path` patterns produces a `CapabilitySet` that excludes those paths | Unit test: capability set does not cover forbidden paths | +| SC4 | Policy YAML with `egress_allowlist` produces `NetworkMode::ProxyOnly` when proxy is enabled | Unit test: network mode matches policy | +| SC5 | Receipts include `sandbox` metadata with enforced capabilities | Receipt JSON contains `sandbox.capabilities` field | +| SC6 | `QueryContext` validates policy-derived capabilities match guard expectations | Pre-flight check: no false denials for allowed paths | +| SC7 | (Stretch) Supervisor intercepts unauthorized file access and routes through guard evaluation | Integration test: seccomp-notify triggers guard check | + +## Constraints + +### C1: nono Library Applies to Current Process +`Sandbox::apply()` restricts the *calling* process. For child process sandboxing, the sandbox must be applied after `fork()` but before `exec()`. This matches nono-cli's `Supervised` execution strategy. + +**Code ref**: `nono/crates/nono/src/sandbox/mod.rs:77-101` + +### C2: Landlock Is Strictly Allow-List +Linux Landlock has no deny semantics. You cannot express "allow everything except `/etc/shadow`". The sandbox starts with zero access and you grant paths explicitly. ClawdStrike's deny-oriented policies (ForbiddenPathGuard) must be inverted: start from allowed paths, omit forbidden ones. + +**Code ref**: `nono/crates/nono/src/sandbox/linux.rs:88-253` + +### C3: Seatbelt Supports Deny Rules (macOS Only) +macOS Seatbelt can express both allow and deny rules. Nono already translates deny groups to Seatbelt platform rules: `(deny file-read-data (subpath "PATH"))`. This is a macOS-specific advantage. + +**Code ref**: `nono/crates/nono-cli/src/policy.rs:437-475` + +### C4: Path Must Exist for Canonicalization +`FsCapability::new_dir()` and `new_file()` call `canonicalize()`, which requires the path to exist. Paths that don't exist at sandbox creation time cannot be granted. This affects dynamic working directories. + +**Code ref**: `nono/crates/nono/src/capability.rs:89-117` + +### C5: Irrevocable Once Applied +Once `Sandbox::apply()` succeeds, there is no API to expand permissions (except via supervisor extensions). The capability set must be complete before application. + +**Code ref**: `nono/crates/nono/src/sandbox/mod.rs:77-101` + +### C6: hush run Process Model +ClawdStrike's `hush run` currently uses `Command::new().spawn()` (not fork+exec). Integrating nono's supervised execution requires switching to fork+exec with sandbox application in the child before exec. + +**Code ref**: `clawdstrike/crates/services/hush-cli/src/hush_run.rs:787-833` + +## Dependencies + +| Dependency | Version | Purpose | +|------------|---------|---------| +| `nono` (library crate) | 0.11.x | `CapabilitySet`, `Sandbox`, `QueryContext`, `SandboxState` | +| `landlock` | 0.4 | Linux kernel sandbox (transitive via nono) | +| `nix` | 0.31 | Unix syscalls for fork/exec (transitive via nono) | +| `libc` | latest | Raw FFI for Seatbelt, seccomp-notify (transitive via nono) | + +## Platform Matrix + +| Feature | Linux (Landlock) | macOS (Seatbelt) | +|---------|-----------------|------------------| +| Filesystem allow-list | ABI v1+ | Always | +| Filesystem deny rules | Not supported | Seatbelt platform rules | +| Network port filtering | ABI v4+ | Always | +| Network domain filtering | Via proxy only | Via proxy only | +| Signal isolation | Not supported | `(deny signal (target others))` | +| File deletion prevention | `AccessFs::RemoveFile` | `(deny file-write-unlink)` | +| Command blocking | Via blocked_commands list | Via blocked_commands list | +| Sandbox extensions | seccomp-notify + fd inject | `sandbox_extension_issue/consume` | +| Supervisor IPC | Unix socket + SCM_RIGHTS | Unix socket + extension tokens | diff --git a/docs/nono-integration/02-architecture.md b/docs/nono-integration/02-architecture.md new file mode 100644 index 000000000..c927342a5 --- /dev/null +++ b/docs/nono-integration/02-architecture.md @@ -0,0 +1,271 @@ +# 02 - Architecture + +## Current State: Advisory Only + +``` +hush run policy.yaml -- agent-command + | + +-- Load policy, create HushEngine + +-- Start HTTP CONNECT proxy (optional) + +-- Prepare sandbox wrapper (optional, basic) + | macOS: sandbox-exec -f profile (hardcoded denies) + | Linux: bwrap with bind mounts + +-- Command::new().spawn() child + | Child runs with UNRESTRICTED access + | Proxy observes CONNECT requests + +-- Wait for child exit + +-- Guards evaluate observed traffic (after the fact) + +-- Generate receipt (advisory verdict) + +-- Exit with policy-determined code +``` + +**Key problem**: Guards produce verdicts but don't prevent operations. The child has full OS permissions. The optional sandbox wrapper is not integrated with policy. + +### Current Integration Points + +| Component | File | Line | Current Behavior | +|-----------|------|------|-----------------| +| Sandbox creation | `hush_run.rs` | 690 | `maybe_prepare_sandbox()` — hardcoded deny paths | +| macOS profile gen | `hush_run.rs` | 746 | `generate_macos_sandbox_profile()` — static Seatbelt | +| Child spawn | `hush_run.rs` | 787 | `spawn_and_wait_child()` — `Command::new().spawn()` | +| Proxy start | `hush_run.rs` | 836 | `start_connect_proxy()` — CONNECT tunnel | +| Guard evaluation | `engine.rs` | 352 | `check_action_report()` — advisory pipeline | +| Receipt creation | `hush_run.rs` | 498 | `Receipt::new()` — no sandbox attestation | + +## Target State: Kernel-Enforced + +``` +hush run policy.yaml -- agent-command + | + +-- Load policy, create HushEngine + +-- Translate policy -> CapabilitySet (NEW) + | ForbiddenPathGuard patterns -> omit from cap set + | PathAllowlistGuard -> direct cap set mapping + | EgressAllowlistGuard -> NetworkMode::ProxyOnly + | ShellCommandGuard -> blocked_commands + +-- Pre-flight: QueryContext validates cap set (NEW) + +-- Start HTTP CONNECT proxy (if egress policy exists) + +-- fork() (CHANGED from Command::spawn) + | + +-- CHILD (sandboxed): + | Apply Sandbox::apply(&caps) (kernel enforcement) + | [Linux] Install seccomp-notify filter (optional) + | exec(agent-command) + | ALL operations constrained by kernel + | + +-- PARENT (unsandboxed): + | [Optional] Run supervisor loop + | Receive seccomp notifications + | Route through HushEngine guards + | Inject fd or deny + | Forward signals to child + | Wait for child exit + | Guards evaluate proxy traffic (network) + | + +-- Generate receipt with sandbox state (CHANGED) + | Includes enforced CapabilitySet + | Includes SandboxState JSON + +-- Exit with policy-determined code +``` + +## Component Architecture + +### New Components + +``` +clawdstrike/crates/libs/clawdstrike/src/ + sandbox/ # NEW module + mod.rs # SandboxPolicy facade + capability_builder.rs # Policy -> CapabilitySet translation + preflight.rs # QueryContext validation + +clawdstrike/crates/services/hush-cli/src/ + sandbox_nono.rs # NEW: replaces maybe_prepare_sandbox() + supervised_exec.rs # NEW: fork+exec with nono sandbox +``` + +### Data Flow + +``` + Policy YAML + | + v + +-------------------+ + | HushEngine | + | (guard config) | + +-------------------+ + | | + guard | | sandbox + eval | | construction + v v + +---------+ +--------------------+ + | Guards | | CapabilityBuilder | + | (13+) | | policy_to_caps() | + +---------+ +--------------------+ + | | + | v + | +-------------+ + | | QueryContext | + | | (pre-flight) | + | +-------------+ + | | + v v + +--------+ +------------+ + |Receipts| | Sandbox | + |+sandbox| | ::apply() | + | state | +------------+ + +--------+ | + v + +-------------+ + | Kernel | + | Enforcement | + +-------------+ +``` + +### Integration with Existing Systems + +#### IRM → Sandbox Validation + +The IRM monitors (FilesystemIrm, NetworkIrm, ExecutionIrm) currently produce advisory `Decision` values. With nono integration: + +1. **FilesystemIrm** forbidden paths → paths omitted from `CapabilitySet` +2. **NetworkIrm** allowed hosts → `NetworkMode::ProxyOnly` forces traffic through proxy where hosts are checked +3. **ExecutionIrm** blocked commands → `CapabilitySet::block_command()` + +The IRM continues to run at the proxy/application level for traffic that passes kernel checks. + +**Code refs**: +- `clawdstrike/crates/libs/clawdstrike/src/irm/fs.rs:26-61` — forbidden path patterns +- `clawdstrike/crates/libs/clawdstrike/src/irm/net.rs:122-188` — host allowlist +- `clawdstrike/crates/libs/clawdstrike/src/irm/exec.rs:75-99` — dangerous command patterns + +#### Guard → CapabilityBuilder + +Each guard type maps to a specific aspect of the `CapabilitySet`: + +| Guard | CapabilitySet Effect | Enforcement Level | +|-------|---------------------|-------------------| +| ForbiddenPathGuard | Omit forbidden paths from grants | Kernel (allow-list) | +| PathAllowlistGuard | Direct mapping to `allow_path()` calls | Kernel (allow-list) | +| EgressAllowlistGuard | `NetworkMode::ProxyOnly` + proxy port | Kernel (port) + App (domain) | +| ShellCommandGuard | `block_command()` for known-dangerous | App (name check pre-exec) + App (regex) | +| SecretLeakGuard | No kernel equivalent | App only | +| PatchIntegrityGuard | No kernel equivalent | App only | +| McpToolGuard | No kernel equivalent | App only | +| PromptInjectionGuard | No kernel equivalent | App only | +| JailbreakGuard | No kernel equivalent | App only | +| ComputerUseGuard | No kernel equivalent | App only | + +#### Proxy → Network Enforcement + +The existing CONNECT proxy provides domain-level filtering. With nono: + +1. Nono enforces `NetworkMode::ProxyOnly { port }` at kernel level +2. All outbound traffic must go through the proxy +3. Proxy performs domain allowlist checks via guards +4. Direct connections bypass is **structurally impossible** (kernel blocks non-proxy ports) + +**Code refs**: +- `clawdstrike/crates/services/hush-cli/src/hush_run.rs:836-898` — proxy start +- `nono/crates/nono/src/capability.rs:514-520` — `proxy_only()` builder + +#### Receipts → Sandbox Attestation + +Current receipt structure (advisory): +```json +{ + "verdict": { "passed": false }, + "provenance": { "violations": [...] } +} +``` + +With nono (enforced). Note: the `capabilities` field is a custom `CapabilitySnapshot` +built from `CapabilitySet` accessors, NOT nono's `SandboxState` (which only has `fs` and +`net_blocked`). See [06-receipt-attestation.md](06-receipt-attestation.md) for full schema. + +```json +{ + "verdict": { "passed": true }, + "provenance": { "violations": [...] }, + "metadata": { + "sandbox": { + "enforced": true, + "platform": { "name": "linux", "mechanism": "landlock", "abi_version": 5 }, + "capabilities": { + "fs": [ + { "resolved": "/usr", "access": "Read", "is_file": false }, + { "resolved": "/project", "access": "ReadWrite", "is_file": false } + ], + "network_mode": "ProxyOnly", + "proxy_port": 8080, + "signal_mode": "Isolated", + "blocked_commands": ["rm", "sudo", "dd"] + } + } + } +} +``` + +**Code refs**: +- `clawdstrike/crates/libs/hush-core/src/receipt.rs:157-245` — Receipt struct +- `nono/crates/nono/src/capability.rs:721-789` — CapabilitySet accessors (fs_capabilities, network_mode, etc.) + +## Execution Strategies + +### Strategy 1: Static Sandbox (Phase 1-2) + +``` +Parent: build CapabilitySet from policy + fork() +Child: Sandbox::apply(&caps) + exec(command) +Parent: wait + generate receipt +``` + +- Simple, no IPC +- All capabilities determined before execution +- Cannot handle dynamic path requests + +### Strategy 2: Supervised Sandbox (Phase 4) + +``` +Parent: build CapabilitySet from policy + create supervisor socket pair + fork() +Child: Sandbox::apply(&caps) + install seccomp-notify (Linux) / enable extensions (macOS) + exec(command) +Parent: supervisor loop: + receive seccomp notification / extension request + read path from child /proc/PID/mem + check never_grant list + route through HushEngine guards + if approved: inject fd / issue extension token + if denied: send EPERM / deny + log to receipt + wait + generate receipt +``` + +- Full dynamic enforcement +- Every file access is guard-evaluated AND kernel-enforced +- Requires fork+exec (not `Command::spawn()`) +- Higher latency per file operation + +## Threading Considerations + +ClawdStrike's `hush run` uses Tokio async runtime. Nono's supervised execution requires `fork()`, which is unsafe in multi-threaded processes. Options: + +1. **Fork before Tokio, start proxy after**: Fork first (single-threaded), then start the Tokio runtime in the parent. The proxy starts after fork, so the child must inherit the proxy port (e.g., via env var or pre-allocated port). The child cannot make network requests until the proxy is ready, but the kernel's `ProxyOnly` mode ensures it cannot bypass the proxy regardless. +2. **Pre-fork pattern**: Use `unsafe { fork() }` with careful thread management, similar to nono-cli's `ThreadingContext::Strict` check. Validate thread count before fork. +3. **Separate process**: Spawn nono-cli as a subprocess that manages the sandbox, communicate via pipe/socket. + +Recommended: Option 1 (fork before Tokio) for simplest integration. The parent starts the async runtime after fork for proxy/event handling. + +> **IMPORTANT**: The child MUST close inherited fds (proxy listening socket, log handles) +> via `close_inherited_fds()` before exec. Without this, the child could impersonate the +> proxy or leak sensitive resources. See nono-cli's `exec_strategy.rs:493` for reference. +> +> Also note that `Sandbox::apply()` in the child must NOT use Rust's `?` operator post-fork. +> Only async-signal-safe operations are permitted. Errors must use raw `libc::write` + `libc::_exit`. + +**Code ref**: `nono/crates/nono-cli/src/exec_strategy.rs:329-365` — threading validation diff --git a/docs/nono-integration/03-sandbox-replacement.md b/docs/nono-integration/03-sandbox-replacement.md new file mode 100644 index 000000000..d27f2b688 --- /dev/null +++ b/docs/nono-integration/03-sandbox-replacement.md @@ -0,0 +1,434 @@ +# 03 - Sandbox Replacement (Phase 1) + +Replace the ad-hoc `sandbox-exec`/`bwrap` wrappers in `hush run` with nono's cross-platform sandbox API. + +## Current Implementation + +### SandboxWrapper Enum + +**File**: `crates/services/hush-cli/src/hush_run.rs:678-688` + +```rust +enum SandboxWrapper { + None, + SandboxExec { profile_path: PathBuf }, // macOS + Bwrap { args: Vec }, // Linux +} +``` + +### maybe_prepare_sandbox() + +**File**: `crates/services/hush-cli/src/hush_run.rs:690-743` + +Generates platform-specific sandbox configuration: + +- **macOS** (lines 698-718): Writes a Seatbelt profile to a temp file, returns `SandboxExec` +- **Linux** (lines 722-731): Builds bwrap bind-mount arguments, returns `Bwrap` + +### generate_macos_sandbox_profile() + +**File**: `crates/services/hush-cli/src/hush_run.rs:746-785` + +Generates a static Seatbelt profile with hardcoded denies: + +```scheme +(version 1) +(allow default) +(deny file-read* (subpath "/Users/USERNAME/.ssh")) +(deny file-read* (subpath "/Users/USERNAME/.gnupg")) +(deny file-read* (subpath "/Users/USERNAME/.aws")) +(deny file-read* (subpath "/Users/USERNAME/.config/gcloud")) +(deny file-read* (subpath "/Users/USERNAME/.config/gh")) +(deny file-read* (subpath "/Users/USERNAME/.config/git")) +(deny file-read* (subpath "/Users/USERNAME/.kube")) +``` + +**Problems with current approach**: +1. Not integrated with policy YAML - hardcoded paths only +2. Uses `(allow default)` - overly permissive base +3. No network enforcement +4. No signal isolation +5. No command blocking +6. Linux bwrap path is minimal (bind mounts only) +7. Not capability-based + +### spawn_and_wait_child() + +**File**: `crates/services/hush-cli/src/hush_run.rs:787-833` + +```rust +// Current: wraps command with platform shim +let mut cmd = match sandbox { + None => Command::new(&command[0]), + SandboxExec { profile_path } => { + Command::new("/usr/bin/sandbox-exec") + .arg("-f").arg(profile_path) + .arg(&command[0]) + } + Bwrap { args } => { + Command::new("bwrap") + .args(args) + .arg(&command[0]) + } +}; +cmd.args(&command[1..]); +// ... set env vars, spawn, wait +``` + +## Replacement Design + +### New: nono as Cargo Dependency + +Add to `crates/services/hush-cli/Cargo.toml`: + +```toml +[dependencies] +nono = { path = "../../../nono/crates/nono" } +# Or when published: nono = "0.11" +``` + +### New: sandbox_nono.rs + +Replace `SandboxWrapper`, `maybe_prepare_sandbox()`, `generate_macos_sandbox_profile()`, and the sandbox branch in `spawn_and_wait_child()` with a unified module. + +#### build_capability_set() + +Builds a `CapabilitySet` from the current execution context: + +```rust +use nono::{CapabilitySet, AccessMode, NetworkMode, Sandbox, SandboxState, QueryContext}; + +pub fn build_capability_set( + working_dir: &Path, + command: &[String], + proxy_port: Option, + extra_read_paths: &[PathBuf], + extra_write_paths: &[PathBuf], + blocked_commands: &[String], +) -> nono::Result { + let mut caps = CapabilitySet::new(); + + // Grant working directory read-write + caps = caps.allow_path(working_dir, AccessMode::ReadWrite)?; + + // System paths (read-only) + // These mirror nono's system_read_* groups + for path in system_read_paths() { + if path.exists() { + caps = caps.allow_path(path, AccessMode::Read)?; + } + } + + // System writable paths (tmp, dev) + for path in system_write_paths() { + if path.exists() { + caps = caps.allow_path(path, AccessMode::ReadWrite)?; + } + } + + // Extra paths from policy + for path in extra_read_paths { + if path.exists() { + caps = caps.allow_path(path, AccessMode::Read)?; + } + } + for path in extra_write_paths { + if path.exists() { + caps = caps.allow_path(path, AccessMode::ReadWrite)?; + } + } + + // Network: proxy-only if proxy is active + if let Some(port) = proxy_port { + caps = caps.proxy_only(port); + } else { + caps = caps.block_network(); + } + + // Blocked commands + for cmd in blocked_commands { + caps = caps.block_command(cmd); + } + + Ok(caps) +} +``` + +#### system_read_paths() / system_write_paths() + +Platform-specific system paths, matching nono's `policy.json` groups: + +```rust +#[cfg(target_os = "macos")] +fn system_read_paths() -> Vec { + // NOTE: Do NOT grant broad directories like /private/var which contains + // sensitive data (FileVault keys, user databases, Keychain DBs). + // Grant only specific subdirectories needed for execution. + vec![ + "/bin", "/usr", "/sbin", + "/System/Library", "/Library", + "/private/etc", + "/opt/homebrew", + ].into_iter().map(PathBuf::from).collect() +} + +#[cfg(target_os = "linux")] +fn system_read_paths() -> Vec { + vec![ + "/bin", "/lib", "/lib64", "/usr", "/sbin", + "/etc", "/proc", "/sys", "/run", + ].into_iter().map(PathBuf::from).collect() +} +``` + +#### validate_capabilities() + +Pre-flight check using `QueryContext`: + +```rust +pub fn validate_capabilities( + caps: &CapabilitySet, + command: &[String], + working_dir: &Path, +) -> Vec { + let ctx = QueryContext::new(caps.clone()); + let mut warnings = vec![]; + + // Verify working directory is accessible + if let QueryResult::Denied(reason) = ctx.query_path(working_dir, AccessMode::ReadWrite) { + warnings.push(format!("Working dir {} denied: {:?}", working_dir.display(), reason)); + } + + // Verify command binary is accessible + if let Ok(bin) = which::which(&command[0]) { + if let QueryResult::Denied(reason) = ctx.query_path(&bin, AccessMode::Read) { + warnings.push(format!("Command {} denied: {:?}", bin.display(), reason)); + } + } + + warnings +} +``` + +#### spawn_sandboxed_child() + +Replace `spawn_and_wait_child()` with fork+exec: + +> **SAFETY NOTE**: After `fork()`, the child process must NOT use Rust's `?` operator, +> `panic!`, or any non-async-signal-safe operations. Errors must be reported via raw +> `libc::write` to stderr and terminated with `libc::_exit`. See nono-cli's +> `exec_strategy.rs:407-418` for the reference implementation. + +```rust +pub fn spawn_sandboxed_child( + caps: &CapabilitySet, + command: &[String], + proxy_port: Option, +) -> Result { + // Pre-fork: prepare all strings (no allocation after fork) + let c_program = CString::new(command[0].as_str())?; + let c_args: Vec = command.iter() + .map(|a| CString::new(a.as_str())) + .collect::>()?; + + // Build child environment: inherit current env + add proxy vars + let mut env_map: HashMap = std::env::vars().collect(); + if let Some(port) = proxy_port { + let proxy_url = format!("http://127.0.0.1:{}", port); + env_map.insert("HTTPS_PROXY".into(), proxy_url.clone()); + env_map.insert("HTTP_PROXY".into(), proxy_url.clone()); + env_map.insert("ALL_PROXY".into(), proxy_url); + } + let c_env: Vec = env_map.iter() + .map(|(k, v)| CString::new(format!("{}={}", k, v))) + .collect::>()?; + + match unsafe { nix::unistd::fork() }? { + ForkResult::Child => { + // CHILD: only async-signal-safe operations from here + + // Close inherited fds (proxy socket, parent resources) + // Keep only stdin(0), stdout(1), stderr(2) + close_inherited_fds(3); + + // Apply sandbox (irrevocable) — no ? operator! + if let Err(e) = Sandbox::apply(caps) { + let msg = format!("nono: sandbox apply failed: {}\n", e); + unsafe { libc::write(2, msg.as_ptr().cast(), msg.len()) }; + unsafe { libc::_exit(126) }; + } + + // Exec — replaces process image + if let Err(e) = nix::unistd::execve(&c_program, &c_args, &c_env) { + let msg = format!("nono: exec failed: {}\n", e); + unsafe { libc::write(2, msg.as_ptr().cast(), msg.len()) }; + unsafe { libc::_exit(127) }; + } + unsafe { libc::_exit(126) }; // unreachable + } + ForkResult::Parent { child } => { + // Forward signals to child + // (install SIGINT/SIGTERM handler that calls kill(child, sig)) + + // Wait for child (blocking — run BEFORE Tokio if applicable) + let status = nix::sys::wait::waitpid(child, None)?; + Ok(exit_code_from_status(status)) + } + } +} + +fn close_inherited_fds(from_fd: i32) { + // Close all fds >= from_fd to prevent child inheriting + // proxy socket, supervisor socket, log handles, etc. + if let Ok(max) = rlimit::getrlimit(rlimit::Resource::NOFILE) { + for fd in from_fd..max.0 as i32 { + unsafe { libc::close(fd) }; + } + } +} +``` + +### Changes to hush_run.rs + +#### Remove + +- `SandboxWrapper` enum (line 678-688) +- `maybe_prepare_sandbox()` (line 690-743) +- `generate_macos_sandbox_profile()` (line 746-785) +- Sandbox branch in `spawn_and_wait_child()` (line 787-833) + +#### Modify cmd_run() + +**File**: `crates/services/hush-cli/src/hush_run.rs:234-577` + +Replace sandbox preparation (lines 377-383) with: + +```rust +// Build capability set +let caps = sandbox_nono::build_capability_set( + &working_dir, + &command, + proxy_port, + &extra_read_paths, + &extra_write_paths, + &blocked_commands, +)?; + +// Pre-flight validation +let warnings = sandbox_nono::validate_capabilities(&caps, &command, &working_dir); +for w in &warnings { + eprintln!("[nono] warning: {}", w); +} + +// Check platform support +if !Sandbox::is_supported() { + eprintln!("[nono] warning: sandbox not supported on this platform"); + eprintln!("[nono] {}", Sandbox::support_info().details); +} +``` + +Replace child spawn (lines 385-406) with: + +```rust +// Spawn sandboxed child +let child_exit_code = sandbox_nono::spawn_sandboxed_child( + &caps, + &command, + &env_vars, +)?; +``` + +### Migration Path + +1. Add `nono` dependency to hush-cli Cargo.toml +2. Create `sandbox_nono.rs` module +3. Replace `maybe_prepare_sandbox()` calls with `build_capability_set()` +4. Replace `spawn_and_wait_child()` sandbox branches with `spawn_sandboxed_child()` +5. Delete `SandboxWrapper`, `generate_macos_sandbox_profile()` +6. Update tests + +### Testing + +```rust +#[test] +fn test_capability_set_blocks_ssh() { + let tmp = tempfile::TempDir::new().unwrap(); // real dir for canonicalization + let caps = build_capability_set( + tmp.path(), + &["ls".into()], + None, // no proxy + &[], + &[], + &[], + ).unwrap(); + + let ctx = QueryContext::new(caps); + let home = dirs::home_dir().unwrap(); + let ssh_dir = home.join(".ssh"); + + // .ssh should NOT be in the capability set + assert!(matches!( + ctx.query_path(&ssh_dir, AccessMode::Read), + QueryResult::Denied(_) + )); +} + +#[test] +fn test_capability_set_allows_working_dir() { + let tmp = tempfile::TempDir::new().unwrap(); + let caps = build_capability_set( + tmp.path(), + &["ls".into()], + None, + &[], + &[], + &[], + ).unwrap(); + + let ctx = QueryContext::new(caps); + assert!(matches!( + ctx.query_path(tmp.path(), AccessMode::ReadWrite), + QueryResult::Allowed(_) + )); +} + +#[test] +fn test_proxy_only_network() { + let tmp = tempfile::TempDir::new().unwrap(); + let caps = build_capability_set( + tmp.path(), + &["ls".into()], + Some(8080), + &[], + &[], + &[], + ).unwrap(); + + // network_mode() returns &NetworkMode — dereference for matches! + assert!(matches!( + *caps.network_mode(), + NetworkMode::ProxyOnly { port: 8080, .. } + )); +} +``` + +### Rollback Plan + +If nono integration causes issues, the `--sandbox=legacy` flag can be added to fall back to the old `sandbox-exec`/`bwrap` behavior during the transition period. + +## Nono API Surface Used (Phase 1) + +| API | Source | Purpose | +|-----|--------|---------| +| `CapabilitySet::new()` | `capability.rs:467` | Create empty set | +| `.allow_path(path, mode)` | `capability.rs:477` | Grant directory access | +| `.allow_file(path, mode)` | `capability.rs:487` | Grant file access | +| `.block_network()` | `capability.rs:497` | Block all network | +| `.proxy_only(port)` | `capability.rs:514` | Route through proxy | +| `.block_command(cmd)` | `capability.rs:630` | Block command execution | +| `Sandbox::apply(&caps)` | `sandbox/mod.rs:77` | Apply kernel sandbox | +| `Sandbox::is_supported()` | `sandbox/mod.rs:105` | Check platform support | +| `Sandbox::support_info()` | `sandbox/mod.rs:124` | Get support details | +| `QueryContext::new(caps)` | `query.rs:58` | Create query context | +| `.query_path(path, mode)` | `query.rs:71` | Check path permission | +| `SandboxState::from_caps()` | `state.rs:34` | Serialize for receipt | diff --git a/docs/nono-integration/04-policy-translation.md b/docs/nono-integration/04-policy-translation.md new file mode 100644 index 000000000..e0670e858 --- /dev/null +++ b/docs/nono-integration/04-policy-translation.md @@ -0,0 +1,434 @@ +# 04 - Policy Translation (Phase 2) + +Translate ClawdStrike policy YAML guard configurations into nono `CapabilitySet` operations. + +## The Fundamental Challenge + +ClawdStrike policies are **deny-oriented**: "block these paths, block these hosts, block these commands." Nono/Landlock is **allow-oriented**: "only these paths, only these ports, only these commands are permitted." Translation requires **inversion**: start from a reasonable allow-set, then subtract what guards forbid. + +macOS Seatbelt is the exception — it supports both allow and deny rules, so some deny-oriented patterns can be expressed directly. + +## Guard-to-Capability Mapping + +### ForbiddenPathGuard → Path Omission + +**Guard source**: `clawdstrike/crates/libs/clawdstrike/src/guards/forbidden_path.rs:44-104` + +**Strategy**: The `CapabilityBuilder` constructs an allow-set of system/working paths. Paths matching ForbiddenPathGuard patterns are **not granted**. On macOS, additional deny platform rules are generated. + +#### Default Forbidden Paths (from guard) + +``` +**/.ssh/** **/.aws/** **/.gnupg/** +**/id_rsa* **/.azure/** **/.kube/** +**/id_ed25519* **/.gcloud/** **/.docker/** +**/id_ecdsa* **/.npmrc **/.password-store/** +/etc/shadow **/.env **/.1password/** +/etc/passwd **/.env.* **/.git-credentials +/etc/sudoers **/.gitconfig +``` + +#### Nono Translation + +**Linux (Landlock)**: These paths are simply never added to the `CapabilitySet`. Since Landlock is strictly allow-list, they are denied by default. The key requirement: ensure no broad grant (like granting `$HOME` read-write) accidentally covers a forbidden path. + +```rust +// Build allow-set, skip forbidden +fn build_fs_caps( + policy: &Policy, + working_dir: &Path, +) -> Result> { + let forbidden = collect_forbidden_patterns(policy); + let mut caps = vec![]; + + // Working directory - always granted + caps.push(FsCapability::new_dir(working_dir, AccessMode::ReadWrite)?); + + // System paths - granted if not forbidden + for sys_path in system_read_paths() { + if !is_forbidden(&sys_path, &forbidden) && sys_path.exists() { + caps.push(FsCapability::new_dir(&sys_path, AccessMode::Read)?); + } + } + + Ok(caps) +} + +fn is_forbidden(path: &Path, patterns: &[GlobPattern]) -> bool { + patterns.iter().any(|p| p.matches_path(path)) +} +``` + +**macOS (Seatbelt)**: In addition to omission, generate explicit deny rules for sensitive paths that might be inside a broad allow: + +```rust +// macOS: add deny platform rules for forbidden paths inside granted directories +// +// IMPORTANT: Paths must be escaped before embedding in Seatbelt S-expressions. +// See nono's macos.rs:282-298 escape_path() for the reference implementation. +// Unescaped paths containing `"` or `\` would break the profile syntax and +// could be exploited as an injection vector. +fn add_deny_rules( + caps: &mut CapabilitySet, + forbidden_paths: &[PathBuf], +) -> Result<()> { + for path in forbidden_paths { + let path_str = escape_seatbelt_path(&path.to_string_lossy()); + // Deny content reads (allow metadata for stat) + caps.add_platform_rule( + format!("(deny file-read-data (subpath \"{}\"))", path_str) + )?; + caps.add_platform_rule( + format!("(deny file-write* (subpath \"{}\"))", path_str) + )?; + } + Ok(()) +} + +/// Escape a path for embedding in a Seatbelt S-expression string. +/// Matches nono's internal escape_path() at sandbox/macos.rs:282. +fn escape_seatbelt_path(path: &str) -> String { + path.replace('\\', "\\\\").replace('"', "\\\"") +} +``` + +#### Translation Gaps + +| Gap | Impact | Mitigation | +|-----|--------|------------| +| Glob `**/.env*` matches anywhere in tree | HIGH | Enumerate known locations; on macOS add deny rules for `$HOME/.env*` | +| Exception support | MEDIUM | Exceptions widen the allow-set; add those paths to caps | +| Dynamic paths (created after sandbox) | MEDIUM | Use supervisor extensions for runtime expansion | +| Symlink resolution differences | LOW | Nono already canonicalizes; both resolve symlinks | + +### PathAllowlistGuard → Direct CapabilitySet Mapping + +**Guard source**: `clawdstrike/crates/libs/clawdstrike/src/guards/path_allowlist.rs:20-28` + +**Strategy**: This is the most natural mapping. The guard's allow-lists translate directly to `CapabilitySet` operations. + +```rust +fn translate_path_allowlist( + config: &PathAllowlistConfig, + caps: &mut CapabilitySet, +) -> Result<()> { + // Read-only paths + for pattern in &config.file_access_allow { + for path in expand_glob_to_existing_paths(pattern)? { + caps.add_fs(FsCapability::new_dir(&path, AccessMode::Read)?); + } + } + + // Write paths + for pattern in &config.file_write_allow { + for path in expand_glob_to_existing_paths(pattern)? { + caps.add_fs(FsCapability::new_dir(&path, AccessMode::ReadWrite)?); + } + } + + Ok(()) +} +``` + +#### Translation Quality: HIGH + +Both systems use allow-lists with per-path access modes. The only gap is glob expansion — ClawdStrike patterns like `**/repo/**` must be resolved to concrete existing paths. + +### EgressAllowlistGuard → Current Runtime `NetworkMode` + Proxy + +**Guard source**: `clawdstrike/crates/libs/clawdstrike/src/guards/egress_allowlist.rs:55-69` + +> macOS note: this section describes the current nono + `hush_proxy` runtime collapse to `NetworkMode::ProxyOnly`. +> It is not the frozen macOS NetworkExtension architecture. +> The active macOS control docs now require a provider-agnostic mediation contract, a content-filter baseline, and a documented exception before transparent proxy can become the implementation target. + +**Strategy**: Current runtime path: kernel enforces proxy-only networking; proxy enforces domain allowlist. macOS target path: freeze a provider-agnostic mediation contract first, then realize it with a content filter provider unless a reviewed exception proves transparent proxy is required. + +```rust +fn translate_egress_policy( + config: &EgressAllowlistConfig, + proxy_port: u16, + caps: &mut CapabilitySet, +) { + if config.default_action == PolicyAction::Block { + // All traffic must go through proxy for domain filtering + caps.set_network_mode_mut(NetworkMode::ProxyOnly { + port: proxy_port, + bind_ports: vec![], + }); + } else { + // Permissive: allow all network, proxy optional + caps.set_network_mode_mut(NetworkMode::AllowAll); + } +} +``` + +The existing `start_connect_proxy()` continues to handle domain-level filtering using `hush_proxy::policy::DomainPolicy`. + +**Code ref**: `clawdstrike/crates/libs/hush-proxy/src/policy.rs:82-112` + +#### Defense in Depth + +``` +Agent tries to connect to evil.com:443 + | + v +Kernel: NetworkMode::ProxyOnly(8080) + → Blocks direct connection to evil.com:443 + → Only allows 127.0.0.1:8080 + | + v +Agent connects through proxy: CONNECT evil.com:443 + | + v +Proxy: EgressAllowlistGuard check + → evil.com not in allowlist + → Returns 403 Forbidden + | + v +Connection denied at BOTH layers +``` + +#### Translation Quality: MEDIUM for the current runtime only + +Kernel enforces port-level restriction (no bypass possible). Domain filtering remains application-level via proxy. Direct IP connections to non-proxy ports are blocked by kernel. This should not be treated as the final macOS NetworkExtension decision. + +### ShellCommandGuard → blocked_commands + +**Guard source**: `clawdstrike/crates/libs/clawdstrike/src/guards/shell_command.rs:35-47` + +**Strategy**: Map dangerous command names to `CapabilitySet::block_command()`. Regex pattern matching (pipe detection, argument inspection) remains guard-level only. + +```rust +fn translate_shell_commands( + config: &ShellCommandConfig, + caps: &mut CapabilitySet, +) { + // Nono's dangerous_commands equivalent + let kernel_blocked = [ + "rm", "rmdir", "dd", "chmod", "chown", + "sudo", "kill", "killall", "shutdown", + "mkfs", "parted", "systemctl", + ]; + + for cmd in &kernel_blocked { + caps.add_blocked_command(*cmd); + } + + // Additional from policy + for pattern in &config.blocked_patterns { + // Extract command name from regex if possible + if let Some(cmd_name) = extract_command_name(pattern) { + caps.add_blocked_command(cmd_name); + } + } +} +``` + +#### What the Kernel CAN Block + +| Pattern | Kernel Enforcement | +|---------|-------------------| +| `rm` | Blocked by command name | +| `sudo` | Blocked by command name | +| `curl \| bash` | `bash` blocked (interpreter) | +| `echo > ~/.ssh/id_rsa` | `~/.ssh` not in allow-set | +| `python -c "os.unlink(...)"` | `python` can be blocked | + +#### What the Kernel CANNOT Block + +| Pattern | Why | Guard Handles | +|---------|-----|--------------| +| `curl \| bash` composite | Kernel blocks `bash` not the pipe | ShellCommandGuard regex | +| `node -e "fs.writeFile(...)"` | Content inspection | ShellCommandGuard path extraction | +| Quoted arguments | Argument parsing | ShellCommandGuard normalization | + +#### Translation Quality: LOW + +Command name blocking is crude but useful as defense-in-depth. **Important caveat**: nono's +`blocked_commands` list is checked at the CLI/profile layer, not by the kernel itself. +If a blocked binary resides in an allowed directory (e.g., `/usr/bin/rm`), Landlock/Seatbelt +will still permit execution — the check happens before exec in userspace. The guard continues +to provide the primary enforcement via regex-based detection. + +### SecretLeakGuard → No Kernel Equivalent + +**Guard source**: `clawdstrike/crates/libs/clawdstrike/src/guards/secret_leak.rs` + +Content inspection (regex matching on file writes for API keys, tokens, private keys) cannot be expressed at the kernel level. The guard continues to run at the application level. + +The kernel sandbox helps indirectly: by restricting which files can be written, it reduces the attack surface for secret exfiltration. + +### Other Guards → Application Level Only + +| Guard | Why No Kernel Mapping | +|-------|----------------------| +| PatchIntegrityGuard | Inspects diff content (additions/deletions counts) | +| McpToolGuard | MCP protocol is application-level | +| PromptInjectionGuard | Semantic analysis of text | +| JailbreakGuard | ML-based classification | +| ComputerUseGuard | CUA action filtering | +| RemoteDesktopSideChannelGuard | Side-channel controls | +| InputInjectionCapabilityGuard | Input injection detection | +| SpiderSenseGuard | Embedding-based threat screening | + +These guards run unchanged. The kernel sandbox provides structural defense beneath them. + +## CapabilityBuilder Implementation + +### Public API + +```rust +pub struct CapabilityBuilder { + policy: Policy, + working_dir: PathBuf, + proxy_port: Option, +} + +impl CapabilityBuilder { + pub fn new(policy: Policy, working_dir: PathBuf) -> Self; + pub fn with_proxy_port(self, port: u16) -> Self; + pub fn build(self) -> nono::Result; + pub fn build_with_diagnostics(self) -> nono::Result<(CapabilitySet, Vec)>; +} + +pub struct TranslationWarning { + pub guard: String, + pub message: String, + pub severity: WarningSeverity, +} +``` + +### build() Algorithm + +> **CRITICAL**: ForbiddenPathGuard MUST be collected BEFORE adding allow paths. +> On Linux (Landlock), once a path is granted, it cannot be revoked. The +> forbidden set must be known before any grants are issued. + +``` +1. Start with empty CapabilitySet +2. Collect forbidden path patterns from ForbiddenPathGuard config (FIRST) +3. Add system read paths — skip any that overlap a forbidden path +4. Add system write paths (tmp, dev) — skip any that overlap a forbidden path +5. Add working directory (ReadWrite) +6. Process PathAllowlistGuard config: + - For each allowed path, check it doesn't cover a forbidden path + - Add each allowed read/write path +7. On macOS: add deny platform rules for forbidden paths inside granted dirs +8. Process EgressAllowlistGuard config: + - Set NetworkMode based on default_action +9. Process ShellCommandGuard config: + - Add blocked commands +10. Deduplicate capabilities +11. Return CapabilitySet +``` + +### Mapping from Policy Rulesets + +#### default.yaml + +``` +forbidden_path.patterns → omit from caps + macOS deny rules +egress_allowlist.allow → current nono runtime: ProxyOnly (default_action: block) +macOS target → provider-agnostic mediated egress contract resolved by the NetworkExtension control doc +shell_command.enabled → standard blocked commands +``` + +**Resulting CapabilitySet**: +- FS: working_dir (RW), system paths (R), tmp (RW) +- Forbidden: ~/.ssh, ~/.aws, ~/.gnupg, ~/.kube, etc. (omitted/denied) +- Network: current runtime `ProxyOnly(proxy_port)`; macOS target is a mediated egress provider contract +- Commands: rm, dd, chmod, sudo, etc. blocked + +#### strict.yaml + +``` +egress_allowlist.allow: [] → current runtime ProxyOnly with empty allowlist +mcp_tool.default_action: block → no kernel equivalent +settings.fail_fast: true → no kernel equivalent +``` + +**Resulting CapabilitySet**: Same structure but the current proxy allowlist is empty (all domains blocked by proxy). The macOS target remains "mediated egress with no approved destinations," not "transparent proxy by default." + +#### ai-agent.yaml + +``` +forbidden_path.exceptions: [**/.env.example] → add .env.example to caps +egress_allowlist.allow: expanded → current runtime ProxyOnly with wider allowlist +``` + +**Resulting CapabilitySet**: Slightly wider FS access, wider current-runtime proxy allowlist. The macOS control docs still decide the concrete provider separately. + +## Pre-flight Validation + +Before applying the sandbox, validate the capability set against expected operations: + +```rust +pub fn preflight_check( + caps: &CapabilitySet, + command: &[String], + working_dir: &Path, + policy: &Policy, +) -> PreflightResult { + let ctx = QueryContext::new(caps.clone()); + let mut errors = vec![]; + let mut warnings = vec![]; + + // 1. Working directory must be accessible + if let QueryResult::Denied(_) = ctx.query_path(working_dir, AccessMode::ReadWrite) { + errors.push("Working directory not accessible in sandbox"); + } + + // 2. Command binary must be readable + if let Ok(bin_path) = which::which(&command[0]) { + if let QueryResult::Denied(_) = ctx.query_path(&bin_path, AccessMode::Read) { + errors.push("Command binary not accessible in sandbox"); + } + } + + // 3. Forbidden paths should NOT be accessible + if let Some(fp) = &policy.guards.forbidden_path { + for pattern in &fp.patterns { + // Spot-check concrete paths derived from patterns + for path in sample_forbidden_paths(pattern) { + if let QueryResult::Allowed(_) = ctx.query_path(&path, AccessMode::Read) { + warnings.push(format!( + "Forbidden path {} is accessible in sandbox", path.display() + )); + } + } + } + } + + PreflightResult { errors, warnings } +} +``` + +## Code References + +### ClawdStrike (read by guards) + +| File | Lines | Content | +|------|-------|---------| +| `guards/forbidden_path.rs` | 44-104 | Default forbidden patterns | +| `guards/forbidden_path.rs` | 221-261 | Path normalization + exception logic | +| `guards/path_allowlist.rs` | 20-28 | Allow-list config fields | +| `guards/path_allowlist.rs` | 125-143 | Check logic | +| `guards/egress_allowlist.rs` | 55-69 | Default allow/block lists | +| `guards/egress_allowlist.rs` | 78-119 | Merge semantics | +| `guards/shell_command.rs` | 35-47 | Dangerous command patterns | +| `guards/shell_command.rs` | 99-149 | Path extraction from commands | +| `policy.rs` | 233-293 | GuardConfigs struct (all guard configs) | + +### Nono (used for translation) + +| File | Lines | Content | +|------|-------|---------| +| `capability.rs` | 477-491 | `allow_path()`, `allow_file()` | +| `capability.rs` | 497-520 | `block_network()`, `proxy_only()` | +| `capability.rs` | 621-633 | `allow_command()`, `block_command()` | +| `capability.rs` | 641-646 | `platform_rule()` (macOS deny rules) | +| `capability.rs` | 808-893 | `deduplicate()` | +| `query.rs` | 71-114 | `query_path()` | +| `policy.rs` | 437-475 | `add_deny_access_rules()` (reference implementation) | +| `policy.json` | full | Group definitions (reference for system paths) | diff --git a/docs/nono-integration/05-supervisor-enforcement.md b/docs/nono-integration/05-supervisor-enforcement.md new file mode 100644 index 000000000..4758187b0 --- /dev/null +++ b/docs/nono-integration/05-supervisor-enforcement.md @@ -0,0 +1,612 @@ +# 05 - Supervisor Enforcement (Phase 3) + +Dynamic enforcement via nono's supervisor IPC, routing kernel-intercepted operations through ClawdStrike guards for real-time allow/deny decisions. + +## Overview + +In Phase 1-2, the sandbox is **static**: all capabilities are determined before execution. Phase 3 adds **dynamic enforcement**: the child starts with a minimal capability set, and the supervisor expands it on-demand by routing requests through ClawdStrike's `HushEngine`. + +This is the most powerful integration — every filesystem operation is both guard-evaluated AND kernel-enforced, with no TOCTOU gap between policy check and enforcement. + +## Platform Mechanisms + +### Linux: seccomp-notify + +The child installs a BPF filter that intercepts `openat`/`openat2` syscalls and routes them to the parent supervisor via a notification fd. + +``` +Child calls open("/project/secret.txt", O_RDONLY) + | + v +Kernel: seccomp-notify intercepts openat syscall + → Child blocks (syscall suspended) + → Notification sent to supervisor fd + | + v +Supervisor: recv_notif(notify_fd) + → Read path from /proc/PID/mem + → Validate notification still active (TOCTOU check) + → Route through HushEngine guards + | + v + If APPROVED: + Supervisor opens file, gets fd + inject_fd(notify_fd, notif_id, fd) → child receives fd as return value + If DENIED: + deny_notif(notify_fd, notif_id) → child receives EPERM +``` + +**Key nono APIs**: + +| Function | File | Line | Purpose | +|----------|------|------|---------| +| `install_seccomp_notify()` | `sandbox/linux.rs` | 449-559 | Install BPF filter for openat/openat2 | +| `recv_notif()` | `sandbox/linux.rs` | 569-596 | Receive notification (blocking) | +| `read_notif_path()` | `sandbox/linux.rs` | 619-649 | Read path from child's /proc/PID/mem | +| `read_open_how()` | `sandbox/linux.rs` | 672-695 | Read openat2 struct for access mode | +| `classify_access_from_flags()` | `sandbox/linux.rs` | 389-395 | Classify O_RDONLY/O_WRONLY/O_RDWR | +| `notif_id_valid()` | `sandbox/linux.rs` | 706-730 | TOCTOU liveness check | +| `inject_fd()` | `sandbox/linux.rs` | 743-775 | Atomically inject fd + complete syscall | +| `deny_notif()` | `sandbox/linux.rs` | 785-799 | Deny with EPERM | + +**BPF program** (installed by `install_seccomp_notify()`): +``` +Instruction 0: Load syscall number +Instruction 1: If openat (257/56) → goto NOTIFY +Instruction 2: If openat2 (437) → goto NOTIFY +Instruction 3: SECCOMP_RET_ALLOW (all other syscalls) +Instruction 4: SECCOMP_RET_USER_NOTIF (route to supervisor) +``` + +### macOS: Seatbelt Extensions + +The supervisor issues HMAC-SHA256 authenticated tokens that the child consumes to expand its sandbox. + +``` +Child needs to access /project/file.txt + → Sends CapabilityRequest via supervisor socket + | + v +Supervisor: recv_message() + → Route through HushEngine guards + | + v + If APPROVED: + token = extension_issue_file("/project/file.txt", AccessMode::Read) + send_message(SupervisorResponse::Decision { Granted }) + send extension token via socket + If DENIED: + send_message(SupervisorResponse::Decision { Denied { reason } }) + | + v +Child: extension_consume(token) + → Sandbox expands to include /project/file.txt +``` + +**Key nono APIs**: + +| Function | File | Line | Purpose | +|----------|------|------|---------| +| `extension_issue_file()` | `sandbox/macos.rs` | 61-104 | Create HMAC-authenticated token | +| `extension_consume()` | `sandbox/macos.rs` | 119-136 | Consume token in sandboxed process | +| `extension_release()` | `sandbox/macos.rs` | 142-156 | Revoke dynamically-granted access | + +**Token properties**: +- HMAC-SHA256 authenticated with per-boot kernel key (cannot be forged) +- Path-specific and access-class-specific +- Survives `fork()` and `exec()` — children inherit expanded access +- Revocable via `extension_release(handle)` + +## Supervisor Socket IPC + +**Nono API**: `supervisor/socket.rs` + +The supervisor socket provides length-prefixed JSON messaging with fd-passing: + +| Method | File | Line | Purpose | +|--------|------|------|---------| +| `SupervisorSocket::pair()` | `socket.rs` | 51-65 | Create connected pair before fork | +| `send_message()` | `socket.rs` | 127-132 | Send SupervisorMessage | +| `recv_message()` | `socket.rs` | 134-140 | Receive SupervisorMessage | +| `send_fd()` | `socket.rs` | 161-209 | Pass fd via SCM_RIGHTS | +| `recv_fd()` | `socket.rs` | 215-293 | Receive fd from peer | +| `peer_pid()` | `socket.rs` | 301-368 | Authenticate peer process | + +**Message types** (`supervisor/types.rs`): + +```rust +struct CapabilityRequest { + request_id: String, // Unique ID (replay protection) + path: PathBuf, // Requested filesystem path + access: AccessMode, // Read, Write, or ReadWrite + reason: Option, // Human-readable reason + child_pid: u32, // Requesting process PID + session_id: String, // Correlates requests in a session +} + +enum ApprovalDecision { + Granted, + Denied { reason: String }, + Timeout, +} +``` + +## Integration with HushEngine + +### GuardSupervisorBackend + +Implement nono's `ApprovalBackend` trait with ClawdStrike's `HushEngine`: + +```rust +use nono::supervisor::{ApprovalBackend, ApprovalDecision, CapabilityRequest}; +use clawdstrike::engine::HushEngine; +use clawdstrike::guards::{GuardAction, GuardContext}; + +pub struct GuardSupervisorBackend { + engine: Arc, + context: GuardContext, + outcome: Arc, + event_emitter: EventEmitter, +} + +impl ApprovalBackend for GuardSupervisorBackend { + fn request_capability( + &self, + request: &CapabilityRequest, + ) -> nono::Result { + let path = request.path.to_string_lossy(); + + // Determine guard action based on access mode + let action = match request.access { + AccessMode::Read => GuardAction::FileAccess(&path), + AccessMode::Write | AccessMode::ReadWrite => { + // For writes, we don't have content — check path only + GuardAction::FileAccess(&path) + } + }; + + // Route through HushEngine + let report = tokio::task::block_in_place(|| { + tokio::runtime::Handle::current() + .block_on(self.engine.check_action_report(&action, &self.context)) + }); + + // Track outcome for receipt + self.outcome.observe_guard_result(&report.overall); + + // Emit event + self.event_emitter.emit(PolicyEvent::supervisor_request( + &request, + &report, + )); + + if report.overall.allowed { + Ok(ApprovalDecision::Granted) + } else { + Ok(ApprovalDecision::Denied { + reason: report.overall.message.clone(), + }) + } + } + + fn backend_name(&self) -> &str { + "clawdstrike-guard-supervisor" + } +} +``` + +### NeverGrant Integration + +Nono's `NeverGrantChecker` validates paths against a permanently-blocked list. Map ClawdStrike's most critical forbidden paths to never_grant: + +```rust +/// Build the never_grant list from policy. +/// +/// NOTE: NeverGrantChecker::new() takes &[String], NOT &[PathBuf]. +/// It performs its own tilde expansion and canonicalization internally, +/// using the passwd database for home directory resolution (not $HOME). +/// Pass paths as strings with ~ for home-relative paths. +fn build_never_grant_list(policy: &Policy) -> Vec { + let mut paths: Vec = vec![ + "~/.ssh/id_rsa".into(), + "~/.ssh/id_ed25519".into(), + "/etc/shadow".into(), + "/etc/sudoers".into(), + ]; + + // From ForbiddenPathGuard with never_override flag + if let Some(fp) = &policy.guards.forbidden_path { + for pattern in &fp.patterns { + for concrete_path in resolve_pattern_to_paths(pattern) { + paths.push(concrete_path.to_string_lossy().into_owned()); + } + } + } + + paths +} +``` + +**Code ref**: `nono/crates/nono/src/supervisor/never_grant.rs:40-62` + +NeverGrantChecker uses component-wise `Path::starts_with()` (not string comparison) to prevent `/etc/shadow2` matching `/etc/shadow`. + +### Rate Limiting + +Nono's supervisor includes a token-bucket rate limiter (10 req/sec, burst 5) to prevent fd-injection flooding. This protects against runaway agents that open thousands of files per second. + +**Code ref**: `nono/crates/nono-cli/src/exec_strategy/supervisor_linux.rs:92-100` (rate_limiter parameter) + +## Supervisor Loop Architecture + +### Linux Implementation + +```rust +// NOTE: This function is synchronous, NOT async. recv_notif() is a blocking +// ioctl that suspends the calling thread. Running this inside an async runtime +// would block the Tokio worker thread and starve other tasks. The supervisor +// loop runs on a dedicated OS thread, separate from Tokio. +// +// For guard evaluation (which uses async guards), we use block_in_place() via +// the GuardSupervisorBackend, which requires a multi-threaded Tokio runtime. +// The Tokio runtime must be started in the parent AFTER fork, with the +// supervisor loop running on its own thread via std::thread::spawn(). +#[cfg(target_os = "linux")] +pub fn run_supervisor_loop( + notify_fd: RawFd, + child_pid: Pid, + engine: Arc, + context: GuardContext, + initial_caps: &CapabilitySet, + never_grant: NeverGrantChecker, + outcome: Arc, + emitter: EventEmitter, +) -> Vec { + let backend = GuardSupervisorBackend { + engine, context, outcome, emitter, + }; + + let initial_paths: Vec<(PathBuf, bool)> = initial_caps + .fs_capabilities() + .iter() + .map(|c| (c.resolved.clone(), c.is_file)) + .collect(); + + let mut denials = vec![]; + let mut rate_limiter = RateLimiter::new(10, 5); // 10/sec, burst 5 + + loop { + // Blocking receive from kernel + let notif = match linux::recv_notif(notify_fd) { + Ok(n) => n, + Err(_) => break, // Child exited, fd closed + }; + + // Read path from child's /proc/PID/mem + let path = match linux::read_notif_path(notif.pid, ¬if.data) { + Ok(p) => PathBuf::from(p), + Err(_) => { + linux::deny_notif(notify_fd, notif.id).ok(); + continue; + } + }; + + // Classify access mode from open flags + let access = linux::classify_access_from_flags(notif.data.args[2] as i32); + + // 1. Check never_grant (immediate deny) + if never_grant.is_blocked(&path) { + denials.push(DenialRecord { + path: path.clone(), + access, + reason: DenialReason::PolicyBlocked, + }); + linux::deny_notif(notify_fd, notif.id).ok(); + continue; + } + + // 2. Fast path: already in initial capability set + if is_in_initial_set(&path, &initial_paths) { + // Open the file as supervisor and inject fd + if let Ok(fd) = open_file_for_inject(&path, access) { + linux::inject_fd(notify_fd, notif.id, fd.as_raw_fd()).ok(); + } else { + linux::deny_notif(notify_fd, notif.id).ok(); + } + continue; + } + + // 3. Rate limit check + if !rate_limiter.try_acquire() { + denials.push(DenialRecord { + path: path.clone(), + access, + reason: DenialReason::RateLimited, + }); + linux::deny_notif(notify_fd, notif.id).ok(); + continue; + } + + // 4. TOCTOU check: notification still valid? + if !linux::notif_id_valid(notify_fd, notif.id) { + continue; // Child already moved on + } + + // 5. Route through ClawdStrike guards + let request = CapabilityRequest { + request_id: uuid::Uuid::new_v4().to_string(), + path: path.clone(), + access, + reason: None, + child_pid: notif.pid, + session_id: "todo".into(), + }; + + match backend.request_capability(&request) { + Ok(ApprovalDecision::Granted) => { + // Second TOCTOU check before inject + if linux::notif_id_valid(notify_fd, notif.id) { + if let Ok(fd) = open_file_for_inject(&path, access) { + linux::inject_fd(notify_fd, notif.id, fd.as_raw_fd()).ok(); + } else { + linux::deny_notif(notify_fd, notif.id).ok(); + } + } + } + Ok(ApprovalDecision::Denied { reason }) => { + denials.push(DenialRecord { + path: path.clone(), + access, + reason: DenialReason::UserDenied, + }); + linux::deny_notif(notify_fd, notif.id).ok(); + } + _ => { + linux::deny_notif(notify_fd, notif.id).ok(); + } + } + } + + denials +} +``` + +### macOS Implementation + +macOS uses extension tokens instead of fd injection: + +```rust +// NOTE: Also synchronous — recv_message() blocks on the Unix socket. +#[cfg(target_os = "macos")] +pub fn run_supervisor_loop_macos( + socket: SupervisorSocket, + engine: Arc, + context: GuardContext, + never_grant: NeverGrantChecker, + outcome: Arc, + emitter: EventEmitter, +) -> Vec { + let backend = GuardSupervisorBackend { + engine, context, outcome, emitter, + }; + let mut denials = vec![]; + + loop { + let msg = match socket.recv_message() { + Ok(m) => m, + Err(_) => break, + }; + + let SupervisorMessage::Request(request) = msg; + + // 1. Check never_grant + if never_grant.is_blocked(&request.path) { + socket.send_response(SupervisorResponse::Decision { + request_id: request.request_id.clone(), + decision: ApprovalDecision::Denied { + reason: "Path is in never_grant list".into(), + }, + }).ok(); + continue; + } + + // 2. Route through guards + match backend.request_capability(&request) { + Ok(ApprovalDecision::Granted) => { + // Issue extension token + match macos::extension_issue_file(&request.path, request.access) { + Ok(token) => { + socket.send_response(SupervisorResponse::Decision { + request_id: request.request_id.clone(), + decision: ApprovalDecision::Granted, + }).ok(); + // Send token as a separate message + socket.send_extension_token(&token).ok(); + } + Err(_) => { + socket.send_response(SupervisorResponse::Decision { + request_id: request.request_id.clone(), + decision: ApprovalDecision::Denied { + reason: "Failed to issue extension token".into(), + }, + }).ok(); + } + } + } + Ok(decision) => { + socket.send_response(SupervisorResponse::Decision { + request_id: request.request_id.clone(), + decision, + }).ok(); + } + Err(e) => { + socket.send_response(SupervisorResponse::Decision { + request_id: request.request_id.clone(), + decision: ApprovalDecision::Denied { + reason: format!("Guard error: {}", e), + }, + }).ok(); + } + } + } + + denials +} +``` + +## Fork+Exec Integration + +### Modified spawn_sandboxed_child() + +```rust +pub fn spawn_supervised_child( + caps: &CapabilitySet, + command: &[String], + env_vars: &[(String, String)], + engine: Arc, + context: GuardContext, + never_grant: NeverGrantChecker, + outcome: Arc, + emitter: EventEmitter, +) -> Result<(i32, Vec)> { + // Create supervisor socket pair + let (supervisor_sock, child_sock) = SupervisorSocket::pair()?; + + // Pre-fork allocations + let c_program = CString::new(command[0].as_str())?; + let c_args = /* ... */; + let c_env = /* ... */; + + match unsafe { fork() }? { + ForkResult::Child => { + // CHILD: only async-signal-safe operations from here. + // Do NOT use ? operator — it invokes Drop/unwind machinery. + drop(supervisor_sock); // Close supervisor end + + // Close inherited fds except stdin/stdout/stderr + child_sock + close_inherited_fds(3, &[child_sock.as_raw_fd()]); + + // Apply Landlock sandbox — no ? operator! + if let Err(e) = Sandbox::apply(caps) { + let msg = format!("nono: sandbox apply failed: {}\n", e); + unsafe { libc::write(2, msg.as_ptr().cast(), msg.len()) }; + unsafe { libc::_exit(126) }; + } + + // Linux: install seccomp-notify, send fd to parent + #[cfg(target_os = "linux")] + { + match linux::install_seccomp_notify() { + Ok(notify_fd) => { + if child_sock.send_fd(notify_fd.as_raw_fd()).is_err() { + unsafe { libc::_exit(126) }; + } + } + Err(_) => unsafe { libc::_exit(126) }, + } + } + + // macOS: enable extensions (already in CapabilitySet) + + // Exec — replaces process image + if let Err(_) = nix::unistd::execve(&c_program, &c_args, &c_env) { + unsafe { libc::_exit(127) }; + } + unsafe { libc::_exit(126) }; // unreachable + } + ForkResult::Parent { child } => { + drop(child_sock); // Close child end + + // Linux: receive notify_fd + #[cfg(target_os = "linux")] + let notify_fd = supervisor_sock.recv_fd()?; + + // Run supervisor loop (blocking until child exits) + let denials = run_supervisor_loop( + notify_fd, + child, + engine, + context, + caps, + never_grant, + outcome, + emitter, + ); + + let status = nix::sys::wait::waitpid(child, None)?; + Ok((exit_code_from_status(status), denials)) + } + } +} +``` + +## Security Properties + +### TOCTOU Protection + +The supervisor loop includes two TOCTOU checks: + +1. **Before guard evaluation**: `notif_id_valid()` confirms the notification is still pending +2. **After guard approval**: Second `notif_id_valid()` before fd injection + +Between these checks, the child's syscall is suspended by the kernel. The child cannot proceed until the supervisor responds. + +**Code ref**: `nono/crates/nono/src/sandbox/linux.rs:706-730` + +### Authorization Binding + +The supervisor opens the file and injects the **supervisor's fd**, not the child's requested path. This means: +- Authorization is bound to the fd, not the path +- Symlink changes between check and use don't matter +- The child receives exactly what the supervisor approved + +**Code ref**: `nono/crates/nono/src/sandbox/linux.rs:743-775` + +### Never-Grant Enforcement + +Paths in the never_grant list are denied **before** guard evaluation. This provides a hard floor that guards cannot override: + +``` +~/.ssh/id_rsa → DENIED (never_grant, before guards) +/etc/shadow → DENIED (never_grant, before guards) +/project/file.txt → routed to guards for evaluation +``` + +**Code ref**: `nono/crates/nono/src/supervisor/never_grant.rs:74-87` + +### Fail-Closed + +Every error path in the supervisor loop results in denial: +- Path read fails → deny +- TOCTOU check fails → skip (child moved on) +- Guard errors → deny +- Rate limit exceeded → deny +- Extension token issuance fails → deny + +## Performance Considerations + +| Operation | Latency | Notes | +|-----------|---------|-------| +| Initial capability set (fast-path) | ~microseconds | fd already available, no guard eval | +| Guard evaluation per request | ~milliseconds | Depends on guard complexity | +| seccomp-notify round-trip | ~100-500us | Kernel context switch overhead | +| Extension token issuance (macOS) | ~microseconds | Kernel HMAC computation | +| Rate limiter check | ~nanoseconds | Token bucket comparison | + +For most agent workloads (tens to hundreds of file operations per session), the overhead is negligible. For build tools opening thousands of files, the fast-path (initial capability set) handles the common case. + +## Audit Trail + +Every supervisor decision is logged as an `AuditEntry`: + +```rust +struct AuditEntry { + timestamp: SystemTime, + request: CapabilityRequest, + decision: ApprovalDecision, + backend: String, // "clawdstrike-guard-supervisor" + duration_ms: u64, +} +``` + +These entries are included in the receipt's `metadata.sandbox.audit` field, providing a complete record of every dynamic capability expansion. diff --git a/docs/nono-integration/06-receipt-attestation.md b/docs/nono-integration/06-receipt-attestation.md new file mode 100644 index 000000000..08f3f0c92 --- /dev/null +++ b/docs/nono-integration/06-receipt-attestation.md @@ -0,0 +1,460 @@ +# 06 - Receipt Attestation + +Extend ClawdStrike's receipt system to attest to kernel-level enforcement, not just advisory observations. + +## Current Receipt System + +**File**: `clawdstrike/crates/libs/hush-core/src/receipt.rs` + +### Receipt Structure + +```rust +// receipt.rs:157-175 +struct Receipt { + version: String, // "1.0.0" + receipt_id: Option, + timestamp: String, // ISO-8601 + content_hash: Hash, // SHA-256 of what was verified + verdict: Verdict, // pass/fail + provenance: Option, + metadata: Option, +} + +// receipt.rs:62-73 +struct Verdict { + passed: bool, + gate_id: Option, + scores: Option, + threshold: Option, +} + +// receipt.rs:136-152 +struct Provenance { + clawdstrike_version: Option, + provider: Option, + policy_hash: Option, + ruleset: Option, + violations: Vec, +} + +// receipt.rs:121-131 +struct ViolationRef { + guard: String, + severity: String, + message: String, + action: Option, +} +``` + +### Current Semantics + +Today, a receipt attests: "We **observed** these violations during execution." + +The verdict reflects what guards detected, but violations were not prevented. A receipt with `passed: false` means "the agent did something bad and we noticed" — not "the agent was prevented from doing something bad." + +### Signing + +**File**: `receipt.rs:289-406` + +```rust +struct SignedReceipt { + receipt: Receipt, + signatures: Signatures, // Ed25519 primary + optional cosigner +} +``` + +Receipts are signed with Ed25519 keypairs. Verification checks all signatures and returns `VerificationResult` with error codes. + +## Enhanced Receipt with Sandbox Attestation + +### New Metadata Schema + +Add a `sandbox` field to receipt metadata that describes the kernel enforcement applied: + +```json +{ + "version": "1.0.0", + "receipt_id": "receipt-uuid", + "timestamp": "2026-03-07T12:00:00Z", + "content_hash": "sha256:abc123...", + "verdict": { + "passed": true, + "gate_id": "hush-run-session-xyz" + }, + "provenance": { + "clawdstrike_version": "0.2.5", + "policy_hash": "sha256:def456...", + "ruleset": "default", + "violations": [] + }, + "metadata": { + "sandbox": { + "enforced": false, + "enforcement_level": "degraded", + "platform": { + "name": "macos", + "mechanisms": ["seatbelt", "endpoint_security"], + "abi_version": null, + "details": "Seatbelt base sandbox with EndpointSecurity provider installed but degraded" + }, + "capabilities": { + "fs": [ + { + "original": "/home/user/project", + "resolved": "/home/user/project", + "access": "ReadWrite", + "is_file": false + }, + { + "original": "/usr", + "resolved": "/usr", + "access": "Read", + "is_file": false + } + ], + "net_blocked": false, + "network_mode": "MediatedEgress", + "proxy_port": null, + "signal_mode": "Isolated", + "blocked_commands": ["rm", "sudo", "dd"], + "extensions_enabled": true + }, + "provider_states": [ + { + "provider": "seatbelt", + "installed": true, + "active": true, + "healthy": true, + "degraded_reason": null + }, + { + "provider": "endpoint_security", + "installed": true, + "active": true, + "healthy": false, + "degraded_reason": "full-disk-access-missing" + }, + { + "provider": "network_extension", + "installed": true, + "active": true, + "healthy": true, + "degraded_reason": null + } + ], + "supervisor": { + "enabled": true, + "backend": "clawdstrike-guard-supervisor", + "requests_total": 47, + "requests_granted": 42, + "requests_denied": 5, + "never_grant_blocks": 2, + "rate_limit_blocks": 0 + }, + "denials": [ + { + "path": "/home/user/.ssh/id_rsa", + "access": "Read", + "reason": "PolicyBlocked", + "timestamp": "2026-03-07T12:01:23Z" + } + ], + "audit": [ + { + "timestamp": "2026-03-07T12:00:05Z", + "path": "/home/user/project/src/main.rs", + "access": "Read", + "decision": "Granted", + "backend": "clawdstrike-guard-supervisor", + "duration_ms": 2 + } + ] + } + } +} +``` + +### Implementation + +#### SandboxAttestation Type + +New type in ClawdStrike with **custom serialization**. We cannot directly wrap nono's +`SandboxState` because it only contains `fs: Vec` and `net_blocked: bool` — +it lacks `network_mode`, `proxy_port`, `signal_mode`, `blocked_commands`, and +`extensions_enabled`. Similarly, nono's `DenialRecord` does not derive `Serialize`/ +`Deserialize` and has no `timestamp` field. `CapabilitySet` itself does not implement serde. + +Therefore, `SandboxAttestation` must define its own serializable types: + +```rust +// NOTE: These are ClawdStrike-owned types, NOT re-exports from nono. +// They are built by reading nono's CapabilitySet accessors, not by +// wrapping SandboxState. + +#[derive(Serialize, Deserialize)] +pub struct SandboxAttestation { + /// Whether kernel enforcement was active + pub enforced: bool, + /// Enforcement level after provider health is evaluated + pub enforcement_level: EnforcementLevel, + /// Platform details + pub platform: PlatformInfo, + /// Serialized capability details (custom, NOT nono::SandboxState) + pub capabilities: CapabilitySnapshot, + /// Per-provider install, health, and degraded-state snapshot + pub provider_states: Vec, + /// Supervisor statistics (if Phase 4) + pub supervisor: Option, + /// Denied operations (with timestamps, unlike nono's DenialRecord) + pub denials: Vec, + /// Audit trail of supervisor decisions + pub audit: Vec, +} + +/// Built from CapabilitySet accessors, not from SandboxState. +#[derive(Serialize, Deserialize)] +pub struct CapabilitySnapshot { + pub fs: Vec, + pub network_mode: String, // "Blocked" | "AllowAll" | "MediatedEgress" | "ProxyOnly" (legacy runtime only) + pub proxy_port: Option, + pub signal_mode: String, // "Isolated" | "AllowAll" + pub blocked_commands: Vec, + pub extensions_enabled: bool, +} + +#[derive(Serialize, Deserialize)] +pub struct FsCapSnapshot { + pub original: String, + pub resolved: String, + pub access: String, + pub is_file: bool, +} + +#[derive(Serialize, Deserialize)] +pub struct TimestampedDenial { + pub path: String, + pub access: String, + pub reason: String, + pub timestamp: String, // ISO-8601, added by ClawdStrike +} + +#[derive(Serialize, Deserialize)] +pub struct ProviderState { + pub provider: String, // "seatbelt" | "endpoint_security" | "network_extension" + pub installed: bool, + pub active: bool, + pub healthy: bool, + pub degraded_reason: Option, +} + +pub enum EnforcementLevel { + /// No kernel enforcement (legacy mode) + None, + /// Static sandbox (Phase 1-2) + Kernel, + /// Dynamic supervisor enforcement (Phase 3) + KernelSupervised, + /// Kernel surfaces existed but one or more required providers were unavailable or unhealthy + Degraded, +} + +pub struct PlatformInfo { + pub name: String, // "linux" | "macos" (from Sandbox::support_info().platform) + pub mechanisms: Vec, // derived from active platform providers, not a single hard-coded string + pub abi_version: Option, + pub details: String, +} + +pub struct SupervisorStats { + pub enabled: bool, + pub backend: String, + pub requests_total: u64, + pub requests_granted: u64, + pub requests_denied: u64, + pub never_grant_blocks: u64, + pub rate_limit_blocks: u64, +} +``` + +#### Building the Attestation + +In `cmd_run()` after child exits: + +```rust +// After child exit, build sandbox attestation +// Build CapabilitySnapshot from CapabilitySet accessors (not SandboxState) +let cap_snapshot = CapabilitySnapshot { + fs: caps.fs_capabilities().iter().map(|c| FsCapSnapshot { + original: c.original.to_string_lossy().into_owned(), + resolved: c.resolved.to_string_lossy().into_owned(), + access: format!("{:?}", c.access), + is_file: c.is_file, + }).collect(), + network_mode: derive_network_mode_for_attestation(&caps, &provider_states), + proxy_port: match caps.network_mode() { + NetworkMode::ProxyOnly { port, .. } => Some(*port), + _ => None, + }, + signal_mode: format!("{:?}", caps.signal_mode()), + blocked_commands: caps.blocked_commands().to_vec(), + extensions_enabled: caps.extensions_enabled(), +}; + +let support = Sandbox::support_info(); +let providers_healthy = provider_states.iter().all(|p| p.active && p.healthy); +let sandbox_attestation = SandboxAttestation { + enforced: support.is_supported && providers_healthy, + enforcement_level: if !providers_healthy { + EnforcementLevel::Degraded + } else if supervisor_enabled { + EnforcementLevel::KernelSupervised + } else { + EnforcementLevel::Kernel + }, + platform: PlatformInfo { + name: support.platform.to_string(), + mechanisms: derive_platform_mechanisms(&provider_states), + abi_version: None, // filled per-platform + details: support.details, + }, + capabilities: cap_snapshot, + provider_states, + supervisor: supervisor_stats, + denials: denial_records, // Vec, built during execution + audit: audit_entries, +}; + +// Merge into receipt metadata +let sandbox_json = serde_json::to_value(&sandbox_attestation)?; +receipt.merge_metadata(json!({ "sandbox": sandbox_json })); +``` + +**Code ref for merge_metadata**: `receipt.rs:213-220` + +### Verdict Semantics Change + +With kernel enforcement, the verdict semantics change: + +| Scenario | Before (Advisory) | After (Enforced) | +|----------|-------------------|------------------| +| Agent tried to read ~/.ssh/id_rsa | `passed: false` (violation observed) | `passed: true` (violation prevented, noted in denials) | +| Agent wrote to allowed path | `passed: true` | `passed: true` | +| Agent tried unknown egress | `passed: false` (proxy logged violation) | `passed: true` only if mediated egress was active and the provider actually blocked it | +| ES provider missed a deadline | `passed: false` or not represented | never claim fully enforced success; receipt must record degraded or unavailable enforcement | + +The key insight: **enforcement changes verdicts from "we saw bad things happen" to "bad things were prevented," but only while the required providers were actually healthy.** A receipt with `enforced: true` and `passed: true` means the agent operated within its sandbox successfully, and any attempted violations were structurally prevented. If a required provider was inactive, unhealthy, or degraded, the receipt must say so instead of silently claiming success. + +A degraded receipt is intentionally different from an enforced receipt. It can still contain useful audit evidence, but it must not be treated as proof that the platform actually enforced the intended policy end to end. + +Violations in `provenance.violations` now mean "the agent attempted these operations, and they were blocked": + +```json +{ + "verdict": { "passed": true }, + "provenance": { + "violations": [ + { + "guard": "ForbiddenPathGuard", + "severity": "Error", + "message": "Attempted access to ~/.ssh/id_rsa (blocked by kernel sandbox)", + "action": "FileAccess" + } + ] + }, + "metadata": { + "sandbox": { + "enforced": true, + "denials": [ + { "path": "~/.ssh/id_rsa", "reason": "PolicyBlocked" } + ] + } + } +} +``` + +### Receipt Verification Enhancement + +Verifiers can now check enforcement claims: + +```rust +impl SignedReceipt { + /// Check if this receipt attests to kernel-level enforcement + pub fn is_kernel_enforced(&self) -> bool { + self.receipt.metadata + .as_ref() + .and_then(|m| m.get("sandbox")) + .and_then(|s| s.get("enforced")) + .and_then(|e| e.as_bool()) + .unwrap_or(false) + } + + /// Get the enforcement level + pub fn enforcement_level(&self) -> EnforcementLevel { + self.receipt.metadata + .as_ref() + .and_then(|m| m.get("sandbox")) + .and_then(|s| s.get("enforcement_level")) + .and_then(|l| l.as_str()) + .map(EnforcementLevel::from_str) + .unwrap_or(EnforcementLevel::None) + } +} +``` + +### Integration with Posture System + +Sandbox enforcement state can feed into posture transitions: + +```yaml +# Policy with posture + sandbox awareness +posture: + initial: "standard" + states: + standard: + capabilities: [file_read, file_write, egress] + budgets: + file_writes: 100 + restricted: + capabilities: [file_read] + budgets: + file_writes: 0 + transitions: + - from: standard + to: restricted + trigger: critical_violation +``` + +When the supervisor denies a never_grant path access, this can trigger a `CriticalViolation` posture transition, tightening both the guard policy and (potentially) the sandbox. + +### Spine Integration + +For distributed attestation, the sandbox state is included in Spine signed envelopes: + +```rust +// Checkpoint includes sandbox attestation hash +let checkpoint = Checkpoint { + receipt_hash: receipt.hash_sha256(), + sandbox_state_hash: sandbox_attestation.hash(), + timestamp: now(), +}; + +let envelope = SignedEnvelope::sign(checkpoint, &keypair); +spine_client.publish(envelope).await?; +``` + +This allows downstream SIEM/SOAR systems to verify that enforcement was active when the receipt was generated. + +## Code References + +| File | Lines | Content | +|------|-------|---------| +| `hush-core/src/receipt.rs` | 157-175 | Receipt struct definition | +| `hush-core/src/receipt.rs` | 62-73 | Verdict struct | +| `hush-core/src/receipt.rs` | 136-152 | Provenance struct | +| `hush-core/src/receipt.rs` | 213-220 | `merge_metadata()` | +| `hush-core/src/receipt.rs` | 289-406 | SignedReceipt, signing, verification | +| `hush_run.rs` | 498-515 | Current receipt creation | +| `nono/src/state.rs` | 34-48 | `SandboxState::from_caps()` | +| `nono/src/state.rs` | 87-91 | `SandboxState::to_json()` | +| `nono/src/sandbox/mod.rs` | 124-143 | `Sandbox::support_info()` | +| `nono/src/diagnostic.rs` | 34-42 | `DenialRecord` struct | diff --git a/docs/nono-integration/07-implementation-plan.md b/docs/nono-integration/07-implementation-plan.md new file mode 100644 index 000000000..31f517708 --- /dev/null +++ b/docs/nono-integration/07-implementation-plan.md @@ -0,0 +1,293 @@ +# 07 - Implementation Plan + +Phased rollout for integrating nono into ClawdStrike, from basic sandbox replacement to full dynamic enforcement. + +## Phase Overview + +``` +Phase 1: Sandbox Replacement (~2 weeks) + Replace sandbox-exec/bwrap with nono library API + +Phase 2: Policy Translation (~3 weeks) + Derive CapabilitySet from policy YAML + +Phase 3: Receipt Attestation (~1 week) + Include sandbox state in signed receipts + +Phase 4: Supervisor Enforcement (~4 weeks) + Dynamic enforcement via seccomp-notify/extensions +``` + +## Phase 1: Sandbox Replacement + +**Goal**: Replace ad-hoc sandbox wrappers with nono's cross-platform API. + +### Tasks + +| # | Task | Effort | Dependency | +|---|------|--------|------------| +| 1.1 | Add `nono` as workspace dependency | S | - | +| 1.2 | Create `sandbox_nono.rs` module in hush-cli | M | 1.1 | +| 1.3 | Implement `build_capability_set()` with hardcoded system paths | M | 1.2 | +| 1.4 | Implement `spawn_sandboxed_child()` with fork+exec | L | 1.2 | +| 1.5 | Wire into `cmd_run()`, replace `maybe_prepare_sandbox()` | M | 1.3, 1.4 | +| 1.6 | Add `--sandbox=nono\|legacy\|none` flag | S | 1.5 | +| 1.7 | Delete `SandboxWrapper`, `generate_macos_sandbox_profile()` | S | 1.5 | +| 1.8 | Integration tests: forbidden paths blocked | M | 1.5 | +| 1.9 | Integration tests: working dir accessible | M | 1.5 | +| 1.10 | Integration tests: network modes | M | 1.5 | +| 1.11 | CI: test on both Linux and macOS | M | 1.8-1.10 | + +### Key Files Modified + +| File | Change | +|------|--------| +| `Cargo.toml` (workspace) | Add `nono` dependency | +| `crates/services/hush-cli/Cargo.toml` | Add `nono` dependency | +| `crates/services/hush-cli/src/sandbox_nono.rs` | **NEW**: capability builder + fork+exec | +| `crates/services/hush-cli/src/hush_run.rs` | Replace sandbox branches, change spawn model | +| `crates/services/hush-cli/src/cli.rs` | Add `--sandbox` flag | + +### Key Code Changes + +**hush_run.rs:690-743** (`maybe_prepare_sandbox()`): +- Delete entirely +- Replace call site (line ~380) with `sandbox_nono::build_capability_set()` + +**hush_run.rs:746-785** (`generate_macos_sandbox_profile()`): +- Delete entirely +- nono generates platform-specific profiles internally + +**hush_run.rs:787-833** (`spawn_and_wait_child()`): +- Replace `Command::new().spawn()` with `fork() + Sandbox::apply() + execve()` +- Environment variables set before fork (inherited by child) +- Signal forwarding: parent forwards SIGINT/SIGTERM to child PID + +### Threading Concern + +`hush run` uses Tokio for the CONNECT proxy. `fork()` in a multi-threaded process is unsafe. Resolution: + +**Option A** (recommended): Fork before starting Tokio runtime. +``` +1. Load policy +2. Build CapabilitySet +3. fork() +4. CHILD: apply sandbox, exec +5. PARENT: start Tokio runtime, run proxy, wait for child +``` + +**Option B** (NOT RECOMMENDED): Use `pre_exec` hook with `Command::new()`. + +> **UNSAFE**: `pre_exec` runs after fork in the child. If the parent has multiple threads +> (e.g., Tokio runtime already running), `Sandbox::apply()` performs memory allocation +> (generating Seatbelt profile strings, opening Landlock PathFds) which is NOT +> async-signal-safe and can deadlock. nono-cli explicitly validates thread count before +> fork (`exec_strategy.rs:329-365`). This option bypasses that safety check. +> Additionally, it does not support seccomp-notify (Phase 4) or fd cleanup. + +Use Option A. + +### Acceptance Criteria + +- [ ] `hush run default.yaml -- ls /` works on macOS and Linux +- [ ] `hush run default.yaml -- cat ~/.ssh/id_rsa` fails with EPERM +- [ ] `hush run default.yaml -- curl https://example.com` is blocked (network) +- [ ] `--sandbox=none` disables kernel enforcement +- [ ] `--sandbox=legacy` uses old sandbox-exec/bwrap (transition period) +- [ ] Clippy + fmt pass, no `unwrap()` usage +- [ ] Child closes inherited fds before exec +- [ ] Child error handling uses `libc::write`/`libc::_exit` (no `?` operator) +- [ ] SignalMode configured appropriately (agents spawning subprocesses may need `AllowAll`) + +--- + +## Phase 2: Policy Translation + +**Goal**: Guard configurations drive `CapabilitySet` construction. + +### Tasks + +| # | Task | Effort | Dependency | +|---|------|--------|------------| +| 2.1 | Create `CapabilityBuilder` struct | M | Phase 1 | +| 2.2 | Implement ForbiddenPathGuard → path omission | L | 2.1 | +| 2.3 | Implement PathAllowlistGuard → allow_path mapping | M | 2.1 | +| 2.4 | Implement EgressAllowlistGuard → NetworkMode | S | 2.1 | +| 2.5 | Implement ShellCommandGuard → blocked_commands | S | 2.1 | +| 2.6 | macOS: add deny platform rules for forbidden paths | M | 2.2 | +| 2.7 | Implement pre-flight validation via QueryContext | M | 2.1 | +| 2.8 | Unit tests: default.yaml produces expected caps | M | 2.2-2.5 | +| 2.9 | Unit tests: strict.yaml produces tighter caps | M | 2.2-2.5 | +| 2.10 | Unit tests: ai-agent.yaml produces wider caps | M | 2.2-2.5 | +| 2.11 | Integration tests: policy-driven sandbox blocks correctly | L | 2.8-2.10 | +| 2.12 | `build_with_diagnostics()` returns TranslationWarnings | M | 2.7 | + +### Key Files + +| File | Change | +|------|--------| +| `crates/libs/clawdstrike/src/sandbox/mod.rs` | **NEW**: module root | +| `crates/libs/clawdstrike/src/sandbox/capability_builder.rs` | **NEW**: policy → caps translation | +| `crates/libs/clawdstrike/src/sandbox/preflight.rs` | **NEW**: QueryContext validation | +| `crates/services/hush-cli/src/sandbox_nono.rs` | Use CapabilityBuilder instead of hardcoded paths | +| `crates/services/hush-cli/src/hush_run.rs` | Pass policy to CapabilityBuilder | + +### Translation Matrix + +| Ruleset | Forbidden Paths | Network | Commands | Expected CapabilitySet | +|---------|----------------|---------|----------|----------------------| +| default.yaml | .ssh, .aws, .gnupg, .kube, .env, etc. | ProxyOnly | rm, dd, chmod, sudo | Standard system + workdir, proxy networking | +| strict.yaml | Same + additional | ProxyOnly (empty allowlist) | Same + tighter | Minimal system + workdir, strict proxy | +| permissive.yaml | Minimal | AllowAll | Minimal | Wide system access | +| ai-agent.yaml | Same minus exceptions | ProxyOnly (wider allowlist) | rm, dd only | Standard + exception paths | + +### Acceptance Criteria + +- [ ] `CapabilityBuilder::new(policy).build()` returns valid `CapabilitySet` +- [ ] `default.yaml` sandbox blocks `~/.ssh`, `~/.aws`, `/etc/shadow` +- [ ] `strict.yaml` sandbox has fewer allowed paths than `default.yaml` +- [ ] `ai-agent.yaml` sandbox allows `.env.example` (exception) +- [ ] Pre-flight warns if working directory would be blocked +- [ ] TranslationWarnings emitted for untranslatable guard configs + +--- + +## Phase 3: Receipt Attestation + +**Goal**: Receipts attest to kernel enforcement. + +### Tasks + +| # | Task | Effort | Dependency | +|---|------|--------|------------| +| 3.1 | Define `SandboxAttestation` struct | S | Phase 2 | +| 3.2 | Implement serialization to JSON | S | 3.1 | +| 3.3 | Build attestation in `cmd_run()` after child exits | M | 3.1 | +| 3.4 | Merge into receipt metadata via `merge_metadata()` | S | 3.3 | +| 3.5 | Add `is_kernel_enforced()` to SignedReceipt | S | 3.4 | +| 3.6 | Update receipt verification to check sandbox claims | M | 3.5 | +| 3.7 | Tests: receipt contains sandbox metadata | M | 3.4 | +| 3.8 | Tests: signed receipt verifies with sandbox data | M | 3.6 | + +### Key Files + +| File | Change | +|------|--------| +| `crates/libs/clawdstrike/src/sandbox/attestation.rs` | **NEW**: SandboxAttestation types | +| `crates/libs/hush-core/src/receipt.rs` | Add helper methods | +| `crates/services/hush-cli/src/hush_run.rs` | Build attestation, merge into receipt | + +### Acceptance Criteria + +- [ ] `hush run` generates receipt with `metadata.sandbox.enforced: true` +- [ ] Receipt includes capability set serialization +- [ ] Receipt includes platform info (landlock/seatbelt, ABI version) +- [ ] Signed receipt verification succeeds with sandbox metadata +- [ ] `hush verify` displays sandbox enforcement status + +--- + +## Phase 4: Supervisor Enforcement + +**Goal**: Dynamic enforcement via supervisor IPC. + +### Tasks + +| # | Task | Effort | Dependency | +|---|------|--------|------------| +| 4.1 | Implement `GuardSupervisorBackend` (ApprovalBackend trait) | L | Phase 2 | +| 4.2 | Build never_grant list from policy | M | 4.1 | +| 4.3 | Implement Linux supervisor loop (seccomp-notify) | XL | 4.1, 4.2 | +| 4.4 | Implement macOS supervisor loop (extensions) | L | 4.1, 4.2 | +| 4.5 | Modify `spawn_sandboxed_child()` for supervisor mode | L | 4.3, 4.4 | +| 4.6 | Add `--supervised` flag to CLI | S | 4.5 | +| 4.7 | Include supervisor stats in receipt | M | 4.5 | +| 4.8 | Include denial records in receipt | M | 4.5 | +| 4.9 | Integration tests: supervisor approves allowed paths | L | 4.5 | +| 4.10 | Integration tests: supervisor denies forbidden paths | L | 4.5 | +| 4.11 | Integration tests: never_grant is absolute | M | 4.5 | +| 4.12 | Integration tests: rate limiting works | M | 4.5 | +| 4.13 | Performance benchmarks | M | 4.5 | + +### Key Files + +| File | Change | +|------|--------| +| `crates/libs/clawdstrike/src/sandbox/supervisor.rs` | **NEW**: GuardSupervisorBackend | +| `crates/libs/clawdstrike/src/sandbox/never_grant.rs` | **NEW**: policy → never_grant | +| `crates/services/hush-cli/src/supervised_exec.rs` | **NEW**: fork+exec with supervisor loop | +| `crates/services/hush-cli/src/hush_run.rs` | Wire supervised execution | +| `crates/services/hush-cli/src/cli.rs` | Add `--supervised` flag | + +### Acceptance Criteria + +- [ ] `hush run --supervised default.yaml -- cat /project/file.txt` succeeds (guard approves) +- [ ] `hush run --supervised default.yaml -- cat ~/.ssh/id_rsa` fails (never_grant) +- [ ] Supervisor loop handles child exit gracefully +- [ ] Rate limiting prevents fd-injection flooding +- [ ] Receipt includes supervisor request/denial counts +- [ ] macOS extension flow works end-to-end +- [ ] Linux seccomp-notify flow works end-to-end +- [ ] Performance: <5ms added latency per file operation + +--- + +## Risk Mitigation + +| Risk | Likelihood | Impact | Mitigation | +|------|-----------|--------|------------| +| Fork in multi-threaded Tokio process | HIGH | HIGH | Fork before Tokio runtime (pre_exec is unsafe — see Phase 1) | +| Path canonicalization failures (nonexistent paths) | MEDIUM | MEDIUM | Skip with warning, log to receipt | +| Landlock ABI not available (old kernel) | MEDIUM | MEDIUM | Graceful degradation, log warning | +| seccomp-notify kernel support (requires 5.9+) | LOW | HIGH | Feature-gate supervisor mode, fallback to static | +| Glob pattern → fixed path translation gaps | HIGH | MEDIUM | Accept coarser enforcement, document gaps | +| Performance regression from supervisor IPC | MEDIUM | LOW | Fast-path for initial capabilities, benchmark | +| Breaking change in nono API | LOW | MEDIUM | Pin version, integration tests | + +## Rollback Strategy + +Each phase has independent rollback: + +- **Phase 1**: `--sandbox=legacy` flag falls back to old wrappers +- **Phase 2**: `--sandbox=static` uses hardcoded paths (Phase 1 behavior) +- **Phase 3**: Receipt metadata is additive; removal doesn't break verification +- **Phase 4**: `--supervised=false` disables supervisor loop, falls back to static sandbox + +## Dependency Graph + +``` +Phase 1 ──> Phase 2 ──> Phase 3 + | + └──> Phase 4 +``` + +Phase 3 and Phase 4 can proceed in parallel after Phase 2, but Phase 3's `SandboxAttestation` +struct should be designed with optional Phase 4 fields (supervisor stats, denial records) +from the start, populated as `None`/empty until Phase 4 is complete. + +## nono API Coverage by Phase + +| API | Phase 1 | Phase 2 | Phase 3 | Phase 4 | +|-----|---------|---------|---------|---------| +| `CapabilitySet::new()` | x | x | x | x | +| `.allow_path()` | x | x | x | x | +| `.block_network()` / `.proxy_only()` | x | x | x | x | +| `.block_command()` | x | x | x | x | +| `.platform_rule()` | | x | | x | +| `.enable_extensions()` | | | | x | +| `Sandbox::apply()` | x | x | x | x | +| `Sandbox::is_supported()` | x | x | x | x | +| `Sandbox::support_info()` | x | | x | | +| `QueryContext::new()` | | x | | | +| `.query_path()` | | x | | | +| `SandboxState::from_caps()` | | | x | x | +| `.to_json()` | | | x | x | +| `SupervisorSocket::pair()` | | | | x | +| `NeverGrantChecker::new()` | | | | x | +| `linux::install_seccomp_notify()` | | | | x | +| `linux::recv_notif()` | | | | x | +| `linux::inject_fd()` / `deny_notif()` | | | | x | +| `macos::extension_issue_file()` | | | | x | +| `macos::extension_consume()` | | | | x | +| `DiagnosticFormatter` | | | | x | +| `DenialRecord` | | | x | x | diff --git a/docs/nono-integration/INDEX.md b/docs/nono-integration/INDEX.md new file mode 100644 index 000000000..8f8ea6976 --- /dev/null +++ b/docs/nono-integration/INDEX.md @@ -0,0 +1,52 @@ +# Nono Integration Documentation Index + +Specification and implementation plan for incorporating [nono](../../standalone/nono/) (OS-level capability-based sandboxing) into ClawdStrike's runtime security enforcement stack. + +## Problem Statement + +ClawdStrike's IRM system and guards are **advisory only**. They evaluate policies, produce verdicts, log violations, and sign receipts - but nothing prevents the child process from performing the forbidden operation at the OS level. The `hush run` command has optional sandbox wrappers (`sandbox-exec` on macOS, `bwrap` on Linux), but these are basic, not integrated with guard verdicts, and not capability-based. + +Nono provides kernel-level sandboxing via Landlock (Linux) and Seatbelt (macOS) with a clean Rust library API. Integrating it closes the enforcement gap: guard verdicts become structurally enforced by the kernel. + +## Documents + +| # | Document | Description | +|---|----------|-------------| +| 01 | [Requirements](01-requirements.md) | Goals, non-goals, success criteria, constraints | +| 02 | [Architecture](02-architecture.md) | Integration architecture, data flow, component interactions | +| 03 | [Sandbox Replacement](03-sandbox-replacement.md) | Phase 1: Replace sandbox-exec/bwrap with nono library | +| 04 | [Policy Translation](04-policy-translation.md) | Phase 2: Translate guard policies to nono CapabilitySets | +| 05 | [Supervisor Enforcement](05-supervisor-enforcement.md) | Phase 3: Dynamic enforcement via supervisor IPC | +| 06 | [Receipt Attestation](06-receipt-attestation.md) | Sandbox state in receipts and signed attestations | +| 07 | [Implementation Plan](07-implementation-plan.md) | Phased rollout, milestones, risk mitigation | + +## Key Insight + +ClawdStrike and nono operate at **different layers** and are **complementary**: + +``` +Agent action + | + v +ClawdStrike guards (semantic, context-aware, advisory) + | - Prompt injection detection + | - Secret leak scanning + | - Shell command regex matching + | - MCP tool filtering + | - Jailbreak detection (ML) + | - Domain-level egress control + v +nono kernel sandbox (structural, irrevocable, enforced) + - Filesystem path allow-list + - Network port/mode restriction + - Command execution blocking + - Signal isolation + - File deletion prevention +``` + +Guards catch what the kernel cannot (content inspection, semantic analysis). The kernel prevents what guards cannot guarantee (TOCTOU-free filesystem isolation, irrevocable restrictions). + +## Source Repositories + +- **ClawdStrike**: `/Users/connor/Medica/backbay/standalone/clawdstrike/` +- **nono**: `/Users/connor/Medica/backbay/standalone/nono/` diff --git a/docs/plans/clawdstrike/macos-es-ne/deployment-and-verification.md b/docs/plans/clawdstrike/macos-es-ne/deployment-and-verification.md new file mode 100644 index 000000000..830d2ff80 --- /dev/null +++ b/docs/plans/clawdstrike/macos-es-ne/deployment-and-verification.md @@ -0,0 +1,73 @@ +# Deployment And Verification Requirements + +## Objective + +Freeze the packaging path and the failure-mode verification bar for the first macOS security-software wave. + +## Deployment Baseline + +- containing app: `apps/agent` +- distribution baseline: Developer ID outside the Mac App Store +- privileged runtime packaging: combined system extension nested under the containing app +- install target: `/Applications` for the containing app path used by the system-extension deployment model +- Mac App Store and app-extension deployment are explicitly deferred from the first wave + +This is the minimum deployment shape that `PKG` is allowed to optimize. It is not allowed to silently switch deployment models. + +## TN3134 And TN3165 Constraints + +`PKG` must treat TN3134 and TN3165 as gating platform documents, not optional background reading. + +For the active wave that means: + +- Developer ID distribution follows the system-extension deployment path +- bundle layout, entitlements, and approval flow must be reviewed against the system-extension model +- any attempt to pivot to an app-extension deployment model is a scope change that requires ORCH approval +- network-provider selection and packaging must stay aligned; do not choose a provider first and discover deployment constraints later + +## Packaging Consequences + +`PKG` owns the combined system-extension packaging family: + +- app entitlements and bundle metadata +- system-extension entitlements, plists, and profiles +- notarization and signing workflow +- release and CI checks for missing macOS packaging artifacts + +The packaging lane must also leave operator-readable evidence for: + +- system extension identifier and bundle identifiers +- signer identity used for the build +- notarization result +- activation status on a real macOS signer or test host + +## Required Failure-Mode Verification + +The first macOS wave is not verified by "it builds." The following states are mandatory verification gates: + +| State | Owner | Required evidence | +|------|-------|-------------------| +| System-extension approval denied | `HOST` + `PKG` | Host reports approval-blocked/degraded state; receipts do not claim enforcement | +| Full Disk Access missing | `ESINT` + `POLAT` | ES health degrades; attestation records provider-unavailable or degraded reason | +| Extension inactive while agent host is healthy | `HOST` + `POLAT` | Status surface shows host-up/provider-down split clearly | +| Launchd restart or host relaunch | `HOST` + `PKG` | Re-registration or degraded detection evidence after restart | +| ES deadline miss or dropped events | `ESINT` + `POLAT` | Explicit counters and degraded attestation evidence | +| NE provider unavailable | `NEINT` + `POLAT` | Host/receipt evidence that enforcement is unavailable rather than silently bypassed | +| Notarization/signing unavailable in worker env | `PKG` | Blocked release-gate handoff instead of a false verified claim | + +## Review Rule + +Any review lane must reject a handoff that: + +- claims macOS enforcement without provider-health evidence +- treats transparent proxy as the default NE path without the documented exception +- omits denial-case or degraded-case verification because the happy path succeeded + +## Apple Sources + +- TN3134: Network Extension provider deployment + https://developer.apple.com/documentation/technotes/tn3134-network-extension-provider-deployment +- TN3165: Packet Filter is not API + https://developer.apple.com/documentation/technotes/tn3165-packet-filter-is-not-api +- System Extensions + https://developer.apple.com/system-extensions/ diff --git a/docs/plans/clawdstrike/macos-es-ne/endpoint-security-auth-contract.md b/docs/plans/clawdstrike/macos-es-ne/endpoint-security-auth-contract.md new file mode 100644 index 000000000..20a0a133c --- /dev/null +++ b/docs/plans/clawdstrike/macos-es-ne/endpoint-security-auth-contract.md @@ -0,0 +1,96 @@ +# EndpointSecurity AUTH Contract + +## Objective + +Define the macOS enforcement contract that replaces the current Linux-only supervised-exec model. + +## Why This Exists + +Today, `crates/services/hush-cli/src/supervised_exec.rs` implements a Linux seccomp-notify loop that: + +- intercepts `openat` and `openat2` +- evaluates the request in userspace +- injects a granted file descriptor back into the child + +That is not the macOS model. + +EndpointSecurity `AUTH_*` events are deadline-bound authorization requests on a kernel snapshot. They do not give ClawdStrike an equivalent "evaluate then inject fd" control surface. They can also fail open when the client misses deadlines or loses health. + +The first macOS wave needs an explicit contract here so workers do not translate Linux semantics directly into an ES implementation. + +## Frozen Invariants + +- macOS must not claim Linux-style fd-injection semantics +- macOS supervised enforcement remains unavailable until the ES provider and attestation surfaces can prove the invariants below +- an ES authorization result is evidence about a point-in-time decision, not proof of post-open immutability +- any deadline miss, dropped-event condition, inactive provider state, missing approval, or missing Full Disk Access must degrade attestation and host health instead of being silently reported as enforced success + +## Minimum Event Contract + +`POLAT` freezes the contract that `ESINT` implements. The minimum acceptable file/process contract for the first wave is: + +- file access authorization starts from `AUTH_OPEN` +- execution-sensitive policy must explicitly map the required exec/process events instead of assuming `AUTH_OPEN` is enough +- evidence for allowed operations must include corresponding notify-side counters or audit output where the platform model requires it +- any additional AUTH event added for create, rename, link, unlink, or process launch must be listed in the reviewed handoff, not implied + +## Timeout And Fail-Open Contract + +`ESINT` and `POLAT` must treat deadline handling as first-order enforcement behavior: + +- every AUTH decision path records decision latency against the message deadline +- deadline misses increment explicit counters exposed to the host and receipt attestation +- any deadline miss or dropped-event condition marks the ES provider degraded +- degraded ES state prevents ClawdStrike from claiming fully enforced macOS supervised mode +- review must reject implementations that collapse "ClawdStrike asked to deny" into "the kernel definitely denied" when the observed platform outcome may have failed open + +## Cache And Muting Contract + +EndpointSecurity cache and muting are allowed only when the implementation can prove: + +- the muted or cached scope is deterministic +- the scope is keyed to a policy/configuration identity +- policy reload or ruleset change invalidates the relevant cache or mute state +- audit output still records the effective decision model used for the run + +If those conditions are not met, the first wave does not use that cache or muting path. + +## TOCTOU Contract + +The ES implementation must document and expose the authorization snapshot boundary: + +- `AUTH_*` authorizes a request on the observed snapshot +- later file-content or file-identity drift is a separate risk +- receipts must never imply that an allow decision made the target immutable +- higher-risk flows should correlate AUTH and NOTIFY evidence rather than overstating continuous protection + +## Host And Attestation Requirements + +The host and receipt surface must expose at least: + +- provider installed state +- provider active state +- provider healthy state +- provider degraded reasons +- deadline-miss counter +- dropped-event counter +- last-healthy timestamp +- whether enforcement was unavailable, degraded, or active during the run + +## Required Verification Evidence + +Before `RESINT` can clear `ESINT`, the lane must show: + +- allow and deny fixtures for the frozen AUTH contract +- a synthetic deadline-miss or over-deadline path that degrades health and attestation +- a dropped-event or inactive-provider path that prevents false "enforced" claims +- a missing Full Disk Access path that surfaces degraded provider health + +## Apple Sources + +- Build an Endpoint Security app (WWDC20) + https://developer.apple.com/videos/play/wwdc2020/10159/ +- Monitoring system events with Endpoint Security + https://developer.apple.com/documentation/endpointsecurity/monitoring-system-events-with-endpoint-security +- System Extensions + https://developer.apple.com/system-extensions/ diff --git a/docs/plans/clawdstrike/macos-es-ne/network-extension-provider-and-topology.md b/docs/plans/clawdstrike/macos-es-ne/network-extension-provider-and-topology.md new file mode 100644 index 000000000..20e60e03a --- /dev/null +++ b/docs/plans/clawdstrike/macos-es-ne/network-extension-provider-and-topology.md @@ -0,0 +1,87 @@ +# Network Extension Provider And Topology + +## Objective + +Freeze the macOS network-enforcement shape before `NEINT` writes code. + +## Why This Exists + +The current repo has a real `ProxyOnly` runtime path in `CapabilitySet`, but that is an implementation artifact of the existing nono + local proxy flow. It is not enough to justify the macOS NetworkExtension architecture. + +Apple's current guidance is narrower: + +- use a content filter provider when the product inspects or blocks TCP/UDP flows +- use a transparent proxy provider only when the other provider models do not fit +- system extensions can combine multiple extension points, including EndpointSecurity and NetworkExtension, inside one combined system extension + +Those constraints have to drive the initial ClawdStrike shape, not the other way around. + +## Frozen Decision + +- `apps/agent` remains the containing app baseline for the first macOS security-software wave. +- The first macOS security-software wave targets one combined system extension nested under `apps/agent`. +- The initial NetworkExtension provider baseline is a content filter provider. +- Transparent proxy is exception-only for this initiative. It is not the default target. +- `apps/desktop` remains out of scope for the initial privileged-component rollout. + +## Rationale + +### Provider Choice + +ClawdStrike's currently stated network requirement is policy-driven mediation of agent egress. That is much closer to "inspect or block flows" than to "preserve an existing proxy product at all costs." + +That means: + +- the current `ProxyOnly` runtime collapse is a legacy transport shape, not a product requirement +- `NEINT` must start from a provider-agnostic mediation contract +- content filter is the default implementation target unless we can prove it is insufficient + +### Topology Choice + +Apple supports combined system extensions with multiple extension points. A combined system extension gives ClawdStrike the simpler first-wave packaging and approval story: + +- one containing app +- one privileged bundle family to sign and notarize +- one activation and health surface for the host to read out +- lane-specific ES and NE code under separate subtrees without pretending the top-level bundle layout is separate + +## Transparent Proxy Exception Rule + +`NEINT` may not implement a transparent proxy path unless ORCH records all of the following in a reviewed handoff: + +1. the product requirement that content filter cannot satisfy +2. the exact provider limitation or unsupported behavior that forces transparent proxy +3. the entitlement, plist, bundle-layout, and activation changes required by the exception +4. the updated verification matrix for that exception path + +Without that handoff, transparent proxy is out of scope for the active wave. + +## Repo Mapping + +The active implementation map uses a combined system-extension container with lane-specific subtrees: + +- host control plane: `apps/agent/src-tauri/src/macos/**` +- combined system extension root: `apps/agent/src-tauri/macos/system-extension/**` +- ES subtree: `apps/agent/src-tauri/macos/system-extension/endpoint-security/**` +- NE subtree: `apps/agent/src-tauri/macos/system-extension/network-extension/**` +- entitlement and plist assets: `apps/agent/src-tauri/macos/system-extension/{entitlements,plists,profiles}/**` + +## Required Review Questions + +Before `NEINT` is accepted, review must answer: + +- does the implemented provider still match the content-filter baseline from this document? +- if not, is there an approved transparent-proxy exception handoff? +- does the host report the provider as installed, active, healthy, and policy-synced? +- does attestation distinguish provider-unavailable and provider-degraded states from actual enforced mediation? + +## Apple Sources + +- TN3165: Packet Filter is not API + https://developer.apple.com/documentation/technotes/tn3165-packet-filter-is-not-api +- Network Extensions for the Modern Mac (WWDC19) + https://developer.apple.com/videos/play/wwdc2019/714/ +- System Extensions + https://developer.apple.com/system-extensions/ +- TN3134: Network Extension provider deployment + https://developer.apple.com/documentation/technotes/tn3134-network-extension-provider-deployment diff --git a/docs/plans/clawdstrike/macos-es-ne/swarm-plan.md b/docs/plans/clawdstrike/macos-es-ne/swarm-plan.md new file mode 100644 index 000000000..038780d71 --- /dev/null +++ b/docs/plans/clawdstrike/macos-es-ne/swarm-plan.md @@ -0,0 +1,349 @@ +# macOS EndpointSecurity + NetworkExtension Swarm Plan + +## Objective + +Execute the first implementation wave for a ClawdStrike macOS integration that: + +- moves the privileged macOS host to `apps/agent` +- adds native EndpointSecurity and NetworkExtension components behind a frozen host and policy contract +- extends receipts and sandbox attestation so macOS enforcement is reported accurately +- adds the entitlement, signing, notarization, and CI wiring required for macOS delivery + +This document is now the active implementation map. The research wave is complete, and the control docs below are the required source of truth for any macOS security-software lane. + +## Control Docs + +- `docs/plans/clawdstrike/macos-es-ne/network-extension-provider-and-topology.md` +- `docs/plans/clawdstrike/macos-es-ne/endpoint-security-auth-contract.md` +- `docs/plans/clawdstrike/macos-es-ne/deployment-and-verification.md` + +## Frozen Decisions And Platform Gates + +- `apps/agent` is the containing app for privileged macOS components. +- Privileged ES and NE logic must not run in the Tauri UI process. They live in dedicated macOS-specific components behind the agent host. +- `apps/desktop` is deferred from the active implementation wave. Desktop readout can follow after the core host, policy, and packaging path is stable. +- Policy and attestation contracts must be frozen before the ES and NE implementation lanes run. +- Packaging, entitlements, signing, and notarization stay serialized after the runtime surfaces exist. +- the first macOS network-provider baseline is a content filter provider, not transparent proxy +- the first macOS privileged-bundle topology is one combined system extension under `apps/agent` +- macOS enforcement semantics are not Linux fd-injection semantics; ES timeout, fail-open, and degraded-state behavior are mandatory contract surfaces +- Developer ID system-extension deployment is the active packaging baseline; Mac App Store and app-extension deployment are deferred + +## Current Repo Anchors + +- Runtime launch and sandbox mode selection: + - `crates/services/hush-cli/src/hush_run.rs` + - `crates/services/hush-cli/src/supervised_exec.rs` +- Policy translation and runtime enforcement: + - `crates/libs/clawdstrike/src/sandbox/capability_builder.rs` + - `crates/libs/clawdstrike/src/sandbox/supervisor.rs` + - `crates/libs/clawdstrike/src/sandbox/never_grant.rs` +- Attestation and receipt surface: + - `crates/libs/clawdstrike/src/sandbox/attestation.rs` + - `crates/services/hush-cli/tests/supervisor_tests.rs` +- Chosen macOS containing app: + - `apps/agent/src-tauri/src/main.rs` + - `apps/agent/src-tauri/src/daemon.rs` + - `apps/agent/src-tauri/Cargo.toml` + - `apps/agent/src-tauri/tauri.conf.json` + - `apps/agent/src-tauri/build.rs` +- Packaging, signing, and release wiring: + - `apps/agent/scripts/prepare-bundled-hushd.sh` + - `scripts/notarize-agent-macos.sh` + - `.github/workflows/ci.yml` + - `.github/workflows/release.yml` + +## Shared Files + +These stay orchestrator-owned for the active implementation wave: + +- `.codex/swarm/lanes.tsv` +- `.codex/swarm/waves.tsv` +- `docs/plans/clawdstrike/macos-es-ne/swarm-plan.md` +- `docs/plans/clawdstrike/macos-es-ne/network-extension-provider-and-topology.md` +- `docs/plans/clawdstrike/macos-es-ne/endpoint-security-auth-contract.md` +- `docs/plans/clawdstrike/macos-es-ne/deployment-and-verification.md` +- `docs/plans/multi-agent/codex-swarm-playbook.md` +- `Cargo.toml` +- `Cargo.lock` + +## Serialized Assets + +These are not shared across active worker lanes. They are either lane-owned or deferred until their lane wave: + +- `apps/agent/src-tauri/Cargo.toml` + lane owner: `HOST` +- `apps/agent/src-tauri/Cargo.lock` + lane owner: `HOST` +- `apps/agent/src-tauri/src/main.rs` + lane owner: `HOST` +- `apps/agent/src-tauri/src/daemon.rs` + lane owner: `HOST` +- `apps/agent/src-tauri/src/macos/**` + lane owner: `HOST` +- `crates/libs/clawdstrike/src/sandbox/capability_builder.rs` + lane owner: `POLAT` +- `crates/libs/clawdstrike/src/sandbox/supervisor.rs` + lane owner: `POLAT` +- `crates/libs/clawdstrike/src/sandbox/never_grant.rs` + lane owner: `POLAT` +- `crates/libs/clawdstrike/src/sandbox/attestation.rs` + lane owner: `POLAT` +- `crates/services/hush-cli/src/hush_run.rs` + lane owner: `POLAT` +- `crates/services/hush-cli/src/supervised_exec.rs` + lane owner: `POLAT` +- `crates/services/hush-cli/tests/supervisor_tests.rs` + lane owner: `POLAT` +- `apps/agent/src-tauri/macos/system-extension/endpoint-security/**` + lane owner: `ESINT` +- `apps/agent/src-tauri/macos/system-extension/network-extension/**` + lane owner: `NEINT` +- `apps/agent/src-tauri/tauri.conf.json` + lane owner: `PKG` +- `apps/agent/src-tauri/build.rs` + lane owner: `PKG` +- `apps/agent/scripts/prepare-bundled-hushd.sh` + lane owner: `PKG` +- `scripts/notarize-agent-macos.sh` + lane owner: `PKG` +- `.github/workflows/ci.yml` + lane owner: `PKG` +- `.github/workflows/release.yml` + lane owner: `PKG` +- `apps/agent/src-tauri/macos/system-extension/entitlements/**` + lane owner: `PKG` +- `apps/agent/src-tauri/macos/system-extension/plists/**` + lane owner: `PKG` +- `apps/agent/src-tauri/macos/system-extension/profiles/**` + lane owner: `PKG` + +## Lane Map + +### ORCH + +Owns initiative coordination, lane boundaries, merge sequencing, and the final consolidated handoff. + +Outputs: + +- implementation-wave readiness +- merge queue decisions +- shared-file policy +- shared registration integration for `Cargo.toml` and `Cargo.lock` when lane work requires it +- exception approval when a lane wants to violate the content-filter baseline or combined-system-extension topology +- updated swarm metadata + +### HOST + +First write lane. Builds the containing-app foundation in `apps/agent`. + +Owned files: + +- `apps/agent/src-tauri/Cargo.toml` +- `apps/agent/src-tauri/Cargo.lock` +- `apps/agent/src-tauri/src/main.rs` +- `apps/agent/src-tauri/src/daemon.rs` +- `apps/agent/src-tauri/src/macos/**` + +Required outcomes: + +- agent-side macOS host module layout +- extension install, approval, activation, and degraded-state surface for the combined system extension +- local IPC and service contract for ES and NE components +- no provider-specific policy decisions that conflict with the control docs +- no packaging or workflow edits + +### POLAT + +Second write lane. Freezes policy translation, runtime bridge, and attestation semantics. + +Owned files: + +- `crates/libs/clawdstrike/src/sandbox/capability_builder.rs` +- `crates/libs/clawdstrike/src/sandbox/supervisor.rs` +- `crates/libs/clawdstrike/src/sandbox/never_grant.rs` +- `crates/libs/clawdstrike/src/sandbox/attestation.rs` +- `crates/services/hush-cli/src/hush_run.rs` +- `crates/services/hush-cli/src/supervised_exec.rs` +- `crates/services/hush-cli/tests/supervisor_tests.rs` +- optional new files under `crates/libs/clawdstrike/src/sandbox/macos/**` + +Required outcomes: + +- frozen host-to-runtime contract consumed by ES and NE lanes +- provider-agnostic network mediation contract that does not hard-code transparent proxy as the macOS target +- attestation schema that can report macOS provider install, activation, health, and degraded state +- backward-compatible receipt attestation deserialization for pre-POLAT `platform.mechanism` and legacy runtime-only payloads +- truthful outer receipt metadata when supervised enforcement is unavailable or degraded +- replacement for the current non-Linux supervised dead-end +- explicit fail-open, timeout, and dropped-event handling contract for macOS ES +- no packaging or workflow edits + +### ESINT + +Third-wave write lane. Implements the EndpointSecurity part of the combined system extension against the frozen host and policy contract. + +Owned files: + +- `apps/agent/src-tauri/macos/system-extension/endpoint-security/**` +- optional fixtures under `fixtures/macos/endpoint-security/**` + +Required outcomes: + +- ES-native component implementation under the combined system-extension surface +- process and file event mapping that matches the contract frozen by `POLAT` +- deadline-miss, dropped-event, and degraded-state counters exposed to the host and attestation surface +- no Linux-style fd-injection claims in code or handoff evidence +- no edits to `apps/agent/src-tauri/**` outside the lane-owned new component surface + +### NEINT + +Third-wave write lane. Implements the NetworkExtension part of the combined system extension against the frozen host and policy contract. + +Owned files: + +- `apps/agent/src-tauri/macos/system-extension/network-extension/**` +- optional fixtures under `fixtures/macos/network-extension/**` + +Required outcomes: + +- native NE component implementation aligned with the frozen network contract +- content-filter provider baseline implementation, unless ORCH approves a documented transparent-proxy exception +- host-consumable status and counter output +- no edits to `apps/agent/src-tauri/**` outside the lane-owned new component surface + +### PKG + +Final write lane. Serializes macOS bundle metadata, signing, entitlements, notarization, and CI release wiring. + +Owned files: + +- `apps/agent/src-tauri/tauri.conf.json` +- `apps/agent/src-tauri/build.rs` +- `apps/agent/scripts/prepare-bundled-hushd.sh` +- `scripts/notarize-agent-macos.sh` +- `.github/workflows/ci.yml` +- `.github/workflows/release.yml` +- `apps/agent/src-tauri/macos/system-extension/entitlements/**` +- `apps/agent/src-tauri/macos/system-extension/plists/**` +- `apps/agent/src-tauri/macos/system-extension/profiles/**` + +Required outcomes: + +- macOS entitlement and plist assets for the containing app plus combined system extension +- signed and notarized build path for the containing app plus combined system extension +- deployment evidence aligned with TN3134 and TN3165 constraints +- CI and release checks that fail closed on missing macOS packaging artifacts +- no edits to policy or runtime contract files + +### Review Lanes + +Each write lane is followed by a dedicated reviewer lane: + +- `RHOST` reviews `HOST` +- `RPOLAT` reviews `POLAT` +- `RESINT` reviews `ESINT` +- `RNEINT` reviews `NEINT` +- `RPKG` reviews `PKG` + +Reviewer outputs must be findings-first and must call out ownership violations, contract drift, missing tests, failure-mode gaps, and merge risk. + +## Dependency Graph + +- `HOST` depends on the control-doc baseline that `apps/agent` is the containing app and the combined system extension is the first-wave topology. +- `POLAT` depends on `HOST` being merged or otherwise frozen via reviewed handoff. +- `ESINT` depends on `POLAT` being merged or otherwise frozen via reviewed handoff plus the EndpointSecurity AUTH contract remaining unchanged. +- `NEINT` depends on `POLAT` being merged or otherwise frozen via reviewed handoff plus the content-filter baseline remaining unchanged unless ORCH approves an exception. +- `PKG` depends on `HOST`, `POLAT`, `ESINT`, and `NEINT` being merged or otherwise frozen via reviewed handoff. +- Review lanes gate the next write wave. Do not advance on author self-report alone. +- Desktop readout is a follow-on initiative, not part of this active lane map. +- no lane may override the provider or topology control docs without an ORCH-reviewed exception handoff + +## Verification Matrix + +### HOST + +- `cargo check --manifest-path apps/agent/src-tauri/Cargo.toml` +- unit or fixture evidence for approval-blocked, provider-inactive, and degraded-state host readout + +### POLAT + +- `cargo test -p hush-cli supervisor_tests -- --nocapture` +- `cargo test -p hush-cli --test supervisor_tests -- --nocapture` +- `cargo test -p clawdstrike sandbox:: -- --nocapture` +- `cargo test -p hush-cli hush_run::tests -- --nocapture` +- end-to-end `run_hush_with_receipt(..., true, ...)` evidence showing degraded non-Linux supervised runs do not serialize or verdict as successful supervised enforcement +- verification that macOS attestation does not report enforced success when a required provider is inactive, degraded, or missing approval + +### ESINT + +- `cargo check --manifest-path apps/agent/src-tauri/Cargo.toml` +- `swift build --package-path apps/agent/src-tauri/macos/system-extension/endpoint-security` +- synthetic deadline-miss or dropped-event evidence that degrades host health and attestation instead of claiming enforced success +- synthetic missing-Full-Disk-Access evidence that surfaces degraded ES state + +### NEINT + +- `cargo check --manifest-path apps/agent/src-tauri/Cargo.toml` +- `swift build --package-path apps/agent/src-tauri/macos/system-extension/network-extension` +- provider-selection evidence showing the content-filter baseline or an approved transparent-proxy exception +- provider-unavailable evidence that degrades host health and attestation instead of silently bypassing mediation + +### PKG + +- `cargo check --manifest-path apps/agent/src-tauri/Cargo.toml` +- on a macOS signer with Apple credentials: `bash scripts/notarize-agent-macos.sh` +- on a macOS signer or QA host: activation evidence for approved and denied system-extension install paths +- restart or relaunch evidence showing either reactivation or explicit degraded-state reporting +- when signer credentials are unavailable in the worker environment, leave a blocked release-gate handoff instead of claiming the lane is fully verified + +### Review Lanes + +- confirm lane verification actually ran or explain why it could not +- reject any transparent-proxy default without the documented exception handoff +- reject any macOS receipt or status claim that reports enforcement while a required provider is inactive or degraded +- review only the owned files plus the reviewed lane's handoff evidence + +## Merge Order + +1. `HOST` +2. `POLAT` +3. `ESINT` and `NEINT` +4. `PKG` +5. ORCH consolidation + +`ESINT` and `NEINT` may merge in either order after both are reviewed, but `PKG` does not start until both are accepted. + +## Wave5 Readiness + +`HOST` and `POLAT` now freeze the following implementation-facing contract for `ESINT` and `NEINT`: + +- host readout lives in `apps/agent/src-tauri/src/macos/status.rs`, `apps/agent/src-tauri/src/macos/host.rs`, and `/api/v1/agent/health`; next waves must populate that surface instead of inventing a parallel status model +- receipt attestation lives in `crates/libs/clawdstrike/src/sandbox/attestation.rs`; next waves must drive `provider_states`, `deadline_miss_count`, `dropped_event_count`, and degraded reasons through that surface rather than side-channel metadata +- outer receipt metadata in `crates/services/hush-cli/src/hush_run.rs` must downgrade `hush.sandbox` and fail the verdict when supervised enforcement is requested but unavailable or degraded +- macOS networking remains the implemented `ProxyOnly` runtime with an explicit legacy backend hint; `NEINT` may not rename that live backend to a more abstract contract string unless the actual implementation changes with ORCH approval +- reviewer lanes for wave5 must reject any ES or NE patch that bypasses these frozen surfaces or silently widens the contract + +## Wave Order + +- `wave0`: `ORCH` +- `wave1`: `HOST` +- `wave2`: `RHOST` +- `wave3`: `POLAT` +- `wave4`: `RPOLAT` +- `wave5`: `ESINT`, `NEINT` +- `wave6`: `RESINT`, `RNEINT` +- `wave7`: `PKG` +- `wave8`: `RPKG` +- `wave9`: `ORCH` + +## Stop Condition + +The implementation wave is complete when ORCH can hand back: + +- merged host foundation in `apps/agent` +- merged policy and attestation contract for macOS enforcement +- reviewed ES and NE component implementations +- merged packaging and release wiring for macOS delivery +- reviewed evidence for denied, degraded, and restart-path behavior +- a follow-on recommendation for desktop diagnostics and operator readout diff --git a/docs/plans/multi-agent/codex-swarm-playbook.md b/docs/plans/multi-agent/codex-swarm-playbook.md index cbecafd23..09a3d59a3 100644 --- a/docs/plans/multi-agent/codex-swarm-playbook.md +++ b/docs/plans/multi-agent/codex-swarm-playbook.md @@ -84,6 +84,16 @@ The scripts use `.codex/swarm/lanes.tsv` and `.codex/swarm/waves.tsv` as the source of truth. Raw `git worktree` commands remain a fallback for debugging the orchestration flow itself. +The `bootstrap` column in `.codex/swarm/lanes.tsv` is preset-based, not shell +source. Keep it to reviewed preset IDs such as `cargo-fetch-locked` or +`cargo-fetch-agent-locked` so the launcher never needs `eval`. + +When multiple initiatives share one `*-worktrees` directory, orchestration +artifacts default to a swarm-specific namespace inferred from the `ORCH` lane +metadata so stale `final.md`, `review.md`, or `run.pid` files do not collide. +Override that namespace with `CLAWDSTRIKE_SWARM_NAMESPACE` or set an exact path +with `CLAWDSTRIKE_SWARM_ORCH_DIR` when needed. + By default they create sibling directories named after the repo, for example: - `../clawdstrike-sdks-worktrees/` @@ -149,17 +159,21 @@ codex exec -C ../clawdstrike-sdks-p1a -p swarm-review \ ## Current Seeded Initiative -`.codex/swarm/lanes.tsv` and `.codex/swarm/waves.tsv` are currently seeded for the Huntronomer -workspace-shell initiative described in: +`.codex/swarm/lanes.tsv` and `.codex/swarm/waves.tsv` are currently seeded for the macOS +EndpointSecurity and NetworkExtension implementation initiative described in: + +- `docs/plans/clawdstrike/macos-es-ne/swarm-plan.md` + +The current seeded wave order is deliberately serialized: -- `docs/plans/clawdstrike/huntronomer/workspace-shell/README.md` -- `docs/plans/clawdstrike/huntronomer/workspace-shell/roadmap.md` -- `docs/plans/clawdstrike/huntronomer/workspace-shell/swarm-plan.md` -- `docs/specs/17-huntronomer-workspace-services.md` -- `docs/specs/18-huntronomer-shell-command-model.md` +- containing-app host foundation +- policy and attestation contract freeze +- parallel ES and NE implementation +- packaging, signing, and notarization +- review gates between each major write wave -Older dispatch and fleet-security lane maps remain valid examples, but they are not the active -launcher target anymore. +Older Huntronomer, dispatch, and fleet-security lane maps remain valid examples, but they are not +the active launcher target anymore. ## Guardrails diff --git a/docs/plans/threat-intel/overview.md b/docs/plans/threat-intel/overview.md index 02c9fbd1e..8f22c8a14 100644 --- a/docs/plans/threat-intel/overview.md +++ b/docs/plans/threat-intel/overview.md @@ -85,7 +85,7 @@ The Threat Intelligence Subsystem transforms Clawdstrike from a rule-based polic ## 4. Integration with Existing System -### 4.1 Guard System Extension +### 4.1 Guard Plugin Extension The threat intelligence components integrate as new guards implementing the existing `Guard` trait: diff --git a/fixtures/macos/endpoint-security/evidence/approval-blocked.json b/fixtures/macos/endpoint-security/evidence/approval-blocked.json new file mode 100644 index 000000000..024263a18 --- /dev/null +++ b/fixtures/macos/endpoint-security/evidence/approval-blocked.json @@ -0,0 +1,5 @@ +{ + "kind": "approval_blocked", + "detail": "System extension approval is blocked or missing for the EndpointSecurity provider.", + "operator_action": "Approve the system extension in System Settings and relaunch the containing app." +} diff --git a/fixtures/macos/endpoint-security/evidence/deadline-miss.json b/fixtures/macos/endpoint-security/evidence/deadline-miss.json new file mode 100644 index 000000000..426ff2a68 --- /dev/null +++ b/fixtures/macos/endpoint-security/evidence/deadline-miss.json @@ -0,0 +1,10 @@ +{ + "kind": "deadline_miss", + "event_type": "auth_open", + "path": "/private/tmp/slow.txt", + "decision": "deny", + "latency_ms": 275, + "deadline_ms": 200, + "notify_observed": false, + "detail": "Synthetic over-deadline AUTH_OPEN path proving fail-open risk." +} diff --git a/fixtures/macos/endpoint-security/evidence/dropped-events.json b/fixtures/macos/endpoint-security/evidence/dropped-events.json new file mode 100644 index 000000000..20931e175 --- /dev/null +++ b/fixtures/macos/endpoint-security/evidence/dropped-events.json @@ -0,0 +1,5 @@ +{ + "kind": "dropped_events", + "dropped_event_count": 3, + "detail": "Synthetic dropped-event evidence for degraded EndpointSecurity provider health." +} diff --git a/fixtures/macos/endpoint-security/evidence/inactive-provider.json b/fixtures/macos/endpoint-security/evidence/inactive-provider.json new file mode 100644 index 000000000..e98e09a99 --- /dev/null +++ b/fixtures/macos/endpoint-security/evidence/inactive-provider.json @@ -0,0 +1,5 @@ +{ + "kind": "inactive_provider", + "detail": "Combined system extension is installed but the EndpointSecurity provider is inactive.", + "operator_action": "Re-activate the system extension and verify the provider handshake." +} diff --git a/fixtures/macos/endpoint-security/evidence/missing-full-disk-access.json b/fixtures/macos/endpoint-security/evidence/missing-full-disk-access.json new file mode 100644 index 000000000..f4b578de4 --- /dev/null +++ b/fixtures/macos/endpoint-security/evidence/missing-full-disk-access.json @@ -0,0 +1,5 @@ +{ + "kind": "missing_full_disk_access", + "detail": "EndpointSecurity host lacks Full Disk Access, so enforcement health is degraded.", + "operator_action": "Grant Full Disk Access to the containing app and relaunch the host." +} diff --git a/fixtures/macos/endpoint-security/status/approval-blocked.json b/fixtures/macos/endpoint-security/status/approval-blocked.json new file mode 100644 index 000000000..21b474aed --- /dev/null +++ b/fixtures/macos/endpoint-security/status/approval-blocked.json @@ -0,0 +1,44 @@ +{ + "authorization_model" : "auth_open_point_in_time", + "contract" : "macos_endpoint_security_auth_contract", + "counters" : { + "auth_open_allow_count" : 0, + "auth_open_deny_count" : 0, + "deadline_miss_count" : 0, + "dropped_event_count" : 0, + "notify_open_count" : 0 + }, + "degraded_reasons" : [ + "system_extension_approval_blocked" + ], + "evidence_paths" : [ + { + "detail" : "System extension approval is blocked or missing.", + "kind" : "approval_blocked", + "path" : "fixtures/macos/endpoint-security/evidence/approval-blocked.json" + } + ], + "fail_open_possible" : true, + "fd_injection_equivalent" : false, + "host_status" : { + "approval" : "approval_blocked", + "endpoint_security" : { + "runtime" : { + "reason" : "system_extension_approval_blocked", + "state" : "degraded" + } + }, + "install_state" : "installed" + }, + "provider_state" : { + "active" : false, + "approval_status" : "blocked", + "availability" : "unavailable", + "degraded_reasons" : [ + "system_extension_approval_blocked" + ], + "healthy" : false, + "installed" : true, + "provider" : "endpoint_security" + } +} diff --git a/fixtures/macos/endpoint-security/status/deadline-miss.json b/fixtures/macos/endpoint-security/status/deadline-miss.json new file mode 100644 index 000000000..546188432 --- /dev/null +++ b/fixtures/macos/endpoint-security/status/deadline-miss.json @@ -0,0 +1,46 @@ +{ + "authorization_model" : "auth_open_point_in_time", + "contract" : "macos_endpoint_security_auth_contract", + "counters" : { + "auth_open_allow_count" : 0, + "auth_open_deny_count" : 1, + "deadline_miss_count" : 1, + "dropped_event_count" : 0, + "notify_open_count" : 0 + }, + "degraded_reasons" : [ + "authorization_deadline_missed", + "live_authorization_signal_missing" + ], + "evidence_paths" : [ + { + "detail" : "Synthetic over-deadline AUTH_OPEN path proving fail-open risk.", + "kind" : "deadline_miss", + "path" : "fixtures/macos/endpoint-security/evidence/deadline-miss.json" + } + ], + "fail_open_possible" : true, + "fd_injection_equivalent" : false, + "host_status" : { + "approval" : "approved", + "endpoint_security" : { + "runtime" : { + "reason" : "authorization_deadline_missed", + "state" : "degraded" + } + }, + "install_state" : "installed" + }, + "provider_state" : { + "active" : true, + "approval_status" : "approved", + "availability" : "degraded", + "degraded_reasons" : [ + "authorization_deadline_missed", + "live_authorization_signal_missing" + ], + "healthy" : false, + "installed" : true, + "provider" : "endpoint_security" + } +} diff --git a/fixtures/macos/endpoint-security/status/deny-decision.json b/fixtures/macos/endpoint-security/status/deny-decision.json new file mode 100644 index 000000000..eec255cf8 --- /dev/null +++ b/fixtures/macos/endpoint-security/status/deny-decision.json @@ -0,0 +1,40 @@ +{ + "authorization_model" : "auth_open_point_in_time", + "contract" : "macos_endpoint_security_auth_contract", + "counters" : { + "auth_open_allow_count" : 0, + "auth_open_deny_count" : 1, + "deadline_miss_count" : 0, + "dropped_event_count" : 0, + "notify_open_count" : 0 + }, + "degraded_reasons" : [ + + ], + "evidence_paths" : [ + + ], + "fail_open_possible" : true, + "fd_injection_equivalent" : false, + "host_status" : { + "approval" : "approved", + "endpoint_security" : { + "runtime" : { + "state" : "active" + } + }, + "install_state" : "installed" + }, + "provider_state" : { + "active" : true, + "approval_status" : "approved", + "availability" : "active", + "degraded_reasons" : [ + + ], + "healthy" : true, + "installed" : true, + "last_healthy_timestamp" : "2026-05-15T06:01:00Z", + "provider" : "endpoint_security" + } +} diff --git a/fixtures/macos/endpoint-security/status/dropped-events.json b/fixtures/macos/endpoint-security/status/dropped-events.json new file mode 100644 index 000000000..c47cb8054 --- /dev/null +++ b/fixtures/macos/endpoint-security/status/dropped-events.json @@ -0,0 +1,45 @@ +{ + "authorization_model" : "auth_open_point_in_time", + "contract" : "macos_endpoint_security_auth_contract", + "counters" : { + "auth_open_allow_count" : 1, + "auth_open_deny_count" : 0, + "deadline_miss_count" : 0, + "dropped_event_count" : 3, + "notify_open_count" : 1 + }, + "degraded_reasons" : [ + "dropped_enforcement_events" + ], + "evidence_paths" : [ + { + "detail" : "EndpointSecurity reported dropped enforcement events.", + "kind" : "dropped_events", + "path" : "fixtures/macos/endpoint-security/evidence/dropped-events.json" + } + ], + "fail_open_possible" : true, + "fd_injection_equivalent" : false, + "host_status" : { + "approval" : "approved", + "endpoint_security" : { + "runtime" : { + "reason" : "dropped_enforcement_events", + "state" : "degraded" + } + }, + "install_state" : "installed" + }, + "provider_state" : { + "active" : true, + "approval_status" : "approved", + "availability" : "degraded", + "degraded_reasons" : [ + "dropped_enforcement_events" + ], + "healthy" : false, + "installed" : true, + "last_healthy_timestamp" : "2026-05-15T06:03:00Z", + "provider" : "endpoint_security" + } +} diff --git a/fixtures/macos/endpoint-security/status/healthy-allow.json b/fixtures/macos/endpoint-security/status/healthy-allow.json new file mode 100644 index 000000000..84df018a1 --- /dev/null +++ b/fixtures/macos/endpoint-security/status/healthy-allow.json @@ -0,0 +1,40 @@ +{ + "authorization_model" : "auth_open_point_in_time", + "contract" : "macos_endpoint_security_auth_contract", + "counters" : { + "auth_open_allow_count" : 1, + "auth_open_deny_count" : 0, + "deadline_miss_count" : 0, + "dropped_event_count" : 0, + "notify_open_count" : 1 + }, + "degraded_reasons" : [ + + ], + "evidence_paths" : [ + + ], + "fail_open_possible" : true, + "fd_injection_equivalent" : false, + "host_status" : { + "approval" : "approved", + "endpoint_security" : { + "runtime" : { + "state" : "active" + } + }, + "install_state" : "installed" + }, + "provider_state" : { + "active" : true, + "approval_status" : "approved", + "availability" : "active", + "degraded_reasons" : [ + + ], + "healthy" : true, + "installed" : true, + "last_healthy_timestamp" : "2026-05-15T06:00:00Z", + "provider" : "endpoint_security" + } +} diff --git a/fixtures/macos/endpoint-security/status/inactive-provider.json b/fixtures/macos/endpoint-security/status/inactive-provider.json new file mode 100644 index 000000000..2a488a8b2 --- /dev/null +++ b/fixtures/macos/endpoint-security/status/inactive-provider.json @@ -0,0 +1,43 @@ +{ + "authorization_model" : "auth_open_point_in_time", + "contract" : "macos_endpoint_security_auth_contract", + "counters" : { + "auth_open_allow_count" : 0, + "auth_open_deny_count" : 0, + "deadline_miss_count" : 0, + "dropped_event_count" : 0, + "notify_open_count" : 0 + }, + "degraded_reasons" : [ + "provider_inactive" + ], + "evidence_paths" : [ + { + "detail" : "EndpointSecurity provider is installed but inactive.", + "kind" : "inactive_provider", + "path" : "fixtures/macos/endpoint-security/evidence/inactive-provider.json" + } + ], + "fail_open_possible" : true, + "fd_injection_equivalent" : false, + "host_status" : { + "approval" : "approved", + "endpoint_security" : { + "runtime" : { + "state" : "inactive" + } + }, + "install_state" : "installed" + }, + "provider_state" : { + "active" : false, + "approval_status" : "approved", + "availability" : "inactive", + "degraded_reasons" : [ + "provider_inactive" + ], + "healthy" : false, + "installed" : true, + "provider" : "endpoint_security" + } +} diff --git a/fixtures/macos/endpoint-security/status/missing-full-disk-access.json b/fixtures/macos/endpoint-security/status/missing-full-disk-access.json new file mode 100644 index 000000000..a0fbb4a27 --- /dev/null +++ b/fixtures/macos/endpoint-security/status/missing-full-disk-access.json @@ -0,0 +1,44 @@ +{ + "authorization_model" : "auth_open_point_in_time", + "contract" : "macos_endpoint_security_auth_contract", + "counters" : { + "auth_open_allow_count" : 0, + "auth_open_deny_count" : 0, + "deadline_miss_count" : 0, + "dropped_event_count" : 0, + "notify_open_count" : 0 + }, + "degraded_reasons" : [ + "missing_full_disk_access" + ], + "evidence_paths" : [ + { + "detail" : "Full Disk Access is missing for the EndpointSecurity host.", + "kind" : "missing_full_disk_access", + "path" : "fixtures/macos/endpoint-security/evidence/missing-full-disk-access.json" + } + ], + "fail_open_possible" : true, + "fd_injection_equivalent" : false, + "host_status" : { + "approval" : "approved", + "endpoint_security" : { + "runtime" : { + "reason" : "missing_full_disk_access", + "state" : "degraded" + } + }, + "install_state" : "installed" + }, + "provider_state" : { + "active" : true, + "approval_status" : "approved", + "availability" : "degraded", + "degraded_reasons" : [ + "missing_full_disk_access" + ], + "healthy" : false, + "installed" : true, + "provider" : "endpoint_security" + } +} diff --git a/fixtures/macos/network-extension/content-filter-provider-approval-blocked.json b/fixtures/macos/network-extension/content-filter-provider-approval-blocked.json new file mode 100644 index 000000000..8dc361e2f --- /dev/null +++ b/fixtures/macos/network-extension/content-filter-provider-approval-blocked.json @@ -0,0 +1,36 @@ +{ + "approval" : "approval_blocked", + "attestation_state" : { + "active" : false, + "approval_status" : "blocked", + "availability" : "unavailable", + "degraded_reasons" : [ + "approval_blocked" + ], + "healthy" : false, + "installed" : true, + "provider" : "network_extension" + }, + "backend_hint" : "legacy_proxy_only_runtime", + "counters" : { + "dropped_verdicts" : 0, + "flows_blocked" : 0, + "flows_observed" : 0, + "remediation_requests" : 0 + }, + "host_status" : { + "runtime" : { + "reason" : "approval_blocked", + "state" : "degraded" + } + }, + "install_state" : "installed", + "policy_synced" : false, + "provider_kind" : "content_filter", + "selection_evidence" : { + "backend_hint" : "legacy_proxy_only_runtime", + "effective_provider" : "content_filter", + "exception_required" : false, + "requested_provider" : "content_filter" + } +} diff --git a/fixtures/macos/network-extension/content-filter-provider-inactive.json b/fixtures/macos/network-extension/content-filter-provider-inactive.json new file mode 100644 index 000000000..0a02b1a03 --- /dev/null +++ b/fixtures/macos/network-extension/content-filter-provider-inactive.json @@ -0,0 +1,35 @@ +{ + "approval" : "approved", + "attestation_state" : { + "active" : false, + "approval_status" : "approved", + "availability" : "inactive", + "degraded_reasons" : [ + "provider_failed" + ], + "healthy" : false, + "installed" : true, + "provider" : "network_extension" + }, + "backend_hint" : "legacy_proxy_only_runtime", + "counters" : { + "dropped_verdicts" : 0, + "flows_blocked" : 0, + "flows_observed" : 0, + "remediation_requests" : 0 + }, + "host_status" : { + "runtime" : { + "state" : "inactive" + } + }, + "install_state" : "installed", + "policy_synced" : false, + "provider_kind" : "content_filter", + "selection_evidence" : { + "backend_hint" : "legacy_proxy_only_runtime", + "effective_provider" : "content_filter", + "exception_required" : false, + "requested_provider" : "content_filter" + } +} diff --git a/fixtures/macos/network-extension/content-filter-provider-selection.json b/fixtures/macos/network-extension/content-filter-provider-selection.json new file mode 100644 index 000000000..de1c07805 --- /dev/null +++ b/fixtures/macos/network-extension/content-filter-provider-selection.json @@ -0,0 +1,36 @@ +{ + "approval" : "approved", + "attestation_state" : { + "active" : true, + "approval_status" : "approved", + "availability" : "degraded", + "degraded_reasons" : [ + "non_enforcing_provider" + ], + "healthy" : false, + "installed" : true, + "provider" : "network_extension" + }, + "backend_hint" : "legacy_proxy_only_runtime", + "counters" : { + "dropped_verdicts" : 0, + "flows_blocked" : 0, + "flows_observed" : 42, + "remediation_requests" : 0 + }, + "host_status" : { + "runtime" : { + "reason" : "non_enforcing_provider", + "state" : "degraded" + } + }, + "install_state" : "installed", + "policy_synced" : true, + "provider_kind" : "content_filter", + "selection_evidence" : { + "backend_hint" : "legacy_proxy_only_runtime", + "effective_provider" : "content_filter", + "exception_required" : false, + "requested_provider" : "content_filter" + } +} diff --git a/fixtures/macos/network-extension/content-filter-provider-unavailable.json b/fixtures/macos/network-extension/content-filter-provider-unavailable.json new file mode 100644 index 000000000..19dc7019a --- /dev/null +++ b/fixtures/macos/network-extension/content-filter-provider-unavailable.json @@ -0,0 +1,36 @@ +{ + "approval" : "unknown", + "attestation_state" : { + "active" : false, + "approval_status" : "unknown", + "availability" : "unavailable", + "degraded_reasons" : [ + "system_extension_not_installed" + ], + "healthy" : false, + "installed" : false, + "provider" : "network_extension" + }, + "backend_hint" : "legacy_proxy_only_runtime", + "counters" : { + "dropped_verdicts" : 0, + "flows_blocked" : 0, + "flows_observed" : 0, + "remediation_requests" : 0 + }, + "host_status" : { + "runtime" : { + "reason" : "system_extension_not_installed", + "state" : "degraded" + } + }, + "install_state" : "not_installed", + "policy_synced" : false, + "provider_kind" : "content_filter", + "selection_evidence" : { + "backend_hint" : "legacy_proxy_only_runtime", + "effective_provider" : "content_filter", + "exception_required" : false, + "requested_provider" : "content_filter" + } +} diff --git a/scripts/codex-swarm/common.sh b/scripts/codex-swarm/common.sh index c727d7fb0..d375b937a 100755 --- a/scripts/codex-swarm/common.sh +++ b/scripts/codex-swarm/common.sh @@ -36,6 +36,121 @@ swarm_repo_name() { esac } +swarm_orchestrator_lane() { + local repo_root="${1:-$(swarm_repo_root)}" + local lane + + lane="$( + awk -F '\t' ' + NR == 1 { + for (i = 1; i <= NF; i++) { + idx[$i] = i + } + next + } + ("role" in idx) && $(idx["role"]) == "workstream_orchestrator" { + print $1 + exit + } + ' "$(swarm_lane_table "$repo_root")" + )" + if [[ -n "$lane" ]]; then + printf '%s\n' "$lane" + return + fi + + lane="$( + awk -F '\t' ' + NR == 1 { + for (i = 1; i <= NF; i++) { + idx[$i] = i + } + next + } + ("profile" in idx) && $(idx["profile"]) == "swarm-orchestrator" { + print $1 + exit + } + ' "$(swarm_lane_table "$repo_root")" + )" + if [[ -n "$lane" ]]; then + printf '%s\n' "$lane" + return + fi + + awk -F '\t' ' + NR == 1 { + for (i = 1; i <= NF; i++) { + idx[$i] = i + } + next + } + tolower($1) == "orch" { + print $1 + exit + } + ("brief_id" in idx) && toupper($(idx["brief_id"])) == "ORCH" { + print $1 + exit + } + ' "$(swarm_lane_table "$repo_root")" +} + +swarm_namespace() { + local repo_root + local namespace + local orch_lane + local orch_worktree + local orch_branch + repo_root="$(swarm_repo_root "${1:-$(pwd)}")" + if [[ -n "${CLAWDSTRIKE_SWARM_NAMESPACE:-}" ]]; then + swarm_assert_safe_namespace_name "$CLAWDSTRIKE_SWARM_NAMESPACE" + printf '%s\n' "$CLAWDSTRIKE_SWARM_NAMESPACE" + return + fi + namespace="$( + awk -F '\t' ' + NR == 1 { + for (i = 1; i <= NF; i++) { + idx[$i] = i + } + next + } + ("swarm" in idx) && $(idx["swarm"]) != "" { + print $(idx["swarm"]) + exit + } + ' "$(swarm_lane_table "$repo_root")" + )" + if [[ -n "$namespace" ]]; then + swarm_assert_safe_namespace_name "$namespace" + printf '%s\n' "$namespace" + return + fi + orch_lane="$(swarm_orchestrator_lane "$repo_root")" + orch_worktree="$(swarm_lane_field "$orch_lane" worktree "$repo_root")" + if [[ "$orch_worktree" == *-orch ]]; then + namespace="${orch_worktree%-orch}" + swarm_assert_safe_namespace_name "$namespace" + printf '%s\n' "$namespace" + return + fi + orch_branch="$(swarm_lane_field "$orch_lane" branch "$repo_root")" + if [[ -n "$orch_branch" ]]; then + namespace="${orch_branch##*/}" + namespace="${namespace%-orchestrator}" + namespace="${namespace%-orch}" + if [[ -n "$namespace" ]] && [[ "${namespace,,}" != "orch" ]]; then + swarm_assert_safe_namespace_name "$namespace" + printf '%s\n' "$namespace" + return + fi + fi + namespace="$(swarm_repo_name "$repo_root")" + swarm_assert_safe_namespace_name "$namespace" + printf '%s\n' "$namespace" +} + swarm_worktrees_dir() { local repo_root local repo_parent @@ -66,11 +181,11 @@ swarm_orchestration_dir() { local repo_root local repo_parent local parent_name - local repo_name + local namespace repo_root="$(swarm_repo_root "${1:-$(pwd)}")" repo_parent="$(swarm_repo_parent_dir "$repo_root")" parent_name="$(basename "$repo_parent")" - repo_name="$(swarm_repo_name "$repo_root")" + namespace="$(swarm_namespace "$repo_root")" if [[ -n "${CLAWDSTRIKE_SWARM_ORCH_DIR:-}" ]]; then printf '%s\n' "$CLAWDSTRIKE_SWARM_ORCH_DIR" return @@ -80,10 +195,10 @@ swarm_orchestration_dir() { printf '%s\n' "$repo_parent" ;; *-worktrees) - printf '%s/%s-orchestration\n' "$(dirname "$repo_parent")" "$repo_name" + printf '%s/%s-orchestration\n' "$(dirname "$repo_parent")" "$namespace" ;; *) - printf '%s/%s-orchestration\n' "$repo_parent" "$repo_name" + printf '%s/%s-orchestration\n' "$repo_parent" "$namespace" ;; esac } @@ -131,9 +246,46 @@ swarm_lane_docs() { printf '%s\n' "$docs" | tr ',' '\n' | sed '/^$/d' } +swarm_assert_safe_lane_name() { + local lane="$1" + if [[ ! "$lane" =~ ^[A-Za-z0-9_-]+$ ]]; then + printf 'Unsafe lane name: %s\n' "$lane" >&2 + exit 1 + fi +} + +swarm_assert_safe_worktree_name() { + local worktree="$1" + if [[ -z "$worktree" || "$worktree" == /* || "$worktree" == *..* || "$worktree" =~ [[:space:]] || ! "$worktree" =~ ^[A-Za-z0-9._-]+$ ]]; then + printf 'Unsafe worktree name: %s\n' "$worktree" >&2 + exit 1 + fi +} + +swarm_assert_safe_namespace_name() { + local namespace="$1" + if [[ -z "$namespace" || "$namespace" == /* || "$namespace" == *..* || "$namespace" =~ [[:space:]] || ! "$namespace" =~ ^[A-Za-z0-9._-]+$ ]]; then + printf 'Unsafe swarm namespace: %s\n' "$namespace" >&2 + exit 1 + fi +} + +swarm_assert_safe_branch_name() { + local branch="$1" + if [[ -z "$branch" || "$branch" == /* || "$branch" == *..* || "$branch" == *'@{'* || "$branch" =~ [[:space:]] || ! "$branch" =~ ^[A-Za-z0-9._/-]+$ ]]; then + printf 'Unsafe branch name: %s\n' "$branch" >&2 + exit 1 + fi + if ! git check-ref-format --branch "$branch" >/dev/null 2>&1; then + printf 'Invalid branch name: %s\n' "$branch" >&2 + exit 1 + fi +} + swarm_require_lane() { local lane="$1" local repo_root="${2:-$(swarm_repo_root)}" + swarm_assert_safe_lane_name "$lane" if [[ -z "$(swarm_lane_field "$lane" lane "$repo_root")" ]]; then printf 'Unknown lane: %s\n' "$lane" >&2 exit 1 @@ -168,9 +320,12 @@ swarm_wave_lanes() { swarm_lane_worktree_path() { local lane="$1" local repo_root="${2:-$(swarm_repo_root)}" + local worktree_name + worktree_name="$(swarm_lane_field "$lane" worktree "$repo_root")" + swarm_assert_safe_worktree_name "$worktree_name" printf '%s/%s\n' \ "$(swarm_worktrees_dir "$repo_root")" \ - "$(swarm_lane_field "$lane" worktree "$repo_root")" + "$worktree_name" } swarm_lane_orch_dir() { @@ -209,8 +364,6 @@ swarm_codex_profile_args() { case "$profile" in swarm-docs) printf '%s\n' \ - --enable \ - multi_agent \ --sandbox \ read-only \ -c \ @@ -218,8 +371,6 @@ swarm_codex_profile_args() { ;; swarm-orchestrator) printf '%s\n' \ - --enable \ - multi_agent \ --sandbox \ workspace-write \ -c \ @@ -227,8 +378,6 @@ swarm_codex_profile_args() { ;; swarm-worker) printf '%s\n' \ - --enable \ - multi_agent \ --sandbox \ workspace-write \ -c \ @@ -236,8 +385,6 @@ swarm_codex_profile_args() { ;; swarm-review) printf '%s\n' \ - --enable \ - multi_agent \ --sandbox \ read-only \ -c \ @@ -285,13 +432,14 @@ swarm_run_lane_bootstrap() { local lane="$1" local repo_root="${2:-$(swarm_repo_root)}" local worktree_path - local bootstrap_cmd + local bootstrap_preset + local -a bootstrap_args=() swarm_require_lane "$lane" "$repo_root" worktree_path="$(swarm_lane_worktree_path "$lane" "$repo_root")" - bootstrap_cmd="$(swarm_lane_bootstrap_cmd "$lane" "$repo_root")" + bootstrap_preset="$(swarm_lane_bootstrap_cmd "$lane" "$repo_root")" - if [[ -z "$bootstrap_cmd" ]]; then + if [[ -z "$bootstrap_preset" || "$bootstrap_preset" == "none" ]]; then return 0 fi @@ -300,10 +448,23 @@ swarm_run_lane_bootstrap() { exit 1 fi - printf 'bootstrap %s: %s\n' "$lane" "$bootstrap_cmd" + case "$bootstrap_preset" in + cargo-fetch-locked) + bootstrap_args=(cargo fetch --locked) + ;; + cargo-fetch-agent-locked) + bootstrap_args=(cargo fetch --locked --manifest-path apps/agent/src-tauri/Cargo.toml) + ;; + *) + printf 'Unknown bootstrap preset for %s: %s\n' "$lane" "$bootstrap_preset" >&2 + exit 1 + ;; + esac + + printf 'bootstrap %s: %s\n' "$lane" "$bootstrap_preset" ( cd "$worktree_path" - eval "$bootstrap_cmd" + "${bootstrap_args[@]}" ) } @@ -358,17 +519,21 @@ swarm_write_lane_prompt() { local brief_id local description local docs_block + local role + local profile swarm_require_lane "$lane" "$repo_root" brief_id="$(swarm_lane_field "$lane" brief_id "$repo_root")" description="$(swarm_lane_field "$lane" description "$repo_root")" docs_block="$(swarm_prompt_docs_block "$lane" "$repo_root")" + role="$(swarm_lane_field "$lane" role "$repo_root")" + profile="$(swarm_lane_field "$lane" profile "$repo_root")" - if [[ "$lane" == "orch" ]]; then + if [[ "$role" == "workstream_orchestrator" || "$profile" == "swarm-orchestrator" ]]; then cat > "$prompt_file" < "$prompt_file" < "$prompt_file" </dev/null 2>&1; then + printf 'Invalid base ref: %s\n' "$base_ref" >&2 + exit 1 +fi + for lane in "${lanes[@]}"; do swarm_require_lane "$lane" "$repo_root" worktree_path="$(swarm_lane_worktree_path "$lane" "$repo_root")" branch_name="$(swarm_lane_field "$lane" branch "$repo_root")" + swarm_assert_safe_branch_name "$branch_name" mkdir -p "$(swarm_lane_orch_dir "$lane" "$repo_root")" if [[ -e "$worktree_path" ]]; then diff --git a/scripts/notarize-agent-macos.sh b/scripts/notarize-agent-macos.sh index 1c2498009..60ac82deb 100755 --- a/scripts/notarize-agent-macos.sh +++ b/scripts/notarize-agent-macos.sh @@ -8,6 +8,95 @@ require_cmd() { fi } +plist_value() { + local plist_path="$1" + local key="$2" + plutil -extract "$key" raw -o - "$plist_path" +} + +validate_source_packaging_assets() { + if grep -R -nE "__[A-Z0-9_]+__" apps/agent/src-tauri/macos/system-extension >/dev/null; then + echo "[notarize] packaging assets still contain placeholders; concrete source metadata is required before notarization" >&2 + exit 1 + fi + + if grep -R -n "scaffold_only" apps/agent/src-tauri/macos/system-extension >/dev/null; then + echo "[notarize] packaging assets still declare scaffold_only state; notarization requires concrete source metadata plus a real embedded system extension bundle" >&2 + exit 1 + fi +} + +validate_embedded_system_extension() { + local app_path="$1" + local out_dir="$2" + local app_info="$app_path/Contents/Info.plist" + local system_extensions_dir="$app_path/Contents/Library/SystemExtensions" + local sysext_path + local sysext_info + local expected_app_bundle_id + local expected_system_extension_bundle_id + local expected_system_extension_version + + if [[ ! -d "$system_extensions_dir" ]]; then + echo "[notarize] built app bundle is missing Contents/Library/SystemExtensions" >&2 + exit 1 + fi + + sysext_path="$(find "$system_extensions_dir" -maxdepth 1 -type d -name '*.systemextension' | head -n 1)" + if [[ -z "$sysext_path" ]]; then + echo "[notarize] built app bundle is missing an embedded .systemextension" >&2 + exit 1 + fi + + sysext_info="$sysext_path/Contents/Info.plist" + if [[ ! -f "$app_info" || ! -f "$sysext_info" ]]; then + echo "[notarize] missing Info.plist in built app or embedded system extension" >&2 + exit 1 + fi + + expected_app_bundle_id="$(plist_value apps/agent/src-tauri/macos/system-extension/plists/agent-packaging-template.plist CFBundleIdentifier)" + expected_system_extension_bundle_id="$(plist_value apps/agent/src-tauri/macos/system-extension/plists/combined-system-extension-template.plist CFBundleIdentifier)" + expected_system_extension_version="$(plist_value apps/agent/src-tauri/macos/system-extension/plists/combined-system-extension-template.plist CFBundleVersion)" + + if [[ "$(plist_value "$app_info" CFBundleIdentifier)" != "$expected_app_bundle_id" ]]; then + echo "[notarize] built app bundle identifier does not match packaging source metadata" >&2 + exit 1 + fi + + if [[ "$(plist_value "$sysext_info" CFBundleIdentifier)" != "$expected_system_extension_bundle_id" ]]; then + echo "[notarize] embedded system extension bundle identifier does not match packaging source metadata" >&2 + exit 1 + fi + + if [[ "$(plist_value "$sysext_info" CFBundleVersion)" != "$expected_system_extension_version" ]]; then + echo "[notarize] embedded system extension version does not match packaging source metadata" >&2 + exit 1 + fi + + codesign --verify --verbose=2 "$sysext_path" | tee "$out_dir/codesign-verify-system-extension.txt" + codesign -d --entitlements :- "$app_path" > "$out_dir/app-entitlements.plist" 2>/dev/null + codesign -d --entitlements :- "$sysext_path" > "$out_dir/system-extension-entitlements.plist" 2>/dev/null + + grep -q "com.apple.developer.system-extension.install" "$out_dir/app-entitlements.plist" || { + echo "[notarize] app bundle is missing system-extension.install entitlement" >&2 + exit 1 + } + grep -q "content-filter-provider-systemextension" "$out_dir/app-entitlements.plist" || { + echo "[notarize] app bundle is missing NetworkExtension install entitlement" >&2 + exit 1 + } + grep -q "com.apple.developer.endpoint-security.client" "$out_dir/system-extension-entitlements.plist" || { + echo "[notarize] embedded system extension is missing EndpointSecurity entitlement" >&2 + exit 1 + } + grep -q "content-filter-provider-systemextension" "$out_dir/system-extension-entitlements.plist" || { + echo "[notarize] embedded system extension is missing NetworkExtension entitlement" >&2 + exit 1 + } + + echo "$sysext_path" > "$out_dir/system-extension-path.txt" +} + if [[ "$(uname -s)" != "Darwin" ]]; then echo "[notarize] this script must run on macOS" >&2 exit 1 @@ -17,8 +106,26 @@ require_cmd security require_cmd codesign require_cmd xcrun require_cmd spctl +require_cmd plutil require_cmd cargo +required_assets=( + "apps/agent/src-tauri/macos/system-extension/entitlements/agent-app.entitlements" + "apps/agent/src-tauri/macos/system-extension/entitlements/combined-system-extension.entitlements" + "apps/agent/src-tauri/macos/system-extension/plists/agent-packaging-template.plist" + "apps/agent/src-tauri/macos/system-extension/plists/combined-system-extension-template.plist" + "apps/agent/src-tauri/macos/system-extension/profiles/developer-id-profile-template.plist" +) + +for asset in "${required_assets[@]}"; do + if [[ ! -f "$asset" ]]; then + echo "[notarize] missing required packaging asset: $asset" >&2 + exit 1 + fi +done + +validate_source_packaging_assets + TEAM_ID="${APPLE_TEAM_ID:-}" SIGNING_IDENTITY="${APPLE_SIGNING_IDENTITY:-}" NOTARY_PROFILE="${NOTARYTOOL_PROFILE:-}" @@ -47,12 +154,15 @@ if [[ -z "$NOTARY_PROFILE" ]]; then fi TS="$(date -u +%Y%m%d-%H%M%S)" -OUT_DIR="docs/roadmaps/cua/research/artifacts/notarization-${TS}" +OUT_DIR="${NOTARIZE_OUT_DIR:-${TMPDIR:-/tmp}/clawdstrike-notarization-${TS}}" mkdir -p "$OUT_DIR" echo "[notarize] building signed app+dmg" pushd apps/agent/src-tauri >/dev/null -APPLE_SIGNING_IDENTITY="$SIGNING_IDENTITY" APPLE_TEAM_ID="$TEAM_ID" cargo tauri build --bundles app,dmg +APPLE_SIGNING_IDENTITY="$SIGNING_IDENTITY" \ +APPLE_TEAM_ID="$TEAM_ID" \ +CLAWDSTRIKE_REQUIRE_CONCRETE_MACOS_PACKAGING=1 \ +cargo tauri build --bundles app,dmg popd >/dev/null APP_PATH="$(ls -t apps/agent/src-tauri/target/release/bundle/macos/*.app | head -n 1)" @@ -66,6 +176,7 @@ fi echo "[notarize] verify codesign" codesign --verify --deep --strict --verbose=2 "$APP_PATH" | tee "$OUT_DIR/codesign-verify.txt" codesign -dv --verbose=4 "$APP_PATH" 2>&1 | tee "$OUT_DIR/codesign-details.txt" +validate_embedded_system_extension "$APP_PATH" "$OUT_DIR" spctl -a -vv "$APP_PATH" 2>&1 | tee "$OUT_DIR/spctl-before.txt" echo "[notarize] submitting dmg for notarization"