import reDENY_PATTERNS = [ r"rm\s+-rf\s+/", r"rm\s+-rf\s+~", r"mkfs", r"dd\s+if=/dev/zero", r"git\s+reset\s+--hard", r"git\s+clean\s+-xfd",]DENY_RE = [re.compile(p) for p in DENY_PATTERNS]def guard_denylist(cmd: str) -> bool: """True を返したら「破壊的なので拒否」""" return any(r.search(cmd) for r in DENY_RE)
import os, re, shlexREAD_ONLY = { "ls","cat","head","tail","wc","grep","stat","file","du","df", "python3","python","node","npm","pytest","echo","printf","jq", "sed","awk","sort","uniq","diff",}GIT_READ_ONLY = {"status","log","diff","show","branch","rev-parse","ls-files","describe","blame"}DESTRUCTIVE_BIN = {"rm","mkfs","mkfs.ext4","dd","truncate","shred","mv","chmod","chown"}WRAPPERS = {"env","sudo","nohup","time","xargs"}SHELLS = {"sh","bash","zsh"}def _split_segments(cmd: str): """&& || ; | で区切り、各セグメントを argv 化する。解析不能なら None""" segs, buf, i, quote = [], "", 0, None while i < len(cmd): c = cmd[i] if quote: buf += c if c == quote: quote = None i += 1 continue if c in "\"'": quote = c; buf += c; i += 1; continue if cmd.startswith("&&", i) or cmd.startswith("||", i): segs.append(buf); buf = ""; i += 2; continue if c in ";|": segs.append(buf); buf = ""; i += 1; continue buf += c; i += 1 segs.append(buf) out = [] for s in segs: s = s.strip() if not s: continue try: argv = shlex.split(s) except ValueError: return None # 引用符が閉じていない等。安全側に倒す if argv: out.append(argv) return outdef guard_allowlist(cmd: str, depth: int = 0) -> bool: """True を返したら拒否。深さ制限つきで sh -c の中身も再帰的に見る""" if depth > 3: return True segments = _split_segments(cmd) if segments is None: return True for argv in segments: head = os.path.basename(argv[0]) # /bin/rm → rm args = argv[1:] # env / sudo などのラッパを剥がす while head in WRAPPERS and args: head = os.path.basename(args[0]); args = args[1:] # sh -c '...' は中身を再帰検査 if head in SHELLS: inner = next((a for a in args if not a.startswith("-")), None) if inner is None or guard_allowlist(inner, depth + 1): return True continue if head in DESTRUCTIVE_BIN: return True if head == "find": # -delete / -exec を持ちうる return True if head == "git": sub = next((a for a in args if not a.startswith("-")), None) if sub not in GIT_READ_ONLY: return True continue if head not in READ_ONLY: # 知らないコマンドは通さない return True # `: > file` のような、コマンドを持たない上書きリダイレクト if re.search(r"(^|[^>\w])>\s*\S", cmd) and not re.search(r">>\s*\S", cmd): return True return False
WRITABLE_PREFIXES = ("/workspace/out/", "/workspace/NOTES.md", "/tmp/")def _redirect_targets(cmd: str): return re.findall(r">>?\s*([^\s;|&]+)", cmd)def guard_scoped(cmd: str) -> bool: targets = _redirect_targets(cmd) if not targets: return guard_allowlist(cmd) for t in targets: if not t.strip("\"'").startswith(WRITABLE_PREFIXES): return True # 書き込み先が全て許可スコープ内なら、リダイレクトを外した本体だけ再検査 body = re.sub(r">>?\s*[^\s;|&]+", "", cmd) return guard_allowlist(body)
「破壊的コマンドの検査」「書き込み先の検査」「監査ログの送出」を素直に3本のフックへ分けると、1 run あたり2.77秒。1本に束ねれば0.92秒です。責務ごとにファイルを分けたくなる場面ですが、ここでは分岐で束ねるほうを選びました。ファイル分割は Python モジュールの側でやれば、起動回数を増やさずに構造は保てます。
sh 版が桁違いに速いのも事実です。ただし、私は移しませんでした。argv 分解・ラッパ剥がし・再帰検査を shell で書き直すと、それ自体がバグの温床になります。0.9 秒を削るために、門番の正しさを賭ける取引には見えませんでした。
代わりに実務的な緩和として置いたのは次の2つです。
判定対象を絞る。payload.get("tool") != "shell" の分岐を最初に置き、シェル以外のツール呼び出しでは import guards に到達させない。ファイル読み取り中心の run では、この分岐だけでフック起因のコストがほぼ消えます。
__pycache__ を温めておく。初回実行は .pyc の生成を含みます。手元では中央値23.0 ms とほぼ差が出ませんでしたが、モジュールが増えれば効いてくる項目です。
この先に測りたいこと
門番としては、いまの形で満足しています。検出率100パーセント、誤検知0パーセント、1 run あたり1秒弱。冒頭に書いた「消してほしくない中間生成物」は、方式Cなら mv /workspace /tmp/gone の時点で止まります。
一方で、コーパス34件はあくまで私が想像できた範囲です。次に測りたいのは、実際の run で拒否された履歴を集めて、そのうち何件が「本当に止めるべきだった」ものだったか —— つまり誤検知率を、私が書いたテストではなくエージェントの実際の振る舞いで採り直すことです。allowlist 方式は安全側に倒れる設計なので、運用で削れる余地がまだあるはずです。私自身、この門番はまだ育てている途中だと思っています。