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 { 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 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 }; 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}` : 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; }; 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); 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; }; 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 (safePath !== undefined) args.push(safePath); 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]; }