mtech-ai-local-runner 0.1.30 → 0.1.31
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/dist/src/adapters/codex/adapter.js +15 -0
- package/dist/src/adapters/codex/exec-launch-host.js +22 -0
- package/dist/src/cli.js +23 -0
- package/dist/src/config.js +4 -4
- package/dist/src/daemon/execution-coordinator.js +62 -2
- package/dist/src/daemon/runtime.js +18 -2
- package/dist/src/gateway-client.js +67 -0
- package/dist/src/platform/macos/launchd.js +13 -2
- package/dist/src/process/node-process-group-host.js +36 -1
- package/dist/src/publication/candidate.js +95 -0
- package/dist/src/publication/execution-host.js +55 -0
- package/dist/src/publication/git-credential-broker.js +150 -0
- package/dist/src/publication/git-credential-helper.js +29 -0
- package/dist/src/publication/git-snapshot.js +321 -0
- package/dist/src/publication/git-transport.js +156 -0
- package/dist/src/publication/installed-host.js +90 -0
- package/dist/src/publication/isolated-verifier.js +67 -0
- package/dist/src/publication/journal.js +87 -0
- package/dist/src/publication/production-host.js +151 -0
- package/dist/src/publication/project-registration.js +49 -0
- package/dist/src/publication/publisher.js +166 -0
- package/dist/src/publication/recovery.js +53 -0
- package/dist/src/publication/runtime-release.js +91 -0
- package/dist/src/safety/command-policy-core.js +124 -0
- package/dist/src/safety/command-policy.js +1 -124
- package/dist/src/safety/job-tool-policy.js +9 -0
- package/dist/src/safety/local-tool-policy.js +1 -1
- package/dist/src/workspace/workspace-manager.js +14 -0
- package/package.json +3 -2
- package/verification/README.md +63 -0
- package/verification/runner-policy.cjs +24 -0
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { protocolHash } from './candidate.js';
|
|
2
|
+
import { PublicationOutcomeUnknownError } from './publisher.js';
|
|
3
|
+
/** Binds the real candidate/publisher pipeline to coordinator ownership and keeps
|
|
4
|
+
* its lease alive through terminal publication. Model reports are never input. */
|
|
5
|
+
export function createLocalFixExecutionHost(composition) {
|
|
6
|
+
const registration = composition.registration ? structuredClone(composition.registration) : undefined;
|
|
7
|
+
return { prepare: async (input) => {
|
|
8
|
+
try {
|
|
9
|
+
const scope = input.job.taskPackage.local_fix_scope;
|
|
10
|
+
if (!registration || !scope)
|
|
11
|
+
throw Error();
|
|
12
|
+
for (const field of ['host_release_sha256', 'test_isolation_sha256', 'publisher_release_sha256'])
|
|
13
|
+
if (!/^[a-f0-9]{64}$/.test(registration.receipt[field]) || registration.receipt[field] !== scope[field])
|
|
14
|
+
throw Error();
|
|
15
|
+
const ids = scope.required_commands, entries = scope.registered_tests;
|
|
16
|
+
if (!Array.isArray(ids) || !ids.length || new Set(ids).size !== ids.length || ids.length !== registration.commands.length)
|
|
17
|
+
throw Error();
|
|
18
|
+
for (const c of registration.commands) {
|
|
19
|
+
if (c.candidateReadPaths?.some(p => !Array.isArray(scope.allowed_paths) || !scope.allowed_paths.includes(p) || Object.hasOwn(c.integrity.readFiles, p)))
|
|
20
|
+
throw Error();
|
|
21
|
+
const e = entries[c.id];
|
|
22
|
+
if (!ids.includes(c.id) || !e || e.review_status !== 'reviewed' || protocolHash(c) !== protocolHash({ id: c.id, executable: e.executable, argv: e.argv, integrity: { executableSha256: e.executable_sha256, readFiles: e.read_files }, ...('candidate_read_paths' in e ? { candidateReadPaths: e.candidate_read_paths } : {}) }))
|
|
23
|
+
throw Error();
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
throw Error('LOCAL_FIX_HOST_REGISTRATION_REJECTED');
|
|
28
|
+
}
|
|
29
|
+
const session = await composition.prepare(input);
|
|
30
|
+
let closed = false, closing, finalization;
|
|
31
|
+
return {
|
|
32
|
+
finalize() {
|
|
33
|
+
if (closed)
|
|
34
|
+
return Promise.reject(new Error('LOCAL_FIX_SESSION_CLOSED'));
|
|
35
|
+
if (!finalization)
|
|
36
|
+
finalization = (async () => {
|
|
37
|
+
const candidate = await session.verifier.verify();
|
|
38
|
+
if (closed)
|
|
39
|
+
throw Error('LOCAL_FIX_SESSION_CLOSED');
|
|
40
|
+
let publication;
|
|
41
|
+
try {
|
|
42
|
+
publication = await session.publisher.publish(candidate);
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
throw new PublicationOutcomeUnknownError();
|
|
46
|
+
}
|
|
47
|
+
const e = candidate.evidence;
|
|
48
|
+
return { publication_id: publication.publication_id, status: 'completed', candidate_fingerprint: e.candidate_fingerprint, snapshot_fingerprint: e.snapshot_fingerprint, verification_fingerprint: e.verification_fingerprint, commit_sha: publication.committed_sha, working_branch: e.working_branch, pr_url: publication.pull_request_url, pull_request_provider_id: publication.pull_request_id, changed_files: e.changed_files.map(f => f.path), diff_hash: e.diff_hash, changed_file_manifest_hash: e.changed_file_manifest_hash };
|
|
49
|
+
})();
|
|
50
|
+
return finalization;
|
|
51
|
+
},
|
|
52
|
+
close() { closed = true; return closing ??= session.close(); },
|
|
53
|
+
};
|
|
54
|
+
} };
|
|
55
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { lstat, realpath, readFile, mkdtemp, chmod, rm } from 'node:fs/promises';
|
|
3
|
+
import { createServer } from 'node:net';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { measureCommandFile } from '../safety/registered-command-integrity.js';
|
|
6
|
+
const denied = () => Error('GIT_CREDENTIAL_SCOPE_REJECTED');
|
|
7
|
+
export const publicationGitConfig = '[core]\nrepositoryformatversion = 0\nbare = false\nhooksPath = /dev/null\nfsmonitor = false\n';
|
|
8
|
+
export function strictPublicationOrigin(origin) {
|
|
9
|
+
const url = new URL(origin);
|
|
10
|
+
if (url.protocol !== 'https:' || url.username || url.password || url.port || url.search || url.hash || url.href !== origin || !/^\/[A-Za-z0-9_-]+\/[A-Za-z0-9_.-]+\.git$/.test(url.pathname) || url.pathname.includes('..') || !/^[a-z0-9.-]+$/.test(url.hostname))
|
|
11
|
+
throw denied();
|
|
12
|
+
}
|
|
13
|
+
export async function assertPinnedGit(file) { if (!path.isAbsolute(file.executable) || await realpath(file.executable) !== file.executable || await measureCommandFile(file.executable) !== file.sha256)
|
|
14
|
+
throw denied(); }
|
|
15
|
+
export async function assertPrivateCheckout(checkout, privateRoot) {
|
|
16
|
+
if (!path.isAbsolute(privateRoot) || await realpath(privateRoot) !== privateRoot || !checkout.startsWith(privateRoot + '/') || await realpath(checkout) !== checkout)
|
|
17
|
+
throw denied();
|
|
18
|
+
for (const dir of [privateRoot, checkout, path.join(checkout, '.git')]) {
|
|
19
|
+
const s = await lstat(dir);
|
|
20
|
+
if (!s.isDirectory() || s.isSymbolicLink() || (s.mode & 0o077) || (process.getuid && s.uid !== process.getuid()))
|
|
21
|
+
throw denied();
|
|
22
|
+
}
|
|
23
|
+
if (await readFile(path.join(checkout, '.git', 'config'), 'utf8') !== publicationGitConfig)
|
|
24
|
+
throw denied();
|
|
25
|
+
}
|
|
26
|
+
/** No inherited auth, proxy, Git/SSH config or helper environment. */
|
|
27
|
+
export function publicationGitEnvironment() { return { PATH: '/usr/bin:/bin', HOME: '/nonexistent', LANG: 'C', LC_ALL: 'C', GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: '/dev/null', GIT_ATTR_NOSYSTEM: '1', GIT_TERMINAL_PROMPT: '0', GIT_NO_REPLACE_OBJECTS: '1', GIT_OPTIONAL_LOCKS: '0' }; }
|
|
28
|
+
export const publicationGitArgs = ['-c', 'core.hooksPath=/dev/null', '-c', 'core.fsmonitor=false', '-c', 'core.attributesFile=/dev/null', '-c', 'core.excludesFile=/dev/null', '-c', 'core.logAllRefUpdates=false', '-c', 'credential.helper=', '-c', 'http.followRedirects=false', '-c', 'protocol.allow=never', '-c', 'protocol.https.allow=always'];
|
|
29
|
+
/** Private one-operation Unix socket serves credentials only to the pinned Git
|
|
30
|
+
* helper for the registered HTTPS host and repository path. Secrets never enter
|
|
31
|
+
* argv/environment/disk. Owner-only directories/socket protect other UIDs; the
|
|
32
|
+
* required model/tool shutdown and OS confinement protect against the same UID. */
|
|
33
|
+
export class ScopedGitCredentialBroker {
|
|
34
|
+
options;
|
|
35
|
+
constructor(options) { this.options = { ...options, git: { ...options.git }, remoteHelper: { ...options.remoteHelper }, node: options.node ? { ...options.node } : undefined, credentialHelper: options.credentialHelper ? { ...options.credentialHelper } : undefined }; }
|
|
36
|
+
async validate(scope) {
|
|
37
|
+
const o = this.options;
|
|
38
|
+
strictPublicationOrigin(o.origin);
|
|
39
|
+
if (scope.origin !== o.origin || !/^ai-work\/local\/[A-Za-z0-9-]+$/.test(o.branch) || scope.ref !== `refs/heads/${o.branch}` || !/^[a-f0-9]{40}$/.test(scope.sha) || !await o.current() || !o.privateRoot)
|
|
40
|
+
throw denied();
|
|
41
|
+
await assertPrivateCheckout(scope.checkout, o.privateRoot);
|
|
42
|
+
if (await readFile(path.join(scope.checkout, '.git', 'HEAD'), 'utf8') !== `ref: ${scope.ref}\n` || (await readFile(path.join(scope.checkout, '.git', scope.ref), 'utf8')).trim() !== scope.sha)
|
|
43
|
+
throw denied();
|
|
44
|
+
await assertPinnedGit(o.git);
|
|
45
|
+
await assertPinnedGit(o.remoteHelper);
|
|
46
|
+
if (!o.node || !o.credentialHelper)
|
|
47
|
+
throw denied();
|
|
48
|
+
await assertPinnedGit(o.node);
|
|
49
|
+
await assertPinnedGit(o.credentialHelper);
|
|
50
|
+
if (await realpath(path.join(path.dirname(o.remoteHelper.executable), 'git-remote-https')) !== o.remoteHelper.executable)
|
|
51
|
+
throw denied();
|
|
52
|
+
}
|
|
53
|
+
async run(scope, read) {
|
|
54
|
+
let directory, server;
|
|
55
|
+
const sockets = new Set();
|
|
56
|
+
try {
|
|
57
|
+
const frozen = Object.freeze({ ...scope });
|
|
58
|
+
await this.validate(frozen);
|
|
59
|
+
const o = this.options, url = new URL(o.origin);
|
|
60
|
+
directory = await mkdtemp(path.join(o.privateRoot, 'auth-'));
|
|
61
|
+
await chmod(directory, 0o700);
|
|
62
|
+
const socketPath = path.join(directory, 's');
|
|
63
|
+
let used = false;
|
|
64
|
+
server = createServer(socket => {
|
|
65
|
+
sockets.add(socket);
|
|
66
|
+
socket.on('close', () => sockets.delete(socket));
|
|
67
|
+
let body = '';
|
|
68
|
+
socket.setTimeout(10000, () => socket.destroy());
|
|
69
|
+
socket.on('error', () => { });
|
|
70
|
+
socket.on('data', chunk => {
|
|
71
|
+
body += chunk.toString();
|
|
72
|
+
if (body.length > 16384) {
|
|
73
|
+
socket.destroy();
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
if (!body.endsWith('\n'))
|
|
77
|
+
return;
|
|
78
|
+
socket.pause();
|
|
79
|
+
void (async () => {
|
|
80
|
+
try {
|
|
81
|
+
const fields = JSON.parse(body);
|
|
82
|
+
if (used || Object.keys(fields).some(k => !['protocol', 'host', 'path', 'username', 'wwwauth[]'].includes(k)) || fields.protocol !== 'https' || fields.host !== url.host || fields.path !== url.pathname.slice(1))
|
|
83
|
+
throw denied();
|
|
84
|
+
used = true;
|
|
85
|
+
await this.validate(frozen);
|
|
86
|
+
const credential = await o.authorization(frozen);
|
|
87
|
+
if (!credential.username || !credential.password || /[\r\n\0]/.test(credential.username + credential.password) || credential.username.length + credential.password.length > 16384)
|
|
88
|
+
throw denied();
|
|
89
|
+
await this.validate(frozen);
|
|
90
|
+
socket.end(JSON.stringify(credential));
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
socket.destroy();
|
|
94
|
+
}
|
|
95
|
+
})();
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
await new Promise((resolve, reject) => { server.once('error', reject); server.listen(socketPath, resolve); });
|
|
99
|
+
await chmod(socketPath, 0o600);
|
|
100
|
+
// Git parses helper strings using a shell; quote each trusted absolute path.
|
|
101
|
+
const quote = (value) => "'" + value.replace(/'/g, "'\\''") + "'";
|
|
102
|
+
const helper = `!${quote(o.node.executable)} ${quote(o.credentialHelper.executable)} --local-fix-helper ${quote(socketPath)}`;
|
|
103
|
+
const args = [...publicationGitArgs, '-c', 'credential.useHttpPath=true', '-c', `credential.helper=${helper}`, ...(read ? ['ls-remote', '--refs', scope.origin, scope.ref] : ['push', '--porcelain', '--no-verify', scope.origin, `${scope.sha}:${scope.ref}`])];
|
|
104
|
+
const output = await new Promise((resolve, reject) => {
|
|
105
|
+
const child = spawn(o.git.executable, args, { cwd: scope.checkout, env: { ...publicationGitEnvironment(), GIT_EXEC_PATH: path.dirname(o.remoteHelper.executable) }, stdio: ['ignore', 'pipe', 'pipe'], detached: true });
|
|
106
|
+
let stdout = '', bytes = 0, settled = false;
|
|
107
|
+
const fail = () => { if (!settled) {
|
|
108
|
+
settled = true;
|
|
109
|
+
try {
|
|
110
|
+
if (child.pid)
|
|
111
|
+
process.kill(-child.pid, 'SIGKILL');
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
child.kill('SIGKILL');
|
|
115
|
+
}
|
|
116
|
+
reject(denied());
|
|
117
|
+
} };
|
|
118
|
+
const timeout = setTimeout(fail, 60000);
|
|
119
|
+
child.on('error', fail);
|
|
120
|
+
child.stdout.on('data', (chunk) => { bytes += chunk.length; if (bytes > 65536)
|
|
121
|
+
fail();
|
|
122
|
+
else
|
|
123
|
+
stdout += chunk.toString(); });
|
|
124
|
+
// Never forward stderr: a failing helper may echo credential material.
|
|
125
|
+
child.stderr.on('data', () => { });
|
|
126
|
+
child.on('close', code => { clearTimeout(timeout); if (!settled) {
|
|
127
|
+
settled = true;
|
|
128
|
+
code === 0 ? resolve(stdout) : reject(denied());
|
|
129
|
+
} });
|
|
130
|
+
});
|
|
131
|
+
await this.validate(frozen);
|
|
132
|
+
return output;
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
throw denied();
|
|
136
|
+
}
|
|
137
|
+
finally {
|
|
138
|
+
for (const socket of sockets)
|
|
139
|
+
socket.destroy();
|
|
140
|
+
server?.close();
|
|
141
|
+
if (directory)
|
|
142
|
+
await rm(directory, { recursive: true, force: true });
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
async push(scope) { await this.run(scope, false); }
|
|
146
|
+
async head(scope) { const output = await this.run(scope, true); if (!output.trim())
|
|
147
|
+
return null; const lines = output.trim().split('\n'); if (lines.length !== 1)
|
|
148
|
+
throw denied(); const match = /^([a-f0-9]{40})\t(.+)$/.exec(lines[0]); if (!match || match[2] !== scope.ref)
|
|
149
|
+
throw denied(); return match[1]; }
|
|
150
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { connect } from 'node:net';
|
|
2
|
+
/** Dedicated child of pinned Git only. No ambient credential or environment lookup.
|
|
3
|
+
* Its private socket is passed as an argument; the socket name is not a secret. */
|
|
4
|
+
export async function runGitCredentialHelper(socketPath, operation, input = process.stdin, output = process.stdout) {
|
|
5
|
+
if (operation !== 'get')
|
|
6
|
+
return;
|
|
7
|
+
let body = '';
|
|
8
|
+
for await (const chunk of input) {
|
|
9
|
+
body += chunk.toString();
|
|
10
|
+
if (body.length > 16384)
|
|
11
|
+
throw Error('CREDENTIAL_REJECTED');
|
|
12
|
+
}
|
|
13
|
+
const fields = {};
|
|
14
|
+
for (const line of body.trim().split('\n')) {
|
|
15
|
+
const i = line.indexOf('=');
|
|
16
|
+
if (i < 1 || Object.hasOwn(fields, line.slice(0, i)))
|
|
17
|
+
throw Error('CREDENTIAL_REJECTED');
|
|
18
|
+
fields[line.slice(0, i)] = line.slice(i + 1);
|
|
19
|
+
}
|
|
20
|
+
const response = await new Promise((resolve, reject) => { const socket = connect(socketPath); let data = ''; socket.setTimeout(10000, () => socket.destroy(Error('timeout'))); socket.on('connect', () => socket.write(JSON.stringify(fields) + '\n')); socket.on('data', chunk => { data += chunk.toString(); if (data.length > 32768)
|
|
21
|
+
socket.destroy(Error('overflow')); }); socket.on('end', () => resolve(data)); socket.on('error', reject); });
|
|
22
|
+
const credential = JSON.parse(response);
|
|
23
|
+
if (!credential.username || !credential.password || /[\r\n\0]/.test(credential.username + credential.password))
|
|
24
|
+
throw Error('CREDENTIAL_REJECTED');
|
|
25
|
+
output.write(`username=${credential.username}\npassword=${credential.password}\n\n`);
|
|
26
|
+
}
|
|
27
|
+
if (process.argv[2] === '--local-fix-helper') {
|
|
28
|
+
runGitCredentialHelper(process.argv[3], process.argv[4]).catch(() => { process.exitCode = 1; });
|
|
29
|
+
}
|
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
import { execFile, spawn } from 'node:child_process';
|
|
2
|
+
import { promisify } from 'node:util';
|
|
3
|
+
import { constants } from 'node:fs';
|
|
4
|
+
import { lstat, mkdir, mkdtemp, open, realpath, readdir, writeFile } from 'node:fs/promises';
|
|
5
|
+
import { createHash } from 'node:crypto';
|
|
6
|
+
import { deflateSync } from 'node:zlib';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { measureCommandFile } from '../safety/registered-command-integrity.js';
|
|
9
|
+
import { protocolHash } from './candidate.js';
|
|
10
|
+
const execute = promisify(execFile), denied = () => Error('SNAPSHOT_REJECTED');
|
|
11
|
+
const digest = (data) => createHash('sha256').update(data).digest('hex');
|
|
12
|
+
const safe = (p) => p.length > 0 && !path.isAbsolute(p) && !p.includes('\\') && !p.includes('\0') && p.split('/').every(s => s !== '' && s !== '.' && s !== '..' && !/^(?:\.git|\.ssh|\.aws|\.codex|\.claude|\.env|\.netrc|\.npmrc|credentials(?:\..*)?)$/i.test(s));
|
|
13
|
+
/** Reads only immutable Git objects and metadata. Never runs diff against worktree
|
|
14
|
+
* content, where malicious clean filters can execute even with --no-ext-diff. */
|
|
15
|
+
export class GitSnapshotHost {
|
|
16
|
+
options;
|
|
17
|
+
seals = new WeakSet();
|
|
18
|
+
constructor(options) { this.options = structuredClone(options); }
|
|
19
|
+
async git(args) {
|
|
20
|
+
const o = this.options;
|
|
21
|
+
if (!path.isAbsolute(o.git.executable) || await realpath(o.git.executable) !== o.git.executable || await measureCommandFile(o.git.executable) !== o.git.sha256)
|
|
22
|
+
throw denied();
|
|
23
|
+
const result = await execute(o.git.executable, ['-c', 'core.hooksPath=/dev/null', '-c', 'core.fsmonitor=false', '-c', 'core.attributesFile=/dev/null', '-c', 'core.excludesFile=/dev/null', ...args], { cwd: o.root, encoding: 'buffer', maxBuffer: o.maxTotalBytes, env: { PATH: '/usr/bin:/bin', HOME: '/nonexistent', LANG: 'C', GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: '/dev/null', GIT_ATTR_NOSYSTEM: '1', GIT_TERMINAL_PROMPT: '0', GIT_OPTIONAL_LOCKS: '0', GIT_NO_REPLACE_OBJECTS: '1' } });
|
|
24
|
+
if (await measureCommandFile(o.git.executable) !== o.git.sha256)
|
|
25
|
+
throw denied();
|
|
26
|
+
return result.stdout;
|
|
27
|
+
}
|
|
28
|
+
/** One pinned, credential-free process for exact immutable objects, rather
|
|
29
|
+
* than a subprocess and executable rehash for every file in every observation. */
|
|
30
|
+
async batchObjects(ids) {
|
|
31
|
+
const o = this.options, unique = [...new Set(ids)];
|
|
32
|
+
if (unique.some(id => !/^[a-f0-9]{40}$/.test(id)) || unique.length > 100000)
|
|
33
|
+
throw denied();
|
|
34
|
+
if (!unique.length)
|
|
35
|
+
return new Map();
|
|
36
|
+
if (await realpath(o.git.executable) !== o.git.executable || await measureCommandFile(o.git.executable) !== o.git.sha256)
|
|
37
|
+
throw denied();
|
|
38
|
+
const output = await new Promise((resolve, reject) => {
|
|
39
|
+
const child = spawn(o.git.executable, ['-c', 'core.hooksPath=/dev/null', '-c', 'core.fsmonitor=false', 'cat-file', '--batch'], { cwd: o.root, env: { PATH: '/usr/bin:/bin', HOME: '/nonexistent', LANG: 'C', GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: '/dev/null', GIT_ATTR_NOSYSTEM: '1', GIT_TERMINAL_PROMPT: '0', GIT_OPTIONAL_LOCKS: '0', GIT_NO_REPLACE_OBJECTS: '1' }, stdio: ['pipe', 'pipe', 'ignore'] });
|
|
40
|
+
const chunks = [];
|
|
41
|
+
let size = 0, failed = false;
|
|
42
|
+
const fail = () => { if (!failed) {
|
|
43
|
+
failed = true;
|
|
44
|
+
child.kill('SIGKILL');
|
|
45
|
+
reject(denied());
|
|
46
|
+
} };
|
|
47
|
+
child.on('error', fail);
|
|
48
|
+
child.stdin.on('error', fail);
|
|
49
|
+
child.stdout.on('data', (chunk) => { size += chunk.length; if (size > o.maxTotalBytes + unique.length * 100)
|
|
50
|
+
fail();
|
|
51
|
+
else
|
|
52
|
+
chunks.push(chunk); });
|
|
53
|
+
child.on('close', code => { if (!failed) {
|
|
54
|
+
if (code !== 0)
|
|
55
|
+
fail();
|
|
56
|
+
else
|
|
57
|
+
resolve(Buffer.concat(chunks));
|
|
58
|
+
} });
|
|
59
|
+
child.stdin.end(unique.join('\n') + '\n');
|
|
60
|
+
});
|
|
61
|
+
if (await measureCommandFile(o.git.executable) !== o.git.sha256)
|
|
62
|
+
throw denied();
|
|
63
|
+
const result = new Map();
|
|
64
|
+
let offset = 0;
|
|
65
|
+
for (const id of unique) {
|
|
66
|
+
const end = output.indexOf(10, offset);
|
|
67
|
+
if (end < 0 || end - offset > 100)
|
|
68
|
+
throw denied();
|
|
69
|
+
const header = /^([a-f0-9]{40}) (blob|tree|commit) ([0-9]+)$/.exec(output.subarray(offset, end).toString());
|
|
70
|
+
if (!header || header[1] !== id)
|
|
71
|
+
throw denied();
|
|
72
|
+
const size = Number(header[3]);
|
|
73
|
+
if (!Number.isSafeInteger(size) || size < 0 || size > o.maxTotalBytes || end + 1 + size >= output.length || output[end + 1 + size] !== 10)
|
|
74
|
+
throw denied();
|
|
75
|
+
const bytes = output.subarray(end + 1, end + 1 + size), type = header[2];
|
|
76
|
+
if (createHash('sha1').update(Buffer.concat([Buffer.from(`${type} ${size}\0`), bytes])).digest('hex') !== id)
|
|
77
|
+
throw denied();
|
|
78
|
+
result.set(id, { type, bytes });
|
|
79
|
+
offset = end + size + 2;
|
|
80
|
+
}
|
|
81
|
+
if (offset !== output.length)
|
|
82
|
+
throw denied();
|
|
83
|
+
return result;
|
|
84
|
+
}
|
|
85
|
+
async currentFile(relative) {
|
|
86
|
+
if (!safe(relative))
|
|
87
|
+
throw denied();
|
|
88
|
+
const absolute = path.join(this.options.root, relative);
|
|
89
|
+
let component = this.options.root;
|
|
90
|
+
const parts = relative.split('/');
|
|
91
|
+
for (let i = 0; i < parts.length; i++) {
|
|
92
|
+
component = path.join(component, parts[i]);
|
|
93
|
+
let stat;
|
|
94
|
+
try {
|
|
95
|
+
stat = await lstat(component);
|
|
96
|
+
}
|
|
97
|
+
catch (e) {
|
|
98
|
+
if (e.code === 'ENOENT')
|
|
99
|
+
return null;
|
|
100
|
+
throw e;
|
|
101
|
+
}
|
|
102
|
+
if (stat.isSymbolicLink() || (i < parts.length - 1 && !stat.isDirectory()))
|
|
103
|
+
throw denied();
|
|
104
|
+
}
|
|
105
|
+
try {
|
|
106
|
+
if (await realpath(absolute) !== absolute)
|
|
107
|
+
throw denied();
|
|
108
|
+
}
|
|
109
|
+
catch (e) {
|
|
110
|
+
if (e.code === 'ENOENT')
|
|
111
|
+
return null;
|
|
112
|
+
throw e;
|
|
113
|
+
}
|
|
114
|
+
const fd = await open(absolute, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
115
|
+
try {
|
|
116
|
+
const before = await fd.stat({ bigint: true });
|
|
117
|
+
if (!before.isFile() || before.nlink !== 1n || before.size > BigInt(this.options.maxFileBytes))
|
|
118
|
+
throw denied();
|
|
119
|
+
const bytes = await fd.readFile(), after = await fd.stat({ bigint: true }), named = await lstat(absolute, { bigint: true });
|
|
120
|
+
for (const s of [after, named])
|
|
121
|
+
if (!s.isFile() || s.nlink !== 1n || s.dev !== before.dev || s.ino !== before.ino || s.size !== before.size || s.mtimeNs !== before.mtimeNs || s.ctimeNs !== before.ctimeNs || s.mode !== before.mode)
|
|
122
|
+
throw denied();
|
|
123
|
+
if (BigInt(bytes.length) !== before.size)
|
|
124
|
+
throw denied();
|
|
125
|
+
return { path: relative, mode: (Number(before.mode) & 0o111) ? 0o100755 : 0o100644, bytes };
|
|
126
|
+
}
|
|
127
|
+
finally {
|
|
128
|
+
await fd.close();
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
async capture() {
|
|
132
|
+
const o = this.options;
|
|
133
|
+
if (!/^[a-f0-9]{40}$/.test(o.baseCommitSha) || !/^ai-work\/local\/[A-Za-z0-9-]+$/.test(o.workingBranch) || await realpath(o.root) !== o.root || !Number.isSafeInteger(o.maxFileBytes) || o.maxFileBytes < 1 || !Number.isSafeInteger(o.maxTotalBytes) || o.maxTotalBytes < o.maxFileBytes)
|
|
134
|
+
throw denied();
|
|
135
|
+
const head = (await this.git(['rev-parse', '--verify', 'HEAD'])).toString().trim(), branch = (await this.git(['symbolic-ref', '--short', 'HEAD'])).toString().trim();
|
|
136
|
+
if (head !== o.baseCommitSha || branch !== o.workingBranch)
|
|
137
|
+
throw denied();
|
|
138
|
+
const base = new Map(), gitlinks = new Map();
|
|
139
|
+
for (const line of (await this.git(['ls-tree', '-r', '-z', '--full-tree', o.baseCommitSha])).toString().split('\0').filter(Boolean)) {
|
|
140
|
+
const link = /^160000 commit ([a-f0-9]{40})\t(.+)$/s.exec(line);
|
|
141
|
+
if (link) {
|
|
142
|
+
if (!safe(link[2]))
|
|
143
|
+
throw denied();
|
|
144
|
+
gitlinks.set(link[2], link[1]);
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
const match = /^(100644|100755) blob ([a-f0-9]{40})\t(.+)$/s.exec(line);
|
|
148
|
+
if (!match || !safe(match[3]))
|
|
149
|
+
throw denied();
|
|
150
|
+
base.set(match[3], { mode: parseInt(match[1], 8), blob: match[2] });
|
|
151
|
+
}
|
|
152
|
+
// Gitlinks remain exact base-tree references. Never traverse or copy their source.
|
|
153
|
+
// Staged replacement/deletion is refused rather than publishing hidden changes.
|
|
154
|
+
const staged = new Map();
|
|
155
|
+
for (const line of (await this.git(['ls-files', '--stage', '-z'])).toString().split('\0').filter(Boolean)) {
|
|
156
|
+
const entry = /^(100644|100755|160000) ([a-f0-9]{40}) 0\t(.+)$/s.exec(line);
|
|
157
|
+
if (!entry || !safe(entry[3]))
|
|
158
|
+
throw denied();
|
|
159
|
+
staged.set(entry[3], entry[1] + ' ' + entry[2]);
|
|
160
|
+
}
|
|
161
|
+
const expected = new Map([...base.entries()].map(([name, value]) => [name, value.mode.toString(8) + ' ' + value.blob]));
|
|
162
|
+
for (const [name, oid] of gitlinks)
|
|
163
|
+
expected.set(name, '160000 ' + oid);
|
|
164
|
+
if (protocolHash([...expected].sort()) !== protocolHash([...staged].sort()))
|
|
165
|
+
throw denied();
|
|
166
|
+
const names = new Set([...base.keys(), ...(await this.git(['ls-files', '--others', '--exclude-standard', '-z', '--'])).toString().split('\0').filter(Boolean)]);
|
|
167
|
+
const blobs = await this.batchObjects([...base.values()].map(v => v.blob));
|
|
168
|
+
const files = [], changes = [];
|
|
169
|
+
let total = 0;
|
|
170
|
+
for (const name of [...names].sort()) {
|
|
171
|
+
if ([...gitlinks.keys()].some(p => name === p || name.startsWith(p + '/')))
|
|
172
|
+
throw denied();
|
|
173
|
+
const current = await this.currentFile(name), prior = base.get(name);
|
|
174
|
+
let before = null;
|
|
175
|
+
if (prior) {
|
|
176
|
+
const blob = blobs.get(prior.blob);
|
|
177
|
+
if (!blob || blob.type !== 'blob')
|
|
178
|
+
throw denied();
|
|
179
|
+
before = blob.bytes;
|
|
180
|
+
if (before.length > o.maxFileBytes)
|
|
181
|
+
throw denied();
|
|
182
|
+
}
|
|
183
|
+
total += (before?.length ?? 0) + (current?.bytes.length ?? 0);
|
|
184
|
+
if (total > o.maxTotalBytes)
|
|
185
|
+
throw denied();
|
|
186
|
+
if (current)
|
|
187
|
+
files.push(current);
|
|
188
|
+
if (!prior || !current || !before.equals(current.bytes) || prior.mode !== current.mode)
|
|
189
|
+
changes.push({ path: name, status: !prior ? 'added' : !current ? 'deleted' : 'modified', before: before?.toString('base64') ?? null, after: current?.bytes.toString('base64') ?? null, before_mode: prior?.mode ?? null, after_mode: current?.mode ?? null });
|
|
190
|
+
}
|
|
191
|
+
const observation = { base_commit_sha: o.baseCommitSha, head_commit_sha: head, working_branch: branch, diff: JSON.stringify({ format: 'local-candidate-binary-diff-v1', changes }), changed_files: changes.map(({ path, status }) => ({ path, status })) };
|
|
192
|
+
return { observation, files, fingerprint: protocolHash({ observation, files: files.map(f => ({ path: f.path, mode: f.mode, sha256: digest(f.bytes) })) }) };
|
|
193
|
+
}
|
|
194
|
+
/** Creates a separate local-only Git checkout with exact base parent objects.
|
|
195
|
+
* Raw object copying and raw file writes invoke no filters, templates or hooks;
|
|
196
|
+
* no local fetch/upload-pack, network or account credentials are involved. */
|
|
197
|
+
async publicationCheckout(sealed) {
|
|
198
|
+
try {
|
|
199
|
+
if (!this.seals.has(sealed))
|
|
200
|
+
throw denied();
|
|
201
|
+
await sealed.assertUnchanged();
|
|
202
|
+
const o = this.options, parent = o.privateRoot;
|
|
203
|
+
if (await realpath(parent) !== parent || (await lstat(parent)).mode & 0o077)
|
|
204
|
+
throw denied();
|
|
205
|
+
const root = await mkdtemp(path.join(parent, 'publisher-')), gitdir = path.join(root, '.git');
|
|
206
|
+
await mkdir(path.join(gitdir, 'objects'), { recursive: true, mode: 0o700 });
|
|
207
|
+
const ref = path.join(gitdir, 'refs', 'heads', o.workingBranch);
|
|
208
|
+
await mkdir(path.dirname(ref), { recursive: true, mode: 0o700 });
|
|
209
|
+
await writeFile(path.join(gitdir, 'HEAD'), `ref: refs/heads/${o.workingBranch}\n`, { mode: 0o600, flag: 'wx' });
|
|
210
|
+
await writeFile(ref, o.baseCommitSha + '\n', { mode: 0o600, flag: 'wx' });
|
|
211
|
+
await writeFile(path.join(gitdir, 'config'), '[core]\nrepositoryformatversion = 0\nbare = false\nhooksPath = /dev/null\nfsmonitor = false\n', { mode: 0o600, flag: 'wx' });
|
|
212
|
+
const objects = new Map([[o.baseCommitSha, 'commit']]);
|
|
213
|
+
const commit = await this.git(['cat-file', 'commit', o.baseCommitSha]);
|
|
214
|
+
const tree = /^tree ([a-f0-9]{40})\n/.exec(commit.toString());
|
|
215
|
+
if (!tree)
|
|
216
|
+
throw denied();
|
|
217
|
+
objects.set(tree[1], 'tree');
|
|
218
|
+
for (const entry of (await this.git(['ls-tree', '-r', '-t', '-z', o.baseCommitSha])).toString().split('\0').filter(Boolean)) {
|
|
219
|
+
if (/^160000 commit [a-f0-9]{40}\t/.test(entry))
|
|
220
|
+
continue;
|
|
221
|
+
const match = /^(?:100644|100755|040000) (blob|tree) ([a-f0-9]{40})\t/.exec(entry);
|
|
222
|
+
if (!match)
|
|
223
|
+
throw denied();
|
|
224
|
+
objects.set(match[2], match[1]);
|
|
225
|
+
}
|
|
226
|
+
const contents = await this.batchObjects([...objects.keys()]);
|
|
227
|
+
for (const [oid, type] of objects) {
|
|
228
|
+
const value = contents.get(oid);
|
|
229
|
+
if (!value || value.type !== type)
|
|
230
|
+
throw denied();
|
|
231
|
+
const content = value.bytes, raw = Buffer.concat([Buffer.from(`${type} ${content.length}\0`), content]);
|
|
232
|
+
if (createHash('sha1').update(raw).digest('hex') !== oid)
|
|
233
|
+
throw denied();
|
|
234
|
+
const dir = path.join(gitdir, 'objects', oid.slice(0, 2));
|
|
235
|
+
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
236
|
+
await writeFile(path.join(dir, oid.slice(2)), deflateSync(raw), { flag: 'wx', mode: 0o400 });
|
|
237
|
+
}
|
|
238
|
+
const copy = async (from, to) => { for (const name of await readdir(from)) {
|
|
239
|
+
const source = path.join(from, name), target = path.join(to, name), stat = await lstat(source);
|
|
240
|
+
if (stat.isSymbolicLink())
|
|
241
|
+
throw denied();
|
|
242
|
+
if (stat.isDirectory()) {
|
|
243
|
+
await mkdir(target, { mode: 0o700 });
|
|
244
|
+
await copy(source, target);
|
|
245
|
+
}
|
|
246
|
+
else {
|
|
247
|
+
const fd = await open(source, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
248
|
+
try {
|
|
249
|
+
await writeFile(target, await fd.readFile(), { flag: 'wx', mode: (stat.mode & 0o111) ? 0o700 : 0o600 });
|
|
250
|
+
}
|
|
251
|
+
finally {
|
|
252
|
+
await fd.close();
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
} };
|
|
256
|
+
await copy(sealed.root, root);
|
|
257
|
+
await sealed.assertUnchanged();
|
|
258
|
+
return root;
|
|
259
|
+
}
|
|
260
|
+
catch {
|
|
261
|
+
throw denied();
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
async observe() { try {
|
|
265
|
+
const a = await this.capture(), b = await this.capture();
|
|
266
|
+
if (a.fingerprint !== b.fingerprint)
|
|
267
|
+
throw denied();
|
|
268
|
+
return structuredClone(a.observation);
|
|
269
|
+
}
|
|
270
|
+
catch {
|
|
271
|
+
throw denied();
|
|
272
|
+
} }
|
|
273
|
+
/** Call only after coordinator has stopped its owned model and revoked tools.
|
|
274
|
+
* Materialization is outside model grants; permissions are defense in depth,
|
|
275
|
+
* not independent protection from a malicious process running as the host UID. */
|
|
276
|
+
async materialize() {
|
|
277
|
+
try {
|
|
278
|
+
const parent = this.options.privateRoot;
|
|
279
|
+
if (await realpath(parent) !== parent)
|
|
280
|
+
throw denied();
|
|
281
|
+
const stat = await lstat(parent);
|
|
282
|
+
if (!stat.isDirectory() || (stat.mode & 0o077) !== 0 || (process.getuid && stat.uid !== process.getuid()))
|
|
283
|
+
throw denied();
|
|
284
|
+
const capture = await this.capture(), root = await mkdtemp(path.join(parent, 'candidate-'));
|
|
285
|
+
for (const f of capture.files) {
|
|
286
|
+
const file = path.join(root, f.path);
|
|
287
|
+
await mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
288
|
+
await writeFile(file, f.bytes, { flag: 'wx', mode: (f.mode & 0o111) ? 0o500 : 0o400 });
|
|
289
|
+
}
|
|
290
|
+
if ((await this.capture()).fingerprint !== capture.fingerprint)
|
|
291
|
+
throw denied();
|
|
292
|
+
const expected = protocolHash(capture.files.map(f => ({ path: f.path, mode: f.mode, sha256: digest(f.bytes) })));
|
|
293
|
+
const assertUnchanged = async () => {
|
|
294
|
+
const entries = [];
|
|
295
|
+
const walk = async (dir, prefix = '') => { for (const item of (await readdir(dir)).sort()) {
|
|
296
|
+
const rel = prefix ? prefix + '/' + item : item, file = path.join(dir, item), s = await lstat(file);
|
|
297
|
+
if (s.isSymbolicLink())
|
|
298
|
+
throw denied();
|
|
299
|
+
if (s.isDirectory())
|
|
300
|
+
await walk(file, rel);
|
|
301
|
+
else {
|
|
302
|
+
if (!s.isFile() || s.nlink !== 1 || s.size > this.options.maxFileBytes)
|
|
303
|
+
throw denied();
|
|
304
|
+
entries.push({ path: rel, mode: (s.mode & 0o111) ? 0o100755 : 0o100644, sha256: await measureCommandFile(file) });
|
|
305
|
+
}
|
|
306
|
+
} };
|
|
307
|
+
await walk(root);
|
|
308
|
+
entries.sort((a, b) => a.path < b.path ? -1 : 1);
|
|
309
|
+
if (protocolHash(entries) !== expected)
|
|
310
|
+
throw denied();
|
|
311
|
+
};
|
|
312
|
+
await assertUnchanged();
|
|
313
|
+
const seal = { root, observation: structuredClone(capture.observation), treeFingerprint: expected, assertUnchanged };
|
|
314
|
+
this.seals.add(seal);
|
|
315
|
+
return Object.freeze(seal);
|
|
316
|
+
}
|
|
317
|
+
catch {
|
|
318
|
+
throw denied();
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
}
|