clawmoat 0.2.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/CONTRIBUTING.md +56 -0
- package/LICENSE +21 -0
- package/README.md +199 -0
- package/bin/clawmoat.js +407 -0
- package/docs/CNAME +1 -0
- package/docs/MIT-RISK-GAP-ANALYSIS.md +146 -0
- package/docs/badge/score-A.svg +21 -0
- package/docs/badge/score-Aplus.svg +21 -0
- package/docs/badge/score-B.svg +21 -0
- package/docs/badge/score-C.svg +21 -0
- package/docs/badge/score-D.svg +21 -0
- package/docs/badge/score-F.svg +21 -0
- package/docs/blog/index.html +90 -0
- package/docs/blog/owasp-agentic-ai-top10.html +187 -0
- package/docs/blog/owasp-agentic-ai-top10.md +185 -0
- package/docs/blog/securing-ai-agents.html +194 -0
- package/docs/blog/securing-ai-agents.md +152 -0
- package/docs/compare.html +312 -0
- package/docs/index.html +654 -0
- package/docs/integrations/langchain.html +281 -0
- package/docs/integrations/openai.html +302 -0
- package/docs/integrations/openclaw.html +310 -0
- package/docs/robots.txt +3 -0
- package/docs/sitemap.xml +28 -0
- package/docs/thanks.html +79 -0
- package/package.json +35 -0
- package/server/Dockerfile +7 -0
- package/server/index.js +85 -0
- package/server/package.json +12 -0
- package/skill/SKILL.md +56 -0
- package/src/badge.js +87 -0
- package/src/index.js +316 -0
- package/src/middleware/openclaw.js +133 -0
- package/src/policies/engine.js +180 -0
- package/src/scanners/exfiltration.js +97 -0
- package/src/scanners/jailbreak.js +81 -0
- package/src/scanners/memory-poison.js +68 -0
- package/src/scanners/pii.js +128 -0
- package/src/scanners/prompt-injection.js +138 -0
- package/src/scanners/secrets.js +97 -0
- package/src/scanners/supply-chain.js +155 -0
- package/src/scanners/urls.js +142 -0
- package/src/utils/config.js +137 -0
- package/src/utils/logger.js +109 -0
package/src/badge.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ClawMoat Security Score Badge Generator
|
|
3
|
+
*
|
|
4
|
+
* Generates shields.io-style SVG badges based on audit/scan results.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const GRADES = {
|
|
8
|
+
'A+': { color: '#10B981', label: 'excellent' },
|
|
9
|
+
'A': { color: '#10B981', label: 'great' },
|
|
10
|
+
'B': { color: '#84CC16', label: 'good' },
|
|
11
|
+
'C': { color: '#F59E0B', label: 'fair' },
|
|
12
|
+
'D': { color: '#EF4444', label: 'poor' },
|
|
13
|
+
'F': { color: '#DC2626', label: 'failing' },
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Calculate security grade from scan/audit findings.
|
|
18
|
+
* @param {object} opts
|
|
19
|
+
* @param {number} opts.totalFindings - Total number of findings
|
|
20
|
+
* @param {number} opts.criticalFindings - Number of critical/high findings
|
|
21
|
+
* @param {number} opts.filesScanned - Number of files scanned
|
|
22
|
+
* @returns {string} Grade (A+ through F)
|
|
23
|
+
*/
|
|
24
|
+
function calculateGrade({ totalFindings = 0, criticalFindings = 0, filesScanned = 1 }) {
|
|
25
|
+
if (totalFindings === 0) return 'A+';
|
|
26
|
+
const ratio = totalFindings / Math.max(filesScanned, 1);
|
|
27
|
+
if (criticalFindings > 0) {
|
|
28
|
+
return criticalFindings >= 3 ? 'F' : 'D';
|
|
29
|
+
}
|
|
30
|
+
if (ratio <= 0.05) return 'A';
|
|
31
|
+
if (ratio <= 0.15) return 'B';
|
|
32
|
+
if (ratio <= 0.3) return 'C';
|
|
33
|
+
if (ratio <= 0.5) return 'D';
|
|
34
|
+
return 'F';
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Generate an SVG badge string.
|
|
39
|
+
* @param {string} grade - The security grade (A+, A, B, C, D, F)
|
|
40
|
+
* @returns {string} SVG markup
|
|
41
|
+
*/
|
|
42
|
+
function generateBadgeSVG(grade) {
|
|
43
|
+
const g = GRADES[grade] || GRADES['F'];
|
|
44
|
+
const gradeText = grade.replace('+', '+');
|
|
45
|
+
const labelWidth = 138;
|
|
46
|
+
const gradeWidth = 40;
|
|
47
|
+
const totalWidth = labelWidth + gradeWidth;
|
|
48
|
+
|
|
49
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" width="${totalWidth}" height="20" role="img" aria-label="ClawMoat Security Score: ${grade}">
|
|
50
|
+
<title>ClawMoat Security Score: ${grade}</title>
|
|
51
|
+
<linearGradient id="s" x2="0" y2="100%">
|
|
52
|
+
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
|
53
|
+
<stop offset="1" stop-opacity=".1"/>
|
|
54
|
+
</linearGradient>
|
|
55
|
+
<clipPath id="r">
|
|
56
|
+
<rect width="${totalWidth}" height="20" rx="3" fill="#fff"/>
|
|
57
|
+
</clipPath>
|
|
58
|
+
<g clip-path="url(#r)">
|
|
59
|
+
<rect width="${labelWidth}" height="20" fill="#0F172A"/>
|
|
60
|
+
<rect x="${labelWidth}" width="${gradeWidth}" height="20" fill="${g.color}"/>
|
|
61
|
+
<rect width="${totalWidth}" height="20" fill="url(#s)"/>
|
|
62
|
+
</g>
|
|
63
|
+
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
|
|
64
|
+
<text aria-hidden="true" x="${labelWidth / 2}" y="15" fill="#010101" fill-opacity=".3">🏰 ClawMoat Score</text>
|
|
65
|
+
<text x="${labelWidth / 2}" y="14">🏰 ClawMoat Score</text>
|
|
66
|
+
<text aria-hidden="true" x="${labelWidth + gradeWidth / 2}" y="15" fill="#010101" fill-opacity=".3">${gradeText}</text>
|
|
67
|
+
<text x="${labelWidth + gradeWidth / 2}" y="14" font-weight="bold">${gradeText}</text>
|
|
68
|
+
</g>
|
|
69
|
+
</svg>`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Get a shields.io URL for the given grade.
|
|
74
|
+
* @param {string} grade
|
|
75
|
+
* @returns {string} shields.io badge URL
|
|
76
|
+
*/
|
|
77
|
+
function getShieldsURL(grade) {
|
|
78
|
+
const colorMap = {
|
|
79
|
+
'A+': 'brightgreen', 'A': 'green', 'B': 'yellowgreen',
|
|
80
|
+
'C': 'yellow', 'D': 'orange', 'F': 'red',
|
|
81
|
+
};
|
|
82
|
+
const encoded = encodeURIComponent(grade);
|
|
83
|
+
const color = colorMap[grade] || 'red';
|
|
84
|
+
return `https://img.shields.io/badge/ClawMoat-${encoded}-${color}`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
module.exports = { calculateGrade, generateBadgeSVG, getShieldsURL, GRADES };
|
package/src/index.js
ADDED
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ClawMoat — Security moat for AI agents
|
|
3
|
+
*
|
|
4
|
+
* Runtime protection against prompt injection, jailbreaks, tool misuse,
|
|
5
|
+
* secret/PII leakage, data exfiltration, memory poisoning, and supply chain attacks.
|
|
6
|
+
*
|
|
7
|
+
* @module clawmoat
|
|
8
|
+
* @example
|
|
9
|
+
* const ClawMoat = require('clawmoat');
|
|
10
|
+
* const moat = new ClawMoat();
|
|
11
|
+
*
|
|
12
|
+
* // Scan inbound message for injection/jailbreak threats
|
|
13
|
+
* const result = moat.scanInbound(userMessage);
|
|
14
|
+
* if (!result.safe) console.log('Threat detected:', result.findings);
|
|
15
|
+
*
|
|
16
|
+
* // Scan outbound text for secret/PII leaks
|
|
17
|
+
* const out = moat.scanOutbound(responseText);
|
|
18
|
+
*
|
|
19
|
+
* // Evaluate a tool call against security policies
|
|
20
|
+
* const policy = moat.evaluateTool('exec', { command: 'rm -rf /' });
|
|
21
|
+
* if (policy.decision === 'deny') console.log('Blocked:', policy.reason);
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const { scanPromptInjection } = require('./scanners/prompt-injection');
|
|
25
|
+
const { scanJailbreak } = require('./scanners/jailbreak');
|
|
26
|
+
const { scanSecrets } = require('./scanners/secrets');
|
|
27
|
+
const { scanPII } = require('./scanners/pii');
|
|
28
|
+
const { scanUrls } = require('./scanners/urls');
|
|
29
|
+
const { scanMemoryPoison } = require('./scanners/memory-poison');
|
|
30
|
+
const { scanExfiltration } = require('./scanners/exfiltration');
|
|
31
|
+
const { scanSkill, scanSkillContent } = require('./scanners/supply-chain');
|
|
32
|
+
const { evaluateToolCall } = require('./policies/engine');
|
|
33
|
+
const { SecurityLogger } = require('./utils/logger');
|
|
34
|
+
const { loadConfig } = require('./utils/config');
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* @typedef {Object} ScanFinding
|
|
38
|
+
* @property {string} type - Finding category (e.g. 'prompt_injection', 'secret_detected', 'pii_detected')
|
|
39
|
+
* @property {string} subtype - Specific detection pattern name
|
|
40
|
+
* @property {string} severity - 'low' | 'medium' | 'high' | 'critical'
|
|
41
|
+
* @property {string} [matched] - The matched text (may be redacted for secrets)
|
|
42
|
+
* @property {number} [position] - Character position in the scanned text
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* @typedef {Object} ScanResult
|
|
47
|
+
* @property {boolean} safe - true if no threats found
|
|
48
|
+
* @property {ScanFinding[]} findings - Array of detected issues
|
|
49
|
+
* @property {string|null} severity - Maximum severity across findings
|
|
50
|
+
* @property {string} action - 'allow' | 'log' | 'warn' | 'block'
|
|
51
|
+
*/
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* @typedef {Object} ToolDecision
|
|
55
|
+
* @property {string} decision - 'allow' | 'deny' | 'warn' | 'review'
|
|
56
|
+
* @property {string} tool - Tool name evaluated
|
|
57
|
+
* @property {string} [reason] - Human-readable explanation
|
|
58
|
+
* @property {string} [severity] - Severity of the policy violation
|
|
59
|
+
*/
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Main ClawMoat security scanner class.
|
|
63
|
+
* Instantiate with optional config to scan text and evaluate tool calls.
|
|
64
|
+
*/
|
|
65
|
+
class ClawMoat {
|
|
66
|
+
/**
|
|
67
|
+
* Create a ClawMoat instance.
|
|
68
|
+
* @param {Object} [opts] - Options
|
|
69
|
+
* @param {Object} [opts.config] - Configuration object (overrides file-based config)
|
|
70
|
+
* @param {string} [opts.configPath] - Path to clawmoat.yml config file
|
|
71
|
+
* @param {string} [opts.logFile] - Path to write security event logs
|
|
72
|
+
* @param {boolean} [opts.quiet] - Suppress console output
|
|
73
|
+
* @param {Function} [opts.onEvent] - Callback for each security event
|
|
74
|
+
*/
|
|
75
|
+
constructor(opts = {}) {
|
|
76
|
+
this.config = opts.config || loadConfig(opts.configPath);
|
|
77
|
+
this.logger = new SecurityLogger({
|
|
78
|
+
logFile: opts.logFile,
|
|
79
|
+
quiet: opts.quiet,
|
|
80
|
+
minSeverity: this.config.alerts?.severity_threshold,
|
|
81
|
+
webhook: this.config.alerts?.webhook,
|
|
82
|
+
onEvent: opts.onEvent,
|
|
83
|
+
});
|
|
84
|
+
this.stats = { scanned: 0, blocked: 0, warnings: 0 };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Scan inbound text for prompt injection, jailbreaks, suspicious URLs, and memory poisoning.
|
|
89
|
+
* @param {string} text - Text to scan (message, email, web content, tool output)
|
|
90
|
+
* @param {Object} [opts] - Options
|
|
91
|
+
* @param {string} [opts.context] - Source context ('message' | 'email' | 'web' | 'tool_output')
|
|
92
|
+
* @returns {ScanResult} Scan result with findings and recommended action
|
|
93
|
+
*/
|
|
94
|
+
scanInbound(text, opts = {}) {
|
|
95
|
+
this.stats.scanned++;
|
|
96
|
+
const results = { findings: [], safe: true, severity: null, action: 'allow' };
|
|
97
|
+
|
|
98
|
+
// Prompt injection scan
|
|
99
|
+
if (this.config.detection?.prompt_injection !== false) {
|
|
100
|
+
const pi = scanPromptInjection(text, opts);
|
|
101
|
+
if (!pi.clean) {
|
|
102
|
+
results.findings.push(...pi.findings);
|
|
103
|
+
results.safe = false;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Jailbreak scan
|
|
108
|
+
if (this.config.detection?.jailbreak !== false) {
|
|
109
|
+
const jb = scanJailbreak(text);
|
|
110
|
+
if (!jb.clean) {
|
|
111
|
+
results.findings.push(...jb.findings);
|
|
112
|
+
results.safe = false;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// URL scan
|
|
117
|
+
if (this.config.detection?.url_scanning !== false) {
|
|
118
|
+
const urls = scanUrls(text, opts);
|
|
119
|
+
if (!urls.clean) {
|
|
120
|
+
results.findings.push(...urls.findings);
|
|
121
|
+
results.safe = false;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Memory poisoning scan
|
|
126
|
+
if (this.config.detection?.memory_poison !== false) {
|
|
127
|
+
const mp = scanMemoryPoison(text, opts);
|
|
128
|
+
if (!mp.clean) {
|
|
129
|
+
results.findings.push(...mp.findings);
|
|
130
|
+
results.safe = false;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Determine action
|
|
135
|
+
if (!results.safe) {
|
|
136
|
+
const maxSev = this._maxSeverity(results.findings);
|
|
137
|
+
results.severity = maxSev;
|
|
138
|
+
results.action = maxSev === 'critical' ? 'block' : maxSev === 'high' ? 'warn' : 'log';
|
|
139
|
+
|
|
140
|
+
if (results.action === 'block') this.stats.blocked++;
|
|
141
|
+
if (results.action === 'warn') this.stats.warnings++;
|
|
142
|
+
|
|
143
|
+
this.logger.log({
|
|
144
|
+
type: 'inbound_threat',
|
|
145
|
+
severity: maxSev,
|
|
146
|
+
message: `${results.findings.length} threat(s) detected in ${opts.context || 'message'}`,
|
|
147
|
+
details: {
|
|
148
|
+
findings: results.findings.map(f => ({ type: f.type, subtype: f.subtype, severity: f.severity })),
|
|
149
|
+
source: opts.context,
|
|
150
|
+
textPreview: text.substring(0, 100),
|
|
151
|
+
},
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return results;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Scan outbound text for secrets, PII, and data exfiltration attempts.
|
|
160
|
+
* @param {string} text - Outbound text to scan
|
|
161
|
+
* @param {Object} [opts] - Options
|
|
162
|
+
* @param {string} [opts.context] - Source context for logging
|
|
163
|
+
* @returns {ScanResult} Scan result with findings and recommended action
|
|
164
|
+
*/
|
|
165
|
+
scanOutbound(text, opts = {}) {
|
|
166
|
+
this.stats.scanned++;
|
|
167
|
+
const results = { findings: [], safe: true, severity: null, action: 'allow' };
|
|
168
|
+
|
|
169
|
+
// Secret scanning
|
|
170
|
+
if (this.config.detection?.secret_scanning !== false) {
|
|
171
|
+
const secrets = scanSecrets(text, { direction: 'outbound', ...opts });
|
|
172
|
+
if (!secrets.clean) {
|
|
173
|
+
results.findings.push(...secrets.findings);
|
|
174
|
+
results.safe = false;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// PII scanning
|
|
179
|
+
if (this.config.detection?.pii !== false) {
|
|
180
|
+
const pii = scanPII(text, opts);
|
|
181
|
+
if (!pii.clean) {
|
|
182
|
+
results.findings.push(...pii.findings);
|
|
183
|
+
results.safe = false;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Exfiltration scanning
|
|
188
|
+
if (this.config.detection?.exfiltration !== false) {
|
|
189
|
+
const exfil = scanExfiltration(text, opts);
|
|
190
|
+
if (!exfil.clean) {
|
|
191
|
+
results.findings.push(...exfil.findings);
|
|
192
|
+
results.safe = false;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (!results.safe) {
|
|
197
|
+
const maxSev = this._maxSeverity(results.findings);
|
|
198
|
+
results.severity = maxSev;
|
|
199
|
+
results.action = maxSev === 'critical' ? 'block' : 'warn';
|
|
200
|
+
|
|
201
|
+
this.stats.blocked++;
|
|
202
|
+
this.logger.log({
|
|
203
|
+
type: 'outbound_leak',
|
|
204
|
+
severity: maxSev,
|
|
205
|
+
message: `Secret/credential detected in outbound ${opts.context || 'message'}`,
|
|
206
|
+
details: {
|
|
207
|
+
findings: results.findings.map(f => ({ type: f.type, subtype: f.subtype, severity: f.severity, matched: f.matched })),
|
|
208
|
+
},
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return results;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Evaluate a tool call against security policies.
|
|
217
|
+
* @param {string} tool - Tool name (e.g. 'exec', 'read', 'write', 'browser')
|
|
218
|
+
* @param {Object} args - Tool arguments to evaluate
|
|
219
|
+
* @returns {ToolDecision} Policy decision with explanation
|
|
220
|
+
*/
|
|
221
|
+
evaluateTool(tool, args) {
|
|
222
|
+
const result = evaluateToolCall(tool, args, this.config.policies || {});
|
|
223
|
+
|
|
224
|
+
if (result.decision !== 'allow') {
|
|
225
|
+
const severity = result.severity || 'medium';
|
|
226
|
+
if (result.decision === 'deny') this.stats.blocked++;
|
|
227
|
+
if (result.decision === 'warn') this.stats.warnings++;
|
|
228
|
+
|
|
229
|
+
this.logger.log({
|
|
230
|
+
type: 'tool_policy',
|
|
231
|
+
severity,
|
|
232
|
+
message: `Tool ${tool}: ${result.decision} — ${result.reason}`,
|
|
233
|
+
details: { tool, decision: result.decision, ...result },
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return result;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Full bidirectional scan: check text as both inbound threat AND outbound leak.
|
|
242
|
+
* @param {string} text - Text to scan
|
|
243
|
+
* @param {Object} [opts] - Options passed to both scanInbound and scanOutbound
|
|
244
|
+
* @returns {{ safe: boolean, inbound: ScanResult, outbound: ScanResult, findings: ScanFinding[] }}
|
|
245
|
+
*/
|
|
246
|
+
scan(text, opts = {}) {
|
|
247
|
+
const inbound = this.scanInbound(text, opts);
|
|
248
|
+
const outbound = this.scanOutbound(text, opts);
|
|
249
|
+
|
|
250
|
+
return {
|
|
251
|
+
safe: inbound.safe && outbound.safe,
|
|
252
|
+
inbound,
|
|
253
|
+
outbound,
|
|
254
|
+
findings: [...inbound.findings, ...outbound.findings],
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Get security event log
|
|
260
|
+
*/
|
|
261
|
+
getEvents(filter) {
|
|
262
|
+
return this.logger.getEvents(filter);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Get summary stats
|
|
267
|
+
*/
|
|
268
|
+
getSummary() {
|
|
269
|
+
return {
|
|
270
|
+
...this.stats,
|
|
271
|
+
events: this.logger.summary(),
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Scan a skill directory or file for supply chain threats (malicious code patterns).
|
|
277
|
+
* @param {string} skillPath - Path to skill directory or file
|
|
278
|
+
* @returns {{ clean: boolean, findings: ScanFinding[], severity: string|null }}
|
|
279
|
+
*/
|
|
280
|
+
scanSkill(skillPath) {
|
|
281
|
+
const result = scanSkill(skillPath);
|
|
282
|
+
|
|
283
|
+
if (!result.clean) {
|
|
284
|
+
const maxSev = this._maxSeverity(result.findings);
|
|
285
|
+
this.logger.log({
|
|
286
|
+
type: 'supply_chain_threat',
|
|
287
|
+
severity: maxSev,
|
|
288
|
+
message: `${result.findings.length} issue(s) in skill: ${skillPath}`,
|
|
289
|
+
details: { findings: result.findings },
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
return result;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
_maxSeverity(findings) {
|
|
297
|
+
const rank = { low: 0, medium: 1, high: 2, critical: 3 };
|
|
298
|
+
return findings.reduce(
|
|
299
|
+
(max, f) => (rank[f.severity] || 0) > (rank[max] || 0) ? f.severity : max,
|
|
300
|
+
'low'
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
module.exports = ClawMoat;
|
|
306
|
+
module.exports.ClawMoat = ClawMoat;
|
|
307
|
+
module.exports.scanPromptInjection = scanPromptInjection;
|
|
308
|
+
module.exports.scanJailbreak = scanJailbreak;
|
|
309
|
+
module.exports.scanSecrets = scanSecrets;
|
|
310
|
+
module.exports.scanPII = scanPII;
|
|
311
|
+
module.exports.scanUrls = scanUrls;
|
|
312
|
+
module.exports.scanMemoryPoison = scanMemoryPoison;
|
|
313
|
+
module.exports.scanExfiltration = scanExfiltration;
|
|
314
|
+
module.exports.scanSkill = scanSkill;
|
|
315
|
+
module.exports.scanSkillContent = scanSkillContent;
|
|
316
|
+
module.exports.evaluateToolCall = evaluateToolCall;
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ClawMoat — OpenClaw Integration Middleware
|
|
3
|
+
*
|
|
4
|
+
* Hooks into OpenClaw's session transcript files to provide
|
|
5
|
+
* real-time monitoring and alerting.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* const { watchSessions } = require('clawmoat/src/middleware/openclaw');
|
|
9
|
+
* watchSessions({ agentDir: '~/.openclaw/agents/main' });
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const fs = require('fs');
|
|
13
|
+
const path = require('path');
|
|
14
|
+
const ClawMoat = require('../index');
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Watch OpenClaw session files for security events
|
|
18
|
+
*/
|
|
19
|
+
function watchSessions(opts = {}) {
|
|
20
|
+
const agentDir = expandHome(opts.agentDir || '~/.openclaw/agents/main');
|
|
21
|
+
const sessionsDir = path.join(agentDir, 'sessions');
|
|
22
|
+
const moat = new ClawMoat(opts);
|
|
23
|
+
|
|
24
|
+
if (!fs.existsSync(sessionsDir)) {
|
|
25
|
+
console.error(`[ClawMoat] Sessions directory not found: ${sessionsDir}`);
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
console.log(`[ClawMoat] 🏰 Watching sessions in ${sessionsDir}`);
|
|
30
|
+
|
|
31
|
+
// Track file sizes to only read new content
|
|
32
|
+
const filePositions = {};
|
|
33
|
+
|
|
34
|
+
const watcher = fs.watch(sessionsDir, (eventType, filename) => {
|
|
35
|
+
if (!filename || !filename.endsWith('.jsonl')) return;
|
|
36
|
+
|
|
37
|
+
const filePath = path.join(sessionsDir, filename);
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
const stat = fs.statSync(filePath);
|
|
41
|
+
const lastPos = filePositions[filename] || 0;
|
|
42
|
+
|
|
43
|
+
if (stat.size <= lastPos) return;
|
|
44
|
+
|
|
45
|
+
// Read only new content
|
|
46
|
+
const fd = fs.openSync(filePath, 'r');
|
|
47
|
+
const buffer = Buffer.alloc(stat.size - lastPos);
|
|
48
|
+
fs.readSync(fd, buffer, 0, buffer.length, lastPos);
|
|
49
|
+
fs.closeSync(fd);
|
|
50
|
+
|
|
51
|
+
filePositions[filename] = stat.size;
|
|
52
|
+
|
|
53
|
+
const newContent = buffer.toString('utf8');
|
|
54
|
+
const lines = newContent.split('\n').filter(Boolean);
|
|
55
|
+
|
|
56
|
+
for (const line of lines) {
|
|
57
|
+
try {
|
|
58
|
+
const entry = JSON.parse(line);
|
|
59
|
+
processEntry(moat, entry, filename);
|
|
60
|
+
} catch {}
|
|
61
|
+
}
|
|
62
|
+
} catch {}
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
moat,
|
|
67
|
+
watcher,
|
|
68
|
+
stop: () => watcher.close(),
|
|
69
|
+
getEvents: (filter) => moat.getEvents(filter),
|
|
70
|
+
getSummary: () => moat.getSummary(),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function processEntry(moat, entry, sessionFile) {
|
|
75
|
+
// Scan user messages (inbound)
|
|
76
|
+
if (entry.role === 'user') {
|
|
77
|
+
const text = extractText(entry);
|
|
78
|
+
if (text) {
|
|
79
|
+
const result = moat.scanInbound(text, { context: 'message', session: sessionFile });
|
|
80
|
+
if (!result.safe && result.action === 'block') {
|
|
81
|
+
console.error(`[ClawMoat] 🚨 BLOCKED threat in ${sessionFile}: ${result.findings[0]?.subtype}`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Audit tool calls (from assistant)
|
|
87
|
+
if (entry.role === 'assistant' && Array.isArray(entry.content)) {
|
|
88
|
+
for (const part of entry.content) {
|
|
89
|
+
if (part.type === 'toolCall') {
|
|
90
|
+
const result = moat.evaluateTool(part.name, part.arguments || {});
|
|
91
|
+
if (result.decision === 'deny') {
|
|
92
|
+
console.error(`[ClawMoat] 🚨 BLOCKED tool call: ${part.name} — ${result.reason}`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Scan tool results for injected content
|
|
99
|
+
if (entry.role === 'tool') {
|
|
100
|
+
const text = extractText(entry);
|
|
101
|
+
if (text) {
|
|
102
|
+
moat.scanInbound(text, { context: 'tool_output', session: sessionFile });
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Check outbound messages for secrets
|
|
107
|
+
if (entry.role === 'assistant') {
|
|
108
|
+
const text = extractText(entry);
|
|
109
|
+
if (text) {
|
|
110
|
+
const result = moat.scanOutbound(text, { context: 'assistant_reply', session: sessionFile });
|
|
111
|
+
if (!result.safe) {
|
|
112
|
+
console.error(`[ClawMoat] 🚨 SECRET in outbound message: ${result.findings[0]?.subtype}`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function extractText(entry) {
|
|
119
|
+
if (typeof entry.content === 'string') return entry.content;
|
|
120
|
+
if (Array.isArray(entry.content)) {
|
|
121
|
+
return entry.content
|
|
122
|
+
.filter(c => c.type === 'text')
|
|
123
|
+
.map(c => c.text)
|
|
124
|
+
.join('\n');
|
|
125
|
+
}
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function expandHome(p) {
|
|
130
|
+
return p.replace(/^~/, process.env.HOME || '/home/user');
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
module.exports = { watchSessions };
|