mtech-ai-local-runner 0.1.7 → 0.1.9

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 (56) hide show
  1. package/README.md +77 -2
  2. package/dist/src/adapters/claude-code/adapter.js +157 -98
  3. package/dist/src/adapters/claude-code/completion-report.js +2 -64
  4. package/dist/src/adapters/claude-code/prompt-compiler.js +1 -60
  5. package/dist/src/adapters/claude-code/runtime-options.js +72 -0
  6. package/dist/src/adapters/codex/adapter.js +170 -0
  7. package/dist/src/adapters/codex/app-server-events.js +84 -0
  8. package/dist/src/adapters/codex/compatibility.js +45 -0
  9. package/dist/src/adapters/codex/config-builder.js +40 -0
  10. package/dist/src/adapters/codex/host-admission.js +3 -0
  11. package/dist/src/adapters/codex/inspection-rpc.js +48 -0
  12. package/dist/src/adapters/codex/job-rpc.js +189 -0
  13. package/dist/src/adapters/codex/native-policy.js +112 -0
  14. package/dist/src/adapters/codex/native-proof.js +14 -0
  15. package/dist/src/adapters/codex/native-session.js +375 -0
  16. package/dist/src/adapters/codex/plugin.js +26 -0
  17. package/dist/src/adapters/codex/pre-model-gate.js +112 -0
  18. package/dist/src/adapters/codex/prompt-compiler.js +13 -0
  19. package/dist/src/adapters/codex/runtime-options.js +163 -0
  20. package/dist/src/adapters/codex/scratch.js +28 -0
  21. package/dist/src/adapters/codex/stdio-rpc.js +113 -0
  22. package/dist/src/adapters/codex/stream-parser.js +85 -0
  23. package/dist/src/adapters/codex/supported-admission.js +97 -0
  24. package/dist/src/adapters/codex/tool-dispatcher.js +20 -0
  25. package/dist/src/adapters/completion-report.js +64 -0
  26. package/dist/src/adapters/discovery.js +76 -0
  27. package/dist/src/adapters/prompt-compiler.js +60 -0
  28. package/dist/src/adapters/registry.js +10 -5
  29. package/dist/src/cli.js +50 -58
  30. package/dist/src/commands/doctor.js +7 -1
  31. package/dist/src/commands/setup.js +3 -0
  32. package/dist/src/daemon/execution-coordinator.js +91 -19
  33. package/dist/src/daemon/job-poller.js +10 -1
  34. package/dist/src/daemon/runtime.js +55 -41
  35. package/dist/src/gateway-client.js +19 -3
  36. package/dist/src/process/node-process-group-host.js +74 -0
  37. package/dist/src/safety/cli-host-admission.js +74 -0
  38. package/dist/src/safety/cli-security-modes.js +4 -0
  39. package/dist/src/safety/codex-production-host.js +267 -0
  40. package/dist/src/safety/codex-supported-releases.js +1 -0
  41. package/dist/src/safety/hook-cli.js +21 -7
  42. package/dist/src/safety/hook-policy-builder.js +4 -11
  43. package/dist/src/safety/job-security-policy.js +62 -0
  44. package/dist/src/safety/job-security-receipt.js +20 -0
  45. package/dist/src/safety/job-security-session.js +133 -0
  46. package/dist/src/safety/job-tool-dispatcher.js +52 -0
  47. package/dist/src/safety/job-tool-policy.js +61 -0
  48. package/dist/src/safety/job-tool-session.js +82 -0
  49. package/dist/src/safety/local-tool-policy.js +101 -0
  50. package/dist/src/safety/local-tool-server.js +174 -0
  51. package/dist/src/safety/local-tool-worker.js +57 -0
  52. package/dist/src/safety/pre-tool-use-hook.js +3 -39
  53. package/dist/src/safety/registered-command-integrity.js +52 -0
  54. package/dist/src/safety/security-requirements.js +38 -0
  55. package/dist/src/safety/subprocess-sandbox.js +62 -0
  56. package/package.json +1 -1
@@ -0,0 +1,57 @@
1
+ /** Standalone worker: --eval avoids ambient module/config lookup. All paths are host-supplied. */
2
+ export const LOCAL_TOOL_WORKER = String.raw `
3
+ const fs = require('node:fs');
4
+ const input = JSON.parse(process.argv[1]);
5
+ let current = '';
6
+ for (const part of input.path.split('/').filter(Boolean)) {
7
+ current += '/' + part;
8
+ try {
9
+ if (fs.lstatSync(current).isSymbolicLink()) throw Error('DENIED_SYMLINK');
10
+ } catch (error) {
11
+ if (!(error.code === 'ENOENT' && current === input.path && input.tool === 'local_edit')) throw error;
12
+ }
13
+ }
14
+ let before;
15
+ try { before = fs.lstatSync(input.path); } catch (error) {
16
+ if (!(error.code === 'ENOENT' && input.tool === 'local_edit')) throw error;
17
+ }
18
+ if (before && (!before.isFile() || before.nlink !== 1 || before.size > 1048576)) throw Error('DENIED_FILE');
19
+ const fd = before ? fs.openSync(input.path, fs.constants.O_NOFOLLOW | fs.constants.O_RDONLY) : null;
20
+ try {
21
+ const actual = fd === null ? null : fs.fstatSync(fd);
22
+ if (actual) {
23
+ const final = fs.lstatSync(input.path);
24
+ if (actual.ino !== before.ino || actual.dev !== before.dev || actual.ino !== final.ino || actual.dev !== final.dev || actual.nlink !== 1) throw Error('DENIED_RACE');
25
+ }
26
+ let result;
27
+ if (input.tool === 'local_edit') {
28
+ const data = Buffer.from(input.content, 'utf8');
29
+ const temp = fs.openSync(input.temp, fs.constants.O_NOFOLLOW | fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY, before ? before.mode & 0o777 : 0o600);
30
+ try {
31
+ fs.writeFileSync(temp, data);
32
+ fs.fsyncSync(temp);
33
+ const tempStat = fs.fstatSync(temp), tempFinal = fs.lstatSync(input.temp);
34
+ if (tempStat.nlink !== 1 || tempStat.ino !== tempFinal.ino || tempStat.dev !== tempFinal.dev) throw Error('DENIED_RACE');
35
+ if (actual) {
36
+ const still = fs.lstatSync(input.path);
37
+ if (still.isSymbolicLink() || still.ino !== actual.ino || still.dev !== actual.dev || still.nlink !== 1) throw Error('DENIED_RACE');
38
+ fs.renameSync(input.temp, input.path);
39
+ } else {
40
+ // Exclusive creation: a concurrent target cannot be overwritten.
41
+ fs.linkSync(input.temp, input.path);
42
+ }
43
+ result = {written: data.length};
44
+ } finally {
45
+ fs.closeSync(temp);
46
+ try { fs.unlinkSync(input.temp); } catch {}
47
+ }
48
+ } else {
49
+ const buffer = Buffer.alloc(1048577);
50
+ const count = fs.readSync(fd, buffer, 0, buffer.length, 0);
51
+ if (count > 1048576) throw Error('DENIED_SIZE');
52
+ const content = buffer.subarray(0, count).toString('utf8');
53
+ result = input.tool === 'local_search' ? {matches: content.split('\n').flatMap((line, index) => line.includes(input.query) ? [{line: index + 1, text: line}] : [])} : {content};
54
+ }
55
+ process.stdout.write(JSON.stringify(result));
56
+ } finally { if (fd !== null) fs.closeSync(fd); }
57
+ `;
@@ -1,6 +1,4 @@
1
- import { evaluateCommand, evaluatePathAction } from "./command-policy.js";
2
- const PATH_EDIT_TOOLS = new Set(["Edit", "Write", "MultiEdit", "NotebookEdit"]);
3
- const READ_PATH_TOOLS = new Set(["Read", "Glob", "Grep"]);
1
+ export const CLAUDE_LOCAL_MCP_TOOLS = Object.freeze(["local_read", "local_search", "local_edit", "local_test"].map(name => `mcp__ai_local__${name}`));
4
2
  function allow(reasonCode) {
5
3
  return {
6
4
  hookSpecificOutput: {
@@ -19,16 +17,6 @@ function deny(reasonCode) {
19
17
  },
20
18
  };
21
19
  }
22
- function pathFromToolInput(toolInput, keys) {
23
- if (!toolInput)
24
- return undefined;
25
- for (const key of keys) {
26
- const value = toolInput[key];
27
- if (typeof value === "string" && value.length > 0)
28
- return value;
29
- }
30
- return undefined;
31
- }
32
20
  /**
33
21
  * Pure PreToolUse decision function: deterministic, side-effect free, and
34
22
  * never echoes raw commands/paths/tool output/secrets back in a decision
@@ -42,30 +30,6 @@ export function evaluateHookEvent(input, policy) {
42
30
  if (typeof toolName !== "string" || toolName.length === 0) {
43
31
  return deny("DENIED_MALFORMED_INPUT");
44
32
  }
45
- if (toolName === "Bash") {
46
- const command = input.tool_input?.command;
47
- if (typeof command !== "string" || command.length === 0) {
48
- return deny("DENIED_MALFORMED_INPUT");
49
- }
50
- const decision = evaluateCommand(command, { requiredCommands: policy.requiredCommands });
51
- return decision.allowed ? allow(decision.reasonCode) : deny(decision.reasonCode);
52
- }
53
- if (PATH_EDIT_TOOLS.has(toolName) || READ_PATH_TOOLS.has(toolName)) {
54
- const relativePath = pathFromToolInput(input.tool_input, ["file_path", "path", "notebook_path", "pattern"]);
55
- if (!relativePath) {
56
- return deny("DENIED_MALFORMED_INPUT");
57
- }
58
- const decision = evaluatePathAction(relativePath, {
59
- worktreeRoot: policy.worktreeRoot,
60
- blockedPaths: policy.blockedPaths,
61
- });
62
- return decision.allowed ? allow(decision.reasonCode) : deny(decision.reasonCode);
63
- }
64
- if (toolName.startsWith("mcp__")) {
65
- const contractToolName = toolName.includes("__") ? toolName.slice(toolName.lastIndexOf("__") + 2) : toolName;
66
- return policy.allowedMcpTools.includes(toolName) || policy.allowedMcpTools.includes(contractToolName)
67
- ? allow("ALLOWED_MCP_READ_TOOL")
68
- : deny("DENIED_MCP_TOOL_NOT_ALLOWED");
69
- }
70
- return deny("DENIED_UNKNOWN_TOOL");
33
+ return CLAUDE_LOCAL_MCP_TOOLS.includes(toolName) && policy.allowedMcpTools.includes(toolName)
34
+ ? allow("ALLOWED_LOCAL_TOOL") : deny("DENIED_TOOL_NOT_ALLOWED");
71
35
  }
@@ -0,0 +1,52 @@
1
+ import { constants } from 'node:fs';
2
+ import { open, lstat } from 'node:fs/promises';
3
+ import { createHash } from 'node:crypto';
4
+ const denied = () => new Error('DENIED_COMMAND_INTEGRITY');
5
+ /** Measures one no-follow regular file. This does not claim atomic path-based
6
+ * exec: OS confinement must contain a racing/hostile script independently. */
7
+ export async function measureCommandFile(file) {
8
+ const fd = await open(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
9
+ try {
10
+ const before = await fd.stat({ bigint: true });
11
+ if (!before.isFile() || before.nlink !== 1n || before.size > 512n * 1024n * 1024n)
12
+ throw denied();
13
+ const hash = createHash('sha256');
14
+ let size = 0n;
15
+ for await (const chunk of fd.createReadStream({ autoClose: false })) {
16
+ size += BigInt(chunk.length);
17
+ if (size > 512n * 1024n * 1024n)
18
+ throw denied();
19
+ hash.update(chunk);
20
+ }
21
+ const after = await fd.stat({ bigint: true }), named = await lstat(file, { bigint: true });
22
+ for (const stat of [after, named])
23
+ if (!stat.isFile() || stat.dev !== before.dev || stat.ino !== before.ino
24
+ || stat.nlink !== 1n || stat.size !== before.size || stat.mtimeNs !== before.mtimeNs || stat.ctimeNs !== before.ctimeNs)
25
+ throw denied();
26
+ if (size !== before.size)
27
+ throw denied();
28
+ return hash.digest('hex');
29
+ }
30
+ finally {
31
+ await fd.close();
32
+ }
33
+ }
34
+ export async function verifyCommandIntegrity(integrity, executable, paths, files) {
35
+ try {
36
+ const hex = (v) => typeof v === 'string' && /^[a-f0-9]{64}$/.test(v);
37
+ if (!integrity || !hex(integrity.executableSha256) || !integrity.readFiles || Array.isArray(integrity.readFiles)
38
+ || new Set(paths).size !== paths.length || files.length !== paths.length
39
+ || JSON.stringify(Object.keys(integrity.readFiles).sort()) !== JSON.stringify([...paths].sort()))
40
+ throw denied();
41
+ if (await measureCommandFile(executable) !== integrity.executableSha256)
42
+ throw denied();
43
+ for (let i = 0; i < paths.length; i++) {
44
+ const expected = integrity.readFiles[paths[i]];
45
+ if (!hex(expected) || await measureCommandFile(files[i]) !== expected)
46
+ throw denied();
47
+ }
48
+ }
49
+ catch {
50
+ throw denied();
51
+ }
52
+ }
@@ -0,0 +1,38 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { SUPPORTED_CLI_MODE, SUPPORTED_CLI_GATES, SUPPORTED_CLI_RISKS } from './cli-security-modes.js';
3
+ import { JOB_SECURITY_GATES } from './job-security-policy.js';
4
+ const rejected = () => new Error('SECURITY_REQUIREMENTS_REJECTED');
5
+ function canonical(v) { if (Array.isArray(v))
6
+ return v.map(canonical); if (v && typeof v === 'object')
7
+ return Object.fromEntries(Object.keys(v).sort().map(k => [k, canonical(v[k])])); return v; }
8
+ /** Same canonical domain as Gateway JSON_UNESCAPED_SLASHES packages. */
9
+ export function securityRequirementsFingerprint(value) {
10
+ const unsigned = { ...value };
11
+ delete unsigned.fingerprint;
12
+ return createHash('sha256').update(JSON.stringify(canonical(unsigned)).replace(/[\u007f-\uffff]/g, c => '\\u' + c.charCodeAt(0).toString(16).padStart(4, '0'))).digest('hex');
13
+ }
14
+ export function requireSecurityRequirements(value, expected) {
15
+ try {
16
+ if (!value || typeof value !== 'object' || Array.isArray(value))
17
+ throw rejected();
18
+ const r = value;
19
+ const supported = r.version === 'cli-security-requirements-v2';
20
+ const fields = ['version', 'adapter_id', 'adapter_version', 'mandatory_gates', 'status', 'evidence_class', 'release_manifest_sha256', 'fingerprint', ...(supported ? ['security_mode', 'accepted_residual_risks'] : [])];
21
+ if (JSON.stringify(Object.keys(r).sort()) !== JSON.stringify(fields.sort())
22
+ || (!supported && r.version !== 'cli-security-requirements-v1') || r.adapter_id !== expected.adapterId || r.adapter_version !== expected.adapterVersion
23
+ || JSON.stringify(r.mandatory_gates) !== JSON.stringify(supported ? SUPPORTED_CLI_GATES : JOB_SECURITY_GATES) || r.status !== 'verified' || r.evidence_class !== (supported ? 'supported_policy_release' : 'native_release')
24
+ || supported && (r.adapter_id !== 'codex_cli' || r.security_mode !== SUPPORTED_CLI_MODE || JSON.stringify(r.accepted_residual_risks) !== JSON.stringify(SUPPORTED_CLI_RISKS))
25
+ || typeof r.release_manifest_sha256 !== 'string' || typeof r.fingerprint !== 'string'
26
+ || !/^[a-f0-9]{64}$/.test(r.release_manifest_sha256) || !/^[a-f0-9]{64}$/.test(r.fingerprint)
27
+ || securityRequirementsFingerprint(r) !== r.fingerprint || expected.requirementsSha256 !== undefined && expected.requirementsSha256 !== r.fingerprint)
28
+ throw rejected();
29
+ const copy = structuredClone(r);
30
+ Object.freeze(copy.mandatory_gates);
31
+ if (copy.accepted_residual_risks)
32
+ Object.freeze(copy.accepted_residual_risks);
33
+ return Object.freeze(copy);
34
+ }
35
+ catch {
36
+ throw rejected();
37
+ }
38
+ }
@@ -0,0 +1,62 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { realpathSync } from 'node:fs';
3
+ import path from 'node:path';
4
+ const quote = (value) => JSON.stringify(value);
5
+ /** Deny-default Seatbelt profile. No inherited profile, environment or shell. */
6
+ export function sandboxProfile(request) {
7
+ const executable = realpathSync(request.executable);
8
+ const directories = new Set(['/']);
9
+ for (const file of [executable, ...request.readFiles, ...request.writeFiles ?? []]) {
10
+ let directory = path.dirname(file);
11
+ while (directory !== '/') {
12
+ directories.add(directory);
13
+ directory = path.dirname(directory);
14
+ }
15
+ }
16
+ const read = [...directories, ...request.readFiles, executable, '/dev/null', '/dev/random', '/dev/urandom'].map(file => `(literal ${quote(file)})`).join(' ');
17
+ const write = (request.writeFiles ?? []).map(file => `(literal ${quote(file)})`).join(' ');
18
+ return `(version 1)(deny default)(allow process-exec (literal ${quote(executable)}))(allow sysctl-read)(allow file-read-metadata)(allow file-map-executable (subpath "/System/Library") (subpath "/usr/lib") (literal ${quote(executable)}))(allow file-read-data (subpath "/System/Library") (subpath "/usr/lib") (subpath "/usr/share") ${read})${write ? `(allow file-write* ${write})` : ''}`;
19
+ }
20
+ export async function runSandboxed(request) {
21
+ if (process.platform !== 'darwin')
22
+ throw new Error('LOCAL_SANDBOX_UNAVAILABLE: macOS Seatbelt required');
23
+ const profile = sandboxProfile(request);
24
+ return new Promise((resolve, reject) => {
25
+ const chunks = [];
26
+ let size = 0, settled = false;
27
+ const child = spawn('/usr/bin/sandbox-exec', ['-p', profile, realpathSync(request.executable), ...request.argv], {
28
+ cwd: request.cwd ?? '/', env: { PATH: '/usr/bin:/bin', LANG: 'C', HOME: '/nonexistent', TMPDIR: '/nonexistent' }, stdio: ['ignore', 'pipe', 'pipe'], detached: true, signal: request.signal,
29
+ });
30
+ const finish = (error) => {
31
+ if (settled)
32
+ return;
33
+ settled = true;
34
+ clearTimeout(timer);
35
+ if (error)
36
+ reject(error);
37
+ else
38
+ resolve(Buffer.concat(chunks).toString('utf8'));
39
+ };
40
+ const kill = () => {
41
+ try {
42
+ if (child.pid)
43
+ process.kill(-child.pid, 'SIGKILL');
44
+ }
45
+ catch { /* already exited */ }
46
+ };
47
+ const timer = request.timeoutMs === undefined ? undefined : setTimeout(() => { kill(); finish(new Error('LOCAL_SANDBOX_UNAVAILABLE: command timed out')); }, request.timeoutMs);
48
+ child.stdout.on('data', (chunk) => {
49
+ size += chunk.length;
50
+ if (size > 1024 * 1024) {
51
+ kill();
52
+ finish(new Error('LOCAL_SANDBOX_UNAVAILABLE: output limit'));
53
+ }
54
+ else
55
+ chunks.push(chunk);
56
+ });
57
+ // Do not disclose arbitrary subprocess stderr or paths to the model.
58
+ child.stderr.resume();
59
+ child.on('error', () => { kill(); finish(new Error('LOCAL_SANDBOX_UNAVAILABLE: could not start restricted process')); });
60
+ child.on('close', (code) => { kill(); finish(code === 0 ? undefined : new Error('LOCAL_SANDBOX_UNAVAILABLE: restricted process failed')); });
61
+ });
62
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mtech-ai-local-runner",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
4
4
  "private": false,
5
5
  "license": "UNLICENSED",
6
6
  "bin": {