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,20 @@
|
|
|
1
|
+
import { requireJobSecurity } from './job-security-policy.js';
|
|
2
|
+
import { requireSecurityRequirements } from './security-requirements.js';
|
|
3
|
+
/** Audit linkage only. Gateway must preserve employee_runner provenance; serialized
|
|
4
|
+
* JSON never mints host authority or native isolation certification. */
|
|
5
|
+
export function createJobSecurityReceipt(policy, input) {
|
|
6
|
+
try {
|
|
7
|
+
requireJobSecurity(policy, { adapterId: policy.pin.adapterId, adapterVersion: policy.pin.adapterVersion, jobId: input.jobId, generation: input.generation, modelId: input.modelId, sourceSha: input.sourceSha });
|
|
8
|
+
if (!Number.isSafeInteger(input.generation) || input.generation < 1 || [input.launchFingerprint, input.contractFingerprint, input.taskPackageFingerprint].some(v => !/^[a-f0-9]{64}$/.test(v)))
|
|
9
|
+
throw Error();
|
|
10
|
+
const r = requireSecurityRequirements(input.requirements, { adapterId: policy.pin.adapterId, adapterVersion: policy.pin.adapterVersion, requirementsSha256: policy.pin.requirementsSha256 });
|
|
11
|
+
if (policy.pin.securityMode !== r.security_mode)
|
|
12
|
+
throw Error();
|
|
13
|
+
const supported = !!policy.pin.securityMode;
|
|
14
|
+
return Object.freeze({ ...(supported ? { security_mode: r.security_mode, accepted_residual_risks: Object.freeze([...r.accepted_residual_risks]) } : {}), receipt_version: supported ? 'cli-job-security-receipt-v2' : 'cli-job-security-receipt-v1', evidence_origin: 'employee_runner', phase: 'pre_execution', job_id: input.jobId, generation: input.generation,
|
|
15
|
+
requirements_fingerprint: r.fingerprint, launch_fingerprint: input.launchFingerprint, contract_fingerprint: input.contractFingerprint, task_package_fingerprint: input.taskPackageFingerprint, policy_fingerprint: policy.fingerprint, pin: Object.freeze({ ...policy.pin }) });
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
throw new Error('JOB_SECURITY_RECEIPT_REJECTED');
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { requireSupportedCodexPolicy } from '../adapters/codex/supported-admission.js';
|
|
3
|
+
import { admitJobSecurity, requireJobSecurity } from './job-security-policy.js';
|
|
4
|
+
import { requireSecurityRequirements } from './security-requirements.js';
|
|
5
|
+
const fail = () => new Error('JOB_SECURITY_SESSION_REJECTED');
|
|
6
|
+
function canonical(v) {
|
|
7
|
+
if (Array.isArray(v))
|
|
8
|
+
return v.map(canonical);
|
|
9
|
+
if (v && typeof v === 'object')
|
|
10
|
+
return Object.fromEntries(Object.keys(v).sort().map(k => [k, canonical(v[k])]));
|
|
11
|
+
return v;
|
|
12
|
+
}
|
|
13
|
+
export const commandRegistryFingerprint = (commands) => createHash('sha256').update(JSON.stringify(canonical(commands))).digest('hex');
|
|
14
|
+
function freeze(value) {
|
|
15
|
+
if (value && typeof value === 'object') {
|
|
16
|
+
for (const item of Object.values(value))
|
|
17
|
+
freeze(item);
|
|
18
|
+
Object.freeze(value);
|
|
19
|
+
}
|
|
20
|
+
return value;
|
|
21
|
+
}
|
|
22
|
+
/** One issuance per job attempt, revoked on cancellation including async races.
|
|
23
|
+
* This wires verified authority; it does not establish native isolation itself. */
|
|
24
|
+
export class JobSecuritySession {
|
|
25
|
+
host;
|
|
26
|
+
used = false;
|
|
27
|
+
revoked = false;
|
|
28
|
+
policy;
|
|
29
|
+
request;
|
|
30
|
+
supportedFactory;
|
|
31
|
+
supportedContext;
|
|
32
|
+
supportedUsed = false;
|
|
33
|
+
registry = [];
|
|
34
|
+
nativeBinding;
|
|
35
|
+
constructor(host) {
|
|
36
|
+
this.host = host;
|
|
37
|
+
}
|
|
38
|
+
async authorize(input) {
|
|
39
|
+
if (this.used || this.revoked)
|
|
40
|
+
throw fail();
|
|
41
|
+
this.used = true;
|
|
42
|
+
let issued;
|
|
43
|
+
try {
|
|
44
|
+
if (!this.host)
|
|
45
|
+
throw fail();
|
|
46
|
+
const request = freeze(structuredClone(input));
|
|
47
|
+
if (!Number.isSafeInteger(request.generation) || request.generation < 1 || !/^[a-f0-9]{40}$/.test(request.sourceSha)
|
|
48
|
+
|| [request.adapterId, request.adapterVersion, request.jobId, request.modelId].some(v => typeof v !== 'string' || !v || v.length > 256 || /[\r\n\0]/.test(v)))
|
|
49
|
+
throw fail();
|
|
50
|
+
const requirements = requireSecurityRequirements(request.requirements, request);
|
|
51
|
+
if ([request.launchFingerprint, request.contractFingerprint, request.taskPackageFingerprint].some(s => !/^[a-f0-9]{64}$/.test(s)))
|
|
52
|
+
throw fail();
|
|
53
|
+
const plan = await this.host(request);
|
|
54
|
+
if (this.revoked)
|
|
55
|
+
throw fail();
|
|
56
|
+
// Snapshot authority before awaiting a verifier; neither commands nor pin may
|
|
57
|
+
// change underneath it. Keep only the host callback for live native identity.
|
|
58
|
+
const pin = freeze(structuredClone(plan.pin)), commands = freeze(structuredClone(plan.commandRegistry));
|
|
59
|
+
for (const key of ['adapterId', 'adapterVersion', 'jobId', 'generation', 'modelId', 'sourceSha'])
|
|
60
|
+
if (pin[key] !== request[key])
|
|
61
|
+
throw fail();
|
|
62
|
+
if (pin.requirementsSha256 !== requirements.fingerprint || !Array.isArray(commands)
|
|
63
|
+
|| pin.commandsSha256 !== commandRegistryFingerprint(commands) || typeof plan.nativeBinding !== 'function')
|
|
64
|
+
throw fail();
|
|
65
|
+
if (pin.securityMode !== requirements.security_mode || pin.securityMode && typeof plan.prepareSupportedAdmission !== 'function')
|
|
66
|
+
throw fail();
|
|
67
|
+
const nativeBinding = plan.nativeBinding.bind(plan);
|
|
68
|
+
issued = await admitJobSecurity(pin, plan.verify.bind(plan));
|
|
69
|
+
if (this.revoked)
|
|
70
|
+
throw fail();
|
|
71
|
+
requireJobSecurity(issued, request);
|
|
72
|
+
this.policy = issued;
|
|
73
|
+
this.registry = commands;
|
|
74
|
+
this.nativeBinding = nativeBinding;
|
|
75
|
+
this.request = request;
|
|
76
|
+
this.supportedFactory = pin.securityMode ? plan.prepareSupportedAdmission.bind(plan) : undefined;
|
|
77
|
+
return issued;
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
issued?.revoke();
|
|
81
|
+
this.revoke();
|
|
82
|
+
throw fail();
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
async supportedAdmission(expected) {
|
|
86
|
+
let context;
|
|
87
|
+
try {
|
|
88
|
+
if (this.revoked || this.supportedUsed || !this.policy?.pin.securityMode || !this.request || !this.supportedFactory)
|
|
89
|
+
throw fail();
|
|
90
|
+
this.supportedUsed = true;
|
|
91
|
+
requireJobSecurity(this.policy, this.request);
|
|
92
|
+
const captured = freeze(structuredClone(expected));
|
|
93
|
+
context = await this.supportedFactory(captured);
|
|
94
|
+
// Snapshot callback data before validation; only the capability retains identity.
|
|
95
|
+
context = { policy: context.policy, binding: freeze(structuredClone(context.binding)) };
|
|
96
|
+
if (this.revoked)
|
|
97
|
+
throw fail();
|
|
98
|
+
const p = this.policy.pin, r = this.request, b = context.binding;
|
|
99
|
+
if (b.jobId !== p.jobId || b.generation !== p.generation || b.sourceSha !== p.sourceSha || b.binarySha256 !== p.binarySha256
|
|
100
|
+
|| b.requirementsFingerprint !== p.requirementsSha256 || b.managedPolicyFingerprint !== p.managedPolicySha256 || b.commandsFingerprint !== p.commandsSha256
|
|
101
|
+
|| b.launchFingerprint !== r.launchFingerprint || b.contractFingerprint !== r.contractFingerprint || b.taskPackageFingerprint !== r.taskPackageFingerprint
|
|
102
|
+
|| captured.model !== p.modelId || captured.configFingerprint !== p.configSha256 || captured.toolSchemaFingerprint !== p.toolsSha256)
|
|
103
|
+
throw fail();
|
|
104
|
+
requireSupportedCodexPolicy(context.policy, captured, b);
|
|
105
|
+
requireJobSecurity(this.policy, r);
|
|
106
|
+
this.supportedContext = context;
|
|
107
|
+
return context;
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
context?.policy?.revoke();
|
|
111
|
+
this.revoke();
|
|
112
|
+
throw fail();
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
commands() {
|
|
116
|
+
return !this.revoked && this.policy ? structuredClone(this.registry) : [];
|
|
117
|
+
}
|
|
118
|
+
binding() {
|
|
119
|
+
try {
|
|
120
|
+
if (this.revoked || !this.policy)
|
|
121
|
+
return null;
|
|
122
|
+
requireJobSecurity(this.policy, this.policy.pin);
|
|
123
|
+
const b = this.nativeBinding?.(), p = this.policy.pin;
|
|
124
|
+
if (!b || b.jobId !== p.jobId || b.generation !== p.generation)
|
|
125
|
+
return null;
|
|
126
|
+
return { ...b };
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
revoke() { this.revoked = true; this.policy?.revoke(); this.supportedContext?.policy.revoke(); this.supportedFactory = undefined; this.request = undefined; this.registry = []; this.nativeBinding = undefined; }
|
|
133
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { localToolInventory } from './local-tool-server.js';
|
|
2
|
+
const rejected = () => new Error('JOB_TOOL_DISPATCH_REJECTED');
|
|
3
|
+
function record(value) { return !!value && typeof value === 'object' && !Array.isArray(value); }
|
|
4
|
+
const id = (value) => typeof value === 'string' && value.length > 0 && value.length <= 256 && !/[\r\n\0]/.test(value);
|
|
5
|
+
const keys = (value, expected) => JSON.stringify(Object.keys(value).sort()) === JSON.stringify([...expected].sort());
|
|
6
|
+
/** Host-owned validation before the existing scoped policy. Not a capability
|
|
7
|
+
* certificate: no production wiring until native authority is actually proven.
|
|
8
|
+
* Host-assigned decimal request sequence is strictly increasing on its ordered
|
|
9
|
+
* channel. A high-water mark rejects replay without an execution-count limit
|
|
10
|
+
* or an ever-growing cache. This sequence is not a model-selected capability. */
|
|
11
|
+
export class JobToolDispatcher {
|
|
12
|
+
policy;
|
|
13
|
+
active;
|
|
14
|
+
#binding;
|
|
15
|
+
#revoked = false;
|
|
16
|
+
#lastRequest = 0n;
|
|
17
|
+
constructor(binding, policy, active) {
|
|
18
|
+
this.policy = policy;
|
|
19
|
+
this.active = active;
|
|
20
|
+
if (![binding.jobId, binding.threadId, binding.turnId].every(id) || !Number.isSafeInteger(binding.generation) || binding.generation < 1)
|
|
21
|
+
throw rejected();
|
|
22
|
+
this.#binding = Object.freeze({ ...binding });
|
|
23
|
+
}
|
|
24
|
+
revoke() { if (this.#revoked)
|
|
25
|
+
return; this.#revoked = true; this.policy.revoke?.(); }
|
|
26
|
+
async dispatch(input) {
|
|
27
|
+
try {
|
|
28
|
+
if (this.#revoked || !this.active() || !record(input)
|
|
29
|
+
|| !keys(input, ['jobId', 'threadId', 'turnId', 'generation', 'requestId', 'tool', 'args']))
|
|
30
|
+
throw rejected();
|
|
31
|
+
const envelope = structuredClone(input), b = this.#binding;
|
|
32
|
+
if (envelope.jobId !== b.jobId || envelope.threadId !== b.threadId || envelope.turnId !== b.turnId || envelope.generation !== b.generation
|
|
33
|
+
|| !id(envelope.requestId) || !/^[1-9][0-9]*$/.test(envelope.requestId) || BigInt(envelope.requestId) <= this.#lastRequest
|
|
34
|
+
|| typeof envelope.tool !== 'string' || !record(envelope.args))
|
|
35
|
+
throw rejected();
|
|
36
|
+
const tool = localToolInventory().find(t => t.name === envelope.tool);
|
|
37
|
+
const properties = tool?.inputSchema.properties;
|
|
38
|
+
if (!record(properties) || !keys(envelope.args, Object.keys(properties))
|
|
39
|
+
|| Object.values(envelope.args).some(value => typeof value !== 'string')
|
|
40
|
+
|| Buffer.byteLength(JSON.stringify(envelope.args)) > 1024 * 1024)
|
|
41
|
+
throw rejected();
|
|
42
|
+
this.#lastRequest = BigInt(envelope.requestId);
|
|
43
|
+
const result = await this.policy.call(envelope.tool, envelope.args);
|
|
44
|
+
if (this.#revoked || !this.active())
|
|
45
|
+
throw rejected();
|
|
46
|
+
return result;
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
throw rejected();
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -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
|
+
}
|