src / shellRun.ts
import { promises as fs } from "fs";
import * as os from "os";
import * as path from "path";
import type { RunOptions, RunResult } from "./exec";
import { expandHome, hardDeny } from "./hardDeny";
import { MANUAL, refusalMessage, route } from "./route";
import type { Assessor, Settings } from "./types";
export interface ShellRunInput {
command: string;
cwd?: string;
timeoutSec?: number;
}
export interface ShellRunDeps {
defaultCwd: string;
settings: Settings;
assess: Assessor;
run: (command: string, options: RunOptions) => Promise<RunResult>;
status?: (text: string) => void;
signal?: AbortSignal;
home?: string;
}
/** The whole guarded pipeline: validate, hard-deny, jev verdict, execute, format. Always resolves to a string for the model. */
export async function shellRun(input: ShellRunInput, deps: ShellRunDeps): Promise<string> {
const command = input.command?.trim();
if (!command) return "Error: command is required";
const home = deps.home ?? os.homedir();
const cwd = path.resolve(deps.defaultCwd, expandHome(input.cwd ?? deps.defaultCwd, home));
const stat = await fs.stat(cwd).catch(() => null);
if (!stat?.isDirectory()) return `Error: not a directory: ${cwd}`;
const denied = hardDeny(command, { cwd, home });
if (denied) return `Blocked: ${denied}. This command is never run by this plugin. ${MANUAL}`;
deps.status?.("Checking command safety…");
let verdict;
try {
verdict = route(await deps.assess({ command, cwd }, deps.signal), deps.settings);
} catch (error) {
const reason = error instanceof Error ? error.message : String(error);
return `Blocked: safety check unavailable (${reason}); the command was not run.`;
}
if (verdict.kind !== "pass") return refusalMessage(verdict);
deps.status?.("Running command…");
const { settings } = deps;
const timeoutSec = Math.max(1, Math.min(input.timeoutSec ?? settings.defaultTimeoutSec, settings.maxTimeoutSec));
try {
const result = await deps.run(command, {
cwd,
timeoutMs: timeoutSec * 1000,
maxChars: settings.maxOutputChars,
signal: deps.signal,
});
return formatResult(result);
} catch (error) {
return `Error: could not start command: ${error instanceof Error ? error.message : String(error)}`;
}
}
function formatResult(result: RunResult): string {
if (result.aborted) return "Error: command aborted";
if (result.timedOut) return "Error: command timed out";
let output = result.output.trim() || "(no output)";
if (result.truncated) output += "\n[output truncated]";
return result.exitCode === 0 ? output : `Error (exit ${result.exitCode ?? "signal"}): ${output}`;
}
src / shellRun.ts
import { promises as fs } from "fs";
import * as os from "os";
import * as path from "path";
import type { RunOptions, RunResult } from "./exec";
import { expandHome, hardDeny } from "./hardDeny";
import { MANUAL, refusalMessage, route } from "./route";
import type { Assessor, Settings } from "./types";
export interface ShellRunInput {
command: string;
cwd?: string;
timeoutSec?: number;
}
export interface ShellRunDeps {
defaultCwd: string;
settings: Settings;
assess: Assessor;
run: (command: string, options: RunOptions) => Promise<RunResult>;
status?: (text: string) => void;
signal?: AbortSignal;
home?: string;
}
/** The whole guarded pipeline: validate, hard-deny, jev verdict, execute, format. Always resolves to a string for the model. */
export async function shellRun(input: ShellRunInput, deps: ShellRunDeps): Promise<string> {
const command = input.command?.trim();
if (!command) return "Error: command is required";
const home = deps.home ?? os.homedir();
const cwd = path.resolve(deps.defaultCwd, expandHome(input.cwd ?? deps.defaultCwd, home));
const stat = await fs.stat(cwd).catch(() => null);
if (!stat?.isDirectory()) return `Error: not a directory: ${cwd}`;
const denied = hardDeny(command, { cwd, home });
if (denied) return `Blocked: ${denied}. This command is never run by this plugin. ${MANUAL}`;
deps.status?.("Checking command safety…");
let verdict;
try {
verdict = route(await deps.assess({ command, cwd }, deps.signal), deps.settings);
} catch (error) {
const reason = error instanceof Error ? error.message : String(error);
return `Blocked: safety check unavailable (${reason}); the command was not run.`;
}
if (verdict.kind !== "pass") return refusalMessage(verdict);
deps.status?.("Running command…");
const { settings } = deps;
const timeoutSec = Math.max(1, Math.min(input.timeoutSec ?? settings.defaultTimeoutSec, settings.maxTimeoutSec));
try {
const result = await deps.run(command, {
cwd,
timeoutMs: timeoutSec * 1000,
maxChars: settings.maxOutputChars,
signal: deps.signal,
});
return formatResult(result);
} catch (error) {
return `Error: could not start command: ${error instanceof Error ? error.message : String(error)}`;
}
}
function formatResult(result: RunResult): string {
if (result.aborted) return "Error: command aborted";
if (result.timedOut) return "Error: command timed out";
let output = result.output.trim() || "(no output)";
if (result.truncated) output += "\n[output truncated]";
return result.exitCode === 0 ? output : `Error (exit ${result.exitCode ?? "signal"}): ${output}`;
}