diff --git a/CHANGELOG.md b/CHANGELOG.md index 2fc64f2f..beac7e28 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ ## [Unreleased] +### 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. + ### Added - **`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. diff --git a/config.example.yaml b/config.example.yaml index d6edf5fa..3a29b110 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -257,8 +257,11 @@ autonomous: # OS fence for the worker's bash tool (AGT-4387). on (default): every bash # call runs under sandbox-exec (macOS) / bwrap (Linux) with writes limited to - # the worktree, temp dir and package caches; network stays open. off: bash - # runs as the daemon user with only the regex denylist (pre-AGT-4387). + # the worktree, temp dir and package caches; network stays open. A host with + # neither binary refuses worker bash commands instead of running them open + # (AGT-4783). off: bash runs unfenced with only the regex denylist. Either way + # the shell gets an allowlisted env and a private HOME — no GitHub, cloud or + # database credentials. # workerSandbox: on # Deterministic baseline-diff verification (enabled by default). diff --git a/src/adapters/tools.sandboxUnavailable.test.ts b/src/adapters/tools.sandboxUnavailable.test.ts new file mode 100644 index 00000000..777a49bf --- /dev/null +++ b/src/adapters/tools.sandboxUnavailable.test.ts @@ -0,0 +1,46 @@ +// ============================================ +// OpenSwarm - workerSandbox "on" without a sandbox fails closed (AGT-4783) +// ============================================ +// +// A host with no sandbox-exec/bwrap used to warn once and run the worker's +// shell unfenced. Kept in its own file because it replaces the sandbox wrapper +// for the whole module graph. + +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { existsSync, mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; + +vi.mock('../support/osSandbox.js', async (importOriginal) => ({ + ...(await importOriginal()), + wrapForSandbox: () => null, +})); + +const { executeTool, SANDBOX_UNAVAILABLE_MESSAGE } = await import('./tools.js'); + +const dirs: string[] = []; +afterEach(() => { + for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }); +}); + +const call = (command: string) => ({ id: 'tc-1', function: { name: 'bash', arguments: JSON.stringify({ command }) } }); + +describe('worker bash with workerSandbox on and no sandbox backend', () => { + it('refuses the command instead of running it unfenced, and names the opt-out', async () => { + const cwd = mkdtempSync(path.join(tmpdir(), 'osw-nosandbox-')); + dirs.push(cwd); + const res = await executeTool(call('touch ran'), cwd, undefined, { sandbox: 'on' }); + expect(res.is_error).toBe(true); + expect(res.content).toBe(SANDBOX_UNAVAILABLE_MESSAGE); + expect(res.content).toContain('autonomous.workerSandbox: off'); + expect(existsSync(path.join(cwd, 'ran'))).toBe(false); + }); + + it('still runs when the operator turned the sandbox off on purpose', async () => { + const cwd = mkdtempSync(path.join(tmpdir(), 'osw-nosandbox-')); + dirs.push(cwd); + const res = await executeTool(call('touch ran'), cwd, undefined, { sandbox: 'off' }); + expect(res.is_error).toBe(false); + expect(existsSync(path.join(cwd, 'ran'))).toBe(true); + }); +}); diff --git a/src/adapters/tools.test.ts b/src/adapters/tools.test.ts index a1b4c7eb..fd09e69e 100644 --- a/src/adapters/tools.test.ts +++ b/src/adapters/tools.test.ts @@ -821,17 +821,38 @@ describe('ToolExecOptions', () => { expect((env.PATH ?? '').split(':').filter((p) => p === cargo)).toHaveLength(1); }); - it('buildBashToolEnv withholds human-surface credentials but preserves DevOps and DB credentials', () => { + // #493 kept DevOps and DB credentials in the worker shell on purpose. AGT-4783 + // reversed that: a worker's `release.sh --help` used the inherited GitHub + // credentials to ship a production deploy, so the shell now gets none. + it('buildBashToolEnv withholds human-surface, DevOps and DB credentials alike', () => { const env = buildBashToolEnv({ PATH: '/usr/bin:/bin', SLACK_BOT_TOKEN: 'drop', DISCORD_WEBHOOK_URL: 'drop', - GITHUB_TOKEN: 'keep', - POSTGRES_DSN: 'keep', + GITHUB_TOKEN: 'drop', + POSTGRES_DSN: 'drop', }); expect(env).not.toHaveProperty('SLACK_BOT_TOKEN'); expect(env).not.toHaveProperty('DISCORD_WEBHOOK_URL'); - expect(env).toMatchObject({ GITHUB_TOKEN: 'keep', POSTGRES_DSN: 'keep' }); + expect(env).not.toHaveProperty('GITHUB_TOKEN'); + expect(env).not.toHaveProperty('POSTGRES_DSN'); + }); + + it('a worker shell command cannot read a GitHub token the daemon holds', async () => { + const saved = process.env.GH_TOKEN; + process.env.GH_TOKEN = 'ghp_daemon_secret'; + try { + const res = await executeTool( + makeCall('bash', { command: 'printf "[%s]" "${GH_TOKEN:-}"; printf "[%s]" "$GH_CONFIG_DIR"' }), + TMP_DIR, + ); + expect(res.is_error).toBe(false); + expect(res.content).not.toContain('ghp_daemon_secret'); + expect(res.content).toMatch(/^\[\]\[.+\]/); + } finally { + if (saved === undefined) delete process.env.GH_TOKEN; + else process.env.GH_TOKEN = saved; + } }); it('bash rejects a literal human-surface webhook write before spawning it', async () => { diff --git a/src/adapters/tools.ts b/src/adapters/tools.ts index 831dab7c..87bb32ea 100644 --- a/src/adapters/tools.ts +++ b/src/adapters/tools.ts @@ -10,7 +10,6 @@ import { existsSync, realpathSync } from 'node:fs'; import { execFile } from 'node:child_process'; import { StringDecoder } from 'node:string_decoder'; import { promisify } from 'node:util'; -import { homedir } from 'node:os'; import path from 'node:path'; import { webFetch, webSearch } from './webTools.js'; import { isMcpTool, callMcpTool } from '../mcp/mcpClient.js'; @@ -21,7 +20,6 @@ import { COORDINATION_TOOL_NAMES, executeCoordinationTool, type CoordinationTool import { humanSurfaceShellWriteReason, isHumanSurfaceReadOnlyEnabled, - stripHumanSurfaceEnv, } from '../mcp/humanSurfacePolicy.js'; import { SandboxOutcomeUnknownError, type SandboxExecutorSession } from '../sandboxExecutor/protocol.js'; import { looksLikeSandboxDenial, workerWritableRoots, wrapForSandbox } from '../support/osSandbox.js'; @@ -30,40 +28,29 @@ import { stageMemory, type RememberKind } from '../agents/stagedMemory.js'; import { linkedMainCheckoutOf } from '../security/gitWorktreeIdentity.js'; import { crossWorktreeAuditNote } from './crossWorktreeAudit.js'; import { publicationCommandIn, publicationFenceMessage } from './publicationFence.js'; -import { resourceAwareTestCommand, testResourceShellPrefix, withTestResourceBudget } from '../support/testResourceBudget.js'; +import { resourceAwareTestCommand, testResourceShellPrefix } from '../support/testResourceBudget.js'; import { isCommandBlocked } from './shellCommandGuard.js'; +import { buildWorkerShellEnv } from './workerEnv.js'; const execFileAsync = promisify(execFile); -let sandboxUnavailableWarned = false; -function warnSandboxUnavailableOnce(): void { - if (sandboxUnavailableWarned) return; - sandboxUnavailableWarned = true; - console.warn('[Tools] workerSandbox is on but this host has no sandbox-exec/bwrap — bash runs unfenced'); -} +/** + * `workerSandbox: on` with no sandbox-exec/bwrap on the host used to warn once + * and run unfenced. An unattended worker must not silently lose its fence, so + * the command is refused and the operator is told how to opt out. (AGT-4783) + */ +export const SANDBOX_UNAVAILABLE_MESSAGE = + 'BLOCKED: autonomous.workerSandbox is "on" but this host has neither sandbox-exec nor bwrap, ' + + 'so the command was not run. Install the sandbox, or set autonomous.workerSandbox: off ' + + 'to run worker shell commands unfenced on purpose.'; /** - * The daemon's launchd PATH is minimal (/usr/bin:/bin:/opt/homebrew/bin, no - * ~/.cargo/bin or ~/.local/bin), and the `bash` tool runs non-login (`bash -c`), - * so it never sources the user's shell profile. Result: `cargo`/`uv`/`pyenv` - * shims are "command not found", the worker cannot build/test its Rust/Python - * changes, and the validation gate + reviewer reject it → Max-iteration STUCK - * (observed live on every WAVE Rust task: "cargo: command not found"). Prepend - * the common user tool bins a login shell would have added. (INT-2485) + * The worker shell's environment: an allowlist with a private HOME and no + * GitHub/git/ssh identity (AGT-4783), plus the user tool bins a login shell + * would add, since `bash -c` never sources the profile (INT-2485). */ export function buildBashToolEnv(base: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv { - const home = homedir(); - const extra = [ - path.join(home, '.cargo', 'bin'), - path.join(home, '.local', 'bin'), - path.join(home, 'go', 'bin'), - path.join(home, '.bun', 'bin'), - '/opt/homebrew/bin', - '/usr/local/bin', - ]; - const current = (base.PATH ?? '').split(':').filter(Boolean); - const merged = [...extra.filter((p) => !current.includes(p)), ...current]; - return withTestResourceBudget(stripHumanSurfaceEnv({ ...base, PATH: merged.join(':') })); + return buildWorkerShellEnv(base); } // ============ 도구 정의 (OpenAI function calling 포맷) ============ @@ -1103,7 +1090,9 @@ export async function executeTool( allowNetwork: true, }) : null; - if (execOptions?.sandbox === 'on' && !fenced) warnSandboxUnavailableOnce(); + if (execOptions?.sandbox === 'on' && !fenced) { + return { tool_call_id: callId, content: SANDBOX_UNAVAILABLE_MESSAGE, is_error: true }; + } try { const { stdout, stderr } = await execFileAsync(fenced?.file ?? 'bash', fenced?.args ?? ['-c', boundedCommand], { cwd, diff --git a/src/adapters/workerEnv.test.ts b/src/adapters/workerEnv.test.ts new file mode 100644 index 00000000..061eba3c --- /dev/null +++ b/src/adapters/workerEnv.test.ts @@ -0,0 +1,133 @@ +// ============================================ +// OpenSwarm - Worker shell environment (AGT-4783) +// ============================================ +// +// The worker's shell must not carry an identity it could act with. These tests +// pin the three parts of that: the variable allowlist, the private HOME (with +// caches re-pointed at the real directories), and the gh/git/ssh identity +// overrides. The live checks run the real `git` binary with the env, because +// "the variable is set" is not the same as "git ignores the keychain". + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { execFileSync, spawnSync } from 'node:child_process'; +import { mkdirSync, mkdtempSync, readdirSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { buildWorkerShellEnv, resetWorkerHomeForTests } from './workerEnv.js'; + +let realHome: string; +let workerHome: string; + +beforeEach(() => { + realHome = mkdtempSync(path.join(tmpdir(), 'osw-real-home-')); + workerHome = mkdtempSync(path.join(tmpdir(), 'osw-worker-home-')); +}); + +afterEach(() => { + rmSync(realHome, { recursive: true, force: true }); + rmSync(workerHome, { recursive: true, force: true }); + resetWorkerHomeForTests(); +}); + +const build = (base: NodeJS.ProcessEnv) => buildWorkerShellEnv(base, { realHome, workerHome }); + +describe('worker shell variable allowlist', () => { + it('drops tokens, keys, DSNs, agent sockets and the daemon\'s own secrets', () => { + const env = build({ + PATH: '/usr/bin:/bin', + GH_TOKEN: 'x', GITHUB_TOKEN: 'x', CLOUDFLARE_API_TOKEN: 'x', AWS_SECRET_ACCESS_KEY: 'x', + SSH_AUTH_SOCK: '/tmp/agent', LINEAR_API_KEY: 'x', OPENAI_API_KEY: 'x', OPENROUTER_API_KEY: 'x', + POSTGRES_DSN: 'x', DATABASE_URL: 'x', OPENSWARM_WEB_TOKEN: 'x', NPM_TOKEN: 'x', + GIT_CONFIG_GLOBAL: '/somewhere/.gitconfig', NODE_OPTIONS: '--require /tmp/hook.js', + }); + for (const key of [ + 'GH_TOKEN', 'GITHUB_TOKEN', 'CLOUDFLARE_API_TOKEN', 'AWS_SECRET_ACCESS_KEY', 'SSH_AUTH_SOCK', + 'LINEAR_API_KEY', 'OPENAI_API_KEY', 'OPENROUTER_API_KEY', 'POSTGRES_DSN', 'DATABASE_URL', + 'OPENSWARM_WEB_TOKEN', 'NPM_TOKEN', 'GIT_CONFIG_GLOBAL', 'NODE_OPTIONS', + ]) { + expect(env, key).not.toHaveProperty(key); + } + }); + + it('keeps locale, terminal, CI and the test-resource budget', () => { + const env = build({ + PATH: '/usr/bin', LANG: 'en_US.UTF-8', LC_ALL: 'C', TERM: 'xterm', CI: 'true', TZ: 'UTC', + OPENSWARM_TEST_PARALLELISM: '2', + }); + expect(env).toMatchObject({ LANG: 'en_US.UTF-8', LC_ALL: 'C', TERM: 'xterm', CI: 'true', TZ: 'UTC' }); + expect(env.OPENSWARM_TEST_PARALLELISM).toBeDefined(); + }); + + it('prepends the user tool bins resolved against the real home', () => { + const env = build({ PATH: '/usr/bin' }); + const parts = (env.PATH ?? '').split(path.delimiter); + expect(parts).toContain(path.join(realHome, '.cargo', 'bin')); + expect(parts.indexOf(path.join(realHome, '.cargo', 'bin'))).toBeLessThan(parts.indexOf('/usr/bin')); + }); +}); + +describe('worker shell HOME', () => { + it('is the private home, not the real one, so file-based logins are not found', () => { + const env = build({ PATH: '/usr/bin', HOME: realHome }); + expect(env.HOME).toBe(workerHome); + expect(env.XDG_CONFIG_HOME).toBe(path.join(workerHome, '.config')); + expect(env.HOME).not.toBe(realHome); + }); + + it('re-points existing caches and toolchain homes at the real directories', () => { + mkdirSync(path.join(realHome, '.cargo'), { recursive: true }); + mkdirSync(path.join(realHome, '.cache', 'uv'), { recursive: true }); + mkdirSync(path.join(realHome, '.npm'), { recursive: true }); + const env = build({ PATH: '/usr/bin' }); + expect(env.CARGO_HOME).toBe(path.join(realHome, '.cargo')); + expect(env.UV_CACHE_DIR).toBe(path.join(realHome, '.cache', 'uv')); + expect(env.npm_config_cache).toBe(path.join(realHome, '.npm')); + // A home the host never created is not invented. + expect(env).not.toHaveProperty('RUSTUP_HOME'); + }); + + it('keeps an explicitly configured toolchain home over the default', () => { + const env = build({ PATH: '/usr/bin', CARGO_HOME: '/opt/cargo' }); + expect(env.CARGO_HOME).toBe('/opt/cargo'); + }); + + it('creates one private home per process when none is given', () => { + const first = buildWorkerShellEnv({ PATH: '/usr/bin' }, { realHome }); + const second = buildWorkerShellEnv({ PATH: '/usr/bin' }, { realHome }); + expect(first.HOME).toBe(second.HOME); + expect(first.HOME).not.toBe(realHome); + rmSync(first.HOME!, { recursive: true, force: true }); + }); +}); + +describe('worker shell gh / git / ssh identity', () => { + it('points gh at an empty config dir and resets git credential helpers', () => { + const env = build({ PATH: '/usr/bin' }); + expect(env.GH_CONFIG_DIR).toBe(path.join(workerHome, '.config', 'gh')); + expect(readdirSync(env.GH_CONFIG_DIR!)).toEqual([]); + expect(env).toMatchObject({ + GIT_CONFIG_COUNT: '1', + GIT_CONFIG_KEY_0: 'credential.helper', + GIT_CONFIG_VALUE_0: '', + GIT_TERMINAL_PROMPT: '0', + GH_PROMPT_DISABLED: '1', + }); + expect(env.GIT_SSH_COMMAND).toContain('-i /dev/null'); + }); + + it('git, run with the env, resolves no credential helper and no password (live)', () => { + const env = { ...build({ PATH: process.env.PATH }), LC_ALL: 'C' }; + // The last value wins; an empty one means "no helper", whatever the system + // config (osxkeychain on macOS) declared before it. + const helpers = execFileSync('git', ['config', '--get-all', 'credential.helper'], { env, encoding: 'utf8' }) + .split('\n'); + expect(helpers.filter(Boolean).length === 0 || helpers.at(-2) === '').toBe(true); + // With no helper and prompts disabled, git cannot produce a password: it + // exits non-zero instead of printing one. + const filled = spawnSync('git', ['credential', 'fill'], { + env, input: 'protocol=https\nhost=github.com\nusername=x\n\n', encoding: 'utf8', + }); + expect(filled.stdout).not.toMatch(/^password=(?!$)/m); + expect(filled.status).not.toBe(0); + }); +}); diff --git a/src/adapters/workerEnv.ts b/src/adapters/workerEnv.ts new file mode 100644 index 00000000..1cb17dcc --- /dev/null +++ b/src/adapters/workerEnv.ts @@ -0,0 +1,155 @@ +// ============================================ +// OpenSwarm - Worker shell environment (AGT-4783) +// ============================================ +// +// The worker's `bash` tool used to inherit the daemon's whole environment and +// its real HOME. On 2026-10-20 a cgf-portal worker ran +// `bash scripts/dev/release.sh --help`; the script called `gh workflow run` with +// the operator's GitHub credentials and shipped an unapproved production deploy. +// No command-name matcher can see inside a script, so the shell is given no +// identity to act with instead: +// +// - only an allowlist of variables crosses over (toolchain, locale, terminal, +// test-resource budget) — tokens, keys, DSNs and the daemon's own secrets +// do not; +// - HOME is a private directory, so file-based logins under the real HOME +// (`wrangler`'s OAuth file, `~/.npmrc`, `~/.netrc`, `~/.docker/config.json`) +// are not found by the tools that read them; caches and toolchain homes are +// re-pointed at the real directories so builds stay as fast as before; +// - `gh` gets an empty config dir, git's credential helpers are reset to none, +// and git over ssh gets no identity. +// +// This removes the credentials tools pick up on their own. A command that +// deliberately reads a token file by absolute path is still possible while the +// worker sandbox allows the network; network-off (S6 W1) closes that. + +import { mkdirSync, mkdtempSync, existsSync } from 'node:fs'; +import { homedir, tmpdir } from 'node:os'; +import path from 'node:path'; +import { withTestResourceBudget } from '../support/testResourceBudget.js'; + +/** Variables a worker shell may inherit verbatim. Everything else is dropped. */ +const INHERITED_KEYS = new Set([ + 'USER', 'LOGNAME', 'SHELL', 'LANG', 'LANGUAGE', 'TERM', 'COLORTERM', + 'NO_COLOR', 'FORCE_COLOR', 'CI', 'TZ', 'TMPDIR', 'TMP', 'TEMP', + // Toolchain selection, no credentials. + 'JAVA_HOME', 'GOROOT', 'VIRTUAL_ENV', 'CONDA_PREFIX', 'PYENV_VERSION', + // Trust stores; a corporate proxy's CA is not a credential. + 'SSL_CERT_FILE', 'SSL_CERT_DIR', 'NODE_EXTRA_CA_CERTS', 'REQUESTS_CA_BUNDLE', 'CURL_CA_BUNDLE', + // Test-resource budget emitted by withTestResourceBudget. + 'OPENSWARM_TEST_PARALLELISM', 'PYTEST_XDIST_AUTO_NUM_WORKERS', 'CARGO_BUILD_JOBS', + 'RAYON_NUM_THREADS', 'CMAKE_BUILD_PARALLEL_LEVEL', 'GOMAXPROCS', 'UV_CONCURRENT_BUILDS', + // Windows process basics. + 'SystemRoot', 'ComSpec', 'PATHEXT', 'windir', +]); + +/** + * Tool homes and caches that live under the real HOME. They are pointed back at + * the real directories (when present and not already set) so an isolated HOME + * does not force a cold toolchain or a re-download on every command. None of + * them is where the tool keeps a login. + */ +function realToolHomes(home: string): Record { + const library = path.join(home, 'Library', 'Caches'); + return { + CARGO_HOME: path.join(home, '.cargo'), + RUSTUP_HOME: path.join(home, '.rustup'), + NVM_DIR: path.join(home, '.nvm'), + PYENV_ROOT: path.join(home, '.pyenv'), + BUN_INSTALL: path.join(home, '.bun'), + GOPATH: path.join(home, 'go'), + XDG_CACHE_HOME: path.join(home, '.cache'), + UV_CACHE_DIR: path.join(home, '.cache', 'uv'), + UV_PYTHON_INSTALL_DIR: path.join(home, '.local', 'share', 'uv', 'python'), + npm_config_cache: path.join(home, '.npm'), + PIP_CACHE_DIR: process.platform === 'darwin' ? path.join(library, 'pip') : path.join(home, '.cache', 'pip'), + GOCACHE: process.platform === 'darwin' ? path.join(library, 'go-build') : path.join(home, '.cache', 'go-build'), + }; +} + +/** User tool bins a login shell would add; the bash tool runs non-login. (INT-2485) */ +function userToolBins(home: string): string[] { + return [ + path.join(home, '.cargo', 'bin'), + path.join(home, '.local', 'bin'), + path.join(home, 'go', 'bin'), + path.join(home, '.bun', 'bin'), + '/opt/homebrew/bin', + '/usr/local/bin', + ]; +} + +let processWorkerHome: string | undefined; + +/** One private HOME per daemon process, created on first use under the temp dir. */ +function defaultWorkerHome(): string { + if (processWorkerHome && existsSync(processWorkerHome)) return processWorkerHome; + processWorkerHome = mkdtempSync(path.join(tmpdir(), 'openswarm-worker-home-')); + return processWorkerHome; +} + +export interface WorkerShellEnvOptions { + /** The real home the tool homes and PATH bins are resolved against. */ + realHome?: string; + /** The private HOME the shell sees. Defaults to one temp dir per process. */ + workerHome?: string; +} + +/** + * The environment a worker's shell command runs with. See the file header for + * what it withholds and why. + */ +export function buildWorkerShellEnv( + base: NodeJS.ProcessEnv = process.env, + options: WorkerShellEnvOptions = {}, +): NodeJS.ProcessEnv { + const realHome = options.realHome ?? homedir(); + const workerHome = options.workerHome ?? defaultWorkerHome(); + const ghConfigDir = path.join(workerHome, '.config', 'gh'); + mkdirSync(ghConfigDir, { recursive: true }); + + const env: NodeJS.ProcessEnv = {}; + for (const [key, value] of Object.entries(base)) { + if (value !== undefined && (INHERITED_KEYS.has(key) || key.startsWith('LC_'))) env[key] = value; + } + + const current = (base.PATH ?? base.Path ?? '').split(path.delimiter).filter(Boolean); + const bins = userToolBins(realHome); + env.PATH = [...bins.filter((p) => !current.includes(p)), ...current].join(path.delimiter); + + for (const [key, dir] of Object.entries(realToolHomes(realHome))) { + const inherited = base[key]; + if (inherited) env[key] = inherited; + else if (existsSync(dir)) env[key] = dir; + } + + Object.assign(env, { + HOME: workerHome, + USERPROFILE: workerHome, + XDG_CONFIG_HOME: path.join(workerHome, '.config'), + XDG_DATA_HOME: path.join(workerHome, '.local', 'share'), + XDG_STATE_HOME: path.join(workerHome, '.local', 'state'), + GH_CONFIG_DIR: ghConfigDir, + GH_PROMPT_DISABLED: '1', + GIT_TERMINAL_PROMPT: '0', + // An empty credential.helper at command-line precedence clears every helper + // configured in the system or global config (osxkeychain included). + GIT_CONFIG_COUNT: '1', + GIT_CONFIG_KEY_0: 'credential.helper', + GIT_CONFIG_VALUE_0: '', + GIT_SSH_COMMAND: 'ssh -F /dev/null -o IdentitiesOnly=yes -i /dev/null -o BatchMode=yes', + // The private HOME has no ~/.gitconfig; commands that need an identity + // (stash, a local commit in a scratch repo) still work. + GIT_AUTHOR_NAME: 'OpenSwarm worker', + GIT_AUTHOR_EMAIL: 'worker@openswarm.invalid', + GIT_COMMITTER_NAME: 'OpenSwarm worker', + GIT_COMMITTER_EMAIL: 'worker@openswarm.invalid', + }); + + return withTestResourceBudget(env); +} + +/** Test seam: forget the per-process worker HOME. */ +export function resetWorkerHomeForTests(): void { + processWorkerHome = undefined; +} diff --git a/src/core/service.ts b/src/core/service.ts index 5ccfa38f..0286d2a6 100644 --- a/src/core/service.ts +++ b/src/core/service.ts @@ -437,7 +437,7 @@ async function startServiceLocked(config: SwarmConfig): Promise { const sandboxMode = config.autonomous.workerSandbox ?? 'on'; 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 — bash runs unfenced') + ? (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)') : '[Service] Worker sandbox: off (bash runs as the daemon user)'); } diff --git a/src/core/types.ts b/src/core/types.ts index 30d7f228..f6c52de1 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -700,7 +700,8 @@ export type AutonomousStartupConfig = { /** * OS fence for the worker's bash tool (AGT-4387): sandbox-exec (macOS) or * bwrap (Linux) with writes limited to the worktree and build caches. - * Default 'on'; a host without either binary warns once and runs unfenced. + * Default 'on'; on a host without either binary the worker's bash commands + * are refused (AGT-4783) — set 'off' to run them unfenced on purpose. */ workerSandbox?: 'on' | 'off'; periodicReviews?: Array<{ profile: 'permissions' | 'hygiene' | 'security' | 'review'; schedule: string; adapter?: 'codex' | 'cc-router' | 'cursor' }>; diff --git a/src/support/osSandbox.ts b/src/support/osSandbox.ts index 409797dc..80f3f791 100644 --- a/src/support/osSandbox.ts +++ b/src/support/osSandbox.ts @@ -79,9 +79,9 @@ export function buildBwrapArgs(spec: SandboxSpec): string[] { /** * Wrap `argv` so it runs inside the host sandbox. Returns null when the host - * has no sandbox — the caller decides whether that is a warning or a refusal - * (the verify stage refuses; the worker warns once and runs open, so a - * Windows host is not silently shut out of every task). + * has no sandbox — the caller decides what that means. Both the verify stage + * and the worker's bash tool refuse (AGT-4783); a host that cannot sandbox + * opts out explicitly with `autonomous.workerSandbox: off`. */ export function wrapForSandbox( argv: string[],