@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
package/dist/cli.js
ADDED
|
@@ -0,0 +1,565 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
/**
|
|
4
|
+
* Oculi CLI — hook router + installer.
|
|
5
|
+
*
|
|
6
|
+
* Subcommands:
|
|
7
|
+
* oculi emit [adapter] — read stdin, route to gateway (default behaviour)
|
|
8
|
+
* oculi install [--agent <name>] — wire user-level hooks + write ~/.oculi/policy.yaml
|
|
9
|
+
* oculi init [--agent <name>] — alias of install (legacy name)
|
|
10
|
+
* oculi uninstall [--agent <name>] — remove Oculi hooks (and policy when no agent remains)
|
|
11
|
+
* oculi serve [options] — start the gateway + dashboard
|
|
12
|
+
* oculi tail [--filter <action>] — live-stream telemetry log
|
|
13
|
+
* oculi report [--json] [--hours N] — summarize recent telemetry
|
|
14
|
+
*
|
|
15
|
+
* Environment variables (emit only):
|
|
16
|
+
* GATEWAY_URL Base URL of the Oculi gateway (default: http://localhost:3000)
|
|
17
|
+
* GATEWAY_TOKEN Bearer token for gateway auth (optional)
|
|
18
|
+
*/
|
|
19
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
20
|
+
const fs_1 = require("fs");
|
|
21
|
+
const path_1 = require("path");
|
|
22
|
+
const router_1 = require("./routes/adapters/router");
|
|
23
|
+
const local_policy_1 = require("./services/local-policy");
|
|
24
|
+
const telemetry_log_1 = require("./services/telemetry-log");
|
|
25
|
+
const detect_1 = require("./install/detect");
|
|
26
|
+
const init_1 = require("./commands/init");
|
|
27
|
+
const uninstall_1 = require("./commands/uninstall");
|
|
28
|
+
const tail_1 = require("./commands/tail");
|
|
29
|
+
const report_1 = require("./commands/report");
|
|
30
|
+
const serve_1 = require("./commands/serve");
|
|
31
|
+
const data_dir_1 = require("./services/data-dir");
|
|
32
|
+
// ── version + help text ───────────────────────────────────────────────────────
|
|
33
|
+
let cachedVersion = null;
|
|
34
|
+
function getVersion() {
|
|
35
|
+
if (cachedVersion !== null)
|
|
36
|
+
return cachedVersion;
|
|
37
|
+
try {
|
|
38
|
+
const pkgPath = (0, path_1.join)(__dirname, '..', 'package.json');
|
|
39
|
+
const pkg = JSON.parse((0, fs_1.readFileSync)(pkgPath, 'utf8'));
|
|
40
|
+
cachedVersion = pkg.version ?? 'unknown';
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
cachedVersion = 'unknown';
|
|
44
|
+
}
|
|
45
|
+
return cachedVersion;
|
|
46
|
+
}
|
|
47
|
+
const TOP_LEVEL_HELP = `oculi — security layer for AI coding agents
|
|
48
|
+
|
|
49
|
+
Usage:
|
|
50
|
+
oculi <command> [options]
|
|
51
|
+
|
|
52
|
+
Commands:
|
|
53
|
+
install [--agent <name>] Wire user-level hooks (~/.claude/settings.json) and
|
|
54
|
+
write ~/.oculi/policy.yaml — captures every Claude
|
|
55
|
+
Code session on this machine.
|
|
56
|
+
uninstall [--agent <name>] Remove Oculi hooks (and policy when nothing remains wired)
|
|
57
|
+
serve [options] Start the gateway + dashboard (zero-config localhost)
|
|
58
|
+
tail [--filter <kind>] Live-stream telemetry log (~/.oculi/telemetry.jsonl)
|
|
59
|
+
report [--json] [--hours N] Summarize recent events
|
|
60
|
+
emit [adapter] Process a hook event from stdin (called by IDEs)
|
|
61
|
+
|
|
62
|
+
Run \`oculi <command> --help\` for command-specific help.
|
|
63
|
+
|
|
64
|
+
Environment variables:
|
|
65
|
+
GATEWAY_URL Gateway URL for hook forwarding (default: http://localhost:3000)
|
|
66
|
+
GATEWAY_TOKEN Bearer token for gateway auth (optional)
|
|
67
|
+
NO_COLOR Disable ANSI colors in tail output
|
|
68
|
+
|
|
69
|
+
Documentation: https://docs.oculisecurity.com
|
|
70
|
+
`;
|
|
71
|
+
const INSTALL_HELP = `oculi install — wire user-level hooks and write policy
|
|
72
|
+
|
|
73
|
+
Usage:
|
|
74
|
+
oculi install [--agent <name>]
|
|
75
|
+
|
|
76
|
+
Options:
|
|
77
|
+
--agent <name> Wire only this agent (claude-code, cursor). When omitted,
|
|
78
|
+
auto-detects installed agents on PATH and wires each.
|
|
79
|
+
|
|
80
|
+
Sets up Oculi at the user level so every Claude Code / Cursor session on this
|
|
81
|
+
machine — in any directory — is intercepted, evaluated against policy, and
|
|
82
|
+
appended to ~/.oculi/telemetry.jsonl.
|
|
83
|
+
|
|
84
|
+
Writes:
|
|
85
|
+
~/.oculi/policy.yaml (preserved if it already exists)
|
|
86
|
+
~/.claude/settings.json merged with PreToolUse / PostToolUse / Stop hooks
|
|
87
|
+
~/.cursor/hooks.json merged with hook entries (if Cursor is installed)
|
|
88
|
+
|
|
89
|
+
Idempotent — safe to re-run. Existing user-authored hooks are preserved.
|
|
90
|
+
|
|
91
|
+
After install, run \`oculi tail\` to live-stream events or \`oculi serve\` to
|
|
92
|
+
open the dashboard at http://127.0.0.1:3000/admin.
|
|
93
|
+
|
|
94
|
+
Examples:
|
|
95
|
+
oculi install # auto-detect, wire all installed agents
|
|
96
|
+
oculi install --agent claude-code # only wire Claude Code
|
|
97
|
+
|
|
98
|
+
Note: \`oculi init\` is a legacy alias for this command.
|
|
99
|
+
`;
|
|
100
|
+
const UNINSTALL_HELP = `oculi uninstall — remove user-level Oculi hooks
|
|
101
|
+
|
|
102
|
+
Usage:
|
|
103
|
+
oculi uninstall [--agent <name>]
|
|
104
|
+
|
|
105
|
+
Options:
|
|
106
|
+
--agent <name> Remove only this agent's hooks (claude-code, cursor). When
|
|
107
|
+
omitted, removes hooks for both agents.
|
|
108
|
+
|
|
109
|
+
Removes only the entries Oculi added from ~/.claude/settings.json and
|
|
110
|
+
~/.cursor/hooks.json; other hooks are preserved. Also removes
|
|
111
|
+
~/.oculi/policy.yaml when no agent has Oculi hooks remaining.
|
|
112
|
+
|
|
113
|
+
(Telemetry files in ~/.oculi/ are left in place.)
|
|
114
|
+
|
|
115
|
+
Examples:
|
|
116
|
+
oculi uninstall # remove all Oculi hooks
|
|
117
|
+
oculi uninstall --agent cursor # remove only Cursor's hooks
|
|
118
|
+
`;
|
|
119
|
+
const EMIT_HELP = `oculi emit — process a hook event from stdin
|
|
120
|
+
|
|
121
|
+
Reads a hook payload from stdin, evaluates local policy, forwards to the
|
|
122
|
+
gateway, and logs telemetry. Invoked by IDE hook commands; rarely run
|
|
123
|
+
directly.
|
|
124
|
+
|
|
125
|
+
Usage:
|
|
126
|
+
oculi emit [adapter]
|
|
127
|
+
oculi emit --adapter <name>
|
|
128
|
+
|
|
129
|
+
Adapters:
|
|
130
|
+
claude-code, cursor, windsurf
|
|
131
|
+
(auto-detected from payload if not specified)
|
|
132
|
+
|
|
133
|
+
Exit codes:
|
|
134
|
+
0 allowed (including fail-open when gateway is unreachable)
|
|
135
|
+
1 usage or parse error
|
|
136
|
+
2 denied by local policy or gateway
|
|
137
|
+
|
|
138
|
+
Example:
|
|
139
|
+
echo '{"hook_event_name":"PreToolUse",...}' | oculi emit claude-code
|
|
140
|
+
`;
|
|
141
|
+
const TAIL_HELP = `oculi tail — live-stream the telemetry log
|
|
142
|
+
|
|
143
|
+
Reads .oculi/telemetry.jsonl from the nearest project directory (walking
|
|
144
|
+
up) or ~/.oculi/telemetry.jsonl, prints recent entries, and watches for
|
|
145
|
+
new appends. SIGINT to exit.
|
|
146
|
+
|
|
147
|
+
Usage:
|
|
148
|
+
oculi tail [--filter <kind>] [--no-color]
|
|
149
|
+
|
|
150
|
+
Options:
|
|
151
|
+
--filter <kind> Show only entries with decision: allow | warn | deny
|
|
152
|
+
--no-color Disable ANSI colors (also: NO_COLOR env var)
|
|
153
|
+
|
|
154
|
+
Example:
|
|
155
|
+
oculi tail --filter deny
|
|
156
|
+
`;
|
|
157
|
+
const SERVE_HELP = `oculi serve — start the gateway and dashboard
|
|
158
|
+
|
|
159
|
+
Usage:
|
|
160
|
+
oculi serve [options]
|
|
161
|
+
|
|
162
|
+
Options:
|
|
163
|
+
--port N Port to listen on (default: 3000)
|
|
164
|
+
--bind ADDR Interface to bind to (default: 127.0.0.1)
|
|
165
|
+
Non-localhost binds require --auth.
|
|
166
|
+
--data-dir DIR Directory for sqlite + config (default: ~/.oculi/)
|
|
167
|
+
--auth SECRET Enable JWT auth on protected endpoints (≥32 chars).
|
|
168
|
+
Also accepted via OCULI_GATEWAY_TOKEN env var.
|
|
169
|
+
--opa-url URL Enable OPA-based central policy at this URL.
|
|
170
|
+
--verbose Enable verbose (debug-level) request logging.
|
|
171
|
+
|
|
172
|
+
Zero-config defaults (no flags, localhost):
|
|
173
|
+
No auth, no OPA, no rate limit, no upstreams.
|
|
174
|
+
Dashboard at http://127.0.0.1:3000/admin.
|
|
175
|
+
|
|
176
|
+
Server mode (--bind non-localhost):
|
|
177
|
+
Auth required (--auth or OCULI_GATEWAY_TOKEN) — refuses to start otherwise.
|
|
178
|
+
Rate limiting enabled (10 capacity / 2 per second per actor:tool).
|
|
179
|
+
|
|
180
|
+
Examples:
|
|
181
|
+
oculi serve # local dev
|
|
182
|
+
oculi serve --port 4000 # different port
|
|
183
|
+
oculi serve --bind 0.0.0.0 --auth $SEC # enterprise deployment
|
|
184
|
+
`;
|
|
185
|
+
const REPORT_HELP = `oculi report — summarize recent telemetry
|
|
186
|
+
|
|
187
|
+
Aggregates entries from the telemetry log over a time window.
|
|
188
|
+
|
|
189
|
+
Usage:
|
|
190
|
+
oculi report [--json] [--hours N]
|
|
191
|
+
|
|
192
|
+
Options:
|
|
193
|
+
--json JSON output instead of human-readable
|
|
194
|
+
--hours N Time window in hours (default: 24)
|
|
195
|
+
|
|
196
|
+
Example:
|
|
197
|
+
oculi report --hours 168 --json
|
|
198
|
+
`;
|
|
199
|
+
// ── helpers ──────────────────────────────────────────────────────────────────
|
|
200
|
+
function hasHelpFlag(argv) {
|
|
201
|
+
return argv.includes('--help') || argv.includes('-h');
|
|
202
|
+
}
|
|
203
|
+
async function readStdin() {
|
|
204
|
+
const chunks = [];
|
|
205
|
+
for await (const chunk of process.stdin) {
|
|
206
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
207
|
+
}
|
|
208
|
+
return Buffer.concat(chunks).toString('utf8').trim();
|
|
209
|
+
}
|
|
210
|
+
function hasFlag(argv, flag) {
|
|
211
|
+
return argv.includes(flag);
|
|
212
|
+
}
|
|
213
|
+
// ── emit ──────────────────────────────────────────────────────────────────────
|
|
214
|
+
function logTelemetry(event, decision, ruleIds = []) {
|
|
215
|
+
try {
|
|
216
|
+
const entry = (0, telemetry_log_1.eventToLogEntry)(event, decision, ruleIds);
|
|
217
|
+
(0, telemetry_log_1.appendTelemetry)(entry);
|
|
218
|
+
}
|
|
219
|
+
catch {
|
|
220
|
+
// Telemetry logging is best-effort — never block the hook response
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
async function runEmit(argv) {
|
|
224
|
+
if (hasHelpFlag(argv)) {
|
|
225
|
+
process.stdout.write(EMIT_HELP);
|
|
226
|
+
process.exit(0);
|
|
227
|
+
}
|
|
228
|
+
const stdin = await readStdin();
|
|
229
|
+
if (!stdin) {
|
|
230
|
+
process.stderr.write('oculi: no input on stdin\n');
|
|
231
|
+
process.exit(1);
|
|
232
|
+
}
|
|
233
|
+
// --adapter <name> OR first positional arg
|
|
234
|
+
const flagIdx = argv.indexOf('--adapter');
|
|
235
|
+
let adapterName;
|
|
236
|
+
if (flagIdx !== -1) {
|
|
237
|
+
adapterName = argv[flagIdx + 1];
|
|
238
|
+
}
|
|
239
|
+
else {
|
|
240
|
+
adapterName = argv.find((a) => !a.startsWith('--'));
|
|
241
|
+
}
|
|
242
|
+
const result = (0, router_1.routeStdin)(stdin, adapterName);
|
|
243
|
+
if (!result) {
|
|
244
|
+
const known = (0, router_1.registeredAdapters)().join(', ');
|
|
245
|
+
process.stderr.write(adapterName
|
|
246
|
+
? `oculi: unknown adapter '${adapterName}'. Known: ${known}\n`
|
|
247
|
+
: `oculi: could not auto-detect adapter from payload. Supported: ${known}\n`);
|
|
248
|
+
process.exit(1);
|
|
249
|
+
}
|
|
250
|
+
// ── Local policy evaluation (client-side, against OculiEvent) ─────────────
|
|
251
|
+
let localDecision = 'allow';
|
|
252
|
+
let localRuleIds = [];
|
|
253
|
+
let localReason;
|
|
254
|
+
const policyPath = (0, local_policy_1.findPolicyFile)();
|
|
255
|
+
if (policyPath) {
|
|
256
|
+
try {
|
|
257
|
+
const policyFile = (0, local_policy_1.loadPolicyFile)(policyPath);
|
|
258
|
+
if (policyFile.rules.length > 0) {
|
|
259
|
+
const policyResult = (0, local_policy_1.evaluateLocalPolicy)(result.event, policyFile.rules);
|
|
260
|
+
localDecision = policyResult.action;
|
|
261
|
+
localRuleIds = policyResult.matchedRules.map((r) => r.id);
|
|
262
|
+
if (policyResult.action === 'deny') {
|
|
263
|
+
const denyIds = policyResult.matchedRules
|
|
264
|
+
.filter((r) => r.action === 'deny')
|
|
265
|
+
.map((r) => r.id)
|
|
266
|
+
.join(', ');
|
|
267
|
+
localReason = `Blocked by Oculi rule: ${denyIds}`;
|
|
268
|
+
// Falls through to the gateway POST so the deny appears in the dashboard.
|
|
269
|
+
// The pre-decided headers below tell the gateway to record it verbatim.
|
|
270
|
+
}
|
|
271
|
+
if (policyResult.action === 'warn') {
|
|
272
|
+
const warnIds = policyResult.matchedRules
|
|
273
|
+
.filter((r) => r.action === 'warn')
|
|
274
|
+
.map((r) => r.id)
|
|
275
|
+
.join(', ');
|
|
276
|
+
localReason = `Oculi warning: ${warnIds}`;
|
|
277
|
+
process.stderr.write(`oculi: warning from local policy: ${warnIds}\n`);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
catch (err) {
|
|
282
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
283
|
+
process.stderr.write(`oculi: failed to load local policy (${policyPath}): ${msg} — skipping\n`);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
// ── Forward to gateway ────────────────────────────────────────────────────
|
|
287
|
+
const gatewayUrl = (process.env.GATEWAY_URL ?? 'http://localhost:3000').replace(/\/$/, '');
|
|
288
|
+
const token = process.env.GATEWAY_TOKEN;
|
|
289
|
+
const headers = {
|
|
290
|
+
'Content-Type': 'application/json',
|
|
291
|
+
'X-Oculi-Adapter': result.adapterName,
|
|
292
|
+
};
|
|
293
|
+
if (token)
|
|
294
|
+
headers['Authorization'] = `Bearer ${token}`;
|
|
295
|
+
// When local policy decided, tell the gateway to record it verbatim.
|
|
296
|
+
// Without these headers the gateway re-evaluates with its own (central) policy,
|
|
297
|
+
// which doesn't know about local rules, so the decision label would be wrong.
|
|
298
|
+
if (localDecision !== 'allow') {
|
|
299
|
+
headers['X-Oculi-Local-Decision'] = localDecision;
|
|
300
|
+
if (localReason)
|
|
301
|
+
headers['X-Oculi-Local-Reason'] = localReason;
|
|
302
|
+
if (localRuleIds.length > 0)
|
|
303
|
+
headers['X-Oculi-Local-Rule-Ids'] = localRuleIds.join(',');
|
|
304
|
+
}
|
|
305
|
+
let res;
|
|
306
|
+
try {
|
|
307
|
+
res = await fetch(`${gatewayUrl}/v1/hooks`, {
|
|
308
|
+
method: 'POST',
|
|
309
|
+
headers,
|
|
310
|
+
body: stdin,
|
|
311
|
+
signal: AbortSignal.timeout(1500),
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
catch {
|
|
315
|
+
process.stderr.write(`oculi: gateway unreachable (${gatewayUrl}) — failing open\n`);
|
|
316
|
+
writeDecisionOutput(result.adapter, result.event, localDecision, localReason, undefined);
|
|
317
|
+
logTelemetry(result.event, localDecision, localRuleIds);
|
|
318
|
+
process.exit(0);
|
|
319
|
+
}
|
|
320
|
+
// Non-2xx is treated the same as unreachable: the gateway gave no usable
|
|
321
|
+
// verdict, so defer to localDecision. Without this, a gateway returning 401
|
|
322
|
+
// (auth misconfig) or 500 (internal error) would translate to a blanket deny.
|
|
323
|
+
if (!res.ok) {
|
|
324
|
+
process.stderr.write(`oculi: gateway error ${res.status} (${gatewayUrl}) — failing open\n`);
|
|
325
|
+
writeDecisionOutput(result.adapter, result.event, localDecision, localReason, undefined);
|
|
326
|
+
logTelemetry(result.event, localDecision, localRuleIds);
|
|
327
|
+
process.exit(0);
|
|
328
|
+
}
|
|
329
|
+
// Gateway returned 2xx. Final decision is driven purely by localDecision —
|
|
330
|
+
// for claude-code the gateway encodes verdicts in the response body, not the
|
|
331
|
+
// status code, so res.ok no longer carries decision signal once we get here.
|
|
332
|
+
const responseText = await res.text();
|
|
333
|
+
const finalDecision = localDecision === 'deny' ? 'deny'
|
|
334
|
+
: localDecision === 'warn' ? 'warn'
|
|
335
|
+
: 'allow';
|
|
336
|
+
// For Stop, never propagate denies — keeps Claude's stop flow predictable.
|
|
337
|
+
const effectiveDecision = result.event.hook_event_name === 'Stop' ? 'allow' : finalDecision;
|
|
338
|
+
logTelemetry(result.event, effectiveDecision, localRuleIds);
|
|
339
|
+
writeDecisionOutput(result.adapter, result.event, effectiveDecision, localReason, responseText);
|
|
340
|
+
process.exit(0);
|
|
341
|
+
}
|
|
342
|
+
/**
|
|
343
|
+
* Emit the adapter's stdout response for a final decision.
|
|
344
|
+
*
|
|
345
|
+
* Reason precedence:
|
|
346
|
+
* - warn → localReason (always set by the local-policy warn branch)
|
|
347
|
+
* - deny → localReason if local policy decided; otherwise extract from the
|
|
348
|
+
* gateway response body; otherwise a generic "Blocked by Oculi gateway".
|
|
349
|
+
*/
|
|
350
|
+
function writeDecisionOutput(adapter, event, decision, localReason, gatewayBody) {
|
|
351
|
+
if (decision === 'allow') {
|
|
352
|
+
process.stdout.write(adapter.formatAllow(event));
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
if (decision === 'warn') {
|
|
356
|
+
const warnReason = localReason ?? 'Oculi warning';
|
|
357
|
+
const warnOutput = adapter.formatWarn
|
|
358
|
+
? adapter.formatWarn(warnReason, event)
|
|
359
|
+
: adapter.formatAllow(event);
|
|
360
|
+
process.stdout.write(warnOutput);
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
const reason = localReason ?? extractGatewayReason(gatewayBody) ?? 'Blocked by Oculi gateway';
|
|
364
|
+
process.stdout.write(adapter.formatDeny(reason, event));
|
|
365
|
+
}
|
|
366
|
+
/**
|
|
367
|
+
* Best-effort extraction of a human-readable reason from a gateway response.
|
|
368
|
+
* Returns the first non-empty string found among the common keys, or null.
|
|
369
|
+
*/
|
|
370
|
+
function extractGatewayReason(body) {
|
|
371
|
+
if (!body)
|
|
372
|
+
return null;
|
|
373
|
+
try {
|
|
374
|
+
const parsed = JSON.parse(body);
|
|
375
|
+
for (const key of ['reason', 'permissionDecisionReason', 'message']) {
|
|
376
|
+
const value = parsed[key];
|
|
377
|
+
if (typeof value === 'string' && value.trim().length > 0) {
|
|
378
|
+
return value;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
catch {
|
|
383
|
+
// Non-JSON body — ignore.
|
|
384
|
+
}
|
|
385
|
+
return null;
|
|
386
|
+
}
|
|
387
|
+
// ── init / uninstall ──────────────────────────────────────────────────────────
|
|
388
|
+
function parseAgentFlag(argv) {
|
|
389
|
+
const i = argv.indexOf('--agent');
|
|
390
|
+
if (i === -1)
|
|
391
|
+
return undefined;
|
|
392
|
+
const v = argv[i + 1];
|
|
393
|
+
if (!v || !detect_1.AGENTS.includes(v)) {
|
|
394
|
+
process.stderr.write(`oculi: --agent expects one of: ${detect_1.AGENTS.join(', ')}\n`);
|
|
395
|
+
process.exit(1);
|
|
396
|
+
}
|
|
397
|
+
return v;
|
|
398
|
+
}
|
|
399
|
+
function runInstallCommand(argv) {
|
|
400
|
+
if (hasHelpFlag(argv)) {
|
|
401
|
+
process.stdout.write(INSTALL_HELP);
|
|
402
|
+
process.exit(0);
|
|
403
|
+
}
|
|
404
|
+
const agent = parseAgentFlag(argv);
|
|
405
|
+
const r = (0, init_1.runInit)({ agent });
|
|
406
|
+
if (r.exitCode !== 0)
|
|
407
|
+
process.exit(r.exitCode);
|
|
408
|
+
}
|
|
409
|
+
function runUninstallCommand(argv) {
|
|
410
|
+
if (hasHelpFlag(argv)) {
|
|
411
|
+
process.stdout.write(UNINSTALL_HELP);
|
|
412
|
+
process.exit(0);
|
|
413
|
+
}
|
|
414
|
+
const agent = parseAgentFlag(argv);
|
|
415
|
+
(0, uninstall_1.runUninstall)({ agent });
|
|
416
|
+
}
|
|
417
|
+
// ── tail ─────────────────────────────────────────────────────────────────────
|
|
418
|
+
function runTailCommand(argv) {
|
|
419
|
+
if (hasHelpFlag(argv)) {
|
|
420
|
+
process.stdout.write(TAIL_HELP);
|
|
421
|
+
process.exit(0);
|
|
422
|
+
}
|
|
423
|
+
const filterIdx = argv.indexOf('--filter');
|
|
424
|
+
let filter;
|
|
425
|
+
if (filterIdx !== -1) {
|
|
426
|
+
const val = argv[filterIdx + 1];
|
|
427
|
+
if (val === 'allow' || val === 'warn' || val === 'deny') {
|
|
428
|
+
filter = val;
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
(0, tail_1.runTail)({
|
|
432
|
+
filter,
|
|
433
|
+
noColor: hasFlag(argv, '--no-color'),
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
// ── report ───────────────────────────────────────────────────────────────────
|
|
437
|
+
function runReportCommand(argv) {
|
|
438
|
+
if (hasHelpFlag(argv)) {
|
|
439
|
+
process.stdout.write(REPORT_HELP);
|
|
440
|
+
process.exit(0);
|
|
441
|
+
}
|
|
442
|
+
const hoursIdx = argv.indexOf('--hours');
|
|
443
|
+
let hours = 24;
|
|
444
|
+
if (hoursIdx !== -1) {
|
|
445
|
+
const val = parseInt(argv[hoursIdx + 1], 10);
|
|
446
|
+
if (!isNaN(val) && val > 0)
|
|
447
|
+
hours = val;
|
|
448
|
+
}
|
|
449
|
+
(0, report_1.runReport)({
|
|
450
|
+
json: hasFlag(argv, '--json'),
|
|
451
|
+
hours,
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
// ── serve ────────────────────────────────────────────────────────────────────
|
|
455
|
+
function parseServeArgs(argv) {
|
|
456
|
+
const opts = {
|
|
457
|
+
port: 3000,
|
|
458
|
+
bind: '127.0.0.1',
|
|
459
|
+
dataDir: (0, data_dir_1.defaultDataDir)(),
|
|
460
|
+
};
|
|
461
|
+
for (let i = 0; i < argv.length; i++) {
|
|
462
|
+
const arg = argv[i];
|
|
463
|
+
const next = argv[i + 1];
|
|
464
|
+
switch (arg) {
|
|
465
|
+
case '--port':
|
|
466
|
+
if (!next)
|
|
467
|
+
throw new Error('--port requires a value');
|
|
468
|
+
opts.port = parseInt(next, 10);
|
|
469
|
+
if (Number.isNaN(opts.port) || opts.port <= 0 || opts.port > 65535) {
|
|
470
|
+
throw new Error(`invalid --port value: ${next}`);
|
|
471
|
+
}
|
|
472
|
+
i++;
|
|
473
|
+
break;
|
|
474
|
+
case '--bind':
|
|
475
|
+
if (!next)
|
|
476
|
+
throw new Error('--bind requires a value');
|
|
477
|
+
opts.bind = next;
|
|
478
|
+
i++;
|
|
479
|
+
break;
|
|
480
|
+
case '--data-dir':
|
|
481
|
+
if (!next)
|
|
482
|
+
throw new Error('--data-dir requires a value');
|
|
483
|
+
opts.dataDir = next;
|
|
484
|
+
i++;
|
|
485
|
+
break;
|
|
486
|
+
case '--auth':
|
|
487
|
+
if (!next)
|
|
488
|
+
throw new Error('--auth requires a secret');
|
|
489
|
+
opts.auth = next;
|
|
490
|
+
i++;
|
|
491
|
+
break;
|
|
492
|
+
case '--opa-url':
|
|
493
|
+
if (!next)
|
|
494
|
+
throw new Error('--opa-url requires a value');
|
|
495
|
+
opts.opaUrl = next;
|
|
496
|
+
i++;
|
|
497
|
+
break;
|
|
498
|
+
case '--verbose':
|
|
499
|
+
opts.verbose = true;
|
|
500
|
+
break;
|
|
501
|
+
default:
|
|
502
|
+
throw new Error(`unknown serve option: ${arg}`);
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
return opts;
|
|
506
|
+
}
|
|
507
|
+
async function runServeCommand(argv) {
|
|
508
|
+
if (hasHelpFlag(argv)) {
|
|
509
|
+
process.stdout.write(SERVE_HELP);
|
|
510
|
+
process.exit(0);
|
|
511
|
+
}
|
|
512
|
+
let opts;
|
|
513
|
+
try {
|
|
514
|
+
opts = parseServeArgs(argv);
|
|
515
|
+
}
|
|
516
|
+
catch (err) {
|
|
517
|
+
process.stderr.write(`oculi: ${err.message}\n`);
|
|
518
|
+
process.stderr.write('Run `oculi serve --help` for usage.\n');
|
|
519
|
+
process.exit(1);
|
|
520
|
+
}
|
|
521
|
+
await (0, serve_1.runServe)(opts);
|
|
522
|
+
}
|
|
523
|
+
// ── main ──────────────────────────────────────────────────────────────────────
|
|
524
|
+
async function main() {
|
|
525
|
+
const argv = process.argv.slice(2);
|
|
526
|
+
const [subcmd, ...rest] = argv;
|
|
527
|
+
if (subcmd === '--help' || subcmd === '-h') {
|
|
528
|
+
process.stdout.write(TOP_LEVEL_HELP);
|
|
529
|
+
process.exit(0);
|
|
530
|
+
}
|
|
531
|
+
if (subcmd === '--version' || subcmd === '-v') {
|
|
532
|
+
process.stdout.write(`${getVersion()}\n`);
|
|
533
|
+
process.exit(0);
|
|
534
|
+
}
|
|
535
|
+
if (subcmd && subcmd.startsWith('-')) {
|
|
536
|
+
process.stderr.write(`oculi: unknown option '${subcmd}'. Run \`oculi --help\` for usage.\n`);
|
|
537
|
+
process.exit(1);
|
|
538
|
+
}
|
|
539
|
+
if (!subcmd || subcmd === 'emit') {
|
|
540
|
+
await runEmit(rest);
|
|
541
|
+
}
|
|
542
|
+
else if (subcmd === 'serve') {
|
|
543
|
+
await runServeCommand(rest);
|
|
544
|
+
}
|
|
545
|
+
else if (subcmd === 'uninstall') {
|
|
546
|
+
runUninstallCommand(rest);
|
|
547
|
+
}
|
|
548
|
+
else if (subcmd === 'install' || subcmd === 'init') {
|
|
549
|
+
runInstallCommand(rest);
|
|
550
|
+
}
|
|
551
|
+
else if (subcmd === 'tail') {
|
|
552
|
+
runTailCommand(rest);
|
|
553
|
+
}
|
|
554
|
+
else if (subcmd === 'report') {
|
|
555
|
+
runReportCommand(rest);
|
|
556
|
+
}
|
|
557
|
+
else {
|
|
558
|
+
// Backwards-compat: `oculi claude-code` with no subcommand → emit
|
|
559
|
+
await runEmit(argv);
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
main().catch((err) => {
|
|
563
|
+
process.stderr.write(`oculi: ${err.message}\n`);
|
|
564
|
+
process.exit(1);
|
|
565
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { Agent } from '../install/detect';
|
|
2
|
+
export interface InitOpts {
|
|
3
|
+
agent?: Agent;
|
|
4
|
+
/** Override the home directory (tests only). Production callers should omit this. */
|
|
5
|
+
home?: string;
|
|
6
|
+
}
|
|
7
|
+
export interface InitResult {
|
|
8
|
+
exitCode: 0 | 1;
|
|
9
|
+
policyPath: string | null;
|
|
10
|
+
policyCreated: boolean;
|
|
11
|
+
wired: Agent[];
|
|
12
|
+
added: Partial<Record<Agent, string[]>>;
|
|
13
|
+
}
|
|
14
|
+
export declare function runInit(opts?: InitOpts): InitResult;
|