@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,1198 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * interceptor.js — Short-circuits local-safe Bash tool_use calls.
5
+ *
6
+ * Flow:
7
+ * 1. Proxy receives full SSE body from Anthropic (stop_reason: tool_use)
8
+ * 2. interceptToolUse() parses the stream, checks every tool block is safe
9
+ * 3. Executes each command — via native JS handler when possible, else exec
10
+ * 4. Sends one follow-up /v1/messages to Anthropic with tool_results appended
11
+ * 5. Repeats if Anthropic responds with more tool_use (up to maxRounds)
12
+ * 6. Returns the final non-tool response — proxy sends it to Claude Code
13
+ *
14
+ * Native handlers cover:
15
+ * cat/bat/head/tail/type, Get-Content — file reads (cross-platform)
16
+ * ls/eza/exa/lsd, dir, Get-ChildItem — directory listing
17
+ * find -name, where/which — file/command search
18
+ * test -f/-e/-d, Test-Path — file existence checks
19
+ * rg, grep, git (read-only) — handled by exec via always_local list
20
+ *
21
+ * Each toolsRun entry now includes outputTokens for per-command dashboard display.
22
+ */
23
+
24
+ const fs = require('fs');
25
+ const path = require('path');
26
+ const { exec } = require('child_process');
27
+ const https = require('https');
28
+ const { routeLocally } = require('./classifier');
29
+ const { distill } = require('./distiller');
30
+ const { scanSecrets } = require('./analyzer');
31
+
32
+ const {
33
+ MAX_OUTPUT,
34
+ readFileNative,
35
+ isReadHandleable, handleReadTool,
36
+ isGlobHandleable, handleGlobTool, globToRegex,
37
+ isGrepHandleable, handleGrepTool,
38
+ isTodoHandleable, handleTodoWriteTool, handleTodoReadTool,
39
+ executeLocalTool,
40
+ } = require('./runtime');
41
+
42
+ // Canonical pipeline modules (Stage 1 architecture).
43
+ // Production tool dispatch for Read/Glob/Grep/TodoWrite/TodoRead now flows
44
+ // through pipeline.processToolEvent; Bash/PowerShell remain on the legacy
45
+ // nativeHandle path because their exec fallback is not yet a Decision shape.
46
+ const { makeBoundaryEvent } = require('./core/boundary-event');
47
+ const pipeline = require('./core/pipeline');
48
+ const toolNames = require('./core/tool-names');
49
+
50
+ // Maps Claude Code tool names → mcp-server names accepted by executeLocalTool
51
+ const MCP_TOOL_NAMES = { Read: 'read_file', Glob: 'find_files', Grep: 'grep' };
52
+
53
+ // ── Legacy export (kept for tests + external callers) ─────────────────────────
54
+
55
+ const LOCAL_BASH_CMDS = new Set([
56
+ 'grep', 'rg', 'find', 'ls', 'cat', 'head', 'tail',
57
+ 'wc', 'git', 'echo', 'pwd', 'which', 'stat', 'file', 'diff',
58
+ ]);
59
+
60
+ const SHELL_META = /[;&|`$<>\\]/;
61
+
62
+ // Used by isNativeHandleable only: excludes backslash so that Windows absolute
63
+ // paths (C:\Users\…, Get-Content C:\…) are not rejected before the native
64
+ // handler can resolve them via path.resolve(). The full SHELL_META (with \)
65
+ // is still used in isInterceptable for non-native paths.
66
+ const SHELL_COMPOSITION = /[;&|`$<>]/;
67
+
68
+ // PowerShell-specific composition guard — same as SHELL_COMPOSITION but without
69
+ // the dollar-sign, because $env:VAR is expanded by expandPsEnvVars() before
70
+ // this check runs. Any remaining $ after expansion (variable refs, subexpressions)
71
+ // is caught by a separate includes('$') test in isPowerShellNativeHandleable.
72
+ const PS_COMPOSITION = /[;&|`<>]/;
73
+
74
+ // Allowed display-only flags for `git log --oneline -N [flags…]`.
75
+ // Shared by isNativeHandleable, isBareGitReadOnly, and isGitCSegment.
76
+ const SAFE_GIT_LOG_FLAGS = /^(--no-color|--color|--no-decorate|--decorate(?:=\S+)?|--graph|--no-graph|--abbrev-commit|--no-abbrev-commit)$/;
77
+
78
+ // ── Fallback reason codes ──────────────────────────────────────────────────────
79
+ // Stable string constants used in log entries and coverage analysis.
80
+ // Kept in one place so analysis queries can join against them reliably.
81
+ const FALLBACK_REASONS = {
82
+ TOOL_NOT_HANDLED: 'tool_not_handled', // Edit, Write, Task, Skill, etc.
83
+ READ_UNSUPPORTED_TYPE: 'read_unsupported_type', // PDF, image, binary, pages param, bad input
84
+ GLOB_INJECTION: 'glob_injection_or_invalid', // injection chars or missing/invalid pattern
85
+ GREP_MULTILINE: 'grep_multiline', // multiline: true (rg -U)
86
+ GREP_INVALID_INPUT: 'grep_invalid_input', // missing/invalid pattern or bad field types
87
+ BASH_EMPTY_CMD: 'bash_empty_cmd', // empty command string
88
+ BASH_SHELL_META: 'bash_shell_meta', // pipe, redirect, backtick, semicolon, etc.
89
+ BASH_UNRECOGNIZED: 'bash_unrecognized', // classifier returned local=false
90
+ PS_SHELL_COMPOSITION: 'ps_shell_composition', // | ; & ` < > found after env expansion
91
+ PS_DOLLAR_AFTER_EXP: 'ps_dollar_after_expansion', // $ remains after $env: expansion
92
+ PS_NOT_NATIVE: 'ps_not_native', // expanded cmd not in native handler list
93
+ SECRET_IN_RESULT: 'secret_in_tool_result', // block_secrets mode + secret found
94
+ API_ERROR: 'api_error', // non-200 from Anthropic on follow-up
95
+ MAX_ROUNDS: 'max_rounds_exceeded', // hit maxRounds limit
96
+ };
97
+
98
+ // ── PowerShell env-var expansion ───────────────────────────────────────────────
99
+
100
+ /**
101
+ * Expand $env:VARNAME references in a PowerShell command string.
102
+ * Only expands the $env: namespace — other $ forms (variables, subexpressions)
103
+ * are left intact so isPowerShellNativeHandleable can reject them.
104
+ */
105
+ function expandPsEnvVars(s) {
106
+ return s.replace(/\$env:([A-Za-z_][A-Za-z0-9_]*)/gi, (_, name) => process.env[name] || '');
107
+ }
108
+
109
+ // ── Native file-system handlers ────────────────────────────────────────────────
110
+
111
+ function stripQuotes(s) {
112
+ return s.trim().replace(/^(['"])(.*)\1$/, '$2');
113
+ }
114
+
115
+ /**
116
+ * Parse flags and the final positional file/dir argument from command parts.
117
+ * Flags are parts that start with '-'; everything after the last flag is joined
118
+ * as the file path (handles paths with spaces if not quoted).
119
+ */
120
+ function parseFlagsAndPath(parts, startIdx = 1) {
121
+ let i = startIdx;
122
+ const flags = [];
123
+ while (i < parts.length && parts[i].startsWith('-')) {
124
+ flags.push(parts[i]);
125
+ // Consume the next token if this flag takes a value (e.g. -n 20)
126
+ if ((parts[i] === '-n' || parts[i] === '--lines') && i + 1 < parts.length && /^\d+$/.test(parts[i + 1])) {
127
+ flags.push(parts[i + 1]);
128
+ i++;
129
+ }
130
+ i++;
131
+ }
132
+ const filePart = stripQuotes(parts.slice(i).join(' '));
133
+ return { flags, filePart };
134
+ }
135
+
136
+ // ── D-2 compound-command helpers ───────────────────────────────────────────────
137
+ // Narrow support for &&-chains (Bash) and ;-chains (PowerShell) where every
138
+ // segment is either a safe git read-only command or a pure echo/Write-Output.
139
+ // Reject-whole-if-any-unknown: a single unrecognized segment blocks the batch.
140
+
141
+ function isEchoSegment(seg) {
142
+ if (!seg) return false;
143
+ const m = /^(?:echo|Write-Output|Write-Host)\s*(.*)$/i.exec(seg.trim());
144
+ if (!m) return false;
145
+ const arg = (m[1] || '').trim();
146
+ return !SHELL_COMPOSITION.test(arg) && !arg.includes('`');
147
+ }
148
+
149
+ // Bare git (no -C): `git status` or `git log --oneline -N [safe flags]`
150
+ function isBareGitReadOnly(seg) {
151
+ const s = seg.trim().replace(/\s+2>&1\s*$/, '').trim();
152
+ const parts = s.split(/\s+/);
153
+ if (parts[0]?.toLowerCase() !== 'git') return false;
154
+ if (parts[1] === 'status' && parts.length === 2) return true;
155
+ if (parts[1] === 'log' && parts[2] === '--oneline' && /^-\d+$/.test(parts[3] || '')) {
156
+ return parts.slice(4).every(f => SAFE_GIT_LOG_FLAGS.test(f));
157
+ }
158
+ return false;
159
+ }
160
+
161
+ // git -C <path> form (already covered by D-1)
162
+ function isGitCSegment(seg) {
163
+ const s = seg.trim().replace(/\s+2>&1\s*$/, '').trim();
164
+ if (SHELL_COMPOSITION.test(s)) return false;
165
+ const parts = s.split(/\s+/);
166
+ if (parts[0]?.toLowerCase() !== 'git') return false;
167
+ if (parts[1] !== '-C' || !parts[2]) return false;
168
+ const sub = parts[3];
169
+ if (sub === 'status' && parts.length === 4) return true;
170
+ if (sub === 'log' && parts[4] === '--oneline' && /^-\d+$/.test(parts[5] || '')) {
171
+ return parts.slice(6).every(f => SAFE_GIT_LOG_FLAGS.test(f));
172
+ }
173
+ return false;
174
+ }
175
+
176
+ // cd "<absolute-path>" — Bash cwd prefix (D-3)
177
+ // Only absolute paths; no shell meta after unquoting; no relative components.
178
+ function isCdSegment(seg) {
179
+ if (!seg) return false;
180
+ const m = /^cd\s+(.+)$/i.exec(seg.trim());
181
+ if (!m) return false;
182
+ const p = m[1].trim().replace(/^(['"])(.*)\1$/, '$2');
183
+ if (!/^(?:[A-Za-z]:\\|\\\\|\/)/.test(p)) return false; // absolute only
184
+ return !SHELL_COMPOSITION.test(p);
185
+ }
186
+
187
+ // Set-Location "<absolute-path>" — PowerShell cwd prefix (D-3)
188
+ function isSetLocationSegment(seg) {
189
+ if (!seg) return false;
190
+ const m = /^Set-Location\s+(.+)$/i.exec(seg.trim());
191
+ if (!m) return false;
192
+ const p = m[1].trim().replace(/^(['"])(.*)\1$/, '$2');
193
+ if (!/^(?:[A-Za-z]:\\|\\\\|\/)/.test(p)) return false; // absolute only
194
+ return !PS_COMPOSITION.test(p) && !p.includes('$');
195
+ }
196
+
197
+ function isCompoundSegment(seg) {
198
+ const s = seg.trim();
199
+ return s.length > 0 && (isEchoSegment(s) || isBareGitReadOnly(s) || isGitCSegment(s) || isCdSegment(s) || isSetLocationSegment(s));
200
+ }
201
+
202
+ // Returns true only if every segment of the chain is recognized and safe.
203
+ function isCompoundHandleable(cmd, sep) {
204
+ const segments = sep === '&&'
205
+ ? cmd.split(' && ')
206
+ : cmd.split(';').map(s => s.trim()).filter(Boolean);
207
+ return segments.length >= 2 && segments.every(isCompoundSegment);
208
+ }
209
+
210
+ // Execute each segment of an already-validated compound chain in sequence.
211
+ // Returns { output: string, exitCode: 0 } — segments are all read-only.
212
+ // cwd is tracked locally; process.cwd() is never mutated.
213
+ function runCompound(segments) {
214
+ const { execSync } = require('child_process');
215
+ const outputs = [];
216
+ let cwd = process.cwd(); // updated by cd/Set-Location segments
217
+ for (const seg of segments) {
218
+ const s = seg.trim();
219
+ if (!s) continue;
220
+
221
+ // cd / Set-Location — update tracked cwd, produce no output
222
+ if (isCdSegment(s) || isSetLocationSegment(s)) {
223
+ const m = /^(?:cd|Set-Location)\s+(.+)$/i.exec(s);
224
+ const rawPath = (m?.[1] || '').trim();
225
+ cwd = rawPath.replace(/^(['"])(.*)\1$/, '$2');
226
+ continue;
227
+ }
228
+
229
+ if (isEchoSegment(s)) {
230
+ const m = /^(?:echo|Write-Output|Write-Host)\s*(.*)$/i.exec(s);
231
+ const arg = (m?.[1] || '').trim().replace(/^(['"])(.*)\1$/, '$2');
232
+ outputs.push(arg);
233
+ continue;
234
+ }
235
+
236
+ if (isBareGitReadOnly(s)) {
237
+ const gitRaw = s.replace(/\s+2>&1\s*$/, '').trim();
238
+ const parts = gitRaw.split(/\s+/);
239
+ try {
240
+ if (parts[1] === 'status') {
241
+ const out = execSync('git status', {
242
+ cwd, timeout: 10000,
243
+ stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf8',
244
+ });
245
+ outputs.push(out.trimEnd());
246
+ } else {
247
+ const n = parseInt(parts[3].slice(1), 10);
248
+ const out = execSync(`git log --oneline -${n}`, {
249
+ cwd, timeout: 10000,
250
+ stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf8',
251
+ });
252
+ outputs.push(out.trimEnd());
253
+ }
254
+ } catch (e) {
255
+ outputs.push(((e.stdout || '') + (e.stderr || '')).toString().trimEnd() || e.message);
256
+ }
257
+ continue;
258
+ }
259
+
260
+ if (isGitCSegment(s)) {
261
+ const nr = nativeHandle(s);
262
+ outputs.push(nr ? nr.output : '');
263
+ continue;
264
+ }
265
+ }
266
+ return { output: outputs.join('\n'), exitCode: 0 };
267
+ }
268
+
269
+ /**
270
+ * Try to handle a command natively (no subprocess).
271
+ * Returns { output: string, exitCode: number } or null if not handled.
272
+ *
273
+ * Placing the native check BEFORE SHELL_META is intentional: it lets us handle
274
+ * - 'test -f file' (would be blocked by dangerous-flag -f in classifier)
275
+ * - Windows paths (backslash in 'Get-Content C:\path' would hit SHELL_META)
276
+ */
277
+ function nativeHandle(cmd) {
278
+ const raw = cmd.trim();
279
+
280
+ // Compound &&-chains (Bash) and ;-chains (PowerShell) — validated before dispatch.
281
+ if (raw.includes(' && ') && isCompoundHandleable(raw, '&&')) {
282
+ return runCompound(raw.split(' && '));
283
+ }
284
+ if (raw.includes(';') && isCompoundHandleable(raw, ';')) {
285
+ return runCompound(raw.split(';').map(s => s.trim()).filter(Boolean));
286
+ }
287
+
288
+ const parts = raw.split(/\s+/);
289
+ const head = parts[0];
290
+ const headL = head.toLowerCase();
291
+ const cwd = process.cwd();
292
+
293
+ // ── cat / bat / type [-n] <file> ──────────────────────────────────────────
294
+ if (headL === 'cat' || headL === 'bat' || headL === 'type') {
295
+ const { flags, filePart } = parseFlagsAndPath(parts);
296
+ if (!filePart) return null;
297
+ const abs = path.resolve(cwd, filePart);
298
+ try {
299
+ let content = readFileNative(abs);
300
+ if (flags.includes('-n')) {
301
+ content = content.split('\n')
302
+ .map((l, i) => `${String(i + 1).padStart(6)}\t${l}`)
303
+ .join('\n');
304
+ }
305
+ return { output: content, exitCode: 0 };
306
+ } catch {
307
+ return { output: `${headL}: ${filePart}: No such file or directory`, exitCode: 1 };
308
+ }
309
+ }
310
+
311
+ // ── Get-Content [-Path|-LiteralPath] <file> ───────────────────────────────
312
+ if (/^get-content$/i.test(head)) {
313
+ let idx = 1;
314
+ if (/^-(?:path|literalpath)$/i.test(parts[1])) idx = 2;
315
+ const filePart = stripQuotes(parts.slice(idx).join(' '));
316
+ if (!filePart) return null;
317
+ const abs = path.resolve(cwd, filePart);
318
+ try {
319
+ return { output: readFileNative(abs), exitCode: 0 };
320
+ } catch {
321
+ return { output: `Get-Content: Cannot find path '${filePart}'`, exitCode: 1 };
322
+ }
323
+ }
324
+
325
+ // ── head [-n N | -N] <file> ───────────────────────────────────────────────
326
+ if (headL === 'head') {
327
+ let n = 10;
328
+ let fileIdx = 1;
329
+ if (parts[1] === '-n' && /^\d+$/.test(parts[2])) { n = parseInt(parts[2]); fileIdx = 3; }
330
+ else if (/^-(\d+)$/.test(parts[1])) { n = parseInt(parts[1].slice(1)); fileIdx = 2; }
331
+ else if (/^-n(\d+)$/.test(parts[1])) { n = parseInt(parts[1].slice(2)); fileIdx = 2; }
332
+ const filePart = stripQuotes(parts.slice(fileIdx).join(' '));
333
+ if (!filePart) return null;
334
+ const abs = path.resolve(cwd, filePart);
335
+ try {
336
+ const lines = readFileNative(abs).split('\n').slice(0, n);
337
+ return { output: lines.join('\n'), exitCode: 0 };
338
+ } catch {
339
+ return { output: `head: ${filePart}: No such file or directory`, exitCode: 1 };
340
+ }
341
+ }
342
+
343
+ // ── tail [-n N | -N] <file> ───────────────────────────────────────────────
344
+ if (headL === 'tail') {
345
+ let n = 10;
346
+ let fileIdx = 1;
347
+ if (parts[1] === '-n' && /^\d+$/.test(parts[2])) { n = parseInt(parts[2]); fileIdx = 3; }
348
+ else if (/^-(\d+)$/.test(parts[1])) { n = parseInt(parts[1].slice(1)); fileIdx = 2; }
349
+ else if (/^-n(\d+)$/.test(parts[1])) { n = parseInt(parts[1].slice(2)); fileIdx = 2; }
350
+ const filePart = stripQuotes(parts.slice(fileIdx).join(' '));
351
+ if (!filePart) return null;
352
+ const abs = path.resolve(cwd, filePart);
353
+ try {
354
+ const lines = readFileNative(abs).split('\n');
355
+ return { output: lines.slice(-n).join('\n'), exitCode: 0 };
356
+ } catch {
357
+ return { output: `tail: ${filePart}: No such file or directory`, exitCode: 1 };
358
+ }
359
+ }
360
+
361
+ // ── ls / eza / exa / lsd [-flags] [dir] ──────────────────────────────────
362
+ if (['ls', 'eza', 'exa', 'lsd'].includes(headL)) {
363
+ let long = false, showAll = false;
364
+ let idx = 1;
365
+ while (idx < parts.length && parts[idx].startsWith('-')) {
366
+ const f = parts[idx];
367
+ if (f.includes('l')) long = true;
368
+ if (f.includes('a') || f.includes('A')) showAll = true;
369
+ idx++;
370
+ }
371
+ const dir = parts[idx] ? stripQuotes(parts.slice(idx).join(' ')) : '.';
372
+ const abs = path.resolve(cwd, dir);
373
+ try {
374
+ const entries = fs.readdirSync(abs, { withFileTypes: true });
375
+ const filtered = showAll ? entries : entries.filter(e => !e.name.startsWith('.'));
376
+ if (long) {
377
+ const lines = filtered.map(e => {
378
+ try {
379
+ const st = fs.statSync(path.join(abs, e.name));
380
+ const size = String(st.size).padStart(8);
381
+ const mtime = st.mtime.toISOString().slice(0, 16).replace('T', ' ');
382
+ const d = e.isDirectory() ? 'd' : '-';
383
+ return `${d}rw-r--r-- ${size} ${mtime} ${e.name}${e.isDirectory() ? '/' : ''}`;
384
+ } catch { return e.name; }
385
+ });
386
+ return { output: lines.join('\n'), exitCode: 0 };
387
+ }
388
+ const names = filtered.map(e => e.name + (e.isDirectory() ? '/' : '')).join(' ');
389
+ return { output: names || '(empty)', exitCode: 0 };
390
+ } catch {
391
+ return { output: `ls: cannot access '${dir}': No such file or directory`, exitCode: 1 };
392
+ }
393
+ }
394
+
395
+ // ── dir [dir] ─────────────────────────────────────────────────────────────
396
+ if (headL === 'dir') {
397
+ const dir = parts[1] ? stripQuotes(parts.slice(1).join(' ')) : '.';
398
+ const abs = path.resolve(cwd, dir);
399
+ try {
400
+ const entries = fs.readdirSync(abs, { withFileTypes: true });
401
+ const lines = entries.map(e =>
402
+ ` ${e.isDirectory() ? '<DIR> ' : ' '} ${e.name}`
403
+ );
404
+ return { output: `Directory of ${abs}\n\n${lines.join('\n')}`, exitCode: 0 };
405
+ } catch {
406
+ return { output: 'File Not Found', exitCode: 1 };
407
+ }
408
+ }
409
+
410
+ // ── Get-ChildItem [-Path|-LiteralPath] [dir] ──────────────────────────────
411
+ if (/^get-childitem$/i.test(head)) {
412
+ let idx = 1;
413
+ if (/^-(?:path|literalpath)$/i.test(parts[1])) idx = 2;
414
+ const dir = parts[idx] ? stripQuotes(parts.slice(idx).join(' ')) : '.';
415
+ const abs = path.resolve(cwd, dir);
416
+ try {
417
+ const entries = fs.readdirSync(abs, { withFileTypes: true });
418
+ const lines = entries.map(e => `${e.isDirectory() ? 'd-r--' : '-a---'} ${e.name}`);
419
+ return { output: lines.join('\n'), exitCode: 0 };
420
+ } catch {
421
+ return { output: `Get-ChildItem: Cannot find path '${dir}'`, exitCode: 1 };
422
+ }
423
+ }
424
+
425
+ // ── find <dir> -name <pattern> [-type f|d] ───────────────────────────────
426
+ if (headL === 'find') {
427
+ const nameIdx = parts.indexOf('-name');
428
+ if (nameIdx < 0) return null; // Only handle -name form natively
429
+ const searchDir = parts[1] ? stripQuotes(parts[1]) : '.';
430
+ const pattern = stripQuotes(parts[nameIdx + 1] || '*');
431
+ const typeIdx = parts.indexOf('-type');
432
+ const typeFilter = typeIdx >= 0 ? parts[typeIdx + 1] : null;
433
+ const abs = path.resolve(cwd, searchDir);
434
+ const re = new RegExp(
435
+ '^' + pattern
436
+ .replace(/[.+^${}()|[\]\\]/g, '\\$&')
437
+ .replace(/\*/g, '.*')
438
+ .replace(/\?/g, '.')
439
+ + '$',
440
+ 'i'
441
+ );
442
+ const SKIP = new Set(['node_modules', '.git', '__pycache__', '.venv', 'dist', 'build']);
443
+ const results = [];
444
+ function walk(dir) {
445
+ if (results.length >= 200) return;
446
+ try {
447
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
448
+ const full = path.join(dir, e.name);
449
+ if (e.isDirectory()) {
450
+ if (!SKIP.has(e.name)) walk(full);
451
+ } else if (!typeFilter || typeFilter === 'f') {
452
+ if (re.test(e.name)) {
453
+ const rel = path.relative(cwd, full).replace(/\\/g, '/');
454
+ results.push(rel.startsWith('.') ? rel : './' + rel);
455
+ }
456
+ }
457
+ }
458
+ } catch {}
459
+ }
460
+ walk(abs);
461
+ return { output: results.join('\n') || '', exitCode: 0 };
462
+ }
463
+
464
+ // ── test -f|-e|-d <file> ─────────────────────────────────────────────────
465
+ // NOTE: isNativeHandleable() catches 'test' BEFORE SHELL_META so that
466
+ // '-f' is not blocked by the dangerous-flag check in the classifier.
467
+ if (headL === 'test') {
468
+ const flag = parts[1];
469
+ if (!['-f', '-e', '-d'].includes(flag)) return null;
470
+ const filePart = stripQuotes(parts.slice(2).join(' '));
471
+ if (!filePart) return null;
472
+ const abs = path.resolve(cwd, filePart);
473
+ try {
474
+ const st = fs.statSync(abs);
475
+ const ok = flag === '-d' ? st.isDirectory() : flag === '-f' ? st.isFile() : true;
476
+ return { output: '', exitCode: ok ? 0 : 1 };
477
+ } catch {
478
+ return { output: '', exitCode: 1 };
479
+ }
480
+ }
481
+
482
+ // ── Test-Path [-Path|-LiteralPath] <file> ─────────────────────────────────
483
+ if (/^test-path$/i.test(head)) {
484
+ let idx = 1;
485
+ if (/^-(?:path|literalpath)$/i.test(parts[1])) idx = 2;
486
+ const filePart = stripQuotes(parts.slice(idx).join(' '));
487
+ if (!filePart) return null;
488
+ const abs = path.resolve(cwd, filePart);
489
+ let exists = false;
490
+ try { fs.statSync(abs); exists = true; } catch {}
491
+ return { output: exists ? 'True' : 'False', exitCode: exists ? 0 : 1 };
492
+ }
493
+
494
+ // ── Select-String [-Pattern] <pattern> [-Path] <file> ────────────────────
495
+ // Only handles single-file paths. Glob paths (* / ?) must fall back to
496
+ // the cloud because we cannot expand them without walking the filesystem
497
+ // in a way that stays consistent with how the Grep tool already handles this.
498
+ if (/^select-string$/i.test(head)) {
499
+ let pattern = null, filePart = null;
500
+ for (let i = 1; i < parts.length; i++) {
501
+ if (/^-pattern$/i.test(parts[i]) && i + 1 < parts.length) {
502
+ pattern = stripQuotes(parts[++i]);
503
+ } else if (/^-(?:path|literalpath)$/i.test(parts[i]) && i + 1 < parts.length) {
504
+ filePart = stripQuotes(parts[++i]);
505
+ } else if (!parts[i].startsWith('-')) {
506
+ if (pattern === null) pattern = stripQuotes(parts[i]);
507
+ else if (filePart === null) filePart = stripQuotes(parts[i]);
508
+ }
509
+ }
510
+ if (!pattern || !filePart || filePart.includes('*') || filePart.includes('?')) return null;
511
+ const abs = path.resolve(cwd, filePart);
512
+ try {
513
+ const content = readFileNative(abs);
514
+ let re;
515
+ try { re = new RegExp(pattern, 'i'); } catch { return null; }
516
+ const matches = content.split('\n')
517
+ .map((line, idx) => re.test(line) ? `${abs}:${idx + 1}:${line}` : null)
518
+ .filter(Boolean);
519
+ return { output: matches.join('\n'), exitCode: matches.length > 0 ? 0 : 1 };
520
+ } catch {
521
+ return { output: `Select-String: Cannot find path '${filePart}'`, exitCode: 1 };
522
+ }
523
+ }
524
+
525
+ // ── git read-only forms ────────────────────────────────────────────────────
526
+ // Handles bare forms (D-4, process.cwd()) and -C forms (D-1, explicit path).
527
+ // Strips trailing 2>&1 before matching — isNativeHandleable does the same.
528
+ // Extra display-only flags on log are accepted by isNativeHandleable but
529
+ // ignored in execution — we always run the canonical form.
530
+ if (headL === 'git') {
531
+ const gitRaw = raw.replace(/\s+2>&1\s*$/, '');
532
+ const gitParts = gitRaw.trim().split(/\s+/);
533
+
534
+ // Bare forms in current cwd (D-4)
535
+ if (gitParts[1] === 'status' && gitParts.length === 2) {
536
+ try {
537
+ const { execSync } = require('child_process');
538
+ const out = execSync('git status', {
539
+ cwd: process.cwd(), timeout: 10000,
540
+ stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf8',
541
+ });
542
+ return { output: out.trimEnd(), exitCode: 0 };
543
+ } catch (e) {
544
+ const msg = ((e.stdout || '') + (e.stderr || '')).toString().trimEnd() || e.message;
545
+ return { output: msg, exitCode: e.status ?? 1 };
546
+ }
547
+ }
548
+ if (gitParts[1] === 'log' && gitParts[2] === '--oneline' && /^-\d+$/.test(gitParts[3] || '')) {
549
+ const n = parseInt(gitParts[3].slice(1));
550
+ try {
551
+ const { execSync } = require('child_process');
552
+ const out = execSync(`git log --oneline -${n}`, {
553
+ cwd: process.cwd(), timeout: 10000,
554
+ stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf8',
555
+ });
556
+ return { output: out.trimEnd(), exitCode: 0 };
557
+ } catch (e) {
558
+ const msg = ((e.stdout || '') + (e.stderr || '')).toString().trimEnd() || e.message;
559
+ return { output: msg, exitCode: e.status ?? 1 };
560
+ }
561
+ }
562
+
563
+ if (gitParts[1] !== '-C' || !gitParts[2]) return null;
564
+ const gitCwd = stripQuotes(gitParts[2]);
565
+ const sub = gitParts[3];
566
+
567
+ if (sub === 'status' && gitParts.length === 4) {
568
+ try {
569
+ const { execSync } = require('child_process');
570
+ const out = execSync('git status', {
571
+ cwd: gitCwd, timeout: 10000,
572
+ stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf8',
573
+ });
574
+ return { output: out.trimEnd(), exitCode: 0 };
575
+ } catch (e) {
576
+ const msg = ((e.stdout || '') + (e.stderr || '')).toString().trimEnd() || e.message;
577
+ return { output: msg, exitCode: e.status ?? 1 };
578
+ }
579
+ }
580
+
581
+ if (sub === 'log' && gitParts[4] === '--oneline' && /^-\d+$/.test(gitParts[5] || '')) {
582
+ const n = parseInt(gitParts[5].slice(1));
583
+ try {
584
+ const { execSync } = require('child_process');
585
+ const out = execSync(`git log --oneline -${n}`, {
586
+ cwd: gitCwd, timeout: 10000,
587
+ stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf8',
588
+ });
589
+ return { output: out.trimEnd(), exitCode: 0 };
590
+ } catch (e) {
591
+ const msg = ((e.stdout || '') + (e.stderr || '')).toString().trimEnd() || e.message;
592
+ return { output: msg, exitCode: e.status ?? 1 };
593
+ }
594
+ }
595
+
596
+ return null;
597
+ }
598
+
599
+ return null;
600
+ }
601
+
602
+ /**
603
+ * Returns true when nativeHandle() will accept this command.
604
+ * Used by isInterceptable() to short-circuit the SHELL_META + classifier checks.
605
+ * SHELL_COMPOSITION (not SHELL_META) is checked here so that Windows absolute
606
+ * paths containing backslashes (Get-Content C:\Users\…) are not rejected.
607
+ * Backslash is excluded because it is a legitimate Windows path separator.
608
+ * The full SHELL_META check (which includes backslash) lives in isInterceptable
609
+ * and applies only to commands that the native layer cannot handle.
610
+ */
611
+ function isNativeHandleable(cmd) {
612
+ if (!cmd) return false;
613
+ // &&-chains: validate every segment before SHELL_COMPOSITION blocks the &.
614
+ if (cmd.includes(' && ')) return isCompoundHandleable(cmd.trim(), '&&');
615
+ // For git commands: strip trailing 2>&1 before the composition check.
616
+ // Safe because the git routing below only allows explicit read-only subcommands.
617
+ // Non-git commands are unaffected.
618
+ let normalized = cmd.trim();
619
+ if (/^git\s/i.test(normalized)) normalized = normalized.replace(/\s+2>&1\s*$/, '');
620
+ if (SHELL_COMPOSITION.test(normalized)) return false;
621
+ const parts = normalized.split(/\s+/);
622
+ const head = parts[0];
623
+ const headL = head.toLowerCase();
624
+
625
+ if (['cat', 'bat', 'head', 'tail'].includes(headL) && parts.length >= 2) return true;
626
+ if (/^get-content$/i.test(head)) return true;
627
+ if (headL === 'type' && parts.length >= 2 && !parts[1].startsWith('-')) return true;
628
+ if (['ls', 'eza', 'exa', 'lsd', 'dir'].includes(headL)) return true;
629
+ if (/^get-childitem$/i.test(head)) return true;
630
+ if (headL === 'find' && parts.includes('-name')) return true;
631
+ if (headL === 'test' && ['-f', '-e', '-d'].includes(parts[1])) return true;
632
+ if (/^test-path$/i.test(head)) return true;
633
+ if (/^select-string$/i.test(head)) {
634
+ // Glob paths (containing * or ?) cannot be served by the single-file handler.
635
+ return !cmd.includes('*') && !cmd.includes('?');
636
+ }
637
+
638
+ // Bare git in current cwd (D-4): reuses isBareGitReadOnly — same two forms only.
639
+ if (headL === 'git' && isBareGitReadOnly(normalized)) return true;
640
+
641
+ // git -C <path> status | git -C <path> log --oneline -N [safe display flags]
642
+ if (headL === 'git' && parts[1] === '-C' && parts[2]) {
643
+ const sub = parts[3];
644
+ if (sub === 'status' && parts.length === 4) return true;
645
+ if (sub === 'log' && parts[4] === '--oneline' && /^-\d+$/.test(parts[5] || '')) {
646
+ return parts.slice(6).every(f => SAFE_GIT_LOG_FLAGS.test(f));
647
+ }
648
+ }
649
+
650
+ return false;
651
+ }
652
+
653
+ /**
654
+ * Returns true when a PowerShell tool block command can be executed natively.
655
+ * Unlike isNativeHandleable(), this function:
656
+ * 1. Expands $env:VAR references before checking (safe — read-only expansion)
657
+ * 2. Uses PS_COMPOSITION (no $ guard) instead of SHELL_COMPOSITION
658
+ * 3. Rejects any remaining $ after expansion (PS variables, subexpressions)
659
+ *
660
+ * This is the entry point for isInterceptable() when block.name === 'PowerShell'.
661
+ */
662
+ function isPowerShellNativeHandleable(cmd) {
663
+ if (!cmd) return false;
664
+ const expanded = expandPsEnvVars(cmd);
665
+ // For git commands: strip trailing 2>&1 before composition check (same logic as isNativeHandleable).
666
+ let normalized = expanded.trim();
667
+ if (/^git\s/i.test(normalized)) normalized = normalized.replace(/\s+2>&1\s*$/, '');
668
+ // ;-chains: validate every segment before PS_COMPOSITION blocks the semicolon.
669
+ if (normalized.includes(';')) return isCompoundHandleable(normalized, ';');
670
+ if (PS_COMPOSITION.test(normalized)) return false;
671
+ if (normalized.includes('$')) return false;
672
+ return isNativeHandleable(normalized);
673
+ }
674
+
675
+ // ── Routing ────────────────────────────────────────────────────────────────────
676
+
677
+ /**
678
+ * Returns true if this tool block can safely be executed locally.
679
+ * Read tool: handled natively when file type is supported (text, no PDF/images).
680
+ * Glob tool: handled natively when pattern is valid and injection-free.
681
+ * Grep tool: handled natively for all modes except multiline (rg -U).
682
+ * TodoWrite/TodoRead: always handled natively (pure structured session state).
683
+ * Bash tool: native handlers checked first, then safe-command classifier.
684
+ */
685
+ function isInterceptable(block) {
686
+ if (block.name === 'Read') return isReadHandleable(block.input);
687
+ if (block.name === 'Glob') return isGlobHandleable(block.input);
688
+ if (block.name === 'Grep') return isGrepHandleable(block.input);
689
+ if (block.name === 'TodoWrite') return isTodoHandleable(block.input, 'TodoWrite');
690
+ if (block.name === 'TodoRead') return isTodoHandleable(block.input, 'TodoRead');
691
+ if (block.name === 'PowerShell') {
692
+ const cmd = (block.input?.command || '').trim();
693
+ return cmd ? isPowerShellNativeHandleable(cmd) : false;
694
+ }
695
+ if (block.name !== 'Bash') return false;
696
+ const cmd = (block.input?.command || '').trim();
697
+ if (!cmd) return false;
698
+ if (isNativeHandleable(cmd)) return true;
699
+ if (SHELL_META.test(cmd)) return false;
700
+ return routeLocally(block.name, cmd).local;
701
+ }
702
+
703
+ /**
704
+ * Returns { handled: boolean, reason: string } for a single tool block.
705
+ * Mirrors isInterceptable() exactly but provides a stable reason code when
706
+ * handled=false. Used by interceptToolUse() to collect fallback_reasons for
707
+ * the coverage log and by tests to verify routing decisions.
708
+ *
709
+ * reason is one of the FALLBACK_REASONS constants (or 'ok' when handled=true).
710
+ */
711
+ function classifyBlock(block) {
712
+ if (block.name === 'Read') {
713
+ return isReadHandleable(block.input)
714
+ ? { handled: true, reason: 'ok' }
715
+ : { handled: false, reason: FALLBACK_REASONS.READ_UNSUPPORTED_TYPE };
716
+ }
717
+
718
+ if (block.name === 'Glob') {
719
+ return isGlobHandleable(block.input)
720
+ ? { handled: true, reason: 'ok' }
721
+ : { handled: false, reason: FALLBACK_REASONS.GLOB_INJECTION };
722
+ }
723
+
724
+ if (block.name === 'Grep') {
725
+ if (!isGrepHandleable(block.input)) {
726
+ return {
727
+ handled: false,
728
+ reason: block.input?.multiline === true
729
+ ? FALLBACK_REASONS.GREP_MULTILINE
730
+ : FALLBACK_REASONS.GREP_INVALID_INPUT,
731
+ };
732
+ }
733
+ return { handled: true, reason: 'ok' };
734
+ }
735
+
736
+ if (block.name === 'TodoWrite' || block.name === 'TodoRead') {
737
+ return { handled: true, reason: 'ok' };
738
+ }
739
+
740
+ if (block.name === 'PowerShell') {
741
+ const rawCmd = (block.input?.command || '').trim();
742
+ if (!rawCmd) return { handled: false, reason: FALLBACK_REASONS.BASH_EMPTY_CMD };
743
+ const expanded = expandPsEnvVars(rawCmd);
744
+ let normalized = expanded.trim();
745
+ if (/^git\s/i.test(normalized)) normalized = normalized.replace(/\s+2>&1\s*$/, '');
746
+ // ;-chains: validate every segment before PS_COMPOSITION blocks the semicolon.
747
+ if (normalized.includes(';')) {
748
+ return isCompoundHandleable(normalized, ';')
749
+ ? { handled: true, reason: 'ok' }
750
+ : { handled: false, reason: FALLBACK_REASONS.PS_SHELL_COMPOSITION };
751
+ }
752
+ if (PS_COMPOSITION.test(normalized)) return { handled: false, reason: FALLBACK_REASONS.PS_SHELL_COMPOSITION };
753
+ if (normalized.includes('$')) return { handled: false, reason: FALLBACK_REASONS.PS_DOLLAR_AFTER_EXP };
754
+ if (!isNativeHandleable(normalized)) return { handled: false, reason: FALLBACK_REASONS.PS_NOT_NATIVE };
755
+ return { handled: true, reason: 'ok' };
756
+ }
757
+
758
+ if (block.name !== 'Bash') {
759
+ return { handled: false, reason: FALLBACK_REASONS.TOOL_NOT_HANDLED };
760
+ }
761
+
762
+ // Bash
763
+ const cmd = (block.input?.command || '').trim();
764
+ if (!cmd) return { handled: false, reason: FALLBACK_REASONS.BASH_EMPTY_CMD };
765
+ if (isNativeHandleable(cmd)) return { handled: true, reason: 'ok' };
766
+ if (SHELL_META.test(cmd)) return { handled: false, reason: FALLBACK_REASONS.BASH_SHELL_META };
767
+ return routeLocally(block.name, cmd).local
768
+ ? { handled: true, reason: 'ok' }
769
+ : { handled: false, reason: FALLBACK_REASONS.BASH_UNRECOGNIZED };
770
+ }
771
+
772
+ // ── SSE parser ─────────────────────────────────────────────────────────────────
773
+
774
+ function parseSSE(buffer) {
775
+ const blocks = {};
776
+ let stopReason = null;
777
+ let message = null;
778
+
779
+ for (const line of buffer.toString('utf8').split('\n')) {
780
+ if (!line.startsWith('data: ')) continue;
781
+ let d;
782
+ try { d = JSON.parse(line.slice(6)); } catch { continue; }
783
+
784
+ switch (d.type) {
785
+ case 'message_start':
786
+ message = d.message;
787
+ break;
788
+
789
+ case 'content_block_start':
790
+ blocks[d.index] = {
791
+ type: d.content_block.type,
792
+ id: d.content_block.id || null,
793
+ name: d.content_block.name || null,
794
+ text: '',
795
+ partialJson: '',
796
+ input: null,
797
+ };
798
+ break;
799
+
800
+ case 'content_block_delta': {
801
+ const blk = blocks[d.index];
802
+ if (!blk) break;
803
+ if (d.delta.type === 'input_json_delta') blk.partialJson += d.delta.partial_json || '';
804
+ if (d.delta.type === 'text_delta') blk.text += d.delta.text || '';
805
+ break;
806
+ }
807
+
808
+ case 'content_block_stop': {
809
+ const blk = blocks[d.index];
810
+ if (blk?.type === 'tool_use' && blk.partialJson) {
811
+ try { blk.input = JSON.parse(blk.partialJson); } catch { blk.input = {}; }
812
+ delete blk.partialJson;
813
+ }
814
+ break;
815
+ }
816
+
817
+ case 'message_delta':
818
+ if (d.delta?.stop_reason) stopReason = d.delta.stop_reason;
819
+ break;
820
+ }
821
+ }
822
+
823
+ return { blocks, stopReason, message };
824
+ }
825
+
826
+ function blocksToContent(blocks) {
827
+ return Object.entries(blocks)
828
+ .sort(([a], [b]) => Number(a) - Number(b))
829
+ .map(([, blk]) => {
830
+ if (blk.type === 'text') return { type: 'text', text: blk.text };
831
+ if (blk.type === 'tool_use') return { type: 'tool_use', id: blk.id, name: blk.name, input: blk.input || {} };
832
+ return null;
833
+ })
834
+ .filter(Boolean);
835
+ }
836
+
837
+ // ── Local execution ────────────────────────────────────────────────────────────
838
+
839
+ function runLocally(cmd) {
840
+ return new Promise(resolve => {
841
+ exec(cmd, { timeout: 10_000, maxBuffer: MAX_OUTPUT, cwd: process.cwd() },
842
+ (err, stdout, stderr) => resolve({
843
+ stdout: stdout || '',
844
+ stderr: stderr || '',
845
+ exitCode: err?.code ?? 0,
846
+ })
847
+ );
848
+ });
849
+ }
850
+
851
+ // ── Tool-result secret scanning ───────────────────────────────────────────────
852
+
853
+ /**
854
+ * Scan all tool_result content strings for secrets.
855
+ * Joins them with newlines before scanning so line numbers are meaningful.
856
+ * Returns the same [{label, line, snippet}] shape as scanSecrets().
857
+ *
858
+ * Exported for unit testing (section 20 in test-interceptor.js).
859
+ */
860
+ function scanToolResults(toolResults) {
861
+ const text = toolResults
862
+ .map(r => (typeof r.content === 'string' ? r.content : ''))
863
+ .join('\n');
864
+ return scanSecrets(text);
865
+ }
866
+
867
+ // ── Anthropic follow-up request ────────────────────────────────────────────────
868
+
869
+ /**
870
+ * Build the headers for the interceptor's follow-up /v1/messages call.
871
+ *
872
+ * The proxy's first request forwards all incoming headers verbatim, so auth
873
+ * always reaches Anthropic. The follow-up is constructed manually — this
874
+ * function ensures we forward whichever auth form Claude Code used:
875
+ * - x-api-key (legacy Anthropic SDK / older Claude Code)
876
+ * - authorization (Bearer token — used by newer Anthropic SDK)
877
+ * Not setting x-api-key to '' when it is absent is the critical fix:
878
+ * `undefined || ''` produced an empty string that caused HTTP 401.
879
+ *
880
+ * Exported for unit testing (see section 18 in test-interceptor.js).
881
+ */
882
+ function buildFollowUpHeaders(authHeaders, payloadLength) {
883
+ const h = {
884
+ 'anthropic-version': authHeaders['anthropic-version'] || '2023-06-01',
885
+ 'content-type': 'application/json',
886
+ };
887
+ if (payloadLength !== undefined) h['content-length'] = String(payloadLength);
888
+ if (authHeaders['x-api-key']) h['x-api-key'] = authHeaders['x-api-key'];
889
+ if (authHeaders['authorization']) h['authorization'] = authHeaders['authorization'];
890
+ if (authHeaders['anthropic-beta']) h['anthropic-beta'] = authHeaders['anthropic-beta'];
891
+ return h;
892
+ }
893
+
894
+ function anthropicRequest(body, authHeaders) {
895
+ return new Promise((resolve, reject) => {
896
+ const payload = JSON.stringify({ ...body, stream: false });
897
+ const headers = buildFollowUpHeaders(authHeaders, Buffer.byteLength(payload));
898
+
899
+ const req = https.request(
900
+ { hostname: 'api.anthropic.com', port: 443, path: '/v1/messages', method: 'POST', headers },
901
+ res => {
902
+ const chunks = [];
903
+ res.on('data', c => chunks.push(c));
904
+ res.on('end', () => {
905
+ try { resolve({ status: res.statusCode, body: JSON.parse(Buffer.concat(chunks).toString()) }); }
906
+ catch (e) { reject(e); }
907
+ });
908
+ }
909
+ );
910
+ req.on('error', reject);
911
+ req.end(payload);
912
+ });
913
+ }
914
+
915
+ // ── Main export ────────────────────────────────────────────────────────────────
916
+
917
+ /**
918
+ * Attempts to intercept all tool_use blocks in an Anthropic SSE response by
919
+ * running them locally (native JS or exec) and continuing the conversation.
920
+ *
921
+ * Each entry in toolsRun includes:
922
+ * tool, cmd, exitCode, bytes, outputTokens, native
923
+ *
924
+ * outputTokens is the estimated token count of the command output — used by
925
+ * the dashboard to display per-command savings (e.g. "⚡ cat auth.py (1.2k tokens saved)").
926
+ *
927
+ * @param {Buffer} sseBody
928
+ * @param {object} reqBody
929
+ * @param {object} reqHeaders
930
+ * @param {object} [opts]
931
+ * @param {number} [opts.maxRounds=5]
932
+ * @param {boolean} [opts.verbose=false]
933
+ * @param {string} [opts.mode='intercept'] Occasio mode — used to decide
934
+ * whether to abort when secrets are found in tool results. Pass the same
935
+ * mode string that index.js uses ('intercept' | 'block_secrets' | 'log').
936
+ *
937
+ * @returns {Promise<{
938
+ * intercepted : boolean,
939
+ * response? : object,
940
+ * toolsRun? : Array<{tool,cmd,exitCode,bytes,outputTokens,native}>,
941
+ * savedInputTokens? : number,
942
+ * secretsInResults? : Array<{label,line,snippet}>,
943
+ * }>}
944
+ */
945
+
946
+ /**
947
+ * Dispatch every tool_use block in `toolBlocks` for one conversation round.
948
+ *
949
+ * Pure round-body extraction from interceptToolUse — no multi-round
950
+ * orchestration, no follow-up calls, no SSE handling. The caller is
951
+ * responsible for partial-batch coordination, follow-up assembly, and
952
+ * cross-round state aggregation.
953
+ *
954
+ * For hardened mode (`mode === 'hardened'` and the tool is Read/Glob/Grep),
955
+ * dispatch goes through the legacy `executeLocalTool` path (unchanged).
956
+ * For all other supported tools, dispatch flows through the canonical
957
+ * pipeline: BoundaryEvent → policy → executor.dispatch → audit.
958
+ *
959
+ * @returns {Promise<{
960
+ * toolResults: Array<{type, tool_use_id, content}>,
961
+ * toolsRun: Array<object>,
962
+ * secrets: Array<{label, line, snippet}>, // hardened-mode-collected only
963
+ * }>}
964
+ */
965
+ async function runOneRound(toolBlocks, ctx) {
966
+ const { mode, todoStore, sessionId, runId, auditor, verbose } = ctx;
967
+ // Stage 3: agent identity is supplied by the calling adapter. Default
968
+ // to claude-code so existing callers (and tests) keep working.
969
+ const agent = ctx.agent || 'claude-code';
970
+ const toolResults = [];
971
+ const toolsRun = [];
972
+ const secrets = [];
973
+
974
+ for (const blk of toolBlocks) {
975
+
976
+ // ── hardened: route Read/Glob/Grep through executeLocalTool ─────────────
977
+ // executeLocalTool applies distillation with correct synthetic cmd strings
978
+ // and secret scanning, matching the MCP server's execution quality.
979
+ if (mode === 'hardened' && MCP_TOOL_NAMES[blk.name]) {
980
+ const mcpName = MCP_TOOL_NAMES[blk.name];
981
+ const r = executeLocalTool(mcpName, blk.input, todoStore);
982
+ const cmdLabel = blk.name === 'Read'
983
+ ? (blk.input?.file_path || '(unknown path)')
984
+ : (blk.input?.pattern || '(no pattern)');
985
+ toolResults.push({ type: 'tool_result', tool_use_id: blk.id, content: r.content });
986
+ toolsRun.push({
987
+ tool: blk.name, tool_use_id: blk.id, cmd: cmdLabel, exitCode: r.exitCode,
988
+ bytes: r.bytes,
989
+ kept_bytes: Buffer.byteLength(r.content || ''),
990
+ prevention_reason: r.distilled ? 'distill_clip' : null,
991
+ outputTokens: r.outputTokens,
992
+ native: true, mcpPath: true,
993
+ distilled: r.distilled,
994
+ distillSaved: r.distillSaved,
995
+ distillLabel: r.distillLabel,
996
+ rawContent: r.rawContent,
997
+ matchCount: r.matchCount,
998
+ });
999
+ if (r.secrets?.length) secrets.push(...r.secrets);
1000
+ if (verbose) {
1001
+ process.stderr.write(` [interceptor/hardened] ${blk.name}(${cmdLabel.slice(0, 60)}) → exit ${r.exitCode}\n`);
1002
+ }
1003
+ continue;
1004
+ }
1005
+
1006
+ // ── Canonical pipeline path for any tool the calling agent registered ──
1007
+ // Stage 3: gate is registry-driven. The agent's name map determines
1008
+ // which tools dispatch through the pipeline; anything unregistered hits
1009
+ // the unsupported-tool fallback below.
1010
+ const canonicalName = toolNames.toCanonical(agent, blk.name);
1011
+ if (canonicalName) {
1012
+ const event = makeBoundaryEvent({
1013
+ direction: 'inbound',
1014
+ kind: 'tool_call',
1015
+ agent,
1016
+ protocol: 'anthropic-http',
1017
+ sessionId,
1018
+ runId,
1019
+ toolName: canonicalName,
1020
+ toolInput: blk.input,
1021
+ raw: blk,
1022
+ });
1023
+ const out = await pipeline.processToolEvent(event, {
1024
+ ctx: { todoStore },
1025
+ auditor: auditor || undefined,
1026
+ });
1027
+ const r = out.result || {};
1028
+
1029
+ if (r.passThrough) {
1030
+ toolResults.push({ type: 'tool_result', tool_use_id: blk.id,
1031
+ content: `(not handled: ${r.reason || 'unknown'})` });
1032
+ continue;
1033
+ }
1034
+
1035
+ // Defensive: BLOCK from the canonical pipeline is not currently emitted
1036
+ // on the tool-call path, but handle it so the pipeline cannot produce an
1037
+ // unhandled state that silently drops a block.
1038
+ if (r.blocked) {
1039
+ toolResults.push({ type: 'tool_result', tool_use_id: blk.id,
1040
+ content: '(blocked by policy)' });
1041
+ continue;
1042
+ }
1043
+
1044
+ const rawOutput = r.output || '';
1045
+ const exitCode = r.exitCode;
1046
+ const matchCount = r.matchCount;
1047
+ const taskCount = r.taskCount;
1048
+ const native = r.native ?? true;
1049
+
1050
+ // Stage 3: gate on canonical name. blk.input is expected to be in the
1051
+ // canonical input shape — the calling adapter's parseConversationTurn
1052
+ // is responsible for input translation. This keeps label extraction
1053
+ // and toolsRun construction agent-agnostic.
1054
+ const isTodo = (canonicalName === 'todo_write' || canonicalName === 'todo_read');
1055
+ const isShell = (canonicalName === 'shell_bash' || canonicalName === 'shell_powershell');
1056
+
1057
+ const label =
1058
+ canonicalName === 'read_file' ? ((blk.input?.file_path || '').trim() || 'read') :
1059
+ canonicalName === 'find_files' ? ((blk.input?.pattern || '').trim() || 'glob') :
1060
+ canonicalName === 'grep' ? ((blk.input?.pattern || '').trim() || 'grep') :
1061
+ canonicalName === 'shell_powershell' ? (r.expandedCmd || (blk.input?.command || '').trim()) :
1062
+ canonicalName === 'shell_bash' ? ((blk.input?.command || '').trim() || 'bash') :
1063
+ (taskCount !== undefined ? `${taskCount} tasks` : canonicalName);
1064
+
1065
+ // When the dispatcher ran a TRANSFORM action it already shaped the output
1066
+ // (redacted secrets, distilled long results). Use the dispatcher's result
1067
+ // metadata directly and skip the unconditional distill() call — the
1068
+ // transform is the authoritative shaping step for this tool call.
1069
+ // For LOCAL or non-transformed results the existing distill() path runs
1070
+ // unchanged, preserving full backward compatibility.
1071
+ const distResult = r.transformed
1072
+ ? { content: rawOutput,
1073
+ distilled: r.distilled || false,
1074
+ savedTokens: r.savedTokens || 0,
1075
+ label: r.label || null,
1076
+ rawContent: r.rawContent || null }
1077
+ : isTodo
1078
+ ? { content: rawOutput, distilled: false, savedTokens: 0, label: null, rawContent: null }
1079
+ : distill(label, rawOutput);
1080
+
1081
+ // Per-tool context budget (policy.tools[name].max_output_tokens): the
1082
+ // FINAL clip before re-entry. Applied after any TRANSFORM/distill so
1083
+ // the budget can further trim already-shaped output. Cap is a positive
1084
+ // integer or absent; loader.normalizeToolEntry enforces the shape.
1085
+ const policyForBudget = require('./policy/loader').load();
1086
+ const toolEntry = (policyForBudget?.tools || {})[canonicalName] || null;
1087
+ const maxOutputTokens = toolEntry && typeof toolEntry.max_output_tokens === 'number'
1088
+ ? toolEntry.max_output_tokens
1089
+ : null;
1090
+ const beforeBudget = distResult.content;
1091
+ let budgetResult = null;
1092
+ if (maxOutputTokens !== null) {
1093
+ const { enforceContextBudget } = require('./context-budget');
1094
+ budgetResult = enforceContextBudget(beforeBudget, maxOutputTokens);
1095
+ }
1096
+ const finalContent = budgetResult ? budgetResult.content : beforeBudget;
1097
+ const output = finalContent;
1098
+ const outputTokens = Math.ceil(Buffer.byteLength(output || '', 'utf8') / 4);
1099
+
1100
+ toolResults.push({ type: 'tool_result', tool_use_id: blk.id, content: output });
1101
+ // Per-call boundary accounting: how many bytes the tool produced (raw)
1102
+ // vs how many actually re-entered the model's next request (kept), plus
1103
+ // a one-token reason code identifying what shaping prevented re-entry.
1104
+ // Drives `occasio boundary` and the public "control what re-enters
1105
+ // the model" claim.
1106
+ const rawBytes = Buffer.byteLength(rawOutput || '');
1107
+ const keptBytes = Buffer.byteLength(output || '');
1108
+ // Reason precedence: the *final* shaping step wins. Budget runs last,
1109
+ // so a budget clip overrides an earlier distill/redact reason in the
1110
+ // recorded prevention_reason. Bytes accounting is still correct either
1111
+ // way (kept = post-budget length).
1112
+ const preventionReason =
1113
+ (budgetResult && budgetResult.clipped) ? 'context_budget' :
1114
+ (r.transformed && r.secretsRedacted?.length) ? 'redact_secrets' :
1115
+ distResult.distilled ? 'distill_clip' :
1116
+ null;
1117
+ toolsRun.push({
1118
+ // Keep blk.name (agent-protocol name) in toolsRun.tool for dashboard
1119
+ // continuity; canonical name is in audit log via event.toolName.
1120
+ tool: blk.name,
1121
+ tool_use_id: blk.id,
1122
+ cmd: isTodo ? `${taskCount} tasks` :
1123
+ canonicalName === 'read_file' ? (label || '(unknown path)') :
1124
+ isShell ? (label || '(no command)') :
1125
+ (label || '(no pattern)'),
1126
+ exitCode,
1127
+ bytes: rawBytes,
1128
+ kept_bytes: keptBytes,
1129
+ prevention_reason: preventionReason,
1130
+ outputTokens, native,
1131
+ distilled: distResult.distilled,
1132
+ distillSaved: distResult.savedTokens,
1133
+ distillLabel: distResult.label,
1134
+ rawContent: distResult.rawContent || null,
1135
+ ...(r.transformed && { transformed: true, transform: r.transform }),
1136
+ ...(r.secretsRedacted?.length && { secretsRedacted: r.secretsRedacted.length }),
1137
+ ...(matchCount !== undefined && { matchCount }),
1138
+ ...(taskCount !== undefined && { taskCount }),
1139
+ });
1140
+ if (verbose) {
1141
+ const tag = blk.name;
1142
+ const note = matchCount !== undefined ? `${matchCount} matches`
1143
+ : taskCount !== undefined ? `${taskCount} tasks`
1144
+ : `exit ${exitCode}`;
1145
+ const labelShort = (label || '').slice(0, 60);
1146
+ const pathTag = native ? 'native' : 'exec';
1147
+ process.stderr.write(` [interceptor/pipeline] ${pathTag} ${tag}(${labelShort}) → ${note}\n`);
1148
+ }
1149
+ continue;
1150
+ }
1151
+
1152
+ // Unknown tool — should not be reachable since isInterceptable gates the
1153
+ // batch upstream. Defensive emit so the pipeline cannot drop a block.
1154
+ toolResults.push({ type: 'tool_result', tool_use_id: blk.id,
1155
+ content: `(unsupported tool: ${blk.name})` });
1156
+ }
1157
+
1158
+ return { toolResults, toolsRun, secrets };
1159
+ }
1160
+
1161
+ // Phase E: interceptToolUse and _legacyInterceptToolUse are removed.
1162
+ // The canonical entry point for an Anthropic conversation containing tool_use
1163
+ // turns is `adapters/claude-code.js::runToolLoop`. Callers (index.js, tests)
1164
+ // import the adapter directly.
1165
+
1166
+ module.exports = {
1167
+ runOneRound,
1168
+ blocksToContent,
1169
+ parseSSE,
1170
+ isInterceptable,
1171
+ classifyBlock,
1172
+ FALLBACK_REASONS,
1173
+ isNativeHandleable,
1174
+ isPowerShellNativeHandleable,
1175
+ isEchoSegment,
1176
+ isBareGitReadOnly,
1177
+ isGitCSegment,
1178
+ isCdSegment,
1179
+ isSetLocationSegment,
1180
+ isCompoundSegment,
1181
+ isCompoundHandleable,
1182
+ expandPsEnvVars,
1183
+ isReadHandleable,
1184
+ isGlobHandleable,
1185
+ isGrepHandleable,
1186
+ isTodoHandleable,
1187
+ nativeHandle,
1188
+ handleReadTool,
1189
+ handleGlobTool,
1190
+ globToRegex,
1191
+ handleGrepTool,
1192
+ handleTodoWriteTool,
1193
+ handleTodoReadTool,
1194
+ scanToolResults,
1195
+ runLocally,
1196
+ buildFollowUpHeaders,
1197
+ LOCAL_BASH_CMDS,
1198
+ };