@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.
- package/LICENSE +202 -0
- package/NOTICE +10 -0
- package/README.md +216 -0
- package/bin/occasio-mcp.js +5 -0
- package/bin/occasio.js +2 -0
- package/bin/supervisor/README.md +90 -0
- package/bin/supervisor/com.occasio.proxy.plist.template +36 -0
- package/bin/supervisor/install-windows-task.ps1 +48 -0
- package/bin/supervisor/occasio.service +18 -0
- package/docs/AUDIT.md +120 -0
- package/docs/attest_verify.py +283 -0
- package/docs/audit_walker.py +65 -0
- package/docs/canonicalize.py +99 -0
- package/docs/compliance-mapping.md +93 -0
- package/docs/demos/mcp-block.md +148 -0
- package/docs/edr-calibration.md +73 -0
- package/docs/edr-demo.md +83 -0
- package/docs/python-verifier.md +74 -0
- package/docs/reference-pipeline.md +140 -0
- package/package.json +69 -0
- package/policy-templates/dev-default.yml +84 -0
- package/policy-templates/finance.yml +61 -0
- package/policy-templates/strict.yml +49 -0
- package/schemas/agent-attestation-v1.json +190 -0
- package/schemas/occasio-policy.schema.json +99 -0
- package/spec/agent-attestation/v1/README.md +137 -0
- package/src/adapters/claude-code.js +518 -0
- package/src/adapters/cline.js +161 -0
- package/src/adapters/computer-use-cli.js +198 -0
- package/src/adapters/computer-use.js +227 -0
- package/src/analyzer.js +170 -0
- package/src/anomaly/cli.js +143 -0
- package/src/anomaly/detectors/deny-rate.js +84 -0
- package/src/anomaly/detectors/file-read-volume.js +109 -0
- package/src/anomaly/detectors/secret-redact-rate.js +107 -0
- package/src/anomaly/detectors/unknown-tool-input.js +83 -0
- package/src/anomaly/index.js +169 -0
- package/src/attest/canonicalize.js +97 -0
- package/src/attest/index.js +355 -0
- package/src/attest/run-slice.js +57 -0
- package/src/attest/sign.js +186 -0
- package/src/attest/verify.js +192 -0
- package/src/audit/errors.js +21 -0
- package/src/audit/input-normalizer.js +121 -0
- package/src/audit/jsonl-auditor.js +178 -0
- package/src/audit/verifier.js +152 -0
- package/src/baseline.js +507 -0
- package/src/boundary.js +238 -0
- package/src/budget.js +42 -0
- package/src/classifier.js +115 -0
- package/src/context-budget.js +77 -0
- package/src/core/boundary-event.js +75 -0
- package/src/core/decision.js +61 -0
- package/src/core/pipeline.js +66 -0
- package/src/core/tool-names.js +105 -0
- package/src/dashboard.js +892 -0
- package/src/demo/README.md +31 -0
- package/src/demo/anomalies-demo.js +211 -0
- package/src/demo/attest-demo.js +198 -0
- package/src/distiller.js +155 -0
- package/src/embeddings.json +72 -0
- package/src/executor/dispatcher.js +230 -0
- package/src/harness.js +817 -0
- package/src/index.js +1711 -0
- package/src/inspect.js +329 -0
- package/src/interceptor.js +1198 -0
- package/src/lao.js +185 -0
- package/src/lao_prep.py +119 -0
- package/src/ledger.js +209 -0
- package/src/mcp-experiment.js +140 -0
- package/src/mcp-normalize.js +139 -0
- package/src/mcp-server.js +320 -0
- package/src/outbound-policy.js +433 -0
- package/src/policy/built-in-classifiers.js +78 -0
- package/src/policy/doctor.js +226 -0
- package/src/policy/engine.js +339 -0
- package/src/policy/init.js +153 -0
- package/src/policy/loader.js +448 -0
- package/src/policy/rules-default.js +36 -0
- package/src/policy/shell-path.js +135 -0
- package/src/policy/show.js +196 -0
- package/src/policy/validate.js +310 -0
- package/src/preflight/cli.js +164 -0
- package/src/preflight/miner.js +329 -0
- package/src/proxy/agent-router.js +93 -0
- package/src/redteam.js +428 -0
- package/src/replay.js +446 -0
- package/src/report/index.js +224 -0
- package/src/runtime.js +595 -0
- package/src/scanner/index.js +49 -0
- package/src/selftest.js +192 -0
- package/src/session.js +36 -0
package/src/index.js
ADDED
|
@@ -0,0 +1,1711 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
const { spawn } = require('child_process');
|
|
3
|
+
const http = require('http');
|
|
4
|
+
const https = require('https');
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const path = require('path');
|
|
7
|
+
const os = require('os');
|
|
8
|
+
// Phase E: the proxy calls the adapter's runToolLoop directly. The
|
|
9
|
+
// interceptToolUse shim is gone โ this is the canonical entry point for an
|
|
10
|
+
// Anthropic conversation that contains tool_use turns.
|
|
11
|
+
//
|
|
12
|
+
// Stage 3: multi-agent routing. The proxy supports multiple AI agents via
|
|
13
|
+
// per-request adapter selection. Detection signal: the
|
|
14
|
+
// `x-occasio-agent` header. Default (header absent or unrecognized)
|
|
15
|
+
// preserves Claude Code behavior exactly. Every adapter is loaded at startup
|
|
16
|
+
// so its tool-name registration runs before any traffic arrives.
|
|
17
|
+
const claudeCodeAdapter = require('./adapters/claude-code');
|
|
18
|
+
const clineAdapter = require('./adapters/cline');
|
|
19
|
+
const toolNamesRegistry = require('./core/tool-names');
|
|
20
|
+
const { selectAdapter: routeAgent, HEADER_NAME: AGENT_HEADER } = require('./proxy/agent-router');
|
|
21
|
+
|
|
22
|
+
const AGENT_ADAPTERS = Object.freeze({
|
|
23
|
+
'claude-code': claudeCodeAdapter,
|
|
24
|
+
'cline': clineAdapter,
|
|
25
|
+
});
|
|
26
|
+
const DEFAULT_AGENT = 'claude-code';
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Per-request adapter selection. Two signals, in order:
|
|
30
|
+
* 1. `x-occasio-agent` HTTP header (explicit)
|
|
31
|
+
* 2. content fingerprint of the SSE response (implicit fallback)
|
|
32
|
+
* Fingerprint is critical because some agents (notably Cline in some
|
|
33
|
+
* release versions) don't expose a custom-headers UI, so the explicit
|
|
34
|
+
* header isn't always reliable.
|
|
35
|
+
*/
|
|
36
|
+
function selectAdapter(headers, sseBody) {
|
|
37
|
+
return routeAgent(headers, AGENT_ADAPTERS, DEFAULT_AGENT, {
|
|
38
|
+
sseBody,
|
|
39
|
+
registry: toolNamesRegistry,
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
const { parseFileTokens, scanSecrets } = require('./analyzer');
|
|
43
|
+
const { optimizeContext } = require('./lao');
|
|
44
|
+
const { randomUUID } = require('crypto');
|
|
45
|
+
const { runLedgerCli } = require('./ledger');
|
|
46
|
+
const { runReplayCli } = require('./replay');
|
|
47
|
+
const { runInspectCli } = require('./inspect');
|
|
48
|
+
const { runAuditCli } = require('./audit/verifier');
|
|
49
|
+
const { budgetStatus, fmtBudget, BUDGET_EXCEEDED_EVENT } = require('./budget');
|
|
50
|
+
|
|
51
|
+
const VERSION = '0.8.0';
|
|
52
|
+
const LOG_SCHEMA_VERSION = 2;
|
|
53
|
+
// Port override via env var (used by `occasio harness` and redteam to
|
|
54
|
+
// run isolated proxies against scratch audit chains on free ports). Default
|
|
55
|
+
// is 8081 to preserve existing user-facing behaviour.
|
|
56
|
+
let PORT = parseInt(process.env.LOCALFIRST_PORT, 10) || 8081;
|
|
57
|
+
const ANTHROPIC_REAL = 'api.anthropic.com';
|
|
58
|
+
const LOG_DIR = path.join(os.homedir(), '.occasio');
|
|
59
|
+
const SESSION_FILE = path.join(LOG_DIR, 'session.json');
|
|
60
|
+
// Captured once at process start; stable for the entire session.
|
|
61
|
+
// Used by the preflight miner to group sessions by project root.
|
|
62
|
+
const SESSION_CWD = process.cwd();
|
|
63
|
+
|
|
64
|
+
function todayStr() { const d = new Date(); return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`; }
|
|
65
|
+
function getLogFile() { return path.join(LOG_DIR, 'logs', `${todayStr()}.jsonl`); }
|
|
66
|
+
function getBlockedFile() { return path.join(LOG_DIR, 'blocked', `${todayStr()}-secrets.log`); }
|
|
67
|
+
|
|
68
|
+
const MODEL_PRICES = {
|
|
69
|
+
'claude-opus-4-6': { in: 15.00, out: 75.00, cache_write: 18.75, cache_read: 1.50 },
|
|
70
|
+
'claude-opus-4': { in: 15.00, out: 75.00, cache_write: 18.75, cache_read: 1.50 },
|
|
71
|
+
'claude-sonnet-4-6': { in: 3.00, out: 15.00, cache_write: 3.75, cache_read: 0.30 },
|
|
72
|
+
'claude-sonnet-4': { in: 3.00, out: 15.00, cache_write: 3.75, cache_read: 0.30 },
|
|
73
|
+
'claude-haiku-4-5': { in: 0.25, out: 1.25, cache_write: 0.30, cache_read: 0.03 },
|
|
74
|
+
'claude-haiku-4': { in: 0.25, out: 1.25, cache_write: 0.30, cache_read: 0.03 },
|
|
75
|
+
'default': { in: 3.00, out: 15.00, cache_write: 3.75, cache_read: 0.30 },
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
function getPrice(model) {
|
|
79
|
+
if (!model) return MODEL_PRICES.default;
|
|
80
|
+
for (const [k, v] of Object.entries(MODEL_PRICES)) {
|
|
81
|
+
if (k !== 'default' && model.includes(k)) return v;
|
|
82
|
+
}
|
|
83
|
+
return MODEL_PRICES.default;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function calcCost(model, inp, out, cacheWrite = 0, cacheRead = 0) {
|
|
87
|
+
const p = getPrice(model);
|
|
88
|
+
return (inp / 1e6 * p.in) + (out / 1e6 * p.out)
|
|
89
|
+
+ (cacheWrite / 1e6 * p.cache_write) + (cacheRead / 1e6 * p.cache_read);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Savings from Anthropic prompt caching: cache reads are 10ร cheaper than fresh input.
|
|
93
|
+
function calcCacheSavings(model, cacheReadTokens) {
|
|
94
|
+
if (!cacheReadTokens) return 0;
|
|
95
|
+
const p = getPrice(model);
|
|
96
|
+
return (cacheReadTokens / 1e6) * (p.in - p.cache_read);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Cross-request compounding savings: reads the run's JSONL entries in sequence order
|
|
100
|
+
// and weights each distilled batch by the exact number of subsequent API calls that
|
|
101
|
+
// carry the smaller result in their conversation history.
|
|
102
|
+
// Formula: ฮฃ (distill_tokens_saved[i] / 1M ร price_in ร (N - i - 1))
|
|
103
|
+
// Returns { savings: float, carryInstances: int }
|
|
104
|
+
// carryInstances = total sum of subsequent-call counts across all distilled batches
|
|
105
|
+
// โ the actual multiplier used โ so the display is self-explanatory.
|
|
106
|
+
// Assumption: tool results accumulate in Claude Code's message history for all
|
|
107
|
+
// subsequent requests in the session (true for normal sessions; may not hold if
|
|
108
|
+
// Claude Code resets context mid-session).
|
|
109
|
+
function calcCompoundingSavings(runId, logFile, model) {
|
|
110
|
+
if (!runId) return { savings: 0, carryInstances: 0 };
|
|
111
|
+
let entries;
|
|
112
|
+
try {
|
|
113
|
+
const lines = fs.readFileSync(logFile, 'utf8').trim().split('\n').filter(Boolean);
|
|
114
|
+
entries = lines
|
|
115
|
+
.map(l => { try { return JSON.parse(l); } catch { return null; } })
|
|
116
|
+
.filter(e => e && e.run_id === runId);
|
|
117
|
+
} catch { return { savings: 0, carryInstances: 0 }; }
|
|
118
|
+
const N = entries.length;
|
|
119
|
+
if (N < 2) return { savings: 0, carryInstances: 0 };
|
|
120
|
+
const p = getPrice(model);
|
|
121
|
+
let savings = 0, carryInstances = 0;
|
|
122
|
+
for (let i = 0; i < N; i++) {
|
|
123
|
+
const dt = entries[i].distill_tokens_saved || 0;
|
|
124
|
+
if (dt > 0) {
|
|
125
|
+
const subsequent = N - i - 1;
|
|
126
|
+
savings += (dt / 1e6) * p.in * subsequent;
|
|
127
|
+
carryInstances += subsequent;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return { savings, carryInstances };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// โโ Persistence โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
134
|
+
|
|
135
|
+
function ensureDirs() {
|
|
136
|
+
for (const sub of ['', 'logs', 'reports', 'blocked', 'distilled']) {
|
|
137
|
+
const d = path.join(LOG_DIR, sub);
|
|
138
|
+
if (!fs.existsSync(d)) fs.mkdirSync(d, { recursive: true });
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
function getDistilledFile() { return path.join(LOG_DIR, 'distilled', `${todayStr()}.jsonl`); }
|
|
142
|
+
function writeLog(e) { ensureDirs(); fs.appendFileSync(getLogFile(), JSON.stringify(e) + '\n'); }
|
|
143
|
+
function writeBlocked(e) { ensureDirs(); fs.appendFileSync(getBlockedFile(), JSON.stringify(e) + '\n'); }
|
|
144
|
+
function writeDistilledRaw(e) { ensureDirs(); fs.appendFileSync(getDistilledFile(), JSON.stringify(e) + '\n'); }
|
|
145
|
+
function updateSession(e) {
|
|
146
|
+
ensureDirs();
|
|
147
|
+
let s = {
|
|
148
|
+
requests:0, input_tokens:0, output_tokens:0, cost:0, blocked:0, intercepted_count:0,
|
|
149
|
+
cache_read_tokens:0, cache_write_tokens:0, cache_savings:0,
|
|
150
|
+
lao_tokens_saved:0, lao_cost_saved:0, tools_local_count:0, tools_mcp_count:0,
|
|
151
|
+
tools_attempted:0,
|
|
152
|
+
};
|
|
153
|
+
try { s = JSON.parse(fs.readFileSync(SESSION_FILE, 'utf8')); } catch {}
|
|
154
|
+
s.requests++;
|
|
155
|
+
s.input_tokens += e.input_tokens || 0;
|
|
156
|
+
s.output_tokens += e.output_tokens || 0;
|
|
157
|
+
s.cost += e.cost || 0;
|
|
158
|
+
s.cache_read_tokens += e.cache_read_tokens || 0;
|
|
159
|
+
s.cache_write_tokens += e.cache_write_tokens || 0;
|
|
160
|
+
s.cache_savings += e.cache_savings || 0;
|
|
161
|
+
s.lao_tokens_saved += e.lao_tokens_saved || 0;
|
|
162
|
+
s.lao_cost_saved += e.lao_cost_saved || 0;
|
|
163
|
+
s.tools_local_count += e.tools_local_count || 0;
|
|
164
|
+
s.tools_mcp_count += e.tools_mcp_count || 0;
|
|
165
|
+
s.distill_tokens_saved += e.distill_tokens_saved || 0;
|
|
166
|
+
s.distill_cost_saved += e.distill_cost_saved || 0;
|
|
167
|
+
s.tools_attempted += e.tools_attempted || 0;
|
|
168
|
+
s.secrets_redacted += e.secrets_redacted || 0;
|
|
169
|
+
s.tools_transformed += e.tools_transformed || 0;
|
|
170
|
+
if (e.model && !s.model) s.model = e.model;
|
|
171
|
+
if (e.intercepted) s.intercepted_count++;
|
|
172
|
+
if (e.event_type === 'blocked' || e.blocked?.length) s.blocked++;
|
|
173
|
+
fs.writeFileSync(SESSION_FILE, JSON.stringify(s));
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// โโ Terminal colors โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
177
|
+
|
|
178
|
+
const col = {
|
|
179
|
+
r: s => `\x1b[31m${s}\x1b[0m`, g: s => `\x1b[32m${s}\x1b[0m`,
|
|
180
|
+
y: s => `\x1b[33m${s}\x1b[0m`, c: s => `\x1b[36m${s}\x1b[0m`,
|
|
181
|
+
d: s => `\x1b[2m${s}\x1b[0m`, b: s => `\x1b[1m${s}\x1b[0m`,
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
// โโ Pre-send manifest โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
185
|
+
|
|
186
|
+
function fmtTok(n) {
|
|
187
|
+
return n >= 1000 ? `${(n / 1000).toFixed(1)}kt` : `${n}t`;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Rough token estimate from a parsed request body.
|
|
192
|
+
* Walks system prompt + all message content blocks and sums character counts / 4.
|
|
193
|
+
* Deliberately conservative: does not include JSON framing overhead so the
|
|
194
|
+
* estimate stays close to actual model-token counts (4 chars โ 1 token).
|
|
195
|
+
*/
|
|
196
|
+
function roughTokensFromBody(body) {
|
|
197
|
+
if (!body) return 0;
|
|
198
|
+
let chars = 0;
|
|
199
|
+
if (body.system) {
|
|
200
|
+
const s = typeof body.system === 'string' ? body.system
|
|
201
|
+
: Array.isArray(body.system) ? body.system.map(b => b.text || '').join('') : '';
|
|
202
|
+
chars += s.length;
|
|
203
|
+
}
|
|
204
|
+
for (const msg of body.messages || []) {
|
|
205
|
+
const c = msg.content;
|
|
206
|
+
if (typeof c === 'string') {
|
|
207
|
+
chars += c.length;
|
|
208
|
+
} else if (Array.isArray(c)) {
|
|
209
|
+
for (const b of c) {
|
|
210
|
+
if (typeof b === 'string') chars += b.length;
|
|
211
|
+
else if (typeof b.text === 'string') chars += b.text.length;
|
|
212
|
+
else if (typeof b.content === 'string') chars += b.content.length;
|
|
213
|
+
else if (Array.isArray(b.content)) {
|
|
214
|
+
for (const cb of b.content) chars += (typeof cb === 'string' ? cb : cb.text || '').length;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return Math.ceil(chars / 4);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Print a compact pre-send manifest to stderr just before the request leaves
|
|
224
|
+
* the machine. Called once per outbound /v1/messages call from the proxy.
|
|
225
|
+
*
|
|
226
|
+
* Format (all dim, โถ in cyan โ one line):
|
|
227
|
+
* HH:MM:SS โถ ~Xkt ยท N msgs ยท dir/file ~Yt ยท dir/file ~Zt +W more
|
|
228
|
+
*
|
|
229
|
+
* totalEst: rough payload size in tokens (marked ~, derived from content chars / 4)
|
|
230
|
+
* Token counts for files are estimates (marked ~). Message count is exact.
|
|
231
|
+
*/
|
|
232
|
+
function printPreSendManifest(fileTokens, outboundMsgCount, totalEst) {
|
|
233
|
+
if (!LIVE_VERBOSE) return; // quiet by default: avoid corrupting Claude Code's TUI
|
|
234
|
+
const ts = new Date().toTimeString().slice(0, 8);
|
|
235
|
+
const top = fileTokens.slice(0, 3);
|
|
236
|
+
const more = fileTokens.length > 3 ? col.d(` +${fileTokens.length - 3} more`) : '';
|
|
237
|
+
|
|
238
|
+
const fileStr = top.map(f => {
|
|
239
|
+
const segs = f.name.replace(/\\/g, '/').split('/');
|
|
240
|
+
const name = segs.length > 2 ? segs.slice(-2).join('/') : f.name;
|
|
241
|
+
return col.d(`${name} ~${fmtTok(f.tokens)}`);
|
|
242
|
+
}).join(col.d(' '));
|
|
243
|
+
|
|
244
|
+
const totalPart = totalEst > 0 ? col.d(`~${fmtTok(totalEst)}`) : '';
|
|
245
|
+
const msgPart = outboundMsgCount > 0 ? col.d(`${outboundMsgCount} msgs`) : '';
|
|
246
|
+
const filePart = fileStr + more;
|
|
247
|
+
|
|
248
|
+
const parts = [totalPart, msgPart, filePart].filter(Boolean);
|
|
249
|
+
const body = parts.join(col.d(' ยท ')) || col.d('(no context)');
|
|
250
|
+
|
|
251
|
+
process.stderr.write(`${col.d(ts)} ${col.c('โถ')} ${body}\n`);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// โโ Distill CLI โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
255
|
+
|
|
256
|
+
function runDistillCli(cliArgs) {
|
|
257
|
+
const file = getDistilledFile();
|
|
258
|
+
if (!fs.existsSync(file)) {
|
|
259
|
+
console.log('No distilled output today.');
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
const entries = fs.readFileSync(file, 'utf8').trim().split('\n')
|
|
263
|
+
.filter(Boolean)
|
|
264
|
+
.map(l => { try { return JSON.parse(l); } catch { return null; } })
|
|
265
|
+
.filter(Boolean);
|
|
266
|
+
|
|
267
|
+
if (entries.length === 0) {
|
|
268
|
+
console.log('No distilled output today.');
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const lastFlagIdx = cliArgs.indexOf('--last');
|
|
273
|
+
const lastN = lastFlagIdx !== -1 ? (parseInt(cliArgs[lastFlagIdx + 1]) || 10) : 10;
|
|
274
|
+
const entryFlagIdx = cliArgs.indexOf('--entry');
|
|
275
|
+
const entryIdx = entryFlagIdx !== -1 ? parseInt(cliArgs[entryFlagIdx + 1]) : null;
|
|
276
|
+
|
|
277
|
+
// Show single entry's raw content
|
|
278
|
+
if (entryIdx != null) {
|
|
279
|
+
const e = entries[entryIdx];
|
|
280
|
+
if (!e) { console.log(`No entry at index ${entryIdx} (${entries.length} total today).`); return; }
|
|
281
|
+
console.log(`\n${col.b(`Entry [${entryIdx}]`)} ${col.d(e.cmd || '?')} ${col.d(`${(e.rawBytes || 0).toLocaleString()} bytes`)}\n`);
|
|
282
|
+
console.log(e.rawContent || '(no raw content saved)');
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// List view
|
|
287
|
+
const recent = entries.slice(-lastN);
|
|
288
|
+
const offset = entries.length - recent.length;
|
|
289
|
+
console.log(`\n${col.b('Distilled outputs')} ${col.d(`last ${recent.length} of ${entries.length} today`)}\n`);
|
|
290
|
+
recent.forEach((e, i) => {
|
|
291
|
+
const idx = offset + i;
|
|
292
|
+
const cmd_ = (e.cmd || '?').slice(0, 48).padEnd(48);
|
|
293
|
+
const kb = ((e.rawBytes || 0) / 1024).toFixed(1).padStart(6);
|
|
294
|
+
console.log(` [${String(idx).padStart(2)}] ${cmd_} ${col.d(`${kb} KB`)} ${col.d(e.label || '')}`);
|
|
295
|
+
});
|
|
296
|
+
console.log(`\n${col.d(' occasio distill --entry <N> show raw output for entry N')}`);
|
|
297
|
+
console.log(`${col.d(' occasio distill --last <N> show last N entries')}\n`);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// โโ CLI commands โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
301
|
+
|
|
302
|
+
const args = process.argv.slice(2);
|
|
303
|
+
const cmd = args[0];
|
|
304
|
+
|
|
305
|
+
if (cmd === '--version' || cmd === '-v') { console.log(`occasio v${VERSION}`); process.exit(0); }
|
|
306
|
+
|
|
307
|
+
if (cmd === 'help' || cmd === '--help' || cmd === '-h') {
|
|
308
|
+
console.log(`
|
|
309
|
+
${col.b(`โก Occasio v${VERSION}`)}
|
|
310
|
+
|
|
311
|
+
${col.b('Usage:')}
|
|
312
|
+
occasio claude [args...] Start Claude with local proxy (intercept + log)
|
|
313
|
+
occasio demo 10-second proof: see Occasio block real secrets
|
|
314
|
+
occasio demo attest End-to-end attestation pipeline against a synthetic audit chain
|
|
315
|
+
occasio demo anomalies End-to-end EDR test: synthetic adversarial chain โ all 4 detectors
|
|
316
|
+
occasio dashboard Open live dashboard for the running session
|
|
317
|
+
occasio register Register shell alias (type 'claude' directly)
|
|
318
|
+
occasio status Show session stats and savings breakdown
|
|
319
|
+
occasio doctor Check setup: Node, claude CLI, port, Python, profile
|
|
320
|
+
occasio clear Reset today's log and session data
|
|
321
|
+
occasio clear --history Wipe all historical logs
|
|
322
|
+
occasio ledger Inspect token ledger (--last N, --summary, --scope session|today)
|
|
323
|
+
occasio replay Replay run audit (--last N, --detail, --run <id>, --attribute)
|
|
324
|
+
occasio distill Inspect distilled outputs (--last N, --entry <N> for raw)
|
|
325
|
+
occasio inspect Cloud-boundary manifest (--last N, --entry N, --run <id>)
|
|
326
|
+
occasio boundary Per-request three-column view: produced / re-entered / prevented
|
|
327
|
+
occasio baseline Behavior baseline: [learn|show|compare|reset] (per project cwd)
|
|
328
|
+
occasio harness Run a real Claude Code session against scratch fixtures and verify governance claims (needs ANTHROPIC_API_KEY)
|
|
329
|
+
occasio redteam Autonomous adversarial test โ tester LLM probes a subject Claude Code session under Occasio (needs ANTHROPIC_API_KEY + @anthropic-ai/sdk)
|
|
330
|
+
occasio policy [show] Show active policy: flags, tool routing, overrides
|
|
331
|
+
occasio policy show --diff Only values that differ from defaults
|
|
332
|
+
occasio policy validate Validate policy.yml and report errors/warnings
|
|
333
|
+
occasio policy init Create a starter policy.yml (safe, non-destructive)
|
|
334
|
+
Use --template strict|finance for a non-default starter
|
|
335
|
+
occasio policy doctor Cross-reference session logs with policy; surface suggestions
|
|
336
|
+
occasio audit [verify] Verify tamper-evident hash chain in pipeline-events.jsonl
|
|
337
|
+
occasio report Governance export: file access log, blocked paths, secret events
|
|
338
|
+
occasio anomalies Live anomaly detection over the audit chain (--window 15m, --json)
|
|
339
|
+
occasio computer-use Apply a Computer-Use policy to a JSONL of tool_use blocks (--dry-run --example)
|
|
340
|
+
occasio attest --run-id <uuid> AI-Agent Behavioral Attestation v1: hash-chain commitment + execution summary for one run
|
|
341
|
+
Add --sign in GitHub Actions (with permissions: id-token: write) for Sigstore keyless signing
|
|
342
|
+
occasio attest verify <file> Re-verify a signed attestation: Sigstore bundle + DSSE payload match + audit chain integrity
|
|
343
|
+
occasio selftest Run governance self-checks on a scratch chain (does not touch your audit log)
|
|
344
|
+
occasio report --format csv CSV export for auditors / SIEM import
|
|
345
|
+
occasio mcp-experiment MCP vs. built-in tool adoption stats (experiment)
|
|
346
|
+
|
|
347
|
+
${col.b('Presets:')}
|
|
348
|
+
--preset balanced (default) Intercept safe reads locally, log all requests
|
|
349
|
+
--preset strict Block requests that contain detected secrets
|
|
350
|
+
--preset off Log only โ no interception, no blocking
|
|
351
|
+
|
|
352
|
+
${col.b('Flags:')}
|
|
353
|
+
--budget <N> Block requests once session cost exceeds $N (e.g. --budget 1.00)
|
|
354
|
+
--hardened Route Read/Glob/Grep through unified runtime (distill + secret scan)
|
|
355
|
+
--block-secrets Alias for --preset strict
|
|
356
|
+
--log-only Alias for --preset off
|
|
357
|
+
--dashboard Open live dashboard at http://localhost:3001
|
|
358
|
+
--port <N> Proxy port (default: 8081)
|
|
359
|
+
--verbose Print live per-request chatter (off by default โ quiet for Claude Code's TUI)
|
|
360
|
+
|
|
361
|
+
${col.b('Multi-agent routing:')}
|
|
362
|
+
Default โ Claude Code adapter
|
|
363
|
+
Header x-occasio-agent: cline โ Cline adapter (synthetic; live validation pending)
|
|
364
|
+
|
|
365
|
+
${col.b('Logs:')} ~/.occasio/logs/YYYY-MM-DD.jsonl
|
|
366
|
+
`);
|
|
367
|
+
process.exit(0);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
if (cmd === 'register') {
|
|
371
|
+
const shell = process.env.SHELL || '';
|
|
372
|
+
const isWindows = process.platform === 'win32';
|
|
373
|
+
|
|
374
|
+
if (isWindows) {
|
|
375
|
+
const profileDir = path.join(os.homedir(), 'Documents', 'PowerShell');
|
|
376
|
+
const profileFile = path.join(profileDir, 'Microsoft.PowerShell_profile.ps1');
|
|
377
|
+
const snippet = `\n# Occasio โ intercept Claude Code traffic\nfunction claude { occasio claude @args }\n`;
|
|
378
|
+
const alreadyMarker = 'occasio claude @args';
|
|
379
|
+
const legacyMarker = 'occasio --intercept @args';
|
|
380
|
+
try {
|
|
381
|
+
if (!fs.existsSync(profileDir)) fs.mkdirSync(profileDir, { recursive: true });
|
|
382
|
+
const existing = fs.existsSync(profileFile) ? fs.readFileSync(profileFile, 'utf8') : '';
|
|
383
|
+
if (existing.includes(alreadyMarker)) {
|
|
384
|
+
console.log(col.g('โ Already registered (PowerShell)'));
|
|
385
|
+
console.log(col.d(' Type: claude'));
|
|
386
|
+
} else if (existing.includes(legacyMarker)) {
|
|
387
|
+
// Upgrade old --intercept form to canonical `occasio claude`
|
|
388
|
+
const updated = existing.replace(
|
|
389
|
+
/function claude \{ occasio --intercept @args \}/g,
|
|
390
|
+
'function claude { occasio claude @args }'
|
|
391
|
+
);
|
|
392
|
+
fs.writeFileSync(profileFile, updated);
|
|
393
|
+
console.log(col.g('โ Updated to canonical form (occasio claude)'));
|
|
394
|
+
console.log('');
|
|
395
|
+
console.log(col.y(` โ Restart PowerShell โ the 'claude' alias is not active yet.`));
|
|
396
|
+
console.log(col.d(` Open a new terminal, or run: . $PROFILE`));
|
|
397
|
+
console.log('');
|
|
398
|
+
} else {
|
|
399
|
+
fs.appendFileSync(profileFile, snippet);
|
|
400
|
+
console.log(col.g(`โ Registered in ${profileFile}`));
|
|
401
|
+
console.log('');
|
|
402
|
+
console.log(col.y(` โ Restart PowerShell โ the 'claude' alias is not active yet.`));
|
|
403
|
+
console.log(col.d(` Open a new terminal, or run: . $PROFILE`));
|
|
404
|
+
console.log('');
|
|
405
|
+
}
|
|
406
|
+
} catch (e) {
|
|
407
|
+
console.log(col.r(`โ Could not write profile: ${e.message}`));
|
|
408
|
+
console.log(col.d(` Add manually to your PowerShell profile:\n function claude { occasio claude @args }`));
|
|
409
|
+
}
|
|
410
|
+
} else {
|
|
411
|
+
const rcFile = (process.env.SHELL || '').includes('zsh')
|
|
412
|
+
? path.join(os.homedir(), '.zshrc')
|
|
413
|
+
: path.join(os.homedir(), '.bashrc');
|
|
414
|
+
const snippet = `\n# Occasio โ intercept Claude Code traffic\nclaude() { occasio claude "$@"; }\n`;
|
|
415
|
+
const alreadyMarker = 'occasio claude "$@"';
|
|
416
|
+
const legacyMarker = 'occasio --intercept "$@"';
|
|
417
|
+
try {
|
|
418
|
+
const existing = fs.existsSync(rcFile) ? fs.readFileSync(rcFile, 'utf8') : '';
|
|
419
|
+
if (existing.includes(alreadyMarker)) {
|
|
420
|
+
console.log(col.g(`โ Already registered (${rcFile})`));
|
|
421
|
+
} else if (existing.includes(legacyMarker)) {
|
|
422
|
+
const updated = existing.replace(
|
|
423
|
+
/claude\(\) \{ occasio --intercept "\$@"; \}/g,
|
|
424
|
+
'claude() { occasio claude "$@"; }'
|
|
425
|
+
);
|
|
426
|
+
fs.writeFileSync(rcFile, updated);
|
|
427
|
+
console.log(col.g(`โ Updated to canonical form in ${rcFile}`));
|
|
428
|
+
} else {
|
|
429
|
+
fs.appendFileSync(rcFile, snippet);
|
|
430
|
+
console.log(col.g(`โ Registered in ${rcFile}`));
|
|
431
|
+
}
|
|
432
|
+
console.log(col.d(' Run: source ' + rcFile + ' โ then type: claude'));
|
|
433
|
+
} catch (e) {
|
|
434
|
+
console.log(col.r(`โ Could not write ${rcFile}: ${e.message}`));
|
|
435
|
+
console.log(col.d(` Add manually:\n claude() { occasio claude "$@"; }`));
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
process.exit(0);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
if (cmd === 'status' || cmd === 'stats') {
|
|
442
|
+
let s = null; try { s = JSON.parse(fs.readFileSync(SESSION_FILE, 'utf8')); } catch {}
|
|
443
|
+
console.log(col.b('\nโก Occasio\n'));
|
|
444
|
+
if (!s) { console.log(col.d(' No session data yet. Run: occasio claude\n')); process.exit(0); }
|
|
445
|
+
|
|
446
|
+
const cacheSav = s.cache_savings || 0;
|
|
447
|
+
const laoSav = s.lao_cost_saved || 0;
|
|
448
|
+
const distSav = s.distill_cost_saved || 0;
|
|
449
|
+
const payload = laoSav + distSav;
|
|
450
|
+
const { savings: context } =
|
|
451
|
+
calcCompoundingSavings(s.run_id, s.log_file || getLogFile(), s.model || '');
|
|
452
|
+
const totalSav = payload + context + cacheSav;
|
|
453
|
+
const broaderCf = (s.cost || 0) + totalSav;
|
|
454
|
+
const savedPct = broaderCf > 0.00001 ? Math.round(totalSav / broaderCf * 100) : 0;
|
|
455
|
+
|
|
456
|
+
// Headline
|
|
457
|
+
if (totalSav > 0.00001) {
|
|
458
|
+
console.log(col.g(` Saved: $${totalSav.toFixed(4)}`) +
|
|
459
|
+
col.d(` (${savedPct}% off โ would have cost $${broaderCf.toFixed(4)})`));
|
|
460
|
+
} else {
|
|
461
|
+
console.log(col.d(` Saved: $0.0000 (no interceptable tool calls in this session yet)`));
|
|
462
|
+
}
|
|
463
|
+
console.log(col.y(` Cost: $${s.cost.toFixed(4)}`));
|
|
464
|
+
|
|
465
|
+
// Plain-English coverage. Defensive: legacy sessions (pre-multi-round-fix)
|
|
466
|
+
// may have tools_attempted undercounted relative to tools_local_count.
|
|
467
|
+
// We clamp the denominator to at least the numerator so the displayed
|
|
468
|
+
// ratio is always 0โ100% and never reads "X of Y < X (>100%)".
|
|
469
|
+
const localCnt = s.tools_local_count || 0;
|
|
470
|
+
const mcpCnt = s.tools_mcp_count || 0;
|
|
471
|
+
const attempted = s.tools_attempted || 0;
|
|
472
|
+
const totalLocal = localCnt + mcpCnt;
|
|
473
|
+
const denom = Math.max(attempted, totalLocal);
|
|
474
|
+
if (denom > 0) {
|
|
475
|
+
const cpct = Math.round(totalLocal / denom * 100);
|
|
476
|
+
const cColor = cpct >= 80 ? col.g : cpct >= 50 ? col.y : col.r;
|
|
477
|
+
console.log(cColor(` Ran locally: ${totalLocal} of ${denom} tool calls (${cpct}%)`));
|
|
478
|
+
}
|
|
479
|
+
if (s.blocked) console.log(col.r(` Blocked: ${s.blocked} secrets`));
|
|
480
|
+
if (s.secrets_redacted) console.log(col.c(` Redacted: ${s.secrets_redacted} secret${s.secrets_redacted !== 1 ? 's' : ''} in tool results`));
|
|
481
|
+
if (s.tools_transformed) console.log(col.c(` Transforms: ${s.tools_transformed} tool result${s.tools_transformed !== 1 ? 's' : ''} shaped`));
|
|
482
|
+
if (s.budget != null) {
|
|
483
|
+
const pct = Math.min(999, Math.round((s.cost || 0) / s.budget * 100));
|
|
484
|
+
const budgetStr = fmtBudget(s.cost || 0, s.budget);
|
|
485
|
+
const budgetColor = pct >= 100 ? col.r : pct >= 80 ? col.y : col.g;
|
|
486
|
+
console.log(budgetColor(` Budget: ${budgetStr}`));
|
|
487
|
+
if (s.budget_exceeded_count) console.log(col.r(` BudgetBlk: ${s.budget_exceeded_count} request(s) blocked`));
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
// Detail
|
|
491
|
+
console.log(col.d(` โโโโ`));
|
|
492
|
+
console.log(col.d(` Requests: ${s.requests} ยท ${(s.input_tokens/1000).toFixed(1)}k tokens in ยท ${(s.output_tokens/1000).toFixed(1)}k out`));
|
|
493
|
+
if (totalSav > 0.00001) {
|
|
494
|
+
const parts = [];
|
|
495
|
+
if (payload > 0.00001) parts.push(`$${payload.toFixed(4)} payload`);
|
|
496
|
+
if (context > 0.00001) parts.push(`$${context.toFixed(4)} context`);
|
|
497
|
+
if (cacheSav > 0.00001) parts.push(`$${cacheSav.toFixed(4)} cache`);
|
|
498
|
+
if (parts.length) console.log(col.d(` Breakdown: ${parts.join(' + ')}`));
|
|
499
|
+
}
|
|
500
|
+
const tail = [];
|
|
501
|
+
if (s.mode) tail.push(`Mode: ${s.mode}`);
|
|
502
|
+
if (s.start) tail.push(`Since: ${new Date(s.start).toLocaleString()}`);
|
|
503
|
+
if (tail.length) console.log(col.d(` ${tail.join(' ยท ')}`));
|
|
504
|
+
console.log(''); process.exit(0);
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
if (cmd === 'clear') {
|
|
508
|
+
ensureDirs();
|
|
509
|
+
const clearAll = args.slice(1).includes('--history');
|
|
510
|
+
if (clearAll) {
|
|
511
|
+
const logsDir = path.join(LOG_DIR, 'logs');
|
|
512
|
+
const blockedDir = path.join(LOG_DIR, 'blocked');
|
|
513
|
+
let n = 0;
|
|
514
|
+
for (const dir of [logsDir, blockedDir]) {
|
|
515
|
+
try { for (const f of fs.readdirSync(dir)) { fs.unlinkSync(path.join(dir, f)); n++; } } catch {}
|
|
516
|
+
}
|
|
517
|
+
try { fs.unlinkSync(SESSION_FILE); } catch {}
|
|
518
|
+
console.log(col.g(`โ Cleared all history (${n} log files) and session data`));
|
|
519
|
+
} else {
|
|
520
|
+
try { fs.unlinkSync(getLogFile()); } catch {}
|
|
521
|
+
try { fs.unlinkSync(SESSION_FILE); } catch {}
|
|
522
|
+
console.log(col.g("โ Cleared today's log and session data"));
|
|
523
|
+
console.log(col.d(' Use --history to wipe all historical logs'));
|
|
524
|
+
}
|
|
525
|
+
process.exit(0);
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
if (cmd === 'ledger') {
|
|
529
|
+
runLedgerCli(args.slice(1));
|
|
530
|
+
process.exit(0);
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
if (cmd === 'replay') {
|
|
534
|
+
runReplayCli(args.slice(1));
|
|
535
|
+
process.exit(0);
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
if (cmd === 'distill') {
|
|
539
|
+
runDistillCli(args.slice(1));
|
|
540
|
+
process.exit(0);
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
if (cmd === 'boundary') {
|
|
544
|
+
const { runBoundaryCli } = require('./boundary');
|
|
545
|
+
const result = runBoundaryCli(args.slice(1));
|
|
546
|
+
process.exit(result.ok ? 0 : 1);
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
if (cmd === 'baseline') {
|
|
550
|
+
const { runBaselineCli } = require('./baseline');
|
|
551
|
+
const result = runBaselineCli(args.slice(1));
|
|
552
|
+
process.exit(result.ok ? 0 : 1);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
if (cmd === 'harness') {
|
|
556
|
+
const { runHarnessCli } = require('./harness');
|
|
557
|
+
runHarnessCli(args.slice(1)).then(r => process.exit(r.ok ? 0 : 1));
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
if (cmd === 'redteam') {
|
|
562
|
+
const { runRedteamCli } = require('./redteam');
|
|
563
|
+
runRedteamCli(args.slice(1)).then(r => process.exit(r.ok ? 0 : 1));
|
|
564
|
+
return;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
if (cmd === 'inspect') {
|
|
568
|
+
runInspectCli(args.slice(1));
|
|
569
|
+
process.exit(0);
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
if (cmd === 'audit') {
|
|
573
|
+
runAuditCli(args.slice(1));
|
|
574
|
+
process.exit(0);
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
if (cmd === 'selftest') {
|
|
578
|
+
const { runSelfTestCli } = require('./selftest');
|
|
579
|
+
runSelfTestCli(); // async, calls process.exit itself
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
if (cmd === 'policy') {
|
|
584
|
+
const sub = args[1];
|
|
585
|
+
if (!sub || sub === 'show') {
|
|
586
|
+
const { runPolicyCli } = require('./policy/show');
|
|
587
|
+
runPolicyCli(args.slice(sub ? 2 : 1));
|
|
588
|
+
process.exit(0);
|
|
589
|
+
}
|
|
590
|
+
if (sub === 'validate') {
|
|
591
|
+
const { runValidateCli } = require('./policy/validate');
|
|
592
|
+
const result = runValidateCli(args.slice(2));
|
|
593
|
+
process.exit(result.ok ? 0 : 1);
|
|
594
|
+
}
|
|
595
|
+
if (sub === 'init') {
|
|
596
|
+
const { runInitCli } = require('./policy/init');
|
|
597
|
+
const result = runInitCli(args.slice(2));
|
|
598
|
+
process.exit(result.ok ? 0 : 1);
|
|
599
|
+
}
|
|
600
|
+
if (sub === 'doctor') {
|
|
601
|
+
const { runDoctorCli } = require('./policy/doctor');
|
|
602
|
+
runDoctorCli(args.slice(2));
|
|
603
|
+
process.exit(0);
|
|
604
|
+
}
|
|
605
|
+
console.error(col.r(`Unknown policy subcommand: ${sub}`));
|
|
606
|
+
console.error(col.d(' Usage: occasio policy [show] [--diff]'));
|
|
607
|
+
console.error(col.d(' occasio policy validate [--file path]'));
|
|
608
|
+
console.error(col.d(' occasio policy init [--template dev-default|strict|finance] [--force] [--file path]'));
|
|
609
|
+
console.error(col.d(' occasio policy doctor [--days N]'));
|
|
610
|
+
process.exit(1);
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
if (cmd === 'report') {
|
|
614
|
+
const { runReportCli } = require('./report/index');
|
|
615
|
+
runReportCli(args.slice(1));
|
|
616
|
+
process.exit(0);
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
if (cmd === 'anomalies' || cmd === 'anomaly') {
|
|
620
|
+
const { runAnomaliesCli } = require('./anomaly/cli');
|
|
621
|
+
process.exit(runAnomaliesCli(args.slice(1)));
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
if (cmd === 'computer-use') {
|
|
625
|
+
const { runComputerUseCli } = require('./adapters/computer-use-cli');
|
|
626
|
+
process.exit(runComputerUseCli(args.slice(1)));
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
if (cmd === 'attest') {
|
|
630
|
+
const { runAttestCli } = require('./attest');
|
|
631
|
+
const r = runAttestCli(args.slice(1));
|
|
632
|
+
if (r && typeof r.then === 'function') {
|
|
633
|
+
r.then(() => process.exit(0)).catch(() => process.exit(1));
|
|
634
|
+
} else {
|
|
635
|
+
process.exit(0);
|
|
636
|
+
}
|
|
637
|
+
return;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
if (cmd === 'preflight') {
|
|
641
|
+
const { runPreflightCli } = require('./preflight/cli');
|
|
642
|
+
runPreflightCli(args.slice(1));
|
|
643
|
+
process.exit(0);
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
if (cmd === 'demo' && args[1] === 'attest') {
|
|
647
|
+
const { runAttestDemoCli } = require('./demo/attest-demo');
|
|
648
|
+
runAttestDemoCli(args.slice(2)).then(code => process.exit(code))
|
|
649
|
+
.catch(e => { process.stderr.write(`[demo attest] ${e.message}\n`); process.exit(1); });
|
|
650
|
+
return;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
if (cmd === 'demo' && (args[1] === 'anomalies' || args[1] === 'anomaly')) {
|
|
654
|
+
const { runAnomaliesDemoCli } = require('./demo/anomalies-demo');
|
|
655
|
+
process.exit(runAnomaliesDemoCli(args.slice(2)));
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
if (cmd === 'demo') {
|
|
659
|
+
const { scanSecrets } = require('../src/analyzer');
|
|
660
|
+
// Realistic-looking but synthetic credentials. Each hits exactly one pattern.
|
|
661
|
+
// No real keys are used; the AWS string is the actual public AWS docs example.
|
|
662
|
+
const FIXTURES = [
|
|
663
|
+
{
|
|
664
|
+
name: '.env',
|
|
665
|
+
content: [
|
|
666
|
+
'# Production environment',
|
|
667
|
+
'NODE_ENV=production',
|
|
668
|
+
'ANTHROPIC_API_KEY=sk-ant-api03-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA_EXAMPLE',
|
|
669
|
+
'PORT=3000',
|
|
670
|
+
].join('\n'),
|
|
671
|
+
},
|
|
672
|
+
{
|
|
673
|
+
name: 'config.yml',
|
|
674
|
+
content: [
|
|
675
|
+
'app:',
|
|
676
|
+
' name: occasio-demo',
|
|
677
|
+
' database:',
|
|
678
|
+
' url: postgres://admin:hunter2hunter2@db.internal:5432/prod',
|
|
679
|
+
' port: 5432',
|
|
680
|
+
].join('\n'),
|
|
681
|
+
},
|
|
682
|
+
{
|
|
683
|
+
name: 'deploy.sh',
|
|
684
|
+
content: [
|
|
685
|
+
'#!/bin/bash',
|
|
686
|
+
'export AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE',
|
|
687
|
+
'aws s3 sync ./build s3://my-bucket/',
|
|
688
|
+
].join('\n'),
|
|
689
|
+
},
|
|
690
|
+
{
|
|
691
|
+
name: 'secrets.json',
|
|
692
|
+
content: [
|
|
693
|
+
'{',
|
|
694
|
+
' "github_token": "ghp_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",',
|
|
695
|
+
' "service": "ci"',
|
|
696
|
+
'}',
|
|
697
|
+
].join('\n'),
|
|
698
|
+
},
|
|
699
|
+
{
|
|
700
|
+
name: 'setup.py',
|
|
701
|
+
content: [
|
|
702
|
+
'import os',
|
|
703
|
+
'',
|
|
704
|
+
'def configure():',
|
|
705
|
+
' # TODO: load from env in production',
|
|
706
|
+
' api_key = "1234567890abcdef1234567890abcdef"',
|
|
707
|
+
' return api_key',
|
|
708
|
+
].join('\n'),
|
|
709
|
+
},
|
|
710
|
+
];
|
|
711
|
+
|
|
712
|
+
console.log(col.b('\nโก Occasio โ secret-block demo\n'));
|
|
713
|
+
console.log(col.d(` Simulating a Claude Code session that reads ${FIXTURES.length} files.`));
|
|
714
|
+
console.log(col.d(` Running the real scanner โ same code path that fires on every tool result.\n`));
|
|
715
|
+
|
|
716
|
+
let totalHits = 0;
|
|
717
|
+
for (const f of FIXTURES) {
|
|
718
|
+
const hits = scanSecrets(f.content);
|
|
719
|
+
console.log(` ${col.b('๐ ' + f.name)}`);
|
|
720
|
+
if (hits.length === 0) {
|
|
721
|
+
console.log(` ${col.g('โ clean')}\n`);
|
|
722
|
+
continue;
|
|
723
|
+
}
|
|
724
|
+
for (const h of hits) {
|
|
725
|
+
const tag = col.r('โ BLOCKED');
|
|
726
|
+
const lbl = col.y(h.label.padEnd(15));
|
|
727
|
+
console.log(` ${tag} ${lbl} line ${h.line}`);
|
|
728
|
+
console.log(` ${col.d(h.snippet)}`);
|
|
729
|
+
totalHits++;
|
|
730
|
+
}
|
|
731
|
+
console.log('');
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
console.log(col.d(' ' + 'โ'.repeat(64)));
|
|
735
|
+
console.log(` ${col.r(totalHits + ' secrets')} that would have been sent to Anthropic โ ${col.g('blocked locally')}.`);
|
|
736
|
+
console.log(col.d(` Each value masked before logging; the raw secret never leaves your machine.\n`));
|
|
737
|
+
process.exit(0);
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
if (cmd === 'mcp-experiment' || cmd === 'mcp-stats') {
|
|
741
|
+
require('./mcp-experiment');
|
|
742
|
+
process.exit(0);
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
if (cmd === 'dashboard') {
|
|
746
|
+
require('./dashboard');
|
|
747
|
+
const url = 'http://localhost:3001';
|
|
748
|
+
process.stderr.write(col.b('\nโก Occasio Dashboard\n\n'));
|
|
749
|
+
process.stderr.write(` ${col.c(url)}\n`);
|
|
750
|
+
process.stderr.write(col.d(' Reads from the running session in ~/.occasio/session.json\n\n'));
|
|
751
|
+
require('child_process').exec(
|
|
752
|
+
process.platform === 'win32' ? `start ${url}` :
|
|
753
|
+
process.platform === 'darwin' ? `open ${url}` : `xdg-open ${url}`,
|
|
754
|
+
{ shell: true }
|
|
755
|
+
);
|
|
756
|
+
return;
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
if (cmd === 'doctor' || cmd === 'check') {
|
|
760
|
+
(async () => {
|
|
761
|
+
const { execSync } = require('child_process');
|
|
762
|
+
const net = require('net');
|
|
763
|
+
let allOk = true;
|
|
764
|
+
const ok = (label, detail) => process.stderr.write(col.g(` โ ${label}`) + (detail ? col.d(` โ ${detail}`) : '') + '\n');
|
|
765
|
+
const bad = (label, hint) => { process.stderr.write(col.r(` โ ${label}`) + (hint ? col.d(` โ ${hint}`) : '') + '\n'); allOk = false; };
|
|
766
|
+
|
|
767
|
+
process.stderr.write(col.b('\nโก Occasio doctor\n\n'));
|
|
768
|
+
|
|
769
|
+
// 1. Node.js version
|
|
770
|
+
const [nodeMajor] = process.versions.node.split('.').map(Number);
|
|
771
|
+
if (nodeMajor >= 18) ok('Node.js', `v${process.versions.node}`);
|
|
772
|
+
else bad('Node.js', `v${process.versions.node} โ requires โฅ 18`);
|
|
773
|
+
|
|
774
|
+
// 2. claude CLI
|
|
775
|
+
try {
|
|
776
|
+
const out = execSync('claude --version', {
|
|
777
|
+
shell: process.platform === 'win32', timeout: 5000,
|
|
778
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
779
|
+
}).toString().trim();
|
|
780
|
+
ok('claude CLI', out.split('\n')[0]);
|
|
781
|
+
} catch { bad('claude CLI', 'not found โ npm install -g @anthropic-ai/claude-code'); }
|
|
782
|
+
|
|
783
|
+
// 3. Log dir writable
|
|
784
|
+
try {
|
|
785
|
+
ensureDirs();
|
|
786
|
+
const probe = path.join(LOG_DIR, '.doctor');
|
|
787
|
+
fs.writeFileSync(probe, ''); fs.unlinkSync(probe);
|
|
788
|
+
ok('log dir', LOG_DIR);
|
|
789
|
+
} catch (e) { bad('log dir', e.message); }
|
|
790
|
+
|
|
791
|
+
// 4. Port availability (async)
|
|
792
|
+
await new Promise(resolve => {
|
|
793
|
+
const srv = net.createServer();
|
|
794
|
+
srv.once('error', () => {
|
|
795
|
+
bad(`port ${PORT}`, `already in use โ kill it: netstat -ano | findstr :${PORT}`);
|
|
796
|
+
resolve();
|
|
797
|
+
});
|
|
798
|
+
srv.once('listening', () => { srv.close(); ok(`port ${PORT}`, 'available'); resolve(); });
|
|
799
|
+
srv.listen(PORT, '127.0.0.1');
|
|
800
|
+
});
|
|
801
|
+
|
|
802
|
+
// 5. Python + LAO scorer script
|
|
803
|
+
const laoPyPath = path.join(__dirname, 'lao_prep.py');
|
|
804
|
+
const laoPyExists = fs.existsSync(laoPyPath);
|
|
805
|
+
let pyFound = false;
|
|
806
|
+
for (const pyCmd of ['python', 'python3']) {
|
|
807
|
+
if (pyFound) break;
|
|
808
|
+
try {
|
|
809
|
+
const out = execSync(`${pyCmd} --version`, {
|
|
810
|
+
shell: process.platform === 'win32', timeout: 5000,
|
|
811
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
812
|
+
}).toString().trim();
|
|
813
|
+
ok('Python (LAO)', out); pyFound = true;
|
|
814
|
+
} catch {}
|
|
815
|
+
}
|
|
816
|
+
if (!pyFound) bad('Python (LAO)', 'not found โ context trimming disabled');
|
|
817
|
+
if (laoPyExists) ok('LAO scorer', laoPyPath);
|
|
818
|
+
else bad('LAO scorer', 'lao_prep.py missing โ context trimming disabled even if Python is installed');
|
|
819
|
+
|
|
820
|
+
// 6. PowerShell profile + execution policy (Windows only)
|
|
821
|
+
if (process.platform === 'win32') {
|
|
822
|
+
const pFile = path.join(os.homedir(), 'Documents', 'PowerShell', 'Microsoft.PowerShell_profile.ps1');
|
|
823
|
+
try {
|
|
824
|
+
const src = fs.existsSync(pFile) ? fs.readFileSync(pFile, 'utf8') : '';
|
|
825
|
+
if (src.includes('occasio claude @args')) ok('PowerShell profile', 'registered');
|
|
826
|
+
else bad('PowerShell profile', 'not registered โ run: occasio register');
|
|
827
|
+
} catch { bad('PowerShell profile', 'cannot read profile'); }
|
|
828
|
+
|
|
829
|
+
// Check execution policy โ Restricted prevents profile scripts from running.
|
|
830
|
+
try {
|
|
831
|
+
const policy = execSync(
|
|
832
|
+
'powershell -NoProfile -Command "Get-ExecutionPolicy -Scope CurrentUser"',
|
|
833
|
+
{ shell: false, timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'] }
|
|
834
|
+
).toString().trim();
|
|
835
|
+
if (policy === 'Restricted' || policy === 'Undefined') {
|
|
836
|
+
bad('ExecutionPolicy', `${policy} โ profile scripts blocked. Fix: Set-ExecutionPolicy -Scope CurrentUser RemoteSigned`);
|
|
837
|
+
allOk = false;
|
|
838
|
+
} else {
|
|
839
|
+
ok('ExecutionPolicy', policy);
|
|
840
|
+
}
|
|
841
|
+
} catch { /* powershell not on PATH or timed out โ skip silently */ }
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
// Session summary
|
|
845
|
+
try {
|
|
846
|
+
const s = JSON.parse(fs.readFileSync(SESSION_FILE, 'utf8'));
|
|
847
|
+
const localSavD = (s.lao_cost_saved || 0) + (s.distill_cost_saved || 0);
|
|
848
|
+
const savingStr = localSavD > 0 ? ` ยท saved $${localSavD.toFixed(4)}` : '';
|
|
849
|
+
process.stderr.write(col.d(`\n Last session: ${s.requests} requests ยท $${(s.cost || 0).toFixed(4)}${savingStr}\n`));
|
|
850
|
+
} catch { process.stderr.write(col.d('\n No session data yet.\n')); }
|
|
851
|
+
|
|
852
|
+
process.stderr.write('\n');
|
|
853
|
+
if (!allOk) {
|
|
854
|
+
process.stderr.write(col.r(' Some checks failed.\n\n'));
|
|
855
|
+
process.exit(1);
|
|
856
|
+
}
|
|
857
|
+
process.stderr.write(col.g(' All checks passed.\n\n'));
|
|
858
|
+
process.exit(0);
|
|
859
|
+
})();
|
|
860
|
+
return;
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
const claudeArgs = cmd === 'claude' ? args.slice(1) : args;
|
|
864
|
+
let mode = 'intercept';
|
|
865
|
+
const mi = claudeArgs.indexOf('--mode');
|
|
866
|
+
if (mi >= 0) { mode = claudeArgs[mi+1]||'intercept'; claudeArgs.splice(mi, 2); }
|
|
867
|
+
const ii = claudeArgs.indexOf('--intercept');
|
|
868
|
+
if (ii >= 0) { mode = 'intercept'; claudeArgs.splice(ii, 1); }
|
|
869
|
+
const li = claudeArgs.indexOf('--log-only');
|
|
870
|
+
if (li >= 0) { mode = 'log'; claudeArgs.splice(li, 1); }
|
|
871
|
+
const bi = claudeArgs.indexOf('--block-secrets');
|
|
872
|
+
if (bi >= 0) { mode = 'block_secrets'; claudeArgs.splice(bi, 1); }
|
|
873
|
+
const mpi = claudeArgs.indexOf('--hardened');
|
|
874
|
+
if (mpi >= 0) { mode = 'hardened'; claudeArgs.splice(mpi, 1); }
|
|
875
|
+
const prIdx = claudeArgs.indexOf('--preset');
|
|
876
|
+
if (prIdx >= 0) {
|
|
877
|
+
const preset = claudeArgs[prIdx + 1] || '';
|
|
878
|
+
claudeArgs.splice(prIdx, 2);
|
|
879
|
+
if (preset === 'strict') mode = 'block_secrets';
|
|
880
|
+
else if (preset === 'off') mode = 'log';
|
|
881
|
+
// 'balanced' or unrecognised โ keep current mode (intercept default)
|
|
882
|
+
}
|
|
883
|
+
const di = claudeArgs.indexOf('--dashboard');
|
|
884
|
+
const useDashboard = di >= 0;
|
|
885
|
+
if (di >= 0) claudeArgs.splice(di, 1);
|
|
886
|
+
const pi = claudeArgs.indexOf('--port');
|
|
887
|
+
if (pi >= 0) { PORT = parseInt(claudeArgs[pi+1], 10) || PORT; claudeArgs.splice(pi, 2); }
|
|
888
|
+
|
|
889
|
+
// Budget flag: --budget <N> sets a session dollar limit.
|
|
890
|
+
const bgtIdx = claudeArgs.indexOf('--budget');
|
|
891
|
+
let budget = null;
|
|
892
|
+
if (bgtIdx >= 0) {
|
|
893
|
+
const bv = parseFloat(claudeArgs[bgtIdx + 1]);
|
|
894
|
+
if (!isNaN(bv) && bv > 0) budget = bv;
|
|
895
|
+
else process.stderr.write(col.y(` โ --budget requires a positive number (e.g. --budget 1.00)\n`));
|
|
896
|
+
claudeArgs.splice(bgtIdx, 2);
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
// Verbose flag: --verbose enables live per-request chatter on stderr.
|
|
900
|
+
// Default is quiet โ Claude Code's TUI redraws the same lines we'd write to,
|
|
901
|
+
// and any concurrent stderr writes corrupt the rendering. The exit summary
|
|
902
|
+
// (Saved banner) and critical events (BLOCKED, BUDGET EXCEEDED, errors)
|
|
903
|
+
// always print; only the per-tool / per-request live ribbon is gated.
|
|
904
|
+
const vIdx = claudeArgs.indexOf('--verbose');
|
|
905
|
+
const LIVE_VERBOSE = vIdx >= 0;
|
|
906
|
+
if (vIdx >= 0) claudeArgs.splice(vIdx, 1);
|
|
907
|
+
let sessionCost = 0; // running in-memory total for budget enforcement
|
|
908
|
+
let budgetWarnFired = false; // fires warning at most once per session
|
|
909
|
+
|
|
910
|
+
const currentRunId = randomUUID();
|
|
911
|
+
const sessionTodoStore = []; // session-scoped todo list, shared across all runToolLoop calls
|
|
912
|
+
const pendingToolInjections = new Map(); // tool_use_id โ distilled content (partial-batch pre-runs)
|
|
913
|
+
|
|
914
|
+
// Stage 1 architecture: production tool dispatch runs through the canonical
|
|
915
|
+
// pipeline. Each Read/Glob/Grep/TodoWrite/TodoRead tool call records one
|
|
916
|
+
// audit row to ~/.occasio/pipeline-events.jsonl. The legacy daily ledger
|
|
917
|
+
// remains the source of truth for cost/usage; this file complements it with
|
|
918
|
+
// per-event tracing and is the foundation for Stage 2's tamper-evident log.
|
|
919
|
+
const { createAuditor: _createAuditor } = require('./audit/jsonl-auditor');
|
|
920
|
+
// Audit-file override via env var. Used by `occasio harness` to run
|
|
921
|
+
// against a scratch chain so the user's real ~/.occasio/pipeline-events
|
|
922
|
+
// .jsonl is never touched. When unset, the auditor uses its default location.
|
|
923
|
+
const sessionAuditor = _createAuditor(process.env.LOCALFIRST_AUDIT_FILE || undefined);
|
|
924
|
+
|
|
925
|
+
// v0.6.6: register a policy-change listener that emits a `policy_loaded`
|
|
926
|
+
// audit row whenever the active policy hash transitions to a new value
|
|
927
|
+
// (cold start, hot reload, or file-now-absent). The proxy is the only
|
|
928
|
+
// long-running process inside this Node instance, so registering once at
|
|
929
|
+
// startup is sufficient. The listener honours the v0.6.4 fail-fatal
|
|
930
|
+
// contract: an audit-write failure aborts the proxy.
|
|
931
|
+
{
|
|
932
|
+
const policyLoader = require('./policy/loader');
|
|
933
|
+
policyLoader.onPolicyChange((change) => {
|
|
934
|
+
const status = sessionAuditor.recordPolicyLoaded(change);
|
|
935
|
+
if (status && status.ok === false) {
|
|
936
|
+
const dropped = status.droppedRow ? JSON.stringify(status.droppedRow) : '(no row attached)';
|
|
937
|
+
process.stderr.write(`\n${col.r('[occasio][audit-fatal]')} policy_loaded write failed: ${status.error?.message}\n`);
|
|
938
|
+
process.stderr.write(`${col.r('[occasio][audit-fatal] dropped row:')} ${dropped}\n`);
|
|
939
|
+
process.stderr.write(`${col.r('[occasio][audit-fatal] proxy aborting; supervisor will restart.')}\n`);
|
|
940
|
+
try { server && server.close && server.close(); } catch {}
|
|
941
|
+
setTimeout(() => process.exit(1), 250);
|
|
942
|
+
}
|
|
943
|
+
});
|
|
944
|
+
// Trigger first load so the cold-start policy_loaded row lands BEFORE
|
|
945
|
+
// any tool call audit row. load() is idempotent โ the proxy and
|
|
946
|
+
// pipeline call it again per request without duplicating the event.
|
|
947
|
+
policyLoader.load();
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
ensureDirs();
|
|
951
|
+
fs.writeFileSync(SESSION_FILE, JSON.stringify({
|
|
952
|
+
run_id: currentRunId,
|
|
953
|
+
log_file: getLogFile(),
|
|
954
|
+
budget: budget,
|
|
955
|
+
requests:0, input_tokens:0, output_tokens:0, cost:0,
|
|
956
|
+
blocked:0, intercepted_count:0,
|
|
957
|
+
budget_exceeded_count: 0,
|
|
958
|
+
cache_read_tokens:0, cache_write_tokens:0, cache_savings:0,
|
|
959
|
+
lao_tokens_saved:0, lao_cost_saved:0, tools_local_count:0, tools_mcp_count:0,
|
|
960
|
+
distill_tokens_saved:0, distill_cost_saved:0, secrets_redacted:0, tools_transformed:0,
|
|
961
|
+
mode, start: new Date().toISOString(),
|
|
962
|
+
}));
|
|
963
|
+
|
|
964
|
+
process.stderr.write(col.b(`\nโก Occasio v${VERSION}\n`));
|
|
965
|
+
const modeLabel = mode === 'block_secrets' ? col.r('block-secrets')
|
|
966
|
+
: mode === 'hardened' ? col.c('hardened')
|
|
967
|
+
: col.d(mode);
|
|
968
|
+
const modeHint = mode === 'intercept' ? col.d(' (--preset strict to block secrets)')
|
|
969
|
+
: mode === 'hardened' ? col.d(' (Read/Glob/Grep โ unified runtime, distill + secret scan)')
|
|
970
|
+
: '';
|
|
971
|
+
process.stderr.write(` ${col.d('mode:')} ${modeLabel}${modeHint} ${col.d('log:')} ${col.d(getLogFile())}\n`);
|
|
972
|
+
if (budget !== null) {
|
|
973
|
+
process.stderr.write(` ${col.d('budget:')} ${col.y(`$${budget.toFixed(4)}`)} ${col.d('(session limit โ requests blocked when exceeded)')}\n`);
|
|
974
|
+
}
|
|
975
|
+
{
|
|
976
|
+
const { execSync: _ex } = require('child_process');
|
|
977
|
+
let _pyOk = false;
|
|
978
|
+
for (const _cmd of ['python', 'python3']) {
|
|
979
|
+
try { _ex(`${_cmd} --version`, { shell: process.platform === 'win32', timeout: 3000, stdio: 'pipe' }); _pyOk = true; break; } catch {}
|
|
980
|
+
}
|
|
981
|
+
if (!_pyOk) process.stderr.write(col.y(` โ LAO disabled โ Python not found (context trimming inactive)\n`));
|
|
982
|
+
}
|
|
983
|
+
const isFirstRun = !fs.existsSync(path.join(LOG_DIR, '.registered'));
|
|
984
|
+
if (isFirstRun) {
|
|
985
|
+
process.stderr.write(col.g(`\n Your traffic is now routing through Occasio.\n`));
|
|
986
|
+
process.stderr.write(col.d(` Run ${col.b('occasio register')} to alias 'claude' directly (one-time).\n`));
|
|
987
|
+
process.stderr.write(col.d(` Run ${col.b('occasio doctor')} to verify your setup.\n`));
|
|
988
|
+
fs.mkdirSync(LOG_DIR, { recursive: true });
|
|
989
|
+
fs.writeFileSync(path.join(LOG_DIR, '.registered'), '');
|
|
990
|
+
}
|
|
991
|
+
if (!LIVE_VERBOSE) {
|
|
992
|
+
process.stderr.write(col.d(` Quiet mode โ session summary at exit. Pass ${col.b('--verbose')} for live per-request chatter.\n`));
|
|
993
|
+
}
|
|
994
|
+
process.stderr.write('\n');
|
|
995
|
+
|
|
996
|
+
if (useDashboard) require('./dashboard');
|
|
997
|
+
|
|
998
|
+
// โโ Proxy server โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
999
|
+
|
|
1000
|
+
const server = http.createServer((req, res) => {
|
|
1001
|
+
if (req.url === '/api/session' && req.method === 'GET') {
|
|
1002
|
+
res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });
|
|
1003
|
+
try { res.end(fs.readFileSync(SESSION_FILE, 'utf8')); } catch { res.end('{}'); }
|
|
1004
|
+
return;
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
const chunks = []; req.on('data', c => chunks.push(c));
|
|
1008
|
+
req.on('end', async () => {
|
|
1009
|
+
const body = Buffer.concat(chunks);
|
|
1010
|
+
const isMsg = req.url?.includes('/v1/messages');
|
|
1011
|
+
let model = '', fileTokens = [], files = [], secrets = [], blocked = [], reqBody = null;
|
|
1012
|
+
|
|
1013
|
+
if (isMsg && body.length) {
|
|
1014
|
+
try {
|
|
1015
|
+
const d = JSON.parse(body.toString());
|
|
1016
|
+
reqBody = d;
|
|
1017
|
+
model = d.model || '';
|
|
1018
|
+
fileTokens = parseFileTokens(d.messages); // Level 1
|
|
1019
|
+
files = fileTokens.map(f => f.name);
|
|
1020
|
+
secrets = scanSecrets(body.toString()); // Level 2
|
|
1021
|
+
|
|
1022
|
+
const rp = path.join(process.cwd(), '.occasio', 'rules.json');
|
|
1023
|
+
if (fs.existsSync(rp)) {
|
|
1024
|
+
try {
|
|
1025
|
+
const rules = JSON.parse(fs.readFileSync(rp, 'utf8'));
|
|
1026
|
+
blocked = files.filter(f => (rules.block || []).some(p => f.includes(p.replace(/\*\*/g, '').replace(/\*/g, ''))));
|
|
1027
|
+
} catch {}
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
const shouldBlock = (mode === 'block_secrets' && secrets.length) || (mode === 'block_rules' && blocked.length);
|
|
1031
|
+
if (shouldBlock) {
|
|
1032
|
+
const blockedNow = new Date();
|
|
1033
|
+
const ts = blockedNow.toTimeString().slice(0, 8);
|
|
1034
|
+
const iso = blockedNow.toISOString();
|
|
1035
|
+
process.stderr.write(`\n${col.d(ts)} ${col.r('๐ BLOCKED')} ${col.d('โ nothing sent to Anthropic')}\n`);
|
|
1036
|
+
for (const sec of secrets) {
|
|
1037
|
+
process.stderr.write(` ${col.r(`โ ${sec.label}`)} ${col.d(`line ${sec.line}`)}\n`);
|
|
1038
|
+
}
|
|
1039
|
+
if (blocked.length) {
|
|
1040
|
+
process.stderr.write(` ${col.r('๐ rule-blocked:')} ${col.d(blocked.join(', '))}\n`);
|
|
1041
|
+
}
|
|
1042
|
+
writeBlocked({ ts, model, secrets, blocked, files });
|
|
1043
|
+
const blockedEntry = {
|
|
1044
|
+
v: LOG_SCHEMA_VERSION, iso, ts, run_id: currentRunId,
|
|
1045
|
+
event_type: 'blocked',
|
|
1046
|
+
model, input_tokens: 0, output_tokens: 0,
|
|
1047
|
+
cache_write_tokens: 0, cache_read_tokens: 0,
|
|
1048
|
+
cache_savings: 0, lao_tokens_saved: 0, lao_cost_saved: 0,
|
|
1049
|
+
distill_tokens_saved: 0, distill_cost_saved: 0,
|
|
1050
|
+
tools_local_count: 0, tools_attempted: 0, cost: 0,
|
|
1051
|
+
files, file_tokens: fileTokens, secrets, blocked,
|
|
1052
|
+
intercepted: false, tools: [],
|
|
1053
|
+
};
|
|
1054
|
+
writeLog(blockedEntry);
|
|
1055
|
+
updateSession(blockedEntry);
|
|
1056
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
1057
|
+
res.end(JSON.stringify({ error: { type: 'blocked', reason: secrets.length ? secrets[0].label : 'rule', by: 'Occasio' } }));
|
|
1058
|
+
return;
|
|
1059
|
+
}
|
|
1060
|
+
} catch {}
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
// โโ Budget enforcement (Stage 2: policy-driven BLOCK) โโโโโโโโโโโโโโโโโโโโโ
|
|
1064
|
+
// The decision-to-block is produced by policy.evaluateRequest, which
|
|
1065
|
+
// reads ~/.occasio/policy.yml's `block_requests_over_budget` rule.
|
|
1066
|
+
// Side effects (verbose print, JSONL log, session counter) stay here as
|
|
1067
|
+
// observability/state concerns, separate from the policy decision.
|
|
1068
|
+
if (isMsg && budget !== null) {
|
|
1069
|
+
const policyEngine = require('./policy/engine');
|
|
1070
|
+
const decision = policyEngine.evaluateRequest({ sessionCost, budget });
|
|
1071
|
+
if (decision.action === 'BLOCK') {
|
|
1072
|
+
const ts = new Date().toTimeString().slice(0, 8);
|
|
1073
|
+
const iso = new Date().toISOString();
|
|
1074
|
+
process.stderr.write(`\n${col.d(ts)} ${col.r('๐ซ BUDGET EXCEEDED')} โ $${sessionCost.toFixed(4)} of $${budget.toFixed(4)} spent this session\n`);
|
|
1075
|
+
process.stderr.write(col.d(` Reset: occasio clear | Increase: restart with --budget ${(budget * 2).toFixed(4)}\n`));
|
|
1076
|
+
const bEntry = {
|
|
1077
|
+
v: LOG_SCHEMA_VERSION, iso, ts, run_id: currentRunId,
|
|
1078
|
+
event_type: BUDGET_EXCEEDED_EVENT,
|
|
1079
|
+
budget_limit: parseFloat(budget.toFixed(6)),
|
|
1080
|
+
budget_spent: parseFloat(sessionCost.toFixed(6)),
|
|
1081
|
+
model, input_tokens: 0, output_tokens: 0,
|
|
1082
|
+
cache_write_tokens: 0, cache_read_tokens: 0, cache_savings: 0,
|
|
1083
|
+
lao_tokens_saved: 0, lao_cost_saved: 0,
|
|
1084
|
+
distill_tokens_saved: 0, distill_cost_saved: 0,
|
|
1085
|
+
tools_local_count: 0, tools_attempted: 0, cost: 0,
|
|
1086
|
+
files, file_tokens: fileTokens, secrets: [], blocked: [],
|
|
1087
|
+
intercepted: false, tools: [],
|
|
1088
|
+
};
|
|
1089
|
+
writeLog(bEntry);
|
|
1090
|
+
try {
|
|
1091
|
+
const s = JSON.parse(fs.readFileSync(SESSION_FILE, 'utf8'));
|
|
1092
|
+
s.budget_exceeded_count = (s.budget_exceeded_count || 0) + 1;
|
|
1093
|
+
fs.writeFileSync(SESSION_FILE, JSON.stringify(s));
|
|
1094
|
+
} catch {}
|
|
1095
|
+
const synth = decision.syntheticResponse;
|
|
1096
|
+
res.writeHead(synth.status, { 'Content-Type': 'application/json' });
|
|
1097
|
+
res.end(JSON.stringify(synth.body));
|
|
1098
|
+
return;
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
1102
|
+
|
|
1103
|
+
// โโ Partial-batch injection: replace pre-run tool result content โโโโโโโโโ
|
|
1104
|
+
// When the previous response was a mixed batch (e.g. [Grep, Bash]),
|
|
1105
|
+
// the interceptor pre-ran the interceptable tools and stored distilled results
|
|
1106
|
+
// in pendingToolInjections keyed by tool_use_id. Replace the raw results that
|
|
1107
|
+
// Claude Code collected before forwarding this tool_results request to Anthropic.
|
|
1108
|
+
if (isMsg && reqBody && pendingToolInjections.size > 0) {
|
|
1109
|
+
try {
|
|
1110
|
+
const msgs = reqBody.messages;
|
|
1111
|
+
if (msgs?.length > 0) {
|
|
1112
|
+
const lastMsg = msgs[msgs.length - 1];
|
|
1113
|
+
if (lastMsg.role === 'user' && Array.isArray(lastMsg.content)) {
|
|
1114
|
+
for (const item of lastMsg.content) {
|
|
1115
|
+
if (item.type === 'tool_result' && pendingToolInjections.has(item.tool_use_id)) {
|
|
1116
|
+
item.content = pendingToolInjections.get(item.tool_use_id);
|
|
1117
|
+
pendingToolInjections.delete(item.tool_use_id);
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
} catch {}
|
|
1123
|
+
}
|
|
1124
|
+
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
1125
|
+
|
|
1126
|
+
// โโ Cache injection + LAO context optimization โโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
1127
|
+
let forwardBody = body;
|
|
1128
|
+
let laoSaved = 0, laoDropped = [];
|
|
1129
|
+
let outboundMessageCount = reqBody?.messages?.length || 0;
|
|
1130
|
+
if (isMsg && reqBody) {
|
|
1131
|
+
try {
|
|
1132
|
+
const b = JSON.parse(JSON.stringify(reqBody));
|
|
1133
|
+
// Inject cache_control on system prompt
|
|
1134
|
+
if (b.system) {
|
|
1135
|
+
if (typeof b.system === 'string') {
|
|
1136
|
+
b.system = [{ type: 'text', text: b.system, cache_control: { type: 'ephemeral' } }];
|
|
1137
|
+
} else if (Array.isArray(b.system) && b.system.length > 0) {
|
|
1138
|
+
const last = b.system[b.system.length - 1];
|
|
1139
|
+
if (!last.cache_control) last.cache_control = { type: 'ephemeral' };
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
// Inject cache_control on last large user message (tool results / long context)
|
|
1143
|
+
if (Array.isArray(b.messages) && b.messages.length >= 2) {
|
|
1144
|
+
const lastUser = [...b.messages].reverse().find(m => m.role === 'user');
|
|
1145
|
+
if (lastUser && !lastUser.cache_control) {
|
|
1146
|
+
if (typeof lastUser.content === 'string' && lastUser.content.length > 1024) {
|
|
1147
|
+
lastUser.content = [{ type: 'text', text: lastUser.content, cache_control: { type: 'ephemeral' } }];
|
|
1148
|
+
} else if (Array.isArray(lastUser.content) && lastUser.content.length > 0) {
|
|
1149
|
+
const lc = lastUser.content[lastUser.content.length - 1];
|
|
1150
|
+
if (!lc.cache_control) lc.cache_control = { type: 'ephemeral' };
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
// โโ Path-2 defense: enforce deny_paths against pre-baked auto-context
|
|
1155
|
+
// The tool-call gate in src/policy/engine.js catches deny_paths
|
|
1156
|
+
// violations in `tool_use` blocks the cloud model EMITS. But
|
|
1157
|
+
// Claude Code (and similar agent runtimes) inject synthetic
|
|
1158
|
+
// `tool_use Read` + `tool_result <file content>` pairs into the
|
|
1159
|
+
// OUTBOUND body as agentic context before the model has had a
|
|
1160
|
+
// chance to call any tool. Those reach the model unless we strip
|
|
1161
|
+
// them here. STRIP semantics (not request-BLOCK): mirror the
|
|
1162
|
+
// existing redact-secrets TRANSFORM convention so the agent sees
|
|
1163
|
+
// structural continuity but the bytes never reach the model.
|
|
1164
|
+
const { enforceOutboundDenyPaths, enforceOutboundSecretRedaction, enforceOutboundShaping } = require('./outbound-policy');
|
|
1165
|
+
const policyForOutbound = require('./policy/loader').load();
|
|
1166
|
+
const outboundResult = enforceOutboundDenyPaths(b, policyForOutbound);
|
|
1167
|
+
if (outboundResult.strips.length > 0) {
|
|
1168
|
+
b.messages = outboundResult.messages;
|
|
1169
|
+
// Emit one BLOCK audit row per stripped tool_result, mirroring the
|
|
1170
|
+
// tool-call-time gate's shape so `occasio report` aggregates
|
|
1171
|
+
// both paths uniformly under blocked_accesses[].
|
|
1172
|
+
const { makeBoundaryEvent } = require('./core/boundary-event');
|
|
1173
|
+
for (const strip of outboundResult.strips) {
|
|
1174
|
+
const evt = makeBoundaryEvent({
|
|
1175
|
+
direction: 'outbound', kind: 'tool_call',
|
|
1176
|
+
agent: 'claude-code', protocol: 'anthropic-http',
|
|
1177
|
+
sessionId: undefined, runId: currentRunId,
|
|
1178
|
+
toolName: /^(Read|read_file)$/i.test(strip.toolName) ? 'read_file'
|
|
1179
|
+
: /^(Glob|find_files)$/i.test(strip.toolName) ? 'find_files'
|
|
1180
|
+
: /^(Grep|grep)$/i.test(strip.toolName) ? 'grep'
|
|
1181
|
+
: /^(Bash|shell_bash)$/i.test(strip.toolName) ? 'shell_bash'
|
|
1182
|
+
: /^(PowerShell|shell_powershell)$/i.test(strip.toolName) ? 'shell_powershell'
|
|
1183
|
+
: strip.toolName,
|
|
1184
|
+
toolInput: { file_path: strip.path, path: strip.path },
|
|
1185
|
+
});
|
|
1186
|
+
const status = sessionAuditor.record(
|
|
1187
|
+
evt,
|
|
1188
|
+
{ action: 'BLOCK', reason: 'outbound-context-' + strip.reason,
|
|
1189
|
+
policySource: policyForOutbound.deny_paths?.length ? 'user' : 'default' },
|
|
1190
|
+
{ blocked: true },
|
|
1191
|
+
);
|
|
1192
|
+
if (status && status.ok === false) {
|
|
1193
|
+
const { AuditWriteError } = require('./audit/errors');
|
|
1194
|
+
throw new AuditWriteError(status.error, status.droppedRow);
|
|
1195
|
+
}
|
|
1196
|
+
}
|
|
1197
|
+
if (LIVE_VERBOSE) {
|
|
1198
|
+
const tsOut = new Date().toTimeString().slice(0, 8);
|
|
1199
|
+
const head = outboundResult.strips.slice(0, 2).map(s => path.basename(s.path)).join(', ');
|
|
1200
|
+
const more = outboundResult.strips.length > 2 ? ` +${outboundResult.strips.length - 2} more` : '';
|
|
1201
|
+
process.stderr.write(`${col.d(tsOut)} ${col.r('๐ outbound-deny')} ${col.d(`stripped: ${head}${more}`)}\n`);
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
1205
|
+
|
|
1206
|
+
// โโ Path-2 defense: outbound secret redaction (symmetric with the
|
|
1207
|
+
// path-1 TRANSFORM redact-secrets). Fires when
|
|
1208
|
+
// redact_secrets_in_tool_results OR block_secrets_in_tool_results is
|
|
1209
|
+
// true. Always redacts (never request-blocks) โ see outbound-policy.js
|
|
1210
|
+
// for the design rationale. One TRANSFORM audit row per redacted
|
|
1211
|
+
// tool_result, attributed to the source tool_use when paired.
|
|
1212
|
+
const secretResult = enforceOutboundSecretRedaction(b, policyForOutbound);
|
|
1213
|
+
if (secretResult.redactions.length > 0) {
|
|
1214
|
+
b.messages = secretResult.messages;
|
|
1215
|
+
const { makeBoundaryEvent: mkBE } = require('./core/boundary-event');
|
|
1216
|
+
for (const r of secretResult.redactions) {
|
|
1217
|
+
const canonicalName =
|
|
1218
|
+
/^(Read|read_file)$/i.test(r.toolName || '') ? 'read_file' :
|
|
1219
|
+
/^(Glob|find_files)$/i.test(r.toolName || '') ? 'find_files' :
|
|
1220
|
+
/^(Grep|grep)$/i.test(r.toolName || '') ? 'grep' :
|
|
1221
|
+
/^(Bash|shell_bash)$/i.test(r.toolName || '') ? 'shell_bash' :
|
|
1222
|
+
/^(PowerShell|shell_powershell)$/i.test(r.toolName || '') ? 'shell_powershell' :
|
|
1223
|
+
(r.toolName || 'tool_result');
|
|
1224
|
+
const evt = mkBE({
|
|
1225
|
+
direction: 'outbound', kind: 'tool_call',
|
|
1226
|
+
agent: 'claude-code', protocol: 'anthropic-http',
|
|
1227
|
+
sessionId: undefined, runId: currentRunId,
|
|
1228
|
+
toolName: canonicalName,
|
|
1229
|
+
toolInput: r.path ? { file_path: r.path, path: r.path } : undefined,
|
|
1230
|
+
});
|
|
1231
|
+
const status = sessionAuditor.record(
|
|
1232
|
+
evt,
|
|
1233
|
+
{ action: 'TRANSFORM',
|
|
1234
|
+
reason: r.reason,
|
|
1235
|
+
transform: 'redact-secrets',
|
|
1236
|
+
policySource: policyForOutbound.deny_patterns?.length ? 'user' : 'default' },
|
|
1237
|
+
{ transformed: true, secretsRedacted: new Array(r.secretsRedacted) },
|
|
1238
|
+
);
|
|
1239
|
+
if (status && status.ok === false) {
|
|
1240
|
+
const { AuditWriteError } = require('./audit/errors');
|
|
1241
|
+
throw new AuditWriteError(status.error, status.droppedRow);
|
|
1242
|
+
}
|
|
1243
|
+
}
|
|
1244
|
+
if (LIVE_VERBOSE) {
|
|
1245
|
+
const tsOut = new Date().toTimeString().slice(0, 8);
|
|
1246
|
+
const total = secretResult.redactions.reduce((s, r) => s + r.secretsRedacted, 0);
|
|
1247
|
+
process.stderr.write(`${col.d(tsOut)} ${col.r('๐ outbound-secrets')} ${col.d(`${total} secret(s) redacted in ${secretResult.redactions.length} tool_result(s)`)}\n`);
|
|
1248
|
+
}
|
|
1249
|
+
}
|
|
1250
|
+
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
1251
|
+
|
|
1252
|
+
// โโ Path-2 defense: outbound output shaping (distill-output +
|
|
1253
|
+
// max_output_tokens). Completes the path-1 โ path-2 symmetry trio
|
|
1254
|
+
// (deny_paths โ secrets โ shaping โ). One TRANSFORM audit row per
|
|
1255
|
+
// shaped tool_result with the steps that ran in `transform` and
|
|
1256
|
+
// saved-tokens recorded.
|
|
1257
|
+
const shapingResult = enforceOutboundShaping(b, policyForOutbound);
|
|
1258
|
+
if (shapingResult.shapings.length > 0) {
|
|
1259
|
+
b.messages = shapingResult.messages;
|
|
1260
|
+
const { makeBoundaryEvent: mkBE2 } = require('./core/boundary-event');
|
|
1261
|
+
for (const sh of shapingResult.shapings) {
|
|
1262
|
+
const canonicalName =
|
|
1263
|
+
/^(Read|read_file)$/i.test(sh.toolName || '') ? 'read_file' :
|
|
1264
|
+
/^(Glob|find_files)$/i.test(sh.toolName || '') ? 'find_files' :
|
|
1265
|
+
/^(Grep|grep)$/i.test(sh.toolName || '') ? 'grep' :
|
|
1266
|
+
/^(Bash|shell_bash)$/i.test(sh.toolName || '') ? 'shell_bash' :
|
|
1267
|
+
/^(PowerShell|shell_powershell)$/i.test(sh.toolName || '') ? 'shell_powershell' :
|
|
1268
|
+
(sh.toolName || 'tool_result');
|
|
1269
|
+
const transformName = sh.reasons.length > 1
|
|
1270
|
+
? sh.reasons.join('+')
|
|
1271
|
+
: sh.reasons[0] || 'distill-output';
|
|
1272
|
+
const evt = mkBE2({
|
|
1273
|
+
direction: 'outbound', kind: 'tool_call',
|
|
1274
|
+
agent: 'claude-code', protocol: 'anthropic-http',
|
|
1275
|
+
sessionId: undefined, runId: currentRunId,
|
|
1276
|
+
toolName: canonicalName,
|
|
1277
|
+
toolInput: sh.path ? { file_path: sh.path, path: sh.path } : undefined,
|
|
1278
|
+
});
|
|
1279
|
+
const status = sessionAuditor.record(
|
|
1280
|
+
evt,
|
|
1281
|
+
{ action: 'TRANSFORM',
|
|
1282
|
+
reason: 'outbound-shaping-' + sh.reasons.join('+'),
|
|
1283
|
+
transform: transformName,
|
|
1284
|
+
policySource: 'user' },
|
|
1285
|
+
{ transformed: true, savedTokens: sh.savedTokens, label: sh.label },
|
|
1286
|
+
);
|
|
1287
|
+
if (status && status.ok === false) {
|
|
1288
|
+
const { AuditWriteError } = require('./audit/errors');
|
|
1289
|
+
throw new AuditWriteError(status.error, status.droppedRow);
|
|
1290
|
+
}
|
|
1291
|
+
}
|
|
1292
|
+
if (LIVE_VERBOSE) {
|
|
1293
|
+
const tsOut = new Date().toTimeString().slice(0, 8);
|
|
1294
|
+
const total = shapingResult.shapings.reduce((s, r) => s + r.savedTokens, 0);
|
|
1295
|
+
process.stderr.write(`${col.d(tsOut)} ${col.r('๐ outbound-shaping')} ${col.d(`${shapingResult.shapings.length} tool_result(s) shaped, ~${total}t saved`)}\n`);
|
|
1296
|
+
}
|
|
1297
|
+
}
|
|
1298
|
+
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
1299
|
+
|
|
1300
|
+
// LAO: trim low-relevance file tool_results when context is large (>20k tokens)
|
|
1301
|
+
const lao = await optimizeContext(b, process.cwd());
|
|
1302
|
+
if (lao.tokensSaved > 0) {
|
|
1303
|
+
b.messages = lao.messages;
|
|
1304
|
+
laoSaved = lao.tokensSaved;
|
|
1305
|
+
laoDropped = lao.filesDropped;
|
|
1306
|
+
}
|
|
1307
|
+
forwardBody = Buffer.from(JSON.stringify(b));
|
|
1308
|
+
outboundMessageCount = b.messages?.length ?? outboundMessageCount;
|
|
1309
|
+
} catch {}
|
|
1310
|
+
}
|
|
1311
|
+
if (laoDropped.length > 0) {
|
|
1312
|
+
const ts0 = new Date().toTimeString().slice(0, 8);
|
|
1313
|
+
const drop = laoDropped.slice(0, 3).join(', ') + (laoDropped.length > 3 ? ` +${laoDropped.length - 3} more` : '');
|
|
1314
|
+
const tStr = laoSaved >= 1000 ? `${(laoSaved / 1000).toFixed(1)}k` : String(laoSaved);
|
|
1315
|
+
if (LIVE_VERBOSE) process.stderr.write(`${col.d(ts0)} ${col.c('๐ฌ LAO')} ${col.d(`trimmed: ${drop} (${tStr} tokens saved)`)}\n`);
|
|
1316
|
+
}
|
|
1317
|
+
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
1318
|
+
|
|
1319
|
+
// โโ Pre-send manifest โ show what is about to leave the machine โโโโโโโโโโ
|
|
1320
|
+
if (isMsg) {
|
|
1321
|
+
let outboundTokensEst = 0;
|
|
1322
|
+
try {
|
|
1323
|
+
// Parse the post-LAO body to get a content-based token estimate.
|
|
1324
|
+
// Falls back to body-length / 5 (accounts for JSON framing overhead) if parsing fails.
|
|
1325
|
+
outboundTokensEst = roughTokensFromBody(JSON.parse(forwardBody.toString('utf8')));
|
|
1326
|
+
} catch { outboundTokensEst = Math.ceil(forwardBody.length / 5); }
|
|
1327
|
+
printPreSendManifest(fileTokens, outboundMessageCount, outboundTokensEst);
|
|
1328
|
+
}
|
|
1329
|
+
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
1330
|
+
|
|
1331
|
+
const hdrs = { ...req.headers, host: ANTHROPIC_REAL, 'content-length': forwardBody.length };
|
|
1332
|
+
delete hdrs['accept-encoding'];
|
|
1333
|
+
// Strip Occasio-internal routing header so it never leaves the machine.
|
|
1334
|
+
delete hdrs[AGENT_HEADER];
|
|
1335
|
+
const pr = https.request({ hostname: ANTHROPIC_REAL, port: 443, path: req.url, method: req.method, headers: hdrs }, pres => {
|
|
1336
|
+
const rc = []; pres.on('data', c => rc.push(c));
|
|
1337
|
+
pres.on('end', async () => {
|
|
1338
|
+
let rb = Buffer.concat(rc);
|
|
1339
|
+
let intercepted = false;
|
|
1340
|
+
let interceptResult = null;
|
|
1341
|
+
let toolsAttempted = 0;
|
|
1342
|
+
let fallbackReasons = [];
|
|
1343
|
+
|
|
1344
|
+
// โโ Interceptor: short-circuit local-safe tool_use โโโโโโโโโโโโโโโโโโโโโ
|
|
1345
|
+
if (isMsg && reqBody && (mode === 'intercept' || mode === 'hardened')) {
|
|
1346
|
+
try {
|
|
1347
|
+
const { adapter: selectedAdapter, agentId: selectedAgent, source: selectedSource } =
|
|
1348
|
+
selectAdapter(req.headers, rb);
|
|
1349
|
+
if (LIVE_VERBOSE && selectedAgent !== 'claude-code') {
|
|
1350
|
+
process.stderr.write(` [proxy] routed to ${selectedAgent} adapter (${selectedSource})\n`);
|
|
1351
|
+
}
|
|
1352
|
+
const result = await selectedAdapter.runToolLoop({
|
|
1353
|
+
initialSse: rb,
|
|
1354
|
+
reqBody,
|
|
1355
|
+
reqHeaders: req.headers,
|
|
1356
|
+
verbose: LIVE_VERBOSE,
|
|
1357
|
+
mode,
|
|
1358
|
+
todoStore: sessionTodoStore,
|
|
1359
|
+
// Every intercepted tool call produces one audit row.
|
|
1360
|
+
auditor: sessionAuditor,
|
|
1361
|
+
sessionId: currentRunId,
|
|
1362
|
+
runId: currentRunId,
|
|
1363
|
+
});
|
|
1364
|
+
toolsAttempted = result.toolsAttempted || 0;
|
|
1365
|
+
fallbackReasons = result.fallbackReasons || [];
|
|
1366
|
+
if (result.intercepted) {
|
|
1367
|
+
intercepted = true;
|
|
1368
|
+
interceptResult = result;
|
|
1369
|
+
rb = Buffer.from(JSON.stringify(result.response), 'utf8');
|
|
1370
|
+
if (LIVE_VERBOSE) {
|
|
1371
|
+
const ts = new Date().toTimeString().slice(0, 8);
|
|
1372
|
+
process.stderr.write('\n');
|
|
1373
|
+
for (const t of result.toolsRun) {
|
|
1374
|
+
const label = t.cmd.length > 52 ? t.cmd.slice(0, 52) + 'โฆ' : t.cmd;
|
|
1375
|
+
const tok = t.outputTokens ?? Math.ceil(t.bytes / 4);
|
|
1376
|
+
const tStr = tok >= 1000 ? `(${(tok / 1000).toFixed(1)}k tokens local)` : '';
|
|
1377
|
+
const dStr = t.distilled ? col.y(` โ ${t.distillLabel}`) : '';
|
|
1378
|
+
process.stderr.write(`${col.d(ts)} ${col.g('โก')} ${col.b(label)} ${col.d(tStr)}${dStr}\n`);
|
|
1379
|
+
}
|
|
1380
|
+
}
|
|
1381
|
+
} else if (result.partialIntercept) {
|
|
1382
|
+
// Mixed batch: pre-ran the interceptable subset, storing results for injection.
|
|
1383
|
+
// The original SSE response passes through to Claude Code unchanged.
|
|
1384
|
+
for (const pr of result.partialResults) {
|
|
1385
|
+
pendingToolInjections.set(pr.tool_use_id, pr.content);
|
|
1386
|
+
}
|
|
1387
|
+
if (LIVE_VERBOSE) {
|
|
1388
|
+
const ts = new Date().toTimeString().slice(0, 8);
|
|
1389
|
+
process.stderr.write('\n');
|
|
1390
|
+
for (const t of result.toolsRun) {
|
|
1391
|
+
const label = t.cmd.length > 52 ? t.cmd.slice(0, 52) + 'โฆ' : t.cmd;
|
|
1392
|
+
const tok = t.outputTokens ?? Math.ceil(t.bytes / 4);
|
|
1393
|
+
const tStr = tok >= 1000 ? `(${(tok / 1000).toFixed(1)}k tokens local)` : '';
|
|
1394
|
+
const dStr = t.distilled ? col.y(` โ ${t.distillLabel}`) : '';
|
|
1395
|
+
process.stderr.write(`${col.d(ts)} ${col.c('โก~')} ${col.b(label)} ${col.d(tStr)}${dStr}\n`);
|
|
1396
|
+
}
|
|
1397
|
+
}
|
|
1398
|
+
toolsAttempted = result.toolsAttempted || 0;
|
|
1399
|
+
fallbackReasons = result.fallbackReasons || [];
|
|
1400
|
+
interceptResult = result; // carry toolsRun for distill accounting in entry below
|
|
1401
|
+
} else if (result.fallbackReason) {
|
|
1402
|
+
const ts = new Date().toTimeString().slice(0, 8);
|
|
1403
|
+
let hint = '';
|
|
1404
|
+
if (result.fallbackReason === 'max rounds exceeded')
|
|
1405
|
+
hint = ' (interceptor limit reached โ Claude Code will handle it)';
|
|
1406
|
+
else if (result.fallbackReason.startsWith('tool not handled:'))
|
|
1407
|
+
hint = ' (Claude Code will handle it)';
|
|
1408
|
+
else if (result.fallbackReason.startsWith('mid-loop tool not handled:'))
|
|
1409
|
+
hint = ' (Claude Code will handle remaining rounds)';
|
|
1410
|
+
else if (result.fallbackReason.startsWith('Anthropic '))
|
|
1411
|
+
hint = ' (will retry via Claude)';
|
|
1412
|
+
else if (result.fallbackReason.startsWith('secret in tool result'))
|
|
1413
|
+
hint = ' (proxy scanner will handle it)';
|
|
1414
|
+
if (LIVE_VERBOSE) process.stderr.write(`${col.d(ts)} ${col.y('โฉ fallback:')} ${col.d(result.fallbackReason + hint)}\n`);
|
|
1415
|
+
// Counter-bug fix: if the fallback carries toolsRun (mid-loop
|
|
1416
|
+
// hit after some tools already ran successfully), preserve them
|
|
1417
|
+
// for the per-request log so the count isn't silently discarded.
|
|
1418
|
+
if (Array.isArray(result.toolsRun) && result.toolsRun.length) {
|
|
1419
|
+
interceptResult = result;
|
|
1420
|
+
}
|
|
1421
|
+
}
|
|
1422
|
+
} catch (e) {
|
|
1423
|
+
// v0.6.4: an audit-write failure is session-fatal. The dropped row
|
|
1424
|
+
// is logged to stderr (so a supervisor can recover it forensically),
|
|
1425
|
+
// the listening socket is closed so no further requests arrive, and
|
|
1426
|
+
// the process exits non-zero. The supervisor templates under
|
|
1427
|
+
// bin/supervisor/ restart us. This trades a graceful degradation
|
|
1428
|
+
// for the stronger guarantee that no successful tool call ever
|
|
1429
|
+
// exists without a matching audit row.
|
|
1430
|
+
if (e && e.name === 'AuditWriteError') {
|
|
1431
|
+
const dropped = e.droppedRow ? JSON.stringify(e.droppedRow) : '(no row attached)';
|
|
1432
|
+
process.stderr.write(`\n${col.r('[occasio][audit-fatal]')} ${e.message}\n`);
|
|
1433
|
+
process.stderr.write(`${col.r('[occasio][audit-fatal] dropped row:')} ${dropped}\n`);
|
|
1434
|
+
process.stderr.write(`${col.r('[occasio][audit-fatal] proxy aborting; supervisor will restart.')}\n`);
|
|
1435
|
+
try { server && server.close && server.close(); } catch {}
|
|
1436
|
+
setTimeout(() => process.exit(1), 250);
|
|
1437
|
+
return;
|
|
1438
|
+
}
|
|
1439
|
+
process.stderr.write(` ${col.r('[interceptor error]')} ${e.message}\n`);
|
|
1440
|
+
}
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1443
|
+
if (isMsg) {
|
|
1444
|
+
let inp = 0, out = 0, cacheWrite = 0, cacheRead = 0;
|
|
1445
|
+
try {
|
|
1446
|
+
const rbStr = rb.toString();
|
|
1447
|
+
try {
|
|
1448
|
+
const rd = JSON.parse(rbStr);
|
|
1449
|
+
inp = rd.usage?.input_tokens || 0;
|
|
1450
|
+
out = rd.usage?.output_tokens || 0;
|
|
1451
|
+
cacheWrite = rd.usage?.cache_creation_input_tokens || 0;
|
|
1452
|
+
cacheRead = rd.usage?.cache_read_input_tokens || 0;
|
|
1453
|
+
} catch {
|
|
1454
|
+
for (const m of rbStr.matchAll(/data:\s*({[^\n]+})/g)) {
|
|
1455
|
+
try {
|
|
1456
|
+
const d = JSON.parse(m[1]);
|
|
1457
|
+
if (d.usage) {
|
|
1458
|
+
inp = d.usage.input_tokens || inp;
|
|
1459
|
+
out = d.usage.output_tokens || out;
|
|
1460
|
+
cacheWrite = d.usage.cache_creation_input_tokens || cacheWrite;
|
|
1461
|
+
cacheRead = d.usage.cache_read_input_tokens || cacheRead;
|
|
1462
|
+
}
|
|
1463
|
+
if (d.type === 'message_delta' && d.usage) out = d.usage.output_tokens || out;
|
|
1464
|
+
} catch {}
|
|
1465
|
+
}
|
|
1466
|
+
}
|
|
1467
|
+
} catch {}
|
|
1468
|
+
|
|
1469
|
+
// When the interceptor ran, Anthropic was billed for N calls:
|
|
1470
|
+
// call #1 โ initial tool_use round (toolCallUsage)
|
|
1471
|
+
// calls #2โฆN-1 โ middle rounds if tool chain > 2 deep (middleRoundsUsage)
|
|
1472
|
+
// call #N โ final response (inp/out from rb)
|
|
1473
|
+
// Sum all non-final rounds so the reported cost is accurate for any chain length.
|
|
1474
|
+
const toolCallUsage = intercepted ? (interceptResult?.toolCallUsage || { input_tokens: 0, output_tokens: 0 }) : null;
|
|
1475
|
+
const middleRoundsUsage = intercepted ? (interceptResult?.middleRoundsUsage || { input_tokens: 0, output_tokens: 0 }) : null;
|
|
1476
|
+
const allPriorTokensIn = (toolCallUsage?.input_tokens || 0) + (middleRoundsUsage?.input_tokens || 0);
|
|
1477
|
+
const allPriorTokensOut = (toolCallUsage?.output_tokens || 0) + (middleRoundsUsage?.output_tokens || 0);
|
|
1478
|
+
const toolCallCost = toolCallUsage ? calcCost(model, allPriorTokensIn, allPriorTokensOut) : 0;
|
|
1479
|
+
const cost = calcCost(model, inp, out, cacheWrite, cacheRead) + toolCallCost;
|
|
1480
|
+
const cacheSavings = calcCacheSavings(model, cacheRead);
|
|
1481
|
+
const allToolsRun = interceptResult?.toolsRun || [];
|
|
1482
|
+
const toolsMcpPrimary = allToolsRun.filter(t => t.mcpPath).length;
|
|
1483
|
+
const toolsLocalCount = allToolsRun.length - toolsMcpPrimary;
|
|
1484
|
+
const laoTokensSaved = laoSaved;
|
|
1485
|
+
const laoSavings = laoTokensSaved > 0 ? calcCost(model, laoTokensSaved, 0) : 0;
|
|
1486
|
+
|
|
1487
|
+
const entryTime = new Date();
|
|
1488
|
+
const ts = entryTime.toTimeString().slice(0, 8);
|
|
1489
|
+
const iso = entryTime.toISOString();
|
|
1490
|
+
const ms = model.replace('claude-', '').replace(/-\d{8}$/, '');
|
|
1491
|
+
const icon = intercepted ? col.g('๐ฆ') : (secrets.length || blocked.length) ? col.r('๐ค') : col.c('๐ค');
|
|
1492
|
+
const cacheStr = cacheRead > 0
|
|
1493
|
+
? col.d(` ยท cache ${(cacheRead / 1000).toFixed(1)}k (-$${cacheSavings.toFixed(4)})`)
|
|
1494
|
+
: '';
|
|
1495
|
+
|
|
1496
|
+
if (LIVE_VERBOSE) process.stderr.write(`\n${col.d(ts)} ${icon} ${col.y(`${inp.toLocaleString()} in / ${out.toLocaleString()} out`)} ${col.d(`$${cost.toFixed(4)} ยท ${ms}`)}${cacheStr}\n`);
|
|
1497
|
+
|
|
1498
|
+
// Level 2: secret with line number in terminal
|
|
1499
|
+
for (const sec of secrets) {
|
|
1500
|
+
process.stderr.write(` ${col.r(`โ ๏ธ ${sec.label} (line ${sec.line})`)}\n`);
|
|
1501
|
+
}
|
|
1502
|
+
|
|
1503
|
+
// Level 2b: secrets found in interceptor tool results (file reads / cmd output).
|
|
1504
|
+
// These never pass through the proxy-level scanner, so we scan them here.
|
|
1505
|
+
// In intercept mode they are forwarded โ this warning and log entry make it visible.
|
|
1506
|
+
// In block_secrets mode the interceptor already aborted before reaching here.
|
|
1507
|
+
const toolResultSecrets = intercepted ? (interceptResult?.secretsInResults || []) : [];
|
|
1508
|
+
for (const sec of toolResultSecrets) {
|
|
1509
|
+
process.stderr.write(` ${col.r(`โ ๏ธ ${sec.label} in tool result (line ${sec.line})`)}\n`);
|
|
1510
|
+
}
|
|
1511
|
+
|
|
1512
|
+
// Level 3: tool runs from interceptor
|
|
1513
|
+
const tools = interceptResult?.toolsRun || [];
|
|
1514
|
+
|
|
1515
|
+
const distillTokensSaved = tools.reduce((s, t) => s + (t.distillSaved || 0), 0);
|
|
1516
|
+
const distillSavings = distillTokensSaved > 0 ? calcCost(model, distillTokensSaved, 0) : 0;
|
|
1517
|
+
const secretsRedacted = tools.reduce((s, t) => s + (t.secretsRedacted || 0), 0);
|
|
1518
|
+
const toolsTransformed = tools.filter(t => t.transformed).length;
|
|
1519
|
+
|
|
1520
|
+
// Same-call payload counterfactual: what this request would have cost without
|
|
1521
|
+
// distillation and LAO trimming (same-call effect only, not cross-request compounding).
|
|
1522
|
+
// Prompt cache savings are NOT included โ shown separately as exact.
|
|
1523
|
+
const payloadCfCost = parseFloat((cost + distillSavings + laoSavings).toFixed(6));
|
|
1524
|
+
|
|
1525
|
+
const eventType = intercepted ? 'local_only'
|
|
1526
|
+
: interceptResult?.partialIntercept ? 'partial_intercept'
|
|
1527
|
+
: laoTokensSaved > 0 ? 'trimmed'
|
|
1528
|
+
: 'cloud_sent';
|
|
1529
|
+
const entry = {
|
|
1530
|
+
v: LOG_SCHEMA_VERSION,
|
|
1531
|
+
iso, ts, run_id: currentRunId,
|
|
1532
|
+
cwd: SESSION_CWD,
|
|
1533
|
+
event_type: eventType,
|
|
1534
|
+
model,
|
|
1535
|
+
input_tokens: inp,
|
|
1536
|
+
output_tokens: out,
|
|
1537
|
+
cache_write_tokens: cacheWrite,
|
|
1538
|
+
cache_read_tokens: cacheRead,
|
|
1539
|
+
cache_savings: parseFloat(cacheSavings.toFixed(6)),
|
|
1540
|
+
lao_tokens_saved: laoTokensSaved,
|
|
1541
|
+
lao_cost_saved: parseFloat(laoSavings.toFixed(6)),
|
|
1542
|
+
distill_tokens_saved: distillTokensSaved,
|
|
1543
|
+
distill_cost_saved: parseFloat(distillSavings.toFixed(6)),
|
|
1544
|
+
payload_counterfactual_cost: payloadCfCost,
|
|
1545
|
+
tools_local_count: toolsLocalCount,
|
|
1546
|
+
tools_mcp_count: toolsMcpPrimary,
|
|
1547
|
+
tools_attempted: toolsAttempted,
|
|
1548
|
+
fallback_reasons: fallbackReasons.length ? fallbackReasons : undefined,
|
|
1549
|
+
cost: parseFloat(cost.toFixed(6)),
|
|
1550
|
+
files,
|
|
1551
|
+
file_tokens: fileTokens,
|
|
1552
|
+
lao_dropped: laoDropped,
|
|
1553
|
+
outbound_message_count: outboundMessageCount || undefined,
|
|
1554
|
+
secrets: [...secrets, ...toolResultSecrets],
|
|
1555
|
+
secrets_redacted: secretsRedacted || undefined,
|
|
1556
|
+
tools_transformed: toolsTransformed || undefined,
|
|
1557
|
+
blocked,
|
|
1558
|
+
intercepted,
|
|
1559
|
+
tools,
|
|
1560
|
+
};
|
|
1561
|
+
writeLog(entry); updateSession(entry);
|
|
1562
|
+
sessionCost += cost;
|
|
1563
|
+
|
|
1564
|
+
// Budget warning: fires once when spend crosses 80 % of budget
|
|
1565
|
+
if (budget !== null && !budgetWarnFired) {
|
|
1566
|
+
const { warnNow, exceeded: nowExceeded, pct } = budgetStatus(sessionCost, budget);
|
|
1567
|
+
if (warnNow) {
|
|
1568
|
+
budgetWarnFired = true;
|
|
1569
|
+
const pctStr = Math.round(pct * 100);
|
|
1570
|
+
if (nowExceeded) {
|
|
1571
|
+
process.stderr.write(col.r(`\n ๐ซ Budget limit reached: $${sessionCost.toFixed(4)} of $${budget.toFixed(4)} โ next request will be blocked.\n`));
|
|
1572
|
+
} else {
|
|
1573
|
+
process.stderr.write(col.y(`\n โ Budget at ${pctStr}%: $${sessionCost.toFixed(4)} of $${budget.toFixed(4)}\n`));
|
|
1574
|
+
process.stderr.write(col.d(` Next request will be blocked at $${budget.toFixed(4)}. Reset: occasio clear\n`));
|
|
1575
|
+
}
|
|
1576
|
+
}
|
|
1577
|
+
}
|
|
1578
|
+
|
|
1579
|
+
// Persist raw distilled output so it can be inspected later
|
|
1580
|
+
const distilledTools = tools.filter(t => t.distilled && t.rawContent);
|
|
1581
|
+
for (const t of distilledTools) {
|
|
1582
|
+
writeDistilledRaw({
|
|
1583
|
+
iso, run_id: currentRunId,
|
|
1584
|
+
cmd: t.cmd, rawBytes: t.bytes,
|
|
1585
|
+
label: t.distillLabel,
|
|
1586
|
+
rawContent: t.rawContent,
|
|
1587
|
+
});
|
|
1588
|
+
}
|
|
1589
|
+
}
|
|
1590
|
+
|
|
1591
|
+
const rh = intercepted
|
|
1592
|
+
? { 'content-type': 'application/json' }
|
|
1593
|
+
: { ...pres.headers };
|
|
1594
|
+
if (!intercepted) { delete rh['content-encoding']; delete rh['transfer-encoding']; }
|
|
1595
|
+
rh['content-length'] = rb.length.toString();
|
|
1596
|
+
res.writeHead(intercepted ? 200 : pres.statusCode, rh);
|
|
1597
|
+
res.end(rb);
|
|
1598
|
+
});
|
|
1599
|
+
});
|
|
1600
|
+
pr.on('error', e => { res.writeHead(502); res.end(JSON.stringify({ error: e.message })); });
|
|
1601
|
+
pr.end(forwardBody);
|
|
1602
|
+
});
|
|
1603
|
+
});
|
|
1604
|
+
|
|
1605
|
+
server.on('error', e => {
|
|
1606
|
+
if (e.code === 'EADDRINUSE') {
|
|
1607
|
+
process.stderr.write(col.r(`\nโ Port ${PORT} already in use. Kill the old process first:\n`));
|
|
1608
|
+
process.stderr.write(col.d(` netstat -ano | findstr :${PORT} โ then: taskkill /PID <pid> /F\n\n`));
|
|
1609
|
+
} else {
|
|
1610
|
+
process.stderr.write(col.r(`\nโ ${e.message}\n`));
|
|
1611
|
+
}
|
|
1612
|
+
process.exit(1);
|
|
1613
|
+
});
|
|
1614
|
+
|
|
1615
|
+
server.listen(PORT, '127.0.0.1', () => {
|
|
1616
|
+
const env = { ...process.env, ANTHROPIC_BASE_URL: `http://localhost:${PORT}` };
|
|
1617
|
+
// On Windows, npm installs binaries as .cmd wrappers (claude.cmd).
|
|
1618
|
+
// spawn() without shell:true calls CreateProcess directly, which won't
|
|
1619
|
+
// execute .cmd files โ claude would silently fail to start.
|
|
1620
|
+
//
|
|
1621
|
+
// shell:true on Windows joins the args array with spaces and runs the
|
|
1622
|
+
// result through `cmd.exe /d /s /c "<line>"`. Node's auto-quoting is
|
|
1623
|
+
// unreliable for args containing spaces (the prompt passed via --print
|
|
1624
|
+
// gets truncated at the first space). Build the command line ourselves
|
|
1625
|
+
// with explicit quoting and pass a single pre-quoted string when
|
|
1626
|
+
// shell:true is in play.
|
|
1627
|
+
let cmdLine, spawnArgs;
|
|
1628
|
+
if (process.platform === 'win32') {
|
|
1629
|
+
const quote = (a) => /[\s"^&|<>]/.test(a)
|
|
1630
|
+
? `"${String(a).replace(/(\\*)"/g, '$1$1\\"').replace(/(\\+)$/, '$1$1')}"`
|
|
1631
|
+
: String(a);
|
|
1632
|
+
cmdLine = ['claude', ...claudeArgs.map(quote)].join(' ');
|
|
1633
|
+
spawnArgs = [];
|
|
1634
|
+
} else {
|
|
1635
|
+
cmdLine = 'claude';
|
|
1636
|
+
spawnArgs = claudeArgs;
|
|
1637
|
+
}
|
|
1638
|
+
const claude = spawn(cmdLine, spawnArgs, {
|
|
1639
|
+
env,
|
|
1640
|
+
stdio: ['inherit', 'inherit', 'inherit'],
|
|
1641
|
+
shell: process.platform === 'win32',
|
|
1642
|
+
});
|
|
1643
|
+
|
|
1644
|
+
claude.on('exit', code => {
|
|
1645
|
+
server.close();
|
|
1646
|
+
try {
|
|
1647
|
+
const s = JSON.parse(fs.readFileSync(SESSION_FILE, 'utf8'));
|
|
1648
|
+
const cacheSavX = s.cache_savings || 0;
|
|
1649
|
+
const laoSavX = s.lao_cost_saved || 0;
|
|
1650
|
+
const distSavX = s.distill_cost_saved || 0;
|
|
1651
|
+
const payloadX = laoSavX + distSavX;
|
|
1652
|
+
const { savings: contextX } =
|
|
1653
|
+
calcCompoundingSavings(s.run_id, s.log_file || getLogFile(), s.model || '');
|
|
1654
|
+
const totalSavX = payloadX + contextX + cacheSavX;
|
|
1655
|
+
const broaderCfX = (s.cost || 0) + totalSavX;
|
|
1656
|
+
const savedPctX = broaderCfX > 0.00001 ? Math.round(totalSavX / broaderCfX * 100) : 0;
|
|
1657
|
+
|
|
1658
|
+
process.stderr.write(col.b('\nโโโ Session โโโโโโโโโโโโโโโโโโโโโโโโโโโโ\n'));
|
|
1659
|
+
|
|
1660
|
+
// Headline
|
|
1661
|
+
if (totalSavX > 0.00001) {
|
|
1662
|
+
process.stderr.write(col.g(` Saved: $${totalSavX.toFixed(4)}`) +
|
|
1663
|
+
col.d(` (${savedPctX}% off โ would have cost $${broaderCfX.toFixed(4)})\n`));
|
|
1664
|
+
} else {
|
|
1665
|
+
process.stderr.write(col.d(` Saved: $0.0000 (no interceptable tool calls in this session yet)\n`));
|
|
1666
|
+
}
|
|
1667
|
+
process.stderr.write(col.y(` Cost: $${s.cost.toFixed(4)}\n`));
|
|
1668
|
+
|
|
1669
|
+
// Plain-English coverage. See status command for clamping rationale.
|
|
1670
|
+
const localCnt = s.tools_local_count || 0;
|
|
1671
|
+
const mcpCnt = s.tools_mcp_count || 0;
|
|
1672
|
+
const attempted = s.tools_attempted || 0;
|
|
1673
|
+
const totalLocal = localCnt + mcpCnt;
|
|
1674
|
+
const denom = Math.max(attempted, totalLocal);
|
|
1675
|
+
if (denom > 0) {
|
|
1676
|
+
const cpct = Math.round(totalLocal / denom * 100);
|
|
1677
|
+
const cColor = cpct >= 80 ? col.g : cpct >= 50 ? col.y : col.r;
|
|
1678
|
+
process.stderr.write(cColor(` Ran locally: ${totalLocal} of ${denom} tool calls (${cpct}%)\n`));
|
|
1679
|
+
}
|
|
1680
|
+
if (s.blocked) process.stderr.write(col.r(` Blocked: ${s.blocked} secrets\n`));
|
|
1681
|
+
if (s.secrets_redacted) process.stderr.write(col.c(` Redacted: ${s.secrets_redacted} secret${s.secrets_redacted !== 1 ? 's' : ''} in tool results\n`));
|
|
1682
|
+
if (s.tools_transformed) process.stderr.write(col.c(` Transforms: ${s.tools_transformed} tool result${s.tools_transformed !== 1 ? 's' : ''} shaped\n`));
|
|
1683
|
+
if (s.budget != null) {
|
|
1684
|
+
const pct = Math.min(999, Math.round((s.cost || 0) / s.budget * 100));
|
|
1685
|
+
const budgetStr = fmtBudget(s.cost || 0, s.budget);
|
|
1686
|
+
const budgetColor = pct >= 100 ? col.r : pct >= 80 ? col.y : col.g;
|
|
1687
|
+
process.stderr.write(budgetColor(` Budget: ${budgetStr}\n`));
|
|
1688
|
+
if (s.budget_exceeded_count) process.stderr.write(col.r(` BudgetBlk: ${s.budget_exceeded_count} request(s) blocked\n`));
|
|
1689
|
+
}
|
|
1690
|
+
|
|
1691
|
+
// Detail
|
|
1692
|
+
process.stderr.write(col.d(` โโโโ\n`));
|
|
1693
|
+
process.stderr.write(col.d(` Requests: ${s.requests} ยท ${(s.input_tokens/1000).toFixed(1)}k tokens in ยท ${(s.output_tokens/1000).toFixed(1)}k out\n`));
|
|
1694
|
+
if (totalSavX > 0.00001) {
|
|
1695
|
+
const parts = [];
|
|
1696
|
+
if (payloadX > 0.00001) parts.push(`$${payloadX.toFixed(4)} payload`);
|
|
1697
|
+
if (contextX > 0.00001) parts.push(`$${contextX.toFixed(4)} context`);
|
|
1698
|
+
if (cacheSavX > 0.00001) parts.push(`$${cacheSavX.toFixed(4)} cache`);
|
|
1699
|
+
if (parts.length) process.stderr.write(col.d(` Breakdown: ${parts.join(' + ')}\n`));
|
|
1700
|
+
}
|
|
1701
|
+
process.stderr.write('โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ\n\n');
|
|
1702
|
+
} catch {}
|
|
1703
|
+
process.exit(code || 0);
|
|
1704
|
+
});
|
|
1705
|
+
|
|
1706
|
+
claude.on('error', () => {
|
|
1707
|
+
process.stderr.write(col.r('\nโ claude not found โ is Claude Code installed?\n'));
|
|
1708
|
+
process.stderr.write(col.d(' Install: https://claude.ai/download or npm install -g @anthropic-ai/claude-code\n'));
|
|
1709
|
+
server.close(); process.exit(1);
|
|
1710
|
+
});
|
|
1711
|
+
});
|