From c137c67294e59237a8650d835754a76b2f5b11df Mon Sep 17 00:00:00 2001 From: CMGS Date: Sat, 7 Nov 2026 20:35:57 +0800 Subject: [PATCH 1/6] silkd: run git clone, push and pull on every lane The none-lane refusal predates the egress proxy relay: a none-lane guest with a policy allowing the git host reaches it through the relay, the same way exec git clone already does, yet GitClone answered unimplemented. Without such a policy git fails with its own error. The test-only lane override goes with the check, and the docs and SDK examples stop pointing none-lane git at Push. --- docs/sdk-python.md | 19 ++++++++++--------- docs/sdk.md | 17 +++++++++-------- docs/silkd.md | 4 ++-- sdk/go/git.go | 4 ++-- sdk/python/cocoonsandbox/sandbox.py | 2 +- silkd/src/git.rs | 10 +--------- silkd/src/net.rs | 11 ----------- silkd/tests/git_e2e.rs | 13 +------------ 8 files changed, 26 insertions(+), 54 deletions(-) diff --git a/docs/sdk-python.md b/docs/sdk-python.md index 10128a419..7854a6692 100644 --- a/docs/sdk-python.md +++ b/docs/sdk-python.md @@ -520,12 +520,12 @@ when the relay drops, which `w.error` tells apart from a clean close (`None`). ## Git ```python -sb.git_clone(url, "/work/repo", branch="main", depth=1, auth=token) # egress lane only +sb.git_clone(url, "/work/repo", branch="main", depth=1, auth=token) st = sb.git_status("/work/repo") # {"type", "branch", "ahead", "behind", "files", "truncated"?} sb.git_add("/work/repo", ["a.txt"]) sha = sb.git_commit("/work/repo", "message", "Dev ") -sb.git_push("/work/repo", auth=token) # egress lane only -sb.git_pull("/work/repo", auth=token) # egress lane only +sb.git_push("/work/repo", auth=token) +sb.git_pull("/work/repo", auth=token) br = sb.git_branches("/work/repo") # {"type", "current", "branches"} sb.git_create_branch("/work/repo", "feature") sb.git_checkout("/work/repo", "feature") @@ -533,9 +533,10 @@ sb.git_delete_branch("/work/repo", "feature") ``` Results are structured (porcelain v2 under the hood), never scraped stdout. -Auth tokens travel as an in-memory header, never touching guest disk. On -the no-network lane, clone/push/pull raise a typed `unimplemented` error -pointing at `push`. +Auth tokens travel as an in-memory header, never touching guest disk. +Clone, push and pull reach the remote the way an exec does: directly on the +egress lane, through the egress proxy elsewhere, so a pool whose policy does +not allow the git host fails them with git's own error. ## Terminals @@ -584,8 +585,8 @@ zero. `undrain()` lifts only a drain and `uncordon()` only a ```python try: - sb.git_clone(url, "/work/repo") + sb.read_file("/work/missing.txt") except SilkdError as e: - if e.kind == "unimplemented": # no-network lane: fall back to push - sb.push("/work/repo", tar_bytes) + if e.kind == "not_found": + ... ``` diff --git a/docs/sdk.md b/docs/sdk.md index d06cf7e10..ce0e30892 100644 --- a/docs/sdk.md +++ b/docs/sdk.md @@ -652,12 +652,12 @@ overflow instead of the stream silently dropping events. ## Git ```go -err = sb.GitClone(ctx, url, "/work/repo", "main", 0, token) // egress lane only; depth > 0 = shallow +err = sb.GitClone(ctx, url, "/work/repo", "main", 0, token) // depth > 0 = shallow st, err := sb.GitStatus(ctx, "/work/repo") // Branch, Ahead, Behind, Files[]; Truncated when the list was cut at ~1 MiB err = sb.GitAdd(ctx, "/work/repo", "a.txt") hash, err := sb.GitCommit(ctx, "/work/repo", "message", "Dev ") -err = sb.GitPush(ctx, "/work/repo", token) // egress lane only -err = sb.GitPull(ctx, "/work/repo", token) // egress lane only +err = sb.GitPush(ctx, "/work/repo", token) +err = sb.GitPull(ctx, "/work/repo", token) br, err := sb.GitBranches(ctx, "/work/repo") // Current + Branches err = sb.GitCreateBranch(ctx, "/work/repo", "feature") err = sb.GitCheckout(ctx, "/work/repo", "feature") @@ -665,9 +665,10 @@ err = sb.GitDeleteBranch(ctx, "/work/repo", "feature") ``` Results are structured (porcelain v2 under the hood), never scraped stdout. -Auth tokens travel as an in-memory header, never touching guest disk. On the -no-network lane, clone/push/pull fail fast with a typed `unimplemented` -error pointing at `Push`. +Auth tokens travel as an in-memory header, never touching guest disk. +Clone, push and pull reach the remote the way an exec does: directly on the +egress lane, through the egress proxy elsewhere, so a pool whose policy does +not allow the git host fails them with git's own error. ## Terminals @@ -737,8 +738,8 @@ tenant. ```go var e *wire.ErrorResp -if errors.As(err, &e) && e.Kind == wire.KindUnimplemented { - // no-network lane: fall back to sb.Push +if errors.As(err, &e) && e.Kind == wire.KindNotFound { + // the guest path does not exist } ``` diff --git a/docs/silkd.md b/docs/silkd.md index 48487b0dc..fc5dac33c 100644 --- a/docs/silkd.md +++ b/docs/silkd.md @@ -73,12 +73,12 @@ A failed verb terminates with `error {kind, message}`: |---|---| | `bad_request` | malformed frame, empty argv, invalid pattern, an exec argv or cwd the guest cannot spawn (missing, not a directory, not executable), an exec or pty `user` the guest cannot resolve (`unknown user`, or `look up user: ` when the name service itself fails) | | `not_found` | unknown pid/session/path, a `port_forward` port nothing listens on, a language with no LSP manifest | -| `unimplemented` | verb unavailable on this lane — notably git clone/push/pull on the no-network lane, whose message points at `fs.push` | +| `unimplemented` | reserved in the protocol; no verb answers it today | | `internal` | everything else (other spawn failures, git errors, I/O) | ## Network lanes -git clone/push/pull consult lane detection: an interface under +Lane detection decides whether execs route directly: an interface under `/sys/class/net` counts only when it has a `device` backing (a virtio or physical NIC). Name-based checks are not enough — the all-builtin sandbox kernel auto-creates virtual tunnels (`sit0` and friends) even on the no-NIC diff --git a/sdk/go/git.go b/sdk/go/git.go index 167357fdd..9c45aef44 100644 --- a/sdk/go/git.go +++ b/sdk/go/git.go @@ -6,7 +6,7 @@ import ( "github.com/cocoonstack/sandbox/protocol/wire" ) -// GitClone clones url into path, shallow when depth > 0; auth is an in-memory header token; needs the egress lane. +// GitClone clones url into path, shallow when depth > 0; auth is an in-memory header token. func (s *Sandbox) GitClone(ctx context.Context, url, path, branch string, depth uint32, auth string) error { return s.doneRPC(ctx, &wire.GitClone{URL: url, Path: path, Branch: branch, Depth: depth, Auth: auth}) } @@ -30,7 +30,7 @@ func (s *Sandbox) GitAdd(ctx context.Context, path string, files ...string) erro return s.doneRPC(ctx, &wire.GitAdd{Path: path, Files: files}) } -// GitPush pushes the current branch; on the no-network lane it fails with a typed error pointing at Push. +// GitPush pushes the current branch. func (s *Sandbox) GitPush(ctx context.Context, path, auth string) error { return s.doneRPC(ctx, &wire.GitPush{Path: path, Auth: auth}) } diff --git a/sdk/python/cocoonsandbox/sandbox.py b/sdk/python/cocoonsandbox/sandbox.py index 8bbc11618..3b2df8b36 100644 --- a/sdk/python/cocoonsandbox/sandbox.py +++ b/sdk/python/cocoonsandbox/sandbox.py @@ -306,7 +306,7 @@ def replace(self, files: list[str], pattern: str, replacement: str) -> list[dict return [f for f in conn.recv_until("done") if f["type"] == "replaced"] def git_clone(self, url: str, path: str, branch: str = "", depth: int = 0, auth: str = "") -> None: - """Clones into path (egress lane only; the none lane answers a typed unimplemented error pointing at push).""" + """Clones into path, shallow when depth is set; auth rides an in-memory header.""" self._done_rpc("git_clone", url=url, path=path, branch=branch or None, depth=depth or None, auth=auth or None) def git_status(self, path: str) -> dict[str, Any]: diff --git a/silkd/src/git.rs b/silkd/src/git.rs index 1ec4cf828..ca846fa25 100644 --- a/silkd/src/git.rs +++ b/silkd/src/git.rs @@ -1,4 +1,4 @@ -//! git verbs wrap the guest git binary into structured results; the none lane fails clone/push/pull with a typed error. +//! git verbs wrap the guest git binary into structured results. use std::process::Stdio; @@ -12,8 +12,6 @@ use crate::sysutil; const STATUS_FILES_BYTES: usize = proto::MAX_FRAME / 8; const STATUS_ENTRY_OVERHEAD: usize = 40; -const NO_EGRESS: &str = "no network on this sandbox; use fs.push to move a repository in"; - /// Clones `url` into `path` (optionally a branch, shallow depth), then Done. pub async fn clone( w: &mut W, @@ -23,9 +21,6 @@ pub async fn clone( depth: Option, auth: Option<&str>, ) -> std::io::Result<()> { - if !crate::net::has_egress() { - return proto::error_frame(w, ErrorKind::Unimplemented, NO_EGRESS).await; - } let depth_arg; let mut args: Vec<&str> = vec!["clone"]; if let Some(b) = branch { @@ -97,9 +92,6 @@ pub async fn net_verb( auth: Option<&str>, verb: &str, ) -> std::io::Result<()> { - if !crate::net::has_egress() { - return proto::error_frame(w, ErrorKind::Unimplemented, NO_EGRESS).await; - } let out = git(path, auth, &[verb]).await?; terminal(w, verb, &out).await } diff --git a/silkd/src/net.rs b/silkd/src/net.rs index 970ddbc6d..e05ce0735 100644 --- a/silkd/src/net.rs +++ b/silkd/src/net.rs @@ -8,7 +8,6 @@ use std::sync::atomic::{AtomicI8, Ordering}; /// File the host writes with its lane verdict, `relay` or `direct`; on the root filesystem so a guest reboot keeps it. const LANE_FILE: &str = "/etc/silkd-lane"; -static LANE_OVERRIDE: AtomicI8 = AtomicI8::new(-1); /// The lane file's verdict once read: -1 not yet present, 0 direct, 1 relay. static LANE: AtomicI8 = AtomicI8::new(-1); // probed once: the NIC set is fixed for the guest's life @@ -27,11 +26,6 @@ static SILKD_NET: LazyLock = LazyLock::new(|| match std::env::var("SILKD_NET /// Reports whether the guest can reach a network; only a `device`-backed interface counts, since the kernel auto-creates virtual tunnels. pub fn has_egress() -> bool { - match LANE_OVERRIDE.load(Ordering::Relaxed) { - 0 => return false, - 1 => return true, - _ => {} - } match *SILKD_NET { 0 => return false, 1 => return true, @@ -55,11 +49,6 @@ pub fn routes_directly() -> bool { !relay } -/// Lane override for tests: set_var would race every concurrent getenv. -pub fn override_egress_for_tests(lane: Option) { - LANE_OVERRIDE.store(lane.map_or(-1, i8::from), Ordering::Relaxed); -} - /// Reads the lane file: `Some(true)` for relay, `Some(false)` for direct, `None` while the host has not written it. fn lane_verdict(path: &str) -> Option { let mut buf = [0u8; 8]; diff --git a/silkd/tests/git_e2e.rs b/silkd/tests/git_e2e.rs index de844ed38..a16abbbcb 100644 --- a/silkd/tests/git_e2e.rs +++ b/silkd/tests/git_e2e.rs @@ -134,28 +134,17 @@ async fn commit_failure_surfaces_git_error() { } #[tokio::test] -async fn clone_lane_behavior() { - silkd::net::override_egress_for_tests(Some(false)); - let refused = tempfile::tempdir().unwrap(); - let none = request(json!({ - "op":"git_clone","url":"https://example.com/x.git","path":refused.path().join("x").to_str().unwrap() - })) - .await; - assert_error(&none[0], "unimplemented"); - assert!(none[0]["message"].as_str().unwrap().contains("fs.push")); - +async fn clone_copies_a_repository() { let src = init_repo(); std::fs::write(src.path().join("r.txt"), "body\n").unwrap(); git(src.path(), &["add", "r.txt"]); git(src.path(), &["commit", "-qm", "init"]); let dst = tempfile::tempdir().unwrap(); let target = dst.path().join("clone"); - silkd::net::override_egress_for_tests(Some(true)); let ok = request(json!({ "op":"git_clone", "url": src.path().to_str().unwrap(), "path": target.to_str().unwrap() })) .await; - silkd::net::override_egress_for_tests(None); assert_eq!(type_of(ok.last().unwrap()), "done", "clone failed: {ok:?}"); assert!(target.join("r.txt").exists()); } From 188870501c9d035e1f330a49c1192297683f7fea Mon Sep 17 00:00:00 2001 From: CMGS Date: Sat, 7 Nov 2026 20:35:57 +0800 Subject: [PATCH 2/6] silkd: dial the egress relays with a nonblocking close-on-exec socket instead of a blocking-pool hop The close-on-exec fix moved each relay connect onto the blocking pool, one thread hop per proxied connection and per DNS datagram, and a stalled host could tie up blocking threads past DATAGRAMS_IN_FLIGHT. The socket is now created SOCK_CLOEXEC|SOCK_NONBLOCK and the connect awaited on the reactor. --- silkd/src/net_egress.rs | 16 +++++++++------- silkd/src/sysutil.rs | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 7 deletions(-) diff --git a/silkd/src/net_egress.rs b/silkd/src/net_egress.rs index 43b25efb8..33bc97c77 100644 --- a/silkd/src/net_egress.rs +++ b/silkd/src/net_egress.rs @@ -130,13 +130,15 @@ where #[cfg(target_os = "linux")] async fn dial_host_cloexec(port: u32) -> io::Result { - let stream = tokio::task::spawn_blocking(move || { - let stream = vsock::VsockStream::connect_with_cid_port(libc::VMADDR_CID_HOST, port)?; - stream.set_nonblocking(true)?; - Ok::<_, io::Error>(stream) - }) - .await - .map_err(io::Error::other)??; + let pending = crate::sysutil::watch_fd(crate::sysutil::vsock_connect_start( + libc::VMADDR_CID_HOST, + port, + )?)?; + drop(pending.writable().await?); + let stream = vsock::VsockStream::from(pending.into_inner()); + if let Some(e) = stream.take_error()? { + return Err(e); + } tokio_vsock::VsockStream::new(stream) } diff --git a/silkd/src/sysutil.rs b/silkd/src/sysutil.rs index 779a84a84..31c2d46f2 100644 --- a/silkd/src/sysutil.rs +++ b/silkd/src/sysutil.rs @@ -209,6 +209,38 @@ pub fn tun_open(name: &str) -> std::io::Result { Ok(file.into()) } +#[cfg(target_os = "linux")] +pub fn vsock_connect_start(cid: u32, port: u32) -> std::io::Result { + // SAFETY: socket(2) takes no pointers. + let raw = unsafe { + libc::socket( + libc::AF_VSOCK, + libc::SOCK_STREAM | libc::SOCK_CLOEXEC | libc::SOCK_NONBLOCK, + 0, + ) + }; + check(raw)?; + // SAFETY: raw is the fresh fd socket(2) just returned, owned by nothing else. + let fd = unsafe { ::from_raw_fd(raw) }; + // SAFETY: sockaddr_vm is plain data and all-zero is a valid value. + let mut addr: libc::sockaddr_vm = unsafe { std::mem::zeroed() }; + addr.svm_family = libc::AF_VSOCK as libc::sa_family_t; + addr.svm_cid = cid; + addr.svm_port = port; + // SAFETY: addr is an initialized sockaddr_vm and the length passed is its size. + let rc = unsafe { + libc::connect( + fd.as_raw_fd(), + (&raw const addr).cast(), + std::mem::size_of::() as libc::socklen_t, + ) + }; + match check(rc) { + Err(e) if e.raw_os_error() != Some(libc::EINPROGRESS) => Err(e), + _ => Ok(fd), + } +} + /// Sets the `TUN_F_*` offloads the tun reader accepts; the kernel refuses a bit it does not know. #[cfg(target_os = "linux")] pub fn tun_set_offload(fd: RawFd, flags: u32) -> std::io::Result<()> { From 87eea8d639d27700e2b0584340576d5a84bacee3 Mon Sep 17 00:00:00 2001 From: CMGS Date: Sat, 7 Nov 2026 20:56:54 +0800 Subject: [PATCH 3/6] e2e, docs: expect git's own error where the none-lane refusal used to answer, and drop the remaining network-lane-only notes smoke's push without a remote and the Python e2e's clone of a host no policy allows now fail with git's own error (internal) instead of unimplemented. The wire, SDK and test docs and egress.md stop tying git to a lane. --- docs/egress.md | 2 +- e2e/cmd/smoke/main.go | 6 +++--- protocol/wire/frame.go | 6 +++--- sdk/go/git.go | 2 +- sdk/python/e2e.py | 6 +++--- silkd/tests/git_e2e.rs | 2 +- 6 files changed, 12 insertions(+), 12 deletions(-) diff --git a/docs/egress.md b/docs/egress.md index 539361867..96a3eb5d8 100644 --- a/docs/egress.md +++ b/docs/egress.md @@ -174,7 +174,7 @@ verdict into `/etc/silkd-lane` before the golden snapshot (or on a cold boot): `relay` on a locked bridge lane, `direct` on a CNI lane. Where silkd reads `relay` it hands the proxy variables to every exec exactly as on the none lane, and the image's units wait for the same file instead of guessing from NIC -presence; git keeps running as on any lane with a NIC. A golden built under +presence. A golden built under another verdict is rebuilt, not adopted. Egress-lane sandboxes never archive and do not hibernate, fork, checkpoint, or diff --git a/e2e/cmd/smoke/main.go b/e2e/cmd/smoke/main.go index 0371240f1..69b277883 100644 --- a/e2e/cmd/smoke/main.go +++ b/e2e/cmd/smoke/main.go @@ -278,13 +278,13 @@ func smokeGit(ctx context.Context, sb *sandbox.Sandbox) error { return fmt.Errorf("after checkout: current=%q, want feature", br.Current) } - if err := sb.GitPush(ctx, "/work", ""); !isSilkdKind(err, wire.KindUnimplemented) { - return fmt.Errorf("push on none lane: %v, want unimplemented", err) + if err := sb.GitPush(ctx, "/work", ""); !isSilkdKind(err, wire.KindInternal) { + return fmt.Errorf("push without a remote: %v, want git's own error", err) } return nil } -// smokeEgress pins that git runs on the egress lane, unlike the none-lane unimplemented guard. +// smokeEgress pins that git reaches a remote on the egress lane. func smokeEgress(ctx context.Context, client *sandbox.Client, template string) error { sb, err := client.New(ctx, template, sandbox.WithNetwork(sandbox.NetEgress)) if err != nil { diff --git a/protocol/wire/frame.go b/protocol/wire/frame.go index d9c5defed..81ec74578 100644 --- a/protocol/wire/frame.go +++ b/protocol/wire/frame.go @@ -334,7 +334,7 @@ type PortForward struct { func (PortForward) Op() string { return "port_forward" } -// GitClone clones a repo; network-lane only; Auth rides an in-memory Authorization header, never guest disk. +// GitClone clones a repo; Auth rides an in-memory Authorization header, never guest disk. type GitClone struct { URL string `json:"url"` Path string `json:"path"` @@ -369,7 +369,7 @@ type GitCommit struct { func (GitCommit) Op() string { return "git_commit" } -// GitPush pushes the current branch; network-lane only. +// GitPush pushes the current branch. type GitPush struct { Path string `json:"path"` Auth string `json:"auth,omitempty"` @@ -377,7 +377,7 @@ type GitPush struct { func (GitPush) Op() string { return "git_push" } -// GitPull pulls the current branch; network-lane only. +// GitPull pulls the current branch. type GitPull struct { Path string `json:"path"` Auth string `json:"auth,omitempty"` diff --git a/sdk/go/git.go b/sdk/go/git.go index 9c45aef44..46306211d 100644 --- a/sdk/go/git.go +++ b/sdk/go/git.go @@ -35,7 +35,7 @@ func (s *Sandbox) GitPush(ctx context.Context, path, auth string) error { return s.doneRPC(ctx, &wire.GitPush{Path: path, Auth: auth}) } -// GitPull pulls the current branch; same lane rules as GitPush. +// GitPull pulls the current branch. func (s *Sandbox) GitPull(ctx context.Context, path, auth string) error { return s.doneRPC(ctx, &wire.GitPull{Path: path, Auth: auth}) } diff --git a/sdk/python/e2e.py b/sdk/python/e2e.py index cf05c40ec..53793c647 100644 --- a/sdk/python/e2e.py +++ b/sdk/python/e2e.py @@ -91,13 +91,13 @@ def _tree(): member = tr.extractfile("tree/t.txt") assert member and member.read() == b"tar-marker" - @step("git-typed-error") + @step("git-unreachable-remote") def _git(): try: sb.git_clone("https://example.com/x.git", "/root/x") - raise AssertionError("clone on the none lane did not raise") + raise AssertionError("clone of a host the policy does not allow succeeded") except SilkdError as exc: - assert exc.kind == "unimplemented", exc + assert exc.kind == "internal", exc @step("hibernate-wake") def _hibernate(): diff --git a/silkd/tests/git_e2e.rs b/silkd/tests/git_e2e.rs index a16abbbcb..e31a250c1 100644 --- a/silkd/tests/git_e2e.rs +++ b/silkd/tests/git_e2e.rs @@ -1,4 +1,4 @@ -//! git verb E2E against a real git binary in temp repos; the none-lane guard uses the test lane override. +//! git verb E2E against a real git binary in temp repos. #![allow(clippy::unwrap_used, clippy::expect_used)] mod common; From 78fb4ead8aa7724b0a70c1972981f4ccc7063eef Mon Sep 17 00:00:00 2001 From: CMGS Date: Sat, 7 Nov 2026 21:10:33 +0800 Subject: [PATCH 4/6] e2e: drop the smoke notes on the removed typed error, and make the egress step read a repository's status The egress step pushed a repository without a remote, which fails the same way on every lane now, so it proved nothing about the lane; it now claims an egress-lane sandbox and reads a status through it. --- e2e/cmd/smoke/main.go | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/e2e/cmd/smoke/main.go b/e2e/cmd/smoke/main.go index 69b277883..c780f7ca6 100644 --- a/e2e/cmd/smoke/main.go +++ b/e2e/cmd/smoke/main.go @@ -29,7 +29,7 @@ func main() { addr := flag.String("addr", "127.0.0.1:7777", "sandboxd address") token := flag.String("token", "", "node api token") template := flag.String("template", "rt:24.04", "template ref") - egress := flag.Bool("egress", false, "also claim an egress-lane sandbox and check lane detection") + egress := flag.Bool("egress", false, "also claim an egress-lane sandbox and run git in it") lspTemplate := flag.String("lsp-template", "", "python-flavor template ref; when set, adds the LSP broker step") flag.Parse() @@ -48,7 +48,6 @@ func run(addr, token, template, lspTemplate string, egress bool) error { ctx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() - // Explicitly the no-network lane: the git step asserts its typed error. client, sb, err := harness.Claim(ctx, addr, token, template, sandbox.WithNetwork(sandbox.NetNone)) if err != nil { return err @@ -284,18 +283,18 @@ func smokeGit(ctx context.Context, sb *sandbox.Sandbox) error { return nil } -// smokeEgress pins that git reaches a remote on the egress lane. +// smokeEgress claims an egress-lane sandbox and reads a repository's status through it. func smokeEgress(ctx context.Context, client *sandbox.Client, template string) error { sb, err := client.New(ctx, template, sandbox.WithNetwork(sandbox.NetEgress)) if err != nil { return fmt.Errorf("claim: %w", err) } defer func() { _ = sb.Close() }() - if _, err := sb.Exec(ctx, "git", "init", "-q", "-b", "main", "/tmp/r"); err != nil { + if _, err = sb.Exec(ctx, "git", "init", "-q", "/tmp/r"); err != nil { return err } - if err := sb.GitPush(ctx, "/tmp/r", ""); !isSilkdKind(err, wire.KindInternal) { - return fmt.Errorf("push on egress lane: %v, want internal git failure (lane misdetected?)", err) + if _, err = sb.GitStatus(ctx, "/tmp/r"); err != nil { + return fmt.Errorf("status on the egress lane: %w", err) } return nil } From dc16d603373f9d3141b68b61ca805d22a25412a9 Mon Sep 17 00:00:00 2001 From: CMGS Date: Sat, 7 Nov 2026 21:31:28 +0800 Subject: [PATCH 5/6] silkd: a named-user exec or pty enters its cwd after dropping to the user Moving the uid/gid drop into the pre_exec hook left std's chdir running as root before it, so a cwd the user cannot enter no longer failed the spawn. With a user and root, the hook now enters the cwd after setuid, and the spawn answers bad_request again; a root-only test pins it. --- silkd/src/exec.rs | 7 +----- silkd/src/pty.rs | 7 +----- silkd/src/sysutil.rs | 47 +++++++++++++++++++++++++++++++++++------ silkd/tests/exec_e2e.rs | 36 +++++++++++++++++++++++++------ 4 files changed, 72 insertions(+), 25 deletions(-) diff --git a/silkd/src/exec.rs b/silkd/src/exec.rs index ca792e8bf..b681da2a5 100644 --- a/silkd/src/exec.rs +++ b/silkd/src/exec.rs @@ -50,16 +50,11 @@ where .stderr(Stdio::piped()) .process_group(0) .kill_on_drop(!req.detach); - if let Some(cwd) = &req.cwd { - cmd.current_dir(cwd); - } cmd.env_clear(); cmd.envs(sysutil::base_env().iter().copied()); cmd.envs(claim.iter().map(|(k, v)| (*k, &**v))); cmd.envs(&req.env); - if let Some(user) = &req.user - && let Err(e) = sysutil::apply_user(&mut cmd, user) - { + if let Err(e) = sysutil::apply_cwd_user(&mut cmd, req.cwd.as_deref(), req.user.as_deref()) { return proto::error_frame(out, ErrorKind::BadRequest, e).await; } diff --git a/silkd/src/pty.rs b/silkd/src/pty.rs index 48c3f5239..1692854b4 100644 --- a/silkd/src/pty.rs +++ b/silkd/src/pty.rs @@ -41,15 +41,10 @@ pub async fn open( let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/bash".to_string()); let mut cmd = Command::new(&shell); cmd.arg("-i"); - if let Some(dir) = &req.cwd { - cmd.current_dir(dir); - } cmd.env_clear().envs(sysutil::base_env().iter().copied()); sysutil::apply_claim_env(&mut cmd); cmd.envs(&req.env); - if let Some(user) = &req.user - && let Err(e) = sysutil::apply_user(&mut cmd, user) - { + if let Err(e) = sysutil::apply_cwd_user(&mut cmd, req.cwd.as_deref(), req.user.as_deref()) { return proto::error_frame(out, ErrorKind::BadRequest, e).await; } match (slave.try_clone(), slave.try_clone()) { diff --git a/silkd/src/sysutil.rs b/silkd/src/sysutil.rs index 31c2d46f2..a8e4e0c39 100644 --- a/silkd/src/sysutil.rs +++ b/silkd/src/sysutil.rs @@ -98,10 +98,20 @@ pub fn align_proxy_env(cmd: &mut Command) { align_proxy_env_for(cmd, crate::net::routes_directly()); } -/// Resolves a username via getpwnam and de-escalates the command onto it with the user's groups. -pub fn apply_user(cmd: &mut Command, user: &str) -> Result<(), String> { +/// Sets the command's cwd and, with a user, de-escalates onto it with the user's groups before entering cwd. +pub fn apply_cwd_user( + cmd: &mut Command, + cwd: Option<&str>, + user: Option<&str>, +) -> Result<(), String> { + let Some(user) = user else { + if let Some(cwd) = cwd { + cmd.current_dir(cwd); + } + return Ok(()); + }; let (uid, gid, home) = lookup_user(user)?; - de_escalate(cmd, user, uid, gid)?; + de_escalate(cmd, user, uid, gid, cwd)?; cmd.env("HOME", home); cmd.env("USER", user); Ok(()) @@ -472,21 +482,37 @@ fn unquote(value: &str) -> Cow<'_, str> { } #[cfg(target_os = "linux")] -fn de_escalate(cmd: &mut Command, user: &str, uid: u32, gid: u32) -> Result<(), String> { +fn de_escalate( + cmd: &mut Command, + user: &str, + uid: u32, + gid: u32, + cwd: Option<&str>, +) -> Result<(), String> { use nix::unistd::{Gid, Uid}; if !nix::unistd::geteuid().is_root() { cmd.uid(uid).gid(gid); + if let Some(cwd) = cwd { + cmd.current_dir(cwd); + } return Ok(()); } let name = std::ffi::CString::new(user).map_err(|e| format!("user {user:?}: {e}"))?; let groups = nix::unistd::getgrouplist(&name, Gid::from_raw(gid)) .map_err(|e| format!("look up groups of {user:?}: {e}"))?; - // SAFETY: setgroups, setgid and setuid are async-signal-safe, the contract for a post-fork pre_exec hook. + let dir = cwd + .map(std::ffi::CString::new) + .transpose() + .map_err(|e| format!("cwd: {e}"))?; + // SAFETY: setgroups, setgid, setuid and chdir are async-signal-safe, the contract for a post-fork pre_exec hook. unsafe { cmd.pre_exec(move || { nix::unistd::setgroups(&groups)?; nix::unistd::setgid(Gid::from_raw(gid))?; nix::unistd::setuid(Uid::from_raw(uid))?; + if let Some(dir) = &dir { + nix::unistd::chdir(dir.as_c_str())?; + } Ok(()) }) }; @@ -494,8 +520,17 @@ fn de_escalate(cmd: &mut Command, user: &str, uid: u32, gid: u32) -> Result<(), } #[cfg(not(target_os = "linux"))] -fn de_escalate(cmd: &mut Command, _user: &str, uid: u32, gid: u32) -> Result<(), String> { +fn de_escalate( + cmd: &mut Command, + _user: &str, + uid: u32, + gid: u32, + cwd: Option<&str>, +) -> Result<(), String> { cmd.uid(uid).gid(gid); + if let Some(cwd) = cwd { + cmd.current_dir(cwd); + } Ok(()) } diff --git a/silkd/tests/exec_e2e.rs b/silkd/tests/exec_e2e.rs index e7cd28b93..32b8f6192 100644 --- a/silkd/tests/exec_e2e.rs +++ b/silkd/tests/exec_e2e.rs @@ -3,6 +3,7 @@ #![allow(clippy::unwrap_used, clippy::expect_used)] mod common; +use std::os::unix::fs::PermissionsExt; use std::sync::Arc; use std::time::Duration; @@ -281,16 +282,11 @@ async fn exec_unknown_user_is_rejected() { #[tokio::test] #[ignore = "needs root"] async fn exec_as_a_user_keeps_its_supplementary_groups() { - let groups = std::fs::read_to_string("/etc/group").unwrap(); - let Some(user) = groups - .lines() - .filter_map(|line| line.rsplit(':').next()) - .find_map(|members| members.split(',').find(|u| !u.is_empty())) - else { + let Some(user) = group_member() else { return; }; let want = std::process::Command::new("id") - .args(["-G", user]) + .args(["-G", &user]) .output() .unwrap(); let mut want: Vec = String::from_utf8(want.stdout) @@ -312,6 +308,23 @@ async fn exec_as_a_user_keeps_its_supplementary_groups() { assert_eq!(got, want, "{frames:?}"); } +#[tokio::test] +#[ignore = "needs root"] +async fn exec_as_a_user_refuses_a_cwd_the_user_cannot_enter() { + let Some(user) = group_member() else { + return; + }; + let locked = tempfile::tempdir().unwrap(); + std::fs::set_permissions(locked.path(), std::fs::Permissions::from_mode(0o700)).unwrap(); + let inner = locked.path().join("inner"); + std::fs::create_dir(&inner).unwrap(); + let frames = request(json!({ + "op":"exec","argv":["true"],"user":user,"cwd":inner.to_str().unwrap() + })) + .await; + assert_error(&frames[0], "bad_request"); +} + #[tokio::test] async fn exec_missing_binary_is_a_bad_request() { let frames = roundtrip(r#"{"op":"exec","argv":["/no/such/binary-xyz"]}"#).await; @@ -469,3 +482,12 @@ async fn a_client_gone_mid_output_releases_the_stdin_a_forked_child_holds() { "the forked child read {read} bytes: stdin kept flowing after its client left" ); } + +fn group_member() -> Option { + std::fs::read_to_string("/etc/group") + .unwrap() + .lines() + .filter_map(|line| line.rsplit(':').next()) + .find_map(|members| members.split(',').find(|u| !u.is_empty())) + .map(str::to_owned) +} From e35d25c546aee37754609531057f573e7d30fe1a Mon Sep 17 00:00:00 2001 From: CMGS Date: Sat, 7 Nov 2026 21:34:10 +0800 Subject: [PATCH 6/6] silkd: run the cwd refusal test as nobody so every root run exercises it --- silkd/tests/exec_e2e.rs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/silkd/tests/exec_e2e.rs b/silkd/tests/exec_e2e.rs index 32b8f6192..ddd5814f1 100644 --- a/silkd/tests/exec_e2e.rs +++ b/silkd/tests/exec_e2e.rs @@ -311,15 +311,12 @@ async fn exec_as_a_user_keeps_its_supplementary_groups() { #[tokio::test] #[ignore = "needs root"] async fn exec_as_a_user_refuses_a_cwd_the_user_cannot_enter() { - let Some(user) = group_member() else { - return; - }; let locked = tempfile::tempdir().unwrap(); std::fs::set_permissions(locked.path(), std::fs::Permissions::from_mode(0o700)).unwrap(); let inner = locked.path().join("inner"); std::fs::create_dir(&inner).unwrap(); let frames = request(json!({ - "op":"exec","argv":["true"],"user":user,"cwd":inner.to_str().unwrap() + "op":"exec","argv":["true"],"user":"nobody","cwd":inner.to_str().unwrap() })) .await; assert_error(&frames[0], "bad_request");