@bookedsolid/rea 0.13.3 → 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/MIGRATING.md +46 -0
- 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/dist/hooks/push-gate/codex-runner.d.ts +14 -0
- package/dist/hooks/push-gate/codex-runner.js +37 -1
- package/dist/hooks/push-gate/index.js +8 -0
- package/dist/hooks/push-gate/policy.d.ts +34 -0
- package/dist/hooks/push-gate/policy.js +25 -0
- package/dist/policy/loader.d.ts +38 -0
- package/dist/policy/loader.js +30 -0
- package/dist/policy/types.d.ts +28 -0
- 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 +47 -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/secret-scanner.sh +30 -0
- package/hooks/settings-protection.sh +49 -8
- package/package.json +4 -2
|
@@ -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,13 +106,59 @@ 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
|
}
|
|
113
121
|
|
|
114
122
|
NORMALIZED=$(normalize_path "$FILE_PATH")
|
|
115
123
|
|
|
124
|
+
# ── 5a. Path-traversal rejection (0.14.0 iron-gate fix) ───────────────────────
|
|
125
|
+
# Reject any path containing a `..` segment BEFORE the literal-match below.
|
|
126
|
+
# Without this, `foo/../CODEOWNERS` would get past `normalize_path()` (which
|
|
127
|
+
# only strips leading project root + URL-decodes) and the literal-match
|
|
128
|
+
# loop would compare `foo/../CODEOWNERS` against the literal `CODEOWNERS`
|
|
129
|
+
# entry — which doesn't match, so the policy lets the write through. The
|
|
130
|
+
# downstream Write/Edit tool then resolves the traversal and writes to
|
|
131
|
+
# `CODEOWNERS` anyway, defeating the gate.
|
|
132
|
+
#
|
|
133
|
+
# Mirrors settings-protection.sh §5a (which has had this guard since
|
|
134
|
+
# 0.10.x). Both pre- and post-decode forms are checked because
|
|
135
|
+
# normalize_path() URL-decodes earlier and an attacker could split the
|
|
136
|
+
# traversal across encodings (`%2E%2E/`, `..%2F`, etc.).
|
|
137
|
+
raw_has_traversal=0
|
|
138
|
+
norm_has_traversal=0
|
|
139
|
+
case "/$FILE_PATH/" in
|
|
140
|
+
*/../*) raw_has_traversal=1 ;;
|
|
141
|
+
esac
|
|
142
|
+
case "/$NORMALIZED/" in
|
|
143
|
+
*/../*) norm_has_traversal=1 ;;
|
|
144
|
+
esac
|
|
145
|
+
# Also catch URL-encoded traversal in case some tool routes raw-encoded
|
|
146
|
+
# paths through here (e.g. file:// inputs). normalize_path()'s decoder
|
|
147
|
+
# only handles a fixed set; an unrecognized encoding would slip past.
|
|
148
|
+
case "$FILE_PATH" in
|
|
149
|
+
*%2[Ee]%2[Ee]*|*%2[Ee].*|*.%2[Ee]*) raw_has_traversal=1 ;;
|
|
150
|
+
esac
|
|
151
|
+
if [[ "$raw_has_traversal" -eq 1 ]] || [[ "$norm_has_traversal" -eq 1 ]]; then
|
|
152
|
+
{
|
|
153
|
+
printf 'BLOCKED PATH: path traversal rejected\n'
|
|
154
|
+
printf '\n'
|
|
155
|
+
printf ' File: %s\n' "$FILE_PATH"
|
|
156
|
+
printf " Rule: path contains a '..' segment; rewrite to a canonical\n"
|
|
157
|
+
printf ' project-relative path without traversal.\n'
|
|
158
|
+
} >&2
|
|
159
|
+
exit 2
|
|
160
|
+
fi
|
|
161
|
+
|
|
116
162
|
for writable in "${AGENT_WRITABLE[@]}"; do
|
|
117
163
|
if [[ "$NORMALIZED" == "$writable" ]] || [[ "$NORMALIZED" == "$writable"* && "$writable" == */ ]]; then
|
|
118
164
|
exit 0
|
|
@@ -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." \
|