@bookedsolid/rea 0.14.0 → 0.15.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/agents/codex-adversarial.md +1 -1
- package/commands/codex-review.md +6 -11
- package/commands/freeze.md +1 -0
- package/commands/review.md +1 -0
- package/dist/cli/doctor.js +12 -6
- package/dist/cli/install/settings-merge.js +3 -2
- package/hooks/_lib/cmd-segments.sh +155 -0
- package/hooks/_lib/protected-paths.sh +39 -0
- package/hooks/attribution-advisory.sh +16 -7
- package/hooks/blocked-paths-enforcer.sh +9 -1
- package/hooks/changeset-security-gate.sh +45 -7
- package/hooks/dangerous-bash-interceptor.sh +70 -46
- package/hooks/dependency-audit-gate.sh +54 -24
- package/hooks/env-file-protection.sh +14 -4
- package/hooks/protected-paths-bash-gate.sh +213 -0
- package/hooks/settings-protection.sh +49 -8
- package/package.json +4 -2
|
@@ -36,7 +36,7 @@ You may read additional files in the repo if needed for context, but do so read-
|
|
|
36
36
|
5. **Parse the Codex output** — extract structured findings.
|
|
37
37
|
6. **Classify findings** by category: security, correctness, edge cases, test gaps, API design, performance.
|
|
38
38
|
7. **Assign verdict**: `pass` (no material findings), `concerns` (findings worth addressing but not blocking), `blocking` (findings that must be fixed before merge).
|
|
39
|
-
8. **Emit an audit entry**
|
|
39
|
+
8. **Emit an audit entry — REQUIRED** for every `/codex-review` invocation. The pre-push gate does not consult audit records to decide pass/fail (post-0.11.0 the gate is stateless), but the `/codex-review` slash command's Step 3 verifies an audit entry was appended for this run and surfaces "review never happened" to the user when one is missing. The two specs are a contract pair — audit emission is what tells the operator their interactive review actually completed. Append via the public `@bookedsolid/rea/audit` helper:
|
|
40
40
|
|
|
41
41
|
```ts
|
|
42
42
|
import { appendAuditRecord, CODEX_REVIEW_TOOL_NAME, CODEX_REVIEW_SERVER_NAME, Tier, InvocationStatus } from '@bookedsolid/rea/audit';
|
package/commands/codex-review.md
CHANGED
|
@@ -7,6 +7,7 @@ allowed-tools:
|
|
|
7
7
|
- Bash(git branch:*)
|
|
8
8
|
- Bash(git rev-parse:*)
|
|
9
9
|
- Read
|
|
10
|
+
- Agent
|
|
10
11
|
---
|
|
11
12
|
|
|
12
13
|
# /codex-review — Adversarial Review via Codex
|
|
@@ -54,23 +55,17 @@ Invoke the `codex-adversarial` agent with:
|
|
|
54
55
|
|
|
55
56
|
The agent wraps `/codex:adversarial-review` and returns structured findings.
|
|
56
57
|
|
|
57
|
-
## Step 3 —
|
|
58
|
+
## Step 3 — (Optional) verify audit entry
|
|
58
59
|
|
|
59
|
-
|
|
60
|
+
Audit emission is **optional** in 0.11.0+. The pre-push gate is stateless and does not consult audit records to decide pass/fail; the agent's structured findings ARE the review. The agent will append an audit entry when it helps forensic traceability (intermittent verdicts, review-history audits) but its absence is not a failure.
|
|
61
|
+
|
|
62
|
+
If you want to confirm an entry was written for this run:
|
|
60
63
|
|
|
61
64
|
```bash
|
|
62
65
|
tail -n 1 .rea/audit.jsonl
|
|
63
66
|
```
|
|
64
67
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
- `tool: "codex-adversarial-review"`
|
|
68
|
-
- `head_sha: <SHA>`
|
|
69
|
-
- `target: <ref>`
|
|
70
|
-
- `finding_count: <N>`
|
|
71
|
-
- `verdict: pass | concerns | blocking`
|
|
72
|
-
|
|
73
|
-
If the audit entry is missing, report it clearly — do not proceed as if the review happened.
|
|
68
|
+
A `codex-adversarial-review` entry with `head_sha`, `target`, `finding_count`, and `verdict` fields is informative — but DO NOT treat its absence as a failure. The review happened if the agent returned text. (Pre-0.15.0 this step was a hard verification gate that contradicted the agent's "audit optional" contract — see Helix Finding 3, 2026-05-03.)
|
|
74
69
|
|
|
75
70
|
## Step 4 — Report
|
|
76
71
|
|
package/commands/freeze.md
CHANGED
package/commands/review.md
CHANGED
package/dist/cli/doctor.js
CHANGED
|
@@ -203,7 +203,7 @@ function checkSettingsJson(baseDir) {
|
|
|
203
203
|
const settingsPath = path.join(baseDir, '.claude', 'settings.json');
|
|
204
204
|
if (!fs.existsSync(settingsPath)) {
|
|
205
205
|
return {
|
|
206
|
-
label: 'settings.json matchers cover Bash + Write|Edit',
|
|
206
|
+
label: 'settings.json matchers cover Bash + Write|Edit|MultiEdit',
|
|
207
207
|
status: 'fail',
|
|
208
208
|
detail: `missing: ${settingsPath}`,
|
|
209
209
|
};
|
|
@@ -216,23 +216,29 @@ function checkSettingsJson(baseDir) {
|
|
|
216
216
|
const needs = [];
|
|
217
217
|
if (!matchers.has('Bash'))
|
|
218
218
|
needs.push('Bash');
|
|
219
|
-
|
|
220
|
-
|
|
219
|
+
// 0.15.0: matcher was widened from `Write|Edit` to `Write|Edit|MultiEdit`
|
|
220
|
+
// in 0.14.0; doctor's check missed the rename. Accept either form so
|
|
221
|
+
// pre-0.14.0 installs that haven't run `rea upgrade` still report
|
|
222
|
+
// accurately, but the canonical produced by `defaultDesiredHooks()` is
|
|
223
|
+
// the wider matcher.
|
|
224
|
+
if (!matchers.has('Write|Edit|MultiEdit') && !matchers.has('Write|Edit')) {
|
|
225
|
+
needs.push('Write|Edit|MultiEdit');
|
|
226
|
+
}
|
|
221
227
|
if (needs.length === 0) {
|
|
222
228
|
return {
|
|
223
|
-
label: 'settings.json matchers cover Bash + Write|Edit',
|
|
229
|
+
label: 'settings.json matchers cover Bash + Write|Edit|MultiEdit',
|
|
224
230
|
status: 'pass',
|
|
225
231
|
};
|
|
226
232
|
}
|
|
227
233
|
return {
|
|
228
|
-
label: 'settings.json matchers cover Bash + Write|Edit',
|
|
234
|
+
label: 'settings.json matchers cover Bash + Write|Edit|MultiEdit',
|
|
229
235
|
status: 'fail',
|
|
230
236
|
detail: `missing PreToolUse matchers: ${needs.join(', ')}`,
|
|
231
237
|
};
|
|
232
238
|
}
|
|
233
239
|
catch (e) {
|
|
234
240
|
return {
|
|
235
|
-
label: 'settings.json matchers cover Bash + Write|Edit',
|
|
241
|
+
label: 'settings.json matchers cover Bash + Write|Edit|MultiEdit',
|
|
236
242
|
status: 'fail',
|
|
237
243
|
detail: e instanceof Error ? e.message : String(e),
|
|
238
244
|
};
|
|
@@ -259,6 +259,7 @@ export function defaultDesiredHooks() {
|
|
|
259
259
|
hooks: [
|
|
260
260
|
{ type: 'command', command: `${base}/dangerous-bash-interceptor.sh`, timeout: 10000, statusMessage: 'Checking command safety...' },
|
|
261
261
|
{ type: 'command', command: `${base}/env-file-protection.sh`, timeout: 5000, statusMessage: 'Checking for .env file reads...' },
|
|
262
|
+
{ type: 'command', command: `${base}/protected-paths-bash-gate.sh`, timeout: 5000, statusMessage: 'Checking for shell-redirect to protected paths...' },
|
|
262
263
|
{ type: 'command', command: `${base}/dependency-audit-gate.sh`, timeout: 15000, statusMessage: 'Verifying package exists...' },
|
|
263
264
|
{ type: 'command', command: `${base}/security-disclosure-gate.sh`, timeout: 5000, statusMessage: 'Checking disclosure policy...' },
|
|
264
265
|
{ type: 'command', command: `${base}/pr-issue-link-gate.sh`, timeout: 5000, statusMessage: 'Checking PR for issue reference...' },
|
|
@@ -267,7 +268,7 @@ export function defaultDesiredHooks() {
|
|
|
267
268
|
},
|
|
268
269
|
{
|
|
269
270
|
event: 'PreToolUse',
|
|
270
|
-
matcher: 'Write|Edit',
|
|
271
|
+
matcher: 'Write|Edit|MultiEdit',
|
|
271
272
|
hooks: [
|
|
272
273
|
{ type: 'command', command: `${base}/secret-scanner.sh`, timeout: 15000, statusMessage: 'Scanning for credentials...' },
|
|
273
274
|
{ type: 'command', command: `${base}/settings-protection.sh`, timeout: 5000, statusMessage: 'Checking settings protection...' },
|
|
@@ -277,7 +278,7 @@ export function defaultDesiredHooks() {
|
|
|
277
278
|
},
|
|
278
279
|
{
|
|
279
280
|
event: 'PostToolUse',
|
|
280
|
-
matcher: 'Write|Edit',
|
|
281
|
+
matcher: 'Write|Edit|MultiEdit',
|
|
281
282
|
hooks: [
|
|
282
283
|
{ type: 'command', command: `${base}/architecture-review-gate.sh`, timeout: 10000, statusMessage: 'Checking architecture impact...' },
|
|
283
284
|
],
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# shellcheck shell=bash
|
|
2
|
+
# hooks/_lib/cmd-segments.sh — shell-segment splitting for Bash-tier hooks.
|
|
3
|
+
#
|
|
4
|
+
# Background: hooks that gate `Bash` tool calls grep `.tool_input.command`
|
|
5
|
+
# for danger words (`rm -rf`, `git restore`, `pnpm install`, etc.). Pre-
|
|
6
|
+
# 0.15.0 every hook ran a single `grep -qE PATTERN "$cmd"` against the
|
|
7
|
+
# whole command string. That false-positives on heredoc bodies and
|
|
8
|
+
# commit messages where the trigger word appears inside content rather
|
|
9
|
+
# than as a command:
|
|
10
|
+
#
|
|
11
|
+
# git commit -m "$(cat <<'EOF'
|
|
12
|
+
# docs: explain why we don't run rm -rf node_modules in CI
|
|
13
|
+
# EOF
|
|
14
|
+
# )"
|
|
15
|
+
#
|
|
16
|
+
# The unanchored regex matches `rm -rf` inside the heredoc body and the
|
|
17
|
+
# hook blocks a perfectly safe commit. Hit during the 2026-05-03 session
|
|
18
|
+
# repeatedly — the pattern that motivated dependency-audit-gate's 0.15.0
|
|
19
|
+
# segment-split fix.
|
|
20
|
+
#
|
|
21
|
+
# This helper exposes two primitives every Bash-tier hook should use:
|
|
22
|
+
#
|
|
23
|
+
# for_each_segment "$CMD" CALLBACK
|
|
24
|
+
# Splits $CMD on shell command separators (`;`, `&&`, `||`, `|`,
|
|
25
|
+
# newlines) and invokes CALLBACK with each segment as $1, plus the
|
|
26
|
+
# leading-prefix-stripped form as $2 (with `sudo`/`exec`/`time`/
|
|
27
|
+
# `then`/`do`/env-var-assignment prefixes removed). Returns 0 if
|
|
28
|
+
# CALLBACK returned 0 for every segment, or the first non-zero
|
|
29
|
+
# CALLBACK exit otherwise.
|
|
30
|
+
#
|
|
31
|
+
# any_segment_matches "$CMD" PATTERN
|
|
32
|
+
# Iterates segments and returns 0 if any segment's prefix-stripped
|
|
33
|
+
# form matches PATTERN (a `grep -qiE` extended regex). Returns 1
|
|
34
|
+
# if no segment matches.
|
|
35
|
+
#
|
|
36
|
+
# Quoting awareness: the splitter is NOT quote-aware. A separator inside
|
|
37
|
+
# a quoted string would be split. This is INTENTIONAL and SAFE: the
|
|
38
|
+
# segments-vs-callback contract is "find segments that anchor on a
|
|
39
|
+
# trigger word." Over-splitting produces extra segments that don't
|
|
40
|
+
# anchor; they're ignored. Under-splitting (treating a quoted separator
|
|
41
|
+
# as part of one segment) is what the original bug was. The trade-off
|
|
42
|
+
# explicitly accepts over-splitting.
|
|
43
|
+
#
|
|
44
|
+
# Quoting note for future maintainers: do not "fix" the over-splitting
|
|
45
|
+
# without breaking the security property. Quote-aware splitting in pure
|
|
46
|
+
# bash is a real lift; if needed it should move to a Node helper.
|
|
47
|
+
|
|
48
|
+
# Split $1 on shell command separators. Emits one segment per line on
|
|
49
|
+
# stdout (empty segments preserved). Used by both higher-level helpers
|
|
50
|
+
# below; not generally called by hooks directly.
|
|
51
|
+
_rea_split_segments() {
|
|
52
|
+
local cmd="$1"
|
|
53
|
+
# GNU sed and BSD sed both honor `s/PATTERN/\n/g` with `-E` for ERE.
|
|
54
|
+
# We use printf+sed instead of bash IFS=$'...' read so the splitter
|
|
55
|
+
# behaves identically across BSD and GNU sed.
|
|
56
|
+
printf '%s\n' "$cmd" | sed -E 's/(\|\||&&|;|\|)/\n/g'
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
# Strip leading whitespace and well-known command prefixes from a single
|
|
60
|
+
# segment. Returns the prefix-stripped form on stdout. Examples:
|
|
61
|
+
# " sudo pnpm install foo" → "pnpm install foo"
|
|
62
|
+
# "NODE_ENV=production pnpm add x" → "pnpm add x"
|
|
63
|
+
# "then pnpm add lodash" → "pnpm add lodash"
|
|
64
|
+
_rea_strip_prefix() {
|
|
65
|
+
local seg="$1"
|
|
66
|
+
# Trim leading whitespace.
|
|
67
|
+
seg="${seg#"${seg%%[![:space:]]*}"}"
|
|
68
|
+
# Strip ONE prefix at a time, looping. This handles compounds like
|
|
69
|
+
# `sudo NODE_ENV=production pnpm add foo`.
|
|
70
|
+
while :; do
|
|
71
|
+
case "$seg" in
|
|
72
|
+
sudo[[:space:]]*|exec[[:space:]]*|time[[:space:]]*|then[[:space:]]*|do[[:space:]]*|else[[:space:]]*)
|
|
73
|
+
# Drop the prefix word and any subsequent whitespace.
|
|
74
|
+
seg="${seg#* }"
|
|
75
|
+
seg="${seg#"${seg%%[![:space:]]*}"}"
|
|
76
|
+
;;
|
|
77
|
+
*)
|
|
78
|
+
# Env-var assignment prefix (`KEY=value `) — only strip if the
|
|
79
|
+
# token before the first space looks like NAME=value.
|
|
80
|
+
if [[ "$seg" =~ ^[A-Za-z_][A-Za-z0-9_]*=[^[:space:]]+[[:space:]]+ ]]; then
|
|
81
|
+
seg="${seg#* }"
|
|
82
|
+
seg="${seg#"${seg%%[![:space:]]*}"}"
|
|
83
|
+
else
|
|
84
|
+
break
|
|
85
|
+
fi
|
|
86
|
+
;;
|
|
87
|
+
esac
|
|
88
|
+
done
|
|
89
|
+
printf '%s' "$seg"
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
# Iterate every segment of $1 and invoke $2 (a function name) with the
|
|
93
|
+
# raw segment as $1 and the prefix-stripped form as $2. The callback's
|
|
94
|
+
# return value is honored: a non-zero return aborts the iteration and
|
|
95
|
+
# becomes the helper's return value.
|
|
96
|
+
for_each_segment() {
|
|
97
|
+
local cmd="$1"
|
|
98
|
+
local callback="$2"
|
|
99
|
+
local segment stripped rc
|
|
100
|
+
while IFS= read -r segment; do
|
|
101
|
+
stripped=$(_rea_strip_prefix "$segment")
|
|
102
|
+
"$callback" "$segment" "$stripped"
|
|
103
|
+
rc=$?
|
|
104
|
+
if [ "$rc" -ne 0 ]; then
|
|
105
|
+
return "$rc"
|
|
106
|
+
fi
|
|
107
|
+
done < <(_rea_split_segments "$cmd")
|
|
108
|
+
return 0
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
# Return 0 if any segment of $1 (after prefix-stripping) matches the
|
|
112
|
+
# extended regex $2 ANYWHERE (not anchored). Case-insensitive. Returns 1
|
|
113
|
+
# if no segment matches.
|
|
114
|
+
#
|
|
115
|
+
# Use this for patterns that may legitimately appear mid-segment, e.g.
|
|
116
|
+
# `Co-Authored-By:` in a commit message body. For "is the segment a
|
|
117
|
+
# call to <command>" use `any_segment_starts_with` instead — that
|
|
118
|
+
# anchors on the start so `echo "rm -rf foo"` doesn't trip an
|
|
119
|
+
# `rm -rf` detector.
|
|
120
|
+
any_segment_matches() {
|
|
121
|
+
local cmd="$1"
|
|
122
|
+
local pattern="$2"
|
|
123
|
+
local segment stripped
|
|
124
|
+
while IFS= read -r segment; do
|
|
125
|
+
stripped=$(_rea_strip_prefix "$segment")
|
|
126
|
+
if printf '%s' "$stripped" | grep -qiE "$pattern"; then
|
|
127
|
+
return 0
|
|
128
|
+
fi
|
|
129
|
+
done < <(_rea_split_segments "$cmd")
|
|
130
|
+
return 1
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
# Return 0 if any segment of $1 (after prefix-stripping) STARTS WITH
|
|
134
|
+
# the extended regex $2. Case-insensitive. Returns 1 if no segment
|
|
135
|
+
# starts with the pattern.
|
|
136
|
+
#
|
|
137
|
+
# This is the right shape for "is this segment a call to <command>"
|
|
138
|
+
# checks. `echo "rm -rf foo"` does NOT trigger an `rm -rf` detector
|
|
139
|
+
# because the segment starts with `echo`, not `rm`. Compare to
|
|
140
|
+
# `any_segment_matches`, which matches anywhere in the segment and
|
|
141
|
+
# would fire on the echo'd argument.
|
|
142
|
+
any_segment_starts_with() {
|
|
143
|
+
local cmd="$1"
|
|
144
|
+
local pattern="$2"
|
|
145
|
+
local segment stripped
|
|
146
|
+
while IFS= read -r segment; do
|
|
147
|
+
stripped=$(_rea_strip_prefix "$segment")
|
|
148
|
+
# `^` anchor + caller pattern. `(?:)` non-capturing group not
|
|
149
|
+
# supported in BSD ERE; we use a simple literal `^` prepend.
|
|
150
|
+
if printf '%s' "$stripped" | grep -qiE "^${pattern}"; then
|
|
151
|
+
return 0
|
|
152
|
+
fi
|
|
153
|
+
done < <(_rea_split_segments "$cmd")
|
|
154
|
+
return 1
|
|
155
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# shellcheck shell=bash
|
|
2
|
+
# hooks/_lib/protected-paths.sh — single source of truth for the
|
|
3
|
+
# hard-protected path list shared between the Write/Edit tier
|
|
4
|
+
# (`settings-protection.sh`) and the Bash tier (`protected-paths-bash-gate.sh`).
|
|
5
|
+
#
|
|
6
|
+
# Pre-0.15.0 this list was duplicated inline in settings-protection.sh;
|
|
7
|
+
# the bash-redirect bypass (`> .rea/HALT`, `tee .rea/policy.yaml`,
|
|
8
|
+
# `cp X .claude/settings.json`, `sed -i .husky/pre-push`) was caught
|
|
9
|
+
# by the principal-engineer audit. The fix: factor the list out so
|
|
10
|
+
# both hooks read the same data, and protect against shell redirects
|
|
11
|
+
# in addition to Write/Edit/MultiEdit tools.
|
|
12
|
+
|
|
13
|
+
# The path list is bash glob patterns matched against project-root-
|
|
14
|
+
# relative paths. Suffix `/` indicates a prefix match; no suffix means
|
|
15
|
+
# exact match. Mirrors the array in settings-protection.sh §6.
|
|
16
|
+
REA_PROTECTED_PATTERNS=(
|
|
17
|
+
'.claude/settings.json'
|
|
18
|
+
'.claude/settings.local.json'
|
|
19
|
+
'.husky/'
|
|
20
|
+
'.rea/policy.yaml'
|
|
21
|
+
'.rea/HALT'
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
# Test whether a project-relative path matches any protected pattern.
|
|
25
|
+
# Usage: if rea_path_is_protected ".rea/HALT"; then echo "blocked"; fi
|
|
26
|
+
# Returns 0 on match, 1 on no match.
|
|
27
|
+
rea_path_is_protected() {
|
|
28
|
+
local p="$1"
|
|
29
|
+
local pattern
|
|
30
|
+
for pattern in "${REA_PROTECTED_PATTERNS[@]}"; do
|
|
31
|
+
if [[ "$p" == "$pattern" ]]; then
|
|
32
|
+
return 0
|
|
33
|
+
fi
|
|
34
|
+
if [[ "$pattern" == */ ]] && [[ "$p" == "$pattern"* ]]; then
|
|
35
|
+
return 0
|
|
36
|
+
fi
|
|
37
|
+
done
|
|
38
|
+
return 1
|
|
39
|
+
}
|
|
@@ -50,14 +50,23 @@ if [[ -z "$CMD" ]]; then
|
|
|
50
50
|
exit 0
|
|
51
51
|
fi
|
|
52
52
|
|
|
53
|
+
# 0.15.0: source the shared shell-segment splitter. Pre-fix, the
|
|
54
|
+
# attribution patterns greped the FULL command — `git commit -m "Note:
|
|
55
|
+
# Co-Authored-By with AI was removed in 0.14"` matched and the commit
|
|
56
|
+
# was blocked even though the message was COMMENTING on attribution
|
|
57
|
+
# rather than including it. Per-segment anchoring scopes detection to
|
|
58
|
+
# segments whose first token is `git commit` / `gh pr create|edit`.
|
|
59
|
+
# shellcheck source=_lib/cmd-segments.sh
|
|
60
|
+
source "$(dirname "$0")/_lib/cmd-segments.sh"
|
|
61
|
+
|
|
53
62
|
# ── 6. Check if this is a relevant command ────────────────────────────────────
|
|
54
63
|
IS_RELEVANT=0
|
|
55
64
|
|
|
56
|
-
if
|
|
65
|
+
if any_segment_matches "$CMD" 'gh[[:space:]]+pr[[:space:]]+(create|edit)'; then
|
|
57
66
|
IS_RELEVANT=1
|
|
58
67
|
fi
|
|
59
68
|
|
|
60
|
-
if
|
|
69
|
+
if any_segment_matches "$CMD" 'git[[:space:]]+commit'; then
|
|
61
70
|
IS_RELEVANT=1
|
|
62
71
|
fi
|
|
63
72
|
|
|
@@ -70,27 +79,27 @@ fi
|
|
|
70
79
|
FOUND=0
|
|
71
80
|
|
|
72
81
|
# Co-Authored-By with noreply@ email
|
|
73
|
-
if
|
|
82
|
+
if any_segment_matches "$CMD" 'Co-Authored-By:.*noreply@'; then
|
|
74
83
|
FOUND=1
|
|
75
84
|
fi
|
|
76
85
|
|
|
77
86
|
# Co-Authored-By with known AI names
|
|
78
|
-
if
|
|
87
|
+
if any_segment_matches "$CMD" 'Co-Authored-By:.*\b(Claude|Sonnet|Opus|Haiku|Copilot|GPT|ChatGPT|Gemini|Cursor|Codeium|Tabnine|Amazon Q|CodeWhisperer|Devin|Windsurf|Cline|Aider|Anthropic|OpenAI|GitHub Copilot)\b'; then
|
|
79
88
|
FOUND=1
|
|
80
89
|
fi
|
|
81
90
|
|
|
82
91
|
# "Generated/Built/Powered with/by [AI Tool]" lines
|
|
83
|
-
if
|
|
92
|
+
if any_segment_matches "$CMD" '(Generated|Created|Built|Powered|Authored|Written|Produced)[[:space:]]+(with|by)[[:space:]]+(Claude|Copilot|GPT|ChatGPT|Gemini|Cursor|Codeium|Tabnine|CodeWhisperer|Devin|Windsurf|Cline|Aider|AI|an? AI)\b'; then
|
|
84
93
|
FOUND=1
|
|
85
94
|
fi
|
|
86
95
|
|
|
87
96
|
# Markdown-linked attribution
|
|
88
|
-
if
|
|
97
|
+
if any_segment_matches "$CMD" '\[Claude Code\]|\[GitHub Copilot\]|\[ChatGPT\]|\[Gemini\]|\[Cursor\]'; then
|
|
89
98
|
FOUND=1
|
|
90
99
|
fi
|
|
91
100
|
|
|
92
101
|
# Emoji attribution
|
|
93
|
-
if
|
|
102
|
+
if any_segment_matches "$CMD" '🤖.*[Gg]enerated'; then
|
|
94
103
|
FOUND=1
|
|
95
104
|
fi
|
|
96
105
|
|
|
@@ -106,7 +106,15 @@ normalize_path() {
|
|
|
106
106
|
if [[ "$p" == "$root"/* ]]; then
|
|
107
107
|
p="${p#$root/}"
|
|
108
108
|
fi
|
|
109
|
-
|
|
109
|
+
# 0.15.0 fix: include `\` (and `%5C` percent-encoded form) in the
|
|
110
|
+
# normalization. Without this, a path like
|
|
111
|
+
# `.github\workflows\release.yml` under Windows / Git Bash reaches
|
|
112
|
+
# that file but compares as a different string than
|
|
113
|
+
# `.github/workflows/release.yml`, missing the literal blocked-paths
|
|
114
|
+
# match. Mirrors settings-protection.sh §4 which has had backslash
|
|
115
|
+
# normalization since 0.10.x.
|
|
116
|
+
p=$(printf '%s' "$p" | sed 's/%2[Ff]/\//g; s/%2[Ee]/./g; s/%20/ /g; s/%5[Cc]/\\/g')
|
|
117
|
+
p=$(printf '%s' "$p" | tr '\\\\' '/')
|
|
110
118
|
p="${p#./}"
|
|
111
119
|
printf '%s' "$p"
|
|
112
120
|
}
|
|
@@ -23,8 +23,12 @@ check_halt
|
|
|
23
23
|
INPUT="$(cat)"
|
|
24
24
|
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // ""')
|
|
25
25
|
|
|
26
|
-
#
|
|
27
|
-
|
|
26
|
+
# 0.15.0 fix: MultiEdit was not in the allowed tool_name set, so the gate
|
|
27
|
+
# silently exited 0 on every MultiEdit call against `.changeset/*.md` —
|
|
28
|
+
# letting GHSA / CVE pre-disclosure through and skipping frontmatter
|
|
29
|
+
# validation. Same bypass shape as the secret-scanner MultiEdit issue
|
|
30
|
+
# fixed in 0.14.0; this is the second hook in the same family.
|
|
31
|
+
if [[ "$TOOL_NAME" != "Write" && "$TOOL_NAME" != "Edit" && "$TOOL_NAME" != "MultiEdit" ]]; then
|
|
28
32
|
exit 0
|
|
29
33
|
fi
|
|
30
34
|
|
|
@@ -37,12 +41,22 @@ fi
|
|
|
37
41
|
|
|
38
42
|
require_jq
|
|
39
43
|
|
|
40
|
-
# Extract the content being written
|
|
44
|
+
# Extract the content being written. MultiEdit content lives at
|
|
45
|
+
# `tool_input.edits[].new_string` (an array, not a scalar) — see the
|
|
46
|
+
# corresponding extraction in hooks/secret-scanner.sh. We use the same
|
|
47
|
+
# defensive coercion (`tostring` + `if type=="array" then . else [] end`)
|
|
48
|
+
# so a malformed payload fails closed: either yields the empty string
|
|
49
|
+
# (no scan needed, exit 0) or yields a pattern-scannable string.
|
|
41
50
|
if [[ "$TOOL_NAME" == "Write" ]]; then
|
|
42
51
|
CONTENT=$(echo "$INPUT" | jq -r '.tool_input.content // ""')
|
|
43
|
-
|
|
44
|
-
# For Edit: check the new_string being inserted
|
|
52
|
+
elif [[ "$TOOL_NAME" == "Edit" ]]; then
|
|
45
53
|
CONTENT=$(echo "$INPUT" | jq -r '.tool_input.new_string // ""')
|
|
54
|
+
else
|
|
55
|
+
CONTENT=$(echo "$INPUT" | jq -r '
|
|
56
|
+
(.tool_input.edits // [] | if type=="array" then . else [] end)
|
|
57
|
+
| map((.new_string // "") | tostring)
|
|
58
|
+
| join("\n")
|
|
59
|
+
')
|
|
46
60
|
fi
|
|
47
61
|
|
|
48
62
|
# ─── 1. SECURITY DISCLOSURE CHECK ───────────────────────────────────────────
|
|
@@ -90,6 +104,19 @@ fi
|
|
|
90
104
|
#
|
|
91
105
|
# A changeset without valid frontmatter is silently ignored by the changesets
|
|
92
106
|
# tool — the package bump and CHANGELOG entry never appear in the release.
|
|
107
|
+
#
|
|
108
|
+
# 0.15.0 fix: skip frontmatter validation for MultiEdit. MultiEdit's
|
|
109
|
+
# `tool_input.edits[].new_string` payload is a list of partial string
|
|
110
|
+
# replacements, not the full file body — running the frontmatter
|
|
111
|
+
# validator against the concatenation of new_strings would reject every
|
|
112
|
+
# legitimate MultiEdit on an existing changeset (none of the edit
|
|
113
|
+
# fragments individually contains a frontmatter block, even though the
|
|
114
|
+
# resulting file does). The disclosure scan above still runs on
|
|
115
|
+
# MultiEdit content because GHSA/CVE patterns match per-fragment without
|
|
116
|
+
# any structural assumption.
|
|
117
|
+
if [[ "$TOOL_NAME" == "MultiEdit" ]]; then
|
|
118
|
+
exit 0
|
|
119
|
+
fi
|
|
93
120
|
|
|
94
121
|
# Must start with ---
|
|
95
122
|
if ! echo "$CONTENT" | head -1 | grep -qE '^---'; then
|
|
@@ -107,9 +134,20 @@ Brief description of what changed and why (close #N if applicable).
|
|
|
107
134
|
Bump types: patch (bug fix/security), minor (new feature), major (breaking change)"
|
|
108
135
|
fi
|
|
109
136
|
|
|
110
|
-
# Must have at least one package bump entry and a closing
|
|
137
|
+
# Must have at least one package bump entry and a closing ---.
|
|
138
|
+
# 0.15.0 fix: accept single-quoted, double-quoted, AND unquoted package
|
|
139
|
+
# names (all three are valid YAML for the same string). Pre-fix the
|
|
140
|
+
# regex required single quotes, so a tool or human authoring the
|
|
141
|
+
# changeset with `"@scope/name": patch` was rejected as malformed even
|
|
142
|
+
# though the Changesets tool itself accepts every form.
|
|
143
|
+
#
|
|
144
|
+
# Codex round-1 P2-1 fix: explicit-alternation form (no backref) so
|
|
145
|
+
# the unquoted variant matches on BSD grep too. The earlier
|
|
146
|
+
# `^([\"']?)[^\"']+\1: ...` shape relied on backref-with-empty-capture
|
|
147
|
+
# semantics that BSD's grep rejects when the capture group's `?` made
|
|
148
|
+
# it absent — quoted forms matched on macOS but unquoted did not.
|
|
111
149
|
FRONTMATTER=$(echo "$CONTENT" | awk '/^---/{count++; if(count==2){exit} next} count==1{print}')
|
|
112
|
-
if ! echo "$FRONTMATTER" | grep -qE "^'
|
|
150
|
+
if ! echo "$FRONTMATTER" | grep -qE "^(\"[^\"]+\"|'[^']+'|[^\"'[:space:]]+): (patch|minor|major)"; then
|
|
113
151
|
json_output "block" \
|
|
114
152
|
"CHANGESET FORMAT GATE: Frontmatter does not contain a valid package bump entry.
|
|
115
153
|
|
|
@@ -15,6 +15,15 @@
|
|
|
15
15
|
|
|
16
16
|
set -uo pipefail
|
|
17
17
|
|
|
18
|
+
# Source shared shell-segment splitter (0.15.0). Provides
|
|
19
|
+
# `any_segment_matches "$CMD" PATTERN` which iterates segments split on
|
|
20
|
+
# &&/||/;/| and runs the pattern with `grep -qiE` against each
|
|
21
|
+
# prefix-stripped segment. Replaces full-command grep that
|
|
22
|
+
# false-positives on heredoc bodies and commit messages mentioning
|
|
23
|
+
# trigger words.
|
|
24
|
+
# shellcheck source=_lib/cmd-segments.sh
|
|
25
|
+
source "$(dirname "$0")/_lib/cmd-segments.sh"
|
|
26
|
+
|
|
18
27
|
# ── 1. Read ALL stdin immediately before doing anything else ──────────────────
|
|
19
28
|
INPUT=$(cat)
|
|
20
29
|
|
|
@@ -84,26 +93,21 @@ add_medium() {
|
|
|
84
93
|
}
|
|
85
94
|
|
|
86
95
|
# ── 7. Per-segment evaluation helper ──────────────────────────────────────────
|
|
87
|
-
#
|
|
88
|
-
#
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
return 0
|
|
94
|
-
fi
|
|
95
|
-
done < <(printf '%s' "$CMD" | sed 's/&&/\n/g; s/||/\n/g; s/;/\n/g')
|
|
96
|
-
return 1
|
|
97
|
-
}
|
|
96
|
+
# (Migrated to `_lib/cmd-segments.sh::any_segment_matches` as of 0.15.0.
|
|
97
|
+
# The previous inline helper was defined here but never called — H3-H17
|
|
98
|
+
# all greped the WHOLE command, which false-positived on heredoc bodies
|
|
99
|
+
# and commit messages mentioning trigger words. Migration: every check
|
|
100
|
+
# now uses `any_segment_matches "$CMD" PATTERN` with the helper sourced
|
|
101
|
+
# at the top of this file.)
|
|
98
102
|
|
|
99
103
|
# ── 8. Smart exclusion flags ──────────────────────────────────────────────────
|
|
100
104
|
CMD_IS_REBASE_SAFE=0
|
|
101
|
-
if
|
|
105
|
+
if any_segment_starts_with "$CMD" 'git[[:space:]]+(rebase)[[:space:]].*(--abort|--continue)'; then
|
|
102
106
|
CMD_IS_REBASE_SAFE=1
|
|
103
107
|
fi
|
|
104
108
|
|
|
105
109
|
CMD_IS_CLEAN_DRY=0
|
|
106
|
-
if
|
|
110
|
+
if any_segment_starts_with "$CMD" 'git[[:space:]]+clean.*([ \t]-n|--dry-run)'; then
|
|
107
111
|
CMD_IS_CLEAN_DRY=1
|
|
108
112
|
fi
|
|
109
113
|
|
|
@@ -111,26 +115,41 @@ fi
|
|
|
111
115
|
|
|
112
116
|
# H1: git push --force or -f (per-segment — prevents --force-with-lease poisoning)
|
|
113
117
|
# A segment containing --force-with-lease is excluded; other segments are not.
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
118
|
+
# 0.15.0: also catches `git push origin +<branch>` (refspec-prefix force-push
|
|
119
|
+
# shorthand) which the previous version missed.
|
|
120
|
+
_h1_check() {
|
|
121
|
+
local _raw="$1" SEGMENT="$2"
|
|
122
|
+
[[ -z "$SEGMENT" ]] && return 0
|
|
123
|
+
# 0.15.0 codex P1 fix: anchor on `^git push`. Pre-fix the unanchored
|
|
124
|
+
# match meant `echo "git push --force is bad"` triggered H1 even
|
|
125
|
+
# though no actual push was happening (the segment after prefix-strip
|
|
126
|
+
# was `echo "..."`, not `git push`). Anchoring scopes detection to
|
|
127
|
+
# segments whose first token IS git push.
|
|
128
|
+
printf '%s' "$SEGMENT" | grep -qiE '^git[[:space:]]+push([[:space:]]|$)' || return 0
|
|
129
|
+
# Skip segments that use the safe --force-with-lease.
|
|
130
|
+
if printf '%s' "$SEGMENT" | grep -qiE -- '--force-with-lease'; then
|
|
131
|
+
return 0
|
|
120
132
|
fi
|
|
121
|
-
|
|
122
|
-
|
|
133
|
+
# 0.15.0 codex P1 fix: combined-flag forms (`-fu`, `-uf`, `-Fu`) and
|
|
134
|
+
# long-form `--force=value` were not caught by the previous
|
|
135
|
+
# `-f[[:space:]]` shape. The flag-cluster pattern `-[a-zA-Z]*f[a-zA-Z]*`
|
|
136
|
+
# (followed by space or EOS) mirrors how H11 handles rm flag clusters.
|
|
137
|
+
# The refspec-prefix `+` on a branch name is git's force-push shorthand.
|
|
138
|
+
if printf '%s' "$SEGMENT" | grep -qiE -- '--force([[:space:]]|=|$)' || \
|
|
139
|
+
printf '%s' "$SEGMENT" | grep -qiE -- '(^|[[:space:]])-[a-zA-Z]*f[a-zA-Z]*([[:space:]]|$)' || \
|
|
140
|
+
printf '%s' "$SEGMENT" | grep -qE -- '[[:space:]]\+[A-Za-z0-9_./-]'; then
|
|
123
141
|
add_high \
|
|
124
142
|
"git push --force — force push detected" \
|
|
125
143
|
"Force-pushing rewrites public history and breaks collaborators' local copies." \
|
|
126
144
|
"Alt: Use 'git push --force-with-lease' — blocks if upstream has new commits you haven't pulled."
|
|
127
|
-
break
|
|
128
145
|
fi
|
|
129
|
-
|
|
146
|
+
return 0
|
|
147
|
+
}
|
|
148
|
+
for_each_segment "$CMD" _h1_check
|
|
130
149
|
|
|
131
150
|
# H2: git rebase — advisory (MEDIUM)
|
|
132
151
|
if [[ $CMD_IS_REBASE_SAFE -eq 0 ]]; then
|
|
133
|
-
if
|
|
152
|
+
if any_segment_starts_with "$CMD" 'git[[:space:]]+rebase([[:space:]]|$)'; then
|
|
134
153
|
add_medium \
|
|
135
154
|
"git rebase — rewrites commit history (advisory)" \
|
|
136
155
|
"Rebase changes commit SHAs. Safe on local feature branches; dangerous on shared/published branches." \
|
|
@@ -140,7 +159,7 @@ if [[ $CMD_IS_REBASE_SAFE -eq 0 ]]; then
|
|
|
140
159
|
fi
|
|
141
160
|
|
|
142
161
|
# H3: git checkout -- .
|
|
143
|
-
if
|
|
162
|
+
if any_segment_starts_with "$CMD" 'git[[:space:]]+checkout[[:space:]]+--[[:space:]]+\.'; then
|
|
144
163
|
add_high \
|
|
145
164
|
"git checkout -- . — discards all uncommitted changes" \
|
|
146
165
|
"Overwrites working tree changes with HEAD. Uncommitted work is lost permanently." \
|
|
@@ -148,8 +167,8 @@ if printf '%s' "$CMD" | grep -qiE 'git[[:space:]]+checkout[[:space:]]+--[[:space
|
|
|
148
167
|
fi
|
|
149
168
|
|
|
150
169
|
# H4: git restore . (any form — with or without --staged flag)
|
|
151
|
-
if
|
|
152
|
-
|
|
170
|
+
if any_segment_starts_with "$CMD" 'git[[:space:]]+restore[[:space:]].*[[:space:]]\.([[:space:]]|$)' || \
|
|
171
|
+
any_segment_starts_with "$CMD" 'git[[:space:]]+restore[[:space:]]+\.[[:space:]]*$'; then
|
|
153
172
|
add_high \
|
|
154
173
|
"git restore . — discards all uncommitted changes" \
|
|
155
174
|
"Restores every tracked file to HEAD, permanently discarding all working tree modifications." \
|
|
@@ -158,7 +177,7 @@ fi
|
|
|
158
177
|
|
|
159
178
|
# H5: git clean -f
|
|
160
179
|
if [[ $CMD_IS_CLEAN_DRY -eq 0 ]]; then
|
|
161
|
-
if
|
|
180
|
+
if any_segment_starts_with "$CMD" 'git[[:space:]]+clean[[:space:]]+-[a-zA-Z]*f'; then
|
|
162
181
|
add_high \
|
|
163
182
|
"git clean -f — removes untracked files" \
|
|
164
183
|
"Permanently deletes untracked files from the working tree. Cannot be undone via git." \
|
|
@@ -167,7 +186,7 @@ if [[ $CMD_IS_CLEAN_DRY -eq 0 ]]; then
|
|
|
167
186
|
fi
|
|
168
187
|
|
|
169
188
|
# H6: DROP TABLE or DROP DATABASE in psql
|
|
170
|
-
if
|
|
189
|
+
if any_segment_matches "$CMD" '(psql|pgcli)[^|&;]*DROP[[:space:]]+(TABLE|DATABASE|SCHEMA)'; then
|
|
171
190
|
add_high \
|
|
172
191
|
"DROP TABLE/DATABASE via psql — destructive DDL" \
|
|
173
192
|
"Running destructive DDL directly in psql bypasses migration pipeline safety checks." \
|
|
@@ -175,7 +194,7 @@ if printf '%s' "$CMD" | grep -qiE '(psql|pgcli)[^|&;]*DROP[[:space:]]+(TABLE|DAT
|
|
|
175
194
|
fi
|
|
176
195
|
|
|
177
196
|
# H7: kill -9 with pgrep subshell
|
|
178
|
-
if
|
|
197
|
+
if any_segment_starts_with "$CMD" 'kill[[:space:]]+-9[[:space:]]+(\$\(|`)'; then
|
|
179
198
|
add_high \
|
|
180
199
|
"kill -9 with pgrep subshell — aggressive process termination" \
|
|
181
200
|
"Sends SIGKILL to processes matched by name, which may kill unintended processes." \
|
|
@@ -183,7 +202,7 @@ if printf '%s' "$CMD" | grep -qiE 'kill[[:space:]]+-9[[:space:]]+(\$\(|`)'; then
|
|
|
183
202
|
fi
|
|
184
203
|
|
|
185
204
|
# H8: killall -9
|
|
186
|
-
if
|
|
205
|
+
if any_segment_starts_with "$CMD" 'killall[[:space:]]+-9[[:space:]]+\S'; then
|
|
187
206
|
add_high \
|
|
188
207
|
"killall -9 — SIGKILL all matching processes" \
|
|
189
208
|
"Immediately terminates all processes with the given name without cleanup." \
|
|
@@ -191,7 +210,7 @@ if printf '%s' "$CMD" | grep -qiE 'killall[[:space:]]+-9[[:space:]]+\S'; then
|
|
|
191
210
|
fi
|
|
192
211
|
|
|
193
212
|
# H9: git commit --no-verify
|
|
194
|
-
if
|
|
213
|
+
if any_segment_starts_with "$CMD" 'git[[:space:]]+commit.*--no-verify'; then
|
|
195
214
|
add_high \
|
|
196
215
|
"git commit --no-verify — skipping pre-commit hooks" \
|
|
197
216
|
"Bypasses all pre-commit safety gates including secret scanning and linting." \
|
|
@@ -199,7 +218,7 @@ if printf '%s' "$CMD" | grep -qiE 'git[[:space:]]+commit.*--no-verify'; then
|
|
|
199
218
|
fi
|
|
200
219
|
|
|
201
220
|
# H10: HUSKY=0 bypass — suppresses all git hooks without --no-verify
|
|
202
|
-
if
|
|
221
|
+
if any_segment_matches "$CMD" '(^|[[:space:];]|&&|\|\|)HUSKY=0[[:space:]]+git[[:space:]]+(commit|push|tag)'; then
|
|
203
222
|
add_high \
|
|
204
223
|
"HUSKY=0 — bypasses all husky git hooks" \
|
|
205
224
|
"Setting HUSKY=0 disables pre-commit, commit-msg, and pre-push safety gates without --no-verify." \
|
|
@@ -208,13 +227,18 @@ fi
|
|
|
208
227
|
|
|
209
228
|
# H11: rm -rf with broad targets
|
|
210
229
|
# Covers combined flags (rm -rf, rm -fr), split flags (rm -r -f), and long flags (rm --recursive --force)
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
230
|
+
# 0.15.0 fix: anchored each target on word boundary (whitespace-or-EOS).
|
|
231
|
+
# The previous form had a bare `\.` which matched `rm -rf .git/foo`
|
|
232
|
+
# (legitimate `.git/`-tree cleanup). Each token now requires either
|
|
233
|
+
# end-of-string or whitespace after — so `.` alone matches `rm -rf .`
|
|
234
|
+
# (the cwd, dangerous) but NOT `rm -rf .git/foo`.
|
|
235
|
+
BROAD_TARGETS='(\/|~\/|\.\/\*|\*|\.|src|dist|build|node_modules)([[:space:]]|$)'
|
|
236
|
+
if any_segment_starts_with "$CMD" "rm[[:space:]]+-[a-zA-Z]*r[a-zA-Z]*f[[:space:]]+${BROAD_TARGETS}" || \
|
|
237
|
+
any_segment_starts_with "$CMD" "rm[[:space:]]+-[a-zA-Z]*f[a-zA-Z]*r[[:space:]]+${BROAD_TARGETS}" || \
|
|
238
|
+
any_segment_starts_with "$CMD" "rm[[:space:]]+-[a-zA-Z]*r[[:space:]]+-[a-zA-Z]*f[[:space:]]+${BROAD_TARGETS}" || \
|
|
239
|
+
any_segment_starts_with "$CMD" "rm[[:space:]]+-[a-zA-Z]*f[[:space:]]+-[a-zA-Z]*r[[:space:]]+${BROAD_TARGETS}" || \
|
|
240
|
+
any_segment_starts_with "$CMD" "rm[[:space:]]+--recursive[[:space:]]+--force[[:space:]]+${BROAD_TARGETS}" || \
|
|
241
|
+
any_segment_starts_with "$CMD" "rm[[:space:]]+--force[[:space:]]+--recursive[[:space:]]+${BROAD_TARGETS}"; then
|
|
218
242
|
add_high \
|
|
219
243
|
"rm -rf with broad target — mass file deletion" \
|
|
220
244
|
"Permanently deletes files and directories. Cannot be undone." \
|
|
@@ -222,7 +246,7 @@ if printf '%s' "$CMD" | grep -qiE "rm[[:space:]]+-[a-zA-Z]*r[a-zA-Z]*f[[:space:]
|
|
|
222
246
|
fi
|
|
223
247
|
|
|
224
248
|
# H12: curl/wget piped directly to shell (supply chain attack vector)
|
|
225
|
-
if
|
|
249
|
+
if any_segment_matches "$CMD" '(curl|wget)[^|]*\|[[:space:]]*(bash|sh|zsh|fish)'; then
|
|
226
250
|
add_high \
|
|
227
251
|
"curl/wget piped to shell — remote code execution" \
|
|
228
252
|
"Executing remote scripts without inspection is a major supply chain risk." \
|
|
@@ -230,7 +254,7 @@ if printf '%s' "$CMD" | grep -qiE '(curl|wget)[^|]*\|[[:space:]]*(bash|sh|zsh|fi
|
|
|
230
254
|
fi
|
|
231
255
|
|
|
232
256
|
# H13: git push --no-verify — bypasses pre-push hooks
|
|
233
|
-
if
|
|
257
|
+
if any_segment_starts_with "$CMD" 'git[[:space:]]+push.*--no-verify'; then
|
|
234
258
|
add_high \
|
|
235
259
|
"git push --no-verify — skipping pre-push hooks" \
|
|
236
260
|
"Bypasses all pre-push safety gates including CI checks." \
|
|
@@ -238,7 +262,7 @@ if printf '%s' "$CMD" | grep -qiE 'git[[:space:]]+push.*--no-verify'; then
|
|
|
238
262
|
fi
|
|
239
263
|
|
|
240
264
|
# H14: git -c core.hooksPath= — redirects or disables hook execution
|
|
241
|
-
if
|
|
265
|
+
if any_segment_starts_with "$CMD" 'git[[:space:]]+-c[[:space:]]+core\.hookspath'; then
|
|
242
266
|
add_high \
|
|
243
267
|
"git -c core.hooksPath — overriding hooks directory" \
|
|
244
268
|
"Redirecting the hooks path can disable all safety hooks." \
|
|
@@ -246,7 +270,7 @@ if printf '%s' "$CMD" | grep -qiE 'git[[:space:]]+-c[[:space:]]+core\.hookspath'
|
|
|
246
270
|
fi
|
|
247
271
|
|
|
248
272
|
# H15: REA_BYPASS env var — attempted escape hatch
|
|
249
|
-
if
|
|
273
|
+
if any_segment_matches "$CMD" '(^|[[:space:];]|&&|\|\|)REA_BYPASS[[:space:]]*='; then
|
|
250
274
|
add_high \
|
|
251
275
|
"REA_BYPASS env var — unauthorized bypass attempt" \
|
|
252
276
|
"Setting REA_BYPASS is not a supported escape mechanism and indicates a bypass attempt." \
|
|
@@ -254,7 +278,7 @@ if printf '%s' "$CMD" | grep -qiE '(^|[[:space:];]|&&|\|\|)REA_BYPASS[[:space:]]
|
|
|
254
278
|
fi
|
|
255
279
|
|
|
256
280
|
# H16: alias/function definitions containing bypass strings
|
|
257
|
-
if
|
|
281
|
+
if any_segment_matches "$CMD" '(alias|function)[[:space:]]+[a-zA-Z_]+.*(--(no-verify|force)|HUSKY=0|core\.hookspath)'; then
|
|
258
282
|
add_high \
|
|
259
283
|
"Alias/function definition with bypass — circumventing safety gates" \
|
|
260
284
|
"Defining aliases or functions that embed bypass flags defeats safety hooks." \
|
|
@@ -307,7 +331,7 @@ fi
|
|
|
307
331
|
# ── 10. MEDIUM severity checks ────────────────────────────────────────────────
|
|
308
332
|
|
|
309
333
|
# M1: npm install --force
|
|
310
|
-
if
|
|
334
|
+
if any_segment_matches "$CMD" 'npm[[:space:]]+(install|i)[[:space:]].*--force'; then
|
|
311
335
|
add_medium \
|
|
312
336
|
"npm install --force — bypasses dependency resolution" \
|
|
313
337
|
"--force skips conflict checks and can install incompatible package versions." \
|
|
@@ -43,30 +43,60 @@ fi
|
|
|
43
43
|
extract_packages() {
|
|
44
44
|
local cmd="$1"
|
|
45
45
|
|
|
46
|
-
#
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
46
|
+
# 0.15.0 fix: the previous parser ran `grep` against the entire bash
|
|
47
|
+
# command string with no segment boundary anchor. A heredoc body or
|
|
48
|
+
# commit-message containing `pnpm install` (e.g. inside
|
|
49
|
+
# `git commit -m "$(cat <<EOF ... pnpm install ... EOF)"`) matched the
|
|
50
|
+
# grep, the `.*` in the sed stripped up to that occurrence, and the rest
|
|
51
|
+
# of the command (`chore:`, `&&`, `||`, etc.) was passed to
|
|
52
|
+
# `npm view <token> name` and reported as missing packages. The hook
|
|
53
|
+
# then refused to commit perfectly innocent code.
|
|
54
|
+
#
|
|
55
|
+
# Fix: split the command on shell command separators (`;`, `&&`, `||`,
|
|
56
|
+
# `|`, newlines) and only run the install-detection on segments whose
|
|
57
|
+
# FIRST non-whitespace token is one of the install commands. Heredoc
|
|
58
|
+
# bodies inside `$()` substitutions are NOT split into separate segments
|
|
59
|
+
# — the entire `$(cat <<EOF ... EOF)` is one token attached to the
|
|
60
|
+
# outer command — but they're never the FIRST token on a segment, so
|
|
61
|
+
# the anchor rejects them.
|
|
62
|
+
|
|
63
|
+
# Tokenize on shell separators. Each `IFS=` entry becomes a separate
|
|
64
|
+
# segment we can anchor against. We use bash's `mapfile` with a sed
|
|
65
|
+
# to inject newlines at separators; awk-based splitting handles the
|
|
66
|
+
# quoting heuristic well enough for the realistic cases (agent-issued
|
|
67
|
+
# commands rarely have separators inside single-quoted strings that
|
|
68
|
+
# would confuse this).
|
|
69
|
+
local segments
|
|
70
|
+
segments=$(printf '%s\n' "$cmd" | sed -E 's/(\|\||\&\&|;|\|)/\n/g')
|
|
71
|
+
|
|
72
|
+
while IFS= read -r segment; do
|
|
73
|
+
# Trim leading whitespace.
|
|
74
|
+
segment="${segment#"${segment%%[![:space:]]*}"}"
|
|
75
|
+
# Anchor to start: only match when the install command is the FIRST
|
|
76
|
+
# thing on the segment, optionally preceded by `sudo` / `exec` /
|
|
77
|
+
# `time` / etc.
|
|
78
|
+
if printf '%s' "$segment" | grep -qiE '^(sudo[[:space:]]+|exec[[:space:]]+|time[[:space:]]+)*(npm[[:space:]]+(install|i|add)|pnpm[[:space:]]+(add|install|i)|yarn[[:space:]]+add)[[:space:]]+'; then
|
|
79
|
+
# Strip the leading prefix wrappers + install command, leaving args.
|
|
80
|
+
local after_cmd
|
|
81
|
+
after_cmd=$(printf '%s' "$segment" | sed -E 's/^(sudo[[:space:]]+|exec[[:space:]]+|time[[:space:]]+)*(npm[[:space:]]+(install|i|add)|pnpm[[:space:]]+(add|install|i)|yarn[[:space:]]+add)[[:space:]]+//')
|
|
82
|
+
|
|
83
|
+
for token in $after_cmd; do
|
|
84
|
+
if [[ "$token" == -* ]]; then continue; fi
|
|
85
|
+
if [[ "$token" == ./* || "$token" == /* || "$token" == ../* ]]; then continue; fi
|
|
86
|
+
if [[ -z "$token" ]]; then continue; fi
|
|
87
|
+
# `npm view` can't validate `@workspace:*` / `link:` / `file:`
|
|
88
|
+
# prefixes (workspace protocols). Skip them — they're never npm
|
|
89
|
+
# registry packages.
|
|
90
|
+
if [[ "$token" == workspace:* || "$token" == link:* || "$token" == file:* || "$token" == git+* ]]; then continue; fi
|
|
91
|
+
local pkg_name
|
|
92
|
+
pkg_name=$(printf '%s' "$token" | sed -E 's/@[^@/]+$//')
|
|
93
|
+
if [[ -z "$pkg_name" ]]; then
|
|
94
|
+
pkg_name="$token"
|
|
95
|
+
fi
|
|
96
|
+
printf '%s\n' "$pkg_name"
|
|
97
|
+
done
|
|
98
|
+
fi
|
|
99
|
+
done <<< "$segments"
|
|
70
100
|
}
|
|
71
101
|
|
|
72
102
|
PACKAGES=$(extract_packages "$CMD")
|
|
@@ -17,6 +17,12 @@
|
|
|
17
17
|
|
|
18
18
|
set -uo pipefail
|
|
19
19
|
|
|
20
|
+
# Source shared shell-segment splitter (0.15.0). Replaces full-command
|
|
21
|
+
# grep that false-positives on commit messages mentioning `.env` (e.g.
|
|
22
|
+
# `git commit -m "stop reading .env via cat"`).
|
|
23
|
+
# shellcheck source=_lib/cmd-segments.sh
|
|
24
|
+
source "$(dirname "$0")/_lib/cmd-segments.sh"
|
|
25
|
+
|
|
20
26
|
INPUT=$(cat)
|
|
21
27
|
|
|
22
28
|
# ── Dependency check ──────────────────────────────────────────────────────────
|
|
@@ -70,17 +76,21 @@ PATTERN_ENV_FILE='(\.env[a-zA-Z0-9._-]*|\.envrc)([[:space:]]|"|'"'"'|$)'
|
|
|
70
76
|
MATCHES_UTILITY=0
|
|
71
77
|
MATCHES_ENV_FILE=0
|
|
72
78
|
|
|
73
|
-
|
|
79
|
+
# 0.15.0: per-segment match. Pre-fix this greped the FULL command which
|
|
80
|
+
# false-positived on commit messages: `git commit -m "stop reading .env
|
|
81
|
+
# files via cat"` matched both PATTERN_UTILITY (cat) and PATTERN_ENV_FILE
|
|
82
|
+
# (.env) and the hook blocked a perfectly safe commit.
|
|
83
|
+
if any_segment_matches "$CMD" "$PATTERN_UTILITY"; then
|
|
74
84
|
MATCHES_UTILITY=1
|
|
75
85
|
fi
|
|
76
86
|
|
|
77
|
-
if
|
|
87
|
+
if any_segment_matches "$CMD" "$PATTERN_ENV_FILE"; then
|
|
78
88
|
MATCHES_ENV_FILE=1
|
|
79
89
|
fi
|
|
80
90
|
|
|
81
91
|
# Direct source/cp of .env files — always block
|
|
82
|
-
if
|
|
83
|
-
|
|
92
|
+
if any_segment_matches "$CMD" "$PATTERN_SOURCE" || \
|
|
93
|
+
any_segment_matches "$CMD" "$PATTERN_CP_ENV"; then
|
|
84
94
|
TRUNCATED_CMD=$(truncate_cmd "$CMD")
|
|
85
95
|
{
|
|
86
96
|
printf 'ENV FILE PROTECTION: Direct sourcing or copying of .env files is blocked.\n'
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# PreToolUse hook: protected-paths-bash-gate.sh
|
|
3
|
+
# Fires BEFORE every Bash tool call.
|
|
4
|
+
# Refuses Bash commands that write to PROTECTED_PATTERNS via shell
|
|
5
|
+
# redirection or write-flag utilities — the kill-switch and policy
|
|
6
|
+
# files MUST be unreachable via any tool surface, including Bash.
|
|
7
|
+
#
|
|
8
|
+
# Pre-0.15.0, settings-protection.sh §6 protected `.rea/HALT`,
|
|
9
|
+
# `.rea/policy.yaml`, `.claude/settings.json`, `.husky/*` against
|
|
10
|
+
# Write/Edit/MultiEdit tool calls. But shell redirects bypassed it
|
|
11
|
+
# entirely:
|
|
12
|
+
#
|
|
13
|
+
# printf '...' > .rea/HALT # bypass — Bash matcher only
|
|
14
|
+
# tee .rea/policy.yaml < new.yaml # bypass
|
|
15
|
+
# cp new-settings.json .claude/settings.json
|
|
16
|
+
# sed -i '' '/foo/d' .husky/pre-push
|
|
17
|
+
# dd of=.rea/HALT
|
|
18
|
+
#
|
|
19
|
+
# This hook closes that gap by detecting redirect/write patterns
|
|
20
|
+
# whose target matches the same `_lib/protected-paths.sh` allowlist.
|
|
21
|
+
#
|
|
22
|
+
# Exit codes:
|
|
23
|
+
# 0 = no protected-path write detected — allow
|
|
24
|
+
# 2 = protected-path write via Bash detected — block
|
|
25
|
+
|
|
26
|
+
set -uo pipefail
|
|
27
|
+
|
|
28
|
+
# shellcheck source=_lib/protected-paths.sh
|
|
29
|
+
source "$(dirname "$0")/_lib/protected-paths.sh"
|
|
30
|
+
# shellcheck source=_lib/cmd-segments.sh
|
|
31
|
+
source "$(dirname "$0")/_lib/cmd-segments.sh"
|
|
32
|
+
|
|
33
|
+
INPUT=$(cat)
|
|
34
|
+
|
|
35
|
+
if ! command -v jq >/dev/null 2>&1; then
|
|
36
|
+
printf 'REA ERROR: jq is required but not installed.\n' >&2
|
|
37
|
+
exit 2
|
|
38
|
+
fi
|
|
39
|
+
|
|
40
|
+
REA_ROOT="${CLAUDE_PROJECT_DIR:-$(pwd)}"
|
|
41
|
+
|
|
42
|
+
# HALT check — uniform with other hooks.
|
|
43
|
+
HALT_FILE="${REA_ROOT}/.rea/HALT"
|
|
44
|
+
if [ -f "$HALT_FILE" ]; then
|
|
45
|
+
printf 'REA HALT: %s\nAll agent operations suspended. Run: rea unfreeze\n' \
|
|
46
|
+
"$(head -c 1024 "$HALT_FILE" 2>/dev/null || echo 'Reason unknown')" >&2
|
|
47
|
+
exit 2
|
|
48
|
+
fi
|
|
49
|
+
|
|
50
|
+
CMD=$(printf '%s' "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null)
|
|
51
|
+
if [[ -z "$CMD" ]]; then
|
|
52
|
+
exit 0
|
|
53
|
+
fi
|
|
54
|
+
|
|
55
|
+
# Normalize a path token: strip enclosing quotes, strip leading
|
|
56
|
+
# `$REA_ROOT/`, strip leading `./`. The result is project-relative
|
|
57
|
+
# for matching against REA_PROTECTED_PATTERNS.
|
|
58
|
+
_normalize_target() {
|
|
59
|
+
local t="$1"
|
|
60
|
+
# Strip matching surrounding quotes.
|
|
61
|
+
if [[ "$t" =~ ^\"(.*)\"$ ]]; then t="${BASH_REMATCH[1]}"; fi
|
|
62
|
+
if [[ "$t" =~ ^\'(.*)\'$ ]]; then t="${BASH_REMATCH[1]}"; fi
|
|
63
|
+
# Strip $REA_ROOT prefix (with or without trailing slash).
|
|
64
|
+
if [[ "$t" == "$REA_ROOT"/* ]]; then t="${t#"$REA_ROOT"/}"; fi
|
|
65
|
+
# Strip leading ./
|
|
66
|
+
while [[ "$t" == ./* ]]; do t="${t#./}"; done
|
|
67
|
+
printf '%s' "$t"
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
# Refuse and exit 2 with a uniform error message.
|
|
71
|
+
_refuse() {
|
|
72
|
+
local pattern="$1" target="$2" segment="$3"
|
|
73
|
+
{
|
|
74
|
+
printf 'PROTECTED PATH (bash): write to a package-managed file blocked\n'
|
|
75
|
+
printf '\n'
|
|
76
|
+
printf ' Pattern matched: %s\n' "$pattern"
|
|
77
|
+
printf ' Resolved target: %s\n' "$target"
|
|
78
|
+
printf ' Segment: %s\n' "$segment"
|
|
79
|
+
printf '\n'
|
|
80
|
+
printf ' Rule: protected paths (kill-switch, policy.yaml, settings.json,\n'
|
|
81
|
+
printf ' .husky/*) are unreachable via Bash redirects too — not just\n'
|
|
82
|
+
printf ' Write/Edit/MultiEdit. To modify, a human must edit directly.\n'
|
|
83
|
+
} >&2
|
|
84
|
+
exit 2
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
# Inspect one segment for redirect / write patterns and refuse if the
|
|
88
|
+
# target matches any protected pattern.
|
|
89
|
+
_check_segment() {
|
|
90
|
+
local _raw="$1" segment="$2"
|
|
91
|
+
[[ -z "$segment" ]] && return 0
|
|
92
|
+
|
|
93
|
+
local target_token=""
|
|
94
|
+
local detected_form=""
|
|
95
|
+
|
|
96
|
+
# bash `[[ =~ ]]` regex literals with `|` and `(...)` parsed inline
|
|
97
|
+
# confuse some bash versions on macOS. Use named variables for each
|
|
98
|
+
# pattern so the literal stays in a string context only.
|
|
99
|
+
local re_redirect='(^|[[:space:]])(&>|2>>|2>|>>|>)[[:space:]]*([^[:space:]&|;<>]+)'
|
|
100
|
+
local re_cpmv='(^|[[:space:]])(cp|mv)[[:space:]]+[^&|;<>]+[[:space:]]([^[:space:]&|;<>]+)[[:space:]]*$'
|
|
101
|
+
local re_sed='(^|[[:space:]])sed[[:space:]]+(-[a-zA-Z]*i[a-zA-Z]*[^[:space:]]*)[[:space:]]+[^&|;<>]+[[:space:]]([^[:space:]&|;<>]+)[[:space:]]*$'
|
|
102
|
+
local re_dd='(^|[[:space:]])dd[[:space:]]+[^&|;<>]*of=([^[:space:]&|;<>]+)'
|
|
103
|
+
# 0.15.0 codex P1 fix: replaced the bash-3.2-broken `(...)*` pattern
|
|
104
|
+
# for tee/truncate flag-skipping with a token-walk approach that
|
|
105
|
+
# works across BSD bash 3.2 and GNU bash 4+. Walks every token after
|
|
106
|
+
# the command, skips flags (single-dash short, double-dash long with
|
|
107
|
+
# optional =value), returns the first non-flag token as the target.
|
|
108
|
+
|
|
109
|
+
if [[ "$segment" =~ $re_redirect ]]; then
|
|
110
|
+
target_token="${BASH_REMATCH[3]}"
|
|
111
|
+
detected_form="redirect ${BASH_REMATCH[2]}"
|
|
112
|
+
elif [[ "$segment" =~ $re_cpmv ]]; then
|
|
113
|
+
target_token="${BASH_REMATCH[3]}"
|
|
114
|
+
detected_form="${BASH_REMATCH[2]}"
|
|
115
|
+
elif [[ "$segment" =~ $re_sed ]]; then
|
|
116
|
+
target_token="${BASH_REMATCH[3]}"
|
|
117
|
+
detected_form="sed -i"
|
|
118
|
+
elif [[ "$segment" =~ $re_dd ]]; then
|
|
119
|
+
target_token="${BASH_REMATCH[2]}"
|
|
120
|
+
detected_form="dd of="
|
|
121
|
+
else
|
|
122
|
+
# tee / truncate / install / ln — token-walk for cross-bash safety.
|
|
123
|
+
# Read tokens, find the command, then return the first non-flag arg.
|
|
124
|
+
local prev_word="" found_cmd=""
|
|
125
|
+
local _seg_for_walk="$segment"
|
|
126
|
+
# Strip leading whitespace.
|
|
127
|
+
_seg_for_walk="${_seg_for_walk#"${_seg_for_walk%%[![:space:]]*}"}"
|
|
128
|
+
# shellcheck disable=SC2086
|
|
129
|
+
set -- $_seg_for_walk
|
|
130
|
+
while [ "$#" -gt 0 ]; do
|
|
131
|
+
local tok="$1"
|
|
132
|
+
shift
|
|
133
|
+
if [[ -z "$found_cmd" ]]; then
|
|
134
|
+
case "$tok" in
|
|
135
|
+
tee|truncate|install|ln)
|
|
136
|
+
found_cmd="$tok"
|
|
137
|
+
;;
|
|
138
|
+
esac
|
|
139
|
+
prev_word="$tok"
|
|
140
|
+
continue
|
|
141
|
+
fi
|
|
142
|
+
# We're inside the command's argv. Skip flags.
|
|
143
|
+
case "$tok" in
|
|
144
|
+
--) continue ;;
|
|
145
|
+
--*=*) continue ;;
|
|
146
|
+
--*)
|
|
147
|
+
# Long flag — may take a value as the NEXT token (we don't
|
|
148
|
+
# know which long options take values). For safety, skip
|
|
149
|
+
# only known no-value long flags; otherwise consume the
|
|
150
|
+
# next token too if it looks like a value.
|
|
151
|
+
case "$tok" in
|
|
152
|
+
--append|--ignore-interrupts|--no-clobber|--force|--no-target-directory|--symbolic|--no-dereference|--reference=*) continue ;;
|
|
153
|
+
*) shift 2>/dev/null || true; continue ;;
|
|
154
|
+
esac
|
|
155
|
+
;;
|
|
156
|
+
-*)
|
|
157
|
+
# Short flag cluster. Skip. truncate -s SIZE — `-s` is a flag,
|
|
158
|
+
# SIZE is its arg. We're conservative: skip the next token if
|
|
159
|
+
# the flag cluster's last char is one of the size-bearing
|
|
160
|
+
# flags (truncate -s, install -m, ln -t).
|
|
161
|
+
case "$tok" in
|
|
162
|
+
-s*|-m*|-o*|-g*|-t*) shift 2>/dev/null || true ;;
|
|
163
|
+
esac
|
|
164
|
+
continue
|
|
165
|
+
;;
|
|
166
|
+
*)
|
|
167
|
+
# First non-flag token — this is the target (or, for cp/mv-
|
|
168
|
+
# like commands, the first source; the cpmv detector above
|
|
169
|
+
# handles those separately). We treat ALL non-flag args as
|
|
170
|
+
# potential targets and check each — that catches
|
|
171
|
+
# `tee a b c` where any of a/b/c could be a protected file.
|
|
172
|
+
target_token="$tok"
|
|
173
|
+
detected_form="$found_cmd"
|
|
174
|
+
# Check this token immediately; if not protected, keep
|
|
175
|
+
# walking — there may be more positional args.
|
|
176
|
+
local _t
|
|
177
|
+
_t=$(_normalize_target "$target_token")
|
|
178
|
+
if rea_path_is_protected "$_t"; then
|
|
179
|
+
local matched=""
|
|
180
|
+
for pattern in "${REA_PROTECTED_PATTERNS[@]}"; do
|
|
181
|
+
if [[ "$_t" == "$pattern" ]]; then matched="$pattern"; break; fi
|
|
182
|
+
if [[ "$pattern" == */ && "$_t" == "$pattern"* ]]; then matched="$pattern"; break; fi
|
|
183
|
+
done
|
|
184
|
+
_refuse "$matched" "$_t" "$segment"
|
|
185
|
+
fi
|
|
186
|
+
# Reset target_token so the post-loop check doesn't double-check.
|
|
187
|
+
target_token=""
|
|
188
|
+
;;
|
|
189
|
+
esac
|
|
190
|
+
done
|
|
191
|
+
fi
|
|
192
|
+
|
|
193
|
+
if [[ -z "$target_token" ]]; then
|
|
194
|
+
return 0
|
|
195
|
+
fi
|
|
196
|
+
|
|
197
|
+
local target
|
|
198
|
+
target=$(_normalize_target "$target_token")
|
|
199
|
+
if rea_path_is_protected "$target"; then
|
|
200
|
+
# Find the matching pattern for the error message.
|
|
201
|
+
local matched=""
|
|
202
|
+
for pattern in "${REA_PROTECTED_PATTERNS[@]}"; do
|
|
203
|
+
if [[ "$target" == "$pattern" ]]; then matched="$pattern"; break; fi
|
|
204
|
+
if [[ "$pattern" == */ && "$target" == "$pattern"* ]]; then matched="$pattern"; break; fi
|
|
205
|
+
done
|
|
206
|
+
_refuse "$matched" "$target" "$segment"
|
|
207
|
+
fi
|
|
208
|
+
return 0
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
for_each_segment "$CMD" _check_segment
|
|
212
|
+
|
|
213
|
+
exit 0
|
|
@@ -157,14 +157,27 @@ LOWER_NORM=$(printf '%s' "$NORMALIZED" | tr '[:upper:]' '[:lower:]')
|
|
|
157
157
|
# package-managed body — §5a kills it before this matcher runs.
|
|
158
158
|
#
|
|
159
159
|
# SECURITY (defense-in-depth): symlinks INSIDE the .d/ surface are
|
|
160
|
-
# refused
|
|
161
|
-
#
|
|
162
|
-
#
|
|
163
|
-
#
|
|
164
|
-
#
|
|
165
|
-
#
|
|
166
|
-
#
|
|
167
|
-
#
|
|
160
|
+
# refused — both final-component AND intermediate-directory symlinks.
|
|
161
|
+
# A fragment is a short shell script authored in place; consumers do
|
|
162
|
+
# not need symlinks here. Without these checks the gate has two
|
|
163
|
+
# bypass shapes:
|
|
164
|
+
#
|
|
165
|
+
# (a) Final-component symlink:
|
|
166
|
+
# ln -s ../pre-push .husky/pre-push.d/00-evil; write 00-evil
|
|
167
|
+
# — caught by `[ -L "$FILE_PATH" ]`.
|
|
168
|
+
#
|
|
169
|
+
# (b) Intermediate-directory symlink (helix Finding 2 / 0.15.0):
|
|
170
|
+
# mkdir .husky/pre-push.d; ln -s ../ .husky/pre-push.d/linkdir
|
|
171
|
+
# write .husky/pre-push.d/linkdir/pre-push
|
|
172
|
+
# — `[ -L $FILE_PATH ]` only inspects the FINAL component, so a
|
|
173
|
+
# not-yet-existing target whose parent contains a symlink resolves
|
|
174
|
+
# to outside the surface (here: `.husky/pre-push`), letting the
|
|
175
|
+
# attacker write through to the package-managed body.
|
|
176
|
+
#
|
|
177
|
+
# Resolve the realpath of the parent directory and require it to live
|
|
178
|
+
# under the literal extension surface. Use a portable `cd ... && pwd -P`
|
|
179
|
+
# subshell pattern (no Python or readlink -f dependency required).
|
|
180
|
+
# Closes the path-string→symlink bypass completely.
|
|
168
181
|
case "$LOWER_NORM" in
|
|
169
182
|
.husky/commit-msg.d/*|.husky/pre-push.d/*)
|
|
170
183
|
if [ -L "$FILE_PATH" ]; then
|
|
@@ -178,6 +191,34 @@ case "$LOWER_NORM" in
|
|
|
178
191
|
} >&2
|
|
179
192
|
exit 2
|
|
180
193
|
fi
|
|
194
|
+
# Resolve the parent directory's realpath. If any intermediate
|
|
195
|
+
# component is a symlink whose target leaves the surface, the
|
|
196
|
+
# resolved path no longer contains `/.husky/<surface>.d/` and we
|
|
197
|
+
# refuse. The parent dir must already exist for this check; if it
|
|
198
|
+
# doesn't, the write is creating the parent, in which case there
|
|
199
|
+
# is no intermediate symlink to follow yet.
|
|
200
|
+
parent_dir=$(dirname -- "$FILE_PATH")
|
|
201
|
+
if [ -d "$parent_dir" ]; then
|
|
202
|
+
resolved_parent=$(cd -P -- "$parent_dir" 2>/dev/null && pwd -P 2>/dev/null) || resolved_parent=""
|
|
203
|
+
if [ -n "$resolved_parent" ]; then
|
|
204
|
+
case "$resolved_parent" in
|
|
205
|
+
*"/.husky/commit-msg.d"*|*"/.husky/pre-push.d"*) : ;;
|
|
206
|
+
*)
|
|
207
|
+
{
|
|
208
|
+
printf 'SETTINGS PROTECTION: extension path resolves outside surface\n'
|
|
209
|
+
printf '\n'
|
|
210
|
+
printf ' Logical: %s\n' "$SAFE_FILE_PATH"
|
|
211
|
+
printf ' Resolved: %s\n' "$resolved_parent"
|
|
212
|
+
printf ' Rule: an intermediate directory of the extension path is a\n'
|
|
213
|
+
printf ' symlink whose target leaves .husky/{commit-msg,pre-push}.d/.\n'
|
|
214
|
+
printf ' Refused to prevent symlinked-parent bypass of the\n'
|
|
215
|
+
printf ' package-managed body protection.\n'
|
|
216
|
+
} >&2
|
|
217
|
+
exit 2
|
|
218
|
+
;;
|
|
219
|
+
esac
|
|
220
|
+
fi
|
|
221
|
+
fi
|
|
181
222
|
# Documented extension surface — agents can write here freely.
|
|
182
223
|
exit 0
|
|
183
224
|
;;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bookedsolid/rea",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.0",
|
|
4
4
|
"description": "Agentic governance layer for Claude Code — policy enforcement, hook-based safety gates, audit logging, and Codex-integrated adversarial review for AI-assisted projects",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Booked Solid Technology <oss@bookedsolid.tech> (https://bookedsolid.tech)",
|
|
@@ -96,9 +96,11 @@
|
|
|
96
96
|
"lint:regex": "node scripts/lint-safe-regex.mjs",
|
|
97
97
|
"format": "prettier --write .",
|
|
98
98
|
"format:check": "prettier --check .",
|
|
99
|
-
"test": "vitest run",
|
|
99
|
+
"test": "pnpm run test:dogfood && pnpm run test:bash-syntax && vitest run",
|
|
100
100
|
"test:watch": "vitest",
|
|
101
101
|
"test:coverage": "vitest run --coverage",
|
|
102
|
+
"test:dogfood": "node tools/check-dogfood-drift.mjs",
|
|
103
|
+
"test:bash-syntax": "bash -c 'for f in hooks/*.sh hooks/_lib/*.sh; do bash -n \"$f\" || exit 1; done && echo \"[bash-syntax] OK — all hooks parse cleanly\"'",
|
|
102
104
|
"type-check": "tsc --noEmit",
|
|
103
105
|
"changeset": "changeset",
|
|
104
106
|
"changeset:version": "changeset version",
|