@occasiolabs/occasio 0.8.2 → 0.8.4

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/src/index.js CHANGED
@@ -59,7 +59,7 @@ const LOG_SCHEMA_VERSION = 2;
59
59
  // Port override via env var (used by `occasio harness` and redteam to
60
60
  // run isolated proxies against scratch audit chains on free ports). Default
61
61
  // is 8081 to preserve existing user-facing behaviour.
62
- let PORT = parseInt(process.env.LOCALFIRST_PORT, 10) || 8081;
62
+ let PORT = parseInt(process.env.OCCASIO_PORT, 10) || 8081;
63
63
  const ANTHROPIC_REAL = 'api.anthropic.com';
64
64
  const LOG_DIR = path.join(os.homedir(), '.occasio');
65
65
  const SESSION_FILE = path.join(LOG_DIR, 'session.json');
@@ -71,70 +71,16 @@ function todayStr() { const d = new Date(); return `${d.getFullYear()}-${String(
71
71
  function getLogFile() { return path.join(LOG_DIR, 'logs', `${todayStr()}.jsonl`); }
72
72
  function getBlockedFile() { return path.join(LOG_DIR, 'blocked', `${todayStr()}-secrets.log`); }
73
73
 
74
- const MODEL_PRICES = {
75
- 'claude-opus-4-6': { in: 15.00, out: 75.00, cache_write: 18.75, cache_read: 1.50 },
76
- 'claude-opus-4': { in: 15.00, out: 75.00, cache_write: 18.75, cache_read: 1.50 },
77
- 'claude-sonnet-4-6': { in: 3.00, out: 15.00, cache_write: 3.75, cache_read: 0.30 },
78
- 'claude-sonnet-4': { in: 3.00, out: 15.00, cache_write: 3.75, cache_read: 0.30 },
79
- 'claude-haiku-4-5': { in: 0.25, out: 1.25, cache_write: 0.30, cache_read: 0.03 },
80
- 'claude-haiku-4': { in: 0.25, out: 1.25, cache_write: 0.30, cache_read: 0.03 },
81
- 'default': { in: 3.00, out: 15.00, cache_write: 3.75, cache_read: 0.30 },
82
- };
83
-
84
- function getPrice(model) {
85
- if (!model) return MODEL_PRICES.default;
86
- for (const [k, v] of Object.entries(MODEL_PRICES)) {
87
- if (k !== 'default' && model.includes(k)) return v;
88
- }
89
- return MODEL_PRICES.default;
90
- }
91
-
92
- function calcCost(model, inp, out, cacheWrite = 0, cacheRead = 0) {
93
- const p = getPrice(model);
94
- return (inp / 1e6 * p.in) + (out / 1e6 * p.out)
95
- + (cacheWrite / 1e6 * p.cache_write) + (cacheRead / 1e6 * p.cache_read);
96
- }
97
-
98
- // Savings from Anthropic prompt caching: cache reads are 10× cheaper than fresh input.
99
- function calcCacheSavings(model, cacheReadTokens) {
100
- if (!cacheReadTokens) return 0;
101
- const p = getPrice(model);
102
- return (cacheReadTokens / 1e6) * (p.in - p.cache_read);
103
- }
104
-
105
- // Cross-request compounding savings: reads the run's JSONL entries in sequence order
106
- // and weights each distilled batch by the exact number of subsequent API calls that
107
- // carry the smaller result in their conversation history.
108
- // Formula: Σ (distill_tokens_saved[i] / 1M × price_in × (N - i - 1))
109
- // Returns { savings: float, carryInstances: int }
110
- // carryInstances = total sum of subsequent-call counts across all distilled batches
111
- // — the actual multiplier used — so the display is self-explanatory.
112
- // Assumption: tool results accumulate in Claude Code's message history for all
113
- // subsequent requests in the session (true for normal sessions; may not hold if
114
- // Claude Code resets context mid-session).
115
- function calcCompoundingSavings(runId, logFile, model) {
116
- if (!runId) return { savings: 0, carryInstances: 0 };
117
- let entries;
118
- try {
119
- const lines = fs.readFileSync(logFile, 'utf8').trim().split('\n').filter(Boolean);
120
- entries = lines
121
- .map(l => { try { return JSON.parse(l); } catch { return null; } })
122
- .filter(e => e && e.run_id === runId);
123
- } catch { return { savings: 0, carryInstances: 0 }; }
124
- const N = entries.length;
125
- if (N < 2) return { savings: 0, carryInstances: 0 };
126
- const p = getPrice(model);
127
- let savings = 0, carryInstances = 0;
128
- for (let i = 0; i < N; i++) {
129
- const dt = entries[i].distill_tokens_saved || 0;
130
- if (dt > 0) {
131
- const subsequent = N - i - 1;
132
- savings += (dt / 1e6) * p.in * subsequent;
133
- carryInstances += subsequent;
134
- }
135
- }
136
- return { savings, carryInstances };
137
- }
74
+ // Pricing extracted to its own module — see src/cost/prices.js for the
75
+ // MODEL_PRICES table + cost-arithmetic helpers. Re-exported here so the
76
+ // rest of index.js keeps its existing call sites unchanged.
77
+ const {
78
+ MODEL_PRICES,
79
+ getPrice,
80
+ calcCost,
81
+ calcCacheSavings,
82
+ calcCompoundingSavings,
83
+ } = require('./cost/prices');
138
84
 
139
85
  // ── Persistence ────────────────────────────────────────────────────────────────
140
86
 
@@ -311,223 +257,22 @@ const cmd = args[0];
311
257
  if (cmd === '--version' || cmd === '-v') { console.log(`occasio v${VERSION}`); process.exit(0); }
312
258
 
313
259
  if (cmd === 'help' || cmd === '--help' || cmd === '-h') {
314
- console.log(`
315
- ${col.b(`⚡ Occasio v${VERSION}`)}
316
-
317
- ${col.b('Usage:')}
318
- occasio claude [args...] Start Claude with local proxy (intercept + log)
319
- occasio demo 10-second proof: see Occasio block real secrets
320
- occasio demo attest End-to-end attestation pipeline against a synthetic audit chain
321
- occasio demo anomalies End-to-end EDR test: synthetic adversarial chain → all 4 detectors
322
- occasio dashboard Open live dashboard for the running session
323
- occasio register Register shell alias (type 'claude' directly)
324
- occasio status Show session stats and savings breakdown
325
- occasio doctor Check setup: Node, claude CLI, port, Python, profile
326
- occasio clear Reset today's log and session data
327
- occasio clear --history Wipe all historical logs
328
- occasio ledger Inspect token ledger (--last N, --summary, --scope session|today)
329
- occasio replay Replay run audit (--last N, --detail, --run <id>, --attribute)
330
- occasio distill Inspect distilled outputs (--last N, --entry <N> for raw)
331
- occasio inspect Cloud-boundary manifest (--last N, --entry N, --run <id>)
332
- occasio boundary Per-request three-column view: produced / re-entered / prevented
333
- occasio baseline Behavior baseline: [learn|show|compare|reset] (per project cwd)
334
- occasio harness Run a real Claude Code session against scratch fixtures and verify governance claims (needs ANTHROPIC_API_KEY)
335
- occasio redteam Autonomous adversarial test — tester LLM probes a subject Claude Code session under Occasio (needs ANTHROPIC_API_KEY + @anthropic-ai/sdk)
336
- occasio policy [show] Show active policy: flags, tool routing, overrides
337
- occasio policy show --diff Only values that differ from defaults
338
- occasio policy validate Validate policy.yml and report errors/warnings
339
- occasio policy init Create a starter policy.yml (safe, non-destructive)
340
- Use --template strict|finance for a non-default starter
341
- occasio policy doctor Cross-reference session logs with policy; surface suggestions
342
- occasio audit [verify] Verify tamper-evident hash chain in pipeline-events.jsonl
343
- occasio report Governance export: file access log, blocked paths, secret events
344
- occasio anomalies Live anomaly detection over the audit chain (--window 15m, --json)
345
- occasio computer-use Apply a Computer-Use policy to a JSONL of tool_use blocks (--dry-run --example)
346
- occasio attest --run-id <uuid> AI-Agent Behavioral Attestation v1: hash-chain commitment + execution summary for one run
347
- Add --sign in GitHub Actions (with permissions: id-token: write) for Sigstore keyless signing
348
- occasio attest verify <file> Re-verify a signed attestation: Sigstore bundle + DSSE payload match + audit chain integrity
349
- occasio selftest Run governance self-checks on a scratch chain (does not touch your audit log)
350
- occasio report --format csv CSV export for auditors / SIEM import
351
- occasio mcp-experiment MCP vs. built-in tool adoption stats (experiment)
352
-
353
- ${col.b('Presets:')}
354
- --preset balanced (default) Intercept safe reads locally, log all requests
355
- --preset strict Block requests that contain detected secrets
356
- --preset off Log only — no interception, no blocking
357
-
358
- ${col.b('Flags:')}
359
- --budget <N> Block requests once session cost exceeds $N (e.g. --budget 1.00)
360
- --hardened Route Read/Glob/Grep through unified runtime (distill + secret scan)
361
- --block-secrets Alias for --preset strict
362
- --log-only Alias for --preset off
363
- --dashboard Open live dashboard at http://localhost:3001
364
- --port <N> Proxy port (default: 8081)
365
- --verbose Print live per-request chatter (off by default — quiet for Claude Code's TUI)
366
-
367
- ${col.b('Multi-agent routing:')}
368
- Default → Claude Code adapter
369
- Header x-occasio-agent: cline → Cline adapter (synthetic; live validation pending)
370
-
371
- ${col.b('Logs:')} ~/.occasio/logs/YYYY-MM-DD.jsonl
372
- `);
260
+ require('./cli/help').run();
373
261
  process.exit(0);
374
262
  }
375
263
 
376
264
  if (cmd === 'register') {
377
- const shell = process.env.SHELL || '';
378
- const isWindows = process.platform === 'win32';
379
-
380
- if (isWindows) {
381
- const profileDir = path.join(os.homedir(), 'Documents', 'PowerShell');
382
- const profileFile = path.join(profileDir, 'Microsoft.PowerShell_profile.ps1');
383
- const snippet = `\n# Occasio — intercept Claude Code traffic\nfunction claude { occasio claude @args }\n`;
384
- const alreadyMarker = 'occasio claude @args';
385
- const legacyMarker = 'occasio --intercept @args';
386
- try {
387
- if (!fs.existsSync(profileDir)) fs.mkdirSync(profileDir, { recursive: true });
388
- const existing = fs.existsSync(profileFile) ? fs.readFileSync(profileFile, 'utf8') : '';
389
- if (existing.includes(alreadyMarker)) {
390
- console.log(col.g('✓ Already registered (PowerShell)'));
391
- console.log(col.d(' Type: claude'));
392
- } else if (existing.includes(legacyMarker)) {
393
- // Upgrade old --intercept form to canonical `occasio claude`
394
- const updated = existing.replace(
395
- /function claude \{ occasio --intercept @args \}/g,
396
- 'function claude { occasio claude @args }'
397
- );
398
- fs.writeFileSync(profileFile, updated);
399
- console.log(col.g('✓ Updated to canonical form (occasio claude)'));
400
- console.log('');
401
- console.log(col.y(` ⚠ Restart PowerShell — the 'claude' alias is not active yet.`));
402
- console.log(col.d(` Open a new terminal, or run: . $PROFILE`));
403
- console.log('');
404
- } else {
405
- fs.appendFileSync(profileFile, snippet);
406
- console.log(col.g(`✓ Registered in ${profileFile}`));
407
- console.log('');
408
- console.log(col.y(` ⚠ Restart PowerShell — the 'claude' alias is not active yet.`));
409
- console.log(col.d(` Open a new terminal, or run: . $PROFILE`));
410
- console.log('');
411
- }
412
- } catch (e) {
413
- console.log(col.r(`✗ Could not write profile: ${e.message}`));
414
- console.log(col.d(` Add manually to your PowerShell profile:\n function claude { occasio claude @args }`));
415
- }
416
- } else {
417
- const rcFile = (process.env.SHELL || '').includes('zsh')
418
- ? path.join(os.homedir(), '.zshrc')
419
- : path.join(os.homedir(), '.bashrc');
420
- const snippet = `\n# Occasio — intercept Claude Code traffic\nclaude() { occasio claude "$@"; }\n`;
421
- const alreadyMarker = 'occasio claude "$@"';
422
- const legacyMarker = 'occasio --intercept "$@"';
423
- try {
424
- const existing = fs.existsSync(rcFile) ? fs.readFileSync(rcFile, 'utf8') : '';
425
- if (existing.includes(alreadyMarker)) {
426
- console.log(col.g(`✓ Already registered (${rcFile})`));
427
- } else if (existing.includes(legacyMarker)) {
428
- const updated = existing.replace(
429
- /claude\(\) \{ occasio --intercept "\$@"; \}/g,
430
- 'claude() { occasio claude "$@"; }'
431
- );
432
- fs.writeFileSync(rcFile, updated);
433
- console.log(col.g(`✓ Updated to canonical form in ${rcFile}`));
434
- } else {
435
- fs.appendFileSync(rcFile, snippet);
436
- console.log(col.g(`✓ Registered in ${rcFile}`));
437
- }
438
- console.log(col.d(' Run: source ' + rcFile + ' — then type: claude'));
439
- } catch (e) {
440
- console.log(col.r(`✗ Could not write ${rcFile}: ${e.message}`));
441
- console.log(col.d(` Add manually:\n claude() { occasio claude "$@"; }`));
442
- }
443
- }
265
+ require('./cli/register').run();
444
266
  process.exit(0);
445
267
  }
446
268
 
447
269
  if (cmd === 'status' || cmd === 'stats') {
448
- let s = null; try { s = JSON.parse(fs.readFileSync(SESSION_FILE, 'utf8')); } catch {}
449
- console.log(col.b('\n⚡ Occasio\n'));
450
- if (!s) { console.log(col.d(' No session data yet. Run: occasio claude\n')); process.exit(0); }
451
-
452
- const cacheSav = s.cache_savings || 0;
453
- const laoSav = s.lao_cost_saved || 0;
454
- const distSav = s.distill_cost_saved || 0;
455
- const payload = laoSav + distSav;
456
- const { savings: context } =
457
- calcCompoundingSavings(s.run_id, s.log_file || getLogFile(), s.model || '');
458
- const totalSav = payload + context + cacheSav;
459
- const broaderCf = (s.cost || 0) + totalSav;
460
- const savedPct = broaderCf > 0.00001 ? Math.round(totalSav / broaderCf * 100) : 0;
461
-
462
- // Headline
463
- if (totalSav > 0.00001) {
464
- console.log(col.g(` Saved: $${totalSav.toFixed(4)}`) +
465
- col.d(` (${savedPct}% off — would have cost $${broaderCf.toFixed(4)})`));
466
- } else {
467
- console.log(col.d(` Saved: $0.0000 (no interceptable tool calls in this session yet)`));
468
- }
469
- console.log(col.y(` Cost: $${s.cost.toFixed(4)}`));
470
-
471
- // Plain-English coverage. Defensive: legacy sessions (pre-multi-round-fix)
472
- // may have tools_attempted undercounted relative to tools_local_count.
473
- // We clamp the denominator to at least the numerator so the displayed
474
- // ratio is always 0–100% and never reads "X of Y < X (>100%)".
475
- const localCnt = s.tools_local_count || 0;
476
- const mcpCnt = s.tools_mcp_count || 0;
477
- const attempted = s.tools_attempted || 0;
478
- const totalLocal = localCnt + mcpCnt;
479
- const denom = Math.max(attempted, totalLocal);
480
- if (denom > 0) {
481
- const cpct = Math.round(totalLocal / denom * 100);
482
- const cColor = cpct >= 80 ? col.g : cpct >= 50 ? col.y : col.r;
483
- console.log(cColor(` Ran locally: ${totalLocal} of ${denom} tool calls (${cpct}%)`));
484
- }
485
- if (s.blocked) console.log(col.r(` Blocked: ${s.blocked} secrets`));
486
- if (s.secrets_redacted) console.log(col.c(` Redacted: ${s.secrets_redacted} secret${s.secrets_redacted !== 1 ? 's' : ''} in tool results`));
487
- if (s.tools_transformed) console.log(col.c(` Transforms: ${s.tools_transformed} tool result${s.tools_transformed !== 1 ? 's' : ''} shaped`));
488
- if (s.budget != null) {
489
- const pct = Math.min(999, Math.round((s.cost || 0) / s.budget * 100));
490
- const budgetStr = fmtBudget(s.cost || 0, s.budget);
491
- const budgetColor = pct >= 100 ? col.r : pct >= 80 ? col.y : col.g;
492
- console.log(budgetColor(` Budget: ${budgetStr}`));
493
- if (s.budget_exceeded_count) console.log(col.r(` BudgetBlk: ${s.budget_exceeded_count} request(s) blocked`));
494
- }
495
-
496
- // Detail
497
- console.log(col.d(` ────`));
498
- console.log(col.d(` Requests: ${s.requests} · ${(s.input_tokens/1000).toFixed(1)}k tokens in · ${(s.output_tokens/1000).toFixed(1)}k out`));
499
- if (totalSav > 0.00001) {
500
- const parts = [];
501
- if (payload > 0.00001) parts.push(`$${payload.toFixed(4)} payload`);
502
- if (context > 0.00001) parts.push(`$${context.toFixed(4)} context`);
503
- if (cacheSav > 0.00001) parts.push(`$${cacheSav.toFixed(4)} cache`);
504
- if (parts.length) console.log(col.d(` Breakdown: ${parts.join(' + ')}`));
505
- }
506
- const tail = [];
507
- if (s.mode) tail.push(`Mode: ${s.mode}`);
508
- if (s.start) tail.push(`Since: ${new Date(s.start).toLocaleString()}`);
509
- if (tail.length) console.log(col.d(` ${tail.join(' · ')}`));
510
- console.log(''); process.exit(0);
270
+ require('./cli/status').run();
271
+ process.exit(0);
511
272
  }
512
273
 
513
274
  if (cmd === 'clear') {
514
- ensureDirs();
515
- const clearAll = args.slice(1).includes('--history');
516
- if (clearAll) {
517
- const logsDir = path.join(LOG_DIR, 'logs');
518
- const blockedDir = path.join(LOG_DIR, 'blocked');
519
- let n = 0;
520
- for (const dir of [logsDir, blockedDir]) {
521
- try { for (const f of fs.readdirSync(dir)) { fs.unlinkSync(path.join(dir, f)); n++; } } catch {}
522
- }
523
- try { fs.unlinkSync(SESSION_FILE); } catch {}
524
- console.log(col.g(`✓ Cleared all history (${n} log files) and session data`));
525
- } else {
526
- try { fs.unlinkSync(getLogFile()); } catch {}
527
- try { fs.unlinkSync(SESSION_FILE); } catch {}
528
- console.log(col.g("✓ Cleared today's log and session data"));
529
- console.log(col.d(' Use --history to wipe all historical logs'));
530
- }
275
+ require('./cli/clear').run(args.slice(1));
531
276
  process.exit(0);
532
277
  }
533
278
 
@@ -926,7 +671,7 @@ const { createAuditor: _createAuditor } = require('./audit/jsonl-auditor');
926
671
  // Audit-file override via env var. Used by `occasio harness` to run
927
672
  // against a scratch chain so the user's real ~/.occasio/pipeline-events
928
673
  // .jsonl is never touched. When unset, the auditor uses its default location.
929
- const sessionAuditor = _createAuditor(process.env.LOCALFIRST_AUDIT_FILE || undefined);
674
+ const sessionAuditor = _createAuditor(process.env.OCCASIO_AUDIT_FILE || undefined);
930
675
 
931
676
  // v0.6.6: register a policy-change listener that emits a `policy_loaded`
932
677
  // audit row whenever the active policy hash transitions to a new value
package/src/mcp-server.js CHANGED
@@ -44,7 +44,7 @@ const LOG_FILE = path.join(os.homedir(), '.occasio', 'mcp-experiment.jsonl');
44
44
  // Audit-file override via env var (symmetric with the Claude Code proxy
45
45
  // in src/index.js). Used by `occasio harness --scenario mcp-*` to
46
46
  // keep MCP test traffic out of the user's real ~/.occasio chain.
47
- let mcpAuditor = createAuditor(process.env.LOCALFIRST_AUDIT_FILE || undefined);
47
+ let mcpAuditor = createAuditor(process.env.OCCASIO_AUDIT_FILE || undefined);
48
48
 
49
49
  // v0.6.6: emit a policy_loaded row on first policy load and on every
50
50
  // hot-reload that changes the policy file's bytes. The MCP server is a
@@ -26,10 +26,10 @@ function resolveConfigPath(p) {
26
26
  return path.resolve(expanded);
27
27
  }
28
28
 
29
- // Default path can be overridden via LOCALFIRST_POLICY_FILE — used by the
29
+ // Default path can be overridden via OCCASIO_POLICY_FILE — used by the
30
30
  // harness/redteam commands to point the proxy at a scratch policy.yml so
31
31
  // the user's real ~/.occasio/policy.yml is never read.
32
- const DEFAULT_PATH = process.env.LOCALFIRST_POLICY_FILE
32
+ const DEFAULT_PATH = process.env.OCCASIO_POLICY_FILE
33
33
  || path.join(os.homedir(), '.occasio', 'policy.yml');
34
34
 
35
35
  // Default tool routing matches the pre-Stage-3 hardcoded behavior.
package/src/redteam.js CHANGED
@@ -410,7 +410,7 @@ async function runRedteamCli(args = []) {
410
410
  process.stdout.write('\n');
411
411
  return result;
412
412
  } finally {
413
- if (!keepScratch && !process.env.LF_REDTEAM_KEEP) {
413
+ if (!keepScratch && !process.env.OCC_REDTEAM_KEEP) {
414
414
  try { fs.rmSync(ctx.workspace, { recursive: true, force: true }); } catch {}
415
415
  }
416
416
  }