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 |
|
| 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) |
|
| `cat`/`head`/`tail`/`less`/`bat`/`nl FILE`, `sed -n`, `awk` | `read` (paged) |
|
||||||
| `grep`/`rg`/`ag`/`ack` over files | `grep` (paged, tagged) |
|
| `grep`/`rg`/`ag`/`ack` over files | `grep` (paged, tagged) |
|
||||||
| `find`/`fd`/`locate`, `ls -R` | `glob` |
|
| `find`/`fd`/`locate`, `ls -R` | `glob` |
|
||||||
@@ -87,6 +88,14 @@ What stays allowed:
|
|||||||
- Text inside quotes is ignored (`git commit -m "fix > bug"`).
|
- Text inside quotes is ignored (`git commit -m "fix > bug"`).
|
||||||
- Redirection to `/dev/null` is fine.
|
- Redirection to `/dev/null` is fine.
|
||||||
- Filters on piped output keep working (`npm test 2>&1 | tail -5`, `ps aux | grep node`).
|
- 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).
|
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).'],
|
[/(^|\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).'],
|
[/(^|[^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*['"]?\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.'],
|
[/(^|\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.
|
// 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*)*$/],
|
[/^(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.'],
|
[/(^|\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. */
|
/** Return a denial message when `cmd` mutates, reads or searches files through the shell. */
|
||||||
export function shellViolation(cmd) {
|
export function shellViolation(cmd) {
|
||||||
|
const inline = inlineScriptViolation(cmd)
|
||||||
|
if (inline) return inline
|
||||||
const blanked = String(cmd)
|
const blanked = String(cmd)
|
||||||
.replace(/'[^']*'/g, "''")
|
.replace(/'[^']*'/g, "''")
|
||||||
.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