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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
third_party/mxc/patches/*.patch text eol=lf -whitespace
21 changes: 21 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -379,6 +379,19 @@ jobs:
- name: Build
run: cargo build --locked --release

- name: Build pinned MXC Windows executor
shell: pwsh
run: |
$mxcDir = Join-Path $env:RUNNER_TEMP "mxc"
git clone --filter=blob:none $env:MXC_REPOSITORY $mxcDir
git -C $mxcDir checkout $env:MXC_REF
Get-ChildItem (Join-Path $env:GITHUB_WORKSPACE "third_party\mxc\patches\*.patch") |
Sort-Object Name | ForEach-Object { git -C $mxcDir apply --whitespace=error $_.FullName }
Push-Location (Join-Path $mxcDir "src")
cargo build --release -p wxc --no-default-features --locked
Pop-Location
Copy-Item (Join-Path $mxcDir "src\target\release\wxc-exec.exe") target\release\wxc-exec.exe

- name: Unit tests
run: cargo test --locked

Expand Down Expand Up @@ -449,6 +462,14 @@ jobs:
run: powershell -ExecutionPolicy Bypass -File e2e\agents\test_agents.ps1 -AxisBin .\target\release\axis.exe
shell: pwsh

- name: Windows MXC ProcessContainer smoke and security tests
shell: pwsh
env:
AXIS_RUN_MXC_BASECONTAINER_E2E: "1"
AXIS_SKIP_UNAVAILABLE_MXC_BASECONTAINER_E2E: "1"
AXIS_TEST_MXC_EXECUTOR: ${{ github.workspace }}\target\release\wxc-exec.exe
run: pwsh -NoProfile -File e2e/windows/test_mxc_processcontainer.ps1 -AxisBin ./target/release/axis.exe

- name: Test agent install (PowerShell)
run: |
# Test axis install --list
Expand Down
22 changes: 22 additions & 0 deletions .github/workflows/nightly.yml
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,20 @@ jobs:
cmp "$RUNNER_TEMP/axis-first-build/$binary" "target/$TARGET/release/$binary"
done

- name: Build MXC Windows executor
if: runner.os == 'Windows'
shell: pwsh
run: |
$mxcDir = Join-Path $env:RUNNER_TEMP "mxc"
git clone --filter=blob:none $env:MXC_REPOSITORY $mxcDir
git -C $mxcDir checkout $env:MXC_REF
Get-ChildItem (Join-Path $env:GITHUB_WORKSPACE "third_party\mxc\patches\*.patch") |
Sort-Object Name | ForEach-Object { git -C $mxcDir apply --whitespace=error $_.FullName }
Push-Location (Join-Path $mxcDir "src")
cargo build --release -p wxc --no-default-features --locked --target ${{ matrix.target }}
Pop-Location
Copy-Item (Join-Path $mxcDir "src\target\${{ matrix.target }}\release\wxc-exec.exe") target\${{ matrix.target }}\release\wxc-exec.exe

- name: Build MXC Linux executor
if: matrix.platform == 'linux-x86_64'
env:
Expand Down Expand Up @@ -285,6 +299,10 @@ jobs:
New-Item -ItemType Directory -Path "$packageDir" -Force
Copy-Item -LiteralPath (Join-Path "$targetDir" "axis.exe") -Destination "$packageDir"
Copy-Item -LiteralPath (Join-Path "$targetDir" "axisd.exe") -Destination "$packageDir"
Copy-Item -LiteralPath (Join-Path "$targetDir" "wxc-exec.exe") -Destination "$packageDir"
Copy-Item -LiteralPath (Join-Path "$targetDir" "axis-wfp-broker.exe") -Destination "$packageDir"
Copy-Item -LiteralPath (Join-Path "$targetDir" "axis-ssh-proxy.exe") -Destination "$packageDir"
Copy-Item -LiteralPath "scripts/install_windows_wfp_broker.ps1" -Destination "$packageDir"
Copy-Item -Path "policies\*.yaml" -Destination "$packageDir"
Copy-Item -LiteralPath "LICENSE" -Destination "$packageDir"
Copy-Item -LiteralPath "install.ps1" -Destination "$packageDir"
Expand Down Expand Up @@ -321,6 +339,10 @@ jobs:
"axis-$env:PLATFORM"
--require axis.exe
--require axisd.exe
--require wxc-exec.exe
--require axis-wfp-broker.exe
--require axis-ssh-proxy.exe
--require install_windows_wfp_broker.ps1
--require REPRODUCIBILITY.json

- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
Expand Down
22 changes: 22 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,20 @@ jobs:
cmp "$RUNNER_TEMP/axis-first-build/$binary" "target/$TARGET/release/$binary"
done

- name: Build MXC Windows executor
if: runner.os == 'Windows'
shell: pwsh
run: |
$mxcDir = Join-Path $env:RUNNER_TEMP "mxc"
git clone --filter=blob:none $env:MXC_REPOSITORY $mxcDir
git -C $mxcDir checkout $env:MXC_REF
Get-ChildItem (Join-Path $env:GITHUB_WORKSPACE "third_party\mxc\patches\*.patch") |
Sort-Object Name | ForEach-Object { git -C $mxcDir apply --whitespace=error $_.FullName }
Push-Location (Join-Path $mxcDir "src")
cargo build --release -p wxc --no-default-features --locked --target ${{ matrix.target }}
Pop-Location
Copy-Item (Join-Path $mxcDir "src\target\${{ matrix.target }}\release\wxc-exec.exe") target\${{ matrix.target }}\release\wxc-exec.exe

- name: Build MXC Linux executor
if: matrix.platform == 'linux-x86_64'
env:
Expand Down Expand Up @@ -277,6 +291,10 @@ jobs:
New-Item -ItemType Directory -Path "$packageDir" -Force
Copy-Item -LiteralPath (Join-Path "$targetDir" "axis.exe") -Destination "$packageDir"
Copy-Item -LiteralPath (Join-Path "$targetDir" "axisd.exe") -Destination "$packageDir"
Copy-Item -LiteralPath (Join-Path "$targetDir" "wxc-exec.exe") -Destination "$packageDir"
Copy-Item -LiteralPath (Join-Path "$targetDir" "axis-wfp-broker.exe") -Destination "$packageDir"
Copy-Item -LiteralPath (Join-Path "$targetDir" "axis-ssh-proxy.exe") -Destination "$packageDir"
Copy-Item -LiteralPath "scripts/install_windows_wfp_broker.ps1" -Destination "$packageDir"
Copy-Item -Path "policies\*.yaml" -Destination "$packageDir"
Copy-Item -LiteralPath "README.md" -Destination "$packageDir"
Copy-Item -LiteralPath "LICENSE" -Destination "$packageDir"
Expand Down Expand Up @@ -314,6 +332,10 @@ jobs:
"axis-$env:PLATFORM"
--require axis.exe
--require axisd.exe
--require wxc-exec.exe
--require axis-wfp-broker.exe
--require axis-ssh-proxy.exe
--require install_windows_wfp_broker.ps1
--require REPRODUCIBILITY.json

- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

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

55 changes: 42 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,11 @@ platform backend is available:

| Layer | Linux | Windows | macOS |
|---|---|---|---|
| Process | MXC Bubblewrap process backend with AXIS seccomp; native Landlock/seccomp retained | Launch blocked; Restricted Token + Job Object are containment targets | Seatbelt (sandbox-exec) |
| Filesystem | MXC Bubblewrap mounts or native Landlock LSM | Launch blocked; NTFS ACLs + Low Integrity are containment targets | Seatbelt profile (subpath rules) |
| Network | block mode or strict netns proxy | Launch blocked; AppContainer + loopback proxy are containment targets | Seatbelt network deny + proxy |
| GPU | Optional HIP Remote artifacts | Unavailable while native launch is blocked | Optional HIP Remote artifacts |
| Inference | Local LLM via llama.cpp or vLLM | Unavailable while native launch is blocked | Same |
| Process | MXC Bubblewrap process backend with AXIS seccomp; native Landlock/seccomp retained | MXC ProcessContainer with AXIS-owned lifecycle cleanup | Seatbelt (sandbox-exec) |
| Filesystem | MXC Bubblewrap mounts or native Landlock LSM | MXC ProcessContainer path allowlists | Seatbelt profile (subpath rules) |
| Network | block mode, MXC cooperative proxy, or strict native netns proxy | MXC allow/block plus BaseContainer strict proxy through the installed AXIS WFP broker | Seatbelt network deny + proxy |
| GPU | Optional HIP Remote artifacts | Optional HIP Remote artifacts | Optional HIP Remote artifacts |
| Inference | Local LLM via llama.cpp or vLLM | Same | Same |

In proxy mode, allowed network requests go through a policy-evaluated proxy.
HIP Remote policies route GPU API calls to a worker process when the optional
Expand Down Expand Up @@ -71,6 +71,11 @@ Linux users may choose
`--with-cap-net-admin --prefix /usr/local/bin` instead, but the helper is the
narrower privileged path.

Windows release archives include the pinned MXC `wxc-exec.exe` beside
`axis.exe`. Windows `runtime.provider: auto` and `mxc` use ProcessContainer;
the incomplete legacy `axis_native` host-spawn path is disabled and never used
as a fallback.

The Linux default MXC process backend also needs the host `bubblewrap` runtime
and unprivileged user namespaces enabled. Those are host runtime prerequisites,
not AXIS privileged install steps.
Expand Down Expand Up @@ -148,14 +153,38 @@ fails closed when the selected provider cannot enforce the policy:
5. bubblewrap fallback — block-mode fallback when Landlock is unavailable and a
safe system `bwrap` can preserve the requested semantics

### Windows (Launch Blocked)

The native Windows backend fails closed before creating a user process. Its
current code does not apply a Job Object, AppContainer, restricted token, NTFS
ACL boundary, proxy boundary, or isolated environment to the initial process.
Those controls remain implementation targets, and no Windows native containment
or bypass-detection claim should be treated as proven until the complete launch
path and negative tests land.
### Windows (MXC ProcessContainer)

Windows process policies default to the packaged MXC `wxc-exec.exe` backend.
AXIS translates the shared policy into MXC JSON, clears the executor environment,
rejects secrets and unsupported surfaces before launch, and places the executor
in a kill-on-close Job Object for timeout and teardown cleanup. AXIS requires
MXC BaseContainer by default, enables its least-privilege token mode, and fails
closed rather than silently selecting another ProcessContainer tier.

The supported slice now includes filesystem read-only/read-write allowlists,
BaseContainer-default-deny normalization for non-overlapping deny rules, a
physical managed Windows profile, child-tree process/aggregate-memory/CPU limits,
default allow/block networking, and BaseContainer strict proxy routing through
the installed AXIS WFP broker. The broker installs an exact proxy permit plus
IPv4/IPv6 default-deny filters against the SID read from the suspended child;
direct TCP, UDP, DNS, and QUIC bypasses remain blocked. Nested deny rules and
GPU/AMD policies still reject instead of weakening the policy. Scoped SSH is
supported through BaseContainer strict proxy mode when every projected key has
the same literal host set and that set exactly matches port-22 network rules.
Managed inference, streaming, host-side provider credential injection, and
conservative token-budget reservation are supported through BaseContainer strict
proxy mode; token-budget exhaustion currently supports the exact `reject` action
only.

Interactive ConPTY is unsupported through BaseContainer on Windows build 26300
because its creation API rejects pseudoconsole startup handles with
`ERROR_INVALID_HANDLE`. AXIS does not force the older AppContainer/DACL tier to
obtain terminal support. AppContainer/DACL fallback remains disabled in the MXC
configuration, so an unavailable BaseContainer is a launch failure.
The pinned MXC project is an early preview and does not claim its profiles are
security boundaries; AXIS therefore treats unsupported or unavailable behavior
as a launch failure, not as permission to execute on the host.

## GPU Sandbox

Expand Down
35 changes: 33 additions & 2 deletions crates/axis-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -425,6 +425,7 @@ async fn main() -> Result<()> {

#[cfg(windows)]
{
let _ = use_system;
let install_script = include_str!("../../../e2e/agents/install_agents.ps1");
let script_path = std::env::temp_dir().join("axis-install-agents.ps1");
std::fs::write(&script_path, install_script)?;
Expand Down Expand Up @@ -785,11 +786,13 @@ async fn main() -> Result<()> {
} else {
None
};
let inference_endpoint = configured_standalone_inference_endpoint()?;

// Start an inline proxy if policy uses proxy mode.
let proxy_addr = match standalone_proxy_config_for_sandbox(
sandbox_id,
&policy,
inference_endpoint,
connect_attribution.clone(),
) {
Some(proxy_config) => {
Expand Down Expand Up @@ -1222,6 +1225,7 @@ fn proxy_bind_addr_for_sandbox(
fn standalone_proxy_config_for_sandbox(
id: axis_core::types::SandboxId,
policy: &axis_core::policy::Policy,
inference_endpoint: Option<std::net::SocketAddr>,
connect_attribution: Option<axis_core::connect_attribution::ConnectAttributionStore>,
) -> Option<axis_proxy::proxy::ProxyConfig> {
if !matches!(policy.network.mode, axis_core::policy::NetworkMode::Proxy) {
Expand All @@ -1233,13 +1237,26 @@ fn standalone_proxy_config_for_sandbox(
bind_addr: proxy_bind_addr_for_sandbox(id, 0, policy),
policy: policy.clone(),
enable_leak_detection: true,
inference_endpoint: None,
inference_endpoint,
connect_attribution,
enable_identity_diagnostics: false,
timing_tx: None,
})
}

fn configured_standalone_inference_endpoint() -> anyhow::Result<Option<std::net::SocketAddr>> {
let Some(value) = std::env::var_os("AXIS_INFERENCE_ENDPOINT") else {
return Ok(None);
};
let value = value
.into_string()
.map_err(|_| anyhow::anyhow!("AXIS_INFERENCE_ENDPOINT must be valid Unicode"))?;
value
.parse()
.map(Some)
.map_err(|error| anyhow::anyhow!("invalid AXIS_INFERENCE_ENDPOINT '{value}': {error}"))
}

fn collect_standalone_sandbox_env() -> Vec<(String, String)> {
collect_standalone_sandbox_env_from(std::env::vars())
}
Expand Down Expand Up @@ -1355,7 +1372,7 @@ mod tests {
for mode in [NetworkMode::Block, NetworkMode::Allow, NetworkMode::Proxy] {
let policy = test_policy(provider, mode.clone());
let bind_addr = proxy_bind_addr_for_sandbox(id, 0, &policy);
let proxy_config = standalone_proxy_config_for_sandbox(id, &policy, None);
let proxy_config = standalone_proxy_config_for_sandbox(id, &policy, None, None);

if matches!(mode, NetworkMode::Proxy) {
#[cfg(target_os = "linux")]
Expand Down Expand Up @@ -1396,13 +1413,27 @@ mod tests {
}
}

#[test]
fn standalone_proxy_keeps_host_inference_endpoint_out_of_policy_data() {
let id = SandboxId::new();
let policy = test_policy(RuntimeProvider::Mxc, NetworkMode::Proxy);
let endpoint = "127.0.0.1:8080".parse().unwrap();

let config =
standalone_proxy_config_for_sandbox(id, &policy, Some(endpoint), None).unwrap();

assert_eq!(config.inference_endpoint, Some(endpoint));
assert!(config.policy.inference.routes.is_empty());
}

#[test]
fn standalone_env_collection_omits_provider_secrets_and_proxy_vars() {
let env = collect_standalone_sandbox_env_from(vec![
("PATH".into(), "/bin".into()),
("ANTHROPIC_API_KEY".into(), "secret".into()),
("OPENAI_API_KEY".into(), "secret".into()),
("ANTHROPIC_BASE_URL".into(), "https://api.example".into()),
("AXIS_INFERENCE_ENDPOINT".into(), "127.0.0.1:8080".into()),
("All_Proxy".into(), "http://proxy-with-creds".into()),
("UNRELATED".into(), "value".into()),
]);
Expand Down
9 changes: 9 additions & 0 deletions crates/axis-core/src/audit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,15 @@ impl AuditLog {
format!("credential leak detected: {pattern}"),
));
}

pub fn inference_budget_denied(&self, sandbox_id: SandboxId, reason: &str) {
self.emit(&AuditEvent::new(
EventCategory::InferenceActivity,
Severity::Medium,
Some(sandbox_id),
format!("inference token budget denied request: {reason}"),
));
}
}

impl Default for AuditLog {
Expand Down
17 changes: 6 additions & 11 deletions crates/axis-core/src/backend_defaults.rs
Original file line number Diff line number Diff line change
Expand Up @@ -209,8 +209,8 @@ pub const BACKEND_DEFAULT_RECORDS: &[BackendDefaultRecord] = &[
id: BackendCapabilityMapId::MxcWindowsProcessContainer,
platform: BackendPlatform::Windows,
execution_class: BackendExecutionClass::Process,
status: BackendDefaultStatus::Candidate,
rationale: "MXC ProcessContainer is a Windows process candidate and must prove resource, lifecycle, startup, and policy behavior before becoming a current default.",
status: BackendDefaultStatus::CurrentDefault,
rationale: "MXC ProcessContainer is the Windows process default. AXIS rejects unsupported policy surfaces, sanitizes the executor boundary, and owns timeout and cleanup around the packaged MXC runtime.",
required_benchmark_metrics: PROCESS_METRICS,
required_security_evidence: SECURITY_EVIDENCE,
benchmark_gate: Some("AXIS_BENCH_MXC_WINDOWS_PROCESSCONTAINER=1"),
Expand Down Expand Up @@ -311,6 +311,10 @@ mod tests {
BackendPlatform::Macos,
BackendCapabilityMapId::AxisNativeMacosSeatbelt,
),
(
BackendPlatform::Windows,
BackendCapabilityMapId::MxcWindowsProcessContainer,
),
] {
let defaults = backend_default_records()
.iter()
Expand All @@ -331,15 +335,6 @@ mod tests {
"{platform:?} process default must match the declared backend decision"
);
}

assert!(
backend_default_records().iter().all(|record| {
record.platform != BackendPlatform::Windows
|| record.execution_class != BackendExecutionClass::Process
|| record.status != BackendDefaultStatus::CurrentDefault
}),
"Windows must not advertise a current process default before confinement is implemented"
);
}

#[test]
Expand Down
20 changes: 19 additions & 1 deletion crates/axis-core/src/backend_evidence.rs
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,24 @@ network:
access: read-write
binaries:
- path: "*/python*"
"#,
},
PolicyScenario {
name: "portable_isolated_no_children",
description: "portable isolated identity with an atomic one-process tree limit",
yaml: r#"
version: 1
name: evidence-portable-isolated-no-children
filesystem:
read_write:
- "{workspace}"
process:
identity: isolated
child_processes: deny
max_memory_mb: 0
cpu_rate_percent: 0
network:
mode: block
"#,
},
PolicyScenario {
Expand Down Expand Up @@ -331,7 +349,7 @@ mod tests {

assert_eq!(reports.len(), backend_default_records().len());
for report in reports {
assert_eq!(report.scenario_count, 4);
assert_eq!(report.scenario_count, POLICY_SCENARIOS.len());
assert_eq!(report.scenarios.len(), report.scenario_count);
assert!(
report.benchmark_gate.is_some(),
Expand Down
Loading
Loading