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
24 changes: 24 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,29 @@
# Changelog

## [1.48.3.0] - 2026-05-28

**`/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 but provided zero actual enforcement. An Edit on a blocked file would succeed silently.

| Bug | Symptom | Root cause |
|-----|---------|-----------|
| Bug 1 | Hook script not found | `link_claude_skill_dirs` symlinked `SKILL.md` but not `bin/` — hook scripts unreachable |
| Bug 2 | Hook never fired | SKILL.md frontmatter `hooks:` is documentation only — setup never wrote to `~/.claude/settings.json` |
| Bug 3 | Hook fired, deny ignored | Scripts returned a flat object; correct format requires `hookSpecificOutput` envelope |

Re-run `./setup` once to activate. After that, `/freeze` actually blocks edits outside the declared directory.

### Itemized changes

#### Fixed
- **Bug 1** — `setup` and `gstack-relink` now symlink each skill's `bin/` alongside `SKILL.md`
- **Bug 2** — `setup` registers PreToolUse hook entries in `~/.claude/settings.json` at install time
- **Bug 3** — `check-freeze.sh` and `check-careful.sh` now output the correct `hookSpecificOutput` envelope

#### For contributors
- `test/hook-scripts.test.ts`: freeze and careful tests assert `hookSpecificOutput` shape

## [1.48.0.0] - 2026-05-26

## **Agents stop dropping AskUserQuestion options when there are 5+.** A new canonical preamble rule + runtime gate makes Conductor's 4-option cap a split-or-batch decision, not a silent trim.
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
1.48.0.0
1.48.3.0
5 changes: 5 additions & 0 deletions bin/gstack-relink
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,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 @@ -40,6 +40,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 @@ -64,4 +73,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 @@ -78,10 +78,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 Code's settings 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 Code's settings 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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "gstack",
"version": "1.48.0.0",
"version": "1.48.3.0",
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
"license": "MIT",
"type": "module",
Expand Down
27 changes: 27 additions & 0 deletions setup
Original file line number Diff line number Diff line change
Expand Up @@ -474,6 +474,12 @@ 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
_link_or_copy "$gstack_dir/$dir_name/SKILL.md" "$target/SKILL.md"
# Link 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
if [ -e "$target/bin" ] && [ ! -L "$target/bin" ]; then rm -rf "$target/bin"; fi
_link_or_copy "$gstack_dir/$dir_name/bin" "$target/bin"
fi
linked+=("$link_name")
fi
done
Expand Down Expand Up @@ -1150,3 +1156,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