diff --git a/.claude/hooks/ask-before-risky-commands.sh b/.claude/hooks/ask-before-risky-commands.sh new file mode 100755 index 0000000..4f8788c --- /dev/null +++ b/.claude/hooks/ask-before-risky-commands.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash +# Claude Code PreToolUse(Bash) gate — pathscale backend service. +# +# Prompts before prod-affecting / destructive commands in ANY wrapper form +# (env VAR=val …, tool -C …, chained with ; && |, multi-line, quoted via +# bash -c '…'). For everything else it stays SILENT (exit 0, no decision), so the +# normal permission rules — permissions.allow / ask / deny in .claude/settings.json +# and the session's permission mode — decide as usual. +# +# This is one layer of defense, not a replacement for the permission system: a +# pattern match over a command string is best-effort (quoting and indirection can +# evade any blocklist). It backs up the declarative permissions.ask list in +# .claude/settings.json — KEEP THE TWO IN SYNC — and for fully autonomous runs, +# prefer OS-level sandboxing on top. +# +# Adapted from PakhomovAlexander/project-hub. Edit RISKY_WORDS for this repo; +# tooling; further branches below gate `git`/`docker push`, `git clean`, recursive +# `rm`, `find -delete`, package publishing, PR-merge/release via `gh`, and a deploy +# script run by path. Add your own command families (e.g. `ssh`, a bespoke deploy +# CLI) to RISKY_WORDS, or trim what you don't use — and mirror the change in +# permissions.ask in .claude/settings.json. +set -u + +# --- the watchlist: command words that should prompt before running ---------------- +RISKY_WORDS="aws|gcloud|az|kubectl|helm|terraform|terragrunt|flyctl|fly" +# ----------------------------------------------------------------------------------- + +# Pull the command out of the hook's stdin JSON (jq if available, else python3). +if command -v jq >/dev/null 2>&1; then + cmd="$(jq -r '.tool_input.command // ""' 2>/dev/null)" +else + cmd="$(python3 -c 'import sys, json; print(json.load(sys.stdin).get("tool_input", {}).get("command", ""))' 2>/dev/null)" +fi + +# Couldn't read the command → stay neutral, let normal permission rules decide. +[ -z "$cmd" ] && exit 0 + +# A command word counts as "at a command position" after start-of-line, whitespace, +# ; & | ( or a quote — and ends before whitespace, a quote, ) ; & | or end-of-line — +# so `bash -c 'git push'` is still seen. +b="(^|[[:space:];&|(\"'\`])" +e="([[:space:];&|)\"'\`]|$)" +# Global options that may sit between a tool and its subcommand (git -C dir push). +opts='([[:space:]]+(-[A-Za-z-]+|[-_A-Za-z0-9]+=[^[:space:]]+|-C[[:space:]]+[^[:space:]]+))*' + +re="${b}(${RISKY_WORDS})${e}" +# git push / git clean, docker push — allowing global options before the subcommand. +re="$re|${b}git${opts}[[:space:]]+(push|clean)${e}" +re="$re|${b}docker${opts}[[:space:]]+push${e}" +# recursive rm (-r / -R / -fr / --recursive), with flags/paths in any order. +re="$re|${b}rm([[:space:]]+[^[:space:]]+)*[[:space:]]+(-[A-Za-z]*[rR]|--recursive)" +# find … -delete — irreversible bulk delete. +re="$re|${b}find([[:space:]]+[^[:space:]]+)*[[:space:]]+-delete${e}" +# package publishing — ships artifacts to a registry. +re="$re|${b}(npm|pnpm|yarn|bun|cargo|gem)([[:space:]]+[^[:space:]]+)*[[:space:]]+publish${e}" +re="$re|${b}twine([[:space:]]+[^[:space:]]+)*[[:space:]]+upload${e}" +# gh mutations that merge, ship, or destroy. +re="$re|${b}gh[[:space:]]+(pr[[:space:]]+merge|repo[[:space:]]+delete|release[[:space:]]+(create|delete))${e}" +re="$re|${b}gh[[:space:]]+api([[:space:]]+[^[:space:]]+)*[[:space:]]+(-X|--method)[[:space:]]+(POST|PUT|PATCH|DELETE)${e}" +# a deploy script invoked by path, e.g. ./scripts/deploy.sh — a word-list can't see it. +re="$re|${b}([./A-Za-z0-9_-]*/)?deploy(\.[A-Za-z]+)?${e}" +# WorkTable data migrations and endpoint regeneration rewrite committed artifacts. +re="$re|${b}([./A-Za-z0-9_-]*/)?regenerate_endpoints(\.[A-Za-z]+)?${e}" + +if printf '%s\n' "$cmd" | grep -Eq "$re"; then + printf '%s' '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"ask","permissionDecisionReason":"Prod-affecting / destructive command — confirm before running."}}' +fi +# No match → no output: fall through to the normal permission flow. +exit 0 diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..a7af469 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,44 @@ +{ + "permissions": { + "allow": [ + "Bash(cargo build:*)", + "Bash(cargo check:*)", + "Bash(cargo test:*)", + "Bash(cargo fmt:*)", + "Bash(cargo clippy:*)", + "Bash(cargo tree:*)", + "Bash(git status:*)", + "Bash(git diff:*)", + "Bash(git log:*)" + ], + "ask": [ + "Bash(git push:*)", + "Bash(cargo publish:*)", + "Bash(npm publish:*)", + "Bash(bun publish:*)", + "Bash(docker push:*)", + "Bash(gh pr merge:*)", + "Bash(gh release:*)", + "Bash(aws:*)", + "Bash(gcloud:*)", + "Bash(az:*)", + "Bash(kubectl:*)", + "Bash(helm:*)", + "Bash(terraform:*)", + "Bash(flyctl:*)" + ] + }, + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "bash \"$CLAUDE_PROJECT_DIR/.claude/hooks/ask-before-risky-commands.sh\"" + } + ] + } + ] + } +} diff --git a/.gitignore b/.gitignore index c4e641a..ad2e116 100644 --- a/.gitignore +++ b/.gitignore @@ -9,4 +9,3 @@ examples/minimal_project/config/generated/* .vscode .idea/ -CLAUDE.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..e151fbf --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,88 @@ +# Working agreement — endpointgen + +The operating contract for **any** coding agent working in this repository. This file is +the single source of truth for the rules: Codex, Cursor and Gemini CLI read `AGENTS.md` +natively, and Claude Code loads it through the `@AGENTS.md` import in +[`CLAUDE.md`](CLAUDE.md). **Never fork these rules into a per-vendor file.** + +**Rust binary** (`endpoint-gen`). + +## Invariants (don't break these) + +- **Keep `cargo fmt` and `cargo clippy --all-targets` clean.** Lint failures are part of the build here, not advisory. +- **Publishing to crates.io is irreversible.** A version number can never be reused, and yanking does not delete. Run `cargo publish --dry-run` first, publish from the merged default branch, and tag the release. +- **A pre-release version (`-alpha`, `-beta`) needs an exact dependency pin.** A plain `"2.0"` requirement will not match `2.0.0-alpha.1`, so consumers must be bumped deliberately. +- **Docs describe what is true now.** If you change behaviour, update the README and any affected doc in the same change. + +## Build & test + +```bash +cargo build +cargo test +cargo fmt && cargo clippy --all-targets # run after every change +``` + +## Verification + +Run what you build before reporting it done. Type-checks and tests verify code correctness, +not feature correctness — **if you can't run it, say so explicitly** rather than implying +success. + +- Compare against the base branch rather than asserting: a pre-existing failing test or lint + error is not something you introduced, and saying so requires checking. +- A build that finishes suspiciously fast was cached, not rebuilt. Force a real rebuild when + the rebuild is the thing you're verifying. + +## PR discipline + +**Always paste the full PR URL** (`https://github.com/pathscale/endpointgen/pull/`), not just the number, so it's +clickable. + + + +## Keeping docs honest + +Hit a factual error here — a stale path, a wrong command, a moved status? Fix it in the same +change. Don't open cosmetic rewording PRs. + +Learned something durable — a gotcha, a decision, a constraint? It belongs **in this repo's +docs**, not in your agent's private memory. Repo docs are versioned, reviewable, and visible +to every agent and human; private memory dies with your machine. + +## Git workflow + +- **Always specify the branch when pushing**: `git push origin branch-name` +- **Branch naming**: `fix/issue-description` or `feat/issue-description` +- **Force-push your own branch freely.** Rebasing a feature branch onto a moved + base, or amending before review, is normal and correct — use + `--force-with-lease` so you don't clobber someone else's push. +- **Never force-push the default branch** (`main`/`master`). That is the history + everyone else builds on, and it is protected server-side for a reason. + +## Guardrails + +[`.claude/settings.json`](.claude/settings.json) and [`.claude/hooks/`](.claude/hooks/) make +Claude Code prompt a human before prod-affecting or destructive commands — pushes, publishing +to a registry, `gh pr merge`, cloud CLIs, recursive deletes, deploy scripts. + +**Other agents don't get that net automatically.** Apply the same rule yourself: ask before +running any command family listed in +[`.claude/hooks/ask-before-risky-commands.sh`](.claude/hooks/ask-before-risky-commands.sh). +It is one layer of defence, not a guarantee — a pattern match over a command string is +best-effort. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..63abaf9 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,12 @@ +@AGENTS.md + +# Claude Code notes — endpointgen + +The import above is binding: [`AGENTS.md`](AGENTS.md) is the **working agreement** for this +repository, and every Claude Code session loads it automatically. Don't copy rules here — +one source of truth, no drift. Only genuinely Claude-specific wiring belongs below. + +- Guardrails live in [`.claude/settings.json`](.claude/settings.json): safe read-only + commands are pre-allowed; pushes, publishing, `gh pr merge`, cloud CLIs and deploys prompt + first (`permissions.ask` plus the `PreToolUse` hook in [`.claude/hooks/`](.claude/hooks/)). +- Keep the hook's `RISKY_WORDS` and `permissions.ask` **in sync** — they back each other up.