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'; 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, ): Promise<{ stdout: string; stderr: string; error?: string }> { // Only allow specific safe read-only git subcommands const allowedSubcommands = ['status', 'log', 'diff', 'show', 'branch', 'tag', 'ls-files']; const subcommand = args[0]; if (!subcommand || !allowedSubcommands.includes(subcommand)) { return { stdout: '', stderr: '', error: `Blocked: git subcommand "${subcommand}" is not allowed. Permitted: ${allowedSubcommands.join(', ')}`, }; } const cmd = `git ${args.map((a) => JSON.stringify(a)).join(' ')}`; try { const { stdout, stderr } = await execAsync(cmd, { cwd, timeout: GIT_TIMEOUT_MS, maxBuffer: MAX_OUTPUT_BYTES, }); return { stdout, stderr }; } catch (err: unknown) { const e = err as { stdout?: string; stderr?: string; message?: string }; return { stdout: e.stdout ?? '', stderr: e.stderr ?? '', error: e.message ?? String(err), }; } } export function createGitTools(sandboxDir?: string): ToolDefinition[] { const defaultCwd = sandboxDir ?? process.cwd(); const gitStatus: ToolDefinition = { name: 'git_status', label: 'Git Status', description: 'Show the working tree status (staged, unstaged, untracked files).', parameters: Type.Object({ cwd: Type.Optional( Type.String({ description: 'Repository working directory (relative to sandbox or absolute within it).', }), ), }), async execute(_toolCallId, params) { const { cwd } = params as { cwd?: string }; const safeCwd = clampCwd(defaultCwd, cwd); const result = await runGit(['status', '--short', '--branch'], safeCwd); const text = result.error ? `Error: ${result.error}\n${result.stderr}` : result.stdout || '(no output)'; return { content: [{ type: 'text' as const, text: text }], details: undefined, }; }, }; const gitLog: ToolDefinition = { name: 'git_log', label: 'Git Log', description: 'Show recent commit history.', parameters: Type.Object({ limit: Type.Optional(Type.Number({ description: 'Number of commits to show (default 20)' })), oneline: Type.Optional( Type.Boolean({ description: 'Compact one-line format (default true)' }), ), cwd: Type.Optional( Type.String({ description: 'Repository working directory (relative to sandbox or absolute within it).', }), ), }), async execute(_toolCallId, params) { const { limit, oneline, cwd } = params as { limit?: number; oneline?: boolean; cwd?: string; }; const safeCwd = clampCwd(defaultCwd, cwd); const args = ['log', `--max-count=${limit ?? 20}`]; if (oneline !== false) args.push('--oneline'); const result = await runGit(args, safeCwd); const text = result.error ? `Error: ${result.error}\n${result.stderr}` : result.stdout || '(no commits)'; return { content: [{ type: 'text' as const, text: text }], details: undefined, }; }, }; const gitDiff: ToolDefinition = { name: 'git_diff', label: 'Git Diff', description: 'Show changes between commits, working tree, or staged changes.', parameters: Type.Object({ staged: Type.Optional( Type.Boolean({ description: 'Show staged (cached) changes instead of unstaged' }), ), ref: Type.Optional( Type.String({ description: 'Compare against this ref (commit SHA, branch, or tag)' }), ), path: Type.Optional( Type.String({ description: 'Limit diff to a specific file or directory' }), ), cwd: Type.Optional( Type.String({ description: 'Repository working directory (relative to sandbox or absolute within it).', }), ), }), async execute(_toolCallId, params) { const { staged, ref, path, cwd } = params as { staged?: boolean; ref?: string; path?: string; cwd?: string; }; const safeCwd = clampCwd(defaultCwd, cwd); const args = ['diff']; if (staged) args.push('--cached'); if (ref) args.push(ref); args.push('--'); if (path) args.push(path); const result = await runGit(args, safeCwd); const text = result.error ? `Error: ${result.error}\n${result.stderr}` : result.stdout || '(no diff)'; return { content: [{ type: 'text' as const, text: text }], details: undefined, }; }, }; return [gitStatus, gitLog, gitDiff]; }