Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,17 @@ box apply try-a # or: box discard try-a

- **SDKs:** [Python](sdk/python/), [TypeScript](sdk/typescript/), [Go](sdk/go/), [Rust](sdk/rust/)
- **MCP:** `claude mcp add box -- box mcp`. Agents can fork but not
apply, unless you start it with `--allow-apply`.
apply, unless you start it with `--allow-apply`. Long output is shown by
its first and last lines, with the rest one `output` call away, and a
re-read of something unchanged costs one line, not the content.
- **Other machines:** list them in `~/.box/hosts.yaml` and `box mcp` fronts
them too, over ssh; a sandbox there is `host/name`. `box hosts` shows them.

```yaml
hosts:
- name: gpu1
ssh: me@gpu1.example.com
```

```python
from sdbox import Sandbox
Expand Down
16 changes: 16 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,22 @@ agent or another sandbox's. The SDK server listens on a Unix socket created
owner-only (`umask` before `listen`), and is never placed in a shared
directory.

## Remote hosts (`hosts.yaml`)

`box mcp` can front other machines listed in `~/.box/hosts.yaml`. It reaches
each one only over ssh: it runs `box __ensure-serve` there, which starts
that host's own `box serve` on its owner-only socket, and forwards that socket
to one in a `0700` directory here. box opens no network listener for this.
ssh is the boundary: whoever can log in to the host can run commands in its
sandboxes, as whoever can open the socket can locally. ssh runs in batch
mode and checks host keys as your ssh config says; box never relaxes that.

The host list is checked before ssh sees it: a destination must be
`user@host` or a config alias, never something ssh would read as an option,
and the remote binary path may hold no character a shell gives meaning to.
An agent connected to `box mcp` can use every sandbox on every listed host,
and `--allow-apply` lets it apply forks on all of them.

## Host-side handling of guest-written files

A guest can write anything into `/data`: symlinks to `/etc/passwd`, `..`
Expand Down
161 changes: 161 additions & 0 deletions internal/cli/hosts.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
package cli

import (
"encoding/json"
"errors"
"fmt"
"net"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"syscall"
"text/tabwriter"
"time"

"github.com/spf13/cobra"

"box/internal/router"
"box/internal/sandbox"
"box/internal/server"
)

// remoteIdle is how long a box serve started for a router waits unused
// before exiting. The router checks after a quiet spell and starts it again.
const remoteIdle = 30 * time.Minute

// apiFor is the API box mcp and box hosts speak through: the local one, or a
// router over it and the hosts in the host list. A missing default host
// list means local only; a missing file named with --hosts is an error.
func apiFor(hostsFile string, localOnly bool) (http.Handler, func(), error) {
local := server.NewHandler(Version)
if localOnly {
return local, func() {}, nil
}
path, explicit := hostsFile, hostsFile != ""
if !explicit {
p, err := router.ConfigPath()
if err != nil {
return nil, nil, err
}
path = p
}
cfg, err := router.LoadConfig(path)
if errors.Is(err, os.ErrNotExist) && !explicit {
return local, func() {}, nil
}
if err != nil {
return nil, nil, err
}
rt, closeAll, err := router.Open(Version, local, cfg)
if err != nil {
return nil, nil, err
}
return rt, closeAll, nil
}

func hostsCmd() *cobra.Command {
var hostsFile string
c := &cobra.Command{
Use: "hosts", Short: "the remote hosts box mcp reaches", GroupID: groupInspect,
Long: "Lists the hosts in ~/.box/hosts.yaml, connects to each and shows\n" +
"what it has. box mcp fronts all of them: a sandbox on another host is\n" +
"named host/name. A host is reached over ssh, so whoever can log in\n" +
"there can run commands in its sandboxes.\n\n" +
" hosts:\n" +
" - name: gpu1\n" +
" ssh: me@gpu1.example.com # user@host or an ssh config alias\n" +
" max_up: 8 # optional: placement keeps at most 8 up\n" +
" box: /usr/local/bin/box # optional: box on that host",
Args: cobra.NoArgs,
ValidArgsFunction: completeNothing,
RunE: func(*cobra.Command, []string) error {
api, closeAll, err := apiFor(hostsFile, false)
if err != nil {
return err
}
defer closeAll()
rec := httptest.NewRecorder()
api.ServeHTTP(rec, httptest.NewRequest("GET", "/v1/hosts", nil))
if rec.Code == http.StatusNotFound {
fmt.Println("no hosts configured; add them to ~/.box/hosts.yaml (see box hosts --help)")
return nil
}
var hosts []struct {
Name, Version, Error string
Sandboxes, Up, Warm int
MaxUp int `json:"max_up"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &hosts); err != nil {
return err
}
tw := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
fmt.Fprintln(tw, "HOST\tSANDBOXES\tUP\tWARM\tVERSION\tSTATUS")
down := 0
for _, h := range hosts {
status := "ok"
if h.Error != "" {
status, down = "unreachable: "+h.Error, down+1
}
up := fmt.Sprint(h.Up)
if h.MaxUp > 0 {
up += fmt.Sprintf("/%d", h.MaxUp)
}
fmt.Fprintf(tw, "%s\t%d\t%s\t%d\t%s\t%s\n", h.Name, h.Sandboxes, up, h.Warm, h.Version, status)
}
tw.Flush()
if down > 0 {
exitCode = 1
return fmt.Errorf("%s unreachable", plural(down, "host", "hosts"))
}
return nil
},
}
c.Flags().StringVar(&hostsFile, "hosts", "", "host list (default ~/.box/hosts.yaml)")
return c
}

// ensureServeCmd starts box serve if it is not running and prints its socket
// path. A router runs it on a remote host over ssh, then forwards that path.
func ensureServeCmd() *cobra.Command {
return &cobra.Command{
Use: "__ensure-serve", Hidden: true,
Args: cobra.NoArgs,
ValidArgsFunction: completeNothing,
RunE: func(*cobra.Command, []string) error {
sock, err := sandbox.SocketPath()
if err != nil {
return err
}
if !listening(sock) {
exe, err := os.Executable()
if err != nil {
return err
}
// No stdio: ssh waits for every holder of its stdout to close.
cmd := exec.Command(exe, "serve", "--socket", sock, "--idle", remoteIdle.String())
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true} // outlive this ssh session
if err := cmd.Start(); err != nil {
return err
}
cmd.Process.Release()
for i := 0; i < 250 && !listening(sock); i++ {
time.Sleep(20 * time.Millisecond)
}
if !listening(sock) {
return fmt.Errorf("started box serve but it never listened on %s", sock)
}
}
fmt.Println(sock)
return nil
},
}
}

func listening(sock string) bool {
c, err := net.Dial("unix", sock)
if err == nil {
c.Close()
}
return err == nil
}
4 changes: 2 additions & 2 deletions internal/cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,13 +59,13 @@ func newRoot() *cobra.Command {

root.AddCommand(
newCmd(), editCmd(), buildCmd(), lsCmd(),
runCmd(), shellCmd(), upCmd(), execCmd(), downCmd(), serveCmd(), mcpCmd(),
runCmd(), shellCmd(), upCmd(), execCmd(), downCmd(), serveCmd(), mcpCmd(), hostsCmd(),
resetCmd(), snapshotCmd(), restoreCmd(),
forkCmd(), diffCmd(), applyCmd(), discardCmd(),
dataCmd(),
envCmd(), logsCmd(), verifyCmd(), inspectCmd(), doctorCmd(),
renameCmd(), destroyCmd(), nukeCmd(),
agentCmd(), prepareCmd(), tendCmd(), watchDataCmd(), overlayCmd(), krunCmd(), exportCmd(), netnsCmd(), fcCmd(), tarOutCmd(), tarInCmd(),
agentCmd(), prepareCmd(), tendCmd(), watchDataCmd(), overlayCmd(), krunCmd(), exportCmd(), netnsCmd(), fcCmd(), tarOutCmd(), tarInCmd(), ensureServeCmd(),
)
return root
}
Expand Down
22 changes: 19 additions & 3 deletions internal/cli/up.go
Original file line number Diff line number Diff line change
Expand Up @@ -186,23 +186,39 @@ func doctorCmd() *cobra.Command {
}

func mcpCmd() *cobra.Command {
var allowApply bool
var allowApply, localOnly bool
var hostsFile string
var budget int
c := &cobra.Command{
Use: "mcp", Short: "MCP server for AI agents (stdio)", GroupID: groupRun,
Long: "Serves box over the Model Context Protocol on stdin and stdout,\n" +
"for Claude Code, Claude Desktop, Cursor and other MCP clients. Tools:\n" +
"list_sandboxes, run, up, exec, down, read_file, write_file, fork,\n" +
"diff, discard. Sandboxes themselves are created and built with the CLI.\n\n" +
"diff, discard, output. Sandboxes themselves are created and built\n" +
"with the CLI.\n\n" +
"With hosts in ~/.box/hosts.yaml (see box hosts), it fronts those too:\n" +
"a sandbox on another host is host/name.\n\n" +
"Long command output is shown by its first and last lines, the rest\n" +
"kept for the output tool, and a read whose result the agent already\n" +
"has answers \"unchanged\": both save the agent's context.\n\n" +
"apply is not offered unless --allow-apply is given: forks exist so a\n" +
"human reviews an agent's work before it reaches real data.\n\n" +
" claude mcp add box -- box mcp",
Args: cobra.NoArgs,
ValidArgsFunction: completeNothing,
RunE: func(*cobra.Command, []string) error {
return mcp.New(server.NewHandler(Version), Version, allowApply).Serve(os.Stdin, os.Stdout)
api, closeAll, err := apiFor(hostsFile, localOnly)
if err != nil {
return err
}
defer closeAll()
return mcp.New(api, Version, mcp.Options{AllowApply: allowApply, OutputBudget: budget}).Serve(os.Stdin, os.Stdout)
},
}
c.Flags().BoolVar(&allowApply, "allow-apply", false, "also let the agent apply forks to their parents")
c.Flags().StringVar(&hostsFile, "hosts", "", "host list (default ~/.box/hosts.yaml)")
c.Flags().BoolVar(&localOnly, "local-only", false, "ignore the host list")
c.Flags().IntVar(&budget, "output-budget", mcp.DefaultOutputBudget, "bytes of each command stream shown before the middle is elided")
return c
}

Expand Down
Loading
Loading