朝、差分を確認していて手が止まりました。夜のあいだに回していたタスクが、リポジトリの中だけを触るはずだったのに、ひとつ上の階層に設定ファイルを書き出していたのです。実害はありませんでした。ただ、書けてしまったという事実の方が、書かれた中身より長く尾を引きました。
Antigravity CLI 1.1.3(7/16)で、always-proceed モードのとき、ワークスペースの外へのファイル書き込みが誤って自動承認されていた不具合が修正されています。私の朝の一件は、まさにこの穴に落ちていたのだと思います。修正が入った今でも、CLI に任せきりにせず、自分の側でも書き込み先を確かめる小さな仕組みを一枚挟んでおきたい。そう考えて作ったガードを、この記事で共有します。
「外に書けてしまう」の正体
書き込み先が意図せず外へ出る原因は、たいてい派手なものではありません。私が踏んだのは次の3つでした。
| 入口 | 何が起きるか | 気づきにくい理由 |
|---|---|---|
| 相対パスと cwd のずれ | ../config/app.json が、想定と違う起点から解決される | 手元では正しく、無人実行時だけ起点が変わる |
.. の混入 | 生成されたパスに .. が紛れ、境界の外へ抜ける | 文字列としては自然に見える |
| シンボリックリンク | ワークスペース内のリンクが、実体は外を指す | パスの見た目だけでは判別できない |
共通しているのは、書き込む直前の「見かけのパス」を信じてはいけない、という点です。文字列を前方一致で調べるだけでは、.. もシンボリックリンクもすり抜けます。必要なのは、実体としてどこを指すのかまで解決してから、境界の内側にあるかを判定することです。
書き込み先を検札する小さなガード
やることは2つだけです。ひとつ、対象パスを実体まで正規化する。ふたつ、それがワークスペース直下に含まれるかを判定する。Node.js で書くと、こう収まります。
// write-guard.mjs
import { realpathSync } from "node:fs";
import { resolve, dirname, relative, isAbsolute, sep } from "node:path";
// ワークスペースの実体パス(起動時に一度だけ解決)
const ROOT = realpathSync(process.env.AGY_WORKSPACE ?? process.cwd());
// 実体がまだ無いパスでも、最も近い実在の親まで遡って解決する
function realpathNearest(p) {
let current = resolve(p);
// 存在する祖先が見つかるまで上へ
// (まだ書かれていないファイルは realpath できないため)
for (;;) {
try {
const realParent = realpathSync(dirname(current));
return resolve(realParent, current.slice(dirname(current).length + 1));
} catch (e) {
if (e.code !== "ENOENT") throw e;
const parent = dirname(current);
if (parent === current) return current; // ルートまで到達
current = parent;
}
}
}
export function assertInsideWorkspace(target) {
const real = realpathNearest(target);
const rel = relative(ROOT, real);
const escapes = rel === "" || rel.startsWith("..") || isAbsolute(rel);
if (escapes) {
throw new Error(
`書き込み先がワークスペースの外です: ${target}\n 解決後: ${real}\n 基準: ${ROOT}`
);
}
return real;
}肝は realpathNearest です。これから書くファイルはまだ存在しないので、そのままでは実体解決できません。そこで、実在する一番近い親までさかのぼって解決し、そこから相対で組み直します。こうすると、途中にシンボリックリンクがあっても実体側で評価され、.. は relative の結果が .. で始まるかどうかで弾けます。
書き込みの直前に一行差し込むだけです。
import { writeFileSync } from "node:fs";
import { assertInsideWorkspace } from "./write-guard.mjs";
function safeWrite(target, data) {
const real = assertInsideWorkspace(target); // 外なら例外で止まる
writeFileSync(real, data);
}すり抜けを試す
ガードは、通ってほしいものを通すより、通ってほしくないものを止められるかで評価すべきだと考えています。私は次の表を試験に落として、緑になるまで直しました。
| 入力 | 期待 | 狙い |
|---|---|---|
notes/today.md | 通す | 正常系 |
./out/build/log.txt | 通す | 内側の相対パス |
../secrets.json | 止める | ひとつ上へ抜ける |
/etc/hosts | 止める | 絶対パスで外へ |
out/../../x | 止める | 内側を経由してから抜ける |
| 外を指すシンボリックリンク配下 | 止める | 実体は境界の外 |
試験そのものも短く書けます。
// write-guard.test.mjs
import assert from "node:assert";
import { symlinkSync, mkdirSync } from "node:fs";
import { assertInsideWorkspace } from "./write-guard.mjs";
const pass = ["notes/today.md", "./out/build/log.txt"];
const block = ["../secrets.json", "/etc/hosts", "out/../../x"];
for (const p of pass) assert.doesNotThrow(() => assertInsideWorkspace(p), p);
for (const p of block) assert.throws(() => assertInsideWorkspace(p), p);
// シンボリックリンクのすり抜け
mkdirSync("out", { recursive: true });
symlinkSync("/tmp", "out/escape"); // 外を指すリンク
assert.throws(() => assertInsideWorkspace("out/escape/pwn"), "symlink escape");
console.log("すべて期待どおりに通過・遮断されました");out/../../x を止められるかどうかが、文字列一致で済ませたガードとの分かれ目です。前方一致だと「out/ で始まるから内側」と誤判定しますが、正規化を通せば実体は外だと分かります。
CLI 側でも二重化しておく
アプリ側の一枚に加えて、CLI を無人で回すときは allow ルールの名前づけでも輪郭を残しておくと安心です。1.1.3 では、確認の要る操作をソフト拒否したうえで、許可に必要な allow ルール名を stderr に出してくれるようになりました。書き込み系のルールは、対象範囲が読み取れる名前にしておくと、後から棚卸ししやすくなります。
# 無人実行のログから、書き込み許可がどこに効いたかだけを拾う
agy -p "$TASK" 2>&1 | grep -iE "allow.*(write|fs)" | sort -u出力される allow ルール名を眺めて、想定より広い許可が効いていないかを朝に一度だけ確認する。これだけでも、静かに広がっていく権限に気づきやすくなります。
運用に落とすときの置き場所
このガードは、書き込みを行う関数の内側、実際に writeFileSync を呼ぶ直前に置くのが最も漏れにくいと感じています。パスを組み立てる場所は複数あっても、最後に書く出口はたいてい一箇所に集まるからです。出口で検札すれば、途中でどんなパスの作られ方をしても、外へは抜けません。
止めたときは、握りつぶさずに必ず記録します。私は「解決後の実体パス」「基準にしたルート」「入力そのもの」の3つをログに残すようにしました。どこを起点に、何が、どこへ抜けようとしたのかが一目で分かるので、原因の切り分けが速くなります。
被害範囲そのものをより広く設計したい場合は、エージェントが壊す前提で組む — ブラスト半径を小さく保つ運用設計が参考になります。夜間の無人実行を一段落ち着いて任せるための全体像は、Background Agent に夜間作業を任せて、朝に後悔しないために — 無人実行のガードレール設計にまとめました。CLI の allow ルールを最小権限から積み上げる逆向きの手順は、黙って通していた確認ツールを、ソフト拒否から allow ルールに起こすと合わせて読むと、権限の絞り方が立体的になります。
まとめ
無人実行で怖いのは、失敗より、静かに成功してしまうことだと改めて思いました。書き込み先の検札は、正規化と包含判定というごく小さな二手で足ります。今日の次の一歩としては、自分のタスクの「書く出口」がどこに集まっているかを一度たどってみることをおすすめします。出口さえ押さえれば、このガードは一箇所で効きます。
個人開発でタスクを自動化していると、権限の既定値を読み直すたびに、任せる範囲を少しずつ描き直すことになります。私自身、その繰り返しの中にいます。同じように夜間の自動処理を回している方の、朝の安心につながれば幸いです。お読みいただきありがとうございました。