@@ -67,6 +67,103 @@ pub struct OciPolicy {
6767 /// `builder.user(...)`; core decides whether a user namespace is actually
6868 /// needed (it self-skips when the identity already matches the runtime).
6969 pub run_user : Option < RunAs > ,
70+
71+ // Networking + HTTP ACL, sourced from `io.sandlock.*` OCI annotations (the
72+ // runtime spec has no native field for these). Multi-value lists use `;` as
73+ // the separator. Enforcement is the same seccomp-notif machinery the
74+ // supervisor already runs for filesystem/proc interception.
75+ /// Outbound allowlist (`io.sandlock.network.allow`).
76+ #[ serde( default ) ]
77+ pub net_allow : Vec < String > ,
78+ /// Outbound denylist (`io.sandlock.network.deny`).
79+ #[ serde( default ) ]
80+ pub net_deny : Vec < String > ,
81+ /// Bind allowlist port specs (`io.sandlock.network.allow_bind`).
82+ #[ serde( default ) ]
83+ pub net_allow_bind : Vec < String > ,
84+ /// Bind denylist port specs (`io.sandlock.network.deny_bind`).
85+ #[ serde( default ) ]
86+ pub net_deny_bind : Vec < String > ,
87+ /// Transparent port remapping for multi-sandbox isolation
88+ /// (`io.sandlock.network.port_remap`). Defaults to on so container
89+ /// servers can bind; the annotation is an explicit opt-out.
90+ #[ serde( default = "default_port_remap" ) ]
91+ pub port_remap : bool ,
92+ /// HTTP ACL allow rules (`io.sandlock.http.allow`).
93+ #[ serde( default ) ]
94+ pub http_allow : Vec < String > ,
95+ /// HTTP ACL deny rules (`io.sandlock.http.deny`).
96+ #[ serde( default ) ]
97+ pub http_deny : Vec < String > ,
98+ /// TCP ports to intercept for the HTTP ACL (`io.sandlock.http.ports`).
99+ #[ serde( default ) ]
100+ pub http_ports : Vec < u16 > ,
101+ /// Host-side PEM CA cert for HTTPS MITM (`io.sandlock.config.http_ca`), bundle-relative.
102+ #[ serde( default ) ]
103+ pub http_ca : Option < PathBuf > ,
104+ /// Host-side PEM CA key for HTTPS MITM (`io.sandlock.config.http_key`), bundle-relative.
105+ #[ serde( default ) ]
106+ pub http_key : Option < PathBuf > ,
107+ /// Sandbox-virtual trust bundles to splice the MITM CA into (`io.sandlock.config.http_inject_ca`).
108+ #[ serde( default ) ]
109+ pub http_inject_ca : Vec < PathBuf > ,
110+ /// Host-side path to write the active MITM CA cert (`io.sandlock.config.http_ca_out`), bundle-relative.
111+ #[ serde( default ) ]
112+ pub http_ca_out : Option < PathBuf > ,
113+ }
114+
115+ /// Annotation key prefix for sandlock-specific OCI configuration.
116+ const SANDLOCK_ANN_PREFIX : & str = "io.sandlock." ;
117+
118+ /// Look up a `io.sandlock.<key>` annotation value.
119+ fn annotation < ' a > (
120+ annotations : Option < & ' a HashMap < String , String > > ,
121+ key : & str ,
122+ ) -> Option < & ' a str > {
123+ annotations
124+ . and_then ( |m| m. get ( & format ! ( "{SANDLOCK_ANN_PREFIX}{key}" ) ) )
125+ . map ( String :: as_str)
126+ }
127+
128+ /// Split a `;`-separated annotation list into trimmed, non-empty items.
129+ ///
130+ /// `;` is the separator (not `,`) because `,` is significant *inside* net specs
131+ /// (`github.com:22,443` is two ports on one host) and HTTP rules contain spaces,
132+ /// so `;` is the only delimiter unambiguous across every grammar.
133+ fn annotation_list ( value : Option < & str > ) -> Vec < String > {
134+ value
135+ . map ( |s| {
136+ s. split ( ';' )
137+ . map ( str:: trim)
138+ . filter ( |item| !item. is_empty ( ) )
139+ . map ( String :: from)
140+ . collect ( )
141+ } )
142+ . unwrap_or_default ( )
143+ }
144+
145+ /// Serde default for [`OciPolicy::port_remap`]: on, matching `from_spec`.
146+ fn default_port_remap ( ) -> bool {
147+ true
148+ }
149+
150+ /// Parse a boolean annotation: `true`/`1`/`yes`/`on` (case-insensitive) are true.
151+ fn annotation_bool ( value : Option < & str > ) -> bool {
152+ matches ! (
153+ value. map( |s| s. trim( ) . to_ascii_lowercase( ) ) . as_deref( ) ,
154+ Some ( "true" | "1" | "yes" | "on" )
155+ )
156+ }
157+
158+ /// Resolve a host-side annotation path: absolute paths as-is, relative ones
159+ /// against the OCI bundle directory so CA material can travel with the bundle.
160+ fn resolve_host_path ( bundle : & Path , value : & str ) -> PathBuf {
161+ let p = PathBuf :: from ( value) ;
162+ if p. is_absolute ( ) {
163+ p
164+ } else {
165+ bundle. join ( p)
166+ }
70167}
71168
72169impl OciPolicy {
@@ -192,6 +289,49 @@ impl OciPolicy {
192289 RunAs { uid : u. uid ( ) , gid : u. gid ( ) }
193290 } ) ;
194291
292+ // Networking + HTTP ACL from `io.sandlock.*` annotations. The OCI runtime
293+ // spec carries no native outbound allow/deny or transparent-proxy config,
294+ // so it travels in annotations; `;` separates multi-value lists.
295+ let ann = spec. annotations ( ) . as_ref ( ) ;
296+ let net_allow = annotation_list ( annotation ( ann, "network.allow" ) ) ;
297+ let net_deny = annotation_list ( annotation ( ann, "network.deny" ) ) ;
298+ let net_allow_bind = annotation_list ( annotation ( ann, "network.allow_bind" ) ) ;
299+ let net_deny_bind = annotation_list ( annotation ( ann, "network.deny_bind" ) ) ;
300+ // Container workloads legitimately run servers (nginx, etc.). sandlock
301+ // gates bind() by default-deny (Landlock BIND_TCP), so without port
302+ // remapping an in-container server fails bind() with EACCES and the
303+ // container exits, which in turn hangs any readiness/verification exec
304+ // that waits on it. Remapped binds are emulated on-behalf and succeed,
305+ // using the requested port when free and a fresh host port only on
306+ // conflict, so co-located sandboxes never collide on a host port.
307+ // Default on; `io.sandlock.network.port_remap: "false"` opts out.
308+ let port_remap =
309+ annotation ( ann, "network.port_remap" ) . is_none_or ( |v| annotation_bool ( Some ( v) ) ) ;
310+ let http_allow = annotation_list ( annotation ( ann, "http.allow" ) ) ;
311+ let http_deny = annotation_list ( annotation ( ann, "http.deny" ) ) ;
312+ let http_ports = annotation_list ( annotation ( ann, "http.ports" ) )
313+ . iter ( )
314+ . map ( |s| {
315+ s. parse :: < u16 > ( ) . map_err ( |_| {
316+ anyhow:: anyhow!(
317+ "invalid io.sandlock.http.ports entry {:?}: expected a port number" ,
318+ s
319+ )
320+ } )
321+ } )
322+ . collect :: < Result < Vec < u16 > > > ( ) ?;
323+ // CA cert/key/output are host-side files the supervisor reads or writes,
324+ // so resolve them relative to the bundle. The inject targets are
325+ // sandbox-virtual paths (e.g. /etc/ssl/certs/ca-certificates.crt) that
326+ // core resolves through the chroot/mounts, so pass them through as-is.
327+ let http_ca = annotation ( ann, "config.http_ca" ) . map ( |p| resolve_host_path ( bundle, p) ) ;
328+ let http_key = annotation ( ann, "config.http_key" ) . map ( |p| resolve_host_path ( bundle, p) ) ;
329+ let http_ca_out = annotation ( ann, "config.http_ca_out" ) . map ( |p| resolve_host_path ( bundle, p) ) ;
330+ let http_inject_ca = annotation_list ( annotation ( ann, "config.http_inject_ca" ) )
331+ . into_iter ( )
332+ . map ( PathBuf :: from)
333+ . collect ( ) ;
334+
195335 Ok ( OciPolicy {
196336 rootfs,
197337 fs_read,
@@ -206,6 +346,18 @@ impl OciPolicy {
206346 cpu_cores,
207347 scratch_dirs,
208348 run_user,
349+ net_allow,
350+ net_deny,
351+ net_allow_bind,
352+ net_deny_bind,
353+ port_remap,
354+ http_allow,
355+ http_deny,
356+ http_ports,
357+ http_ca,
358+ http_key,
359+ http_inject_ca,
360+ http_ca_out,
209361 } )
210362 }
211363
@@ -268,14 +420,45 @@ impl OciPolicy {
268420 builder = builder. user ( ru. uid , ru. gid ) ;
269421 }
270422
271- // Container workloads legitimately run servers (nginx, etc.). sandlock
272- // gates bind() by default-deny (Landlock BIND_TCP), so without this an
273- // in-container server fails bind() with EACCES and the container exits,
274- // which in turn hangs any readiness/verification exec that waits on it.
275- // Enable port remapping: binds are emulated on-behalf and succeed,
276- // using the requested port when free and a fresh host port only on
277- // conflict, so co-located sandboxes never collide on a host port.
278- builder = builder. port_remap ( true ) ;
423+ // Networking + HTTP ACL. Each is a no-op when its annotation was absent.
424+ // The network seccomp handlers register automatically once the policy
425+ // carries destination/bind rules, so no OCI-side enforcement is needed.
426+ for spec in & self . net_allow {
427+ builder = builder. net_allow ( spec. clone ( ) ) ;
428+ }
429+ for spec in & self . net_deny {
430+ builder = builder. net_deny ( spec. clone ( ) ) ;
431+ }
432+ for spec in & self . net_allow_bind {
433+ builder = builder. net_allow_bind ( spec. clone ( ) ) ;
434+ }
435+ for spec in & self . net_deny_bind {
436+ builder = builder. net_deny_bind ( spec. clone ( ) ) ;
437+ }
438+ if self . port_remap {
439+ builder = builder. port_remap ( true ) ;
440+ }
441+ for rule in & self . http_allow {
442+ builder = builder. http_allow ( rule) ;
443+ }
444+ for rule in & self . http_deny {
445+ builder = builder. http_deny ( rule) ;
446+ }
447+ for port in & self . http_ports {
448+ builder = builder. http_port ( * port) ;
449+ }
450+ if let Some ( ref ca) = self . http_ca {
451+ builder = builder. http_ca ( ca) ;
452+ }
453+ if let Some ( ref key) = self . http_key {
454+ builder = builder. http_key ( key) ;
455+ }
456+ for path in & self . http_inject_ca {
457+ builder = builder. http_inject_ca ( path) ;
458+ }
459+ if let Some ( ref out) = self . http_ca_out {
460+ builder = builder. http_ca_out ( out) ;
461+ }
279462
280463 builder. build_unchecked ( ) . map_err ( Into :: into)
281464 }
@@ -481,6 +664,119 @@ mod tests {
481664 . unwrap ( )
482665 }
483666
667+ fn spec_with_annotations ( pairs : & [ ( & str , & str ) ] ) -> Spec {
668+ let mut annotations = HashMap :: new ( ) ;
669+ for ( k, v) in pairs {
670+ annotations. insert ( k. to_string ( ) , v. to_string ( ) ) ;
671+ }
672+ SpecBuilder :: default ( )
673+ . version ( "1.0.2" )
674+ . root ( RootBuilder :: default ( ) . path ( "rootfs" ) . readonly ( false ) . build ( ) . unwrap ( ) )
675+ . annotations ( annotations)
676+ . build ( )
677+ . unwrap ( )
678+ }
679+
680+ #[ test]
681+ fn from_spec_parses_network_annotations ( ) {
682+ let dir = tempdir ( ) . unwrap ( ) ;
683+ let bundle = dir. path ( ) ;
684+ fs:: create_dir_all ( bundle. join ( "rootfs" ) ) . unwrap ( ) ;
685+
686+ let spec = spec_with_annotations ( & [
687+ ( "io.sandlock.network.allow" , "api.openai.com:443; github.com:22,443" ) ,
688+ ( "io.sandlock.network.deny" , "10.0.0.0/8" ) ,
689+ ( "io.sandlock.network.allow_bind" , "8080,9000-9005" ) ,
690+ ( "io.sandlock.network.deny_bind" , "22" ) ,
691+ ( "io.sandlock.network.port_remap" , "true" ) ,
692+ ] ) ;
693+ let policy = OciPolicy :: from_spec ( & spec, bundle, "test" ) . unwrap ( ) ;
694+
695+ // `;` separates rules; `,` stays inside a single rule.
696+ assert_eq ! ( policy. net_allow, vec![ "api.openai.com:443" , "github.com:22,443" ] ) ;
697+ assert_eq ! ( policy. net_deny, vec![ "10.0.0.0/8" ] ) ;
698+ assert_eq ! ( policy. net_allow_bind, vec![ "8080,9000-9005" ] ) ;
699+ assert_eq ! ( policy. net_deny_bind, vec![ "22" ] ) ;
700+ assert ! ( policy. port_remap) ;
701+ }
702+
703+ #[ test]
704+ fn from_spec_parses_http_annotations_and_resolves_paths ( ) {
705+ let dir = tempdir ( ) . unwrap ( ) ;
706+ let bundle = dir. path ( ) ;
707+ fs:: create_dir_all ( bundle. join ( "rootfs" ) ) . unwrap ( ) ;
708+
709+ let spec = spec_with_annotations ( & [
710+ ( "io.sandlock.http.allow" , "GET api.internal/v1/*; POST api.internal/submit" ) ,
711+ ( "io.sandlock.http.deny" , "* */admin/*" ) ,
712+ ( "io.sandlock.http.ports" , "80;8080" ) ,
713+ ( "io.sandlock.config.http_ca" , "ca.pem" ) ,
714+ ( "io.sandlock.config.http_key" , "/abs/key.pem" ) ,
715+ ( "io.sandlock.config.http_inject_ca" , "/etc/ssl/certs/ca-certificates.crt" ) ,
716+ ] ) ;
717+ let policy = OciPolicy :: from_spec ( & spec, bundle, "test" ) . unwrap ( ) ;
718+
719+ assert_eq ! ( policy. http_allow, vec![ "GET api.internal/v1/*" , "POST api.internal/submit" ] ) ;
720+ assert_eq ! ( policy. http_deny, vec![ "* */admin/*" ] ) ;
721+ assert_eq ! ( policy. http_ports, vec![ 80 , 8080 ] ) ;
722+ // Relative host path resolves against the bundle; absolute stays as-is.
723+ assert_eq ! ( policy. http_ca, Some ( bundle. join( "ca.pem" ) ) ) ;
724+ assert_eq ! ( policy. http_key, Some ( PathBuf :: from( "/abs/key.pem" ) ) ) ;
725+ // Inject targets are sandbox-virtual, passed through unchanged.
726+ assert_eq ! ( policy. http_inject_ca, vec![ PathBuf :: from( "/etc/ssl/certs/ca-certificates.crt" ) ] ) ;
727+ }
728+
729+ #[ test]
730+ fn from_spec_rejects_malformed_http_port ( ) {
731+ let dir = tempdir ( ) . unwrap ( ) ;
732+ let bundle = dir. path ( ) ;
733+ fs:: create_dir_all ( bundle. join ( "rootfs" ) ) . unwrap ( ) ;
734+ let spec = spec_with_annotations ( & [ ( "io.sandlock.http.ports" , "not-a-port" ) ] ) ;
735+ assert ! ( OciPolicy :: from_spec( & spec, bundle, "test" ) . is_err( ) ) ;
736+ }
737+
738+ #[ test]
739+ fn from_spec_without_network_annotations_is_empty ( ) {
740+ let dir = tempdir ( ) . unwrap ( ) ;
741+ let bundle = dir. path ( ) ;
742+ fs:: create_dir_all ( bundle. join ( "rootfs" ) ) . unwrap ( ) ;
743+ let policy = OciPolicy :: from_spec ( & minimal_spec ( ) , bundle, "test" ) . unwrap ( ) ;
744+ assert ! ( policy. net_allow. is_empty( ) ) ;
745+ assert ! ( policy. net_deny. is_empty( ) ) ;
746+ // Port remapping stays on by default so container servers can bind.
747+ assert ! ( policy. port_remap) ;
748+ assert ! ( policy. http_ports. is_empty( ) ) ;
749+ assert ! ( policy. http_ca. is_none( ) ) ;
750+ }
751+
752+ #[ test]
753+ fn port_remap_annotation_opts_out ( ) {
754+ let dir = tempdir ( ) . unwrap ( ) ;
755+ let bundle = dir. path ( ) ;
756+ fs:: create_dir_all ( bundle. join ( "rootfs" ) ) . unwrap ( ) ;
757+ let spec = spec_with_annotations ( & [ ( "io.sandlock.network.port_remap" , "false" ) ] ) ;
758+ let policy = OciPolicy :: from_spec ( & spec, bundle, "test" ) . unwrap ( ) ;
759+ assert ! ( !policy. port_remap) ;
760+ let sandbox = policy. to_sandbox ( ) . unwrap ( ) ;
761+ assert ! ( !sandbox. port_remap) ;
762+ }
763+
764+ #[ test]
765+ fn to_sandbox_wires_network_policy ( ) {
766+ let dir = tempdir ( ) . unwrap ( ) ;
767+ let bundle = dir. path ( ) ;
768+ fs:: create_dir_all ( bundle. join ( "rootfs" ) ) . unwrap ( ) ;
769+ let spec = spec_with_annotations ( & [
770+ ( "io.sandlock.network.allow" , "api.openai.com:443" ) ,
771+ ( "io.sandlock.network.port_remap" , "true" ) ,
772+ ] ) ;
773+ let policy = OciPolicy :: from_spec ( & spec, bundle, "test" ) . unwrap ( ) ;
774+ let sandbox = policy. to_sandbox ( ) . unwrap ( ) ;
775+ // The builder parsed the allow spec into a rule, and the bool passed through.
776+ assert ! ( !sandbox. net_allow. is_empty( ) ) ;
777+ assert ! ( sandbox. port_remap) ;
778+ }
779+
484780 #[ test]
485781 fn from_spec_parses_rootfs_and_cwd ( ) {
486782 let dir = tempdir ( ) . unwrap ( ) ;
0 commit comments