re.compile(r"os.killpg\s(\s[01]\s,", re.S),
Root cause: the killpg rule encoded the spec constant literally — [1] (os.killpg(1, …), init) — but POSIX killpg(0, …) signals the calling process's own process group, which is the terminal-jail's own group. The rule never enumerated that sibling vector, so os.killpg(0, …) sailed through as ALLOW. Fix = enumerate the whole killpg family (not just the spec example), apply it in the engine, and keep the aggressive probe as a separate always-run tripwire.
Files (~/terminal-jail-interruptor/):
terminal_jail_interruptor.py — engine with the fixed rule set and a lexical sanitizer:
def build_rules() -> list[Rule]:
return [
# [1] extended to [01]: literal own-pgrp (0) or init (1)
Rule("killpg-own-or-init",
re.compile(r"os\.killpg\s*\(\s*[01]\s*,", re.S),
"killpg targeting own process group (0) or init (1) ... [1] extended to [01]"),
# computed own-pgrp variants
Rule("killpg-computed-own-pgrp",
re.compile(r"os\.killpg\s*\(\s*(?:os\.getpgrp\s*\(\s*\)|os\.getpgid\s*\(\s*0\s*\))\s*,", re.S),
"killpg on computed own process group (getpgrp()/getpgid(0))"),
# family catch-all: no killpg form escapes (arbitrary pgids, sig-0 probes)
Rule("killpg-any", re.compile(r"os\.killpg\s*\(", re.S), "any process-group signaling denied"),
# libc-kill alias family
Rule("kill-negative-pid",
re.compile(r"os\.kill\s*\(\s*-\s*(?:1|0|os\.getpgrp\s*\(\s*\))", re.S),
"negative-pid os.kill: process-group/broadcast kill alias"),
Rule("kill-negative-pid-any", re.compile(r"os\.kill\s*\(\s*-\s*\d+", re.S), "..."),
Rule("kill-zero-own-pgrp", re.compile(r"os\.kill\s*\(\s*0\s*,", re.S),
"os.kill(0, sig) == killpg(getpgrp(), sig): own-group kill"),
# shell escapes fail closed at the CALL SITE (payload strings untrustworthy)
Rule("shell-exec-os-system", re.compile(r"os\.system\s*\(", re.S), "..."),
Rule("shell-exec-subprocess",
re.compile(r"subprocess\.(?:run|Popen|call|check_call|check_output)\s*\(", re.S), "..."),
]
Rules match the executable surface: _sanitize() strips string literals (incl. triple-quoted and escaped quotes) and comments first, so text = "os.killpg(0, sig)" # docstring is ALLOW while os.killpg(f"{0}", sig) still hits the call-structure rule.
battery_main.py — 23 engine verdicts: 10 benign (ALLOW) + 13 adversarial (DENY), now including os.killpg(0, …) so the main battery no longer misses the vector.
probe_killpg.py — the extra 9-vector probe, kept as a separate script (it is the tripwire that outruns spec examples):
PROBE_VECTORS = [
("os.killpg(0, signal.SIGKILL)", DENY, "own pgrp, literal 0"),
("os.killpg(1, signal.SIGKILL)", DENY, "init, literal 1"),
("os.killpg(os.getpgrp(), signal.SIGKILL)", DENY, "own pgrp via getpgrp()"),
("os.killpg(os.getpgid(0), signal.SIGKILL)", DENY, "own pgrp via getpgid(0)"),
("os.killpg(0, 0)", DENY, "sig-0 existence probe on own pgrp"),
("os.kill(-1, signal.SIGKILL)", DENY, "broadcast via negative pid"),
("os.kill(0, signal.SIGKILL)", DENY, "kill(0) own-pgrp alias"),
("os.kill(-os.getpgrp(), signal.SIGKILL)", DENY, "negative computed pgrp"),
("os.killpg(0, signal.SIGINT)", DENY, "own pgrp, SIGINT variant"),
]
run_all.py — runs both every window (python3 run_all.py; cron: 0 * * * * cd …/terminal-jail-interruptor && python3 run_all.py >> /var/log/jail-battery.log); exits 1 on any escape.
``` $ python3 run_all.py --legacy main battery: 23 verdicts, 23 pass, 0 fail killpg probe: 9 vectors, 9 blocked, 0 escaped legacy [1]-only rule caught 1/9 (the other 8 were the blind spot) legacy demo (pre-fix behaviour): code : 'os.killpg(0, signal.SIGKILL) # own process group' legacy rule : matched=False (BLIND SPOT -> ALLOW) fixed engine : DENY (rule=killpg-own-or-init) EXIT=0 ``` - **32/32 window checks pass**; both scripts also run standalone (`python3 probe_killpg.py` → exit 0, `python3 battery_main.py` → exit 0). - **Regression proven:** the legacy `[1]`-only rule demonstrably allows `os.killpg(0, …)`; the fixed engine denies it. - **Edge cases (26/26 pass):** whitespace-obfuscated `killpg(\n 0,\n …)`; computed `getpgrp()`/`getpgid(0)`; arbitrary pgid `9999`; sig-0 probes; `os.kill(-1)` without a signal arg; negative computed pgrp; shell `os.system`/`subprocess` kill; and false-positive guards — inert `signal.SIGKILL` reference, `"killpg" in dir(os)`, bare `os.killpg` attribute, `os.killpg_myhelper(0, 9)`, payload mentions in strings/comments/docstrings/f-strings/escaped quotes.
{"model": "deepseek-v4-flash", "problem_class": "python-security-rule-adversarial-vectors", "result": "passed", "tests": 32}Root cause: the killpg rule encoded the spec constant literally — [1] (os.killpg(1, …), init) — but POSIX killpg(0, …) signals the calling process's own process group, which is the terminal-jail's own group. The rule never enumerated that sibling vector, so os.killpg(0, …) sailed through as ALLOW. Fix = enumerate the whole killpg family (not just the spec example), apply it in the engine, and keep the aggressive probe as a separate always-run tripwire.
Files (~/terminal-jail-interruptor/):
terminal_jail_interruptor.py — engine with the fixed rule set and a lexical sanitizer:
def build_rules() -> list[Rule]:
return [
# [1] extended to [01]: literal own-pgrp (0) or init (1)
Rule("killpg-own-or-init",
re.compile(r"os\.killpg\s*\(\s*[01]\s*,", re.S),
"killpg targeting own process group (0) or init (1) ... [1] extended to [01]"),
# computed own-pgrp variants
Rule("killpg-computed-own-pgrp",
re.compile(r"os\.killpg\s*\(\s*(?:os\.getpgrp\s*\(\s*\)|os\.getpgid\s*\(\s*0\s*\))\s*,", re.S),
"killpg on computed own process group (getpgrp()/getpgid(0))"),
# family catch-all: no killpg form escapes (arbitrary pgids, sig-0 probes)
Rule("killpg-any", re.compile(r"os\.killpg\s*\(", re.S), "any process-group signaling denied"),
# libc-kill alias family
Rule("kill-negative-pid",
re.compile(r"os\.kill\s*\(\s*-\s*(?:1|0|os\.getpgrp\s*\(\s*\))", re.S),
"negative-pid os.kill: process-group/broadcast kill alias"),
Rule("kill-negative-pid-any", re.compile(r"os\.kill\s*\(\s*-\s*\d+", re.S), "..."),
Rule("kill-zero-own-pgrp", re.compile(r"os\.kill\s*\(\s*0\s*,", re.S),
"os.kill(0, sig) == killpg(getpgrp(), sig): own-group kill"),
# shell escapes fail closed at the CALL SITE (payload strings untrustworthy)
Rule("shell-exec-os-system", re.compile(r"os\.system\s*\(", re.S), "..."),
Rule("shell-exec-subprocess",
re.compile(r"subprocess\.(?:run|Popen|call|check_call|check_output)\s*\(", re.S), "..."),
]
Rules match the executable surface: _sanitize() strips string literals (incl. triple-quoted and escaped quotes) and comments first, so text = "os.killpg(0, sig)" # docstring is ALLOW while os.killpg(f"{0}", sig) still hits the call-structure rule.
battery_main.py — 23 engine verdicts: 10 benign (ALLOW) + 13 adversarial (DENY), now including os.killpg(0, …) so the main battery no longer misses the vector.
probe_killpg.py — the extra 9-vector probe, kept as a separate script (it is the tripwire that outruns spec examples):
PROBE_VECTORS = [
("os.killpg(0, signal.SIGKILL)", DENY, "own pgrp, literal 0"),
("os.killpg(1, signal.SIGKILL)", DENY, "init, literal 1"),
("os.killpg(os.getpgrp(), signal.SIGKILL)", DENY, "own pgrp via getpgrp()"),
("os.killpg(os.getpgid(0), signal.SIGKILL)", DENY, "own pgrp via getpgid(0)"),
("os.killpg(0, 0)", DENY, "sig-0 existence probe on own pgrp"),
("os.kill(-1, signal.SIGKILL)", DENY, "broadcast via negative pid"),
("os.kill(0, signal.SIGKILL)", DENY, "kill(0) own-pgrp alias"),
("os.kill(-os.getpgrp(), signal.SIGKILL)", DENY, "negative computed pgrp"),
("os.killpg(0, signal.SIGINT)", DENY, "own pgrp, SIGINT variant"),
]
run_all.py — runs both every window (python3 run_all.py; cron: 0 * * * * cd …/terminal-jail-interruptor && python3 run_all.py >> /var/log/jail-battery.log); exits 1 on any escape.
``` $ python3 run_all.py --legacy main battery: 23 verdicts, 23 pass, 0 fail killpg probe: 9 vectors, 9 blocked, 0 escaped legacy [1]-only rule caught 1/9 (the other 8 were the blind spot) legacy demo (pre-fix behaviour): code : 'os.killpg(0, signal.SIGKILL) # own process group' legacy rule : matched=False (BLIND SPOT -> ALLOW) fixed engine : DENY (rule=killpg-own-or-init) EXIT=0 ``` - **32/32 window checks pass**; both scripts also run standalone (`python3 probe_killpg.py` → exit 0, `python3 battery_main.py` → exit 0). - **Regression proven:** the legacy `[1]`-only rule demonstrably allows `os.killpg(0, …)`; the fixed engine denies it. - **Edge cases (26/26 pass):** whitespace-obfuscated `killpg(\n 0,\n …)`; computed `getpgrp()`/`getpgid(0)`; arbitrary pgid `9999`; sig-0 probes; `os.kill(-1)` without a signal arg; negative computed pgrp; shell `os.system`/`subprocess` kill; and false-positive guards — inert `signal.SIGKILL` reference, `"killpg" in dir(os)`, bare `os.killpg` attribute, `os.killpg_myhelper(0, 9)`, payload mentions in strings/comments/docstrings/f-strings/escaped quotes.
{"model": "deepseek-v4-flash", "problem_class": "python-security-rule-adversarial-vectors", "result": "passed", "tests": 32}