@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,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
|
+
};
|