Read the script
#!/usr/bin/env node
'use strict';
// inline-script-block.test.js: tests for the inline-script-block PreToolUse
// hook. Each case spawns the hook with a Bash tool_input on stdin and checks
// the permission decision.
//
// Run with: node --test /Users/cgraff/.work/hooks/inline-script-block.test.js
const test = require('node:test');
const assert = require('node:assert/strict');
const path = require('node:path');
const { spawnSync } = require('node:child_process');
const hookPath = path.join(__dirname, 'inline-script-block.js');
const BAN_SENTENCE = 'Inline scripts are banned. Write the code to a file in the source tree and run that file.';
function runHook(input) {
return spawnSync(process.execPath, [hookPath], { input, encoding: 'utf8' });
}
function decision(command) {
const result = runHook(JSON.stringify({ tool_name: 'Bash', tool_input: { command } }));
if (result.stdout === '' && result.status === 0) return 'allow';
const parsed = JSON.parse(result.stdout);
if (parsed.hookSpecificOutput.permissionDecision === 'deny') return 'deny';
return `unexpected output: ${result.stdout}`;
}
const DENY = [
// eval / print / interactive flags in any spelling
'node -e "console.log(1)"',
"node '-e' 'x'",
'node "-e" x',
'node --eval x',
'node --eval=x',
'node -p 1',
'node -pe 1',
'node -ep 1',
'node -ie',
'node -i',
'node --interactive',
'node --print 1',
'node --print=1',
'node --input-type=module -e x',
'node --input-type module',
'node --no-warnings -e x',
"node $'-e' x",
'node -e x file.js',
// stdin scripts
'node -',
'node - --flag',
'node --input-type=module -',
'node -- -',
'node /dev/stdin',
'node /dev/fd/0',
'node <<EOF\nconsole.log(1)\nEOF',
'node <<-EOF\nconsole.log(1)\nEOF',
'node < script.js',
"node <<< 'x'",
'node --no-warnings < script.js',
'node <(echo x)',
'node 0< script.js',
// data: URL preloads
"node --import 'data:text/javascript,console.log(1)'",
'node --import=data:text/javascript,x',
'node --loader data:text/javascript,x file.js',
'node -r data:text/javascript,x file.js',
"NODE_OPTIONS='--import=data:text/javascript,x' node --version",
'export NODE_OPTIONS=--import=data:text/javascript,x',
'env NODE_OPTIONS="--import=data:text/javascript,x" node file.js',
// wrappers, env prefixes, absolute runtime paths
'pnpm node -e x',
'yarn node -e x',
'npx tsx -e x',
'timeout 5 node -e x',
'FOO=1 node -e x',
'/Users/cgraff/.nodenv/shims/node -e x',
'./node_modules/.bin/tsx -e x',
'echo $(node -e x)',
'(node -e x)',
'xargs node -e x',
// other runtimes
'nodejs -e x',
'ts-node -e x',
'ts-node -p 1',
'tsx -e x',
'bun -e x',
'bun -p 1',
'bun -',
// deno
"deno eval 'x'",
'deno repl',
'deno run -',
'deno run -A -',
'deno run /dev/stdin',
"deno run 'data:application/typescript,x'",
'deno run --import-map map.json -',
'deno <<EOF\nx\nEOF',
'deno run < script.ts',
// chained segments
'git status && node -e x',
'ls; node -p 1',
'ls || node -e x',
'echo x | node -e x',
'node -e x &',
'ls\nnode -e x',
// line continuation and zero-width evasion (U+200B zero-width space, U+FEFF BOM)
'node \\\n -e x',
`node ${String.fromCharCode(0x200b)}-e x`,
`node -${String.fromCharCode(0x200b)}e x`,
`node${String.fromCharCode(0xfeff)} -e x`,
// command strings run by a shell or wrapper
"bash -c 'node -e x'",
'zsh -lc "node -p 1"',
"sh -c 'node - <<EOF\nx\nEOF'",
'/bin/bash -c "node -e x"',
'bash -o pipefail -c "node -e x"',
"timeout 5 bash -c 'node -e x'",
"su -c 'node -e x'",
"su - root -c 'node -e x'",
"npm exec -c 'node -e x'",
"npm x -c 'node -e x'",
'npx -c "node -e x"',
"bun exec 'node -e x'",
'eval "node -e x"',
'eval node -e x',
`bash -c "bash -c 'node -e x'"`,
// unresolved expansions in the flag window and NODE_OPTIONS
'F=-e\nnode $F x',
'node $F x',
'node ${F} x',
'node "$@"',
'node --import "$U" file.js',
'node --max-old-space-size=$MEM build.js',
'NODE_OPTIONS="--import=$U" node file.js',
'deno run $S',
'deno run --config $C script.ts',
// bun subcommands
'bun repl',
'bun run -',
'bun run --watch -',
// bare runtime: a REPL, or a script piped in on stdin
'node',
'bun',
'tsx',
'deno',
'node 2>&1',
'echo x | node',
// bare runtime under a wrapper
'pnpm node',
'yarn node',
'timeout 5 node',
'timeout 5s node',
'timeout 1.5s node',
'timeout 2m node',
'timeout --signal=KILL 5s node',
'sudo node',
'env node',
'command node',
'exec node',
'nohup node',
'stdbuf -oL node',
'xargs node',
'npx node',
'pnpm exec node',
// bun x runs the tokens after it as a fresh command
'bun x tsx -e x',
'bun x tsx -p 1',
'bunx tsx -e x',
// a command word that is an unresolved expansion
'bash -c "$CMD"',
"zsh -c '$CMD'",
'eval "$CMD"',
'eval $CMD',
'CMD="node -e x"\n$CMD',
'"$@"',
'$(which node) -e x',
];
const ALLOW = [
// file-based invocations
'node file.mjs',
'node file.mjs --flag -e value',
'node script.js -p 3000',
'node script.js -',
'node -- file.js',
'node --version',
'node -v',
'node --env-file=.env server.js',
'node -r ./setup.js file.js',
'node --require ./setup.js file.js',
'node --import ./register.mjs file.js',
'node --import=./register.mjs file.js',
'node --test hooks/inline-script-block.test.js',
'node hook.js < payload.json',
'node hook.js <<EOF\n{"a":1}\nEOF',
'node hook.js 2>&1',
'node --max-old-space-size=4096 build.js',
'node --max-old-space-size 4096 build.js',
'node -C development file.js',
'node --title myapp file.js',
'node ~/.work/skills/archify/bin/archify.mjs deliver architecture spec.json out.html --quality showcase --json',
'node build.js --import=data:text/javascript,x',
'pnpm node file.js',
'timeout 5 node file.js',
'timeout 5s node file.js',
'FOO=1 node file.js',
'NODE_OPTIONS=--max-old-space-size=4096 node build.js',
'NODE_OPTI