mecord-connect 1.0.0
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/DISCLOSURE +47 -0
- package/LICENSE +64 -0
- package/README.md +44 -0
- package/bin/operator.mjs +10 -0
- package/package.json +58 -0
- package/runtime/app/apps/local-agent/src/approval-store.js +342 -0
- package/runtime/app/apps/local-agent/src/bootstrap-config.js +255 -0
- package/runtime/app/apps/local-agent/src/cli.js +237 -0
- package/runtime/app/apps/local-agent/src/device-reset.js +260 -0
- package/runtime/app/apps/local-agent/src/emergency-stop.js +141 -0
- package/runtime/app/apps/local-agent/src/main.js +311 -0
- package/runtime/app/apps/local-agent/src/privacy-data.js +129 -0
- package/runtime/app/apps/local-agent/src/relay-agent.js +333 -0
- package/runtime/app/apps/local-agent/src/relay-enrollment.js +156 -0
- package/runtime/app/apps/local-agent/src/relay-session-credentials.js +436 -0
- package/runtime/app/apps/local-agent/src/remote.js +130 -0
- package/runtime/app/apps/local-agent/src/runtime-factory.js +66 -0
- package/runtime/app/apps/local-agent/src/server.js +578 -0
- package/runtime/app/package.json +4 -0
- package/runtime/app/src/capabilities/browser-cdp-connection.js +199 -0
- package/runtime/app/src/capabilities/browser-cdp-frames.js +288 -0
- package/runtime/app/src/capabilities/browser-cdp-page.js +430 -0
- package/runtime/app/src/capabilities/browser-cdp.js +384 -0
- package/runtime/app/src/capabilities/browser-managed.js +345 -0
- package/runtime/app/src/capabilities/docker.js +448 -0
- package/runtime/app/src/capabilities/filesystem.js +264 -0
- package/runtime/app/src/capabilities/git-checkpoint.js +415 -0
- package/runtime/app/src/capabilities/git-write.js +321 -0
- package/runtime/app/src/capabilities/git.js +252 -0
- package/runtime/app/src/capabilities/index.js +16 -0
- package/runtime/app/src/capabilities/path-scope.js +86 -0
- package/runtime/app/src/capabilities/postgres.js +566 -0
- package/runtime/app/src/capabilities/process.js +258 -0
- package/runtime/app/src/capabilities/project-command.js +599 -0
- package/runtime/app/src/capabilities/project-transaction.js +215 -0
- package/runtime/app/src/capabilities/project.js +67 -0
- package/runtime/app/src/capabilities/system.js +40 -0
- package/runtime/app/src/capabilities/vscode.js +312 -0
- package/runtime/app/src/capabilities/windows-uia.js +331 -0
- package/runtime/app/src/core/account-device-registry.js +604 -0
- package/runtime/app/src/core/action-identity.js +44 -0
- package/runtime/app/src/core/audit.js +295 -0
- package/runtime/app/src/core/capability-policy.js +58 -0
- package/runtime/app/src/core/child-environment.js +29 -0
- package/runtime/app/src/core/device-enrollment.js +417 -0
- package/runtime/app/src/core/device-identity.js +436 -0
- package/runtime/app/src/core/device-registry.js +477 -0
- package/runtime/app/src/core/device-reset.js +178 -0
- package/runtime/app/src/core/device-routing.js +305 -0
- package/runtime/app/src/core/durable-state.js +306 -0
- package/runtime/app/src/core/errors.js +20 -0
- package/runtime/app/src/core/evidence.js +9 -0
- package/runtime/app/src/core/index.js +17 -0
- package/runtime/app/src/core/machine-state.js +160 -0
- package/runtime/app/src/core/network-authority.js +20 -0
- package/runtime/app/src/core/policy.js +67 -0
- package/runtime/app/src/core/product-identity.js +7 -0
- package/runtime/app/src/core/provenance.js +18 -0
- package/runtime/app/src/core/provider-learning.js +274 -0
- package/runtime/app/src/core/public-plugin-surface.js +25 -0
- package/runtime/app/src/core/public-restricted-data.js +105 -0
- package/runtime/app/src/core/rate-limit.js +118 -0
- package/runtime/app/src/core/relay-client.js +568 -0
- package/runtime/app/src/core/relay-delivery-store.js +512 -0
- package/runtime/app/src/core/relay-result-store.js +321 -0
- package/runtime/app/src/core/release-update.js +253 -0
- package/runtime/app/src/core/router.js +96 -0
- package/runtime/app/src/core/runtime.js +136 -0
- package/runtime/app/src/core/scoped-path-syntax.js +16 -0
- package/runtime/app/src/core/session-token.js +498 -0
- package/runtime/app/src/core/task-orchestrator.js +1148 -0
- package/runtime/app/src/core/task-store.js +517 -0
- package/runtime/app/src/core/task.js +142 -0
- package/runtime/app/src/core/trusted-executable.js +124 -0
- package/runtime/app/src/core/types.js +83 -0
- package/runtime/app/src/core/windows-path-lease.js +138 -0
- package/runtime/native/operator-windows-dpapi.exe +0 -0
- package/runtime/native/operator-windows-path-lease.exe +0 -0
- package/runtime/native/operator-windows-uia.exe +0 -0
- package/runtime/runtime-manifest.json +380 -0
- package/src/cli.mjs +345 -0
- package/src/publish-check.mjs +22 -0
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import crypto from 'node:crypto';
|
|
3
|
+
import fs from 'node:fs/promises';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
|
|
7
|
+
import { evidence } from '../core/evidence.js';
|
|
8
|
+
import { OperatorError } from '../core/errors.js';
|
|
9
|
+
import { resolveSupportedGitExecutable } from '../core/trusted-executable.js';
|
|
10
|
+
import { GitCheckpointProvider } from './git-checkpoint.js';
|
|
11
|
+
|
|
12
|
+
const SCORE = {
|
|
13
|
+
reliability: 0.99,
|
|
14
|
+
latency: 0.94,
|
|
15
|
+
determinism: 0.99,
|
|
16
|
+
security: 0.97,
|
|
17
|
+
reversibility: 0.97,
|
|
18
|
+
informationQuality: 0.99,
|
|
19
|
+
interactionCost: 0.01
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const DISABLED_HOOKS_PATH = process.platform === 'win32' ? 'NUL' : '/dev/null';
|
|
23
|
+
const SAFE_GIT_PREFIX = ['--no-pager', '--no-lazy-fetch', '-c', 'core.fsmonitor=false', '-c', `core.hooksPath=${DISABLED_HOOKS_PATH}`, '--literal-pathspecs'];
|
|
24
|
+
const MAX_OUTPUT_BYTES = 1024 * 1024;
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
export class GitWriteProvider {
|
|
37
|
+
name = 'git.write.native';
|
|
38
|
+
#checkpoint ;
|
|
39
|
+
|
|
40
|
+
constructor(options ) {
|
|
41
|
+
this.#checkpoint = new GitCheckpointProvider(options);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
supports(action ) {
|
|
45
|
+
return action.capability === 'git.write';
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
advertises(action ) {
|
|
49
|
+
if (!this.supports(action)) return false;
|
|
50
|
+
try { resolveSupportedGitExecutable(gitEnvironment({})); return true; } catch { return false; }
|
|
51
|
+
}
|
|
52
|
+
score() { return SCORE; }
|
|
53
|
+
|
|
54
|
+
async execute(action ) {
|
|
55
|
+
const started = performance.now();
|
|
56
|
+
try {
|
|
57
|
+
resolveSupportedGitExecutable(gitEnvironment({}));
|
|
58
|
+
const operation = String(action.input.operation ?? '');
|
|
59
|
+
if (!['stage', 'unstage', 'commit'].includes(operation)) {
|
|
60
|
+
throw new OperatorError('INVALID_GIT_WRITE_OPERATION', 'operation must be stage, unstage, or commit.');
|
|
61
|
+
}
|
|
62
|
+
const cwd = String(action.input.cwd ?? '');
|
|
63
|
+
const expectedCurrentFingerprint = String(action.input.expectedCurrentFingerprint ?? '');
|
|
64
|
+
if (!/^[0-9a-f]{64}$/i.test(expectedCurrentFingerprint)) {
|
|
65
|
+
throw new OperatorError('GIT_WRITE_FINGERPRINT_REQUIRED', 'expectedCurrentFingerprint from git.checkpoint inspect is required.');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const before = await this.#inspectState(cwd);
|
|
69
|
+
if (before.fingerprint !== expectedCurrentFingerprint) {
|
|
70
|
+
throw new OperatorError('GIT_WRITE_STATE_CHANGED', 'Repository state changed after the supplied write precondition was captured.', {
|
|
71
|
+
retryable: true,
|
|
72
|
+
details: { expectedCurrentFingerprint, actualCurrentFingerprint: before.fingerprint }
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const checkpointResult = await this.#checkpoint.execute({
|
|
77
|
+
id: `${action.id}:checkpoint`,
|
|
78
|
+
capability: 'git.checkpoint.create',
|
|
79
|
+
risk: 'write',
|
|
80
|
+
input: { cwd, label: `automatic checkpoint before git ${operation}` },
|
|
81
|
+
provenance: { kind: 'runtime', source: action.id }
|
|
82
|
+
});
|
|
83
|
+
if (!checkpointResult.ok) {
|
|
84
|
+
throw new OperatorError(checkpointResult.error?.code ?? 'GIT_CHECKPOINT_FAILED', checkpointResult.error?.message ?? 'Unable to create pre-write checkpoint.');
|
|
85
|
+
}
|
|
86
|
+
const checkpoint = checkpointResult.output ;
|
|
87
|
+
|
|
88
|
+
if (operation === 'stage') return await this.#stage(action, started, before, checkpoint.id);
|
|
89
|
+
if (operation === 'unstage') return await this.#unstage(action, started, before, checkpoint.id);
|
|
90
|
+
return await this.#commit(action, started, before, checkpoint.id);
|
|
91
|
+
} catch (error) {
|
|
92
|
+
const op = error instanceof OperatorError
|
|
93
|
+
? error
|
|
94
|
+
: new OperatorError('GIT_WRITE_ERROR', error instanceof Error ? error.message : String(error));
|
|
95
|
+
return {
|
|
96
|
+
ok: false,
|
|
97
|
+
capability: action.capability,
|
|
98
|
+
provider: this.name,
|
|
99
|
+
evidence: [evidence('git_write', 'fail', op.message, { code: op.code })],
|
|
100
|
+
error: { code: op.code, message: op.message, retryable: op.retryable },
|
|
101
|
+
durationMs: Math.round(performance.now() - started)
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async #stage(action , started , before , checkpointId ) {
|
|
107
|
+
const paths = validatePaths(action.input.paths);
|
|
108
|
+
const all = action.input.all === true;
|
|
109
|
+
if (!all && paths.length === 0) throw new OperatorError('GIT_PATHS_REQUIRED', 'stage requires paths or all=true.');
|
|
110
|
+
await runGit(before.root, ['add', '-A', '--', ...(all ? ['.'] : paths)], {});
|
|
111
|
+
const after = await this.#inspectState(before.root);
|
|
112
|
+
const staged = await runGit(before.root, ['diff', '--cached', '--name-only', '-z', '--no-ext-diff', '--no-textconv'], {});
|
|
113
|
+
const stagedPaths = staged.stdout.split('\0').filter(Boolean).slice(0, 500);
|
|
114
|
+
return success(action, started, 'stage', before, after, checkpointId, {
|
|
115
|
+
stagedPaths,
|
|
116
|
+
all
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async #unstage(action , started , before , checkpointId ) {
|
|
121
|
+
if (!before.head) throw new OperatorError('GIT_UNBORN_UNSTAGE_UNSUPPORTED', 'unstage currently requires an existing HEAD commit.');
|
|
122
|
+
const paths = validatePaths(action.input.paths);
|
|
123
|
+
const all = action.input.all === true;
|
|
124
|
+
if (!all && paths.length === 0) throw new OperatorError('GIT_PATHS_REQUIRED', 'unstage requires paths or all=true.');
|
|
125
|
+
await runGit(before.root, ['restore', '--staged', '--', ...(all ? ['.'] : paths)], {});
|
|
126
|
+
const after = await this.#inspectState(before.root);
|
|
127
|
+
return success(action, started, 'unstage', before, after, checkpointId, { all, paths: all ? [] : paths });
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async #commit(action , started , before , checkpointId ) {
|
|
131
|
+
const message = String(action.input.message ?? '').trim();
|
|
132
|
+
if (!message || message.length > 4000 || message.includes('\0')) {
|
|
133
|
+
throw new OperatorError('INVALID_COMMIT_MESSAGE', 'commit message must contain 1-4000 characters and no NUL bytes.');
|
|
134
|
+
}
|
|
135
|
+
const staged = await runGit(before.root, ['diff', '--cached', '--quiet', '--no-ext-diff', '--no-textconv'], {}, true);
|
|
136
|
+
if (staged.code === 0) throw new OperatorError('GIT_NOTHING_STAGED', 'No staged changes are available to commit.');
|
|
137
|
+
if (staged.code !== 1) throw new OperatorError('GIT_STAGED_CHECK_FAILED', staged.stderr.trim() || `git diff exited ${staged.code}.`);
|
|
138
|
+
|
|
139
|
+
const identity = await commitIdentityEnv(before.root);
|
|
140
|
+
const hooksDir = await fs.mkdtemp(path.join(os.tmpdir(), 'operator-empty-hooks-'));
|
|
141
|
+
try {
|
|
142
|
+
await runGit(before.root, [
|
|
143
|
+
'-c', `core.hooksPath=${hooksDir}`,
|
|
144
|
+
'-c', 'commit.gpgSign=false',
|
|
145
|
+
'commit', '--no-verify', '--no-gpg-sign', '-m', message
|
|
146
|
+
], identity);
|
|
147
|
+
} finally {
|
|
148
|
+
await fs.rm(hooksDir, { recursive: true, force: true });
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const newHead = (await runGit(before.root, ['rev-parse', 'HEAD'], {})).stdout.trim();
|
|
152
|
+
if (!/^[0-9a-f]{40,64}$/i.test(newHead) || newHead === before.head) {
|
|
153
|
+
throw new OperatorError('GIT_COMMIT_POSTCONDITION_FAILED', 'Git commit did not advance HEAD as expected.');
|
|
154
|
+
}
|
|
155
|
+
const committedTree = (await runGit(before.root, ['rev-parse', 'HEAD^{tree}'], {})).stdout.trim();
|
|
156
|
+
if (committedTree !== before.indexTree) {
|
|
157
|
+
throw new OperatorError('GIT_COMMIT_POSTCONDITION_FAILED', 'Committed tree does not match the staged index captured by the precondition.', {
|
|
158
|
+
details: { expectedIndexTree: before.indexTree, committedTree }
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
if (before.head) {
|
|
162
|
+
const parent = (await runGit(before.root, ['rev-parse', 'HEAD^'], {})).stdout.trim();
|
|
163
|
+
if (parent !== before.head) {
|
|
164
|
+
throw new OperatorError('GIT_COMMIT_POSTCONDITION_FAILED', 'New commit parent does not match the pre-write HEAD.', {
|
|
165
|
+
details: { expectedParent: before.head, actualParent: parent }
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
const after = await this.#inspectState(before.root);
|
|
170
|
+
return success(action, started, 'commit', before, after, checkpointId, {
|
|
171
|
+
newHead,
|
|
172
|
+
previousHead: before.head,
|
|
173
|
+
committedTree,
|
|
174
|
+
hooksDisabled: true,
|
|
175
|
+
gpgSigningDisabled: true
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async #inspectState(cwd ) {
|
|
180
|
+
const result = await this.#checkpoint.execute({
|
|
181
|
+
id: crypto.randomUUID(),
|
|
182
|
+
capability: 'git.checkpoint.inspect',
|
|
183
|
+
risk: 'read',
|
|
184
|
+
input: { cwd },
|
|
185
|
+
provenance: { kind: 'runtime' }
|
|
186
|
+
});
|
|
187
|
+
if (!result.ok) throw new OperatorError(result.error?.code ?? 'GIT_STATE_INSPECTION_FAILED', result.error?.message ?? 'Unable to inspect Git state.');
|
|
188
|
+
return (result.output ).current;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function validatePaths(input ) {
|
|
193
|
+
const raw = Array.isArray(input) ? input.map(String) : [];
|
|
194
|
+
if (raw.length > 200) throw new OperatorError('TOO_MANY_GIT_PATHS', 'At most 200 paths may be supplied.');
|
|
195
|
+
return raw.map((item) => {
|
|
196
|
+
const value = item.trim();
|
|
197
|
+
if (!value || value.length > 1000 || value.includes('\0') || value.includes('\r') || value.includes('\n')) {
|
|
198
|
+
throw new OperatorError('INVALID_GIT_PATH', 'Git paths must be non-empty bounded single-line relative paths.');
|
|
199
|
+
}
|
|
200
|
+
if (path.isAbsolute(value) || value.startsWith(':')) throw new OperatorError('INVALID_GIT_PATH', 'Absolute paths and Git pathspec magic are not allowed.');
|
|
201
|
+
const normalized = path.normalize(value);
|
|
202
|
+
if (normalized === '..' || normalized.startsWith(`..${path.sep}`) || path.isAbsolute(normalized)) {
|
|
203
|
+
throw new OperatorError('INVALID_GIT_PATH', 'Git path escapes the repository root.');
|
|
204
|
+
}
|
|
205
|
+
return value;
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function success(
|
|
210
|
+
action ,
|
|
211
|
+
started ,
|
|
212
|
+
operation ,
|
|
213
|
+
before ,
|
|
214
|
+
after ,
|
|
215
|
+
checkpointId ,
|
|
216
|
+
details
|
|
217
|
+
) {
|
|
218
|
+
return {
|
|
219
|
+
ok: true,
|
|
220
|
+
capability: action.capability,
|
|
221
|
+
provider: 'git.write.native',
|
|
222
|
+
output: {
|
|
223
|
+
operation,
|
|
224
|
+
before,
|
|
225
|
+
after,
|
|
226
|
+
checkpointId,
|
|
227
|
+
...details
|
|
228
|
+
},
|
|
229
|
+
evidence: [
|
|
230
|
+
evidence('git_write', 'pass', `Structured Git ${operation} completed without a command shell.`, { operation, checkpointId }),
|
|
231
|
+
evidence('recovery_checkpoint', 'pass', 'A non-mutating checkpoint was created before the Git write.', { checkpointId }),
|
|
232
|
+
evidence('postcondition', 'pass', 'Repository state was re-inspected after the Git write.', { beforeFingerprint: before.fingerprint, afterFingerprint: after.fingerprint })
|
|
233
|
+
],
|
|
234
|
+
durationMs: Math.round(performance.now() - started)
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
async function commitIdentityEnv(root ) {
|
|
239
|
+
const [nameResult, emailResult] = await Promise.all([
|
|
240
|
+
runGit(root, ['config', '--local', '--get', 'user.name'], {}, true),
|
|
241
|
+
runGit(root, ['config', '--local', '--get', 'user.email'], {}, true)
|
|
242
|
+
]);
|
|
243
|
+
for (const result of [nameResult, emailResult]) {
|
|
244
|
+
if (![0, 1].includes(result.code)) {
|
|
245
|
+
throw new OperatorError('GIT_CONFIG_INSPECTION_FAILED', result.stderr.trim() || 'Unable to inspect repository-local Git identity.');
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
let name = 'Operator';
|
|
249
|
+
let email = 'operator@local.invalid';
|
|
250
|
+
if (nameResult.code === 0 && emailResult.code === 0) {
|
|
251
|
+
const localName = nameResult.stdout.trim();
|
|
252
|
+
const localEmail = emailResult.stdout.trim();
|
|
253
|
+
if (!localName || localName.length > 200 || /[\0\r\n]/.test(localName)) throw new OperatorError('GIT_IDENTITY_INVALID', 'Repository-local Git user.name is invalid.');
|
|
254
|
+
if (!localEmail || localEmail.length > 320 || /[\0\r\n]/.test(localEmail)) throw new OperatorError('GIT_IDENTITY_INVALID', 'Repository-local Git user.email is invalid.');
|
|
255
|
+
name = localName;
|
|
256
|
+
email = localEmail;
|
|
257
|
+
}
|
|
258
|
+
return {
|
|
259
|
+
GIT_AUTHOR_NAME: name, GIT_AUTHOR_EMAIL: email,
|
|
260
|
+
GIT_COMMITTER_NAME: name, GIT_COMMITTER_EMAIL: email
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function gitEnvironment(extraEnv ) {
|
|
265
|
+
const nullConfig = process.platform === 'win32' ? 'NUL' : '/dev/null';
|
|
266
|
+
const env = {
|
|
267
|
+
GIT_PAGER: '',
|
|
268
|
+
GIT_TERMINAL_PROMPT: '0',
|
|
269
|
+
GIT_CONFIG_GLOBAL: nullConfig,
|
|
270
|
+
GIT_CONFIG_SYSTEM: nullConfig,
|
|
271
|
+
GIT_CONFIG_NOSYSTEM: '1',
|
|
272
|
+
GIT_NO_LAZY_FETCH: '1'
|
|
273
|
+
};
|
|
274
|
+
for (const key of ['PATH', 'Path', 'PATHEXT', 'SYSTEMROOT', 'WINDIR', 'COMSPEC', 'HOME', 'USERPROFILE', 'HOMEDRIVE', 'HOMEPATH', 'USER', 'USERNAME', 'LOGNAME', 'TMP', 'TEMP', 'TMPDIR', 'LANG', 'LC_ALL', 'LC_CTYPE']) {
|
|
275
|
+
if (process.env[key] !== undefined) env[key] = process.env[key];
|
|
276
|
+
}
|
|
277
|
+
for (const [key, value] of Object.entries(extraEnv)) {
|
|
278
|
+
if (value !== undefined) env[key] = value;
|
|
279
|
+
}
|
|
280
|
+
return env;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
async function runGit(cwd , args , extraEnv , allowNonZero = false) {
|
|
284
|
+
return await new Promise((resolve, reject) => {
|
|
285
|
+
const environment = gitEnvironment(extraEnv);
|
|
286
|
+
const gitExecutable = resolveSupportedGitExecutable(environment);
|
|
287
|
+
const child = spawn(gitExecutable, [...SAFE_GIT_PREFIX, ...args], {
|
|
288
|
+
cwd,
|
|
289
|
+
shell: false,
|
|
290
|
+
windowsHide: true,
|
|
291
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
292
|
+
env: environment
|
|
293
|
+
});
|
|
294
|
+
const stdout = [];
|
|
295
|
+
const stderr = [];
|
|
296
|
+
let bytes = 0;
|
|
297
|
+
const capture = (bucket ) => (chunk ) => {
|
|
298
|
+
if (bytes >= MAX_OUTPUT_BYTES) return;
|
|
299
|
+
const sliced = chunk.subarray(0, MAX_OUTPUT_BYTES - bytes);
|
|
300
|
+
bucket.push(sliced);
|
|
301
|
+
bytes += sliced.byteLength;
|
|
302
|
+
};
|
|
303
|
+
child.stdout.on('data', capture(stdout));
|
|
304
|
+
child.stderr.on('data', capture(stderr));
|
|
305
|
+
child.once('error', reject);
|
|
306
|
+
const timer = setTimeout(() => {
|
|
307
|
+
child.kill('SIGTERM');
|
|
308
|
+
setTimeout(() => child.kill('SIGKILL'), 1_000).unref();
|
|
309
|
+
}, 30_000);
|
|
310
|
+
timer.unref();
|
|
311
|
+
child.once('close', (code) => {
|
|
312
|
+
clearTimeout(timer);
|
|
313
|
+
const result = { code: code ?? -1, stdout: Buffer.concat(stdout).toString('utf8'), stderr: Buffer.concat(stderr).toString('utf8') };
|
|
314
|
+
if (result.code !== 0 && !allowNonZero) {
|
|
315
|
+
reject(new OperatorError('GIT_COMMAND_FAILED', result.stderr.trim().slice(0, 1200) || `git ${args[0] ?? ''} exited ${result.code}.`));
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
resolve(result);
|
|
319
|
+
});
|
|
320
|
+
});
|
|
321
|
+
}
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
import { assertPublicSafePath, containsRestrictedData } from '../core/public-restricted-data.js';
|
|
5
|
+
import { OperatorError } from '../core/errors.js';
|
|
6
|
+
import { resolveSupportedGitExecutable } from '../core/trusted-executable.js';
|
|
7
|
+
import { ProcessProvider } from './process.js';
|
|
8
|
+
|
|
9
|
+
const SCORE = {
|
|
10
|
+
reliability: 0.99,
|
|
11
|
+
latency: 0.98,
|
|
12
|
+
determinism: 0.99,
|
|
13
|
+
security: 0.97,
|
|
14
|
+
reversibility: 0.92,
|
|
15
|
+
informationQuality: 0.99,
|
|
16
|
+
interactionCost: 0.01
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const NULL_GIT_CONFIG = process.platform === 'win32' ? 'NUL' : '/dev/null';
|
|
20
|
+
const SAFE_GIT_PREFIX = ['--no-pager', '--no-lazy-fetch', '-c', 'core.fsmonitor=false', '-c', `core.hooksPath=${NULL_GIT_CONFIG}`];
|
|
21
|
+
const PUBLIC_GIT_PREFIX = [...SAFE_GIT_PREFIX, '--literal-pathspecs'];
|
|
22
|
+
const READ_ONLY_GIT_ENV = Object.freeze({
|
|
23
|
+
GIT_CONFIG_GLOBAL: NULL_GIT_CONFIG,
|
|
24
|
+
GIT_CONFIG_SYSTEM: NULL_GIT_CONFIG,
|
|
25
|
+
GIT_CONFIG_NOSYSTEM: '1',
|
|
26
|
+
GIT_ATTR_NOSYSTEM: '1',
|
|
27
|
+
GIT_OPTIONAL_LOCKS: '0',
|
|
28
|
+
GIT_NO_LAZY_FETCH: '1'
|
|
29
|
+
});
|
|
30
|
+
const PATHSPEC_META = /[*?\[\]{}]/;
|
|
31
|
+
|
|
32
|
+
export class GitProvider {
|
|
33
|
+
name = 'git.native';
|
|
34
|
+
#process ;
|
|
35
|
+
|
|
36
|
+
constructor(options ) {
|
|
37
|
+
this.#process = new ProcessProvider({
|
|
38
|
+
allowedRoots: options.allowedRoots,
|
|
39
|
+
allowedExecutables: ['git'],
|
|
40
|
+
environmentOverrides: READ_ONLY_GIT_ENV
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
supports(action ) {
|
|
45
|
+
return ['git.status', 'git.diff', 'git.rev-parse'].includes(action.capability);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
advertises(action ) {
|
|
49
|
+
if (!this.supports(action)) return false;
|
|
50
|
+
try { resolveSupportedGitExecutable(process.env); return true; } catch { return false; }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
score() { return SCORE; }
|
|
54
|
+
|
|
55
|
+
async execute(action , context = {}) {
|
|
56
|
+
try { resolveSupportedGitExecutable(process.env); } catch (error) {
|
|
57
|
+
const op = error instanceof OperatorError
|
|
58
|
+
? error
|
|
59
|
+
: new OperatorError('GIT_VERSION_CHECK_FAILED', error instanceof Error ? error.message : String(error));
|
|
60
|
+
return gitFailure(action, op.code, op.message);
|
|
61
|
+
}
|
|
62
|
+
const cwd = String(action.input.cwd ?? '');
|
|
63
|
+
const paths = Array.isArray(action.input.paths) ? action.input.paths.map(String) : [];
|
|
64
|
+
const publicLiteral = action.capability === 'git.diff' && action.input.publicLiteralFiles === true;
|
|
65
|
+
|
|
66
|
+
if (action.capability !== 'git.rev-parse') {
|
|
67
|
+
const filterFailure = await this.#rejectContentFilters(action, cwd, context);
|
|
68
|
+
if (filterFailure) return filterFailure;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (publicLiteral) {
|
|
72
|
+
const validation = await validatePublicLiteralPaths(cwd, paths);
|
|
73
|
+
if (validation) return gitFailure(action, validation.code, validation.message);
|
|
74
|
+
|
|
75
|
+
const tracked = await this.#run(action, cwd, [
|
|
76
|
+
...PUBLIC_GIT_PREFIX, 'ls-files', '-z', '--', ...paths
|
|
77
|
+
], context);
|
|
78
|
+
if (!tracked.ok) return tracked;
|
|
79
|
+
const trackedNames = splitGitNul((tracked.output )?.stdout);
|
|
80
|
+
const requested = new Set(paths.map(normalizeGitPath));
|
|
81
|
+
if (trackedNames.some((name) => !requested.has(name))) {
|
|
82
|
+
return gitFailure(action, 'GIT_PUBLIC_PATH_FILTER_INVALID', 'Public Git diff accepts explicit literal files only.');
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const names = await this.#run(action, cwd, [
|
|
86
|
+
...PUBLIC_GIT_PREFIX, 'diff', '--name-only', '-z', '--no-ext-diff', '--no-textconv', '--ignore-submodules=all', '--', ...paths
|
|
87
|
+
], context);
|
|
88
|
+
if (!names.ok) return names;
|
|
89
|
+
const changed = splitGitNul((names.output )?.stdout);
|
|
90
|
+
if (changed.some((name) => !requested.has(name))) {
|
|
91
|
+
return gitFailure(action, 'GIT_PUBLIC_PATH_FILTER_INVALID', 'Git selected a file outside the requested literal set.');
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const prefix = publicLiteral ? PUBLIC_GIT_PREFIX : SAFE_GIT_PREFIX;
|
|
96
|
+
const args = action.capability === 'git.status'
|
|
97
|
+
? [...SAFE_GIT_PREFIX, 'status', '--porcelain=v1', '-z', '--branch', '--ignore-submodules=all']
|
|
98
|
+
: action.capability === 'git.diff'
|
|
99
|
+
? [...prefix, 'diff', '--no-ext-diff', '--no-textconv', '--ignore-submodules=all', '--', ...paths]
|
|
100
|
+
: [...SAFE_GIT_PREFIX, 'rev-parse', '--show-toplevel'];
|
|
101
|
+
const result = await this.#run(action, cwd, args, context);
|
|
102
|
+
return action.capability === 'git.status' && result.ok ? structuredGitStatus(result) : result;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async #rejectContentFilters(action , cwd , context ) {
|
|
106
|
+
const inspected = await this.#process.execute({
|
|
107
|
+
...action,
|
|
108
|
+
capability: 'terminal.execute',
|
|
109
|
+
input: {
|
|
110
|
+
executable: 'git',
|
|
111
|
+
args: [...SAFE_GIT_PREFIX, 'config', '--name-only', '--get-regexp', '^filter\\..*\\.(clean|smudge|process)$'],
|
|
112
|
+
cwd,
|
|
113
|
+
timeoutMs: 30_000
|
|
114
|
+
}
|
|
115
|
+
}, context);
|
|
116
|
+
const output = inspected.output ;
|
|
117
|
+
if (!inspected.ok && output?.exitCode !== 1) {
|
|
118
|
+
return gitFailure(action, 'GIT_CONFIG_INSPECTION_FAILED', String(output?.stderr ?? inspected.error?.message ?? 'Unable to inspect Git content-filter configuration.').trim());
|
|
119
|
+
}
|
|
120
|
+
const keys = String(output?.stdout ?? '').split(/\r?\n/).map((item) => item.trim()).filter(Boolean);
|
|
121
|
+
if (keys.length > 0) {
|
|
122
|
+
return gitFailure(action, 'GIT_CONTENT_FILTER_DENIED', 'Git status/diff deny repository content filters because they can execute arbitrary commands.');
|
|
123
|
+
}
|
|
124
|
+
return undefined;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async #run(action , cwd , args , context ) {
|
|
128
|
+
const result = await this.#process.execute({ ...action, capability: 'terminal.execute', input: { executable: 'git', args, cwd, timeoutMs: 30_000 } }, context);
|
|
129
|
+
return { ...result, capability: action.capability, provider: this.name };
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function structuredGitStatus(result ) {
|
|
134
|
+
const raw = result.output ;
|
|
135
|
+
const records = String(raw?.stdout ?? '').split('\0').filter(Boolean);
|
|
136
|
+
let branch = null;
|
|
137
|
+
let detached = false;
|
|
138
|
+
if (records[0]?.startsWith('## ')) {
|
|
139
|
+
const parsed = safeStatusBranch(records.shift() .slice(3));
|
|
140
|
+
branch = parsed.branch;
|
|
141
|
+
detached = parsed.detached;
|
|
142
|
+
}
|
|
143
|
+
const entries = [];
|
|
144
|
+
for (let index = 0; index < records.length; index += 1) {
|
|
145
|
+
const record = records[index] ;
|
|
146
|
+
if (record.length < 4 || record[2] !== ' ') { entries.push({ kind: 'unknown', restricted: true }); continue; }
|
|
147
|
+
const status = record.slice(0, 2);
|
|
148
|
+
const pathValue = safeStatusPath(record.slice(3));
|
|
149
|
+
const entry = {
|
|
150
|
+
indexStatus: status[0],
|
|
151
|
+
worktreeStatus: status[1],
|
|
152
|
+
kind: status === '??' ? 'untracked' : status.includes('R') ? 'renamed' : status.includes('C') ? 'copied' : status.includes('U') ? 'unmerged' : 'tracked'
|
|
153
|
+
};
|
|
154
|
+
if (pathValue) entry.path = pathValue; else entry.restricted = true;
|
|
155
|
+
if ((status.includes('R') || status.includes('C')) && index + 1 < records.length) {
|
|
156
|
+
const original = safeStatusPath(records[++index] );
|
|
157
|
+
if (original) entry.originalPath = original; else entry.originalRestricted = true;
|
|
158
|
+
}
|
|
159
|
+
entries.push(entry);
|
|
160
|
+
}
|
|
161
|
+
return { ...result, output: { branch, detached, clean: entries.length === 0, entries, truncated: raw?.truncated === true } };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async function validatePublicLiteralPaths(
|
|
165
|
+
cwd ,
|
|
166
|
+
paths
|
|
167
|
+
) {
|
|
168
|
+
if (paths.length === 0) {
|
|
169
|
+
return { code: 'GIT_PUBLIC_PATH_FILTER_REQUIRED', message: 'Public Git diff requires explicit literal file paths.' };
|
|
170
|
+
}
|
|
171
|
+
for (const raw of paths) {
|
|
172
|
+
const normalized = normalizeGitPath(raw);
|
|
173
|
+
const segments = normalized.split('/');
|
|
174
|
+
if (!raw || raw.trim() !== raw || normalized === '' || normalized === '.' || normalized === '..') {
|
|
175
|
+
return invalidPublicPath();
|
|
176
|
+
}
|
|
177
|
+
if (path.isAbsolute(raw) || /^[A-Za-z]:[\\/]/.test(raw) || /^\\\\/.test(raw)) {
|
|
178
|
+
return invalidPublicPath();
|
|
179
|
+
}
|
|
180
|
+
if (raw.startsWith(':') || raw.startsWith('!') || PATHSPEC_META.test(raw)) {
|
|
181
|
+
return invalidPublicPath();
|
|
182
|
+
}
|
|
183
|
+
if (segments.includes('..') || segments.includes('.')) return invalidPublicPath();
|
|
184
|
+
try {
|
|
185
|
+
assertPublicSafePath(normalized);
|
|
186
|
+
} catch {
|
|
187
|
+
return { code: 'RESTRICTED_DATA_PATH_DENIED', message: 'The public plugin cannot access credential or secret-bearing paths.' };
|
|
188
|
+
}
|
|
189
|
+
const resolved = path.resolve(cwd, ...segments);
|
|
190
|
+
const relative = path.relative(path.resolve(cwd), resolved);
|
|
191
|
+
if (relative.startsWith('..') || path.isAbsolute(relative)) return invalidPublicPath();
|
|
192
|
+
try {
|
|
193
|
+
const stat = await fs.lstat(resolved);
|
|
194
|
+
if (stat.isDirectory()) return invalidPublicPath();
|
|
195
|
+
} catch (error) {
|
|
196
|
+
const code = (error ).code;
|
|
197
|
+
if (code !== 'ENOENT') return invalidPublicPath();
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return undefined;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function invalidPublicPath() {
|
|
204
|
+
return {
|
|
205
|
+
code: 'GIT_PUBLIC_PATH_FILTER_INVALID',
|
|
206
|
+
message: 'Public Git diff accepts explicit project-relative literal file paths only.'
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function splitGitNul(value ) {
|
|
211
|
+
return String(value ?? '').split('\0').filter(Boolean).map(normalizeGitPath);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function normalizeGitPath(value ) {
|
|
215
|
+
return value.replace(/\\/g, '/').replace(/^\.\//, '');
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function gitFailure(action , code , message ) {
|
|
219
|
+
return {
|
|
220
|
+
ok: false,
|
|
221
|
+
capability: action.capability,
|
|
222
|
+
provider: 'git.native',
|
|
223
|
+
evidence: [],
|
|
224
|
+
error: { code, message, retryable: false },
|
|
225
|
+
durationMs: 0
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
function safeStatusBranch(raw ) {
|
|
231
|
+
let value = raw.trim();
|
|
232
|
+
if (value === 'HEAD (no branch)' || value.startsWith('HEAD detached ')) {
|
|
233
|
+
return { branch: null, detached: true };
|
|
234
|
+
}
|
|
235
|
+
value = value.replace(/^No commits yet on /, '').replace(/^Initial commit on /, '');
|
|
236
|
+
const tracking = value.indexOf('...');
|
|
237
|
+
if (tracking >= 0) value = value.slice(0, tracking);
|
|
238
|
+
const bracket = value.indexOf(' [');
|
|
239
|
+
if (bracket >= 0) value = value.slice(0, bracket);
|
|
240
|
+
const valid = /^[A-Za-z0-9][A-Za-z0-9._/-]{0,255}$/.test(value) && !containsRestrictedData(value);
|
|
241
|
+
return { branch: valid ? value : '[hidden]', detached: false };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
function safeStatusPath(raw ) {
|
|
246
|
+
const normalized = normalizeGitPath(raw);
|
|
247
|
+
const segments = normalized.split('/');
|
|
248
|
+
if (!normalized || normalized.length > 4096) return undefined;
|
|
249
|
+
if (path.isAbsolute(normalized) || segments.includes('.') || segments.includes('..')) return undefined;
|
|
250
|
+
try { assertPublicSafePath(normalized); } catch { return undefined; }
|
|
251
|
+
return containsRestrictedData(normalized) ? undefined : normalized;
|
|
252
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export * from './path-scope.js';
|
|
2
|
+
export * from './filesystem.js';
|
|
3
|
+
export * from './process.js';
|
|
4
|
+
export * from './git.js';
|
|
5
|
+
export * from './git-checkpoint.js';
|
|
6
|
+
export * from './git-write.js';
|
|
7
|
+
export * from './system.js';
|
|
8
|
+
export * from './project.js';
|
|
9
|
+
export * from './project-command.js';
|
|
10
|
+
export * from './project-transaction.js';
|
|
11
|
+
export * from './docker.js';
|
|
12
|
+
export * from './postgres.js';
|
|
13
|
+
export * from './vscode.js';
|
|
14
|
+
export * from './browser-cdp.js';
|
|
15
|
+
export * from './browser-managed.js';
|
|
16
|
+
export * from './windows-uia.js';
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { OperatorError } from '../core/errors.js';
|
|
4
|
+
import { normalizeScopedPathSyntax } from '../core/scoped-path-syntax.js';
|
|
5
|
+
import { withWindowsPathLease } from '../core/windows-path-lease.js';
|
|
6
|
+
|
|
7
|
+
function lexicalInside(candidate , root ) {
|
|
8
|
+
const rel = path.relative(root, candidate);
|
|
9
|
+
return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export class PathScope {
|
|
13
|
+
roots ;
|
|
14
|
+
#windowsPathLeaseExecutable ;
|
|
15
|
+
|
|
16
|
+
constructor(roots , options = {}) {
|
|
17
|
+
if (roots.length === 0) throw new OperatorError('NO_ALLOWED_ROOTS', 'At least one allowed root is required.');
|
|
18
|
+
this.roots = roots.map((root) => path.resolve(root));
|
|
19
|
+
this.#windowsPathLeaseExecutable = options.windowsPathLeaseExecutable;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async resolveExisting(inputPath ) {
|
|
23
|
+
const absolute = this.#absolute(inputPath);
|
|
24
|
+
const real = await fs.realpath(absolute);
|
|
25
|
+
const realRoots = await Promise.all(this.roots.map(async (root) => {
|
|
26
|
+
try { return await fs.realpath(root); } catch { return root; }
|
|
27
|
+
}));
|
|
28
|
+
if (!realRoots.some((root) => lexicalInside(real, root))) {
|
|
29
|
+
throw new OperatorError('PATH_OUTSIDE_SCOPE', 'Resolved path escapes the authorized roots.', { details: { inputPath, real } });
|
|
30
|
+
}
|
|
31
|
+
return real;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async resolveForWrite(inputPath ) {
|
|
35
|
+
const absolute = this.#absolute(inputPath);
|
|
36
|
+
const parent = await fs.realpath(path.dirname(absolute));
|
|
37
|
+
const realRoots = await Promise.all(this.roots.map(async (root) => {
|
|
38
|
+
try { return await fs.realpath(root); } catch { return root; }
|
|
39
|
+
}));
|
|
40
|
+
if (!realRoots.some((root) => lexicalInside(parent, root))) {
|
|
41
|
+
throw new OperatorError('PATH_OUTSIDE_SCOPE', 'Write target escapes the authorized roots.', { details: { inputPath, parent } });
|
|
42
|
+
}
|
|
43
|
+
return path.join(parent, path.basename(absolute));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async withExisting (inputPath , operation ) {
|
|
47
|
+
const absolute = this.#absolute(inputPath);
|
|
48
|
+
const root = this.#lexicalRoot(absolute);
|
|
49
|
+
return await withWindowsPathLease({
|
|
50
|
+
root,
|
|
51
|
+
target: absolute,
|
|
52
|
+
mode: 'existing',
|
|
53
|
+
executable: this.#windowsPathLeaseExecutable
|
|
54
|
+
}, async () => await operation(await this.resolveExisting(absolute)));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async withForWrite (inputPath , operation ) {
|
|
58
|
+
const absolute = this.#absolute(inputPath);
|
|
59
|
+
const root = this.#lexicalRoot(absolute);
|
|
60
|
+
return await withWindowsPathLease({
|
|
61
|
+
root,
|
|
62
|
+
target: absolute,
|
|
63
|
+
mode: 'parent',
|
|
64
|
+
executable: this.#windowsPathLeaseExecutable
|
|
65
|
+
}, async () => await operation(await this.resolveForWrite(absolute)));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
#absolute(inputPath ) {
|
|
69
|
+
const syntax = normalizeScopedPathSyntax(inputPath);
|
|
70
|
+
if (syntax.kind === 'native-absolute') return syntax.value;
|
|
71
|
+
if (syntax.kind === 'foreign-windows-absolute') {
|
|
72
|
+
throw new OperatorError('PATH_OUTSIDE_SCOPE', 'Foreign absolute paths are outside the authorized roots.');
|
|
73
|
+
}
|
|
74
|
+
if (this.roots.length !== 1) {
|
|
75
|
+
throw new OperatorError('PATH_OUTSIDE_SCOPE', 'Relative paths require exactly one authorized root.');
|
|
76
|
+
}
|
|
77
|
+
return path.resolve(this.roots[0] , syntax.value);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
#lexicalRoot(absolute ) {
|
|
81
|
+
const candidates = this.roots.filter((root) => lexicalInside(absolute, root));
|
|
82
|
+
candidates.sort((a, b) => b.length - a.length);
|
|
83
|
+
if (!candidates[0]) throw new OperatorError('PATH_OUTSIDE_SCOPE', 'Requested path escapes the authorized roots.');
|
|
84
|
+
return candidates[0];
|
|
85
|
+
}
|
|
86
|
+
}
|