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.
- package/README.md +77 -2
- package/dist/src/adapters/claude-code/adapter.js +157 -98
- package/dist/src/adapters/claude-code/completion-report.js +2 -64
- package/dist/src/adapters/claude-code/prompt-compiler.js +1 -60
- package/dist/src/adapters/claude-code/runtime-options.js +72 -0
- package/dist/src/adapters/codex/adapter.js +170 -0
- package/dist/src/adapters/codex/app-server-events.js +84 -0
- package/dist/src/adapters/codex/compatibility.js +45 -0
- package/dist/src/adapters/codex/config-builder.js +40 -0
- package/dist/src/adapters/codex/host-admission.js +3 -0
- package/dist/src/adapters/codex/inspection-rpc.js +48 -0
- package/dist/src/adapters/codex/job-rpc.js +189 -0
- package/dist/src/adapters/codex/native-policy.js +112 -0
- package/dist/src/adapters/codex/native-proof.js +14 -0
- package/dist/src/adapters/codex/native-session.js +375 -0
- package/dist/src/adapters/codex/plugin.js +26 -0
- package/dist/src/adapters/codex/pre-model-gate.js +112 -0
- package/dist/src/adapters/codex/prompt-compiler.js +13 -0
- package/dist/src/adapters/codex/runtime-options.js +163 -0
- package/dist/src/adapters/codex/scratch.js +28 -0
- package/dist/src/adapters/codex/stdio-rpc.js +113 -0
- package/dist/src/adapters/codex/stream-parser.js +85 -0
- package/dist/src/adapters/codex/supported-admission.js +97 -0
- package/dist/src/adapters/codex/tool-dispatcher.js +20 -0
- package/dist/src/adapters/completion-report.js +64 -0
- package/dist/src/adapters/discovery.js +76 -0
- package/dist/src/adapters/prompt-compiler.js +60 -0
- package/dist/src/adapters/registry.js +10 -5
- package/dist/src/cli.js +50 -58
- package/dist/src/commands/doctor.js +7 -1
- package/dist/src/commands/setup.js +3 -0
- package/dist/src/daemon/execution-coordinator.js +91 -19
- package/dist/src/daemon/job-poller.js +10 -1
- package/dist/src/daemon/runtime.js +55 -41
- package/dist/src/gateway-client.js +19 -3
- package/dist/src/process/node-process-group-host.js +74 -0
- package/dist/src/safety/cli-host-admission.js +74 -0
- package/dist/src/safety/cli-security-modes.js +4 -0
- package/dist/src/safety/codex-production-host.js +267 -0
- package/dist/src/safety/codex-supported-releases.js +1 -0
- package/dist/src/safety/hook-cli.js +21 -7
- package/dist/src/safety/hook-policy-builder.js +4 -11
- package/dist/src/safety/job-security-policy.js +62 -0
- package/dist/src/safety/job-security-receipt.js +20 -0
- package/dist/src/safety/job-security-session.js +133 -0
- package/dist/src/safety/job-tool-dispatcher.js +52 -0
- package/dist/src/safety/job-tool-policy.js +61 -0
- package/dist/src/safety/job-tool-session.js +82 -0
- package/dist/src/safety/local-tool-policy.js +101 -0
- package/dist/src/safety/local-tool-server.js +174 -0
- package/dist/src/safety/local-tool-worker.js +57 -0
- package/dist/src/safety/pre-tool-use-hook.js +3 -39
- package/dist/src/safety/registered-command-integrity.js +52 -0
- package/dist/src/safety/security-requirements.js +38 -0
- package/dist/src/safety/subprocess-sandbox.js +62 -0
- 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
|
-
|
|
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
|
-
|
|
46
|
-
|
|
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
|
+
}
|