Skip to content

feat(hooks): PowerShell hook variants behind --hook-shell powershell - #97

Merged
tigers1997 merged 1 commit into
mainfrom
feat/powershell-hooks
Aug 25, 2026
Merged

tigers1997 merged 1 commit into
mainfrom
feat/powershell-hooks

Conversation

@tigers1997

Copy link
Copy Markdown
Owner

What & why

Split out of #95 at the AI reviewer's request — it blocked that PR as scope creep, correctly. This is that commit unchanged.

PowerShell hook variants behind --hook-shell powershell. The shell: "bash" fix earlier in this stack covers Windows with Git Bash; this covers machines without it. Six hooks ship a .ps1 sibling, swapped per entry by name. Hooks without a sibling stay bash, which is correct rather than lazy: check-package-availability probes apt/brew and sessionstart-drift-check is jq-driven, so both are Linux/macOS-shaped by nature.

Three Windows traps, each found by running the hooks rather than reading them — worth a reviewer's attention because each one fails silently:

  • PowerShell 5.1 reads .ps1 as ANSI, so a UTF-8 em-dash decoded to a cp1252 smart quote — which PowerShell accepts as a string delimiter — and microbit-enforcer.ps1 failed to parse. All shipped .ps1 are ASCII and BOM-free, enforced by --check along with the .sh pairing.
  • Windows ships execution policy Restricted, so naming a .ps1 directly fails with "running scripts is disabled on this system". An earlier round of my own testing passed only because this session runs with a bypass already in place — the generated command now spawns PowerShell with -ExecutionPolicy Bypass, which applies to that child process running a script the user installed deliberately and never changes machine policy.
  • A wrapping powershell -Command collapses a non-zero child exit to 1, which would have turned a PreToolUse block (exit 2) into a non-blocking error — the safety hooks would have appeared to work while permitting everything. The command ends with ; exit $LASTEXITCODE; exit 2 was then verified to survive both a direct -File invocation and a -Command wrapper.

A target_path_for rule keyed to .sh would also have routed microbit-enforcer.ps1 into .claude/skills/ and never installed it — caught by asserting every settings entry resolves to a file on disk.

Type of change

  • feat — new module / skill / feature (minor bump)
  • fix — bug fix (patch bump)
  • docs — documentation only
  • chore — tooling, CI, release plumbing
  • refactor — no behavioral change
  • BREAKING

Scope

  • One logical change. One commit: PowerShell hook variants.
  • Modules affected: core, safety, git-workflow, token-efficiency, commands
  • Personas affected: none — --hook-shell defaults to bash, so no persona output changes.

Tests

  • python3 configure.py --check passes locally.
  • test/portability/test-powershell-hooks.sh asserts the wiring on any platform (every entry resolves to an installed file, .ps1 entries carry the bypass and exit-code propagation, some hooks still run bash) and executes the hooks wherever PowerShell is present; pwsh ships on all three GitHub runner images.

CHANGELOG

  • Added an entry under ## Unreleased.
  • I will SHA-anchor the entry after merge.

License & NOTICE

  • My contribution is my own work.
  • No AGPL-incompatible code.
  • No third-party code added.
  • templates/discipline-skills/ untouched by this PR.
  • LICENSE / NOTICE untouched.

Signing

  • All commits are signed. — Not signed (no key on the authoring machine). A squash-merge signs the result.
  • Conventional Commits prefix.

I understand

  • An automated AI review will run on this PR.
  • No merge is possible while any required check is red.
  • My contribution rights are described in CONTRIBUTING.md.

Fifth of five stacked PRs — based on feat/audit-open-items (#95).

@claude

claude Bot commented Aug 25, 2026

Copy link
Copy Markdown

VERDICT: PASS

Clean, self-contained addition. All blocking criteria checked and clear:

  • CHANGELOG ## Unreleased entry present for the user-visible feat.
  • All new .ps1 files and test code are original; no third-party sources; no NOTICE entry needed.
  • templates/discipline-skills/ untouched.
  • No new settings.json schema keys — shell: "powershell" is an existing CC 2.1.81+ key already in SchemaStore, previously added for the "bash" value.
  • Single logical change throughout: six .ps1 siblings, --hook-shell powershell intake flag, --check enforcement (ASCII/BOM-free + mandatory .sh pairing), and a wiring+execution test in test/portability/.

Implementation details verified: apply_hook_shell_to_settings runs after _strip_doc_labels; swap_hook_shell is called in both the module-paths loop and the flag-extraPaths loop in collect_files; powershell_hook_command correctly includes -ExecutionPolicy Bypass and ; exit $LASTEXITCODE; the inline rm -f … || true marker-clear is translated via POWERSHELL_INLINE_COMMANDS and the test asserts no bash inline survives the swap.

@tigers1997
tigers1997 force-pushed the feat/powershell-hooks branch from 6849cb2 to 754297f Compare August 25, 2026 15:51
@claude

claude Bot commented Aug 25, 2026

Copy link
Copy Markdown

VERDICT: COMMENT-ONLY

Advisory

configure.py:2118 -- POWERSHELL_INLINE_COMMANDS is an exact-string key on the bash inline marker-clear command.

The dict maps the literal bash command string to its PowerShell equivalent. If the marker-clear command ever changes (e.g., a fourth marker file added alongside .frozen, .guarded, .careful), the dict key won't match, the translation silently won't apply, and PowerShell-only Windows machines get the raw bash inline -- which fails with no interpreter. This is the exact silent-break class the PR was designed to prevent.

The fix is low-ceremony: extract the bash command into a named constant that both POWERSHELL_INLINE_COMMANDS and the settings-patch template reference, so a change to one forces a change to the other. Not a blocker today since the command hasn't changed and the test suite covers the wiring.


Everything else checks out: CHANGELOG entry present and detailed; no third-party code (NOTICE unchanged, correctly); templates/discipline-skills/ not touched; no new schema keys (shell: "powershell" is already validated); target_path_for widened to .ps1 in config_schema.py:1110; exit codes match bash siblings (exit 1 for microbit-enforcer block, exit 2 for block-dangerous-bash/scan-secrets -- consistent); -ExecutionPolicy Bypass + ; exit $LASTEXITCODE correct; ASCII/BOM enforcement wired into --check; test coverage via test/portability/test-powershell-hooks.sh. Scope is tight -- single feature.

@tigers1997
tigers1997 force-pushed the feat/audit-open-items branch from 93aaa46 to e3c6009 Compare August 25, 2026 16:05
@tigers1997
tigers1997 force-pushed the feat/powershell-hooks branch from 754297f to 5d79acd Compare August 25, 2026 16:05
@claude

claude Bot commented Aug 25, 2026

Copy link
Copy Markdown

VERDICT: PASS

All six ps1 hooks are ASCII and BOM-free (enforced by --check), exit codes match their bash siblings (block-dangerous-bash and scan-secrets both use exit 2; microbit-enforcer uses exit 1, consistent with the bash version's own exit codes), target_path_for in config_schema.py:1109-1112 correctly routes microbit-enforcer.ps1 to .claude/hooks/ so powershell_hook_command's hardcoded path is accurate for all six hooks, and the POWERSHELL_INLINE_COMMANDS dict key matches the settings-patch inline command string exactly. CHANGELOG ## Unreleased entry present; no third-party code; templates/discipline-skills/ untouched; no new settings.json schema keys; single logical change.

@tigers1997
tigers1997 force-pushed the feat/audit-open-items branch from e3c6009 to 23f2180 Compare August 25, 2026 16:21
@tigers1997
tigers1997 force-pushed the feat/powershell-hooks branch from 5d79acd to e6b9724 Compare August 25, 2026 16:21
@claude

claude Bot commented Aug 25, 2026

Copy link
Copy Markdown

VERDICT: PASS

Clean, focused split from #95. Nothing blocking.

The per-entry swap logic in apply_hook_shell_to_settings is sound, target_path_for was already widened to route microbit-enforcer.ps1 to .claude/hooks/ (not skills), the generic hooks/ rule covers the other five, and --check enforces the ASCII/BOM-free constraint plus the .sh sibling pairing so orphan .ps1 files can never accumulate. Default scaffold (bash) is unchanged; no persona snapshot drift. CHANGELOG entry present. templates/discipline-skills/ untouched. No third-party code.

@tigers1997
tigers1997 force-pushed the feat/audit-open-items branch from 23f2180 to dfe8323 Compare August 25, 2026 17:35
The `shell: "bash"` fix covers Windows *with* Git Bash. This covers the
machines without it, where "bash" has nothing to resolve to.

Six hooks now ship a .ps1 sibling -- block-dangerous-bash, scan-secrets,
format-on-write, stop-run-checks, pre-compact-snapshot, microbit-enforcer --
and --hook-shell powershell swaps a .sh for its sibling by name, per entry,
setting "shell": "powershell" on just those hooks. Everything without a
sibling stays bash, which is correct rather than lazy: check-package-
availability probes apt/brew and sessionstart-drift-check is jq-driven, so
both are Linux/macOS-shaped by nature. The answer persists to
.claude-config.json like the rest of the intake; the default is unchanged.

Three Windows-specific traps, each found by running the hooks rather than by
reading about them:

  a) Windows PowerShell 5.1 -- still the default -- reads .ps1 as the system
     ANSI code page unless the file carries a BOM. A UTF-8 em-dash decodes to
     a cp1252 smart quote, which PowerShell accepts as a string delimiter: the
     string terminated mid-line and microbit-enforcer.ps1 failed to parse. All
     shipped .ps1 are ASCII and BOM-free, and --check enforces that, plus the
     .sh pairing -- an orphan .ps1 would never be installed, since the swap is
     by name.

  b) Windows ships execution policy Restricted, so naming a .ps1 directly
     fails with "running scripts is disabled on this system" -- a silent,
     machine-dependent break of exactly the kind this work exists to prevent.
     The generated command spawns PowerShell with -ExecutionPolicy Bypass,
     which applies only to that child process running a script the user
     installed deliberately, and never changes machine policy.

  c) A wrapping `powershell -Command` collapses any non-zero child exit to 1,
     which would have turned a PreToolUse BLOCK (exit 2) into a mere
     non-blocking error -- the safety hooks would have appeared to work while
     silently permitting everything. The command ends with
     `; exit $LASTEXITCODE`, and exit 2 was then verified to survive both a
     direct -File invocation and a -Command wrapper.

The SessionStart marker-clear is not a script but an inline `rm -f … || true`,
which is not valid PowerShell, so it gets an explicit translation.

New test/portability/test-powershell-hooks.sh asserts the wiring on any
platform -- every entry resolves to an installed file, .ps1 entries carry the
bypass and the exit-code propagation, and some hooks still run bash -- and
executes the hooks wherever PowerShell is present (pwsh ships on all three
GitHub runner images). README and docs/03 document the flag, the policy
tradeoff, and which hooks stay bash.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JVndNviHZSnbKJnWP7jFbV
@tigers1997
tigers1997 changed the base branch from feat/audit-open-items to main August 25, 2026 17:43
@tigers1997
tigers1997 force-pushed the feat/powershell-hooks branch from e6b9724 to 8d2ddde Compare August 25, 2026 17:43
@claude

claude Bot commented Aug 25, 2026

Copy link
Copy Markdown

VERDICT: PASS

All six PS1 files ship with their bash siblings, the --check enforcement for ASCII/BOM-free/sibling pairing is wired correctly, the CHANGELOG ## Unreleased entry is present and thorough, no new settings.json schema keys are introduced (the existing shell key is already schema-validated at CC 2.1.81+), the discipline-skills MIT subtree is untouched, and all new code is original (no NOTICE entry needed). The exit 1 in the PS1 frozen/guarded paths is consistent with the bash sibling and is therefore not a regression.

@tigers1997
tigers1997 merged commit f8460eb into main Aug 25, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant