← 記事一覧

モデルに判断を委ねる前に:Agent! のシェル・ガードレールの内側

ShellSafetyService、デーモン側での再チェック、そして Jev のセカンドオピニオンが、AI エージェントによる Mac の全消去をどう防ぐのかを、実際の Swift コードとともに解説します。

今週、TechRadar は、あるコーディングエージェントが短時間のうちに約 48,000 個のファイルを削除し、その後で謝罪したと報じました。謝罪しても何も変わりません。ファイルは失われたままです。

Agent! は Launch Agent を通じてユーザーとして、また Launch Daemon を通じて root としてシェルコマンドを実行できます。SD カードへの書き込み、権限の修復、ビルドフォルダーの掃除ができるのはそのおかげです。しかしそれは同時に、「モデルはたぶん慎重にやってくれるだろう」はセキュリティモデルとは呼べない、ということでもあります。だから Agent! はモデルに慎重さを求めません。何かが実行される前に、すべてのコマンドをコードで 3 層にわたってチェックします。

第 1 層:モデルが言いくるめられないハードコードのガードレール

ShellSafetyService は、Agent/Services/ShellSafetyService.swift にあるごく普通の Swift の enum です。冒頭のドキュメントコメントがその方針を示しています。これは あらゆる実行経路の前に 動作し、壊滅的なコマンドを ディスパッチせずに 拒否します。システムプロンプトは、その実態どおり「最後の備え」であって、強制を担う層ではないと明記されています。

エントリーポイントは、コマンド、それが実行されるコンテキスト、そしてタブのプロジェクトフォルダーを受け取ります。

static func check(_ command: String,
                  context: Context = .userAgent,
                  projectFolder: String = "") -> Verdict

Verdict は、allowed、人間が読める reason、そして監査ログ用の短い rule id で構成されます。reason はモデルに向けて書かれています。ツールの結果として返されるので、LLM はコマンドが なぜ 拒否されたのかを理解でき、ただ再試行を繰り返すことがありません。

複合コマンドをシェルと同じように読む

素朴なフィルターは文字列の先頭しか確認しません。しかし攻撃者も混乱したモデルも、そんな都合には合わせてくれません。check はコマンドを ;、&&、||、|、改行で分割し、 各セグメント をそれぞれチェックします。そのため、前半が無害であっても ls; rm -rf / はブロックされます。

この順序には、意図的な例外が 1 つあります。古典的なフォーク爆弾 :(){ :|:& };: は、まさに分割処理で切り離される文字である ; と | に 依存 しています。そこで、フォーク爆弾のチェックだけは分割前のコマンド全体に対して実行されます。

偽装をはがす

rm を照合する前に、stripPrefixWrappers というヘルパーが、コマンドの動作を変えないラッパーをはがします。対象は sudo、exec、command、builtin、eval、doas です。さらに、FOO=bar のような先頭の環境変数代入も取り除きます。つまり、sudo rm -rf ~ や FOO=1 exec rm -rf ~ も、ラッパーなしのコマンドと同じルールに引っかかります。

フラグもパターンマッチではなく、きちんと解析されます。-rf、-fr、-Rf、-r -f、--recursive --force はいずれも検出されます。パーサーは短いフラグのまとまりの中から r と f を探し、さらにロング形式も認識するからです。

何を拒否するのか

ソースコードに登場するルール id は次のとおりです。

ルール防ぐもの
rm.catastrophic/、* や ./* のような裸のグロブ、またはあらゆる表記のホームディレクトリ(~、~/*、$HOME、${HOME}/*、ホームのリテラルパス)に対する rm -rf
rm.no-preserve-root/ の保護を明示的に回避する --no-preserve-root
rm.project-folderエージェントが作業中のプロジェクトフォルダーの再帰的な削除、またはその中身すべてのグロブ削除。名前を指定したサブフォルダーの削除は引き続き許可されます。
rm.dangerous-targetユーザーレベルの経路におけるその他の危険な rm の対象
fork-bomb自己複製するプロセス爆弾
mv.to-devnullファイルを /dev/null に移動することによる「削除」
find.delete-broad-root広すぎるルートからの find … -delete
perms.recursive-on-rootルートレベルのパスに対する再帰的な権限変更

プロジェクトフォルダーのルールについては、少し補足しておきましょう。冒頭のインシデントに最も直接的に対応するのがこのルールです。依頼がどのような言い回しであっても、エージェントが作業を任されたプロジェクトそのものを消し去ることがあってはなりません。

root はあえて別扱い

root デーモンには 最も厳しい ルールが適用されると思うかもしれません。実際には、適用範囲が最も狭いのです。その理由はコメントに書かれています。デーモンはディスクのクローンや mkfs といったシステムレベルの作業をするために存在しており、「こちらの邪魔をすべきではない」からです。そのため .rootDaemon コンテキストでは、check は直接 checkCatastrophicRm に進み、回復不能な 3 つのパターン(/、裸のグロブ、ホーム)に加えて、--no-preserve-root とプロジェクトフォルダーの全消去だけをブロックします。それ以外はすべてオペレーターの判断に委ねられます。

これは見習う価値のある設計判断です。正当な管理作業まで妨げるガードレールは、いずれ無効にされてしまいます。回復不能なミスだけを防ぐガードレールは、有効なまま使い続けられます。

第 2 層:デーモンがもう一度チェックする

アプリは何かをディスパッチする前に ShellSafetyService.check を実行します。しかしヘルパー側はそれを鵜呑みにはしません。Shared/DaemonCore.swift では、監査ログのエントリーを書き込んだ直後に、デーモンが自分の側で 同じチェック を実行します。

// Defense-in-depth: the app already runs this same check before
// dispatching, but any same-team-signed client can reach the mach
// service directly.
let verdict = ShellSafetyService.check(
    script,
    context: auditCategory == .launchDaemon ? .rootDaemon : .userAgent,
    projectFolder: workingDirectory
)

ブロックされたコマンドはルール id とともに拒否として記録され、終了ステータス 126 が返されます。/bin/zsh に到達することはありません。これが重要なのは、XPC リスナーが同じチームで署名されたクライアントなら何でも受け入れるからです。同じチームで署名された別のプログラムが mach サービスに直接接続してきても、やはりこのガードレールに行き当たります。

第 3 層:パターンが見逃すものに対するセカンドオピニオン、Jev

パターンルールは正確ですが、知っているのは書き留めたパターンだけです。破壊的なコマンドの多くは rm -rf / のようには見えません。間違ったファイルへの truncate、CLI にパイプで渡される SQL の DROP、向きを間違えた dd などです。

そこで登場するのが Jev です。JevAdvisor.swift にある、オプションの助言レイヤーです。ShellSafetyService をすでに 通過した すべてのコマンドが Jev に送られ、データを不可逆的に破壊する可能性が評価されます。設定したしきい値を超えると、Agent! は明確なメッセージとともに実行を拒否します。

Refused: Jev rated this command 85% likely to irreversibly destroy data.
Command: …
Narrow the target or run it yourself if this is intentional.

いくつかの細部から、その適用範囲がいかに慎重に定められているかがわかります。

  • 補うだけで、置き換えはしません。 コードコメントにははっきりと、強制を担うのは ShellSafetyService であり、Jev はパターンルールが見逃したものを拾うだけだと書かれています。しきい値があえて高めに設定されているのはそのためです。デフォルトは 70% で、設定画面から 0〜100% の範囲で 10% 刻みに調整できます。
  • 失敗時は通すが、はっきり知らせます。 キーがない、トグルがオフ、ネットワーク障害といった場合は「意見なし」として扱われるため、不安定なサービスがタスクを止めることはありません。それでも失敗はログに記録されるので(⚠️ Jev check failed, command allowed)、期限切れのキーが「Jev が安全と判断した」と誤解されることはありません。
  • キャンセルは確実にキャンセルです。 Jev が判断している最中にタスクを止めれば、コマンドは実行されません。
  • すべての判定が見えます。 各チェックでは、リスクの割合、許可か拒否か、回答したモデル、トークン数が記録されます。

思い込みではなく、テスト済み

AgentTests/ShellSafetyServiceTests.swift には、このガードレールのためのテストが 30 個あります。また、ヘルパーのコマンドはすべて実行前に監査ログへ書き込まれるため、エージェントが何をしようとしたのか、許可されなかった操作も含めて、いつでも再構成できます。

エージェントを作るすべての人へのポイント

  1. プロンプトではなく、コードで強制する。 プロンプトは提案にすぎません。Swift の enum は違います。
  2. シェルと同じように解析する。 複合コマンドを分割し、ラッパーをはがし、フラグを正規化しましょう。
  3. 特権の境界でもう一度チェックする。 呼び出し元が自分のクライアントだけだと信じてはいけません。
  4. 珍しいものではなく、回復不能なものを防ぐ。 範囲の狭いルールは生き残り、うるさいルールは無効にされます。
  5. その上に判断を加え、失敗が見えるようにする。 セカンドオピニオンが価値を持つのは、それが答えなかったときにそうとわかる場合だけです。

これらはすべて GitHub で公開されています。ぜひ読んで、穴を探してみてください。止められるべきだったのに通ってしまうコマンドを見つけたら、issue を立ててください。