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:
Agent committed 2026-10-11 00:54:18 +00:00
1 parent 583aa9a10b
commit bcb7468686
3 files changed
+199 -2

No files matched your search

+111 -1
View File
@@ -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, '""')