Skip to content

Feat/control socket introspection#153

Open
sachin2605 wants to merge 4 commits into
multikernel:mainfrom
bytehooks:feat/control-socket-introspection
Open

Feat/control socket introspection#153
sachin2605 wants to merge 4 commits into
multikernel:mainfrom
bytehooks:feat/control-socket-introspection

Conversation

@sachin2605

@sachin2605 sachin2605 commented Jul 19, 2026

Copy link
Copy Markdown
Contributor

Summary

Replaces the /dev/shm network_registry with a per-sandbox control socket architecture under /dev/shm/sandlock-$UID/<name>/. Every sandbox (CLI, Python SDK, embedded) gets its own runtime directory with a pid file and a Unix stream socket for introspection. Adds two new CLI subcommands (sandlock ps, sandlock config) and hardens sandlock kill against race conditions.

RFC: #68

Changes

New: per-sandbox runtime directory & control socket (control.rs)

  • Atomic pid file (.pid.tmp → rename → pid) with dual-line format: child_pid\nsupervisor_pid\n
  • Name collision detection: kill(supervisor_pid, 0) before overwriting an existing sandbox dir → ErrorKind::AlreadyExists
  • 2-second minimum-age guard in list_live_sandboxes to prevent pruning a sandbox that's still starting up
  • Server-side 64 KB response cap in write_response — oversized payloads return an error rather than being truncated silently
  • Wire protocol: 4-byte BE length prefix + UTF-8 JSON, one client at a time per socket
  • cleanup_runtime_dir best-effort teardown (called from Drop paths, never panics)

New CLI subcommands

  • sandlock ps — lists all running sandboxes with NAME, PID, UPTIME, and CMD. Uptime is computed from /proc/<pid>/stat starttime vs /proc/uptime. CMD is read from /proc/<pid>/cmdline, truncated to 60 chars.
  • sandlock config <name> — fetches the effective Sandbox policy from a running sandbox via control socket. Supports --toml for TOML output (round-trips through ProfileInput).

sandlock kill hardening

  • Reads both PIDs from the dual-line pid file (child + supervisor)
  • Liveness check via kill(supervisor_pid, 0) before attempting to kill
  • killpg(child_pid, SIGKILL) + direct kill(supervisor_pid, SIGKILL) to cover the case where supervisor is in a different process group
  • Backward-compatible: falls back to child_pid if pid file is old single-line format

control_socket opt-out knob

  • New control_socket: bool field on Sandbox (default true, #[serde(skip)]) and SandboxBuilder
  • When false, no runtime dir, pid file, or control-socket task is created — the sandbox is invisible to sandlock ps / sandlock config
  • no_supervisor sandboxes create a control socket only when control_socket=true

network_registry removal

  • Deleted network_registry.rs (146 lines) — the old /dev/shm/sandlock-$UID/network.json shared file with flock-based synchronization
  • Removed port_remap registration path from main.rs (~60 lines of spawn+register+unregister logic)
  • Moved format_net_rule and format_ports from main.rs into sandlock-core as public free functions so sandlock config can serialize NetRules to TOML/JSON without duplicating rendering logic
  • sandlock_cli_test.rs: ported all tests to use sandlock_core::format_net_rule
  • Name auto-generation moved from network_registry::next_name() into sandlock-core (sandbox_ prefix + PID + counter in sandbox.rs do_create_stdio/no_supervisor path)

Integration tests (10 new control socket tests)

  • test_control_setup_and_cleanup — runtime dir created with pid file and socket, cleaned up on shutdown
  • test_control_config_verb — control socket serves effective policy as JSON
  • test_control_config_verb_toml — control socket serves effective policy as TOML
  • test_control_unknown_verb — unknown verb returns error response
  • test_control_list_livelist_live_sandboxes discovers running sandboxes
  • test_control_name_collision — duplicate name with live supervisor returns AlreadyExists
  • test_control_prunes_stale_dirs — dead supervisor dirs are removed
  • test_control_prunes_stale_dirs_via_cli — CLI path also prunes dead dirs
  • test_control_no_supervisorno_supervisor sandboxes still get a control socket
  • test_control_socket_disabledcontrol_socket: false skips runtime dir entirely

Documentation

  • README.mdsandlock list replaced with sandlock ps and sandlock config examples
  • docs/sandbox-reference.mdno_supervisor added to Python synopsis, TOML profile example, and [program] field table; control_socket added to Runtime kwargs table

Files changed

15 files, +1,787 / −290

File Δ
crates/sandlock-core/src/control.rs +583 (new)
crates/sandlock-core/src/profile.rs +204
crates/sandlock-core/src/sandbox.rs +109
crates/sandlock-core/src/sandbox/builder.rs +77/−10
crates/sandlock-core/src/seccomp/state.rs +12
crates/sandlock-core/src/lib.rs +3/−3
crates/sandlock-core/tests/integration/test_control.rs +328 (new)
crates/sandlock-core/tests/integration.rs +3
crates/sandlock-cli/src/main.rs +304/−240
crates/sandlock-cli/src/network_registry.rs −146 (deleted)
crates/sandlock-cli/tests/cli_test.rs +270
crates/sandlock-cli/Cargo.toml +1
Cargo.lock +1
README.md +29/−9
docs/sandbox-reference.md +4

Test plan

  • Core lib unit tests: 51/51 pass
  • Core integration tests: 302/302 pass (includes 10 control socket tests)
  • CLI integration tests: 28/30 pass (2 pre-existing UID mapping failures, unrelated)
  • Profile integration tests: 6/6 pass

Design decisions

  1. Dual PIDs — pid file stores both child and supervisor PIDs so sandlock ps can read /proc/<child_pid>/stat for uptime while sandlock kill can target the supervisor (which owns the socket)
  2. Atomic pid file — temp+rename prevents list_live_sandboxes from seeing a partially-written pid file during sandbox startup
  3. Name collision detectionkill(supervisor_pid, 0) before overwriting prevents accidentally reusing a name that's still running
  4. 2-second pruning guard — prevents list_live_sandboxes from pruning a sandbox that was created microseconds ago
  5. 64 KB response cap — server-side cap prevents accidental large payloads from blowing up buffer allocations
  6. control_socket: bool opt-out — gives callers who don't want introspection overhead a clean escape hatch

@congwang-mk

congwang-mk commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Thanks for the PR!

Just design-level review. Layering looks right: control.rs holding the dir helpers, server loop, and client helper means the CLI has no duplicated wire logic. Dedicated tokio task instead of folding accept() into the notify loop is the correct answer to RFC open question 2. Snapshot + read-dynamic-denies-at-request-time correctly avoids the stale-file problem the RFC calls out.

Five things worth resolving before merge:

1. pid file and socket refer to different processes. setup_runtime_dir(name, pid) gets the forked child pid, but the socket is served by the supervisor. ps liveness, kill, UPTIME, and CMD all key off the child; config keys off the supervisor. If the supervisor dies while the child lives, the sandbox still lists but config fails with a raw connect error. Write both pids, or pick one and document which.

2. Name collision silently destroys a live sandbox. setup_runtime_dir does an unconditional remove_dir_all. Two sandboxes named web: the second wipes the first's pid file and socket, the first disappears from ps, kill web hits the second, and the second's Drop removes the dir while the first is still running. Needs a liveness check and a hard error on collision.

3. Pruning races startup. list_live_sandboxes deletes any dir without a parseable pid file. setup_runtime_dir creates the dir, then writes pid, then binds. A concurrent sandlock ps in that window deletes the runtime dir of a sandbox that is coming up fine. Fix with temp+rename for the pid file, or only prune dirs past a minimum age.

4. The 64 KB cap is client-side only. send_control_request rejects responses over 65536 bytes; the server never caps what it writes. A policy with many fs rules produces a config that is sent successfully and cannot be read. Cap both ends or neither.

5. No opt-out. Every Sandbox, including embedded users who never asked for introspection, now creates a /dev/shm dir and a tokio task, with best-effort failures going to stderr. Worth a builder knob, particularly for nested-sandbox and library contexts where that warning is pure noise.

@sachin2605
sachin2605 force-pushed the feat/control-socket-introspection branch from 84602b9 to a5df3da Compare July 25, 2026 19:14
Add a per-sandbox Unix control socket under /dev/shm/sandlock-$UID/<name>/
that serves introspection requests.  Each sandbox gets a runtime directory
containing a `pid` file and a `control.sock` Unix socket.

CLI changes:
- `sandlock list` → `sandlock ps` (NAME/PID/UPTIME/CMD columns via /proc)
- `sandlock config <name>` queries the control socket for effective policy
  (JSON or --toml output)
- `sandlock kill <name>` reads pid from the per-sandbox pid file
- Removed network_registry.rs and all network.json references
- Removed --name incompatibility with --no-supervisor

Core changes:
- New `control` module: runtime dir helpers, control loop (tokio task),
  wire protocol (4-byte BE length + JSON, v:1, verb:config), SO_PEERCRED
  audit, stale-dir pruning via kill(pid,0) liveness check
- `profile.rs`: added Serialize to all section structs; added reverse
  serializer (sandbox_to_profile, sandbox_to_toml, sandbox_to_json,
  format_net_rule, format_http_rule, bind_ports_to_specs, time_start_str)
- `seccomp/state.rs`: added DeniedSet::denied_paths() accessor
- `sandbox.rs`: Runtime gains control_handle + control_dir fields; control
  socket setup in do_create_stdio (supervisor path) + pid-file-only for
  no_supervisor; control loop spawned after notif startup; cleanup in
  wait() and Drop

Wire protocol: 4-byte big-endian length prefix + UTF-8 JSON.
Request: {"v":1,"verb":"config","args":{}}
Response: {"v":1,"ok":true,"data":{...ProfileInput...}}
Add 10 integration tests in sandlock-core exercising the control socket
wire protocol (list, config, prune stale dirs, serializer round-trips).
Add 8 CLI integration tests for ps, config, kill, and help commands.
Make control socket setup best-effort (warn instead of fail) so nested
sandboxes where /dev/shm is restricted by outer landlock still work.

Update README.md: replace `sandlock list` with `sandlock ps`, add
`sandlock config` examples, update port virtualization section.
- Dual PIDs in runtime dir pid file (child_pid + supervisor_pid)
- Name collision detection in setup_runtime_dir (kill(pid,0) check)
- Pruning race guard: atomic temp+rename pid write, 2s minimum-age
- 64KB response cap server-side in write_response
- control_socket opt-out knob on Sandbox/SandboxBuilder
- Wire supervisor_pid through do_create_stdio, gated on control_socket
- Update kill command for dual PIDs + killpg on child PID
@sachin2605
sachin2605 force-pushed the feat/control-socket-introspection branch from a5df3da to 482da97 Compare July 25, 2026 19:19
Document the `no_supervisor` field (serde-serialized, already in the
struct and CLI) and the new `control_socket` builder-only field
(introduced in RFC multikernel#68) in the Python synopsis, TOML profile example,
`[program]` table, and Runtime kwargs section of the sandbox reference.
@congwang-mk

Copy link
Copy Markdown
Contributor

Thanks for the rework. Points 1, 3, 4, 5 from my previous review are properly addressed (two-line pid file with supervisor liveness, temp+rename plus the 2s grace, server-side cap, control_socket knob), and the tests cover them. Three things still need to change before merge:

1. Removing network_registry loses port discovery entirely (blocking). The remapped port is only knowable at runtime, so the old virtual -> real column in list was the only way for a user to find which host port to connect to. ps now shows no PORTS at all, which breaks the port-remap workflow. Please add a ports verb on the control socket answering from Sandbox::port_mappings() at request time (more accurate than the registry, which only refreshed on on_bind and went stale on SIGKILL), and have ps restore the PORTS column by querying each live sandbox's socket, printing - when the socket is missing. Dropping the allowed-hosts column is fine since that is static config visible via sandlock config.

2. The name-collision fix misses the no_supervisor path. setup_runtime_dir now checks liveness and returns AlreadyExists, good. But the if no_supervisor && self.control_socket block in do_spawn still does an unconditional remove_dir_all with no liveness check, then sets control_dir so its own Drop removes the dir: a --no-supervisor sandbox named the same as a live sandbox wipes the live one's pid file, which is exactly the scenario from my earlier comment, just moved to one path. Share the setup_runtime_dir logic (minus the socket) instead of duplicating it; that also fixes the missing 0700 chmod there. Relatedly, in the supervisor path please hard-fail the spawn on ErrorKind::AlreadyExists specifically; best-effort-and-warn is right for restricted /dev/shm, but a collision should not leave a second sandbox running invisible to ps.

3. proc_cmdline can panic. &joined[..57] slices at a byte offset, so a multi-byte UTF-8 character spanning byte 57 makes sandlock ps panic. Truncate on a char boundary.

Smaller items, fine as part of this PR or a fast follow-up:

  • #[serde(skip)] on control_socket deserializes to false. All current paths go through the builder so nothing breaks today, but any future serde round-trip of Sandbox silently disables introspection. Use skip_serializing plus default = "..." returning true, or drop the skip.
  • sandlock kill SIGKILLs the supervisor, so cleanup never runs and the runtime dir lingers until the next ps prunes it. Call cleanup_runtime_dir from the kill command.
  • send_control_request has no connect/read timeouts; a wedged supervisor blocks the CLI forever.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants