Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 17 additions & 3 deletions hooks/pre-tool-scope-guard
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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" <plan-path>
# 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\" <plan-path>."
_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
Comment on lines 148 to 162
fi

Expand Down
74 changes: 74 additions & 0 deletions hooks/scope-lock-apply
Original file line number Diff line number Diff line change
@@ -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 <plan-path>
#
# Extracts the "## Scope Manifest" section from <plan-path>, computes its
# sha256, and writes the digest to <plan-path>.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 <plan-path>\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}"
2 changes: 1 addition & 1 deletion skills/alignment-check/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <UTC ISO-8601 timestamp>`.
2. Computes the manifest's sha256 and writes `<plan-path>.scope-lock`.
2. Writes the lock file by running `bash "${CLAUDE_PLUGIN_ROOT}/hooks/scope-lock-apply" <plan-path>` (do **not** use the Write tool — it is blocked for `*.scope-lock` paths). The helper computes the manifest's sha256 and writes `<plan-path>.scope-lock` via shell redirection.
3. Commits both files (`chore: lock scope for <feature> (alignment passed)`).

After the lock is in place, proceed to execution:
Expand Down
6 changes: 5 additions & 1 deletion skills/scope-lock/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <UTC ISO-8601 timestamp>`.
- Compute `sha256` of the manifest section (from `## Scope Manifest` to the next `##` heading) and write it to `<plan-path>.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" <plan-path>
```
The helper extracts the `## Scope Manifest` section, computes its sha256, and writes `<plan-path>.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 <feature> (alignment passed)`.

**`subagent-driven-development` (per-task checkpoint):**
Expand Down
Loading