Repository Public
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