mtech-ai-local-runner 0.1.30 → 0.1.32
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 +64 -1
- package/dist/src/adapters/claude-code/adapter.js +76 -17
- package/dist/src/adapters/codex/adapter.js +146 -30
- package/dist/src/adapters/codex/exec-launch-host.js +50 -11
- package/dist/src/adapters/codex/stream-parser.js +8 -17
- package/dist/src/adapters/completion-report.js +8 -5
- package/dist/src/cli.js +23 -0
- package/dist/src/config.js +4 -4
- package/dist/src/daemon/daemon.js +62 -4
- package/dist/src/daemon/execution-coordinator.js +351 -77
- package/dist/src/daemon/job-poller.js +4 -1
- package/dist/src/daemon/manual-codex.js +10 -3
- package/dist/src/daemon/preparation-lease.js +142 -0
- package/dist/src/daemon/runtime.js +65 -23
- package/dist/src/daemon/terminal-recovery.js +29 -5
- package/dist/src/gateway-client.js +120 -22
- package/dist/src/platform/macos/launchd.js +13 -2
- package/dist/src/process/node-process-group-host.js +89 -34
- package/dist/src/process/owned-process-guardian.js +140 -0
- 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 +120 -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 +155 -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/publication/server-ci-error.js +15 -0
- package/dist/src/publication/server-ci-host.js +160 -0
- package/dist/src/runner.js +33 -4
- package/dist/src/safety/codex-daemon-host.js +2 -2
- package/dist/src/safety/command-policy-core.js +124 -0
- package/dist/src/safety/command-policy.js +1 -124
- package/dist/src/safety/execution-failure.js +4 -0
- package/dist/src/safety/job-tool-policy.js +9 -0
- package/dist/src/safety/local-tool-policy.js +1 -1
- package/dist/src/safety/terminal-evidence.js +67 -16
- package/dist/src/safety/unsupported-supervisor-command.js +12 -0
- package/dist/src/state-store.js +50 -7
- 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,156 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { promisify } from 'node:util';
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import { deflateSync, inflateSync } from 'node:zlib';
|
|
5
|
+
import { lstat, readdir, readFile, writeFile, mkdir } from 'node:fs/promises';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { protocolHash } from './candidate.js';
|
|
8
|
+
import { assertPinnedGit, assertPrivateCheckout, publicationGitEnvironment, publicationGitArgs, strictPublicationOrigin } from './git-credential-broker.js';
|
|
9
|
+
const execute = promisify(execFile), denied = () => Error('GIT_PUBLICATION_REJECTED');
|
|
10
|
+
/** Only DeterministicPublisher may dispatch validated signed requests. This
|
|
11
|
+
* adapter independently binds all evidence and the current host fence. */
|
|
12
|
+
export class GitPublicationTransport {
|
|
13
|
+
options;
|
|
14
|
+
constructor(options) { this.options = { ...options, evidence: structuredClone(options.evidence), git: { ...options.git } }; }
|
|
15
|
+
async git(args) { const o = this.options; await assertPinnedGit(o.git); const r = await execute(o.git.executable, [...publicationGitArgs, ...args], { cwd: o.checkout, env: publicationGitEnvironment(), maxBuffer: 1024 * 1024 }); await assertPinnedGit(o.git); return r.stdout.trim(); }
|
|
16
|
+
async validate(request) {
|
|
17
|
+
const o = this.options;
|
|
18
|
+
strictPublicationOrigin(o.origin);
|
|
19
|
+
if (!await o.current() || request.protocol_version !== 'deterministic-publication-v1' || !['git.commit', 'git.push'].includes(request.action) || !/^[A-Za-z0-9-]{1,128}$/.test(request.publication_id) || !Number.isFinite(request.expires_at) || request.expires_at <= Date.now() / 1000 || !/^[a-f0-9]{64}$/.test(request.request_fingerprint) || !/^[a-f0-9]{40}$/.test(request.base_commit_sha) || request.working_branch !== `ai-work/local/${request.attempt_id}` || !/^[A-Za-z0-9-]+$/.test(request.attempt_id))
|
|
20
|
+
throw denied();
|
|
21
|
+
for (const key of Object.keys(o.evidence))
|
|
22
|
+
if (protocolHash(request[key]) !== protocolHash(o.evidence[key]))
|
|
23
|
+
throw denied();
|
|
24
|
+
if (!request.receipts.some(r => r.action_key === request.action && /^[a-f0-9]{64}$/.test(r.action_fingerprint)))
|
|
25
|
+
throw denied();
|
|
26
|
+
await assertPrivateCheckout(o.checkout, o.privateRoot);
|
|
27
|
+
// Reject linked/shared object stores, worktrees, alternate config and symlinks.
|
|
28
|
+
const metadata = async (dir) => { for (const name of await readdir(dir)) {
|
|
29
|
+
const file = path.join(dir, name), s = await lstat(file);
|
|
30
|
+
if (s.isSymbolicLink() || (!s.isDirectory() && (!s.isFile() || s.nlink !== 1)) || (process.getuid && s.uid !== process.getuid()))
|
|
31
|
+
throw denied();
|
|
32
|
+
if (s.isDirectory())
|
|
33
|
+
await metadata(file);
|
|
34
|
+
} };
|
|
35
|
+
await metadata(path.join(o.checkout, '.git'));
|
|
36
|
+
const allowed = new Set(['HEAD', 'config', 'objects', 'refs', 'shallow']);
|
|
37
|
+
if ((await readdir(path.join(o.checkout, '.git'))).some(n => !allowed.has(n)))
|
|
38
|
+
throw denied();
|
|
39
|
+
try {
|
|
40
|
+
if ((await readdir(path.join(o.checkout, '.git', 'objects'))).some(n => !/^[a-f0-9]{2}$/.test(n)))
|
|
41
|
+
throw denied();
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
throw denied();
|
|
45
|
+
}
|
|
46
|
+
if (await readFile(path.join(o.checkout, '.git', 'HEAD'), 'utf8') !== `ref: refs/heads/${request.working_branch}\n`)
|
|
47
|
+
throw denied();
|
|
48
|
+
const entries = [];
|
|
49
|
+
let total = 0;
|
|
50
|
+
const walk = async (dir, prefix = '') => { for (const name of (await readdir(dir)).sort()) {
|
|
51
|
+
if (!prefix && name === '.git')
|
|
52
|
+
continue;
|
|
53
|
+
const rel = prefix ? prefix + '/' + name : name, file = path.join(dir, name), s = await lstat(file);
|
|
54
|
+
if (s.isSymbolicLink() || name.includes('\n') || name.includes('\0') || (!s.isDirectory() && (!s.isFile() || s.nlink !== 1)))
|
|
55
|
+
throw denied();
|
|
56
|
+
if (s.isDirectory())
|
|
57
|
+
await walk(file, rel);
|
|
58
|
+
else {
|
|
59
|
+
total += s.size;
|
|
60
|
+
if (s.size > (o.maxFileBytes ?? 16 * 1024 * 1024) || total > (o.maxTotalBytes ?? 64 * 1024 * 1024))
|
|
61
|
+
throw denied();
|
|
62
|
+
entries.push({ path: rel, mode: (s.mode & 0o111) ? 0o100755 : 0o100644, bytes: await readFile(file) });
|
|
63
|
+
}
|
|
64
|
+
} };
|
|
65
|
+
await walk(o.checkout);
|
|
66
|
+
entries.sort((a, b) => a.path < b.path ? -1 : 1);
|
|
67
|
+
if (protocolHash(entries.map(e => ({ path: e.path, mode: e.mode, sha256: createHash('sha256').update(e.bytes).digest('hex') }))) !== o.sealedTreeFingerprint)
|
|
68
|
+
throw denied();
|
|
69
|
+
for (const line of (await this.git(['ls-tree', '-r', '-z', request.base_commit_sha])).split('\0').filter(Boolean)) {
|
|
70
|
+
const gitlink = /^160000 commit ([a-f0-9]{40})\t(.+)$/.exec(line);
|
|
71
|
+
if (!gitlink)
|
|
72
|
+
continue;
|
|
73
|
+
const name = gitlink[2];
|
|
74
|
+
if (!name || name.split('/').some(p => !p || p === '.' || p === '..' || p === '.git') || name.includes('\\') || entries.some(e => e.path === name || e.path.startsWith(name + '/') || name.startsWith(e.path + '/')))
|
|
75
|
+
throw denied();
|
|
76
|
+
entries.push({ path: name, mode: 0o160000, bytes: Buffer.alloc(0), gitlink: gitlink[1] });
|
|
77
|
+
}
|
|
78
|
+
if (!await o.current())
|
|
79
|
+
throw denied();
|
|
80
|
+
return entries;
|
|
81
|
+
}
|
|
82
|
+
async object(type, bytes, write) { const raw = Buffer.concat([Buffer.from(`${type} ${bytes.length}\0`), bytes]), oid = createHash('sha1').update(raw).digest('hex'); if (write) {
|
|
83
|
+
const dir = path.join(this.options.checkout, '.git', 'objects', oid.slice(0, 2)), file = path.join(dir, oid.slice(2));
|
|
84
|
+
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
85
|
+
try {
|
|
86
|
+
await writeFile(file, deflateSync(raw), { flag: 'wx', mode: 0o400 });
|
|
87
|
+
}
|
|
88
|
+
catch (e) {
|
|
89
|
+
if (e.code !== 'EEXIST' || !inflateSync(await readFile(file)).equals(raw))
|
|
90
|
+
throw denied();
|
|
91
|
+
}
|
|
92
|
+
} return oid; }
|
|
93
|
+
async tree(entries, write, prefix = '') {
|
|
94
|
+
const names = new Map();
|
|
95
|
+
const children = new Set();
|
|
96
|
+
for (const entry of entries) {
|
|
97
|
+
if (!entry.path.startsWith(prefix))
|
|
98
|
+
continue;
|
|
99
|
+
const rest = entry.path.slice(prefix.length), slash = rest.indexOf('/');
|
|
100
|
+
if (slash >= 0)
|
|
101
|
+
children.add(rest.slice(0, slash));
|
|
102
|
+
else
|
|
103
|
+
names.set(rest, { mode: entry.mode.toString(8), oid: entry.gitlink ?? await this.object('blob', entry.bytes, write) });
|
|
104
|
+
}
|
|
105
|
+
for (const child of children)
|
|
106
|
+
names.set(child, { mode: '40000', oid: await this.tree(entries, write, prefix + child + '/') });
|
|
107
|
+
const chunks = [...names].sort(([a, av], [b, bv]) => Buffer.compare(Buffer.from(a + (av.mode === '40000' ? '/' : '')), Buffer.from(b + (bv.mode === '40000' ? '/' : '')))).map(([name, v]) => Buffer.concat([Buffer.from(`${v.mode} ${name}\0`), Buffer.from(v.oid, 'hex')]));
|
|
108
|
+
return this.object('tree', Buffer.concat(chunks), write);
|
|
109
|
+
}
|
|
110
|
+
marker(r) { return `Local Fix ${r.attempt_id}\n\nLocal-Fix-Publication: ${r.publication_id}\nLocal-Fix-Candidate: ${r.candidate_fingerprint}\n`; }
|
|
111
|
+
async commit(r, entries, write) { const tree = await this.tree(entries, write); return this.object('commit', Buffer.from(`tree ${tree}\nparent ${r.base_commit_sha}\nauthor Local Fix <local-fix@invalid> 1 +0000\ncommitter Local Fix <local-fix@invalid> 1 +0000\n\n${this.marker(r)}Local-Fix-Request: ${r.request_fingerprint}\n`), write); }
|
|
112
|
+
scope(r) { return { origin: this.options.origin, checkout: this.options.checkout, ref: `refs/heads/${r.working_branch}`, sha: r.accepted_results.committed_sha }; }
|
|
113
|
+
async acceptedCommit(r, entries) { const sha = r.accepted_results.committed_sha; if (!sha || !/^[a-f0-9]{40}$/.test(sha) || await this.git(['rev-parse', '--verify', 'HEAD']) !== sha)
|
|
114
|
+
throw denied(); const raw = await this.git(['cat-file', 'commit', sha]); if (!raw.includes(`\nparent ${r.base_commit_sha}\n`) || !raw.includes('\n' + this.marker(r)) || !/^Local-Fix-Request: [a-f0-9]{64}$/m.test(raw) || !raw.startsWith(`tree ${await this.tree(entries, false)}\n`))
|
|
115
|
+
throw denied(); return sha; }
|
|
116
|
+
async execute(request) {
|
|
117
|
+
try {
|
|
118
|
+
const r = structuredClone(request), entries = await this.validate(r);
|
|
119
|
+
if (r.action === 'git.push') {
|
|
120
|
+
const sha = await this.acceptedCommit(r, entries);
|
|
121
|
+
await this.validate(r);
|
|
122
|
+
await this.options.broker.push(this.scope(r));
|
|
123
|
+
if (!await this.options.current())
|
|
124
|
+
throw denied();
|
|
125
|
+
return { pushed_branch_head: sha };
|
|
126
|
+
}
|
|
127
|
+
const sha = await this.commit(r, entries, false), head = await this.git(['rev-parse', '--verify', 'HEAD']);
|
|
128
|
+
if (head === sha)
|
|
129
|
+
return { committed_sha: sha };
|
|
130
|
+
if (head !== r.base_commit_sha)
|
|
131
|
+
throw denied();
|
|
132
|
+
await this.commit(r, entries, true);
|
|
133
|
+
await this.validate(r);
|
|
134
|
+
await writeFile(path.join(this.options.checkout, '.git', 'shallow'), r.base_commit_sha + '\n', { mode: 0o600 });
|
|
135
|
+
await this.git(['update-ref', `refs/heads/${r.working_branch}`, sha, r.base_commit_sha]);
|
|
136
|
+
if (!await this.options.current())
|
|
137
|
+
throw denied();
|
|
138
|
+
return { committed_sha: sha };
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
throw denied();
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
async reconcile(request) { try {
|
|
145
|
+
const r = structuredClone(request), entries = await this.validate(r);
|
|
146
|
+
if (r.action === 'git.commit') {
|
|
147
|
+
const sha = await this.commit(r, entries, false);
|
|
148
|
+
return await this.git(['rev-parse', '--verify', 'HEAD']) === sha ? { committed_sha: sha } : null;
|
|
149
|
+
}
|
|
150
|
+
const sha = await this.acceptedCommit(r, entries);
|
|
151
|
+
return await this.options.broker.head(this.scope(r)) === sha ? { pushed_branch_head: sha } : null;
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
throw denied();
|
|
155
|
+
} }
|
|
156
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { constants } from 'node:fs';
|
|
2
|
+
import { lstat, open, realpath } from 'node:fs/promises';
|
|
3
|
+
import { createPublicKey } from 'node:crypto';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { createProductionLocalFixHost } from './production-host.js';
|
|
7
|
+
import { ScopedGitCredentialBroker, assertPinnedGit, strictPublicationOrigin } from './git-credential-broker.js';
|
|
8
|
+
import { protocolHash } from './candidate.js';
|
|
9
|
+
import { createServerCiLocalFixHost, assertServerCiRegistration } from './server-ci-host.js';
|
|
10
|
+
import { verifyLocalFixRuntime } from './runtime-release.js';
|
|
11
|
+
const denied = () => Error('LOCAL_FIX_INSTALLATION_REJECTED');
|
|
12
|
+
async function privateFile(file) { const s = await lstat(file); if (!s.isFile() || s.isSymbolicLink() || s.nlink !== 1 || (s.mode & 0o077) !== 0 || s.size > 128 * 1024 || (process.getuid && s.uid !== process.getuid()) || await realpath(file) !== file)
|
|
13
|
+
throw denied(); return s; }
|
|
14
|
+
async function readPrivateJson(file) {
|
|
15
|
+
const initial = await privateFile(file), f = await open(file, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
16
|
+
try {
|
|
17
|
+
const before = await f.stat();
|
|
18
|
+
if (before.ino !== initial.ino || before.dev !== initial.dev || !before.isFile() || before.nlink !== 1 || (before.mode & 0o077) !== 0 || (process.getuid && before.uid !== process.getuid()) || before.size > 128 * 1024)
|
|
19
|
+
throw denied();
|
|
20
|
+
const value = JSON.parse(await f.readFile('utf8')), after = await f.stat(), named = await privateFile(file);
|
|
21
|
+
for (const st of [after, named])
|
|
22
|
+
if (st.ino !== before.ino || st.dev !== before.dev || st.size !== before.size || st.mtimeMs !== before.mtimeMs || st.ctimeMs !== before.ctimeMs || st.mode !== before.mode || st.uid !== before.uid || st.nlink !== 1)
|
|
23
|
+
throw denied();
|
|
24
|
+
if (!value || Array.isArray(value) || typeof value !== 'object')
|
|
25
|
+
throw denied();
|
|
26
|
+
return value;
|
|
27
|
+
}
|
|
28
|
+
finally {
|
|
29
|
+
await f.close();
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/** No installation file is created here. A missing file preserves investigation
|
|
33
|
+
* behavior. Explicit approved installation is owner-only; credentials are read
|
|
34
|
+
* only for an exact post-freeze Git operation, never during daemon discovery. */
|
|
35
|
+
export async function loadInstalledLocalFixHost(input) {
|
|
36
|
+
try {
|
|
37
|
+
const file = path.join(input.stateRoot, 'local-fix-host.json');
|
|
38
|
+
try {
|
|
39
|
+
await lstat(file);
|
|
40
|
+
}
|
|
41
|
+
catch (e) {
|
|
42
|
+
if (e.code === 'ENOENT')
|
|
43
|
+
return undefined;
|
|
44
|
+
throw e;
|
|
45
|
+
}
|
|
46
|
+
const stateStat = await lstat(input.stateRoot);
|
|
47
|
+
if (!stateStat.isDirectory() || stateStat.isSymbolicLink() || (stateStat.mode & 0o077) !== 0 || (process.getuid && stateStat.uid !== process.getuid()) || await realpath(input.stateRoot) !== input.stateRoot)
|
|
48
|
+
throw denied();
|
|
49
|
+
const raw = await readPrivateJson(file);
|
|
50
|
+
if (raw.schema === 'local-fix-host-installation-v2') {
|
|
51
|
+
const config = raw;
|
|
52
|
+
if (config.enabled !== true || config.verification_mode !== 'gateway_ci_v1' || config.runnerId !== input.runnerId)
|
|
53
|
+
throw denied();
|
|
54
|
+
assertServerCiRegistration(config.registration);
|
|
55
|
+
const packageRoot = await realpath(fileURLToPath(new URL('../../../', import.meta.url)));
|
|
56
|
+
const assertReleaseCurrent = () => verifyLocalFixRuntime(packageRoot, config.runtimeFiles, config.registration.receipt);
|
|
57
|
+
await assertReleaseCurrent();
|
|
58
|
+
await assertPinnedGit(config.git);
|
|
59
|
+
const privateRoot = path.join(input.stateRoot, 'local-fix-publication'), s = await lstat(privateRoot);
|
|
60
|
+
if (!s.isDirectory() || s.isSymbolicLink() || (s.mode & 0o077) !== 0 || (process.getuid && s.uid !== process.getuid()) || await realpath(privateRoot) !== privateRoot)
|
|
61
|
+
throw denied();
|
|
62
|
+
return createServerCiLocalFixHost({ runnerId: input.runnerId, privateRoot, git: config.git, registration: config.registration, gateway: input.gateway, assertReleaseCurrent, canonicalApprovalId: async (publicationId) => {
|
|
63
|
+
let approvals;
|
|
64
|
+
try {
|
|
65
|
+
approvals = await readPrivateJson(path.join(input.stateRoot, 'local-fix-pr-approvals.json'));
|
|
66
|
+
}
|
|
67
|
+
catch (e) {
|
|
68
|
+
if (e.code === 'ENOENT')
|
|
69
|
+
return undefined;
|
|
70
|
+
throw e;
|
|
71
|
+
}
|
|
72
|
+
const id = approvals[publicationId];
|
|
73
|
+
if (id !== undefined && (typeof id !== 'string' || !/^[a-zA-Z0-9-]{1,191}$/.test(id)))
|
|
74
|
+
throw denied();
|
|
75
|
+
return id;
|
|
76
|
+
} });
|
|
77
|
+
}
|
|
78
|
+
const config = raw;
|
|
79
|
+
if (config.schema !== 'local-fix-host-installation-v1' || config.enabled !== true || config.runnerId !== input.runnerId || !config.registration || config.registration.review.manifestFingerprint !== protocolHash(config.registration.manifest))
|
|
80
|
+
throw denied();
|
|
81
|
+
const packageRoot = await realpath(fileURLToPath(new URL('../../../', import.meta.url)));
|
|
82
|
+
const assertReleaseCurrent = () => verifyLocalFixRuntime(packageRoot, config.runtimeFiles, config.registration.receipt);
|
|
83
|
+
await assertReleaseCurrent();
|
|
84
|
+
strictPublicationOrigin(config.origin);
|
|
85
|
+
const key = createPublicKey(config.publicKeyPem);
|
|
86
|
+
if (key.asymmetricKeyType !== 'ed25519')
|
|
87
|
+
throw denied();
|
|
88
|
+
if (config.node.executable !== await realpath(process.execPath) || config.credentialHelper.executable !== await realpath(fileURLToPath(new URL('./git-credential-helper.js', import.meta.url))))
|
|
89
|
+
throw denied();
|
|
90
|
+
await Promise.all([config.git, config.remoteHelper, config.node, config.credentialHelper].map(assertPinnedGit));
|
|
91
|
+
const privateRoot = path.join(input.stateRoot, 'local-fix-publication');
|
|
92
|
+
const s = await lstat(privateRoot);
|
|
93
|
+
if (!s.isDirectory() || s.isSymbolicLink() || (s.mode & 0o077) !== 0 || (process.getuid && s.uid !== process.getuid()) || await realpath(privateRoot) !== privateRoot)
|
|
94
|
+
throw denied();
|
|
95
|
+
const credentials = path.join(input.stateRoot, 'local-fix-git-credentials.json');
|
|
96
|
+
await privateFile(credentials); // metadata only
|
|
97
|
+
return createProductionLocalFixHost({ runnerId: input.runnerId, privateRoot, git: config.git, origin: config.origin, publicKeyPem: config.publicKeyPem, registration: config.registration, gateway: input.gateway, assertReleaseCurrent,
|
|
98
|
+
broker: ({ branch, current }) => new ScopedGitCredentialBroker({ git: config.git, remoteHelper: config.remoteHelper, node: config.node, credentialHelper: config.credentialHelper, origin: config.origin, branch, current, privateRoot, authorization: async (scope) => {
|
|
99
|
+
if (scope.origin !== config.origin || !await current())
|
|
100
|
+
throw denied();
|
|
101
|
+
const value = await readPrivateJson(credentials);
|
|
102
|
+
if (value.origin !== scope.origin || typeof value.username !== 'string' || typeof value.password !== 'string' || !value.username || !value.password)
|
|
103
|
+
throw denied();
|
|
104
|
+
return { username: value.username, password: value.password };
|
|
105
|
+
} }),
|
|
106
|
+
canonicalApprovalId: async (publicationId) => { const approvalsFile = path.join(input.stateRoot, 'local-fix-pr-approvals.json'); let approvals; try {
|
|
107
|
+
approvals = await readPrivateJson(approvalsFile);
|
|
108
|
+
}
|
|
109
|
+
catch (e) {
|
|
110
|
+
if (e.code === 'ENOENT')
|
|
111
|
+
return undefined;
|
|
112
|
+
throw e;
|
|
113
|
+
} const id = approvals[publicationId]; if (id !== undefined && (typeof id !== 'string' || !/^[a-zA-Z0-9-]{1,191}$/.test(id)))
|
|
114
|
+
throw denied(); return id; },
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
throw denied();
|
|
119
|
+
}
|
|
120
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { realpath } from 'node:fs/promises';
|
|
3
|
+
import { measureCommandFile } from '../safety/registered-command-integrity.js';
|
|
4
|
+
import { LocalToolPolicy } from '../safety/local-tool-policy.js';
|
|
5
|
+
import { protocolHash } from './candidate.js';
|
|
6
|
+
/** Reuses the exact registered-command sandbox: no ambient credentials, network,
|
|
7
|
+
* subprocess permissions, repository writes, or additional dependency grants.
|
|
8
|
+
* Unsupported PHP/Node project commands remain denied by existing confinement. */
|
|
9
|
+
function buildIsolatedVerifier(input, allowCandidateReads = false) {
|
|
10
|
+
const options = { ...input, commands: structuredClone(input.commands), allowedPaths: [...input.allowedPaths], blockedPaths: [...input.blockedPaths] };
|
|
11
|
+
return async (snapshot) => {
|
|
12
|
+
const executionCommands = [];
|
|
13
|
+
for (const command of options.commands) {
|
|
14
|
+
const derived = structuredClone(command), names = command.candidateReadPaths ?? [];
|
|
15
|
+
if (!Array.isArray(names) || new Set(names).size !== names.length || (!allowCandidateReads && names.length))
|
|
16
|
+
throw Error('ISOLATED_VERIFICATION_REJECTED');
|
|
17
|
+
for (const name of names) {
|
|
18
|
+
if (typeof name !== 'string' || !name || path.isAbsolute(name) || name.includes('\\') || name.split('/').some(p => !p || p === '.' || p === '..' || /^(?:\.git|\.env(?:\..*)?|\.ssh|\.aws|\.codex|\.claude|\.npmrc|\.netrc|credentials(?:\..*)?|auth\.(?:json|toml|ya?ml)|AGENTS\.md|CLAUDE\.md)$/i.test(p)) || !options.allowedPaths.includes(name) || Object.hasOwn(command.integrity.readFiles, name) || options.blockedPaths.some(p => name === p || name.startsWith(p.replace(/\/\*\*$/, '') + '/')))
|
|
19
|
+
throw Error('ISOLATED_VERIFICATION_REJECTED');
|
|
20
|
+
const file = path.join(options.root, name);
|
|
21
|
+
if (await realpath(file) !== file)
|
|
22
|
+
throw Error('ISOLATED_VERIFICATION_REJECTED');
|
|
23
|
+
derived.integrity.readFiles[name] = await measureCommandFile(file);
|
|
24
|
+
}
|
|
25
|
+
executionCommands.push(derived);
|
|
26
|
+
}
|
|
27
|
+
const policy = new LocalToolPolicy({ worktreeRoot: options.root, access: 'read', allowedPaths: options.allowedPaths, blockedPaths: options.blockedPaths, requiredCommands: executionCommands.map(c => ({ ...c, readPaths: Object.keys(c.integrity.readFiles) })) });
|
|
28
|
+
try {
|
|
29
|
+
if (snapshot.commands.length !== options.commands.length)
|
|
30
|
+
throw Error('ISOLATED_VERIFICATION_REJECTED');
|
|
31
|
+
const results = [];
|
|
32
|
+
for (const command of options.commands) {
|
|
33
|
+
const fingerprint = protocolHash(command);
|
|
34
|
+
if (!await options.current() || !snapshot.commands.some(c => c.id === command.id && c.fingerprint === fingerprint))
|
|
35
|
+
throw Error('ISOLATED_VERIFICATION_REJECTED');
|
|
36
|
+
await policy.call('local_test', { command: command.id });
|
|
37
|
+
if (!await options.current())
|
|
38
|
+
throw Error('ISOLATED_VERIFICATION_REJECTED');
|
|
39
|
+
results.push({ command_id: command.id, command_fingerprint: fingerprint, snapshot_fingerprint: snapshot.snapshot_fingerprint, status: 'passed' });
|
|
40
|
+
}
|
|
41
|
+
return results;
|
|
42
|
+
}
|
|
43
|
+
finally {
|
|
44
|
+
policy.revoke();
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
export function createIsolatedVerifier(input) { return buildIsolatedVerifier(input); }
|
|
49
|
+
/** Materializes the exact observed candidate outside model access and verifies
|
|
50
|
+
* that copy. Registered argv must be relative to its root; no argv rewriting or
|
|
51
|
+
* wider file grants are introduced. Caller can then construct a separate
|
|
52
|
+
* publication checkout from the retained sealed snapshot. */
|
|
53
|
+
export function createSealedIsolatedVerifier(options) {
|
|
54
|
+
let sealed = null;
|
|
55
|
+
return { materialized: () => sealed, verify: async (snapshot) => {
|
|
56
|
+
const { createHash } = await import('node:crypto');
|
|
57
|
+
const copy = await options.host.materialize();
|
|
58
|
+
const manifest = [...copy.observation.changed_files].sort((a, b) => a.path < b.path ? -1 : 1);
|
|
59
|
+
if (createHash('sha256').update(copy.observation.diff).digest('hex') !== snapshot.diff_hash || protocolHash(manifest) !== snapshot.changed_file_manifest_hash)
|
|
60
|
+
throw Error('ISOLATED_VERIFICATION_REJECTED');
|
|
61
|
+
await copy.assertUnchanged();
|
|
62
|
+
const receipts = await buildIsolatedVerifier({ ...options, root: copy.root }, true)(snapshot);
|
|
63
|
+
await copy.assertUnchanged();
|
|
64
|
+
sealed = copy;
|
|
65
|
+
return receipts;
|
|
66
|
+
} };
|
|
67
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { constants } from 'node:fs';
|
|
2
|
+
import { lstat, mkdir, open, realpath, rename, rmdir, unlink } from 'node:fs/promises';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
/** Private host-owned directory outside model/test access. No credentials stored.
|
|
6
|
+
* fsync+rename gives durable intent; lock directories survive crashes and require
|
|
7
|
+
* explicit owner recovery. Never steal stale locks or replay uncertain effects. */
|
|
8
|
+
export class FilePublicationJournal {
|
|
9
|
+
root;
|
|
10
|
+
held = new Set();
|
|
11
|
+
constructor(root) {
|
|
12
|
+
this.root = root;
|
|
13
|
+
}
|
|
14
|
+
async file(key) {
|
|
15
|
+
if (!/^[a-f0-9]{64}$/.test(key) || !path.isAbsolute(this.root) || await realpath(this.root) !== this.root)
|
|
16
|
+
throw Error('PUBLICATION_JOURNAL_REJECTED');
|
|
17
|
+
const stat = await lstat(this.root);
|
|
18
|
+
if (!stat.isDirectory() || stat.isSymbolicLink() || (stat.mode & 0o077) !== 0 || (process.getuid && stat.uid !== process.getuid()))
|
|
19
|
+
throw Error('PUBLICATION_JOURNAL_REJECTED');
|
|
20
|
+
return path.join(this.root, key + '.json');
|
|
21
|
+
}
|
|
22
|
+
async exclusive(key, operation) {
|
|
23
|
+
const lock = (await this.file(key)) + '.lock';
|
|
24
|
+
try {
|
|
25
|
+
await mkdir(lock, { mode: 0o700 });
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
throw Error('PUBLICATION_BUSY');
|
|
29
|
+
}
|
|
30
|
+
this.held.add(key);
|
|
31
|
+
try {
|
|
32
|
+
return await operation();
|
|
33
|
+
}
|
|
34
|
+
finally {
|
|
35
|
+
this.held.delete(key);
|
|
36
|
+
await rmdir(lock);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
async load(key) {
|
|
40
|
+
const file = await this.file(key);
|
|
41
|
+
let fd;
|
|
42
|
+
try {
|
|
43
|
+
fd = await open(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
44
|
+
}
|
|
45
|
+
catch (e) {
|
|
46
|
+
if (e.code === 'ENOENT')
|
|
47
|
+
return;
|
|
48
|
+
throw Error('PUBLICATION_JOURNAL_REJECTED');
|
|
49
|
+
}
|
|
50
|
+
try {
|
|
51
|
+
const stat = await fd.stat();
|
|
52
|
+
if (!stat.isFile() || stat.nlink !== 1 || stat.size > 1024 * 1024 || (stat.mode & 0o077) !== 0)
|
|
53
|
+
throw Error('PUBLICATION_JOURNAL_REJECTED');
|
|
54
|
+
return JSON.parse(await fd.readFile('utf8'));
|
|
55
|
+
}
|
|
56
|
+
finally {
|
|
57
|
+
await fd.close();
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
async save(key, record) {
|
|
61
|
+
const file = await this.file(key);
|
|
62
|
+
if (!this.held.has(key))
|
|
63
|
+
throw Error('PUBLICATION_JOURNAL_REJECTED');
|
|
64
|
+
const temporary = file + '.' + randomUUID() + '.tmp';
|
|
65
|
+
const fd = await open(temporary, 'wx', 0o600);
|
|
66
|
+
try {
|
|
67
|
+
await fd.writeFile(JSON.stringify(record));
|
|
68
|
+
await fd.sync();
|
|
69
|
+
}
|
|
70
|
+
finally {
|
|
71
|
+
await fd.close();
|
|
72
|
+
}
|
|
73
|
+
try {
|
|
74
|
+
await rename(temporary, file);
|
|
75
|
+
const directory = await open(this.root, constants.O_RDONLY);
|
|
76
|
+
try {
|
|
77
|
+
await directory.sync();
|
|
78
|
+
}
|
|
79
|
+
finally {
|
|
80
|
+
await directory.close();
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
finally {
|
|
84
|
+
await unlink(temporary).catch(() => { });
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { createPublicKey, randomUUID } from 'node:crypto';
|
|
2
|
+
import { constants } from 'node:fs';
|
|
3
|
+
import { lstat, realpath, open, rename, unlink } from 'node:fs/promises';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { CandidateVerifier, protocolHash } from './candidate.js';
|
|
6
|
+
import { createLocalFixExecutionHost } from './execution-host.js';
|
|
7
|
+
import { GitSnapshotHost } from './git-snapshot.js';
|
|
8
|
+
import { createSealedIsolatedVerifier } from './isolated-verifier.js';
|
|
9
|
+
import { FilePublicationJournal } from './journal.js';
|
|
10
|
+
import { DeterministicPublisher, PublicationOutcomeUnknownError } from './publisher.js';
|
|
11
|
+
import { GitPublicationTransport } from './git-transport.js';
|
|
12
|
+
import { assertPrivateCheckout } from './git-credential-broker.js';
|
|
13
|
+
import { buildReviewedProjectRegistration } from './project-registration.js';
|
|
14
|
+
const denied = () => Error('LOCAL_FIX_PRODUCTION_HOST_REJECTED');
|
|
15
|
+
const fields = ['binding_id', 'actor_id', 'runner_id', 'job_id', 'generation', 'attempt_id', 'execution_contract_id', 'work_loop_id', 'repository_lease_id', 'repository_key', 'base_commit_sha', 'working_branch', 'contract_fingerprint', 'task_package_fingerprint', 'launch_fingerprint'];
|
|
16
|
+
function proof(value) {
|
|
17
|
+
const output = Object.fromEntries(fields.map(k => [k, value[k]]));
|
|
18
|
+
if (!Number.isSafeInteger(output.generation) || output.generation < 1 || fields.some(k => k !== 'generation' && (typeof output[k] !== 'string' || !output[k])))
|
|
19
|
+
throw denied();
|
|
20
|
+
return output;
|
|
21
|
+
}
|
|
22
|
+
/** Reviewed composition: no model callback, ambient Git identity/helper, source
|
|
23
|
+
* upload or account credential is used. Installation/activation is separate. */
|
|
24
|
+
export function createProductionLocalFixHost(options) {
|
|
25
|
+
const registration = structuredClone(options.registration);
|
|
26
|
+
const host = createLocalFixExecutionHost({ registration: { receipt: registration.receipt, commands: registration.manifest.commands }, prepare: async (input) => {
|
|
27
|
+
await options.assertReleaseCurrent?.();
|
|
28
|
+
const lifecycle = input.lifecycle;
|
|
29
|
+
if (!lifecycle)
|
|
30
|
+
throw Error('LOCAL_FIX_OWNED_LIFECYCLE_REQUIRED');
|
|
31
|
+
const { job, bindingId } = input, pkg = job.taskPackage, repo = pkg.repository, scope = pkg.local_fix_scope;
|
|
32
|
+
if (!lifecycle.isCurrent() || repo.provider_url !== options.origin || repo.key !== registration.manifest.project || job.gatewayTaskPackageFingerprint !== protocolHash(pkg) || pkg.runtime_adapter !== 'codex_cli')
|
|
33
|
+
throw denied();
|
|
34
|
+
const root = await realpath(options.privateRoot), stat = await lstat(root);
|
|
35
|
+
if (root !== options.privateRoot || !stat.isDirectory() || stat.isSymbolicLink() || (stat.mode & 0o077) !== 0 || (process.getuid && stat.uid !== process.getuid()) || root === input.checkoutPath || root.startsWith(input.checkoutPath + path.sep))
|
|
36
|
+
throw denied();
|
|
37
|
+
const allowed = pkg.allowed_paths, commands = registration.manifest.commands;
|
|
38
|
+
const verificationPaths = [...new Set([...allowed, ...commands.flatMap(c => Object.keys(c.integrity.readFiles))])];
|
|
39
|
+
await buildReviewedProjectRegistration({ root: input.checkoutPath, manifest: registration.manifest, review: registration.review, allowedPaths: verificationPaths, blockedPaths: ['.env', '.env.*', '.git/**', '.ssh/**', '.aws/**'] });
|
|
40
|
+
const fence = { jobId: job.id, generation: job.generation, leaseToken: job.leaseToken, launchGrant: job.launchGrant, bindingId };
|
|
41
|
+
const binding = proof((await options.gateway.localPublicationContext(fence)).context);
|
|
42
|
+
if (!lifecycle.isCurrent() || binding.runner_id !== options.runnerId || binding.job_id !== job.id || binding.generation !== job.generation || binding.binding_id !== bindingId || binding.work_loop_id !== job.workLoopId || binding.repository_key !== repo.key || binding.base_commit_sha !== repo.base_commit_sha || binding.working_branch !== repo.working_branch || binding.attempt_id !== scope.attempt_id || binding.contract_fingerprint !== job.gatewayContractFingerprint || binding.task_package_fingerprint !== job.gatewayTaskPackageFingerprint || binding.launch_fingerprint !== job.launchFingerprint)
|
|
43
|
+
throw denied();
|
|
44
|
+
let closed = false;
|
|
45
|
+
const current = async () => { if (closed || !lifecycle.isCurrent())
|
|
46
|
+
return false; try {
|
|
47
|
+
await options.assertReleaseCurrent?.();
|
|
48
|
+
if (closed || !lifecycle.isCurrent())
|
|
49
|
+
return false;
|
|
50
|
+
const context = proof((await options.gateway.localPublicationContext(fence)).context);
|
|
51
|
+
return !closed && lifecycle.isCurrent() && protocolHash(context) === protocolHash(binding);
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return false;
|
|
55
|
+
} };
|
|
56
|
+
const frozenCurrent = async () => lifecycle.isFrozen() && await current();
|
|
57
|
+
const limits = scope.candidate_limits;
|
|
58
|
+
const snapshots = new GitSnapshotHost({ root: input.checkoutPath, privateRoot: root, git: options.git, baseCommitSha: binding.base_commit_sha, workingBranch: binding.working_branch, maxFileBytes: limits.max_file_bytes, maxTotalBytes: 128 * 1024 * 1024 });
|
|
59
|
+
const isolated = createSealedIsolatedVerifier({ host: snapshots, allowedPaths: verificationPaths, blockedPaths: ['.env', '.env.*', '.git/**', '.ssh/**', '.aws/**'], commands, current: frozenCurrent });
|
|
60
|
+
const verifier = new CandidateVerifier({ binding, root: input.checkoutPath, allowedPaths: allowed, blockedPaths: ['.env', '.env.*', '.git/**', '.ssh/**', '.aws/**'], maxFiles: limits.max_changed_files, maxDiffBytes: limits.max_diff_bytes, maxFileBytes: limits.max_file_bytes, commands, current: frozenCurrent, assertRetainedSeal: async () => { const seal = isolated.materialized(); if (!seal)
|
|
61
|
+
throw denied(); await seal.assertUnchanged(); }, observe: () => snapshots.observe(), verify: isolated.verify });
|
|
62
|
+
let git;
|
|
63
|
+
const publisher = new DeterministicPublisher({ publicKey: createPublicKey(options.publicKeyPem), journal: new FilePublicationJournal(root), assertCurrent: c => verifier.assertCurrent(c), gateway: {
|
|
64
|
+
authorize: async (candidate) => { const securityReceipt = lifecycle.securityReceipt(); if (!securityReceipt || !await frozenCurrent())
|
|
65
|
+
throw denied(); const value = await options.gateway.authorizeLocalPublication({ ...fence, candidate: candidate, testReceipts: (await verified()).testReceipts, securityReceipt: { ...securityReceipt }, hostReceipt: registration.receipt }); return { publication_id: value.publication.id }; },
|
|
66
|
+
issue: async (publicationId, action) => { if (!await frozenCurrent())
|
|
67
|
+
throw denied(); return (await options.gateway.issueLocalPublicationAction({ ...fence, publicationId, action, commitMessage: `Fix approved local attempt ${binding.attempt_id}`, pullRequestTitle: `Fix approved local attempt ${binding.attempt_id}`, pullRequestBody: `Verified isolated local fix for attempt ${binding.attempt_id}.` })).publication_request; },
|
|
68
|
+
accept: async (publicationId, action, result) => { if (!await frozenCurrent())
|
|
69
|
+
throw new PublicationOutcomeUnknownError(); await options.gateway.acceptLocalPublicationResult({ ...fence, publicationId, action, result: result }); },
|
|
70
|
+
}, provider: { execute: async (request) => {
|
|
71
|
+
if (request.action === 'bitbucket.pull_request_create')
|
|
72
|
+
return remotePr(request);
|
|
73
|
+
return (await gitTransport()).execute(request);
|
|
74
|
+
}, reconcile: async (request) => request.action === 'bitbucket.pull_request_create' ? remotePr(request) : (await gitTransport()).reconcile(request) } });
|
|
75
|
+
// Exactly one verified capability per session; no serialized model object can mint it.
|
|
76
|
+
let candidate;
|
|
77
|
+
const verified = async () => candidate ??= await verifier.verify();
|
|
78
|
+
const gitTransport = async () => {
|
|
79
|
+
if (!git) {
|
|
80
|
+
const seal = isolated.materialized();
|
|
81
|
+
if (!seal || !candidate || !await frozenCurrent())
|
|
82
|
+
throw denied();
|
|
83
|
+
const expected = { schema: 'local-publication-checkout-v1', binding_fingerprint: protocolHash(binding), candidate_fingerprint: candidate.evidence.candidate_fingerprint, tree_fingerprint: seal.treeFingerprint, origin: options.origin, git: options.git, manifest_fingerprint: registration.review.manifestFingerprint };
|
|
84
|
+
// Publisher journal lock serializes this reference with action intents.
|
|
85
|
+
// Persist before any Git effect; recovery must reopen the same checkout.
|
|
86
|
+
const file = path.join(root, 'checkout-' + protocolHash({ binding: binding, candidate: candidate.evidence.candidate_fingerprint }) + '.json');
|
|
87
|
+
let checkout;
|
|
88
|
+
let fd;
|
|
89
|
+
try {
|
|
90
|
+
fd = await open(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
91
|
+
}
|
|
92
|
+
catch (error) {
|
|
93
|
+
if (error.code !== 'ENOENT')
|
|
94
|
+
throw denied();
|
|
95
|
+
}
|
|
96
|
+
if (fd) {
|
|
97
|
+
try {
|
|
98
|
+
const before = await fd.stat();
|
|
99
|
+
if (!before.isFile() || before.nlink !== 1 || before.size > 16384 || (before.mode & 0o777) !== 0o600 || (process.getuid && before.uid !== process.getuid()))
|
|
100
|
+
throw denied();
|
|
101
|
+
const saved = JSON.parse(await fd.readFile('utf8'));
|
|
102
|
+
const after = await fd.stat(), named = await lstat(file);
|
|
103
|
+
for (const st of [after, named])
|
|
104
|
+
if (!st.isFile() || st.dev !== before.dev || st.ino !== before.ino || st.size !== before.size || st.nlink !== 1 || st.mtimeMs !== before.mtimeMs || st.ctimeMs !== before.ctimeMs)
|
|
105
|
+
throw denied();
|
|
106
|
+
const { checkout_name, ...bound } = saved;
|
|
107
|
+
if (protocolHash(bound) !== protocolHash(expected) || typeof checkout_name !== 'string' || !/^publisher-[A-Za-z0-9]+$/.test(checkout_name))
|
|
108
|
+
throw denied();
|
|
109
|
+
checkout = path.join(root, checkout_name);
|
|
110
|
+
await assertPrivateCheckout(checkout, root);
|
|
111
|
+
}
|
|
112
|
+
finally {
|
|
113
|
+
await fd.close();
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
else {
|
|
117
|
+
checkout = await snapshots.publicationCheckout(seal);
|
|
118
|
+
await assertPrivateCheckout(checkout, root);
|
|
119
|
+
const temporary = file + '.' + randomUUID() + '.tmp', metadata = await open(temporary, 'wx', 0o600);
|
|
120
|
+
try {
|
|
121
|
+
await metadata.writeFile(JSON.stringify({ ...expected, checkout_name: path.basename(checkout) }));
|
|
122
|
+
await metadata.sync();
|
|
123
|
+
}
|
|
124
|
+
finally {
|
|
125
|
+
await metadata.close();
|
|
126
|
+
}
|
|
127
|
+
try {
|
|
128
|
+
await rename(temporary, file);
|
|
129
|
+
const directory = await open(root, constants.O_RDONLY);
|
|
130
|
+
try {
|
|
131
|
+
await directory.sync();
|
|
132
|
+
}
|
|
133
|
+
finally {
|
|
134
|
+
await directory.close();
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
finally {
|
|
138
|
+
await unlink(temporary).catch(() => { });
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
if (!await frozenCurrent())
|
|
142
|
+
throw denied();
|
|
143
|
+
git = new GitPublicationTransport({ checkout, privateRoot: root, git: options.git, sealedTreeFingerprint: seal.treeFingerprint, evidence: candidate.evidence, origin: options.origin, current: frozenCurrent, broker: typeof options.broker === 'function' ? options.broker({ branch: binding.working_branch, current: frozenCurrent }) : options.broker, maxFileBytes: limits.max_file_bytes });
|
|
144
|
+
}
|
|
145
|
+
return git;
|
|
146
|
+
};
|
|
147
|
+
const remotePr = async (request) => { if (!await frozenCurrent())
|
|
148
|
+
throw new PublicationOutcomeUnknownError(); const canonicalApprovalId = await options.canonicalApprovalId?.(request.publication_id); if (!await frozenCurrent())
|
|
149
|
+
throw new PublicationOutcomeUnknownError(); const response = await options.gateway.executeLocalPublicationPr({ ...fence, publicationId: request.publication_id, signedRequest: request, canonicalApprovalId }); if (response.result.status !== 'completed')
|
|
150
|
+
throw new PublicationOutcomeUnknownError(); return response.result; };
|
|
151
|
+
return { verifier: { verify: verified }, publisher, close: async () => { closed = true; } };
|
|
152
|
+
} });
|
|
153
|
+
return { requiresOwnedFreeze: true, prepare: async (input) => { if (!input.lifecycle)
|
|
154
|
+
throw Error('LOCAL_FIX_OWNED_LIFECYCLE_REQUIRED'); return host.prepare(input); } };
|
|
155
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { realpath } from 'node:fs/promises';
|
|
3
|
+
import { verifyCommandIntegrity } from '../safety/registered-command-integrity.js';
|
|
4
|
+
import { evaluateCommand } from '../safety/command-policy.js';
|
|
5
|
+
import { protocolHash } from './candidate.js';
|
|
6
|
+
const denied = () => new Error('PROJECT_REGISTRATION_REJECTED');
|
|
7
|
+
const privateSegment = /^(?:\.git|\.mcp(?:\..*)?|\.env(?:\..*)?|\.ssh|\.aws|\.config|\.codex|\.claude|\.runner|\.local-runner|\.npmrc|\.netrc|id_rsa|id_ed25519|credentials(?:\..*)?|auth\.(?:json|toml|ya?ml)(?:\..*)?|mcp\.(?:json|toml|ya?ml)(?:\..*)?|AGENTS\.md|CLAUDE\.md|vendor|node_modules)$/i;
|
|
8
|
+
function exactName(name) { return typeof name === 'string' && !!name && !path.isAbsolute(name) && !/[\\\0*?\[\]{}]/.test(name) && name.split('/').every(s => !!s && s !== '.' && s !== '..' && !privateSegment.test(s)); }
|
|
9
|
+
function blocked(name, scopes) { return scopes.some(scope => scope === '.env.*' ? name.startsWith('.env.') : name === scope || name.startsWith(scope.replace(/\/\*\*$/, '') + '/')); }
|
|
10
|
+
/** Validates reviewed exact registrations against the current host and scope.
|
|
11
|
+
* Receipt authenticity/ownership belongs to host release attestation; this API
|
|
12
|
+
* does not approve a manifest or imply project/incident acceptance. Only the
|
|
13
|
+
* existing bundled Node runtime is supported; no new sandbox grants are added. */
|
|
14
|
+
export async function buildReviewedProjectRegistration(input) {
|
|
15
|
+
try {
|
|
16
|
+
const options = structuredClone(input), { manifest, review } = options;
|
|
17
|
+
if (!path.isAbsolute(options.root) || await realpath(options.root) !== options.root || manifest.schema !== 'local-project-verification-v1' || !/^[a-zA-Z0-9._-]+$/.test(manifest.project) || !Array.isArray(manifest.commands) || !manifest.commands.length || !review || !review.receiptId?.trim() || !review.reviewer?.trim() || review.manifestFingerprint !== protocolHash(manifest))
|
|
18
|
+
throw denied();
|
|
19
|
+
const runtime = await realpath(process.execPath), ids = new Set();
|
|
20
|
+
for (const command of manifest.commands) {
|
|
21
|
+
if (!/^[a-zA-Z0-9._-]+$/.test(command.id) || ids.has(command.id) || command.executable !== runtime || !Array.isArray(command.argv) || !command.argv.length || !command.integrity?.readFiles)
|
|
22
|
+
throw denied();
|
|
23
|
+
ids.add(command.id);
|
|
24
|
+
const pins = Object.keys(command.integrity.readFiles), dynamic = command.candidateReadPaths ?? [], all = [...pins, ...dynamic];
|
|
25
|
+
if (!Array.isArray(dynamic) || !pins.length || new Set(all).size !== all.length || all.some(name => !exactName(name) || !options.allowedPaths.includes(name) || blocked(name, options.blockedPaths)))
|
|
26
|
+
throw denied();
|
|
27
|
+
// First argv names an immutable harness; remaining file arguments name
|
|
28
|
+
// explicit reviewed immutable or candidate dependencies.
|
|
29
|
+
// No eval/preload/runtime flags, absolute argv, shell or ambient module loading.
|
|
30
|
+
if (!pins.includes(command.argv[0]) || command.argv.some(arg => typeof arg !== 'string' || !all.includes(arg)))
|
|
31
|
+
throw denied();
|
|
32
|
+
const rendered = [path.basename(runtime), ...command.argv].join(' ');
|
|
33
|
+
if (!evaluateCommand(rendered, { requiredCommands: [rendered] }).allowed)
|
|
34
|
+
throw denied();
|
|
35
|
+
const files = [];
|
|
36
|
+
for (const name of pins) {
|
|
37
|
+
const file = path.join(options.root, name);
|
|
38
|
+
if (await realpath(file) !== file)
|
|
39
|
+
throw denied();
|
|
40
|
+
files.push(file);
|
|
41
|
+
}
|
|
42
|
+
await verifyCommandIntegrity(command.integrity, runtime, pins, files);
|
|
43
|
+
}
|
|
44
|
+
return { commands: manifest.commands, manifestFingerprint: review.manifestFingerprint, reviewReceiptId: review.receiptId };
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
throw denied();
|
|
48
|
+
}
|
|
49
|
+
}
|