mtech-ai-local-runner 0.1.77 → 0.1.80

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 (42) hide show
  1. package/README.md +33 -0
  2. package/claude-mod/.claude-plugin/marketplace.json +1 -0
  3. package/claude-mod/.claude-plugin/plugin.json +1 -0
  4. package/claude-mod/hooks/gateway.ts +47 -0
  5. package/claude-mod/hooks/hooks.json +16 -0
  6. package/claude-mod/hooks/model.ts +155 -0
  7. package/claude-mod/hooks/register.tsx +276 -0
  8. package/claude-mod/types/index.d.ts +22 -0
  9. package/dist/src/adapters/claude-code/adapter.js +16 -5
  10. package/dist/src/adapters/claude-code/prompt-compiler.js +17 -4
  11. package/dist/src/adapters/claude-code/runtime-options.js +4 -1
  12. package/dist/src/adapters/codex/config-builder.js +2 -1
  13. package/dist/src/adapters/codex/exec-invocation.js +6 -1
  14. package/dist/src/adapters/codex/exec-preflight.js +4 -2
  15. package/dist/src/adapters/codex/exec-runtime-options.js +3 -3
  16. package/dist/src/adapters/codex/exec-tool-gate.js +7 -3
  17. package/dist/src/adapters/codex/native-policy.js +2 -2
  18. package/dist/src/adapters/codex/native-session.js +5 -4
  19. package/dist/src/adapters/codex/pre-model-gate.js +8 -3
  20. package/dist/src/asks/claude-mod.js +163 -0
  21. package/dist/src/asks/commands.js +26 -6
  22. package/dist/src/asks/doctor.js +4 -3
  23. package/dist/src/asks/integrations.js +1 -1
  24. package/dist/src/cli.js +34 -3
  25. package/dist/src/daemon/repository-access-monitor.js +66 -0
  26. package/dist/src/daemon/runtime.js +16 -1
  27. package/dist/src/gateway-client.js +2 -0
  28. package/dist/src/safety/claude-production-host.js +20 -2
  29. package/dist/src/safety/claude-release.js +4 -1
  30. package/dist/src/safety/claude-review-campaign.js +4 -4
  31. package/dist/src/safety/claude-review-run.js +7 -4
  32. package/dist/src/safety/codex-exec-production-host.js +7 -5
  33. package/dist/src/safety/codex-manual-controller.js +2 -2
  34. package/dist/src/safety/hook-policy-builder.js +3 -3
  35. package/dist/src/safety/job-tool-dispatcher.js +14 -3
  36. package/dist/src/safety/job-tool-session.js +2 -2
  37. package/dist/src/safety/local-tool-policy.js +229 -3
  38. package/dist/src/safety/local-tool-self-test.js +44 -0
  39. package/dist/src/safety/local-tool-server.js +18 -6
  40. package/dist/src/safety/local-tool-worker.js +43 -0
  41. package/dist/src/safety/pre-tool-use-hook.js +17 -2
  42. package/package.json +3 -1
@@ -2,12 +2,21 @@ import { validateLocalReadRange } from './local-read-range.js';
2
2
  import { isPatchContent } from './local-edit-content.js';
3
3
  import path from 'node:path';
4
4
  import { randomBytes } from 'node:crypto';
5
- import { lstat, realpath } from 'node:fs/promises';
5
+ import { lstat, opendir, realpath } from 'node:fs/promises';
6
6
  import { evaluateCommand } from './command-policy.js';
7
7
  import { runSandboxed, runSandboxedMeasured } from './subprocess-sandbox.js';
8
8
  import { LOCAL_TOOL_WORKER } from './local-tool-worker.js';
9
9
  import { verifyCommandIntegrity } from './registered-command-integrity.js';
10
10
  export const LOCAL_TOOL_NAMES = ['local_read', 'local_search', 'local_edit', 'local_replace', 'local_test'];
11
+ /** Named tool inventories (Work Decision f43e7aa0). v1 is the original five, unchanged; v2 adds the read-only local_find.
12
+ * A job serves exactly one: the one whose fingerprint its reviewed input names (local-tool-server.ts localToolInventoryFor). */
13
+ export const LOCAL_TOOL_INVENTORIES = Object.freeze({ 'local-tools-v1': LOCAL_TOOL_NAMES, 'local-tools-v2': Object.freeze([...LOCAL_TOOL_NAMES, 'local_find']) });
14
+ /** The tool names of one inventory; an unknown id fails closed. */
15
+ export function localToolNames(id = 'local-tools-v1') {
16
+ if (!Object.hasOwn(LOCAL_TOOL_INVENTORIES, id))
17
+ throw new Error('DENIED_TOOL_INVENTORY');
18
+ return LOCAL_TOOL_INVENTORIES[id];
19
+ }
11
20
  // Private state/control basenames only: Auth.php, auth/ and mcp.ts are ordinary source.
12
21
  const sensitive = (segment) => /^(?:\.git|\.mcp(?:\..*)?|\.npmrc|\.netrc|id_rsa|id_ed25519|\.local-runner|\.runner|\.env(?:\..*)?|\.codex|\.claude|\.ssh|\.aws|\.config|auth\.(?:json|toml|ya?ml)(?:\..*)?|credentials(?:\..*)?|AGENTS\.md|CLAUDE\.md|mcp\.(?:json|toml|ya?ml)(?:\..*)?)$/i.test(segment);
13
22
  const contains = (root, file) => file === root || file.startsWith(root + path.sep);
@@ -17,13 +26,29 @@ function scopePath(root, scope) {
17
26
  throw new Error('DENIED_UNSUPPORTED_SCOPE');
18
27
  return path.resolve(root, directory);
19
28
  }
20
- function blockedScopeContains(root, scope, file) {
29
+ function exactBlockedScopeContains(root, scope, file) {
21
30
  // The compiled repository policy includes this private-file family. Handle
22
31
  // it only as a denial, never as a wildcard grant or an ignored block.
23
32
  if (scope === '.env.*')
24
33
  return file.startsWith(path.join(root, '.env.'));
25
34
  return contains(scopePath(root, scope), file);
26
35
  }
36
+ // APFS and HFS+ resolve names regardless of case and Unicode normalization, so app/secrets reaches a blocked
37
+ // app/Secrets. A block therefore also matches the folded spelling: a variant can only be refused, never granted.
38
+ const fold = (value) => value.normalize('NFC').toLowerCase();
39
+ function blockedScopeContains(root, scope, file) {
40
+ return exactBlockedScopeContains(root, scope, file) || exactBlockedScopeContains(fold(root), fold(scope), fold(file));
41
+ }
42
+ /** local_find bounds (design 2026-10-30-local-claude-file-discovery). Reaching one returns `truncated: true`, never an error. */
43
+ export const LOCAL_FIND_BOUNDS = Object.freeze({ depth: 16, entries: 20_000, candidates: 2_000, candidateBytes: 32 * 1024 * 1024, fileBytes: 1024 * 1024,
44
+ lineChars: 300, resultBytes: 64 * 1024, timeMs: 10_000,
45
+ /** Not in the design: Seatbelt compile time grows faster than the number of read grants (2,000 take 4 to 8 s), so
46
+ * the candidates are searched by one sandboxed worker per batch, each granted exactly its batch. */
47
+ batchFiles: 250,
48
+ /** Kept for sandbox startup and exit inside the overall time. */
49
+ workerReserveMs: 1_500 });
50
+ const FIND_KEYS = ['path', 'name', 'text', 'max_results'];
51
+ const DEPENDENCY_DIRECTORIES = ['vendor', 'node_modules'];
27
52
  export class LocalToolPolicy {
28
53
  options;
29
54
  abort = new AbortController();
@@ -31,16 +56,19 @@ export class LocalToolPolicy {
31
56
  this.options = structuredClone(options);
32
57
  if (!path.isAbsolute(options.worktreeRoot))
33
58
  throw new Error('DENIED_ROOT');
59
+ localToolNames(options.inventory);
34
60
  }
35
61
  revoke() { this.abort.abort(); }
36
62
  async call(tool, args) {
37
63
  if (this.abort.signal.aborted)
38
64
  throw new Error('DENIED_REVOKED');
39
- if (!LOCAL_TOOL_NAMES.includes(tool))
65
+ if (!localToolNames(this.options.inventory).includes(tool))
40
66
  throw new Error('DENIED_TOOL');
41
67
  if (!args || typeof args !== 'object' || Array.isArray(args))
42
68
  throw new Error('DENIED_ARGUMENTS');
43
69
  const input = args;
70
+ if (tool === 'local_find')
71
+ return this.find(input);
44
72
  if ((tool === 'local_edit' || tool === 'local_replace') && this.options.access !== 'write')
45
73
  throw new Error('DENIED_READ_ONLY');
46
74
  if (tool === 'local_test')
@@ -104,6 +132,204 @@ export class LocalToolPolicy {
104
132
  throw new Error('DENIED_SIZE');
105
133
  return file;
106
134
  }
135
+ /** The scope rules of authorizedFile, for a directory: inside an allowed scope or above one, never blocked. */
136
+ async authorizedDirectory(value) {
137
+ const root = path.resolve(this.options.worktreeRoot);
138
+ if (value !== undefined && (typeof value !== 'string' || !value || value.includes('\0') || path.isAbsolute(value) || value.split(/[\\/]/).some(p => p === '..' || sensitive(p))))
139
+ throw new Error('DENIED_PATH');
140
+ const directory = value === undefined ? root : path.resolve(root, value);
141
+ if (!contains(root, directory) || !this.options.allowedPaths.some(scope => { const allowed = scopePath(root, scope); return contains(allowed, directory) || contains(directory, allowed); })
142
+ || this.options.blockedPaths.some(scope => blockedScopeContains(root, scope, directory)))
143
+ throw new Error('DENIED_SCOPE');
144
+ if (await realpath(root) !== root)
145
+ throw new Error('DENIED_ROOT');
146
+ let component = root;
147
+ for (const segment of path.relative(root, directory).split(path.sep).filter(Boolean)) {
148
+ component = path.join(component, segment);
149
+ if ((await lstat(component)).isSymbolicLink())
150
+ throw new Error('DENIED_SYMLINK');
151
+ }
152
+ const stat = await lstat(directory, { bigint: true });
153
+ if (!stat.isDirectory())
154
+ throw new Error('DENIED_FILE');
155
+ return { root, directory, dev: stat.dev, ino: stat.ino };
156
+ }
157
+ /** Read-only discovery. Names are walked on the host with opendir and lstat only, under the scope rules of
158
+ * authorizedFile, never following a symlink. File content is read only by the sandboxed worker, with a read grant
159
+ * for exactly the bounded candidate list, and each file re-checked there as local_read does. */
160
+ async find(input) {
161
+ const bounds = { ...LOCAL_FIND_BOUNDS, ...this.options.findBounds }, deadline = Date.now() + bounds.timeMs;
162
+ if (Object.keys(input).some(key => !FIND_KEYS.includes(key)))
163
+ throw new Error('DENIED_ARGUMENTS');
164
+ const { name, text } = input;
165
+ if (name !== undefined && (typeof name !== 'string' || !name || name.length > 256) || text !== undefined && (typeof text !== 'string' || !text || text.length > 4096) || name === undefined && text === undefined)
166
+ throw new Error('DENIED_QUERY');
167
+ const max = input.max_results ?? 100;
168
+ if (!Number.isSafeInteger(max) || max < 1 || max > 200)
169
+ throw new Error('DENIED_ARGUMENTS');
170
+ const limit = max, needle = typeof name === 'string' ? name.toLowerCase() : undefined;
171
+ const start = await this.authorizedDirectory(input.path), root = start.root;
172
+ const allowed = this.options.allowedPaths.map(scope => scopePath(root, scope));
173
+ const blocked = (file) => this.options.blockedPaths.some(scope => blockedScopeContains(root, scope, file));
174
+ // vendor/ and node_modules/ are walked only when the requested directory is inside one of them.
175
+ const dependencies = path.relative(root, start.directory).split(path.sep).some(segment => DEPENDENCY_DIRECTORIES.includes(segment));
176
+ const names = [], candidates = [];
177
+ let visited = 0, files = 0, truncated = false;
178
+ const stack = [{ directory: start.directory, depth: 0, dev: start.dev, ino: start.ino }];
179
+ walk: while (stack.length) {
180
+ const next = stack.pop();
181
+ if (this.abort.signal.aborted)
182
+ throw new Error('DENIED_REVOKED');
183
+ if (Date.now() >= deadline || visited >= bounds.entries) {
184
+ truncated = true;
185
+ break;
186
+ }
187
+ // The directory must still be the inode its parent listed, before and after reading it: opendir follows a symlink.
188
+ const same = async () => { try {
189
+ const s = await lstat(next.directory, { bigint: true });
190
+ return s.isDirectory() && s.dev === next.dev && s.ino === next.ino;
191
+ }
192
+ catch {
193
+ return false;
194
+ } };
195
+ if (!await same())
196
+ continue;
197
+ const entries = [];
198
+ // A directory this user cannot read is left out, like any entry the walk skips.
199
+ const handle = await opendir(next.directory, { bufferSize: 64 }).catch(() => undefined);
200
+ if (!handle)
201
+ continue;
202
+ try {
203
+ for (;;) {
204
+ const entry = await handle.read();
205
+ if (!entry)
206
+ break;
207
+ if (visited + entries.length >= bounds.entries) {
208
+ truncated = true;
209
+ break;
210
+ }
211
+ entries.push(entry.name);
212
+ }
213
+ }
214
+ finally {
215
+ await handle.close();
216
+ }
217
+ if (!await same())
218
+ continue;
219
+ visited += entries.length;
220
+ const directories = [];
221
+ for (const entry of entries.sort()) {
222
+ if (Date.now() >= deadline) {
223
+ truncated = true;
224
+ break walk;
225
+ }
226
+ const file = path.join(next.directory, entry);
227
+ if (entry.split(/[\\/]/).some(p => p === '..' || sensitive(p)) || blocked(file))
228
+ continue;
229
+ let stat;
230
+ try {
231
+ stat = await lstat(file, { bigint: true });
232
+ }
233
+ catch {
234
+ continue;
235
+ }
236
+ if (stat.isSymbolicLink())
237
+ continue;
238
+ if (stat.isDirectory()) {
239
+ if (!dependencies && DEPENDENCY_DIRECTORIES.includes(entry) || !allowed.some(scope => contains(scope, file) || contains(file, scope)))
240
+ continue;
241
+ if (next.depth >= bounds.depth) {
242
+ truncated = true;
243
+ continue;
244
+ }
245
+ directories.push({ directory: file, depth: next.depth + 1, dev: stat.dev, ino: stat.ino });
246
+ continue;
247
+ }
248
+ if (!stat.isFile() || !allowed.some(scope => contains(scope, file)))
249
+ continue;
250
+ const rel = path.relative(root, file);
251
+ if (text === undefined) {
252
+ files++;
253
+ if (rel.toLowerCase().includes(needle))
254
+ names.push(rel);
255
+ continue;
256
+ }
257
+ if (needle !== undefined && !rel.toLowerCase().includes(needle) || stat.nlink !== 1n || stat.size > BigInt(bounds.fileBytes))
258
+ continue;
259
+ candidates.push({ rel, file, dev: stat.dev, ino: stat.ino, size: stat.size });
260
+ }
261
+ stack.push(...directories.reverse());
262
+ }
263
+ // Bounds cut in path order, after sorting, so a result always holds the first paths (the walk is bounded by entries).
264
+ if (text === undefined) {
265
+ names.sort();
266
+ if (names.length > limit) {
267
+ truncated = true;
268
+ names.length = limit;
269
+ }
270
+ return this.boundedFind(names.map(rel => ({ path: rel })), truncated, files);
271
+ }
272
+ candidates.sort((a, b) => a.rel < b.rel ? -1 : a.rel > b.rel ? 1 : 0);
273
+ let bytes = 0n, kept = 0;
274
+ for (const candidate of candidates) {
275
+ if (kept >= bounds.candidates || bytes + candidate.size > BigInt(bounds.candidateBytes)) {
276
+ truncated = true;
277
+ break;
278
+ }
279
+ bytes += candidate.size;
280
+ kept++;
281
+ }
282
+ candidates.length = kept;
283
+ const matches = [];
284
+ let scanned = 0, size = 0;
285
+ for (let offset = 0; offset < candidates.length; offset += bounds.batchFiles) {
286
+ const remaining = deadline - Date.now(), batch = candidates.slice(offset, offset + bounds.batchFiles), slots = limit - matches.length;
287
+ if (remaining <= bounds.workerReserveMs) {
288
+ truncated = true;
289
+ break;
290
+ }
291
+ let output;
292
+ try {
293
+ output = await runSandboxed({ executable: process.execPath, argv: ['--no-addons', '--no-global-search-paths', '--eval', LOCAL_TOOL_WORKER, JSON.stringify({ tool: 'local_find', text, max_results: slots,
294
+ budget_ms: remaining - bounds.workerReserveMs, files: batch.map(c => [c.file, String(c.dev), String(c.ino)]) })], readFiles: batch.map(c => c.file), timeoutMs: remaining, signal: this.abort.signal });
295
+ }
296
+ catch (error) {
297
+ // A batch stopped at the overall time limit is the time bound: keep what was found and say truncated.
298
+ if (!this.abort.signal.aborted && Date.now() >= deadline - 50) {
299
+ truncated = true;
300
+ break;
301
+ }
302
+ throw error;
303
+ }
304
+ if (this.abort.signal.aborted)
305
+ throw new Error('DENIED_REVOKED');
306
+ const result = JSON.parse(output);
307
+ if (!Array.isArray(result.matches) || result.matches.length > slots || !Number.isSafeInteger(result.scanned) || result.scanned < 0 || result.scanned > batch.length
308
+ || result.matches.some(m => !Array.isArray(m) || !Number.isSafeInteger(m[0]) || m[0] < 0 || m[0] >= batch.length || !Number.isSafeInteger(m[1]) || m[1] < 1 || typeof m[2] !== 'string'))
309
+ throw new Error('LOCAL_FIND_RESULT_REJECTED');
310
+ scanned += result.scanned;
311
+ for (const [index, line, value] of result.matches) {
312
+ const match = { path: batch[index].rel, line, text: value.slice(0, bounds.lineChars) };
313
+ matches.push(match);
314
+ size += Buffer.byteLength(JSON.stringify(match)) + 1;
315
+ }
316
+ if (result.truncated === true || size > bounds.resultBytes) {
317
+ truncated = true;
318
+ break;
319
+ }
320
+ }
321
+ return this.boundedFind(matches, truncated, scanned);
322
+ }
323
+ /** At most 64 KiB of JSON: matches are dropped from the end, which is the end of the path order. */
324
+ boundedFind(matches, truncated, scanned) {
325
+ const result = { matches, truncated, scanned_files: scanned };
326
+ let size = Buffer.byteLength(JSON.stringify(result));
327
+ while (size > LOCAL_FIND_BOUNDS.resultBytes) {
328
+ size -= Buffer.byteLength(JSON.stringify(matches.pop())) + 1;
329
+ result.truncated = true;
330
+ }
331
+ return result;
332
+ }
107
333
  async prepareTest(input) {
108
334
  if (this.abort.signal.aborted)
109
335
  throw new Error('DENIED_REVOKED');
@@ -0,0 +1,44 @@
1
+ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { LocalToolPolicy } from './local-tool-policy.js';
4
+ import { runSandboxedMeasured } from './subprocess-sandbox.js';
5
+ const PROBE = 'runner doctor probe\n';
6
+ /**
7
+ * Reads and searches a probe file with the same LocalToolPolicy and sandbox a job's local_read and
8
+ * local_search use, in a fresh directory under the workspace root that holds job checkouts, then
9
+ * removes it (Work Item 6f9e64ae: every file tool call failed on one employee's Mac, and a job
10
+ * reports only LOCAL_TOOL_FAILED). No repository content is read.
11
+ */
12
+ export async function localToolSelfTest(workspaceRoot, deps = {}) {
13
+ const node = process.execPath;
14
+ const parent = path.join(workspaceRoot, 'doctor');
15
+ await mkdir(parent, { recursive: true, mode: 0o700 });
16
+ const root = await mkdtemp(path.join(parent, 'local-tools-'));
17
+ let tool = 'local_read';
18
+ try {
19
+ await mkdir(path.join(root, 'src'), { mode: 0o700 });
20
+ await writeFile(path.join(root, 'src', 'probe.txt'), PROBE, { mode: 0o600 });
21
+ const policy = deps.createPolicy?.(root)
22
+ ?? new LocalToolPolicy({ worktreeRoot: root, access: 'read', allowedPaths: ['src/**'], blockedPaths: [], requiredCommands: [] });
23
+ const read = await policy.call('local_read', { path: 'src/probe.txt' });
24
+ if (read?.content !== PROBE)
25
+ throw new Error('local_read returned different content');
26
+ tool = 'local_search';
27
+ const search = await policy.call('local_search', { path: 'src/probe.txt', query: 'probe' });
28
+ if (!Array.isArray(search?.matches) || search.matches.length !== 1)
29
+ throw new Error('local_search returned different matches');
30
+ return { status: 'passed', node };
31
+ }
32
+ catch (error) {
33
+ const detail = error instanceof Error ? error.message : String(error);
34
+ const result = { status: 'failed', tool, detail, node };
35
+ if (detail.startsWith('LOCAL_SANDBOX_UNAVAILABLE')) {
36
+ const measured = await runSandboxedMeasured({ executable: node, argv: ['--version'], readFiles: [], timeoutMs: 10000 });
37
+ result.sandbox = { status: measured.status, ...(measured.reason ? { reason: measured.reason } : {}), exit_code: measured.exit_code };
38
+ }
39
+ return result;
40
+ }
41
+ finally {
42
+ await rm(root, { recursive: true, force: true });
43
+ }
44
+ }
@@ -1,7 +1,7 @@
1
1
  import { createServer } from 'node:http';
2
2
  import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
3
3
  import path from 'node:path';
4
- import { LOCAL_TOOL_NAMES } from './local-tool-policy.js';
4
+ import { LOCAL_TOOL_INVENTORIES, localToolNames } from './local-tool-policy.js';
5
5
  const discoveryScopes = new WeakMap();
6
6
  export function localDiscoveryScopeMatches(scope, endpoint, token) {
7
7
  if (!scope || typeof scope !== 'object')
@@ -26,10 +26,20 @@ const schemas = {
26
26
  local_edit: { type: 'object', properties: { path: { type: 'string' }, content: { type: 'string', description: 'The complete new contents of the entire file. Do not send a diff, patch, hunk, or edit instructions.' } }, required: ['path', 'content'], additionalProperties: false },
27
27
  local_replace: { type: 'object', properties: { path: { type: 'string' }, old: { type: 'string', minLength: 1 }, new: { type: 'string' } }, required: ['path', 'old', 'new'], additionalProperties: false },
28
28
  local_test: { type: 'object', properties: { command: { type: 'string' } }, required: ['command'], additionalProperties: false },
29
+ // Every property is optional, so no `required`; the policy refuses a call with neither name nor text (DENIED_QUERY).
30
+ local_find: { type: 'object', properties: { path: { type: 'string', description: 'Checkout-relative directory to search below; omit it for the whole checkout.' }, name: { type: 'string', maxLength: 256, description: 'Literal, case-insensitive part of the checkout-relative file path.' }, text: { type: 'string', maxLength: 4096, description: 'Literal, case-sensitive part of one line.' }, max_results: { type: 'integer', minimum: 1, maximum: 200, description: 'Most matches returned; default 100.' } }, additionalProperties: false },
29
31
  };
30
- /** Same immutable-by-copy inventory used by native admission and MCP discovery. */
31
- export function localToolInventory() {
32
- return LOCAL_TOOL_NAMES.map(name => ({ name, description: name === 'local_read' ? 'Find locations with local_search first, then use local_read with start_line and end_line to read only the needed inclusive range (maximum 400 lines). Both range arguments are required together; invalid or beyond-EOF ranges are rejected, never widened. Returns content, start_line, end_line and total_lines for ranges; omit both for the entire file.' : name === 'local_edit' ? 'Replace the entire file at path with content (or create it). Read the current file first and include all unchanged text in content. This is not a diff or patch tool; patch headers and hunks are rejected.' : name === 'local_replace' ? 'Replace exactly one occurrence of old with new in the file at path. Read the file first; old must match exactly, including whitespace, and appear once. Prefer this for small changes; use local_edit only to write a whole file.' : `Scoped ${name} operation`, inputSchema: structuredClone(schemas[name]) }));
32
+ export const LOCAL_TOOL_INVENTORY_IDS = Object.freeze(Object.keys(LOCAL_TOOL_INVENTORIES));
33
+ /** Same immutable-by-copy inventory used by native admission and MCP discovery. Without an id it is local-tools-v1, the
34
+ * inventory every caller not selected per job keeps. Descriptions and schemas are part of each inventory's fingerprint:
35
+ * changing them changes what the Gateway pins, so a change is a new inventory, never an edit of a listed one. */
36
+ export function localToolInventory(id = 'local-tools-v1') {
37
+ return localToolNames(id).map(name => ({ name, description: name === 'local_find' ? 'Find files inside the approved scope by name (a literal, case-insensitive part of the checkout-relative file path), by text (a literal, case-sensitive part of a line) or by both. path is a directory to search below; vendor/ and node_modules/ are skipped unless path is inside them. Returns matches sorted by path, each with line and text for a text match, the files scanned, and truncated: true when a search bound was reached. Then use local_search and local_read on a file found.' : name === 'local_read' ? 'Find locations with local_search first, then use local_read with start_line and end_line to read only the needed inclusive range (maximum 400 lines). Both range arguments are required together; invalid or beyond-EOF ranges are rejected, never widened. Returns content, start_line, end_line and total_lines for ranges; omit both for the entire file.' : name === 'local_edit' ? 'Replace the entire file at path with content (or create it). Read the current file first and include all unchanged text in content. This is not a diff or patch tool; patch headers and hunks are rejected.' : name === 'local_replace' ? 'Replace exactly one occurrence of old with new in the file at path. Read the file first; old must match exactly, including whitespace, and appear once. Prefer this for small changes; use local_edit only to write a whole file.' : `Scoped ${name} operation`, inputSchema: structuredClone(schemas[name]) }));
38
+ }
39
+ /** The inventory whose fingerprint under `hash` is `sha256`, or undefined: a job serves only an inventory its reviewed
40
+ * input names. Codex descriptors hash with `fingerprint`, Claude pins with `claudeCanonicalSha256` (same canonical JSON). */
41
+ export function localToolInventoryFor(sha256, hash) {
42
+ return typeof sha256 === 'string' && /^[a-f0-9]{64}$/.test(sha256) ? LOCAL_TOOL_INVENTORY_IDS.find(id => hash({ tools: localToolInventory(id) }) === sha256) : undefined;
33
43
  }
34
44
  /** Job-only JSON MCP endpoint; intentionally has no Gateway transport or discovery. */
35
45
  export class LocalToolServer {
@@ -69,7 +79,9 @@ export class LocalToolServer {
69
79
  discoveryScope = Object.freeze({ kind: 'local_discovery_scope' });
70
80
  constructor(options) {
71
81
  this.options = options;
82
+ this.tools = localToolNames(options.inventory);
72
83
  }
84
+ tools;
73
85
  revoke() { const first = !this.revoked; this.revoked = true; this.token = null; if (first)
74
86
  this.observe('revoked'); this.options.policy.revoke?.(); }
75
87
  observe(event) { try {
@@ -183,7 +195,7 @@ export class LocalToolServer {
183
195
  if (rpc.method === 'tools/list') {
184
196
  res.once('finish', () => { if (!this.revoked)
185
197
  this.observe('inventory_response'); });
186
- send({ tools: localToolInventory() });
198
+ send({ tools: localToolInventory(this.options.inventory) });
187
199
  return;
188
200
  }
189
201
  if (rpc.method !== 'tools/call' || !rpc.params || typeof rpc.params !== 'object' || Array.isArray(rpc.params)) {
@@ -195,7 +207,7 @@ export class LocalToolServer {
195
207
  return;
196
208
  }
197
209
  const params = rpc.params;
198
- if (!LOCAL_TOOL_NAMES.includes(params.name)) {
210
+ if (typeof params.name !== 'string' || !this.tools.includes(params.name)) {
199
211
  reply(403, { error: 'Forbidden tool' });
200
212
  return;
201
213
  }
@@ -12,6 +12,48 @@ if (input.tool === 'local_replace') {
12
12
  if (typeof input.new !== 'string' || Buffer.byteLength(input.new) > 1048576) throw Error('DENIED_CONTENT');
13
13
  if (isPatchContent(input.new)) throw Error('DENIED_PATCH_CONTENT');
14
14
  }
15
+ // local_find: only the host's candidate list, each [path, dev, ino] as the host saw it. A file that fails a local_read
16
+ // check, has a NUL byte in its first 8 KiB or cannot be read here is skipped, not reported.
17
+ const findText = (input) => {
18
+ const deadline = Date.now() + input.budget_ms, buffer = Buffer.alloc(1048577), matches = [];
19
+ let scanned = 0, size = 0, truncated = false;
20
+ const content = ([file, dev, ino]) => {
21
+ try {
22
+ let current = '';
23
+ for (const part of file.split('/').filter(Boolean)) {
24
+ current += '/' + part;
25
+ if (fs.lstatSync(current).isSymbolicLink()) return null;
26
+ }
27
+ const before = fs.lstatSync(file, {bigint: true});
28
+ if (!before.isFile() || before.nlink !== 1n || before.size > 1048576n || String(before.dev) !== dev || String(before.ino) !== ino) return null;
29
+ const fd = fs.openSync(file, fs.constants.O_NOFOLLOW | fs.constants.O_RDONLY);
30
+ try {
31
+ const actual = fs.fstatSync(fd, {bigint: true}), final = fs.lstatSync(file, {bigint: true});
32
+ if (actual.ino !== before.ino || actual.dev !== before.dev || final.ino !== before.ino || final.dev !== before.dev || actual.nlink !== 1n) return null;
33
+ const count = fs.readSync(fd, buffer, 0, buffer.length, 0);
34
+ if (count > 1048576 || buffer.subarray(0, Math.min(count, 8192)).includes(0)) return null;
35
+ return buffer.subarray(0, count).toString('utf8');
36
+ } finally { fs.closeSync(fd); }
37
+ } catch { return null; }
38
+ };
39
+ files: for (let index = 0; index < input.files.length; index++) {
40
+ if (Date.now() >= deadline) { truncated = true; break; }
41
+ const data = content(input.files[index]);
42
+ if (data === null) continue;
43
+ scanned++;
44
+ const lines = data.split('\n');
45
+ for (let line = 0; line < lines.length; line++) {
46
+ if (!lines[line].includes(input.text)) continue;
47
+ const match = [index, line + 1, lines[line].slice(0, 300)];
48
+ size += JSON.stringify(match).length + 1;
49
+ if (matches.length >= input.max_results || size > 65536) { truncated = true; break files; }
50
+ matches.push(match);
51
+ }
52
+ }
53
+ return {matches, scanned, truncated};
54
+ };
55
+ if (input.tool === 'local_find') process.stdout.write(JSON.stringify(findText(input)));
56
+ else {
15
57
  try {
16
58
  const readRange = input.tool === 'local_read' ? validateLocalReadRange(input) : undefined;
17
59
  let current = '';
@@ -90,4 +132,5 @@ try {
90
132
  if (!rangeDenied && !replaceDenied) throw error;
91
133
  process.stdout.write(JSON.stringify({denied: error.message}));
92
134
  }
135
+ }
93
136
  `;
@@ -1,4 +1,19 @@
1
- export const CLAUDE_LOCAL_MCP_TOOLS = Object.freeze(["local_read", "local_search", "local_edit", "local_replace", "local_test"].map(name => `mcp__ai_local__${name}`));
1
+ const localMcp = (names) => Object.freeze(names.map(name => `mcp__ai_local__${name}`));
2
+ const V1_TOOLS = ["local_read", "local_search", "local_edit", "local_replace", "local_test"];
3
+ /** Job-local MCP names of each tool inventory (local-tool-policy.ts LOCAL_TOOL_INVENTORIES, Work Decision f43e7aa0). The
4
+ * hook script does not load the policy module, so a test keeps the two equal. A job's policy allows only its own. */
5
+ export const CLAUDE_LOCAL_MCP_INVENTORIES = Object.freeze({
6
+ "local-tools-v1": localMcp(V1_TOOLS), "local-tools-v2": localMcp([...V1_TOOLS, "local_find"])
7
+ });
8
+ /** The local-tools-v1 names, what a job without a named inventory runs. */
9
+ export const CLAUDE_LOCAL_MCP_TOOLS = CLAUDE_LOCAL_MCP_INVENTORIES["local-tools-v1"];
10
+ const KNOWN_LOCAL_MCP_TOOLS = new Set(Object.values(CLAUDE_LOCAL_MCP_INVENTORIES).flat());
11
+ /** The names of one inventory; an unknown id fails closed. */
12
+ export function claudeLocalMcpTools(id = "local-tools-v1") {
13
+ if (!Object.hasOwn(CLAUDE_LOCAL_MCP_INVENTORIES, id))
14
+ throw new Error("DENIED_TOOL_INVENTORY");
15
+ return CLAUDE_LOCAL_MCP_INVENTORIES[id];
16
+ }
2
17
  function allow(reasonCode) {
3
18
  return {
4
19
  hookSpecificOutput: {
@@ -30,6 +45,6 @@ export function evaluateHookEvent(input, policy) {
30
45
  if (typeof toolName !== "string" || toolName.length === 0) {
31
46
  return deny("DENIED_MALFORMED_INPUT");
32
47
  }
33
- return CLAUDE_LOCAL_MCP_TOOLS.includes(toolName) && policy.allowedMcpTools.includes(toolName)
48
+ return KNOWN_LOCAL_MCP_TOOLS.has(toolName) && policy.allowedMcpTools.includes(toolName)
34
49
  ? allow("ALLOWED_LOCAL_TOOL") : deny("DENIED_TOOL_NOT_ALLOWED");
35
50
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mtech-ai-local-runner",
3
- "version": "0.1.77",
3
+ "version": "0.1.80",
4
4
  "private": false,
5
5
  "license": "UNLICENSED",
6
6
  "bin": {
@@ -8,6 +8,8 @@
8
8
  },
9
9
  "files": [
10
10
  "dist/src",
11
+ "claude-mod",
12
+ "!claude-mod/**/*.test.tsx",
11
13
  "README.md",
12
14
  "LICENSE",
13
15
  "native/bin/codex-observe-darwin-arm64",