@oculisecurity/cli 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.
- package/LICENSE.txt +201 -0
- package/README.md +67 -0
- package/dist/cli.d.ts +18 -0
- package/dist/cli.js +565 -0
- package/dist/commands/init.d.ts +14 -0
- package/dist/commands/init.js +135 -0
- package/dist/commands/report.d.ts +33 -0
- package/dist/commands/report.js +145 -0
- package/dist/commands/serve.d.ts +27 -0
- package/dist/commands/serve.js +163 -0
- package/dist/commands/tail.d.ts +7 -0
- package/dist/commands/tail.js +211 -0
- package/dist/commands/uninstall.d.ts +13 -0
- package/dist/commands/uninstall.js +111 -0
- package/dist/config.d.ts +17 -0
- package/dist/config.js +90 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +35 -0
- package/dist/init.d.ts +9 -0
- package/dist/init.js +50 -0
- package/dist/install/claude-code.d.ts +13 -0
- package/dist/install/claude-code.js +118 -0
- package/dist/install/cursor.d.ts +13 -0
- package/dist/install/cursor.js +119 -0
- package/dist/install/detect.d.ts +5 -0
- package/dist/install/detect.js +64 -0
- package/dist/middleware/auth.d.ts +15 -0
- package/dist/middleware/auth.js +116 -0
- package/dist/routes/adapters/claude-code.d.ts +38 -0
- package/dist/routes/adapters/claude-code.js +125 -0
- package/dist/routes/adapters/cursor.d.ts +21 -0
- package/dist/routes/adapters/cursor.js +139 -0
- package/dist/routes/adapters/index.d.ts +16 -0
- package/dist/routes/adapters/index.js +56 -0
- package/dist/routes/adapters/router.d.ts +31 -0
- package/dist/routes/adapters/router.js +97 -0
- package/dist/routes/adapters/schema.d.ts +141 -0
- package/dist/routes/adapters/schema.js +83 -0
- package/dist/routes/adapters/windsurf.d.ts +6 -0
- package/dist/routes/adapters/windsurf.js +48 -0
- package/dist/routes/admin.d.ts +15 -0
- package/dist/routes/admin.js +399 -0
- package/dist/routes/call.d.ts +13 -0
- package/dist/routes/call.js +68 -0
- package/dist/routes/events.d.ts +7 -0
- package/dist/routes/events.js +125 -0
- package/dist/routes/health.d.ts +2 -0
- package/dist/routes/health.js +12 -0
- package/dist/routes/hooks.d.ts +11 -0
- package/dist/routes/hooks.js +166 -0
- package/dist/routes/mcp.d.ts +10 -0
- package/dist/routes/mcp.js +170 -0
- package/dist/routes/openai-tools.d.ts +9 -0
- package/dist/routes/openai-tools.js +121 -0
- package/dist/server.d.ts +11 -0
- package/dist/server.js +118 -0
- package/dist/services/audit.d.ts +92 -0
- package/dist/services/audit.js +388 -0
- package/dist/services/data-dir.d.ts +7 -0
- package/dist/services/data-dir.js +61 -0
- package/dist/services/local-policy-templates.d.ts +9 -0
- package/dist/services/local-policy-templates.js +47 -0
- package/dist/services/local-policy.d.ts +39 -0
- package/dist/services/local-policy.js +172 -0
- package/dist/services/policy-store.d.ts +82 -0
- package/dist/services/policy-store.js +331 -0
- package/dist/services/policy.d.ts +8 -0
- package/dist/services/policy.js +126 -0
- package/dist/services/ratelimit.d.ts +26 -0
- package/dist/services/ratelimit.js +60 -0
- package/dist/services/sanitizer.d.ts +9 -0
- package/dist/services/sanitizer.js +73 -0
- package/dist/services/sqlite-loader.d.ts +4 -0
- package/dist/services/sqlite-loader.js +16 -0
- package/dist/services/telemetry-log.d.ts +76 -0
- package/dist/services/telemetry-log.js +260 -0
- package/dist/services/tool-executor.d.ts +46 -0
- package/dist/services/tool-executor.js +167 -0
- package/dist/services/upstream.d.ts +18 -0
- package/dist/services/upstream.js +72 -0
- package/dist/types.d.ts +112 -0
- package/dist/types.js +3 -0
- package/package.json +72 -0
- package/public/favicon.svg +4 -0
- package/public/index.html +3893 -0
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export interface InstallOpts {
|
|
2
|
+
/** Override the home directory (tests only). Production callers should omit this. */
|
|
3
|
+
home?: string;
|
|
4
|
+
}
|
|
5
|
+
export interface InstallResult {
|
|
6
|
+
path: string;
|
|
7
|
+
added: string[];
|
|
8
|
+
removed: string[];
|
|
9
|
+
}
|
|
10
|
+
export declare function installCursor(opts?: InstallOpts): InstallResult;
|
|
11
|
+
export declare function uninstallCursor(opts?: InstallOpts): InstallResult;
|
|
12
|
+
/** Does ~/.cursor/hooks.json still have any oculi-marked hook? */
|
|
13
|
+
export declare function hasOculiHooks(home?: string): boolean;
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.installCursor = installCursor;
|
|
37
|
+
exports.uninstallCursor = uninstallCursor;
|
|
38
|
+
exports.hasOculiHooks = hasOculiHooks;
|
|
39
|
+
const fs = __importStar(require("fs"));
|
|
40
|
+
const path = __importStar(require("path"));
|
|
41
|
+
const os = __importStar(require("os"));
|
|
42
|
+
/** Cursor hook events managed by Oculi. */
|
|
43
|
+
const OCULI_EVENTS = [
|
|
44
|
+
'beforeShellExecution',
|
|
45
|
+
'afterFileEdit',
|
|
46
|
+
'beforeReadFile',
|
|
47
|
+
'beforeMCPExecution',
|
|
48
|
+
'stop',
|
|
49
|
+
];
|
|
50
|
+
const OCULI_MARKER = 'oculi emit cursor';
|
|
51
|
+
function resolveConfigPath(opts) {
|
|
52
|
+
return path.join(opts.home ?? os.homedir(), '.cursor', 'hooks.json');
|
|
53
|
+
}
|
|
54
|
+
function readConfig(filePath) {
|
|
55
|
+
if (!fs.existsSync(filePath)) {
|
|
56
|
+
return { version: 1, hooks: {} };
|
|
57
|
+
}
|
|
58
|
+
const raw = fs.readFileSync(filePath, 'utf8');
|
|
59
|
+
const parsed = JSON.parse(raw);
|
|
60
|
+
return {
|
|
61
|
+
version: parsed.version ?? 1,
|
|
62
|
+
hooks: parsed.hooks ?? {},
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
function installCursor(opts = {}) {
|
|
66
|
+
const configPath = resolveConfigPath(opts);
|
|
67
|
+
const config = readConfig(configPath);
|
|
68
|
+
const added = [];
|
|
69
|
+
for (const event of OCULI_EVENTS) {
|
|
70
|
+
const entries = config.hooks[event] ?? [];
|
|
71
|
+
const alreadyInstalled = entries.some((e) => e.command.includes(OCULI_MARKER));
|
|
72
|
+
if (!alreadyInstalled) {
|
|
73
|
+
config.hooks[event] = [...entries, { command: `oculi emit cursor ${event}` }];
|
|
74
|
+
added.push(event);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
if (added.length > 0) {
|
|
78
|
+
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
|
79
|
+
fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n', 'utf8');
|
|
80
|
+
}
|
|
81
|
+
return { path: configPath, added, removed: [] };
|
|
82
|
+
}
|
|
83
|
+
function uninstallCursor(opts = {}) {
|
|
84
|
+
const configPath = resolveConfigPath(opts);
|
|
85
|
+
const config = readConfig(configPath);
|
|
86
|
+
const removed = [];
|
|
87
|
+
for (const event of Object.keys(config.hooks)) {
|
|
88
|
+
const before = config.hooks[event];
|
|
89
|
+
const after = before.filter((e) => !e.command.includes(OCULI_MARKER));
|
|
90
|
+
if (after.length < before.length) {
|
|
91
|
+
removed.push(...before.filter((e) => e.command.includes(OCULI_MARKER)).map(() => event));
|
|
92
|
+
if (after.length === 0) {
|
|
93
|
+
delete config.hooks[event];
|
|
94
|
+
}
|
|
95
|
+
else {
|
|
96
|
+
config.hooks[event] = after;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
if (removed.length > 0) {
|
|
101
|
+
if (Object.keys(config.hooks).length === 0) {
|
|
102
|
+
fs.writeFileSync(configPath, JSON.stringify({ version: config.version, hooks: {} }, null, 2) + '\n', 'utf8');
|
|
103
|
+
}
|
|
104
|
+
else {
|
|
105
|
+
fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n', 'utf8');
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return { path: configPath, added: [], removed };
|
|
109
|
+
}
|
|
110
|
+
/** Does ~/.cursor/hooks.json still have any oculi-marked hook? */
|
|
111
|
+
function hasOculiHooks(home) {
|
|
112
|
+
const configPath = resolveConfigPath({ home });
|
|
113
|
+
const config = readConfig(configPath);
|
|
114
|
+
for (const entries of Object.values(config.hooks)) {
|
|
115
|
+
if (entries.some((e) => e.command.includes(OCULI_MARKER)))
|
|
116
|
+
return true;
|
|
117
|
+
}
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export declare const AGENTS: readonly ["claude-code", "cursor"];
|
|
2
|
+
export type Agent = (typeof AGENTS)[number];
|
|
3
|
+
export declare const BINARIES: Record<Agent, string>;
|
|
4
|
+
export declare function isAgentInstalled(agent: Agent): boolean;
|
|
5
|
+
export declare function detectAgents(): Agent[];
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.BINARIES = exports.AGENTS = void 0;
|
|
37
|
+
exports.isAgentInstalled = isAgentInstalled;
|
|
38
|
+
exports.detectAgents = detectAgents;
|
|
39
|
+
const fs = __importStar(require("fs"));
|
|
40
|
+
const path = __importStar(require("path"));
|
|
41
|
+
exports.AGENTS = ['claude-code', 'cursor'];
|
|
42
|
+
exports.BINARIES = {
|
|
43
|
+
'claude-code': 'claude',
|
|
44
|
+
cursor: 'cursor',
|
|
45
|
+
};
|
|
46
|
+
const WIN_EXTS = ['.exe', '.cmd', '.bat'];
|
|
47
|
+
function pathEntries() {
|
|
48
|
+
const sep = process.platform === 'win32' ? ';' : ':';
|
|
49
|
+
return (process.env.PATH ?? '').split(sep).filter(Boolean);
|
|
50
|
+
}
|
|
51
|
+
function existsInDir(dir, bin) {
|
|
52
|
+
if (process.platform === 'win32') {
|
|
53
|
+
if (WIN_EXTS.some((e) => fs.existsSync(path.join(dir, bin + e))))
|
|
54
|
+
return true;
|
|
55
|
+
}
|
|
56
|
+
return fs.existsSync(path.join(dir, bin));
|
|
57
|
+
}
|
|
58
|
+
function isAgentInstalled(agent) {
|
|
59
|
+
const bin = exports.BINARIES[agent];
|
|
60
|
+
return pathEntries().some((dir) => existsInDir(dir, bin));
|
|
61
|
+
}
|
|
62
|
+
function detectAgents() {
|
|
63
|
+
return exports.AGENTS.filter(isAgentInstalled);
|
|
64
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { FastifyRequest, FastifyInstance } from 'fastify';
|
|
2
|
+
import { AuthenticatedActor } from '../types';
|
|
3
|
+
/**
|
|
4
|
+
* Resolve an AuthenticatedActor from a request — used by routes that need
|
|
5
|
+
* auth but handle it themselves (e.g. /mcp which also accepts ?token= for SSE).
|
|
6
|
+
* Reads from request.actor (set by middleware) or falls back to the ?token=
|
|
7
|
+
* query parameter (browsers can't set headers for SSE connections).
|
|
8
|
+
*/
|
|
9
|
+
export declare function resolveActor(request: FastifyRequest, jwtSecret: string): AuthenticatedActor | null;
|
|
10
|
+
declare module 'fastify' {
|
|
11
|
+
interface FastifyRequest {
|
|
12
|
+
actor: AuthenticatedActor;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
export declare function registerAuthMiddleware(app: FastifyInstance, jwtSecret: string): void;
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.resolveActor = resolveActor;
|
|
7
|
+
exports.registerAuthMiddleware = registerAuthMiddleware;
|
|
8
|
+
const jsonwebtoken_1 = __importDefault(require("jsonwebtoken"));
|
|
9
|
+
/**
|
|
10
|
+
* Resolve an AuthenticatedActor from a request — used by routes that need
|
|
11
|
+
* auth but handle it themselves (e.g. /mcp which also accepts ?token= for SSE).
|
|
12
|
+
* Reads from request.actor (set by middleware) or falls back to the ?token=
|
|
13
|
+
* query parameter (browsers can't set headers for SSE connections).
|
|
14
|
+
*/
|
|
15
|
+
function resolveActor(request, jwtSecret) {
|
|
16
|
+
if (request.actor?.actor)
|
|
17
|
+
return request.actor;
|
|
18
|
+
const query = request.query;
|
|
19
|
+
const token = query.token;
|
|
20
|
+
if (!token)
|
|
21
|
+
return null;
|
|
22
|
+
try {
|
|
23
|
+
const payload = jsonwebtoken_1.default.verify(token, jwtSecret);
|
|
24
|
+
if (!payload.actor || !payload.orgId || !Array.isArray(payload.roles))
|
|
25
|
+
return null;
|
|
26
|
+
return { sub: payload.sub, actor: payload.actor, orgId: payload.orgId, roles: payload.roles };
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
function registerAuthMiddleware(app, jwtSecret) {
|
|
33
|
+
// Decorate request so TypeScript knows the property exists
|
|
34
|
+
app.decorateRequest('actor', null);
|
|
35
|
+
// Prehandler hook: runs on every request before route handlers
|
|
36
|
+
app.addHook('preHandler', async (request, reply) => {
|
|
37
|
+
// No-auth mode (e.g. `oculi serve` on localhost): inject a synthetic admin
|
|
38
|
+
// actor on every request and skip all JWT logic. Routes that gate on
|
|
39
|
+
// `request.actor` (admin APIs, MCP) still work; protected proxy endpoints
|
|
40
|
+
// (/v1/call etc.) are reachable, which is intended for local dev.
|
|
41
|
+
if (!jwtSecret) {
|
|
42
|
+
request.actor = {
|
|
43
|
+
sub: 'local',
|
|
44
|
+
actor: 'local-dev',
|
|
45
|
+
orgId: 'default',
|
|
46
|
+
roles: ['admin'],
|
|
47
|
+
};
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
// Skip auth for health check and all /admin static assets.
|
|
51
|
+
// The admin HTML is served unauthenticated; the /admin/logs and /admin/upstreams
|
|
52
|
+
// API routes enforce auth themselves via request.actor checks.
|
|
53
|
+
if (request.url === '/health' ||
|
|
54
|
+
request.url === '/favicon.ico' ||
|
|
55
|
+
request.url === '/favicon.svg' ||
|
|
56
|
+
request.url === '/v1/events' ||
|
|
57
|
+
request.url === '/v1/hooks') {
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
// For /admin and /mcp routes: parse JWT if present but don't require it.
|
|
61
|
+
// /admin static assets need no auth; API routes check request.actor internally.
|
|
62
|
+
// /mcp handles auth inside the route (also accepts ?token= for SSE browser connections).
|
|
63
|
+
if (request.url.startsWith('/admin') || request.url.startsWith('/api/admin') || request.url.startsWith('/mcp')) {
|
|
64
|
+
const header = request.headers['authorization'];
|
|
65
|
+
if (header?.startsWith('Bearer ')) {
|
|
66
|
+
try {
|
|
67
|
+
const p = jsonwebtoken_1.default.verify(header.slice(7), jwtSecret);
|
|
68
|
+
if (p.actor && p.orgId && Array.isArray(p.roles)) {
|
|
69
|
+
request.actor = { sub: p.sub, actor: p.actor, orgId: p.orgId, roles: p.roles };
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
catch { /* token invalid — continue without auth */ }
|
|
73
|
+
}
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
const authHeader = request.headers['authorization'];
|
|
77
|
+
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
|
78
|
+
reply.code(401).send({
|
|
79
|
+
error: 'Missing or malformed Authorization header. Expected: Bearer <token>',
|
|
80
|
+
code: 'MISSING_TOKEN',
|
|
81
|
+
});
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
const token = authHeader.slice(7);
|
|
85
|
+
let payload;
|
|
86
|
+
try {
|
|
87
|
+
payload = jsonwebtoken_1.default.verify(token, jwtSecret);
|
|
88
|
+
}
|
|
89
|
+
catch (err) {
|
|
90
|
+
const message = err instanceof jsonwebtoken_1.default.TokenExpiredError
|
|
91
|
+
? 'Token has expired'
|
|
92
|
+
: err instanceof jsonwebtoken_1.default.JsonWebTokenError
|
|
93
|
+
? 'Invalid token signature'
|
|
94
|
+
: 'Token verification failed';
|
|
95
|
+
reply.code(401).send({
|
|
96
|
+
error: message,
|
|
97
|
+
code: 'INVALID_TOKEN',
|
|
98
|
+
});
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
// Validate required fields
|
|
102
|
+
if (!payload.actor || !payload.orgId || !Array.isArray(payload.roles)) {
|
|
103
|
+
reply.code(401).send({
|
|
104
|
+
error: 'Token payload missing required fields (actor, orgId, roles)',
|
|
105
|
+
code: 'MALFORMED_TOKEN',
|
|
106
|
+
});
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
request.actor = {
|
|
110
|
+
sub: payload.sub,
|
|
111
|
+
actor: payload.actor,
|
|
112
|
+
orgId: payload.orgId,
|
|
113
|
+
roles: payload.roles,
|
|
114
|
+
};
|
|
115
|
+
});
|
|
116
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { OculiEvent } from './schema';
|
|
2
|
+
/**
|
|
3
|
+
* Parse a Claude Code hook event from raw stdin JSON and the hook event name.
|
|
4
|
+
*
|
|
5
|
+
* This is the single source of truth for all Claude Code payload parsing.
|
|
6
|
+
* The HTTP adapter's normalize() delegates here so that nothing outside this
|
|
7
|
+
* module ever needs to understand Claude Code's wire format.
|
|
8
|
+
*
|
|
9
|
+
* @param stdin Raw JSON string sent to the hook's stdin by Claude Code.
|
|
10
|
+
* @param hookEvent Hook event name from the Claude Code runtime
|
|
11
|
+
* (e.g. 'PreToolUse', 'PostToolUse').
|
|
12
|
+
*/
|
|
13
|
+
export declare function parseClaudeCodeEvent(stdin: string, hookEvent: string): OculiEvent;
|
|
14
|
+
/**
|
|
15
|
+
* Detect whether a parsed request body came from Claude Code.
|
|
16
|
+
* Detection key: tool_name + session_id (Cursor uses conversation_id instead).
|
|
17
|
+
*/
|
|
18
|
+
export declare function detect(body: Record<string, unknown>): boolean;
|
|
19
|
+
/**
|
|
20
|
+
* Normalize a pre-parsed HTTP request body into an OculiEvent.
|
|
21
|
+
* Delegates all parsing logic to parseClaudeCodeEvent.
|
|
22
|
+
*/
|
|
23
|
+
export declare function normalize(body: Record<string, unknown>): OculiEvent;
|
|
24
|
+
/**
|
|
25
|
+
* Output shapes follow the Claude Code hook spec at
|
|
26
|
+
* https://code.claude.com/docs/en/hooks.
|
|
27
|
+
*
|
|
28
|
+
* - PreToolUse: blocking via `hookSpecificOutput.permissionDecision`.
|
|
29
|
+
* - PostToolUse: cannot block the tool (already ran); top-level `decision: "block"`
|
|
30
|
+
* only prevents Claude from continuing to the next turn.
|
|
31
|
+
* - Stop / others: empty `{}` = allow / default behavior. We never block Stop.
|
|
32
|
+
*
|
|
33
|
+
* All shapes are emitted with exit code 0; Claude Code ignores stdout on exit 2.
|
|
34
|
+
*/
|
|
35
|
+
export declare function formatAllow(_event: OculiEvent): string;
|
|
36
|
+
export declare function formatDeny(reason: string, event: OculiEvent): string;
|
|
37
|
+
export declare function formatWarn(reason: string, event: OculiEvent): string;
|
|
38
|
+
export declare function denyStatusCode(): number;
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.parseClaudeCodeEvent = parseClaudeCodeEvent;
|
|
4
|
+
exports.detect = detect;
|
|
5
|
+
exports.normalize = normalize;
|
|
6
|
+
exports.formatAllow = formatAllow;
|
|
7
|
+
exports.formatDeny = formatDeny;
|
|
8
|
+
exports.formatWarn = formatWarn;
|
|
9
|
+
exports.denyStatusCode = denyStatusCode;
|
|
10
|
+
/**
|
|
11
|
+
* Adapter for Claude Code PreToolUse / PostToolUse hook payloads.
|
|
12
|
+
*
|
|
13
|
+
* Wire format (sent to the hook's stdin by Claude Code):
|
|
14
|
+
* { hook_event_name, tool_name, tool_input, tool_response, session_id, cwd, ... }
|
|
15
|
+
*
|
|
16
|
+
* Block response (written to stdout):
|
|
17
|
+
* { "decision": "block", "reason": "..." }
|
|
18
|
+
*/
|
|
19
|
+
/**
|
|
20
|
+
* Normalize Claude Code tool names to standard cross-IDE identifiers.
|
|
21
|
+
* Unmatched tool names pass through unchanged (preserving specificity).
|
|
22
|
+
*/
|
|
23
|
+
const TOOL_NORMALIZATIONS = {
|
|
24
|
+
Bash: 'shell',
|
|
25
|
+
Read: 'file_read',
|
|
26
|
+
Write: 'file_edit',
|
|
27
|
+
Edit: 'file_edit',
|
|
28
|
+
MultiEdit: 'file_edit',
|
|
29
|
+
};
|
|
30
|
+
/**
|
|
31
|
+
* Parse a Claude Code hook event from raw stdin JSON and the hook event name.
|
|
32
|
+
*
|
|
33
|
+
* This is the single source of truth for all Claude Code payload parsing.
|
|
34
|
+
* The HTTP adapter's normalize() delegates here so that nothing outside this
|
|
35
|
+
* module ever needs to understand Claude Code's wire format.
|
|
36
|
+
*
|
|
37
|
+
* @param stdin Raw JSON string sent to the hook's stdin by Claude Code.
|
|
38
|
+
* @param hookEvent Hook event name from the Claude Code runtime
|
|
39
|
+
* (e.g. 'PreToolUse', 'PostToolUse').
|
|
40
|
+
*/
|
|
41
|
+
function parseClaudeCodeEvent(stdin, hookEvent) {
|
|
42
|
+
const body = JSON.parse(stdin);
|
|
43
|
+
const phase = hookEvent === 'PreToolUse' ? 'pre' : 'post';
|
|
44
|
+
const rawTool = body.tool_name;
|
|
45
|
+
const tool = TOOL_NORMALIZATIONS[rawTool] ?? rawTool;
|
|
46
|
+
const toolInput = body.tool_input;
|
|
47
|
+
const cwd = body.cwd;
|
|
48
|
+
// For shell tools, populate shell_command and action.command from tool_input
|
|
49
|
+
let shell_command;
|
|
50
|
+
let action;
|
|
51
|
+
if (tool === 'shell' && toolInput?.command) {
|
|
52
|
+
shell_command = String(toolInput.command);
|
|
53
|
+
action = { command: shell_command };
|
|
54
|
+
}
|
|
55
|
+
return {
|
|
56
|
+
schema_version: '1',
|
|
57
|
+
ide_source: 'claude-code',
|
|
58
|
+
actor: 'claude-code',
|
|
59
|
+
org_id: 'default',
|
|
60
|
+
hook_event_name: hookEvent,
|
|
61
|
+
phase,
|
|
62
|
+
session_id: body.session_id,
|
|
63
|
+
tool,
|
|
64
|
+
tool_args: toolInput,
|
|
65
|
+
tool_result: body.tool_response,
|
|
66
|
+
shell_command,
|
|
67
|
+
cwd,
|
|
68
|
+
context: cwd ? { workspace: cwd } : undefined,
|
|
69
|
+
action,
|
|
70
|
+
timestamp: new Date().toISOString(),
|
|
71
|
+
raw_payload: body,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Detect whether a parsed request body came from Claude Code.
|
|
76
|
+
* Detection key: tool_name + session_id (Cursor uses conversation_id instead).
|
|
77
|
+
*/
|
|
78
|
+
function detect(body) {
|
|
79
|
+
return (typeof body.tool_name === 'string' &&
|
|
80
|
+
typeof body.session_id === 'string');
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Normalize a pre-parsed HTTP request body into an OculiEvent.
|
|
84
|
+
* Delegates all parsing logic to parseClaudeCodeEvent.
|
|
85
|
+
*/
|
|
86
|
+
function normalize(body) {
|
|
87
|
+
const hookEvent = body.hook_event_name ?? 'PostToolUse';
|
|
88
|
+
return parseClaudeCodeEvent(JSON.stringify(body), hookEvent);
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Output shapes follow the Claude Code hook spec at
|
|
92
|
+
* https://code.claude.com/docs/en/hooks.
|
|
93
|
+
*
|
|
94
|
+
* - PreToolUse: blocking via `hookSpecificOutput.permissionDecision`.
|
|
95
|
+
* - PostToolUse: cannot block the tool (already ran); top-level `decision: "block"`
|
|
96
|
+
* only prevents Claude from continuing to the next turn.
|
|
97
|
+
* - Stop / others: empty `{}` = allow / default behavior. We never block Stop.
|
|
98
|
+
*
|
|
99
|
+
* All shapes are emitted with exit code 0; Claude Code ignores stdout on exit 2.
|
|
100
|
+
*/
|
|
101
|
+
function formatAllow(_event) {
|
|
102
|
+
return '{}';
|
|
103
|
+
}
|
|
104
|
+
function formatDeny(reason, event) {
|
|
105
|
+
if (event.hook_event_name === 'PreToolUse') {
|
|
106
|
+
return JSON.stringify({
|
|
107
|
+
hookSpecificOutput: {
|
|
108
|
+
hookEventName: 'PreToolUse',
|
|
109
|
+
permissionDecision: 'deny',
|
|
110
|
+
permissionDecisionReason: reason,
|
|
111
|
+
},
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
// PostToolUse (and unknown events) — prevent Claude from continuing.
|
|
115
|
+
return JSON.stringify({ decision: 'block', reason });
|
|
116
|
+
}
|
|
117
|
+
function formatWarn(reason, event) {
|
|
118
|
+
if (event.hook_event_name === 'PreToolUse') {
|
|
119
|
+
return JSON.stringify({ systemMessage: reason });
|
|
120
|
+
}
|
|
121
|
+
return '{}';
|
|
122
|
+
}
|
|
123
|
+
function denyStatusCode() {
|
|
124
|
+
return 200; // Claude Code reads the JSON body; HTTP status is always 200
|
|
125
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { OculiEvent } from './schema';
|
|
2
|
+
/**
|
|
3
|
+
* Parse a Cursor hook event from raw stdin JSON and the hook event name.
|
|
4
|
+
*
|
|
5
|
+
* This is the single source of truth for all Cursor payload parsing.
|
|
6
|
+
* The HTTP adapter's normalize() delegates here.
|
|
7
|
+
*
|
|
8
|
+
* @param stdin Raw JSON string sent to the hook's stdin by Cursor.
|
|
9
|
+
* @param hookEvent Hook event name (e.g. 'beforeShellExecution', 'stop').
|
|
10
|
+
*/
|
|
11
|
+
export declare function parseCursorEvent(stdin: string, hookEvent: string): OculiEvent;
|
|
12
|
+
/** Detect whether a parsed request body came from Cursor. */
|
|
13
|
+
export declare function detect(body: Record<string, unknown>): boolean;
|
|
14
|
+
/**
|
|
15
|
+
* Normalize a pre-parsed HTTP request body into an OculiEvent.
|
|
16
|
+
* Delegates all parsing logic to parseCursorEvent.
|
|
17
|
+
*/
|
|
18
|
+
export declare function normalize(body: Record<string, unknown>): OculiEvent;
|
|
19
|
+
export declare function formatAllow(_event: OculiEvent): string;
|
|
20
|
+
export declare function formatDeny(reason: string, _event: OculiEvent): string;
|
|
21
|
+
export declare function denyStatusCode(): number;
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.parseCursorEvent = parseCursorEvent;
|
|
4
|
+
exports.detect = detect;
|
|
5
|
+
exports.normalize = normalize;
|
|
6
|
+
exports.formatAllow = formatAllow;
|
|
7
|
+
exports.formatDeny = formatDeny;
|
|
8
|
+
exports.denyStatusCode = denyStatusCode;
|
|
9
|
+
const crypto_1 = require("crypto");
|
|
10
|
+
/**
|
|
11
|
+
* Adapter for Cursor hook payloads.
|
|
12
|
+
*
|
|
13
|
+
* Wire formats (all share conversation_id + generation_id + hook_event_name):
|
|
14
|
+
*
|
|
15
|
+
* beforeShellExecution: { conversation_id, generation_id, hook_event_name, content, workspace_roots }
|
|
16
|
+
* afterFileEdit: { conversation_id, generation_id, hook_event_name, file_path, content, workspace_roots }
|
|
17
|
+
* beforeReadFile: { conversation_id, generation_id, hook_event_name, file_path, content, workspace_roots }
|
|
18
|
+
* beforeMCPExecution: { conversation_id, generation_id, hook_event_name, mcp_server, mcp_tool, workspace_roots }
|
|
19
|
+
* afterMCPExecution: { conversation_id, generation_id, hook_event_name, mcp_server, mcp_tool, workspace_roots }
|
|
20
|
+
* stop: { conversation_id, generation_id, hook_event_name, workspace_roots }
|
|
21
|
+
*
|
|
22
|
+
* Detection: conversation_id + generation_id (both strings).
|
|
23
|
+
*
|
|
24
|
+
* Block response (written to stdout):
|
|
25
|
+
* { "permissionDecision": "deny", "permissionDecisionReason": "..." }
|
|
26
|
+
*/
|
|
27
|
+
/** 16-hex-char SHA-256 prefix — enough to detect duplicates, not enough to reconstruct content. */
|
|
28
|
+
function sha256prefix(content) {
|
|
29
|
+
return (0, crypto_1.createHash)('sha256').update(content).digest('hex').slice(0, 16);
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Parse a Cursor hook event from raw stdin JSON and the hook event name.
|
|
33
|
+
*
|
|
34
|
+
* This is the single source of truth for all Cursor payload parsing.
|
|
35
|
+
* The HTTP adapter's normalize() delegates here.
|
|
36
|
+
*
|
|
37
|
+
* @param stdin Raw JSON string sent to the hook's stdin by Cursor.
|
|
38
|
+
* @param hookEvent Hook event name (e.g. 'beforeShellExecution', 'stop').
|
|
39
|
+
*/
|
|
40
|
+
function parseCursorEvent(stdin, hookEvent) {
|
|
41
|
+
const body = JSON.parse(stdin);
|
|
42
|
+
let phase;
|
|
43
|
+
if (hookEvent === 'stop') {
|
|
44
|
+
phase = 'complete';
|
|
45
|
+
}
|
|
46
|
+
else if (hookEvent.startsWith('after')) {
|
|
47
|
+
phase = 'post';
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
phase = 'pre';
|
|
51
|
+
}
|
|
52
|
+
let tool;
|
|
53
|
+
let tool_args;
|
|
54
|
+
let tool_result;
|
|
55
|
+
let file_path;
|
|
56
|
+
let shell_command;
|
|
57
|
+
let mcp_server;
|
|
58
|
+
let action;
|
|
59
|
+
switch (hookEvent) {
|
|
60
|
+
case 'beforeShellExecution':
|
|
61
|
+
tool = 'shell';
|
|
62
|
+
shell_command = body.content;
|
|
63
|
+
tool_args = { command: body.content };
|
|
64
|
+
if (body.content)
|
|
65
|
+
action = { command: body.content };
|
|
66
|
+
break;
|
|
67
|
+
case 'beforeReadFile':
|
|
68
|
+
tool = 'file_read';
|
|
69
|
+
file_path = body.file_path;
|
|
70
|
+
tool_args = { path: body.file_path };
|
|
71
|
+
if (body.content)
|
|
72
|
+
action = { content_hash: sha256prefix(body.content) };
|
|
73
|
+
break;
|
|
74
|
+
case 'afterFileEdit':
|
|
75
|
+
tool = 'file_edit';
|
|
76
|
+
file_path = body.file_path;
|
|
77
|
+
tool_result = { path: body.file_path };
|
|
78
|
+
if (body.content)
|
|
79
|
+
action = { content_hash: sha256prefix(body.content) };
|
|
80
|
+
break;
|
|
81
|
+
case 'beforeMCPExecution':
|
|
82
|
+
case 'afterMCPExecution':
|
|
83
|
+
tool = 'mcp_call';
|
|
84
|
+
mcp_server = body.mcp_server;
|
|
85
|
+
tool_args = { mcp_tool: body.mcp_tool, mcp_server: body.mcp_server };
|
|
86
|
+
break;
|
|
87
|
+
case 'stop':
|
|
88
|
+
tool = 'session_stop';
|
|
89
|
+
break;
|
|
90
|
+
}
|
|
91
|
+
const workspace_roots = Array.isArray(body.workspace_roots)
|
|
92
|
+
? body.workspace_roots
|
|
93
|
+
: undefined;
|
|
94
|
+
const conversationId = body.conversation_id;
|
|
95
|
+
const workspace = workspace_roots?.[0];
|
|
96
|
+
return {
|
|
97
|
+
schema_version: '1',
|
|
98
|
+
ide_source: 'cursor',
|
|
99
|
+
actor: 'cursor',
|
|
100
|
+
org_id: 'default',
|
|
101
|
+
hook_event_name: hookEvent,
|
|
102
|
+
phase,
|
|
103
|
+
session_id: conversationId,
|
|
104
|
+
trace_id: body.generation_id,
|
|
105
|
+
tool,
|
|
106
|
+
tool_args,
|
|
107
|
+
tool_result,
|
|
108
|
+
file_path,
|
|
109
|
+
shell_command,
|
|
110
|
+
mcp_server,
|
|
111
|
+
workspace_roots,
|
|
112
|
+
context: { workspace, conversation_id: conversationId },
|
|
113
|
+
action,
|
|
114
|
+
timestamp: new Date().toISOString(),
|
|
115
|
+
raw_payload: body,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
/** Detect whether a parsed request body came from Cursor. */
|
|
119
|
+
function detect(body) {
|
|
120
|
+
return (typeof body.conversation_id === 'string' &&
|
|
121
|
+
typeof body.generation_id === 'string');
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Normalize a pre-parsed HTTP request body into an OculiEvent.
|
|
125
|
+
* Delegates all parsing logic to parseCursorEvent.
|
|
126
|
+
*/
|
|
127
|
+
function normalize(body) {
|
|
128
|
+
const hookEvent = body.hook_event_name ?? 'unknown';
|
|
129
|
+
return parseCursorEvent(JSON.stringify(body), hookEvent);
|
|
130
|
+
}
|
|
131
|
+
function formatAllow(_event) {
|
|
132
|
+
return JSON.stringify({ permissionDecision: 'allow' });
|
|
133
|
+
}
|
|
134
|
+
function formatDeny(reason, _event) {
|
|
135
|
+
return JSON.stringify({ permissionDecision: 'deny', permissionDecisionReason: reason });
|
|
136
|
+
}
|
|
137
|
+
function denyStatusCode() {
|
|
138
|
+
return 200; // Cursor reads permissionDecision from the JSON body
|
|
139
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { OculiEvent } from './schema';
|
|
2
|
+
export interface HookAdapter {
|
|
3
|
+
detect(body: Record<string, unknown>): boolean;
|
|
4
|
+
normalize(body: Record<string, unknown>): OculiEvent;
|
|
5
|
+
formatAllow(event: OculiEvent): string;
|
|
6
|
+
formatDeny(reason: string, event: OculiEvent): string;
|
|
7
|
+
/** Non-blocking advisory output. Optional: only adapters whose hook protocol distinguishes warn from allow need to implement this. */
|
|
8
|
+
formatWarn?(reason: string, event: OculiEvent): string;
|
|
9
|
+
/** HTTP status code for deny responses. Most adapters use 200 (body carries the decision); Windsurf uses 403. */
|
|
10
|
+
denyStatusCode(): number;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Find the first adapter that recognises the given payload.
|
|
14
|
+
* Returns null if no adapter matches (unknown source).
|
|
15
|
+
*/
|
|
16
|
+
export declare function detectAdapter(body: Record<string, unknown>): HookAdapter | null;
|