forge-orkes 0.42.0 → 0.44.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/bin/create-forge.js +138 -1
  2. package/package.json +1 -1
  3. package/template/.claude/agents/doc-reviewer.md +115 -0
  4. package/template/.claude/agents/performance-reviewer.md +138 -0
  5. package/template/.claude/agents/security-reviewer.md +163 -0
  6. package/template/.claude/hooks/README.md +39 -0
  7. package/template/.claude/hooks/block-dangerous-commands.sh +158 -0
  8. package/template/.claude/hooks/forge-active-skill-guard.sh +44 -0
  9. package/template/.claude/hooks/format-on-save.sh +95 -0
  10. package/template/.claude/hooks/protect-files.sh +101 -0
  11. package/template/.claude/hooks/scan-secrets.sh +87 -0
  12. package/template/.claude/hooks/tests/README.md +76 -0
  13. package/template/.claude/hooks/tests/cases/block-dangerous-commands.cases.json +376 -0
  14. package/template/.claude/hooks/tests/cases/protect-files.cases.json +222 -0
  15. package/template/.claude/hooks/tests/cases/scan-secrets.cases.json +218 -0
  16. package/template/.claude/hooks/tests/cases/warn-large-files.cases.json +146 -0
  17. package/template/.claude/hooks/tests/run.sh +118 -0
  18. package/template/.claude/hooks/warn-large-files.sh +71 -0
  19. package/template/.claude/rules/README.md +63 -0
  20. package/template/.claude/rules/agent-discipline.md +14 -0
  21. package/template/.claude/settings.json +69 -1
  22. package/template/.claude/skills/architecting/SKILL.md +1 -1
  23. package/template/.claude/skills/chief-of-staff/SKILL.md +14 -0
  24. package/template/.claude/skills/executing/SKILL.md +16 -0
  25. package/template/.claude/skills/forge/SKILL.md +12 -1
  26. package/template/.claude/skills/initializing/SKILL.md +4 -0
  27. package/template/.claude/skills/planning/SKILL.md +13 -1
  28. package/template/.claude/skills/reviewing/SKILL.md +2 -0
  29. package/template/.claude/skills/verifying/SKILL.md +19 -8
  30. package/template/.forge/FORGE.md +16 -3
  31. package/template/.forge/migrations/0.43.0-safety-policy.md +60 -0
  32. package/template/.forge/migrations/0.44.0-desire-paths.md +57 -0
@@ -0,0 +1,158 @@
1
+ #!/usr/bin/env bash
2
+ # Forge safety guardrail — blocks destructive shell commands.
3
+ # PreToolUse hook for Bash. Exit 2 = block. Exit 0 = allow.
4
+ #
5
+ # Deterministic belt-and-braces: this fires regardless of what the model decides,
6
+ # so it catches the cases a model-driven skill (securing) would miss when skipped.
7
+ #
8
+ # Configurable via env:
9
+ # CLAUDE_PROTECTED_BRANCHES comma list of branches push is refused to.
10
+ # Default: main,master + git init.defaultBranch.
11
+ # Set to "" (empty) to DISABLE the protected-branch
12
+ # push check only — force-push, fs, db, and publish
13
+ # guards still apply. Forge's own dev repo does this
14
+ # because framework work legitimately lands on main.
15
+
16
+ set -uo pipefail
17
+
18
+ emit_deny() {
19
+ local reason="${1//\"/\\\"}"
20
+ printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"%s"}}\n' "$reason"
21
+ exit 2
22
+ }
23
+
24
+ if ! command -v jq >/dev/null 2>&1; then
25
+ emit_deny "jq is required for command protection hooks but is not installed. Install with: brew install jq (macOS) or apt install jq (Linux)."
26
+ fi
27
+
28
+ INPUT=$(cat)
29
+ COMMAND=$(printf '%s' "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null || true)
30
+ [ -z "$COMMAND" ] && exit 0
31
+
32
+ contains_cmd() { printf '%s' "$COMMAND" | grep -qE "$1"; }
33
+ contains_icmd() { printf '%s' "$COMMAND" | grep -qiE "$1"; }
34
+
35
+ # ── Protected branch list ────────────────────────────────────────────────
36
+ # Use ${VAR-default} (no colon) so an explicitly-empty value disables the check
37
+ # while an unset value falls back to the default.
38
+ DEFAULT_BRANCHES="main,master"
39
+ if GIT_DEFAULT=$(git config --get init.defaultBranch 2>/dev/null) && [ -n "$GIT_DEFAULT" ]; then
40
+ DEFAULT_BRANCHES="$DEFAULT_BRANCHES,$GIT_DEFAULT"
41
+ fi
42
+ PROTECTED_BRANCHES="${CLAUDE_PROTECTED_BRANCHES-$DEFAULT_BRANCHES}"
43
+
44
+ # ── Git push protections ────────────────────────────────────────────────
45
+ if contains_cmd '(^|[;&|()]+[[:space:]]*)git[[:space:]]+push'; then
46
+ # Protected-branch checks are skipped when the list is empty.
47
+ if [ -n "$PROTECTED_BRANCHES" ]; then
48
+ BR_REGEX=$(printf '%s' "$PROTECTED_BRANCHES" | tr ',' '\n' | awk 'NF{printf "%s%s",sep,$0; sep="|"}')
49
+ # Explicit refspec to a protected branch (origin main, :main, HEAD:main)
50
+ if contains_cmd "git[[:space:]]+push[[:space:]]+[^[:space:]]+[[:space:]]+([^[:space:]]*:)?($BR_REGEX)(\$|[[:space:]])"; then
51
+ MATCHED_BRANCH=$(printf '%s' "$COMMAND" | grep -oE "($BR_REGEX)(\$|[[:space:]])" | head -1 | tr -d '[:space:]')
52
+ emit_deny "Blocked: push to protected branch '${MATCHED_BRANCH:-main}'. Use a feature branch and open a PR."
53
+ fi
54
+ if contains_cmd "git[[:space:]]+push.*:($BR_REGEX)(\$|[[:space:]])"; then
55
+ MATCHED_BRANCH=$(printf '%s' "$COMMAND" | grep -oE ":($BR_REGEX)(\$|[[:space:]])" | head -1 | tr -d ': [:space:]')
56
+ emit_deny "Blocked: push to protected branch '${MATCHED_BRANCH:-main}' via refspec. Use a feature branch and open a PR."
57
+ fi
58
+ # Bare `git push` while on protected branch
59
+ if contains_cmd 'git[[:space:]]+push[[:space:]]*($|[;&|])'; then
60
+ CURRENT=$(git branch --show-current 2>/dev/null || true)
61
+ if [ -n "$CURRENT" ] && printf '%s' ",$PROTECTED_BRANCHES," | grep -q ",$CURRENT,"; then
62
+ emit_deny "Blocked: you are on '$CURRENT' (a protected branch). Switch to a feature branch."
63
+ fi
64
+ fi
65
+ fi
66
+ # Force push (but allow --force-with-lease). Always enforced — Forge never force-pushes.
67
+ if contains_cmd 'git[[:space:]]+push([[:space:]]+[^[:space:]]+)*[[:space:]]+(-[a-zA-Z]*f[a-zA-Z]*|--force)([[:space:]=]|$)' \
68
+ && ! contains_cmd '\-\-force-with-lease'; then
69
+ emit_deny "Blocked: force push is not allowed. Use --force-with-lease if you must overwrite remote."
70
+ fi
71
+ fi
72
+
73
+ # ── Destructive filesystem operations ───────────────────────────────────
74
+ # rm -rf targeting root, home, $HOME, $VAR (any unresolved expansion), or parent traversal.
75
+ CMD_NOQUOTE=$(printf '%s' "$COMMAND" | tr -d "'\"")
76
+ if printf '%s' "$CMD_NOQUOTE" | grep -qE 'rm[[:space:]]+(-[a-zA-Z]*[[:space:]]+)*-?[a-zA-Z]*r[a-zA-Z]*f[a-zA-Z]*[[:space:]]+(/([[:space:]]|\*|$)|~|\$HOME|\$[A-Za-z_][A-Za-z0-9_]*|\.\./\.\.)' ; then
77
+ emit_deny "Blocked: recursive force-delete on /, ~, \$HOME, an unresolved \$VAR, or .../.. path. Specify a concrete safe target."
78
+ fi
79
+ # rm -rf /usr, /etc, /var, /bin, etc.
80
+ if printf '%s' "$CMD_NOQUOTE" | grep -qE 'rm[[:space:]]+(-[a-zA-Z]+[[:space:]]+)*-?[a-zA-Z]*r[a-zA-Z]*f[a-zA-Z]*[[:space:]]+/(usr|etc|var|bin|sbin|lib|opt|root|boot)([[:space:]/]|$)'; then
81
+ emit_deny "Blocked: recursive delete targeting a system directory."
82
+ fi
83
+ # rm -rf where the target is produced by $(...) or `...` command substitution.
84
+ if printf '%s' "$CMD_NOQUOTE" | grep -qE 'rm[[:space:]]+(-[a-zA-Z]*[[:space:]]+)*-?[a-zA-Z]*r[a-zA-Z]*f[a-zA-Z]*[[:space:]]+[^;&|]*(\$\(|`)'; then
85
+ emit_deny "Blocked: recursive force-delete with a shell-substituted target (\$(...) or \`...\`). Pass a literal path."
86
+ fi
87
+
88
+ # ── Dangerous database operations ───────────────────────────────────────
89
+ if contains_icmd 'DROP[[:space:]]+(TABLE|DATABASE|SCHEMA)[[:space:]]+'; then
90
+ emit_deny "Blocked: DROP TABLE/DATABASE/SCHEMA detected. Run manually if intended."
91
+ fi
92
+ # DELETE FROM without a WHERE on the SAME statement (split on ';').
93
+ if printf '%s\n' "$COMMAND" | awk '
94
+ BEGIN { IGNORECASE=1; RS=";" }
95
+ /DELETE[[:space:]]+FROM[[:space:]]+[A-Za-z_][A-Za-z0-9_.]*/ {
96
+ if ($0 !~ /WHERE/) { print "BAD"; exit }
97
+ }
98
+ ' | grep -q BAD; then
99
+ emit_deny "Blocked: DELETE FROM without a WHERE clause. Add a WHERE or run manually."
100
+ fi
101
+ if contains_icmd 'TRUNCATE[[:space:]]+TABLE'; then
102
+ emit_deny "Blocked: TRUNCATE TABLE detected. Run manually if intended."
103
+ fi
104
+
105
+ # ── Framework-specific destructive migration commands ───────────────────
106
+ # These drop data. Block — require manual invocation against a dev DB.
107
+ if contains_icmd '(php[[:space:]]+artisan[[:space:]]+(migrate:fresh|migrate:reset|migrate:refresh|db:wipe)|(rails|rake)[[:space:]]+db:(drop|reset)|prisma[[:space:]]+migrate[[:space:]]+reset|sequelize[[:space:]]+db:drop)([[:space:]]|$)'; then
108
+ emit_deny "Blocked: destructive migration command detected (drops/recreates the database). Run manually, and only against a dev DB."
109
+ fi
110
+
111
+ # ── Dangerous system commands ───────────────────────────────────────────
112
+ # chmod: any world-writable/universal mode (0?777 or a+rwx)
113
+ if contains_cmd 'chmod([[:space:]]+-[a-zA-Z]+)*[[:space:]]+0?777([[:space:]]|$)' \
114
+ || contains_cmd 'chmod([[:space:]]+-[a-zA-Z]+)*[[:space:]]+a\+rwx([[:space:]]|$)'; then
115
+ emit_deny "Blocked: chmod 777 / a+rwx grants everyone full access. Use restrictive perms."
116
+ fi
117
+
118
+ # curl/wget piped to a shell
119
+ if contains_cmd '(curl|wget)[[:space:]].*\|[[:space:]]*(sudo[[:space:]]+)?(bash|sh|zsh|ksh|fish|dash|csh)([[:space:]]|$)'; then
120
+ emit_deny "Blocked: piping downloaded content directly to a shell is dangerous."
121
+ fi
122
+
123
+ # Disk / partition. Only REDIRECTIONS to /dev/ are destructive — `2>/dev/null` is not.
124
+ if printf '%s' "$COMMAND" | grep -qE '(^|[^0-9&])>[[:space:]]*/dev/[a-zA-Z][a-zA-Z0-9]*' \
125
+ && ! printf '%s' "$COMMAND" | grep -qE '>[[:space:]]*/dev/(null|stdout|stderr|tty|zero|random|urandom)([[:space:]]|$)' ; then
126
+ emit_deny "Blocked: redirection into a raw device file can destroy data."
127
+ fi
128
+ if contains_cmd '(^|[;&|[:space:]])(mkfs|mkfs\.[a-z0-9]+)([[:space:]]|$)' \
129
+ || contains_cmd '(^|[;&|[:space:]])dd[[:space:]]+[^|]*(if|of)=/dev/[a-zA-Z]' ; then
130
+ emit_deny "Blocked: mkfs/dd against a device node. Irreversible data loss."
131
+ fi
132
+
133
+ # ── Destructive git ─────────────────────────────────────────────────────
134
+ if contains_cmd 'git[[:space:]]+reset[[:space:]]+--hard'; then
135
+ emit_deny "Blocked: git reset --hard discards uncommitted changes permanently. Use git stash or commit first."
136
+ fi
137
+ if contains_cmd 'git[[:space:]]+clean[[:space:]]+-[a-zA-Z]*f'; then
138
+ emit_deny "Blocked: git clean -f permanently deletes untracked files."
139
+ fi
140
+
141
+ # ── Accidental package publishing ───────────────────────────────────────
142
+ # Allow --dry-run variants. Covers the common package managers.
143
+ publish_patterns=(
144
+ '(npm|yarn|pnpm|bun)[[:space:]]+publish'
145
+ 'cargo[[:space:]]+publish'
146
+ 'gem[[:space:]]+push'
147
+ 'twine[[:space:]]+upload'
148
+ 'uv[[:space:]]+publish'
149
+ 'poetry[[:space:]]+publish'
150
+ 'composer[[:space:]]+publish'
151
+ )
152
+ for pat in "${publish_patterns[@]}"; do
153
+ if contains_cmd "$pat" && ! contains_cmd '(^|[[:space:]])(--dry-run|-n)([[:space:]=]|$)'; then
154
+ emit_deny "Blocked: publishing packages should run in CI or be done manually, not via the agent."
155
+ fi
156
+ done
157
+
158
+ exit 0
@@ -0,0 +1,44 @@
1
+ #!/usr/bin/env bash
2
+ # Forge PreToolUse(Write|Edit) workflow guard.
3
+ #
4
+ # Blocks code edits when no Forge skill is driving the session, nudging the
5
+ # operator to invoke /forge or /quick-tasking first. A skill IS active when
6
+ # EITHER signal holds:
7
+ #
8
+ # (a) the ephemeral marker .forge/.active-skill exists — written by the
9
+ # PostToolUse(Skill) hook on every Skill invocation; reliable for short
10
+ # skills (quick-tasking) but cleared between turns / at session
11
+ # boundaries, so a long executing run loses it mid-flight; OR
12
+ # (b) a milestone is mid-execution (current.status: executing) — the durable
13
+ # signal that survives across turns because it lives in a committed state
14
+ # file, not an ephemeral marker.
15
+ #
16
+ # (b) is the fix for the failure mode where the marker was cleared 3+ times in
17
+ # one executing session and legitimate edits got blocked (forge#12). When (b)
18
+ # holds we also re-assert the marker so the rest of the turn is cheap.
19
+ #
20
+ # Contract: exit 0 = allow, exit 2 = block with guidance. Advisory workflow
21
+ # gate — the documented bypass is `touch .forge/.active-skill`.
22
+
23
+ set -uo pipefail
24
+
25
+ ROOT="$(git rev-parse --show-toplevel 2>/dev/null)" || ROOT="${CLAUDE_PROJECT_DIR:-$PWD}"
26
+ [ -n "$ROOT" ] || ROOT="$PWD"
27
+ FORGE="$ROOT/.forge"
28
+
29
+ # (a) marker present → active.
30
+ [ -f "$FORGE/.active-skill" ] && exit 0
31
+
32
+ # (b) a milestone is executing → durable "skill active" signal that outlives the
33
+ # marker. Match the indented `current.status: executing` line; a milestone
34
+ # file is small, so the grep is cheap.
35
+ if ls "$FORGE"/state/milestone-*.yml >/dev/null 2>&1; then
36
+ if grep -qE '^[[:space:]]+status:[[:space:]]*executing([[:space:]]|$)' "$FORGE"/state/milestone-*.yml 2>/dev/null; then
37
+ # Self-heal: re-assert the marker so later checks this turn short-circuit at (a).
38
+ printf 'executing\n' > "$FORGE/.active-skill" 2>/dev/null || true
39
+ exit 0
40
+ fi
41
+ fi
42
+
43
+ echo "[Forge] No active skill. Invoke /forge or /quick-tasking before editing code. To bypass: touch .forge/.active-skill" >&2
44
+ exit 2
@@ -0,0 +1,95 @@
1
+ #!/usr/bin/env bash
2
+ # Forge — runs the project's formatter after the agent edits a file.
3
+ # PostToolUse hook for Edit|Write|MultiEdit. Always exits 0 — a formatting
4
+ # failure must never block subsequent work.
5
+ #
6
+ # "Most specific project config wins". A formatter only runs when BOTH the tool
7
+ # and the project's config for it are present, so this never fires against repos
8
+ # that haven't opted in.
9
+
10
+ set -uo pipefail
11
+
12
+ # jq missing → silently skip. Never block work over formatting.
13
+ command -v jq >/dev/null 2>&1 || exit 0
14
+
15
+ INPUT=$(cat)
16
+ FILE_PATH=$(printf '%s' "$INPUT" | jq -r '.tool_input.file_path // empty' 2>/dev/null || true)
17
+ [ -z "$FILE_PATH" ] && exit 0
18
+ [ ! -f "$FILE_PATH" ] && exit 0
19
+
20
+ EXT="${FILE_PATH##*.}"
21
+ EXT_LC=$(printf '%s' "$EXT" | tr '[:upper:]' '[:lower:]')
22
+
23
+ REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || pwd)
24
+
25
+ has_file() { [ -f "$REPO_ROOT/$1" ]; }
26
+ has_any() { for f in "$@"; do has_file "$f" && return 0; done; return 1; }
27
+ pyproject_has() { has_file "pyproject.toml" && grep -q "^\[$1\]" "$REPO_ROOT/pyproject.toml" 2>/dev/null; }
28
+ pkgjson_has_key() { has_file "package.json" && grep -q "\"$1\"" "$REPO_ROOT/package.json" 2>/dev/null; }
29
+
30
+ run() {
31
+ printf 'format-on-save: %s\n' "$*" >&2
32
+ "$@" >/dev/null 2>&1 || true
33
+ }
34
+
35
+ case "$EXT_LC" in
36
+ # ── Python ────────────────────────────────────────────────────────────
37
+ py)
38
+ if command -v ruff >/dev/null 2>&1 \
39
+ && ( has_any "ruff.toml" ".ruff.toml" || pyproject_has "tool.ruff" ); then
40
+ run ruff format "$FILE_PATH"
41
+ run ruff check --fix "$FILE_PATH"
42
+ elif command -v black >/dev/null 2>&1 && pyproject_has "tool.black"; then
43
+ run black --quiet "$FILE_PATH"
44
+ command -v isort >/dev/null 2>&1 && run isort --quiet "$FILE_PATH"
45
+ fi
46
+ ;;
47
+ # ── PHP / Laravel ─────────────────────────────────────────────────────
48
+ php)
49
+ if [ -x "$REPO_ROOT/vendor/bin/pint" ]; then
50
+ run "$REPO_ROOT/vendor/bin/pint" "$FILE_PATH"
51
+ elif command -v php-cs-fixer >/dev/null 2>&1 && has_any ".php-cs-fixer.php" ".php-cs-fixer.dist.php"; then
52
+ run php-cs-fixer fix "$FILE_PATH"
53
+ fi
54
+ ;;
55
+ # ── JS / TS / Vue / CSS / HTML / JSON / MD / YAML ─────────────────────
56
+ js|jsx|ts|tsx|vue|mjs|cjs|css|scss|sass|less|html|json|jsonc|md|mdx|yml|yaml)
57
+ if has_any "biome.json" "biome.jsonc" && [ -x "$REPO_ROOT/node_modules/.bin/biome" ]; then
58
+ run "$REPO_ROOT/node_modules/.bin/biome" format --write "$FILE_PATH"
59
+ run "$REPO_ROOT/node_modules/.bin/biome" check --write "$FILE_PATH"
60
+ else
61
+ if [ -x "$REPO_ROOT/node_modules/.bin/prettier" ] \
62
+ && ( has_any ".prettierrc" ".prettierrc.json" ".prettierrc.js" ".prettierrc.cjs" ".prettierrc.mjs" ".prettierrc.yaml" ".prettierrc.yml" "prettier.config.js" "prettier.config.cjs" "prettier.config.mjs" \
63
+ || pkgjson_has_key "prettier" ); then
64
+ run "$REPO_ROOT/node_modules/.bin/prettier" --write --log-level=silent "$FILE_PATH"
65
+ fi
66
+ case "$EXT_LC" in
67
+ js|jsx|ts|tsx|vue|mjs|cjs)
68
+ if [ -x "$REPO_ROOT/node_modules/.bin/eslint" ] \
69
+ && ( has_any ".eslintrc" ".eslintrc.js" ".eslintrc.cjs" ".eslintrc.json" ".eslintrc.yml" ".eslintrc.yaml" "eslint.config.js" "eslint.config.mjs" "eslint.config.cjs" "eslint.config.ts" \
70
+ || pkgjson_has_key "eslintConfig" ); then
71
+ run "$REPO_ROOT/node_modules/.bin/eslint" --fix "$FILE_PATH"
72
+ fi
73
+ ;;
74
+ esac
75
+ fi
76
+ ;;
77
+ # ── Rust ──────────────────────────────────────────────────────────────
78
+ rs)
79
+ command -v rustfmt >/dev/null 2>&1 && run rustfmt "$FILE_PATH"
80
+ ;;
81
+ # ── Go ────────────────────────────────────────────────────────────────
82
+ go)
83
+ if command -v goimports >/dev/null 2>&1; then
84
+ run goimports -w "$FILE_PATH"
85
+ elif command -v gofmt >/dev/null 2>&1; then
86
+ run gofmt -w "$FILE_PATH"
87
+ fi
88
+ ;;
89
+ # ── Shell ─────────────────────────────────────────────────────────────
90
+ sh|bash)
91
+ command -v shfmt >/dev/null 2>&1 && run shfmt -w "$FILE_PATH"
92
+ ;;
93
+ esac
94
+
95
+ exit 0
@@ -0,0 +1,101 @@
1
+ #!/usr/bin/env bash
2
+ # Forge safety guardrail — blocks edits to sensitive or generated files.
3
+ # PreToolUse hook for Edit|Write. Exit 2 = block. Exit 0 = allow.
4
+
5
+ set -uo pipefail
6
+
7
+ emit() {
8
+ # $1 = decision (deny|ask) ; $2 = reason
9
+ local decision="$1"
10
+ local reason="${2//\"/\\\"}"
11
+ printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"%s","permissionDecisionReason":"%s"}}\n' "$decision" "$reason"
12
+ exit 2
13
+ }
14
+
15
+ if ! command -v jq >/dev/null 2>&1; then
16
+ emit deny "jq is required for file protection hooks but is not installed. Install with: brew install jq (macOS) or apt install jq (Linux)."
17
+ fi
18
+
19
+ INPUT=$(cat)
20
+ FILE_PATH=$(printf '%s' "$INPUT" | jq -r '.tool_input.file_path // empty' 2>/dev/null || true)
21
+ [ -z "$FILE_PATH" ] && exit 0
22
+
23
+ BASENAME=$(basename -- "$FILE_PATH")
24
+ BASENAME_LC=$(printf '%s' "$BASENAME" | tr '[:upper:]' '[:lower:]')
25
+ PATH_LC=$(printf '%s' "$FILE_PATH" | tr '[:upper:]' '[:lower:]')
26
+
27
+ # Allowlist: template/example env files are meant to be checked in and edited.
28
+ case "$BASENAME_LC" in
29
+ .env.example|.env.sample|.env.dist|.env.template)
30
+ exit 0
31
+ ;;
32
+ esac
33
+
34
+ # Protected basename patterns (matched case-insensitively).
35
+ PROTECTED_PATTERNS=(
36
+ ".env"
37
+ ".env.*"
38
+ "*.pem"
39
+ "*.key"
40
+ "*.crt"
41
+ "*.p12"
42
+ "*.pfx"
43
+ "id_rsa"
44
+ "id_ed25519"
45
+ "credentials.json"
46
+ ".npmrc"
47
+ ".pypirc"
48
+ ".composer-auth.json"
49
+ "auth.json"
50
+ # Lock files — update through the package manager, never by hand.
51
+ "package-lock.json"
52
+ "yarn.lock"
53
+ "pnpm-lock.yaml"
54
+ "composer.lock"
55
+ "uv.lock"
56
+ "poetry.lock"
57
+ "Gemfile.lock"
58
+ "Cargo.lock"
59
+ "go.sum"
60
+ # Generated / minified
61
+ "*.gen.ts"
62
+ "*.generated.*"
63
+ "*.min.js"
64
+ "*.min.css"
65
+ )
66
+
67
+ shopt -s nocasematch 2>/dev/null || true
68
+ for pattern in "${PROTECTED_PATTERNS[@]}"; do
69
+ case "$BASENAME_LC" in
70
+ $pattern)
71
+ emit deny "Protected file: $BASENAME matches pattern '$pattern'. Edit via the package manager or the appropriate secret store."
72
+ ;;
73
+ esac
74
+ done
75
+
76
+ # Sensitive directories (lower-cased path for case-insensitive matches).
77
+ case "$PATH_LC" in
78
+ .git/*|*/.git/*)
79
+ emit deny "Cannot edit files inside .git/" ;;
80
+ secrets/*|*/secrets/*)
81
+ emit deny "Cannot edit files inside secrets/" ;;
82
+ .secrets/*|*/.secrets/*)
83
+ emit deny "Cannot edit files inside .secrets/" ;;
84
+ .env|.env.*|*/.env|*/.env.*)
85
+ emit deny "Cannot edit .env files" ;;
86
+ esac
87
+
88
+ # Framework-authoring files. In a normal project these are re-synced via
89
+ # /upgrading, never hand-edited — so we block hooks and prompt on settings.
90
+ # FORGE_ALLOW_HOOK_EDITS=1 lifts both, set only in the Forge framework repo
91
+ # itself (where authoring hooks and settings IS the work).
92
+ if [ -z "${FORGE_ALLOW_HOOK_EDITS:-}" ]; then
93
+ case "$PATH_LC" in
94
+ .claude/hooks/*|*/.claude/hooks/*)
95
+ emit deny "Cannot edit hook scripts — these enforce safety boundaries. Update them in the framework repo and re-sync (/upgrading)." ;;
96
+ .claude/settings.json|*/.claude/settings.json|.claude/settings.local.json|*/.claude/settings.local.json)
97
+ emit ask "Editing settings.json — this controls permissions and hooks. Confirm this change." ;;
98
+ esac
99
+ fi
100
+
101
+ exit 0
@@ -0,0 +1,87 @@
1
+ #!/usr/bin/env bash
2
+ # Forge safety guardrail — scans file content for accidental secrets before writing.
3
+ # PreToolUse hook for Edit|Write. Exit 2 = block (decision "ask" so the user can
4
+ # override for genuine test fixtures). Exit 0 = allow.
5
+
6
+ set -uo pipefail
7
+
8
+ emit_deny() {
9
+ local reason="${1//\"/\\\"}"
10
+ printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"%s"}}\n' "$reason"
11
+ exit 2
12
+ }
13
+
14
+ if ! command -v jq >/dev/null 2>&1; then
15
+ emit_deny "jq is required for secret scanning but is not installed. Install with: brew install jq (macOS) or apt install jq (Linux)."
16
+ fi
17
+
18
+ INPUT=$(cat)
19
+ TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // empty')
20
+
21
+ if [ "$TOOL_NAME" = "Write" ]; then
22
+ CONTENT=$(echo "$INPUT" | jq -r '.tool_input.content // empty')
23
+ elif [ "$TOOL_NAME" = "Edit" ]; then
24
+ CONTENT=$(echo "$INPUT" | jq -r '.tool_input.new_string // empty')
25
+ else
26
+ exit 0
27
+ fi
28
+
29
+ [ -z "$CONTENT" ] && exit 0
30
+
31
+ MATCHES=""
32
+
33
+ # AWS Access Key IDs
34
+ if echo "$CONTENT" | grep -qE 'AKIA[0-9A-Z]{16}'; then
35
+ MATCHES="$MATCHES AWS access key (AKIA...);"
36
+ fi
37
+ # AWS Secret Access Keys (40 chars base64 after a key assignment)
38
+ if echo "$CONTENT" | grep -qiE '(aws_secret_access_key|secret_key)[[:space:]]*[=:][[:space:]]*["\x27]?[A-Za-z0-9/+=]{40}'; then
39
+ MATCHES="$MATCHES AWS secret key;"
40
+ fi
41
+ # GitHub tokens (PAT, OAuth, App)
42
+ if echo "$CONTENT" | grep -qE '(ghp_|gho_|ghs_|ghr_|github_pat_)[a-zA-Z0-9_]{20,}'; then
43
+ MATCHES="$MATCHES GitHub token;"
44
+ fi
45
+ # OpenAI / Anthropic / Stripe style keys (sk-...)
46
+ if echo "$CONTENT" | grep -qE 'sk-[a-zA-Z0-9]{20,}'; then
47
+ MATCHES="$MATCHES API key (sk-...);"
48
+ fi
49
+ # Anthropic-specific keys
50
+ if echo "$CONTENT" | grep -qE 'sk-ant-[a-zA-Z0-9_-]{20,}'; then
51
+ MATCHES="$MATCHES Anthropic API key;"
52
+ fi
53
+ # Slack tokens
54
+ if echo "$CONTENT" | grep -qE 'xox[bpras]-[0-9a-zA-Z-]{10,}'; then
55
+ MATCHES="$MATCHES Slack token;"
56
+ fi
57
+ # Google API keys
58
+ if echo "$CONTENT" | grep -qE 'AIza[0-9A-Za-z_-]{35}'; then
59
+ MATCHES="$MATCHES Google API key;"
60
+ fi
61
+ # Private key blocks
62
+ if echo "$CONTENT" | grep -qE -- '-----BEGIN[[:space:]]+(RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----'; then
63
+ MATCHES="$MATCHES private key block;"
64
+ fi
65
+ # Connection strings with embedded credentials
66
+ if echo "$CONTENT" | grep -qE '(mongodb|postgres|postgresql|mysql|redis|amqp|smtp|mssql)(\+[a-z]+)?://[^:[:space:]]+:[^@[:space:]]+@'; then
67
+ MATCHES="$MATCHES connection string with credentials;"
68
+ fi
69
+ # Laravel APP_KEY with a real base64 value (not a placeholder)
70
+ if echo "$CONTENT" | grep -qE 'APP_KEY[[:space:]]*=[[:space:]]*base64:[A-Za-z0-9+/=]{40,}'; then
71
+ MATCHES="$MATCHES Laravel APP_KEY;"
72
+ fi
73
+ # Generic password/secret/token assignments with literal string values.
74
+ # Excludes env var references (process.env, os.environ, getenv, ${...}, ENV[, env(, config()).
75
+ if echo "$CONTENT" | grep -qiE '(password|secret|token|api_key|apikey|api_secret)[[:space:]]*[=:][[:space:]]*["\x27][^"\x27]{8,}["\x27]' && \
76
+ ! echo "$CONTENT" | grep -qiE '(password|secret|token|api_key|apikey|api_secret)[[:space:]]*[=:][[:space:]]*["\x27]?(process\.env|os\.environ|getenv|\$\{|ENV\[|env\(|config\()'; then
77
+ MATCHES="$MATCHES hardcoded credential;"
78
+ fi
79
+
80
+ if [ -n "$MATCHES" ]; then
81
+ # "ask" not "deny" — warn but let the user override (could be a fixture).
82
+ REASON="Possible secret detected in content:$MATCHES Review carefully before allowing."
83
+ echo "{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"ask\",\"permissionDecisionReason\":\"$REASON\"}}"
84
+ exit 2
85
+ fi
86
+
87
+ exit 0
@@ -0,0 +1,76 @@
1
+ # Hook tests
2
+
3
+ Data-driven test harness for the Claude Code hooks in `.claude/hooks/`. Cases live in JSON files, the driver pipes each input to the hook and checks the exit code and stdout.
4
+
5
+ ## Running
6
+
7
+ ```bash
8
+ # All hooks
9
+ .claude/hooks/tests/run.sh
10
+
11
+ # One hook
12
+ .claude/hooks/tests/run.sh protect-files
13
+ .claude/hooks/tests/run.sh block-dangerous-commands
14
+ .claude/hooks/tests/run.sh warn-large-files
15
+ ```
16
+
17
+ Requires `jq`. Install with `brew install jq` (macOS) or `apt install jq` (Linux).
18
+
19
+ ## Layout
20
+
21
+ ```
22
+ tests/
23
+ run.sh # driver
24
+ cases/
25
+ <hook-name>.cases.json # test cases for .claude/hooks/<hook-name>.sh
26
+ ```
27
+
28
+ The driver matches cases to hook scripts by filename: `cases/protect-files.cases.json` runs against `.claude/hooks/protect-files.sh`. If the hook script is missing or not executable, the test file is skipped.
29
+
30
+ ## Case schema
31
+
32
+ Each case file is a JSON array of objects:
33
+
34
+ ```json
35
+ {
36
+ "name": "denies editing .env at repo root",
37
+ "input": { "tool_name": "Edit", "tool_input": { "file_path": ".env" } },
38
+ "expect_exit": 2,
39
+ "expect_stdout_contains": "\"permissionDecision\":\"deny\"",
40
+ "expect_stdout_not_contains": "allow"
41
+ }
42
+ ```
43
+
44
+ | Field | Required | Meaning |
45
+ |---|---|---|
46
+ | `name` | yes | Human-readable test label |
47
+ | `input` | yes | JSON piped to the hook on stdin — matches Claude Code's hook input format |
48
+ | `expect_exit` | no (default 0) | Expected process exit code. `2` = permission denied, `0` = allowed |
49
+ | `expect_stdout_contains` | no | Substring that must appear in stdout (combined with stderr) |
50
+ | `expect_stdout_not_contains` | no | Substring that must NOT appear |
51
+
52
+ The driver combines stdout and stderr, so a hook that writes errors to stderr is still matched against `expect_stdout_contains`.
53
+
54
+ ## Adding a new case
55
+
56
+ 1. Pick the right case file (`cases/<hook>.cases.json`) — or create one if you are testing a new hook.
57
+ 2. Append a new object to the array. Keep the name as a sentence that reads like a spec: `"denies rm -rf $HOME"`, `"allows artisan migrate"`.
58
+ 3. Run the hook-specific filter to verify: `.claude/hooks/tests/run.sh <hook>`.
59
+
60
+ ## Adding a new hook
61
+
62
+ 1. Write the hook at `.claude/hooks/<hook-name>.sh` and `chmod +x` it.
63
+ 2. Create `cases/<hook-name>.cases.json` with at least one deny case, one allow case, and one no-op case (missing input field).
64
+ 3. Run `.claude/hooks/tests/run.sh <hook-name>`.
65
+
66
+ ## What good coverage looks like
67
+
68
+ For a `deny`/`allow` style hook, every case file should include:
69
+
70
+ - At least one **deny** per distinct rule the hook implements (so the hook script cannot be trivially gutted without turning tests red).
71
+ - At least one **allow** that sits right next to a deny path (e.g. `git push origin main` denies, `git push origin feature/foo` allows) — proves the pattern is specific, not blanket.
72
+ - One **no-op** case where the hook input does not match the shape the hook expects (e.g. `file_path` missing, `command` empty) — proves the hook fails open for irrelevant events.
73
+
74
+ ## Why this lives here
75
+
76
+ Hooks run on every tool call the agent makes. If they regress silently, either the agent starts bypassing real protections, or innocent edits start getting blocked and the hooks get disabled in frustration. A small test harness catches both before they land.