diff --git a/dsh-server-docs/scripts/bash-output-guard.py b/dsh-server-docs/scripts/bash-output-guard.py index eb2df52..68a23ba 100644 --- a/dsh-server-docs/scripts/bash-output-guard.py +++ b/dsh-server-docs/scripts/bash-output-guard.py @@ -110,6 +110,22 @@ def log(root, detail): CAT_BIG = 200 * 1024 # `cat` 目标文件 > 200 KB 才值得拦(小文件放行 ⇒ 防日常误伤) +READ_BIG = 400 * 1024 # `Read` 目标文件 > 400 KB ⇒ 至少几万 token ⇒ 拦(2026-09-15 实测补) +# ↑ 为什么补 Read:本钩子最初只管 Bash,但实测 `Read` 是**第二大输出源** —— +# 某会话 44 次 / 累计 238 K 字符、单条最大 33 K 字符(≈13 K token);另一会话 136 次 / 217 K 字符, +# 且**完全没有被任何机制覆盖**。⇒ "想把大输出从上下文里去掉",就必须覆盖所有会灌大输出的工具。 + + +def read_too_big(payload): + """`Read` 一个大文件 ⇒ True。取不到大小(文件不存在/相对路径)⇒ False(放行)。""" + ti = payload.get('tool_input') or {} + p = ti.get('file_path') or ti.get('path') or '' + if not isinstance(p, str) or not p: + return False + try: + return os.path.getsize(p) > READ_BIG + except OSError: + return False def cat_bigfile(cmd): @@ -169,7 +185,8 @@ def main(): return if (payload.get('hook_event_name') or '') != 'PreToolUse': return - if (payload.get('tool_name') or '') != 'Bash': + tool = payload.get('tool_name') or '' + if tool not in ('Bash', 'Read'): # 覆盖**所有**会灌大输出的工具(2026-09-15 由用户点出补 Read) return if os.environ.get('DSH_OUTPUT_GUARD_OFF'): return @@ -178,6 +195,19 @@ def main(): return except Exception: pass + if tool == 'Read': + if read_too_big(payload): + fp = str(((payload.get('tool_input') or {}).get('file_path')) or '') + log(str(root), 'DENY|Read 大文件|%s' % fp[:110]) + _emit({'hookSpecificOutput': { + 'hookEventName': 'PreToolUse', 'permissionDecision': 'deny', + 'permissionDecisionReason': ( + '💰 拦下:**Read 大文件**(`%s`)—— 它的**全文会写进会话历史、之后每轮全量重发**。\n' + '✅ 分段读:`Read` 加 `offset` / `limit`(例 `limit: 120`)只取你要的那段;' + '或先 `grep -n "关键词" 文件 | head -20` 定位行号,再读那几行。\n' + 'ℹ️ 确实要通读 ⇒ 用 Bash 分批 `sed -n \'1,200p\' 文件`;急停 env `DSH_OUTPUT_GUARD_OFF=1` ' + '或 `.workbuddy/bash-guard.disabled`。' % fp[:80])}}) + return cmd = ((payload.get('tool_input') or {}).get('command')) or '' if not cmd or SAFE.search(cmd): return