@bookedsolid/rea 0.2.1 → 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.
- package/.husky/pre-push +15 -18
- package/README.md +41 -1
- package/THREAT_MODEL.md +100 -29
- package/dist/audit/append.d.ts +21 -8
- package/dist/audit/append.js +48 -83
- package/dist/audit/fs.d.ts +68 -0
- package/dist/audit/fs.js +171 -0
- package/dist/cli/audit.d.ts +40 -0
- package/dist/cli/audit.js +205 -0
- package/dist/cli/doctor.d.ts +19 -4
- package/dist/cli/doctor.js +172 -5
- package/dist/cli/index.js +26 -1
- package/dist/cli/init.js +93 -7
- 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/utils.d.ts +4 -0
- package/dist/cli/utils.js +4 -0
- package/dist/gateway/audit/rotator.d.ts +116 -0
- package/dist/gateway/audit/rotator.js +289 -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 +24 -1
- package/dist/gateway/middleware/audit.js +103 -58
- 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 +47 -0
- package/dist/policy/loader.js +47 -0
- package/dist/policy/profiles.d.ts +13 -0
- package/dist/policy/profiles.js +12 -0
- package/dist/policy/types.d.ts +52 -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/package.json +3 -1
- package/profiles/bst-internal-no-codex.yaml +5 -0
- package/profiles/bst-internal.yaml +7 -0
- package/scripts/tarball-smoke.sh +197 -0
|
@@ -0,0 +1,121 @@
|
|
|
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 const SECRET_NAME_HEURISTIC = /(TOKEN|KEY|SECRET|PASSWORD|CREDENTIAL)/i;
|
|
32
|
+
/** POSIX identifier — matches legal env var names. */
|
|
33
|
+
const VAR_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
34
|
+
/**
|
|
35
|
+
* Matches `${...}` with ANY inner content (including empty / illegal). The
|
|
36
|
+
* inner content is re-validated against VAR_NAME_RE so we can emit a clear
|
|
37
|
+
* error message per-occurrence.
|
|
38
|
+
*
|
|
39
|
+
* Note: the regex is non-greedy and bounded by `}`, so an unterminated
|
|
40
|
+
* `${foo` will NOT match here — callers detect unterminated braces by
|
|
41
|
+
* scanning for a literal `${` with no matching `}` after replacement.
|
|
42
|
+
*/
|
|
43
|
+
const PLACEHOLDER_RE = /\$\{([^}]*)\}/g;
|
|
44
|
+
/**
|
|
45
|
+
* Interpolate `${VAR}` placeholders in every value of `rawEnv` against
|
|
46
|
+
* `processEnv`. Pure function — no I/O, no mutation of inputs.
|
|
47
|
+
*
|
|
48
|
+
* Throws on malformed syntax (unterminated brace, empty name, illegal
|
|
49
|
+
* identifier chars). Malformed templates are a LOAD-TIME problem, not a
|
|
50
|
+
* runtime one, so the throw bubbles up to the registry loader / server
|
|
51
|
+
* spawn path where it can be reported with file + key context.
|
|
52
|
+
*/
|
|
53
|
+
export function interpolateEnv(rawEnv, processEnv) {
|
|
54
|
+
const resolved = {};
|
|
55
|
+
const missingSet = new Set();
|
|
56
|
+
const missing = [];
|
|
57
|
+
const secretKeys = [];
|
|
58
|
+
for (const [key, template] of Object.entries(rawEnv)) {
|
|
59
|
+
validateNoUnterminatedBrace(key, template);
|
|
60
|
+
const referencedSecretName = { seen: false };
|
|
61
|
+
let anyMissing = false;
|
|
62
|
+
const replaced = template.replace(PLACEHOLDER_RE, (_match, inner) => {
|
|
63
|
+
if (inner.length === 0) {
|
|
64
|
+
throw new Error(`registry env value for "${key}" contains empty \${} placeholder — expected \${VAR}`);
|
|
65
|
+
}
|
|
66
|
+
if (!VAR_NAME_RE.test(inner)) {
|
|
67
|
+
throw new Error(`registry env value for "${key}" references invalid var name "${inner}" — ` +
|
|
68
|
+
'expected POSIX identifier matching /^[A-Za-z_][A-Za-z0-9_]*$/');
|
|
69
|
+
}
|
|
70
|
+
if (SECRET_NAME_HEURISTIC.test(inner)) {
|
|
71
|
+
referencedSecretName.seen = true;
|
|
72
|
+
}
|
|
73
|
+
const v = processEnv[inner];
|
|
74
|
+
if (typeof v !== 'string') {
|
|
75
|
+
if (!missingSet.has(inner)) {
|
|
76
|
+
missingSet.add(inner);
|
|
77
|
+
missing.push(inner);
|
|
78
|
+
}
|
|
79
|
+
anyMissing = true;
|
|
80
|
+
// Return the original placeholder so tests can see the unresolved
|
|
81
|
+
// template if they inspect resolved[key]. Callers MUST consult
|
|
82
|
+
// `missing` and refuse to start the server — they should not ship
|
|
83
|
+
// this value to a child process.
|
|
84
|
+
return `\${${inner}}`;
|
|
85
|
+
}
|
|
86
|
+
return v;
|
|
87
|
+
});
|
|
88
|
+
// A key is secret-bearing when either (a) its name matches the heuristic
|
|
89
|
+
// or (b) any `${VAR}` it references does. This matches the redact-by-default
|
|
90
|
+
// contract documented in the PR body: the template is auditable, the
|
|
91
|
+
// runtime value is not.
|
|
92
|
+
if (SECRET_NAME_HEURISTIC.test(key) || referencedSecretName.seen) {
|
|
93
|
+
secretKeys.push(key);
|
|
94
|
+
}
|
|
95
|
+
// Record resolved value regardless of missing — downstream caller uses
|
|
96
|
+
// `missing` as the sole signal for "refuse to start". If the caller
|
|
97
|
+
// chooses to proceed, the unresolved placeholder is a loud canary.
|
|
98
|
+
void anyMissing;
|
|
99
|
+
resolved[key] = replaced;
|
|
100
|
+
}
|
|
101
|
+
return { resolved, missing, secretKeys };
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Scan for a literal `${` that has no matching `}` after it. The main
|
|
105
|
+
* replace pass uses a regex that REQUIRES `}`, so unterminated opens
|
|
106
|
+
* would be silently kept as literals without this pre-check — which
|
|
107
|
+
* would ship a raw `${...` string to the child, nearly always a bug.
|
|
108
|
+
*/
|
|
109
|
+
function validateNoUnterminatedBrace(key, template) {
|
|
110
|
+
let i = 0;
|
|
111
|
+
while (i < template.length) {
|
|
112
|
+
const open = template.indexOf('${', i);
|
|
113
|
+
if (open === -1)
|
|
114
|
+
return;
|
|
115
|
+
const close = template.indexOf('}', open + 2);
|
|
116
|
+
if (close === -1) {
|
|
117
|
+
throw new Error(`registry env value for "${key}" contains unterminated \${ — add a closing } or escape the literal`);
|
|
118
|
+
}
|
|
119
|
+
i = close + 1;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
@@ -12,7 +12,7 @@ declare const RegistryServerSchema: z.ZodObject<{
|
|
|
12
12
|
name: z.ZodString;
|
|
13
13
|
command: z.ZodString;
|
|
14
14
|
args: z.ZodDefault<z.ZodArray<z.ZodString, "many">>;
|
|
15
|
-
env: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodString
|
|
15
|
+
env: z.ZodEffects<z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodString>>, Record<string, string>, Record<string, string> | undefined>;
|
|
16
16
|
env_passthrough: z.ZodOptional<z.ZodArray<z.ZodEffects<z.ZodString, string, string>, "many">>;
|
|
17
17
|
tier_overrides: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodNativeEnum<typeof Tier>>>;
|
|
18
18
|
enabled: z.ZodDefault<z.ZodBoolean>;
|
|
@@ -39,7 +39,7 @@ declare const RegistrySchema: z.ZodObject<{
|
|
|
39
39
|
name: z.ZodString;
|
|
40
40
|
command: z.ZodString;
|
|
41
41
|
args: z.ZodDefault<z.ZodArray<z.ZodString, "many">>;
|
|
42
|
-
env: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodString
|
|
42
|
+
env: z.ZodEffects<z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodString>>, Record<string, string>, Record<string, string> | undefined>;
|
|
43
43
|
env_passthrough: z.ZodOptional<z.ZodArray<z.ZodEffects<z.ZodString, string, string>, "many">>;
|
|
44
44
|
tier_overrides: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodNativeEnum<typeof Tier>>>;
|
|
45
45
|
enabled: z.ZodDefault<z.ZodBoolean>;
|
package/dist/registry/loader.js
CHANGED
|
@@ -11,6 +11,7 @@ import path from 'node:path';
|
|
|
11
11
|
import { parse as parseYaml } from 'yaml';
|
|
12
12
|
import { z } from 'zod';
|
|
13
13
|
import { Tier } from '../policy/types.js';
|
|
14
|
+
import { interpolateEnv } from './interpolate.js';
|
|
14
15
|
/**
|
|
15
16
|
* Regex used to refuse passthrough of var names that look like secrets.
|
|
16
17
|
* Explicit `env:` mapping is the escape hatch — if a user types the value into
|
|
@@ -25,7 +26,27 @@ const RegistryServerSchema = z
|
|
|
25
26
|
.regex(/^[a-z0-9][a-z0-9-]*$/, 'server name must be lowercase-kebab and cannot start with a dash'),
|
|
26
27
|
command: z.string().min(1),
|
|
27
28
|
args: z.array(z.string()).default([]),
|
|
28
|
-
|
|
29
|
+
// Values may contain `${VAR}` placeholders (see `registry/interpolate.ts`)
|
|
30
|
+
// which resolve at server-spawn time against rea-serve's own process.env.
|
|
31
|
+
// We validate SYNTAX at load time by running a dry-run pass with an empty
|
|
32
|
+
// host env — interpolateEnv throws on malformed syntax but treats missing
|
|
33
|
+
// vars as runtime signal, not a load-time error. The runtime check lives
|
|
34
|
+
// in `downstream.ts` where it can mark the affected server unhealthy
|
|
35
|
+
// without taking down the gateway.
|
|
36
|
+
env: z
|
|
37
|
+
.record(z.string())
|
|
38
|
+
.default({})
|
|
39
|
+
.superRefine((raw, ctx) => {
|
|
40
|
+
try {
|
|
41
|
+
interpolateEnv(raw, {});
|
|
42
|
+
}
|
|
43
|
+
catch (err) {
|
|
44
|
+
ctx.addIssue({
|
|
45
|
+
code: z.ZodIssueCode.custom,
|
|
46
|
+
message: err instanceof Error ? err.message : String(err),
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
}),
|
|
29
50
|
env_passthrough: z
|
|
30
51
|
.array(z
|
|
31
52
|
.string()
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TOFU gate — the startup-time bridge between `classifyServers` and the
|
|
3
|
+
* gateway's downstream pool.
|
|
4
|
+
*
|
|
5
|
+
* Responsibilities:
|
|
6
|
+
*
|
|
7
|
+
* 1. Load the current fingerprint store (or treat missing as first-run).
|
|
8
|
+
* 2. Classify every server in the registry.
|
|
9
|
+
* 3. Emit the side effects a classification implies:
|
|
10
|
+
* - `first-seen` → audit allowed, LOUD stderr block, log info.
|
|
11
|
+
* - `unchanged` → silent.
|
|
12
|
+
* - `drifted` (no bypass) → audit denied, stderr warn, log warn,
|
|
13
|
+
* and the server is FILTERED OUT of the returned set.
|
|
14
|
+
* - `drifted` (bypass via REA_ACCEPT_DRIFT) → log info, audit allowed
|
|
15
|
+
* with `bypassed: true` in metadata; server remains in the set.
|
|
16
|
+
* 4. Persist the updated store.
|
|
17
|
+
*
|
|
18
|
+
* Returns the filtered `RegistryServer[]` the gateway should actually wire
|
|
19
|
+
* into its `DownstreamPool`. Drifted-without-bypass servers are DROPPED here
|
|
20
|
+
* so the pool never has a chance to spawn them — the gateway stays up,
|
|
21
|
+
* other servers remain available, the upstream client just sees a smaller
|
|
22
|
+
* tool catalog.
|
|
23
|
+
*/
|
|
24
|
+
import type { RegistryServer } from './types.js';
|
|
25
|
+
import { type TofuClassification } from './tofu.js';
|
|
26
|
+
import { type Logger } from '../gateway/log.js';
|
|
27
|
+
export interface TofuGateResult {
|
|
28
|
+
accepted: RegistryServer[];
|
|
29
|
+
classifications: TofuClassification[];
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Apply the TOFU gate and return the filtered server set for the pool.
|
|
33
|
+
*
|
|
34
|
+
* Audit, stderr, and structured-log side effects all fire here. The caller
|
|
35
|
+
* (gateway startup) does not need to repeat them.
|
|
36
|
+
*
|
|
37
|
+
* The optional `logger` is the G5 gateway logger threaded from `rea serve`.
|
|
38
|
+
* When omitted (tests, doctor callers without a serve session), a default
|
|
39
|
+
* logger is created so TOFU records always participate in structured output.
|
|
40
|
+
*/
|
|
41
|
+
export declare function applyTofuGate(baseDir: string, servers: RegistryServer[], logger?: Logger): Promise<TofuGateResult>;
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TOFU gate — the startup-time bridge between `classifyServers` and the
|
|
3
|
+
* gateway's downstream pool.
|
|
4
|
+
*
|
|
5
|
+
* Responsibilities:
|
|
6
|
+
*
|
|
7
|
+
* 1. Load the current fingerprint store (or treat missing as first-run).
|
|
8
|
+
* 2. Classify every server in the registry.
|
|
9
|
+
* 3. Emit the side effects a classification implies:
|
|
10
|
+
* - `first-seen` → audit allowed, LOUD stderr block, log info.
|
|
11
|
+
* - `unchanged` → silent.
|
|
12
|
+
* - `drifted` (no bypass) → audit denied, stderr warn, log warn,
|
|
13
|
+
* and the server is FILTERED OUT of the returned set.
|
|
14
|
+
* - `drifted` (bypass via REA_ACCEPT_DRIFT) → log info, audit allowed
|
|
15
|
+
* with `bypassed: true` in metadata; server remains in the set.
|
|
16
|
+
* 4. Persist the updated store.
|
|
17
|
+
*
|
|
18
|
+
* Returns the filtered `RegistryServer[]` the gateway should actually wire
|
|
19
|
+
* into its `DownstreamPool`. Drifted-without-bypass servers are DROPPED here
|
|
20
|
+
* so the pool never has a chance to spawn them — the gateway stays up,
|
|
21
|
+
* other servers remain available, the upstream client just sees a smaller
|
|
22
|
+
* tool catalog.
|
|
23
|
+
*/
|
|
24
|
+
import { Tier, InvocationStatus } from '../policy/types.js';
|
|
25
|
+
import { appendAuditRecord } from '../audit/append.js';
|
|
26
|
+
import { loadFingerprintStore, saveFingerprintStore, } from './fingerprints-store.js';
|
|
27
|
+
import { classifyServers, updateStore, } from './tofu.js';
|
|
28
|
+
import { createLogger } from '../gateway/log.js';
|
|
29
|
+
const TOFU_TOOL_NAME = 'rea.tofu';
|
|
30
|
+
const TOFU_SERVER_NAME = 'rea';
|
|
31
|
+
/** Inner box width (characters between the vertical borders). */
|
|
32
|
+
const BOX_INNER_WIDTH = 64;
|
|
33
|
+
/**
|
|
34
|
+
* Render a line inside the TOFU banner box. Truncates overlong content with
|
|
35
|
+
* an ellipsis so the right border stays aligned even if a server name or
|
|
36
|
+
* label is unusually long. Pure string formatting — no side effects.
|
|
37
|
+
*/
|
|
38
|
+
function boxLine(content) {
|
|
39
|
+
const padded = ` ${content}`;
|
|
40
|
+
const truncated = padded.length > BOX_INNER_WIDTH ? padded.slice(0, BOX_INNER_WIDTH - 1) + '…' : padded;
|
|
41
|
+
return ` ║${truncated.padEnd(BOX_INNER_WIDTH, ' ')}║`;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Apply the TOFU gate and return the filtered server set for the pool.
|
|
45
|
+
*
|
|
46
|
+
* Audit, stderr, and structured-log side effects all fire here. The caller
|
|
47
|
+
* (gateway startup) does not need to repeat them.
|
|
48
|
+
*
|
|
49
|
+
* The optional `logger` is the G5 gateway logger threaded from `rea serve`.
|
|
50
|
+
* When omitted (tests, doctor callers without a serve session), a default
|
|
51
|
+
* logger is created so TOFU records always participate in structured output.
|
|
52
|
+
*/
|
|
53
|
+
export async function applyTofuGate(baseDir, servers, logger) {
|
|
54
|
+
const log = logger ?? createLogger();
|
|
55
|
+
const store = await loadFingerprintStore(baseDir);
|
|
56
|
+
const acceptDrift = process.env.REA_ACCEPT_DRIFT;
|
|
57
|
+
const classifications = classifyServers(servers, store, acceptDrift !== undefined ? { acceptDrift } : {});
|
|
58
|
+
const byName = new Map(servers.map((s) => [s.name, s]));
|
|
59
|
+
const accepted = [];
|
|
60
|
+
for (const c of classifications) {
|
|
61
|
+
const server = byName.get(c.server);
|
|
62
|
+
if (server === undefined)
|
|
63
|
+
continue; // defensive — classifyServers preserves order
|
|
64
|
+
await emitSideEffects(baseDir, c, log);
|
|
65
|
+
if (c.verdict === 'drifted' && !c.bypassed)
|
|
66
|
+
continue;
|
|
67
|
+
accepted.push(server);
|
|
68
|
+
}
|
|
69
|
+
const nextStore = updateStore(store, classifications);
|
|
70
|
+
await saveFingerprintStore(baseDir, nextStore);
|
|
71
|
+
return { accepted, classifications };
|
|
72
|
+
}
|
|
73
|
+
async function emitSideEffects(baseDir, c, log) {
|
|
74
|
+
if (c.verdict === 'unchanged')
|
|
75
|
+
return;
|
|
76
|
+
if (c.verdict === 'first-seen') {
|
|
77
|
+
// LOUD stderr — deliberately eye-catching. An attacker landing a poisoned
|
|
78
|
+
// registry at first install is the exact case this surface defends.
|
|
79
|
+
process.stderr.write([
|
|
80
|
+
'',
|
|
81
|
+
` ╔${'═'.repeat(BOX_INNER_WIDTH)}╗`,
|
|
82
|
+
boxLine(' rea TOFU: NEW DOWNSTREAM SERVER RECORDED'),
|
|
83
|
+
` ╠${'═'.repeat(BOX_INNER_WIDTH)}╣`,
|
|
84
|
+
boxLine(` name: ${c.server}`),
|
|
85
|
+
boxLine(` fingerprint: ${c.current.slice(0, 16)}…`),
|
|
86
|
+
boxLine(''),
|
|
87
|
+
boxLine(' If you did not add this server, STOP and inspect'),
|
|
88
|
+
boxLine(' .rea/registry.yaml before any tool call executes.'),
|
|
89
|
+
` ╚${'═'.repeat(BOX_INNER_WIDTH)}╝`,
|
|
90
|
+
'',
|
|
91
|
+
].join('\n'));
|
|
92
|
+
log.info({
|
|
93
|
+
event: 'registry.tofu.first_seen',
|
|
94
|
+
message: `TOFU: new downstream server "${c.server}" recorded on first start`,
|
|
95
|
+
server_name: c.server,
|
|
96
|
+
fingerprint: c.current,
|
|
97
|
+
});
|
|
98
|
+
await safeAudit(baseDir, log, {
|
|
99
|
+
status: InvocationStatus.Allowed,
|
|
100
|
+
metadata: {
|
|
101
|
+
event: 'tofu.first_seen',
|
|
102
|
+
server: c.server,
|
|
103
|
+
fingerprint: c.current,
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
// verdict === 'drifted'
|
|
109
|
+
if (c.bypassed) {
|
|
110
|
+
// Intentionally quieter than the unbypassed block: the operator set
|
|
111
|
+
// REA_ACCEPT_DRIFT, so this is authorized rotation and does not need
|
|
112
|
+
// the blocking banner. A single stderr line + warn-level log + audit
|
|
113
|
+
// entry is the documented UI for accepted drift.
|
|
114
|
+
process.stderr.write(`[rea] TOFU: accepting drift for "${c.server}" (REA_ACCEPT_DRIFT set) — fingerprint rotated.\n`);
|
|
115
|
+
log.warn({
|
|
116
|
+
event: 'registry.tofu.drift_accepted',
|
|
117
|
+
message: `TOFU: accepted fingerprint drift for "${c.server}" (REA_ACCEPT_DRIFT bypass)`,
|
|
118
|
+
server_name: c.server,
|
|
119
|
+
stored: c.stored,
|
|
120
|
+
current: c.current,
|
|
121
|
+
});
|
|
122
|
+
await safeAudit(baseDir, log, {
|
|
123
|
+
status: InvocationStatus.Allowed,
|
|
124
|
+
metadata: {
|
|
125
|
+
event: 'tofu.drift_accepted',
|
|
126
|
+
server: c.server,
|
|
127
|
+
stored_fingerprint: c.stored,
|
|
128
|
+
current_fingerprint: c.current,
|
|
129
|
+
bypassed: true,
|
|
130
|
+
},
|
|
131
|
+
});
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
process.stderr.write([
|
|
135
|
+
'',
|
|
136
|
+
` ╔${'═'.repeat(BOX_INNER_WIDTH)}╗`,
|
|
137
|
+
boxLine(' rea TOFU: FINGERPRINT DRIFT — SERVER BLOCKED'),
|
|
138
|
+
` ╠${'═'.repeat(BOX_INNER_WIDTH)}╣`,
|
|
139
|
+
boxLine(` name: ${c.server}`),
|
|
140
|
+
boxLine(` stored: ${(c.stored ?? '').slice(0, 16)}…`),
|
|
141
|
+
boxLine(` current: ${c.current.slice(0, 16)}…`),
|
|
142
|
+
boxLine(''),
|
|
143
|
+
boxLine(' The server will NOT connect. Other servers remain up.'),
|
|
144
|
+
boxLine(' To accept (once): REA_ACCEPT_DRIFT=<name> rea serve'),
|
|
145
|
+
` ╚${'═'.repeat(BOX_INNER_WIDTH)}╝`,
|
|
146
|
+
'',
|
|
147
|
+
].join('\n'));
|
|
148
|
+
log.warn({
|
|
149
|
+
event: 'registry.tofu.drift_blocked',
|
|
150
|
+
message: `TOFU: server fingerprint changed for "${c.server}" — possible proxy poisoning, server blocked`,
|
|
151
|
+
server_name: c.server,
|
|
152
|
+
stored: c.stored,
|
|
153
|
+
current: c.current,
|
|
154
|
+
});
|
|
155
|
+
await safeAudit(baseDir, log, {
|
|
156
|
+
status: InvocationStatus.Denied,
|
|
157
|
+
error: `fingerprint drift for "${c.server}" — server blocked (set REA_ACCEPT_DRIFT to accept)`,
|
|
158
|
+
metadata: {
|
|
159
|
+
event: 'tofu.drift_blocked',
|
|
160
|
+
server: c.server,
|
|
161
|
+
stored_fingerprint: c.stored,
|
|
162
|
+
current_fingerprint: c.current,
|
|
163
|
+
},
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Append a TOFU audit record. Errors in the audit path are logged but
|
|
168
|
+
* never propagated — an audit-log outage must not take down the gateway.
|
|
169
|
+
*/
|
|
170
|
+
async function safeAudit(baseDir, log, entry) {
|
|
171
|
+
try {
|
|
172
|
+
const input = {
|
|
173
|
+
tool_name: TOFU_TOOL_NAME,
|
|
174
|
+
server_name: TOFU_SERVER_NAME,
|
|
175
|
+
status: entry.status,
|
|
176
|
+
tier: Tier.Read,
|
|
177
|
+
metadata: entry.metadata,
|
|
178
|
+
...(entry.error !== undefined ? { error: entry.error } : {}),
|
|
179
|
+
};
|
|
180
|
+
await appendAuditRecord(baseDir, input);
|
|
181
|
+
}
|
|
182
|
+
catch (err) {
|
|
183
|
+
log.error({
|
|
184
|
+
event: 'registry.tofu.audit_failed',
|
|
185
|
+
message: 'TOFU: audit append failed — gateway continues, record lost',
|
|
186
|
+
error: err instanceof Error ? err.message : String(err),
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TOFU classifier — the G7 gate between `.rea/registry.yaml` and the
|
|
3
|
+
* downstream pool.
|
|
4
|
+
*
|
|
5
|
+
* For each server declared in the registry, classify as:
|
|
6
|
+
*
|
|
7
|
+
* - `first-seen` — no entry in `.rea/fingerprints.json`. Record the
|
|
8
|
+
* fingerprint, surface a LOUD block to the operator, allow the server
|
|
9
|
+
* to connect. This is the TOFU trust-on-first-use decision; the
|
|
10
|
+
* loudness is deliberate so a silent poisoning at first install is
|
|
11
|
+
* still visible in stderr / audit / logs.
|
|
12
|
+
*
|
|
13
|
+
* - `unchanged` — fingerprint matches the stored value. Proceed normally.
|
|
14
|
+
*
|
|
15
|
+
* - `drifted` — fingerprint differs from the stored value. Refuse to
|
|
16
|
+
* connect the server unless `REA_ACCEPT_DRIFT` names it for a single
|
|
17
|
+
* boot. The rest of the gateway stays up — other servers remain
|
|
18
|
+
* available, the upstream client just sees a smaller catalog.
|
|
19
|
+
*
|
|
20
|
+
* The audit entry, log line, and stderr block are emitted by the caller
|
|
21
|
+
* (the gateway startup sequence). This module is pure classification plus
|
|
22
|
+
* store updates; keeping it side-effect-free makes it unit-testable
|
|
23
|
+
* without stubbing the filesystem or the audit chain.
|
|
24
|
+
*/
|
|
25
|
+
import type { RegistryServer } from './types.js';
|
|
26
|
+
import type { FingerprintStore } from './fingerprints-store.js';
|
|
27
|
+
export type TofuVerdict = 'first-seen' | 'unchanged' | 'drifted';
|
|
28
|
+
export interface TofuClassification {
|
|
29
|
+
server: string;
|
|
30
|
+
verdict: TofuVerdict;
|
|
31
|
+
/** Current fingerprint (always present — we always compute it). */
|
|
32
|
+
current: string;
|
|
33
|
+
/** Stored fingerprint, when one existed. Absent for `first-seen`. */
|
|
34
|
+
stored?: string;
|
|
35
|
+
/** Whether `REA_ACCEPT_DRIFT` bypass was honored for this server. */
|
|
36
|
+
bypassed: boolean;
|
|
37
|
+
}
|
|
38
|
+
export interface ClassifyOptions {
|
|
39
|
+
/**
|
|
40
|
+
* Raw value of `REA_ACCEPT_DRIFT`. Accepts a single server name or a
|
|
41
|
+
* comma-separated list. Whitespace is trimmed. Empty or undefined means
|
|
42
|
+
* no bypass.
|
|
43
|
+
*/
|
|
44
|
+
acceptDrift?: string;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Classify every server in `servers` against the loaded `store`. Pure:
|
|
48
|
+
* does not read or write the filesystem. Returns one classification per
|
|
49
|
+
* server in the same order.
|
|
50
|
+
*
|
|
51
|
+
* ## Rename-with-removal defense (scope: narrow)
|
|
52
|
+
*
|
|
53
|
+
* A name-only lookup is not enough when an attacker rewrites a
|
|
54
|
+
* previously trusted entry AND changes its `name` at the same time: the
|
|
55
|
+
* stored lookup would miss and the tampered entry would land as benign
|
|
56
|
+
* `first-seen`. To close THAT specific shape we compute a rename signal
|
|
57
|
+
* at boot time:
|
|
58
|
+
*
|
|
59
|
+
* - `disappeared = stored_names - registry_names`
|
|
60
|
+
* - `appeared = registry_names - stored_names`
|
|
61
|
+
*
|
|
62
|
+
* If BOTH sets are non-empty in the same boot, at least one declared
|
|
63
|
+
* entry has been renamed-with-removal (stored entry vanished, a new name
|
|
64
|
+
* showed up). In that case every entry in `appeared` is promoted from
|
|
65
|
+
* `first-seen` to `drifted`: the operator MUST explicitly accept it via
|
|
66
|
+
* `REA_ACCEPT_DRIFT=<new-name>` before it connects.
|
|
67
|
+
*
|
|
68
|
+
* ### What this defense does NOT catch
|
|
69
|
+
*
|
|
70
|
+
* If the attacker leaves the old trusted entry in the registry (e.g.
|
|
71
|
+
* flipped `enabled: false`, or left untouched as a decoy) and ADDS a
|
|
72
|
+
* tampered entry under a new name, `disappeared` stays empty, the
|
|
73
|
+
* set-difference heuristic does not fire, and the new entry lands as
|
|
74
|
+
* `first-seen`. That is **not a bypass** of the TOFU contract — it is
|
|
75
|
+
* structurally identical to `operator added a new MCP server`, which
|
|
76
|
+
* TOFU intentionally allows with a LOUD stderr banner demanding the
|
|
77
|
+
* operator's attention. The first-seen banner is the enforcement
|
|
78
|
+
* mechanism for that shape; the rename-with-removal heuristic above is
|
|
79
|
+
* strictly additional coverage for the harder-to-notice shape where the
|
|
80
|
+
* old entry disappears at the same moment the new one arrives.
|
|
81
|
+
*
|
|
82
|
+
* Genuinely additive installs (new entry appended with no concurrent
|
|
83
|
+
* removal) also remain `first-seen` and get the usual LOUD banner.
|
|
84
|
+
*
|
|
85
|
+
* This is strictly additive over the name-based lookup — a name-matched
|
|
86
|
+
* drift (same name, changed config) still classifies as `drifted` via
|
|
87
|
+
* the primary path.
|
|
88
|
+
*
|
|
89
|
+
* The fingerprint itself already includes `server.name` (see
|
|
90
|
+
* `fingerprint.ts` canonicalization), so an attacker cannot make a
|
|
91
|
+
* renamed entry's fingerprint coincide with a stored one under a
|
|
92
|
+
* different name. That means a cross-name fingerprint match would never
|
|
93
|
+
* happen in practice — the set-difference heuristic above is what
|
|
94
|
+
* actually defends the rename-with-removal shape.
|
|
95
|
+
*/
|
|
96
|
+
export declare function classifyServers(servers: RegistryServer[], store: FingerprintStore, opts?: ClassifyOptions): TofuClassification[];
|
|
97
|
+
/**
|
|
98
|
+
* Merge classifications into an updated store. Applies the TOFU rule:
|
|
99
|
+
*
|
|
100
|
+
* - `first-seen` → add the current fingerprint.
|
|
101
|
+
* - `unchanged` → keep the existing value (no-op).
|
|
102
|
+
* - `drifted` → if bypassed, overwrite with the current fingerprint
|
|
103
|
+
* (operator has authorized the update); otherwise keep
|
|
104
|
+
* the stored value (drift persists across restart until
|
|
105
|
+
* explicitly accepted).
|
|
106
|
+
*
|
|
107
|
+
* Does not prune entries for servers that were removed from the registry —
|
|
108
|
+
* that decision is the operator's, and silently dropping fingerprints
|
|
109
|
+
* would let an attacker rename-then-reinstall a server to reset TOFU state.
|
|
110
|
+
*/
|
|
111
|
+
export declare function updateStore(store: FingerprintStore, classifications: TofuClassification[]): FingerprintStore;
|