@bookedsolid/rea 0.31.0 → 0.33.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.
Files changed (43) hide show
  1. package/.husky/prepare-commit-msg +80 -6
  2. package/MIGRATING.md +24 -15
  3. package/dist/cli/hook.js +60 -22
  4. package/dist/hooks/_lib/halt-check.d.ts +78 -0
  5. package/dist/hooks/_lib/halt-check.js +106 -0
  6. package/dist/hooks/_lib/payload.d.ts +124 -0
  7. package/dist/hooks/_lib/payload.js +245 -0
  8. package/dist/hooks/_lib/segments.d.ts +125 -0
  9. package/dist/hooks/_lib/segments.js +766 -0
  10. package/dist/hooks/architecture-review-gate/index.d.ts +58 -0
  11. package/dist/hooks/architecture-review-gate/index.js +250 -0
  12. package/dist/hooks/attribution-advisory/index.d.ts +72 -0
  13. package/dist/hooks/attribution-advisory/index.js +233 -0
  14. package/dist/hooks/bash-scanner/protected-scan.js +14 -2
  15. package/dist/hooks/changeset-security-gate/index.d.ts +71 -0
  16. package/dist/hooks/changeset-security-gate/index.js +330 -0
  17. package/dist/hooks/dependency-audit-gate/index.d.ts +91 -0
  18. package/dist/hooks/dependency-audit-gate/index.js +294 -0
  19. package/dist/hooks/env-file-protection/index.d.ts +55 -0
  20. package/dist/hooks/env-file-protection/index.js +159 -0
  21. package/dist/hooks/pr-issue-link-gate/index.d.ts +91 -0
  22. package/dist/hooks/pr-issue-link-gate/index.js +127 -0
  23. package/dist/hooks/security-disclosure-gate/index.d.ts +91 -0
  24. package/dist/hooks/security-disclosure-gate/index.js +502 -0
  25. package/hooks/_lib/protected-paths.sh +10 -3
  26. package/hooks/architecture-review-gate.sh +92 -77
  27. package/hooks/attribution-advisory.sh +139 -131
  28. package/hooks/changeset-security-gate.sh +114 -149
  29. package/hooks/dependency-audit-gate.sh +115 -156
  30. package/hooks/env-file-protection.sh +130 -97
  31. package/hooks/pr-issue-link-gate.sh +114 -45
  32. package/hooks/security-disclosure-gate.sh +148 -316
  33. package/hooks/settings-protection.sh +13 -9
  34. package/package.json +1 -1
  35. package/templates/architecture-review-gate.dogfood-staged.sh +116 -0
  36. package/templates/attribution-advisory.dogfood-staged.sh +170 -0
  37. package/templates/changeset-security-gate.dogfood-staged.sh +137 -0
  38. package/templates/dependency-audit-gate.dogfood-staged.sh +138 -0
  39. package/templates/env-file-protection.dogfood-staged.sh +157 -0
  40. package/templates/pr-issue-link-gate.dogfood-staged.sh +134 -0
  41. package/templates/prepare-commit-msg.husky.sh +80 -6
  42. package/templates/security-disclosure-gate.dogfood-staged.sh +171 -0
  43. package/templates/settings-protection.dogfood.patch +58 -0
@@ -1,172 +1,137 @@
1
- #!/usr/bin/env bash
2
- # changeset-security-gate.sh — PreToolUse: Write|Edit
1
+ #!/bin/bash
2
+ # PreToolUse hook: changeset-security-gate.sh
3
+ # 0.33.0+ — Node-binary shim for `rea hook changeset-security-gate`.
3
4
  #
4
- # Guards .changeset/*.md files against two failure modes:
5
+ # Pre-0.33.0 the gate's full body lived here as bash (172 LOC, frontmatter
6
+ # validation + GHSA/CVE scan + MultiEdit-aware tool handling). The
7
+ # migration to the parser-backed Node binary moves all of that into
8
+ # `src/hooks/changeset-security-gate/index.ts`.
5
9
  #
6
- # 1. SECURITY DISCLOSURE LEAK GHSA IDs or CVE numbers written to a changeset
7
- # file before the advisory is published. Changeset files are committed to git
8
- # and appear verbatim in CHANGELOG.md referencing a GHSA ID pre-publish
9
- # creates public pre-disclosure in git history.
10
+ # Behavioral contract is preserved byte-for-byte: exit 0 on
11
+ # pass-through / non-changeset / valid frontmatter, exit 2 on HALT /
12
+ # disclosure leak / malformed frontmatter / malformed payload.
10
13
  #
11
- # 2. MISSING OR MALFORMED FRONTMATTER — changeset files without proper frontmatter
12
- # are silently ignored by the changesets tool, wasting the release entry.
14
+ # # CLI-resolution trust boundary
13
15
  #
14
- # Triggered by: PreToolUse Write and Edit tools
15
-
16
- set -euo pipefail
16
+ # Realpath sandbox check + version probe. Same shape as the 0.32.0
17
+ # pilots.
18
+ #
19
+ # # Fail-closed posture
20
+ #
21
+ # changeset-security-gate is BLOCKING-tier — the pre-0.33.0 bash body
22
+ # refused on GHSA/CVE patterns and on malformed frontmatter. Early-exit
23
+ # branches fail closed AFTER the relevance pre-gate passes.
17
24
 
18
- # shellcheck source=_lib/common.sh
19
- source "$(dirname "$0")/_lib/common.sh"
25
+ set -uo pipefail
20
26
 
27
+ # 1. HALT check.
28
+ # shellcheck source=_lib/halt-check.sh
29
+ source "$(dirname "$0")/_lib/halt-check.sh"
21
30
  check_halt
31
+ REA_ROOT=$(rea_root)
22
32
 
23
- INPUT="$(cat)"
24
- TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // ""')
25
-
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
33
- exit 0
34
- fi
35
-
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"
33
+ proj="${CLAUDE_PROJECT_DIR:-$REA_ROOT}"
43
34
 
44
- FILE_PATH=$(extract_file_path "$INPUT")
45
-
46
- # Only care about .changeset/*.md files — exclude README.md (changeset tool metadata)
47
- if ! echo "$FILE_PATH" | grep -qE '\.changeset/[^/]+\.md$' || echo "$FILE_PATH" | grep -qE '\.changeset/README\.md$'; then
48
- exit 0
49
- fi
50
-
51
- CONTENT=$(extract_write_content "$INPUT")
52
-
53
- # ─── 1. SECURITY DISCLOSURE CHECK ───────────────────────────────────────────
35
+ # 2. Relevance pre-gate. This is a PreToolUse Write/Edit/MultiEdit/
36
+ # NotebookEdit matcher, so the payload always has a `tool_input.
37
+ # file_path` (or `notebook_path`).
54
38
  #
55
- # These patterns in a changeset mean security details are about to be committed
56
- # to git history BEFORE the advisory is published — creating pre-disclosure.
57
- # GHSA IDs and CVE numbers must NEVER appear in changeset files.
58
-
59
- DISCLOSURE_PATTERNS=(
60
- 'GHSA-[0-9A-Za-z]{4}-[0-9A-Za-z]{4}-[0-9A-Za-z]{4}'
61
- 'CVE-[0-9]{4}-[0-9]+'
62
- )
63
-
64
- MATCHED_PATTERN=""
65
- for PATTERN in "${DISCLOSURE_PATTERNS[@]}"; do
66
- if echo "$CONTENT" | grep -qE "$PATTERN"; then
67
- MATCHED_PATTERN="$PATTERN"
68
- break
39
+ # 2026-05-15 codex round-2 P2 fix: scan `tool_input.file_path` /
40
+ # `tool_input.notebook_path` ONLY, NOT the raw JSON payload. Pre-fix
41
+ # a Write to `README.md` whose body merely mentions `.changeset/`
42
+ # (e.g. "See .changeset/example.md") tripped the fail-closed branch
43
+ # when the CLI was unbuilt — the substring lived in the
44
+ # tool_input.content blob, not in the target path. The Node body
45
+ # correctly filters by file_path; the shim's pre-gate must match
46
+ # that posture.
47
+ INPUT=$(cat)
48
+ RELEVANT=0
49
+ PROBE=""
50
+ if command -v jq >/dev/null 2>&1; then
51
+ PROBE=$(printf '%s' "$INPUT" | jq -r '(.tool_input.file_path // .tool_input.notebook_path // "")' 2>/dev/null || true)
52
+ if printf '%s' "$PROBE" | grep -qE '\.changeset/'; then
53
+ RELEVANT=1
54
+ fi
55
+ else
56
+ if printf '%s' "$INPUT" | grep -qE '\.changeset/'; then
57
+ RELEVANT=1
69
58
  fi
70
- done
71
-
72
- if [[ -n "$MATCHED_PATTERN" ]]; then
73
- json_output "block" \
74
- "CHANGESET SECURITY GATE: This changeset contains a security advisory identifier (matched: '${MATCHED_PATTERN}').
75
-
76
- Do NOT reference GHSA IDs or CVE numbers in changeset files before the advisory is published.
77
- Changeset files are committed to git — this creates pre-disclosure in public history and CHANGELOG.
78
-
79
- CORRECT approach for security fix changesets:
80
- Use vague language only — no identifiers, no vulnerability details.
81
-
82
- WRONG: 'fix(hooks): patch GHSA-3w3m-7gg4-f82g — symlink-guard now covers Edit tool'
83
- RIGHT: 'security: extend symlink protection to cover all write-capable tools'
84
-
85
- WRONG: 'security: fix CVE-2026-1234 prompt injection via tool descriptions'
86
- RIGHT: 'security: harden middleware chain against indirect instruction attacks'
87
-
88
- After the release ships:
89
- 1. Publish the GitHub Security Advisory (Security tab → Advisories → Publish)
90
- 2. The GHSA becomes the detailed public disclosure document
91
- 3. Optionally update CHANGELOG.md post-publish to add the GHSA reference"
92
59
  fi
93
-
94
- # ─── 2. FRONTMATTER VALIDATION ───────────────────────────────────────────────
95
- #
96
- # A changeset without valid frontmatter is silently ignored by the changesets
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
60
+ if [ "$RELEVANT" -eq 0 ]; then
109
61
  exit 0
110
62
  fi
111
63
 
112
- # Must start with ---
113
- if ! echo "$CONTENT" | head -1 | grep -qE '^---'; then
114
- json_output "block" \
115
- "CHANGESET FORMAT GATE: Missing frontmatter block.
116
-
117
- Every changeset must start with a frontmatter block specifying which package to bump:
118
-
119
- ---
120
- '@bookedsolid/rea': patch
121
- ---
122
-
123
- Brief description of what changed and why (close #N if applicable).
124
-
125
- Bump types: patch (bug fix/security), minor (new feature), major (breaking change)"
64
+ # 3. Resolve the rea CLI.
65
+ REA_ARGV=()
66
+ RESOLVED_CLI_PATH=""
67
+ if [ -f "$proj/node_modules/@bookedsolid/rea/dist/cli/index.js" ]; then
68
+ REA_ARGV=(node "$proj/node_modules/@bookedsolid/rea/dist/cli/index.js")
69
+ RESOLVED_CLI_PATH="$proj/node_modules/@bookedsolid/rea/dist/cli/index.js"
70
+ elif [ -f "$proj/dist/cli/index.js" ]; then
71
+ REA_ARGV=(node "$proj/dist/cli/index.js")
72
+ RESOLVED_CLI_PATH="$proj/dist/cli/index.js"
126
73
  fi
127
74
 
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.
140
- FRONTMATTER=$(echo "$CONTENT" | awk '/^---/{count++; if(count==2){exit} next} count==1{print}')
141
- if ! echo "$FRONTMATTER" | grep -qE "^(\"[^\"]+\"|'[^']+'|[^\"'[:space:]]+): (patch|minor|major)"; then
142
- json_output "block" \
143
- "CHANGESET FORMAT GATE: Frontmatter does not contain a valid package bump entry.
144
-
145
- The frontmatter must include at least one package/bump pair:
146
-
147
- ---
148
- '@bookedsolid/rea': patch
149
- ---
150
-
151
- Valid bump types: patch | minor | major"
75
+ if [ "${#REA_ARGV[@]}" -eq 0 ]; then
76
+ printf 'rea: changeset-security-gate cannot run — the rea CLI is not built.\n' >&2
77
+ printf 'Run `pnpm install && pnpm build` (or `npm install` for a consumer install) to restore protection.\n' >&2
78
+ exit 2
152
79
  fi
153
80
 
154
- # Must have a non-empty description after the closing ---
155
- DESCRIPTION=$(echo "$CONTENT" | awk 'BEGIN{count=0} /^---/{count++; next} count>=2{print}' | grep -v '^[[:space:]]*$' | head -1 || true)
156
- if [[ -z "$DESCRIPTION" ]]; then
157
- json_output "block" \
158
- "CHANGESET FORMAT GATE: Missing description after frontmatter.
159
-
160
- Add a meaningful description explaining what changed and why:
161
-
162
- ---
163
- '@bookedsolid/rea': patch
164
- ---
81
+ # 4. Realpath sandbox check.
82
+ if ! command -v node >/dev/null 2>&1; then
83
+ printf 'rea: changeset-security-gate cannot run — `node` is not on PATH.\n' >&2
84
+ exit 2
85
+ fi
165
86
 
166
- fix(gateway): policy-loader now uses async I/O with 500ms TTL cache
87
+ sandbox_check=$(node -e '
88
+ const fs = require("fs");
89
+ const path = require("path");
90
+ const cli = process.argv[1];
91
+ const projDir = process.argv[2];
92
+ let real, realProj;
93
+ try { real = fs.realpathSync(cli); } catch (e) {
94
+ process.stdout.write("bad:realpath"); process.exit(1);
95
+ }
96
+ try { realProj = fs.realpathSync(projDir); } catch (e) {
97
+ process.stdout.write("bad:realpath-proj"); process.exit(1);
98
+ }
99
+ const sep = path.sep;
100
+ const projWithSep = realProj.endsWith(sep) ? realProj : realProj + sep;
101
+ if (!(real === realProj || real.startsWith(projWithSep))) {
102
+ process.stdout.write("bad:cli-escapes-project"); process.exit(1);
103
+ }
104
+ let cur = path.dirname(path.dirname(path.dirname(real)));
105
+ let found = false;
106
+ for (let i = 0; i < 20 && cur && cur !== path.dirname(cur); i += 1) {
107
+ const pj = path.join(cur, "package.json");
108
+ if (fs.existsSync(pj)) {
109
+ try {
110
+ const data = JSON.parse(fs.readFileSync(pj, "utf8"));
111
+ if (data && data.name === "@bookedsolid/rea") { found = true; break; }
112
+ } catch (e) { /* keep walking */ }
113
+ }
114
+ cur = path.dirname(cur);
115
+ }
116
+ if (!found) { process.stdout.write("bad:no-rea-pkg-json"); process.exit(1); }
117
+ process.stdout.write("ok");
118
+ ' -- "$RESOLVED_CLI_PATH" "$proj" 2>/dev/null)
119
+
120
+ if [ "$sandbox_check" != "ok" ]; then
121
+ printf 'rea: changeset-security-gate FAILED sandbox check (%s) — refusing.\n' "$sandbox_check" >&2
122
+ exit 2
123
+ fi
167
124
 
168
- Previously, loadPolicy used fs.readFileSync on every tool invocation, blocking
169
- the event loop under concurrency. Closes #34."
125
+ # 5. Version-probe.
126
+ probe_out=$("${REA_ARGV[@]}" hook changeset-security-gate --help 2>&1)
127
+ probe_status=$?
128
+ if [ "$probe_status" -ne 0 ] || ! printf '%s' "$probe_out" | grep -q -e 'changeset-security-gate'; then
129
+ printf 'rea: this shim requires the `rea hook changeset-security-gate` subcommand (introduced in 0.33.0).\n' >&2
130
+ printf 'The resolved CLI at %s does not implement it.\n' "$RESOLVED_CLI_PATH" >&2
131
+ printf 'Run `pnpm install` (or `npm install`) to sync the CLI; refusing in the meantime to preserve enforcement.\n' >&2
132
+ exit 2
170
133
  fi
171
134
 
172
- exit 0
135
+ # 6. Forward stdin.
136
+ printf '%s' "$INPUT" | "${REA_ARGV[@]}" hook changeset-security-gate
137
+ exit $?
@@ -1,179 +1,138 @@
1
1
  #!/bin/bash
2
2
  # PreToolUse hook: dependency-audit-gate.sh
3
- # Fires BEFORE every Bash tool call.
4
- # Detects package install commands (npm install, pnpm add, yarn add) and
5
- # verifies the package exists on the registry before allowing the install.
3
+ # 0.33.0+ Node-binary shim for `rea hook dependency-audit-gate`.
6
4
  #
7
- # Exit codes:
8
- # 0 = allow (not an install command, or package verified)
9
- # 2 = block (package not found on registry)
5
+ # Pre-0.33.0 the gate's full body lived here as bash (179 LOC, the
6
+ # segment splitter + install-pattern detection + per-package
7
+ # `npm view` probe). The migration to the parser-backed Node binary
8
+ # moves all of that into `src/hooks/dependency-audit-gate/index.ts`.
9
+ #
10
+ # Behavioral contract is preserved byte-for-byte: exit 0 on
11
+ # pass-through / all-packages-verified, exit 2 on HALT / any package
12
+ # missing / malformed payload.
13
+ #
14
+ # # CLI-resolution trust boundary
15
+ #
16
+ # Realpath sandbox check + version probe. Same shape as the 0.32.0
17
+ # pilots and the env-file-protection shim above.
18
+ #
19
+ # # Fail-closed posture
20
+ #
21
+ # dependency-audit-gate is BLOCKING-tier — the pre-0.33.0 bash body
22
+ # refused on missing packages. Early-exit branches (CLI missing,
23
+ # node missing, sandbox failed, version skew) fail closed AFTER the
24
+ # relevance pre-gate passes.
10
25
 
11
26
  set -uo pipefail
12
27
 
13
- # ── 1. Read ALL stdin immediately ─────────────────────────────────────────────
14
- INPUT=$(cat)
15
-
16
- # ── 2. Dependency check ──────────────────────────────────────────────────────
17
- if ! command -v jq >/dev/null 2>&1; then
18
- printf 'REA ERROR: jq is required but not installed.\n' >&2
19
- printf 'Install: brew install jq OR apt-get install -y jq\n' >&2
20
- exit 2
21
- fi
22
-
23
- # ── 3. HALT check ────────────────────────────────────────────────────────────
24
- # 0.16.0: HALT check sourced from shared _lib/halt-check.sh.
28
+ # 1. HALT check.
25
29
  # shellcheck source=_lib/halt-check.sh
26
30
  source "$(dirname "$0")/_lib/halt-check.sh"
27
31
  check_halt
28
32
  REA_ROOT=$(rea_root)
29
33
 
30
- # ── 4. Parse command ──────────────────────────────────────────────────────────
31
- CMD=$(printf '%s' "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null)
32
-
33
- if [[ -z "$CMD" ]]; then
34
- exit 0
35
- fi
36
-
37
- # ── 5. Detect package install commands ────────────────────────────────────────
38
- # Match: npm install <pkg>, npm i <pkg>, pnpm add <pkg>, yarn add <pkg>
39
- # Skip: npm install (no args), npm ci, npm install --save-dev (without new pkg)
34
+ proj="${CLAUDE_PROJECT_DIR:-$REA_ROOT}"
40
35
 
41
- extract_packages() {
42
- local cmd="$1"
43
-
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
- # 0.17.0 helix-017 #3: unwrap nested-shell wrappers (`bash -c 'PAYLOAD'`,
62
- # `sh -lc "PAYLOAD"`, etc.) before splitting so the inner install
63
- # command becomes a segment that anchors against the install-pattern
64
- # check below. Pre-fix `bash -lc 'npm install pkg'` produced a single
65
- # segment whose first token was `bash` — install-detection skipped.
66
- # 0.17.0 helix-019 #3: delegate splitting to the shared
67
- # `_rea_split_segments` so this gate inherits the full separator set
68
- # (including bare `&` background-process operator added in 0.16.1)
69
- # and the quote-mask that prevents over-fire from in-quote separators.
70
- # Pre-fix the local segmenter splat on `|||&&|;|` only, missing bare
71
- # `&` — `echo warmup & pnpm add lodash` stayed merged into one segment
72
- # and the install-pattern leading-token check skipped it entirely.
73
- local segments
74
- if [ -f "$(dirname "$0")/_lib/cmd-segments.sh" ]; then
75
- # shellcheck source=_lib/cmd-segments.sh
76
- source "$(dirname "$0")/_lib/cmd-segments.sh"
77
- segments=$(_rea_split_segments "$cmd")
78
- else
79
- # Fallback (lib unavailable): legacy local splitter preserved.
80
- segments=$(printf '%s\n' "$cmd" | sed -E 's/(\|\||\&\&|;|\||\&)/\n/g')
36
+ # 2. Relevance pre-gate. Look for any install-pattern keyword.
37
+ #
38
+ # 2026-05-15 codex round-2 P2 fix: scan `tool_input.command` ONLY,
39
+ # not the raw JSON payload. Pre-fix `git commit -m "docs: run pnpm
40
+ # install foo before start"` triggered the fail-closed branch on a
41
+ # fresh checkout (the install-pattern regex hit the substring
42
+ # inside the commit-message ARG of the git command, not a real
43
+ # install invocation). The Node body's segment-anchored matcher
44
+ # correctly distinguishes between the two the shim's pre-gate
45
+ # must match that posture.
46
+ #
47
+ # `jq`-less fallback preserves the pre-0.33.0 over-trigger shape.
48
+ INPUT=$(cat)
49
+ RELEVANT=0
50
+ PROBE=""
51
+ if command -v jq >/dev/null 2>&1; then
52
+ PROBE=$(printf '%s' "$INPUT" | jq -r '.tool_input.command // ""' 2>/dev/null || true)
53
+ if printf '%s' "$PROBE" | grep -qE '(npm[[:space:]]+(install|i|add)|pnpm[[:space:]]+(add|install|i)|yarn[[:space:]]+add)[[:space:]]'; then
54
+ RELEVANT=1
81
55
  fi
82
-
83
- while IFS= read -r segment; do
84
- # Trim leading whitespace.
85
- segment="${segment#"${segment%%[![:space:]]*}"}"
86
- # Anchor to start: only match when the install command is the FIRST
87
- # thing on the segment, optionally preceded by `sudo` / `exec` /
88
- # `time` / etc.
89
- #
90
- # 0.16.1 helix-016 P2 fix: also strip leading KEY=VALUE env-var
91
- # assignments. Pre-fix the prefix allow-list only permitted
92
- # sudo/exec/time, so `CI=1 pnpm add foo` and
93
- # `NODE_ENV=development npm install bar` bypassed the audit
94
- # entirely. POSIX shell allows any number of leading KEY=VALUE
95
- # assignments before the command word; we strip them the same
96
- # way the shell does.
97
- local stripped_segment
98
- stripped_segment=$(printf '%s' "$segment" | sed -E 's/^([[:space:]]*[A-Za-z_][A-Za-z0-9_]*=[^[:space:]]+[[:space:]]+)+//')
99
-
100
- if printf '%s' "$stripped_segment" | grep -qiE '^(sudo[[:space:]]+|exec[[:space:]]+|time[[:space:]]+)*(npm[[:space:]]+(install|i|add)|pnpm[[:space:]]+(add|install|i)|yarn[[:space:]]+add)[[:space:]]+'; then
101
- # Strip the leading prefix wrappers + install command, leaving args.
102
- local after_cmd
103
- after_cmd=$(printf '%s' "$stripped_segment" | sed -E 's/^(sudo[[:space:]]+|exec[[:space:]]+|time[[:space:]]+)*(npm[[:space:]]+(install|i|add)|pnpm[[:space:]]+(add|install|i)|yarn[[:space:]]+add)[[:space:]]+//')
104
-
105
- for token in $after_cmd; do
106
- if [[ "$token" == -* ]]; then continue; fi
107
- if [[ "$token" == ./* || "$token" == /* || "$token" == ../* ]]; then continue; fi
108
- if [[ -z "$token" ]]; then continue; fi
109
- # 0.16.1: tighten token classification (helix-016 sibling concern).
110
- # A "package name" is something that doesn't contain shell
111
- # metacharacters — `2>&1`, `$VAR`, etc. are never valid npm
112
- # package names. Skip any token containing `=`, `>`, `<`, `&`,
113
- # `|`, `;`, `$`, backtick, or quotes.
114
- if [[ "$token" == *=* || "$token" == *">"* || "$token" == *"<"* ||
115
- "$token" == *"&"* || "$token" == *"|"* || "$token" == *";"* ||
116
- "$token" == *'$'* || "$token" == *'`'* ||
117
- "$token" == *'"'* || "$token" == *"'"* ]]; then continue; fi
118
- # `npm view` can't validate `@workspace:*` / `link:` / `file:`
119
- # prefixes (workspace protocols). Skip them — they're never npm
120
- # registry packages.
121
- if [[ "$token" == workspace:* || "$token" == link:* || "$token" == file:* || "$token" == git+* ]]; then continue; fi
122
- local pkg_name
123
- pkg_name=$(printf '%s' "$token" | sed -E 's/@[^@/]+$//')
124
- if [[ -z "$pkg_name" ]]; then
125
- pkg_name="$token"
126
- fi
127
- printf '%s\n' "$pkg_name"
128
- done
129
- fi
130
- done <<< "$segments"
131
- }
132
-
133
- PACKAGES=$(extract_packages "$CMD")
134
-
135
- if [[ -z "$PACKAGES" ]]; then
56
+ else
57
+ if printf '%s' "$INPUT" | grep -qE '(npm[[:space:]]+(install|i|add)|pnpm[[:space:]]+(add|install|i)|yarn[[:space:]]+add)[[:space:]]'; then
58
+ RELEVANT=1
59
+ fi
60
+ fi
61
+ if [ "$RELEVANT" -eq 0 ]; then
136
62
  exit 0
137
63
  fi
138
64
 
139
- # ── 6. Verify packages exist on registry ──────────────────────────────────────
140
- FAILED=""
141
- CHECKED=0
65
+ # 3. Resolve the rea CLI.
66
+ REA_ARGV=()
67
+ RESOLVED_CLI_PATH=""
68
+ if [ -f "$proj/node_modules/@bookedsolid/rea/dist/cli/index.js" ]; then
69
+ REA_ARGV=(node "$proj/node_modules/@bookedsolid/rea/dist/cli/index.js")
70
+ RESOLVED_CLI_PATH="$proj/node_modules/@bookedsolid/rea/dist/cli/index.js"
71
+ elif [ -f "$proj/dist/cli/index.js" ]; then
72
+ REA_ARGV=(node "$proj/dist/cli/index.js")
73
+ RESOLVED_CLI_PATH="$proj/dist/cli/index.js"
74
+ fi
142
75
 
143
- while IFS= read -r pkg; do
144
- [[ -z "$pkg" ]] && continue
145
- CHECKED=$((CHECKED + 1))
76
+ if [ "${#REA_ARGV[@]}" -eq 0 ]; then
77
+ printf 'rea: dependency-audit-gate cannot run the rea CLI is not built.\n' >&2
78
+ printf 'Run `pnpm install && pnpm build` (or `npm install` for a consumer install) to restore protection.\n' >&2
79
+ exit 2
80
+ fi
146
81
 
147
- # Cap at 5 packages per command to avoid slow hook
148
- if [[ $CHECKED -gt 5 ]]; then
149
- break
150
- fi
82
+ # 4. Realpath sandbox check.
83
+ if ! command -v node >/dev/null 2>&1; then
84
+ printf 'rea: dependency-audit-gate cannot run — `node` is not on PATH.\n' >&2
85
+ exit 2
86
+ fi
151
87
 
152
- # Use npm view to check if package exists
153
- # macOS doesn't have `timeout` by default, use a background process with kill
154
- if command -v timeout >/dev/null 2>&1; then
155
- if ! timeout 5 npm view "$pkg" name >/dev/null 2>&1; then
156
- FAILED="${FAILED} - ${pkg}\n"
157
- fi
158
- else
159
- # Fallback: run npm view without timeout (still fast for simple checks)
160
- if ! npm view "$pkg" name >/dev/null 2>&1; then
161
- FAILED="${FAILED} - ${pkg}\n"
162
- fi
163
- fi
164
- done <<< "$PACKAGES"
88
+ sandbox_check=$(node -e '
89
+ const fs = require("fs");
90
+ const path = require("path");
91
+ const cli = process.argv[1];
92
+ const projDir = process.argv[2];
93
+ let real, realProj;
94
+ try { real = fs.realpathSync(cli); } catch (e) {
95
+ process.stdout.write("bad:realpath"); process.exit(1);
96
+ }
97
+ try { realProj = fs.realpathSync(projDir); } catch (e) {
98
+ process.stdout.write("bad:realpath-proj"); process.exit(1);
99
+ }
100
+ const sep = path.sep;
101
+ const projWithSep = realProj.endsWith(sep) ? realProj : realProj + sep;
102
+ if (!(real === realProj || real.startsWith(projWithSep))) {
103
+ process.stdout.write("bad:cli-escapes-project"); process.exit(1);
104
+ }
105
+ let cur = path.dirname(path.dirname(path.dirname(real)));
106
+ let found = false;
107
+ for (let i = 0; i < 20 && cur && cur !== path.dirname(cur); i += 1) {
108
+ const pj = path.join(cur, "package.json");
109
+ if (fs.existsSync(pj)) {
110
+ try {
111
+ const data = JSON.parse(fs.readFileSync(pj, "utf8"));
112
+ if (data && data.name === "@bookedsolid/rea") { found = true; break; }
113
+ } catch (e) { /* keep walking */ }
114
+ }
115
+ cur = path.dirname(cur);
116
+ }
117
+ if (!found) { process.stdout.write("bad:no-rea-pkg-json"); process.exit(1); }
118
+ process.stdout.write("ok");
119
+ ' -- "$RESOLVED_CLI_PATH" "$proj" 2>/dev/null)
120
+
121
+ if [ "$sandbox_check" != "ok" ]; then
122
+ printf 'rea: dependency-audit-gate FAILED sandbox check (%s) — refusing.\n' "$sandbox_check" >&2
123
+ exit 2
124
+ fi
165
125
 
166
- if [[ -n "$FAILED" ]]; then
167
- {
168
- printf 'DEPENDENCY AUDIT: Package not found on npm registry\n'
169
- printf '\n'
170
- printf ' The following packages could not be verified:\n'
171
- printf '%b' "$FAILED"
172
- printf '\n'
173
- printf ' Rule: All packages must exist on the npm registry before installation.\n'
174
- printf ' Check: Is the package name spelled correctly? Does it exist on npmjs.com?\n'
175
- } >&2
126
+ # 5. Version-probe.
127
+ probe_out=$("${REA_ARGV[@]}" hook dependency-audit-gate --help 2>&1)
128
+ probe_status=$?
129
+ if [ "$probe_status" -ne 0 ] || ! printf '%s' "$probe_out" | grep -q -e 'dependency-audit-gate'; then
130
+ printf 'rea: this shim requires the `rea hook dependency-audit-gate` subcommand (introduced in 0.33.0).\n' >&2
131
+ printf 'The resolved CLI at %s does not implement it.\n' "$RESOLVED_CLI_PATH" >&2
132
+ printf 'Run `pnpm install` (or `npm install`) to sync the CLI; refusing in the meantime to preserve enforcement.\n' >&2
176
133
  exit 2
177
134
  fi
178
135
 
179
- exit 0
136
+ # 6. Forward stdin.
137
+ printf '%s' "$INPUT" | "${REA_ARGV[@]}" hook dependency-audit-gate
138
+ exit $?