From b0868d48e652a09c6765b9843bf36952dcc858c4 Mon Sep 17 00:00:00 2001 From: Colin McDonnell Date: Wed, 27 May 2026 23:22:26 +0000 Subject: [PATCH] git tool: reject {command, args[0]} duplicates with a directed error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit models occasionally call `pullfrog_git({command:"status", args:["status"]})`, which shells out to `git status status`. git silently treats args[0] as a pathspec — when no file/dir matches, status prints "nothing to commit, working tree clean" even on a dirty tree. observed in production (Skn0tt/beckerbuch run 26519563044): the agent looped trying to reconcile that against a real diff, burned ~$3 / ~6min of opus, and only escaped when it switched to `args: ["--porcelain"]`. generalises to every subcommand (`diff diff`, `log log`, ...). guard `args[0]?.toLowerCase() === command.toLowerCase()` with a directed throw pointing the model at the disambiguated `args: ["--", ""]` escape hatch for the rare legitimate pathspec case (`--` works under every subcommand, unlike a bare positional which can be parsed as a ref by log/diff/checkout/restore/reset). description also leads with the no-args case and explicitly forbids repeating the subcommand in args. schema already had args.optional(). --- mcp/git.ts | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/mcp/git.ts b/mcp/git.ts index 344e630..f621c7d 100644 --- a/mcp/git.ts +++ b/mcp/git.ts @@ -520,7 +520,9 @@ export function GitTool(ctx: ToolContext) { return tool({ name: "git", description: - "Run a git subcommand. `command` is a single subcommand; flags and positional args go in `args`. " + + "Run a git subcommand. `command` is the subcommand ONLY — never repeat it inside `args`. " + + "`args` is optional; omit it entirely for no-flag invocations like plain `git status`. " + + 'Example: `git({ command: "status" })` for plain `git status`. ' + 'Example: `git({ command: "log", args: ["--oneline", "-n", "20"] })`. ' + 'Example: `git({ command: "diff", args: ["origin/main..HEAD"] })`. ' + "For push/fetch, use the dedicated MCP tools (push_branch, git_fetch). " + @@ -530,6 +532,21 @@ export function GitTool(ctx: ToolContext) { const command = params.command; const args = params.args ?? []; + // guard: {command:"status",args:["status"]} → `git status status`, where + // git silently treats args[0] as a pathspec. when nothing matches the + // path, status prints "nothing to commit, working tree clean" even on a + // dirty tree — a real model failure mode that burned a ~$3 run before + // self-correction. generalises to every subcommand (`diff diff`, + // `log log`, etc.). + if (args[0]?.toLowerCase() === command.toLowerCase()) { + throw new Error( + `git ${command}: '${args[0]}' duplicates the subcommand — drop args[0] ` + + `(the subcommand only belongs in 'command'). git would otherwise parse it as ` + + `a pathspec and silently return empty/clean output when nothing matches. ` + + `if you really meant a pathspec named '${args[0]}', use args: ["--", "${args[0]}"].` + ); + } + const redirect = AUTH_REQUIRED_REDIRECT[command]; if (redirect) { throw new Error(`git ${command} is not available through this tool — ${redirect}`);