@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.
Files changed (40) hide show
  1. package/README.md +89 -0
  2. package/bin/attest.mjs +4 -0
  3. package/bin/check.mjs +4 -0
  4. package/bin/doctor.mjs +4 -0
  5. package/bin/generate.mjs +4 -0
  6. package/bin/governance.mjs +4 -0
  7. package/bin/hook.mjs +4 -0
  8. package/bin/upgrade.mjs +4 -0
  9. package/canonical/gates.json +42 -0
  10. package/canonical/manifest.json +476 -0
  11. package/canonical/plugin-aliases.json +25 -0
  12. package/canonical/provider-lifecycle.json +48 -0
  13. package/canonical/providers.json +455 -0
  14. package/canonical/roles.json +12 -0
  15. package/canonical/rules.json +81 -0
  16. package/canonical/schemas/attestation.schema.json +61 -0
  17. package/canonical/schemas/diagnostic.schema.json +37 -0
  18. package/canonical/schemas/gates.schema.json +27 -0
  19. package/canonical/schemas/lock.schema.json +189 -0
  20. package/canonical/schemas/manifest.schema.json +103 -0
  21. package/canonical/schemas/plugin-aliases.schema.json +59 -0
  22. package/canonical/schemas/provider-hook-coverage.schema.json +173 -0
  23. package/canonical/schemas/provider-lifecycle.schema.json +126 -0
  24. package/canonical/schemas/providers.schema.json +917 -0
  25. package/canonical/schemas/roles.schema.json +27 -0
  26. package/canonical/schemas/rules.schema.json +46 -0
  27. package/canonical/schemas/upgrade-plan.schema.json +31 -0
  28. package/package.json +42 -0
  29. package/src/authority-decision-evidence.mjs +413 -0
  30. package/src/canonical-order.mjs +8 -0
  31. package/src/carrier-projection.mjs +407 -0
  32. package/src/cli.mjs +114 -0
  33. package/src/closed-tool-execution.mjs +1001 -0
  34. package/src/common.mjs +278 -0
  35. package/src/contract.mjs +760 -0
  36. package/src/hook-api.mjs +107 -0
  37. package/src/index.mjs +14 -0
  38. package/src/provider-hook-normalization.mjs +1646 -0
  39. package/src/provider-review-binding.mjs +2377 -0
  40. 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
+ }