Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <runId>`, `exec await <runId> --timeout <s>` and `exec answer <runId> [--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.
Expand Down
7 changes: 5 additions & 2 deletions config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
46 changes: 46 additions & 0 deletions src/adapters/tools.sandboxUnavailable.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof import('../support/osSandbox.js')>()),
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);
});
});
29 changes: 25 additions & 4 deletions src/adapters/tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
47 changes: 18 additions & 29 deletions src/adapters/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand All @@ -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 포맷) ============
Expand Down Expand Up @@ -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,
Expand Down
133 changes: 133 additions & 0 deletions src/adapters/workerEnv.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading
Loading