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
22 changes: 21 additions & 1 deletion docs/site/reference/sandbox.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,24 @@

| Sandbox | Platforms | Trigger |
|---------|-----------|---------|
| [`safehouse`](https://agent-safehouse.dev/) | macOS | A `.safehouse` profile in the working directory, or the `--safehouse` flag |
| [`safehouse`](https://agent-safehouse.dev/) | macOS | A `.safehouse` profile in the working directory, `--safehouse`, or any `--safehouse-*` option |

## Additional profiles

| Option | Type | Description | Default |
|--------|------|-------------|---------|
| `--safehouse-append-profile PATH` | Optional, repeatable path | Append a safehouse policy file for this launch. | None |

```bash
spacedock claude --safehouse-append-profile=file.sb
spacedock codex --safehouse-append-profile="profiles/local rules.sb"
spacedock pi --safehouse-append-profile=first.sb --safehouse-append-profile=second.sb
```

Both `--safehouse-append-profile=PATH` and `--safehouse-append-profile PATH` select safehouse.
Place the option before `--`; tokens after `--` go to the coding agent.
Relative paths start in the directory where you launch Spacedock.
Each occurrence supplies one path; repeats retain their order.
Safehouse loads project-config profiles before these profiles, then applies its final write protections.
Safehouse reports invalid profile paths or content, and Spacedock returns the failure.
An existing sandbox stays active; this option still requests a safehouse launch and cannot relax the parent sandbox.
23 changes: 15 additions & 8 deletions internal/cli/frontdoor.go
Original file line number Diff line number Diff line change
Expand Up @@ -891,20 +891,21 @@ type frontDoorArgs struct {
}

// frontDoorFlags binds the spacedock-owned front-door flags onto a pflag.FlagSet
// so cobra owns their vocabulary natively: the three value-taking safehouse knobs
// so cobra owns their vocabulary natively: the value-taking safehouse knobs
// are StringArray (accept both `--flag value` and `--flag=value`, accumulate on
// repeat), and the bare `--safehouse`/`--skip-compat-check` are Bool. The
// returned pointers are read back by parseFrontDoorArgs after Parse. The same
// binding feeds the per-command cobra help (AC-4), so the help and the parser
// never drift.
type frontDoorFlags struct {
safehouse *bool
skipCheck *bool
noInstall *bool
enable *[]string
addDirs *[]string
addDirsRO *[]string
pluginDir *[]string
safehouse *bool
skipCheck *bool
noInstall *bool
enable *[]string
addDirs *[]string
addDirsRO *[]string
appendProfile *[]string
pluginDir *[]string
}

func bindFrontDoorFlags(fs *pflag.FlagSet) frontDoorFlags {
Expand All @@ -921,6 +922,8 @@ func bindFrontDoorFlags(fs *pflag.FlagSet) frontDoorFlags {
"Grant safehouse read-write access to a directory; repeatable"),
addDirsRO: fs.StringArray("safehouse-add-dirs-ro", nil,
"Grant safehouse read-only access to a directory; repeatable"),
appendProfile: fs.StringArray("safehouse-append-profile", nil,
"Append a safehouse policy file; repeatable; relative paths use the launch directory"),
pluginDir: fs.StringArray("plugin-dir", nil,
"Select a local Spacedock checkout before -- (relaxes the version gate); repeatable"),
}
Expand Down Expand Up @@ -958,6 +961,10 @@ func parseFrontDoorArgs(args []string) (fd frontDoorArgs, err error) {
fd.safehouseFlags = append(fd.safehouseFlags, "add-dirs-ro="+v)
}

for _, v := range *flags.appendProfile {
fd.safehouseFlags = append(fd.safehouseFlags, "append-profile="+v)
}

// ArgsLenAtDash is the count of positionals seen before `--` (or -1 when no
// `--` was given). Without a `--`, every positional is the task and nothing
// forwards. With a `--`, the pre-dash positionals join into the task and the
Expand Down
1 change: 1 addition & 0 deletions internal/cli/help.go
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ func setPiHelp(cmd *cobra.Command, w io.Writer) {
cmd.Flags().StringArray("safehouse-enable", nil, "Enable a safehouse capability (KEY[,KEY]); repeatable; e.g. --safehouse-enable ssh,docker")
cmd.Flags().StringArray("safehouse-add-dirs", nil, "Grant safehouse read-write access to a directory; repeatable")
cmd.Flags().StringArray("safehouse-add-dirs-ro", nil, "Grant safehouse read-only access to a directory; repeatable")
cmd.Flags().StringArray("safehouse-append-profile", nil, "Append a safehouse policy file; repeatable; relative paths use the launch directory")
cmd.SetHelpFunc(func(c *cobra.Command, _ []string) {
fmt.Fprint(w, tagline+`

Expand Down
2 changes: 2 additions & 0 deletions internal/cli/help_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ func TestFrontDoorHelpCarriesDetail(t *testing.T) {
"--safehouse-enable",
"--safehouse-add-dirs",
"--safehouse-add-dirs-ro",
"--safehouse-append-profile",
"Append a safehouse policy file; repeatable; relative paths use the launch directory",
"--skip-compat-check",
"--plugin-dir",
"forward verbatim",
Expand Down
4 changes: 4 additions & 0 deletions internal/cli/pi.go
Original file line number Diff line number Diff line change
Expand Up @@ -494,6 +494,7 @@ func parsePiFrontDoorArgs(args []string) (fd frontDoorArgs, pluginDirs []string,
enable := fs.StringArray("safehouse-enable", nil, "Enable a safehouse capability (KEY[,KEY]); repeatable; e.g. --safehouse-enable ssh,docker")
addDirs := fs.StringArray("safehouse-add-dirs", nil, "Grant safehouse read-write access to a directory; repeatable")
addDirsRO := fs.StringArray("safehouse-add-dirs-ro", nil, "Grant safehouse read-only access to a directory; repeatable")
appendProfile := fs.StringArray("safehouse-append-profile", nil, "Append a safehouse policy file; repeatable; relative paths use the launch directory")
if err := fs.Parse(args); err != nil {
return frontDoorArgs{}, nil, err
}
Expand All @@ -507,6 +508,9 @@ func parsePiFrontDoorArgs(args []string) (fd frontDoorArgs, pluginDirs []string,
for _, v := range *addDirsRO {
fd.safehouseFlags = append(fd.safehouseFlags, "add-dirs-ro="+v)
}
for _, v := range *appendProfile {
fd.safehouseFlags = append(fd.safehouseFlags, "append-profile="+v)
}
positionals := fs.Args()
dash := fs.ArgsLenAtDash()
var taskTokens []string
Expand Down
4 changes: 4 additions & 0 deletions internal/cli/pi_frontdoor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ type fakePiRuntimeOps struct {
statOK map[string]bool
launched []string
launchedEnv []string
launchCalls int
launchCode int // host exit code Launch returns (default 0)
piInstalls []string // sources captured by PiInstall
piInstallOut string
Expand Down Expand Up @@ -47,6 +48,7 @@ func (f *fakePiRuntimeOps) Stat(path string) error {
}

func (f *fakePiRuntimeOps) Launch(argv []string, env []string) (int, error) {
f.launchCalls++
f.launched = append([]string(nil), argv...)
f.launchedEnv = append([]string(nil), env...)
return f.launchCode, nil
Expand Down Expand Up @@ -1077,6 +1079,8 @@ func TestPiHelpCarriesSafehouseDetail(t *testing.T) {
"--safehouse-enable",
"--safehouse-add-dirs",
"--safehouse-add-dirs-ro",
"--safehouse-append-profile",
"Append a safehouse policy file; repeatable; relative paths use the launch directory",
"--plugin-dir",
"--safehouse-add-dirs ~/scratch",
"forward verbatim",
Expand Down
122 changes: 122 additions & 0 deletions internal/cli/safehouse_knob_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ package cli
import (
"bytes"
"context"
"errors"
"strings"
"testing"

Expand Down Expand Up @@ -115,3 +116,124 @@ func TestSafehouseBadValueNamesKnob(t *testing.T) {
t.Fatalf("error leaked the internal malformed-flag text: %q", stderr.String())
}
}

// Count launches so failure cannot hide a second, unprofiled attempt.
type appendProfileHost struct {
*fakeHost
calls int
}

func (f *appendProfileHost) Launch(argv, env []string) (int, error) {
f.calls++
return f.fakeHost.Launch(argv, env)
}

func TestAppendProfileLiteralParsing(t *testing.T) {
for _, parse := range []struct {
name string
fn func([]string) (frontDoorArgs, error)
}{
{"shared", parseFrontDoorArgs}, {"pi", func(args []string) (frontDoorArgs, error) { fd, _, err := parsePiFrontDoorArgs(args); return fd, err }},
} {
for _, value := range []string{"relative.sb", "/absolute path/a,b:=c.sb", "~/$(echo data);*.sb", "", "--host-looking", "--"} {
for _, args := range [][]string{{"--safehouse-append-profile=" + value}, {"--safehouse-append-profile", value}} {
t.Run(parse.name+"/"+strings.Join(args, " "), func(t *testing.T) {
fd, err := parse.fn(args)
if err != nil {
t.Fatal(err)
}
extra, err := safehouse.TranslateFlags(fd.safehouseFlags)
if err != nil || !equalArgv(extra, []string{"--append-profile=" + value}) || len(fd.passthrough) != 0 {
t.Fatalf("literal value lost: fd=%+v extra=%q err=%v", fd, extra, err)
}
})
}
}
fd, err := parse.fn([]string{"--safehouse-append-profile=a.sb", "--safehouse-add-dirs-ro=/ro", "--safehouse-append-profile=b.sb", "--safehouse-enable=ssh", "--safehouse-add-dirs=/rw", "--safehouse-append-profile=b.sb"})
if err != nil {
t.Fatal(err)
}
extra, err := safehouse.TranslateFlags(fd.safehouseFlags)
want := []string{"--enable=ssh", "--add-dirs=/rw", "--add-dirs-ro=/ro", "--append-profile=a.sb", "--append-profile=b.sb", "--append-profile=b.sb"}
if err != nil || !equalArgv(extra, want) {
t.Fatalf("%s grouped order: %q, %v", parse.name, extra, err)
}
}
}

func TestAppendProfileLaunchContract(t *testing.T) {
withExecutablePath(t, executableFixture(t), nil)
repo, pkg, home, dir := t.TempDir(), t.TempDir(), t.TempDir(), t.TempDir()
writePiSkillFixtures(t, repo)
writePiSubagentsFixtures(t, pkg)
manifest := compatibleManifest(t)
for _, host := range []string{"claude", "codex", "pi"} {
t.Run(host, func(t *testing.T) {
run := func(args []string, exit int, missing bool) (int, []string, []string, int) {
var out, errout bytes.Buffer
if host == "pi" {
ops := piSafehouseReadyOps(repo, pkg)
ops.launchCode = exit
if missing {
delete(ops.lookPath, "safehouse")
}
code := runPi(context.Background(), append([]string{"--plugin-dir", repo}, args...), dir, piTestEnv(pkg, home), ops, &out, &errout)
return code, ops.launched, ops.launchedEnv, ops.launchCalls
}
ops := &appendProfileHost{fakeHost: &fakeHost{manifest: manifest, launchCode: exit}}
look := lookFound
if missing {
look = func(string) (string, error) { return "", errors.New("not found") }
}
launch := runClaude
if host == "codex" {
launch = runCodex
}
code := launch(context.Background(), args, dir, ops, look, &out, &errout)
return code, ops.launchedArg, ops.launchedEnv, ops.calls
}
tail := []string{"do task", "--", "--model", "example"}
for _, inside := range []string{"", "agent-safehouse"} {
t.Setenv("APP_SANDBOX_CONTAINER_ID", inside)
code, baseline, baselineEnv, calls := run(append([]string{"--safehouse"}, tail...), 0, false)
if code != 0 || calls != 1 {
t.Fatalf("baseline exit=%d calls=%d", code, calls)
}
for _, exit := range []int{0, 23} {
args := append([]string{"--safehouse-append-profile=first,a:=b.sb", "--safehouse-append-profile", "second.sb", "--safehouse-append-profile=second.sb", "--safehouse-append-profile="}, tail...)
code, argv, env, calls := run(args, exit, false)
var stripped, profiles []string
before := true
for _, arg := range argv {
if arg == "--" {
before = false
}
if before && strings.HasPrefix(arg, "--append-profile=") {
profiles = append(profiles, arg)
} else {
stripped = append(stripped, arg)
}
}
want := []string{"--append-profile=first,a:=b.sb", "--append-profile=second.sb", "--append-profile=second.sb", "--append-profile="}
if code != exit || calls != 1 || !equalArgv(profiles, want) || !equalArgv(stripped, baseline) || !equalArgv(env, baselineEnv) {
t.Fatalf("exit=%d calls=%d profiles=%q argv=%q; baseline=%q", code, calls, profiles, argv, baseline)
}
}
}
for _, missingBinary := range []bool{false, true} {
arg := "--safehouse-append-profile"
if missingBinary {
arg += "=file.sb"
}
code, argv, _, calls := run([]string{arg}, 0, missingBinary)
if code == 0 || calls != 0 || len(argv) != 0 {
t.Fatalf("bad request launched: exit=%d calls=%d argv=%q", code, calls, argv)
}
}
code, argv, _, calls := run([]string{"--", "--safehouse-append-profile=host.sb"}, 0, false)
if code != 0 || calls != 1 || argv[0] != host || !strings.Contains(strings.Join(argv, " "), "--safehouse-append-profile=host.sb") {
t.Fatalf("delimiter changed: exit=%d calls=%d argv=%q", code, calls, argv)
}
})
}
}
2 changes: 2 additions & 0 deletions internal/safehouse/safehouse.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ func TranslateFlags(deprefixed []string) (extra []string, err error) {
extra = append(extra, "--add-dirs="+value)
case "add-dirs-ro":
extra = append(extra, "--add-dirs-ro="+value)
case "append-profile":
extra = append(extra, "--append-profile="+value)
default:
return nil, fmt.Errorf("safehouse: unknown flag --safehouse-%s", key)
}
Expand Down
1 change: 1 addition & 0 deletions internal/safehouse/safehouse_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,7 @@ func TestTranslateFlags(t *testing.T) {
want []string
}{
{"empty", nil, nil},
{"profiles-literal-ordered", []string{"append-profile=a,b:=c.sb", "append-profile=", "append-profile=a,b:=c.sb"}, []string{"--append-profile=a,b:=c.sb", "--append-profile=", "--append-profile=a,b:=c.sb"}},
{"enable-single", []string{"enable=docker"}, []string{"--enable=docker"}},
{"enable-comma-split", []string{"enable=ssh,docker"}, []string{"--enable=ssh", "--enable=docker"}},
{"add-dirs", []string{"add-dirs=/a"}, []string{"--add-dirs=/a"}},
Expand Down
Loading