SPB Git

Repository Public

git.ts 16.9 KB 348 lines typescript

1/**2 * Git tools. These run OUTSIDE the sandbox (trusted component), so nothing the agent can write may influence3 * what they execute:4 *  - always `execFile('git', argv)` — never a shell;5 *  - hooks and fsmonitor disabled on every invocation, no pager/editor/external diff/gpg program, no `ext::` transport;6 *  - minimal environment (no worker secrets), prompts disabled;7 *  - `<workspace>/.git` must exist (no discovery in parent directories, no "bare repository in the working tree");8 *  - `.git/config`, `.git/hooks`, `.git/commondir` are unwritable by file tools and by the sandboxed shell.9 */10import { execFile } from 'node:child_process';11import { lstat, realpath } from 'node:fs/promises';12import os from 'node:os';13import path from 'node:path';14import { z } from 'zod';15import { PathError, resolveInWorkspace } from './paths.js';16import { opt, toStrictJsonSchema } from './schema.js';17import { fail, type ReconcileResult, type Tool, type ToolContext, type ToolRunResult } from './types.js';1819export const GIT_SAFE_CONFIG: readonly string[] = [20  '-c', 'core.hooksPath=/dev/null',21  '-c', 'core.fsmonitor=false',22  '-c', 'core.pager=cat',23  '-c', 'core.editor=true',24  '-c', 'commit.gpgsign=false',25  '-c', 'tag.gpgsign=false',26  '-c', 'protocol.ext.allow=never',27  '-c', 'submodule.recurse=false',28  '-c', 'credential.interactive=false',29];3031export interface GitResult {32  code: number | null;33  stdout: string;34  stderr: string;35  truncated: boolean;36}3738/** Worker PATH without relative entries or entries inside the workspace (the agent could plant a `git`/`ssh` there). */39function safePath(workspaceReal: string): string {40  const dirs = (process.env.PATH ?? '')41    .split(':')42    .filter((d) => path.isAbsolute(d) && d !== workspaceReal && !path.normalize(d).startsWith(workspaceReal + path.sep));43  return dirs.length > 0 ? dirs.join(':') : '/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin';44}4546function gitEnv(workspaceReal: string, remote: boolean): Record<string, string> {47  const env: Record<string, string> = {48    PATH: safePath(workspaceReal),49    HOME: os.homedir(),50    LANG: 'C',51    LC_ALL: 'C',52    GIT_TERMINAL_PROMPT: '0',53    GIT_OPTIONAL_LOCKS: '0',54    GIT_CEILING_DIRECTORIES: path.dirname(workspaceReal),55    GIT_PAGER: 'cat',56    GIT_EDITOR: 'true',57    GIT_ASKPASS: '/usr/bin/false',58    SSH_ASKPASS: '/usr/bin/false',59  };60  if (remote) {61    env.GIT_SSH_COMMAND = 'ssh -o BatchMode=yes';62    if (process.env.SSH_AUTH_SOCK) env.SSH_AUTH_SOCK = process.env.SSH_AUTH_SOCK; // key agent only; never key material63  }64  return env;65}6667/** Run git with the safe configuration. Rejects only on spawn failure; non-zero exit is returned. */68export async function runGit(69  workspaceAbs: string,70  args: string[],71  opts: { signal?: AbortSignal; timeoutMs?: number; remote?: boolean; maxBuffer?: number } = {},72): Promise<GitResult> {73  const workspaceReal = await realpath(workspaceAbs);74  return new Promise((resolve, reject) => {75    execFile(76      'git',77      [...GIT_SAFE_CONFIG, ...args],78      {79        cwd: workspaceReal,80        env: gitEnv(workspaceReal, opts.remote === true),81        timeout: opts.timeoutMs ?? 60_000,82        killSignal: 'SIGKILL',83        maxBuffer: opts.maxBuffer ?? 4 * 1024 * 1024,84        signal: opts.signal,85        encoding: 'utf8',86      },87      (err, stdout, stderr) => {88        if (!err) return resolve({ code: 0, stdout, stderr, truncated: false });89        const e = err as NodeJS.ErrnoException & { code?: string | number; killed?: boolean };90        if (e.code === 'ENOENT') return reject(new Error('git executable not found'));91        if (e.code === 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER') return resolve({ code: 0, stdout, stderr, truncated: true });92        if (e.name === 'AbortError' || e.code === 'ABORT_ERR') return resolve({ code: null, stdout, stderr: `${stderr}\n(aborted)`, truncated: false });93        if (e.killed) return resolve({ code: null, stdout, stderr: `${stderr}\n(git timed out and was killed)`, truncated: false });94        resolve({ code: typeof e.code === 'number' ? e.code : 1, stdout, stderr, truncated: false });95      },96    );97  });98}99100async function requireRepo(ctx: ToolContext): Promise<string | null> {101  try {102    const ws = await realpath(ctx.workspaceAbs);103    await lstat(path.join(ws, '.git'));104    return null;105  } catch {106    return 'The workspace is not a git repository (no .git at the workspace root).';107  }108}109110function clip(text: string, max: number): string {111  if (text.length <= max) return text;112  return `${text.slice(0, max)}\n… (${text.length - max} more characters not shown)`;113}114const errText = (r: GitResult) => (r.stderr.trim() || r.stdout.trim() || `git exited with code ${r.code}`).slice(0, 4000);115116async function relPaths(ctx: ToolContext, paths: string[]): Promise<string[]> {117  const out: string[] = [];118  for (const p of paths) {119    const r = await resolveInWorkspace(ctx.workspaceAbs, p, 'read');120    out.push(r.rel === '' ? '.' : r.rel);121  }122  return out;123}124125// ---- git_status ------------------------------------------------------------------------------------------------126127const GitStatusInput = z.object({});128type GitStatusInput = z.infer<typeof GitStatusInput>;129130export const gitStatusTool: Tool<GitStatusInput> = {131  def: {132    name: 'git_status',133    description:134      'Show the current branch and the working tree status (staged, modified, untracked files) in short porcelain format. ' +135      'Use it before committing and to see what your shell commands changed. Read-only. Takes no arguments.',136    inputSchema: toStrictJsonSchema(GitStatusInput),137  },138  input: GitStatusInput,139  effect: 'read',140  risk: 'read',141  parallelSafe: true,142  async run(_input, ctx): Promise<ToolRunResult> {143    const no = await requireRepo(ctx);144    if (no) return fail(no);145    const r = await runGit(ctx.workspaceAbs, ['status', '--porcelain=v1', '--branch', '--ignore-submodules=all'], { signal: ctx.signal });146    if (r.code !== 0) return fail(`git status failed: ${errText(r)}`);147    const lines = r.stdout.split('\n').filter((l) => l.length > 0);148    const shown = lines.slice(0, 400);149    let out = shown.join('\n');150    if (lines.length > shown.length) out += `\n… ${lines.length - shown.length} more entries not shown`;151    if (lines.length <= 1) out += '\n(working tree clean)';152    return { ok: true, output: out };153  },154};155156// ---- git_diff --------------------------------------------------------------------------------------------------157158const REF_RE = /^[A-Za-z0-9_][A-Za-z0-9_./~^@{}-]{0,200}$/;159const GitDiffInput = z.object({160  staged: opt(z.boolean()).describe('true = diff of the index against HEAD (what would be committed); false/null = unstaged changes of the working tree.'),161  ref: opt(z.string().max(200)).describe('Optional revision or range to diff against, e.g. "HEAD~1", "main", "main...HEAD". null for the default.'),162  paths: opt(z.array(z.string().min(1).max(1000)).max(50)).describe('Limit the diff to these workspace-relative paths. null for everything.'),163});164type GitDiffInput = z.infer<typeof GitDiffInput>;165166export const gitDiffTool: Tool<GitDiffInput> = {167  def: {168    name: 'git_diff',169    description:170      'Show a unified diff of tracked files: unstaged changes by default, staged changes with staged=true, or against a revision/range with ref. ' +171      'Untracked files do not appear (use git_status). Use it to review your changes before committing. Read-only; large diffs are truncated and stored as an artifact.',172    inputSchema: toStrictJsonSchema(GitDiffInput),173  },174  input: GitDiffInput,175  effect: 'read',176  risk: 'read',177  parallelSafe: true,178  async run(input, ctx): Promise<ToolRunResult> {179    const no = await requireRepo(ctx);180    if (no) return fail(no);181    if (input.ref && !REF_RE.test(input.ref)) return fail(`Invalid ref "${input.ref}": only revision names and ranges are accepted.`);182    let rels: string[] = [];183    try {184      rels = await relPaths(ctx, input.paths ?? []);185    } catch (e) {186      if (e instanceof PathError) return fail(e.message);187      throw e;188    }189    const args = ['diff', '--no-ext-diff', '--no-textconv', '--no-color', '--ignore-submodules=all'];190    if (input.staged) args.push('--cached');191    if (input.ref) args.push(input.ref);192    args.push('--', ...rels);193    const r = await runGit(ctx.workspaceAbs, args, { signal: ctx.signal });194    if (r.code !== 0) return fail(`git diff failed: ${errText(r)}`);195    const text = r.stdout.length === 0 ? '(no differences)' : r.stdout + (r.truncated ? '\n… diff truncated at 4 MiB' : '');196    return { ok: true, output: text, diff: r.stdout.length > 0 ? clip(r.stdout, 8000) : undefined };197  },198};199200// ---- git_commit ------------------------------------------------------------------------------------------------201202const GitCommitInput = z.object({203  message: z.string().min(1).max(4000).describe('Commit message: a short subject line, optionally a blank line and a body. Trailers are added automatically.'),204  paths: opt(z.array(z.string().min(1).max(1000)).max(200)).describe('Workspace-relative paths to stage (files or directories, including deletions). null stages every change in the workspace.'),205});206type GitCommitInput = z.infer<typeof GitCommitInput>;207208const oneLine = (s: string) => s.replace(/[\r\n]+/g, ' ').trim();209210export const gitCommitTool: Tool<GitCommitInput> = {211  def: {212    name: 'git_commit',213    description:214      'Stage changes (the given paths, or everything when paths is null) and create a commit on the current branch. ' +215      'Commit after each verified unit of work. If there is nothing to commit the call succeeds as a no-op. ' +216      'Repository hooks are not run. Does not push (use git_push, which needs owner approval).',217    inputSchema: toStrictJsonSchema(GitCommitInput),218  },219  input: GitCommitInput,220  effect: 'idempotent_write',221  risk: 'workspace_write',222  parallelSafe: false,223  describeForApproval(input) {224    return { title: `git commit: ${oneLine(input.message).slice(0, 100)}`, detail: input.paths ? `Stage ${input.paths.join(', ')} and commit.` : 'Stage all changes and commit.' };225  },226  async run(input, ctx): Promise<ToolRunResult> {227    const no = await requireRepo(ctx);228    if (no) return fail(no);229    const g = (args: string[]) => runGit(ctx.workspaceAbs, args, { signal: ctx.signal });230    const stepTrailer = `AOS-Step: ${oneLine(ctx.idemKey)}`;231232    // idempotence: this very step already produced HEAD (crash after commit, before the journal write)233    const head = await g(['log', '-1', '--format=%H%n%B']);234    if (head.code === 0) {235      const [sha, ...body] = head.stdout.split('\n');236      if (body.some((l) => l.trim() === stepTrailer)) return { ok: true, output: `Already committed by this step: ${(sha ?? '').slice(0, 12)} (no-op).` };237    }238239    let rels: string[] | null = null;240    try {241      if (input.paths && input.paths.length > 0) rels = await relPaths(ctx, input.paths);242    } catch (e) {243      if (e instanceof PathError) return fail(e.message);244      throw e;245    }246    const add = await g(rels ? ['add', '-A', '--', ...rels] : ['add', '-A']);247    if (add.code !== 0) return fail(`git add failed: ${errText(add)}`);248249    const staged = await g(['diff', '--cached', '--quiet', '--ignore-submodules=all']);250    if (staged.code === 0) return { ok: true, output: 'Nothing to commit: no staged changes (no-op).' };251    if (staged.code !== 1) return fail(`git diff --cached failed: ${errText(staged)}`);252253    const identity: string[] = [];254    const name = await g(['config', 'user.name']);255    const email = await g(['config', 'user.email']);256    if (name.code !== 0 || name.stdout.trim() === '') identity.push('-c', 'user.name=Agent OS');257    if (email.code !== 0 || email.stdout.trim() === '') identity.push('-c', 'user.email=agent@agent-os.invalid');258259    const message = `${input.message.trim()}\n\nAOS-Mission: ${oneLine(ctx.missionId)}\n${stepTrailer}\n`;260    const commit = await g([...identity, 'commit', '--no-verify', '--no-gpg-sign', '--cleanup=whitespace', '-m', message]);261    if (commit.code !== 0) return fail(`git commit failed: ${errText(commit)}`);262    const show = await g(['show', '--stat', '--format=%h %s', '--no-color', 'HEAD']);263    return { ok: true, output: `Committed.\n${clip(show.stdout.trim(), 4000)}` };264  },265};266267// ---- git_push --------------------------------------------------------------------------------------------------268269const NAME_RE = /^[A-Za-z0-9_][A-Za-z0-9_.-]{0,100}$/;270const BRANCH_RE = /^[A-Za-z0-9_][A-Za-z0-9_./-]{0,200}$/;271const GitPushInput = z.object({272  remote: opt(z.string().max(100)).describe('Name of a configured remote. null = "origin".'),273  branch: opt(z.string().max(200)).describe('Local branch to push to the same name on the remote. null = the current branch.'),274});275type GitPushInput = z.infer<typeof GitPushInput>;276277async function pushTarget(input: GitPushInput, ctx: ToolContext): Promise<{ remote: string; branch: string } | { error: string }> {278  const remote = input.remote ?? 'origin';279  if (!NAME_RE.test(remote)) return { error: `Invalid remote name "${remote}". Pass the NAME of a configured remote, not a URL.` };280  const remotes = await runGit(ctx.workspaceAbs, ['remote'], { signal: ctx.signal });281  if (remotes.code !== 0) return { error: `git remote failed: ${errText(remotes)}` };282  const names = remotes.stdout.split('\n').map((s) => s.trim()).filter(Boolean);283  if (!names.includes(remote)) return { error: `Remote "${remote}" is not configured (configured: ${names.join(', ') || 'none'}). Remotes are set up by the owner.` };284  let branch = input.branch ?? '';285  if (!branch) {286    const cur = await runGit(ctx.workspaceAbs, ['symbolic-ref', '--short', '-q', 'HEAD'], { signal: ctx.signal });287    branch = cur.stdout.trim();288    if (cur.code !== 0 || !branch) return { error: 'HEAD is detached: pass the branch to push explicitly.' };289  }290  if (!BRANCH_RE.test(branch) || branch.includes('..') || branch.endsWith('.lock') || branch.endsWith('/')) return { error: `Invalid branch name "${branch}".` };291  const exists = await runGit(ctx.workspaceAbs, ['rev-parse', '--verify', '-q', `refs/heads/${branch}`], { signal: ctx.signal });292  if (exists.code !== 0) return { error: `Local branch "${branch}" does not exist.` };293  return { remote, branch };294}295296export const gitPushTool: Tool<GitPushInput> = {297  def: {298    name: 'git_push',299    description:300      'Push a local branch to a configured remote (same branch name; never a force push). This leaves the workspace and is visible to others, so it requires owner approval unless a policy rule allows it. ' +301      'Only push when the mission asks for it or the work is complete and committed. Fails if the remote has commits you do not have.',302    inputSchema: toStrictJsonSchema(GitPushInput),303  },304  input: GitPushInput,305  effect: 'non_idempotent',306  risk: 'external',307  parallelSafe: false,308  describeForApproval(input) {309    return {310      title: `git push ${input.remote ?? 'origin'} ${input.branch ?? '(current branch)'}`,311      detail: `Push local branch ${input.branch ?? '(current branch)'} to remote "${input.remote ?? 'origin'}" (no force). Commits become visible outside the workspace.`,312    };313  },314  async run(input, ctx): Promise<ToolRunResult> {315    const no = await requireRepo(ctx);316    if (no) return fail(no);317    const t = await pushTarget(input, ctx);318    if ('error' in t) return fail(t.error);319    const ref = `refs/heads/${t.branch}`;320    const r = await runGit(ctx.workspaceAbs, ['push', '--porcelain', '--no-verify', '--recurse-submodules=no', t.remote, `${ref}:${ref}`], { signal: ctx.signal, timeoutMs: 180_000, remote: true });321    if (r.code !== 0) return fail(`git push failed: ${clip(`${r.stdout}\n${r.stderr}`.trim(), 4000)}`);322    const sha = await runGit(ctx.workspaceAbs, ['rev-parse', ref], { signal: ctx.signal });323    return { ok: true, output: `Pushed ${t.branch} to ${t.remote} at ${sha.stdout.trim().slice(0, 12)}.\n${clip(r.stdout.trim(), 2000)}` };324  },325  /** Has the push happened? Compare the remote ref (ls-remote) with the local ref. */326  async reconcile(input, ctx): Promise<ReconcileResult> {327    try {328      if (await requireRepo(ctx)) return { unknown: true };329      const t = await pushTarget(input, ctx);330      if ('error' in t) return { unknown: true };331      const ref = `refs/heads/${t.branch}`;332      const local = await runGit(ctx.workspaceAbs, ['rev-parse', '--verify', '-q', ref], { signal: ctx.signal });333      if (local.code !== 0) return { unknown: true };334      const remote = await runGit(ctx.workspaceAbs, ['ls-remote', '--heads', t.remote, ref], { signal: ctx.signal, timeoutMs: 60_000, remote: true });335      if (remote.code !== 0) return { unknown: true };336      const line = remote.stdout.split('\n').find((l) => l.trim().endsWith(ref));337      const remoteSha = line ? (line.split(/\s+/)[0] ?? '') : '';338      const localSha = local.stdout.trim();339      if (remoteSha === localSha && /^[0-9a-f]{40,64}$/.test(localSha)) {340        return { done: true, output: `Pushed ${t.branch} to ${t.remote} at ${localSha.slice(0, 12)} (confirmed by ls-remote after an interrupted step).` };341      }342      return { done: false };343    } catch {344      return { unknown: true };345    }346  },347};348
Files main
Menu

Theme