@bookedsolid/rea 0.3.0 → 0.5.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 (64) hide show
  1. package/.husky/pre-push +15 -18
  2. package/README.md +41 -1
  3. package/dist/cache/review-cache.d.ts +115 -0
  4. package/dist/cache/review-cache.js +200 -0
  5. package/dist/cli/cache.d.ts +52 -0
  6. package/dist/cli/cache.js +112 -0
  7. package/dist/cli/doctor.d.ts +19 -4
  8. package/dist/cli/doctor.js +172 -5
  9. package/dist/cli/index.js +50 -1
  10. package/dist/cli/init.js +109 -7
  11. package/dist/cli/install/gitignore.d.ts +114 -0
  12. package/dist/cli/install/gitignore.js +356 -0
  13. package/dist/cli/install/pre-push.d.ts +335 -0
  14. package/dist/cli/install/pre-push.js +2818 -0
  15. package/dist/cli/serve.d.ts +64 -0
  16. package/dist/cli/serve.js +270 -2
  17. package/dist/cli/status.d.ts +90 -0
  18. package/dist/cli/status.js +399 -0
  19. package/dist/cli/upgrade.js +20 -0
  20. package/dist/cli/utils.d.ts +4 -0
  21. package/dist/cli/utils.js +4 -0
  22. package/dist/gateway/circuit-breaker.d.ts +17 -0
  23. package/dist/gateway/circuit-breaker.js +32 -3
  24. package/dist/gateway/downstream-pool.d.ts +2 -1
  25. package/dist/gateway/downstream-pool.js +2 -2
  26. package/dist/gateway/downstream.d.ts +39 -3
  27. package/dist/gateway/downstream.js +73 -14
  28. package/dist/gateway/log.d.ts +122 -0
  29. package/dist/gateway/log.js +334 -0
  30. package/dist/gateway/middleware/audit.d.ts +10 -1
  31. package/dist/gateway/middleware/audit.js +26 -1
  32. package/dist/gateway/middleware/blocked-paths.d.ts +0 -9
  33. package/dist/gateway/middleware/blocked-paths.js +439 -67
  34. package/dist/gateway/middleware/injection.d.ts +218 -13
  35. package/dist/gateway/middleware/injection.js +433 -51
  36. package/dist/gateway/middleware/kill-switch.d.ts +10 -1
  37. package/dist/gateway/middleware/kill-switch.js +20 -1
  38. package/dist/gateway/observability/metrics.d.ts +125 -0
  39. package/dist/gateway/observability/metrics.js +321 -0
  40. package/dist/gateway/server.d.ts +19 -0
  41. package/dist/gateway/server.js +99 -15
  42. package/dist/policy/loader.d.ts +23 -0
  43. package/dist/policy/loader.js +30 -0
  44. package/dist/policy/profiles.d.ts +13 -0
  45. package/dist/policy/profiles.js +12 -0
  46. package/dist/policy/types.d.ts +48 -0
  47. package/dist/registry/fingerprint.d.ts +73 -0
  48. package/dist/registry/fingerprint.js +81 -0
  49. package/dist/registry/fingerprints-store.d.ts +62 -0
  50. package/dist/registry/fingerprints-store.js +111 -0
  51. package/dist/registry/interpolate.d.ts +58 -0
  52. package/dist/registry/interpolate.js +121 -0
  53. package/dist/registry/loader.d.ts +2 -2
  54. package/dist/registry/loader.js +22 -1
  55. package/dist/registry/tofu-gate.d.ts +41 -0
  56. package/dist/registry/tofu-gate.js +189 -0
  57. package/dist/registry/tofu.d.ts +111 -0
  58. package/dist/registry/tofu.js +173 -0
  59. package/dist/registry/types.d.ts +9 -1
  60. package/hooks/push-review-gate.sh +185 -1
  61. package/package.json +1 -1
  62. package/profiles/bst-internal-no-codex.yaml +5 -0
  63. package/profiles/bst-internal.yaml +7 -0
  64. package/scripts/tarball-smoke.sh +197 -0
@@ -0,0 +1,173 @@
1
+ /**
2
+ * TOFU classifier — the G7 gate between `.rea/registry.yaml` and the
3
+ * downstream pool.
4
+ *
5
+ * For each server declared in the registry, classify as:
6
+ *
7
+ * - `first-seen` — no entry in `.rea/fingerprints.json`. Record the
8
+ * fingerprint, surface a LOUD block to the operator, allow the server
9
+ * to connect. This is the TOFU trust-on-first-use decision; the
10
+ * loudness is deliberate so a silent poisoning at first install is
11
+ * still visible in stderr / audit / logs.
12
+ *
13
+ * - `unchanged` — fingerprint matches the stored value. Proceed normally.
14
+ *
15
+ * - `drifted` — fingerprint differs from the stored value. Refuse to
16
+ * connect the server unless `REA_ACCEPT_DRIFT` names it for a single
17
+ * boot. The rest of the gateway stays up — other servers remain
18
+ * available, the upstream client just sees a smaller catalog.
19
+ *
20
+ * The audit entry, log line, and stderr block are emitted by the caller
21
+ * (the gateway startup sequence). This module is pure classification plus
22
+ * store updates; keeping it side-effect-free makes it unit-testable
23
+ * without stubbing the filesystem or the audit chain.
24
+ */
25
+ import { fingerprintServer } from './fingerprint.js';
26
+ import { FINGERPRINT_STORE_VERSION } from './fingerprints-store.js';
27
+ function parseAcceptDrift(raw) {
28
+ if (raw === undefined || raw.trim() === '')
29
+ return new Set();
30
+ return new Set(raw
31
+ .split(',')
32
+ .map((s) => s.trim())
33
+ .filter((s) => s.length > 0));
34
+ }
35
+ /**
36
+ * Classify every server in `servers` against the loaded `store`. Pure:
37
+ * does not read or write the filesystem. Returns one classification per
38
+ * server in the same order.
39
+ *
40
+ * ## Rename-with-removal defense (scope: narrow)
41
+ *
42
+ * A name-only lookup is not enough when an attacker rewrites a
43
+ * previously trusted entry AND changes its `name` at the same time: the
44
+ * stored lookup would miss and the tampered entry would land as benign
45
+ * `first-seen`. To close THAT specific shape we compute a rename signal
46
+ * at boot time:
47
+ *
48
+ * - `disappeared = stored_names - registry_names`
49
+ * - `appeared = registry_names - stored_names`
50
+ *
51
+ * If BOTH sets are non-empty in the same boot, at least one declared
52
+ * entry has been renamed-with-removal (stored entry vanished, a new name
53
+ * showed up). In that case every entry in `appeared` is promoted from
54
+ * `first-seen` to `drifted`: the operator MUST explicitly accept it via
55
+ * `REA_ACCEPT_DRIFT=<new-name>` before it connects.
56
+ *
57
+ * ### What this defense does NOT catch
58
+ *
59
+ * If the attacker leaves the old trusted entry in the registry (e.g.
60
+ * flipped `enabled: false`, or left untouched as a decoy) and ADDS a
61
+ * tampered entry under a new name, `disappeared` stays empty, the
62
+ * set-difference heuristic does not fire, and the new entry lands as
63
+ * `first-seen`. That is **not a bypass** of the TOFU contract — it is
64
+ * structurally identical to `operator added a new MCP server`, which
65
+ * TOFU intentionally allows with a LOUD stderr banner demanding the
66
+ * operator's attention. The first-seen banner is the enforcement
67
+ * mechanism for that shape; the rename-with-removal heuristic above is
68
+ * strictly additional coverage for the harder-to-notice shape where the
69
+ * old entry disappears at the same moment the new one arrives.
70
+ *
71
+ * Genuinely additive installs (new entry appended with no concurrent
72
+ * removal) also remain `first-seen` and get the usual LOUD banner.
73
+ *
74
+ * This is strictly additive over the name-based lookup — a name-matched
75
+ * drift (same name, changed config) still classifies as `drifted` via
76
+ * the primary path.
77
+ *
78
+ * The fingerprint itself already includes `server.name` (see
79
+ * `fingerprint.ts` canonicalization), so an attacker cannot make a
80
+ * renamed entry's fingerprint coincide with a stored one under a
81
+ * different name. That means a cross-name fingerprint match would never
82
+ * happen in practice — the set-difference heuristic above is what
83
+ * actually defends the rename-with-removal shape.
84
+ */
85
+ export function classifyServers(servers, store, opts = {}) {
86
+ const bypass = parseAcceptDrift(opts.acceptDrift);
87
+ const registryNames = new Set(servers.map((s) => s.name));
88
+ const storedNames = new Set(Object.keys(store.servers));
89
+ const disappeared = new Set();
90
+ for (const n of storedNames) {
91
+ if (!registryNames.has(n))
92
+ disappeared.add(n);
93
+ }
94
+ const appeared = new Set();
95
+ for (const n of registryNames) {
96
+ if (!storedNames.has(n))
97
+ appeared.add(n);
98
+ }
99
+ // A rename is only plausible when something vanished AND something new
100
+ // appeared in the same boot. Pure additions (disappeared empty) are
101
+ // still benign first-seen.
102
+ const renameDetected = disappeared.size > 0 && appeared.size > 0;
103
+ // When a rename is detected, surface one disappeared stored fingerprint
104
+ // so the drift-block banner and audit entry have a concrete `stored`
105
+ // value to display. The choice is deterministic (first in insertion
106
+ // order from the store object) and documented as representative rather
107
+ // than authoritative — the operator's job is to compare the new entry
108
+ // against .rea/fingerprints.json by hand.
109
+ const representativeStored = renameDetected && disappeared.size > 0
110
+ ? store.servers[[...disappeared][0]]
111
+ : undefined;
112
+ return servers.map((s) => {
113
+ const current = fingerprintServer(s);
114
+ const stored = store.servers[s.name];
115
+ if (stored === undefined) {
116
+ if (renameDetected && appeared.has(s.name)) {
117
+ // Rename-then-tamper defense: promote to drifted. Operator must
118
+ // REA_ACCEPT_DRIFT the new name to let it connect.
119
+ const c = {
120
+ server: s.name,
121
+ verdict: 'drifted',
122
+ current,
123
+ bypassed: bypass.has(s.name),
124
+ };
125
+ if (representativeStored !== undefined)
126
+ c.stored = representativeStored;
127
+ return c;
128
+ }
129
+ return { server: s.name, verdict: 'first-seen', current, bypassed: false };
130
+ }
131
+ if (stored === current) {
132
+ return { server: s.name, verdict: 'unchanged', current, stored, bypassed: false };
133
+ }
134
+ return {
135
+ server: s.name,
136
+ verdict: 'drifted',
137
+ current,
138
+ stored,
139
+ bypassed: bypass.has(s.name),
140
+ };
141
+ });
142
+ }
143
+ /**
144
+ * Merge classifications into an updated store. Applies the TOFU rule:
145
+ *
146
+ * - `first-seen` → add the current fingerprint.
147
+ * - `unchanged` → keep the existing value (no-op).
148
+ * - `drifted` → if bypassed, overwrite with the current fingerprint
149
+ * (operator has authorized the update); otherwise keep
150
+ * the stored value (drift persists across restart until
151
+ * explicitly accepted).
152
+ *
153
+ * Does not prune entries for servers that were removed from the registry —
154
+ * that decision is the operator's, and silently dropping fingerprints
155
+ * would let an attacker rename-then-reinstall a server to reset TOFU state.
156
+ */
157
+ export function updateStore(store, classifications) {
158
+ const next = {
159
+ version: FINGERPRINT_STORE_VERSION,
160
+ servers: { ...store.servers },
161
+ };
162
+ for (const c of classifications) {
163
+ if (c.verdict === 'first-seen') {
164
+ next.servers[c.server] = c.current;
165
+ continue;
166
+ }
167
+ if (c.verdict === 'drifted' && c.bypassed) {
168
+ next.servers[c.server] = c.current;
169
+ }
170
+ // unchanged / drifted-no-bypass → leave store untouched for this server
171
+ }
172
+ return next;
173
+ }
@@ -11,7 +11,15 @@ export interface RegistryServer {
11
11
  command: string;
12
12
  /** Arguments passed to the spawned child process. */
13
13
  args: string[];
14
- /** Environment variables merged onto the child process env. */
14
+ /**
15
+ * Environment variables merged onto the child process env. Values may
16
+ * reference rea-serve's own `process.env` via `${VAR}` syntax — e.g.
17
+ * `{ BOT_TOKEN: '${DISCORD_BOT_TOKEN}' }`. Only the curly-brace form is
18
+ * supported; no `$VAR`, no defaults, no command substitution. If a
19
+ * referenced var is unset at spawn time the affected server fails to
20
+ * start (the rest of the gateway still comes up). See
21
+ * `registry/interpolate.ts` for the full grammar and contract.
22
+ */
15
23
  env: Record<string, string>;
16
24
  /**
17
25
  * Optional opt-in list of operator-env var names to forward into the child.
@@ -56,8 +56,31 @@ fi
56
56
  # ── 4. Parse command ──────────────────────────────────────────────────────────
57
57
  CMD=$(printf '%s' "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null)
58
58
 
59
+ # ── 4a. BUG-008: self-detect git's native pre-push contract ───────────────────
60
+ # When the hook is wired into `.husky/pre-push`, git invokes it with
61
+ # `$1 = remote name`, `$2 = remote url`
62
+ # and delivers one line per refspec on stdin:
63
+ # `<local_ref> <local_sha> <remote_ref> <remote_sha>`
64
+ # The Claude Code PreToolUse wrapper instead delivers JSON on stdin, which is
65
+ # what the jq parse above targets. When jq returns empty, the stdin may in
66
+ # fact be git's pre-push ref-list — sniff the first non-blank line, and if it
67
+ # matches the `<ref> <40-hex> <ref> <40-hex>` shape, synthesize CMD as
68
+ # `git push <remote>` (from argv $1) so the remainder of the gate runs
69
+ # through the pre-push parser in step 6 rather than the argv fallback.
70
+ #
71
+ # Any other stdin shape (empty, random JSON, a non-push tool call) still
72
+ # exits 0 here — the gate is a no-op for non-push Bash calls by design.
73
+ FIRST_STDIN_LINE=$(printf '%s' "$INPUT" | awk 'NF { print; exit }')
59
74
  if [[ -z "$CMD" ]]; then
60
- exit 0
75
+ if [[ -n "$FIRST_STDIN_LINE" ]] \
76
+ && printf '%s' "$FIRST_STDIN_LINE" \
77
+ | grep -qE '^[^[:space:]]+[[:space:]]+[0-9a-f]{40}[[:space:]]+[^[:space:]]+[[:space:]]+[0-9a-f]{40}[[:space:]]*$'; then
78
+ # Git native pre-push path. Remote comes from argv $1 — falls back to
79
+ # `origin` for safety if the hook was invoked without arguments.
80
+ CMD="git push ${1:-origin}"
81
+ else
82
+ exit 0
83
+ fi
61
84
  fi
62
85
 
63
86
  # Only trigger on git push commands
@@ -73,6 +96,167 @@ if [[ -f "$POLICY_FILE" ]]; then
73
96
  fi
74
97
  fi
75
98
 
99
+ # ── 5a. REA_SKIP_PUSH_REVIEW — whole-gate escape hatch ───────────────────────
100
+ # An opt-in bypass for the ENTIRE push-review gate (not just the Codex branch).
101
+ # Exists to unblock consumers when rea itself is broken (as in BUG-009 pre-0.5.0)
102
+ # or a corrupt policy/audit file would otherwise deadlock a push. Requires an
103
+ # explicit non-empty reason; the value of REA_SKIP_PUSH_REVIEW is recorded
104
+ # verbatim in the audit record as the reason.
105
+ #
106
+ # Fail-closed contract matches REA_SKIP_CODEX_REVIEW:
107
+ # - missing dist/audit/append.js → exit 2
108
+ # - missing git identity → exit 2
109
+ # - Node failure → exit 2
110
+ #
111
+ # Audit tool_name is `push.review.skipped`. This is intentionally NOT
112
+ # `codex.review` or `codex.review.skipped` — a skip of the whole gate is a
113
+ # separately-audited event and does not satisfy the Codex-review jq predicate.
114
+ if [[ -n "${REA_SKIP_PUSH_REVIEW:-}" ]]; then
115
+ SKIP_REASON="$REA_SKIP_PUSH_REVIEW"
116
+ AUDIT_APPEND_JS="${REA_ROOT}/dist/audit/append.js"
117
+
118
+ if [[ ! -f "$AUDIT_APPEND_JS" ]]; then
119
+ {
120
+ printf 'PUSH BLOCKED: REA_SKIP_PUSH_REVIEW requires rea to be built.\n'
121
+ printf '\n'
122
+ printf ' REA_SKIP_PUSH_REVIEW is set but %s is missing.\n' "$AUDIT_APPEND_JS"
123
+ printf ' Run: pnpm build\n'
124
+ printf '\n'
125
+ } >&2
126
+ exit 2
127
+ fi
128
+
129
+ # Codex F2: CI-aware refusal. The skip hatch is ambient — any process that
130
+ # can set env vars can flip the gate off with a forged git identity (git
131
+ # config is mutable repo config). In a CI context, refuse by default; only
132
+ # allow if the policy explicitly opted in via review.allow_skip_in_ci=true.
133
+ if [[ -n "${CI:-}" ]]; then
134
+ ALLOW_CI_SKIP=""
135
+ READ_FIELD_JS="${REA_ROOT}/dist/scripts/read-policy-field.js"
136
+ if [[ -f "$READ_FIELD_JS" ]]; then
137
+ ALLOW_CI_SKIP=$(REA_ROOT="$REA_ROOT" node "$READ_FIELD_JS" review.allow_skip_in_ci 2>/dev/null || echo "")
138
+ fi
139
+ if [[ "$ALLOW_CI_SKIP" != "true" ]]; then
140
+ {
141
+ printf 'PUSH BLOCKED: REA_SKIP_PUSH_REVIEW refused in CI context.\n'
142
+ printf '\n'
143
+ printf ' CI env var is set. An unauthenticated env-var bypass in a shared\n'
144
+ printf ' build agent is not trusted. To enable, set\n'
145
+ printf ' review:\n'
146
+ printf ' allow_skip_in_ci: true\n'
147
+ printf ' in .rea/policy.yaml — explicitly authorizing env-var skips in CI.\n'
148
+ printf '\n'
149
+ } >&2
150
+ exit 2
151
+ fi
152
+ fi
153
+
154
+ SKIP_ACTOR=$(cd "$REA_ROOT" && git config user.email 2>/dev/null || echo "")
155
+ if [[ -z "$SKIP_ACTOR" ]]; then
156
+ SKIP_ACTOR=$(cd "$REA_ROOT" && git config user.name 2>/dev/null || echo "")
157
+ fi
158
+ if [[ -z "$SKIP_ACTOR" ]]; then
159
+ {
160
+ printf 'PUSH BLOCKED: REA_SKIP_PUSH_REVIEW requires a git identity.\n'
161
+ printf '\n'
162
+ # shellcheck disable=SC2016 # backticks are literal markdown in user-facing message
163
+ printf ' Neither `git config user.email` nor `git config user.name`\n'
164
+ printf ' is set. The skip audit record would have no actor; refusing\n'
165
+ printf ' to bypass without one.\n'
166
+ printf '\n'
167
+ } >&2
168
+ exit 2
169
+ fi
170
+
171
+ SKIP_BRANCH=$(cd "$REA_ROOT" && git branch --show-current 2>/dev/null || echo "")
172
+ SKIP_HEAD=$(cd "$REA_ROOT" && git rev-parse HEAD 2>/dev/null || echo "")
173
+
174
+ # Codex F2: record OS identity alongside the (mutable, git-sourced) actor so
175
+ # downstream auditors can reconstruct who REALLY invoked the bypass on a
176
+ # shared host. None of these are forgeable from inside the push process alone.
177
+ SKIP_OS_UID=$(id -u 2>/dev/null || echo "")
178
+ SKIP_OS_WHOAMI=$(whoami 2>/dev/null || echo "")
179
+ SKIP_OS_HOST=$(hostname 2>/dev/null || echo "")
180
+ SKIP_OS_PID=$$
181
+ SKIP_OS_PPID=$PPID
182
+ SKIP_OS_PPID_CMD=$(ps -o command= -p "$PPID" 2>/dev/null | head -c 512 || echo "")
183
+ SKIP_OS_TTY=$(tty 2>/dev/null || echo "not-a-tty")
184
+ SKIP_OS_CI="${CI:-}"
185
+
186
+ SKIP_METADATA=$(jq -n \
187
+ --arg head_sha "$SKIP_HEAD" \
188
+ --arg branch "$SKIP_BRANCH" \
189
+ --arg reason "$SKIP_REASON" \
190
+ --arg actor "$SKIP_ACTOR" \
191
+ --arg os_uid "$SKIP_OS_UID" \
192
+ --arg os_whoami "$SKIP_OS_WHOAMI" \
193
+ --arg os_hostname "$SKIP_OS_HOST" \
194
+ --arg os_pid "$SKIP_OS_PID" \
195
+ --arg os_ppid "$SKIP_OS_PPID" \
196
+ --arg os_ppid_cmd "$SKIP_OS_PPID_CMD" \
197
+ --arg os_tty "$SKIP_OS_TTY" \
198
+ --arg os_ci "$SKIP_OS_CI" \
199
+ '{
200
+ head_sha: $head_sha,
201
+ branch: $branch,
202
+ reason: $reason,
203
+ actor: $actor,
204
+ verdict: "skipped",
205
+ os_identity: {
206
+ uid: $os_uid,
207
+ whoami: $os_whoami,
208
+ hostname: $os_hostname,
209
+ pid: $os_pid,
210
+ ppid: $os_ppid,
211
+ ppid_cmd: $os_ppid_cmd,
212
+ tty: $os_tty,
213
+ ci: $os_ci
214
+ }
215
+ }' 2>/dev/null)
216
+
217
+ if [[ -z "$SKIP_METADATA" ]]; then
218
+ {
219
+ printf 'PUSH BLOCKED: REA_SKIP_PUSH_REVIEW could not serialize audit metadata.\n' >&2
220
+ } >&2
221
+ exit 2
222
+ fi
223
+
224
+ REA_ROOT="$REA_ROOT" REA_SKIP_METADATA="$SKIP_METADATA" \
225
+ node --input-type=module -e "
226
+ const mod = await import(process.env.REA_ROOT + '/dist/audit/append.js');
227
+ const metadata = JSON.parse(process.env.REA_SKIP_METADATA);
228
+ await mod.appendAuditRecord(process.env.REA_ROOT, {
229
+ tool_name: 'push.review.skipped',
230
+ server_name: 'rea.escape_hatch',
231
+ status: mod.InvocationStatus.Allowed,
232
+ tier: mod.Tier.Read,
233
+ metadata,
234
+ });
235
+ " 2>/dev/null
236
+ NODE_STATUS=$?
237
+ if [[ "$NODE_STATUS" -ne 0 ]]; then
238
+ {
239
+ printf 'PUSH BLOCKED: REA_SKIP_PUSH_REVIEW audit-append failed (node exit %s).\n' "$NODE_STATUS"
240
+ printf ' Refusing to bypass the push gate without a receipt.\n'
241
+ } >&2
242
+ exit 2
243
+ fi
244
+
245
+ {
246
+ printf '\n'
247
+ printf '== PUSH REVIEW GATE SKIPPED via REA_SKIP_PUSH_REVIEW\n'
248
+ printf ' Reason: %s\n' "$SKIP_REASON"
249
+ printf ' Actor: %s\n' "$SKIP_ACTOR"
250
+ printf ' Branch: %s\n' "${SKIP_BRANCH:-<detached>}"
251
+ printf ' Head: %s\n' "${SKIP_HEAD:-<unknown>}"
252
+ printf ' Audited: .rea/audit.jsonl (tool_name=push.review.skipped)\n'
253
+ printf '\n'
254
+ printf ' This is a gate weakening. Every invocation is permanently audited.\n'
255
+ printf '\n'
256
+ } >&2
257
+ exit 0
258
+ fi
259
+
76
260
  # ── 6. Determine source/target commits for each refspec ──────────────────────
77
261
  # The authoritative source for which commits are being pushed is the pre-push
78
262
  # hook stdin contract: one line per refspec, with fields
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bookedsolid/rea",
3
- "version": "0.3.0",
3
+ "version": "0.5.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)",
@@ -32,6 +32,11 @@ blocked_paths:
32
32
  - SECURITY.md
33
33
  - THREAT_MODEL.md
34
34
  notification_channel: ""
35
+ # G9: Booked-internal consumers retain the stricter 0.2.x posture — a single
36
+ # literal injection match at write/destructive tier denies (does not merely
37
+ # warn). External profiles inherit the schema default `false`.
38
+ injection:
39
+ suspicious_blocks_writes: true
35
40
  context_protection:
36
41
  delegate_to_subagent:
37
42
  - pnpm run build
@@ -15,6 +15,13 @@ blocked_paths:
15
15
  - SECURITY.md
16
16
  - THREAT_MODEL.md
17
17
  notification_channel: ""
18
+ # G9: Booked-internal consumers retain the stricter 0.2.x posture — a single
19
+ # literal injection match at write/destructive tier denies (does not merely
20
+ # warn). External profiles (open-source, client-engagement, minimal, lit-wc)
21
+ # inherit the schema default `false` so upgrading 0.2.x consumers are not
22
+ # silently tightened.
23
+ injection:
24
+ suspicious_blocks_writes: true
18
25
  context_protection:
19
26
  delegate_to_subagent:
20
27
  - pnpm run build
@@ -0,0 +1,197 @@
1
+ #!/usr/bin/env bash
2
+ # tarball-smoke.sh — exercise the packed @bookedsolid/rea tarball end-to-end
3
+ # in an isolated tempdir. Catches packaging regressions (missing files from
4
+ # `files:`, broken exports map, shebang / chmod issues on `bin`, postinstall
5
+ # failures, dependency resolution drift) BEFORE the tarball reaches npm.
6
+ #
7
+ # Must be run from the repo root. Assumes `dist/` has already been built.
8
+ #
9
+ # Runs under CI on every PR and on every push to main; also recommended as a
10
+ # manual gate before hand-authorizing a Changesets release PR merge.
11
+ #
12
+ # ## Developer-run negative probe (optional, not in CI)
13
+ #
14
+ # To verify the tree-equality asserts actually fail loud on a missing shipped
15
+ # file, temporarily drop `commands/` or `.husky/` from `package.json#files[]`,
16
+ # run this script, and confirm it exits non-zero at the install-surface diff
17
+ # step. Revert the `files:` edit before committing. CI does not run this probe
18
+ # because it would mutate package.json.
19
+ #
20
+ # Exit codes:
21
+ # 0 — smoke passed
22
+ # 1 — preflight failure (missing dist, pack failed)
23
+ # 2 — smoke assertion failure (bin missing, init/doctor failed, exports broken)
24
+ set -euo pipefail
25
+
26
+ REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
27
+ cd "$REPO_ROOT"
28
+
29
+ if [ ! -d "dist" ]; then
30
+ echo "[smoke] FAIL — dist/ not found. Run 'pnpm build' first." >&2
31
+ exit 1
32
+ fi
33
+
34
+ PACK_DIR="$(mktemp -d -t rea-smoke-pack-XXXXXX)"
35
+ SMOKE_DIR="$(mktemp -d -t rea-smoke-install-XXXXXX)"
36
+ DIFF_TMP="$(mktemp -t rea-smoke-diff-XXXXXX)"
37
+ cleanup() { rm -rf -- "$PACK_DIR" "$SMOKE_DIR" 2>/dev/null || true; rm -f "$DIFF_TMP"; }
38
+ # EXIT alone misses Ctrl-C / TERM / HUP during local runs, leaving
39
+ # /tmp/rea-smoke-* tempdirs behind. Trap the interrupt signals too.
40
+ trap cleanup EXIT HUP INT TERM
41
+
42
+ echo "[smoke] pack → $PACK_DIR"
43
+ pnpm pack --pack-destination "$PACK_DIR" >/dev/null
44
+ TARBALL="$(ls "$PACK_DIR"/bookedsolid-rea-*.tgz | head -1)"
45
+ if [ -z "$TARBALL" ] || [ ! -f "$TARBALL" ]; then
46
+ echo "[smoke] FAIL — pnpm pack produced no tarball in $PACK_DIR" >&2
47
+ exit 1
48
+ fi
49
+ echo "[smoke] tarball: $(basename "$TARBALL") ($(wc -c < "$TARBALL" | awk '{printf "%.0f KB\n", $1/1024}'))"
50
+
51
+ echo "[smoke] install in $SMOKE_DIR"
52
+ cd "$SMOKE_DIR"
53
+ npm init -y >/dev/null
54
+ npm install --no-audit --no-fund --loglevel=error "$TARBALL"
55
+
56
+ # Drop the temp package.json + lockfile that `npm init -y` + `npm install`
57
+ # wrote. The tempdir must look like a fresh consumer project (no package.json)
58
+ # so `rea init` exercises the same code path a brand-new consumer hits.
59
+ rm -f package.json package-lock.json
60
+ git init -q
61
+
62
+ echo "[smoke] rea --version"
63
+ VERSION_OUT="$(./node_modules/.bin/rea --version)"
64
+ # Pass the repo-root package.json path via argv to avoid interpolating it
65
+ # into a JS string literal — paths with apostrophes, backslashes, or `${...}`
66
+ # expansions would otherwise break the require() call.
67
+ EXPECTED_VERSION="$(node -p "require(process.argv[1]).version" "$REPO_ROOT/package.json")"
68
+ if [ "$VERSION_OUT" != "$EXPECTED_VERSION" ]; then
69
+ echo "[smoke] FAIL — rea --version returned '$VERSION_OUT', expected '$EXPECTED_VERSION'" >&2
70
+ exit 2
71
+ fi
72
+ echo "[smoke] → $VERSION_OUT"
73
+
74
+ echo "[smoke] rea --help"
75
+ ./node_modules/.bin/rea --help >/dev/null
76
+
77
+ echo "[smoke] rea init --yes --profile open-source"
78
+ ./node_modules/.bin/rea init --yes --profile open-source
79
+
80
+ # Verify the installed layout matches what init claims it wrote.
81
+ for expected in .rea/policy.yaml .rea/registry.yaml .claude/settings.json CLAUDE.md .rea/install-manifest.json; do
82
+ if [ ! -f "$expected" ]; then
83
+ echo "[smoke] FAIL — rea init did not create $expected" >&2
84
+ exit 2
85
+ fi
86
+ done
87
+
88
+ # ---------------------------------------------------------------------------
89
+ # Install-surface tree-equality asserts.
90
+ #
91
+ # Prior versions counted `.claude/agents/*.md` and `.claude/hooks/*.sh` and
92
+ # never verified `.claude/commands/` or the shipped `.husky/pre-push`. A
93
+ # tarball that dropped either surface still passed. We now diff sorted file
94
+ # lists: any missing OR extra file fails loud and names the delta.
95
+ #
96
+ # Surfaces under test:
97
+ # 1. .claude/agents/ ↔ repo agents/*.md (flat)
98
+ # 2. .claude/hooks/ ↔ repo hooks/**/*.sh (flat + _lib/)
99
+ # 3. .claude/commands/ ↔ repo commands/*.md (flat)
100
+ # 4. node_modules/.../.husky ↔ repo .husky/{commit-msg,pre-push}
101
+ #
102
+ # The `.husky/` check targets the package tree under node_modules because
103
+ # `rea init` only copies `.husky/*` into the consumer when `.husky/` already
104
+ # exists there. On a fresh consumer (this smoke's default), the hooks live as
105
+ # `.git/hooks/{commit-msg,pre-push}` via the fallback installers. What must
106
+ # ALWAYS be true is that the tarball itself ships the `.husky/` source of
107
+ # truth — without it, husky-using consumers get nothing.
108
+ # ---------------------------------------------------------------------------
109
+
110
+ assert_tree_equal() {
111
+ # $1 — label for error messages
112
+ # $2 — file listing of the source tree (one relative path per line)
113
+ # $3 — file listing of the installed tree (one relative path per line)
114
+ local label="$1" src="$2" dst="$3"
115
+ if [ -z "$src" ] || [ -z "$dst" ]; then
116
+ printf '[smoke] FAIL — empty file listing for %s\n' "$label" >&2
117
+ exit 2
118
+ fi
119
+ if ! diff -u <(printf '%s\n' "$src" | sort -u) <(printf '%s\n' "$dst" | sort -u) > "$DIFF_TMP" 2>&1; then
120
+ echo "[smoke] FAIL — $label differs between source tree and installed tree:" >&2
121
+ cat "$DIFF_TMP" >&2
122
+ exit 2
123
+ fi
124
+ }
125
+
126
+ # 1. agents — flat listing of *.md
127
+ AGENTS_SRC="$(cd "$REPO_ROOT/agents" && find . -maxdepth 1 -type f -name '*.md' | sed 's|^\./||')"
128
+ AGENTS_DST="$(cd "$SMOKE_DIR/.claude/agents" && find . -maxdepth 1 -type f -name '*.md' | sed 's|^\./||')"
129
+ assert_tree_equal ".claude/agents tree" "$AGENTS_SRC" "$AGENTS_DST"
130
+
131
+ # 2. hooks — recursive listing of *.sh (walks hooks/_lib/ too)
132
+ HOOKS_SRC="$(cd "$REPO_ROOT/hooks" && find . -type f -name '*.sh' | sed 's|^\./||')"
133
+ HOOKS_DST="$(cd "$SMOKE_DIR/.claude/hooks" && find . -type f -name '*.sh' | sed 's|^\./||')"
134
+ assert_tree_equal ".claude/hooks tree" "$HOOKS_SRC" "$HOOKS_DST"
135
+
136
+ # 3. commands — flat listing of *.md
137
+ COMMANDS_SRC="$(cd "$REPO_ROOT/commands" && find . -maxdepth 1 -type f -name '*.md' | sed 's|^\./||')"
138
+ COMMANDS_DST="$(cd "$SMOKE_DIR/.claude/commands" && find . -maxdepth 1 -type f -name '*.md' | sed 's|^\./||')"
139
+ assert_tree_equal ".claude/commands tree" "$COMMANDS_SRC" "$COMMANDS_DST"
140
+
141
+ # 4. husky — explicit pre-push + commit-msg existence inside the package
142
+ # tree under node_modules. `rea init` does not copy these into a fresh
143
+ # consumer's root, so we check the shipped copy directly. If either file
144
+ # is missing from the tarball, husky-using consumers silently get zero
145
+ # enforcement on their next `pnpm install`.
146
+ #
147
+ # Executable-bit check is intentionally NOT asserted here: npm's tarball
148
+ # format strips the group/other execute bits from non-`bin:` files on
149
+ # install, so the shipped file lives at mode 0644. What matters is that
150
+ # the installers in commit-msg.ts and pre-push.ts use these as templates
151
+ # and chmod the destination (.git/hooks/... or .husky/...) themselves.
152
+ HUSKY_PKG_DIR="$SMOKE_DIR/node_modules/@bookedsolid/rea/.husky"
153
+ for husky_file in commit-msg pre-push; do
154
+ path="$HUSKY_PKG_DIR/$husky_file"
155
+ if [ ! -f "$path" ]; then
156
+ echo "[smoke] FAIL — tarball missing .husky/$husky_file (expected at $path)" >&2
157
+ exit 2
158
+ fi
159
+ done
160
+
161
+ # On a fresh consumer (no pre-existing .husky/), rea installs the fallback
162
+ # pre-push + commit-msg into .git/hooks/. Assert that path landed too — it is
163
+ # the enforcement surface for this smoke's simulated consumer.
164
+ for git_hook in commit-msg pre-push; do
165
+ path="$SMOKE_DIR/.git/hooks/$git_hook"
166
+ if [ ! -f "$path" ]; then
167
+ echo "[smoke] FAIL — .git/hooks/$git_hook missing after rea init" >&2
168
+ exit 2
169
+ fi
170
+ if [ ! -x "$path" ]; then
171
+ echo "[smoke] FAIL — .git/hooks/$git_hook is not executable" >&2
172
+ exit 2
173
+ fi
174
+ done
175
+
176
+ AGENT_COUNT="$(printf '%s\n' "$AGENTS_DST" | grep -c . || true)"
177
+ HOOK_COUNT="$(printf '%s\n' "$HOOKS_DST" | grep -c . || true)"
178
+ COMMAND_COUNT="$(printf '%s\n' "$COMMANDS_DST" | grep -c . || true)"
179
+ echo "[smoke] → $AGENT_COUNT agents, $HOOK_COUNT hooks, $COMMAND_COUNT commands, .husky/{commit-msg,pre-push} shipped, .git/hooks/{commit-msg,pre-push} installed"
180
+
181
+ echo "[smoke] rea doctor"
182
+ ./node_modules/.bin/rea doctor
183
+
184
+ # Verify every declared public export resolves. If the exports map points at a
185
+ # file that didn't ship in `files:`, this is where we catch it.
186
+ echo "[smoke] resolve exports"
187
+ node --input-type=module -e "
188
+ import('@bookedsolid/rea').then(m => { if (typeof m !== 'object') { console.error('bad root export'); process.exit(2); } });
189
+ import('@bookedsolid/rea/policy').then(m => { if (!m) { console.error('bad /policy export'); process.exit(2); } });
190
+ import('@bookedsolid/rea/middleware').then(m => { if (!m) { console.error('bad /middleware export'); process.exit(2); } });
191
+ import('@bookedsolid/rea/audit').then(m => {
192
+ if (typeof m.appendAuditRecord !== 'function') { console.error('audit.appendAuditRecord not a function'); process.exit(2); }
193
+ });
194
+ "
195
+ echo "[smoke] → root, /policy, /middleware, /audit all resolve"
196
+
197
+ echo "[smoke] PASS"