@occasiolabs/occasio 0.8.4 → 0.8.6
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/README.md +4 -3
- package/docs/ADAPTER-STAGE-2-MIGRATION.md +59 -0
- package/docs/STAGE-2-STEP-5-SHELL-PLAN.md +107 -0
- package/docs/THREAT-MODEL.md +195 -0
- package/docs/edr-calibration.md +29 -0
- package/package.json +8 -3
- package/src/adapters/claude-code.js +1 -2
- package/src/adapters/computer-use.js +1 -1
- package/src/anomaly/cli.js +4 -1
- package/src/anomaly/detectors/deny-rate.js +2 -1
- package/src/anomaly/detectors/file-read-volume.js +2 -1
- package/src/anomaly/index.js +5 -0
- package/src/boundary.js +1 -1
- package/src/classifier.js +1 -1
- package/src/cli/clear.js +4 -4
- package/src/cli/conversation.js +121 -0
- package/src/cli/help.js +62 -38
- package/src/cli/recap.js +367 -0
- package/src/cli/status.js +1 -1
- package/src/dashboard.js +2 -3
- package/src/demo/audit-demo.js +330 -0
- package/src/distiller.js +1 -1
- package/src/executor/dispatcher.js +2 -2
- package/src/executor/native-handlers/glob.js +173 -0
- package/src/executor/native-handlers/grep.js +258 -0
- package/src/executor/native-handlers/read.js +99 -0
- package/src/executor/native-handlers/todo.js +56 -0
- package/src/harness.js +8 -10
- package/src/index.js +118 -30
- package/src/inspect.js +1 -1
- package/src/interceptor.js +9 -29
- package/src/ledger.js +2 -3
- package/src/mcp-experiment.js +4 -4
- package/src/mcp-server.js +3 -3
- package/src/policy/doctor.js +2 -2
- package/src/policy/engine.js +0 -1
- package/src/policy/init.js +1 -1
- package/src/policy/loader.js +3 -3
- package/src/policy/show.js +1 -2
- package/src/preflight/cli.js +0 -1
- package/src/preflight/miner.js +3 -6
- package/src/redteam.js +1 -2
- package/src/replay.js +1 -1
- package/src/report/index.js +0 -4
- package/src/runtime.js +42 -444
- package/src/selftest.js +1 -1
- package/src/session.js +1 -1
package/src/runtime.js
CHANGED
|
@@ -17,460 +17,58 @@
|
|
|
17
17
|
* mcp-server.js — for executeLocalTool (hardened execution wrapper)
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
-
const fs = require('fs');
|
|
21
|
-
const path = require('path');
|
|
22
20
|
const { distill } = require('./distiller');
|
|
23
21
|
const { estimateTokens, scanSecrets } = require('./analyzer');
|
|
24
22
|
|
|
25
|
-
// ── Shared constants ───────────────────────────────────────────────────────────
|
|
26
|
-
|
|
27
|
-
const MAX_OUTPUT = 512 * 1024; // 512 KB — same cap as exec maxBuffer
|
|
28
|
-
|
|
29
|
-
// File extensions the native Read handler cannot serve correctly.
|
|
30
|
-
// PDFs and images need structured rendering (base64, page extraction) that we
|
|
31
|
-
// cannot replicate; Jupyter notebooks need cell-by-cell parsing. All others
|
|
32
|
-
// are treated as UTF-8 text and handled natively.
|
|
33
|
-
const READ_SKIP_EXTENSIONS = new Set([
|
|
34
|
-
'.pdf', '.ipynb',
|
|
35
|
-
'.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.ico',
|
|
36
|
-
'.zip', '.gz', '.tar', '.bz2', '.xz', '.7z', '.rar',
|
|
37
|
-
'.exe', '.dll', '.so', '.dylib',
|
|
38
|
-
]);
|
|
39
|
-
|
|
40
|
-
// ── Shared helper ──────────────────────────────────────────────────────────────
|
|
41
|
-
|
|
42
|
-
function readFileNative(absPath) {
|
|
43
|
-
const buf = fs.readFileSync(absPath);
|
|
44
|
-
if (buf.length > MAX_OUTPUT) {
|
|
45
|
-
return buf.slice(0, MAX_OUTPUT).toString('utf8') + '\n[truncated — file too large]';
|
|
46
|
-
}
|
|
47
|
-
return buf.toString('utf8');
|
|
48
|
-
}
|
|
49
|
-
|
|
50
23
|
// ── Read tool support ──────────────────────────────────────────────────────────
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
const ext = path.extname(fp).toLowerCase();
|
|
63
|
-
return !READ_SKIP_EXTENSIONS.has(ext);
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
/**
|
|
67
|
-
* Read a file natively and return content formatted like `cat -n` (1-based line
|
|
68
|
-
* numbers), honouring the optional offset (0-based line index) and limit fields
|
|
69
|
-
* that the Claude Code Read tool sends for partial reads.
|
|
70
|
-
*/
|
|
71
|
-
function handleReadTool(input) {
|
|
72
|
-
const fp = (input?.file_path || '').trim();
|
|
73
|
-
if (!fp) return { output: '(no file_path provided)', exitCode: 1 };
|
|
74
|
-
|
|
75
|
-
const abs = path.resolve(process.cwd(), fp);
|
|
76
|
-
try {
|
|
77
|
-
const content = readFileNative(abs); // already caps at MAX_OUTPUT
|
|
78
|
-
const lines = content.split('\n');
|
|
79
|
-
const offset = (typeof input.offset === 'number' && input.offset >= 0) ? input.offset : 0;
|
|
80
|
-
const limit = (typeof input.limit === 'number' && input.limit > 0) ? input.limit : lines.length;
|
|
81
|
-
const slice = lines.slice(offset, offset + limit);
|
|
82
|
-
// Line numbers reflect position in the file (not the slice), matching cat -n.
|
|
83
|
-
const formatted = slice.map((l, i) => `${String(offset + i + 1).padStart(6)}\t${l}`).join('\n');
|
|
84
|
-
return { output: formatted, exitCode: 0 };
|
|
85
|
-
} catch (e) {
|
|
86
|
-
const msg = e.code === 'ENOENT'
|
|
87
|
-
? `${fp}: No such file or directory`
|
|
88
|
-
: `${fp}: ${e.message}`;
|
|
89
|
-
return { output: `Read: ${msg}`, exitCode: 1 };
|
|
90
|
-
}
|
|
91
|
-
}
|
|
24
|
+
// Moved to src/executor/native-handlers/read.js as Stage-2 of the executor
|
|
25
|
+
// migration (see docs/ADAPTER-STAGE-2-MIGRATION.md). Re-exported here so
|
|
26
|
+
// existing consumers (interceptor, MCP server, tests) keep working unchanged.
|
|
27
|
+
// MAX_OUTPUT and readFileNative are also consumed by the Grep code below.
|
|
28
|
+
const {
|
|
29
|
+
MAX_OUTPUT,
|
|
30
|
+
READ_SKIP_EXTENSIONS,
|
|
31
|
+
readFileNative,
|
|
32
|
+
isReadHandleable,
|
|
33
|
+
handleReadTool,
|
|
34
|
+
} = require('./executor/native-handlers/read');
|
|
92
35
|
|
|
93
36
|
// ── Glob tool support ──────────────────────────────────────────────────────────
|
|
94
|
-
|
|
95
|
-
//
|
|
96
|
-
//
|
|
97
|
-
const
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
if (!input || typeof input !== 'object') return false;
|
|
107
|
-
const pattern = input.pattern;
|
|
108
|
-
if (!pattern || typeof pattern !== 'string' || !pattern.trim()) return false;
|
|
109
|
-
if (GLOB_INJECTION_RE.test(pattern)) return false;
|
|
110
|
-
if (input.path != null && typeof input.path !== 'string') return false;
|
|
111
|
-
return true;
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
// Escape regex metacharacters in a literal string segment.
|
|
115
|
-
function escapeRegexChars(s) {
|
|
116
|
-
return s.replace(/[.+^${}()|[\]\\]/g, '\\$&');
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
/**
|
|
120
|
-
* Convert a glob pattern to a RegExp.
|
|
121
|
-
* Supports: ** (any path depth), * (single segment), ? (single char),
|
|
122
|
-
* {ts,tsx} (alternation), [abc] (character classes).
|
|
123
|
-
* Exported for unit testing.
|
|
124
|
-
*/
|
|
125
|
-
function globToRegex(pattern) {
|
|
126
|
-
// Normalise Windows separators in the pattern.
|
|
127
|
-
const p = pattern.replace(/\\/g, '/');
|
|
128
|
-
|
|
129
|
-
let re = '';
|
|
130
|
-
let i = 0;
|
|
131
|
-
while (i < p.length) {
|
|
132
|
-
// ** — match any path segments (including none), consuming the trailing /
|
|
133
|
-
if (p[i] === '*' && p[i + 1] === '*') {
|
|
134
|
-
re += '.*';
|
|
135
|
-
i += 2;
|
|
136
|
-
if (p[i] === '/') i++; // consume separator after **
|
|
137
|
-
continue;
|
|
138
|
-
}
|
|
139
|
-
// * — match within a single path segment
|
|
140
|
-
if (p[i] === '*') { re += '[^/]*'; i++; continue; }
|
|
141
|
-
// ? — match a single character within a segment
|
|
142
|
-
if (p[i] === '?') { re += '[^/]'; i++; continue; }
|
|
143
|
-
// {a,b,c} — alternation
|
|
144
|
-
if (p[i] === '{') {
|
|
145
|
-
const end = p.indexOf('}', i);
|
|
146
|
-
if (end !== -1) {
|
|
147
|
-
const alts = p.slice(i + 1, end).split(',').map(escapeRegexChars);
|
|
148
|
-
re += `(?:${alts.join('|')})`;
|
|
149
|
-
i = end + 1;
|
|
150
|
-
continue;
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
// [abc] / [^abc] — pass character classes through verbatim
|
|
154
|
-
if (p[i] === '[') {
|
|
155
|
-
const end = p.indexOf(']', i);
|
|
156
|
-
if (end !== -1) { re += p.slice(i, end + 1); i = end + 1; continue; }
|
|
157
|
-
}
|
|
158
|
-
re += escapeRegexChars(p[i]);
|
|
159
|
-
i++;
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
// On Windows, matching is case-insensitive; on POSIX it's case-sensitive.
|
|
163
|
-
const flags = process.platform === 'win32' ? 'i' : '';
|
|
164
|
-
return new RegExp(`^${re}$`, flags);
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
/**
|
|
168
|
-
* Walk `dir` recursively, collecting paths that match `regex`.
|
|
169
|
-
* Results are relative to `baseDir`.
|
|
170
|
-
*/
|
|
171
|
-
function walkGlob(dir, baseDir, regex, results) {
|
|
172
|
-
if (results.length >= GLOB_MAX) return;
|
|
173
|
-
let entries;
|
|
174
|
-
try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
|
|
175
|
-
catch { return; }
|
|
176
|
-
|
|
177
|
-
for (const entry of entries) {
|
|
178
|
-
if (results.length >= GLOB_MAX) break;
|
|
179
|
-
if (GLOB_SKIP.has(entry.name)) continue;
|
|
180
|
-
const abs = path.join(dir, entry.name);
|
|
181
|
-
// Normalise to forward slashes for matching (consistent on all platforms).
|
|
182
|
-
const rel = path.relative(baseDir, abs).replace(/\\/g, '/');
|
|
183
|
-
if (entry.isDirectory()) {
|
|
184
|
-
walkGlob(abs, baseDir, regex, results);
|
|
185
|
-
} else if (regex.test(rel)) {
|
|
186
|
-
results.push(rel);
|
|
187
|
-
}
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
/**
|
|
192
|
-
* Resolve glob pattern + optional base path to a sorted list of matching paths,
|
|
193
|
-
* relative to CWD. Returns { output, exitCode, matchCount }.
|
|
194
|
-
*/
|
|
195
|
-
function handleGlobTool(input) {
|
|
196
|
-
const pattern = (input?.pattern || '').trim();
|
|
197
|
-
if (!pattern) return { output: '(no pattern provided)', exitCode: 1, matchCount: 0 };
|
|
198
|
-
|
|
199
|
-
const baseDir = input?.path
|
|
200
|
-
? path.resolve(process.cwd(), input.path)
|
|
201
|
-
: process.cwd();
|
|
202
|
-
|
|
203
|
-
// Reject if base path escapes CWD for safety.
|
|
204
|
-
const cwd = process.cwd();
|
|
205
|
-
if (!baseDir.startsWith(cwd) && baseDir !== cwd) {
|
|
206
|
-
// Allow absolute paths outside CWD — Glob is read-only and safe.
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
let regex;
|
|
210
|
-
try { regex = globToRegex(pattern); }
|
|
211
|
-
catch (e) { return { output: `Glob: invalid pattern: ${e.message}`, exitCode: 1, matchCount: 0 }; }
|
|
212
|
-
|
|
213
|
-
const results = [];
|
|
214
|
-
walkGlob(baseDir, baseDir, regex, results);
|
|
215
|
-
results.sort();
|
|
216
|
-
|
|
217
|
-
const truncated = results.length >= GLOB_MAX;
|
|
218
|
-
const lines = results.map(r => path.join(baseDir !== cwd ? baseDir : '', r).replace(/\\/g, '/'));
|
|
219
|
-
const output = lines.join('\n') + (truncated ? `\n(truncated at ${GLOB_MAX} results)` : '');
|
|
220
|
-
return { output: output || '(no matches)', exitCode: 0, matchCount: results.length };
|
|
221
|
-
}
|
|
37
|
+
// Moved to src/executor/native-handlers/glob.js as Stage-2 Step 3 of the
|
|
38
|
+
// executor migration. GLOB_SKIP and globToRegex are also consumed by the Grep
|
|
39
|
+
// code below.
|
|
40
|
+
const {
|
|
41
|
+
GLOB_INJECTION_RE,
|
|
42
|
+
GLOB_SKIP,
|
|
43
|
+
GLOB_MAX,
|
|
44
|
+
isGlobHandleable,
|
|
45
|
+
globToRegex,
|
|
46
|
+
walkGlob,
|
|
47
|
+
handleGlobTool,
|
|
48
|
+
} = require('./executor/native-handlers/glob');
|
|
222
49
|
|
|
223
50
|
// ── Grep tool support ──────────────────────────────────────────────────────────
|
|
224
|
-
|
|
225
|
-
const
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
['java', ['.java']],
|
|
236
|
-
['rb', ['.rb']],
|
|
237
|
-
['css', ['.css', '.scss', '.sass', '.less']],
|
|
238
|
-
['html', ['.html', '.htm']],
|
|
239
|
-
['json', ['.json', '.jsonc']],
|
|
240
|
-
['md', ['.md', '.mdx']],
|
|
241
|
-
['yaml', ['.yaml', '.yml']],
|
|
242
|
-
['sh', ['.sh', '.bash', '.zsh']],
|
|
243
|
-
['c', ['.c', '.h']],
|
|
244
|
-
['cpp', ['.cpp', '.cc', '.cxx', '.hpp', '.hh']],
|
|
245
|
-
]);
|
|
246
|
-
|
|
247
|
-
const VALID_GREP_MODES = new Set(['content', 'files_with_matches', 'count']);
|
|
248
|
-
|
|
249
|
-
function isGrepHandleable(input) {
|
|
250
|
-
if (!input || typeof input !== 'object') return false;
|
|
251
|
-
const pattern = input.pattern;
|
|
252
|
-
if (!pattern || typeof pattern !== 'string' || !pattern.trim()) return false;
|
|
253
|
-
// Optional fields must be the right type when present.
|
|
254
|
-
if (input.path != null && typeof input.path !== 'string') return false;
|
|
255
|
-
if (input.glob != null && typeof input.glob !== 'string') return false;
|
|
256
|
-
if (input.type != null && typeof input.type !== 'string') return false;
|
|
257
|
-
if (input.output_mode != null && !VALID_GREP_MODES.has(input.output_mode)) return false;
|
|
258
|
-
// Cross-line matching (rg -U) requires full-file regex — not supported natively.
|
|
259
|
-
if (input.multiline === true) return false;
|
|
260
|
-
return true;
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
// Read a file for grep: returns null for binary files or on read error.
|
|
264
|
-
function tryReadGrep(absPath) {
|
|
265
|
-
try {
|
|
266
|
-
const buf = fs.readFileSync(absPath);
|
|
267
|
-
if (buf.slice(0, 512).includes(0)) return null; // binary file — skip
|
|
268
|
-
return (buf.length > MAX_OUTPUT ? buf.slice(0, MAX_OUTPUT) : buf).toString('utf8');
|
|
269
|
-
} catch { return null; }
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
// Walk directory collecting absolute file paths, honouring glob and type filters.
|
|
273
|
-
function walkGrepFiles(dir, baseDir, globRegex, globHasDir, typeExts, results) {
|
|
274
|
-
if (results.length >= GREP_FILE_CAP) return;
|
|
275
|
-
let entries;
|
|
276
|
-
try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
|
|
277
|
-
catch { return; }
|
|
278
|
-
for (const entry of entries) {
|
|
279
|
-
if (results.length >= GREP_FILE_CAP) break;
|
|
280
|
-
if (GLOB_SKIP.has(entry.name)) continue;
|
|
281
|
-
const abs = path.join(dir, entry.name);
|
|
282
|
-
if (entry.isDirectory()) {
|
|
283
|
-
walkGrepFiles(abs, baseDir, globRegex, globHasDir, typeExts, results);
|
|
284
|
-
} else {
|
|
285
|
-
if (typeExts && !typeExts.includes(path.extname(abs).toLowerCase())) continue;
|
|
286
|
-
if (globRegex) {
|
|
287
|
-
// Glob patterns with path separators match against the relative path;
|
|
288
|
-
// plain filename globs (e.g. "*.ts") match against the basename only.
|
|
289
|
-
const testStr = globHasDir
|
|
290
|
-
? path.relative(baseDir, abs).replace(/\\/g, '/')
|
|
291
|
-
: path.basename(abs);
|
|
292
|
-
if (!globRegex.test(testStr)) continue;
|
|
293
|
-
}
|
|
294
|
-
results.push(abs);
|
|
295
|
-
}
|
|
296
|
-
}
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
/**
|
|
300
|
-
* Execute a structured Grep tool call locally.
|
|
301
|
-
*
|
|
302
|
-
* Supports: pattern, path, glob, type, output_mode (files_with_matches | content | count),
|
|
303
|
-
* -i (case-insensitive), -C / context / -A / -B (context lines), head_limit, offset.
|
|
304
|
-
*
|
|
305
|
-
* Does NOT support multiline (cross-line regex) — isGrepHandleable rejects those.
|
|
306
|
-
*/
|
|
307
|
-
function handleGrepTool(input) {
|
|
308
|
-
const pattern = (input?.pattern || '').trim();
|
|
309
|
-
if (!pattern) return { output: '(no pattern provided)', exitCode: 1, matchCount: 0 };
|
|
310
|
-
|
|
311
|
-
const searchRoot = input?.path
|
|
312
|
-
? path.resolve(process.cwd(), input.path)
|
|
313
|
-
: process.cwd();
|
|
314
|
-
|
|
315
|
-
const outputMode = input?.output_mode || 'files_with_matches';
|
|
316
|
-
const caseInsens = input?.['-i'] === true;
|
|
317
|
-
const contextN = typeof input?.['-C'] === 'number' ? input['-C'] :
|
|
318
|
-
typeof input?.context === 'number' ? input.context : 0;
|
|
319
|
-
const linesBefore = typeof input?.['-B'] === 'number' ? input['-B'] : contextN;
|
|
320
|
-
const linesAfter = typeof input?.['-A'] === 'number' ? input['-A'] : contextN;
|
|
321
|
-
const headLimit = typeof input?.head_limit === 'number' && input.head_limit > 0
|
|
322
|
-
? Math.min(input.head_limit, GREP_MAX_RESULTS)
|
|
323
|
-
: GREP_MAX_RESULTS;
|
|
324
|
-
const skipLines = typeof input?.offset === 'number' && input.offset > 0 ? input.offset : 0;
|
|
325
|
-
|
|
326
|
-
let regex;
|
|
327
|
-
try {
|
|
328
|
-
regex = new RegExp(pattern, 'g' + (caseInsens ? 'i' : ''));
|
|
329
|
-
} catch (e) {
|
|
330
|
-
return { output: `Grep: invalid pattern: ${e.message}`, exitCode: 1, matchCount: 0 };
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
// Build type extension filter.
|
|
334
|
-
let typeExts = null;
|
|
335
|
-
if (input?.type) {
|
|
336
|
-
const t = input.type.toLowerCase();
|
|
337
|
-
typeExts = GREP_TYPE_EXTS.get(t) || [t.startsWith('.') ? t : `.${t}`];
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
// Build glob file filter.
|
|
341
|
-
let globRegex = null;
|
|
342
|
-
let globHasDir = false;
|
|
343
|
-
if (input?.glob) {
|
|
344
|
-
try {
|
|
345
|
-
globRegex = globToRegex(input.glob);
|
|
346
|
-
globHasDir = input.glob.includes('/') || input.glob.includes('**');
|
|
347
|
-
} catch { /* ignore invalid glob — no filter applied */ }
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
// Collect candidate files.
|
|
351
|
-
let files = [];
|
|
352
|
-
try {
|
|
353
|
-
const stat = fs.statSync(searchRoot);
|
|
354
|
-
if (stat.isFile()) {
|
|
355
|
-
files.push(searchRoot);
|
|
356
|
-
} else {
|
|
357
|
-
walkGrepFiles(searchRoot, searchRoot, globRegex, globHasDir, typeExts, files);
|
|
358
|
-
files.sort();
|
|
359
|
-
}
|
|
360
|
-
} catch (e) {
|
|
361
|
-
return { output: `Grep: cannot access path: ${e.message}`, exitCode: 1, matchCount: 0 };
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
const outputLines = [];
|
|
365
|
-
let totalMatches = 0;
|
|
366
|
-
let truncated = false;
|
|
367
|
-
const wantMore = () => outputLines.length < skipLines + headLimit;
|
|
368
|
-
const relOf = abs => path.relative(searchRoot, abs).replace(/\\/g, '/') || path.basename(abs);
|
|
369
|
-
|
|
370
|
-
if (outputMode === 'files_with_matches') {
|
|
371
|
-
for (const absFile of files) {
|
|
372
|
-
if (!wantMore()) { truncated = true; break; }
|
|
373
|
-
const content = tryReadGrep(absFile);
|
|
374
|
-
if (!content) continue;
|
|
375
|
-
regex.lastIndex = 0;
|
|
376
|
-
if (regex.test(content)) { totalMatches++; outputLines.push(relOf(absFile)); }
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
} else if (outputMode === 'count') {
|
|
380
|
-
for (const absFile of files) {
|
|
381
|
-
if (!wantMore()) { truncated = true; break; }
|
|
382
|
-
const content = tryReadGrep(absFile);
|
|
383
|
-
if (!content) continue;
|
|
384
|
-
let count = 0;
|
|
385
|
-
for (const line of content.split('\n')) { regex.lastIndex = 0; if (regex.test(line)) count++; }
|
|
386
|
-
if (count > 0) { totalMatches += count; outputLines.push(`${relOf(absFile)}:${count}`); }
|
|
387
|
-
}
|
|
388
|
-
|
|
389
|
-
} else { // content
|
|
390
|
-
for (const absFile of files) {
|
|
391
|
-
if (!wantMore()) { truncated = true; break; }
|
|
392
|
-
const content = tryReadGrep(absFile);
|
|
393
|
-
if (!content) continue;
|
|
394
|
-
const fileLabel = relOf(absFile);
|
|
395
|
-
const fileLines = content.split('\n');
|
|
396
|
-
const matchSet = new Set();
|
|
397
|
-
for (let i = 0; i < fileLines.length; i++) {
|
|
398
|
-
regex.lastIndex = 0;
|
|
399
|
-
if (regex.test(fileLines[i])) matchSet.add(i);
|
|
400
|
-
}
|
|
401
|
-
if (!matchSet.size) continue;
|
|
402
|
-
totalMatches += matchSet.size;
|
|
403
|
-
|
|
404
|
-
// Merge context windows into non-overlapping groups.
|
|
405
|
-
const sorted = [...matchSet].sort((a, b) => a - b);
|
|
406
|
-
const groups = [];
|
|
407
|
-
let gs = -1, ge = -1;
|
|
408
|
-
for (const idx of sorted) {
|
|
409
|
-
const s = Math.max(0, idx - linesBefore);
|
|
410
|
-
const e = Math.min(fileLines.length - 1, idx + linesAfter);
|
|
411
|
-
if (gs === -1) { gs = s; ge = e; }
|
|
412
|
-
else if (s <= ge + 1) { ge = Math.max(ge, e); }
|
|
413
|
-
else { groups.push([gs, ge]); gs = s; ge = e; }
|
|
414
|
-
}
|
|
415
|
-
if (gs !== -1) groups.push([gs, ge]);
|
|
416
|
-
|
|
417
|
-
let firstGroup = true;
|
|
418
|
-
for (const [gStart, gEnd] of groups) {
|
|
419
|
-
if (!wantMore()) { truncated = true; break; }
|
|
420
|
-
if (!firstGroup) outputLines.push('--');
|
|
421
|
-
firstGroup = false;
|
|
422
|
-
for (let i = gStart; i <= gEnd && wantMore(); i++) {
|
|
423
|
-
const sep = matchSet.has(i) ? ':' : '-';
|
|
424
|
-
outputLines.push(`${fileLabel}${sep}${i + 1}${sep}${fileLines[i]}`);
|
|
425
|
-
}
|
|
426
|
-
}
|
|
427
|
-
}
|
|
428
|
-
}
|
|
429
|
-
|
|
430
|
-
const sliced = outputLines.slice(skipLines, skipLines + headLimit);
|
|
431
|
-
const text = sliced.join('\n') || '(no matches)';
|
|
432
|
-
const suffix = truncated ? '\n(truncated — use head_limit/offset to paginate)' : '';
|
|
433
|
-
return { output: text + suffix, exitCode: 0, matchCount: totalMatches };
|
|
434
|
-
}
|
|
51
|
+
// Moved to src/executor/native-handlers/grep.js as Stage-2 Step 4.
|
|
52
|
+
const {
|
|
53
|
+
GREP_MAX_RESULTS,
|
|
54
|
+
GREP_FILE_CAP,
|
|
55
|
+
GREP_TYPE_EXTS,
|
|
56
|
+
VALID_GREP_MODES,
|
|
57
|
+
isGrepHandleable,
|
|
58
|
+
tryReadGrep,
|
|
59
|
+
walkGrepFiles,
|
|
60
|
+
handleGrepTool,
|
|
61
|
+
} = require('./executor/native-handlers/grep');
|
|
435
62
|
|
|
436
63
|
// ── Todo tool support ──────────────────────────────────────────────────────────
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
if (toolName === 'TodoWrite') {
|
|
446
|
-
if (!input || typeof input !== 'object') return false;
|
|
447
|
-
return Array.isArray(input.todos);
|
|
448
|
-
}
|
|
449
|
-
return false;
|
|
450
|
-
}
|
|
451
|
-
|
|
452
|
-
/**
|
|
453
|
-
* Handle a TodoWrite call: replace the session todo list with input.todos.
|
|
454
|
-
* Returns { output: '', exitCode: 0, taskCount: N } on success.
|
|
455
|
-
* Claude Code expects an empty-string response from write tools.
|
|
456
|
-
*/
|
|
457
|
-
function handleTodoWriteTool(input, todoStore) {
|
|
458
|
-
const todos = input?.todos;
|
|
459
|
-
if (!Array.isArray(todos)) {
|
|
460
|
-
return { output: 'TodoWrite: todos must be an array', exitCode: 1, taskCount: 0 };
|
|
461
|
-
}
|
|
462
|
-
todoStore.splice(0, todoStore.length, ...todos);
|
|
463
|
-
return { output: '', exitCode: 0, taskCount: todos.length };
|
|
464
|
-
}
|
|
465
|
-
|
|
466
|
-
/**
|
|
467
|
-
* Handle a TodoRead call: return the session todo list as a JSON string.
|
|
468
|
-
* Returns { output: string, exitCode: 0, taskCount: N }.
|
|
469
|
-
*/
|
|
470
|
-
function handleTodoReadTool(todoStore) {
|
|
471
|
-
const output = JSON.stringify(todoStore, null, 2);
|
|
472
|
-
return { output, exitCode: 0, taskCount: todoStore.length };
|
|
473
|
-
}
|
|
64
|
+
// Moved to src/executor/native-handlers/todo.js as Stage-2 of the executor
|
|
65
|
+
// migration (see docs/ADAPTER-STAGE-2-MIGRATION.md). Re-exported here so
|
|
66
|
+
// existing consumers (interceptor, tests) keep working unchanged.
|
|
67
|
+
const {
|
|
68
|
+
isTodoHandleable,
|
|
69
|
+
handleTodoWriteTool,
|
|
70
|
+
handleTodoReadTool,
|
|
71
|
+
} = require('./executor/native-handlers/todo');
|
|
474
72
|
|
|
475
73
|
// ── MCP execution wrapper ──────────────────────────────────────────────────────
|
|
476
74
|
|
package/src/selftest.js
CHANGED
|
@@ -171,7 +171,7 @@ async function runSelfTest(opts = {}) {
|
|
|
171
171
|
|
|
172
172
|
} finally {
|
|
173
173
|
loader._resetCache();
|
|
174
|
-
try { fs.rmSync(fixtures.root, { recursive: true, force: true }); } catch {}
|
|
174
|
+
try { fs.rmSync(fixtures.root, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
175
175
|
}
|
|
176
176
|
|
|
177
177
|
const failed = scenarios.filter(s => s.status === 'fail').length;
|
package/src/session.js
CHANGED
|
@@ -30,7 +30,7 @@ function incrementSessionMcpCount(n = 1) {
|
|
|
30
30
|
if (!s || !s.run_id) return; // no active proxy session — don't create a stale entry
|
|
31
31
|
s.tools_mcp_count = (s.tools_mcp_count || 0) + n;
|
|
32
32
|
fs.writeFileSync(SESSION_FILE, JSON.stringify(s));
|
|
33
|
-
} catch {}
|
|
33
|
+
} catch { /* ignore */ }
|
|
34
34
|
}
|
|
35
35
|
|
|
36
36
|
module.exports = { SESSION_FILE, incrementSessionMcpCount };
|