diff --git a/hooks/pre-tool-scope-guard b/hooks/pre-tool-scope-guard index 22d9515..a9c3e72 100755 --- a/hooks/pre-tool-scope-guard +++ b/hooks/pre-tool-scope-guard @@ -10,7 +10,8 @@ # — git push / gh pr create when locked plan hash mismatches # — git push / git commit to main or master (unless SUPERPOWERS_ALLOW_DEFAULT_BRANCH=1) # Write / Edit / MultiEdit tool -# — *.scope-lock files (unless SUPERPOWERS_SCOPE_LOCK_WRITE=1) +# — *.scope-lock files (agent path: hooks/scope-lock-apply via Bash; +# operator path: SUPERPOWERS_SCOPE_LOCK_WRITE=1) # — docs/plans/*.md when plan is Locked (unless SUPERPOWERS_PLAN_LOCK_WRITE=1) # # Global opt-out: set SUPERPOWERS_HOOKS_DISABLE=1 in the *operator's* terminal @@ -141,10 +142,23 @@ case "$tool_name" in while IFS= read -r fpath; do [ -z "$fpath" ] && continue - # ── 5. scope-lock files (always blocked unless sentinel env var) ───── + # ── 5. scope-lock files ────────────────────────────────────────────── + # Agent path: bash "${CLAUDE_PLUGIN_ROOT}/hooks/scope-lock-apply" + # Operator path (pre-session): export SUPERPOWERS_SCOPE_LOCK_WRITE=1 if printf '%s' "$fpath" | grep -qE '\.scope-lock$'; then if [ "${SUPERPOWERS_SCOPE_LOCK_WRITE:-}" != "1" ]; then - block "Writing to '$(basename "$fpath")' is blocked — .scope-lock files are written exclusively by the scope-lock skill during alignment-check PASS. Direct edits break the manifest integrity guarantee and allow silent scope tampering. To update the lock legitimately: go through the unlock path (recording-decisions → update manifest → re-run alignment-check), which will regenerate the lock file." + _reason="Writing to '$(basename "$fpath")' is blocked —" + _reason="${_reason} .scope-lock files must be written by the scope-lock skill's helper," + _reason="${_reason} not via the Write tool." + _reason="${_reason} Run: bash \"\${CLAUDE_PLUGIN_ROOT}/hooks/scope-lock-apply\" ." + _reason="${_reason} The helper extracts the Scope Manifest section, computes its sha256," + _reason="${_reason} and writes the lock file via shell redirection (which is not blocked)." + _reason="${_reason} Direct Write/Edit calls break the manifest integrity guarantee" + _reason="${_reason} and allow silent scope tampering." + _reason="${_reason} To update the lock legitimately after a scope reduction:" + _reason="${_reason} use the unlock path (recording-decisions → update manifest" + _reason="${_reason} → re-run alignment-check → re-run scope-lock-apply)." + block "$_reason" fi fi diff --git a/hooks/scope-lock-apply b/hooks/scope-lock-apply new file mode 100755 index 0000000..5dc858a --- /dev/null +++ b/hooks/scope-lock-apply @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +# hooks/scope-lock-apply +# Compute and write the .scope-lock file for a plan. +# +# Usage: scope-lock-apply +# +# Extracts the "## Scope Manifest" section from , computes its +# sha256, and writes the digest to .scope-lock via shell +# redirection. +# +# This helper exists because the Write/Edit/MultiEdit tools are blocked for +# *.scope-lock paths by hooks/pre-tool-scope-guard (to prevent silent scope +# tampering by implementation skills or subagents). The scope-lock skill is +# the only legitimate writer of these files, and calling this helper via the +# Bash tool is the sanctioned path — it uses shell redirection rather than +# the Write tool, so the guard never fires. +# +# The hash format is a single bare hex sha256 digest, which is exactly what +# tests/plan-scope-check.sh --verify-lock expects: +# expected="$(awk 'NF && !/^#/ {print; exit}' "$lock")" + +set -euo pipefail + +plan="${1:-}" + +if [ -z "$plan" ]; then + printf 'Usage: scope-lock-apply \n' >&2 + exit 3 +fi + +if [ ! -f "$plan" ]; then + printf 'Error: plan file not found: %s\n' "$plan" >&2 + exit 1 +fi + +# Verify the plan has a Scope Manifest section before attempting to hash it. +if ! grep -qE '^## Scope Manifest[[:space:]]*$' "$plan"; then + printf 'Error: no "## Scope Manifest" section found in %s\n' "$plan" >&2 + printf 'The manifest section is required before locking. See skills/scope-lock/SKILL.md.\n' >&2 + exit 1 +fi + +# Compute sha256 of the manifest section. +# The awk pattern below is intentionally identical to extract_manifest() in +# tests/plan-scope-check.sh — the byte stream must match exactly so that +# tests/plan-scope-check.sh --verify-lock produces the same hash. +# Extract to a temp file to avoid duplicating the awk script across sha256 +# backend branches. +tmpfile=$(mktemp) +trap 'rm -f "$tmpfile"' EXIT + +awk ' + /^## Scope Manifest[[:space:]]*$/ { in_section = 1; print; next } + in_section && /^## / { in_section = 0 } + in_section { print } +' "$plan" > "$tmpfile" + +if command -v sha256sum >/dev/null 2>&1; then + hash=$(sha256sum < "$tmpfile" | awk '{print $1}') +elif command -v shasum >/dev/null 2>&1; then + hash=$(shasum -a 256 < "$tmpfile" | awk '{print $1}') +else + printf 'Error: sha256sum or shasum is required but neither was found\n' >&2 + exit 1 +fi + +if [ -z "$hash" ]; then + printf 'Error: failed to compute sha256 for %s\n' "$plan" >&2 + exit 1 +fi + +lock_file="${plan}.scope-lock" +printf '%s\n' "$hash" > "$lock_file" +printf 'scope-lock-apply: %s written (sha256=%s…)\n' "$lock_file" "${hash:0:12}" diff --git a/skills/alignment-check/SKILL.md b/skills/alignment-check/SKILL.md index 082407e..879b131 100644 --- a/skills/alignment-check/SKILL.md +++ b/skills/alignment-check/SKILL.md @@ -130,7 +130,7 @@ Re-run alignment check after revision. **Max 2 revision cycles** before escalati After alignment passes, **lock the plan's scope** so subsequent execution cannot silently rescope. Invoke `superpowers:scope-lock` with the plan path. The scope-lock skill: 1. Stamps the plan's `**Status:**` line with `Locked `. -2. Computes the manifest's sha256 and writes `.scope-lock`. +2. Writes the lock file by running `bash "${CLAUDE_PLUGIN_ROOT}/hooks/scope-lock-apply" ` (do **not** use the Write tool — it is blocked for `*.scope-lock` paths). The helper computes the manifest's sha256 and writes `.scope-lock` via shell redirection. 3. Commits both files (`chore: lock scope for (alignment passed)`). After the lock is in place, proceed to execution: diff --git a/skills/scope-lock/SKILL.md b/skills/scope-lock/SKILL.md index 2634c64..3e0edbd 100644 --- a/skills/scope-lock/SKILL.md +++ b/skills/scope-lock/SKILL.md @@ -104,7 +104,11 @@ The unlock path is intentionally heavyweight. Cheap unlock = no lock at all. **`alignment-check` (pre-lock and re-lock):** - After PASS, edit the plan's `**Status:**` line to `Locked `. -- Compute `sha256` of the manifest section (from `## Scope Manifest` to the next `##` heading) and write it to `.scope-lock`. This is the lock file. +- Write the lock file by running the helper via Bash (do **not** use the Write tool — the Write tool is blocked for `*.scope-lock` paths by the scope guard hook): + ``` + bash "${CLAUDE_PLUGIN_ROOT}/hooks/scope-lock-apply" + ``` + The helper extracts the `## Scope Manifest` section, computes its sha256, and writes `.scope-lock` via shell redirection. It prints the path and hash prefix on success, or an error message and exits non-zero on failure. - Commit both files in the same commit: `chore: lock scope for (alignment passed)`. **`subagent-driven-development` (per-task checkpoint):**