Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
b6295f8
feat(linux): prove native enforcement substrate
tumberger Aug 31, 2026
4f00cb6
fix(linux): handle pre-denied setgroups state
tumberger Aug 31, 2026
490bf1d
fix(linux): probe namespace enforcement safely
tumberger Aug 31, 2026
b54b06e
feat(linux): preserve explicit runtime aliases
tumberger Aug 31, 2026
5965f8a
fix(linux): preserve allow-all socket semantics
tumberger Aug 31, 2026
60e7a05
fix(linux): harden namespace mount enforcement
tumberger Aug 31, 2026
32f0e28
fix(linux): close asynchronous socket bypasses
tumberger Aug 31, 2026
84e232b
feat(linux): support explicit runtime devices
tumberger Aug 31, 2026
e1c2d6c
fix(linux): rebuild visible runtime aliases
tumberger Aug 31, 2026
1b09ab5
fix(linux): keep readable mounts non-executable
tumberger Aug 31, 2026
510f1af
fix(linux): close remaining policy gaps
tumberger Aug 31, 2026
2fe80ab
fix(linux): tighten native enforcement invariants
tumberger Aug 31, 2026
a6244e0
test(linux): verify an actual noexec mount
tumberger Aug 31, 2026
46d9ec5
fix(linux): isolate IPC and hard-link aliases
tumberger Sep 1, 2026
4d79698
fix(linux): isolate host IPC capabilities
tumberger Sep 1, 2026
2e9d6b7
fix(linux): preserve protected runtime aliases
tumberger Sep 1, 2026
d80649f
fix(linux): reject recursive write protections
tumberger Sep 1, 2026
d061745
refactor(linux): narrow preparation internals
tumberger Sep 1, 2026
fbc3730
fix(linux): preserve cross-platform plan checks
tumberger Sep 1, 2026
c1ce3e3
feat(linux): expose bounded primary executable identity
tumberger Sep 1, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 25 additions & 5 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,17 +40,37 @@ jobs:
- name: Test Seatbelt enforcement
run: make test-live

linux-facade:
name: Linux facade compile
runs-on: ubuntu-latest
linux-substrate:
name: Linux enforcement substrate (${{ matrix.runner }})
strategy:
fail-fast: false
matrix:
runner: [ubuntu-24.04, ubuntu-24.04-arm]
runs-on: ${{ matrix.runner }}
steps:
- name: Check out repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
with:
persist-credentials: false
- name: Install Rust toolchain
run: rustup toolchain install 1.91.1 --profile minimal
- name: Check facade and run external consumer
run: rustup toolchain install 1.91.1 --profile minimal --component clippy
- name: Lint Linux backend
run: cargo clippy -p sandy-linux --all-targets --all-features --locked -- -D warnings
- name: Verify restricted user namespaces fail closed
env:
SANDY_LINUX_EXPECT_UNSUPPORTED: "1"
run: |
if [ "$(cat /proc/sys/kernel/apparmor_restrict_unprivileged_userns)" = "1" ]; then
cargo test -p sandy-linux --test live_linux --features live-tests --locked
fi
- name: Enable user namespaces for positive enforcement tests
run: |
if [ -f /proc/sys/kernel/apparmor_restrict_unprivileged_userns ]; then
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
fi
- name: Test Linux backend
run: make test-live-linux
- name: Preserve unsupported facade contract
run: |
cargo check -p sandy-sandbox --all-targets --all-features --locked
cargo run --manifest-path tests/package-consumer/Cargo.toml --locked
34 changes: 27 additions & 7 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ This file governs the entire repository.

Sandy is a macOS-native process sandbox for AI coding agents.

- Cargo workspace: `sandy-core`, `sandy-seatbelt`, `sandy-sandbox`, and
`sandy-cli`
- Cargo workspace: `sandy-core`, `sandy-seatbelt`, `sandy-linux`,
`sandy-sandbox`, and `sandy-cli`
- Installed executable: `sandy`
- Default mode: standalone sandboxing
- Optional integrations: preserve verified existing Kontext hooks or
Expand Down Expand Up @@ -45,7 +45,7 @@ There is no implicit discovery, include, URL, fallback, or user-to-user
inheritance. File I/O and ambient resolution remain in `sandy-cli`; lexical
validation and deterministic additive composition remain in `sandy-core`.

Do not add Linux, detached sessions, a PTY proxy, domain filtering, credential
Do not add detached sessions, a PTY proxy, domain filtering, credential
brokering, dynamic grants, rollback, resource limits, raw Seatbelt input, or
organization-managed Kontext support or outside-sandbox synchronous hook
decision services without an explicit scope decision.
Expand All @@ -55,12 +55,13 @@ changes.

## Architecture

Use a virtual workspace with four packages representing validation,
Use a virtual workspace with five packages representing validation,
native-code, embedding, and product boundaries.

```text
crates/core/ package sandy-core; validated security contract
crates/seatbelt/ package sandy-seatbelt; macOS compiler and FFI
crates/linux/ package sandy-linux; Linux preparation and enforcement
crates/sandy/ package sandy-sandbox; supported Rust facade
crates/cli/ package sandy-cli; sandy binary and product UX
```
Expand All @@ -75,18 +76,20 @@ Keep dependencies flowing in one direction:
CLI
-> sandy-core
-> sandy-seatbelt -> sandy-core
-> sandy-linux -> sandy-core

sandy facade
-> sandy-core
-> sandy-seatbelt -> sandy-core
-> sandy-linux -> sandy-core

optional integrations
-> typed capabilities
-> never raw Seatbelt source
```

`sandy-core` performs deterministic validation but no ambient filesystem
discovery. `sandy-seatbelt` receives only validated policy and does not see
discovery. Native backends receive only validated policy and do not see
argv, environment, agent preset names, Clap, or service configuration. The CLI
does not render policy. `SandboxPolicy` is the shared, side-effect-free intent;
callers may construct it with the Rust builder or its strict, bounded,
Expand Down Expand Up @@ -204,8 +207,25 @@ cleanup. The FFI boundary exposes only owned safe Rust types and functions.
Raw pointers, unsafe functions, native error buffers, and raw sandbox flags
must not escape it.

Adding a native symbol requires documenting its SDK declaration, availability,
deprecation status, cleanup contract, and live macOS coverage.
`sandy-linux` follows the same rule. Its only permitted unsafe and native-call
module is:

```text
crates/linux/src/ffi.rs
```

The Linux backend separates deterministic `LinuxPolicyPlan`, ambient
`PreparedLinuxSandbox`, and irreversible `apply()` states. It requires a
single-threaded caller, private user, mount, and IPC namespaces, fixed Landlock
ABI 6 semantics with signal scoping, complete capability removal, and final
seccomp enforcement. It replaces the inherited session keyring with an
anonymous ring and denies key-management syscalls before untrusted work. It
never clears an inherited host `noexec` restriction. Unsupported policy
combinations fail before enforcement; there is no weaker fallback.

Adding a native operation requires documenting its declaration, availability,
deprecation status, cleanup contract, and live coverage on every supported
host and architecture.

## Data and CLI contracts

Expand Down
68 changes: 65 additions & 3 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 10 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
[workspace]
members = ["crates/core", "crates/seatbelt", "crates/cli", "crates/sandy"]
members = [
"crates/core",
"crates/seatbelt",
"crates/linux",
"crates/cli",
"crates/sandy",
]
default-members = ["crates/cli"]
resolver = "3"

Expand All @@ -16,15 +22,18 @@ authors = ["Kontext Security"]
assert_cmd = "2.0.17"
clap = { version = "4.5.51", features = ["derive"] }
libc = "0.2.177"
landlock = "0.4.7"
predicates = "3.1.3"
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.145"
sha2 = "0.10.9"
tempfile = "3.23.0"
thiserror = "2.0.17"
seccompiler = "0.5.0"

sandy-core = { path = "crates/core", version = "=0.1.9" } # x-release-please-version
sandy-seatbelt = { path = "crates/seatbelt", version = "=0.1.9" } # x-release-please-version
sandy-linux = { path = "crates/linux", version = "=0.1.9" } # x-release-please-version

[workspace.lints.rust]
rust_2018_idioms = { level = "deny", priority = -1 }
Expand Down
5 changes: 4 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
.DEFAULT_GOAL := help

.PHONY: help build test test-live fmt fmt-check clippy deny package-check formula-check release-version-check check run
.PHONY: help build test test-live test-live-linux fmt fmt-check clippy deny package-check formula-check release-version-check check run

help: ## Show available targets.
@awk 'BEGIN {FS = ":.*##"; printf "Usage:\n make <target>\n\n"} /^[a-zA-Z_0-9-]+:.*?##/ { printf " %-12s %s\n", $$1, $$2 }' $(MAKEFILE_LIST)
Expand All @@ -15,6 +15,9 @@ test-live: ## Run sacrificial macOS Seatbelt enforcement tests.
cargo test -p sandy-cli --test live_macos --locked -- --ignored --test-threads=1
cargo test -p sandy-sandbox --test live_macos --features live-tests --locked

test-live-linux: ## Run sacrificial Linux enforcement tests.
cargo test -p sandy-linux --test live_linux --features live-tests --locked -- --test-threads=1

fmt: ## Format Rust sources.
cargo fmt --all

Expand Down
34 changes: 34 additions & 0 deletions crates/linux/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
[package]
name = "sandy-linux"
description = "Linux enforcement backend for Sandy"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
homepage.workspace = true
authors.workspace = true
publish = false

[features]
live-tests = []

[dependencies]
sandy-core.workspace = true

[target.'cfg(target_os = "linux")'.dependencies]
landlock.workspace = true
libc.workspace = true
seccompiler.workspace = true

[dev-dependencies]
tempfile.workspace = true

[[test]]
name = "live_linux"
path = "tests/live_linux.rs"
harness = false
required-features = ["live-tests"]

[lints]
workspace = true
8 changes: 8 additions & 0 deletions crates/linux/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# sandy-linux

Private Linux enforcement backend for Sandy. It lowers a validated,
platform-neutral policy into a fixed native plan, pins every ambient resource,
and applies the irreversible boundary to the calling process.

This package is an implementation detail. Use the supported `sandy-sandbox`
facade instead of depending on it directly.
35 changes: 35 additions & 0 deletions crates/linux/src/apply.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
use crate::{
LinuxError, LinuxErrorKind, PreparedLinuxSandbox, capabilities, landlock, mount, namespace,
seccomp,
};

/// Irreversibly applies a fully prepared Linux sandbox to the current process.
///
/// Any error from this function may leave the process partially restricted.
/// The caller must terminate immediately and must not run untrusted code.
pub fn apply(mut prepared: PreparedLinuxSandbox) -> Result<(), LinuxError> {
namespace::enter(&prepared.namespace, prepared.block_network)?;
// Descriptors opened before CLONE_NEWNS still refer to mounts in the old
// namespace and cannot be cloned with open_tree. Reopen every canonical
// source in the new namespace and prove it is the same object before the
// old filesystem view is hidden.
crate::prepare::repin_after_namespace(&mut prepared.mount)?;
mount::construct_and_enter(&prepared.mount)?;
landlock::apply(prepared.landlock)?;
capabilities::drop_all(prepared.namespace.last_capability)?;
seccomp::apply(&prepared.seccomp)?;
verify_postconditions()
}

fn verify_postconditions() -> Result<(), LinuxError> {
// After pivot_root the former host root and procfs must be absent. This is
// an invariant check, not a source of policy authority.
if std::path::Path::new("/.old_root").exists() {
Err(LinuxError::new(
LinuxErrorKind::EnforcementFailed,
"postcondition verification",
))
} else {
Ok(())
}
}
7 changes: 7 additions & 0 deletions crates/linux/src/capabilities.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
use crate::{LinuxError, LinuxErrorKind, ffi};

pub(crate) fn drop_all(last_capability: u32) -> Result<(), LinuxError> {
ffi::drop_all_capabilities(last_capability)
.and_then(|()| ffi::verify_no_capabilities())
.map_err(|_| LinuxError::new(LinuxErrorKind::EnforcementFailed, "capability removal"))
}
Loading