From 24e6b84add7f8739ad3be4c49548c693485d2553 Mon Sep 17 00:00:00 2001 From: Bastien Chanot Date: Sat, 27 Jun 2026 16:55:39 +0200 Subject: [PATCH] =?UTF-8?q?feat(deploy):=20deploy-commit.sh=20=E2=80=94=20?= =?UTF-8?q?allowlist=20surgical=20commit=20for=20.claude/deploy/?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Ho5EQCFTSvYamuRtVZpp2d --- lib/deploy-commit.sh | 55 +++++++++++++++++++++++++++++++++ lib/tests/deploy-commit.test.sh | 45 +++++++++++++++++++++++++++ 2 files changed, 100 insertions(+) create mode 100644 lib/deploy-commit.sh create mode 100644 lib/tests/deploy-commit.test.sh diff --git a/lib/deploy-commit.sh b/lib/deploy-commit.sh new file mode 100644 index 0000000..e6b40e2 --- /dev/null +++ b/lib/deploy-commit.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +# deploy-commit.sh — surgical commit for the .claude/deploy/ runbook family. +# Allowlist scope = .claude/deploy/ ONLY (inverse of doc-commit's .claude exclusion). +set -u + +_in_git_repo() { git rev-parse --is-inside-work-tree >/dev/null 2>&1; } + +_unsafe_state() { # 0 = unsafe + local g; g=$(git rev-parse --git-dir 2>/dev/null) || return 0 + git symbolic-ref -q HEAD >/dev/null 2>&1 || return 0 # detached HEAD + [ -e "$g/MERGE_HEAD" ] || [ -d "$g/rebase-merge" ] || \ + [ -d "$g/rebase-apply" ] || [ -e "$g/CHERRY_PICK_HEAD" ] && return 0 + return 1 +} + +_out_of_scope() { # 0 = forbidden, 1 = in scope + case "$1" in + *..*) return 0 ;; # traversal — forbidden FIRST + .claude/deploy/*) return 1 ;; # allowed + *) return 0 ;; # everything else forbidden + esac +} + +_scope_violations() { local p; for p in "$@"; do _out_of_scope "$p" && printf '%s\n' "$p"; done; } + +_changed_only() { # echo passed files that actually have changes + local p; for p in "$@"; do + [ -n "$(git status --porcelain -- "$p" 2>/dev/null)" ] && printf '%s\n' "$p"; done +} + +cmd="${1:-}"; shift || true +_in_git_repo || { echo "deploy-commit: not a git repo" >&2; exit 2; } + +case "$cmd" in + pending) + [ "$#" -gt 0 ] || { echo "deploy-commit: pending needs file args" >&2; exit 2; } + [ -n "$(_changed_only "$@")" ] && exit 0 || exit 1 ;; + commit) + msg="${1:-}"; shift || true + [ -n "$msg" ] && [ "$#" -gt 0 ] || { echo "deploy-commit: commit needs ..." >&2; exit 2; } + mapfile -t violations < <(_scope_violations "$@") + if [ "${#violations[@]}" -gt 0 ]; then + { echo "deploy-commit: REFUSED — path(s) outside .claude/deploy/ allowlist:"; + printf ' - %s\n' "${violations[@]}"; + echo "deploy-commit: NOTHING committed. Caller must pass only .claude/deploy/ files."; } >&2 + exit 4 + fi + _unsafe_state && { echo "deploy-commit: unsafe git state (detached/merge/rebase) — not committing" >&2; exit 3; } + mapfile -t changed < <(_changed_only "$@") + [ "${#changed[@]}" -gt 0 ] || exit 1 + git add -- "${changed[@]}" + git commit -q -m "$msg" -- "${changed[@]}" || { echo "deploy-commit: git commit failed" >&2; exit 1; } + git rev-parse --short HEAD ;; + *) echo "usage: deploy-commit.sh pending ... | commit \"\" ..." >&2; exit 2 ;; +esac diff --git a/lib/tests/deploy-commit.test.sh b/lib/tests/deploy-commit.test.sh new file mode 100644 index 0000000..3806943 --- /dev/null +++ b/lib/tests/deploy-commit.test.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +# lib/tests/deploy-commit.test.sh +set -u +H="$(cd "$(dirname "$0")/.." && pwd)/deploy-commit.sh" +pass=0; fail=0 +mkrepo() { local d; d=$(mktemp -d); git -C "$d" init -q; git -C "$d" config user.email t@t; + git -C "$d" config user.name t; mkdir -p "$d/.claude/deploy"; printf 'x\n' >"$d/seed"; + git -C "$d" add seed; git -C "$d" commit -q -m seed; printf '%s' "$d"; } +check() { if [ "$2" = "$3" ]; then pass=$((pass+1)); else fail=$((fail+1)); + printf 'FAIL %s: got[%s] want[%s]\n' "$1" "$2" "$3"; fi; } + +d=$(mkrepo); printf 'run\n' >"$d/.claude/deploy/PROCEDURE.md" +out=$( cd "$d" && bash "$H" commit "docs(deploy): t" .claude/deploy/PROCEDURE.md ); rc=$? +check T1-rc "$rc" 0 +check T1-committed-only "$(git -C "$d" show --name-only --format= HEAD)" ".claude/deploy/PROCEDURE.md" +check T1-hash-nonempty "$([ -n "$out" ] && echo y || echo n)" y + +d=$(mkrepo); printf 'b\n' >"$d/src.txt" +( cd "$d" && bash "$H" commit "x" src.txt ) >/dev/null 2>&1; check T2-out-of-scope-rc "$?" 4 + +d=$(mkrepo) +( cd "$d" && bash "$H" commit "x" ".claude/deploy/../memory/secret" ) >/dev/null 2>&1 +check T3-traversal-rc "$?" 4 + +d=$(mkrepo); printf 'p\n' >"$d/.claude/deploy/PROCEDURE.md"; printf 's\n' >"$d/src.txt" +( cd "$d" && bash "$H" commit "x" .claude/deploy/PROCEDURE.md src.txt ) >/dev/null 2>&1 +check T4-mixed-refuses-all "$?" 4 +check T4-nothing-committed "$(git -C "$d" rev-list --count HEAD)" 1 + +d=$(mkrepo); git -C "$d" checkout -q --detach +printf 'p\n' >"$d/.claude/deploy/PROCEDURE.md" +( cd "$d" && bash "$H" commit "x" .claude/deploy/PROCEDURE.md ) >/dev/null 2>&1 +check T5-unsafe-rc "$?" 3 + +d=$(mkrepo) +( cd "$d" && bash "$H" pending .claude/deploy/PROCEDURE.md ); check T6-pending-clean-rc "$?" 1 + +d=$(mkrepo); printf 'p\n' >"$d/.claude/deploy/PROCEDURE.md" +printf 'i\n' >"$d/.claude/deploy/INCIDENTS.md"; printf '{}\n' >"$d/.claude/deploy/STATE.json" +( cd "$d" && bash "$H" commit "docs(deploy): learn" .claude/deploy/PROCEDURE.md \ + .claude/deploy/INCIDENTS.md .claude/deploy/STATE.json ) >/dev/null 2>&1 +check T7-atomic-rc "$?" 0 +check T7-three-files "$(git -C "$d" show --name-only --format= HEAD | grep -c deploy)" 3 + +printf 'PASS=%s FAIL=%s\n' "$pass" "$fail"; [ "$fail" -eq 0 ]