@occasiolabs/occasio 0.8.3 → 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/README.md +1 -0
- package/docs/ARCHITECTURE.md +171 -0
- package/package.json +8 -2
- package/src/attest/check-summary.js +1 -1
- package/src/attest/index.js +14 -1
- package/src/audit/jsonl-auditor.js +180 -14
- package/src/audit/repair.js +118 -0
- package/src/audit/verifier.js +36 -2
- package/src/cli/clear.js +55 -0
- package/src/cli/help.js +81 -0
- package/src/cli/register.js +90 -0
- package/src/cli/status.js +94 -0
- package/src/cost/prices.js +106 -0
- package/src/index.js +15 -270
package/src/index.js
CHANGED
|
@@ -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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
449
|
-
|
|
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
|
-
|
|
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
|
|