cipher-security 2.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/bin/cipher.js +566 -0
- package/lib/api/billing.js +321 -0
- package/lib/api/compliance.js +693 -0
- package/lib/api/controls.js +1401 -0
- package/lib/api/index.js +49 -0
- package/lib/api/marketplace.js +467 -0
- package/lib/api/openai-proxy.js +383 -0
- package/lib/api/server.js +685 -0
- package/lib/autonomous/feedback-loop.js +554 -0
- package/lib/autonomous/framework.js +512 -0
- package/lib/autonomous/index.js +97 -0
- package/lib/autonomous/leaderboard.js +594 -0
- package/lib/autonomous/modes/architect.js +412 -0
- package/lib/autonomous/modes/blue.js +386 -0
- package/lib/autonomous/modes/incident.js +684 -0
- package/lib/autonomous/modes/privacy.js +369 -0
- package/lib/autonomous/modes/purple.js +294 -0
- package/lib/autonomous/modes/recon.js +250 -0
- package/lib/autonomous/parallel.js +587 -0
- package/lib/autonomous/researcher.js +583 -0
- package/lib/autonomous/runner.js +955 -0
- package/lib/autonomous/scheduler.js +615 -0
- package/lib/autonomous/task-parser.js +127 -0
- package/lib/autonomous/validators/forensic.js +266 -0
- package/lib/autonomous/validators/osint.js +216 -0
- package/lib/autonomous/validators/privacy.js +296 -0
- package/lib/autonomous/validators/purple.js +298 -0
- package/lib/autonomous/validators/sigma.js +248 -0
- package/lib/autonomous/validators/threat-model.js +363 -0
- package/lib/benchmark/agent.js +119 -0
- package/lib/benchmark/baselines.js +43 -0
- package/lib/benchmark/builder.js +143 -0
- package/lib/benchmark/config.js +35 -0
- package/lib/benchmark/coordinator.js +91 -0
- package/lib/benchmark/index.js +20 -0
- package/lib/benchmark/llm.js +58 -0
- package/lib/benchmark/models.js +137 -0
- package/lib/benchmark/reporter.js +103 -0
- package/lib/benchmark/runner.js +103 -0
- package/lib/benchmark/sandbox.js +96 -0
- package/lib/benchmark/scorer.js +32 -0
- package/lib/benchmark/solver.js +166 -0
- package/lib/benchmark/tools.js +62 -0
- package/lib/bot/bot.js +238 -0
- package/lib/brand.js +105 -0
- package/lib/commands.js +100 -0
- package/lib/complexity.js +377 -0
- package/lib/config.js +213 -0
- package/lib/gateway/client.js +309 -0
- package/lib/gateway/commands.js +991 -0
- package/lib/gateway/config-validate.js +109 -0
- package/lib/gateway/gateway.js +367 -0
- package/lib/gateway/index.js +62 -0
- package/lib/gateway/mode.js +309 -0
- package/lib/gateway/plugins.js +222 -0
- package/lib/gateway/prompt.js +214 -0
- package/lib/mcp/server.js +262 -0
- package/lib/memory/compressor.js +425 -0
- package/lib/memory/engine.js +763 -0
- package/lib/memory/evolution.js +668 -0
- package/lib/memory/index.js +58 -0
- package/lib/memory/orchestrator.js +506 -0
- package/lib/memory/retriever.js +515 -0
- package/lib/memory/synthesizer.js +333 -0
- package/lib/pipeline/async-scanner.js +510 -0
- package/lib/pipeline/binary-analysis.js +1043 -0
- package/lib/pipeline/dom-xss-scanner.js +435 -0
- package/lib/pipeline/github-actions.js +792 -0
- package/lib/pipeline/index.js +124 -0
- package/lib/pipeline/osint.js +498 -0
- package/lib/pipeline/sarif.js +373 -0
- package/lib/pipeline/scanner.js +880 -0
- package/lib/pipeline/template-manager.js +525 -0
- package/lib/pipeline/xss-scanner.js +353 -0
- package/lib/setup-wizard.js +288 -0
- package/package.json +31 -0
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
// Copyright (c) 2026 defconxt. All rights reserved.
|
|
2
|
+
// Licensed under AGPL-3.0 — see LICENSE file for details.
|
|
3
|
+
// CIPHER is a trademark of defconxt.
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* mode.js — Mode detection for CIPHER gateway.
|
|
7
|
+
*
|
|
8
|
+
* Ports Python gateway/mode.py with behavioral fidelity:
|
|
9
|
+
* - parseKeywordTable: extract mode→keywords from CLAUDE.md trigger table
|
|
10
|
+
* - detectMode: route message to operating mode via explicit prefix or keyword scoring
|
|
11
|
+
* - stripModePrefix: remove [MODE: X] prefix from message text
|
|
12
|
+
*
|
|
13
|
+
* @module gateway/mode
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
// Matches [MODE: X] at the start of a message, case-insensitive.
|
|
17
|
+
const EXPLICIT_RE = /^\[MODE:\s*(\w+)\]/i;
|
|
18
|
+
|
|
19
|
+
// Matches the trigger keywords table block inside CLAUDE.md.
|
|
20
|
+
// Python: re.DOTALL → JS `s` flag, re.IGNORECASE → `i` flag.
|
|
21
|
+
const TABLE_HEADER_RE = /Trigger Keywords.*?\n\|[-| ]+\|\n(.*?)(?:\n-+|\n\n|$)/si;
|
|
22
|
+
|
|
23
|
+
// Individual data row: | WORD | content |
|
|
24
|
+
// Python: re.MULTILINE → JS `m` flag.
|
|
25
|
+
const TABLE_ROW_RE = /^\|\s*(\w+)\s*\|\s*(.+?)\s*\|/gm;
|
|
26
|
+
|
|
27
|
+
// Known header/separator cell values to skip.
|
|
28
|
+
const SKIP_ROW_NAMES = new Set(['MODE', '---']);
|
|
29
|
+
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
// Supplementary keywords — ported verbatim from Python mode.py
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
/** @type {Record<string, string[]>} */
|
|
35
|
+
const SUPPLEMENTARY_KEYWORDS = {
|
|
36
|
+
RED: [
|
|
37
|
+
// Tools
|
|
38
|
+
'smb', 'nmap', 'bloodhound', 'mimikatz', 'impacket', 'cobaltstrike',
|
|
39
|
+
'cobalt strike', 'msfconsole', 'metasploit', 'hashcat', 'john the ripper',
|
|
40
|
+
'burp suite', 'sqlmap', 'ettercap', 'bettercap', 'scapy', 'hydra',
|
|
41
|
+
'crackmapexec', 'evil-winrm', 'chisel', 'ligolo', 'rubeus', 'certipy',
|
|
42
|
+
'responder', 'beef', 'empire', 'sliver', 'havoc',
|
|
43
|
+
// Techniques
|
|
44
|
+
'brute force', 'credential stuffing', 'credential dumping', 'kerberoast',
|
|
45
|
+
'as-rep', 'pentest', 'penetration test', 'post-exploitation', 'exfiltrate',
|
|
46
|
+
'exfiltration', 'privilege escalation', 'escalate privilege', 'initial access',
|
|
47
|
+
'dcsync', 'secretsdump', 'pass the hash', 'pass the ticket', 'golden ticket',
|
|
48
|
+
'silver ticket', 'ntlm relay', 'sql injection', 'xss', 'cross-site scripting',
|
|
49
|
+
'command injection', 'buffer overflow', 'heap spray', 'rop chain', 'shellcode',
|
|
50
|
+
'arp spoofing', 'arp poisoning', 'arp cache poisoning', 'man in the middle',
|
|
51
|
+
'man-in-the-middle', 'session hijacking', 'dns tunneling', 'dns poisoning',
|
|
52
|
+
'phishing campaign', 'phishing website', 'phishing email', 'spear phishing',
|
|
53
|
+
'spear-phishing', 'covert channel', 'webshell', 'web shell',
|
|
54
|
+
'reverse engineering', 'dll injection', 'process injection',
|
|
55
|
+
'token impersonation', 'tokenimpersonation', 'password spraying',
|
|
56
|
+
'password spray', 'kerberos', 'ntlm', 'lsass', 'sam database',
|
|
57
|
+
'crack password', 'crack hash', 'pivot', 'pivoting',
|
|
58
|
+
// Attack types
|
|
59
|
+
'vlan hopping', 'ssl stripping', 'ssl strip', 'syn flood', 'ddos attack',
|
|
60
|
+
'ddos', 'dos attack', 'malicious usb', 'rubber ducky', 'evil twin',
|
|
61
|
+
'rogue ap', 'poisoning attack', 'spoofing attack', 'injection attack',
|
|
62
|
+
'race condition exploit', 'deserialization', 'ssrf', 'csrf', 'lfi', 'rfi',
|
|
63
|
+
'path traversal attack', 'directory traversal', 'xml external entity', 'xxe',
|
|
64
|
+
'ssti', 'template injection', 'type juggling', 'prototype pollution',
|
|
65
|
+
'insecure deserialization',
|
|
66
|
+
],
|
|
67
|
+
|
|
68
|
+
BLUE: [
|
|
69
|
+
// Tools
|
|
70
|
+
'splunk', 'elastic', 'elasticsearch', 'kibana', 'sentinel', 'kql', 'spl',
|
|
71
|
+
'yara', 'snort', 'suricata', 'zeek', 'osquery', 'wazuh', 'velociraptor',
|
|
72
|
+
'carbon black', 'crowdstrike', 'elk stack', 'logstash', 'graylog', 'qradar',
|
|
73
|
+
'arcsight', 'chronicle', 'datadog security',
|
|
74
|
+
// Techniques
|
|
75
|
+
'alert rule', 'alert rules', 'detect malware', 'detect lateral',
|
|
76
|
+
'detect exfiltration', 'detect anomal', 'log correlation', 'log monitoring',
|
|
77
|
+
'baseline', 'false positive', 'false positives', 'tuning', 'rule tuning',
|
|
78
|
+
'ioc detection', 'behavioral detection', 'anomaly detection',
|
|
79
|
+
'network monitoring', 'file integrity', 'audit policy', 'audit policies',
|
|
80
|
+
'windows event', 'event log', 'syslog', 'sysmon',
|
|
81
|
+
// From scale eval failures
|
|
82
|
+
'honeypot', 'honeynet', 'deception technology', 'canary token',
|
|
83
|
+
'default credential', 'continuous monitoring', 'security automation',
|
|
84
|
+
'security playbook', 'soar', 'dwell time', 'mean time to detect', 'mttd',
|
|
85
|
+
'mean time to respond', 'mttr', 'security posture', 'threat visibility',
|
|
86
|
+
'security stack', 'powershell logging',
|
|
87
|
+
],
|
|
88
|
+
|
|
89
|
+
PURPLE: [
|
|
90
|
+
'att&ck coverage', 'mitre coverage', 'detection gap', 'detection gaps',
|
|
91
|
+
'coverage gap', 'coverage gaps', 'emulate', 'adversary emulation',
|
|
92
|
+
'atomic red team', 'caldera', 'purple team', 'detection coverage',
|
|
93
|
+
'how well do our', 'gaps in our', 'gaps in detecting',
|
|
94
|
+
'test our detection', 'test our defenses', 'simulate attack',
|
|
95
|
+
'simulate an attack', 'rules against', 'evaluate our',
|
|
96
|
+
'effectiveness of our',
|
|
97
|
+
],
|
|
98
|
+
|
|
99
|
+
INCIDENT: [
|
|
100
|
+
'compromise', 'compromised', 'ransomware', 'malware found', 'we found',
|
|
101
|
+
'active breach', 'evidence collection', 'got an alert', 'beacon callback',
|
|
102
|
+
'what do we do', 'we detected', 'suspicious activity', 'active incident',
|
|
103
|
+
'we suspect', 'forensic analysis', 'forensic investigation',
|
|
104
|
+
'memory forensics', 'disk forensics', 'how do we proceed',
|
|
105
|
+
'how should we respond', 'how do we contain', 'contain the breach',
|
|
106
|
+
'containment plan', 'our siem shows', 'our ids detected',
|
|
107
|
+
'our firewall logs show', 'signs of lateral', 'unusual traffic',
|
|
108
|
+
'unusual activity', 'incident runbook', 'evidence preservation',
|
|
109
|
+
'chain of custody', 'root cause analysis', 'post-incident', 'after-action',
|
|
110
|
+
],
|
|
111
|
+
|
|
112
|
+
RECON: [
|
|
113
|
+
'enumerate subdomains', 'subdomain enumeration', 'dns recon', 'shodan',
|
|
114
|
+
'censys', 'whois', 'footprinting', 'footprint', 'gather intelligence',
|
|
115
|
+
'intelligence gathering', 'attack surface', 'asset discovery',
|
|
116
|
+
'exposed services', 'exposed assets', 'open ports', 'port scanning',
|
|
117
|
+
'banner grabbing', 'google dorking', 'google dork', 'github recon',
|
|
118
|
+
'linkedin', 'email harvesting', 'email addresses', 'amass', 'subfinder',
|
|
119
|
+
'httpx', 'theharvester', 'recon-ng', 'maltego', 'spiderfoot', 's3 bucket',
|
|
120
|
+
'cloud enumeration', 'digital presence', 'network footprint',
|
|
121
|
+
],
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
// Keywords too generic to be decisive — score 0.5 instead of word count.
|
|
125
|
+
const GENERIC_KEYWORDS = new Set([
|
|
126
|
+
'enumerate', 'credential', 'persistence', 'detection',
|
|
127
|
+
'att&ck', 'network', 'firewall',
|
|
128
|
+
]);
|
|
129
|
+
|
|
130
|
+
// Defensive signals that boost BLUE and reduce RED.
|
|
131
|
+
const DEFENSIVE_SIGNALS = [
|
|
132
|
+
'detect', 'detecting', 'alert on', 'alerting', 'monitor for',
|
|
133
|
+
'monitoring', 'hunt for', 'hunting', 'identify indicator',
|
|
134
|
+
'write a rule', 'write a query', 'kql ',
|
|
135
|
+
'yara rule', 'snort rule', 'suricata rule',
|
|
136
|
+
'indicators of', 'how do i detect', 'how can i detect',
|
|
137
|
+
'how to detect', 'for detecting', 'rule for',
|
|
138
|
+
'detection rule', 'to identify potential', 'to detect',
|
|
139
|
+
];
|
|
140
|
+
|
|
141
|
+
// Incident context signals.
|
|
142
|
+
const INCIDENT_SIGNALS = [
|
|
143
|
+
'we suspect', 'we found', 'we detected', 'our siem',
|
|
144
|
+
'our ids', 'our firewall', 'how do we', 'how should we',
|
|
145
|
+
'contain', 'respond to', 'triage', 'runbook',
|
|
146
|
+
];
|
|
147
|
+
|
|
148
|
+
// ---------------------------------------------------------------------------
|
|
149
|
+
// Public API
|
|
150
|
+
// ---------------------------------------------------------------------------
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Parse the `| Mode | Trigger Keywords |` table from CLAUDE.md text.
|
|
154
|
+
*
|
|
155
|
+
* Returns a dict mapping UPPERCASE mode names to arrays of lowercase,
|
|
156
|
+
* stripped keyword strings. Returns empty object if the table is absent.
|
|
157
|
+
*
|
|
158
|
+
* @param {string} claudeMdText - Raw CLAUDE.md content
|
|
159
|
+
* @returns {Record<string, string[]>}
|
|
160
|
+
*/
|
|
161
|
+
export function parseKeywordTable(claudeMdText) {
|
|
162
|
+
if (!claudeMdText || !claudeMdText.trim()) {
|
|
163
|
+
return {};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
let rowsText;
|
|
167
|
+
const headerMatch = TABLE_HEADER_RE.exec(claudeMdText);
|
|
168
|
+
if (!headerMatch) {
|
|
169
|
+
// Try a looser parse: scan full text for table rows
|
|
170
|
+
if (!claudeMdText.includes('Trigger Keywords')) {
|
|
171
|
+
return {};
|
|
172
|
+
}
|
|
173
|
+
rowsText = claudeMdText;
|
|
174
|
+
} else {
|
|
175
|
+
rowsText = headerMatch[0];
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** @type {Record<string, string[]>} */
|
|
179
|
+
const result = {};
|
|
180
|
+
|
|
181
|
+
// Reset lastIndex since TABLE_ROW_RE has /g flag
|
|
182
|
+
TABLE_ROW_RE.lastIndex = 0;
|
|
183
|
+
let rowMatch;
|
|
184
|
+
while ((rowMatch = TABLE_ROW_RE.exec(rowsText)) !== null) {
|
|
185
|
+
const mode = rowMatch[1].trim().toUpperCase();
|
|
186
|
+
|
|
187
|
+
// Skip table header row and separators
|
|
188
|
+
if (SKIP_ROW_NAMES.has(mode)) continue;
|
|
189
|
+
|
|
190
|
+
const rawKeywords = rowMatch[2];
|
|
191
|
+
// Skip if the "keyword" cell looks like a column header
|
|
192
|
+
if (rawKeywords.toLowerCase().includes('trigger keywords')) continue;
|
|
193
|
+
|
|
194
|
+
const keywords = rawKeywords
|
|
195
|
+
.split(',')
|
|
196
|
+
.map(kw => kw.trim().toLowerCase())
|
|
197
|
+
.filter(Boolean);
|
|
198
|
+
|
|
199
|
+
if (mode && keywords.length) {
|
|
200
|
+
result[mode] = keywords;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Merge supplementary keywords
|
|
205
|
+
for (const [mode, extraKws] of Object.entries(SUPPLEMENTARY_KEYWORDS)) {
|
|
206
|
+
if (result[mode]) {
|
|
207
|
+
const existing = new Set(result[mode]);
|
|
208
|
+
for (const kw of extraKws) {
|
|
209
|
+
if (!existing.has(kw)) {
|
|
210
|
+
result[mode].push(kw);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
} else {
|
|
214
|
+
result[mode] = [...extraKws];
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return result;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Detect the CIPHER operating mode for a given message.
|
|
223
|
+
*
|
|
224
|
+
* Detection order:
|
|
225
|
+
* 1. Explicit [MODE: X] prefix → returns immediately
|
|
226
|
+
* 2. Weighted keyword scoring with supplementary keywords,
|
|
227
|
+
* defensive/incident boosting, and tie-breaking disambiguation
|
|
228
|
+
*
|
|
229
|
+
* @param {string} message - Raw user message text
|
|
230
|
+
* @param {Record<string, string[]>} keywordTable - From parseKeywordTable
|
|
231
|
+
* @returns {[string, boolean]} [mode, needsClarification]
|
|
232
|
+
*/
|
|
233
|
+
export function detectMode(message, keywordTable) {
|
|
234
|
+
// 1. Check explicit prefix.
|
|
235
|
+
const prefixMatch = message.match(EXPLICIT_RE);
|
|
236
|
+
if (prefixMatch) {
|
|
237
|
+
return [prefixMatch[1].toUpperCase(), false];
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// 2. Weighted keyword scoring.
|
|
241
|
+
const lowerMessage = message.toLowerCase();
|
|
242
|
+
/** @type {Record<string, number>} */
|
|
243
|
+
const modeScores = {};
|
|
244
|
+
|
|
245
|
+
for (const [mode, keywords] of Object.entries(keywordTable)) {
|
|
246
|
+
let score = 0;
|
|
247
|
+
for (const keyword of keywords) {
|
|
248
|
+
if (lowerMessage.includes(keyword)) {
|
|
249
|
+
// Multi-word phrases are more specific → higher weight
|
|
250
|
+
const wordCount = keyword.split(/\s+/).length;
|
|
251
|
+
const kwScore = GENERIC_KEYWORDS.has(keyword) ? 0.5 : wordCount;
|
|
252
|
+
score = Math.max(score, kwScore);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
if (score > 0) {
|
|
256
|
+
modeScores[mode] = score;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// 3. Intent-based disambiguation
|
|
261
|
+
const hasDefensive = DEFENSIVE_SIGNALS.some(sig => lowerMessage.includes(sig));
|
|
262
|
+
const hasIncident = INCIDENT_SIGNALS.some(sig => lowerMessage.includes(sig));
|
|
263
|
+
|
|
264
|
+
if (hasDefensive && modeScores.BLUE !== undefined && modeScores.RED !== undefined) {
|
|
265
|
+
// Defensive context: boost BLUE, reduce RED
|
|
266
|
+
// But NOT if PURPLE is already scoring well
|
|
267
|
+
if (modeScores.PURPLE === undefined || modeScores.PURPLE < (modeScores.BLUE || 0)) {
|
|
268
|
+
modeScores.BLUE = modeScores.BLUE + 1.5;
|
|
269
|
+
modeScores.RED = Math.max(modeScores.RED - 1.0, 0.1);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if (hasIncident && modeScores.INCIDENT !== undefined && modeScores.RED !== undefined) {
|
|
274
|
+
// Incident context: boost INCIDENT, reduce RED
|
|
275
|
+
modeScores.INCIDENT = modeScores.INCIDENT + 1.5;
|
|
276
|
+
modeScores.RED = Math.max(modeScores.RED - 1.0, 0.1);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
if (Object.keys(modeScores).length === 0) {
|
|
280
|
+
return ['ARCHITECT', false];
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Find the top score and all modes that achieved it.
|
|
284
|
+
const topScore = Math.max(...Object.values(modeScores));
|
|
285
|
+
const topModes = Object.entries(modeScores)
|
|
286
|
+
.filter(([, s]) => s >= topScore - 0.1)
|
|
287
|
+
.map(([m]) => m);
|
|
288
|
+
|
|
289
|
+
if (topModes.length === 1) {
|
|
290
|
+
return [topModes[0], false];
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Multiple modes tied — needs clarification.
|
|
294
|
+
const sortedModes = topModes.sort().join(',');
|
|
295
|
+
return [sortedModes, true];
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Remove [MODE: X] prefix from message text.
|
|
300
|
+
*
|
|
301
|
+
* Strips any leading whitespace after the prefix. Returns the original
|
|
302
|
+
* string unchanged if no prefix is present.
|
|
303
|
+
*
|
|
304
|
+
* @param {string} message
|
|
305
|
+
* @returns {string}
|
|
306
|
+
*/
|
|
307
|
+
export function stripModePrefix(message) {
|
|
308
|
+
return message.replace(EXPLICIT_RE, '').trimStart();
|
|
309
|
+
}
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
// Copyright (c) 2026 defconxt. All rights reserved.
|
|
2
|
+
// Licensed under AGPL-3.0 — see LICENSE file for details.
|
|
3
|
+
// CIPHER is a trademark of defconxt.
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* plugins.js — Plugin discovery and loading for CIPHER gateway.
|
|
7
|
+
*
|
|
8
|
+
* Ports Python gateway/plugins.py:
|
|
9
|
+
* - PluginManager: discovers plugins from ~/.config/cipher/plugins/
|
|
10
|
+
* - Each plugin dir needs plugin.yaml (parsed with yaml npm) and SKILL.md
|
|
11
|
+
* - Returns PluginInfo objects with name, version, modes, priority, skill_content
|
|
12
|
+
* - Graceful fallback when plugin directory doesn't exist
|
|
13
|
+
*
|
|
14
|
+
* @module gateway/plugins
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { readFileSync, existsSync, readdirSync, statSync } from 'node:fs';
|
|
18
|
+
import { join } from 'node:path';
|
|
19
|
+
import { homedir } from 'node:os';
|
|
20
|
+
import { parse as parseYaml } from 'yaml';
|
|
21
|
+
|
|
22
|
+
const USER_PLUGIN_DIR = join(homedir(), '.config', 'cipher', 'plugins');
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @typedef {Object} PluginInfo
|
|
26
|
+
* @property {string} name
|
|
27
|
+
* @property {string} version
|
|
28
|
+
* @property {string} author
|
|
29
|
+
* @property {string} description
|
|
30
|
+
* @property {string[]} modes - Uppercase mode names this plugin activates for
|
|
31
|
+
* @property {string[]} triggers - Lowercase trigger keywords
|
|
32
|
+
* @property {number} priority - 0-100, lower = loaded first
|
|
33
|
+
* @property {string} path - Directory containing SKILL.md
|
|
34
|
+
* @property {string} source - "user" | "entrypoint"
|
|
35
|
+
* @property {string} skill_content - Loaded SKILL.md content
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Load and validate plugin.yaml from a directory. Returns null on failure.
|
|
40
|
+
*
|
|
41
|
+
* @param {string} pluginDir
|
|
42
|
+
* @returns {Record<string, any> | null}
|
|
43
|
+
*/
|
|
44
|
+
function loadPluginYaml(pluginDir) {
|
|
45
|
+
const yamlFile = join(pluginDir, 'plugin.yaml');
|
|
46
|
+
if (!existsSync(yamlFile)) return null;
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
const raw = readFileSync(yamlFile, 'utf-8');
|
|
50
|
+
const data = parseYaml(raw);
|
|
51
|
+
if (!data || typeof data !== 'object' || Array.isArray(data)) return null;
|
|
52
|
+
if (!data.name) return null;
|
|
53
|
+
return data;
|
|
54
|
+
} catch {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Load SKILL.md from a plugin directory.
|
|
61
|
+
*
|
|
62
|
+
* @param {string} pluginDir
|
|
63
|
+
* @returns {string}
|
|
64
|
+
*/
|
|
65
|
+
function loadSkillContent(pluginDir) {
|
|
66
|
+
const skillFile = join(pluginDir, 'SKILL.md');
|
|
67
|
+
if (!existsSync(skillFile)) return '';
|
|
68
|
+
return readFileSync(skillFile, 'utf-8');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Create a PluginInfo from a plugin directory.
|
|
73
|
+
*
|
|
74
|
+
* @param {string} pluginDir
|
|
75
|
+
* @param {string} source
|
|
76
|
+
* @returns {PluginInfo | null}
|
|
77
|
+
*/
|
|
78
|
+
function pluginFromDir(pluginDir, source) {
|
|
79
|
+
let meta = loadPluginYaml(pluginDir);
|
|
80
|
+
if (!meta) {
|
|
81
|
+
// If no plugin.yaml but has SKILL.md, create minimal metadata
|
|
82
|
+
if (existsSync(join(pluginDir, 'SKILL.md'))) {
|
|
83
|
+
const dirName = pluginDir.split('/').pop() || 'unknown';
|
|
84
|
+
meta = { name: dirName };
|
|
85
|
+
} else {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const content = loadSkillContent(pluginDir);
|
|
91
|
+
if (!content) return null;
|
|
92
|
+
|
|
93
|
+
const modesRaw = meta.modes || [];
|
|
94
|
+
const modes = Array.isArray(modesRaw) ? modesRaw.map(m => String(m).toUpperCase()) : [];
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
name: meta.name || pluginDir.split('/').pop(),
|
|
98
|
+
version: meta.version || '0.0.0',
|
|
99
|
+
author: meta.author || 'unknown',
|
|
100
|
+
description: meta.description || '',
|
|
101
|
+
modes,
|
|
102
|
+
triggers: Array.isArray(meta.triggers)
|
|
103
|
+
? meta.triggers.map(t => String(t).toLowerCase())
|
|
104
|
+
: [],
|
|
105
|
+
priority: parseInt(meta.priority ?? 50, 10),
|
|
106
|
+
path: pluginDir,
|
|
107
|
+
source,
|
|
108
|
+
skill_content: content,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export class PluginManager {
|
|
113
|
+
/**
|
|
114
|
+
* @param {Object} [opts]
|
|
115
|
+
* @param {string} [opts.dataRoot] - Not used directly but kept for API compat
|
|
116
|
+
* @param {string} [opts.pluginDir] - Override plugin directory for testing
|
|
117
|
+
*/
|
|
118
|
+
constructor(opts = {}) {
|
|
119
|
+
this._pluginDir = opts.pluginDir || USER_PLUGIN_DIR;
|
|
120
|
+
/** @type {PluginInfo[]} */
|
|
121
|
+
this._plugins = [];
|
|
122
|
+
this._discovered = false;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** @returns {PluginInfo[]} */
|
|
126
|
+
get plugins() {
|
|
127
|
+
if (!this._discovered) this.discover();
|
|
128
|
+
return this._plugins;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Scan all plugin sources and return discovered plugins.
|
|
133
|
+
*
|
|
134
|
+
* @returns {PluginInfo[]}
|
|
135
|
+
*/
|
|
136
|
+
discover() {
|
|
137
|
+
this._plugins = [];
|
|
138
|
+
|
|
139
|
+
// Scan user plugin directory
|
|
140
|
+
if (existsSync(this._pluginDir)) {
|
|
141
|
+
try {
|
|
142
|
+
const entries = readdirSync(this._pluginDir).sort();
|
|
143
|
+
for (const entry of entries) {
|
|
144
|
+
const entryPath = join(this._pluginDir, entry);
|
|
145
|
+
try {
|
|
146
|
+
if (statSync(entryPath).isDirectory()) {
|
|
147
|
+
const plugin = pluginFromDir(entryPath, 'user');
|
|
148
|
+
if (plugin) this._plugins.push(plugin);
|
|
149
|
+
}
|
|
150
|
+
} catch {
|
|
151
|
+
// Skip entries we can't stat
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
} catch {
|
|
155
|
+
// Plugin directory unreadable — graceful fallback
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Sort by priority (lower first)
|
|
160
|
+
this._plugins.sort((a, b) => a.priority - b.priority);
|
|
161
|
+
this._discovered = true;
|
|
162
|
+
|
|
163
|
+
return this._plugins;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Get all plugins that activate for a given mode.
|
|
168
|
+
*
|
|
169
|
+
* @param {string} mode - CIPHER mode name
|
|
170
|
+
* @returns {PluginInfo[]}
|
|
171
|
+
*/
|
|
172
|
+
getSkillsForMode(mode) {
|
|
173
|
+
const upper = mode.toUpperCase();
|
|
174
|
+
return this.plugins.filter(p => p.modes.includes(upper));
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Get additional trigger keywords from plugins.
|
|
179
|
+
*
|
|
180
|
+
* @returns {Record<string, string[]>}
|
|
181
|
+
*/
|
|
182
|
+
getExtraTriggers() {
|
|
183
|
+
/** @type {Record<string, string[]>} */
|
|
184
|
+
const triggers = {};
|
|
185
|
+
for (const plugin of this.plugins) {
|
|
186
|
+
if (!plugin.triggers || !plugin.triggers.length) continue;
|
|
187
|
+
for (const mode of plugin.modes) {
|
|
188
|
+
if (!triggers[mode]) triggers[mode] = [];
|
|
189
|
+
triggers[mode].push(...plugin.triggers);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return triggers;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Find a plugin by name.
|
|
197
|
+
*
|
|
198
|
+
* @param {string} name
|
|
199
|
+
* @returns {PluginInfo | null}
|
|
200
|
+
*/
|
|
201
|
+
getPluginByName(name) {
|
|
202
|
+
return this.plugins.find(p => p.name === name) || null;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Return a serializable list of plugin metadata for CLI display.
|
|
207
|
+
*
|
|
208
|
+
* @returns {Array<Record<string, any>>}
|
|
209
|
+
*/
|
|
210
|
+
listPlugins() {
|
|
211
|
+
return this.plugins.map(p => ({
|
|
212
|
+
name: p.name,
|
|
213
|
+
version: p.version,
|
|
214
|
+
author: p.author,
|
|
215
|
+
description: p.description,
|
|
216
|
+
modes: p.modes,
|
|
217
|
+
source: p.source,
|
|
218
|
+
path: p.path,
|
|
219
|
+
priority: p.priority,
|
|
220
|
+
}));
|
|
221
|
+
}
|
|
222
|
+
}
|