@mauribadnights/clooks 0.2.2 → 0.3.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/README.md +72 -1
- package/dist/auth.d.ts +13 -0
- package/dist/auth.js +82 -0
- package/dist/builtin-hooks.d.ts +11 -0
- package/dist/builtin-hooks.js +67 -0
- package/dist/cli.js +128 -2
- package/dist/constants.d.ts +4 -0
- package/dist/constants.js +5 -1
- package/dist/deps.d.ts +13 -0
- package/dist/deps.js +83 -0
- package/dist/doctor.js +56 -0
- package/dist/handlers.d.ts +6 -3
- package/dist/handlers.js +81 -46
- package/dist/index.d.ts +10 -5
- package/dist/index.js +23 -1
- package/dist/llm.d.ts +1 -1
- package/dist/llm.js +8 -4
- package/dist/manifest.d.ts +4 -0
- package/dist/manifest.js +24 -0
- package/dist/migrate.js +15 -0
- package/dist/plugin.d.ts +50 -0
- package/dist/plugin.js +279 -0
- package/dist/ratelimit.d.ts +12 -0
- package/dist/ratelimit.js +44 -0
- package/dist/server.d.ts +5 -0
- package/dist/server.js +119 -5
- package/dist/shortcircuit.d.ts +20 -0
- package/dist/shortcircuit.js +49 -0
- package/dist/types.d.ts +31 -0
- package/hooks/check-update.js +37 -0
- package/package.json +2 -1
package/dist/plugin.js
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// clooks plugin system — load, install, uninstall, merge plugin manifests
|
|
3
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
+
exports.loadRegistry = loadRegistry;
|
|
5
|
+
exports.saveRegistry = saveRegistry;
|
|
6
|
+
exports.validatePluginManifest = validatePluginManifest;
|
|
7
|
+
exports.loadPlugins = loadPlugins;
|
|
8
|
+
exports.mergeManifests = mergeManifests;
|
|
9
|
+
exports.installPlugin = installPlugin;
|
|
10
|
+
exports.uninstallPlugin = uninstallPlugin;
|
|
11
|
+
exports.listPlugins = listPlugins;
|
|
12
|
+
const fs_1 = require("fs");
|
|
13
|
+
const path_1 = require("path");
|
|
14
|
+
const yaml_1 = require("yaml");
|
|
15
|
+
const constants_js_1 = require("./constants.js");
|
|
16
|
+
/**
|
|
17
|
+
* Load the plugin registry (installed.json).
|
|
18
|
+
*/
|
|
19
|
+
function loadRegistry(registryPath = constants_js_1.PLUGIN_REGISTRY) {
|
|
20
|
+
if (!(0, fs_1.existsSync)(registryPath)) {
|
|
21
|
+
return { plugins: [] };
|
|
22
|
+
}
|
|
23
|
+
try {
|
|
24
|
+
const raw = (0, fs_1.readFileSync)(registryPath, 'utf-8');
|
|
25
|
+
const parsed = JSON.parse(raw);
|
|
26
|
+
if (!parsed || !Array.isArray(parsed.plugins)) {
|
|
27
|
+
return { plugins: [] };
|
|
28
|
+
}
|
|
29
|
+
return parsed;
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return { plugins: [] };
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Save the plugin registry.
|
|
37
|
+
*/
|
|
38
|
+
function saveRegistry(registry, registryPath = constants_js_1.PLUGIN_REGISTRY) {
|
|
39
|
+
const dir = (0, path_1.resolve)(registryPath, '..');
|
|
40
|
+
if (!(0, fs_1.existsSync)(dir)) {
|
|
41
|
+
(0, fs_1.mkdirSync)(dir, { recursive: true });
|
|
42
|
+
}
|
|
43
|
+
(0, fs_1.writeFileSync)(registryPath, JSON.stringify(registry, null, 2) + '\n', 'utf-8');
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Validate a plugin manifest.
|
|
47
|
+
* Similar to validateManifest but checks plugin-specific fields (name, version required).
|
|
48
|
+
*/
|
|
49
|
+
function validatePluginManifest(manifest) {
|
|
50
|
+
if (!manifest.name || typeof manifest.name !== 'string') {
|
|
51
|
+
throw new Error('Plugin manifest must have a "name" string field');
|
|
52
|
+
}
|
|
53
|
+
if (!manifest.version || typeof manifest.version !== 'string') {
|
|
54
|
+
throw new Error('Plugin manifest must have a "version" string field');
|
|
55
|
+
}
|
|
56
|
+
if (!manifest.handlers || typeof manifest.handlers !== 'object') {
|
|
57
|
+
throw new Error('Plugin manifest must have a "handlers" object');
|
|
58
|
+
}
|
|
59
|
+
const seenIds = new Set();
|
|
60
|
+
for (const [eventName, handlers] of Object.entries(manifest.handlers)) {
|
|
61
|
+
if (!constants_js_1.HOOK_EVENTS.includes(eventName)) {
|
|
62
|
+
throw new Error(`Unknown hook event: "${eventName}". Valid events: ${constants_js_1.HOOK_EVENTS.join(', ')}`);
|
|
63
|
+
}
|
|
64
|
+
if (!Array.isArray(handlers)) {
|
|
65
|
+
throw new Error(`Handlers for "${eventName}" must be an array`);
|
|
66
|
+
}
|
|
67
|
+
for (const handler of handlers) {
|
|
68
|
+
if (!handler.id || typeof handler.id !== 'string') {
|
|
69
|
+
throw new Error(`Each handler must have a string "id" (event: ${eventName})`);
|
|
70
|
+
}
|
|
71
|
+
if (seenIds.has(handler.id)) {
|
|
72
|
+
throw new Error(`Duplicate handler id: "${handler.id}"`);
|
|
73
|
+
}
|
|
74
|
+
seenIds.add(handler.id);
|
|
75
|
+
if (!handler.type || !['script', 'inline', 'llm'].includes(handler.type)) {
|
|
76
|
+
throw new Error(`Handler "${handler.id}" must have type "script", "inline", or "llm"`);
|
|
77
|
+
}
|
|
78
|
+
if (handler.type === 'script' && !('command' in handler && handler.command)) {
|
|
79
|
+
throw new Error(`Script handler "${handler.id}" must have a "command" field`);
|
|
80
|
+
}
|
|
81
|
+
if (handler.type === 'inline' && !('module' in handler && handler.module)) {
|
|
82
|
+
throw new Error(`Inline handler "${handler.id}" must have a "module" field`);
|
|
83
|
+
}
|
|
84
|
+
if (handler.type === 'llm') {
|
|
85
|
+
const llm = handler;
|
|
86
|
+
if (!llm.model) {
|
|
87
|
+
throw new Error(`LLM handler "${handler.id}" must have a "model" field`);
|
|
88
|
+
}
|
|
89
|
+
if (!llm.prompt) {
|
|
90
|
+
throw new Error(`LLM handler "${handler.id}" must have a "prompt" field`);
|
|
91
|
+
}
|
|
92
|
+
const validModels = ['claude-haiku-4-5', 'claude-sonnet-4-6', 'claude-opus-4-6'];
|
|
93
|
+
if (!validModels.includes(llm.model)) {
|
|
94
|
+
throw new Error(`LLM handler "${handler.id}" model must be one of: ${validModels.join(', ')}`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
// Validate prefetch if present
|
|
100
|
+
if (manifest.prefetch !== undefined) {
|
|
101
|
+
if (!Array.isArray(manifest.prefetch)) {
|
|
102
|
+
throw new Error('prefetch must be an array');
|
|
103
|
+
}
|
|
104
|
+
const validKeys = ['transcript', 'git_status', 'git_diff'];
|
|
105
|
+
for (const key of manifest.prefetch) {
|
|
106
|
+
if (!validKeys.includes(key)) {
|
|
107
|
+
throw new Error(`Invalid prefetch key: "${key}". Valid keys: ${validKeys.join(', ')}`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Load all installed plugins and return their manifests.
|
|
114
|
+
*/
|
|
115
|
+
function loadPlugins(pluginsDir = constants_js_1.PLUGINS_DIR, registryPath = constants_js_1.PLUGIN_REGISTRY) {
|
|
116
|
+
const registry = loadRegistry(registryPath);
|
|
117
|
+
const results = [];
|
|
118
|
+
for (const plugin of registry.plugins) {
|
|
119
|
+
const manifestPath = (0, path_1.join)(plugin.path, constants_js_1.PLUGIN_MANIFEST_NAME);
|
|
120
|
+
if (!(0, fs_1.existsSync)(manifestPath)) {
|
|
121
|
+
// Skip plugins with missing manifests (silently — doctor will catch this)
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
try {
|
|
125
|
+
const raw = (0, fs_1.readFileSync)(manifestPath, 'utf-8');
|
|
126
|
+
const parsed = (0, yaml_1.parse)(raw);
|
|
127
|
+
validatePluginManifest(parsed);
|
|
128
|
+
results.push({ name: plugin.name, manifest: parsed });
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
// Skip plugins with invalid manifests
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return results;
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Namespace a handler ID with a plugin name.
|
|
139
|
+
*/
|
|
140
|
+
function namespaceId(pluginName, handlerId) {
|
|
141
|
+
return `${pluginName}/${handlerId}`;
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Merge user manifest + plugin manifests into a composite manifest.
|
|
145
|
+
* Plugin handler IDs are namespaced as "pluginName/handlerId".
|
|
146
|
+
* Prefetch keys are unioned.
|
|
147
|
+
* Settings come from user manifest only.
|
|
148
|
+
*/
|
|
149
|
+
function mergeManifests(userManifest, plugins) {
|
|
150
|
+
// Deep clone user manifest handlers
|
|
151
|
+
const merged = {
|
|
152
|
+
handlers: {},
|
|
153
|
+
prefetch: userManifest.prefetch ? [...userManifest.prefetch] : undefined,
|
|
154
|
+
settings: userManifest.settings,
|
|
155
|
+
};
|
|
156
|
+
// Copy user handlers
|
|
157
|
+
for (const [event, handlers] of Object.entries(userManifest.handlers)) {
|
|
158
|
+
if (handlers) {
|
|
159
|
+
merged.handlers[event] = [...handlers];
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
// Merge plugin handlers
|
|
163
|
+
for (const { name: pluginName, manifest: pluginManifest } of plugins) {
|
|
164
|
+
// Union prefetch keys
|
|
165
|
+
if (pluginManifest.prefetch && pluginManifest.prefetch.length > 0) {
|
|
166
|
+
if (!merged.prefetch) {
|
|
167
|
+
merged.prefetch = [];
|
|
168
|
+
}
|
|
169
|
+
for (const key of pluginManifest.prefetch) {
|
|
170
|
+
if (!merged.prefetch.includes(key)) {
|
|
171
|
+
merged.prefetch.push(key);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
// Namespace and add handlers
|
|
176
|
+
for (const [event, handlers] of Object.entries(pluginManifest.handlers)) {
|
|
177
|
+
if (!handlers)
|
|
178
|
+
continue;
|
|
179
|
+
const hookEvent = event;
|
|
180
|
+
if (!merged.handlers[hookEvent]) {
|
|
181
|
+
merged.handlers[hookEvent] = [];
|
|
182
|
+
}
|
|
183
|
+
for (const handler of handlers) {
|
|
184
|
+
const namespacedHandler = {
|
|
185
|
+
...handler,
|
|
186
|
+
id: namespaceId(pluginName, handler.id),
|
|
187
|
+
};
|
|
188
|
+
// Namespace depends references too
|
|
189
|
+
if (namespacedHandler.depends) {
|
|
190
|
+
namespacedHandler.depends = namespacedHandler.depends.map(dep =>
|
|
191
|
+
// If the dep already contains a slash, it references another plugin — leave it
|
|
192
|
+
dep.includes('/') ? dep : namespaceId(pluginName, dep));
|
|
193
|
+
}
|
|
194
|
+
merged.handlers[hookEvent].push(namespacedHandler);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return merged;
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Install a plugin from a local directory path.
|
|
202
|
+
* 1. Read clooks-plugin.yaml from the path
|
|
203
|
+
* 2. Validate it
|
|
204
|
+
* 3. Copy the directory to plugins dir under {name}/
|
|
205
|
+
* 4. Register in installed.json
|
|
206
|
+
* 5. Resolve $PLUGIN_DIR in handler commands to the installed path
|
|
207
|
+
*/
|
|
208
|
+
function installPlugin(sourcePath, pluginsDir = constants_js_1.PLUGINS_DIR, registryPath = constants_js_1.PLUGIN_REGISTRY) {
|
|
209
|
+
const resolvedSource = (0, path_1.resolve)(sourcePath);
|
|
210
|
+
const manifestPath = (0, path_1.join)(resolvedSource, constants_js_1.PLUGIN_MANIFEST_NAME);
|
|
211
|
+
if (!(0, fs_1.existsSync)(manifestPath)) {
|
|
212
|
+
throw new Error(`No ${constants_js_1.PLUGIN_MANIFEST_NAME} found at ${resolvedSource}`);
|
|
213
|
+
}
|
|
214
|
+
const raw = (0, fs_1.readFileSync)(manifestPath, 'utf-8');
|
|
215
|
+
const pluginManifest = (0, yaml_1.parse)(raw);
|
|
216
|
+
validatePluginManifest(pluginManifest);
|
|
217
|
+
const destPath = (0, path_1.join)(pluginsDir, pluginManifest.name);
|
|
218
|
+
// Ensure plugins dir exists
|
|
219
|
+
if (!(0, fs_1.existsSync)(pluginsDir)) {
|
|
220
|
+
(0, fs_1.mkdirSync)(pluginsDir, { recursive: true });
|
|
221
|
+
}
|
|
222
|
+
// Remove existing installation if present
|
|
223
|
+
if ((0, fs_1.existsSync)(destPath)) {
|
|
224
|
+
(0, fs_1.rmSync)(destPath, { recursive: true, force: true });
|
|
225
|
+
}
|
|
226
|
+
// Copy plugin directory to plugins dir
|
|
227
|
+
(0, fs_1.cpSync)(resolvedSource, destPath, { recursive: true });
|
|
228
|
+
// Resolve $PLUGIN_DIR in handler commands within the installed copy
|
|
229
|
+
const installedManifestPath = (0, path_1.join)(destPath, constants_js_1.PLUGIN_MANIFEST_NAME);
|
|
230
|
+
const installedRaw = (0, fs_1.readFileSync)(installedManifestPath, 'utf-8');
|
|
231
|
+
const resolved = installedRaw.replace(/\$PLUGIN_DIR/g, destPath);
|
|
232
|
+
(0, fs_1.writeFileSync)(installedManifestPath, resolved, 'utf-8');
|
|
233
|
+
// Resolve relative extras.readme to absolute path
|
|
234
|
+
const resolvedManifest = (0, yaml_1.parse)(resolved);
|
|
235
|
+
if (resolvedManifest.extras?.readme && !resolvedManifest.extras.readme.startsWith('/')) {
|
|
236
|
+
resolvedManifest.extras.readme = (0, path_1.join)(destPath, resolvedManifest.extras.readme);
|
|
237
|
+
(0, fs_1.writeFileSync)(installedManifestPath, (0, yaml_1.stringify)(resolvedManifest), 'utf-8');
|
|
238
|
+
}
|
|
239
|
+
// Update registry
|
|
240
|
+
const registry = loadRegistry(registryPath);
|
|
241
|
+
// Remove any existing entry for this plugin
|
|
242
|
+
registry.plugins = registry.plugins.filter(p => p.name !== pluginManifest.name);
|
|
243
|
+
const entry = {
|
|
244
|
+
name: pluginManifest.name,
|
|
245
|
+
version: pluginManifest.version,
|
|
246
|
+
path: destPath,
|
|
247
|
+
installedAt: new Date().toISOString(),
|
|
248
|
+
};
|
|
249
|
+
registry.plugins.push(entry);
|
|
250
|
+
saveRegistry(registry, registryPath);
|
|
251
|
+
return entry;
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* Uninstall a plugin by name.
|
|
255
|
+
* 1. Remove from installed.json
|
|
256
|
+
* 2. Delete the plugin directory
|
|
257
|
+
*/
|
|
258
|
+
function uninstallPlugin(name, pluginsDir = constants_js_1.PLUGINS_DIR, registryPath = constants_js_1.PLUGIN_REGISTRY) {
|
|
259
|
+
const registry = loadRegistry(registryPath);
|
|
260
|
+
const plugin = registry.plugins.find(p => p.name === name);
|
|
261
|
+
if (!plugin) {
|
|
262
|
+
throw new Error(`Plugin "${name}" is not installed`);
|
|
263
|
+
}
|
|
264
|
+
// Remove from registry
|
|
265
|
+
registry.plugins = registry.plugins.filter(p => p.name !== name);
|
|
266
|
+
saveRegistry(registry, registryPath);
|
|
267
|
+
// Delete plugin directory
|
|
268
|
+
const pluginPath = (0, path_1.join)(pluginsDir, name);
|
|
269
|
+
if ((0, fs_1.existsSync)(pluginPath)) {
|
|
270
|
+
(0, fs_1.rmSync)(pluginPath, { recursive: true, force: true });
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* List installed plugins.
|
|
275
|
+
*/
|
|
276
|
+
function listPlugins(registryPath = constants_js_1.PLUGIN_REGISTRY) {
|
|
277
|
+
const registry = loadRegistry(registryPath);
|
|
278
|
+
return registry.plugins;
|
|
279
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export declare class RateLimiter {
|
|
2
|
+
private attempts;
|
|
3
|
+
private maxAttempts;
|
|
4
|
+
private windowMs;
|
|
5
|
+
constructor(maxAttempts?: number, windowMs?: number);
|
|
6
|
+
/** Check if source is rate-limited. Returns true if allowed. */
|
|
7
|
+
check(source: string): boolean;
|
|
8
|
+
/** Record an attempt from source. */
|
|
9
|
+
record(source: string): void;
|
|
10
|
+
/** Clean up old entries. */
|
|
11
|
+
cleanup(): void;
|
|
12
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// clooks rate limiting — protect against auth brute-force
|
|
3
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
+
exports.RateLimiter = void 0;
|
|
5
|
+
class RateLimiter {
|
|
6
|
+
attempts = new Map(); // source → timestamps
|
|
7
|
+
maxAttempts;
|
|
8
|
+
windowMs;
|
|
9
|
+
constructor(maxAttempts = 10, windowMs = 60_000) {
|
|
10
|
+
this.maxAttempts = maxAttempts;
|
|
11
|
+
this.windowMs = windowMs;
|
|
12
|
+
}
|
|
13
|
+
/** Check if source is rate-limited. Returns true if allowed. */
|
|
14
|
+
check(source) {
|
|
15
|
+
const now = Date.now();
|
|
16
|
+
const timestamps = this.attempts.get(source);
|
|
17
|
+
if (!timestamps)
|
|
18
|
+
return true;
|
|
19
|
+
// Count recent attempts within window
|
|
20
|
+
const recent = timestamps.filter(t => now - t <= this.windowMs);
|
|
21
|
+
return recent.length < this.maxAttempts;
|
|
22
|
+
}
|
|
23
|
+
/** Record an attempt from source. */
|
|
24
|
+
record(source) {
|
|
25
|
+
const now = Date.now();
|
|
26
|
+
const timestamps = this.attempts.get(source) ?? [];
|
|
27
|
+
timestamps.push(now);
|
|
28
|
+
this.attempts.set(source, timestamps);
|
|
29
|
+
}
|
|
30
|
+
/** Clean up old entries. */
|
|
31
|
+
cleanup() {
|
|
32
|
+
const now = Date.now();
|
|
33
|
+
for (const [source, timestamps] of this.attempts) {
|
|
34
|
+
const recent = timestamps.filter(t => now - t <= this.windowMs);
|
|
35
|
+
if (recent.length === 0) {
|
|
36
|
+
this.attempts.delete(source);
|
|
37
|
+
}
|
|
38
|
+
else {
|
|
39
|
+
this.attempts.set(source, recent);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
exports.RateLimiter = RateLimiter;
|
package/dist/server.d.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { type Server } from 'http';
|
|
2
2
|
import type { FSWatcher } from 'fs';
|
|
3
3
|
import { MetricsCollector } from './metrics.js';
|
|
4
|
+
import { DenyCache } from './shortcircuit.js';
|
|
5
|
+
import { RateLimiter } from './ratelimit.js';
|
|
4
6
|
import type { Manifest } from './types.js';
|
|
5
7
|
export interface ServerContext {
|
|
6
8
|
server: Server;
|
|
@@ -8,6 +10,9 @@ export interface ServerContext {
|
|
|
8
10
|
startTime: number;
|
|
9
11
|
manifest: Manifest;
|
|
10
12
|
watcher?: FSWatcher;
|
|
13
|
+
denyCache: DenyCache;
|
|
14
|
+
rateLimiter: RateLimiter;
|
|
15
|
+
cleanupInterval?: ReturnType<typeof setInterval>;
|
|
11
16
|
}
|
|
12
17
|
/**
|
|
13
18
|
* Create the HTTP server for hook handling.
|
package/dist/server.js
CHANGED
|
@@ -13,6 +13,8 @@ const handlers_js_1 = require("./handlers.js");
|
|
|
13
13
|
const prefetch_js_1 = require("./prefetch.js");
|
|
14
14
|
const watcher_js_1 = require("./watcher.js");
|
|
15
15
|
const auth_js_1 = require("./auth.js");
|
|
16
|
+
const shortcircuit_js_1 = require("./shortcircuit.js");
|
|
17
|
+
const ratelimit_js_1 = require("./ratelimit.js");
|
|
16
18
|
const constants_js_1 = require("./constants.js");
|
|
17
19
|
const manifest_js_1 = require("./manifest.js");
|
|
18
20
|
function log(msg) {
|
|
@@ -82,13 +84,43 @@ function sendJson(res, status, data) {
|
|
|
82
84
|
*/
|
|
83
85
|
function createServer(manifest, metrics) {
|
|
84
86
|
const startTime = Date.now();
|
|
85
|
-
const
|
|
87
|
+
const denyCache = new shortcircuit_js_1.DenyCache();
|
|
88
|
+
const rateLimiter = new ratelimit_js_1.RateLimiter();
|
|
89
|
+
const ctx = {
|
|
90
|
+
server: null,
|
|
91
|
+
metrics,
|
|
92
|
+
startTime,
|
|
93
|
+
manifest,
|
|
94
|
+
denyCache,
|
|
95
|
+
rateLimiter,
|
|
96
|
+
};
|
|
86
97
|
const authToken = manifest.settings?.authToken ?? '';
|
|
98
|
+
// Periodic cleanup for deny cache and rate limiter (every 60s)
|
|
99
|
+
ctx.cleanupInterval = setInterval(() => {
|
|
100
|
+
denyCache.cleanup();
|
|
101
|
+
rateLimiter.cleanup();
|
|
102
|
+
}, 60_000);
|
|
103
|
+
// Unref so it doesn't keep the process alive
|
|
104
|
+
if (ctx.cleanupInterval && typeof ctx.cleanupInterval === 'object' && 'unref' in ctx.cleanupInterval) {
|
|
105
|
+
ctx.cleanupInterval.unref();
|
|
106
|
+
}
|
|
87
107
|
const server = (0, http_1.createServer)(async (req, res) => {
|
|
88
108
|
const url = req.url ?? '/';
|
|
89
109
|
const method = req.method ?? 'GET';
|
|
90
|
-
//
|
|
110
|
+
// Public health endpoint — minimal, no auth
|
|
91
111
|
if (method === 'GET' && url === '/health') {
|
|
112
|
+
sendJson(res, 200, { status: 'ok' });
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
// Detailed health endpoint — authenticated if authToken configured
|
|
116
|
+
if (method === 'GET' && url === '/health/detail') {
|
|
117
|
+
if (authToken) {
|
|
118
|
+
const authHeader = req.headers['authorization'];
|
|
119
|
+
if (!(0, auth_js_1.validateAuth)(authHeader, authToken)) {
|
|
120
|
+
sendJson(res, 401, { error: 'Unauthorized' });
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
92
124
|
const handlerCount = Object.values(ctx.manifest.handlers)
|
|
93
125
|
.reduce((sum, arr) => sum + (arr?.length ?? 0), 0);
|
|
94
126
|
sendJson(res, 200, {
|
|
@@ -101,9 +133,16 @@ function createServer(manifest, metrics) {
|
|
|
101
133
|
}
|
|
102
134
|
// Auth check for all POST requests
|
|
103
135
|
if (method === 'POST' && authToken) {
|
|
136
|
+
const source = req.socket.remoteAddress ?? 'unknown';
|
|
137
|
+
// Rate limiting check
|
|
138
|
+
if (!rateLimiter.check(source)) {
|
|
139
|
+
sendJson(res, 429, { error: 'Too many requests' });
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
104
142
|
const authHeader = req.headers['authorization'];
|
|
105
143
|
if (!(0, auth_js_1.validateAuth)(authHeader, authToken)) {
|
|
106
|
-
|
|
144
|
+
rateLimiter.record(source);
|
|
145
|
+
log(`Auth failure from ${source}`);
|
|
107
146
|
sendJson(res, 401, { error: 'Unauthorized' });
|
|
108
147
|
return;
|
|
109
148
|
}
|
|
@@ -139,6 +178,14 @@ function createServer(manifest, metrics) {
|
|
|
139
178
|
sendJson(res, 400, { error: 'Invalid JSON body' });
|
|
140
179
|
return;
|
|
141
180
|
}
|
|
181
|
+
// Short-circuit: skip PostToolUse if PreToolUse denied this tool
|
|
182
|
+
if (event === 'PostToolUse' && input.tool_name && input.session_id) {
|
|
183
|
+
if (denyCache.isDenied(input.session_id, input.tool_name)) {
|
|
184
|
+
log(`PostToolUse skipped — PreToolUse denied for ${input.tool_name}`);
|
|
185
|
+
sendJson(res, 200, {});
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
142
189
|
log(`Hook: ${eventName} (${handlers.length} handler${handlers.length > 1 ? 's' : ''})`);
|
|
143
190
|
try {
|
|
144
191
|
// Pre-fetch shared context if configured
|
|
@@ -163,7 +210,6 @@ function createServer(manifest, metrics) {
|
|
|
163
210
|
});
|
|
164
211
|
// Track cost for LLM handlers
|
|
165
212
|
if (result.usage && result.cost_usd !== undefined && result.cost_usd > 0) {
|
|
166
|
-
// Find the handler config to get model info
|
|
167
213
|
const handlerConfig = handlers.find(h => h.id === result.id);
|
|
168
214
|
if (handlerConfig && handlerConfig.type === 'llm') {
|
|
169
215
|
const llmConfig = handlerConfig;
|
|
@@ -179,6 +225,27 @@ function createServer(manifest, metrics) {
|
|
|
179
225
|
}
|
|
180
226
|
}
|
|
181
227
|
}
|
|
228
|
+
// Short-circuit: if PreToolUse had a deny, record it in the cache
|
|
229
|
+
if (event === 'PreToolUse' && input.tool_name && input.session_id) {
|
|
230
|
+
const hasDeny = results.some(r => {
|
|
231
|
+
if (!r.ok || !r.output || typeof r.output !== 'object')
|
|
232
|
+
return false;
|
|
233
|
+
const out = r.output;
|
|
234
|
+
// Check hookSpecificOutput.permissionDecision === 'deny'
|
|
235
|
+
if (out.hookSpecificOutput && typeof out.hookSpecificOutput === 'object') {
|
|
236
|
+
const hso = out.hookSpecificOutput;
|
|
237
|
+
if (hso.permissionDecision === 'deny')
|
|
238
|
+
return true;
|
|
239
|
+
}
|
|
240
|
+
// Check decision === 'block'
|
|
241
|
+
if (out.decision === 'block')
|
|
242
|
+
return true;
|
|
243
|
+
return false;
|
|
244
|
+
});
|
|
245
|
+
if (hasDeny) {
|
|
246
|
+
denyCache.recordDeny(input.session_id, input.tool_name);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
182
249
|
const merged = mergeResults(results);
|
|
183
250
|
log(` -> ${results.filter((r) => r.ok).length}/${results.length} ok, response keys: ${Object.keys(merged).join(', ') || '(empty)'}`);
|
|
184
251
|
sendJson(res, 200, merged);
|
|
@@ -222,7 +289,52 @@ function startDaemon(manifest, metrics, options) {
|
|
|
222
289
|
if (!options?.noWatch) {
|
|
223
290
|
ctx.watcher = (0, watcher_js_1.startWatcher)(constants_js_1.MANIFEST_PATH, () => {
|
|
224
291
|
try {
|
|
225
|
-
const newManifest = (0, manifest_js_1.
|
|
292
|
+
const newManifest = (0, manifest_js_1.loadCompositeManifest)();
|
|
293
|
+
// Diff handlers: find removed, added, and changed handlers
|
|
294
|
+
const oldIds = new Set();
|
|
295
|
+
const oldHandlerMap = new Map();
|
|
296
|
+
for (const handlers of Object.values(ctx.manifest.handlers)) {
|
|
297
|
+
if (!handlers)
|
|
298
|
+
continue;
|
|
299
|
+
for (const h of handlers) {
|
|
300
|
+
oldIds.add(h.id);
|
|
301
|
+
oldHandlerMap.set(h.id, h);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
const newIds = new Set();
|
|
305
|
+
const newHandlerMap = new Map();
|
|
306
|
+
for (const handlers of Object.values(newManifest.handlers)) {
|
|
307
|
+
if (!handlers)
|
|
308
|
+
continue;
|
|
309
|
+
for (const h of handlers) {
|
|
310
|
+
newIds.add(h.id);
|
|
311
|
+
newHandlerMap.set(h.id, h);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
// Removed handlers: clean up their state
|
|
315
|
+
for (const id of oldIds) {
|
|
316
|
+
if (!newIds.has(id)) {
|
|
317
|
+
(0, handlers_js_1.cleanupHandlerState)(id);
|
|
318
|
+
log(` Handler removed: ${id}`);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
// Added handlers: initialize fresh state (happens automatically on first use)
|
|
322
|
+
for (const id of newIds) {
|
|
323
|
+
if (!oldIds.has(id)) {
|
|
324
|
+
log(` Handler added: ${id}`);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
// Changed handlers with sessionIsolation: reset state
|
|
328
|
+
for (const id of newIds) {
|
|
329
|
+
if (oldIds.has(id)) {
|
|
330
|
+
const newH = newHandlerMap.get(id);
|
|
331
|
+
const oldH = oldHandlerMap.get(id);
|
|
332
|
+
if (newH.sessionIsolation && JSON.stringify(oldH) !== JSON.stringify(newH)) {
|
|
333
|
+
(0, handlers_js_1.cleanupHandlerState)(id);
|
|
334
|
+
log(` Handler changed (session-isolated, state reset): ${id}`);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
}
|
|
226
338
|
ctx.manifest = newManifest;
|
|
227
339
|
log('Manifest reloaded successfully');
|
|
228
340
|
}
|
|
@@ -240,6 +352,8 @@ function startDaemon(manifest, metrics, options) {
|
|
|
240
352
|
const shutdown = () => {
|
|
241
353
|
log('Shutting down...');
|
|
242
354
|
(0, watcher_js_1.stopWatcher)(ctx.watcher ?? null);
|
|
355
|
+
if (ctx.cleanupInterval)
|
|
356
|
+
clearInterval(ctx.cleanupInterval);
|
|
243
357
|
ctx.server.close(() => {
|
|
244
358
|
try {
|
|
245
359
|
if ((0, fs_1.existsSync)(constants_js_1.PID_FILE))
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-memory cache of denied PreToolUse calls.
|
|
3
|
+
* Keyed by session_id + tool_name.
|
|
4
|
+
* Entries expire after TTL_MS (30 seconds).
|
|
5
|
+
*/
|
|
6
|
+
export declare class DenyCache {
|
|
7
|
+
private cache;
|
|
8
|
+
private static TTL_MS;
|
|
9
|
+
private makeKey;
|
|
10
|
+
/** Record a denied PreToolUse. */
|
|
11
|
+
recordDeny(sessionId: string, toolName: string): void;
|
|
12
|
+
/** Check if a tool call was recently denied. */
|
|
13
|
+
isDenied(sessionId: string, toolName: string): boolean;
|
|
14
|
+
/** Clean up expired entries. */
|
|
15
|
+
cleanup(): void;
|
|
16
|
+
/** Clear all entries. */
|
|
17
|
+
clear(): void;
|
|
18
|
+
/** Get current cache size (for testing). */
|
|
19
|
+
get size(): number;
|
|
20
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// clooks short-circuit chains — deny cache for PreToolUse → PostToolUse
|
|
3
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
+
exports.DenyCache = void 0;
|
|
5
|
+
/**
|
|
6
|
+
* In-memory cache of denied PreToolUse calls.
|
|
7
|
+
* Keyed by session_id + tool_name.
|
|
8
|
+
* Entries expire after TTL_MS (30 seconds).
|
|
9
|
+
*/
|
|
10
|
+
class DenyCache {
|
|
11
|
+
cache = new Map(); // key → timestamp
|
|
12
|
+
static TTL_MS = 30_000;
|
|
13
|
+
makeKey(sessionId, toolName) {
|
|
14
|
+
return `${sessionId}:${toolName}`;
|
|
15
|
+
}
|
|
16
|
+
/** Record a denied PreToolUse. */
|
|
17
|
+
recordDeny(sessionId, toolName) {
|
|
18
|
+
this.cache.set(this.makeKey(sessionId, toolName), Date.now());
|
|
19
|
+
}
|
|
20
|
+
/** Check if a tool call was recently denied. */
|
|
21
|
+
isDenied(sessionId, toolName) {
|
|
22
|
+
const ts = this.cache.get(this.makeKey(sessionId, toolName));
|
|
23
|
+
if (ts === undefined)
|
|
24
|
+
return false;
|
|
25
|
+
if (Date.now() - ts > DenyCache.TTL_MS) {
|
|
26
|
+
this.cache.delete(this.makeKey(sessionId, toolName));
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
return true;
|
|
30
|
+
}
|
|
31
|
+
/** Clean up expired entries. */
|
|
32
|
+
cleanup() {
|
|
33
|
+
const now = Date.now();
|
|
34
|
+
for (const [key, ts] of this.cache) {
|
|
35
|
+
if (now - ts > DenyCache.TTL_MS) {
|
|
36
|
+
this.cache.delete(key);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/** Clear all entries. */
|
|
41
|
+
clear() {
|
|
42
|
+
this.cache.clear();
|
|
43
|
+
}
|
|
44
|
+
/** Get current cache size (for testing). */
|
|
45
|
+
get size() {
|
|
46
|
+
return this.cache.size;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
exports.DenyCache = DenyCache;
|
package/dist/types.d.ts
CHANGED
|
@@ -31,6 +31,7 @@ export interface LLMHandlerConfig {
|
|
|
31
31
|
timeout?: number;
|
|
32
32
|
enabled?: boolean;
|
|
33
33
|
sessionIsolation?: boolean;
|
|
34
|
+
depends?: string[];
|
|
34
35
|
}
|
|
35
36
|
/** Script handler config */
|
|
36
37
|
export interface ScriptHandlerConfig {
|
|
@@ -41,6 +42,7 @@ export interface ScriptHandlerConfig {
|
|
|
41
42
|
timeout?: number;
|
|
42
43
|
enabled?: boolean;
|
|
43
44
|
sessionIsolation?: boolean;
|
|
45
|
+
depends?: string[];
|
|
44
46
|
}
|
|
45
47
|
/** Inline handler config */
|
|
46
48
|
export interface InlineHandlerConfig {
|
|
@@ -51,6 +53,7 @@ export interface InlineHandlerConfig {
|
|
|
51
53
|
timeout?: number;
|
|
52
54
|
enabled?: boolean;
|
|
53
55
|
sessionIsolation?: boolean;
|
|
56
|
+
depends?: string[];
|
|
54
57
|
}
|
|
55
58
|
/** Union of all handler configs */
|
|
56
59
|
export type HandlerConfig = ScriptHandlerConfig | InlineHandlerConfig | LLMHandlerConfig;
|
|
@@ -125,3 +128,31 @@ export interface DiagnosticResult {
|
|
|
125
128
|
status: 'ok' | 'warn' | 'error';
|
|
126
129
|
message: string;
|
|
127
130
|
}
|
|
131
|
+
/** Plugin extras metadata (freeform, extensible) */
|
|
132
|
+
export interface PluginExtras {
|
|
133
|
+
skills?: string[];
|
|
134
|
+
agents?: string[];
|
|
135
|
+
readme?: string;
|
|
136
|
+
[key: string]: unknown;
|
|
137
|
+
}
|
|
138
|
+
/** Plugin manifest (clooks-plugin.yaml) */
|
|
139
|
+
export interface PluginManifest {
|
|
140
|
+
name: string;
|
|
141
|
+
version: string;
|
|
142
|
+
description?: string;
|
|
143
|
+
author?: string;
|
|
144
|
+
handlers: Partial<Record<HookEvent, HandlerConfig[]>>;
|
|
145
|
+
prefetch?: PrefetchKey[];
|
|
146
|
+
extras?: PluginExtras;
|
|
147
|
+
}
|
|
148
|
+
/** Installed plugin registry entry */
|
|
149
|
+
export interface InstalledPlugin {
|
|
150
|
+
name: string;
|
|
151
|
+
version: string;
|
|
152
|
+
path: string;
|
|
153
|
+
installedAt: string;
|
|
154
|
+
}
|
|
155
|
+
/** Plugin registry file format (installed.json) */
|
|
156
|
+
export interface PluginRegistry {
|
|
157
|
+
plugins: InstalledPlugin[];
|
|
158
|
+
}
|