@bookedsolid/rea 0.3.0 → 0.4.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 (56) hide show
  1. package/.husky/pre-push +15 -18
  2. package/README.md +41 -1
  3. package/dist/cli/doctor.d.ts +19 -4
  4. package/dist/cli/doctor.js +172 -5
  5. package/dist/cli/index.js +9 -1
  6. package/dist/cli/init.js +93 -7
  7. package/dist/cli/install/pre-push.d.ts +335 -0
  8. package/dist/cli/install/pre-push.js +2818 -0
  9. package/dist/cli/serve.d.ts +64 -0
  10. package/dist/cli/serve.js +270 -2
  11. package/dist/cli/status.d.ts +90 -0
  12. package/dist/cli/status.js +399 -0
  13. package/dist/cli/utils.d.ts +4 -0
  14. package/dist/cli/utils.js +4 -0
  15. package/dist/gateway/circuit-breaker.d.ts +17 -0
  16. package/dist/gateway/circuit-breaker.js +32 -3
  17. package/dist/gateway/downstream-pool.d.ts +2 -1
  18. package/dist/gateway/downstream-pool.js +2 -2
  19. package/dist/gateway/downstream.d.ts +39 -3
  20. package/dist/gateway/downstream.js +73 -14
  21. package/dist/gateway/log.d.ts +122 -0
  22. package/dist/gateway/log.js +334 -0
  23. package/dist/gateway/middleware/audit.d.ts +10 -1
  24. package/dist/gateway/middleware/audit.js +26 -1
  25. package/dist/gateway/middleware/blocked-paths.d.ts +0 -9
  26. package/dist/gateway/middleware/blocked-paths.js +439 -67
  27. package/dist/gateway/middleware/injection.d.ts +218 -13
  28. package/dist/gateway/middleware/injection.js +433 -51
  29. package/dist/gateway/middleware/kill-switch.d.ts +10 -1
  30. package/dist/gateway/middleware/kill-switch.js +20 -1
  31. package/dist/gateway/observability/metrics.d.ts +125 -0
  32. package/dist/gateway/observability/metrics.js +321 -0
  33. package/dist/gateway/server.d.ts +19 -0
  34. package/dist/gateway/server.js +99 -15
  35. package/dist/policy/loader.d.ts +13 -0
  36. package/dist/policy/loader.js +28 -0
  37. package/dist/policy/profiles.d.ts +13 -0
  38. package/dist/policy/profiles.js +12 -0
  39. package/dist/policy/types.d.ts +28 -0
  40. package/dist/registry/fingerprint.d.ts +73 -0
  41. package/dist/registry/fingerprint.js +81 -0
  42. package/dist/registry/fingerprints-store.d.ts +62 -0
  43. package/dist/registry/fingerprints-store.js +111 -0
  44. package/dist/registry/interpolate.d.ts +58 -0
  45. package/dist/registry/interpolate.js +121 -0
  46. package/dist/registry/loader.d.ts +2 -2
  47. package/dist/registry/loader.js +22 -1
  48. package/dist/registry/tofu-gate.d.ts +41 -0
  49. package/dist/registry/tofu-gate.js +189 -0
  50. package/dist/registry/tofu.d.ts +111 -0
  51. package/dist/registry/tofu.js +173 -0
  52. package/dist/registry/types.d.ts +9 -1
  53. package/package.json +1 -1
  54. package/profiles/bst-internal-no-codex.yaml +5 -0
  55. package/profiles/bst-internal.yaml +7 -0
  56. package/scripts/tarball-smoke.sh +197 -0
@@ -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>;
@@ -94,6 +101,9 @@ declare const PolicySchema: z.ZodObject<{
94
101
  blocked_paths: string[];
95
102
  notification_channel: string;
96
103
  injection_detection?: "block" | "warn" | undefined;
104
+ injection?: {
105
+ suspicious_blocks_writes?: boolean | undefined;
106
+ } | undefined;
97
107
  context_protection?: {
98
108
  delegate_to_subagent: string[];
99
109
  max_bash_output_lines?: number | undefined;
@@ -127,6 +137,9 @@ declare const PolicySchema: z.ZodObject<{
127
137
  block_ai_attribution?: boolean | undefined;
128
138
  notification_channel?: string | undefined;
129
139
  injection_detection?: "block" | "warn" | undefined;
140
+ injection?: {
141
+ suspicious_blocks_writes?: boolean | undefined;
142
+ } | undefined;
130
143
  context_protection?: {
131
144
  delegate_to_subagent?: string[] | undefined;
132
145
  max_bash_output_lines?: number | undefined;
@@ -64,6 +64,33 @@ const AuditPolicySchema = z
64
64
  rotation: AuditRotationPolicySchema.optional(),
65
65
  })
66
66
  .strict();
67
+ /**
68
+ * G9: injection tier escalation. `suspicious_blocks_writes` is fully
69
+ * optional at the schema layer — absence is distinguishable from an
70
+ * explicit `false`. The middleware (`createInjectionMiddleware`) then
71
+ * applies the action-aware default:
72
+ *
73
+ * - `injection_detection: block` (default) + flag unset → `true`
74
+ * (0.2.x parity — a single literal match at write/destructive tier
75
+ * still denies for upgraded consumers who omit the `injection:` block)
76
+ * - `injection_detection: block` + flag explicit `false` → `false`
77
+ * (explicit opt-out)
78
+ * - `injection_detection: warn` + flag unset or `false` → `false`
79
+ * (warn mode preserves 0.2.x warn-only semantics)
80
+ * - flag explicit `true` (pinned in `bst-internal*`) → `true`
81
+ *
82
+ * This avoids the Codex-reported regression in PR #25 where the schema
83
+ * default of `false` silently loosened `injection_detection: block`
84
+ * behavior on upgrade for non-bst consumers.
85
+ *
86
+ * `likely_injection` verdicts (multi-literal matches, base64-decoded matches,
87
+ * or any read-tier match) are ALWAYS deny regardless of this flag.
88
+ */
89
+ const InjectionPolicySchema = z
90
+ .object({
91
+ suspicious_blocks_writes: z.boolean().optional(),
92
+ })
93
+ .strict();
67
94
  const PolicySchema = z
68
95
  .object({
69
96
  version: z.string(),
@@ -77,6 +104,7 @@ const PolicySchema = z
77
104
  blocked_paths: z.array(z.string()),
78
105
  notification_channel: z.string().default(''),
79
106
  injection_detection: z.enum(['block', 'warn']).optional(),
107
+ injection: InjectionPolicySchema.optional(),
80
108
  context_protection: ContextProtectionSchema.optional(),
81
109
  review: ReviewPolicySchema.optional(),
82
110
  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;
@@ -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();
@@ -77,6 +77,33 @@ export interface AuditRotationPolicy {
77
77
  export interface AuditPolicy {
78
78
  rotation?: AuditRotationPolicy;
79
79
  }
80
+ /**
81
+ * G9 — injection tier escalation knobs. The classifier bucketed matches into
82
+ * `clean` / `suspicious` / `likely_injection`; this block governs what happens
83
+ * to the `suspicious` bucket (a single literal match at write/destructive tier,
84
+ * no base64 escalation). `likely_injection` is ALWAYS a deny regardless of
85
+ * these knobs.
86
+ *
87
+ * `suspicious_blocks_writes` —
88
+ * `undefined` (omitted): middleware defaults based on `injection_detection`:
89
+ * block mode defaults to `true` (0.2.x parity — single literal at
90
+ * write/destructive tier still denies); warn mode defaults to `false`
91
+ * (preserves 0.2.x warn-only semantics).
92
+ * `false` (explicit opt-out): suspicious matches warn-only (log + audit
93
+ * metadata, `status: allowed`), regardless of `injection_detection`.
94
+ * `true` (pinned in `bst-internal*` and this repo's own policy): suspicious
95
+ * matches at write/destructive tier deny with verdict `suspicious` in the
96
+ * audit record.
97
+ *
98
+ * G9 follow-up (post-merge Codex finding #1): the pre-patch schema default
99
+ * of `false` silently loosened 0.2.x `injection_detection: block` behavior
100
+ * for any consumer who upgraded without adding the `injection:` block.
101
+ * Making this field optional and defaulting it at the middleware restores
102
+ * 0.2.x parity.
103
+ */
104
+ export interface InjectionPolicy {
105
+ suspicious_blocks_writes?: boolean;
106
+ }
80
107
  export interface Policy {
81
108
  version: string;
82
109
  profile: string;
@@ -89,6 +116,7 @@ export interface Policy {
89
116
  blocked_paths: string[];
90
117
  notification_channel: string;
91
118
  injection_detection?: 'block' | 'warn';
119
+ injection?: InjectionPolicy;
92
120
  context_protection?: ContextProtection;
93
121
  review?: ReviewPolicy;
94
122
  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;