security: constrain dogfood delivery execution (#1487)

This commit is contained in:
2026-08-30 16:51:28 -05:00
parent 8356e72c1c
commit f194ccb8a3
13 changed files with 672 additions and 52 deletions
+4 -2
View File
@@ -8,7 +8,9 @@ GATEWAY_HOST_PORT=14242
# GATEWAY_IMAGE=git.mosaicstack.dev/mosaicstack/stack/gateway:sha-acf640d # GATEWAY_IMAGE=git.mosaicstack.dev/mosaicstack/stack/gateway:sha-acf640d
# Optional explicit dogfood overlay (docker-compose.dogfood.yml). # Optional explicit dogfood overlay (docker-compose.dogfood.yml).
# Both paths are required when that overlay is used. Use a dedicated next-based # All three paths are required when that overlay is used. Use a dedicated
# worktree and the external home of the unprivileged stack-dogfood seat. # next-based worktree, its canonical clone's .git directory, and the external
# home of the unprivileged stack-dogfood seat.
# MOSAIC_DOGFOOD_WORKTREE=/home/example/src/mosaic-stack-worktrees/dogfood-1487 # MOSAIC_DOGFOOD_WORKTREE=/home/example/src/mosaic-stack-worktrees/dogfood-1487
# MOSAIC_DOGFOOD_COMMON_GIT_DIR=/home/example/src/mosaic-stack/.git
# MOSAIC_DOGFOOD_SEAT_HOME=/home/example/.mosaic/fleet/agents/stack-dogfood # MOSAIC_DOGFOOD_SEAT_HOME=/home/example/.mosaic/fleet/agents/stack-dogfood
+8 -5
View File
@@ -226,10 +226,12 @@ seat outside the container, then set these paths in `.env`:
```dotenv ```dotenv
MOSAIC_DOGFOOD_WORKTREE=/path/to/mosaic-stack-worktrees/dogfood-1487 MOSAIC_DOGFOOD_WORKTREE=/path/to/mosaic-stack-worktrees/dogfood-1487
MOSAIC_DOGFOOD_COMMON_GIT_DIR=/path/to/mosaic-stack/.git
MOSAIC_DOGFOOD_SEAT_HOME=/path/to/.mosaic/fleet/agents/stack-dogfood MOSAIC_DOGFOOD_SEAT_HOME=/path/to/.mosaic/fleet/agents/stack-dogfood
``` ```
The seat home must contain only that seat's credential at The common Git directory must match the worktree's `.git` pointer. The seat home
must contain only that seat's credential at
`secrets/gitea-mosaicstack-stack-dogfood.token`. Never place the token value in `secrets/gitea-mosaicstack-stack-dogfood.token`. Never place the token value in
`.env`. Start the overlay with: `.env`. Start the overlay with:
@@ -240,10 +242,11 @@ docker compose \
--profile stack up -d --profile stack up -d
``` ```
The overlay scopes regular-agent tools to the mounted checkout. For issue and PR The overlay removes the general shell tool for every session, including admins.
operations, instruct the agent to use `/opt/mosaic/tools/git/`. The gateway image File tools stay inside the mounted checkout. Two dedicated delivery tools stage
configures `git-credential-mosaic` as Git's system credential helper, so pushes and explicit paths, run the CI queue guard, push through `git-credential-mosaic`, and
`pr-create.sh` resolve only the `stack-dogfood` slot and fail if it is absent. open PRs through `pr-create.sh`. They resolve only the `stack-dogfood` slot and fail
if it is absent.
This deployment route is separate from the local source-development restrictions This deployment route is separate from the local source-development restrictions
below. below.
+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 { SkillLoaderService } from './skill-loader.service.js';
import { createBrainTools } from './tools/brain-tools.js'; import { createBrainTools } from './tools/brain-tools.js';
import { createCoordTools } from './tools/coord-tools.js'; import { createCoordTools } from './tools/coord-tools.js';
import { createDeliveryTools } from './tools/delivery-tools.js';
import { createMemoryTools } from './tools/memory-tools.js'; import { createMemoryTools } from './tools/memory-tools.js';
import { createFileTools } from './tools/file-tools.js'; import { createFileTools } from './tools/file-tools.js';
import { createGitTools } from './tools/git-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 { createWebTools } from './tools/web-tools.js';
import { createSearchTools } from './tools/search-tools.js'; import { createSearchTools } from './tools/search-tools.js';
import type { SessionInfoDto, SessionMetrics } from './session.dto.js'; import type { SessionInfoDto, SessionMetrics } from './session.dto.js';
@@ -167,7 +168,8 @@ export class AgentService implements OnModuleDestroy {
), ),
...createFileTools(sandboxDir), ...createFileTools(sandboxDir),
...createGitTools(sandboxDir), ...createGitTools(sandboxDir),
...createShellTools(sandboxDir), ...createShellToolsIfEnabled(sandboxDir),
...createDeliveryTools(sandboxDir),
...createWebTools(), ...createWebTools(),
...createSearchTools(), ...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 } from '@sinclair/typebox';
import type { ToolDefinition } from '@mariozechner/pi-coding-agent'; import type { ToolDefinition } from '@mariozechner/pi-coding-agent';
import { readFile, writeFile, readdir, stat } from 'node:fs/promises'; 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_READ_BYTES = 512 * 1024; // 512 KB read limit
const MAX_WRITE_BYTES = 1024 * 1024; // 1 MB write limit const MAX_WRITE_BYTES = 1024 * 1024; // 1 MB write limit
@@ -92,7 +92,7 @@ export function createFileTools(baseDir: string): ToolDefinition[] {
}; };
let safePath: string; let safePath: string;
try { try {
safePath = guardPathUnsafe(path, baseDir); safePath = guardWritePath(path, baseDir);
} catch (err) { } catch (err) {
if (err instanceof SandboxEscapeError) { if (err instanceof SandboxEscapeError) {
return { return {
+2 -1
View File
@@ -1,8 +1,9 @@
export { createBrainTools } from './brain-tools.js'; export { createBrainTools } from './brain-tools.js';
export { createCoordTools } from './coord-tools.js'; export { createCoordTools } from './coord-tools.js';
export { createDeliveryTools } from './delivery-tools.js';
export { createFileTools } from './file-tools.js'; export { createFileTools } from './file-tools.js';
export { createGitTools } from './git-tools.js'; export { createGitTools } from './git-tools.js';
export { createSearchTools } from './search-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 { createWebTools } from './web-tools.js';
export { createSkillTools } from './skill-tools.js'; export { createSkillTools } from './skill-tools.js';
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'; 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 path from 'node:path';
import os from 'node:os'; import os from 'node:os';
import fs from 'node:fs'; import fs from 'node:fs';
@@ -101,4 +101,55 @@ describe('guardPath', () => {
fs.rmSync(tmpDir, { recursive: true, force: true }); 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 path from 'node:path';
import fs from 'node:fs'; import fs from 'node:fs';
/** function isContained(candidate: string, root: string): boolean {
* Resolves a user-provided path and verifies it is inside the allowed sandbox directory. return candidate === root || candidate.startsWith(root + path.sep);
* 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);
// Normalize both paths to resolve any symlinks in the sandbox root itself. function assertLexicalContainment(userPath: string, sandboxDir: string): string {
// For the user path, we check containment BEFORE resolving symlinks in the path const resolved = path.resolve(sandboxDir, userPath);
// (so we catch symlink escape attempts too — the resolved path must still be under sandbox) const sandboxAbsolute = path.resolve(sandboxDir);
if (!resolved.startsWith(sandboxResolved + path.sep) && resolved !== sandboxResolved) { if (!isContained(resolved, sandboxAbsolute)) {
throw new SandboxEscapeError(userPath, sandboxDir, resolved); throw new SandboxEscapeError(userPath, sandboxDir, resolved);
} }
return resolved; return resolved;
} }
/** /**
* Validates a path without resolving symlinks in the user-provided portion. * Resolve an existing path and verify both its lexical path and real symlink
* Use for paths that may not exist yet (creates, writes). * target remain inside the sandbox.
* */
* Performs a lexical containment check only using path.resolve. 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 { export function guardPathUnsafe(userPath: string, sandboxDir: string): string {
const resolved = path.resolve(sandboxDir, userPath); return assertLexicalContainment(userPath, sandboxDir);
const sandboxAbs = path.resolve(sandboxDir);
if (!resolved.startsWith(sandboxAbs + path.sep) && resolved !== sandboxAbs) {
throw new SandboxEscapeError(userPath, sandboxDir, resolved);
}
return resolved;
} }
export class SandboxEscapeError extends Error { 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[] { export function createShellTools(sandboxDir?: string): ToolDefinition[] {
const defaultCwd = sandboxDir ?? process.cwd(); const defaultCwd = sandboxDir ?? process.cwd();
+13 -1
View File
@@ -9,12 +9,24 @@ services:
MOSAIC_GIT_IDENTITY: stack-dogfood MOSAIC_GIT_IDENTITY: stack-dogfood
MOSAIC_BRAIN_HOME: /opt/mosaic/brain MOSAIC_BRAIN_HOME: /opt/mosaic/brain
AGENT_FILE_SANDBOX_DIR: /workspace/stack AGENT_FILE_SANDBOX_DIR: /workspace/stack
AGENT_USER_TOOLS: fs_read_file,fs_write_file,fs_list_directory,fs_edit_file,git_status,git_log,git_diff,shell_exec # Disable the general shell before admin/user allowlist resolution. Delivery
# uses execFile-only tools bound to the queue and PR wrappers below.
AGENT_SHELL_ENABLED: 'false'
AGENT_DELIVERY_ENABLED: 'true'
MOSAIC_GIT_TOOLS_DIR: /opt/mosaic/tools/git
MOSAIC_INTEGRATION_TRUNK: next
AGENT_USER_TOOLS: fs_read_file,fs_write_file,fs_list_directory,fs_edit_file,git_status,git_log,git_diff,git_publish_branch,git_open_pull_request
volumes: volumes:
# Mount a dedicated worktree, never the canonical clone or divergent local main. # Mount a dedicated worktree, never the canonical clone or divergent local main.
- type: bind - type: bind
source: ${MOSAIC_DOGFOOD_WORKTREE:?set to a dedicated next-based stack worktree} source: ${MOSAIC_DOGFOOD_WORKTREE:?set to a dedicated next-based stack worktree}
target: /workspace/stack target: /workspace/stack
# A Git worktree's .git file points into the canonical clone's common Git
# directory. Mount that directory at its original absolute path so Git can
# resolve the pointer. File tools cannot traverse outside /workspace/stack.
- type: bind
source: ${MOSAIC_DOGFOOD_COMMON_GIT_DIR:?set to the canonical stack clone .git directory}
target: ${MOSAIC_DOGFOOD_COMMON_GIT_DIR:?set to the canonical stack clone .git directory}
# Only this seat home enters the container. Other fleet credentials stay outside. # Only this seat home enters the container. Other fleet credentials stay outside.
- type: bind - type: bind
source: ${MOSAIC_DOGFOOD_SEAT_HOME:?set to the external stack-dogfood seat directory} source: ${MOSAIC_DOGFOOD_SEAT_HOME:?set to the external stack-dogfood seat directory}
+5 -3
View File
@@ -33,13 +33,15 @@ ENV NODE_ENV=production
# bash/curl/python3 are runtime dependencies of the provider-neutral Mosaic git # bash/curl/python3 are runtime dependencies of the provider-neutral Mosaic git
# wrappers. jq supports wrapper discovery for non-canonical Gitea hosts. # wrappers. jq supports wrapper discovery for non-canonical Gitea hosts.
RUN apk add --no-cache bash curl git jq python3 \ RUN apk add --no-cache bash curl git jq python3 \
&& ln -sf /bin/bash /usr/bin/bash \
&& mkdir -p /opt/mosaic/.workspaces \ && mkdir -p /opt/mosaic/.workspaces \
&& chown -R node:node /opt/mosaic /app && chown -R node:node /opt/mosaic /app
ENV MOSAIC_ROOT=/opt/mosaic ENV MOSAIC_ROOT=/opt/mosaic
# Dogfood agents use the same fail-closed credential helper and PR-create wrapper # Dogfood agents use the same fail-closed credential helper, queue guard, and
# as fleet seats. Copy only that operation and its shared dependencies. Unrelated # PR-create wrapper as fleet seats. Copy only those operations and their shared
# fleet operations, including merge and infrastructure tools, stay out of the image. # dependencies. Merge and infrastructure tools stay out of the image.
COPY --from=builder /app/packages/mosaic/framework/tools/git/pr-create.sh /opt/mosaic/tools/git/pr-create.sh COPY --from=builder /app/packages/mosaic/framework/tools/git/pr-create.sh /opt/mosaic/tools/git/pr-create.sh
COPY --from=builder /app/packages/mosaic/framework/tools/git/ci-queue-wait.sh /opt/mosaic/tools/git/ci-queue-wait.sh
COPY --from=builder /app/packages/mosaic/framework/tools/git/detect-platform.sh /opt/mosaic/tools/git/detect-platform.sh COPY --from=builder /app/packages/mosaic/framework/tools/git/detect-platform.sh /opt/mosaic/tools/git/detect-platform.sh
COPY --from=builder /app/packages/mosaic/framework/tools/git/repo-decl.sh /opt/mosaic/tools/git/repo-decl.sh COPY --from=builder /app/packages/mosaic/framework/tools/git/repo-decl.sh /opt/mosaic/tools/git/repo-decl.sh
COPY --from=builder /app/packages/mosaic/framework/tools/git/git-credential-mosaic /opt/mosaic/tools/git/git-credential-mosaic COPY --from=builder /app/packages/mosaic/framework/tools/git/git-credential-mosaic /opt/mosaic/tools/git/git-credential-mosaic
+34 -3
View File
@@ -5,7 +5,7 @@ set -euo pipefail
repo_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) repo_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
tmp=$(mktemp -d) tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT trap 'rm -rf "$tmp"' EXIT
mkdir -p "$tmp/worktree" "$tmp/seat/secrets" mkdir -p "$tmp/worktree" "$tmp/common.git" "$tmp/seat/secrets"
base_config_json=$( base_config_json=$(
cd "$repo_root" cd "$repo_root"
@@ -26,6 +26,10 @@ for key in (
"MOSAIC_BRAIN_HOME", "MOSAIC_BRAIN_HOME",
"AGENT_FILE_SANDBOX_DIR", "AGENT_FILE_SANDBOX_DIR",
"AGENT_USER_TOOLS", "AGENT_USER_TOOLS",
"AGENT_SHELL_ENABLED",
"AGENT_DELIVERY_ENABLED",
"MOSAIC_GIT_TOOLS_DIR",
"MOSAIC_INTEGRATION_TRUNK",
): ):
assert key not in env, f"base compose unexpectedly sets dogfood variable {key}" assert key not in env, f"base compose unexpectedly sets dogfood variable {key}"
@@ -38,6 +42,7 @@ config_json=$(
cd "$repo_root" cd "$repo_root"
BETTER_AUTH_SECRET=test-only-not-a-credential \ BETTER_AUTH_SECRET=test-only-not-a-credential \
MOSAIC_DOGFOOD_WORKTREE="$tmp/worktree" \ MOSAIC_DOGFOOD_WORKTREE="$tmp/worktree" \
MOSAIC_DOGFOOD_COMMON_GIT_DIR="$tmp/common.git" \
MOSAIC_DOGFOOD_SEAT_HOME="$tmp/seat" \ MOSAIC_DOGFOOD_SEAT_HOME="$tmp/seat" \
docker compose \ docker compose \
-f docker-compose.yml \ -f docker-compose.yml \
@@ -46,7 +51,7 @@ config_json=$(
config --format json config --format json
) )
CONFIG_JSON="$config_json" EXPECT_WORKTREE="$tmp/worktree" EXPECT_SEAT="$tmp/seat" python3 <<'PY' CONFIG_JSON="$config_json" EXPECT_WORKTREE="$tmp/worktree" EXPECT_COMMON_GIT="$tmp/common.git" EXPECT_SEAT="$tmp/seat" python3 <<'PY'
import json import json
import os import os
@@ -59,6 +64,10 @@ expected_env = {
"MOSAIC_GIT_IDENTITY": "stack-dogfood", "MOSAIC_GIT_IDENTITY": "stack-dogfood",
"MOSAIC_BRAIN_HOME": "/opt/mosaic/brain", "MOSAIC_BRAIN_HOME": "/opt/mosaic/brain",
"AGENT_FILE_SANDBOX_DIR": "/workspace/stack", "AGENT_FILE_SANDBOX_DIR": "/workspace/stack",
"AGENT_SHELL_ENABLED": "false",
"AGENT_DELIVERY_ENABLED": "true",
"MOSAIC_GIT_TOOLS_DIR": "/opt/mosaic/tools/git",
"MOSAIC_INTEGRATION_TRUNK": "next",
} }
for key, value in expected_env.items(): for key, value in expected_env.items():
assert env.get(key) == value, f"{key}: expected {value!r}, got {env.get(key)!r}" assert env.get(key) == value, f"{key}: expected {value!r}, got {env.get(key)!r}"
@@ -72,8 +81,10 @@ assert allowed == {
"git_status", "git_status",
"git_log", "git_log",
"git_diff", "git_diff",
"shell_exec", "git_publish_branch",
"git_open_pull_request",
}, f"unexpected dogfood tool set: {sorted(allowed)}" }, f"unexpected dogfood tool set: {sorted(allowed)}"
assert "shell_exec" not in allowed
mounts = {mount["target"]: mount for mount in gateway["volumes"]} mounts = {mount["target"]: mount for mount in gateway["volumes"]}
worktree = mounts["/workspace/stack"] worktree = mounts["/workspace/stack"]
@@ -81,6 +92,11 @@ assert worktree["type"] == "bind"
assert worktree["source"] == os.environ["EXPECT_WORKTREE"] assert worktree["source"] == os.environ["EXPECT_WORKTREE"]
assert not worktree.get("read_only", False), "dogfood worktree must be writable" assert not worktree.get("read_only", False), "dogfood worktree must be writable"
common_git = mounts[os.environ["EXPECT_COMMON_GIT"]]
assert common_git["type"] == "bind"
assert common_git["source"] == os.environ["EXPECT_COMMON_GIT"]
assert not common_git.get("read_only", False), "common Git directory must accept branch updates"
seat = mounts["/opt/mosaic/brain/fleet/agents/stack-dogfood"] seat = mounts["/opt/mosaic/brain/fleet/agents/stack-dogfood"]
assert seat["type"] == "bind" assert seat["type"] == "bind"
assert seat["source"] == os.environ["EXPECT_SEAT"] assert seat["source"] == os.environ["EXPECT_SEAT"]
@@ -105,6 +121,19 @@ expect_missing_path() {
cd "$repo_root" cd "$repo_root"
env -u MOSAIC_DOGFOOD_WORKTREE \ env -u MOSAIC_DOGFOOD_WORKTREE \
BETTER_AUTH_SECRET=test-only-not-a-credential \ BETTER_AUTH_SECRET=test-only-not-a-credential \
MOSAIC_DOGFOOD_COMMON_GIT_DIR="$tmp/common.git" \
MOSAIC_DOGFOOD_SEAT_HOME="$tmp/seat" \
docker compose -f docker-compose.yml -f docker-compose.dogfood.yml \
--profile stack config 2>&1
)
rc=$?
;;
MOSAIC_DOGFOOD_COMMON_GIT_DIR)
output=$(
cd "$repo_root"
env -u MOSAIC_DOGFOOD_COMMON_GIT_DIR \
BETTER_AUTH_SECRET=test-only-not-a-credential \
MOSAIC_DOGFOOD_WORKTREE="$tmp/worktree" \
MOSAIC_DOGFOOD_SEAT_HOME="$tmp/seat" \ MOSAIC_DOGFOOD_SEAT_HOME="$tmp/seat" \
docker compose -f docker-compose.yml -f docker-compose.dogfood.yml \ docker compose -f docker-compose.yml -f docker-compose.dogfood.yml \
--profile stack config 2>&1 --profile stack config 2>&1
@@ -117,6 +146,7 @@ expect_missing_path() {
env -u MOSAIC_DOGFOOD_SEAT_HOME \ env -u MOSAIC_DOGFOOD_SEAT_HOME \
BETTER_AUTH_SECRET=test-only-not-a-credential \ BETTER_AUTH_SECRET=test-only-not-a-credential \
MOSAIC_DOGFOOD_WORKTREE="$tmp/worktree" \ MOSAIC_DOGFOOD_WORKTREE="$tmp/worktree" \
MOSAIC_DOGFOOD_COMMON_GIT_DIR="$tmp/common.git" \
docker compose -f docker-compose.yml -f docker-compose.dogfood.yml \ docker compose -f docker-compose.yml -f docker-compose.dogfood.yml \
--profile stack config 2>&1 --profile stack config 2>&1
) )
@@ -139,6 +169,7 @@ expect_missing_path() {
} }
expect_missing_path MOSAIC_DOGFOOD_WORKTREE expect_missing_path MOSAIC_DOGFOOD_WORKTREE
expect_missing_path MOSAIC_DOGFOOD_COMMON_GIT_DIR
expect_missing_path MOSAIC_DOGFOOD_SEAT_HOME expect_missing_path MOSAIC_DOGFOOD_SEAT_HOME
printf 'dogfood compose verification passed\n' printf 'dogfood compose verification passed\n'