@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.
Files changed (92) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +10 -0
  3. package/README.md +216 -0
  4. package/bin/occasio-mcp.js +5 -0
  5. package/bin/occasio.js +2 -0
  6. package/bin/supervisor/README.md +90 -0
  7. package/bin/supervisor/com.occasio.proxy.plist.template +36 -0
  8. package/bin/supervisor/install-windows-task.ps1 +48 -0
  9. package/bin/supervisor/occasio.service +18 -0
  10. package/docs/AUDIT.md +120 -0
  11. package/docs/attest_verify.py +283 -0
  12. package/docs/audit_walker.py +65 -0
  13. package/docs/canonicalize.py +99 -0
  14. package/docs/compliance-mapping.md +93 -0
  15. package/docs/demos/mcp-block.md +148 -0
  16. package/docs/edr-calibration.md +73 -0
  17. package/docs/edr-demo.md +83 -0
  18. package/docs/python-verifier.md +74 -0
  19. package/docs/reference-pipeline.md +140 -0
  20. package/package.json +69 -0
  21. package/policy-templates/dev-default.yml +84 -0
  22. package/policy-templates/finance.yml +61 -0
  23. package/policy-templates/strict.yml +49 -0
  24. package/schemas/agent-attestation-v1.json +190 -0
  25. package/schemas/occasio-policy.schema.json +99 -0
  26. package/spec/agent-attestation/v1/README.md +137 -0
  27. package/src/adapters/claude-code.js +518 -0
  28. package/src/adapters/cline.js +161 -0
  29. package/src/adapters/computer-use-cli.js +198 -0
  30. package/src/adapters/computer-use.js +227 -0
  31. package/src/analyzer.js +170 -0
  32. package/src/anomaly/cli.js +143 -0
  33. package/src/anomaly/detectors/deny-rate.js +84 -0
  34. package/src/anomaly/detectors/file-read-volume.js +109 -0
  35. package/src/anomaly/detectors/secret-redact-rate.js +107 -0
  36. package/src/anomaly/detectors/unknown-tool-input.js +83 -0
  37. package/src/anomaly/index.js +169 -0
  38. package/src/attest/canonicalize.js +97 -0
  39. package/src/attest/index.js +355 -0
  40. package/src/attest/run-slice.js +57 -0
  41. package/src/attest/sign.js +186 -0
  42. package/src/attest/verify.js +192 -0
  43. package/src/audit/errors.js +21 -0
  44. package/src/audit/input-normalizer.js +121 -0
  45. package/src/audit/jsonl-auditor.js +178 -0
  46. package/src/audit/verifier.js +152 -0
  47. package/src/baseline.js +507 -0
  48. package/src/boundary.js +238 -0
  49. package/src/budget.js +42 -0
  50. package/src/classifier.js +115 -0
  51. package/src/context-budget.js +77 -0
  52. package/src/core/boundary-event.js +75 -0
  53. package/src/core/decision.js +61 -0
  54. package/src/core/pipeline.js +66 -0
  55. package/src/core/tool-names.js +105 -0
  56. package/src/dashboard.js +892 -0
  57. package/src/demo/README.md +31 -0
  58. package/src/demo/anomalies-demo.js +211 -0
  59. package/src/demo/attest-demo.js +198 -0
  60. package/src/distiller.js +155 -0
  61. package/src/embeddings.json +72 -0
  62. package/src/executor/dispatcher.js +230 -0
  63. package/src/harness.js +817 -0
  64. package/src/index.js +1711 -0
  65. package/src/inspect.js +329 -0
  66. package/src/interceptor.js +1198 -0
  67. package/src/lao.js +185 -0
  68. package/src/lao_prep.py +119 -0
  69. package/src/ledger.js +209 -0
  70. package/src/mcp-experiment.js +140 -0
  71. package/src/mcp-normalize.js +139 -0
  72. package/src/mcp-server.js +320 -0
  73. package/src/outbound-policy.js +433 -0
  74. package/src/policy/built-in-classifiers.js +78 -0
  75. package/src/policy/doctor.js +226 -0
  76. package/src/policy/engine.js +339 -0
  77. package/src/policy/init.js +153 -0
  78. package/src/policy/loader.js +448 -0
  79. package/src/policy/rules-default.js +36 -0
  80. package/src/policy/shell-path.js +135 -0
  81. package/src/policy/show.js +196 -0
  82. package/src/policy/validate.js +310 -0
  83. package/src/preflight/cli.js +164 -0
  84. package/src/preflight/miner.js +329 -0
  85. package/src/proxy/agent-router.js +93 -0
  86. package/src/redteam.js +428 -0
  87. package/src/replay.js +446 -0
  88. package/src/report/index.js +224 -0
  89. package/src/runtime.js +595 -0
  90. package/src/scanner/index.js +49 -0
  91. package/src/selftest.js +192 -0
  92. 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
+ };