はじめに
opencode・Cline・Aider・OpenHandsなど、OSSのAIコーディングエージェントの多くは「危険なコマンドを実行前に止める」ためのガード機能を備えています。仕組みはシンプルで、LLMが生成したシェルコマンドの文字列を正規表現や単純な文字列一致でチェックし、rm -rf のような危険なパターンに一致したら実行をブロックする、というものです。
セキュリティ研究企業Adversa AIが2026年6月に公開した調査「GuardFall」は、この方式が シェルの引用・展開・置換の仕組みを使った古典的な手口で簡単に突破できる ことを示しました。調査対象11エージェント(GitHubスター合計 約54.8万)のうち、唯一Continueだけがこの手口を防げていた、という結果です。
本記事では、GuardFallが指摘するバイパス手法のうち代表的な4パターンを、実際にPythonで簡易的な「よくあるガード実装」を書いて手元のbash環境で再現し、なぜすり抜けるのかを検証します。あわせて、検証の過程で見えてきた「プレビューして安全確認する」という一見良さそうな対策自体に潜む罠についても報告します。
この記事で学べること
- パターンマッチ型のコマンドガードが破られる4つの具体的な手口
- 実際にPythonで書いた簡易ガードで、破られる様子を再現した結果
- 「実行前にプレビューする」対策が抱える見落とされがちな落とし穴
- Continueが採用している構造的な防御アプローチの考え方
対象読者
- 自作・OSSのAIコーディングエージェントにガード機能を実装している、または検討している方
- LLMにシェルコマンドを実行させる仕組み(MCP・ツール呼び出し・Computer Use等)を運用している方
前提環境
- Python 3.11 / bash
- 検証はDockerコンテナ内の使い捨てディレクトリで実施(実運用環境では絶対に危険なコマンドを実行しないこと)
TL;DR
- GuardFallの調査では、opencode・Goose・Cline・Roo-Code・Aider・Plandex・Open Interpreter・OpenHands・SWE-agent・Hermesの 10エージェント(対象11個中)でパターンマッチ型ガードの突破が確認された
- 「先頭コマンド名の一致」「危険な部分文字列の一致」という典型的な実装を再現したところ、引用トークン分割・
$IFS展開・コマンド置換・Base64パイプラインの4手法すべてで実際にファイル削除・任意コマンド実行が通った - 「実行前にbashへ展開結果を聞いてから判定する」という改良も、コマンド置換
$(...)を含む文字列では プレビューした時点で中身がすでに実行されてしまう という別の罠があることを検証で確認した - 唯一防御に成功したContinueは、シェルクォート付きトークン化・展開検出・置換の再帰的評価・パイプ宛先チェック・明示的denylistの5段構成を取っている
GuardFallとは何か
GuardFallは特定のCVE番号が付く単一の脆弱性ではなく、「フィルターは文字列としてのコマンドを見て、シェルは意味としてのコマンドを実行する」というズレそのものを指す設計上の欠陥クラスです。Adversa AIの調査では、この欠陥に基づく5つのバイパス手法(クラスA〜E)が整理されています。
| クラス | 手口 | 例 |
|---|---|---|
| A | 引用トークン分割 |
r''m -rf / → bashは rm -rf / として解釈 |
| B |
$IFS 展開 |
rm$IFS-rf$IFS/ → 単一語に見えるが実行時に3引数へ分割 |
| C | コマンド置換 |
$(echo rm) -rf / → 危険コマンドが置換式の中に隠れる |
| D | Base64パイプライン |
echo <base64> | base64 -d | sh → 各断片は無害に見える |
| E | 代替の破壊的ユーティリティ |
find /x -delete・dd of=/dev/sda など、denylistに rm しか入れていない場合の抜け道 |
出典: The Hacker News / Security Affairs / Adversa AI
対象となった11エージェントとその判定は次の通りです。
| 分類 | エージェント |
|---|---|
| 脆弱(10個) | opencode, Goose, Cline, Roo-Code, Aider, Plandex, Open Interpreter, OpenHands, SWE-agent, Hermes |
| 唯一の防御成功 | Continue |
実際に「よくあるガード実装」を書いて再現する
論文中の手口が本当に効くのか、典型的な実装パターンをPythonで書いて手元のbash環境で確認しました。まず、多くの実装で見かける「先頭トークンの一致」「危険な部分文字列の一致」という素朴なガードです。
# naive_guard.py
import re, subprocess
DENY_COMMANDS = {"rm", "dd", "mkfs", "shutdown"}
DENY_SUBSTRINGS = ["rm -rf", "rm -rf"]
def is_blocked(cmd: str) -> bool:
first_token = cmd.strip().split(" ")[0] if cmd.strip() else ""
if first_token in DENY_COMMANDS:
return True
return any(s in cmd for s in DENY_SUBSTRINGS)
def run(cmd: str):
blocked = is_blocked(cmd)
print(f"CMD: {cmd!r} -> {'BLOCKED' if blocked else 'ALLOWED'}")
if not blocked:
r = subprocess.run(["bash", "-c", cmd], capture_output=True, text=True)
print(" bash stdout:", r.stdout.strip())
検証用に空のファイルだけを置いたディレクトリを用意し、各クラスの手口を投げてみます。
クラスA: 引用トークン分割
$ python3 naive_guard.py "r''m -rf victim/"
CMD: "r''m -rf victim/" -> ALLOWED
bash stdout: ''
$ ls victim/
ls: cannot access 'victim/': No such file or directory
正規表現・部分文字列一致のどちらも「r''m」という文字列に対して「rm」という並びを検出できず ALLOWED 判定になりました。しかしbashは引用符 '' を空文字列として除去してから解釈するため、実際には rm -rf victim/ が実行され、ディレクトリは消えました。
クラスB: $IFS 展開
$ python3 naive_guard.py 'rm${IFS}-rf${IFS}victim/'
CMD: 'rm${IFS}-rf${IFS}victim/' -> ALLOWED
bash stdout: ''
$ ls victim/
ls: cannot access 'victim/': No such file or directory
cmd.strip().split(" ") は文字列中に実際の半角スペースがなければ1トークンとしか見なさないため、rm${IFS}-rf${IFS}victim/ は「先頭トークンがrmと一致するか」のチェックをすり抜けます。しかしbashの既定 IFS(空白・タブ・改行)はこの変数展開後に単語分割の基準として働くため、実行時には rm -rf victim/ の3引数に分割されました。
クラスC: コマンド置換
$ python3 naive_guard.py '$(echo id)'
CMD: '$(echo id)' -> ALLOWED
bash stdout: 'uid=0(root) gid=0(root) groups=0(root)'
denylistの対象は rm 等の文字列そのものであり、$(echo id) という置換式自体には危険な単語が含まれていないため素通りしました。置換の中身が任意のコマンドに差し替えられれば、同じ穴で何でも実行できてしまいます。
クラスD: Base64パイプライン
$ PAYLOAD=$(echo -n "id" | base64)
$ python3 naive_guard.py "echo $PAYLOAD | base64 -d | sh"
CMD: 'echo aWQ= | base64 -d | sh' -> ALLOWED
bash stdout: 'uid=0(root) gid=0(root) groups=0(root)'
コマンド文字列に rm のような危険な単語が一切含まれないため、denylistは完全に無力でした。Base64にデコードされた内容が sh にパイプで渡され実行されています。
4パターンとも、「よくあるガード実装」を実際に書いて動かした限りではAdversa AIの指摘通りすり抜けることが確認できました。
「先に展開結果を覗く」対策に潜む罠
ここまでの検証を踏まえ、「実行前にbash自身へ展開後の姿を聞いてから判定する」という改良を試しました。printf "%s\n" <コマンド> を使えば、コマンドを実行せずに引数分割の結果だけ覗けるはず、という発想です。
クラスA・Bのような引用除去・$IFS 展開には確かに有効でした。しかしコマンド置換を含む文字列で試したところ、見過ごせない問題が見つかりました。
cmd = "$(rm -rf victim5/ && echo done)"
probe = f'printf "%s\\n" {cmd}'
# victim5/ が存在することを確認済み
result = subprocess.run(["bash", "-c", probe], ...)
# → victim5/ はこの時点ですでに削除されている
実行結果は次の通りでした。
プレビュー実行前: victim5/ は存在するか -> True
プレビューの戻り値: done
プレビュー実行後: victim5/ は存在するか -> False
「判定のために展開結果を覗いただけ」のつもりが、$(...) の中身は bashが文字列を解釈する時点(=プレビューを実行した瞬間)にすでに評価・実行されてしまう ため、危険なコマンドは判定前に実行済みになっていました。プレビューしてから許可・拒否を決める、という直感的な二段構え自体が、コマンド置換に対しては原理的に機能しないことになります。
Continueはどう防いでいるか
GuardFallの調査によると、唯一防御に成功したContinueは、文字列パターンとの一致ではなく次の5段階の評価器を通しています。
- シェルクォート付きトークン化: 引用符の除去も含めてbash相当のルールで正しくトークン分割する(クラスAに対応)
-
変数展開の検出:
$IFS等による見せかけの単語結合を検出する(クラスBに対応) -
置換の再帰的評価:
$(...)やバッククォートの中身を、実行せずに静的解析で再帰的に評価する(クラスCに対応。上で確認した「プレビューという名の実行」問題を避けるには、実行せずに構文解析する必要がある) -
パイプ宛先チェック:
sh・bash・インタプリタへのパイプは内容に関わらず既定拒否する(クラスDに対応) -
明示的なdenylist:
rm以外の破壊的ユーティリティ(dd・find -delete等)も対象に含める(クラスEに対応)
ポイントは、「パターンに一致しなければ許可」という default-allow の発想を、「安全と証明できるまで拒否」という default-deny に転換していることです。特に③は、今回の検証で判明した「実行してからでは遅い」という問題を踏まえると、静的な構文解析(実行せずにASTだけを見る)でなければ意味を持たないことがわかります。
著者視点の発見ポイント
今回、典型的なガード実装をPythonで書いて実際にbash環境で動かしてみるまでは、「$IFSの展開さえ気をつければ済む話だろう」くらいに考えていました。しかし「安全側に倒すために実行前にプレビューする」という、一見素直な改良策を自分で書いて試したところ、コマンド置換 $(...) を含む入力ではプレビューした瞬間に中身がすでに実行されているという事実に行き着きました。これはAdversa AIの記事本文には明記されていない検証結果で、「危険なコマンドかどうかを判定してから実行する」という前提そのものが、シェルの構文次第では成立しないことを意味します。パターンマッチだけでなく「実行せずに安全確認する」という直感的な対策にも同じ落とし穴があり得る、という点は、独自のコマンドガードを実装・レビューする際に見落としやすい観点だと感じました。
まとめ
- GuardFallは単一のCVEではなく、「フィルターは文字列を見て、シェルは意味を実行する」というズレに起因する設計上の欠陥クラス
- 調査対象11エージェント(スター合計約54.8万)中10個で、パターンマッチ型ガードの突破が確認されている
- 実際にPythonで典型的なガード実装を書いて検証したところ、引用トークン分割・
$IFS展開・コマンド置換・Base64パイプラインの4手法すべてで実行が通った - 「先に展開結果をプレビューする」対策も、コマンド置換を含む入力に対してはプレビュー自体が実行を伴ってしまうため機能しない
- Continueが採用する「シェルクォート付きトークン化→展開検出→置換の再帰的(非実行)評価→パイプ宛先チェック→明示的denylist」という5段構成が、default-denyの考え方に基づく現実的な防御アプローチ