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>
213 lines
6.7 KiB
TypeScript
213 lines
6.7 KiB
TypeScript
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];
|
|
}
|