Skip to content

feat(minimald,sandbox2): wire net switch into live OwnIp launch path - #547

Merged
norrietaylor merged 5 commits into
mainfrom
sdd/542-wire-ownip-switch-83d6bef29ff7a00a
Jun 23, 2026
Merged

feat(minimald,sandbox2): wire net switch into live OwnIp launch path#547
norrietaylor merged 5 commits into
mainfrom
sdd/542-wire-ownip-switch-83d6bef29ff7a00a

Conversation

@gominimal-aw-bot

@gominimal-aw-bot gominimal-aw-bot Bot commented Jun 23, 2026

Copy link
Copy Markdown
Contributor

Lands the live R1.5 wiring for the minimald sandbox2/SandboxLauncher
side: the minimald::net building blocks from Unit 1 (#496, merged via #525)
are now connected to the real session-launch path, so an OwnIp PTask gets a
tap bridged onto a running per-host gvproxy switch instead of an empty
namespace. This unblocks #499 (egress/ingress policy) and #500, which need a
running switch to attach policy to. The minvmd-side analog is #535/#540.

The three architectural decisions confirmed on the issue are applied as agreed:

  1. Switch ownership — the GvproxySwitch + IpAllocator is a
    daemon-scoped singleton constructed once in server.rs (R1.4 one gvproxy
    per host, R1.6 process-lifetime allocator). A shared
    Arc<tokio::sync::Mutex<GvproxySwitch>> is threaded through the sessions
    manager and session actor into each per-launch SandboxLauncher, which
    drives attach/detach against it rather than owning its lifecycle.
  2. sandbox2minimald::net boundarysandbox2 makes no
    minimald::net call (that would be a dependency cycle). It only unshares
    the empty network namespace; the launched hakoniwa::Child's PID
    (id(), whose /proc/<pid>/ns/net is the PTask netns — the unshare
    happens before hakoniwa's internal PID-namespace fork) is what the
    minimald side targets. The stale OwnIp comment in sandbox2 is
    corrected. All tap/ip calls live in session_host.rs / net::switch.
  3. gvproxy binary path — a new Config::gvproxy_bin field with a fixed
    install-path default when unset; GVPROXY_BIN stays scoped to the
    #[ignore] proof.

On an OwnIp launch the launcher allocates a lease, opens a host tap, moves
it into the PTask netns and configures its MAC + 100.64.0.0/16 address +
route, attaches the relay, and holds the SwitchRelay (plus a switch-detach
guard that decrements the refcount and stops gvproxy after the last PTask
leaves) for the session lifetime. HostNet/NoNet are untouched, so OwnIp
now diverges from NoNet in the live path.

Proof artifacts

1. Test (CI, deferred) — UC1/UC6 netns proofs drive the production wiring.
crates/minimald/tests/netns.rs UC6 now moves+configures each PTask tap via
the production minimald::net::switch::tap_netns_commands against a
PID-identified netns created by the same CLONE_NEWNET unshare that
sandbox2::new_container issues — not a hand-rolled ip netns sequence. These
are #[ignore] and gated on MINIMALD_NETNS_TEST; they need netns + gvproxy +
root and run in .github/workflows/ci-netns.yml. They compile and are listed
(ignored) in the local run:

     Running tests/netns.rs (target/debug/deps/netns-c23cf2ae76dc436f)
running 2 tests
test netns_uc1_nonet_refuses_egress ... ignored, needs a network namespace; gated on MINIMALD_NETNS_TEST, run by ci-netns.yml
test netns_uc6_ownip_ptask_to_ptask ... ignored, needs netns + gvproxy; gated on MINIMALD_NETNS_TEST, run by ci-netns.yml
test result: ok. 0 passed; 0 failed; 2 ignored; 0 measured; 0 filtered out

2. Test (in-sandbox) — the production move/configure command construction.
A new unit test asserts tap_netns_commands moves the tap by PID then enters
the namespace via nsenter -t <pid> -n for every config command, renders the
lease as <ip>/<prefix> CIDR, and routes via the switch gateway. It fails on
base (the function does not exist):

test net::switch::tests::tap_netns_commands_move_then_configure_by_pid ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 51 filtered out

3. CLI — the wiring compiles and the unit suites pass; OwnIp diverges from
NoNet.
cargo test -p minimald -p sandbox2 --locked and
cargo clippy -p minimald -p sandbox2 --all-targets --locked -- -D warnings:

test result: ok. 51 passed; 0 failed; 1 ignored; 0 measured; 0 filtered out   # minimald lib
test result: ok. 8 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out    # sandbox2 lib
    Finished `dev` profile ... (clippy: 0 warnings, -D warnings)

cargo build --locked -p minimald -p sandbox2 and cargo fmt --all -- --check
are also green.

Verification note

The whole-workspace cargo build --locked could not complete in the build
sandbox because the unrelated remote-proto crate's build script requires
protoc, which is not installed here (and cannot be installed without
privileges). remote-proto is not in minimald/sandbox2's dependency
tree, so this is an environment gap orthogonal to this change — the crates this
PR touches build, test, lint, and format cleanly. The privileged netns e2e is
deferred to ci-netns.yml by design.


Merging this pull request closes the task sub-issue #542. Once every task
sub-issue of its tracking issue is closed, the pipeline advances that tracking
issue to sdd:done for a final human review.

Closes #542

Generated by sdd-execute (opus tier) for issue #542 ·

Summary by CodeRabbit

Release Notes

  • New Features
    • Added a configurable gvproxy binary path override, with a built-in default fallback.
    • Enabled OwnIp session networking by provisioning a tap in the target network namespace, configuring it with the allocated MAC/IP (CIDR) and a default route via the subnet gateway.
    • Exposed subnet details (including CIDR prefix length) to ensure accurate tap/network rendering.
  • Tests
    • Expanded Linux netns integration tests to verify the end-to-end tap-in-namespace command flow (PID-based namespace entry) and the resulting interface/route configuration.

`sandbox2` treated `OwnIp` like `NoNet` and `SandboxLauncher` never
constructed a switch, so an `OwnIp` PTask got an empty namespace and
nothing downstream (#499/#500) had a running gvproxy to attach policy
to. This lands the live R1.5 wiring on the minimald sandbox2/launcher
side.

- server.rs: construct the per-host `GvproxySwitch` + `IpAllocator` once
  at daemon scope (R1.4 one-gvproxy-per-host, R1.6 process-lifetime
  allocator) and thread the shared `Arc<Mutex<GvproxySwitch>>` through
  the sessions manager and session actor into every `SandboxLauncher`.
  The gvproxy binary path comes from a new `Config::gvproxy_bin` field
  (fixed install-path default when unset), not the test's `GVPROXY_BIN`.
- session_host.rs: on an `OwnIp` launch, drive the attach on the
  minimald side — allocate a lease, `open_tap`, move the tap into the
  PTask netns (targeted by the `hakoniwa::Child`'s PID) and configure
  its MAC + `100.64.0.0/16` address + route, `attach_to_switch`, and
  hold the `SwitchRelay` (plus a switch-detach guard) for the session
  lifetime. `HostNet`/`NoNet` are unchanged.
- net/switch.rs: add `tap_netns_commands` (single-sourced move/configure
  argv) + `move_tap_into_netns`; net/mod.rs exposes `SwitchSubnet::prefix`
  and `GvproxySwitch::subnet`.
- sandbox2: no `minimald::net` call (that would be a dependency cycle);
  only the empty namespace is unshared and the PID surfaced. The stale
  `OwnIp` comment is corrected accordingly.
- tests/netns.rs: the UC6 proof drives the production `tap_netns_commands`
  against a PID-identified netns (the same `CLONE_NEWNET` sandbox2
  unshares), not a hand-rolled `ip netns` sequence.

Refs #499, #525.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jun 23, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: ea190167-0517-49e9-bda6-0e8111eddd6a

📥 Commits

Reviewing files that changed from the base of the PR and between 53fa883 and 48cbd81.

📒 Files selected for processing (1)
  • crates/minimald/src/net/switch.rs

📝 Walkthrough

Walkthrough

Adds a daemon-scoped GvproxySwitch that is constructed once in ServerState, injected through Manager::initSession::runSandboxLauncher, and used on NetworkMode::OwnIp launches to allocate a tap lease, move the tap into the sandbox netns via new tap_netns_commands/move_tap_into_netns helpers, configure the interface, start the relay, and detach on session end. Integration tests are updated to use the production PID-based netns flow.

Changes

OwnIp Gvproxy Switch Wiring

Layer / File(s) Summary
Net accessors and tap-netns command helpers
crates/minimald/src/net/mod.rs, crates/minimald/src/net/switch.rs
SwitchSubnet gains prefix() and GvproxySwitch gains subnet(); switch.rs adds tap_netns_commands (argv builder for ip/nsenter sequences to move and configure a tap by netns PID) and move_tap_into_netns (async executor that spawns commands and returns io::Error with the failing command on non-zero exit), with unit test validating command structure.
Config gvproxy_bin field and daemon-scoped switch construction
crates/minimald/src/server.rs, crates/minimald/src/main.rs, crates/minimald/src/test_harness.rs
Config adds optional gvproxy_bin override with DEFAULT_GVPROXY_BIN fallback and a private resolver method; ServerState::new constructs Arc<Mutex<GvproxySwitch>> once at daemon scope and injects it into Manager::init; startup and test harness initialize gvproxy_bin: None.
Manager and Session actor GvproxySwitch injection
crates/minimald/src/sessions.rs, crates/minimald/src/session.rs
Manager stores a net_switch field; Manager::init accepts and threads it through GetSessionSession::run; Session actor stores and forwards net_switch to SandboxLauncher via Arc::clone; session manager tests construct a placeholder switch.
OwnIpAttachment guard and SandboxLauncher OwnIp launch path
crates/minimald/src/session_host.rs, crates/sandbox2/src/lib.rs
SandboxLauncher gains net_switch; OwnIpAttachment wraps the relay and schedules async detach() on Drop; attach_own_ip allocates a lease, opens/moves/configures a tap into the sandbox netns (by PID), and starts the relay; SessionLauncher Guard becomes (Env, Option<OwnIpAttachment>); launch kills/reaps sandbox on attach failure; sandbox2 comment describes fail-closed netns behavior.
Integration tests migrated to PID-based netns flow
crates/minimald/tests/netns.rs
UC6 Ptask replaces named netns with netns_pid/holder Child; spawn_netns_holder uses sudo unshare --net and echo $$ to get stable host PID; provisioning iterates tap_netns_commands by PID; client retry uses nsenter -t <pid> -n; teardown kills holder instead of deleting named netns.

Sequence Diagram(s)

sequenceDiagram
  participant ServerState
  participant Manager
  participant Session
  participant SandboxLauncher
  participant GvproxySwitch
  participant SandboxProcess

  ServerState->>GvproxySwitch: new(gvproxy_bin_path, state_dir)
  activate GvproxySwitch
  deactivate GvproxySwitch
  ServerState->>Manager: init(net_switch: Arc<Mutex<GvproxySwitch>>)
  Manager->>Session: run(..., net_switch: Arc::clone)
  Session->>SandboxLauncher: construct(net_switch: Arc::clone)
  SandboxLauncher->>SandboxProcess: spawn (OwnIp mode)
  activate SandboxProcess
  SandboxProcess-->>SandboxLauncher: Child (netns_pid)
  deactivate SandboxProcess
  SandboxLauncher->>GvproxySwitch: attach() lease
  activate GvproxySwitch
  GvproxySwitch-->>SandboxLauncher: PtaskLease, SwitchSubnet
  deactivate GvproxySwitch
  SandboxLauncher->>SandboxLauncher: open_tap + move_tap_into_netns(netns_pid)
  SandboxLauncher->>GvproxySwitch: attach_to_switch(relay_fd)
  activate GvproxySwitch
  GvproxySwitch-->>SandboxLauncher: SwitchRelay
  deactivate GvproxySwitch
  SandboxLauncher-->>Session: OwnIpAttachment (guards relay)
  Note over Session: Session lifecycle holds guard<br/>On drop: async detach()
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • gominimal/minimal#507: Introduces NetworkMode plumbing through minimald into SandboxLauncher, which this PR uses to conditionally trigger the shared GvproxySwitch attach logic for OwnIp sessions.
  • gominimal/minimal#525: Builds the foundational minimald::net primitives (GvproxySwitch, tap relay, IpAllocator, SwitchSubnet) that this PR wires into the live session-launch path via tap_netns_commands and OwnIpAttachment.
  • gominimal/minimal#510: Adds the CI job that fetches and pins gvproxy, enabling the netns-filtered integration tests in this PR to run with the production tap_netns_commands wiring.

Suggested labels

needs-human

Suggested reviewers

  • bryan-minimal

🐇 A daemon-scoped switch, shared across all the sessions,
tap moves by PID, netns configures with precisions.
OwnIpAttachment guards the relay flame,
drops on session end—the rabbit tames!
Arc-cloned from server down through the launch,
gvproxy is wired! The daemon can staunch. 🎉

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title accurately summarizes the main architectural change: wiring the net switch into the live OwnIp launch path across minimald and sandbox2.
Linked Issues check ✅ Passed The PR fully implements the coding requirements from #542: switch ownership at daemon scope, GvproxySwitch injection into SandboxLauncher, PTask netns exposure via PID, tap attach/detach operations, and all specified file changes.
Out of Scope Changes check ✅ Passed All changes are directly scoped to implementing #542: switch initialization, session injection, tap lifecycle management, and netns integration testing. No unrelated refactoring or scope creep detected.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/minimald/src/session_host.rs`:
- Around line 742-749: The spawned sandbox process is not being cleaned up when
the attach_own_ip call fails at the error boundary where it returns with the
question mark operator. To fix this, either set kill_on_drop(true) on the
hakoniwa::Command before calling spawn (similar to the pattern used in
exec.rs:344 and net/mod.rs:502), or explicitly call kill on the process variable
in the error path before returning if attach_own_ip fails. The key is ensuring
that when attach_own_ip returns an error for the OwnIp NetworkMode case, the
process variable does not simply drop and orphan the child process.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 383c312d-171d-4843-b601-31ba9f0c5c60

📥 Commits

Reviewing files that changed from the base of the PR and between 1b89403 and 103f944.

📒 Files selected for processing (10)
  • crates/minimald/src/main.rs
  • crates/minimald/src/net/mod.rs
  • crates/minimald/src/net/switch.rs
  • crates/minimald/src/server.rs
  • crates/minimald/src/session.rs
  • crates/minimald/src/session_host.rs
  • crates/minimald/src/sessions.rs
  • crates/minimald/src/test_harness.rs
  • crates/minimald/tests/netns.rs
  • crates/sandbox2/src/lib.rs

Comment thread crates/minimald/src/session_host.rs
@github-actions

Copy link
Copy Markdown

Revise claim for head 103f944.

@github-actions

Copy link
Copy Markdown

Auto-revise 1 of 3.

@gominimal-aw-bot

This comment has been minimized.

@gominimal-aw-bot gominimal-aw-bot Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Generated by sdd-review for issue #547

Comment thread crates/minimald/src/session_host.rs
Comment thread crates/minimald/src/session_host.rs Outdated
When `attach_own_ip` failed during an `OwnIp` launch, the spawned
`hakoniwa::Child` was dropped without being killed. A `hakoniwa::Child`
does not terminate on drop (it orphans the child, the same hazard the
`kill_on_drop(true)` calls in `exec.rs`/`net/mod.rs` guard against), so a
failed switch attach left the sandbox process running. Kill and reap the
process explicitly in the attach error path before propagating the error.

Also document two correctness constraints surfaced in review: sessions
must be drained before the tokio runtime is stopped so each
`OwnIpAttachment`'s scheduled `detach` runs, and the PTask network
namespace is empty until `attach_own_ip` returns.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@gominimal-aw-bot

Copy link
Copy Markdown
Contributor Author

Commit pushed: e87df3a

Generated by sdd-execute (opus tier)

@gominimal-aw-bot

Copy link
Copy Markdown
Contributor Author

Addressed the review feedback in a follow-up commit on this branch.

1. (Major, CodeRabbit) Orphaned sandbox process on OwnIp attach failurecrates/minimald/src/session_host.rs. When attach_own_ip failed, the spawned hakoniwa::Child dropped without being killed, and hakoniwa::Child does not terminate on drop (it orphans the child — the same hazard the kill_on_drop(true) calls at exec.rs:344 / net/mod.rs:502 guard against). hakoniwa::Command has no kill_on_drop, so the attach error path now kills and reaps the process explicitly before propagating the error, mirroring the Message::Kill warn-on-failure pattern already in this file.

2. (Low) Shutdown ordering — documented on the OwnIpAttachment doc comment: sessions must be drained before the tokio runtime is stopped, otherwise a pending detach never runs and the switch refcount stays elevated.

3. (Low) Empty-namespace startup window — documented at the launch site: the PTask netns is empty until attach_own_ip returns; benign for a shell PTask, a constraint for any future non-shell PTask.

Verification (touched crates):

  • cargo fmt --all -- --check — clean
  • cargo clippy -p minimald -p sandbox2 --all-targets --locked -- -D warnings — clean
  • cargo test -p minimald -p sandbox2 --locked — 51 passed / 1 ignored (minimald lib), 8 passed (sandbox2), doctests ok; the #[ignore] UC1/UC6 netns proofs remain deferred to ci-netns.yml.

The full-workspace build is still blocked here only by remote-proto's protoc requirement, which is outside this PR's dependency tree.

Generated by sdd-execute (opus tier) for issue #547 ·

@gominimal-aw-bot gominimal-aw-bot Bot mentioned this pull request Jun 23, 2026

@gominimal-aw-bot gominimal-aw-bot Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Generated by sdd-review for issue #547

Comment thread crates/minimald/src/net/switch.rs Outdated
@gominimal-aw-bot

This comment has been minimized.

The OwnIp tap setup loop in move_tap_into_netns used the prefix
"configuring PTask tap failed" for every command, but command 0 moves
the tap into the PTask namespace (ip link set <tap> netns <pid>) rather
than configuring it. Name the failing phase by command index so a move
failure no longer reports as a configuration failure.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@gominimal-aw-bot

Copy link
Copy Markdown
Contributor Author

Commit pushed: 707efe2

Generated by sdd-execute (opus tier)

@gominimal-aw-bot

Copy link
Copy Markdown
Contributor Author

Addressed the review note on crates/minimald/src/net/switch.rs:199.

move_tap_into_netns now names the failing phase by command index: command 0 (ip link set <tap> netns <pid>) reports "moving PTask tap into its namespace", while the remaining nsenter commands report "configuring PTask tap". The failing argv is still included verbatim.

Verified green on the touched crate: cargo fmt --all -- --check, cargo clippy -p minimald --all-targets --locked -- -D warnings, and cargo test -p minimald --locked (51 passed).

Generated by sdd-execute (opus tier) for issue #547 ·

@gominimal-aw-bot

This comment has been minimized.

@gominimal-aw-bot gominimal-aw-bot Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Generated by sdd-review for issue #547

Comment thread crates/minimald/src/session_host.rs Outdated
Comment thread crates/minimald/src/session_host.rs
The OwnIp attach error path used an `else if`, so `process.wait()` only
ran when `process.kill()` succeeded. When `kill` fails with `ESRCH`
because the sandbox process already exited during the attach window,
`wait` never ran and the child was left as a zombie for the daemon
lifetime. Split into two independent `if` arms so the child is always
reaped — the standard SIGKILL-then-waitpid idiom.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@gominimal-aw-bot

Copy link
Copy Markdown
Contributor Author

Commit pushed: 53fa883

Generated by sdd-execute (opus tier)

@gominimal-aw-bot

Copy link
Copy Markdown
Contributor Author

Addressed the MEDIUM — Correctness review note on the OwnIp attach error path (session_host.rs).

The error arm used if process.kill() { ... } else if process.wait() { ... }, so wait() only ran when kill() succeeded. When kill() fails with ESRCH — the common case where the sandbox process already exited during the attach window — wait() was skipped and the child was left as a zombie for the daemon lifetime. Split into two independent if arms so the child is always reaped, whether or not kill() succeeds (the standard SIGKILL-then-waitpid idiom).

Verified green for the touched crates: cargo fmt --all -- --check, cargo build --locked -p minimald -p sandbox2, cargo clippy --locked -p minimald -p sandbox2 --all-targets -- -D warnings, and cargo test --locked -p minimald -p sandbox2 all pass.

Generated by sdd-execute (opus tier) for issue #547 ·

@norrietaylor
norrietaylor enabled auto-merge (squash) June 23, 2026 21:11
@gominimal-aw-bot

This comment has been minimized.

@gominimal-aw-bot gominimal-aw-bot Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Generated by sdd-review for issue #547

Comment thread crates/minimald/src/net/switch.rs Outdated
Comment thread crates/minimald/src/session_host.rs
move_tap_into_netns dispatched ip/nsenter by PATH-relative name while
holding CAP_NET_ADMIN in the host namespace; a binary shadowing them
early in an inherited PATH would run at that capability. Resolve the
program against a fixed trusted directory list and pin the same PATH on
the child so nsenter's inner ip resolves safely too.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0143kv2BRrRqGxmVwwHskQtS
@gominimal-aw-bot

Copy link
Copy Markdown
Contributor Author

sdd-validate findings — Implementation boundary

PR: #547 · Head SHA: 48cbd81 · Task: #542
Boundary resolved: Implementation — all 10 changed files are Rust source/test files (crates/minimald/**, crates/sandbox2/src/lib.rs); no spec, architecture, or spike files changed.


Gate 1 — Proof artifacts re-executed and passing

Both proof artifacts declared in task #542 are subject to the same infrastructure limit as prior validation passes: the agent sandbox has no Rust toolchain and no outbound registry egress (index.crates.io blocked), so cargo commands cannot proceed past the fetch stage. Both are covered by consumer CI.

Proof artifact Status Covering check
UC1/UC6 #[ignore] netns proofs in crates/minimald/tests/netns.rs Info — deferred to consumer CI .github/workflows/ci-netns.yml (provisions root + gvproxy + netns; runs cargo test ... netns -- --include-ignored --nocapture with MINIMALD_NETNS_TEST=1; triggers on crates/minimald/** and crates/sandbox2/** path filters — both changed in this PR)
cargo test -p minimald -p sandbox2 --locked + cargo clippy -p minimald -p sandbox2 --all-targets -- -D warnings Info — deferred to consumer CI ci.ymltest job (cargo nextest run --workspace, superset) and clippy job (cargo clippy --workspace --all-targets -- -D warnings, superset), gated through the ci-success required check

No proof artifact ran and reported a failure. Each is blocked only by the network constraint before exercising the change, and each is covered by a named consumer required check. No needs-human on this gate.


Gate 2 — Changed files within task scope

Task #542 files in scope: lists three primary files: crates/sandbox2/src/lib.rs ✓, crates/minimald/src/session_host.rs ✓, crates/minimald/src/server.rs ✓.

The PR changes 7 additional files outside the explicit list:

File Change in this PR Relationship to scope
crates/minimald/src/net/switch.rs Adds tap_netns_commands, move_tap_into_netns, TRUSTED_EXEC_PATH, trusted_program (commit 48cbd81 hardens binary resolution under CAP_NET_ADMIN), and the tap_netns_commands_move_then_configure_by_pid unit test Called directly by session_host.rs's attach_own_ip; tap_netns_commands is named in the task's proof artifacts field
crates/minimald/src/net/mod.rs Adds SwitchSubnet::prefix() and GvproxySwitch::subnet() Required by session_host.rs's attach_own_ip to render the lease CIDR and gateway
crates/minimald/src/session.rs Adds net_switch: Arc<Mutex<GvproxySwitch>> field and threads it to SandboxLauncher Required plumbing from server.rs singleton through sessions → session → session_host
crates/minimald/src/sessions.rs Adds net_switch field on Manager, wires through init() Required plumbing, same chain
crates/minimald/src/main.rs Adds gvproxy_bin: None to Config struct literal Required: Config in server.rs gained a new field
crates/minimald/src/test_harness.rs Adds gvproxy_bin: None to test Config Same reason; keeps existing test builds compiling
crates/minimald/tests/netns.rs Rewrites UC6 Ptask to use PID-identified netns (spawn_netns_holder) and drive tap_netns_commands directly instead of hand-rolled ip netns Explicitly named in the task's proof artifacts: field

No protected paths touched (no .github/, decisions/, templates/.github/, or secrets). All 7 are directly required by the wiring the task describes: Rust requires threading the Arc<> through every intermediate struct in the server → sessions → session → SandboxLauncher chain, net/switch.rs implements and the proof directly names tap_netns_commands, and tests/netns.rs is the ci-netns.yml proof.

Result: ⚠️ Warning ×7 — files changed outside the 3-file explicit scope listing; all are logically necessary, none are protected paths.


Gate 3 — No real credentials in the diff

Pass. No API keys, tokens, secrets, or credentials in the diff. TRUSTED_EXEC_PATH = "/usr/sbin:/sbin:/usr/bin:/bin" and DEFAULT_GVPROXY_BIN = "/usr/lib/minimal/bin/gvproxy" are filesystem path constants; /nonexistent/gvproxy is a test placeholder.


Note on commit 48cbd81

The newest commit ("fix(minimald): exec ip/nsenter from trusted paths under CAP_NET_ADMIN") adds TRUSTED_EXEC_PATH and trusted_program() in net/switch.rs so move_tap_into_netns resolves ip/nsenter to absolute paths under /usr/sbin:/sbin:/usr/bin:/bin and pins PATH in the child environment before spawning with CAP_NET_ADMIN. This addresses the PATH-shadow attack surface for privileged binaries. All three implementation gates continue to pass clean against this change.


Summary

Gate Result
G1 — Proof artifacts i️ ×2 Info — deferred to consumer CI (ci-netns.yml, ci.yml/ci-success)
G2 — Files in scope ⚠️ ×7 Warning — out-of-explicit-scope files, all logically required, no protected paths
G3 — No credentials ✅ Pass

No Blocker findings. No needs-human applied.

Lifecycle: tracking issue #478 already carries sdd:review — no label move needed (idempotent).

Generated by sdd-validate for PR #547

Generated by sdd-validate for issue #547 ·

@norrietaylor
norrietaylor merged commit 350e492 into main Jun 23, 2026
110 of 112 checks passed
@norrietaylor
norrietaylor deleted the sdd/542-wire-ownip-switch-83d6bef29ff7a00a branch June 23, 2026 21:38

@gominimal-aw-bot gominimal-aw-bot Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Generated by sdd-review for issue #547

Ok(relay) => relay,
Err(e) => {
let _ = switch.lock().await.detach().await;
return Err(e);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LOW · correctness — When the inner async block fails and the rollback switch.detach() also fails, its error is silently discarded:

let _ = switch.lock().await.detach().await;

If detach returns Err here, the gvproxy refcount stays elevated and gvproxy will not stop when the last PTask eventually leaves ("a leaked count would keep it running", per the comment above). The OwnIpAttachment::drop path uses tracing::warn! on detach failures; matching that approach here would surface this edge case for debugging:

Err(e) => {
    if let Err(de) = switch.lock().await.detach().await {
        tracing::warn!(error = %de, "rolling back switch attach after OwnIp setup failure");
    }
    return Err(e);
}

return candidate.to_string_lossy().into_owned();
}
}
program.to_string()

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LOW · security — When trusted_program returns a bare name (the fallback for unusual layouts), Command::new(bare_name) resolves the executable using the parent process's PATH environment variable, not TRUSTED_EXEC_PATH. The .env("PATH", TRUSTED_EXEC_PATH) call on the Command is the child's environment — it applies to programs the child invokes (e.g. the ip that nsenter -n re-execs inside the PTask namespace), not to the initial exec lookup. A tampered parent PATH in this fallback branch could shadow ip or nsenter at CAP_NET_ADMIN.

The code comment correctly notes "just without the hardening" and in practice ip / nsenter will be under /sbin or /usr/sbin on every standard Linux distribution, so the fallback is rare. No change is strictly required, but extending the comment to clarify that .env("PATH", ...) does not protect the initial exec lookup (only the child's inner calls) would prevent a future maintainer from assuming full coverage when the fallback fires.

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.

feat(minimald,sandbox2): wire minimald::net switch into the live OwnIp session-launch path (R1.5)

1 participant