@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.
@@ -0,0 +1,55 @@
1
+ // `occasio clear` — reset session + today's log.
2
+ // `occasio clear --history` — wipe ALL historical logs + blocked-secret records.
3
+ //
4
+ // Destructive but bounded to ~/.occasio/. session.json is unconditionally
5
+ // removed; logs are removed file-by-file (no rmdir) so an exotic
6
+ // permission error on one file does not abort the rest.
7
+
8
+ 'use strict';
9
+
10
+ const fs = require('fs');
11
+ const os = require('os');
12
+ const path = require('path');
13
+
14
+ const LOG_DIR = path.join(os.homedir(), '.occasio');
15
+ const SESSION_FILE = path.join(LOG_DIR, 'session.json');
16
+
17
+ const col = {
18
+ g: s => `\x1b[32m${s}\x1b[0m`,
19
+ d: s => `\x1b[2m${s}\x1b[0m`,
20
+ };
21
+
22
+ function todayStr() {
23
+ const d = new Date();
24
+ return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`;
25
+ }
26
+ function getLogFile() { return path.join(LOG_DIR, 'logs', `${todayStr()}.jsonl`); }
27
+
28
+ function ensureDirs() {
29
+ for (const sub of ['', 'logs', 'reports', 'blocked', 'distilled']) {
30
+ const d = path.join(LOG_DIR, sub);
31
+ if (!fs.existsSync(d)) fs.mkdirSync(d, { recursive: true });
32
+ }
33
+ }
34
+
35
+ function run(args) {
36
+ ensureDirs();
37
+ const clearAll = (args || []).includes('--history');
38
+ if (clearAll) {
39
+ const logsDir = path.join(LOG_DIR, 'logs');
40
+ const blockedDir = path.join(LOG_DIR, 'blocked');
41
+ let n = 0;
42
+ for (const dir of [logsDir, blockedDir]) {
43
+ try { for (const f of fs.readdirSync(dir)) { fs.unlinkSync(path.join(dir, f)); n++; } } catch {}
44
+ }
45
+ try { fs.unlinkSync(SESSION_FILE); } catch {}
46
+ console.log(col.g(`✓ Cleared all history (${n} log files) and session data`));
47
+ } else {
48
+ try { fs.unlinkSync(getLogFile()); } catch {}
49
+ try { fs.unlinkSync(SESSION_FILE); } catch {}
50
+ console.log(col.g("✓ Cleared today's log and session data"));
51
+ console.log(col.d(' Use --history to wipe all historical logs'));
52
+ }
53
+ }
54
+
55
+ module.exports = { run };
@@ -0,0 +1,81 @@
1
+ // `occasio help` — top-level usage. Pure text; no side effects other
2
+ // than console.log. Each CLI command lives in its own file under
3
+ // src/cli/ as part of the index.js decomposition (see CHANGELOG).
4
+
5
+ 'use strict';
6
+
7
+ const VERSION = (() => {
8
+ try { return require('../../package.json').version; }
9
+ catch { return '0.0.0-unknown'; }
10
+ })();
11
+
12
+ const col = {
13
+ r: s => `\x1b[31m${s}\x1b[0m`, g: s => `\x1b[32m${s}\x1b[0m`,
14
+ y: s => `\x1b[33m${s}\x1b[0m`, c: s => `\x1b[36m${s}\x1b[0m`,
15
+ d: s => `\x1b[2m${s}\x1b[0m`, b: s => `\x1b[1m${s}\x1b[0m`,
16
+ };
17
+
18
+ function run() {
19
+ console.log(`
20
+ ${col.b(`⚡ Occasio v${VERSION}`)}
21
+
22
+ ${col.b('Usage:')}
23
+ occasio claude [args...] Start Claude with local proxy (intercept + log)
24
+ occasio demo 10-second proof: see Occasio block real secrets
25
+ occasio demo attest End-to-end attestation pipeline against a synthetic audit chain
26
+ occasio demo anomalies End-to-end EDR test: synthetic adversarial chain → all 4 detectors
27
+ occasio dashboard Open live dashboard for the running session
28
+ occasio register Register shell alias (type 'claude' directly)
29
+ occasio status Show session stats and savings breakdown
30
+ occasio doctor Check setup: Node, claude CLI, port, Python, profile
31
+ occasio clear Reset today's log and session data
32
+ occasio clear --history Wipe all historical logs
33
+ occasio ledger Inspect token ledger (--last N, --summary, --scope session|today)
34
+ occasio replay Replay run audit (--last N, --detail, --run <id>, --attribute)
35
+ occasio distill Inspect distilled outputs (--last N, --entry <N> for raw)
36
+ occasio inspect Cloud-boundary manifest (--last N, --entry N, --run <id>)
37
+ occasio boundary Per-request three-column view: produced / re-entered / prevented
38
+ occasio baseline Behavior baseline: [learn|show|compare|reset] (per project cwd)
39
+ occasio harness Run a real Claude Code session against scratch fixtures and verify governance claims (needs ANTHROPIC_API_KEY)
40
+ occasio redteam Autonomous adversarial test — tester LLM probes a subject Claude Code session under Occasio (needs ANTHROPIC_API_KEY + @anthropic-ai/sdk)
41
+ occasio policy [show] Show active policy: flags, tool routing, overrides
42
+ occasio policy show --diff Only values that differ from defaults
43
+ occasio policy validate Validate policy.yml and report errors/warnings
44
+ occasio policy init Create a starter policy.yml (safe, non-destructive)
45
+ Use --template strict|finance for a non-default starter
46
+ occasio policy doctor Cross-reference session logs with policy; surface suggestions
47
+ occasio audit [verify] Verify tamper-evident hash chain in pipeline-events.jsonl
48
+ occasio audit repair Truncate a crash-partial trailing line (--file <path> [--dry-run])
49
+ occasio report Governance export: file access log, blocked paths, secret events
50
+ occasio anomalies Live anomaly detection over the audit chain (--window 15m, --json)
51
+ occasio computer-use Apply a Computer-Use policy to a JSONL of tool_use blocks (--dry-run --example)
52
+ occasio attest --run-id <uuid> AI-Agent Behavioral Attestation v1: hash-chain commitment + execution summary for one run
53
+ Add --sign in GitHub Actions (with permissions: id-token: write) for Sigstore keyless signing
54
+ occasio attest verify <file> Re-verify a signed attestation: Sigstore bundle + DSSE payload match + audit chain integrity
55
+ occasio selftest Run governance self-checks on a scratch chain (does not touch your audit log)
56
+ occasio report --format csv CSV export for auditors / SIEM import
57
+ occasio mcp-experiment MCP vs. built-in tool adoption stats (experiment)
58
+
59
+ ${col.b('Presets:')}
60
+ --preset balanced (default) Intercept safe reads locally, log all requests
61
+ --preset strict Block requests that contain detected secrets
62
+ --preset off Log only — no interception, no blocking
63
+
64
+ ${col.b('Flags:')}
65
+ --budget <N> Block requests once session cost exceeds $N (e.g. --budget 1.00)
66
+ --hardened Route Read/Glob/Grep through unified runtime (distill + secret scan)
67
+ --block-secrets Alias for --preset strict
68
+ --log-only Alias for --preset off
69
+ --dashboard Open live dashboard at http://localhost:3001
70
+ --port <N> Proxy port (default: 8081)
71
+ --verbose Print live per-request chatter (off by default — quiet for Claude Code's TUI)
72
+
73
+ ${col.b('Multi-agent routing:')}
74
+ Default → Claude Code adapter
75
+ Header x-occasio-agent: cline → Cline adapter (synthetic; live validation pending)
76
+
77
+ ${col.b('Logs:')} ~/.occasio/logs/YYYY-MM-DD.jsonl
78
+ `);
79
+ }
80
+
81
+ module.exports = { run };
@@ -0,0 +1,90 @@
1
+ // `occasio register` — installs a `claude()` shell function so the user
2
+ // can keep typing `claude` and silently get routed through the proxy.
3
+ //
4
+ // Idempotent: detects the canonical marker and exits; auto-upgrades the
5
+ // legacy `--intercept` snippet to `claude` if found. Best-effort on
6
+ // failure — prints a manual instruction rather than crashing.
7
+
8
+ 'use strict';
9
+
10
+ const fs = require('fs');
11
+ const os = require('os');
12
+ const path = require('path');
13
+
14
+ const col = {
15
+ r: s => `\x1b[31m${s}\x1b[0m`, g: s => `\x1b[32m${s}\x1b[0m`,
16
+ y: s => `\x1b[33m${s}\x1b[0m`, d: s => `\x1b[2m${s}\x1b[0m`,
17
+ };
18
+
19
+ function registerWindows() {
20
+ const profileDir = path.join(os.homedir(), 'Documents', 'PowerShell');
21
+ const profileFile = path.join(profileDir, 'Microsoft.PowerShell_profile.ps1');
22
+ const snippet = `\n# Occasio — intercept Claude Code traffic\nfunction claude { occasio claude @args }\n`;
23
+ const alreadyMarker = 'occasio claude @args';
24
+ const legacyMarker = 'occasio --intercept @args';
25
+ try {
26
+ if (!fs.existsSync(profileDir)) fs.mkdirSync(profileDir, { recursive: true });
27
+ const existing = fs.existsSync(profileFile) ? fs.readFileSync(profileFile, 'utf8') : '';
28
+ if (existing.includes(alreadyMarker)) {
29
+ console.log(col.g('✓ Already registered (PowerShell)'));
30
+ console.log(col.d(' Type: claude'));
31
+ } else if (existing.includes(legacyMarker)) {
32
+ const updated = existing.replace(
33
+ /function claude \{ occasio --intercept @args \}/g,
34
+ 'function claude { occasio claude @args }'
35
+ );
36
+ fs.writeFileSync(profileFile, updated);
37
+ console.log(col.g('✓ Updated to canonical form (occasio claude)'));
38
+ console.log('');
39
+ console.log(col.y(` ⚠ Restart PowerShell — the 'claude' alias is not active yet.`));
40
+ console.log(col.d(` Open a new terminal, or run: . $PROFILE`));
41
+ console.log('');
42
+ } else {
43
+ fs.appendFileSync(profileFile, snippet);
44
+ console.log(col.g(`✓ Registered in ${profileFile}`));
45
+ console.log('');
46
+ console.log(col.y(` ⚠ Restart PowerShell — the 'claude' alias is not active yet.`));
47
+ console.log(col.d(` Open a new terminal, or run: . $PROFILE`));
48
+ console.log('');
49
+ }
50
+ } catch (e) {
51
+ console.log(col.r(`✗ Could not write profile: ${e.message}`));
52
+ console.log(col.d(` Add manually to your PowerShell profile:\n function claude { occasio claude @args }`));
53
+ }
54
+ }
55
+
56
+ function registerPosix() {
57
+ const rcFile = (process.env.SHELL || '').includes('zsh')
58
+ ? path.join(os.homedir(), '.zshrc')
59
+ : path.join(os.homedir(), '.bashrc');
60
+ const snippet = `\n# Occasio — intercept Claude Code traffic\nclaude() { occasio claude "$@"; }\n`;
61
+ const alreadyMarker = 'occasio claude "$@"';
62
+ const legacyMarker = 'occasio --intercept "$@"';
63
+ try {
64
+ const existing = fs.existsSync(rcFile) ? fs.readFileSync(rcFile, 'utf8') : '';
65
+ if (existing.includes(alreadyMarker)) {
66
+ console.log(col.g(`✓ Already registered (${rcFile})`));
67
+ } else if (existing.includes(legacyMarker)) {
68
+ const updated = existing.replace(
69
+ /claude\(\) \{ occasio --intercept "\$@"; \}/g,
70
+ 'claude() { occasio claude "$@"; }'
71
+ );
72
+ fs.writeFileSync(rcFile, updated);
73
+ console.log(col.g(`✓ Updated to canonical form in ${rcFile}`));
74
+ } else {
75
+ fs.appendFileSync(rcFile, snippet);
76
+ console.log(col.g(`✓ Registered in ${rcFile}`));
77
+ }
78
+ console.log(col.d(' Run: source ' + rcFile + ' — then type: claude'));
79
+ } catch (e) {
80
+ console.log(col.r(`✗ Could not write ${rcFile}: ${e.message}`));
81
+ console.log(col.d(` Add manually:\n claude() { occasio claude "$@"; }`));
82
+ }
83
+ }
84
+
85
+ function run() {
86
+ if (process.platform === 'win32') registerWindows();
87
+ else registerPosix();
88
+ }
89
+
90
+ module.exports = { run };
@@ -0,0 +1,94 @@
1
+ // `occasio status` — session summary (cost, savings, coverage, budget).
2
+ // Read-only against ~/.occasio/session.json + today's JSONL log.
3
+
4
+ 'use strict';
5
+
6
+ const fs = require('fs');
7
+ const os = require('os');
8
+ const path = require('path');
9
+
10
+ const { calcCompoundingSavings } = require('../cost/prices');
11
+ const { fmtBudget } = require('../budget');
12
+
13
+ const LOG_DIR = path.join(os.homedir(), '.occasio');
14
+ const SESSION_FILE = path.join(LOG_DIR, 'session.json');
15
+
16
+ const col = {
17
+ r: s => `\x1b[31m${s}\x1b[0m`, g: s => `\x1b[32m${s}\x1b[0m`,
18
+ y: s => `\x1b[33m${s}\x1b[0m`, c: s => `\x1b[36m${s}\x1b[0m`,
19
+ d: s => `\x1b[2m${s}\x1b[0m`, b: s => `\x1b[1m${s}\x1b[0m`,
20
+ };
21
+
22
+ function todayStr() {
23
+ const d = new Date();
24
+ return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`;
25
+ }
26
+ function getLogFile() { return path.join(LOG_DIR, 'logs', `${todayStr()}.jsonl`); }
27
+
28
+ function run() {
29
+ let s = null; try { s = JSON.parse(fs.readFileSync(SESSION_FILE, 'utf8')); } catch {}
30
+ console.log(col.b('\n⚡ Occasio\n'));
31
+ if (!s) { console.log(col.d(' No session data yet. Run: occasio claude\n')); return; }
32
+
33
+ const cacheSav = s.cache_savings || 0;
34
+ const laoSav = s.lao_cost_saved || 0;
35
+ const distSav = s.distill_cost_saved || 0;
36
+ const payload = laoSav + distSav;
37
+ const { savings: context } =
38
+ calcCompoundingSavings(s.run_id, s.log_file || getLogFile(), s.model || '');
39
+ const totalSav = payload + context + cacheSav;
40
+ const broaderCf = (s.cost || 0) + totalSav;
41
+ const savedPct = broaderCf > 0.00001 ? Math.round(totalSav / broaderCf * 100) : 0;
42
+
43
+ // Headline
44
+ if (totalSav > 0.00001) {
45
+ console.log(col.g(` Saved: $${totalSav.toFixed(4)}`) +
46
+ col.d(` (${savedPct}% off — would have cost $${broaderCf.toFixed(4)})`));
47
+ } else {
48
+ console.log(col.d(` Saved: $0.0000 (no interceptable tool calls in this session yet)`));
49
+ }
50
+ console.log(col.y(` Cost: $${s.cost.toFixed(4)}`));
51
+
52
+ // Plain-English coverage. Defensive: legacy sessions (pre-multi-round-fix)
53
+ // may have tools_attempted undercounted relative to tools_local_count.
54
+ // We clamp the denominator to at least the numerator so the displayed
55
+ // ratio is always 0–100% and never reads "X of Y < X (>100%)".
56
+ const localCnt = s.tools_local_count || 0;
57
+ const mcpCnt = s.tools_mcp_count || 0;
58
+ const attempted = s.tools_attempted || 0;
59
+ const totalLocal = localCnt + mcpCnt;
60
+ const denom = Math.max(attempted, totalLocal);
61
+ if (denom > 0) {
62
+ const cpct = Math.round(totalLocal / denom * 100);
63
+ const cColor = cpct >= 80 ? col.g : cpct >= 50 ? col.y : col.r;
64
+ console.log(cColor(` Ran locally: ${totalLocal} of ${denom} tool calls (${cpct}%)`));
65
+ }
66
+ if (s.blocked) console.log(col.r(` Blocked: ${s.blocked} secrets`));
67
+ if (s.secrets_redacted) console.log(col.c(` Redacted: ${s.secrets_redacted} secret${s.secrets_redacted !== 1 ? 's' : ''} in tool results`));
68
+ if (s.tools_transformed) console.log(col.c(` Transforms: ${s.tools_transformed} tool result${s.tools_transformed !== 1 ? 's' : ''} shaped`));
69
+ if (s.budget != null) {
70
+ const pct = Math.min(999, Math.round((s.cost || 0) / s.budget * 100));
71
+ const budgetStr = fmtBudget(s.cost || 0, s.budget);
72
+ const budgetColor = pct >= 100 ? col.r : pct >= 80 ? col.y : col.g;
73
+ console.log(budgetColor(` Budget: ${budgetStr}`));
74
+ if (s.budget_exceeded_count) console.log(col.r(` BudgetBlk: ${s.budget_exceeded_count} request(s) blocked`));
75
+ }
76
+
77
+ // Detail
78
+ console.log(col.d(` ────`));
79
+ console.log(col.d(` Requests: ${s.requests} · ${(s.input_tokens/1000).toFixed(1)}k tokens in · ${(s.output_tokens/1000).toFixed(1)}k out`));
80
+ if (totalSav > 0.00001) {
81
+ const parts = [];
82
+ if (payload > 0.00001) parts.push(`$${payload.toFixed(4)} payload`);
83
+ if (context > 0.00001) parts.push(`$${context.toFixed(4)} context`);
84
+ if (cacheSav > 0.00001) parts.push(`$${cacheSav.toFixed(4)} cache`);
85
+ if (parts.length) console.log(col.d(` Breakdown: ${parts.join(' + ')}`));
86
+ }
87
+ const tail = [];
88
+ if (s.mode) tail.push(`Mode: ${s.mode}`);
89
+ if (s.start) tail.push(`Since: ${new Date(s.start).toLocaleString()}`);
90
+ if (tail.length) console.log(col.d(` ${tail.join(' · ')}`));
91
+ console.log('');
92
+ }
93
+
94
+ module.exports = { run };
@@ -0,0 +1,106 @@
1
+ // Token-cost arithmetic for Anthropic-priced models. Extracted from
2
+ // src/index.js so the proxy hot-path doesn't carry pricing data, and so
3
+ // MODEL_PRICES updates land in a file small enough to review at a glance.
4
+ //
5
+ // Prices are USD per 1M tokens. Cache-write is the one-time cost to
6
+ // populate a cache breakpoint; cache-read is the cheap subsequent hit.
7
+ //
8
+ // Substring matching is intentional — Anthropic's model_id strings often
9
+ // carry a dated suffix (claude-haiku-4-5-20251001) we want to absorb
10
+ // without a table update. The trade-off: a truly unknown model silently
11
+ // falls back to `default`. We warn once per unknown model so the failure
12
+ // is loud the first time and quiet thereafter.
13
+
14
+ 'use strict';
15
+
16
+ const fs = require('fs');
17
+
18
+ const MODEL_PRICES = {
19
+ 'claude-opus-4-6': { in: 15.00, out: 75.00, cache_write: 18.75, cache_read: 1.50 },
20
+ 'claude-opus-4': { in: 15.00, out: 75.00, cache_write: 18.75, cache_read: 1.50 },
21
+ 'claude-sonnet-4-6': { in: 3.00, out: 15.00, cache_write: 3.75, cache_read: 0.30 },
22
+ 'claude-sonnet-4': { in: 3.00, out: 15.00, cache_write: 3.75, cache_read: 0.30 },
23
+ 'claude-haiku-4-5': { in: 0.25, out: 1.25, cache_write: 0.30, cache_read: 0.03 },
24
+ 'claude-haiku-4': { in: 0.25, out: 1.25, cache_write: 0.30, cache_read: 0.03 },
25
+ 'default': { in: 3.00, out: 15.00, cache_write: 3.75, cache_read: 0.30 },
26
+ };
27
+
28
+ // Track which unknown model names we've already complained about, so a
29
+ // long session with a new model surfaces the warning exactly once instead
30
+ // of on every request.
31
+ const _warnedUnknown = new Set();
32
+
33
+ function getPrice(model) {
34
+ if (!model) return MODEL_PRICES.default;
35
+ for (const [k, v] of Object.entries(MODEL_PRICES)) {
36
+ if (k !== 'default' && model.includes(k)) return v;
37
+ }
38
+ if (!_warnedUnknown.has(model)) {
39
+ _warnedUnknown.add(model);
40
+ // stderr so it doesn't pollute proxy stdout. Silenceable via env for
41
+ // CI runs that legitimately want to price unknown models as default.
42
+ if (!process.env.OCCASIO_QUIET_PRICING) {
43
+ process.stderr.write(
44
+ `[occasio] warning: unknown model "${model}" — falling back to default pricing ` +
45
+ `(in:$${MODEL_PRICES.default.in}/M, out:$${MODEL_PRICES.default.out}/M). ` +
46
+ `Add it to src/cost/prices.js to silence this.\n`
47
+ );
48
+ }
49
+ }
50
+ return MODEL_PRICES.default;
51
+ }
52
+
53
+ function calcCost(model, inp, out, cacheWrite = 0, cacheRead = 0) {
54
+ const p = getPrice(model);
55
+ return (inp / 1e6 * p.in) + (out / 1e6 * p.out)
56
+ + (cacheWrite / 1e6 * p.cache_write) + (cacheRead / 1e6 * p.cache_read);
57
+ }
58
+
59
+ // Savings from Anthropic prompt caching: cache reads are 10× cheaper than fresh input.
60
+ function calcCacheSavings(model, cacheReadTokens) {
61
+ if (!cacheReadTokens) return 0;
62
+ const p = getPrice(model);
63
+ return (cacheReadTokens / 1e6) * (p.in - p.cache_read);
64
+ }
65
+
66
+ // Cross-request compounding savings: reads the run's JSONL entries in sequence order
67
+ // and weights each distilled batch by the exact number of subsequent API calls that
68
+ // carry the smaller result in their conversation history.
69
+ // Formula: Σ (distill_tokens_saved[i] / 1M × price_in × (N - i - 1))
70
+ // Returns { savings: float, carryInstances: int }
71
+ // carryInstances = total sum of subsequent-call counts across all distilled batches
72
+ // — the actual multiplier used — so the display is self-explanatory.
73
+ // Assumption: tool results accumulate in Claude Code's message history for all
74
+ // subsequent requests in the session (true for normal sessions; may not hold if
75
+ // Claude Code resets context mid-session).
76
+ function calcCompoundingSavings(runId, logFile, model) {
77
+ if (!runId) return { savings: 0, carryInstances: 0 };
78
+ let entries;
79
+ try {
80
+ const lines = fs.readFileSync(logFile, 'utf8').trim().split('\n').filter(Boolean);
81
+ entries = lines
82
+ .map(l => { try { return JSON.parse(l); } catch { return null; } })
83
+ .filter(e => e && e.run_id === runId);
84
+ } catch { return { savings: 0, carryInstances: 0 }; }
85
+ const N = entries.length;
86
+ if (N < 2) return { savings: 0, carryInstances: 0 };
87
+ const p = getPrice(model);
88
+ let savings = 0, carryInstances = 0;
89
+ for (let i = 0; i < N; i++) {
90
+ const dt = entries[i].distill_tokens_saved || 0;
91
+ if (dt > 0) {
92
+ const subsequent = N - i - 1;
93
+ savings += (dt / 1e6) * p.in * subsequent;
94
+ carryInstances += subsequent;
95
+ }
96
+ }
97
+ return { savings, carryInstances };
98
+ }
99
+
100
+ module.exports = {
101
+ MODEL_PRICES,
102
+ getPrice,
103
+ calcCost,
104
+ calcCacheSavings,
105
+ calcCompoundingSavings,
106
+ };