clawmoat 0.8.0 → 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/.dockerignore +9 -0
- package/CHANGELOG.md +18 -0
- package/DEMO.md +87 -0
- package/Dockerfile +5 -18
- package/README.md +232 -8
- package/THREAT_MODEL.md +129 -0
- package/agent/README.md +131 -0
- package/agent/index.js +471 -0
- package/agent/install-service.sh +94 -0
- package/agent/openclaw-hook.js +453 -0
- package/agent/provider-setup.js +649 -0
- package/agent/setup.js +274 -0
- package/assets/BADGE-USAGE.md +20 -0
- package/assets/clawmoat-badge.svg +21 -0
- package/bin/clawmoat.js +468 -111
- package/docs/affiliates/dashboard.html +124 -0
- package/docs/affiliates/index.html +236 -0
- package/docs/agent-install.html +183 -0
- package/docs/ai-agent-security-scanner.html +10 -6
- package/docs/badge/index.html +149 -0
- package/docs/badge/scanning.svg +23 -0
- package/docs/blog/386-malicious-skills.html +11 -4
- package/docs/blog/40000-exposed-openclaw-instances.html +11 -4
- package/docs/blog/agent-trust-protocol.html +5 -4
- package/docs/blog/ai-agent-earns-commissions.html +230 -0
- package/docs/blog/bugmageddon-agent-firewall.html +174 -0
- package/docs/blog/calculator-math.html +180 -0
- package/docs/blog/clawmoat-vs-llamafirewall-nemo-guardrails.html +10 -4
- package/docs/blog/host-guardian-launch.html +18 -8
- package/docs/blog/ibm-experts-agent-runtime-protection.html +15 -6
- package/docs/blog/index.html +67 -9
- package/docs/blog/langchain-security-tutorial.html +18 -8
- package/docs/blog/mcp-30-cves-security-crisis.html +11 -4
- package/docs/blog/meta-researcher-rogue-agent.html +201 -0
- package/docs/blog/microsoft-openclaw-workstation-security.html +5 -4
- package/docs/blog/nist-ai-agent-standards-clawmoat.html +16 -8
- package/docs/blog/oasis-websocket-hijack.html +11 -4
- package/docs/blog/ollama-openclaw-security.html +10 -4
- package/docs/blog/openclaw-enterprise-readiness-claw10.html +5 -4
- package/docs/blog/openclaw-security-reckoning-2026.html +11 -4
- package/docs/blog/owasp-agentic-ai-top10.html +18 -8
- package/docs/blog/securing-ai-agents.html +18 -8
- package/docs/blog/supply-chain-agents.html +18 -8
- package/docs/business/index.html +11 -16
- package/docs/business/install.html +21 -7
- package/docs/checklist.html +10 -4
- package/docs/compare/index.html +122 -0
- package/docs/compare/lakera/index.html +62 -0
- package/docs/compare/llm-guard/index.html +49 -0
- package/docs/compare/snyk-agent-scan/index.html +63 -0
- package/docs/compare.html +10 -6
- package/docs/dashboard/index.html +520 -0
- package/docs/finance/index.html +9 -6
- package/docs/guides/business-deployment.html +770 -0
- package/docs/hall-of-fame.html +11 -5
- package/docs/index.html +266 -137
- package/docs/integrations/langchain.html +14 -6
- package/docs/integrations/openai.html +14 -6
- package/docs/integrations/openclaw.html +55 -7
- package/docs/plans/2026-03-26-threat-intel-api.md +255 -0
- package/docs/plans/2026-04-14-bugmageddon-marketing-pack.md +329 -0
- package/docs/plans/2026-04-14-clawmoat-v1-bugmageddon.md +248 -0
- package/docs/plans/2026-04-14-v1-release-update.md +91 -0
- package/docs/plans/2026-04-19-supabase-audit.md +68 -0
- package/docs/plans/2026-05-12-sales-push.md +303 -0
- package/docs/playground/index.html +893 -0
- package/docs/playground.html +4 -7
- package/docs/rfcs/defense-in-depth.md +467 -0
- package/docs/scan/index.html +156 -12
- package/docs/services/case-study.html +255 -0
- package/docs/services/downloads/install-openclaw.bat +45 -0
- package/docs/services/downloads/install-openclaw.command +38 -0
- package/docs/services/downloads/install-openclaw.sh +38 -0
- package/docs/services/get-started.html +165 -0
- package/docs/services/index.html +598 -0
- package/docs/services/multi-agent-security.html +284 -0
- package/docs/services/one-pager.html +99 -0
- package/docs/services/pitch-deck.html +229 -0
- package/docs/services/roi-calculator.html +258 -0
- package/docs/sitemap.xml +62 -2
- package/docs/support/index.html +12 -1
- package/docs/templates/customer-service/HEARTBEAT.md +61 -0
- package/docs/templates/customer-service/MEMORY.md +89 -0
- package/docs/templates/customer-service/SOUL.md +41 -0
- package/docs/templates/customer-service/USER.md +56 -0
- package/docs/templates/executive/HEARTBEAT.md +86 -0
- package/docs/templates/executive/MEMORY.md +92 -0
- package/docs/templates/executive/SOUL.md +44 -0
- package/docs/templates/executive/USER.md +62 -0
- package/docs/templates/finance/HEARTBEAT.md +58 -0
- package/docs/templates/finance/MEMORY.md +87 -0
- package/docs/templates/finance/SOUL.md +38 -0
- package/docs/templates/finance/USER.md +53 -0
- package/docs/templates/index.html +115 -0
- package/docs/templates/operations/HEARTBEAT.md +63 -0
- package/docs/templates/operations/MEMORY.md +68 -0
- package/docs/templates/operations/SOUL.md +38 -0
- package/docs/templates/operations/USER.md +49 -0
- package/docs/templates/sales/HEARTBEAT.md +55 -0
- package/docs/templates/sales/MEMORY.md +89 -0
- package/docs/templates/sales/SOUL.md +34 -0
- package/docs/templates/sales/USER.md +54 -0
- package/eslint.config.js +32 -0
- package/evals/README.md +29 -0
- package/evals/cases.json +390 -0
- package/evals/results.md +68 -0
- package/evals/run.js +180 -0
- package/examples/demo-attack/demo.js +186 -0
- package/examples/python-quickstart/README.md +54 -0
- package/examples/python-quickstart/clawmoat_client.py +167 -0
- package/examples/video-demo/README.md +14 -0
- package/examples/video-demo/scene-a-normal.js +29 -0
- package/examples/video-demo/scene-b-attack-arrives.js +31 -0
- package/examples/video-demo/scene-c-hijack.js +44 -0
- package/examples/video-demo/scene-d-clawmoat.js +46 -0
- package/integrations/crewai/README.md +32 -0
- package/integrations/crewai/clawmoat_crewai/__init__.py +17 -0
- package/integrations/crewai/clawmoat_crewai/guard.py +103 -0
- package/integrations/crewai/pyproject.toml +21 -0
- package/integrations/langchain/README.md +91 -0
- package/integrations/langchain/clawmoat_langchain/__init__.py +17 -0
- package/integrations/langchain/clawmoat_langchain/callback.py +489 -0
- package/integrations/langchain/pyproject.toml +32 -0
- package/integrations/litellm/README.md +324 -0
- package/integrations/litellm/clawmoat_litellm/__init__.py +21 -0
- package/integrations/litellm/clawmoat_litellm/callback.py +329 -0
- package/integrations/litellm/clawmoat_litellm/proxy_middleware.py +224 -0
- package/integrations/litellm/pyproject.toml +74 -0
- package/integrations/openai-agents/README.md +392 -0
- package/integrations/openai-agents/clawmoat_openai_agents/__init__.py +20 -0
- package/integrations/openai-agents/clawmoat_openai_agents/guardrail.py +431 -0
- package/integrations/openai-agents/clawmoat_openai_agents/middleware.py +311 -0
- package/integrations/openai-agents/pyproject.toml +76 -0
- package/package.json +6 -5
- package/plugins/openclaw-adapter/PHASE1.md +439 -0
- package/plugins/openclaw-adapter/README.md +103 -0
- package/plugins/openclaw-adapter/SPEC.md +1644 -0
- package/plugins/openclaw-adapter/package.json +31 -0
- package/plugins/openclaw-adapter/src/index.test.ts +226 -0
- package/plugins/openclaw-adapter/src/index.ts +140 -0
- package/plugins/openclaw-adapter/tsconfig.json +14 -0
- package/server/data/threats.json +290 -0
- package/server/index.js +142 -7
- package/src/adapters/express.js +161 -0
- package/src/adapters/index.js +92 -0
- package/src/adapters/langchain.js +185 -0
- package/src/approval/index.js +456 -0
- package/src/ban-scanner.js +200 -0
- package/src/boundary-scanner.js +296 -0
- package/src/ci-scanner.js +279 -0
- package/src/code-scanner.js +245 -0
- package/src/enforce.js +166 -0
- package/src/formatters/json.js +80 -0
- package/src/formatters/sarif.js +388 -0
- package/src/guardian/alerts.js +34 -3
- package/src/guardian/index.js +41 -2
- package/src/index.js +102 -0
- package/src/integrations/agentmesh.js +501 -0
- package/src/language-detector.js +201 -0
- package/src/mcp-scanner.js +253 -0
- package/src/multimodal/index.js +579 -0
- package/src/obfuscation-scanner.js +457 -0
- package/src/policy-engine.js +402 -0
- package/src/scanners/dependency-attacks.js +128 -0
- package/src/scanners/prompt-injection.js +18 -0
- package/src/scanners/supply-chain.js +14 -0
- package/src/templates/default-config.yml +90 -0
- package/src/vuln-ops/exploitability.js +46 -0
- package/src/watch/live-monitor.js +720 -0
- package/clawmoat-0.8.0.tgz +0 -0
- package/server/index.js.patch +0 -1
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Interactive Approval Workflow with Pluggable Notification Channels
|
|
3
|
+
* Security primitive for agent governance — agents must ask before dangerous actions
|
|
4
|
+
*
|
|
5
|
+
* @module approval
|
|
6
|
+
* @example
|
|
7
|
+
* const { ApprovalWorkflow, ConsoleChannel } = require('./approval');
|
|
8
|
+
*
|
|
9
|
+
* const workflow = new ApprovalWorkflow({
|
|
10
|
+
* defaultTimeout: 30000,
|
|
11
|
+
* channels: [new ConsoleChannel()]
|
|
12
|
+
* });
|
|
13
|
+
*
|
|
14
|
+
* const approved = await workflow.requestApproval({
|
|
15
|
+
* action: 'delete_file',
|
|
16
|
+
* agent: 'maintenance-bot',
|
|
17
|
+
* reason: 'Cleaning up temporary files in /tmp/agent-cache',
|
|
18
|
+
* timeout: 60000
|
|
19
|
+
* });
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const { randomUUID } = require('crypto');
|
|
23
|
+
const { createReadStream, createWriteStream, existsSync } = require('fs');
|
|
24
|
+
const { appendFile } = require('fs/promises');
|
|
25
|
+
const { createInterface } = require('readline');
|
|
26
|
+
const http = require('http');
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @typedef {Object} ApprovalRequest
|
|
30
|
+
* @property {string} action - Description of the action requiring approval
|
|
31
|
+
* @property {string} agent - Name/ID of the agent requesting approval
|
|
32
|
+
* @property {string} reason - Human-readable explanation of why this action is needed
|
|
33
|
+
* @property {number} [timeout=30000] - Timeout in ms before auto-approval/denial
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* @typedef {Object} ApprovalResponse
|
|
38
|
+
* @property {string} requestId - Unique request identifier
|
|
39
|
+
* @property {boolean} approved - Whether the action was approved
|
|
40
|
+
* @property {string} source - How the decision was made ('user', 'timeout', 'policy')
|
|
41
|
+
* @property {string} [reason] - Optional explanation for the decision
|
|
42
|
+
* @property {number} timestamp - Unix timestamp when decision was made
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Base interface for notification channels
|
|
47
|
+
*/
|
|
48
|
+
class NotificationChannel {
|
|
49
|
+
/**
|
|
50
|
+
* Notify about a pending approval request
|
|
51
|
+
* @param {ApprovalRequest & { requestId: string, expiresAt: number }} request
|
|
52
|
+
* @returns {Promise<void>}
|
|
53
|
+
*/
|
|
54
|
+
async notify(request) {
|
|
55
|
+
throw new Error('notify() must be implemented');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Check if a response has been provided for this request
|
|
60
|
+
* @param {string} requestId
|
|
61
|
+
* @returns {Promise<{ approved: boolean, reason?: string } | null>}
|
|
62
|
+
*/
|
|
63
|
+
async checkResponse(requestId) {
|
|
64
|
+
throw new Error('checkResponse() must be implemented');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Cleanup any resources for this request
|
|
69
|
+
* @param {string} requestId
|
|
70
|
+
* @returns {Promise<void>}
|
|
71
|
+
*/
|
|
72
|
+
async cleanup(requestId) {
|
|
73
|
+
// Default: no-op
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Console channel - prompts via stdin/stdout
|
|
79
|
+
*/
|
|
80
|
+
class ConsoleChannel extends NotificationChannel {
|
|
81
|
+
constructor() {
|
|
82
|
+
super();
|
|
83
|
+
this.pendingRequests = new Map();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async notify(request) {
|
|
87
|
+
console.log('\n🚨 APPROVAL REQUIRED 🚨');
|
|
88
|
+
console.log(`Agent: ${request.agent}`);
|
|
89
|
+
console.log(`Action: ${request.action}`);
|
|
90
|
+
console.log(`Reason: ${request.reason}`);
|
|
91
|
+
console.log(`Expires: ${new Date(request.expiresAt).toISOString()}`);
|
|
92
|
+
console.log(`Respond with: approve ${request.requestId} OR deny ${request.requestId} [reason]`);
|
|
93
|
+
console.log('');
|
|
94
|
+
|
|
95
|
+
// Store for response checking
|
|
96
|
+
this.pendingRequests.set(request.requestId, {
|
|
97
|
+
active: true,
|
|
98
|
+
response: null
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
// Start listening for stdin if not already
|
|
102
|
+
if (!this._stdinListener) {
|
|
103
|
+
this._startStdinListener();
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async checkResponse(requestId) {
|
|
108
|
+
const pending = this.pendingRequests.get(requestId);
|
|
109
|
+
if (!pending || !pending.response) return null;
|
|
110
|
+
|
|
111
|
+
return pending.response;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async cleanup(requestId) {
|
|
115
|
+
this.pendingRequests.delete(requestId);
|
|
116
|
+
|
|
117
|
+
if (this.pendingRequests.size === 0 && this._stdinListener) {
|
|
118
|
+
this._stdinListener.close();
|
|
119
|
+
this._stdinListener = null;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
_startStdinListener() {
|
|
124
|
+
this._stdinListener = createInterface({
|
|
125
|
+
input: process.stdin,
|
|
126
|
+
output: process.stdout
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
this._stdinListener.on('line', (line) => {
|
|
130
|
+
const match = line.trim().match(/^(approve|deny)\s+([a-f0-9-]+)(?:\s+(.+))?$/i);
|
|
131
|
+
if (!match) return;
|
|
132
|
+
|
|
133
|
+
const [, action, requestId, reason] = match;
|
|
134
|
+
const pending = this.pendingRequests.get(requestId);
|
|
135
|
+
|
|
136
|
+
if (pending && pending.active) {
|
|
137
|
+
pending.response = {
|
|
138
|
+
approved: action.toLowerCase() === 'approve',
|
|
139
|
+
reason: reason || undefined
|
|
140
|
+
};
|
|
141
|
+
pending.active = false;
|
|
142
|
+
|
|
143
|
+
console.log(`✅ Response recorded: ${action.toUpperCase()} ${requestId}`);
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Webhook channel - sends HTTP POST and polls for response
|
|
151
|
+
*/
|
|
152
|
+
class WebhookChannel extends NotificationChannel {
|
|
153
|
+
constructor(options = {}) {
|
|
154
|
+
super();
|
|
155
|
+
this.webhookUrl = options.webhookUrl;
|
|
156
|
+
this.responseUrl = options.responseUrl; // URL to poll for responses
|
|
157
|
+
this.secret = options.secret; // Optional webhook signature secret
|
|
158
|
+
|
|
159
|
+
if (!this.webhookUrl) {
|
|
160
|
+
throw new Error('WebhookChannel requires webhookUrl option');
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async notify(request) {
|
|
165
|
+
const payload = JSON.stringify({
|
|
166
|
+
type: 'approval_request',
|
|
167
|
+
requestId: request.requestId,
|
|
168
|
+
action: request.action,
|
|
169
|
+
agent: request.agent,
|
|
170
|
+
reason: request.reason,
|
|
171
|
+
expiresAt: request.expiresAt,
|
|
172
|
+
timestamp: Date.now()
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
const url = new URL(this.webhookUrl);
|
|
176
|
+
const options = {
|
|
177
|
+
hostname: url.hostname,
|
|
178
|
+
port: url.port || (url.protocol === 'https:' ? 443 : 80),
|
|
179
|
+
path: url.pathname + url.search,
|
|
180
|
+
method: 'POST',
|
|
181
|
+
headers: {
|
|
182
|
+
'Content-Type': 'application/json',
|
|
183
|
+
'Content-Length': Buffer.byteLength(payload),
|
|
184
|
+
'User-Agent': 'ClawMoat-Approval/1.0'
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
// Add signature if secret provided
|
|
189
|
+
if (this.secret) {
|
|
190
|
+
const crypto = require('crypto');
|
|
191
|
+
const signature = crypto.createHmac('sha256', this.secret).update(payload).digest('hex');
|
|
192
|
+
options.headers['X-ClawMoat-Signature'] = `sha256=${signature}`;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return new Promise((resolve, reject) => {
|
|
196
|
+
const req = http.request(options, (res) => {
|
|
197
|
+
if (res.statusCode >= 200 && res.statusCode < 300) {
|
|
198
|
+
resolve();
|
|
199
|
+
} else {
|
|
200
|
+
reject(new Error(`Webhook failed: HTTP ${res.statusCode}`));
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
req.on('error', reject);
|
|
205
|
+
req.write(payload);
|
|
206
|
+
req.end();
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async checkResponse(requestId) {
|
|
211
|
+
if (!this.responseUrl) return null;
|
|
212
|
+
|
|
213
|
+
const url = new URL(this.responseUrl);
|
|
214
|
+
url.searchParams.set('requestId', requestId);
|
|
215
|
+
|
|
216
|
+
return new Promise((resolve) => {
|
|
217
|
+
const options = {
|
|
218
|
+
hostname: url.hostname,
|
|
219
|
+
port: url.port || (url.protocol === 'https:' ? 443 : 80),
|
|
220
|
+
path: url.pathname + url.search,
|
|
221
|
+
method: 'GET',
|
|
222
|
+
headers: { 'User-Agent': 'ClawMoat-Approval/1.0' }
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
const req = http.request(options, (res) => {
|
|
226
|
+
if (res.statusCode !== 200) {
|
|
227
|
+
resolve(null);
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
let data = '';
|
|
232
|
+
res.on('data', chunk => data += chunk);
|
|
233
|
+
res.on('end', () => {
|
|
234
|
+
try {
|
|
235
|
+
const response = JSON.parse(data);
|
|
236
|
+
if (response.requestId === requestId && response.decision) {
|
|
237
|
+
resolve({
|
|
238
|
+
approved: response.decision === 'approve',
|
|
239
|
+
reason: response.reason
|
|
240
|
+
});
|
|
241
|
+
} else {
|
|
242
|
+
resolve(null);
|
|
243
|
+
}
|
|
244
|
+
} catch {
|
|
245
|
+
resolve(null);
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
req.on('error', () => resolve(null));
|
|
251
|
+
req.setTimeout(5000, () => {
|
|
252
|
+
req.destroy();
|
|
253
|
+
resolve(null);
|
|
254
|
+
});
|
|
255
|
+
req.end();
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Callback channel - uses provided functions for notification and response checking
|
|
262
|
+
*/
|
|
263
|
+
class CallbackChannel extends NotificationChannel {
|
|
264
|
+
constructor(options = {}) {
|
|
265
|
+
super();
|
|
266
|
+
this.notifyFn = options.notifyFn;
|
|
267
|
+
this.checkResponseFn = options.checkResponseFn;
|
|
268
|
+
|
|
269
|
+
if (typeof this.notifyFn !== 'function') {
|
|
270
|
+
throw new Error('CallbackChannel requires notifyFn function');
|
|
271
|
+
}
|
|
272
|
+
if (typeof this.checkResponseFn !== 'function') {
|
|
273
|
+
throw new Error('CallbackChannel requires checkResponseFn function');
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
async notify(request) {
|
|
278
|
+
return this.notifyFn(request);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
async checkResponse(requestId) {
|
|
282
|
+
return this.checkResponseFn(requestId);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Main approval workflow coordinator
|
|
288
|
+
*/
|
|
289
|
+
class ApprovalWorkflow {
|
|
290
|
+
constructor(options = {}) {
|
|
291
|
+
this.defaultTimeout = options.defaultTimeout || 30000;
|
|
292
|
+
this.defaultAction = options.defaultAction || 'deny'; // 'approve' | 'deny'
|
|
293
|
+
this.channels = Array.isArray(options.channels) ? options.channels : [new ConsoleChannel()];
|
|
294
|
+
this.auditLog = options.auditLog || null; // Path to audit log file
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Request approval for an action
|
|
299
|
+
* @param {ApprovalRequest} request
|
|
300
|
+
* @returns {Promise<ApprovalResponse>}
|
|
301
|
+
*/
|
|
302
|
+
async requestApproval(request) {
|
|
303
|
+
const requestId = randomUUID();
|
|
304
|
+
const timeout = request.timeout || this.defaultTimeout;
|
|
305
|
+
const expiresAt = Date.now() + timeout;
|
|
306
|
+
|
|
307
|
+
const fullRequest = {
|
|
308
|
+
...request,
|
|
309
|
+
requestId,
|
|
310
|
+
expiresAt
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
// Log the request
|
|
314
|
+
await this._auditLog({
|
|
315
|
+
type: 'approval_request',
|
|
316
|
+
requestId,
|
|
317
|
+
action: request.action,
|
|
318
|
+
agent: request.agent,
|
|
319
|
+
reason: request.reason,
|
|
320
|
+
timeout,
|
|
321
|
+
timestamp: Date.now()
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
// Notify all channels
|
|
325
|
+
const notificationPromises = this.channels.map(channel =>
|
|
326
|
+
channel.notify(fullRequest).catch(err => {
|
|
327
|
+
console.error(`Channel notification failed: ${err.message}`);
|
|
328
|
+
})
|
|
329
|
+
);
|
|
330
|
+
|
|
331
|
+
await Promise.allSettled(notificationPromises);
|
|
332
|
+
|
|
333
|
+
// Poll for responses with timeout
|
|
334
|
+
return new Promise((resolve) => {
|
|
335
|
+
const pollInterval = Math.min(1000, timeout / 10); // Poll every 1s or 1/10th of timeout
|
|
336
|
+
let timeoutHandle;
|
|
337
|
+
let pollHandle;
|
|
338
|
+
|
|
339
|
+
const checkResponses = async () => {
|
|
340
|
+
for (const channel of this.channels) {
|
|
341
|
+
try {
|
|
342
|
+
const response = await channel.checkResponse(requestId);
|
|
343
|
+
if (response) {
|
|
344
|
+
clearTimeout(timeoutHandle);
|
|
345
|
+
clearInterval(pollHandle);
|
|
346
|
+
|
|
347
|
+
// Cleanup all channels
|
|
348
|
+
await Promise.allSettled(
|
|
349
|
+
this.channels.map(ch => ch.cleanup(requestId))
|
|
350
|
+
);
|
|
351
|
+
|
|
352
|
+
const result = {
|
|
353
|
+
requestId,
|
|
354
|
+
approved: response.approved,
|
|
355
|
+
source: 'user',
|
|
356
|
+
reason: response.reason,
|
|
357
|
+
timestamp: Date.now()
|
|
358
|
+
};
|
|
359
|
+
|
|
360
|
+
await this._auditLog({
|
|
361
|
+
type: 'approval_response',
|
|
362
|
+
...result
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
resolve(result);
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
} catch (err) {
|
|
369
|
+
// Ignore channel errors during polling
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
};
|
|
373
|
+
|
|
374
|
+
// Start polling
|
|
375
|
+
pollHandle = setInterval(checkResponses, pollInterval);
|
|
376
|
+
|
|
377
|
+
// Set timeout
|
|
378
|
+
timeoutHandle = setTimeout(async () => {
|
|
379
|
+
clearInterval(pollHandle);
|
|
380
|
+
|
|
381
|
+
// Cleanup all channels
|
|
382
|
+
await Promise.allSettled(
|
|
383
|
+
this.channels.map(ch => ch.cleanup(requestId))
|
|
384
|
+
);
|
|
385
|
+
|
|
386
|
+
const result = {
|
|
387
|
+
requestId,
|
|
388
|
+
approved: this.defaultAction === 'approve',
|
|
389
|
+
source: 'timeout',
|
|
390
|
+
reason: `No response within ${timeout}ms, defaulting to ${this.defaultAction}`,
|
|
391
|
+
timestamp: Date.now()
|
|
392
|
+
};
|
|
393
|
+
|
|
394
|
+
await this._auditLog({
|
|
395
|
+
type: 'approval_timeout',
|
|
396
|
+
...result
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
resolve(result);
|
|
400
|
+
}, timeout);
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Get audit log entries
|
|
406
|
+
* @param {Object} [filter] - Optional filter criteria
|
|
407
|
+
* @returns {Promise<Object[]>}
|
|
408
|
+
*/
|
|
409
|
+
async getAuditLog(filter = {}) {
|
|
410
|
+
if (!this.auditLog || !existsSync(this.auditLog)) {
|
|
411
|
+
return [];
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
const entries = [];
|
|
415
|
+
const fileStream = createReadStream(this.auditLog);
|
|
416
|
+
const rl = createInterface({ input: fileStream });
|
|
417
|
+
|
|
418
|
+
for await (const line of rl) {
|
|
419
|
+
try {
|
|
420
|
+
const entry = JSON.parse(line);
|
|
421
|
+
|
|
422
|
+
// Apply filters
|
|
423
|
+
if (filter.requestId && entry.requestId !== filter.requestId) continue;
|
|
424
|
+
if (filter.agent && entry.agent !== filter.agent) continue;
|
|
425
|
+
if (filter.type && entry.type !== filter.type) continue;
|
|
426
|
+
if (filter.since && entry.timestamp < filter.since) continue;
|
|
427
|
+
if (filter.until && entry.timestamp > filter.until) continue;
|
|
428
|
+
|
|
429
|
+
entries.push(entry);
|
|
430
|
+
} catch {
|
|
431
|
+
// Skip malformed lines
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
return entries;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
async _auditLog(entry) {
|
|
439
|
+
if (!this.auditLog) return;
|
|
440
|
+
|
|
441
|
+
const logLine = JSON.stringify(entry) + '\n';
|
|
442
|
+
try {
|
|
443
|
+
await appendFile(this.auditLog, logLine);
|
|
444
|
+
} catch (err) {
|
|
445
|
+
console.error(`Audit log write failed: ${err.message}`);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
module.exports = {
|
|
451
|
+
ApprovalWorkflow,
|
|
452
|
+
NotificationChannel,
|
|
453
|
+
ConsoleChannel,
|
|
454
|
+
WebhookChannel,
|
|
455
|
+
CallbackChannel
|
|
456
|
+
};
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ban Topics / Substrings / Allow-Deny Lists
|
|
3
|
+
*
|
|
4
|
+
* Stolen from LLM Guard. Enterprises love obvious controls.
|
|
5
|
+
* Configure banned topics, required keywords, regex patterns, and deny/allow lists.
|
|
6
|
+
*
|
|
7
|
+
* @module ban-scanner
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
'use strict';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Create a ban scanner with configurable rules
|
|
14
|
+
* @param {Object} config - Ban configuration
|
|
15
|
+
* @param {string[]} [config.bannedSubstrings=[]] - Exact substrings to block (case-insensitive)
|
|
16
|
+
* @param {RegExp[]|string[]} [config.bannedPatterns=[]] - Regex patterns to block
|
|
17
|
+
* @param {string[]} [config.bannedTopics=[]] - Topic keywords to block
|
|
18
|
+
* @param {string[]} [config.allowedTopics=[]] - If set, ONLY these topics are allowed
|
|
19
|
+
* @param {string[]} [config.requiredSubstrings=[]] - At least one must be present (for output validation)
|
|
20
|
+
* @param {Object} [config.customRules=[]] - Array of {name, test: (text) => bool, severity, message}
|
|
21
|
+
* @returns {Object} Scanner instance
|
|
22
|
+
*/
|
|
23
|
+
function createBanScanner(config = {}) {
|
|
24
|
+
const {
|
|
25
|
+
bannedSubstrings = [],
|
|
26
|
+
bannedPatterns = [],
|
|
27
|
+
bannedTopics = [],
|
|
28
|
+
allowedTopics = [],
|
|
29
|
+
requiredSubstrings = [],
|
|
30
|
+
customRules = [],
|
|
31
|
+
} = config;
|
|
32
|
+
|
|
33
|
+
// Pre-compile patterns
|
|
34
|
+
const compiledPatterns = bannedPatterns.map(p =>
|
|
35
|
+
p instanceof RegExp ? p : new RegExp(p, 'gi')
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Scan text against ban rules
|
|
40
|
+
* @param {string} text - Text to scan
|
|
41
|
+
* @returns {Object} { safe, findings }
|
|
42
|
+
*/
|
|
43
|
+
function scan(text) {
|
|
44
|
+
const findings = [];
|
|
45
|
+
const lower = text.toLowerCase();
|
|
46
|
+
|
|
47
|
+
// Check banned substrings
|
|
48
|
+
for (const sub of bannedSubstrings) {
|
|
49
|
+
if (lower.includes(sub.toLowerCase())) {
|
|
50
|
+
findings.push({
|
|
51
|
+
type: 'banned_content',
|
|
52
|
+
subtype: 'banned_substring',
|
|
53
|
+
severity: 'high',
|
|
54
|
+
confidence: 1.0,
|
|
55
|
+
evidence: `Banned substring found: "${sub}"`,
|
|
56
|
+
matched: sub,
|
|
57
|
+
recommended_action: 'block',
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Check banned patterns
|
|
63
|
+
for (const pattern of compiledPatterns) {
|
|
64
|
+
pattern.lastIndex = 0; // Reset regex state
|
|
65
|
+
const match = pattern.exec(text);
|
|
66
|
+
if (match) {
|
|
67
|
+
findings.push({
|
|
68
|
+
type: 'banned_content',
|
|
69
|
+
subtype: 'banned_pattern',
|
|
70
|
+
severity: 'high',
|
|
71
|
+
confidence: 0.95,
|
|
72
|
+
evidence: `Banned pattern matched: "${match[0].substring(0, 60)}"`,
|
|
73
|
+
matched: match[0].substring(0, 100),
|
|
74
|
+
recommended_action: 'block',
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Check banned topics (keyword-based)
|
|
80
|
+
for (const topic of bannedTopics) {
|
|
81
|
+
const topicWords = topic.toLowerCase().split(/\s+/);
|
|
82
|
+
const allPresent = topicWords.every(w => lower.includes(w));
|
|
83
|
+
if (allPresent) {
|
|
84
|
+
findings.push({
|
|
85
|
+
type: 'banned_content',
|
|
86
|
+
subtype: 'banned_topic',
|
|
87
|
+
severity: 'medium',
|
|
88
|
+
confidence: 0.7,
|
|
89
|
+
evidence: `Banned topic detected: "${topic}"`,
|
|
90
|
+
topic,
|
|
91
|
+
recommended_action: 'block',
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Check allowed topics (if set, text MUST match at least one)
|
|
97
|
+
if (allowedTopics.length > 0) {
|
|
98
|
+
const matchesAllowed = allowedTopics.some(topic => {
|
|
99
|
+
const topicWords = topic.toLowerCase().split(/\s+/);
|
|
100
|
+
return topicWords.some(w => lower.includes(w));
|
|
101
|
+
});
|
|
102
|
+
if (!matchesAllowed) {
|
|
103
|
+
findings.push({
|
|
104
|
+
type: 'banned_content',
|
|
105
|
+
subtype: 'off_topic',
|
|
106
|
+
severity: 'medium',
|
|
107
|
+
confidence: 0.6,
|
|
108
|
+
evidence: `Text does not match any allowed topics: ${allowedTopics.join(', ')}`,
|
|
109
|
+
recommended_action: 'block',
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Check required substrings (output validation)
|
|
115
|
+
if (requiredSubstrings.length > 0) {
|
|
116
|
+
const hasRequired = requiredSubstrings.some(s => lower.includes(s.toLowerCase()));
|
|
117
|
+
if (!hasRequired) {
|
|
118
|
+
findings.push({
|
|
119
|
+
type: 'banned_content',
|
|
120
|
+
subtype: 'missing_required',
|
|
121
|
+
severity: 'low',
|
|
122
|
+
confidence: 0.8,
|
|
123
|
+
evidence: `Missing required content. Expected one of: ${requiredSubstrings.join(', ')}`,
|
|
124
|
+
recommended_action: 'warn',
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Custom rules
|
|
130
|
+
for (const rule of customRules) {
|
|
131
|
+
try {
|
|
132
|
+
if (rule.test(text)) {
|
|
133
|
+
findings.push({
|
|
134
|
+
type: 'banned_content',
|
|
135
|
+
subtype: 'custom_rule',
|
|
136
|
+
severity: rule.severity || 'medium',
|
|
137
|
+
confidence: 0.9,
|
|
138
|
+
evidence: rule.message || `Custom rule "${rule.name}" triggered`,
|
|
139
|
+
rule: rule.name,
|
|
140
|
+
recommended_action: rule.action || 'block',
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
} catch (_) { /* skip broken rules */ }
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
safe: findings.length === 0,
|
|
148
|
+
findings,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return { scan };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Pre-built rulesets
|
|
156
|
+
const PRESETS = {
|
|
157
|
+
// Block competitor mentions (for support bots)
|
|
158
|
+
noCompetitors: (competitors) => ({
|
|
159
|
+
bannedSubstrings: competitors,
|
|
160
|
+
}),
|
|
161
|
+
|
|
162
|
+
// Block personal information requests
|
|
163
|
+
noPIIRequests: () => ({
|
|
164
|
+
bannedPatterns: [
|
|
165
|
+
/what.{0,20}(social security|ssn|credit card|bank account)/i,
|
|
166
|
+
/tell me your.{0,20}(password|address|phone|email)/i,
|
|
167
|
+
/give me.{0,20}(credentials|access|token|key)/i,
|
|
168
|
+
],
|
|
169
|
+
}),
|
|
170
|
+
|
|
171
|
+
// Block harmful content
|
|
172
|
+
noHarmful: () => ({
|
|
173
|
+
bannedTopics: [
|
|
174
|
+
'how to hack', 'how to exploit', 'how to attack',
|
|
175
|
+
'make a bomb', 'make a weapon', 'create malware',
|
|
176
|
+
'bypass security', 'disable firewall', 'crack password',
|
|
177
|
+
],
|
|
178
|
+
}),
|
|
179
|
+
|
|
180
|
+
// Coding agent safety
|
|
181
|
+
codingAgent: () => ({
|
|
182
|
+
bannedPatterns: [
|
|
183
|
+
/rm\s+-rf\s+[\/~]/,
|
|
184
|
+
/curl.*\|\s*bash/,
|
|
185
|
+
/wget.*\|\s*sh/,
|
|
186
|
+
/chmod\s+777/,
|
|
187
|
+
/:()\s*{.*}/,
|
|
188
|
+
],
|
|
189
|
+
bannedSubstrings: [
|
|
190
|
+
'/etc/shadow',
|
|
191
|
+
'DROP TABLE',
|
|
192
|
+
'format c:',
|
|
193
|
+
],
|
|
194
|
+
}),
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
module.exports = {
|
|
198
|
+
createBanScanner,
|
|
199
|
+
PRESETS,
|
|
200
|
+
};
|