Skip to content
Open
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
33 changes: 33 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,38 @@
# Changelog

## [1.35.0.1] - 2026-05-15

**`/freeze` and `/careful` now actually block. Three independent bugs — no bin/ symlink, no hook registration, wrong response format — were all required for the hooks to fire and enforce.**

The `/freeze` skill blocks Edit and Write outside a declared directory. `/careful` warns before destructive Bash commands. Both worked during skill invocation (state file written, UI feedback shown) but provided zero actual enforcement. An Edit on a blocked file would succeed silently. Telemetry logged a `boundary_deny` event, which made it look like the hook fired — it did fire, the deny was just ignored.

### The three numbers that matter

Root cause: `check-freeze.sh` and `check-careful.sh` ran fine when called directly. The problem was in how they were wired, not in the pattern-matching logic.

| Bug | Symptom | Root cause |
|-----|---------|-----------|
| Bug 1 | Hook script not found | `link_claude_skill_dirs` symlinked `SKILL.md` but not `bin/` — `${CLAUDE_SKILL_DIR}/bin/check-freeze.sh` resolved to a non-existent path |
| Bug 2 | Hook never fired | SKILL.md frontmatter `hooks:` is documentation — Claude Code only fires hooks registered in `~/.claude/settings.json`. Setup never wrote there |
| Bug 3 | Hook fired, deny ignored | Scripts returned `{"permissionDecision":"deny","message":"..."}` — a flat object Claude Code doesn't recognise. Correct format is `{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"..."}}` |

With all three fixed, `/freeze` enforces the boundary on Edit, Write, and MultiEdit. `/careful` warns on destructive Bash commands when active. Both work in every new session after re-running `./setup`.

### What this means for users who ran `/freeze` or `/careful` before

Re-run `./setup` once. Setup now symlinks each skill's `bin/` directory and writes the PreToolUse hook entries to `~/.claude/settings.json`. After that, `/freeze` actually blocks edits outside the declared directory. `/careful` is a no-op unless you invoke it (gated by `~/.gstack/careful-active.txt`), so it won't interfere with sessions where you didn't ask for safety mode.

### Itemized changes

#### Fixed
- **Bug 1** — `setup` and `gstack-relink` now symlink each skill's `bin/` directory alongside `SKILL.md` so hook scripts in the frontmatter are reachable at `${CLAUDE_SKILL_DIR}/bin/`
- **Bug 2** — `setup` registers PreToolUse entries for freeze (Edit|Write|MultiEdit) and careful (Bash) in `~/.claude/settings.json` at install time via the extended `gstack-settings-hook`; `gstack-settings-hook` gains `add-pretooluse` and `remove-pretooluse` actions
- **Bug 3** — `check-freeze.sh` now outputs `{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"..."}}` as required; `check-careful.sh` does the same for `permissionDecision:"ask"`
- **Bonus** — `check-freeze.sh` resolves its state root via `gstack-paths` to honour `GSTACK_HOME` in addition to `CLAUDE_PLUGIN_DATA`; `check-careful.sh` is a no-op unless `~/.gstack/careful-active.txt` exists (created when `/careful` is invoked)

#### For contributors
- `test/hook-scripts.test.ts`: all careful tests use `withCarefulActive()` and pass `CLAUDE_PLUGIN_DATA` pointing to a temp dir; freeze tests assert `hookSpecificOutput` shape; two new tests verify the envelope structure and confirm allow responses remain `{}`

## [1.35.0.0] - 2026-05-13

## **Docs become a tracked surface, not an afterthought. `/document-generate` writes them from scratch, `/document-release` audits coverage in four Diataxis quadrants.**
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
1.35.0.0
1.35.0.1
5 changes: 5 additions & 0 deletions bin/gstack-relink
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,11 @@ for skill_dir in "$INSTALL_DIR"/*/; do
# Create real directory with symlinked SKILL.md (absolute path)
mkdir -p "$target"
ln -snf "$INSTALL_DIR/$skill/SKILL.md" "$target/SKILL.md"
# Symlink bin/ so hook scripts declared in SKILL.md frontmatter are reachable
if [ -d "$INSTALL_DIR/$skill/bin" ]; then
[ -L "$target/bin" ] && rm "$target/bin"
ln -snf "$INSTALL_DIR/$skill/bin" "$target/bin"
fi
SKILL_COUNT=$((SKILL_COUNT + 1))
done

Expand Down
88 changes: 82 additions & 6 deletions bin/gstack-settings-hook
Original file line number Diff line number Diff line change
@@ -1,20 +1,28 @@
#!/usr/bin/env bash
# gstack-settings-hook — add/remove SessionStart hooks in Claude Code settings.json
# gstack-settings-hook — add/remove hooks in Claude Code settings.json
#
# Usage:
# gstack-settings-hook add <hook-command> # add SessionStart hook
# gstack-settings-hook remove <hook-command> # remove SessionStart hook
# gstack-settings-hook add <hook-command> # add SessionStart hook
# gstack-settings-hook remove <hook-command> # remove SessionStart hook
# gstack-settings-hook add-pretooluse <matcher> <command> # add PreToolUse hook
# gstack-settings-hook remove-pretooluse <matcher> <command> # remove PreToolUse hook
#
# Requires: bun (already a gstack hard dependency)
# Writes atomically: .tmp + rename to prevent corruption on crash/disk-full.

set -euo pipefail

ACTION="${1:-}"
HOOK_CMD="${2:-}"
SETTINGS_FILE="${GSTACK_SETTINGS_FILE:-$HOME/.claude/settings.json}"

if [ -z "$ACTION" ] || [ -z "$HOOK_CMD" ]; then
if [ -z "$ACTION" ]; then
echo "Usage: gstack-settings-hook {add|remove} <hook-command>" >&2
echo " gstack-settings-hook {add-pretooluse|remove-pretooluse} <matcher> <command>" >&2
exit 1
fi

HOOK_CMD="${2:-}"
if [ -z "$HOOK_CMD" ]; then
echo "Usage: gstack-settings-hook {add|remove} <hook-command>" >&2
exit 1
fi
Expand Down Expand Up @@ -75,8 +83,76 @@ case "$ACTION" in
fs.renameSync(tmp, settingsPath);
" 2>/dev/null
;;
add-pretooluse)
MATCHER="${2:-}"
HOOK_CMD2="${3:-}"
if [ -z "$MATCHER" ] || [ -z "$HOOK_CMD2" ]; then
echo "Usage: gstack-settings-hook add-pretooluse <matcher> <command>" >&2
exit 1
fi
GSTACK_SETTINGS_PATH="$SETTINGS_FILE" GSTACK_MATCHER="$MATCHER" GSTACK_HOOK_CMD="$HOOK_CMD2" bun -e "
const fs = require('fs');
const settingsPath = process.env.GSTACK_SETTINGS_PATH;
const matcher = process.env.GSTACK_MATCHER;
const hookCmd = process.env.GSTACK_HOOK_CMD;

let settings = {};
try { settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); } catch {}

if (!settings.hooks) settings.hooks = {};
if (!settings.hooks.PreToolUse) settings.hooks.PreToolUse = [];

// Dedup: check if this exact matcher+command combo is already registered
const exists = settings.hooks.PreToolUse.some(entry =>
entry.matcher === matcher &&
entry.hooks && entry.hooks.some(h => h.command === hookCmd)
);

if (!exists) {
settings.hooks.PreToolUse.push({
matcher,
hooks: [{ type: 'command', command: hookCmd }]
});
}

const tmp = settingsPath + '.tmp';
fs.writeFileSync(tmp, JSON.stringify(settings, null, 2) + '\n');
fs.renameSync(tmp, settingsPath);
" 2>/dev/null
;;
remove-pretooluse)
MATCHER="${2:-}"
HOOK_CMD2="${3:-}"
if [ -z "$MATCHER" ] || [ -z "$HOOK_CMD2" ]; then
echo "Usage: gstack-settings-hook remove-pretooluse <matcher> <command>" >&2
exit 1
fi
[ -f "$SETTINGS_FILE" ] || exit 0
GSTACK_SETTINGS_PATH="$SETTINGS_FILE" GSTACK_MATCHER="$MATCHER" GSTACK_HOOK_CMD="$HOOK_CMD2" bun -e "
const fs = require('fs');
const settingsPath = process.env.GSTACK_SETTINGS_PATH;
const matcher = process.env.GSTACK_MATCHER;
const hookCmd = process.env.GSTACK_HOOK_CMD;

let settings = {};
try { settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); } catch { process.exit(0); }

if (settings.hooks && settings.hooks.PreToolUse) {
settings.hooks.PreToolUse = settings.hooks.PreToolUse.filter(entry =>
!(entry.matcher === matcher &&
entry.hooks && entry.hooks.some(h => h.command === hookCmd))
);
if (settings.hooks.PreToolUse.length === 0) delete settings.hooks.PreToolUse;
if (Object.keys(settings.hooks).length === 0) delete settings.hooks;
}

const tmp = settingsPath + '.tmp';
fs.writeFileSync(tmp, JSON.stringify(settings, null, 2) + '\n');
fs.renameSync(tmp, settingsPath);
" 2>/dev/null
;;
*)
echo "Unknown action: $ACTION (expected add or remove)" >&2
echo "Unknown action: $ACTION (expected add, remove, add-pretooluse, or remove-pretooluse)" >&2
exit 1
;;
esac
20 changes: 19 additions & 1 deletion careful/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,15 @@ mkdir -p ~/.gstack/analytics
echo '{"skill":"careful","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null || echo "unknown")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
```

Activate the careful guard (writes the state file that check-careful.sh checks):

```bash
eval "$(~/.claude/skills/gstack/bin/gstack-paths)"
mkdir -p "$GSTACK_STATE_ROOT"
touch "$GSTACK_STATE_ROOT/careful-active.txt"
echo "CAREFUL_STATE: $GSTACK_STATE_ROOT/careful-active.txt"
```

## What's protected

| Pattern | Example | Risk |
Expand All @@ -60,4 +69,13 @@ The hook reads the command from the tool input JSON, checks it against the
patterns above, and returns `permissionDecision: "ask"` with a warning message
if a match is found. You can always override the warning and proceed.

To deactivate, end the conversation or start a new one. Hooks are session-scoped.
The hook is registered globally at install time but is a no-op unless
`~/.gstack/careful-active.txt` exists. The file is created when `/careful` is
invoked and deleted when the session ends or when the user explicitly removes it.

To deactivate, run:
```bash
eval "$(~/.claude/skills/gstack/bin/gstack-paths)"
rm -f "$GSTACK_STATE_ROOT/careful-active.txt"
echo "careful guardrails deactivated"
```
20 changes: 19 additions & 1 deletion careful/SKILL.md.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,15 @@ mkdir -p ~/.gstack/analytics
echo '{"skill":"careful","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null || echo "unknown")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
```

Activate the careful guard (writes the state file that check-careful.sh checks):

```bash
eval "$(~/.claude/skills/gstack/bin/gstack-paths)"
mkdir -p "$GSTACK_STATE_ROOT"
touch "$GSTACK_STATE_ROOT/careful-active.txt"
echo "CAREFUL_STATE: $GSTACK_STATE_ROOT/careful-active.txt"
```

## What's protected

| Pattern | Example | Risk |
Expand All @@ -59,4 +68,13 @@ The hook reads the command from the tool input JSON, checks it against the
patterns above, and returns `permissionDecision: "ask"` with a warning message
if a match is found. You can always override the warning and proceed.

To deactivate, end the conversation or start a new one. Hooks are session-scoped.
The hook is registered globally at install time but is a no-op unless
`~/.gstack/careful-active.txt` exists. The file is created when `/careful` is
invoked and deleted when the session ends or when the user explicitly removes it.

To deactivate, run:
```bash
eval "$(~/.claude/skills/gstack/bin/gstack-paths)"
rm -f "$GSTACK_STATE_ROOT/careful-active.txt"
echo "careful guardrails deactivated"
```
27 changes: 25 additions & 2 deletions careful/bin/check-careful.sh
Original file line number Diff line number Diff line change
@@ -1,9 +1,32 @@
#!/usr/bin/env bash
# check-careful.sh — PreToolUse hook for /careful skill
# Reads JSON from stdin, checks Bash command for destructive patterns.
# Returns {"permissionDecision":"ask","message":"..."} to warn, or {} to allow.
# Returns hookSpecificOutput with permissionDecision "ask" to warn, or {} to allow.
set -euo pipefail

# Resolve state root (consistent with freeze and gstack-paths)
_PATHS_BIN=""
for _p in \
"${CLAUDE_SKILL_DIR:-}/../gstack/bin/gstack-paths" \
"$HOME/.claude/skills/gstack/bin/gstack-paths"; do
[ -x "$_p" ] && { _PATHS_BIN="$_p"; break; }
done

if [ -n "$_PATHS_BIN" ]; then
eval "$("$_PATHS_BIN" 2>/dev/null)" 2>/dev/null || true
_STATE_DIR="${GSTACK_STATE_ROOT:-${CLAUDE_PLUGIN_DATA:-$HOME/.gstack}}"
else
_STATE_DIR="${CLAUDE_PLUGIN_DATA:-$HOME/.gstack}"
fi

# No-op unless /careful has been activated (wrote careful-active.txt)
# This allows the hook to be registered globally without interfering in
# sessions where /careful was never invoked.
if [ ! -f "$_STATE_DIR/careful-active.txt" ]; then
echo '{}'
exit 0
fi

# Read stdin (JSON with tool_input)
INPUT=$(cat)

Expand Down Expand Up @@ -106,7 +129,7 @@ if [ -n "$WARN" ]; then
echo '{"event":"hook_fire","skill":"careful","pattern":"'"$PATTERN"'","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null || echo "unknown")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true

WARN_ESCAPED=$(printf '%s' "$WARN" | sed 's/"/\\"/g')
printf '{"permissionDecision":"ask","message":"[careful] %s"}\n' "$WARN_ESCAPED"
printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"ask","permissionDecisionReason":"[careful] %s"}}\n' "$WARN_ESCAPED"
else
echo '{}'
fi
8 changes: 5 additions & 3 deletions freeze/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,10 +74,12 @@ again. To remove it, run `/unfreeze` or end the session."

The hook reads `file_path` from the Edit/Write tool input JSON, then checks
whether the path starts with the freeze directory. If not, it returns
`permissionDecision: "deny"` to block the operation.
a `hookSpecificOutput` deny decision to block the operation.

The freeze boundary persists for the session via the state file. The hook
script reads it on every Edit/Write invocation.
The hook is registered globally in `~/.claude/settings.json` by the gstack
installer. It is a no-op when no freeze state file exists, so it does not
interfere in sessions where `/freeze` has not been invoked. The freeze
boundary persists via the state file; `/unfreeze` clears it.

## Notes

Expand Down
8 changes: 5 additions & 3 deletions freeze/SKILL.md.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -73,10 +73,12 @@ again. To remove it, run `/unfreeze` or end the session."

The hook reads `file_path` from the Edit/Write tool input JSON, then checks
whether the path starts with the freeze directory. If not, it returns
`permissionDecision: "deny"` to block the operation.
a `hookSpecificOutput` deny decision to block the operation.

The freeze boundary persists for the session via the state file. The hook
script reads it on every Edit/Write invocation.
The hook is registered globally in `~/.claude/settings.json` by the gstack
installer. It is a no-op when no freeze state file exists, so it does not
interfere in sessions where `/freeze` has not been invoked. The freeze
boundary persists via the state file; `/unfreeze` clears it.

## Notes

Expand Down
23 changes: 19 additions & 4 deletions freeze/bin/check-freeze.sh
Original file line number Diff line number Diff line change
@@ -1,14 +1,28 @@
#!/usr/bin/env bash
# check-freeze.sh — PreToolUse hook for /freeze skill
# Reads JSON from stdin, checks if file_path is within the freeze boundary.
# Returns {"permissionDecision":"deny","message":"..."} to block, or {} to allow.
# Returns hookSpecificOutput with permissionDecision "deny" to block, or {} to allow.
set -euo pipefail

# Read stdin
INPUT=$(cat)

# Locate the freeze directory state file
STATE_DIR="${CLAUDE_PLUGIN_DATA:-$HOME/.gstack}"
# Locate the freeze directory state file via gstack-paths for consistent resolution
# across GSTACK_HOME / CLAUDE_PLUGIN_DATA / $HOME/.gstack fallback chain.
_PATHS_BIN=""
for _p in \
"${CLAUDE_SKILL_DIR:-}/../gstack/bin/gstack-paths" \
"$HOME/.claude/skills/gstack/bin/gstack-paths"; do
[ -x "$_p" ] && { _PATHS_BIN="$_p"; break; }
done

if [ -n "$_PATHS_BIN" ]; then
eval "$("$_PATHS_BIN" 2>/dev/null)" 2>/dev/null || true
STATE_DIR="${GSTACK_STATE_ROOT:-${CLAUDE_PLUGIN_DATA:-$HOME/.gstack}}"
else
STATE_DIR="${CLAUDE_PLUGIN_DATA:-$HOME/.gstack}"
fi

FREEZE_FILE="$STATE_DIR/freeze-dir.txt"

# If no freeze file exists, allow everything (not yet configured)
Expand Down Expand Up @@ -74,6 +88,7 @@ case "$FILE_PATH" in
mkdir -p ~/.gstack/analytics 2>/dev/null || true
echo '{"event":"hook_fire","skill":"freeze","pattern":"boundary_deny","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null || echo "unknown")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true

printf '{"permissionDecision":"deny","message":"[freeze] Blocked: %s is outside the freeze boundary (%s). Only edits within the frozen directory are allowed."}\n' "$FILE_PATH" "$FREEZE_DIR"
_REASON=$(printf '[freeze] Blocked: %s is outside the freeze boundary (%s). Only edits within the frozen directory are allowed.' "$FILE_PATH" "$FREEZE_DIR" | sed 's/"/\\"/g')
printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"%s"}}\n' "$_REASON"
;;
esac
26 changes: 26 additions & 0 deletions setup
Original file line number Diff line number Diff line change
Expand Up @@ -402,6 +402,11 @@ link_claude_skill_dirs() {
# Validate target isn't a symlink before creating the link
if [ -L "$target/SKILL.md" ]; then rm "$target/SKILL.md"; fi
ln -snf "$gstack_dir/$dir_name/SKILL.md" "$target/SKILL.md"
# Symlink bin/ so hook scripts declared in SKILL.md frontmatter are reachable
if [ -d "$gstack_dir/$dir_name/bin" ]; then
if [ -L "$target/bin" ]; then rm "$target/bin"; fi
ln -snf "$gstack_dir/$dir_name/bin" "$target/bin"
fi
linked+=("$link_name")
fi
done
Expand Down Expand Up @@ -1042,3 +1047,24 @@ if [ "$NO_TEAM_MODE" -eq 1 ]; then

log "Team mode disabled: auto-update hook removed."
fi

# 11. Register PreToolUse hooks for safety skills (freeze, careful)
# Hooks declared in SKILL.md frontmatter are not auto-read by Claude Code — they
# must be present in ~/.claude/settings.json. Register them here so the hook
# scripts are actually invoked when the skills are active.
# check-freeze.sh returns {} when no freeze state file exists, so it is safe as a
# permanent global hook. check-careful.sh checks for ~/.gstack/careful-active.txt
# and is similarly safe when /careful has not been invoked.
if [ -x "$SETTINGS_HOOK" ] && command -v bun >/dev/null 2>&1; then
_FREEZE_HOOK_SCRIPT="$SOURCE_GSTACK_DIR/freeze/bin/check-freeze.sh"
_CAREFUL_HOOK_SCRIPT="$SOURCE_GSTACK_DIR/careful/bin/check-careful.sh"

if [ -f "$_FREEZE_HOOK_SCRIPT" ]; then
"$SETTINGS_HOOK" add-pretooluse "Edit|Write|MultiEdit" "bash $_FREEZE_HOOK_SCRIPT" 2>/dev/null || true
log " registered PreToolUse hook: freeze (Edit/Write/MultiEdit)"
fi
if [ -f "$_CAREFUL_HOOK_SCRIPT" ]; then
"$SETTINGS_HOOK" add-pretooluse "Bash" "bash $_CAREFUL_HOOK_SCRIPT" 2>/dev/null || true
log " registered PreToolUse hook: careful (Bash)"
fi
fi
Loading