@a13xu/lucid 1.1.0 → 1.9.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/LICENSE +21 -21
  2. package/README.md +221 -99
  3. package/build/config.d.ts +37 -0
  4. package/build/config.js +45 -0
  5. package/build/database.d.ts +54 -0
  6. package/build/database.js +175 -62
  7. package/build/guardian/checklist.js +66 -66
  8. package/build/guardian/coding-analyzer.d.ts +11 -0
  9. package/build/guardian/coding-analyzer.js +393 -0
  10. package/build/guardian/coding-rules.d.ts +1 -0
  11. package/build/guardian/coding-rules.js +97 -0
  12. package/build/index.js +241 -2
  13. package/build/indexer/ast.d.ts +9 -0
  14. package/build/indexer/ast.js +158 -0
  15. package/build/indexer/file.d.ts +15 -0
  16. package/build/indexer/file.js +100 -0
  17. package/build/indexer/project.d.ts +8 -0
  18. package/build/indexer/project.js +320 -0
  19. package/build/memory/experience.d.ts +11 -0
  20. package/build/memory/experience.js +85 -0
  21. package/build/retrieval/context.d.ts +29 -0
  22. package/build/retrieval/context.js +219 -0
  23. package/build/retrieval/qdrant.d.ts +16 -0
  24. package/build/retrieval/qdrant.js +135 -0
  25. package/build/retrieval/tfidf.d.ts +14 -0
  26. package/build/retrieval/tfidf.js +64 -0
  27. package/build/security/alerts.d.ts +44 -0
  28. package/build/security/alerts.js +228 -0
  29. package/build/security/env.d.ts +24 -0
  30. package/build/security/env.js +85 -0
  31. package/build/security/guard.d.ts +35 -0
  32. package/build/security/guard.js +133 -0
  33. package/build/security/ratelimit.d.ts +34 -0
  34. package/build/security/ratelimit.js +105 -0
  35. package/build/security/smtp.d.ts +26 -0
  36. package/build/security/smtp.js +125 -0
  37. package/build/security/ssrf.d.ts +18 -0
  38. package/build/security/ssrf.js +109 -0
  39. package/build/security/waf.d.ts +33 -0
  40. package/build/security/waf.js +174 -0
  41. package/build/store/content.d.ts +3 -0
  42. package/build/store/content.js +11 -0
  43. package/build/tools/coding-guard.d.ts +24 -0
  44. package/build/tools/coding-guard.js +82 -0
  45. package/build/tools/context.d.ts +39 -0
  46. package/build/tools/context.js +105 -0
  47. package/build/tools/grep.d.ts +17 -0
  48. package/build/tools/grep.js +65 -0
  49. package/build/tools/init.d.ts +51 -0
  50. package/build/tools/init.js +212 -0
  51. package/build/tools/remember.d.ts +4 -4
  52. package/build/tools/reward.d.ts +29 -0
  53. package/build/tools/reward.js +154 -0
  54. package/build/tools/sync.d.ts +18 -0
  55. package/build/tools/sync.js +76 -0
  56. package/package.json +55 -48
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Security guard — orchestrates all security checks for every tool call.
3
+ *
4
+ * Pipeline per request:
5
+ * 1. Rate limit check
6
+ * 2. WAF input validation (size, injection, path traversal, ReDoS)
7
+ * 3. Output leakage scan (before returning to caller)
8
+ *
9
+ * Enabled by default. Disable per-check via lucid.config.json:
10
+ * { "security": { "rateLimiting": false, "waf": false } }
11
+ */
12
+ import { rateLimiter, rateLimitMessage } from "./ratelimit.js";
13
+ import { checkStringField, checkOutputLeakage, checkReDoS, } from "./waf.js";
14
+ import { allowHost } from "./ssrf.js";
15
+ import { sendAlert } from "./alerts.js";
16
+ let _cfg = {
17
+ rateLimiting: true,
18
+ waf: true,
19
+ outputScan: true,
20
+ };
21
+ export function configureGuard(cfg) {
22
+ _cfg = { ..._cfg, ...cfg };
23
+ if (cfg.limits) {
24
+ rateLimiter.configure(cfg.limits);
25
+ }
26
+ if (cfg.trustedHosts) {
27
+ for (const host of cfg.trustedHosts)
28
+ allowHost(host);
29
+ }
30
+ }
31
+ const OK = { blocked: false };
32
+ function blocked(reason, violations) {
33
+ return { blocked: true, reason, violations };
34
+ }
35
+ /**
36
+ * Per-tool WAF rules — maps each tool name to its field validation strategy.
37
+ * Returns the first violation found, or null if clean.
38
+ */
39
+ function wafCheckArgs(tool, args) {
40
+ const str = (key) => (typeof args[key] === "string" ? args[key] : "");
41
+ switch (tool) {
42
+ case "remember":
43
+ return firstViolation([
44
+ checkStringField("entity", str("entity"), {}),
45
+ checkStringField("observation", str("observation"), {}),
46
+ ]);
47
+ case "relate":
48
+ return firstViolation([
49
+ checkStringField("from", str("from"), {}),
50
+ checkStringField("to", str("to"), {}),
51
+ ]);
52
+ case "recall":
53
+ case "get_context":
54
+ return checkStringField("query", str("query"), {});
55
+ case "forget":
56
+ return checkStringField("entity", str("entity"), {});
57
+ case "sync_file":
58
+ case "validate_file":
59
+ return checkStringField("path", str("path"), { isPath: true });
60
+ case "grep_code": {
61
+ const sizeCheck = checkStringField("pattern", str("pattern"), {});
62
+ if (sizeCheck.blocked)
63
+ return sizeCheck;
64
+ return checkReDoS(str("pattern"));
65
+ }
66
+ case "check_drift":
67
+ return checkStringField("code", str("code"), {});
68
+ case "init_project":
69
+ case "sync_project":
70
+ return str("directory")
71
+ ? checkStringField("directory", str("directory"), { isPath: true })
72
+ : null;
73
+ default:
74
+ return null;
75
+ }
76
+ }
77
+ function firstViolation(results) {
78
+ for (const r of results) {
79
+ if (r.blocked)
80
+ return r;
81
+ }
82
+ return null;
83
+ }
84
+ /** Run all security checks for an inbound tool call. */
85
+ export function guardRequest(tool, args) {
86
+ // 1. Rate limiting
87
+ if (_cfg.rateLimiting !== false) {
88
+ const rl = rateLimiter.check(tool);
89
+ if (!rl.allowed) {
90
+ const msg = rateLimitMessage(tool, rl);
91
+ // Alert on repeated rate limit hits (severity: medium)
92
+ fireAlert({ severity: "medium", rule: "RATE_LIMIT", tool, detail: msg });
93
+ return blocked(msg);
94
+ }
95
+ }
96
+ // 2. WAF input validation
97
+ if (_cfg.waf !== false && args && typeof args === "object") {
98
+ const waf = wafCheckArgs(tool, args);
99
+ if (waf?.blocked) {
100
+ const v = waf.violations[0];
101
+ const detail = v?.detail ?? "Input rejected";
102
+ // Alert on HIGH/CRITICAL violations immediately
103
+ if (v && (v.severity === "high" || v.severity === "critical")) {
104
+ fireAlert({ severity: v.severity, rule: v.rule, tool, detail });
105
+ }
106
+ return blocked(`🛡️ WAF [${v?.rule ?? "UNKNOWN"}] (${v?.severity ?? "?"}): ${detail}`, waf.violations);
107
+ }
108
+ }
109
+ return OK;
110
+ }
111
+ /** Fire-and-forget alert — never throws, never blocks tool execution. */
112
+ function fireAlert(event) {
113
+ sendAlert({ ...event, timestamp: new Date().toISOString() }).catch((e) => {
114
+ console.error(`[lucid:guard] Alert dispatch failed: ${e.message}`);
115
+ });
116
+ }
117
+ // ---------------------------------------------------------------------------
118
+ // Output guard — run before returning response to caller
119
+ // ---------------------------------------------------------------------------
120
+ /** Scan output for sensitive data leakage. Logs a warning; does not block. */
121
+ export function guardOutput(tool, text) {
122
+ if (_cfg.outputScan === false)
123
+ return text;
124
+ const leaks = checkOutputLeakage(text);
125
+ if (leaks.length > 0) {
126
+ // Log to stderr (never to stdout — that's the MCP channel)
127
+ console.error(`[lucid:security] ⚠️ Possible data leakage in response for "${tool}": ` +
128
+ leaks.map((v) => v.detail).join(", "));
129
+ // Redact the response rather than blocking it
130
+ return text + "\n\n⚠️ [Security notice: response may contain sensitive data — review before sharing]";
131
+ }
132
+ return text;
133
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * In-memory sliding-window rate limiter.
3
+ *
4
+ * No external dependencies — uses a circular timestamp buffer per key.
5
+ * Configurable per-tool limits; heavy operations have tighter defaults.
6
+ */
7
+ export interface RateLimitConfig {
8
+ /** Window duration in milliseconds (default: 60_000 = 1 minute) */
9
+ windowMs: number;
10
+ /** Max requests allowed within the window */
11
+ maxRequests: number;
12
+ }
13
+ export interface RateLimitResult {
14
+ allowed: boolean;
15
+ remaining: number;
16
+ retryAfterMs: number;
17
+ limit: number;
18
+ windowMs: number;
19
+ }
20
+ declare class RateLimiter {
21
+ private windows;
22
+ private overrides;
23
+ /** Override limits from config (called at startup). */
24
+ configure(overrides: Record<string, Partial<RateLimitConfig>>): void;
25
+ private getConfig;
26
+ private getWindow;
27
+ check(tool: string): RateLimitResult;
28
+ /** Reset all counters (useful in tests). */
29
+ reset(): void;
30
+ }
31
+ export declare const rateLimiter: RateLimiter;
32
+ /** Format a rate-limit rejection message. */
33
+ export declare function rateLimitMessage(tool: string, result: RateLimitResult): string;
34
+ export {};
@@ -0,0 +1,105 @@
1
+ /**
2
+ * In-memory sliding-window rate limiter.
3
+ *
4
+ * No external dependencies — uses a circular timestamp buffer per key.
5
+ * Configurable per-tool limits; heavy operations have tighter defaults.
6
+ */
7
+ // Default per-tool limits (requests per minute)
8
+ const DEFAULT_LIMITS = {
9
+ // Heavy — decompress + score all files
10
+ get_context: { windowMs: 60_000, maxRequests: 20 },
11
+ grep_code: { windowMs: 60_000, maxRequests: 30 },
12
+ sync_project: { windowMs: 60_000, maxRequests: 5 },
13
+ // Medium
14
+ recall: { windowMs: 60_000, maxRequests: 60 },
15
+ recall_all: { windowMs: 60_000, maxRequests: 20 },
16
+ validate_file: { windowMs: 60_000, maxRequests: 30 },
17
+ check_drift: { windowMs: 60_000, maxRequests: 30 },
18
+ // Light — default for anything not listed
19
+ _default: { windowMs: 60_000, maxRequests: 120 },
20
+ };
21
+ // ---------------------------------------------------------------------------
22
+ // Sliding window implementation
23
+ // ---------------------------------------------------------------------------
24
+ /** Circular buffer of request timestamps for one key. */
25
+ class SlidingWindow {
26
+ windowMs;
27
+ max;
28
+ timestamps = [];
29
+ constructor(windowMs, max) {
30
+ this.windowMs = windowMs;
31
+ this.max = max;
32
+ }
33
+ /** Returns true if the request is allowed; records the timestamp. */
34
+ allow() {
35
+ const now = Date.now();
36
+ const cutoff = now - this.windowMs;
37
+ // Drop expired entries
38
+ this.timestamps = this.timestamps.filter((t) => t > cutoff);
39
+ if (this.timestamps.length >= this.max)
40
+ return false;
41
+ this.timestamps.push(now);
42
+ return true;
43
+ }
44
+ /** Remaining requests in current window. */
45
+ remaining() {
46
+ const now = Date.now();
47
+ const cutoff = now - this.windowMs;
48
+ const active = this.timestamps.filter((t) => t > cutoff).length;
49
+ return Math.max(0, this.max - active);
50
+ }
51
+ /** Milliseconds until oldest request falls out of window. */
52
+ retryAfterMs() {
53
+ if (this.timestamps.length === 0)
54
+ return 0;
55
+ const oldest = Math.min(...this.timestamps);
56
+ return Math.max(0, oldest + this.windowMs - Date.now());
57
+ }
58
+ }
59
+ class RateLimiter {
60
+ windows = new Map();
61
+ overrides = new Map();
62
+ /** Override limits from config (called at startup). */
63
+ configure(overrides) {
64
+ for (const [tool, cfg] of Object.entries(overrides)) {
65
+ const base = DEFAULT_LIMITS[tool] ?? DEFAULT_LIMITS["_default"];
66
+ this.overrides.set(tool, { ...base, ...cfg });
67
+ }
68
+ }
69
+ getConfig(tool) {
70
+ return this.overrides.get(tool) ?? DEFAULT_LIMITS[tool] ?? DEFAULT_LIMITS["_default"];
71
+ }
72
+ getWindow(key, cfg) {
73
+ let w = this.windows.get(key);
74
+ if (!w) {
75
+ w = new SlidingWindow(cfg.windowMs, cfg.maxRequests);
76
+ this.windows.set(key, w);
77
+ }
78
+ return w;
79
+ }
80
+ check(tool) {
81
+ const cfg = this.getConfig(tool);
82
+ const window = this.getWindow(tool, cfg);
83
+ const allowed = window.allow();
84
+ return {
85
+ allowed,
86
+ remaining: window.remaining(),
87
+ retryAfterMs: allowed ? 0 : window.retryAfterMs(),
88
+ limit: cfg.maxRequests,
89
+ windowMs: cfg.windowMs,
90
+ };
91
+ }
92
+ /** Reset all counters (useful in tests). */
93
+ reset() {
94
+ this.windows.clear();
95
+ }
96
+ }
97
+ // Singleton — one rate limiter per server process
98
+ export const rateLimiter = new RateLimiter();
99
+ /** Format a rate-limit rejection message. */
100
+ export function rateLimitMessage(tool, result) {
101
+ const retryAfterSec = Math.ceil(result.retryAfterMs / 1000);
102
+ return (`🚦 Rate limit exceeded for "${tool}": ` +
103
+ `${result.limit} requests/${result.windowMs / 1000}s allowed. ` +
104
+ `Retry after ${retryAfterSec}s.`);
105
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Minimal SMTP client — Node.js built-ins only (net + tls + crypto).
3
+ *
4
+ * Supports:
5
+ * - Direct TLS (port 465, implicit SSL)
6
+ * - STARTTLS (port 587, explicit upgrade)
7
+ * - AUTH LOGIN
8
+ * - Plain-text message body
9
+ */
10
+ export interface SmtpConfig {
11
+ host: string;
12
+ port: number;
13
+ user: string;
14
+ /** Must come from LUCID_SMTP_PASS env var — never hardcoded */
15
+ pass: string;
16
+ from: string;
17
+ /** true = direct TLS (port 465); false/omit = STARTTLS (port 587) */
18
+ secure?: boolean;
19
+ timeoutMs?: number;
20
+ }
21
+ export interface SmtpMessage {
22
+ to: string;
23
+ subject: string;
24
+ body: string;
25
+ }
26
+ export declare function sendEmail(cfg: SmtpConfig, msg: SmtpMessage): Promise<void>;
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Minimal SMTP client — Node.js built-ins only (net + tls + crypto).
3
+ *
4
+ * Supports:
5
+ * - Direct TLS (port 465, implicit SSL)
6
+ * - STARTTLS (port 587, explicit upgrade)
7
+ * - AUTH LOGIN
8
+ * - Plain-text message body
9
+ */
10
+ import * as net from "net";
11
+ import * as tls from "tls";
12
+ function readResponse(socket, timeoutMs) {
13
+ return new Promise((resolve, reject) => {
14
+ let buf = "";
15
+ const timer = setTimeout(() => reject(new Error("SMTP read timeout")), timeoutMs);
16
+ const handler = (chunk) => {
17
+ buf += chunk.toString("utf-8");
18
+ // A complete (possibly multi-line) SMTP response ends with "DDD text\r\n"
19
+ // where DDD is followed by a space (not a dash, which denotes continuation)
20
+ if (/\r?\n$/.test(buf)) {
21
+ const rawLines = buf.split(/\r?\n/).filter((l) => l.length > 0);
22
+ const last = rawLines[rawLines.length - 1];
23
+ // Final line: code + space (e.g. "250 OK")
24
+ if (/^\d{3} /.test(last)) {
25
+ clearTimeout(timer);
26
+ socket.removeListener("data", handler);
27
+ const code = parseInt(last.slice(0, 3), 10);
28
+ resolve({ code, lines: rawLines });
29
+ }
30
+ }
31
+ };
32
+ socket.on("data", handler);
33
+ socket.once("error", (e) => { clearTimeout(timer); reject(e); });
34
+ });
35
+ }
36
+ async function cmd(socket, command, timeout) {
37
+ await new Promise((res, rej) => {
38
+ socket.write(command + "\r\n", "utf-8", (err) => (err ? rej(err) : res()));
39
+ });
40
+ return readResponse(socket, timeout);
41
+ }
42
+ function expect(res, ...codes) {
43
+ if (!codes.includes(res.code)) {
44
+ throw new Error(`SMTP unexpected response ${res.code}: ${res.lines.join(" | ")}`);
45
+ }
46
+ }
47
+ function b64(s) {
48
+ return Buffer.from(s, "utf-8").toString("base64");
49
+ }
50
+ // ---------------------------------------------------------------------------
51
+ // Main send function
52
+ // ---------------------------------------------------------------------------
53
+ export async function sendEmail(cfg, msg) {
54
+ const timeout = cfg.timeoutMs ?? 15_000;
55
+ let socket;
56
+ if (cfg.secure) {
57
+ // Direct TLS — connect already encrypted
58
+ socket = await new Promise((resolve, reject) => {
59
+ const s = tls.connect({ host: cfg.host, port: cfg.port, servername: cfg.host });
60
+ s.once("secureConnect", () => resolve(s));
61
+ s.once("error", reject);
62
+ setTimeout(() => reject(new Error("TLS connect timeout")), timeout);
63
+ });
64
+ }
65
+ else {
66
+ // Plain TCP first (STARTTLS later)
67
+ socket = await new Promise((resolve, reject) => {
68
+ const s = net.createConnection({ host: cfg.host, port: cfg.port });
69
+ s.once("connect", () => resolve(s));
70
+ s.once("error", reject);
71
+ setTimeout(() => reject(new Error("TCP connect timeout")), timeout);
72
+ });
73
+ }
74
+ try {
75
+ // 1. Server greeting
76
+ expect(await readResponse(socket, timeout), 220);
77
+ // 2. EHLO
78
+ const ehlo = await cmd(socket, `EHLO lucid-security`, timeout);
79
+ expect(ehlo, 250);
80
+ // 3. STARTTLS upgrade (plain TCP only)
81
+ if (!cfg.secure) {
82
+ expect(await cmd(socket, "STARTTLS", timeout), 220);
83
+ // Wrap plain socket in TLS
84
+ socket = await new Promise((resolve, reject) => {
85
+ const upgraded = tls.connect({
86
+ socket: socket,
87
+ host: cfg.host,
88
+ servername: cfg.host,
89
+ });
90
+ upgraded.once("secureConnect", () => resolve(upgraded));
91
+ upgraded.once("error", reject);
92
+ setTimeout(() => reject(new Error("STARTTLS upgrade timeout")), timeout);
93
+ });
94
+ // EHLO again after TLS
95
+ expect(await cmd(socket, `EHLO lucid-security`, timeout), 250);
96
+ }
97
+ // 4. AUTH LOGIN
98
+ expect(await cmd(socket, "AUTH LOGIN", timeout), 334);
99
+ expect(await cmd(socket, b64(cfg.user), timeout), 334);
100
+ expect(await cmd(socket, b64(cfg.pass), timeout), 235);
101
+ // 5. Envelope
102
+ expect(await cmd(socket, `MAIL FROM:<${cfg.from}>`, timeout), 250);
103
+ expect(await cmd(socket, `RCPT TO:<${msg.to}>`, timeout), 250);
104
+ // 6. Message body
105
+ expect(await cmd(socket, "DATA", timeout), 354);
106
+ const date = new Date().toUTCString();
107
+ const body = [
108
+ `Date: ${date}`,
109
+ `From: ${cfg.from}`,
110
+ `To: ${msg.to}`,
111
+ `Subject: ${msg.subject}`,
112
+ `MIME-Version: 1.0`,
113
+ `Content-Type: text/plain; charset=UTF-8`,
114
+ ``,
115
+ msg.body,
116
+ `.`,
117
+ ].join("\r\n");
118
+ expect(await cmd(socket, body, timeout), 250);
119
+ // 7. Quit
120
+ await cmd(socket, "QUIT", timeout);
121
+ }
122
+ finally {
123
+ socket.destroy();
124
+ }
125
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * SSRF (Server-Side Request Forgery) prevention.
3
+ *
4
+ * Validates URLs before any outbound fetch call to ensure they point to
5
+ * legitimate external services and not to internal network resources.
6
+ */
7
+ /** Register a trusted host (called once at startup with Qdrant URL etc.) */
8
+ export declare function allowHost(urlOrHost: string): void;
9
+ export declare function resetAllowedHosts(): void;
10
+ export interface UrlCheckResult {
11
+ allowed: boolean;
12
+ reason?: string;
13
+ }
14
+ export declare function validateUrl(rawUrl: string): UrlCheckResult;
15
+ /** Throw if URL is not allowed. Use before every outbound fetch. */
16
+ export declare function assertSafeUrl(url: string): void;
17
+ /** Resolve and return timeout-wrapped fetch (default 10s). */
18
+ export declare function safeFetch(url: string, init?: RequestInit, timeoutMs?: number): Promise<Response>;
@@ -0,0 +1,109 @@
1
+ /**
2
+ * SSRF (Server-Side Request Forgery) prevention.
3
+ *
4
+ * Validates URLs before any outbound fetch call to ensure they point to
5
+ * legitimate external services and not to internal network resources.
6
+ */
7
+ // ---------------------------------------------------------------------------
8
+ // Private/reserved IP ranges to block
9
+ // ---------------------------------------------------------------------------
10
+ // IPv4 ranges that should never be reachable from outside
11
+ const BLOCKED_IPV4_PATTERNS = [
12
+ /^127\./, // loopback
13
+ /^10\./, // RFC-1918 class A
14
+ /^172\.(1[6-9]|2\d|3[01])\./, // RFC-1918 class B
15
+ /^192\.168\./, // RFC-1918 class C
16
+ /^169\.254\./, // link-local / AWS metadata
17
+ /^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./, // CGNAT (RFC-6598)
18
+ /^0\./, // "this" network
19
+ /^255\./, // broadcast
20
+ /^::1$/, // IPv6 loopback
21
+ /^fc00:/i, // IPv6 unique local
22
+ /^fe80:/i, // IPv6 link-local
23
+ ];
24
+ // Hostnames that are always blocked
25
+ const BLOCKED_HOSTS = new Set([
26
+ "localhost",
27
+ "metadata.google.internal",
28
+ "metadata",
29
+ ]);
30
+ // Cloud metadata endpoints
31
+ const BLOCKED_PATHS_PREFIX = [
32
+ "/latest/meta-data", // AWS EC2 metadata
33
+ "/computeMetadata", // GCP metadata
34
+ "/metadata/instance", // Azure IMDS
35
+ "/odata/", // Azure (generic)
36
+ ];
37
+ // ---------------------------------------------------------------------------
38
+ // Allowlist (set from env/config at startup)
39
+ // ---------------------------------------------------------------------------
40
+ let _allowedHosts = new Set();
41
+ /** Register a trusted host (called once at startup with Qdrant URL etc.) */
42
+ export function allowHost(urlOrHost) {
43
+ try {
44
+ const host = new URL(urlOrHost).hostname.toLowerCase();
45
+ _allowedHosts.add(host);
46
+ }
47
+ catch {
48
+ _allowedHosts.add(urlOrHost.toLowerCase());
49
+ }
50
+ }
51
+ export function resetAllowedHosts() {
52
+ _allowedHosts = new Set();
53
+ }
54
+ export function validateUrl(rawUrl) {
55
+ // 1. Must be parseable
56
+ let parsed;
57
+ try {
58
+ parsed = new URL(rawUrl);
59
+ }
60
+ catch {
61
+ return { allowed: false, reason: "Malformed URL" };
62
+ }
63
+ // 2. Only http/https allowed
64
+ if (!["http:", "https:"].includes(parsed.protocol)) {
65
+ return { allowed: false, reason: `Protocol not allowed: ${parsed.protocol}` };
66
+ }
67
+ const hostname = parsed.hostname.toLowerCase();
68
+ const path = parsed.pathname;
69
+ // 3. Check blocked hostnames
70
+ if (BLOCKED_HOSTS.has(hostname)) {
71
+ return { allowed: false, reason: `Blocked hostname: ${hostname}` };
72
+ }
73
+ // 4. Check blocked IPv4 patterns
74
+ for (const pattern of BLOCKED_IPV4_PATTERNS) {
75
+ if (pattern.test(hostname)) {
76
+ return { allowed: false, reason: `Blocked IP range: ${hostname}` };
77
+ }
78
+ }
79
+ // 5. Check cloud metadata paths
80
+ for (const prefix of BLOCKED_PATHS_PREFIX) {
81
+ if (path.startsWith(prefix)) {
82
+ return { allowed: false, reason: `Blocked metadata path: ${path}` };
83
+ }
84
+ }
85
+ // 6. If allowlist is populated, host must be in it
86
+ if (_allowedHosts.size > 0 && !_allowedHosts.has(hostname)) {
87
+ return { allowed: false, reason: `Host not in allowlist: ${hostname}` };
88
+ }
89
+ return { allowed: true };
90
+ }
91
+ /** Throw if URL is not allowed. Use before every outbound fetch. */
92
+ export function assertSafeUrl(url) {
93
+ const result = validateUrl(url);
94
+ if (!result.allowed) {
95
+ throw new Error(`SSRF guard blocked request: ${result.reason}`);
96
+ }
97
+ }
98
+ /** Resolve and return timeout-wrapped fetch (default 10s). */
99
+ export async function safeFetch(url, init = {}, timeoutMs = 10_000) {
100
+ assertSafeUrl(url);
101
+ const controller = new AbortController();
102
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
103
+ try {
104
+ return await fetch(url, { ...init, signal: controller.signal });
105
+ }
106
+ finally {
107
+ clearTimeout(timer);
108
+ }
109
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * WAF (Web Application Firewall) rules for MCP tool inputs.
3
+ *
4
+ * Covers:
5
+ * - Path traversal & directory escape
6
+ * - Null-byte & CRLF injection
7
+ * - ReDoS (catastrophic backtracking) detection
8
+ * - Input size limits (DoS prevention)
9
+ * - Suspicious injection patterns (SQLi, command injection)
10
+ * - Sensitive data leakage in outputs
11
+ */
12
+ export type Severity = "low" | "medium" | "high" | "critical";
13
+ export interface WafViolation {
14
+ rule: string;
15
+ severity: Severity;
16
+ detail: string;
17
+ }
18
+ export interface WafResult {
19
+ blocked: boolean;
20
+ violations: WafViolation[];
21
+ }
22
+ export declare function checkSize(field: string, value: string): WafResult;
23
+ export declare function checkInjection(value: string): WafResult;
24
+ export declare function checkPath(inputPath: string, allowedRoot?: string): WafResult;
25
+ export declare function checkReDoS(pattern: string): WafResult;
26
+ export declare function checkInjectionPatterns(value: string): WafResult;
27
+ /** Check if a text blob about to be returned contains secrets. */
28
+ export declare function checkOutputLeakage(text: string): WafViolation[];
29
+ export declare function checkStringField(field: string, value: string, opts?: {
30
+ isPath?: boolean;
31
+ isRegex?: boolean;
32
+ allowedRoot?: string;
33
+ }): WafResult;