From 048ee16c710315d27ca8e354b657e2a008c03520 Mon Sep 17 00:00:00 2001 From: Brent Rager Date: Fri, 10 Jul 2026 22:58:43 -0400 Subject: [PATCH] th-44bace: smooth-agent plugin ships shared repo guardrail hooks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each SmooAI repo hand-copied identical .claude/hooks/ guardrails that drifted: smooth had th-curl-hint but smooai didn't, smooai had a repo- name-substituted enforce-worktree, and smooblue had no guardrails at all. Move them into the smooth-agent marketplace plugin as one source of truth, genericized so the same scripts guard any SmooAI repo — the main worktree, its parent, and the repo name are derived from `git worktree list` at runtime instead of being hardcoded per repo: - enforce-worktree.sh PreToolUse Edit|Write|Bash - session-worktree-warning.sh SessionStart startup - th-curl-hint.sh PreToolUse Bash - enforce-pearls-labels.sh PostToolUse Bash Wired in hooks/hooks.json; plugin bumped 0.1.0 -> 0.2.0. Repos enable via enabledPlugins and drop their local copies in follow-up per-repo changes (the SessionStart pearls-workflow context stays global via `th prime`). Verified: 8/8 direct script tests across smooth + smooai contexts (edit-on- main asks, .claude bypass, feature-worktree allow, git-commit ask, git -C worktree allow, non-git safe default, repo name in messages), th-curl-hint + enforce-pearls-labels smoke tests, all plugin JSON valid. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_015zP7baF7SHR89bPftsX1kp --- .changeset/smooth-agent-guardrail-hooks.md | 5 + .claude-plugin/marketplace.json | 4 +- .../smooth-agent/.claude-plugin/plugin.json | 4 +- claude-plugins/smooth-agent/README.md | 16 +++ .../hooks/enforce-pearls-labels.sh | 20 ++++ .../smooth-agent/hooks/enforce-worktree.sh | 112 ++++++++++++++++++ claude-plugins/smooth-agent/hooks/hooks.json | 44 +++++++ .../hooks/session-worktree-warning.sh | 18 +++ .../smooth-agent/hooks/th-curl-hint.sh | 94 +++++++++++++++ 9 files changed, 313 insertions(+), 4 deletions(-) create mode 100644 .changeset/smooth-agent-guardrail-hooks.md create mode 100755 claude-plugins/smooth-agent/hooks/enforce-pearls-labels.sh create mode 100755 claude-plugins/smooth-agent/hooks/enforce-worktree.sh create mode 100755 claude-plugins/smooth-agent/hooks/session-worktree-warning.sh create mode 100755 claude-plugins/smooth-agent/hooks/th-curl-hint.sh diff --git a/.changeset/smooth-agent-guardrail-hooks.md b/.changeset/smooth-agent-guardrail-hooks.md new file mode 100644 index 00000000..c2bffa97 --- /dev/null +++ b/.changeset/smooth-agent-guardrail-hooks.md @@ -0,0 +1,5 @@ +--- +"@smooai/smooth": patch +--- + +smooth-agent plugin (0.2.0): ship the shared SmooAI repo guardrail hooks so smooth·smooai·smooblue stop hand-copying `.claude/hooks/`. The plugin now provides `enforce-worktree` (PreToolUse), `session-worktree-warning` (SessionStart), `th-curl-hint` (PreToolUse), and `enforce-pearls-labels` (PostToolUse) — all repo-agnostic (main worktree, parent, and repo name derived from git at runtime), so one source of truth guards every SmooAI repo. Enable per-repo via `enabledPlugins: {"smooth-agent@smooth": true}` and delete the local hook copies. diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 9d7cf2d3..00bf2b7b 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -12,8 +12,8 @@ { "name": "smooth-agent", "source": "./claude-plugins/smooth-agent", - "description": "Run a Big Smooth that drives Claude Code worker sessions over tmux (rate-limit-resilient), coordinate agents over th-mail, and track work in pearls. Provides the /smooth command.", - "version": "0.1.0", + "description": "Run a Big Smooth that drives Claude Code worker sessions over tmux (rate-limit-resilient), coordinate agents over th-mail, and track work in pearls. Provides the /smooth command. Ships the shared SmooAI repo guardrail hooks (worktree enforcement, th-over-curl nudges, pearls-label reminder).", + "version": "0.2.0", "category": "orchestration", "keywords": ["orchestration", "tmux", "multi-agent", "th-mail", "pearls", "rate-limit"], "author": { diff --git a/claude-plugins/smooth-agent/.claude-plugin/plugin.json b/claude-plugins/smooth-agent/.claude-plugin/plugin.json index 497da94b..5b39f765 100644 --- a/claude-plugins/smooth-agent/.claude-plugin/plugin.json +++ b/claude-plugins/smooth-agent/.claude-plugin/plugin.json @@ -1,8 +1,8 @@ { "$schema": "https://anthropic.com/claude-code/plugin.schema.json", "name": "smooth-agent", - "version": "0.1.0", - "description": "Big Smooth orchestration for Claude Code: tmux-supervised worker sessions that survive the account-wide rate-limit throttle, coordinate over th-mail, and track work in pearls. Provides the /smooth command.", + "version": "0.2.0", + "description": "Big Smooth orchestration for Claude Code: tmux-supervised worker sessions that survive the account-wide rate-limit throttle, coordinate over th-mail, and track work in pearls. Provides the /smooth command. Also ships the shared SmooAI repo guardrails as hooks (worktree enforcement, th-over-curl nudges, pearls-label reminder) so smooth·smooai·smooblue stop hand-copying .claude/hooks.", "author": { "name": "SmooAI", "email": "brent@smoo.ai" diff --git a/claude-plugins/smooth-agent/README.md b/claude-plugins/smooth-agent/README.md index 69ff47ce..5041c0b7 100644 --- a/claude-plugins/smooth-agent/README.md +++ b/claude-plugins/smooth-agent/README.md @@ -16,6 +16,22 @@ sessions that survive the account-wide rate-limit throttle, coordinate them over - **SessionStart hook** — when a session is launched by `th claude run` (which exports `SMOOTH_AGENT_HANDLE`), auto-registers it on the th-mail bus so Big Smooth can address it by id. +- **Shared repo guardrail hooks** — the SmooAI worktree/pearls guardrails that + used to be hand-copied into every repo's `.claude/hooks/`, now one source of + truth (pearl th-44bace). All derive the repo/main-worktree from git at runtime, + so the same scripts guard smooth·smooai·smooblue: + - `enforce-worktree.sh` (PreToolUse Edit/Write/Bash) — asks before editing + source or committing on `main` in the main worktree. + - `session-worktree-warning.sh` (SessionStart) — warns when a session opens + in the main worktree on `main`. + - `th-curl-hint.sh` (PreToolUse Bash) — nudges raw curl against + api/auth.smoo.ai / Jira toward the `th` CLI, and flags two secret-handling + footguns. + - `enforce-pearls-labels.sh` (PostToolUse Bash) — reminds to label a + `th pearls create`. + + Enable per-repo in `.claude/settings.json` (`enabledPlugins`) and delete the + local `.claude/hooks/` copies — see the repo's own settings for the pattern. ## Requires diff --git a/claude-plugins/smooth-agent/hooks/enforce-pearls-labels.sh b/claude-plugins/smooth-agent/hooks/enforce-pearls-labels.sh new file mode 100755 index 00000000..ac812438 --- /dev/null +++ b/claude-plugins/smooth-agent/hooks/enforce-pearls-labels.sh @@ -0,0 +1,20 @@ +#!/bin/bash +# Warn when th pearls create is called without labels. +# Runs as PostToolUse on Bash — provides feedback after the command runs. +# Exit 0 = allow (with optional stderr feedback) + +TOOL_INPUT="$TOOL_INPUT" + +# Only check th pearls create commands +if ! echo "$TOOL_INPUT" | grep -q 'th pearls create'; then + exit 0 +fi + +# Check if --add-label was included +if echo "$TOOL_INPUT" | grep -qE '\-l\s|\-\-labels|\-\-add-label'; then + exit 0 +fi + +# Warn (exit 0 so it doesn't block, but stderr gives feedback) +echo "WARNING: 'th pearls create' was called without labels. Please add labels: th pearls update -l . Available: ai, approval, bugfix, config, database, docs, frontend, game, infra, integration, knowledge, marketing, pricing, realtime, sdk, security, setup, sme-review, social-media, testing" >&2 +exit 0 diff --git a/claude-plugins/smooth-agent/hooks/enforce-worktree.sh b/claude-plugins/smooth-agent/hooks/enforce-worktree.sh new file mode 100755 index 00000000..41d79c93 --- /dev/null +++ b/claude-plugins/smooth-agent/hooks/enforce-worktree.sh @@ -0,0 +1,112 @@ +#!/bin/bash +# smooth-agent plugin — enforce the worktree workflow. +# +# Blocks feature work on the main branch in the MAIN worktree. Runs on +# PreToolUse for Edit, Write, and Bash (git commit). Exit 0 = allow, +# Exit 1 = ask the user, Exit 2 = hard block. +# +# Repo-agnostic: the main worktree, its parent, and the repo name are +# derived from git at runtime (via `git worktree list --porcelain`, whose +# first entry is always the main worktree), so the same script guards +# smooth, smooai, smooblue, and any repo that follows the sibling-worktree +# convention `/-/`. Consolidated from the per-repo +# copies that hardcoded a single path (pearl th-44bace). + +# Resolve the MAIN worktree for whichever repo this session is in. +DIR="${CLAUDE_PROJECT_DIR:-$PWD}" +MAIN_WORKTREE=$(git -C "$DIR" worktree list --porcelain 2>/dev/null | awk '/^worktree /{print $2; exit}') +# Not a git repo (or no worktree) → nothing to enforce. +[[ -z "$MAIN_WORKTREE" ]] && exit 0 +WORKTREE_PARENT=$(dirname "$MAIN_WORKTREE") +REPO_NAME=$(basename "$MAIN_WORKTREE") +BYPASS_FILE="$MAIN_WORKTREE/.claude/worktree-bypass" + +# Session bypass: if the bypass file exists, allow everything. +if [[ -f "$BYPASS_FILE" ]]; then + exit 0 +fi + +# Read the event from stdin +INPUT=$(cat) +TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // empty' 2>/dev/null) +TOOL_INPUT=$(echo "$INPUT" | jq -r '.tool_input // empty' 2>/dev/null) + +# Helper: check if a path is inside a feature worktree (not the main worktree) +is_in_worktree() { + local path="$1" + if [[ "$path" == "$WORKTREE_PARENT/$REPO_NAME-"* ]]; then + return 0 + fi + return 1 +} + +# For Edit/Write: block source code changes targeting the main worktree +if [[ "$TOOL_NAME" == "Edit" || "$TOOL_NAME" == "Write" ]]; then + FILE_PATH=$(echo "$TOOL_INPUT" | jq -r '.file_path // empty' 2>/dev/null) + # Allow if the file is in a feature worktree + if is_in_worktree "$FILE_PATH"; then + exit 0 + fi + # Allow changes to .claude/, .beads/, .changeset/, CLAUDE.md, memory files + if [[ "$FILE_PATH" == *"/.claude/"* || "$FILE_PATH" == *"/.beads/"* || "$FILE_PATH" == *"/.changeset/"* || "$FILE_PATH" == *"CLAUDE.md"* || "$FILE_PATH" == *"/memory/"* ]]; then + exit 0 + fi + # Allow edits to files outside this repo entirely + if [[ "$FILE_PATH" != "$MAIN_WORKTREE/"* ]]; then + exit 0 + fi + # Only block if we're actually on main in the main worktree + BRANCH=$(git -C "$MAIN_WORKTREE" symbolic-ref --short HEAD 2>/dev/null) + if [[ "$BRANCH" != "main" && "$BRANCH" != "master" ]]; then + exit 0 + fi + # Allow edits during an active merge (conflict resolution) + if [[ -f "$MAIN_WORKTREE/.git/MERGE_HEAD" ]]; then + exit 0 + fi + # Ask permission for source code edits on main + cat >&2 </dev/null) + + # Allow if the command targets a worktree via git -C or cd + if echo "$COMMAND" | grep -qE "git\s+-C\s+.*/$REPO_NAME-"; then + exit 0 + fi + if echo "$COMMAND" | grep -qE "cd\s+.*/$REPO_NAME-.*&&.*git\s+commit"; then + exit 0 + fi + + # Block git commit on main (unless it's a merge --no-ff or we're resolving a merge) + if echo "$COMMAND" | grep -qE 'git\s+commit' && ! echo "$COMMAND" | grep -q '\-\-no-ff'; then + # Allow commits during an active merge (conflict resolution) + if [[ -f "$MAIN_WORKTREE/.git/MERGE_HEAD" ]]; then + exit 0 + fi + # Check if we're on main + BRANCH=$(git -C "$MAIN_WORKTREE" symbolic-ref --short HEAD 2>/dev/null) + if [[ "$BRANCH" == "main" || "$BRANCH" == "master" ]]; then + cat >&2 </dev/null | awk '/^worktree /{print $2; exit}') +[[ -z "$MAIN" ]] && exit 0 +BRANCH=$(git -C "$MAIN" symbolic-ref --short HEAD 2>/dev/null) +REPO=$(basename "$MAIN") + +if [[ ( "$DIR" == "$MAIN" || "$DIR" == "$MAIN/"* ) && ( "$BRANCH" == "main" || "$BRANCH" == "master" ) ]]; then + echo "⚠️ You are in the MAIN worktree (${MAIN}/) on the ${BRANCH} branch. Do NOT do feature work here. Create a worktree first: git worktree add ../${REPO}-SMOODEV-XX-desc -b SMOODEV-XX-desc ${BRANCH}" +fi +exit 0 diff --git a/claude-plugins/smooth-agent/hooks/th-curl-hint.sh b/claude-plugins/smooth-agent/hooks/th-curl-hint.sh new file mode 100755 index 00000000..f12952b9 --- /dev/null +++ b/claude-plugins/smooth-agent/hooks/th-curl-hint.sh @@ -0,0 +1,94 @@ +#!/bin/bash +# th-curl-hint: PreToolUse Bash hook that nudges the agent toward the `th` CLI +# whenever the command about to run is a raw curl against a Smoo platform endpoint +# that already has a `th` wrapper. Also flags two well-known footguns (sst secret +# list, gh secret set with stdin echo) toward their scripts/secret-helpers wrappers. +# +# Exit codes: 0 allow silently, 1 ask the user (with stderr hint visible to Claude), +# 2 hard block. We use 1 — non-blocking nudge with a clear hint, override by confirming. +# +# Background: docs/Engineering/Using-th-CLI.md · pearl th-500495 / th-8b3d79 + +INPUT=$(cat) +TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // empty' 2>/dev/null) +[[ "$TOOL_NAME" != "Bash" ]] && exit 0 + +CMD=$(echo "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null) +[[ -z "$CMD" ]] && exit 0 + +# Bypass: if the message body itself mentions `th-curl-hint:ack` (e.g. the agent +# explained why curl is the right call), let it through. +if echo "$CMD" | grep -q 'th-curl-hint:ack'; then + exit 0 +fi + +emit() { + cat >&2 </agents → th api agents list [--org ] + /organizations//knowledge → th api knowledge list + /organizations//config/… → th api config (schemas|environments|values|feature-flag) + /organizations//jobs → th api jobs list + /organizations//members → th api members list + /organizations//auth-clients → th api keys list (dashboard auth required) + /admin/… → th admin … (planned — see pearl th-feebd2) +Full surface: th api help" + exit 1 +fi + +# --- atlassian.net Jira REST ---------------------------------------------------- +if echo "$CMD" | grep -qE 'curl[^|;&]+atlassian\.net/rest/api'; then + emit "raw curl against Jira REST" \ + "For read paths use \`th jira sync --pull\` followed by \`th pearls list / show\`. +Write verbs (create issue, transition status) aren't wrapped yet — if that's what +you need, this is the case where the override is fine. File a pearl on the smooth +repo so the next person doesn't have to curl." + exit 1 +fi + +# --- gh secret set with stdin echo (newline corruption — SMOODEV-879/909) ------- +if echo "$CMD" | grep -qE '(echo|printf)[^|]+\|\s*gh\s+secret\s+set'; then + emit "gh secret set with stdin echo — trailing-newline footgun" \ + "Use scripts/secret-helpers/gh-secret-set instead. The echo/printf pipeline +stores \"value\\n\" and silently breaks byte-comparing consumers (OAuth client_secret, +argon2 hashes — SMOODEV-879 burned us twice). The wrapper strips trailing whitespace +and refuses empty or mid-string newlines." + exit 1 +fi + +# --- pnpm sst secret list (plaintext leakage — SMOODEV-908) --------------------- +if echo "$CMD" | grep -qE 'pnpm\s+sst\s+secret\s+list' && ! echo "$CMD" | grep -q 'sst-secret-list'; then + emit "raw \`pnpm sst secret list\` leaks every secret as plaintext" \ + "Use scripts/secret-helpers/sst-secret-list --stage instead. It redacts +values by default; pass --reveal only when you need them. The raw command prints +Name=value pairs that leak hard in screenshares / Slack / transcripts (SMOODEV-908)." + exit 1 +fi + +exit 0