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