wolverine-ai 1.0.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/PLATFORM.md +442 -0
- package/README.md +475 -0
- package/SERVER_BEST_PRACTICES.md +62 -0
- package/TELEMETRY.md +108 -0
- package/bin/wolverine.js +95 -0
- package/examples/01-basic-typo.js +31 -0
- package/examples/02-multi-file/routes/users.js +15 -0
- package/examples/02-multi-file/server.js +25 -0
- package/examples/03-syntax-error.js +23 -0
- package/examples/04-secret-leak.js +14 -0
- package/examples/05-expired-key.js +27 -0
- package/examples/06-json-config/config.json +13 -0
- package/examples/06-json-config/server.js +28 -0
- package/examples/07-rate-limit-loop.js +11 -0
- package/examples/08-sandbox-escape.js +20 -0
- package/examples/buggy-server.js +39 -0
- package/examples/demos/01-basic-typo/index.js +20 -0
- package/examples/demos/01-basic-typo/routes/api.js +13 -0
- package/examples/demos/01-basic-typo/routes/health.js +4 -0
- package/examples/demos/02-multi-file/index.js +24 -0
- package/examples/demos/02-multi-file/routes/api.js +13 -0
- package/examples/demos/02-multi-file/routes/health.js +4 -0
- package/examples/demos/03-syntax-error/index.js +18 -0
- package/examples/demos/04-secret-leak/index.js +16 -0
- package/examples/demos/05-expired-key/index.js +21 -0
- package/examples/demos/06-json-config/config.json +9 -0
- package/examples/demos/06-json-config/index.js +20 -0
- package/examples/demos/07-null-crash/index.js +16 -0
- package/examples/run-demo.js +110 -0
- package/package.json +67 -0
- package/server/config/settings.json +62 -0
- package/server/index.js +33 -0
- package/server/routes/api.js +12 -0
- package/server/routes/health.js +16 -0
- package/server/routes/time.js +12 -0
- package/src/agent/agent-engine.js +727 -0
- package/src/agent/goal-loop.js +140 -0
- package/src/agent/research-agent.js +120 -0
- package/src/agent/sub-agents.js +176 -0
- package/src/backup/backup-manager.js +321 -0
- package/src/brain/brain.js +315 -0
- package/src/brain/embedder.js +131 -0
- package/src/brain/function-map.js +263 -0
- package/src/brain/vector-store.js +267 -0
- package/src/core/ai-client.js +387 -0
- package/src/core/cluster-manager.js +144 -0
- package/src/core/config.js +89 -0
- package/src/core/error-parser.js +87 -0
- package/src/core/health-monitor.js +129 -0
- package/src/core/models.js +132 -0
- package/src/core/patcher.js +55 -0
- package/src/core/runner.js +464 -0
- package/src/core/system-info.js +141 -0
- package/src/core/verifier.js +146 -0
- package/src/core/wolverine.js +290 -0
- package/src/dashboard/server.js +1332 -0
- package/src/index.js +94 -0
- package/src/logger/event-logger.js +237 -0
- package/src/logger/pricing.js +96 -0
- package/src/logger/repair-history.js +109 -0
- package/src/logger/token-tracker.js +277 -0
- package/src/mcp/mcp-client.js +224 -0
- package/src/mcp/mcp-registry.js +228 -0
- package/src/mcp/mcp-security.js +152 -0
- package/src/monitor/perf-monitor.js +300 -0
- package/src/monitor/process-monitor.js +231 -0
- package/src/monitor/route-prober.js +191 -0
- package/src/notifications/notifier.js +227 -0
- package/src/platform/heartbeat.js +93 -0
- package/src/platform/queue.js +53 -0
- package/src/platform/register.js +64 -0
- package/src/platform/telemetry.js +76 -0
- package/src/security/admin-auth.js +150 -0
- package/src/security/injection-detector.js +174 -0
- package/src/security/rate-limiter.js +152 -0
- package/src/security/sandbox.js +128 -0
- package/src/security/secret-redactor.js +217 -0
- package/src/skills/skill-registry.js +129 -0
- package/src/skills/sql.js +375 -0
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rate Limiter — prevents error explosion from draining API budget.
|
|
3
|
+
*
|
|
4
|
+
* Uses a sliding window + token bucket approach:
|
|
5
|
+
* - Tracks API calls in a rolling time window
|
|
6
|
+
* - Enforces max calls per window
|
|
7
|
+
* - Enforces minimum gap between calls
|
|
8
|
+
* - Detects error loops (same error repeating) and backs off exponentially
|
|
9
|
+
*/
|
|
10
|
+
class RateLimiter {
|
|
11
|
+
constructor(options = {}) {
|
|
12
|
+
// Max API calls within the rolling window
|
|
13
|
+
this.maxCallsPerWindow = options.maxCallsPerWindow || 10;
|
|
14
|
+
// Window size in ms (default: 10 minutes)
|
|
15
|
+
this.windowMs = options.windowMs || 10 * 60 * 1000;
|
|
16
|
+
// Minimum gap between calls in ms (default: 5 seconds)
|
|
17
|
+
this.minGapMs = options.minGapMs || 5000;
|
|
18
|
+
// Max cost per hour in estimated tokens (rough budget protection)
|
|
19
|
+
this.maxTokensPerHour = options.maxTokensPerHour || 100000;
|
|
20
|
+
|
|
21
|
+
// Internal state
|
|
22
|
+
this._callLog = []; // timestamps of recent calls
|
|
23
|
+
this._tokenLog = []; // { timestamp, tokens } for budget tracking
|
|
24
|
+
this._errorSignatures = {}; // signature -> { count, lastSeen, backoffMs }
|
|
25
|
+
this._lastCallTime = 0;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Check if a call is allowed. Returns { allowed, reason, waitMs }.
|
|
30
|
+
* If not allowed, waitMs indicates how long to wait before retrying.
|
|
31
|
+
*/
|
|
32
|
+
check(errorSignature) {
|
|
33
|
+
const now = Date.now();
|
|
34
|
+
this._pruneOldEntries(now);
|
|
35
|
+
|
|
36
|
+
// 1. Minimum gap between calls
|
|
37
|
+
const sinceLast = now - this._lastCallTime;
|
|
38
|
+
if (sinceLast < this.minGapMs) {
|
|
39
|
+
const waitMs = this.minGapMs - sinceLast;
|
|
40
|
+
return { allowed: false, reason: `Rate limit: minimum ${this.minGapMs}ms gap between calls. Wait ${waitMs}ms.`, waitMs };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// 2. Sliding window limit
|
|
44
|
+
if (this._callLog.length >= this.maxCallsPerWindow) {
|
|
45
|
+
const oldestInWindow = this._callLog[0];
|
|
46
|
+
const waitMs = oldestInWindow + this.windowMs - now;
|
|
47
|
+
return { allowed: false, reason: `Rate limit: ${this.maxCallsPerWindow} calls per ${this.windowMs / 1000}s window exceeded.`, waitMs };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// 3. Hourly token budget
|
|
51
|
+
const hourAgo = now - 3600000;
|
|
52
|
+
const recentTokens = this._tokenLog
|
|
53
|
+
.filter(e => e.timestamp > hourAgo)
|
|
54
|
+
.reduce((sum, e) => sum + e.tokens, 0);
|
|
55
|
+
if (recentTokens >= this.maxTokensPerHour) {
|
|
56
|
+
return { allowed: false, reason: `Budget limit: estimated ${recentTokens} tokens used in the last hour (max: ${this.maxTokensPerHour}).`, waitMs: 60000 };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// 4. Error loop detection — same error repeating gets exponential backoff
|
|
60
|
+
if (errorSignature) {
|
|
61
|
+
const sig = this._errorSignatures[errorSignature];
|
|
62
|
+
if (sig && sig.count >= 2) {
|
|
63
|
+
const timeSinceLast = now - sig.lastSeen;
|
|
64
|
+
if (timeSinceLast < sig.backoffMs) {
|
|
65
|
+
const waitMs = sig.backoffMs - timeSinceLast;
|
|
66
|
+
return {
|
|
67
|
+
allowed: false,
|
|
68
|
+
reason: `Error loop detected: same error seen ${sig.count} times. Backing off ${sig.backoffMs / 1000}s.`,
|
|
69
|
+
waitMs,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return { allowed: true };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Record a call was made. Call this after a successful API request.
|
|
80
|
+
*/
|
|
81
|
+
record(errorSignature, estimatedTokens = 3000) {
|
|
82
|
+
const now = Date.now();
|
|
83
|
+
this._callLog.push(now);
|
|
84
|
+
this._tokenLog.push({ timestamp: now, tokens: estimatedTokens });
|
|
85
|
+
this._lastCallTime = now;
|
|
86
|
+
|
|
87
|
+
// Track error signature for loop detection
|
|
88
|
+
if (errorSignature) {
|
|
89
|
+
if (!this._errorSignatures[errorSignature]) {
|
|
90
|
+
this._errorSignatures[errorSignature] = { count: 0, lastSeen: 0, backoffMs: 5000 };
|
|
91
|
+
}
|
|
92
|
+
const sig = this._errorSignatures[errorSignature];
|
|
93
|
+
sig.count++;
|
|
94
|
+
sig.lastSeen = now;
|
|
95
|
+
// Exponential backoff: 5s, 10s, 20s, 40s, 80s... capped at 5 min
|
|
96
|
+
sig.backoffMs = Math.min(5000 * Math.pow(2, sig.count - 1), 300000);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Clear the error signature tracker (call after a successful fix that doesn't recur).
|
|
102
|
+
*/
|
|
103
|
+
clearSignature(errorSignature) {
|
|
104
|
+
delete this._errorSignatures[errorSignature];
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Generate a stable signature for an error (for loop detection).
|
|
109
|
+
*/
|
|
110
|
+
static signature(errorMessage, filePath) {
|
|
111
|
+
// Strip line numbers and paths to create a stable key
|
|
112
|
+
const normalized = (errorMessage || "")
|
|
113
|
+
.replace(/:\d+:\d+/g, "")
|
|
114
|
+
.replace(/\(.+\)/g, "")
|
|
115
|
+
.trim();
|
|
116
|
+
return `${filePath || "unknown"}::${normalized}`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Get current state for debugging/logging.
|
|
121
|
+
*/
|
|
122
|
+
getStats() {
|
|
123
|
+
const now = Date.now();
|
|
124
|
+
this._pruneOldEntries(now);
|
|
125
|
+
return {
|
|
126
|
+
callsInWindow: this._callLog.length,
|
|
127
|
+
maxCallsPerWindow: this.maxCallsPerWindow,
|
|
128
|
+
trackedErrorSignatures: Object.keys(this._errorSignatures).length,
|
|
129
|
+
estimatedTokensLastHour: this._tokenLog
|
|
130
|
+
.filter(e => e.timestamp > now - 3600000)
|
|
131
|
+
.reduce((sum, e) => sum + e.tokens, 0),
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
_pruneOldEntries(now) {
|
|
136
|
+
const windowStart = now - this.windowMs;
|
|
137
|
+
this._callLog = this._callLog.filter(t => t > windowStart);
|
|
138
|
+
|
|
139
|
+
const hourAgo = now - 3600000;
|
|
140
|
+
this._tokenLog = this._tokenLog.filter(e => e.timestamp > hourAgo);
|
|
141
|
+
|
|
142
|
+
// Clean stale error signatures (not seen in 30 min)
|
|
143
|
+
const staleThreshold = now - 30 * 60 * 1000;
|
|
144
|
+
for (const key of Object.keys(this._errorSignatures)) {
|
|
145
|
+
if (this._errorSignatures[key].lastSeen < staleThreshold) {
|
|
146
|
+
delete this._errorSignatures[key];
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
module.exports = { RateLimiter };
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
const path = require("path");
|
|
2
|
+
const fs = require("fs");
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* File Sandbox — restricts all file operations to the project directory.
|
|
6
|
+
*
|
|
7
|
+
* Every file read/write in wolverine passes through this gate.
|
|
8
|
+
* Rejects path traversal, symlink escapes, and out-of-bounds access.
|
|
9
|
+
*/
|
|
10
|
+
class Sandbox {
|
|
11
|
+
constructor(rootDir) {
|
|
12
|
+
this.rootDir = path.resolve(rootDir);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Resolve and validate a file path. Throws if the path escapes the sandbox.
|
|
17
|
+
* Returns the resolved absolute path.
|
|
18
|
+
*/
|
|
19
|
+
resolve(filePath) {
|
|
20
|
+
// Resolve relative to sandbox root
|
|
21
|
+
const resolved = path.isAbsolute(filePath)
|
|
22
|
+
? path.resolve(filePath)
|
|
23
|
+
: path.resolve(this.rootDir, filePath);
|
|
24
|
+
|
|
25
|
+
// Normalize separators for consistent comparison
|
|
26
|
+
const normalizedResolved = resolved.replace(/\\/g, "/").toLowerCase();
|
|
27
|
+
const normalizedRoot = this.rootDir.replace(/\\/g, "/").toLowerCase();
|
|
28
|
+
|
|
29
|
+
if (!normalizedResolved.startsWith(normalizedRoot + "/") && normalizedResolved !== normalizedRoot) {
|
|
30
|
+
throw new SandboxViolationError(
|
|
31
|
+
`Path "${filePath}" resolves to "${resolved}" which is outside the sandbox root "${this.rootDir}"`
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Check for symlink escape
|
|
36
|
+
if (fs.existsSync(resolved)) {
|
|
37
|
+
const real = fs.realpathSync(resolved);
|
|
38
|
+
const normalizedReal = real.replace(/\\/g, "/").toLowerCase();
|
|
39
|
+
if (!normalizedReal.startsWith(normalizedRoot + "/") && normalizedReal !== normalizedRoot) {
|
|
40
|
+
throw new SandboxViolationError(
|
|
41
|
+
`Path "${filePath}" is a symlink that escapes the sandbox (real path: "${real}")`
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return resolved;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Read a file within the sandbox.
|
|
51
|
+
*/
|
|
52
|
+
readFile(filePath) {
|
|
53
|
+
const resolved = this.resolve(filePath);
|
|
54
|
+
return fs.readFileSync(resolved, "utf-8");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Write a file within the sandbox.
|
|
59
|
+
*/
|
|
60
|
+
writeFile(filePath, content) {
|
|
61
|
+
const resolved = this.resolve(filePath);
|
|
62
|
+
fs.writeFileSync(resolved, content, "utf-8");
|
|
63
|
+
return resolved;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Check if a file exists within the sandbox.
|
|
68
|
+
*/
|
|
69
|
+
exists(filePath) {
|
|
70
|
+
try {
|
|
71
|
+
const resolved = this.resolve(filePath);
|
|
72
|
+
return fs.existsSync(resolved);
|
|
73
|
+
} catch (e) {
|
|
74
|
+
if (e instanceof SandboxViolationError) return false;
|
|
75
|
+
throw e;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Copy a file within the sandbox.
|
|
81
|
+
*/
|
|
82
|
+
copyFile(src, dest) {
|
|
83
|
+
const resolvedSrc = this.resolve(src);
|
|
84
|
+
const resolvedDest = this.resolve(dest);
|
|
85
|
+
fs.copyFileSync(resolvedSrc, resolvedDest);
|
|
86
|
+
return resolvedDest;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Delete a file within the sandbox.
|
|
91
|
+
*/
|
|
92
|
+
unlinkFile(filePath) {
|
|
93
|
+
const resolved = this.resolve(filePath);
|
|
94
|
+
if (fs.existsSync(resolved)) {
|
|
95
|
+
fs.unlinkSync(resolved);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Validate a set of AI-proposed changes — all file paths must be in sandbox.
|
|
101
|
+
* Returns { valid: boolean, violations: string[] }
|
|
102
|
+
*/
|
|
103
|
+
validateChanges(changes) {
|
|
104
|
+
const violations = [];
|
|
105
|
+
for (const change of changes) {
|
|
106
|
+
try {
|
|
107
|
+
this.resolve(change.file);
|
|
108
|
+
} catch (e) {
|
|
109
|
+
if (e instanceof SandboxViolationError) {
|
|
110
|
+
violations.push(e.message);
|
|
111
|
+
} else {
|
|
112
|
+
throw e;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return { valid: violations.length === 0, violations };
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
class SandboxViolationError extends Error {
|
|
121
|
+
constructor(message) {
|
|
122
|
+
super(message);
|
|
123
|
+
this.name = "SandboxViolationError";
|
|
124
|
+
this.code = "SANDBOX_VIOLATION";
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
module.exports = { Sandbox, SandboxViolationError };
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
const fs = require("fs");
|
|
2
|
+
const path = require("path");
|
|
3
|
+
const chalk = require("chalk");
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Secret Redactor — prevents secrets from leaking to AI, brain, logs, or dashboard.
|
|
7
|
+
*
|
|
8
|
+
* On init, reads .env.local / .env to build a map of secret values → env key names.
|
|
9
|
+
* Then every string that passes through redact() gets values replaced with
|
|
10
|
+
* `process.env.KEY_NAME` so the AI knows which secret is referenced without
|
|
11
|
+
* seeing the actual value.
|
|
12
|
+
*
|
|
13
|
+
* Also detects common secret patterns (API keys, tokens, connection strings)
|
|
14
|
+
* that might not be in .env files.
|
|
15
|
+
*
|
|
16
|
+
* This runs as a gate on EVERY outbound path:
|
|
17
|
+
* - Before AI calls (error messages, source code, stack traces)
|
|
18
|
+
* - Before brain memory storage
|
|
19
|
+
* - Before event logger persistence
|
|
20
|
+
* - Before dashboard SSE broadcast
|
|
21
|
+
* - Before agent tool results
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
// Patterns that look like secrets even if we don't know them from .env
|
|
25
|
+
const SECRET_PATTERNS = [
|
|
26
|
+
// API keys
|
|
27
|
+
{ pattern: /sk-[a-zA-Z0-9_-]{20,}/g, label: "[REDACTED_API_KEY]" },
|
|
28
|
+
{ pattern: /sk-proj-[a-zA-Z0-9_-]{20,}/g, label: "[REDACTED_OPENAI_KEY]" },
|
|
29
|
+
{ pattern: /ghp_[a-zA-Z0-9]{36,}/g, label: "[REDACTED_GITHUB_TOKEN]" },
|
|
30
|
+
{ pattern: /gho_[a-zA-Z0-9]{36,}/g, label: "[REDACTED_GITHUB_OAUTH]" },
|
|
31
|
+
{ pattern: /xoxb-[a-zA-Z0-9-]+/g, label: "[REDACTED_SLACK_TOKEN]" },
|
|
32
|
+
{ pattern: /xoxp-[a-zA-Z0-9-]+/g, label: "[REDACTED_SLACK_TOKEN]" },
|
|
33
|
+
// AWS
|
|
34
|
+
{ pattern: /AKIA[0-9A-Z]{16}/g, label: "[REDACTED_AWS_KEY]" },
|
|
35
|
+
// Connection strings
|
|
36
|
+
{ pattern: /(?:mongodb|postgres|mysql|redis|amqp):\/\/[^\s"'`]+/gi, label: "[REDACTED_CONNECTION_STRING]" },
|
|
37
|
+
// Bearer tokens
|
|
38
|
+
{ pattern: /Bearer\s+[a-zA-Z0-9._-]{20,}/g, label: "Bearer [REDACTED_TOKEN]" },
|
|
39
|
+
// Generic long hex/base64 secrets (32+ chars of hex or base64)
|
|
40
|
+
{ pattern: /(?:password|secret|token|key|credential|auth)['"`]?\s*[:=]\s*['"`]([a-zA-Z0-9+/=_-]{32,})['"`]/gi, label: null }, // handled specially
|
|
41
|
+
// JWT tokens
|
|
42
|
+
{ pattern: /eyJ[a-zA-Z0-9_-]{10,}\.eyJ[a-zA-Z0-9_-]{10,}\.[a-zA-Z0-9_-]{10,}/g, label: "[REDACTED_JWT]" },
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
class SecretRedactor {
|
|
46
|
+
constructor(projectRoot) {
|
|
47
|
+
this.projectRoot = path.resolve(projectRoot);
|
|
48
|
+
// Map: secret value → env key name (sorted longest first for greedy matching)
|
|
49
|
+
this._secretMap = [];
|
|
50
|
+
// Set of raw secret values for fast detection
|
|
51
|
+
this._secretValues = new Set();
|
|
52
|
+
|
|
53
|
+
this._loadEnvSecrets();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Redact all secrets from a string.
|
|
58
|
+
* Replaces actual secret values with `process.env.KEY_NAME`.
|
|
59
|
+
* Also catches pattern-based secrets not in .env.
|
|
60
|
+
*
|
|
61
|
+
* @param {string} text - Any string that might contain secrets
|
|
62
|
+
* @returns {string} - Redacted string
|
|
63
|
+
*/
|
|
64
|
+
redact(text) {
|
|
65
|
+
if (!text || typeof text !== "string") return text;
|
|
66
|
+
|
|
67
|
+
let result = text;
|
|
68
|
+
|
|
69
|
+
// 1. Replace known env secret values with their key names (longest first)
|
|
70
|
+
for (const { value, key } of this._secretMap) {
|
|
71
|
+
if (result.includes(value)) {
|
|
72
|
+
result = result.split(value).join(`process.env.${key}`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// 2. Apply pattern-based redaction for secrets we don't know about
|
|
77
|
+
for (const { pattern, label } of SECRET_PATTERNS) {
|
|
78
|
+
if (label) {
|
|
79
|
+
result = result.replace(pattern, label);
|
|
80
|
+
} else {
|
|
81
|
+
// Special handling for key=value patterns — replace just the value part
|
|
82
|
+
result = result.replace(pattern, (match, capturedValue) => {
|
|
83
|
+
if (capturedValue && capturedValue.length >= 32) {
|
|
84
|
+
return match.replace(capturedValue, "[REDACTED_SECRET]");
|
|
85
|
+
}
|
|
86
|
+
return match;
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return result;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Redact secrets from a structured object (deep).
|
|
96
|
+
* Walks all string values in objects/arrays.
|
|
97
|
+
*/
|
|
98
|
+
redactObject(obj) {
|
|
99
|
+
if (typeof obj === "string") return this.redact(obj);
|
|
100
|
+
if (Array.isArray(obj)) return obj.map(item => this.redactObject(item));
|
|
101
|
+
if (obj && typeof obj === "object") {
|
|
102
|
+
const result = {};
|
|
103
|
+
for (const [key, val] of Object.entries(obj)) {
|
|
104
|
+
result[key] = this.redactObject(val);
|
|
105
|
+
}
|
|
106
|
+
return result;
|
|
107
|
+
}
|
|
108
|
+
return obj;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Check if a string contains any known secrets. Fast check.
|
|
113
|
+
*/
|
|
114
|
+
containsSecrets(text) {
|
|
115
|
+
if (!text) return false;
|
|
116
|
+
for (const { value } of this._secretMap) {
|
|
117
|
+
if (text.includes(value)) return true;
|
|
118
|
+
}
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Reload secrets (call if .env files change).
|
|
124
|
+
*/
|
|
125
|
+
reload() {
|
|
126
|
+
this._secretMap = [];
|
|
127
|
+
this._secretValues.clear();
|
|
128
|
+
this._loadEnvSecrets();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Get stats for logging.
|
|
133
|
+
*/
|
|
134
|
+
getStats() {
|
|
135
|
+
return {
|
|
136
|
+
trackedSecrets: this._secretMap.length,
|
|
137
|
+
envFiles: this._envFilesLoaded || 0,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// -- Private --
|
|
142
|
+
|
|
143
|
+
_loadEnvSecrets() {
|
|
144
|
+
const envFiles = [".env.local", ".env", ".env.production", ".env.development"];
|
|
145
|
+
let filesLoaded = 0;
|
|
146
|
+
|
|
147
|
+
for (const envFile of envFiles) {
|
|
148
|
+
const filePath = path.join(this.projectRoot, envFile);
|
|
149
|
+
if (!fs.existsSync(filePath)) continue;
|
|
150
|
+
|
|
151
|
+
filesLoaded++;
|
|
152
|
+
const content = fs.readFileSync(filePath, "utf-8");
|
|
153
|
+
const lines = content.split("\n");
|
|
154
|
+
|
|
155
|
+
for (const line of lines) {
|
|
156
|
+
// Skip comments and empty lines
|
|
157
|
+
const trimmed = line.trim();
|
|
158
|
+
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
159
|
+
|
|
160
|
+
const eqIndex = trimmed.indexOf("=");
|
|
161
|
+
if (eqIndex === -1) continue;
|
|
162
|
+
|
|
163
|
+
const key = trimmed.slice(0, eqIndex).trim();
|
|
164
|
+
const value = trimmed.slice(eqIndex + 1).trim()
|
|
165
|
+
// Remove surrounding quotes
|
|
166
|
+
.replace(/^['"]|['"]$/g, "");
|
|
167
|
+
|
|
168
|
+
// Only track values that look like secrets (long enough, not just a port number)
|
|
169
|
+
if (value.length >= 8 && !this._isNonSecret(key, value)) {
|
|
170
|
+
this._secretMap.push({ key, value });
|
|
171
|
+
this._secretValues.add(value);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Also add current process.env values for known secret keys
|
|
177
|
+
const knownSecretKeys = [
|
|
178
|
+
"OPENAI_API_KEY", "DATABASE_URL", "REDIS_URL", "JWT_SECRET",
|
|
179
|
+
"SESSION_SECRET", "AWS_SECRET_ACCESS_KEY", "GITHUB_TOKEN",
|
|
180
|
+
"SLACK_TOKEN", "STRIPE_SECRET_KEY", "SENDGRID_API_KEY",
|
|
181
|
+
];
|
|
182
|
+
|
|
183
|
+
for (const key of knownSecretKeys) {
|
|
184
|
+
const value = process.env[key];
|
|
185
|
+
if (value && value.length >= 8 && !this._secretValues.has(value)) {
|
|
186
|
+
this._secretMap.push({ key, value });
|
|
187
|
+
this._secretValues.add(value);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Sort longest values first for greedy replacement
|
|
192
|
+
this._secretMap.sort((a, b) => b.value.length - a.value.length);
|
|
193
|
+
this._envFilesLoaded = filesLoaded;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Heuristic: is this key/value pair NOT a secret?
|
|
198
|
+
* Avoids redacting things like PORT=6969 or NODE_ENV=production.
|
|
199
|
+
*/
|
|
200
|
+
_isNonSecret(key, value) {
|
|
201
|
+
const nonSecretKeys = /^(PORT|HOST|NODE_ENV|DEBUG|LOG_LEVEL|TZ|LANG|PATH|HOME|USER|SHELL|TERM|HOSTNAME)$/i;
|
|
202
|
+
if (nonSecretKeys.test(key)) return true;
|
|
203
|
+
|
|
204
|
+
// Model names aren't secrets
|
|
205
|
+
if (key.endsWith("_MODEL")) return true;
|
|
206
|
+
|
|
207
|
+
// Pure numbers aren't secrets
|
|
208
|
+
if (/^\d+$/.test(value)) return true;
|
|
209
|
+
|
|
210
|
+
// Short common values
|
|
211
|
+
if (["true", "false", "development", "production", "staging", "test", "localhost"].includes(value.toLowerCase())) return true;
|
|
212
|
+
|
|
213
|
+
return false;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
module.exports = { SecretRedactor };
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
const fs = require("fs");
|
|
2
|
+
const path = require("path");
|
|
3
|
+
const chalk = require("chalk");
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Skill Registry — discovers skills and injects relevant ones into agent context.
|
|
7
|
+
*
|
|
8
|
+
* claw-code pattern:
|
|
9
|
+
* 1. On startup, scan all skills and build a token-searchable index
|
|
10
|
+
* 2. Before any AI call, match the user's command/error against skills
|
|
11
|
+
* 3. Inject matched skill context into the prompt (not the AI — the prompt)
|
|
12
|
+
*
|
|
13
|
+
* Skills are JS modules in src/skills/ that export:
|
|
14
|
+
* - name: string
|
|
15
|
+
* - description: string
|
|
16
|
+
* - keywords: string[] — tokens for matching
|
|
17
|
+
* - usage: string — code example to inject into agent prompts
|
|
18
|
+
* - middleware?: function — Express middleware to mount
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
class SkillRegistry {
|
|
22
|
+
constructor(options = {}) {
|
|
23
|
+
this.skillsDir = options.skillsDir || path.join(__dirname);
|
|
24
|
+
this._skills = [];
|
|
25
|
+
this._loaded = false;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Discover and load all skills from the skills directory.
|
|
30
|
+
*/
|
|
31
|
+
load() {
|
|
32
|
+
if (!fs.existsSync(this.skillsDir)) return;
|
|
33
|
+
|
|
34
|
+
const files = fs.readdirSync(this.skillsDir).filter(f =>
|
|
35
|
+
f.endsWith(".js") && f !== "skill-registry.js"
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
for (const file of files) {
|
|
39
|
+
try {
|
|
40
|
+
const mod = require(path.join(this.skillsDir, file));
|
|
41
|
+
const skill = {
|
|
42
|
+
file,
|
|
43
|
+
name: mod.SKILL_NAME || file.replace(".js", ""),
|
|
44
|
+
description: mod.SKILL_DESCRIPTION || "",
|
|
45
|
+
keywords: mod.SKILL_KEYWORDS || [],
|
|
46
|
+
usage: mod.SKILL_USAGE || "",
|
|
47
|
+
hasMiddleware: typeof mod.sqlGuard === "function" || typeof mod.middleware === "function",
|
|
48
|
+
};
|
|
49
|
+
this._skills.push(skill);
|
|
50
|
+
} catch (err) {
|
|
51
|
+
console.log(chalk.yellow(` ⚠️ Skill load error (${file}): ${err.message}`));
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
this._loaded = true;
|
|
56
|
+
if (this._skills.length > 0) {
|
|
57
|
+
console.log(chalk.gray(` 🔧 Skills: ${this._skills.map(s => s.name).join(", ")}`));
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Match skills to a command/error using token scoring.
|
|
63
|
+
* claw-code pattern: tokenize → score against name/description/keywords → return top matches.
|
|
64
|
+
*
|
|
65
|
+
* @param {string} text — user command or error message
|
|
66
|
+
* @param {number} limit — max skills to return
|
|
67
|
+
* @returns {Array<{ name, description, usage, score }>}
|
|
68
|
+
*/
|
|
69
|
+
match(text, limit = 3) {
|
|
70
|
+
if (!text || this._skills.length === 0) return [];
|
|
71
|
+
|
|
72
|
+
const tokens = text.toLowerCase()
|
|
73
|
+
.replace(/[^a-z0-9\s]/g, " ")
|
|
74
|
+
.split(/\s+/)
|
|
75
|
+
.filter(t => t.length > 2);
|
|
76
|
+
|
|
77
|
+
const scored = this._skills.map(skill => {
|
|
78
|
+
const haystacks = [
|
|
79
|
+
skill.name.toLowerCase(),
|
|
80
|
+
skill.description.toLowerCase(),
|
|
81
|
+
...skill.keywords.map(k => k.toLowerCase()),
|
|
82
|
+
].join(" ");
|
|
83
|
+
|
|
84
|
+
let score = 0;
|
|
85
|
+
for (const token of tokens) {
|
|
86
|
+
if (haystacks.includes(token)) score++;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return { ...skill, score };
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
return scored
|
|
93
|
+
.filter(s => s.score > 0)
|
|
94
|
+
.sort((a, b) => b.score - a.score)
|
|
95
|
+
.slice(0, limit);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Build context string for matched skills — inject into AI prompt.
|
|
100
|
+
* claw-code pattern: pre-enrich the prompt with tool availability.
|
|
101
|
+
*/
|
|
102
|
+
buildContext(text) {
|
|
103
|
+
const matches = this.match(text);
|
|
104
|
+
if (matches.length === 0) return "";
|
|
105
|
+
|
|
106
|
+
const parts = ["\n## Available Skills"];
|
|
107
|
+
for (const skill of matches) {
|
|
108
|
+
parts.push(`### ${skill.name}`);
|
|
109
|
+
if (skill.description) parts.push(skill.description);
|
|
110
|
+
if (skill.usage) parts.push("```js\n" + skill.usage + "\n```");
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return parts.join("\n");
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Get all skills for dashboard display.
|
|
118
|
+
*/
|
|
119
|
+
getAll() {
|
|
120
|
+
return this._skills.map(s => ({
|
|
121
|
+
name: s.name,
|
|
122
|
+
description: s.description,
|
|
123
|
+
keywords: s.keywords,
|
|
124
|
+
hasMiddleware: s.hasMiddleware,
|
|
125
|
+
}));
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
module.exports = { SkillRegistry };
|