@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.
Files changed (65) hide show
  1. package/.husky/pre-push +15 -18
  2. package/README.md +41 -1
  3. package/THREAT_MODEL.md +100 -29
  4. package/dist/audit/append.d.ts +21 -8
  5. package/dist/audit/append.js +48 -83
  6. package/dist/audit/fs.d.ts +68 -0
  7. package/dist/audit/fs.js +171 -0
  8. package/dist/cli/audit.d.ts +40 -0
  9. package/dist/cli/audit.js +205 -0
  10. package/dist/cli/doctor.d.ts +19 -4
  11. package/dist/cli/doctor.js +172 -5
  12. package/dist/cli/index.js +26 -1
  13. package/dist/cli/init.js +93 -7
  14. package/dist/cli/install/pre-push.d.ts +335 -0
  15. package/dist/cli/install/pre-push.js +2818 -0
  16. package/dist/cli/serve.d.ts +64 -0
  17. package/dist/cli/serve.js +270 -2
  18. package/dist/cli/status.d.ts +90 -0
  19. package/dist/cli/status.js +399 -0
  20. package/dist/cli/utils.d.ts +4 -0
  21. package/dist/cli/utils.js +4 -0
  22. package/dist/gateway/audit/rotator.d.ts +116 -0
  23. package/dist/gateway/audit/rotator.js +289 -0
  24. package/dist/gateway/circuit-breaker.d.ts +17 -0
  25. package/dist/gateway/circuit-breaker.js +32 -3
  26. package/dist/gateway/downstream-pool.d.ts +2 -1
  27. package/dist/gateway/downstream-pool.js +2 -2
  28. package/dist/gateway/downstream.d.ts +39 -3
  29. package/dist/gateway/downstream.js +73 -14
  30. package/dist/gateway/log.d.ts +122 -0
  31. package/dist/gateway/log.js +334 -0
  32. package/dist/gateway/middleware/audit.d.ts +24 -1
  33. package/dist/gateway/middleware/audit.js +103 -58
  34. package/dist/gateway/middleware/blocked-paths.d.ts +0 -9
  35. package/dist/gateway/middleware/blocked-paths.js +439 -67
  36. package/dist/gateway/middleware/injection.d.ts +218 -13
  37. package/dist/gateway/middleware/injection.js +433 -51
  38. package/dist/gateway/middleware/kill-switch.d.ts +10 -1
  39. package/dist/gateway/middleware/kill-switch.js +20 -1
  40. package/dist/gateway/observability/metrics.d.ts +125 -0
  41. package/dist/gateway/observability/metrics.js +321 -0
  42. package/dist/gateway/server.d.ts +19 -0
  43. package/dist/gateway/server.js +99 -15
  44. package/dist/policy/loader.d.ts +47 -0
  45. package/dist/policy/loader.js +47 -0
  46. package/dist/policy/profiles.d.ts +13 -0
  47. package/dist/policy/profiles.js +12 -0
  48. package/dist/policy/types.d.ts +52 -0
  49. package/dist/registry/fingerprint.d.ts +73 -0
  50. package/dist/registry/fingerprint.js +81 -0
  51. package/dist/registry/fingerprints-store.d.ts +62 -0
  52. package/dist/registry/fingerprints-store.js +111 -0
  53. package/dist/registry/interpolate.d.ts +58 -0
  54. package/dist/registry/interpolate.js +121 -0
  55. package/dist/registry/loader.d.ts +2 -2
  56. package/dist/registry/loader.js +22 -1
  57. package/dist/registry/tofu-gate.d.ts +41 -0
  58. package/dist/registry/tofu-gate.js +189 -0
  59. package/dist/registry/tofu.d.ts +111 -0
  60. package/dist/registry/tofu.js +173 -0
  61. package/dist/registry/types.d.ts +9 -1
  62. package/package.json +3 -1
  63. package/profiles/bst-internal-no-codex.yaml +5 -0
  64. package/profiles/bst-internal.yaml +7 -0
  65. package/scripts/tarball-smoke.sh +197 -0
@@ -1,11 +1,43 @@
1
+ import { z } from 'zod';
1
2
  import type { Middleware } from './chain.js';
3
+ import { Tier } from '../../policy/types.js';
2
4
  import { type SafeRegex } from '../redact-safe/match-timeout.js';
3
5
  /**
4
6
  * Known prompt injection phrases (lowercase for case-insensitive matching).
5
7
  * These patterns are commonly used to override system instructions in tool
6
8
  * descriptions or resource content returned by downstream MCP servers.
9
+ *
10
+ * SECURITY (G9 follow-up): inputs are NFKC-normalized, whitespace-collapsed,
11
+ * and zero-width-stripped before matching (see `normalizeForMatch`). That
12
+ * means the phrases below can safely be written with plain ASCII spaces —
13
+ * the normalizer will fold NBSP, en-space, em-space, zero-width joiners,
14
+ * etc. into the same form so crafted Unicode variants cannot bypass.
15
+ *
16
+ * The pattern library is intentionally terse. Extending it is follow-up
17
+ * work (G9.1): pattern-set extensibility via policy is out of scope for
18
+ * this patch. Phrases added here must be short, lowercase, and tolerate
19
+ * the normalization pipeline (no Unicode, no non-ASCII punctuation).
7
20
  */
8
21
  export declare const INJECTION_PHRASES: readonly string[];
22
+ /**
23
+ * G9 follow-up — normalize an input string to a canonical form for literal
24
+ * phrase matching.
25
+ *
26
+ * 1. NFKC Unicode normalization — folds compatibility forms (fullwidth
27
+ * letters, mathematical alphanumerics) into ASCII equivalents.
28
+ * 2. Strip all Default_Ignorable_Code_Point characters — invisible codepoints
29
+ * that have no rendering and are used only to visually split or obscure
30
+ * injection keywords (soft hyphen, zero-width joiners/non-joiners/spaces,
31
+ * BIDI isolation controls, variation selectors, BOM, etc.).
32
+ * 3. Collapse any run of Unicode whitespace (including NBSP, en/em space)
33
+ * to a single ASCII space.
34
+ * 4. Lowercase — matches the case-insensitive contract of INJECTION_PHRASES.
35
+ *
36
+ * NEVER logs or exports the normalized text; it is used only for match-time
37
+ * comparison. The audit record still surfaces the PHRASE that matched, not
38
+ * the normalized input.
39
+ */
40
+ export declare function normalizeForMatch(input: string): string;
9
41
  /**
10
42
  * Base64-token scanner regex. The only regex the injection middleware runs
11
43
  * against untrusted payloads; wrapped in `SafeRegex` at middleware creation
@@ -24,6 +56,11 @@ export declare const INJECTION_BASE64_SHAPE: RegExp;
24
56
  * one invocation append to an array under this key.
25
57
  */
26
58
  export declare const INJECTION_TIMEOUT_METADATA_KEY = "injection.regex_timeout";
59
+ /**
60
+ * Audit metadata key for the classifier verdict. The value is an
61
+ * `InjectionClassifierMetadata` object.
62
+ */
63
+ export declare const INJECTION_METADATA_KEY = "injection";
27
64
  export interface InjectionTimeoutEvent {
28
65
  event: 'injection.regex_timeout';
29
66
  pattern_source: 'default';
@@ -31,10 +68,103 @@ export interface InjectionTimeoutEvent {
31
68
  input_bytes: number;
32
69
  timeout_ms: number;
33
70
  }
71
+ /**
72
+ * G9 — classifier verdict written under `ctx.metadata.injection`. The audit
73
+ * middleware exports `ctx.metadata` verbatim, so this object becomes the
74
+ * permanent record of why a call was allowed, warned, or denied.
75
+ *
76
+ * `verdict` —
77
+ * `clean`: no match, no metadata is written (this type exists only to
78
+ * describe the internal return of `classifyInjection`).
79
+ * `suspicious`: exactly one literal match at write/destructive tier, no
80
+ * base64 escalation. Warn-only by default; deny when
81
+ * `policy.injection.suspicious_blocks_writes === true`.
82
+ * `likely_injection`: always deny. Triggered by any of:
83
+ * - ≥2 distinct literal pattern matches
84
+ * - any match found after base64 decoding
85
+ * - any match at read tier (read-tier is permissive by design — a hit
86
+ * there is anomalous)
87
+ * - an unknown/missing tier (fail-closed)
88
+ * `error` (G9 follow-up): scanner failure — at least one regex call
89
+ * exceeded its worker-bounded timeout. Treated as inconclusive; the
90
+ * call is NOT denied on this signal alone, but the metadata record
91
+ * carries a stable `verdict` field so downstream audit consumers do
92
+ * not receive a timeout event without a verdict shape.
93
+ *
94
+ * `matched_patterns` — the distinct phrase strings from `INJECTION_PHRASES`
95
+ * that matched. Sorted for audit-log determinism. NEVER includes the input
96
+ * text itself (no payload leakage). On `verdict: 'error'` this array may be
97
+ * empty (the scan was abandoned).
98
+ *
99
+ * `base64_decoded` — true iff at least one match was found in content that
100
+ * was base64-decoded before matching. On `verdict: 'error'` this field
101
+ * reports whether any base64-decoded match was observed BEFORE the timeout.
102
+ */
103
+ export interface InjectionClassifierMetadata {
104
+ verdict: 'suspicious' | 'likely_injection' | 'error';
105
+ matched_patterns: string[];
106
+ base64_decoded: boolean;
107
+ }
108
+ /**
109
+ * G9 follow-up — zod schema for the `ctx.metadata.injection` record the
110
+ * middleware emits. Every emitted record has a `verdict` field; the schema
111
+ * exists so internal test code (and a follow-up public surface, once we
112
+ * decide how to expose audit-record types) can catch shape regressions —
113
+ * notably the pre-fix behavior where a regex-timeout emitted timing
114
+ * metadata under a different key without ever writing a verdict.
115
+ *
116
+ * INTERNAL today. Not reachable via the published package `exports` map
117
+ * (only `.`, `./policy`, `./middleware`, and `./audit` are public). If
118
+ * downstream consumers (e.g. Helix) need to validate audit records they
119
+ * read off `.rea/audit.jsonl`, we will promote this to a public entrypoint
120
+ * in a follow-up (filed as G9.2). Do not rely on this symbol from outside
121
+ * the rea repo yet.
122
+ */
123
+ export declare const InjectionMetadataSchema: z.ZodObject<{
124
+ verdict: z.ZodEnum<["suspicious", "likely_injection", "error"]>;
125
+ matched_patterns: z.ZodArray<z.ZodString, "many">;
126
+ base64_decoded: z.ZodBoolean;
127
+ }, "strict", z.ZodTypeAny, {
128
+ verdict: "error" | "suspicious" | "likely_injection";
129
+ matched_patterns: string[];
130
+ base64_decoded: boolean;
131
+ }, {
132
+ verdict: "error" | "suspicious" | "likely_injection";
133
+ matched_patterns: string[];
134
+ base64_decoded: boolean;
135
+ }>;
34
136
  interface CompiledInjectionPatterns {
35
137
  base64Token: SafeRegex;
36
138
  base64Shape: SafeRegex;
37
139
  }
140
+ /**
141
+ * G9 — scan result split by match origin so the classifier can distinguish
142
+ * a single-literal hit (potentially `suspicious`) from a base64-decoded hit
143
+ * (always `likely_injection`). The Sets deduplicate by phrase; same phrase
144
+ * matched twice counts as one distinct pattern.
145
+ */
146
+ export interface InjectionScanResult {
147
+ literalMatches: Set<string>;
148
+ base64DecodedMatches: Set<string>;
149
+ }
150
+ /**
151
+ * G9 — pure helper that walks an arbitrary `unknown` value and returns every
152
+ * successfully decoded base64-looking string. Decoding is attempted only for
153
+ * strings that:
154
+ * - are ≥ `MIN_BASE64_PROBE_LENGTH` (24) chars
155
+ * - have length divisible by 4 (base64 framing)
156
+ * - match the `INJECTION_BASE64_SHAPE` (`^[A-Za-z0-9+/]+=*$`)
157
+ * - decode to a UTF-8 string that is ≥95% printable and contains no null bytes
158
+ *
159
+ * NOTE: This function is NOT called from the middleware body. The inline base64
160
+ * probe in `scanStringForInjection` (via `INJECTION_BASE64_PATTERN`) already
161
+ * covers embedded base64 token detection. Calling `decodeBase64Strings` as a
162
+ * second full-tree pass would duplicate that work and add an avoidable DoS
163
+ * amplification surface (full tree traversal + decoded-string allocation for
164
+ * every base64-shaped leaf). This function is exported for testing and external
165
+ * use only.
166
+ */
167
+ export declare function decodeBase64Strings(input: unknown): string[];
38
168
  export interface ScanForInjectionOptions {
39
169
  onTimeout?: (patternId: string, input: string) => void;
40
170
  }
@@ -44,36 +174,111 @@ export interface ScanForInjectionOptions {
44
174
  */
45
175
  export declare function compileInjectionPatterns(timeoutMs: number, onTimeout?: (patternId: string, input: string) => void): CompiledInjectionPatterns;
46
176
  /**
47
- * Scan a string for known prompt injection phrases.
48
- * Also decodes base64 tokens and checks the decoded content.
49
- * Returns an array of matched phrase descriptions, empty if clean.
177
+ * Scan a single string and record hits into the provided `InjectionScanResult`
178
+ * buckets. Exported for test surface and for callers who want to scan a known
179
+ * string without walking a tree.
50
180
  *
51
- * The `safe` parameter carries precompiled SafeRegex wrappers; callers build
52
- * it once via `compileInjectionPatterns`.
181
+ * - Literal matches (case-insensitive substring) go into `literalMatches`.
182
+ * - Base64-decoded matches (tokens extracted via `INJECTION_BASE64_PATTERN`,
183
+ * decoded, then re-scanned for literals) go into `base64DecodedMatches`.
184
+ *
185
+ * Set semantics dedupe by phrase: the same phrase matched five times in one
186
+ * string counts as one distinct pattern, which is intentional for the
187
+ * classifier's "≥2 distinct patterns → likely" rule.
188
+ */
189
+ export declare function scanStringForInjection(input: string, result: InjectionScanResult, safe: CompiledInjectionPatterns): void;
190
+ /**
191
+ * Back-compat wrapper: legacy callers (and the old audit-metadata consumer)
192
+ * received a flat `string[]` of "literal: …" / "base64-encoded: …" descriptions.
193
+ * Kept as an exported helper so `scripts/lint-safe-regex.mjs` and any external
194
+ * consumer that imported it continue to work. New code should call
195
+ * `scanStringForInjection` directly.
53
196
  */
54
197
  export declare function scanForInjection(input: string, safe: CompiledInjectionPatterns): string[];
198
+ /**
199
+ * Recursively scan an unknown value (string, array, or plain object) and
200
+ * accumulate matches into the supplied `InjectionScanResult` buckets.
201
+ */
202
+ export declare function scanValueForInjection(value: unknown, result: InjectionScanResult, safe: CompiledInjectionPatterns): void;
203
+ /**
204
+ * G9 — classify a scan result into `clean` / `suspicious` / `likely_injection`
205
+ * using the tier and the distinctness of literal matches.
206
+ *
207
+ * Decision table (first match wins):
208
+ *
209
+ * 1. No literal AND no base64-decoded matches
210
+ * → { verdict: 'clean' }
211
+ * 2. Any base64-decoded match (regardless of count/tier)
212
+ * → { verdict: 'likely_injection', base64_decoded: true }
213
+ * 3. ≥2 distinct literal matches
214
+ * → { verdict: 'likely_injection' }
215
+ * 4. Tier is Read (or undefined — fail closed)
216
+ * → { verdict: 'likely_injection' }
217
+ * 5. Exactly 1 literal match at Write/Destructive
218
+ * → { verdict: 'suspicious' }
219
+ *
220
+ * Extension point: a future "deny-tag" per-pattern metadata layer can force
221
+ * any match to `likely_injection`. Not wired in this PR — TODO below.
222
+ */
223
+ export type InjectionClassification = {
224
+ verdict: 'clean';
225
+ } | InjectionClassifierMetadata;
226
+ export declare function classifyInjection(scan: InjectionScanResult, tier: Tier | undefined): InjectionClassification;
55
227
  export type InjectionAction = 'block' | 'warn';
56
228
  export interface InjectionMiddlewareOptions {
57
229
  /** Timeout budget for each regex call. Default 100ms. */
58
230
  matchTimeoutMs?: number;
231
+ /**
232
+ * G9 — governs whether `suspicious` classifications at write/destructive
233
+ * tier deny. `likely_injection` is ALWAYS deny regardless of this flag.
234
+ *
235
+ * Interpreted by `createInjectionMiddleware` with the `action` parameter:
236
+ *
237
+ * - `action: 'warn'` (legacy `injection_detection: warn`): this flag is
238
+ * IGNORED. Suspicious stays warn-only, 0.2.x parity. Operators who
239
+ * pinned warn mode never get a suspicious-deny, even if a profile
240
+ * layer flipped the flag to `true`.
241
+ * - `action: 'block'` + flag `true`: suspicious denies.
242
+ * - `action: 'block'` + flag `false`: suspicious warns only.
243
+ * - `action: 'block'` + flag `undefined` (UNSET): defaults to `false`
244
+ * to preserve 0.3.x behavior. Consumers who omit the `injection:`
245
+ * policy block will NOT be silently tightened on upgrade. To enable
246
+ * the stricter posture, set `injection.suspicious_blocks_writes: true`
247
+ * explicitly in policy (or use the bst-internal profile, which
248
+ * already sets it).
249
+ *
250
+ * Wired from `policy.injection.suspicious_blocks_writes` by the gateway.
251
+ * When the policy omits the `injection:` block, the field is `undefined`
252
+ * (the loader schema no longer applies a default) so this middleware can
253
+ * distinguish "not configured" from "explicitly false".
254
+ */
255
+ suspiciousBlocksWrites?: boolean;
59
256
  }
60
257
  /**
61
- * PostToolUse middleware: scans tool results for prompt injection patterns.
258
+ * PostToolUse middleware: classifies tool results for prompt injection.
259
+ *
260
+ * G9 tiered classifier:
261
+ * - `clean` → allow, no log
262
+ * - `suspicious` → warn (stderr + audit metadata `injection.suspicious`).
263
+ * Denies only when `suspiciousBlocksWrites: true`.
264
+ * - `likely_injection` → always deny, always log.
62
265
  *
63
266
  * Operates on tool output (ctx.result) returned from downstream MCP servers.
64
- * On detection:
65
- * - Always logs to audit metadata and emits a warning to stderr.
66
- * - If action is 'block' (default), sets ctx.status to Denied and blocks the result.
67
- * - If action is 'warn', allows the result through with a warning only.
68
267
  *
69
- * SECURITY: Checking PostToolUse (after downstream execution, before the result
70
- * reaches the LLM) is the correct place to catch injection in tool descriptions
71
- * and resource content coming from potentially untrusted downstream servers.
268
+ * SECURITY: Checking PostToolUse (after downstream execution, before the
269
+ * result reaches the LLM) is the correct place to catch injection in tool
270
+ * descriptions and resource content coming from potentially untrusted
271
+ * downstream servers.
72
272
  *
73
273
  * SECURITY (G3): The only regexes this middleware runs are wrapped in
74
274
  * `SafeRegex` with a 100ms default per-call timeout. On timeout the scanner
75
275
  * records an audit event and proceeds — blocking is governed by the literal
76
276
  * substring checks (which have no ReDoS surface).
277
+ *
278
+ * The legacy `action` parameter (`'block' | 'warn'`) selects the fallback
279
+ * behavior for `suspicious` verdicts when the G9 flag is unset — preserving
280
+ * 0.2.x `injection_detection: 'warn'` semantics for operators who pinned it.
281
+ * `likely_injection` ignores this parameter.
77
282
  */
78
283
  export declare function createInjectionMiddleware(action?: InjectionAction, opts?: InjectionMiddlewareOptions): Middleware;
79
284
  export {};