@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,99 @@
1
+ import type { ExtractedContent } from "./types.js";
2
+
3
+ /** Regex matching fenced code blocks: ```lang\n...\n``` */
4
+ const FENCED_BLOCK_RE = /^```(\w*)\n([\s\S]*?)^```$/gm;
5
+
6
+ /** Heuristic: high density of syntax chars suggests code */
7
+ const CODE_INDICATORS = [
8
+ /^import\s+/m,
9
+ /^from\s+\S+\s+import/m,
10
+ /^(?:function|const|let|var|class|def|async|export)\s/m,
11
+ /[{};]\s*$/m,
12
+ /^\s*(?:if|for|while|return|try|catch)\s*[\s(]/m,
13
+ ];
14
+
15
+ const CODE_CHAR_THRESHOLD = 0.15;
16
+
17
+ /** Extract code blocks from a mixed agent response */
18
+ export function extractCode(agentResponse: string): ExtractedContent {
19
+ const codeBlocks: string[] = [];
20
+ const languages: string[] = [];
21
+ let naturalLanguage = agentResponse;
22
+
23
+ // Extract fenced code blocks
24
+ const matches = [...agentResponse.matchAll(FENCED_BLOCK_RE)];
25
+
26
+ if (matches.length > 0) {
27
+ for (const match of matches) {
28
+ const lang = match[1] || "unknown";
29
+ const code = match[2];
30
+ languages.push(lang);
31
+ codeBlocks.push(code);
32
+ naturalLanguage = naturalLanguage.replace(match[0], "");
33
+ }
34
+ naturalLanguage = naturalLanguage.replace(/\n{3,}/g, "\n\n").trim();
35
+ return { naturalLanguage, codeBlocks, languages };
36
+ }
37
+
38
+ // Secondary: detect indented code blocks (4+ spaces or tab after blank line)
39
+ const lines = agentResponse.split("\n");
40
+ let inBlock = false;
41
+ let currentBlock: string[] = [];
42
+ const nlLines: string[] = [];
43
+
44
+ for (let i = 0; i < lines.length; i++) {
45
+ const line = lines[i];
46
+ const isIndented = /^(?: |\t)/.test(line);
47
+ const prevBlank = i === 0 || lines[i - 1].trim() === "";
48
+
49
+ if (isIndented && (inBlock || prevBlank)) {
50
+ inBlock = true;
51
+ currentBlock.push(line.replace(/^(?: |\t)/, ""));
52
+ } else {
53
+ if (inBlock && currentBlock.length > 0) {
54
+ codeBlocks.push(currentBlock.join("\n"));
55
+ languages.push("unknown");
56
+ currentBlock = [];
57
+ }
58
+ inBlock = false;
59
+ nlLines.push(line);
60
+ }
61
+ }
62
+ if (currentBlock.length > 0) {
63
+ codeBlocks.push(currentBlock.join("\n"));
64
+ languages.push("unknown");
65
+ }
66
+
67
+ if (codeBlocks.length > 0) {
68
+ return {
69
+ naturalLanguage: nlLines.join("\n").replace(/\n{3,}/g, "\n\n").trim(),
70
+ codeBlocks,
71
+ languages,
72
+ };
73
+ }
74
+
75
+ // Fallback heuristic: if response looks like code, treat it all as code
76
+ if (looksLikeCode(agentResponse)) {
77
+ return {
78
+ naturalLanguage: "",
79
+ codeBlocks: [agentResponse],
80
+ languages: ["unknown"],
81
+ };
82
+ }
83
+
84
+ return { naturalLanguage: agentResponse, codeBlocks: [], languages: [] };
85
+ }
86
+
87
+ /** Heuristic check: does the text look like source code? */
88
+ function looksLikeCode(text: string): boolean {
89
+ const indicatorHits = CODE_INDICATORS.filter((re) => re.test(text)).length;
90
+ if (indicatorHits >= 2) return true;
91
+
92
+ const syntaxChars = (text.match(/[{}();=<>[\]]/g) || []).length;
93
+ return syntaxChars / text.length > CODE_CHAR_THRESHOLD;
94
+ }
95
+
96
+ /** Join multiple code blocks for the code_response field */
97
+ export function joinCodeBlocks(blocks: string[]): string {
98
+ return blocks.join("\n\n---\n\n");
99
+ }
package/src/config.ts ADDED
@@ -0,0 +1,126 @@
1
+ import { readFileSync, existsSync } from "node:fs";
2
+ import { resolve, join } from "node:path";
3
+ import { homedir } from "node:os";
4
+ import type { AirsConfig, Mode } from "./types.js";
5
+
6
+ const VALID_MODES: Mode[] = ["observe", "enforce", "bypass"];
7
+ const DEFAULT_ENDPOINT = "https://service.api.aisecurity.paloaltonetworks.com";
8
+ const DEFAULT_PROMPT_PROFILE = "cursor-ide-prompt-profile";
9
+ const DEFAULT_RESPONSE_PROFILE = "cursor-ide-response-profile";
10
+
11
+ /** Resolve environment variable references like ${VAR_NAME} */
12
+ function resolveEnvVars(value: string): string {
13
+ return value.replace(/\$\{(\w+)\}/g, (_, name) => process.env[name] ?? "");
14
+ }
15
+
16
+ /** Validate a URL string */
17
+ function isValidUrl(url: string): boolean {
18
+ try {
19
+ new URL(url);
20
+ return true;
21
+ } catch {
22
+ return false;
23
+ }
24
+ }
25
+
26
+ /**
27
+ * Resolve the config file path by searching (in order):
28
+ * 1. Explicit path argument
29
+ * 2. .cursor/hooks/airs-config.json in CURSOR_PROJECT_DIR (project-level)
30
+ * 3. .cursor/hooks/airs-config.json in cwd
31
+ * 4. ~/.cursor/hooks/airs-config.json (global/user-level)
32
+ * 5. airs-config.json in cwd (project root fallback)
33
+ */
34
+ function resolveConfigPath(configPath?: string): string {
35
+ if (configPath) return configPath;
36
+
37
+ const candidates: string[] = [];
38
+
39
+ // Cursor injects CURSOR_PROJECT_DIR when running hooks
40
+ const cursorDir = process.env.CURSOR_PROJECT_DIR;
41
+ if (cursorDir) {
42
+ candidates.push(join(cursorDir, ".cursor", "hooks", "airs-config.json"));
43
+ }
44
+
45
+ candidates.push(
46
+ join(process.cwd(), ".cursor", "hooks", "airs-config.json"),
47
+ // Global/user-level config (for globally installed hooks)
48
+ join(homedir(), ".cursor", "hooks", "airs-config.json"),
49
+ resolve(process.cwd(), "airs-config.json"),
50
+ );
51
+
52
+ for (const candidate of candidates) {
53
+ if (existsSync(candidate)) return candidate;
54
+ }
55
+
56
+ // Return the first candidate for the error message
57
+ return candidates[0];
58
+ }
59
+
60
+ /** Load and validate AIRS configuration from a JSON file */
61
+ export function loadConfig(configPath?: string): AirsConfig {
62
+ const resolved = resolveConfigPath(configPath);
63
+
64
+ let raw: string;
65
+ try {
66
+ raw = readFileSync(resolved, "utf-8");
67
+ } catch {
68
+ throw new Error(`Failed to read config file: ${resolved}`);
69
+ }
70
+
71
+ let parsed: Record<string, unknown>;
72
+ try {
73
+ parsed = JSON.parse(raw);
74
+ } catch {
75
+ throw new Error(`Invalid JSON in config file: ${resolved}`);
76
+ }
77
+
78
+ const config = parsed as unknown as AirsConfig;
79
+
80
+ // Resolve env var references in endpoint, fall back to default US endpoint
81
+ config.endpoint = resolveEnvVars(config.endpoint);
82
+ if (!config.endpoint || config.endpoint === "${AIRS_API_ENDPOINT}") {
83
+ config.endpoint = DEFAULT_ENDPOINT;
84
+ }
85
+
86
+ // Resolve env var references in profile names, fall back to defaults
87
+ config.profiles.prompt = resolveEnvVars(config.profiles.prompt) || DEFAULT_PROMPT_PROFILE;
88
+ config.profiles.response = resolveEnvVars(config.profiles.response) || DEFAULT_RESPONSE_PROFILE;
89
+
90
+ // Validate mode
91
+ if (!VALID_MODES.includes(config.mode)) {
92
+ throw new Error(
93
+ `Invalid mode "${config.mode}". Must be one of: ${VALID_MODES.join(", ")}`,
94
+ );
95
+ }
96
+
97
+ // Validate endpoint URL
98
+ if (!isValidUrl(config.endpoint)) {
99
+ throw new Error(`Invalid endpoint URL: "${config.endpoint}"`);
100
+ }
101
+
102
+ // Validate API key env var is set
103
+ const apiKey = process.env[config.apiKeyEnvVar];
104
+ if (!apiKey || apiKey.trim() === "") {
105
+ throw new Error(
106
+ `API key environment variable "${config.apiKeyEnvVar}" is not set or empty`,
107
+ );
108
+ }
109
+
110
+ // Validate profiles
111
+ if (!config.profiles?.prompt || !config.profiles?.response) {
112
+ throw new Error("Config must include profiles.prompt and profiles.response");
113
+ }
114
+
115
+ // Validate timeout
116
+ if (typeof config.timeout_ms !== "number" || config.timeout_ms <= 0) {
117
+ throw new Error("timeout_ms must be a positive number");
118
+ }
119
+
120
+ return config;
121
+ }
122
+
123
+ /** Get the API key value from the environment */
124
+ export function getApiKey(config: AirsConfig): string {
125
+ return process.env[config.apiKeyEnvVar] ?? "";
126
+ }
@@ -0,0 +1,48 @@
1
+ /** Enforcement action per detection service */
2
+ export type EnforcementAction = "block" | "mask" | "allow";
3
+
4
+ /** Default enforcement configuration */
5
+ export const DEFAULT_ENFORCEMENT: Record<string, EnforcementAction> = {
6
+ prompt_injection: "block",
7
+ dlp: "block",
8
+ malicious_code: "block",
9
+ url_categorization: "block",
10
+ toxicity: "block",
11
+ custom_topic: "block",
12
+ };
13
+
14
+ interface Finding {
15
+ detection_service: string;
16
+ verdict: string;
17
+ detail: string;
18
+ }
19
+
20
+ /** Determine the enforcement action for a set of findings */
21
+ export function getEnforcementAction(
22
+ findings: Finding[],
23
+ enforcement: Record<string, EnforcementAction> = DEFAULT_ENFORCEMENT,
24
+ ): EnforcementAction {
25
+ let hasMask = false;
26
+
27
+ for (const finding of findings) {
28
+ const action = enforcement[finding.detection_service] ?? "block";
29
+ if (action === "block") return "block";
30
+ if (action === "mask") hasMask = true;
31
+ }
32
+
33
+ return hasMask ? "mask" : "allow";
34
+ }
35
+
36
+ /** Simple masking: replace sensitive substrings with asterisks */
37
+ export function maskContent(
38
+ content: string,
39
+ patterns: string[],
40
+ ): string {
41
+ let masked = content;
42
+ for (const pattern of patterns) {
43
+ if (pattern && masked.includes(pattern)) {
44
+ masked = masked.replaceAll(pattern, "*".repeat(pattern.length));
45
+ }
46
+ }
47
+ return masked;
48
+ }
@@ -0,0 +1,84 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Cursor hook: afterAgentResponse
4
+ *
5
+ * Intercepts the AI agent's response before it is displayed to the developer.
6
+ * Extracts code blocks for dedicated malicious code scanning via code_response.
7
+ *
8
+ * Cursor contract:
9
+ * stdin → JSON { response, conversation_id, model, user_email, ... }
10
+ * stdout → JSON { permission: "allow"|"deny", userMessage?, agentMessage? }
11
+ * exit 0 = success, exit 2 = deny
12
+ * stderr → debug logs (visible in Cursor "Hooks" output panel)
13
+ */
14
+ import { loadConfig } from "../config.js";
15
+ import { Logger } from "../logger.js";
16
+ import { scanResponse } from "../scanner.js";
17
+ import type { AfterAgentResponseInput, CursorHookOutput } from "../types.js";
18
+
19
+ /** Read all of stdin as a string */
20
+ async function readStdin(): Promise<string> {
21
+ const chunks: Buffer[] = [];
22
+ for await (const chunk of process.stdin) {
23
+ chunks.push(Buffer.from(chunk));
24
+ }
25
+ return Buffer.concat(chunks).toString("utf-8");
26
+ }
27
+
28
+ /** Write a Cursor hook response to stdout */
29
+ function respond(output: CursorHookOutput): void {
30
+ process.stdout.write(JSON.stringify(output) + "\n");
31
+ }
32
+
33
+ /** Allow the response through (fail-open default) */
34
+ function allowThrough(message?: string): void {
35
+ const output: CursorHookOutput = { permission: "allow" };
36
+ if (message) output.userMessage = message;
37
+ respond(output);
38
+ }
39
+
40
+ async function main(): Promise<void> {
41
+ const raw = await readStdin();
42
+
43
+ let input: AfterAgentResponseInput;
44
+ try {
45
+ input = JSON.parse(raw);
46
+ } catch {
47
+ console.error("[AIRS] Failed to parse hook stdin as JSON, allowing through.");
48
+ allowThrough();
49
+ return;
50
+ }
51
+
52
+ // Expose Cursor-provided identity as env var for downstream use
53
+ if (input.user_email) {
54
+ process.env.CURSOR_USER_EMAIL = input.user_email;
55
+ }
56
+
57
+ let config;
58
+ try {
59
+ config = loadConfig();
60
+ } catch (err) {
61
+ console.error(`[AIRS] Config error: ${err}`);
62
+ allowThrough("Prisma AIRS: configuration error — scan skipped (fail-open).");
63
+ return;
64
+ }
65
+
66
+ const logger = new Logger(config.logging.path, config.logging.include_content);
67
+ const result = await scanResponse(config, input.text, logger);
68
+
69
+ if (result.action === "block") {
70
+ const output: CursorHookOutput = {
71
+ permission: "deny",
72
+ userMessage: result.message ?? "Prisma AIRS blocked this response.",
73
+ };
74
+ respond(output);
75
+ process.exit(2);
76
+ }
77
+
78
+ allowThrough(result.message);
79
+ }
80
+
81
+ main().catch((err) => {
82
+ console.error(`[AIRS] Unhandled hook error: ${err}`);
83
+ allowThrough();
84
+ });
@@ -0,0 +1,86 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Cursor hook: beforeSubmitPrompt
4
+ *
5
+ * Intercepts the developer's prompt before it is sent to the AI model.
6
+ * Reads structured JSON from stdin, scans the prompt via Prisma AIRS,
7
+ * and writes a JSON response to stdout telling Cursor to allow or deny.
8
+ *
9
+ * Cursor contract:
10
+ * stdin → JSON { prompt, conversation_id, model, user_email, ... }
11
+ * stdout → JSON { permission: "allow"|"deny", userMessage?, agentMessage? }
12
+ * exit 0 = success, exit 2 = deny
13
+ * stderr → debug logs (visible in Cursor "Hooks" output panel)
14
+ */
15
+ import { loadConfig } from "../config.js";
16
+ import { Logger } from "../logger.js";
17
+ import { scanPrompt } from "../scanner.js";
18
+ import type { BeforeSubmitPromptInput, BeforeSubmitPromptOutput } from "../types.js";
19
+
20
+ /** Read all of stdin as a string */
21
+ async function readStdin(): Promise<string> {
22
+ const chunks: Buffer[] = [];
23
+ for await (const chunk of process.stdin) {
24
+ chunks.push(Buffer.from(chunk));
25
+ }
26
+ return Buffer.concat(chunks).toString("utf-8");
27
+ }
28
+
29
+ /** Write a beforeSubmitPrompt response to stdout */
30
+ function respond(output: BeforeSubmitPromptOutput): void {
31
+ process.stdout.write(JSON.stringify(output) + "\n");
32
+ }
33
+
34
+ /** Allow the prompt through (fail-open default) */
35
+ function allowThrough(): void {
36
+ respond({ continue: true });
37
+ }
38
+
39
+ /** Block the prompt */
40
+ function blockPrompt(message: string): void {
41
+ respond({ continue: false, user_message: message });
42
+ }
43
+
44
+ async function main(): Promise<void> {
45
+ const raw = await readStdin();
46
+
47
+ let input: BeforeSubmitPromptInput;
48
+ try {
49
+ input = JSON.parse(raw);
50
+ } catch {
51
+ // stdin wasn't valid JSON — fail-open
52
+ console.error("[AIRS] Failed to parse hook stdin as JSON, allowing through.");
53
+ allowThrough();
54
+ return;
55
+ }
56
+
57
+ // Expose Cursor-provided identity as env var for downstream use
58
+ if (input.user_email) {
59
+ process.env.CURSOR_USER_EMAIL = input.user_email;
60
+ }
61
+
62
+ let config;
63
+ try {
64
+ config = loadConfig();
65
+ } catch (err) {
66
+ console.error(`[AIRS] Config error: ${err}`);
67
+ allowThrough();
68
+ return;
69
+ }
70
+
71
+ const logger = new Logger(config.logging.path, config.logging.include_content);
72
+ const result = await scanPrompt(config, input.prompt, logger);
73
+
74
+ if (result.action === "block") {
75
+ blockPrompt(result.message ?? "Prisma AIRS blocked this prompt.");
76
+ return;
77
+ }
78
+
79
+ allowThrough();
80
+ }
81
+
82
+ main().catch((err) => {
83
+ // Fail-open: never block the developer on unhandled errors
84
+ console.error(`[AIRS] Unhandled hook error: ${err}`);
85
+ allowThrough();
86
+ });
@@ -0,0 +1,27 @@
1
+ import { existsSync, statSync, renameSync, unlinkSync } from "node:fs";
2
+
3
+ const MAX_SIZE_BYTES = 10 * 1024 * 1024; // 10MB
4
+ const MAX_ROTATIONS = 5;
5
+
6
+ /** Rotate log file if it exceeds MAX_SIZE_BYTES */
7
+ export function rotateIfNeeded(logPath: string): void {
8
+ try {
9
+ if (!existsSync(logPath)) return;
10
+ const stat = statSync(logPath);
11
+ if (stat.size < MAX_SIZE_BYTES) return;
12
+
13
+ // Shift existing rotated files
14
+ for (let i = MAX_ROTATIONS; i >= 1; i--) {
15
+ const from = i === 1 ? logPath : `${logPath}.${i - 1}`;
16
+ const to = `${logPath}.${i}`;
17
+ if (existsSync(from)) {
18
+ if (i === MAX_ROTATIONS && existsSync(to)) {
19
+ unlinkSync(to);
20
+ }
21
+ renameSync(from, to);
22
+ }
23
+ }
24
+ } catch {
25
+ // Never crash on rotation failure
26
+ }
27
+ }
package/src/logger.ts ADDED
@@ -0,0 +1,55 @@
1
+ import { appendFileSync, mkdirSync } from "node:fs";
2
+ import { dirname } from "node:path";
3
+ import type { ScanLogEntry } from "./types.js";
4
+ import { rotateIfNeeded } from "./log-rotation.js";
5
+
6
+ /** Structured JSON Lines logger — appends one JSON object per line */
7
+ export class Logger {
8
+ private logPath: string;
9
+ private includeContent: boolean;
10
+ private writeCount = 0;
11
+
12
+ constructor(logPath: string, includeContent = false) {
13
+ this.logPath = logPath;
14
+ this.includeContent = includeContent;
15
+ }
16
+
17
+ /** Log a scan result entry */
18
+ logScan(entry: Omit<ScanLogEntry, "timestamp">): void {
19
+ const full: ScanLogEntry = {
20
+ timestamp: new Date().toISOString(),
21
+ ...entry,
22
+ };
23
+
24
+ // Strip content field unless debug mode
25
+ if (!this.includeContent) {
26
+ delete full.content;
27
+ }
28
+
29
+ this.write(full);
30
+ }
31
+
32
+ /** Log a generic event */
33
+ logEvent(event: string, data: Record<string, unknown> = {}): void {
34
+ this.write({
35
+ timestamp: new Date().toISOString(),
36
+ event,
37
+ ...data,
38
+ });
39
+ }
40
+
41
+ private write(entry: object): void {
42
+ try {
43
+ // Check for log rotation every 100 writes to avoid stat() overhead
44
+ this.writeCount++;
45
+ if (this.writeCount % 100 === 0) {
46
+ rotateIfNeeded(this.logPath);
47
+ }
48
+
49
+ mkdirSync(dirname(this.logPath), { recursive: true });
50
+ appendFileSync(this.logPath, JSON.stringify(entry) + "\n", "utf-8");
51
+ } catch {
52
+ // Never crash the hook on log failure
53
+ }
54
+ }
55
+ }