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

@@ -1,7 +1,7 @@
import { Type } from '@sinclair/typebox';
import type { ToolDefinition } from '@mariozechner/pi-coding-agent';
import { spawn } from 'node:child_process';
import { resolve, relative } from 'node:path';
import { guardPath, SandboxEscapeError } from './path-guard.js';
const DEFAULT_TIMEOUT_MS = 30_000;
const MAX_OUTPUT_BYTES = 100 * 1024; // 100 KB
@@ -68,22 +68,6 @@ function extractBaseCommand(command: string): string {
return firstToken.split('/').pop() ?? firstToken;
}
/**
* 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;
}
function runCommand(
command: string,
options: { timeoutMs: number; cwd?: string },
@@ -185,7 +169,21 @@ export function createShellTools(sandboxDir?: string): ToolDefinition[] {
}
const timeoutMs = Math.min(timeout ?? DEFAULT_TIMEOUT_MS, 60_000);
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 runCommand(command, {
timeoutMs,