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
48 changes: 42 additions & 6 deletions internal/cli/frontdoor.go
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,15 @@ func runClaude(ctx context.Context, args []string, dir string, ops hostOps, look
inner = append(inner, "--dangerously-skip-permissions")
}
inner = append(inner, "--agent", "spacedock:first-officer")
// An unsandboxed launch has no safehouse isolation, so per-action permission
// prompting is friction without a matching safety gain: start the first officer
// in auto permission-mode. Suppressed when the operator already chose a mode and
// on a resume (which rides its own session intent, like the bootstrap prompt).
// The sandboxed arm's --dangerously-skip-permissions above already covers its
// posture, so this is the !wrap counterpart, not a replacement.
if !wrap && !resume && !passthroughHasFlag(fd.passthrough, "--permission-mode") {
inner = append(inner, "--permission-mode", "auto")
}
inner = append(inner, fd.passthrough...)
if !resume {
inner = append(inner, launchPrompt(bootstrapPrompt, fd))
Expand Down Expand Up @@ -368,6 +377,22 @@ func hasPluginDir(passthrough []string) bool {
return false
}

// passthroughHasFlag reports whether the operator already supplied any of the
// named host flags in the passthrough, in either `--flag value` or `--flag=value`
// form. The unsandboxed launchers consult it before injecting their default
// permission/approval flag so an operator-supplied one is never duplicated
// (operator wins). Mirrors hasPluginDir, generalized over a flag set.
func passthroughHasFlag(passthrough []string, names ...string) bool {
for _, a := range passthrough {
for _, name := range names {
if a == name || strings.HasPrefix(a, name+"=") {
return true
}
}
}
return false
}

// containsResume reports whether the operator forwarded any of claude's
// session-resume forms (which carry their own session intent, so the bootstrap
// prompt is suppressed): `--resume`, `--resume=<id>`, `-r`, `--continue`, `-c`.
Expand Down Expand Up @@ -458,6 +483,15 @@ func runCodex(ctx context.Context, args []string, dir string, ops hostOps, lookP
if wrap {
inner = append(inner, "--dangerously-bypass-approvals-and-sandbox")
}
// An unsandboxed launch has no safehouse isolation; codex has no single
// auto-mode flag, so its nearest analog to claude's auto permission-mode is
// `--ask-for-approval on-request` (the model decides when to escalate).
// Suppressed when the operator already chose an approval policy and on a resume
// (which rides its own session intent). The sandboxed arm's bypass flag above
// already covers its posture, so this is the !wrap counterpart.
if !wrap && !resume && !passthroughHasFlag(fd.passthrough, "--ask-for-approval", "-a") {
inner = append(inner, "--ask-for-approval", "on-request")
}
inner = append(inner, fd.passthrough...)
if !resume {
inner = append(inner, launchPrompt(codexBootstrapPrompt, fd))
Expand Down Expand Up @@ -512,12 +546,14 @@ var valueTakingHostFlags = map[string]map[string]bool{
},
"codex": {
"-m": true, "--model": true,
"--config": true,
"-c": true,
"--cd": true,
"--image": true,
"--sandbox": true,
"--profile": true,
"--config": true,
"-c": true,
"--cd": true,
"--image": true,
"--sandbox": true,
"--profile": true,
"--ask-for-approval": true,
"-a": true,
},
}

Expand Down
234 changes: 234 additions & 0 deletions internal/cli/frontdoor_permission_mode_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,234 @@
// ABOUTME: AC-1..AC-4 oracles for the unsandboxed-launch permission posture:
// ABOUTME: claude --permission-mode auto / codex --ask-for-approval on-request injection.
package cli

import (
"bytes"
"context"
"testing"
)

// argvHasFlagValue reports whether argv contains the space-form flag/value pair
// (a `flag` token immediately followed by `value`), and how many times `flag`
// appears in total. The count lets an oracle assert a single occurrence (operator
// override must not produce a duplicate).
func argvHasFlagValue(argv []string, flag, value string) (pair bool, count int) {
for i, tok := range argv {
if tok == flag {
count++
if i+1 < len(argv) && argv[i+1] == value {
pair = true
}
}
}
return pair, count
}

// AC-1: an unsandboxed `spacedock claude` launch injects `--permission-mode auto`
// and carries NO `--dangerously-skip-permissions`; the sandboxed launch is
// unchanged (`--dangerously-skip-permissions`, NO injected `--permission-mode`).
func TestClaudeUnsandboxedInjectsAutoPermissionMode(t *testing.T) {
t.Run("unsandboxed-injects-auto", func(t *testing.T) {
dir := t.TempDir() // no .safehouse
fake := &fakeHost{manifest: compatibleManifest(t)}
var stdout, stderr bytes.Buffer

code := runClaude(context.Background(), nil, dir, fake, lookFound, &stdout, &stderr)

if code != 0 {
t.Fatalf("exit = %d, want 0 (stderr=%q)", code, stderr.String())
}
if pair, _ := argvHasFlagValue(fake.launchedArg, "--permission-mode", "auto"); !pair {
t.Fatalf("unsandboxed claude launch missing --permission-mode auto: %v", fake.launchedArg)
}
for _, tok := range fake.launchedArg {
if tok == "--dangerously-skip-permissions" {
t.Fatalf("unsandboxed claude launch carried --dangerously-skip-permissions: %v", fake.launchedArg)
}
}
})
t.Run("sandboxed-unchanged", func(t *testing.T) {
dir := safehouseFixtureDir(t)
fake := &fakeHost{manifest: compatibleManifest(t)}
var stdout, stderr bytes.Buffer

code := runClaude(context.Background(), nil, dir, fake, lookFound, &stdout, &stderr)

if code != 0 {
t.Fatalf("exit = %d, want 0 (stderr=%q)", code, stderr.String())
}
sawSkip := false
for _, tok := range fake.launchedArg {
if tok == "--dangerously-skip-permissions" {
sawSkip = true
}
if tok == "--permission-mode" {
t.Fatalf("sandboxed claude launch carried injected --permission-mode: %v", fake.launchedArg)
}
}
if !sawSkip {
t.Fatalf("sandboxed claude launch missing --dangerously-skip-permissions: %v", fake.launchedArg)
}
})
}

// AC-2 (captain option A): an unsandboxed `spacedock codex` launch injects
// `--ask-for-approval on-request` and carries NO bypass flag; the sandboxed
// launch is unchanged (`--dangerously-bypass-approvals-and-sandbox`, NO injected
// approval flag).
func TestCodexUnsandboxedInjectsOnRequestApproval(t *testing.T) {
t.Run("unsandboxed-injects-on-request", func(t *testing.T) {
dir := t.TempDir() // no .safehouse
fake := &fakeHost{manifest: compatibleManifest(t)}
var stdout, stderr bytes.Buffer

code := runCodex(context.Background(), nil, dir, fake, lookFound, &stdout, &stderr)

if code != 0 {
t.Fatalf("exit = %d, want 0 (stderr=%q)", code, stderr.String())
}
if pair, _ := argvHasFlagValue(fake.launchedArg, "--ask-for-approval", "on-request"); !pair {
t.Fatalf("unsandboxed codex launch missing --ask-for-approval on-request: %v", fake.launchedArg)
}
for _, tok := range fake.launchedArg {
if tok == "--dangerously-bypass-approvals-and-sandbox" {
t.Fatalf("unsandboxed codex launch carried the bypass flag: %v", fake.launchedArg)
}
}
})
t.Run("sandboxed-unchanged", func(t *testing.T) {
dir := safehouseFixtureDir(t)
fake := &fakeHost{manifest: compatibleManifest(t)}
var stdout, stderr bytes.Buffer

code := runCodex(context.Background(), nil, dir, fake, lookFound, &stdout, &stderr)

if code != 0 {
t.Fatalf("exit = %d, want 0 (stderr=%q)", code, stderr.String())
}
sawBypass := false
for _, tok := range fake.launchedArg {
if tok == "--dangerously-bypass-approvals-and-sandbox" {
sawBypass = true
}
if tok == "--ask-for-approval" {
t.Fatalf("sandboxed codex launch carried injected --ask-for-approval: %v", fake.launchedArg)
}
}
if !sawBypass {
t.Fatalf("sandboxed codex launch missing --dangerously-bypass-approvals-and-sandbox: %v", fake.launchedArg)
}
})
}

// AC-3: an operator-supplied permission/approval flag in the passthrough
// suppresses the spacedock-injected one — exactly one occurrence, operator value
// wins (no duplicate). Covered for both space form and equals form.
func TestOperatorPermissionFlagSuppressesInjection(t *testing.T) {
t.Run("claude-space-form", func(t *testing.T) {
dir := t.TempDir() // no .safehouse
fake := &fakeHost{manifest: compatibleManifest(t)}
var stdout, stderr bytes.Buffer

code := runClaude(context.Background(), []string{"--", "--permission-mode", "plan"}, dir, fake, lookFound, &stdout, &stderr)

if code != 0 {
t.Fatalf("exit = %d, want 0 (stderr=%q)", code, stderr.String())
}
pair, count := argvHasFlagValue(fake.launchedArg, "--permission-mode", "plan")
if !pair {
t.Fatalf("operator --permission-mode plan not preserved: %v", fake.launchedArg)
}
if count != 1 {
t.Fatalf("--permission-mode appears %d times, want 1 (no injected duplicate): %v", count, fake.launchedArg)
}
})
t.Run("claude-equals-form", func(t *testing.T) {
dir := t.TempDir() // no .safehouse
fake := &fakeHost{manifest: compatibleManifest(t)}
var stdout, stderr bytes.Buffer

code := runClaude(context.Background(), []string{"--", "--permission-mode=plan"}, dir, fake, lookFound, &stdout, &stderr)

if code != 0 {
t.Fatalf("exit = %d, want 0 (stderr=%q)", code, stderr.String())
}
for _, tok := range fake.launchedArg {
if tok == "--permission-mode" {
t.Fatalf("injected space-form --permission-mode despite operator equals-form: %v", fake.launchedArg)
}
}
})
t.Run("codex-space-form", func(t *testing.T) {
dir := t.TempDir() // no .safehouse
fake := &fakeHost{manifest: compatibleManifest(t)}
var stdout, stderr bytes.Buffer

code := runCodex(context.Background(), []string{"--", "--ask-for-approval", "untrusted"}, dir, fake, lookFound, &stdout, &stderr)

if code != 0 {
t.Fatalf("exit = %d, want 0 (stderr=%q)", code, stderr.String())
}
pair, count := argvHasFlagValue(fake.launchedArg, "--ask-for-approval", "untrusted")
if !pair {
t.Fatalf("operator --ask-for-approval untrusted not preserved: %v", fake.launchedArg)
}
if count != 1 {
t.Fatalf("--ask-for-approval appears %d times, want 1 (no injected duplicate): %v", count, fake.launchedArg)
}
})
t.Run("codex-short-form", func(t *testing.T) {
dir := t.TempDir() // no .safehouse
fake := &fakeHost{manifest: compatibleManifest(t)}
var stdout, stderr bytes.Buffer

code := runCodex(context.Background(), []string{"--", "-a", "untrusted"}, dir, fake, lookFound, &stdout, &stderr)

if code != 0 {
t.Fatalf("exit = %d, want 0 (stderr=%q)", code, stderr.String())
}
for _, tok := range fake.launchedArg {
if tok == "--ask-for-approval" {
t.Fatalf("injected --ask-for-approval despite operator short-form -a: %v", fake.launchedArg)
}
}
})
}

// AC-4: the injected flag rides the non-resume gate — a resumed unsandboxed
// launch is NOT forced into the auto/approval mode. Mirrors the resume-suppression
// oracle: the bootstrap prompt and the injected flag share the same gate.
func TestResumeUnsandboxedSuppressesInjection(t *testing.T) {
t.Run("claude-resume", func(t *testing.T) {
dir := t.TempDir() // no .safehouse
fake := &fakeHost{manifest: compatibleManifest(t)}
var stdout, stderr bytes.Buffer

code := runClaude(context.Background(), []string{"--", "--resume"}, dir, fake, lookFound, &stdout, &stderr)

if code != 0 {
t.Fatalf("exit = %d, want 0 (stderr=%q)", code, stderr.String())
}
for _, tok := range fake.launchedArg {
if tok == "--permission-mode" {
t.Fatalf("resumed claude launch carried injected --permission-mode: %v", fake.launchedArg)
}
}
})
t.Run("codex-resume", func(t *testing.T) {
dir := t.TempDir() // no .safehouse
fake := &fakeHost{manifest: compatibleManifest(t)}
var stdout, stderr bytes.Buffer

code := runCodex(context.Background(), []string{"--", "resume", "abc123"}, dir, fake, lookFound, &stdout, &stderr)

if code != 0 {
t.Fatalf("exit = %d, want 0 (stderr=%q)", code, stderr.String())
}
for _, tok := range fake.launchedArg {
if tok == "--ask-for-approval" {
t.Fatalf("resumed codex launch carried injected --ask-for-approval: %v", fake.launchedArg)
}
}
})
}
16 changes: 8 additions & 8 deletions internal/cli/frontdoor_stray_prompt_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ func TestClaudeStrayPromptAfterDashWarns(t *testing.T) {
if !strings.Contains(warn, "BEFORE") {
t.Fatalf("warning does not name the corrected form (prompt BEFORE `--`): %q", warn)
}
want := []string{"claude", "--agent", "spacedock:first-officer", "--model", "gpt-x", "@/tmp/handoff.md", wantBootstrapPrompt}
want := []string{"claude", "--agent", "spacedock:first-officer", "--permission-mode", "auto", "--model", "gpt-x", "@/tmp/handoff.md", wantBootstrapPrompt}
if !equalArgv(fake.launchedArg, want) {
t.Fatalf("launch argv = %v, want %v (warn must not change the argv)", fake.launchedArg, want)
}
Expand Down Expand Up @@ -67,7 +67,7 @@ func TestClaudeStrayPromptSession12Shape(t *testing.T) {
if strings.Contains(warn, "/co") {
t.Fatalf("warning names the spacedock-injected --plugin-dir value (shadows the real prompt): %q", warn)
}
want := []string{"claude", "--agent", "spacedock:first-officer", "--plugin-dir", "/co", "--model", "gpt-x", "@/tmp/handoff.md", wantBootstrapPrompt}
want := []string{"claude", "--agent", "spacedock:first-officer", "--permission-mode", "auto", "--plugin-dir", "/co", "--model", "gpt-x", "@/tmp/handoff.md", wantBootstrapPrompt}
if !equalArgv(fake.launchedArg, want) {
t.Fatalf("launch argv = %v, want %v (warn must not change the argv)", fake.launchedArg, want)
}
Expand Down Expand Up @@ -120,7 +120,7 @@ func TestStrayPromptGuardNegatives(t *testing.T) {
return runClaude(context.Background(), args, dir, fake, lookFound, &stdout, stderr)
},
args: []string{"--", "-p", "do the thing"},
want: []string{"claude", "--agent", "spacedock:first-officer", "-p", "do the thing", wantBootstrapPrompt},
want: []string{"claude", "--agent", "spacedock:first-officer", "--permission-mode", "auto", "-p", "do the thing", wantBootstrapPrompt},
},
{
name: "codex exec subcommand-arg",
Expand All @@ -129,7 +129,7 @@ func TestStrayPromptGuardNegatives(t *testing.T) {
return runCodex(context.Background(), args, dir, fake, lookFound, &stdout, stderr)
},
args: []string{"--", "exec", "do the thing"},
want: []string{"codex", "exec", "do the thing", wantCodexBootstrapPrompt},
want: []string{"codex", "--ask-for-approval", "on-request", "exec", "do the thing", wantCodexBootstrapPrompt},
},
{
name: "claude hasTask short-circuit",
Expand All @@ -138,7 +138,7 @@ func TestStrayPromptGuardNegatives(t *testing.T) {
return runClaude(context.Background(), args, dir, fake, lookFound, &stdout, stderr)
},
args: []string{"task before", "--", "@/tmp/handoff.md"},
want: []string{"claude", "--agent", "spacedock:first-officer", "@/tmp/handoff.md", wantBootstrapPrompt + " task before"},
want: []string{"claude", "--agent", "spacedock:first-officer", "--permission-mode", "auto", "@/tmp/handoff.md", wantBootstrapPrompt + " task before"},
},
{
// An unrecognized `-`-prefixed flag's value must NOT get the prescriptive
Expand All @@ -150,7 +150,7 @@ func TestStrayPromptGuardNegatives(t *testing.T) {
return runClaude(context.Background(), args, dir, fake, lookFound, &stdout, stderr)
},
args: []string{"--", "--some-new-flag", "the-value"},
want: []string{"claude", "--agent", "spacedock:first-officer", "--some-new-flag", "the-value", wantBootstrapPrompt},
want: []string{"claude", "--agent", "spacedock:first-officer", "--permission-mode", "auto", "--some-new-flag", "the-value", wantBootstrapPrompt},
},
{
// The spacedock-injected `--plugin-dir <dir>` prefix lands the `exec`
Expand All @@ -164,7 +164,7 @@ func TestStrayPromptGuardNegatives(t *testing.T) {
return runCodex(context.Background(), args, dir, fake, lookFound, &stdout, stderr)
},
args: []string{"--plugin-dir", "/co", "--", "exec", "do the thing"},
want: []string{"codex", "--plugin-dir", "/co", "exec", "do the thing", wantCodexBootstrapPrompt},
want: []string{"codex", "--ask-for-approval", "on-request", "--plugin-dir", "/co", "exec", "do the thing", wantCodexBootstrapPrompt},
},
{
// Same structural skip for the codex `resume` subcommand behind the
Expand All @@ -175,7 +175,7 @@ func TestStrayPromptGuardNegatives(t *testing.T) {
return runCodex(context.Background(), args, dir, fake, lookFound, &stdout, stderr)
},
args: []string{"--plugin-dir", "/co", "--", "resume", "abc123"},
want: []string{"codex", "--plugin-dir", "/co", "resume", "abc123", wantCodexBootstrapPrompt},
want: []string{"codex", "--ask-for-approval", "on-request", "--plugin-dir", "/co", "resume", "abc123", wantCodexBootstrapPrompt},
},
}
for _, tc := range cases {
Expand Down
Loading
Loading