src / permissions.ts
import { existsSync, readFileSync } from "fs";
import { homedir } from "os";
import { join, resolve, sep } from "path";
export const PERMISSIONS_DIR = join(homedir(), ".lmstudio-toolkit");
export const PERMISSIONS_PATH = join(PERMISSIONS_DIR, "permissions.json");
export type PermRule = "read_write" | "read" | "deny";
export type DefaultPerm = "read_write" | "read" | "deny";
export interface CommandExecutorPolicy {
enabled: boolean;
/**
* Allow system/launcher commands (open, say, osascript, pbcopy, …) that have no
* meaningful working directory. When true these bypass the cwd permission check
* and run from the home directory.
*/
allow_system_commands: boolean;
/** Wildcard patterns — * matches any sequence of chars. Empty = allow all (except deny list). */
allow_commands: string[];
/** Wildcard patterns — * matches any sequence of chars. Always checked first. */
deny_commands: string[];
timeout_ms: number;
max_output_chars: number;
}
/**
* Command tier derived from the working directory's path permission:
* deny → no commands at all (change working_directory to proceed)
* read → only READ_ONLY_BINARIES (ls, cat, grep, …)
* read_write → any command not in the deny list; write-impact paths verified
*/
export interface Permissions {
version: number;
/** Applied to any path that has no matching rule. */
default: DefaultPerm;
/** path → rule. More specific (longer) paths take priority. */
rules: Record<string, PermRule>;
command_executor: CommandExecutorPolicy;
/**
* Injected at the top of the first user message in every new chat.
* Empty string = no injection.
*/
custom_system_prompt: string;
}
// Commands that are clearly read-only; path args inside them are NOT write-checked.
const READ_ONLY_BINARIES = new Set([
"ls", "ll", "la", "lsd", "exa", "eza", "dir",
"cat", "head", "tail", "less", "more", "bat", "pg",
"grep", "egrep", "fgrep", "rg", "ag", "ack",
"find", "fd", "locate", "mlocate",
"wc", "sort", "uniq", "cut", "tr", "paste", "join", "comm", "diff", "cmp",
"echo", "printf", "print",
"pwd", "cd",
"file", "stat", "du", "df", "lsof", "lsblk", "lscpu", "lshw",
"which", "type", "whereis", "whatis", "man", "help",
"ps", "top", "htop", "btop", "pgrep", "pstree",
"env", "printenv",
"date", "cal", "uname", "hostname", "whoami", "id",
"tree",
"xxd", "od", "hexdump",
"md5sum", "sha1sum", "sha256sum", "sha512sum", "cksum",
"ping", "traceroute", "nslookup", "dig", "host",
"netstat", "ss", "ip", "ifconfig",
"git", // git commit etc. are handled by the write-path check on working_dirs
]);
// Commands whose non-flag arguments are treated as paths that require write access.
const WRITE_IMPACT_BINARIES = new Set([
"rm", "rmdir", "unlink", "shred", "wipe", "srm",
"mv", "rename",
"cp", "rsync", "scp",
"chmod", "chown", "chgrp", "chattr", "setfacl",
"truncate", "dd", "tee", "install",
"ln",
"touch",
"mkdir",
"sed", // sed -i modifies in-place
"awk", // awk can write via redirection but we check args
"perl", // perl -i modifies files
"python3", "python", "node", // can write anything; checked via path args
]);
/**
* System/launcher commands that have no meaningful working directory.
* When allow_system_commands is true these skip the cwd permission check
* and run from the home directory. They still pass the deny list.
*/
export const SYSTEM_COMMANDS = new Set([
// macOS launchers & UI
"open", "osascript",
// Clipboard
"pbcopy", "pbpaste",
// Audio / TTS
"say", "afplay",
// Notifications
"terminal-notifier", "osascript",
// Screen
"screencapture",
// Spotlight / search
"mdfind", "mdls",
// Keep-awake / power
"caffeinate",
// Misc info (no write impact)
"sw_vers", "system_profiler",
// Linux equivalents
"xdg-open", "notify-send", "paplay", "aplay",
]);
export const DEFAULT_DENY_COMMANDS: string[] = [
// Privilege escalation
"sudo*", "su", "doas", "pkexec", "runas",
// System auth
"passwd*", "chpasswd", "chsh", "chfn",
"usermod", "useradd", "userdel", "groupmod", "groupadd", "groupdel",
"visudo", "sudoedit",
// Shutdown / power
"shutdown*", "reboot*", "halt*", "poweroff*", "init",
"systemctl", // stop/disable can kill services
"launchctl", // macOS equivalent
// Disk / filesystem destruction
"mkfs*", "mke2fs", "mkswap",
"fdisk", "parted", "gdisk", "gparted",
"diskutil", // macOS
"dd", // raw disk write
"dc3dd", "dcfldd",
"shred", "wipe", "srm",
// Process nuking
"kill", "killall", "pkill",
// Network config (can expose machine)
"iptables*", "ip6tables*", "nft", "pfctl",
"ufw",
// Cron / scheduled tasks (persistence)
"crontab", "at", "atq", "atrm",
// History / log tampering
"history",
// macOS SIP / security
"csrutil", "nvram", "spctl",
];
const DEFAULT_PERMS: Permissions = {
version: 2,
default: "deny",
rules: {},
command_executor: {
enabled: false,
allow_system_commands: false,
allow_commands: [],
deny_commands: DEFAULT_DENY_COMMANDS,
timeout_ms: 30000,
max_output_chars: 20000,
},
custom_system_prompt: "",
};
/** Expand leading ~ to the home directory, matching shell behaviour. */
export function expandPath(p: string): string {
if (p === "~") return homedir();
if (p.startsWith("~/") || p.startsWith("~\\")) return join(homedir(), p.slice(2));
return p;
}
export function loadPermissions(): Permissions {
if (!existsSync(PERMISSIONS_PATH)) return structuredClone(DEFAULT_PERMS);
try {
const raw = JSON.parse(readFileSync(PERMISSIONS_PATH, "utf-8"));
return {
version: raw.version ?? 2,
default: raw.default ?? "deny",
rules: typeof raw.rules === "object" && raw.rules !== null ? raw.rules : {},
command_executor: { ...DEFAULT_PERMS.command_executor, ...(raw.command_executor ?? {}) },
custom_system_prompt: typeof raw.custom_system_prompt === "string" ? raw.custom_system_prompt : "",
};
} catch {
return structuredClone(DEFAULT_PERMS);
}
}
/**
* Match a wildcard pattern against a value.
* `*` in the pattern matches any sequence of characters (including empty).
* Examples: "sudo*" matches "sudo", "sudoedit"
* "mk*s" matches "mkfs", "mke2fs"
* "rm" matches only "rm" exactly
*/
export function matchesPattern(pattern: string, value: string): boolean {
if (!pattern.includes("*")) return pattern === value;
// Build a regex: escape special chars, then replace literal \* back to .*
const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
return new RegExp(`^${escaped}$`).test(value);
}
/**
* Resolve the effective permission for `target` by finding the longest-prefix
* matching rule, falling back to `default`.
*/
export function resolveRule(perms: Permissions, target: string): PermRule {
const rTarget = resolve(target);
let bestLen = -1;
let bestRule: PermRule | null = null;
for (const [rulePath, rule] of Object.entries(perms.rules)) {
const rRule = resolve(rulePath);
const normRule = rRule.endsWith(sep) ? rRule : rRule + sep;
if (rTarget === rRule || rTarget.startsWith(normRule)) {
if (rRule.length > bestLen) {
bestLen = rRule.length;
bestRule = rule;
}
}
}
return bestRule ?? perms.default;
}
export function canRead(perms: Permissions, target: string): boolean {
const rule = resolveRule(perms, target);
return rule === "read" || rule === "read_write";
}
export function canWrite(perms: Permissions, target: string): boolean {
return resolveRule(perms, target) === "read_write";
}
export function checkRead(perms: Permissions, target: string): string | null {
if (canRead(perms, target)) return null;
const rule = resolveRule(perms, target);
if (rule === "deny") return `Access denied: '${resolve(target)}' is in a denied path.`;
return `Access denied: '${resolve(target)}' has no read permission.`;
}
export function checkWrite(perms: Permissions, target: string): string | null {
if (canWrite(perms, target)) return null;
const rule = resolveRule(perms, target);
if (rule === "deny") return `Access denied: '${resolve(target)}' is in a denied path.`;
if (rule === "read") return `Access denied: '${resolve(target)}' is read-only.`;
return `Access denied: '${resolve(target)}' has no write permission.`;
}
/**
* Validate and gate a command using the unified permission tier of `cwd`.
*
* Tier mapping (mirrors file-access rules):
* deny → no commands; model must pass a different working_directory
* read → READ_ONLY_BINARIES only (ls, cat, grep, find, …)
* read_write → any command not in the deny/allow lists;
* write-impact path args are additionally write-checked
*
* Returns null when allowed, or a human-readable error string.
*/
export function checkCommand(perms: Permissions, command: string, cwd: string): string | null {
const policy = perms.command_executor;
if (!policy.enabled) return "Command executor is disabled. Enable it in the control panel.";
const tokens = command.trim().split(/\s+/);
const binary = (tokens[0] ?? "").split(/[\\/]/).pop() ?? "";
// ── system/launcher commands (open, say, osascript, …) ───────────────────
// These have no meaningful cwd — skip the path permission check entirely
// and run from the home directory. Deny list still applies.
if (policy.allow_system_commands && SYSTEM_COMMANDS.has(binary)) {
for (const pattern of policy.deny_commands) {
if (matchesPattern(pattern, binary)) {
return `Command '${binary}' matches deny pattern '${pattern}'.`;
}
}
if (policy.allow_commands.length > 0 && !policy.allow_commands.some((p) => matchesPattern(p, binary))) {
return `Command '${binary}' is not in the allow list.`;
}
return null; // system command allowed — cwd will default to homedir() in the tool
}
const rCwd = cwd ? resolve(expandPath(cwd)) : "";
if (!rCwd) {
return "No working directory provided. Pass working_directory or enable system commands in the control panel.";
}
const cwdTier = resolveRule(perms, rCwd);
// ── deny tier ────────────────────────────────────────────────────────────
if (cwdTier === "deny") {
return (
`Working directory '${rCwd}' is denied. ` +
`Pass a different working_directory that has read or read_write permission.`
);
}
// Global deny list (wildcards) — always checked regardless of tier
for (const pattern of policy.deny_commands) {
if (matchesPattern(pattern, binary)) {
return `Command '${binary}' matches deny pattern '${pattern}'.`;
}
}
// ── read tier: only read-only commands ──────────────────────────────────
if (cwdTier === "read") {
if (!READ_ONLY_BINARIES.has(binary)) {
return (
`'${rCwd}' has read-only permission. ` +
`'${binary}' is not a read-only command. ` +
`Grant read_write permission to this path to run write commands here.`
);
}
// Read commands also get allow-list filtered if set
if (policy.allow_commands.length > 0 && !policy.allow_commands.some((p) => matchesPattern(p, binary))) {
return `Command '${binary}' is not in the allow list.`;
}
return null; // read-only command in read zone — allowed
}
// ── read_write tier: anything not denied/restricted ─────────────────────
if (policy.allow_commands.length > 0) {
if (!policy.allow_commands.some((p) => matchesPattern(p, binary))) {
return `Command '${binary}' is not in the allow list.`;
}
}
// Write-impact commands: verify path args have write access
if (WRITE_IMPACT_BINARIES.has(binary)) {
let pastDashDash = false;
for (let i = 1; i < tokens.length; i++) {
const t = tokens[i];
if (t === "--") { pastDashDash = true; continue; }
if (!pastDashDash && t.startsWith("-")) continue;
if (!t) continue;
const expanded = expandPath(t);
const full = expanded.startsWith("/") ? expanded : join(rCwd, expanded);
const denied = checkWrite(perms, resolve(full));
if (denied) {
return (
`Command '${binary}' targets '${resolve(full)}' which is not write-accessible. ` +
`Grant read_write permission to that path in the control panel.`
);
}
}
}
return null;
}
// Keep as alias so toolsProvider doesn't need updating
export function checkCommandPaths(): null { return null; }