@occasiolabs/occasio 0.8.3 → 0.8.5

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 (52) hide show
  1. package/README.md +1 -0
  2. package/docs/ADAPTER-STAGE-2-MIGRATION.md +59 -0
  3. package/docs/ARCHITECTURE.md +171 -0
  4. package/docs/STAGE-2-STEP-5-SHELL-PLAN.md +107 -0
  5. package/docs/THREAT-MODEL.md +195 -0
  6. package/docs/edr-calibration.md +29 -0
  7. package/package.json +12 -2
  8. package/src/adapters/claude-code.js +1 -2
  9. package/src/adapters/computer-use.js +1 -1
  10. package/src/anomaly/cli.js +4 -1
  11. package/src/anomaly/detectors/deny-rate.js +2 -1
  12. package/src/anomaly/detectors/file-read-volume.js +2 -1
  13. package/src/anomaly/index.js +5 -0
  14. package/src/attest/check-summary.js +1 -1
  15. package/src/attest/index.js +14 -1
  16. package/src/audit/jsonl-auditor.js +180 -14
  17. package/src/audit/repair.js +118 -0
  18. package/src/audit/verifier.js +36 -2
  19. package/src/boundary.js +1 -1
  20. package/src/classifier.js +1 -1
  21. package/src/cli/clear.js +55 -0
  22. package/src/cli/help.js +102 -0
  23. package/src/cli/register.js +90 -0
  24. package/src/cli/status.js +94 -0
  25. package/src/cost/prices.js +106 -0
  26. package/src/dashboard.js +2 -3
  27. package/src/distiller.js +1 -1
  28. package/src/executor/dispatcher.js +2 -2
  29. package/src/executor/native-handlers/glob.js +173 -0
  30. package/src/executor/native-handlers/grep.js +258 -0
  31. package/src/executor/native-handlers/read.js +99 -0
  32. package/src/executor/native-handlers/todo.js +56 -0
  33. package/src/harness.js +8 -10
  34. package/src/index.js +26 -283
  35. package/src/inspect.js +1 -1
  36. package/src/interceptor.js +9 -29
  37. package/src/ledger.js +2 -3
  38. package/src/mcp-experiment.js +4 -4
  39. package/src/mcp-server.js +3 -3
  40. package/src/policy/doctor.js +2 -2
  41. package/src/policy/engine.js +0 -1
  42. package/src/policy/init.js +1 -1
  43. package/src/policy/loader.js +3 -3
  44. package/src/policy/show.js +1 -2
  45. package/src/preflight/cli.js +0 -1
  46. package/src/preflight/miner.js +3 -6
  47. package/src/redteam.js +1 -2
  48. package/src/replay.js +1 -1
  49. package/src/report/index.js +0 -4
  50. package/src/runtime.js +42 -444
  51. package/src/selftest.js +1 -1
  52. package/src/session.js +1 -1
@@ -0,0 +1,102 @@
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
+ // Maturity tags follow the bewertung pillars:
6
+ // (stable) — load-bearing, has test coverage and field validation
7
+ // (beta) — works end-to-end but missing breadth (one detector, one preset)
8
+ // (alpha) — scaffold; needs operator calibration before relying on it
9
+
10
+ 'use strict';
11
+
12
+ const VERSION = (() => {
13
+ try { return require('../../package.json').version; }
14
+ catch { return '0.0.0-unknown'; }
15
+ })();
16
+
17
+ const col = {
18
+ r: s => `\x1b[31m${s}\x1b[0m`, g: s => `\x1b[32m${s}\x1b[0m`,
19
+ y: s => `\x1b[33m${s}\x1b[0m`, c: s => `\x1b[36m${s}\x1b[0m`,
20
+ d: s => `\x1b[2m${s}\x1b[0m`, b: s => `\x1b[1m${s}\x1b[0m`,
21
+ };
22
+
23
+ function run() {
24
+ console.log(`
25
+ ${col.b(`⚡ Occasio v${VERSION}`)}
26
+
27
+ ${col.b('60-Second Start:')}
28
+ ${col.c('occasio init')} Create policy.yml from a template
29
+ ${col.c('occasio register')} Install shell alias so 'claude' uses the proxy
30
+ ${col.c('claude --version')} Confirm the wrapper resolves Claude Code
31
+
32
+ ${col.b('Usage:')} occasio <command> [args...] (or oc <command>)
33
+
34
+ ${col.b('Setup')} ${col.d('— one-time, per project')}
35
+ init ${col.d('(stable)')} Create starter policy.yml (--template strict|finance)
36
+ register ${col.d('(stable)')} Register shell alias (type 'claude' directly)
37
+ doctor ${col.d('(stable)')} Check setup: Node, claude CLI, port, Python, profile
38
+
39
+ ${col.b('Run')} ${col.d('— start a session, observe live state')}
40
+ claude [args...] ${col.d('(stable)')} Start Claude with local proxy (intercept + log)
41
+ status ${col.d('(stable)')} Session stats, savings breakdown, coverage
42
+ clear ${col.d('(stable)')} Reset today's log and session data
43
+ clear --history ${col.d('(stable)')} Wipe all historical logs
44
+ ledger ${col.d('(stable)')} Inspect token ledger (--last N, --summary, --scope)
45
+ dashboard ${col.d('(beta)')} Open live dashboard at http://localhost:3001
46
+
47
+ ${col.b('Inspect')} ${col.d('— forensics over what the agent did')}
48
+ replay ${col.d('(stable)')} Replay run audit (--last N, --detail, --run <id>)
49
+ boundary ${col.d('(stable)')} Per-request: produced / re-entered / prevented
50
+ inspect ${col.d('(stable)')} Cloud-boundary manifest (--last N, --entry N)
51
+ distill ${col.d('(stable)')} Inspect distilled outputs (--last N, --entry <N>)
52
+ report ${col.d('(stable)')} Governance export (--format csv for SIEM)
53
+ preflight ${col.d('(beta)')} Read-only miner over past logs
54
+ baseline ${col.d('(beta)')} Behavior baseline: [learn|show|compare|reset]
55
+
56
+ ${col.b('Audit')} ${col.d('— tamper-evidence and attestation')}
57
+ audit verify ${col.d('(stable)')} Verify hash chain in pipeline-events.jsonl
58
+ audit repair ${col.d('(stable)')} Truncate crash-partial trailing line (--file --dry-run)
59
+ attest --run-id <uuid> ${col.d('(stable)')} Behavioral attestation: hash-chain + execution summary
60
+ ${col.d('Add --sign in GitHub Actions for Sigstore keyless signing')}
61
+ attest verify <file> ${col.d('(stable)')} Re-verify signed attestation (bundle + DSSE + chain)
62
+ selftest ${col.d('(stable)')} Run governance self-checks on scratch chain
63
+
64
+ ${col.b('Detect')} ${col.d('— anomalies, adversarial probes')}
65
+ anomalies ${col.d('(beta)')} Windowed EDR over the audit chain (--window 15m --json)
66
+ harness ${col.d('(alpha)')} Real Claude Code run vs. governance claims (API key required)
67
+ redteam ${col.d('(alpha)')} Autonomous adversarial probe (API key + SDK required)
68
+
69
+ ${col.b('Policy & extras')}
70
+ policy [show] ${col.d('(stable)')} Show active policy: flags, routing, overrides
71
+ policy show --diff ${col.d('(stable)')} Only values that differ from defaults
72
+ policy validate ${col.d('(stable)')} Validate policy.yml and report errors/warnings
73
+ policy doctor ${col.d('(beta)')} Cross-reference logs with policy; suggest tightening
74
+ computer-use ${col.d('(alpha)')} Apply policy to a JSONL of tool_use blocks (--dry-run --example)
75
+ mcp-experiment ${col.d('(beta)')} MCP vs. built-in tool adoption stats
76
+ demo ${col.d('(stable)')} 10-second proof: see Occasio block real secrets
77
+ demo attest ${col.d('(stable)')} End-to-end attestation pipeline against a synthetic chain
78
+ demo anomalies ${col.d('(stable)')} End-to-end EDR test: synthetic adversarial chain
79
+
80
+ ${col.b('Presets:')}
81
+ --preset balanced (default) Intercept safe reads locally, log all requests
82
+ --preset strict Block requests that contain detected secrets
83
+ --preset off Log only — no interception, no blocking
84
+
85
+ ${col.b('Flags:')}
86
+ --budget <N> Block requests once session cost exceeds $N (e.g. --budget 1.00)
87
+ --hardened Route Read/Glob/Grep through unified runtime (distill + secret scan)
88
+ --block-secrets Alias for --preset strict
89
+ --log-only Alias for --preset off
90
+ --dashboard Open live dashboard at http://localhost:3001
91
+ --port <N> Proxy port (default: 8081)
92
+ --verbose Print live per-request chatter (off by default)
93
+
94
+ ${col.b('Multi-agent routing:')}
95
+ Default → Claude Code adapter
96
+ Header x-occasio-agent: cline → Cline adapter (synthetic; live validation pending)
97
+
98
+ ${col.b('Logs:')} ~/.occasio/logs/YYYY-MM-DD.jsonl
99
+ `);
100
+ }
101
+
102
+ 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 { /* ignore */ }
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
+ };
package/src/dashboard.js CHANGED
@@ -16,7 +16,6 @@ const path = require('path');
16
16
  const os = require('os');
17
17
 
18
18
  const DASHBOARD_PORT = 3001;
19
- const PROXY_PORT = 8081;
20
19
  const LOG_DIR = path.join(os.homedir(), '.occasio');
21
20
  const SESSION_FILE = path.join(LOG_DIR, 'session.json');
22
21
 
@@ -97,8 +96,8 @@ const server = http.createServer((req, res) => {
97
96
  }
98
97
 
99
98
  if (req.url === '/api/clear' && req.method === 'POST') {
100
- try { fs.writeFileSync(todayLogFile(), ''); } catch {}
101
- try { fs.writeFileSync(SESSION_FILE, '{}'); } catch {}
99
+ try { fs.writeFileSync(todayLogFile(), ''); } catch { /* ignore */ }
100
+ try { fs.writeFileSync(SESSION_FILE, '{}'); } catch { /* ignore */ }
102
101
  res.writeHead(200, { 'Content-Type': 'application/json' });
103
102
  res.end('{"ok":true}');
104
103
  broadcast({ type: 'update', session: {}, entries: [] });
package/src/distiller.js CHANGED
@@ -120,7 +120,7 @@ const FAIL_RE = /\b(FAIL|FAILED|ERROR|error:|✗|×|AssertionError|not ok|ERRORE
120
120
  * Keeps all failure-related lines (plus 1 line of context each side) and the
121
121
  * last 15 lines (usually the summary). Clips total to TEST_MAX_LINES.
122
122
  */
123
- function distillTestOutput(output, rawBytes, cmd) {
123
+ function distillTestOutput(output, rawBytes, _cmd) {
124
124
  const lines = output.split('\n');
125
125
  const none = { content: output, distilled: false, savedTokens: 0, label: '', rawBytes, rawContent: null };
126
126
  if (lines.length <= TEST_MAX_LINES) return none;
@@ -44,7 +44,7 @@ const NATIVE_HANDLERS = {
44
44
  // but nativeHandle returned null, fall back to the exec subprocess. The
45
45
  // returned `native` field tells the caller which path was taken.
46
46
  [CANONICAL.SHELL_BASH]: async (input) => {
47
- const cmd = (input?.command || '').trim();
47
+ const cmd = (typeof input?.command === 'string' ? input.command : '').trim();
48
48
  if (!cmd) return null;
49
49
  const nr = nativeHandle(cmd);
50
50
  if (nr !== null) {
@@ -63,7 +63,7 @@ const NATIVE_HANDLERS = {
63
63
  // then native-only execution. expandedCmd is returned so the caller can
64
64
  // record the actually-executed command in toolsRun.
65
65
  [CANONICAL.SHELL_POWERSHELL]: (input) => {
66
- const rawCmd = (input?.command || '').trim();
66
+ const rawCmd = (typeof input?.command === 'string' ? input.command : '').trim();
67
67
  if (!rawCmd) return null;
68
68
  const cmd = expandPsEnvVars(rawCmd);
69
69
  const nr = nativeHandle(cmd);
@@ -0,0 +1,173 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Native handler for the Glob tool.
5
+ *
6
+ * Pure filesystem function: takes a glob pattern (+ optional base path) and
7
+ * returns a sorted list of matching paths. No dependency on the interceptor
8
+ * pipeline, Anthropic API, or shell execution. Safe to import in any process
9
+ * context.
10
+ *
11
+ * Extracted from src/runtime.js as Stage-2 Step 3 of the executor migration
12
+ * (see docs/ADAPTER-STAGE-2-MIGRATION.md). src/runtime.js re-exports these
13
+ * so existing consumers keep working unchanged.
14
+ */
15
+
16
+ const fs = require('fs');
17
+ const path = require('path');
18
+
19
+ // ── Glob tool support ──────────────────────────────────────────────────────────
20
+
21
+ // Characters that indicate shell injection in a glob pattern.
22
+ // We reject patterns containing these so handleGlobTool stays read-only.
23
+ const GLOB_INJECTION_RE = /[;&|`$<>!]/;
24
+
25
+ // Directories skipped during recursive glob walks.
26
+ const GLOB_SKIP = new Set(['node_modules', '.git', '.hg', '.svn', 'dist', 'build', '__pycache__', '.venv', 'venv']);
27
+
28
+ // Maximum number of matches returned to avoid overwhelming the model context.
29
+ const GLOB_MAX = 500;
30
+
31
+ // Maximum recursion depth from baseDir. Hard cap on path-traversal DoS
32
+ // (a fuzz-discovered class — see THREAT-MODEL.md residual risk #5).
33
+ // Tunable via env for special-case repos.
34
+ const GLOB_MAX_DEPTH = Number(process.env.OCCASIO_GLOB_MAX_DEPTH) || 16;
35
+
36
+ // Soft wall-clock limit per walk in ms. Stops a walk that strayed onto a huge
37
+ // subtree (e.g. agent globbed up from /) before it burns seconds. Stop is
38
+ // best-effort — the caller still receives whatever was collected so far.
39
+ const GLOB_MAX_MS = Number(process.env.OCCASIO_GLOB_MAX_MS) || 2_000;
40
+
41
+ function isGlobHandleable(input) {
42
+ if (!input || typeof input !== 'object') return false;
43
+ const pattern = input.pattern;
44
+ if (!pattern || typeof pattern !== 'string' || !pattern.trim()) return false;
45
+ if (GLOB_INJECTION_RE.test(pattern)) return false;
46
+ if (input.path != null && typeof input.path !== 'string') return false;
47
+ return true;
48
+ }
49
+
50
+ // Escape regex metacharacters in a literal string segment.
51
+ function escapeRegexChars(s) {
52
+ return s.replace(/[.+^${}()|[\]\\]/g, '\\$&');
53
+ }
54
+
55
+ /**
56
+ * Convert a glob pattern to a RegExp.
57
+ * Supports: ** (any path depth), * (single segment), ? (single char),
58
+ * {ts,tsx} (alternation), [abc] (character classes).
59
+ * Exported for unit testing.
60
+ */
61
+ function globToRegex(pattern) {
62
+ // Normalise Windows separators in the pattern.
63
+ const p = pattern.replace(/\\/g, '/');
64
+
65
+ let re = '';
66
+ let i = 0;
67
+ while (i < p.length) {
68
+ // ** — match any path segments (including none), consuming the trailing /
69
+ if (p[i] === '*' && p[i + 1] === '*') {
70
+ re += '.*';
71
+ i += 2;
72
+ if (p[i] === '/') i++; // consume separator after **
73
+ continue;
74
+ }
75
+ // * — match within a single path segment
76
+ if (p[i] === '*') { re += '[^/]*'; i++; continue; }
77
+ // ? — match a single character within a segment
78
+ if (p[i] === '?') { re += '[^/]'; i++; continue; }
79
+ // {a,b,c} — alternation
80
+ if (p[i] === '{') {
81
+ const end = p.indexOf('}', i);
82
+ if (end !== -1) {
83
+ const alts = p.slice(i + 1, end).split(',').map(escapeRegexChars);
84
+ re += `(?:${alts.join('|')})`;
85
+ i = end + 1;
86
+ continue;
87
+ }
88
+ }
89
+ // [abc] / [^abc] — pass character classes through verbatim
90
+ if (p[i] === '[') {
91
+ const end = p.indexOf(']', i);
92
+ if (end !== -1) { re += p.slice(i, end + 1); i = end + 1; continue; }
93
+ }
94
+ re += escapeRegexChars(p[i]);
95
+ i++;
96
+ }
97
+
98
+ // On Windows, matching is case-insensitive; on POSIX it's case-sensitive.
99
+ const flags = process.platform === 'win32' ? 'i' : '';
100
+ return new RegExp(`^${re}$`, flags);
101
+ }
102
+
103
+ /**
104
+ * Walk `dir` recursively, collecting paths that match `regex`.
105
+ * Results are relative to `baseDir`.
106
+ */
107
+ function walkGlob(dir, baseDir, regex, results, depth = 0, deadline = Infinity) {
108
+ if (results.length >= GLOB_MAX) return;
109
+ if (depth >= GLOB_MAX_DEPTH) return;
110
+ if (Date.now() >= deadline) return;
111
+ let entries;
112
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
113
+ catch { return; }
114
+
115
+ for (const entry of entries) {
116
+ if (results.length >= GLOB_MAX) break;
117
+ if (Date.now() >= deadline) break;
118
+ if (GLOB_SKIP.has(entry.name)) continue;
119
+ const abs = path.join(dir, entry.name);
120
+ // Normalise to forward slashes for matching (consistent on all platforms).
121
+ const rel = path.relative(baseDir, abs).replace(/\\/g, '/');
122
+ if (entry.isDirectory()) {
123
+ walkGlob(abs, baseDir, regex, results, depth + 1, deadline);
124
+ } else if (regex.test(rel)) {
125
+ results.push(rel);
126
+ }
127
+ }
128
+ }
129
+
130
+ /**
131
+ * Resolve glob pattern + optional base path to a sorted list of matching paths,
132
+ * relative to CWD. Returns { output, exitCode, matchCount }.
133
+ */
134
+ function handleGlobTool(input) {
135
+ const pattern = (typeof input?.pattern === 'string' ? input.pattern : '').trim();
136
+ if (!pattern) return { output: '(no pattern provided)', exitCode: 1, matchCount: 0 };
137
+
138
+ const baseDir = input?.path
139
+ ? path.resolve(process.cwd(), input.path)
140
+ : process.cwd();
141
+
142
+ const cwd = process.cwd();
143
+
144
+ let regex;
145
+ try { regex = globToRegex(pattern); }
146
+ catch (e) { return { output: `Glob: invalid pattern: ${e.message}`, exitCode: 1, matchCount: 0 }; }
147
+
148
+ const results = [];
149
+ const deadline = Date.now() + GLOB_MAX_MS;
150
+ walkGlob(baseDir, baseDir, regex, results, 0, deadline);
151
+ const timedOut = Date.now() >= deadline;
152
+ results.sort();
153
+
154
+ const truncated = results.length >= GLOB_MAX;
155
+ const lines = results.map(r => path.join(baseDir !== cwd ? baseDir : '', r).replace(/\\/g, '/'));
156
+ const suffix = truncated ? `\n(truncated at ${GLOB_MAX} results)`
157
+ : timedOut ? `\n(truncated — walk exceeded ${GLOB_MAX_MS} ms)`
158
+ : '';
159
+ const output = lines.join('\n') + suffix;
160
+ return { output: output || '(no matches)', exitCode: 0, matchCount: results.length };
161
+ }
162
+
163
+ module.exports = {
164
+ GLOB_INJECTION_RE,
165
+ GLOB_SKIP,
166
+ GLOB_MAX,
167
+ GLOB_MAX_DEPTH,
168
+ GLOB_MAX_MS,
169
+ isGlobHandleable,
170
+ globToRegex,
171
+ walkGlob,
172
+ handleGlobTool,
173
+ };