@bookedsolid/rea 0.30.0 → 0.30.1

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.
@@ -88,12 +88,31 @@ rea_invoke() {
88
88
  ENABLED=$(rea_invoke hook policy-get attribution.co_author.enabled 2>/dev/null)
89
89
  REA_RC=$?
90
90
 
91
+ # REA_RC interpretation:
92
+ # 0 — rea CLI ran and returned a value (or empty for an
93
+ # unset key). Use the CLI reads.
94
+ # non-zero — rea CLI unreachable (127 sentinel), too old to know
95
+ # `hook policy-get`, OR the policy YAML is unparseable.
96
+ # In every one of those cases the policy file ITSELF
97
+ # may still be valid block-form YAML, so fall back to
98
+ # the embedded python3 parser. The realistic invalid-
99
+ # config case — `enabled: true` with an empty name or
100
+ # email — is caught downstream by the `[ -z "$CO_NAME" ]`
101
+ # defense-in-depth guard, which exits 0 without
102
+ # augmenting regardless of which reader produced the
103
+ # values. (An earlier 0.30.1 revision fail-closed on
104
+ # non-127 exit codes; codex round 1 showed that
105
+ # regressed the supported stale-CLI / pre-`pnpm i` flow,
106
+ # because an old `rea` exits non-zero exactly like an
107
+ # unparseable policy — the two are indistinguishable by
108
+ # exit code.)
91
109
  if [ "$REA_RC" = "0" ]; then
92
110
  CO_NAME=$(rea_invoke hook policy-get attribution.co_author.name 2>/dev/null || printf '')
93
111
  CO_EMAIL=$(rea_invoke hook policy-get attribution.co_author.email 2>/dev/null || printf '')
94
112
  SKIP_MERGE=$(rea_invoke hook policy-get attribution.co_author.skip_merge 2>/dev/null || printf 'false')
95
113
  elif command -v python3 >/dev/null 2>&1; then
96
- # rea CLI unreachable — fall back to Python block-form parser.
114
+ # rea CLI unreachable / stale / policy unparseable — fall back to the
115
+ # Python block-form parser.
97
116
  CO_AUTHOR_PARSE=$(python3 - "$POLICY_FILE" <<'PY' 2>/dev/null
98
117
  import re
99
118
  import sys
@@ -301,7 +301,7 @@ export function checkSettingsSchema(baseDir, strict) {
301
301
  detail: `malformed JSON: ${e instanceof Error ? e.message : String(e)}`,
302
302
  };
303
303
  }
304
- const result = validateSettings(parsed);
304
+ const result = validateSettings(parsed, { strict });
305
305
  const issues = [];
306
306
  if (!result.parsed) {
307
307
  issues.push(...result.errors.map((e) => `schema: ${e}`));
@@ -460,12 +460,22 @@ export function isGitRepo(baseDir) {
460
460
  */
461
461
  /**
462
462
  * Resolve the active git hooks directory for the doctor's prepare-commit-msg
463
- * check. Mirrors `installCommitMsgHook`'s `readHooksPathFromGit` but
464
- * synchronous (doctor is sync end-to-end). Honors `core.hooksPath` when set
465
- * (husky 9 installs land at `.husky/_/`); falls back to `.git/hooks/`
466
- * otherwise. Codex round 1 P2: prior implementation always looked at
467
- * `.git/hooks/prepare-commit-msg`, false-reporting missing on any consumer
468
- * running husky.
463
+ * check. Mirrors `installPrepareCommitMsgHook`'s resolution order
464
+ * (synchronous doctor is sync end-to-end):
465
+ *
466
+ * 1. `core.hooksPath` explicit operator override (husky 9 installs
467
+ * land at `.husky/_/`). Honored verbatim.
468
+ * 2. `git rev-parse --git-path hooks` — resolves the canonical hooks
469
+ * dir even when `.git` is a FILE (linked worktrees, submodules).
470
+ * 0.30.1 round-5 P2: the prior implementation hardcoded
471
+ * `.git/hooks`, which is wrong for worktrees/submodules where
472
+ * `.git` is a gitdir pointer file, not a directory.
473
+ * 3. `.git/hooks` — last-resort fallback when git itself is missing.
474
+ *
475
+ * The Husky 9 STUB indirection (active file at the resolved path is a
476
+ * `. "${0%/*}/h"` stub that dispatches to `.husky/prepare-commit-msg`)
477
+ * is followed separately inside `checkPrepareCommitMsgHook` via
478
+ * `isHusky9Stub` / `resolveHusky9StubTarget`.
469
479
  */
470
480
  function resolveHooksDirSync(baseDir) {
471
481
  try {
@@ -479,7 +489,20 @@ function resolveHooksDirSync(baseDir) {
479
489
  }
480
490
  }
481
491
  catch {
482
- // git missing or `core.hooksPath` unset — fall through to default.
492
+ // git missing or `core.hooksPath` unset — fall through.
493
+ }
494
+ try {
495
+ const out = execFileSync('git', ['-C', baseDir, 'rev-parse', '--git-path', 'hooks'], {
496
+ encoding: 'utf8',
497
+ stdio: ['ignore', 'pipe', 'ignore'],
498
+ });
499
+ const trimmed = out.trim();
500
+ if (trimmed.length > 0) {
501
+ return path.isAbsolute(trimmed) ? trimmed : path.join(baseDir, trimmed);
502
+ }
503
+ }
504
+ catch {
505
+ // git missing — fall through to the literal default.
483
506
  }
484
507
  return path.join(baseDir, '.git', 'hooks');
485
508
  }
@@ -2066,8 +2066,20 @@ export interface SettingsValidationResult {
2066
2066
  * best-effort scan of any `hooks: { PreToolUse: [...] }` shape we
2067
2067
  * recognize, so the operator still sees traversal + missing-hooks
2068
2068
  * findings.
2069
+ *
2070
+ * `strict` selects the schema:
2071
+ * - `false` (default) — `SettingsSchema`, top-level `.passthrough()`.
2072
+ * Unknown harness keys pass. Used by `rea upgrade` and advisory
2073
+ * `rea doctor`.
2074
+ * - `true` — `SettingsSchemaStrict`, top-level `.strict()`. Unknown
2075
+ * keys fail the parse. Used only by `rea doctor --strict` (the CI
2076
+ * gate path). 0.30.1 round-5 P2: the strict schema existed since
2077
+ * 0.30.0 but `validateSettings` never accepted the selector, so
2078
+ * `rea doctor --strict` silently ran the lenient schema.
2069
2079
  */
2070
- export declare function validateSettings(input: unknown): SettingsValidationResult;
2080
+ export declare function validateSettings(input: unknown, options?: {
2081
+ strict?: boolean;
2082
+ }): SettingsValidationResult;
2071
2083
  /**
2072
2084
  * Cross-check: every name in `EXPECTED_HOOKS` should appear as the
2073
2085
  * basename of at least one `command` across PreToolUse + PostToolUse.
@@ -154,8 +154,18 @@ export function validateNoTraversal(settings) {
154
154
  * best-effort scan of any `hooks: { PreToolUse: [...] }` shape we
155
155
  * recognize, so the operator still sees traversal + missing-hooks
156
156
  * findings.
157
+ *
158
+ * `strict` selects the schema:
159
+ * - `false` (default) — `SettingsSchema`, top-level `.passthrough()`.
160
+ * Unknown harness keys pass. Used by `rea upgrade` and advisory
161
+ * `rea doctor`.
162
+ * - `true` — `SettingsSchemaStrict`, top-level `.strict()`. Unknown
163
+ * keys fail the parse. Used only by `rea doctor --strict` (the CI
164
+ * gate path). 0.30.1 round-5 P2: the strict schema existed since
165
+ * 0.30.0 but `validateSettings` never accepted the selector, so
166
+ * `rea doctor --strict` silently ran the lenient schema.
157
167
  */
158
- export function validateSettings(input) {
168
+ export function validateSettings(input, options = {}) {
159
169
  const result = {
160
170
  parsed: false,
161
171
  settings: null,
@@ -164,7 +174,8 @@ export function validateSettings(input) {
164
174
  missingReaHooks: [],
165
175
  warnings: [],
166
176
  };
167
- const parsed = SettingsSchema.safeParse(input);
177
+ const schema = options.strict === true ? SettingsSchemaStrict : SettingsSchema;
178
+ const parsed = schema.safeParse(input);
168
179
  if (parsed.success) {
169
180
  result.parsed = true;
170
181
  result.settings = parsed.data;
@@ -249,7 +249,7 @@ declare const PolicySchema: z.ZodObject<{
249
249
  attribution: z.ZodOptional<z.ZodObject<{
250
250
  co_author: z.ZodOptional<z.ZodEffects<z.ZodObject<{
251
251
  enabled: z.ZodOptional<z.ZodBoolean>;
252
- name: z.ZodOptional<z.ZodString>;
252
+ name: z.ZodOptional<z.ZodEffects<z.ZodString, string, string>>;
253
253
  email: z.ZodUnion<[z.ZodOptional<z.ZodString>, z.ZodLiteral<"">]>;
254
254
  skip_merge: z.ZodOptional<z.ZodBoolean>;
255
255
  }, "strict", z.ZodTypeAny, {
@@ -231,10 +231,24 @@ const GatewayPolicySchema = z
231
231
  * Stricter validation is the consumer's job — RFC 5322 is too permissive
232
232
  * for an opt-in audit footprint anyway.
233
233
  */
234
+ // 0.30.1 round-5 P2: the `name` value lands verbatim inside a
235
+ // `Co-Authored-By: <name> <email>` git trailer. A newline or carriage
236
+ // return in `name` would split the trailer across lines — git's
237
+ // `interpret-trailers` would drop the continuation and the augmenter
238
+ // could even inject arbitrary extra trailer lines. Reject any ASCII
239
+ // control character (newline, CR, tab, NUL, …) in `name`.
240
+ const CONTROL_CHAR_RE = /[\x00-\x1f\x7f]/;
234
241
  const AttributionCoAuthorSchema = z
235
242
  .object({
236
243
  enabled: z.boolean().optional(),
237
- name: z.string().optional(),
244
+ name: z
245
+ .string()
246
+ .refine((v) => !CONTROL_CHAR_RE.test(v), {
247
+ message: 'attribution.co_author.name must not contain control characters ' +
248
+ '(newlines, tabs, carriage returns) — the value is written verbatim ' +
249
+ 'into a single-line Co-Authored-By git trailer.',
250
+ })
251
+ .optional(),
238
252
  email: z
239
253
  .string()
240
254
  .regex(/^[^\s<>@]+@[^\s<>@]+\.[^\s<>@]+$/, {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bookedsolid/rea",
3
- "version": "0.30.0",
3
+ "version": "0.30.1",
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)",
@@ -88,12 +88,31 @@ rea_invoke() {
88
88
  ENABLED=$(rea_invoke hook policy-get attribution.co_author.enabled 2>/dev/null)
89
89
  REA_RC=$?
90
90
 
91
+ # REA_RC interpretation:
92
+ # 0 — rea CLI ran and returned a value (or empty for an
93
+ # unset key). Use the CLI reads.
94
+ # non-zero — rea CLI unreachable (127 sentinel), too old to know
95
+ # `hook policy-get`, OR the policy YAML is unparseable.
96
+ # In every one of those cases the policy file ITSELF
97
+ # may still be valid block-form YAML, so fall back to
98
+ # the embedded python3 parser. The realistic invalid-
99
+ # config case — `enabled: true` with an empty name or
100
+ # email — is caught downstream by the `[ -z "$CO_NAME" ]`
101
+ # defense-in-depth guard, which exits 0 without
102
+ # augmenting regardless of which reader produced the
103
+ # values. (An earlier 0.30.1 revision fail-closed on
104
+ # non-127 exit codes; codex round 1 showed that
105
+ # regressed the supported stale-CLI / pre-`pnpm i` flow,
106
+ # because an old `rea` exits non-zero exactly like an
107
+ # unparseable policy — the two are indistinguishable by
108
+ # exit code.)
91
109
  if [ "$REA_RC" = "0" ]; then
92
110
  CO_NAME=$(rea_invoke hook policy-get attribution.co_author.name 2>/dev/null || printf '')
93
111
  CO_EMAIL=$(rea_invoke hook policy-get attribution.co_author.email 2>/dev/null || printf '')
94
112
  SKIP_MERGE=$(rea_invoke hook policy-get attribution.co_author.skip_merge 2>/dev/null || printf 'false')
95
113
  elif command -v python3 >/dev/null 2>&1; then
96
- # rea CLI unreachable — fall back to Python block-form parser.
114
+ # rea CLI unreachable / stale / policy unparseable — fall back to the
115
+ # Python block-form parser.
97
116
  CO_AUTHOR_PARSE=$(python3 - "$POLICY_FILE" <<'PY' 2>/dev/null
98
117
  import re
99
118
  import sys