Skip to content

Commit a344728

Browse files
committed
fix(isolation)!: validate explicit fence projections
Require each compute driver to map its native evidence to individual outer-fence guarantees, and reject incomplete projections before a boundary becomes ready. Exercise the assembled remote confirmation path for invalid audit, property, generation, and digest evidence. BREAKING CHANGE: BoundaryConfig and SandboxRuntimeDescriptor use outer_fence projections rather than the earlier driver_fence representation. State written by earlier builds cannot be decoded; operators must stop and recreate affected sandboxes after upgrading. Signed-off-by: Drew Newberry <anewberry@nvidia.com>
1 parent 28f8c06 commit a344728

11 files changed

Lines changed: 407 additions & 60 deletions

File tree

‎architecture/sandbox.md‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,21 @@ controller loss fails closed. A digest commits those guarantees to the native
9191
driver evidence without teaching the shared contract about container networks,
9292
Kubernetes objects, VM devices, or accelerator resources.
9393

94+
Each driver makes that projection explicitly. Non-empty native evidence alone
95+
does not establish a guarantee:
96+
97+
| Driver | Native evidence | Guarantees projected by the driver |
98+
|---|---|---|
99+
| Docker | Pinned container ID, `network_mode=none`, and no unexpected network attachments | No workload route establishes default-deny, revocation, and controller-loss behavior; the attachment inspection establishes that no unmanaged route exists. |
100+
| Podman | Pinned container ID, `--network=none`, and no unexpected network attachments | The same container-network facts establish the same four guarantees. |
101+
| Kubernetes | NetworkPolicy UID and resource version, ingress and egress isolation, and zero workload egress rules | The persisted, selecting policy establishes default-deny and continued denial after revocation or controller loss; zero egress rules establish that no unmanaged route is permitted. |
102+
| VM | Generation and zero guest network devices | The absent NIC establishes all four guarantees; approved traffic uses the separate supervisor-owned channel. |
103+
104+
The shared contract checks that all four guarantees are present, that the
105+
projection names the admitted generation, and that its evidence digest matches
106+
the value passed to the workload-side runtime. It does not infer guarantees or
107+
interpret the native fields.
108+
94109
When the admitted main process exits, its status and retained terminal output
95110
remain available. The confirmed sandbox and supervisor-owned access plane continue
96111
to serve policy-authorized exec and loopback forwarding until explicit stop or

‎crates/openshell-driver-docker/src/isolation.rs‎

Lines changed: 43 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ use std::net::IpAddr;
1212
use std::path::PathBuf;
1313

1414
use openshell_isolation_interface::contract::{
15-
BackendError, OuterFenceGuarantees, ResolvedWorkloadIdentity,
15+
BackendError, OuterFenceGuarantee, OuterFenceGuarantees, ResolvedWorkloadIdentity,
1616
};
1717
use openshell_sandbox_backend::GPU_RESOURCE_CLAIM;
1818
use openshell_sandbox_backend::boundary_protocol::{
@@ -30,18 +30,31 @@ struct DockerOuterFenceEvidence<'a> {
3030

3131
impl DockerOuterFenceEvidence<'_> {
3232
fn project(&self, generation: &str) -> Result<OuterFenceGuarantees, BackendError> {
33-
if self.container_id.is_empty()
34-
|| self.network_mode != "none"
35-
|| !self.unexpected_networks.is_empty()
36-
{
33+
if self.container_id.is_empty() {
3734
return Err(BackendError::Descriptor(
3835
"Docker outer fence evidence is incomplete".to_string(),
3936
));
4037
}
38+
let mut established = Vec::new();
39+
if self.network_mode == "none" {
40+
// With no container network namespace attachment, workload egress
41+
// remains denied both after revocation and if the supervisor exits.
42+
established.extend([
43+
OuterFenceGuarantee::DefaultDenyEgress,
44+
OuterFenceGuarantee::RevocationVerified,
45+
OuterFenceGuarantee::ControllerLossFailsClosed,
46+
]);
47+
}
48+
if self.unexpected_networks.is_empty() {
49+
established.push(OuterFenceGuarantee::NoUnmanagedEgressPath);
50+
}
4151
let encoded = serde_json::to_vec(self).map_err(|error| {
4252
BackendError::Descriptor(format!("encode Docker outer fence evidence: {error}"))
4353
})?;
44-
OuterFenceGuarantees::confirmed(generation, &encoded)
54+
let projection =
55+
OuterFenceGuarantees::from_driver_evidence(generation, established, &encoded)?;
56+
projection.validate(generation)?;
57+
Ok(projection)
4558
}
4659
}
4760

@@ -130,6 +143,30 @@ impl DockerBoundarySpec {
130143
mod tests {
131144
use super::*;
132145

146+
#[test]
147+
fn outer_fence_projection_rejects_each_missing_native_fact() {
148+
let unexpected_networks = vec!["bridge".to_string()];
149+
for evidence in [
150+
DockerOuterFenceEvidence {
151+
container_id: "",
152+
network_mode: "none",
153+
unexpected_networks: &[],
154+
},
155+
DockerOuterFenceEvidence {
156+
container_id: "container",
157+
network_mode: "bridge",
158+
unexpected_networks: &[],
159+
},
160+
DockerOuterFenceEvidence {
161+
container_id: "container",
162+
network_mode: "none",
163+
unexpected_networks: &unexpected_networks,
164+
},
165+
] {
166+
assert!(evidence.project("generation-1").is_err());
167+
}
168+
}
169+
133170
#[test]
134171
fn provisioning_binds_container_and_image_claims() {
135172
let session_id = openshell_core::SandboxSessionId::new();

‎crates/openshell-driver-kubernetes/src/isolation.rs‎

Lines changed: 62 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ use k8s_openapi::apimachinery::pkg::apis::meta::v1::LabelSelector;
2121
use k8s_openapi::apimachinery::pkg::util::intstr::IntOrString;
2222
use kube::core::ObjectMeta;
2323
use openshell_isolation_interface::contract::{
24-
BackendError, OuterFenceGuarantees, ResolvedWorkloadIdentity,
24+
BackendError, OuterFenceGuarantee, OuterFenceGuarantees, ResolvedWorkloadIdentity,
2525
};
2626
use openshell_sandbox_backend::boundary_protocol::{
2727
BoundaryConfig, BoundaryListener, GatewayVerificationKey, SandboxRuntimeDescriptor,
@@ -40,20 +40,31 @@ struct KubernetesOuterFenceEvidence<'a> {
4040

4141
impl KubernetesOuterFenceEvidence<'_> {
4242
fn project(&self, generation: &str) -> Result<OuterFenceGuarantees, BackendError> {
43-
if self.network_policy_uid.is_empty()
44-
|| self.network_policy_resource_version.is_empty()
45-
|| !self.ingress_isolated
46-
|| !self.egress_isolated
47-
|| self.egress_rule_count != 0
48-
{
43+
if self.network_policy_uid.is_empty() || self.network_policy_resource_version.is_empty() {
4944
return Err(BackendError::Descriptor(
5045
"Kubernetes outer fence evidence is incomplete".to_string(),
5146
));
5247
}
48+
let mut established = Vec::new();
49+
if self.ingress_isolated && self.egress_isolated && self.egress_rule_count == 0 {
50+
// A persisted policy selecting both directions with no egress rule
51+
// continues to deny direct egress after revocation or controller loss.
52+
established.extend([
53+
OuterFenceGuarantee::DefaultDenyEgress,
54+
OuterFenceGuarantee::RevocationVerified,
55+
OuterFenceGuarantee::ControllerLossFailsClosed,
56+
]);
57+
}
58+
if self.egress_isolated && self.egress_rule_count == 0 {
59+
established.push(OuterFenceGuarantee::NoUnmanagedEgressPath);
60+
}
5361
let encoded = serde_json::to_vec(self).map_err(|error| {
5462
BackendError::Descriptor(format!("encode Kubernetes outer fence evidence: {error}"))
5563
})?;
56-
OuterFenceGuarantees::confirmed(generation, &encoded)
64+
let projection =
65+
OuterFenceGuarantees::from_driver_evidence(generation, established, &encoded)?;
66+
projection.validate(generation)?;
67+
Ok(projection)
5768
}
5869
}
5970

@@ -289,6 +300,49 @@ impl KubernetesSandboxRuntimeBoundarySpec {
289300
mod tests {
290301
use super::*;
291302

303+
#[test]
304+
fn outer_fence_projection_rejects_each_missing_native_fact() {
305+
for evidence in [
306+
KubernetesOuterFenceEvidence {
307+
network_policy_uid: "",
308+
network_policy_resource_version: "1",
309+
ingress_isolated: true,
310+
egress_isolated: true,
311+
egress_rule_count: 0,
312+
},
313+
KubernetesOuterFenceEvidence {
314+
network_policy_uid: "uid",
315+
network_policy_resource_version: "",
316+
ingress_isolated: true,
317+
egress_isolated: true,
318+
egress_rule_count: 0,
319+
},
320+
KubernetesOuterFenceEvidence {
321+
network_policy_uid: "uid",
322+
network_policy_resource_version: "1",
323+
ingress_isolated: false,
324+
egress_isolated: true,
325+
egress_rule_count: 0,
326+
},
327+
KubernetesOuterFenceEvidence {
328+
network_policy_uid: "uid",
329+
network_policy_resource_version: "1",
330+
ingress_isolated: true,
331+
egress_isolated: false,
332+
egress_rule_count: 0,
333+
},
334+
KubernetesOuterFenceEvidence {
335+
network_policy_uid: "uid",
336+
network_policy_resource_version: "1",
337+
ingress_isolated: true,
338+
egress_isolated: true,
339+
egress_rule_count: 1,
340+
},
341+
] {
342+
assert!(evidence.project("generation-1").is_err());
343+
}
344+
}
345+
292346
fn spec() -> KubernetesSandboxRuntimeBoundarySpec {
293347
KubernetesSandboxRuntimeBoundarySpec {
294348
boundary_id: "sandbox-1".to_string(),

‎crates/openshell-driver-podman/src/isolation.rs‎

Lines changed: 46 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@ use std::path::PathBuf;
1010

1111
use openshell_core::ComputeDriverError;
1212
use openshell_core::proto::compute::v1::DriverSandbox;
13-
use openshell_isolation_interface::contract::{OuterFenceGuarantees, ResolvedWorkloadIdentity};
13+
use openshell_isolation_interface::contract::{
14+
OuterFenceGuarantee, OuterFenceGuarantees, ResolvedWorkloadIdentity,
15+
};
1416
use openshell_sandbox_backend::boundary_protocol::{
1517
BoundaryConfig, BoundaryListener, GatewayVerificationKey, SandboxRuntimeDescriptor,
1618
SandboxTlsClientConfig, SandboxTlsServerConfig, SandboxTransport,
@@ -36,14 +38,28 @@ struct PodmanOuterFenceEvidence<'a> {
3638

3739
impl PodmanOuterFenceEvidence<'_> {
3840
fn project(&self, generation: &str) -> Result<OuterFenceGuarantees, ComputeDriverError> {
39-
if self.container_id.is_empty()
40-
|| self.network_mode != "none"
41-
|| !self.unexpected_networks.is_empty()
42-
{
41+
if self.container_id.is_empty() {
4342
return Err(invalid("Podman outer fence evidence is incomplete"));
4443
}
44+
let mut established = Vec::new();
45+
if self.network_mode == "none" {
46+
// With no container network namespace attachment, workload egress
47+
// remains denied both after revocation and if the supervisor exits.
48+
established.extend([
49+
OuterFenceGuarantee::DefaultDenyEgress,
50+
OuterFenceGuarantee::RevocationVerified,
51+
OuterFenceGuarantee::ControllerLossFailsClosed,
52+
]);
53+
}
54+
if self.unexpected_networks.is_empty() {
55+
established.push(OuterFenceGuarantee::NoUnmanagedEgressPath);
56+
}
4557
let encoded = serde_json::to_vec(self).map_err(invalid)?;
46-
OuterFenceGuarantees::confirmed(generation, &encoded).map_err(invalid)
58+
let projection =
59+
OuterFenceGuarantees::from_driver_evidence(generation, established, &encoded)
60+
.map_err(invalid)?;
61+
projection.validate(generation).map_err(invalid)?;
62+
Ok(projection)
4763
}
4864
}
4965

@@ -349,6 +365,30 @@ mod tests {
349365
SupervisorAuthBundle,
350366
};
351367

368+
#[test]
369+
fn outer_fence_projection_rejects_each_missing_native_fact() {
370+
let unexpected_networks = vec!["podman".to_string()];
371+
for evidence in [
372+
PodmanOuterFenceEvidence {
373+
container_id: "",
374+
network_mode: "none",
375+
unexpected_networks: &[],
376+
},
377+
PodmanOuterFenceEvidence {
378+
container_id: "container",
379+
network_mode: "bridge",
380+
unexpected_networks: &[],
381+
},
382+
PodmanOuterFenceEvidence {
383+
container_id: "container",
384+
network_mode: "none",
385+
unexpected_networks: &unexpected_networks,
386+
},
387+
] {
388+
assert!(evidence.project("generation-1").is_err());
389+
}
390+
}
391+
352392
fn authentication() -> SandboxLaunchAuthentication {
353393
SandboxLaunchAuthentication {
354394
supervisor: SupervisorAuthBundle {

‎crates/openshell-driver-vm/src/isolation/mod.rs‎

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
//! the common control and boundary behavior.
1010
1111
use openshell_isolation_interface::contract::{
12-
BackendError, OuterFenceGuarantees, ResolvedWorkloadIdentity,
12+
BackendError, OuterFenceGuarantee, OuterFenceGuarantees, ResolvedWorkloadIdentity,
1313
};
1414
use openshell_sandbox_backend::boundary_protocol::{
1515
BoundaryConfig, BoundaryListener, GatewayVerificationKey, SandboxRuntimeDescriptor,
@@ -26,15 +26,30 @@ struct VmOuterFenceEvidence<'a> {
2626

2727
impl VmOuterFenceEvidence<'_> {
2828
fn project(&self) -> Result<OuterFenceGuarantees, BackendError> {
29-
if self.generation.is_empty() || self.network_device_count != 0 {
29+
if self.generation.is_empty() {
3030
return Err(BackendError::Descriptor(
3131
"VM outer fence evidence is incomplete".to_string(),
3232
));
3333
}
34+
let established = (self.network_device_count == 0).then_some([
35+
// A guest with no NIC has no kernel network path. Closing the
36+
// supervisor-owned channel revokes access, and controller loss
37+
// cannot introduce a device.
38+
OuterFenceGuarantee::DefaultDenyEgress,
39+
OuterFenceGuarantee::NoUnmanagedEgressPath,
40+
OuterFenceGuarantee::RevocationVerified,
41+
OuterFenceGuarantee::ControllerLossFailsClosed,
42+
]);
3443
let encoded = serde_json::to_vec(self).map_err(|error| {
3544
BackendError::Descriptor(format!("encode VM outer fence evidence: {error}"))
3645
})?;
37-
OuterFenceGuarantees::confirmed(self.generation, &encoded)
46+
let projection = OuterFenceGuarantees::from_driver_evidence(
47+
self.generation,
48+
established.into_iter().flatten(),
49+
&encoded,
50+
)?;
51+
projection.validate(self.generation)?;
52+
Ok(projection)
3853
}
3954
}
4055

@@ -128,6 +143,26 @@ mod tests {
128143
generate_sandbox_tls_material,
129144
};
130145

146+
#[test]
147+
fn outer_fence_projection_rejects_each_missing_native_fact() {
148+
assert!(
149+
VmOuterFenceEvidence {
150+
generation: "",
151+
network_device_count: 0,
152+
}
153+
.project()
154+
.is_err()
155+
);
156+
assert!(
157+
VmOuterFenceEvidence {
158+
generation: "generation-1",
159+
network_device_count: 1,
160+
}
161+
.project()
162+
.is_err()
163+
);
164+
}
165+
131166
#[test]
132167
fn provisioning_binds_identical_resource_claims() {
133168
let session_id = openshell_core::SandboxSessionId::new();

‎crates/openshell-isolation-interface/src/contract.rs‎

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -417,9 +417,14 @@ pub struct OuterFenceGuarantees {
417417
}
418418

419419
impl OuterFenceGuarantees {
420-
/// Construct guarantees after the driver has validated its native evidence.
421-
pub fn confirmed(
420+
/// Bind the guarantees explicitly established by driver-owned evidence.
421+
///
422+
/// This constructor deliberately does not infer guarantees from the mere
423+
/// presence of evidence. The driver must inspect its native state and
424+
/// project each established guarantee before calling this function.
425+
pub fn from_driver_evidence(
422426
generation: impl Into<String>,
427+
established: impl IntoIterator<Item = OuterFenceGuarantee>,
423428
native_evidence: &[u8],
424429
) -> Result<Self, BackendError> {
425430
let generation = generation.into();
@@ -434,12 +439,7 @@ impl OuterFenceGuarantees {
434439
binding.extend_from_slice(native_evidence);
435440
Ok(Self {
436441
generation,
437-
established: BTreeSet::from([
438-
OuterFenceGuarantee::DefaultDenyEgress,
439-
OuterFenceGuarantee::NoUnmanagedEgressPath,
440-
OuterFenceGuarantee::RevocationVerified,
441-
OuterFenceGuarantee::ControllerLossFailsClosed,
442-
]),
442+
established: established.into_iter().collect(),
443443
evidence_digest: Sha256Digest::compute(&binding),
444444
})
445445
}

0 commit comments

Comments
 (0)