ot-cli 0.2.1__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.
- openterminal/__init__.py +3 -0
- openterminal/agent/__init__.py +39 -0
- openterminal/agent/backend.py +180 -0
- openterminal/agent/build.py +115 -0
- openterminal/agent/middleware.py +111 -0
- openterminal/agent/model.py +41 -0
- openterminal/agent/prompt.py +63 -0
- openterminal/agent/runner.py +340 -0
- openterminal/agent/sysprobe.py +164 -0
- openterminal/agent/token_est.py +49 -0
- openterminal/app.py +52 -0
- openterminal/approval.py +7 -0
- openterminal/backend.py +3 -0
- openterminal/banner.py +7 -0
- openterminal/cli.py +426 -0
- openterminal/cmdset.py +7 -0
- openterminal/config.py +322 -0
- openterminal/connections/__init__.py +20 -0
- openterminal/connections/db.py +240 -0
- openterminal/connections/models.py +289 -0
- openterminal/connections/secrets.py +68 -0
- openterminal/connections_db.py +6 -0
- openterminal/core/__init__.py +41 -0
- openterminal/core/ai_flow.py +781 -0
- openterminal/core/auth.py +499 -0
- openterminal/core/cmdset_runner.py +352 -0
- openterminal/core/exec_events.py +650 -0
- openterminal/core/history.py +79 -0
- openterminal/core/hook_gate.py +415 -0
- openterminal/core/messages.py +193 -0
- openterminal/core/nested_ssh.py +178 -0
- openterminal/core/pipeline.py +1187 -0
- openterminal/core/pump.py +190 -0
- openterminal/core/transcript.py +26 -0
- openterminal/frontend/__init__.py +21 -0
- openterminal/frontend/approval.py +40 -0
- openterminal/frontend/banner.py +40 -0
- openterminal/frontend/headless.py +290 -0
- openterminal/frontend/render.py +41 -0
- openterminal/frontend/taskview.py +184 -0
- openterminal/frontend/term.py +1417 -0
- openterminal/headless.py +7 -0
- openterminal/history_db.py +7 -0
- openterminal/local_pty.py +3 -0
- openterminal/local_win.py +3 -0
- openterminal/policy.py +362 -0
- openterminal/render.py +7 -0
- openterminal/secrets_store.py +5 -0
- openterminal/session/__init__.py +17 -0
- openterminal/session/base.py +328 -0
- openterminal/session/local_posix.py +184 -0
- openterminal/session/local_win.py +173 -0
- openterminal/session/ssh.py +321 -0
- openterminal/shell/__init__.py +17 -0
- openterminal/shell/cmdset.py +49 -0
- openterminal/shell/integration.py +1298 -0
- openterminal/shell_integration.py +7 -0
- openterminal/shell_session.py +8 -0
- openterminal/skills/__init__.py +45 -0
- openterminal/skills/markdown-tables/SKILL.md +247 -0
- openterminal/ssh_pty.py +6 -0
- openterminal/sysprobe.py +7 -0
- openterminal/taskview.py +7 -0
- openterminal/term_frontend.py +7 -0
- openterminal/token_est.py +3 -0
- openterminal/transcript.py +7 -0
- openterminal/web/__init__.py +1 -0
- openterminal/web/files_api.py +789 -0
- openterminal/web/frontend/index.html +37 -0
- openterminal/web/frontend/static/app.js +3632 -0
- openterminal/web/frontend/static/block_stream.js +274 -0
- openterminal/web/frontend/static/block_view.js +383 -0
- openterminal/web/frontend/static/files_panel.js +782 -0
- openterminal/web/frontend/static/md_lite.js +127 -0
- openterminal/web/frontend/static/modal.js +122 -0
- openterminal/web/frontend/static/pad_ledger.js +46 -0
- openterminal/web/frontend/static/style.css +915 -0
- openterminal/web/frontend/static/ui/ui.css +1 -0
- openterminal/web/frontend/static/ui/ui.js +6292 -0
- openterminal/web/frontend/vendor/addon-fit.js +2 -0
- openterminal/web/frontend/vendor/xterm.css +8 -0
- openterminal/web/frontend/vendor/xterm.js +8 -0
- openterminal/web/protocol.py +41 -0
- openterminal/web/server.py +322 -0
- openterminal/web/worker.py +62 -0
- ot_cli-0.2.1.dist-info/METADATA +439 -0
- ot_cli-0.2.1.dist-info/RECORD +91 -0
- ot_cli-0.2.1.dist-info/WHEEL +5 -0
- ot_cli-0.2.1.dist-info/entry_points.txt +3 -0
- ot_cli-0.2.1.dist-info/licenses/LICENSE +202 -0
- ot_cli-0.2.1.dist-info/top_level.txt +1 -0
openterminal/headless.py
ADDED
openterminal/policy.py
ADDED
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
"""命令分级:auto(只读自动)/ approve(高危审批)/ deny(灾难拒绝)。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import shlex
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import Literal
|
|
10
|
+
|
|
11
|
+
Level = Literal["auto", "approve", "deny"]
|
|
12
|
+
|
|
13
|
+
_SEP_RE = re.compile(r"&&|\|\||;|\|")
|
|
14
|
+
_SUBST_RE = re.compile(r"\$\((.*?)\)|`([^`]*)`", re.S)
|
|
15
|
+
_REDIRECT_RE = re.compile(r">>?\s*([^\s;|&><]+)")
|
|
16
|
+
# 黑洞目标:重定向进这些只是丢弃流,一个文件都不碰。
|
|
17
|
+
# 不放行的话 PowerShell 的 `2>$null`(真机上模型查 docker 服务的惯用写法)
|
|
18
|
+
# 会被判「重定向覆盖文件」,整条只读查询顶成待审批、任务合法挂起,
|
|
19
|
+
# 页面停在「AI 正在思考」(bug2 的卡死点,见 b2-round1.png 审批卡)。
|
|
20
|
+
# 大小写不敏感:PowerShell/Windows 的 $null、NUL 都吃。
|
|
21
|
+
_NULL_SINKS = frozenset({"/dev/null", "$null", "null", "nul"})
|
|
22
|
+
# Windows 目标上模型调命令常带可执行后缀:where.exe / docker.exe / net.exe。
|
|
23
|
+
# 不归一就落不进对应的只读/子命令表,一律兜底审批。
|
|
24
|
+
_EXE_SUFFIX_RE = re.compile(r"\.(?:exe|com|cmd|bat|ps1)$", re.I)
|
|
25
|
+
# 控制流词:真命令在 (...) 条件与 {...} 块体里,裸看首词只会兜底审批。
|
|
26
|
+
_CTRL_VERBS = frozenset({
|
|
27
|
+
"if", "while", "foreach", "for", "switch", "else", "elseif", "try", "trap",
|
|
28
|
+
"foreach-object", # PowerShell 的 ForEach { … } 走同一条块体重判
|
|
29
|
+
})
|
|
30
|
+
# 条件/块体里的非命令片段:裸字面量、变量读取、成员读取(`ForEach { $_.Name }`)。
|
|
31
|
+
# 判成命令就把整段兜底成审批——真机 `Get-Service *docker* | ForEach { $_.Name }`
|
|
32
|
+
# 就是这么被顶成待审批的。
|
|
33
|
+
_LITERAL_PIECE_RE = re.compile(
|
|
34
|
+
r"^(?:\d+|\$?true|\$?false|\$(?:_|[A-Za-z][\w]*)(?:\.[\w]+)*)$", re.I)
|
|
35
|
+
_PIECE_RE = re.compile(r"\(([^()]*)\)|\{([^{}]*)\}", re.S)
|
|
36
|
+
_ASSIGN_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=")
|
|
37
|
+
|
|
38
|
+
_READ_VERBS = frozenset({
|
|
39
|
+
"ls", "cat", "pwd", "echo", "ps", "head", "tail", "grep", "find", "df", "du",
|
|
40
|
+
"free", "uptime", "whoami", "command", "which", "where", "wc", "sort", "uniq", "diff",
|
|
41
|
+
"stat", "date", "env", "printenv", "uname", "hostname", "id", "realpath",
|
|
42
|
+
"basename", "dirname", "md5sum", "sha256sum", "file", "type", "lsblk",
|
|
43
|
+
"lscpu", "free", "ip", "ifconfig", "netstat", "ss", "lsof", "journalctl",
|
|
44
|
+
# PowerShell 只读 cmdlet(Windows 目标):不列则本地任务步步进审批
|
|
45
|
+
"Get-Location", "Get-ChildItem", "Get-Content", "Get-Item",
|
|
46
|
+
"Get-ItemProperty", "Get-Process", "Get-Service", "Get-Date",
|
|
47
|
+
"Get-TimeZone", "Get-Command", "Get-Help", "Get-Member", "Get-Alias",
|
|
48
|
+
"Get-Variable", "Get-History", "Get-PSDrive", "Get-PSProvider",
|
|
49
|
+
"Get-ComputerInfo", "Get-NetIPAddress", "Get-NetAdapter", "Get-NetRoute",
|
|
50
|
+
"Test-Path", "Select-String", "Select-Object", "Where-Object", "Sort-Object",
|
|
51
|
+
"Measure-Object", "Format-Table", "Format-List", "Format-Wide",
|
|
52
|
+
"Out-String", "Out-Host", "Out-Null", "Write-Output", "Write-Host", "group",
|
|
53
|
+
"Measure-Command", "Compare-Object", "dir", "tree", "attrib",
|
|
54
|
+
# 短别名(系统提示词「PowerShell 优先短别名(Sort/Select/Where)」):
|
|
55
|
+
# 不收进来的话模型听话用了别名,只读查询照样落兜底审批。
|
|
56
|
+
# ForEach/% 不在此列:它们是块体执行器,走 _CTRL_VERBS 逐段重判。
|
|
57
|
+
"Select", "Where", "Measure", "ft", "fl", "fw", "sls",
|
|
58
|
+
"gi", "gci", "gc", "gps", "gsv", "gcm", "gdr", "gmo", "gv", "rvpa",
|
|
59
|
+
})
|
|
60
|
+
_GIT_READ = frozenset({
|
|
61
|
+
"status", "log", "diff", "show", "branch", "remote", "rev-parse", "blame",
|
|
62
|
+
"ls-files", "describe", "shortlog", "whatchanged",
|
|
63
|
+
})
|
|
64
|
+
# ip 的修改型子命令:出现即视为改动网络配置(show 类仍自动)
|
|
65
|
+
_IP_MUTATE = frozenset({
|
|
66
|
+
"add", "del", "delete", "set", "flush", "replace", "change", "append",
|
|
67
|
+
})
|
|
68
|
+
# 删除/改动文件类命令:进审批面板时展示明确风险理由
|
|
69
|
+
_DELETE_REASON = {
|
|
70
|
+
"rm": "删除文件", "rmdir": "删除目录", "unlink": "删除文件/链接",
|
|
71
|
+
"shred": "不可恢复地删除文件",
|
|
72
|
+
}
|
|
73
|
+
# docker 只读子命令:纯查看容器/镜像/网络/卷状态
|
|
74
|
+
_DOCKER_READ = frozenset({
|
|
75
|
+
"ps", "images", "inspect", "logs", "events", "port", "top", "stats",
|
|
76
|
+
"version", "info", "diff", "history", "search", "wait", "list", "ls", "df",
|
|
77
|
+
"help",
|
|
78
|
+
})
|
|
79
|
+
# docker 的对象管理组:第二个词决定读写(ls/inspect 查看,create/rm 改动)
|
|
80
|
+
_DOCKER_GROUPS = frozenset({
|
|
81
|
+
"container", "image", "network", "volume", "system", "node", "service",
|
|
82
|
+
"stack", "secret", "config",
|
|
83
|
+
})
|
|
84
|
+
_ROOTISH = {"/", "/*", "~", "~/*", "$HOME", "$HOME/*"}
|
|
85
|
+
_DEVICE_RE = re.compile(r"^/dev/(sd|nvme|vd|disk)")
|
|
86
|
+
_BOOT_RE = re.compile(r"^/(boot|EFI)(/|$)")
|
|
87
|
+
_FORBIDDEN_VERBS = frozenset({"shutdown", "halt", "poweroff", "reboot"})
|
|
88
|
+
# 只读查询型首选项:只打印列表/状态/版本/用法,不改动状态。
|
|
89
|
+
# 不放行的话,模型探测 `wsl -l -v` / `docker --version` 会把整条「查看容器」
|
|
90
|
+
# 顶成待审批,任务合法挂起、页面停在「AI 正在思考」(真机 bug2 的卡死点)。
|
|
91
|
+
# 用法见 _classify_segment:只在「首项是查询旗标 且 后面全是选项」时放行,
|
|
92
|
+
# 带目标/负载的(`umount -l /mnt`、`rm -l file`)落回逐词判定。
|
|
93
|
+
_READ_VERBS_L = frozenset(v.lower() for v in _READ_VERBS)
|
|
94
|
+
_QUERY_LEAD = frozenset({
|
|
95
|
+
"-l", "--list", "--status", "--info", "--show", "--print", "--dump",
|
|
96
|
+
"-V", "--version", "-h", "--help", "help", "--usage", "-?",
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _docker_action(word: str) -> str:
|
|
101
|
+
"""docker 子命令/对象动作的旗标写法归一:-v/--version → version。
|
|
102
|
+
|
|
103
|
+
`docker --version`、`docker container --help` 这类查询被落进改动分支,
|
|
104
|
+
整条命令就进审批了。"""
|
|
105
|
+
return {
|
|
106
|
+
"-v": "version", "--version": "version",
|
|
107
|
+
"-h": "help", "--help": "help",
|
|
108
|
+
}.get(word, word)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
_ORDER = {"auto": 0, "approve": 1, "deny": 2}
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@dataclass
|
|
115
|
+
class Decision:
|
|
116
|
+
level: Level
|
|
117
|
+
reasons: list[str]
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _normalize(cmd: str) -> str:
|
|
121
|
+
return re.sub(r"\s+", " ", cmd.strip())
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _drop_assignments(tokens: list[str]) -> list[str]:
|
|
125
|
+
while tokens and _ASSIGN_RE.match(tokens[0]):
|
|
126
|
+
tokens = tokens[1:]
|
|
127
|
+
return tokens
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
# 重定向词:`>`、`>>`、`2>file`、`&>`、`2>&1` 这些都不是命令参数。
|
|
131
|
+
# 目标文件已在 _classify_segment 的 _REDIRECT_RE 里审过,这里只管从词表里
|
|
132
|
+
# 剥掉尾巴,否则 `wsl -l -v 2>NUL` 的 `2>NUL` 顶掉「全是选项」的放行。
|
|
133
|
+
_REDIR_TOKEN_RE = re.compile(r"^(?:\d+|&)?>>?")
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _drop_redirects(tokens: list[str]) -> list[str]:
|
|
137
|
+
return [t for t in tokens if not _REDIR_TOKEN_RE.match(t)]
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _classify_segment(seg: str) -> Decision:
|
|
141
|
+
"""对单条(无连接符)命令分级。"""
|
|
142
|
+
reasons: list[str] = []
|
|
143
|
+
|
|
144
|
+
# 重定向目标检查(可能 deny)。黑洞目标直接跳过:丢弃流既不 deny
|
|
145
|
+
# 也不算「覆盖文件」,否则只读命令被自己的 2>$null 顶成待审批。
|
|
146
|
+
for target in _REDIRECT_RE.findall(seg):
|
|
147
|
+
if target.lower() in _NULL_SINKS:
|
|
148
|
+
continue
|
|
149
|
+
if _DEVICE_RE.match(target) or _BOOT_RE.match(target):
|
|
150
|
+
return Decision("deny", [f"重定向写入危险目标 {target}"])
|
|
151
|
+
if target not in _ROOTISH:
|
|
152
|
+
reasons.append("重定向覆盖文件")
|
|
153
|
+
|
|
154
|
+
try:
|
|
155
|
+
tokens = shlex.split(seg, posix=True)
|
|
156
|
+
except ValueError:
|
|
157
|
+
return Decision("approve", ["命令语法无法静态解析"])
|
|
158
|
+
tokens = _drop_redirects(_drop_assignments(tokens))
|
|
159
|
+
if not tokens:
|
|
160
|
+
return Decision("auto", [])
|
|
161
|
+
|
|
162
|
+
verb = _EXE_SUFFIX_RE.sub("", os.path.basename(tokens[0]))
|
|
163
|
+
args = tokens[1:]
|
|
164
|
+
joined = " ".join(args)
|
|
165
|
+
|
|
166
|
+
# sudo:先看内部命令是否灾难,否则提权审批
|
|
167
|
+
if verb == "sudo":
|
|
168
|
+
inner = _classify_segment(" ".join(args))
|
|
169
|
+
if inner.level == "deny":
|
|
170
|
+
return inner
|
|
171
|
+
return Decision("approve", ["sudo 提权执行"] + inner.reasons)
|
|
172
|
+
|
|
173
|
+
# deny 规则
|
|
174
|
+
if verb == "rm":
|
|
175
|
+
flags = "".join(a for a in args if a.startswith("-") and not a.startswith("--"))
|
|
176
|
+
recursive = "r" in flags or "R" in flags
|
|
177
|
+
if recursive and any(a in _ROOTISH for a in args):
|
|
178
|
+
return Decision("deny", ["递归删除根/家目录"])
|
|
179
|
+
if verb.startswith("mkfs"):
|
|
180
|
+
return Decision("deny", ["格式化文件系统"])
|
|
181
|
+
if verb == "dd":
|
|
182
|
+
if any(re.match(r"of=/dev/(sd|nvme|vd|disk)", a) for a in args):
|
|
183
|
+
return Decision("deny", ["dd 直写块设备"])
|
|
184
|
+
if ":(){" in seg.replace(" ", ""):
|
|
185
|
+
return Decision("deny", ["fork 炸弹"])
|
|
186
|
+
if verb in _FORBIDDEN_VERBS or (verb == "init" and "0" in args):
|
|
187
|
+
return Decision("deny", ["关机/重启会中断远程会话"])
|
|
188
|
+
if verb == "chmod" and any(a in tokens for a in ("-R", "-r")):
|
|
189
|
+
if "777" in args and any(a in _ROOTISH for a in args):
|
|
190
|
+
return Decision("deny", ["对根目录递归 777 授权"])
|
|
191
|
+
|
|
192
|
+
# 控制流:取括号条件 + 花括号块体逐段重判,取最严。条件/块体都空才落回
|
|
193
|
+
# 兜底(`for x in a b; do ...; done` 这种无括号写法维持原判定)。
|
|
194
|
+
if verb.lower() in _CTRL_VERBS:
|
|
195
|
+
raw = [q.strip() for a, b in _PIECE_RE.findall(seg)
|
|
196
|
+
for q in (a, b) if q.strip()]
|
|
197
|
+
if raw and not any(not _LITERAL_PIECE_RE.match(q) for q in raw):
|
|
198
|
+
# 条件/块体全是字面量与成员读取(`ForEach { $_.Name }`):
|
|
199
|
+
# 没有任何可执行动作,兜底审批只会白挂一次。
|
|
200
|
+
return Decision("auto", [])
|
|
201
|
+
pieces = [q for q in raw if not _LITERAL_PIECE_RE.match(q)]
|
|
202
|
+
if pieces:
|
|
203
|
+
worst = Decision("auto", [])
|
|
204
|
+
# 块体里也可能 `a; b` 并排:先按分隔符拆开再逐段重判,
|
|
205
|
+
# 否则 shlex 把 `Name;` 当一个词,改动作藏在分号后会被看漏。
|
|
206
|
+
for q in pieces:
|
|
207
|
+
for sub in _SEP_RE.split(q):
|
|
208
|
+
sub = sub.strip()
|
|
209
|
+
if not sub:
|
|
210
|
+
continue
|
|
211
|
+
d = _classify_segment(sub)
|
|
212
|
+
if _ORDER[d.level] > _ORDER[worst.level]:
|
|
213
|
+
worst = d
|
|
214
|
+
return worst
|
|
215
|
+
|
|
216
|
+
# 只读查询:首项是查询旗标、后面全是选项(无目标/无负载)→ 纯查询。
|
|
217
|
+
# 放 deny 之后,保证 `rm -rf / --help` / `shutdown -h now` 仍被拦。
|
|
218
|
+
if args and args[0] in _QUERY_LEAD and all(a.startswith("-") for a in args[1:]):
|
|
219
|
+
return Decision("auto", [])
|
|
220
|
+
|
|
221
|
+
# auto 规则
|
|
222
|
+
if verb == "git":
|
|
223
|
+
sub = args[0] if args else ""
|
|
224
|
+
if sub in _GIT_READ:
|
|
225
|
+
return Decision("auto", [])
|
|
226
|
+
if sub == "config":
|
|
227
|
+
# 读取(无参/--list/只给键名)自动;键值成对或带写入型 flag 即写配置
|
|
228
|
+
rest = [a for a in args[1:] if not a.startswith("-")]
|
|
229
|
+
write_flags = {"--unset", "--unset-all", "--add", "--replace-all",
|
|
230
|
+
"--edit"}
|
|
231
|
+
if write_flags & set(args) or len(rest) >= 2:
|
|
232
|
+
return Decision("approve", ["git config 写入配置"])
|
|
233
|
+
return Decision("auto", [])
|
|
234
|
+
return Decision("approve", ["git 写操作"])
|
|
235
|
+
if verb == "find":
|
|
236
|
+
if "-delete" in args or "-exec" in args:
|
|
237
|
+
return Decision("approve", ["find 带删除/执行动作"])
|
|
238
|
+
return Decision("auto", [])
|
|
239
|
+
if verb == "env":
|
|
240
|
+
rest = _drop_assignments(args)
|
|
241
|
+
if not rest:
|
|
242
|
+
return Decision("auto", [])
|
|
243
|
+
return _classify_segment(" ".join(rest))
|
|
244
|
+
if verb in ("docker", "podman"):
|
|
245
|
+
# 查看(ps/logs/inspect/…)自动;run/exec/rm/stop 等改动容器
|
|
246
|
+
# 环境的操作审批;exec 内嵌命令无法静态判定,保守审批
|
|
247
|
+
sub = _docker_action(args[0]) if args else ""
|
|
248
|
+
rest = args[1:]
|
|
249
|
+
if sub in _DOCKER_GROUPS:
|
|
250
|
+
obj = _docker_action(rest[0]) if rest else ""
|
|
251
|
+
if obj in _DOCKER_READ or obj == "show":
|
|
252
|
+
return Decision("auto", [])
|
|
253
|
+
return Decision("approve", [f"{verb} {sub} 改动容器环境"])
|
|
254
|
+
if sub == "compose":
|
|
255
|
+
action = _docker_action(rest[0]) if rest else ""
|
|
256
|
+
# compose config 是查看/校验编排文件,与 docker config 对象组无关
|
|
257
|
+
if action in _DOCKER_READ or action == "config":
|
|
258
|
+
return Decision("auto", [])
|
|
259
|
+
return Decision("approve", [f"{verb} compose 操作容器环境"])
|
|
260
|
+
if sub in _DOCKER_READ:
|
|
261
|
+
return Decision("auto", [])
|
|
262
|
+
if not sub:
|
|
263
|
+
return Decision("approve", [f"{verb} 缺少子命令,无法判定"])
|
|
264
|
+
return Decision("approve", [f"{verb} {sub} 操作容器环境"])
|
|
265
|
+
if verb == "ip" and args:
|
|
266
|
+
# ip 的 show/查询自动;出现修改型子命令(add/del/set/…)即审批
|
|
267
|
+
if any(a in _IP_MUTATE for a in args):
|
|
268
|
+
return Decision("approve", ["ip 修改网络配置"])
|
|
269
|
+
return Decision("auto", [])
|
|
270
|
+
# sc:PowerShell 里 `sc` 是 Set-Content 的别名——`sc query com.docker.service`
|
|
271
|
+
# 实为「把 com.docker.service 写进文件 query」(真机 2026-10-07 仓库根的
|
|
272
|
+
# query/start 两个空名文件就是这么来的)。cmd 的 sc.exe 才是服务控制器。
|
|
273
|
+
# shell 上下文不进策略,一律按写文件审批,绝不当只读服务查询放行。
|
|
274
|
+
if verb == "sc":
|
|
275
|
+
return Decision("approve", ["sc 在 PowerShell 里是 Set-Content(写文件)"])
|
|
276
|
+
if verb == "ifconfig":
|
|
277
|
+
# 无参/只给接口名 = 查看;更多参数 = 配置网卡
|
|
278
|
+
if len(args) <= 1:
|
|
279
|
+
return Decision("auto", [])
|
|
280
|
+
return Decision("approve", ["ifconfig 配置网卡"])
|
|
281
|
+
if verb == "hostname" and args:
|
|
282
|
+
return Decision("approve", ["修改主机名"])
|
|
283
|
+
if verb == "date" and any(a in ("-s", "--set") or a.startswith("-s")
|
|
284
|
+
for a in args):
|
|
285
|
+
return Decision("approve", ["设置系统时间"])
|
|
286
|
+
# PowerShell/Windows 命令名大小写不敏感(get-service == Get-Service)
|
|
287
|
+
if (verb in _READ_VERBS or verb.lower() in _READ_VERBS_L) and not reasons:
|
|
288
|
+
return Decision("auto", [])
|
|
289
|
+
if verb in _DELETE_REASON:
|
|
290
|
+
reasons.append(_DELETE_REASON[verb])
|
|
291
|
+
|
|
292
|
+
# 兜底:无法判定一律审批
|
|
293
|
+
if not reasons:
|
|
294
|
+
reasons.append("非只读命令或无法判定")
|
|
295
|
+
return Decision("approve", reasons)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
class Policy:
|
|
299
|
+
def __init__(
|
|
300
|
+
self,
|
|
301
|
+
*,
|
|
302
|
+
mode: str = "tiered",
|
|
303
|
+
auto_extra: list[str] | None = None,
|
|
304
|
+
approve_extra: list[str] | None = None,
|
|
305
|
+
deny_extra: list[str] | None = None,
|
|
306
|
+
) -> None:
|
|
307
|
+
self.mode = mode
|
|
308
|
+
self.auto_extra = {_normalize(c) for c in (auto_extra or [])}
|
|
309
|
+
self.approve_extra = {_normalize(c) for c in (approve_extra or [])}
|
|
310
|
+
self.deny_extra = {_normalize(c) for c in (deny_extra or [])}
|
|
311
|
+
|
|
312
|
+
def _segments(self, command: str) -> list[str]:
|
|
313
|
+
segs = [s.strip() for s in _SEP_RE.split(command) if s.strip()]
|
|
314
|
+
for m in _SUBST_RE.finditer(command):
|
|
315
|
+
inner = m.group(1) or m.group(2)
|
|
316
|
+
if inner.strip():
|
|
317
|
+
segs.append(inner.strip())
|
|
318
|
+
return segs or [command]
|
|
319
|
+
|
|
320
|
+
def classify(self, command: str) -> Decision:
|
|
321
|
+
norm = _normalize(command)
|
|
322
|
+
if norm in self.deny_extra:
|
|
323
|
+
return Decision("deny", ["用户 deny_extra 规则"])
|
|
324
|
+
if norm in self.auto_extra:
|
|
325
|
+
return Decision("auto", [])
|
|
326
|
+
if norm in self.approve_extra:
|
|
327
|
+
return Decision("approve", ["用户 approve_extra 规则"])
|
|
328
|
+
|
|
329
|
+
if self.mode == "deny-all":
|
|
330
|
+
return Decision("deny", ["策略模式 deny-all"])
|
|
331
|
+
if self.mode == "approve-all":
|
|
332
|
+
return Decision("approve", ["策略模式 approve-all"])
|
|
333
|
+
|
|
334
|
+
worst: Decision = Decision("auto", [])
|
|
335
|
+
order = {"auto": 0, "approve": 1, "deny": 2}
|
|
336
|
+
for seg in self._segments(norm):
|
|
337
|
+
d = _classify_segment(seg)
|
|
338
|
+
if order[d.level] > order[worst.level]:
|
|
339
|
+
worst = d
|
|
340
|
+
return worst
|
|
341
|
+
|
|
342
|
+
# 审批理由中属「无害」的白名单:仅命中这些 → normal(蓝框),其余高危(红框)
|
|
343
|
+
BENIGN_APPROVE_REASONS = frozenset({"重定向覆盖文件", "用户 approve_extra 规则"})
|
|
344
|
+
|
|
345
|
+
def needs_approval(self, command: str, allowed: set[str] | None = None) -> bool:
|
|
346
|
+
"""命令是否需进人审——HITL ``_when`` 与命令卡出生预判的唯一来源。
|
|
347
|
+
|
|
348
|
+
allowed:会话内「始终允许」累积的命令集(只增);命中即免审。
|
|
349
|
+
"""
|
|
350
|
+
if allowed and command in allowed:
|
|
351
|
+
return False
|
|
352
|
+
return self.classify(command).level == "approve"
|
|
353
|
+
|
|
354
|
+
def risk(self, command: str) -> str:
|
|
355
|
+
"""审批风险级:删除/提权/写改/无法判定等 → high;仅无害理由 → normal。
|
|
356
|
+
|
|
357
|
+
免审命令(auto,reasons 为空)无风险可言:一律 normal——否则 `echo`
|
|
358
|
+
这类只读命令在命令卡上顶着「⚠ 高危」红框(真机 2026-10-16)。
|
|
359
|
+
"""
|
|
360
|
+
reasons = set(self.classify(command).reasons)
|
|
361
|
+
return ("normal" if not reasons or reasons <= self.BENIGN_APPROVE_REASONS
|
|
362
|
+
else "high")
|
openterminal/render.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""session 域(阶段二 Task 12 重组)。
|
|
2
|
+
|
|
3
|
+
base.py——ShellSession 协议、BasePtySession、命令包装/捕获切片
|
|
4
|
+
(BEGIN/END 标记协议);local_posix.py(仅 POSIX 可导入:fcntl)/
|
|
5
|
+
local_win.py(仅 Windows 可导入:pywinpty)/ssh.py——三种 PTY 会话实现。
|
|
6
|
+
|
|
7
|
+
本 __init__ 只再导出 base 的公开名(对应旧 ``shell_session`` 模块的
|
|
8
|
+
命名空间);平台专属会话类各居其模块,旧路径 local_pty / local_win /
|
|
9
|
+
ssh_pty 壳直连对应子模块——不可在包级急切导入平台专属子模块
|
|
10
|
+
(Windows 上 fcntl 不存在,旧代码即靠「从不顶层导入 local_pty」存活)。
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from .base import ( # noqa: F401
|
|
14
|
+
BEGIN_MARKER, END_MARKER, BasePtySession, CommandResult, ShellSession,
|
|
15
|
+
_PASSWORD_PROMPT_RE, _needs_password_prompt, slice_capture,
|
|
16
|
+
truncate_output, wrap_command, wrap_passthrough,
|
|
17
|
+
)
|