refactor: simplify destructive command matching

This commit is contained in:
Haoran
2026-08-27 00:33:25 +08:00
parent 1293797585
commit 08263f49b3
22 changed files with 507 additions and 3324 deletions

View File

@@ -54,39 +54,17 @@ def check_deny_list(command: str) -> str | None:
return None return None
``` ```
**ゲート 2**:ルールマッチング — 「いつユーザーに聞くべきか」を記述する。各ルールはツールとチェック条件を指定する。shell ルールは quoted separator を構文として扱わずに command を分割し、直接 command、`if`/`for` の本体、`cmd /c``sh -c` の payload など、実際に command が実行される位置を確認する。 **ゲート 2**:ルールマッチング — 「いつユーザーに聞くべきか」を記述する。各ルールはツールとチェック条件を指定する。
ここでの matcher は一般的な command 形式を説明するためのものであり、完全な shell parser や security sandbox ではない。
```python ```python
import shlex import re
SHELL_SEPARATORS = ";&|\n" DESTRUCTIVE_COMMAND_WORD = re.compile(
DESTRUCTIVE_COMMANDS = {"rm", "del"} r"(?i)(?:^|[;&|()\n])\s*(?:rm|del)(?=\s|$|[;&|()])"
)
def shell_tokens(command: str) -> list[str]:
lexer = shlex.shlex(command, posix=False,
punctuation_chars=SHELL_SEPARATORS)
lexer.whitespace = " \t\r"
lexer.whitespace_split = True
lexer.commenters = ""
return list(lexer)
def contains_destructive_command(command: str) -> bool: def contains_destructive_command(command: str) -> bool:
try: return bool(DESTRUCTIVE_COMMAND_WORD.search(command))
tokens = shell_tokens(command)
except ValueError:
return True
segment = []
for token in tokens:
if is_shell_separator(token):
if segment_has_destructive_command(segment):
return True
segment = []
else:
segment.append(token)
return segment_has_destructive_command(segment)
PERMISSION_RULES = [ PERMISSION_RULES = [
{ {
@@ -97,7 +75,7 @@ PERMISSION_RULES = [
{ {
"tools": ["bash"], "tools": ["bash"],
"check": lambda args: contains_destructive_command(args.get("command", "")) or any( "check": lambda args: contains_destructive_command(args.get("command", "")) or any(
kw in args.get("command", "") for kw in ["> /etc/", "chmod 777"] kw in args.get("command", "") for kw in ["rm ", "> /etc/", "chmod 777"]
), ),
"message": "Potentially destructive command", "message": "Potentially destructive command",
}, },
@@ -174,7 +152,7 @@ python s03_permission/code.py
2. `Delete the file test.txt`bash + rm でゲート 2 が発動) 2. `Delete the file test.txt`bash + rm でゲート 2 が発動)
3. `What files are in the current directory?`(読み取り専用、すべて通過) 3. `What files are in the current directory?`(読み取り専用、すべて通過)
4. `Try to write a file to /etc/something`(作業ディレクトリ外への書き込みでゲート 2 が発動) 4. `Try to write a file to /etc/something`(作業ディレクトリ外への書き込みでゲート 2 が発動)
5. Windows では `del test.txt``DEL test.txt``if exist test.txt del test.txt` がゲート 2 を発動し、`model``delimiter``echo del test.txt``echo "safe; del test.txt"` は発動しない。 5. Windows では `del test.txt``DEL test.txt` がゲート 2 を発動し、`model``delimiter``echo del test.txt` は発動しない。
観察のポイント:どの操作がそのまま通過するか? どれに確認が必要か? どれが即座に拒否されるか? 観察のポイント:どの操作がそのまま通過するか? どれに確認が必要か? どれが即座に拒否されるか?

View File

@@ -54,39 +54,17 @@ def check_deny_list(command: str) -> str | None:
return None return None
``` ```
**Gate 2**: Rule matching — describes "when to ask the user." Each rule specifies a tool and a check condition. The shell rule tokenizes commands without treating quoted separators as syntax, then checks executable positions such as direct commands, `if`/`for` bodies, and `cmd /c` or `sh -c` payloads. **Gate 2**: Rule matching — describes "when to ask the user." Each rule specifies a tool and a check condition.
This is a teaching-level matcher for common command forms, not a complete shell parser or security sandbox.
```python ```python
import shlex import re
SHELL_SEPARATORS = ";&|\n" DESTRUCTIVE_COMMAND_WORD = re.compile(
DESTRUCTIVE_COMMANDS = {"rm", "del"} r"(?i)(?:^|[;&|()\n])\s*(?:rm|del)(?=\s|$|[;&|()])"
)
def shell_tokens(command: str) -> list[str]:
lexer = shlex.shlex(command, posix=False,
punctuation_chars=SHELL_SEPARATORS)
lexer.whitespace = " \t\r"
lexer.whitespace_split = True
lexer.commenters = ""
return list(lexer)
def contains_destructive_command(command: str) -> bool: def contains_destructive_command(command: str) -> bool:
try: return bool(DESTRUCTIVE_COMMAND_WORD.search(command))
tokens = shell_tokens(command)
except ValueError:
return True
segment = []
for token in tokens:
if is_shell_separator(token):
if segment_has_destructive_command(segment):
return True
segment = []
else:
segment.append(token)
return segment_has_destructive_command(segment)
PERMISSION_RULES = [ PERMISSION_RULES = [
{ {
@@ -97,7 +75,7 @@ PERMISSION_RULES = [
{ {
"tools": ["bash"], "tools": ["bash"],
"check": lambda args: contains_destructive_command(args.get("command", "")) or any( "check": lambda args: contains_destructive_command(args.get("command", "")) or any(
kw in args.get("command", "") for kw in ["> /etc/", "chmod 777"] kw in args.get("command", "") for kw in ["rm ", "> /etc/", "chmod 777"]
), ),
"message": "Potentially destructive command", "message": "Potentially destructive command",
}, },
@@ -174,7 +152,7 @@ Try these prompts:
2. `Delete the file test.txt` (bash + rm triggers Gate 2) 2. `Delete the file test.txt` (bash + rm triggers Gate 2)
3. `What files are in the current directory?` (read-only, all pass) 3. `What files are in the current directory?` (read-only, all pass)
4. `Try to write a file to /etc/something` (writing outside workspace triggers Gate 2) 4. `Try to write a file to /etc/something` (writing outside workspace triggers Gate 2)
5. On Windows, `del test.txt`, `DEL test.txt`, and `if exist test.txt del test.txt` trigger Gate 2, while `model`, `delimiter`, `echo del test.txt`, and `echo "safe; del test.txt"` do not. 5. On Windows, `del test.txt` and `DEL test.txt` trigger Gate 2, while `model`, `delimiter`, and `echo del test.txt` do not.
What to watch for: Which operations pass through? Which need your confirmation? Which are denied outright? What to watch for: Which operations pass through? Which need your confirmation? Which are denied outright?

View File

@@ -54,39 +54,17 @@ def check_deny_list(command: str) -> str | None:
return None return None
``` ```
**闸门 2**负责规则匹配,用来描述"什么时候需要问用户"。每条规则指定工具和检查条件。shell 规则会先拆分命令,但不把引号内的分隔符当成语法,再检查直接命令、`if`/`for` 主体以及 `cmd /c``sh -c` 等真正执行命令的位置。 **闸门 2**负责规则匹配,用来描述"什么时候需要问用户"。每条规则指定工具和检查条件。
这里的 matcher 只用于讲解常见命令形式,并不是完整的 shell parser 或安全沙箱。
```python ```python
import shlex import re
SHELL_SEPARATORS = ";&|\n" DESTRUCTIVE_COMMAND_WORD = re.compile(
DESTRUCTIVE_COMMANDS = {"rm", "del"} r"(?i)(?:^|[;&|()\n])\s*(?:rm|del)(?=\s|$|[;&|()])"
)
def shell_tokens(command: str) -> list[str]:
lexer = shlex.shlex(command, posix=False,
punctuation_chars=SHELL_SEPARATORS)
lexer.whitespace = " \t\r"
lexer.whitespace_split = True
lexer.commenters = ""
return list(lexer)
def contains_destructive_command(command: str) -> bool: def contains_destructive_command(command: str) -> bool:
try: return bool(DESTRUCTIVE_COMMAND_WORD.search(command))
tokens = shell_tokens(command)
except ValueError:
return True
segment = []
for token in tokens:
if is_shell_separator(token):
if segment_has_destructive_command(segment):
return True
segment = []
else:
segment.append(token)
return segment_has_destructive_command(segment)
PERMISSION_RULES = [ PERMISSION_RULES = [
{ {
@@ -97,7 +75,7 @@ PERMISSION_RULES = [
{ {
"tools": ["bash"], "tools": ["bash"],
"check": lambda args: contains_destructive_command(args.get("command", "")) or any( "check": lambda args: contains_destructive_command(args.get("command", "")) or any(
kw in args.get("command", "") for kw in ["> /etc/", "chmod 777"] kw in args.get("command", "") for kw in ["rm ", "> /etc/", "chmod 777"]
), ),
"message": "Potentially destructive command", "message": "Potentially destructive command",
}, },
@@ -174,7 +152,7 @@ python s03_permission/code.py
2. `Delete the file test.txt`bash + rm 会触发闸门 2 2. `Delete the file test.txt`bash + rm 会触发闸门 2
3. `What files are in the current directory?`(只读,全部通过) 3. `What files are in the current directory?`(只读,全部通过)
4. `Try to write a file to /etc/something`(写工作区外,触发闸门 2 4. `Try to write a file to /etc/something`(写工作区外,触发闸门 2
5. 在 Windows 上,`del test.txt``DEL test.txt``if exist test.txt del test.txt` 会触发闸门 2`model``delimiter``echo del test.txt``echo "safe; del test.txt"` 不会。 5. 在 Windows 上,`del test.txt``DEL test.txt` 会触发闸门 2`model``delimiter``echo del test.txt` 不会。
观察重点:哪些操作直接通过?哪些需要你确认?哪些被直接拒绝? 观察重点:哪些操作直接通过?哪些需要你确认?哪些被直接拒绝?

View File

@@ -33,7 +33,6 @@ Builds on s02 (multi-tool). Usage:
import os import os
import re import re
import shlex
import subprocess import subprocess
from pathlib import Path from pathlib import Path
@@ -154,190 +153,13 @@ def check_deny_list(command: str) -> str | None:
# Gate 2: Rule matching - context-dependent checks # Gate 2: Rule matching - context-dependent checks
SHELL_SEPARATORS = ";&|\n" DESTRUCTIVE_COMMAND_WORD = re.compile(
DESTRUCTIVE_COMMANDS = {"rm", "del"} r"(?i)(?:^|[;&|()\n])\s*(?:rm|del)(?=\s|$|[;&|()])"
SHELL_WRAPPERS = {"sh", "bash", "zsh", "dash", "cmd", "cmd.exe"}
COMMAND_PREFIXES = {"command", "call"}
CONTROL_PREFIXES = {"then", "do", "else", "!", "{"}
COMPARE_OPERATORS = {"equ", "neq", "lss", "leq", "gtr", "geq"}
MAX_COMMAND_NESTING = 16
DESTRUCTIVE_SUBCOMMAND = re.compile(
r"(?i)(?:\$\(|[<>]\(|\x60)\s*(?:rm|del)"
r"(?=\s|$|[;&|()])"
) )
def shell_tokens(command: str) -> list[str]: def contains_destructive_command(command: str) -> bool:
lexer = shlex.shlex( return bool(DESTRUCTIVE_COMMAND_WORD.search(command))
command, posix=False, punctuation_chars=SHELL_SEPARATORS
)
lexer.whitespace = " \t\r"
lexer.whitespace_split = True
lexer.commenters = ""
return list(lexer)
def shell_syntax_outside_single_quotes(command: str) -> str:
visible = []
single_quoted = double_quoted = escaped = False
for char in command:
if escaped:
visible.append(" ")
escaped = False
elif char == "\\" and not single_quoted:
visible.append(" ")
escaped = True
elif char == '"' and not single_quoted:
double_quoted = not double_quoted
visible.append(char)
elif char == "'" and not double_quoted:
single_quoted = not single_quoted
visible.append(" ")
else:
visible.append(" " if single_quoted else char)
return "".join(visible)
def unquote_shell_token(token: str) -> str:
if len(token) >= 2 and token[0] in "'\"" and token[-1] == token[0]:
return token[1:-1]
return token
def command_name(token: str) -> str:
value = unquote_shell_token(token).lstrip("@").strip("()").casefold()
if value.startswith("del/"):
return "del"
return value.replace("\\", "/").rsplit("/", 1)[-1]
def is_shell_separator(token: str) -> bool:
return bool(token) and all(char in SHELL_SEPARATORS for char in token)
def is_shell_assignment(token: str) -> bool:
name, separator, _ = unquote_shell_token(token).partition("=")
return bool(
separator
and name
and not name[0].isdigit()
and name.replace("_", "a").isalnum()
)
def segment_has_destructive_command(
tokens: list[str], depth: int = 0
) -> bool:
if depth >= MAX_COMMAND_NESTING:
return True
index = 0
while index < len(tokens) and is_shell_assignment(tokens[index]):
index += 1
if index >= len(tokens):
return False
name = command_name(tokens[index])
if name in DESTRUCTIVE_COMMANDS:
return True
if name in CONTROL_PREFIXES:
return segment_has_destructive_command(tokens[index + 1:], depth + 1)
if name == "env":
index += 1
while index < len(tokens) and (
unquote_shell_token(tokens[index]).startswith("-")
or is_shell_assignment(tokens[index])
):
index += 1
return segment_has_destructive_command(tokens[index:], depth + 1)
if name in COMMAND_PREFIXES:
index += 1
options = []
while (
index < len(tokens)
and unquote_shell_token(tokens[index]).startswith("-")
):
options.append(unquote_shell_token(tokens[index]))
index += 1
if name == "command" and any(
"v" in option.lstrip("-").casefold() for option in options
):
return False
return segment_has_destructive_command(tokens[index:], depth + 1)
if name in SHELL_WRAPPERS:
for flag_index in range(index + 1, len(tokens)):
flag = unquote_shell_token(tokens[flag_index]).casefold()
is_command_flag = (
flag in {"/c", "/k"}
if name.startswith("cmd")
else flag.startswith("-")
and not flag.startswith("--")
and "c" in flag[1:]
)
if is_command_flag:
nested = " ".join(
unquote_shell_token(token)
for token in tokens[flag_index + 1:]
)
return contains_destructive_command(nested, depth + 1)
return False
if name == "if":
index += 1
while (
index < len(tokens)
and command_name(tokens[index]) in {"/i", "not"}
):
index += 1
if index >= len(tokens):
return False
condition = command_name(tokens[index])
if condition in {"exist", "defined", "errorlevel", "cmdextversion"}:
return segment_has_destructive_command(
tokens[index + 2:], depth + 1
)
if "==" in unquote_shell_token(tokens[index]):
return segment_has_destructive_command(
tokens[index + 1:], depth + 1
)
if (
index + 2 < len(tokens)
and command_name(tokens[index + 1]) in COMPARE_OPERATORS
):
return segment_has_destructive_command(
tokens[index + 3:], depth + 1
)
return False
if name == "for":
for do_index, token in enumerate(tokens[index + 1:], index + 1):
if command_name(token) == "do":
return segment_has_destructive_command(
tokens[do_index + 1:], depth + 1
)
return False
def contains_destructive_command(command: str, depth: int = 0) -> bool:
if depth >= MAX_COMMAND_NESTING:
return True
try:
tokens = shell_tokens(command)
except ValueError:
return True
if DESTRUCTIVE_SUBCOMMAND.search(
shell_syntax_outside_single_quotes(command)
):
return True
segment = []
for token in tokens:
if is_shell_separator(token):
if segment_has_destructive_command(segment, depth):
return True
segment = []
else:
segment.append(token)
return segment_has_destructive_command(segment, depth)
PERMISSION_RULES = [ PERMISSION_RULES = [
@@ -346,7 +168,7 @@ PERMISSION_RULES = [
"message": "Writing outside workspace"}, "message": "Writing outside workspace"},
{"tools": ["bash"], {"tools": ["bash"],
"check": lambda args: contains_destructive_command(args.get("command", "")) or "check": lambda args: contains_destructive_command(args.get("command", "")) or
any(kw in args.get("command", "") for kw in ["> /etc/", "chmod 777"]), any(kw in args.get("command", "") for kw in ["rm ", "> /etc/", "chmod 777"]),
"message": "Potentially destructive command"}, "message": "Potentially destructive command"},
] ]

View File

@@ -102,15 +102,12 @@ agent_loop(history)
**PreToolUse / PostToolUse**、ツール実行の前後のフック。s03 の権限チェックロジックは PreToolUse フックに包まれ、さらにログフックと大出力リマインダーが追加される: **PreToolUse / PostToolUse**、ツール実行の前後のフック。s03 の権限チェックロジックは PreToolUse フックに包まれ、さらにログフックと大出力リマインダーが追加される:
```python ```python
# PreToolUse: 権限チェックs03 から引き継いだ matcher を含む # PreToolUse: 権限チェックs03 のロジック、ループからフックに移動
def permission_hook(block): def permission_hook(block):
if block.name == "bash": if block.name == "bash":
command = block.input.get("command", "")
for pattern in DENY_LIST: for pattern in DENY_LIST:
if pattern in command: if pattern in block.input.get("command", ""):
return "Permission denied by deny list" return "Permission denied by deny list"
if contains_destructive_command(command):
return "Potentially destructive command"
if block.name in ("read_file", "write_file", "edit_file"): if block.name in ("read_file", "write_file", "edit_file"):
path = block.input.get("path", "") path = block.input.get("path", "")
if not (WORKDIR / path).resolve().is_relative_to(WORKDIR): if not (WORKDIR / path).resolve().is_relative_to(WORKDIR):

View File

@@ -102,15 +102,12 @@ agent_loop(history)
**PreToolUse / PostToolUse**, hooks before and after tool execution. s03's permission check logic is now wrapped as a PreToolUse hook, plus a logging hook and a large-output reminder: **PreToolUse / PostToolUse**, hooks before and after tool execution. s03's permission check logic is now wrapped as a PreToolUse hook, plus a logging hook and a large-output reminder:
```python ```python
# PreToolUse: permission check (including the matcher inherited from s03) # PreToolUse: permission check (s03 logic, moved from loop to hook)
def permission_hook(block): def permission_hook(block):
if block.name == "bash": if block.name == "bash":
command = block.input.get("command", "")
for pattern in DENY_LIST: for pattern in DENY_LIST:
if pattern in command: if pattern in block.input.get("command", ""):
return "Permission denied by deny list" return "Permission denied by deny list"
if contains_destructive_command(command):
return "Potentially destructive command"
if block.name in ("read_file", "write_file", "edit_file"): if block.name in ("read_file", "write_file", "edit_file"):
path = block.input.get("path", "") path = block.input.get("path", "")
if not (WORKDIR / path).resolve().is_relative_to(WORKDIR): if not (WORKDIR / path).resolve().is_relative_to(WORKDIR):

View File

@@ -102,15 +102,12 @@ agent_loop(history)
**PreToolUse / PostToolUse**,工具执行前后的 hook。s03 的权限检查逻辑现在包装成 PreToolUse hook再加一个日志 hook 和一个大输出提醒: **PreToolUse / PostToolUse**,工具执行前后的 hook。s03 的权限检查逻辑现在包装成 PreToolUse hook再加一个日志 hook 和一个大输出提醒:
```python ```python
# PreToolUse: 权限检查(包含从 s03 沿用的 matcher # PreToolUse: 权限检查(s03 的逻辑,从循环移到 hook
def permission_hook(block): def permission_hook(block):
if block.name == "bash": if block.name == "bash":
command = block.input.get("command", "")
for pattern in DENY_LIST: for pattern in DENY_LIST:
if pattern in command: if pattern in block.input.get("command", ""):
return "Permission denied by deny list" return "Permission denied by deny list"
if contains_destructive_command(command):
return "Potentially destructive command"
if block.name in ("read_file", "write_file", "edit_file"): if block.name in ("read_file", "write_file", "edit_file"):
path = block.input.get("path", "") path = block.input.get("path", "")
if not (WORKDIR / path).resolve().is_relative_to(WORKDIR): if not (WORKDIR / path).resolve().is_relative_to(WORKDIR):

View File

@@ -22,7 +22,6 @@ Hooks run callbacks at fixed points in the agent loop:
import os import os
import re import re
import shlex
import subprocess import subprocess
from pathlib import Path from pathlib import Path
@@ -141,191 +140,14 @@ def trigger_hooks(event: str, *args):
# s03 permission check logic, now wrapped as a hook # s03 permission check logic, now wrapped as a hook
DENY_LIST = ["rm -rf /", "sudo", "shutdown", "reboot", "mkfs", "dd if="] DENY_LIST = ["rm -rf /", "sudo", "shutdown", "reboot", "mkfs", "dd if="]
SHELL_SEPARATORS = ";&|\n" DESTRUCTIVE_COMMAND_WORD = re.compile(
DESTRUCTIVE_COMMANDS = {"rm", "del"} r"(?i)(?:^|[;&|()\n])\s*(?:rm|del)(?=\s|$|[;&|()])"
SHELL_WRAPPERS = {"sh", "bash", "zsh", "dash", "cmd", "cmd.exe"}
COMMAND_PREFIXES = {"command", "call"}
CONTROL_PREFIXES = {"then", "do", "else", "!", "{"}
COMPARE_OPERATORS = {"equ", "neq", "lss", "leq", "gtr", "geq"}
MAX_COMMAND_NESTING = 16
DESTRUCTIVE_SUBCOMMAND = re.compile(
r"(?i)(?:\$\(|[<>]\(|\x60)\s*(?:rm|del)"
r"(?=\s|$|[;&|()])"
) )
DESTRUCTIVE = ["> /etc/", "chmod 777"] DESTRUCTIVE = ["rm ", "> /etc/", "chmod 777"]
def shell_tokens(command: str) -> list[str]: def contains_destructive_command(command: str) -> bool:
lexer = shlex.shlex( return bool(DESTRUCTIVE_COMMAND_WORD.search(command))
command, posix=False, punctuation_chars=SHELL_SEPARATORS
)
lexer.whitespace = " \t\r"
lexer.whitespace_split = True
lexer.commenters = ""
return list(lexer)
def shell_syntax_outside_single_quotes(command: str) -> str:
visible = []
single_quoted = double_quoted = escaped = False
for char in command:
if escaped:
visible.append(" ")
escaped = False
elif char == "\\" and not single_quoted:
visible.append(" ")
escaped = True
elif char == '"' and not single_quoted:
double_quoted = not double_quoted
visible.append(char)
elif char == "'" and not double_quoted:
single_quoted = not single_quoted
visible.append(" ")
else:
visible.append(" " if single_quoted else char)
return "".join(visible)
def unquote_shell_token(token: str) -> str:
if len(token) >= 2 and token[0] in "'\"" and token[-1] == token[0]:
return token[1:-1]
return token
def command_name(token: str) -> str:
value = unquote_shell_token(token).lstrip("@").strip("()").casefold()
if value.startswith("del/"):
return "del"
return value.replace("\\", "/").rsplit("/", 1)[-1]
def is_shell_separator(token: str) -> bool:
return bool(token) and all(char in SHELL_SEPARATORS for char in token)
def is_shell_assignment(token: str) -> bool:
name, separator, _ = unquote_shell_token(token).partition("=")
return bool(
separator
and name
and not name[0].isdigit()
and name.replace("_", "a").isalnum()
)
def segment_has_destructive_command(
tokens: list[str], depth: int = 0
) -> bool:
if depth >= MAX_COMMAND_NESTING:
return True
index = 0
while index < len(tokens) and is_shell_assignment(tokens[index]):
index += 1
if index >= len(tokens):
return False
name = command_name(tokens[index])
if name in DESTRUCTIVE_COMMANDS:
return True
if name in CONTROL_PREFIXES:
return segment_has_destructive_command(tokens[index + 1:], depth + 1)
if name == "env":
index += 1
while index < len(tokens) and (
unquote_shell_token(tokens[index]).startswith("-")
or is_shell_assignment(tokens[index])
):
index += 1
return segment_has_destructive_command(tokens[index:], depth + 1)
if name in COMMAND_PREFIXES:
index += 1
options = []
while (
index < len(tokens)
and unquote_shell_token(tokens[index]).startswith("-")
):
options.append(unquote_shell_token(tokens[index]))
index += 1
if name == "command" and any(
"v" in option.lstrip("-").casefold() for option in options
):
return False
return segment_has_destructive_command(tokens[index:], depth + 1)
if name in SHELL_WRAPPERS:
for flag_index in range(index + 1, len(tokens)):
flag = unquote_shell_token(tokens[flag_index]).casefold()
is_command_flag = (
flag in {"/c", "/k"}
if name.startswith("cmd")
else flag.startswith("-")
and not flag.startswith("--")
and "c" in flag[1:]
)
if is_command_flag:
nested = " ".join(
unquote_shell_token(token)
for token in tokens[flag_index + 1:]
)
return contains_destructive_command(nested, depth + 1)
return False
if name == "if":
index += 1
while (
index < len(tokens)
and command_name(tokens[index]) in {"/i", "not"}
):
index += 1
if index >= len(tokens):
return False
condition = command_name(tokens[index])
if condition in {"exist", "defined", "errorlevel", "cmdextversion"}:
return segment_has_destructive_command(
tokens[index + 2:], depth + 1
)
if "==" in unquote_shell_token(tokens[index]):
return segment_has_destructive_command(
tokens[index + 1:], depth + 1
)
if (
index + 2 < len(tokens)
and command_name(tokens[index + 1]) in COMPARE_OPERATORS
):
return segment_has_destructive_command(
tokens[index + 3:], depth + 1
)
return False
if name == "for":
for do_index, token in enumerate(tokens[index + 1:], index + 1):
if command_name(token) == "do":
return segment_has_destructive_command(
tokens[do_index + 1:], depth + 1
)
return False
def contains_destructive_command(command: str, depth: int = 0) -> bool:
if depth >= MAX_COMMAND_NESTING:
return True
try:
tokens = shell_tokens(command)
except ValueError:
return True
if DESTRUCTIVE_SUBCOMMAND.search(
shell_syntax_outside_single_quotes(command)
):
return True
segment = []
for token in tokens:
if is_shell_separator(token):
if segment_has_destructive_command(segment, depth):
return True
segment = []
else:
segment.append(token)
return segment_has_destructive_command(segment, depth)
def permission_hook(block): def permission_hook(block):

View File

@@ -26,7 +26,6 @@ import ast
import json import json
import os import os
import re import re
import shlex
import subprocess import subprocess
from pathlib import Path from pathlib import Path
@@ -220,191 +219,14 @@ def trigger_hooks(event: str, *args):
return None return None
DENY_LIST = ["rm -rf /", "sudo", "shutdown", "reboot", "mkfs", "dd if="] DENY_LIST = ["rm -rf /", "sudo", "shutdown", "reboot", "mkfs", "dd if="]
SHELL_SEPARATORS = ";&|\n" DESTRUCTIVE_COMMAND_WORD = re.compile(
DESTRUCTIVE_COMMANDS = {"rm", "del"} r"(?i)(?:^|[;&|()\n])\s*(?:rm|del)(?=\s|$|[;&|()])"
SHELL_WRAPPERS = {"sh", "bash", "zsh", "dash", "cmd", "cmd.exe"}
COMMAND_PREFIXES = {"command", "call"}
CONTROL_PREFIXES = {"then", "do", "else", "!", "{"}
COMPARE_OPERATORS = {"equ", "neq", "lss", "leq", "gtr", "geq"}
MAX_COMMAND_NESTING = 16
DESTRUCTIVE_SUBCOMMAND = re.compile(
r"(?i)(?:\$\(|[<>]\(|\x60)\s*(?:rm|del)"
r"(?=\s|$|[;&|()])"
) )
DESTRUCTIVE = ["> /etc/", "chmod 777"] DESTRUCTIVE = ["rm ", "> /etc/", "chmod 777"]
def shell_tokens(command: str) -> list[str]: def contains_destructive_command(command: str) -> bool:
lexer = shlex.shlex( return bool(DESTRUCTIVE_COMMAND_WORD.search(command))
command, posix=False, punctuation_chars=SHELL_SEPARATORS
)
lexer.whitespace = " \t\r"
lexer.whitespace_split = True
lexer.commenters = ""
return list(lexer)
def shell_syntax_outside_single_quotes(command: str) -> str:
visible = []
single_quoted = double_quoted = escaped = False
for char in command:
if escaped:
visible.append(" ")
escaped = False
elif char == "\\" and not single_quoted:
visible.append(" ")
escaped = True
elif char == '"' and not single_quoted:
double_quoted = not double_quoted
visible.append(char)
elif char == "'" and not double_quoted:
single_quoted = not single_quoted
visible.append(" ")
else:
visible.append(" " if single_quoted else char)
return "".join(visible)
def unquote_shell_token(token: str) -> str:
if len(token) >= 2 and token[0] in "'\"" and token[-1] == token[0]:
return token[1:-1]
return token
def command_name(token: str) -> str:
value = unquote_shell_token(token).lstrip("@").strip("()").casefold()
if value.startswith("del/"):
return "del"
return value.replace("\\", "/").rsplit("/", 1)[-1]
def is_shell_separator(token: str) -> bool:
return bool(token) and all(char in SHELL_SEPARATORS for char in token)
def is_shell_assignment(token: str) -> bool:
name, separator, _ = unquote_shell_token(token).partition("=")
return bool(
separator
and name
and not name[0].isdigit()
and name.replace("_", "a").isalnum()
)
def segment_has_destructive_command(
tokens: list[str], depth: int = 0
) -> bool:
if depth >= MAX_COMMAND_NESTING:
return True
index = 0
while index < len(tokens) and is_shell_assignment(tokens[index]):
index += 1
if index >= len(tokens):
return False
name = command_name(tokens[index])
if name in DESTRUCTIVE_COMMANDS:
return True
if name in CONTROL_PREFIXES:
return segment_has_destructive_command(tokens[index + 1:], depth + 1)
if name == "env":
index += 1
while index < len(tokens) and (
unquote_shell_token(tokens[index]).startswith("-")
or is_shell_assignment(tokens[index])
):
index += 1
return segment_has_destructive_command(tokens[index:], depth + 1)
if name in COMMAND_PREFIXES:
index += 1
options = []
while (
index < len(tokens)
and unquote_shell_token(tokens[index]).startswith("-")
):
options.append(unquote_shell_token(tokens[index]))
index += 1
if name == "command" and any(
"v" in option.lstrip("-").casefold() for option in options
):
return False
return segment_has_destructive_command(tokens[index:], depth + 1)
if name in SHELL_WRAPPERS:
for flag_index in range(index + 1, len(tokens)):
flag = unquote_shell_token(tokens[flag_index]).casefold()
is_command_flag = (
flag in {"/c", "/k"}
if name.startswith("cmd")
else flag.startswith("-")
and not flag.startswith("--")
and "c" in flag[1:]
)
if is_command_flag:
nested = " ".join(
unquote_shell_token(token)
for token in tokens[flag_index + 1:]
)
return contains_destructive_command(nested, depth + 1)
return False
if name == "if":
index += 1
while (
index < len(tokens)
and command_name(tokens[index]) in {"/i", "not"}
):
index += 1
if index >= len(tokens):
return False
condition = command_name(tokens[index])
if condition in {"exist", "defined", "errorlevel", "cmdextversion"}:
return segment_has_destructive_command(
tokens[index + 2:], depth + 1
)
if "==" in unquote_shell_token(tokens[index]):
return segment_has_destructive_command(
tokens[index + 1:], depth + 1
)
if (
index + 2 < len(tokens)
and command_name(tokens[index + 1]) in COMPARE_OPERATORS
):
return segment_has_destructive_command(
tokens[index + 3:], depth + 1
)
return False
if name == "for":
for do_index, token in enumerate(tokens[index + 1:], index + 1):
if command_name(token) == "do":
return segment_has_destructive_command(
tokens[do_index + 1:], depth + 1
)
return False
def contains_destructive_command(command: str, depth: int = 0) -> bool:
if depth >= MAX_COMMAND_NESTING:
return True
try:
tokens = shell_tokens(command)
except ValueError:
return True
if DESTRUCTIVE_SUBCOMMAND.search(
shell_syntax_outside_single_quotes(command)
):
return True
segment = []
for token in tokens:
if is_shell_separator(token):
if segment_has_destructive_command(segment, depth):
return True
segment = []
else:
segment.append(token)
return segment_has_destructive_command(segment, depth)
def permission_hook(block): def permission_hook(block):

View File

@@ -20,7 +20,6 @@ The subagent has no task tool, so it cannot delegate again.
import os import os
import re import re
import shlex
import subprocess import subprocess
from pathlib import Path from pathlib import Path
@@ -156,191 +155,14 @@ def trigger_hooks(event: str, *args):
DENY_LIST = ["rm -rf /", "sudo", "shutdown", "reboot", "mkfs", "dd if="] DENY_LIST = ["rm -rf /", "sudo", "shutdown", "reboot", "mkfs", "dd if="]
SHELL_SEPARATORS = ";&|\n" DESTRUCTIVE_COMMAND_WORD = re.compile(
DESTRUCTIVE_COMMANDS = {"rm", "del"} r"(?i)(?:^|[;&|()\n])\s*(?:rm|del)(?=\s|$|[;&|()])"
SHELL_WRAPPERS = {"sh", "bash", "zsh", "dash", "cmd", "cmd.exe"}
COMMAND_PREFIXES = {"command", "call"}
CONTROL_PREFIXES = {"then", "do", "else", "!", "{"}
COMPARE_OPERATORS = {"equ", "neq", "lss", "leq", "gtr", "geq"}
MAX_COMMAND_NESTING = 16
DESTRUCTIVE_SUBCOMMAND = re.compile(
r"(?i)(?:\$\(|[<>]\(|\x60)\s*(?:rm|del)"
r"(?=\s|$|[;&|()])"
) )
DESTRUCTIVE = ["> /etc/", "chmod 777"] DESTRUCTIVE = ["rm ", "> /etc/", "chmod 777"]
def shell_tokens(command: str) -> list[str]: def contains_destructive_command(command: str) -> bool:
lexer = shlex.shlex( return bool(DESTRUCTIVE_COMMAND_WORD.search(command))
command, posix=False, punctuation_chars=SHELL_SEPARATORS
)
lexer.whitespace = " \t\r"
lexer.whitespace_split = True
lexer.commenters = ""
return list(lexer)
def shell_syntax_outside_single_quotes(command: str) -> str:
visible = []
single_quoted = double_quoted = escaped = False
for char in command:
if escaped:
visible.append(" ")
escaped = False
elif char == "\\" and not single_quoted:
visible.append(" ")
escaped = True
elif char == '"' and not single_quoted:
double_quoted = not double_quoted
visible.append(char)
elif char == "'" and not double_quoted:
single_quoted = not single_quoted
visible.append(" ")
else:
visible.append(" " if single_quoted else char)
return "".join(visible)
def unquote_shell_token(token: str) -> str:
if len(token) >= 2 and token[0] in "'\"" and token[-1] == token[0]:
return token[1:-1]
return token
def command_name(token: str) -> str:
value = unquote_shell_token(token).lstrip("@").strip("()").casefold()
if value.startswith("del/"):
return "del"
return value.replace("\\", "/").rsplit("/", 1)[-1]
def is_shell_separator(token: str) -> bool:
return bool(token) and all(char in SHELL_SEPARATORS for char in token)
def is_shell_assignment(token: str) -> bool:
name, separator, _ = unquote_shell_token(token).partition("=")
return bool(
separator
and name
and not name[0].isdigit()
and name.replace("_", "a").isalnum()
)
def segment_has_destructive_command(
tokens: list[str], depth: int = 0
) -> bool:
if depth >= MAX_COMMAND_NESTING:
return True
index = 0
while index < len(tokens) and is_shell_assignment(tokens[index]):
index += 1
if index >= len(tokens):
return False
name = command_name(tokens[index])
if name in DESTRUCTIVE_COMMANDS:
return True
if name in CONTROL_PREFIXES:
return segment_has_destructive_command(tokens[index + 1:], depth + 1)
if name == "env":
index += 1
while index < len(tokens) and (
unquote_shell_token(tokens[index]).startswith("-")
or is_shell_assignment(tokens[index])
):
index += 1
return segment_has_destructive_command(tokens[index:], depth + 1)
if name in COMMAND_PREFIXES:
index += 1
options = []
while (
index < len(tokens)
and unquote_shell_token(tokens[index]).startswith("-")
):
options.append(unquote_shell_token(tokens[index]))
index += 1
if name == "command" and any(
"v" in option.lstrip("-").casefold() for option in options
):
return False
return segment_has_destructive_command(tokens[index:], depth + 1)
if name in SHELL_WRAPPERS:
for flag_index in range(index + 1, len(tokens)):
flag = unquote_shell_token(tokens[flag_index]).casefold()
is_command_flag = (
flag in {"/c", "/k"}
if name.startswith("cmd")
else flag.startswith("-")
and not flag.startswith("--")
and "c" in flag[1:]
)
if is_command_flag:
nested = " ".join(
unquote_shell_token(token)
for token in tokens[flag_index + 1:]
)
return contains_destructive_command(nested, depth + 1)
return False
if name == "if":
index += 1
while (
index < len(tokens)
and command_name(tokens[index]) in {"/i", "not"}
):
index += 1
if index >= len(tokens):
return False
condition = command_name(tokens[index])
if condition in {"exist", "defined", "errorlevel", "cmdextversion"}:
return segment_has_destructive_command(
tokens[index + 2:], depth + 1
)
if "==" in unquote_shell_token(tokens[index]):
return segment_has_destructive_command(
tokens[index + 1:], depth + 1
)
if (
index + 2 < len(tokens)
and command_name(tokens[index + 1]) in COMPARE_OPERATORS
):
return segment_has_destructive_command(
tokens[index + 3:], depth + 1
)
return False
if name == "for":
for do_index, token in enumerate(tokens[index + 1:], index + 1):
if command_name(token) == "do":
return segment_has_destructive_command(
tokens[do_index + 1:], depth + 1
)
return False
def contains_destructive_command(command: str, depth: int = 0) -> bool:
if depth >= MAX_COMMAND_NESTING:
return True
try:
tokens = shell_tokens(command)
except ValueError:
return True
if DESTRUCTIVE_SUBCOMMAND.search(
shell_syntax_outside_single_quotes(command)
):
return True
segment = []
for token in tokens:
if is_shell_separator(token):
if segment_has_destructive_command(segment, depth):
return True
segment = []
else:
segment.append(token)
return segment_has_destructive_command(segment, depth)
def permission_hook(block): def permission_hook(block):

View File

@@ -21,7 +21,6 @@ The model loads the full SKILL.md only when it calls load_skill.
import os import os
import re import re
import shlex
import subprocess import subprocess
from pathlib import Path from pathlib import Path
@@ -243,191 +242,14 @@ def trigger_hooks(event: str, *args):
DENY_LIST = ["rm -rf /", "sudo", "shutdown", "reboot", "mkfs", "dd if="] DENY_LIST = ["rm -rf /", "sudo", "shutdown", "reboot", "mkfs", "dd if="]
SHELL_SEPARATORS = ";&|\n" DESTRUCTIVE_COMMAND_WORD = re.compile(
DESTRUCTIVE_COMMANDS = {"rm", "del"} r"(?i)(?:^|[;&|()\n])\s*(?:rm|del)(?=\s|$|[;&|()])"
SHELL_WRAPPERS = {"sh", "bash", "zsh", "dash", "cmd", "cmd.exe"}
COMMAND_PREFIXES = {"command", "call"}
CONTROL_PREFIXES = {"then", "do", "else", "!", "{"}
COMPARE_OPERATORS = {"equ", "neq", "lss", "leq", "gtr", "geq"}
MAX_COMMAND_NESTING = 16
DESTRUCTIVE_SUBCOMMAND = re.compile(
r"(?i)(?:\$\(|[<>]\(|\x60)\s*(?:rm|del)"
r"(?=\s|$|[;&|()])"
) )
DESTRUCTIVE = ["> /etc/", "chmod 777"] DESTRUCTIVE = ["rm ", "> /etc/", "chmod 777"]
def shell_tokens(command: str) -> list[str]: def contains_destructive_command(command: str) -> bool:
lexer = shlex.shlex( return bool(DESTRUCTIVE_COMMAND_WORD.search(command))
command, posix=False, punctuation_chars=SHELL_SEPARATORS
)
lexer.whitespace = " \t\r"
lexer.whitespace_split = True
lexer.commenters = ""
return list(lexer)
def shell_syntax_outside_single_quotes(command: str) -> str:
visible = []
single_quoted = double_quoted = escaped = False
for char in command:
if escaped:
visible.append(" ")
escaped = False
elif char == "\\" and not single_quoted:
visible.append(" ")
escaped = True
elif char == '"' and not single_quoted:
double_quoted = not double_quoted
visible.append(char)
elif char == "'" and not double_quoted:
single_quoted = not single_quoted
visible.append(" ")
else:
visible.append(" " if single_quoted else char)
return "".join(visible)
def unquote_shell_token(token: str) -> str:
if len(token) >= 2 and token[0] in "'\"" and token[-1] == token[0]:
return token[1:-1]
return token
def command_name(token: str) -> str:
value = unquote_shell_token(token).lstrip("@").strip("()").casefold()
if value.startswith("del/"):
return "del"
return value.replace("\\", "/").rsplit("/", 1)[-1]
def is_shell_separator(token: str) -> bool:
return bool(token) and all(char in SHELL_SEPARATORS for char in token)
def is_shell_assignment(token: str) -> bool:
name, separator, _ = unquote_shell_token(token).partition("=")
return bool(
separator
and name
and not name[0].isdigit()
and name.replace("_", "a").isalnum()
)
def segment_has_destructive_command(
tokens: list[str], depth: int = 0
) -> bool:
if depth >= MAX_COMMAND_NESTING:
return True
index = 0
while index < len(tokens) and is_shell_assignment(tokens[index]):
index += 1
if index >= len(tokens):
return False
name = command_name(tokens[index])
if name in DESTRUCTIVE_COMMANDS:
return True
if name in CONTROL_PREFIXES:
return segment_has_destructive_command(tokens[index + 1:], depth + 1)
if name == "env":
index += 1
while index < len(tokens) and (
unquote_shell_token(tokens[index]).startswith("-")
or is_shell_assignment(tokens[index])
):
index += 1
return segment_has_destructive_command(tokens[index:], depth + 1)
if name in COMMAND_PREFIXES:
index += 1
options = []
while (
index < len(tokens)
and unquote_shell_token(tokens[index]).startswith("-")
):
options.append(unquote_shell_token(tokens[index]))
index += 1
if name == "command" and any(
"v" in option.lstrip("-").casefold() for option in options
):
return False
return segment_has_destructive_command(tokens[index:], depth + 1)
if name in SHELL_WRAPPERS:
for flag_index in range(index + 1, len(tokens)):
flag = unquote_shell_token(tokens[flag_index]).casefold()
is_command_flag = (
flag in {"/c", "/k"}
if name.startswith("cmd")
else flag.startswith("-")
and not flag.startswith("--")
and "c" in flag[1:]
)
if is_command_flag:
nested = " ".join(
unquote_shell_token(token)
for token in tokens[flag_index + 1:]
)
return contains_destructive_command(nested, depth + 1)
return False
if name == "if":
index += 1
while (
index < len(tokens)
and command_name(tokens[index]) in {"/i", "not"}
):
index += 1
if index >= len(tokens):
return False
condition = command_name(tokens[index])
if condition in {"exist", "defined", "errorlevel", "cmdextversion"}:
return segment_has_destructive_command(
tokens[index + 2:], depth + 1
)
if "==" in unquote_shell_token(tokens[index]):
return segment_has_destructive_command(
tokens[index + 1:], depth + 1
)
if (
index + 2 < len(tokens)
and command_name(tokens[index + 1]) in COMPARE_OPERATORS
):
return segment_has_destructive_command(
tokens[index + 3:], depth + 1
)
return False
if name == "for":
for do_index, token in enumerate(tokens[index + 1:], index + 1):
if command_name(token) == "do":
return segment_has_destructive_command(
tokens[do_index + 1:], depth + 1
)
return False
def contains_destructive_command(command: str, depth: int = 0) -> bool:
if depth >= MAX_COMMAND_NESTING:
return True
try:
tokens = shell_tokens(command)
except ValueError:
return True
if DESTRUCTIVE_SUBCOMMAND.search(
shell_syntax_outside_single_quotes(command)
):
return True
segment = []
for token in tokens:
if is_shell_separator(token):
if segment_has_destructive_command(segment, depth):
return True
segment = []
else:
segment.append(token)
return segment_has_destructive_command(segment, depth)
def permission_hook(block): def permission_hook(block):

View File

@@ -40,7 +40,6 @@ import glob
import json import json
import os import os
import re import re
import shlex
import subprocess import subprocess
import uuid import uuid
from pathlib import Path from pathlib import Path
@@ -179,191 +178,14 @@ def trigger_hooks(event: str, *args):
DENY_LIST = ["rm -rf /", "sudo", "shutdown", "reboot", "mkfs", "dd if="] DENY_LIST = ["rm -rf /", "sudo", "shutdown", "reboot", "mkfs", "dd if="]
SHELL_SEPARATORS = ";&|\n" DESTRUCTIVE_COMMAND_WORD = re.compile(
DESTRUCTIVE_COMMANDS = {"rm", "del"} r"(?i)(?:^|[;&|()\n])\s*(?:rm|del)(?=\s|$|[;&|()])"
SHELL_WRAPPERS = {"sh", "bash", "zsh", "dash", "cmd", "cmd.exe"}
COMMAND_PREFIXES = {"command", "call"}
CONTROL_PREFIXES = {"then", "do", "else", "!", "{"}
COMPARE_OPERATORS = {"equ", "neq", "lss", "leq", "gtr", "geq"}
MAX_COMMAND_NESTING = 16
DESTRUCTIVE_SUBCOMMAND = re.compile(
r"(?i)(?:\$\(|[<>]\(|\x60)\s*(?:rm|del)"
r"(?=\s|$|[;&|()])"
) )
DESTRUCTIVE = ["> /etc/", "chmod 777"] DESTRUCTIVE = ["rm ", "> /etc/", "chmod 777"]
def shell_tokens(command: str) -> list[str]: def contains_destructive_command(command: str) -> bool:
lexer = shlex.shlex( return bool(DESTRUCTIVE_COMMAND_WORD.search(command))
command, posix=False, punctuation_chars=SHELL_SEPARATORS
)
lexer.whitespace = " \t\r"
lexer.whitespace_split = True
lexer.commenters = ""
return list(lexer)
def shell_syntax_outside_single_quotes(command: str) -> str:
visible = []
single_quoted = double_quoted = escaped = False
for char in command:
if escaped:
visible.append(" ")
escaped = False
elif char == "\\" and not single_quoted:
visible.append(" ")
escaped = True
elif char == '"' and not single_quoted:
double_quoted = not double_quoted
visible.append(char)
elif char == "'" and not double_quoted:
single_quoted = not single_quoted
visible.append(" ")
else:
visible.append(" " if single_quoted else char)
return "".join(visible)
def unquote_shell_token(token: str) -> str:
if len(token) >= 2 and token[0] in "'\"" and token[-1] == token[0]:
return token[1:-1]
return token
def command_name(token: str) -> str:
value = unquote_shell_token(token).lstrip("@").strip("()").casefold()
if value.startswith("del/"):
return "del"
return value.replace("\\", "/").rsplit("/", 1)[-1]
def is_shell_separator(token: str) -> bool:
return bool(token) and all(char in SHELL_SEPARATORS for char in token)
def is_shell_assignment(token: str) -> bool:
name, separator, _ = unquote_shell_token(token).partition("=")
return bool(
separator
and name
and not name[0].isdigit()
and name.replace("_", "a").isalnum()
)
def segment_has_destructive_command(
tokens: list[str], depth: int = 0
) -> bool:
if depth >= MAX_COMMAND_NESTING:
return True
index = 0
while index < len(tokens) and is_shell_assignment(tokens[index]):
index += 1
if index >= len(tokens):
return False
name = command_name(tokens[index])
if name in DESTRUCTIVE_COMMANDS:
return True
if name in CONTROL_PREFIXES:
return segment_has_destructive_command(tokens[index + 1:], depth + 1)
if name == "env":
index += 1
while index < len(tokens) and (
unquote_shell_token(tokens[index]).startswith("-")
or is_shell_assignment(tokens[index])
):
index += 1
return segment_has_destructive_command(tokens[index:], depth + 1)
if name in COMMAND_PREFIXES:
index += 1
options = []
while (
index < len(tokens)
and unquote_shell_token(tokens[index]).startswith("-")
):
options.append(unquote_shell_token(tokens[index]))
index += 1
if name == "command" and any(
"v" in option.lstrip("-").casefold() for option in options
):
return False
return segment_has_destructive_command(tokens[index:], depth + 1)
if name in SHELL_WRAPPERS:
for flag_index in range(index + 1, len(tokens)):
flag = unquote_shell_token(tokens[flag_index]).casefold()
is_command_flag = (
flag in {"/c", "/k"}
if name.startswith("cmd")
else flag.startswith("-")
and not flag.startswith("--")
and "c" in flag[1:]
)
if is_command_flag:
nested = " ".join(
unquote_shell_token(token)
for token in tokens[flag_index + 1:]
)
return contains_destructive_command(nested, depth + 1)
return False
if name == "if":
index += 1
while (
index < len(tokens)
and command_name(tokens[index]) in {"/i", "not"}
):
index += 1
if index >= len(tokens):
return False
condition = command_name(tokens[index])
if condition in {"exist", "defined", "errorlevel", "cmdextversion"}:
return segment_has_destructive_command(
tokens[index + 2:], depth + 1
)
if "==" in unquote_shell_token(tokens[index]):
return segment_has_destructive_command(
tokens[index + 1:], depth + 1
)
if (
index + 2 < len(tokens)
and command_name(tokens[index + 1]) in COMPARE_OPERATORS
):
return segment_has_destructive_command(
tokens[index + 3:], depth + 1
)
return False
if name == "for":
for do_index, token in enumerate(tokens[index + 1:], index + 1):
if command_name(token) == "do":
return segment_has_destructive_command(
tokens[do_index + 1:], depth + 1
)
return False
def contains_destructive_command(command: str, depth: int = 0) -> bool:
if depth >= MAX_COMMAND_NESTING:
return True
try:
tokens = shell_tokens(command)
except ValueError:
return True
if DESTRUCTIVE_SUBCOMMAND.search(
shell_syntax_outside_single_quotes(command)
):
return True
segment = []
for token in tokens:
if is_shell_separator(token):
if segment_has_destructive_command(segment, depth):
return True
segment = []
else:
segment.append(token)
return segment_has_destructive_command(segment, depth)
def permission_hook(block): def permission_hook(block):

View File

@@ -12,7 +12,6 @@ import glob
import json import json
import os import os
import re import re
import shlex
import subprocess import subprocess
from pathlib import Path from pathlib import Path
@@ -635,191 +634,14 @@ def trigger_hooks(event: str, *args):
return None return None
DENY_LIST = ["rm -rf /", "sudo", "shutdown", "reboot", "mkfs", "dd if="] DENY_LIST = ["rm -rf /", "sudo", "shutdown", "reboot", "mkfs", "dd if="]
SHELL_SEPARATORS = ";&|\n" DESTRUCTIVE_COMMAND_WORD = re.compile(
DESTRUCTIVE_COMMANDS = {"rm", "del"} r"(?i)(?:^|[;&|()\n])\s*(?:rm|del)(?=\s|$|[;&|()])"
SHELL_WRAPPERS = {"sh", "bash", "zsh", "dash", "cmd", "cmd.exe"}
COMMAND_PREFIXES = {"command", "call"}
CONTROL_PREFIXES = {"then", "do", "else", "!", "{"}
COMPARE_OPERATORS = {"equ", "neq", "lss", "leq", "gtr", "geq"}
MAX_COMMAND_NESTING = 16
DESTRUCTIVE_SUBCOMMAND = re.compile(
r"(?i)(?:\$\(|[<>]\(|\x60)\s*(?:rm|del)"
r"(?=\s|$|[;&|()])"
) )
DESTRUCTIVE = ["> /etc/", "chmod 777"] DESTRUCTIVE = ["rm ", "> /etc/", "chmod 777"]
def shell_tokens(command: str) -> list[str]: def contains_destructive_command(command: str) -> bool:
lexer = shlex.shlex( return bool(DESTRUCTIVE_COMMAND_WORD.search(command))
command, posix=False, punctuation_chars=SHELL_SEPARATORS
)
lexer.whitespace = " \t\r"
lexer.whitespace_split = True
lexer.commenters = ""
return list(lexer)
def shell_syntax_outside_single_quotes(command: str) -> str:
visible = []
single_quoted = double_quoted = escaped = False
for char in command:
if escaped:
visible.append(" ")
escaped = False
elif char == "\\" and not single_quoted:
visible.append(" ")
escaped = True
elif char == '"' and not single_quoted:
double_quoted = not double_quoted
visible.append(char)
elif char == "'" and not double_quoted:
single_quoted = not single_quoted
visible.append(" ")
else:
visible.append(" " if single_quoted else char)
return "".join(visible)
def unquote_shell_token(token: str) -> str:
if len(token) >= 2 and token[0] in "'\"" and token[-1] == token[0]:
return token[1:-1]
return token
def command_name(token: str) -> str:
value = unquote_shell_token(token).lstrip("@").strip("()").casefold()
if value.startswith("del/"):
return "del"
return value.replace("\\", "/").rsplit("/", 1)[-1]
def is_shell_separator(token: str) -> bool:
return bool(token) and all(char in SHELL_SEPARATORS for char in token)
def is_shell_assignment(token: str) -> bool:
name, separator, _ = unquote_shell_token(token).partition("=")
return bool(
separator
and name
and not name[0].isdigit()
and name.replace("_", "a").isalnum()
)
def segment_has_destructive_command(
tokens: list[str], depth: int = 0
) -> bool:
if depth >= MAX_COMMAND_NESTING:
return True
index = 0
while index < len(tokens) and is_shell_assignment(tokens[index]):
index += 1
if index >= len(tokens):
return False
name = command_name(tokens[index])
if name in DESTRUCTIVE_COMMANDS:
return True
if name in CONTROL_PREFIXES:
return segment_has_destructive_command(tokens[index + 1:], depth + 1)
if name == "env":
index += 1
while index < len(tokens) and (
unquote_shell_token(tokens[index]).startswith("-")
or is_shell_assignment(tokens[index])
):
index += 1
return segment_has_destructive_command(tokens[index:], depth + 1)
if name in COMMAND_PREFIXES:
index += 1
options = []
while (
index < len(tokens)
and unquote_shell_token(tokens[index]).startswith("-")
):
options.append(unquote_shell_token(tokens[index]))
index += 1
if name == "command" and any(
"v" in option.lstrip("-").casefold() for option in options
):
return False
return segment_has_destructive_command(tokens[index:], depth + 1)
if name in SHELL_WRAPPERS:
for flag_index in range(index + 1, len(tokens)):
flag = unquote_shell_token(tokens[flag_index]).casefold()
is_command_flag = (
flag in {"/c", "/k"}
if name.startswith("cmd")
else flag.startswith("-")
and not flag.startswith("--")
and "c" in flag[1:]
)
if is_command_flag:
nested = " ".join(
unquote_shell_token(token)
for token in tokens[flag_index + 1:]
)
return contains_destructive_command(nested, depth + 1)
return False
if name == "if":
index += 1
while (
index < len(tokens)
and command_name(tokens[index]) in {"/i", "not"}
):
index += 1
if index >= len(tokens):
return False
condition = command_name(tokens[index])
if condition in {"exist", "defined", "errorlevel", "cmdextversion"}:
return segment_has_destructive_command(
tokens[index + 2:], depth + 1
)
if "==" in unquote_shell_token(tokens[index]):
return segment_has_destructive_command(
tokens[index + 1:], depth + 1
)
if (
index + 2 < len(tokens)
and command_name(tokens[index + 1]) in COMPARE_OPERATORS
):
return segment_has_destructive_command(
tokens[index + 3:], depth + 1
)
return False
if name == "for":
for do_index, token in enumerate(tokens[index + 1:], index + 1):
if command_name(token) == "do":
return segment_has_destructive_command(
tokens[do_index + 1:], depth + 1
)
return False
def contains_destructive_command(command: str, depth: int = 0) -> bool:
if depth >= MAX_COMMAND_NESTING:
return True
try:
tokens = shell_tokens(command)
except ValueError:
return True
if DESTRUCTIVE_SUBCOMMAND.search(
shell_syntax_outside_single_quotes(command)
):
return True
segment = []
for token in tokens:
if is_shell_separator(token):
if segment_has_destructive_command(segment, depth):
return True
segment = []
else:
segment.append(token)
return segment_has_destructive_command(segment, depth)
def permission_hook(block): def permission_hook(block):

View File

@@ -25,7 +25,6 @@ import glob
import json import json
import os import os
import re import re
import shlex
import secrets import secrets
import subprocess import subprocess
from dataclasses import asdict, dataclass from dataclasses import asdict, dataclass
@@ -445,191 +444,14 @@ def trigger_hooks(event: str, *args):
DENY_LIST = ["rm -rf /", "sudo", "shutdown", "reboot", "mkfs", "dd if="] DENY_LIST = ["rm -rf /", "sudo", "shutdown", "reboot", "mkfs", "dd if="]
SHELL_SEPARATORS = ";&|\n" DESTRUCTIVE_COMMAND_WORD = re.compile(
DESTRUCTIVE_COMMANDS = {"rm", "del"} r"(?i)(?:^|[;&|()\n])\s*(?:rm|del)(?=\s|$|[;&|()])"
SHELL_WRAPPERS = {"sh", "bash", "zsh", "dash", "cmd", "cmd.exe"}
COMMAND_PREFIXES = {"command", "call"}
CONTROL_PREFIXES = {"then", "do", "else", "!", "{"}
COMPARE_OPERATORS = {"equ", "neq", "lss", "leq", "gtr", "geq"}
MAX_COMMAND_NESTING = 16
DESTRUCTIVE_SUBCOMMAND = re.compile(
r"(?i)(?:\$\(|[<>]\(|\x60)\s*(?:rm|del)"
r"(?=\s|$|[;&|()])"
) )
DESTRUCTIVE = ["> /etc/", "chmod 777"] DESTRUCTIVE = ["rm ", "> /etc/", "chmod 777"]
def shell_tokens(command: str) -> list[str]: def contains_destructive_command(command: str) -> bool:
lexer = shlex.shlex( return bool(DESTRUCTIVE_COMMAND_WORD.search(command))
command, posix=False, punctuation_chars=SHELL_SEPARATORS
)
lexer.whitespace = " \t\r"
lexer.whitespace_split = True
lexer.commenters = ""
return list(lexer)
def shell_syntax_outside_single_quotes(command: str) -> str:
visible = []
single_quoted = double_quoted = escaped = False
for char in command:
if escaped:
visible.append(" ")
escaped = False
elif char == "\\" and not single_quoted:
visible.append(" ")
escaped = True
elif char == '"' and not single_quoted:
double_quoted = not double_quoted
visible.append(char)
elif char == "'" and not double_quoted:
single_quoted = not single_quoted
visible.append(" ")
else:
visible.append(" " if single_quoted else char)
return "".join(visible)
def unquote_shell_token(token: str) -> str:
if len(token) >= 2 and token[0] in "'\"" and token[-1] == token[0]:
return token[1:-1]
return token
def command_name(token: str) -> str:
value = unquote_shell_token(token).lstrip("@").strip("()").casefold()
if value.startswith("del/"):
return "del"
return value.replace("\\", "/").rsplit("/", 1)[-1]
def is_shell_separator(token: str) -> bool:
return bool(token) and all(char in SHELL_SEPARATORS for char in token)
def is_shell_assignment(token: str) -> bool:
name, separator, _ = unquote_shell_token(token).partition("=")
return bool(
separator
and name
and not name[0].isdigit()
and name.replace("_", "a").isalnum()
)
def segment_has_destructive_command(
tokens: list[str], depth: int = 0
) -> bool:
if depth >= MAX_COMMAND_NESTING:
return True
index = 0
while index < len(tokens) and is_shell_assignment(tokens[index]):
index += 1
if index >= len(tokens):
return False
name = command_name(tokens[index])
if name in DESTRUCTIVE_COMMANDS:
return True
if name in CONTROL_PREFIXES:
return segment_has_destructive_command(tokens[index + 1:], depth + 1)
if name == "env":
index += 1
while index < len(tokens) and (
unquote_shell_token(tokens[index]).startswith("-")
or is_shell_assignment(tokens[index])
):
index += 1
return segment_has_destructive_command(tokens[index:], depth + 1)
if name in COMMAND_PREFIXES:
index += 1
options = []
while (
index < len(tokens)
and unquote_shell_token(tokens[index]).startswith("-")
):
options.append(unquote_shell_token(tokens[index]))
index += 1
if name == "command" and any(
"v" in option.lstrip("-").casefold() for option in options
):
return False
return segment_has_destructive_command(tokens[index:], depth + 1)
if name in SHELL_WRAPPERS:
for flag_index in range(index + 1, len(tokens)):
flag = unquote_shell_token(tokens[flag_index]).casefold()
is_command_flag = (
flag in {"/c", "/k"}
if name.startswith("cmd")
else flag.startswith("-")
and not flag.startswith("--")
and "c" in flag[1:]
)
if is_command_flag:
nested = " ".join(
unquote_shell_token(token)
for token in tokens[flag_index + 1:]
)
return contains_destructive_command(nested, depth + 1)
return False
if name == "if":
index += 1
while (
index < len(tokens)
and command_name(tokens[index]) in {"/i", "not"}
):
index += 1
if index >= len(tokens):
return False
condition = command_name(tokens[index])
if condition in {"exist", "defined", "errorlevel", "cmdextversion"}:
return segment_has_destructive_command(
tokens[index + 2:], depth + 1
)
if "==" in unquote_shell_token(tokens[index]):
return segment_has_destructive_command(
tokens[index + 1:], depth + 1
)
if (
index + 2 < len(tokens)
and command_name(tokens[index + 1]) in COMPARE_OPERATORS
):
return segment_has_destructive_command(
tokens[index + 3:], depth + 1
)
return False
if name == "for":
for do_index, token in enumerate(tokens[index + 1:], index + 1):
if command_name(token) == "do":
return segment_has_destructive_command(
tokens[do_index + 1:], depth + 1
)
return False
def contains_destructive_command(command: str, depth: int = 0) -> bool:
if depth >= MAX_COMMAND_NESTING:
return True
try:
tokens = shell_tokens(command)
except ValueError:
return True
if DESTRUCTIVE_SUBCOMMAND.search(
shell_syntax_outside_single_quotes(command)
):
return True
segment = []
for token in tokens:
if is_shell_separator(token):
if segment_has_destructive_command(segment, depth):
return True
segment = []
else:
segment.append(token)
return segment_has_destructive_command(segment, depth)
def permission_hook(block): def permission_hook(block):

View File

@@ -15,7 +15,6 @@ import atexit
import glob import glob
import os import os
import re import re
import shlex
import signal import signal
import subprocess import subprocess
import threading import threading
@@ -227,191 +226,14 @@ def trigger_hooks(event: str, *args):
DENY_LIST = ["rm -rf /", "sudo", "shutdown", "reboot", "mkfs", "dd if="] DENY_LIST = ["rm -rf /", "sudo", "shutdown", "reboot", "mkfs", "dd if="]
SHELL_SEPARATORS = ";&|\n" DESTRUCTIVE_COMMAND_WORD = re.compile(
DESTRUCTIVE_COMMANDS = {"rm", "del"} r"(?i)(?:^|[;&|()\n])\s*(?:rm|del)(?=\s|$|[;&|()])"
SHELL_WRAPPERS = {"sh", "bash", "zsh", "dash", "cmd", "cmd.exe"}
COMMAND_PREFIXES = {"command", "call"}
CONTROL_PREFIXES = {"then", "do", "else", "!", "{"}
COMPARE_OPERATORS = {"equ", "neq", "lss", "leq", "gtr", "geq"}
MAX_COMMAND_NESTING = 16
DESTRUCTIVE_SUBCOMMAND = re.compile(
r"(?i)(?:\$\(|[<>]\(|\x60)\s*(?:rm|del)"
r"(?=\s|$|[;&|()])"
) )
DESTRUCTIVE = ["> /etc/", "chmod 777"] DESTRUCTIVE = ["rm ", "> /etc/", "chmod 777"]
def shell_tokens(command: str) -> list[str]: def contains_destructive_command(command: str) -> bool:
lexer = shlex.shlex( return bool(DESTRUCTIVE_COMMAND_WORD.search(command))
command, posix=False, punctuation_chars=SHELL_SEPARATORS
)
lexer.whitespace = " \t\r"
lexer.whitespace_split = True
lexer.commenters = ""
return list(lexer)
def shell_syntax_outside_single_quotes(command: str) -> str:
visible = []
single_quoted = double_quoted = escaped = False
for char in command:
if escaped:
visible.append(" ")
escaped = False
elif char == "\\" and not single_quoted:
visible.append(" ")
escaped = True
elif char == '"' and not single_quoted:
double_quoted = not double_quoted
visible.append(char)
elif char == "'" and not double_quoted:
single_quoted = not single_quoted
visible.append(" ")
else:
visible.append(" " if single_quoted else char)
return "".join(visible)
def unquote_shell_token(token: str) -> str:
if len(token) >= 2 and token[0] in "'\"" and token[-1] == token[0]:
return token[1:-1]
return token
def command_name(token: str) -> str:
value = unquote_shell_token(token).lstrip("@").strip("()").casefold()
if value.startswith("del/"):
return "del"
return value.replace("\\", "/").rsplit("/", 1)[-1]
def is_shell_separator(token: str) -> bool:
return bool(token) and all(char in SHELL_SEPARATORS for char in token)
def is_shell_assignment(token: str) -> bool:
name, separator, _ = unquote_shell_token(token).partition("=")
return bool(
separator
and name
and not name[0].isdigit()
and name.replace("_", "a").isalnum()
)
def segment_has_destructive_command(
tokens: list[str], depth: int = 0
) -> bool:
if depth >= MAX_COMMAND_NESTING:
return True
index = 0
while index < len(tokens) and is_shell_assignment(tokens[index]):
index += 1
if index >= len(tokens):
return False
name = command_name(tokens[index])
if name in DESTRUCTIVE_COMMANDS:
return True
if name in CONTROL_PREFIXES:
return segment_has_destructive_command(tokens[index + 1:], depth + 1)
if name == "env":
index += 1
while index < len(tokens) and (
unquote_shell_token(tokens[index]).startswith("-")
or is_shell_assignment(tokens[index])
):
index += 1
return segment_has_destructive_command(tokens[index:], depth + 1)
if name in COMMAND_PREFIXES:
index += 1
options = []
while (
index < len(tokens)
and unquote_shell_token(tokens[index]).startswith("-")
):
options.append(unquote_shell_token(tokens[index]))
index += 1
if name == "command" and any(
"v" in option.lstrip("-").casefold() for option in options
):
return False
return segment_has_destructive_command(tokens[index:], depth + 1)
if name in SHELL_WRAPPERS:
for flag_index in range(index + 1, len(tokens)):
flag = unquote_shell_token(tokens[flag_index]).casefold()
is_command_flag = (
flag in {"/c", "/k"}
if name.startswith("cmd")
else flag.startswith("-")
and not flag.startswith("--")
and "c" in flag[1:]
)
if is_command_flag:
nested = " ".join(
unquote_shell_token(token)
for token in tokens[flag_index + 1:]
)
return contains_destructive_command(nested, depth + 1)
return False
if name == "if":
index += 1
while (
index < len(tokens)
and command_name(tokens[index]) in {"/i", "not"}
):
index += 1
if index >= len(tokens):
return False
condition = command_name(tokens[index])
if condition in {"exist", "defined", "errorlevel", "cmdextversion"}:
return segment_has_destructive_command(
tokens[index + 2:], depth + 1
)
if "==" in unquote_shell_token(tokens[index]):
return segment_has_destructive_command(
tokens[index + 1:], depth + 1
)
if (
index + 2 < len(tokens)
and command_name(tokens[index + 1]) in COMPARE_OPERATORS
):
return segment_has_destructive_command(
tokens[index + 3:], depth + 1
)
return False
if name == "for":
for do_index, token in enumerate(tokens[index + 1:], index + 1):
if command_name(token) == "do":
return segment_has_destructive_command(
tokens[do_index + 1:], depth + 1
)
return False
def contains_destructive_command(command: str, depth: int = 0) -> bool:
if depth >= MAX_COMMAND_NESTING:
return True
try:
tokens = shell_tokens(command)
except ValueError:
return True
if DESTRUCTIVE_SUBCOMMAND.search(
shell_syntax_outside_single_quotes(command)
):
return True
segment = []
for token in tokens:
if is_shell_separator(token):
if segment_has_destructive_command(segment, depth):
return True
segment = []
else:
segment.append(token)
return segment_has_destructive_command(segment, depth)
def permission_hook(block): def permission_hook(block):

View File

@@ -17,7 +17,6 @@ import glob
import json import json
import os import os
import re import re
import shlex
import secrets import secrets
import subprocess import subprocess
import threading import threading
@@ -175,191 +174,14 @@ def trigger_hooks(event: str, *args):
DENY_LIST = ["rm -rf /", "sudo", "shutdown", "reboot", "mkfs", "dd if="] DENY_LIST = ["rm -rf /", "sudo", "shutdown", "reboot", "mkfs", "dd if="]
SHELL_SEPARATORS = ";&|\n" DESTRUCTIVE_COMMAND_WORD = re.compile(
DESTRUCTIVE_COMMANDS = {"rm", "del"} r"(?i)(?:^|[;&|()\n])\s*(?:rm|del)(?=\s|$|[;&|()])"
SHELL_WRAPPERS = {"sh", "bash", "zsh", "dash", "cmd", "cmd.exe"}
COMMAND_PREFIXES = {"command", "call"}
CONTROL_PREFIXES = {"then", "do", "else", "!", "{"}
COMPARE_OPERATORS = {"equ", "neq", "lss", "leq", "gtr", "geq"}
MAX_COMMAND_NESTING = 16
DESTRUCTIVE_SUBCOMMAND = re.compile(
r"(?i)(?:\$\(|[<>]\(|\x60)\s*(?:rm|del)"
r"(?=\s|$|[;&|()])"
) )
DESTRUCTIVE = ["> /etc/", "chmod 777"] DESTRUCTIVE = ["rm ", "> /etc/", "chmod 777"]
def shell_tokens(command: str) -> list[str]: def contains_destructive_command(command: str) -> bool:
lexer = shlex.shlex( return bool(DESTRUCTIVE_COMMAND_WORD.search(command))
command, posix=False, punctuation_chars=SHELL_SEPARATORS
)
lexer.whitespace = " \t\r"
lexer.whitespace_split = True
lexer.commenters = ""
return list(lexer)
def shell_syntax_outside_single_quotes(command: str) -> str:
visible = []
single_quoted = double_quoted = escaped = False
for char in command:
if escaped:
visible.append(" ")
escaped = False
elif char == "\\" and not single_quoted:
visible.append(" ")
escaped = True
elif char == '"' and not single_quoted:
double_quoted = not double_quoted
visible.append(char)
elif char == "'" and not double_quoted:
single_quoted = not single_quoted
visible.append(" ")
else:
visible.append(" " if single_quoted else char)
return "".join(visible)
def unquote_shell_token(token: str) -> str:
if len(token) >= 2 and token[0] in "'\"" and token[-1] == token[0]:
return token[1:-1]
return token
def command_name(token: str) -> str:
value = unquote_shell_token(token).lstrip("@").strip("()").casefold()
if value.startswith("del/"):
return "del"
return value.replace("\\", "/").rsplit("/", 1)[-1]
def is_shell_separator(token: str) -> bool:
return bool(token) and all(char in SHELL_SEPARATORS for char in token)
def is_shell_assignment(token: str) -> bool:
name, separator, _ = unquote_shell_token(token).partition("=")
return bool(
separator
and name
and not name[0].isdigit()
and name.replace("_", "a").isalnum()
)
def segment_has_destructive_command(
tokens: list[str], depth: int = 0
) -> bool:
if depth >= MAX_COMMAND_NESTING:
return True
index = 0
while index < len(tokens) and is_shell_assignment(tokens[index]):
index += 1
if index >= len(tokens):
return False
name = command_name(tokens[index])
if name in DESTRUCTIVE_COMMANDS:
return True
if name in CONTROL_PREFIXES:
return segment_has_destructive_command(tokens[index + 1:], depth + 1)
if name == "env":
index += 1
while index < len(tokens) and (
unquote_shell_token(tokens[index]).startswith("-")
or is_shell_assignment(tokens[index])
):
index += 1
return segment_has_destructive_command(tokens[index:], depth + 1)
if name in COMMAND_PREFIXES:
index += 1
options = []
while (
index < len(tokens)
and unquote_shell_token(tokens[index]).startswith("-")
):
options.append(unquote_shell_token(tokens[index]))
index += 1
if name == "command" and any(
"v" in option.lstrip("-").casefold() for option in options
):
return False
return segment_has_destructive_command(tokens[index:], depth + 1)
if name in SHELL_WRAPPERS:
for flag_index in range(index + 1, len(tokens)):
flag = unquote_shell_token(tokens[flag_index]).casefold()
is_command_flag = (
flag in {"/c", "/k"}
if name.startswith("cmd")
else flag.startswith("-")
and not flag.startswith("--")
and "c" in flag[1:]
)
if is_command_flag:
nested = " ".join(
unquote_shell_token(token)
for token in tokens[flag_index + 1:]
)
return contains_destructive_command(nested, depth + 1)
return False
if name == "if":
index += 1
while (
index < len(tokens)
and command_name(tokens[index]) in {"/i", "not"}
):
index += 1
if index >= len(tokens):
return False
condition = command_name(tokens[index])
if condition in {"exist", "defined", "errorlevel", "cmdextversion"}:
return segment_has_destructive_command(
tokens[index + 2:], depth + 1
)
if "==" in unquote_shell_token(tokens[index]):
return segment_has_destructive_command(
tokens[index + 1:], depth + 1
)
if (
index + 2 < len(tokens)
and command_name(tokens[index + 1]) in COMPARE_OPERATORS
):
return segment_has_destructive_command(
tokens[index + 3:], depth + 1
)
return False
if name == "for":
for do_index, token in enumerate(tokens[index + 1:], index + 1):
if command_name(token) == "do":
return segment_has_destructive_command(
tokens[do_index + 1:], depth + 1
)
return False
def contains_destructive_command(command: str, depth: int = 0) -> bool:
if depth >= MAX_COMMAND_NESTING:
return True
try:
tokens = shell_tokens(command)
except ValueError:
return True
if DESTRUCTIVE_SUBCOMMAND.search(
shell_syntax_outside_single_quotes(command)
):
return True
segment = []
for token in tokens:
if is_shell_separator(token):
if segment_has_destructive_command(segment, depth):
return True
segment = []
else:
segment.append(token)
return segment_has_destructive_command(segment, depth)
def request_permission(block, reason: str) -> str | None: def request_permission(block, reason: str) -> str | None:

View File

@@ -25,7 +25,6 @@ import json
import os import os
import random import random
import re import re
import shlex
import secrets import secrets
import select import select
import subprocess import subprocess
@@ -1664,191 +1663,14 @@ TOOL_HANDLERS = {
HOOKS = {"UserPromptSubmit": [], "PreToolUse": [], "PostToolUse": [], "Stop": []} HOOKS = {"UserPromptSubmit": [], "PreToolUse": [], "PostToolUse": [], "Stop": []}
DENY_LIST = ["rm -rf /", "sudo", "shutdown", "reboot", "mkfs", "dd if="] DENY_LIST = ["rm -rf /", "sudo", "shutdown", "reboot", "mkfs", "dd if="]
SHELL_SEPARATORS = ";&|\n" DESTRUCTIVE_COMMAND_WORD = re.compile(
DESTRUCTIVE_COMMANDS = {"rm", "del"} r"(?i)(?:^|[;&|()\n])\s*(?:rm|del)(?=\s|$|[;&|()])"
SHELL_WRAPPERS = {"sh", "bash", "zsh", "dash", "cmd", "cmd.exe"}
COMMAND_PREFIXES = {"command", "call"}
CONTROL_PREFIXES = {"then", "do", "else", "!", "{"}
COMPARE_OPERATORS = {"equ", "neq", "lss", "leq", "gtr", "geq"}
MAX_COMMAND_NESTING = 16
DESTRUCTIVE_SUBCOMMAND = re.compile(
r"(?i)(?:\$\(|[<>]\(|\x60)\s*(?:rm|del)"
r"(?=\s|$|[;&|()])"
) )
DESTRUCTIVE = ["> /etc/", "chmod 777"] DESTRUCTIVE = ["rm ", "> /etc/", "chmod 777"]
def shell_tokens(command: str) -> list[str]: def contains_destructive_command(command: str) -> bool:
lexer = shlex.shlex( return bool(DESTRUCTIVE_COMMAND_WORD.search(command))
command, posix=False, punctuation_chars=SHELL_SEPARATORS
)
lexer.whitespace = " \t\r"
lexer.whitespace_split = True
lexer.commenters = ""
return list(lexer)
def shell_syntax_outside_single_quotes(command: str) -> str:
visible = []
single_quoted = double_quoted = escaped = False
for char in command:
if escaped:
visible.append(" ")
escaped = False
elif char == "\\" and not single_quoted:
visible.append(" ")
escaped = True
elif char == '"' and not single_quoted:
double_quoted = not double_quoted
visible.append(char)
elif char == "'" and not double_quoted:
single_quoted = not single_quoted
visible.append(" ")
else:
visible.append(" " if single_quoted else char)
return "".join(visible)
def unquote_shell_token(token: str) -> str:
if len(token) >= 2 and token[0] in "'\"" and token[-1] == token[0]:
return token[1:-1]
return token
def command_name(token: str) -> str:
value = unquote_shell_token(token).lstrip("@").strip("()").casefold()
if value.startswith("del/"):
return "del"
return value.replace("\\", "/").rsplit("/", 1)[-1]
def is_shell_separator(token: str) -> bool:
return bool(token) and all(char in SHELL_SEPARATORS for char in token)
def is_shell_assignment(token: str) -> bool:
name, separator, _ = unquote_shell_token(token).partition("=")
return bool(
separator
and name
and not name[0].isdigit()
and name.replace("_", "a").isalnum()
)
def segment_has_destructive_command(
tokens: list[str], depth: int = 0
) -> bool:
if depth >= MAX_COMMAND_NESTING:
return True
index = 0
while index < len(tokens) and is_shell_assignment(tokens[index]):
index += 1
if index >= len(tokens):
return False
name = command_name(tokens[index])
if name in DESTRUCTIVE_COMMANDS:
return True
if name in CONTROL_PREFIXES:
return segment_has_destructive_command(tokens[index + 1:], depth + 1)
if name == "env":
index += 1
while index < len(tokens) and (
unquote_shell_token(tokens[index]).startswith("-")
or is_shell_assignment(tokens[index])
):
index += 1
return segment_has_destructive_command(tokens[index:], depth + 1)
if name in COMMAND_PREFIXES:
index += 1
options = []
while (
index < len(tokens)
and unquote_shell_token(tokens[index]).startswith("-")
):
options.append(unquote_shell_token(tokens[index]))
index += 1
if name == "command" and any(
"v" in option.lstrip("-").casefold() for option in options
):
return False
return segment_has_destructive_command(tokens[index:], depth + 1)
if name in SHELL_WRAPPERS:
for flag_index in range(index + 1, len(tokens)):
flag = unquote_shell_token(tokens[flag_index]).casefold()
is_command_flag = (
flag in {"/c", "/k"}
if name.startswith("cmd")
else flag.startswith("-")
and not flag.startswith("--")
and "c" in flag[1:]
)
if is_command_flag:
nested = " ".join(
unquote_shell_token(token)
for token in tokens[flag_index + 1:]
)
return contains_destructive_command(nested, depth + 1)
return False
if name == "if":
index += 1
while (
index < len(tokens)
and command_name(tokens[index]) in {"/i", "not"}
):
index += 1
if index >= len(tokens):
return False
condition = command_name(tokens[index])
if condition in {"exist", "defined", "errorlevel", "cmdextversion"}:
return segment_has_destructive_command(
tokens[index + 2:], depth + 1
)
if "==" in unquote_shell_token(tokens[index]):
return segment_has_destructive_command(
tokens[index + 1:], depth + 1
)
if (
index + 2 < len(tokens)
and command_name(tokens[index + 1]) in COMPARE_OPERATORS
):
return segment_has_destructive_command(
tokens[index + 3:], depth + 1
)
return False
if name == "for":
for do_index, token in enumerate(tokens[index + 1:], index + 1):
if command_name(token) == "do":
return segment_has_destructive_command(
tokens[do_index + 1:], depth + 1
)
return False
def contains_destructive_command(command: str, depth: int = 0) -> bool:
if depth >= MAX_COMMAND_NESTING:
return True
try:
tokens = shell_tokens(command)
except ValueError:
return True
if DESTRUCTIVE_SUBCOMMAND.search(
shell_syntax_outside_single_quotes(command)
):
return True
segment = []
for token in tokens:
if is_shell_separator(token):
if segment_has_destructive_command(segment, depth):
return True
segment = []
else:
segment.append(token)
return segment_has_destructive_command(segment, depth)
def register_hook(event: str, callback): def register_hook(event: str, callback):

View File

@@ -25,7 +25,6 @@ Need: pip install anthropic python-dotenv + .env with ANTHROPIC_API_KEY
import glob import glob
import os import os
import re import re
import shlex
import subprocess import subprocess
from pathlib import Path from pathlib import Path
@@ -370,191 +369,14 @@ def assemble_system_prompt() -> str:
HOOKS = {"UserPromptSubmit": [], "PreToolUse": [], "PostToolUse": [], "Stop": []} HOOKS = {"UserPromptSubmit": [], "PreToolUse": [], "PostToolUse": [], "Stop": []}
DENY_LIST = ["rm -rf /", "sudo", "shutdown", "reboot", "mkfs", "dd if="] DENY_LIST = ["rm -rf /", "sudo", "shutdown", "reboot", "mkfs", "dd if="]
SHELL_SEPARATORS = ";&|\n" DESTRUCTIVE_COMMAND_WORD = re.compile(
DESTRUCTIVE_COMMANDS = {"rm", "del"} r"(?i)(?:^|[;&|()\n])\s*(?:rm|del)(?=\s|$|[;&|()])"
SHELL_WRAPPERS = {"sh", "bash", "zsh", "dash", "cmd", "cmd.exe"}
COMMAND_PREFIXES = {"command", "call"}
CONTROL_PREFIXES = {"then", "do", "else", "!", "{"}
COMPARE_OPERATORS = {"equ", "neq", "lss", "leq", "gtr", "geq"}
MAX_COMMAND_NESTING = 16
DESTRUCTIVE_SUBCOMMAND = re.compile(
r"(?i)(?:\$\(|[<>]\(|\x60)\s*(?:rm|del)"
r"(?=\s|$|[;&|()])"
) )
DESTRUCTIVE = ["> /etc/", "chmod 777"] DESTRUCTIVE = ["rm ", "> /etc/", "chmod 777"]
def shell_tokens(command: str) -> list[str]: def contains_destructive_command(command: str) -> bool:
lexer = shlex.shlex( return bool(DESTRUCTIVE_COMMAND_WORD.search(command))
command, posix=False, punctuation_chars=SHELL_SEPARATORS
)
lexer.whitespace = " \t\r"
lexer.whitespace_split = True
lexer.commenters = ""
return list(lexer)
def shell_syntax_outside_single_quotes(command: str) -> str:
visible = []
single_quoted = double_quoted = escaped = False
for char in command:
if escaped:
visible.append(" ")
escaped = False
elif char == "\\" and not single_quoted:
visible.append(" ")
escaped = True
elif char == '"' and not single_quoted:
double_quoted = not double_quoted
visible.append(char)
elif char == "'" and not double_quoted:
single_quoted = not single_quoted
visible.append(" ")
else:
visible.append(" " if single_quoted else char)
return "".join(visible)
def unquote_shell_token(token: str) -> str:
if len(token) >= 2 and token[0] in "'\"" and token[-1] == token[0]:
return token[1:-1]
return token
def command_name(token: str) -> str:
value = unquote_shell_token(token).lstrip("@").strip("()").casefold()
if value.startswith("del/"):
return "del"
return value.replace("\\", "/").rsplit("/", 1)[-1]
def is_shell_separator(token: str) -> bool:
return bool(token) and all(char in SHELL_SEPARATORS for char in token)
def is_shell_assignment(token: str) -> bool:
name, separator, _ = unquote_shell_token(token).partition("=")
return bool(
separator
and name
and not name[0].isdigit()
and name.replace("_", "a").isalnum()
)
def segment_has_destructive_command(
tokens: list[str], depth: int = 0
) -> bool:
if depth >= MAX_COMMAND_NESTING:
return True
index = 0
while index < len(tokens) and is_shell_assignment(tokens[index]):
index += 1
if index >= len(tokens):
return False
name = command_name(tokens[index])
if name in DESTRUCTIVE_COMMANDS:
return True
if name in CONTROL_PREFIXES:
return segment_has_destructive_command(tokens[index + 1:], depth + 1)
if name == "env":
index += 1
while index < len(tokens) and (
unquote_shell_token(tokens[index]).startswith("-")
or is_shell_assignment(tokens[index])
):
index += 1
return segment_has_destructive_command(tokens[index:], depth + 1)
if name in COMMAND_PREFIXES:
index += 1
options = []
while (
index < len(tokens)
and unquote_shell_token(tokens[index]).startswith("-")
):
options.append(unquote_shell_token(tokens[index]))
index += 1
if name == "command" and any(
"v" in option.lstrip("-").casefold() for option in options
):
return False
return segment_has_destructive_command(tokens[index:], depth + 1)
if name in SHELL_WRAPPERS:
for flag_index in range(index + 1, len(tokens)):
flag = unquote_shell_token(tokens[flag_index]).casefold()
is_command_flag = (
flag in {"/c", "/k"}
if name.startswith("cmd")
else flag.startswith("-")
and not flag.startswith("--")
and "c" in flag[1:]
)
if is_command_flag:
nested = " ".join(
unquote_shell_token(token)
for token in tokens[flag_index + 1:]
)
return contains_destructive_command(nested, depth + 1)
return False
if name == "if":
index += 1
while (
index < len(tokens)
and command_name(tokens[index]) in {"/i", "not"}
):
index += 1
if index >= len(tokens):
return False
condition = command_name(tokens[index])
if condition in {"exist", "defined", "errorlevel", "cmdextversion"}:
return segment_has_destructive_command(
tokens[index + 2:], depth + 1
)
if "==" in unquote_shell_token(tokens[index]):
return segment_has_destructive_command(
tokens[index + 1:], depth + 1
)
if (
index + 2 < len(tokens)
and command_name(tokens[index + 1]) in COMPARE_OPERATORS
):
return segment_has_destructive_command(
tokens[index + 3:], depth + 1
)
return False
if name == "for":
for do_index, token in enumerate(tokens[index + 1:], index + 1):
if command_name(token) == "do":
return segment_has_destructive_command(
tokens[do_index + 1:], depth + 1
)
return False
def contains_destructive_command(command: str, depth: int = 0) -> bool:
if depth >= MAX_COMMAND_NESTING:
return True
try:
tokens = shell_tokens(command)
except ValueError:
return True
if DESTRUCTIVE_SUBCOMMAND.search(
shell_syntax_outside_single_quotes(command)
):
return True
segment = []
for token in tokens:
if is_shell_separator(token):
if segment_has_destructive_command(segment, depth):
return True
segment = []
else:
segment.append(token)
return segment_has_destructive_command(segment, depth)
def register_hook(event: str, callback): def register_hook(event: str, callback):

View File

@@ -32,7 +32,6 @@ import glob
import json import json
import os import os
import re import re
import shlex
import subprocess import subprocess
import sys import sys
import time import time
@@ -47,191 +46,14 @@ DEFAULT_STOP_HOOK_BLOCK_CAP = 8
MAX_GOAL_LENGTH = 4000 MAX_GOAL_LENGTH = 4000
CLEAR_ALIASES = {"clear", "stop", "off", "reset", "none", "cancel"} CLEAR_ALIASES = {"clear", "stop", "off", "reset", "none", "cancel"}
DENY_LIST = ["rm -rf /", "sudo", "shutdown", "reboot", "mkfs", "dd if="] DENY_LIST = ["rm -rf /", "sudo", "shutdown", "reboot", "mkfs", "dd if="]
SHELL_SEPARATORS = ";&|\n" DESTRUCTIVE_COMMAND_WORD = re.compile(
DESTRUCTIVE_COMMANDS = {"rm", "del"} r"(?i)(?:^|[;&|()\n])\s*(?:rm|del)(?=\s|$|[;&|()])"
SHELL_WRAPPERS = {"sh", "bash", "zsh", "dash", "cmd", "cmd.exe"}
COMMAND_PREFIXES = {"command", "call"}
CONTROL_PREFIXES = {"then", "do", "else", "!", "{"}
COMPARE_OPERATORS = {"equ", "neq", "lss", "leq", "gtr", "geq"}
MAX_COMMAND_NESTING = 16
DESTRUCTIVE_SUBCOMMAND = re.compile(
r"(?i)(?:\$\(|[<>]\(|\x60)\s*(?:rm|del)"
r"(?=\s|$|[;&|()])"
) )
DESTRUCTIVE = ["> /etc/", "chmod 777"] DESTRUCTIVE = ["rm ", "> /etc/", "chmod 777"]
def shell_tokens(command: str) -> list[str]: def contains_destructive_command(command: str) -> bool:
lexer = shlex.shlex( return bool(DESTRUCTIVE_COMMAND_WORD.search(command))
command, posix=False, punctuation_chars=SHELL_SEPARATORS
)
lexer.whitespace = " \t\r"
lexer.whitespace_split = True
lexer.commenters = ""
return list(lexer)
def shell_syntax_outside_single_quotes(command: str) -> str:
visible = []
single_quoted = double_quoted = escaped = False
for char in command:
if escaped:
visible.append(" ")
escaped = False
elif char == "\\" and not single_quoted:
visible.append(" ")
escaped = True
elif char == '"' and not single_quoted:
double_quoted = not double_quoted
visible.append(char)
elif char == "'" and not double_quoted:
single_quoted = not single_quoted
visible.append(" ")
else:
visible.append(" " if single_quoted else char)
return "".join(visible)
def unquote_shell_token(token: str) -> str:
if len(token) >= 2 and token[0] in "'\"" and token[-1] == token[0]:
return token[1:-1]
return token
def command_name(token: str) -> str:
value = unquote_shell_token(token).lstrip("@").strip("()").casefold()
if value.startswith("del/"):
return "del"
return value.replace("\\", "/").rsplit("/", 1)[-1]
def is_shell_separator(token: str) -> bool:
return bool(token) and all(char in SHELL_SEPARATORS for char in token)
def is_shell_assignment(token: str) -> bool:
name, separator, _ = unquote_shell_token(token).partition("=")
return bool(
separator
and name
and not name[0].isdigit()
and name.replace("_", "a").isalnum()
)
def segment_has_destructive_command(
tokens: list[str], depth: int = 0
) -> bool:
if depth >= MAX_COMMAND_NESTING:
return True
index = 0
while index < len(tokens) and is_shell_assignment(tokens[index]):
index += 1
if index >= len(tokens):
return False
name = command_name(tokens[index])
if name in DESTRUCTIVE_COMMANDS:
return True
if name in CONTROL_PREFIXES:
return segment_has_destructive_command(tokens[index + 1:], depth + 1)
if name == "env":
index += 1
while index < len(tokens) and (
unquote_shell_token(tokens[index]).startswith("-")
or is_shell_assignment(tokens[index])
):
index += 1
return segment_has_destructive_command(tokens[index:], depth + 1)
if name in COMMAND_PREFIXES:
index += 1
options = []
while (
index < len(tokens)
and unquote_shell_token(tokens[index]).startswith("-")
):
options.append(unquote_shell_token(tokens[index]))
index += 1
if name == "command" and any(
"v" in option.lstrip("-").casefold() for option in options
):
return False
return segment_has_destructive_command(tokens[index:], depth + 1)
if name in SHELL_WRAPPERS:
for flag_index in range(index + 1, len(tokens)):
flag = unquote_shell_token(tokens[flag_index]).casefold()
is_command_flag = (
flag in {"/c", "/k"}
if name.startswith("cmd")
else flag.startswith("-")
and not flag.startswith("--")
and "c" in flag[1:]
)
if is_command_flag:
nested = " ".join(
unquote_shell_token(token)
for token in tokens[flag_index + 1:]
)
return contains_destructive_command(nested, depth + 1)
return False
if name == "if":
index += 1
while (
index < len(tokens)
and command_name(tokens[index]) in {"/i", "not"}
):
index += 1
if index >= len(tokens):
return False
condition = command_name(tokens[index])
if condition in {"exist", "defined", "errorlevel", "cmdextversion"}:
return segment_has_destructive_command(
tokens[index + 2:], depth + 1
)
if "==" in unquote_shell_token(tokens[index]):
return segment_has_destructive_command(
tokens[index + 1:], depth + 1
)
if (
index + 2 < len(tokens)
and command_name(tokens[index + 1]) in COMPARE_OPERATORS
):
return segment_has_destructive_command(
tokens[index + 3:], depth + 1
)
return False
if name == "for":
for do_index, token in enumerate(tokens[index + 1:], index + 1):
if command_name(token) == "do":
return segment_has_destructive_command(
tokens[do_index + 1:], depth + 1
)
return False
def contains_destructive_command(command: str, depth: int = 0) -> bool:
if depth >= MAX_COMMAND_NESTING:
return True
try:
tokens = shell_tokens(command)
except ValueError:
return True
if DESTRUCTIVE_SUBCOMMAND.search(
shell_syntax_outside_single_quotes(command)
):
return True
segment = []
for token in tokens:
if is_shell_separator(token):
if segment_has_destructive_command(segment, depth):
return True
segment = []
else:
segment.append(token)
return segment_has_destructive_command(segment, depth)
class GoalError(Exception): class GoalError(Exception):

View File

@@ -90,6 +90,8 @@ def permission_result(lesson, block):
COMMAND_CASES = ( COMMAND_CASES = (
("rm file.txt", True), ("rm file.txt", True),
("/usr/bin/rm file.txt", True),
("command rm file.txt", True),
("DEL file.txt", True), ("DEL file.txt", True),
("echo ready; rm file.txt", True), ("echo ready; rm file.txt", True),
("echo ready && del file.txt", True), ("echo ready && del file.txt", True),
@@ -98,38 +100,10 @@ COMMAND_CASES = (
("echo ready & rm file.txt", True), ("echo ready & rm file.txt", True),
("(del file.txt)", True), ("(del file.txt)", True),
("rm; echo ready", True), ("rm; echo ready", True),
("if exist test.txt del test.txt", True),
("if not exist other.txt DEL test.txt", True),
("cmd /c del test.txt", True),
('cmd /c "if exist test.txt del test.txt"', True),
('for %F in (test.txt) do del "%F"', True),
("@DEL test.txt", True),
("call del test.txt", True),
("command rm test.txt", True),
("env FLAG=1 rm test.txt", True),
("sh -c 'rm test.txt'", True),
("bash -lc 'rm test.txt'", True),
("{ rm test.txt; }", True),
("/usr/bin/rm test.txt", True),
("del/q test.txt", True),
("echo $(rm test.txt)", True),
('echo "$(rm test.txt)"', True),
("echo `rm test.txt`", True),
("cat <(rm test.txt)", True),
("then " * 20 + "echo safe", True),
("echo 'unterminated", True),
("model list", False), ("model list", False),
("delimiter file.txt", False), ("delimiter file.txt", False),
("echo del file.txt", False), ("echo del file.txt", False),
("echo; delimiter file.txt", False), ("echo; delimiter file.txt", False),
("not-rm file.txt", False),
('echo "safe; del test.txt"', False),
("echo 'safe (rm test.txt)'", False),
('echo ";" del test.txt', False),
('if "del"=="safe" echo okay', False),
('printf "rm test.txt\\n"', False),
("command -v rm", False),
("echo '$(rm test.txt)'", False),
) )

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long