mirror of
https://github.com/shareAI-lab/analysis_claude_code.git
synced 2026-09-21 12:53:37 +08:00
Merge pull request #548 from mameikagou/fix-s03-del-command-448
fix(s03): match Windows del as a command word
This commit is contained in:
@@ -57,6 +57,15 @@ def check_deny_list(command: str) -> str | None:
|
|||||||
**ゲート 2**:ルールマッチング — 「いつユーザーに聞くべきか」を記述する。各ルールはツールとチェック条件を指定する。
|
**ゲート 2**:ルールマッチング — 「いつユーザーに聞くべきか」を記述する。各ルールはツールとチェック条件を指定する。
|
||||||
|
|
||||||
```python
|
```python
|
||||||
|
import re
|
||||||
|
|
||||||
|
DESTRUCTIVE_COMMAND_WORD = re.compile(
|
||||||
|
r"(?i)(?:^|[;&|()\n])\s*(?:rm|del)(?=\s|$|[;&|()])"
|
||||||
|
)
|
||||||
|
|
||||||
|
def contains_destructive_command(command: str) -> bool:
|
||||||
|
return bool(DESTRUCTIVE_COMMAND_WORD.search(command))
|
||||||
|
|
||||||
PERMISSION_RULES = [
|
PERMISSION_RULES = [
|
||||||
{
|
{
|
||||||
"tools": ["read_file", "write_file", "edit_file"],
|
"tools": ["read_file", "write_file", "edit_file"],
|
||||||
@@ -65,7 +74,9 @@ PERMISSION_RULES = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"tools": ["bash"],
|
"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 ["rm ", "> /etc/", "chmod 777"]
|
||||||
|
),
|
||||||
"message": "Potentially destructive command",
|
"message": "Potentially destructive command",
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
@@ -141,6 +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` がゲート 2 を発動し、`model`、`delimiter`、`echo del test.txt` は発動しない。
|
||||||
|
|
||||||
観察のポイント:どの操作がそのまま通過するか? どれに確認が必要か? どれが即座に拒否されるか?
|
観察のポイント:どの操作がそのまま通過するか? どれに確認が必要か? どれが即座に拒否されるか?
|
||||||
|
|
||||||
|
|||||||
@@ -57,6 +57,15 @@ def check_deny_list(command: str) -> str | 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.
|
||||||
|
|
||||||
```python
|
```python
|
||||||
|
import re
|
||||||
|
|
||||||
|
DESTRUCTIVE_COMMAND_WORD = re.compile(
|
||||||
|
r"(?i)(?:^|[;&|()\n])\s*(?:rm|del)(?=\s|$|[;&|()])"
|
||||||
|
)
|
||||||
|
|
||||||
|
def contains_destructive_command(command: str) -> bool:
|
||||||
|
return bool(DESTRUCTIVE_COMMAND_WORD.search(command))
|
||||||
|
|
||||||
PERMISSION_RULES = [
|
PERMISSION_RULES = [
|
||||||
{
|
{
|
||||||
"tools": ["read_file", "write_file", "edit_file"],
|
"tools": ["read_file", "write_file", "edit_file"],
|
||||||
@@ -65,7 +74,9 @@ PERMISSION_RULES = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"tools": ["bash"],
|
"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 ["rm ", "> /etc/", "chmod 777"]
|
||||||
|
),
|
||||||
"message": "Potentially destructive command",
|
"message": "Potentially destructive command",
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
@@ -141,6 +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` 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?
|
||||||
|
|
||||||
|
|||||||
@@ -57,6 +57,15 @@ def check_deny_list(command: str) -> str | None:
|
|||||||
**闸门 2**负责规则匹配,用来描述"什么时候需要问用户"。每条规则指定工具和检查条件。
|
**闸门 2**负责规则匹配,用来描述"什么时候需要问用户"。每条规则指定工具和检查条件。
|
||||||
|
|
||||||
```python
|
```python
|
||||||
|
import re
|
||||||
|
|
||||||
|
DESTRUCTIVE_COMMAND_WORD = re.compile(
|
||||||
|
r"(?i)(?:^|[;&|()\n])\s*(?:rm|del)(?=\s|$|[;&|()])"
|
||||||
|
)
|
||||||
|
|
||||||
|
def contains_destructive_command(command: str) -> bool:
|
||||||
|
return bool(DESTRUCTIVE_COMMAND_WORD.search(command))
|
||||||
|
|
||||||
PERMISSION_RULES = [
|
PERMISSION_RULES = [
|
||||||
{
|
{
|
||||||
"tools": ["read_file", "write_file", "edit_file"],
|
"tools": ["read_file", "write_file", "edit_file"],
|
||||||
@@ -65,7 +74,9 @@ PERMISSION_RULES = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"tools": ["bash"],
|
"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 ["rm ", "> /etc/", "chmod 777"]
|
||||||
|
),
|
||||||
"message": "Potentially destructive command",
|
"message": "Potentially destructive command",
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
@@ -141,6 +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` 会触发闸门 2,而 `model`、`delimiter` 和 `echo del test.txt` 不会。
|
||||||
|
|
||||||
观察重点:哪些操作直接通过?哪些需要你确认?哪些被直接拒绝?
|
观察重点:哪些操作直接通过?哪些需要你确认?哪些被直接拒绝?
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ Builds on s02 (multi-tool). Usage:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -152,12 +153,22 @@ def check_deny_list(command: str) -> str | None:
|
|||||||
|
|
||||||
|
|
||||||
# Gate 2: Rule matching - context-dependent checks
|
# Gate 2: Rule matching - context-dependent checks
|
||||||
|
DESTRUCTIVE_COMMAND_WORD = re.compile(
|
||||||
|
r"(?i)(?:^|[;&|()\n])\s*(?:rm|del)(?=\s|$|[;&|()])"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def contains_destructive_command(command: str) -> bool:
|
||||||
|
return bool(DESTRUCTIVE_COMMAND_WORD.search(command))
|
||||||
|
|
||||||
|
|
||||||
PERMISSION_RULES = [
|
PERMISSION_RULES = [
|
||||||
{"tools": ["read_file", "write_file", "edit_file"],
|
{"tools": ["read_file", "write_file", "edit_file"],
|
||||||
"check": lambda args: not (WORKDIR / args.get("path", "")).resolve().is_relative_to(WORKDIR),
|
"check": lambda args: not (WORKDIR / args.get("path", "")).resolve().is_relative_to(WORKDIR),
|
||||||
"message": "Writing outside workspace"},
|
"message": "Writing outside workspace"},
|
||||||
{"tools": ["bash"],
|
{"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 ["rm ", "> /etc/", "chmod 777"]),
|
||||||
"message": "Potentially destructive command"},
|
"message": "Potentially destructive command"},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ Hooks run callbacks at fixed points in the agent loop:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -139,22 +140,32 @@ 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="]
|
||||||
|
DESTRUCTIVE_COMMAND_WORD = re.compile(
|
||||||
|
r"(?i)(?:^|[;&|()\n])\s*(?:rm|del)(?=\s|$|[;&|()])"
|
||||||
|
)
|
||||||
DESTRUCTIVE = ["rm ", "> /etc/", "chmod 777"]
|
DESTRUCTIVE = ["rm ", "> /etc/", "chmod 777"]
|
||||||
|
|
||||||
|
|
||||||
|
def contains_destructive_command(command: str) -> bool:
|
||||||
|
return bool(DESTRUCTIVE_COMMAND_WORD.search(command))
|
||||||
|
|
||||||
|
|
||||||
def permission_hook(block):
|
def permission_hook(block):
|
||||||
"""PreToolUse: s03 check_permission() logic moved here."""
|
"""PreToolUse: s03 check_permission() logic moved here."""
|
||||||
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 block.input.get("command", ""):
|
if pattern in command:
|
||||||
print(f"\n\033[31m[blocked] '{pattern}'\033[0m")
|
print(f"\n\033[31m[blocked] '{pattern}'\033[0m")
|
||||||
return "Permission denied by deny list"
|
return "Permission denied by deny list"
|
||||||
for kw in DESTRUCTIVE:
|
if contains_destructive_command(command) or any(
|
||||||
if kw in block.input.get("command", ""):
|
kw in command for kw in DESTRUCTIVE
|
||||||
print(f"\n\033[33m[permission] Potentially destructive command\033[0m")
|
):
|
||||||
print(f" Tool: {block.name}({block.input})")
|
print(f"\n\033[33m[permission] Potentially destructive command\033[0m")
|
||||||
choice = input(" Allow? [y/N] ").strip().lower()
|
print(f" Tool: {block.name}({block.input})")
|
||||||
if choice not in ("y", "yes"):
|
choice = input(" Allow? [y/N] ").strip().lower()
|
||||||
return "Permission denied by user"
|
if choice not in ("y", "yes"):
|
||||||
|
return "Permission denied by user"
|
||||||
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):
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ without an update, the harness adds a reminder alongside the tool results.
|
|||||||
import ast
|
import ast
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -218,8 +219,16 @@ 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="]
|
||||||
|
DESTRUCTIVE_COMMAND_WORD = re.compile(
|
||||||
|
r"(?i)(?:^|[;&|()\n])\s*(?:rm|del)(?=\s|$|[;&|()])"
|
||||||
|
)
|
||||||
DESTRUCTIVE = ["rm ", "> /etc/", "chmod 777"]
|
DESTRUCTIVE = ["rm ", "> /etc/", "chmod 777"]
|
||||||
|
|
||||||
|
|
||||||
|
def contains_destructive_command(command: str) -> bool:
|
||||||
|
return bool(DESTRUCTIVE_COMMAND_WORD.search(command))
|
||||||
|
|
||||||
|
|
||||||
def permission_hook(block):
|
def permission_hook(block):
|
||||||
"""PreToolUse: s03 permission logic, registered as an s04 hook."""
|
"""PreToolUse: s03 permission logic, registered as an s04 hook."""
|
||||||
if block.name == "bash":
|
if block.name == "bash":
|
||||||
@@ -228,13 +237,14 @@ def permission_hook(block):
|
|||||||
if pattern in command:
|
if pattern in command:
|
||||||
print(f"\n\033[31m[blocked] '{pattern}'\033[0m")
|
print(f"\n\033[31m[blocked] '{pattern}'\033[0m")
|
||||||
return "Permission denied by deny list"
|
return "Permission denied by deny list"
|
||||||
for keyword in DESTRUCTIVE:
|
if contains_destructive_command(command) or any(
|
||||||
if keyword in command:
|
keyword in command for keyword in DESTRUCTIVE
|
||||||
print(f"\n\033[33m[permission] Potentially destructive command\033[0m")
|
):
|
||||||
print(f" Tool: {block.name}({block.input})")
|
print(f"\n\033[33m[permission] Potentially destructive command\033[0m")
|
||||||
choice = input(" Allow? [y/N] ").strip().lower()
|
print(f" Tool: {block.name}({block.input})")
|
||||||
if choice not in ("y", "yes"):
|
choice = input(" Allow? [y/N] ").strip().lower()
|
||||||
return "Permission denied by user"
|
if choice not in ("y", "yes"):
|
||||||
|
return "Permission denied by user"
|
||||||
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):
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ The subagent has no task tool, so it cannot delegate again.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -154,9 +155,16 @@ 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="]
|
||||||
|
DESTRUCTIVE_COMMAND_WORD = re.compile(
|
||||||
|
r"(?i)(?:^|[;&|()\n])\s*(?:rm|del)(?=\s|$|[;&|()])"
|
||||||
|
)
|
||||||
DESTRUCTIVE = ["rm ", "> /etc/", "chmod 777"]
|
DESTRUCTIVE = ["rm ", "> /etc/", "chmod 777"]
|
||||||
|
|
||||||
|
|
||||||
|
def contains_destructive_command(command: str) -> bool:
|
||||||
|
return bool(DESTRUCTIVE_COMMAND_WORD.search(command))
|
||||||
|
|
||||||
|
|
||||||
def permission_hook(block):
|
def permission_hook(block):
|
||||||
"""PreToolUse: block denied operations and ask about risky ones."""
|
"""PreToolUse: block denied operations and ask about risky ones."""
|
||||||
if block.name == "bash":
|
if block.name == "bash":
|
||||||
@@ -165,13 +173,14 @@ def permission_hook(block):
|
|||||||
if pattern in command:
|
if pattern in command:
|
||||||
print(f"\n\033[31m[blocked] '{pattern}'\033[0m")
|
print(f"\n\033[31m[blocked] '{pattern}'\033[0m")
|
||||||
return "Permission denied by deny list"
|
return "Permission denied by deny list"
|
||||||
for keyword in DESTRUCTIVE:
|
if contains_destructive_command(command) or any(
|
||||||
if keyword in command:
|
keyword in command for keyword in DESTRUCTIVE
|
||||||
print("\n\033[33m[permission] Potentially destructive command\033[0m")
|
):
|
||||||
print(f" Tool: {block.name}({block.input})")
|
print("\n\033[33m[permission] Potentially destructive command\033[0m")
|
||||||
choice = input(" Allow? [y/N] ").strip().lower()
|
print(f" Tool: {block.name}({block.input})")
|
||||||
if choice not in ("y", "yes"):
|
choice = input(" Allow? [y/N] ").strip().lower()
|
||||||
return "Permission denied by user"
|
if choice not in ("y", "yes"):
|
||||||
|
return "Permission denied by user"
|
||||||
|
|
||||||
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", "")
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ The model loads the full SKILL.md only when it calls load_skill.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -241,9 +242,16 @@ 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="]
|
||||||
|
DESTRUCTIVE_COMMAND_WORD = re.compile(
|
||||||
|
r"(?i)(?:^|[;&|()\n])\s*(?:rm|del)(?=\s|$|[;&|()])"
|
||||||
|
)
|
||||||
DESTRUCTIVE = ["rm ", "> /etc/", "chmod 777"]
|
DESTRUCTIVE = ["rm ", "> /etc/", "chmod 777"]
|
||||||
|
|
||||||
|
|
||||||
|
def contains_destructive_command(command: str) -> bool:
|
||||||
|
return bool(DESTRUCTIVE_COMMAND_WORD.search(command))
|
||||||
|
|
||||||
|
|
||||||
def permission_hook(block):
|
def permission_hook(block):
|
||||||
"""PreToolUse: block denied operations and ask about risky ones."""
|
"""PreToolUse: block denied operations and ask about risky ones."""
|
||||||
if block.name == "bash":
|
if block.name == "bash":
|
||||||
@@ -252,13 +260,14 @@ def permission_hook(block):
|
|||||||
if pattern in command:
|
if pattern in command:
|
||||||
print(f"\n\033[31m[blocked] '{pattern}'\033[0m")
|
print(f"\n\033[31m[blocked] '{pattern}'\033[0m")
|
||||||
return "Permission denied by deny list"
|
return "Permission denied by deny list"
|
||||||
for keyword in DESTRUCTIVE:
|
if contains_destructive_command(command) or any(
|
||||||
if keyword in command:
|
keyword in command for keyword in DESTRUCTIVE
|
||||||
print("\n\033[33m[permission] Potentially destructive command\033[0m")
|
):
|
||||||
print(f" Tool: {block.name}({block.input})")
|
print("\n\033[33m[permission] Potentially destructive command\033[0m")
|
||||||
choice = input(" Allow? [y/N] ").strip().lower()
|
print(f" Tool: {block.name}({block.input})")
|
||||||
if choice not in ("y", "yes"):
|
choice = input(" Allow? [y/N] ").strip().lower()
|
||||||
return "Permission denied by user"
|
if choice not in ("y", "yes"):
|
||||||
|
return "Permission denied by user"
|
||||||
|
|
||||||
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", "")
|
||||||
|
|||||||
@@ -178,16 +178,25 @@ 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="]
|
||||||
|
DESTRUCTIVE_COMMAND_WORD = re.compile(
|
||||||
|
r"(?i)(?:^|[;&|()\n])\s*(?:rm|del)(?=\s|$|[;&|()])"
|
||||||
|
)
|
||||||
DESTRUCTIVE = ["rm ", "> /etc/", "chmod 777"]
|
DESTRUCTIVE = ["rm ", "> /etc/", "chmod 777"]
|
||||||
|
|
||||||
|
|
||||||
|
def contains_destructive_command(command: str) -> bool:
|
||||||
|
return bool(DESTRUCTIVE_COMMAND_WORD.search(command))
|
||||||
|
|
||||||
|
|
||||||
def permission_hook(block):
|
def permission_hook(block):
|
||||||
if block.name == "bash":
|
if block.name == "bash":
|
||||||
command = block.input.get("command", "")
|
command = block.input.get("command", "")
|
||||||
for pattern in DENY_LIST:
|
for pattern in DENY_LIST:
|
||||||
if pattern in command:
|
if pattern in command:
|
||||||
return f"Permission denied by deny list: {pattern}"
|
return f"Permission denied by deny list: {pattern}"
|
||||||
if any(keyword in command for keyword in DESTRUCTIVE):
|
if contains_destructive_command(command) or any(
|
||||||
|
keyword in command for keyword in DESTRUCTIVE
|
||||||
|
):
|
||||||
print("\n\033[33m[permission] Potentially destructive command\033[0m")
|
print("\n\033[33m[permission] Potentially destructive command\033[0m")
|
||||||
print(f" Tool: {block.name}({block.input})")
|
print(f" Tool: {block.name}({block.input})")
|
||||||
if input(" Allow? [y/N] ").strip().lower() not in ("y", "yes"):
|
if input(" Allow? [y/N] ").strip().lower() not in ("y", "yes"):
|
||||||
|
|||||||
@@ -634,15 +634,25 @@ 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="]
|
||||||
|
DESTRUCTIVE_COMMAND_WORD = re.compile(
|
||||||
|
r"(?i)(?:^|[;&|()\n])\s*(?:rm|del)(?=\s|$|[;&|()])"
|
||||||
|
)
|
||||||
DESTRUCTIVE = ["rm ", "> /etc/", "chmod 777"]
|
DESTRUCTIVE = ["rm ", "> /etc/", "chmod 777"]
|
||||||
|
|
||||||
|
|
||||||
|
def contains_destructive_command(command: str) -> bool:
|
||||||
|
return bool(DESTRUCTIVE_COMMAND_WORD.search(command))
|
||||||
|
|
||||||
|
|
||||||
def permission_hook(block):
|
def permission_hook(block):
|
||||||
if block.name == "bash":
|
if block.name == "bash":
|
||||||
command = block.input.get("command", "")
|
command = block.input.get("command", "")
|
||||||
for pattern in DENY_LIST:
|
for pattern in DENY_LIST:
|
||||||
if pattern in command:
|
if pattern in command:
|
||||||
return f"Permission denied by deny list: {pattern}"
|
return f"Permission denied by deny list: {pattern}"
|
||||||
if any(keyword in command for keyword in DESTRUCTIVE):
|
if contains_destructive_command(command) or any(
|
||||||
|
keyword in command for keyword in DESTRUCTIVE
|
||||||
|
):
|
||||||
print("\n\033[33m[permission] Potentially destructive command\033[0m")
|
print("\n\033[33m[permission] Potentially destructive command\033[0m")
|
||||||
print(f" Tool: {block.name}({block.input})")
|
print(f" Tool: {block.name}({block.input})")
|
||||||
if input(" Allow? [y/N] ").strip().lower() not in ("y", "yes"):
|
if input(" Allow? [y/N] ").strip().lower() not in ("y", "yes"):
|
||||||
|
|||||||
@@ -444,9 +444,16 @@ 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="]
|
||||||
|
DESTRUCTIVE_COMMAND_WORD = re.compile(
|
||||||
|
r"(?i)(?:^|[;&|()\n])\s*(?:rm|del)(?=\s|$|[;&|()])"
|
||||||
|
)
|
||||||
DESTRUCTIVE = ["rm ", "> /etc/", "chmod 777"]
|
DESTRUCTIVE = ["rm ", "> /etc/", "chmod 777"]
|
||||||
|
|
||||||
|
|
||||||
|
def contains_destructive_command(command: str) -> bool:
|
||||||
|
return bool(DESTRUCTIVE_COMMAND_WORD.search(command))
|
||||||
|
|
||||||
|
|
||||||
def permission_hook(block):
|
def permission_hook(block):
|
||||||
if block.name == "bash":
|
if block.name == "bash":
|
||||||
command = block.input.get("command", "")
|
command = block.input.get("command", "")
|
||||||
@@ -454,7 +461,9 @@ def permission_hook(block):
|
|||||||
if pattern in command:
|
if pattern in command:
|
||||||
print(f"\n\033[31m[blocked] '{pattern}'\033[0m")
|
print(f"\n\033[31m[blocked] '{pattern}'\033[0m")
|
||||||
return "Permission denied by deny list"
|
return "Permission denied by deny list"
|
||||||
if any(keyword in command for keyword in DESTRUCTIVE):
|
if contains_destructive_command(command) or any(
|
||||||
|
keyword in command for keyword in DESTRUCTIVE
|
||||||
|
):
|
||||||
print("\n\033[33m[permission] Potentially destructive command\033[0m")
|
print("\n\033[33m[permission] Potentially destructive command\033[0m")
|
||||||
print(f" Tool: {block.name}({block.input})")
|
print(f" Tool: {block.name}({block.input})")
|
||||||
choice = input(" Allow? [y/N] ").strip().lower()
|
choice = input(" Allow? [y/N] ").strip().lower()
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ s11_background_tasks.py - Background Tasks
|
|||||||
import atexit
|
import atexit
|
||||||
import glob
|
import glob
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import signal
|
import signal
|
||||||
import subprocess
|
import subprocess
|
||||||
import threading
|
import threading
|
||||||
@@ -225,9 +226,16 @@ 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="]
|
||||||
|
DESTRUCTIVE_COMMAND_WORD = re.compile(
|
||||||
|
r"(?i)(?:^|[;&|()\n])\s*(?:rm|del)(?=\s|$|[;&|()])"
|
||||||
|
)
|
||||||
DESTRUCTIVE = ["rm ", "> /etc/", "chmod 777"]
|
DESTRUCTIVE = ["rm ", "> /etc/", "chmod 777"]
|
||||||
|
|
||||||
|
|
||||||
|
def contains_destructive_command(command: str) -> bool:
|
||||||
|
return bool(DESTRUCTIVE_COMMAND_WORD.search(command))
|
||||||
|
|
||||||
|
|
||||||
def permission_hook(block):
|
def permission_hook(block):
|
||||||
if block.name == "bash":
|
if block.name == "bash":
|
||||||
command = block.input.get("command", "")
|
command = block.input.get("command", "")
|
||||||
@@ -235,7 +243,9 @@ def permission_hook(block):
|
|||||||
if pattern in command:
|
if pattern in command:
|
||||||
print(f"\n\033[31m[blocked] '{pattern}'\033[0m")
|
print(f"\n\033[31m[blocked] '{pattern}'\033[0m")
|
||||||
return "Permission denied by deny list"
|
return "Permission denied by deny list"
|
||||||
if any(keyword in command for keyword in DESTRUCTIVE):
|
if contains_destructive_command(command) or any(
|
||||||
|
keyword in command for keyword in DESTRUCTIVE
|
||||||
|
):
|
||||||
print("\n\033[33m[permission] Potentially destructive command\033[0m")
|
print("\n\033[33m[permission] Potentially destructive command\033[0m")
|
||||||
print(f" Tool: {block.name}({block.input})")
|
print(f" Tool: {block.name}({block.input})")
|
||||||
choice = input(" Allow? [y/N] ").strip().lower()
|
choice = input(" Allow? [y/N] ").strip().lower()
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ s12_cron_scheduler.py - Cron Scheduler
|
|||||||
import glob
|
import glob
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import secrets
|
import secrets
|
||||||
import subprocess
|
import subprocess
|
||||||
import threading
|
import threading
|
||||||
@@ -173,9 +174,16 @@ 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="]
|
||||||
|
DESTRUCTIVE_COMMAND_WORD = re.compile(
|
||||||
|
r"(?i)(?:^|[;&|()\n])\s*(?:rm|del)(?=\s|$|[;&|()])"
|
||||||
|
)
|
||||||
DESTRUCTIVE = ["rm ", "> /etc/", "chmod 777"]
|
DESTRUCTIVE = ["rm ", "> /etc/", "chmod 777"]
|
||||||
|
|
||||||
|
|
||||||
|
def contains_destructive_command(command: str) -> bool:
|
||||||
|
return bool(DESTRUCTIVE_COMMAND_WORD.search(command))
|
||||||
|
|
||||||
|
|
||||||
def request_permission(block, reason: str) -> str | None:
|
def request_permission(block, reason: str) -> str | None:
|
||||||
if threading.current_thread() is not threading.main_thread():
|
if threading.current_thread() is not threading.main_thread():
|
||||||
return "Permission denied: scheduled turns cannot request interactive approval"
|
return "Permission denied: scheduled turns cannot request interactive approval"
|
||||||
@@ -195,7 +203,9 @@ def permission_hook(block):
|
|||||||
if pattern in command:
|
if pattern in command:
|
||||||
print(f"\n\033[31m[blocked] '{pattern}'\033[0m")
|
print(f"\n\033[31m[blocked] '{pattern}'\033[0m")
|
||||||
return "Permission denied by deny list"
|
return "Permission denied by deny list"
|
||||||
if any(keyword in command for keyword in DESTRUCTIVE):
|
if contains_destructive_command(command) or any(
|
||||||
|
keyword in command for keyword in DESTRUCTIVE
|
||||||
|
):
|
||||||
return request_permission(block, "Potentially destructive command")
|
return request_permission(block, "Potentially destructive command")
|
||||||
|
|
||||||
if block.name in ("read_file", "write_file", "edit_file"):
|
if block.name in ("read_file", "write_file", "edit_file"):
|
||||||
|
|||||||
@@ -1663,9 +1663,16 @@ 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="]
|
||||||
|
DESTRUCTIVE_COMMAND_WORD = re.compile(
|
||||||
|
r"(?i)(?:^|[;&|()\n])\s*(?:rm|del)(?=\s|$|[;&|()])"
|
||||||
|
)
|
||||||
DESTRUCTIVE = ["rm ", "> /etc/", "chmod 777"]
|
DESTRUCTIVE = ["rm ", "> /etc/", "chmod 777"]
|
||||||
|
|
||||||
|
|
||||||
|
def contains_destructive_command(command: str) -> bool:
|
||||||
|
return bool(DESTRUCTIVE_COMMAND_WORD.search(command))
|
||||||
|
|
||||||
|
|
||||||
def register_hook(event: str, callback):
|
def register_hook(event: str, callback):
|
||||||
HOOKS[event].append(callback)
|
HOOKS[event].append(callback)
|
||||||
|
|
||||||
@@ -1686,7 +1693,9 @@ def check_permission(block, prompt_user: bool = True) -> str | None:
|
|||||||
for pattern in DENY_LIST:
|
for pattern in DENY_LIST:
|
||||||
if pattern in command:
|
if pattern in command:
|
||||||
return f"Permission denied by deny list: {pattern}"
|
return f"Permission denied by deny list: {pattern}"
|
||||||
if any(keyword in command for keyword in DESTRUCTIVE):
|
if contains_destructive_command(command) or any(
|
||||||
|
keyword in command for keyword in DESTRUCTIVE
|
||||||
|
):
|
||||||
if not prompt_user:
|
if not prompt_user:
|
||||||
return "Permission required: ask Lead to run this command."
|
return "Permission required: ask Lead to run this command."
|
||||||
print(f"\n[permission] {block.name}({block.input})")
|
print(f"\n[permission] {block.name}({block.input})")
|
||||||
|
|||||||
@@ -369,9 +369,16 @@ 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="]
|
||||||
|
DESTRUCTIVE_COMMAND_WORD = re.compile(
|
||||||
|
r"(?i)(?:^|[;&|()\n])\s*(?:rm|del)(?=\s|$|[;&|()])"
|
||||||
|
)
|
||||||
DESTRUCTIVE = ["rm ", "> /etc/", "chmod 777"]
|
DESTRUCTIVE = ["rm ", "> /etc/", "chmod 777"]
|
||||||
|
|
||||||
|
|
||||||
|
def contains_destructive_command(command: str) -> bool:
|
||||||
|
return bool(DESTRUCTIVE_COMMAND_WORD.search(command))
|
||||||
|
|
||||||
|
|
||||||
def register_hook(event: str, callback):
|
def register_hook(event: str, callback):
|
||||||
HOOKS[event].append(callback)
|
HOOKS[event].append(callback)
|
||||||
|
|
||||||
@@ -390,7 +397,9 @@ def permission_hook(block):
|
|||||||
for pattern in DENY_LIST:
|
for pattern in DENY_LIST:
|
||||||
if pattern in command:
|
if pattern in command:
|
||||||
return f"Permission denied by deny list: {pattern}"
|
return f"Permission denied by deny list: {pattern}"
|
||||||
if any(keyword in command for keyword in DESTRUCTIVE):
|
if contains_destructive_command(command) or any(
|
||||||
|
keyword in command for keyword in DESTRUCTIVE
|
||||||
|
):
|
||||||
print(f"\n[permission] {block.name}({block.input})")
|
print(f"\n[permission] {block.name}({block.input})")
|
||||||
if input("Allow? [y/N] ").strip().lower() not in {"y", "yes"}:
|
if input("Allow? [y/N] ").strip().lower() not in {"y", "yes"}:
|
||||||
return "Permission denied by user"
|
return "Permission denied by user"
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import asyncio
|
|||||||
import glob
|
import glob
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
@@ -45,9 +46,16 @@ 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="]
|
||||||
|
DESTRUCTIVE_COMMAND_WORD = re.compile(
|
||||||
|
r"(?i)(?:^|[;&|()\n])\s*(?:rm|del)(?=\s|$|[;&|()])"
|
||||||
|
)
|
||||||
DESTRUCTIVE = ["rm ", "> /etc/", "chmod 777"]
|
DESTRUCTIVE = ["rm ", "> /etc/", "chmod 777"]
|
||||||
|
|
||||||
|
|
||||||
|
def contains_destructive_command(command: str) -> bool:
|
||||||
|
return bool(DESTRUCTIVE_COMMAND_WORD.search(command))
|
||||||
|
|
||||||
|
|
||||||
class GoalError(Exception):
|
class GoalError(Exception):
|
||||||
"""The goal command or evaluator could not be used safely."""
|
"""The goal command or evaluator could not be used safely."""
|
||||||
|
|
||||||
@@ -598,7 +606,9 @@ class AgentSession:
|
|||||||
for pattern in DENY_LIST:
|
for pattern in DENY_LIST:
|
||||||
if pattern in command:
|
if pattern in command:
|
||||||
return f"Permission denied by deny list: {pattern}"
|
return f"Permission denied by deny list: {pattern}"
|
||||||
if any(keyword in command for keyword in DESTRUCTIVE):
|
if contains_destructive_command(command) or any(
|
||||||
|
keyword in command for keyword in DESTRUCTIVE
|
||||||
|
):
|
||||||
print(f"\n[permission] {name}({arguments})")
|
print(f"\n[permission] {name}({arguments})")
|
||||||
if input("Allow? [y/N] ").strip().lower() not in {"y", "yes"}:
|
if input("Allow? [y/N] ").strip().lower() not in {"y", "yes"}:
|
||||||
return "Permission denied by user"
|
return "Permission denied by user"
|
||||||
|
|||||||
127
tests/test_permission_command_words.py
Normal file
127
tests/test_permission_command_words.py
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
import importlib.util
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import types
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
PERMISSION_LESSONS = tuple(
|
||||||
|
ROOT / chapter / "code.py"
|
||||||
|
for chapter in (
|
||||||
|
"s03_permission",
|
||||||
|
"s04_hooks",
|
||||||
|
"s05_todo_write",
|
||||||
|
"s06_subagent",
|
||||||
|
"s07_skill_loading",
|
||||||
|
"s08_context_compact",
|
||||||
|
"s09_memory",
|
||||||
|
"s10_task_system",
|
||||||
|
"s11_background_tasks",
|
||||||
|
"s12_cron_scheduler",
|
||||||
|
"s13_agent_teams",
|
||||||
|
"s14_mcp_plugin",
|
||||||
|
"s17_goal_loop",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def load_lesson(workdir: Path, lesson_path: Path):
|
||||||
|
fake_anthropic = types.ModuleType("anthropic")
|
||||||
|
fake_dotenv = types.ModuleType("dotenv")
|
||||||
|
|
||||||
|
class FakeAnthropic:
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
self.messages = types.SimpleNamespace(create=None)
|
||||||
|
|
||||||
|
fake_anthropic.Anthropic = FakeAnthropic
|
||||||
|
fake_dotenv.load_dotenv = lambda override=True: None
|
||||||
|
|
||||||
|
previous_modules = {
|
||||||
|
"anthropic": sys.modules.get("anthropic"),
|
||||||
|
"dotenv": sys.modules.get("dotenv"),
|
||||||
|
}
|
||||||
|
previous_cwd = Path.cwd()
|
||||||
|
previous_model = os.environ.get("MODEL_ID")
|
||||||
|
module_name = f"permission_words_{lesson_path.parent.name}_{time.time_ns()}"
|
||||||
|
spec = importlib.util.spec_from_file_location(module_name, lesson_path)
|
||||||
|
assert spec is not None and spec.loader is not None
|
||||||
|
module = importlib.util.module_from_spec(spec)
|
||||||
|
|
||||||
|
sys.modules["anthropic"] = fake_anthropic
|
||||||
|
sys.modules["dotenv"] = fake_dotenv
|
||||||
|
sys.modules[module_name] = module
|
||||||
|
try:
|
||||||
|
os.chdir(workdir)
|
||||||
|
os.environ["MODEL_ID"] = "test-model"
|
||||||
|
spec.loader.exec_module(module)
|
||||||
|
return module
|
||||||
|
finally:
|
||||||
|
os.chdir(previous_cwd)
|
||||||
|
if previous_model is None:
|
||||||
|
os.environ.pop("MODEL_ID", None)
|
||||||
|
else:
|
||||||
|
os.environ["MODEL_ID"] = previous_model
|
||||||
|
for name, previous in previous_modules.items():
|
||||||
|
if previous is None:
|
||||||
|
sys.modules.pop(name, None)
|
||||||
|
else:
|
||||||
|
sys.modules[name] = previous
|
||||||
|
sys.modules.pop(module_name, None)
|
||||||
|
|
||||||
|
|
||||||
|
def permission_result(lesson, block):
|
||||||
|
if hasattr(lesson, "check_rules"):
|
||||||
|
return lesson.check_rules(block.name, block.input)
|
||||||
|
if hasattr(lesson, "permission_hook"):
|
||||||
|
return lesson.permission_hook(block)
|
||||||
|
|
||||||
|
goal = lesson.GoalController(evaluator=None)
|
||||||
|
session = lesson.AgentSession(
|
||||||
|
client=None,
|
||||||
|
model="test-model",
|
||||||
|
goal=goal,
|
||||||
|
workdir=Path.cwd(),
|
||||||
|
)
|
||||||
|
return session._permission_hook(block)
|
||||||
|
|
||||||
|
|
||||||
|
COMMAND_CASES = (
|
||||||
|
("rm file.txt", True),
|
||||||
|
("/usr/bin/rm file.txt", True),
|
||||||
|
("command rm file.txt", True),
|
||||||
|
("DEL file.txt", True),
|
||||||
|
("echo ready; rm file.txt", True),
|
||||||
|
("echo ready && del file.txt", True),
|
||||||
|
("echo ready || RM file.txt", True),
|
||||||
|
("echo ready | del file.txt", True),
|
||||||
|
("echo ready & rm file.txt", True),
|
||||||
|
("(del file.txt)", True),
|
||||||
|
("rm; echo ready", True),
|
||||||
|
("model list", False),
|
||||||
|
("delimiter file.txt", False),
|
||||||
|
("echo del file.txt", False),
|
||||||
|
("echo; delimiter file.txt", False),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"lesson_path", PERMISSION_LESSONS, ids=lambda path: path.parent.name
|
||||||
|
)
|
||||||
|
@pytest.mark.parametrize("command, expected", COMMAND_CASES)
|
||||||
|
def test_permission_command_words_cover_position_case_and_boundaries(
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
lesson_path: Path,
|
||||||
|
command: str,
|
||||||
|
expected: bool,
|
||||||
|
) -> None:
|
||||||
|
lesson = load_lesson(tmp_path, lesson_path)
|
||||||
|
monkeypatch.setattr("builtins.input", lambda _prompt: "n")
|
||||||
|
block = types.SimpleNamespace(name="bash", input={"command": command})
|
||||||
|
|
||||||
|
result = permission_result(lesson, block)
|
||||||
|
|
||||||
|
assert bool(result) is expected
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user