@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.
- package/.husky/commit-msg +130 -0
- package/.husky/pre-push +128 -0
- package/README.md +5 -5
- package/agents/codex-adversarial.md +23 -8
- package/commands/codex-review.md +2 -2
- package/dist/audit/append.d.ts +62 -0
- package/dist/audit/append.js +189 -0
- package/dist/audit/codex-event.d.ts +28 -0
- package/dist/audit/codex-event.js +15 -0
- package/dist/cli/doctor.d.ts +60 -1
- package/dist/cli/doctor.js +459 -20
- package/dist/cli/index.js +35 -5
- package/dist/cli/init.d.ts +13 -0
- package/dist/cli/init.js +278 -67
- package/dist/cli/install/canonical.d.ts +43 -0
- package/dist/cli/install/canonical.js +101 -0
- package/dist/cli/install/claude-md.d.ts +48 -0
- package/dist/cli/install/claude-md.js +93 -0
- package/dist/cli/install/commit-msg.d.ts +30 -0
- package/dist/cli/install/commit-msg.js +102 -0
- package/dist/cli/install/copy.d.ts +169 -0
- package/dist/cli/install/copy.js +455 -0
- package/dist/cli/install/fs-safe.d.ts +91 -0
- package/dist/cli/install/fs-safe.js +347 -0
- package/dist/cli/install/manifest-io.d.ts +12 -0
- package/dist/cli/install/manifest-io.js +44 -0
- package/dist/cli/install/manifest-schema.d.ts +83 -0
- package/dist/cli/install/manifest-schema.js +80 -0
- package/dist/cli/install/reagent.d.ts +59 -0
- package/dist/cli/install/reagent.js +160 -0
- package/dist/cli/install/settings-merge.d.ts +91 -0
- package/dist/cli/install/settings-merge.js +239 -0
- package/dist/cli/install/sha.d.ts +9 -0
- package/dist/cli/install/sha.js +21 -0
- package/dist/cli/serve.d.ts +11 -0
- package/dist/cli/serve.js +72 -6
- package/dist/cli/upgrade.d.ts +67 -0
- package/dist/cli/upgrade.js +509 -0
- package/dist/gateway/downstream-pool.d.ts +39 -0
- package/dist/gateway/downstream-pool.js +93 -0
- package/dist/gateway/downstream.d.ts +80 -0
- package/dist/gateway/downstream.js +196 -0
- package/dist/gateway/middleware/audit-types.d.ts +10 -0
- package/dist/gateway/middleware/audit.js +14 -0
- package/dist/gateway/middleware/injection.d.ts +59 -2
- package/dist/gateway/middleware/injection.js +91 -14
- package/dist/gateway/middleware/kill-switch.d.ts +20 -5
- package/dist/gateway/middleware/kill-switch.js +57 -35
- package/dist/gateway/middleware/redact.d.ts +83 -6
- package/dist/gateway/middleware/redact.js +133 -46
- package/dist/gateway/observability/codex-probe.d.ts +110 -0
- package/dist/gateway/observability/codex-probe.js +234 -0
- package/dist/gateway/observability/codex-telemetry.d.ts +93 -0
- package/dist/gateway/observability/codex-telemetry.js +221 -0
- package/dist/gateway/redact-safe/match-timeout.d.ts +83 -0
- package/dist/gateway/redact-safe/match-timeout.js +179 -0
- package/dist/gateway/reviewers/claude-self.d.ts +99 -0
- package/dist/gateway/reviewers/claude-self.js +316 -0
- package/dist/gateway/reviewers/codex.d.ts +64 -0
- package/dist/gateway/reviewers/codex.js +80 -0
- package/dist/gateway/reviewers/select.d.ts +64 -0
- package/dist/gateway/reviewers/select.js +102 -0
- package/dist/gateway/reviewers/types.d.ts +85 -0
- package/dist/gateway/reviewers/types.js +14 -0
- package/dist/gateway/server.d.ts +51 -0
- package/dist/gateway/server.js +258 -0
- package/dist/gateway/session.d.ts +9 -0
- package/dist/gateway/session.js +17 -0
- package/dist/policy/loader.d.ts +59 -0
- package/dist/policy/loader.js +65 -0
- package/dist/policy/profiles.d.ts +80 -0
- package/dist/policy/profiles.js +94 -0
- package/dist/policy/types.d.ts +38 -0
- package/dist/registry/loader.d.ts +98 -0
- package/dist/registry/loader.js +153 -0
- package/dist/registry/types.d.ts +44 -0
- package/dist/registry/types.js +6 -0
- package/dist/scripts/read-policy-field.d.ts +36 -0
- package/dist/scripts/read-policy-field.js +96 -0
- package/hooks/push-review-gate.sh +627 -17
- package/package.json +13 -2
- package/profiles/bst-internal-no-codex.yaml +40 -0
- package/profiles/bst-internal.yaml +23 -0
- package/profiles/client-engagement.yaml +23 -0
- package/profiles/lit-wc.yaml +17 -0
- package/profiles/minimal.yaml +11 -0
- package/profiles/open-source-no-codex.yaml +33 -0
- package/profiles/open-source.yaml +18 -0
- package/scripts/lint-safe-regex.mjs +78 -0
- package/scripts/postinstall.mjs +131 -0
|
@@ -1,10 +1,25 @@
|
|
|
1
1
|
import type { Middleware } from './chain.js';
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
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
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
|
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
|
-
*
|
|
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
|
|
13
|
-
* within the object structure rather than JSON.stringify→replace→
|
|
14
|
-
* could corrupt the result if a replacement changes JSON
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
|
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,
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
*
|
|
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:
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
83
|
-
|
|
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
|
+
}
|