src / hardDeny.ts
import * as os from "os";
import * as path from "path";
export interface HardDenyOptions {
cwd: string;
/** Defaults to the current user's home directory. */
home?: string;
}
interface Resolved {
cwd: string;
home: string;
}
const MAX_DEPTH = 5;
const SHELLS = new Set(["sh", "bash", "zsh", "dash", "ksh", "fish"]);
const WRAPPERS = new Set([
"env", "command", "exec", "nohup", "time", "nice", "xargs", "builtin", "timeout", "stdbuf", "caffeinate",
]);
const CONTROL = new Set(["{", "}", "!", "if", "then", "else", "elif", "do", "while", "until", "fi", "done", "for"]);
const ALWAYS_DENIED = new Set(["sudo", "su", "doas", "shutdown", "reboot", "halt", "poweroff"]);
const PROTECTED_ROOTS = ["/etc", "/private/etc", "/System", "/Library", "/boot", "/usr", "/bin", "/sbin"];
const TOP_LEVEL = ["/Users", "/Applications", "/Volumes", "/home", "/var", "/opt", "/private", "/tmp", "/root"];
const RECURSIVE_TARGET_PROGRAMS = new Set(["rm", "chmod", "chown", "chgrp"]);
const WRITES_ALL_ARGS = new Set(["rm", "rmdir", "mv", "tee", "truncate", "chmod", "chown", "chgrp", "shred", "unlink"]);
const WRITES_LAST_ARG = new Set(["cp", "ln", "install"]);
const ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/;
const RAW_PATTERNS: Array<[RegExp, string]> = [
[/:\s*\(\s*\)\s*\{[^}]*:\s*\|\s*:/, "fork bomb"],
[
/\b(?:curl|wget|fetch)\b[^|;]*\|\s*(?:sudo\s+)?(?:env\s+)?(?:\S*\/)?(?:sh|bash|zsh|dash|ksh|fish)\b/,
"piping a download into a shell",
],
[
/\b(?:sh|bash|zsh|dash|ksh|fish|eval)\b[^;|]*(?:<\(|\$\(|`)\s*(?:curl|wget|fetch)\b/,
"running downloaded code in a shell",
],
];
/**
* Deterministic backstop that runs before jev and can never be overridden.
* Returns a reason string when the command must be refused, otherwise null.
* It is best-effort pattern matching, not a sandbox: jev judges everything it lets through.
*/
export function hardDeny(command: string, options: HardDenyOptions): string | null {
return denyInner(command, { cwd: options.cwd, home: options.home ?? os.homedir() }, 0);
}
export function expandHome(arg: string, home: string): string {
if (arg === "~" || arg.startsWith("~/")) return home + arg.slice(1);
for (const variable of ["$HOME", "${HOME}"]) {
if (arg === variable || arg.startsWith(variable + "/")) return home + arg.slice(variable.length);
}
return arg;
}
function denyInner(command: string, opts: Resolved, depth: number): string | null {
if (depth > MAX_DEPTH) return "commands are nested too deeply to inspect";
for (const [pattern, reason] of RAW_PATTERNS) {
if (pattern.test(command)) return reason;
}
for (const words of scan(command)) {
const reason = checkCommand(words, opts, depth);
if (reason) return reason;
}
return null;
}
/** Finds a `$(...)`, backtick, `<(...)` or `>(...)` starting at index i. `end` is the index of the last character. */
function readSubstitution(input: string, i: number): { inner: string; end: number } | null {
const c = input[i];
if (c === "`") {
const close = input.indexOf("`", i + 1);
return close === -1
? { inner: input.slice(i + 1), end: input.length - 1 }
: { inner: input.slice(i + 1, close), end: close };
}
if ((c === "$" || c === "<" || c === ">") && input[i + 1] === "(") {
let depth = 1;
let j = i + 2;
while (j < input.length && depth > 0) {
if (input[j] === "(") depth++;
else if (input[j] === ")") depth--;
j++;
}
return depth === 0
? { inner: input.slice(i + 2, j - 1), end: j - 1 }
: { inner: input.slice(i + 2), end: input.length - 1 };
}
return null;
}
/** Splits a command line into simple commands (each a list of words), descending into substitutions. */
function scan(input: string): string[][] {
const commands: string[][] = [];
let words: string[] = [];
let cur = "";
let inWord = false;
let quote: "'" | '"' | null = null;
const endWord = () => {
if (inWord) {
words.push(cur);
cur = "";
inWord = false;
}
};
const endCommand = () => {
endWord();
if (words.length) commands.push(words);
words = [];
};
for (let i = 0; i < input.length; i++) {
const c = input[i];
if (quote === "'") {
if (c === "'") quote = null;
else cur += c;
continue;
}
if (c === "\\" && i + 1 < input.length) {
cur += input[++i];
inWord = true;
continue;
}
const canSubstitute = quote === '"' ? c === "`" || c === "$" : true;
const sub = canSubstitute ? readSubstitution(input, i) : null;
if (sub) {
commands.push(...scan(sub.inner));
cur += "$(…)";
inWord = true;
i = sub.end;
continue;
}
if (quote === '"') {
if (c === '"') quote = null;
else cur += c;
continue;
}
if (c === "'" || c === '"') {
quote = c;
inWord = true;
} else if (c === "\n" || c === "\r" || "|&;()".includes(c)) {
endCommand();
} else if (/\s/.test(c)) {
endWord();
} else {
cur += c;
inWord = true;
}
}
endCommand();
return commands;
}
/** Drops leading control keywords, VAR=value assignments and wrapper programs (env, nohup, xargs, ...). */
function normalize(raw: string[]): string[] {
const words = raw.slice();
for (;;) {
while (words.length && (CONTROL.has(words[0]) || ASSIGNMENT.test(words[0]))) words.shift();
if (!words.length) return words;
const program = path.basename(words[0]);
if (!WRAPPERS.has(program)) return words;
words.shift();
while (
words.length &&
(words[0].startsWith("-") ||
ASSIGNMENT.test(words[0]) ||
((program === "timeout" || program === "nice") && /^\d/.test(words[0])))
) {
words.shift();
}
}
}
function isProtected(resolved: string, home: string): boolean {
const roots = [...PROTECTED_ROOTS, path.join(home, ".ssh")];
return roots.some(root => resolved === root || resolved.startsWith(root + "/"));
}
function writesProtected(target: string, opts: Resolved): boolean {
const resolved = path.resolve(opts.cwd, expandHome(target, opts.home));
return isProtected(resolved, opts.home) || /^\/dev\/(?:sd|disk|rdisk|nvme)/.test(resolved);
}
function isBroadTarget(arg: string, opts: Resolved): boolean {
const expanded = expandHome(arg, opts.home);
if (["*", "./*", ".", "..", "./", "../"].includes(expanded)) return true;
const base = expanded.endsWith("/*") ? expanded.slice(0, -2) || "/" : expanded;
const resolved = path.resolve(opts.cwd, base);
return (
resolved === "/" ||
resolved === opts.home ||
opts.home.startsWith(resolved + "/") ||
isProtected(resolved, opts.home) ||
TOP_LEVEL.includes(resolved)
);
}
function isRecursive(args: string[]): boolean {
return args.some(a => a === "--recursive" || /^-[a-zA-Z]*[rR][a-zA-Z]*$/.test(a));
}
function checkCommand(raw: string[], opts: Resolved, depth: number): string | null {
for (let i = 0; i < raw.length; i++) {
const redirect = /^\d*>>?\|?(.*)$/.exec(raw[i]);
if (!redirect) continue;
const target = redirect[1] || raw[i + 1];
if (target && writesProtected(target, opts)) return `it writes to a protected location (${target})`;
}
const words = normalize(raw);
if (!words.length) return null;
const program = path.basename(words[0]);
const args = words.slice(1);
const targets = args.filter(a => !a.startsWith("-"));
if (ALWAYS_DENIED.has(program)) return `'${program}' is not allowed`;
if (program.startsWith("mkfs")) return "formatting filesystems is not allowed";
if (SHELLS.has(program)) {
const flag = args.findIndex(a => /^-[a-zA-Z]*c$/.test(a));
if (flag !== -1 && args[flag + 1] !== undefined) return denyInner(args[flag + 1], opts, depth + 1);
}
if (program === "eval") return denyInner(args.join(" "), opts, depth + 1);
if (program === "dd") {
for (const arg of args) {
if (!arg.startsWith("of=")) continue;
const target = arg.slice(3);
if (/^\/dev\/(?!(?:null|zero|stdout|stderr|tty)$)/.test(target) || writesProtected(target, opts)) {
return "dd writing to a device or protected location is not allowed";
}
}
}
if (RECURSIVE_TARGET_PROGRAMS.has(program) && isRecursive(args) && targets.some(t => isBroadTarget(t, opts))) {
return `recursive '${program}' on a broad or system path is not allowed`;
}
const written = WRITES_ALL_ARGS.has(program) ? targets : WRITES_LAST_ARG.has(program) ? targets.slice(-1) : [];
for (const target of written) {
if (writesProtected(target, opts)) return `'${program}' would modify a protected location (${target})`;
}
return null;
}
src / hardDeny.ts
import * as os from "os";
import * as path from "path";
export interface HardDenyOptions {
cwd: string;
/** Defaults to the current user's home directory. */
home?: string;
}
interface Resolved {
cwd: string;
home: string;
}
const MAX_DEPTH = 5;
const SHELLS = new Set(["sh", "bash", "zsh", "dash", "ksh", "fish"]);
const WRAPPERS = new Set([
"env", "command", "exec", "nohup", "time", "nice", "xargs", "builtin", "timeout", "stdbuf", "caffeinate",
]);
const CONTROL = new Set(["{", "}", "!", "if", "then", "else", "elif", "do", "while", "until", "fi", "done", "for"]);
const ALWAYS_DENIED = new Set(["sudo", "su", "doas", "shutdown", "reboot", "halt", "poweroff"]);
const PROTECTED_ROOTS = ["/etc", "/private/etc", "/System", "/Library", "/boot", "/usr", "/bin", "/sbin"];
const TOP_LEVEL = ["/Users", "/Applications", "/Volumes", "/home", "/var", "/opt", "/private", "/tmp", "/root"];
const RECURSIVE_TARGET_PROGRAMS = new Set(["rm", "chmod", "chown", "chgrp"]);
const WRITES_ALL_ARGS = new Set(["rm", "rmdir", "mv", "tee", "truncate", "chmod", "chown", "chgrp", "shred", "unlink"]);
const WRITES_LAST_ARG = new Set(["cp", "ln", "install"]);
const ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/;
const RAW_PATTERNS: Array<[RegExp, string]> = [
[/:\s*\(\s*\)\s*\{[^}]*:\s*\|\s*:/, "fork bomb"],
[
/\b(?:curl|wget|fetch)\b[^|;]*\|\s*(?:sudo\s+)?(?:env\s+)?(?:\S*\/)?(?:sh|bash|zsh|dash|ksh|fish)\b/,
"piping a download into a shell",
],
[
/\b(?:sh|bash|zsh|dash|ksh|fish|eval)\b[^;|]*(?:<\(|\$\(|`)\s*(?:curl|wget|fetch)\b/,
"running downloaded code in a shell",
],
];
/**
* Deterministic backstop that runs before jev and can never be overridden.
* Returns a reason string when the command must be refused, otherwise null.
* It is best-effort pattern matching, not a sandbox: jev judges everything it lets through.
*/
export function hardDeny(command: string, options: HardDenyOptions): string | null {
return denyInner(command, { cwd: options.cwd, home: options.home ?? os.homedir() }, 0);
}
export function expandHome(arg: string, home: string): string {
if (arg === "~" || arg.startsWith("~/")) return home + arg.slice(1);
for (const variable of ["$HOME", "${HOME}"]) {
if (arg === variable || arg.startsWith(variable + "/")) return home + arg.slice(variable.length);
}
return arg;
}
function denyInner(command: string, opts: Resolved, depth: number): string | null {
if (depth > MAX_DEPTH) return "commands are nested too deeply to inspect";
for (const [pattern, reason] of RAW_PATTERNS) {
if (pattern.test(command)) return reason;
}
for (const words of scan(command)) {
const reason = checkCommand(words, opts, depth);
if (reason) return reason;
}
return null;
}
/** Finds a `$(...)`, backtick, `<(...)` or `>(...)` starting at index i. `end` is the index of the last character. */
function readSubstitution(input: string, i: number): { inner: string; end: number } | null {
const c = input[i];
if (c === "`") {
const close = input.indexOf("`", i + 1);
return close === -1
? { inner: input.slice(i + 1), end: input.length - 1 }
: { inner: input.slice(i + 1, close), end: close };
}
if ((c === "$" || c === "<" || c === ">") && input[i + 1] === "(") {
let depth = 1;
let j = i + 2;
while (j < input.length && depth > 0) {
if (input[j] === "(") depth++;
else if (input[j] === ")") depth--;
j++;
}
return depth === 0
? { inner: input.slice(i + 2, j - 1), end: j - 1 }
: { inner: input.slice(i + 2), end: input.length - 1 };
}
return null;
}
/** Splits a command line into simple commands (each a list of words), descending into substitutions. */
function scan(input: string): string[][] {
const commands: string[][] = [];
let words: string[] = [];
let cur = "";
let inWord = false;
let quote: "'" | '"' | null = null;
const endWord = () => {
if (inWord) {
words.push(cur);
cur = "";
inWord = false;
}
};
const endCommand = () => {
endWord();
if (words.length) commands.push(words);
words = [];
};
for (let i = 0; i < input.length; i++) {
const c = input[i];
if (quote === "'") {
if (c === "'") quote = null;
else cur += c;
continue;
}
if (c === "\\" && i + 1 < input.length) {
cur += input[++i];
inWord = true;
continue;
}
const canSubstitute = quote === '"' ? c === "`" || c === "$" : true;
const sub = canSubstitute ? readSubstitution(input, i) : null;
if (sub) {
commands.push(...scan(sub.inner));
cur += "$(…)";
inWord = true;
i = sub.end;
continue;
}
if (quote === '"') {
if (c === '"') quote = null;
else cur += c;
continue;
}
if (c === "'" || c === '"') {
quote = c;
inWord = true;
} else if (c === "\n" || c === "\r" || "|&;()".includes(c)) {
endCommand();
} else if (/\s/.test(c)) {
endWord();
} else {
cur += c;
inWord = true;
}
}
endCommand();
return commands;
}
/** Drops leading control keywords, VAR=value assignments and wrapper programs (env, nohup, xargs, ...). */
function normalize(raw: string[]): string[] {
const words = raw.slice();
for (;;) {
while (words.length && (CONTROL.has(words[0]) || ASSIGNMENT.test(words[0]))) words.shift();
if (!words.length) return words;
const program = path.basename(words[0]);
if (!WRAPPERS.has(program)) return words;
words.shift();
while (
words.length &&
(words[0].startsWith("-") ||
ASSIGNMENT.test(words[0]) ||
((program === "timeout" || program === "nice") && /^\d/.test(words[0])))
) {
words.shift();
}
}
}
function isProtected(resolved: string, home: string): boolean {
const roots = [...PROTECTED_ROOTS, path.join(home, ".ssh")];
return roots.some(root => resolved === root || resolved.startsWith(root + "/"));
}
function writesProtected(target: string, opts: Resolved): boolean {
const resolved = path.resolve(opts.cwd, expandHome(target, opts.home));
return isProtected(resolved, opts.home) || /^\/dev\/(?:sd|disk|rdisk|nvme)/.test(resolved);
}
function isBroadTarget(arg: string, opts: Resolved): boolean {
const expanded = expandHome(arg, opts.home);
if (["*", "./*", ".", "..", "./", "../"].includes(expanded)) return true;
const base = expanded.endsWith("/*") ? expanded.slice(0, -2) || "/" : expanded;
const resolved = path.resolve(opts.cwd, base);
return (
resolved === "/" ||
resolved === opts.home ||
opts.home.startsWith(resolved + "/") ||
isProtected(resolved, opts.home) ||
TOP_LEVEL.includes(resolved)
);
}
function isRecursive(args: string[]): boolean {
return args.some(a => a === "--recursive" || /^-[a-zA-Z]*[rR][a-zA-Z]*$/.test(a));
}
function checkCommand(raw: string[], opts: Resolved, depth: number): string | null {
for (let i = 0; i < raw.length; i++) {
const redirect = /^\d*>>?\|?(.*)$/.exec(raw[i]);
if (!redirect) continue;
const target = redirect[1] || raw[i + 1];
if (target && writesProtected(target, opts)) return `it writes to a protected location (${target})`;
}
const words = normalize(raw);
if (!words.length) return null;
const program = path.basename(words[0]);
const args = words.slice(1);
const targets = args.filter(a => !a.startsWith("-"));
if (ALWAYS_DENIED.has(program)) return `'${program}' is not allowed`;
if (program.startsWith("mkfs")) return "formatting filesystems is not allowed";
if (SHELLS.has(program)) {
const flag = args.findIndex(a => /^-[a-zA-Z]*c$/.test(a));
if (flag !== -1 && args[flag + 1] !== undefined) return denyInner(args[flag + 1], opts, depth + 1);
}
if (program === "eval") return denyInner(args.join(" "), opts, depth + 1);
if (program === "dd") {
for (const arg of args) {
if (!arg.startsWith("of=")) continue;
const target = arg.slice(3);
if (/^\/dev\/(?!(?:null|zero|stdout|stderr|tty)$)/.test(target) || writesProtected(target, opts)) {
return "dd writing to a device or protected location is not allowed";
}
}
}
if (RECURSIVE_TARGET_PROGRAMS.has(program) && isRecursive(args) && targets.some(t => isBroadTarget(t, opts))) {
return `recursive '${program}' on a broad or system path is not allowed`;
}
const written = WRITES_ALL_ARGS.has(program) ? targets : WRITES_LAST_ARG.has(program) ? targets.slice(-1) : [];
for (const target of written) {
if (writesProtected(target, opts)) return `'${program}' would modify a protected location (${target})`;
}
return null;
}