| name | sync-skills-lock |
| description | ルート直下の `skills-lock.json` の `computedHash` を upstream リポジトリの最新状態と照合して更新する。`source` が `Fandhe-AI/<repo>` に完全一致しないエントリは clone せず skip (安全弁)。submodule 配下の `skills-lock.json` は触らない。contribute-skill のマージ後や upstream 同期後、「ハッシュ更新」「skills-lock 同期」などで使用。 |
| argument-hint | [skill-name] (省略時は全スキル) |
| user-invocable | true |
| model | sonnet |
sync-skills-lock
ルート直下の skills-lock.json の computedHash を、upstream リポジトリの現状と照合して更新する。
対象ファイル
- ルート: 呼び出し元リポジトリ直下の
skills-lock.json — このスキルが唯一編集するファイル
- 除外: submodule 配下の
skills-lock.json — submodule 境界を跨がないため 絶対に触らない
前提条件
gh CLI がインストールされ、認証済みであること
node / npx が利用可能であること(npx skills add を使用するため)。skills CLI は固定版(SKILLS_CLI_VERSION)で実行する。値と更新手順は「skills CLI のバージョン固定と更新手順」節を参照
python3 が利用可能であること(状態署名〔path_state / index_state_signature〕と復元処理〔既存 ignored ファイルの比較・復元等〕で使用。主要 Linux / macOS には標準搭載されている。無い環境では Step 4 フェンスが npx 実行前に fail-closed で停止するため、導入してから再実行すること)
file CLI が利用可能であること(未追跡バイナリファイルの種別表示に使用。未導入の環境では
種別が file コマンド未検出 として表示され、承認前にサイズ・git blob ハッシュのみで
判断することになる。macOS / 主要 Linux ディストリビューションには標準搭載されている)
- ルート直下の
skills-lock.json が存在すること
- 実行前に
skills-lock.json に未コミットの変更がないこと(ステージ済み・未ステージ問わず)。本スキルの実行中に発生する変更は sync 由来のみとなり、git add skills-lock.json で全体をステージしても無関係な変更が混入しない
- 対象スキルの
.agents/skills/<name>/ に未コミット変更がないこと。npx skills add は .agents/skills/<name>/ を upstream の最新版で上書きするため、そのディレクトリに WIP が存在すると即座に失われる。git checkout で戻せるのは「最後にコミットされた状態」のみであり、npx 実行前の未コミット編集は復元できない。未追跡ファイルとして存在する WIP も対象であり、git status --porcelain で検出する
- 消費側リポジトリが commit 済み local patch を持つ場合: vendored skill(
.agents/skills/ 配下)へ commit 済みの local patch を適用しているリポジトリは、その検証・再適用の入口として repository-owned checker scripts/check-skill-local-patches.sh(無引数 = check / apply の 2 モード)と台帳 .agents/skills/LOCAL-PATCHES.md を持つ。commit 済み patch は上記 clean ガードでは保護できないため、checker が存在する場合は同期の前後(Step 4 の pre-check・Step 5.5 の apply + 最終検証)での成功が必須(非 0 は fail-closed で同期・stage しない)。台帳があるのに checker が無い状態も検証不能として fail-closed で停止する。checker(apply)の書き込み先は当該スキルディレクトリ・skills-lock.json・durable patch 置き場 scripts/local-patches/ に限る契約とし、範囲外の変更は各実行直後の digest 比較で fail-closed に検出する(検出範囲は Git が追跡・列挙する対象に限る best-effort であり、書き込み制限の保証ではない。保証はユーザーによる checker 内容レビュー + blob hash 承認が担う。詳細は Step 4 の「検出範囲の限界」コメント)。checker は消費側が配置する実行可能コードのため「存在するだけ」では実行せず、symlink ではない regular file(HEAD 側 mode も 100644/100755)であり、HEAD に commit 済みで worktree と一致し、かつユーザーへ由来・内容を提示して blob hash 単位の明示承認を得た場合のみ実行する(Step 4 で機械検証)。実行対象は worktree のファイルではなく承認済み HEAD blob を取り出した一時ファイル(CHECKER_EXEC)とし、hash 確認後に worktree の checker を差し替える TOCTOU 経路を断つ
- 通常構成のメイン worktree で実行すること。linked worktree(
git worktree add で作られた作業ツリー)では .git が gitdir を指す通常ファイルになり、実 Git ディレクトリ(.git/worktrees/<name>/ と共有側の refs・logs・config・objects)が状態署名の対象外になるため、Step 4 フェンスが npx 実行前に git rev-parse --absolute-git-dir / --git-common-dir の不一致で検出して fail-closed で拒否する。同様に、実 Git ディレクトリが作業ツリー外にある構成(git clone --separate-git-dir・submodule checkout・.git が symlink)も、「実 Git ディレクトリ = 作業ツリー直下の .git 実体ディレクトリ」の検証(--show-toplevel との厳密一致 + lstat)で npx 実行前に fail-closed で拒否する
フロー
Step 1: 引数を確認し、事前条件を検証する
TARGET="$ARGUMENTS" # 空なら全スキル対象
# 引数指定時は kebab-case のみ許可(パストラバーサル防止)
if [[ -n "${TARGET}" && ! "${TARGET}" =~ ^[a-z][a-z0-9-]+$ ]]; then
echo "エラー: スキル名は小文字 kebab-case のみ許可されています: ${TARGET}"
exit 1
fi
引数ありの場合は該当スキルのみ処理、なしの場合は skills-lock.json の全エントリを対象にする。
次に skills-lock.json の clean 状態を確認する。未コミット変更(ステージ済み・未ステージ問わず)があれば中止する。
# skills-lock.json に未コミット変更があれば中止(sync 由来以外の変更の混入を防ぐ)
# git diff 系は untracked を検出しないため porcelain を使う
if [[ -n "$(git status --porcelain -- skills-lock.json)" ]]; then
echo "エラー: skills-lock.json に未コミットの変更があります。コミットまたは退避してから再実行してください。"
exit 1
fi
Step 2: upstream 一覧を集計する
skills-lock.json を読み、source フィールドごとにスキルをグルーピングする(同一リポへの処理を 1 回にまとめるため)。
Fandhe-AI/agent-cli-skills:
- create-commit
- create-issue
- ...
Step 3: source を検証する
安全弁: 処理前に必ず source フィールドが Fandhe-AI/<repo> に完全一致することを確認する。前方一致では ../ を含む値が通過し、clone 時の URL パス正規化で組織外リポジトリを対象にできてしまうため、OWNER/REPO へ正規化後に厳密な正規表現で検証する。想定外の source は skip してユーザーに警告する。skills-lock.json の改ざん・誤設定によって untrusted リポジトリから clone することを防ぐためである。
REPO_SLUG="${SOURCE#https://github.com/}"
REPO_SLUG="${REPO_SLUG%.git}"
if [[ ! "$REPO_SLUG" =~ ^Fandhe-AI/[A-Za-z0-9._-]+$ ]] \
|| [[ "$REPO_SLUG" == "Fandhe-AI/." || "$REPO_SLUG" == "Fandhe-AI/.." ]]; then
echo "警告: 想定外の source: $SOURCE — このスキルは skip します"
continue
fi
Step 4–7: 対象スキルを1つずつ処理する(ループ)
対象スキルそれぞれについて、次の 4→5→6→7 を順に実行し、1スキル完了後に次スキルへ進む。全スキル sync であっても同時に複数スキルを処理せず、1スキルずつ完結させること。
Step 4: npx skills add で computedHash を更新する
sha256sum などで手動計算するのではなく、npx skills add に計算を任せる。これにより CLI の内部アルゴリズムと完全に一致する。
# 当該スキルの install ツリーに未コミット変更があれば npx が上書きするため skip
# git diff 系は untracked を検出しないため porcelain を使う(未追跡 WIP も保護対象)
if [[ -n "$(git status --porcelain -- ".agents/skills/${SKILL_NAME}/")" ]]; then
echo "警告: .agents/skills/${SKILL_NAME}/ に未コミット変更(未追跡含む)があります。npx の上書きで失われるため skip します。"
continue
fi
# python3 の存在確認(PR #412 codex P1 指摘): 状態署名(path_state /
# index_state_signature)と復元処理(restore_preexisting_ignored 等)が python3 を
# 必須実行する。無いまま進むと npx 実行後の検査・復元の途中で command-not-found に
# なり、スコープ外検査が中途半端なまま停止する(fail-open 経路)。python3 の不在は
# スキル単位の事情ではなく環境全体の条件のため、skip(continue)ではなくループ全体を
# npx 実行前に exit 1 で停止する(fail-closed。この時点では npx 未実行のため残置なし)。
if ! command -v python3 >/dev/null 2>&1; then
echo "エラー: python3 が見つかりません。状態署名・復元処理で python3 が必要です。導入してから再実行してください(fail-closed)。" >&2
exit 1
fi
# linked worktree の実行拒否(PR #412 codex P0 指摘): linked worktree
# (git worktree add で作られた作業ツリー)では .git が gitdir を指す通常ファイルで、
# 実 Git ディレクトリ(.git/worktrees/<name>/ と common dir 側の refs・logs・config・
# objects)は作業ツリーの外にある。repo_state_signature の走査(path_state .)は
# 作業ツリー内しか対象にせず、index_state_signature も index の論理状態しか補わない
# ため、npx が git update-ref 等で共有リポジトリの参照・設定を改変しても porcelain・
# ツリー署名・index 署名のすべてが前後一致し成功扱いになる。メイン worktree でのみ
# 成立する「--absolute-git-dir と --git-common-dir の一致」を一次根拠に判定し、
# 不一致(linked worktree のほか、実 Git ディレクトリが署名対象に収まらない
# submodule 等の変則構成すべて)は npx 未実行のまま fail-closed で拒否する。
# --git-common-dir は相対パスを返し得る(--path-format=absolute は git 2.31+ のため
# 使わない)ので、cd + pwd -P で双方を物理パスへ正規化してから比較する。
# rev-parse・正規化の失敗も「メイン worktree と確認できない」であって
# 「メインである」ではないため中止する。実行場所の異常はスキル単位の事情ではなく
# リポジトリ全体の条件のため、skip(continue)ではなくループ全体を exit 1 で
# 停止する(この時点では npx 未実行のため残置なし)。
if ! GIT_DIR_ABS="$(git rev-parse --absolute-git-dir)" \
|| ! GIT_COMMON_DIR_RAW="$(git rev-parse --git-common-dir)" \
|| ! GIT_DIR_PHYS="$(cd "${GIT_DIR_ABS}" && pwd -P)" \
|| ! GIT_COMMON_DIR_PHYS="$(cd "${GIT_COMMON_DIR_RAW}" && pwd -P)"; then
echo "エラー: Git ディレクトリの解決(git rev-parse --absolute-git-dir / --git-common-dir)に失敗しました。メイン worktree での実行と確認できないため中止します(fail-closed)。" >&2
exit 1
fi
if [[ "${GIT_DIR_PHYS}" != "${GIT_COMMON_DIR_PHYS}" ]]; then
echo "エラー: linked worktree(git worktree add で作られた作業ツリー)では実 Git ディレクトリ(.git/worktrees/<name>/ と共有側の refs・logs・config・objects)が状態署名の対象外になり、npx による共有リポジトリの改変を検出できないため実行を拒否します(fail-closed)。メイン worktree で実行し直してください。" >&2
exit 1
fi
# git-dir と common-dir の一致は linked worktree を除外するだけで、「実 Git
# ディレクトリが署名対象の作業ツリー内にある」ことまでは保証しない
# (PR #412 Bugbot High 指摘)。git clone --separate-git-dir(.git が gitdir を
# 指す gitfile)・submodule checkout(.git/modules/<name>/ を指す gitfile)・
# .git が symlink の構成では両者が一致したまま実体が作業ツリー外にあり、
# path_state の走査(起点 .)が refs・hooks・objects を含まないため、npx の
# 改変が porcelain・ツリー署名・index 署名のすべてをすり抜ける。そこで実 Git
# ディレクトリが「作業ツリー直下の .git 実体ディレクトリ」であることまで検証
# する: toplevel を上と同じ手法(cd + pwd -P)で物理パスへ正規化し、
# (1) GIT_DIR_PHYS が <toplevel>/.git と厳密一致、(2) <toplevel>/.git を lstat
# して symlink ではなく directory であること(gitfile = 通常ファイル・symlink
# はいずれも拒否)、の両方を要求する。不成立は npx 未実行のまま中止する。
if ! TOPLEVEL_RAW="$(git rev-parse --show-toplevel)" \
|| ! TOPLEVEL_PHYS="$(cd "${TOPLEVEL_RAW}" && pwd -P)"; then
echo "エラー: 作業ツリールートの解決(git rev-parse --show-toplevel)に失敗しました。実 Git ディレクトリが作業ツリー内の .git ディレクトリであることを確認できないため中止します(fail-closed)。" >&2
exit 1
fi
if [[ "${GIT_DIR_PHYS}" != "${TOPLEVEL_PHYS}/.git" ]] \
|| [[ -L "${TOPLEVEL_PHYS}/.git" ]] \
|| [[ ! -d "${TOPLEVEL_PHYS}/.git" ]]; then
echo "エラー: 実 Git ディレクトリが作業ツリー直下の .git 実体ディレクトリであることを確認できません(git clone --separate-git-dir・submodule・.git の symlink 等)。実 Git ディレクトリが状態署名の対象外になり、npx による改変を検出できないため実行を拒否します(fail-closed)。通常構成のメイン worktree で実行し直してください。" >&2
exit 1
fi
# カレントディレクトリが作業ツリールートであることの検証(Bugbot Medium 指摘):
# 上のブロックは GIT_DIR が <toplevel>/.git であることまでしか確認しない。
# repo_state_signature は path_state の起点を「.」(cwd)にしているため、
# サブディレクトリから実行すると .git(hooks・refs・config・objects)が起点の
# 走査対象から外れ、上の worktree 検証をすべて満たしたまま npx による .git 改変を
# 検出できなくなる。pwd -P を toplevel と同じ手法で物理パス化し、厳密一致しない
# 場合は npx 未実行のまま中止する。
if ! CWD_PHYS="$(pwd -P)"; then
echo "エラー: カレントディレクトリの物理パス解決(pwd -P)に失敗しました。作業ツリールートでの実行と確認できないため中止します(fail-closed)。" >&2
exit 1
fi
if [[ "${CWD_PHYS}" != "${TOPLEVEL_PHYS}" ]]; then
echo "エラー: カレントディレクトリ(${CWD_PHYS})が作業ツリールート(${TOPLEVEL_PHYS})と一致しません。repo_state_signature はカレントディレクトリを起点に走査するため、サブディレクトリから実行すると .git(hooks・refs・config・objects)が署名対象外になり npx による改変を検出できません。作業ツリールートで実行し直してください(fail-closed)。" >&2
exit 1
fi
# 許可先経路の実体検証(PR #412 P0 指摘): スコープ外検査(porcelain 比較・状態
# シグネチャ比較)は .agents/skills/${SKILL_NAME} を「パス文字列」で走査除外する。
# この経路上のいずれかの要素が実行前から symlink だと、npx がリンク先(リポジトリ外を
# 含む)へ書いた内容は除外側に吸われてどの検査にも現れない。そのため npx 実行前に
# 各要素を lstat し、存在するものはすべて実体のディレクトリであることを要求する
# (symlink・非ディレクトリは fail-closed で中止。npx は実行しない)。存在しない
# 要素のみ、初回インストールで npx が正当に新規作成するケースとして許容する
# (後段の REPO_SIG_OMITS の条件付き omit と同じ判定基準)。symlink 化された経路は
# レイアウト自体の異常であり人間の確認を要するため、skip(continue)ではなく
# ループ全体を exit 1 で停止する(この時点では npx 未実行のため残置なし)。
# この検査は開始時点のレイアウトのみを保証する(TOCTOU)。npx が実行中に許可先を
# 置換するケースは、許可先要素自身の署名(SKILL_DIR_SIG_SPEC の prune-under)と
# 実行後の再検証(verify_scope_path_after_run)が受け持つ。
for SCOPE_PATH_COMPONENT in ".agents" ".agents/skills" ".agents/skills/${SKILL_NAME}"; do
if [[ -L "${SCOPE_PATH_COMPONENT}" ]]; then
echo "エラー: ${SCOPE_PATH_COMPONENT} がシンボリックリンクです。npx の書き込みがリンク先(リポジトリ外を含む)へ向かい、スコープ外書き込み検査で検出できないため中止します(fail-closed)。実体ディレクトリへ置き換えてから再実行してください。" >&2
exit 1
fi
if [[ -e "${SCOPE_PATH_COMPONENT}" && ! -d "${SCOPE_PATH_COMPONENT}" ]]; then
echo "エラー: ${SCOPE_PATH_COMPONENT} がディレクトリではありません。npx の書き込み先として想定外の実体のため中止します(fail-closed)。" >&2
exit 1
fi
done
# skills-lock.json の実体検証(PR #412 codex P0 指摘): skills-lock.json は状態
# シグネチャの prune と porcelain フィルタの双方で「パス文字列」により除外されるため、
# 実行前から外向き symlink だと npx がリンク先(リポジトリ外を含む)へ書き込んでも
# どの検査にも現れない。存在する場合は lstat で regular file であることを要求する
# (symlink・ディレクトリ等は npx 未実行のまま fail-closed で中止)。不存在は初回
# 生成として許容する。-f は symlink を辿るため、-L 判定で symlink を先に排除する。
# レイアウト自体の異常で人間の確認を要するため、skip(continue)ではなくループ全体を
# exit 1 で停止する。この検査も開始時点のみを保証する(TOCTOU)。npx が実行中に
# 置換するケースは実行後の再検証(verify_lock_file_after_run)が受け持つ。
if [[ -L "skills-lock.json" ]]; then
echo "エラー: skills-lock.json がシンボリックリンクです。npx の書き込みがリンク先(リポジトリ外を含む)へ向かい、スコープ外書き込み検査で検出できないため中止します(fail-closed)。実体ファイルへ置き換えてから再実行してください。" >&2
exit 1
fi
if [[ -e "skills-lock.json" && ! -f "skills-lock.json" ]]; then
echo "エラー: skills-lock.json が regular file ではありません。npx の書き込み先として想定外の実体のため中止します(fail-closed)。" >&2
exit 1
fi
# 消費側リポジトリが vendored skill へ commit 済み local patch を適用している場合
# (台帳: .agents/skills/LOCAL-PATCHES.md)、commit 済み patch は上の clean ガードを
# 通過してしまうため、npx より前に repository-owned checker(check mode)を必須にする。
# checker 非 0、および台帳があるのに checker が無い状態は、fail-closed で npx を
# 実行しない(同期を開始しない)。LOCAL_PATCH_GUARD は checker の存在確認より後の
# 全失敗経路で restore_contract_scope 呼び出しの条件に使う sticky フラグであり、
# scripts/skills-lock-update.sh と同じ変数名・同じ用途で揃える(Bugbot Medium 指摘:
# `-f scripts/check-skill-local-patches.sh` を毎回再チェックすると、npx や checker
# 自身がそのパスを消した場合に「checker は存在した(このフラグは true のまま)」を
# 見失い、契約範囲の復元が働かなくなる)。
LOCAL_PATCH_GUARD=false
if [[ -f scripts/check-skill-local-patches.sh ]]; then
LOCAL_PATCH_GUARD=true
# checker は導入先リポジトリが配置する実行可能コードであり、「存在するだけ」で実行しては
# ならない(未信頼な checkout・未レビュー PR の任意コードが、差分提示・承認より前に
# ユーザー権限で走る経路になる)。実行前に次のすべてを満たすことを確認する(fail-closed):
# (1) worktree の checker が symlink ではない regular file である(git hash-object は
# symlink のリンク先内容を読むため、-L を先に拒否しないと「HEAD と同内容の外部
# ファイルへの symlink」が (2) の一致検証をすり抜ける)
# (2) HEAD に commit 済みで、worktree の内容が HEAD の blob と一致する
# (未追跡・未コミット変更の checker は拒否 = レビューを経ていないコードを実行しない)
# (3) HEAD 側のエントリ mode が 100644 / 100755 の regular file である
# (120000 = symlink エントリの blob はリンク先文字列であり、実行対象にできない)
# (4) ユーザーへ由来と内容を提示し、この blob hash に対する実行の明示承認を得ている
# (承認は hash 単位で本フロー全体に有効。内容が変われば再承認。
# 提示: git log -1 -- scripts/check-skill-local-patches.sh /
# git show HEAD:scripts/check-skill-local-patches.sh)
# 実行は worktree のファイルではなく、承認済み HEAD blob を取り出した一時ファイル
# (CHECKER_EXEC)に対して行う。hash 確認後〜bash 実行の間に worktree の checker を
# 差し替える TOCTOU 経路を、実行対象を承認済み blob へ固定することで断つ
if [[ -L scripts/check-skill-local-patches.sh ]]; then
echo "エラー: checker がシンボリックリンクです。リンク先差し替えで HEAD 一致検証をすり抜けられるため同期しません(fail-closed)。実体ファイルへ置き換えてから再実行する。"
exit 1
fi
if [[ ! -f scripts/check-skill-local-patches.sh ]]; then
echo "エラー: checker が regular file ではありません。実行対象にできないため同期しません(fail-closed)。"
exit 1
fi
CHECKER_HEAD_MODE="$(git ls-tree HEAD -- scripts/check-skill-local-patches.sh 2>/dev/null | awk '{print $1}')"
if [[ "${CHECKER_HEAD_MODE}" != "100644" && "${CHECKER_HEAD_MODE}" != "100755" ]]; then
echo "エラー: HEAD の checker が regular file ではありません(mode: ${CHECKER_HEAD_MODE:-エントリなし})。symlink 等は実行対象にできないため同期しません(fail-closed)。"
exit 1
fi
CHECKER_HASH="$(git hash-object -- scripts/check-skill-local-patches.sh)"
if [[ "${CHECKER_HASH}" != "$(git rev-parse HEAD:scripts/check-skill-local-patches.sh 2>/dev/null || true)" ]]; then
echo "エラー: checker が HEAD に commit 済みの内容と一致しません(未 commit・未追跡・未コミット変更)。任意コード実行を防ぐため同期しません(fail-closed)。"
exit 1
fi
echo "CHECKER_HASH=${CHECKER_HASH} # 実行承認の対象となる blob hash。由来・内容と合わせてユーザーへ提示する"
# ユーザー承認(上記 (4))を得たら、承認された hash を CHECKER_APPROVED_HASH に設定する。
# 承認は変数の設定によってのみ成立し、未設定・不一致のまま checker を実行する経路は無い
if [[ "${CHECKER_APPROVED_HASH:-}" != "${CHECKER_HASH}" ]]; then
echo "エラー: checker はユーザー承認済みの blob hash(CHECKER_APPROVED_HASH)と一致する場合のみ実行できます。承認を得てから再実行してください(fail-closed)。"
exit 1
fi
# 承認済み HEAD blob を一時ファイルへ取り出す。以後の checker 実行(同期前 check /
# Step 5.5 の apply・最終 check)はすべてこのファイルを使い、worktree の checker は
# 実行しない。取り出し失敗のまま進むと空ファイルの bash 実行(exit 0 の no-op)が
# 「検証成功」に化けるため fail-closed で停止する
CHECKER_EXEC="$(mktemp)"
if ! git cat-file blob "${CHECKER_APPROVED_HASH}" > "${CHECKER_EXEC}"; then
echo "エラー: 承認済み checker blob(${CHECKER_APPROVED_HASH})の取り出しに失敗しました。同期しません(fail-closed)。"
exit 1
fi
echo "CHECKER_EXEC=${CHECKER_EXEC} # 実行対象(承認済み blob の取り出し先)。Step 5.5 まで同一 shell で保持する(失われたら承認済み hash から再作成する)"
# checker を実行する唯一の経路(codex P1 指摘: CHECKER_EXEC はただの一時ファイルで
# あり、直前の実行〔特に apply は checker 自身のコードを実行するため自己書換えも
# 可能〕による置換・改変〔TOCTOU〕が、検証されないまま次の実行へ紛れ込み得る。
# 起動時に1度取り出した後は再検証せず bash に渡していたため、同期前 check の直後に
# CHECKER_EXEC が残置・置換された場合、承認済み blob と異なるコードが Step 5.5 の
# apply・最終検証で実行される経路が残っていた)。呼び出しごとに毎回
# `git hash-object` でハッシュを再検証し、不一致・不在なら承認済み blob から
# 無条件に書き直してから実行する。検証と実行を1関数に閉じ込めることで、呼び出し
# 箇所(同期前 check・Step 5.5 の apply・最終検証)が増えても検証漏れが構造的に
# 起きないようにする。Step 5.5 で shell セッションが切れて本関数が失われている
# 場合は、他の Step 4 定義関数(outside_state 等)と同様にこの定義を再実行してから
# 使う。戻り値の扱い: 「取り出し・検証自体の失敗」と「checker が実行された結果の
# 非ゼロ終了」を呼び出し元が区別できるよう、前者は CHECKER_RUN_VERIFY_FAILED=1 を
# 立ててから非ゼロを返す(checker 自身の exit code と衝突しない専用シグナル)。
run_checker() {
CHECKER_RUN_VERIFY_FAILED=0
if [[ -z "${CHECKER_EXEC:-}" || ! -f "${CHECKER_EXEC}" \
|| "$(git hash-object -- "${CHECKER_EXEC}" 2>/dev/null || echo missing)" != "${CHECKER_APPROVED_HASH}" ]]; then
# 置換前の一時ファイルを明示的に削除してから再代入する(Bugbot Medium / codex
# P2 指摘: 再代入するだけだと旧一時ファイルが /tmp に残る。checker の apply に
# よる自己書換えという通常ケースでもこの分岐は毎回通るため、mktemp の直前に
# 確実に削除する)。
[[ -z "${CHECKER_EXEC:-}" ]] || rm -f "${CHECKER_EXEC}"
CHECKER_EXEC="$(mktemp)"
if ! git cat-file blob "${CHECKER_APPROVED_HASH}" > "${CHECKER_EXEC}" \
|| [[ ! -s "${CHECKER_EXEC}" ]] \
|| [[ "$(git hash-object -- "${CHECKER_EXEC}")" != "${CHECKER_APPROVED_HASH}" ]]; then
echo "エラー: 承認済み checker blob(${CHECKER_APPROVED_HASH})の取り出し・検証に失敗しました(fail-closed)。"
CHECKER_RUN_VERIFY_FAILED=1
return 1
fi
fi
bash "${CHECKER_EXEC}" "$@"
}
# durable patch 置き場は承認時に directory ごと stage し、却下時に index からの復元 +
# git clean の対象になるため、未 stage の WIP・未追跡ファイルが残っていると巻き込まれて
# 失われる。staged のみの変更(= 本ループ内で承認済みに積み上がった分)は許容する。
# producer(git status)を grep -q へ直接 pipe すると、-q の早期終了による SIGPIPE で
# pipefail 下のパイプラインが偽になり WIP を見逃し得るため、一旦変数へ取得してから判定する
LOCAL_PATCHES_STATUS="$(git status --porcelain -- scripts/local-patches/)"
if grep -q '^.[^ ]' <<<"${LOCAL_PATCHES_STATUS}"; then
echo "エラー: scripts/local-patches/ に未 stage の変更・未追跡ファイルがあります。承認・却下経路が巻き込むため同期しません(fail-closed)。"
exit 1
fi
# 契約範囲(skills-lock.json / 当該スキル / durable patch)外の状態 digest。worktree 側は
# 「HEAD を基底にした一時 index へ範囲外のみ git add -A」した tree hash で捉える
# (未追跡・削除を含む全ファイルが実 blob hash で比較され、diff の表示文字列に依存しない
# = dirty なバイナリの上書きも検出する。範囲内は HEAD のまま固定されるため同期による
# 正当な変更では digest が動かない)。index 側は ls-files -s の blob hash で捉える。
# 基準(PRE_OUTSIDE)は checker の初回実行(同期前 check)より前に取得する。check の後に
# 取得すると、check mode が行った範囲外変更が基準へ取り込まれ検出できなくなる。
#
# 【検出範囲の限界(重要)】この digest は Git が追跡・列挙できる対象(非 ignore の
# worktree / index)に限られる。.gitignore 対象・.git/ 配下(config・hooks 等)・
# リポジトリ外への書き込みは検出できない。同一権限で任意コードを実行した後の
# tree 比較は書き込み制限の「保証」にはならず、信頼アンカーはあくまで実行前の
# ユーザーによる checker 内容レビュー + blob hash 承認である。本検証はその上に
# 重ねる best-effort の追加防御(defense-in-depth)として扱うこと
OUTSIDE_PATHSPEC=(. ":(exclude)skills-lock.json" ":(exclude).agents/skills/${SKILL_NAME}" ":(exclude)scripts/local-patches")
# 内部コマンド(git read-tree / git add / git write-tree / git ls-files / git
# hash-object)はいずれも `local` 代入・`$(...)` 経由で呼ぶため、失敗しても
# `set -e` が必ずこの関数の呼び出し元(`$(outside_state)` を `[[ ]]` の条件式内で
# 使う verify_outside_and_checker 等)まで伝播するとは限らない(`[[ ]]` の条件式は
# errexit の伝播対象外であり、内部でコマンドが早期に失敗して空文字のまま関数を
# 抜けても、その非ゼロ終了は握り潰されて文字列比較にしか使われない)。空 digest
# 同士が偶然一致すると「契約範囲外の変更なし」と誤判定し得るため(Bugbot Medium
# 指摘)、失敗時は他の呼び出し・他の成功時 digest と絶対に一致しない一意な
# エラーマーカーを出力したうえで明示的に非ゼロを返す。PID($BASHPID。呼び出しごとに
# 新しいサブシェルが fork されるため呼び出し間で重複しない)とナノ秒時刻を組み合わせ、
# date が使えない環境向けに $RANDOM を二重フォールバックにする。
# git ls-files | git hash-object のような素のパイプラインは、このフェンスが
# pipefail を有効化していない限り末尾コマンドの終了状態しか見ない(Bugbot
# Medium / codex P1 指摘)。git ls-files だけが失敗しても空 stdin が正常に
# hash 化されて rc=0 のままになり、上の fail-closed 分岐(エラーマーカー)を
# 通らない。scripts/skills-lock-update.sh はファイル先頭の `set -euo pipefail`
# で保護されているため同型の記述でも安全だが、このフェンス単体では保証がないため、
# パイプラインを使わず producer(git ls-files)の出力を一時ファイルへ書き出して
# から終了コードを個別に検査し、その後 git hash-object をファイル入力で実行する
# 形に分離する。
outside_state() {
local tmp_index_dir wt_tree idx_digest idx_list_file rc=0
tmp_index_dir="$(mktemp -d)" || rc=1
if [[ "${rc}" -eq 0 ]] && ! GIT_INDEX_FILE="${tmp_index_dir}/index" git read-tree HEAD; then
rc=1
fi
if [[ "${rc}" -eq 0 ]] && ! GIT_INDEX_FILE="${tmp_index_dir}/index" git add -A -- "${OUTSIDE_PATHSPEC[@]}"; then
rc=1
fi
if [[ "${rc}" -eq 0 ]]; then
wt_tree="$(GIT_INDEX_FILE="${tmp_index_dir}/index" git write-tree)" || rc=1
fi
rm -rf "${tmp_index_dir}"
idx_list_file=""
if [[ "${rc}" -eq 0 ]]; then
idx_list_file="$(mktemp)" || rc=1
fi
if [[ "${rc}" -eq 0 ]] && ! git ls-files -s -z -- "${OUTSIDE_PATHSPEC[@]}" > "${idx_list_file}"; then
rc=1
fi
if [[ "${rc}" -eq 0 ]]; then
idx_digest="$(git hash-object --stdin < "${idx_list_file}")" || rc=1
fi
[[ -z "${idx_list_file}" ]] || rm -f "${idx_list_file}"
if [[ "${rc}" -ne 0 ]]; then
echo "(outside-state-error:${BASHPID:-$$}:$(date +%s%N 2>/dev/null || echo "${RANDOM}${RANDOM}"))"
return 1
fi
echo "${wt_tree}:${idx_digest}"
}
if ! PRE_OUTSIDE="$(outside_state)"; then
echo "エラー: 契約範囲外の基準 digest(PRE_OUTSIDE)の取得に失敗しました。以後の範囲外書き込み検出ができないため同期を開始しません(fail-closed。checker は未実行です)。"
exit 1
fi
echo "PRE_OUTSIDE=${PRE_OUTSIDE} # 範囲外検証の基準 digest。Step 5.5 まで同一 shell で保持する(失われたら再設定に使う)"
# checker の「初回実行より前」に index snapshot を取得する。同期前 check(check mode)も
# 契約上、契約範囲(当該スキル / skills-lock.json / durable patch)を変更・stage し得るため、
# すべての失敗経路(pre-check 失敗・検証失敗・npx 失敗・Step 5.5 の apply / 最終 check 失敗)と
# Step 6 の却下で、この snapshot で契約範囲全体を同期開始前へ戻す
PRE_SYNC_TREE="$(git write-tree)"
echo "PRE_SYNC_TREE=${PRE_SYNC_TREE} # 失敗・却下時の契約範囲復元に使う snapshot hash。控えておくこと"
# 契約範囲を同期開始前へ戻す共通処理。部分変更(checker の stage 含む)を残して終了しない。
# 復元は git restore の pathspec で契約パスに限定し、範囲外 path の index・worktree には
# 一切触れない(index 全体を git read-tree で書き換えると、verify_outside_and_checker が
# 「範囲外は git restore --staged -- <path> で手動復旧」と案内した直後にその案内自体を
# 誤りにしてしまう)。git restore は no-overlay が既定のため、同期開始前 tree に無い
# tracked ファイルは契約パス内に限り index・worktree から取り除かれる。契約範囲内の
# 未追跡ファイルは git clean で即削除せず一時ディレクトリへ退避する(checker が契約
# ディレクトリ内へ移動・新規作成したファイルの唯一のコピーであり得るため、削除は
# データ喪失になる。退避先を案内し、削除の判断は人間へ委ねる)。退避自体に失敗した
# 場合は worktree 復元を行わず index のみへ降格して fail-closed で停止する(Issue #418)
restore_contract_scope() {
: "${PRE_SYNC_TREE:?同期開始前 snapshot が未設定のため復元できません}"
local restore_targets=(--staged --worktree) untracked_list moved=0 p
# 退避(mkdir -p / mv)自体の失敗を検査するためのフラグ。呼び出し文は全箇所
# `restore_contract_scope || true` の形であり、bash の仕様上 `||` の右辺・左辺の
# コマンド文脈で呼ばれた関数内では set -e が抑止される(この関数内の mkdir/mv の
# 失敗は自動では中断にならない)。検査なしに git restore --worktree へ進むと、
# checker が未追跡化・新規作成した「唯一のコピー」が退避されないまま
# PRE_SYNC_TREE の内容で無音に上書きされ、データ喪失になる(Issue #418)。退避対象を
# 1 件ずつ処理し、失敗があれば worktree 復元を行わず index のみへ降格する
# (revert_in_scope の非破壊モードと同じ判断)
local backup_failed=0 failed_paths=()
# Issue #422: backup_failed のうち「列挙自体(mktemp / git ls-files / mktemp -d)が
# 失敗し 1 件も退避していない」モードだけを区別するフラグ。個別ファイルの mv 失敗
# (enum_failed=0 のまま backup_failed=1 になる)とは異なり、この場合は「未追跡集合が
# 何か」自体が不明なため git clean は依然として行えないが、「worktree の現状自体は
# 壊れていない」ため revert_in_scope が cp 退避を挟んで tracked の checkout を
# 救済できる(revert_in_scope 側の CONTRACT_UNTRACKED_ENUM_FAILED 分岐参照)
local enum_failed=0
# npx 実行後検証が許可先経路・skills-lock.json の妥協(symlink 化等)を検出している場合、
# worktree への書き込み・未追跡退避のパス走査がリンク先(リポジトリ外を含む)へ向かい得る
# ため index のみ復元する(worktree 側は revert_in_scope が手動復旧を案内済み)。
# 妥協フラグは npx 実行後にのみ設定されるため、それ以前の失敗経路では既定 0 で参照する
if [[ "${SCOPE_PATH_COMPROMISED:-0}" -ne 0 || "${LOCK_FILE_COMPROMISED:-0}" -ne 0 ]]; then
restore_targets=(--staged)
echo "許可先経路または skills-lock.json の妥協を検出しているため、契約範囲の復元は index のみ行います。worktree 側は案内済みの手順で手動復旧してください。"
else
if ! untracked_list="$(mktemp)"; then
echo "エラー: 未追跡ファイル列挙用の一時ファイル作成に失敗しました。退避の完全性を確認できないため index のみ復元します。"
backup_failed=1
enum_failed=1
fi
# skills-lock.json も退避対象に含める。通常は tracked のため列挙されないが、checker が
# git rm --cached 等で未追跡化して内容変更した後に失敗すると、退避なしの git restore が
# その唯一の内容を上書きしてしまう
if [[ "${backup_failed}" -eq 0 ]] \
&& ! git ls-files -z --others --exclude-standard -- skills-lock.json ".agents/skills/${SKILL_NAME}/" scripts/local-patches/ > "${untracked_list}"; then
# 列挙自体が失敗した場合、退避の完全性を確認できないまま worktree 復元へ
# 進むと未列挙の未追跡ファイルが無音に上書きされ得るため、警告に留めず
# 失敗として扱う(revert_in_scope 導入時と同じ fail-closed 判断)
echo "エラー: 契約範囲内の未追跡ファイル列挙に失敗しました。退避の完全性を確認できないため index のみ復元します。"
backup_failed=1
enum_failed=1
fi
if [[ "${backup_failed}" -eq 0 ]]; then
if ! CONTRACT_UNTRACKED_BACKUP_DIR="$(mktemp -d)"; then
echo "エラー: 未追跡ファイルの退避先ディレクトリ作成に失敗しました。index のみ復元します。"
backup_failed=1
enum_failed=1
fi
fi
if [[ "${backup_failed}" -eq 0 ]]; then
while IFS= read -r -d '' p; do
if ! mkdir -p "${CONTRACT_UNTRACKED_BACKUP_DIR}/$(dirname "${p}")" \
|| ! mv -- "${p}" "${CONTRACT_UNTRACKED_BACKUP_DIR}/${p}"; then
# 退避に失敗したファイルはスキップし、残りのファイルの退避は継続する
# (保全できるコピーを最大化する)。1 件でも失敗すれば worktree 復元は
# 行わない
backup_failed=1
failed_paths+=("${p}")
continue
fi
moved=1
done < "${untracked_list}"
fi
rm -f "${untracked_list:-}"
if [[ "${moved}" -eq 1 ]]; then
echo "契約範囲内の未追跡ファイルは削除せず ${CONTRACT_UNTRACKED_BACKUP_DIR} に相対パス構造で退避しました。内容を確認し、不要なら手動で削除してください。"
elif [[ -n "${CONTRACT_UNTRACKED_BACKUP_DIR:-}" ]]; then
# backup_failed=1 かつ moved=0(退避先ディレクトリ作成には成功したが最初の
# ファイルの mkdir/mv で失敗した)場合も、空のまま残る退避先ディレクトリを掃除する。
# CONTRACT_UNTRACKED_BACKUP_DIR は mktemp -d 自体が失敗した経路(未追跡ファイル
# 列挙用の一時ファイル作成失敗・git ls-files 失敗・mktemp -d 失敗)では未設定の
# ままのため、set -u 下での unbound variable エラーを避けるため -n で存在確認
# してから参照する
rmdir "${CONTRACT_UNTRACKED_BACKUP_DIR}" 2>/dev/null || true
fi
if [[ "${backup_failed}" -eq 1 ]]; then
# 退避の完全性を確認できない以上、worktree への git restore は行わない
# (未退避の唯一のコピーを上書きするおそれがあるため)。index のみ復元へ降格する
restore_targets=(--staged)
echo "エラー: 契約範囲内の未追跡ファイルの退避に失敗しました。worktree の復元は行いません(fail-closed)。"
if [[ "${#failed_paths[@]}" -gt 0 ]]; then
echo "退避できなかったパス: ${failed_paths[*]}"
fi
if [[ "${moved}" -eq 1 ]]; then
echo "退避済み分は ${CONTRACT_UNTRACKED_BACKUP_DIR} に残しています。"
fi
echo "未退避のファイルを手動で退避してから、git restore --worktree --source=${PRE_SYNC_TREE} -- <path> で契約範囲の worktree を復旧してください。"
fi
fi
# revert_in_scope(この関数の直後に必ず呼ばれる)が backup_failed を検知できる
# よう、global へも反映する。backup_failed=1 のとき restore_targets は
# (--staged) へ降格して worktree には触れないが、`git restore --staged` 自体は
# tracked な契約パスの index を PRE_SYNC_TREE へ戻すため、この後 revert_in_scope
# が無条件に `git checkout -- <契約パス>` を実行すると、その index を worktree へ
# 書き戻して未退避の worktree 内容を上書きしてしまう(Bugbot 指摘: Index restore
# enables later overwrite)。revert_in_scope 側でこの global を見て checkout /
# clean を丸ごとスキップし、退避が失敗した worktree を意図的に未復元のまま残す。
CONTRACT_UNTRACKED_BACKUP_FAILED="${backup_failed}"
CONTRACT_UNTRACKED_ENUM_FAILED="${enum_failed}"
# 複数パスを 1 コマンドへ渡すと pathspec 不一致 1 件で全体が失敗するため 1 コマンド
# 1 パスで分離する。pathspec は index に対しても照合されるため、PRE_SYNC_TREE 取得後に
# 新規作成・stage されたファイルは tree に無くても no-overlay で index(・worktree)から
# 取り除かれる(実測済み)。pathspec 不一致(tree にも index にも無い)は復元対象なしを
# 意味するため許容するが、真の失敗(権限エラー等)と区別が付かないため、成功可否は
# コマンドの終了コードではなく下の復元後検証で fail-closed に判定する
git restore "${restore_targets[@]}" --source="${PRE_SYNC_TREE}" -- skills-lock.json 2>/dev/null || true
git restore "${restore_targets[@]}" --source="${PRE_SYNC_TREE}" -- ".agents/skills/${SKILL_NAME}/" 2>/dev/null || true
git restore "${restore_targets[@]}" --source="${PRE_SYNC_TREE}" -- scripts/local-patches/ 2>/dev/null || true
# 復元後検証(fail-closed): 契約パスの index が PRE_SYNC_TREE と一致し、worktree 復元
# モードでは worktree 側も PRE_SYNC_TREE と一致し未追跡も残っていない(未追跡は上で
# 退避済み)ことを実測してから成功を表示する。pathspec miss や restore の失敗を
# 「復元対象なし」として成功扱いすると、新規 staged ファイルの残留(Step 7 の git add
# への混入経路)を見逃すため。worktree 側の判定基準は HEAD ではなく PRE_SYNC_TREE:
# HEAD 基準の git status --porcelain を使うと、同期前から存在した正当な staged 変更
# (scripts/local-patches/ で許容している「staged のみの変更」)が復元完了後も常に
# 非空として現れ、PRE_SYNC_TREE と完全一致した正しい復元を誤報してしまう
local verify_ok=1
if [[ "${backup_failed}" -eq 1 ]]; then
# 退避に失敗している以上、index 側が PRE_SYNC_TREE と一致していても
# 「復元完了」ではない(worktree は意図的に未復元のまま残しているため)。
# 無条件の成功メッセージを出さず、常に fail-closed の非ゼロで終了する
verify_ok=0
elif ! git diff --cached --quiet "${PRE_SYNC_TREE}" -- skills-lock.json ".agents/skills/${SKILL_NAME}/" scripts/local-patches/; then
verify_ok=0
elif [[ "${restore_targets[*]}" == *--worktree* ]]; then
if ! git diff --quiet "${PRE_SYNC_TREE}" -- skills-lock.json ".agents/skills/${SKILL_NAME}/" scripts/local-patches/ \
|| [[ -n "$(git ls-files --others --exclude-standard -- skills-lock.json ".agents/skills/${SKILL_NAME}/" scripts/local-patches/)" ]]; then
verify_ok=0
fi
fi
if [[ "${verify_ok}" -eq 1 ]]; then
echo "契約範囲を同期開始前(PRE_SYNC_TREE=${PRE_SYNC_TREE})へ復元しました(範囲外 path の index・worktree には触れていません)。"
elif [[ "${backup_failed}" -eq 1 ]]; then
echo "エラー: 未追跡ファイルの退避に失敗したため、契約範囲の復元は index のみで停止しました(worktree は未復元。fail-closed)。"
# 呼び出し元が失敗を検知できるよう非ゼロを返す(Bugbot Medium 指摘: 従来は
# echo するだけで成功扱いのまま返っていたため、呼び出し元が fail-closed に
# 分岐できなかった)。呼び出し文は `restore_contract_scope || true` の形で
# この非ゼロを吸収し、set -e/pipefail の下で本関数の呼び出し文自体が
# 呼び出し元の revert_in_scope・案内 echo をスキップして異常終了する
# (cleanup が一部欠落する回帰)ことを防ぐ。継続後の可否判定が必要な
# 呼び出し元(Step 4 npx 失敗経路)は戻り値を明示的に変数へ拾う。
return 1
else
echo "エラー: 契約範囲の復元後検証で差分が残っています。git diff --cached ${PRE_SYNC_TREE} -- <契約パス> / git diff ${PRE_SYNC_TREE} -- <契約パス> / git ls-files --others -- <契約パス> で残留を確認し、git restore --staged --worktree --source=${PRE_SYNC_TREE} -- <path> で手動復旧してください(fail-closed。復元完了とは扱いません)。"
return 1
fi
}
# checker の「すべての」実行(同期前 check / Step 5.5 の apply・最終 check)の直後に、
# 実行結果に関わらず範囲外 digest と checker 自身の blob hash を再検証する。範囲外を
# 書き換えて非 0 終了するケース・checker 自身を未承認コードへ置換するケースを、次の
# 実行より前に fail-closed で検出するため
verify_outside_and_checker() {
if [[ -L scripts/check-skill-local-patches.sh ]] \
|| [[ "$(git hash-object -- scripts/check-skill-local-patches.sh 2>/dev/null || echo missing)" != "${CHECKER_APPROVED_HASH}" ]]; then
echo "エラー: checker 自身が書き換えられました(symlink 化を含む)。未承認の状態のため以後実行しません(fail-closed)。"
return 1
fi
# outside_state の失敗は「変化なしと確認できない」であって「変化なし」ではない
# ため、戻り値を明示的に検査する([[ ]] の条件式内で `$(outside_state)` を直接
# 使うと、内部コマンドの失敗による非ゼロ終了が握り潰され空文字列同士の比較に
# 落ちてしまう。関数自体が一意なエラーマーカーを返す設計にしてあるためこの
# 比較でも安全側に倒れるが、ここでは戻り値も明示的に見て二重に fail-closed を
# 担保する)。
local outside_now
if ! outside_now="$(outside_state)"; then
echo "エラー: 契約範囲外の digest 取得に失敗しました。変化なしと確認できないため、範囲外書き込みありとして扱います(fail-closed)。"
return 1
fi
if [[ "${outside_now}" != "${PRE_OUTSIDE}" ]]; then
echo "エラー: checker が契約範囲外の path を変更しました(fail-closed)。"
echo "git status --porcelain で範囲外の変更を特定し、tracked は git restore -- <path> / index は git restore --staged -- <path> で手動復旧してください(契約範囲用の却下手順では範囲外は戻りません)。checker 側の修正も必要です。"
return 1
fi
return 0
}
echo "==> 同期前の local patch 検証(check)"
pre_check_rc=0
# 実行対象は worktree のファイルではなく、承認済み blob を毎回再検証してから取り出す
# CHECKER_EXEC(run_checker)
run_checker || pre_check_rc=$?
if [[ "${CHECKER_RUN_VERIFY_FAILED}" -eq 1 ]]; then
restore_contract_scope || true
echo "(npx は実行していません)"
exit 1
fi
if ! verify_outside_and_checker; then
restore_contract_scope || true
echo "(npx は実行していません)"
exit 1
fi
if [[ "${pre_check_rc}" -ne 0 ]]; then
restore_contract_scope || true
echo "エラー: 同期前の local patch 検証に失敗しました。修復してから再実行してください(fail-closed。npx は実行していません)。"
exit 1
fi
elif [[ -f .agents/skills/LOCAL-PATCHES.md ]]; then
echo "エラー: .agents/skills/LOCAL-PATCHES.md があるのに scripts/check-skill-local-patches.sh がありません。local patch を検証できないため同期しません(fail-closed)。"
exit 1
fi
# skills CLI (vercel-labs/skills) は固定版でのみ実行する(未固定 npx はレジストリ
# 最新版の無検証即時実行になり、差分確認・承認より前に走る supply chain 経路になる)。
# 1つ目の --yes は npx 自体のインストール確認プロンプトのスキップ、末尾の --yes は
# skills CLI へ渡す確認プロンプトのスキップで、別物(位置で区別される)。
SKILLS_CLI_VERSION="1.5.22" # scripts/skills-lock-update.sh と同一値。更新手順は下記節を参照
# 作業ツリーの状態シグネチャ(種別 + パーミッション + 内容)を1行で返す。
# 第1引数のパスを起点に、通常ファイルは内容の sha256、シンボリックリンクは
# リンク先文字列の sha256(リンク先の解決はしない)、ディレクトリ・gitlink は
# 自身の mode に加えて配下全エントリ(サブディレクトリの mode・ディレクトリ向け
# symlink のリンク先と mode・ファイルの mode と内容ハッシュ)をバイト列ソートで
# 決定的に再帰集約した sha256 を返す。存在しないパスは "MISSING"(それ自体が
# 1つの状態であり、エラーではない)。第2引数以降で走査の除外を指定できる:
# prune:<rel> — 起点からの相対パス <rel> をエントリごと走査から除外する。
# パス区切りをまたがない per-segment glob(fnmatch)を使える
# (例: .git/MERGE_* は .git 直下にのみ一致し .git/hooks/ 配下の
# 同名ファイルには一致しない)。npx が書き換えてよいスコープ内と、
# .git のうち通常の git 操作で変動し得る領域の限定除外に使う
# prune-under:<rel> — <rel> 自身のメタデータ(種別・mode・symlink のリンク先)は
# 記録するが、配下へは降下せず記録もしない(完全一致のみで glob
# 不可)。既存の許可先ディレクトリに使う: 配下(npx の正当な書き込み
# 先)は除外しつつ、要素自身のディレクトリ→symlink 置換・chmod は
# 前後シグネチャの不一致として検出する(PR #412 P0 指摘: エントリ
# ごと prune すると npx 実行中の symlink 置換が署名に現れない)
# omit:<rel> — <rel> 自身のメタデータ(存在・mode)は記録しないが配下は走査する
# (実行前に存在しなかった親ディレクトリを npx が正当に新規作成する
# ケースの許容に使う。完全一致のみで glob 不可)
#
# PR #412 の P1 指摘群(porcelain に現れない状態変化の見逃し: 配下ファイルの
# 内容上書き・ディレクトリと dirlink の変更・ディレクトリの chmod)は、いずれも
# 「git status に現れたパスだけを個別にシグネチャ化する」構造に起因する同一クラス。
# git はディレクトリの mode を追跡しないため、スコープ外ディレクトリの chmod は
# status の前後どちらにも現れず、status 由来のパス集合をどれだけ精緻にハッシュ
# しても原理的に検出できない。そのためこのシグネチャは status 由来のパスではなく
# リポジトリルート全体(スコープ内と、.git のうち git 操作で変動し得る領域のみ
# 除外。.git/config・.git/hooks/ 等の永続メタデータは署名対象)へ適用する。
# .gitignore 対象の
# ファイルも同じ理由(porcelain に現れない)で走査対象に含める。対象は skills
# 配布リポジトリで作業ツリーが小さく、全走査 + 全ハッシュを前後 2 回行っても
# 実用上問題ない。python3 は主要 Linux / macOS に標準搭載されている。stat
# コマンドの出力書式は環境(BSD/GNU)で異なるため、シェルの `stat` は使わず
# python3 の os.lstat に統一する。取得エラー(lstat・open・走査失敗)は
# 「読めなかっただけ」を「変化なし」と誤認する fail-open 経路になるため、
# 握り潰さず即座に非ゼロ終了して呼び出し側で fail-closed に扱う。
path_state() {
local path="$1"
shift
python3 - "${path}" "$@" <<'PYEOF'
import fnmatch, hashlib, os, stat, sys
path = sys.argv[1]
# 除外指定(prune: 走査ごと除外 / prune-under: 自身は記録し配下のみ除外 /
# omit: 自身のメタデータのみ不記録)を解釈する。
prunes = []
prune_unders = set()
omits = set()
for spec in sys.argv[2:]:
label, _, rel = spec.partition(":")
if label == "prune" and rel:
prunes.append(rel)
elif label == "prune-under" and rel:
prune_unders.add(rel)
elif label == "omit" and rel:
omits.add(rel)
else:
print(f"path_state: 不正な除外指定: {spec}", file=sys.stderr)
sys.exit(1)
def pruned(rel):
# prune はパス区切りをまたがない per-segment glob で照合する。素の fnmatch は
# `*` が `/` もまたいで一致するため、`.git/MERGE_*` のような浅い階層向けの
# パターンが `.git/hooks/` 配下の同名ファイル(署名対象へ残したい深い階層)
# まで巻き込んでしまう。セグメント数の一致を要求してから各セグメントを個別に
# 照合し、除外が意図した深さの外へ広がらないようにする。
segs = rel.split(os.sep)
for pat in prunes:
pat_segs = pat.split("/")
if len(pat_segs) == len(segs) and all(
fnmatch.fnmatchcase(s, p) for s, p in zip(segs, pat_segs)
):
return True
return False
def fail(err):
# 部分的なシグネチャを出力したまま正常終了すると、呼び出し側が欠損に気付けない。
# ファイル内容は出力せず(秘密情報混入防止)、エラー要因のみ stderr へ出して
# 非ゼロ終了する。
print(f"path_state: 状態取得に失敗: {err}", file=sys.stderr)
sys.exit(1)
def file_hash(p):
fh = hashlib.sha256()
with open(p, "rb") as f:
while True:
chunk = f.read(1 << 20)
if not chunk:
break
fh.update(chunk)
return fh.hexdigest()
try:
st = os.lstat(path)
except FileNotFoundError:
print("MISSING")
sys.exit(0)
except OSError as e:
fail(e)
mode = oct(stat.S_IMODE(st.st_mode))
kind = stat.S_IFMT(st.st_mode)
h = hashlib.sha256()
try:
if stat.S_ISLNK(st.st_mode):
h.update(os.readlink(path).encode("utf-8", "surrogateescape"))
elif stat.S_ISREG(st.st_mode):
h.update(file_hash(path).encode("ascii"))
else:
# ディレクトリ・gitlink 等。配下の各エントリを相対パス・パーミッション・
# 内容(種別に応じたハッシュ)でソートして正規化し、走査順に依存せず
# 決定的な signature にする。os.walk は既定(followlinks=False)で
# symlink の指す先へは降りないため、ディレクトリ向け symlink は dirnames
# として自身(リンク先文字列・mode)だけを記録し、リンク先ディレクトリの
# 中身が二重に取り込まれることはない。
entries = []
for dirpath, dirnames, filenames in os.walk(path, onerror=fail):
kept = []
for dname in sorted(dirnames):
# dirnames 自体(配下ディレクトリ・ディレクトリ向け symlink)を
# lstat して entries へ含める。os.walk は filenames 経由で列挙
# しないため、ここで記録しないと配下ディレクトリの mode 変更や
# ディレクトリ向け symlink のリンク先・mode 変更がシグネチャに
# 反映されない(PR #412 P1 指摘)。
dp = os.path.join(dirpath, dname)
rel = os.path.relpath(dp, path)
if pruned(rel):
continue
# prune-under は自身のエントリ(下の記録処理)は残しつつ降下だけを
# 止める(kept へ入れない = os.walk がこの配下へ降りない)。
if rel not in prune_unders:
kept.append(dname)
if rel in omits:
continue
dst = os.lstat(dp)
dmode = oct(stat.S_IMODE(dst.st_mode))
if stat.S_ISLNK(dst.st_mode):
target_hash = hashlib.sha256(
os.readlink(dp).encode("utf-8", "surrogateescape")
).hexdigest()
entries.append(f"{rel}:{dmode}:dirlink:{target_hash}")
else:
entries.append(f"{rel}:{dmode}:dir")
# prune した名前を降下対象からも外す(os.walk は dirnames の
# in-place 更新で走査対象を制御する仕様)。
dirnames[:] = kept
for name in sorted(filenames):
p = os.path.join(dirpath, name)
rel = os.path.relpath(p, path)
if pruned(rel) or rel in omits:
continue
fst = os.lstat(p)
fmode = oct(stat.S_IMODE(fst.st_mode))
if stat.S_ISLNK(fst.st_mode):
target_hash = hashlib.sha256(
os.readlink(p).encode("utf-8", "surrogateescape")
).hexdigest()
entries.append(f"{rel}:{fmode}:link:{target_hash}")
elif stat.S_ISREG(fst.st_mode):
entries.append(f"{rel}:{fmode}:reg:{file_hash(p)}")
else:
# デバイスファイル等の特殊な種別は内容ハッシュが定義できない
# ため種別・mode のみ記録する。
entries.append(f"{rel}:{fmode}:other")
entries.sort()
for entry in entries:
h.update(entry.encode("utf-8", "surrogateescape"))
h.update(b"\n")
except OSError as e:
fail(e)
print(f"{kind}:{mode}:{h.hexdigest()}")
PYEOF
}
# リポジトリルート全体の状態シグネチャ(スコープ外書き込み検出の実体)。
# 除外は次の 3 種のみ:
# - スコープ内 — skills-lock.json / .agents/skills/${SKILL_NAME}(npx の正当な書き込み先)。
# ただし許可先ディレクトリ自身の除外方法は SKILL_DIR_SIG_SPEC(npx 実行前に一度
# だけ確定)で切り替える: 既存なら prune-under(配下のみ除外・要素自身の種別・
# mode・symlink 先は署名)にして、npx が実行中に許可先を外向き symlink へ置換して
# リンク先へ書く TOCTOU を前後シグネチャ不一致として検出する(PR #412 P0 指摘。
# 事前の lstat 検査は開始時点しか見ない)。実行前に不存在(初回インストール)の
# 場合のみ prune(エントリごと除外)にして正当な新規作成を誤検知にしない — この
# 場合の symlink 置換・symlink としての新規作成は、npx 実行後の許可先経路
# 再検証(verify_scope_path_after_run)が fail-closed で拒否する
# - .git のうち、このフロー自身が前後スナップショット間に実行する git コマンドで
# 変動し得る領域のみ — 前後シグネチャの間に走る git 操作は
# `git status --porcelain -z -uall`(実行後スナップショット取得)だけであり、
# status が触るのは index の stat cache 更新(.git/index)とその一時 lock
# (.git/index.lock)のみ。リバート用の git checkout / git clean は
# 実行後シグネチャ取得より後の失敗経路でしか呼ばれないため、署名比較に影響しない。
# 以前は objects・refs・packed-refs・HEAD・logs・worktrees 等も prune していたが、
# npx がこれらへ書き込む(履歴・参照の改変)とスコープ外検査を丸ごと迂回できて
# しまうため(PR #412 P0 指摘)、実測で避けられない index・index.lock 以外は
# すべて署名対象に含める。lock の prune を `.git/*.lock` のワイルドカードに
# すると、npx が残した永続 lock(.git/config.lock・.git/HEAD.lock 等)まで
# 検査から漏れるため(PR #412 codex P1 指摘)、自プロセスの git status が
# 作り得る .git/index.lock だけを完全一致で prune する。
# prune した .git/index の背後で npx が index の論理状態を改変するケースは、
# index_state_signature(下記)の前後比較が受け持つ。
# 代償として、同期実行中にこのリポジトリで並行 git 操作
# (他 worktree 含む)を行うとシグネチャ不一致(誤検知)として停止し得る
# (注意事項に明記。fail-closed 側に倒す設計判断)
# - REPO_SIG_OMITS — 実行前に存在しなかった場合の .agents / .agents/skills
# (npx 実行前スナップショットの直前に一度だけ確定する)。既存なら omit せず
# 種別・mode・symlink 先を通常どおり署名するため、既存親ディレクトリの chmod や
# ディレクトリ→symlink 置換は検出される(PR #412 P1 指摘)。不存在だった場合
# のみ omit し、初回インストールで npx が親ディレクトリを正当に新規作成する
# ケースを誤検知にしない。omit でも配下の走査は継続するため、同居する他スキルの
# ツリー(スコープ外)は引き続き保護される
#
# REPO_SIG_OMITS は前後 2 回の呼び出しで同一でなければならない(実行後の存在有無で
# 再判定すると、初回インストールの正当な新規作成が前後不一致=誤検知になる)。
# 空配列の "${arr[@]}" 展開は bash 3.2 の set -u で unbound になるため
# ${arr[@]+...} 形式で参照する。
REPO_SIG_OMITS=()
# 既定は prune-under(既存許可先向け)。初回インストール(実行前に不存在)の場合のみ
# npx 実行前の判定ブロックで prune へ切り替える。前後 2 回の呼び出しで同一で
# なければならない(REPO_SIG_OMITS と同じ理由)。
SKILL_DIR_SIG_SPEC="prune-under:.agents/skills/${SKILL_NAME}"
# index の論理状態のシグネチャ。.git/index はファイルとしては prune せざるを得ない
# (このフロー自身の git status が stat cache を正当に更新するため)が、その背後で
# npx がエントリの追加・削除・blob 差し替えや skip-worktree / assume-unchanged
# ビットの付与を行っても検出できなくなる(PR #412 codex P1 指摘。特に skip-worktree
# を立てられると、以後その tracked ファイルの変更が git status から恒久的に隠れる)。
# stat cache と独立な論理状態 — `git ls-files --stage`(mode・object・stage・パス)と
# `git ls-files -v`(状態タグ。skip-worktree は S、assume-unchanged は小文字)— を
# sha256 へまとめ、repo_state_signature の出力へ連結して前後比較する。git status の
# stat cache 更新はどちらの出力も変えないため、このフロー自身に起因する誤検知はない。
# 取得失敗は「変化なしと確認できない」ため非ゼロで返し、呼び出し側の fail-closed
# (sentinel 比較)へ倒す。
index_state_signature() {
local staged tags digest
staged="$(git ls-files --stage)" || return 1
tags="$(git ls-files -v)" || return 1
digest="$(printf '%s\n--\n%s\n' "${staged}" "${tags}" \
| python3 -c 'import hashlib, sys; print(hashlib.sha256(sys.stdin.buffer.read()).hexdigest())')" || return 1
printf '%s\n' "${digest}"
}
repo_state_signature() {
local tree_sig index_sig
tree_sig="$(path_state . \
"prune:skills-lock.json" \
"${SKILL_DIR_SIG_SPEC}" \
"prune:.git/index" \
"prune:.git/index.lock" \
${REPO_SIG_OMITS[@]+"${REPO_SIG_OMITS[@]}"})" || return 1
index_sig="$(index_state_signature)" || return 1
printf '%s:index:%s\n' "${tree_sig}" "${index_sig}"
}
# git status --porcelain -z の1レコード("XY PATH\0")からスコープ内
# (skills-lock.json / .agents/skills/${SKILL_NAME}/ 配下)を除いたレコードだけを
# outfile へ NUL 区切りで書き出す。検出の主体はリポジトリ全体の状態シグネチャ
# (repo_state_signature)であり、このレコード列は status レベルの前後比較と、
# 検出時の報告(どのパスが git status 上で変化したか)に使う。ディレクトリの
# chmod 等 status に現れない変化はこの一覧に載らず、シグネチャ不一致としてのみ
# 検出される。固定長プレフィックス(ステータス2文字+空白1文字=3文字)を
# 切り落としてパスを取り出すため、C-quote(改行等を含むパスのダブルクォート化)の
# 影響を受けない(-z 出力は raw byte のパスであり、path をそのままファイルアクセス
# に使ってよい)。
filter_out_of_scope() {
local infile="$1" outfile="$2" record path
: > "${outfile}"
while IFS= read -r -d '' record; do
path="${record:3}"
if [[ "${path}" == "skills-lock.json" || "${path}" == ".agents/skills/${SKILL_NAME}/"* ]]; then
continue
fi
printf '%s\0' "${record}" >> "${outfile}"
done < "${infile}"
}
# npx が新規作成した .gitignore 対象ファイルのみを許可先配下から削除する
# (revert_in_scope の補助)。`git clean -fdx` は npx 実行前から許可先配下に存在した
# ignored ファイル(.DS_Store 等)まで削除してしまうため使わない(PR #412 Bugbot
# Medium 指摘)。実行前インベントリ(SCOPE_INVENTORY_FILE。find -print0 の NUL 区切り
# 全ファイル一覧)に存在しないパスに限り、許可先配下であることを再検証したうえで
# 個別削除する。実行前から存在した ignored ファイルは内容が書き換えられていても
# 削除しない(保全。変更・削除の検出と復元は restore_preexisting_ignored が
# 実行前バックアップとの比較で行う)。パス名に
# スペース・改行を含み得る前提で、一覧の受け渡しは全経路 NUL 区切りで行う。
# npx が新規作成した「ignored ファイルのみを含む空ディレクトリ」は best-effort で
# 残り得るが、ファイル残置と異なり後続処理の誤認を生まないため許容する。
# ls-files の出力は pipe ではなく一時ファイルで python3 へ渡す(ヒアドキュメントで
# プログラムを与える python3 は stdin をヒアドキュメントに占有されるため、pipe との
# 併用ができない — 併用すると読み手のいない pipe への書き込みで SIGPIPE になる)。
remove_new_ignored_in_scope() {
local ignored_list rc=0
ignored_list="$(mktemp)" || return 1
if git ls-files -z --others --ignored --exclude-standard -- ".agents/skills/${SKILL_NAME}/" > "${ignored_list}" 2>/dev/null; then
SKILL_DIR=".agents/skills/${SKILL_NAME}" INVENTORY_FILE="${SCOPE_INVENTORY_FILE}" IGNORED_LIST_FILE="${ignored_list}" python3 - <<'PYEOF' || rc=1
import os
skill_dir = os.environ["SKILL_DIR"]
prefix = skill_dir + "/"
with open(os.environ["INVENTORY_FILE"], "rb") as f:
inventory = {p for p in f.read().split(b"\0") if p}
with open(os.environ["IGNORED_LIST_FILE"], "rb") as f:
ignored_paths = [p for p in f.read().split(b"\0") if p]
for raw in ignored_paths:
text = raw.decode("utf-8", "surrogateescape")
# 削除は kebab-case 検証済みの許可先配下に厳密に限定する(ls-files の出力を
# 信用しきらず、prefix 一致と `..` セグメント不在を自衛的に再検証する)。
if not text.startswith(prefix) or ".." in text.split("/"):
continue
if raw in inventory:
continue # 実行前から存在した ignored ファイルは保全する
try:
if os.path.isdir(text) and not os.path.islink(text):
continue # ディレクトリ自体は削除対象にしない(ファイル・symlink のみ)
os.unlink(text)
except FileNotFoundError:
pass
PYEOF
else
rc=1
fi
rm -f "${ignored_list}"
return "${rc}"
}
# 実行前から許可先配下に存在した ignored ファイル(.DS_Store 等)を、npx 実行前の
# バックアップ(IGNORED_BACKUP_DIR。相対パス構造・mode を保持)と比較し、変化
# (内容・mode・種別の変更、削除)があればバックアップから復元する。許可先配下は
# npx の正当な書き込み先だが、ignored ファイルは同期対象外であり npx が変更して
# よい理由がないため、変更を検出したら復元して警告する(処理自体は継続してよい
# 契約。復元の失敗のみ呼び出し側で非ゼロ終了へ倒す)。許可先配下は
# repo_state_signature の prune-under で署名から除外されるため、この変化は
# シグネチャ比較では検出できず、バックアップとの直接比較だけが検出手段になる。
# 事前の symlink 走査(npx 実行前)により対象は regular file のみである前提。
# 復元先が npx により symlink 化されている可能性に備え、書き込み前に unlink し、
# 親ディレクトリの realpath が許可先内に収まることを検証する(リンク先への
# 書き込み防止)。親ディレクトリの再作成は make_parent_dirs(存在する最深の
# 祖先の containment 検証 + 1 階層ずつの lstat 付き os.mkdir)で行い、
# os.makedirs が中間 symlink を辿ってリポジトリ外へディレクトリを作る経路を
# 残さない。バックアップとの比較・復元は mode 比較を含むため、stat の
# 出力書式差(BSD/GNU)を避けて python3 の os.lstat に統一する。
restore_preexisting_ignored() {
# バックアップ対象が無ければ(初回インストール・既存 ignored なし)何もしない。
[[ -s "${IGNORED_BASELINE_FILE}" ]] || return 0
SKILL_DIR=".agents/skills/${SKILL_NAME}" IGNORED_BACKUP_DIR="${IGNORED_BACKUP_DIR}" IGNORED_BASELINE_FILE="${IGNORED_BASELINE_FILE}" python3 - <<'PYEOF'
import os, shutil, stat, sys
skill_dir = os.environ["SKILL_DIR"]
prefix = skill_dir + "/"
backup_dir = os.environ["IGNORED_BACKUP_DIR"]
root = os.path.realpath(skill_dir)
def fail(msg):
# 復元に失敗したまま正常終了すると、破壊された既存 ignored ファイルが
# 「復元済み」として扱われてしまうため、必ず非ゼロ終了して呼び出し側で
# fail-closed(バックアップ dir の保全 + 手動復旧の案内)に扱わせる。
print(f"restore_preexisting_ignored: {msg}", file=sys.stderr)
sys.exit(1)
def make_parent_dirs(parent):
# os.makedirs を先に呼ぶと、中間ディレクトリが外向き symlink へ置換されて
# いた場合にリンク先(リポジトリ外)へディレクトリを作ってしまう — 作成
# 自体がスコープ逸脱であり、後段の containment チェックで停止しても外側の
# dir は残る(PR #412 Bugbot Medium 指摘)。そこで先に「存在する最深の祖先」
# の realpath が許可先の内側か、許可先へ至る素の経路上(root の祖先)にある
# ことを検証し、さらに作成は 1 階層ずつ lstat で symlink・非ディレクトリで
# ないことを確認しながら os.mkdir で行う(symlink を辿る経路を残さない。
# 実行後の配下 symlink 走査が先に停止させるため通常は到達しない防御多層)。
anc = parent
while anc and not os.path.lexists(anc):
anc = os.path.dirname(anc)
if anc:
real_anc = os.path.realpath(anc)
if not (
real_anc == root
or real_anc.startswith(root + os.sep)
or root.startswith(real_anc + os.sep)
):
fail(f"復元先の祖先が許可先の外を指しています: {parent}")
cur = ""
for part in parent.split(os.sep):
cur = os.path.join(cur, part) if cur else part
try:
cst = os.lstat(cur)
except FileNotFoundError:
os.mkdir(cur)
continue
if not stat.S_ISDIR(cst.st_mode):
fail(f"復元先の中間経路がディレクトリではありません(symlink 等): {cur}")
with open(os.environ["IGNORED_BASELINE_FILE"], "rb") as f:
paths = [p for p in f.read().split(b"\0") if p]
for raw in paths:
text = raw.decode("utf-8", "surrogateescape")
# 復元は kebab-case 検証済みの許可先配下に厳密に限定する(自前で列挙した
# 一覧でも、prefix 一致と `..` セグメント不在を自衛的に再検証する)。
if not text.startswith(prefix) or ".." in text.split("/"):
continue
backup = os.path.join(backup_dir, text)
try:
bst = os.lstat(backup)
changed = False
reason = ""
try:
cst = os.lstat(text)
if not stat.S_ISREG(cst.st_mode):
changed, reason = True, "種別が変化"
elif stat.S_IMODE(cst.st_mode) != stat.S_IMODE(bst.st_mode):
changed, reason = True, "mode が変化"
else:
with open(backup, "rb") as f1, open(text, "rb") as f2:
while True:
c1 = f1.read(1 << 20)
c2 = f2.read(1 << 20)
if c1 != c2:
changed, reason = True, "内容が変化"
break
if not c1:
break
except FileNotFoundError:
changed, reason = True, "削除されていた"
if not changed:
continue
parent = os.path.dirname(text)
if parent:
make_parent_dirs(parent)
real_parent = os.path.realpath(parent)
# 親ディレクトリが symlink 化されているとリンク先(リポジトリ外を
# 含む)へ書き込んでしまうため、realpath の包含を検証してから書く。
if real_parent != root and not real_parent.startswith(root + os.sep):
fail(f"復元先の親ディレクトリが許可先の外を指しています: {text}")
try:
# 復元先自体が symlink 化されていた場合にリンク先へ書かないよう、
# 既存エントリを外してから regular file として書き戻す。
os.unlink(text)
except FileNotFoundError:
pass
shutil.copyfile(backup, text)
os.chmod(text, stat.S_IMODE(bst.st_mode))
print(
f"警告: npx が実行前から存在した ignored ファイルを変更したため"
f"({reason})、バックアップから復元しました: {text}",
file=sys.stderr,
)
except OSError as e:
fail(f"{text} の復元に失敗: {e}")
PYEOF
}
# スコープ内(skills-lock.json / .agents/skills/${SKILL_NAME}/)の変更をリバートする。
# npx 失敗・スコープ外検出・実行後 git status 取得失敗のすべての異常終端経路が
# 共有する(PR #412 P1: どの異常経路でも生成済みのスコープ内変更を残置しない契約)。
# 2つのパスを1つの `git checkout --` に渡すとアトミックに扱われ、どちらか一方が
# 「追跡対象なし」(初回具現化・untracked のみの書き込み時)で pathspec エラーになると
# コマンド全体が失敗し、もう一方(skills-lock.json)も復元されないまま抜けてしまう
# ため、必ず1コマンド1パスで分離する。git clean はディレクトリが存在しない場合に
# 非ゼロ終了するため、存在確認してから呼ぶ(set -e 下で無条件に呼ぶと呼び出し元の
# 案内メッセージより先に停止し得る)。.gitignore 対象の残置
# (Issue #413)は -fdx ではなく remove_new_ignored_in_scope(実行前インベントリ
# との突き合わせ)で解消する(既存 ignored ファイルの巻き添え削除防止)。
# 実行後再検証(verify_scope_path_after_run)が許可先経路の symlink 化・非
# ディレクトリ化を検出した場合、または実行後の配下 symlink 走査が npx 実行中に
# 作られた symlink を検出した場合(いずれも SCOPE_PATH_COMPROMISED=1)は、
# checkout・clean・ignored 削除のパス走査がリンク先(リポジトリ外を含む)へ
# 向かい得るため、skills-lock.json の復元のみ行い、許可先配下への削除系操作は
# 一切行わず案内に留める(symlink を通じた外部削除の防止。fail-closed)。
# skills-lock.json 自体が npx 実行中に symlink へ置換されたケース
# (LOCK_FILE_COMPROMISED=1)では、素の git checkout がリンク先(リポジトリ外を
# 含む)へ書き込み得るため、先に rm で symlink 自体を除去(リンク先は辿らない)
# してから checkout で index の内容を regular file として書き戻す。symlink 以外の
# 想定外実体(ディレクトリ等)は rm -f で除去できず checkout も安全に働かないため、
# 一切触らず手動復旧を案内する。
# SKILL_NAME はループの現在値を呼び出し時に参照する。
# 第1引数(既定 0)を 1 にすると git clean -fd / remove_new_ignored_in_scope を
# 一切実行しない「非破壊モード」になる(codex P0 指摘: preview_untracked の
# git ls-files 失敗時など、未追跡集合を安全に列挙できなかった状態でこの関数を
# 呼ぶと、checker / npx が作成した「唯一のコピーであり得る」未追跡ファイルを
# バックアップなしで git clean -fd が削除してしまう。列挙が壊れている以上、
# 何を消してよいか判定できないため、削除系操作は一切行わず手動復旧の案内に
# 留める)。checkout(tracked のみが対象で未追跡ファイルには触れない)と
# restore_preexisting_ignored(npx 実行前バックアップからの復元。列挙ではなく
# 実行前に確定済みの一覧を使うため安全)はこのモードでも従来どおり実行する。
#
# Issue #422: 未追跡集合の「列挙自体」(git ls-files --others 等)が失敗し、1 件も
# 退避できていない(=何が未退避か分からない)モードに限り、checkout が上書きし得る
# worktree の現状を git 非依存の cp でまるごと保全してから tracked の checkout を
# 行えるようにするための下ごしらえ。壊れている git には一切頼らず、ファイルシステム
# 操作(cp -p / cp -RP)のみで skills-lock.json と .agents/skills/${SKILL_NAME}/ を
# mktemp -d 先へ複製する。呼び出し元(revert_in_scope)は、この関数が成功した場合に
# 限り checkout を実行し、失敗した場合は従来どおり checkout ごと丸ごとスキップする
# (fail-closed。cp が失敗した状態で checkout すると、退避されていない worktree
# 内容が無音に上書きされ得るため)。
contract_worktree_backup() {
local dir
if ! dir="$(mktemp -d)"; then
echo "エラー: worktree 保全用の退避先ディレクトリ作成に失敗しました。" >&2
return 1
fi
local ok=1
if [[ -e "skills-lock.json" ]]; then
if [[ -L "skills-lock.json" ]]; then
# symlink はリンク先(リポジトリ外を含む)を辿らないよう対象外にする
# (SCOPE_PATH_COMPROMISED / LOCK_FILE_COMPROMISED と同じ fail-closed 判断。
# 呼び出し元はこれらのフラグが 0 のときのみこの関数を呼ぶため通常到達しないが、
# 二重の防御として保持する)。
echo "エラー: skills-lock.json がシンボリックリンクのため保全できません。" >&2
ok=0
elif [[ -f "skills-lock.json" ]]; then
cp -p -- "skills-lock.json" "${dir}/skills-lock.json" || ok=0
fi
fi
if [[ "${ok}" -eq 1 && -e ".agents/skills/${SKILL_NAME}" ]]; then
if [[ -L ".agents/skills/${SKILL_NAME}" ]]; then
echo "エラー: .agents/skills/${SKILL_NAME}/ がシンボリックリンクのため保全できません。" >&2
ok=0
elif [[ -d ".agents/skills/${SKILL_NAME}" ]]; then
# -RP: シンボリックリンクを辿らずリンク自体を複製する(配下に symlink が
# 混在していてもリンク先へは書き込み・読み取りしない)
if ! mkdir -p "${dir}/.agents/skills"; then
ok=0
elif ! cp -RP -- ".agents/skills/${SKILL_NAME}" "${dir}/.agents/skills/${SKILL_NAME}"; then
ok=0
fi
fi
fi
if [[ "${ok}" -eq 1 ]]; then
CONTRACT_WORKTREE_BACKUP_DIR="${dir}"
return 0
fi
# 部分コピーが残っている場合はデータ保全を優先し、削除せず退避先を保全する。
# 何もコピーされていない(空のまま)場合のみ退避先ディレクトリを掃除する。
if [[ -z "$(find "${dir}" -mindepth 1 -print -quit 2>/dev/null)" ]]; then
rmdir "${dir}" 2>/dev/null || true
else
echo "エラー: worktree の保全が一部失敗しましたが、退避できた分は ${dir} に残しています。" >&2
fi
return 1
}
revert_in_scope() {
local skip_clean="${1:-0}"
# 呼び出し元が「実際に checkout / clean が走ったか」を判定できるよう、この
# 呼び出し内で保全のため checkout・clean をスキップした場合のみ 1 を立てる
# (Bugbot Medium 指摘・Issue #418 系: post-npx の呼び出し元がこの値を見ずに
# 「スコープ内の変更はリバートしました」と無条件表示すると、worktree に未退避の
# 変更が残っているのに復元済みと誤認させる。codex P1 指摘・PR #420: 個別の原因
# フラグの寄せ集めで表示分岐すると SCOPE_PATH_COMPROMISED 経路のような変種を
# 取りこぼすため、スキップの有無はこの単一フラグへ一元化する。呼び出しごとに
# 0 へ戻し、checkout / clean / 復元のいずれかをスキップした経路で必ず 1 にする)。
REVERT_IN_SCOPE_SKIPPED=0
if [[ "${CONTRACT_UNTRACKED_BACKUP_FAILED:-0}" -eq 1 ]]; then
# restore_contract_scope が契約範囲内の未追跡ファイル退避に失敗し、fail-closed で
# index のみ復元(worktree は意図的に未復元)へ降格した直後の呼び出し。ここで
# 通常どおり `git checkout -- <契約パス>` を実行すると、その index(すでに
# PRE_SYNC_TREE へ戻されている)を worktree へ書き戻し、restore_contract_scope が
# 守ろうとした未退避の worktree 内容(checker / npx が作成した唯一のコピーで
# あり得る)を無音に上書きしてしまう(Bugbot 指摘: Index restore enables later
# overwrite / Issue #418 の再発)。既存 ignored の復元だけは untracked backup の
# 失敗と無関係(別のバックアップ機構)のため、このブロックの外側で従来どおり実行する。
#
# Issue #422: ただし「未追跡集合の列挙自体」が失敗し 1 件も退避できていない
# モード(個別 mv 失敗と異なり、何が未退避か分からないだけで worktree の現状
# 自体は破損していない)に限り、checkout 前に git 非依存の cp で worktree を
# まるごと保全できれば、tracked 分(skills-lock.json / .agents/skills 配下)の
# checkout は行ってよい。これにより checker が無いリポジトリ(preview_untracked
# ケース7)と同じ「tracked はリバートされる」挙動に揃える。妥協検出
# (SCOPE_PATH_COMPROMISED / LOCK_FILE_COMPROMISED)がある場合は、checkout 先の
# パス自体の信頼性が崩れているため、この救済経路には進まない。
if [[ "${CONTRACT_UNTRACKED_ENUM_FAILED:-0}" -eq 1 \
&& "${SCOPE_PATH_COMPROMISED:-0}" -eq 0 \
&& "${LOCK_FILE_COMPROMISED:-0}" -eq 0 ]] \
&& contract_worktree_backup; then
# 1 コマンド 1 パスで分離する理由は他の checkout 呼び出しと同じ
# (revert_in_scope 冒頭のコメント参照)。git clean は未追跡集合が依然
# 不明なままのため、この経路でも実行しない。
git checkout -- skills-lock.json 2>/dev/null || true
git checkout -- ".agents/skills/${SKILL_NAME}/" 2>/dev/null || true
echo "警告: 契約範囲内の未追跡ファイル列挙が失敗したため、checkout 前の worktree 内容を ${CONTRACT_WORKTREE_BACKUP_DIR} へ複製してから skills-lock.json / .agents/skills/${SKILL_NAME}/ の tracked 分のみ checkout しました(git clean は実行していません)。git status --porcelain -- skills-lock.json \".agents/skills/${SKILL_NAME}/\" で残留未追跡を確認し、必要なら手動で後始末してください(fail-closed。完全復元ではありません)。" >&2
else
echo "警告: 契約範囲内の未追跡ファイル退避が失敗したため、skills-lock.json / .agents/skills/${SKILL_NAME}/ の checkout・git clean は行いません(index のみ復元済みの worktree を上書きしないための保全)。git status --porcelain -- skills-lock.json \".agents/skills/${SKILL_NAME}/\" で内容を確認し、必要なら手動で復旧してください(fail-closed)。" >&2
fi
REVERT_IN_SCOPE_SKIPPED=1
else
if [[ "${LOCK_FILE_COMPROMISED}" -ne 0 ]]; then
if [[ -L "skills-lock.json" ]]; then
rm -f skills-lock.json
git checkout -- skills-lock.json 2>/dev/null || true
elif [[ -e "skills-lock.json" && ! -f "skills-lock.json" ]]; then
echo "警告: skills-lock.json が想定外の実体(ディレクトリ等)に置換されているため自動復元しません。実体を確認・除去してから git checkout -- skills-lock.json で復元してください。" >&2
# skills-lock.json の復元をスキップしたため完全復元ではない
REVERT_IN_SCOPE_SKIPPED=1
else
git checkout -- skills-lock.json 2>/dev/null || true
fi
else
git checkout -- skills-lock.json 2>/dev/null || true
fi
if [[ "${SCOPE_PATH_COMPROMISED}" -ne 0 ]]; then
# symlink 越しの走査を避けるため既存 ignored の復元も行わない。復元素材を
# 失わないよう、バックアップ dir は削除せず保全して手動復旧に委ねる。
IGNORED_BACKUP_KEEP=1
echo "警告: 許可先経路またはその配下が symlink 等へ置換・作成されているため、.agents/skills/${SKILL_NAME}/ への checkout・git clean・ignored 削除・既存 ignored の復元は行いません(リンク先への削除・書き込みを避けるため)。symlink の指す先と許可先の内容を手動確認し、symlink を除去してから復旧してください。既存 ignored ファイルのバックアップは ${IGNORED_BACKUP_DIR} に相対パス構造で残っています。" >&2
# codex P1 指摘(PR #420): この経路は CONTRACT_UNTRACKED_BACKUP_FAILED が
# 0 のまま checkout・clean をスキップして return するため、ここでも必ず立てる
REVERT_IN_SCOPE_SKIPPED=1
return 0
fi
git checkout -- ".agents/skills/${SKILL_NAME}/" 2>/dev/null || true
if [[ "${skip_clean}" == "1" ]]; then
echo "警告: 未追跡ファイル一覧を安全に取得できなかったため、.agents/skills/${SKILL_NAME}/ 配下の git clean は実行していません(checker / npx が作成した未追跡ファイルを誤って削除しないための保全。データ喪失防止を優先)。npx / checker による書き込み・未追跡ファイルが残っている可能性があるため、次を手動で確認してください: git status --porcelain -- \".agents/skills/${SKILL_NAME}/\"(内容を確認したうえで不要なもののみ git clean -fd \".agents/skills/${SKILL_NAME}/\" で削除する)。" >&2
# git clean をスキップし未追跡が残り得るため完全復元ではない
REVERT_IN_SCOPE_SKIPPED=1
elif [[ -d ".agents/skills/${SKILL_NAME}/" ]]; then
git clean -fd -- ".agents/skills/${SKILL_NAME}/" || true
remove_new_ignored_in_scope || true
fi
fi
# 既存 ignored の変更・削除はここでバックアップから戻す(checkout / clean /
# remove_new_ignored_in_scope はいずれも既存 ignored に触れないため、この復元が
# 唯一の回復経路)。復元自体が失敗した場合はバックアップ dir を保全して案内し、
# IGNORED_RESTORE_FAILED で呼び出し元へ伝える(手動復旧を要するため、npx 失敗
# 経路でも skip(continue)せずループ全体を exit 1 で停止させる)。
if ! restore_preexisting_ignored; then
IGNORED_RESTORE_FAILED=1
IGNORED_BACKUP_KEEP=1
# codex P1 指摘(PR #420・3 巡目): 復元失敗もフラグ契約の「復元をスキップした
# (完全実行できなかった)経路」に含まれる。ここで立てないと status 取得失敗の
# 呼び出し元が、既存 ignored が未復元のまま「変更はリバートしました」と表示して
# しまう(IGNORED_RESTORE_FAILED はループ停止の判定用で、表示分岐はこの単一
# フラグに一元化する)
REVERT_IN_SCOPE_SKIPPED=1
echo "エラー: 実行前から存在した ignored ファイルの復元に失敗しました。バックアップは ${IGNORED_BACKUP_DIR} に相対パス構造で残っています。手動で復旧してください。" >&2
fi
}
# npx 実行後の許可先経路の再検証(PR #412 P0 指摘: TOCTOU)。事前の lstat 検査は
# 開始時点しか見ないため、npx が実行中に許可先(またはその親)を外向き symlink へ
# 置換した・初回インストールで .agents 自体を symlink として作成したケースは、
# 事後に全パス要素を再度 lstat しなければ検出できない(存在する要素はすべて実体の
# ディレクトリであることを要求し、symlink・非ディレクトリは fail-closed で拒否)。
# 併せて既存許可先は prune-under により要素自身が前後シグネチャへ署名されるため、
# 置換自体もシグネチャ不一致として検出される(この関数はその場合の原因特定と、
# 初回インストール(prune でシグネチャに現れない)の防御を担う)。
verify_scope_path_after_run() {
local component
for component in ".agents" ".agents/skills" ".agents/skills/${SKILL_NAME}"; do
if [[ -L "${component}" ]]; then
echo "エラー: npx 実行後の再検証で ${component} がシンボリックリンクになっています。npx が実行中に許可先を symlink へ置換し、リンク先(リポジトリ外を含む)へ書き込んだ可能性があります。リンク先の内容とリポジトリ外への書き込み有無を手動確認してください(fail-closed)。" >&2
return 1
fi
if [[ -e "${component}" && ! -d "${component}" ]]; then
echo "エラー: npx 実行後の再検証で ${component} がディレクトリではありません。npx の書き込み先として想定外の実体のため、手動確認が必要です(fail-closed)。" >&2
return 1
fi
done
return 0
}
# npx 実行後の skills-lock.json の実体再検証(PR #412 codex P0 指摘: TOCTOU)。
# skills-lock.json は署名の prune と porcelain フィルタの双方からパスで除外される
# ため、npx が実行中に外向き symlink へ置換してリンク先へ書き込んでもどの検査にも
# 現れない。実行後に lstat し、存在するのに regular file でなければ fail-closed で
# 拒否する(事前検証時の不存在→生成のケースも regular file であることを要求する。
# 実行後の不存在は改変ではなく npx の書き込み不発のため、ここでは不問とする)。
verify_lock_file_after_run() {
if [[ -L "skills-lock.json" ]]; then
echo "エラー: npx 実行後の再検証で skills-lock.json がシンボリックリンクになっています。npx が実行中に置換し、リンク先(リポジトリ外を含む)へ書き込んだ可能性があります。リンク先の内容とリポジトリ外への書き込み有無を手動確認してください(fail-closed)。" >&2
return 1
fi
if [[ -e "skills-lock.json" && ! -f "skills-lock.json" ]]; then
echo "エラー: npx 実行後の再検証で skills-lock.json が regular file ではありません。npx の書き込み先として想定外の実体のため、手動確認が必要です(fail-closed)。" >&2
return 1
fi
return 0
}
# npx 実行前後のスナップショット比較(スコープ外書き込みの多層防御)用の一時ファイル。
# -uall は新規ディレクトリの collapse(`?? .agents/` への丸め)を防ぐため必須。
# status.renames=false でユーザー環境の git config に依存せず rename 検出を無効化する。
SNAP_BEFORE="$(mktemp)"
SNAP_AFTER="$(mktemp)"
SNAP_FILTERED_BEFORE="$(mktemp)"
SNAP_FILTERED_AFTER="$(mktemp)"
NPX_OUTPUT_FILE="$(mktemp)"
SCOPE_INVENTORY_FILE="$(mktemp)"
IGNORED_BASELINE_FILE="$(mktemp)"
# 既存 ignored ファイルの実行前バックアップ先(相対パス構造・mode を保持)。
# 復元失敗時は IGNORED_BACKUP_KEEP=1 で削除を抑止し、手動復旧の素材として残す。
IGNORED_BACKUP_DIR="$(mktemp -d)"
IGNORED_BACKUP_KEEP=0
IGNORED_RESTORE_FAILED=0
# npx 実行後の許可先経路 再検証(verify_scope_path_after_run)と許可先配下の
# 実行後 symlink 走査の結果フラグ。1 のとき revert_in_scope は許可先配下への
# 削除系操作を行わない(symlink 越しのリポジトリ外削除の防止)。npx 実行直後に
# 一度だけ更新する。
SCOPE_PATH_COMPROMISED=0
# skills-lock.json の実行後 再検証(verify_lock_file_after_run)の結果フラグ。
# 1 のとき revert_in_scope は素の git checkout でリンク先へ書き込まないよう、
# symlink を rm で除去してから復元する。npx 実行直後に一度だけ更新する。
LOCK_FILE_COMPROMISED=0
# npx 実行前の許可先配下の全ファイルインベントリ(.gitignore 対象を含む。
# find -print0 の NUL 区切り)。revert_in_scope(remove_new_ignored_in_scope)が
# 「npx が新規作成した ignored ファイル」だけを選別削除するための基準になる
# (実行前から存在した ignored ファイルの巻き添え削除防止。PR #412 Bugbot Medium
# 指摘)。取得に失敗すると選別基準を失い、リバートが既存 ignored ファイルを誤削除
# し得るため fail-closed で中止する(この時点では npx 未実行のため残置なし)。
# 許可先が不存在(初回インストール)の場合は空インベントリ(npx が作るものはすべて
# 新規)とする。
if [[ -e ".agents/skills/${SKILL_NAME}" ]]; then
# 許可先配下の既存 symlink の事前拒否。repo_state_signature は prune-under で
# 許可先配下を署名から除外するため、配下に実行前から symlink があると、npx が
# リンクを辿ってリポジトリ外へ書き込んでも前後比較のどの検査にも現れない
# (ルート 3 要素の lstat 検証は経路自身しか見ず、配下は守れない)。1 件でも
# あれば npx 未実行のまま fail-closed で中止する。この拒否により、後段の既存
# ignored バックアップ・復元は対象を regular file のみと仮定できる。走査自体の
# 失敗も「symlink なしと確認できない」であって「なし」ではないため中止する。
# レイアウト自体の異常で人間の確認を要するため、skip(continue)ではなく
# ループ全体を exit 1 で停止する(事前 lstat 検査と同じ扱い。npx 未実行のため残置なし)。
if ! SCOPE_PREEXISTING_SYMLINKS="$(find ".agents/skills/${SKILL_NAME}" -type l)"; then
echo "エラー: 許可先配下の symlink 走査(find)に失敗しました。リポジトリ外への書き込み経路が無いことを確認できないため中止します(fail-closed)。" >&2
exit 1
fi
if [[ -n "${SCOPE_PREEXISTING_SYMLINKS}" ]]; then
echo "エラー: 許可先(.agents/skills/${SKILL_NAME})配下に既存のシンボリックリンクがあります。npx がリンクを辿ってリポジトリ外へ書き込んでも、配下を署名から除外している前後比較では検出できないため中止します(fail-closed)。以下を実体ファイルへ置き換えるか削除してから再実行してください:" >&2
printf '%s\n' "${SCOPE_PREEXISTING_SYMLINKS}" >&2
exit 1
fi
if ! find ".agents/skills/${SKILL_NAME}" -print0 > "${SCOPE_INVENTORY_FILE}"; then
echo "エラー: npx 実行前の許可先インベントリ取得(find)に失敗しました。リバート時に既存 ignored ファイルを誤削除しないための基準を確保できないため中止します。" >&2
exit 1
fi
# 既存 ignored ファイル(実行前時点)の列挙とバックアップ。実行前インベントリは
# パス一覧のみで内容を持たないため、npx が既存 ignored を上書き・削除しても
# そのままでは復元も検出もできない(許可先配下は署名から除外され、シグネチャ
# 比較にも現れない)。内容・mode を相対パス構造ごと退避し(cp -p)、実行後に
# restore_preexisting_ignored が比較・復元する。列挙・バックアップの失敗は
# 復元素材を確保できないため npx 未実行のまま中止する(fail-closed)。
if ! git ls-files -z --others --ignored --exclude-standard -- ".agents/skills/${SKILL_NAME}/" > "${IGNORED_BASELINE_FILE}"; then
echo "エラー: npx 実行前の既存 ignored ファイル列挙(git ls-files)に失敗しました。上書き・削除からの復元素材を確保できないため中止します(fail-closed)。" >&2
exit 1
fi
while IFS= read -r -d '' IGNORED_PATH; do
if ! mkdir -p "${IGNORED_BACKUP_DIR}/${IGNORED_PATH%/*}" \
|| ! cp -p -- "${IGNORED_PATH}" "${IGNORED_BACKUP_DIR}/${IGNORED_PATH}"; then
echo "エラー: 既存 ignored ファイルのバックアップに失敗しました: ${IGNORED_PATH} — 上書き・削除からの復元素材を確保できないため中止します(fail-closed)。" >&2
exit 1
fi
done < "${IGNORED_BASELINE_FILE}"
fi
if ! git -c status.renames=false status --porcelain -z -uall > "${SNAP_BEFORE}"; then
echo "エラー: npx 実行前の git status 取得に失敗しました。スコープ外書き込みの検出ができないため中止します。" >&2
exit 1
fi
filter_out_of_scope "${SNAP_BEFORE}" "${SNAP_FILTERED_BEFORE}"
# .agents / .agents/skills の omit(自身のメタデータ不記録)は「npx 実行前に存在
# したか」で決める。既存なら通常どおり署名対象にして chmod・ディレクトリ→symlink
# 置換を検出し(PR #412 P1 指摘)、不存在だった場合のみ omit して初回インストール
# での正当な親ディレクトリ新規作成を誤検知にしない。判定は npx 実行前のここで
# 一度だけ行い、前後の両シグネチャで同じ除外集合を使う(実行後に再判定すると
# 初回作成が前後不一致になる)。-e は壊れた symlink で偽になるため -L も併せて
# 見る(symlink 自体は「存在」として署名対象に含める)。
if [[ ! -e ".agents" && ! -L ".agents" ]]; then
REPO_SIG_OMITS+=("omit:.agents")
fi
if [[ ! -e ".agents/skills" && ! -L ".agents/skills" ]]; then
REPO_SIG_OMITS+=("omit:.agents/skills")
fi
# 許可先ディレクトリ自身も同じ基準で確定する: 既存なら prune-under(既定値)のまま
# 要素自身を署名し、npx 実行中のディレクトリ→symlink 置換(TOCTOU)を前後不一致で
# 検出する。実行前に不存在(初回インストール)の場合のみ prune へ切り替え、npx に
# よる正当な新規作成を誤検知にしない(このケースの symlink 化は実行後の
# verify_scope_path_after_run が拒否する)。
if [[ ! -e ".agents/skills/${SKILL_NAME}" && ! -L ".agents/skills/${SKILL_NAME}" ]]; then
SKILL_DIR_SIG_SPEC="prune:.agents/skills/${SKILL_NAME}"
fi
# リポジトリ全体の状態シグネチャは「その時点のディスク内容・モード」を読むため、
# 必ず npx を呼ぶ前にここで確定させる。npx 実行後に取得すると「変更前」のつもりが
# 実質「変更後」と一致してしまい、実行前から M・?? だったスコープ外ファイルの
# 内容・モードだけの上書きを見逃す。取得失敗時はスコープ外書き込みを検出できない
# ため fail-closed で中止する(この時点では npx 未実行のため残置なし)。
if ! REPO_STATE_BEFORE="$(repo_state_signature)"; then
echo "エラー: npx 実行前の状態シグネチャ取得に失敗しました。スコープ外書き込みの検出ができないため中止します。" >&2
exit 1
fi
# CLI に computedHash を更新させる。固定版が解決できない場合(該当版の不存在・
# レジストリ障害)は npx が非ゼロ終了する。その場合は当該スキルを中止(skip)し、
# 固定版を外した再実行はしない(fail-closed。暗黙の最新版フォールバックはしない)。
# ここでの fail-closed は「他スキルへの処理を止めない」という Step 1/3 の他の skip
# 分岐と同じ意味であり、「script 全体を停止する」という意味ではない
# (`scripts/skills-lock-update.sh` 単体実行時の set -euo pipefail による停止とは別軸。
# 詳細は下記「skills CLI のバージョン固定と更新手順」節の fail-closed 記述を参照)。
# --agent universal は書き込み先を union ストア(.agents/skills/<name>/)+
# skills-lock.json のみに限定する一次防御。個別 agent 指定(claude-code 等)は
# .agents/skills を経由せず .claude/skills/ 等へ直接コピーしレイアウトを変えるため
# 使わない(実測: スクラッチリポジトリで --agent universal のみ .agents/skills/
# 以外へ書き込みが無いことを確認済み)。npx の出力を tee で NPX_OUTPUT_FILE へも
# 保存し、後段の「Invalid agents」no-op 検出に使う。PIPESTATUS は pipeline 直後の
# 「次のコマンド実行前」にしか正しい値を保持しない。変数代入も1個のコマンドと
# 数えられるため、`NPX_STATUS=...; TEE_STATUS=...` のように2つの代入に分けると、
# 1つ目の代入自体がその時点の PIPESTATUS を(要素数1・値0の配列へ)上書きして
# しまい、2つ目の代入が参照する PIPESTATUS[1] は `set -u` 下で unbound variable
# エラーになる(実測: bash 5.3 で再現)。配列全体を単一の代入
# `arr=("${PIPESTATUS[@]}")` でスナップショットし、添字アクセスはそのコピーに対して
# 行う。npx([0])・tee([1])両方を読む理由: tee がディスク容量不足等で非ゼロ終了
# すると NPX_OUTPUT_FILE が空・不完全なまま残り得るが、npx 自体は成功(exit 0)
# し得るため、tee 側の失敗は npx の終了コードだけを見る分岐からは検出できない
# (Issue #410 CI 失敗指摘)。TEE_STATUS が非ゼロなら、その不完全な NPX_OUTPUT_FILE
# を前提にした「Invalid agents」no-op 判定を信頼せず、NPX_STATUS を強制的に失敗へ
# 倒して以降の失敗経路(事後スコープ外検査・スコープ内リバート・skip)へ合流させる。
# errexit の無効化(scripts/skills-lock-update.sh と同じ理由): このフェンスが
# `set -e`/`pipefail` の下で実行される場合、npx が非ゼロ終了すると tee との
# パイプライン全体が失敗し、次行の PIPE_EXIT_SNAPSHOT 取得より前にシェルが
# その場で終了してしまう。そうなると事後のスコープ外検査・スコープ内リバート
# (下の NPX_STATUS 分岐)に到達できず、`skills-lock.json` / スキルツリーの
# 部分書き込みが検査もリバートもされないまま残置される(Bugbot High 指摘)。
# npx 行の前後だけ errexit を無効化し、pipeline の終了コードを PIPESTATUS から
# 読んでから元の状態(`$-`)を保存し、元々有効だった場合のみ戻す。本フェンスは
# scripts/skills-lock-update.sh と異なり、errexit が無効なエージェント対話シェルへ
# コピペ実行され得る。無条件 `set -e` で復元すると呼び出し元シェルへ errexit を
# 新規に有効化してしまい、同一シェルで後続する Step 6 却下コマンド等がガードの
# 無い非ゼロ終了で途中 abort し得るため、元々有効だった場合のみ再有効化する
# (Issue #417)。
case $- in *e*) ERREXIT_WAS_ON=1 ;; *) ERREXIT_WAS_ON=0 ;; esac
set +e
npx --yes "skills@${SKILLS_CLI_VERSION}" add "${SOURCE}" --skill "${SKILL_NAME}" --agent universal --yes 2>&1 | tee "${NPX_OUTPUT_FILE}"
PIPE_EXIT_SNAPSHOT=("${PIPESTATUS[@]}")
if [[ "${ERREXIT_WAS_ON}" -eq 1 ]]; then set -e; fi
NPX_STATUS="${PIPE_EXIT_SNAPSHOT[0]}"
TEE_STATUS="${PIPE_EXIT_SNAPSHOT[1]}"
if [[ "${TEE_STATUS}" -ne 0 ]]; then
echo "警告: npx の出力を ${NPX_OUTPUT_FILE} へ保存する tee が失敗しました(終了コード ${TEE_STATUS}。ディスク容量不足等)。出力ファイルが不完全なため、npx 自体の終了コード(${NPX_STATUS})に関わらず失敗として扱います。"
NPX_STATUS=1
fi
# 許可先経路の実行後再検証(PR #412 P0 指摘: 実行中の symlink 置換 / 初回
# インストールでの symlink 作成)。事後シグネチャ取得より前にここで一度だけ判定し、
# 検出時は共通失敗経路へ合流させる(revert_in_scope は SCOPE_PATH_COMPROMISED を
# 見て許可先配下への削除系操作を行わず、リポジトリ外書き込みの可能性の手動確認を
# 案内する)。
if ! verify_scope_path_after_run; then
SCOPE_PATH_COMPROMISED=1
NPX_STATUS=1
fi
# skills-lock.json の実行後 再検証(PR #412 codex P0 指摘: 実行中の symlink 置換)。
# 検出時は共通失敗経路へ合流させる(revert_in_scope は LOCK_FILE_COMPROMISED を見て
# symlink を rm で除去してから checkout し、リンク先への書き込みを避ける)。
if ! verify_lock_file_after_run; then
LOCK_FILE_COMPROMISED=1
NPX_STATUS=1
fi
# 許可先配下の実行後 symlink 走査(PR #412 Bugbot High 指摘: TOCTOU)。事前走査は
# npx 実行前の 0 件しか保証せず、npx が実行中に配下へ作った外向き symlink とそれ
# 経由の書き込みは、配下を署名から除外している前後比較にも、経路 3 要素しか見ない
# verify_scope_path_after_run にも現れない。事後に見つかる symlink はすべて npx
# 実行中の作成物であり、これを残したまま checkout・clean・ignored 削除・復元を
# 走らせるとリンク先(リポジトリ外を含む)への削除・書き込みになり得るため、
# 1 件でも検出したら経路妥協(SCOPE_PATH_COMPROMISED)と同じ保全経路へ倒す。
# 走査自体の失敗も「symlink なしと確認できない」であって「なし」ではないため
# 同様に倒す(fail-closed)。
if [[ "${SCOPE_PATH_COMPROMISED}" -eq 0 && -d ".agents/skills/${SKILL_NAME}" ]]; then
if ! SCOPE_POST_SYMLINKS="$(find ".agents/skills/${SKILL_NAME}" -type l)"; then
echo "エラー: npx 実行後の許可先配下 symlink 走査(find)に失敗しました。リンク先(リポジトリ外を含む)への書き込み経路が無いことを確認できないため、許可先配下への自動復旧を行わず停止します(fail-closed)。" >&2
SCOPE_PATH_COMPROMISED=1
NPX_STATUS=1
elif [[ -n "${SCOPE_POST_SYMLINKS}" ]]; then
echo "エラー: npx が実行中に許可先(.agents/skills/${SKILL_NAME})配下へシンボリックリンクを作成しました。リンク先(リポジトリ外を含む)へ書き込んだ可能性があり、symlink 越しの走査を避けるため許可先配下への自動復旧は行いません。リンク先の内容とリポジトリ外への書き込み有無を手動確認してください(fail-closed):" >&2
printf '%s\n' "${SCOPE_POST_SYMLINKS}" >&2
SCOPE_PATH_COMPROMISED=1
NPX_STATUS=1
fi
fi
# CLI がバージョン更新等で --agent universal を認識できなくなった場合、
# エラー表示のうえ exit 0 の no-op になる(実測: skills@1.5.22 で確認済み)。
# 検知しないまま先へ進むと「同期したつもりで何も更新されていない」まま完了扱いに
# なるため、npx 自体の終了コードとは別に明示的な失敗として扱う(上の TEE_STATUS
# チェックで NPX_STATUS が強制失敗化されていればここは通らない)。
# この分岐で直接 exit すると実行後スナップショット・シグネチャ比較・スコープ内
# リバートをすべて迂回する(PR #412 P1 指摘)。「Invalid agents」は CLI の出力文言に
# 過ぎず、将来版が部分書き込みの後に同じ文言を出しても no-op とは限らないため、
# NPX_STATUS=1 を設定して下の共通失敗経路へ合流させ、「成功・失敗いずれの経路でも
# 事後検査へ必ず到達」の契約を守る。universal 無効は以降の全スキルでも再現し
# 続行に意味がないため、共通失敗経路の末尾で continue ではなく exit 1 で終端する
# (NPX_NOOP_DETECTED がその分岐と汎用メッセージの抑止を担う)。
NPX_NOOP_DETECTED=0
if [[ "${NPX_STATUS}" -eq 0 && "${SCOPE_PATH_COMPROMISED}" -eq 0 ]] && grep -q "Invalid agents" "${NPX_OUTPUT_FILE}"; then
echo "エラー: skills CLI が --agent universal を認識せず、何も実行していません(exit 0 の no-op)。SKILLS_CLI_VERSION 更新時は「skills CLI のバージョン固定と更新手順」節に従い universal の有効性を再確認してください。" >&2
NPX_NOOP_DETECTED=1
NPX_STATUS=1
fi
if [[ "${NPX_STATUS}" -ne 0 ]]; then
if [[ "${NPX_NOOP_DETECTED}" -eq 0 && "${SCOPE_PATH_COMPROMISED}" -eq 0 && "${LOCK_FILE_COMPROMISED}" -eq 0 ]]; then
echo "警告: skills@${SKILLS_CLI_VERSION} の実行が失敗しました(該当版の不存在・レジストリ障害・ダウンロード中断等、原因は問わない)。"
fi
# 失敗が部分書き込み後に発生した場合、skills-lock.json / .agents/skills/${SKILL_NAME}/ に
# 加えてスコープ外(他エージェントツリー等)にも残置され得るため、失敗経路でも
# 実行後スナップショットを取ってスコープ外残留を検査する(下の成功経路と同一ロジック)。
git -c status.renames=false status --porcelain -z -uall > "${SNAP_AFTER}" || true
filter_out_of_scope "${SNAP_AFTER}" "${SNAP_FILTERED_AFTER}"
# 実行後シグネチャの取得失敗は「変化なしと確認できない」であって「変化なし」では
# ないため、比較不能な sentinel を入れて必ず不一致(= 残置疑いの報告)へ倒す。
if ! REPO_STATE_AFTER="$(repo_state_signature)"; then
echo "エラー: npx 実行後の状態シグネチャ取得に失敗しました。変化なしと確認できないため、スコープ外残置ありとして扱います(fail-closed)。" >&2
REPO_STATE_AFTER="(signature-error)"
fi
# 成功経路の多層防御(下記の判定表・NPX_STATUS -eq 0 の分岐)と同じ検出基準を
# 失敗経路にも適用する。ここで検出したら NPX_STATUS_OUT_OF_SCOPE_DIRT=1 を立てて
# 下の exit 1 判定に合流させる(Bugbot High 指摘: 検出しても continue するだけでは
# 未リバートのスコープ外差分が「元から存在した dirty 状態」と誤認され得る。
# `scripts/skills-lock-update.sh` はこの検出を単に「変化なし」ではないため
# ループ全体を止める理由に含めており、このフェンスも同じ停止経路を取る)。
NPX_STATUS_OUT_OF_SCOPE_DIRT=0
if ! cmp -s "${SNAP_FILTERED_BEFORE}" "${SNAP_FILTERED_AFTER}" \
|| [[ "${REPO_STATE_BEFORE}" != "${REPO_STATE_AFTER}" ]]; then
echo "エラー: 失敗した npx 実行がスコープ外へも書き込んだ可能性があります。以下を確認してください(削除はしていません):" >&2
while IFS= read -r -d '' rec; do printf ' %s\n' "${rec}" >&2; done < "${SNAP_FILTERED_AFTER}"
echo " (ディレクトリの chmod 等、git status に現れない変化は上記一覧に載りません。状態シグネチャの不一致として検出されています)" >&2
NPX_STATUS_OUT_OF_SCOPE_DIRT=1
fi
# 次スキルの `git add skills-lock.json`(Step 7)が
# この残置変更を承認済みの変更と一緒に stage してしまわないよう、Step 6 の却下時と
# 同じ手順で当該スキル分のみを即座にリバートしてから skip する。
CONTRACT_RESTORE_VERIFY_FAILED=0
if [[ "${LOCAL_PATCH_GUARD}" == true ]]; then
# checker を持つリポジトリでは、同期前 check(check mode)が契約範囲(durable patch 含む)を
# 変更・stage している可能性があり、index からの worktree 復元しか行わない
# revert_in_scope だけでは同期開始前へ戻らない。契約パス限定の restore_contract_scope で
# index も同期開始前へ戻す(前スキルの承認済み積上げは PRE_SYNC_TREE に含まれるため保持。
# 許可先経路・skills-lock.json の妥協検出時は関数内で index のみの復元へ切り替わる)。
# 呼び出しは revert_in_scope より「前」でなければならない: revert_in_scope の
# git clean -fd を先に走らせると、restore_contract_scope が退避するはずの契約範囲内の
# 未追跡ファイル(checker が移動・新規作成した唯一のコピーであり得る)が削除される。
# 復元後の revert_in_scope は契約パスの checkout / clean が実質 no-op になり、ignored
# ファイルの選別削除・既存 ignored の復元・妥協検出時の保全案内だけが働く。
# 戻り値を明示的に拾う(Bugbot Medium 指摘: この分岐は他の失敗経路と異なり
# 条件次第で continue し得るため、restore_contract_scope の復元後検証が失敗した
# 場合に「契約範囲が半端に復元されたまま次スキルの npx へ進む」ことを防ぐには、
# 下の exit 1 判定へ明示的に合流させる必要がある)。
if ! restore_contract_scope; then
CONTRACT_RESTORE_VERIFY_FAILED=1
fi
fi
revert_in_scope
rm -f "${SNAP_BEFORE}" "${SNAP_AFTER}" "${SNAP_FILTERED_BEFORE}" "${SNAP_FILTERED_AFTER}" "${NPX_OUTPUT_FILE}" "${SCOPE_INVENTORY_FILE}" "${IGNORED_BASELINE_FILE}"
if [[ "${IGNORED_BACKUP_KEEP}" -eq 0 ]]; then rm -rf "${IGNORED_BACKUP_DIR}"; fi
# universal 無効の no-op は以降の全スキルでも再現するため、skip(continue)ではなく
# ループ全体を停止する(事後検査・リバートは上で完了済み)。許可先経路の
# symlink 化(SCOPE_PATH_COMPROMISED)もレイアウト自体の異常で人間の確認を要する
# ため、同様にループ全体を停止する(事前 lstat 検査の exit 1 と同じ扱い)。
# 既存 ignored の復元失敗(IGNORED_RESTORE_FAILED)も手動復旧を要するため停止する
# (バックアップ dir は revert_in_scope が保全済み)。skills-lock.json の置換
# (LOCK_FILE_COMPROMISED)もリンク先への書き込み有無の手動確認を要するため停止する。
# スコープ外書き込みの検出(NPX_STATUS_OUT_OF_SCOPE_DIRT)も、上のスコープ外書き込み
# 検出(多層防御)と同じ停止経路であり、continue(skip)ではなくループ全体を止める。
# 契約範囲の復元後検証失敗(CONTRACT_RESTORE_VERIFY_FAILED)も同様: 半端に復元された
# 契約範囲のまま次スキルの npx を続けると、その npx が汚染された前提状態の上で動く
# ため continue せずループ全体を止める(Bugbot Medium 指摘)。
if [[ "${NPX_NOOP_DETECTED}" -eq 1 || "${SCOPE_PATH_COMPROMISED}" -eq 1 || "${LOCK_FILE_COMPROMISED}" -eq 1 || "${IGNORED_RESTORE_FAILED}" -eq 1 || "${NPX_STATUS_OUT_OF_SCOPE_DIRT}" -eq 1 || "${CONTRACT_RESTORE_VERIFY_FAILED}" -eq 1 ]]; then
exit 1
fi
echo "警告: 固定版を外した再実行はせず、当該スキルの変更をリバートして skip します。"
continue
fi
# npx 実行後のスナップショット。前後のスコープ外差分を見るため、取得条件は
# SNAP_BEFORE と完全に揃える。取得失敗時にその場で exit すると、npx が生成済みの
# スコープ内変更を残置したままスコープ外シグネチャ検査も行われない(PR #412 P1
# 指摘)。状態シグネチャは git 非依存(python3 の走査)で取得できるため、可能な
# 範囲で事後検査(スコープ外残置疑いの報告)を行い、スコープ内をリバートしてから
# fail-closed で exit 1 する(ループ停止。以降のスキルも同じ git 障害に当たるため
# skip 継続に意味がない)。
if ! git -c status.renames=false status --porcelain -z -uall > "${SNAP_AFTER}"; then
echo "エラー: npx 実行後の git status 取得に失敗しました。porcelain レコードでの前後比較ができません。" >&2
if REPO_STATE_AFTER="$(repo_state_signature)"; then
if [[ "${REPO_STATE_BEFORE}" != "${REPO_STATE_AFTER}" ]]; then
echo "エラー: スコープ外(skills-lock.json / .agents/skills/${SKILL_NAME}/ 以外)へも書き込まれた可能性があります(状態シグネチャ不一致)。git status / git diff で手動確認してください(削除はしていません)。" >&2
fi
else
# シグネチャも取れない場合は「変化なし」と確認できないため、残置の可能性ありと
# して案内する(fail-closed。green 側へ倒さない)。
echo "エラー: 実行後の状態シグネチャ取得にも失敗しました。スコープ外残置の可能性を排除できません。git status / git diff で手動確認してください(fail-closed)。" >&2
fi
if [[ "${LOCAL_PATCH_GUARD}" == true ]]; then
# 同期前 check の staged 契約変更を残さない(fail-closed 契約)。git clean -fd による
# 未追跡削除より先に退避を効かせるため revert_in_scope より前に呼ぶ(NPX_STATUS 分岐と同じ)
restore_contract_scope || true
fi
revert_in_scope
# revert_in_scope が CONTRACT_UNTRACKED_BACKUP_FAILED を検知して checkout・clean を
# 保全のためスキップした場合、worktree には未退避の変更が残っている(「リバート
# しました」は虚偽になる)。REVERT_IN_SCOPE_SKIPPED で分岐し、その場合は
# revert_in_scope 自身が案内済みの警告と重複させず、完了を意味する文言は出さない
# (Bugbot Medium 指摘: Stale revert success after backup skip / Issue #418 系)。
if [[ "${REVERT_IN_SCOPE_SKIPPED:-0}" -ne 1 ]]; then
echo "スコープ内(skills-lock.json / .agents/skills/${SKILL_NAME}/)の変更はリバートしました。" >&2
fi
rm -f "${SNAP_BEFORE}" "${SNAP_AFTER}" "${SNAP_FILTERED_BEFORE}" "${SNAP_FILTERED_AFTER}" "${NPX_OUTPUT_FILE}" "${SCOPE_INVENTORY_FILE}" "${IGNORED_BASELINE_FILE}"
if [[ "${IGNORED_BACKUP_KEEP}" -eq 0 ]]; then rm -rf "${IGNORED_BACKUP_DIR}"; fi
exit 1
fi
filter_out_of_scope "${SNAP_AFTER}" "${SNAP_FILTERED_AFTER}"
# 実行後シグネチャの取得失敗は「変化なしと確認できない」であって「変化なし」では
# ないため、比較不能な sentinel を入れて必ず不一致(= 検出・リバート・停止)へ倒す。
if ! REPO_STATE_AFTER="$(repo_state_signature)"; then
echo "エラー: npx 実行後の状態シグネチャ取得に失敗しました。変化なしと確認できないため、スコープ外書き込みありとして扱います(fail-closed)。" >&2
REPO_STATE_AFTER="(signature-error)"
fi
# スコープ外の状態が前後で一致しなければ、--agent universal が抑止しているはずの
# スコープ外書き込みが発生したことになる(一次防御を突破した場合の多層防御)。
# 判定は 2 軸: (1) porcelain レコード(SNAP_FILTERED_*)と (2) リポジトリ全体の
# 状態シグネチャ(REPO_STATE_*、repo_state_signature の出力)。実行前から M・??
# だったスコープ外ファイルの内容・モードだけの上書きや、ディレクトリの chmod・
# ディレクトリ向け symlink の変更・.gitignore 対象ファイルの上書きは porcelain
# レコードに現れないため、シグネチャ側の不一致だけがそれを検出できる。
# 他の skip 分岐と異なり、ここは
# continue ではなく exit 1 でループ全体を停止する。スコープ外の汚染は自動リバート
# されないため、続行すると最終報告が「clean」でも実際は dirty 残留となり、この
# issue が問題視している状態そのものになる。この時点までに承認・stage 済みの他
# スキル分は index に残ったままになるため、停止後は `git status` で確認し、必要な
# 分だけ `git commit` するか `git reset` で戻すかを判断すること。
if ! cmp -s "${SNAP_FILTERED_BEFORE}" "${SNAP_FILTERED_AFTER}" \
|| [[ "${REPO_STATE_BEFORE}" != "${REPO_STATE_AFTER}" ]]; then
echo "エラー: npx skills add がスコープ外(skills-lock.json / .agents/skills/${SKILL_NAME}/ 以外)へ書き込みました。" >&2
echo "==> 実行前のスコープ外状態:" >&2
while IFS= read -r -d '' rec; do printf ' %s\n' "${rec}" >&2; done < "${SNAP_FILTERED_BEFORE}"
echo "==> 実行後のスコープ外状態:" >&2
while IFS= read -r -d '' rec; do printf ' %s\n' "${rec}" >&2; done < "${SNAP_FILTERED_AFTER}"
echo "(ディレクトリの chmod 等、git status に現れない変化は上記一覧に載りません。その場合は状態シグネチャの不一致として検出されています)" >&2
echo "スコープ内(skills-lock.json / .agents/skills/${SKILL_NAME}/)の変更はこれからリバートします。" >&2
echo "スコープ外は既存の WIP を巻き込む恐れがあるため自動リバートしません。" >&2
echo "上記パスの内容を git status / git diff で確認し、必要なら手動で" >&2
echo " git checkout -- <path> (変更前が clean だった追跡ファイルの場合)" >&2
echo " git clean -fd <path> (変更前に存在しなかった未追跡ファイルの場合)" >&2
echo "を実行してください。" >&2
if [[ "${LOCAL_PATCH_GUARD}" == true ]]; then
# 同期前 check の staged 契約変更を残さない(fail-closed 契約)。git clean -fd による
# 未追跡削除より先に退避を効かせるため revert_in_scope より前に呼ぶ(NPX_STATUS 分岐と同じ)
restore_contract_scope || true
fi
revert_in_scope
rm -f "${SNAP_BEFORE}" "${SNAP_AFTER}" "${SNAP_FILTERED_BEFORE}" "${SNAP_FILTERED_AFTER}" "${NPX_OUTPUT_FILE}" "${SCOPE_INVENTORY_FILE}" "${IGNORED_BASELINE_FILE}"
if [[ "${IGNORED_BACKUP_KEEP}" -eq 0 ]]; then rm -rf "${IGNORED_BACKUP_DIR}"; fi
exit 1
fi
# 成功経路でも既存 ignored ファイル(.DS_Store 等)の変更・削除を実行前バックアップ
# との比較で検査する。許可先配下は署名から除外されるためシグネチャ比較には現れず、
# この比較だけが検出手段になる。変化があればバックアップから復元して警告し、同期
# 自体は継続する(ignored ファイルは同期対象外で、復元すれば成果物に影響しないため)。
# 復元に失敗した場合のみバックアップ dir を保全し、手動復旧を要するためループ全体を
# exit 1 で停止する(fail-closed)。
if ! restore_preexisting_ignored; then
IGNORED_BACKUP_KEEP=1
echo "エラー: 実行前から存在した ignored ファイルの復元に失敗しました。バックアップは ${IGNORED_BACKUP_DIR} に相対パス構造で残っています。手動で復旧してください。" >&2
if [[ "${LOCAL_PATCH_GUARD}" == true ]]; then
# 失敗終端では同期前 check の staged 契約変更も残さない(fail-closed 契約。
# npx の同期結果ごと同期開始前へ戻し、部分状態のまま Step 7 の git add へ進む経路を断つ)
restore_contract_scope || true
fi
# 他の post-npx 失敗経路と同じくスコープ内をリバートする。checker が無い
# リポジトリでは、ここで revert_in_scope を呼ばないと npx による skills-lock.json /
# .agents/skills/${SKILL_NAME}/ への書き込みが worktree に残ったまま停止し、
# 次回実行時の「実行前 clean」ガードに引っかかって当該スキルが skip され続ける。
# revert_in_scope は内部で restore_preexisting_ignored を再試行するが、失敗が
# 続いても IGNORED_BACKUP_KEEP=1 で警告するだけで非ゼロ終了はしない
# (呼び出し元のこの分岐がすでに exit 1 を担保しているため)。
revert_in_scope
rm -f "${SNAP_BEFORE}" "${SNAP_AFTER}" "${SNAP_FILTERED_BEFORE}" "${SNAP_FILTERED_AFTER}" "${NPX_OUTPUT_FILE}" "${SCOPE_INVENTORY_FILE}" "${IGNORED_BASELINE_FILE}"
exit 1
fi
rm -f "${SNAP_BEFORE}" "${SNAP_AFTER}" "${SNAP_FILTERED_BEFORE}" "${SNAP_FILTERED_AFTER}" "${NPX_OUTPUT_FILE}" "${SCOPE_INVENTORY_FILE}" "${IGNORED_BASELINE_FILE}"
if [[ "${IGNORED_BACKUP_KEEP}" -eq 0 ]]; then rm -rf "${IGNORED_BACKUP_DIR}"; fi
npx skills add は以下を行う:
- upstream の最新スキルをダウンロード
- インストール先(
.agents/skills/<name>/)を最新化
skills-lock.json の computedHash を CLI 算出値で更新
重要な副作用: npx skills add はインストール済みファイルを最新の upstream 版で上書きする。upstream との同期が目的のため、これは意図した動作である。上記の per-skill clean ガードは git status --porcelain を使い、ステージ済み・未ステージ・未追跡ファイルも含めて検出する。WIP がある場合は npx 実行前に skip するため、未コミット編集の消失は防止される。