@cdot65/prisma-airs-cursor-hooks 0.1.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.
Files changed (41) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +295 -0
  3. package/airs-config.json +32 -0
  4. package/dist/adapters/cursor-adapter.js +51 -0
  5. package/dist/adapters/index.js +1 -0
  6. package/dist/adapters/types.js +1 -0
  7. package/dist/airs-client.js +92 -0
  8. package/dist/circuit-breaker.js +66 -0
  9. package/dist/cli.js +59 -0
  10. package/dist/code-extractor.js +85 -0
  11. package/dist/config.js +101 -0
  12. package/dist/dlp-masking.js +31 -0
  13. package/dist/hooks/after-agent-response.js +75 -0
  14. package/dist/hooks/before-submit-prompt.js +75 -0
  15. package/dist/log-rotation.js +27 -0
  16. package/dist/logger.js +47 -0
  17. package/dist/scanner.js +298 -0
  18. package/dist/types.js +1 -0
  19. package/package.json +64 -0
  20. package/scripts/airs-stats.ts +119 -0
  21. package/scripts/install-hooks.ts +153 -0
  22. package/scripts/uninstall-hooks.ts +72 -0
  23. package/scripts/validate-connection.ts +45 -0
  24. package/scripts/validate-detection.ts +52 -0
  25. package/scripts/verify-hooks.ts +84 -0
  26. package/src/adapters/cursor-adapter.ts +62 -0
  27. package/src/adapters/index.ts +2 -0
  28. package/src/adapters/types.ts +14 -0
  29. package/src/airs-client.ts +136 -0
  30. package/src/circuit-breaker.ts +88 -0
  31. package/src/cli.ts +65 -0
  32. package/src/code-extractor.ts +99 -0
  33. package/src/config.ts +126 -0
  34. package/src/dlp-masking.ts +48 -0
  35. package/src/hooks/after-agent-response.ts +84 -0
  36. package/src/hooks/before-submit-prompt.ts +86 -0
  37. package/src/log-rotation.ts +27 -0
  38. package/src/logger.ts +55 -0
  39. package/src/scanner.ts +388 -0
  40. package/src/types.ts +153 -0
  41. package/tsconfig.build.json +19 -0
package/src/scanner.ts ADDED
@@ -0,0 +1,388 @@
1
+ import type { ScanResponse } from "@cdot65/prisma-airs-sdk";
2
+ import type { AirsConfig, HookResult, ScanDirection, ScanLogEntry } from "./types.js";
3
+ import {
4
+ scanPromptContent,
5
+ scanResponseContent,
6
+ AISecSDKException,
7
+ } from "./airs-client.js";
8
+ import { extractCode, joinCodeBlocks } from "./code-extractor.js";
9
+ import { Logger } from "./logger.js";
10
+ import { getEnforcementAction, maskContent, DEFAULT_ENFORCEMENT } from "./dlp-masking.js";
11
+
12
+ // ---------------------------------------------------------------------------
13
+ // Human-readable detection labels for UX messages
14
+ // ---------------------------------------------------------------------------
15
+ const DETECTION_LABELS: Record<string, string> = {
16
+ prompt_injection: "Prompt Injection",
17
+ dlp: "Data Loss Prevention (DLP)",
18
+ toxicity: "Toxic Content",
19
+ url_categorization: "Suspicious URL",
20
+ malicious_code: "Malicious Code",
21
+ custom_topic: "Topic Policy Violation",
22
+ };
23
+
24
+ function friendlyDetectionName(key: string): string {
25
+ return DETECTION_LABELS[key] ?? key;
26
+ }
27
+
28
+ // ---------------------------------------------------------------------------
29
+ // Detection extraction from SDK ScanResponse
30
+ // ---------------------------------------------------------------------------
31
+
32
+ interface DetectionInfo {
33
+ services: string[];
34
+ findings: { detection_service: string; verdict: string; detail: string }[];
35
+ }
36
+
37
+ function extractDetections(result: ScanResponse): DetectionInfo {
38
+ const services: string[] = [];
39
+ const findings: { detection_service: string; verdict: string; detail: string }[] = [];
40
+ const pd = result.prompt_detected;
41
+ if (pd) {
42
+ if (pd.injection) {
43
+ services.push("prompt_injection");
44
+ findings.push({ detection_service: "prompt_injection", verdict: "malicious", detail: "Prompt injection detected" });
45
+ }
46
+ if (pd.dlp) {
47
+ services.push("dlp");
48
+ findings.push({ detection_service: "dlp", verdict: "detected", detail: "Sensitive data detected in prompt" });
49
+ }
50
+ if (pd.toxic_content) {
51
+ services.push("toxicity");
52
+ findings.push({ detection_service: "toxicity", verdict: "toxic", detail: "Toxic content detected" });
53
+ }
54
+ if (pd.url_cats) {
55
+ services.push("url_categorization");
56
+ findings.push({ detection_service: "url_categorization", verdict: "suspicious", detail: "Suspicious URL detected" });
57
+ }
58
+ if (pd.malicious_code) {
59
+ services.push("malicious_code");
60
+ findings.push({ detection_service: "malicious_code", verdict: "malicious", detail: "Malicious code detected" });
61
+ }
62
+ if (pd.topic_violation) {
63
+ services.push("custom_topic");
64
+ findings.push({ detection_service: "custom_topic", verdict: "violation", detail: "Topic policy violation" });
65
+ }
66
+ }
67
+ const rd = result.response_detected;
68
+ if (rd) {
69
+ if (rd.malicious_code) {
70
+ services.push("malicious_code");
71
+ findings.push({ detection_service: "malicious_code", verdict: "malicious", detail: "Malicious code detected in response" });
72
+ }
73
+ if (rd.dlp) {
74
+ services.push("dlp");
75
+ findings.push({ detection_service: "dlp", verdict: "detected", detail: "Sensitive data detected in response" });
76
+ }
77
+ if (rd.toxic_content) {
78
+ services.push("toxicity");
79
+ findings.push({ detection_service: "toxicity", verdict: "toxic", detail: "Toxic content in response" });
80
+ }
81
+ if (rd.url_cats) {
82
+ services.push("url_categorization");
83
+ findings.push({ detection_service: "url_categorization", verdict: "suspicious", detail: "Suspicious URL in response" });
84
+ }
85
+ }
86
+ return { services, findings };
87
+ }
88
+
89
+ // ---------------------------------------------------------------------------
90
+ // Resolve the developer's identity
91
+ // Cursor provides CURSOR_USER_EMAIL; fall back to git config then OS user.
92
+ // ---------------------------------------------------------------------------
93
+
94
+ function getAppUser(): string {
95
+ const cursorEmail = process.env.CURSOR_USER_EMAIL;
96
+ if (cursorEmail) return cursorEmail;
97
+
98
+ try {
99
+ const { execSync } = require("node:child_process");
100
+ return execSync("git config user.email", { encoding: "utf-8" }).trim();
101
+ } catch {
102
+ return process.env.USER ?? process.env.USERNAME ?? "unknown";
103
+ }
104
+ }
105
+
106
+ // ---------------------------------------------------------------------------
107
+ // Build UX-friendly block messages
108
+ // ---------------------------------------------------------------------------
109
+
110
+ function buildPromptBlockMessage(
111
+ detections: string[],
112
+ category: string,
113
+ profileName: string,
114
+ scanId: string,
115
+ ): string {
116
+ const detectionList = detections.map(friendlyDetectionName).join(", ");
117
+ return [
118
+ "",
119
+ "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━",
120
+ " Prisma AIRS — Prompt Blocked",
121
+ "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━",
122
+ "",
123
+ ` What happened: Your prompt was flagged by the ${detectionList} security check.`,
124
+ ` Category: ${category}`,
125
+ ` Profile: ${profileName}`,
126
+ "",
127
+ " What to do:",
128
+ " - Review your prompt for sensitive data, injection patterns, or policy violations.",
129
+ " - Modify the prompt and try again.",
130
+ " - If you believe this is a false positive, contact your security team",
131
+ ` and reference Scan ID: ${scanId}`,
132
+ "",
133
+ "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━",
134
+ "",
135
+ ].join("\n");
136
+ }
137
+
138
+ function buildResponseBlockMessage(
139
+ detections: string[],
140
+ category: string,
141
+ profileName: string,
142
+ scanId: string,
143
+ ): string {
144
+ const detectionList = detections.map(friendlyDetectionName).join(", ");
145
+ return [
146
+ "",
147
+ "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━",
148
+ " Prisma AIRS — Response Blocked",
149
+ "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━",
150
+ "",
151
+ ` What happened: The AI response was flagged by the ${detectionList} security check.`,
152
+ ` Category: ${category}`,
153
+ ` Profile: ${profileName}`,
154
+ "",
155
+ " What to do:",
156
+ " - The response may have contained malicious code, sensitive data, or unsafe content.",
157
+ " - Try rephrasing your original prompt to get a different response.",
158
+ " - If you believe this is a false positive, contact your security team",
159
+ ` and reference Scan ID: ${scanId}`,
160
+ "",
161
+ "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━",
162
+ "",
163
+ ].join("\n");
164
+ }
165
+
166
+ function buildMaskedMessage(maskedServices: string[]): string {
167
+ const names = maskedServices.map(friendlyDetectionName).join(", ");
168
+ return [
169
+ "",
170
+ "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━",
171
+ " Prisma AIRS — Sensitive Data Masked",
172
+ "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━",
173
+ "",
174
+ ` ${names} detected sensitive data in your content.`,
175
+ " The flagged patterns have been masked with asterisks.",
176
+ "",
177
+ "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━",
178
+ "",
179
+ ].join("\n");
180
+ }
181
+
182
+ // ---------------------------------------------------------------------------
183
+ // scanPrompt — called by beforeSubmitPrompt hook
184
+ // ---------------------------------------------------------------------------
185
+
186
+ export async function scanPrompt(
187
+ config: AirsConfig,
188
+ prompt: string,
189
+ logger: Logger,
190
+ ): Promise<HookResult> {
191
+ if (config.mode === "bypass") {
192
+ logger.logEvent("scan_bypassed", { direction: "prompt" });
193
+ return { action: "pass" };
194
+ }
195
+
196
+ if (!prompt.trim()) {
197
+ return { action: "pass" };
198
+ }
199
+
200
+ const appUser = getAppUser();
201
+
202
+ try {
203
+ const { result, latencyMs } = await scanPromptContent(config, prompt, appUser, logger);
204
+
205
+ const verdict = result.action === "block" ? "block" : "allow";
206
+ const { services: detections, findings } = extractDetections(result);
207
+
208
+ const actionTaken =
209
+ config.mode === "observe"
210
+ ? "observed"
211
+ : verdict === "block"
212
+ ? "blocked"
213
+ : "allowed";
214
+
215
+ logger.logScan({
216
+ event: "scan_complete",
217
+ scan_id: result.scan_id ?? "",
218
+ direction: "prompt" as ScanDirection,
219
+ verdict: verdict as "allow" | "block",
220
+ action_taken: actionTaken,
221
+ latency_ms: latencyMs,
222
+ detection_services_triggered: detections,
223
+ error: null,
224
+ });
225
+
226
+ if (config.mode === "enforce" && verdict === "block") {
227
+ // Check per-service enforcement — some services may be set to "mask" or "allow"
228
+ const enforcement = config.enforcement ?? DEFAULT_ENFORCEMENT;
229
+ const enforcementAction = getEnforcementAction(findings, enforcement);
230
+
231
+ if (enforcementAction === "allow") {
232
+ return { action: "pass" };
233
+ }
234
+
235
+ if (enforcementAction === "mask") {
236
+ // DLP masking: we can't mask prompt content that's already been sent,
237
+ // but we log it and warn the user
238
+ const maskedServices = findings
239
+ .filter((f) => (enforcement[f.detection_service] ?? "block") === "mask")
240
+ .map((f) => f.detection_service);
241
+ logger.logEvent("dlp_mask_applied", { direction: "prompt", services: maskedServices });
242
+ return { action: "pass", message: buildMaskedMessage(maskedServices) };
243
+ }
244
+
245
+ return {
246
+ action: "block",
247
+ message: buildPromptBlockMessage(
248
+ detections,
249
+ result.category ?? "policy violation",
250
+ config.profiles.prompt,
251
+ result.scan_id ?? "unknown",
252
+ ),
253
+ };
254
+ }
255
+
256
+ return { action: "pass" };
257
+ } catch (err) {
258
+ const isAuth =
259
+ err instanceof AISecSDKException && err.message.includes("401");
260
+
261
+ const message = isAuth
262
+ ? "AIRS authentication failed. Check your API key."
263
+ : "AIRS scan failed — allowing prompt (fail-open)";
264
+
265
+ logger.logScan({
266
+ event: "scan_error",
267
+ scan_id: "",
268
+ direction: "prompt",
269
+ verdict: "allow",
270
+ action_taken: "error",
271
+ latency_ms: 0,
272
+ detection_services_triggered: [],
273
+ error: message,
274
+ });
275
+
276
+ if (isAuth) {
277
+ return { action: "pass", message: `Warning: ${message}` };
278
+ }
279
+ return { action: "pass" };
280
+ }
281
+ }
282
+
283
+ // ---------------------------------------------------------------------------
284
+ // scanResponse — called by afterAgentResponse hook
285
+ // ---------------------------------------------------------------------------
286
+
287
+ export async function scanResponse(
288
+ config: AirsConfig,
289
+ responseText: string,
290
+ logger: Logger,
291
+ ): Promise<HookResult> {
292
+ if (config.mode === "bypass") {
293
+ logger.logEvent("scan_bypassed", { direction: "response" });
294
+ return { action: "pass" };
295
+ }
296
+
297
+ if (!responseText.trim()) {
298
+ return { action: "pass" };
299
+ }
300
+
301
+ const appUser = getAppUser();
302
+ const extracted = extractCode(responseText);
303
+ const codeResponse =
304
+ extracted.codeBlocks.length > 0
305
+ ? joinCodeBlocks(extracted.codeBlocks)
306
+ : undefined;
307
+
308
+ const nlText = codeResponse ? extracted.naturalLanguage : responseText;
309
+
310
+ try {
311
+ const { result, latencyMs } = await scanResponseContent(
312
+ config, nlText, codeResponse, appUser, logger,
313
+ );
314
+
315
+ const verdict = result.action === "block" ? "block" : "allow";
316
+ const { services: detections, findings } = extractDetections(result);
317
+
318
+ const actionTaken =
319
+ config.mode === "observe"
320
+ ? "observed"
321
+ : verdict === "block"
322
+ ? "blocked"
323
+ : "allowed";
324
+
325
+ logger.logScan({
326
+ event: "scan_complete",
327
+ scan_id: result.scan_id ?? "",
328
+ direction: "response",
329
+ verdict: verdict as "allow" | "block",
330
+ action_taken: actionTaken,
331
+ latency_ms: latencyMs,
332
+ detection_services_triggered: detections,
333
+ error: null,
334
+ });
335
+
336
+ if (config.mode === "enforce" && verdict === "block") {
337
+ const enforcement = config.enforcement ?? DEFAULT_ENFORCEMENT;
338
+ const enforcementAction = getEnforcementAction(findings, enforcement);
339
+
340
+ if (enforcementAction === "allow") {
341
+ return { action: "pass" };
342
+ }
343
+
344
+ if (enforcementAction === "mask") {
345
+ const maskedServices = findings
346
+ .filter((f) => (enforcement[f.detection_service] ?? "block") === "mask")
347
+ .map((f) => f.detection_service);
348
+ logger.logEvent("dlp_mask_applied", { direction: "response", services: maskedServices });
349
+ return { action: "pass", message: buildMaskedMessage(maskedServices) };
350
+ }
351
+
352
+ return {
353
+ action: "block",
354
+ message: buildResponseBlockMessage(
355
+ detections,
356
+ result.category ?? "policy violation",
357
+ config.profiles.response,
358
+ result.scan_id ?? "unknown",
359
+ ),
360
+ };
361
+ }
362
+
363
+ return { action: "pass" };
364
+ } catch (err) {
365
+ const isAuth =
366
+ err instanceof AISecSDKException && err.message.includes("401");
367
+
368
+ const message = isAuth
369
+ ? "AIRS authentication failed. Check your API key."
370
+ : "AIRS scan failed — allowing response (fail-open)";
371
+
372
+ logger.logScan({
373
+ event: "scan_error",
374
+ scan_id: "",
375
+ direction: "response",
376
+ verdict: "allow",
377
+ action_taken: "error",
378
+ latency_ms: 0,
379
+ detection_services_triggered: [],
380
+ error: message,
381
+ });
382
+
383
+ if (isAuth) {
384
+ return { action: "pass", message: `Warning: ${message}` };
385
+ }
386
+ return { action: "pass" };
387
+ }
388
+ }
package/src/types.ts ADDED
@@ -0,0 +1,153 @@
1
+ // Re-export SDK types we use directly
2
+ export type {
3
+ ScanResponse,
4
+ Metadata,
5
+ } from "@cdot65/prisma-airs-sdk";
6
+
7
+ /** Operational mode */
8
+ export type Mode = "observe" | "enforce" | "bypass";
9
+
10
+ /** Retry configuration */
11
+ export interface RetryConfig {
12
+ enabled: boolean;
13
+ max_attempts: number;
14
+ backoff_base_ms: number;
15
+ }
16
+
17
+ /** Logging configuration */
18
+ export interface LoggingConfig {
19
+ path: string;
20
+ include_content: boolean;
21
+ }
22
+
23
+ /** Profile configuration */
24
+ export interface ProfileConfig {
25
+ prompt: string;
26
+ response: string;
27
+ }
28
+
29
+ /** Per-detection-service enforcement action */
30
+ export type EnforcementAction = "block" | "mask" | "allow";
31
+
32
+ /** Per-service enforcement overrides (Phase 3) */
33
+ export interface EnforcementConfig {
34
+ [detectionService: string]: EnforcementAction;
35
+ }
36
+
37
+ /** Circuit breaker configuration */
38
+ export interface CircuitBreakerConfig {
39
+ enabled: boolean;
40
+ failure_threshold: number;
41
+ cooldown_ms: number;
42
+ }
43
+
44
+ /** Top-level AIRS configuration (airs-config.json) */
45
+ export interface AirsConfig {
46
+ endpoint: string;
47
+ apiKeyEnvVar: string;
48
+ profiles: ProfileConfig;
49
+ mode: Mode;
50
+ timeout_ms: number;
51
+ retry: RetryConfig;
52
+ logging: LoggingConfig;
53
+ /** Per-service enforcement actions (only applies in enforce mode) */
54
+ enforcement?: EnforcementConfig;
55
+ /** Circuit breaker settings */
56
+ circuit_breaker?: CircuitBreakerConfig;
57
+ }
58
+
59
+ /** Scan direction */
60
+ export type ScanDirection = "prompt" | "response";
61
+
62
+ /** Log entry written by the structured logger */
63
+ export interface ScanLogEntry {
64
+ timestamp: string;
65
+ event: string;
66
+ scan_id: string;
67
+ direction: ScanDirection;
68
+ verdict: "allow" | "block";
69
+ action_taken: "allowed" | "blocked" | "observed" | "bypassed" | "error";
70
+ latency_ms: number;
71
+ detection_services_triggered: string[];
72
+ error: string | null;
73
+ content?: string;
74
+ }
75
+
76
+ /** Result from code extraction */
77
+ export interface ExtractedContent {
78
+ naturalLanguage: string;
79
+ codeBlocks: string[];
80
+ languages: string[];
81
+ }
82
+
83
+ /** Internal hook result from scanner logic */
84
+ export interface HookResult {
85
+ action: "pass" | "block";
86
+ message?: string;
87
+ }
88
+
89
+ // ---------------------------------------------------------------------------
90
+ // Cursor Hooks API types (v1)
91
+ // See: https://docs.cursor.com/configuration/hooks
92
+ // ---------------------------------------------------------------------------
93
+
94
+ /** Common fields Cursor injects into every hook's stdin JSON */
95
+ export interface CursorHookInput {
96
+ conversation_id?: string;
97
+ generation_id?: string;
98
+ model?: string;
99
+ hook_event_name: string;
100
+ cursor_version?: string;
101
+ workspace_roots?: string[];
102
+ user_email?: string;
103
+ transcript_path?: string;
104
+ }
105
+
106
+ /** stdin for beforeSubmitPrompt hook */
107
+ export interface BeforeSubmitPromptInput extends CursorHookInput {
108
+ prompt: string;
109
+ attachments?: unknown[];
110
+ }
111
+
112
+ /** stdin for afterAgentResponse hook */
113
+ export interface AfterAgentResponseInput extends CursorHookInput {
114
+ text: string;
115
+ }
116
+
117
+ /**
118
+ * Cursor hook stdout JSON for beforeSubmitPrompt.
119
+ * Uses continue: true/false to allow/block the prompt.
120
+ */
121
+ export interface BeforeSubmitPromptOutput {
122
+ continue: boolean;
123
+ user_message?: string;
124
+ }
125
+
126
+ /**
127
+ * Cursor hook stdout JSON for most other hooks.
128
+ * Uses permission: "allow"|"deny" to allow/block.
129
+ */
130
+ export interface CursorHookOutput {
131
+ /** "allow" passes through, "deny" blocks */
132
+ permission: "allow" | "deny";
133
+ /** Message shown to the user in Cursor's UI */
134
+ userMessage?: string;
135
+ /** Message injected into the agent context (invisible to the user) */
136
+ agentMessage?: string;
137
+ }
138
+
139
+ /** Cursor hooks.json file format */
140
+ export interface CursorHooksConfig {
141
+ version: 1;
142
+ hooks: {
143
+ [eventName: string]: CursorHookEntry[];
144
+ };
145
+ }
146
+
147
+ /** Single hook entry inside hooks.json */
148
+ export interface CursorHookEntry {
149
+ command: string;
150
+ timeout?: number;
151
+ /** true = block the action if the hook fails; false = fail-open (default) */
152
+ failClosed?: boolean;
153
+ }
@@ -0,0 +1,19 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ES2022",
5
+ "moduleResolution": "bundler",
6
+ "strict": true,
7
+ "esModuleInterop": true,
8
+ "skipLibCheck": true,
9
+ "forceConsistentCasingInFileNames": true,
10
+ "resolveJsonModule": true,
11
+ "declaration": false,
12
+ "sourceMap": false,
13
+ "outDir": "dist",
14
+ "rootDir": "src",
15
+ "types": ["node"]
16
+ },
17
+ "include": ["src/**/*.ts"],
18
+ "exclude": ["node_modules", "dist"]
19
+ }