@cdot65/prisma-airs 0.1.3 → 0.2.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.
@@ -0,0 +1,341 @@
1
+ /**
2
+ * Prisma AIRS Outbound Security Scanner (message_sending)
3
+ *
4
+ * Scans ALL outbound responses for:
5
+ * - WildFire: malicious URLs and content
6
+ * - Toxicity: harmful/abusive content
7
+ * - URL Filtering: disallowed URL categories
8
+ * - DLP: sensitive data leakage
9
+ * - Malicious Code: malware/exploits
10
+ * - Custom Topics: org-specific policy violations
11
+ * - Grounding: hallucination detection
12
+ *
13
+ * CAN BLOCK via { cancel: true } or modify via { content: "..." }
14
+ */
15
+
16
+ import { scan, type ScanResult } from "../../src/scanner";
17
+
18
+ // Event shape from OpenClaw message_sending hook
19
+ interface MessageSendingEvent {
20
+ content?: string;
21
+ to?: string;
22
+ channel?: string;
23
+ metadata?: {
24
+ sessionKey?: string;
25
+ messageId?: string;
26
+ };
27
+ }
28
+
29
+ // Context passed to hook
30
+ interface HookContext {
31
+ channelId?: string;
32
+ accountId?: string;
33
+ conversationId?: string;
34
+ cfg?: PluginConfig;
35
+ }
36
+
37
+ // Plugin config structure
38
+ interface PluginConfig {
39
+ plugins?: {
40
+ entries?: {
41
+ "prisma-airs"?: {
42
+ config?: {
43
+ outbound_scanning_enabled?: boolean;
44
+ profile_name?: string;
45
+ app_name?: string;
46
+ fail_closed?: boolean;
47
+ dlp_mask_only?: boolean;
48
+ };
49
+ };
50
+ };
51
+ };
52
+ }
53
+
54
+ // Hook result type - can modify content or cancel
55
+ interface HookResult {
56
+ content?: string;
57
+ cancel?: boolean;
58
+ }
59
+
60
+ // Map AIRS categories to user-friendly messages
61
+ const CATEGORY_MESSAGES: Record<string, string> = {
62
+ // Core detections
63
+ prompt_injection: "prompt injection attempt",
64
+ dlp_prompt: "sensitive data in input",
65
+ dlp_response: "sensitive data leakage",
66
+ url_filtering_prompt: "disallowed URL in input",
67
+ url_filtering_response: "disallowed URL in response",
68
+ malicious_url: "malicious URL detected",
69
+ toxicity: "inappropriate content",
70
+ toxic_content: "inappropriate content",
71
+ malicious_code: "malicious code detected",
72
+ agent_threat: "AI agent threat",
73
+ grounding: "response grounding violation",
74
+ ungrounded: "ungrounded response",
75
+ custom_topic: "policy violation",
76
+ topic_violation: "policy violation",
77
+ db_security: "database security threat",
78
+ safe: "safe",
79
+ benign: "safe",
80
+ api_error: "security scan error",
81
+ "scan-failure": "security scan failed",
82
+ };
83
+
84
+ // Categories that can be masked instead of blocked
85
+ const MASKABLE_CATEGORIES = ["dlp_response", "dlp_prompt", "dlp"];
86
+
87
+ // Categories that always require full block
88
+ const ALWAYS_BLOCK_CATEGORIES = [
89
+ "malicious_code",
90
+ "malicious_url",
91
+ "toxicity",
92
+ "toxic_content",
93
+ "agent_threat",
94
+ "prompt_injection",
95
+ "db_security",
96
+ "scan-failure",
97
+ ];
98
+
99
+ /**
100
+ * Get plugin configuration
101
+ */
102
+ function getPluginConfig(ctx: HookContext): {
103
+ enabled: boolean;
104
+ profileName: string;
105
+ appName: string;
106
+ failClosed: boolean;
107
+ dlpMaskOnly: boolean;
108
+ } {
109
+ const cfg = ctx.cfg?.plugins?.entries?.["prisma-airs"]?.config;
110
+ return {
111
+ enabled: cfg?.outbound_scanning_enabled !== false,
112
+ profileName: cfg?.profile_name ?? "default",
113
+ appName: cfg?.app_name ?? "openclaw",
114
+ failClosed: cfg?.fail_closed ?? true, // Default fail-closed
115
+ dlpMaskOnly: cfg?.dlp_mask_only ?? true, // Default mask instead of block for DLP
116
+ };
117
+ }
118
+
119
+ /**
120
+ * Mask sensitive data in content
121
+ *
122
+ * Uses regex patterns for common PII types.
123
+ * TODO: Use AIRS API match offsets for precision masking when available.
124
+ */
125
+ function maskSensitiveData(content: string): string {
126
+ let masked = content;
127
+
128
+ // Social Security Numbers (XXX-XX-XXXX)
129
+ masked = masked.replace(/\b\d{3}-\d{2}-\d{4}\b/g, "[SSN REDACTED]");
130
+
131
+ // Credit Card Numbers (with or without spaces/dashes)
132
+ masked = masked.replace(/\b(?:\d{4}[-\s]?){3}\d{4}\b/g, "[CARD REDACTED]");
133
+
134
+ // Email addresses
135
+ masked = masked.replace(
136
+ /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/g,
137
+ "[EMAIL REDACTED]"
138
+ );
139
+
140
+ // API keys and tokens (common patterns)
141
+ masked = masked.replace(
142
+ /\b(?:sk-|pk-|api[_-]?key[_-]?|token[_-]?|secret[_-]?|password[_-]?)[a-zA-Z0-9_-]{16,}\b/gi,
143
+ "[API KEY REDACTED]"
144
+ );
145
+
146
+ // AWS keys
147
+ masked = masked.replace(/\b(?:AKIA|ABIA|ACCA|ASIA)[A-Z0-9]{16}\b/g, "[AWS KEY REDACTED]");
148
+
149
+ // Generic long alphanumeric strings that look like secrets (40+ chars)
150
+ masked = masked.replace(/\b[a-zA-Z0-9_-]{40,}\b/g, (match) => {
151
+ // Only redact if it looks like a key (has mixed case or numbers)
152
+ if (/[a-z]/.test(match) && /[A-Z]/.test(match) && /[0-9]/.test(match)) {
153
+ return "[SECRET REDACTED]";
154
+ }
155
+ return match;
156
+ });
157
+
158
+ // US Phone numbers
159
+ masked = masked.replace(
160
+ /\b(?:\+1[-.\s]?)?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}\b/g,
161
+ "[PHONE REDACTED]"
162
+ );
163
+
164
+ // IP addresses (private ranges especially)
165
+ masked = masked.replace(
166
+ /\b(?:10\.\d{1,3}\.\d{1,3}\.\d{1,3}|172\.(?:1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3}|192\.168\.\d{1,3}\.\d{1,3})\b/g,
167
+ "[IP REDACTED]"
168
+ );
169
+
170
+ return masked;
171
+ }
172
+
173
+ /**
174
+ * Build user-friendly block message
175
+ */
176
+ function buildBlockMessage(result: ScanResult): string {
177
+ const reasons = result.categories
178
+ .map((cat) => CATEGORY_MESSAGES[cat] || cat.replace(/_/g, " "))
179
+ .filter((r) => r !== "safe")
180
+ .join(", ");
181
+
182
+ return (
183
+ `I apologize, but I'm unable to provide that response due to security policy` +
184
+ (reasons ? ` (${reasons})` : "") +
185
+ `. Please rephrase your request or contact support if you believe this is an error.`
186
+ );
187
+ }
188
+
189
+ /**
190
+ * Determine if result should be masked vs blocked
191
+ */
192
+ function shouldMaskOnly(result: ScanResult, config: { dlpMaskOnly: boolean }): boolean {
193
+ if (!config.dlpMaskOnly) return false;
194
+
195
+ // Check if any always-block categories are present
196
+ const hasBlockingCategory = result.categories.some((cat) =>
197
+ ALWAYS_BLOCK_CATEGORIES.includes(cat)
198
+ );
199
+ if (hasBlockingCategory) return false;
200
+
201
+ // Check if all categories are maskable
202
+ const allMaskable = result.categories.every(
203
+ (cat) => MASKABLE_CATEGORIES.includes(cat) || cat === "safe" || cat === "benign"
204
+ );
205
+
206
+ return allMaskable;
207
+ }
208
+
209
+ /**
210
+ * Main hook handler
211
+ */
212
+ const handler = async (
213
+ event: MessageSendingEvent,
214
+ ctx: HookContext
215
+ ): Promise<HookResult | void> => {
216
+ const config = getPluginConfig(ctx);
217
+
218
+ // Check if outbound scanning is enabled
219
+ if (!config.enabled) {
220
+ return;
221
+ }
222
+
223
+ // Validate we have content to scan
224
+ const content = event.content;
225
+ if (!content || typeof content !== "string" || content.trim().length === 0) {
226
+ return;
227
+ }
228
+
229
+ const sessionKey = event.metadata?.sessionKey || ctx.conversationId || "unknown";
230
+
231
+ let result: ScanResult;
232
+
233
+ try {
234
+ // Scan the outbound response
235
+ result = await scan({
236
+ response: content,
237
+ profileName: config.profileName,
238
+ appName: config.appName,
239
+ });
240
+ } catch (err) {
241
+ console.error(
242
+ JSON.stringify({
243
+ event: "prisma_airs_outbound_scan_error",
244
+ timestamp: new Date().toISOString(),
245
+ sessionKey,
246
+ error: err instanceof Error ? err.message : String(err),
247
+ })
248
+ );
249
+
250
+ // Fail-closed: block on scan failure
251
+ if (config.failClosed) {
252
+ return {
253
+ content:
254
+ "I apologize, but I'm unable to provide a response at this time due to a security verification issue. Please try again.",
255
+ };
256
+ }
257
+
258
+ return; // Fail-open
259
+ }
260
+
261
+ // Log the scan result
262
+ console.log(
263
+ JSON.stringify({
264
+ event: "prisma_airs_outbound_scan",
265
+ timestamp: new Date().toISOString(),
266
+ sessionKey,
267
+ action: result.action,
268
+ severity: result.severity,
269
+ categories: result.categories,
270
+ scanId: result.scanId,
271
+ reportId: result.reportId,
272
+ latencyMs: result.latencyMs,
273
+ responseDetected: result.responseDetected,
274
+ })
275
+ );
276
+
277
+ // Handle allow - no modification needed
278
+ if (result.action === "allow") {
279
+ return;
280
+ }
281
+
282
+ // Handle warn - log but allow through
283
+ if (result.action === "warn") {
284
+ console.log(
285
+ JSON.stringify({
286
+ event: "prisma_airs_outbound_warn",
287
+ timestamp: new Date().toISOString(),
288
+ sessionKey,
289
+ severity: result.severity,
290
+ categories: result.categories,
291
+ scanId: result.scanId,
292
+ })
293
+ );
294
+ return; // Allow through with warning logged
295
+ }
296
+
297
+ // Handle block
298
+ if (result.action === "block") {
299
+ // Check if we should mask instead of block (DLP-only)
300
+ if (shouldMaskOnly(result, config)) {
301
+ const maskedContent = maskSensitiveData(content);
302
+
303
+ // Only return modified content if masking actually changed something
304
+ if (maskedContent !== content) {
305
+ console.log(
306
+ JSON.stringify({
307
+ event: "prisma_airs_outbound_mask",
308
+ timestamp: new Date().toISOString(),
309
+ sessionKey,
310
+ categories: result.categories,
311
+ scanId: result.scanId,
312
+ })
313
+ );
314
+
315
+ return {
316
+ content: maskedContent,
317
+ };
318
+ }
319
+ }
320
+
321
+ // Full block - replace content entirely
322
+ console.log(
323
+ JSON.stringify({
324
+ event: "prisma_airs_outbound_block",
325
+ timestamp: new Date().toISOString(),
326
+ sessionKey,
327
+ action: result.action,
328
+ severity: result.severity,
329
+ categories: result.categories,
330
+ scanId: result.scanId,
331
+ reportId: result.reportId,
332
+ })
333
+ );
334
+
335
+ return {
336
+ content: buildBlockMessage(result),
337
+ };
338
+ }
339
+ };
340
+
341
+ export default handler;
@@ -0,0 +1,40 @@
1
+ ---
2
+ name: prisma-airs-tools
3
+ description: "Block dangerous tool calls when security threats are detected"
4
+ metadata: { "openclaw": { "emoji": "🛑", "events": ["before_tool_call"] } }
5
+ ---
6
+
7
+ # Prisma AIRS Tool Gating
8
+
9
+ Blocks dangerous tool calls when security warnings are active from inbound scanning.
10
+
11
+ ## Behavior
12
+
13
+ This hook runs before each tool call and checks if the current session has an active security warning (from `message_received` or `before_agent_start` scanning). Based on the detected threat categories, it blocks specific tools that could be dangerous.
14
+
15
+ ## Tool Blocking Matrix
16
+
17
+ | Threat Category | Blocked Tools |
18
+ | ------------------------------- | ----------------------------- |
19
+ | `agent-threat` | ALL external tools |
20
+ | `sql-injection` / `db-security` | exec, database, query, sql |
21
+ | `malicious-code` | exec, write, edit, eval, bash |
22
+ | `prompt-injection` | exec, gateway, message, cron |
23
+ | `malicious-url` | web_fetch, browser, curl |
24
+
25
+ ## High-Risk Tools (Default)
26
+
27
+ These tools are blocked on ANY detected threat:
28
+
29
+ - `exec` - Command execution
30
+ - `Bash` - Shell access
31
+ - `write` - File writing
32
+ - `edit` - File editing
33
+ - `gateway` - Gateway operations
34
+ - `message` - Sending messages
35
+ - `cron` - Scheduled tasks
36
+
37
+ ## Configuration
38
+
39
+ - `tool_gating_enabled`: Enable/disable (default: true)
40
+ - `high_risk_tools`: List of tools to block on any threat
@@ -0,0 +1,279 @@
1
+ /**
2
+ * Prisma AIRS Tool Gating (before_tool_call)
3
+ *
4
+ * Blocks dangerous tool calls when security warnings are active.
5
+ * CAN BLOCK via { block: true, blockReason: "..." }
6
+ */
7
+
8
+ import { getCachedScanResult } from "../../src/scan-cache";
9
+ import type { ScanResult } from "../../src/scanner";
10
+
11
+ // Event shape from OpenClaw before_tool_call hook
12
+ interface BeforeToolCallEvent {
13
+ toolName: string;
14
+ toolId?: string;
15
+ params?: Record<string, unknown>;
16
+ }
17
+
18
+ // Context passed to hook
19
+ interface HookContext {
20
+ sessionKey?: string;
21
+ channelId?: string;
22
+ accountId?: string;
23
+ conversationId?: string;
24
+ cfg?: PluginConfig;
25
+ }
26
+
27
+ // Plugin config structure
28
+ interface PluginConfig {
29
+ plugins?: {
30
+ entries?: {
31
+ "prisma-airs"?: {
32
+ config?: {
33
+ tool_gating_enabled?: boolean;
34
+ high_risk_tools?: string[];
35
+ };
36
+ };
37
+ };
38
+ };
39
+ }
40
+
41
+ // Hook result type
42
+ interface HookResult {
43
+ params?: Record<string, unknown>;
44
+ block?: boolean;
45
+ blockReason?: string;
46
+ }
47
+
48
+ // Tool blocking rules by threat category
49
+ const TOOL_BLOCKS: Record<string, string[]> = {
50
+ // AI Agent threats - block ALL external actions
51
+ "agent-threat": [
52
+ "exec",
53
+ "Bash",
54
+ "bash",
55
+ "write",
56
+ "Write",
57
+ "edit",
58
+ "Edit",
59
+ "gateway",
60
+ "message",
61
+ "cron",
62
+ "browser",
63
+ "web_fetch",
64
+ "WebFetch",
65
+ "database",
66
+ "query",
67
+ "sql",
68
+ "eval",
69
+ "NotebookEdit",
70
+ ],
71
+
72
+ // SQL/Database injection - block database and exec tools
73
+ "sql-injection": ["exec", "Bash", "bash", "database", "query", "sql", "eval"],
74
+ db_security: ["exec", "Bash", "bash", "database", "query", "sql", "eval"],
75
+ "db-security": ["exec", "Bash", "bash", "database", "query", "sql", "eval"],
76
+
77
+ // Malicious code - block code execution and file writes
78
+ "malicious-code": [
79
+ "exec",
80
+ "Bash",
81
+ "bash",
82
+ "write",
83
+ "Write",
84
+ "edit",
85
+ "Edit",
86
+ "eval",
87
+ "NotebookEdit",
88
+ ],
89
+ malicious_code: [
90
+ "exec",
91
+ "Bash",
92
+ "bash",
93
+ "write",
94
+ "Write",
95
+ "edit",
96
+ "Edit",
97
+ "eval",
98
+ "NotebookEdit",
99
+ ],
100
+
101
+ // Prompt injection - block sensitive tools
102
+ "prompt-injection": ["exec", "Bash", "bash", "gateway", "message", "cron"],
103
+ prompt_injection: ["exec", "Bash", "bash", "gateway", "message", "cron"],
104
+
105
+ // Malicious URLs - block web access
106
+ "malicious-url": ["web_fetch", "WebFetch", "browser", "Browser", "curl"],
107
+ malicious_url: ["web_fetch", "WebFetch", "browser", "Browser", "curl"],
108
+ url_filtering_prompt: ["web_fetch", "WebFetch", "browser", "Browser", "curl"],
109
+
110
+ // Scan failure - block high-risk tools
111
+ "scan-failure": [
112
+ "exec",
113
+ "Bash",
114
+ "bash",
115
+ "write",
116
+ "Write",
117
+ "edit",
118
+ "Edit",
119
+ "gateway",
120
+ "message",
121
+ "cron",
122
+ ],
123
+ };
124
+
125
+ // Default high-risk tools (blocked on any threat)
126
+ const DEFAULT_HIGH_RISK_TOOLS = [
127
+ "exec",
128
+ "Bash",
129
+ "bash",
130
+ "write",
131
+ "Write",
132
+ "edit",
133
+ "Edit",
134
+ "gateway",
135
+ "message",
136
+ "cron",
137
+ ];
138
+
139
+ /**
140
+ * Get plugin configuration
141
+ */
142
+ function getPluginConfig(ctx: HookContext): {
143
+ enabled: boolean;
144
+ highRiskTools: string[];
145
+ } {
146
+ const cfg = ctx.cfg?.plugins?.entries?.["prisma-airs"]?.config;
147
+ return {
148
+ enabled: cfg?.tool_gating_enabled !== false,
149
+ highRiskTools: cfg?.high_risk_tools ?? DEFAULT_HIGH_RISK_TOOLS,
150
+ };
151
+ }
152
+
153
+ /**
154
+ * Determine if a tool should be blocked based on scan result
155
+ */
156
+ function shouldBlockTool(
157
+ toolName: string,
158
+ scanResult: ScanResult,
159
+ highRiskTools: string[]
160
+ ): { block: boolean; reason: string } {
161
+ // Collect all tools that should be blocked based on detected categories
162
+ const blockedTools = new Set<string>();
163
+
164
+ for (const category of scanResult.categories) {
165
+ const tools = TOOL_BLOCKS[category];
166
+ if (tools) {
167
+ tools.forEach((t) => blockedTools.add(t.toLowerCase()));
168
+ }
169
+ }
170
+
171
+ // Add high-risk tools if any threat was detected
172
+ const hasThreat =
173
+ scanResult.action === "block" ||
174
+ scanResult.action === "warn" ||
175
+ (scanResult.categories.length > 0 &&
176
+ !scanResult.categories.every((c) => c === "safe" || c === "benign"));
177
+
178
+ if (hasThreat) {
179
+ highRiskTools.forEach((t) => blockedTools.add(t.toLowerCase()));
180
+ }
181
+
182
+ // Check if this tool should be blocked
183
+ const toolLower = toolName.toLowerCase();
184
+ if (blockedTools.has(toolLower)) {
185
+ const threatCategories = scanResult.categories
186
+ .filter((c) => c !== "safe" && c !== "benign")
187
+ .join(", ");
188
+
189
+ return {
190
+ block: true,
191
+ reason:
192
+ `Tool '${toolName}' blocked due to security threat: ${threatCategories || "unknown"}. ` +
193
+ `Scan ID: ${scanResult.scanId || "N/A"}`,
194
+ };
195
+ }
196
+
197
+ return { block: false, reason: "" };
198
+ }
199
+
200
+ /**
201
+ * Main hook handler
202
+ */
203
+ const handler = async (
204
+ event: BeforeToolCallEvent,
205
+ ctx: HookContext
206
+ ): Promise<HookResult | void> => {
207
+ const config = getPluginConfig(ctx);
208
+
209
+ // Check if tool gating is enabled
210
+ if (!config.enabled) {
211
+ return;
212
+ }
213
+
214
+ // Get tool name
215
+ const toolName = event.toolName;
216
+ if (!toolName) {
217
+ return;
218
+ }
219
+
220
+ // Build session key
221
+ const sessionKey = ctx.sessionKey || ctx.conversationId || "unknown";
222
+
223
+ // Get cached scan result from inbound scanning
224
+ const scanResult = getCachedScanResult(sessionKey);
225
+ if (!scanResult) {
226
+ return; // No scan result cached, allow through
227
+ }
228
+
229
+ // Check if result indicates a safe message
230
+ if (
231
+ scanResult.action === "allow" &&
232
+ (scanResult.severity === "SAFE" ||
233
+ scanResult.categories.every((c) => c === "safe" || c === "benign"))
234
+ ) {
235
+ return; // Safe, allow all tools
236
+ }
237
+
238
+ // Check if this tool should be blocked
239
+ const { block, reason } = shouldBlockTool(toolName, scanResult, config.highRiskTools);
240
+
241
+ if (block) {
242
+ console.log(
243
+ JSON.stringify({
244
+ event: "prisma_airs_tool_block",
245
+ timestamp: new Date().toISOString(),
246
+ sessionKey,
247
+ toolName,
248
+ toolId: event.toolId,
249
+ scanAction: scanResult.action,
250
+ severity: scanResult.severity,
251
+ categories: scanResult.categories,
252
+ scanId: scanResult.scanId,
253
+ })
254
+ );
255
+
256
+ return {
257
+ block: true,
258
+ blockReason: reason,
259
+ };
260
+ }
261
+
262
+ // Tool allowed, log for audit
263
+ if (scanResult.action !== "allow") {
264
+ console.log(
265
+ JSON.stringify({
266
+ event: "prisma_airs_tool_allow",
267
+ timestamp: new Date().toISOString(),
268
+ sessionKey,
269
+ toolName,
270
+ toolId: event.toolId,
271
+ note: "Tool allowed despite active security warning",
272
+ scanAction: scanResult.action,
273
+ categories: scanResult.categories,
274
+ })
275
+ );
276
+ }
277
+ };
278
+
279
+ export default handler;