super-code-assistant 3.3.6__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. commands/__init__.py +859 -0
  2. core/__init__.py +0 -0
  3. core/config.py +263 -0
  4. core/config_template.json +7 -0
  5. core/context.py +271 -0
  6. core/engine.py +635 -0
  7. core/file_state.py +279 -0
  8. core/llm.py +309 -0
  9. core/model_capabilities.py +45 -0
  10. core/permissions.py +204 -0
  11. core/sandbox/__init__.py +15 -0
  12. core/sandbox/blacklist.py +176 -0
  13. core/sandbox/config.py +38 -0
  14. core/sandbox/network.py +136 -0
  15. core/sandbox/path_protection.py +126 -0
  16. core/session.py +295 -0
  17. core/tool.py +45 -0
  18. features/__init__.py +0 -0
  19. features/compact.py +945 -0
  20. features/coordinator.py +105 -0
  21. features/cost_tracker.py +184 -0
  22. features/extract_memories.py +326 -0
  23. features/find_relevant_memories.py +376 -0
  24. features/git_ai.py +256 -0
  25. features/memory.py +531 -0
  26. features/memory_age.py +66 -0
  27. features/memory_scan.py +153 -0
  28. features/memory_types.py +34 -0
  29. features/plan.py +327 -0
  30. features/skills.py +300 -0
  31. features/worker_manager.py +232 -0
  32. mcp/__init__.py +0 -0
  33. mcp/client.py +112 -0
  34. mcp/loader.py +80 -0
  35. mcp/tool_proxy.py +59 -0
  36. super_code_assistant-3.3.6.dist-info/METADATA +45 -0
  37. super_code_assistant-3.3.6.dist-info/RECORD +61 -0
  38. super_code_assistant-3.3.6.dist-info/WHEEL +5 -0
  39. super_code_assistant-3.3.6.dist-info/entry_points.txt +2 -0
  40. super_code_assistant-3.3.6.dist-info/top_level.txt +7 -0
  41. tools/__init__.py +21 -0
  42. tools/agent.py +132 -0
  43. tools/ask_user.py +111 -0
  44. tools/bash.py +77 -0
  45. tools/file_edit.py +269 -0
  46. tools/file_read.py +206 -0
  47. tools/file_write.py +78 -0
  48. tools/glob_tool.py +81 -0
  49. tools/grep_tool.py +134 -0
  50. tools/plan_tools.py +75 -0
  51. tools/skill.py +108 -0
  52. tools/tool.py +44 -0
  53. tools/web_fetch.py +129 -0
  54. tools/web_search.py +220 -0
  55. tui/__init__.py +0 -0
  56. tui/app.py +726 -0
  57. tui/clipboard_image.py +42 -0
  58. tui/keylistener.py +140 -0
  59. tui/prompt.py +752 -0
  60. tui/query.py +200 -0
  61. tui/rendering.py +135 -0
core/permissions.py ADDED
@@ -0,0 +1,204 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import sys
5
+ from typing import Literal, TYPE_CHECKING
6
+
7
+ from core.tool import Tool
8
+
9
+ if TYPE_CHECKING:
10
+ from tui.keylistener import EscListener
11
+
12
+ PermissionBehavior = Literal["allow", "deny"]
13
+
14
+ _PLAN_MODE_ALLOWED_TOOLS = {
15
+ "Read", "Glob", "Grep", "AskUserQuestion",
16
+ "EnterPlanMode", "ExitPlanMode",
17
+ "Agent", "SendMessage", "TaskStop",
18
+ }
19
+ _PLAN_MODE_WRITE_TOOLS = {"Edit", "Write"}
20
+
21
+
22
+ class PermissionChecker:
23
+ """Read-only tools are auto-allowed. Bash/writes prompt the user (y/n/always)."""
24
+
25
+ def __init__(self, auto_approve: bool = False, sandbox_manager=None):
26
+ self._auto_approve = auto_approve # 是否自动批准所有工具
27
+ self._sandbox_manager = sandbox_manager # 沙箱管理器(用于 auto_approve_if_sandboxed)
28
+ self._always_allow: set[str] = set() # 用户选择“总是允许”的工具集合
29
+ self._esc_listener: EscListener | None = None # ESC监听器
30
+ self._plan_manager = None # 计划管理器
31
+ self._mode: str = "default" # 当前模式
32
+ self._pre_plan_mode: str | None = None # 进入计划模式之前的模式
33
+ self._pre_plan_always_allow: set[str] | None = None # 进入计划模式之前的always allow备份
34
+ self._dream_mode: bool = False # 是否dream模式
35
+ self._dream_memory_dir: str | None = None # dream模式允许写入的目录
36
+
37
+ def set_plan_manager(self, plan_manager) -> None:
38
+ self._plan_manager = plan_manager
39
+
40
+ def enter_dream_mode(self, memory_dir: str) -> None:
41
+ self._dream_mode = True
42
+ self._dream_memory_dir = os.path.realpath(memory_dir)
43
+
44
+ def exit_dream_mode(self) -> None:
45
+ self._dream_mode = False
46
+ self._dream_memory_dir = None
47
+
48
+ def set_esc_listener(self, listener) -> None:
49
+ self._esc_listener = listener
50
+
51
+ @property
52
+ def mode(self) -> str:
53
+ return self._mode
54
+
55
+ def enter_plan_mode(self) -> None:
56
+ self._pre_plan_mode = self._mode
57
+ self._pre_plan_always_allow = set(self._always_allow)
58
+ self._mode = "plan"
59
+ self._always_allow -= {"Bash", "Edit", "Write", "Agent"} # 从always_allow中移除这些工具
60
+
61
+ def exit_plan_mode(self) -> None:
62
+ self._mode = self._pre_plan_mode or "default"
63
+ self._pre_plan_mode = None
64
+ if self._pre_plan_always_allow is not None:
65
+ self._always_allow = self._pre_plan_always_allow
66
+ self._pre_plan_always_allow = None
67
+
68
+ def check(self, tool: Tool, inputs: dict) -> PermissionBehavior:
69
+ if self._dream_mode:
70
+ return self._check_dream(tool, inputs)
71
+ if self._mode == "plan":
72
+ return self._check_plan(tool, inputs)
73
+ if tool.is_read_only():
74
+ return "allow"
75
+ if self._auto_approve:
76
+ return "allow"
77
+ # 沙箱激活时自动批准 Bash(命令已被黑名单保护)
78
+ if (self._sandbox_manager is not None
79
+ and tool.name == "Bash"
80
+ and self._sandbox_manager._config is not None
81
+ and self._sandbox_manager._config.auto_approve_if_sandboxed):
82
+ return "allow"
83
+ if tool.name in self._always_allow:
84
+ return "allow"
85
+ return self._prompt_user(tool, inputs)
86
+
87
+ def _check_plan(self, tool: Tool, inputs: dict) -> PermissionBehavior:
88
+ if tool.name in _PLAN_MODE_ALLOWED_TOOLS:
89
+ return "allow"
90
+ if tool.name in _PLAN_MODE_WRITE_TOOLS:
91
+ file_path = inputs.get("file_path", "")
92
+ plan_path = self._plan_manager.plan_file_path if self._plan_manager else None
93
+ if plan_path and os.path.realpath(file_path) == os.path.realpath(plan_path): # 返回指定路径的规范化绝对路径,并解析所有符号链接(软链接)
94
+ return "allow"
95
+ from rich.console import Console
96
+ Console().print(f"[yellow]Plan mode: can only edit the plan file ({plan_path})[/yellow]")
97
+ return "deny"
98
+ from rich.console import Console
99
+ Console().print(f"[yellow]Plan mode: {tool.name} is not allowed.[/yellow]")
100
+ return "deny"
101
+
102
+ def _check_dream(self, tool: Tool, inputs: dict) -> PermissionBehavior:
103
+ if tool.is_read_only():
104
+ return "allow"
105
+ if tool.name in ("Edit", "Write"):
106
+ file_path = inputs.get("file_path", "")
107
+ if (self._dream_memory_dir and isinstance(file_path, str)
108
+ and os.path.realpath(file_path).startswith(self._dream_memory_dir)):
109
+ return "allow"
110
+ return "deny"
111
+
112
+ def _prompt_user(self, tool: Tool, inputs: dict) -> PermissionBehavior:
113
+ from rich.console import Console
114
+ from prompt_toolkit.application import Application as PTApp
115
+ from prompt_toolkit.key_binding import KeyBindings
116
+ from prompt_toolkit.layout import Layout
117
+ from prompt_toolkit.layout.containers import Window
118
+ from prompt_toolkit.layout.controls import FormattedTextControl
119
+
120
+ console = Console()
121
+ console.print(f"\n[bold yellow]Permission required:[/bold yellow] [bold]{tool.name}[/bold]")
122
+
123
+ # For Edit tool, show old_string/new_string as a diff in a dashed-border Panel
124
+ if tool.name == "Edit" and "old_string" in inputs and "new_string" in inputs:
125
+ old_str = str(inputs.get("old_string", ""))
126
+ new_str = str(inputs.get("new_string", ""))
127
+ diff_lines = []
128
+ if old_str:
129
+ for line in old_str.splitlines():
130
+ diff_lines.append(f"[red]- {line}[/red]")
131
+ if new_str:
132
+ for line in new_str.splitlines():
133
+ diff_lines.append(f"[green]+ {line}[/green]")
134
+ from rich.panel import Panel
135
+ diff_body = "\n".join(diff_lines)
136
+ if len(diff_body) > 500:
137
+ diff_body = diff_body[:500] + "\n..."
138
+ console.print(Panel(diff_body, border_style="dim", padding=(0, 1)))
139
+ for k, v in inputs.items():
140
+ if k in ("old_string", "new_string"):
141
+ continue
142
+ val = str(v)[:200] + ("..." if len(str(v)) > 200 else "")
143
+ console.print(f" [dim]{k}:[/dim] {val}")
144
+ else:
145
+ for k, v in inputs.items():
146
+ val = str(v)[:200] + ("..." if len(str(v)) > 200 else "")
147
+ console.print(f" [dim]{k}:[/dim] {val}")
148
+
149
+ if self._esc_listener:
150
+ self._esc_listener.pause()
151
+
152
+ # 选项:(返回值, 显示文本)
153
+ options = [("allow", "Yes"), ("deny", "No"), ("always", "Always allow")]
154
+ selected = [0]
155
+
156
+ def get_text():
157
+ lines = []
158
+ for i, (_, label) in enumerate(options):
159
+ if i == selected[0]:
160
+ lines.append(("bold fg:ansigreen", f" \u276f {label}\n"))
161
+ else:
162
+ lines.append(("", f" {label}\n"))
163
+ return lines
164
+
165
+ kb = KeyBindings()
166
+
167
+ @kb.add("up")
168
+ def _(event):
169
+ selected[0] = (selected[0] - 1) % len(options)
170
+ event.app.invalidate()
171
+
172
+ @kb.add("down")
173
+ def _(event):
174
+ selected[0] = (selected[0] + 1) % len(options)
175
+ event.app.invalidate()
176
+
177
+ @kb.add("enter")
178
+ def _(event):
179
+ event.app.exit(result=options[selected[0]][0])
180
+
181
+ @kb.add("c-c")
182
+ def _(event):
183
+ event.app.exit(result="deny")
184
+
185
+ try:
186
+ app = PTApp(
187
+ layout=Layout(Window(FormattedTextControl(get_text))),
188
+ key_bindings=kb,
189
+ full_screen=False,
190
+ refresh_interval=None,
191
+ )
192
+ choice = app.run()
193
+ except Exception:
194
+ choice = "deny"
195
+ finally:
196
+ if self._esc_listener:
197
+ self._esc_listener.resume()
198
+
199
+ if choice == "always":
200
+ self._always_allow.add(tool.name)
201
+ return "allow"
202
+ if choice == "allow":
203
+ return "allow"
204
+ return "deny"
@@ -0,0 +1,15 @@
1
+ """沙箱模块:命令黑名单过滤(非 OS 级隔离)。
2
+
3
+ 启用方式:--sandbox CLI 参数,或 super-code.json 中 sandbox.enabled = true。
4
+
5
+ 目录结构:
6
+ config.py — SandboxConfig dataclass,从 super-code.json 解析
7
+ blacklist.py — 命令黑名单规则 + SandboxManager
8
+ path_protection.py — 文件路径保护(禁止写入/读取的目录)
9
+ """
10
+ from __future__ import annotations
11
+
12
+ from .blacklist import SandboxManager
13
+ from .config import SandboxConfig
14
+
15
+ __all__ = ["SandboxManager", "SandboxConfig"]
@@ -0,0 +1,176 @@
1
+ """命令黑名单:规则编译 + 检查入口。
2
+
3
+ 内置规则按类别组织,每项 (re.Pattern, description)。
4
+ 外部可通过 SandboxConfig.extra_patterns 追加更多规则。
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import re
9
+ import logging
10
+
11
+ from .config import SandboxConfig
12
+ from .path_protection import check_path as _check_path
13
+ from .network import check_network as _check_network
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+ # ============================================================
18
+ # 内置黑名单规则(30+ 条,按类别分组)
19
+ # ============================================================
20
+
21
+ _BUILTIN_RULES: list[tuple[re.Pattern[str], str]] = []
22
+
23
+ def _add(pattern: str, desc: str) -> None:
24
+ _BUILTIN_RULES.append((re.compile(pattern, re.I), desc))
25
+
26
+ # ---- 文件系统破坏 ----
27
+ _add(r'\brm\b[^|;&]*-(?:[a-z]*r[a-z]*f|[a-z]*f[a-z]*r)', "recursive force delete (rm -rf)")
28
+ _add(r'\brm\b[^|;&]*/(?:\s|$)', "delete root /")
29
+ _add(r'\b(?:rd|rmdir)\b[^|;&]*/[sS]', "recursive rmdir (Windows)")
30
+ _add(r'\bdel\b[^|;&]*/[sSq]', "recursive del (Windows)")
31
+ _add(r'>\s*(?:\w:)?[/\\](?:[Ww]indows[/\\]?)?[Ss]ystem32', "overwrite System32")
32
+ _add(r'>\s*/etc/(?:passwd|shadow|sudoers|hosts)\b', "overwrite system file")
33
+ _add(r'>\s*/dev/(?:sd|hd|nvme|mmcblk|disk|loop)\w*', "overwrite disk device")
34
+
35
+ # ---- 磁盘/分区操作 ----
36
+ _add(r'\b(?:newfs|mkfs|fsck)\b', "filesystem create/check")
37
+ _add(r'\bdiskutil\s+eraseDisk\b', "disk format (macOS)")
38
+ _add(r'\bformat\s+[a-zA-Z]:', "disk format (Windows)")
39
+ _add(r'\bdd\b.*\bof=/dev/(?:sd|hd|nvme|mmcblk)\w*', "raw disk write (dd)")
40
+
41
+ # ---- 系统配置修改 ----
42
+ _add(r'\bchmod\b[^|;&]*[47]77', "chmod 777 (world-writable)")
43
+ _add(r'\bchmod\b[^|;&]*/(?:etc|usr|bin|sbin|boot)\b', "chmod on system dir")
44
+ _add(r'\bchown\b[^|;&]*/(?:etc|usr|bin|sbin|boot)\b', "chown on system dir")
45
+
46
+ # ---- 代码执行 / 环境变量注入 ----
47
+ # eval + 网络下载/远程执行才算危险;bare eval "$(brew shellenv)" 是合法初始化
48
+ _add(r'\beval\b.*\b(?:curl|wget|nc|ssh|bash\s+-c|sh\s+-c)\b', "eval with remote code")
49
+ _add(r'\bLD_PRELOAD\b', "LD_PRELOAD injection")
50
+ _add(r'\bDYLD_INSERT_LIBRARIES\b', "DYLD_INSERT_LIBRARIES injection")
51
+ _add(r'\bLD_LIBRARY_PATH\b.*=', "LD_LIBRARY_PATH override")
52
+
53
+ # source with command substitution(动态路径);不拦 source /path/to/venv/bin/activate
54
+ _add(r'\bsource\s+(?:\$\(|`|\$\{)', "source with dynamic path")
55
+
56
+ # ---- 包管理器全局安装 ----
57
+ _add(r'\bpip3?\b.*install\b(?!.*--user)', "pip install (global)")
58
+ _add(r'\bnpm\b.*install\b.*-g\b', "npm install -g")
59
+ _add(r'\bapt\b.*install\b', "apt install")
60
+ _add(r'\byum\b.*install\b', "yum install")
61
+ _add(r'\bbrew\b.*install\b', "brew install")
62
+
63
+ # ---- 远程传输 / 数据外泄 ----
64
+ _add(r'\bssh\b.*\b(?:root|admin)@', "ssh to privileged user")
65
+ _add(r'\bscp\b', "scp (remote file copy)")
66
+ _add(r'\brsync\b.*(?:@|::|rsync://)', "rsync to remote host")
67
+ _add(r'\bnc\b\s+-[lL]', "netcat listen mode")
68
+ _add(r'\bnc\b.*\s+-e\b', "netcat exec mode")
69
+
70
+ # ---- 持久化 / 定时任务 ----
71
+ _add(r'\bcrontab\b', "crontab modification")
72
+ _add(r'\bat\b\s', "at job scheduling")
73
+ _add(r'\bsystemctl\b\s+enable\b', "systemctl enable")
74
+
75
+ # ---- 网络/防火墙 ----
76
+ _add(r'\biptables\b', "iptables modification")
77
+ _add(r'\bnft\b\s', "nftables modification")
78
+
79
+ # ---- 挂载操作 ----
80
+ _add(r'\bmount\b\s', "mount filesystem")
81
+ _add(r'\bumount\b\s', "unmount filesystem")
82
+
83
+ # ---- 裸设备/内存访问 ----
84
+ _add(r'>\s*/dev/(?:mem|kmem|port)\b', "raw memory/port access")
85
+
86
+ # ---- 编码混淆绕过 ----
87
+ _add(r'base64\s+(?:-d|--decode).*\|?\s*(?:bash|sh|zsh|python|perl|ruby)\b', "base64 decode → execute")
88
+ _add(r'xxd\s+-r\b.*\|?\s*(?:bash|sh|zsh)\b', "xxd decode → execute")
89
+
90
+ # ---- Fork bomb ----
91
+ _add(r':\(\)\s*\{.*:\s*\|.*:.*&.*\}', "fork bomb")
92
+
93
+ del _add # 清理辅助函数,避免污染模块命名空间
94
+
95
+
96
+ # ============================================================
97
+ # SandboxManager
98
+ # ============================================================
99
+
100
+ class SandboxManager:
101
+ """命令黑名单过滤器。
102
+
103
+ 使用方式:
104
+ config = SandboxConfig(enabled=True, excluded_commands=["docker"])
105
+ sandbox = SandboxManager(config)
106
+ allowed, reason = sandbox.check("rm -rf /")
107
+ """
108
+
109
+ def __init__(self, config: SandboxConfig | None = None) -> None:
110
+ self._config = config
111
+
112
+ def check(self, command: str) -> tuple[bool, str]:
113
+ """检查命令是否允许执行。
114
+
115
+ 返回 (True, "") 表示允许,(False, reason) 表示拒绝。
116
+ """
117
+ config = self._config
118
+
119
+ if config:
120
+ # 1. 排除列表优先:excluded_commands 开头的命令跳过所有检查(白名单豁免)
121
+ for exc in config.excluded_commands:
122
+ if command.strip().startswith(exc):
123
+ return True, ""
124
+
125
+ # 2. 命令精确黑名单(blocked_commands)
126
+ cmd_name = command.strip().split()[0] if command.strip() else ""
127
+ if cmd_name and cmd_name in config.blocked_commands:
128
+ preview = _preview(command)
129
+ reason = f"Sandbox blocked: forbidden command '{cmd_name}': {preview}"
130
+ logger.info(reason)
131
+ return False, reason
132
+
133
+ # 3. 内置正则规则
134
+ for pattern, desc in _BUILTIN_RULES:
135
+ if pattern.search(command):
136
+ preview = _preview(command)
137
+ reason = f"Sandbox blocked: {desc}: {preview}"
138
+ logger.info(reason)
139
+ return False, reason
140
+
141
+ # 4. 用户追加的正则规则
142
+ if config and config.extra_patterns:
143
+ for pat_str, desc in config.extra_patterns:
144
+ try:
145
+ if re.search(pat_str, command, re.I):
146
+ preview = _preview(command)
147
+ reason = f"Sandbox blocked: {desc}: {preview}"
148
+ logger.info(reason)
149
+ return False, reason
150
+ except re.error:
151
+ logger.warning("Invalid sandbox extra pattern: %s", pat_str)
152
+
153
+ return True, ""
154
+
155
+ def check_path(self, file_path: str, operation: str = "write") -> tuple[bool, str]:
156
+ """检查文件路径是否受保护(禁止写入/读取的目录)。
157
+
158
+ operation: "write" 或 "read"
159
+ 返回 (True, "") 表示允许,(False, reason) 表示拒绝。
160
+ """
161
+ return _check_path(file_path, operation)
162
+
163
+ def check_network(self, command: str) -> tuple[bool, str]:
164
+ """检查命令中网络外发操作的目标域名是否合规。
165
+
166
+ 依赖 config 中的 allowed_domains / denied_domains 配置。
167
+ 两者都为空时不做任何限制(向后兼容)。
168
+ 返回 (True, "") 表示允许,(False, reason) 表示拒绝。
169
+ """
170
+ if self._config is None:
171
+ return True, ""
172
+ return _check_network(command, self._config.allowed_domains, self._config.denied_domains)
173
+
174
+
175
+ def _preview(command: str, max_len: int = 80) -> str:
176
+ return command[:max_len] + ("..." if len(command) > max_len else "")
core/sandbox/config.py ADDED
@@ -0,0 +1,38 @@
1
+ """沙箱配置:SandboxConfig dataclass + 从 super-code.json 解析。"""
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import dataclass, field
5
+
6
+
7
+ @dataclass
8
+ class SandboxConfig:
9
+ """沙箱所有可配置项。"""
10
+
11
+ enabled: bool = False
12
+ # 命令级黑名单(精确匹配命令名,如 "mkfs")
13
+ blocked_commands: list[str] = field(default_factory=list)
14
+ # 正则黑名单(额外规则,追加到内置规则之后)
15
+ extra_patterns: list[tuple[str, str]] = field(default_factory=list)
16
+ # 跳过沙箱检查的命令前缀(如 "docker", "bazel")
17
+ excluded_commands: list[str] = field(default_factory=list)
18
+ # 沙箱激活时自动批准 Bash 调用(因为命令已被黑名单保护)
19
+ auto_approve_if_sandboxed: bool = False
20
+ # 网络外发域名白名单(非空时:只允许列表内的域名外发)
21
+ allowed_domains: list[str] = field(default_factory=list)
22
+ # 网络外发域名黑名单(命中直接拒绝,优先级高于白名单)
23
+ denied_domains: list[str] = field(default_factory=list)
24
+
25
+ @classmethod
26
+ def from_dict(cls, d: dict | None) -> "SandboxConfig":
27
+ """从 super-code.json 的 sandbox 段创建配置。"""
28
+ if not d:
29
+ return cls()
30
+ return cls(
31
+ enabled=bool(d.get("enabled", False)),
32
+ blocked_commands=list(d.get("blocked_commands") or []),
33
+ extra_patterns=list(d.get("extra_patterns") or []),
34
+ excluded_commands=list(d.get("excluded_commands") or []),
35
+ auto_approve_if_sandboxed=bool(d.get("auto_approve_if_sandboxed", False)),
36
+ allowed_domains=list(d.get("allowed_domains") or []),
37
+ denied_domains=list(d.get("denied_domains") or []),
38
+ )
@@ -0,0 +1,136 @@
1
+ """网络外发控制:从命令中提取目标域名,按黑白名单过滤。
2
+
3
+ Phase 4 — 防止 curl/wget/Invoke-WebRequest 将数据外泄到未授权域名。
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import re
8
+ import logging
9
+ from urllib.parse import urlparse
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+ # 网络外发命令匹配(curl、wget、PowerShell Invoke-WebRequest/iwr)
14
+ # 使用 \b 单词边界匹配,覆盖 /usr/bin/curl 这类带路径的调用
15
+ _NET_CMD_RE = re.compile(
16
+ r'\b(?:curl(?:\.exe)?|wget(?:\.exe)?|Invoke-WebRequest|iwr)\b',
17
+ re.I,
18
+ )
19
+
20
+ # 从命令中提取 URL(带 scheme 的完整 URL,或带路径/端口的裸域名)
21
+ _URL_RE = re.compile(
22
+ r'(?:https?|ftp)://[^\s"\';&|`$()<>]+' # http(s)://host/path
23
+ r'|' # 或
24
+ r'(?:^|\s)(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}' # 裸域名
25
+ r'(?::\d+(?:/\S*)?|/\S+)', # 必须有路径或端口(区分文件参数)
26
+ re.I,
27
+ )
28
+
29
+ # 检测 curl/wget 的文件上传参数(-d @file、--data @file、-T file、--upload-file file、--post-file file)
30
+ # 上传本地文件是数据外泄的关键指标
31
+ _FILE_UPLOAD_RE = re.compile(
32
+ r'(?:-d|--data(?:-raw|-binary|-urlencode)?)[\s=]*@\S|' # -d@file、-d @file、--data=@file
33
+ r'(?:-T|--upload-file)\s+\S|' # -T file、--upload-file file
34
+ r'--post-file[=\s]\S', # --post-file=path、--post-file path
35
+ re.I,
36
+ )
37
+
38
+
39
+ def check_network(command: str, allowed: list[str], denied: list[str]) -> tuple[bool, str]:
40
+ """检查命令中网络外发操作的目标域名是否合规。
41
+
42
+ Args:
43
+ command: 完整的 shell 命令
44
+ allowed: 域名白名单(非空时只允许列表中的域名)
45
+ denied: 域名黑名单(命中直接拒绝)
46
+
47
+ Returns:
48
+ (True, "") 允许执行,(False, reason) 拒绝。
49
+ """
50
+ # 1. 判断是否为网络外发命令
51
+ if not _NET_CMD_RE.search(command):
52
+ return True, ""
53
+
54
+ # 2. 文件上传检查(独立于域名配置,始终生效)
55
+ if _FILE_UPLOAD_RE.search(command):
56
+ preview = command[:80] + ("..." if len(command) > 80 else "")
57
+ reason = f"Sandbox blocked: file upload via network command: {preview}"
58
+ logger.info(reason)
59
+ return False, reason
60
+
61
+ # 3. 无域名配置 → 不限制(文件上传已在上一步拦截)
62
+ if not allowed and not denied:
63
+ return True, ""
64
+
65
+ # 4. 提取命令中所有 URL(用 finditer,避免 capturing group 导致 findall 只返回 scheme)
66
+ urls = [m.group(0) for m in _URL_RE.finditer(command)]
67
+ if not urls:
68
+ return True, ""
69
+
70
+ # 5. 对每个 URL 提取域名并检查
71
+ for raw_url in urls:
72
+ url_str = raw_url.strip()
73
+ hostname = _extract_hostname(url_str)
74
+
75
+ if not hostname:
76
+ continue
77
+
78
+ # 记录请求 URL(用于日志和错误信息,脱敏处理 query 参数)
79
+ preview = _safe_url_preview(url_str)
80
+
81
+ # 6. 黑名单优先
82
+ for denied_domain in denied:
83
+ if _domain_match(hostname, denied_domain):
84
+ reason = f"Sandbox blocked: network request to denied domain '{hostname}': {preview}"
85
+ logger.info(reason)
86
+ return False, reason
87
+
88
+ # 7. 白名单检查(白名单非空时,未命中 = 拒绝)
89
+ if allowed:
90
+ matched = any(_domain_match(hostname, a) for a in allowed)
91
+ if not matched:
92
+ reason = f"Sandbox blocked: network request to unlisted domain '{hostname}': {preview}"
93
+ logger.info(reason)
94
+ return False, reason
95
+
96
+ return True, ""
97
+
98
+
99
+ def _extract_hostname(url_str: str) -> str:
100
+ """从 URL 中提取主机名。
101
+
102
+ 处理两种格式:
103
+ - http(s)://host/path → urlparse 正常解析
104
+ - host/path, host:8080/path → 裸域名,需手动提取
105
+ """
106
+ if "://" in url_str:
107
+ try:
108
+ parsed = urlparse(url_str)
109
+ return parsed.hostname or ""
110
+ except Exception:
111
+ return ""
112
+ # 裸域名:取第一个 '/' 或 ':' 之前的部分作为 hostname
113
+ bare = url_str.split("/")[0] # "host:8080" or "host"
114
+ return bare.split(":")[0] # "host"
115
+
116
+
117
+ def _domain_match(hostname: str, rule: str) -> bool:
118
+ """域名匹配:精确匹配或子域名匹配。
119
+
120
+ rule="example.com" 匹配 example.com 和 *.example.com
121
+ rule=".example.com" 等价于 rule="example.com"
122
+ """
123
+ rule = rule.lstrip(".")
124
+ return hostname == rule or hostname.endswith("." + rule)
125
+
126
+
127
+ def _safe_url_preview(url: str, max_len: int = 80) -> str:
128
+ """URL 预览(截断前先尝试去掉 query 参数,避免 token 信息泄漏到日志)。"""
129
+ try:
130
+ parsed = urlparse(url)
131
+ clean = f"{parsed.scheme}://{parsed.hostname}{parsed.path or ''}"
132
+ if clean and len(clean) > 20:
133
+ return clean[:max_len] + ("..." if len(clean) > max_len else "")
134
+ except Exception:
135
+ pass
136
+ return url[:max_len] + ("..." if len(url) > max_len else "")