mtech-ai-local-runner 0.1.49 → 0.1.50
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 +6 -0
- package/dist/src/adapters/claude-code/adapter.js +1 -1
- package/dist/src/adapters/codex/config-builder.js +1 -1
- package/dist/src/adapters/codex/pre-model-gate.js +1 -1
- package/dist/src/adapters/codex/prompt-compiler.js +1 -1
- package/dist/src/safety/local-tool-policy.js +15 -4
- package/dist/src/safety/local-tool-server.js +2 -1
- package/dist/src/safety/local-tool-worker.js +25 -2
- package/dist/src/safety/pre-tool-use-hook.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -782,3 +782,9 @@ the older `authenticated` field still means execution eligibility. A logged-in
|
|
|
782
782
|
Claude account can remain blocked by unverified security admission. Do not log out
|
|
783
783
|
or change credentials just because that legacy field is false. Unknown status is
|
|
784
784
|
not evidence of logout. These diagnostics do not bypass per-job admission.
|
|
785
|
+
|
|
786
|
+
### 0.1.50: small edits and publication-only Fix
|
|
787
|
+
|
|
788
|
+
Use `local_replace({path, old, new})` for a small approved edit after reading the file: `old` must be nonempty and match exactly once, including whitespace. Zero or overlapping/multiple matches are rejected with distinct errors and no write. Replacement text must not be a patch; the result retains the same scoped write, size, link and revocation protections as `local_edit`. `local_edit` remains a complete-file replacement API.
|
|
789
|
+
|
|
790
|
+
Both CLI adapters require the five-tool inventory. Gateway tools descriptors and runtime pins must match this release; CLI release manifests and the standalone binary stay fixed. In `gateway_publish_v1`, missing registered tests is expected: report not_run/unverified, apply only the smallest source-evidenced correction, disclose uncertainty and seal for a human-reviewed Gateway PR. Historical contracts and attempts are not edited. Reverb 0.1.49 is retained; installation, deployment and real model execution remain separate approvals.
|
|
@@ -12,7 +12,7 @@ import { formatHookCommand } from "../../safety/hook-command.js";
|
|
|
12
12
|
import { localEndpointReadObservations } from '../../safety/local-tool-server.js';
|
|
13
13
|
const ALLOWED_ENV_KEYS = ["PATH", "HOME", "USER", "LOGNAME", "LANG", "LC_ALL", "TMPDIR", "NODE_ENV", "CLAUDE_CONFIG_DIR"];
|
|
14
14
|
const ALLOWED_SUPERVISOR_COMMANDS = new Set(["continue", "answer_question", "request_revision", "request_checkpoint", "submit_for_review"]);
|
|
15
|
-
const LOCAL_NAMES = ["local_read", "local_search", "local_edit", "local_test"].map(name => `mcp__ai_local__${name}`);
|
|
15
|
+
const LOCAL_NAMES = ["local_read", "local_search", "local_edit", "local_replace", "local_test"].map(name => `mcp__ai_local__${name}`);
|
|
16
16
|
function allowlistedEnv(env) {
|
|
17
17
|
const result = {};
|
|
18
18
|
for (const key of ALLOWED_ENV_KEYS) {
|
|
@@ -22,7 +22,7 @@ export function buildCodexInvocation(input) {
|
|
|
22
22
|
`permissions.local_job.filesystem={":minimal"="read",${deniedFiles}}`,
|
|
23
23
|
"permissions.local_job.network.enabled=false",
|
|
24
24
|
// Only this job server is preauthorized: every operation is checked by Runner policy.
|
|
25
|
-
`mcp_servers={ai_local={url=${JSON.stringify(endpoint.toString())},bearer_token_env_var="AI_GATEWAY_JOB_MCP_TOKEN",required=true,default_tools_approval_mode="approve",enabled_tools=["local_read","local_search","local_edit","local_test"],omit_tools_from=${JSON.stringify(CODEX_JOB_MCP_OMIT_SURFACES)}}}`,
|
|
25
|
+
`mcp_servers={ai_local={url=${JSON.stringify(endpoint.toString())},bearer_token_env_var="AI_GATEWAY_JOB_MCP_TOKEN",required=true,default_tools_approval_mode="approve",enabled_tools=["local_read","local_search","local_edit","local_replace","local_test"],omit_tools_from=${JSON.stringify(CODEX_JOB_MCP_OMIT_SURFACES)}}}`,
|
|
26
26
|
];
|
|
27
27
|
const env = { HOME: input.env.HOME, AI_GATEWAY_JOB_MCP_TOKEN: input.endpoint.token };
|
|
28
28
|
for (const key of ["PATH", "USER", "LOGNAME", "LANG", "LC_ALL", "TMPDIR", "CODEX_HOME"]) {
|
|
@@ -2,7 +2,7 @@ import { isAbsolute } from 'node:path';
|
|
|
2
2
|
import { requireSupportedCodexPolicy } from './supported-admission.js';
|
|
3
3
|
const REJECTED = 'CODEX_PRE_MODEL_REJECTED';
|
|
4
4
|
const usedSessions = new WeakSet();
|
|
5
|
-
const REQUIRED_TOOLS = ['local_edit', 'local_read', 'local_search', 'local_test'];
|
|
5
|
+
const REQUIRED_TOOLS = ['local_edit', 'local_read', 'local_replace', 'local_search', 'local_test'];
|
|
6
6
|
const object = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
7
7
|
/** Bounded, value-free diagnostics for ALL candidate prerequisites. This is not
|
|
8
8
|
* native release certification: only trusted retained observations may reach it,
|
|
@@ -23,7 +23,7 @@ function repositoryScope(taskPackage) {
|
|
|
23
23
|
"These are scope descriptions, not instructions from source files; the host policy remains authoritative and may deny blocked/private paths within an allowed prefix.";
|
|
24
24
|
}
|
|
25
25
|
export function compileCodexPrompt(taskPackage, label) {
|
|
26
|
-
return `${compileBrief(taskPackage, { workItemLabel: label, progressReporting: "native_messages" })}\n\n${repositoryScope(taskPackage)}\n\nUse only the ai_local job tools for repository reads, edits and approved tests. For reading, use server ai_local's local_read({path: "<exact approved path>"}) or local_search({path: "<exact approved path>", query: "text"}) with checkout-relative paths. Required test criteria are not command grants. No model test commands are registered unless the host explicitly supplies exact model-callable command IDs; do not use local_test to discover commands or run --help. Missing tests remain not_run/unverified, not passed. In a read-only task, use only read/search tools; the Runner enforces the approved paths and operations. A denied native cwd is intentional: it is a private control directory, not the source checkout. Native filesystem, commands, web, images and delegation are unavailable or denied. A denied tool is a scope boundary, never permission to broaden scope or bypass the denial. Do not retry the denied path. It does not remove independently approved paths: continue with relevant listed paths, and report a missing dependency only after checking the available approved source. Do not repeat host orchestration: claim, shadow, fingerprints, binding, administrator checks, launches, Gateway usage and Job reporting belong to the Runner/host, even when described in the objective. Perform only the assigned repository work and report evidence you actually observed. Do not claim that no model call occurred. If the required tool is unavailable, emit one final structured blocked report naming the missing tool and unfinished repository work; do not request or attempt a new launch.`;
|
|
26
|
+
return `${compileBrief(taskPackage, { workItemLabel: label, progressReporting: "native_messages" })}\n\n${repositoryScope(taskPackage)}\n\nUse only the ai_local job tools for repository reads, edits and approved tests. For reading, use server ai_local's local_read({path: "<exact approved path>"}) or local_search({path: "<exact approved path>", query: "text"}) with checkout-relative paths. For small approved edits, use local_replace({path, old, new}) after reading: old must match exactly once, including whitespace. local_edit requires the complete replacement file, never a patch. Required test criteria are not command grants. No model test commands are registered unless the host explicitly supplies exact model-callable command IDs; do not use local_test to discover commands or run --help. Missing tests remain not_run/unverified, not passed. In a read-only task, use only read/search tools; the Runner enforces the approved paths and operations. A denied native cwd is intentional: it is a private control directory, not the source checkout. Native filesystem, commands, web, images and delegation are unavailable or denied. A denied tool is a scope boundary, never permission to broaden scope or bypass the denial. Do not retry the denied path. It does not remove independently approved paths: continue with relevant listed paths, and report a missing dependency only after checking the available approved source. Do not repeat host orchestration: claim, shadow, fingerprints, binding, administrator checks, launches, Gateway usage and Job reporting belong to the Runner/host, even when described in the objective. Perform only the assigned repository work and report evidence you actually observed. Do not claim that no model call occurred. If the required tool is unavailable, emit one final structured blocked report naming the missing tool and unfinished repository work; do not request or attempt a new launch.`;
|
|
27
27
|
}
|
|
28
28
|
export const CODEX_REPORT_SCHEMA = {
|
|
29
29
|
type: "object", additionalProperties: false, required: ["status", "report", "evidence", "pending_work"],
|
|
@@ -6,7 +6,7 @@ import { evaluateCommand } from './command-policy.js';
|
|
|
6
6
|
import { runSandboxed, runSandboxedMeasured } from './subprocess-sandbox.js';
|
|
7
7
|
import { LOCAL_TOOL_WORKER } from './local-tool-worker.js';
|
|
8
8
|
import { verifyCommandIntegrity } from './registered-command-integrity.js';
|
|
9
|
-
export const LOCAL_TOOL_NAMES = ['local_read', 'local_search', 'local_edit', 'local_test'];
|
|
9
|
+
export const LOCAL_TOOL_NAMES = ['local_read', 'local_search', 'local_edit', 'local_replace', 'local_test'];
|
|
10
10
|
// Private state/control basenames only: Auth.php, auth/ and mcp.ts are ordinary source.
|
|
11
11
|
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);
|
|
12
12
|
const contains = (root, file) => file === root || file.startsWith(root + path.sep);
|
|
@@ -40,11 +40,19 @@ export class LocalToolPolicy {
|
|
|
40
40
|
if (!args || typeof args !== 'object' || Array.isArray(args))
|
|
41
41
|
throw new Error('DENIED_ARGUMENTS');
|
|
42
42
|
const input = args;
|
|
43
|
-
if (tool === 'local_edit' && this.options.access !== 'write')
|
|
43
|
+
if ((tool === 'local_edit' || tool === 'local_replace') && this.options.access !== 'write')
|
|
44
44
|
throw new Error('DENIED_READ_ONLY');
|
|
45
45
|
if (tool === 'local_test')
|
|
46
46
|
return this.test(input);
|
|
47
47
|
const file = await this.authorizedFile(input.path, tool === 'local_edit');
|
|
48
|
+
if (tool === 'local_replace') {
|
|
49
|
+
if (typeof input.old !== 'string' || !input.old || Buffer.byteLength(input.old) > 1024 * 1024)
|
|
50
|
+
throw new Error('DENIED_REPLACE_OLD');
|
|
51
|
+
if (typeof input.new !== 'string' || Buffer.byteLength(input.new) > 1024 * 1024)
|
|
52
|
+
throw new Error('DENIED_CONTENT');
|
|
53
|
+
if (isPatchContent(input.new))
|
|
54
|
+
throw new Error('DENIED_PATCH_CONTENT');
|
|
55
|
+
}
|
|
48
56
|
if (tool === 'local_edit' && (typeof input.content !== 'string' || Buffer.byteLength(input.content) > 1024 * 1024))
|
|
49
57
|
throw new Error('DENIED_CONTENT');
|
|
50
58
|
if (tool === 'local_edit' && isPatchContent(input.content))
|
|
@@ -52,10 +60,13 @@ export class LocalToolPolicy {
|
|
|
52
60
|
if (tool === 'local_search' && (typeof input.query !== 'string' || input.query.length > 4096))
|
|
53
61
|
throw new Error('DENIED_QUERY');
|
|
54
62
|
const temp = file + '.runner-edit-' + randomBytes(16).toString('hex');
|
|
55
|
-
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 });
|
|
63
|
+
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, old: input.old, new: input.new })], readFiles: (tool === 'local_edit' || tool === 'local_replace') ? [file, temp] : [file], writeFiles: (tool === 'local_edit' || tool === 'local_replace') ? [file, temp] : [], timeoutMs: 10000, signal: this.abort.signal });
|
|
56
64
|
if (this.abort.signal.aborted)
|
|
57
65
|
throw new Error('DENIED_REVOKED');
|
|
58
|
-
|
|
66
|
+
const result = JSON.parse(output);
|
|
67
|
+
if (tool === 'local_replace' && ['DENIED_REPLACE_NOT_FOUND', 'DENIED_REPLACE_AMBIGUOUS', 'DENIED_SIZE'].includes(result.denied))
|
|
68
|
+
throw new Error(result.denied);
|
|
69
|
+
return result;
|
|
59
70
|
}
|
|
60
71
|
async authorizedFile(value, allowMissing = false) {
|
|
61
72
|
if (typeof value !== 'string' || !value || value.includes('\0') || path.isAbsolute(value) || value.split(/[\\/]/).some(p => p === '..' || sensitive(p)))
|
|
@@ -24,11 +24,12 @@ const schemas = {
|
|
|
24
24
|
local_read: { type: 'object', properties: { path: { type: 'string' } }, required: ['path'], additionalProperties: false },
|
|
25
25
|
local_search: { type: 'object', properties: { path: { type: 'string' }, query: { type: 'string' } }, required: ['path', 'query'], additionalProperties: false },
|
|
26
26
|
local_edit: { type: 'object', properties: { path: { type: 'string' }, content: { type: 'string', description: 'The complete new contents of the entire file. Do not send a diff, patch, hunk, or edit instructions.' } }, required: ['path', 'content'], additionalProperties: false },
|
|
27
|
+
local_replace: { type: 'object', properties: { path: { type: 'string' }, old: { type: 'string', minLength: 1 }, new: { type: 'string' } }, required: ['path', 'old', 'new'], additionalProperties: false },
|
|
27
28
|
local_test: { type: 'object', properties: { command: { type: 'string' } }, required: ['command'], additionalProperties: false },
|
|
28
29
|
};
|
|
29
30
|
/** Same immutable-by-copy inventory used by native admission and MCP discovery. */
|
|
30
31
|
export function localToolInventory() {
|
|
31
|
-
return LOCAL_TOOL_NAMES.map(name => ({ name, description: name === 'local_edit' ? 'Replace the entire file at path with content (or create it). Read the current file first and include all unchanged text in content. This is not a diff or patch tool; patch headers and hunks are rejected.' : `Scoped ${name} operation`, inputSchema: structuredClone(schemas[name]) }));
|
|
32
|
+
return LOCAL_TOOL_NAMES.map(name => ({ name, description: name === 'local_edit' ? 'Replace the entire file at path with content (or create it). Read the current file first and include all unchanged text in content. This is not a diff or patch tool; patch headers and hunks are rejected.' : name === 'local_replace' ? 'Replace exactly one occurrence of old with new in the file at path. Read the file first; old must match exactly, including whitespace, and appear once. Prefer this for small changes; use local_edit only to write a whole file.' : `Scoped ${name} operation`, inputSchema: structuredClone(schemas[name]) }));
|
|
32
33
|
}
|
|
33
34
|
/** Job-only JSON MCP endpoint; intentionally has no Gateway transport or discovery. */
|
|
34
35
|
export class LocalToolServer {
|
|
@@ -5,6 +5,12 @@ const fs = require('node:fs');
|
|
|
5
5
|
const input = JSON.parse(process.argv[1]);
|
|
6
6
|
const isPatchContent = ${isPatchContent.toString()};
|
|
7
7
|
if (input.tool === 'local_edit' && isPatchContent(input.content)) throw Error('DENIED_PATCH_CONTENT');
|
|
8
|
+
if (input.tool === 'local_replace') {
|
|
9
|
+
if (typeof input.old !== 'string' || !input.old || Buffer.byteLength(input.old) > 1048576) throw Error('DENIED_REPLACE_OLD');
|
|
10
|
+
if (typeof input.new !== 'string' || Buffer.byteLength(input.new) > 1048576) throw Error('DENIED_CONTENT');
|
|
11
|
+
if (isPatchContent(input.new)) throw Error('DENIED_PATCH_CONTENT');
|
|
12
|
+
}
|
|
13
|
+
try {
|
|
8
14
|
let current = '';
|
|
9
15
|
for (const part of input.path.split('/').filter(Boolean)) {
|
|
10
16
|
current += '/' + part;
|
|
@@ -27,8 +33,20 @@ try {
|
|
|
27
33
|
if (actual.ino !== before.ino || actual.dev !== before.dev || actual.ino !== final.ino || actual.dev !== final.dev || actual.nlink !== 1) throw Error('DENIED_RACE');
|
|
28
34
|
}
|
|
29
35
|
let result;
|
|
30
|
-
if (input.tool === 'local_edit') {
|
|
31
|
-
|
|
36
|
+
if (input.tool === 'local_edit' || input.tool === 'local_replace') {
|
|
37
|
+
let content = input.content;
|
|
38
|
+
if (input.tool === 'local_replace') {
|
|
39
|
+
const buffer = Buffer.alloc(1048577);
|
|
40
|
+
const count = fs.readSync(fd, buffer, 0, buffer.length, 0);
|
|
41
|
+
if (count > 1048576) throw Error('DENIED_SIZE');
|
|
42
|
+
const original = new TextDecoder('utf-8', {fatal: true, ignoreBOM: true}).decode(buffer.subarray(0, count));
|
|
43
|
+
const first = original.indexOf(input.old);
|
|
44
|
+
if (first < 0) throw Error('DENIED_REPLACE_NOT_FOUND');
|
|
45
|
+
if (original.indexOf(input.old, first + 1) >= 0) throw Error('DENIED_REPLACE_AMBIGUOUS');
|
|
46
|
+
content = original.slice(0, first) + input.new + original.slice(first + input.old.length);
|
|
47
|
+
}
|
|
48
|
+
const data = Buffer.from(content, 'utf8');
|
|
49
|
+
if (data.length > 1048576) throw Error('DENIED_SIZE');
|
|
32
50
|
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);
|
|
33
51
|
try {
|
|
34
52
|
fs.writeFileSync(temp, data);
|
|
@@ -37,6 +55,7 @@ try {
|
|
|
37
55
|
if (tempStat.nlink !== 1 || tempStat.ino !== tempFinal.ino || tempStat.dev !== tempFinal.dev) throw Error('DENIED_RACE');
|
|
38
56
|
if (actual) {
|
|
39
57
|
const still = fs.lstatSync(input.path);
|
|
58
|
+
if (input.tool === 'local_replace' && (still.size !== actual.size || still.mtimeMs !== actual.mtimeMs || still.ctimeMs !== actual.ctimeMs)) throw Error('DENIED_RACE');
|
|
40
59
|
if (still.isSymbolicLink() || still.ino !== actual.ino || still.dev !== actual.dev || still.nlink !== 1) throw Error('DENIED_RACE');
|
|
41
60
|
fs.renameSync(input.temp, input.path);
|
|
42
61
|
} else {
|
|
@@ -57,4 +76,8 @@ try {
|
|
|
57
76
|
}
|
|
58
77
|
process.stdout.write(JSON.stringify(result));
|
|
59
78
|
} finally { if (fd !== null) fs.closeSync(fd); }
|
|
79
|
+
} catch (error) {
|
|
80
|
+
if (input.tool !== 'local_replace' || !['DENIED_REPLACE_NOT_FOUND','DENIED_REPLACE_AMBIGUOUS','DENIED_SIZE'].includes(error.message)) throw error;
|
|
81
|
+
process.stdout.write(JSON.stringify({denied: error.message}));
|
|
82
|
+
}
|
|
60
83
|
`;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export const CLAUDE_LOCAL_MCP_TOOLS = Object.freeze(["local_read", "local_search", "local_edit", "local_test"].map(name => `mcp__ai_local__${name}`));
|
|
1
|
+
export const CLAUDE_LOCAL_MCP_TOOLS = Object.freeze(["local_read", "local_search", "local_edit", "local_replace", "local_test"].map(name => `mcp__ai_local__${name}`));
|
|
2
2
|
function allow(reasonCode) {
|
|
3
3
|
return {
|
|
4
4
|
hookSpecificOutput: {
|