orizu 0.5.16 → 0.5.18
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 +15 -0
- package/dist/archive-artifacts-cli.js +213 -0
- package/dist/artifact-pull.js +1 -3
- package/dist/dataset-edit-rows.js +90 -0
- package/dist/dataset-upload-snapshot.js +22 -0
- package/dist/diff-comments-cli.js +175 -0
- package/dist/egress-policy.js +12 -0
- package/dist/error-response.js +21 -15
- package/dist/git-credential.js +316 -10
- package/dist/git-recovery-environment.js +156 -0
- package/dist/github-setup.js +3 -11
- package/dist/help.js +134 -5
- package/dist/hosted-boot.js +36 -1
- package/dist/hosted-bootstrap-rehearsal.js +8 -0
- package/dist/hosted-broker-server.js +141 -129
- package/dist/hosted-git-http-server.js +70 -71
- package/dist/hosted-runtime-assets.js +136 -10
- package/dist/hosted-session-cli.js +435 -61
- package/dist/index.js +176 -213
- package/dist/json-response.js +21 -0
- package/dist/loopback-serve.js +38 -0
- package/dist/markdown-report-input.js +67 -0
- package/dist/optimization-export-cli.js +30 -0
- package/dist/optimizations-list-cli.js +18 -8
- package/dist/prompt-draft-push.js +7 -5
- package/dist/prompt-report-cli.js +86 -0
- package/dist/repository-backup-core.js +1518 -0
- package/dist/repository-recovery-manifest.js +326 -0
- package/dist/scorer-draft-push.js +1 -22
- package/dist/scores-accept-cli.js +1 -3
- package/dist/start-intent.js +25 -0
- package/dist/workbench-cli.js +164 -25
- package/dist/workspace-sync.js +5 -0
- package/dist/zip-draft-push.js +1 -24
- package/package.json +1 -1
- package/src/archive-artifacts-cli.ts +376 -0
- package/src/artifact-pull.ts +1 -4
- package/src/dataset-edit-rows.ts +124 -0
- package/src/dataset-upload-snapshot.ts +41 -0
- package/src/diff-comments-cli.ts +299 -0
- package/src/egress-policy.ts +24 -1
- package/src/error-response.ts +29 -16
- package/src/git-credential.ts +424 -9
- package/src/git-recovery-environment.ts +212 -0
- package/src/github-setup.ts +3 -12
- package/src/help.ts +134 -5
- package/src/hosted-boot.ts +47 -1
- package/src/hosted-bootstrap-rehearsal.ts +7 -0
- package/src/hosted-broker-server.ts +152 -134
- package/src/hosted-git-http-server.ts +64 -65
- package/src/hosted-runtime-assets.ts +141 -10
- package/src/hosted-session-cli.ts +553 -25
- package/src/index.ts +229 -276
- package/src/json-response.ts +37 -0
- package/src/loopback-serve.ts +55 -0
- package/src/markdown-report-input.ts +80 -0
- package/src/optimization-export-cli.ts +72 -0
- package/src/optimizations-list-cli.ts +26 -9
- package/src/prompt-draft-push.ts +8 -6
- package/src/prompt-report-cli.ts +135 -0
- package/src/repository-backup-core.ts +2537 -0
- package/src/repository-recovery-manifest.ts +521 -0
- package/src/scorer-draft-push.ts +1 -6
- package/src/scores-accept-cli.ts +1 -4
- package/src/start-intent.ts +41 -0
- package/src/workbench-cli.ts +219 -21
- package/src/workspace-sync.ts +4 -0
- package/src/zip-draft-push.ts +1 -6
- package/vendor/orizu-gepa-python/src/orizu_gepa/cli.py +1 -1
- package/vendor/orizu-gepa-python/src/orizu_gepa/client.py +4 -1
- package/vendor/skills/orizu-cli/SKILL.md +1 -0
- package/vendor/skills/orizu-cli/references/cli-reference.md +43 -0
package/dist/git-credential.js
CHANGED
|
@@ -22,6 +22,80 @@ import { dirname, join } from 'path';
|
|
|
22
22
|
import { authedFetch } from './http.js';
|
|
23
23
|
import { readJsonManifest } from './workspace.js';
|
|
24
24
|
const GITHUB_HOST = 'github.com';
|
|
25
|
+
const ARTIFACTS_HOST = /^[0-9a-f]{32}\.artifacts\.cloudflare\.net$/;
|
|
26
|
+
/**
|
|
27
|
+
* ALI-1285: the Artifacts contract declares the credential OPAQUE
|
|
28
|
+
* (`plaintext: string`) with no documented body format. Pinning
|
|
29
|
+
* `art_v1_<40 hex>` would reject a contract-conformant credential that the
|
|
30
|
+
* broker and the runtime contract both accept. Only what the git Basic-auth
|
|
31
|
+
* transport requires is enforced: bounded, no whitespace, no control
|
|
32
|
+
* characters, and at least 16 characters (a security floor, not a format
|
|
33
|
+
* claim). Mirrors `workers/artifacts-runtime/contracts.ts` GIT_PASSWORD.
|
|
34
|
+
*/
|
|
35
|
+
const ARTIFACTS_PASSWORD = /^[^\s\u0000-\u001f\u007f]{16,1024}$/;
|
|
36
|
+
const ARTIFACTS_MAX_EXPIRY_SKEW_MS = 305_000;
|
|
37
|
+
const SAFE_REMOTE_COMPONENT = /^[^\u0000-\u0020\u007f\\?#]+$/;
|
|
38
|
+
export const REPO_TOKEN_PURPOSES = [
|
|
39
|
+
'read',
|
|
40
|
+
'write',
|
|
41
|
+
];
|
|
42
|
+
function isRepoTokenPurpose(value) {
|
|
43
|
+
return REPO_TOKEN_PURPOSES.includes(value);
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Git appends get/store/erase after the configured helper command, so an
|
|
47
|
+
* explicit path-aware configuration looks like:
|
|
48
|
+
* `!orizu git-credential --purpose=write`.
|
|
49
|
+
*
|
|
50
|
+
* Session credentials use the hosted runtime helper, which carries the
|
|
51
|
+
* server-bound session ID. This local helper deliberately accepts only
|
|
52
|
+
* sessionless developer read/write purposes.
|
|
53
|
+
*/
|
|
54
|
+
export function parseGitCredentialInvocation(args) {
|
|
55
|
+
let operation = null;
|
|
56
|
+
let purpose = null;
|
|
57
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
58
|
+
const arg = args[index];
|
|
59
|
+
if (arg === '--purpose') {
|
|
60
|
+
if (purpose !== null)
|
|
61
|
+
return null;
|
|
62
|
+
const value = args[index + 1];
|
|
63
|
+
if (!value || !isRepoTokenPurpose(value))
|
|
64
|
+
return null;
|
|
65
|
+
purpose = value;
|
|
66
|
+
index += 1;
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
if (arg.startsWith('--purpose=')) {
|
|
70
|
+
if (purpose !== null)
|
|
71
|
+
return null;
|
|
72
|
+
const value = arg.slice('--purpose='.length);
|
|
73
|
+
if (!isRepoTokenPurpose(value))
|
|
74
|
+
return null;
|
|
75
|
+
purpose = value;
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
if (arg === 'get' || arg === 'store' || arg === 'erase') {
|
|
79
|
+
if (operation !== null)
|
|
80
|
+
return null;
|
|
81
|
+
operation = arg;
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
return operation === null ? null : { operation, purpose };
|
|
87
|
+
}
|
|
88
|
+
export async function runGitCredentialInvocation(args, io) {
|
|
89
|
+
const invocation = parseGitCredentialInvocation(args);
|
|
90
|
+
if (!invocation) {
|
|
91
|
+
io.printErr('Invalid git-credential invocation');
|
|
92
|
+
return 1;
|
|
93
|
+
}
|
|
94
|
+
return runGitCredential(invocation.operation, {
|
|
95
|
+
...io,
|
|
96
|
+
purpose: invocation.purpose ?? undefined,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
25
99
|
export function parseCredentialInput(stdin) {
|
|
26
100
|
const map = {};
|
|
27
101
|
for (const rawLine of stdin.split('\n')) {
|
|
@@ -80,17 +154,47 @@ export function resolveWorkspaceId(io) {
|
|
|
80
154
|
return fromEnv;
|
|
81
155
|
return resolveAttachedWorkspaceId(io.cwd);
|
|
82
156
|
}
|
|
83
|
-
async function mintToken(fetcher, workspaceId, purpose) {
|
|
157
|
+
async function mintToken(fetcher, workspaceId, purpose, expectedRepoFullName) {
|
|
84
158
|
const response = await fetcher(`/api/cli/workspaces/${encodeURIComponent(workspaceId)}/repo-token`, {
|
|
85
159
|
method: 'POST',
|
|
86
160
|
headers: { 'Content-Type': 'application/json' },
|
|
87
161
|
body: JSON.stringify({ purpose }),
|
|
88
162
|
});
|
|
89
163
|
if (response.ok) {
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
164
|
+
let data;
|
|
165
|
+
try {
|
|
166
|
+
data = await response.json();
|
|
167
|
+
}
|
|
168
|
+
catch {
|
|
169
|
+
return {
|
|
170
|
+
ok: false,
|
|
171
|
+
status: response.status,
|
|
172
|
+
error: 'Broker returned invalid JSON',
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
if (typeof data !== 'object' || data === null) {
|
|
176
|
+
return {
|
|
177
|
+
ok: false,
|
|
178
|
+
status: response.status,
|
|
179
|
+
error: 'Broker returned an invalid GitHub credential',
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
const credential = data;
|
|
183
|
+
if (!(credential.provider === undefined ||
|
|
184
|
+
credential.provider === 'github') ||
|
|
185
|
+
credential.remote !== undefined ||
|
|
186
|
+
credential.repo !== expectedRepoFullName ||
|
|
187
|
+
typeof credential.token !== 'string' ||
|
|
188
|
+
credential.token.length === 0 ||
|
|
189
|
+
credential.token.length > 512 ||
|
|
190
|
+
/[\u0000-\u0020\u007f]/u.test(credential.token)) {
|
|
191
|
+
return {
|
|
192
|
+
ok: false,
|
|
193
|
+
status: response.status,
|
|
194
|
+
error: 'Broker returned a credential for a different repository provider or path',
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
return { ok: true, token: credential.token };
|
|
94
198
|
}
|
|
95
199
|
let error = `status ${response.status}`;
|
|
96
200
|
try {
|
|
@@ -103,24 +207,226 @@ async function mintToken(fetcher, workspaceId, purpose) {
|
|
|
103
207
|
}
|
|
104
208
|
return { ok: false, status: response.status, error };
|
|
105
209
|
}
|
|
210
|
+
function isArtifactsHost(host) {
|
|
211
|
+
if (!host)
|
|
212
|
+
return false;
|
|
213
|
+
const normalized = host.toLowerCase();
|
|
214
|
+
return ARTIFACTS_HOST.test(normalized);
|
|
215
|
+
}
|
|
216
|
+
function exactGithubRepoFullName(input) {
|
|
217
|
+
const protocol = input.protocol?.toLowerCase();
|
|
218
|
+
const host = input.host?.toLowerCase();
|
|
219
|
+
const rawPath = input.path;
|
|
220
|
+
if (protocol !== 'https' ||
|
|
221
|
+
host !== GITHUB_HOST ||
|
|
222
|
+
!rawPath ||
|
|
223
|
+
!SAFE_REMOTE_COMPONENT.test(rawPath)) {
|
|
224
|
+
return null;
|
|
225
|
+
}
|
|
226
|
+
const path = rawPath.startsWith('/') ? rawPath : `/${rawPath}`;
|
|
227
|
+
try {
|
|
228
|
+
const url = new URL(`https://${GITHUB_HOST}${path}`);
|
|
229
|
+
if (url.origin !== `https://${GITHUB_HOST}` ||
|
|
230
|
+
url.username ||
|
|
231
|
+
url.password ||
|
|
232
|
+
url.port ||
|
|
233
|
+
url.search ||
|
|
234
|
+
url.hash ||
|
|
235
|
+
url.pathname !== path) {
|
|
236
|
+
return null;
|
|
237
|
+
}
|
|
238
|
+
const segments = url.pathname
|
|
239
|
+
.slice(1)
|
|
240
|
+
.split('/')
|
|
241
|
+
.filter(Boolean);
|
|
242
|
+
if (segments.length !== 2)
|
|
243
|
+
return null;
|
|
244
|
+
const [owner, rawRepository] = segments;
|
|
245
|
+
const repository = rawRepository.endsWith('.git')
|
|
246
|
+
? rawRepository.slice(0, -4)
|
|
247
|
+
: rawRepository;
|
|
248
|
+
if (!/^[A-Za-z0-9](?:[A-Za-z0-9_.-]{0,99})$/.test(owner) ||
|
|
249
|
+
!/^[A-Za-z0-9_.-]{1,100}$/.test(repository)) {
|
|
250
|
+
return null;
|
|
251
|
+
}
|
|
252
|
+
return `${owner}/${repository}`;
|
|
253
|
+
}
|
|
254
|
+
catch {
|
|
255
|
+
return null;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
function exactArtifactsCredentialKey(input) {
|
|
259
|
+
const protocol = input.protocol?.toLowerCase();
|
|
260
|
+
const host = input.host?.toLowerCase();
|
|
261
|
+
const rawPath = input.path;
|
|
262
|
+
if (protocol !== 'https' ||
|
|
263
|
+
!host ||
|
|
264
|
+
!isArtifactsHost(host) ||
|
|
265
|
+
!rawPath ||
|
|
266
|
+
!SAFE_REMOTE_COMPONENT.test(host) ||
|
|
267
|
+
!SAFE_REMOTE_COMPONENT.test(rawPath)) {
|
|
268
|
+
return null;
|
|
269
|
+
}
|
|
270
|
+
const path = rawPath.startsWith('/') ? rawPath : `/${rawPath}`;
|
|
271
|
+
try {
|
|
272
|
+
const url = new URL(`https://${host}${path}`);
|
|
273
|
+
if (url.protocol !== 'https:' ||
|
|
274
|
+
url.username ||
|
|
275
|
+
url.password ||
|
|
276
|
+
url.port ||
|
|
277
|
+
url.search ||
|
|
278
|
+
url.hash ||
|
|
279
|
+
url.hostname !== host ||
|
|
280
|
+
!ARTIFACTS_HOST.test(url.hostname) ||
|
|
281
|
+
!url.pathname.startsWith('/git/') ||
|
|
282
|
+
!url.pathname.endsWith('.git') ||
|
|
283
|
+
`${url.origin}${url.pathname}` !==
|
|
284
|
+
`https://${host}${path}`) {
|
|
285
|
+
return null;
|
|
286
|
+
}
|
|
287
|
+
return `${url.origin}${url.pathname}`;
|
|
288
|
+
}
|
|
289
|
+
catch {
|
|
290
|
+
return null;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
async function mintArtifactsCredential(fetcher, workspaceId, purpose, expectedCredentialKey, nowMs) {
|
|
294
|
+
const response = await fetcher(`/api/cli/workspaces/${encodeURIComponent(workspaceId)}/repo-token`, {
|
|
295
|
+
method: 'POST',
|
|
296
|
+
headers: { 'Content-Type': 'application/json' },
|
|
297
|
+
body: JSON.stringify({ purpose }),
|
|
298
|
+
});
|
|
299
|
+
if (!response.ok) {
|
|
300
|
+
return {
|
|
301
|
+
ok: false,
|
|
302
|
+
error: `repository credential service returned status ${response.status}`,
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
let data;
|
|
306
|
+
try {
|
|
307
|
+
data = await response.json();
|
|
308
|
+
}
|
|
309
|
+
catch {
|
|
310
|
+
return {
|
|
311
|
+
ok: false,
|
|
312
|
+
error: 'repository credential service returned invalid JSON',
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
if (typeof data !== 'object' || data === null) {
|
|
316
|
+
return {
|
|
317
|
+
ok: false,
|
|
318
|
+
error: 'repository credential service returned an invalid credential',
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
const credential = data;
|
|
322
|
+
const remote = typeof credential.remote === 'string'
|
|
323
|
+
? credential.remote
|
|
324
|
+
: null;
|
|
325
|
+
let resolvedCredentialKey = null;
|
|
326
|
+
if (remote !== null) {
|
|
327
|
+
try {
|
|
328
|
+
const url = new URL(remote);
|
|
329
|
+
if (url.protocol === 'https:' &&
|
|
330
|
+
!url.username &&
|
|
331
|
+
!url.password &&
|
|
332
|
+
!url.port &&
|
|
333
|
+
!url.search &&
|
|
334
|
+
!url.hash &&
|
|
335
|
+
ARTIFACTS_HOST.test(url.hostname) &&
|
|
336
|
+
url.pathname.startsWith('/git/') &&
|
|
337
|
+
url.pathname.endsWith('.git') &&
|
|
338
|
+
url.toString() === remote) {
|
|
339
|
+
resolvedCredentialKey = `${url.origin}${url.pathname}`;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
catch {
|
|
343
|
+
resolvedCredentialKey = null;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
if (resolvedCredentialKey !== expectedCredentialKey) {
|
|
347
|
+
return {
|
|
348
|
+
ok: false,
|
|
349
|
+
error: 'server-resolved repository does not match Git exact path',
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
if (credential.provider !== 'cloudflare_artifacts' ||
|
|
353
|
+
credential.username !== 'x' ||
|
|
354
|
+
typeof credential.token !== 'string' ||
|
|
355
|
+
!ARTIFACTS_PASSWORD.test(credential.token)) {
|
|
356
|
+
return {
|
|
357
|
+
ok: false,
|
|
358
|
+
error: 'repository credential service returned an invalid credential',
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
const expiresAt = typeof credential.expiresAt === 'string'
|
|
362
|
+
? credential.expiresAt
|
|
363
|
+
: '';
|
|
364
|
+
const expiresAtMs = Date.parse(expiresAt);
|
|
365
|
+
if (!Number.isSafeInteger(nowMs) ||
|
|
366
|
+
!Number.isFinite(expiresAtMs) ||
|
|
367
|
+
new Date(expiresAtMs).toISOString() !== expiresAt ||
|
|
368
|
+
expiresAtMs <= nowMs ||
|
|
369
|
+
expiresAtMs > nowMs + ARTIFACTS_MAX_EXPIRY_SKEW_MS) {
|
|
370
|
+
return {
|
|
371
|
+
ok: false,
|
|
372
|
+
error: 'repository credential service returned an invalid credential expiry',
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
return {
|
|
376
|
+
ok: true,
|
|
377
|
+
username: 'x',
|
|
378
|
+
password: credential.token,
|
|
379
|
+
};
|
|
380
|
+
}
|
|
106
381
|
export async function runGitCredential(op, io) {
|
|
107
382
|
// store/erase are no-ops: we never persist a credential to disk.
|
|
108
383
|
if (op !== 'get')
|
|
109
384
|
return 0;
|
|
110
385
|
const input = parseCredentialInput(io.stdin);
|
|
111
|
-
|
|
112
|
-
|
|
386
|
+
const inputHost = input.host?.toLowerCase();
|
|
387
|
+
// Only broker for the legacy GitHub host or the exact Artifacts service.
|
|
388
|
+
// Anything else is git's own business.
|
|
389
|
+
if (inputHost &&
|
|
390
|
+
inputHost !== GITHUB_HOST &&
|
|
391
|
+
!isArtifactsHost(inputHost)) {
|
|
113
392
|
return 0;
|
|
393
|
+
}
|
|
114
394
|
const workspaceId = resolveWorkspaceId(io);
|
|
115
395
|
if (!workspaceId) {
|
|
116
396
|
// Unattached directory: stay silent so git falls through cleanly.
|
|
117
397
|
return 0;
|
|
118
398
|
}
|
|
119
399
|
const fetcher = io.fetcher ?? authedFetch;
|
|
400
|
+
if (isArtifactsHost(inputHost)) {
|
|
401
|
+
if (!io.purpose) {
|
|
402
|
+
io.printErr('orizu git-credential: an Artifacts remote requires an explicit repository purpose');
|
|
403
|
+
return 1;
|
|
404
|
+
}
|
|
405
|
+
const credentialKey = exactArtifactsCredentialKey(input);
|
|
406
|
+
if (credentialKey === null) {
|
|
407
|
+
io.printErr('orizu git-credential: refusing Artifacts credential selection without an exact repository path');
|
|
408
|
+
return 1;
|
|
409
|
+
}
|
|
410
|
+
const minted = await mintArtifactsCredential(fetcher, workspaceId, io.purpose, credentialKey, (io.nowMs ?? Date.now)());
|
|
411
|
+
if (!minted.ok) {
|
|
412
|
+
io.printErr(`orizu git-credential: could not broker an Artifacts credential (${minted.error})`);
|
|
413
|
+
return 1;
|
|
414
|
+
}
|
|
415
|
+
io.print(`username=${minted.username}`);
|
|
416
|
+
io.print(`password=${minted.password}`);
|
|
417
|
+
io.print('');
|
|
418
|
+
return 0;
|
|
419
|
+
}
|
|
120
420
|
// Mint write first (curators/pushers), fall back to read on 403 (members).
|
|
121
|
-
|
|
122
|
-
if (!
|
|
123
|
-
|
|
421
|
+
const expectedRepoFullName = exactGithubRepoFullName(input);
|
|
422
|
+
if (!expectedRepoFullName) {
|
|
423
|
+
io.printErr('orizu git-credential: refusing GitHub credential selection without an exact repository path');
|
|
424
|
+
return 1;
|
|
425
|
+
}
|
|
426
|
+
const primaryPurpose = io.purpose ?? 'write';
|
|
427
|
+
let minted = await mintToken(fetcher, workspaceId, primaryPurpose, expectedRepoFullName);
|
|
428
|
+
if (!io.purpose && !minted.ok && minted.status === 403) {
|
|
429
|
+
minted = await mintToken(fetcher, workspaceId, 'read', expectedRepoFullName);
|
|
124
430
|
}
|
|
125
431
|
if (!minted.ok) {
|
|
126
432
|
io.printErr(`orizu git-credential: could not broker a token for this workspace (${minted.error}). ` +
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { isAbsolute } from 'node:path';
|
|
2
|
+
function assertAbsoluteDirectoryPath(value, label) {
|
|
3
|
+
if (!isAbsolute(value) ||
|
|
4
|
+
value.includes('\u0000') ||
|
|
5
|
+
/[\r\n]/.test(value)) {
|
|
6
|
+
throw new Error(`${label} must be an absolute path without control characters`);
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
function normalizeProtocols(protocols) {
|
|
10
|
+
const requested = protocols ?? ['https'];
|
|
11
|
+
for (const protocol of requested) {
|
|
12
|
+
if (protocol !== 'https' && protocol !== 'file') {
|
|
13
|
+
throw new Error(`unsupported Git recovery protocol: ${String(protocol)}`);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
const requestedSet = new Set(requested);
|
|
17
|
+
const normalized = ['https', 'file'].filter(protocol => requestedSet.has(protocol));
|
|
18
|
+
if (normalized.length === 0 || !normalized.includes('https')) {
|
|
19
|
+
throw new Error('the hardened Git environment must allow HTTPS transport');
|
|
20
|
+
}
|
|
21
|
+
return normalized;
|
|
22
|
+
}
|
|
23
|
+
function shellQuote(value) {
|
|
24
|
+
return `'${value.replace(/'/g, "'\\''")}'`;
|
|
25
|
+
}
|
|
26
|
+
function credentialHelperConfigValue(helper) {
|
|
27
|
+
if (helper === undefined)
|
|
28
|
+
return '';
|
|
29
|
+
if (!isAbsolute(helper.executablePath)) {
|
|
30
|
+
throw new Error('recovery credential helper requires an absolute executable path');
|
|
31
|
+
}
|
|
32
|
+
const values = [helper.executablePath, ...(helper.arguments ?? [])];
|
|
33
|
+
for (const value of values) {
|
|
34
|
+
if (value.length === 0 ||
|
|
35
|
+
Buffer.byteLength(value, 'utf8') > 4_096 ||
|
|
36
|
+
/[\u0000-\u001f\u007f]/.test(value)) {
|
|
37
|
+
throw new Error('recovery credential helper path and arguments must be non-empty, bounded, and control-free');
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return `!exec ${values.map(shellQuote).join(' ')}`;
|
|
41
|
+
}
|
|
42
|
+
function gitConfig(hooksDir, allowedProtocols, credentialHelper) {
|
|
43
|
+
const fileAllowed = allowedProtocols.includes('file') ? 'always' : 'never';
|
|
44
|
+
return [
|
|
45
|
+
{ key: 'credential.helper', value: credentialHelperConfigValue(credentialHelper) },
|
|
46
|
+
{ key: 'credential.useHttpPath', value: 'true' },
|
|
47
|
+
{ key: 'core.hooksPath', value: hooksDir },
|
|
48
|
+
{ key: 'core.attributesFile', value: '/dev/null' },
|
|
49
|
+
{ key: 'core.fsmonitor', value: 'false' },
|
|
50
|
+
{ key: 'core.sshCommand', value: '/usr/bin/false' },
|
|
51
|
+
{ key: 'protocol.allow', value: 'never' },
|
|
52
|
+
{ key: 'protocol.version', value: '2' },
|
|
53
|
+
{ key: 'protocol.https.allow', value: 'always' },
|
|
54
|
+
{ key: 'protocol.http.allow', value: 'never' },
|
|
55
|
+
{ key: 'protocol.file.allow', value: fileAllowed },
|
|
56
|
+
{ key: 'protocol.ext.allow', value: 'never' },
|
|
57
|
+
{ key: 'http.followRedirects', value: 'false' },
|
|
58
|
+
{ key: 'http.sslVerify', value: 'true' },
|
|
59
|
+
{ key: 'http.saveCookies', value: 'false' },
|
|
60
|
+
{ key: 'fetch.fsckObjects', value: 'true' },
|
|
61
|
+
{ key: 'transfer.fsckObjects', value: 'true' },
|
|
62
|
+
{ key: 'receive.fsckObjects', value: 'true' },
|
|
63
|
+
// Bound Git's own parallelism and delta caches inside the independently
|
|
64
|
+
// enforced job memory/PID boundary. These only trade throughput and pack
|
|
65
|
+
// compression efficiency; all object-integrity checks remain enabled.
|
|
66
|
+
{ key: 'pack.threads', value: '1' },
|
|
67
|
+
{ key: 'index.threads', value: '1' },
|
|
68
|
+
{ key: 'pack.windowMemory', value: '67108864' },
|
|
69
|
+
{ key: 'pack.deltaCacheSize', value: '67108864' },
|
|
70
|
+
{ key: 'core.deltaBaseCacheLimit', value: '67108864' },
|
|
71
|
+
{ key: 'fetch.recurseSubmodules', value: 'false' },
|
|
72
|
+
{ key: 'submodule.recurse', value: 'false' },
|
|
73
|
+
{ key: 'filter.lfs.process', value: '' },
|
|
74
|
+
{ key: 'filter.lfs.smudge', value: '' },
|
|
75
|
+
{ key: 'filter.lfs.clean', value: '' },
|
|
76
|
+
{ key: 'filter.lfs.required', value: 'false' },
|
|
77
|
+
{ key: 'maintenance.auto', value: 'false' },
|
|
78
|
+
{ key: 'gc.auto', value: '0' },
|
|
79
|
+
];
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Construct, rather than scrub, the environment. Only PATH crosses the
|
|
83
|
+
* boundary from the parent process. Provider tokens, cloud credentials,
|
|
84
|
+
* ambient Git repository variables, HOME config, SSH agents, and askpass
|
|
85
|
+
* helpers therefore cannot accidentally enter the recovery subprocess.
|
|
86
|
+
*/
|
|
87
|
+
export function buildHardenedGitEnvironment(input) {
|
|
88
|
+
assertAbsoluteDirectoryPath(input.homeDir, 'Git recovery HOME');
|
|
89
|
+
assertAbsoluteDirectoryPath(input.hooksDir, 'Git recovery hooks directory');
|
|
90
|
+
if (input.tempDir !== undefined) {
|
|
91
|
+
assertAbsoluteDirectoryPath(input.tempDir, 'Git recovery temp directory');
|
|
92
|
+
}
|
|
93
|
+
const allowedProtocols = normalizeProtocols(input.allowedProtocols);
|
|
94
|
+
const sourceEnv = input.sourceEnv ?? process.env;
|
|
95
|
+
const path = sourceEnv.PATH;
|
|
96
|
+
if (!path || /[\u0000\r\n]/.test(path)) {
|
|
97
|
+
throw new Error('Git recovery requires a safe PATH');
|
|
98
|
+
}
|
|
99
|
+
const config = gitConfig(input.hooksDir, allowedProtocols, input.credentialHelper);
|
|
100
|
+
const env = {
|
|
101
|
+
// Never inherit runtime mode across the recovery boundary. Git ignores
|
|
102
|
+
// NODE_ENV, while an explicitly installed credential helper may not.
|
|
103
|
+
NODE_ENV: 'production',
|
|
104
|
+
PATH: path,
|
|
105
|
+
HOME: input.homeDir,
|
|
106
|
+
XDG_CONFIG_HOME: input.homeDir,
|
|
107
|
+
LANG: 'C',
|
|
108
|
+
LC_ALL: 'C',
|
|
109
|
+
GIT_CONFIG_NOSYSTEM: '1',
|
|
110
|
+
GIT_CONFIG_SYSTEM: '/dev/null',
|
|
111
|
+
GIT_CONFIG_GLOBAL: '/dev/null',
|
|
112
|
+
GIT_TERMINAL_PROMPT: '0',
|
|
113
|
+
GIT_ASKPASS: '/usr/bin/false',
|
|
114
|
+
SSH_ASKPASS: '/usr/bin/false',
|
|
115
|
+
GCM_INTERACTIVE: 'Never',
|
|
116
|
+
GIT_PROTOCOL_FROM_USER: '0',
|
|
117
|
+
GIT_NO_REPLACE_OBJECTS: '1',
|
|
118
|
+
GIT_LFS_SKIP_SMUDGE: '1',
|
|
119
|
+
GIT_OPTIONAL_LOCKS: '0',
|
|
120
|
+
GIT_ALLOW_PROTOCOL: allowedProtocols.join(':'),
|
|
121
|
+
GIT_CONFIG_COUNT: String(config.length),
|
|
122
|
+
...(input.tempDir === undefined ? {} : { TMPDIR: input.tempDir }),
|
|
123
|
+
};
|
|
124
|
+
config.forEach(({ key, value }, index) => {
|
|
125
|
+
env[`GIT_CONFIG_KEY_${index}`] = key;
|
|
126
|
+
env[`GIT_CONFIG_VALUE_${index}`] = value;
|
|
127
|
+
});
|
|
128
|
+
return env;
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Test/diagnostic helper for the non-secret `GIT_CONFIG_COUNT` contract.
|
|
132
|
+
* Rejecting holes also prevents a caller from constructing an ambiguous config.
|
|
133
|
+
*/
|
|
134
|
+
export function parseConfiguredGitEnvironment(env) {
|
|
135
|
+
const countText = env.GIT_CONFIG_COUNT;
|
|
136
|
+
if (!countText || !/^(0|[1-9][0-9]*)$/.test(countText)) {
|
|
137
|
+
throw new Error('invalid GIT_CONFIG_COUNT');
|
|
138
|
+
}
|
|
139
|
+
const count = Number(countText);
|
|
140
|
+
if (!Number.isSafeInteger(count) || count > 128) {
|
|
141
|
+
throw new Error('GIT_CONFIG_COUNT exceeds the recovery configuration budget');
|
|
142
|
+
}
|
|
143
|
+
const parsed = new Map();
|
|
144
|
+
for (let index = 0; index < count; index += 1) {
|
|
145
|
+
const key = env[`GIT_CONFIG_KEY_${index}`];
|
|
146
|
+
const value = env[`GIT_CONFIG_VALUE_${index}`];
|
|
147
|
+
if (key === undefined || value === undefined) {
|
|
148
|
+
throw new Error(`incomplete hardened Git config at index ${index}`);
|
|
149
|
+
}
|
|
150
|
+
if (parsed.has(key)) {
|
|
151
|
+
throw new Error(`duplicate hardened Git config key: ${key}`);
|
|
152
|
+
}
|
|
153
|
+
parsed.set(key, value);
|
|
154
|
+
}
|
|
155
|
+
return parsed;
|
|
156
|
+
}
|
package/dist/github-setup.js
CHANGED
|
@@ -17,16 +17,8 @@ import { spawnSync } from 'child_process';
|
|
|
17
17
|
import { existsSync, mkdirSync } from 'fs';
|
|
18
18
|
import { join } from 'path';
|
|
19
19
|
import { authedFetch } from './http.js';
|
|
20
|
+
import { sanitizeTerminalText } from './json-response.js';
|
|
20
21
|
import { assertWorkspaceDirUsable, workspaceExists } from './workspace.js';
|
|
21
|
-
/**
|
|
22
|
-
* Strip terminal control characters from server-supplied strings before they
|
|
23
|
-
* are printed (mirrors index.ts `sanitizeTerminalText`), so a hostile or
|
|
24
|
-
* mangled provisioning `warnings[]`/`defaultBranch` value can't inject escape
|
|
25
|
-
* sequences into the user's terminal.
|
|
26
|
-
*/
|
|
27
|
-
function sanitizeServerText(value) {
|
|
28
|
-
return String(value).replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F]/g, '');
|
|
29
|
-
}
|
|
30
22
|
const DEFAULT_POLL_INTERVAL_MS = 2000;
|
|
31
23
|
// ALI-1141: long enough for a slow org-picker + repo-selection walk, but the
|
|
32
24
|
// wait MUST be finite — when the App is already installed in the target org,
|
|
@@ -231,10 +223,10 @@ export async function runHostedAttach(options, io) {
|
|
|
231
223
|
repoFullName = data.repo;
|
|
232
224
|
provisioned = true;
|
|
233
225
|
if (data.rulesetApplied === true) {
|
|
234
|
-
io.print(` Default-branch protection applied (ruleset "orizu-default-branch-protection"${data.defaultBranch ? ` on ${
|
|
226
|
+
io.print(` Default-branch protection applied (ruleset "orizu-default-branch-protection"${data.defaultBranch ? ` on ${sanitizeTerminalText(data.defaultBranch)}` : ''}).`);
|
|
235
227
|
}
|
|
236
228
|
for (const warning of data.warnings ?? []) {
|
|
237
|
-
io.print(` ⚠ ${
|
|
229
|
+
io.print(` ⚠ ${sanitizeTerminalText(warning)}`);
|
|
238
230
|
}
|
|
239
231
|
}
|
|
240
232
|
const cleanUrl = `https://github.com/${repoFullName}.git`;
|