Skip to content

Feat/control socket introspection - #153

Merged
congwang-mk merged 10 commits into
multikernel:mainfrom
bytehooks:feat/control-socket-introspection
Jul 28, 2026
Merged

Feat/control socket introspection#153
congwang-mk merged 10 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.

@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.

@congwang-mk

Copy link
Copy Markdown
Contributor

Thanks for the update! The three blockers from my last review are properly resolved: the ports verb answering from live NetworkState with the PORTS column back in ps, the shared setup_runtime_dir_no_socket with hard-fail on collision, and the char-boundary truncation in proc_cmdline. The smaller items (serde default, kill cleanup, socket timeouts) all landed too. Four things before merge:

1. Sandbox name is now a path component but is never validated as one (blocking). sandbox_validate_name only rejects empty, >64 bytes, and NUL. sandlock run --name .. makes sandbox_dir() resolve to /dev/shm, and the "dead or unparseable, safe to remove" branch in setup_runtime_dir_no_socket then calls remove_dir_all on it, deleting every user-owned entry in /dev/shm. Names containing / similarly escape the runtime root, and kill/config join raw argv into the path without any validation. Please reject /, ., and .. in sandbox_validate_name and validate in the CLI kill/config paths too.

2. The re-fixed behaviors have no tests, and the PR description claims tests that are not on the branch (blocking). test_control_name_collision, test_control_no_supervisor, and test_control_socket_disabled are listed in the description but absent from the diff, and there is no test for the ports verb or the PORTS column. These are exactly the behaviors that regressed once already in this PR's history. Please add them and refresh the description (file/line counts are also stale).

3. Python name counter: dataclass pollution plus a racy lock init. _name_counter: int = 0 is an annotated class-level assignment inside a @dataclass, so it becomes a real field (shows up in __init__, repr, and the _HANDLED_FIELDS guard loop). Annotate it ClassVar[int]. And the lazy if cls._name_lock is None: cls._name_lock = threading.Lock() is itself a check-then-act race, which can mint duplicate names and now hard-fails the second spawn. A module-level itertools.count(1) fixes both.

4. Drop the "old single-line pid file" fallbacks. kill, read_supervisor_pid, and list_live_sandboxes all fall back to a single-line format that never shipped: the previous mechanism was network.json, and the two-line file is introduced by this same PR. Pre-1.0 we prefer hard breaks over compat shims, and this is compat with nothing.

sachin2605 and others added 10 commits July 28, 2026 10:33
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
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.
- Add `ports` verb to control socket for live port-mapping discovery
- Show PORTS column in `sandlock ps` via per-sandbox socket queries
- Refactor `setup_runtime_dir` into shared `setup_runtime_dir_no_socket`
  with liveness check; hard-fail on AlreadyExists in supervisor path
- Fix potential panic in proc_cmdline on multi-byte UTF-8 boundary
- Fix serde control_socket: use skip_serializing + default = true
- Clean up runtime dir after `sandlock kill`
- Add 2-second read/write timeouts on control socket connections

🤖 Generated with [Pochi](https://getpochi.com)

Co-Authored-By: Pochi <noreply@getpochi.com>
All integration tests shared the hardcoded sandbox name "test" (225
occurrences across 18 files).  The name-collision liveness check added
in `setup_runtime_dir_no_socket` correctly refuses to overwrite a live
sandbox, so parallel `cargo test` threads would race on the shared name
and fail with `AlreadyExists`.

Remove every `.with_name("test")` call and fix 10 call sites where
`.with_name("test")` was used as a consuming builder step before
`.run_interactive()` — those now use `let mut policy` instead.

The auto-generated name (`sandbox-{pid}-{counter}`) already guarantees
uniqueness when no explicit name is set.

Result: 309/315 pass with `--test-threads=1` (6 pre-existing failures
unrelated to this change).

🤖 Generated with [Pochi](https://getpochi.com)

Co-Authored-By: Pochi <noreply@getpochi.com>
The previous `#[serde(skip_serializing, default = "...")]` omitted
`control_socket` during serialization but bincode still expected to read
it during deserialization, causing `UnexpectedEof`. Changed to
`#[serde(skip, default = "default_control_socket")]` which skips both
serialize and deserialize for all formats (bincode, JSON, TOML) while
still defaulting to `true` on load.

🤖 Generated with [Pochi](https://getpochi.com)

Co-Authored-By: Pochi <noreply@getpochi.com>
Python _resolve_name() used only PID (sandbox-{pid}) without a counter,
so concurrent sandboxes in the same process (e.g. threads) collided on
the same name. The second sandbox hit the AlreadyExists liveness check
introduced in setup_runtime_dir_no_socket, which replaced the old
unconditional remove_dir_all that silently masked this bug.

Add a module-level, lock-guarded counter that mirrors the Rust
sandbox_resolve_name(None) pattern: sandbox-{pid}-{counter}.

🤖 Generated with [Pochi](https://getpochi.com)

Co-Authored-By: Pochi <noreply@getpochi.com>
- Name validation: reject /, ., .. in sandbox_validate_name,
  CLI validate_cli_name (kill/config paths), and Python __post_init__
- Drop single-line pid file fallback in read_supervisor_pid,
  list_live_sandboxes, and CLI kill — hard-require 2-line format
- Python: use module-level itertools.count(1) instead of class-level
  ClassVar + lazy Lock to avoid dataclass field pollution and races
- Tests: 20 control integration tests covering name collision,
  no-supervisor, socket disabled, ports verb, PORTS column, invalid
  names, CLI input validation, nonexistent kill, and stale single-line
  pid file pruning
@sachin2605
sachin2605 force-pushed the feat/control-socket-introspection branch from 1150ea4 to 907b052 Compare July 28, 2026 11:16
@sachin2605

Copy link
Copy Markdown
Contributor Author

All four review items addressed:

Fix 1 — Name validation (path traversal)

Sandbox names become directory components under /dev/shm/sandlock-$UID/.
Reject /, ., .. in three layers:

  • sandbox_validate_name in sandbox-core (spawn-time, run/start paths)
  • validate_cli_name in sandlock-cli (kill and config commands, before
    any filesystem access)
  • Sandbox.__post_init__ in the Python bindings

Fix 4 — Drop single-line pid file fallback

The pid file always uses the two-line child_pid\nsupervisor_pid\n format
(established in 0.6.0). Removed the old single-line backwards-compat shim
in three locations:

  • read_supervisor_pid — returns None if line 2 is missing (was:
    fell back to line 1)
  • list_live_sandboxes — prunes the dir + continues if supervisor_pid
    parse fails (was: unwrap_or(child_pid))
  • CLI kill — exits with "invalid pid file" error (was: silently
    fell back to child_pid)

Fix 3 — Python ClassVar + race

Replaced class-level _name_counter: int = 0 and lazy _name_lock with
module-level _name_counter = itertools.count(1). _next_name is now a
@staticmethod using next(_name_counter). This avoids both dataclass
field pollution (annotated assignments at class level become real fields)
and a check-then-act race on the lazy lock init.

Fix 2 — Tests

20 control integration tests (up from 4):

  • test_control_name_collision — second sandbox with same name fails
    with "already running"
  • test_control_no_supervisor--no-supervisor sandbox appears in ps
    with PORTS column
  • test_control_socket_disabled — builder control_socket(false) and
    default true
  • test_control_ports_verb — ports verb returns empty map with no port
    forwarding configured
  • test_control_ps_ports_columnps header contains PORTS column
  • test_control_invalid_names/, .., ., a/b, ../etc rejected
    at spawn time
  • test_control_cli_kill_rejects_bad_namessandlock kill .. / . /
    a/b / /dev/shm rejected before filesystem access
  • test_control_cli_config_rejects_bad_namessandlock config .. /
    . / a/b rejected
  • test_control_cli_kill_nonexistentkill on nonexistent name
    returns "no sandbox named"
  • test_control_single_line_pid_file_is_pruned — stale single-line pid
    file dirs are excluded from listing AND physically pruned

Extended test_invalid_sandbox_name with /, ., .., a/b, ../etc
cases.

@congwang-mk
congwang-mk merged commit b946c89 into multikernel:main Jul 28, 2026
13 checks passed
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