feat(gateway): tool path hardening + sandbox escape prevention (P8-016) (#177)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
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:
@@ -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}`
|
||||
|
||||
Reference in New Issue
Block a user