diff --git a/README.md b/README.md index 1d7b0f5..3c9d659 100644 --- a/README.md +++ b/README.md @@ -77,7 +77,8 @@ A `tools/pre-execute` listener makes the agent use these tools instead of workin | Denied | Use instead | | --- | --- | -| `sed -i`, `perl -i`, `ed`/`ex`, `tee`, `>`/`>>` into files, heredocs, `truncate`/`dd`, `python -c`/`node -e`… | `edit` (hashline) | +| `sed -i`, `perl -i`, `ed`/`ex`, `tee`, `>`/`>>` into files, heredocs, `truncate`/`dd` | `edit` (hashline) | +| Inline scripts that can touch files, processes or the network (`node -e "require('fs')…"`, `node --input-type=module --eval "import …"`, `python3 -c "open(…)"`, `ruby -e "File.write…"`, `deno eval`, `bun -e`, `php -r`…), or scripts piped in through stdin (`… \| node`, `node - < f`) | `read`/`grep`/`edit` | | `cat`/`head`/`tail`/`less`/`bat`/`nl FILE`, `sed -n`, `awk` | `read` (paged) | | `grep`/`rg`/`ag`/`ack` over files | `grep` (paged, tagged) | | `find`/`fd`/`locate`, `ls -R` | `glob` | @@ -87,6 +88,14 @@ What stays allowed: - Text inside quotes is ignored (`git commit -m "fix > bug"`). - Redirection to `/dev/null` is fine. - Filters on piped output keep working (`npm test 2>&1 | tail -5`, `ps aux | grep node`). +- Computation-only one-liners: `node -e "console.log(1+1)"`, `node -p "2**10"`, `python3 -c "print(sum(range(9)))"`, `ruby -e 'puts 1+1'`. Running a file is a normal command (`node --test test/`, `node build.mjs`). + +How inline scripts are judged: the guard works out which interpreter runs (also through `env`, `sudo` and absolute paths) and pulls out its program from `-e`/`-p`/`--eval`/`--print`/`--eval=`/`-c`/`-r`/`eval`. The program is allowed only if it contains **none** of the identifiers that start any I/O path, for example: +- JavaScript: `require`, `import`, `process`, `globalThis`, `eval`, `Function`, `constructor`, `fetch`, `Deno`, `Bun`, or bracket access with a string (`x['y']`); +- Python: `import`, `__import__`, `open`, `os`, `subprocess`, dunder attributes; +- Ruby/Perl: `File`, `IO`, `system`, `open`, backticks. + +Banning the identifiers that start an I/O path covers obfuscation too: `fs['write'+'File']` still needs `require` or `import` to get `fs`. A program the guard can't read (stdin, `-`) is denied. If the guard can't check whether a path exists, `write` is denied rather than allowed (it fails closed). diff --git a/index.js b/index.js index 47f9853..99282ee 100644 --- a/index.js +++ b/index.js @@ -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, '""') diff --git a/test/guard.test.mjs b/test/guard.test.mjs new file mode 100644 index 0000000..7c10b4f --- /dev/null +++ b/test/guard.test.mjs @@ -0,0 +1,78 @@ +// Guard rules for shell commands, focused on inline interpreter scripts. +import test from 'node:test' +import assert from 'node:assert/strict' +import { shellViolation, inlineScriptViolation } from '../index.js' + +const ALLOW = [ + // harmless computation one-liners + `node -e "console.log(1+1)"`, + `node -p "2**10"`, + `node --eval 'console.log([1,2,3].map(x => x * 2))'`, + `node --print="JSON.stringify({a: 1})"`, + `node -pe "Math.max(3, 7)"`, + `python3 -c "print(2**10)"`, + `python3 -c 'print(sum(range(100)))'`, + `ruby -e 'puts [1,2,3].sum'`, + `perl -e 'print 6*7, "\\n"'`, + `node -e 'console.log(JSON.parse(\`{"a":1}\`).a)'`, + // running files / tools is a normal command, not an inline script + 'node --test test/', + 'node build.mjs', + 'node --check index.js', + 'python3 manage.py migrate', + 'node --version', + 'python3 --version', + // unrelated commands + 'git status', + 'npm test 2>&1 | tail -5', +] + +const BLOCK = [ + // the reported gap + `node --input-type=module --eval "import fs from 'node:fs'; fs.readFileSync('a')"`, + `node --input-type=module -e "import('node:fs').then(m => m.writeFileSync('a', 'b'))"`, + // direct file / process access + `node -e "require('fs').writeFileSync('a', 'b')"`, + `node -e "require('child_process').execSync('cat f')"`, + `node -p "process.mainModule.require('fs')"`, + `node -e "fs['write'+'File']('a','b')"`, + `node -e "globalThis['req'+'uire']('fs')"`, + `node -e "this.constructor.constructor('return process')()"`, + `node -e "eval('requ'+'ire')('fs')"`, + `node -e "new Function('return process')()"`, + `node -e "fetch('http://x')"`, + `python3 -c "open('f','w').write('x')"`, + `python3 -c "import os; os.remove('f')"`, + `python3 -c "__import__('os').system('ls')"`, + `python3 -c "print(().__class__.__bases__[0].__subclasses__())"`, + `ruby -e "File.write('x', 1)"`, + 'ruby -e "puts `ls`"', + `perl -e 'open(F, ">x")'`, + `deno eval "Deno.writeTextFileSync('x','y')"`, + `bun -e "Bun.write('x','y')"`, + `php -r "file_put_contents('x','y');"`, + // scripts we cannot inspect + 'echo "require(1)" | node', + 'cat script.js | node -', + 'node - < script.js', + 'python3 - < s.py', + // wrapped forms + `env FOO=1 node -e "require('fs')"`, + `sudo node -e "require('fs')"`, + `/usr/bin/node -e "require('fs')"`, + `ls && node -e "require('fs')"`, +] + +test('harmless inline one-liners and normal commands are allowed', () => { + for (const c of ALLOW) assert.equal(shellViolation(c), null, `should allow: ${c}`) +}) + +test('file/process/network-capable or uninspectable inline scripts are blocked', () => { + for (const c of BLOCK) assert.ok(inlineScriptViolation(c) || shellViolation(c), `should block: ${c}`) +}) + +test('denial message points at the right tools', () => { + const m = shellViolation(`node -e "require('fs').readFileSync('a')"`) + assert.match(m, /read\/grep\/edit/) + assert.match(m, /computation-only one-liners are fine/) +})