@cdot65/prisma-airs 0.1.4 → 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.
@@ -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;
package/index.ts CHANGED
@@ -116,7 +116,7 @@ export default function register(api: PluginApi): void {
116
116
  const hasApiKey = isConfigured();
117
117
  respond(true, {
118
118
  plugin: "prisma-airs",
119
- version: "0.1.4",
119
+ version: "0.2.0",
120
120
  config: {
121
121
  profile_name: cfg.profile_name ?? "default",
122
122
  app_name: cfg.app_name ?? "openclaw",
@@ -215,7 +215,7 @@ export default function register(api: PluginApi): void {
215
215
  const hasKey = isConfigured();
216
216
  console.log("Prisma AIRS Plugin Status");
217
217
  console.log("-------------------------");
218
- console.log(`Version: 0.1.4`);
218
+ console.log(`Version: 0.2.0`);
219
219
  console.log(`Profile: ${cfg.profile_name ?? "default"}`);
220
220
  console.log(`App Name: ${cfg.app_name ?? "openclaw"}`);
221
221
  console.log(`Reminder: ${cfg.reminder_enabled ?? true}`);
@@ -266,7 +266,7 @@ export default function register(api: PluginApi): void {
266
266
  // Export plugin metadata for discovery
267
267
  export const id = "prisma-airs";
268
268
  export const name = "Prisma AIRS Security";
269
- export const version = "0.1.4";
269
+ export const version = "0.2.0";
270
270
 
271
271
  // Re-export scanner types and functions
272
272
  export { scan, isConfigured } from "./src/scanner";
@@ -1,10 +1,16 @@
1
1
  {
2
2
  "id": "prisma-airs",
3
3
  "name": "Prisma AIRS Security",
4
- "description": "AI Runtime Security scanning via Palo Alto Networks - TypeScript implementation with Gateway RPC, agent tool, and bootstrap reminder hook",
5
- "version": "0.1.4",
4
+ "description": "AI Runtime Security - full AIRS detection suite with audit logging, context injection, outbound blocking, and tool gating",
5
+ "version": "0.2.0",
6
6
  "entrypoint": "index.ts",
7
- "hooks": ["hooks/prisma-airs-guard"],
7
+ "hooks": [
8
+ "hooks/prisma-airs-guard",
9
+ "hooks/prisma-airs-audit",
10
+ "hooks/prisma-airs-context",
11
+ "hooks/prisma-airs-outbound",
12
+ "hooks/prisma-airs-tools"
13
+ ],
8
14
  "configSchema": {
9
15
  "type": "object",
10
16
  "additionalProperties": false,
@@ -23,6 +29,42 @@
23
29
  "type": "boolean",
24
30
  "default": true,
25
31
  "description": "Inject security scanning reminder on agent bootstrap"
32
+ },
33
+ "audit_enabled": {
34
+ "type": "boolean",
35
+ "default": true,
36
+ "description": "Enable audit logging of all inbound messages"
37
+ },
38
+ "context_injection_enabled": {
39
+ "type": "boolean",
40
+ "default": true,
41
+ "description": "Inject security warnings into agent context"
42
+ },
43
+ "outbound_scanning_enabled": {
44
+ "type": "boolean",
45
+ "default": true,
46
+ "description": "Enable scanning and blocking of outbound responses"
47
+ },
48
+ "tool_gating_enabled": {
49
+ "type": "boolean",
50
+ "default": true,
51
+ "description": "Block dangerous tools when threats are detected"
52
+ },
53
+ "fail_closed": {
54
+ "type": "boolean",
55
+ "default": true,
56
+ "description": "Block messages when AIRS scan fails (default: true for security)"
57
+ },
58
+ "dlp_mask_only": {
59
+ "type": "boolean",
60
+ "default": true,
61
+ "description": "Mask DLP violations instead of blocking (when no other violations)"
62
+ },
63
+ "high_risk_tools": {
64
+ "type": "array",
65
+ "items": { "type": "string" },
66
+ "default": ["exec", "Bash", "write", "Write", "edit", "Edit", "gateway", "message", "cron"],
67
+ "description": "Tools to block on any detected threat"
26
68
  }
27
69
  }
28
70
  },
@@ -37,9 +79,38 @@
37
79
  },
38
80
  "reminder_enabled": {
39
81
  "label": "Enable Bootstrap Reminder"
82
+ },
83
+ "audit_enabled": {
84
+ "label": "Enable Audit Logging",
85
+ "description": "Log all inbound messages with scan results"
86
+ },
87
+ "context_injection_enabled": {
88
+ "label": "Enable Context Injection",
89
+ "description": "Inject security warnings into agent context"
90
+ },
91
+ "outbound_scanning_enabled": {
92
+ "label": "Enable Outbound Scanning",
93
+ "description": "Scan and block/mask outbound responses"
94
+ },
95
+ "tool_gating_enabled": {
96
+ "label": "Enable Tool Gating",
97
+ "description": "Block dangerous tools during active threats"
98
+ },
99
+ "fail_closed": {
100
+ "label": "Fail Closed",
101
+ "description": "Block on scan failure (recommended for security)"
102
+ },
103
+ "dlp_mask_only": {
104
+ "label": "DLP Mask Only",
105
+ "description": "Mask sensitive data instead of blocking"
106
+ },
107
+ "high_risk_tools": {
108
+ "label": "High Risk Tools",
109
+ "description": "Tools blocked on any threat detection"
40
110
  }
41
111
  },
42
112
  "requires": {
43
- "env": ["PANW_AI_SEC_API_KEY"]
113
+ "env": ["PANW_AI_SEC_API_KEY"],
114
+ "envOptional": ["PANW_AI_SEC_PROFILE_NAME"]
44
115
  }
45
116
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@cdot65/prisma-airs",
3
- "version": "0.1.4",
4
- "description": "Prisma AIRS (AI Runtime Security) plugin for OpenClaw - TypeScript implementation with Gateway RPC, agent tool, and bootstrap hook",
3
+ "version": "0.2.1",
4
+ "description": "Prisma AIRS (AI Runtime Security) plugin for OpenClaw - Full security suite with audit logging, context injection, outbound blocking, and tool gating",
5
5
  "type": "module",
6
6
  "main": "index.ts",
7
7
  "scripts": {
@@ -0,0 +1,167 @@
1
+ /**
2
+ * Tests for scan-cache module
3
+ */
4
+
5
+ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
6
+ import {
7
+ cacheScanResult,
8
+ getCachedScanResult,
9
+ getCachedScanResultIfMatch,
10
+ clearScanResult,
11
+ getCacheStats,
12
+ hashMessage,
13
+ stopCleanup,
14
+ startCleanup,
15
+ } from "./scan-cache";
16
+ import type { ScanResult } from "./scanner";
17
+
18
+ // Mock scan result
19
+ const mockScanResult: ScanResult = {
20
+ action: "block",
21
+ severity: "HIGH",
22
+ categories: ["prompt_injection"],
23
+ scanId: "scan_123",
24
+ reportId: "report_456",
25
+ profileName: "default",
26
+ promptDetected: { injection: true, dlp: false, urlCats: false },
27
+ responseDetected: { dlp: false, urlCats: false },
28
+ latencyMs: 100,
29
+ };
30
+
31
+ describe("scan-cache", () => {
32
+ beforeEach(() => {
33
+ // Clear cache before each test
34
+ clearScanResult("test-session");
35
+ clearScanResult("session-1");
36
+ clearScanResult("session-2");
37
+ });
38
+
39
+ afterEach(() => {
40
+ // Stop cleanup interval to prevent test interference
41
+ stopCleanup();
42
+ });
43
+
44
+ describe("cacheScanResult", () => {
45
+ it("should cache a scan result", () => {
46
+ cacheScanResult("test-session", mockScanResult);
47
+ const result = getCachedScanResult("test-session");
48
+ expect(result).toEqual(mockScanResult);
49
+ });
50
+
51
+ it("should cache with message hash", () => {
52
+ const hash = hashMessage("test message");
53
+ cacheScanResult("test-session", mockScanResult, hash);
54
+ const result = getCachedScanResultIfMatch("test-session", hash);
55
+ expect(result).toEqual(mockScanResult);
56
+ });
57
+ });
58
+
59
+ describe("getCachedScanResult", () => {
60
+ it("should return undefined for non-existent key", () => {
61
+ const result = getCachedScanResult("non-existent");
62
+ expect(result).toBeUndefined();
63
+ });
64
+
65
+ it("should return cached result", () => {
66
+ cacheScanResult("test-session", mockScanResult);
67
+ const result = getCachedScanResult("test-session");
68
+ expect(result).toEqual(mockScanResult);
69
+ });
70
+
71
+ it("should return undefined for expired entries", () => {
72
+ // Mock Date.now to simulate time passing
73
+ const originalNow = Date.now;
74
+ const startTime = 1000000;
75
+ vi.spyOn(Date, "now").mockReturnValue(startTime);
76
+
77
+ cacheScanResult("test-session", mockScanResult);
78
+
79
+ // Advance time past TTL (30 seconds)
80
+ vi.spyOn(Date, "now").mockReturnValue(startTime + 31000);
81
+
82
+ const result = getCachedScanResult("test-session");
83
+ expect(result).toBeUndefined();
84
+
85
+ // Restore
86
+ Date.now = originalNow;
87
+ });
88
+ });
89
+
90
+ describe("getCachedScanResultIfMatch", () => {
91
+ it("should return result if hash matches", () => {
92
+ const hash = hashMessage("test message");
93
+ cacheScanResult("test-session", mockScanResult, hash);
94
+ const result = getCachedScanResultIfMatch("test-session", hash);
95
+ expect(result).toEqual(mockScanResult);
96
+ });
97
+
98
+ it("should return undefined if hash does not match", () => {
99
+ const hash1 = hashMessage("message 1");
100
+ const hash2 = hashMessage("message 2");
101
+ cacheScanResult("test-session", mockScanResult, hash1);
102
+ const result = getCachedScanResultIfMatch("test-session", hash2);
103
+ expect(result).toBeUndefined();
104
+ });
105
+
106
+ it("should return result if no hash was stored", () => {
107
+ cacheScanResult("test-session", mockScanResult);
108
+ const result = getCachedScanResultIfMatch("test-session", "any-hash");
109
+ expect(result).toEqual(mockScanResult);
110
+ });
111
+ });
112
+
113
+ describe("clearScanResult", () => {
114
+ it("should clear cached result", () => {
115
+ cacheScanResult("test-session", mockScanResult);
116
+ clearScanResult("test-session");
117
+ const result = getCachedScanResult("test-session");
118
+ expect(result).toBeUndefined();
119
+ });
120
+
121
+ it("should not affect other sessions", () => {
122
+ cacheScanResult("session-1", mockScanResult);
123
+ cacheScanResult("session-2", { ...mockScanResult, scanId: "scan_456" });
124
+ clearScanResult("session-1");
125
+
126
+ expect(getCachedScanResult("session-1")).toBeUndefined();
127
+ expect(getCachedScanResult("session-2")).toBeDefined();
128
+ });
129
+ });
130
+
131
+ describe("hashMessage", () => {
132
+ it("should produce consistent hashes", () => {
133
+ const hash1 = hashMessage("test message");
134
+ const hash2 = hashMessage("test message");
135
+ expect(hash1).toEqual(hash2);
136
+ });
137
+
138
+ it("should produce different hashes for different messages", () => {
139
+ const hash1 = hashMessage("message 1");
140
+ const hash2 = hashMessage("message 2");
141
+ expect(hash1).not.toEqual(hash2);
142
+ });
143
+
144
+ it("should handle empty string", () => {
145
+ const hash = hashMessage("");
146
+ expect(hash).toEqual("0");
147
+ });
148
+ });
149
+
150
+ describe("getCacheStats", () => {
151
+ it("should return cache stats", () => {
152
+ const stats = getCacheStats();
153
+ expect(stats).toHaveProperty("size");
154
+ expect(stats).toHaveProperty("ttlMs");
155
+ expect(stats.ttlMs).toEqual(30000);
156
+ });
157
+ });
158
+
159
+ describe("cleanup", () => {
160
+ it("should be able to stop and start cleanup", () => {
161
+ stopCleanup();
162
+ startCleanup();
163
+ // No error means success
164
+ expect(true).toBe(true);
165
+ });
166
+ });
167
+ });