git tool: reject {command, args[0]} duplicates with a directed error

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: ["--", "<name>"]`
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().
This commit is contained in:
Colin McDonnell
2026-05-27 23:22:26 +00:00
committed by pullfrog[bot]
parent c89b0c7b4a
commit b0868d48e6
+18 -1
View File
@@ -520,7 +520,9 @@ export function GitTool(ctx: ToolContext) {
return tool({ return tool({
name: "git", name: "git",
description: 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: "log", args: ["--oneline", "-n", "20"] })`. ' +
'Example: `git({ command: "diff", args: ["origin/main..HEAD"] })`. ' + 'Example: `git({ command: "diff", args: ["origin/main..HEAD"] })`. ' +
"For push/fetch, use the dedicated MCP tools (push_branch, git_fetch). " + "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 command = params.command;
const args = params.args ?? []; 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]; const redirect = AUTH_REQUIRED_REDIRECT[command];
if (redirect) { if (redirect) {
throw new Error(`git ${command} is not available through this tool — ${redirect}`); throw new Error(`git ${command} is not available through this tool — ${redirect}`);