import { lstat } from "node:fs/promises"; import { resolve } from "node:path"; import { sha256 } from "../../core/contracts.js"; import { gitAuthEnv, run, subprocessOptions } from "./process.js"; import { changedFiles } from "./repository/changes.js"; const forbiddenPaths = [ ".gitea/", ".ci-agents/", ".opencode/", ".git/", ".gitmodules", "AGENTS.md", ]; export function validateChangedFiles(files: string[]): void { if (files.length > 80) throw new Error(`Agent changed ${files.length} files; maximum is 80`); for (const file of files) { if ( !file || file.includes("\0") || file.includes("\n") || file.startsWith("/") || file.includes("../") ) { throw new Error(`Unsafe changed path: ${JSON.stringify(file)}`); } if ( forbiddenPaths.some( (path) => file === path || file.startsWith(path), ) ) { throw new Error(`Agent changed protected path: ${file}`); } if ( file .split("/") .some( (segment) => segment.toLowerCase() === "bin" || segment.toLowerCase() === "obj", ) ) { throw new Error(`Agent changed generated output path: ${file}`); } } } export async function validateChangedFileTypes( workspace: string, files: string[], ): Promise { for (const file of files) { try { const path = resolve(workspace, file); if (!path.startsWith(`${resolve(workspace)}/`)) throw new Error(`Changed path escapes workspace: ${file}`); const stat = await lstat(path); if (stat.isSymbolicLink() || !stat.isFile()) throw new Error(`Changed path is not a regular file: ${file}`); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; } } } export async function commitAndPush(input: { workspace: string; files: string[]; branch: string; token: string; pushUrl: string; message: string; expectedRemoteSha?: string | null; baseSha?: string; expectedDiffDigest?: string; signal?: AbortSignal; timeoutMs?: number; }): Promise { validateChangedFiles(input.files); await validateChangedFileTypes(input.workspace, input.files); const working = await changedFiles(input.workspace, input); validateChangedFiles(working); await validateChangedFileTypes(input.workspace, working); if ( working.length && JSON.stringify(working) !== JSON.stringify([...input.files].sort()) ) { throw new Error( "Working-tree changes differ from the publication file list", ); } if (input.expectedRemoteSha !== undefined) { const remoteRef = `refs/heads/${input.branch}`; const output = await run( "git", ["ls-remote", "--heads", input.pushUrl, remoteRef], subprocessOptions(input.workspace, input, { env: gitAuthEnv(input.token), }), ); const remoteShas = parseRemoteShas(output, remoteRef, input.branch); const remoteSha = remoteShas[0] || null; if (remoteSha !== input.expectedRemoteSha) { const localSha = ( await run( "git", ["rev-parse", "HEAD"], subprocessOptions(input.workspace, input), ) ).trim(); if (!working.length && remoteSha === localSha) { await verifyCommittedDiff(input, localSha); return localSha; } throw new Error( `Remote branch ${input.branch} changed before publication`, ); } } if (working.length) { await run( "git", ["-c", "core.hooksPath=/dev/null", "add", "--", ...input.files], subprocessOptions(input.workspace, input), ); await run( "git", [ "-c", "core.hooksPath=/dev/null", "-c", "commit.gpgSign=false", "commit", "-m", input.message, ], subprocessOptions(input.workspace, input, { env: { GIT_AUTHOR_NAME: process.env.CI_AGENT_GIT_NAME || "Gitea Agent", GIT_AUTHOR_EMAIL: process.env.CI_AGENT_GIT_EMAIL || "gitea-agent@localhost", GIT_COMMITTER_NAME: process.env.CI_AGENT_GIT_NAME || "Gitea Agent", GIT_COMMITTER_EMAIL: process.env.CI_AGENT_GIT_EMAIL || "gitea-agent@localhost", }, }), ); } const sha = ( await run( "git", ["rev-parse", "HEAD"], subprocessOptions(input.workspace, input), ) ).trim(); await verifyCommittedDiff(input, sha); await run( "git", [ "-c", "core.hooksPath=/dev/null", "-c", "push.gpgSign=false", "push", "--no-force", input.pushUrl, `HEAD:refs/heads/${input.branch}`, ], subprocessOptions(input.workspace, input, { env: gitAuthEnv(input.token), }), ); return sha; } function parseRemoteShas( output: string, remoteRef: string, branch: string, ): string[] { const values: string[] = []; for (const line of output.split("\n").filter(Boolean)) { const match = /^([0-9a-f]{40}|[0-9a-f]{64})\s+(.+)$/.exec(line); if (!match) throw new Error( `Remote branch ${branch} returned an invalid state`, ); const sha = match[1]; if (match[2] === remoteRef && sha) values.push(sha); } if (values.length > 1) throw new Error(`Remote branch ${branch} returned an invalid state`); return values; } async function verifyCommittedDiff( input: { workspace: string; baseSha?: string; expectedDiffDigest?: string; signal?: AbortSignal; timeoutMs?: number; }, head: string, ): Promise { if (!input.baseSha || !input.expectedDiffDigest) return; const diff = await run( "git", [ "diff", "--binary", "--no-ext-diff", "--no-color", "--unified=5", input.baseSha, head, "--", ], subprocessOptions(input.workspace, input, { maxOutput: 500_000 }), ); if (sha256(diff) !== input.expectedDiffDigest) throw new Error("Committed diff differs from the reviewed content"); }