@oculisecurity/cli 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE.txt +201 -0
- package/README.md +67 -0
- package/dist/cli.d.ts +18 -0
- package/dist/cli.js +565 -0
- package/dist/commands/init.d.ts +14 -0
- package/dist/commands/init.js +135 -0
- package/dist/commands/report.d.ts +33 -0
- package/dist/commands/report.js +145 -0
- package/dist/commands/serve.d.ts +27 -0
- package/dist/commands/serve.js +163 -0
- package/dist/commands/tail.d.ts +7 -0
- package/dist/commands/tail.js +211 -0
- package/dist/commands/uninstall.d.ts +13 -0
- package/dist/commands/uninstall.js +111 -0
- package/dist/config.d.ts +17 -0
- package/dist/config.js +90 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +35 -0
- package/dist/init.d.ts +9 -0
- package/dist/init.js +50 -0
- package/dist/install/claude-code.d.ts +13 -0
- package/dist/install/claude-code.js +118 -0
- package/dist/install/cursor.d.ts +13 -0
- package/dist/install/cursor.js +119 -0
- package/dist/install/detect.d.ts +5 -0
- package/dist/install/detect.js +64 -0
- package/dist/middleware/auth.d.ts +15 -0
- package/dist/middleware/auth.js +116 -0
- package/dist/routes/adapters/claude-code.d.ts +38 -0
- package/dist/routes/adapters/claude-code.js +125 -0
- package/dist/routes/adapters/cursor.d.ts +21 -0
- package/dist/routes/adapters/cursor.js +139 -0
- package/dist/routes/adapters/index.d.ts +16 -0
- package/dist/routes/adapters/index.js +56 -0
- package/dist/routes/adapters/router.d.ts +31 -0
- package/dist/routes/adapters/router.js +97 -0
- package/dist/routes/adapters/schema.d.ts +141 -0
- package/dist/routes/adapters/schema.js +83 -0
- package/dist/routes/adapters/windsurf.d.ts +6 -0
- package/dist/routes/adapters/windsurf.js +48 -0
- package/dist/routes/admin.d.ts +15 -0
- package/dist/routes/admin.js +399 -0
- package/dist/routes/call.d.ts +13 -0
- package/dist/routes/call.js +68 -0
- package/dist/routes/events.d.ts +7 -0
- package/dist/routes/events.js +125 -0
- package/dist/routes/health.d.ts +2 -0
- package/dist/routes/health.js +12 -0
- package/dist/routes/hooks.d.ts +11 -0
- package/dist/routes/hooks.js +166 -0
- package/dist/routes/mcp.d.ts +10 -0
- package/dist/routes/mcp.js +170 -0
- package/dist/routes/openai-tools.d.ts +9 -0
- package/dist/routes/openai-tools.js +121 -0
- package/dist/server.d.ts +11 -0
- package/dist/server.js +118 -0
- package/dist/services/audit.d.ts +92 -0
- package/dist/services/audit.js +388 -0
- package/dist/services/data-dir.d.ts +7 -0
- package/dist/services/data-dir.js +61 -0
- package/dist/services/local-policy-templates.d.ts +9 -0
- package/dist/services/local-policy-templates.js +47 -0
- package/dist/services/local-policy.d.ts +39 -0
- package/dist/services/local-policy.js +172 -0
- package/dist/services/policy-store.d.ts +82 -0
- package/dist/services/policy-store.js +331 -0
- package/dist/services/policy.d.ts +8 -0
- package/dist/services/policy.js +126 -0
- package/dist/services/ratelimit.d.ts +26 -0
- package/dist/services/ratelimit.js +60 -0
- package/dist/services/sanitizer.d.ts +9 -0
- package/dist/services/sanitizer.js +73 -0
- package/dist/services/sqlite-loader.d.ts +4 -0
- package/dist/services/sqlite-loader.js +16 -0
- package/dist/services/telemetry-log.d.ts +76 -0
- package/dist/services/telemetry-log.js +260 -0
- package/dist/services/tool-executor.d.ts +46 -0
- package/dist/services/tool-executor.js +167 -0
- package/dist/services/upstream.d.ts +18 -0
- package/dist/services/upstream.js +72 -0
- package/dist/types.d.ts +112 -0
- package/dist/types.js +3 -0
- package/package.json +72 -0
- package/public/favicon.svg +4 -0
- package/public/index.html +3893 -0
|
@@ -0,0 +1,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;
|