CLIツールにローカルLLM機能を組み込むときにやったセキュリティ対策5つ
自作のCLIツール wip(WSLC向けの開発体験ラッパー)に、プロジェクトの設定ファイルをAIに書かせる wip init --ai / wip help --ai という機能を足したところ、レビューで複数の穴が見つかり、まとめて塞ぎました。この記事では実際に実装した5つの対策を、コードとともに紹介します。
今回の5つの対策を1枚にまとめたスライドです。

by slidict
この記事で分かること
- ローカルLLMサーバーとの通信を「宛先の検証」「送信前の開示」「応答サイズの上限」の3段構えで守る方法
- 設定ファイルを出力・送信する前に、キー名に頼らず秘密情報を確実にマスクする方法
- CLIがコマンドを解決してから実行するまでの間に起きうる、実行対象のすり替えを防ぐ方法
- 信頼できない可能性のあるYAML入力から、パーサーをCPU/メモリ枯渇から守る方法
背景
wip init --ai は、プロジェクトディレクトリを解析してファイルの中身をローカルのLLMサーバー(LM Studio、Ollamaなど)に渡し、wip.yml の草案を書かせる機能です。wip help --ai も同様にローカルLLMに質問を投げます。この手の機能を足すと、次のような論点が一気に増えます。
- 接続先のURLを間違えて、あるいは書き換えられて、意図しないホストにファイルの中身を送ってしまわないか
- 送信前に「何を」「どこへ」送るのかユーザーに見えているか
- 応答してくるサーバーが行儀よく動くとは限らない(応答が巨大、接続が固まる等)ときにCLI側が巻き込まれないか
- 送信対象や画面に出力する設定に、うっかり秘密情報が混ざらないか
これらはAI連携に限らずCLIツール全般に言えることですが、「外部にネットワークで何かを送る機能を足した」タイミングでまとめて点検する価値があります。
やったこと
1. AI送信先の検証と、送信前の開示
LocalAiProvider.ValidateBaseUrl は、指定されたURLがループバック(localhost 等)以外へのHTTP(非TLS)送信を許さないようにします。
public static Uri ValidateBaseUrl(string baseUrl, bool allowInsecureRemoteHttp = false)
{
if (!Uri.TryCreate(baseUrl, UriKind.Absolute, out var uri) || string.IsNullOrEmpty(uri.Host) ||
(uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps))
{
throw new WipException("AI server URL must be an absolute HTTP or HTTPS URL");
}
if (!uri.IsLoopback && uri.Scheme == Uri.UriSchemeHttp && !allowInsecureRemoteHttp)
{
throw new WipException(
"Remote AI servers must use HTTPS (or require explicit --allow-remote-ai approval for insecure HTTP)");
}
return uri;
}
これだけだと「ループバック以外はHTTPSにしろ」で終わりですが、実際にはもう一段階あります。CLI側の RequireRemoteApproval が、スキームに関係なくループバック以外への送信そのものを、明示フラグ無しでは拒否します。
private static void RequireRemoteApproval(Uri endpoint, bool allowRemoteAi)
{
if (!endpoint.IsLoopback && !allowRemoteAi)
{
throw new WipException(
$"Refusing to send data to remote AI host '{endpoint.Host}' without --allow-remote-ai");
}
}
つまりデフォルトでは「ローカルのAIサーバーとだけ話す」が既定動作で、リモートを使うには --allow-remote-ai という明示的なオプトインが要る2段構えです。加えて、実際に送信する直前に宛先ホストと送信予定ファイルの一覧を標準エラー出力に表示します。
private static void AnnounceDestination(Uri endpoint, IReadOnlyList<string> files)
{
Console.Error.WriteLine($"AI destination: {endpoint.Host} ({endpoint.Scheme.ToUpperInvariant()})");
if (files.Count == 0)
{
Console.Error.WriteLine("Files to send: none");
return;
}
// ...(ファイル一覧を1行ずつ出力)
}
さらに、既存の wip.yml を送信対象に含める前に、それ自体が秘密情報らしき内容を含んでいないかを確認し、含んでいれば送らずに警告するチェックも入れました(ProjectAnalyzer.ContainsPossibleSecret、キー名の正規表現マッチと API_KEY = "..." のような代入パターンの両方を見ます)。「何を・どこへ」を機械的に検証しつつ、実行前にユーザーにも見える形で開示する、という二重の作りです。
2. AIサーバーからの応答にサイズ上限を設ける
送信先を絞っても、応答してくるサーバー自体が行儀よく動くとは限りません(ローカルで動かしている別プロセスの設定ミスや、--allow-remote-ai で許可したリモート先の不調も含め)。LocalAiProvider は用途ごとに応答サイズの上限を定数で持っています。
internal const long ModelsResponseMaxBytes = 2 * 1024 * 1024;
internal const long ChatResponseMaxBytes = 4 * 1024 * 1024;
internal const int GeneratedContentMaxCharacters = 1024 * 1024;
internal const int ErrorBodyMaxBytes = 4 * 1024;
チェックは2段階です。まず Content-Length ヘッダーが上限を超えて申告していれば、ボディを読む前に即座に拒否します。ヘッダーを信用しきらず、実際に読むストリームにも同じ上限を適用します。
private static async Task<JsonDocument> ParseResponseAsync(
HttpContent content, long maxBytes, string description, CancellationToken cancellationToken)
{
if (content.Headers.ContentLength is long contentLength && contentLength > maxBytes)
{
throw new WipException(
$"Local AI server returned a {description} larger than the {maxBytes}-byte limit");
}
await using var responseStream = await content.ReadAsStreamAsync(cancellationToken);
await using var limitedStream = new LimitedReadStream(responseStream, maxBytes);
return await JsonDocument.ParseAsync(limitedStream, cancellationToken: cancellationToken);
}
エラー応答の本文も無制限に取り込まず、ErrorBodyMaxBytes(4KiB)で打ち切ってから制御文字をエスケープして表示します。巨大な応答や制御文字混じりの応答でターミナル・ログが荒れるのを防ぐためです。
3. 設定ファイルのシークレットredactionを構造認識型にする
wip config などで設定内容を表示する際、秘密情報はマスクされます。以前はキー名が SecretPattern(token|password|secret|... 等の正規表現)にマッチするかどうかだけを見ていましたが、それだと dependencies.<name>.env や commands.<name>.env の下に、正規表現に引っかからない名前の環境変数として秘密情報が置かれた場合にすり抜けてしまいます。変数名は自由に付けられる以上、名前だけを信号にはできません。
private static OrderedDictionary<string, object?> RedactMapping(
OrderedDictionary<string, object?> mapping,
IReadOnlyList<string> path)
{
var result = RubyValue.NewMapping();
var redactEnvironment = IsContainerEnvironment(path);
foreach (var (key, value) in mapping)
{
result[key] = redactEnvironment || SecretPattern().IsMatch(key)
? "[REDACTED]"
: RedactSecrets(value, [.. path, key]);
}
return result;
}
private static bool IsContainerEnvironment(IReadOnlyList<string> path) =>
path.Count == 3 &&
path[2] == "env" &&
path[0] is "dependencies" or "commands";
走査中に現在のパス(["dependencies", "<name>", "env"] のような配列)を引き回しておき、そのパスが「コンテナのenvブロック」かどうかで無条件マスクするか、名前ベースの正規表現に頼るかを切り替えます。加えて SecretPattern 自体にも connection_string / database_url / dsn / cookie / session を追加し、名前ベースの網羅性も広げました(public_key のような無害な名前まで拾わないよう、key 単体では反応しない設計です)。
4. コマンド解決をTOCTOUセーフにする
wip は設定されたコマンド名(例えば wslc)をPATHから探して実行します。以前の CommandResolver は「見つかったかどうか」を真偽値で返すだけで、実際にどのパスを実行するかは呼び出し側が別途もう一度解決していました。この間にカレントディレクトリが変わっていると、最初に見つけたはずのファイルとは別の、同名の実行ファイルが実行されてしまう可能性があります(典型的なTOCTOU: Time-of-Check to Time-of-Use)。
修正後は、解決した時点の絶対パスをそのまま返し、以後はそのパス自体を使います。
private static string? ResolveFile(string candidate)
{
// Freeze relative paths before checking them so the returned value cannot be
// reinterpreted against a different working directory by the eventual caller.
var fullPath = TryGetFullPath(candidate);
if (fullPath is not null && File.Exists(fullPath))
{
return fullPath;
}
if (!OperatingSystem.IsWindows() || Path.HasExtension(candidate))
{
return null;
}
var extensions = System.Environment.GetEnvironmentVariable("PATHEXT") ?? ".COM;.EXE;.BAT;.CMD";
// ...(PATHEXTを順に試し、見つかった絶対パスを返す)
}
Windows特有の話として、拡張子なしのコマンド名(wslc)は PATHEXT(.COM;.EXE;.BAT;.CMD 等)を順に試して実在するファイル名を確定させる必要があります。ここで拡張子付きの実ファイル名まで解決してから返すことで、後段が「拡張子なしの名前を再解決する」ような二度手間・ズレの余地も無くしています。
5. YAMLパーサーに入力サイズ・ノード数の上限を設ける
wip.yml を読み込むパーサーにも、サイズ・件数の上限を追加しました。
internal const int MaxInputSize = 1024 * 1024; // 1 MiB
internal const int MaxTotalNodes = 150_000;
internal const int MaxScalarLength = 256 * 1024; // 256 KiB
internal const int MaxCollectionElements = 50_000;
ファイル読み込みは FileInfo.Length の事前チェックと、実際に読むストリームの両方に上限をかけ(LimitedReadStream)、パース中もノード数・スカラー長・コレクションの要素数をそれぞれ数えて超過したら即座に ConfigException を投げます。壊れた、あるいは悪意を持って作られた巨大なYAMLが、パーサーのメモリやCPUを不必要に食いつぶして固まる事態を防ぐのが狙いです。
結果・学び
- 5つの対策はいずれも同じ時期(v2.3.1〜v2.4.0あたり)にまとめて入っており、
wip help --aiやwip init --aiのような「ローカルLLM機能を足す」変更それ自体がレビューのきっかけになりました。AI連携機能を足すときは、その変更を単体でレビューするだけでなく、既存の設定表示・パーサー・コマンド実行といった周辺コードも合わせて見直す価値があります。 - 「送信先の検証」と「送信前の開示」は別レイヤーの対策として両方実装しました。検証だけだと想定漏れがあれば通ってしまいますし、開示だけだとユーザーの見落としに頼ることになります。
- 秘密情報のマスクは、キー名のパターンマッチだけに頼ると設計上すり抜けが起きる場所(今回で言えば環境変数ブロック)があるため、「どこにあるか(構造)」と「何という名前か(パターン)」を組み合わせるのが安全でした。
- 外部と通信する機能を足すレビューでは、"何を検証するか" だけでなく "相手からの応答をどう制限するか" も対になる論点として扱うと漏れが減ります。