261 lines
6.1 KiB
TypeScript
261 lines
6.1 KiB
TypeScript
const ALWAYS_SAFE_COMMANDS = new Set([
|
|
"cat",
|
|
"head",
|
|
"tail",
|
|
"less",
|
|
"more",
|
|
"grep",
|
|
"rg",
|
|
"ag",
|
|
"ack",
|
|
"fgrep",
|
|
"egrep",
|
|
"ls",
|
|
"tree",
|
|
"file",
|
|
"stat",
|
|
"du",
|
|
"df",
|
|
"wc",
|
|
"diff",
|
|
"cmp",
|
|
"comm",
|
|
"cut",
|
|
"tr",
|
|
"nl",
|
|
"column",
|
|
"fold",
|
|
"pwd",
|
|
"whoami",
|
|
"hostname",
|
|
"date",
|
|
"uname",
|
|
"uptime",
|
|
"id",
|
|
"echo",
|
|
"printf",
|
|
"env",
|
|
"printenv",
|
|
"which",
|
|
"whereis",
|
|
"type",
|
|
"basename",
|
|
"dirname",
|
|
"realpath",
|
|
"readlink",
|
|
"jq",
|
|
"yq",
|
|
"strings",
|
|
"xxd",
|
|
"hexdump",
|
|
"cd",
|
|
]);
|
|
|
|
const SAFE_GIT_SUBCOMMANDS = new Set([
|
|
"status",
|
|
"diff",
|
|
"log",
|
|
"show",
|
|
"branch",
|
|
"tag",
|
|
"remote",
|
|
]);
|
|
|
|
// gh CLI read-only commands: category -> allowed actions
|
|
// null means any action is allowed for that category
|
|
const SAFE_GH_COMMANDS: Record<string, Set<string> | null> = {
|
|
pr: new Set(["list", "status", "checks", "diff", "view"]),
|
|
issue: new Set(["list", "status", "view"]),
|
|
repo: new Set(["list", "view", "gitignore", "license"]),
|
|
run: new Set(["list", "view", "watch", "download"]),
|
|
release: new Set(["list", "view", "download"]),
|
|
search: null, // all search subcommands are read-only
|
|
api: null, // usually GET requests for exploration
|
|
status: null, // top-level command, no action needed
|
|
};
|
|
|
|
/**
|
|
* Read-only bundled skill scripts that are safe to execute without approval.
|
|
* Only scripts from the bundled searching-messages skill are allowed.
|
|
* We check for specific path patterns to prevent malicious scripts in user directories.
|
|
*/
|
|
const BUNDLED_READ_ONLY_SCRIPTS = [
|
|
// Bundled skills path (production): /path/to/skills/searching-messages/scripts/...
|
|
"/skills/searching-messages/scripts/search-messages.ts",
|
|
"/skills/searching-messages/scripts/get-messages.ts",
|
|
// Source path (development): /path/to/src/skills/builtin/searching-messages/scripts/...
|
|
"/skills/builtin/searching-messages/scripts/search-messages.ts",
|
|
"/skills/builtin/searching-messages/scripts/get-messages.ts",
|
|
];
|
|
|
|
/**
|
|
* Check if a script path is a known read-only bundled skill script
|
|
*/
|
|
function isReadOnlySkillScript(scriptPath: string): boolean {
|
|
// Normalize path separators for cross-platform
|
|
const normalized = scriptPath.replace(/\\/g, "/");
|
|
return BUNDLED_READ_ONLY_SCRIPTS.some((pattern) =>
|
|
normalized.endsWith(pattern),
|
|
);
|
|
}
|
|
|
|
// Operators that are always dangerous (file redirects, command substitution)
|
|
// Note: &&, ||, ; are handled by splitting and checking each segment
|
|
const DANGEROUS_OPERATOR_PATTERN = /(>>|>|\$\(|`)/;
|
|
|
|
export function isReadOnlyShellCommand(
|
|
command: string | string[] | undefined | null,
|
|
): boolean {
|
|
if (!command) {
|
|
return false;
|
|
}
|
|
|
|
if (Array.isArray(command)) {
|
|
if (command.length === 0) {
|
|
return false;
|
|
}
|
|
const joined = command.join(" ");
|
|
const [executable, ...rest] = command;
|
|
if (executable && isShellExecutor(executable)) {
|
|
const nested = extractDashCArgument(rest);
|
|
if (!nested) {
|
|
return false;
|
|
}
|
|
return isReadOnlyShellCommand(nested);
|
|
}
|
|
return isReadOnlyShellCommand(joined);
|
|
}
|
|
|
|
const trimmed = command.trim();
|
|
if (!trimmed) {
|
|
return false;
|
|
}
|
|
|
|
if (DANGEROUS_OPERATOR_PATTERN.test(trimmed)) {
|
|
return false;
|
|
}
|
|
|
|
// Split on command separators: |, &&, ||, ;
|
|
// Each segment must be safe for the whole command to be safe
|
|
const segments = trimmed
|
|
.split(/\||&&|\|\||;/)
|
|
.map((segment) => segment.trim())
|
|
.filter(Boolean);
|
|
|
|
if (segments.length === 0) {
|
|
return false;
|
|
}
|
|
|
|
for (const segment of segments) {
|
|
if (!isSafeSegment(segment)) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
function isSafeSegment(segment: string): boolean {
|
|
const tokens = tokenize(segment);
|
|
if (tokens.length === 0) {
|
|
return false;
|
|
}
|
|
|
|
const command = tokens[0];
|
|
if (!command) {
|
|
return false;
|
|
}
|
|
if (isShellExecutor(command)) {
|
|
const nested = extractDashCArgument(tokens.slice(1));
|
|
if (!nested) {
|
|
return false;
|
|
}
|
|
return isReadOnlyShellCommand(stripQuotes(nested));
|
|
}
|
|
|
|
if (!ALWAYS_SAFE_COMMANDS.has(command)) {
|
|
if (command === "git") {
|
|
const subcommand = tokens[1];
|
|
if (!subcommand) {
|
|
return false;
|
|
}
|
|
return SAFE_GIT_SUBCOMMANDS.has(subcommand);
|
|
}
|
|
if (command === "gh") {
|
|
const category = tokens[1];
|
|
if (!category) {
|
|
return false;
|
|
}
|
|
if (!(category in SAFE_GH_COMMANDS)) {
|
|
return false;
|
|
}
|
|
const allowedActions = SAFE_GH_COMMANDS[category];
|
|
// null means any action is allowed (e.g., gh search, gh api, gh status)
|
|
if (allowedActions === null) {
|
|
return true;
|
|
}
|
|
// undefined means category not in map (shouldn't happen after 'in' check)
|
|
if (allowedActions === undefined) {
|
|
return false;
|
|
}
|
|
const action = tokens[2];
|
|
if (!action) {
|
|
return false;
|
|
}
|
|
return allowedActions.has(action);
|
|
}
|
|
if (command === "find") {
|
|
return !/-delete|\s-exec\b/.test(segment);
|
|
}
|
|
if (command === "sort") {
|
|
return !/\s-o\b/.test(segment);
|
|
}
|
|
// Allow npx tsx for read-only skill scripts (searching-messages)
|
|
if (command === "npx" && tokens[1] === "tsx") {
|
|
const scriptPath = tokens[2];
|
|
if (scriptPath && isReadOnlySkillScript(scriptPath)) {
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
function isShellExecutor(command: string): boolean {
|
|
return command === "bash" || command === "sh";
|
|
}
|
|
|
|
function tokenize(segment: string): string[] {
|
|
const matches = segment.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g);
|
|
if (!matches) {
|
|
return [];
|
|
}
|
|
return matches.map((token) => stripQuotes(token));
|
|
}
|
|
|
|
function stripQuotes(value: string): string {
|
|
if (
|
|
(value.startsWith('"') && value.endsWith('"')) ||
|
|
(value.startsWith("'") && value.endsWith("'"))
|
|
) {
|
|
return value.slice(1, -1);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function extractDashCArgument(tokens: string[]): string | undefined {
|
|
for (let i = 0; i < tokens.length; i += 1) {
|
|
const token = tokens[i];
|
|
if (!token) {
|
|
continue;
|
|
}
|
|
if (token === "-c" || token === "-lc" || /^-[a-zA-Z]*c$/.test(token)) {
|
|
return tokens[i + 1];
|
|
}
|
|
}
|
|
return undefined;
|
|
}
|