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.
- package/bin/create-forge.js +138 -1
- package/package.json +1 -1
- package/template/.claude/agents/doc-reviewer.md +115 -0
- package/template/.claude/agents/performance-reviewer.md +138 -0
- package/template/.claude/agents/security-reviewer.md +163 -0
- package/template/.claude/hooks/README.md +39 -0
- package/template/.claude/hooks/block-dangerous-commands.sh +158 -0
- package/template/.claude/hooks/forge-active-skill-guard.sh +44 -0
- package/template/.claude/hooks/format-on-save.sh +95 -0
- package/template/.claude/hooks/protect-files.sh +101 -0
- package/template/.claude/hooks/scan-secrets.sh +87 -0
- package/template/.claude/hooks/tests/README.md +76 -0
- package/template/.claude/hooks/tests/cases/block-dangerous-commands.cases.json +376 -0
- package/template/.claude/hooks/tests/cases/protect-files.cases.json +222 -0
- package/template/.claude/hooks/tests/cases/scan-secrets.cases.json +218 -0
- package/template/.claude/hooks/tests/cases/warn-large-files.cases.json +146 -0
- package/template/.claude/hooks/tests/run.sh +118 -0
- package/template/.claude/hooks/warn-large-files.sh +71 -0
- package/template/.claude/rules/README.md +63 -0
- package/template/.claude/rules/agent-discipline.md +14 -0
- package/template/.claude/settings.json +69 -1
- package/template/.claude/skills/architecting/SKILL.md +1 -1
- package/template/.claude/skills/chief-of-staff/SKILL.md +14 -0
- package/template/.claude/skills/executing/SKILL.md +16 -0
- package/template/.claude/skills/forge/SKILL.md +12 -1
- package/template/.claude/skills/initializing/SKILL.md +4 -0
- package/template/.claude/skills/planning/SKILL.md +13 -1
- package/template/.claude/skills/reviewing/SKILL.md +2 -0
- package/template/.claude/skills/verifying/SKILL.md +19 -8
- package/template/.forge/FORGE.md +16 -3
- package/template/.forge/migrations/0.43.0-safety-policy.md +60 -0
- 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.
|