v1.2.0: guard allows computation-only inline one-liners; blocks --eval/--input-type, child_process, string-keyed access, stdin-piped scripts, deno/bun/php
This commit is contained in:
1 parent
583aa9a10b
commit
bcb7468686
3 files changed
+199
-2
No files matched your search
@@ -48,7 +48,9 @@ const SHELL_RULES = [
|
||||
[/(^|\s)(tee)\s+(-a\s+)?[^-\s]/, 'writing files with tee is not allowed — use the edit tool (or write for new files).'],
|
||||
[/(^|[^0-9&<>])>>?\s*(?!&|\/dev\/(null|stderr|stdout))[^\s|&;]/, 'shell redirection into files is not allowed — use the edit tool (or write for new files).'],
|
||||
[/<<-?\s*['"]?\w+/, 'heredocs are not allowed for writing files — use the edit tool (or write for new files).'],
|
||||
[/(^|\s)(python3?|node|ruby|perl)\s+-[ce]\s/, 'inline interpreter scripts are not allowed for file reads/writes — use read/grep/edit.'],
|
||||
// Inline interpreter scripts (node -e/-p/--eval/--print, python -c, ruby/perl -e, deno eval…)
|
||||
// are checked separately by inlineScriptViolation(): harmless computation passes, anything that
|
||||
// can reach the filesystem or spawn a process is denied.
|
||||
[/(^|\s)(truncate|dd)\s/, 'truncate/dd are not allowed — use the edit tool.'],
|
||||
// A pipe consumer (`… | head`) only trims command output and stays allowed: segments start after the pipe.
|
||||
[/^(cat|head|tail|less|more|bat|nl)\s+(?:-\S+\s+)*[^-\s]/, 'reading files through the shell is not allowed — use the read tool (paged, 200 lines per call).', /^(head|tail)\s+(?:-[a-zA-Z]*\s*\d*\s*|-\d+\s*|--?\w+(=\S+)?\s*)*$/],
|
||||
@@ -59,8 +61,116 @@ const SHELL_RULES = [
|
||||
[/(^|\s)sed\s+(-n|--quiet)/, 'sed -n is not allowed for reading files — use the read tool with offset.'],
|
||||
]
|
||||
|
||||
// ---------------------------------------------------------------- inline scripts
|
||||
//
|
||||
// Allowlist, not denylist: an inline script is allowed only when its body contains NO identifier
|
||||
// that can reach the filesystem, a subprocess, the network, dynamic code or a module loader.
|
||||
// Anything we cannot read (script piped via stdin, `-` as the program, a script file) is denied.
|
||||
// Obfuscation is covered because the identifiers that *start* any I/O path (require, import,
|
||||
// process, globalThis, eval, Function, open, os, subprocess, __import__, …) are themselves banned —
|
||||
// `fs['write'+'File']` still needs `require`/`import` to obtain `fs`.
|
||||
|
||||
/** Interpreters and the flags that take an inline program as their next argument. */
|
||||
const INTERPRETERS = [
|
||||
{ re: /^(node|nodejs|bun)$/, flags: ['-e', '--eval', '-p', '--print', '-pe', '-ep'], stdin: true },
|
||||
{ re: /^deno$/, flags: ['eval'], stdin: true },
|
||||
{ re: /^(python|python[23](\.\d+)?|pypy3?)$/, flags: ['-c'], stdin: true },
|
||||
{ re: /^ruby$/, flags: ['-e'], stdin: true },
|
||||
{ re: /^perl$/, flags: ['-e', '-E'], stdin: true },
|
||||
{ re: /^php$/, flags: ['-r'], stdin: true },
|
||||
{ re: /^(lua|luajit)$/, flags: ['-e'], stdin: true },
|
||||
]
|
||||
|
||||
const JS_BANNED = /\b(require|import|process|globalThis|global|window|self|module|exports|eval|Function|constructor|prototype|__proto__|Deno|Bun|fetch|XMLHttpRequest|WebSocket|Worker|setTimeout|setInterval|queueMicrotask|Reflect|Proxy|Symbol|WebAssembly|child_process|fs|net|http|https|dgram|vm|os)\b|\[\s*['"`]|\bthis\b/
|
||||
const PY_BANNED = /\b(import|__import__|open|exec|eval|compile|globals|locals|vars|getattr|setattr|delattr|__builtins__|__class__|__subclasses__|__dict__|__globals__|builtins|os|sys|subprocess|shutil|pathlib|Path|io|socket|ctypes|input|breakpoint|help|memoryview)\b/
|
||||
const RB_BANNED = /\b(File|Dir|IO|Kernel|Process|system|exec|spawn|fork|open|require|load|eval|send|public_send|instance_eval|class_eval|const_get|ObjectSpace|Socket|ENV|STDIN|ARGF|Open3|`)\b|`|%x/
|
||||
const PL_BANNED = /\b(open|opendir|unlink|rename|mkdir|rmdir|system|exec|fork|qx|require|use|do|eval|chmod|chown|symlink|link|truncate|sysopen|readpipe|glob|socket)\b|`|<\s*\w*\s*>/
|
||||
const GENERIC_BANNED = /\b(io|os|require|load|dofile|loadfile|loadstring|exec|system|popen|shell_exec|passthru|proc_open|fopen|file_get_contents|file_put_contents|unlink|include|eval)\b|`/
|
||||
|
||||
function bannedFor(name) {
|
||||
if (/^(node|nodejs|bun|deno)$/.test(name)) return JS_BANNED
|
||||
if (/^(python|python[23](\.\d+)?|pypy3?)$/.test(name)) return PY_BANNED
|
||||
if (name === 'ruby') return RB_BANNED
|
||||
if (name === 'perl') return PL_BANNED
|
||||
return GENERIC_BANNED
|
||||
}
|
||||
|
||||
/** Minimal shell tokenizer: words with quotes removed (single, double, $'…'), plus operators. */
|
||||
function tokenize(cmd) {
|
||||
const out = []
|
||||
let cur = ''; let has = false; let i = 0
|
||||
const s = String(cmd)
|
||||
const push = () => { if (has) out.push(cur); cur = ''; has = false }
|
||||
while (i < s.length) {
|
||||
const c = s[i]
|
||||
if (c === "'") { const j = s.indexOf("'", i + 1); cur += s.slice(i + 1, j < 0 ? s.length : j); has = true; i = j < 0 ? s.length : j + 1; continue }
|
||||
if (c === '"') {
|
||||
let j = i + 1
|
||||
while (j < s.length && s[j] !== '"') { if (s[j] === '\\' && j + 1 < s.length) { cur += s[j + 1]; j += 2; continue } cur += s[j++] }
|
||||
has = true; i = j + 1; continue
|
||||
}
|
||||
if (c === '\\' && i + 1 < s.length) { cur += s[i + 1]; has = true; i += 2; continue }
|
||||
if (/\s/.test(c)) { push(); i++; continue }
|
||||
if (c === ';' || c === '|' || c === '&' || c === '(' || c === ')' || c === '\n') {
|
||||
push()
|
||||
const two = s.slice(i, i + 2)
|
||||
if (two === '&&' || two === '||') { out.push(two); i += 2 } else { out.push(c); i++ }
|
||||
continue
|
||||
}
|
||||
cur += c; has = true; i++
|
||||
}
|
||||
push()
|
||||
return out
|
||||
}
|
||||
|
||||
const OPS = new Set([';', '|', '&', '&&', '||', '(', ')', '\n'])
|
||||
|
||||
/**
|
||||
* Deny inline-interpreter scripts that could read, write, run or fetch anything.
|
||||
* @returns a denial message, or null when every inline program is plain computation.
|
||||
*/
|
||||
export function inlineScriptViolation(cmd) {
|
||||
const toks = tokenize(cmd)
|
||||
for (let i = 0; i < toks.length; i++) {
|
||||
// Command position: start, or right after an operator (skipping env-style prefixes).
|
||||
if (i > 0 && !OPS.has(toks[i - 1])) continue
|
||||
let k = i
|
||||
while (k < toks.length && (/^(sudo|command|exec|time|nice|nohup|env)$/.test(toks[k]) || /^\w+=/.test(toks[k]) || /^-/.test(toks[k]) && /^(sudo|env|nice)$/.test(toks[k - 1] ?? ''))) k++
|
||||
const prog = (toks[k] ?? '').split('/').pop()
|
||||
const interp = INTERPRETERS.find((x) => x.re.test(prog))
|
||||
if (!interp) continue
|
||||
const deny = (why) => `Blocked by omp-edit guard: ${why} — use read/grep/edit for files (computation-only one-liners are fine). (command: ${String(cmd).slice(0, 120)})`
|
||||
// Collect this command's arguments.
|
||||
const args = []
|
||||
for (let j = k + 1; j < toks.length && !OPS.has(toks[j]); j++) args.push(toks[j])
|
||||
const fromPipe = i > 0 && toks[i - 1] === '|'
|
||||
let program = null
|
||||
for (let j = 0; j < args.length; j++) {
|
||||
const a = args[j]
|
||||
const eq = /^(--eval|--print)=(.*)$/s.exec(a)
|
||||
if (eq) { program = eq[2]; break }
|
||||
if (interp.flags.includes(a)) { program = args[j + 1] ?? ''; break }
|
||||
// Combined short flags ending in the program flag (node -pe / python -Bc).
|
||||
if (/^-[a-zA-Z]+$/.test(a) && interp.flags.some((f) => f.length === 2 && a.endsWith(f[1]) && f !== a)) { program = args[j + 1] ?? ''; break }
|
||||
}
|
||||
if (program === null) {
|
||||
const firstOperand = args.find((a) => !a.startsWith('-'))
|
||||
// A script read from stdin (`… | node`, `node -`, `node < f`) cannot be inspected.
|
||||
if (fromPipe || args.includes('-') || firstOperand === undefined) {
|
||||
if (args.some((a) => /^(--version|-v|-V|--help|-h)$/.test(a))) continue
|
||||
return deny(`${prog} reading its program from stdin cannot be checked`)
|
||||
}
|
||||
continue // `node script.js`: running a file is a normal command, not an inline script
|
||||
}
|
||||
if (bannedFor(prog).test(program)) return deny(`this ${prog} one-liner can reach files, processes or the network`)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** Return a denial message when `cmd` mutates, reads or searches files through the shell. */
|
||||
export function shellViolation(cmd) {
|
||||
const inline = inlineScriptViolation(cmd)
|
||||
if (inline) return inline
|
||||
const blanked = String(cmd)
|
||||
.replace(/'[^']*'/g, "''")
|
||||
.replace(/"(?:\\.|[^"\\])*"/g, '""')
|
||||
|
||||
Reference in new issue
Block a user