Skip to content

Commit 0aa1927

Browse files
committed
feat(driver-vm): add configurable sandbox UID/GID and update docs/examples
Phase 4 of the numeric-UID plan: replace hardcoded SANDBOX_UID (10001) in VM rootfs preparation with configurable sandbox_uid/sandbox_gid fields. Changes: - Add sandbox_uid/sandbox_gid to VmDriverConfig with serde derives - Pass resolved UID/GID through prepare_sandbox_rootfs_from_image_root to ensure_sandbox_guest_user which writes /etc/passwd/group/gshadow - Update BYOC Dockerfile: remove groupadd/useradd, document runtime UID injection and the ability to skip baked-in sandbox user - Update gateway-config.mdx: document sandbox_uid/sandbox_gid for both Kubernetes (with OpenShift SCC autodetection) and VM drivers - Update sandbox-compute-drivers.mdx: add Sandbox User Identity section explaining numeric UID support across all compute drivers - Update rootfs tests to use non-default UIDs, verify config passthrough Signed-off-by: Seth Jennings <sjenning@redhat.com>
1 parent cc4deb7 commit 0aa1927

6 files changed

Lines changed: 106 additions & 23 deletions

File tree

crates/openshell-driver-vm/src/driver.rs

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -207,7 +207,7 @@ enum GuestImagePayloadSource {
207207
LocalDocker { rootfs_archive: PathBuf },
208208
}
209209

210-
#[derive(Debug, Clone)]
210+
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
211211
pub struct VmDriverConfig {
212212
pub openshell_endpoint: String,
213213
pub state_dir: PathBuf,
@@ -225,8 +225,19 @@ pub struct VmDriverConfig {
225225
pub gpu_enabled: bool,
226226
pub gpu_mem_mib: u32,
227227
pub gpu_vcpus: u8,
228+
/// Resolved sandbox UID for rootfs `/etc/passwd` entry.
229+
/// When empty, defaults to 10001 (the legacy hardcoded value).
230+
#[serde(default, skip_serializing_if = "Option::is_none")]
231+
pub sandbox_uid: Option<u32>,
232+
/// Resolved sandbox GID for rootfs `/etc/passwd` and `/etc/group` entries.
233+
/// When empty, defaults to the resolved UID.
234+
#[serde(default, skip_serializing_if = "Option::is_none")]
235+
pub sandbox_gid: Option<u32>,
228236
}
229237

238+
/// Default sandbox UID used by the VM driver when no config value is set.
239+
pub const DEFAULT_SANDBOX_UID: u32 = 10001;
240+
230241
impl Default for VmDriverConfig {
231242
fn default() -> Self {
232243
Self {
@@ -246,11 +257,23 @@ impl Default for VmDriverConfig {
246257
gpu_enabled: false,
247258
gpu_mem_mib: 8192,
248259
gpu_vcpus: 4,
260+
sandbox_uid: None,
261+
sandbox_gid: None,
249262
}
250263
}
251264
}
252265

253266
impl VmDriverConfig {
267+
/// Resolve the sandbox UID, falling back to DEFAULT_SANDBOX_UID.
268+
pub fn resolve_sandbox_uid(&self) -> u32 {
269+
self.sandbox_uid.unwrap_or(DEFAULT_SANDBOX_UID)
270+
}
271+
272+
/// Resolve the sandbox GID, falling back to the resolved UID.
273+
pub fn resolve_sandbox_gid(&self, resolved_uid: u32) -> u32 {
274+
self.sandbox_gid.unwrap_or(resolved_uid)
275+
}
276+
254277
fn requires_tls_materials(&self) -> bool {
255278
self.openshell_endpoint.starts_with("https://")
256279
}
@@ -2545,21 +2568,28 @@ impl VmDriver {
25452568
let image_identity_owned = image_identity.to_string();
25462569
let exported_rootfs_for_build = exported_rootfs.clone();
25472570
let prepared_rootfs_for_build = prepared_rootfs.clone();
2571+
let sandbox_uid = self.config.resolve_sandbox_uid();
2572+
let sandbox_gid = self.config.resolve_sandbox_gid(sandbox_uid);
25482573
self.publish_vm_progress(
25492574
sandbox_id,
25502575
"PreparingRootfs",
2551-
format!("Preparing VM rootfs for local image \"{image_ref}\""),
2576+
format!(
2577+
"Preparing VM rootfs for local image \"{image_ref}\" (sandbox uid={sandbox_uid})"
2578+
),
25522579
HashMap::from([
25532580
("image_ref".to_string(), image_ref.to_string()),
25542581
("image_source".to_string(), "local_docker".to_string()),
25552582
("image_identity".to_string(), image_identity.to_string()),
2583+
("sandbox_uid".to_string(), sandbox_uid.to_string()),
25562584
]),
25572585
);
25582586
let prepare_result = tokio::task::spawn_blocking(move || {
25592587
extract_rootfs_archive_to(&exported_rootfs_for_build, &prepared_rootfs_for_build)?;
25602588
prepare_sandbox_rootfs_from_image_root(
25612589
&prepared_rootfs_for_build,
25622590
&image_identity_owned,
2591+
sandbox_uid,
2592+
sandbox_gid,
25632593
)
25642594
.map_err(|err| {
25652595
format!("vm sandbox image '{image_ref_owned}' is not base-compatible: {err}")
@@ -2678,20 +2708,25 @@ impl VmDriver {
26782708
let image_ref_owned = image_ref.to_string();
26792709
let image_identity_owned = image_identity.to_string();
26802710
let prepared_rootfs_for_build = prepared_rootfs.clone();
2711+
let sandbox_uid = self.config.resolve_sandbox_uid();
2712+
let sandbox_gid = self.config.resolve_sandbox_gid(sandbox_uid);
26812713
self.publish_vm_progress(
26822714
sandbox_id,
26832715
"PreparingRootfs",
2684-
format!("Preparing VM rootfs for image \"{image_ref}\""),
2716+
format!("Preparing VM rootfs for image \"{image_ref}\" (sandbox uid={sandbox_uid})"),
26852717
HashMap::from([
26862718
("image_ref".to_string(), image_ref.to_string()),
26872719
("image_source".to_string(), "registry".to_string()),
26882720
("image_identity".to_string(), image_identity.to_string()),
2721+
("sandbox_uid".to_string(), sandbox_uid.to_string()),
26892722
]),
26902723
);
26912724
let prepare_result = tokio::task::spawn_blocking(move || {
26922725
prepare_sandbox_rootfs_from_image_root(
26932726
&prepared_rootfs_for_build,
26942727
&image_identity_owned,
2728+
sandbox_uid,
2729+
sandbox_gid,
26952730
)
26962731
.map_err(|err| {
26972732
format!("vm sandbox image '{image_ref_owned}' is not base-compatible: {err}")

crates/openshell-driver-vm/src/main.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,8 @@ async fn main() -> Result<()> {
214214
gpu_enabled: args.gpu,
215215
gpu_mem_mib: args.gpu_mem_mib,
216216
gpu_vcpus: args.gpu_vcpus,
217+
sandbox_uid: None,
218+
sandbox_gid: None,
217219
})
218220
.await
219221
.map_err(|err| miette::miette!("{err}"))?;

crates/openshell-driver-vm/src/rootfs.rs

Lines changed: 20 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,10 @@ pub const fn sandbox_guest_init_path() -> &'static str {
2929
pub fn prepare_sandbox_rootfs_from_image_root(
3030
rootfs: &Path,
3131
image_identity: &str,
32+
sandbox_uid: u32,
33+
sandbox_gid: u32,
3234
) -> Result<(), String> {
33-
prepare_sandbox_rootfs(rootfs)?;
35+
prepare_sandbox_rootfs(rootfs, sandbox_uid, sandbox_gid)?;
3436
validate_sandbox_rootfs(rootfs)?;
3537
fs::write(
3638
rootfs.join(ROOTFS_VARIANT_MARKER),
@@ -348,7 +350,7 @@ fn append_symlink_to_archive(
348350
.map_err(|e| format!("append symlink {}: {e}", source_path.display()))
349351
}
350352

351-
fn prepare_sandbox_rootfs(rootfs: &Path) -> Result<(), String> {
353+
fn prepare_sandbox_rootfs(rootfs: &Path, sandbox_uid: u32, sandbox_gid: u32) -> Result<(), String> {
352354
for relative in ["opt/openshell/.initialized", "opt/openshell/.rootfs-type"] {
353355
remove_rootfs_path(rootfs, relative)?;
354356
}
@@ -377,7 +379,7 @@ fn prepare_sandbox_rootfs(rootfs: &Path) -> Result<(), String> {
377379
fs::create_dir_all(&opt_dir).map_err(|e| format!("create {}: {e}", opt_dir.display()))?;
378380
fs::write(opt_dir.join(".rootfs-type"), "sandbox\n")
379381
.map_err(|e| format!("write sandbox rootfs marker: {e}"))?;
380-
ensure_sandbox_guest_user(rootfs)?;
382+
ensure_sandbox_guest_user(rootfs, sandbox_uid, sandbox_gid)?;
381383
create_sandbox_mountpoint(&rootfs.join("sandbox"))?;
382384
create_sandbox_mountpoint(&rootfs.join("image-cache"))?;
383385
create_sandbox_mountpoint(&rootfs.join("lower"))?;
@@ -752,24 +754,25 @@ fn temporary_injection_path(image_path: &Path) -> PathBuf {
752754
))
753755
}
754756

755-
fn ensure_sandbox_guest_user(rootfs: &Path) -> Result<(), String> {
756-
const SANDBOX_UID: u32 = 10001;
757-
const SANDBOX_GID: u32 = 10001;
758-
757+
fn ensure_sandbox_guest_user(
758+
rootfs: &Path,
759+
sandbox_uid: u32,
760+
sandbox_gid: u32,
761+
) -> Result<(), String> {
759762
let etc_dir = rootfs.join("etc");
760763
fs::create_dir_all(&etc_dir).map_err(|e| format!("create {}: {e}", etc_dir.display()))?;
761764

762765
ensure_line_in_file(
763766
&etc_dir.join("group"),
764-
&format!("sandbox:x:{SANDBOX_GID}:"),
767+
&format!("sandbox:x:{sandbox_gid}:"),
765768
|line| line.starts_with("sandbox:"),
766769
)?;
767770
ensure_line_in_file(&etc_dir.join("gshadow"), "sandbox:!::", |line| {
768771
line.starts_with("sandbox:")
769772
})?;
770773
ensure_line_in_file(
771774
&etc_dir.join("passwd"),
772-
&format!("sandbox:x:{SANDBOX_UID}:{SANDBOX_GID}:OpenShell Sandbox:/sandbox:/bin/bash"),
775+
&format!("sandbox:x:{sandbox_uid}:{sandbox_gid}:OpenShell Sandbox:/sandbox:/bin/bash"),
773776
|line| line.starts_with("sandbox:"),
774777
)?;
775778
ensure_line_in_file(
@@ -936,7 +939,9 @@ mod tests {
936939
fs::write(rootfs.join("bin/sed"), b"sed").expect("write sed");
937940
fs::write(rootfs.join("sbin/ip"), b"ip").expect("write ip");
938941

939-
prepare_sandbox_rootfs(&rootfs).expect("prepare sandbox rootfs");
942+
// Use a non-standard UID so the test doesn't collide with the default.
943+
let uid = 20001;
944+
prepare_sandbox_rootfs(&rootfs, uid, uid).expect("prepare sandbox rootfs");
940945
validate_sandbox_rootfs(&rootfs).expect("validate sandbox rootfs");
941946

942947
assert!(rootfs.join("srv/openshell-vm-sandbox-init.sh").is_file());
@@ -955,12 +960,14 @@ mod tests {
955960
assert!(
956961
fs::read_to_string(rootfs.join("etc/passwd"))
957962
.expect("read passwd")
958-
.contains("sandbox:x:10001:10001:OpenShell Sandbox:/sandbox:/bin/bash")
963+
.contains(&format!(
964+
"sandbox:x:{uid}:{uid}:OpenShell Sandbox:/sandbox:/bin/bash"
965+
))
959966
);
960967
assert!(
961968
fs::read_to_string(rootfs.join("etc/group"))
962969
.expect("read group")
963-
.contains("sandbox:x:10001:")
970+
.contains(&format!("sandbox:x:{uid}:"))
964971
);
965972
assert_eq!(
966973
fs::read_to_string(rootfs.join("etc/hosts")).expect("read hosts"),
@@ -980,7 +987,7 @@ mod tests {
980987
fs::create_dir_all(rootfs.join("sandbox")).expect("create sandbox workdir");
981988
fs::write(rootfs.join("sandbox/app.py"), "print('hello')\n").expect("write app");
982989

983-
prepare_sandbox_rootfs(&rootfs).expect("prepare sandbox rootfs");
990+
prepare_sandbox_rootfs(&rootfs, 10001, 10001).expect("prepare sandbox rootfs");
984991

985992
assert!(rootfs.join("sandbox").is_dir());
986993
assert_eq!(

docs/reference/gateway-config.mdx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,12 @@ sa_token_ttl_secs = 3600
197197
# shared roots such as /run, /var, /tmp, and /etc are rejected.
198198
# Supervisor-to-gateway auth still uses gateway JWTs.
199199
provider_spiffe_workload_api_socket_path = "/spiffe-workload-api/spire-agent.sock"
200+
# Explicit sandbox UID/GID for the supervisor container securityContext and
201+
# PVC init container. When unset, the driver auto-detects from OpenShift SCC
202+
# namespace annotations (openshift.io/sa.scc.uid-range) if present, falling
203+
# back to 1000 on non-OpenShift clusters.
204+
# sandbox_uid = 1500
205+
# sandbox_gid = 1500
200206
```
201207

202208
### Docker
@@ -309,6 +315,9 @@ overlay_disk_mib = 4096
309315
guest_tls_ca = "/var/lib/openshell/guest-tls/ca.pem"
310316
guest_tls_cert = "/var/lib/openshell/guest-tls/client.pem"
311317
guest_tls_key = "/var/lib/openshell/guest-tls/client-key.pem"
318+
# Resolved sandbox UID/GID for the rootfs /etc/passwd entry.
319+
# Defaults to 10001 when unset; matching GID is used if sandbox_gid is empty.
320+
# sandbox_uid = 20001
312321
```
313322

314323
### Extension Driver

docs/reference/sandbox-compute-drivers.mdx

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -315,3 +315,29 @@ If Agent Sandbox is upgraded in place, restart the OpenShell gateway after the c
315315
<Note>
316316
`Sandbox.spec.volumeClaimTemplates` is immutable after creation. To change storage configuration, delete the sandbox and create a new one with the updated spec.
317317
</Note>
318+
319+
## Sandbox User Identity
320+
321+
OpenShell accepts both the hardcoded username `"sandbox"` and numeric UIDs in `[1000, 2_000_000_000]` for the supervisor's process identity (the policy's `run_as_user` field). The driver resolves the UID at sandbox creation time and passes it to the supervisor via environment variables.
322+
323+
### Kubernetes / OpenShift
324+
325+
The Kubernetes driver auto-detects the sandbox UID from OpenShift SCC namespace annotations:
326+
327+
- `openshift.io/sa.scc.uid-range` (format: `<start>/<size>`, e.g. `1000000000/10000`) provides the UID.
328+
- `openshift.io/sa.scc.supplemental-groups` provides the GID when present; otherwise the resolved UID is used as the GID.
329+
- On non-OpenShift clusters, or when annotations are absent, the driver falls back to `1000`.
330+
331+
You can override autodetection with explicit `sandbox_uid` / `sandbox_gid` config in `[openshell.drivers.kubernetes]`. When set, the driver skips namespace annotation lookup entirely.
332+
333+
The resolved UID/GID appear in:
334+
- Supervisor container environment variables (`OPENSHELL_SANDBOX_UID`, `OPENSHELL_SANDBOX_GID`) for direct kernel-level privilege dropping without `/etc/passwd` lookups.
335+
- PVC init container `securityContext.runAsUser/runAsGroup/fsGroup` for workspace ownership operations.
336+
337+
### VM Driver
338+
339+
The VM driver injects the sandbox UID into the rootfs guest's `/etc/passwd`, `/etc/group`, and `/etc/gshadow` during rootfs preparation. Default UID is `10001`; configure `sandbox_uid` in `[openshell.drivers.vm]` to use a different value.
340+
341+
### Custom Images
342+
343+
Custom sandbox images no longer need a baked-in `"sandbox"` user. If your image requires a passwd entry for tools like `sudo` or `ssh`, add one manually (e.g. `RUN useradd -m -u 1500 deploy`). The supervisor resolves the numeric UID directly via `setuid()` without needing `/etc/passwd`.

examples/bring-your-own-container/Dockerfile

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,15 +14,19 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
1414
curl iproute2 nftables \
1515
&& rm -rf /var/lib/apt/lists/*
1616

17-
# Create the sandbox user for non-root execution.
18-
# Use a high UID range to avoid conflicts with host users when running without
19-
# user namespace remapping (UID in container = UID on host).
20-
RUN groupadd -g 1000660000 sandbox && \
21-
useradd -m -u 1000660000 -g sandbox sandbox
17+
# The sandbox user is injected at runtime by the compute driver.
18+
# Kubernetes: resolved from OpenShift SCC namespace annotations or explicit
19+
# sandbox_uid config. VM: resolves to 10001 by default, configurable in
20+
# gateway TOML.
21+
#
22+
# Images no longer need a baked-in "sandbox" user — numeric UIDs are accepted
23+
# and the driver passes them directly to setuid()/chown() at sandbox start.
24+
# If your image requires a passwd entry for tools like ssh or sudo, add one
25+
# manually (e.g. RUN useradd -m -u 1500 deploy).
2226

23-
RUN install -d -o sandbox -g sandbox /sandbox
27+
RUN install -d /sandbox
2428
WORKDIR /sandbox
25-
COPY --chown=sandbox:sandbox app.py .
29+
COPY app.py .
2630

2731
EXPOSE 8080
2832

0 commit comments

Comments
 (0)