compose: add wrapper-first dogfood workspace (#1488)
ci/woodpecker/push/publish Pipeline was successful

Co-authored-by: marcie <[email protected]>
This commit was merged in pull request #1488.
This commit is contained in:
2026-08-30 22:29:30 +00:00
committed by orch-01
parent e18d13d36f
commit 736b0affc1
16 changed files with 1708 additions and 264 deletions
+4 -2
View File
@@ -27,10 +27,11 @@ import { McpClientService } from '../mcp-client/mcp-client.service.js';
import { SkillLoaderService } from './skill-loader.service.js';
import { createBrainTools } from './tools/brain-tools.js';
import { createCoordTools } from './tools/coord-tools.js';
import { createDeliveryTools } from './tools/delivery-tools.js';
import { createMemoryTools } from './tools/memory-tools.js';
import { createFileTools } from './tools/file-tools.js';
import { createGitTools } from './tools/git-tools.js';
import { createShellTools } from './tools/shell-tools.js';
import { createShellToolsIfEnabled } from './tools/shell-tools.js';
import { createWebTools } from './tools/web-tools.js';
import { createSearchTools } from './tools/search-tools.js';
import type { SessionInfoDto, SessionMetrics } from './session.dto.js';
@@ -167,7 +168,8 @@ export class AgentService implements OnModuleDestroy {
),
...createFileTools(sandboxDir),
...createGitTools(sandboxDir),
...createShellTools(sandboxDir),
...createShellToolsIfEnabled(sandboxDir),
...createDeliveryTools(sandboxDir),
...createWebTools(),
...createSearchTools(),
];
@@ -0,0 +1,210 @@
import { afterEach, describe, expect, it } from 'vitest';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import type { ToolDefinition } from '@mariozechner/pi-coding-agent';
import { createFileTools } from './file-tools.js';
import { createShellTools, createShellToolsIfEnabled } from './shell-tools.js';
import {
createDeliveryTools,
type DeliveryToolEnvironment,
type ProcessResult,
type ProcessRunner,
} from './delivery-tools.js';
const tempDirs: string[] = [];
function tempDir(prefix: string): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
tempDirs.push(dir);
return dir;
}
function textOf(result: unknown): string {
const typed = result as { content: Array<{ text: string }> };
return typed.content.map((item) => item.text).join('\n');
}
async function execute(tool: ToolDefinition, params: Record<string, unknown>): Promise<unknown> {
return (
tool.execute as unknown as (id: string, input: Record<string, unknown>) => Promise<unknown>
)('test-call', params);
}
function ok(stdout = ''): ProcessResult {
return { exitCode: 0, stdout, stderr: '', timedOut: false };
}
function deliveryEnv(extra: Partial<DeliveryToolEnvironment> = {}): DeliveryToolEnvironment {
return {
AGENT_DELIVERY_ENABLED: 'true',
MOSAIC_GIT_TOOLS_DIR: '/opt/mosaic/tools/git',
MOSAIC_GIT_IDENTITY: 'stack-dogfood',
MOSAIC_AGENT_NAME: 'stack-dogfood',
MOSAIC_BRAIN_HOME: '/opt/mosaic/brain',
MOSAIC_INTEGRATION_TRUNK: 'next',
HOME: '/home/node',
PATH: '/usr/bin:/bin',
...extra,
};
}
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true });
}
});
describe('dogfood execution boundary', () => {
it('removes shell_exec mechanically while its first-token bypass red control stays live', async () => {
const sandbox = tempDir('mosaic-shell-boundary-');
expect(createShellToolsIfEnabled(sandbox, { AGENT_SHELL_ENABLED: 'false' })).toEqual([]);
const redControl = createShellTools(sandbox)[0]!;
const result = await execute(redControl, { command: 'env printf FIRST_TOKEN_BYPASS' });
expect(textOf(result)).toContain('FIRST_TOKEN_BYPASS');
});
it('refuses an outside-sandbox token-shaped read and proves the path guard is the enforcement', async () => {
const root = tempDir('mosaic-file-boundary-');
const sandbox = path.join(root, 'workspace', 'stack');
const token = path.join(
root,
'brain',
'fleet',
'agents',
'stack-dogfood',
'secrets',
'gitea-mosaicstack-stack-dogfood.token',
);
fs.mkdirSync(sandbox, { recursive: true });
fs.mkdirSync(path.dirname(token), { recursive: true });
fs.writeFileSync(token, 'OUTSIDE_SANDBOX_SENTINEL');
const read = createFileTools(sandbox).find((tool) => tool.name === 'fs_read_file')!;
const refused = await execute(read, { path: token });
expect(textOf(refused)).toContain('Path escape attempt blocked');
expect(textOf(refused)).not.toContain('OUTSIDE_SANDBOX_SENTINEL');
fs.symlinkSync(token, path.join(sandbox, 'credential.token'));
const symlinkRefused = await execute(read, { path: 'credential.token' });
expect(textOf(symlinkRefused)).toContain('Path escape attempt blocked');
expect(textOf(symlinkRefused)).not.toContain('OUTSIDE_SANDBOX_SENTINEL');
const redRead = createFileTools(root).find((tool) => tool.name === 'fs_read_file')!;
const redControl = await execute(redRead, { path: token });
expect(textOf(redControl)).toContain('OUTSIDE_SANDBOX_SENTINEL');
});
});
describe('delivery tools', () => {
it('stay absent unless explicitly enabled and reject identity mismatch', () => {
const sandbox = tempDir('mosaic-delivery-disabled-');
expect(createDeliveryTools(sandbox, {})).toEqual([]);
expect(() =>
createDeliveryTools(sandbox, deliveryEnv({ MOSAIC_AGENT_NAME: 'another-seat' })),
).toThrow('matching safe MOSAIC agent and git identities');
});
it('publishes through execFile-only git and queue operations with a scrubbed environment', async () => {
const sandbox = tempDir('mosaic-delivery-publish-');
fs.writeFileSync(path.join(sandbox, 'change.md'), 'change');
const calls: Array<{ file: string; args: readonly string[]; env: NodeJS.ProcessEnv }> = [];
const runner: ProcessRunner = async (file, args, options) => {
calls.push({ file, args, env: options.env });
if (args[0] === 'branch') return ok('feat/1487-dogfood-proof\n');
return ok();
};
const hostile = {
...deliveryEnv(),
BASH_ENV: '/tmp/injected',
'BASH_FUNC_read%%': '() { :; }',
GITEA_TOKEN: 'must-not-cross',
} as DeliveryToolEnvironment;
const publish = createDeliveryTools(sandbox, hostile, runner).find(
(tool) => tool.name === 'git_publish_branch',
)!;
const result = await execute(publish, {
issue: 1487,
paths: ['change.md'],
commitMessage: 'docs: dogfood proof (#1487)',
});
expect(textOf(result)).toBe('Published branch feat/1487-dogfood-proof as stack-dogfood.');
expect(calls.map((call) => call.file)).toEqual([
'/usr/bin/git',
'/usr/bin/git',
'/usr/bin/git',
'/opt/mosaic/tools/git/ci-queue-wait.sh',
'/usr/bin/git',
]);
expect(calls[3]!.args).toEqual(['--purpose', 'push', '-B', 'feat/1487-dogfood-proof']);
expect(calls[4]!.args).toEqual(['push', '--set-upstream', 'origin', 'feat/1487-dogfood-proof']);
for (const call of calls) {
expect(call.file).not.toMatch(/(?:^|\/)sh$/);
expect(call.env).not.toHaveProperty('BASH_ENV');
expect(Object.keys(call.env).some((key) => key.startsWith('BASH_FUNC_'))).toBe(false);
expect(call.env).not.toHaveProperty('GITEA_TOKEN');
expect(call.env.MOSAIC_GIT_IDENTITY).toBe('stack-dogfood');
}
});
it('opens PRs only through pr-create.sh against next', async () => {
const sandbox = tempDir('mosaic-delivery-pr-');
const calls: Array<{ file: string; args: readonly string[] }> = [];
const runner: ProcessRunner = async (file, args) => {
calls.push({ file, args });
if (args[0] === 'branch') return ok('feat/1487-dogfood-proof\n');
return ok('https://git.mosaicstack.dev/mosaicstack/stack/pulls/999\n');
};
const openPr = createDeliveryTools(sandbox, deliveryEnv(), runner).find(
(tool) => tool.name === 'git_open_pull_request',
)!;
const result = await execute(openPr, {
issue: 1487,
title: 'docs: dogfood proof',
body: 'Measured from the in-stack agent.',
});
expect(textOf(result)).toContain('/pulls/999');
expect(calls[1]!.file).toBe('/opt/mosaic/tools/git/pr-create.sh');
expect(calls[1]!.args).toEqual([
'-t',
'docs: dogfood proof',
'-b',
'Measured from the in-stack agent.',
'-B',
'next',
'-H',
'feat/1487-dogfood-proof',
'-i',
'1487',
]);
});
it('blocks publish paths outside the sandbox before staging', async () => {
const root = tempDir('mosaic-delivery-path-');
const sandbox = path.join(root, 'sandbox');
const outside = path.join(root, 'outside.md');
fs.mkdirSync(sandbox);
fs.writeFileSync(outside, 'OUTSIDE_DELIVERY_SENTINEL');
const calls: Array<{ file: string; args: readonly string[] }> = [];
const runner: ProcessRunner = async (file, args) => {
calls.push({ file, args });
return args[0] === 'branch' ? ok('feat/1487-dogfood-proof\n') : ok();
};
const publish = createDeliveryTools(sandbox, deliveryEnv(), runner).find(
(tool) => tool.name === 'git_publish_branch',
)!;
const result = await execute(publish, {
issue: 1487,
paths: [outside],
commitMessage: 'docs: must not publish',
});
expect(textOf(result)).toContain('Path escape attempt blocked');
expect(textOf(result)).not.toContain('OUTSIDE_DELIVERY_SENTINEL');
expect(calls).toHaveLength(1);
});
});
@@ -0,0 +1,282 @@
import { Type } from '@sinclair/typebox';
import type { ToolDefinition } from '@mariozechner/pi-coding-agent';
import { spawn } from 'node:child_process';
import path from 'node:path';
import { guardPath, SandboxEscapeError } from './path-guard.js';
const PROCESS_TIMEOUT_MS = 120_000;
const MAX_OUTPUT_BYTES = 100 * 1024;
const SAFE_IDENTITY = /^[a-z0-9][a-z0-9-]{0,62}$/;
const SAFE_BRANCH = /^(?:feat|fix|docs|test)\/[a-z0-9][a-z0-9._/-]*$/i;
export interface ProcessResult {
exitCode: number | null;
stdout: string;
stderr: string;
timedOut: boolean;
}
export type ProcessRunner = (
file: string,
args: readonly string[],
options: { cwd: string; env: NodeJS.ProcessEnv; timeoutMs: number },
) => Promise<ProcessResult>;
export interface DeliveryToolEnvironment {
AGENT_DELIVERY_ENABLED?: string;
MOSAIC_GIT_TOOLS_DIR?: string;
MOSAIC_GIT_IDENTITY?: string;
MOSAIC_AGENT_NAME?: string;
MOSAIC_BRAIN_HOME?: string;
MOSAIC_CREDENTIAL_SPOOL?: string;
MOSAIC_CREDENTIAL_LINEAGE_FENCE?: string;
MOSAIC_INTEGRATION_TRUNK?: string;
HOME?: string;
PATH?: string;
LANG?: string;
LC_ALL?: string;
}
function runProcess(
file: string,
args: readonly string[],
options: { cwd: string; env: NodeJS.ProcessEnv; timeoutMs: number },
): Promise<ProcessResult> {
return new Promise((resolve) => {
const child = spawn(file, [...args], {
cwd: options.cwd,
env: options.env,
shell: false,
stdio: ['ignore', 'pipe', 'pipe'],
});
let stdout = '';
let stderr = '';
let timedOut = false;
let outputBytes = 0;
const append = (current: string, chunk: Buffer): string => {
const remaining = MAX_OUTPUT_BYTES - outputBytes;
if (remaining <= 0) return current;
outputBytes += chunk.length;
return current + chunk.subarray(0, remaining).toString();
};
child.stdout.on('data', (chunk: Buffer) => {
stdout = append(stdout, chunk);
});
child.stderr.on('data', (chunk: Buffer) => {
stderr = append(stderr, chunk);
});
const timer = setTimeout(() => {
timedOut = true;
child.kill('SIGTERM');
}, options.timeoutMs);
child.on('error', (error) => {
clearTimeout(timer);
resolve({ exitCode: null, stdout, stderr: `${stderr}${String(error)}`, timedOut });
});
child.on('close', (exitCode) => {
clearTimeout(timer);
resolve({ exitCode, stdout, stderr, timedOut });
});
});
}
function cleanEnvironment(env: DeliveryToolEnvironment): NodeJS.ProcessEnv {
const clean: NodeJS.ProcessEnv = {
GIT_TERMINAL_PROMPT: '0',
};
for (const key of [
'HOME',
'PATH',
'LANG',
'LC_ALL',
'MOSAIC_GIT_IDENTITY',
'MOSAIC_AGENT_NAME',
'MOSAIC_BRAIN_HOME',
'MOSAIC_CREDENTIAL_SPOOL',
'MOSAIC_CREDENTIAL_LINEAGE_FENCE',
] as const) {
const value = env[key];
if (value !== undefined) clean[key] = value;
}
return clean;
}
function textResult(text: string): {
content: Array<{ type: 'text'; text: string }>;
details: undefined;
} {
return { content: [{ type: 'text', text }], details: undefined };
}
function describeFailure(label: string, result: ProcessResult): string {
if (result.timedOut) return `${label} timed out`;
const diagnostic = result.stderr.trim() || result.stdout.trim() || 'no diagnostic output';
return `${label} failed (exit ${result.exitCode ?? 'null'}): ${diagnostic}`;
}
function currentBranchPattern(issue: number): RegExp {
return new RegExp(`^(?:feat|fix|docs|test)/${issue}(?:[-/].+)$`, 'i');
}
export function createDeliveryTools(
sandboxDir: string,
sourceEnv: DeliveryToolEnvironment = process.env,
runner: ProcessRunner = runProcess,
): ToolDefinition[] {
if (sourceEnv.AGENT_DELIVERY_ENABLED !== 'true') return [];
const identity = sourceEnv.MOSAIC_GIT_IDENTITY ?? '';
const agentName = sourceEnv.MOSAIC_AGENT_NAME ?? '';
const toolsDir = sourceEnv.MOSAIC_GIT_TOOLS_DIR ?? '';
const baseBranch = sourceEnv.MOSAIC_INTEGRATION_TRUNK ?? 'next';
if (!SAFE_IDENTITY.test(identity) || identity !== agentName) {
throw new Error('Delivery tools require matching safe MOSAIC agent and git identities');
}
if (!path.isAbsolute(toolsDir)) {
throw new Error('Delivery tools require an absolute MOSAIC_GIT_TOOLS_DIR');
}
if (!SAFE_BRANCH.test(`feat/${baseBranch}`) || baseBranch.includes('/')) {
throw new Error('Delivery tools require a safe integration branch name');
}
const env = cleanEnvironment(sourceEnv);
const queueGuard = path.join(toolsDir, 'ci-queue-wait.sh');
const prCreate = path.join(toolsDir, 'pr-create.sh');
const run = (file: string, args: readonly string[], timeoutMs = PROCESS_TIMEOUT_MS) =>
runner(file, args, { cwd: sandboxDir, env, timeoutMs });
const readBranch = async (): Promise<{ branch?: string; error?: string }> => {
const result = await run('/usr/bin/git', ['branch', '--show-current'], 15_000);
if (result.exitCode !== 0) return { error: describeFailure('git branch', result) };
const branch = result.stdout.trim();
if (!SAFE_BRANCH.test(branch))
return { error: `Unsafe delivery branch: ${branch || '<empty>'}` };
if (branch === baseBranch || branch === 'main') {
return { error: `Refusing delivery from protected branch ${branch}` };
}
return { branch };
};
const publish: ToolDefinition = {
name: 'git_publish_branch',
label: 'Publish Git Branch',
description:
'Stage explicit files in the current sandbox branch, commit them as the dedicated dogfood identity, run the CI queue guard, and push the branch. No shell or raw provider API is used.',
parameters: Type.Object({
issue: Type.Integer({ minimum: 1, description: 'Tracking issue number' }),
paths: Type.Array(Type.String(), {
minItems: 1,
maxItems: 100,
description: 'Files to stage, relative to the sandbox root',
}),
commitMessage: Type.String({ minLength: 1, maxLength: 4000 }),
}),
async execute(_toolCallId, params) {
const { issue, paths, commitMessage } = params as {
issue: number;
paths: string[];
commitMessage: string;
};
const branchResult = await readBranch();
if (!branchResult.branch) return textResult(`Error: ${branchResult.error}`);
const branch = branchResult.branch;
if (!currentBranchPattern(issue).test(branch)) {
return textResult(`Error: branch ${branch} does not carry issue ${issue}`);
}
const relativePaths: string[] = [];
try {
const sandboxRoot = guardPath('.', sandboxDir);
for (const candidate of paths) {
const resolved = guardPath(candidate, sandboxDir);
const relative = path.relative(sandboxRoot, resolved);
if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) {
throw new SandboxEscapeError(candidate, sandboxDir, resolved);
}
relativePaths.push(relative);
}
} catch (error) {
return textResult(`Error: ${error instanceof Error ? error.message : String(error)}`);
}
const add = await run('/usr/bin/git', ['add', '--', ...relativePaths], 30_000);
if (add.exitCode !== 0) return textResult(`Error: ${describeFailure('git add', add)}`);
const commit = await run(
'/usr/bin/git',
[
'-c',
`user.name=${identity}`,
'-c',
`user.email=${identity}@mosaic.invalid`,
'commit',
'-m',
commitMessage,
'--',
...relativePaths,
],
60_000,
);
if (commit.exitCode !== 0)
return textResult(`Error: ${describeFailure('git commit', commit)}`);
const queue = await run(queueGuard, ['--purpose', 'push', '-B', branch]);
if (queue.exitCode !== 0) {
return textResult(`Error: ${describeFailure('CI queue guard', queue)}`);
}
const push = await run(
'/usr/bin/git',
['push', '--set-upstream', 'origin', branch],
PROCESS_TIMEOUT_MS,
);
if (push.exitCode !== 0) return textResult(`Error: ${describeFailure('git push', push)}`);
return textResult(`Published branch ${branch} as ${identity}.`);
},
};
const openPr: ToolDefinition = {
name: 'git_open_pull_request',
label: 'Open Pull Request',
description:
'Open a pull request from the current sandbox branch through the Mosaic pr-create wrapper. The wrapper targets the configured integration branch and links the tracking issue.',
parameters: Type.Object({
issue: Type.Integer({ minimum: 1, description: 'Tracking issue number' }),
title: Type.String({ minLength: 1, maxLength: 240 }),
body: Type.String({ maxLength: 20_000 }),
}),
async execute(_toolCallId, params) {
const { issue, title, body } = params as { issue: number; title: string; body: string };
const branchResult = await readBranch();
if (!branchResult.branch) return textResult(`Error: ${branchResult.error}`);
const branch = branchResult.branch;
if (!currentBranchPattern(issue).test(branch)) {
return textResult(`Error: branch ${branch} does not carry issue ${issue}`);
}
const result = await run(prCreate, [
'-t',
title,
'-b',
body,
'-B',
baseBranch,
'-H',
branch,
'-i',
String(issue),
]);
if (result.exitCode !== 0) {
return textResult(`Error: ${describeFailure('pr-create wrapper', result)}`);
}
return textResult(result.stdout.trim() || `Pull request opened from ${branch}.`);
},
};
return [publish, openPr];
}
+2 -2
View File
@@ -1,7 +1,7 @@
import { Type } from '@sinclair/typebox';
import type { ToolDefinition } from '@mariozechner/pi-coding-agent';
import { readFile, writeFile, readdir, stat } from 'node:fs/promises';
import { guardPath, guardPathUnsafe, SandboxEscapeError } from './path-guard.js';
import { guardPath, guardWritePath, SandboxEscapeError } from './path-guard.js';
const MAX_READ_BYTES = 512 * 1024; // 512 KB read limit
const MAX_WRITE_BYTES = 1024 * 1024; // 1 MB write limit
@@ -92,7 +92,7 @@ export function createFileTools(baseDir: string): ToolDefinition[] {
};
let safePath: string;
try {
safePath = guardPathUnsafe(path, baseDir);
safePath = guardWritePath(path, baseDir);
} catch (err) {
if (err instanceof SandboxEscapeError) {
return {
+2 -1
View File
@@ -1,8 +1,9 @@
export { createBrainTools } from './brain-tools.js';
export { createCoordTools } from './coord-tools.js';
export { createDeliveryTools } from './delivery-tools.js';
export { createFileTools } from './file-tools.js';
export { createGitTools } from './git-tools.js';
export { createSearchTools } from './search-tools.js';
export { createShellTools } from './shell-tools.js';
export { createShellTools, createShellToolsIfEnabled } from './shell-tools.js';
export { createWebTools } from './web-tools.js';
export { createSkillTools } from './skill-tools.js';
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
import { guardPath, guardPathUnsafe, SandboxEscapeError } from './path-guard.js';
import { guardPath, guardPathUnsafe, guardWritePath, SandboxEscapeError } from './path-guard.js';
import path from 'node:path';
import os from 'node:os';
import fs from 'node:fs';
@@ -101,4 +101,55 @@ describe('guardPath', () => {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
it('rejects a symlink inside the sandbox that resolves outside it', () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'path-guard-test-'));
const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'path-guard-outside-'));
try {
const target = path.join(outside, 'credential.token');
fs.writeFileSync(target, 'OUTSIDE_SYMLINK_SENTINEL');
fs.symlinkSync(target, path.join(tmpDir, 'credential.token'));
expect(() => guardPath('credential.token', tmpDir)).toThrow(SandboxEscapeError);
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
fs.rmSync(outside, { recursive: true, force: true });
}
});
});
describe('guardWritePath', () => {
it('allows a new file under an existing real sandbox directory', () => {
const sandbox = fs.mkdtempSync(path.join(os.tmpdir(), 'path-write-test-'));
try {
expect(guardWritePath('new.txt', sandbox)).toBe(path.join(sandbox, 'new.txt'));
} finally {
fs.rmSync(sandbox, { recursive: true, force: true });
}
});
it('rejects writes through a file symlink that resolves outside the sandbox', () => {
const sandbox = fs.mkdtempSync(path.join(os.tmpdir(), 'path-write-test-'));
const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'path-write-outside-'));
try {
const target = path.join(outside, 'credential.token');
fs.writeFileSync(target, 'OUTSIDE_WRITE_SENTINEL');
fs.symlinkSync(target, path.join(sandbox, 'credential.token'));
expect(() => guardWritePath('credential.token', sandbox)).toThrow(SandboxEscapeError);
} finally {
fs.rmSync(sandbox, { recursive: true, force: true });
fs.rmSync(outside, { recursive: true, force: true });
}
});
it('rejects new files under a directory symlink that leaves the sandbox', () => {
const sandbox = fs.mkdtempSync(path.join(os.tmpdir(), 'path-write-test-'));
const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'path-write-outside-'));
try {
fs.symlinkSync(outside, path.join(sandbox, 'outside'));
expect(() => guardWritePath('outside/new.txt', sandbox)).toThrow(SandboxEscapeError);
} finally {
fs.rmSync(sandbox, { recursive: true, force: true });
fs.rmSync(outside, { recursive: true, force: true });
}
});
});
+48 -32
View File
@@ -1,47 +1,63 @@
import path from 'node:path';
import fs from 'node:fs';
/**
* Resolves a user-provided path and verifies it is inside the allowed sandbox directory.
* Throws SandboxEscapeError if the resolved path is outside the sandbox.
*
* Uses realpathSync to resolve symlinks in the sandbox root. The user-supplied path
* is checked for containment AFTER lexical resolution but BEFORE resolving any symlinks
* within the user path — so symlink escape attempts are caught too.
*
* @param userPath - The path provided by the agent (may be relative or absolute)
* @param sandboxDir - The allowed root directory (already validated on session creation)
* @returns The resolved absolute path, guaranteed to be within sandboxDir
*/
export function guardPath(userPath: string, sandboxDir: string): string {
const resolved = path.resolve(sandboxDir, userPath);
const sandboxResolved = fs.realpathSync.native(sandboxDir);
function isContained(candidate: string, root: string): boolean {
return candidate === root || candidate.startsWith(root + path.sep);
}
// Normalize both paths to resolve any symlinks in the sandbox root itself.
// For the user path, we check containment BEFORE resolving symlinks in the path
// (so we catch symlink escape attempts too — the resolved path must still be under sandbox)
if (!resolved.startsWith(sandboxResolved + path.sep) && resolved !== sandboxResolved) {
function assertLexicalContainment(userPath: string, sandboxDir: string): string {
const resolved = path.resolve(sandboxDir, userPath);
const sandboxAbsolute = path.resolve(sandboxDir);
if (!isContained(resolved, sandboxAbsolute)) {
throw new SandboxEscapeError(userPath, sandboxDir, resolved);
}
return resolved;
}
/**
* Validates a path without resolving symlinks in the user-provided portion.
* Use for paths that may not exist yet (creates, writes).
*
* Performs a lexical containment check only using path.resolve.
* Resolve an existing path and verify both its lexical path and real symlink
* target remain inside the sandbox.
*/
export function guardPath(userPath: string, sandboxDir: string): string {
const resolved = assertLexicalContainment(userPath, sandboxDir);
const sandboxReal = fs.realpathSync.native(sandboxDir);
const resolvedReal = fs.realpathSync.native(resolved);
if (!isContained(resolvedReal, sandboxReal)) {
throw new SandboxEscapeError(userPath, sandboxDir, resolvedReal);
}
return resolvedReal;
}
/**
* Resolve a writable file path whose parent already exists. Existing targets
* are resolved fully. New targets use the real parent directory, which blocks
* writes through a parent symlink that leaves the sandbox.
*/
export function guardWritePath(userPath: string, sandboxDir: string): string {
const resolved = assertLexicalContainment(userPath, sandboxDir);
const sandboxReal = fs.realpathSync.native(sandboxDir);
let writableReal: string;
try {
writableReal = fs.realpathSync.native(resolved);
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code !== 'ENOENT') throw error;
const parentReal = fs.realpathSync.native(path.dirname(resolved));
writableReal = path.join(parentReal, path.basename(resolved));
}
if (!isContained(writableReal, sandboxReal)) {
throw new SandboxEscapeError(userPath, sandboxDir, writableReal);
}
return writableReal;
}
/**
* Lexical-only validation for non-filesystem pathspecs such as `git diff --`
* targets, where the path may name a deleted file and Git does not dereference
* a tracked symlink.
*/
export function guardPathUnsafe(userPath: string, sandboxDir: string): string {
const resolved = path.resolve(sandboxDir, userPath);
const sandboxAbs = path.resolve(sandboxDir);
if (!resolved.startsWith(sandboxAbs + path.sep) && resolved !== sandboxAbs) {
throw new SandboxEscapeError(userPath, sandboxDir, resolved);
}
return resolved;
return assertLexicalContainment(userPath, sandboxDir);
}
export class SandboxEscapeError extends Error {
@@ -128,6 +128,14 @@ function runCommand(
});
}
export function createShellToolsIfEnabled(
sandboxDir: string | undefined,
env: NodeJS.ProcessEnv = process.env,
): ToolDefinition[] {
if (env['AGENT_SHELL_ENABLED'] === 'false') return [];
return createShellTools(sandboxDir);
}
export function createShellTools(sandboxDir?: string): ToolDefinition[] {
const defaultCwd = sandboxDir ?? process.cwd();