@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
|
@@ -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
|
package/hooks/secret-scanner.sh
CHANGED
|
@@ -40,11 +40,41 @@ fi
|
|
|
40
40
|
FILE_PATH=$(printf '%s' "$INPUT" | jq -r '.tool_input.file_path // empty' 2>/dev/null)
|
|
41
41
|
CONTENT_WRITE=$(printf '%s' "$INPUT" | jq -r '.tool_input.content // empty' 2>/dev/null)
|
|
42
42
|
CONTENT_EDIT=$(printf '%s' "$INPUT" | jq -r '.tool_input.new_string // empty' 2>/dev/null)
|
|
43
|
+
# MultiEdit (0.14.0 fix): the payload is at tool_input.edits[].new_string —
|
|
44
|
+
# an array, not a scalar — and the prior versions of this hook never read
|
|
45
|
+
# it. Result: any agent could route credential writes through MultiEdit and
|
|
46
|
+
# bypass the secret scanner entirely. We extract every `new_string` value
|
|
47
|
+
# from the edits array and concatenate them with newlines so the awk-based
|
|
48
|
+
# pattern scan below treats them like any other write content.
|
|
49
|
+
#
|
|
50
|
+
# Defensive coercion (codex round-1 P1): a malformed payload where
|
|
51
|
+
# `new_string` is a number, object, or array would make jq error out, the
|
|
52
|
+
# `2>/dev/null` would swallow stderr, `CONTENT_MULTIEDIT` would be empty,
|
|
53
|
+
# and the precedence chain below would fall through to `exit 0` —
|
|
54
|
+
# silently allowing the write. Same fail-open mode for a non-array
|
|
55
|
+
# `edits` value. We:
|
|
56
|
+
#
|
|
57
|
+
# 1. Coerce `.tool_input.edits` to `[]` if it's anything other than an
|
|
58
|
+
# array (`if type=="array" then . else [] end`)
|
|
59
|
+
# 2. Coerce every `new_string` to a string via `tostring` so jq cannot
|
|
60
|
+
# fail on heterogeneous types
|
|
61
|
+
#
|
|
62
|
+
# Both layers fail closed: a malformed payload either yields the empty
|
|
63
|
+
# string (no scan needed, exit 0 from the precedence chain) or yields a
|
|
64
|
+
# pattern-scannable string. There is no path where jq errors silently and
|
|
65
|
+
# the hook falls through to allow.
|
|
66
|
+
CONTENT_MULTIEDIT=$(printf '%s' "$INPUT" | jq -r '
|
|
67
|
+
(.tool_input.edits // [] | if type=="array" then . else [] end)
|
|
68
|
+
| map((.new_string // "") | tostring)
|
|
69
|
+
| join("\n")
|
|
70
|
+
' 2>/dev/null)
|
|
43
71
|
|
|
44
72
|
if [[ -n "$CONTENT_WRITE" ]]; then
|
|
45
73
|
CONTENT="$CONTENT_WRITE"
|
|
46
74
|
elif [[ -n "$CONTENT_EDIT" ]]; then
|
|
47
75
|
CONTENT="$CONTENT_EDIT"
|
|
76
|
+
elif [[ -n "$CONTENT_MULTIEDIT" ]]; then
|
|
77
|
+
CONTENT="$CONTENT_MULTIEDIT"
|
|
48
78
|
else
|
|
49
79
|
exit 0
|
|
50
80
|
fi
|
|
@@ -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",
|