@qijenchen/governance 0.1.0-beta.94
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 +89 -0
- package/bin/attest.mjs +4 -0
- package/bin/check.mjs +4 -0
- package/bin/doctor.mjs +4 -0
- package/bin/generate.mjs +4 -0
- package/bin/governance.mjs +4 -0
- package/bin/hook.mjs +4 -0
- package/bin/upgrade.mjs +4 -0
- package/canonical/gates.json +42 -0
- package/canonical/manifest.json +476 -0
- package/canonical/plugin-aliases.json +25 -0
- package/canonical/provider-lifecycle.json +48 -0
- package/canonical/providers.json +455 -0
- package/canonical/roles.json +12 -0
- package/canonical/rules.json +81 -0
- package/canonical/schemas/attestation.schema.json +61 -0
- package/canonical/schemas/diagnostic.schema.json +37 -0
- package/canonical/schemas/gates.schema.json +27 -0
- package/canonical/schemas/lock.schema.json +189 -0
- package/canonical/schemas/manifest.schema.json +103 -0
- package/canonical/schemas/plugin-aliases.schema.json +59 -0
- package/canonical/schemas/provider-hook-coverage.schema.json +173 -0
- package/canonical/schemas/provider-lifecycle.schema.json +126 -0
- package/canonical/schemas/providers.schema.json +917 -0
- package/canonical/schemas/roles.schema.json +27 -0
- package/canonical/schemas/rules.schema.json +46 -0
- package/canonical/schemas/upgrade-plan.schema.json +31 -0
- package/package.json +42 -0
- package/src/authority-decision-evidence.mjs +413 -0
- package/src/canonical-order.mjs +8 -0
- package/src/carrier-projection.mjs +407 -0
- package/src/cli.mjs +114 -0
- package/src/closed-tool-execution.mjs +1001 -0
- package/src/common.mjs +278 -0
- package/src/contract.mjs +760 -0
- package/src/hook-api.mjs +107 -0
- package/src/index.mjs +14 -0
- package/src/provider-hook-normalization.mjs +1646 -0
- package/src/provider-review-binding.mjs +2377 -0
- package/src/snapshot.mjs +520 -0
|
@@ -0,0 +1,1001 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import { spawnSync } from 'node:child_process'
|
|
3
|
+
import {
|
|
4
|
+
chmodSync,
|
|
5
|
+
closeSync,
|
|
6
|
+
constants as fsConstants,
|
|
7
|
+
existsSync,
|
|
8
|
+
fstatSync,
|
|
9
|
+
fsyncSync,
|
|
10
|
+
lstatSync,
|
|
11
|
+
mkdtempSync,
|
|
12
|
+
mkdirSync,
|
|
13
|
+
openSync,
|
|
14
|
+
readFileSync,
|
|
15
|
+
readlinkSync,
|
|
16
|
+
readSync,
|
|
17
|
+
realpathSync,
|
|
18
|
+
rmSync,
|
|
19
|
+
statSync,
|
|
20
|
+
symlinkSync,
|
|
21
|
+
writeFileSync,
|
|
22
|
+
} from 'node:fs'
|
|
23
|
+
import { tmpdir } from 'node:os'
|
|
24
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'
|
|
25
|
+
|
|
26
|
+
const CLOSED_GIT_EXECUTABLES = Object.freeze({
|
|
27
|
+
darwin: '/usr/bin/git',
|
|
28
|
+
linux: '/usr/bin/git',
|
|
29
|
+
})
|
|
30
|
+
const CLOSED_GIT_GLOBAL_ARGUMENTS = Object.freeze([
|
|
31
|
+
'--no-optional-locks',
|
|
32
|
+
'-c', 'core.autocrlf=false',
|
|
33
|
+
'-c', 'core.fsmonitor=false',
|
|
34
|
+
'-c', 'core.hooksPath=/dev/null',
|
|
35
|
+
'-c', 'credential.helper=',
|
|
36
|
+
'-c', 'diff.external=',
|
|
37
|
+
'-c', 'core.attributesFile=/dev/null',
|
|
38
|
+
'-c', 'core.excludesFile=/dev/null',
|
|
39
|
+
'-c', 'core.untrackedCache=false',
|
|
40
|
+
'-c', 'init.templateDir=',
|
|
41
|
+
])
|
|
42
|
+
const CLOSED_GIT_ENVIRONMENT = Object.freeze({
|
|
43
|
+
GIT_CONFIG_GLOBAL: '/dev/null',
|
|
44
|
+
GIT_CONFIG_NOSYSTEM: '1',
|
|
45
|
+
GIT_NO_LAZY_FETCH: '1',
|
|
46
|
+
GIT_NO_REPLACE_OBJECTS: '1',
|
|
47
|
+
GIT_OPTIONAL_LOCKS: '0',
|
|
48
|
+
GIT_PAGER: '/bin/cat',
|
|
49
|
+
GIT_TERMINAL_PROMPT: '0',
|
|
50
|
+
HOME: '/dev/null',
|
|
51
|
+
LANG: 'C',
|
|
52
|
+
LC_ALL: 'C',
|
|
53
|
+
PATH: '/usr/bin:/bin',
|
|
54
|
+
XDG_CONFIG_HOME: '/dev/null',
|
|
55
|
+
})
|
|
56
|
+
const CLOSED_GH_CANDIDATES = Object.freeze({
|
|
57
|
+
darwin: ['/opt/homebrew/bin/gh', '/usr/local/bin/gh', '/usr/bin/gh'],
|
|
58
|
+
linux: ['/usr/bin/gh', '/usr/local/bin/gh', '/home/linuxbrew/.linuxbrew/bin/gh'],
|
|
59
|
+
})
|
|
60
|
+
const CLOSED_PRIVATE_RUNTIME_BASES = Object.freeze({
|
|
61
|
+
darwin: '/private/tmp',
|
|
62
|
+
linux: '/tmp',
|
|
63
|
+
})
|
|
64
|
+
const CLOSED_HOOK_EXECUTABLE_CANDIDATES = Object.freeze({
|
|
65
|
+
darwin: Object.freeze({
|
|
66
|
+
bash: Object.freeze(['/bin/bash']),
|
|
67
|
+
git: Object.freeze(['/usr/bin/git']),
|
|
68
|
+
jq: Object.freeze(['/usr/bin/jq']),
|
|
69
|
+
python3: Object.freeze(['/usr/bin/python3']),
|
|
70
|
+
}),
|
|
71
|
+
linux: Object.freeze({
|
|
72
|
+
bash: Object.freeze(['/usr/bin/bash', '/bin/bash']),
|
|
73
|
+
git: Object.freeze(['/usr/bin/git']),
|
|
74
|
+
jq: Object.freeze(['/usr/bin/jq']),
|
|
75
|
+
python3: Object.freeze(['/usr/bin/python3']),
|
|
76
|
+
}),
|
|
77
|
+
})
|
|
78
|
+
const CLOSED_HOOK_SYSTEM_PATH_CANDIDATES = Object.freeze([
|
|
79
|
+
'/usr/bin',
|
|
80
|
+
'/bin',
|
|
81
|
+
'/usr/sbin',
|
|
82
|
+
'/sbin',
|
|
83
|
+
])
|
|
84
|
+
const CLOSED_GH_ARGUMENT_PREFIX = Object.freeze([])
|
|
85
|
+
const DANGEROUS_LOCAL_GIT_CONFIG = /^(?:alias\.|credential\.|filter\.|gpg\.|http\.|include\.|includeif\.|merge\..+\.driver$|protocol\..+\.allow$|submodule\.|tar\..+\.command$|url\..+\.(?:insteadof|pushinsteadof)$|core\.(?:alternaterefscommand|askpass|attributesfile|editor|excludesfile|gitproxy|hookspath|pager|sshcommand|worktree)$|extensions\.worktreeconfig$|interactive\.difffilter$|remote\..+\.(?:proxy|receivepack|uploadpack|vcs)$|sequence\.editor$)|^diff\..+\.(?:command|textconv)$/
|
|
86
|
+
|
|
87
|
+
const cachedGhExecutables = new Map()
|
|
88
|
+
const cachedHookToolPaths = new Map()
|
|
89
|
+
let ghCleanupRegistered = false
|
|
90
|
+
const ghCleanupRoots = new Set()
|
|
91
|
+
const MAX_CLOSED_EXECUTABLE_BYTES = 512 * 1024 * 1024
|
|
92
|
+
const MAX_CANONICAL_GIT_HOOK_BYTES = 1024 * 1024
|
|
93
|
+
const MAX_CLOSED_GIT_OUTPUT_BYTES = 256 * 1024 * 1024
|
|
94
|
+
|
|
95
|
+
function invariant(condition, message) {
|
|
96
|
+
if (!condition) throw new Error(message)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function sha256(bytes) {
|
|
100
|
+
return createHash('sha256').update(bytes).digest('hex')
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function isContained(root, target) {
|
|
104
|
+
const rel = relative(root, target)
|
|
105
|
+
return rel === '' || (rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel))
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function sameIdentity(left, right) {
|
|
109
|
+
return left.dev === right.dev
|
|
110
|
+
&& left.ino === right.ino
|
|
111
|
+
&& left.mode === right.mode
|
|
112
|
+
&& left.nlink === right.nlink
|
|
113
|
+
&& left.size === right.size
|
|
114
|
+
&& left.mtimeNs === right.mtimeNs
|
|
115
|
+
&& left.ctimeNs === right.ctimeNs
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function sameDirectoryIdentity(left, right) {
|
|
119
|
+
return left.dev === right.dev
|
|
120
|
+
&& left.ino === right.ino
|
|
121
|
+
&& left.mode === right.mode
|
|
122
|
+
&& left.uid === right.uid
|
|
123
|
+
&& left.gid === right.gid
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function validateClosedRuntimeBaseCandidate(candidate, runtimePlatform) {
|
|
127
|
+
const configured = candidate
|
|
128
|
+
invariant(configured, `Closed private runtime is unsupported on platform ${runtimePlatform}`)
|
|
129
|
+
const canonical = realpathSync(configured)
|
|
130
|
+
const info = lstatSync(canonical, { bigint: true })
|
|
131
|
+
const currentUid = typeof process.getuid === 'function' ? BigInt(process.getuid()) : null
|
|
132
|
+
const rootStickyOrClosed = info.uid === 0n
|
|
133
|
+
&& (
|
|
134
|
+
(info.mode & 0o022n) === 0n
|
|
135
|
+
|| (info.mode & 0o1000n) === 0o1000n
|
|
136
|
+
)
|
|
137
|
+
const ownedPrivate = currentUid !== null
|
|
138
|
+
&& info.uid === currentUid
|
|
139
|
+
&& (info.mode & 0o022n) === 0n
|
|
140
|
+
invariant(
|
|
141
|
+
info.isDirectory()
|
|
142
|
+
&& !info.isSymbolicLink()
|
|
143
|
+
&& (rootStickyOrClosed || ownedPrivate),
|
|
144
|
+
`Closed private runtime base is unsafe at ${canonical}`,
|
|
145
|
+
)
|
|
146
|
+
return canonical
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function validateClosedPrivateRuntimeBase(runtimePlatform) {
|
|
150
|
+
// Honor the standard per-user temporary directory (TMPDIR / os.tmpdir()) before the
|
|
151
|
+
// platform fallback. A hardcoded /private/tmp breaks sandboxed and CI environments where
|
|
152
|
+
// only TMPDIR is writable; a per-user 0700 directory is at least as private as a
|
|
153
|
+
// root-owned sticky directory.
|
|
154
|
+
return validateClosedRuntimeBaseCandidate(
|
|
155
|
+
tmpdir() || CLOSED_PRIVATE_RUNTIME_BASES[runtimePlatform],
|
|
156
|
+
runtimePlatform,
|
|
157
|
+
)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function validateClosedPlatformRuntimeBase(runtimePlatform) {
|
|
161
|
+
return validateClosedRuntimeBaseCandidate(CLOSED_PRIVATE_RUNTIME_BASES[runtimePlatform], runtimePlatform)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function resolveClosedPrivateRuntimeBase(runtimePlatform = process.platform) {
|
|
165
|
+
return validateClosedPrivateRuntimeBase(runtimePlatform)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function stableExecutableDigest(path, maxBytes = MAX_CLOSED_EXECUTABLE_BYTES) {
|
|
169
|
+
let descriptor
|
|
170
|
+
try {
|
|
171
|
+
invariant(Number.isSafeInteger(maxBytes) && maxBytes > 0 && maxBytes <= MAX_CLOSED_EXECUTABLE_BYTES, 'Closed executable byte limit is invalid')
|
|
172
|
+
const pathBefore = lstatSync(path, { bigint: true })
|
|
173
|
+
descriptor = openSync(
|
|
174
|
+
path,
|
|
175
|
+
fsConstants.O_RDONLY
|
|
176
|
+
| (fsConstants.O_CLOEXEC ?? 0)
|
|
177
|
+
| (fsConstants.O_NOFOLLOW ?? 0),
|
|
178
|
+
)
|
|
179
|
+
const before = fstatSync(descriptor, { bigint: true })
|
|
180
|
+
invariant(
|
|
181
|
+
sameIdentity(pathBefore, before)
|
|
182
|
+
&& before.isFile()
|
|
183
|
+
&& before.size > 0n
|
|
184
|
+
&& before.size <= BigInt(maxBytes),
|
|
185
|
+
`Closed executable size is outside the safe range at ${path}`,
|
|
186
|
+
)
|
|
187
|
+
const digest = createHash('sha256')
|
|
188
|
+
const chunk = Buffer.allocUnsafe(1024 * 1024)
|
|
189
|
+
let total = 0n
|
|
190
|
+
while (true) {
|
|
191
|
+
const count = readSync(descriptor, chunk, 0, chunk.length, null)
|
|
192
|
+
if (count === 0) break
|
|
193
|
+
digest.update(chunk.subarray(0, count))
|
|
194
|
+
total += BigInt(count)
|
|
195
|
+
}
|
|
196
|
+
const after = fstatSync(descriptor, { bigint: true })
|
|
197
|
+
const pathAfter = lstatSync(path, { bigint: true })
|
|
198
|
+
invariant(
|
|
199
|
+
sameIdentity(before, after)
|
|
200
|
+
&& sameIdentity(after, pathAfter)
|
|
201
|
+
&& total === before.size,
|
|
202
|
+
`Closed executable changed while it was authenticated at ${path}`,
|
|
203
|
+
)
|
|
204
|
+
return Object.freeze({
|
|
205
|
+
info: after,
|
|
206
|
+
sha256: digest.digest('hex'),
|
|
207
|
+
})
|
|
208
|
+
} finally {
|
|
209
|
+
if (descriptor !== undefined) closeSync(descriptor)
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function canonicalRepositoryRoot(repoRoot) {
|
|
214
|
+
if (repoRoot === undefined) return null
|
|
215
|
+
invariant(
|
|
216
|
+
typeof repoRoot === 'string' && resolve(repoRoot) === repoRoot,
|
|
217
|
+
'Closed tool repository root must be an absolute normalized path',
|
|
218
|
+
)
|
|
219
|
+
const canonical = realpathSync(repoRoot)
|
|
220
|
+
const info = lstatSync(canonical)
|
|
221
|
+
invariant(
|
|
222
|
+
canonical === repoRoot && info.isDirectory() && !info.isSymbolicLink(),
|
|
223
|
+
'Closed tool repository root must be one canonical real directory',
|
|
224
|
+
)
|
|
225
|
+
return canonical
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function validateClosedHookExecutableSource({
|
|
229
|
+
candidate,
|
|
230
|
+
label,
|
|
231
|
+
repoRoot,
|
|
232
|
+
runtimePlatform,
|
|
233
|
+
allowCurrentOwner = false,
|
|
234
|
+
}) {
|
|
235
|
+
invariant(
|
|
236
|
+
typeof candidate === 'string' && isAbsolute(candidate) && resolve(candidate) === candidate,
|
|
237
|
+
`Closed ${label} executable candidate must be one absolute normalized path`,
|
|
238
|
+
)
|
|
239
|
+
const source = realpathSync(candidate)
|
|
240
|
+
const currentUid = typeof process.getuid === 'function' ? BigInt(process.getuid()) : null
|
|
241
|
+
const authenticated = stableExecutableDigest(source)
|
|
242
|
+
const info = authenticated.info
|
|
243
|
+
invariant(
|
|
244
|
+
info.isFile()
|
|
245
|
+
&& !info.isSymbolicLink()
|
|
246
|
+
&& (info.mode & 0o111n) !== 0n
|
|
247
|
+
&& (info.mode & 0o022n) === 0n
|
|
248
|
+
&& (
|
|
249
|
+
info.uid === 0n
|
|
250
|
+
|| (
|
|
251
|
+
allowCurrentOwner
|
|
252
|
+
&& currentUid !== null
|
|
253
|
+
&& info.uid === currentUid
|
|
254
|
+
&& info.nlink === 1n
|
|
255
|
+
)
|
|
256
|
+
),
|
|
257
|
+
`Closed ${label} executable source is unsafe at ${source}`,
|
|
258
|
+
)
|
|
259
|
+
if (repoRoot) {
|
|
260
|
+
invariant(
|
|
261
|
+
!isContained(repoRoot, source),
|
|
262
|
+
`Closed ${label} executable source cannot be inside the governed repository`,
|
|
263
|
+
)
|
|
264
|
+
}
|
|
265
|
+
return Object.freeze({
|
|
266
|
+
label,
|
|
267
|
+
runtimePlatform,
|
|
268
|
+
sha256: authenticated.sha256,
|
|
269
|
+
source,
|
|
270
|
+
sourceInfo: info,
|
|
271
|
+
})
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function validateClosedRunningNodeExecutable({ candidate, repoRoot, runtimePlatform }) {
|
|
275
|
+
invariant(
|
|
276
|
+
typeof candidate === 'string' && isAbsolute(candidate) && resolve(candidate) === candidate,
|
|
277
|
+
'Closed node executable candidate must be one absolute normalized path',
|
|
278
|
+
)
|
|
279
|
+
const source = realpathSync(candidate)
|
|
280
|
+
// The coherent provenance invariant for the hook-child interpreter is IDENTITY with the
|
|
281
|
+
// interpreter already executing this governance code: realpath(candidate) ==
|
|
282
|
+
// realpath(process.execPath), TOCTOU-bound below by inode identity plus a full content
|
|
283
|
+
// digest of exactly the bytes that will run. A blessed ownership/mode class is the wrong
|
|
284
|
+
// invariant for the running interpreter: managed GitHub runners ship node world-writable
|
|
285
|
+
// by image design (actions/runner-images install-nodejs.sh runs `chmod -R 777
|
|
286
|
+
// /usr/local/bin`; configure-system.sh runs `chmod -R 777 /opt`, covering the
|
|
287
|
+
// setup-node toolcache), so no ownership/mode class can admit the actual interpreter
|
|
288
|
+
// there — and rejecting the very binary whose in-process code performs the check adds no
|
|
289
|
+
// assurance, because a compromised running interpreter could bypass any in-process
|
|
290
|
+
// check. Foreign node candidates (explicitly configured paths that are NOT the running
|
|
291
|
+
// interpreter) keep the full blessed-provenance validation.
|
|
292
|
+
if (source !== realpathSync(process.execPath)) {
|
|
293
|
+
return validateClosedHookExecutableSource({
|
|
294
|
+
candidate,
|
|
295
|
+
label: 'node',
|
|
296
|
+
repoRoot,
|
|
297
|
+
runtimePlatform,
|
|
298
|
+
allowCurrentOwner: true,
|
|
299
|
+
})
|
|
300
|
+
}
|
|
301
|
+
const authenticated = stableExecutableDigest(source)
|
|
302
|
+
const info = authenticated.info
|
|
303
|
+
invariant(
|
|
304
|
+
info.isFile()
|
|
305
|
+
&& !info.isSymbolicLink()
|
|
306
|
+
&& (info.mode & 0o111n) !== 0n,
|
|
307
|
+
`Closed node executable source is unsafe at ${source}`,
|
|
308
|
+
)
|
|
309
|
+
if (repoRoot) {
|
|
310
|
+
invariant(
|
|
311
|
+
!isContained(repoRoot, source),
|
|
312
|
+
'Closed node executable source cannot be inside the governed repository',
|
|
313
|
+
)
|
|
314
|
+
}
|
|
315
|
+
return Object.freeze({
|
|
316
|
+
label: 'node',
|
|
317
|
+
runtimePlatform,
|
|
318
|
+
sha256: authenticated.sha256,
|
|
319
|
+
source,
|
|
320
|
+
sourceInfo: info,
|
|
321
|
+
})
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function resolveClosedHookExecutable({
|
|
325
|
+
label,
|
|
326
|
+
nodeExecutable,
|
|
327
|
+
repoRoot,
|
|
328
|
+
runtimePlatform,
|
|
329
|
+
}) {
|
|
330
|
+
if (label === 'node') {
|
|
331
|
+
return validateClosedRunningNodeExecutable({
|
|
332
|
+
candidate: nodeExecutable,
|
|
333
|
+
repoRoot,
|
|
334
|
+
runtimePlatform,
|
|
335
|
+
})
|
|
336
|
+
}
|
|
337
|
+
const candidates = CLOSED_HOOK_EXECUTABLE_CANDIDATES[runtimePlatform]?.[label]
|
|
338
|
+
invariant(candidates, `Closed ${label} execution is unsupported on platform ${runtimePlatform}`)
|
|
339
|
+
for (const candidate of candidates) {
|
|
340
|
+
if (!existsSync(candidate)) continue
|
|
341
|
+
try {
|
|
342
|
+
return validateClosedHookExecutableSource({
|
|
343
|
+
candidate,
|
|
344
|
+
label,
|
|
345
|
+
repoRoot,
|
|
346
|
+
runtimePlatform,
|
|
347
|
+
})
|
|
348
|
+
} catch {}
|
|
349
|
+
}
|
|
350
|
+
throw new Error(`No authenticated ${label} executable is available from the closed platform candidates`)
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function resolveClosedHookSystemPaths(runtimePlatform) {
|
|
354
|
+
invariant(
|
|
355
|
+
CLOSED_HOOK_EXECUTABLE_CANDIDATES[runtimePlatform],
|
|
356
|
+
`Closed hook tool execution is unsupported on platform ${runtimePlatform}`,
|
|
357
|
+
)
|
|
358
|
+
const records = new Map()
|
|
359
|
+
for (const candidate of CLOSED_HOOK_SYSTEM_PATH_CANDIDATES) {
|
|
360
|
+
if (!existsSync(candidate)) continue
|
|
361
|
+
const canonical = realpathSync(candidate)
|
|
362
|
+
if (records.has(canonical)) continue
|
|
363
|
+
const info = lstatSync(canonical, { bigint: true })
|
|
364
|
+
invariant(
|
|
365
|
+
info.isDirectory()
|
|
366
|
+
&& !info.isSymbolicLink()
|
|
367
|
+
&& info.uid === 0n
|
|
368
|
+
&& (info.mode & 0o022n) === 0n,
|
|
369
|
+
`Closed hook system PATH entry is unsafe at ${canonical}`,
|
|
370
|
+
)
|
|
371
|
+
records.set(canonical, Object.freeze({ path: canonical, info }))
|
|
372
|
+
}
|
|
373
|
+
invariant(records.size > 0, 'Closed hook system PATH has no authenticated directory')
|
|
374
|
+
return Object.freeze([...records.values()])
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function closedGitIdentityEnvironment(identity) {
|
|
378
|
+
if (identity === undefined) return {}
|
|
379
|
+
invariant(identity && typeof identity === 'object' && !Array.isArray(identity), 'Closed Git identity must be an object')
|
|
380
|
+
const expected = [
|
|
381
|
+
'authorDate',
|
|
382
|
+
'authorEmail',
|
|
383
|
+
'authorName',
|
|
384
|
+
'committerDate',
|
|
385
|
+
'committerEmail',
|
|
386
|
+
'committerName',
|
|
387
|
+
]
|
|
388
|
+
invariant(
|
|
389
|
+
JSON.stringify(Object.keys(identity).sort()) === JSON.stringify(expected),
|
|
390
|
+
'Closed Git identity has an open or incomplete shape',
|
|
391
|
+
)
|
|
392
|
+
for (const key of expected) {
|
|
393
|
+
invariant(
|
|
394
|
+
typeof identity[key] === 'string'
|
|
395
|
+
&& identity[key].length > 0
|
|
396
|
+
&& identity[key].length <= 320
|
|
397
|
+
&& !/[\0\r\n]/.test(identity[key]),
|
|
398
|
+
`Closed Git identity ${key} is invalid`,
|
|
399
|
+
)
|
|
400
|
+
}
|
|
401
|
+
invariant(
|
|
402
|
+
!Number.isNaN(Date.parse(identity.authorDate))
|
|
403
|
+
&& !Number.isNaN(Date.parse(identity.committerDate)),
|
|
404
|
+
'Closed Git identity dates are invalid',
|
|
405
|
+
)
|
|
406
|
+
return {
|
|
407
|
+
GIT_AUTHOR_DATE: identity.authorDate,
|
|
408
|
+
GIT_AUTHOR_EMAIL: identity.authorEmail,
|
|
409
|
+
GIT_AUTHOR_NAME: identity.authorName,
|
|
410
|
+
GIT_COMMITTER_DATE: identity.committerDate,
|
|
411
|
+
GIT_COMMITTER_EMAIL: identity.committerEmail,
|
|
412
|
+
GIT_COMMITTER_NAME: identity.committerName,
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
export function validateClosedGitExecutable(executable) {
|
|
417
|
+
invariant(typeof executable === 'string' && resolve(executable) === executable, 'Closed Git executable must be an absolute normalized path')
|
|
418
|
+
let info
|
|
419
|
+
try {
|
|
420
|
+
info = lstatSync(executable)
|
|
421
|
+
} catch (error) {
|
|
422
|
+
throw new Error(`Closed Git executable is unavailable at ${executable}: ${error.message}`, { cause: error })
|
|
423
|
+
}
|
|
424
|
+
invariant(
|
|
425
|
+
info.isFile()
|
|
426
|
+
&& !info.isSymbolicLink()
|
|
427
|
+
&& info.uid === 0
|
|
428
|
+
&& (info.mode & 0o111) !== 0
|
|
429
|
+
&& (info.mode & 0o022) === 0
|
|
430
|
+
&& realpathSync(executable) === executable,
|
|
431
|
+
`Closed Git executable is unsafe at ${executable}`,
|
|
432
|
+
)
|
|
433
|
+
return executable
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
export function resolveClosedGitExecutable(runtimePlatform = process.platform) {
|
|
437
|
+
const executable = CLOSED_GIT_EXECUTABLES[runtimePlatform]
|
|
438
|
+
invariant(executable, `Closed Git execution is unsupported on platform ${runtimePlatform}`)
|
|
439
|
+
return validateClosedGitExecutable(executable)
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
export function runClosedGit(args, {
|
|
443
|
+
cwd,
|
|
444
|
+
gitIdentity,
|
|
445
|
+
input,
|
|
446
|
+
maxInputBytes = 64 * 1024 * 1024,
|
|
447
|
+
output = 'capture',
|
|
448
|
+
maxOutputBytes = 8 * 1024 * 1024,
|
|
449
|
+
runner = spawnSync,
|
|
450
|
+
runtimePlatform = process.platform,
|
|
451
|
+
timeoutMs = 5000,
|
|
452
|
+
} = {}) {
|
|
453
|
+
invariant(Array.isArray(args) && args.length > 0 && args.every(value => typeof value === 'string'), 'Closed Git arguments must be a non-empty string array')
|
|
454
|
+
invariant(
|
|
455
|
+
!args.some(value => value === '-c'
|
|
456
|
+
|| value.startsWith('-c')
|
|
457
|
+
|| value === '--config-env'
|
|
458
|
+
|| value.startsWith('--config-env=')),
|
|
459
|
+
'Closed Git callers may not inject global configuration arguments',
|
|
460
|
+
)
|
|
461
|
+
invariant(typeof cwd === 'string' && resolve(cwd) === cwd, 'Closed Git cwd must be one absolute normalized path')
|
|
462
|
+
const cwdInfo = lstatSync(cwd)
|
|
463
|
+
invariant(
|
|
464
|
+
cwdInfo.isDirectory() && !cwdInfo.isSymbolicLink() && realpathSync(cwd) === cwd,
|
|
465
|
+
'Closed Git cwd must be one canonical real directory',
|
|
466
|
+
)
|
|
467
|
+
invariant(['buffer', 'capture', 'ignore'].includes(output), 'Closed Git output mode is unsupported')
|
|
468
|
+
invariant(Number.isInteger(maxInputBytes) && maxInputBytes > 0 && maxInputBytes <= 128 * 1024 * 1024, 'Closed Git input limit is outside the safe range')
|
|
469
|
+
invariant(Number.isInteger(maxOutputBytes) && maxOutputBytes > 0 && maxOutputBytes <= MAX_CLOSED_GIT_OUTPUT_BYTES, 'Closed Git output limit is outside the safe range')
|
|
470
|
+
invariant(Number.isInteger(timeoutMs) && timeoutMs > 0 && timeoutMs <= 30_000, 'Closed Git timeout is outside the safe range')
|
|
471
|
+
invariant(typeof runner === 'function', 'Closed Git runner must be callable')
|
|
472
|
+
invariant(
|
|
473
|
+
input === undefined || typeof input === 'string' || Buffer.isBuffer(input),
|
|
474
|
+
'Closed Git input must be a string or Buffer',
|
|
475
|
+
)
|
|
476
|
+
invariant(
|
|
477
|
+
input === undefined || Buffer.byteLength(input) <= maxInputBytes,
|
|
478
|
+
'Closed Git input exceeds the closed size limit',
|
|
479
|
+
)
|
|
480
|
+
return runner(
|
|
481
|
+
resolveClosedGitExecutable(runtimePlatform),
|
|
482
|
+
[...CLOSED_GIT_GLOBAL_ARGUMENTS, ...args],
|
|
483
|
+
{
|
|
484
|
+
cwd,
|
|
485
|
+
encoding: output === 'capture' ? 'utf8' : (output === 'buffer' ? null : undefined),
|
|
486
|
+
env: {
|
|
487
|
+
...CLOSED_GIT_ENVIRONMENT,
|
|
488
|
+
...closedGitIdentityEnvironment(gitIdentity),
|
|
489
|
+
},
|
|
490
|
+
input,
|
|
491
|
+
maxBuffer: maxOutputBytes,
|
|
492
|
+
shell: false,
|
|
493
|
+
stdio: output === 'ignore'
|
|
494
|
+
? [input === undefined ? 'ignore' : 'pipe', 'ignore', 'ignore']
|
|
495
|
+
: [input === undefined ? 'ignore' : 'pipe', 'pipe', 'pipe'],
|
|
496
|
+
timeout: timeoutMs,
|
|
497
|
+
windowsHide: true,
|
|
498
|
+
},
|
|
499
|
+
)
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
function closedGitOutput(repoRoot, args, label, options = {}) {
|
|
503
|
+
const result = runClosedGit(args, { cwd: resolve(repoRoot), ...options })
|
|
504
|
+
invariant(
|
|
505
|
+
!result.error && result.signal === null && result.status === 0 && typeof result.stdout === 'string',
|
|
506
|
+
`Cannot resolve ${label} through closed Git`,
|
|
507
|
+
)
|
|
508
|
+
return result.stdout
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
export function validateCanonicalGovernanceHook(repoRoot) {
|
|
512
|
+
const root = realpathSync(resolve(repoRoot))
|
|
513
|
+
const expectedHookRoot = join(root, '.husky')
|
|
514
|
+
const hookRootInfo = lstatSync(expectedHookRoot, { bigint: true })
|
|
515
|
+
const precommitPath = join(expectedHookRoot, 'pre-commit')
|
|
516
|
+
const precommitPathInfo = lstatSync(precommitPath, { bigint: true })
|
|
517
|
+
invariant(
|
|
518
|
+
precommitPathInfo.isFile() && !precommitPathInfo.isSymbolicLink(),
|
|
519
|
+
'Repository-local Git hook path is not backed by the canonical executable .husky/pre-commit file',
|
|
520
|
+
)
|
|
521
|
+
const precommitSource = realpathSync(precommitPath)
|
|
522
|
+
const authenticated = stableExecutableDigest(precommitPath, MAX_CANONICAL_GIT_HOOK_BYTES)
|
|
523
|
+
const precommitInfo = authenticated.info
|
|
524
|
+
const currentUid = typeof process.getuid === 'function' ? BigInt(process.getuid()) : null
|
|
525
|
+
invariant(
|
|
526
|
+
hookRootInfo.isDirectory()
|
|
527
|
+
&& !hookRootInfo.isSymbolicLink()
|
|
528
|
+
&& (hookRootInfo.mode & 0o022n) === 0n
|
|
529
|
+
&& currentUid !== null
|
|
530
|
+
&& (hookRootInfo.uid === currentUid || hookRootInfo.uid === 0n)
|
|
531
|
+
&& realpathSync(expectedHookRoot) === expectedHookRoot
|
|
532
|
+
&& precommitSource === precommitPath
|
|
533
|
+
&& precommitInfo.isFile()
|
|
534
|
+
&& !precommitInfo.isSymbolicLink()
|
|
535
|
+
&& precommitInfo.nlink === 1n
|
|
536
|
+
&& (precommitInfo.mode & 0o111n) !== 0n
|
|
537
|
+
&& (precommitInfo.mode & 0o022n) === 0n
|
|
538
|
+
&& (precommitInfo.mode & 0o7000n) === 0n
|
|
539
|
+
&& (precommitInfo.uid === currentUid || precommitInfo.uid === 0n),
|
|
540
|
+
'Repository-local Git hook path is not backed by the canonical executable .husky/pre-commit file',
|
|
541
|
+
)
|
|
542
|
+
return Object.freeze({
|
|
543
|
+
root,
|
|
544
|
+
hookRoot: expectedHookRoot,
|
|
545
|
+
hookPath: precommitPath,
|
|
546
|
+
sha256: authenticated.sha256,
|
|
547
|
+
})
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
export function assertClosedGitLocalConfiguration(repoRoot, options = {}) {
|
|
551
|
+
const {
|
|
552
|
+
allowHookPathMigration = false,
|
|
553
|
+
...gitOptions
|
|
554
|
+
} = options
|
|
555
|
+
invariant(typeof allowHookPathMigration === 'boolean', 'Closed Git hook migration option must be boolean')
|
|
556
|
+
const root = realpathSync(resolve(repoRoot))
|
|
557
|
+
const names = closedGitOutput(
|
|
558
|
+
root,
|
|
559
|
+
['config', '--local', '--no-includes', '--null', '--name-only', '--list'],
|
|
560
|
+
'repository-local Git configuration',
|
|
561
|
+
gitOptions,
|
|
562
|
+
).split('\0').filter(Boolean)
|
|
563
|
+
const hookPathNames = names.filter(name => name.toLowerCase() === 'core.hookspath')
|
|
564
|
+
if (hookPathNames.length > 0 && !allowHookPathMigration) {
|
|
565
|
+
const hookPaths = closedGitOutput(
|
|
566
|
+
root,
|
|
567
|
+
['config', '--local', '--no-includes', '--null', '--get-all', 'core.hooksPath'],
|
|
568
|
+
'repository-local Git hook path',
|
|
569
|
+
gitOptions,
|
|
570
|
+
).split('\0').filter(Boolean)
|
|
571
|
+
invariant(hookPathNames.length === 1 && hookPaths.length === 1, 'Repository-local Git hook path must be one exact canonical binding')
|
|
572
|
+
// Accept every standard husky binding shape (relative `.husky`, husky v9 `.husky/_`,
|
|
573
|
+
// or an absolute path) as long as it stays inside this repository. Byte-exact
|
|
574
|
+
// absolute-path equality broke linked worktrees, re-clones, and path renames.
|
|
575
|
+
const resolvedHookPath = resolve(root, hookPaths[0])
|
|
576
|
+
invariant(
|
|
577
|
+
isContained(root, resolvedHookPath),
|
|
578
|
+
'Repository-local Git hook path must stay inside the repository',
|
|
579
|
+
)
|
|
580
|
+
}
|
|
581
|
+
const dangerous = names
|
|
582
|
+
.map(name => name.toLowerCase())
|
|
583
|
+
.filter(name => name !== 'core.hookspath')
|
|
584
|
+
.filter(name => DANGEROUS_LOCAL_GIT_CONFIG.test(name))
|
|
585
|
+
.sort()
|
|
586
|
+
invariant(
|
|
587
|
+
dangerous.length === 0,
|
|
588
|
+
`Repository-local Git configuration may execute unsupported external commands: ${dangerous.join(', ')}`,
|
|
589
|
+
)
|
|
590
|
+
return true
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
function ghSourceCandidates({ executable, runtimePlatform }) {
|
|
594
|
+
if (executable !== undefined) {
|
|
595
|
+
invariant(typeof executable === 'string' && isAbsolute(executable) && resolve(executable) === executable, 'Closed GitHub CLI override must be one absolute normalized path')
|
|
596
|
+
return [executable]
|
|
597
|
+
}
|
|
598
|
+
const candidates = CLOSED_GH_CANDIDATES[runtimePlatform]
|
|
599
|
+
invariant(candidates, `Closed GitHub CLI execution is unsupported on platform ${runtimePlatform}`)
|
|
600
|
+
return candidates
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
function validateGhSource(candidate, repoRoot) {
|
|
604
|
+
const source = realpathSync(candidate)
|
|
605
|
+
const info = lstatSync(source, { bigint: true })
|
|
606
|
+
const currentUid = typeof process.getuid === 'function' ? BigInt(process.getuid()) : null
|
|
607
|
+
invariant(
|
|
608
|
+
info.isFile()
|
|
609
|
+
&& !info.isSymbolicLink()
|
|
610
|
+
&& info.nlink === 1n
|
|
611
|
+
&& (info.mode & 0o111n) !== 0n
|
|
612
|
+
&& (info.mode & 0o022n) === 0n
|
|
613
|
+
&& (info.uid === 0n || (currentUid !== null && info.uid === currentUid)),
|
|
614
|
+
`Closed GitHub CLI source is unsafe at ${source}`,
|
|
615
|
+
)
|
|
616
|
+
if (repoRoot) {
|
|
617
|
+
const canonicalRepo = realpathSync(resolve(repoRoot))
|
|
618
|
+
invariant(!isContained(canonicalRepo, source), 'Closed GitHub CLI source cannot be inside the governed repository')
|
|
619
|
+
}
|
|
620
|
+
return { source, info }
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
function registerPrivateCleanup(root) {
|
|
624
|
+
ghCleanupRoots.add(root)
|
|
625
|
+
if (ghCleanupRegistered) return
|
|
626
|
+
ghCleanupRegistered = true
|
|
627
|
+
process.once('exit', () => {
|
|
628
|
+
for (const cleanupRoot of ghCleanupRoots) {
|
|
629
|
+
try {
|
|
630
|
+
chmodSync(cleanupRoot, 0o700)
|
|
631
|
+
rmSync(cleanupRoot, { recursive: true, force: true })
|
|
632
|
+
} catch {}
|
|
633
|
+
}
|
|
634
|
+
})
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
function materializeClosedGhExecutable(options = {}) {
|
|
638
|
+
let selected = null
|
|
639
|
+
for (const candidate of ghSourceCandidates(options)) {
|
|
640
|
+
if (!existsSync(candidate)) continue
|
|
641
|
+
try {
|
|
642
|
+
selected = validateGhSource(candidate, options.repoRoot)
|
|
643
|
+
break
|
|
644
|
+
} catch (error) {
|
|
645
|
+
if (options.executable !== undefined) throw error
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
invariant(selected, 'No authenticated GitHub CLI executable is available from the closed platform candidates')
|
|
649
|
+
const cacheKey = `${options.runtimePlatform ?? process.platform}\0${selected.source}`
|
|
650
|
+
const cached = cachedGhExecutables.get(cacheKey)
|
|
651
|
+
if (cached) {
|
|
652
|
+
const current = lstatSync(cached.source, { bigint: true })
|
|
653
|
+
invariant(sameIdentity(current, cached.sourceInfo), 'Closed GitHub CLI source changed after it was authenticated')
|
|
654
|
+
const privateInfo = lstatSync(cached.executable, { bigint: true })
|
|
655
|
+
invariant(
|
|
656
|
+
sameIdentity(privateInfo, cached.privateInfo)
|
|
657
|
+
&& sha256(readFileSync(cached.executable)) === cached.sha256,
|
|
658
|
+
'Private GitHub CLI executable changed after it was authenticated',
|
|
659
|
+
)
|
|
660
|
+
return cached.executable
|
|
661
|
+
}
|
|
662
|
+
const temporaryBase = validateClosedPrivateRuntimeBase(options.runtimePlatform ?? process.platform)
|
|
663
|
+
const root = realpathSync(mkdtempSync(join(temporaryBase, 'qijenchen-closed-gh-')))
|
|
664
|
+
chmodSync(root, 0o700)
|
|
665
|
+
if (options.repoRoot) {
|
|
666
|
+
const canonicalRepo = realpathSync(resolve(options.repoRoot))
|
|
667
|
+
invariant(!isContained(canonicalRepo, root), 'Closed GitHub CLI private executable cannot be materialized inside the governed repository')
|
|
668
|
+
}
|
|
669
|
+
const executable = join(root, 'gh')
|
|
670
|
+
const before = lstatSync(selected.source, { bigint: true })
|
|
671
|
+
const bytes = readFileSync(selected.source)
|
|
672
|
+
const after = lstatSync(selected.source, { bigint: true })
|
|
673
|
+
invariant(sameIdentity(before, after), 'GitHub CLI source changed while it was authenticated')
|
|
674
|
+
let descriptor
|
|
675
|
+
try {
|
|
676
|
+
descriptor = openSync(executable, 'wx', 0o500)
|
|
677
|
+
writeFileSync(descriptor, bytes)
|
|
678
|
+
fsyncSync(descriptor)
|
|
679
|
+
closeSync(descriptor)
|
|
680
|
+
descriptor = undefined
|
|
681
|
+
} finally {
|
|
682
|
+
if (descriptor !== undefined) closeSync(descriptor)
|
|
683
|
+
}
|
|
684
|
+
const privateInfo = lstatSync(executable, { bigint: true })
|
|
685
|
+
invariant(
|
|
686
|
+
privateInfo.isFile()
|
|
687
|
+
&& !privateInfo.isSymbolicLink()
|
|
688
|
+
&& privateInfo.nlink === 1n
|
|
689
|
+
&& (privateInfo.mode & 0o777n) === 0o500n
|
|
690
|
+
&& privateInfo.size === BigInt(bytes.length)
|
|
691
|
+
&& sha256(readFileSync(executable)) === sha256(bytes),
|
|
692
|
+
'Private GitHub CLI executable failed authenticated materialization',
|
|
693
|
+
)
|
|
694
|
+
registerPrivateCleanup(root)
|
|
695
|
+
const materialized = Object.freeze({
|
|
696
|
+
executable,
|
|
697
|
+
privateInfo,
|
|
698
|
+
root,
|
|
699
|
+
sha256: sha256(bytes),
|
|
700
|
+
source: selected.source,
|
|
701
|
+
sourceInfo: after,
|
|
702
|
+
})
|
|
703
|
+
cachedGhExecutables.set(cacheKey, materialized)
|
|
704
|
+
return executable
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
export function materializeClosedHookToolProfile({
|
|
708
|
+
nodeExecutable = process.execPath,
|
|
709
|
+
repoRoot,
|
|
710
|
+
runtimePlatform = process.platform,
|
|
711
|
+
} = {}) {
|
|
712
|
+
invariant(
|
|
713
|
+
typeof nodeExecutable === 'string'
|
|
714
|
+
&& isAbsolute(nodeExecutable)
|
|
715
|
+
&& resolve(nodeExecutable) === nodeExecutable,
|
|
716
|
+
'Closed hook Node executable must be one absolute normalized path',
|
|
717
|
+
)
|
|
718
|
+
const canonicalRepo = canonicalRepositoryRoot(repoRoot)
|
|
719
|
+
const executableRecords = Object.fromEntries(
|
|
720
|
+
['bash', 'git', 'jq', 'node', 'python3'].map((label) => [
|
|
721
|
+
label,
|
|
722
|
+
resolveClosedHookExecutable({
|
|
723
|
+
label,
|
|
724
|
+
nodeExecutable,
|
|
725
|
+
repoRoot: canonicalRepo,
|
|
726
|
+
runtimePlatform,
|
|
727
|
+
}),
|
|
728
|
+
]),
|
|
729
|
+
)
|
|
730
|
+
const systemPaths = resolveClosedHookSystemPaths(runtimePlatform)
|
|
731
|
+
const cacheKey = [
|
|
732
|
+
runtimePlatform,
|
|
733
|
+
canonicalRepo ?? '',
|
|
734
|
+
...Object.values(executableRecords).map(record => `${record.label}:${record.source}:${record.sha256}`),
|
|
735
|
+
...systemPaths.map(record => record.path),
|
|
736
|
+
].join('\0')
|
|
737
|
+
const cached = cachedHookToolPaths.get(cacheKey)
|
|
738
|
+
if (cached) {
|
|
739
|
+
cached.verify()
|
|
740
|
+
return cached
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
// If the honored TMPDIR sits inside the governed repository (common in isolated test
|
|
744
|
+
// fixtures that pin TMPDIR to a snapshot directory), fall back to a base outside the
|
|
745
|
+
// repository: first the platform base, then the repository's parent directory (for
|
|
746
|
+
// sandboxes where only the checkout's own volume is writable).
|
|
747
|
+
let root = null
|
|
748
|
+
const baseCandidates = []
|
|
749
|
+
const honoredBase = validateClosedPrivateRuntimeBase(runtimePlatform)
|
|
750
|
+
if (!canonicalRepo || !isContained(canonicalRepo, honoredBase)) baseCandidates.push(honoredBase)
|
|
751
|
+
else {
|
|
752
|
+
baseCandidates.push(() => validateClosedPlatformRuntimeBase(runtimePlatform))
|
|
753
|
+
baseCandidates.push(() => dirname(canonicalRepo))
|
|
754
|
+
}
|
|
755
|
+
let lastBaseError = null
|
|
756
|
+
for (const candidate of baseCandidates) {
|
|
757
|
+
try {
|
|
758
|
+
const temporaryBase = typeof candidate === 'function' ? candidate() : candidate
|
|
759
|
+
root = realpathSync(mkdtempSync(join(temporaryBase, 'qijenchen-closed-hook-tools-')))
|
|
760
|
+
break
|
|
761
|
+
} catch (error) {
|
|
762
|
+
lastBaseError = error
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
if (!root) throw lastBaseError || new Error('Closed hook tool profile has no writable private base')
|
|
766
|
+
chmodSync(root, 0o700)
|
|
767
|
+
if (canonicalRepo) {
|
|
768
|
+
invariant(
|
|
769
|
+
!isContained(canonicalRepo, root),
|
|
770
|
+
'Closed hook tool profile cannot be materialized inside the governed repository',
|
|
771
|
+
)
|
|
772
|
+
}
|
|
773
|
+
const homeDirectory = join(root, 'home')
|
|
774
|
+
const tempDirectory = join(root, 'tmp')
|
|
775
|
+
const directoryDescriptor = (path) => {
|
|
776
|
+
const descriptor = openSync(path, fsConstants.O_RDONLY | (fsConstants.O_CLOEXEC ?? 0))
|
|
777
|
+
try {
|
|
778
|
+
return fstatSync(descriptor, { bigint: true })
|
|
779
|
+
} finally {
|
|
780
|
+
closeSync(descriptor)
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
mkdirSync(homeDirectory, { mode: 0o700 })
|
|
784
|
+
mkdirSync(tempDirectory, { mode: 0o700 })
|
|
785
|
+
const materializedHome = realpathSync(homeDirectory)
|
|
786
|
+
const materializedTemp = realpathSync(tempDirectory)
|
|
787
|
+
|
|
788
|
+
const executablePaths = {}
|
|
789
|
+
const linkRecords = {}
|
|
790
|
+
for (const [label, record] of Object.entries(executableRecords)) {
|
|
791
|
+
const executablePath = join(root, label)
|
|
792
|
+
symlinkSync(record.source, executablePath)
|
|
793
|
+
const linkInfo = lstatSync(executablePath, { bigint: true })
|
|
794
|
+
invariant(
|
|
795
|
+
linkInfo.isSymbolicLink()
|
|
796
|
+
&& readlinkSync(executablePath) === record.source,
|
|
797
|
+
`Closed ${label} executable alias failed authenticated materialization`,
|
|
798
|
+
)
|
|
799
|
+
executablePaths[label] = executablePath
|
|
800
|
+
linkRecords[label] = Object.freeze({
|
|
801
|
+
info: linkInfo,
|
|
802
|
+
path: executablePath,
|
|
803
|
+
target: record.source,
|
|
804
|
+
})
|
|
805
|
+
}
|
|
806
|
+
chmodSync(root, 0o500)
|
|
807
|
+
const rootInfo = lstatSync(root, { bigint: true })
|
|
808
|
+
const homeInfo = directoryDescriptor(materializedHome)
|
|
809
|
+
const tempInfo = directoryDescriptor(materializedTemp)
|
|
810
|
+
const executablePath = [root, ...systemPaths.map(record => record.path)].join(':')
|
|
811
|
+
const profileSha256 = sha256(JSON.stringify({
|
|
812
|
+
executablePath,
|
|
813
|
+
executables: Object.fromEntries(
|
|
814
|
+
Object.entries(executableRecords).map(([label, record]) => [
|
|
815
|
+
label,
|
|
816
|
+
{ source: record.source, sha256: record.sha256 },
|
|
817
|
+
]),
|
|
818
|
+
),
|
|
819
|
+
runtimePlatform,
|
|
820
|
+
schemaVersion: 1,
|
|
821
|
+
systemPaths: systemPaths.map(record => record.path),
|
|
822
|
+
}))
|
|
823
|
+
|
|
824
|
+
const verify = () => {
|
|
825
|
+
const currentRoot = lstatSync(root, { bigint: true })
|
|
826
|
+
invariant(
|
|
827
|
+
sameIdentity(currentRoot, rootInfo)
|
|
828
|
+
&& currentRoot.isDirectory()
|
|
829
|
+
&& !currentRoot.isSymbolicLink()
|
|
830
|
+
&& (currentRoot.mode & 0o777n) === 0o500n,
|
|
831
|
+
'Closed hook tool profile root changed after it was authenticated',
|
|
832
|
+
)
|
|
833
|
+
invariant(
|
|
834
|
+
sameDirectoryIdentity(directoryDescriptor(materializedHome), homeInfo)
|
|
835
|
+
&& sameDirectoryIdentity(directoryDescriptor(materializedTemp), tempInfo),
|
|
836
|
+
'Closed hook private HOME or TMPDIR changed after it was authenticated',
|
|
837
|
+
)
|
|
838
|
+
for (const [label, record] of Object.entries(executableRecords)) {
|
|
839
|
+
const sourceInfo = lstatSync(record.source, { bigint: true })
|
|
840
|
+
invariant(
|
|
841
|
+
sameIdentity(sourceInfo, record.sourceInfo),
|
|
842
|
+
`Closed ${label} executable source changed after it was authenticated`,
|
|
843
|
+
)
|
|
844
|
+
const link = linkRecords[label]
|
|
845
|
+
const linkInfo = lstatSync(link.path, { bigint: true })
|
|
846
|
+
invariant(
|
|
847
|
+
sameIdentity(linkInfo, link.info)
|
|
848
|
+
&& linkInfo.isSymbolicLink()
|
|
849
|
+
&& readlinkSync(link.path) === link.target,
|
|
850
|
+
`Closed ${label} executable alias changed after it was authenticated`,
|
|
851
|
+
)
|
|
852
|
+
}
|
|
853
|
+
for (const record of systemPaths) {
|
|
854
|
+
invariant(
|
|
855
|
+
sameIdentity(lstatSync(record.path, { bigint: true }), record.info),
|
|
856
|
+
`Closed hook system PATH entry changed after it was authenticated:${record.path}`,
|
|
857
|
+
)
|
|
858
|
+
}
|
|
859
|
+
return true
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
registerPrivateCleanup(root)
|
|
863
|
+
const profile = Object.freeze({
|
|
864
|
+
executablePath,
|
|
865
|
+
executables: Object.freeze(executablePaths),
|
|
866
|
+
homeDirectory: materializedHome,
|
|
867
|
+
profileSha256,
|
|
868
|
+
root,
|
|
869
|
+
runtimePlatform,
|
|
870
|
+
sources: Object.freeze(Object.fromEntries(
|
|
871
|
+
Object.entries(executableRecords).map(([label, record]) => [
|
|
872
|
+
label,
|
|
873
|
+
Object.freeze({ path: record.source, sha256: record.sha256 }),
|
|
874
|
+
]),
|
|
875
|
+
)),
|
|
876
|
+
tempDirectory: materializedTemp,
|
|
877
|
+
verify,
|
|
878
|
+
})
|
|
879
|
+
cachedHookToolPaths.set(cacheKey, profile)
|
|
880
|
+
profile.verify()
|
|
881
|
+
return profile
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
export function captureClosedGitHubToken({
|
|
885
|
+
token,
|
|
886
|
+
environment = process.env,
|
|
887
|
+
requireToken = true,
|
|
888
|
+
tokenEnvironmentName = 'GH_TOKEN',
|
|
889
|
+
} = {}) {
|
|
890
|
+
invariant(
|
|
891
|
+
tokenEnvironmentName === null || tokenEnvironmentName === 'GH_TOKEN' || tokenEnvironmentName === 'GITHUB_TOKEN',
|
|
892
|
+
'Closed GitHub CLI token authority name is unsupported',
|
|
893
|
+
)
|
|
894
|
+
if (token !== undefined) {
|
|
895
|
+
invariant(typeof token === 'string', 'Closed GitHub CLI token override must be a string')
|
|
896
|
+
}
|
|
897
|
+
const ambient = ['GH_TOKEN', 'GITHUB_TOKEN']
|
|
898
|
+
.filter(name => typeof environment?.[name] === 'string' && environment[name] !== '')
|
|
899
|
+
invariant(ambient.length <= 1, 'Closed GitHub CLI received multiple token authorities')
|
|
900
|
+
if (token !== undefined && token !== '') {
|
|
901
|
+
invariant(ambient.length === 0, 'Closed GitHub CLI explicit token conflicts with an ambient token authority')
|
|
902
|
+
} else if (ambient.length) {
|
|
903
|
+
invariant(
|
|
904
|
+
tokenEnvironmentName !== null && ambient[0] === tokenEnvironmentName,
|
|
905
|
+
`Closed GitHub CLI token authority must be ${tokenEnvironmentName ?? 'an explicit token'}`,
|
|
906
|
+
)
|
|
907
|
+
}
|
|
908
|
+
const candidate = token !== undefined && token !== ''
|
|
909
|
+
? token
|
|
910
|
+
: (ambient.length ? environment[ambient[0]] : null)
|
|
911
|
+
if (candidate === null) {
|
|
912
|
+
invariant(!requireToken, 'Closed GitHub CLI requires one explicit GH_TOKEN or GITHUB_TOKEN')
|
|
913
|
+
return null
|
|
914
|
+
}
|
|
915
|
+
invariant(
|
|
916
|
+
candidate.length >= 8
|
|
917
|
+
&& candidate.length <= 4096
|
|
918
|
+
&& !/[\0\r\n]/.test(candidate),
|
|
919
|
+
'Closed GitHub CLI token is malformed',
|
|
920
|
+
)
|
|
921
|
+
return candidate
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
export function runClosedGh(args, {
|
|
925
|
+
cwd,
|
|
926
|
+
environment = process.env,
|
|
927
|
+
executable,
|
|
928
|
+
input,
|
|
929
|
+
maxInputBytes = 16 * 1024 * 1024,
|
|
930
|
+
maxOutputBytes = 16 * 1024 * 1024,
|
|
931
|
+
output = 'capture',
|
|
932
|
+
repoRoot,
|
|
933
|
+
requireToken = true,
|
|
934
|
+
runtimePlatform = process.platform,
|
|
935
|
+
timeoutMs = 60_000,
|
|
936
|
+
token,
|
|
937
|
+
tokenEnvironmentName = 'GH_TOKEN',
|
|
938
|
+
} = {}) {
|
|
939
|
+
invariant(Array.isArray(args) && args.length > 0 && args.every(value => typeof value === 'string'), 'Closed GitHub CLI arguments must be a non-empty string array')
|
|
940
|
+
invariant(typeof cwd === 'string' && resolve(cwd) === cwd, 'Closed GitHub CLI cwd must be one absolute normalized path')
|
|
941
|
+
invariant(['buffer', 'capture', 'ignore'].includes(output), 'Closed GitHub CLI output mode is unsupported')
|
|
942
|
+
invariant(Number.isInteger(maxInputBytes) && maxInputBytes > 0 && maxInputBytes <= 64 * 1024 * 1024, 'Closed GitHub CLI input limit is outside the safe range')
|
|
943
|
+
invariant(Number.isInteger(maxOutputBytes) && maxOutputBytes > 0 && maxOutputBytes <= 128 * 1024 * 1024, 'Closed GitHub CLI output limit is outside the safe range')
|
|
944
|
+
invariant(Number.isInteger(timeoutMs) && timeoutMs > 0 && timeoutMs <= 120_000, 'Closed GitHub CLI timeout is outside the safe range')
|
|
945
|
+
invariant(
|
|
946
|
+
input === undefined || typeof input === 'string' || Buffer.isBuffer(input),
|
|
947
|
+
'Closed GitHub CLI input must be a string or Buffer',
|
|
948
|
+
)
|
|
949
|
+
invariant(
|
|
950
|
+
input === undefined || Buffer.byteLength(input) <= maxInputBytes,
|
|
951
|
+
'Closed GitHub CLI input exceeds the closed size limit',
|
|
952
|
+
)
|
|
953
|
+
const githubToken = captureClosedGitHubToken({
|
|
954
|
+
token,
|
|
955
|
+
environment,
|
|
956
|
+
requireToken,
|
|
957
|
+
tokenEnvironmentName,
|
|
958
|
+
})
|
|
959
|
+
const privateExecutable = materializeClosedGhExecutable({ executable, repoRoot, runtimePlatform })
|
|
960
|
+
const privateHome = dirname(privateExecutable)
|
|
961
|
+
return spawnSync(
|
|
962
|
+
privateExecutable,
|
|
963
|
+
[...CLOSED_GH_ARGUMENT_PREFIX, ...args],
|
|
964
|
+
{
|
|
965
|
+
cwd,
|
|
966
|
+
encoding: output === 'capture' ? 'utf8' : (output === 'buffer' ? null : undefined),
|
|
967
|
+
env: {
|
|
968
|
+
GH_HOST: 'github.com',
|
|
969
|
+
GH_CONFIG_DIR: privateHome,
|
|
970
|
+
GH_PAGER: 'cat',
|
|
971
|
+
GH_PROMPT_DISABLED: '1',
|
|
972
|
+
...(githubToken ? { GH_TOKEN: githubToken } : {}),
|
|
973
|
+
HOME: privateHome,
|
|
974
|
+
LANG: 'C',
|
|
975
|
+
LC_ALL: 'C',
|
|
976
|
+
NO_COLOR: '1',
|
|
977
|
+
PAGER: 'cat',
|
|
978
|
+
PATH: '/usr/bin:/bin',
|
|
979
|
+
XDG_CONFIG_HOME: privateHome,
|
|
980
|
+
},
|
|
981
|
+
input,
|
|
982
|
+
maxBuffer: maxOutputBytes,
|
|
983
|
+
shell: false,
|
|
984
|
+
stdio: output === 'ignore' ? 'ignore' : ['pipe', 'pipe', 'pipe'],
|
|
985
|
+
timeout: timeoutMs,
|
|
986
|
+
windowsHide: true,
|
|
987
|
+
},
|
|
988
|
+
)
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
export function closedToolExecutionPaths() {
|
|
992
|
+
return Object.freeze({
|
|
993
|
+
git: resolveClosedGitExecutable(),
|
|
994
|
+
gh: [...cachedGhExecutables.values()].map(record => record.executable).sort(),
|
|
995
|
+
hookProfiles: [...cachedHookToolPaths.values()].map(record => Object.freeze({
|
|
996
|
+
executablePath: record.executablePath,
|
|
997
|
+
profileSha256: record.profileSha256,
|
|
998
|
+
root: record.root,
|
|
999
|
+
})),
|
|
1000
|
+
})
|
|
1001
|
+
}
|