From c1f850f6332f09bdcfaab6703087a308038681ed Mon Sep 17 00:00:00 2001 From: unohee Date: Thu, 22 Oct 2026 01:16:35 +0900 Subject: [PATCH] fix(adapters): refuse to start the autonomous runner on delegated CLI adapters (AGT-4444) claude, codex and cursor hand the whole tool loop to another program, so the publication fence, protected files, role tool limits and the credential-free sandboxed shell never reach them; base.ts could only log that they were dropped. The daemon now checks every unattended adapter setting (adapter, defaultRoles, projectAgents roles, adapterRouting primary and fallbacks, orchestrator, loopAdvisor) and throws at startup naming each one. autonomous.allowDelegatedAdapters: true opts out. --- CHANGELOG.md | 14 ++-- config.example.yaml | 8 ++ src/adapters/unattendedPolicy.test.ts | 104 ++++++++++++++++++++++++++ src/adapters/unattendedPolicy.ts | 91 ++++++++++++++++++++++ src/core/config.test.ts | 3 + src/core/config.ts | 3 + src/core/service.test.ts | 17 +++++ src/core/service.ts | 4 + src/core/types.ts | 7 ++ 9 files changed, 245 insertions(+), 6 deletions(-) create mode 100644 src/adapters/unattendedPolicy.test.ts create mode 100644 src/adapters/unattendedPolicy.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index eaceacc7..c75cf351 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,24 +4,26 @@ ### Changed -- **Worker shell commands get loopback-only network by default (AGT-4798).** AGT-4783 removed the credentials a worker's shell picked up on its own; a command could still read a token file by absolute path and use it while the sandbox allowed the network. The macOS sandbox profile now allows only `localhost` (bind and inbound on the local end, outbound to the remote end — a single `network* (local ip "localhost:*")` rule was measured to also admit remote connections), and bwrap keeps `--unshare-net`. Tests that start a local server still work; anything that reaches another host fails with `EPERM`. Measured before the change: cgf-portal's `uv run --frozen pytest` and OpenSwarm's vitest run offline, because worktrees share installed dependencies. New `autonomous.workerNetwork: loopback | open` (default `loopback`) — set `open` on a host whose sandbox cannot isolate the network. The worker prompt now says what is true (no credentials, no remote network) and tells it not to probe release/deploy scripts with `--help`. - -### Added +- **The daemon refuses to start when unattended work is routed to a delegated CLI adapter (AGT-4444).** `claude`, `codex` and `cursor` hand the whole tool loop to another program, so OpenSwarm's publication fence, protected files, role tool limits and (since AGT-4783/4798) the credential-free, loopback-only shell never reach them; `base.ts` could only log that they were dropped, and `claude` runs with `--permission-mode bypassPermissions`. `src/adapters/unattendedPolicy.ts` now checks every place the daemon can pick an adapter — the top-level `adapter`, `autonomous.defaultRoles`, per-project `roles`, `adapterRouting` primary **and fallbacks** (whose schema default is `['cc-router', 'cursor']`, so a bare `adapterRouting:` block already routed to cursor), `orchestrator` and `loopAdvisor` — and startup throws naming each offending setting. A role switched off is not checked. Opt out with `autonomous.allowDelegatedAdapters: true`. Interactive CLI use is unaffected: the check runs only when an `autonomous` section exists. Periodic read-only reviews (`periodicReviews[].adapter`) are not covered. -- **`projectAgents[].setup` — dependency install commands that run in the worktree before the worker (AGT-4798).** They run with the network open but inside the sandbox and without credentials, in order, stopping at the first failure; a failed setup ends the run as `infra_error` before any model call. Most projects need none. - -### Changed +- **Worker shell commands get loopback-only network by default (AGT-4798).** AGT-4783 removed the credentials a worker's shell picked up on its own; a command could still read a token file by absolute path and use it while the sandbox allowed the network. The macOS sandbox profile now allows only `localhost` (bind and inbound on the local end, outbound to the remote end — a single `network* (local ip "localhost:*")` rule was measured to also admit remote connections), and bwrap keeps `--unshare-net`. Tests that start a local server still work; anything that reaches another host fails with `EPERM`. Measured before the change: cgf-portal's `uv run --frozen pytest` and OpenSwarm's vitest run offline, because worktrees share installed dependencies. New `autonomous.workerNetwork: loopback | open` (default `loopback`) — set `open` on a host whose sandbox cannot isolate the network. The worker prompt now says what is true (no credentials, no remote network) and tells it not to probe release/deploy scripts with `--help`. - **Worker shell commands no longer carry the operator's credentials (AGT-4783).** On 2026-10-20 a cgf-portal worker ran `bash scripts/dev/release.sh --help`; the script called `gh workflow run` with the daemon's GitHub login and shipped an unapproved production deploy. A command-name fence cannot see inside a script, so the worker's `bash` tool now runs with `src/adapters/workerEnv.ts`: an allowlisted environment (toolchain, locale, terminal, test-resource budget — no tokens, keys, DSNs, `SSH_AUTH_SOCK` or `OPENSWARM_*` secrets), a private `HOME` with caches and toolchain homes (`CARGO_HOME`, `UV_CACHE_DIR`, `npm_config_cache`, …) pointed back at the real directories, an empty `GH_CONFIG_DIR`, git credential helpers reset to none, and git-over-ssh without an identity. Measured inside the real sandbox before and after: `gh auth token`, `git credential fill` and `wrangler whoami` all authenticated before and none do after; `uv run --frozen` in cgf-portal and `cargo`/`npm` still work. This reverses #493's choice to keep DevOps and DB credentials in the worker shell. A command that reads a token file by absolute path is still possible while the sandbox allows the network; turning the network off is the next step (S6 W1, with the dependency setup step AGT-4798). + - **`autonomous.workerSandbox: on` on a host with neither `sandbox-exec` nor `bwrap` now refuses worker bash commands instead of warning once and running them unfenced.** Set `autonomous.workerSandbox: off` to run them unfenced on purpose (for example on Windows). The boot log line says which applies. ### Added +- **`projectAgents[].setup` — dependency install commands that run in the worktree before the worker (AGT-4798).** They run with the network open but inside the sandbox and without credentials, in order, stopping at the first failure; a failed setup ends the run as `infra_error` before any model call. Most projects need none. + - **`openswarm exec --issue INT-123` deploys the worker pipeline for one Linear issue asynchronously and hands the run to an external orchestrator.** It resolves the issue, checks the repo's Linear project mapping and the deployable state, records an **exec hold**, and posts to the daemon's `/api/work` (falling back to a detached `work` child only when the daemon is unreachable — never on an HTTP refusal, so a 403 from `autonomous.allowedProjects` cannot be routed around), then prints `{runId, identifier, transport, deployed}` and returns. `exec status `, `exec await --timeout ` and `exec answer [--correlation hq-…] [--text …]` are the observation half: `await` blocks until the run elevates or finishes and prints the structured elevation JSON (state, park reason, branch/worktree/PR, pending questions with their class, and the exact next command), and `answer` writes the reply to the coordination board as `actorRole: 'orchestrator'` — the same delegated authority OpenSwarm's own supervisor answers with — resumes the ledger row through the exact-correlation park contract first, re-marks the hold, and redeploys. All four take `--json`. Reads use a read-only handle (`{ readonly: true }`, the `ledgerOverviewTool` pattern) so `status`/`await` never create a database, migrate a schema, or take a write lock on a live daemon's file. + - **`src/automation/execHold.ts` — a durable hold that stops `idle_fill` from re-running a run its external orchestrator is still deciding on.** The park AGT-4257's idle_fill is designed to lift is exactly the elevation `exec await` is waiting for, so a held issue skips every idle_fill branch in `filterAlreadyProcessed` (the DONE/Backlog reopen, the ephemeral-guard resume, the NEEDS_HUMAN resume, and the RETRY_AT lift) and the daemon logs `held by exec elevation — awaiting the external orchestrator`. Its own table, not `automation_runs.metadata_json`, which `registerRun`'s conflict branch replaces whole and `observeTask` rewrites every pass (`draftCache.ts` keeps its state in its own table for the same reason). Writes fail **closed** (a run deployed without its hold is one idle_fill will steal, so the deploy aborts), reads fail **open** (the heartbeat hot path degrades to the previous behaviour rather than dying), and `EXEC_HOLD_TTL_MS` (24h, refreshed by every `exec answer`) is the safety valve that keeps an orchestrator who walked away from blocking the issue forever. + - **The hold covers the built-in `OrchestratorSupervisor` too, so the escalation belongs to the external agent alone.** The plan's requirement was that a held run elevates to the external orchestrator *only*; gating `idle_fill` is not sufficient, because the supervisor independently selects a held task's `human-question` from the board and can answer it with its `coordination_answer_question` tool — resuming the run behind the agent that is waiting on `exec await`. `selectOrchestratorItems` now drops any event whose `taskId` is held (holding set injectable, resolved lazily so a sweep with no candidate events never opens the database), and `executeOrchestratorTrackerTool` refuses the answer for a held task as a second, fail-**closed** line of defence — an unreadable hold store reads as held there, the opposite of the heartbeat's fail-open, because a wrongly refused answer costs one more sweep while a wrongly accepted one is the silent re-run this feature exists to prevent. (The supervisor is off in a default config — `AutonomousConfig.orchestrator` is optional and absent from `config.yaml` — but `config.example.yaml` enables it, so the gate matters for any deployment that turned it on.) - **Review-hardening pass on the same four commands, after re-reading them against the plan.** Five fixes, each a real defect rather than a polish item. (1) `exec await` could never wait for a run it had just deployed: the detached child resolves config, Linear, and its project mapping before it registers the ledger row, and `await` returned `2` on the first missing-row read, so `exec --issue … && exec await …` always lost. It now polls through the gap until the deadline and only then reports `UNKNOWN`. (2) `exec answer` without `--correlation` reused `classifyRunForAwait`, which admits `NEEDS_RECONCILE` — an elevation the orchestrator *should* see, but not an input park: `markReady` would have admitted the run back into the pool with artifact reconciliation skipped. Replaced with an explicit `ANSWERABLE_STATES` whitelist (`NEEDS_HUMAN`, `RETRY_AT`, `NEEDS_SPEC`, `NEEDS_ENV`); a correlation-carrying answer is still allowed in any state. (3) `exec answer A --correlation ` answered B — settling B's sibling asks through `answerHumanQuestion` — and then resumed and redeployed A. The correlation is now bound to the run before any write and a mismatch exits 2. (4) The daemon health probe folded every non-2xx into "unreachable" via `res.ok`, so a daemon answering 401/403/503 on its own `/api/stats` triggered the local-child fallback — precisely the allow-list bypass the deploy path is written to prevent. The probe now separates `down` (fetch/connect failure → fallback) from `refused` (reachable but declining → exit 2, no fallback). (5) Exit codes: a refused dispatch returns 2, not 1 (a `202` with `queued: 0` is a refusal before deploying, not a deploy that failed), and bare `openswarm exec` exits 2 rather than 0 — `.help()` calls `process.exit(0)` before `process.exitCode = 2` could matter, so it is `outputHelp()` now. + - **`exec status --json` is a clean document even after `exec` gained subcommands.** Commander 12 attributes a parent-declared option name to the parent while parsing a subcommand, so `openswarm exec status --json` left `--json` on `exec` and the subcommand's action saw an empty opts object — the flags are read with `optsWithGlobals()` instead. Three supporting fixes came with it: the `preAction` hook reads `optsWithGlobals()` too; that hook's **second** `loadConfig()` (the adapter alignment, after the quiet telemetry read) is now silenced under `--json` as well, because it announces the config path on stdout *before* any command action installs its redirect, so `openswarm exec status --json | jq` got a three-line banner ahead of the document on a machine with no `provider-override.json` (a configured override skips that load, which is how the bug hid); and `execCommand`'s stdout writer is bound at module load, because the `--json` reroute of `console.log` to stderr was swallowing the one document that must reach stdout. ### Fixed diff --git a/config.example.yaml b/config.example.yaml index 8d5ee98d..c9398acd 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -272,6 +272,14 @@ autonomous: # that need the network go in projectAgents[].setup below. # workerNetwork: loopback + # Delegated CLI adapters (claude, codex, cursor) own their tool loop, so + # OpenSwarm's publication fence, protected files, role tool limits and the + # credential-free sandboxed shell do not reach them (claude even runs with + # bypassPermissions). The daemon refuses to start when any unattended setting + # names one: adapter, defaultRoles, projectAgents roles, adapterRouting + # primary/fallbacks, orchestrator, loopAdvisor. true accepts that on purpose. + # allowDelegatedAdapters: false + # Deterministic baseline-diff verification (enabled by default). # Define repo-specific commands in .openswarm/verify.yaml; see templates/verify.example.yaml. verify: diff --git a/src/adapters/unattendedPolicy.test.ts b/src/adapters/unattendedPolicy.test.ts new file mode 100644 index 00000000..7ff16e4f --- /dev/null +++ b/src/adapters/unattendedPolicy.test.ts @@ -0,0 +1,104 @@ +// ============================================ +// OpenSwarm - Which adapters may run unattended (AGT-4444) +// ============================================ +// +// The refusal is only worth having if it covers every place the daemon can pick +// an adapter. Each surface below is checked on its own: a policy that looked at +// `defaultRoles` but not `adapterRouting.fallbacks` would pass the first test +// and still route unattended work to a CLI that has no fences. + +import { describe, expect, it } from 'vitest'; +import { + DELEGATED_ADAPTERS, + assertUnattendedAdapters, + collectUnattendedAdapterViolations, + unattendedAdapterViolation, + type UnattendedAdapterConfig, +} from './unattendedPolicy.js'; +import { ADAPTER_NAMES } from '../core/adapterNames.js'; + +const safe = (autonomous: NonNullable = {}): UnattendedAdapterConfig => ({ + adapter: 'codex-responses', + autonomous, +}); + +describe('unattendedAdapterViolation', () => { + it('refuses exactly the adapters whose CLI owns the tool loop', () => { + for (const name of DELEGATED_ADAPTERS) expect(unattendedAdapterViolation(name)).toContain(`'${name}'`); + const inProcess = ADAPTER_NAMES.filter((name) => !(DELEGATED_ADAPTERS as readonly string[]).includes(name)); + expect(inProcess.length).toBeGreaterThan(0); + for (const name of inProcess) expect(unattendedAdapterViolation(name), name).toBeNull(); + }); + + it('only names adapters that exist, so a rename cannot silently empty the list', () => { + for (const name of DELEGATED_ADAPTERS) expect(ADAPTER_NAMES).toContain(name); + }); +}); + +describe('collectUnattendedAdapterViolations', () => { + it('is quiet for a config that stays on OpenSwarm\'s own loop', () => { + expect(collectUnattendedAdapterViolations(safe({ + defaultRoles: { worker: { adapter: 'openrouter', enabled: true }, reviewer: { adapter: 'codex-responses' } }, + adapterRouting: { primary: 'codex-responses', fallbacks: ['cc-router', 'codex-responses'] }, + }))).toEqual([]); + }); + + it('is quiet when there is no autonomous section — an interactive CLI is attended', () => { + expect(collectUnattendedAdapterViolations({ adapter: 'claude' })).toEqual([]); + }); + + it.each([ + ['the top-level adapter', { adapter: 'claude', autonomous: {} }, 'adapter:'], + ['a default role', safe({ defaultRoles: { worker: { adapter: 'cursor', enabled: true } } }), 'autonomous.defaultRoles.worker.adapter'], + ['a project role override', safe({ projectAgents: [{ projectPath: '/p', roles: { reviewer: { adapter: 'codex' } } }] }), 'autonomous.projectAgents[/p].roles.reviewer.adapter'], + ['the routing primary', safe({ adapterRouting: { primary: 'claude' } }), 'autonomous.adapterRouting.primary'], + ['a routing fallback', safe({ adapterRouting: { fallbacks: ['cc-router', 'cursor'] } }), 'autonomous.adapterRouting.fallbacks[1]'], + ['the orchestrator', safe({ orchestrator: { adapter: 'codex' } }), 'autonomous.orchestrator.adapter'], + ['the loop advisor', safe({ loopAdvisor: { adapter: 'claude' } }), 'autonomous.loopAdvisor.adapter'], + ] as const)('catches a delegated adapter in %s', (_label, config, where) => { + const violations = collectUnattendedAdapterViolations(config as UnattendedAdapterConfig); + expect(violations).toHaveLength(1); + expect(violations[0]).toContain(where); + }); + + it('reports every offending setting, not just the first', () => { + const violations = collectUnattendedAdapterViolations({ + adapter: 'claude', + autonomous: { + defaultRoles: { worker: { adapter: 'cursor' } }, + adapterRouting: { fallbacks: ['codex'] }, + }, + }); + expect(violations).toHaveLength(3); + }); + + it('skips a role the operator switched off, which never runs', () => { + expect(collectUnattendedAdapterViolations(safe({ + defaultRoles: { advisor: { adapter: 'claude', enabled: false } }, + }))).toEqual([]); + }); + + it('accepts everything once the operator opts in', () => { + expect(collectUnattendedAdapterViolations({ + adapter: 'claude', + autonomous: { allowDelegatedAdapters: true, defaultRoles: { worker: { adapter: 'cursor' } } }, + })).toEqual([]); + }); + + it('treats only an explicit true as an opt-in', () => { + for (const value of [false, undefined]) { + expect(collectUnattendedAdapterViolations({ adapter: 'claude', autonomous: { allowDelegatedAdapters: value } })).toHaveLength(1); + } + }); +}); + +describe('assertUnattendedAdapters', () => { + it('does not throw for a safe config', () => { + expect(() => assertUnattendedAdapters(safe())).not.toThrow(); + }); + + it('throws naming each setting, the safe alternatives and the opt-out', () => { + expect(() => assertUnattendedAdapters({ adapter: 'claude', autonomous: { adapterRouting: { fallbacks: ['cursor'] } } })) + .toThrowError(/adapter: 'claude'[\s\S]*fallbacks\[0\]: 'cursor'[\s\S]*codex-responses[\s\S]*allowDelegatedAdapters: true/); + }); +}); diff --git a/src/adapters/unattendedPolicy.ts b/src/adapters/unattendedPolicy.ts new file mode 100644 index 00000000..1fe6e802 --- /dev/null +++ b/src/adapters/unattendedPolicy.ts @@ -0,0 +1,91 @@ +// ============================================ +// OpenSwarm - Which adapters may run unattended (AGT-4444) +// ============================================ +// +// Four of the worker's safeguards live in OpenSwarm's own tool loop: the +// publication fence, protected files, the role tool allow/deny list and (since +// AGT-4783/4798) the credential-free, loopback-only shell. The delegated CLI +// adapters hand the whole turn loop to another program (`claude`, `codex`, +// `cursor`), so none of those reach it — `base.ts` can only log that they were +// dropped. A daemon that routes unattended work there has no fence at all; +// `claude` even runs with `--permission-mode bypassPermissions`. +// +// Unattended means the daemon: any configured `autonomous` section, heartbeat +// on or off, because an explicit dispatch runs the same workers. The refusal is +// at startup, where it is loud, rather than at the first run, where it is a +// warning in a log nobody reads. `autonomous.allowDelegatedAdapters: true` is +// the operator's explicit acceptance of the missing fences. + +/** Adapters whose CLI owns the tool loop; OpenSwarm's fences do not apply to them. */ +export const DELEGATED_ADAPTERS = ['claude', 'codex', 'cursor'] as const; + +/** Why `name` may not run unattended, or null when it runs OpenSwarm's own loop. */ +export function unattendedAdapterViolation(name: string): string | null { + if (!(DELEGATED_ADAPTERS as readonly string[]).includes(name)) return null; + return `'${name}' delegates the tool loop to its own CLI, so the publication fence, protected files, ` + + 'role tool limits and the credential-free sandboxed shell do not apply to it'; +} + +interface RoleLike { adapter?: string; enabled?: boolean } +type RolesLike = Partial>; + +/** The part of `SwarmConfig` that decides which adapters unattended runs can reach. */ +export interface UnattendedAdapterConfig { + adapter?: string; + autonomous?: { + allowDelegatedAdapters?: boolean; + defaultRoles?: RolesLike; + projectAgents?: Array<{ projectPath: string; roles?: RolesLike }>; + adapterRouting?: { primary?: string; fallbacks?: readonly string[] }; + orchestrator?: { adapter?: string }; + loopAdvisor?: { adapter?: string }; + }; +} + +function* roleAdapters(roles: RolesLike | undefined, where: string): Generator<[string, string]> { + for (const [role, value] of Object.entries(roles ?? {})) { + // A role the operator switched off never runs, so its adapter is not reachable. + if (!value?.adapter || value.enabled === false) continue; + yield [`${where}.${role}.adapter`, value.adapter]; + } +} + +/** + * Every place a delegated adapter is named for unattended work, as + * `: ` lines. Empty when the operator opted in. A role that + * names no adapter inherits the top-level one, which is checked on its own. + */ +export function collectUnattendedAdapterViolations(config: UnattendedAdapterConfig): string[] { + const autonomous = config.autonomous; + if (!autonomous || autonomous.allowDelegatedAdapters === true) return []; + + const named: Array<[string, string | undefined]> = [ + ['adapter', config.adapter], + ['autonomous.adapterRouting.primary', autonomous.adapterRouting?.primary], + ['autonomous.orchestrator.adapter', autonomous.orchestrator?.adapter], + ['autonomous.loopAdvisor.adapter', autonomous.loopAdvisor?.adapter], + ...(autonomous.adapterRouting?.fallbacks ?? []).map((name, i): [string, string] => [`autonomous.adapterRouting.fallbacks[${i}]`, name]), + ...roleAdapters(autonomous.defaultRoles, 'autonomous.defaultRoles'), + ...(autonomous.projectAgents ?? []).flatMap((agent) => + [...roleAdapters(agent.roles, `autonomous.projectAgents[${agent.projectPath}].roles`)]), + ]; + + const violations: string[] = []; + for (const [where, name] of named) { + const reason = name ? unattendedAdapterViolation(name) : null; + if (reason) violations.push(`${where}: ${reason}`); + } + return violations; +} + +/** Throw, naming every offending setting and the opt-out, when the daemon would route unattended work to a delegated CLI. */ +export function assertUnattendedAdapters(config: UnattendedAdapterConfig): void { + const violations = collectUnattendedAdapterViolations(config); + if (violations.length === 0) return; + throw new Error( + 'Refusing to start the autonomous runner on adapters that cannot be fenced:\n - ' + + `${violations.join('\n - ')}\n` + + 'Use an adapter that runs OpenSwarm\'s own loop (codex-responses, openrouter, gpt, atlascloud, lmstudio, local, ' + + 'cc-router, ollama-cloud), or set autonomous.allowDelegatedAdapters: true to run unattended without those fences.', + ); +} diff --git a/src/core/config.test.ts b/src/core/config.test.ts index 3989fd40..42098f54 100644 --- a/src/core/config.test.ts +++ b/src/core/config.test.ts @@ -380,11 +380,13 @@ agents: autonomous: { enabled: true, workerNetwork: 'open', + allowDelegatedAdapters: true, projectAgents: [{ projectPath: '~/dev/cgf-portal', setup: [' uv sync --frozen '] }], }, })); const autonomous = loadConfig('/tmp/config.json').autonomous; expect(autonomous?.workerNetwork).toBe('open'); + expect(autonomous?.allowDelegatedAdapters).toBe(true); expect(autonomous?.projectAgents?.[0].setup).toEqual(['uv sync --frozen']); }); @@ -470,6 +472,7 @@ agents: expect(config.autonomous?.periodicReviews?.[0]).toMatchObject({ profile: 'hygiene' }); expect(config.autonomous?.workerSandbox).toBe('on'); // default: the fence is on (AGT-4387) expect(config.autonomous?.workerNetwork).toBe('loopback'); // default: no remote network (AGT-4798) + expect(config.autonomous?.allowDelegatedAdapters).toBe(false); // default: delegated CLIs refused (AGT-4444) expect(config.autonomous?.draftModel).toBe('deepseek/deepseek-v4-flash'); // single-model fleet knob expect(config.autonomous?.orchestrator).toEqual({ enabled: true, diff --git a/src/core/config.ts b/src/core/config.ts index 345baaf5..8a30ecb0 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -479,6 +479,8 @@ const AutonomousConfigSchema = z.object({ * (bash as the daemon user, regex denylist only). */ workerSandbox: z.enum(['on', 'off']).default('on'), + /** Accept delegated CLI adapters (claude/codex/cursor) for unattended work despite the missing fences (AGT-4444) */ + allowDelegatedAdapters: z.boolean().default(false), /** Network for the worker's shell: loopback only (default) or open (AGT-4798) */ workerNetwork: z.enum(['loopback', 'open']).default('loopback'), periodicReviews: z.array(z.object({ @@ -888,6 +890,7 @@ function transformConfig(raw: RawConfig): SwarmConfig { adapterRouting: raw.autonomous.adapterRouting, workerSandbox: raw.autonomous.workerSandbox, workerNetwork: raw.autonomous.workerNetwork, + allowDelegatedAdapters: raw.autonomous.allowDelegatedAdapters, periodicReviews: raw.autonomous.periodicReviews, orchestrator: raw.autonomous.orchestrator, orchestratorSchedule: raw.autonomous.orchestratorSchedule, diff --git a/src/core/service.test.ts b/src/core/service.test.ts index 29a2906c..68ec232e 100644 --- a/src/core/service.test.ts +++ b/src/core/service.test.ts @@ -202,6 +202,9 @@ describe('service', () => { const mockAutonomousConfig: SwarmConfig = { ...mockConfig, autonomous: { + // These tests exercise boot behaviour on adapter 'claude'; the adapter + // policy itself is covered in unattendedPolicy.test.ts. (AGT-4444) + allowDelegatedAdapters: true, enabled: true, pairMode: true, schedule: '*/30 * * * *', @@ -248,6 +251,19 @@ describe('service', () => { }); + it('refuses to start the autonomous runner on a delegated CLI adapter unless the operator opted in (AGT-4444)', async () => { + const { startAutonomous } = await import('../automation/autonomousRunner.js'); + vi.mocked(startAutonomous).mockClear(); + const strict = { + ...mockAutonomousConfig, + adapter: 'claude', + autonomous: { ...mockAutonomousConfig.autonomous!, allowDelegatedAdapters: false }, + } as SwarmConfig; + + await expect(startService(strict)).rejects.toThrow(/Refusing to start the autonomous runner[\s\S]*adapter: 'claude'[\s\S]*allowDelegatedAdapters/); + expect(startAutonomous).not.toHaveBeenCalled(); + }); + it('forwards the whole decomposition config, not a hand-picked subset', async () => { const { startAutonomous } = await import('../automation/autonomousRunner.js'); const decomposition = { @@ -733,6 +749,7 @@ describe('service', () => { const configWithAutonomous: SwarmConfig = { ...mockConfig, autonomous: { + allowDelegatedAdapters: true, enabled: true, pairMode: true, schedule: '*/30 * * * *', diff --git a/src/core/service.ts b/src/core/service.ts index af3f3b4d..defe4940 100644 --- a/src/core/service.ts +++ b/src/core/service.ts @@ -278,6 +278,10 @@ async function startServiceLocked(config: SwarmConfig): Promise { // and user-initiated dispatch (POST /api/work, dashboard buttons) all keep // working. `enabled: true` additionally turns the heartbeat on. (INT-3388) if (config.autonomous) { + // Refuse before anything starts: an unattended worker on a CLI-owned tool + // loop has none of OpenSwarm's fences. (AGT-4444) + const { assertUnattendedAdapters } = await import('../adapters/unattendedPolicy.js'); + assertUnattendedAdapters(config); const heartbeatEnabled = config.autonomous.enabled === true; console.log(heartbeatEnabled ? '[Service] Autonomous mode auto-start enabled' diff --git a/src/core/types.ts b/src/core/types.ts index 213bc1e8..49284e2a 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -717,6 +717,13 @@ export type AutonomousStartupConfig = { * Dependency installs that need the network go in projectAgents[].setup. */ workerNetwork?: 'loopback' | 'open'; + /** + * Run unattended work on delegated CLI adapters (claude/codex/cursor) even + * though OpenSwarm's publication fence, protected files and credential-free + * shell do not reach them (AGT-4444). Default false: the daemon refuses to + * start when such an adapter is configured. + */ + allowDelegatedAdapters?: boolean; periodicReviews?: Array<{ profile: 'permissions' | 'hygiene' | 'security' | 'review'; schedule: string; adapter?: 'codex' | 'cc-router' | 'cursor' }>; /** Explicit high-capability project supervisor. */ orchestrator?: OrchestratorConfig;