diff --git a/CHANGELOG.md b/CHANGELOG.md index beac7e28..eaceacc7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,14 @@ ### 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 + +- **`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 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. diff --git a/config.example.yaml b/config.example.yaml index 3a29b110..8d5ee98d 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -264,6 +264,14 @@ autonomous: # database credentials. # workerSandbox: on + # Network for the worker's shell (AGT-4798). loopback (default): only + # 127.0.0.1/::1 — a script the worker runs cannot reach GitHub, Cloudflare or + # a package registry, but a test can still start a local server. open: the + # pre-AGT-4798 behaviour, for a host whose sandbox cannot isolate the network + # (e.g. a container where bwrap cannot create a network namespace). Installs + # that need the network go in projectAgents[].setup below. + # workerNetwork: loopback + # Deterministic baseline-diff verification (enabled by default). # Define repo-specific commands in .openswarm/verify.yaml; see templates/verify.example.yaml. verify: @@ -320,6 +328,11 @@ autonomous: # generatedOutputRules: # - command: npm run generate:catalog # outputs: [docs/DATA-CATALOG.md] + # # Run in the worktree before the worker, network open, no credentials + # # (AGT-4798). Worktrees already share installed deps via sharedPaths, so + # # most projects need none; a failure ends the run before any model call. + # setup: + # - uv sync --frozen # Job profiles override the default Worker/Reviewer pair by task estimate. jobProfiles: diff --git a/src/adapters/tools.test.ts b/src/adapters/tools.test.ts index fd09e69e..666ab5f2 100644 --- a/src/adapters/tools.test.ts +++ b/src/adapters/tools.test.ts @@ -1214,6 +1214,32 @@ describe.runIf(hasMacSandbox)('bash tool with sandbox: on (live sandbox-exec)', } }); + it('worker bash reaches loopback but not a remote host by default; workerNetwork open restores it (AGT-4798)', async () => { + const { setWorkerNetworkPolicy } = await import('../support/osSandbox.js'); + const wt = await fs.mkdtemp(path.join(os.tmpdir(), 'osw-tool-net-')); + const probe = [ + 'import socket, errno', + 's = socket.socket(); s.bind(("127.0.0.1", 0)); s.listen()', + 'c = socket.socket(); c.connect(s.getsockname()); print("loopback=ok")', + 'try:', + ' socket.create_connection(("1.1.1.1", 443), timeout=3); print("remote=OPEN")', + 'except OSError as e:', + ' print("remote=" + ("EPERM" if e.errno == errno.EPERM else "other"))', + ].join('\n'); + await fs.writeFile(path.join(wt, 'probe.py'), probe); + try { + const fenced = await executeTool(makeCall('bash', { command: 'python3 probe.py' }), wt, createReadCache(), { sandbox: 'on' }); + expect(fenced.content).toContain('loopback=ok'); + expect(fenced.content).toContain('remote=EPERM'); + setWorkerNetworkPolicy('open'); + const open = await executeTool(makeCall('bash', { command: 'python3 probe.py' }), wt, createReadCache(), { sandbox: 'on' }); + expect(open.content).not.toContain('remote=EPERM'); + } finally { + setWorkerNetworkPolicy('loopback'); + await fs.rm(wt, { recursive: true, force: true }); + } + }); + it('sandbox: off (or unset) keeps the old unfenced behaviour', async () => { const wt = await fs.mkdtemp(path.join(os.tmpdir(), 'osw-tool-nofence-')); const homeTarget = path.join(homedir(), `.osw-nofence-probe-${process.pid}`); diff --git a/src/adapters/tools.ts b/src/adapters/tools.ts index 87bb32ea..455ac2e8 100644 --- a/src/adapters/tools.ts +++ b/src/adapters/tools.ts @@ -22,7 +22,7 @@ import { isHumanSurfaceReadOnlyEnabled, } from '../mcp/humanSurfacePolicy.js'; import { SandboxOutcomeUnknownError, type SandboxExecutorSession } from '../sandboxExecutor/protocol.js'; -import { looksLikeSandboxDenial, workerWritableRoots, wrapForSandbox } from '../support/osSandbox.js'; +import { getWorkerNetworkPolicy, looksLikeSandboxDenial, workerWritableRoots, wrapForSandbox } from '../support/osSandbox.js'; import { listNotes, readNote, writeNote } from '../support/scratchpad.js'; import { stageMemory, type RememberKind } from '../agents/stagedMemory.js'; import { linkedMainCheckoutOf } from '../security/gitWorktreeIdentity.js'; @@ -1087,7 +1087,7 @@ export async function executeTool( const fenced = execOptions?.sandbox === 'on' ? wrapForSandbox(['bash', '-c', boundedCommand], { writableRoots: workerWritableRoots(cwd, execOptions.scratchpadRunId), - allowNetwork: true, + allowNetwork: getWorkerNetworkPolicy() === 'open', }) : null; if (execOptions?.sandbox === 'on' && !fenced) { diff --git a/src/agents/workerSetup.test.ts b/src/agents/workerSetup.test.ts new file mode 100644 index 00000000..d8a510c2 --- /dev/null +++ b/src/agents/workerSetup.test.ts @@ -0,0 +1,94 @@ +// ============================================ +// OpenSwarm - Worker dependency setup step (AGT-4798) +// ============================================ + +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; + +// Only the "sandbox on, no backend" case reaches the wrapper; every other test +// runs with the sandbox off so it does not depend on the host. +vi.mock('../support/osSandbox.js', async (importOriginal) => ({ + ...(await importOriginal()), + wrapForSandbox: () => null, +})); + +const { runWorkerSetup, setupFailureBeforeWorker } = await import('./workerSetup.js'); + +const dirs: string[] = []; +const workdir = () => { + const dir = mkdtempSync(path.join(tmpdir(), 'osw-setup-')); + dirs.push(dir); + return dir; +}; +afterEach(() => { + for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }); +}); + +describe('runWorkerSetup', () => { + it('is a no-op when the project declares no setup', async () => { + expect(await runWorkerSetup(undefined, workdir(), { sandbox: 'off' })).toEqual({ ok: true, ran: 0 }); + expect(await runWorkerSetup([' '], workdir(), { sandbox: 'off' })).toEqual({ ok: true, ran: 0 }); + }); + + it('runs the commands in order inside the worktree', async () => { + const cwd = workdir(); + const result = await runWorkerSetup(['echo one > log', 'echo two >> log'], cwd, { sandbox: 'off' }); + expect(result).toEqual({ ok: true, ran: 2 }); + expect(readFileSync(path.join(cwd, 'log'), 'utf8')).toBe('one\ntwo\n'); + }); + + it('stops at the first failing command and reports its output', async () => { + const cwd = workdir(); + const result = await runWorkerSetup( + ['echo resolving; echo "error: lockfile out of date" >&2; exit 3', 'touch second'], + cwd, + { sandbox: 'off' }, + ); + expect(result).toMatchObject({ ok: false, command: expect.stringContaining('exit 3') }); + expect(!result.ok && result.detail).toContain('lockfile out of date'); + expect(existsSync(path.join(cwd, 'second'))).toBe(false); + }); + + it('reports a timeout as a timeout', async () => { + const result = await runWorkerSetup(['sleep 5'], workdir(), { sandbox: 'off', timeoutMs: 200 }); + expect(result).toMatchObject({ ok: false, detail: 'timed out' }); + }); + + it('runs without the daemon\'s credentials', async () => { + const cwd = workdir(); + const saved = process.env.GH_TOKEN; + process.env.GH_TOKEN = 'ghp_daemon_secret'; + try { + await runWorkerSetup(['printf "%s" "${GH_TOKEN:-none}" > seen'], cwd, { sandbox: 'off' }); + expect(readFileSync(path.join(cwd, 'seen'), 'utf8')).toBe('none'); + } finally { + if (saved === undefined) delete process.env.GH_TOKEN; + else process.env.GH_TOKEN = saved; + } + }); + + it('refuses when the sandbox is on but the host has none, like the bash tool', async () => { + const cwd = workdir(); + const result = await runWorkerSetup(['touch ran'], cwd, { sandbox: 'on' }); + expect(result).toMatchObject({ ok: false, detail: expect.stringContaining('autonomous.workerSandbox: off') }); + expect(existsSync(path.join(cwd, 'ran'))).toBe(false); + }); +}); + +describe('setupFailureBeforeWorker', () => { + it('lets the worker start when there is no setup or it succeeds', async () => { + expect(await setupFailureBeforeWorker(undefined, workdir(), 'off')).toBeUndefined(); + expect(await setupFailureBeforeWorker(['true'], workdir(), 'off')).toBeUndefined(); + }); + + it('ends the run as infra_error, naming the failed command, before any stage runs', async () => { + const result = await setupFailureBeforeWorker(['echo "no lockfile" >&2; exit 1'], workdir(), 'off'); + expect(result).toMatchObject({ + success: false, iterations: 0, finalStatus: 'infra_error', stages: [], + failureDetail: expect.stringContaining('no lockfile'), + }); + expect(result?.failureDetail).toContain('worker setup `echo'); + }); +}); diff --git a/src/agents/workerSetup.ts b/src/agents/workerSetup.ts new file mode 100644 index 00000000..5000efc0 --- /dev/null +++ b/src/agents/workerSetup.ts @@ -0,0 +1,100 @@ +// ============================================ +// OpenSwarm - Worker dependency setup step (AGT-4798) +// ============================================ +// +// The worker's shell runs with loopback-only network (AGT-4798) and no +// credentials (AGT-4783). Most runs need nothing more: worktrees share the +// project's installed dependencies through sharedPaths, and measured on +// cgf-portal and OpenSwarm the test suites run offline. A project that does +// need an install step declares it as `projectAgents[].setup`; those commands +// run here once, before the worker's loop, with the network open but still +// inside the sandbox and still without credentials. A failed setup ends the +// run before any model call: a worker started on a broken environment spends +// its turns discovering that. + +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import { buildWorkerShellEnv } from '../adapters/workerEnv.js'; +import { workerWritableRoots, wrapForSandbox } from '../support/osSandbox.js'; +import type { PipelineResult } from './pairPipeline.js'; + +const execFileAsync = promisify(execFile); + +/** Upper bound for one setup command; a cold `uv sync` or `npm ci` fits. */ +export const WORKER_SETUP_TIMEOUT_MS = 10 * 60_000; + +export interface WorkerSetupOptions { + /** `autonomous.workerSandbox`; `on` without a sandbox backend refuses, like the bash tool. */ + sandbox?: 'on' | 'off'; + timeoutMs?: number; +} + +export type WorkerSetupResult = + | { ok: true; ran: number } + | { ok: false; command: string; detail: string }; + +function tail(text: string, lines = 12): string { + return text.trim().split('\n').slice(-lines).join('\n'); +} + +/** Run the project's declared setup commands in the worktree, in order, stopping at the first failure. */ +export async function runWorkerSetup( + commands: readonly string[] | undefined, + cwd: string, + options: WorkerSetupOptions = {}, +): Promise { + const steps = (commands ?? []).map((command) => command.trim()).filter(Boolean); + const sandbox = options.sandbox ?? 'on'; + for (const command of steps) { + const fenced = sandbox === 'on' + ? wrapForSandbox(['bash', '-c', command], { writableRoots: workerWritableRoots(cwd), allowNetwork: true }) + : null; + if (sandbox === 'on' && !fenced) { + return { + ok: false, + command, + detail: 'autonomous.workerSandbox is "on" but this host has neither sandbox-exec nor bwrap; ' + + 'set autonomous.workerSandbox: off to run setup unfenced on purpose', + }; + } + try { + await execFileAsync(fenced?.file ?? 'bash', fenced?.args ?? ['-c', command], { + cwd, + env: buildWorkerShellEnv(), + timeout: options.timeoutMs ?? WORKER_SETUP_TIMEOUT_MS, + maxBuffer: 1024 * 1024 * 8, + }); + } catch (error) { + const err = error as { stdout?: string; stderr?: string; killed?: boolean; message?: string }; + const output = `${err.stdout ?? ''}\n${err.stderr ?? ''}`; + const reason = err.killed ? 'timed out' : tail(output) || err.message || 'failed'; + return { ok: false, command, detail: reason }; + } + } + return { ok: true, ran: steps.length }; +} + +/** + * The pipeline result for a run whose setup failed, or undefined when setup + * succeeded or there was none. Called before the worker's loop: installs need + * the network the worker's shell no longer has. The caller's cleanup removes + * the (clean) worktree. + */ +export async function setupFailureBeforeWorker( + commands: readonly string[] | undefined, + cwd: string, + sandbox?: 'on' | 'off', +): Promise { + if (!commands?.length) return undefined; + const setup = await runWorkerSetup(commands, cwd, { sandbox }); + if (setup.ok) return undefined; + return { + success: false, + sessionId: `worker-setup-fail-${Date.now()}`, + iterations: 0, + totalDuration: 0, + finalStatus: 'infra_error', + failureDetail: `worker setup \`${setup.command}\` failed: ${setup.detail}`, + stages: [], + }; +} diff --git a/src/automation/autonomousRunner.ts b/src/automation/autonomousRunner.ts index 2a6c4364..7ff2eff4 100644 --- a/src/automation/autonomousRunner.ts +++ b/src/automation/autonomousRunner.ts @@ -46,7 +46,7 @@ import { import { checkWorkAllowed } from '../support/timeWindow.js'; import { shouldEarlyStuckForInfeasibility } from '../support/feasibilityDetector.js'; import { citedPathsAreEphemeral } from '../support/worktreeEphemeral.js'; -import { resolveProjectGoal } from '../support/projectGoal.js'; +import { resolveOwningProject, resolveProjectGoal } from '../support/projectGoal.js'; import { recordTaskOutcome } from '../memory/repoKnowledge.js'; import { updateProjectAfterTask } from '../linear/projectUpdater.js'; import { TaskScheduler, initScheduler, normalizeProjectPath } from '../orchestration/taskScheduler.js'; @@ -3600,6 +3600,7 @@ export class AutonomousRunner { jobProfiles: this.config.jobProfiles, getRolesForProject: (p) => this.getRolesForProject(p), getProjectGoal: (p) => resolveProjectGoal(this.config.projectAgents, p), + getProjectSetup: (p) => resolveOwningProject(this.config.projectAgents, p)?.setup, reportToDiscord, worktreeMode: this.config.worktreeMode ?? false, scheduleNextHeartbeat: () => this.scheduleNextHeartbeat(), diff --git a/src/automation/runnerExecution.ts b/src/automation/runnerExecution.ts index bdc5ab90..eaf558b5 100644 --- a/src/automation/runnerExecution.ts +++ b/src/automation/runnerExecution.ts @@ -177,6 +177,7 @@ export interface ExecutionContext { getRolesForProject: (projectPath: string) => DefaultRolesConfig | undefined; /** Standing goal configured for the project at this path; every stage reads it (AGT-4662). */ getProjectGoal?: (projectPath: string) => string | undefined; + getProjectSetup?: (projectPath: string) => string[] | undefined; // projectAgents[].setup (AGT-4798) reportToDiscord: (message: string | EmbedBuilder) => Promise; /** Git worktree mode: work in an isolated worktree per issue, auto-create PR */ worktreeMode?: boolean; @@ -969,6 +970,8 @@ export async function executePipeline( } try { + const setupFailure = await (await import('../agents/workerSetup.js')).setupFailureBeforeWorker(ctx.getProjectSetup?.(projectPath), actualPath, ctx.workerSandbox); // AGT-4798 + if (setupFailure) return setupFailure; const roles = ctx.getRolesForProject(projectPath); // look up config using original path const { prepareRunCoordination, normalizeAdapterRouting } = await import('../coordination/runCoordination.js'); const runMetadata = pipelineMetadata(task, projectPath, worktreeInfo); diff --git a/src/core/config.test.ts b/src/core/config.test.ts index 36f59206..3989fd40 100644 --- a/src/core/config.test.ts +++ b/src/core/config.test.ts @@ -371,6 +371,23 @@ agents: expect(agents?.[1].goal).toBeUndefined(); }); + it('carries workerNetwork and a project setup list to the runtime config (AGT-4798)', () => { + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(readFileSync).mockReturnValue(JSON.stringify({ + language: 'en', + linear: { apiKey: 'k', teamId: 't' }, + agents: [{ name: 'main', projectPath: '/p', enabled: true, paused: false }], + autonomous: { + enabled: true, + workerNetwork: 'open', + projectAgents: [{ projectPath: '~/dev/cgf-portal', setup: [' uv sync --frozen '] }], + }, + })); + const autonomous = loadConfig('/tmp/config.json').autonomous; + expect(autonomous?.workerNetwork).toBe('open'); + expect(autonomous?.projectAgents?.[0].setup).toEqual(['uv sync --frozen']); + }); + it('rejects a goal that is empty or longer than a goal should be (AGT-4662)', () => { const withGoal = (goal: string) => JSON.stringify({ language: 'en', @@ -452,6 +469,7 @@ agents: expect(config.autonomous?.mcpPolicies?.orchestrator.servers).toEqual(['github', 'linear']); 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?.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 6674f508..345baaf5 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -241,6 +241,8 @@ const ProjectAgentConfigSchema = z.object({ })).optional(), /** Standing goal every stage follows for tasks in this project (AGT-4662) */ goal: z.string().trim().min(1).max(MAX_PROJECT_GOAL_CHARS).optional(), + /** Dependency install commands run before the worker, network open, no credentials (AGT-4798) */ + setup: z.array(z.string().trim().min(1)).optional(), /** Per-role configuration override */ roles: ProjectRolesOverrideSchema, }); @@ -477,6 +479,8 @@ const AutonomousConfigSchema = z.object({ * (bash as the daemon user, regex denylist only). */ workerSandbox: z.enum(['on', 'off']).default('on'), + /** 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({ profile: z.enum(['permissions', 'hygiene', 'security', 'review']), schedule: z.string().min(1), @@ -883,6 +887,7 @@ function transformConfig(raw: RawConfig): SwarmConfig { mcpPolicies: raw.autonomous.mcpPolicies, adapterRouting: raw.autonomous.adapterRouting, workerSandbox: raw.autonomous.workerSandbox, + workerNetwork: raw.autonomous.workerNetwork, periodicReviews: raw.autonomous.periodicReviews, orchestrator: raw.autonomous.orchestrator, orchestratorSchedule: raw.autonomous.orchestratorSchedule, diff --git a/src/core/service.ts b/src/core/service.ts index 0286d2a6..af3f3b4d 100644 --- a/src/core/service.ts +++ b/src/core/service.ts @@ -433,8 +433,11 @@ async function startServiceLocked(config: SwarmConfig): Promise { : `[Service] Autonomous runner ready for explicit dispatch (pairMode: ${config.autonomous.pairMode}${modelInfo})`); // Say which fence the worker's shell runs under, so an unfenced native // daemon is visible in the boot log rather than discovered later. (AGT-4387) - const { detectSandboxBackend } = await import('../support/osSandbox.js'); + const { detectSandboxBackend, setWorkerNetworkPolicy } = await import('../support/osSandbox.js'); const sandboxMode = config.autonomous.workerSandbox ?? 'on'; + const networkPolicy = config.autonomous.workerNetwork ?? 'loopback'; + setWorkerNetworkPolicy(networkPolicy); + console.log(`[Service] Worker network: ${networkPolicy}`); const backend = detectSandboxBackend(); console.log(sandboxMode === 'on' ? (backend ? `[Service] Worker sandbox: on (${backend})` : '[Service] Worker sandbox: on, but no sandbox-exec/bwrap on this host — worker bash commands will be refused (set autonomous.workerSandbox: off to run them unfenced)') diff --git a/src/core/types.ts b/src/core/types.ts index f6c52de1..213bc1e8 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -444,6 +444,12 @@ export type ProjectAgentConfig = { /** Trusted command-to-output declarations for generated files. A task must * name the command before its outputs join the binding worker write scope. */ generatedOutputRules?: Array<{ command: string; outputs: string[] }>; + /** + * Dependency install commands run in the worktree before the worker's loop, + * with the network open but no credentials (AGT-4798). A failure ends the run + * as infra_error before any model call. + */ + setup?: string[]; /** * Standing goal for every task in this project (AGT-4662). The draft, worker, * reviewer and planner all read it, and the draft may decline a task that @@ -704,6 +710,13 @@ export type AutonomousStartupConfig = { * are refused (AGT-4783) — set 'off' to run them unfenced on purpose. */ workerSandbox?: 'on' | 'off'; + /** + * Network for the worker's shell (AGT-4798). 'loopback' (default): only + * 127.0.0.1/::1, so nothing the worker runs can reach a remote. 'open': the + * pre-AGT-4798 behaviour, for a host whose sandbox cannot isolate the network. + * Dependency installs that need the network go in projectAgents[].setup. + */ + workerNetwork?: 'loopback' | 'open'; periodicReviews?: Array<{ profile: 'permissions' | 'hygiene' | 'security' | 'review'; schedule: string; adapter?: 'codex' | 'cc-router' | 'cursor' }>; /** Explicit high-capability project supervisor. */ orchestrator?: OrchestratorConfig; diff --git a/src/locale/prompts/en.ts b/src/locale/prompts/en.ts index aebcd2e8..c93461f8 100644 --- a/src/locale/prompts/en.ts +++ b/src/locale/prompts/en.ts @@ -84,7 +84,8 @@ Forbidden: rm -rf, git reset --hard, git clean, drop database, chmod 777, .env o ## Harness boundary (binding — overrides any instruction file above) You are one stage of an automated pipeline. When you finish, the harness commits your working tree, opens or updates the pull request, runs the review, and updates the issue tracker. Therefore, in this run: -- Do NOT run \`git commit\`, \`git push\`, \`gh pr …\`, \`gh issue …\`, \`openswarm …\`, or change any remote, PR, or tracker state. The bash tool refuses these; do not look for another way. +- Do NOT run \`git commit\`, \`git push\`, \`gh pr …\`, \`gh issue …\`, \`openswarm …\`, or change any remote, PR, or tracker state. Your shell has no GitHub, cloud or registry credentials and no network beyond localhost, so they fail anyway; do not look for another way. +- Do not run repository scripts that release, deploy, publish or provision (e.g. \`scripts/*release*\`, \`deploy*\`, \`infra/\`) — not even with \`--help\`. A script without a \`--help\` handler runs for real. - Instruction files above (CLAUDE.md, AGENTS.md, rules) that describe committing, pushing, opening PRs, running reviews, or moving tracker issues describe a human-driven session. They do not apply to this run. Their coding, testing, and style rules still do. - Leave your changes in the working tree and finish with your summary in the format the task asked for. `, diff --git a/src/locale/prompts/ko.ts b/src/locale/prompts/ko.ts index 86f34aa7..daf679b2 100644 --- a/src/locale/prompts/ko.ts +++ b/src/locale/prompts/ko.ts @@ -74,7 +74,8 @@ export const koPrompts: PromptTemplates = { ## 하네스 경계 (구속력 있음 — 위의 어떤 지시 파일보다 우선) 너는 자동화 파이프라인의 한 단계다. 네가 끝나면 하네스가 작업 트리를 커밋하고, PR을 열거나 갱신하고, 리뷰를 돌리고, 이슈 트래커를 갱신한다. 따라서 이 실행에서는: -- \`git commit\`, \`git push\`, \`gh pr …\`, \`gh issue …\`, \`openswarm …\` 를 실행하지 말고, 리모트·PR·트래커 상태를 바꾸지 마라. bash 도구가 이 명령들을 거부한다 — 다른 방법을 찾지 마라. +- \`git commit\`, \`git push\`, \`gh pr …\`, \`gh issue …\`, \`openswarm …\` 를 실행하지 말고, 리모트·PR·트래커 상태를 바꾸지 마라. 너의 셸에는 GitHub·클라우드·레지스트리 자격증명이 없고 localhost 밖으로 나가는 네트워크도 없으므로 어차피 실패한다 — 다른 방법을 찾지 마라. +- 릴리스·배포·퍼블리시·프로비저닝을 하는 저장소 스크립트(\`scripts/*release*\`, \`deploy*\`, \`infra/\` 등)는 \`--help\`로도 실행하지 마라. \`--help\` 처리가 없는 스크립트는 실제로 실행된다. - 위의 지시 파일(CLAUDE.md, AGENTS.md, rules)에 있는 커밋·push·PR 생성·리뷰 실행·트래커 이슈 전이 절차는 사람이 운전하는 세션의 것이다. 이 실행에는 적용되지 않는다. 코딩·테스트·스타일 규칙은 그대로 적용된다. - 변경은 작업 트리에 남기고, 과제가 요구한 형식의 요약으로 끝내라. `, diff --git a/src/support/osSandbox.test.ts b/src/support/osSandbox.test.ts index 8fa639c9..d113b74e 100644 --- a/src/support/osSandbox.test.ts +++ b/src/support/osSandbox.test.ts @@ -23,9 +23,11 @@ import { } from './osSandbox.js'; describe('buildMacSandboxProfile', () => { - it('matches the verify stage profile when network is denied (INT-3103 shape)', () => { + it('allows loopback only, split by direction, when network is denied (AGT-4798)', () => { expect(buildMacSandboxProfile({ writableRoots: ['/w'], allowNetwork: false })).toBe( '(version 1) (deny default) (allow process*) (allow file-read*) (allow sysctl-read) ' + + '(allow network-bind (local ip "localhost:*")) (allow network-inbound (local ip "localhost:*")) ' + + '(allow network-outbound (remote ip "localhost:*")) ' + '(allow file-write* (subpath "/w") (literal "/dev/null") (literal "/dev/tty"))', ); }); @@ -116,4 +118,24 @@ describe.runIf(hasMacSandbox)('sandbox-exec fence (live)', () => { expect(existsSync(join(outside, 'nope'))).toBe(false); expect(detectSandboxBackend('darwin')).toBe('sandbox-exec'); }); + + // The distinguishing error is EPERM: a host that is merely offline fails + // with ENETUNREACH or a timeout instead, so this cannot pass by accident. + it('without network, serves loopback and refuses a remote host (AGT-4798)', () => { + const wt = mkdtempSync(join(tmpdir(), 'osw-net-')); + const probe = [ + 'import socket, errno', + 's = socket.socket(); s.bind(("127.0.0.1", 0)); s.listen()', + 'c = socket.socket(); c.connect(s.getsockname()); print("loopback=ok")', + 'try:', + ' socket.create_connection(("1.1.1.1", 443), timeout=3); print("remote=OPEN")', + 'except OSError as e:', + ' print("remote=" + ("EPERM" if e.errno == errno.EPERM else "other:" + str(e.errno)))', + ].join('\n'); + const spec = { writableRoots: [realpathSync(wt)], allowNetwork: false }; + const fenced = wrapForSandbox(['python3', '-c', probe], spec, 'sandbox-exec')!; + const out = execFileSync(fenced.file, fenced.args, { encoding: 'utf8' }); + expect(out).toContain('loopback=ok'); + expect(out).toContain('remote=EPERM'); + }); }); diff --git a/src/support/osSandbox.ts b/src/support/osSandbox.ts index 80f3f791..d7985878 100644 --- a/src/support/osSandbox.ts +++ b/src/support/osSandbox.ts @@ -23,10 +23,33 @@ import { join } from 'node:path'; export interface SandboxSpec { /** Directories the command may write to. Others are read-only. */ writableRoots: string[]; - /** Whether the command may open network connections. */ + /** + * Whether the command may reach the network. When false it may still use + * loopback (a test that starts a local server), never another host. + */ allowNetwork: boolean; } +/** + * How much network a worker's shell gets (AGT-4798). `loopback` (default): only + * 127.0.0.1/::1, so a script that wraps `gh`, `wrangler` or `curl` cannot reach + * a remote even with a credential it found on disk. `open`: the previous + * behaviour, for a host whose sandbox cannot isolate the network (e.g. a + * container where bwrap cannot create a network namespace). + */ +export type WorkerNetworkPolicy = 'loopback' | 'open'; + +let workerNetworkPolicy: WorkerNetworkPolicy = 'loopback'; + +/** Set once at daemon start from `autonomous.workerNetwork`. */ +export function setWorkerNetworkPolicy(policy: WorkerNetworkPolicy): void { + workerNetworkPolicy = policy; +} + +export function getWorkerNetworkPolicy(): WorkerNetworkPolicy { + return workerNetworkPolicy; +} + export interface SandboxedCommand { file: string; args: string[]; @@ -60,7 +83,13 @@ export function buildMacSandboxProfile(spec: SandboxSpec): string { '(literal "/dev/null")', '(literal "/dev/tty")', ].join(' '); - const network = spec.allowNetwork ? ' (allow network*)' : ''; + // Loopback only, split by direction. A single `(allow network* (local ip + // "localhost:*"))` also admits outbound connections to remote hosts (an + // outbound socket's local end matches), measured on macOS 2026-10-21. + const network = spec.allowNetwork + ? ' (allow network*)' + : ' (allow network-bind (local ip "localhost:*")) (allow network-inbound (local ip "localhost:*"))' + + ' (allow network-outbound (remote ip "localhost:*"))'; return `(version 1) (deny default) (allow process*) (allow file-read*) (allow sysctl-read)${network} (allow file-write* ${writes})`; } diff --git a/src/support/projectGoal.ts b/src/support/projectGoal.ts index ddd598d8..2fd89dfe 100644 --- a/src/support/projectGoal.ts +++ b/src/support/projectGoal.ts @@ -20,21 +20,17 @@ function projectRoot(path: string): string { } /** - * The goal configured for the project that owns `projectPath`, or undefined. - * - * `projectPath` may be a task worktree nested under the project (the pipeline - * runs in `/worktree/`), so a project owns every path beneath it. - * When projects nest, the most specific one wins. A project without a goal does - * not fall through to an enclosing project's goal: that would hand a repo the - * direction written for its parent. + * The configured project that owns `projectPath`: the most specific entry whose + * path is `projectPath` or one of its ancestors (a task worktree sits under its + * project). Settings are read from the owner only, never from an enclosing one. */ -export function resolveProjectGoal( - agents: ReadonlyArray | undefined, +export function resolveOwningProject( + agents: ReadonlyArray | undefined, projectPath: string, -): string | undefined { +): T | undefined { if (!agents?.length) return undefined; const target = projectRoot(projectPath); - let owner: GoalBearingProject | undefined; + let owner: T | undefined; let ownerRoot = ''; for (const agent of agents) { const root = projectRoot(agent.projectPath); @@ -44,7 +40,23 @@ export function resolveProjectGoal( ownerRoot = root; } } - const goal = owner?.goal?.trim(); + return owner; +} + +/** + * The goal configured for the project that owns `projectPath`, or undefined. + * + * `projectPath` may be a task worktree nested under the project (the pipeline + * runs in `/worktree/`), so a project owns every path beneath it. + * When projects nest, the most specific one wins. A project without a goal does + * not fall through to an enclosing project's goal: that would hand a repo the + * direction written for its parent. + */ +export function resolveProjectGoal( + agents: ReadonlyArray | undefined, + projectPath: string, +): string | undefined { + const goal = resolveOwningProject(agents, projectPath)?.goal?.trim(); if (!goal) return undefined; return goal.length > MAX_PROJECT_GOAL_CHARS ? `${goal.slice(0, MAX_PROJECT_GOAL_CHARS)}…` : goal; }