mtech-ai-local-runner 0.1.8 → 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 +45 -4
- 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 +19 -2
- package/dist/src/daemon/runtime.js +34 -41
- 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,61 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
function object(value) {
|
|
3
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
4
|
+
throw new Error('Invalid Job policy');
|
|
5
|
+
return value;
|
|
6
|
+
}
|
|
7
|
+
function strings(value) {
|
|
8
|
+
if (!Array.isArray(value) || value.some(item => typeof item !== 'string'))
|
|
9
|
+
throw new Error('Invalid Job policy list');
|
|
10
|
+
return value;
|
|
11
|
+
}
|
|
12
|
+
function prefix(scope) {
|
|
13
|
+
if (!scope || path.isAbsolute(scope))
|
|
14
|
+
throw new Error('Unsupported Job path scope');
|
|
15
|
+
const relative = scope.replace(/^\.\//, '');
|
|
16
|
+
if (!relative || path.isAbsolute(relative))
|
|
17
|
+
throw new Error('Unsupported Job path scope');
|
|
18
|
+
const value = relative === '**' ? '' : relative.endsWith('/**') ? relative.slice(0, -3) : relative.replace(/\/$/, '');
|
|
19
|
+
if (path.isAbsolute(value) || /[?*\[\]{}\\\0]/.test(value) || value && value.split('/').some(segment => segment === '..' || segment === '.' || segment === ''))
|
|
20
|
+
throw new Error('Unsupported Job path scope');
|
|
21
|
+
return value;
|
|
22
|
+
}
|
|
23
|
+
const contains = (outer, inner) => !outer || outer === inner || inner.startsWith(outer + '/');
|
|
24
|
+
/** Exact host-owned command registry only. npm/git commands have no default registration:
|
|
25
|
+
* their dependencies and subprocess permissions need separate reviewed integration. */
|
|
26
|
+
export function jobToolPolicyOptions(worktreeRoot, pkg, approved, registry, runtime) {
|
|
27
|
+
if (!runtime.runtimeAdapter || !runtime.runtimeAdapterVersion || approved.runtime_adapter !== runtime.runtimeAdapter || approved.runtime_adapter_version !== runtime.runtimeAdapterVersion
|
|
28
|
+
|| pkg.execution !== undefined && object(pkg.execution).runtime_adapter !== runtime.runtimeAdapter)
|
|
29
|
+
throw new Error('Job runtime differs from approval');
|
|
30
|
+
const source = object(approved.repository);
|
|
31
|
+
if (!Array.isArray(pkg.repositories) || pkg.repositories.length !== 1)
|
|
32
|
+
throw new Error('Job requires one approved repository');
|
|
33
|
+
const repo = object(pkg.repositories[0]);
|
|
34
|
+
if (repo.repository_key !== source.key || repo.base_commit_sha !== source.base_commit_sha)
|
|
35
|
+
throw new Error('Job repository differs from approval');
|
|
36
|
+
if (typeof source.key !== 'string' || !source.key || typeof source.base_commit_sha !== 'string' || !/^[a-f0-9]{40}$/i.test(source.base_commit_sha)
|
|
37
|
+
|| (repo.access !== 'read' && repo.access !== 'write') || (source.access !== 'read' && source.access !== 'write'))
|
|
38
|
+
throw new Error('Job repository approval is invalid');
|
|
39
|
+
const allowedPaths = [];
|
|
40
|
+
for (const launch of strings(approved.allowed_paths))
|
|
41
|
+
for (const full of strings(repo.allowed_paths)) {
|
|
42
|
+
const a = prefix(launch), b = prefix(full);
|
|
43
|
+
if (contains(a, b))
|
|
44
|
+
allowedPaths.push(full);
|
|
45
|
+
else if (contains(b, a))
|
|
46
|
+
allowedPaths.push(launch);
|
|
47
|
+
}
|
|
48
|
+
if (allowedPaths.length === 0)
|
|
49
|
+
throw new Error('Job approved source scope has no intersection');
|
|
50
|
+
const commands = strings(object(object(pkg.contract).verification).required_commands);
|
|
51
|
+
const approvedCommands = strings(approved.required_commands);
|
|
52
|
+
if (commands.length !== approvedCommands.length || JSON.stringify([...commands].sort()) !== JSON.stringify([...approvedCommands].sort()))
|
|
53
|
+
throw new Error('Job required commands differ from approval');
|
|
54
|
+
const requiredCommands = commands.map(id => {
|
|
55
|
+
const entries = registry.filter(command => command.id === id);
|
|
56
|
+
if (entries.length !== 1)
|
|
57
|
+
throw new Error('Job required command is not exactly registered');
|
|
58
|
+
return structuredClone(entries[0]);
|
|
59
|
+
});
|
|
60
|
+
return { worktreeRoot, access: repo.access === 'write' && source.access === 'write' ? 'write' : 'read', allowedPaths: [...new Set(allowedPaths)], blockedPaths: strings(repo.blocked_paths), requiredCommands };
|
|
61
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { JobToolDispatcher } from './job-tool-dispatcher.js';
|
|
2
|
+
import { LocalToolPolicy } from './local-tool-policy.js';
|
|
3
|
+
import { LocalToolServer } from './local-tool-server.js';
|
|
4
|
+
const sameBinding = (a, b) => b !== null && a.jobId === b.jobId && a.threadId === b.threadId && a.turnId === b.turnId && a.generation === b.generation;
|
|
5
|
+
/** Per-job endpoint and scoped worker lifecycle. This is not native CLI admission. */
|
|
6
|
+
export class JobToolSession {
|
|
7
|
+
options;
|
|
8
|
+
policy;
|
|
9
|
+
server;
|
|
10
|
+
dispatcher;
|
|
11
|
+
bound;
|
|
12
|
+
sequence = 0n;
|
|
13
|
+
started = false;
|
|
14
|
+
revoked = false;
|
|
15
|
+
closing;
|
|
16
|
+
constructor(options) {
|
|
17
|
+
this.options = options;
|
|
18
|
+
this.policy = new LocalToolPolicy(options.policyOptions);
|
|
19
|
+
}
|
|
20
|
+
async call(tool, args) {
|
|
21
|
+
try {
|
|
22
|
+
if (this.revoked)
|
|
23
|
+
throw Error();
|
|
24
|
+
const binding = this.options.binding();
|
|
25
|
+
if (!binding)
|
|
26
|
+
throw Error();
|
|
27
|
+
if (!this.dispatcher) {
|
|
28
|
+
this.bound = Object.freeze({ ...binding });
|
|
29
|
+
this.dispatcher = new JobToolDispatcher(this.bound, this.policy, () => !this.revoked && this.options.active() && sameBinding(this.bound, this.options.binding()));
|
|
30
|
+
}
|
|
31
|
+
if (!sameBinding(this.bound, binding))
|
|
32
|
+
throw Error();
|
|
33
|
+
return await this.dispatcher.dispatch({ ...this.bound, requestId: String(++this.sequence), tool, args });
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
throw new Error('JOB_TOOL_DISPATCH_REJECTED');
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
async start() {
|
|
40
|
+
if (this.revoked || this.started)
|
|
41
|
+
throw new Error('JOB_TOOL_SESSION_START_FAILED');
|
|
42
|
+
this.started = true;
|
|
43
|
+
try {
|
|
44
|
+
const scoped = { call: (tool, args) => this.call(tool, args), revoke: () => this.policy.revoke() };
|
|
45
|
+
this.server = this.options.createServer?.(scoped) ?? new LocalToolServer({ policy: scoped });
|
|
46
|
+
const endpoint = await this.server.start();
|
|
47
|
+
if (this.revoked)
|
|
48
|
+
throw Error();
|
|
49
|
+
return endpoint;
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
await this.close().catch(() => { });
|
|
53
|
+
throw new Error('JOB_TOOL_SESSION_START_FAILED');
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
revoke() {
|
|
57
|
+
if (this.revoked)
|
|
58
|
+
return;
|
|
59
|
+
this.revoked = true;
|
|
60
|
+
// Clear the endpoint token before cancellation reaches any worker.
|
|
61
|
+
try {
|
|
62
|
+
this.server?.revoke();
|
|
63
|
+
}
|
|
64
|
+
finally {
|
|
65
|
+
this.dispatcher?.revoke();
|
|
66
|
+
this.policy.revoke();
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
close() {
|
|
70
|
+
if (this.closing)
|
|
71
|
+
return this.closing;
|
|
72
|
+
this.closing = (async () => {
|
|
73
|
+
try {
|
|
74
|
+
this.revoke();
|
|
75
|
+
}
|
|
76
|
+
finally {
|
|
77
|
+
await this.server?.close();
|
|
78
|
+
}
|
|
79
|
+
})();
|
|
80
|
+
return this.closing;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { randomBytes } from 'node:crypto';
|
|
3
|
+
import { lstat, realpath } from 'node:fs/promises';
|
|
4
|
+
import { evaluateCommand } from './command-policy.js';
|
|
5
|
+
import { runSandboxed } from './subprocess-sandbox.js';
|
|
6
|
+
import { LOCAL_TOOL_WORKER } from './local-tool-worker.js';
|
|
7
|
+
import { verifyCommandIntegrity } from './registered-command-integrity.js';
|
|
8
|
+
export const LOCAL_TOOL_NAMES = ['local_read', 'local_search', 'local_edit', 'local_test'];
|
|
9
|
+
// Private state/control basenames only: Auth.php, auth/ and mcp.ts are ordinary source.
|
|
10
|
+
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);
|
|
11
|
+
const contains = (root, file) => file === root || file.startsWith(root + path.sep);
|
|
12
|
+
function scopePath(root, scope) {
|
|
13
|
+
const directory = scope === '**' ? '.' : scope.endsWith('/**') ? scope.slice(0, -3) || '.' : scope;
|
|
14
|
+
if (/[?*\[\]{}]/.test(directory))
|
|
15
|
+
throw new Error('DENIED_UNSUPPORTED_SCOPE');
|
|
16
|
+
return path.resolve(root, directory);
|
|
17
|
+
}
|
|
18
|
+
export class LocalToolPolicy {
|
|
19
|
+
options;
|
|
20
|
+
abort = new AbortController();
|
|
21
|
+
constructor(options) {
|
|
22
|
+
this.options = structuredClone(options);
|
|
23
|
+
if (!path.isAbsolute(options.worktreeRoot))
|
|
24
|
+
throw new Error('DENIED_ROOT');
|
|
25
|
+
}
|
|
26
|
+
revoke() { this.abort.abort(); }
|
|
27
|
+
async call(tool, args) {
|
|
28
|
+
if (this.abort.signal.aborted)
|
|
29
|
+
throw new Error('DENIED_REVOKED');
|
|
30
|
+
if (!LOCAL_TOOL_NAMES.includes(tool))
|
|
31
|
+
throw new Error('DENIED_TOOL');
|
|
32
|
+
if (!args || typeof args !== 'object' || Array.isArray(args))
|
|
33
|
+
throw new Error('DENIED_ARGUMENTS');
|
|
34
|
+
const input = args;
|
|
35
|
+
if (tool === 'local_edit' && this.options.access !== 'write')
|
|
36
|
+
throw new Error('DENIED_READ_ONLY');
|
|
37
|
+
if (tool === 'local_test')
|
|
38
|
+
return this.test(input);
|
|
39
|
+
const file = await this.authorizedFile(input.path, tool === 'local_edit');
|
|
40
|
+
if (tool === 'local_edit' && (typeof input.content !== 'string' || Buffer.byteLength(input.content) > 1024 * 1024))
|
|
41
|
+
throw new Error('DENIED_CONTENT');
|
|
42
|
+
if (tool === 'local_search' && (typeof input.query !== 'string' || input.query.length > 4096))
|
|
43
|
+
throw new Error('DENIED_QUERY');
|
|
44
|
+
const temp = file + '.runner-edit-' + randomBytes(16).toString('hex');
|
|
45
|
+
const output = await runSandboxed({ executable: process.execPath, argv: ['--no-addons', '--no-global-search-paths', '--eval', LOCAL_TOOL_WORKER, JSON.stringify({ tool, path: file, temp, content: input.content, query: input.query })], readFiles: tool === 'local_edit' ? [file, temp] : [file], writeFiles: tool === 'local_edit' ? [file, temp] : [], timeoutMs: 10000, signal: this.abort.signal });
|
|
46
|
+
if (this.abort.signal.aborted)
|
|
47
|
+
throw new Error('DENIED_REVOKED');
|
|
48
|
+
return JSON.parse(output);
|
|
49
|
+
}
|
|
50
|
+
async authorizedFile(value, allowMissing = false) {
|
|
51
|
+
if (typeof value !== 'string' || !value || value.includes('\0') || path.isAbsolute(value) || value.split(/[\\/]/).some(p => p === '..' || sensitive(p)))
|
|
52
|
+
throw new Error('DENIED_PATH');
|
|
53
|
+
const root = path.resolve(this.options.worktreeRoot), file = path.resolve(root, value);
|
|
54
|
+
if (!contains(root, file) || !this.options.allowedPaths.some(scope => contains(scopePath(root, scope), file)) || this.options.blockedPaths.some(scope => contains(scopePath(root, scope), file)))
|
|
55
|
+
throw new Error('DENIED_SCOPE');
|
|
56
|
+
if (await realpath(root) !== root)
|
|
57
|
+
throw new Error('DENIED_ROOT');
|
|
58
|
+
let component = root;
|
|
59
|
+
for (const segment of path.relative(root, file).split(path.sep)) {
|
|
60
|
+
component = path.join(component, segment);
|
|
61
|
+
let stat;
|
|
62
|
+
try {
|
|
63
|
+
stat = await lstat(component);
|
|
64
|
+
}
|
|
65
|
+
catch (error) {
|
|
66
|
+
if (allowMissing && component === file && error.code === 'ENOENT')
|
|
67
|
+
return file;
|
|
68
|
+
throw error;
|
|
69
|
+
}
|
|
70
|
+
if (stat.isSymbolicLink())
|
|
71
|
+
throw new Error('DENIED_SYMLINK');
|
|
72
|
+
}
|
|
73
|
+
const stat = await lstat(file);
|
|
74
|
+
if (!stat.isFile())
|
|
75
|
+
throw new Error('DENIED_FILE');
|
|
76
|
+
if (stat.nlink !== 1)
|
|
77
|
+
throw new Error('DENIED_HARDLINK');
|
|
78
|
+
if (stat.size > 1024 * 1024)
|
|
79
|
+
throw new Error('DENIED_SIZE');
|
|
80
|
+
return file;
|
|
81
|
+
}
|
|
82
|
+
async test(input) {
|
|
83
|
+
const command = this.options.requiredCommands.find(command => command.id === input.command);
|
|
84
|
+
if (!command || !path.isAbsolute(command.executable) || command.argv.some(arg => typeof arg !== 'string'))
|
|
85
|
+
throw new Error('DENIED_COMMAND');
|
|
86
|
+
const rendered = [path.basename(command.executable), ...command.argv].join(' ');
|
|
87
|
+
if (!evaluateCommand(rendered, { requiredCommands: [rendered] }).allowed)
|
|
88
|
+
throw new Error('DENIED_COMMAND');
|
|
89
|
+
// Every dependency is an explicit registered file intersected with package policy.
|
|
90
|
+
// Test code has no worktree writes, ambient configuration, network or executable grants.
|
|
91
|
+
const executable = await realpath(command.executable);
|
|
92
|
+
const argv = executable === await realpath(process.execPath) ? ['--no-addons', '--no-global-search-paths', ...command.argv] : command.argv;
|
|
93
|
+
const readFiles = await Promise.all((command.readPaths ?? []).map(file => this.authorizedFile(file)));
|
|
94
|
+
await verifyCommandIntegrity(command.integrity, executable, command.readPaths ?? [], readFiles);
|
|
95
|
+
const output = await runSandboxed({ executable, argv, readFiles, signal: this.abort.signal });
|
|
96
|
+
await verifyCommandIntegrity(command.integrity, executable, command.readPaths ?? [], readFiles);
|
|
97
|
+
if (this.abort.signal.aborted)
|
|
98
|
+
throw new Error('DENIED_REVOKED');
|
|
99
|
+
return { output };
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { createServer } from 'node:http';
|
|
2
|
+
import { randomBytes, timingSafeEqual } from 'node:crypto';
|
|
3
|
+
import { LOCAL_TOOL_NAMES } from './local-tool-policy.js';
|
|
4
|
+
const MAX_BODY = 1024 * 1024;
|
|
5
|
+
const schemas = {
|
|
6
|
+
local_read: { type: 'object', properties: { path: { type: 'string' } }, required: ['path'], additionalProperties: false },
|
|
7
|
+
local_search: { type: 'object', properties: { path: { type: 'string' }, query: { type: 'string' } }, required: ['path', 'query'], additionalProperties: false },
|
|
8
|
+
local_edit: { type: 'object', properties: { path: { type: 'string' }, content: { type: 'string' } }, required: ['path', 'content'], additionalProperties: false },
|
|
9
|
+
local_test: { type: 'object', properties: { command: { type: 'string' } }, required: ['command'], additionalProperties: false },
|
|
10
|
+
};
|
|
11
|
+
/** Same immutable-by-copy inventory used by native admission and MCP discovery. */
|
|
12
|
+
export function localToolInventory() {
|
|
13
|
+
return LOCAL_TOOL_NAMES.map(name => ({ name, description: `Scoped ${name} operation`, inputSchema: structuredClone(schemas[name]) }));
|
|
14
|
+
}
|
|
15
|
+
/** Job-only JSON MCP endpoint; intentionally has no Gateway transport or discovery. */
|
|
16
|
+
export class LocalToolServer {
|
|
17
|
+
options;
|
|
18
|
+
server = null;
|
|
19
|
+
revoked = false;
|
|
20
|
+
starting = null;
|
|
21
|
+
token = null;
|
|
22
|
+
url = null;
|
|
23
|
+
constructor(options) {
|
|
24
|
+
this.options = options;
|
|
25
|
+
}
|
|
26
|
+
revoke() { this.revoked = true; this.token = null; this.options.policy.revoke?.(); }
|
|
27
|
+
async start() {
|
|
28
|
+
if (this.revoked)
|
|
29
|
+
throw new Error('Local tool server revoked');
|
|
30
|
+
if (this.server)
|
|
31
|
+
throw new Error('Local tool server already started');
|
|
32
|
+
this.token = randomBytes(32).toString('hex');
|
|
33
|
+
this.server = createServer(async (req, res) => {
|
|
34
|
+
const reply = (status, body) => {
|
|
35
|
+
if (res.writableEnded)
|
|
36
|
+
return;
|
|
37
|
+
res.writeHead(status, { 'content-type': 'application/json' });
|
|
38
|
+
res.end(JSON.stringify(body));
|
|
39
|
+
};
|
|
40
|
+
if (req.method !== 'POST') {
|
|
41
|
+
reply(405, { error: 'Method not allowed' });
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
if (req.url !== '/mcp') {
|
|
45
|
+
reply(404, { error: 'Not found' });
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
if (req.headers.origin !== undefined || req.headers.host !== new URL(this.url).host) {
|
|
49
|
+
reply(403, { error: 'Forbidden' });
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
const token = this.token, expected = Buffer.from(token ? `Bearer ${token}` : ''), supplied = Buffer.from(req.headers.authorization ?? '');
|
|
53
|
+
if (!token || expected.length !== supplied.length || !timingSafeEqual(expected, supplied)) {
|
|
54
|
+
reply(401, { error: 'Unauthorized' });
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
if (req.headers['content-type']?.split(';')[0] !== 'application/json') {
|
|
58
|
+
reply(415, { error: 'JSON required' });
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
try {
|
|
62
|
+
const chunks = [];
|
|
63
|
+
let size = 0;
|
|
64
|
+
for await (const chunk of req) {
|
|
65
|
+
size += chunk.length;
|
|
66
|
+
if (size > MAX_BODY) {
|
|
67
|
+
reply(413, { error: 'Payload too large' });
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
chunks.push(chunk);
|
|
71
|
+
}
|
|
72
|
+
if (this.token !== token) {
|
|
73
|
+
reply(401, { error: 'Authorization revoked' });
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
let value;
|
|
77
|
+
try {
|
|
78
|
+
value = JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
reply(400, { error: 'Invalid JSON' });
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
85
|
+
reply(400, { error: 'Invalid RPC' });
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
const rpc = value;
|
|
89
|
+
if (rpc.jsonrpc !== '2.0' || typeof rpc.method !== 'string') {
|
|
90
|
+
reply(400, { error: 'Invalid RPC' });
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
if (rpc.method === 'notifications/initialized' && rpc.id === undefined) {
|
|
94
|
+
reply(202, {});
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
if (!(typeof rpc.id === 'string' || typeof rpc.id === 'number' && Number.isFinite(rpc.id))) {
|
|
98
|
+
reply(400, { error: 'Invalid RPC id' });
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
const send = (result) => reply(200, { jsonrpc: '2.0', id: rpc.id, result });
|
|
102
|
+
if (rpc.method === 'initialize') {
|
|
103
|
+
send({ protocolVersion: '2024-11-05', capabilities: { tools: {} }, serverInfo: { name: 'runner-local-tools', version: '1' } });
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
if (rpc.method === 'ping') {
|
|
107
|
+
send({});
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
if (rpc.method === 'tools/list') {
|
|
111
|
+
send({ tools: localToolInventory() });
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
if (rpc.method !== 'tools/call' || !rpc.params || typeof rpc.params !== 'object' || Array.isArray(rpc.params)) {
|
|
115
|
+
reply(403, { error: 'Forbidden MCP request' });
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
const params = rpc.params;
|
|
119
|
+
if (!LOCAL_TOOL_NAMES.includes(params.name)) {
|
|
120
|
+
reply(403, { error: 'Forbidden tool' });
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
try {
|
|
124
|
+
const result = await this.options.policy.call(params.name, params.arguments);
|
|
125
|
+
if (this.token !== token) {
|
|
126
|
+
reply(401, { error: 'Authorization revoked' });
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
send({ content: [{ type: 'text', text: JSON.stringify(result) }] });
|
|
130
|
+
}
|
|
131
|
+
catch (error) {
|
|
132
|
+
if (this.token !== token) {
|
|
133
|
+
reply(401, { error: 'Authorization revoked' });
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
// Only stable policy error identifiers cross this boundary, never raw paths/stderr.
|
|
137
|
+
const code = error instanceof Error ? error.message.match(/^(DENIED_[A-Z_]+|LOCAL_SANDBOX_UNAVAILABLE)/)?.[0] : undefined;
|
|
138
|
+
send({ isError: true, content: [{ type: 'text', text: code ?? 'LOCAL_TOOL_FAILED' }] });
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
reply(400, { error: 'Invalid request' });
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
this.server.requestTimeout = 15000;
|
|
146
|
+
this.server.headersTimeout = 10000;
|
|
147
|
+
this.starting = new Promise((resolve, reject) => { this.server.once('error', reject); this.server.listen(0, '127.0.0.1', () => resolve()); });
|
|
148
|
+
await this.starting;
|
|
149
|
+
if (this.revoked) {
|
|
150
|
+
await this.close();
|
|
151
|
+
throw new Error('Local tool server revoked');
|
|
152
|
+
}
|
|
153
|
+
const address = this.server.address();
|
|
154
|
+
if (!address || typeof address === 'string')
|
|
155
|
+
throw new Error('Failed to bind local tool server');
|
|
156
|
+
this.url = `http://127.0.0.1:${address.port}/mcp`;
|
|
157
|
+
return { url: this.url, token: this.token };
|
|
158
|
+
}
|
|
159
|
+
async close() {
|
|
160
|
+
this.revoke();
|
|
161
|
+
const server = this.server;
|
|
162
|
+
this.server = null;
|
|
163
|
+
if (!server)
|
|
164
|
+
return;
|
|
165
|
+
await this.starting?.catch(() => { });
|
|
166
|
+
server.closeAllConnections();
|
|
167
|
+
await new Promise((resolve, reject) => server.close(error => {
|
|
168
|
+
if (error && error.code !== 'ERR_SERVER_NOT_RUNNING')
|
|
169
|
+
reject(error);
|
|
170
|
+
else
|
|
171
|
+
resolve();
|
|
172
|
+
}));
|
|
173
|
+
}
|
|
174
|
+
}
|
|
@@ -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
|
+
}
|