UNPKG

@juspay/neurolink

Version:

The pipe layer of an AI nervous system: one interface connecting provider neurons to your application, across three inference types — generate, stream and decide. `decide` returns typed, calibrated judgements from a non-generative model (~400ms, ~$0.00002

619 lines (618 loc) • 25.6 kB
/** * Read-only git toolset (N4.4) — six bounded tools on the hardened runner. * * A reviewing agent asks git the same handful of questions over and over: what * changed, against what base, who wrote this line, what files does the tree * hold. The tempting shape — "let it run `git ...` through the shell" — is * wrong twice. It hands the model a shell, and it hands git a free-form * argument string, which is not read-only at all: `--output=<file>` writes, * and `diff.external` runs an arbitrary program. * * So the model supplies VALUES, never flags. Each tool validates a ref, a * path, a line range or a count, assembles a fixed argv from them, and runs it * through {@link startCommandWithPolicy} with a policy of its own — a * one-executable allowlist rooted at the repository. Registering these tools * therefore widens nothing: it does not let `run_command_bg` execute git, and * it does not require a general command policy to exist. * * Output follows the same rule as every other big payload here: the COMPLETE * stdout is banked (N3) and the tool returns a bounded preview plus the * read-back call. A 40 MB diff costs a few hundred tokens and loses nothing. * * @module agent/gitTools */ import { relative } from "node:path"; import { z } from "zod"; import { logger } from "../utils/logger.js"; import { resolvePathWithinRoot } from "../utils/pathSandbox.js"; import { awaitBackgroundCommand, readBackgroundCommandOutput, startCommandWithPolicy, } from "./backgroundCommands.js"; import { resolveChecklistSessionId } from "./taskChecklist.js"; const hostSettings = new WeakMap(); /** Wall-clock budget per git invocation. */ const DEFAULT_GIT_TIMEOUT_MS = 60_000; /** Byte cap per stream — a repository-sized diff is still a legitimate answer. */ const DEFAULT_GIT_MAX_OUTPUT_BYTES = 33_554_432; /** Characters of stdout returned inline. */ const DEFAULT_GIT_PREVIEW_CHARS = 2_000; /** Ceiling on the inline preview, whatever the caller configures. */ const MAX_GIT_PREVIEW_CHARS = 4_000; /** Characters of stderr returned inline when git wrote any. */ const GIT_STDERR_PREVIEW_CHARS = 600; /** Commits returned by `git_log` when the caller names no bound. */ const DEFAULT_LOG_MAX_COUNT = 20; /** Hard bound on commits per `git_log` call. */ const MAX_LOG_MAX_COUNT = 500; /** Lines returned by `git_blame` when only a start line is given. */ const DEFAULT_BLAME_SPAN = 100; /** * Global arguments prepended to every invocation. * * `--no-pager` and the empty `diff.external` are the two that matter: a user * gitconfig can point either at a program, and a "read-only" tool that runs * whatever `diff.external` names is not read-only. Per-driver programs * (`diff.<driver>.command`, `diff.<driver>.textconv`) have unbounded names, so * they cannot be neutralised here — every diff-producing subcommand passes * `--no-ext-diff --no-textconv` instead. */ const GIT_GLOBAL_ARGS = [ "--no-pager", "-c", "color.ui=false", "-c", "diff.external=", "-c", "core.fsmonitor=false", ]; /** * Ref characters git actually uses — including `..`/`...` ranges, `^`/`~` * ancestry, `@{upstream}` and `rev:path`. A leading `-` is refused separately, * so a value can never be read as a flag. */ const GIT_REF_PATTERN = /^[A-Za-z0-9._/^~@{}:+-]{1,200}$/; /** Date expressions for `--since`, e.g. "2 weeks ago" or "2026-01-31". */ const GIT_DATE_PATTERN = /^[A-Za-z0-9 :,.+-]{1,64}$/; // ── Settings ─────────────────────────────────────────────────────────────── /** Resolve and remember one host's git-toolset settings. */ export function configureGitTools(host, options) { const settings = { repoRoot: options.repoRoot, timeoutMs: options.timeoutMs ?? DEFAULT_GIT_TIMEOUT_MS, maxOutputBytes: options.maxOutputBytes ?? DEFAULT_GIT_MAX_OUTPUT_BYTES, previewChars: Math.min(Math.max(1, options.previewChars ?? DEFAULT_GIT_PREVIEW_CHARS), MAX_GIT_PREVIEW_CHARS), gitExecutable: options.gitExecutable?.trim() || "git", }; hostSettings.set(host, settings); return settings; } function settingsFor(host) { return hostSettings.get(host); } const NOT_CONFIGURED = "The git toolset is not configured on this instance. The host must call " + "registerGitTools({ repoRoot }) before any git tool can run."; // ── Small helpers ────────────────────────────────────────────────────────── function errorMessage(error) { return error instanceof Error ? error.message : String(error); } /** Matches `agentToolRegistrar`'s convention: the recovery step is IN the text. */ function refusal(message) { return { isError: true, error: message }; } /** * A ref the model supplied, or the reason it was refused. * * The leading-dash check is the load-bearing one: without it `--output=x` in a * `ref` field becomes a flag git honours, and the tool stops being read-only. */ function checkRef(value, field) { const trimmed = value.trim(); if (!trimmed) { return `${field} must not be empty. Name a branch, tag or commit.`; } if (trimmed.startsWith("-")) { return (`${field} must not start with "-": these tools take VALUES, not flags, and a ` + "value that looks like a flag is refused. Pass a branch, tag or commit."); } if (!GIT_REF_PATTERN.test(trimmed)) { return (`${field} "${trimmed.slice(0, 60)}" is not a valid git revision. Use a branch, ` + "tag, commit sha, or a range like main..HEAD."); } return undefined; } /** * Turn a model-supplied path into one git can be given: repository-relative, * proven to resolve inside the repository root. */ function checkPath(value, settings) { const trimmed = value.trim(); if (!trimmed) { return { error: "path must not be empty." }; } if (trimmed.startsWith("-")) { return { error: 'path must not start with "-": these tools take VALUES, not flags. Pass a ' + "path relative to the repository root.", }; } if (trimmed.includes("\0")) { return { error: "path must not contain NUL bytes." }; } const resolved = resolvePathWithinRoot(trimmed, settings.repoRoot); if (resolved.error !== undefined) { return resolved; } // Canonicalise the root the same way the target was canonicalised: with a // symlinked root component (macOS tmpdir's /var -> /private/var is the // everyday case) the raw root and the resolved target never share a prefix, // and every in-repo path would be refused as outside the repository. const root = resolvePathWithinRoot(".", settings.repoRoot); if (root.error !== undefined) { return root; } const rel = relative(root.path, resolved.path); if (rel.startsWith("..")) { return { error: `Access denied: "${trimmed}" is outside the repository ${settings.repoRoot}.`, }; } return { path: rel || "." }; } function clamp(value, low, high) { return Math.min(Math.max(Math.trunc(value), low), high); } function logFormatArgs(format) { switch (format) { case "full": return ["--pretty=fuller"]; case "stat": return ["--stat"]; case "name-only": return ["--name-only"]; default: return ["--oneline"]; } } /** * Environment for git. It REPLACES the parent environment, so a repository's * commands never see the host's credentials, and the three variables that stop * git from blocking on a human (`GIT_TERMINAL_PROMPT`) or paging (`GIT_PAGER`) * are set explicitly. */ function gitEnvironment() { const env = { LANG: "C", LC_ALL: "C", GIT_TERMINAL_PROMPT: "0", GIT_OPTIONAL_LOCKS: "0", GIT_PAGER: "cat", }; if (process.env.PATH) { env.PATH = process.env.PATH; } // HOME is kept so a repository covered by `safe.directory` still answers. if (process.env.HOME) { env.HOME = process.env.HOME; } // Windows: git may not start without SystemRoot, and temporary-file // operations need TEMP/TMP. Forwarded only when present, so POSIX // environments are byte-identical to before. for (const name of ["SystemRoot", "TEMP", "TMP"]) { const value = process.env[name]; if (value) { env[name] = value; } } return env; } function fallbackRef(label, reason) { return { artifactId: "", label, kind: "command-output", sizeBytes: 0, preview: "", readBackHint: `No artifact was created for this output: ${reason}`, }; } // ── Running one git command ──────────────────────────────────────────────── /** * Run one fixed argv against the repository and return a bounded result whose * full output is banked. * * Exported so host code can drive the same six questions without a model in * the loop; `args` is the git argument list (`["log", "--oneline"]`), assembled * by a caller that validated every value in it. */ export async function runGitCommand(host, args, sessionId) { const settings = settingsFor(host); if (!settings) { throw new Error(NOT_CONFIGURED); } const argv = [settings.gitExecutable, ...GIT_GLOBAL_ARGS, ...args]; const policy = { allowedExecutables: [settings.gitExecutable], cwdRoot: settings.repoRoot, defaultTimeoutMs: settings.timeoutMs, maxOutputBytes: settings.maxOutputBytes, }; const label = `git ${args[0] ?? ""}`.trim(); const handle = await startCommandWithPolicy(host, argv, { cwd: settings.repoRoot, label, env: gitEnvironment(), ...(sessionId && { sessionId }), }, policy); const status = await awaitBackgroundCommand(host, handle.taskId); const head = await readBackgroundCommandOutput(host, handle.taskId, { stream: "stdout", offset: 0, limit: settings.previewChars, }); const errors = await readBackgroundCommandOutput(host, handle.taskId, { stream: "stderr", offset: 0, limit: GIT_STDERR_PREVIEW_CHARS, }); const output = status.stdout ?? fallbackRef(`${label} [stdout]`, status.error ?? "the command did not settle normally"); return { command: argv, ok: status.exitCode === 0, ...(status.exitCode !== undefined && { exitCode: status.exitCode }), state: status.state, preview: head.hasMore ? `${head.content}…` : head.content, output, readBackHint: output.readBackHint, ...(errors.content && { stderrPreview: errors.hasMore ? `${errors.content}…` : errors.content, }), }; } // ── Model-facing tools ───────────────────────────────────────────────────── const LOG_SCHEMA = z.object({ ref: z .string() .optional() .describe('Branch, tag, commit or range, e.g. "main..HEAD". Default: HEAD.'), path: z .string() .optional() .describe("Limit the history to this path, relative to the repository root."), maxCount: z .number() .optional() .describe("How many commits to return. Default 20, maximum 500."), since: z .string() .optional() .describe('Only commits after this date, e.g. "2 weeks ago" or "2026-01-31".'), format: z .enum(["oneline", "full", "stat", "name-only"]) .optional() .describe('How much to show per commit: "oneline" (default), "full", "stat" (change ' + 'counts per file), or "name-only" (file names).'), }); const SHOW_SCHEMA = z.object({ ref: z .string() .describe('What to show: a commit, tag, or "<commit>:<path>" for a file at a revision.'), path: z.string().optional().describe("Restrict the shown diff to this path."), nameOnly: z .boolean() .optional() .describe("Return only the names of the changed files."), }); const DIFF_SCHEMA = z.object({ base: z .string() .optional() .describe("Left side of the comparison. Omit both sides to diff the working tree."), head: z .string() .optional() .describe("Right side of the comparison. Requires base."), path: z.string().optional().describe("Restrict the diff to this path."), nameOnly: z .boolean() .optional() .describe("Return only the names of the changed files — cheap, and often enough."), stat: z .boolean() .optional() .describe("Return per-file change counts instead of a patch."), unified: z .number() .optional() .describe("Lines of context around each hunk. Default 3, maximum 50."), }); const BLAME_SCHEMA = z.object({ path: z.string().describe("File to blame, relative to the repository root."), ref: z .string() .optional() .describe("Revision to blame at. Default: the working tree."), lineStart: z .number() .optional() .describe("First line to blame. Blame the whole file when omitted."), lineEnd: z .number() .optional() .describe("Last line to blame. Defaults to 100 lines after lineStart."), }); const MERGE_BASE_SCHEMA = z.object({ base: z.string().describe("First revision, e.g. the target branch."), head: z.string().describe("Second revision, e.g. the pull request head."), }); const LS_FILES_SCHEMA = z.object({ path: z .string() .optional() .describe("Restrict the listing to this directory or path, relative to the root."), }); function buildLogArgs(input, settings) { const args = ["log", "--no-color", "--no-ext-diff", "--no-textconv"]; args.push(`--max-count=${clamp(input.maxCount ?? DEFAULT_LOG_MAX_COUNT, 1, MAX_LOG_MAX_COUNT)}`); args.push(...logFormatArgs(input.format ?? "oneline")); if (input.since !== undefined) { const since = input.since.trim(); if (!GIT_DATE_PATTERN.test(since)) { return refusal(`since "${since.slice(0, 60)}" is not a date expression. Use something like ` + '"2 weeks ago" or "2026-01-31".'); } args.push(`--since=${since}`); } if (input.ref !== undefined) { const bad = checkRef(input.ref, "ref"); if (bad) { return refusal(bad); } args.push(input.ref.trim()); } if (input.path !== undefined) { const resolved = checkPath(input.path, settings); if (resolved.error !== undefined) { return refusal(resolved.error); } args.push("--", resolved.path); } return args; } function buildDiffArgs(input, settings) { if (input.head !== undefined && input.base === undefined) { return refusal("diff needs a base when you name a head. Pass both sides, e.g. " + '{ base: "main", head: "HEAD" }.'); } const args = ["diff", "--no-color", "--no-ext-diff", "--no-textconv"]; if (input.nameOnly) { args.push("--name-only"); } if (input.stat) { args.push("--stat"); } if (input.unified !== undefined) { args.push(`-U${clamp(input.unified, 0, 50)}`); } for (const [field, value] of [ ["base", input.base], ["head", input.head], ]) { if (value !== undefined) { const bad = checkRef(value, field); if (bad) { return refusal(bad); } args.push(value.trim()); } } if (input.path !== undefined) { const resolved = checkPath(input.path, settings); if (resolved.error !== undefined) { return refusal(resolved.error); } args.push("--", resolved.path); } return args; } function buildBlameArgs(input, settings) { const resolved = checkPath(input.path, settings); if (resolved.error !== undefined) { return refusal(resolved.error); } // No `--no-color` here: `git blame` has no such option (it offers // --color-lines / --color-by-age), and passing it is a usage error. The // global `color.ui=false` covers this subcommand instead. const args = ["blame"]; if (input.lineStart !== undefined || input.lineEnd !== undefined) { // A lone lineEnd still bounds the blame: derive the start a span back // rather than silently blaming the whole file. const requestedEnd = input.lineEnd !== undefined ? clamp(input.lineEnd, 1, Number.MAX_SAFE_INTEGER) : undefined; const start = input.lineStart !== undefined ? clamp(input.lineStart, 1, Number.MAX_SAFE_INTEGER) : Math.max(1, (requestedEnd ?? 1) - DEFAULT_BLAME_SPAN + 1); const end = clamp(requestedEnd ?? start + DEFAULT_BLAME_SPAN - 1, start, Number.MAX_SAFE_INTEGER); args.push("-L", `${start},${end}`); } if (input.ref !== undefined) { const bad = checkRef(input.ref, "ref"); if (bad) { return refusal(bad); } args.push(input.ref.trim()); } args.push("--", resolved.path); return args; } /** * The six read-only git tools, bound to `host`. Register them with * `host.registerTool()` (see `NeuroLink.registerGitTools()`), never on the * tool registry directly: only the "user-defined" category reaches the LLM's * tool schema. */ export function createGitTools(host) { const run = async (args, sessionId) => { try { return await runGitCommand(host, args, sessionId); } catch (error) { logger.debug("[GitTools] Command refused or failed", { subcommand: args[0], error: errorMessage(error), }); return refusal(errorMessage(error)); } }; return { git_log: { name: "git_log", description: "Commit history, optionally for one path or one range. Read-only. Returns a " + "bounded preview; the complete output is banked and readable with retrieve_context.", inputSchema: LOG_SCHEMA, execute: async (params, context) => { const parsed = LOG_SCHEMA.safeParse(params ?? {}); if (!parsed.success) { return refusal("git_log expects { ref?, path?, maxCount?, since?, format? }. Call it again " + "with those fields, or with no arguments for the last 20 commits."); } const settings = settingsFor(host); if (!settings) { return refusal(NOT_CONFIGURED); } const args = buildLogArgs(parsed.data, settings); return Array.isArray(args) ? run(args, resolveChecklistSessionId(host, context)) : args; }, }, git_show: { name: "git_show", description: "Show one commit (message plus patch), or a file at a revision with " + '"<commit>:<path>". Read-only, output banked in full.', inputSchema: SHOW_SCHEMA, execute: async (params, context) => { const parsed = SHOW_SCHEMA.safeParse(params ?? {}); if (!parsed.success) { return refusal("git_show expects { ref, path?, nameOnly? } with ref naming a commit, tag " + 'or "<commit>:<path>". Call it again with a ref.'); } const settings = settingsFor(host); if (!settings) { return refusal(NOT_CONFIGURED); } const bad = checkRef(parsed.data.ref, "ref"); if (bad) { return refusal(bad); } const args = ["show", "--no-color", "--no-ext-diff", "--no-textconv"]; if (parsed.data.nameOnly) { args.push("--name-only"); } args.push(parsed.data.ref.trim()); if (parsed.data.path !== undefined) { const resolved = checkPath(parsed.data.path, settings); if (resolved.error !== undefined) { return refusal(resolved.error); } args.push("--", resolved.path); } return run(args, resolveChecklistSessionId(host, context)); }, }, git_diff: { name: "git_diff", description: "Diff the working tree, or two revisions. Prefer nameOnly or stat first — a " + "full patch of a large change is enormous, and it is banked either way.", inputSchema: DIFF_SCHEMA, execute: async (params, context) => { const parsed = DIFF_SCHEMA.safeParse(params ?? {}); if (!parsed.success) { return refusal("git_diff expects { base?, head?, path?, nameOnly?, stat?, unified? }. Call " + "it again with no arguments to diff the working tree."); } const settings = settingsFor(host); if (!settings) { return refusal(NOT_CONFIGURED); } const args = buildDiffArgs(parsed.data, settings); return Array.isArray(args) ? run(args, resolveChecklistSessionId(host, context)) : args; }, }, git_blame: { name: "git_blame", description: "Who last changed each line of a file, optionally for one line range. " + "Read-only, output banked in full.", inputSchema: BLAME_SCHEMA, execute: async (params, context) => { const parsed = BLAME_SCHEMA.safeParse(params ?? {}); if (!parsed.success) { return refusal("git_blame expects { path, ref?, lineStart?, lineEnd? } with path naming a " + "file in the repository. Call it again with a path."); } const settings = settingsFor(host); if (!settings) { return refusal(NOT_CONFIGURED); } const args = buildBlameArgs(parsed.data, settings); return Array.isArray(args) ? run(args, resolveChecklistSessionId(host, context)) : args; }, }, git_merge_base: { name: "git_merge_base", description: "The commit two revisions diverged from — the honest base for reviewing a " + "branch, rather than diffing against a moving target.", inputSchema: MERGE_BASE_SCHEMA, execute: async (params, context) => { const parsed = MERGE_BASE_SCHEMA.safeParse(params ?? {}); if (!parsed.success) { return refusal("git_merge_base expects { base, head }, both naming a revision. Call it " + "again with both."); } const settings = settingsFor(host); if (!settings) { return refusal(NOT_CONFIGURED); } for (const [field, value] of [ ["base", parsed.data.base], ["head", parsed.data.head], ]) { const bad = checkRef(value, field); if (bad) { return refusal(bad); } } return run(["merge-base", parsed.data.base.trim(), parsed.data.head.trim()], resolveChecklistSessionId(host, context)); }, }, git_ls_files: { name: "git_ls_files", description: "List the files git tracks, optionally under one path. Read-only, output " + "banked in full.", inputSchema: LS_FILES_SCHEMA, execute: async (params, context) => { const parsed = LS_FILES_SCHEMA.safeParse(params ?? {}); if (!parsed.success) { return refusal("git_ls_files expects { path? }. Call it again with no arguments to list " + "everything git tracks."); } const settings = settingsFor(host); if (!settings) { return refusal(NOT_CONFIGURED); } const args = ["ls-files"]; if (parsed.data.path !== undefined) { const resolved = checkPath(parsed.data.path, settings); if (resolved.error !== undefined) { return refusal(resolved.error); } args.push("--", resolved.path); } return run(args, resolveChecklistSessionId(host, context)); }, }, }; }