src / tools / vcsTool.ts
src / tools / vcsTool.ts
import { tool, type Tool, type ToolCallContext } from "@lmstudio/sdk";
import { z } from "zod";
import { AgenticError } from "../core/errors";
import { errorResult, okResult } from "../core/result";
import {
applyGate,
compact,
consumeApproval,
hashOperation,
type GateOutcome,
} from "../policy/gateRuntime";
import type { PluginRuntime } from "../runtime";
import type { CommandResult } from "../execution/processRunner";
function resultData(result: CommandResult) {
return {
id: result.id,
status: result.status,
executable: result.executable,
args: result.args,
cwd: result.cwd,
exitCode: result.exitCode,
durationMs: result.durationMs,
stdout: result.stdoutPreview,
stderr: result.stderrPreview,
stdoutTruncated: result.stdoutTruncated,
stderrTruncated: result.stderrTruncated,
error: result.error,
};
}
function requireValue(value: string | undefined, field: string): string {
if (!value?.trim()) throw new AgenticError("INVALID_INPUT", `workspace_vcs requires ${field}.`);
return value.trim();
}
/**
* git takes options and refs in the same argv positions, so a value that
* starts with `-` is read as a flag however carefully the args array is built:
* `{action:"branch", create:true, branch:"-D", ref:"feature"}` produced
* `git branch -D feature`, a deletion the gate had already classified
* non-destructive because `create: true` is not `delete: true`, and
* `{action:"push", branch:"--mirror"}` produced `git push origin --mirror`,
* which force-updates and deletes remote refs with `forcedPush` false.
*
* `+` and `:` are the same problem one layer down: `git push origin :feature`
* deletes the remote branch and `+feature` force-updates it, neither of them
* setting `force`. None of the three can begin a ref name — git's own
* check-ref-format forbids a leading `-` — so refusing them costs nothing.
* `:` stays legal mid-value: `show` with `HEAD:src/index.ts` is a real use.
*/
function safeRef(value: string, field: string): string {
if (/^[-+:]/.test(value)) {
throw new AgenticError(
"INVALID_INPUT",
`${field} may not start with '-', '+' or ':': git would read it as an option or a refspec rather than a ref. Pass the plain branch or ref name.`,
);
}
if (!/^[A-Za-z0-9._/@:+-]+$/.test(value)) {
throw new AgenticError("INVALID_INPUT", `${field} contains unsupported characters.`);
}
return value;
}
/**
* `remote` names a remote the user already configured; it is never a URL. The
* ref character class accepted `https://evil.example/r.git` and
* `git@evil.example:a/b.git` whole, and `git push <url>` sends the repository
* to a host nobody configured — an exfiltration path with no network policy in
* front of it.
*/
function safeRemote(value: string): string {
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(value)) {
throw new AgenticError(
"INVALID_INPUT",
"remote must be the name of a configured remote (letters, digits, '.', '_' or '-'), not a URL or an option.",
);
}
return value;
}
/**
* What to do instead when `allowDestructiveEdits` refuses a git action. The
* gate message names the setting; a model that only reads the refusal still
* has to guess a next move, and in a live run it guessed "give up and commit
* to the wrong branch". Naming the non-destructive alternative fixes that.
*/
const DESTRUCTIVE_ALTERNATIVE: Readonly<Record<string, string>> = {
checkout:
'Use action "branch" with create true to create the branch without switching the working tree, and tell the user you could not check it out.',
branch: "Leave the branch in place; deleting one needs that setting.",
push: "Push without force, or ask the user to enable the setting.",
};
function withDestructiveFallback(error: unknown, action: string): unknown {
const alternative = DESTRUCTIVE_ALTERNATIVE[action];
if (!alternative || !(error instanceof AgenticError) || error.code !== "PROTECTED_PATH") {
return error;
}
return new AgenticError("PROTECTED_PATH", `${error.message} ${alternative}`, error.details);
}
export function createVcsTool(runtime: PluginRuntime): Tool {
return tool({
name: "workspace_vcs",
description:
"Git and GitHub CLI, no shell strings. Actions: init, status, diff, log, show, add, commit, checkout, branch, push, gh_auth, issue_list, issue_create, pr_list, pr_create, pr_diff. init a repo for a new project, then add and commit each verified change; push and issue/PR only when asked. checkout needs allowDestructiveEdits; branch create does not. Manual mode stages mutations: pending_approval + an id, report it and stop, re-issue after /accept. Plan mode without an approved plan fails APPROVAL_REQUIRED.",
parameters: {
action: z.enum([
"init",
"status",
"diff",
"log",
"show",
"add",
"commit",
"checkout",
"branch",
"push",
"gh_auth",
"issue_list",
"issue_create",
"pr_list",
"pr_create",
"pr_diff",
]),
cwd: z.string().optional(),
path: z.string().optional(),
paths: z.array(z.string()).optional(),
cached: z.boolean().optional(),
limit: z.number().int().min(1).max(200).optional(),
ref: z.string().optional(),
message: z.string().optional(),
branch: z.string().optional(),
create: z.boolean().optional(),
delete: z.boolean().optional(),
force: z.boolean().optional(),
remote: z.string().optional(),
title: z.string().optional(),
body: z.string().optional(),
labels: z.array(z.string()).optional(),
base: z.string().optional(),
head: z.string().optional(),
draft: z.boolean().optional(),
number: z.number().int().min(1).optional(),
state: z.enum(["open", "closed", "all", "merged"]).optional(),
idempotency_key: z.string().optional(),
},
implementation: async (input: {
action:
| "init"
| "status"
| "diff"
| "log"
| "show"
| "add"
| "commit"
| "checkout"
| "branch"
| "push"
| "gh_auth"
| "issue_list"
| "issue_create"
| "pr_list"
| "pr_create"
| "pr_diff";
cwd?: string;
path?: string;
paths?: string[];
cached?: boolean;
limit?: number;
ref?: string;
message?: string;
branch?: string;
create?: boolean;
delete?: boolean;
force?: boolean;
remote?: string;
title?: string;
body?: string;
labels?: string[];
base?: string;
head?: string;
draft?: boolean;
number?: number;
state?: "open" | "closed" | "all" | "merged";
idempotency_key?: string;
}, ctx: ToolCallContext) => {
try {
if (!runtime.settings.processExecutionEnabled || !runtime.settings.vcsToolsEnabled) {
throw new AgenticError(
"PROCESS_DISABLED",
"Git/GitHub tools require process execution and VCS tools to be enabled.",
);
}
let executable = "git";
let args: string[] = [];
let mutatesWorkingTree = false;
switch (input.action) {
case "init":
args = ["init"];
if (input.branch) args.push("-b", safeRef(input.branch, "branch"));
break;
case "status":
args = ["status", "--short", "--branch"];
break;
case "diff":
args = ["diff", "--no-ext-diff", "--no-color"];
if (input.cached) args.push("--cached");
if (input.path) args.push("--", input.path);
break;
case "log":
args = [
"log",
`-${input.limit ?? 20}`,
"--date=iso-strict",
"--pretty=format:%h%x09%ad%x09%an%x09%s",
];
if (input.path) args.push("--", input.path);
break;
case "show":
args = ["show", "--no-ext-diff", "--no-color", safeRef(input.ref ?? "HEAD", "ref")];
break;
case "add":
if (!input.paths?.length) throw new AgenticError("INVALID_INPUT", "git add requires paths.");
args = ["add", "--", ...input.paths];
break;
case "commit":
args = ["commit", "-m", requireValue(input.message, "message")];
break;
case "checkout": {
const ref = safeRef(requireValue(input.ref ?? input.branch, "ref or branch"), "ref");
args = ["checkout"];
if (input.create) args.push("-b");
if (input.force) args.push("--force");
args.push(ref);
mutatesWorkingTree = true;
break;
}
case "branch": {
const branch = input.branch ? safeRef(input.branch, "branch") : undefined;
if (input.delete) {
if (!branch) throw new AgenticError("INVALID_INPUT", "branch delete requires branch.");
args = ["branch", input.force ? "-D" : "-d", branch];
mutatesWorkingTree = true;
} else if (input.create) {
if (!branch) throw new AgenticError("INVALID_INPUT", "branch create requires branch.");
args = ["branch", branch];
if (input.ref) args.push(safeRef(input.ref, "ref"));
} else {
args = ["branch", "--verbose", "--no-color"];
}
break;
}
case "push":
args = ["push", safeRemote(input.remote ?? "origin")];
if (input.branch) args.push(safeRef(input.branch, "branch"));
if (input.force) args.push("--force-with-lease");
break;
case "gh_auth":
executable = "gh";
args = ["auth", "status"];
break;
case "issue_list":
executable = "gh";
args = [
"issue",
"list",
"--limit",
String(input.limit ?? 30),
"--state",
input.state === "closed" ? "closed" : input.state === "all" ? "all" : "open",
"--json",
"number,title,state,url,labels,author,updatedAt",
];
break;
case "issue_create":
executable = "gh";
args = ["issue", "create", "--title", requireValue(input.title, "title")];
if (input.body !== undefined) args.push("--body", input.body);
for (const label of input.labels ?? []) args.push("--label", label);
break;
case "pr_list":
executable = "gh";
args = [
"pr",
"list",
"--limit",
String(input.limit ?? 30),
"--state",
input.state === "closed" || input.state === "merged" || input.state === "all"
? input.state
: "open",
"--json",
"number,title,state,url,headRefName,baseRefName,author,updatedAt,isDraft",
];
break;
case "pr_create":
executable = "gh";
args = ["pr", "create", "--title", requireValue(input.title, "title")];
if (input.body !== undefined) args.push("--body", input.body);
if (input.base) args.push("--base", safeRef(input.base, "base"));
if (input.head) args.push("--head", safeRef(input.head, "head"));
if (input.draft) args.push("--draft");
break;
case "pr_diff":
executable = "gh";
args = ["pr", "diff"];
if (input.number !== undefined) args.push(String(input.number));
args.push("--color", "never");
break;
}
const forcedPush = input.action === "push" && input.force === true;
// `init` creates .git/ in the user's tree: a non-destructive mutation
// that Manual mode exists to ask about.
const mutating = new Set(["init", "add", "commit", "checkout", "branch", "push", "issue_create", "pr_create"]);
const branchIsReadOnly = input.action === "branch" && !input.create && !input.delete;
let gate: GateOutcome | undefined;
if (mutating.has(input.action) && !branchIsReadOnly) {
try {
gate = await applyGate(runtime, ctx, {
kind: "vcs",
operation: `vcs.${input.action}`,
operationHash: hashOperation("vcs", { executable, args, cwd: input.cwd ?? "." }),
title: `${executable} ${args.join(" ")}`.slice(0, 300),
description: `cwd ${input.cwd ?? "."}`,
destructive: mutatesWorkingTree || forcedPush,
reference: {},
resume: { tool: "workspace_vcs", arguments: compact({ ...input }) },
});
} catch (error) {
throw withDestructiveFallback(error, input.action);
}
if (gate.outcome === "stage") return gate.result;
}
const result = await runtime.processes.run({
executable,
args,
cwd: input.cwd,
timeoutSeconds: 180,
idempotencyKey: input.idempotency_key,
});
if (gate) await consumeApproval(runtime, ctx, gate, { jobId: result.id });
const ok = result.status === "completed" && result.exitCode === 0;
// `-> 128` read as a result, not a failure, and the only thing that
// said why (`fatal: not a git repository`) lived in stderr, which is
// omitted when summarizing. The summary and the first fact are all that
// survive compaction, so the verdict and the cause go in both. Shape
// follows workspace_command: status word, then the code.
const outcome = ok
? "0"
: `exit ${result.exitCode ?? result.signal ?? result.status} (failed)`;
const headline = `${executable} ${args.join(" ")} -> ${outcome}`;
const cause = result.stderrPreview.trim().split(/\r?\n/)[0]?.trim() ?? "";
return okResult({
operation: `vcs.${input.action}`,
summary: headline,
data: resultData(result),
artifacts: result.artifacts,
importance: ok ? (input.action === "status" || input.action === "log" ? "normal" : "high") : "high",
facts: [
headline,
...(!ok && cause ? [cause.slice(0, 300)] : []),
...(result.stdoutPreview.trim() ? [result.stdoutPreview.trim().slice(0, 2000)] : []),
],
omit: ["data.stdout", "data.stderr"],
});
} catch (error) {
return errorResult(`vcs.${input.action}`, error, "high");
}
},
});
}
import { tool, type Tool, type ToolCallContext } from "@lmstudio/sdk";
import { z } from "zod";
import { AgenticError } from "../core/errors";
import { errorResult, okResult } from "../core/result";
import {
applyGate,
compact,
consumeApproval,
hashOperation,
type GateOutcome,
} from "../policy/gateRuntime";
import type { PluginRuntime } from "../runtime";
import type { CommandResult } from "../execution/processRunner";
function resultData(result: CommandResult) {
return {
id: result.id,
status: result.status,
executable: result.executable,
args: result.args,
cwd: result.cwd,
exitCode: result.exitCode,
durationMs: result.durationMs,
stdout: result.stdoutPreview,
stderr: result.stderrPreview,
stdoutTruncated: result.stdoutTruncated,
stderrTruncated: result.stderrTruncated,
error: result.error,
};
}
function requireValue(value: string | undefined, field: string): string {
if (!value?.trim()) throw new AgenticError("INVALID_INPUT", `workspace_vcs requires ${field}.`);
return value.trim();
}
/**
* git takes options and refs in the same argv positions, so a value that
* starts with `-` is read as a flag however carefully the args array is built:
* `{action:"branch", create:true, branch:"-D", ref:"feature"}` produced
* `git branch -D feature`, a deletion the gate had already classified
* non-destructive because `create: true` is not `delete: true`, and
* `{action:"push", branch:"--mirror"}` produced `git push origin --mirror`,
* which force-updates and deletes remote refs with `forcedPush` false.
*
* `+` and `:` are the same problem one layer down: `git push origin :feature`
* deletes the remote branch and `+feature` force-updates it, neither of them
* setting `force`. None of the three can begin a ref name — git's own
* check-ref-format forbids a leading `-` — so refusing them costs nothing.
* `:` stays legal mid-value: `show` with `HEAD:src/index.ts` is a real use.
*/
function safeRef(value: string, field: string): string {
if (/^[-+:]/.test(value)) {
throw new AgenticError(
"INVALID_INPUT",
`${field} may not start with '-', '+' or ':': git would read it as an option or a refspec rather than a ref. Pass the plain branch or ref name.`,
);
}
if (!/^[A-Za-z0-9._/@:+-]+$/.test(value)) {
throw new AgenticError("INVALID_INPUT", `${field} contains unsupported characters.`);
}
return value;
}
/**
* `remote` names a remote the user already configured; it is never a URL. The
* ref character class accepted `https://evil.example/r.git` and
* `git@evil.example:a/b.git` whole, and `git push <url>` sends the repository
* to a host nobody configured — an exfiltration path with no network policy in
* front of it.
*/
function safeRemote(value: string): string {
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(value)) {
throw new AgenticError(
"INVALID_INPUT",
"remote must be the name of a configured remote (letters, digits, '.', '_' or '-'), not a URL or an option.",
);
}
return value;
}
/**
* What to do instead when `allowDestructiveEdits` refuses a git action. The
* gate message names the setting; a model that only reads the refusal still
* has to guess a next move, and in a live run it guessed "give up and commit
* to the wrong branch". Naming the non-destructive alternative fixes that.
*/
const DESTRUCTIVE_ALTERNATIVE: Readonly<Record<string, string>> = {
checkout:
'Use action "branch" with create true to create the branch without switching the working tree, and tell the user you could not check it out.',
branch: "Leave the branch in place; deleting one needs that setting.",
push: "Push without force, or ask the user to enable the setting.",
};
function withDestructiveFallback(error: unknown, action: string): unknown {
const alternative = DESTRUCTIVE_ALTERNATIVE[action];
if (!alternative || !(error instanceof AgenticError) || error.code !== "PROTECTED_PATH") {
return error;
}
return new AgenticError("PROTECTED_PATH", `${error.message} ${alternative}`, error.details);
}
export function createVcsTool(runtime: PluginRuntime): Tool {
return tool({
name: "workspace_vcs",
description:
"Git and GitHub CLI, no shell strings. Actions: init, status, diff, log, show, add, commit, checkout, branch, push, gh_auth, issue_list, issue_create, pr_list, pr_create, pr_diff. init a repo for a new project, then add and commit each verified change; push and issue/PR only when asked. checkout needs allowDestructiveEdits; branch create does not. Manual mode stages mutations: pending_approval + an id, report it and stop, re-issue after /accept. Plan mode without an approved plan fails APPROVAL_REQUIRED.",
parameters: {
action: z.enum([
"init",
"status",
"diff",
"log",
"show",
"add",
"commit",
"checkout",
"branch",
"push",
"gh_auth",
"issue_list",
"issue_create",
"pr_list",
"pr_create",
"pr_diff",
]),
cwd: z.string().optional(),
path: z.string().optional(),
paths: z.array(z.string()).optional(),
cached: z.boolean().optional(),
limit: z.number().int().min(1).max(200).optional(),
ref: z.string().optional(),
message: z.string().optional(),
branch: z.string().optional(),
create: z.boolean().optional(),
delete: z.boolean().optional(),
force: z.boolean().optional(),
remote: z.string().optional(),
title: z.string().optional(),
body: z.string().optional(),
labels: z.array(z.string()).optional(),
base: z.string().optional(),
head: z.string().optional(),
draft: z.boolean().optional(),
number: z.number().int().min(1).optional(),
state: z.enum(["open", "closed", "all", "merged"]).optional(),
idempotency_key: z.string().optional(),
},
implementation: async (input: {
action:
| "init"
| "status"
| "diff"
| "log"
| "show"
| "add"
| "commit"
| "checkout"
| "branch"
| "push"
| "gh_auth"
| "issue_list"
| "issue_create"
| "pr_list"
| "pr_create"
| "pr_diff";
cwd?: string;
path?: string;
paths?: string[];
cached?: boolean;
limit?: number;
ref?: string;
message?: string;
branch?: string;
create?: boolean;
delete?: boolean;
force?: boolean;
remote?: string;
title?: string;
body?: string;
labels?: string[];
base?: string;
head?: string;
draft?: boolean;
number?: number;
state?: "open" | "closed" | "all" | "merged";
idempotency_key?: string;
}, ctx: ToolCallContext) => {
try {
if (!runtime.settings.processExecutionEnabled || !runtime.settings.vcsToolsEnabled) {
throw new AgenticError(
"PROCESS_DISABLED",
"Git/GitHub tools require process execution and VCS tools to be enabled.",
);
}
let executable = "git";
let args: string[] = [];
let mutatesWorkingTree = false;
switch (input.action) {
case "init":
args = ["init"];
if (input.branch) args.push("-b", safeRef(input.branch, "branch"));
break;
case "status":
args = ["status", "--short", "--branch"];
break;
case "diff":
args = ["diff", "--no-ext-diff", "--no-color"];
if (input.cached) args.push("--cached");
if (input.path) args.push("--", input.path);
break;
case "log":
args = [
"log",
`-${input.limit ?? 20}`,
"--date=iso-strict",
"--pretty=format:%h%x09%ad%x09%an%x09%s",
];
if (input.path) args.push("--", input.path);
break;
case "show":
args = ["show", "--no-ext-diff", "--no-color", safeRef(input.ref ?? "HEAD", "ref")];
break;
case "add":
if (!input.paths?.length) throw new AgenticError("INVALID_INPUT", "git add requires paths.");
args = ["add", "--", ...input.paths];
break;
case "commit":
args = ["commit", "-m", requireValue(input.message, "message")];
break;
case "checkout": {
const ref = safeRef(requireValue(input.ref ?? input.branch, "ref or branch"), "ref");
args = ["checkout"];
if (input.create) args.push("-b");
if (input.force) args.push("--force");
args.push(ref);
mutatesWorkingTree = true;
break;
}
case "branch": {
const branch = input.branch ? safeRef(input.branch, "branch") : undefined;
if (input.delete) {
if (!branch) throw new AgenticError("INVALID_INPUT", "branch delete requires branch.");
args = ["branch", input.force ? "-D" : "-d", branch];
mutatesWorkingTree = true;
} else if (input.create) {
if (!branch) throw new AgenticError("INVALID_INPUT", "branch create requires branch.");
args = ["branch", branch];
if (input.ref) args.push(safeRef(input.ref, "ref"));
} else {
args = ["branch", "--verbose", "--no-color"];
}
break;
}
case "push":
args = ["push", safeRemote(input.remote ?? "origin")];
if (input.branch) args.push(safeRef(input.branch, "branch"));
if (input.force) args.push("--force-with-lease");
break;
case "gh_auth":
executable = "gh";
args = ["auth", "status"];
break;
case "issue_list":
executable = "gh";
args = [
"issue",
"list",
"--limit",
String(input.limit ?? 30),
"--state",
input.state === "closed" ? "closed" : input.state === "all" ? "all" : "open",
"--json",
"number,title,state,url,labels,author,updatedAt",
];
break;
case "issue_create":
executable = "gh";
args = ["issue", "create", "--title", requireValue(input.title, "title")];
if (input.body !== undefined) args.push("--body", input.body);
for (const label of input.labels ?? []) args.push("--label", label);
break;
case "pr_list":
executable = "gh";
args = [
"pr",
"list",
"--limit",
String(input.limit ?? 30),
"--state",
input.state === "closed" || input.state === "merged" || input.state === "all"
? input.state
: "open",
"--json",
"number,title,state,url,headRefName,baseRefName,author,updatedAt,isDraft",
];
break;
case "pr_create":
executable = "gh";
args = ["pr", "create", "--title", requireValue(input.title, "title")];
if (input.body !== undefined) args.push("--body", input.body);
if (input.base) args.push("--base", safeRef(input.base, "base"));
if (input.head) args.push("--head", safeRef(input.head, "head"));
if (input.draft) args.push("--draft");
break;
case "pr_diff":
executable = "gh";
args = ["pr", "diff"];
if (input.number !== undefined) args.push(String(input.number));
args.push("--color", "never");
break;
}
const forcedPush = input.action === "push" && input.force === true;
// `init` creates .git/ in the user's tree: a non-destructive mutation
// that Manual mode exists to ask about.
const mutating = new Set(["init", "add", "commit", "checkout", "branch", "push", "issue_create", "pr_create"]);
const branchIsReadOnly = input.action === "branch" && !input.create && !input.delete;
let gate: GateOutcome | undefined;
if (mutating.has(input.action) && !branchIsReadOnly) {
try {
gate = await applyGate(runtime, ctx, {
kind: "vcs",
operation: `vcs.${input.action}`,
operationHash: hashOperation("vcs", { executable, args, cwd: input.cwd ?? "." }),
title: `${executable} ${args.join(" ")}`.slice(0, 300),
description: `cwd ${input.cwd ?? "."}`,
destructive: mutatesWorkingTree || forcedPush,
reference: {},
resume: { tool: "workspace_vcs", arguments: compact({ ...input }) },
});
} catch (error) {
throw withDestructiveFallback(error, input.action);
}
if (gate.outcome === "stage") return gate.result;
}
const result = await runtime.processes.run({
executable,
args,
cwd: input.cwd,
timeoutSeconds: 180,
idempotencyKey: input.idempotency_key,
});
if (gate) await consumeApproval(runtime, ctx, gate, { jobId: result.id });
const ok = result.status === "completed" && result.exitCode === 0;
// `-> 128` read as a result, not a failure, and the only thing that
// said why (`fatal: not a git repository`) lived in stderr, which is
// omitted when summarizing. The summary and the first fact are all that
// survive compaction, so the verdict and the cause go in both. Shape
// follows workspace_command: status word, then the code.
const outcome = ok
? "0"
: `exit ${result.exitCode ?? result.signal ?? result.status} (failed)`;
const headline = `${executable} ${args.join(" ")} -> ${outcome}`;
const cause = result.stderrPreview.trim().split(/\r?\n/)[0]?.trim() ?? "";
return okResult({
operation: `vcs.${input.action}`,
summary: headline,
data: resultData(result),
artifacts: result.artifacts,
importance: ok ? (input.action === "status" || input.action === "log" ? "normal" : "high") : "high",
facts: [
headline,
...(!ok && cause ? [cause.slice(0, 300)] : []),
...(result.stdoutPreview.trim() ? [result.stdoutPreview.trim().slice(0, 2000)] : []),
],
omit: ["data.stdout", "data.stderr"],
});
} catch (error) {
return errorResult(`vcs.${input.action}`, error, "high");
}
},
});
}