Skip to content

Commit fccbbc0

Browse files
committed
refactor(supervisor): add backend-neutral boundary primitives
Signed-off-by: Drew Newberry <385+drew@users.noreply.github.com>
1 parent 6cf58c4 commit fccbbc0

20 files changed

Lines changed: 3557 additions & 600 deletions

File tree

‎Cargo.lock‎

Lines changed: 4 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎crates/openshell-sandbox/src/lib.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -378,6 +378,7 @@ pub async fn run_sandbox(
378378
agent_proposals.clone(),
379379
workspace_rx.clone(),
380380
&upstream_proxy_args,
381+
None,
381382
)
382383
.await?,
383384
)

‎crates/openshell-supervisor-network/Cargo.toml‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,14 @@ rust-version.workspace = true
1212

1313
[dependencies]
1414
openshell-core = { path = "../openshell-core" }
15+
openshell-isolation = { path = "../openshell-isolation" }
1516
openshell-ocsf = { path = "../openshell-ocsf" }
1617
openshell-policy = { path = "../openshell-policy" }
1718
openshell-router = { path = "../openshell-router" }
1819
openshell-supervisor-middleware = { path = "../openshell-supervisor-middleware" }
1920

21+
async-trait = "0.1"
22+
2023
apollo-parser = { workspace = true }
2124
aws-sigv4 = { version = "1", features = ["sign-http", "http1"] }
2225
aws-credential-types = { version = "1", features = ["hardcoded-credentials"] }
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
//! The in-pod binary-identity resolver (RFC 0012 runtime contract).
5+
//!
6+
//! RFC 0012 delivers executable identity on every
7+
//! [`MediatedConnection`](openshell_isolation::contract::MediatedConnection):
8+
//! the backend resolves identity for the accepted connection before mediation.
9+
//! An unresolved identity denies that connection. This is the in-pod
10+
//! resolution mechanism — procfs, keyed by the workload-side TCP peer port —
11+
//! kept in this crate on purpose: the proxy that consumes identity is here, and
12+
//! so are procfs and the binary identity cache. Stronger backends may use a
13+
//! different resolution mechanism without changing the contract. The result
14+
//! type lives in the lower `openshell-isolation` crate (network -> isolation ->
15+
//! core, acyclic).
16+
//!
17+
//! The legacy listener still resolves identity in the proxy hot path. The RFC
18+
//! 0012 co-located source invokes this resolver before returning each accepted
19+
//! connection, so mediation consumes the bound identity result.
20+
21+
use std::sync::Arc;
22+
use std::sync::atomic::AtomicU32;
23+
24+
use openshell_isolation::contract::{BinaryIdentity, ResolveError, Sha256Digest};
25+
26+
/// In-pod binary-identity resolver: reads and hashes the executable resolved
27+
/// for an accepted connection from procfs. Resolution fails closed; it never
28+
/// fabricates identity fields.
29+
#[derive(Clone)]
30+
pub struct ProcfsIdentityResolver {
31+
/// The workload entrypoint PID, whose network namespace owns the peer
32+
/// sockets the proxy resolves. Published once the agent starts.
33+
pub entrypoint_pid: Arc<AtomicU32>,
34+
}
35+
36+
impl ProcfsIdentityResolver {
37+
/// Resolve the executable identity behind an accepted workload connection.
38+
pub fn resolve_connection(
39+
&self,
40+
workload_addr: std::net::SocketAddr,
41+
proxy_addr: std::net::SocketAddr,
42+
) -> Result<BinaryIdentity, ResolveError> {
43+
// procfs resolution is Linux-only; on other targets the supervisor has
44+
// no procfs to read, so resolution fails closed.
45+
#[cfg(target_os = "linux")]
46+
{
47+
self.resolve_via_procfs(workload_addr, proxy_addr)
48+
}
49+
#[cfg(not(target_os = "linux"))]
50+
{
51+
let _ = (workload_addr, proxy_addr);
52+
Err(ResolveError::Failed(
53+
"no procfs on this platform; identity resolution unavailable".to_string(),
54+
))
55+
}
56+
}
57+
}
58+
59+
#[cfg(target_os = "linux")]
60+
impl ProcfsIdentityResolver {
61+
fn resolve_via_procfs(
62+
&self,
63+
workload_addr: std::net::SocketAddr,
64+
proxy_addr: std::net::SocketAddr,
65+
) -> Result<BinaryIdentity, ResolveError> {
66+
use std::sync::atomic::Ordering;
67+
68+
let entrypoint_pid = self.entrypoint_pid.load(Ordering::Acquire);
69+
if entrypoint_pid == 0 {
70+
// No workload yet: nothing to attribute the connection to. Fail
71+
// closed so a binary-scoped rule cannot match an unattributed peer.
72+
return Err(ResolveError::NotFound);
73+
}
74+
75+
let connection = crate::procfs::WorkloadProxyTcpConnection::new(workload_addr, proxy_addr);
76+
let owners = crate::procfs::resolve_tcp_peer_socket_owners(entrypoint_pid, connection)
77+
.map_err(|_| ResolveError::NotFound)?;
78+
let mut identities = Vec::with_capacity(owners.owners.len());
79+
for owner in owners.owners {
80+
identities.push(Self::resolve_owner(owner.pid, entrypoint_pid)?);
81+
}
82+
let Some(identity) = identities.first().cloned() else {
83+
return Err(ResolveError::NotFound);
84+
};
85+
if identities.iter().skip(1).any(|candidate| {
86+
candidate.binary_path != identity.binary_path
87+
|| candidate.binary_digest != identity.binary_digest
88+
|| candidate.ancestors != identity.ancestors
89+
|| candidate.cmdline_paths != identity.cmdline_paths
90+
}) {
91+
return Err(ResolveError::Failed(
92+
"shared socket owners have different policy identities".to_string(),
93+
));
94+
}
95+
Ok(identity)
96+
}
97+
98+
fn resolve_owner(owner_pid: u32, entrypoint_pid: u32) -> Result<BinaryIdentity, ResolveError> {
99+
let binary_path = crate::procfs::binary_path(owner_pid.cast_signed())
100+
.map_err(|error| ResolveError::Failed(error.to_string()))?;
101+
102+
// Hash the live `/proc/<pid>/exe` object, not the reopened resolved
103+
// path: opening the magic symlink pins the inode the process is actually
104+
// executing, so a post-resolution swap of the path cannot launder the
105+
// hash. A missing digest is `None`, never an empty string, and an
106+
// unhashable binary fails closed rather than asserting an identity the
107+
// resolver could not verify.
108+
let exe = std::path::PathBuf::from(format!("/proc/{owner_pid}/exe"));
109+
let binary_digest = match crate::procfs::file_sha256(&exe) {
110+
Ok(digest) => Some(digest.parse::<Sha256Digest>()?),
111+
Err(_) => {
112+
return Err(ResolveError::Failed(
113+
"could not hash resolved executable; refusing to assert identity".to_string(),
114+
));
115+
}
116+
};
117+
118+
let ancestors = crate::procfs::collect_ancestor_binaries(owner_pid, entrypoint_pid);
119+
let mut exclude = ancestors.clone();
120+
exclude.push(binary_path.clone());
121+
let cmdline_paths =
122+
crate::procfs::collect_cmdline_paths(owner_pid, entrypoint_pid, &exclude);
123+
124+
Ok(BinaryIdentity {
125+
binary_path,
126+
binary_digest,
127+
ancestors,
128+
cmdline_paths,
129+
})
130+
}
131+
}
132+
133+
#[cfg(test)]
134+
mod tests {
135+
use super::*;
136+
137+
/// Stands in for the mediation service: a binary-scoped rule can only be
138+
/// authorized by a resolved identity carrying the fields it requires.
139+
fn admits_binary_rule(result: Result<BinaryIdentity, ResolveError>) -> bool {
140+
matches!(result, Ok(identity) if identity.binary_digest.is_some())
141+
}
142+
143+
#[test]
144+
fn fails_closed_before_the_workload_starts() {
145+
// entrypoint_pid == 0 means no agent yet; identity must fail closed so a
146+
// binary-scoped rule cannot be satisfied by an unattributed connection.
147+
let resolver = ProcfsIdentityResolver {
148+
entrypoint_pid: Arc::new(AtomicU32::new(0)),
149+
};
150+
assert!(!admits_binary_rule(resolver.resolve_connection(
151+
"127.0.0.1:12345".parse().unwrap(),
152+
"127.0.0.1:3128".parse().unwrap(),
153+
)));
154+
}
155+
156+
#[test]
157+
fn unknown_peer_fails_closed() {
158+
// A peer port no live workload connection owns must resolve to an error,
159+
// never a fabricated identity.
160+
let resolver = ProcfsIdentityResolver {
161+
entrypoint_pid: Arc::new(AtomicU32::new(u32::MAX - 1)),
162+
};
163+
assert!(!admits_binary_rule(resolver.resolve_connection(
164+
"127.0.0.1:1".parse().unwrap(),
165+
"127.0.0.1:3128".parse().unwrap(),
166+
)));
167+
}
168+
}

‎crates/openshell-supervisor-network/src/l7/tls.rs‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@ use std::io::BufReader;
1717
use std::path::{Path, PathBuf};
1818
use std::sync::{Arc, Mutex};
1919
use tokio::io::{AsyncRead, AsyncWrite};
20-
use tokio::net::TcpStream;
2120
use tokio_rustls::{TlsAcceptor, TlsConnector};
2221

2322
const MAX_CACHED_CERTS: usize = 256;
@@ -170,11 +169,14 @@ impl ProxyTlsState {
170169
/// Accept TLS from a sandbox client, presenting a dynamic cert for the hostname.
171170
///
172171
/// Returns a TLS stream that can be used for plaintext HTTP inspection.
173-
pub async fn tls_terminate_client(
174-
client: TcpStream,
172+
pub async fn tls_terminate_client<S>(
173+
client: S,
175174
tls_state: &ProxyTlsState,
176175
hostname: &str,
177-
) -> Result<impl AsyncRead + AsyncWrite + Unpin + Send> {
176+
) -> Result<impl AsyncRead + AsyncWrite + Unpin + Send>
177+
where
178+
S: AsyncRead + AsyncWrite + Unpin + Send,
179+
{
178180
let acceptor = tls_state.acceptor_for(hostname)?;
179181
let tls_stream = acceptor.accept(client).await.into_diagnostic()?;
180182
Ok(tls_stream)

‎crates/openshell-supervisor-network/src/lib.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
//! aggregate them.
1010
1111
pub mod identity;
12+
pub mod identity_source;
1213
pub mod inference_routes;
1314
pub mod l7;
1415
pub mod opa;

0 commit comments

Comments
 (0)