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
@@ -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).
|
||||
|
||||
|
||||
@@ -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, '""')
|
||||
|
||||
@@ -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/)
|
||||
})
|
||||
Reference in new issue
Block a user