@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
@@ -0,0 +1,298 @@
1
+ import { scanPromptContent, scanResponseContent, AISecSDKException, } from "./airs-client.js";
2
+ import { extractCode, joinCodeBlocks } from "./code-extractor.js";
3
+ import { getEnforcementAction, DEFAULT_ENFORCEMENT } from "./dlp-masking.js";
4
+ // ---------------------------------------------------------------------------
5
+ // Human-readable detection labels for UX messages
6
+ // ---------------------------------------------------------------------------
7
+ const DETECTION_LABELS = {
8
+ prompt_injection: "Prompt Injection",
9
+ dlp: "Data Loss Prevention (DLP)",
10
+ toxicity: "Toxic Content",
11
+ url_categorization: "Suspicious URL",
12
+ malicious_code: "Malicious Code",
13
+ custom_topic: "Topic Policy Violation",
14
+ };
15
+ function friendlyDetectionName(key) {
16
+ return DETECTION_LABELS[key] ?? key;
17
+ }
18
+ function extractDetections(result) {
19
+ const services = [];
20
+ const findings = [];
21
+ const pd = result.prompt_detected;
22
+ if (pd) {
23
+ if (pd.injection) {
24
+ services.push("prompt_injection");
25
+ findings.push({ detection_service: "prompt_injection", verdict: "malicious", detail: "Prompt injection detected" });
26
+ }
27
+ if (pd.dlp) {
28
+ services.push("dlp");
29
+ findings.push({ detection_service: "dlp", verdict: "detected", detail: "Sensitive data detected in prompt" });
30
+ }
31
+ if (pd.toxic_content) {
32
+ services.push("toxicity");
33
+ findings.push({ detection_service: "toxicity", verdict: "toxic", detail: "Toxic content detected" });
34
+ }
35
+ if (pd.url_cats) {
36
+ services.push("url_categorization");
37
+ findings.push({ detection_service: "url_categorization", verdict: "suspicious", detail: "Suspicious URL detected" });
38
+ }
39
+ if (pd.malicious_code) {
40
+ services.push("malicious_code");
41
+ findings.push({ detection_service: "malicious_code", verdict: "malicious", detail: "Malicious code detected" });
42
+ }
43
+ if (pd.topic_violation) {
44
+ services.push("custom_topic");
45
+ findings.push({ detection_service: "custom_topic", verdict: "violation", detail: "Topic policy violation" });
46
+ }
47
+ }
48
+ const rd = result.response_detected;
49
+ if (rd) {
50
+ if (rd.malicious_code) {
51
+ services.push("malicious_code");
52
+ findings.push({ detection_service: "malicious_code", verdict: "malicious", detail: "Malicious code detected in response" });
53
+ }
54
+ if (rd.dlp) {
55
+ services.push("dlp");
56
+ findings.push({ detection_service: "dlp", verdict: "detected", detail: "Sensitive data detected in response" });
57
+ }
58
+ if (rd.toxic_content) {
59
+ services.push("toxicity");
60
+ findings.push({ detection_service: "toxicity", verdict: "toxic", detail: "Toxic content in response" });
61
+ }
62
+ if (rd.url_cats) {
63
+ services.push("url_categorization");
64
+ findings.push({ detection_service: "url_categorization", verdict: "suspicious", detail: "Suspicious URL in response" });
65
+ }
66
+ }
67
+ return { services, findings };
68
+ }
69
+ // ---------------------------------------------------------------------------
70
+ // Resolve the developer's identity
71
+ // Cursor provides CURSOR_USER_EMAIL; fall back to git config then OS user.
72
+ // ---------------------------------------------------------------------------
73
+ function getAppUser() {
74
+ const cursorEmail = process.env.CURSOR_USER_EMAIL;
75
+ if (cursorEmail)
76
+ return cursorEmail;
77
+ try {
78
+ const { execSync } = require("node:child_process");
79
+ return execSync("git config user.email", { encoding: "utf-8" }).trim();
80
+ }
81
+ catch {
82
+ return process.env.USER ?? process.env.USERNAME ?? "unknown";
83
+ }
84
+ }
85
+ // ---------------------------------------------------------------------------
86
+ // Build UX-friendly block messages
87
+ // ---------------------------------------------------------------------------
88
+ function buildPromptBlockMessage(detections, category, profileName, scanId) {
89
+ const detectionList = detections.map(friendlyDetectionName).join(", ");
90
+ return [
91
+ "",
92
+ "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━",
93
+ " Prisma AIRS — Prompt Blocked",
94
+ "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━",
95
+ "",
96
+ ` What happened: Your prompt was flagged by the ${detectionList} security check.`,
97
+ ` Category: ${category}`,
98
+ ` Profile: ${profileName}`,
99
+ "",
100
+ " What to do:",
101
+ " - Review your prompt for sensitive data, injection patterns, or policy violations.",
102
+ " - Modify the prompt and try again.",
103
+ " - If you believe this is a false positive, contact your security team",
104
+ ` and reference Scan ID: ${scanId}`,
105
+ "",
106
+ "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━",
107
+ "",
108
+ ].join("\n");
109
+ }
110
+ function buildResponseBlockMessage(detections, category, profileName, scanId) {
111
+ const detectionList = detections.map(friendlyDetectionName).join(", ");
112
+ return [
113
+ "",
114
+ "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━",
115
+ " Prisma AIRS — Response Blocked",
116
+ "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━",
117
+ "",
118
+ ` What happened: The AI response was flagged by the ${detectionList} security check.`,
119
+ ` Category: ${category}`,
120
+ ` Profile: ${profileName}`,
121
+ "",
122
+ " What to do:",
123
+ " - The response may have contained malicious code, sensitive data, or unsafe content.",
124
+ " - Try rephrasing your original prompt to get a different response.",
125
+ " - If you believe this is a false positive, contact your security team",
126
+ ` and reference Scan ID: ${scanId}`,
127
+ "",
128
+ "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━",
129
+ "",
130
+ ].join("\n");
131
+ }
132
+ function buildMaskedMessage(maskedServices) {
133
+ const names = maskedServices.map(friendlyDetectionName).join(", ");
134
+ return [
135
+ "",
136
+ "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━",
137
+ " Prisma AIRS — Sensitive Data Masked",
138
+ "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━",
139
+ "",
140
+ ` ${names} detected sensitive data in your content.`,
141
+ " The flagged patterns have been masked with asterisks.",
142
+ "",
143
+ "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━",
144
+ "",
145
+ ].join("\n");
146
+ }
147
+ // ---------------------------------------------------------------------------
148
+ // scanPrompt — called by beforeSubmitPrompt hook
149
+ // ---------------------------------------------------------------------------
150
+ export async function scanPrompt(config, prompt, logger) {
151
+ if (config.mode === "bypass") {
152
+ logger.logEvent("scan_bypassed", { direction: "prompt" });
153
+ return { action: "pass" };
154
+ }
155
+ if (!prompt.trim()) {
156
+ return { action: "pass" };
157
+ }
158
+ const appUser = getAppUser();
159
+ try {
160
+ const { result, latencyMs } = await scanPromptContent(config, prompt, appUser, logger);
161
+ const verdict = result.action === "block" ? "block" : "allow";
162
+ const { services: detections, findings } = extractDetections(result);
163
+ const actionTaken = config.mode === "observe"
164
+ ? "observed"
165
+ : verdict === "block"
166
+ ? "blocked"
167
+ : "allowed";
168
+ logger.logScan({
169
+ event: "scan_complete",
170
+ scan_id: result.scan_id ?? "",
171
+ direction: "prompt",
172
+ verdict: verdict,
173
+ action_taken: actionTaken,
174
+ latency_ms: latencyMs,
175
+ detection_services_triggered: detections,
176
+ error: null,
177
+ });
178
+ if (config.mode === "enforce" && verdict === "block") {
179
+ // Check per-service enforcement — some services may be set to "mask" or "allow"
180
+ const enforcement = config.enforcement ?? DEFAULT_ENFORCEMENT;
181
+ const enforcementAction = getEnforcementAction(findings, enforcement);
182
+ if (enforcementAction === "allow") {
183
+ return { action: "pass" };
184
+ }
185
+ if (enforcementAction === "mask") {
186
+ // DLP masking: we can't mask prompt content that's already been sent,
187
+ // but we log it and warn the user
188
+ const maskedServices = findings
189
+ .filter((f) => (enforcement[f.detection_service] ?? "block") === "mask")
190
+ .map((f) => f.detection_service);
191
+ logger.logEvent("dlp_mask_applied", { direction: "prompt", services: maskedServices });
192
+ return { action: "pass", message: buildMaskedMessage(maskedServices) };
193
+ }
194
+ return {
195
+ action: "block",
196
+ message: buildPromptBlockMessage(detections, result.category ?? "policy violation", config.profiles.prompt, result.scan_id ?? "unknown"),
197
+ };
198
+ }
199
+ return { action: "pass" };
200
+ }
201
+ catch (err) {
202
+ const isAuth = err instanceof AISecSDKException && err.message.includes("401");
203
+ const message = isAuth
204
+ ? "AIRS authentication failed. Check your API key."
205
+ : "AIRS scan failed — allowing prompt (fail-open)";
206
+ logger.logScan({
207
+ event: "scan_error",
208
+ scan_id: "",
209
+ direction: "prompt",
210
+ verdict: "allow",
211
+ action_taken: "error",
212
+ latency_ms: 0,
213
+ detection_services_triggered: [],
214
+ error: message,
215
+ });
216
+ if (isAuth) {
217
+ return { action: "pass", message: `Warning: ${message}` };
218
+ }
219
+ return { action: "pass" };
220
+ }
221
+ }
222
+ // ---------------------------------------------------------------------------
223
+ // scanResponse — called by afterAgentResponse hook
224
+ // ---------------------------------------------------------------------------
225
+ export async function scanResponse(config, responseText, logger) {
226
+ if (config.mode === "bypass") {
227
+ logger.logEvent("scan_bypassed", { direction: "response" });
228
+ return { action: "pass" };
229
+ }
230
+ if (!responseText.trim()) {
231
+ return { action: "pass" };
232
+ }
233
+ const appUser = getAppUser();
234
+ const extracted = extractCode(responseText);
235
+ const codeResponse = extracted.codeBlocks.length > 0
236
+ ? joinCodeBlocks(extracted.codeBlocks)
237
+ : undefined;
238
+ const nlText = codeResponse ? extracted.naturalLanguage : responseText;
239
+ try {
240
+ const { result, latencyMs } = await scanResponseContent(config, nlText, codeResponse, appUser, logger);
241
+ const verdict = result.action === "block" ? "block" : "allow";
242
+ const { services: detections, findings } = extractDetections(result);
243
+ const actionTaken = config.mode === "observe"
244
+ ? "observed"
245
+ : verdict === "block"
246
+ ? "blocked"
247
+ : "allowed";
248
+ logger.logScan({
249
+ event: "scan_complete",
250
+ scan_id: result.scan_id ?? "",
251
+ direction: "response",
252
+ verdict: verdict,
253
+ action_taken: actionTaken,
254
+ latency_ms: latencyMs,
255
+ detection_services_triggered: detections,
256
+ error: null,
257
+ });
258
+ if (config.mode === "enforce" && verdict === "block") {
259
+ const enforcement = config.enforcement ?? DEFAULT_ENFORCEMENT;
260
+ const enforcementAction = getEnforcementAction(findings, enforcement);
261
+ if (enforcementAction === "allow") {
262
+ return { action: "pass" };
263
+ }
264
+ if (enforcementAction === "mask") {
265
+ const maskedServices = findings
266
+ .filter((f) => (enforcement[f.detection_service] ?? "block") === "mask")
267
+ .map((f) => f.detection_service);
268
+ logger.logEvent("dlp_mask_applied", { direction: "response", services: maskedServices });
269
+ return { action: "pass", message: buildMaskedMessage(maskedServices) };
270
+ }
271
+ return {
272
+ action: "block",
273
+ message: buildResponseBlockMessage(detections, result.category ?? "policy violation", config.profiles.response, result.scan_id ?? "unknown"),
274
+ };
275
+ }
276
+ return { action: "pass" };
277
+ }
278
+ catch (err) {
279
+ const isAuth = err instanceof AISecSDKException && err.message.includes("401");
280
+ const message = isAuth
281
+ ? "AIRS authentication failed. Check your API key."
282
+ : "AIRS scan failed — allowing response (fail-open)";
283
+ logger.logScan({
284
+ event: "scan_error",
285
+ scan_id: "",
286
+ direction: "response",
287
+ verdict: "allow",
288
+ action_taken: "error",
289
+ latency_ms: 0,
290
+ detection_services_triggered: [],
291
+ error: message,
292
+ });
293
+ if (isAuth) {
294
+ return { action: "pass", message: `Warning: ${message}` };
295
+ }
296
+ return { action: "pass" };
297
+ }
298
+ }
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/package.json ADDED
@@ -0,0 +1,64 @@
1
+ {
2
+ "name": "@cdot65/prisma-airs-cursor-hooks",
3
+ "version": "0.1.0",
4
+ "description": "Cursor IDE hooks integrating Prisma AIRS scanning into the developer workflow",
5
+ "type": "module",
6
+ "keywords": [
7
+ "cursor",
8
+ "cursor-hooks",
9
+ "prisma",
10
+ "airs",
11
+ "ai-security",
12
+ "prompt-injection",
13
+ "dlp",
14
+ "malicious-code",
15
+ "palo-alto"
16
+ ],
17
+ "author": "cdot65",
18
+ "license": "MIT",
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "https://github.com/cdot65/prisma-airs-cursor-hooks.git"
22
+ },
23
+ "homepage": "https://cdot65.github.io/prisma-airs-cursor-hooks/",
24
+ "bugs": {
25
+ "url": "https://github.com/cdot65/prisma-airs-cursor-hooks/issues"
26
+ },
27
+ "engines": {
28
+ "node": ">=18.0.0"
29
+ },
30
+ "bin": {
31
+ "prisma-airs-hooks": "dist/cli.js"
32
+ },
33
+ "files": [
34
+ "dist/",
35
+ "scripts/",
36
+ "airs-config.json",
37
+ "tsconfig.build.json",
38
+ "src/"
39
+ ],
40
+ "scripts": {
41
+ "test": "vitest run",
42
+ "test:watch": "vitest",
43
+ "build": "tsc -p tsconfig.build.json",
44
+ "prepare": "npm run build",
45
+ "typecheck": "tsc --noEmit",
46
+ "validate-connection": "tsx scripts/validate-connection.ts",
47
+ "validate-detection": "tsx scripts/validate-detection.ts",
48
+ "install-hooks": "tsx scripts/install-hooks.ts",
49
+ "uninstall-hooks": "tsx scripts/uninstall-hooks.ts",
50
+ "verify-hooks": "tsx scripts/verify-hooks.ts",
51
+ "stats": "tsx scripts/airs-stats.ts",
52
+ "docs:serve": "mkdocs serve",
53
+ "docs:build": "mkdocs build"
54
+ },
55
+ "dependencies": {
56
+ "@cdot65/prisma-airs-sdk": "^0.6.7",
57
+ "tsx": "^4.19.0"
58
+ },
59
+ "devDependencies": {
60
+ "@types/node": "^25.5.0",
61
+ "typescript": "^5.5.0",
62
+ "vitest": "^2.1.0"
63
+ }
64
+ }
@@ -0,0 +1,119 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * Display scan statistics from the AIRS log file.
4
+ * Run: npx tsx scripts/airs-stats.ts [--since 24h] [--json]
5
+ */
6
+ import { readFileSync, existsSync } from "node:fs";
7
+ import { resolve } from "node:path";
8
+
9
+ interface LogEntry {
10
+ timestamp: string;
11
+ event: string;
12
+ scan_id?: string;
13
+ direction?: string;
14
+ verdict?: string;
15
+ action_taken?: string;
16
+ latency_ms?: number;
17
+ detection_services_triggered?: string[];
18
+ error?: string | null;
19
+ }
20
+
21
+ function parseSince(arg: string): number {
22
+ const match = arg.match(/^(\d+)(h|d|m)$/);
23
+ if (!match) return 24 * 60 * 60 * 1000; // default 24h
24
+ const val = parseInt(match[1]);
25
+ const unit = match[2];
26
+ const multipliers: Record<string, number> = { m: 60_000, h: 3_600_000, d: 86_400_000 };
27
+ return val * (multipliers[unit] ?? 3_600_000);
28
+ }
29
+
30
+ function percentile(sorted: number[], p: number): number {
31
+ if (sorted.length === 0) return 0;
32
+ const idx = Math.ceil((p / 100) * sorted.length) - 1;
33
+ return sorted[Math.max(0, idx)];
34
+ }
35
+
36
+ function main() {
37
+ const args = process.argv.slice(2);
38
+ const jsonOutput = args.includes("--json");
39
+ const sinceIdx = args.indexOf("--since");
40
+ const sinceMs = sinceIdx >= 0 ? parseSince(args[sinceIdx + 1]) : 24 * 60 * 60 * 1000;
41
+
42
+ const logPath = resolve(process.cwd(), ".cursor", "hooks", "airs-scan.log");
43
+ if (!existsSync(logPath)) {
44
+ console.log("No log file found at", logPath);
45
+ process.exit(0);
46
+ }
47
+
48
+ const cutoff = new Date(Date.now() - sinceMs).toISOString();
49
+ const lines = readFileSync(logPath, "utf-8").trim().split("\n").filter(Boolean);
50
+ const entries: LogEntry[] = lines
51
+ .map((l) => { try { return JSON.parse(l); } catch { return null; } })
52
+ .filter((e): e is LogEntry => e !== null && e.timestamp >= cutoff);
53
+
54
+ const scans = entries.filter((e) => e.event === "scan_complete");
55
+ const prompts = scans.filter((e) => e.direction === "prompt");
56
+ const responses = scans.filter((e) => e.direction === "response");
57
+
58
+ const allowed = scans.filter((e) => e.action_taken === "allowed" || e.action_taken === "observed").length;
59
+ const blocked = scans.filter((e) => e.action_taken === "blocked").length;
60
+ const observed = scans.filter((e) => e.action_taken === "observed").length;
61
+ const errors = scans.filter((e) => e.action_taken === "error" || e.action_taken === "bypassed").length;
62
+
63
+ const detections: Record<string, number> = {};
64
+ for (const s of scans) {
65
+ for (const d of s.detection_services_triggered ?? []) {
66
+ detections[d] = (detections[d] ?? 0) + 1;
67
+ }
68
+ }
69
+
70
+ const latencies = scans.map((s) => s.latency_ms ?? 0).sort((a, b) => a - b);
71
+
72
+ const stats = {
73
+ total: scans.length,
74
+ prompts: prompts.length,
75
+ responses: responses.length,
76
+ allowed,
77
+ blocked,
78
+ observed,
79
+ errors,
80
+ detections,
81
+ latency: {
82
+ p50: percentile(latencies, 50),
83
+ p95: percentile(latencies, 95),
84
+ p99: percentile(latencies, 99),
85
+ },
86
+ };
87
+
88
+ if (jsonOutput) {
89
+ console.log(JSON.stringify(stats, null, 2));
90
+ return;
91
+ }
92
+
93
+ const pct = (n: number) => scans.length > 0 ? ((n / scans.length) * 100).toFixed(1) : "0.0";
94
+
95
+ console.log(`Prisma AIRS Hook Statistics`);
96
+ console.log(`${"─".repeat(45)}`);
97
+ console.log(`Total scans: ${stats.total}`);
98
+ console.log(`Prompts scanned: ${stats.prompts}`);
99
+ console.log(`Responses scanned: ${stats.responses}`);
100
+ console.log(`Verdicts:`);
101
+ console.log(` Allowed: ${stats.allowed} (${pct(stats.allowed)}%)`);
102
+ console.log(` Blocked: ${stats.blocked} (${pct(stats.blocked)}%)`);
103
+ console.log(` Observed: ${stats.observed} (${pct(stats.observed)}%)`);
104
+ console.log(` Errors/Bypassed: ${stats.errors} (${pct(stats.errors)}%)`);
105
+
106
+ if (Object.keys(detections).length > 0) {
107
+ console.log(`Detection triggers:`);
108
+ for (const [service, count] of Object.entries(detections)) {
109
+ console.log(` ${service}: ${count}`);
110
+ }
111
+ }
112
+
113
+ console.log(`Latency:`);
114
+ console.log(` p50: ${stats.latency.p50}ms`);
115
+ console.log(` p95: ${stats.latency.p95}ms`);
116
+ console.log(` p99: ${stats.latency.p99}ms`);
117
+ }
118
+
119
+ main();
@@ -0,0 +1,153 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * Install Prisma AIRS hooks into Cursor.
4
+ *
5
+ * Usage:
6
+ * npx tsx scripts/install-hooks.ts # project-level (.cursor/hooks.json)
7
+ * npx tsx scripts/install-hooks.ts --global # user-level (~/.cursor/hooks.json)
8
+ *
9
+ * Cursor reads hooks.json from multiple locations (all execute if present):
10
+ * 1. Project: <workspace>/.cursor/hooks.json
11
+ * 2. User: ~/.cursor/hooks.json
12
+ * 3. Enterprise: /Library/Application Support/Cursor/hooks.json (macOS)
13
+ * /etc/cursor/hooks.json (Linux)
14
+ */
15
+ import {
16
+ mkdirSync,
17
+ copyFileSync,
18
+ existsSync,
19
+ readFileSync,
20
+ writeFileSync,
21
+ } from "node:fs";
22
+ import { join, resolve } from "node:path";
23
+ import { homedir } from "node:os";
24
+ import type { CursorHooksConfig } from "../src/types.js";
25
+
26
+ const PROJECT_ROOT = resolve(import.meta.dirname, "..");
27
+ const isGlobal = process.argv.includes("--global");
28
+
29
+ // Determine target paths based on scope
30
+ const CURSOR_DIR = isGlobal
31
+ ? join(homedir(), ".cursor")
32
+ : join(process.cwd(), ".cursor");
33
+ const HOOKS_JSON_PATH = join(CURSOR_DIR, "hooks.json");
34
+ const AIRS_CONFIG_DIR = join(CURSOR_DIR, "hooks");
35
+ const AIRS_CONFIG_DEST = join(AIRS_CONFIG_DIR, "airs-config.json");
36
+
37
+ function main() {
38
+ const scope = isGlobal ? "global (user-level)" : "project-level";
39
+ console.log(`Installing Prisma AIRS Cursor hooks [${scope}]...\n`);
40
+
41
+ // ---- Validate environment ----
42
+ const apiKey = process.env.AIRS_API_KEY;
43
+ const apiEndpoint = process.env.AIRS_API_ENDPOINT;
44
+
45
+ if (!apiKey) {
46
+ console.warn(
47
+ " WARNING: AIRS_API_KEY is not set in your environment.\n" +
48
+ " Hooks will fail-open until this variable is available.\n" +
49
+ " Set it with: export AIRS_API_KEY=<your-x-pan-token>\n",
50
+ );
51
+ }
52
+ if (!apiEndpoint) {
53
+ console.warn(
54
+ " WARNING: AIRS_API_ENDPOINT is not set in your environment.\n" +
55
+ " Set it with: export AIRS_API_ENDPOINT=https://<region>.api.prismacloud.io\n",
56
+ );
57
+ }
58
+
59
+ // ---- Create directories ----
60
+ mkdirSync(AIRS_CONFIG_DIR, { recursive: true });
61
+
62
+ // ---- Write or merge hooks.json ----
63
+ const distDir = join(PROJECT_ROOT, "dist", "hooks");
64
+ const beforePromptCmd = `node "${join(distDir, "before-submit-prompt.js")}"`;
65
+ const afterResponseCmd = `node "${join(distDir, "after-agent-response.js")}"`;
66
+
67
+ // Verify dist exists
68
+ if (!existsSync(distDir)) {
69
+ console.error(" ERROR: dist/ not found. Run 'npm run build' first.\n");
70
+ process.exit(1);
71
+ }
72
+
73
+ let existingConfig: CursorHooksConfig | null = null;
74
+ if (existsSync(HOOKS_JSON_PATH)) {
75
+ try {
76
+ existingConfig = JSON.parse(readFileSync(HOOKS_JSON_PATH, "utf-8"));
77
+ console.log(` Found existing ${HOOKS_JSON_PATH} — merging AIRS hooks.\n`);
78
+ } catch {
79
+ console.warn(` WARNING: existing hooks.json is invalid JSON — overwriting.\n`);
80
+ }
81
+ }
82
+
83
+ const hooksConfig: CursorHooksConfig = existingConfig ?? {
84
+ version: 1,
85
+ hooks: {},
86
+ };
87
+
88
+ // Ensure our hooks are registered (idempotent — don't duplicate)
89
+ if (!hooksConfig.hooks.beforeSubmitPrompt) {
90
+ hooksConfig.hooks.beforeSubmitPrompt = [];
91
+ }
92
+ const hasPromptHook = hooksConfig.hooks.beforeSubmitPrompt.some(
93
+ (h) => h.command.includes("before-submit-prompt"),
94
+ );
95
+ if (!hasPromptHook) {
96
+ hooksConfig.hooks.beforeSubmitPrompt.push({
97
+ command: beforePromptCmd,
98
+ timeout: 5000,
99
+ failClosed: false,
100
+ });
101
+ }
102
+
103
+ if (!hooksConfig.hooks.afterAgentResponse) {
104
+ hooksConfig.hooks.afterAgentResponse = [];
105
+ }
106
+ const hasResponseHook = hooksConfig.hooks.afterAgentResponse.some(
107
+ (h) => h.command.includes("after-agent-response"),
108
+ );
109
+ if (!hasResponseHook) {
110
+ hooksConfig.hooks.afterAgentResponse.push({
111
+ command: afterResponseCmd,
112
+ timeout: 5000,
113
+ failClosed: false,
114
+ });
115
+ }
116
+
117
+ writeFileSync(HOOKS_JSON_PATH, JSON.stringify(hooksConfig, null, 2) + "\n", "utf-8");
118
+ console.log(` Wrote ${HOOKS_JSON_PATH}`);
119
+
120
+ // ---- Copy AIRS config template ----
121
+ if (!existsSync(AIRS_CONFIG_DEST)) {
122
+ copyFileSync(join(PROJECT_ROOT, "airs-config.json"), AIRS_CONFIG_DEST);
123
+ console.log(` Copied airs-config.json → ${AIRS_CONFIG_DEST}`);
124
+ } else {
125
+ console.log(` Config already exists at ${AIRS_CONFIG_DEST} (preserved)`);
126
+ }
127
+
128
+ // ---- Summary ----
129
+ console.log("\n✅ Hooks installed successfully\n");
130
+ if (isGlobal) {
131
+ console.log(" Scope: GLOBAL — hooks apply to ALL Cursor workspaces.\n");
132
+ console.log(` hooks.json: ${HOOKS_JSON_PATH}`);
133
+ console.log(` airs-config: ${AIRS_CONFIG_DEST}\n`);
134
+ } else {
135
+ console.log(" Scope: PROJECT — hooks apply only to this workspace.\n");
136
+ console.log(" Tip: use --global to install for all workspaces:\n");
137
+ console.log(" npm run install-hooks -- --global\n");
138
+ }
139
+ console.log(" Cursor will run these hooks automatically:");
140
+ console.log(" beforeSubmitPrompt → scans prompts via Prisma AIRS");
141
+ console.log(" afterAgentResponse → scans AI responses (incl. code extraction)\n");
142
+ console.log(" Environment variables (set in your shell profile):");
143
+ console.log(" AIRS_API_KEY — x-pan-token for AIRS API (required)");
144
+ console.log(" AIRS_API_ENDPOINT — regional base URL (optional, defaults to US)");
145
+ console.log(" AIRS_PROMPT_PROFILE — prompt security profile name (optional)");
146
+ console.log(" AIRS_RESPONSE_PROFILE — response security profile name (optional)\n");
147
+ console.log(" Next steps:");
148
+ console.log(" 1. npm run validate-connection");
149
+ console.log(" 2. npm run validate-detection");
150
+ console.log(" 3. Restart Cursor to pick up the new hooks.json");
151
+ }
152
+
153
+ main();