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
2 changes: 1 addition & 1 deletion bin/gstack-team-init
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ Install it:

Then restart your AI coding tool.
MSG
echo '{"permissionDecision":"deny","message":"gstack is required but not installed. See stderr for install instructions."}'
echo '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"gstack is required but not installed. See stderr for install instructions."}}'
exit 0
fi

Expand Down
6 changes: 4 additions & 2 deletions careful/bin/check-careful.sh
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
#!/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 a PreToolUse hookSpecificOutput with permissionDecision "ask" to warn,
# or {} to allow. The decision MUST be nested under hookSpecificOutput — Claude
# Code ignores a top-level permissionDecision, which silently no-ops the warning.
set -euo pipefail

# Read stdin (JSON with tool_input)
Expand Down Expand Up @@ -94,7 +96,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
6 changes: 4 additions & 2 deletions freeze/bin/check-freeze.sh
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
#!/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 a PreToolUse hookSpecificOutput with permissionDecision "deny" to block,
# or {} to allow. The decision MUST be nested under hookSpecificOutput — Claude
# Code ignores a top-level permissionDecision, which silently no-ops the block.
set -euo pipefail

# Read stdin
Expand Down Expand Up @@ -74,6 +76,6 @@ 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"
printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"[freeze] Blocked: %s is outside the freeze boundary (%s). Only edits within the frozen directory are allowed."}}\n' "$FILE_PATH" "$FREEZE_DIR"
;;
esac
98 changes: 49 additions & 49 deletions test/hook-scripts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,34 +67,34 @@ describe('check-careful.sh', () => {
test('rm -rf /var/data warns with recursive delete message', () => {
const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput('rm -rf /var/data'));
expect(exitCode).toBe(0);
expect(output.permissionDecision).toBe('ask');
expect(output.message).toContain('recursive delete');
expect(output.hookSpecificOutput?.permissionDecision).toBe('ask');
expect(output.hookSpecificOutput?.permissionDecisionReason).toContain('recursive delete');
});

test('rm -r ./some-dir warns', () => {
const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput('rm -r ./some-dir'));
expect(exitCode).toBe(0);
expect(output.permissionDecision).toBe('ask');
expect(output.message).toContain('recursive delete');
expect(output.hookSpecificOutput?.permissionDecision).toBe('ask');
expect(output.hookSpecificOutput?.permissionDecisionReason).toContain('recursive delete');
});

test('rm -rf node_modules allows (safe exception)', () => {
const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput('rm -rf node_modules'));
expect(exitCode).toBe(0);
expect(output.permissionDecision).toBeUndefined();
expect(output.hookSpecificOutput?.permissionDecision).toBeUndefined();
});

test('rm -rf .next dist allows (multiple safe targets)', () => {
const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput('rm -rf .next dist'));
expect(exitCode).toBe(0);
expect(output.permissionDecision).toBeUndefined();
expect(output.hookSpecificOutput?.permissionDecision).toBeUndefined();
});

test('rm -rf node_modules /var/data warns (mixed safe+unsafe)', () => {
const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput('rm -rf node_modules /var/data'));
expect(exitCode).toBe(0);
expect(output.permissionDecision).toBe('ask');
expect(output.message).toContain('recursive delete');
expect(output.hookSpecificOutput?.permissionDecision).toBe('ask');
expect(output.hookSpecificOutput?.permissionDecisionReason).toContain('recursive delete');
});

test.each([
Expand All @@ -108,8 +108,8 @@ describe('check-careful.sh', () => {
])('never lets a safe-looking target hide a destructive command: %s', (command) => {
const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput(command));
expect(exitCode).toBe(0);
expect(output.permissionDecision).toBe('ask');
expect(output.message).toContain('recursive delete');
expect(output.hookSpecificOutput?.permissionDecision).toBe('ask');
expect(output.hookSpecificOutput?.permissionDecisionReason).toContain('recursive delete');
});
});

Expand All @@ -123,22 +123,22 @@ describe('check-careful.sh', () => {
test('psql DROP TABLE warns with DROP in message', () => {
const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput('psql -c DROP TABLE users;'));
expect(exitCode).toBe(0);
expect(output.permissionDecision).toBe('ask');
expect(output.message).toContain('DROP');
expect(output.hookSpecificOutput?.permissionDecision).toBe('ask');
expect(output.hookSpecificOutput?.permissionDecisionReason).toContain('DROP');
});

test('mysql drop database warns (case insensitive)', () => {
const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput('mysql -e drop database mydb'));
expect(exitCode).toBe(0);
expect(output.permissionDecision).toBe('ask');
expect(output.message.toLowerCase()).toContain('drop');
expect(output.hookSpecificOutput?.permissionDecision).toBe('ask');
expect(output.hookSpecificOutput?.permissionDecisionReason.toLowerCase()).toContain('drop');
});

test('psql TRUNCATE warns', () => {
const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput('psql -c TRUNCATE orders;'));
expect(exitCode).toBe(0);
expect(output.permissionDecision).toBe('ask');
expect(output.message).toContain('TRUNCATE');
expect(output.hookSpecificOutput?.permissionDecision).toBe('ask');
expect(output.hookSpecificOutput?.permissionDecisionReason).toContain('TRUNCATE');
});
});

Expand All @@ -148,36 +148,36 @@ describe('check-careful.sh', () => {
test('git push --force warns with force-push', () => {
const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput('git push --force origin main'));
expect(exitCode).toBe(0);
expect(output.permissionDecision).toBe('ask');
expect(output.message).toContain('force-push');
expect(output.hookSpecificOutput?.permissionDecision).toBe('ask');
expect(output.hookSpecificOutput?.permissionDecisionReason).toContain('force-push');
});

test('git push -f warns', () => {
const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput('git push -f origin main'));
expect(exitCode).toBe(0);
expect(output.permissionDecision).toBe('ask');
expect(output.message).toContain('force-push');
expect(output.hookSpecificOutput?.permissionDecision).toBe('ask');
expect(output.hookSpecificOutput?.permissionDecisionReason).toContain('force-push');
});

test('git reset --hard warns with uncommitted', () => {
const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput('git reset --hard HEAD~3'));
expect(exitCode).toBe(0);
expect(output.permissionDecision).toBe('ask');
expect(output.message).toContain('uncommitted');
expect(output.hookSpecificOutput?.permissionDecision).toBe('ask');
expect(output.hookSpecificOutput?.permissionDecisionReason).toContain('uncommitted');
});

test('git checkout . warns', () => {
const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput('git checkout .'));
expect(exitCode).toBe(0);
expect(output.permissionDecision).toBe('ask');
expect(output.message).toContain('uncommitted');
expect(output.hookSpecificOutput?.permissionDecision).toBe('ask');
expect(output.hookSpecificOutput?.permissionDecisionReason).toContain('uncommitted');
});

test('git restore . warns', () => {
const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput('git restore .'));
expect(exitCode).toBe(0);
expect(output.permissionDecision).toBe('ask');
expect(output.message).toContain('uncommitted');
expect(output.hookSpecificOutput?.permissionDecision).toBe('ask');
expect(output.hookSpecificOutput?.permissionDecisionReason).toContain('uncommitted');
});
});

Expand All @@ -187,22 +187,22 @@ describe('check-careful.sh', () => {
test('kubectl delete warns with kubectl in message', () => {
const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput('kubectl delete pod my-pod'));
expect(exitCode).toBe(0);
expect(output.permissionDecision).toBe('ask');
expect(output.message).toContain('kubectl');
expect(output.hookSpecificOutput?.permissionDecision).toBe('ask');
expect(output.hookSpecificOutput?.permissionDecisionReason).toContain('kubectl');
});

test('docker rm -f warns', () => {
const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput('docker rm -f container123'));
expect(exitCode).toBe(0);
expect(output.permissionDecision).toBe('ask');
expect(output.message).toContain('Docker');
expect(output.hookSpecificOutput?.permissionDecision).toBe('ask');
expect(output.hookSpecificOutput?.permissionDecisionReason).toContain('Docker');
});

test('docker system prune -a warns', () => {
const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput('docker system prune -a'));
expect(exitCode).toBe(0);
expect(output.permissionDecision).toBe('ask');
expect(output.message).toContain('Docker');
expect(output.hookSpecificOutput?.permissionDecision).toBe('ask');
expect(output.hookSpecificOutput?.permissionDecisionReason).toContain('Docker');
});
});

Expand All @@ -221,7 +221,7 @@ describe('check-careful.sh', () => {
test(`"${cmd}" allows`, () => {
const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput(cmd));
expect(exitCode).toBe(0);
expect(output.permissionDecision).toBeUndefined();
expect(output.hookSpecificOutput?.permissionDecision).toBeUndefined();
});
}
});
Expand All @@ -232,13 +232,13 @@ describe('check-careful.sh', () => {
test('empty command allows gracefully', () => {
const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput(''));
expect(exitCode).toBe(0);
expect(output.permissionDecision).toBeUndefined();
expect(output.hookSpecificOutput?.permissionDecision).toBeUndefined();
});

test('missing command field allows gracefully', () => {
const { exitCode, output } = runHook(CAREFUL_SCRIPT, { tool_input: {} });
expect(exitCode).toBe(0);
expect(output.permissionDecision).toBeUndefined();
expect(output.hookSpecificOutput?.permissionDecision).toBeUndefined();
});

test('malformed JSON input allows gracefully (exit 0, output {})', () => {
Expand All @@ -255,8 +255,8 @@ describe('check-careful.sh', () => {
const rawJson = '{"tool_input":{"command":\n"rm -rf /tmp/important"}}';
const { exitCode, output } = runHookRaw(CAREFUL_SCRIPT, rawJson);
expect(exitCode).toBe(0);
expect(output.permissionDecision).toBe('ask');
expect(output.message).toContain('recursive delete');
expect(output.hookSpecificOutput?.permissionDecision).toBe('ask');
expect(output.hookSpecificOutput?.permissionDecisionReason).toContain('recursive delete');
});
});
});
Expand All @@ -275,7 +275,7 @@ describe('check-freeze.sh', () => {
{ CLAUDE_PLUGIN_DATA: stateDir },
);
expect(exitCode).toBe(0);
expect(output.permissionDecision).toBeUndefined();
expect(output.hookSpecificOutput?.permissionDecision).toBeUndefined();
});
});

Expand All @@ -287,7 +287,7 @@ describe('check-freeze.sh', () => {
{ CLAUDE_PLUGIN_DATA: stateDir },
);
expect(exitCode).toBe(0);
expect(output.permissionDecision).toBeUndefined();
expect(output.hookSpecificOutput?.permissionDecision).toBeUndefined();
});
});
});
Expand All @@ -301,9 +301,9 @@ describe('check-freeze.sh', () => {
{ CLAUDE_PLUGIN_DATA: stateDir },
);
expect(exitCode).toBe(0);
expect(output.permissionDecision).toBe('deny');
expect(output.message).toContain('freeze');
expect(output.message).toContain('outside');
expect(output.hookSpecificOutput?.permissionDecision).toBe('deny');
expect(output.hookSpecificOutput?.permissionDecisionReason).toContain('freeze');
expect(output.hookSpecificOutput?.permissionDecisionReason).toContain('outside');
});
});

Expand All @@ -315,9 +315,9 @@ describe('check-freeze.sh', () => {
{ CLAUDE_PLUGIN_DATA: stateDir },
);
expect(exitCode).toBe(0);
expect(output.permissionDecision).toBe('deny');
expect(output.message).toContain('freeze');
expect(output.message).toContain('outside');
expect(output.hookSpecificOutput?.permissionDecision).toBe('deny');
expect(output.hookSpecificOutput?.permissionDecisionReason).toContain('freeze');
expect(output.hookSpecificOutput?.permissionDecisionReason).toContain('outside');
});
});
});
Expand All @@ -331,8 +331,8 @@ describe('check-freeze.sh', () => {
{ CLAUDE_PLUGIN_DATA: stateDir },
);
expect(exitCode).toBe(0);
expect(output.permissionDecision).toBe('deny');
expect(output.message).toContain('outside');
expect(output.hookSpecificOutput?.permissionDecision).toBe('deny');
expect(output.hookSpecificOutput?.permissionDecisionReason).toContain('outside');
});
});
});
Expand All @@ -347,7 +347,7 @@ describe('check-freeze.sh', () => {
{ CLAUDE_PLUGIN_DATA: stateDir },
);
expect(exitCode).toBe(0);
expect(output.permissionDecision).toBeUndefined();
expect(output.hookSpecificOutput?.permissionDecision).toBeUndefined();
} finally {
fs.rmSync(stateDir, { recursive: true, force: true });
}
Expand All @@ -363,7 +363,7 @@ describe('check-freeze.sh', () => {
{ CLAUDE_PLUGIN_DATA: stateDir },
);
expect(exitCode).toBe(0);
expect(output.permissionDecision).toBeUndefined();
expect(output.hookSpecificOutput?.permissionDecision).toBeUndefined();
});
});
});
Expand Down