Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/smooth-agent-guardrail-hooks.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 2 additions & 2 deletions .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
4 changes: 2 additions & 2 deletions claude-plugins/smooth-agent/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
16 changes: 16 additions & 0 deletions claude-plugins/smooth-agent/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
20 changes: 20 additions & 0 deletions claude-plugins/smooth-agent/hooks/enforce-pearls-labels.sh
Original file line number Diff line number Diff line change
@@ -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 <id> -l <labels>. 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
112 changes: 112 additions & 0 deletions claude-plugins/smooth-agent/hooks/enforce-worktree.sh
Original file line number Diff line number Diff line change
@@ -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 `<parent>/<repo>-<branch>/`. 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 <<EOF
⚠️ You are about to edit source code directly on the main branch of $REPO_NAME.

ASK THE USER: "Should I make this change directly on main, or create a worktree?"

If they say worktree, create one:
git worktree add ../$REPO_NAME-SMOODEV-XX-short-desc -b SMOODEV-XX-short-desc main
EOF
exit 1
fi

# For Bash: block git commit on main (but allow merges, pulls, pushes, and worktree commits)
if [[ "$TOOL_NAME" == "Bash" ]]; then
COMMAND=$(echo "$TOOL_INPUT" | jq -r '.command // empty' 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 <<EOF
⚠️ You are about to commit directly to the main branch of $REPO_NAME.

ASK THE USER: "Should I commit this directly on main, or use a worktree?"

Commits on main typically happen via merge (git merge BRANCH --no-ff).
EOF
exit 1
fi
fi
fi

exit 0
44 changes: 44 additions & 0 deletions claude-plugins/smooth-agent/hooks/hooks.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,50 @@
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/register-agent.sh"
}
]
},
{
"matcher": "startup",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/session-worktree-warning.sh"
}
]
}
],
"PreToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/enforce-worktree.sh"
}
]
},
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/enforce-worktree.sh"
},
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/th-curl-hint.sh"
}
]
}
],
"PostToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/enforce-pearls-labels.sh"
}
]
}
]
}
Expand Down
18 changes: 18 additions & 0 deletions claude-plugins/smooth-agent/hooks/session-worktree-warning.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
#!/bin/bash
# smooth-agent plugin — SessionStart warning when a session opens in the
# MAIN worktree on the main branch (where feature work is forbidden).
#
# Repo-agnostic: derives the main worktree + repo name from git, so it
# warns correctly in smooth, smooai, smooblue, etc. (pearl th-44bace).
# stdout from a SessionStart hook is injected into the session as context.

DIR="${CLAUDE_PROJECT_DIR:-$PWD}"
MAIN=$(git -C "$DIR" worktree list --porcelain 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
94 changes: 94 additions & 0 deletions claude-plugins/smooth-agent/hooks/th-curl-hint.sh
Original file line number Diff line number Diff line change
@@ -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 <<EOF
⚠️ th-curl-hint: $1

$2

If you really need raw curl here (e.g. testing the wrapper itself, or hitting a
verb that has no \`th\` equivalent yet), append \` # th-curl-hint:ack reason=...\`
to the command and re-run. If this is the second time you're overriding for the
same reason, file a pearl: a missing \`th\` subcommand is the actual fix.

Reference: docs/Engineering/Using-th-CLI.md
EOF
}

# --- auth.smoo.ai token endpoint -----------------------------------------------
if echo "$CMD" | grep -qE 'curl[^|;&]+auth\.smoo\.ai/token'; then
emit "raw curl against auth.smoo.ai/token" \
"Use \`th api login\` (or \`SMOOAI_CLIENT_ID=… SMOOAI_CLIENT_SECRET=… th api login\`).
It exchanges client_credentials for a JWT and stores it at ~/.smooth/auth/smooai.json
so subsequent \`th api …\` calls just work."
exit 1
fi

# --- api.smoo.ai - the big one --------------------------------------------------
if echo "$CMD" | grep -qE 'curl[^|;&]+api\.smoo\.ai'; then
emit "raw curl against api.smoo.ai" \
"Use \`th api …\` — it handles auth-header injection, JWT refresh, JSON pretty-printing,
and pagination. Quick map:
/organizations/<id>/agents → th api agents list [--org <id>]
/organizations/<id>/knowledge → th api knowledge list
/organizations/<id>/config/… → th api config (schemas|environments|values|feature-flag)
/organizations/<id>/jobs → th api jobs list
/organizations/<id>/members → th api members list
/organizations/<id>/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 <env> 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
Loading