From 83b356c1c8dae9fd359fd9f99081ab0e47139c20 Mon Sep 17 00:00:00 2001 From: CL Kao Date: Tue, 22 Sep 2026 09:52:48 -0700 Subject: [PATCH] feat: forward appended Safehouse profiles through front doors --- docs/site/reference/sandbox.md | 22 ++++- internal/cli/frontdoor.go | 23 +++-- internal/cli/help.go | 1 + internal/cli/help_test.go | 2 + internal/cli/pi.go | 4 + internal/cli/pi_frontdoor_test.go | 4 + internal/cli/safehouse_knob_test.go | 122 +++++++++++++++++++++++++++ internal/safehouse/safehouse.go | 2 + internal/safehouse/safehouse_test.go | 1 + 9 files changed, 172 insertions(+), 9 deletions(-) diff --git a/docs/site/reference/sandbox.md b/docs/site/reference/sandbox.md index 14e9429ee..def9de62c 100644 --- a/docs/site/reference/sandbox.md +++ b/docs/site/reference/sandbox.md @@ -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. diff --git a/internal/cli/frontdoor.go b/internal/cli/frontdoor.go index b6fe500c8..77e9ba90c 100644 --- a/internal/cli/frontdoor.go +++ b/internal/cli/frontdoor.go @@ -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 { @@ -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"), } @@ -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 diff --git a/internal/cli/help.go b/internal/cli/help.go index 0c545df68..4649195de 100644 --- a/internal/cli/help.go +++ b/internal/cli/help.go @@ -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+` diff --git a/internal/cli/help_test.go b/internal/cli/help_test.go index 771ad2059..045799df1 100644 --- a/internal/cli/help_test.go +++ b/internal/cli/help_test.go @@ -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", diff --git a/internal/cli/pi.go b/internal/cli/pi.go index 9f8ca3856..62742ac89 100644 --- a/internal/cli/pi.go +++ b/internal/cli/pi.go @@ -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 } @@ -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 diff --git a/internal/cli/pi_frontdoor_test.go b/internal/cli/pi_frontdoor_test.go index b67862d78..f4d66bf3e 100644 --- a/internal/cli/pi_frontdoor_test.go +++ b/internal/cli/pi_frontdoor_test.go @@ -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 @@ -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 @@ -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", diff --git a/internal/cli/safehouse_knob_test.go b/internal/cli/safehouse_knob_test.go index c891b7741..754064ed8 100644 --- a/internal/cli/safehouse_knob_test.go +++ b/internal/cli/safehouse_knob_test.go @@ -5,6 +5,7 @@ package cli import ( "bytes" "context" + "errors" "strings" "testing" @@ -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) + } + }) + } +} diff --git a/internal/safehouse/safehouse.go b/internal/safehouse/safehouse.go index 1d98c70e7..01303341f 100644 --- a/internal/safehouse/safehouse.go +++ b/internal/safehouse/safehouse.go @@ -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) } diff --git a/internal/safehouse/safehouse_test.go b/internal/safehouse/safehouse_test.go index 2f9bb3551..d9e18efc8 100644 --- a/internal/safehouse/safehouse_test.go +++ b/internal/safehouse/safehouse_test.go @@ -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"}},