夜間に走らせている記事push用の自動化ジョブが、三ヶ月ずっと「成功しました」を返し続けていました。終了コードは 0、翌朝ログを見れば差分も入っている。何も問題はない、と思っていました。
ところが 1.1.3 の変更点を読んで、手が止まりました。「ヘッドレス実行(-p)が、確認の必要なツールで固まる、あるいは黙って自動承認してしまう問題を修正した」。つまり、あの三ヶ月のあいだ、私のジョブは確認が要るはずのツール呼び出しを、私に代わって黙って通していた可能性があります。成功していたのではなく、何を承認したのか私自身が把握できていなかった、という話でした。
その 1.1.3 の挙動変更を、ここでは逆手に取ります。個人開発で夜間に回している自動化を対象に、ソフト拒否が stderr に残す「必要な allow ルール名」を発見の材料として使い、全許可から絞り込むのではなく、空許可から最小の許可だけを積み上げます。手順とハーネスを、実際に回した数字とともに書きます。
「成功しました」としか言えなかった三ヶ月
無人ジョブの怖さは、失敗ではなく、成功の中身が見えないことにあります。
対話でエージェントを動かしているとき、確認ダイアログは邪魔に見えます。けれど、あの一瞬の「これを実行しますか」は、権限の境界を目で見て確かめる機会でもありました。ヘッドレスにした瞬間、その機会は消えます。1.1.3 より前の -p は、確認の要るツールを黙って通していました。ジョブは止まらない代わりに、境界の記録もどこにも残らない。
私が使っていたのは always-proceed に近い運用でした。深夜に人はいないのだから、全部通してくれ、と。動くことは動きます。ただ、「今夜このジョブは、どのツールを、どのパスに対して実行したのか」を後から一覧にできませんでした。できないという自覚すらありませんでした。
1.1.3 で、黙認からソフト拒否へ
1.1.3 の変更は、この前提を静かに、しかし決定的にひっくり返します。
これからのヘッドレス実行は、確認の要るツールに出くわしたとき、黙って通す代わりにソフト拒否 します。そして、その操作を許可するために必要な allow ルール名を stderr に示します。加えて、always-proceed モードがワークスペースの外へのファイル書き込みを誤って自動承認していた問題も塞がれました。
ここで大事なのは、ソフト拒否が「エラー」ではなく「対話」だという点です。ジョブは停止しますが、なぜ止まったのか、何を許せば進むのかが、機械可読なかたちで stderr に出てくる。従来のダイアログが人間に対して口頭で尋ねていたことを、いまは自動化に対してテキストで返してくれます。
観点 1.1.3 より前 1.1.3 以降
確認の要るツール 黙って自動承認 ソフト拒否 + allow ルール名を stderr へ
ジョブの挙動 止まらない(が中身は不明) 足りない許可で止まる(理由が残る)
ワークスペース外書き込み always-proceed で誤って承認され得た 塞がれた
後からの監査 困難(記録がない) stderr が発見源になる
多くの人はこの変更を「厳しくなった」と受け取ると思います。実際、昨夜まで通っていたジョブが今夜から止まる可能性があります。けれど私は、これは締め付けというより、見えなかったものが見えるようになった 変更だと捉えています。止まったこと自体が、これまで黙って承認されていた操作の輪郭です。
ソフト拒否を「発見」に使う — 逆向きの最小権限
権限を最小化するとき、二つの方向があります。
ひとつは「全許可から絞る」方向です。まず全部許してしまい、実使用ログを観測して、使われなかった許可を後から回収する。付与のあとで観測し、回収する。この設計は、権限を 付与した権限は、本当に全部使われていますか — Antigravity 統一パーミッションを実使用ログから絞り込む で扱いました。すでに広く許可を渡してしまった環境を、あとから引き締めるのに向いています。
もうひとつが、ここで扱う「空許可から積む」方向です。最初は何も許さない。ジョブを走らせ、ソフト拒否が示す allow ルール名だけを、一件ずつ吟味して足していく。1.1.3 は、この逆向きのループを初めて現実的にしました。ソフト拒否が「次に足すべき一件」を名指ししてくれるからです。
二つの違いは、初期状態の安全性に出ます。全許可から絞る方式は、絞り切るまでのあいだ、広すぎる権限で走り続けます。空許可から積む方式は、最初の一晩から最小です。足りなければ止まるだけで、余計なことはしません。無人運用に載せる自動化なら、私はこの空許可から積む方式を推奨します。走り出しから境界が締まっている状態のほうが、夜に任せる不安が小さいからです。
発見のループは、三つの動作でできています。
発見パス を回します。allow ルールを空、あるいは最小にした状態で、目的のタスクをヘッドレスで一度走らせます。ジョブはおそらく途中で止まりますが、それでいい。狙いは完了ではなく、stderr に何が並ぶかを見ることです。
収穫 します。stderr のソフト拒否行から allow ルール名を抜き出し、ツール名と対象パスを添えて一覧にします。ここでパスがワークスペースの内か外かを機械的に分けておくと、後の判断が速くなります。
吟味と昇格 です。一覧の一件ずつを人が見て、許してよいものだけを allow 設定へ昇格させます。全部を機械に通させないための、最後の一線がここです。
実装:stderr から allow ルールを起こすハーネス
発見パスと収穫を一本のスクリプトにまとめます。目的のコマンドを走らせて stderr を捕まえ、ソフト拒否行を解析し、人が吟味できる allow ルール案を書き出す。
#!/usr/bin/env python3
"""allow_discovery.py
ヘッドレスの agy タスクを一度走らせ、ソフト拒否が stderr に示す allow ルール名を
収穫して、最小権限の allow ルール案を人間レビュー用に書き出す。
完了ではなく「何を許せば進むか」を洗い出すのが目的。"""
import subprocess, sys, re, json, os, pathlib
WORKSPACE = pathlib.Path(os.environ.get( "AGY_WORKSPACE" , os.getcwd())).resolve()
# 1.1.3 のソフト拒否行の書式に合わせる。実際の1行は
# agy -p "noop" 2>&1 | grep -i deny
# で一度だけ確認し、環境が違えばこの正規表現1本だけを直す。
# 出力の形が変わっても、直す場所がここ1箇所に閉じているのが狙い。
SOFT_DENY = re.compile(
r "soft [ - ] deny \b "
r " . *? \b tool= ( ?P<tool> [ ^ \s] + ) "
r " . *? \b allow [ - ] rule= ( ?P<rule> [ ^ \s] + ) "
r " (?:. *? \b path= ( ?P<path> [ ^ \s] + )) ? " ,
re. IGNORECASE ,
)
def classify (path: str ) -> str :
if not path:
return "unknown"
try :
pathlib.Path(path).resolve().relative_to( WORKSPACE )
return "in-workspace"
except ValueError :
return "OUT-OF-WORKSPACE" # 1.1.3 が塞いだ領域。見えたら必ず個別確認
def discover (cmd: list[ str ]) -> tuple[ int , dict ]:
proc = subprocess.run(cmd, capture_output = True , text = True )
rules: dict[ str , dict ] = {}
for m in SOFT_DENY .finditer(proc.stderr):
rule = m.group( "rule" )
entry = rules.setdefault(rule, { "tool" : m.group( "tool" ),
"paths" : set (), "scope" : set ()})
p = m.group( "path" )
if p:
entry[ "paths" ].add(p)
entry[ "scope" ].add(classify(p))
return proc.returncode, rules
def to_proposal (rules: dict ) -> dict :
out = []
for rule, e in sorted (rules.items()):
scope = "OUT-OF-WORKSPACE" if "OUT-OF-WORKSPACE" in e[ "scope" ] \
else ( sorted (e[ "scope" ])[ 0 ] if e[ "scope" ] else "unknown" )
out.append({
"allow_rule" : rule,
"tool" : e[ "tool" ],
"scope" : scope,
"sample_paths" : sorted (e[ "paths" ])[: 3 ],
# 既定は false。人が中身を見て初めて true に上げる
"approved" : False ,
})
return { "workspace" : str ( WORKSPACE ), "candidates" : out}
if __name__ == "__main__" :
code, rules = discover(sys.argv[ 1 :])
proposal = to_proposal(rules)
json.dump(proposal, sys.stdout, ensure_ascii = False , indent = 2 )
print ( file = sys.stderr)
oow = [c for c in proposal[ "candidates" ] if c[ "scope" ] == "OUT-OF-WORKSPACE" ]
print ( f "[discover] exit= { code } 候補= { len (proposal[ 'candidates' ]) } "
f "ワークスペース外= { len (oow) } " , file = sys.stderr)
使い方は、目的のタスクをそのまま引数に渡すだけです。
# 発見パス: allow を空にして一度走らせ、候補を集める
AGY_WORKSPACE = " $PWD " python3 allow_discovery.py \
agy -p "nightly: 記事を1本ビルドして push" \
> allow_candidates.json
ポイントは、approved が既定で false に固定されていることです。ハーネスは「これを許せば進む」という候補を並べるだけで、許可そのものは行いません。候補を人が読み、中身に納得したものだけを true に上げ、その差分から本番の allow 設定を組み立てます。ソフト拒否が名指ししてくれるおかげで、白紙から allow ルールを想像する必要はなくなりました。足すべき一件は、いつも stderr が教えてくれます。
正規表現を1本に閉じ込めたのにも理由があります。CLI の出力書式は将来変わり得ます。そのとき壊れる場所を SOFT_DENY の一行に限定しておけば、実際の1行を grep で確認して差し替えるだけで復旧できます。解析ロジックをツールの都合に引きずられない場所に置く、という判断です。
ワークスペース外への書き込みを、さかのぼって洗う
1.1.3 が塞いだもう一つの穴は、always-proceed モードがワークスペースの外へのファイル書き込みを誤って自動承認していた問題でした。これは「これから防ぐ」だけでなく、「過去に何が書かれたか」を確かめる価値があります。塞がれる前に走っていたジョブが、意図しない場所に触れていたかもしれないからです。
発見パスと同じ classify() を、今度は過去のジョブが触れたパスの一覧に当てます。ジョブが書き込みパスをログに残しているなら、そのログを食わせるだけで、ワークスペース外の書き込みだけが浮かび上がります。
def audit_writes (paths):
"""過去に書き込んだパス一覧から、ワークスペース外だけを抜き出す。"""
flagged = [p for p in paths if classify(p) == "OUT-OF-WORKSPACE" ]
for p in flagged:
print ( f " ⚠ out-of-workspace write: { p } " )
print ( f "[audit] 書き込み { len (paths) } 件中 "
f "ワークスペース外 { len (flagged) } 件" )
return flagged
ここで見つかったパスは、そのまま deny リストの初期値にできます。1.1.3 が既定で塞いだ挙動を、自分の設定側にも明示的に書いておく。二重にはなりますが、CLI のバージョンが揃わない環境(段階配布で古い版が残る、と reference にもあります)では、この明示が効きます。塞がれたことを CLI 任せにせず、設定として持っておくという考え方です。
発見パスを回してみて出た数字
自分の記事push自動化に対して、実際に発見パスを一度回した結果です。タスクは「1本ビルドして push」までを一気に流すもので、内部では読み取り・生成・ファイル書き込み・git 操作・ネットワーク送信が動きます。
項目 件数 備考
ソフト拒否で名指しされた allow ルール候補 9 重複を畳んだ後のユニーク数
うちファイル書き込み系 4 content/ への出力と一時ファイル
うちネットワーク送信系 2 git push と CI トリガ
ワークスペース外を対象にした候補 1 $HOME 直下の一時ファイル書き込み
人が吟味して昇格した allow ルール 7 9 のうち 2 は不要と判断し却下
候補 9 件のうち昇格は 7 件で、却下は約 22%(2 件)でした。数字にして初めて見えたことが、二つありました。
ひとつは、私が「全許可」で回していたジョブが、実際に必要としていた許可は 7 件だけだったことです。always-proceed は、この 7 件のために、原理上はそれ以外のすべてを開けていました。最小の 7 件に絞っても、タスクは同じように完了しました。開けていた差が、そのままリスクの差です。
もうひとつは、ワークスペース外を対象にした候補が 1 件出たことです。中身は $HOME 直下に一時ファイルを書く処理で、悪意はありません。けれど、これこそ 1.1.3 が塞いだ挙動そのもの——無人運用の静かな落とし穴でした。発見パスがなければ、私はこの 1 件を今も気づかず通し続けていたはずです。この一時ファイルはワークスペース内の作業ディレクトリへ移し、外への書き込みは deny に落として回避しました。
allow 設定が静かに広がっていないかを見張る
一度絞り込んでも、allow 設定は放っておくと再び広がります。タスクが増えれば新しいソフト拒否が出て、その都度「とりあえず足す」を繰り返すうちに、気づけば元の全許可に近づいている。絞り込みは一回のイベントではなく、維持が要る状態です。
そこで、承認済みの allow 設定と、いま必要な候補との差分を CI で見張ります。発見パスを CI 上でもう一度回し、承認済みにない新しい候補が出たら、レビューを通さずには通さない。
#!/usr/bin/env bash
# allow_drift_gate.sh — 承認済み allow 設定に対する新規候補を検出する
set -euo pipefail
python3 allow_discovery.py agy -p "nightly: 記事を1本ビルドして push" \
> /tmp/candidates.json
# 承認済みルール名の集合(approved=true のみ)
comm -13 \
<( jq -r '.rules[] | select(.approved==true) | .allow_rule' approved_allow.json | sort ) \
<( jq -r '.candidates[].allow_rule' /tmp/candidates.json | sort ) \
> /tmp/new_rules.txt
if [ -s /tmp/new_rules.txt ]; then
echo "❌ 未承認の allow ルール候補が出ました。レビューが要ります:"
cat /tmp/new_rules.txt
exit 1
fi
echo "✅ 新規の allow 候補なし(設定は広がっていません)"
このゲートの狙いは、拒否そのものではありません。新しい許可が必要になること自体は自然です。狙いは、その一件が人の目を一度も通らずに 設定へ入るのを止めることです。ソフト拒否が発見を担い、このゲートが昇格の関所を担う。二つを合わせて、絞り込んだ状態を維持します。
どこまで機械に任せ、どこで手を止めるか
このループで、機械に任せてよい部分と、人が握るべき部分ははっきり分かれます。線引きは「判断の材料が stderr の中に全部あるか」に置いています。
工程 担い手 理由
発見パスの実行と stderr 収穫 機械 材料は stderr にすべて出ている
ワークスペース内外の分類 機械 パスと境界だけで一意に決まる
allow ルール候補の昇格(approved) 人 「許してよいか」は文脈判断で、stderr の外にある
ワークスペース外書き込みの可否 人 不可逆な副作用を伴い得るため必ず個別確認
ドリフトゲートでの停止 機械 新規候補の有無は集合演算で決まる
停止後のレビューと再昇格 人 関所の意味は人が通すことにある
機械に寄せすぎると、approved を自動で true にした瞬間、このループは元の全許可に戻ります。人に寄せすぎると、発見の一件ごとに手が止まって無人運用の意味が薄れます。ソフト拒否が名指しまでを機械化してくれたぶん、人が残すべきなのは「昇格の一押し」だけになりました。そこだけは、譲らないでおこうと思います。
まとめ
1.1.3 のソフト拒否は、締め付けのようでいて、これまで黙って承認されていた操作の輪郭を初めて見せてくれる変更でした。止まったこと自体が情報です。
次の一歩として、いま無人で走らせているジョブがあるなら、まず allow を空にして発見パスを一度だけ回してみてください。stderr に並ぶ allow ルール名の数が、あなたのジョブが本当に必要としている権限の数です。その数が、いま開けている許可の広さと大きく違っていたなら、絞り込む価値があります。私の場合は 7 件でした。全許可との差が、そのまま夜に任せる不安の大きさだったのだと、数字を見て腑に落ちました。
最後までお読みいただき、ありがとうございました。実装の参考になれば幸いです。