@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.
- package/hooks/prisma-airs-audit/HOOK.md +47 -0
- package/hooks/prisma-airs-audit/handler.ts +167 -0
- package/hooks/prisma-airs-context/HOOK.md +41 -0
- package/hooks/prisma-airs-context/handler.ts +295 -0
- package/hooks/prisma-airs-guard/handler.test.ts +72 -35
- package/hooks/prisma-airs-guard/handler.ts +54 -39
- package/hooks/prisma-airs-outbound/HOOK.md +43 -0
- package/hooks/prisma-airs-outbound/handler.test.ts +296 -0
- package/hooks/prisma-airs-outbound/handler.ts +341 -0
- package/hooks/prisma-airs-tools/HOOK.md +40 -0
- package/hooks/prisma-airs-tools/handler.ts +279 -0
- package/index.ts +25 -7
- package/openclaw.plugin.json +74 -3
- package/package.json +2 -2
- package/src/scan-cache.test.ts +167 -0
- package/src/scan-cache.ts +134 -0
|
@@ -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;
|