@bookedsolid/rea 0.48.1 → 0.49.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/THREAT_MODEL.md +70 -0
- package/dist/cli/doctor.d.ts +1 -0
- package/dist/cli/doctor.js +241 -0
- package/dist/cli/init.d.ts +12 -0
- package/dist/cli/init.js +161 -0
- package/dist/cli/install/self-pin.d.ts +440 -0
- package/dist/cli/install/self-pin.js +853 -0
- package/dist/cli/upgrade.js +134 -0
- package/dist/policy/loader.d.ts +13 -0
- package/dist/policy/loader.js +36 -0
- package/dist/policy/profiles.d.ts +13 -0
- package/dist/policy/profiles.js +12 -0
- package/dist/policy/types.d.ts +38 -0
- package/hooks/_lib/bootstrap-allowlist.sh +1075 -0
- package/hooks/blocked-paths-bash-gate.sh +35 -12
- package/hooks/protected-paths-bash-gate.sh +30 -12
- package/package.json +3 -1
- package/profiles/bst-internal-no-codex.yaml +4 -0
- package/profiles/bst-internal.yaml +28 -0
- package/profiles/client-engagement.yaml +9 -0
- package/profiles/lit-wc.yaml +6 -0
- package/profiles/minimal.yaml +11 -0
- package/profiles/open-source-no-codex.yaml +4 -0
- package/profiles/open-source.yaml +11 -0
|
@@ -0,0 +1,1075 @@
|
|
|
1
|
+
# shellcheck shell=bash
|
|
2
|
+
# hooks/_lib/bootstrap-allowlist.sh — bootstrap allowlist for the
|
|
3
|
+
# Bash-tier protected-paths / blocked-paths gate shims.
|
|
4
|
+
# Introduced 0.49.0.
|
|
5
|
+
#
|
|
6
|
+
# # Problem this solves
|
|
7
|
+
#
|
|
8
|
+
# `rea init` writes `.claude/hooks/blocked-paths-bash-gate.sh` and
|
|
9
|
+
# `.claude/hooks/protected-paths-bash-gate.sh` that depend on the
|
|
10
|
+
# `@bookedsolid/rea` CLI being resolvable from `node_modules/`. Until
|
|
11
|
+
# 0.49.0, `rea init` did NOT add the dep to the consumer's
|
|
12
|
+
# `package.json` — so any fresh clone + `pnpm install` produced a
|
|
13
|
+
# brick state where the shims refused 100% of Bash calls (including
|
|
14
|
+
# the `pnpm add -D @bookedsolid/rea` that would recover).
|
|
15
|
+
#
|
|
16
|
+
# This helper provides a NARROW allowlist: when the CLI is missing,
|
|
17
|
+
# AND `package.json` declares `@bookedsolid/rea` (dependencies or
|
|
18
|
+
# devDependencies), AND the Bash payload is a single recognised PM
|
|
19
|
+
# install / add invocation, pass through. Everything else still
|
|
20
|
+
# refuses.
|
|
21
|
+
#
|
|
22
|
+
# # Security stance
|
|
23
|
+
#
|
|
24
|
+
# - Allowlist runs ONLY when the CLI is unreachable (CLI-missing
|
|
25
|
+
# branch). The CLI-present path is unaffected.
|
|
26
|
+
# - Allowlist is ALWAYS-ON by default (no env-var toggle ever
|
|
27
|
+
# participates in the decision). Operators can disable via policy
|
|
28
|
+
# (`policy.bootstrap_allowlist.enabled: false`).
|
|
29
|
+
# - Precondition: `<project>/package.json` parseable as JSON object and
|
|
30
|
+
# declares `@bookedsolid/rea` under `dependencies` OR
|
|
31
|
+
# `devDependencies` (exact-key match, string value). NOT
|
|
32
|
+
# `optionalDependencies`, NOT `peerDependencies`, NOT
|
|
33
|
+
# `pnpm.overrides`.
|
|
34
|
+
# - Multi-segment payloads refuse INSIDE this helper. The caller
|
|
35
|
+
# passes the RAW extracted Bash command; the helper sources
|
|
36
|
+
# `cmd-segments.sh`, runs `_rea_split_segments` on the trimmed
|
|
37
|
+
# payload, and short-circuits to `refuse` when the result has more
|
|
38
|
+
# than one segment. This keeps the segmentation contract centralised
|
|
39
|
+
# in the allowlist (the gate shims do not need to re-split, and any
|
|
40
|
+
# future caller that forgets to pre-split is still safe).
|
|
41
|
+
# - Quoted argv forms (`pnpm "install"`, `'pnpm' install`) refuse-
|
|
42
|
+
# fallthrough — quoted tokens are a defense feature, not a bug.
|
|
43
|
+
# - argv[0] basename match is exact-string, no slashes. Path-form
|
|
44
|
+
# commands (`./pnpm install`, `/usr/local/bin/pnpm install`) refuse.
|
|
45
|
+
# - Audit event `rea.bash.bootstrap_allow` is emitted on every match
|
|
46
|
+
# so operators can post-hoc verify what the allowlist let through.
|
|
47
|
+
#
|
|
48
|
+
# # Return convention
|
|
49
|
+
#
|
|
50
|
+
# `bootstrap_allowlist_check <command>`:
|
|
51
|
+
# - exits 0 on stdout `"allow"` — gate should pass the payload
|
|
52
|
+
# - exits 0 on stdout `"refuse"` — gate should follow its existing
|
|
53
|
+
# CLI-missing refusal path (banner +
|
|
54
|
+
# exit 2 for blocking, exit 0 for
|
|
55
|
+
# advisory)
|
|
56
|
+
#
|
|
57
|
+
# We deliberately use stdout-with-uniform-exit-0 rather than exit
|
|
58
|
+
# codes 0/1/2 so caller code can distinguish allowlist outcomes from
|
|
59
|
+
# any subshell process-control errors that bash would also surface as
|
|
60
|
+
# non-zero exits.
|
|
61
|
+
#
|
|
62
|
+
# # Bash 3.2 compatibility
|
|
63
|
+
#
|
|
64
|
+
# This helper targets macOS bash 3.2. Avoid: `mapfile`, `${var,,}`,
|
|
65
|
+
# `[[ =~ ]]`. OK: `case`, `read -ra`, `[[ ]]` for string compare
|
|
66
|
+
# without regex.
|
|
67
|
+
|
|
68
|
+
set -uo pipefail
|
|
69
|
+
|
|
70
|
+
# Source the segment splitter so the caller can reuse it on the same
|
|
71
|
+
# input. The caller MUST have already verified single-segment shape
|
|
72
|
+
# before calling us; we don't re-split here, but we DO defend against
|
|
73
|
+
# accidental misuse below.
|
|
74
|
+
_BOOTSTRAP_ALLOWLIST_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
|
|
75
|
+
# shellcheck source=cmd-segments.sh
|
|
76
|
+
. "$_BOOTSTRAP_ALLOWLIST_DIR/cmd-segments.sh"
|
|
77
|
+
|
|
78
|
+
# Hash a string with whichever SHA-256 tool exists. Echoes 64 hex
|
|
79
|
+
# chars to stdout on success, empty on failure.
|
|
80
|
+
_bootstrap_sha256() {
|
|
81
|
+
local input="$1"
|
|
82
|
+
local hash=""
|
|
83
|
+
if command -v shasum >/dev/null 2>&1; then
|
|
84
|
+
hash=$(printf '%s' "$input" | shasum -a 256 2>/dev/null | awk '{print $1}')
|
|
85
|
+
elif command -v sha256sum >/dev/null 2>&1; then
|
|
86
|
+
hash=$(printf '%s' "$input" | sha256sum 2>/dev/null | awk '{print $1}')
|
|
87
|
+
fi
|
|
88
|
+
printf '%s' "$hash"
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
# Hash a FILE with whichever SHA-256 tool exists. Echoes 64 hex chars.
|
|
92
|
+
_bootstrap_sha256_file() {
|
|
93
|
+
local file="$1"
|
|
94
|
+
local hash=""
|
|
95
|
+
if [ ! -f "$file" ]; then
|
|
96
|
+
printf ''
|
|
97
|
+
return 0
|
|
98
|
+
fi
|
|
99
|
+
if command -v shasum >/dev/null 2>&1; then
|
|
100
|
+
hash=$(shasum -a 256 "$file" 2>/dev/null | awk '{print $1}')
|
|
101
|
+
elif command -v sha256sum >/dev/null 2>&1; then
|
|
102
|
+
hash=$(sha256sum "$file" 2>/dev/null | awk '{print $1}')
|
|
103
|
+
fi
|
|
104
|
+
printf '%s' "$hash"
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
# Read whether the allowlist is policy-disabled.
|
|
108
|
+
# Returns 0 (enabled, default) or 1 (disabled).
|
|
109
|
+
#
|
|
110
|
+
# Conflict-resolution: the architect locked "drop the grep tier" —
|
|
111
|
+
# jq → node only. Node is guaranteed available via `engines.node:
|
|
112
|
+
# ">=22"`. If neither tool parses the policy, refuse to ALLOW (default
|
|
113
|
+
# = enabled, but a malformed policy reads as "unknown" and we keep the
|
|
114
|
+
# allowlist on, otherwise an attacker who corrupts the policy could
|
|
115
|
+
# strip the bootstrap recovery path).
|
|
116
|
+
_bootstrap_allowlist_policy_enabled() {
|
|
117
|
+
local policy_file="${1:-}"
|
|
118
|
+
if [ -z "$policy_file" ] || [ ! -f "$policy_file" ]; then
|
|
119
|
+
# No policy file → schema default = enabled.
|
|
120
|
+
return 0
|
|
121
|
+
fi
|
|
122
|
+
local val=""
|
|
123
|
+
# Tier 1: jq. Best signal — handles YAML poorly but operators
|
|
124
|
+
# whose policy.yaml is JSON-shaped get a clean read. We do not
|
|
125
|
+
# actually expect this to fire since policy.yaml is YAML; this is
|
|
126
|
+
# defense-in-depth.
|
|
127
|
+
if command -v jq >/dev/null 2>&1; then
|
|
128
|
+
val=$(jq -r 'try .bootstrap_allowlist.enabled // empty' "$policy_file" 2>/dev/null || true)
|
|
129
|
+
case "$val" in
|
|
130
|
+
true|false) ;;
|
|
131
|
+
*) val="" ;;
|
|
132
|
+
esac
|
|
133
|
+
fi
|
|
134
|
+
# Tier 2: node with a tightened inline parser.
|
|
135
|
+
#
|
|
136
|
+
# R7-P2 (codex round 7): the parser must mirror the TS reader's
|
|
137
|
+
# YAML semantics. The TS reader uses `yaml.parse()` then validates
|
|
138
|
+
# with zod `z.boolean()`, so the ONLY accepted bool tokens are
|
|
139
|
+
# `true`/`True`/`TRUE` and `false`/`False`/`FALSE` (the `yaml` v2
|
|
140
|
+
# package recognises these as booleans; everything else —
|
|
141
|
+
# `no`, `off`, `"false"`, etc. — parses as a STRING and then
|
|
142
|
+
# fails zod validation with a clear error at CLI load time).
|
|
143
|
+
# Pre-fix this parser only recognised lowercase `true|false`, so
|
|
144
|
+
# a policy with `enabled: False` parsed as "enabled" in bash but
|
|
145
|
+
# as "disabled" in TS — silent drift.
|
|
146
|
+
#
|
|
147
|
+
# NOTE: we cannot `require("yaml")` here because the whole reason
|
|
148
|
+
# this code path runs is the CLI is missing — `node_modules/yaml`
|
|
149
|
+
# may not yet exist. We rely on stdlib only.
|
|
150
|
+
#
|
|
151
|
+
# Recognised block forms (top-level OR indented):
|
|
152
|
+
# bootstrap_allowlist:
|
|
153
|
+
# enabled: <true|True|TRUE|false|False|FALSE>
|
|
154
|
+
# Recognised flow form:
|
|
155
|
+
# bootstrap_allowlist: { enabled: <bool> }
|
|
156
|
+
#
|
|
157
|
+
# Anything we cannot confidently parse keeps the schema default
|
|
158
|
+
# (enabled = true) — a malformed policy MUST NOT silently strip
|
|
159
|
+
# the bootstrap recovery path.
|
|
160
|
+
if [ -z "$val" ] && command -v node >/dev/null 2>&1; then
|
|
161
|
+
val=$(node -e '
|
|
162
|
+
const fs = require("fs");
|
|
163
|
+
// Normalize a YAML scalar bool token to canonical `true`/`false`.
|
|
164
|
+
// Returns null if the token is not a recognised YAML boolean
|
|
165
|
+
// (mirrors the strict `z.boolean()` validation in the TS reader).
|
|
166
|
+
function normBool(s) {
|
|
167
|
+
if (typeof s !== "string") return null;
|
|
168
|
+
const t = s.trim();
|
|
169
|
+
if (t === "true" || t === "True" || t === "TRUE") return "true";
|
|
170
|
+
if (t === "false" || t === "False" || t === "FALSE") return "false";
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
try {
|
|
174
|
+
const raw = fs.readFileSync(process.argv[1], "utf8");
|
|
175
|
+
const lines = raw.split(/\r?\n/);
|
|
176
|
+
// Find the `bootstrap_allowlist:` key. The TS reader honors
|
|
177
|
+
// top-level placement; we also accept (defensively) the same
|
|
178
|
+
// key at a single indentation level deeper, since the `yaml`
|
|
179
|
+
// package would accept it inside the document root regardless
|
|
180
|
+
// of leading whitespace.
|
|
181
|
+
let i = -1;
|
|
182
|
+
let inlineFlow = "";
|
|
183
|
+
let baseIndent = 0;
|
|
184
|
+
for (let k = 0; k < lines.length; k++) {
|
|
185
|
+
const m = /^(\s*)bootstrap_allowlist:\s*(.*)$/.exec(lines[k]);
|
|
186
|
+
if (m) {
|
|
187
|
+
i = k;
|
|
188
|
+
baseIndent = (m[1] || "").length;
|
|
189
|
+
inlineFlow = (m[2] || "").trim();
|
|
190
|
+
break;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
if (i === -1) { process.exit(0); }
|
|
194
|
+
// Flow form on the same line: `bootstrap_allowlist: { enabled: <bool> }`
|
|
195
|
+
if (inlineFlow.length > 0) {
|
|
196
|
+
const flw = inlineFlow.match(/^\{\s*enabled:\s*([A-Za-z]+)\s*\}\s*$/);
|
|
197
|
+
if (flw) {
|
|
198
|
+
const b = normBool(flw[1]);
|
|
199
|
+
if (b !== null) { process.stdout.write(b); process.exit(0); }
|
|
200
|
+
}
|
|
201
|
+
process.exit(0);
|
|
202
|
+
}
|
|
203
|
+
// Block form: walk subsequent lines until indentation drops
|
|
204
|
+
// back to baseIndent or below (which terminates the block).
|
|
205
|
+
for (let k = i + 1; k < lines.length; k++) {
|
|
206
|
+
const line = lines[k];
|
|
207
|
+
// Blank or comment lines do not terminate the block.
|
|
208
|
+
if (/^\s*(#|$)/.test(line)) continue;
|
|
209
|
+
// Indentation must be strictly deeper than the parent key.
|
|
210
|
+
const indMatch = /^(\s*)\S/.exec(line);
|
|
211
|
+
const ind = indMatch ? indMatch[1].length : 0;
|
|
212
|
+
if (ind <= baseIndent) break;
|
|
213
|
+
// Match `enabled: <token>` — accept lowercase, capitalized,
|
|
214
|
+
// and uppercase boolean tokens via normBool. Reject quoted
|
|
215
|
+
// forms (the regex requires a bare identifier).
|
|
216
|
+
const m = /^\s+enabled:\s*([A-Za-z]+)\b/.exec(line);
|
|
217
|
+
if (m) {
|
|
218
|
+
const b = normBool(m[1]);
|
|
219
|
+
if (b !== null) { process.stdout.write(b); process.exit(0); }
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
} catch (e) { process.exit(0); }
|
|
223
|
+
' -- "$policy_file" 2>/dev/null || true)
|
|
224
|
+
case "$val" in
|
|
225
|
+
true|false) ;;
|
|
226
|
+
*) val="" ;;
|
|
227
|
+
esac
|
|
228
|
+
fi
|
|
229
|
+
if [ "$val" = "false" ]; then
|
|
230
|
+
return 1
|
|
231
|
+
fi
|
|
232
|
+
# Default = enabled (true OR unparseable). A malformed policy MUST
|
|
233
|
+
# NOT strip the bootstrap recovery path — see security comment above.
|
|
234
|
+
return 0
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
# Verify package.json precondition.
|
|
238
|
+
# Args: $1 = path to package.json
|
|
239
|
+
# Echoes "<declared-range>" on stdout when matched (truncated to 16
|
|
240
|
+
# chars for the audit field). Empty on stdout when not matched.
|
|
241
|
+
# Returns 0 always — caller checks stdout.
|
|
242
|
+
_bootstrap_check_package_json() {
|
|
243
|
+
local pj="$1"
|
|
244
|
+
# R20-P2 (codex round 20): refuse when `package.json` is itself a
|
|
245
|
+
# symbolic link. Pre-fix `[ -f ]` follows symlinks, so a CLI-
|
|
246
|
+
# missing checkout whose `package.json` points outside the project
|
|
247
|
+
# tree would still trust the symlink target's declaration of
|
|
248
|
+
# `@bookedsolid/rea`. The package manager would then mutate that
|
|
249
|
+
# OUT-OF-TREE target on the next `pnpm add` / `npm install`,
|
|
250
|
+
# silently rewriting a file the operator did not intend the gate
|
|
251
|
+
# to cover. Mirrors the R10-P2 symlink refusal added to
|
|
252
|
+
# `selfPinRea` / `checkUpgradeBlockingPin` / `checkSelfPinDeclaredSync`
|
|
253
|
+
# — every surface that READS package.json on the bootstrap path
|
|
254
|
+
# must apply the same lstat-based guard. POSIX `[ -L ]` tests
|
|
255
|
+
# whether the path itself is a symlink without dereferencing it
|
|
256
|
+
# (bash 3.2 safe; supported on macOS / Linux / Alpine / Busybox).
|
|
257
|
+
if [ -L "$pj" ]; then
|
|
258
|
+
printf 'rea bootstrap allowlist refusing: %s is a symlink.\n' "$pj" >&2
|
|
259
|
+
printf 'Trusting it would let the package manager mutate a target outside the project tree.\n' >&2
|
|
260
|
+
return 0
|
|
261
|
+
fi
|
|
262
|
+
if [ ! -f "$pj" ]; then
|
|
263
|
+
return 0
|
|
264
|
+
fi
|
|
265
|
+
# Tier 1: jq.
|
|
266
|
+
#
|
|
267
|
+
# P2-2 (codex round 1): the previous expression used `//` to fall
|
|
268
|
+
# through from `dependencies` to `devDependencies`, but jq's `//`
|
|
269
|
+
# treats BOTH `null` AND `false` as "default triggers". A hostile
|
|
270
|
+
# `package.json` with `{ "dependencies": { "@bookedsolid/rea": false } }`
|
|
271
|
+
# would fall through to `devDependencies` lookup — inconsistent with
|
|
272
|
+
# the node tier (which type-guards `typeof x === "string"`) and a
|
|
273
|
+
# latent forge surface if npm ever relaxes its current rejection of
|
|
274
|
+
# non-string version values. `select(type=="string")` makes both
|
|
275
|
+
# tiers type-equivalent: only string values qualify, every other
|
|
276
|
+
# JSON shape (false / null / number / array / object) refuses.
|
|
277
|
+
local val=""
|
|
278
|
+
if command -v jq >/dev/null 2>&1; then
|
|
279
|
+
val=$(jq -r '
|
|
280
|
+
(.dependencies["@bookedsolid/rea"] // .devDependencies["@bookedsolid/rea"])
|
|
281
|
+
| select(type == "string")
|
|
282
|
+
// empty
|
|
283
|
+
' "$pj" 2>/dev/null || true)
|
|
284
|
+
fi
|
|
285
|
+
# Tier 2: node (guaranteed via engines.node).
|
|
286
|
+
#
|
|
287
|
+
# P2-1 (codex round 1): strip a leading UTF-8 BOM (EF BB BF) before
|
|
288
|
+
# JSON.parse. Some Windows-authored package.json manifests start with
|
|
289
|
+
# a BOM; JSON.parse rejects it (the spec is unambiguous). Pre-fix, a
|
|
290
|
+
# BOM-prefixed manifest declaring @bookedsolid/rea would be treated
|
|
291
|
+
# as missing here, refusing the install command that the dogfood
|
|
292
|
+
# `selfPinRea` write path tolerates fine — asymmetric handling.
|
|
293
|
+
# Mirrors the strip applied in src/cli/install/self-pin.ts (P2-3).
|
|
294
|
+
if [ -z "$val" ] && command -v node >/dev/null 2>&1; then
|
|
295
|
+
val=$(node -e '
|
|
296
|
+
try {
|
|
297
|
+
const fs = require("fs");
|
|
298
|
+
let raw = fs.readFileSync(process.argv[1], "utf8");
|
|
299
|
+
if (raw.charCodeAt(0) === 0xFEFF) { raw = raw.slice(1); }
|
|
300
|
+
const pkg = JSON.parse(raw);
|
|
301
|
+
if (!pkg || typeof pkg !== "object" || Array.isArray(pkg)) process.exit(0);
|
|
302
|
+
const lookIn = function (key) {
|
|
303
|
+
const v = pkg[key];
|
|
304
|
+
if (v && typeof v === "object" && !Array.isArray(v)) {
|
|
305
|
+
const x = v["@bookedsolid/rea"];
|
|
306
|
+
if (typeof x === "string") return x;
|
|
307
|
+
}
|
|
308
|
+
return null;
|
|
309
|
+
};
|
|
310
|
+
const deps = lookIn("dependencies");
|
|
311
|
+
if (deps !== null) { process.stdout.write(deps); process.exit(0); }
|
|
312
|
+
const dev = lookIn("devDependencies");
|
|
313
|
+
if (dev !== null) { process.stdout.write(dev); process.exit(0); }
|
|
314
|
+
} catch (e) { /* fall through to empty */ }
|
|
315
|
+
' -- "$pj" 2>/dev/null || true)
|
|
316
|
+
fi
|
|
317
|
+
# Defense-in-depth: refuse semver values longer than 256 chars
|
|
318
|
+
# (cap the audit field size and prevent a packed/forged JSON
|
|
319
|
+
# blob from blowing up the audit line).
|
|
320
|
+
if [ "${#val}" -gt 256 ]; then
|
|
321
|
+
val=""
|
|
322
|
+
fi
|
|
323
|
+
# Truncate to 16 chars for audit shape.
|
|
324
|
+
if [ -n "$val" ]; then
|
|
325
|
+
printf '%s' "${val:0:16}"
|
|
326
|
+
fi
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
# Emit audit event `rea.bash.bootstrap_allow`. Two-tier:
|
|
331
|
+
# Tier 1: `rea hook audit-emit` when CLI is reachable (rare in our
|
|
332
|
+
# caller — the whole point is CLI-missing — but `dist/cli/`
|
|
333
|
+
# could exist without `node_modules/@bookedsolid/rea/`).
|
|
334
|
+
# Tier 2: hand-write the JSONL with PINNED key order so the canonical
|
|
335
|
+
# TS audit reader accepts it.
|
|
336
|
+
#
|
|
337
|
+
# Args:
|
|
338
|
+
# $1 = shim name (e.g. "blocked-paths-bash-gate")
|
|
339
|
+
# $2 = pm token (e.g. "pnpm")
|
|
340
|
+
# $3 = argv shape (e.g. "install")
|
|
341
|
+
# $4 = argv-segments sha256
|
|
342
|
+
# $5 = package.json sha256
|
|
343
|
+
# $6 = package.json declares-rea ("true" or "false")
|
|
344
|
+
# $7 = declared version range (truncated to 16 chars)
|
|
345
|
+
# $8 = policy enabled ("true" or "false")
|
|
346
|
+
# $9 = CLAUDE_PROJECT_DIR
|
|
347
|
+
_bootstrap_emit_audit() {
|
|
348
|
+
local shim="$1"
|
|
349
|
+
local pm="$2"
|
|
350
|
+
local argv_shape="$3"
|
|
351
|
+
local argv_sha="$4"
|
|
352
|
+
local pj_sha="$5"
|
|
353
|
+
local declares_rea="$6"
|
|
354
|
+
local declared_range="$7"
|
|
355
|
+
local policy_enabled="$8"
|
|
356
|
+
local proj="$9"
|
|
357
|
+
local audit_file="$proj/.rea/audit.jsonl"
|
|
358
|
+
local timestamp
|
|
359
|
+
timestamp=$(node -e 'process.stdout.write(new Date().toISOString())' 2>/dev/null || true)
|
|
360
|
+
if [ -z "$timestamp" ]; then
|
|
361
|
+
timestamp=$(date -u +"%Y-%m-%dT%H:%M:%S.000Z" 2>/dev/null || true)
|
|
362
|
+
fi
|
|
363
|
+
if [ -z "$timestamp" ]; then
|
|
364
|
+
# Truly stripped container — emit a sentinel so the record is
|
|
365
|
+
# still well-formed.
|
|
366
|
+
timestamp="1970-01-01T00:00:00.000Z"
|
|
367
|
+
fi
|
|
368
|
+
|
|
369
|
+
# P2-2 (codex round 1): cross-process mutex around the
|
|
370
|
+
# read-tail → compute-hash → append sequence. Two concurrently-allowed
|
|
371
|
+
# bootstrap commands (e.g. `pnpm install` started twice from racing
|
|
372
|
+
# editor instances, or `corepack prepare` + `pnpm install` in the
|
|
373
|
+
# same hook fire) would otherwise read the same `prev_hash` tail
|
|
374
|
+
# value and append two records with the same chain pointer —
|
|
375
|
+
# forking the audit chain and breaking `rea audit verify`.
|
|
376
|
+
#
|
|
377
|
+
# mkdir is the canonical portable mutex on POSIX: creation is
|
|
378
|
+
# atomic w.r.t. concurrent processes, fails fast if the dir exists,
|
|
379
|
+
# and works on macOS/Linux/Alpine/Busybox without external tools.
|
|
380
|
+
# The TS canonical writer (src/audit/append.ts) uses
|
|
381
|
+
# `proper-lockfile` on `.rea/`; we cannot reach for that here
|
|
382
|
+
# because the whole reason this helper runs is the CLI being
|
|
383
|
+
# unbuilt — proper-lockfile lives in node_modules. The two
|
|
384
|
+
# mechanisms are conservative w.r.t. each other: a TS writer
|
|
385
|
+
# holding the proper-lockfile lock does not block our mkdir, but
|
|
386
|
+
# the practical concern is OUR concurrent self-races (two bash
|
|
387
|
+
# helper invocations against the same .rea/), and mkdir serialises
|
|
388
|
+
# those cleanly. The race window where the TS writer and the bash
|
|
389
|
+
# helper interleave is vanishingly small in practice (the bash
|
|
390
|
+
# helper only runs when the CLI is unreachable; if the CLI is
|
|
391
|
+
# unreachable the TS writer cannot run anyway).
|
|
392
|
+
#
|
|
393
|
+
# Lock-acquire policy: retry up to 50x at 50ms (≈2.5s window)
|
|
394
|
+
# when sub-second sleep is available, otherwise 5x at 1s
|
|
395
|
+
# (5s window). The `pnpm install` / `npm ci` commands the
|
|
396
|
+
# allowlist guards take seconds-to-minutes; a multi-second lock
|
|
397
|
+
# window is invisible to operators. On lock-acquire failure we
|
|
398
|
+
# refuse rather than silently bypassing — every allow MUST be
|
|
399
|
+
# auditable.
|
|
400
|
+
local lockdir="$proj/.rea/.audit.lock"
|
|
401
|
+
local lock_max_iter=50
|
|
402
|
+
local lock_sleep="0.05"
|
|
403
|
+
if ! sleep 0.01 2>/dev/null; then
|
|
404
|
+
lock_max_iter=5
|
|
405
|
+
lock_sleep="1"
|
|
406
|
+
fi
|
|
407
|
+
local lock_acquired=0
|
|
408
|
+
local lock_i=0
|
|
409
|
+
# Ensure .rea/ exists so mkdir(.audit.lock) has a parent. The
|
|
410
|
+
# bash helper is the only writer in the bootstrap state, so this
|
|
411
|
+
# is safe to do BEFORE locking — we only race on the audit-file
|
|
412
|
+
# tail, not on .rea/ creation.
|
|
413
|
+
mkdir -p "$proj/.rea" 2>/dev/null || true
|
|
414
|
+
# Stale-lock recovery: a process killed mid-write (SIGKILL, OOM,
|
|
415
|
+
# operator ^C through the wrong window) leaves the lockdir
|
|
416
|
+
# hanging. Anything older than 1 minute is unambiguously stale —
|
|
417
|
+
# the locked region holds for ~100ms typical, ~5s pathological.
|
|
418
|
+
# `find -mmin` is non-POSIX but supported by macOS find, GNU find
|
|
419
|
+
# (Linux), and Busybox find (Alpine). Failure to detect staleness
|
|
420
|
+
# is acceptable: we still fall back to the existing CLI-missing
|
|
421
|
+
# refusal path after the acquire window times out.
|
|
422
|
+
if [ -d "$lockdir" ]; then
|
|
423
|
+
if find "$lockdir" -maxdepth 0 -mmin +1 -type d 2>/dev/null | grep -q .; then
|
|
424
|
+
rmdir "$lockdir" 2>/dev/null || true
|
|
425
|
+
fi
|
|
426
|
+
fi
|
|
427
|
+
while [ "$lock_i" -lt "$lock_max_iter" ]; do
|
|
428
|
+
if mkdir "$lockdir" 2>/dev/null; then
|
|
429
|
+
lock_acquired=1
|
|
430
|
+
break
|
|
431
|
+
fi
|
|
432
|
+
sleep "$lock_sleep" 2>/dev/null || true
|
|
433
|
+
lock_i=$((lock_i + 1))
|
|
434
|
+
done
|
|
435
|
+
if [ "$lock_acquired" -eq 0 ]; then
|
|
436
|
+
# Lock starvation — refuse rather than fork the chain.
|
|
437
|
+
return 1
|
|
438
|
+
fi
|
|
439
|
+
|
|
440
|
+
# Build the metadata sub-object via node so JSON escaping is
|
|
441
|
+
# bulletproof — Bash's printf does not escape control characters
|
|
442
|
+
# the way JSON requires.
|
|
443
|
+
local record=""
|
|
444
|
+
record=$(node -e '
|
|
445
|
+
const crypto = require("crypto");
|
|
446
|
+
const fs = require("fs");
|
|
447
|
+
const path = require("path");
|
|
448
|
+
const args = process.argv.slice(1);
|
|
449
|
+
const auditFile = args[0];
|
|
450
|
+
const timestamp = args[1];
|
|
451
|
+
const shim = args[2];
|
|
452
|
+
const pm = args[3];
|
|
453
|
+
const argvShape = args[4];
|
|
454
|
+
const argvSha = args[5];
|
|
455
|
+
const pjSha = args[6];
|
|
456
|
+
const declaresRea = args[7] === "true";
|
|
457
|
+
const declaredRange = args[8];
|
|
458
|
+
const policyEnabled = args[9] === "true";
|
|
459
|
+
// Read prev_hash from the last line of the existing audit file.
|
|
460
|
+
//
|
|
461
|
+
// R4-P1 (codex round 4): tail-validation must distinguish three
|
|
462
|
+
// states:
|
|
463
|
+
//
|
|
464
|
+
// 1. File absent OR zero bytes → GENESIS (prev_hash = all-zeros)
|
|
465
|
+
// 2. Last line parses as JSON with a valid 64-hex `hash` field
|
|
466
|
+
// → NORMAL (prev_hash = that hash)
|
|
467
|
+
// 3. File exists with bytes but the last non-empty line is
|
|
468
|
+
// partial / not-JSON / missing the hash field / wrong
|
|
469
|
+
// hash shape → CORRUPTION
|
|
470
|
+
//
|
|
471
|
+
// Pre-fix, case (3) silently fell back to genesis — the next
|
|
472
|
+
// bootstrap allow appended a record whose prev_hash pointed at
|
|
473
|
+
// the genesis sentinel instead of the real tail, permanently
|
|
474
|
+
// forking the chain. The "every allow is auditable" invariant
|
|
475
|
+
// requires us to refuse rather than fork. On corruption we
|
|
476
|
+
// emit empty stdout + a stderr explainer; the bash caller
|
|
477
|
+
// already refuses when stdout is empty (see "if [ -z \"$record\" ]"
|
|
478
|
+
// a few lines down).
|
|
479
|
+
const HEX64 = /^[0-9a-f]{64}$/;
|
|
480
|
+
const GENESIS = "0000000000000000000000000000000000000000000000000000000000000000";
|
|
481
|
+
let prevHash = null;
|
|
482
|
+
let exists = false;
|
|
483
|
+
try {
|
|
484
|
+
const st = fs.statSync(auditFile);
|
|
485
|
+
exists = st.isFile();
|
|
486
|
+
} catch (e) { /* ENOENT or perms → treat as not-present */ }
|
|
487
|
+
if (!exists) {
|
|
488
|
+
prevHash = GENESIS;
|
|
489
|
+
} else {
|
|
490
|
+
let raw = "";
|
|
491
|
+
try { raw = fs.readFileSync(auditFile, "utf8"); }
|
|
492
|
+
catch (e) {
|
|
493
|
+
// File present but unreadable. Distinguish-able from
|
|
494
|
+
// genesis: refuse.
|
|
495
|
+
process.stderr.write("rea: bootstrap-allowlist refused — audit file " + auditFile + " is unreadable.\n");
|
|
496
|
+
process.exit(0);
|
|
497
|
+
}
|
|
498
|
+
if (raw.length === 0) {
|
|
499
|
+
prevHash = GENESIS;
|
|
500
|
+
} else {
|
|
501
|
+
// Find the LAST non-empty line. If the file does not end
|
|
502
|
+
// with "\n", the trailing partial line is a crash-mid-write
|
|
503
|
+
// signal — that is corruption.
|
|
504
|
+
const endsWithNewline = raw.endsWith("\n");
|
|
505
|
+
const lines = raw.split("\n");
|
|
506
|
+
// After split, a trailing newline produces a final "" entry.
|
|
507
|
+
// A missing trailing newline leaves the partial tail as the
|
|
508
|
+
// final entry — and we refuse on that.
|
|
509
|
+
if (!endsWithNewline) {
|
|
510
|
+
process.stderr.write("rea: bootstrap-allowlist refused — audit tail at " + auditFile + " is missing a trailing newline (partial write detected). Repair the chain before retrying.\n");
|
|
511
|
+
process.exit(0);
|
|
512
|
+
}
|
|
513
|
+
// Strip the trailing empty entry and find the last non-blank line.
|
|
514
|
+
while (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
|
|
515
|
+
if (lines.length === 0) {
|
|
516
|
+
// File contained only newlines — refuse, this is corruption.
|
|
517
|
+
process.stderr.write("rea: bootstrap-allowlist refused — audit file " + auditFile + " contains only whitespace.\n");
|
|
518
|
+
process.exit(0);
|
|
519
|
+
}
|
|
520
|
+
const lastLine = lines[lines.length - 1];
|
|
521
|
+
let lastObj = null;
|
|
522
|
+
try { lastObj = JSON.parse(lastLine); }
|
|
523
|
+
catch (e) {
|
|
524
|
+
process.stderr.write("rea: bootstrap-allowlist refused — audit tail at " + auditFile + " line " + lines.length + " is not valid JSON.\n");
|
|
525
|
+
process.exit(0);
|
|
526
|
+
}
|
|
527
|
+
if (lastObj === null || typeof lastObj !== "object" || Array.isArray(lastObj)) {
|
|
528
|
+
process.stderr.write("rea: bootstrap-allowlist refused — audit tail at " + auditFile + " line " + lines.length + " is not a JSON object.\n");
|
|
529
|
+
process.exit(0);
|
|
530
|
+
}
|
|
531
|
+
if (typeof lastObj.hash !== "string" || !HEX64.test(lastObj.hash)) {
|
|
532
|
+
process.stderr.write("rea: bootstrap-allowlist refused — audit tail at " + auditFile + " line " + lines.length + " is missing a valid 64-hex `hash` field.\n");
|
|
533
|
+
process.exit(0);
|
|
534
|
+
}
|
|
535
|
+
prevHash = lastObj.hash;
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
if (prevHash === null) {
|
|
539
|
+
// Defensive — should be unreachable; every branch above either
|
|
540
|
+
// sets prevHash or exits.
|
|
541
|
+
process.exit(0);
|
|
542
|
+
}
|
|
543
|
+
// PINNED key order — matches the canonical AuditRecord shape.
|
|
544
|
+
const recordBase = {
|
|
545
|
+
timestamp: timestamp,
|
|
546
|
+
session_id: "bash-tier",
|
|
547
|
+
tool_name: "rea.bash.bootstrap_allow",
|
|
548
|
+
server_name: "rea",
|
|
549
|
+
tier: "write",
|
|
550
|
+
status: "allowed",
|
|
551
|
+
autonomy_level: "unknown",
|
|
552
|
+
duration_ms: 0,
|
|
553
|
+
prev_hash: prevHash,
|
|
554
|
+
emission_source: "rea-cli",
|
|
555
|
+
metadata: {
|
|
556
|
+
shim: shim,
|
|
557
|
+
pm: pm,
|
|
558
|
+
argv_shape: argvShape,
|
|
559
|
+
argv_segments_sha256: argvSha,
|
|
560
|
+
package_json_sha256: pjSha,
|
|
561
|
+
package_json_declares_rea: declaresRea,
|
|
562
|
+
declared_version_range: declaredRange,
|
|
563
|
+
cli_resolution: "missing",
|
|
564
|
+
policy_enabled: policyEnabled,
|
|
565
|
+
},
|
|
566
|
+
};
|
|
567
|
+
const canonical = JSON.stringify(recordBase);
|
|
568
|
+
const hash = crypto.createHash("sha256").update(canonical).digest("hex");
|
|
569
|
+
const record = Object.assign({}, recordBase, { hash: hash });
|
|
570
|
+
process.stdout.write(JSON.stringify(record));
|
|
571
|
+
' -- "$audit_file" "$timestamp" "$shim" "$pm" "$argv_shape" "$argv_sha" "$pj_sha" "$declares_rea" "$declared_range" "$policy_enabled" || true)
|
|
572
|
+
# R4-P1 (codex round 4): keep stderr UN-redirected on the
|
|
573
|
+
# record-build call. The corruption-refusal branch writes a
|
|
574
|
+
# human-readable explainer to stderr, and operators investigating
|
|
575
|
+
# a refused bootstrap allow need to see WHY the chain refused.
|
|
576
|
+
# Any other node-side failure (OOM, missing crypto module, etc.)
|
|
577
|
+
# is also legitimately operator-actionable — silencing those
|
|
578
|
+
# was hiding signal, not noise.
|
|
579
|
+
|
|
580
|
+
if [ -z "$record" ]; then
|
|
581
|
+
# Audit-emit unavailable — security-architect mandate: every
|
|
582
|
+
# bootstrap_allow MUST be auditable. Refuse rather than allow
|
|
583
|
+
# silently. The caller treats stderr-only-refuse as a fallthrough
|
|
584
|
+
# to the existing CLI-missing path (banner + exit 2).
|
|
585
|
+
rmdir "$lockdir" 2>/dev/null || true
|
|
586
|
+
return 1
|
|
587
|
+
fi
|
|
588
|
+
|
|
589
|
+
# Append + fsync via a single node call.
|
|
590
|
+
node -e '
|
|
591
|
+
const fs = require("fs");
|
|
592
|
+
const path = require("path");
|
|
593
|
+
const auditFile = process.argv[1];
|
|
594
|
+
const line = process.argv[2] + "\n";
|
|
595
|
+
try {
|
|
596
|
+
fs.mkdirSync(path.dirname(auditFile), { recursive: true });
|
|
597
|
+
const fd = fs.openSync(auditFile, "a");
|
|
598
|
+
fs.writeSync(fd, line);
|
|
599
|
+
try { fs.fsyncSync(fd); } catch (e) {}
|
|
600
|
+
fs.closeSync(fd);
|
|
601
|
+
} catch (e) {
|
|
602
|
+
process.exit(1);
|
|
603
|
+
}
|
|
604
|
+
' -- "$audit_file" "$record" 2>/dev/null
|
|
605
|
+
local append_rc=$?
|
|
606
|
+
rmdir "$lockdir" 2>/dev/null || true
|
|
607
|
+
return $append_rc
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
# Match the package-spec argument of a `pm add` invocation against the
|
|
611
|
+
# bare-only shape for `@bookedsolid/rea`.
|
|
612
|
+
#
|
|
613
|
+
# R6-P2 (codex round 6): version-pinned forms are REFUSED.
|
|
614
|
+
#
|
|
615
|
+
# Accepted: @bookedsolid/rea (bare — install whatever the consumer's
|
|
616
|
+
# existing self-pin admits)
|
|
617
|
+
# Rejected: @bookedsolid/rea@<anything> (incl. dist-tags `@latest`,
|
|
618
|
+
# `@next`, exact versions
|
|
619
|
+
# `@0.48.0`, ranges, etc.)
|
|
620
|
+
# every other spec (different package, malformed scope).
|
|
621
|
+
#
|
|
622
|
+
# Rationale: the bootstrap allowlist's stated job is to recover a
|
|
623
|
+
# CLI-missing repo. Version selection is `rea init` (caret pin at
|
|
624
|
+
# install time) and `rea upgrade` (managed-caret bump via the TS path,
|
|
625
|
+
# under audit) territory — NOT the Bash-tier bootstrap path. Allowing
|
|
626
|
+
# `@bookedsolid/rea@<ver>` here let a Bash-only session retarget the
|
|
627
|
+
# trusted gate binary mid-bootstrap by pinning to an older or
|
|
628
|
+
# attacker-controlled version, defeating the new `package.json` blocked-
|
|
629
|
+
# path protection in bst-internal*. Stripping the version branch closes
|
|
630
|
+
# that retarget surface entirely. See THREAT_MODEL.md §5.23.
|
|
631
|
+
#
|
|
632
|
+
# Returns 0 if matched (bare spec), 1 otherwise.
|
|
633
|
+
_bootstrap_match_rea_spec() {
|
|
634
|
+
[ "$1" = '@bookedsolid/rea' ]
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
# Classify the argv array (already split) against the per-PM allowed
|
|
638
|
+
# shape lists. Echoes the shape token (e.g. "install" / "ci" /
|
|
639
|
+
# "add-rea") on stdout when matched, OR empty stdout on no match.
|
|
640
|
+
# Always returns 0.
|
|
641
|
+
#
|
|
642
|
+
# argv[0] is the PM name (basename-exact-matched by the caller).
|
|
643
|
+
_bootstrap_classify_pnpm() {
|
|
644
|
+
# $@ = full argv (including pnpm)
|
|
645
|
+
local argv0="${1:-}"
|
|
646
|
+
shift || true
|
|
647
|
+
case "${argv0}" in
|
|
648
|
+
pnpm) ;;
|
|
649
|
+
*) return 0 ;;
|
|
650
|
+
esac
|
|
651
|
+
# Shapes we accept (post-pnpm). R6-P2: bare `@bookedsolid/rea` only;
|
|
652
|
+
# version-pinned `@bookedsolid/rea@<ver>` is refused.
|
|
653
|
+
# install
|
|
654
|
+
# i
|
|
655
|
+
# install --frozen-lockfile
|
|
656
|
+
# install --no-frozen-lockfile
|
|
657
|
+
# i --frozen-lockfile
|
|
658
|
+
# i --no-frozen-lockfile
|
|
659
|
+
# add -D @bookedsolid/rea
|
|
660
|
+
# add --save-dev @bookedsolid/rea
|
|
661
|
+
local first="${1:-}"
|
|
662
|
+
case "$first" in
|
|
663
|
+
install|i)
|
|
664
|
+
shift
|
|
665
|
+
if [ "$#" -eq 0 ]; then
|
|
666
|
+
printf 'install'
|
|
667
|
+
return 0
|
|
668
|
+
fi
|
|
669
|
+
if [ "$#" -eq 1 ]; then
|
|
670
|
+
case "$1" in
|
|
671
|
+
'--frozen-lockfile'|'--no-frozen-lockfile')
|
|
672
|
+
printf 'install-locked'
|
|
673
|
+
return 0
|
|
674
|
+
;;
|
|
675
|
+
esac
|
|
676
|
+
fi
|
|
677
|
+
return 0
|
|
678
|
+
;;
|
|
679
|
+
add)
|
|
680
|
+
shift
|
|
681
|
+
# Expect: -D|--save-dev <pkg-spec>
|
|
682
|
+
if [ "$#" -ne 2 ]; then
|
|
683
|
+
return 0
|
|
684
|
+
fi
|
|
685
|
+
case "$1" in
|
|
686
|
+
'-D'|'--save-dev') ;;
|
|
687
|
+
*) return 0 ;;
|
|
688
|
+
esac
|
|
689
|
+
if _bootstrap_match_rea_spec "$2"; then
|
|
690
|
+
printf 'add-rea'
|
|
691
|
+
return 0
|
|
692
|
+
fi
|
|
693
|
+
return 0
|
|
694
|
+
;;
|
|
695
|
+
esac
|
|
696
|
+
return 0
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
_bootstrap_classify_npm() {
|
|
700
|
+
local argv0="${1:-}"
|
|
701
|
+
shift || true
|
|
702
|
+
case "${argv0}" in
|
|
703
|
+
npm) ;;
|
|
704
|
+
*) return 0 ;;
|
|
705
|
+
esac
|
|
706
|
+
local first="${1:-}"
|
|
707
|
+
case "$first" in
|
|
708
|
+
install|i)
|
|
709
|
+
shift
|
|
710
|
+
if [ "$#" -eq 0 ]; then
|
|
711
|
+
printf 'install'
|
|
712
|
+
return 0
|
|
713
|
+
fi
|
|
714
|
+
# npm install -D @bookedsolid/rea
|
|
715
|
+
# npm install --save-dev @bookedsolid/rea
|
|
716
|
+
# R6-P2: version-pinned forms refuse.
|
|
717
|
+
if [ "$#" -eq 2 ]; then
|
|
718
|
+
case "$1" in
|
|
719
|
+
'-D'|'--save-dev') ;;
|
|
720
|
+
*) return 0 ;;
|
|
721
|
+
esac
|
|
722
|
+
if _bootstrap_match_rea_spec "$2"; then
|
|
723
|
+
printf 'add-rea'
|
|
724
|
+
return 0
|
|
725
|
+
fi
|
|
726
|
+
fi
|
|
727
|
+
return 0
|
|
728
|
+
;;
|
|
729
|
+
ci)
|
|
730
|
+
shift
|
|
731
|
+
if [ "$#" -eq 0 ]; then
|
|
732
|
+
printf 'ci'
|
|
733
|
+
return 0
|
|
734
|
+
fi
|
|
735
|
+
return 0
|
|
736
|
+
;;
|
|
737
|
+
esac
|
|
738
|
+
return 0
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
_bootstrap_classify_yarn() {
|
|
742
|
+
local argv0="${1:-}"
|
|
743
|
+
shift || true
|
|
744
|
+
case "${argv0}" in
|
|
745
|
+
yarn) ;;
|
|
746
|
+
*) return 0 ;;
|
|
747
|
+
esac
|
|
748
|
+
local first="${1:-}"
|
|
749
|
+
if [ -z "$first" ]; then
|
|
750
|
+
# Bare `yarn` (yarn classic install).
|
|
751
|
+
printf 'install'
|
|
752
|
+
return 0
|
|
753
|
+
fi
|
|
754
|
+
case "$first" in
|
|
755
|
+
install)
|
|
756
|
+
shift
|
|
757
|
+
if [ "$#" -eq 0 ]; then
|
|
758
|
+
printf 'install'
|
|
759
|
+
return 0
|
|
760
|
+
fi
|
|
761
|
+
return 0
|
|
762
|
+
;;
|
|
763
|
+
add)
|
|
764
|
+
shift
|
|
765
|
+
# yarn add -D @bookedsolid/rea
|
|
766
|
+
# yarn add --dev @bookedsolid/rea
|
|
767
|
+
# R6-P2: version-pinned forms refuse.
|
|
768
|
+
if [ "$#" -eq 2 ]; then
|
|
769
|
+
case "$1" in
|
|
770
|
+
'-D'|'--dev') ;;
|
|
771
|
+
*) return 0 ;;
|
|
772
|
+
esac
|
|
773
|
+
if _bootstrap_match_rea_spec "$2"; then
|
|
774
|
+
printf 'add-rea'
|
|
775
|
+
return 0
|
|
776
|
+
fi
|
|
777
|
+
fi
|
|
778
|
+
return 0
|
|
779
|
+
;;
|
|
780
|
+
esac
|
|
781
|
+
return 0
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
_bootstrap_classify_corepack() {
|
|
785
|
+
local argv0="${1:-}"
|
|
786
|
+
shift || true
|
|
787
|
+
case "${argv0}" in
|
|
788
|
+
corepack) ;;
|
|
789
|
+
*) return 0 ;;
|
|
790
|
+
esac
|
|
791
|
+
local first="${1:-}"
|
|
792
|
+
case "$first" in
|
|
793
|
+
enable)
|
|
794
|
+
shift
|
|
795
|
+
if [ "$#" -eq 0 ]; then
|
|
796
|
+
printf 'corepack-enable'
|
|
797
|
+
return 0
|
|
798
|
+
fi
|
|
799
|
+
# `corepack enable pnpm` (or yarn/npm)
|
|
800
|
+
if [ "$#" -eq 1 ]; then
|
|
801
|
+
case "$1" in
|
|
802
|
+
pnpm|yarn|npm)
|
|
803
|
+
printf 'corepack-enable-pm'
|
|
804
|
+
return 0
|
|
805
|
+
;;
|
|
806
|
+
esac
|
|
807
|
+
fi
|
|
808
|
+
return 0
|
|
809
|
+
;;
|
|
810
|
+
prepare)
|
|
811
|
+
shift
|
|
812
|
+
# `corepack prepare pnpm@<ver> --activate`
|
|
813
|
+
if [ "$#" -eq 2 ]; then
|
|
814
|
+
case "$1" in
|
|
815
|
+
pnpm@*|yarn@*|npm@*)
|
|
816
|
+
local pmver="${1%%@*}"
|
|
817
|
+
local ver="${1#*@}"
|
|
818
|
+
# Ver charset same as rea-spec (semver-like).
|
|
819
|
+
if [ -z "$ver" ] || [ "${#ver}" -gt 64 ]; then
|
|
820
|
+
return 0
|
|
821
|
+
fi
|
|
822
|
+
local i=0
|
|
823
|
+
while [ $i -lt ${#ver} ]; do
|
|
824
|
+
local c="${ver:$i:1}"
|
|
825
|
+
case "$c" in
|
|
826
|
+
[A-Za-z0-9._~+\-]|'^') ;;
|
|
827
|
+
*) return 0 ;;
|
|
828
|
+
esac
|
|
829
|
+
i=$((i + 1))
|
|
830
|
+
done
|
|
831
|
+
if [ "$2" = '--activate' ]; then
|
|
832
|
+
# Avoid unused-var warning for pmver (purely documentary
|
|
833
|
+
# at this point — already constrained by the case above).
|
|
834
|
+
: "$pmver"
|
|
835
|
+
printf 'corepack-prepare'
|
|
836
|
+
return 0
|
|
837
|
+
fi
|
|
838
|
+
;;
|
|
839
|
+
esac
|
|
840
|
+
fi
|
|
841
|
+
return 0
|
|
842
|
+
;;
|
|
843
|
+
esac
|
|
844
|
+
return 0
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
# Entrypoint.
|
|
848
|
+
# Args:
|
|
849
|
+
# $1 = shim name (used in audit event)
|
|
850
|
+
# $2 = the extracted single-segment Bash command
|
|
851
|
+
# $3 = path to package.json (canonical: $CLAUDE_PROJECT_DIR/package.json)
|
|
852
|
+
# $4 = path to policy.yaml (canonical: $CLAUDE_PROJECT_DIR/.rea/policy.yaml)
|
|
853
|
+
# $5 = $proj (realpath-resolved project dir for the audit log)
|
|
854
|
+
#
|
|
855
|
+
# Echoes "allow" or "refuse" on stdout. Always exits 0.
|
|
856
|
+
bootstrap_allowlist_check() {
|
|
857
|
+
# ast-parser-specialist required: declare local IFS so a hostile
|
|
858
|
+
# caller cannot reshape word-splitting via IFS leakage.
|
|
859
|
+
local IFS=$' \t\n'
|
|
860
|
+
|
|
861
|
+
local shim="$1"
|
|
862
|
+
local cmd="$2"
|
|
863
|
+
local pj="$3"
|
|
864
|
+
local policy_file="$4"
|
|
865
|
+
local proj="$5"
|
|
866
|
+
|
|
867
|
+
# Policy precondition: enabled?
|
|
868
|
+
if ! _bootstrap_allowlist_policy_enabled "$policy_file"; then
|
|
869
|
+
printf 'refuse'
|
|
870
|
+
return 0
|
|
871
|
+
fi
|
|
872
|
+
|
|
873
|
+
# Precondition: package.json declares @bookedsolid/rea?
|
|
874
|
+
local declared_range=""
|
|
875
|
+
declared_range=$(_bootstrap_check_package_json "$pj")
|
|
876
|
+
if [ -z "$declared_range" ]; then
|
|
877
|
+
printf 'refuse'
|
|
878
|
+
return 0
|
|
879
|
+
fi
|
|
880
|
+
|
|
881
|
+
# Trim leading/trailing whitespace from the command.
|
|
882
|
+
local trimmed="$cmd"
|
|
883
|
+
trimmed="${trimmed#"${trimmed%%[![:space:]]*}"}"
|
|
884
|
+
trimmed="${trimmed%"${trimmed##*[![:space:]]}"}"
|
|
885
|
+
if [ -z "$trimmed" ]; then
|
|
886
|
+
printf 'refuse'
|
|
887
|
+
return 0
|
|
888
|
+
fi
|
|
889
|
+
|
|
890
|
+
# Defense: refuse multi-segment payloads. The shim helper passes us
|
|
891
|
+
# the extracted command; the caller is responsible for routing
|
|
892
|
+
# multi-segment commands away from the allowlist, but if it didn't,
|
|
893
|
+
# we hard-refuse here. _rea_split_segments outputs one segment per
|
|
894
|
+
# line; we count via a single subprocess.
|
|
895
|
+
local seg_count=0
|
|
896
|
+
while IFS= read -r _seg; do
|
|
897
|
+
if [ -n "$_seg" ]; then
|
|
898
|
+
seg_count=$((seg_count + 1))
|
|
899
|
+
fi
|
|
900
|
+
done < <(_rea_split_segments "$trimmed")
|
|
901
|
+
if [ "$seg_count" -gt 1 ]; then
|
|
902
|
+
printf 'refuse'
|
|
903
|
+
return 0
|
|
904
|
+
fi
|
|
905
|
+
|
|
906
|
+
# Split argv via `read -ra`. Quoted forms refuse-fallthrough — a
|
|
907
|
+
# quoted argv token does not match the bare-string allowlist
|
|
908
|
+
# patterns, which is intentional: an attacker laundering through
|
|
909
|
+
# quotes is the kind of payload we want to refuse.
|
|
910
|
+
local -a ARGV
|
|
911
|
+
read -ra ARGV <<<"$trimmed"
|
|
912
|
+
if [ "${#ARGV[@]}" -eq 0 ]; then
|
|
913
|
+
printf 'refuse'
|
|
914
|
+
return 0
|
|
915
|
+
fi
|
|
916
|
+
|
|
917
|
+
local argv0="${ARGV[0]}"
|
|
918
|
+
# Reject path-form argv[0] (anything containing a slash).
|
|
919
|
+
case "$argv0" in
|
|
920
|
+
*/*) printf 'refuse'; return 0 ;;
|
|
921
|
+
esac
|
|
922
|
+
# Reject anything containing characters outside [A-Za-z0-9._-].
|
|
923
|
+
case "$argv0" in
|
|
924
|
+
*[!A-Za-z0-9._-]*) printf 'refuse'; return 0 ;;
|
|
925
|
+
esac
|
|
926
|
+
|
|
927
|
+
local pm=""
|
|
928
|
+
local shape=""
|
|
929
|
+
case "$argv0" in
|
|
930
|
+
pnpm) pm="pnpm"; shape=$(_bootstrap_classify_pnpm "${ARGV[@]}") ;;
|
|
931
|
+
npm) pm="npm"; shape=$(_bootstrap_classify_npm "${ARGV[@]}") ;;
|
|
932
|
+
yarn) pm="yarn"; shape=$(_bootstrap_classify_yarn "${ARGV[@]}") ;;
|
|
933
|
+
corepack)pm="corepack"; shape=$(_bootstrap_classify_corepack "${ARGV[@]}") ;;
|
|
934
|
+
*)
|
|
935
|
+
printf 'refuse'
|
|
936
|
+
return 0
|
|
937
|
+
;;
|
|
938
|
+
esac
|
|
939
|
+
|
|
940
|
+
if [ -z "$shape" ]; then
|
|
941
|
+
printf 'refuse'
|
|
942
|
+
return 0
|
|
943
|
+
fi
|
|
944
|
+
|
|
945
|
+
# Build audit fields.
|
|
946
|
+
# Hash the argv segments (space-joined for a stable canonical form).
|
|
947
|
+
local argv_canonical=""
|
|
948
|
+
argv_canonical=$(printf '%s' "${ARGV[*]}")
|
|
949
|
+
local argv_sha=""
|
|
950
|
+
argv_sha=$(_bootstrap_sha256 "$argv_canonical")
|
|
951
|
+
local pj_sha=""
|
|
952
|
+
pj_sha=$(_bootstrap_sha256_file "$pj")
|
|
953
|
+
if [ -z "$argv_sha" ] || [ -z "$pj_sha" ]; then
|
|
954
|
+
# R7-P1 (codex round 7): hasher unavailable — every allow MUST
|
|
955
|
+
# be auditable, so we refuse-HARD. The `refuse-hard` stdout token
|
|
956
|
+
# tells the shim caller to refuse via banner regardless of the
|
|
957
|
+
# substring-scan verdict; collapsing this into the plain `refuse`
|
|
958
|
+
# token would let the shim fall through to "no-substring →
|
|
959
|
+
# silent allow" and break the auditability invariant.
|
|
960
|
+
printf 'refuse-hard'
|
|
961
|
+
return 0
|
|
962
|
+
fi
|
|
963
|
+
|
|
964
|
+
# Emit audit. R7-P1: emit-failure is also refuse-hard for the same
|
|
965
|
+
# auditability reason.
|
|
966
|
+
if ! _bootstrap_emit_audit "$shim" "$pm" "$shape" "$argv_sha" \
|
|
967
|
+
"$pj_sha" "true" "$declared_range" "true" "$proj"; then
|
|
968
|
+
printf 'refuse-hard'
|
|
969
|
+
return 0
|
|
970
|
+
fi
|
|
971
|
+
|
|
972
|
+
printf 'allow'
|
|
973
|
+
return 0
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
# P1-2 (codex round 2) / R5-P1 (codex round 5) / R7-P1 (codex round 7):
|
|
977
|
+
# shim-side helper. Consults the allowlist for a CLI-missing Bash
|
|
978
|
+
# payload when argv[0] is a recognized PM (pnpm/npm/yarn/corepack),
|
|
979
|
+
# keeping the shim integration small enough to stay under its
|
|
980
|
+
# ≤120-LOC budget.
|
|
981
|
+
#
|
|
982
|
+
# # Return-code contract
|
|
983
|
+
#
|
|
984
|
+
# The allowlist OPENS gates, it does NOT CLOSE them — EXCEPT for the
|
|
985
|
+
# audit-integrity gate, which is fail-CLOSED. The shim's substring
|
|
986
|
+
# scan stays determinative for refusal in the ordinary case; the
|
|
987
|
+
# allowlist provides the audit trail when it permits an otherwise-
|
|
988
|
+
# suspicious (substring-matched) command. But when audit emission
|
|
989
|
+
# itself fails (`.rea/audit.jsonl` corrupted, hasher unavailable,
|
|
990
|
+
# disk full, etc.), every PM payload — substring-matched or not —
|
|
991
|
+
# must refuse, because allowing without an audit trail breaks the
|
|
992
|
+
# "every bootstrap allow is auditable" invariant.
|
|
993
|
+
#
|
|
994
|
+
# Return codes:
|
|
995
|
+
# - EXIT 0: PM payload, allowlist allows (audit event emitted by
|
|
996
|
+
# the helper itself). Caller should `exit 0` IMMEDIATELY — the
|
|
997
|
+
# auditable-allow path, valid whether or not the substring scan
|
|
998
|
+
# matched.
|
|
999
|
+
# - EXIT 1: refuse-FALLTHROUGH. argv[0] not a PM, OR shape didn't
|
|
1000
|
+
# match, OR precondition failed (no rea declaration). Caller
|
|
1001
|
+
# decides what to do based on its own substring-scan result:
|
|
1002
|
+
# * substring matched → caller emits CLI-missing banner.
|
|
1003
|
+
# * no substring match → caller preserves the documented
|
|
1004
|
+
# "no-policy / no-match => allow" posture (silent allow,
|
|
1005
|
+
# no audit).
|
|
1006
|
+
# - EXIT 2: refuse-HARD. Audit-integrity failure (R7-P1 / codex
|
|
1007
|
+
# round 7). Caller MUST refuse via banner regardless of the
|
|
1008
|
+
# substring-scan verdict — silently allowing would violate the
|
|
1009
|
+
# auditability invariant and let a corrupted audit chain go
|
|
1010
|
+
# unenforced. The helper has already printed an operator-
|
|
1011
|
+
# actionable explainer to stderr (e.g. "audit tail at <path>
|
|
1012
|
+
# line <N> is not valid JSON").
|
|
1013
|
+
#
|
|
1014
|
+
# Args:
|
|
1015
|
+
# $1 = shim name (passed verbatim to bootstrap_allowlist_check)
|
|
1016
|
+
# $2 = the extracted Bash command string
|
|
1017
|
+
# $3 = REA_ROOT (resolved by the shim's halt-check.sh sourcing)
|
|
1018
|
+
_bootstrap_shim_pm_route() {
|
|
1019
|
+
# R3-P1 (codex round 3): pin a local IFS so a hostile parent
|
|
1020
|
+
# environment that exported `IFS=X` cannot reshape the `read -ra`
|
|
1021
|
+
# below. Matches the same defense `bootstrap_allowlist_check`
|
|
1022
|
+
# applies (see line ~736).
|
|
1023
|
+
local IFS=$' \t\n'
|
|
1024
|
+
|
|
1025
|
+
local shim_name="$1"
|
|
1026
|
+
local cmd="$2"
|
|
1027
|
+
local rea_root="$3"
|
|
1028
|
+
|
|
1029
|
+
# Extract argv0 basename via `read -ra` (R3-P1: tab-separator and
|
|
1030
|
+
# leading-whitespace shapes get parsed identically to what the
|
|
1031
|
+
# allowlist itself does).
|
|
1032
|
+
local -a _PM_ARGV
|
|
1033
|
+
read -ra _PM_ARGV <<<"$cmd"
|
|
1034
|
+
if [ "${#_PM_ARGV[@]}" -eq 0 ]; then
|
|
1035
|
+
return 1
|
|
1036
|
+
fi
|
|
1037
|
+
local argv0="${_PM_ARGV[0]}"
|
|
1038
|
+
argv0="${argv0##*/}"
|
|
1039
|
+
case "$argv0" in
|
|
1040
|
+
pnpm|npm|yarn|corepack) ;;
|
|
1041
|
+
*) return 1 ;;
|
|
1042
|
+
esac
|
|
1043
|
+
|
|
1044
|
+
# Resolve project root with realpath. CLAUDE_PROJECT_DIR wins when
|
|
1045
|
+
# available; fall back to REA_ROOT. ast-parser-specialist locked
|
|
1046
|
+
# the cd-pwd-P idiom for realpath resolution on bash 3.2.
|
|
1047
|
+
local proj_root="$rea_root"
|
|
1048
|
+
if [ -n "${CLAUDE_PROJECT_DIR:-}" ]; then
|
|
1049
|
+
if proj_root=$(cd "$CLAUDE_PROJECT_DIR" 2>/dev/null && pwd -P 2>/dev/null); then
|
|
1050
|
+
:
|
|
1051
|
+
else
|
|
1052
|
+
proj_root="$rea_root"
|
|
1053
|
+
fi
|
|
1054
|
+
fi
|
|
1055
|
+
|
|
1056
|
+
local policy_file="$rea_root/.rea/policy.yaml"
|
|
1057
|
+
if [ ! -f "$policy_file" ]; then
|
|
1058
|
+
policy_file="$proj_root/.rea/policy.yaml"
|
|
1059
|
+
fi
|
|
1060
|
+
local pj="$proj_root/package.json"
|
|
1061
|
+
|
|
1062
|
+
local verdict
|
|
1063
|
+
verdict=$(bootstrap_allowlist_check "$shim_name" "$cmd" "$pj" "$policy_file" "$proj_root")
|
|
1064
|
+
case "$verdict" in
|
|
1065
|
+
allow) return 0 ;;
|
|
1066
|
+
# R7-P1: audit-integrity failure — propagate refuse-hard to the
|
|
1067
|
+
# shim caller so it refuses via banner regardless of substring
|
|
1068
|
+
# scan. `bootstrap_allowlist_check` already printed the explainer
|
|
1069
|
+
# to stderr (preserved through the un-suppressed pipe in
|
|
1070
|
+
# `_bootstrap_emit_audit`).
|
|
1071
|
+
refuse-hard) return 2 ;;
|
|
1072
|
+
*) return 1 ;;
|
|
1073
|
+
esac
|
|
1074
|
+
}
|
|
1075
|
+
|