Directly under the root, skills-lock.json の computedHash is updated by checking it against the current state of the upstream repository.
Target files
- root: directly under the calling repository root,
skills-lock.json— the only file this skill edits - Excluded: under submodules,
skills-lock.jsonis not edited (because it does not cross submodule boundaries)
Prerequisites
ghThe CLI must be installed and authenticatednode/npxmust be available (becausenpx skills addis used).skillsThe CLI is run at a pinned version (SKILLS_CLI_VERSION). For the value and update procedure, see the section "Pinning and updating the skills CLI version"python3must be available (used for state signatures [path_state/index_state_signature] and restoration processing [comparison/restoration of existing ignored files, etc.]. It is installed by default on major Linux / macOS. In environments where it is absent, the Step 4 fence stops fail-closed before npx execution, so install it and then re-run)fileThe CLI must be available (used to display the type of untracked binary files. In environments where it is not installed, the type is displayed asfile コマンド未検出, and the decision must be made only from the size and git blob hash before approval. It is installed by default on macOS / major Linux distributions)- Directly under the root,
skills-lock.jsonmust exist - Before execution,
skills-lock.jsonmust have no uncommitted changes(regardless of staged or unstaged). Changes occurring during this skill's execution will be only those from sync, and even ifgit add skills-lock.jsonstages everything, unrelated changes will not be mixed in - For the target skill's
.agents/skills/<name>/, there must be no uncommitted changes。npx skills addは.agents/skills/<name>/is overwritten with the latest upstream version, so if WIP exists in that directory it is lost immediately.git checkoutcan only restore "the last committed state", and uncommitted edits made before npx execution cannot be restored.WIP that exists as untracked files is also targetedandgit status --porcelaindetect with - When the consuming repository has a committed local patch: vendored skill(
.agents/skills/) and the repository applies a committed local patch, as the entry point for verification/reapplication, the repository-owned checkerscripts/check-skill-local-patches.sh(no arguments = check /applytwo modes) and the ledger.agents/skills/LOCAL-PATCHES.mdhas. Since committed patches cannot be protected by the above clean guard, if a checker exists, success before and after synchronization (Step 4's pre-check, Step 5.5's apply + final verification) is mandatory (non-zero results in fail-closed, and synchronization/staging does not occur). If a ledger exists but no checker exists, this is also treated as unverifiable and stops fail-closed. The write destinations of the checker (apply) are limited by contract to the skill directory in question,skills-lock.json, and the durable patch locationscripts/local-patches/, and out-of-scope changes are detected fail-closed by digest comparison immediately after each execution (the detection range is best-effort limited to targets that Git tracks and enumerates, and is not a guarantee of write restrictions. The guarantee is provided by the user's review of the checker contents + blob hash approval. For details, see the "Limits of detection range" comment in Step 4). Because the checker is executable code placed by the consumer, it is not executed merely because it "exists"; it is executed only if it is a regular file that is not a symlink (the HEAD-side mode is also 100644/100755), is committed to HEAD and matches the worktree, and the user has been presented with its origin and contents and has given explicit approval on a blob hash basis (mechanically verified in Step 4). The execution target is not the worktree file but a temporary file extracted from the approved HEAD blob (CHECKER_EXEC), and the TOCTOU path that swaps the worktree checker after hash verification is cut off - Execute in the main worktree of the normal configuration。linked worktree(
git worktree add), the working tree created by.gitbecomes a regular file pointing to the gitdir, and the real Git directory (.git/worktrees/<name>/and the shared side'srefs・logs・configand objects) falls outside the state signature, so the Step 4 fence detects this by the mismatch ofgit rev-parse --absolute-git-dir/--git-common-dirbefore npx execution and rejects it fail-closed. Similarly, configurations where the real Git directory is outside the working tree (git clone --separate-git-dir・submodule checkout・.gitis a symlink) are also rejected fail-closed before npx execution by verification (.git= the--show-toplevelactual directory directly under the working tree) (strict match with
flow
Step 1: Check arguments and verify preconditions
TARGET="$ARGUMENTS" # 空なら全スキル対象
# 引数指定時は kebab-case のみ許可(パストラバーサル防止)
if [[ -n "${TARGET}" && ! "${TARGET}" =~ ^[a-z][a-z0-9-]+$ ]]; then
echo "エラー: スキル名は小文字 kebab-case のみ許可されています: ${TARGET}"
exit 1
fi
If there are arguments, process only the corresponding skill; if there are none, target all entries of skills-lock.json .
Next, check the clean state of skills-lock.json . If there are uncommitted changes (whether staged or unstaged), abort.
# 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: Aggregate the upstream list
skills-lock.json and group skills by thesource field (to consolidate processing for the same repository into one pass).
Fandhe-AI/agent-cli-skills:
- create-commit
- create-issue
- ...
Step 3: Verify the source
Safety valve: Before processing, always confirm that it exactly matches source field is Fandhe-AI/<repo> . With prefix matching, values containing ../ pass through, and due to URL path normalization during clone, out-of-organization repositories could be targeted, so verify with a strict regular expression after normalizing toOWNER/REPO . Skip unexpected sources and warn the user.skills-lock.json This is to prevent cloning from an untrusted repository due to tampering or misconfiguration.
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: Process the target skills one at a time (loop)
For each target skill, execute the following 4→5→6→7 in order,after completing one skill, proceed to the next skill. Even if all skills are in sync, do not process multiple skills simultaneously; complete them one skill at a time.
Step 4: Update computedHash with npx skills add
sha256sum rather than manually calculating with etc.,npx skills add let the calculation be handled by. This ensures a complete match with the CLI's internal algorithm.
# 当該スキルの 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 does the following:
- Download the latest skill from upstream
- Bring the install destination (
.agents/skills/<name>/) up to date skills-lock.jsonのcomputedHashUpdate with the CLI-calculated value
Important side effect: npx skills add overwrites the installed files with the latest upstream version. Since the purpose is synchronization with upstream, this is the intended behavior. The per-skill clean guard above uses git status --porcelain and detects staged, unstaged, anduntracked files as well. If there is WIP, it is skipped before running npx, so loss of uncommitted edits is prevented.
Note: For skills that pass the clean guard, npx immediately skills-lock.json と .agents/skills/<name>/ rewrites. Since the changes are finalized before user approval (Step 6), if you do not approve, you need to revert according to the guidance in Step 6.
Limiting the write scope (--agent universal): npx skills add If run without agent/path restrictions, it may also write to the trees for each detected agent (.claude/skills/ etc.). However, the clean guard, Step 5 preview, Step 6 revert, and Step 7 git add are all skills-lock.json と .agents/skills/<name>/ only, and if writes outside the scope occur, WIP overwriting, review bypass, and "dirty residue after a clean report" can happen.--agent universal writes are limited to .agents/skills/<name>/ と skills-lock.json , and do not write to other agent trees (.claude/skills/ etc.) (verified: confirmed in a scratch repository). Even if this assumption is broken and writes occur due to a CLI version update, etc., it stops fail-closed by comparing snapshots before and after npx execution (defense in depth).
Step 5: Display the diff for the relevant skill
git diff does not display untracked files. The clean guard in Step 4 (git status --porcelain)
npx skills add Since the relevant directory is always clean before execution,in cases where files have increased on the upstream side,
those new files will without exception become untracked. If you only show the tracked diff and
proceed to the approval decision in Step 6,
you could approve without checking its contents at all, so present both the tracked diff and the contents of untracked files separately.
# 当該スキルにスコープした tracked 差分を表示する
git diff -- skills-lock.json ".agents/skills/${SKILL_NAME}/"
# 未追跡ファイルを列挙し、内容を diff 形式で表示する。
# この集合は Step 7(承認・git add)が新規に取り込む集合、Step 6(拒否・git clean -fd)が
# 削除する集合と同一(.gitignore 対象を除く非追跡ファイル)であり、
# 「プレビュー = 承認 = 拒否」の 3 経路が同じ対象を扱うことを保証する。
# git ls-files の既定出力は改行区切りのため、ファイル名自体に改行を含む未追跡ファイルが
# あると 1 パスが複数の存在しないパスへ分割される。分割後の各 git diff は失敗し || true で
# 握り潰される一方、Step 7 の git add は実ファイルをそのまま取り込むため、内容を表示しない
# まま承認できてしまう(-z / NUL 区切りで防ぐ)。
UNTRACKED_COUNT=0
# git ls-files をプロセス置換へ直接つなぐと、set -euo pipefail はその終了コードを
# 検査しない。失敗(破損 index・権限エラー等)しても while は0回実行され「なし」と
# 誤表示するため、一時ファイルへ書き出し if ! ... で明示的に終了コードを検査する
# (scripts/skills-lock-update.sh と同一のガード)。
UNTRACKED_LIST_FILE="$(mktemp)"
trap 'rm -f "${UNTRACKED_LIST_FILE}"' EXIT
if ! git ls-files -z --others --exclude-standard -- ".agents/skills/${SKILL_NAME}/" > "${UNTRACKED_LIST_FILE}"; then
echo "エラー: git ls-files が失敗し、未追跡ファイルの一覧化を確認できません。中止します。" >&2
# scripts/skills-lock-update.sh の同一経路(preview_untracked)と揃える(Bugbot
# Medium 指摘: 従来はここで exit 1 するだけで、npx が成功させた契約範囲(tracked 分)
# の復元も行われなかった)。ただし git ls-files が壊れている以上「何が npx の新規
# 作成物か」を安全に確定できないため、破壊的な git clean -fd は行わない(codex P0
# 指摘の再発防止)。tracked 分の非破壊的な復元(restore_contract_scope || true →
# revert_in_scope 1〔非破壊モード。git clean -fd / remove_new_ignored_in_scope を
# スキップする〕)だけを行い、未追跡ファイルは手動確認へ委ねる。
if [[ "${LOCAL_PATCH_GUARD}" == true ]]; then
restore_contract_scope || true
fi
revert_in_scope 1
echo "エラー: 未追跡ファイルの一覧化ができなかったため、.agents/skills/${SKILL_NAME}/ 配下の破壊的な後始末(git clean -fd)はスキップしました。npx / checker の書き込み・未追跡ファイルが残っている可能性があります。git status --porcelain -- \".agents/skills/${SKILL_NAME}/\" で確認し、内容を確認したうえで不要なもののみ手動で git clean -fd \".agents/skills/${SKILL_NAME}/\" してください(fail-closed)。" >&2
exit 1
fi
while IFS= read -r -d '' f; do
if [[ "${UNTRACKED_COUNT}" -eq 0 ]]; then
echo "==> 新規(未追跡)ファイル — 承認時に git add で取り込まれる集合:"
fi
UNTRACKED_COUNT=$((UNTRACKED_COUNT + 1))
# 空ファイルは git diff --no-index が差分を出力しないため、diff の見出しだけでは
# どのファイルが追加されるか分からない。先に printf でファイル名自体を明示してから
# 内容の diff を表示する(0 byte のファイルでも名前は必ず見える)。
printf '%s\n' "--- ${f} ---"
# バイナリファイルは git diff --no-index が "Binary files ... differ" としか出力せず、
# 追加される中身を一切提示しない。numstat の追加/削除行数が両方 "-" になる出力で
# バイナリ判定し、内容の代わりに種別・サイズ・ハッシュを明示することで、中身を
# 確認できないまま承認(Step 7 の git add)だけが通ってしまう非対称を防ぐ。
NUMSTAT="$(git diff --no-index --numstat -- /dev/null "${f}" 2>/dev/null || true)"
if [[ "${NUMSTAT}" == -$'\t'-$'\t'* ]]; then
FILE_SIZE="$(wc -c < "${f}" | tr -d '[:space:]')"
if command -v file >/dev/null 2>&1; then
FILE_TYPE="$(file -b -- "${f}" 2>/dev/null || echo "unknown")"
else
FILE_TYPE="file コマンド未検出"
fi
FILE_HASH="$(git hash-object -- "${f}")"
# object format は repository 設定依存(既定 sha1 / 拡張 sha256)で出力桁数が変わる
# (sha1: 40 桁 / sha256: 64 桁)。固定表記 "git-blob-sha1" だと sha256 リポジトリで
# 実際のアルゴリズムと表示が食い違うため、表記自体をアルゴリズム非依存にする。
OBJECT_FORMAT="$(git rev-parse --show-object-format 2>/dev/null || echo unknown)"
printf '%s\n' "==> バイナリファイル(内容は表示されません): type=${FILE_TYPE} size=${FILE_SIZE}bytes git-blob-hash(${OBJECT_FORMAT})=${FILE_HASH}"
else
# --no-index は index を変更しない(git add -N は使わない。Step 6 の拒否経路が
# index からの git checkout -- で承認済み他スキルの hash を復元する設計に依存しており、
# intent-to-add エントリの混入はその復元設計と干渉するため)。
# 差分ありのとき exit 1 を返す仕様のため、表示専用のこの呼び出しに限り || true で
# set -e の中断を避ける。
git diff --no-index -- /dev/null "${f}" || true
fi
done < "${UNTRACKED_LIST_FILE}"
if [[ "${UNTRACKED_COUNT}" -eq 0 ]]; then
echo "==> 新規(未追跡)ファイル: なし"
fi
Check the changes and the updated computedHash together with the contents of untracked files, to the user.
checker(scripts/check-skill-local-patches.sh) repository, the diff at this point is the raw upstream diff, and the local patch has not yet been reapplied (do not confuse this with the final diff after reapplication in Step 5.5).
Step 5.5: Reapply the local patch and perform final verification (only for repositories with a checker)
npx skills add overwrite, the local patch of the consuming repository has disappeared from the worktree, sobefore stage, reapply and perform final verification. The checker (apply) may modify and stage not only the skill directory in question but also the durable patch (scripts/local-patches/), so (1) snapshot the index before apply for strict restoration in case of rejection or failure, and (2) after apply, mechanically verify that the set of changes stays within the contract scope (skills-lock.json, the skill in question,scripts/local-patches/). Only if everything succeeds, proceed to Step 6 and beyond.
if [[ -f scripts/check-skill-local-patches.sh ]]; then
# Step 4 と同一 shell セッションの前提を機械検証する。セッションが切れて失われた場合は、
# outside_state / verify_outside_and_checker / restore_contract_scope / run_checker
# (いずれも Step 4 で定義した関数)を Step 4 のとおり再定義し、
# PRE_OUTSIDE / CHECKER_APPROVED_HASH には Step 4 が表示・承認した値を設定してから進む。
# 値が不明なまま進んではならない(fail-closed で中止し、Step 6 の却下手順で戻す)
: "${PRE_OUTSIDE:?Step 4 で表示された基準 digest を設定してから実行する}"
: "${CHECKER_APPROVED_HASH:?ユーザー承認済みの checker blob hash を設定してから実行する}"
# 却下・失敗時の復元は Step 4 で「checker 初回実行より前」に取得・表示済みの
# PRE_SYNC_TREE(同期開始前の index snapshot)を使う。ここで snapshot を取り直しては
# ならない — npx 後の index を基準にすると、復元先が「raw upstream + 同期前 check の
# stage」になり、「同期前へ戻す」という契約に反する(local patch が外れた状態が残る)
: "${PRE_SYNC_TREE:?Step 4 で表示された同期開始前 snapshot を設定してから実行する}"
# 範囲外 digest の基準(PRE_OUTSIDE)・outside_state・verify_outside_and_checker は
# Step 4 で checker の初回実行より前に定義・取得済みのものを同一 shell セッションで
# そのまま使う(ここで取り直すと、同期前 check 以降の範囲外変更が基準へ取り込まれてしまう)
# 実行対象は worktree の checker ではなく、承認済み blob(CHECKER_APPROVED_HASH)を
# 毎回再検証してから取り出す一時ファイル(run_checker。Step 4 で定義済み)。checker
# の各実行(同期前 check・apply・最終検証)の直前に必ずハッシュを再検証するため
# (codex P1 指摘: verify-then-execute の隣接。apply は checker 自身のコードを実行
# するため一時ファイルを自己書換えし得るが、直後の最終検証は run_checker が再検証
# してから承認済み blob へ書き戻すので、書き換えられた内容がそのまま実行されることは
# ない)、ここで個別に取り出し・検証するコードは不要(呼び出しごとに run_checker が
# 行う)。
# local patch の再適用(--3way fallback や index 復元により、patch 対象 file や durable patch が
# stage されることがある)
apply_rc=0
run_checker apply || apply_rc=$?
if [[ "${CHECKER_RUN_VERIFY_FAILED}" -eq 1 ]]; then
restore_contract_scope || true
exit 1
fi
if ! verify_outside_and_checker; then
# 範囲外の破壊は verify_outside_and_checker の案内どおり手動復旧として残しつつ、
# 契約範囲(部分適用された patch・checker 由来の index 変更)は Step 4 で定義済みの
# restore_contract_scope で自動復元してから終了する(「すべての失敗経路で同期開始前へ
# 戻す」契約。以下の 3 失敗分岐も同じ。shell セッションが切れて関数が失われている
# 場合は Step 4 のとおり再定義してから実行する)
restore_contract_scope || true
exit 1
fi
if [[ "${apply_rc}" -ne 0 ]]; then
echo "エラー: local patch の再適用に失敗しました。stage・commit へ進まないでください(fail-closed)。"
restore_contract_scope || true
exit 1
fi
# 再適用後の最終検証(worktree / index / durable patch)
check_rc=0
run_checker || check_rc=$?
if [[ "${CHECKER_RUN_VERIFY_FAILED}" -eq 1 ]]; then
restore_contract_scope || true
exit 1
fi
if ! verify_outside_and_checker; then
restore_contract_scope || true
exit 1
fi
if [[ "${check_rc}" -ne 0 ]]; then
echo "エラー: 再適用後の最終検証に失敗しました。stage・commit へ進まないでください(fail-closed)。"
restore_contract_scope || true
exit 1
fi
fi
If any of these is non-zero, stop fail-closedand do not proceed to Steps 6 and 7 (approval and stage).Do not stage a state with a missing local patch as if it were approved. For all failure branches, restore_contract_scope automatically restores the index + worktree within the contract scope (the skill in question,skills-lock.json・scripts/local-patches/) to the state before the start of synchronization, and then exits (untracked files within the contract scope are not deleted but moved to a temporary directory and reported. If the move itself fails, do not restore the worktree and stop with only the index). Only when destruction of an out-of-scope path is reported, manually recover the out-of-scope portion according to the guidance inverify_outside_and_checker and then investigate the cause.
Step 6: Ask the user to approve the skill in question
Only if there is a diff, ask the user to confirm "may this update be applied?". The preview in Step 5
(git ls-files --others --exclude-standard), the rejection in this Step (git clean -fd), and the approval in Step 7
(git add) target the same set (changes to tracked files + non-ignored untracked files).
.gitignore Targets are not handled by any route.
For repositories with a checker, the target of approval is the final diff after Step 5.5 is completed(commit candidates including the upstream update + local patch reapplication. Including durable patch scripts/local-patches/ ), and is confirmed as follows.git diff HEAD does not display untracked files, so for the untracked portion, rerun the same procedure as in Step 5 (enumerate withgit ls-files -z --others --exclude-standard , display contents viagit diff --no-index / binary detection) for paths within the contract scope, and present them together with the tracked diff:
git diff HEAD -- skills-lock.json ".agents/skills/${SKILL_NAME}/" scripts/local-patches/
# 未追跡分は Step 5 のプレビュー処理(一時ファイル + NUL 区切り + 空/バイナリ判定。
# git ls-files の失敗は fail-closed で中止)を、pathspec を
# ".agents/skills/${SKILL_NAME}/" scripts/local-patches/ の 2 つへ広げて再実行し、
# 名前の列挙だけでなく内容(git diff --no-index / バイナリは種別・サイズ・hash)まで提示する
If rejected, immediately revert only the skill in question andcontinue to the next skill(do not abort the whole process).
However, if the immediately following verification detects that the revert is incomplete, exclude it from this skip-continuation and abort the entire loop
exit 1 stop at this point (fail-closed; because if you proceed to the next skill while it is incomplete, the remaining rejected changes may end up being staged together at the next skill's approval):
# 当該スキルの変更のみをリバート(追跡ファイル)。
# git checkout -- <file> は HEAD ではなく「index(ステージ)」の内容を作業ツリーへ復元する。
# 前スキルの承認変更は git add で既に index に載っているため、checkout 後の作業ツリーにも
# 引き継がれ、承認済み computedHash が消えることはない。
# 2つのパスを1つの `git checkout --` に渡すとアトミックに扱われ、どちらか一方が
# 「追跡対象なし」(初回具現化・untracked のみの書き込み時)で pathspec エラーになると
# コマンド全体が失敗し、もう一方(skills-lock.json)も復元されない。必ず1コマンド1パスで
# 分離し、一方の失敗が他方の復元を阻害しないようにする。
# skills-lock.json は初回生成(未追跡)だと pathspec エラーで非ゼロ終了するため、
# revert_in_scope(行 1046-1084)と同じく || true で吸収する。errexit 有効シェルで
# 実行された場合でもここで途中 abort しない(Issue #417。復元の成否はこの直後の
# 検証ブロックで確認する — || true は成功扱いではなく確認を先送りするだけ)
git checkout -- skills-lock.json 2>/dev/null || true
git checkout -- ".agents/skills/${SKILL_NAME}/" 2>/dev/null || true
# skills-lock.json が npx の初回生成で未追跡のまま残っている場合、上の checkout は
# 「追跡対象なし」で何もせず(|| true が吸収)ファイルは削除されない。Step 4 の事前
# clean ガードにより実行前の未追跡 skills-lock.json は無いことが保証されているため、
# ここで未追跡のまま見つかる skills-lock.json は今回の npx が生成したものに限られ、
# 安全に削除できる。削除しないと直後の検証(git ls-files --others)が必ず残留を検出し、
# 「却下時は当該スキルのみリバートして次スキルへ continue」という契約を満たせないまま
# 必ず exit 1 になる(Issue #417 P1 指摘)。
# この経路は checker を持たない(このフェンスは checker 非経由の単純リバート専用)ため、
# 未追跡 skills-lock.json の唯一の出所は npx の初回生成であり削除して問題ない。checker
# を持つリポジトリ向けの却下フェンス(下記「checker を持つリポジトリの却下は次を使う」節)が
# 同種のファイルを削除せず一時ディレクトリへ退避するのは、checker(apply)が既存内容を
# 書き換えた唯一のコピーである可能性があるためで、事情が異なる(この経路にはその可能性がない)。
# `[[ -n "$(cmd)" ]]` は cmd の終了コードを見ず出力の空/非空だけを見るため、権限・index
# 異常等で cmd 自体が失敗して空出力になった場合「残留なし」と誤判定し得る(Issue #417
# P0 指摘)。command substitution は if の条件式として実行し、失敗時は変数を空のまま
# fail-closed で停止してから、内容の空・非空を判定する
if ! LOCK_UNTRACKED_CHECK="$(git ls-files -z --others --exclude-standard -- skills-lock.json)"; then
echo "エラー: skills-lock.json の未追跡判定(git ls-files)に失敗しました。空出力を「未追跡なし」と誤判定しないため停止します(fail-closed)。権限・index 異常等の原因を解消してから再実行してください。" >&2
exit 1
fi
if [[ -n "${LOCK_UNTRACKED_CHECK}" ]]; then
rm -f skills-lock.json
fi
# npx が新規作成した未追跡ファイルも削除(Step 4 の clean ガードで実行前は clean を保証済み)。
# ${SKILL_NAME} は kebab-case 検証済みのため、対象は当該スキルディレクトリ配下に限定される。
# git clean はディレクトリが存在しない場合に非ゼロ終了するため、revert_in_scope と同じく
# 存在確認してから呼ぶ(errexit 有効シェルでの途中 abort 防止。Issue #417)
if [[ -d ".agents/skills/${SKILL_NAME}" ]]; then
git clean -fd -- ".agents/skills/${SKILL_NAME}/"
fi
# 上記 || true / 存在確認ガードは失敗を握り潰し得るため、却下リバートが実際に完了したかを
# ここで実測してから次スキルへ進む。スキルディレクトリ配下が clean(porcelain 空)かつ
# skills-lock.json が clean(tracked/untracked 双方)でなければ fail-closed でループ全体を
# 停止する。
# - `git diff --quiet` は worktree vs index の比較(他スキルの承認済み stage 分には影響
# されない)であり、tracked な残留変更を検出する
# - ただし `git diff --quiet` は untracked ファイルを無視するため、これだけでは
# skills-lock.json が初回生成(未追跡)のまま残置されたケースを見逃す(`|| true` が
# 吸収する pathspec エラーと同じ穴。Bugbot 指摘)。`git ls-files --others` で untracked
# の残留も別途検出する
# 却下リバートが未完了のまま次スキルへ進むと、残留した却下対象の変更が次スキルの承認時に
# 一緒に stage され得るため、warning のみで継続してはならない(Issue #417。checker を
# 持つリポジトリ向けの却下フェンス(同期開始前 snapshot からの復元)と同じ fail-closed
# 扱いに揃える)
# `[[ -n "$(git status ...)" ]]` / `[[ -n "$(git ls-files ...)" ]]` は command substitution
# の終了コードを見ないため、権限・index 異常等でコマンド自体が失敗して空出力になった場合
# 「残留なし」と誤判定し得る(Issue #417 P0 指摘。CI codex-review 指摘と同一の穴)。
# 各出力を個別代入し、取得失敗を明示的に検知してから内容判定へ進む
if ! FINAL_STATUS_OUT="$(git status --porcelain -- ".agents/skills/${SKILL_NAME}/")"; then
echo "エラー: ${SKILL_NAME} 配下の残留確認(git status)に失敗しました。空出力を「残留なし」と誤判定しないため停止します(fail-closed)。原因を解消してから再実行してください。" >&2
exit 1
fi
if ! FINAL_LOCK_UNTRACKED_OUT="$(git ls-files -z --others --exclude-standard -- skills-lock.json)"; then
echo "エラー: skills-lock.json の未追跡残留確認(git ls-files)に失敗しました。空出力を「残留なし」と誤判定しないため停止します(fail-closed)。原因を解消してから再実行してください。" >&2
exit 1
fi
if [[ -n "${FINAL_STATUS_OUT}" ]] \
|| ! git diff --quiet -- skills-lock.json \
|| [[ -n "${FINAL_LOCK_UNTRACKED_OUT}" ]]; then
echo "エラー: 却下リバートが完了していません(${SKILL_NAME} 配下の残留変更、または skills-lock.json の worktree 差分・未追跡残留)。git status を確認し、手動復旧してから再実行してください(fail-closed。次スキルへは進めません)。" >&2
exit 1
fi
Due to the clean guard in Step 4, npx Since the directory in question is guaranteed to be clean (including untracked files) before execution,git clean the untracked files deleted by npx are limited to those created bygit clean The target of ${SKILL_NAME} is limited to only the kebab-case-validated
This revert is performed "before the next skill's npx skills add execution", soskills-lock.json only the portion for that skill is restored fromgit checkout -- is restored from the index rather than HEAD, so the hashes of other approved skills are retained in both the index and the working tree and are unaffected.
For rejection in repositories that have a checker, use the following(because pre-sync check and Step 5.5 apply may have staged the files of the skill in question or the durable patch into the index,git checkout --(index → worktree) alone will not revert it. Using the PRE_SYNC_TREEsaved in Step 4 before the checker's first execution (the index snapshot before the start of sync) as the source,limited to contract pathsrestore the index + worktree. Do not use a snapshot obtained after npx — the restore destination would become the raw upstream state, violating the contract of "reverting to before sync". For the entire index, git read-tree Do not use this either — it would rewrite the index even for out-of-scope paths and contradict the out-of-scope manual recovery guidance):
# PRE_SYNC_TREE は Step 4 が表示した同期開始前の snapshot hash。shell を跨いで変数が消えている
# 場合は表示済みの値を代入してから実行する。未設定のまま復元してはならない
: "${PRE_SYNC_TREE:?Step 4 の同期開始前 snapshot hash を設定してから実行する(未設定のまま復元しない)}"
# npx・apply が新規作成した未追跡ファイルは git clean で即削除せず一時ディレクトリへ退避する
# (checker が契約ディレクトリ内へ移動・新規作成したファイルの唯一のコピーであり得るため。
# 退避先を確認し、不要と判断してから手動で削除する)。退避(mktemp -d / mktemp /
# git ls-files / mkdir / mv)自体の失敗を検査せず git restore --worktree へ進むと、退避されなかった「唯一の
# コピー」が PRE_SYNC_TREE の内容で無音に上書きされ、データ喪失になる(Issue #418)。
# このフェンスはループ外側でユーザーが直接実行する手動手順のため、mktemp -d /
# mktemp / git ls-files の失敗は復元へ一切進まず即座に停止する(自動復元経路(Step 4 の
# restore_contract_scope)のような「index のみへ降格して継続」は行わない)
if ! CONTRACT_UNTRACKED_BACKUP_DIR="$(mktemp -d)"; then
echo "エラー: 未追跡ファイルの退避先ディレクトリ作成に失敗しました。復元の完全性を確認できないため、git restore には一切進みません(fail-closed)。原因を解消してから再実行してください。"
exit 1
fi
# skills-lock.json も対象に含める(checker が git rm --cached 等で未追跡化して内容変更した
# 場合、その唯一の内容を退避せずに restore で上書きしないため。通常は tracked で列挙されない)。
# パイプ経由の while はサブシェルで実行され git ls-files 自体の失敗が不可視になるため、
# 一時ファイルへ書き出してから読む。mktemp 自体の失敗も検査する(検査しないと、errexit
# オフのシェルでは空パスへのリダイレクトが後続で誤解を招くエラーになり、set -e 下では
# fail-closed メッセージも退避先の掃除もないまま即中断する。Issue #418 と同型)
if ! untracked_list="$(mktemp)"; then
echo "エラー: 未追跡ファイル列挙用の一時ファイル作成に失敗しました。復元の完全性を確認できないため、git restore には一切進みません(fail-closed)。原因を解消してから再実行してください。"
# この時点で退避先はまだ空(mv 前)のため、空ディレクトリのみ削除して片付ける
rmdir "${CONTRACT_UNTRACKED_BACKUP_DIR}" 2>/dev/null || true
exit 1
fi
if ! git ls-files -z --others --exclude-standard -- skills-lock.json ".agents/skills/${SKILL_NAME}/" scripts/local-patches/ > "${untracked_list}"; then
echo "エラー: 契約範囲内の未追跡ファイル列挙に失敗しました。復元の完全性を確認できないため、git restore には一切進みません(fail-closed)。原因を解消してから再実行してください。"
rm -f "${untracked_list}"
# この時点でも退避先はまだ空(mv 前)のため、空ディレクトリのみ削除して片付ける
rmdir "${CONTRACT_UNTRACKED_BACKUP_DIR}" 2>/dev/null || true
exit 1
fi
backup_failed=0
failed_paths=()
moved=0
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}"
rm -f "${untracked_list}"
echo "未追跡ファイルの退避先: ${CONTRACT_UNTRACKED_BACKUP_DIR}"
# 契約パス限定で index (+ 退避が全件成功していれば worktree も) を同期開始前へ復元する。
# この同期(pre-check・npx・apply)で生じた契約範囲の stage だけが取り除かれ、承認済みの
# 他スキル分・範囲外 path の index は一切変更されない。git restore は no-overlay が既定の
# ため、同期開始前 tree に無い tracked ファイルは契約パス内に限り index・worktree から
# 取り除かれる(pathspec は index にも照合されるため、snapshot 後に新規作成・stage された
# ファイルも取り除かれる)。tree にも index にも無い pathspec の不一致エラーのみ復元対象
# なしとして無視できるが、真の失敗と区別が付かないため path ごとに分離したうえで、成功
# 可否は下の復元後検証で判定する
restore_targets=(--staged --worktree)
if [[ "${backup_failed}" -eq 1 ]]; then
# 退避の完全性を確認できない以上、worktree への git restore は行わない
# (未退避の唯一のコピーを上書きするおそれがあるため)。index のみ復元へ降格する
restore_targets=(--staged)
echo "エラー: 契約範囲内の未追跡ファイルの退避に失敗しました。worktree の復元は行いません(fail-closed)。"
echo "退避できなかったパス: ${failed_paths[*]}"
if [[ "${moved}" -eq 1 ]]; then
echo "退避済み分は ${CONTRACT_UNTRACKED_BACKUP_DIR} に残しています。"
fi
fi
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
if [[ "${backup_failed}" -eq 1 ]]; then
echo "エラー: 未追跡ファイルの退避に失敗したため、契約範囲の復元は index のみで停止しました(worktree は未復元)。未退避のファイルを手動で退避してから、git restore --worktree --source=${PRE_SYNC_TREE} -- <path> で契約範囲の worktree を復旧し、再実行してください(fail-closed。この却下は完了とは扱わず、ループ全体を停止します)。"
exit 1
fi
# 復元後検証(fail-closed): 契約パスの index・worktree が同期開始前(PRE_SYNC_TREE)と一致し、
# 未追跡も残っていない(未追跡は上で退避済み)ことを実測してから完了と扱う。判定基準は
# HEAD ではなく PRE_SYNC_TREE(HEAD 基準の git status --porcelain だと、同期前から存在した
# 正当な staged 変更が復元完了後も非空として現れ、正しい復元を誤報する)。
# 検証に失敗した場合はループ全体を exit 1 で停止する(Bugbot Medium 指摘: 従来はここで
# echo するだけで完了扱いのまま次スキルへ continue していたため、「却下時は fail-closed」
# というコメント上の意図に反し、半端に復元された契約範囲(skills-lock.json・当該スキル・
# durable patch のいずれかに raw upstream + 同期前 check の stage が混在した状態)のまま
# 次スキルの npx が走り得た)。この却下フェンスは Step 4・5.5 の自動失敗経路と異なり
# ループの外側でユーザーが実行する手動手順のため、`revert_in_scope` の「次スキルへ
# continue」という既定動作とは切り離し、検証失敗時だけ明示的に停止する。
if git diff --cached --quiet "${PRE_SYNC_TREE}" -- skills-lock.json ".agents/skills/${SKILL_NAME}/" scripts/local-patches/ \
&& git diff --quiet "${PRE_SYNC_TREE}" -- skills-lock.json ".agents/skills/${SKILL_NAME}/" scripts/local-patches/ \
&& [[ -z "$(git ls-files --others --exclude-standard -- skills-lock.json ".agents/skills/${SKILL_NAME}/" scripts/local-patches/)" ]]; then
echo "復元完了(検証済み)"
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。この却下は完了とは扱わず、ループ全体を停止します)。"
exit 1
fi
The target is under the kebab-case-validated skill directory in question,skills-lock.json・durable patch(scripts/local-patches/) only, and it does not affect the staging of other approved skills or the index of out-of-scope paths. In thefailure pathof Steps 4 and 5.5, the same restoration is automatically executed by restore_contract_scope , so this manual fence is used only when the user rejects. If the post-restoration verification for rejection (the above fence) fails, do not treat the rejection itself as complete and stop the entire loop with exit 1 (the above default behavior of "if rejected, continue to the next skill" holds only when the post-restoration verification succeeds). If saving the untracked files (mktemp -d / mktemp / git ls-files / mkdir / mv) fails, do not perform any git restore to the worktree and stop fail-closed (so as not to silently overwrite the only copy that has not been saved).
Step 7: stage the approved skill (accumulate within the loop)
# 当該スキルのファイルのみをステージング(tracked 変更 + Step 5 で提示した未追跡ファイル)
git add skills-lock.json ".agents/skills/${SKILL_NAME}/"
# checker を持つリポジトリは、承認対象(Step 6 の最終 diff)に含めた durable patch の
# 変更も同じ承認単位で stage する
if [[ -f scripts/check-skill-local-patches.sh && -d scripts/local-patches/ ]]; then
git add scripts/local-patches/
fi
skills-lock.json is a single JSON file, so line-by-line partial staging is not realistic. However, because the pre-guard in Step 1 guarantees a clean state at the start of execution, staging the entire file includes only changes originating from sync, and unrelated edits will not be mixed in. By executing this command within the loop, even when syncing all skills for multiple skills, all processed skills are staged without excess or deficiency.In repositories that have a checker, successful Step 5.5 (apply + final check) is a prerequisite for stagingandcomputedHash Do not modify the value written by npx for the upstream version (do not update the hash with a local patch).
Step 8: Commit proposal (run only once after the loop)
After the loop completes, commit all staged approved skills together as a single commit.
git commit -m "$(cat <<'EOF'
chore(skills-lock): upstream の最新ハッシュと同期
<変更内容の要約>
EOF
)"
Ask the user whether it is OK to commit. If there were no approved skills (all rejected, or no diff), do not commit and inform the user of that.
skills CLI version pinning and update procedure
Why: npx skills add When run without a pinned version, npx, if not present in the local cache, immediately fetches and executes the current latest version from the registry without confirmation.skillsIf the (vercel-labs/skills) package were taken over, this would be a path to arbitrary code execution. Moreover, because this execution runsbeforeStep 5's diff check and Step 6's user approval, it cannot be prevented by exact-match verification of the source's Fandhe-AI/<repo> . Pinning to an exact version (X.Y.Z。dist-tag・^/~ ranges are forbidden) becomes the trust anchor.
How to decide the pinned version:
npm view skills versionCheck the current latest withnpm view skills repository.urlがvercel-labs/skillsVerify that it isnpm view skills time --jsonVerify with etc. that the publication date is not unnatural- Check the diff between the relevant tags and the release notes in the upstream repository, and adopt it if there are no problems
Update procedure:
scripts/skills-lock-update.shのSKILLS_CLI_VERSIONand, in the Step 4 fence of this file, update with theSKILLS_CLI_VERSIONをsame commit(make the values match exactly)node --test skills/sync-skills-lock/tests/Verify that both files match withuniversalVerify that is a valid agent id in the new version too. If it has changed to an invalid value, the CLI displays an error and becomes a no-op ofexit 0(measured: confirmed withskills@1.5.22), so if you operate without noticing, you end up in a state of "thinking you synced but nothing was updated." How to check: actually run 1 skill with--agent universalin a scratch repository, and verify thatInvalid agentsdoes not appear in the output and that.agents/skills/<name>/is actually updated- Actually run it with 1 skill, and verify that the diff is normal and that writes are limited to
.agents/skills/<name>/とskills-lock.jsononly (that other trees such asgit status --porcelainに.claude/do not appear) chore(sync-skills-lock): skills CLI を X.Y.Z へ更新commit with
fail-closed: If the pinned version cannot be resolved (the version does not exist, registry failure), npx exits non-zero. There is no path that silently falls back to the latest version, and rewriting to dist-tags or range specifications is also forbidden.npx On the failure path of , always execute the same out-of-scope write check as on the success path (the "defense in depth" described later). On both execution paths, disable errexit only for thenpx line (set +e/set -e), save the exit code from PIPESTATUS , and always execute the post-hoc out-of-scope check on either the success or failure path (underset -e/pipefail , lacking this disabling means thatnpx terminates abnormally on the spot along with the entire pipeline on a non-zero exit, and partial writes are left behind without reaching post-inspection/revert). However, the restore method differs by path: the Step 4 fence side of this file saves the caller shell's errexit state ($-) and only if it was originally enabled, set -e performs a conditional restore to re-enable it, and does not perform an unconditional set -e (because this fence may be copy-pasted and executed into an agent interactive shell where errexit is disabled, an unconditional restore would newly enable errexit in the caller shell, and subsequent Step 6 rejection commands etc. in the same shell could abort midway on an unguarded non-zero exit). On the other hand, scripts/skills-lock-update.sh declares set -euo pipefail itself at the top of the file, so the premise that errexit is always enabled holds, and that script side correctly keeps the unconditional set -e restore (see the comments in that script for details).scripts/skills-lock-update.sh is executed standalone, the entire script is stopped with exit 1 (see the comments in that script for why set +e/set -e is inserted only around this line). On the other hand, in the Step 4 fence of this file (the path that processes multiple skills in a loop), when a failure ofnpx is detected, after post-inspection, revert the partial writes for that skill using the same procedure as the Step 6 rejection (git checkout -- / git clean -fd), then skip (continue) and proceed to the next skill — this is the same control flow as the other skip branches in Step 1/3, and does not stop the entire loop. Skipping without interposing post-inspection/revert cannot prevent leftover changes when the failure occurs after partial writes (within scope, the next skill's git add(Step 7) would stage them together with approved changes; outside scope, they could be misidentified by subsequent processing as a 'pre-existing dirty state'), so both are mandatory procedures.Detection of out-of-scope writes (exit 1) is a separate stop path from this, andcontinue stops the entire loop rather than(see the next section 'Restricting the Write Scope' for details). This stop path also extends to the success path (NPX_STATUS -eq 0) as well as npx failurepath: if post-inspection after a failure detects an out-of-scope diff or state signature mismatch, the same criteria as the success path set NPX_STATUS_OUT_OF_SCOPE_DIRT=1 and stop the entire loop with exit 1 , and do not proceed to the next skill withcontinue (because skipping as-is could cause un-reverted out-of-scope diffs to be misidentified as a 'pre-existing dirty state').
Restricting the Write Scope (--agent universal and out-of-scope detection)
npx skills add without agent/path restrictions, it may also write to each agent-oriented tree detected by the CLI (.claude/skills/ etc.). However, the clean guard (prerequisite section, Step 4), preview (Step 5), revert (Step 6), approval git add(Step 7) are all skills-lock.json と .agents/skills/<name>/ Since this targets only , if a write outside the scope occurs, (1) WIP overwrite, (2) review (preview) bypass, and (3) "dirty residue after reporting clean" can occur.
Prevent with 2 layers:
- Primary defense (
--agent universal): Add--agent universalto the npx call in Step 4, and limit the write destination to the union store (.agents/skills/<name>/)とskills-lock.jsononly. Do not use individual agent specification (claude-code, etc.) because it copies directly into the target tree without going through.agents/skills/and changes the layout - Defense in depth (snapshot comparison before/after execution + state signature comparison): Immediately before and after the npx execution in Step 4, take a snapshot of the entire repository with
git status --porcelain -z -ualland compare the differences excluding those within scope (skills-lock.json/.agents/skills/<name>/). In addition, the entire repository root (exclusions are within scope and, among.git, onlygit statusthat can fluctuate during the before/after snapshots when this flow itself executes.git/indexand its temporary lock [only exact matches of.git/index.lock. Residual persistent locks such as.git/config.lock・.git/HEAD.lockare subject to signing and are detected as a signature mismatch]..git/objects・.git/refs・packed-refs・HEAD・logs・worktrees・modulesInclude the rest of the entire area including.git/config・.git/hooks/and persistent Git metadata such as.agents/.agents/skillsin the signing target to also detect history/reference alterations, hook planting, and configuration changes.repo_state_signature(path_stateitself is a signing target only if it existed before execution, and only if it did not exist before execution, omit its own metadata to allow legitimate parent creation on first install) with a state signature at.starting fromrepo_state_signature. python3. Consolidate into one (regular files: mode + content sha256; symbolic links: mode + link target sha256; directories/gitlinks: sha256 aggregating their own mode + all entries under them recursively by relative path, permissions, and content hash) and compare before and after.git ls-files --stage+git ls-files -vfurther concatenates the sha256 of the index's logical state (.git/index. entries, skip-worktree / assume-unchanged bits) to also detect index alterations behind the pruned.gitignoreas a before/after mismatch. Since git does not track directory modes, a method that only signatures paths appearing in git status cannot in principle detect chmod on out-of-scope directories or overwrites of files targeted by--agent universal, so the scan is performed on the entire working tree rather than the path set from status. Only when not just the status + path record but also the entire signature matches is it determined that there is "no change"; if there is a difference in either, it means a write thatexit 1should have suppressed occurred, so revert within scope and stop the entire loop withUnlike other skip branches (continue), this detection does not continue the loop: Because out-of-scope contamination is not automatically reverted, so if you continue, the final report may say "clean" while in reality dirty residue remains. Other skills that were approved and staged up to the point of stopping will remain in the index, so the operator should check withgit statusand then, for only the necessary amount,git commitorgit resetdecide whether to revert with
As a precondition for both layers, first verify before running npx that the execution location is the main worktree (git rev-parse --absolute-git-dir と --git-common-dir normalize to a physical path and compare; if they do not match, stop fail-closed. In a linked worktree, the actual Git directory [.git/worktrees/<name>/ and the shared side's refs・logs・configand objects] are outside the working tree and are not included in the scan target of repo_state_signature , so even if npx modifies the shared repository via git update-ref etc., all inspections before and after will match). This equality comparison only excludes linked worktrees and does not guarantee the location of the actual Git directory, so next also verify that "the actual Git directory is the .git real directory directly under the working tree" (--show-toplevel normalize to a physical path and require an exact match between the physical path of --absolute-git-dir and <toplevel>/.git , and further lstat <toplevel>/.git to require that it is a directory, not a symlink.git clone --separate-git-dir・submodule checkout・.git In a configuration where is a symlink, git-dir and common-dir remain matching while the entity is outside the working tree, falling into the same undetectable situation). Next, lstat-verify each element of the allowed destination path (.agents / .agents/skills / .agents/skills/<name>), and if an existing element is a symlink or a non-directory, do not run npx and stop fail-closed (at the beginning of the Step 4 fence). The out-of-scope inspection excludes allowed destinations by "path string", so if the path is a symlink pointing outside the repository before execution, writes by npx to the link target will not appear in any inspection. Only non-existent elements are allowed as legitimate new creation for the first installation.skills-lock.json is also lstat-verified at the same stage: because it is excluded by path string in both the state signature's prune and the porcelain filter, if it is an outward-facing symlink before execution, writes by npx to the link target will not appear in any inspection. If it exists, require that it is a regular file; if it is a symlink or non-regular file, do not run npx and stop fail-closed (non-existence is allowed as first-time generation). In addition, the allowed destinationunderis also scanned with find -type l before running npx, and if there is even one existing symlink, do not run npx and stop fail-closed (under the allowed destination is excluded from the signature by prune-under , so even if npx follows an existing symlink under it and writes outside the repository, it will not appear in the before/after signature comparison. Only prior rejection is the defense). Furthermore, under the allowed destination,existing ignored files(.DS_Store etc.) are evacuated with their contents and mode to a temporary backup directory before running npx (cp -p- Preserve the relative path structure), and after execution compare with the backup to detect changes/deletions and restore from the backup (on the success path, warn and continue processing; on the failure path, revert_in_scope and restore. Only if the restoration itself fails, preserve the backup directory and exit non-zero).
Because this pre-check only looks at the starting point (TOCTOU), layer on four more defenses:
- Signature of the allowed destination element itself (
prune-under): the state signature does not "exclude the entire entry" of the existing allowed destination directory, but excludes only its contents and signs the element itself (type, mode, symlink target) before and after (SKILL_DIR_SIG_SPECのprune-under. Only for the first install, when it did not exist before execution,pruneexcludes everything). If npxduring executionreplaces the allowed destination with an outward symlink, it is detected as a mismatch between the before and after signatures - Post-execution re-validation (
verify_scope_path_after_run): after npx execution (before acquiring the post-execution signature), lstat all elements of the allowed destination path again, and if any existing element is a symlink or non-directory, stop fail-closed. This also rejects the case where, on first install, npx created.agentsitself as an outward symlink (a path that does not appear in the signature due to omission of parent elements / pruning of the allowed destination). On detection (SCOPE_PATH_COMPROMISED=1)のrevert_in_scopeはskills-lock.jsononly restores, and does not perform anygit checkout/git clean/ ignored deletion under the allowed destination — to avoid accidents that delete or rewrite files at the link target (outside the repository) through the symlink, guide the user to manually check the link target's contents and whether there were writes outside the repository, and exit non-zero (stop the entire loop) - Post-execution scan for symlinks under the destination: after npx execution (
verify_scope_path_after_runfollowing) scan the allowed destinationunderagain withfind -type l. The pre-scan only guarantees 0 items before execution, and outward symlinks created by npxduring executionunder the destination and writes through them do not appear in either the before/after comparison, which excludes the contents from the signature, or the re-validation, which only looks at 3 path elements. Since any symlink found afterward was created during npx execution, if even one is detected (as well as failure of the scan itself),SCOPE_PATH_COMPROMISED=1fall back to the preservation path: skip allgit checkout/git clean/ ignored deletion / restoration of existing ignored under the allowed destination, perform only the restoration ofskills-lock.json, explicitly state the detected path and backup directory, and exit non-zero (guide manual recovery. Stop the entire loop) - Post-execution
skills-lock.jsonre-validation (verify_lock_file_after_run): after npx execution (verify_scope_path_after_runfollowing) lstatskills-lock.jsonagain, and if it exists but is not a regular file, stop fail-closed (the case of nonexistent before execution -> created also requires a regular file). If npxduring executionskills-lock.jsonis replaced with an outward-facing symlink and written to the link target, the same path is excluded from both the signature'spruneand the porcelain filter, so it does not appear in any check. Upon detection (LOCK_FILE_COMPROMISED=1)のrevert_in_scopedoes not perform a plaingit checkout -- skills-lock.json(which could write to the link target), and after removing the symlink itself withrm(without following the link target), it writes the index contents back as a regular file via checkout. Any unexpected entity other than a symlink (such as a directory) is left completely untouched, manual recovery is advised, and the process exits with a non-zero status (halting the entire loop)
Detection limitations: git status --porcelain は .gitignore does not report targets, so porcelain snapshot comparison (criterion 1)alonecannot detect writes to ignored paths. However, state signature comparison (criterion 2) scans the entire working tree including .gitignore targets and incorporates them into content hashes, so writes to ignored paths can also be detected as signature mismatches (the detection and halt themselves still work; the relevant path simply does not appear in the porcelain-derived report list). Signature comparison can also detect cases where out-of-scope files that were already M (tracked, modified) or ?? (untracked) before npx execution have only their contents and permissions overwritten without changing either the status string or path, as well as cases where directories or gitlinks (including uninitialized submodules) are rewritten to a different state..git under is also .git/index と .git/index.lock the entire area excludingobjects・refs・packed-refs・HEAD・logs・worktrees・modules・config・hooks、.git/config.lock ) is subject to signing, and modifications to history and references can also be detected. In-scope revert (revert_in_scope) matches against the full file inventory under the permitted destinations obtained before npx execution (find -print0, including ignored, NUL-separated) to individually delete only the " .gitignore target files newly created by npx", so ignored files written under.agents/skills/<name>/ are not left behind on abnormal termination, whileignored files that existed before execution (such as.DS_Store ) are not deleted and are preserved(git clean -fdx is not used because it would also delete existing ignored files). Cases where npx overwrites or deletes existing ignored files do not appear in signature comparison since the permitted destinations are excluded from the signature, so they are detected by comparison with a backup including contents and mode obtained before npx execution (IGNORED_BACKUP_DIR), andrestore_preexisting_ignored restores from the backup (on the success path, it warns and continues; on the failure path, it restores as part of revert_in_scope . If restoration fails, the backup directory is preserved and the process exits with a non-zero status). This restoration can assume regular files only because the symlink scan under the permitted destinations before npx execution (find -type l) rejects existing symlinks in a fail-closed manner. The constraints that actually remain are the following two: (1) writes to the pruned .git/index・.git/index.lock のas a filecannot be detected (this flow's own git status always updates the index after npx execution, so including it in the signing target would always cause a false positive). However, the index'slogical state(addition/deletion of entries, blob replacement, skip-worktree / assume-unchanged bits) is git ls-files --stage + git ls-files -v is detected by the before/after comparison (index_state_signature), so the remaining blind spots are limited to stat cache-equivalent information and .git/index.lock itself. (2) .git As the cost of making most of it a signing target, concurrent git operations during synchronous execution (including other worktrees) may stop the sync as a signature mismatch (false positive) (see Notes. This is a design decision that favors the fail-closed side). This residual risk is judged to be small, combined with the fact that the primary defense (--agent universal) suppresses the write itself.
Notes
- Mid-way rejection during all-skill sync: Since approval and staging are performed one skill at a time, even if you reject partway through, the staging of already-approved skills is retained. Commit all at once after processing all skills
- Do not perform concurrent git operations (including other worktrees) in this repository during synchronous execution: The state signature is
.gitincludes under.git/indexand temporary locks in the signing target, so concurrent operations such as fetch, commit, checkout, etc. changerefs・objects・logs・worktreesand may stop the sync as a signature mismatch (false positive). This design favors stopping (fail-closed) rather than missing detection (fail-open); if a false positive occurs, stop the concurrent operations and re-run - Cannot be executed in a linked worktree:
git worktree addIn a worktree created with.git,.git/worktrees/<name>/is a regular file pointing to the gitdir, and the actual Git directory (refs・logs・configand objects) falls outside the state signature scan target, so the worktree verification at the beginning of Step 4 (--absolute-git-dirと--git-common-dirphysical path comparison) stops in a fail-closed manner before npx execution. Re-run in the main worktree - Cannot be executed even in a configuration where the actual Git directory is outside the working tree:
git clone --separate-git-dir(.gitis a gitfile pointing to the gitdir), submodule checkout, and configurations where.gitis a symlink, git-dir and common-dir match while the entity is outside the working tree, falling into the same undetectable state as a linked worktree, so the additional verification at the beginning of Step 4 (--show-toplevelstrict match with the physical path +<toplevel>/.gitlstat) stops in a fail-closed manner before npx execution..gitis the actual directory - Cannot be executed when the permitted destination path is a symlink:
.agents/.agents/skills/.agents/skills/<name>is a symlink or not a directory, the entity verification at the beginning of Step 4 stops in a fail-closed manner before npx execution (because writes outside the repository through a symlink do not appear in the out-of-scope check). Replace it with the actual directory and re-run. npx isduring executionEven when the destination is replaced with a symlink (including when it was created as a symlink during the initial installation), the signature of the destination element itself (prune-under) and the post-execution re-verification (verify_scope_path_after_run) stop in a fail-closed manner, and at this time no deletion-type operations such asgit cleanunder the destination are performed (to avoid deleting the link target through the symlink; see the "Write scope restriction" section). Even when an existing symlink is present under the destinationunder the destination, thefind -type lscan before npx execution stops in a fail-closed manner (because the subtree is excluded from the signature, and writes to the link target do not appear in any check). Even when npxduring executioncreates a symlink under the destination during execution, the rescan after npx execution (find -type l) detects it and falls back to the same preservation path (skipping deletion-type operations and restoration under the destination, preserving the backup, and stopping). Replace the relevant symlink with a real file or delete it, then re-run - Changes to existing ignored files are automatically restored: When npx overwrites or deletes ignored files that existed under the destination before execution (such as
.DS_Store), this is detected by comparison with the pre-execution backup, and the contents and mode are restored from the backup (on the success path, a warning is issued and synchronization continues). If restoration fails, the process stops while leaving the backup directory (the path is shown in the error message), so recover manually from there - If an out-of-scope write is detected, the entire loop stops: See the "Write scope restriction" section.
continuenotexit 1, so processing stops with the other approved and staged skills remaining in the index. Manuallygit statuscheck and decide whether to commit orgit resetrun skills-lock.jsoncannot be executed if it is a symlink or non-regular file:skills-lock.jsonis excluded from both the signaturepruneand the porcelain filter, so if it is a symlink, writes to the link target (including outside the repository) do not appear in any check. The real-entity verification at the beginning of Step 4 stops in a fail-closed manner before npx execution (replace it with a real file, then re-run). Even when npxduring executionreplaces it during execution, the post-execution re-verification (verify_lock_file_after_run) stops, and restoration removes the symlink withrmand then writes it back withgit checkout(to avoid writes to the link target; see the "Write scope restriction" section)skills-lock.jsonstages everything on the premise that it is clean before execution: Since it is a single JSON file, partial staging is not realistic. The pre-check in Step 1 guarantees cleanliness and prevents changes other than those originating from sync from being mixed in- root's
skills-lock.jsononly edit: Do not touch anything under submodules - source exact-match verification (required):
sourceをOWNER/REPOafter normalizing toFandhe-AI/<repo>Entries that do not exactly matchcontribute-skillare skipped (the same safety valve as../). With prefix matching, values containingskills-lock.jsonwould pass through, so verification is performed with an exact-match regular expression. To defend against tampering or misconfiguration of npx skills add --yesskips the overwrite confirmation: if there are destructive changes in upstream,git diffmust always be used to check the contents- Handling of new skills: skills that exist locally but are not registered in upstream (such as
contribute-skill,sync-skills-lockitself) are registered after the upstream merge. Before the merge, do not writecomputedHashon your own - The Step 5 preview does not modify the index: use
git add -N(intent-to-add) butgit diff --no-indexto display untracked files. This is because the rejection path in Step 6 depends on the design of restoring the hashes of other approved skills from the index viagit checkout --, and mixing in i-t-a entries interferes with that restoration design - Run the skills CLI with a pinned version:
npx skills addmust not be run with an unpinned version. For how to decide the pinned version and the update procedure, refer to the section "Pinning and updating the skills CLI version" - Protection of local patches (mandatory for repositories with a checker): do not omit the order of checker before npx (Step 4) → reapplication before approval + final verification + contract scope verification (Step 5.5) → stage only on success (Step 7). If the checker is non-zero or only the ledger (
.agents/skills/LOCAL-PATCHES.md) exists, stop fail-closed, and do not stage or commit a state in which the local patch is missing. Durable patches that apply may change (scripts/local-patches/) are treated as the same set in all of approval (final diff in Step 6 + re-running the untracked preview), stage (Step 7), and rejection (index restoration fromPRE_SYNC_TREE), and synchronization is not started while unstaged WIP remains in the same directory (fail-closed in Step 4).computedHashmaintains the upstream version's value
Execution in a sandbox environment
This skill requires GitHub operations over the network (such as fetching the upstream repository vianpx skills add ). The relevant commands are executed with the sandbox disabled on a per-command basis. It cannot be executed in an environment where network blocking cannot be lifted.
Verification
After committing, confirm completion with the following.
# skills-lock.json が更新済みであることを確認
git show HEAD -- skills-lock.json | grep computedHash
# 差分なし(sync 完了)を確認
git status --porcelain skills-lock.json
- The commit includes the
computedHashupdate of the skill to be synced - There are no remaining changes in staged or unstaged files
Manual regression check for untracked file visualization
The case where files have increased in upstream (whether the Step 5 preview extension is working) can be checked without running npx by simulating untracked files. Check both the addition of a flat file and the typical case where upstream adds a whole new subdirectory (such asreferences/ ).Note that the verification method in step 3 differs between the two cases
(described later).
# 1a. clean な状態で検証用ファイル(フラット)を作成し、npx が新規ファイルを増やした直後の状態を再現する
touch ".agents/skills/${SKILL_NAME}/__preview_regression_check__.md"
# 1b. 新規サブディレクトリごと追加されるケースも作成する
mkdir -p ".agents/skills/${SKILL_NAME}/__preview_regression_dir__"
touch ".agents/skills/${SKILL_NAME}/__preview_regression_dir__/new.md"
# 2. Step 5 のプレビュー部(上記コマンド)を実行し、両方の検証用ファイルの内容(0 byte でも
# 「新規(未追跡)ファイル — 承認時に git add で取り込まれる集合:」の一覧に、サブディレクトリ
# 配下のファイルも含めてフルパスで名前が出ること)が表示されることを確認する
# (プレビューは git ls-files ベースのため、ディレクトリではなく個々のファイルパスを列挙する)
# 3. git clean -fdn(dry-run)の一覧と照合する。
# フラットファイルはプレビューの行と `git clean -fdn` の行が完全一致する。
# 新規サブディレクトリは `git clean -fdn` が個々のファイルではなく親ディレクトリを
# まとめて1行(`Would remove <dir>/`)で報告するため、行単位の完全一致では照合できない。
# この場合はプレビューの各ファイルパスが `git clean -fdn` のいずれかの出力行(ファイル
# 自身、またはその祖先ディレクトリ)で始まることを確認する(前方一致で照合する)。
# 完全一致を要求すると、正常に動作しているプレビューを「壊れている」と誤診断する。
git clean -fdn -- ".agents/skills/${SKILL_NAME}/"
# 4. 検証用ファイル・ディレクトリを削除して原状復帰する
rm ".agents/skills/${SKILL_NAME}/__preview_regression_check__.md"
rm -rf ".agents/skills/${SKILL_NAME}/__preview_regression_dir__"
Relationship with existing skills
contribute-skillIt is recommended to operate this skill after the skill revision has been merged upstreamcreate-commitfollows the Conventional Commits of- As a collection of executable commands, refer to
scripts/skills-lock-update.shrefer to