Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 36 additions & 2 deletions s03_permission/README.ja.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,9 +54,40 @@ def check_deny_list(command: str) -> str | None:
return None
```

**ゲート 2**:ルールマッチング — 「いつユーザーに聞くべきか」を記述する。各ルールはツールとチェック条件を指定する。
**ゲート 2**:ルールマッチング — 「いつユーザーに聞くべきか」を記述する。各ルールはツールとチェック条件を指定する。shell ルールは quoted separator を構文として扱わずに command を分割し、直接 command、`if`/`for` の本体、`cmd /c` や `sh -c` の payload など、実際に command が実行される位置を確認する。

ここでの matcher は一般的な command 形式を説明するためのものであり、完全な shell parser や security sandbox ではない。

```python
import shlex

SHELL_SEPARATORS = ";&|\n"
DESTRUCTIVE_COMMANDS = {"rm", "del"}

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:
try:
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 = [
{
"tools": ["read_file", "write_file", "edit_file"],
Expand All @@ -65,7 +96,9 @@ PERMISSION_RULES = [
},
{
"tools": ["bash"],
"check": lambda args: any(kw in args.get("command", "") for kw in ["rm ", "> /etc/", "chmod 777"]),
"check": lambda args: contains_destructive_command(args.get("command", "")) or any(
kw in args.get("command", "") for kw in ["> /etc/", "chmod 777"]
),
"message": "Potentially destructive command",
},
]
Expand Down Expand Up @@ -141,6 +174,7 @@ python s03_permission/code.py
2. `Delete the file test.txt`(bash + rm でゲート 2 が発動)
3. `What files are in the current directory?`(読み取り専用、すべて通過)
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"` は発動しない。

観察のポイント:どの操作がそのまま通過するか? どれに確認が必要か? どれが即座に拒否されるか?

Expand Down
38 changes: 36 additions & 2 deletions s03_permission/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,9 +54,40 @@ def check_deny_list(command: str) -> str | None:
return None
```

**Gate 2**: Rule matching — describes "when to ask the user." Each rule specifies a tool and a check condition.
**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.

This is a teaching-level matcher for common command forms, not a complete shell parser or security sandbox.

```python
import shlex

SHELL_SEPARATORS = ";&|\n"
DESTRUCTIVE_COMMANDS = {"rm", "del"}

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:
try:
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 = [
{
"tools": ["read_file", "write_file", "edit_file"],
Expand All @@ -65,7 +96,9 @@ PERMISSION_RULES = [
},
{
"tools": ["bash"],
"check": lambda args: any(kw in args.get("command", "") for kw in ["rm ", "> /etc/", "chmod 777"]),
"check": lambda args: contains_destructive_command(args.get("command", "")) or any(
kw in args.get("command", "") for kw in ["> /etc/", "chmod 777"]
),
"message": "Potentially destructive command",
},
]
Expand Down Expand Up @@ -141,6 +174,7 @@ Try these prompts:
2. `Delete the file test.txt` (bash + rm triggers Gate 2)
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)
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.

What to watch for: Which operations pass through? Which need your confirmation? Which are denied outright?

Expand Down
38 changes: 36 additions & 2 deletions s03_permission/README.zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,9 +54,40 @@ def check_deny_list(command: str) -> str | None:
return None
```

**闸门 2**负责规则匹配,用来描述"什么时候需要问用户"。每条规则指定工具和检查条件。
**闸门 2**负责规则匹配,用来描述"什么时候需要问用户"。每条规则指定工具和检查条件。shell 规则会先拆分命令,但不把引号内的分隔符当成语法,再检查直接命令、`if`/`for` 主体以及 `cmd /c`、`sh -c` 等真正执行命令的位置。

这里的 matcher 只用于讲解常见命令形式,并不是完整的 shell parser 或安全沙箱。

```python
import shlex

SHELL_SEPARATORS = ";&|\n"
DESTRUCTIVE_COMMANDS = {"rm", "del"}

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:
try:
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 = [
{
"tools": ["read_file", "write_file", "edit_file"],
Expand All @@ -65,7 +96,9 @@ PERMISSION_RULES = [
},
{
"tools": ["bash"],
"check": lambda args: any(kw in args.get("command", "") for kw in ["rm ", "> /etc/", "chmod 777"]),
"check": lambda args: contains_destructive_command(args.get("command", "")) or any(
kw in args.get("command", "") for kw in ["> /etc/", "chmod 777"]
),
"message": "Potentially destructive command",
},
]
Expand Down Expand Up @@ -141,6 +174,7 @@ python s03_permission/code.py
2. `Delete the file test.txt`(bash + rm 会触发闸门 2)
3. `What files are in the current directory?`(只读,全部通过)
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"` 不会。

观察重点:哪些操作直接通过?哪些需要你确认?哪些被直接拒绝?

Expand Down
191 changes: 190 additions & 1 deletion s03_permission/code.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@
"""

import os
import re
import shlex
import subprocess
from pathlib import Path

Expand Down Expand Up @@ -152,12 +154,199 @@ def check_deny_list(command: str) -> str | None:


# Gate 2: Rule matching - context-dependent checks
SHELL_SEPARATORS = ";&|\n"
DESTRUCTIVE_COMMANDS = {"rm", "del"}
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]:
lexer = shlex.shlex(
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 = [
{"tools": ["read_file", "write_file", "edit_file"],
"check": lambda args: not (WORKDIR / args.get("path", "")).resolve().is_relative_to(WORKDIR),
"message": "Writing outside workspace"},
{"tools": ["bash"],
"check": lambda args: any(kw in args.get("command", "") for kw in ["rm ", "> /etc/", "chmod 777"]),
"check": lambda args: contains_destructive_command(args.get("command", "")) or
any(kw in args.get("command", "") for kw in ["> /etc/", "chmod 777"]),
"message": "Potentially destructive command"},
]

Expand Down
Loading