@bookedsolid/rea 0.14.0 → 0.16.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 +13 -6
- package/dist/cli/install/settings-merge.js +3 -2
- package/hooks/_lib/cmd-segments.sh +176 -0
- package/hooks/_lib/halt-check.sh +7 -1
- package/hooks/_lib/path-normalize.sh +72 -0
- package/hooks/_lib/payload-read.sh +66 -0
- package/hooks/_lib/policy-read.sh +4 -1
- package/hooks/_lib/protected-paths.sh +50 -0
- package/hooks/architecture-review-gate.sh +14 -11
- package/hooks/attribution-advisory.sh +21 -14
- package/hooks/blocked-paths-enforcer.sh +48 -17
- package/hooks/changeset-security-gate.sh +43 -14
- package/hooks/dangerous-bash-interceptor.sh +101 -93
- package/hooks/dependency-audit-gate.sh +59 -31
- package/hooks/env-file-protection.sh +19 -11
- package/hooks/protected-paths-bash-gate.sh +303 -0
- package/hooks/secret-scanner.sh +18 -47
- package/hooks/settings-protection.sh +111 -15
- package/package.json +4 -2
|
@@ -23,13 +23,11 @@ if ! command -v jq >/dev/null 2>&1; then
|
|
|
23
23
|
fi
|
|
24
24
|
|
|
25
25
|
# ── 3. HALT check ────────────────────────────────────────────────────────────
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
exit 2
|
|
32
|
-
fi
|
|
26
|
+
# 0.16.0: HALT check sourced from shared _lib/halt-check.sh.
|
|
27
|
+
# shellcheck source=_lib/halt-check.sh
|
|
28
|
+
source "$(dirname "$0")/_lib/halt-check.sh"
|
|
29
|
+
check_halt
|
|
30
|
+
REA_ROOT=$(rea_root)
|
|
33
31
|
|
|
34
32
|
# ── 4. Extract file path from payload ─────────────────────────────────────────
|
|
35
33
|
FILE_PATH=$(printf '%s' "$INPUT" | jq -r '.tool_input.file_path // empty' 2>/dev/null)
|
|
@@ -100,16 +98,12 @@ AGENT_WRITABLE=(
|
|
|
100
98
|
'.rea/audit/'
|
|
101
99
|
)
|
|
102
100
|
|
|
103
|
-
normalize_path
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
p=$(printf '%s' "$p" | sed 's/%2[Ff]/\//g; s/%2[Ee]/./g; s/%20/ /g')
|
|
110
|
-
p="${p#./}"
|
|
111
|
-
printf '%s' "$p"
|
|
112
|
-
}
|
|
101
|
+
# 0.16.0: normalize_path migrated to shared `_lib/path-normalize.sh`.
|
|
102
|
+
# Both this hook AND settings-protection.sh consume the same helper
|
|
103
|
+
# so URL-decoding / backslash-translation / `./`-stripping cannot
|
|
104
|
+
# drift between them again.
|
|
105
|
+
# shellcheck source=_lib/path-normalize.sh
|
|
106
|
+
source "$(dirname "$0")/_lib/path-normalize.sh"
|
|
113
107
|
|
|
114
108
|
NORMALIZED=$(normalize_path "$FILE_PATH")
|
|
115
109
|
|
|
@@ -211,4 +205,41 @@ for blocked in "${BLOCKED_PATHS[@]}"; do
|
|
|
211
205
|
fi
|
|
212
206
|
done
|
|
213
207
|
|
|
208
|
+
# ── 0.16.0 fix H.2: intermediate-symlink resolution ──────────────────────────
|
|
209
|
+
# Same shape as Helix Finding 2 against blocked_paths policy entries.
|
|
210
|
+
# If `secrets/` is in blocked_paths and an attacker creates
|
|
211
|
+
# `pretty/ -> ../secrets/`, then writes `pretty/foo`, the literal-match
|
|
212
|
+
# loop above sees `pretty/foo` (no match) and exits 0 — the downstream
|
|
213
|
+
# Write tool follows the symlink and lands the body in `secrets/foo`.
|
|
214
|
+
# Mirrors settings-protection.sh §6c.
|
|
215
|
+
if [[ -e "$FILE_PATH" || -d "$(dirname -- "$FILE_PATH")" ]]; then
|
|
216
|
+
parent_dir=$(dirname -- "$FILE_PATH")
|
|
217
|
+
if [[ -d "$parent_dir" ]]; then
|
|
218
|
+
resolved_parent=$(cd -P -- "$parent_dir" 2>/dev/null && pwd -P 2>/dev/null) || resolved_parent=""
|
|
219
|
+
if [[ -n "$resolved_parent" && "$resolved_parent" == "$REA_ROOT"/* ]]; then
|
|
220
|
+
relative_resolved="${resolved_parent#"$REA_ROOT"/}"
|
|
221
|
+
resolved_target="${relative_resolved}/$(basename -- "$FILE_PATH")"
|
|
222
|
+
resolved_target_lc=$(printf '%s' "$resolved_target" | tr '[:upper:]' '[:lower:]')
|
|
223
|
+
for blocked in "${BLOCKED_PATHS[@]}"; do
|
|
224
|
+
blocked_lc=$(printf '%s' "$blocked" | tr '[:upper:]' '[:lower:]')
|
|
225
|
+
if [[ "$resolved_target_lc" == "$blocked_lc" ]] || \
|
|
226
|
+
{ [[ "$blocked_lc" == */ ]] && [[ "$resolved_target_lc" == "$blocked_lc"* ]]; }; then
|
|
227
|
+
{
|
|
228
|
+
printf 'BLOCKED PATH: intermediate-symlink resolution blocked\n'
|
|
229
|
+
printf '\n'
|
|
230
|
+
printf ' Logical: %s\n' "$FILE_PATH"
|
|
231
|
+
printf ' Resolved: %s\n' "$resolved_target"
|
|
232
|
+
printf ' Blocked by: %s\n' "$blocked"
|
|
233
|
+
printf ' Source: .rea/policy.yaml → blocked_paths\n'
|
|
234
|
+
printf '\n'
|
|
235
|
+
printf ' Rule: an intermediate directory of the path is a symlink\n'
|
|
236
|
+
printf ' whose target falls inside a blocked policy entry.\n'
|
|
237
|
+
} >&2
|
|
238
|
+
exit 2
|
|
239
|
+
fi
|
|
240
|
+
done
|
|
241
|
+
fi
|
|
242
|
+
fi
|
|
243
|
+
fi
|
|
244
|
+
|
|
214
245
|
exit 0
|
|
@@ -23,27 +23,32 @@ 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. 0.16.0: NotebookEdit added too (changesets are .md files
|
|
30
|
+
# but a malicious agent could in principle route a .md write through
|
|
31
|
+
# NotebookEdit's new_source path; cheap to allow, free to test).
|
|
32
|
+
if [[ "$TOOL_NAME" != "Write" && "$TOOL_NAME" != "Edit" && "$TOOL_NAME" != "MultiEdit" && "$TOOL_NAME" != "NotebookEdit" ]]; then
|
|
28
33
|
exit 0
|
|
29
34
|
fi
|
|
30
35
|
|
|
31
|
-
|
|
36
|
+
require_jq
|
|
37
|
+
|
|
38
|
+
# 0.16.0: payload extraction migrated to `_lib/payload-read.sh`. Shared
|
|
39
|
+
# helpers handle every write-tier tool with the same defensive
|
|
40
|
+
# coercion. Adding the next write-tier tool is a one-line edit there.
|
|
41
|
+
# shellcheck source=_lib/payload-read.sh
|
|
42
|
+
source "$(dirname "$0")/_lib/payload-read.sh"
|
|
43
|
+
|
|
44
|
+
FILE_PATH=$(extract_file_path "$INPUT")
|
|
32
45
|
|
|
33
46
|
# Only care about .changeset/*.md files — exclude README.md (changeset tool metadata)
|
|
34
47
|
if ! echo "$FILE_PATH" | grep -qE '\.changeset/[^/]+\.md$' || echo "$FILE_PATH" | grep -qE '\.changeset/README\.md$'; then
|
|
35
48
|
exit 0
|
|
36
49
|
fi
|
|
37
50
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
# Extract the content being written
|
|
41
|
-
if [[ "$TOOL_NAME" == "Write" ]]; then
|
|
42
|
-
CONTENT=$(echo "$INPUT" | jq -r '.tool_input.content // ""')
|
|
43
|
-
else
|
|
44
|
-
# For Edit: check the new_string being inserted
|
|
45
|
-
CONTENT=$(echo "$INPUT" | jq -r '.tool_input.new_string // ""')
|
|
46
|
-
fi
|
|
51
|
+
CONTENT=$(extract_write_content "$INPUT")
|
|
47
52
|
|
|
48
53
|
# ─── 1. SECURITY DISCLOSURE CHECK ───────────────────────────────────────────
|
|
49
54
|
#
|
|
@@ -90,6 +95,19 @@ fi
|
|
|
90
95
|
#
|
|
91
96
|
# A changeset without valid frontmatter is silently ignored by the changesets
|
|
92
97
|
# tool — the package bump and CHANGELOG entry never appear in the release.
|
|
98
|
+
#
|
|
99
|
+
# 0.15.0 fix: skip frontmatter validation for MultiEdit. MultiEdit's
|
|
100
|
+
# `tool_input.edits[].new_string` payload is a list of partial string
|
|
101
|
+
# replacements, not the full file body — running the frontmatter
|
|
102
|
+
# validator against the concatenation of new_strings would reject every
|
|
103
|
+
# legitimate MultiEdit on an existing changeset (none of the edit
|
|
104
|
+
# fragments individually contains a frontmatter block, even though the
|
|
105
|
+
# resulting file does). The disclosure scan above still runs on
|
|
106
|
+
# MultiEdit content because GHSA/CVE patterns match per-fragment without
|
|
107
|
+
# any structural assumption.
|
|
108
|
+
if [[ "$TOOL_NAME" == "MultiEdit" ]]; then
|
|
109
|
+
exit 0
|
|
110
|
+
fi
|
|
93
111
|
|
|
94
112
|
# Must start with ---
|
|
95
113
|
if ! echo "$CONTENT" | head -1 | grep -qE '^---'; then
|
|
@@ -107,9 +125,20 @@ Brief description of what changed and why (close #N if applicable).
|
|
|
107
125
|
Bump types: patch (bug fix/security), minor (new feature), major (breaking change)"
|
|
108
126
|
fi
|
|
109
127
|
|
|
110
|
-
# Must have at least one package bump entry and a closing
|
|
128
|
+
# Must have at least one package bump entry and a closing ---.
|
|
129
|
+
# 0.15.0 fix: accept single-quoted, double-quoted, AND unquoted package
|
|
130
|
+
# names (all three are valid YAML for the same string). Pre-fix the
|
|
131
|
+
# regex required single quotes, so a tool or human authoring the
|
|
132
|
+
# changeset with `"@scope/name": patch` was rejected as malformed even
|
|
133
|
+
# though the Changesets tool itself accepts every form.
|
|
134
|
+
#
|
|
135
|
+
# Codex round-1 P2-1 fix: explicit-alternation form (no backref) so
|
|
136
|
+
# the unquoted variant matches on BSD grep too. The earlier
|
|
137
|
+
# `^([\"']?)[^\"']+\1: ...` shape relied on backref-with-empty-capture
|
|
138
|
+
# semantics that BSD's grep rejects when the capture group's `?` made
|
|
139
|
+
# it absent — quoted forms matched on macOS but unquoted did not.
|
|
111
140
|
FRONTMATTER=$(echo "$CONTENT" | awk '/^---/{count++; if(count==2){exit} next} count==1{print}')
|
|
112
|
-
if ! echo "$FRONTMATTER" | grep -qE "^'
|
|
141
|
+
if ! echo "$FRONTMATTER" | grep -qE "^(\"[^\"]+\"|'[^']+'|[^\"'[:space:]]+): (patch|minor|major)"; then
|
|
113
142
|
json_output "block" \
|
|
114
143
|
"CHANGESET FORMAT GATE: Frontmatter does not contain a valid package bump entry.
|
|
115
144
|
|
|
@@ -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
|
|
|
@@ -26,13 +35,11 @@ if ! command -v jq >/dev/null 2>&1; then
|
|
|
26
35
|
fi
|
|
27
36
|
|
|
28
37
|
# ── 3. HALT check ─────────────────────────────────────────────────────────────
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
exit 2
|
|
35
|
-
fi
|
|
38
|
+
# 0.16.0: HALT check sourced from shared _lib/halt-check.sh.
|
|
39
|
+
# shellcheck source=_lib/halt-check.sh
|
|
40
|
+
source "$(dirname "$0")/_lib/halt-check.sh"
|
|
41
|
+
check_halt
|
|
42
|
+
REA_ROOT=$(rea_root)
|
|
36
43
|
|
|
37
44
|
# ── 4. Parse tool_input.command from the hook payload ─────────────────────────
|
|
38
45
|
CMD=$(printf '%s' "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null)
|
|
@@ -84,26 +91,21 @@ add_medium() {
|
|
|
84
91
|
}
|
|
85
92
|
|
|
86
93
|
# ── 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
|
-
}
|
|
94
|
+
# (Migrated to `_lib/cmd-segments.sh::any_segment_matches` as of 0.15.0.
|
|
95
|
+
# The previous inline helper was defined here but never called — H3-H17
|
|
96
|
+
# all greped the WHOLE command, which false-positived on heredoc bodies
|
|
97
|
+
# and commit messages mentioning trigger words. Migration: every check
|
|
98
|
+
# now uses `any_segment_matches "$CMD" PATTERN` with the helper sourced
|
|
99
|
+
# at the top of this file.)
|
|
98
100
|
|
|
99
101
|
# ── 8. Smart exclusion flags ──────────────────────────────────────────────────
|
|
100
102
|
CMD_IS_REBASE_SAFE=0
|
|
101
|
-
if
|
|
103
|
+
if any_segment_starts_with "$CMD" 'git[[:space:]]+(rebase)[[:space:]].*(--abort|--continue)'; then
|
|
102
104
|
CMD_IS_REBASE_SAFE=1
|
|
103
105
|
fi
|
|
104
106
|
|
|
105
107
|
CMD_IS_CLEAN_DRY=0
|
|
106
|
-
if
|
|
108
|
+
if any_segment_starts_with "$CMD" 'git[[:space:]]+clean.*([ \t]-n|--dry-run)'; then
|
|
107
109
|
CMD_IS_CLEAN_DRY=1
|
|
108
110
|
fi
|
|
109
111
|
|
|
@@ -111,26 +113,41 @@ fi
|
|
|
111
113
|
|
|
112
114
|
# H1: git push --force or -f (per-segment — prevents --force-with-lease poisoning)
|
|
113
115
|
# A segment containing --force-with-lease is excluded; other segments are not.
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
116
|
+
# 0.15.0: also catches `git push origin +<branch>` (refspec-prefix force-push
|
|
117
|
+
# shorthand) which the previous version missed.
|
|
118
|
+
_h1_check() {
|
|
119
|
+
local _raw="$1" SEGMENT="$2"
|
|
120
|
+
[[ -z "$SEGMENT" ]] && return 0
|
|
121
|
+
# 0.15.0 codex P1 fix: anchor on `^git push`. Pre-fix the unanchored
|
|
122
|
+
# match meant `echo "git push --force is bad"` triggered H1 even
|
|
123
|
+
# though no actual push was happening (the segment after prefix-strip
|
|
124
|
+
# was `echo "..."`, not `git push`). Anchoring scopes detection to
|
|
125
|
+
# segments whose first token IS git push.
|
|
126
|
+
printf '%s' "$SEGMENT" | grep -qiE '^git[[:space:]]+push([[:space:]]|$)' || return 0
|
|
127
|
+
# Skip segments that use the safe --force-with-lease.
|
|
128
|
+
if printf '%s' "$SEGMENT" | grep -qiE -- '--force-with-lease'; then
|
|
129
|
+
return 0
|
|
120
130
|
fi
|
|
121
|
-
|
|
122
|
-
|
|
131
|
+
# 0.15.0 codex P1 fix: combined-flag forms (`-fu`, `-uf`, `-Fu`) and
|
|
132
|
+
# long-form `--force=value` were not caught by the previous
|
|
133
|
+
# `-f[[:space:]]` shape. The flag-cluster pattern `-[a-zA-Z]*f[a-zA-Z]*`
|
|
134
|
+
# (followed by space or EOS) mirrors how H11 handles rm flag clusters.
|
|
135
|
+
# The refspec-prefix `+` on a branch name is git's force-push shorthand.
|
|
136
|
+
if printf '%s' "$SEGMENT" | grep -qiE -- '--force([[:space:]]|=|$)' || \
|
|
137
|
+
printf '%s' "$SEGMENT" | grep -qiE -- '(^|[[:space:]])-[a-zA-Z]*f[a-zA-Z]*([[:space:]]|$)' || \
|
|
138
|
+
printf '%s' "$SEGMENT" | grep -qE -- '[[:space:]]\+[A-Za-z0-9_./-]'; then
|
|
123
139
|
add_high \
|
|
124
140
|
"git push --force — force push detected" \
|
|
125
141
|
"Force-pushing rewrites public history and breaks collaborators' local copies." \
|
|
126
142
|
"Alt: Use 'git push --force-with-lease' — blocks if upstream has new commits you haven't pulled."
|
|
127
|
-
break
|
|
128
143
|
fi
|
|
129
|
-
|
|
144
|
+
return 0
|
|
145
|
+
}
|
|
146
|
+
for_each_segment "$CMD" _h1_check
|
|
130
147
|
|
|
131
148
|
# H2: git rebase — advisory (MEDIUM)
|
|
132
149
|
if [[ $CMD_IS_REBASE_SAFE -eq 0 ]]; then
|
|
133
|
-
if
|
|
150
|
+
if any_segment_starts_with "$CMD" 'git[[:space:]]+rebase([[:space:]]|$)'; then
|
|
134
151
|
add_medium \
|
|
135
152
|
"git rebase — rewrites commit history (advisory)" \
|
|
136
153
|
"Rebase changes commit SHAs. Safe on local feature branches; dangerous on shared/published branches." \
|
|
@@ -140,7 +157,7 @@ if [[ $CMD_IS_REBASE_SAFE -eq 0 ]]; then
|
|
|
140
157
|
fi
|
|
141
158
|
|
|
142
159
|
# H3: git checkout -- .
|
|
143
|
-
if
|
|
160
|
+
if any_segment_starts_with "$CMD" 'git[[:space:]]+checkout[[:space:]]+--[[:space:]]+\.'; then
|
|
144
161
|
add_high \
|
|
145
162
|
"git checkout -- . — discards all uncommitted changes" \
|
|
146
163
|
"Overwrites working tree changes with HEAD. Uncommitted work is lost permanently." \
|
|
@@ -148,8 +165,8 @@ if printf '%s' "$CMD" | grep -qiE 'git[[:space:]]+checkout[[:space:]]+--[[:space
|
|
|
148
165
|
fi
|
|
149
166
|
|
|
150
167
|
# H4: git restore . (any form — with or without --staged flag)
|
|
151
|
-
if
|
|
152
|
-
|
|
168
|
+
if any_segment_starts_with "$CMD" 'git[[:space:]]+restore[[:space:]].*[[:space:]]\.([[:space:]]|$)' || \
|
|
169
|
+
any_segment_starts_with "$CMD" 'git[[:space:]]+restore[[:space:]]+\.[[:space:]]*$'; then
|
|
153
170
|
add_high \
|
|
154
171
|
"git restore . — discards all uncommitted changes" \
|
|
155
172
|
"Restores every tracked file to HEAD, permanently discarding all working tree modifications." \
|
|
@@ -158,7 +175,7 @@ fi
|
|
|
158
175
|
|
|
159
176
|
# H5: git clean -f
|
|
160
177
|
if [[ $CMD_IS_CLEAN_DRY -eq 0 ]]; then
|
|
161
|
-
if
|
|
178
|
+
if any_segment_starts_with "$CMD" 'git[[:space:]]+clean[[:space:]]+-[a-zA-Z]*f'; then
|
|
162
179
|
add_high \
|
|
163
180
|
"git clean -f — removes untracked files" \
|
|
164
181
|
"Permanently deletes untracked files from the working tree. Cannot be undone via git." \
|
|
@@ -167,7 +184,7 @@ if [[ $CMD_IS_CLEAN_DRY -eq 0 ]]; then
|
|
|
167
184
|
fi
|
|
168
185
|
|
|
169
186
|
# H6: DROP TABLE or DROP DATABASE in psql
|
|
170
|
-
if
|
|
187
|
+
if any_segment_matches "$CMD" '(psql|pgcli)[^|&;]*DROP[[:space:]]+(TABLE|DATABASE|SCHEMA)'; then
|
|
171
188
|
add_high \
|
|
172
189
|
"DROP TABLE/DATABASE via psql — destructive DDL" \
|
|
173
190
|
"Running destructive DDL directly in psql bypasses migration pipeline safety checks." \
|
|
@@ -175,7 +192,7 @@ if printf '%s' "$CMD" | grep -qiE '(psql|pgcli)[^|&;]*DROP[[:space:]]+(TABLE|DAT
|
|
|
175
192
|
fi
|
|
176
193
|
|
|
177
194
|
# H7: kill -9 with pgrep subshell
|
|
178
|
-
if
|
|
195
|
+
if any_segment_starts_with "$CMD" 'kill[[:space:]]+-9[[:space:]]+(\$\(|`)'; then
|
|
179
196
|
add_high \
|
|
180
197
|
"kill -9 with pgrep subshell — aggressive process termination" \
|
|
181
198
|
"Sends SIGKILL to processes matched by name, which may kill unintended processes." \
|
|
@@ -183,7 +200,7 @@ if printf '%s' "$CMD" | grep -qiE 'kill[[:space:]]+-9[[:space:]]+(\$\(|`)'; then
|
|
|
183
200
|
fi
|
|
184
201
|
|
|
185
202
|
# H8: killall -9
|
|
186
|
-
if
|
|
203
|
+
if any_segment_starts_with "$CMD" 'killall[[:space:]]+-9[[:space:]]+\S'; then
|
|
187
204
|
add_high \
|
|
188
205
|
"killall -9 — SIGKILL all matching processes" \
|
|
189
206
|
"Immediately terminates all processes with the given name without cleanup." \
|
|
@@ -191,7 +208,7 @@ if printf '%s' "$CMD" | grep -qiE 'killall[[:space:]]+-9[[:space:]]+\S'; then
|
|
|
191
208
|
fi
|
|
192
209
|
|
|
193
210
|
# H9: git commit --no-verify
|
|
194
|
-
if
|
|
211
|
+
if any_segment_starts_with "$CMD" 'git[[:space:]]+commit.*--no-verify'; then
|
|
195
212
|
add_high \
|
|
196
213
|
"git commit --no-verify — skipping pre-commit hooks" \
|
|
197
214
|
"Bypasses all pre-commit safety gates including secret scanning and linting." \
|
|
@@ -199,7 +216,7 @@ if printf '%s' "$CMD" | grep -qiE 'git[[:space:]]+commit.*--no-verify'; then
|
|
|
199
216
|
fi
|
|
200
217
|
|
|
201
218
|
# H10: HUSKY=0 bypass — suppresses all git hooks without --no-verify
|
|
202
|
-
if
|
|
219
|
+
if any_segment_matches "$CMD" '(^|[[:space:];]|&&|\|\|)HUSKY=0[[:space:]]+git[[:space:]]+(commit|push|tag)'; then
|
|
203
220
|
add_high \
|
|
204
221
|
"HUSKY=0 — bypasses all husky git hooks" \
|
|
205
222
|
"Setting HUSKY=0 disables pre-commit, commit-msg, and pre-push safety gates without --no-verify." \
|
|
@@ -208,13 +225,18 @@ fi
|
|
|
208
225
|
|
|
209
226
|
# H11: rm -rf with broad targets
|
|
210
227
|
# 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
|
-
|
|
228
|
+
# 0.15.0 fix: anchored each target on word boundary (whitespace-or-EOS).
|
|
229
|
+
# The previous form had a bare `\.` which matched `rm -rf .git/foo`
|
|
230
|
+
# (legitimate `.git/`-tree cleanup). Each token now requires either
|
|
231
|
+
# end-of-string or whitespace after — so `.` alone matches `rm -rf .`
|
|
232
|
+
# (the cwd, dangerous) but NOT `rm -rf .git/foo`.
|
|
233
|
+
BROAD_TARGETS='(\/|~\/|\.\/\*|\*|\.|src|dist|build|node_modules)([[:space:]]|$)'
|
|
234
|
+
if any_segment_starts_with "$CMD" "rm[[:space:]]+-[a-zA-Z]*r[a-zA-Z]*f[[:space:]]+${BROAD_TARGETS}" || \
|
|
235
|
+
any_segment_starts_with "$CMD" "rm[[:space:]]+-[a-zA-Z]*f[a-zA-Z]*r[[:space:]]+${BROAD_TARGETS}" || \
|
|
236
|
+
any_segment_starts_with "$CMD" "rm[[:space:]]+-[a-zA-Z]*r[[:space:]]+-[a-zA-Z]*f[[:space:]]+${BROAD_TARGETS}" || \
|
|
237
|
+
any_segment_starts_with "$CMD" "rm[[:space:]]+-[a-zA-Z]*f[[:space:]]+-[a-zA-Z]*r[[:space:]]+${BROAD_TARGETS}" || \
|
|
238
|
+
any_segment_starts_with "$CMD" "rm[[:space:]]+--recursive[[:space:]]+--force[[:space:]]+${BROAD_TARGETS}" || \
|
|
239
|
+
any_segment_starts_with "$CMD" "rm[[:space:]]+--force[[:space:]]+--recursive[[:space:]]+${BROAD_TARGETS}"; then
|
|
218
240
|
add_high \
|
|
219
241
|
"rm -rf with broad target — mass file deletion" \
|
|
220
242
|
"Permanently deletes files and directories. Cannot be undone." \
|
|
@@ -222,7 +244,7 @@ if printf '%s' "$CMD" | grep -qiE "rm[[:space:]]+-[a-zA-Z]*r[a-zA-Z]*f[[:space:]
|
|
|
222
244
|
fi
|
|
223
245
|
|
|
224
246
|
# H12: curl/wget piped directly to shell (supply chain attack vector)
|
|
225
|
-
if
|
|
247
|
+
if any_segment_matches "$CMD" '(curl|wget)[^|]*\|[[:space:]]*(bash|sh|zsh|fish)'; then
|
|
226
248
|
add_high \
|
|
227
249
|
"curl/wget piped to shell — remote code execution" \
|
|
228
250
|
"Executing remote scripts without inspection is a major supply chain risk." \
|
|
@@ -230,7 +252,7 @@ if printf '%s' "$CMD" | grep -qiE '(curl|wget)[^|]*\|[[:space:]]*(bash|sh|zsh|fi
|
|
|
230
252
|
fi
|
|
231
253
|
|
|
232
254
|
# H13: git push --no-verify — bypasses pre-push hooks
|
|
233
|
-
if
|
|
255
|
+
if any_segment_starts_with "$CMD" 'git[[:space:]]+push.*--no-verify'; then
|
|
234
256
|
add_high \
|
|
235
257
|
"git push --no-verify — skipping pre-push hooks" \
|
|
236
258
|
"Bypasses all pre-push safety gates including CI checks." \
|
|
@@ -238,7 +260,7 @@ if printf '%s' "$CMD" | grep -qiE 'git[[:space:]]+push.*--no-verify'; then
|
|
|
238
260
|
fi
|
|
239
261
|
|
|
240
262
|
# H14: git -c core.hooksPath= — redirects or disables hook execution
|
|
241
|
-
if
|
|
263
|
+
if any_segment_starts_with "$CMD" 'git[[:space:]]+-c[[:space:]]+core\.hookspath'; then
|
|
242
264
|
add_high \
|
|
243
265
|
"git -c core.hooksPath — overriding hooks directory" \
|
|
244
266
|
"Redirecting the hooks path can disable all safety hooks." \
|
|
@@ -246,7 +268,7 @@ if printf '%s' "$CMD" | grep -qiE 'git[[:space:]]+-c[[:space:]]+core\.hookspath'
|
|
|
246
268
|
fi
|
|
247
269
|
|
|
248
270
|
# H15: REA_BYPASS env var — attempted escape hatch
|
|
249
|
-
if
|
|
271
|
+
if any_segment_matches "$CMD" '(^|[[:space:];]|&&|\|\|)REA_BYPASS[[:space:]]*='; then
|
|
250
272
|
add_high \
|
|
251
273
|
"REA_BYPASS env var — unauthorized bypass attempt" \
|
|
252
274
|
"Setting REA_BYPASS is not a supported escape mechanism and indicates a bypass attempt." \
|
|
@@ -254,60 +276,46 @@ if printf '%s' "$CMD" | grep -qiE '(^|[[:space:];]|&&|\|\|)REA_BYPASS[[:space:]]
|
|
|
254
276
|
fi
|
|
255
277
|
|
|
256
278
|
# H16: alias/function definitions containing bypass strings
|
|
257
|
-
if
|
|
279
|
+
if any_segment_matches "$CMD" '(alias|function)[[:space:]]+[a-zA-Z_]+.*(--(no-verify|force)|HUSKY=0|core\.hookspath)'; then
|
|
258
280
|
add_high \
|
|
259
281
|
"Alias/function definition with bypass — circumventing safety gates" \
|
|
260
282
|
"Defining aliases or functions that embed bypass flags defeats safety hooks." \
|
|
261
283
|
"Alt: Do not wrap bypass patterns in aliases or functions."
|
|
262
284
|
fi
|
|
263
285
|
|
|
264
|
-
# H17: context_protection — block commands that should be delegated to subagents
|
|
286
|
+
# H17: context_protection — block commands that should be delegated to subagents.
|
|
265
287
|
# Reads context_protection.delegate_to_subagent from .rea/policy.yaml.
|
|
266
288
|
# These commands produce excessive output that exhausts coordinator context windows.
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
done < "$POLICY_FILE"
|
|
293
|
-
|
|
294
|
-
for pattern in "${DELEGATE_PATTERNS[@]+"${DELEGATE_PATTERNS[@]}"}"; do
|
|
295
|
-
# Use fixed-string match — these are command prefixes, not regex
|
|
296
|
-
if printf '%s' "$CMD" | grep -qF "$pattern"; then
|
|
297
|
-
add_high \
|
|
298
|
-
"Context protection — command must run in a subagent" \
|
|
299
|
-
"This command produces excessive output that will exhaust the coordinator context window. Delegate it to a subagent instead of running it directly." \
|
|
300
|
-
"Alt: Use the Agent tool to delegate: Agent(subagent_type: 'qa-engineer-automation', prompt: 'Run $pattern and report pass/fail summary only.')" \
|
|
301
|
-
"Alt: The context_protection policy in .rea/policy.yaml lists commands that must be delegated."
|
|
302
|
-
break
|
|
303
|
-
fi
|
|
304
|
-
done
|
|
305
|
-
fi
|
|
289
|
+
#
|
|
290
|
+
# 0.16.0 fix J.2: replaced the inline YAML parser (40+ lines reimplementing
|
|
291
|
+
# block-sequence walking) with `policy_list` from `_lib/policy-read.sh`.
|
|
292
|
+
# Same parser shape as every other rea hook now reads policy via the shared
|
|
293
|
+
# helper; drift between hooks is structurally impossible.
|
|
294
|
+
# shellcheck source=_lib/policy-read.sh
|
|
295
|
+
source "$(dirname "$0")/_lib/policy-read.sh"
|
|
296
|
+
|
|
297
|
+
DELEGATE_PATTERNS=()
|
|
298
|
+
while IFS= read -r pattern; do
|
|
299
|
+
[[ -z "$pattern" ]] && continue
|
|
300
|
+
DELEGATE_PATTERNS+=("$pattern")
|
|
301
|
+
done < <(policy_list "delegate_to_subagent")
|
|
302
|
+
|
|
303
|
+
for pattern in "${DELEGATE_PATTERNS[@]+"${DELEGATE_PATTERNS[@]}"}"; do
|
|
304
|
+
# Use fixed-string match — these are command prefixes, not regex.
|
|
305
|
+
if printf '%s' "$CMD" | grep -qF "$pattern"; then
|
|
306
|
+
add_high \
|
|
307
|
+
"Context protection — command must run in a subagent" \
|
|
308
|
+
"This command produces excessive output that will exhaust the coordinator context window. Delegate it to a subagent instead of running it directly." \
|
|
309
|
+
"Alt: Use the Agent tool to delegate: Agent(subagent_type: 'qa-engineer-automation', prompt: 'Run $pattern and report pass/fail summary only.')" \
|
|
310
|
+
"Alt: The context_protection policy in .rea/policy.yaml lists commands that must be delegated."
|
|
311
|
+
break
|
|
312
|
+
fi
|
|
313
|
+
done
|
|
306
314
|
|
|
307
315
|
# ── 10. MEDIUM severity checks ────────────────────────────────────────────────
|
|
308
316
|
|
|
309
317
|
# M1: npm install --force
|
|
310
|
-
if
|
|
318
|
+
if any_segment_matches "$CMD" 'npm[[:space:]]+(install|i)[[:space:]].*--force'; then
|
|
311
319
|
add_medium \
|
|
312
320
|
"npm install --force — bypasses dependency resolution" \
|
|
313
321
|
"--force skips conflict checks and can install incompatible package versions." \
|
|
@@ -21,13 +21,11 @@ if ! command -v jq >/dev/null 2>&1; then
|
|
|
21
21
|
fi
|
|
22
22
|
|
|
23
23
|
# ── 3. HALT check ────────────────────────────────────────────────────────────
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
exit 2
|
|
30
|
-
fi
|
|
24
|
+
# 0.16.0: HALT check sourced from shared _lib/halt-check.sh.
|
|
25
|
+
# shellcheck source=_lib/halt-check.sh
|
|
26
|
+
source "$(dirname "$0")/_lib/halt-check.sh"
|
|
27
|
+
check_halt
|
|
28
|
+
REA_ROOT=$(rea_root)
|
|
31
29
|
|
|
32
30
|
# ── 4. Parse command ──────────────────────────────────────────────────────────
|
|
33
31
|
CMD=$(printf '%s' "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null)
|
|
@@ -43,30 +41,60 @@ fi
|
|
|
43
41
|
extract_packages() {
|
|
44
42
|
local cmd="$1"
|
|
45
43
|
|
|
46
|
-
#
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
44
|
+
# 0.15.0 fix: the previous parser ran `grep` against the entire bash
|
|
45
|
+
# command string with no segment boundary anchor. A heredoc body or
|
|
46
|
+
# commit-message containing `pnpm install` (e.g. inside
|
|
47
|
+
# `git commit -m "$(cat <<EOF ... pnpm install ... EOF)"`) matched the
|
|
48
|
+
# grep, the `.*` in the sed stripped up to that occurrence, and the rest
|
|
49
|
+
# of the command (`chore:`, `&&`, `||`, etc.) was passed to
|
|
50
|
+
# `npm view <token> name` and reported as missing packages. The hook
|
|
51
|
+
# then refused to commit perfectly innocent code.
|
|
52
|
+
#
|
|
53
|
+
# Fix: split the command on shell command separators (`;`, `&&`, `||`,
|
|
54
|
+
# `|`, newlines) and only run the install-detection on segments whose
|
|
55
|
+
# FIRST non-whitespace token is one of the install commands. Heredoc
|
|
56
|
+
# bodies inside `$()` substitutions are NOT split into separate segments
|
|
57
|
+
# — the entire `$(cat <<EOF ... EOF)` is one token attached to the
|
|
58
|
+
# outer command — but they're never the FIRST token on a segment, so
|
|
59
|
+
# the anchor rejects them.
|
|
60
|
+
|
|
61
|
+
# Tokenize on shell separators. Each `IFS=` entry becomes a separate
|
|
62
|
+
# segment we can anchor against. We use bash's `mapfile` with a sed
|
|
63
|
+
# to inject newlines at separators; awk-based splitting handles the
|
|
64
|
+
# quoting heuristic well enough for the realistic cases (agent-issued
|
|
65
|
+
# commands rarely have separators inside single-quoted strings that
|
|
66
|
+
# would confuse this).
|
|
67
|
+
local segments
|
|
68
|
+
segments=$(printf '%s\n' "$cmd" | sed -E 's/(\|\||\&\&|;|\|)/\n/g')
|
|
69
|
+
|
|
70
|
+
while IFS= read -r segment; do
|
|
71
|
+
# Trim leading whitespace.
|
|
72
|
+
segment="${segment#"${segment%%[![:space:]]*}"}"
|
|
73
|
+
# Anchor to start: only match when the install command is the FIRST
|
|
74
|
+
# thing on the segment, optionally preceded by `sudo` / `exec` /
|
|
75
|
+
# `time` / etc.
|
|
76
|
+
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
|
|
77
|
+
# Strip the leading prefix wrappers + install command, leaving args.
|
|
78
|
+
local after_cmd
|
|
79
|
+
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:]]+//')
|
|
80
|
+
|
|
81
|
+
for token in $after_cmd; do
|
|
82
|
+
if [[ "$token" == -* ]]; then continue; fi
|
|
83
|
+
if [[ "$token" == ./* || "$token" == /* || "$token" == ../* ]]; then continue; fi
|
|
84
|
+
if [[ -z "$token" ]]; then continue; fi
|
|
85
|
+
# `npm view` can't validate `@workspace:*` / `link:` / `file:`
|
|
86
|
+
# prefixes (workspace protocols). Skip them — they're never npm
|
|
87
|
+
# registry packages.
|
|
88
|
+
if [[ "$token" == workspace:* || "$token" == link:* || "$token" == file:* || "$token" == git+* ]]; then continue; fi
|
|
89
|
+
local pkg_name
|
|
90
|
+
pkg_name=$(printf '%s' "$token" | sed -E 's/@[^@/]+$//')
|
|
91
|
+
if [[ -z "$pkg_name" ]]; then
|
|
92
|
+
pkg_name="$token"
|
|
93
|
+
fi
|
|
94
|
+
printf '%s\n' "$pkg_name"
|
|
95
|
+
done
|
|
96
|
+
fi
|
|
97
|
+
done <<< "$segments"
|
|
70
98
|
}
|
|
71
99
|
|
|
72
100
|
PACKAGES=$(extract_packages "$CMD")
|