#!/usr/bin/env python3 # -*- coding: utf-8 -*- """bash-output-guard —— `PreToolUse(Bash)`:在**命令执行前**拦下"会灌爆上下文"的读命令。 为什么需要(2026-09-15 实测) ──────────────────────────── 对话历史是 **append-only**:一次工具调用在转录里落两条记录 —— `function_call`(命令) + `function_call_result`(**输出正文**);**输出一旦生成就永久留在 messages 里,且每轮全量重发**。 实测某会话 **553 轮 × 平均 42 万 token = 2.33 亿 input**,而 output 仅占 **0.35%**。 ⇒ 想真正"把大输出从上下文里去掉",**唯一的时机就是它被生成之前**(模型无权删自己的历史)。 边界(重要) ────────── * **只拦"读"类高风险命令**,且**必须给出等价限流写法**(教而非堵)。 * **判不准就放行**(fail-open)—— 本机不是沙箱,但这类命令本身不破坏数据,误拦的代价只是麻烦。 * 急停:env `DSH_OUTPUT_GUARD_OFF=1`,或新建 `<工作区>/.workbuddy/bash-guard.disabled`。 * 命中才写一行日志 `<工作区>/.workbuddy/bash-guard.log`(用于调误报,上限 300 行)。 安装(`settings.json` 的 hooks 段 · **新增一条** · ⚠️ **需完全重启才生效**) ──────────────────────────────────────────────────────────────────── "PreToolUse": [ …, { "matcher": "Bash", "hooks": [{ "type": "command", "command": "\"\" \"<此脚本>\"", "timeout": 10 }] } ] ⛔ **别给本脚本加 `-E`**:`-E` 会屏蔽 `PYTHONUTF8`/`PYTHONIOENCODING` ⇒ stdin 回退 cp936 ⇒ 含中文的 payload 解析失败且**静默 fail-open**(同目录 `stop-dialog-guard.py` 已因此"白排查一天")。 """ import io import json import os import re import sys import time LOG_REL = os.path.join('.workbuddy', 'bash-guard.log') SCOPE = 'aliyun-dsh-server' # 兜底工作区:脚本位于 <工作区>/dsh-server-docs/scripts/ ⇒ 上溯三级 WS_FALLBACK = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) # (是否 core, 正则, 名称, 更省的写法) # ⚠️ 设计原则:**不能全拦** —— 误拦挡住正事 ⇒ 我会来回试/绕路 ⇒ 反而更贵;漏拦只是多花点 token。 # core=True = 几乎必然巨大、误拦率≈0 ⇒ **默认就拦** # core=False = 经常确实需要全量 ⇒ **仅 hard 模式拦**(默认放行) RULES = [ # cat:**按目标文件大小判定**(小文件不拦)—— 这是防日常误伤的关键,见 cat_bigfile() (True, '__CAT_BIGFILE__', '`cat` 大文件', '改用 `head -30 文件` / `sed -n \'1,30p\' 文件` / `wc -l 文件`'), # ⚠️ 2026-09-15 **回放今日 12 个会话的 4049 条真实命令**:本规则命中 62 次,抽样 **29/29 全是窄范围** # (单文件 / 具体目录),**0 条从根**;连 `grep -c` 这种本来只有几行的也被误伤。 # 旧正则 `\bgrep\b[^|;]*-[a-zA-Z]*[rR]` 还有 bug:`[^|;]*` 会匹配到**路径里的 `-server`** # (`-s`+`erve`+`r`)⇒ 凡路径含 `aliyun-dsh-server` 就必命中。 # ⇒ **误伤 >> 收益** ⇒ 降为 loose(仅 `hard` 模式拦),并把正则收紧到「选项紧跟 grep」。 (False, r'\bgrep\s+-[a-zA-Z]*[rR]\b', '递归 grep', '改用 `grep -rn … | head -30`,或 `grep -rc …`(只要计数)'), # ⚠️ 只拦 `-R`(递归全树);**不拦 `ls -la`**(含 l 会误伤日常);选项必须紧跟 ls(同样防路径误匹配) (True, r'\bls\s+-[a-zA-Z]*R\b', '`ls -R` 递归全树', '改用 `ls -la 目录 | head -20`,或 `ls 目录 | wc -l`'), (True, r'\bfind\s+(/[A-Za-z]|[A-Za-z]:)', '`find` 从盘符/根起全树扫', '改用 `find 具体目录 -maxdepth 3 … | head -20`'), (True, r'\bjournalctl\b(?!.*(-n\s*\d|head|--since))', '`journalctl` 无行数/时间限制', '改用 `journalctl -u X -n 50 --no-pager`(`--since` 也算限流,放行)'), (True, r'\bdmesg\b(?!.*head)', '`dmesg` 无行数限制', '改用 `dmesg | tail -30`'), (False, r'(^|[|;&]\s*)rg\b(?!.*\|)', '递归 rg 无管道限流', '改用 `rg … | head -30`,或 `rg -c …`'), (False, r'\bgit\s+(log|diff)\b(?!.*(-n\s*\d|--max-count|head))', '`git log/diff` 无行数限制', '改用 `git log --oneline -5` / `git diff --stat`'), (False, r'\b(du|tree)\b[^|;]*\s(/|[A-Za-z]:)', '`du/tree` 从根起', '改用 `du -sh 具体目录` / `tree -L 2 目录 | head -40`'), ] # 命令里已有限流 ⇒ 放行(只按"最外层"有就好了) SAFE = re.compile(r'\|\s*(head|tail|wc|grep\s+-c|cut|sed\s+-n|awk|uniq|sort\s+-u)\b') def _read_stdin(): """⚠️ 必须走 buffer 显式 UTF-8(`-E` 下 sys.stdin 是 cp936)。""" try: return sys.stdin.buffer.read().decode('utf-8', 'replace') except Exception: try: return sys.stdin.read() except Exception: return '' def _emit(obj): data = json.dumps(obj, ensure_ascii=False).encode('utf-8') try: sys.stdout.buffer.write(data) sys.stdout.buffer.flush() except Exception: try: sys.stdout.write(data.decode('utf-8', 'replace')) sys.stdout.flush() except Exception: pass def log(root, detail): try: p = os.path.join(root, LOG_REL) os.makedirs(os.path.dirname(p), exist_ok=True) with io.open(p, 'a', encoding='utf-8', newline='\n') as f: f.write('%s\t%s\n' % (time.strftime('%Y-%m-%d %H:%M:%S'), detail)) lines = io.open(p, encoding='utf-8').read().split('\n') if len(lines) > 300: io.open(p, 'w', encoding='utf-8', newline='\n').write('\n'.join(lines[-150:])) except Exception: pass CAT_BIG = 200 * 1024 # `cat` 目标文件 > 200 KB 才值得拦(小文件放行 ⇒ 防日常误伤) def cat_bigfile(cmd): """`cat <文件>` 且目标文件 > CAT_BIG ⇒ True。取不到大小(相对路径/不存在/管道源)⇒ False(放行)。""" m = re.search(r'(?:^|[|;&]\s*)cat\s+(?:-[A-Za-z]+\s+)*([^\s|>;&]+)', cmd) if not m: return False p = m.group(1).strip('"\'') if p in ('-', '/dev/null', '/dev/stdin'): return False try: return os.path.getsize(p) > CAT_BIG except OSError: return False def segments(cmd): """按 shell 分隔符切段(`;` `&&` `||` `|` 换行)—— 只对**每段的开头**做匹配。 ⚠️ 为什么不匹配整条命令文本(2026-09-15 实测教训):那样**引号里的字符串也会被拦** —— 例如 `printf '...ls -laR...'`、测试脚本、把命令写进文档,全都会误拦(阻塞面过大)。 按段匹配既保住真拦(`cd x && ls -laR` 的第二段以 `ls` 开头 ⇒ 照样拦),又不误伤"只是提到"。 """ return [s.strip() for s in re.split(r'(?:&&|\|\||;|\n|\|)', cmd) if s.strip()] def reason_for(cmd, hard=False): segs = segments(cmd) for core, pat, why, fix in RULES: if not core and not hard: # loose 条只在 hard 模式生效 continue for s in segs: if pat == '__CAT_BIGFILE__': if cat_bigfile(s): return why, fix continue if re.match(pat, s): # ← match(段首),不是 search(全串) return why, fix return None, None def main(): raw = _read_stdin() payload = None if raw.strip(): try: payload = json.loads(raw) except ValueError: payload = None root = (os.environ.get('CODEBUDDY_PROJECT_DIR') or os.environ.get('DSH_WORKSPACE') or (isinstance(payload, dict) and payload.get('cwd')) or WS_FALLBACK) tp = str((payload or {}).get('transcript_path') or '') if isinstance(payload, dict) else '' if isinstance(payload, dict): # 入口即留痕(只记 event,低频;便于判"有没有被调用") log(str(root), 'entry|event=%s|in_scope=%s' % (payload.get('hook_event_name') or '(parse-fail)', SCOPE in tp)) if not isinstance(payload, dict): return if (payload.get('hook_event_name') or '') != 'PreToolUse': return if (payload.get('tool_name') or '') != 'Bash': return if os.environ.get('DSH_OUTPUT_GUARD_OFF'): return try: if os.path.exists(os.path.join(root, '.workbuddy', 'bash-guard.disabled')): return except Exception: pass cmd = ((payload.get('tool_input') or {}).get('command')) or '' if not cmd or SAFE.search(cmd): return try: md = io.open(os.path.join(root, '.workbuddy', 'bash-guard-mode'), encoding='utf-8').read().lower() except OSError: md = '' if 'off' in md: return why, fix = reason_for(cmd, hard=('hard' in md)) if not why: return log(str(root), 'DENY|%s|%s' % (why, cmd.replace('\n', ' ')[:110])) _emit({'hookSpecificOutput': { 'hookEventName': 'PreToolUse', 'permissionDecision': 'deny', 'permissionDecisionReason': ( '💰 拦下:**%s** —— 这类命令的输出会**永久留在会话上下文里、每轮全量重发**' '(实测某会话 553 轮 × 平均 42 万 token = 2.33 亿 input,' 'output 仅占 0.35%%)。\n' '✅ 换成限流写法再发:%s\n' 'ℹ️ 若确实需要全量:**先落盘再只读关键行**(`… > /tmp/x.txt 2>&1` 然后 `sed -n \'1,40p\' /tmp/x.txt`);' '急停用 env `DSH_OUTPUT_GUARD_OFF=1` 或新建 `<工作区>/.workbuddy/bash-guard.disabled`。' % (why, fix))}}) if __name__ == '__main__': try: main() except Exception: pass # fail-open:本钩子只为省积分,绝不因自身异常阻断正常工作 sys.exit(0)