diff --git a/.changeset/sandcastle-first-class-sandbox.md b/.changeset/sandcastle-first-class-sandbox.md new file mode 100644 index 000000000..14bfa196c --- /dev/null +++ b/.changeset/sandcastle-first-class-sandbox.md @@ -0,0 +1,5 @@ +--- +"@bradygaster/squad-cli": minor +--- + +Add first-class sandcastle sandbox execution support across watch/triage/loop/start/bridge, including `--sandbox-flags` passthrough, centralized execution config validation, updated CLI/help/docs, and acceptance/unit test coverage. diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 000000000..feda1bc29 --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,65 @@ +# Squad Runtime Context + +Shared language for how Squad chooses and configures execution environments for agent work. + +## Language + +**Sandbox Provider**: +The execution environment family used for an agent session, with a stable behavior and security model. +_Avoid_: runner, shell mode, host mode + +**Sandcastle Sandbox**: +A Sandbox Provider backed by Sandcastle for isolated execution, selected as a first-class option rather than an ad hoc flag bundle. +_Avoid_: custom flags, one-off command override + +**Sandbox Selector**: +A dedicated user-facing option for choosing a Sandbox Provider, independent from raw Copilot flag passthrough. +_Avoid_: hidden flag parsing, implicit provider inference + +**Permission Profile**: +A separate user-facing option that controls tool-consent behavior for a session, independent from Sandbox Provider selection. +_Avoid_: sandbox-implies-permissions, bundled execution mode + +**Sandbox Availability Policy**: +The rule applied when a selected Sandbox Provider cannot be used. Default policy is fail fast with a clear error. +_Avoid_: silent fallback, best-effort provider swap + +**Sandbox Coverage**: +The requirement that Sandbox Selector behavior applies uniformly to every Squad-managed Copilot spawn path. +_Avoid_: partial command support, path-specific semantics + +**Sandbox Provider Enum**: +The canonical allowed Sandbox Provider values are `copilot` and `sandcastle`. +_Avoid_: free-form provider strings, implicit provider aliases + +**Sandbox Precedence**: +When multiple sources specify sandbox, resolution order is CLI flag, then config file, then environment variable, then default `copilot`. +_Avoid_: source-order ambiguity, hidden precedence rules + +**Sandcastle Profile Scope**: +Version 1 exposes Sandcastle as a single default profile. Provider-internal engine selection is deferred to a later version. +_Avoid_: multi-axis v1 configuration, premature engine-specific UX + +**Sandbox Override Conflict Policy**: +Selecting a sandbox and specifying a custom agent command override at the same time is invalid by default and fails fast. +_Avoid_: override-bypasses-sandbox, silent command precedence + +**Permission Profile Enum**: +The canonical allowed Permission Profile values are `interactive`, `yolo`, and `autopilot`. +_Avoid_: free-form approval flags, implicit permission modes + +**Execution Surface Flags**: +The canonical CLI options are `--sandbox ` and `--permission-profile `. +_Avoid_: command-specific naming drift, hidden aliases + +**Execution Surface Environment Variables**: +The canonical environment variable options are `SQUAD_SANDBOX` and `SQUAD_PERMISSION_PROFILE`. +_Avoid_: undocumented env aliases, mismatched naming with CLI flags + +**Execution Error Codes**: +Fail-fast execution configuration errors use stable machine-readable codes: `SQUAD_SANDBOX_UNAVAILABLE`, `SQUAD_SANDBOX_OVERRIDE_CONFLICT`, `SQUAD_SANDBOX_INVALID_VALUE`, and `SQUAD_PERMISSION_PROFILE_INVALID_VALUE`. +_Avoid_: message-only error contracts, unstable code names + +**Execution Telemetry Dimensions**: +Each Squad-managed spawn records low-cardinality execution config fields: `sandbox_provider`, `permission_profile`, `source_of_truth`, and `conflict_blocked`. +_Avoid_: high-cardinality payloads, opaque precedence telemetry diff --git a/README.md b/README.md index b2ab79a3d..b2ea5f65e 100644 --- a/README.md +++ b/README.md @@ -142,7 +142,7 @@ Say **"squad commands"** in chat to see a categorized menu of common operations | `squad upgrade` | Update Squad-owned files to latest; never touches your team state; use `--global` to upgrade personal squad, `--migrate-directory` to rename `.ai-team/` → `.squad/` | | `squad upgrade --self` | Update the Squad CLI package itself; add `--insider` for dev-channel prerelease builds | | `squad status` | Show which squad is active and why | -| `squad triage` | **Watch mode** — poll for issues and auto-triage to team (aliases: `watch`, `loop`); use `--interval ` to set polling frequency (default: 10); with `--execute` dispatch Copilot agents; use `--agent-cmd`, `--copilot-flags`, `--auth-user` to customize agent execution; `--health` shows watch status; `--log-file` for diagnostics | +| `squad triage` | **Watch mode** — poll for issues and auto-triage to team (alias: `watch`); use `--interval ` to set polling frequency (default: 10); with `--execute` dispatch Copilot agents; use `--sandbox`, `--permission-profile`, `--agent-cmd`, `--copilot-flags`, `--auth-user` to customize execution; `--health` shows watch status; `--log-file` for diagnostics | | `squad copilot` | Add/remove the Copilot coding agent (@copilot); use `--off` to remove, `--auto-assign` to enable auto-assignment | | `squad doctor` | Check your setup and diagnose issues (alias: `heartbeat`) | | `squad link ` | Connect to a remote team | @@ -163,6 +163,8 @@ Say **"squad commands"** in chat to see a categorized menu of common operations Ralph continuously polls for work and dispatches agents to handle it. Watch mode helps a human team stay responsive — Ralph automates triage, execution handoffs, and monitoring, then escalates back to people when judgment or approval is needed. +For full sandbox and permission-profile behavior (resolution precedence, validation rules, and error codes), see [SANDBOX.md](SANDBOX.md). + ### Quick Start ```bash @@ -178,6 +180,12 @@ npx @bradygaster/squad-cli watch --execute \ --copilot-flags "--yolo --autopilot --mcp mail --agent squad" \ --auth-user myaccount +# Run with Sandcastle sandbox and autopilot profile +npx @bradygaster/squad-cli watch --execute \ + --sandbox sandcastle \ + --sandbox-flags "--your-sandcastle-flag value" \ + --permission-profile autopilot + # Run watch with diagnostics npx @bradygaster/squad-cli watch --execute --log-file ./watch.log --verbose @@ -191,6 +199,9 @@ npx @bradygaster/squad-cli watch --health |------|-------------| | `--execute` | Enable agent execution (spawn Copilot sessions for actionable issues) | | `--interval N` | Poll every N minutes (default: 10) | +| `--sandbox ` | Execution sandbox (`copilot` or `sandcastle`; default: `copilot`) | +| `--sandbox-flags "..."` | Extra provider flags passed to `sandcastle` when sandbox is `sandcastle` | +| `--permission-profile ` | Copilot permission profile (`interactive`, `yolo`, `autopilot`; default: `yolo`) | | `--agent-cmd` | Custom agent command (default: `gh copilot`) | | `--copilot-flags` | Flags passed to the agent runner (e.g., `--yolo --autopilot`) | | `--auth-user` | GitHub/Azure DevOps account to use for agent auth | @@ -202,6 +213,32 @@ npx @bradygaster/squad-cli watch --health | `--notify-level` | Control output verbosity (`all` / `important` / `none`, default: `important`) | | `--state-backend` | Persistence strategy (`git-notes` or `orphan-branch`, default: in-memory) | +### Sandbox Resolution and Validation + +Watch (`squad watch` / `squad triage`) and loop (`squad loop`) resolve execution settings with this precedence: + +1. CLI flags (`--sandbox`, `--permission-profile`) +2. `.squad/config.json` values +3. Environment variables (`SQUAD_SANDBOX`, `SQUAD_PERMISSION_PROFILE`) +4. Built-in defaults (`sandbox=copilot`, `permission-profile=yolo`) + +Validation rules: + +- `--sandbox` supports `copilot` and `sandcastle`. +- With `--sandbox sandcastle`, Squad maps prompt args to Sandcastle (`-p/--prompt` -> `--prompt`) and does not forward Copilot-only permission flags. +- `--sandbox-flags` is passed to sandcastle when `--sandbox sandcastle` is active. +- `--permission-profile` supports `interactive`, `yolo`, and `autopilot`. +- `sandcastle` requires the `sandcastle` binary to be available on PATH. +- `squad start` and `squad copilot-bridge` require `sandbox=copilot` because they depend on Copilot ACP/PTY behavior. +- Explicit sandbox selection conflicts with `--agent-cmd` overrides. + +Stable error codes surfaced by the CLI: + +- `SQUAD_SANDBOX_UNAVAILABLE` +- `SQUAD_SANDBOX_OVERRIDE_CONFLICT` +- `SQUAD_SANDBOX_INVALID_VALUE` +- `SQUAD_PERMISSION_PROFILE_INVALID_VALUE` + ### How Watch Decides What to Execute Ralph uses an **agent-delegated selection pattern**: diff --git a/SANDBOX.md b/SANDBOX.md new file mode 100644 index 000000000..3825953d3 --- /dev/null +++ b/SANDBOX.md @@ -0,0 +1,173 @@ +# Sandbox and Permission Profiles + +This document explains how Squad chooses an execution sandbox and permission profile for agent runs. + +## Why This Exists + +Squad supports two execution controls: + +- Sandbox provider: where the agent process runs (`copilot` or `sandcastle`) +- Permission profile: how permissive the agent run should be (`interactive`, `yolo`, `autopilot`) + +These controls are first-class for watch/triage and loop flows, with deterministic precedence and stable error codes. + +## Supported Values + +Sandbox providers: + +- `copilot` (default) +- `sandcastle` + +Permission profiles: + +- `interactive` +- `yolo` (default) +- `autopilot` + +## Install Sandcastle + +Sandcastle is not bundled with Squad. Install it separately and make sure the +`sandcastle` executable is on your PATH. + +Project: https://github.com/mattpocock/sandcastle + +Install command: + +```bash +npm install -g @ai-hero/sandcastle +``` + +Important: install the scoped package `@ai-hero/sandcastle`. There are unrelated +packages named `sandcastle` on npm that do not provide the expected CLI surface. + +Verify install: + +```bash +sandcastle --help +``` + +If this command fails, Squad will fail fast with `SQUAD_SANDBOX_UNAVAILABLE` +when `sandcastle` is selected. + +## Resolution Precedence + +Execution config is resolved in this order: + +1. CLI flags +2. Project config (`.squad/config.json`) +3. Environment variables +4. Built-in defaults + +Concrete inputs: + +- CLI: `--sandbox`, `--permission-profile` +- Config: `sandbox`, `permissionProfile` +- Env: `SQUAD_SANDBOX`, `SQUAD_PERMISSION_PROFILE`, `SQUAD_SANDBOX_FLAGS` +- Defaults: `sandbox=copilot`, `permissionProfile=yolo` + +## Validation Rules + +- Invalid sandbox value fails fast. +- Invalid permission profile value fails fast. +- `sandcastle` requires a working `sandcastle` binary on PATH. +- Explicit sandbox selection conflicts with `--agent-cmd` override. + +## Stable Error Codes + +These codes are intentionally stable for automation and CI checks: + +- `SQUAD_SANDBOX_UNAVAILABLE` +- `SQUAD_SANDBOX_OVERRIDE_CONFLICT` +- `SQUAD_SANDBOX_INVALID_VALUE` +- `SQUAD_PERMISSION_PROFILE_INVALID_VALUE` + +## Permission Profile Behavior + +Permission profiles normalize flags passed to Copilot CLI: + +- `interactive`: strips `--yolo` and `--autopilot` +- `yolo`: ensures `--yolo` +- `autopilot`: ensures both `--yolo` and `--autopilot` + +This makes behavior deterministic even if user-provided extra flags include permission switches. + +Note: permission profile flags are a Copilot concern. They are not forwarded to +Sandcastle. + +## Using Sandcastle Options from Squad + +Current status: + +- First-class sandbox selection (`--sandbox sandcastle`) validates and selects + the sandcastle executable for supported execution paths. +- Provider-specific sandcastle flags are supported via `--sandbox-flags "..."`. +- Prompt arguments are mapped for sandcastle compatibility: + - `-p ` / `--prompt ` -> `--prompt ` + - `--prompt-file ` is passed through +- Copilot-specific flags (for example `--yolo`, `--autopilot`, MCP-injection flags) + are not forwarded to sandcastle. + +What you can do today: + +1. Use first-class sandbox/profile controls for policy and validation: + - `--sandbox sandcastle` + - `--sandbox-flags "..."` + - `--permission-profile ` +2. Do not combine explicit sandbox selection with `--agent-cmd` in the same + invocation; that combination is blocked by design (`SQUAD_SANDBOX_OVERRIDE_CONFLICT`). + +Example watch invocation with provider flags: + +```bash +squad watch --execute \ + --sandbox sandcastle \ + --sandbox-flags "--your-flag value" \ + --permission-profile autopilot +``` + +## Where It Applies + +Primary command paths: + +- `squad watch` +- `squad triage` (watch alias) +- `squad loop` + +Additional spawn paths also use the same execution resolver for consistency: + +- `squad start` (requires `sandbox=copilot`) +- `squad copilot-bridge` (requires `sandbox=copilot`) + +## Examples + +Watch with Sandcastle + autopilot: + +```bash +squad watch --execute --sandbox sandcastle --permission-profile autopilot +``` + +Loop with interactive profile: + +```bash +squad loop --permission-profile interactive +``` + +Environment-driven defaults: + +```bash +export SQUAD_SANDBOX=sandcastle +export SQUAD_PERMISSION_PROFILE=yolo +squad watch --execute +``` + +## Troubleshooting + +If you see sandbox/profile errors: + +1. Run `squad watch --help` or `squad loop --help` to verify accepted values. +2. Confirm `sandcastle` is installed and available in PATH if selected. +3. Remove `--agent-cmd` when using explicit sandbox selection. +4. Check for unexpected env vars: + - `SQUAD_SANDBOX` + - `SQUAD_PERMISSION_PROFILE` +5. Check `.squad/config.json` for stale values overriding your defaults. diff --git a/packages/squad-cli/src/cli-entry.ts b/packages/squad-cli/src/cli-entry.ts index b7f231266..d4b3c7be1 100644 --- a/packages/squad-cli/src/cli-entry.ts +++ b/packages/squad-cli/src/cli-entry.ts @@ -155,10 +155,10 @@ async function main(): Promise { console.log(` Flags: --global (init in personal squad directory)`); console.log(` ${BOLD}init${RESET} Initialize Squad (markdown-only, default)`); console.log(` Flags: --sdk (SDK builder syntax)`); - console.log(` --roles (use base roles)`); - console.log(` --global (personal squad dir)`); - console.log(` --no-workflows (skip CI setup)`); - console.log(` --preset (apply a preset after init)`); + console.log(` --roles (use base roles)`); + console.log(` --global (personal squad dir)`); + console.log(` --no-workflows (skip CI setup)`); + console.log(` --preset (apply a preset after init)`); console.log(` --state-backend (local|orphan|two-layer)`); console.log(` Usage: init --mode remote `); console.log(` Creates .squad/config.json pointing to an external team root`); @@ -183,6 +183,9 @@ async function main(): Promise { console.log(` Default: checks every 10 minutes (Ctrl+C to stop)`); console.log(` Core flags:`); console.log(` --execute (spawn agents to work on issues)`); + console.log(` --sandbox (execution provider)`); + console.log(` --sandbox-flags "..." (extra sandcastle flags)`); + console.log(` --permission-profile `); console.log(` --copilot-flags "..." (extra copilot CLI flags)`); console.log(` --max-concurrent N (parallel issue limit, default 1)`); console.log(` --timeout N (max minutes per issue, default 30)`); @@ -203,6 +206,9 @@ async function main(): Promise { console.log(` Reads loop.md and runs it each cycle (no issues needed)`); console.log(` Flags: --init (generate boilerplate loop.md)`); console.log(` --file (custom loop file)`); + console.log(` --sandbox (execution provider)`); + console.log(` --sandbox-flags "..." (extra sandcastle flags)`); + console.log(` --permission-profile `); console.log(` --monitor-email, --monitor-teams (add monitoring)`); console.log(` ${BOLD}hire${RESET} Team creation wizard`); console.log(` Usage: hire [--name ] [--role ]`); @@ -225,10 +231,10 @@ async function main(): Promise { console.log(` ${BOLD}nap${RESET} Context hygiene (compress, prune, archive .squad/ state)`); console.log(` Usage: nap [--deep] [--dry-run]`); console.log(` Flags: --deep (thorough cleanup), --dry-run (preview only)`); - console.log(` ${BOLD}memory${RESET} Governed memory operations`); + console.log(` ${BOLD}memory${RESET} Governed memory operations`); console.log(` Usage: memory write --content "..." --class LOCAL`); console.log(` Diagnostics: --log-level info|debug or --verbose`); - console.log(` ${BOLD}state-mcp${RESET} MCP bridge exposing Squad runtime state tools`); + console.log(` ${BOLD}state-mcp${RESET} MCP bridge exposing Squad runtime state tools`); console.log(` ${BOLD}doctor${RESET} Validate squad setup (check files, config, health)`); console.log(` ${BOLD}consult${RESET} Enter consult mode with your personal squad`); console.log(` Flags: --status, --check`); @@ -345,9 +351,9 @@ async function main(): Promise { const sdkMod = hasGlobal ? await lazySquadSdk() : null; const dest = hasGlobal ? sdkMod!.resolveGlobalSquadPath() : process.cwd(); - const noWorkflows = args.includes('--no-workflows'); - const mcpFrontmatter = args.includes('--mcp-frontmatter'); - const sdk = args.includes('--sdk'); + const noWorkflows = args.includes('--no-workflows'); + const mcpFrontmatter = args.includes('--mcp-frontmatter'); + const sdk = args.includes('--sdk'); const roles = args.includes('--roles'); const presetIdx = args.indexOf('--preset'); const presetName = (presetIdx !== -1 && args[presetIdx + 1]) ? args[presetIdx + 1] : undefined; @@ -355,7 +361,7 @@ async function main(): Promise { const sbIdx = args.indexOf('--state-backend'); const initStateBackend = (sbIdx !== -1 && args[sbIdx + 1]) ? args[sbIdx + 1] : undefined; // Global init: suppress workflows (no GitHub CI in ~/.config/squad/) and bootstrap personal squad - runInit(dest, { includeWorkflows: !noWorkflows && !hasGlobal, sdk, roles, isGlobal: hasGlobal, stateBackend: initStateBackend, mcpFrontmatter }).then(async () => { + runInit(dest, { includeWorkflows: !noWorkflows && !hasGlobal, sdk, roles, isGlobal: hasGlobal, stateBackend: initStateBackend, mcpFrontmatter }).then(async () => { if (presetName) { const { seedBuiltinPresets, applyPreset } = await import('@bradygaster/squad-sdk/presets'); const { resolvePresetsDir, ensureSquadHome } = await import('@bradygaster/squad-sdk/resolution'); @@ -504,8 +510,8 @@ async function main(): Promise { const { runStateMcp } = await import('./cli/commands/state-mcp.js'); await runStateMcp(getSquadStartDir()); return; - } - + } + if (cmd === 'sync') { const { runSync } = await import('./cli/commands/sync.js'); const quiet = args.includes('--quiet'); @@ -567,6 +573,21 @@ async function main(): Promise { ? args[agentCmdIdx + 1] : undefined; + const sandboxIdx = args.indexOf('--sandbox'); + const sandbox = (sandboxIdx !== -1 && args[sandboxIdx + 1]) + ? args[sandboxIdx + 1] + : undefined; + + const sandboxFlagsIdx = args.indexOf('--sandbox-flags'); + const sandboxFlags = (sandboxFlagsIdx !== -1 && args[sandboxFlagsIdx + 1]) + ? args[sandboxFlagsIdx + 1] + : undefined; + + const permissionProfileIdx = args.indexOf('--permission-profile'); + const permissionProfile = (permissionProfileIdx !== -1 && args[permissionProfileIdx + 1]) + ? args[permissionProfileIdx + 1] + : undefined; + const maxConcurrentIdx = args.indexOf('--max-concurrent'); const maxConcurrent = (maxConcurrentIdx !== -1 && args[maxConcurrentIdx + 1]) ? parseInt(args[maxConcurrentIdx + 1]!, 10) @@ -669,31 +690,44 @@ async function main(): Promise { } // Load config: .squad/config.json merged with CLI overrides - const config = loadWatchConfig(getSquadStartDir(), { - interval, - execute, - maxConcurrent, - timeout, - copilotFlags, - agentCmd, - verbose, - dispatchMode, - logFile, - authUser, - notifyLevel, - overnightStart, - overnightEnd, - sentinelFile, - stateBackend: mappedBackend, - stateContext, - capabilities: Object.keys(capabilities).length > 0 ? capabilities : undefined, - }); + let config; + try { + config = loadWatchConfig(getSquadStartDir(), { + interval, + execute, + maxConcurrent, + timeout, + copilotFlags, + agentCmd, + sandbox: sandbox as 'copilot' | 'sandcastle' | undefined, + sandboxFlags, + permissionProfile: permissionProfile as 'interactive' | 'yolo' | 'autopilot' | undefined, + verbose, + dispatchMode, + logFile, + authUser, + notifyLevel, + overnightStart, + overnightEnd, + sentinelFile, + stateBackend: mappedBackend, + stateContext, + capabilities: Object.keys(capabilities).length > 0 ? capabilities : undefined, + }); + } catch (err) { + const maybeCode = (err as { code?: string }).code; + if (typeof maybeCode === 'string' && maybeCode.startsWith('SQUAD_')) { + fatal(`${maybeCode}: ${(err as Error).message}`); + } + throw err; + } // After parsing all flags, check for positional args that look like prompts. // Skip values that follow known value-flags (e.g. "--interval 5" → "5" is not positional). const knownValueFlags = new Set([ '--interval', '--copilot-flags', '--agent-cmd', '--max-concurrent', '--timeout', '--board-project', '--board-owner', '--auth-user', '--dispatch-mode', '--log-file', '--notify-level', '--overnight-start', '--overnight-end', '--sentinel-file', '--state-backend', + '--sandbox', '--sandbox-flags', '--permission-profile', ]); const watchArgStart = args.indexOf(cmd) + 1; const watchArgs = args.slice(watchArgStart); @@ -756,6 +790,21 @@ async function main(): Promise { ? args[agentCmdIdx + 1] : undefined; + const sandboxIdx = args.indexOf('--sandbox'); + const sandbox = (sandboxIdx !== -1 && args[sandboxIdx + 1]) + ? args[sandboxIdx + 1] + : undefined; + + const sandboxFlagsIdx = args.indexOf('--sandbox-flags'); + const sandboxFlags = (sandboxFlagsIdx !== -1 && args[sandboxFlagsIdx + 1]) + ? args[sandboxFlagsIdx + 1] + : undefined; + + const permissionProfileIdx = args.indexOf('--permission-profile'); + const permissionProfile = (permissionProfileIdx !== -1 && args[permissionProfileIdx + 1]) + ? args[permissionProfileIdx + 1] + : undefined; + // Capability flags const { createDefaultRegistry: createReg } = await import('./cli/commands/watch/index.js'); const reg = createReg(); @@ -771,6 +820,9 @@ async function main(): Promise { timeout, copilotFlags, agentCmd, + sandbox: sandbox as 'copilot' | 'sandcastle' | undefined, + sandboxFlags, + permissionProfile: permissionProfile as 'interactive' | 'yolo' | 'autopilot' | undefined, capabilities, }); return; diff --git a/packages/squad-cli/src/cli/commands/copilot-bridge.ts b/packages/squad-cli/src/cli/commands/copilot-bridge.ts index 31c25f6c8..91a6f0012 100644 --- a/packages/squad-cli/src/cli/commands/copilot-bridge.ts +++ b/packages/squad-cli/src/cli/commands/copilot-bridge.ts @@ -7,7 +7,8 @@ import { spawn, execSync, type ChildProcess } from 'node:child_process'; import { createInterface } from 'node:readline'; -import { withAdditionalMcpConfig } from '../core/copilot-invocation.js'; +import { resolveExecutionConfig, ExecutionConfigError } from '../core/execution-config.js'; +import { buildSandboxCommand } from './sandbox-command.js'; export interface CopilotBridgeConfig { cwd: string; @@ -77,11 +78,34 @@ export class CopilotBridge { args.push('--agent', this.config.agent); } - // Inject project mcp-config so squad_state MCP tools register (Copilot - // CLI 1.0.58 ignores the project-level .copilot/mcp-config.json). - const finalArgs = withAdditionalMcpConfig('copilot', args, this.config.cwd); + let execution; + try { + execution = resolveExecutionConfig({ + envSandbox: process.env['SQUAD_SANDBOX'], + envPermissionProfile: process.env['SQUAD_PERMISSION_PROFILE'], + }); + } catch (err) { + if (err instanceof ExecutionConfigError) { + throw new Error(`${err.code}: ${err.message}`); + } + throw err; + } + + if (execution.sandbox === 'sandcastle') { + throw new Error( + 'SQUAD_SANDBOX_UNSUPPORTED_MODE: squad copilot-bridge requires sandbox=copilot because it depends on --acp --stdio.', + ); + } + + const sandboxCommand = buildSandboxCommand({ + sandbox: execution.sandbox, + sandboxFlags: process.env['SQUAD_SANDBOX_FLAGS'], + permissionProfile: execution.permissionProfile, + teamRoot: this.config.cwd, + baseArgs: args, + }); - this.child = spawn('copilot', finalArgs, { + this.child = spawn(sandboxCommand.cmd, sandboxCommand.args, { cwd: this.config.cwd, stdio: ['pipe', 'pipe', 'pipe'], }); diff --git a/packages/squad-cli/src/cli/commands/loop.ts b/packages/squad-cli/src/cli/commands/loop.ts index e52432e83..870e74efc 100644 --- a/packages/squad-cli/src/cli/commands/loop.ts +++ b/packages/squad-cli/src/cli/commands/loop.ts @@ -14,7 +14,12 @@ import { fileURLToPath } from 'node:url'; import { effectiveSquadDir } from '../core/effective-squad-dir.js'; import { fatal } from '../core/errors.js'; import { GREEN, RED, DIM, BOLD, RESET, YELLOW } from '../core/output.js'; -import { withAdditionalMcpConfig } from '../core/copilot-invocation.js'; +import { + resolveExecutionConfig, + ExecutionConfigError, + type ResolvedExecutionConfig, +} from '../core/execution-config.js'; +import { buildSandboxCommand } from './sandbox-command.js'; import { CapabilityRegistry, createDefaultRegistry, @@ -53,6 +58,12 @@ export interface LoopConfig { copilotFlags?: string; /** Fully override the agent command (e.g., `gh copilot --yolo`). */ agentCmd?: string; + /** First-class sandbox selector. */ + sandbox?: 'copilot' | 'sandcastle'; + /** Extra flags passed to sandcastle when sandbox=sandcastle. */ + sandboxFlags?: string; + /** First-class permission profile. */ + permissionProfile?: 'interactive' | 'yolo' | 'autopilot'; /** Capability overrides, keyed by capability name. */ capabilities: Record>; } @@ -133,7 +144,14 @@ export function generateLoopFile(): string { function buildLoopAgentCommand( prompt: string, - options: { agentCmd?: string; copilotFlags?: string; teamRoot?: string }, + options: { + agentCmd?: string; + copilotFlags?: string; + teamRoot?: string; + sandbox?: 'copilot' | 'sandcastle'; + sandboxFlags?: string; + permissionProfile?: 'interactive' | 'yolo' | 'autopilot'; + }, ): { cmd: string; args: string[] } { if (options.agentCmd) { const parts = options.agentCmd.trim().split(/\s+/); @@ -143,7 +161,13 @@ function buildLoopAgentCommand( if (options.copilotFlags) { args.push(...options.copilotFlags.trim().split(/\s+/)); } - return { cmd: 'copilot', args: withAdditionalMcpConfig('copilot', args, options.teamRoot) }; + return buildSandboxCommand({ + sandbox: options.sandbox, + sandboxFlags: options.sandboxFlags, + permissionProfile: options.permissionProfile, + teamRoot: options.teamRoot, + baseArgs: args, + }); } // ── Capability Phase Runner ────────────────────────────────────── @@ -314,6 +338,22 @@ export async function runLoop(dest: string, options: LoopConfig): Promise fatal('timeout must be a positive number of minutes'); } + let execution: ResolvedExecutionConfig; + try { + execution = resolveExecutionConfig({ + cliSandbox: options.sandbox, + cliPermissionProfile: options.permissionProfile, + envSandbox: process.env['SQUAD_SANDBOX'], + envPermissionProfile: process.env['SQUAD_PERMISSION_PROFILE'], + agentCmd: options.agentCmd, + }); + } catch (err) { + if (err instanceof ExecutionConfigError) { + fatal(`${err.code}: ${err.message}`); + } + throw err; + } + // Preflight: verify copilot CLI is available (skip if user overrides the agent command) if (!options.agentCmd) { try { @@ -331,6 +371,10 @@ export async function runLoop(dest: string, options: LoopConfig): Promise timeout: timeoutMinutes, copilotFlags: options.copilotFlags, agentCmd: options.agentCmd, + sandbox: execution.sandbox, + sandboxFlags: options.sandboxFlags ?? process.env['SQUAD_SANDBOX_FLAGS'], + permissionProfile: execution.permissionProfile, + executionSource: execution.sourceOfTruth, capabilities: options.capabilities, }; @@ -355,6 +399,10 @@ export async function runLoop(dest: string, options: LoopConfig): Promise config: {}, agentCmd: options.agentCmd, copilotFlags: options.copilotFlags, + sandbox: execution.sandbox, + sandboxFlags: watchConfig.sandboxFlags, + permissionProfile: execution.permissionProfile, + executionSource: execution.sourceOfTruth, }; // Preflight capabilities (pre-scan + housekeeping only) @@ -367,6 +415,9 @@ export async function runLoop(dest: string, options: LoopConfig): Promise if (options.copilotFlags) { console.log(`${DIM}Copilot flags: ${options.copilotFlags}${RESET}`); } + console.log( + `${DIM}Execution: sandbox=${execution.sandbox}, profile=${execution.permissionProfile}, source=${execution.sourceOfTruth}${RESET}`, + ); console.log(); let round = 0; @@ -387,6 +438,9 @@ export async function runLoop(dest: string, options: LoopConfig): Promise agentCmd: options.agentCmd, copilotFlags: options.copilotFlags, teamRoot, + sandbox: execution.sandbox, + sandboxFlags: watchConfig.sandboxFlags, + permissionProfile: execution.permissionProfile, }); console.log(`${GREEN}▶${RESET} [${ts}] Round ${round} — running loop prompt`); diff --git a/packages/squad-cli/src/cli/commands/sandbox-command.ts b/packages/squad-cli/src/cli/commands/sandbox-command.ts new file mode 100644 index 000000000..796688b30 --- /dev/null +++ b/packages/squad-cli/src/cli/commands/sandbox-command.ts @@ -0,0 +1,76 @@ +import { withAdditionalMcpConfig } from '../core/copilot-invocation.js'; +import { + applyPermissionProfileArgs, + type PermissionProfile, + type SandboxProvider, +} from '../core/execution-config.js'; + +function splitFlags(flags: string | undefined): string[] { + if (!flags) return []; + return flags.trim().split(/\s+/).filter(Boolean); +} + +export interface SandboxCommandOptions { + sandbox?: SandboxProvider; + permissionProfile?: PermissionProfile; + teamRoot?: string; + baseArgs: string[]; + sandboxFlags?: string; +} + +function extractSandcastleArgs(baseArgs: string[]): string[] { + const args: string[] = []; + + for (let i = 0; i < baseArgs.length; i += 1) { + const token = baseArgs[i]; + if (!token) continue; + + if (token === '-p' || token === '--prompt') { + const value = baseArgs[i + 1]; + if (value) { + args.push('--prompt', value); + i += 1; + } + continue; + } + + if (token === '--prompt-file') { + const value = baseArgs[i + 1]; + if (value) { + args.push('--prompt-file', value); + i += 1; + } + continue; + } + + if (token.startsWith('--prompt=')) { + const value = token.slice('--prompt='.length); + if (value) args.push('--prompt', value); + continue; + } + + if (token.startsWith('--prompt-file=')) { + const value = token.slice('--prompt-file='.length); + if (value) args.push('--prompt-file', value); + continue; + } + } + + return args; +} + +export function buildSandboxCommand(options: SandboxCommandOptions): { cmd: string; args: string[] } { + const sandbox = options.sandbox ?? 'copilot'; + const permissionProfile = options.permissionProfile ?? 'yolo'; + + if (sandbox === 'sandcastle') { + const args = [ + ...splitFlags(options.sandboxFlags), + ...extractSandcastleArgs(options.baseArgs), + ]; + return { cmd: 'sandcastle', args }; + } + + const withMcp = withAdditionalMcpConfig('copilot', options.baseArgs, options.teamRoot); + return { cmd: 'copilot', args: applyPermissionProfileArgs(withMcp, permissionProfile) }; +} diff --git a/packages/squad-cli/src/cli/commands/start.ts b/packages/squad-cli/src/cli/commands/start.ts index 35c574f60..62d8e9ddd 100644 --- a/packages/squad-cli/src/cli/commands/start.ts +++ b/packages/squad-cli/src/cli/commands/start.ts @@ -14,7 +14,7 @@ import path from 'node:path'; import { createReadStream } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { FSStorageProvider, RemoteBridge } from '@bradygaster/squad-sdk'; -import { withAdditionalMcpConfig } from '../core/copilot-invocation.js'; +import { resolveExecutionConfig, ExecutionConfigError } from '../core/execution-config.js'; const storage = new FSStorageProvider(); import type { RemoteBridgeConfig } from '@bradygaster/squad-sdk'; @@ -25,6 +25,7 @@ import { getMachineId, getGitInfo, } from './rc-tunnel.js'; +import { buildSandboxCommand } from './sandbox-command.js'; const BOLD = '\x1b[1m'; const RESET = '\x1b[0m'; @@ -167,14 +168,45 @@ export async function runStart(cwd: string, options: StartOptions): Promise { diff --git a/packages/squad-cli/src/cli/commands/watch/capabilities/execute.ts b/packages/squad-cli/src/cli/commands/watch/capabilities/execute.ts index 4aa367523..1da4e1502 100644 --- a/packages/squad-cli/src/cli/commands/watch/capabilities/execute.ts +++ b/packages/squad-cli/src/cli/commands/watch/capabilities/execute.ts @@ -9,7 +9,7 @@ import type { WatchCapability, WatchContext, PreflightResult, CapabilityResult } import type { MachineCapabilities } from '@bradygaster/squad-sdk/ralph/capabilities'; import { createVerboseLogger } from '../verbose.js'; import { loadAgentCharter } from '../../../shell/spawn.js'; -import { withAdditionalMcpConfig } from '../../../core/copilot-invocation.js'; +import { buildSandboxCommand } from '../../sandbox-command.js'; /** Normalized work item for execution. */ export interface ExecutableWorkItem { @@ -62,7 +62,13 @@ function buildAgentCommand( if (context.copilotFlags) { args.push(...context.copilotFlags.trim().split(/\s+/)); } - return { cmd: 'copilot', args: withAdditionalMcpConfig('copilot', args, context.teamRoot) }; + return buildSandboxCommand({ + sandbox: context.sandbox, + sandboxFlags: context.sandboxFlags, + permissionProfile: context.permissionProfile, + teamRoot: context.teamRoot, + baseArgs: args, + }); } /** Labels that indicate an issue should not be auto-executed. */ diff --git a/packages/squad-cli/src/cli/commands/watch/capabilities/monitor-email.ts b/packages/squad-cli/src/cli/commands/watch/capabilities/monitor-email.ts index 24e7eaf57..68b3064f5 100644 --- a/packages/squad-cli/src/cli/commands/watch/capabilities/monitor-email.ts +++ b/packages/squad-cli/src/cli/commands/watch/capabilities/monitor-email.ts @@ -4,7 +4,7 @@ import { execFile } from 'node:child_process'; import type { WatchCapability, WatchContext, PreflightResult, CapabilityResult } from '../types.js'; -import { withAdditionalMcpConfig } from '../../../core/copilot-invocation.js'; +import { buildSandboxCommand } from '../../sandbox-command.js'; function buildAgentCommand(prompt: string, context: WatchContext): { cmd: string; args: string[] } { if (context.agentCmd) { @@ -13,7 +13,13 @@ function buildAgentCommand(prompt: string, context: WatchContext): { cmd: string } const args = ['-p', prompt]; if (context.copilotFlags) args.push(...context.copilotFlags.trim().split(/\s+/)); - return { cmd: 'copilot', args: withAdditionalMcpConfig('copilot', args, context.teamRoot) }; + return buildSandboxCommand({ + sandbox: context.sandbox, + sandboxFlags: context.sandboxFlags, + permissionProfile: context.permissionProfile, + teamRoot: context.teamRoot, + baseArgs: args, + }); } function spawnWithTimeout(cmd: string, args: string[], cwd: string, timeoutMs: number): Promise { diff --git a/packages/squad-cli/src/cli/commands/watch/capabilities/monitor-teams.ts b/packages/squad-cli/src/cli/commands/watch/capabilities/monitor-teams.ts index 3989f06b1..6fc4f36db 100644 --- a/packages/squad-cli/src/cli/commands/watch/capabilities/monitor-teams.ts +++ b/packages/squad-cli/src/cli/commands/watch/capabilities/monitor-teams.ts @@ -4,7 +4,7 @@ import { execFile } from 'node:child_process'; import type { WatchCapability, WatchContext, PreflightResult, CapabilityResult } from '../types.js'; -import { withAdditionalMcpConfig } from '../../../core/copilot-invocation.js'; +import { buildSandboxCommand } from '../../sandbox-command.js'; /** Build agent command from prompt, respecting --agent-cmd. */ function buildAgentCommand(prompt: string, context: WatchContext): { cmd: string; args: string[] } { @@ -14,7 +14,13 @@ function buildAgentCommand(prompt: string, context: WatchContext): { cmd: string } const args = ['-p', prompt]; if (context.copilotFlags) args.push(...context.copilotFlags.trim().split(/\s+/)); - return { cmd: 'copilot', args: withAdditionalMcpConfig('copilot', args, context.teamRoot) }; + return buildSandboxCommand({ + sandbox: context.sandbox, + sandboxFlags: context.sandboxFlags, + permissionProfile: context.permissionProfile, + teamRoot: context.teamRoot, + baseArgs: args, + }); } function spawnWithTimeout(cmd: string, args: string[], cwd: string, timeoutMs: number): Promise { diff --git a/packages/squad-cli/src/cli/commands/watch/capabilities/retro.ts b/packages/squad-cli/src/cli/commands/watch/capabilities/retro.ts index 160336fb3..9ca72a21f 100644 --- a/packages/squad-cli/src/cli/commands/watch/capabilities/retro.ts +++ b/packages/squad-cli/src/cli/commands/watch/capabilities/retro.ts @@ -6,7 +6,7 @@ import path from 'node:path'; import { execFile } from 'node:child_process'; import { FSStorageProvider } from '@bradygaster/squad-sdk'; import type { WatchCapability, WatchContext, PreflightResult, CapabilityResult } from '../types.js'; -import { withAdditionalMcpConfig } from '../../../core/copilot-invocation.js'; +import { buildSandboxCommand } from '../../sandbox-command.js'; const storage = new FSStorageProvider(); @@ -17,7 +17,13 @@ function buildAgentCommand(prompt: string, context: WatchContext): { cmd: string } const args = ['-p', prompt]; if (context.copilotFlags) args.push(...context.copilotFlags.trim().split(/\s+/)); - return { cmd: 'copilot', args: withAdditionalMcpConfig('copilot', args, context.teamRoot) }; + return buildSandboxCommand({ + sandbox: context.sandbox, + sandboxFlags: context.sandboxFlags, + permissionProfile: context.permissionProfile, + teamRoot: context.teamRoot, + baseArgs: args, + }); } function spawnWithTimeout(cmd: string, args: string[], cwd: string, timeoutMs: number): Promise { diff --git a/packages/squad-cli/src/cli/commands/watch/capabilities/wave-dispatch.ts b/packages/squad-cli/src/cli/commands/watch/capabilities/wave-dispatch.ts index 0befa23f0..9dd31c515 100644 --- a/packages/squad-cli/src/cli/commands/watch/capabilities/wave-dispatch.ts +++ b/packages/squad-cli/src/cli/commands/watch/capabilities/wave-dispatch.ts @@ -4,7 +4,7 @@ import { execFile, type ChildProcess } from 'node:child_process'; import type { WatchCapability, WatchContext, PreflightResult, CapabilityResult } from '../types.js'; -import { withAdditionalMcpConfig } from '../../../core/copilot-invocation.js'; +import { buildSandboxCommand } from '../../sandbox-command.js'; interface SubTask { description: string; @@ -42,7 +42,13 @@ function buildAgentCommand(prompt: string, context: WatchContext): { cmd: string } const args = ['-p', prompt]; if (context.copilotFlags) args.push(...context.copilotFlags.trim().split(/\s+/)); - return { cmd: 'copilot', args: withAdditionalMcpConfig('copilot', args, context.teamRoot) }; + return buildSandboxCommand({ + sandbox: context.sandbox, + sandboxFlags: context.sandboxFlags, + permissionProfile: context.permissionProfile, + teamRoot: context.teamRoot, + baseArgs: args, + }); } function executeSubTask( diff --git a/packages/squad-cli/src/cli/commands/watch/config.ts b/packages/squad-cli/src/cli/commands/watch/config.ts index 2236c29ab..89ccee369 100644 --- a/packages/squad-cli/src/cli/commands/watch/config.ts +++ b/packages/squad-cli/src/cli/commands/watch/config.ts @@ -7,6 +7,12 @@ import path from 'node:path'; import { FSStorageProvider } from '@bradygaster/squad-sdk'; import type { SquadStateContext, StateBackendType } from '@bradygaster/squad-sdk'; +import { + resolveExecutionConfig, + type SandboxProvider, + type PermissionProfile, + type ExecutionSource, +} from '../../core/execution-config.js'; const storage = new FSStorageProvider(); @@ -22,6 +28,14 @@ export interface WatchConfig { copilotFlags?: string; /** Hidden — fully override the agent command. */ agentCmd?: string; + /** First-class sandbox provider. */ + sandbox?: SandboxProvider; + /** Extra sandbox provider flags, used when sandbox=sandcastle. */ + sandboxFlags?: string; + /** First-class permission profile. */ + permissionProfile?: PermissionProfile; + /** Where execution config came from after precedence resolution. */ + executionSource?: ExecutionSource; /** Dispatch mode: 'task' (default 1:1), 'fleet' (batch read-only), 'hybrid' (auto-classify). */ dispatchMode?: DispatchMode; /** Optional path to a log file. When set, console output is tee'd to the file with timestamps. */ @@ -51,6 +65,9 @@ const DEFAULTS: WatchConfig = { execute: false, maxConcurrent: 1, timeout: 30, + sandbox: 'copilot', + permissionProfile: 'yolo', + executionSource: 'default', dispatchMode: undefined, capabilities: {}, }; @@ -88,6 +105,7 @@ export function loadWatchConfig( timeout: cliOverrides.timeout ?? fileConfig.timeout ?? DEFAULTS.timeout, copilotFlags: cliOverrides.copilotFlags ?? fileConfig.copilotFlags ?? DEFAULTS.copilotFlags, agentCmd: cliOverrides.agentCmd ?? fileConfig.agentCmd ?? DEFAULTS.agentCmd, + sandboxFlags: cliOverrides.sandboxFlags ?? fileConfig.sandboxFlags ?? process.env['SQUAD_SANDBOX_FLAGS'], dispatchMode: cliOverrides.dispatchMode ?? fileConfig.dispatchMode ?? DEFAULTS.dispatchMode, logFile: cliOverrides.logFile ?? fileConfig.logFile ?? DEFAULTS.logFile, capabilities: { @@ -105,6 +123,20 @@ export function loadWatchConfig( stateContext: cliOverrides.stateContext, }; + const execution = resolveExecutionConfig({ + cliSandbox: cliOverrides.sandbox, + configSandbox: fileConfig.sandbox, + envSandbox: process.env['SQUAD_SANDBOX'], + cliPermissionProfile: cliOverrides.permissionProfile, + configPermissionProfile: fileConfig.permissionProfile, + envPermissionProfile: process.env['SQUAD_PERMISSION_PROFILE'], + agentCmd: merged.agentCmd, + }); + + merged.sandbox = execution.sandbox; + merged.permissionProfile = execution.permissionProfile; + merged.executionSource = execution.sourceOfTruth; + return merged; } @@ -118,6 +150,11 @@ function normalizeFileConfig(raw: Record): Partial if (typeof raw['timeout'] === 'number') result.timeout = raw['timeout']; if (typeof raw['copilotFlags'] === 'string') result.copilotFlags = raw['copilotFlags']; if (typeof raw['agentCmd'] === 'string') result.agentCmd = raw['agentCmd']; + if (typeof raw['sandboxFlags'] === 'string') result.sandboxFlags = raw['sandboxFlags']; + if (typeof raw['sandbox'] === 'string') result.sandbox = raw['sandbox'] as SandboxProvider; + if (typeof raw['permissionProfile'] === 'string') { + result.permissionProfile = raw['permissionProfile'] as PermissionProfile; + } if (typeof raw['verbose'] === 'boolean') result.verbose = raw['verbose']; if (typeof raw['dispatchMode'] === 'string') { const mode = raw['dispatchMode'] as string; @@ -146,7 +183,12 @@ function normalizeFileConfig(raw: Record): Partial // Everything else is a capability key const caps: Record> = {}; - const reserved = new Set(['interval', 'execute', 'maxConcurrent', 'timeout', 'copilotFlags', 'agentCmd', 'verbose', 'dispatchMode', 'logFile', 'authUser', 'notifyLevel', 'overnightStart', 'overnightEnd', 'sentinelFile', 'stateBackend']); + const reserved = new Set([ + 'interval', 'execute', 'maxConcurrent', 'timeout', 'copilotFlags', 'agentCmd', + 'sandbox', 'sandboxFlags', 'permissionProfile', + 'verbose', 'dispatchMode', 'logFile', 'authUser', 'notifyLevel', + 'overnightStart', 'overnightEnd', 'sentinelFile', 'stateBackend', + ]); for (const [key, value] of Object.entries(raw)) { if (reserved.has(key)) continue; if (typeof value === 'boolean' || (typeof value === 'object' && value !== null && !Array.isArray(value))) { diff --git a/packages/squad-cli/src/cli/commands/watch/index.ts b/packages/squad-cli/src/cli/commands/watch/index.ts index 954be8da1..8967a82b9 100644 --- a/packages/squad-cli/src/cli/commands/watch/index.ts +++ b/packages/squad-cli/src/cli/commands/watch/index.ts @@ -14,7 +14,7 @@ import { FSStorageProvider } from '@bradygaster/squad-sdk'; import { effectiveSquadDir } from '../../core/effective-squad-dir.js'; import { fatal } from '../../core/errors.js'; import { GREEN, RED, DIM, BOLD, RESET, YELLOW } from '../../core/output.js'; -import { withAdditionalMcpConfig } from '../../core/copilot-invocation.js'; +import { buildSandboxCommand } from '../sandbox-command.js'; import { parseRoutingRules, parseModuleOwnership, @@ -565,6 +565,9 @@ export interface WatchOptions { execute?: boolean; copilotFlags?: string; agentCmd?: string; + sandbox?: 'copilot' | 'sandcastle'; + sandboxFlags?: string; + permissionProfile?: 'interactive' | 'yolo' | 'autopilot'; maxConcurrent?: number; issueTimeoutMinutes?: number; monitorTeams?: boolean; @@ -597,6 +600,9 @@ function legacyToConfig(options: WatchOptions): WatchConfig { timeout: options.issueTimeoutMinutes ?? 30, copilotFlags: options.copilotFlags, agentCmd: options.agentCmd, + sandbox: options.sandbox, + sandboxFlags: options.sandboxFlags, + permissionProfile: options.permissionProfile, capabilities, }; } @@ -617,7 +623,13 @@ export function buildAgentCommand( } const args = ['-p', prompt]; if (options.copilotFlags) args.push(...options.copilotFlags.trim().split(/\s+/)); - return { cmd: 'copilot', args: withAdditionalMcpConfig('copilot', args, teamRoot) }; + return buildSandboxCommand({ + sandbox: options.sandbox, + sandboxFlags: options.sandboxFlags, + permissionProfile: options.permissionProfile, + teamRoot, + baseArgs: args, + }); } export async function selfPull(teamRoot: string): Promise { @@ -713,6 +725,10 @@ export async function runWatch(dest: string, options: WatchOptions | WatchConfig agentCmd: config.agentCmd ?? '(default: gh copilot)', dispatchMode: config.capabilities['wave-dispatch'] ? 'wave' : 'task', maxConcurrent: config.maxConcurrent ?? 1, + sandbox: config.sandbox ?? 'copilot', + sandboxFlags: config.sandboxFlags ?? '(none)', + permissionProfile: config.permissionProfile ?? 'yolo', + executionSource: config.executionSource ?? 'default', }); // Auth check (verbose only — avoid unnecessary process spawn on every startup) @@ -810,6 +826,10 @@ export async function runWatch(dest: string, options: WatchOptions | WatchConfig config: {}, agentCmd: config.agentCmd, copilotFlags: config.copilotFlags, + sandbox: config.sandbox, + sandboxFlags: config.sandboxFlags, + permissionProfile: config.permissionProfile, + executionSource: config.executionSource, verbose: config.verbose, pidTracker, }; diff --git a/packages/squad-cli/src/cli/commands/watch/types.ts b/packages/squad-cli/src/cli/commands/watch/types.ts index 05ee68411..06a2afdc2 100644 --- a/packages/squad-cli/src/cli/commands/watch/types.ts +++ b/packages/squad-cli/src/cli/commands/watch/types.ts @@ -6,6 +6,7 @@ */ import type { PlatformAdapter } from '@bradygaster/squad-sdk/platform'; +import type { SandboxProvider, PermissionProfile, ExecutionSource } from '../../core/execution-config.js'; /** Phase within a single watch round. */ export type WatchPhase = 'pre-scan' | 'post-triage' | 'post-execute' | 'housekeeping'; @@ -37,6 +38,12 @@ export interface WatchContext { /** Hidden --agent-cmd override. */ agentCmd?: string; copilotFlags?: string; + /** Resolved first-class execution config. */ + sandbox?: SandboxProvider; + /** Extra provider flags passed when sandbox is sandcastle. */ + sandboxFlags?: string; + permissionProfile?: PermissionProfile; + executionSource?: ExecutionSource; /** Verbose diagnostic output enabled. */ verbose?: boolean; /** PID tracker for child process cleanup (optional — only set when watch is running). */ diff --git a/packages/squad-cli/src/cli/core/command-help.ts b/packages/squad-cli/src/cli/core/command-help.ts index e92d8f961..cd4932131 100644 --- a/packages/squad-cli/src/cli/core/command-help.ts +++ b/packages/squad-cli/src/cli/core/command-help.ts @@ -104,6 +104,9 @@ const COMMAND_HELP: Record = { console.log(` ${BOLD}--file ${RESET} Path to loop file (default: loop.md)`); console.log(` ${BOLD}--interval ${RESET} Override loop interval in minutes`); console.log(` ${BOLD}--timeout ${RESET} Override max minutes per cycle`); + console.log(` ${BOLD}--sandbox ${RESET} Sandbox provider (copilot|sandcastle)`); + console.log(` ${BOLD}--sandbox-flags "..."${RESET} Extra flags passed to sandcastle`); + console.log(` ${BOLD}--permission-profile ${RESET} Permission profile (interactive|yolo|autopilot)`); console.log(` ${BOLD}--copilot-flags "..."${RESET} Extra flags for Copilot CLI`); console.log(` ${BOLD}--agent-cmd ${RESET} Override the agent command`); console.log(`\nCapabilities (composable with the loop):`); @@ -354,6 +357,9 @@ function printWatchHelp(name: 'triage' | 'watch', version: string): void { console.log(`Default: checks every 10 minutes (Ctrl+C to stop).\n`); console.log(`Core flags:`); console.log(` ${BOLD}--execute${RESET} Spawn agents to work on issues`); + console.log(` ${BOLD}--sandbox ${RESET} Sandbox provider (copilot|sandcastle)`); + console.log(` ${BOLD}--sandbox-flags "..."${RESET} Extra flags passed to sandcastle`); + console.log(` ${BOLD}--permission-profile ${RESET} Permission profile (interactive|yolo|autopilot)`); console.log(` ${BOLD}--copilot-flags "..."${RESET} Extra flags for Copilot CLI`); console.log(` ${BOLD}--max-concurrent N${RESET} Parallel issue limit (default 1)`); console.log(` ${BOLD}--timeout N${RESET} Max minutes per issue (default 30)`); diff --git a/packages/squad-cli/src/cli/core/execution-config.ts b/packages/squad-cli/src/cli/core/execution-config.ts new file mode 100644 index 000000000..80fbde264 --- /dev/null +++ b/packages/squad-cli/src/cli/core/execution-config.ts @@ -0,0 +1,152 @@ +/** + * Execution config resolver for Squad-managed agent spawns. + * + * Provides first-class sandbox + permission-profile resolution with precedence: + * CLI > config > env > defaults. + */ + +import { execFileSync } from 'node:child_process'; + +export type SandboxProvider = 'copilot' | 'sandcastle'; +export type PermissionProfile = 'interactive' | 'yolo' | 'autopilot'; +export type ExecutionSource = 'cli' | 'config' | 'env' | 'default'; + +export type ExecutionErrorCode = + | 'SQUAD_SANDBOX_UNAVAILABLE' + | 'SQUAD_SANDBOX_OVERRIDE_CONFLICT' + | 'SQUAD_SANDBOX_INVALID_VALUE' + | 'SQUAD_PERMISSION_PROFILE_INVALID_VALUE'; + +export class ExecutionConfigError extends Error { + readonly code: ExecutionErrorCode; + + constructor(code: ExecutionErrorCode, message: string) { + super(message); + this.code = code; + } +} + +export interface ResolvedExecutionConfig { + sandbox: SandboxProvider; + permissionProfile: PermissionProfile; + sandboxSource: ExecutionSource; + permissionProfileSource: ExecutionSource; + sourceOfTruth: ExecutionSource; + conflictBlocked: boolean; +} + +export interface ResolveExecutionConfigInput { + cliSandbox?: string; + configSandbox?: string; + envSandbox?: string; + cliPermissionProfile?: string; + configPermissionProfile?: string; + envPermissionProfile?: string; + agentCmd?: string; +} + +const DEFAULT_SANDBOX: SandboxProvider = 'copilot'; +const DEFAULT_PERMISSION_PROFILE: PermissionProfile = 'yolo'; + +const SOURCE_ORDER: ExecutionSource[] = ['cli', 'config', 'env', 'default']; + +function resolveByPrecedence( + cliValue: string | undefined, + configValue: string | undefined, + envValue: string | undefined, +): { value: string | undefined; source: ExecutionSource } { + if (cliValue !== undefined) return { value: cliValue, source: 'cli' }; + if (configValue !== undefined) return { value: configValue, source: 'config' }; + if (envValue !== undefined) return { value: envValue, source: 'env' }; + return { value: undefined, source: 'default' }; +} + +function normalizeSandbox(value: string | undefined): SandboxProvider { + const lower = (value ?? DEFAULT_SANDBOX).toLowerCase(); + if (lower === 'copilot' || lower === 'sandcastle') { + return lower; + } + throw new ExecutionConfigError( + 'SQUAD_SANDBOX_INVALID_VALUE', + `Invalid sandbox value "${value}". Valid values: copilot, sandcastle.`, + ); +} + +function normalizePermissionProfile(value: string | undefined): PermissionProfile { + const lower = (value ?? DEFAULT_PERMISSION_PROFILE).toLowerCase(); + if (lower === 'interactive' || lower === 'yolo' || lower === 'autopilot') { + return lower; + } + throw new ExecutionConfigError( + 'SQUAD_PERMISSION_PROFILE_INVALID_VALUE', + `Invalid permission profile value "${value}". Valid values: interactive, yolo, autopilot.`, + ); +} + +function isSandcastleAvailable(): boolean { + try { + const help = execFileSync('sandcastle', ['--help'], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + timeout: 3000, + shell: process.platform === 'win32', + }); + + // Guard against unrelated binaries named "sandcastle". + return typeof help === 'string' && (help.includes('--prompt') || help.includes('--prompt-file')); + } catch { + return false; + } +} + +function pickSourceOfTruth(a: ExecutionSource, b: ExecutionSource): ExecutionSource { + const rankA = SOURCE_ORDER.indexOf(a); + const rankB = SOURCE_ORDER.indexOf(b); + return rankA <= rankB ? a : b; +} + +export function resolveExecutionConfig(input: ResolveExecutionConfigInput): ResolvedExecutionConfig { + const sandboxResolution = resolveByPrecedence(input.cliSandbox, input.configSandbox, input.envSandbox); + const profileResolution = resolveByPrecedence( + input.cliPermissionProfile, + input.configPermissionProfile, + input.envPermissionProfile, + ); + + const sandbox = normalizeSandbox(sandboxResolution.value); + const permissionProfile = normalizePermissionProfile(profileResolution.value); + + const explicitSandbox = sandboxResolution.source !== 'default'; + if (explicitSandbox && input.agentCmd) { + throw new ExecutionConfigError( + 'SQUAD_SANDBOX_OVERRIDE_CONFLICT', + 'Sandbox selection conflicts with --agent-cmd. Remove one or the other.', + ); + } + + if (sandbox === 'sandcastle' && !isSandcastleAvailable()) { + throw new ExecutionConfigError( + 'SQUAD_SANDBOX_UNAVAILABLE', + 'Sandcastle sandbox is selected but unavailable or incompatible. Install/configure @ai-hero/sandcastle or use --sandbox copilot.', + ); + } + + return { + sandbox, + permissionProfile, + sandboxSource: sandboxResolution.source, + permissionProfileSource: profileResolution.source, + sourceOfTruth: pickSourceOfTruth(sandboxResolution.source, profileResolution.source), + conflictBlocked: false, + }; +} + +/** + * Normalize permission flags so profile is deterministic even with user copilot flags. + */ +export function applyPermissionProfileArgs(args: string[], profile: PermissionProfile): string[] { + const stripped = args.filter((a) => a !== '--yolo' && a !== '--autopilot'); + if (profile === 'interactive') return stripped; + if (profile === 'yolo') return [...stripped, '--yolo']; + return [...stripped, '--yolo', '--autopilot']; +} diff --git a/test/acceptance/acceptance.test.ts b/test/acceptance/acceptance.test.ts index 300355fed..af0afdbf6 100644 --- a/test/acceptance/acceptance.test.ts +++ b/test/acceptance/acceptance.test.ts @@ -43,6 +43,7 @@ runFeature(join(featuresDir, 'exit-codes.feature'), registry); // Consult and extract command tests runFeature(join(featuresDir, 'consult-command.feature'), registry); runFeature(join(featuresDir, 'extract-command.feature'), registry); +runFeature(join(featuresDir, 'execution-config.feature'), registry); // Subcommand --help intercept (#1201) runFeature(join(featuresDir, 'subcommand-help.feature'), registry); diff --git a/test/acceptance/features/execution-config.feature b/test/acceptance/features/execution-config.feature new file mode 100644 index 000000000..b6a448aec --- /dev/null +++ b/test/acceptance/features/execution-config.feature @@ -0,0 +1,25 @@ +Feature: Execution config flags + + Scenario: triage help documents sandbox and permission profile flags + When I run "squad triage --help" + Then the output contains "--sandbox " + And the output contains "--sandbox-flags" + And the output contains "--permission-profile " + And the exit code is 0 + + Scenario: loop help documents sandbox and permission profile flags + When I run "squad loop --help" + Then the output contains "--sandbox " + And the output contains "--sandbox-flags" + And the output contains "--permission-profile " + And the exit code is 0 + + Scenario: triage rejects invalid sandbox value + When I run "squad triage --sandbox invalid" + Then the output contains "SQUAD_SANDBOX_INVALID_VALUE" + And the exit code is 1 + + Scenario: triage rejects invalid permission profile value + When I run "squad triage --permission-profile invalid" + Then the output contains "SQUAD_PERMISSION_PROFILE_INVALID_VALUE" + And the exit code is 1 diff --git a/test/cli/execution-config.test.ts b/test/cli/execution-config.test.ts new file mode 100644 index 000000000..2ccc7f0f8 --- /dev/null +++ b/test/cli/execution-config.test.ts @@ -0,0 +1,102 @@ +import { describe, it, expect, vi } from 'vitest'; +import { execFileSync } from 'node:child_process'; + +vi.mock('node:child_process', () => ({ + execFileSync: vi.fn(() => { + throw new Error('sandcastle missing'); + }), +})); + +import { + applyPermissionProfileArgs, + resolveExecutionConfig, + ExecutionConfigError, +} from '../../packages/squad-cli/src/cli/core/execution-config.js'; + +describe('execution-config', () => { + const execFileSyncMock = vi.mocked(execFileSync); + + it('accepts sandcastle when help output matches expected CLI surface', () => { + execFileSyncMock.mockReturnValueOnce('Usage: sandcastle --prompt [--prompt-file ]'); + + const result = resolveExecutionConfig({ cliSandbox: 'sandcastle' }); + + expect(result.sandbox).toBe('sandcastle'); + expect(result.sandboxSource).toBe('cli'); + }); + + it('rejects incompatible sandcastle binaries', () => { + execFileSyncMock.mockReturnValueOnce('Usage: some-other-sandcastle --version'); + + expect(() => resolveExecutionConfig({ cliSandbox: 'sandcastle' })).toThrowError(ExecutionConfigError); + try { + resolveExecutionConfig({ cliSandbox: 'sandcastle' }); + } catch (err) { + const execErr = err as ExecutionConfigError; + expect(execErr.code).toBe('SQUAD_SANDBOX_UNAVAILABLE'); + expect(execErr.message).toContain('@ai-hero/sandcastle'); + } + }); + + it('resolves defaults when no inputs are provided', () => { + const result = resolveExecutionConfig({}); + expect(result.sandbox).toBe('copilot'); + expect(result.permissionProfile).toBe('yolo'); + expect(result.sourceOfTruth).toBe('default'); + }); + + it('applies precedence CLI > config > env > default', () => { + const result = resolveExecutionConfig({ + cliSandbox: 'copilot', + configSandbox: 'sandcastle', + envSandbox: 'sandcastle', + cliPermissionProfile: 'autopilot', + configPermissionProfile: 'interactive', + envPermissionProfile: 'interactive', + }); + + expect(result.sandbox).toBe('copilot'); + expect(result.sandboxSource).toBe('cli'); + expect(result.permissionProfile).toBe('autopilot'); + expect(result.permissionProfileSource).toBe('cli'); + expect(result.sourceOfTruth).toBe('cli'); + }); + + it('throws stable invalid sandbox error code', () => { + expect(() => resolveExecutionConfig({ cliSandbox: 'bogus' })).toThrowError(ExecutionConfigError); + try { + resolveExecutionConfig({ cliSandbox: 'bogus' }); + } catch (err) { + expect((err as ExecutionConfigError).code).toBe('SQUAD_SANDBOX_INVALID_VALUE'); + } + }); + + it('throws stable invalid permission profile error code', () => { + expect(() => resolveExecutionConfig({ cliPermissionProfile: 'always-yes' })).toThrowError(ExecutionConfigError); + try { + resolveExecutionConfig({ cliPermissionProfile: 'always-yes' }); + } catch (err) { + expect((err as ExecutionConfigError).code).toBe('SQUAD_PERMISSION_PROFILE_INVALID_VALUE'); + } + }); + + it('throws conflict error when explicit sandbox and agentCmd are both set', () => { + expect(() => resolveExecutionConfig({ + cliSandbox: 'copilot', + agentCmd: 'custom-agent --x', + })).toThrowError(ExecutionConfigError); + + try { + resolveExecutionConfig({ cliSandbox: 'copilot', agentCmd: 'custom-agent --x' }); + } catch (err) { + expect((err as ExecutionConfigError).code).toBe('SQUAD_SANDBOX_OVERRIDE_CONFLICT'); + } + }); + + it('normalizes profile flags deterministically', () => { + const base = ['-p', 'hello', '--yolo', '--autopilot']; + expect(applyPermissionProfileArgs(base, 'interactive')).toEqual(['-p', 'hello']); + expect(applyPermissionProfileArgs(base, 'yolo')).toEqual(['-p', 'hello', '--yolo']); + expect(applyPermissionProfileArgs(base, 'autopilot')).toEqual(['-p', 'hello', '--yolo', '--autopilot']); + }); +}); diff --git a/test/cli/sandbox-command.test.ts b/test/cli/sandbox-command.test.ts new file mode 100644 index 000000000..9710b497a --- /dev/null +++ b/test/cli/sandbox-command.test.ts @@ -0,0 +1,39 @@ +import { describe, it, expect } from 'vitest'; +import { buildSandboxCommand } from '../../packages/squad-cli/src/cli/commands/sandbox-command.js'; + +describe('sandbox-command', () => { + it('builds sandcastle command with mapped prompt args and sandbox flags', () => { + const result = buildSandboxCommand({ + sandbox: 'sandcastle', + sandboxFlags: '--isolation strict --trace', + permissionProfile: 'autopilot', + baseArgs: ['-p', 'hello', '--yolo', '--autopilot', '--model', 'gpt-5'], + }); + + expect(result.cmd).toBe('sandcastle'); + expect(result.args).toEqual([ + '--isolation', 'strict', '--trace', '--prompt', 'hello', + ]); + }); + + it('passes through --prompt-file for sandcastle', () => { + const result = buildSandboxCommand({ + sandbox: 'sandcastle', + baseArgs: ['--prompt-file', '/tmp/prompt.md'], + }); + + expect(result.cmd).toBe('sandcastle'); + expect(result.args).toEqual(['--prompt-file', '/tmp/prompt.md']); + }); + + it('builds copilot command and normalizes permission flags', () => { + const result = buildSandboxCommand({ + sandbox: 'copilot', + permissionProfile: 'interactive', + baseArgs: ['-p', 'hello', '--yolo', '--autopilot'], + }); + + expect(result.cmd).toBe('copilot'); + expect(result.args).toEqual(['-p', 'hello']); + }); +}); diff --git a/test/cli/watch-config-execution.test.ts b/test/cli/watch-config-execution.test.ts new file mode 100644 index 000000000..a0d10ebcf --- /dev/null +++ b/test/cli/watch-config-execution.test.ts @@ -0,0 +1,75 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import { tmpdir } from 'node:os'; +import { loadWatchConfig } from '../../packages/squad-cli/src/cli/commands/watch/config.js'; +import type { ExecutionConfigError } from '../../packages/squad-cli/src/cli/core/execution-config.js'; + +describe('watch config execution settings', () => { + let root: string; + const oldSandbox = process.env['SQUAD_SANDBOX']; + const oldProfile = process.env['SQUAD_PERMISSION_PROFILE']; + const oldSandboxFlags = process.env['SQUAD_SANDBOX_FLAGS']; + + beforeEach(() => { + root = mkdtempSync(path.join(tmpdir(), 'squad-watch-config-')); + mkdirSync(path.join(root, '.squad'), { recursive: true }); + }); + + afterEach(() => { + if (oldSandbox === undefined) delete process.env['SQUAD_SANDBOX']; + else process.env['SQUAD_SANDBOX'] = oldSandbox; + if (oldProfile === undefined) delete process.env['SQUAD_PERMISSION_PROFILE']; + else process.env['SQUAD_PERMISSION_PROFILE'] = oldProfile; + if (oldSandboxFlags === undefined) delete process.env['SQUAD_SANDBOX_FLAGS']; + else process.env['SQUAD_SANDBOX_FLAGS'] = oldSandboxFlags; + rmSync(root, { recursive: true, force: true }); + }); + + it('resolves execution settings from env when no CLI/config values are provided', () => { + process.env['SQUAD_SANDBOX'] = 'copilot'; + process.env['SQUAD_PERMISSION_PROFILE'] = 'autopilot'; + process.env['SQUAD_SANDBOX_FLAGS'] = '--trace --isolation strict'; + + const cfg = loadWatchConfig(root, {}); + expect(cfg.sandbox).toBe('copilot'); + expect(cfg.permissionProfile).toBe('autopilot'); + expect(cfg.sandboxFlags).toBe('--trace --isolation strict'); + expect(cfg.executionSource).toBe('env'); + }); + + it('lets CLI override config and env values', () => { + writeFileSync(path.join(root, '.squad', 'config.json'), JSON.stringify({ + watch: { + sandbox: 'copilot', + permissionProfile: 'interactive', + }, + }, null, 2)); + process.env['SQUAD_SANDBOX'] = 'copilot'; + process.env['SQUAD_PERMISSION_PROFILE'] = 'interactive'; + + const cfg = loadWatchConfig(root, { + sandbox: 'copilot', + sandboxFlags: '--from-cli', + permissionProfile: 'yolo', + }); + + expect(cfg.sandbox).toBe('copilot'); + expect(cfg.sandboxFlags).toBe('--from-cli'); + expect(cfg.permissionProfile).toBe('yolo'); + expect(cfg.executionSource).toBe('cli'); + }); + + it('throws stable conflict code for explicit sandbox + agentCmd', () => { + try { + loadWatchConfig(root, { + sandbox: 'copilot', + agentCmd: 'custom-agent --run', + }); + throw new Error('expected loadWatchConfig to throw'); + } catch (err) { + const e = err as ExecutionConfigError; + expect(e.code).toBe('SQUAD_SANDBOX_OVERRIDE_CONFLICT'); + } + }); +}); diff --git a/test/cli/watch-execute.test.ts b/test/cli/watch-execute.test.ts index 2d9e7c0fc..398518121 100644 --- a/test/cli/watch-execute.test.ts +++ b/test/cli/watch-execute.test.ts @@ -68,6 +68,53 @@ describe('CLI: watch execute mode', () => { expect(args).toContain('value'); expect(args).toContain('-p'); }); + + it('uses sandcastle command when sandbox is sandcastle', async () => { + const issue: WatchWorkItem = { + number: 52, + title: 'Run in sandcastle', + body: '', + labels: [{ name: 'squad:eecom' }], + assignees: [], + }; + const teamRoot = '/path/to/squad'; + const options = { + intervalMinutes: 10, + sandbox: 'sandcastle' as const, + sandboxFlags: '--isolation strict', + }; + + const { cmd, args } = buildAgentCommand(issue, teamRoot, options); + expect(cmd).toBe('sandcastle'); + expect(args).toContain('--isolation'); + expect(args).toContain('strict'); + expect(args).toContain('--prompt'); + expect(args.some((a) => a.includes('issue #52'))).toBe(true); + expect(args).not.toContain('--yolo'); + expect(args).not.toContain('--autopilot'); + }); + + it('enforces interactive permission profile over copilot flags', async () => { + const issue: WatchWorkItem = { + number: 51, + title: 'Respect explicit permission profile', + body: '', + labels: [{ name: 'squad:eecom' }], + assignees: [], + }; + const teamRoot = '/path/to/squad'; + const options = { + intervalMinutes: 10, + copilotFlags: '--autopilot --yolo --model gpt-5', + permissionProfile: 'interactive' as const, + }; + + const { args } = buildAgentCommand(issue, teamRoot, options); + expect(args).toContain('--model'); + expect(args).toContain('gpt-5'); + expect(args).not.toContain('--yolo'); + expect(args).not.toContain('--autopilot'); + }); }); describe('findExecutableIssues', () => {