222 lines
8.5 KiB
Python
222 lines
8.5 KiB
Python
#!/usr/bin/env python3
|
||
"""shell_screen_check.py - 验证 /shell 记录里对全屏程序的处理
|
||
|
||
交互式 shell 的原始输出里混着转义序列、重绘空格和全屏程序的整屏画面。
|
||
不清洗的话模型看到的就是几百个空格加一串光标定位;清过头又会把命令和
|
||
状态行一起吃掉。这里用 printf 造出确定的输入来钉住行为:
|
||
|
||
1. 备用屏幕(vim/less/top 走这条路)——画面必须省略,只留首尾标记;
|
||
2. 成片空格(nano/top 靠空格重绘)——必须压掉,但短缩进要原样保留;
|
||
3. 大输出里夹着的备用屏幕——跨 read 也要能认出来。
|
||
|
||
用法:
|
||
shell_screen_check.py <ligent 二进制> <mock base-url>
|
||
"""
|
||
import glob
|
||
import json
|
||
import os
|
||
import pty
|
||
import re
|
||
import select
|
||
import shutil
|
||
import sys
|
||
import time
|
||
|
||
ALT_TEXT = "备用屏幕里的机密内容"
|
||
NORMAL_TEXT = "正常输出"
|
||
|
||
|
||
def drive(binary, base, commands, wait=1.6, term="xterm-256color"):
|
||
"""在 pty 里跑 ligent,进 /shell 执行 commands,导出后返回记录文本。
|
||
|
||
term=None 表示不设 TERM,用来观察退化行为。"""
|
||
saved = os.environ.copy()
|
||
os.environ["LIGENT_API_KEY"] = "sk-test"
|
||
# 没有 TERM 的话 less/vim 会退回非全屏模式,测不到备用屏幕那条路。
|
||
if term is None:
|
||
os.environ.pop("TERM", None)
|
||
else:
|
||
os.environ["TERM"] = term
|
||
|
||
# 每轮都清空归档:归档名是随机的,按文件名排序取"最后一个"会拿到上一轮。
|
||
bindir = os.path.dirname(os.path.abspath(binary))
|
||
shutil.rmtree(os.path.join(bindir, "ligent.archive"), ignore_errors=True)
|
||
|
||
pid, fd = pty.fork()
|
||
if pid == 0:
|
||
os.execv(binary, [binary, "--base-url", base, "--no-confirm", "--auto-save"])
|
||
os._exit(127)
|
||
|
||
# 行编辑器每次回到提示符都会打开括号粘贴 —— 用它当"轮到输入了"的信号。
|
||
# 只按"输出安静"等是不行的:等待动画每 250ms 画一帧,pty 永远不会安静
|
||
# 超过 idle,于是每次都要白等满整个额度。
|
||
PROMPT_READY = b"\x1b[?2004h"
|
||
ready_seen = 0
|
||
tail = b""
|
||
|
||
def note(chunk):
|
||
nonlocal tail, ready_seen
|
||
buf = tail + chunk
|
||
ready_seen += buf.count(PROMPT_READY)
|
||
tail = buf[-(len(PROMPT_READY) - 1):]
|
||
|
||
def drain(seconds, idle=0.4, until_ready=False):
|
||
want = ready_seen + 1 if until_ready else None
|
||
end = time.time() + seconds
|
||
last = time.time()
|
||
while time.time() < end:
|
||
r, _, _ = select.select([fd], [], [], 0.02)
|
||
if r:
|
||
try:
|
||
chunk = os.read(fd, 65536)
|
||
except OSError:
|
||
return
|
||
if not chunk:
|
||
return
|
||
note(chunk)
|
||
last = time.time()
|
||
if want is not None and ready_seen >= want:
|
||
return
|
||
elif time.time() - last > idle:
|
||
return
|
||
|
||
try:
|
||
drain(1.5, until_ready=True) # 等横幅画完、回到提示符
|
||
for line in ["/shell"] + commands + ["exit", "/export", "/exit"]:
|
||
os.write(fd, line.encode("utf-8") + b"\n")
|
||
drain(wait, until_ready=True)
|
||
finally:
|
||
for fn in (lambda: os.close(fd), lambda: os.waitpid(pid, 0)):
|
||
try:
|
||
fn()
|
||
except OSError:
|
||
pass
|
||
os.environ.clear()
|
||
os.environ.update(saved)
|
||
|
||
bindir = os.path.dirname(os.path.abspath(binary))
|
||
files = sorted(glob.glob(os.path.join(bindir, "ligent.archive", "*.json")))
|
||
if not files:
|
||
return None
|
||
with open(files[0], encoding="utf-8") as fh:
|
||
data = json.load(fh)
|
||
users = [m["content"] for m in data["messages"] if m["role"] == "user"]
|
||
return users[-1] if users else ""
|
||
|
||
|
||
def between_markers(rec, start="[全屏程序运行中,画面内容已省略]", end="[全屏程序结束]"):
|
||
"""取出"全屏程序"首尾标记之间的内容。
|
||
|
||
命令回显必然把输入里的字面文本也录进去,所以不能直接断言"机密文本不在
|
||
记录里"——只能断言它没出现在真正被省略的那段区间内。"""
|
||
parts = rec.split(start)
|
||
if len(parts) < 2:
|
||
return None
|
||
return parts[1].split(end)[0]
|
||
|
||
|
||
def main():
|
||
if len(sys.argv) < 3:
|
||
print("用法: shell_screen_check.py <二进制> <base-url>")
|
||
return 2
|
||
binary, base = sys.argv[1], sys.argv[2]
|
||
bindir = os.path.dirname(os.path.abspath(binary))
|
||
archive = os.path.join(bindir, "ligent.archive")
|
||
|
||
fails = 0
|
||
total = 0
|
||
|
||
def check(name, ok, detail=""):
|
||
nonlocal fails, total
|
||
total += 1
|
||
if ok:
|
||
print(" ✓ %s" % name)
|
||
else:
|
||
fails += 1
|
||
print(" ✗ %s%s" % (name, (" " + detail) if detail else ""))
|
||
|
||
# ---- 1. 备用屏幕:画面省略,只留首尾标记 ----
|
||
print("备用屏幕(vim/less 路径)")
|
||
rec = drive(binary, base, [
|
||
r"printf '\033[?1049h" + ALT_TEXT + r"\033[?1049l'",
|
||
"echo " + NORMAL_TEXT,
|
||
])
|
||
if rec is None:
|
||
check("拿到记录", False, "没有生成归档")
|
||
return 1
|
||
check("拿到记录", True)
|
||
body = between_markers(rec)
|
||
check("首尾标记成对出现", body is not None, "没有找到完整的一对标记")
|
||
check("全屏区间内没有画面内容", body is not None and ALT_TEXT not in body,
|
||
"机密内容漏出来了")
|
||
check("留下开始标记", "全屏程序运行中" in rec)
|
||
check("留下结束标记", "全屏程序结束" in rec)
|
||
check("标记之外的输出还在", NORMAL_TEXT in rec)
|
||
check("记录里没有裸转义符", "\x1b" not in rec)
|
||
|
||
# ---- 2. 成片空格:压掉,但缩进保留 ----
|
||
print("成片空格(nano/top 重绘路径)")
|
||
rec = drive(binary, base, [
|
||
r"printf 'A'; printf '%0.s ' $(seq 1 200); printf 'B\n'",
|
||
r"printf ' 缩进四个空格\n'",
|
||
])
|
||
if rec is None:
|
||
check("拿到记录", False, "没有生成归档")
|
||
return 1
|
||
check("拿到记录", True)
|
||
check("200 个空格被压缩", not re.search(r" {9,}", rec), "还留着成片空白")
|
||
check("压缩后仍保留 A 和 B", "A" in rec and "B" in rec)
|
||
check("短缩进原样保留", " 缩进四个空格" in rec, "缩进被误伤")
|
||
blank = sum(1 for ch in rec if ch in " \n\t") / max(1, len(rec))
|
||
check("空白占比低于 40%", blank < 0.40, "实际 %.0f%%" % (100 * blank))
|
||
|
||
# ---- 3. 大输出里夹着的备用屏幕 ----
|
||
print("大输出(跨 read)里的备用屏幕")
|
||
rec = drive(binary, base, [
|
||
"head -c 8000 /dev/zero | tr '\\0' 'x'; printf '\\033[?1049h"
|
||
+ ALT_TEXT + "\\033[?1049l'",
|
||
])
|
||
if rec is None:
|
||
check("拿到记录", False, "没有生成归档")
|
||
return 1
|
||
check("拿到记录", True)
|
||
body = between_markers(rec)
|
||
check("跨块后仍认得出备用屏幕", body is not None and ALT_TEXT not in body,
|
||
"切断后漏了画面")
|
||
check("跨块标记成对出现", body is not None)
|
||
|
||
# ---- 4. 同一个会话里进两次全屏,标记必须成对 ----
|
||
print("连续两次进入全屏")
|
||
rec = drive(binary, base, [
|
||
r"printf '\033[?1049hAAA\033[?1049l'; printf '\033[?1049hBBB\033[?1049l'",
|
||
])
|
||
if rec is None:
|
||
check("拿到记录", False, "没有生成归档")
|
||
return 1
|
||
check("拿到记录", True)
|
||
check("两次开始标记", rec.count("[全屏程序运行中,画面内容已省略]") == 2,
|
||
"实际 %d 个" % rec.count("[全屏程序运行中,画面内容已省略]"))
|
||
check("两次结束标记", rec.count("[全屏程序结束]") == 2,
|
||
"实际 %d 个" % rec.count("[全屏程序结束]"))
|
||
|
||
# ---- 5. TERM 退化时补一个像样的值 ----
|
||
# TERM=dumb 时 nano 会放弃备用屏幕、改成逐行重绘,整段忽略就失效了。
|
||
# 而 ligent 自己的 REPL 就在用光标定位,说明终端并非真的 dumb。
|
||
for bad in ("dumb", None):
|
||
label = "TERM=%s" % (bad if bad else "(未设置)")
|
||
print("退化的 %s" % label)
|
||
rec = drive(binary, base, ["echo TERM_IS=$TERM"], term=bad)
|
||
if rec is None:
|
||
check("拿到记录", False, "没有生成归档")
|
||
continue
|
||
check("%s 时补上可用 TERM" % label, "TERM_IS=xterm-256color" in rec,
|
||
"实际记录:%s" % rec.strip().replace("\n", " | ")[-80:])
|
||
|
||
shutil.rmtree(archive, ignore_errors=True)
|
||
print("\n%d 项检查,%d 项失败" % (total, fails))
|
||
return 1 if fails else 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|