forge-orkes 0.42.0 → 0.46.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/forge-reserve.sh +162 -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/forge-reserve.test.sh +121 -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 +14 -2
- package/template/.claude/skills/reviewing/SKILL.md +2 -0
- package/template/.claude/skills/verifying/SKILL.md +19 -8
- package/template/.forge/FORGE.md +32 -15
- package/template/.forge/migrations/0.43.0-safety-policy.md +60 -0
- package/template/.forge/migrations/0.44.0-desire-paths.md +57 -0
- package/template/.forge/migrations/0.46.0-id-reservation-ledger.md +44 -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,162 @@
|
|
|
1
|
+
#!/usr/bin/env sh
|
|
2
|
+
# forge-reserve — allocate a collision-safe sequential id (ADR/DEF/FR/NFR).
|
|
3
|
+
#
|
|
4
|
+
# Implements ADR-021: the coordination point is a machine-local ledger in the
|
|
5
|
+
# git COMMON dir (.git/forge/id-reservations), shared instantly across every
|
|
6
|
+
# sibling worktree of the repo on this machine — the input ADR-016's
|
|
7
|
+
# branch-tracked reservations.yml could not see. Same primitive as the
|
|
8
|
+
# integration flag (.git/forge/integration-pending).
|
|
9
|
+
#
|
|
10
|
+
# forge-reserve <kind> --milestone <id> [--summary <text>]
|
|
11
|
+
# kind ∈ {adr|def|fr|nfr}
|
|
12
|
+
#
|
|
13
|
+
# stdout: the allocated id and NOTHING else (e.g. ADR-022). All logs → stderr.
|
|
14
|
+
# exit: 0 success; 2 bad/missing kind or args; 3 lock timeout. On non-zero
|
|
15
|
+
# exit nothing is written.
|
|
16
|
+
#
|
|
17
|
+
# Side effects (both UNcommitted — the caller commits reservations.yml with its
|
|
18
|
+
# work): appends one TSV line to the common-dir ledger and one YAML block to the
|
|
19
|
+
# worktree's .forge/reservations.yml, both under a portable mkdir lock.
|
|
20
|
+
#
|
|
21
|
+
# Pure POSIX sh — no bashisms, no flock (absent on macOS, ADR-021 point 3),
|
|
22
|
+
# no node/python. Only git/mkdir/awk/grep/sed/date/stat.
|
|
23
|
+
set -eu
|
|
24
|
+
|
|
25
|
+
WAIT="${FORGE_RESERVE_WAIT:-5}" # seconds: bounded lock-acquire budget
|
|
26
|
+
STALE="${FORGE_RESERVE_STALE:-15}" # seconds: a lock older than this is broken
|
|
27
|
+
SLEEP="0.1" # retry interval while the lock is held
|
|
28
|
+
|
|
29
|
+
# --- 1. parse args ----------------------------------------------------------
|
|
30
|
+
kind=""
|
|
31
|
+
milestone=""
|
|
32
|
+
summary=""
|
|
33
|
+
while [ $# -gt 0 ]; do
|
|
34
|
+
case "$1" in
|
|
35
|
+
--milestone) shift; milestone="${1:-}"; [ $# -gt 0 ] && shift || true ;;
|
|
36
|
+
--milestone=*) milestone="${1#*=}"; shift ;;
|
|
37
|
+
--summary) shift; summary="${1:-}"; [ $# -gt 0 ] && shift || true ;;
|
|
38
|
+
--summary=*) summary="${1#*=}"; shift ;;
|
|
39
|
+
-*) printf 'forge-reserve: unknown option: %s\n' "$1" >&2; exit 2 ;;
|
|
40
|
+
*)
|
|
41
|
+
if [ -z "$kind" ]; then kind="$1"; shift
|
|
42
|
+
else printf 'forge-reserve: unexpected argument: %s\n' "$1" >&2; exit 2; fi
|
|
43
|
+
;;
|
|
44
|
+
esac
|
|
45
|
+
done
|
|
46
|
+
|
|
47
|
+
# Validate kind BEFORE touching git, the lock, or any file — a bad kind must
|
|
48
|
+
# exit non-zero having written nothing (must_have truth #3).
|
|
49
|
+
case "$kind" in
|
|
50
|
+
adr) prefix="ADR" ;;
|
|
51
|
+
def) prefix="DEF" ;;
|
|
52
|
+
fr) prefix="FR" ;;
|
|
53
|
+
nfr) prefix="NFR" ;;
|
|
54
|
+
"") printf 'forge-reserve: missing kind (adr|def|fr|nfr)\n' >&2; exit 2 ;;
|
|
55
|
+
*) printf 'forge-reserve: unknown kind: %s (expected adr|def|fr|nfr)\n' "$kind" >&2; exit 2 ;;
|
|
56
|
+
esac
|
|
57
|
+
[ -n "$milestone" ] || { printf 'forge-reserve: --milestone <id> is required\n' >&2; exit 2; }
|
|
58
|
+
|
|
59
|
+
# --- 2. resolve paths -------------------------------------------------------
|
|
60
|
+
common="$(git rev-parse --git-common-dir)"
|
|
61
|
+
top="$(git rev-parse --show-toplevel)"
|
|
62
|
+
mkdir -p "$common/forge"
|
|
63
|
+
ledger="$common/forge/id-reservations"
|
|
64
|
+
lock="$common/forge/id-reservations.lock"
|
|
65
|
+
reservations="$top/.forge/reservations.yml"
|
|
66
|
+
|
|
67
|
+
# Portable mtime → epoch: BSD/macOS `stat -f %m`, GNU `stat -c %Y`. This is the
|
|
68
|
+
# one place a platform branch is warranted (ADR-021 point 3: macOS is a
|
|
69
|
+
# first-class platform and its stat flags differ from GNU's).
|
|
70
|
+
get_mtime() {
|
|
71
|
+
stat -f %m "$1" 2>/dev/null || stat -c %Y "$1" 2>/dev/null || true
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
# --- 3. acquire lock (atomic mkdir, bounded retry, staleness-break) ----------
|
|
75
|
+
# mkdir is POSIX-atomic: it succeeds for exactly one racer and fails if the dir
|
|
76
|
+
# already exists — a lock without flock. A crashed helper's lock is broken after
|
|
77
|
+
# STALE seconds so it can't wedge the namespace forever.
|
|
78
|
+
max_iters="$(awk -v w="$WAIT" -v s="$SLEEP" 'BEGIN{print int(w/s)}')"
|
|
79
|
+
iters=0
|
|
80
|
+
while ! mkdir "$lock" 2>/dev/null; do
|
|
81
|
+
mt="$(get_mtime "$lock")"
|
|
82
|
+
now_epoch="$(date +%s)"
|
|
83
|
+
if [ -n "$mt" ] && [ "$((now_epoch - mt))" -ge "$STALE" ]; then
|
|
84
|
+
printf 'forge-reserve: breaking stale lock (age %ss): %s\n' "$((now_epoch - mt))" "$lock" >&2
|
|
85
|
+
rmdir "$lock" 2>/dev/null || true
|
|
86
|
+
continue
|
|
87
|
+
fi
|
|
88
|
+
iters="$((iters + 1))"
|
|
89
|
+
if [ "$iters" -ge "$max_iters" ]; then
|
|
90
|
+
printf 'forge-reserve: lock timeout after ~%ss: %s\n' "$WAIT" "$lock" >&2
|
|
91
|
+
exit 3
|
|
92
|
+
fi
|
|
93
|
+
sleep "$SLEEP"
|
|
94
|
+
done
|
|
95
|
+
# Only now that the lock is ours do we install the release trap.
|
|
96
|
+
trap 'rmdir "$lock" 2>/dev/null || true' EXIT INT TERM
|
|
97
|
+
|
|
98
|
+
# --- 4/5. three-way max(ledger ∪ in-tree ∪ main:reservations.yml) + 1 --------
|
|
99
|
+
# Everything numeric goes through awk (n+0) so leading-zero ids like 021 are read
|
|
100
|
+
# as decimal 21, never octal — no shell $(( )) octal trap.
|
|
101
|
+
|
|
102
|
+
# (a) ledger — same-machine siblings, INCLUDING uncommitted (the ADR-016-blind input)
|
|
103
|
+
ledger_max=0
|
|
104
|
+
if [ -f "$ledger" ]; then
|
|
105
|
+
ledger_max="$(awk -F'\t' -v k="$kind" \
|
|
106
|
+
'$1==k { split($2,a,"-"); n=a[2]+0; if(n>m)m=n } END{print m+0}' "$ledger")"
|
|
107
|
+
fi
|
|
108
|
+
|
|
109
|
+
# (b) in-tree — landed ids in THIS worktree. Exact-prefix match in awk ($1==p) so
|
|
110
|
+
# FR does not swallow NFR (FR is a substring of NFR).
|
|
111
|
+
if [ "$kind" = "adr" ]; then
|
|
112
|
+
intree_max="$(ls "$top/docs/decisions" 2>/dev/null \
|
|
113
|
+
| awk -F- '/^ADR-[0-9]/ { n=$2+0; if(n>m)m=n } END{print m+0}')"
|
|
114
|
+
else
|
|
115
|
+
intree_max="$(grep -rhoE '[A-Z]+-[0-9]+' "$top/.forge/requirements/" 2>/dev/null \
|
|
116
|
+
| awk -F- -v p="$prefix" '$1==p { n=$2+0; if(n>m)m=n } END{print m+0}')"
|
|
117
|
+
fi
|
|
118
|
+
|
|
119
|
+
# (c) main:reservations.yml — cross-machine / cold-start floor (survives a
|
|
120
|
+
# .git/forge wipe; carries another machine's MERGED reservations). Absent → 0.
|
|
121
|
+
main_max="$(git show main:.forge/reservations.yml 2>/dev/null \
|
|
122
|
+
| grep -oE '[A-Z]+-[0-9]+' \
|
|
123
|
+
| awk -F- -v p="$prefix" '$1==p { n=$2+0; if(n>m)m=n } END{print m+0}')"
|
|
124
|
+
[ -n "$main_max" ] || main_max=0
|
|
125
|
+
|
|
126
|
+
next="$(awk -v a="$ledger_max" -v b="$intree_max" -v c="$main_max" \
|
|
127
|
+
'BEGIN{m=a; if(b>m)m=b; if(c>m)m=c; print m+1}')"
|
|
128
|
+
id="$(printf '%s-%03d' "$prefix" "$next")"
|
|
129
|
+
|
|
130
|
+
# --- 6. dual-write (still under the lock) -----------------------------------
|
|
131
|
+
now="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
|
132
|
+
printf '%s\t%s\t%s\t%s\n' "$kind" "$id" "$milestone" "$now" >> "$ledger"
|
|
133
|
+
|
|
134
|
+
# reservations.yml missing → seed the 0.42.0 starter HEADER, but with a bare
|
|
135
|
+
# `reservations:` key (not the guide's `reservations: []`) so the block items we
|
|
136
|
+
# append below form a valid sequence — appending `- kind:` under a `[]` flow list
|
|
137
|
+
# is malformed YAML. Existing repo files already use the bare-key block form.
|
|
138
|
+
if [ ! -f "$reservations" ]; then
|
|
139
|
+
mkdir -p "$(dirname "$reservations")"
|
|
140
|
+
cat > "$reservations" <<'YML'
|
|
141
|
+
# Forge ID Reservations — cross-worktree coordination for ADR / DEF / FR / NFR numbers
|
|
142
|
+
# Written by .claude/hooks/forge-reserve.sh (ADR-021). The machine-local ledger
|
|
143
|
+
# (.git/forge/id-reservations) is the same-machine coordination point; this file
|
|
144
|
+
# is the durable committed audit trail + cross-machine floor. Append-only; never
|
|
145
|
+
# edit prior entries. Next number for a kind = max(ledger, in-tree, main) + 1.
|
|
146
|
+
# kind ∈ {adr,def,fr,nfr}. See FORGE.md → ID Reservation Protocol + ADR-021.
|
|
147
|
+
reservations:
|
|
148
|
+
YML
|
|
149
|
+
fi
|
|
150
|
+
|
|
151
|
+
# YAML double-quoted scalar: escape backslash then double-quote.
|
|
152
|
+
esc_summary="$(printf '%s' "$summary" | sed 's/\\/\\\\/g; s/"/\\"/g')"
|
|
153
|
+
{
|
|
154
|
+
printf ' - kind: %s\n' "$kind"
|
|
155
|
+
printf ' id: %s\n' "$id"
|
|
156
|
+
printf ' milestone: %s\n' "$milestone"
|
|
157
|
+
printf ' reserved_at: "%s"\n' "$now"
|
|
158
|
+
printf ' summary: "%s"\n' "$esc_summary"
|
|
159
|
+
} >> "$reservations"
|
|
160
|
+
|
|
161
|
+
# --- 7. emit the id (stdout only); trap releases the lock on exit ------------
|
|
162
|
+
printf '%s\n' "$id"
|
|
@@ -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
|