@bookedsolid/rea 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.husky/pre-push +15 -18
- package/README.md +41 -1
- package/dist/cache/review-cache.d.ts +115 -0
- package/dist/cache/review-cache.js +200 -0
- package/dist/cli/cache.d.ts +52 -0
- package/dist/cli/cache.js +112 -0
- package/dist/cli/doctor.d.ts +19 -4
- package/dist/cli/doctor.js +172 -5
- package/dist/cli/index.js +50 -1
- package/dist/cli/init.js +109 -7
- package/dist/cli/install/gitignore.d.ts +114 -0
- package/dist/cli/install/gitignore.js +356 -0
- package/dist/cli/install/pre-push.d.ts +335 -0
- package/dist/cli/install/pre-push.js +2818 -0
- package/dist/cli/serve.d.ts +64 -0
- package/dist/cli/serve.js +270 -2
- package/dist/cli/status.d.ts +90 -0
- package/dist/cli/status.js +399 -0
- package/dist/cli/upgrade.js +20 -0
- package/dist/cli/utils.d.ts +4 -0
- package/dist/cli/utils.js +4 -0
- package/dist/gateway/circuit-breaker.d.ts +17 -0
- package/dist/gateway/circuit-breaker.js +32 -3
- package/dist/gateway/downstream-pool.d.ts +2 -1
- package/dist/gateway/downstream-pool.js +2 -2
- package/dist/gateway/downstream.d.ts +39 -3
- package/dist/gateway/downstream.js +73 -14
- package/dist/gateway/log.d.ts +122 -0
- package/dist/gateway/log.js +334 -0
- package/dist/gateway/middleware/audit.d.ts +10 -1
- package/dist/gateway/middleware/audit.js +26 -1
- package/dist/gateway/middleware/blocked-paths.d.ts +0 -9
- package/dist/gateway/middleware/blocked-paths.js +439 -67
- package/dist/gateway/middleware/injection.d.ts +218 -13
- package/dist/gateway/middleware/injection.js +433 -51
- package/dist/gateway/middleware/kill-switch.d.ts +10 -1
- package/dist/gateway/middleware/kill-switch.js +20 -1
- package/dist/gateway/observability/metrics.d.ts +125 -0
- package/dist/gateway/observability/metrics.js +321 -0
- package/dist/gateway/server.d.ts +19 -0
- package/dist/gateway/server.js +99 -15
- package/dist/policy/loader.d.ts +23 -0
- package/dist/policy/loader.js +30 -0
- package/dist/policy/profiles.d.ts +13 -0
- package/dist/policy/profiles.js +12 -0
- package/dist/policy/types.d.ts +48 -0
- package/dist/registry/fingerprint.d.ts +73 -0
- package/dist/registry/fingerprint.js +81 -0
- package/dist/registry/fingerprints-store.d.ts +62 -0
- package/dist/registry/fingerprints-store.js +111 -0
- package/dist/registry/interpolate.d.ts +58 -0
- package/dist/registry/interpolate.js +121 -0
- package/dist/registry/loader.d.ts +2 -2
- package/dist/registry/loader.js +22 -1
- package/dist/registry/tofu-gate.d.ts +41 -0
- package/dist/registry/tofu-gate.js +189 -0
- package/dist/registry/tofu.d.ts +111 -0
- package/dist/registry/tofu.js +173 -0
- package/dist/registry/types.d.ts +9 -1
- package/hooks/push-review-gate.sh +185 -1
- package/package.json +1 -1
- package/profiles/bst-internal-no-codex.yaml +5 -0
- package/profiles/bst-internal.yaml +7 -0
- package/scripts/tarball-smoke.sh +197 -0
package/dist/policy/loader.d.ts
CHANGED
|
@@ -13,6 +13,13 @@ declare const PolicySchema: z.ZodObject<{
|
|
|
13
13
|
blocked_paths: z.ZodArray<z.ZodString, "many">;
|
|
14
14
|
notification_channel: z.ZodDefault<z.ZodString>;
|
|
15
15
|
injection_detection: z.ZodOptional<z.ZodEnum<["block", "warn"]>>;
|
|
16
|
+
injection: z.ZodOptional<z.ZodObject<{
|
|
17
|
+
suspicious_blocks_writes: z.ZodOptional<z.ZodBoolean>;
|
|
18
|
+
}, "strict", z.ZodTypeAny, {
|
|
19
|
+
suspicious_blocks_writes?: boolean | undefined;
|
|
20
|
+
}, {
|
|
21
|
+
suspicious_blocks_writes?: boolean | undefined;
|
|
22
|
+
}>>;
|
|
16
23
|
context_protection: z.ZodOptional<z.ZodObject<{
|
|
17
24
|
delegate_to_subagent: z.ZodDefault<z.ZodArray<z.ZodString, "many">>;
|
|
18
25
|
max_bash_output_lines: z.ZodOptional<z.ZodNumber>;
|
|
@@ -25,10 +32,16 @@ declare const PolicySchema: z.ZodObject<{
|
|
|
25
32
|
}>>;
|
|
26
33
|
review: z.ZodOptional<z.ZodObject<{
|
|
27
34
|
codex_required: z.ZodOptional<z.ZodBoolean>;
|
|
35
|
+
cache_max_age_seconds: z.ZodOptional<z.ZodNumber>;
|
|
36
|
+
allow_skip_in_ci: z.ZodOptional<z.ZodBoolean>;
|
|
28
37
|
}, "strict", z.ZodTypeAny, {
|
|
29
38
|
codex_required?: boolean | undefined;
|
|
39
|
+
cache_max_age_seconds?: number | undefined;
|
|
40
|
+
allow_skip_in_ci?: boolean | undefined;
|
|
30
41
|
}, {
|
|
31
42
|
codex_required?: boolean | undefined;
|
|
43
|
+
cache_max_age_seconds?: number | undefined;
|
|
44
|
+
allow_skip_in_ci?: boolean | undefined;
|
|
32
45
|
}>>;
|
|
33
46
|
redact: z.ZodOptional<z.ZodObject<{
|
|
34
47
|
match_timeout_ms: z.ZodOptional<z.ZodNumber>;
|
|
@@ -94,12 +107,17 @@ declare const PolicySchema: z.ZodObject<{
|
|
|
94
107
|
blocked_paths: string[];
|
|
95
108
|
notification_channel: string;
|
|
96
109
|
injection_detection?: "block" | "warn" | undefined;
|
|
110
|
+
injection?: {
|
|
111
|
+
suspicious_blocks_writes?: boolean | undefined;
|
|
112
|
+
} | undefined;
|
|
97
113
|
context_protection?: {
|
|
98
114
|
delegate_to_subagent: string[];
|
|
99
115
|
max_bash_output_lines?: number | undefined;
|
|
100
116
|
} | undefined;
|
|
101
117
|
review?: {
|
|
102
118
|
codex_required?: boolean | undefined;
|
|
119
|
+
cache_max_age_seconds?: number | undefined;
|
|
120
|
+
allow_skip_in_ci?: boolean | undefined;
|
|
103
121
|
} | undefined;
|
|
104
122
|
redact?: {
|
|
105
123
|
match_timeout_ms?: number | undefined;
|
|
@@ -127,12 +145,17 @@ declare const PolicySchema: z.ZodObject<{
|
|
|
127
145
|
block_ai_attribution?: boolean | undefined;
|
|
128
146
|
notification_channel?: string | undefined;
|
|
129
147
|
injection_detection?: "block" | "warn" | undefined;
|
|
148
|
+
injection?: {
|
|
149
|
+
suspicious_blocks_writes?: boolean | undefined;
|
|
150
|
+
} | undefined;
|
|
130
151
|
context_protection?: {
|
|
131
152
|
delegate_to_subagent?: string[] | undefined;
|
|
132
153
|
max_bash_output_lines?: number | undefined;
|
|
133
154
|
} | undefined;
|
|
134
155
|
review?: {
|
|
135
156
|
codex_required?: boolean | undefined;
|
|
157
|
+
cache_max_age_seconds?: number | undefined;
|
|
158
|
+
allow_skip_in_ci?: boolean | undefined;
|
|
136
159
|
} | undefined;
|
|
137
160
|
redact?: {
|
|
138
161
|
match_timeout_ms?: number | undefined;
|
package/dist/policy/loader.js
CHANGED
|
@@ -24,6 +24,8 @@ const ContextProtectionSchema = z.object({
|
|
|
24
24
|
const ReviewPolicySchema = z
|
|
25
25
|
.object({
|
|
26
26
|
codex_required: z.boolean().optional(),
|
|
27
|
+
cache_max_age_seconds: z.number().int().positive().optional(),
|
|
28
|
+
allow_skip_in_ci: z.boolean().optional(),
|
|
27
29
|
})
|
|
28
30
|
.strict();
|
|
29
31
|
/**
|
|
@@ -64,6 +66,33 @@ const AuditPolicySchema = z
|
|
|
64
66
|
rotation: AuditRotationPolicySchema.optional(),
|
|
65
67
|
})
|
|
66
68
|
.strict();
|
|
69
|
+
/**
|
|
70
|
+
* G9: injection tier escalation. `suspicious_blocks_writes` is fully
|
|
71
|
+
* optional at the schema layer — absence is distinguishable from an
|
|
72
|
+
* explicit `false`. The middleware (`createInjectionMiddleware`) then
|
|
73
|
+
* applies the action-aware default:
|
|
74
|
+
*
|
|
75
|
+
* - `injection_detection: block` (default) + flag unset → `true`
|
|
76
|
+
* (0.2.x parity — a single literal match at write/destructive tier
|
|
77
|
+
* still denies for upgraded consumers who omit the `injection:` block)
|
|
78
|
+
* - `injection_detection: block` + flag explicit `false` → `false`
|
|
79
|
+
* (explicit opt-out)
|
|
80
|
+
* - `injection_detection: warn` + flag unset or `false` → `false`
|
|
81
|
+
* (warn mode preserves 0.2.x warn-only semantics)
|
|
82
|
+
* - flag explicit `true` (pinned in `bst-internal*`) → `true`
|
|
83
|
+
*
|
|
84
|
+
* This avoids the Codex-reported regression in PR #25 where the schema
|
|
85
|
+
* default of `false` silently loosened `injection_detection: block`
|
|
86
|
+
* behavior on upgrade for non-bst consumers.
|
|
87
|
+
*
|
|
88
|
+
* `likely_injection` verdicts (multi-literal matches, base64-decoded matches,
|
|
89
|
+
* or any read-tier match) are ALWAYS deny regardless of this flag.
|
|
90
|
+
*/
|
|
91
|
+
const InjectionPolicySchema = z
|
|
92
|
+
.object({
|
|
93
|
+
suspicious_blocks_writes: z.boolean().optional(),
|
|
94
|
+
})
|
|
95
|
+
.strict();
|
|
67
96
|
const PolicySchema = z
|
|
68
97
|
.object({
|
|
69
98
|
version: z.string(),
|
|
@@ -77,6 +106,7 @@ const PolicySchema = z
|
|
|
77
106
|
blocked_paths: z.array(z.string()),
|
|
78
107
|
notification_channel: z.string().default(''),
|
|
79
108
|
injection_detection: z.enum(['block', 'warn']).optional(),
|
|
109
|
+
injection: InjectionPolicySchema.optional(),
|
|
80
110
|
context_protection: ContextProtectionSchema.optional(),
|
|
81
111
|
review: ReviewPolicySchema.optional(),
|
|
82
112
|
redact: RedactPolicySchema.optional(),
|
|
@@ -28,6 +28,13 @@ export declare const ProfileSchema: z.ZodObject<{
|
|
|
28
28
|
blocked_paths: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
|
|
29
29
|
notification_channel: z.ZodOptional<z.ZodString>;
|
|
30
30
|
injection_detection: z.ZodOptional<z.ZodEnum<["block", "warn"]>>;
|
|
31
|
+
injection: z.ZodOptional<z.ZodObject<{
|
|
32
|
+
suspicious_blocks_writes: z.ZodOptional<z.ZodBoolean>;
|
|
33
|
+
}, "strict", z.ZodTypeAny, {
|
|
34
|
+
suspicious_blocks_writes?: boolean | undefined;
|
|
35
|
+
}, {
|
|
36
|
+
suspicious_blocks_writes?: boolean | undefined;
|
|
37
|
+
}>>;
|
|
31
38
|
context_protection: z.ZodOptional<z.ZodObject<{
|
|
32
39
|
delegate_to_subagent: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
|
|
33
40
|
max_bash_output_lines: z.ZodOptional<z.ZodNumber>;
|
|
@@ -46,6 +53,9 @@ export declare const ProfileSchema: z.ZodObject<{
|
|
|
46
53
|
blocked_paths?: string[] | undefined;
|
|
47
54
|
notification_channel?: string | undefined;
|
|
48
55
|
injection_detection?: "block" | "warn" | undefined;
|
|
56
|
+
injection?: {
|
|
57
|
+
suspicious_blocks_writes?: boolean | undefined;
|
|
58
|
+
} | undefined;
|
|
49
59
|
context_protection?: {
|
|
50
60
|
delegate_to_subagent?: string[] | undefined;
|
|
51
61
|
max_bash_output_lines?: number | undefined;
|
|
@@ -58,6 +68,9 @@ export declare const ProfileSchema: z.ZodObject<{
|
|
|
58
68
|
blocked_paths?: string[] | undefined;
|
|
59
69
|
notification_channel?: string | undefined;
|
|
60
70
|
injection_detection?: "block" | "warn" | undefined;
|
|
71
|
+
injection?: {
|
|
72
|
+
suspicious_blocks_writes?: boolean | undefined;
|
|
73
|
+
} | undefined;
|
|
61
74
|
context_protection?: {
|
|
62
75
|
delegate_to_subagent?: string[] | undefined;
|
|
63
76
|
max_bash_output_lines?: number | undefined;
|
package/dist/policy/profiles.js
CHANGED
|
@@ -25,6 +25,17 @@ const ContextProtectionProfileSchema = z
|
|
|
25
25
|
max_bash_output_lines: z.number().int().positive().optional(),
|
|
26
26
|
})
|
|
27
27
|
.strict();
|
|
28
|
+
/**
|
|
29
|
+
* G9: injection tier-escalation knobs. Profile-layer schema mirrors the policy
|
|
30
|
+
* loader's `InjectionPolicySchema` but leaves the flag fully optional so the
|
|
31
|
+
* profile-default lives at the policy-loader layer (ships `false` by default).
|
|
32
|
+
* Strict mode still rejects typos so a misspelled key fails loudly at init.
|
|
33
|
+
*/
|
|
34
|
+
const InjectionProfileSchema = z
|
|
35
|
+
.object({
|
|
36
|
+
suspicious_blocks_writes: z.boolean().optional(),
|
|
37
|
+
})
|
|
38
|
+
.strict();
|
|
28
39
|
/**
|
|
29
40
|
* Profile is PolicySchema with every field optional. Strict mode still rejects
|
|
30
41
|
* unknown keys so a typo in a profile YAML fails loudly at init time rather
|
|
@@ -39,6 +50,7 @@ export const ProfileSchema = z
|
|
|
39
50
|
blocked_paths: z.array(z.string()).optional(),
|
|
40
51
|
notification_channel: z.string().optional(),
|
|
41
52
|
injection_detection: z.enum(['block', 'warn']).optional(),
|
|
53
|
+
injection: InjectionProfileSchema.optional(),
|
|
42
54
|
context_protection: ContextProtectionProfileSchema.optional(),
|
|
43
55
|
})
|
|
44
56
|
.strict();
|
package/dist/policy/types.d.ts
CHANGED
|
@@ -31,6 +31,26 @@ export interface ReviewPolicy {
|
|
|
31
31
|
* log. Default when unset is `true` (Codex required).
|
|
32
32
|
*/
|
|
33
33
|
codex_required?: boolean;
|
|
34
|
+
/**
|
|
35
|
+
* Review-cache TTL used by `rea cache check` (BUG-009). Entries older
|
|
36
|
+
* than this window are treated as a miss, forcing re-review. Default
|
|
37
|
+
* when unset is 3600 seconds (1 hour) — matches the windows the
|
|
38
|
+
* push-review-gate hook already assumes. Express in seconds, positive
|
|
39
|
+
* integer.
|
|
40
|
+
*/
|
|
41
|
+
cache_max_age_seconds?: number;
|
|
42
|
+
/**
|
|
43
|
+
* Authorization for `REA_SKIP_PUSH_REVIEW` / `REA_SKIP_CODEX_REVIEW` when
|
|
44
|
+
* the `CI` environment variable is set. The skip hatches are ambient and
|
|
45
|
+
* unauthenticated — a leaked env file or a malicious parent process can
|
|
46
|
+
* bypass the gate and record a forged actor (git config is mutable repo
|
|
47
|
+
* config). Refusing these hatches in CI contexts by default removes that
|
|
48
|
+
* bypass surface. Set `true` ONLY on build agents where the operator has
|
|
49
|
+
* an independent reason to trust the environment. Default `false`.
|
|
50
|
+
*
|
|
51
|
+
* Added in 0.5.0 as Codex F2 on the PR1 adversarial review.
|
|
52
|
+
*/
|
|
53
|
+
allow_skip_in_ci?: boolean;
|
|
34
54
|
}
|
|
35
55
|
/**
|
|
36
56
|
* User-supplied redaction pattern entry. Each pattern has a stable `name` used
|
|
@@ -77,6 +97,33 @@ export interface AuditRotationPolicy {
|
|
|
77
97
|
export interface AuditPolicy {
|
|
78
98
|
rotation?: AuditRotationPolicy;
|
|
79
99
|
}
|
|
100
|
+
/**
|
|
101
|
+
* G9 — injection tier escalation knobs. The classifier bucketed matches into
|
|
102
|
+
* `clean` / `suspicious` / `likely_injection`; this block governs what happens
|
|
103
|
+
* to the `suspicious` bucket (a single literal match at write/destructive tier,
|
|
104
|
+
* no base64 escalation). `likely_injection` is ALWAYS a deny regardless of
|
|
105
|
+
* these knobs.
|
|
106
|
+
*
|
|
107
|
+
* `suspicious_blocks_writes` —
|
|
108
|
+
* `undefined` (omitted): middleware defaults based on `injection_detection`:
|
|
109
|
+
* block mode defaults to `true` (0.2.x parity — single literal at
|
|
110
|
+
* write/destructive tier still denies); warn mode defaults to `false`
|
|
111
|
+
* (preserves 0.2.x warn-only semantics).
|
|
112
|
+
* `false` (explicit opt-out): suspicious matches warn-only (log + audit
|
|
113
|
+
* metadata, `status: allowed`), regardless of `injection_detection`.
|
|
114
|
+
* `true` (pinned in `bst-internal*` and this repo's own policy): suspicious
|
|
115
|
+
* matches at write/destructive tier deny with verdict `suspicious` in the
|
|
116
|
+
* audit record.
|
|
117
|
+
*
|
|
118
|
+
* G9 follow-up (post-merge Codex finding #1): the pre-patch schema default
|
|
119
|
+
* of `false` silently loosened 0.2.x `injection_detection: block` behavior
|
|
120
|
+
* for any consumer who upgraded without adding the `injection:` block.
|
|
121
|
+
* Making this field optional and defaulting it at the middleware restores
|
|
122
|
+
* 0.2.x parity.
|
|
123
|
+
*/
|
|
124
|
+
export interface InjectionPolicy {
|
|
125
|
+
suspicious_blocks_writes?: boolean;
|
|
126
|
+
}
|
|
80
127
|
export interface Policy {
|
|
81
128
|
version: string;
|
|
82
129
|
profile: string;
|
|
@@ -89,6 +136,7 @@ export interface Policy {
|
|
|
89
136
|
blocked_paths: string[];
|
|
90
137
|
notification_channel: string;
|
|
91
138
|
injection_detection?: 'block' | 'warn';
|
|
139
|
+
injection?: InjectionPolicy;
|
|
92
140
|
context_protection?: ContextProtection;
|
|
93
141
|
review?: ReviewPolicy;
|
|
94
142
|
redact?: RedactPolicy;
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Registry server fingerprinting — G7 proxy-poisoning defense.
|
|
3
|
+
*
|
|
4
|
+
* ## Threat model
|
|
5
|
+
*
|
|
6
|
+
* The registry file (`.rea/registry.yaml`) is plain YAML on the operator's
|
|
7
|
+
* disk. An attacker who lands a malicious template via `rea init`, or who
|
|
8
|
+
* patches the file out-of-band (compromised dependency postinstall, CI-bot
|
|
9
|
+
* misconfig, editor plugin writing through stale buffers), can silently swap
|
|
10
|
+
* a downstream server's `command`, `args`, or `env` keys. The gateway would
|
|
11
|
+
* spawn the new child at next startup and proxy it without challenge.
|
|
12
|
+
*
|
|
13
|
+
* Fingerprinting defends the **catalog-tampering** vector: we hash the
|
|
14
|
+
* canonicalized server config on first sight (TOFU — trust on first use),
|
|
15
|
+
* persist it to `.rea/fingerprints.json`, and on every subsequent boot refuse
|
|
16
|
+
* to connect servers whose fingerprint has drifted without an explicit
|
|
17
|
+
* one-shot acknowledgement (`REA_ACCEPT_DRIFT=<name>`).
|
|
18
|
+
*
|
|
19
|
+
* ## Scope: path-only, not binary
|
|
20
|
+
*
|
|
21
|
+
* We fingerprint the **config path** (name, command, args, env KEY SET,
|
|
22
|
+
* env_passthrough, tier_overrides). We do NOT hash the binary contents at
|
|
23
|
+
* `config.command`. Three reasons:
|
|
24
|
+
*
|
|
25
|
+
* 1. Binary hashing turns TOFU into a slow-boot tax — cold spawns already
|
|
26
|
+
* dominate first-run latency; adding N sha256-of-binary operations makes
|
|
27
|
+
* this worse on every restart.
|
|
28
|
+
* 2. Legitimate MCP server upgrades (e.g. `@modelcontextprotocol/server-git`
|
|
29
|
+
* patch version bump) would legitimately change the binary content and
|
|
30
|
+
* would trip false-positive drift on every upgrade.
|
|
31
|
+
* 3. The G7 threat model is **registry tampering** (YAML rewrite), which the
|
|
32
|
+
* canonicalized config hash covers cleanly. Host compromise — where an
|
|
33
|
+
* attacker swaps the on-disk binary at `config.command` — is a different
|
|
34
|
+
* G-number (supply-chain / host-integrity), not G7.
|
|
35
|
+
*
|
|
36
|
+
* ## Env values vs env keys
|
|
37
|
+
*
|
|
38
|
+
* We fingerprint the SORTED KEY SET of `config.env`, not the values. Values
|
|
39
|
+
* frequently contain secrets (`GITHUB_TOKEN: ghp_...`) that the operator may
|
|
40
|
+
* legitimately rotate; rotating a secret must not trip drift. Adding or
|
|
41
|
+
* removing a key IS semantic change (new permission scope, new passthrough
|
|
42
|
+
* surface) — that trips drift and is caught.
|
|
43
|
+
*/
|
|
44
|
+
import type { RegistryServer } from './types.js';
|
|
45
|
+
/**
|
|
46
|
+
* Canonical representation of a server for fingerprinting. Field order is
|
|
47
|
+
* fixed so JSON.stringify output is deterministic; arrays/keys are sorted.
|
|
48
|
+
*/
|
|
49
|
+
interface CanonicalServer {
|
|
50
|
+
name: string;
|
|
51
|
+
command: string;
|
|
52
|
+
args: string[];
|
|
53
|
+
env_keys: string[];
|
|
54
|
+
env_passthrough: string[];
|
|
55
|
+
tier_overrides: Array<[string, string]>;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Compute a stable sha256 fingerprint of a registry server's config path.
|
|
59
|
+
* Pure function — same input produces the same output forever.
|
|
60
|
+
*
|
|
61
|
+
* Two callers with the same server entry in different registries must get
|
|
62
|
+
* the same fingerprint; two servers that differ in any material way (command,
|
|
63
|
+
* args, env KEY presence, passthrough surface, tier override for any tool)
|
|
64
|
+
* must get different fingerprints.
|
|
65
|
+
*/
|
|
66
|
+
export declare function fingerprintServer(server: RegistryServer): string;
|
|
67
|
+
/**
|
|
68
|
+
* Test hook: expose the canonical form so tests can assert what is and is
|
|
69
|
+
* not included in the fingerprint input. Not part of the public API — no
|
|
70
|
+
* consumer should depend on this shape remaining stable.
|
|
71
|
+
*/
|
|
72
|
+
export declare function __canonicalizeForTests(server: RegistryServer): CanonicalServer;
|
|
73
|
+
export {};
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Registry server fingerprinting — G7 proxy-poisoning defense.
|
|
3
|
+
*
|
|
4
|
+
* ## Threat model
|
|
5
|
+
*
|
|
6
|
+
* The registry file (`.rea/registry.yaml`) is plain YAML on the operator's
|
|
7
|
+
* disk. An attacker who lands a malicious template via `rea init`, or who
|
|
8
|
+
* patches the file out-of-band (compromised dependency postinstall, CI-bot
|
|
9
|
+
* misconfig, editor plugin writing through stale buffers), can silently swap
|
|
10
|
+
* a downstream server's `command`, `args`, or `env` keys. The gateway would
|
|
11
|
+
* spawn the new child at next startup and proxy it without challenge.
|
|
12
|
+
*
|
|
13
|
+
* Fingerprinting defends the **catalog-tampering** vector: we hash the
|
|
14
|
+
* canonicalized server config on first sight (TOFU — trust on first use),
|
|
15
|
+
* persist it to `.rea/fingerprints.json`, and on every subsequent boot refuse
|
|
16
|
+
* to connect servers whose fingerprint has drifted without an explicit
|
|
17
|
+
* one-shot acknowledgement (`REA_ACCEPT_DRIFT=<name>`).
|
|
18
|
+
*
|
|
19
|
+
* ## Scope: path-only, not binary
|
|
20
|
+
*
|
|
21
|
+
* We fingerprint the **config path** (name, command, args, env KEY SET,
|
|
22
|
+
* env_passthrough, tier_overrides). We do NOT hash the binary contents at
|
|
23
|
+
* `config.command`. Three reasons:
|
|
24
|
+
*
|
|
25
|
+
* 1. Binary hashing turns TOFU into a slow-boot tax — cold spawns already
|
|
26
|
+
* dominate first-run latency; adding N sha256-of-binary operations makes
|
|
27
|
+
* this worse on every restart.
|
|
28
|
+
* 2. Legitimate MCP server upgrades (e.g. `@modelcontextprotocol/server-git`
|
|
29
|
+
* patch version bump) would legitimately change the binary content and
|
|
30
|
+
* would trip false-positive drift on every upgrade.
|
|
31
|
+
* 3. The G7 threat model is **registry tampering** (YAML rewrite), which the
|
|
32
|
+
* canonicalized config hash covers cleanly. Host compromise — where an
|
|
33
|
+
* attacker swaps the on-disk binary at `config.command` — is a different
|
|
34
|
+
* G-number (supply-chain / host-integrity), not G7.
|
|
35
|
+
*
|
|
36
|
+
* ## Env values vs env keys
|
|
37
|
+
*
|
|
38
|
+
* We fingerprint the SORTED KEY SET of `config.env`, not the values. Values
|
|
39
|
+
* frequently contain secrets (`GITHUB_TOKEN: ghp_...`) that the operator may
|
|
40
|
+
* legitimately rotate; rotating a secret must not trip drift. Adding or
|
|
41
|
+
* removing a key IS semantic change (new permission scope, new passthrough
|
|
42
|
+
* surface) — that trips drift and is caught.
|
|
43
|
+
*/
|
|
44
|
+
import { createHash } from 'node:crypto';
|
|
45
|
+
function canonicalize(server) {
|
|
46
|
+
const envKeys = Object.keys(server.env).sort();
|
|
47
|
+
const passthrough = [...(server.env_passthrough ?? [])].sort();
|
|
48
|
+
const overrides = Object.entries(server.tier_overrides ?? {})
|
|
49
|
+
.map(([k, v]) => [k, String(v)])
|
|
50
|
+
.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0));
|
|
51
|
+
return {
|
|
52
|
+
name: server.name,
|
|
53
|
+
command: server.command,
|
|
54
|
+
args: [...server.args],
|
|
55
|
+
env_keys: envKeys,
|
|
56
|
+
env_passthrough: passthrough,
|
|
57
|
+
tier_overrides: overrides,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Compute a stable sha256 fingerprint of a registry server's config path.
|
|
62
|
+
* Pure function — same input produces the same output forever.
|
|
63
|
+
*
|
|
64
|
+
* Two callers with the same server entry in different registries must get
|
|
65
|
+
* the same fingerprint; two servers that differ in any material way (command,
|
|
66
|
+
* args, env KEY presence, passthrough surface, tier override for any tool)
|
|
67
|
+
* must get different fingerprints.
|
|
68
|
+
*/
|
|
69
|
+
export function fingerprintServer(server) {
|
|
70
|
+
const canonical = canonicalize(server);
|
|
71
|
+
const json = JSON.stringify(canonical);
|
|
72
|
+
return createHash('sha256').update(json).digest('hex');
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Test hook: expose the canonical form so tests can assert what is and is
|
|
76
|
+
* not included in the fingerprint input. Not part of the public API — no
|
|
77
|
+
* consumer should depend on this shape remaining stable.
|
|
78
|
+
*/
|
|
79
|
+
export function __canonicalizeForTests(server) {
|
|
80
|
+
return canonicalize(server);
|
|
81
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TOFU fingerprint store — persisted trust anchors for each downstream
|
|
3
|
+
* server declared in `.rea/registry.yaml`.
|
|
4
|
+
*
|
|
5
|
+
* Stored at `.rea/fingerprints.json`. Versioned schema (currently `"1"`)
|
|
6
|
+
* so we can migrate shape without a surprise parse failure on upgrade.
|
|
7
|
+
*
|
|
8
|
+
* ## Format
|
|
9
|
+
*
|
|
10
|
+
* ```json
|
|
11
|
+
* {
|
|
12
|
+
* "version": "1",
|
|
13
|
+
* "servers": {
|
|
14
|
+
* "discord-ops": "a3f4...",
|
|
15
|
+
* "obsidian": "b1c2..."
|
|
16
|
+
* }
|
|
17
|
+
* }
|
|
18
|
+
* ```
|
|
19
|
+
*
|
|
20
|
+
* ## Corruption policy
|
|
21
|
+
*
|
|
22
|
+
* A missing file is the **first-run** state. An unparseable or
|
|
23
|
+
* schema-invalid file is NOT silently ignored: the loader throws. The
|
|
24
|
+
* gateway treats that as a fail-closed signal — refuse to start rather than
|
|
25
|
+
* reset TOFU state, which would downgrade a real attack to a first-seen
|
|
26
|
+
* acceptance. The operator can delete the file deliberately to re-bootstrap.
|
|
27
|
+
*
|
|
28
|
+
* ## Concurrency
|
|
29
|
+
*
|
|
30
|
+
* Writes use an atomic `write → rename` pattern to avoid torn reads. The
|
|
31
|
+
* gateway is the only writer in normal operation (startup TOFU check),
|
|
32
|
+
* so we do not take a file lock — two concurrent `rea serve` processes
|
|
33
|
+
* in the same repo is not a supported state.
|
|
34
|
+
*/
|
|
35
|
+
import { z } from 'zod';
|
|
36
|
+
export declare const FINGERPRINT_STORE_VERSION = "1";
|
|
37
|
+
declare const FingerprintStoreSchema: z.ZodObject<{
|
|
38
|
+
version: z.ZodLiteral<"1">;
|
|
39
|
+
servers: z.ZodRecord<z.ZodString, z.ZodString>;
|
|
40
|
+
}, "strict", z.ZodTypeAny, {
|
|
41
|
+
version: "1";
|
|
42
|
+
servers: Record<string, string>;
|
|
43
|
+
}, {
|
|
44
|
+
version: "1";
|
|
45
|
+
servers: Record<string, string>;
|
|
46
|
+
}>;
|
|
47
|
+
export type FingerprintStore = z.infer<typeof FingerprintStoreSchema>;
|
|
48
|
+
declare function storePathFor(baseDir: string): string;
|
|
49
|
+
/**
|
|
50
|
+
* Load the fingerprint store. Returns an empty store if the file does not
|
|
51
|
+
* exist (first-run). Throws on unreadable or schema-invalid files — do NOT
|
|
52
|
+
* catch and treat as first-run, that would let an attacker who corrupts the
|
|
53
|
+
* file downgrade a drift event to first-seen acceptance.
|
|
54
|
+
*/
|
|
55
|
+
export declare function loadFingerprintStore(baseDir: string): Promise<FingerprintStore>;
|
|
56
|
+
/**
|
|
57
|
+
* Persist the fingerprint store. Writes to a sibling `.new` file then
|
|
58
|
+
* renames into place so a crashed process never leaves a half-written store
|
|
59
|
+
* that would fail to parse on next boot.
|
|
60
|
+
*/
|
|
61
|
+
export declare function saveFingerprintStore(baseDir: string, store: FingerprintStore): Promise<void>;
|
|
62
|
+
export { FingerprintStoreSchema, storePathFor as __fingerprintStorePathForTests };
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TOFU fingerprint store — persisted trust anchors for each downstream
|
|
3
|
+
* server declared in `.rea/registry.yaml`.
|
|
4
|
+
*
|
|
5
|
+
* Stored at `.rea/fingerprints.json`. Versioned schema (currently `"1"`)
|
|
6
|
+
* so we can migrate shape without a surprise parse failure on upgrade.
|
|
7
|
+
*
|
|
8
|
+
* ## Format
|
|
9
|
+
*
|
|
10
|
+
* ```json
|
|
11
|
+
* {
|
|
12
|
+
* "version": "1",
|
|
13
|
+
* "servers": {
|
|
14
|
+
* "discord-ops": "a3f4...",
|
|
15
|
+
* "obsidian": "b1c2..."
|
|
16
|
+
* }
|
|
17
|
+
* }
|
|
18
|
+
* ```
|
|
19
|
+
*
|
|
20
|
+
* ## Corruption policy
|
|
21
|
+
*
|
|
22
|
+
* A missing file is the **first-run** state. An unparseable or
|
|
23
|
+
* schema-invalid file is NOT silently ignored: the loader throws. The
|
|
24
|
+
* gateway treats that as a fail-closed signal — refuse to start rather than
|
|
25
|
+
* reset TOFU state, which would downgrade a real attack to a first-seen
|
|
26
|
+
* acceptance. The operator can delete the file deliberately to re-bootstrap.
|
|
27
|
+
*
|
|
28
|
+
* ## Concurrency
|
|
29
|
+
*
|
|
30
|
+
* Writes use an atomic `write → rename` pattern to avoid torn reads. The
|
|
31
|
+
* gateway is the only writer in normal operation (startup TOFU check),
|
|
32
|
+
* so we do not take a file lock — two concurrent `rea serve` processes
|
|
33
|
+
* in the same repo is not a supported state.
|
|
34
|
+
*/
|
|
35
|
+
import fs from 'node:fs/promises';
|
|
36
|
+
import path from 'node:path';
|
|
37
|
+
import { z } from 'zod';
|
|
38
|
+
const FINGERPRINTS_FILE = 'fingerprints.json';
|
|
39
|
+
const REA_DIR = '.rea';
|
|
40
|
+
export const FINGERPRINT_STORE_VERSION = '1';
|
|
41
|
+
const FingerprintStoreSchema = z
|
|
42
|
+
.object({
|
|
43
|
+
version: z.literal(FINGERPRINT_STORE_VERSION),
|
|
44
|
+
servers: z.record(z.string().regex(/^[a-f0-9]{64}$/, 'fingerprint must be lowercase hex sha256')),
|
|
45
|
+
})
|
|
46
|
+
.strict();
|
|
47
|
+
function storePathFor(baseDir) {
|
|
48
|
+
return path.join(baseDir, REA_DIR, FINGERPRINTS_FILE);
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Load the fingerprint store. Returns an empty store if the file does not
|
|
52
|
+
* exist (first-run). Throws on unreadable or schema-invalid files — do NOT
|
|
53
|
+
* catch and treat as first-run, that would let an attacker who corrupts the
|
|
54
|
+
* file downgrade a drift event to first-seen acceptance.
|
|
55
|
+
*/
|
|
56
|
+
export async function loadFingerprintStore(baseDir) {
|
|
57
|
+
const filePath = storePathFor(baseDir);
|
|
58
|
+
let raw;
|
|
59
|
+
try {
|
|
60
|
+
raw = await fs.readFile(filePath, 'utf8');
|
|
61
|
+
}
|
|
62
|
+
catch (err) {
|
|
63
|
+
if (err.code === 'ENOENT') {
|
|
64
|
+
return { version: FINGERPRINT_STORE_VERSION, servers: {} };
|
|
65
|
+
}
|
|
66
|
+
throw new Error(`failed to read fingerprint store at ${filePath}: ${err instanceof Error ? err.message : err}`);
|
|
67
|
+
}
|
|
68
|
+
let parsed;
|
|
69
|
+
try {
|
|
70
|
+
parsed = JSON.parse(raw);
|
|
71
|
+
}
|
|
72
|
+
catch (err) {
|
|
73
|
+
throw new Error(`fingerprint store at ${filePath} is not valid JSON — delete the file to re-bootstrap TOFU if this is intentional: ${err instanceof Error ? err.message : err}`);
|
|
74
|
+
}
|
|
75
|
+
const result = FingerprintStoreSchema.safeParse(parsed);
|
|
76
|
+
if (!result.success) {
|
|
77
|
+
throw new Error(`fingerprint store at ${filePath} failed schema validation: ${result.error.message}`);
|
|
78
|
+
}
|
|
79
|
+
return result.data;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Persist the fingerprint store. Writes to a sibling `.new` file then
|
|
83
|
+
* renames into place so a crashed process never leaves a half-written store
|
|
84
|
+
* that would fail to parse on next boot.
|
|
85
|
+
*/
|
|
86
|
+
export async function saveFingerprintStore(baseDir, store) {
|
|
87
|
+
const filePath = storePathFor(baseDir);
|
|
88
|
+
const tmpPath = `${filePath}.new`;
|
|
89
|
+
await fs.mkdir(path.join(baseDir, REA_DIR), { recursive: true });
|
|
90
|
+
// Validate before write — a malformed in-memory store should never be
|
|
91
|
+
// persisted. The parse is cheap and catches bugs in the classify layer.
|
|
92
|
+
FingerprintStoreSchema.parse(store);
|
|
93
|
+
const serialized = JSON.stringify(store, null, 2) + '\n';
|
|
94
|
+
await fs.writeFile(tmpPath, serialized, 'utf8');
|
|
95
|
+
try {
|
|
96
|
+
await fs.rename(tmpPath, filePath);
|
|
97
|
+
}
|
|
98
|
+
catch (err) {
|
|
99
|
+
// Best-effort cleanup of the orphaned .new file so a retry doesn't
|
|
100
|
+
// accumulate cruft. If the unlink itself fails, swallow — the original
|
|
101
|
+
// rename error is the one the caller needs to see.
|
|
102
|
+
try {
|
|
103
|
+
await fs.unlink(tmpPath);
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
/* ignore cleanup failure */
|
|
107
|
+
}
|
|
108
|
+
throw err;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
export { FingerprintStoreSchema, storePathFor as __fingerprintStorePathForTests };
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Environment-variable interpolation for the registry's explicit `env:` map.
|
|
3
|
+
*
|
|
4
|
+
* Supports a deliberately minimal syntax — ONLY `${VAR}` (curly-brace form)
|
|
5
|
+
* in env VALUES (keys are never interpolated). This keeps the surface area
|
|
6
|
+
* small enough to reason about:
|
|
7
|
+
*
|
|
8
|
+
* - No bare `$VAR` form (ambiguous with shell semantics).
|
|
9
|
+
* - No default syntax (`${VAR:-fallback}`) — 0.3.0 ships without it.
|
|
10
|
+
* - No command substitution (`$(cmd)`) — never.
|
|
11
|
+
* - No recursive expansion. If `${FOO}` resolves to a string that itself
|
|
12
|
+
* contains `${BAR}`, the inner text is treated as a literal. This is
|
|
13
|
+
* intentional to prevent a malicious env var contents from triggering
|
|
14
|
+
* a second round of lookups.
|
|
15
|
+
*
|
|
16
|
+
* Var names follow POSIX identifier rules: `^[A-Za-z_][A-Za-z0-9_]*$`.
|
|
17
|
+
* Anything else inside `${...}` is a syntax error.
|
|
18
|
+
*
|
|
19
|
+
* Secret tagging: if either the env KEY OR any referenced `${VAR}` NAME
|
|
20
|
+
* matches the secret-name heuristic (TOKEN/KEY/SECRET/PASSWORD/CREDENTIAL),
|
|
21
|
+
* the resolved entry's key is added to `secretKeys`. Callers use this to
|
|
22
|
+
* gate logging / redaction decisions. The resolved VALUE never flows into
|
|
23
|
+
* audit records on its own — downstream.ts passes it straight to the child
|
|
24
|
+
* transport — but `secretKeys` is exported so a future telemetry path can
|
|
25
|
+
* make the right call without re-deriving the heuristic.
|
|
26
|
+
*/
|
|
27
|
+
/**
|
|
28
|
+
* Regex used to flag env keys and interpolated var names that look like
|
|
29
|
+
* secrets. Kept in sync with the same pattern in `registry/loader.ts`.
|
|
30
|
+
*/
|
|
31
|
+
export declare const SECRET_NAME_HEURISTIC: RegExp;
|
|
32
|
+
export interface InterpolateResult {
|
|
33
|
+
/** Env map with every `${VAR}` resolved against `processEnv`. */
|
|
34
|
+
resolved: Record<string, string>;
|
|
35
|
+
/**
|
|
36
|
+
* Names of env vars referenced by the template but absent from
|
|
37
|
+
* `processEnv` (or present but not a string). Empty when every
|
|
38
|
+
* reference was satisfied. Deduplicated, in first-seen order.
|
|
39
|
+
*/
|
|
40
|
+
missing: string[];
|
|
41
|
+
/**
|
|
42
|
+
* Env KEYS in `resolved` that should be treated as secret-bearing —
|
|
43
|
+
* either because the key name itself matches the heuristic, or
|
|
44
|
+
* because one of the `${VAR}` names referenced in its value did.
|
|
45
|
+
* Callers MUST NOT log the resolved value of these keys.
|
|
46
|
+
*/
|
|
47
|
+
secretKeys: string[];
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Interpolate `${VAR}` placeholders in every value of `rawEnv` against
|
|
51
|
+
* `processEnv`. Pure function — no I/O, no mutation of inputs.
|
|
52
|
+
*
|
|
53
|
+
* Throws on malformed syntax (unterminated brace, empty name, illegal
|
|
54
|
+
* identifier chars). Malformed templates are a LOAD-TIME problem, not a
|
|
55
|
+
* runtime one, so the throw bubbles up to the registry loader / server
|
|
56
|
+
* spawn path where it can be reported with file + key context.
|
|
57
|
+
*/
|
|
58
|
+
export declare function interpolateEnv(rawEnv: Record<string, string>, processEnv: NodeJS.ProcessEnv): InterpolateResult;
|