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.
Files changed (72) hide show
  1. package/README.md +15 -0
  2. package/dist/archive-artifacts-cli.js +213 -0
  3. package/dist/artifact-pull.js +1 -3
  4. package/dist/dataset-edit-rows.js +90 -0
  5. package/dist/dataset-upload-snapshot.js +22 -0
  6. package/dist/diff-comments-cli.js +175 -0
  7. package/dist/egress-policy.js +12 -0
  8. package/dist/error-response.js +21 -15
  9. package/dist/git-credential.js +316 -10
  10. package/dist/git-recovery-environment.js +156 -0
  11. package/dist/github-setup.js +3 -11
  12. package/dist/help.js +134 -5
  13. package/dist/hosted-boot.js +36 -1
  14. package/dist/hosted-bootstrap-rehearsal.js +8 -0
  15. package/dist/hosted-broker-server.js +141 -129
  16. package/dist/hosted-git-http-server.js +70 -71
  17. package/dist/hosted-runtime-assets.js +136 -10
  18. package/dist/hosted-session-cli.js +435 -61
  19. package/dist/index.js +176 -213
  20. package/dist/json-response.js +21 -0
  21. package/dist/loopback-serve.js +38 -0
  22. package/dist/markdown-report-input.js +67 -0
  23. package/dist/optimization-export-cli.js +30 -0
  24. package/dist/optimizations-list-cli.js +18 -8
  25. package/dist/prompt-draft-push.js +7 -5
  26. package/dist/prompt-report-cli.js +86 -0
  27. package/dist/repository-backup-core.js +1518 -0
  28. package/dist/repository-recovery-manifest.js +326 -0
  29. package/dist/scorer-draft-push.js +1 -22
  30. package/dist/scores-accept-cli.js +1 -3
  31. package/dist/start-intent.js +25 -0
  32. package/dist/workbench-cli.js +164 -25
  33. package/dist/workspace-sync.js +5 -0
  34. package/dist/zip-draft-push.js +1 -24
  35. package/package.json +1 -1
  36. package/src/archive-artifacts-cli.ts +376 -0
  37. package/src/artifact-pull.ts +1 -4
  38. package/src/dataset-edit-rows.ts +124 -0
  39. package/src/dataset-upload-snapshot.ts +41 -0
  40. package/src/diff-comments-cli.ts +299 -0
  41. package/src/egress-policy.ts +24 -1
  42. package/src/error-response.ts +29 -16
  43. package/src/git-credential.ts +424 -9
  44. package/src/git-recovery-environment.ts +212 -0
  45. package/src/github-setup.ts +3 -12
  46. package/src/help.ts +134 -5
  47. package/src/hosted-boot.ts +47 -1
  48. package/src/hosted-bootstrap-rehearsal.ts +7 -0
  49. package/src/hosted-broker-server.ts +152 -134
  50. package/src/hosted-git-http-server.ts +64 -65
  51. package/src/hosted-runtime-assets.ts +141 -10
  52. package/src/hosted-session-cli.ts +553 -25
  53. package/src/index.ts +229 -276
  54. package/src/json-response.ts +37 -0
  55. package/src/loopback-serve.ts +55 -0
  56. package/src/markdown-report-input.ts +80 -0
  57. package/src/optimization-export-cli.ts +72 -0
  58. package/src/optimizations-list-cli.ts +26 -9
  59. package/src/prompt-draft-push.ts +8 -6
  60. package/src/prompt-report-cli.ts +135 -0
  61. package/src/repository-backup-core.ts +2537 -0
  62. package/src/repository-recovery-manifest.ts +521 -0
  63. package/src/scorer-draft-push.ts +1 -6
  64. package/src/scores-accept-cli.ts +1 -4
  65. package/src/start-intent.ts +41 -0
  66. package/src/workbench-cli.ts +219 -21
  67. package/src/workspace-sync.ts +4 -0
  68. package/src/zip-draft-push.ts +1 -6
  69. package/vendor/orizu-gepa-python/src/orizu_gepa/cli.py +1 -1
  70. package/vendor/orizu-gepa-python/src/orizu_gepa/client.py +4 -1
  71. package/vendor/skills/orizu-cli/SKILL.md +1 -0
  72. package/vendor/skills/orizu-cli/references/cli-reference.md +43 -0
@@ -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
- const data = (await response.json());
91
- if (data.token)
92
- return { ok: true, token: data.token };
93
- return { ok: false, status: response.status, error: 'Broker returned no token' };
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
- // Only broker for github.com; anything else is git's own business.
112
- if (input.host && input.host !== GITHUB_HOST)
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
- let minted = await mintToken(fetcher, workspaceId, 'write');
122
- if (!minted.ok && minted.status === 403) {
123
- minted = await mintToken(fetcher, workspaceId, 'read');
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
+ }
@@ -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 ${sanitizeServerText(data.defaultBranch)}` : ''}).`);
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(` ⚠ ${sanitizeServerText(warning)}`);
229
+ io.print(` ⚠ ${sanitizeTerminalText(warning)}`);
238
230
  }
239
231
  }
240
232
  const cleanUrl = `https://github.com/${repoFullName}.git`;