本文へスキップ

ローカルLLMにコードを書かせる前に作った3層のガード

9 分で読めます

ローカルLLMにコードを書かせるハーネスを作るとき、最初に書くべきなのはプロンプトではなくガードだった。Ollamaで動かすqwenにwrite_filerun_commandを渡す以上、rm -rf ~が返ってくる可能性は常にある。「そういうコマンドを出さないように指示する」で守るのは無理で、出してきても実行できない状態を先に作る必要がある。

パスジェイル・コマンド許可リスト・sandbox-execの3層で「モデルに何をさせられるか」の上限を固定した。実装しながら踏んだ穴と、塞ぎきれずに残したものを書く。

全体の構え

モデルに公開するのは6ツールだけ。list_files / read_file / write_file / edit_file / run_command / finish。すべての引数がガードを通ってから実処理に届く。

6ツールの引数が3層のガードを通ってworkspaceに届くまで

ここで先に決めたのは、ガード違反でハーネスを落とさないこと。違反は例外GuardViolationとして上がるが、ツール層がそれを捕まえてERROR[path_escape]: 絶対パスは使えませんのような文字列にしてモデルへ返す。モデルは自分で言い直せるし、違反はメトリクスに残る。

第1層: パスジェイル

モデルが指定した相対パスを検証して絶対パスにする。拒否するのは空文字・NUL・~展開・絶対パス・..を含むパス、そしてrealpath解決後にworkspaceの外を指すパス。

class Jail:
    def __init__(self, root):
        # macOS では /tmp が /private/tmp の symlink なので、
        # ここで正規化しておかないと配下判定が常に失敗する
        self.root = Path(os.path.realpath(str(root)))

    def resolve(self, raw, *, must_exist=False):
        candidate = Path(raw)
        if candidate.is_absolute():
            raise GuardViolation("path_escape", f"絶対パスは使えません: {raw}")
        if ".." in candidate.parts:
            raise GuardViolation("path_escape", f"'..' を含むパスは使えません: {raw}")

        # realpath は途中の symlink をすべて解決する。存在しない末端はそのまま連結される
        resolved = Path(os.path.realpath(self.root / candidate))
        if resolved != self.root and not resolved.is_relative_to(self.root):
            raise GuardViolation("path_escape", f"symlink 経由でも外には出られません: {raw}")
        return resolved

コンストラクタのrealpathは最初ハマった箇所で、macOSの/tmp/private/tmpへのsymlinkなので、テストでtmp_pathを渡すとis_relative_toが常にFalseになって全部拒否される。root側も正規化して初めて成立する。

realpathを通しても、まだ2つ抜け道がある

1つ目は最後のコンポーネントrealpathはパスの途中を解決するが、その後に実際openするまでの間にsymlinkが差し変わる余地があるし、そもそも「存在しない末端」は連結されるだけなので、書き込み時にはsymlinkが残りうる。これはO_NOFOLLOWで潰す。

2つ目はハードリンク。これはrealpathでもO_NOFOLLOWでも見抜けない。workspace内のハードリンクが外部のファイルを指していると、パスジェイルもsandbox-execのパスベースのポリシーもすり抜けて外部を書き換えられる。ハーネス自身はハードリンクを作らないので、リンク数が1を超える通常ファイルは全部拒否してよい。

def write_bytes(path, data):
    fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_NOFOLLOW | os.O_NONBLOCK, 0o644)
    try:
        st = os.fstat(fd)
        if not stat.S_ISREG(st.st_mode):
            raise GuardViolation("io", f"通常ファイルではありません: {path.name}")
        if st.st_nlink > 1:
            raise GuardViolation("hardlink", f"ハードリンクされたファイルは扱えません(リンク数 {st.st_nlink})")
        os.ftruncate(fd, 0)   # ← O_TRUNC を open に含めない理由がここ
        os.write(fd, data)
    finally:
        os.close(fd)

O_TRUNCos.openのフラグに入れなかったのは意図的で、入れるとハードリンク判定より先に中身が切り詰められる。「拒否したのにファイルは空になっている」という最悪の負け方をするので、判定を通してからftruncateする。

第2層: コマンド許可リスト

デフォルト全拒否で、allowed_commands.jsonに列挙したコマンドだけが通る。シェルは一切起動せず、subprocessshell=Falseとargv配列で呼ぶ。

一番効いたのは値を取るフラグを全面禁止にしたことだった。値を取るフラグは「次の引数の意味」を変えてしまうので、位置ベースのパス検証が丸ごとずれる。

grep -f patterns.txt app.js       # -f の値はパターンファイル = 任意ファイルの読み出し
grep --include=*.env -r .          # フラグの中に埋まったパスは位置検証に引っかからない

「危険なフラグを個別に列挙して弾く」方式にすると、この手の亜種を数え切れない。値を取るフラグというカテゴリごと落とすほうが、許可リストとして人間がレビューできる。

まとめられた1文字フラグも1文字ずつ割って照合する。-laを文字列として許可リストと突き合わせると、-l-aが個別に許可されているかを見ないまま通ってしまう。

if arg.startswith("--"):
    if arg not in spec.long_flags:
        raise GuardViolation("flag_denied", f"{name} で許可されていないフラグです: {arg}")
else:
    # '-la' のようにまとめられた 1 文字フラグを 1 文字ずつ検証する
    for ch in arg[1:]:
        if ch not in spec.short_flags:
            raise GuardViolation("flag_denied", f"{name} で許可されていないフラグです: -{ch}")

フラグ以外の位置引数はすべてジェイル検証を通し、workspace相対に正規化して組み直す。このとき位置引数が-で始まる場合(そういう名前のファイルや検索パターン)は--で区切りを入れる。入れないとコマンド側でフラグとして解釈され、せっかくの検証をすり抜けられる。

実行ファイルの解決も許可リストの一部で、PATHの乗っ取りを避けるため設定した固定ディレクトリ群を先に探し、見つからないときだけwhichにフォールバックする。いずれの場合もworkspace内の実行ファイルは起動しない(モデルが自分で書いたスクリプトに実行ビットを立てて呼ぶ経路を塞ぐ)。

第3層: macOSのsandbox-exec

許可リストを通ったコマンドを、さらにOSレベルのサンドボックスで包む。第1層に穴があっても、ネットワークとworkspace外書き込みだけはカーネル側で止まる。

(version 1)
(allow default)
(deny network*)
(deny file-write*)
(allow file-write* (subpath "{workspace}"))
(allow file-write-data
    (literal "/dev/null")
    (literal "/dev/zero")
    (literal "/dev/random")
    (literal "/dev/urandom"))

(allow default)を土台にして危険な能力だけ落とす形にした。逆向き(deny defaultから必要な権限を足す)は素直に見えるが、dyld・ロケール・sysctlの読み込みまで列挙する必要があって、許可コマンドが起動しなくなる方向で壊れる。「壊れたら気づける」向きに倒すならallow defaultから落とすほうだった。

実行側はcwdをworkspaceに固定し、親の環境変数を継承せず、start_new_session=Trueで独立したプロセスグループに置く。タイムアウトしたらグループごとSIGKILLする。

proc = subprocess.Popen(
    argv,
    cwd=str(jail.root),
    env=_child_env(cfg, jail),      # 親の環境は継承しない。HOME は workspace に向ける
    stdin=subprocess.DEVNULL,       # 入力待ちでのハングを防ぐ
    stdout=subprocess.PIPE,
    stderr=subprocess.PIPE,
    start_new_session=True,         # まとめて kill できるようにする
)

HOMEをworkspaceに向けているのは、~を展開するツールが実ホームディレクトリを読みに行くのを防ぐため。

FIFOが1つあるだけでハーネスが永久に止まる

一番きれいにやられたのがこれだった。workspaceに名前付きパイプ(FIFO)があると、write_bytesos.open(..., O_WRONLY)読み手が現れるまでブロックする。POSIXの仕様どおりの挙動だが、ハーネスの不変条件「何が起きてもループを落とさない」に真正面から反する。落ちるのではなく、無音で止まる。

O_NONBLOCKを付けて解決した。通常ファイルの読み書きには影響しない。FIFOは結局その後のS_ISREG判定で拒否されるので、挙動としては「ハングせず拒否」になる。

厄介なのはテストのほうで、ハングしないことは同じプロセス内ではassertできない。テスト自身が一緒に止まるので、失敗ではなく無応答になる。タイムアウト付きの別プロセスで実行して確認する形にした。

def test_FIFOを掴んでもハングせず拒否される(jail, workspace):
    os.mkfifo(workspace / "pipe")
    script = (
        "from harness.guard import Jail, GuardViolation, write_bytes, open_read_bytes\n"
        "jail = Jail(%r)\n"
        "for fn in (lambda: write_bytes(jail.root / 'pipe', b'x'),\n"
        "           lambda: open_read_bytes(jail.root / 'pipe', 100)):\n"
        "    try:\n"
        "        fn(); print('LEAKED')\n"
        "    except (GuardViolation, OSError) as exc:\n"
        "        print('BLOCKED', type(exc).__name__)\n"
    ) % str(workspace)

    proc = subprocess.run([sys.executable, "-c", script],
                          capture_output=True, text=True, timeout=15)

    assert "LEAKED" not in proc.stdout
    assert proc.stdout.count("BLOCKED") == 2

timeout=15を超えたらsubprocess.TimeoutExpiredが上がってテストが赤くなる。「止まらないこと」を落ちる形に変換したのがポイントで、これをやらないとリグレッションしたときにCIが無応答になるだけで原因が分からない。

防げていないこと

正直に書いておくと、以下は塞げていない。

workspace外の読み取りはOSレベルでは止めていない。 サンドボックスはdeny file-write*deny network*だけで、file-read*は許可している。deny file-read*にするとdyldやロケールの読み込みまで落ちて許可コマンドが起動しないため。つまり読み取りの防御は第1層(パス引数のジェイル検証と、値を取るフラグの全面禁止)だけに依存していて、ここにバグがあれば任意ファイルの読み出しは成立しうる。

TOCTOUの窓が理論上ある。 検証と実際のopenの間はアトミックではない。O_NOFOLLOWと「workspace内にsymlinkを置かない」不変条件で実用上は塞いでいるが、workspaceに並行して書ける第三者がいる前提なら競合させられる。

プロンプトインジェクション自体は防げない。 タスク仕様やモデルが読んだファイルに指示文が混ざっていれば、モデルはそれに従いうる。ただし従った結果できることはガードの範囲内に限られるので、被害はworkspace内に閉じる。ガードを「モデルを正しく振る舞わせるもの」ではなく「振る舞いの上限を決めるもの」として設計したのは、まさにここが理由だった。

実測

ガードのpytestは137件(pytest --collect-only -qで実測)。そのうえで、ガード付きハーネス経由でモデルにWebサイトを作らせた。

計測条件は次のとおり。

項目
マシン Apple M5 Max / メモリ36GB / macOS 26.4
実行 Ollama(ローカル、ネットワーク遮断下)
モデル qwen3.6:27b(27.8B)・qwen3.8:27b(27.3B)(ともに Q4_K_M)
タスク HTML骨格 → CSS+ダーク+レスポンシブ → JS機能 → 変更依頼の4本
試行 各モデル1回ずつ
項目 qwen3.6:27b qwen3.8:27b
一発合格率 4/4 4/4
合計時間 約37分 約39分
ガード違反 1(command_syntax) 0

数字より収穫だったのは、3.6が弾かれてから自力で回復したことだった。パイプ入りのコマンドを投げてガードに拒否され、返ってきたこの文字列を読んで書き直し、完走している(トランスクリプトから転記)。

ERROR[command_syntax]: シェルの機能 ('|') は使えません。パイプ・リダイレクト・
コマンド連結は一切実行できません。メタ文字を引数の中身として渡したい場合は
配列形式 ["grep", "-n", "foo$", "app.js"] で指定してください

エラー文字列に正しい書き方の例を入れておいたのが効いている。「使えません」だけで返していたら、同じ形を試し直して残りのターンを溶かしていた可能性が高い。

学び

ガード違反は例外ではなく、モデルに読める文字列で返す。 違反でセッションを終わらせる作りにしていたら、上の「自力で回復」は起きなかった。エラーメッセージはユーザー向けではなくモデル向けのUIで、直し方まで書いておくと1ターンで復帰する。

1つ塞ぐと隣に穴が空くと思っておく。 今回の第1層は..を弾く → symlinkで抜けられる → realpathで解決する → 末端のsymlinkが残る → O_NOFOLLOW → ハードリンクは見抜けない → リンク数で拒否 → FIFOでハングする → O_NONBLOCK、という順に穴が出てきた。どれも前の対策の外側から来ている。「パスの検証」を1つの関数で終わったことにせず、ファイルを実際に開く瞬間まで検証を持ち込むresolveだけでなくfstat後にも判定する)のが結果的に効いた。

防げないことを書き出しておく。 上の「防げていないこと」はREADMEにも同じ内容を置いてある。全部防いだつもりのガードが一番危ないし、次に触るとき(自分でも他人でも)どこが薄いか分からないと、薄いところに機能を足してしまう。