custodex 1.0.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/package.json ADDED
@@ -0,0 +1,21 @@
1
+ {
2
+ "name": "custodex",
3
+ "version": "1.0.0",
4
+ "description": "Universal AI agent governance — one command to govern Claude, Cursor, Gemini CLI, and OpenCode",
5
+ "type": "module",
6
+ "bin": { "custodex": "dist/index.js" },
7
+ "main": "dist/index.js",
8
+ "scripts": {
9
+ "build": "tsc",
10
+ "prepublishOnly": "npm run build"
11
+ },
12
+ "keywords": ["ai-governance", "mcp", "claude", "cursor", "gemini", "agent-security"],
13
+ "author": "Custodex",
14
+ "license": "MIT",
15
+ "devDependencies": {
16
+ "@types/node": "^20.11.0",
17
+ "typescript": "^5.3.0"
18
+ },
19
+ "engines": { "node": ">=18.0.0" },
20
+ "files": ["dist/", "hooks/", "plugins/"]
21
+ }
@@ -0,0 +1,330 @@
1
+ /**
2
+ * OpenCode Plugin for Custodex AI Governance
3
+ *
4
+ * Provides mandatory pre-execution verification and post-execution telemetry
5
+ * for every tool call made by OpenCode sessions. Installed automatically by
6
+ * `npx custodex` into ~/.config/opencode/plugins/custodex-opencode.ts.
7
+ *
8
+ * Runtime: Bun (used by OpenCode). Uses native fetch — zero dependencies.
9
+ *
10
+ * Config resolution order:
11
+ * 1. ~/.custodex/config.json { apiKey, baseUrl }
12
+ * 2. CUSTODEX_API_KEY + CUSTODEX_BASE_URL environment variables
13
+ * 3. Warn and return empty hooks (graceful no-op)
14
+ */
15
+
16
+ import { readFileSync, mkdirSync } from "fs";
17
+ import { join } from "path";
18
+ import { homedir } from "os";
19
+
20
+ // ─── Types ────────────────────────────────────────────────────────────────────
21
+
22
+ /** Shape of ~/.custodex/config.json */
23
+ interface CustodexConfig {
24
+ apiKey: string;
25
+ baseUrl: string;
26
+ }
27
+
28
+ /** Custodex /api/verify response */
29
+ interface VerifyResponse {
30
+ decision: "allowed" | "denied";
31
+ reason?: string;
32
+ enforcementLevel?: "warn" | "throttle" | "block" | "suspend";
33
+ violatedPolicies?: string[];
34
+ }
35
+
36
+ /** Custodex /api/agents/register response */
37
+ interface RegisterResponse {
38
+ agentId: string;
39
+ name: string;
40
+ status: string;
41
+ }
42
+
43
+ /** OpenCode session.created event payload */
44
+ interface SessionCreatedInput {
45
+ sessionId?: string;
46
+ [key: string]: unknown;
47
+ }
48
+
49
+ /** OpenCode tool.execute.before event payload */
50
+ interface ToolExecuteInput {
51
+ tool?: string;
52
+ [key: string]: unknown;
53
+ }
54
+
55
+ /** OpenCode tool.execute args/output payload */
56
+ interface ToolExecuteOutput {
57
+ args?: Record<string, unknown>;
58
+ result?: unknown;
59
+ [key: string]: unknown;
60
+ }
61
+
62
+ /** OpenCode plugin context object */
63
+ interface OpenCodeContext {
64
+ project?: string;
65
+ directory?: string;
66
+ [key: string]: unknown;
67
+ }
68
+
69
+ // ─── Config Loading ───────────────────────────────────────────────────────────
70
+
71
+ /**
72
+ * Load Custodex configuration from disk or environment variables.
73
+ *
74
+ * Tries ~/.custodex/config.json first, falls back to env vars.
75
+ * Returns null if neither source provides both apiKey and baseUrl.
76
+ */
77
+ function loadConfig(): CustodexConfig | null {
78
+ const configPath = join(homedir(), ".custodex", "config.json");
79
+
80
+ try {
81
+ const raw = readFileSync(configPath, "utf8");
82
+ const parsed = JSON.parse(raw) as Partial<CustodexConfig>;
83
+ if (parsed.apiKey && parsed.baseUrl) {
84
+ return { apiKey: parsed.apiKey, baseUrl: parsed.baseUrl };
85
+ }
86
+ } catch {
87
+ // File does not exist or is not valid JSON — fall through to env vars
88
+ }
89
+
90
+ const apiKey = process.env["CUSTODEX_API_KEY"];
91
+ const baseUrl = process.env["CUSTODEX_BASE_URL"];
92
+
93
+ if (apiKey && baseUrl) {
94
+ return { apiKey, baseUrl };
95
+ }
96
+
97
+ return null;
98
+ }
99
+
100
+ // ─── HTTP Client ──────────────────────────────────────────────────────────────
101
+
102
+ /**
103
+ * Send a POST request to the Custodex API.
104
+ *
105
+ * Uses native fetch (available in Node 18+ / Bun). Times out after 4 seconds.
106
+ * Returns parsed JSON on success or null on any error (network, timeout, non-2xx).
107
+ *
108
+ * @param config - Custodex API configuration
109
+ * @param path - API path, e.g. "/api/verify"
110
+ * @param body - JSON-serialisable request body
111
+ */
112
+ async function custodexFetch(
113
+ config: CustodexConfig,
114
+ path: string,
115
+ body: object
116
+ ): Promise<unknown> {
117
+ const controller = new AbortController();
118
+ const timeout = setTimeout(() => controller.abort(), 4_000);
119
+
120
+ try {
121
+ const response = await fetch(`${config.baseUrl}${path}`, {
122
+ method: "POST",
123
+ headers: {
124
+ "Content-Type": "application/json",
125
+ Authorization: `Bearer ${config.apiKey}`,
126
+ },
127
+ body: JSON.stringify(body),
128
+ signal: controller.signal,
129
+ });
130
+
131
+ if (!response.ok) {
132
+ return null;
133
+ }
134
+
135
+ return (await response.json()) as unknown;
136
+ } catch {
137
+ // Network error, timeout, or parse failure — treat as null
138
+ return null;
139
+ } finally {
140
+ clearTimeout(timeout);
141
+ }
142
+ }
143
+
144
+ // ─── Tool → Action / Scope Mapping ───────────────────────────────────────────
145
+
146
+ /**
147
+ * Map an OpenCode tool name to a Custodex action string.
148
+ *
149
+ * @param tool - The tool name reported by OpenCode (e.g. "Write", "Bash")
150
+ */
151
+ function mapToolToAction(tool: string): string {
152
+ if (/write|edit|patch/i.test(tool)) return "file:write";
153
+ if (/read/i.test(tool)) return "file:read";
154
+ if (/bash|shell|exec/i.test(tool)) return "shell:execute";
155
+ if (/glob|grep|search|find/i.test(tool)) return "file:search";
156
+ if (/fetch|web|http/i.test(tool)) return "web:access";
157
+ if (/agent|task|spawn/i.test(tool)) return "agent:spawn";
158
+ return "tool:use";
159
+ }
160
+
161
+ /**
162
+ * Map an OpenCode tool name to a Custodex scope string.
163
+ *
164
+ * @param tool - The tool name reported by OpenCode
165
+ */
166
+ function mapToolToScope(tool: string): string {
167
+ if (/write|edit|patch/i.test(tool)) return "file:write";
168
+ if (/read/i.test(tool)) return "file:read";
169
+ if (/bash|shell|exec/i.test(tool)) return "shell:execute";
170
+ if (/glob|grep|search|find/i.test(tool)) return "file:search";
171
+ if (/fetch|web|http/i.test(tool)) return "web:access";
172
+ return "tool:use";
173
+ }
174
+
175
+ // ─── State ────────────────────────────────────────────────────────────────────
176
+
177
+ /**
178
+ * In-memory map from OpenCode session ID → Custodex agent ID.
179
+ *
180
+ * OpenCode runs in a single process, so a module-level Map is safe.
181
+ * The key "current" is used when no explicit session ID is present.
182
+ */
183
+ const agentIds = new Map<string, string>();
184
+
185
+ /**
186
+ * Ensure the Custodex state directory exists.
187
+ * Created lazily on first session to avoid side-effects at import time.
188
+ */
189
+ const stateDir = join(homedir(), ".custodex", "state");
190
+ let stateDirEnsured = false;
191
+
192
+ function ensureStateDir(): void {
193
+ if (!stateDirEnsured) {
194
+ mkdirSync(stateDir, { recursive: true });
195
+ stateDirEnsured = true;
196
+ }
197
+ }
198
+
199
+ // ─── Plugin Export ────────────────────────────────────────────────────────────
200
+
201
+ /**
202
+ * CustodexGovernance — OpenCode plugin entry point.
203
+ *
204
+ * Receives the OpenCode context object and returns an event-hook map.
205
+ * All hooks are async. Throwing in `tool.execute.before` blocks the action.
206
+ *
207
+ * @param ctx - OpenCode context with project/directory metadata
208
+ */
209
+ export const CustodexGovernance = async (ctx: OpenCodeContext): Promise<Record<string, unknown>> => {
210
+ const config = loadConfig();
211
+
212
+ if (!config) {
213
+ console.warn(
214
+ "[Custodex] No configuration found. " +
215
+ "Run `npx custodex` to set up governance, or set " +
216
+ "CUSTODEX_API_KEY and CUSTODEX_BASE_URL environment variables."
217
+ );
218
+ return {};
219
+ }
220
+
221
+ ensureStateDir();
222
+
223
+ return {
224
+ // ── Register agent on session creation ──────────────────────────────────
225
+ /**
226
+ * Called when OpenCode starts a new session.
227
+ *
228
+ * Registers an ephemeral agent with Custodex and stores the resulting
229
+ * agentId in module-level state for use by subsequent hooks.
230
+ */
231
+ "session.created": async (input: SessionCreatedInput): Promise<void> => {
232
+ const sessionKey = input.sessionId ?? "current";
233
+ const agentName = `${ctx.project ?? "unknown"}-opencode`;
234
+
235
+ const result = await custodexFetch(config, "/api/agents/register", {
236
+ name: agentName,
237
+ scopes: ["read", "write", "execute"],
238
+ metadata: {
239
+ source: "opencode-plugin",
240
+ project: ctx.directory ?? ctx.project ?? "unknown",
241
+ sessionId: sessionKey,
242
+ },
243
+ protocol: "mcp",
244
+ }) as RegisterResponse | null;
245
+
246
+ if (result?.agentId) {
247
+ agentIds.set(sessionKey, result.agentId);
248
+ // Always keep a "current" alias for hooks that don't receive sessionId
249
+ agentIds.set("current", result.agentId);
250
+ }
251
+ },
252
+
253
+ // ── MANDATORY: verify before tool execution ──────────────────────────────
254
+ /**
255
+ * Called synchronously before every tool invocation.
256
+ *
257
+ * Queries Custodex /api/verify with the mapped action and scope.
258
+ * If the decision is "denied", throws an Error to block the tool call.
259
+ * OpenCode treats a thrown error in this hook as a hard block.
260
+ *
261
+ * A null response (API down / timeout) defaults to allow so that a
262
+ * Custodex outage does not halt developer workflows. Change to throw
263
+ * if your policy requires fail-closed behaviour.
264
+ */
265
+ "tool.execute.before": async (
266
+ input: ToolExecuteInput,
267
+ output: ToolExecuteOutput
268
+ ): Promise<void> => {
269
+ const agentId = agentIds.get("current");
270
+ const toolName = input.tool ?? "unknown";
271
+ const action = mapToolToAction(toolName);
272
+ const scope = mapToolToScope(toolName);
273
+
274
+ // Truncate tool args to avoid sending large payloads
275
+ const toolInput = JSON.stringify(output.args ?? {}).slice(0, 500);
276
+
277
+ const result = await custodexFetch(config, "/api/verify", {
278
+ action,
279
+ scope,
280
+ metadata: {
281
+ agentId,
282
+ toolName,
283
+ toolInput,
284
+ source: "opencode",
285
+ },
286
+ }) as VerifyResponse | null;
287
+
288
+ if (result?.decision === "denied") {
289
+ throw new Error(
290
+ `[Custodex] Governance denied: ${result.reason ?? "Policy violation"}` +
291
+ (result.violatedPolicies?.length
292
+ ? ` (policies: ${result.violatedPolicies.join(", ")})`
293
+ : "")
294
+ );
295
+ }
296
+ },
297
+
298
+ // ── Log telemetry after tool execution (fire-and-forget) ─────────────────
299
+ /**
300
+ * Called after every tool invocation completes (success or error).
301
+ *
302
+ * Posts telemetry to Custodex asynchronously — never awaited, never
303
+ * allowed to propagate errors. A failure here must not affect the session.
304
+ */
305
+ "tool.execute.after": async (
306
+ input: ToolExecuteInput,
307
+ _output: ToolExecuteOutput
308
+ ): Promise<void> => {
309
+ const agentId = agentIds.get("current");
310
+ const toolName = input.tool ?? "unknown";
311
+ const action = mapToolToAction(toolName);
312
+ const scope = mapToolToScope(toolName);
313
+
314
+ // Fire-and-forget: intentionally not awaited
315
+ custodexFetch(config, "/api/telemetry", {
316
+ action,
317
+ scope,
318
+ decision: "allowed",
319
+ latencyMs: 0,
320
+ metadata: {
321
+ agentId,
322
+ toolName,
323
+ source: "opencode",
324
+ },
325
+ }).catch((): void => {
326
+ // Silently discard telemetry errors — never block the session
327
+ });
328
+ },
329
+ };
330
+ };