feat(gateway): tool path hardening + sandbox escape prevention (P8-016) (#177)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful

Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
This commit was merged in pull request #177.
This commit is contained in:
2026-03-16 02:02:48 +00:00
committed by jason.woltje
parent f0741e045f
commit 7f6464bbda
7 changed files with 320 additions and 57 deletions

View File

@@ -2,29 +2,13 @@ import { Type } from '@sinclair/typebox';
import type { ToolDefinition } from '@mariozechner/pi-coding-agent';
import { exec } from 'node:child_process';
import { promisify } from 'node:util';
import { resolve, relative } from 'node:path';
import { guardPath, guardPathUnsafe, SandboxEscapeError } from './path-guard.js';
const execAsync = promisify(exec);
const GIT_TIMEOUT_MS = 15_000;
const MAX_OUTPUT_BYTES = 100 * 1024; // 100 KB
/**
* Clamp a user-supplied cwd to within the sandbox directory.
* If the resolved path escapes the sandbox (via ../ or absolute path outside),
* falls back to the sandbox directory itself.
*/
function clampCwd(sandboxDir: string, requestedCwd?: string): string {
if (!requestedCwd) return sandboxDir;
const resolved = resolve(sandboxDir, requestedCwd);
const rel = relative(sandboxDir, resolved);
if (rel.startsWith('..') || rel.startsWith('/')) {
// Escape attempt — fall back to sandbox root
return sandboxDir;
}
return resolved;
}
async function runGit(
args: string[],
cwd?: string,
@@ -74,7 +58,21 @@ export function createGitTools(sandboxDir?: string): ToolDefinition[] {
}),
async execute(_toolCallId, params) {
const { cwd } = params as { cwd?: string };
const safeCwd = clampCwd(defaultCwd, cwd);
let safeCwd: string;
try {
safeCwd = guardPath(cwd ?? '.', defaultCwd);
} catch (err) {
if (err instanceof SandboxEscapeError) {
return {
content: [{ type: 'text' as const, text: `Error: ${err.message}` }],
details: undefined,
};
}
return {
content: [{ type: 'text' as const, text: `Error: ${String(err)}` }],
details: undefined,
};
}
const result = await runGit(['status', '--short', '--branch'], safeCwd);
const text = result.error
? `Error: ${result.error}\n${result.stderr}`
@@ -107,7 +105,21 @@ export function createGitTools(sandboxDir?: string): ToolDefinition[] {
oneline?: boolean;
cwd?: string;
};
const safeCwd = clampCwd(defaultCwd, cwd);
let safeCwd: string;
try {
safeCwd = guardPath(cwd ?? '.', defaultCwd);
} catch (err) {
if (err instanceof SandboxEscapeError) {
return {
content: [{ type: 'text' as const, text: `Error: ${err.message}` }],
details: undefined,
};
}
return {
content: [{ type: 'text' as const, text: `Error: ${String(err)}` }],
details: undefined,
};
}
const args = ['log', `--max-count=${limit ?? 20}`];
if (oneline !== false) args.push('--oneline');
const result = await runGit(args, safeCwd);
@@ -148,12 +160,43 @@ export function createGitTools(sandboxDir?: string): ToolDefinition[] {
path?: string;
cwd?: string;
};
const safeCwd = clampCwd(defaultCwd, cwd);
let safeCwd: string;
try {
safeCwd = guardPath(cwd ?? '.', defaultCwd);
} catch (err) {
if (err instanceof SandboxEscapeError) {
return {
content: [{ type: 'text' as const, text: `Error: ${err.message}` }],
details: undefined,
};
}
return {
content: [{ type: 'text' as const, text: `Error: ${String(err)}` }],
details: undefined,
};
}
let safePath: string | undefined;
if (path !== undefined) {
try {
safePath = guardPathUnsafe(path, defaultCwd);
} catch (err) {
if (err instanceof SandboxEscapeError) {
return {
content: [{ type: 'text' as const, text: `Error: ${err.message}` }],
details: undefined,
};
}
return {
content: [{ type: 'text' as const, text: `Error: ${String(err)}` }],
details: undefined,
};
}
}
const args = ['diff'];
if (staged) args.push('--cached');
if (ref) args.push(ref);
args.push('--');
if (path) args.push(path);
if (safePath !== undefined) args.push(safePath);
const result = await runGit(args, safeCwd);
const text = result.error
? `Error: ${result.error}\n${result.stderr}`