@bookedsolid/rea 0.1.0 → 0.2.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 (90) hide show
  1. package/.husky/commit-msg +130 -0
  2. package/.husky/pre-push +128 -0
  3. package/README.md +5 -5
  4. package/agents/codex-adversarial.md +23 -8
  5. package/commands/codex-review.md +2 -2
  6. package/dist/audit/append.d.ts +62 -0
  7. package/dist/audit/append.js +189 -0
  8. package/dist/audit/codex-event.d.ts +28 -0
  9. package/dist/audit/codex-event.js +15 -0
  10. package/dist/cli/doctor.d.ts +60 -1
  11. package/dist/cli/doctor.js +459 -20
  12. package/dist/cli/index.js +35 -5
  13. package/dist/cli/init.d.ts +13 -0
  14. package/dist/cli/init.js +278 -67
  15. package/dist/cli/install/canonical.d.ts +43 -0
  16. package/dist/cli/install/canonical.js +101 -0
  17. package/dist/cli/install/claude-md.d.ts +48 -0
  18. package/dist/cli/install/claude-md.js +93 -0
  19. package/dist/cli/install/commit-msg.d.ts +30 -0
  20. package/dist/cli/install/commit-msg.js +102 -0
  21. package/dist/cli/install/copy.d.ts +169 -0
  22. package/dist/cli/install/copy.js +455 -0
  23. package/dist/cli/install/fs-safe.d.ts +91 -0
  24. package/dist/cli/install/fs-safe.js +347 -0
  25. package/dist/cli/install/manifest-io.d.ts +12 -0
  26. package/dist/cli/install/manifest-io.js +44 -0
  27. package/dist/cli/install/manifest-schema.d.ts +83 -0
  28. package/dist/cli/install/manifest-schema.js +80 -0
  29. package/dist/cli/install/reagent.d.ts +59 -0
  30. package/dist/cli/install/reagent.js +160 -0
  31. package/dist/cli/install/settings-merge.d.ts +91 -0
  32. package/dist/cli/install/settings-merge.js +239 -0
  33. package/dist/cli/install/sha.d.ts +9 -0
  34. package/dist/cli/install/sha.js +21 -0
  35. package/dist/cli/serve.d.ts +11 -0
  36. package/dist/cli/serve.js +72 -6
  37. package/dist/cli/upgrade.d.ts +67 -0
  38. package/dist/cli/upgrade.js +509 -0
  39. package/dist/gateway/downstream-pool.d.ts +39 -0
  40. package/dist/gateway/downstream-pool.js +93 -0
  41. package/dist/gateway/downstream.d.ts +80 -0
  42. package/dist/gateway/downstream.js +196 -0
  43. package/dist/gateway/middleware/audit-types.d.ts +10 -0
  44. package/dist/gateway/middleware/audit.js +14 -0
  45. package/dist/gateway/middleware/injection.d.ts +59 -2
  46. package/dist/gateway/middleware/injection.js +91 -14
  47. package/dist/gateway/middleware/kill-switch.d.ts +20 -5
  48. package/dist/gateway/middleware/kill-switch.js +57 -35
  49. package/dist/gateway/middleware/redact.d.ts +83 -6
  50. package/dist/gateway/middleware/redact.js +133 -46
  51. package/dist/gateway/observability/codex-probe.d.ts +110 -0
  52. package/dist/gateway/observability/codex-probe.js +234 -0
  53. package/dist/gateway/observability/codex-telemetry.d.ts +93 -0
  54. package/dist/gateway/observability/codex-telemetry.js +221 -0
  55. package/dist/gateway/redact-safe/match-timeout.d.ts +83 -0
  56. package/dist/gateway/redact-safe/match-timeout.js +179 -0
  57. package/dist/gateway/reviewers/claude-self.d.ts +99 -0
  58. package/dist/gateway/reviewers/claude-self.js +316 -0
  59. package/dist/gateway/reviewers/codex.d.ts +64 -0
  60. package/dist/gateway/reviewers/codex.js +80 -0
  61. package/dist/gateway/reviewers/select.d.ts +64 -0
  62. package/dist/gateway/reviewers/select.js +102 -0
  63. package/dist/gateway/reviewers/types.d.ts +85 -0
  64. package/dist/gateway/reviewers/types.js +14 -0
  65. package/dist/gateway/server.d.ts +51 -0
  66. package/dist/gateway/server.js +258 -0
  67. package/dist/gateway/session.d.ts +9 -0
  68. package/dist/gateway/session.js +17 -0
  69. package/dist/policy/loader.d.ts +59 -0
  70. package/dist/policy/loader.js +65 -0
  71. package/dist/policy/profiles.d.ts +80 -0
  72. package/dist/policy/profiles.js +94 -0
  73. package/dist/policy/types.d.ts +38 -0
  74. package/dist/registry/loader.d.ts +98 -0
  75. package/dist/registry/loader.js +153 -0
  76. package/dist/registry/types.d.ts +44 -0
  77. package/dist/registry/types.js +6 -0
  78. package/dist/scripts/read-policy-field.d.ts +36 -0
  79. package/dist/scripts/read-policy-field.js +96 -0
  80. package/hooks/push-review-gate.sh +627 -17
  81. package/package.json +13 -2
  82. package/profiles/bst-internal-no-codex.yaml +40 -0
  83. package/profiles/bst-internal.yaml +23 -0
  84. package/profiles/client-engagement.yaml +23 -0
  85. package/profiles/lit-wc.yaml +17 -0
  86. package/profiles/minimal.yaml +11 -0
  87. package/profiles/open-source-no-codex.yaml +33 -0
  88. package/profiles/open-source.yaml +18 -0
  89. package/scripts/lint-safe-regex.mjs +78 -0
  90. package/scripts/postinstall.mjs +131 -0
@@ -1,10 +1,25 @@
1
1
  import type { Middleware } from './chain.js';
2
2
  /**
3
- * Checks for `.rea/HALT` file. If present, denies the invocation.
3
+ * HALT semantic guarantee:
4
+ * - HALT is read exactly once per invocation, at the top of this middleware layer.
5
+ * - Decision is final for the remainder of the chain; downstream middleware and
6
+ * the terminal never re-check HALT.
7
+ * - Creating HALT mid-flight does NOT cancel in-flight invocations. Remove HALT
8
+ * to re-enable new invocations; outstanding ones complete.
9
+ * - This layer is first in the chain so the decision frames every other layer's
10
+ * view. Do not reorder.
4
11
  *
5
- * SECURITY: Validates HALT is a regular file (not directory/symlink to sensitive file).
6
- * SECURITY: Symlinks must resolve to a target within `.rea/`.
7
- * SECURITY: Caps read size to prevent oversized error strings.
8
- * SECURITY: Fails closed on unexpected errors.
12
+ * Implementation:
13
+ * - Exactly ONE syscall on the HALT file per invocation: `fs.open(path, O_RDONLY)`.
14
+ * There is no preceding stat/exists/lstat call, so there is no TOCTOU window
15
+ * between a check and a subsequent open.
16
+ * - ENOENT on open → HALT absent → proceed with the chain.
17
+ * - Open succeeds → HALT present → deny. The file descriptor is then used
18
+ * (best-effort) to read the reason string for the error message, with the
19
+ * read size capped at {@link MAX_HALT_READ_BYTES}. The denial does NOT depend
20
+ * on the read succeeding.
21
+ * - Any other errno → unknown state → deny (fail-closed).
22
+ * - The decision is recorded on `ctx.metadata.halt_decision` for audit and is
23
+ * never re-consulted by downstream middleware.
9
24
  */
10
25
  export declare function createKillSwitchMiddleware(baseDir: string): Middleware;
@@ -6,53 +6,75 @@ const MAX_HALT_READ_BYTES = 1024;
6
6
  const REA_DIR = '.rea';
7
7
  const HALT_FILE = 'HALT';
8
8
  /**
9
- * Checks for `.rea/HALT` file. If present, denies the invocation.
9
+ * HALT semantic guarantee:
10
+ * - HALT is read exactly once per invocation, at the top of this middleware layer.
11
+ * - Decision is final for the remainder of the chain; downstream middleware and
12
+ * the terminal never re-check HALT.
13
+ * - Creating HALT mid-flight does NOT cancel in-flight invocations. Remove HALT
14
+ * to re-enable new invocations; outstanding ones complete.
15
+ * - This layer is first in the chain so the decision frames every other layer's
16
+ * view. Do not reorder.
10
17
  *
11
- * SECURITY: Validates HALT is a regular file (not directory/symlink to sensitive file).
12
- * SECURITY: Symlinks must resolve to a target within `.rea/`.
13
- * SECURITY: Caps read size to prevent oversized error strings.
14
- * SECURITY: Fails closed on unexpected errors.
18
+ * Implementation:
19
+ * - Exactly ONE syscall on the HALT file per invocation: `fs.open(path, O_RDONLY)`.
20
+ * There is no preceding stat/exists/lstat call, so there is no TOCTOU window
21
+ * between a check and a subsequent open.
22
+ * - ENOENT on open → HALT absent → proceed with the chain.
23
+ * - Open succeeds → HALT present → deny. The file descriptor is then used
24
+ * (best-effort) to read the reason string for the error message, with the
25
+ * read size capped at {@link MAX_HALT_READ_BYTES}. The denial does NOT depend
26
+ * on the read succeeding.
27
+ * - Any other errno → unknown state → deny (fail-closed).
28
+ * - The decision is recorded on `ctx.metadata.halt_decision` for audit and is
29
+ * never re-consulted by downstream middleware.
15
30
  */
16
31
  export function createKillSwitchMiddleware(baseDir) {
17
32
  return async (ctx, next) => {
18
33
  const haltPath = path.join(baseDir, REA_DIR, HALT_FILE);
34
+ let fh;
19
35
  try {
20
- const stat = await fs.stat(haltPath);
21
- if (!stat.isFile()) {
22
- ctx.status = InvocationStatus.Denied;
23
- ctx.error = 'Kill switch active: HALT exists (non-file)';
24
- return;
25
- }
26
- const lstat = await fs.lstat(haltPath);
27
- if (lstat.isSymbolicLink()) {
28
- const target = await fs.realpath(haltPath);
29
- const reaDir = path.join(baseDir, REA_DIR);
30
- if (!target.startsWith(reaDir)) {
31
- ctx.status = InvocationStatus.Denied;
32
- ctx.error = 'Kill switch active: HALT is a symlink outside .rea/';
33
- return;
34
- }
35
- }
36
- const fh = await fs.open(haltPath, fsConstants.O_RDONLY);
37
- try {
38
- const buf = Buffer.alloc(MAX_HALT_READ_BYTES);
39
- const { bytesRead } = await fh.read(buf, 0, MAX_HALT_READ_BYTES, 0);
40
- const reason = buf.subarray(0, bytesRead).toString('utf8').trim();
41
- ctx.status = InvocationStatus.Denied;
42
- ctx.error = `Kill switch active: ${reason}`;
43
- }
44
- finally {
45
- await fh.close();
46
- }
47
- return;
36
+ fh = await fs.open(haltPath, fsConstants.O_RDONLY);
48
37
  }
49
38
  catch (err) {
50
- if (err.code === 'ENOENT') {
39
+ const errno = err.code;
40
+ if (errno === 'ENOENT') {
41
+ // HALT absent at the moment of check. Decision is final — no re-check.
42
+ ctx.metadata.halt_decision = 'absent';
43
+ ctx.metadata.halt_at_invocation = null;
51
44
  await next();
52
45
  return;
53
46
  }
47
+ // Any other errno (EACCES, EPERM, EISDIR on some platforms, EIO, …) is an
48
+ // unknown state. Fail closed: deny the invocation and surface the errno.
54
49
  ctx.status = InvocationStatus.Denied;
55
- ctx.error = `Kill switch check failed: ${err.message}`;
50
+ ctx.error = `Kill switch check failed: ${errno ?? 'unknown'} (${err.message})`;
51
+ ctx.metadata.halt_decision = 'unknown';
52
+ ctx.metadata.halt_at_invocation = null;
53
+ return;
54
+ }
55
+ // Open succeeded → HALT is present → decision is locked to DENY.
56
+ // The subsequent read is best-effort only; it shapes the error message but
57
+ // does NOT influence the decision. Timestamp reflects the moment the
58
+ // decision was made, not the file's mtime.
59
+ ctx.metadata.halt_decision = 'present';
60
+ ctx.metadata.halt_at_invocation = new Date().toISOString();
61
+ let reason = '';
62
+ try {
63
+ const buf = Buffer.alloc(MAX_HALT_READ_BYTES);
64
+ const { bytesRead } = await fh.read(buf, 0, MAX_HALT_READ_BYTES, 0);
65
+ reason = buf.subarray(0, bytesRead).toString('utf8').trim();
66
+ }
67
+ catch {
68
+ // Read failed (e.g., EISDIR on Linux when HALT is a directory). The
69
+ // denial still stands — we just fall back to a generic reason string.
70
+ reason = '';
71
+ }
72
+ finally {
73
+ await fh.close().catch(() => {
74
+ /* closing a dead fd is not actionable — denial already recorded */
75
+ });
56
76
  }
77
+ ctx.status = InvocationStatus.Denied;
78
+ ctx.error = reason ? `Kill switch active: ${reason}` : 'Kill switch active: HALT present';
57
79
  };
58
80
  }
@@ -1,16 +1,93 @@
1
1
  import type { Middleware } from './chain.js';
2
+ import { type SafeRegex, type MatchTimeoutOptions } from '../redact-safe/match-timeout.js';
2
3
  /**
3
- * Redact secrets from a string, returning the redacted string and list of redacted field names.
4
+ * Patterns that match common secret formats.
5
+ * Each pattern has a name (for audit logging) and a regex.
6
+ *
7
+ * SECURITY: Patterns use case-insensitive flag where applicable.
8
+ * SECURITY: Input is sanitized (null bytes stripped) before matching.
9
+ * SECURITY (G3): Every pattern is wrapped in a `SafeRegex` with a per-call
10
+ * timeout so a catastrophic backtracker cannot hang the gateway. See
11
+ * `src/gateway/redact-safe/match-timeout.ts`.
12
+ */
13
+ export declare const SECRET_PATTERNS: ReadonlyArray<{
14
+ name: string;
15
+ pattern: RegExp;
16
+ }>;
17
+ /**
18
+ * Sentinel inserted when a redaction pattern exceeds its timeout budget. The
19
+ * original field is replaced entirely — the middleware never lets a potentially
20
+ * secret-bearing string pass through when the scanner failed to complete.
21
+ */
22
+ export declare const REDACT_TIMEOUT_SENTINEL = "[REDACTED: pattern timeout]";
23
+ /**
24
+ * Identifier for the timeout audit event. Emitted on `ctx.metadata` as an
25
+ * array under this key so multiple timeouts in one invocation are recorded.
26
+ */
27
+ export declare const REDACT_TIMEOUT_METADATA_KEY = "redact.regex_timeout";
28
+ export interface RedactTimeoutEvent {
29
+ event: 'redact.regex_timeout';
30
+ pattern_source: 'default' | 'user';
31
+ pattern_id: string;
32
+ input_bytes: number;
33
+ timeout_ms: number;
34
+ }
35
+ /**
36
+ * A compiled secret pattern — the original `RegExp` plus a `SafeRegex` wrapper
37
+ * that carries the configured timeout + audit callback. Precompiled at
38
+ * middleware-creation time so the per-call worker spawn is the only overhead.
4
39
  */
5
- export declare function redactSecrets(input: string): {
40
+ export interface CompiledSecretPattern {
41
+ name: string;
42
+ source: 'default' | 'user';
43
+ safe: SafeRegex;
44
+ }
45
+ /**
46
+ * Build a list of compiled SECRET_PATTERNS using the provided timeout options.
47
+ * Exported so the middleware factory can reuse it with its own `onTimeout`.
48
+ */
49
+ export declare function compileDefaultSecretPatterns(opts?: MatchTimeoutOptions & {
50
+ source?: 'default' | 'user';
51
+ }): CompiledSecretPattern[];
52
+ /**
53
+ * Redact secrets from a string, returning the redacted string and the list of
54
+ * pattern names that matched. Timeouts are reported via `onTimeout` — the
55
+ * caller is responsible for audit accounting.
56
+ *
57
+ * On timeout for a given pattern, the scanner replaces the entire input with
58
+ * `REDACT_TIMEOUT_SENTINEL` (see the comment above the sentinel) and short-
59
+ * circuits further scanning of that value. This is the safe choice: we cannot
60
+ * let an un-scanned string leak downstream.
61
+ */
62
+ export declare function redactSecrets(input: string, patterns: CompiledSecretPattern[], onTimeout?: (ev: {
63
+ name: string;
64
+ source: 'default' | 'user';
65
+ input: string;
66
+ }) => void): {
6
67
  output: string;
7
68
  redacted: string[];
69
+ timedOut: boolean;
8
70
  };
71
+ export interface RedactMiddlewareOptions {
72
+ /** Timeout budget for each regex test/replace call. Default 100ms. */
73
+ matchTimeoutMs?: number;
74
+ /** Optional user-supplied patterns (loaded from policy). */
75
+ userPatterns?: CompiledSecretPattern[];
76
+ }
9
77
  /**
10
- * Post-execution middleware: scans tool output for secret patterns and redacts them.
78
+ * Build the redact middleware with a configured timeout budget and (optionally)
79
+ * user-supplied patterns loaded from policy. Both default and user patterns are
80
+ * wrapped in `SafeRegex` so every regex the middleware runs is bounded.
11
81
  *
12
- * SECURITY: For non-string results, redaction operates on individual string values
13
- * within the object structure rather than JSON.stringify→replace→JSON.parse, which
14
- * could corrupt the result if a replacement changes JSON structure.
82
+ * SECURITY: For non-string results, redaction operates on individual string
83
+ * values within the object structure rather than JSON.stringify replace
84
+ * JSON.parse, which could corrupt the result if a replacement changes JSON
85
+ * structure.
86
+ */
87
+ export declare function createRedactMiddleware(opts?: RedactMiddlewareOptions): Middleware;
88
+ /**
89
+ * Default-configured redact middleware (100ms timeout, default patterns only).
90
+ * Preserved for callers that don't need to configure; `createRedactMiddleware`
91
+ * is the new canonical factory.
15
92
  */
16
93
  export declare const redactMiddleware: Middleware;
@@ -1,11 +1,15 @@
1
+ import { wrapRegex } from '../redact-safe/match-timeout.js';
1
2
  /**
2
3
  * Patterns that match common secret formats.
3
4
  * Each pattern has a name (for audit logging) and a regex.
4
5
  *
5
6
  * SECURITY: Patterns use case-insensitive flag where applicable.
6
7
  * SECURITY: Input is sanitized (null bytes stripped) before matching.
8
+ * SECURITY (G3): Every pattern is wrapped in a `SafeRegex` with a per-call
9
+ * timeout so a catastrophic backtracker cannot hang the gateway. See
10
+ * `src/gateway/redact-safe/match-timeout.ts`.
7
11
  */
8
- const SECRET_PATTERNS = [
12
+ export const SECRET_PATTERNS = [
9
13
  { name: 'AWS Access Key', pattern: /AKIA[0-9A-Z]{16}/gi },
10
14
  {
11
15
  name: 'AWS Secret Key',
@@ -17,7 +21,11 @@ const SECRET_PATTERNS = [
17
21
  pattern: /(?:api[_-]?key|apikey)\s*[:=]\s*["']?[A-Za-z0-9\-_.]{20,}["']?/gi,
18
22
  },
19
23
  { name: 'Bearer Token', pattern: /bearer\s+[A-Za-z0-9\-_.~+/]+=*/gi },
20
- { name: 'Private Key', pattern: /-----BEGIN\s+(?:RSA\s+|EC\s+|DSA\s+)?PRIVATE\s+KEY-----/gi },
24
+ // PEM armor header canonical format uses single spaces. Bounded repetition
25
+ // avoids the nested-`\s+` ReDoS pattern that safe-regex flags on the broader
26
+ // form (`\s+(?:FOO\s+|BAR\s+)?PRIVATE\s+KEY-----`). PEMs with non-space
27
+ // separators are non-standard and not in our threat model.
28
+ { name: 'Private Key', pattern: /-----BEGIN (?:(?:RSA|EC|DSA) )?PRIVATE KEY-----/gi },
21
29
  { name: 'Discord Token', pattern: /[MN][A-Za-z\d]{23,}\.[\w-]{6}\.[\w-]{27,}/g },
22
30
  // Base64-encoded AWS access key (AKIA... in base64 starts with QUTJQ)
23
31
  { name: 'Base64 AWS Key', pattern: /QUtJQ[A-Za-z0-9+/]{17,}={0,2}/g },
@@ -29,6 +37,17 @@ const SECRET_PATTERNS = [
29
37
  // Hugging Face access tokens
30
38
  { name: 'Hugging Face Token', pattern: /hf_[a-zA-Z0-9]{32,}/g },
31
39
  ];
40
+ /**
41
+ * Sentinel inserted when a redaction pattern exceeds its timeout budget. The
42
+ * original field is replaced entirely — the middleware never lets a potentially
43
+ * secret-bearing string pass through when the scanner failed to complete.
44
+ */
45
+ export const REDACT_TIMEOUT_SENTINEL = '[REDACTED: pattern timeout]';
46
+ /**
47
+ * Identifier for the timeout audit event. Emitted on `ctx.metadata` as an
48
+ * array under this key so multiple timeouts in one invocation are recorded.
49
+ */
50
+ export const REDACT_TIMEOUT_METADATA_KEY = 'redact.regex_timeout';
32
51
  /**
33
52
  * Strip null bytes and other control characters that could break regex matching.
34
53
  */
@@ -36,61 +55,129 @@ function sanitizeInput(input) {
36
55
  return input.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f]/g, '');
37
56
  }
38
57
  /**
39
- * Redact secrets from a string, returning the redacted string and list of redacted field names.
58
+ * Build a list of compiled SECRET_PATTERNS using the provided timeout options.
59
+ * Exported so the middleware factory can reuse it with its own `onTimeout`.
40
60
  */
41
- export function redactSecrets(input) {
61
+ export function compileDefaultSecretPatterns(opts = {}) {
62
+ const source = opts.source ?? 'default';
63
+ return SECRET_PATTERNS.map(({ name, pattern }) => ({
64
+ name,
65
+ source,
66
+ safe: wrapRegex(pattern, opts),
67
+ }));
68
+ }
69
+ /**
70
+ * Redact secrets from a string, returning the redacted string and the list of
71
+ * pattern names that matched. Timeouts are reported via `onTimeout` — the
72
+ * caller is responsible for audit accounting.
73
+ *
74
+ * On timeout for a given pattern, the scanner replaces the entire input with
75
+ * `REDACT_TIMEOUT_SENTINEL` (see the comment above the sentinel) and short-
76
+ * circuits further scanning of that value. This is the safe choice: we cannot
77
+ * let an un-scanned string leak downstream.
78
+ */
79
+ export function redactSecrets(input, patterns, onTimeout) {
42
80
  let output = sanitizeInput(input);
43
81
  const redacted = [];
44
- for (const { name, pattern } of SECRET_PATTERNS) {
45
- // Reset lastIndex for global regexes
46
- pattern.lastIndex = 0;
47
- if (pattern.test(output)) {
48
- pattern.lastIndex = 0;
49
- output = output.replace(pattern, '[REDACTED]');
50
- redacted.push(name);
82
+ for (const { name, source, safe } of patterns) {
83
+ const t = safe.test(output);
84
+ if (t.timedOut) {
85
+ if (onTimeout)
86
+ onTimeout({ name, source, input: output });
87
+ return { output: REDACT_TIMEOUT_SENTINEL, redacted: [name], timedOut: true };
88
+ }
89
+ if (!t.matched)
90
+ continue;
91
+ const r = safe.replace(output, '[REDACTED]');
92
+ if (r.timedOut) {
93
+ if (onTimeout)
94
+ onTimeout({ name, source, input: output });
95
+ return { output: REDACT_TIMEOUT_SENTINEL, redacted: [name], timedOut: true };
51
96
  }
97
+ output = r.output;
98
+ redacted.push(name);
52
99
  }
53
- return { output, redacted };
100
+ return { output, redacted, timedOut: false };
54
101
  }
55
102
  /**
56
- * Post-execution middleware: scans tool output for secret patterns and redacts them.
103
+ * Helper: push a timeout event onto `ctx.metadata[REDACT_TIMEOUT_METADATA_KEY]`.
104
+ * Uses an array so multiple timeouts in one invocation are all recorded.
57
105
  *
58
- * SECURITY: For non-string results, redaction operates on individual string values
59
- * within the object structure rather than JSON.stringify→replace→JSON.parse, which
60
- * could corrupt the result if a replacement changes JSON structure.
106
+ * SECURITY: The input text is NEVER put into metadata — only `input_bytes`.
61
107
  */
62
- export const redactMiddleware = async (ctx, next) => {
63
- // SECURITY: Pre-execution — scan arguments for secrets before they reach the downstream tool.
64
- if (ctx.arguments) {
65
- const argRedacted = [];
66
- redactDeep(ctx.arguments, argRedacted);
67
- if (argRedacted.length > 0) {
68
- ctx.redacted_fields = [...new Set(argRedacted)];
69
- }
70
- }
71
- await next();
72
- if (ctx.result == null)
73
- return;
74
- if (typeof ctx.result === 'string') {
75
- const { output, redacted } = redactSecrets(ctx.result);
76
- if (redacted.length > 0) {
77
- ctx.result = output;
78
- ctx.redacted_fields = [...new Set([...(ctx.redacted_fields ?? []), ...redacted])];
79
- }
80
- return;
108
+ function recordTimeoutOnCtx(ctx, name, source, inputBytes, timeoutMs) {
109
+ const ev = {
110
+ event: 'redact.regex_timeout',
111
+ pattern_source: source,
112
+ pattern_id: name,
113
+ input_bytes: inputBytes,
114
+ timeout_ms: timeoutMs,
115
+ };
116
+ const existing = ctx.metadata[REDACT_TIMEOUT_METADATA_KEY];
117
+ if (Array.isArray(existing)) {
118
+ existing.push(ev);
81
119
  }
82
- // For objects, deeply redact all string values in-place
83
- const allRedacted = [];
84
- redactDeep(ctx.result, allRedacted);
85
- if (allRedacted.length > 0) {
86
- ctx.redacted_fields = [...new Set([...(ctx.redacted_fields ?? []), ...allRedacted])];
120
+ else {
121
+ ctx.metadata[REDACT_TIMEOUT_METADATA_KEY] = [ev];
87
122
  }
88
- };
123
+ }
124
+ /**
125
+ * Build the redact middleware with a configured timeout budget and (optionally)
126
+ * user-supplied patterns loaded from policy. Both default and user patterns are
127
+ * wrapped in `SafeRegex` so every regex the middleware runs is bounded.
128
+ *
129
+ * SECURITY: For non-string results, redaction operates on individual string
130
+ * values within the object structure rather than JSON.stringify → replace →
131
+ * JSON.parse, which could corrupt the result if a replacement changes JSON
132
+ * structure.
133
+ */
134
+ export function createRedactMiddleware(opts = {}) {
135
+ const timeoutMs = opts.matchTimeoutMs ?? 100;
136
+ const defaultPatterns = compileDefaultSecretPatterns({ timeoutMs, source: 'default' });
137
+ const userPatterns = opts.userPatterns ?? [];
138
+ const allPatterns = [...defaultPatterns, ...userPatterns];
139
+ return async (ctx, next) => {
140
+ const recordTimeout = (name, source, input) => {
141
+ recordTimeoutOnCtx(ctx, name, source, Buffer.byteLength(input, 'utf8'), timeoutMs);
142
+ };
143
+ // SECURITY: Pre-execution — scan arguments for secrets before they reach the downstream tool.
144
+ if (ctx.arguments) {
145
+ const argRedacted = [];
146
+ redactDeep(ctx.arguments, argRedacted, allPatterns, recordTimeout);
147
+ if (argRedacted.length > 0) {
148
+ ctx.redacted_fields = [...new Set(argRedacted)];
149
+ }
150
+ }
151
+ await next();
152
+ if (ctx.result == null)
153
+ return;
154
+ if (typeof ctx.result === 'string') {
155
+ const { output, redacted } = redactSecrets(ctx.result, allPatterns, (ev) => recordTimeout(ev.name, ev.source, ev.input));
156
+ if (redacted.length > 0) {
157
+ ctx.result = output;
158
+ ctx.redacted_fields = [...new Set([...(ctx.redacted_fields ?? []), ...redacted])];
159
+ }
160
+ return;
161
+ }
162
+ // For objects, deeply redact all string values in-place
163
+ const allRedacted = [];
164
+ redactDeep(ctx.result, allRedacted, allPatterns, recordTimeout);
165
+ if (allRedacted.length > 0) {
166
+ ctx.redacted_fields = [...new Set([...(ctx.redacted_fields ?? []), ...allRedacted])];
167
+ }
168
+ };
169
+ }
170
+ /**
171
+ * Default-configured redact middleware (100ms timeout, default patterns only).
172
+ * Preserved for callers that don't need to configure; `createRedactMiddleware`
173
+ * is the new canonical factory.
174
+ */
175
+ export const redactMiddleware = createRedactMiddleware();
89
176
  /**
90
177
  * Recursively walk an object/array and redact string values in-place.
91
178
  * Uses a WeakSet to guard against circular references.
92
179
  */
93
- function redactDeep(obj, redacted, seen = new WeakSet()) {
180
+ function redactDeep(obj, redacted, patterns, onTimeout, seen = new WeakSet()) {
94
181
  if (obj == null || typeof obj !== 'object')
95
182
  return;
96
183
  // Guard against circular references
@@ -100,14 +187,14 @@ function redactDeep(obj, redacted, seen = new WeakSet()) {
100
187
  if (Array.isArray(obj)) {
101
188
  for (let i = 0; i < obj.length; i++) {
102
189
  if (typeof obj[i] === 'string') {
103
- const { output, redacted: r } = redactSecrets(obj[i]);
190
+ const { output, redacted: r } = redactSecrets(obj[i], patterns, (ev) => onTimeout(ev.name, ev.source, ev.input));
104
191
  if (r.length > 0) {
105
192
  obj[i] = output;
106
193
  redacted.push(...r);
107
194
  }
108
195
  }
109
196
  else {
110
- redactDeep(obj[i], redacted, seen);
197
+ redactDeep(obj[i], redacted, patterns, onTimeout, seen);
111
198
  }
112
199
  }
113
200
  return;
@@ -115,14 +202,14 @@ function redactDeep(obj, redacted, seen = new WeakSet()) {
115
202
  const record = obj;
116
203
  for (const key of Object.keys(record)) {
117
204
  if (typeof record[key] === 'string') {
118
- const { output, redacted: r } = redactSecrets(record[key]);
205
+ const { output, redacted: r } = redactSecrets(record[key], patterns, (ev) => onTimeout(ev.name, ev.source, ev.input));
119
206
  if (r.length > 0) {
120
207
  record[key] = output;
121
208
  redacted.push(...r);
122
209
  }
123
210
  }
124
211
  else {
125
- redactDeep(record[key], redacted, seen);
212
+ redactDeep(record[key], redacted, patterns, onTimeout, seen);
126
213
  }
127
214
  }
128
215
  }
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Codex availability probe (G11.3).
3
+ *
4
+ * Passive, periodic reachability check for the Codex CLI, used by `rea serve`
5
+ * at startup and by `rea doctor` to surface a one-line status about whether
6
+ * Codex is actually usable right now. This is INTENTIONALLY separate from
7
+ * the reviewer-selection path in `src/gateway/reviewers/select.ts`:
8
+ *
9
+ * - The selector decides which reviewer to run for a specific push (it
10
+ * respects `REA_REVIEWER`, registry pin, policy, etc.).
11
+ * - The probe just reports "is the Codex CLI responding at all?" as a
12
+ * observability signal — never gates a review.
13
+ *
14
+ * Startup must NEVER fail-closed on a probe failure. Codex going away is a
15
+ * degraded state, not a fatal one; the push gate has its own audited escape
16
+ * hatch (`REA_SKIP_CODEX_REVIEW`, G11.1).
17
+ *
18
+ * ## Probe shape
19
+ *
20
+ * 1. `codex --version` — must exit 0 within {@link VERSION_TIMEOUT_MS}.
21
+ * Success → `cli_installed: true` and `version` populated from stdout.
22
+ * 2. Catalog check — see the `tryCatalogProbe` comment below. We try
23
+ * a best-effort authenticated subcommand with a short timeout. If the
24
+ * subcommand is unrecognized by this Codex build, we degrade to "assume
25
+ * authenticated iff cli_installed is true" rather than flagging a false
26
+ * negative.
27
+ *
28
+ * `cli_responsive` is the AND of both. Consumers should treat
29
+ * `cli_responsive: false` as "Codex may be unavailable — plan accordingly",
30
+ * not as authoritative proof that a specific review will fail.
31
+ *
32
+ * ## Concurrency
33
+ *
34
+ * `probe()` is safe to call concurrently. We serialize via a module-local
35
+ * promise; callers queue up behind the in-flight probe instead of kicking off
36
+ * duplicate exec calls. `start()` / `stop()` manage a single `setInterval`
37
+ * with `.unref()` so the probe never pins the event loop.
38
+ */
39
+ /**
40
+ * Narrow test seam mirroring the shape in `src/gateway/reviewers/codex.ts`.
41
+ * Kept module-local; production callers never pass their own.
42
+ */
43
+ export type ExecFileFn = (file: string, args: readonly string[], options: {
44
+ timeout: number;
45
+ }) => Promise<{
46
+ stdout: string;
47
+ stderr: string;
48
+ }>;
49
+ /**
50
+ * Observable Codex state. Serialized verbatim into the doctor output and
51
+ * (later, G5) into a metrics export. Keep field names stable.
52
+ */
53
+ export interface CodexProbeState {
54
+ /** `codex --version` exited 0 within the version-probe timeout. */
55
+ cli_installed: boolean;
56
+ /** Catalog probe succeeded (or was degraded-skipped — see module header). */
57
+ cli_authenticated: boolean;
58
+ /** `cli_installed && cli_authenticated`. */
59
+ cli_responsive: boolean;
60
+ /** ISO-8601 timestamp of the most recent `probe()` completion. */
61
+ last_probe_at: string;
62
+ /** Populated on failure; cleared on the next successful probe. */
63
+ last_error?: string;
64
+ /** Parsed from `codex --version` stdout on success. */
65
+ version?: string;
66
+ }
67
+ export interface CodexProbeOptions {
68
+ execFileFn?: ExecFileFn;
69
+ timeoutInstallMs?: number;
70
+ timeoutCatalogMs?: number;
71
+ }
72
+ export declare class CodexProbe {
73
+ private readonly exec;
74
+ private readonly versionTimeoutMs;
75
+ private readonly catalogTimeoutMs;
76
+ private state;
77
+ private inFlight;
78
+ private timer;
79
+ private readonly listeners;
80
+ constructor(opts?: CodexProbeOptions);
81
+ /**
82
+ * Execute a single probe. Safe to call concurrently — overlapping callers
83
+ * await the single in-flight attempt. Never throws.
84
+ */
85
+ probe(): Promise<CodexProbeState>;
86
+ /** Start periodic polling. Immediate probe, then every `intervalMs`. */
87
+ start(intervalMs?: number): void;
88
+ /** Stop periodic polling. Safe to call even if never started. */
89
+ stop(): void;
90
+ /** Snapshot of the most recent probe state. Never throws. */
91
+ getState(): CodexProbeState;
92
+ /**
93
+ * Subscribe to state transitions. Returns an unsubscribe function. The
94
+ * listener fires only when any observable field changes, not on every
95
+ * tick.
96
+ */
97
+ onStateChange(listener: (state: CodexProbeState) => void): () => void;
98
+ /** Core probe logic. Private — use `probe()`. */
99
+ private runProbe;
100
+ /**
101
+ * Try `codex catalog --json`. Returns:
102
+ * - `{ ok: true }` on exit 0.
103
+ * - `{ ok: false, skipped: true }` when the subcommand is unrecognized
104
+ * (best-effort detection on stderr).
105
+ * - `{ ok: false, skipped: false, error }` on any other failure.
106
+ */
107
+ private tryCatalogProbe;
108
+ /** Persist `next` and fire listeners if anything observable changed. */
109
+ private commit;
110
+ }