Some checks failed
ci/woodpecker/push/ci Pipeline failed
Co-authored-by: Jason Woltje <jason@diversecanvas.com> Co-committed-by: Jason Woltje <jason@diversecanvas.com>
170 lines
5.5 KiB
TypeScript
170 lines
5.5 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 { 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];
|
|
}
|