@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
|
@@ -0,0 +1,518 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* ClaudeCodeAdapter — owns all Anthropic-HTTP and Claude-Code-tool-name
|
|
5
|
+
* knowledge. Every protocol-specific concern about Claude Code's wire format
|
|
6
|
+
* lives here.
|
|
7
|
+
*
|
|
8
|
+
* Stage 1 contract:
|
|
9
|
+
* - Wraps existing parseSSE / classifyBlock / interceptToolUse from the
|
|
10
|
+
* legacy interceptor module. Does NOT decompose them.
|
|
11
|
+
* - Translates between Claude Code's tool-block shape and the canonical
|
|
12
|
+
* BoundaryEvent shape.
|
|
13
|
+
*
|
|
14
|
+
* Stage 2+ will move SSE parsing, name canonicalization, and follow-up call
|
|
15
|
+
* logic in here as native code (not just wrappers around interceptor).
|
|
16
|
+
*
|
|
17
|
+
* Leak detector: any string literal 'Read'/'Glob'/'Bash'/etc. outside this
|
|
18
|
+
* module is a Stage 1 architecture leak. Tracked as a known leak; Stage 2
|
|
19
|
+
* introduces a canonical name registry.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const {
|
|
23
|
+
parseSSE,
|
|
24
|
+
classifyBlock,
|
|
25
|
+
isInterceptable,
|
|
26
|
+
buildFollowUpHeaders,
|
|
27
|
+
} = require('../interceptor');
|
|
28
|
+
const { makeBoundaryEvent } = require('../core/boundary-event');
|
|
29
|
+
const toolNames = require('../core/tool-names');
|
|
30
|
+
|
|
31
|
+
const AGENT = 'claude-code';
|
|
32
|
+
const PROTOCOL = 'anthropic-http';
|
|
33
|
+
|
|
34
|
+
// Claude Code → canonical tool-name map. Registered at adapter load so that
|
|
35
|
+
// any pipeline / policy / dispatcher / audit code that runs afterwards sees
|
|
36
|
+
// canonical names regardless of the originating agent.
|
|
37
|
+
toolNames.register(AGENT, {
|
|
38
|
+
Read: toolNames.CANONICAL.READ_FILE,
|
|
39
|
+
Glob: toolNames.CANONICAL.FIND_FILES,
|
|
40
|
+
Grep: toolNames.CANONICAL.GREP,
|
|
41
|
+
TodoWrite: toolNames.CANONICAL.TODO_WRITE,
|
|
42
|
+
TodoRead: toolNames.CANONICAL.TODO_READ,
|
|
43
|
+
Bash: toolNames.CANONICAL.SHELL_BASH,
|
|
44
|
+
PowerShell: toolNames.CANONICAL.SHELL_POWERSHELL,
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Translate a Claude tool block name to its canonical name.
|
|
49
|
+
* Falls back to the original name if unmapped (lets unknown tools flow
|
|
50
|
+
* through to the policy engine, which will PASS them).
|
|
51
|
+
*/
|
|
52
|
+
function canonicalNameOf(claudeBlockName) {
|
|
53
|
+
return toolNames.toCanonical(AGENT, claudeBlockName) || claudeBlockName;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Anthropic-specific outbound HTTP. Owned by the adapter because it is the
|
|
57
|
+
// only place the cloud-side wire format lives. Stage 1 keeps the underlying
|
|
58
|
+
// HTTPS call inside legacy `anthropicRequest`; the adapter wraps it so that
|
|
59
|
+
// `interceptToolUse` no longer reaches into Anthropic-specific HTTP directly.
|
|
60
|
+
const https = require('https');
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Forward an assembled follow-up body to Anthropic. Returns
|
|
64
|
+
* { status, body } where body is the parsed JSON response.
|
|
65
|
+
*
|
|
66
|
+
* @param {object} reqBody Anthropic /v1/messages body (with messages array)
|
|
67
|
+
* @param {object} authHeaders Caller's request headers (for auth + anthropic-version)
|
|
68
|
+
*/
|
|
69
|
+
function forwardToCloud(reqBody, authHeaders) {
|
|
70
|
+
return new Promise((resolve, reject) => {
|
|
71
|
+
const payload = JSON.stringify({ ...reqBody, stream: false });
|
|
72
|
+
const headers = buildFollowUpHeaders(authHeaders, Buffer.byteLength(payload));
|
|
73
|
+
const req = https.request(
|
|
74
|
+
{ hostname: 'api.anthropic.com', port: 443, path: '/v1/messages', method: 'POST', headers },
|
|
75
|
+
res => {
|
|
76
|
+
const chunks = [];
|
|
77
|
+
res.on('data', c => chunks.push(c));
|
|
78
|
+
res.on('end', () => {
|
|
79
|
+
try { resolve({ status: res.statusCode, body: JSON.parse(Buffer.concat(chunks).toString()) }); }
|
|
80
|
+
catch (e) { reject(e); }
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
);
|
|
84
|
+
req.on('error', reject);
|
|
85
|
+
req.end(payload);
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Parse an SSE response buffer and emit one BoundaryEvent per tool_use block.
|
|
91
|
+
* Wraps interceptor.parseSSE — does not reimplement.
|
|
92
|
+
*
|
|
93
|
+
* @param {Buffer} sseBuffer
|
|
94
|
+
* @param {object} [ctx] { sessionId, runId }
|
|
95
|
+
* @returns {object[]} BoundaryEvents (kind: 'tool_call', direction: 'inbound')
|
|
96
|
+
*/
|
|
97
|
+
function parseResponse(sseBuffer, ctx = {}) {
|
|
98
|
+
const parsed = parseSSE(sseBuffer);
|
|
99
|
+
const events = [];
|
|
100
|
+
const blocks = parsed.blocks || {};
|
|
101
|
+
for (const idx of Object.keys(blocks)) {
|
|
102
|
+
const block = blocks[idx];
|
|
103
|
+
if (!block || block.type !== 'tool_use') continue;
|
|
104
|
+
events.push(makeBoundaryEvent({
|
|
105
|
+
direction: 'inbound',
|
|
106
|
+
kind: 'tool_call',
|
|
107
|
+
agent: AGENT,
|
|
108
|
+
protocol: PROTOCOL,
|
|
109
|
+
sessionId: ctx.sessionId,
|
|
110
|
+
runId: ctx.runId,
|
|
111
|
+
// Stage 3: emit canonical names. The original Claude name is preserved
|
|
112
|
+
// in `raw` for any callers that need protocol-specific access.
|
|
113
|
+
toolName: canonicalNameOf(block.name),
|
|
114
|
+
toolInput: block.input,
|
|
115
|
+
raw: block,
|
|
116
|
+
}));
|
|
117
|
+
}
|
|
118
|
+
return events;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Parse one conversation turn from an Anthropic SSE buffer.
|
|
123
|
+
*
|
|
124
|
+
* Returned shape exposes both the legacy structural fields (blocks, stopReason,
|
|
125
|
+
* message) and a canonical events array. interceptToolUse needs all three
|
|
126
|
+
* during its current state-threading; the events array is what the pipeline
|
|
127
|
+
* consumes. Stage 2/D moves this into adapter-only territory.
|
|
128
|
+
*
|
|
129
|
+
* @param {Buffer} sseBuffer
|
|
130
|
+
* @param {object} [ctx] { sessionId, runId }
|
|
131
|
+
* @returns {{ blocks: object, stopReason: string|null, message: object|null, events: object[] }}
|
|
132
|
+
*/
|
|
133
|
+
function parseConversationTurn(sseBuffer, ctx = {}) {
|
|
134
|
+
const parsed = parseSSE(sseBuffer);
|
|
135
|
+
const events = [];
|
|
136
|
+
const blocks = parsed.blocks || {};
|
|
137
|
+
for (const idx of Object.keys(blocks)) {
|
|
138
|
+
const block = blocks[idx];
|
|
139
|
+
if (!block || block.type !== 'tool_use') continue;
|
|
140
|
+
events.push(makeBoundaryEvent({
|
|
141
|
+
direction: 'inbound',
|
|
142
|
+
kind: 'tool_call',
|
|
143
|
+
agent: AGENT,
|
|
144
|
+
protocol: PROTOCOL,
|
|
145
|
+
sessionId: ctx.sessionId,
|
|
146
|
+
runId: ctx.runId,
|
|
147
|
+
toolName: block.name,
|
|
148
|
+
toolInput: block.input,
|
|
149
|
+
raw: block,
|
|
150
|
+
}));
|
|
151
|
+
}
|
|
152
|
+
return {
|
|
153
|
+
blocks: parsed.blocks,
|
|
154
|
+
stopReason: parsed.stopReason,
|
|
155
|
+
message: parsed.message,
|
|
156
|
+
events,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Build a Claude-protocol tool_use block from a BoundaryEvent.
|
|
162
|
+
* Used by adapter.classify (and other code that needs to consult
|
|
163
|
+
* `interceptor.classifyBlock`, which still uses Claude protocol names).
|
|
164
|
+
*
|
|
165
|
+
* Translates canonical → Claude name via the registry.
|
|
166
|
+
*/
|
|
167
|
+
function eventToToolBlock(event) {
|
|
168
|
+
const claudeName = toolNames.toAgentName(AGENT, event.toolName) || event.toolName;
|
|
169
|
+
return {
|
|
170
|
+
type: 'tool_use',
|
|
171
|
+
id: event.id,
|
|
172
|
+
name: claudeName,
|
|
173
|
+
input: event.toolInput,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Adapter-level classification helper. Delegates to existing classifyBlock.
|
|
179
|
+
* The policy engine consumes this, not BoundaryEvent internals directly.
|
|
180
|
+
*/
|
|
181
|
+
function classify(event) {
|
|
182
|
+
return classifyBlock(eventToToolBlock(event));
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Adapter-level interceptability check. Delegates to existing isInterceptable.
|
|
187
|
+
*/
|
|
188
|
+
function adapterIsInterceptable(event) {
|
|
189
|
+
return isInterceptable(eventToToolBlock(event));
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* runToolLoop — the canonical multi-round orchestration for an Anthropic
|
|
194
|
+
* conversation that contains tool_use turns.
|
|
195
|
+
*
|
|
196
|
+
* Owns:
|
|
197
|
+
* - parsing the initial SSE (parseConversationTurn)
|
|
198
|
+
* - per-round dispatch through the pipeline (runOneRound)
|
|
199
|
+
* - cross-round secret accumulation (scanToolResults)
|
|
200
|
+
* - follow-up calls to Anthropic (forwardToCloud)
|
|
201
|
+
* - token-usage threading (toolCallUsage / middleRoundsUsage)
|
|
202
|
+
* - partial-batch round-0 short-circuit
|
|
203
|
+
* - max-rounds guard
|
|
204
|
+
*
|
|
205
|
+
* Returns the same shape `interceptToolUse` returned previously — the contract
|
|
206
|
+
* is preserved exactly so that `interceptToolUse` is now a 4-line shim and
|
|
207
|
+
* `index.js` is unaffected.
|
|
208
|
+
*
|
|
209
|
+
* Stage 2 will collapse the inline secret-scan + verbose-pre-send-manifest
|
|
210
|
+
* code into pipeline TRANSFORM Decisions and observability sinks.
|
|
211
|
+
*/
|
|
212
|
+
async function runToolLoop({
|
|
213
|
+
initialSse, reqBody, reqHeaders,
|
|
214
|
+
maxRounds = 5, verbose = false, mode = 'intercept', todoStore = [],
|
|
215
|
+
auditor = null, sessionId, runId,
|
|
216
|
+
// Stage 3 multi-agent plumbing. Defaults preserve Claude Code behavior;
|
|
217
|
+
// a second adapter (e.g., cline) supplies its own _agent + _parser to
|
|
218
|
+
// route the same loop body through its own protocol-specific parsing.
|
|
219
|
+
_agent = AGENT,
|
|
220
|
+
_parser = parseConversationTurn,
|
|
221
|
+
}) {
|
|
222
|
+
// Lazy-require interceptor helpers (cyclic-require — interceptor depends on
|
|
223
|
+
// this adapter, so we resolve at call time, not module-load time).
|
|
224
|
+
const fs = require('fs');
|
|
225
|
+
const path = require('path');
|
|
226
|
+
const {
|
|
227
|
+
classifyBlock, isInterceptable,
|
|
228
|
+
blocksToContent, runOneRound,
|
|
229
|
+
scanToolResults, FALLBACK_REASONS,
|
|
230
|
+
} = require('../interceptor');
|
|
231
|
+
|
|
232
|
+
const { blocks: initialBlocks, stopReason: initialStop, message: initialMessage } =
|
|
233
|
+
_parser(initialSse, { sessionId, runId });
|
|
234
|
+
|
|
235
|
+
if (verbose) {
|
|
236
|
+
const os = require('os');
|
|
237
|
+
const dbg = {
|
|
238
|
+
ts: new Date().toTimeString().slice(0, 8),
|
|
239
|
+
stopReason: initialStop,
|
|
240
|
+
blocks: Object.keys(initialBlocks).length,
|
|
241
|
+
bodyLen: initialSse.length,
|
|
242
|
+
preview: initialSse.toString('utf8').slice(0, 200),
|
|
243
|
+
};
|
|
244
|
+
fs.appendFileSync(path.join(os.homedir(), '.occasio', 'interceptor-debug.log'), JSON.stringify(dbg) + '\n');
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (initialStop !== 'tool_use') return { intercepted: false, toolsAttempted: 0, fallbackReasons: [] };
|
|
248
|
+
|
|
249
|
+
const toolCallUsage = {
|
|
250
|
+
input_tokens: initialMessage?.usage?.input_tokens ?? 0,
|
|
251
|
+
output_tokens: initialMessage?.usage?.output_tokens ?? 0,
|
|
252
|
+
};
|
|
253
|
+
const savedInputTokens = toolCallUsage.input_tokens;
|
|
254
|
+
|
|
255
|
+
const initialToolBlocks = Object.values(initialBlocks).filter(b => b.type === 'tool_use');
|
|
256
|
+
if (!initialToolBlocks.length) return { intercepted: false, toolsAttempted: 0, fallbackReasons: [] };
|
|
257
|
+
|
|
258
|
+
// Stage 3: classify via the policy engine + canonical-name registry rather
|
|
259
|
+
// than the Claude-specific classifyBlock. For Claude Code, this produces
|
|
260
|
+
// identical results (the default policy reproduces classifyBlock's output);
|
|
261
|
+
// for Cline / future agents, it correctly recognizes their tool calls.
|
|
262
|
+
//
|
|
263
|
+
// Slice E (BLOCK enforcement): "handled by the pipeline" means LOCAL, BLOCK,
|
|
264
|
+
// or TRANSFORM — every action the dispatcher knows how to terminate locally.
|
|
265
|
+
// Only PASS (and unregistered tool names) fall through to the cloud. Without
|
|
266
|
+
// this distinction a BLOCK Decision silently degraded to a cloud passthrough,
|
|
267
|
+
// so deny_paths / deny_patterns / secret-block events never produced an
|
|
268
|
+
// audit-log BLOCK row or a synthetic refusal to the agent.
|
|
269
|
+
const policyEng = require('../policy/engine');
|
|
270
|
+
const classifyForAgent = (b) => {
|
|
271
|
+
const canonical = toolNames.toCanonical(_agent, b.name);
|
|
272
|
+
if (!canonical) return { handled: false, reason: 'tool_not_handled', action: 'PASS' };
|
|
273
|
+
const ev = makeBoundaryEvent({
|
|
274
|
+
direction: 'inbound', kind: 'tool_call',
|
|
275
|
+
agent: _agent, protocol: PROTOCOL,
|
|
276
|
+
toolName: canonical, toolInput: b.input,
|
|
277
|
+
});
|
|
278
|
+
const dec = policyEng.evaluate(ev);
|
|
279
|
+
return { handled: dec.action !== 'PASS', reason: dec.reason, action: dec.action };
|
|
280
|
+
};
|
|
281
|
+
const initialClassified = initialToolBlocks.map(b => classifyForAgent(b));
|
|
282
|
+
const allHandled = initialClassified.every(c => c.handled);
|
|
283
|
+
const someHandled = !allHandled && initialClassified.some(c => c.handled);
|
|
284
|
+
const partialBatch = someHandled;
|
|
285
|
+
// BLOCK is "handled" but is *not* a fallback — exclude it from unhandled
|
|
286
|
+
// bookkeeping so debug logs and fallback_reason strings don't misattribute
|
|
287
|
+
// an enforced refusal as "tool not handled, passing through to cloud."
|
|
288
|
+
const unhandledNames = initialToolBlocks.filter((_, i) => !initialClassified[i].handled).map(b => b.name);
|
|
289
|
+
const uniqueReasons = [...new Set(initialClassified.filter(c => !c.handled).map(c => c.reason))];
|
|
290
|
+
|
|
291
|
+
if (!allHandled) {
|
|
292
|
+
if (verbose) {
|
|
293
|
+
const os = require('os');
|
|
294
|
+
const allNames = initialToolBlocks.map(b => b.name);
|
|
295
|
+
const handledNames = initialToolBlocks.filter((_, i) => initialClassified[i].handled).map(b => b.name);
|
|
296
|
+
const unhandledCmds = initialToolBlocks
|
|
297
|
+
.filter((_, i) => !initialClassified[i].handled)
|
|
298
|
+
.filter(b => b.name === 'Bash' || b.name === 'PowerShell')
|
|
299
|
+
.map(b => (b.input?.command || '').trim())
|
|
300
|
+
.filter(Boolean);
|
|
301
|
+
fs.appendFileSync(
|
|
302
|
+
path.join(os.homedir(), '.occasio', 'interceptor-debug.log'),
|
|
303
|
+
JSON.stringify({
|
|
304
|
+
ts: new Date().toTimeString().slice(0, 8),
|
|
305
|
+
fallback: partialBatch ? 'partial batch' : 'tool not handled',
|
|
306
|
+
allNames, unhandled: unhandledNames,
|
|
307
|
+
...(partialBatch ? { handled: handledNames } : {}),
|
|
308
|
+
reasons: uniqueReasons,
|
|
309
|
+
...(unhandledCmds.length ? { cmds: unhandledCmds } : {}),
|
|
310
|
+
}) + '\n',
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
if (!partialBatch) {
|
|
314
|
+
return {
|
|
315
|
+
intercepted: false,
|
|
316
|
+
toolsAttempted: initialToolBlocks.length,
|
|
317
|
+
fallbackReasons: uniqueReasons,
|
|
318
|
+
fallbackReason: `tool not handled: ${[...new Set(unhandledNames)].join(', ')}`,
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// toolsAttempted accumulates across rounds so the "Ran locally: X of Y"
|
|
324
|
+
// invariant (numerator ≤ denominator) holds for multi-round sessions.
|
|
325
|
+
// Round 0 = initialToolBlocks.length (includes any unhandled in mixed batches).
|
|
326
|
+
// Round 1+ = each round's toolBlocks.length (all interceptable since
|
|
327
|
+
// mid-loop unhandled bails out before reaching dispatch).
|
|
328
|
+
let toolsAttempted = initialToolBlocks.length;
|
|
329
|
+
const round0Blocks = partialBatch
|
|
330
|
+
? initialToolBlocks.filter((_, i) => initialClassified[i].handled)
|
|
331
|
+
: null;
|
|
332
|
+
|
|
333
|
+
const toolsRun = [];
|
|
334
|
+
const allSecretsInResults = [];
|
|
335
|
+
let messages = reqBody.messages.slice();
|
|
336
|
+
let curBlocks = initialBlocks;
|
|
337
|
+
const middleRoundsUsage = { input_tokens: 0, output_tokens: 0 };
|
|
338
|
+
|
|
339
|
+
for (let round = 0; round < maxRounds; round++) {
|
|
340
|
+
let toolBlocks;
|
|
341
|
+
if (round === 0 && round0Blocks) {
|
|
342
|
+
toolBlocks = round0Blocks;
|
|
343
|
+
} else {
|
|
344
|
+
toolBlocks = Object.values(curBlocks).filter(b => b.type === 'tool_use');
|
|
345
|
+
// Stage 3: mid-loop interceptability check is also registry+policy-driven.
|
|
346
|
+
const midClassified = toolBlocks.map(b => classifyForAgent(b));
|
|
347
|
+
if (!midClassified.every(c => c.handled)) {
|
|
348
|
+
const midReasons = [...new Set(midClassified.filter(c => !c.handled).map(c => c.reason))];
|
|
349
|
+
return {
|
|
350
|
+
intercepted: false,
|
|
351
|
+
toolsAttempted,
|
|
352
|
+
fallbackReasons: midReasons,
|
|
353
|
+
fallbackReason: `mid-loop tool not handled: ${midReasons.join(', ')}`,
|
|
354
|
+
// Counter-bug fix: surface tools that ran in earlier rounds so the
|
|
355
|
+
// proxy's per-request log credits them. Without this, a fallback
|
|
356
|
+
// hit on round N silently discards the toolsRun from rounds 0..N-1.
|
|
357
|
+
toolsRun,
|
|
358
|
+
secretsInResults: allSecretsInResults,
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
// Round > 0: every block here is interceptable and will be dispatched.
|
|
362
|
+
// Add its count so toolsAttempted spans all rounds, matching toolsRun.
|
|
363
|
+
toolsAttempted += toolBlocks.length;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
if (!partialBatch) {
|
|
367
|
+
const assistantContent = blocksToContent(curBlocks);
|
|
368
|
+
messages = [...messages, { role: 'assistant', content: assistantContent }];
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const _round = await runOneRound(toolBlocks, {
|
|
372
|
+
mode, todoStore, sessionId, runId, auditor, verbose,
|
|
373
|
+
agent: _agent,
|
|
374
|
+
});
|
|
375
|
+
const toolResults = _round.toolResults;
|
|
376
|
+
if (_round.toolsRun.length) toolsRun.push(..._round.toolsRun);
|
|
377
|
+
if (_round.secrets.length) allSecretsInResults.push(..._round.secrets);
|
|
378
|
+
|
|
379
|
+
if (partialBatch && round === 0) {
|
|
380
|
+
if (verbose) {
|
|
381
|
+
process.stderr.write(
|
|
382
|
+
` [interceptor] partial batch: ran [${round0Blocks.map(b => b.name).join(', ')}], ` +
|
|
383
|
+
`passing through [${unhandledNames.join(', ')}]\n`
|
|
384
|
+
);
|
|
385
|
+
}
|
|
386
|
+
return {
|
|
387
|
+
intercepted: false,
|
|
388
|
+
partialIntercept: true,
|
|
389
|
+
partialResults: toolResults,
|
|
390
|
+
toolsRun,
|
|
391
|
+
toolsAttempted,
|
|
392
|
+
fallbackReasons: uniqueReasons,
|
|
393
|
+
fallbackReason: `partial: ${[...new Set(unhandledNames)].join(', ')} not handled`,
|
|
394
|
+
toolCallUsage,
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
messages = [...messages, { role: 'user', content: toolResults }];
|
|
399
|
+
|
|
400
|
+
// Stage 2: secret-scan-on-tool-results runs through the policy engine.
|
|
401
|
+
// The engine reads ~/.occasio/policy.yml and emits PASS or BLOCK.
|
|
402
|
+
// Legacy `mode === 'block_secrets'` semantics are preserved by passing
|
|
403
|
+
// the mode into the evaluator.
|
|
404
|
+
const policy = require('../policy/engine');
|
|
405
|
+
const resultsDecision = policy.evaluateToolResults(toolResults, { mode });
|
|
406
|
+
if (resultsDecision.secrets?.length) {
|
|
407
|
+
allSecretsInResults.push(...resultsDecision.secrets);
|
|
408
|
+
}
|
|
409
|
+
if (resultsDecision.action === 'BLOCK') {
|
|
410
|
+
if (verbose) {
|
|
411
|
+
const lbl = resultsDecision.secrets?.[0]?.label || 'unknown';
|
|
412
|
+
process.stderr.write(
|
|
413
|
+
` [interceptor] secret in tool result (${lbl}) — policy BLOCK, falling back to proxy scanner\n`
|
|
414
|
+
);
|
|
415
|
+
}
|
|
416
|
+
return {
|
|
417
|
+
intercepted: false,
|
|
418
|
+
toolsAttempted,
|
|
419
|
+
fallbackReasons: [FALLBACK_REASONS.SECRET_IN_RESULT],
|
|
420
|
+
fallbackReason: resultsDecision.reason,
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
if (verbose) {
|
|
425
|
+
const D = '\x1b[2m', C = '\x1b[36m', R = '\x1b[0m';
|
|
426
|
+
const ts = new Date().toTimeString().slice(0, 8);
|
|
427
|
+
let followUpChars = 0;
|
|
428
|
+
for (const msg of messages) {
|
|
429
|
+
const mc = msg.content;
|
|
430
|
+
if (typeof mc === 'string') {
|
|
431
|
+
followUpChars += mc.length;
|
|
432
|
+
} else if (Array.isArray(mc)) {
|
|
433
|
+
for (const b of mc) {
|
|
434
|
+
if (typeof b === 'string') followUpChars += b.length;
|
|
435
|
+
else if (typeof b.text === 'string') followUpChars += b.text.length;
|
|
436
|
+
else if (typeof b.content === 'string') followUpChars += b.content.length;
|
|
437
|
+
else if (Array.isArray(b.content)) {
|
|
438
|
+
for (const cb of b.content) followUpChars += (typeof cb === 'string' ? cb : cb.text || '').length;
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
const fEst = Math.ceil(followUpChars / 4);
|
|
444
|
+
const fStr = fEst > 0
|
|
445
|
+
? `~${fEst >= 1000 ? (fEst / 1000).toFixed(1) + 'kt' : fEst + 't'} · `
|
|
446
|
+
: '';
|
|
447
|
+
const roundTools = toolsRun.slice(toolsRun.length - toolBlocks.length);
|
|
448
|
+
const toolSummary = roundTools.map(t => {
|
|
449
|
+
const c = t.cmd.length > 32 ? t.cmd.slice(0, 32) + '…' : t.cmd;
|
|
450
|
+
let extra = '';
|
|
451
|
+
if (t.matchCount != null) extra = ` →${t.matchCount}`;
|
|
452
|
+
else if (t.outputTokens >= 1000) extra = ` ~${(t.outputTokens / 1000).toFixed(1)}kt`;
|
|
453
|
+
else if (t.outputTokens > 0) extra = ` ~${t.outputTokens}t`;
|
|
454
|
+
return `${t.tool}(${c})${extra}`;
|
|
455
|
+
}).join(' ');
|
|
456
|
+
const body = `${fStr}${messages.length} msgs${toolSummary ? ' · ' + toolSummary : ''}`;
|
|
457
|
+
process.stderr.write(`${D}${ts}${R} ${C}↑${R} ${D}${body}${R}\n`);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
const { status, body: nextBody } = await forwardToCloud(
|
|
461
|
+
{ ...reqBody, messages },
|
|
462
|
+
reqHeaders
|
|
463
|
+
);
|
|
464
|
+
|
|
465
|
+
if (status !== 200) {
|
|
466
|
+
if (verbose) process.stderr.write(` [interceptor] Anthropic ${status}, bailing\n`);
|
|
467
|
+
return {
|
|
468
|
+
intercepted: false,
|
|
469
|
+
toolsAttempted,
|
|
470
|
+
fallbackReasons: [FALLBACK_REASONS.API_ERROR],
|
|
471
|
+
fallbackReason: `Anthropic ${status} on follow-up`,
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
if (nextBody.stop_reason !== 'tool_use') {
|
|
476
|
+
return {
|
|
477
|
+
intercepted: true, response: nextBody, toolsRun,
|
|
478
|
+
toolsAttempted,
|
|
479
|
+
fallbackReasons: [],
|
|
480
|
+
savedInputTokens, toolCallUsage, middleRoundsUsage,
|
|
481
|
+
secretsInResults: allSecretsInResults,
|
|
482
|
+
};
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
middleRoundsUsage.input_tokens += nextBody.usage?.input_tokens || 0;
|
|
486
|
+
middleRoundsUsage.output_tokens += nextBody.usage?.output_tokens || 0;
|
|
487
|
+
|
|
488
|
+
curBlocks = {};
|
|
489
|
+
(nextBody.content || []).forEach((blk, i) => {
|
|
490
|
+
curBlocks[i] = {
|
|
491
|
+
type: blk.type,
|
|
492
|
+
id: blk.id || null,
|
|
493
|
+
name: blk.name || null,
|
|
494
|
+
text: blk.type === 'text' ? blk.text : '',
|
|
495
|
+
input: blk.type === 'tool_use' ? blk.input : null,
|
|
496
|
+
};
|
|
497
|
+
});
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
return {
|
|
501
|
+
intercepted: false,
|
|
502
|
+
toolsAttempted,
|
|
503
|
+
fallbackReasons: [FALLBACK_REASONS.MAX_ROUNDS],
|
|
504
|
+
fallbackReason: 'max rounds exceeded',
|
|
505
|
+
};
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
module.exports = {
|
|
509
|
+
AGENT,
|
|
510
|
+
PROTOCOL,
|
|
511
|
+
parseResponse,
|
|
512
|
+
parseConversationTurn,
|
|
513
|
+
eventToToolBlock,
|
|
514
|
+
classify,
|
|
515
|
+
isInterceptable: adapterIsInterceptable,
|
|
516
|
+
forwardToCloud,
|
|
517
|
+
runToolLoop,
|
|
518
|
+
};
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Cline adapter — second AI agent supported by Occasio.
|
|
5
|
+
*
|
|
6
|
+
* Cline (https://github.com/cline/cline) is a VS Code extension that uses
|
|
7
|
+
* the Anthropic Messages API for tool calls. Because both Cline and Claude
|
|
8
|
+
* Code speak Anthropic SSE, this adapter shares the multi-round protocol
|
|
9
|
+
* loop with `claude-code` and only contributes:
|
|
10
|
+
* 1. its tool name → canonical name mapping (registered with tool-names)
|
|
11
|
+
* 2. per-tool input shape translation (Cline's `path` → canonical `file_path`)
|
|
12
|
+
* 3. a thin runToolLoop wrapper that calls the Anthropic loop with its
|
|
13
|
+
* own parser and agent identity
|
|
14
|
+
*
|
|
15
|
+
* ### LIVE_VALIDATION_PENDING
|
|
16
|
+
*
|
|
17
|
+
* The mappings below are based on Cline's published source (2025/2026
|
|
18
|
+
* cline/cline repository). Live validation against a real Cline session
|
|
19
|
+
* routed through the proxy is required before this adapter is considered
|
|
20
|
+
* production-ready. Specific items to verify:
|
|
21
|
+
*
|
|
22
|
+
* - tool name strings (currently best-effort; Cline tool names occasionally
|
|
23
|
+
* change between releases)
|
|
24
|
+
* - input field names per tool (currently translated from public docs)
|
|
25
|
+
* - whether Cline routes through the Anthropic /v1/messages endpoint or
|
|
26
|
+
* a different path when a custom base URL is configured
|
|
27
|
+
*
|
|
28
|
+
* Until live-validated, do not document Cline support as a launched feature.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
const claudeCode = require('./claude-code');
|
|
32
|
+
const toolNames = require('../core/tool-names');
|
|
33
|
+
const { makeBoundaryEvent } = require('../core/boundary-event');
|
|
34
|
+
const { parseSSE } = require('../interceptor');
|
|
35
|
+
|
|
36
|
+
const AGENT = 'cline';
|
|
37
|
+
const PROTOCOL = 'anthropic-http';
|
|
38
|
+
|
|
39
|
+
// Cline → canonical tool-name map. Only the tools whose semantics map
|
|
40
|
+
// cleanly onto canonical handlers are registered here. Tools without a
|
|
41
|
+
// Occasio-native equivalent (write_to_file, browser_action, etc.) are
|
|
42
|
+
// left unmapped — they fall through to PASS via the policy engine.
|
|
43
|
+
toolNames.register(AGENT, {
|
|
44
|
+
read_file: toolNames.CANONICAL.READ_FILE, // identity name; input differs
|
|
45
|
+
search_files: toolNames.CANONICAL.GREP,
|
|
46
|
+
list_files: toolNames.CANONICAL.FIND_FILES,
|
|
47
|
+
execute_command: toolNames.CANONICAL.SHELL_BASH,
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Per-tool input transformers: agent shape → canonical shape.
|
|
52
|
+
* The canonical shape is currently equivalent to Claude Code's input shape
|
|
53
|
+
* (since the dispatcher's NATIVE_HANDLERS expect that). When a richer
|
|
54
|
+
* canonical shape is introduced (Stage 4+), update accordingly.
|
|
55
|
+
*
|
|
56
|
+
* Mappings, all subject to LIVE_VALIDATION_PENDING:
|
|
57
|
+
*
|
|
58
|
+
* read_file : { path } → { file_path }
|
|
59
|
+
* search_files : { regex, path?, file_pattern? }
|
|
60
|
+
* → { pattern, path?, glob? }
|
|
61
|
+
* list_files : { path, recursive? } → { pattern: `${path}/${recursive?'**\/*':'*'}` }
|
|
62
|
+
* execute_command : { command, requires_approval? }
|
|
63
|
+
* → { command }
|
|
64
|
+
*/
|
|
65
|
+
const INPUT_TRANSFORMERS = {
|
|
66
|
+
read_file: (input) => ({
|
|
67
|
+
file_path: input?.path || input?.file_path,
|
|
68
|
+
...(input && typeof input.offset === 'number' ? { offset: input.offset } : {}),
|
|
69
|
+
...(input && typeof input.limit === 'number' ? { limit: input.limit } : {}),
|
|
70
|
+
}),
|
|
71
|
+
|
|
72
|
+
search_files: (input) => ({
|
|
73
|
+
pattern: input?.regex || input?.pattern,
|
|
74
|
+
...(input?.path ? { path: input.path } : {}),
|
|
75
|
+
...(input?.file_pattern ? { glob: input.file_pattern } : {}),
|
|
76
|
+
}),
|
|
77
|
+
|
|
78
|
+
list_files: (input) => {
|
|
79
|
+
const base = (input?.path || '.').replace(/\\$/, '');
|
|
80
|
+
const recurse = input?.recursive === true || input?.recursive === 'true';
|
|
81
|
+
return { pattern: recurse ? `${base}/**/*` : `${base}/*` };
|
|
82
|
+
},
|
|
83
|
+
|
|
84
|
+
execute_command: (input) => ({
|
|
85
|
+
command: (input?.command || '').toString().trim(),
|
|
86
|
+
}),
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
function translateInput(clineToolName, input) {
|
|
90
|
+
const t = INPUT_TRANSFORMERS[clineToolName];
|
|
91
|
+
return t ? t(input || {}) : input;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Parse a Cline conversation turn (Anthropic SSE shape with Cline tool blocks).
|
|
96
|
+
* Returns the same `{ blocks, stopReason, message, events }` shape as
|
|
97
|
+
* `claudeCode.parseConversationTurn`, but:
|
|
98
|
+
* - block.input is rewritten in place to canonical shape (so downstream
|
|
99
|
+
* `runOneRound` / dispatcher / handlers see a uniform input)
|
|
100
|
+
* - events carry canonical toolNames AND canonical inputs
|
|
101
|
+
* - agent is 'cline'
|
|
102
|
+
*/
|
|
103
|
+
function parseConversationTurn(sseBuffer, ctx = {}) {
|
|
104
|
+
// Cline speaks Anthropic SSE, so the structural parse is identical to
|
|
105
|
+
// Claude Code's. Use parseSSE directly (no need to round-trip through
|
|
106
|
+
// claudeCode.parseConversationTurn just to build events we'd discard).
|
|
107
|
+
const parsed = parseSSE(sseBuffer);
|
|
108
|
+
|
|
109
|
+
// Rewrite each tool_use block's input from Cline's shape to canonical,
|
|
110
|
+
// and emit BoundaryEvents with canonical names + canonical inputs.
|
|
111
|
+
const events = [];
|
|
112
|
+
for (const idx of Object.keys(parsed.blocks || {})) {
|
|
113
|
+
const block = parsed.blocks[idx];
|
|
114
|
+
if (!block || block.type !== 'tool_use') continue;
|
|
115
|
+
// Preserve the original Cline-shape input on raw_input for diagnostics
|
|
116
|
+
// (e.g., audit / dashboard would want to surface Cline's actual fields).
|
|
117
|
+
block.raw_input = block.input;
|
|
118
|
+
block.input = translateInput(block.name, block.input);
|
|
119
|
+
const canonical = toolNames.toCanonical(AGENT, block.name);
|
|
120
|
+
if (!canonical) continue;
|
|
121
|
+
events.push(makeBoundaryEvent({
|
|
122
|
+
direction: 'inbound',
|
|
123
|
+
kind: 'tool_call',
|
|
124
|
+
agent: AGENT,
|
|
125
|
+
protocol: PROTOCOL,
|
|
126
|
+
sessionId: ctx.sessionId,
|
|
127
|
+
runId: ctx.runId,
|
|
128
|
+
toolName: canonical,
|
|
129
|
+
toolInput: block.input,
|
|
130
|
+
raw: block,
|
|
131
|
+
}));
|
|
132
|
+
}
|
|
133
|
+
return {
|
|
134
|
+
blocks: parsed.blocks,
|
|
135
|
+
stopReason: parsed.stopReason,
|
|
136
|
+
message: parsed.message,
|
|
137
|
+
events,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* runToolLoop — thin wrapper around claudeCode.runToolLoop that supplies
|
|
143
|
+
* Cline's parser and agent identity. Both adapters speak Anthropic API,
|
|
144
|
+
* so the multi-round logic is shared.
|
|
145
|
+
*/
|
|
146
|
+
async function runToolLoop(opts) {
|
|
147
|
+
return claudeCode.runToolLoop({
|
|
148
|
+
...opts,
|
|
149
|
+
_agent: AGENT,
|
|
150
|
+
_parser: parseConversationTurn,
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
module.exports = {
|
|
155
|
+
AGENT,
|
|
156
|
+
PROTOCOL,
|
|
157
|
+
parseConversationTurn,
|
|
158
|
+
runToolLoop,
|
|
159
|
+
// Exposed for tests + diagnostics; callers should not rely on the shape.
|
|
160
|
+
INPUT_TRANSFORMERS,
|
|
161
|
+
};
|