@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.
Files changed (85) hide show
  1. package/LICENSE.txt +201 -0
  2. package/README.md +67 -0
  3. package/dist/cli.d.ts +18 -0
  4. package/dist/cli.js +565 -0
  5. package/dist/commands/init.d.ts +14 -0
  6. package/dist/commands/init.js +135 -0
  7. package/dist/commands/report.d.ts +33 -0
  8. package/dist/commands/report.js +145 -0
  9. package/dist/commands/serve.d.ts +27 -0
  10. package/dist/commands/serve.js +163 -0
  11. package/dist/commands/tail.d.ts +7 -0
  12. package/dist/commands/tail.js +211 -0
  13. package/dist/commands/uninstall.d.ts +13 -0
  14. package/dist/commands/uninstall.js +111 -0
  15. package/dist/config.d.ts +17 -0
  16. package/dist/config.js +90 -0
  17. package/dist/index.d.ts +1 -0
  18. package/dist/index.js +35 -0
  19. package/dist/init.d.ts +9 -0
  20. package/dist/init.js +50 -0
  21. package/dist/install/claude-code.d.ts +13 -0
  22. package/dist/install/claude-code.js +118 -0
  23. package/dist/install/cursor.d.ts +13 -0
  24. package/dist/install/cursor.js +119 -0
  25. package/dist/install/detect.d.ts +5 -0
  26. package/dist/install/detect.js +64 -0
  27. package/dist/middleware/auth.d.ts +15 -0
  28. package/dist/middleware/auth.js +116 -0
  29. package/dist/routes/adapters/claude-code.d.ts +38 -0
  30. package/dist/routes/adapters/claude-code.js +125 -0
  31. package/dist/routes/adapters/cursor.d.ts +21 -0
  32. package/dist/routes/adapters/cursor.js +139 -0
  33. package/dist/routes/adapters/index.d.ts +16 -0
  34. package/dist/routes/adapters/index.js +56 -0
  35. package/dist/routes/adapters/router.d.ts +31 -0
  36. package/dist/routes/adapters/router.js +97 -0
  37. package/dist/routes/adapters/schema.d.ts +141 -0
  38. package/dist/routes/adapters/schema.js +83 -0
  39. package/dist/routes/adapters/windsurf.d.ts +6 -0
  40. package/dist/routes/adapters/windsurf.js +48 -0
  41. package/dist/routes/admin.d.ts +15 -0
  42. package/dist/routes/admin.js +399 -0
  43. package/dist/routes/call.d.ts +13 -0
  44. package/dist/routes/call.js +68 -0
  45. package/dist/routes/events.d.ts +7 -0
  46. package/dist/routes/events.js +125 -0
  47. package/dist/routes/health.d.ts +2 -0
  48. package/dist/routes/health.js +12 -0
  49. package/dist/routes/hooks.d.ts +11 -0
  50. package/dist/routes/hooks.js +166 -0
  51. package/dist/routes/mcp.d.ts +10 -0
  52. package/dist/routes/mcp.js +170 -0
  53. package/dist/routes/openai-tools.d.ts +9 -0
  54. package/dist/routes/openai-tools.js +121 -0
  55. package/dist/server.d.ts +11 -0
  56. package/dist/server.js +118 -0
  57. package/dist/services/audit.d.ts +92 -0
  58. package/dist/services/audit.js +388 -0
  59. package/dist/services/data-dir.d.ts +7 -0
  60. package/dist/services/data-dir.js +61 -0
  61. package/dist/services/local-policy-templates.d.ts +9 -0
  62. package/dist/services/local-policy-templates.js +47 -0
  63. package/dist/services/local-policy.d.ts +39 -0
  64. package/dist/services/local-policy.js +172 -0
  65. package/dist/services/policy-store.d.ts +82 -0
  66. package/dist/services/policy-store.js +331 -0
  67. package/dist/services/policy.d.ts +8 -0
  68. package/dist/services/policy.js +126 -0
  69. package/dist/services/ratelimit.d.ts +26 -0
  70. package/dist/services/ratelimit.js +60 -0
  71. package/dist/services/sanitizer.d.ts +9 -0
  72. package/dist/services/sanitizer.js +73 -0
  73. package/dist/services/sqlite-loader.d.ts +4 -0
  74. package/dist/services/sqlite-loader.js +16 -0
  75. package/dist/services/telemetry-log.d.ts +76 -0
  76. package/dist/services/telemetry-log.js +260 -0
  77. package/dist/services/tool-executor.d.ts +46 -0
  78. package/dist/services/tool-executor.js +167 -0
  79. package/dist/services/upstream.d.ts +18 -0
  80. package/dist/services/upstream.js +72 -0
  81. package/dist/types.d.ts +112 -0
  82. package/dist/types.js +3 -0
  83. package/package.json +72 -0
  84. package/public/favicon.svg +4 -0
  85. package/public/index.html +3893 -0
@@ -0,0 +1,92 @@
1
+ import { AuditRecord } from '../types';
2
+ import { AppConfig } from '../config';
3
+ export declare class AuditService {
4
+ private readonly db;
5
+ readonly storeFullArgs: boolean;
6
+ constructor(config: AppConfig);
7
+ private initialize;
8
+ /** Read the persisted byte offset for an absolute jsonl path. Zero when missing. */
9
+ private getReplayOffset;
10
+ private setReplayOffset;
11
+ /** SHA-256 hex prefix of JSON-serialised data */
12
+ hash(data: unknown): string;
13
+ log(record: Omit<AuditRecord, 'id'>): void;
14
+ /** Read recent audit records (descending by timestamp) */
15
+ query(limit?: number, offset?: number): AuditRecord[];
16
+ /** Count total rows */
17
+ count(): number;
18
+ /** Summary stats for the last N hours */
19
+ summary(hours?: number): {
20
+ totalCalls: number;
21
+ deniedCalls: number;
22
+ avgLatencyMs: number;
23
+ rateLimitViolations: number;
24
+ };
25
+ /** Top N tools by total call count */
26
+ topTools(limit?: number): Array<{
27
+ tool: string;
28
+ count: number;
29
+ }>;
30
+ /** Top N denied tools */
31
+ topDeniedTools(limit?: number): Array<{
32
+ tool: string;
33
+ count: number;
34
+ }>;
35
+ /** Top N actors by activity */
36
+ topActors(limit?: number): Array<{
37
+ actor: string;
38
+ count: number;
39
+ }>;
40
+ /** Allowed vs denied breakdown */
41
+ decisionBreakdown(): {
42
+ allowed: number;
43
+ denied: number;
44
+ };
45
+ /** Time-series: calls grouped into fixed-width buckets */
46
+ timeSeries(hours?: number, bucketMinutes?: number): Array<{
47
+ bucket: string;
48
+ count: number;
49
+ }>;
50
+ /** Search audit records with optional filters */
51
+ search(opts: {
52
+ q?: string;
53
+ decision?: string;
54
+ from?: string;
55
+ to?: string;
56
+ limit?: number;
57
+ offset?: number;
58
+ }): {
59
+ total: number;
60
+ results: AuditRecord[];
61
+ };
62
+ private static readonly ALLOWED_GROUP_BY_FIELDS;
63
+ /** Group-by aggregation with optional WHERE filters */
64
+ aggregate(opts: {
65
+ groupBy: string[];
66
+ q?: string;
67
+ decision?: string;
68
+ from?: string;
69
+ to?: string;
70
+ limit?: number;
71
+ }): {
72
+ groupBy: string[];
73
+ results: Array<Record<string, string | number>>;
74
+ };
75
+ /**
76
+ * Backfill audit_logs from `~/.oculi/telemetry.jsonl` (or whichever path is
77
+ * passed). Resumes from the persisted byte offset, so re-running serve does
78
+ * not double-count. Returns counts for observability.
79
+ *
80
+ * The CLI client always appends to telemetry.jsonl; the gateway only writes
81
+ * to sqlite. Without this replay, events generated while `oculi serve` was
82
+ * NOT running are invisible to the dashboard. See plan file for the full
83
+ * divergence analysis.
84
+ */
85
+ replayFromTelemetry(jsonlPath: string): {
86
+ inserted: number;
87
+ skipped: number;
88
+ newOffset: number;
89
+ rotated: boolean;
90
+ };
91
+ close(): void;
92
+ }
@@ -0,0 +1,388 @@
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.AuditService = void 0;
7
+ const crypto_1 = __importDefault(require("crypto"));
8
+ const fs_1 = __importDefault(require("fs"));
9
+ const path_1 = __importDefault(require("path"));
10
+ const sqlite_loader_1 = require("./sqlite-loader");
11
+ const telemetry_log_1 = require("./telemetry-log");
12
+ class AuditService {
13
+ db;
14
+ storeFullArgs;
15
+ constructor(config) {
16
+ this.storeFullArgs = config.storeFullArgs;
17
+ // Ensure data directory exists
18
+ const dir = path_1.default.dirname(config.dbPath);
19
+ if (!fs_1.default.existsSync(dir)) {
20
+ fs_1.default.mkdirSync(dir, { recursive: true });
21
+ }
22
+ const Ctor = (0, sqlite_loader_1.loadSqlite)();
23
+ this.db = new Ctor(config.dbPath);
24
+ this.db.pragma('journal_mode = WAL');
25
+ this.db.pragma('foreign_keys = ON');
26
+ this.initialize();
27
+ }
28
+ initialize() {
29
+ this.db.exec(`
30
+ CREATE TABLE IF NOT EXISTS audit_logs (
31
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
32
+ requestId TEXT NOT NULL,
33
+ timestamp TEXT NOT NULL,
34
+ actor TEXT NOT NULL,
35
+ orgId TEXT NOT NULL,
36
+ upstreamId TEXT NOT NULL,
37
+ tool TEXT NOT NULL,
38
+ argsHash TEXT NOT NULL,
39
+ argsJson TEXT,
40
+ decision TEXT NOT NULL CHECK(decision IN ('allow', 'deny')),
41
+ reason TEXT NOT NULL,
42
+ latencyMs INTEGER NOT NULL,
43
+ outcome TEXT NOT NULL CHECK(outcome IN ('success', 'error', 'denied')),
44
+ responseHash TEXT,
45
+ ip TEXT NOT NULL,
46
+ sessionId TEXT NOT NULL
47
+ );
48
+
49
+ CREATE INDEX IF NOT EXISTS idx_audit_actor ON audit_logs(actor);
50
+ CREATE INDEX IF NOT EXISTS idx_audit_tool ON audit_logs(tool);
51
+ CREATE INDEX IF NOT EXISTS idx_audit_timestamp ON audit_logs(timestamp);
52
+ CREATE INDEX IF NOT EXISTS idx_audit_decision ON audit_logs(decision);
53
+ CREATE INDEX IF NOT EXISTS idx_audit_requestId ON audit_logs(requestId);
54
+
55
+ CREATE TABLE IF NOT EXISTS replay_state (
56
+ key TEXT PRIMARY KEY,
57
+ value TEXT NOT NULL
58
+ );
59
+ `);
60
+ // Migration: add responseJson column to existing databases (SQLite ignores duplicate ADD COLUMN errors)
61
+ try {
62
+ this.db.exec('ALTER TABLE audit_logs ADD COLUMN responseJson TEXT');
63
+ }
64
+ catch {
65
+ // Column already exists — no-op
66
+ }
67
+ }
68
+ /** Read the persisted byte offset for an absolute jsonl path. Zero when missing. */
69
+ getReplayOffset(jsonlPath) {
70
+ const key = `replay:${jsonlPath}`;
71
+ const row = this.db
72
+ .prepare('SELECT value FROM replay_state WHERE key = ?')
73
+ .get(key);
74
+ if (!row)
75
+ return 0;
76
+ const n = parseInt(row.value, 10);
77
+ return Number.isFinite(n) && n >= 0 ? n : 0;
78
+ }
79
+ setReplayOffset(jsonlPath, offset) {
80
+ const key = `replay:${jsonlPath}`;
81
+ this.db
82
+ .prepare('INSERT INTO replay_state (key, value) VALUES (?, ?) ' +
83
+ 'ON CONFLICT(key) DO UPDATE SET value = excluded.value')
84
+ .run(key, String(offset));
85
+ }
86
+ /** SHA-256 hex prefix of JSON-serialised data */
87
+ hash(data) {
88
+ return crypto_1.default
89
+ .createHash('sha256')
90
+ .update(JSON.stringify(data))
91
+ .digest('hex')
92
+ .slice(0, 16);
93
+ }
94
+ log(record) {
95
+ const stmt = this.db.prepare(`
96
+ INSERT INTO audit_logs (
97
+ requestId, timestamp, actor, orgId, upstreamId, tool,
98
+ argsHash, argsJson, decision, reason, latencyMs,
99
+ outcome, responseHash, responseJson, ip, sessionId
100
+ ) VALUES (
101
+ @requestId, @timestamp, @actor, @orgId, @upstreamId, @tool,
102
+ @argsHash, @argsJson, @decision, @reason, @latencyMs,
103
+ @outcome, @responseHash, @responseJson, @ip, @sessionId
104
+ )
105
+ `);
106
+ stmt.run({
107
+ ...record,
108
+ argsJson: record.argsJson ?? null,
109
+ responseHash: record.responseHash ?? null,
110
+ responseJson: record.responseJson ?? null,
111
+ });
112
+ }
113
+ /** Read recent audit records (descending by timestamp) */
114
+ query(limit = 100, offset = 0) {
115
+ return this.db
116
+ .prepare('SELECT * FROM audit_logs ORDER BY timestamp DESC LIMIT ? OFFSET ?')
117
+ .all(limit, offset);
118
+ }
119
+ /** Count total rows */
120
+ count() {
121
+ const row = this.db
122
+ .prepare('SELECT COUNT(*) as n FROM audit_logs')
123
+ .get();
124
+ return row.n;
125
+ }
126
+ /** Summary stats for the last N hours */
127
+ summary(hours = 24) {
128
+ const since = new Date(Date.now() - hours * 3600_000).toISOString();
129
+ const row = this.db
130
+ .prepare(`SELECT
131
+ COUNT(*) AS totalCalls,
132
+ SUM(CASE WHEN decision = 'deny' THEN 1 ELSE 0 END) AS deniedCalls,
133
+ COALESCE(AVG(latencyMs), 0) AS avgLatencyMs,
134
+ SUM(CASE WHEN reason = 'rate limit exceeded' THEN 1 ELSE 0 END) AS rateLimitViolations
135
+ FROM audit_logs
136
+ WHERE timestamp >= ?`)
137
+ .get(since);
138
+ return {
139
+ totalCalls: row.totalCalls,
140
+ deniedCalls: row.deniedCalls,
141
+ avgLatencyMs: Math.round(row.avgLatencyMs),
142
+ rateLimitViolations: row.rateLimitViolations,
143
+ };
144
+ }
145
+ /** Top N tools by total call count */
146
+ topTools(limit = 5) {
147
+ return this.db
148
+ .prepare(`SELECT tool, COUNT(*) AS count
149
+ FROM audit_logs
150
+ GROUP BY tool
151
+ ORDER BY count DESC
152
+ LIMIT ?`)
153
+ .all(limit);
154
+ }
155
+ /** Top N denied tools */
156
+ topDeniedTools(limit = 5) {
157
+ return this.db
158
+ .prepare(`SELECT tool, COUNT(*) AS count
159
+ FROM audit_logs
160
+ WHERE decision = 'deny'
161
+ GROUP BY tool
162
+ ORDER BY count DESC
163
+ LIMIT ?`)
164
+ .all(limit);
165
+ }
166
+ /** Top N actors by activity */
167
+ topActors(limit = 5) {
168
+ return this.db
169
+ .prepare(`SELECT actor, COUNT(*) AS count
170
+ FROM audit_logs
171
+ GROUP BY actor
172
+ ORDER BY count DESC
173
+ LIMIT ?`)
174
+ .all(limit);
175
+ }
176
+ /** Allowed vs denied breakdown */
177
+ decisionBreakdown() {
178
+ const row = this.db
179
+ .prepare(`SELECT
180
+ SUM(CASE WHEN decision = 'allow' THEN 1 ELSE 0 END) AS allowed,
181
+ SUM(CASE WHEN decision = 'deny' THEN 1 ELSE 0 END) AS denied
182
+ FROM audit_logs`)
183
+ .get();
184
+ return { allowed: row.allowed ?? 0, denied: row.denied ?? 0 };
185
+ }
186
+ /** Time-series: calls grouped into fixed-width buckets */
187
+ timeSeries(hours = 1, bucketMinutes = 5) {
188
+ const since = new Date(Date.now() - hours * 3600_000).toISOString();
189
+ const bucketSeconds = bucketMinutes * 60;
190
+ return this.db
191
+ .prepare(`SELECT
192
+ strftime('%Y-%m-%dT%H:%M:00Z',
193
+ (CAST(strftime('%s', timestamp) AS INTEGER) / ${bucketSeconds}) * ${bucketSeconds},
194
+ 'unixepoch'
195
+ ) AS bucket,
196
+ COUNT(*) AS count
197
+ FROM audit_logs
198
+ WHERE timestamp >= ?
199
+ GROUP BY bucket
200
+ ORDER BY bucket ASC`)
201
+ .all(since);
202
+ }
203
+ /** Search audit records with optional filters */
204
+ search(opts) {
205
+ const limit = Math.min(opts.limit ?? 100, 500);
206
+ const offset = opts.offset ?? 0;
207
+ const q = opts.q?.trim() || null;
208
+ const decision = opts.decision === 'allow' || opts.decision === 'deny' ? opts.decision : null;
209
+ const conditions = [];
210
+ const params = [];
211
+ if (q) {
212
+ conditions.push('(actor LIKE ? OR tool LIKE ? OR upstreamId LIKE ?)');
213
+ const like = '%' + q + '%';
214
+ params.push(like, like, like);
215
+ }
216
+ if (decision) {
217
+ conditions.push('decision = ?');
218
+ params.push(decision);
219
+ }
220
+ if (opts.from) {
221
+ conditions.push('timestamp >= ?');
222
+ params.push(opts.from);
223
+ }
224
+ if (opts.to) {
225
+ conditions.push('timestamp <= ?');
226
+ params.push(opts.to);
227
+ }
228
+ const where = conditions.length ? 'WHERE ' + conditions.join(' AND ') : '';
229
+ const total = this.db
230
+ .prepare('SELECT COUNT(*) AS n FROM audit_logs ' + where)
231
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
232
+ .get(...params).n;
233
+ const records = this.db
234
+ .prepare('SELECT * FROM audit_logs ' + where + ' ORDER BY timestamp DESC LIMIT ? OFFSET ?')
235
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
236
+ .all(...params, limit, offset);
237
+ return { total, results: records };
238
+ }
239
+ static ALLOWED_GROUP_BY_FIELDS = {
240
+ actor: 'actor',
241
+ tool: 'tool',
242
+ upstream: 'upstreamId',
243
+ upstreamId: 'upstreamId',
244
+ decision: 'decision',
245
+ outcome: 'outcome',
246
+ orgId: 'orgId',
247
+ };
248
+ /** Group-by aggregation with optional WHERE filters */
249
+ aggregate(opts) {
250
+ const limit = Math.min(opts.limit ?? 100, 500);
251
+ const q = opts.q?.trim() || null;
252
+ const decision = opts.decision === 'allow' || opts.decision === 'deny' ? opts.decision : null;
253
+ // Map user-provided field names to SQL column names (injection prevention)
254
+ const allowlist = AuditService.ALLOWED_GROUP_BY_FIELDS;
255
+ const resolvedCols = opts.groupBy
256
+ .map((f) => allowlist[f] ?? null)
257
+ .filter((c) => c !== null);
258
+ if (resolvedCols.length === 0) {
259
+ return { groupBy: opts.groupBy, results: [] };
260
+ }
261
+ const conditions = [];
262
+ const params = [];
263
+ if (q) {
264
+ conditions.push('(actor LIKE ? OR tool LIKE ? OR upstreamId LIKE ?)');
265
+ const like = '%' + q + '%';
266
+ params.push(like, like, like);
267
+ }
268
+ if (decision) {
269
+ conditions.push('decision = ?');
270
+ params.push(decision);
271
+ }
272
+ if (opts.from) {
273
+ conditions.push('timestamp >= ?');
274
+ params.push(opts.from);
275
+ }
276
+ if (opts.to) {
277
+ conditions.push('timestamp <= ?');
278
+ params.push(opts.to);
279
+ }
280
+ const where = conditions.length ? 'WHERE ' + conditions.join(' AND ') : '';
281
+ const colList = resolvedCols.join(', ');
282
+ const sql = `SELECT ${colList}, COUNT(*) AS count FROM audit_logs ${where} GROUP BY ${colList} ORDER BY count DESC LIMIT ?`;
283
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
284
+ const rows = this.db.prepare(sql).all(...params, limit);
285
+ return { groupBy: opts.groupBy, results: rows };
286
+ }
287
+ /**
288
+ * Backfill audit_logs from `~/.oculi/telemetry.jsonl` (or whichever path is
289
+ * passed). Resumes from the persisted byte offset, so re-running serve does
290
+ * not double-count. Returns counts for observability.
291
+ *
292
+ * The CLI client always appends to telemetry.jsonl; the gateway only writes
293
+ * to sqlite. Without this replay, events generated while `oculi serve` was
294
+ * NOT running are invisible to the dashboard. See plan file for the full
295
+ * divergence analysis.
296
+ */
297
+ replayFromTelemetry(jsonlPath) {
298
+ const startOffset = this.getReplayOffset(jsonlPath);
299
+ const { entries, newOffset, skipped, rotated } = (0, telemetry_log_1.readTelemetryLinesFromOffset)(jsonlPath, startOffset);
300
+ if (entries.length === 0) {
301
+ // Persist newOffset anyway so a rotation reset (offset went 1000 → 0
302
+ // because the new file is smaller) is recorded.
303
+ if (newOffset !== startOffset)
304
+ this.setReplayOffset(jsonlPath, newOffset);
305
+ return { inserted: 0, skipped, newOffset, rotated };
306
+ }
307
+ let inserted = 0;
308
+ let perRowFailures = 0;
309
+ const insert = this.db.transaction((batch) => {
310
+ for (const entry of batch) {
311
+ try {
312
+ const record = telemetryEntryToAuditRecord(entry, this.hash.bind(this));
313
+ this.log(record);
314
+ inserted++;
315
+ }
316
+ catch {
317
+ // Per-row failure (e.g., a field combination that violates a CHECK
318
+ // constraint). Skip this row but keep the rest of the batch.
319
+ perRowFailures++;
320
+ }
321
+ }
322
+ this.setReplayOffset(jsonlPath, newOffset);
323
+ });
324
+ insert(entries);
325
+ return {
326
+ inserted,
327
+ skipped: skipped + perRowFailures,
328
+ newOffset,
329
+ rotated,
330
+ };
331
+ }
332
+ close() {
333
+ this.db.close();
334
+ }
335
+ }
336
+ exports.AuditService = AuditService;
337
+ /**
338
+ * Map a telemetry jsonl entry to an audit_logs row.
339
+ *
340
+ * Key divergences from the live `POST /v1/hooks` write path ([routes/hooks.ts]):
341
+ * - `requestId` is freshly generated (jsonl has no equivalent).
342
+ * - `argsJson` is always null (matches the gateway's `storeFullArgs: false`
343
+ * default; jsonl carries `tool_args` but we don't backfill it to avoid
344
+ * surprising users who never enabled full-args storage).
345
+ * - `decision='warn'` collapses to `'allow'` because audit_logs CHECK
346
+ * constraint allows only allow/deny — same as the live handler's behavior
347
+ * at routes/hooks.ts (the warn → allow + distinguishing reason fold).
348
+ * - `ip` is the loopback address; jsonl is generated client-side and has no
349
+ * real remote IP.
350
+ */
351
+ function telemetryEntryToAuditRecord(entry, hash) {
352
+ const policyDecision = entry.policy_decision;
353
+ const decision = policyDecision === 'deny' ? 'deny' : 'allow';
354
+ const outcome = entry.error
355
+ ? 'error'
356
+ : decision === 'deny'
357
+ ? 'denied'
358
+ : 'success';
359
+ const ruleIds = entry.policy_rule_ids ?? [];
360
+ const reasonParts = [];
361
+ if (policyDecision === 'warn')
362
+ reasonParts.push('warn');
363
+ if (ruleIds.length > 0)
364
+ reasonParts.push(ruleIds.join(','));
365
+ const reason = reasonParts.length > 0 ? reasonParts.join(': ') : 'telemetry';
366
+ // Generate a request ID and use it as the sessionId fallback when the
367
+ // jsonl entry lacks one — matches the live handler's behavior at
368
+ // routes/hooks.ts (`sessionId = event.session_id ?? requestId`).
369
+ const requestId = crypto_1.default.randomUUID();
370
+ return {
371
+ requestId,
372
+ timestamp: entry.timestamp,
373
+ actor: entry.actor || 'unknown',
374
+ orgId: entry.org_id || 'default',
375
+ upstreamId: `ext:${entry.ide_source || 'unknown'}`,
376
+ tool: entry.tool ?? '__stop__',
377
+ argsHash: hash(entry.tool_args ?? {}),
378
+ argsJson: null,
379
+ decision,
380
+ reason,
381
+ latencyMs: entry.duration_ms ?? 0,
382
+ outcome,
383
+ responseHash: null,
384
+ responseJson: null,
385
+ ip: '127.0.0.1',
386
+ sessionId: entry.session_id || requestId,
387
+ };
388
+ }
@@ -0,0 +1,7 @@
1
+ /** Default data directory: ~/.oculi/ */
2
+ export declare function defaultDataDir(): string;
3
+ /**
4
+ * Ensure `dir` exists and is locked down to the current user (chmod 700).
5
+ * Returns the absolute path. Idempotent.
6
+ */
7
+ export declare function ensureDataDir(dir: string): string;
@@ -0,0 +1,61 @@
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.defaultDataDir = defaultDataDir;
37
+ exports.ensureDataDir = ensureDataDir;
38
+ const fs = __importStar(require("fs"));
39
+ const os = __importStar(require("os"));
40
+ const path = __importStar(require("path"));
41
+ /** Default data directory: ~/.oculi/ */
42
+ function defaultDataDir() {
43
+ return path.join(os.homedir(), '.oculi');
44
+ }
45
+ /**
46
+ * Ensure `dir` exists and is locked down to the current user (chmod 700).
47
+ * Returns the absolute path. Idempotent.
48
+ */
49
+ function ensureDataDir(dir) {
50
+ const abs = path.resolve(dir);
51
+ fs.mkdirSync(abs, { recursive: true, mode: 0o700 });
52
+ // mkdir's `mode` is masked by umask on existing dirs; chmod is the
53
+ // authoritative fix when the dir was created earlier with a wider mode.
54
+ try {
55
+ fs.chmodSync(abs, 0o700);
56
+ }
57
+ catch {
58
+ // Best-effort — chmod can fail on Windows / weird filesystems.
59
+ }
60
+ return abs;
61
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Starter policy template for `oculi init`.
3
+ *
4
+ * Stored as a raw YAML string (not an object) so we can include comments
5
+ * that explain the format to the user.
6
+ */
7
+ export declare const TEMPLATE_NAMES: readonly ["standard"];
8
+ export type TemplateName = (typeof TEMPLATE_NAMES)[number];
9
+ export declare const templates: Record<TemplateName, string>;
@@ -0,0 +1,47 @@
1
+ "use strict";
2
+ /**
3
+ * Starter policy template for `oculi init`.
4
+ *
5
+ * Stored as a raw YAML string (not an object) so we can include comments
6
+ * that explain the format to the user.
7
+ */
8
+ Object.defineProperty(exports, "__esModule", { value: true });
9
+ exports.templates = exports.TEMPLATE_NAMES = void 0;
10
+ exports.TEMPLATE_NAMES = ['standard'];
11
+ exports.templates = {
12
+ standard: `# Oculi local policy — standard (sensible defaults)
13
+ #
14
+ # Blocks dangerous shell commands and path traversal.
15
+ # Warns on .env file access and MCP tool calls.
16
+
17
+ rules:
18
+ - id: "no-rm-rf"
19
+ match:
20
+ tool: shell
21
+ command_pattern: "rm\\\\s+-rf"
22
+ action: deny
23
+
24
+ - id: "no-path-traversal"
25
+ match:
26
+ tool: shell
27
+ command_pattern: "\\\\.\\\\./"
28
+ action: deny
29
+
30
+ - id: "warn-env-access"
31
+ match:
32
+ tool: file_read
33
+ file_pattern: "\\\\.env"
34
+ action: warn
35
+
36
+ - id: "warn-env-edit"
37
+ match:
38
+ tool: file_edit
39
+ file_pattern: "\\\\.env"
40
+ action: warn
41
+
42
+ - id: "warn-mcp"
43
+ match:
44
+ tool: mcp_call
45
+ action: warn
46
+ `,
47
+ };
@@ -0,0 +1,39 @@
1
+ import { OculiEvent } from '../routes/adapters/schema';
2
+ export interface PolicyRuleMatch {
3
+ tool?: string;
4
+ command_pattern?: string;
5
+ file_pattern?: string;
6
+ mcp_server?: string;
7
+ }
8
+ export interface PolicyRule {
9
+ id: string;
10
+ match: PolicyRuleMatch;
11
+ action: 'deny' | 'warn' | 'allow';
12
+ }
13
+ export interface LocalPolicyFile {
14
+ rules: PolicyRule[];
15
+ }
16
+ export interface LocalPolicyResult {
17
+ action: 'deny' | 'warn' | 'allow';
18
+ matchedRules: PolicyRule[];
19
+ }
20
+ /**
21
+ * Search for `.oculi/policy.yaml` starting from `startDir`, walking up to
22
+ * the filesystem root, then falling back to `~/.oculi/policy.yaml`.
23
+ * Returns the first path found, or null.
24
+ */
25
+ export declare function findPolicyFile(startDir?: string): string | null;
26
+ /**
27
+ * Load and parse a policy file. Returns null if the file doesn't exist.
28
+ * Throws on parse errors so callers can decide how to handle them.
29
+ */
30
+ export declare function loadPolicyFile(filePath: string): LocalPolicyFile;
31
+ export declare function ruleMatches(rule: PolicyRule, event: OculiEvent): boolean;
32
+ /**
33
+ * Evaluate local policy rules against a normalized OculiEvent.
34
+ *
35
+ * - Only evaluates on `pre` phase events. Post/complete → allow.
36
+ * - Collects all matching rules, highest-precedence action wins (deny > warn > allow).
37
+ * - If no rules match → allow.
38
+ */
39
+ export declare function evaluateLocalPolicy(event: OculiEvent, rules: PolicyRule[]): LocalPolicyResult;