@occasiolabs/occasio 0.8.1

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 (92) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +10 -0
  3. package/README.md +216 -0
  4. package/bin/occasio-mcp.js +5 -0
  5. package/bin/occasio.js +2 -0
  6. package/bin/supervisor/README.md +90 -0
  7. package/bin/supervisor/com.occasio.proxy.plist.template +36 -0
  8. package/bin/supervisor/install-windows-task.ps1 +48 -0
  9. package/bin/supervisor/occasio.service +18 -0
  10. package/docs/AUDIT.md +120 -0
  11. package/docs/attest_verify.py +283 -0
  12. package/docs/audit_walker.py +65 -0
  13. package/docs/canonicalize.py +99 -0
  14. package/docs/compliance-mapping.md +93 -0
  15. package/docs/demos/mcp-block.md +148 -0
  16. package/docs/edr-calibration.md +73 -0
  17. package/docs/edr-demo.md +83 -0
  18. package/docs/python-verifier.md +74 -0
  19. package/docs/reference-pipeline.md +140 -0
  20. package/package.json +69 -0
  21. package/policy-templates/dev-default.yml +84 -0
  22. package/policy-templates/finance.yml +61 -0
  23. package/policy-templates/strict.yml +49 -0
  24. package/schemas/agent-attestation-v1.json +190 -0
  25. package/schemas/occasio-policy.schema.json +99 -0
  26. package/spec/agent-attestation/v1/README.md +137 -0
  27. package/src/adapters/claude-code.js +518 -0
  28. package/src/adapters/cline.js +161 -0
  29. package/src/adapters/computer-use-cli.js +198 -0
  30. package/src/adapters/computer-use.js +227 -0
  31. package/src/analyzer.js +170 -0
  32. package/src/anomaly/cli.js +143 -0
  33. package/src/anomaly/detectors/deny-rate.js +84 -0
  34. package/src/anomaly/detectors/file-read-volume.js +109 -0
  35. package/src/anomaly/detectors/secret-redact-rate.js +107 -0
  36. package/src/anomaly/detectors/unknown-tool-input.js +83 -0
  37. package/src/anomaly/index.js +169 -0
  38. package/src/attest/canonicalize.js +97 -0
  39. package/src/attest/index.js +355 -0
  40. package/src/attest/run-slice.js +57 -0
  41. package/src/attest/sign.js +186 -0
  42. package/src/attest/verify.js +192 -0
  43. package/src/audit/errors.js +21 -0
  44. package/src/audit/input-normalizer.js +121 -0
  45. package/src/audit/jsonl-auditor.js +178 -0
  46. package/src/audit/verifier.js +152 -0
  47. package/src/baseline.js +507 -0
  48. package/src/boundary.js +238 -0
  49. package/src/budget.js +42 -0
  50. package/src/classifier.js +115 -0
  51. package/src/context-budget.js +77 -0
  52. package/src/core/boundary-event.js +75 -0
  53. package/src/core/decision.js +61 -0
  54. package/src/core/pipeline.js +66 -0
  55. package/src/core/tool-names.js +105 -0
  56. package/src/dashboard.js +892 -0
  57. package/src/demo/README.md +31 -0
  58. package/src/demo/anomalies-demo.js +211 -0
  59. package/src/demo/attest-demo.js +198 -0
  60. package/src/distiller.js +155 -0
  61. package/src/embeddings.json +72 -0
  62. package/src/executor/dispatcher.js +230 -0
  63. package/src/harness.js +817 -0
  64. package/src/index.js +1711 -0
  65. package/src/inspect.js +329 -0
  66. package/src/interceptor.js +1198 -0
  67. package/src/lao.js +185 -0
  68. package/src/lao_prep.py +119 -0
  69. package/src/ledger.js +209 -0
  70. package/src/mcp-experiment.js +140 -0
  71. package/src/mcp-normalize.js +139 -0
  72. package/src/mcp-server.js +320 -0
  73. package/src/outbound-policy.js +433 -0
  74. package/src/policy/built-in-classifiers.js +78 -0
  75. package/src/policy/doctor.js +226 -0
  76. package/src/policy/engine.js +339 -0
  77. package/src/policy/init.js +153 -0
  78. package/src/policy/loader.js +448 -0
  79. package/src/policy/rules-default.js +36 -0
  80. package/src/policy/shell-path.js +135 -0
  81. package/src/policy/show.js +196 -0
  82. package/src/policy/validate.js +310 -0
  83. package/src/preflight/cli.js +164 -0
  84. package/src/preflight/miner.js +329 -0
  85. package/src/proxy/agent-router.js +93 -0
  86. package/src/redteam.js +428 -0
  87. package/src/replay.js +446 -0
  88. package/src/report/index.js +224 -0
  89. package/src/runtime.js +595 -0
  90. package/src/scanner/index.js +49 -0
  91. package/src/selftest.js +192 -0
  92. package/src/session.js +36 -0
@@ -0,0 +1,507 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * baseline.js — per-project agent behaviour baseline + anomaly detection.
5
+ *
6
+ * Reads ~/.occasio/logs/*.jsonl, groups by run_id, and builds a frequency
7
+ * profile of tool usage scoped to a single project root (cwd). A subsequent
8
+ * `compare` pass flags any session whose tool calls deviate from that profile
9
+ * — new paths, new tool categories, new shell verbs, or volume spikes.
10
+ *
11
+ * Storage: ~/.occasio/baseline/<cwd-hash>.json. One file per project root.
12
+ * Mining is opt-in (`occasio baseline learn`), comparison is opt-in
13
+ * (`occasio baseline compare`). The proxy never auto-records or auto-checks.
14
+ *
15
+ * Schema requirement: log entries must have a `cwd` field. Entries without
16
+ * `cwd` are silently skipped — they predate v0.6.3.
17
+ */
18
+
19
+ const fs = require('fs');
20
+ const path = require('path');
21
+ const os = require('os');
22
+ const crypto = require('crypto');
23
+
24
+ const LOG_DIR = path.join(os.homedir(), '.occasio', 'logs');
25
+ const BASELINE_DIR = path.join(os.homedir(), '.occasio', 'baseline');
26
+
27
+ // Patterns that bump severity to "high" regardless of baseline novelty.
28
+ // Read of any of these is treated as a high-severity anomaly even when the
29
+ // baseline is empty (cold-start protection for the most-sensitive locations).
30
+ const SENSITIVE_PATH_PATTERNS = [
31
+ /[\\/]\.ssh([\\/]|$)/i,
32
+ /[\\/]\.aws([\\/]|$)/i,
33
+ /[\\/]\.gnupg([\\/]|$)/i,
34
+ /[\\/]\.gcloud([\\/]|$)/i,
35
+ /[\\/]\.azure([\\/]|$)/i,
36
+ /[\\/]credentials([\\/]|$)/i,
37
+ /[\\/]secrets([\\/]|$)/i,
38
+ /[\\/]\.env([\\/.]|$)/i,
39
+ /^\/etc\/(shadow|passwd|sudoers)/,
40
+ /[\\/]private[\\/]/i,
41
+ ];
42
+
43
+ // Shell verbs whose first occurrence is always at least medium severity.
44
+ // Network-touching / destructive / privilege-escalating commands.
45
+ const HIGH_RISK_SHELL_VERBS = new Set([
46
+ 'curl', 'wget', 'nc', 'ncat', 'ssh', 'scp', 'sftp', 'rsync',
47
+ 'rm', 'rmdir', 'dd', 'mkfs', 'format',
48
+ 'sudo', 'su', 'doas', 'pkexec',
49
+ 'npm', 'pip', 'gem', 'cargo', 'apt', 'brew',
50
+ ]);
51
+
52
+ // Tool categories we track in the baseline. Canonical names only.
53
+ const TRACKED_TOOLS = new Set([
54
+ 'read_file', 'find_files', 'grep',
55
+ 'shell_bash', 'shell_powershell',
56
+ 'todo_write', 'todo_read',
57
+ ]);
58
+
59
+ // ── normalisation ────────────────────────────────────────────────────────────
60
+
61
+ function normCwd(c) {
62
+ if (!c) return '';
63
+ return process.platform === 'win32' ? c.toLowerCase() : c;
64
+ }
65
+
66
+ function cwdHash(c) {
67
+ return crypto.createHash('sha256').update(normCwd(c)).digest('hex').slice(0, 16);
68
+ }
69
+
70
+ function baselinePathFor(c) {
71
+ return path.join(BASELINE_DIR, `${cwdHash(c)}.json`);
72
+ }
73
+
74
+ // Map an agent-protocol tool name to a canonical category.
75
+ function canonicalToolName(name) {
76
+ if (!name) return null;
77
+ const lower = String(name).toLowerCase();
78
+ if (lower === 'read' || lower === 'read_file') return 'read_file';
79
+ if (lower === 'glob' || lower === 'find_files') return 'find_files';
80
+ if (lower === 'grep') return 'grep';
81
+ if (lower === 'bash' || lower === 'shell_bash') return 'shell_bash';
82
+ if (lower === 'powershell' || lower === 'shell_powershell') return 'shell_powershell';
83
+ if (lower === 'todowrite' || lower === 'todo_write') return 'todo_write';
84
+ if (lower === 'todoread' || lower === 'todo_read') return 'todo_read';
85
+ return null;
86
+ }
87
+
88
+ function isSensitivePath(p) {
89
+ if (!p || typeof p !== 'string') return false;
90
+ return SENSITIVE_PATH_PATTERNS.some(re => re.test(p));
91
+ }
92
+
93
+ function firstWord(cmd) {
94
+ if (!cmd || typeof cmd !== 'string') return null;
95
+ const m = cmd.trim().match(/^([A-Za-z][\w.+-]*)/);
96
+ return m ? m[1].toLowerCase() : null;
97
+ }
98
+
99
+ // ── log reading ──────────────────────────────────────────────────────────────
100
+
101
+ function readRecentEntries({ days = 30 } = {}) {
102
+ const cutoff = Date.now() - days * 24 * 60 * 60 * 1000;
103
+ let files = [];
104
+ try { files = fs.readdirSync(LOG_DIR).filter(f => f.endsWith('.jsonl')); }
105
+ catch { return []; }
106
+ files.sort();
107
+ const entries = [];
108
+ for (const f of files) {
109
+ let text;
110
+ try { text = fs.readFileSync(path.join(LOG_DIR, f), 'utf8'); } catch { continue; }
111
+ for (const line of text.split('\n')) {
112
+ if (!line.trim()) continue;
113
+ try {
114
+ const e = JSON.parse(line);
115
+ if (!e.iso) continue;
116
+ if (new Date(e.iso).getTime() < cutoff) continue;
117
+ entries.push(e);
118
+ } catch { /* skip malformed */ }
119
+ }
120
+ }
121
+ return entries;
122
+ }
123
+
124
+ function filterByCwd(entries, c) {
125
+ const target = normCwd(c);
126
+ return entries.filter(e => normCwd(e.cwd) === target);
127
+ }
128
+
129
+ function groupByRun(entries) {
130
+ const runs = new Map();
131
+ for (const e of entries) {
132
+ const k = e.run_id || '__nokey__';
133
+ if (!runs.has(k)) runs.set(k, []);
134
+ runs.get(k).push(e);
135
+ }
136
+ return Array.from(runs.values());
137
+ }
138
+
139
+ // Extract per-tool-call records from a run.
140
+ // Each record: { tool, path, verb } (path/verb may be null)
141
+ function extractToolCalls(run) {
142
+ const out = [];
143
+ for (const entry of run) {
144
+ if (!Array.isArray(entry.tools)) continue;
145
+ for (const t of entry.tools) {
146
+ const canonical = canonicalToolName(t.tool);
147
+ if (!canonical || !TRACKED_TOOLS.has(canonical)) continue;
148
+ const rec = { tool: canonical, path: null, verb: null };
149
+ if (canonical === 'read_file' || canonical === 'find_files' || canonical === 'grep') {
150
+ if (t.cmd && typeof t.cmd === 'string') rec.path = t.cmd;
151
+ } else if (canonical === 'shell_bash' || canonical === 'shell_powershell') {
152
+ rec.verb = firstWord(t.cmd);
153
+ }
154
+ out.push(rec);
155
+ }
156
+ }
157
+ return out;
158
+ }
159
+
160
+ // ── mining ───────────────────────────────────────────────────────────────────
161
+
162
+ function median(arr) {
163
+ if (!arr.length) return 0;
164
+ const sorted = [...arr].sort((a, b) => a - b);
165
+ const mid = Math.floor(sorted.length / 2);
166
+ return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
167
+ }
168
+
169
+ function quantile(arr, q) {
170
+ if (!arr.length) return 0;
171
+ const sorted = [...arr].sort((a, b) => a - b);
172
+ const idx = Math.min(sorted.length - 1, Math.floor(sorted.length * q));
173
+ return sorted[idx];
174
+ }
175
+
176
+ /**
177
+ * Build a baseline object from a list of run-grouped tool-call records.
178
+ * Pure function — does not touch the filesystem.
179
+ *
180
+ * @param {Array<Array<object>>} runRecords output of extractToolCalls per run
181
+ * @param {object} meta { project_root }
182
+ * @returns {object} baseline
183
+ */
184
+ function buildBaseline(runRecords, meta = {}) {
185
+ const tools = {};
186
+ const paths = {};
187
+ const verbs = {};
188
+ const counts = [];
189
+
190
+ for (const run of runRecords) {
191
+ counts.push(run.length);
192
+ const seenInRun = { tools: new Set(), paths: new Set(), verbs: new Set() };
193
+ for (const r of run) {
194
+ tools[r.tool] = tools[r.tool] || { count: 0, sessions: 0 };
195
+ tools[r.tool].count += 1;
196
+ seenInRun.tools.add(r.tool);
197
+ if (r.path) {
198
+ paths[r.path] = paths[r.path] || { count: 0, sessions: 0 };
199
+ paths[r.path].count += 1;
200
+ seenInRun.paths.add(r.path);
201
+ }
202
+ if (r.verb) {
203
+ verbs[r.verb] = verbs[r.verb] || { count: 0, sessions: 0 };
204
+ verbs[r.verb].count += 1;
205
+ seenInRun.verbs.add(r.verb);
206
+ }
207
+ }
208
+ for (const t of seenInRun.tools) tools[t].sessions += 1;
209
+ for (const p of seenInRun.paths) paths[p].sessions += 1;
210
+ for (const v of seenInRun.verbs) verbs[v].sessions += 1;
211
+ }
212
+
213
+ return {
214
+ schema_version: 1,
215
+ generated_at: new Date().toISOString(),
216
+ project_root: meta.project_root || null,
217
+ session_count: runRecords.length,
218
+ tools, paths, verbs,
219
+ session_tool_counts: {
220
+ median: median(counts),
221
+ p75: quantile(counts, 0.75),
222
+ p95: quantile(counts, 0.95),
223
+ max: counts.length ? Math.max(...counts) : 0,
224
+ },
225
+ };
226
+ }
227
+
228
+ /**
229
+ * High-level mining: read logs for the last N days, scope to the given
230
+ * project root, return a baseline object. Does not write to disk.
231
+ */
232
+ function mineBaseline({ cwd, days = 30 } = {}) {
233
+ if (!cwd) throw new Error('mineBaseline requires { cwd }');
234
+ const all = readRecentEntries({ days });
235
+ const scoped = filterByCwd(all, cwd);
236
+ const runs = groupByRun(scoped);
237
+ const runRecords = runs.map(extractToolCalls);
238
+ return buildBaseline(runRecords, { project_root: cwd });
239
+ }
240
+
241
+ // ── comparison ───────────────────────────────────────────────────────────────
242
+
243
+ /**
244
+ * Compare a session (array of tool-call records) against a baseline.
245
+ * Returns { anomalies: [...], summary: {...} }.
246
+ *
247
+ * Anomaly types:
248
+ * new_path — path not in baseline
249
+ * new_tool — tool category not in baseline
250
+ * new_shell_verb — shell verb not in baseline
251
+ * sensitive_path — read of a path in SENSITIVE_PATH_PATTERNS (always high)
252
+ * volume_spike — session tool count exceeds baseline.p95 × 1.5
253
+ */
254
+ function compareSession(session, baseline) {
255
+ const anomalies = [];
256
+ const baseTools = (baseline && baseline.tools) || {};
257
+ const basePaths = (baseline && baseline.paths) || {};
258
+ const baseVerbs = (baseline && baseline.verbs) || {};
259
+ const baseSessions = (baseline && baseline.session_count) || 0;
260
+ const lowConfidence = baseSessions < 5;
261
+
262
+ const seenPaths = new Set();
263
+ const seenVerbs = new Set();
264
+ const seenTools = new Set();
265
+
266
+ for (const r of session) {
267
+ // Sensitive path: always flag, regardless of baseline state
268
+ if (r.path && isSensitivePath(r.path)) {
269
+ anomalies.push({
270
+ type: 'sensitive_path',
271
+ detail: `${r.tool} ${r.path}`,
272
+ severity: 'high',
273
+ });
274
+ }
275
+ // New tool category
276
+ if (!seenTools.has(r.tool)) {
277
+ seenTools.add(r.tool);
278
+ if (!baseTools[r.tool] && baseSessions > 0) {
279
+ anomalies.push({
280
+ type: 'new_tool',
281
+ detail: r.tool,
282
+ severity: 'medium',
283
+ });
284
+ }
285
+ }
286
+ // New path (read_file / find_files / grep)
287
+ if (r.path && !seenPaths.has(r.path)) {
288
+ seenPaths.add(r.path);
289
+ if (!basePaths[r.path] && baseSessions > 0 && !isSensitivePath(r.path)) {
290
+ anomalies.push({
291
+ type: 'new_path',
292
+ detail: `${r.tool} ${r.path}`,
293
+ severity: 'medium',
294
+ });
295
+ }
296
+ }
297
+ // New shell verb
298
+ if (r.verb && !seenVerbs.has(r.verb)) {
299
+ seenVerbs.add(r.verb);
300
+ if (!baseVerbs[r.verb] && baseSessions > 0) {
301
+ anomalies.push({
302
+ type: 'new_shell_verb',
303
+ detail: r.verb,
304
+ severity: HIGH_RISK_SHELL_VERBS.has(r.verb) ? 'high' : 'medium',
305
+ });
306
+ }
307
+ }
308
+ }
309
+
310
+ // Volume spike: only when baseline has enough data to define "normal"
311
+ if (baseSessions >= 5 && session.length > 0) {
312
+ const p95 = baseline.session_tool_counts.p95 || 0;
313
+ if (p95 > 0 && session.length > p95 * 1.5) {
314
+ anomalies.push({
315
+ type: 'volume_spike',
316
+ detail: `${session.length} tool calls (baseline p95 ${p95})`,
317
+ severity: session.length > p95 * 3 ? 'high' : 'medium',
318
+ });
319
+ }
320
+ }
321
+
322
+ return {
323
+ anomalies,
324
+ summary: {
325
+ session_tool_calls: session.length,
326
+ baseline_sessions: baseSessions,
327
+ low_confidence: lowConfidence,
328
+ high_severity: anomalies.filter(a => a.severity === 'high').length,
329
+ medium_severity: anomalies.filter(a => a.severity === 'medium').length,
330
+ },
331
+ };
332
+ }
333
+
334
+ // ── persistence ──────────────────────────────────────────────────────────────
335
+
336
+ function readBaseline(c) {
337
+ try {
338
+ const text = fs.readFileSync(baselinePathFor(c), 'utf8');
339
+ return JSON.parse(text);
340
+ } catch {
341
+ return null;
342
+ }
343
+ }
344
+
345
+ function writeBaseline(c, baseline) {
346
+ if (!fs.existsSync(BASELINE_DIR)) fs.mkdirSync(BASELINE_DIR, { recursive: true });
347
+ fs.writeFileSync(baselinePathFor(c), JSON.stringify(baseline, null, 2));
348
+ return baselinePathFor(c);
349
+ }
350
+
351
+ function deleteBaseline(c) {
352
+ try { fs.unlinkSync(baselinePathFor(c)); return true; } catch { return false; }
353
+ }
354
+
355
+ // ── renderers ────────────────────────────────────────────────────────────────
356
+
357
+ const C = (() => {
358
+ if (process.env.NO_COLOR || !process.stdout.isTTY) {
359
+ return new Proxy({}, { get: () => (s) => s });
360
+ }
361
+ return {
362
+ b: (s) => `\x1b[1m${s}\x1b[0m`,
363
+ d: (s) => `\x1b[2m${s}\x1b[0m`,
364
+ g: (s) => `\x1b[32m${s}\x1b[0m`,
365
+ y: (s) => `\x1b[33m${s}\x1b[0m`,
366
+ r: (s) => `\x1b[31m${s}\x1b[0m`,
367
+ c: (s) => `\x1b[36m${s}\x1b[0m`,
368
+ };
369
+ })();
370
+
371
+ function fmtBaseline(b) {
372
+ if (!b) return '\n (no baseline)\n';
373
+ const lines = [];
374
+ lines.push('');
375
+ lines.push(` ${C.b('Project:')} ${b.project_root || '(unknown)'}`);
376
+ lines.push(` ${C.b('Generated:')} ${b.generated_at}`);
377
+ lines.push(` ${C.b('Sessions:')} ${b.session_count}`);
378
+ lines.push('');
379
+ lines.push(` ${C.b('Tool categories used:')}`);
380
+ for (const [t, v] of Object.entries(b.tools || {}).sort((a, b) => b[1].count - a[1].count)) {
381
+ lines.push(` ${t.padEnd(18)} ${String(v.count).padStart(5)} calls in ${v.sessions} session(s)`);
382
+ }
383
+ lines.push('');
384
+ const pathEntries = Object.entries(b.paths || {}).sort((a, b) => b[1].count - a[1].count);
385
+ if (pathEntries.length) {
386
+ lines.push(` ${C.b('Top paths:')}`);
387
+ for (const [p, v] of pathEntries.slice(0, 10)) {
388
+ lines.push(` ${v.count.toString().padStart(4)}× ${C.d(p)}`);
389
+ }
390
+ if (pathEntries.length > 10) lines.push(` ${C.d(`… and ${pathEntries.length - 10} more`)}`);
391
+ lines.push('');
392
+ }
393
+ const verbEntries = Object.entries(b.verbs || {}).sort((a, b) => b[1].count - a[1].count);
394
+ if (verbEntries.length) {
395
+ lines.push(` ${C.b('Shell verbs:')}`);
396
+ for (const [v, e] of verbEntries) {
397
+ lines.push(` ${v.padEnd(12)} ${String(e.count).padStart(4)} calls in ${e.sessions} session(s)`);
398
+ }
399
+ lines.push('');
400
+ }
401
+ const s = b.session_tool_counts || {};
402
+ lines.push(` ${C.b('Session size:')} median ${s.median} p75 ${s.p75} p95 ${s.p95} max ${s.max}`);
403
+ return lines.join('\n');
404
+ }
405
+
406
+ function fmtComparison(cmp) {
407
+ const lines = [];
408
+ if (!cmp) return '\n (no comparison)\n';
409
+ lines.push('');
410
+ const { summary, anomalies } = cmp;
411
+ lines.push(` ${C.b('Session:')} ${summary.session_tool_calls} tool calls`);
412
+ lines.push(` ${C.b('Baseline:')} ${summary.baseline_sessions} sessions${summary.low_confidence ? C.d(' (low confidence — <5 sessions)') : ''}`);
413
+ if (anomalies.length === 0) {
414
+ lines.push('');
415
+ lines.push(` ${C.g('✓ No anomalies. Session matches baseline.')}`);
416
+ return lines.join('\n');
417
+ }
418
+ lines.push('');
419
+ lines.push(` ${C.b('Anomalies:')} ${C.r(summary.high_severity + ' high')}, ${C.y(summary.medium_severity + ' medium')}`);
420
+ lines.push('');
421
+ for (const a of anomalies) {
422
+ const sev = a.severity === 'high' ? C.r('HIGH ')
423
+ : a.severity === 'medium' ? C.y('MED ')
424
+ : C.d('LOW ');
425
+ lines.push(` ${sev} ${a.type.padEnd(16)} ${a.detail}`);
426
+ }
427
+ return lines.join('\n');
428
+ }
429
+
430
+ // ── CLI ──────────────────────────────────────────────────────────────────────
431
+
432
+ function runBaselineCli(args = []) {
433
+ const sub = args[0];
434
+ const daysIdx = args.indexOf('--days');
435
+ const days = daysIdx >= 0 ? (parseInt(args[daysIdx + 1], 10) || 30) : 30;
436
+ const cwd = process.cwd();
437
+
438
+ if (!sub || sub === 'show') {
439
+ const b = readBaseline(cwd);
440
+ if (!b) {
441
+ process.stdout.write('\n ' + C.d(`No baseline for this project. Run: ${C.b('occasio baseline learn')}`) + '\n');
442
+ return { ok: false };
443
+ }
444
+ process.stdout.write(`\n${C.b('Occasio Baseline')}\n` + fmtBaseline(b) + '\n');
445
+ return { ok: true };
446
+ }
447
+
448
+ if (sub === 'learn') {
449
+ const baseline = mineBaseline({ cwd, days });
450
+ const target = writeBaseline(cwd, baseline);
451
+ process.stdout.write(`\n${C.b('Occasio Baseline — learn')}\n`);
452
+ process.stdout.write(` Source: last ${days} days of logs\n`);
453
+ process.stdout.write(` Saved: ${target}\n`);
454
+ process.stdout.write(fmtBaseline(baseline) + '\n');
455
+ return { ok: true, baseline };
456
+ }
457
+
458
+ if (sub === 'compare') {
459
+ const baseline = readBaseline(cwd);
460
+ if (!baseline) {
461
+ process.stdout.write('\n ' + C.d(`No baseline yet. Run: ${C.b('occasio baseline learn')}`) + '\n');
462
+ return { ok: false };
463
+ }
464
+ // Compare LAST run (most recent run_id) for this cwd.
465
+ const all = readRecentEntries({ days });
466
+ const scoped = filterByCwd(all, cwd);
467
+ const runs = groupByRun(scoped);
468
+ if (runs.length === 0) {
469
+ process.stdout.write('\n ' + C.d('No recent sessions for this project to compare.') + '\n');
470
+ return { ok: false };
471
+ }
472
+ // Pick the run whose latest event is the most recent.
473
+ const lastRun = runs
474
+ .map(r => ({ run: r, lastIso: r.reduce((m, e) => Math.max(m, new Date(e.iso || 0).getTime()), 0) }))
475
+ .sort((a, b) => b.lastIso - a.lastIso)[0].run;
476
+ const session = extractToolCalls(lastRun);
477
+ const cmp = compareSession(session, baseline);
478
+ process.stdout.write(`\n${C.b('Occasio Baseline — compare')}\n` + fmtComparison(cmp) + '\n');
479
+ return { ok: cmp.anomalies.length === 0, comparison: cmp };
480
+ }
481
+
482
+ if (sub === 'reset') {
483
+ const ok = deleteBaseline(cwd);
484
+ process.stdout.write('\n ' + (ok ? C.g('Baseline deleted.') : C.d('No baseline to delete.')) + '\n');
485
+ return { ok };
486
+ }
487
+
488
+ process.stdout.write('\n ' + C.r(`Unknown subcommand: ${sub}`) + '\n');
489
+ process.stdout.write(' ' + C.d('Use: occasio baseline [learn|show|compare|reset]') + '\n');
490
+ return { ok: false };
491
+ }
492
+
493
+ module.exports = {
494
+ // pure functions
495
+ buildBaseline, mineBaseline, compareSession,
496
+ extractToolCalls, groupByRun, filterByCwd,
497
+ canonicalToolName, isSensitivePath, firstWord,
498
+ // io
499
+ readBaseline, writeBaseline, deleteBaseline, baselinePathFor,
500
+ readRecentEntries,
501
+ // renderers
502
+ fmtBaseline, fmtComparison,
503
+ // cli
504
+ runBaselineCli,
505
+ // exported for tests
506
+ TRACKED_TOOLS, SENSITIVE_PATH_PATTERNS, HIGH_RISK_SHELL_VERBS,
507
+ };