@a13xu/lucid 1.4.0 → 1.9.1
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/README.md +118 -14
- package/build/config.d.ts +37 -0
- package/build/config.js +45 -0
- package/build/database.d.ts +36 -1
- package/build/database.js +85 -1
- package/build/guardian/coding-analyzer.d.ts +11 -0
- package/build/guardian/coding-analyzer.js +393 -0
- package/build/guardian/coding-rules.d.ts +1 -0
- package/build/guardian/coding-rules.js +97 -0
- package/build/index.js +164 -3
- package/build/indexer/ast.d.ts +9 -0
- package/build/indexer/ast.js +158 -0
- package/build/indexer/project.js +21 -13
- package/build/memory/experience.d.ts +11 -0
- package/build/memory/experience.js +85 -0
- package/build/retrieval/context.d.ts +29 -0
- package/build/retrieval/context.js +219 -0
- package/build/retrieval/qdrant.d.ts +16 -0
- package/build/retrieval/qdrant.js +135 -0
- package/build/retrieval/tfidf.d.ts +14 -0
- package/build/retrieval/tfidf.js +64 -0
- package/build/security/alerts.d.ts +44 -0
- package/build/security/alerts.js +228 -0
- package/build/security/env.d.ts +24 -0
- package/build/security/env.js +85 -0
- package/build/security/guard.d.ts +35 -0
- package/build/security/guard.js +133 -0
- package/build/security/ratelimit.d.ts +34 -0
- package/build/security/ratelimit.js +105 -0
- package/build/security/smtp.d.ts +26 -0
- package/build/security/smtp.js +125 -0
- package/build/security/ssrf.d.ts +18 -0
- package/build/security/ssrf.js +109 -0
- package/build/security/waf.d.ts +33 -0
- package/build/security/waf.js +174 -0
- package/build/tools/coding-guard.d.ts +24 -0
- package/build/tools/coding-guard.js +82 -0
- package/build/tools/context.d.ts +39 -0
- package/build/tools/context.js +105 -0
- package/build/tools/init.d.ts +41 -1
- package/build/tools/init.js +124 -22
- package/build/tools/remember.d.ts +4 -4
- package/build/tools/reward.d.ts +29 -0
- package/build/tools/reward.js +154 -0
- package/build/tools/sync.js +15 -0
- package/package.json +9 -2
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Security alert dispatcher.
|
|
3
|
+
*
|
|
4
|
+
* Channels (all optional, configured via lucid-admin.json + env vars):
|
|
5
|
+
* - Webhook (generic HTTP POST, HMAC-SHA256 signed)
|
|
6
|
+
* - Slack (incoming webhook)
|
|
7
|
+
* - Email (SMTP via smtp.ts)
|
|
8
|
+
*
|
|
9
|
+
* Sensitive values MUST come from environment variables:
|
|
10
|
+
* LUCID_SMTP_PASS — SMTP password
|
|
11
|
+
* LUCID_WEBHOOK_SECRET — HMAC signing secret for webhook
|
|
12
|
+
*
|
|
13
|
+
* Config is stored in <project>/.claude/lucid-admin.json (non-sensitive fields only).
|
|
14
|
+
*/
|
|
15
|
+
import { createHmac } from "crypto";
|
|
16
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "fs";
|
|
17
|
+
import { join } from "path";
|
|
18
|
+
import { safeFetch } from "./ssrf.js";
|
|
19
|
+
import { sendEmail } from "./smtp.js";
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// Config loader
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
let _config = null;
|
|
24
|
+
let _configDir = null;
|
|
25
|
+
export const ADMIN_CONFIG_FILE = "lucid-admin.json";
|
|
26
|
+
export function loadAdminConfig(projectDir) {
|
|
27
|
+
_configDir = join(projectDir, ".claude");
|
|
28
|
+
const path = join(_configDir, ADMIN_CONFIG_FILE);
|
|
29
|
+
if (existsSync(path)) {
|
|
30
|
+
try {
|
|
31
|
+
_config = JSON.parse(readFileSync(path, "utf-8"));
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
_config = {};
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
else {
|
|
38
|
+
_config = {};
|
|
39
|
+
}
|
|
40
|
+
return _config;
|
|
41
|
+
}
|
|
42
|
+
export function saveAdminConfig(projectDir, cfg) {
|
|
43
|
+
const dir = join(projectDir, ".claude");
|
|
44
|
+
mkdirSync(dir, { recursive: true });
|
|
45
|
+
// Merge with existing
|
|
46
|
+
const existing = loadAdminConfig(projectDir);
|
|
47
|
+
const merged = { ...existing, ...cfg };
|
|
48
|
+
_config = merged;
|
|
49
|
+
// Strip any sensitive fields that shouldn't be persisted to disk
|
|
50
|
+
// (user might accidentally pass smtpPass — we silently drop it)
|
|
51
|
+
const safe = { ...merged };
|
|
52
|
+
delete safe["smtpPass"];
|
|
53
|
+
delete safe["webhookSecret"];
|
|
54
|
+
writeFileSync(join(dir, ADMIN_CONFIG_FILE), JSON.stringify(safe, null, 2) + "\n", "utf-8");
|
|
55
|
+
}
|
|
56
|
+
export function getAdminConfig() {
|
|
57
|
+
return _config ?? {};
|
|
58
|
+
}
|
|
59
|
+
export function isAdminConfigured() {
|
|
60
|
+
const c = _config ?? {};
|
|
61
|
+
return !!(c.adminEmail || c.webhookUrl || c.slackWebhookUrl);
|
|
62
|
+
}
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
// HMAC webhook signature
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
function signPayload(body) {
|
|
67
|
+
const secret = process.env["LUCID_WEBHOOK_SECRET"];
|
|
68
|
+
if (!secret)
|
|
69
|
+
return null;
|
|
70
|
+
return "sha256=" + createHmac("sha256", secret).update(body, "utf-8").digest("hex");
|
|
71
|
+
}
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
// Channels
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
async function dispatchWebhook(url, event) {
|
|
76
|
+
const body = JSON.stringify({
|
|
77
|
+
source: "lucid-security",
|
|
78
|
+
...event,
|
|
79
|
+
});
|
|
80
|
+
const headers = { "Content-Type": "application/json" };
|
|
81
|
+
const sig = signPayload(body);
|
|
82
|
+
if (sig)
|
|
83
|
+
headers["X-Lucid-Signature"] = sig;
|
|
84
|
+
const res = await safeFetch(url, { method: "POST", headers, body });
|
|
85
|
+
if (!res.ok)
|
|
86
|
+
throw new Error(`Webhook HTTP ${res.status}`);
|
|
87
|
+
}
|
|
88
|
+
async function dispatchSlack(webhookUrl, event) {
|
|
89
|
+
const icon = event.severity === "critical" ? "🚨" : "⚠️";
|
|
90
|
+
const payload = {
|
|
91
|
+
text: `${icon} *Lucid Security Alert* — ${event.severity.toUpperCase()}`,
|
|
92
|
+
blocks: [
|
|
93
|
+
{
|
|
94
|
+
type: "section",
|
|
95
|
+
text: {
|
|
96
|
+
type: "mrkdwn",
|
|
97
|
+
text: `${icon} *[${event.severity.toUpperCase()}] ${event.rule}*\n` +
|
|
98
|
+
`*Tool:* \`${event.tool}\`\n` +
|
|
99
|
+
`*Detail:* ${event.detail}\n` +
|
|
100
|
+
`*Project:* ${_config?.projectName ?? "unknown"}\n` +
|
|
101
|
+
`*Time:* ${event.timestamp}`,
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
],
|
|
105
|
+
};
|
|
106
|
+
const res = await safeFetch(webhookUrl, {
|
|
107
|
+
method: "POST",
|
|
108
|
+
headers: { "Content-Type": "application/json" },
|
|
109
|
+
body: JSON.stringify(payload),
|
|
110
|
+
});
|
|
111
|
+
if (!res.ok)
|
|
112
|
+
throw new Error(`Slack HTTP ${res.status}`);
|
|
113
|
+
}
|
|
114
|
+
async function dispatchEmail(event) {
|
|
115
|
+
const cfg = _config ?? {};
|
|
116
|
+
const smtpPass = process.env["LUCID_SMTP_PASS"];
|
|
117
|
+
if (!smtpPass)
|
|
118
|
+
throw new Error("LUCID_SMTP_PASS env var not set");
|
|
119
|
+
if (!cfg.adminEmail)
|
|
120
|
+
throw new Error("adminEmail not configured");
|
|
121
|
+
if (!cfg.smtpHost)
|
|
122
|
+
throw new Error("smtpHost not configured");
|
|
123
|
+
const smtpCfg = {
|
|
124
|
+
host: cfg.smtpHost,
|
|
125
|
+
port: cfg.smtpPort ?? 587,
|
|
126
|
+
user: cfg.smtpUser ?? cfg.adminEmail,
|
|
127
|
+
pass: smtpPass,
|
|
128
|
+
from: cfg.smtpFrom ?? `Lucid Security <${cfg.smtpUser ?? cfg.adminEmail}>`,
|
|
129
|
+
secure: cfg.smtpPort === 465,
|
|
130
|
+
};
|
|
131
|
+
const icon = event.severity === "critical" ? "🚨" : "⚠️";
|
|
132
|
+
const subject = `${icon} [${event.severity.toUpperCase()}] Lucid Security Alert — ${event.rule}`;
|
|
133
|
+
const body = [
|
|
134
|
+
`Lucid Security Alert`,
|
|
135
|
+
`${"─".repeat(40)}`,
|
|
136
|
+
``,
|
|
137
|
+
`Severity : ${event.severity.toUpperCase()}`,
|
|
138
|
+
`Rule : ${event.rule}`,
|
|
139
|
+
`Tool : ${event.tool}`,
|
|
140
|
+
`Detail : ${event.detail}`,
|
|
141
|
+
`Project : ${cfg.projectName ?? "unknown"}`,
|
|
142
|
+
`Time : ${event.timestamp}`,
|
|
143
|
+
``,
|
|
144
|
+
`─────────────────────────────────────────`,
|
|
145
|
+
`This alert was sent automatically by lucid.`,
|
|
146
|
+
`Configure alerts in .claude/lucid-admin.json`,
|
|
147
|
+
].join("\n");
|
|
148
|
+
await sendEmail(smtpCfg, { to: cfg.adminEmail, subject, body });
|
|
149
|
+
}
|
|
150
|
+
// ---------------------------------------------------------------------------
|
|
151
|
+
// Main dispatch — fire-and-forget safe
|
|
152
|
+
// ---------------------------------------------------------------------------
|
|
153
|
+
const SEVERITY_ORDER = { low: 0, medium: 1, high: 2, critical: 3 };
|
|
154
|
+
export async function sendAlert(event) {
|
|
155
|
+
const cfg = _config ?? {};
|
|
156
|
+
const alertOn = cfg.alertOn ?? ["critical", "high"];
|
|
157
|
+
// Check if this severity is configured to alert
|
|
158
|
+
const minSeverity = Math.min(...alertOn.map((s) => SEVERITY_ORDER[s]));
|
|
159
|
+
if (SEVERITY_ORDER[event.severity] < minSeverity)
|
|
160
|
+
return;
|
|
161
|
+
const errors = [];
|
|
162
|
+
// Dispatch to all configured channels concurrently
|
|
163
|
+
const dispatches = [];
|
|
164
|
+
if (cfg.webhookUrl) {
|
|
165
|
+
dispatches.push(dispatchWebhook(cfg.webhookUrl, event).catch((e) => {
|
|
166
|
+
errors.push(`webhook: ${e.message}`);
|
|
167
|
+
}));
|
|
168
|
+
}
|
|
169
|
+
if (cfg.slackWebhookUrl) {
|
|
170
|
+
dispatches.push(dispatchSlack(cfg.slackWebhookUrl, event).catch((e) => {
|
|
171
|
+
errors.push(`slack: ${e.message}`);
|
|
172
|
+
}));
|
|
173
|
+
}
|
|
174
|
+
if (cfg.adminEmail && cfg.smtpHost) {
|
|
175
|
+
dispatches.push(dispatchEmail(event).catch((e) => {
|
|
176
|
+
errors.push(`email: ${e.message}`);
|
|
177
|
+
}));
|
|
178
|
+
}
|
|
179
|
+
await Promise.all(dispatches);
|
|
180
|
+
if (errors.length > 0) {
|
|
181
|
+
console.error(`[lucid:alerts] ⚠️ Failed to deliver ${errors.length} alert(s): ${errors.join("; ")}`);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
/** Send a test alert to verify all configured channels work. */
|
|
185
|
+
export async function sendTestAlert(projectDir) {
|
|
186
|
+
loadAdminConfig(projectDir);
|
|
187
|
+
const event = {
|
|
188
|
+
severity: "low",
|
|
189
|
+
rule: "TEST",
|
|
190
|
+
tool: "init_project",
|
|
191
|
+
detail: "Lucid security alerts are correctly configured and working.",
|
|
192
|
+
timestamp: new Date().toISOString(),
|
|
193
|
+
projectDir,
|
|
194
|
+
};
|
|
195
|
+
const results = [];
|
|
196
|
+
const cfg = _config ?? {};
|
|
197
|
+
if (cfg.webhookUrl) {
|
|
198
|
+
try {
|
|
199
|
+
await dispatchWebhook(cfg.webhookUrl, event);
|
|
200
|
+
results.push("✅ Webhook: test alert delivered");
|
|
201
|
+
}
|
|
202
|
+
catch (e) {
|
|
203
|
+
results.push(`❌ Webhook: ${e.message}`);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
if (cfg.slackWebhookUrl) {
|
|
207
|
+
try {
|
|
208
|
+
await dispatchSlack(cfg.slackWebhookUrl, event);
|
|
209
|
+
results.push("✅ Slack: test alert delivered");
|
|
210
|
+
}
|
|
211
|
+
catch (e) {
|
|
212
|
+
results.push(`❌ Slack: ${e.message}`);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
if (cfg.adminEmail && cfg.smtpHost) {
|
|
216
|
+
try {
|
|
217
|
+
await dispatchEmail(event);
|
|
218
|
+
results.push(`✅ Email: test alert sent to ${cfg.adminEmail}`);
|
|
219
|
+
}
|
|
220
|
+
catch (e) {
|
|
221
|
+
results.push(`❌ Email: ${e.message}`);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
if (results.length === 0) {
|
|
225
|
+
results.push("⚠️ No alert channels configured yet");
|
|
226
|
+
}
|
|
227
|
+
return results;
|
|
228
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Secure environment variable access.
|
|
3
|
+
*
|
|
4
|
+
* Rules:
|
|
5
|
+
* - Never expose raw values in error messages or logs
|
|
6
|
+
* - Always validate format before use
|
|
7
|
+
* - Mask secrets in any diagnostic output
|
|
8
|
+
*/
|
|
9
|
+
/** Get a required env var. Throws a safe error (no value leak) if missing. */
|
|
10
|
+
export declare function requireEnv(key: string): string;
|
|
11
|
+
/** Get an optional env var with a typed default. */
|
|
12
|
+
export declare function optionalEnv(key: string, defaultValue: string): string;
|
|
13
|
+
/** Get a numeric env var. Returns default if missing or non-numeric. */
|
|
14
|
+
export declare function numericEnv(key: string, defaultValue: number): number;
|
|
15
|
+
/** Get a boolean env var ("true"/"1"/"yes" → true, anything else → false). */
|
|
16
|
+
export declare function boolEnv(key: string, defaultValue: boolean): boolean;
|
|
17
|
+
/** Mask a value for safe logging. Shows first 4 and last 2 chars only. */
|
|
18
|
+
export declare function maskSecret(value: string): string;
|
|
19
|
+
/** Safe env dump for diagnostics — masks any key that looks sensitive. */
|
|
20
|
+
export declare function safeDump(keys: string[]): Record<string, string>;
|
|
21
|
+
/** Validate a URL-format env var. Throws with safe message on failure. */
|
|
22
|
+
export declare function requireEnvUrl(key: string): string;
|
|
23
|
+
/** Validate that an API key looks like a real key (non-trivial length). */
|
|
24
|
+
export declare function requireEnvApiKey(key: string, minLength?: number): string;
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Secure environment variable access.
|
|
3
|
+
*
|
|
4
|
+
* Rules:
|
|
5
|
+
* - Never expose raw values in error messages or logs
|
|
6
|
+
* - Always validate format before use
|
|
7
|
+
* - Mask secrets in any diagnostic output
|
|
8
|
+
*/
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
// Core getters
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
/** Get a required env var. Throws a safe error (no value leak) if missing. */
|
|
13
|
+
export function requireEnv(key) {
|
|
14
|
+
const val = process.env[key];
|
|
15
|
+
if (!val || val.trim() === "") {
|
|
16
|
+
throw new Error(`Missing required environment variable: ${key}`);
|
|
17
|
+
}
|
|
18
|
+
return val.trim();
|
|
19
|
+
}
|
|
20
|
+
/** Get an optional env var with a typed default. */
|
|
21
|
+
export function optionalEnv(key, defaultValue) {
|
|
22
|
+
const val = process.env[key];
|
|
23
|
+
return val && val.trim() !== "" ? val.trim() : defaultValue;
|
|
24
|
+
}
|
|
25
|
+
/** Get a numeric env var. Returns default if missing or non-numeric. */
|
|
26
|
+
export function numericEnv(key, defaultValue) {
|
|
27
|
+
const raw = process.env[key];
|
|
28
|
+
if (!raw)
|
|
29
|
+
return defaultValue;
|
|
30
|
+
const n = Number(raw.trim());
|
|
31
|
+
return Number.isFinite(n) ? n : defaultValue;
|
|
32
|
+
}
|
|
33
|
+
/** Get a boolean env var ("true"/"1"/"yes" → true, anything else → false). */
|
|
34
|
+
export function boolEnv(key, defaultValue) {
|
|
35
|
+
const raw = process.env[key];
|
|
36
|
+
if (!raw)
|
|
37
|
+
return defaultValue;
|
|
38
|
+
return /^(true|1|yes)$/i.test(raw.trim());
|
|
39
|
+
}
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
// Masking for logs / diagnostics
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
const SECRET_PATTERNS = [
|
|
44
|
+
/key/i, /secret/i, /token/i, /password/i, /pwd/i, /auth/i, /credential/i,
|
|
45
|
+
];
|
|
46
|
+
/** Mask a value for safe logging. Shows first 4 and last 2 chars only. */
|
|
47
|
+
export function maskSecret(value) {
|
|
48
|
+
if (value.length <= 8)
|
|
49
|
+
return "***";
|
|
50
|
+
return `${value.slice(0, 4)}…${value.slice(-2)}`;
|
|
51
|
+
}
|
|
52
|
+
/** Safe env dump for diagnostics — masks any key that looks sensitive. */
|
|
53
|
+
export function safeDump(keys) {
|
|
54
|
+
const result = {};
|
|
55
|
+
for (const key of keys) {
|
|
56
|
+
const val = process.env[key];
|
|
57
|
+
if (!val) {
|
|
58
|
+
result[key] = "<not set>";
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
const isSensitive = SECRET_PATTERNS.some((p) => p.test(key));
|
|
62
|
+
result[key] = isSensitive ? maskSecret(val) : val;
|
|
63
|
+
}
|
|
64
|
+
return result;
|
|
65
|
+
}
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
// Format validators (called before using values)
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
const URL_RE = /^https?:\/\/[^\s/$.?#].[^\s]*$/i;
|
|
70
|
+
/** Validate a URL-format env var. Throws with safe message on failure. */
|
|
71
|
+
export function requireEnvUrl(key) {
|
|
72
|
+
const val = requireEnv(key);
|
|
73
|
+
if (!URL_RE.test(val)) {
|
|
74
|
+
throw new Error(`Environment variable ${key} must be a valid URL (got invalid format)`);
|
|
75
|
+
}
|
|
76
|
+
return val;
|
|
77
|
+
}
|
|
78
|
+
/** Validate that an API key looks like a real key (non-trivial length). */
|
|
79
|
+
export function requireEnvApiKey(key, minLength = 16) {
|
|
80
|
+
const val = requireEnv(key);
|
|
81
|
+
if (val.length < minLength) {
|
|
82
|
+
throw new Error(`Environment variable ${key} appears to be too short for an API key (min ${minLength} chars)`);
|
|
83
|
+
}
|
|
84
|
+
return val;
|
|
85
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
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 { type RateLimitConfig } from "./ratelimit.js";
|
|
13
|
+
import { type WafViolation } from "./waf.js";
|
|
14
|
+
export interface SecurityConfig {
|
|
15
|
+
/** Enable/disable rate limiting (default: true) */
|
|
16
|
+
rateLimiting?: boolean;
|
|
17
|
+
/** Enable/disable WAF input checks (default: true) */
|
|
18
|
+
waf?: boolean;
|
|
19
|
+
/** Enable/disable output leakage scan (default: true) */
|
|
20
|
+
outputScan?: boolean;
|
|
21
|
+
/** Per-tool rate limit overrides */
|
|
22
|
+
limits?: Record<string, Partial<RateLimitConfig>>;
|
|
23
|
+
/** Trusted hostnames for outbound requests */
|
|
24
|
+
trustedHosts?: string[];
|
|
25
|
+
}
|
|
26
|
+
export declare function configureGuard(cfg: SecurityConfig): void;
|
|
27
|
+
export interface GuardResult {
|
|
28
|
+
blocked: boolean;
|
|
29
|
+
reason?: string;
|
|
30
|
+
violations?: WafViolation[];
|
|
31
|
+
}
|
|
32
|
+
/** Run all security checks for an inbound tool call. */
|
|
33
|
+
export declare function guardRequest(tool: string, args: unknown): GuardResult;
|
|
34
|
+
/** Scan output for sensitive data leakage. Logs a warning; does not block. */
|
|
35
|
+
export declare function guardOutput(tool: string, text: string): string;
|
|
@@ -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>;
|