@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
package/src/common.mjs
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto'
|
|
2
|
+
import { readFileSync } from 'node:fs'
|
|
3
|
+
import {
|
|
4
|
+
chmod,
|
|
5
|
+
lstat,
|
|
6
|
+
mkdir,
|
|
7
|
+
realpath,
|
|
8
|
+
readFile,
|
|
9
|
+
readdir,
|
|
10
|
+
readlink,
|
|
11
|
+
rename,
|
|
12
|
+
rm,
|
|
13
|
+
writeFile,
|
|
14
|
+
} from 'node:fs/promises'
|
|
15
|
+
import { dirname, isAbsolute, relative, resolve, sep } from 'node:path'
|
|
16
|
+
import { fileURLToPath } from 'node:url'
|
|
17
|
+
import { compareUtf8Bytes } from './canonical-order.mjs'
|
|
18
|
+
|
|
19
|
+
export { compareUtf8Bytes } from './canonical-order.mjs'
|
|
20
|
+
|
|
21
|
+
export const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
|
22
|
+
export const DEFAULT_MANIFEST = resolve(PACKAGE_ROOT, 'canonical/manifest.json')
|
|
23
|
+
export const PACKAGE_VERSION = JSON.parse(readFileSync(resolve(PACKAGE_ROOT, 'package.json'), 'utf8')).version
|
|
24
|
+
|
|
25
|
+
const severityOrder = new Map([
|
|
26
|
+
['critical', 0],
|
|
27
|
+
['major', 1],
|
|
28
|
+
['minor', 2],
|
|
29
|
+
['info', 3],
|
|
30
|
+
])
|
|
31
|
+
|
|
32
|
+
export function canonicalize(value) {
|
|
33
|
+
if (Array.isArray(value)) return value.map(canonicalize)
|
|
34
|
+
if (value && typeof value === 'object') {
|
|
35
|
+
const out = {}
|
|
36
|
+
for (const key of Object.keys(value).sort(compareUtf8Bytes)) {
|
|
37
|
+
if (value[key] !== undefined) out[key] = canonicalize(value[key])
|
|
38
|
+
}
|
|
39
|
+
return out
|
|
40
|
+
}
|
|
41
|
+
return value
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function stableJson(value) {
|
|
45
|
+
return JSON.stringify(canonicalize(value), null, 2) + '\n'
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function digest(value) {
|
|
49
|
+
const body = Buffer.isBuffer(value) ? value : Buffer.from(typeof value === 'string' ? value : stableJson(value))
|
|
50
|
+
return `sha256:${createHash('sha256').update(body).digest('hex')}`
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function readJson(file) {
|
|
54
|
+
return JSON.parse(await readFile(file, 'utf8'))
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export async function exists(file) {
|
|
58
|
+
try {
|
|
59
|
+
await lstat(file)
|
|
60
|
+
return true
|
|
61
|
+
} catch (error) {
|
|
62
|
+
if (error?.code === 'ENOENT') return false
|
|
63
|
+
throw error
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function relativePath(value, label = 'path') {
|
|
68
|
+
if (typeof value !== 'string' || value.trim() === '' || isAbsolute(value)) {
|
|
69
|
+
throw new Error(`${label} must be a non-empty repository-relative path`)
|
|
70
|
+
}
|
|
71
|
+
const normalized = value.replaceAll('\\', '/').replace(/^\.\//, '').replace(/\/+$/, '')
|
|
72
|
+
if (!normalized || normalized.split('/').some((part) => part === '..' || part === '')) {
|
|
73
|
+
throw new Error(`${label} escapes its repository root: ${value}`)
|
|
74
|
+
}
|
|
75
|
+
return normalized
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function resolveWithin(root, value, label = 'path') {
|
|
79
|
+
const rel = relativePath(value, label)
|
|
80
|
+
const absolute = resolve(root, rel)
|
|
81
|
+
const rootPrefix = resolve(root) + sep
|
|
82
|
+
if (absolute !== resolve(root) && !absolute.startsWith(rootPrefix)) throw new Error(`${label} escapes its repository root: ${value}`)
|
|
83
|
+
return absolute
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// `resolveWithin` proves lexical containment only. Repository paths also need a
|
|
87
|
+
// no-symlink walk before they are used as control-plane inputs or write targets;
|
|
88
|
+
// otherwise an in-repo parent symlink could redirect a trusted operation outside
|
|
89
|
+
// the repository after the lexical check has passed.
|
|
90
|
+
export async function assertNoSymlinkWithin(root, target, label = 'path', { allowMissing = true } = {}) {
|
|
91
|
+
const rootPath = resolve(root)
|
|
92
|
+
const targetPath = resolve(target)
|
|
93
|
+
const rel = relative(rootPath, targetPath)
|
|
94
|
+
if (rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
|
|
95
|
+
throw new Error(`${label} escapes its repository root: ${target}`)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const realRoot = await realpath(rootPath)
|
|
99
|
+
let current = realRoot
|
|
100
|
+
const parts = rel ? rel.split(sep) : []
|
|
101
|
+
for (let index = 0; index < parts.length; index += 1) {
|
|
102
|
+
current = resolve(current, parts[index])
|
|
103
|
+
let info
|
|
104
|
+
try {
|
|
105
|
+
info = await lstat(current)
|
|
106
|
+
} catch (error) {
|
|
107
|
+
if (error?.code === 'ENOENT' && allowMissing) return targetPath
|
|
108
|
+
throw error
|
|
109
|
+
}
|
|
110
|
+
if (info.isSymbolicLink()) throw new Error(`${label} contains a symbolic-link path component: ${relative(realRoot, current)}`)
|
|
111
|
+
if (index < parts.length - 1 && !info.isDirectory()) {
|
|
112
|
+
throw new Error(`${label} contains a non-directory path component: ${relative(realRoot, current)}`)
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return targetPath
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export async function assertRegularFileWithin(root, target, label = 'file') {
|
|
119
|
+
const targetPath = await assertNoSymlinkWithin(root, target, label, { allowMissing: false })
|
|
120
|
+
const info = await lstat(targetPath)
|
|
121
|
+
if (!info.isFile()) throw new Error(`${label} must be a regular file`)
|
|
122
|
+
return targetPath
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function modeString(mode) {
|
|
126
|
+
return (mode & 0o777).toString(8).padStart(4, '0')
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function diagnostic({ ruleId, severity = 'major', kind, path = '', expected, actual, outcome = 'FAIL', message }) {
|
|
130
|
+
return {
|
|
131
|
+
ruleId,
|
|
132
|
+
severity,
|
|
133
|
+
evidence: canonicalize({ kind, path, expected, actual }),
|
|
134
|
+
outcome,
|
|
135
|
+
message,
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function sortDiagnostics(items) {
|
|
140
|
+
return [...items].sort((a, b) =>
|
|
141
|
+
(severityOrder.get(a.severity) ?? 99) - (severityOrder.get(b.severity) ?? 99)
|
|
142
|
+
|| compareUtf8Bytes(a.ruleId, b.ruleId)
|
|
143
|
+
|| compareUtf8Bytes(a.evidence?.path || '', b.evidence?.path || '')
|
|
144
|
+
|| compareUtf8Bytes(a.message, b.message))
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function result(command, diagnostics = [], data = undefined, forcedOutcome = undefined) {
|
|
148
|
+
const sorted = sortDiagnostics(diagnostics)
|
|
149
|
+
const hasFailure = sorted.some((item) => ['FAIL', 'BLOCK', 'ERROR'].includes(item.outcome))
|
|
150
|
+
return canonicalize({
|
|
151
|
+
schemaVersion: 1,
|
|
152
|
+
command,
|
|
153
|
+
outcome: forcedOutcome || (hasFailure ? 'FAIL' : 'PASS'),
|
|
154
|
+
diagnostics: sorted,
|
|
155
|
+
data,
|
|
156
|
+
})
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function humanResult(value) {
|
|
160
|
+
const lines = [`${value.outcome === 'PASS' || value.outcome === 'APPLIED' || value.outcome === 'PLAN' ? '✅' : '❌'} ${value.command}: ${value.outcome}`]
|
|
161
|
+
for (const item of value.diagnostics) {
|
|
162
|
+
const at = item.evidence?.path ? ` (${item.evidence.path})` : ''
|
|
163
|
+
lines.push(` [${item.severity.toUpperCase()}] ${item.ruleId}${at}: ${item.message}`)
|
|
164
|
+
}
|
|
165
|
+
return lines.join('\n')
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export async function writeFileWithMode(file, content, mode = 0o644) {
|
|
169
|
+
await mkdir(dirname(file), { recursive: true })
|
|
170
|
+
await writeFile(file, content)
|
|
171
|
+
await chmod(file, mode)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export async function writeJsonAtomic(file, value, mode = 0o644) {
|
|
175
|
+
await mkdir(dirname(file), { recursive: true })
|
|
176
|
+
const temporary = `${file}.tmp-${process.pid}-${randomUUID()}`
|
|
177
|
+
try {
|
|
178
|
+
await writeFileWithMode(temporary, stableJson(value), mode)
|
|
179
|
+
await rename(temporary, file)
|
|
180
|
+
} finally {
|
|
181
|
+
await rm(temporary, { force: true }).catch(() => {})
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export async function inventoryTree(root, { excludes = [], projectFile = null } = {}) {
|
|
186
|
+
if (!(await exists(root))) return []
|
|
187
|
+
const rootInfo = await lstat(root)
|
|
188
|
+
if (!rootInfo.isDirectory() || rootInfo.isSymbolicLink()) {
|
|
189
|
+
throw new Error(`inventory root must be a regular directory, not ${rootInfo.isSymbolicLink() ? 'a symbolic link' : 'another file type'}: ${root}`)
|
|
190
|
+
}
|
|
191
|
+
const excludedRoots = excludes.map((value, index) => relativePath(value, `inventory exclusion ${index}`))
|
|
192
|
+
if (new Set(excludedRoots).size !== excludedRoots.length) throw new Error('inventory exclusions must be unique')
|
|
193
|
+
const isExcluded = path => excludedRoots.some(excluded => path === excluded || path.startsWith(`${excluded}/`))
|
|
194
|
+
const entries = []
|
|
195
|
+
|
|
196
|
+
async function visit(directory, prefix) {
|
|
197
|
+
const children = await readdir(directory, { withFileTypes: true })
|
|
198
|
+
children.sort((a, b) => compareUtf8Bytes(a.name, b.name))
|
|
199
|
+
for (const child of children) {
|
|
200
|
+
const absolute = resolve(directory, child.name)
|
|
201
|
+
const rel = prefix ? `${prefix}/${child.name}` : child.name
|
|
202
|
+
if (isExcluded(rel)) continue
|
|
203
|
+
const info = await lstat(absolute)
|
|
204
|
+
if (child.isDirectory()) {
|
|
205
|
+
await visit(absolute, rel)
|
|
206
|
+
} else if (child.isSymbolicLink()) {
|
|
207
|
+
const target = await readlink(absolute)
|
|
208
|
+
entries.push({ path: rel, type: 'symlink', mode: modeString(info.mode), hash: digest(target), target })
|
|
209
|
+
} else if (child.isFile()) {
|
|
210
|
+
const body = await readFile(absolute)
|
|
211
|
+
const projected = projectFile === null ? body : await projectFile({ path: rel, bytes: body })
|
|
212
|
+
if (!Buffer.isBuffer(projected)) throw new TypeError(`inventory file projection must return a Buffer:${rel}`)
|
|
213
|
+
entries.push({ path: rel, type: 'file', mode: modeString(info.mode), hash: digest(projected), size: projected.length })
|
|
214
|
+
} else {
|
|
215
|
+
entries.push({ path: rel, type: 'unsupported', mode: modeString(info.mode), hash: null })
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
await visit(root, '')
|
|
221
|
+
return entries
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export function treeDigest(entries) {
|
|
225
|
+
return digest(entries.map(({ path, type, mode, hash, target }) => canonicalize({ path, type, mode, hash, target })))
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export function compareInventories(expected, actual, { prefix = '', ruleId = 'GOV-SNAPSHOT-001' } = {}) {
|
|
229
|
+
const diagnostics = []
|
|
230
|
+
const expectedByPath = new Map(expected.map((entry) => [entry.path, entry]))
|
|
231
|
+
const actualByPath = new Map(actual.map((entry) => [entry.path, entry]))
|
|
232
|
+
const displayPath = (path) => prefix ? `${prefix.replace(/\/$/, '')}/${path}` : path
|
|
233
|
+
|
|
234
|
+
for (const [path, wanted] of expectedByPath) {
|
|
235
|
+
const got = actualByPath.get(path)
|
|
236
|
+
if (!got) {
|
|
237
|
+
diagnostics.push(diagnostic({ ruleId, severity: 'critical', kind: 'missing-file', path: displayPath(path), expected: wanted.hash, actual: null, message: 'Generated file is missing.' }))
|
|
238
|
+
continue
|
|
239
|
+
}
|
|
240
|
+
if (wanted.type !== got.type) {
|
|
241
|
+
diagnostics.push(diagnostic({ ruleId, severity: 'critical', kind: 'type-mismatch', path: displayPath(path), expected: wanted.type, actual: got.type, message: 'Generated path type differs from the canonical snapshot.' }))
|
|
242
|
+
continue
|
|
243
|
+
}
|
|
244
|
+
if (wanted.hash !== got.hash) diagnostics.push(diagnostic({ ruleId, severity: 'critical', kind: 'content-hash-mismatch', path: displayPath(path), expected: wanted.hash, actual: got.hash, message: 'Generated file content differs from the canonical snapshot.' }))
|
|
245
|
+
if (wanted.mode !== got.mode) diagnostics.push(diagnostic({ ruleId, severity: 'major', kind: 'mode-mismatch', path: displayPath(path), expected: wanted.mode, actual: got.mode, message: 'Generated file mode differs from the canonical snapshot.' }))
|
|
246
|
+
}
|
|
247
|
+
for (const [path, got] of actualByPath) {
|
|
248
|
+
if (!expectedByPath.has(path)) diagnostics.push(diagnostic({ ruleId, severity: 'critical', kind: 'extra-file', path: displayPath(path), expected: null, actual: got.hash, message: 'Unexpected file exists in the generated snapshot.' }))
|
|
249
|
+
}
|
|
250
|
+
return diagnostics
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export async function inventoryFile(file, displayPath) {
|
|
254
|
+
if (!(await exists(file))) return []
|
|
255
|
+
const info = await lstat(file)
|
|
256
|
+
if (!info.isFile()) return [{ path: displayPath, type: info.isSymbolicLink() ? 'symlink' : 'unsupported', mode: modeString(info.mode), hash: null }]
|
|
257
|
+
const body = await readFile(file)
|
|
258
|
+
return [{ path: displayPath, type: 'file', mode: modeString(info.mode), hash: digest(body), size: body.length }]
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export async function readStdin() {
|
|
262
|
+
const chunks = []
|
|
263
|
+
for await (const chunk of process.stdin) chunks.push(chunk)
|
|
264
|
+
return Buffer.concat(chunks).toString('utf8')
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
export function getAtPath(value, dottedPath) {
|
|
268
|
+
let current = value
|
|
269
|
+
for (const part of dottedPath.split('.')) {
|
|
270
|
+
if (current == null) return undefined
|
|
271
|
+
current = current[part]
|
|
272
|
+
}
|
|
273
|
+
return current
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
export function unique(values) {
|
|
277
|
+
return [...new Set(values)]
|
|
278
|
+
}
|