src / toolsProvider.ts
import { readFile, rm, writeFile } from "fs/promises";
import { tool, type Tool, type ToolsProviderController } from "@lmstudio/sdk";
import { z } from "zod";
import { appendAuditLog, sha256 } from "./audit";
import { loadRuntimeConfig, type PowerShell7Config } from "./config";
import { runPowerShellEncodedCommand, runPowerShellFile } from "./powershell";
import { resolvePowerShellExecutable } from "./powershellPath";
import { ensureScriptsDirectory, resolveScriptPath, validateExistingScriptFile } from "./ps1Files";
import { scanPowerShellRisk, type RiskLevel, type RiskResult } from "./riskScanner";
const TOOL_TIMEOUT_MAX_SECONDS = 300;
type InlinePowerShellResult = {
ok: boolean;
exitCode: number | null;
signal: string | null;
stdout: string;
stderr: string;
durationMs: number;
timedOut: boolean;
truncated: boolean;
stdoutBytes: number;
stderrBytes: number;
stdoutMaxBytes: number;
stderrMaxBytes: number;
stdoutTruncated: boolean;
stderrTruncated: boolean;
pathResolutionError?: string;
powershellPathMode?: "auto" | "manual";
riskLevel: RiskLevel;
riskReason?: string;
commandPreview: string;
blockedReason?: string;
confirmationRequired?: boolean;
};
export async function toolsProvider(ctl: ToolsProviderController) {
const tools: Tool[] = [
tool({
name: "diagnose_powershell7",
description:
"Diagnose the PowerShell7 plugin configuration, resolved PowerShell path, version, working directory, script directory, and safety limits.",
parameters: {},
implementation: async () => diagnosePowerShell7(ctl),
}),
tool({
name: "get_powershell7_config",
description: "Return the current PowerShell7 plugin configuration as seen by LM Studio without running PowerShell.",
parameters: {},
implementation: async () => getPowerShell7Config(ctl),
}),
tool({
name: "run_powershell7",
description:
"Run an inline PowerShell 7 command using real pwsh. Uses spawn with shell disabled, EncodedCommand, timeout limits, output caps, risk checks, high-risk confirmation, CLIXML cleanup, and audit logging.",
parameters: {
command: z.string().min(1).max(12000).describe("PowerShell command text to execute."),
workingDirectory: z
.string()
.optional()
.describe("Optional working directory. Defaults to the plugin/LM Studio working directory."),
timeoutSeconds: z.number().int().min(1).max(TOOL_TIMEOUT_MAX_SECONDS).optional(),
requireConfirmation: z
.boolean()
.optional()
.describe("If true or omitted, high-risk commands return confirmationRequired instead of running."),
},
implementation: async ({ command, workingDirectory, timeoutSeconds, requireConfirmation }) =>
runInlinePowerShell7(ctl, command, workingDirectory, timeoutSeconds, requireConfirmation),
}),
tool({
name: "create_ps1_file",
description:
"Create or overwrite a .ps1 PowerShell script file inside the plugin scripts directory. Use this when the user wants to save a reusable PowerShell script before running it.",
parameters: {
fileName: z
.string()
.min(1)
.max(160)
.describe("Script file name. Must end with .ps1. Path separators are not allowed."),
content: z.string().min(1).max(100000).describe("PowerShell script content."),
overwrite: z.boolean().optional().describe("Whether to overwrite an existing script. Defaults to false."),
},
implementation: async ({ fileName, content, overwrite }) =>
createPs1File(ctl, fileName, content, overwrite),
}),
tool({
name: "run_ps1_file",
description: "Run a .ps1 PowerShell script file from the plugin scripts directory using PowerShell 7.",
parameters: {
fileName: z.string().min(1).max(160).describe("Script file name inside the scripts directory. Must end with .ps1."),
args: z
.array(z.string().max(2000))
.max(50)
.optional()
.describe("Optional script arguments passed after the script path."),
timeoutSeconds: z.number().int().min(1).max(TOOL_TIMEOUT_MAX_SECONDS).optional(),
requireConfirmation: z
.boolean()
.optional()
.describe("If true or omitted, high-risk scripts return confirmationRequired instead of running."),
},
implementation: async ({ fileName, args, timeoutSeconds, requireConfirmation }) =>
runPs1File(ctl, fileName, args ?? [], timeoutSeconds, requireConfirmation),
}),
tool({
name: "delete_ps1_file",
description: "Delete a .ps1 script file from the plugin scripts directory.",
parameters: {
fileName: z.string().min(1).max(160).describe("Script file name inside the scripts directory. Must end with .ps1."),
},
implementation: async ({ fileName }) => deletePs1File(ctl, fileName),
}),
];
return tools;
}
async function diagnosePowerShell7(ctl: ToolsProviderController) {
const config = loadRuntimeConfig(ctl);
const pluginWorkingDirectory = ctl.getWorkingDirectory();
const resolution = await resolvePowerShellExecutable(config);
const base = {
ok: resolution.ok,
powershellPathMode: config.powershellPathMode,
manualPowerShellPath: config.manualPowerShellPath,
resolvedPowerShellPath: resolution.ok ? resolution.path : undefined,
pathResolutionError: resolution.ok ? undefined : resolution.error,
configSource: config.configSource,
workingDirectory: pluginWorkingDirectory,
defaultTimeoutSeconds: config.defaultTimeoutSeconds,
maxTimeoutSeconds: config.maxTimeoutSeconds,
stdoutMaxBytes: config.stdoutMaxBytes,
stderrMaxBytes: config.stderrMaxBytes,
stripAnsiOutput: config.stripAnsiOutput,
prependNormalErrorView: config.prependNormalErrorView,
scriptsDirectory: config.scriptsDirectory,
auditLogPath: config.auditLogPath,
};
if (!resolution.ok) {
return base;
}
const versionCommand = `[PSCustomObject]@{
PSVersion = $PSVersionTable.PSVersion.ToString()
PSEdition = $PSVersionTable.PSEdition
Platform = $PSVersionTable.Platform
} | ConvertTo-Json`;
const runResult = await runPowerShellEncodedCommand(versionCommand, pluginWorkingDirectory, 5, config);
if (!runResult.ok) {
return { ...base, ok: false, pathResolutionError: runResult.stderr || "PowerShell version diagnostic command failed." };
}
try {
const parsed = JSON.parse(runResult.stdout) as { PSVersion?: string; PSEdition?: string; Platform?: string };
return {
...base,
ok: true,
version: parsed.PSVersion,
edition: parsed.PSEdition,
platform: parsed.Platform,
};
} catch {
return { ...base, ok: false, pathResolutionError: "PowerShell version output was not valid JSON." };
}
}
async function getPowerShell7Config(ctl: ToolsProviderController) {
const config = loadRuntimeConfig(ctl);
return {
powershellPathMode: config.powershellPathMode,
manualPowerShellPath: config.manualPowerShellPath,
configSource: config.configSource,
workingDirectory: ctl.getWorkingDirectory(),
defaultTimeoutSeconds: config.defaultTimeoutSeconds,
maxTimeoutSeconds: config.maxTimeoutSeconds,
stdoutMaxBytes: config.stdoutMaxBytes,
stderrMaxBytes: config.stderrMaxBytes,
stripAnsiOutput: config.stripAnsiOutput,
prependNormalErrorView: config.prependNormalErrorView,
scriptsDirectory: config.scriptsDirectory,
auditLogPath: config.auditLogPath,
};
}
async function runInlinePowerShell7(
ctl: ToolsProviderController,
command: string,
workingDirectory: string | undefined,
timeoutSeconds: number | undefined,
requireConfirmation: boolean | undefined,
): Promise<InlinePowerShellResult> {
const config = loadRuntimeConfig(ctl);
const startedAt = Date.now();
const pluginWorkingDirectory = ctl.getWorkingDirectory();
const cwd = workingDirectory ?? pluginWorkingDirectory;
const riskScan = scanPowerShellRisk(command);
const commandPreview = previewCommand(command);
if (riskScan.riskLevel === "blocked") {
const result: InlinePowerShellResult = {
ok: false,
exitCode: null,
signal: null,
stdout: "",
stderr: "",
durationMs: Date.now() - startedAt,
timedOut: false,
truncated: false,
stdoutBytes: 0,
stderrBytes: 0,
stdoutMaxBytes: config.stdoutMaxBytes,
stderrMaxBytes: config.stderrMaxBytes,
stdoutTruncated: false,
stderrTruncated: false,
riskLevel: "blocked",
riskReason: riskScan.reason,
commandPreview,
blockedReason: riskScan.reason ?? "Command is blocked by the PowerShell7 risk scanner.",
};
await audit(ctl, config, {
action: "run_powershell7",
timestamp: new Date().toISOString(),
cwd,
riskLevel: result.riskLevel,
riskReason: result.riskReason,
commandPreview,
commandSha256: sha256(command),
executed: false,
confirmationRequired: false,
exitCode: result.exitCode,
signal: result.signal,
timedOut: result.timedOut,
durationMs: result.durationMs,
blockedReason: result.blockedReason,
});
return result;
}
if (riskScan.riskLevel === "high" && requireConfirmation !== false) {
const result: InlinePowerShellResult = {
ok: false,
exitCode: null,
signal: null,
stdout: "",
stderr: "",
durationMs: Date.now() - startedAt,
timedOut: false,
truncated: false,
stdoutBytes: 0,
stderrBytes: 0,
stdoutMaxBytes: config.stdoutMaxBytes,
stderrMaxBytes: config.stderrMaxBytes,
stdoutTruncated: false,
stderrTruncated: false,
riskLevel: "high",
riskReason: riskScan.reason,
commandPreview,
blockedReason: riskScan.reason ?? "High-risk command requires explicit confirmation.",
confirmationRequired: true,
};
await audit(ctl, config, {
action: "run_powershell7",
timestamp: new Date().toISOString(),
cwd,
riskLevel: result.riskLevel,
riskReason: result.riskReason,
commandPreview,
commandSha256: sha256(command),
executed: false,
confirmationRequired: true,
exitCode: result.exitCode,
signal: result.signal,
timedOut: result.timedOut,
durationMs: result.durationMs,
blockedReason: result.blockedReason,
});
return result;
}
const timeout = resolveTimeout(timeoutSeconds, config);
const runResult = await runPowerShellEncodedCommand(command, cwd, timeout, config);
const result: InlinePowerShellResult = {
...runResult,
riskLevel: riskScan.riskLevel,
riskReason: riskScan.reason,
commandPreview,
};
await audit(ctl, config, {
action: "run_powershell7",
timestamp: new Date().toISOString(),
cwd,
riskLevel: result.riskLevel,
riskReason: result.riskReason,
commandPreview,
commandSha256: sha256(command),
executed: true,
confirmationRequired: false,
exitCode: result.exitCode,
signal: result.signal,
timedOut: result.timedOut,
durationMs: result.durationMs,
});
return result;
}
async function createPs1File(
ctl: ToolsProviderController,
fileName: string,
content: string,
overwrite: boolean | undefined,
) {
const config = loadRuntimeConfig(ctl);
const pluginWorkingDirectory = ctl.getWorkingDirectory();
const pathResult = resolveScriptPath(pluginWorkingDirectory, config, fileName);
const riskScan = scanPowerShellRisk(content);
const contentSha256 = sha256(content);
const baseAudit = {
action: "create_ps1_file",
timestamp: new Date().toISOString(),
fileName,
path: pathResult.ok ? pathResult.scriptPath : undefined,
riskLevel: riskScan.riskLevel,
riskReason: riskScan.reason,
contentSha256,
};
if (!pathResult.ok) {
const result = createPs1Result(false, fileName, riskScan, { error: pathResult.error });
await audit(ctl, config, { ...baseAudit, written: false, error: pathResult.error });
return result;
}
if (riskScan.riskLevel === "blocked") {
const error = riskScan.reason ?? "Script content is blocked by the PowerShell7 risk scanner.";
const result = createPs1Result(false, pathResult.fileName, riskScan, {
intendedPath: pathResult.scriptPath,
error,
written: false,
});
await audit(ctl, config, { ...baseAudit, fileName: pathResult.fileName, written: false, error });
return result;
}
const existingScript = await validateExistingScriptFile(pathResult.scriptPath, pathResult.scriptsDirectory);
const scriptExists = existingScript.ok;
if (!existingScript.ok && !isMissingFileError(existingScript.error)) {
const result = createPs1Result(false, pathResult.fileName, riskScan, {
intendedPath: pathResult.scriptPath,
error: existingScript.error,
written: false,
});
await audit(ctl, config, { ...baseAudit, fileName: pathResult.fileName, written: false, error: existingScript.error });
return result;
}
if (scriptExists && overwrite !== true) {
const error = "Script already exists. Set overwrite=true to replace it.";
const result = createPs1Result(false, pathResult.fileName, riskScan, {
intendedPath: pathResult.scriptPath,
error,
written: false,
});
await audit(ctl, config, { ...baseAudit, fileName: pathResult.fileName, written: false, error });
return result;
}
try {
await ensureScriptsDirectory(pathResult.scriptsDirectory);
await writeFile(pathResult.scriptPath, content, "utf8");
const result = createPs1Result(true, pathResult.fileName, riskScan, {
path: pathResult.scriptPath,
bytesWritten: Buffer.byteLength(content, "utf8"),
requiresConfirmationToRun: riskScan.riskLevel === "high" ? true : undefined,
written: true,
});
await audit(ctl, config, { ...baseAudit, fileName: pathResult.fileName, written: true });
return result;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
const result = createPs1Result(false, pathResult.fileName, riskScan, {
intendedPath: pathResult.scriptPath,
error: message,
written: false,
});
await audit(ctl, config, { ...baseAudit, fileName: pathResult.fileName, written: false, error: message });
return result;
}
}
function createPs1Result(
ok: boolean,
fileName: string,
risk: RiskResult,
extra: {
path?: string;
intendedPath?: string;
bytesWritten?: number;
requiresConfirmationToRun?: boolean;
error?: string;
written?: boolean;
},
) {
return {
ok,
fileName,
path: ok ? extra.path : undefined,
intendedPath: ok ? undefined : extra.intendedPath,
bytesWritten: extra.bytesWritten,
riskLevel: risk.riskLevel,
riskReason: risk.reason,
requiresConfirmationToRun: extra.requiresConfirmationToRun,
error: extra.error,
written: extra.written ?? ok,
};
}
async function runPs1File(
ctl: ToolsProviderController,
fileName: string,
args: string[],
timeoutSeconds: number | undefined,
requireConfirmation: boolean | undefined,
) {
const config = loadRuntimeConfig(ctl);
const pluginWorkingDirectory = ctl.getWorkingDirectory();
const pathResult = resolveScriptPath(pluginWorkingDirectory, config, fileName);
const startedAt = Date.now();
if (!pathResult.ok) {
const result = runPs1BlockedResult(config, fileName, "", "blocked", pathResult.error, Date.now() - startedAt);
await audit(ctl, config, {
action: "run_ps1_file",
timestamp: new Date().toISOString(),
fileName,
scriptPath: "",
argsCount: args.length,
riskLevel: result.riskLevel,
executed: false,
confirmationRequired: false,
exitCode: result.exitCode,
signal: result.signal,
timedOut: result.timedOut,
durationMs: result.durationMs,
blockedReason: result.blockedReason,
});
return result;
}
const existingScript = await validateExistingScriptFile(pathResult.scriptPath, pathResult.scriptsDirectory);
if (!existingScript.ok) {
const result = runPs1BlockedResult(
config,
pathResult.fileName,
pathResult.scriptPath,
"blocked",
existingScript.error,
Date.now() - startedAt,
);
await audit(ctl, config, {
action: "run_ps1_file",
timestamp: new Date().toISOString(),
fileName: pathResult.fileName,
scriptPath: pathResult.scriptPath,
argsCount: args.length,
riskLevel: result.riskLevel,
executed: false,
confirmationRequired: false,
exitCode: result.exitCode,
signal: result.signal,
timedOut: result.timedOut,
durationMs: result.durationMs,
blockedReason: result.blockedReason,
});
return result;
}
let scriptContent: string;
try {
scriptContent = await readFile(pathResult.scriptPath, "utf8");
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
const result = runPs1BlockedResult(
config,
pathResult.fileName,
pathResult.scriptPath,
"blocked",
message,
Date.now() - startedAt,
);
await audit(ctl, config, {
action: "run_ps1_file",
timestamp: new Date().toISOString(),
fileName: pathResult.fileName,
scriptPath: pathResult.scriptPath,
argsCount: args.length,
riskLevel: result.riskLevel,
executed: false,
confirmationRequired: false,
exitCode: result.exitCode,
signal: result.signal,
timedOut: result.timedOut,
durationMs: result.durationMs,
blockedReason: result.blockedReason,
});
return result;
}
const riskScan = scanPowerShellRisk(scriptContent);
const scriptSha256 = sha256(scriptContent);
if (riskScan.riskLevel === "blocked") {
const blockedReason = riskScan.reason ?? "Script content is blocked by the PowerShell7 risk scanner.";
const result = runPs1BlockedResult(
config,
pathResult.fileName,
pathResult.scriptPath,
"blocked",
blockedReason,
Date.now() - startedAt,
riskScan.reason,
);
await auditRunPs1(ctl, config, pathResult.fileName, pathResult.scriptPath, args.length, riskScan, scriptSha256, result, false, false);
return result;
}
if (riskScan.riskLevel === "high" && requireConfirmation !== false) {
const blockedReason = riskScan.reason ?? "High-risk script requires explicit confirmation.";
const result = {
...runPs1BlockedResult(
config,
pathResult.fileName,
pathResult.scriptPath,
"high" as const,
blockedReason,
Date.now() - startedAt,
riskScan.reason,
),
confirmationRequired: true,
};
await auditRunPs1(ctl, config, pathResult.fileName, pathResult.scriptPath, args.length, riskScan, scriptSha256, result, false, true);
return result;
}
const timeout = resolveTimeout(timeoutSeconds, config);
const runResult = await runPowerShellFile(pathResult.scriptPath, args, pluginWorkingDirectory, timeout, config);
const result = {
...runResult,
riskLevel: riskScan.riskLevel,
riskReason: riskScan.reason,
fileName: pathResult.fileName,
scriptPath: pathResult.scriptPath,
};
await auditRunPs1(ctl, config, pathResult.fileName, pathResult.scriptPath, args.length, riskScan, scriptSha256, result, true, false);
return result;
}
function runPs1BlockedResult(
config: PowerShell7Config,
fileName: string,
scriptPath: string,
riskLevel: RiskLevel,
blockedReason: string,
durationMs: number,
riskReason?: string,
) {
return {
ok: false,
exitCode: null,
signal: null,
stdout: "",
stderr: "",
durationMs,
timedOut: false,
truncated: false,
stdoutBytes: 0,
stderrBytes: 0,
stdoutMaxBytes: config.stdoutMaxBytes,
stderrMaxBytes: config.stderrMaxBytes,
stdoutTruncated: false,
stderrTruncated: false,
riskLevel,
riskReason,
fileName,
scriptPath,
blockedReason,
};
}
async function auditRunPs1(
ctl: ToolsProviderController,
config: PowerShell7Config,
fileName: string,
scriptPath: string,
argsCount: number,
riskScan: RiskResult,
scriptSha256: string,
result: {
exitCode: number | null;
signal: string | null;
timedOut: boolean;
durationMs: number;
blockedReason?: string;
},
executed: boolean,
confirmationRequired: boolean,
) {
await audit(ctl, config, {
action: "run_ps1_file",
timestamp: new Date().toISOString(),
fileName,
scriptPath,
argsCount,
riskLevel: riskScan.riskLevel,
riskReason: riskScan.reason,
scriptSha256,
executed,
confirmationRequired,
exitCode: result.exitCode,
signal: result.signal,
timedOut: result.timedOut,
durationMs: result.durationMs,
blockedReason: result.blockedReason,
});
}
async function deletePs1File(ctl: ToolsProviderController, fileName: string) {
const config = loadRuntimeConfig(ctl);
const pluginWorkingDirectory = ctl.getWorkingDirectory();
const pathResult = resolveScriptPath(pluginWorkingDirectory, config, fileName);
if (!pathResult.ok) {
const result = { ok: false, fileName, deleted: false, error: pathResult.error };
await audit(ctl, config, {
action: "delete_ps1_file",
timestamp: new Date().toISOString(),
fileName,
deleted: false,
error: pathResult.error,
});
return result;
}
const existingScript = await validateExistingScriptFile(pathResult.scriptPath, pathResult.scriptsDirectory);
if (!existingScript.ok) {
const error = isMissingFileError(existingScript.error) ? "Script file does not exist." : existingScript.error;
const result = { ok: false, fileName: pathResult.fileName, deleted: false, path: pathResult.scriptPath, error };
await audit(ctl, config, {
action: "delete_ps1_file",
timestamp: new Date().toISOString(),
fileName: pathResult.fileName,
path: pathResult.scriptPath,
deleted: false,
error,
});
return result;
}
try {
await rm(pathResult.scriptPath, { force: false, recursive: false });
const result = { ok: true, fileName: pathResult.fileName, deleted: true, path: pathResult.scriptPath };
await audit(ctl, config, {
action: "delete_ps1_file",
timestamp: new Date().toISOString(),
fileName: pathResult.fileName,
path: pathResult.scriptPath,
deleted: true,
});
return result;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
const result = { ok: false, fileName: pathResult.fileName, deleted: false, path: pathResult.scriptPath, error: message };
await audit(ctl, config, {
action: "delete_ps1_file",
timestamp: new Date().toISOString(),
fileName: pathResult.fileName,
path: pathResult.scriptPath,
deleted: false,
error: message,
});
return result;
}
}
function resolveTimeout(timeoutSeconds: number | undefined, config: PowerShell7Config): number {
return Math.min(timeoutSeconds ?? config.defaultTimeoutSeconds, config.maxTimeoutSeconds, TOOL_TIMEOUT_MAX_SECONDS);
}
function previewCommand(command: string): string {
const singleLine = command.replace(/\s+/g, " ").trim();
return singleLine.length <= 240 ? singleLine : `${singleLine.slice(0, 237)}...`;
}
function isMissingFileError(error: string): boolean {
return /\bENOENT\b|cannot find|no such file/i.test(error);
}
async function audit(ctl: ToolsProviderController, config: PowerShell7Config, entry: Record<string, unknown>) {
try {
await appendAuditLog(ctl.getWorkingDirectory(), config, entry);
} catch {
// Tool execution should not fail solely because the audit file is temporarily unavailable.
}
}