@bookedsolid/rea 0.10.1 → 0.10.3

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.
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Public entry point for the review-gate TypeScript port (G).
3
+ *
4
+ * ## Scope after Phase 2a (0.10.3)
5
+ *
6
+ * - Phase 1 primitives (args, banner, cache-key, constants, errors,
7
+ * hash, metadata, policy, protected-paths) — pure, dependency-free.
8
+ * - Phase 2a supporting modules (base-resolve, diff, audit, cache) —
9
+ * wrap git subprocesses, wrap audit append/scan, wrap the review-
10
+ * cache lookup. No top-level gate yet; composition lands in Phase 2b.
11
+ *
12
+ * The bash core in `hooks/_lib/push-review-core.sh` continues to run in
13
+ * production until phase 4. These exports are library-level primitives
14
+ * that tests and Phase 2b compose; no behavioral surface is registered
15
+ * for external callers here.
16
+ *
17
+ * See `docs/design/push-review-ts-port.md` for the full plan.
18
+ */
19
+ export * from './args.js';
20
+ export * from './banner.js';
21
+ export * from './cache-key.js';
22
+ export * from './constants.js';
23
+ export * from './errors.js';
24
+ export * from './hash.js';
25
+ export * from './metadata.js';
26
+ export * from './policy.js';
27
+ export * from './protected-paths.js';
28
+ export { currentBranch, diffNameStatus, fullDiff, gitCommonDir, hasCommitLocally, mergeBase, readGitActor, readGitConfig, refExists, resolveHead, resolveRefToSha, resolveRemoteDefaultRef, resolveUpstream, revListCount, spawnGit, type DiffResult, type GitRunResult, type GitRunner, type NameStatusResult, } from './diff.js';
29
+ export { computeInitialTargetLabel, resolveBaseForRefspec, resolveNewBranchBase, stripRefsHeadsOnly, type ResolveBaseDeps, type ResolvedBase, } from './base-resolve.js';
30
+ export { CODEX_REVIEW_SKIPPED_TOOL, ESCAPE_HATCH_SERVER, PUSH_REVIEW_CACHE_ERROR_TOOL, PUSH_REVIEW_CACHE_HIT_TOOL, PUSH_REVIEW_SERVER, PUSH_REVIEW_SKIPPED_TOOL, emitCodexReviewSkipped, emitPushReviewSkipped, hasValidCodexReview, isQualifyingCodexReview, type SkipCodexReviewAuditInput, type SkipPushReviewAuditInput, } from './audit.js';
31
+ export { checkReviewCache, type CacheOutcome, type CheckReviewCacheInput, } from './cache.js';
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Public entry point for the review-gate TypeScript port (G).
3
+ *
4
+ * ## Scope after Phase 2a (0.10.3)
5
+ *
6
+ * - Phase 1 primitives (args, banner, cache-key, constants, errors,
7
+ * hash, metadata, policy, protected-paths) — pure, dependency-free.
8
+ * - Phase 2a supporting modules (base-resolve, diff, audit, cache) —
9
+ * wrap git subprocesses, wrap audit append/scan, wrap the review-
10
+ * cache lookup. No top-level gate yet; composition lands in Phase 2b.
11
+ *
12
+ * The bash core in `hooks/_lib/push-review-core.sh` continues to run in
13
+ * production until phase 4. These exports are library-level primitives
14
+ * that tests and Phase 2b compose; no behavioral surface is registered
15
+ * for external callers here.
16
+ *
17
+ * See `docs/design/push-review-ts-port.md` for the full plan.
18
+ */
19
+ // Phase 1 primitives
20
+ export * from './args.js';
21
+ export * from './banner.js';
22
+ export * from './cache-key.js';
23
+ export * from './constants.js';
24
+ export * from './errors.js';
25
+ export * from './hash.js';
26
+ export * from './metadata.js';
27
+ export * from './policy.js';
28
+ export * from './protected-paths.js';
29
+ // Phase 2a supporting modules — re-export explicit names to avoid
30
+ // double-exporting `computeCacheKey` (which lives in both cache-key.ts
31
+ // and cache.ts; cache.ts's is a strict re-export of Phase 1's).
32
+ export { currentBranch, diffNameStatus, fullDiff, gitCommonDir, hasCommitLocally, mergeBase, readGitActor, readGitConfig, refExists, resolveHead, resolveRefToSha, resolveRemoteDefaultRef, resolveUpstream, revListCount, spawnGit, } from './diff.js';
33
+ export { computeInitialTargetLabel, resolveBaseForRefspec, resolveNewBranchBase, stripRefsHeadsOnly, } from './base-resolve.js';
34
+ export { CODEX_REVIEW_SKIPPED_TOOL, ESCAPE_HATCH_SERVER, PUSH_REVIEW_CACHE_ERROR_TOOL, PUSH_REVIEW_CACHE_HIT_TOOL, PUSH_REVIEW_SERVER, PUSH_REVIEW_SKIPPED_TOOL, emitCodexReviewSkipped, emitPushReviewSkipped, hasValidCodexReview, isQualifyingCodexReview, } from './audit.js';
35
+ export { checkReviewCache, } from './cache.js';
@@ -0,0 +1,98 @@
1
+ /**
2
+ * OS / agent / repo identity collection for audit records.
3
+ *
4
+ * Closes defect M (0.9.4): the bash core's `jq --arg os_pid "$PID"` wrote
5
+ * the pid as a JSON string; the audit consumer expected an integer, and the
6
+ * schema had to tolerate both. The TS port emits pid/ppid as numbers from
7
+ * day one (the JS runtime's `process.pid` is already a `number`), removing
8
+ * the whole class of jq `--arg` vs `--argjson` confusion.
9
+ *
10
+ * ## Why capture this at all
11
+ *
12
+ * A skip-audit record (`REA_SKIP_PUSH_REVIEW` / `REA_SKIP_CODEX_REVIEW`) is
13
+ * the one place the gate is voluntarily weakened. The actor field (from
14
+ * `git config user.email`) is mutable — a process with write access to
15
+ * `.git/config` can stamp any email it likes. Supplementing that with
16
+ * non-forgeable host-level identity (uid, hostname, pid, ppid, tty, CI
17
+ * flag) gives a forensic investigator something to cross-reference when
18
+ * a skip record turns out to have been unauthorized.
19
+ *
20
+ * ## Determinism + testability
21
+ *
22
+ * Every collector is a plain function over `process` / `os` / `node:child_process`
23
+ * so tests can stub the collector's inputs cleanly. The public API takes no
24
+ * arguments (production use) and the helpers are exported for tests.
25
+ */
26
+ export interface OsIdentity {
27
+ /** POSIX uid as a string (empty when not available — Windows). */
28
+ uid: string;
29
+ /** `whoami` output — username or empty string. */
30
+ whoami: string;
31
+ /** `hostname` output. */
32
+ hostname: string;
33
+ /** This process's pid — number, not string (defect M). */
34
+ pid: number;
35
+ /** Parent process's pid — number, not string (defect M). */
36
+ ppid: number;
37
+ /** `ps -o command= -p $PPID` output, capped at 512 bytes. */
38
+ ppid_cmd: string;
39
+ /** `tty` output or `"not-a-tty"`. */
40
+ tty: string;
41
+ /** `CI` env var value or empty string. */
42
+ ci: string;
43
+ }
44
+ /**
45
+ * Collect current-process OS identity. Every individual collector degrades
46
+ * to an empty-string fallback on any exception — matching the bash core's
47
+ * `id -u || echo ""`, `whoami || echo ""`, `hostname || echo ""` pattern
48
+ * (push-review-core.sh §420-422, 426). Codex pass-1 on phase 1 flagged
49
+ * the earlier implementation that called `userInfo()` and `hostname()`
50
+ * outside any try/catch and could therefore throw on hosts with broken
51
+ * NSS/passwd lookups, which would silently block the skip-audit path.
52
+ */
53
+ export declare function collectOsIdentity(): OsIdentity;
54
+ /**
55
+ * Read POSIX uid + whoami. Each field is collected INDEPENDENTLY so a
56
+ * partial-lookup-failure (e.g. LDAP/NSS returns the uid but no passwd
57
+ * entry) still yields one field rather than dropping both. Bash-core
58
+ * parity (push-review-core.sh §420-421): `id -u` and `whoami` are two
59
+ * separate invocations, each with its own `|| echo ""` fallback.
60
+ *
61
+ * Codex pass-2 on phase 1 flagged the prior single-`userInfo()` version:
62
+ * a broken NSS lookup zeroed out both fields at once, weakening forensic
63
+ * metadata on shared hosts. We now prefer the POSIX primitives
64
+ * (`os.userInfo()` internally reads the passwd entry) but isolate the
65
+ * failures so uid and whoami cannot both disappear together when only
66
+ * one of them is actually unavailable.
67
+ */
68
+ export declare function readUidAndWhoami(): {
69
+ uid: string;
70
+ whoami: string;
71
+ };
72
+ /**
73
+ * Read `hostname` via `os.hostname`. Returns an empty string on any error.
74
+ * Exported for unit tests.
75
+ */
76
+ export declare function readHostname(): string;
77
+ /**
78
+ * Read `ps -o command= -p <ppid>` safely. Returns the (truncated) command
79
+ * or an empty string on any failure.
80
+ *
81
+ * Security: args are passed as an array, never interpolated into a shell
82
+ * string. `ps` is the only executable spawned.
83
+ */
84
+ export declare function readPpidCommand(ppid: number): string;
85
+ /**
86
+ * Return the actual controlling tty path (e.g. `/dev/ttys001`) when one
87
+ * exists, or the literal string `not-a-tty` otherwise. Bash-core parity
88
+ * (push-review-core.sh §426): `tty 2>/dev/null || echo "not-a-tty"`.
89
+ *
90
+ * Codex pass-1 on phase 1 flagged the earlier `/dev/tty` literal as a
91
+ * parity regression — the audit consumer expects the real device path so
92
+ * forensic tooling can distinguish tty1 from tty2 on the same host.
93
+ *
94
+ * Implementation: we shell out to `tty(1)` exactly as bash does. On
95
+ * systems without `tty` (distroless, minimal Alpine without coreutils-
96
+ * full) we degrade to `not-a-tty`.
97
+ */
98
+ export declare function readTty(): string;
@@ -0,0 +1,158 @@
1
+ /**
2
+ * OS / agent / repo identity collection for audit records.
3
+ *
4
+ * Closes defect M (0.9.4): the bash core's `jq --arg os_pid "$PID"` wrote
5
+ * the pid as a JSON string; the audit consumer expected an integer, and the
6
+ * schema had to tolerate both. The TS port emits pid/ppid as numbers from
7
+ * day one (the JS runtime's `process.pid` is already a `number`), removing
8
+ * the whole class of jq `--arg` vs `--argjson` confusion.
9
+ *
10
+ * ## Why capture this at all
11
+ *
12
+ * A skip-audit record (`REA_SKIP_PUSH_REVIEW` / `REA_SKIP_CODEX_REVIEW`) is
13
+ * the one place the gate is voluntarily weakened. The actor field (from
14
+ * `git config user.email`) is mutable — a process with write access to
15
+ * `.git/config` can stamp any email it likes. Supplementing that with
16
+ * non-forgeable host-level identity (uid, hostname, pid, ppid, tty, CI
17
+ * flag) gives a forensic investigator something to cross-reference when
18
+ * a skip record turns out to have been unauthorized.
19
+ *
20
+ * ## Determinism + testability
21
+ *
22
+ * Every collector is a plain function over `process` / `os` / `node:child_process`
23
+ * so tests can stub the collector's inputs cleanly. The public API takes no
24
+ * arguments (production use) and the helpers are exported for tests.
25
+ */
26
+ import { hostname, userInfo } from 'node:os';
27
+ import { spawnSync } from 'node:child_process';
28
+ /**
29
+ * Collect current-process OS identity. Every individual collector degrades
30
+ * to an empty-string fallback on any exception — matching the bash core's
31
+ * `id -u || echo ""`, `whoami || echo ""`, `hostname || echo ""` pattern
32
+ * (push-review-core.sh §420-422, 426). Codex pass-1 on phase 1 flagged
33
+ * the earlier implementation that called `userInfo()` and `hostname()`
34
+ * outside any try/catch and could therefore throw on hosts with broken
35
+ * NSS/passwd lookups, which would silently block the skip-audit path.
36
+ */
37
+ export function collectOsIdentity() {
38
+ const { uid, whoami } = readUidAndWhoami();
39
+ const host = readHostname();
40
+ const pid = process.pid;
41
+ const ppid = process.ppid;
42
+ const ppid_cmd = readPpidCommand(ppid);
43
+ const tty = readTty();
44
+ const ci = process.env['CI'] ?? '';
45
+ return { uid, whoami, hostname: host, pid, ppid, ppid_cmd, tty, ci };
46
+ }
47
+ /**
48
+ * Read POSIX uid + whoami. Each field is collected INDEPENDENTLY so a
49
+ * partial-lookup-failure (e.g. LDAP/NSS returns the uid but no passwd
50
+ * entry) still yields one field rather than dropping both. Bash-core
51
+ * parity (push-review-core.sh §420-421): `id -u` and `whoami` are two
52
+ * separate invocations, each with its own `|| echo ""` fallback.
53
+ *
54
+ * Codex pass-2 on phase 1 flagged the prior single-`userInfo()` version:
55
+ * a broken NSS lookup zeroed out both fields at once, weakening forensic
56
+ * metadata on shared hosts. We now prefer the POSIX primitives
57
+ * (`os.userInfo()` internally reads the passwd entry) but isolate the
58
+ * failures so uid and whoami cannot both disappear together when only
59
+ * one of them is actually unavailable.
60
+ */
61
+ export function readUidAndWhoami() {
62
+ let uid = '';
63
+ let whoami = '';
64
+ try {
65
+ // Node exposes the raw numeric uid via process.getuid() on POSIX —
66
+ // this goes through the kernel, not through passwd. If it throws
67
+ // (Windows), the fallback is '' and we still try whoami below.
68
+ const getuid = process.getuid;
69
+ if (typeof getuid === 'function') {
70
+ const raw = getuid.call(process);
71
+ if (typeof raw === 'number' && raw >= 0)
72
+ uid = String(raw);
73
+ }
74
+ }
75
+ catch {
76
+ // swallow — uid stays ''
77
+ }
78
+ try {
79
+ const info = userInfo({ encoding: 'utf8' });
80
+ whoami = info.username ?? '';
81
+ // If the kernel-uid probe above failed but userInfo() succeeded, use
82
+ // its uid as a secondary source.
83
+ if (uid.length === 0 && typeof info.uid === 'number' && info.uid >= 0) {
84
+ uid = String(info.uid);
85
+ }
86
+ }
87
+ catch {
88
+ // swallow — whoami stays ''
89
+ }
90
+ return { uid, whoami };
91
+ }
92
+ /**
93
+ * Read `hostname` via `os.hostname`. Returns an empty string on any error.
94
+ * Exported for unit tests.
95
+ */
96
+ export function readHostname() {
97
+ try {
98
+ return hostname();
99
+ }
100
+ catch {
101
+ return '';
102
+ }
103
+ }
104
+ /**
105
+ * Read `ps -o command= -p <ppid>` safely. Returns the (truncated) command
106
+ * or an empty string on any failure.
107
+ *
108
+ * Security: args are passed as an array, never interpolated into a shell
109
+ * string. `ps` is the only executable spawned.
110
+ */
111
+ export function readPpidCommand(ppid) {
112
+ if (!Number.isFinite(ppid) || ppid <= 0)
113
+ return '';
114
+ try {
115
+ const result = spawnSync('ps', ['-o', 'command=', '-p', String(ppid)], {
116
+ encoding: 'utf8',
117
+ timeout: 2_000,
118
+ });
119
+ if (result.status !== 0)
120
+ return '';
121
+ const out = (result.stdout ?? '').replace(/\n+$/, '');
122
+ return out.slice(0, 512);
123
+ }
124
+ catch {
125
+ return '';
126
+ }
127
+ }
128
+ /**
129
+ * Return the actual controlling tty path (e.g. `/dev/ttys001`) when one
130
+ * exists, or the literal string `not-a-tty` otherwise. Bash-core parity
131
+ * (push-review-core.sh §426): `tty 2>/dev/null || echo "not-a-tty"`.
132
+ *
133
+ * Codex pass-1 on phase 1 flagged the earlier `/dev/tty` literal as a
134
+ * parity regression — the audit consumer expects the real device path so
135
+ * forensic tooling can distinguish tty1 from tty2 on the same host.
136
+ *
137
+ * Implementation: we shell out to `tty(1)` exactly as bash does. On
138
+ * systems without `tty` (distroless, minimal Alpine without coreutils-
139
+ * full) we degrade to `not-a-tty`.
140
+ */
141
+ export function readTty() {
142
+ try {
143
+ const result = spawnSync('tty', [], {
144
+ encoding: 'utf8',
145
+ timeout: 2_000,
146
+ stdio: ['inherit', 'pipe', 'pipe'],
147
+ });
148
+ if (result.status === 0) {
149
+ const out = (result.stdout ?? '').replace(/\n+$/, '');
150
+ if (out.length > 0)
151
+ return out;
152
+ }
153
+ }
154
+ catch {
155
+ // fall through to the not-a-tty fallback
156
+ }
157
+ return 'not-a-tty';
158
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Policy accessors for the review-gate modules.
3
+ *
4
+ * The bash core resolves `review.codex_required` via `node dist/scripts/read-
5
+ * policy-field.js` and evaluates `REA_SKIP_PUSH_REVIEW` / `REA_SKIP_CODEX_REVIEW`
6
+ * directly from env vars. The TS port composes the same behavior over the
7
+ * already-TS `loadPolicy` helper, removing the fork/exec hop and the
8
+ * exit-code-parsing that came with it.
9
+ *
10
+ * Fail-closed: when the policy file is malformed or unreadable, the
11
+ * returned `codex_required` is `true`. This matches the bash core's
12
+ * "treating as true" path (design §2, security carry-forward).
13
+ */
14
+ import type { Policy } from '../../policy/types.js';
15
+ export interface ResolvedPolicy {
16
+ /** Resolved `review.codex_required`; true when malformed/absent. */
17
+ codex_required: boolean;
18
+ /** Resolved `review.allow_skip_in_ci`; false when absent. */
19
+ allow_skip_in_ci: boolean;
20
+ /** Full policy (undefined when load failed — caller emits a WARN). */
21
+ policy: Policy | null;
22
+ /** Warning text from the loader, if any — surfaced to stderr by the caller. */
23
+ warning: string | null;
24
+ }
25
+ /**
26
+ * Resolve review-related policy fields. Never throws — any error path
27
+ * returns `codex_required: true` with a `warning` populated so the caller
28
+ * can decide whether to echo it.
29
+ */
30
+ export declare function resolveReviewPolicy(baseDir: string): ResolvedPolicy;
31
+ /**
32
+ * Skip-env evaluation. The bash core reads `REA_SKIP_PUSH_REVIEW` +
33
+ * `REA_SKIP_CODEX_REVIEW` with simple non-empty semantics. We preserve
34
+ * that exactly: any non-empty value triggers the skip path, and the
35
+ * VALUE is used as the skip reason. Empty or unset = no skip.
36
+ */
37
+ export interface SkipEnv {
38
+ /** `REA_SKIP_PUSH_REVIEW` value or null. */
39
+ push_review_reason: string | null;
40
+ /** `REA_SKIP_CODEX_REVIEW` value or null. */
41
+ codex_review_reason: string | null;
42
+ }
43
+ export declare function readSkipEnv(env?: NodeJS.ProcessEnv): SkipEnv;
44
+ /**
45
+ * True iff `CI` env is set to a non-empty value. The bash core checks
46
+ * `[[ -n "${CI:-}" ]]` — we match that.
47
+ */
48
+ export declare function isCiContext(env?: NodeJS.ProcessEnv): boolean;
49
+ /**
50
+ * Legacy-bash kill switch (design §11.2). When `REA_LEGACY_PUSH_REVIEW=1`,
51
+ * the CLI entry point delegates to the preserved bash core for one
52
+ * release window. Advertised in `rea doctor`; sunset after 90 days of
53
+ * clean 0.11.x running on canaries.
54
+ */
55
+ export declare function isLegacyBashKillSwitchOn(env?: NodeJS.ProcessEnv): boolean;
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Policy accessors for the review-gate modules.
3
+ *
4
+ * The bash core resolves `review.codex_required` via `node dist/scripts/read-
5
+ * policy-field.js` and evaluates `REA_SKIP_PUSH_REVIEW` / `REA_SKIP_CODEX_REVIEW`
6
+ * directly from env vars. The TS port composes the same behavior over the
7
+ * already-TS `loadPolicy` helper, removing the fork/exec hop and the
8
+ * exit-code-parsing that came with it.
9
+ *
10
+ * Fail-closed: when the policy file is malformed or unreadable, the
11
+ * returned `codex_required` is `true`. This matches the bash core's
12
+ * "treating as true" path (design §2, security carry-forward).
13
+ */
14
+ import { loadPolicy } from '../../policy/loader.js';
15
+ /**
16
+ * Resolve review-related policy fields. Never throws — any error path
17
+ * returns `codex_required: true` with a `warning` populated so the caller
18
+ * can decide whether to echo it.
19
+ */
20
+ export function resolveReviewPolicy(baseDir) {
21
+ try {
22
+ const policy = loadPolicy(baseDir);
23
+ const review = policy.review;
24
+ const codex_required = review?.codex_required === false ? false : true;
25
+ const allow_skip_in_ci = review?.allow_skip_in_ci === true;
26
+ return {
27
+ codex_required,
28
+ allow_skip_in_ci,
29
+ policy,
30
+ warning: null,
31
+ };
32
+ }
33
+ catch (err) {
34
+ const msg = err instanceof Error ? err.message : String(err);
35
+ // Policy-file-not-found is a first-run condition for consumers running
36
+ // the gate before `rea init`; treat codex_required as true and surface
37
+ // the warning so the operator knows what state the gate is in.
38
+ return {
39
+ codex_required: true,
40
+ allow_skip_in_ci: false,
41
+ policy: null,
42
+ warning: `review-gate: could not load .rea/policy.yaml — ${msg}. Treating codex_required=true (fail-closed).`,
43
+ };
44
+ }
45
+ }
46
+ export function readSkipEnv(env = process.env) {
47
+ const pr = env['REA_SKIP_PUSH_REVIEW'];
48
+ const cr = env['REA_SKIP_CODEX_REVIEW'];
49
+ return {
50
+ push_review_reason: typeof pr === 'string' && pr.length > 0 ? pr : null,
51
+ codex_review_reason: typeof cr === 'string' && cr.length > 0 ? cr : null,
52
+ };
53
+ }
54
+ /**
55
+ * True iff `CI` env is set to a non-empty value. The bash core checks
56
+ * `[[ -n "${CI:-}" ]]` — we match that.
57
+ */
58
+ export function isCiContext(env = process.env) {
59
+ const v = env['CI'];
60
+ return typeof v === 'string' && v.length > 0;
61
+ }
62
+ /**
63
+ * Legacy-bash kill switch (design §11.2). When `REA_LEGACY_PUSH_REVIEW=1`,
64
+ * the CLI entry point delegates to the preserved bash core for one
65
+ * release window. Advertised in `rea doctor`; sunset after 90 days of
66
+ * clean 0.11.x running on canaries.
67
+ */
68
+ export function isLegacyBashKillSwitchOn(env = process.env) {
69
+ const v = env['REA_LEGACY_PUSH_REVIEW'];
70
+ return typeof v === 'string' && v.length > 0 && v !== '0';
71
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Protected-path detection. Given a `git diff --name-status` output blob,
3
+ * return true iff any change touches one of the prefixes in
4
+ * `PROTECTED_PATH_PREFIXES`.
5
+ *
6
+ * ## Why this is a dedicated module
7
+ *
8
+ * The bash core uses `awk -v re='^(src/gateway/...)' '{...}'` inline in
9
+ * the main gate loop (push-review-core.sh:904-923). That regex is
10
+ * duplicated in `.husky/pre-push` (the native-git shim) and in at least
11
+ * two places in THREAT_MODEL.md. A single TS helper with a grep-able
12
+ * constant in `constants.ts` removes the drift risk.
13
+ *
14
+ * ## Input shape
15
+ *
16
+ * `git diff --name-status <merge_base>..<local_sha>` output. Each line is:
17
+ * <STATUS>\t<path1>[\t<path2>]
18
+ * STATUS is one letter, possibly followed by a similarity score for
19
+ * rename/copy (`R100`, `C95`). STATUS letters we care about: A, C, D, M,
20
+ * R, T, U — the bash core's `status !~ /^[ACDMRTU]/` filter. We match
21
+ * that exactly.
22
+ */
23
+ /**
24
+ * Parse a single `git diff --name-status` line and extract the paths that
25
+ * matter for protected-path detection. Rename (`R`) and copy (`C`) lines
26
+ * carry two paths separated by tabs; both are checked against the
27
+ * protected-path set.
28
+ *
29
+ * Returns an empty array for irrelevant status letters or malformed lines.
30
+ */
31
+ export declare function extractPathsFromStatusLine(line: string): string[];
32
+ /**
33
+ * True iff `path` starts with one of the protected-path prefixes. Exported
34
+ * for unit tests; callers should usually use `diffTouchesProtectedPaths`.
35
+ */
36
+ export declare function isProtectedPath(filePath: string): boolean;
37
+ /**
38
+ * True iff the given `git diff --name-status` output contains at least
39
+ * one protected-path hit. Returns the set of hit paths (deduped) for
40
+ * audit-record metadata.
41
+ */
42
+ export interface ProtectedPathScanResult {
43
+ hit: boolean;
44
+ paths: string[];
45
+ }
46
+ export declare function scanNameStatusForProtectedPaths(nameStatusOutput: string): ProtectedPathScanResult;
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Protected-path detection. Given a `git diff --name-status` output blob,
3
+ * return true iff any change touches one of the prefixes in
4
+ * `PROTECTED_PATH_PREFIXES`.
5
+ *
6
+ * ## Why this is a dedicated module
7
+ *
8
+ * The bash core uses `awk -v re='^(src/gateway/...)' '{...}'` inline in
9
+ * the main gate loop (push-review-core.sh:904-923). That regex is
10
+ * duplicated in `.husky/pre-push` (the native-git shim) and in at least
11
+ * two places in THREAT_MODEL.md. A single TS helper with a grep-able
12
+ * constant in `constants.ts` removes the drift risk.
13
+ *
14
+ * ## Input shape
15
+ *
16
+ * `git diff --name-status <merge_base>..<local_sha>` output. Each line is:
17
+ * <STATUS>\t<path1>[\t<path2>]
18
+ * STATUS is one letter, possibly followed by a similarity score for
19
+ * rename/copy (`R100`, `C95`). STATUS letters we care about: A, C, D, M,
20
+ * R, T, U — the bash core's `status !~ /^[ACDMRTU]/` filter. We match
21
+ * that exactly.
22
+ */
23
+ import { PROTECTED_PATH_PREFIXES } from './constants.js';
24
+ /** Set of single-letter status codes the gate cares about. */
25
+ const RELEVANT_STATUS = new Set(['A', 'C', 'D', 'M', 'R', 'T', 'U']);
26
+ /**
27
+ * Parse a single `git diff --name-status` line and extract the paths that
28
+ * matter for protected-path detection. Rename (`R`) and copy (`C`) lines
29
+ * carry two paths separated by tabs; both are checked against the
30
+ * protected-path set.
31
+ *
32
+ * Returns an empty array for irrelevant status letters or malformed lines.
33
+ */
34
+ export function extractPathsFromStatusLine(line) {
35
+ if (line.length === 0)
36
+ return [];
37
+ const parts = line.split('\t');
38
+ if (parts.length < 2)
39
+ return [];
40
+ const status = parts[0] ?? '';
41
+ if (status.length === 0)
42
+ return [];
43
+ const statusLetter = status[0];
44
+ if (statusLetter === undefined || !RELEVANT_STATUS.has(statusLetter)) {
45
+ return [];
46
+ }
47
+ return parts.slice(1).filter((p) => p.length > 0);
48
+ }
49
+ /**
50
+ * True iff `path` starts with one of the protected-path prefixes. Exported
51
+ * for unit tests; callers should usually use `diffTouchesProtectedPaths`.
52
+ */
53
+ export function isProtectedPath(filePath) {
54
+ for (const prefix of PROTECTED_PATH_PREFIXES) {
55
+ if (filePath.startsWith(prefix))
56
+ return true;
57
+ // A bare `.rea` or `hooks` path (no trailing slash) is a directory
58
+ // boundary match — `.rea/audit.jsonl` passes, `my-rea.config` does
59
+ // not. startsWith on the prefix-with-slash enforces that naturally.
60
+ }
61
+ return false;
62
+ }
63
+ export function scanNameStatusForProtectedPaths(nameStatusOutput) {
64
+ if (nameStatusOutput.length === 0) {
65
+ return { hit: false, paths: [] };
66
+ }
67
+ const hits = new Set();
68
+ for (const line of nameStatusOutput.split('\n')) {
69
+ const paths = extractPathsFromStatusLine(line);
70
+ for (const p of paths) {
71
+ if (isProtectedPath(p))
72
+ hits.add(p);
73
+ }
74
+ }
75
+ return { hit: hits.size > 0, paths: Array.from(hits).sort() };
76
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bookedsolid/rea",
3
- "version": "0.10.1",
3
+ "version": "0.10.3",
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)",