@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/snapshot.mjs
ADDED
|
@@ -0,0 +1,520 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto'
|
|
2
|
+
import { realpathSync } from 'node:fs'
|
|
3
|
+
import { cp, mkdir, readFile, rename, rm } from 'node:fs/promises'
|
|
4
|
+
import { basename, dirname, resolve } from 'node:path'
|
|
5
|
+
import { mkdtemp } from 'node:fs/promises'
|
|
6
|
+
import { tmpdir } from 'node:os'
|
|
7
|
+
import {
|
|
8
|
+
PACKAGE_VERSION,
|
|
9
|
+
assertNoSymlinkWithin,
|
|
10
|
+
canonicalize,
|
|
11
|
+
compareUtf8Bytes,
|
|
12
|
+
compareInventories,
|
|
13
|
+
diagnostic,
|
|
14
|
+
digest,
|
|
15
|
+
exists,
|
|
16
|
+
inventoryFile,
|
|
17
|
+
inventoryTree,
|
|
18
|
+
resolveWithin,
|
|
19
|
+
result,
|
|
20
|
+
stableJson,
|
|
21
|
+
treeDigest,
|
|
22
|
+
writeFileWithMode,
|
|
23
|
+
writeJsonAtomic,
|
|
24
|
+
} from './common.mjs'
|
|
25
|
+
import {
|
|
26
|
+
assertArtifactSchema,
|
|
27
|
+
inspectContract,
|
|
28
|
+
inspectSkillParity,
|
|
29
|
+
sourceRecords,
|
|
30
|
+
} from './contract.mjs'
|
|
31
|
+
import {
|
|
32
|
+
assertClosedGitLocalConfiguration,
|
|
33
|
+
runClosedGit,
|
|
34
|
+
} from './closed-tool-execution.mjs'
|
|
35
|
+
|
|
36
|
+
function provenance(contract) {
|
|
37
|
+
return {
|
|
38
|
+
generated: true,
|
|
39
|
+
generator: '@qijenchen/governance',
|
|
40
|
+
generatorVersion: PACKAGE_VERSION,
|
|
41
|
+
manifestId: contract.manifest.id,
|
|
42
|
+
contractDigest: contract.contractDigest,
|
|
43
|
+
role: contract.roleId,
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function hasFailure(diagnostics) {
|
|
48
|
+
return diagnostics.some((item) => ['FAIL', 'BLOCK', 'ERROR'].includes(item.outcome))
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function applicableRules(contract) {
|
|
52
|
+
return contract.rules
|
|
53
|
+
.filter((rule) => rule.appliesToRoles?.includes('*') || rule.appliesToRoles?.includes(contract.roleId))
|
|
54
|
+
.map((rule) => ({
|
|
55
|
+
ruleId: rule.ruleId,
|
|
56
|
+
severity: rule.severity,
|
|
57
|
+
source: rule.source,
|
|
58
|
+
blocking: rule.blocking,
|
|
59
|
+
gateIds: rule.gateIds,
|
|
60
|
+
providerCoverage: rule.providerCoverage,
|
|
61
|
+
}))
|
|
62
|
+
.sort((a, b) => compareUtf8Bytes(a.ruleId, b.ruleId))
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function providerAdapter(contract, provider) {
|
|
66
|
+
return `#!/usr/bin/env node
|
|
67
|
+
// @generated by @qijenchen/governance ${PACKAGE_VERSION}; role=${contract.roleId}; contract=${contract.contractDigest}; DO NOT EDIT.
|
|
68
|
+
import { runHookCli } from '@qijenchen/governance/hook'
|
|
69
|
+
|
|
70
|
+
await runHookCli({ provider: ${JSON.stringify(provider.id)}, role: ${JSON.stringify(contract.roleId)} })
|
|
71
|
+
`
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function provenanceMarkdown(contract, sources, skillParity) {
|
|
75
|
+
const sourceRows = sources.map((source) => `| ${source.id} | \`${source.path}\` | \`${source.hash}\` |`).join('\n')
|
|
76
|
+
const parityRows = skillParity.map((item) => `| ${item.id} | ${item.sourceProvider} \`${item.sourceDirectory}\` | ${item.targetProvider} \`${item.targetDirectory}\` | ${item.sourceSkills.length} shared + ${item.requiredTargetExtras.length} target-only |`).join('\n')
|
|
77
|
+
return `<!-- @generated by @qijenchen/governance ${PACKAGE_VERSION}; role=${contract.roleId}; contract=${contract.contractDigest}; DO NOT EDIT. -->
|
|
78
|
+
# Governance snapshot provenance
|
|
79
|
+
|
|
80
|
+
- Manifest: \`${contract.manifest.id}\`
|
|
81
|
+
- Role: \`${contract.roleId}\`
|
|
82
|
+
- Contract digest: \`${contract.contractDigest}\`
|
|
83
|
+
- Generator: \`@qijenchen/governance@${PACKAGE_VERSION}\`
|
|
84
|
+
|
|
85
|
+
No rule prose is copied into this snapshot. Paths and digests point to their canonical owners.
|
|
86
|
+
|
|
87
|
+
## Canonical inputs
|
|
88
|
+
|
|
89
|
+
| ID | Owner path | Digest |
|
|
90
|
+
|---|---|---|
|
|
91
|
+
${sourceRows}
|
|
92
|
+
|
|
93
|
+
## Provider skill parity
|
|
94
|
+
|
|
95
|
+
| Contract | Source | Target | Expected surface |
|
|
96
|
+
|---|---|---|---|
|
|
97
|
+
${parityRows || '| — | — | — | — |'}
|
|
98
|
+
`
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export async function buildExpectedSnapshot(contract, temporaryRepoRoot) {
|
|
102
|
+
const output = resolveWithin(temporaryRepoRoot, contract.outputDirectory, 'outputDirectory')
|
|
103
|
+
const lock = resolveWithin(temporaryRepoRoot, contract.lockFile, 'lockFile')
|
|
104
|
+
await rm(output, { recursive: true, force: true })
|
|
105
|
+
await rm(lock, { force: true })
|
|
106
|
+
await mkdir(output, { recursive: true })
|
|
107
|
+
|
|
108
|
+
const sources = await sourceRecords(contract)
|
|
109
|
+
const parity = await inspectSkillParity(contract)
|
|
110
|
+
const roleProviders = (contract.role?.requiredProviders || [])
|
|
111
|
+
.map((id) => contract.providers.find((provider) => provider.id === id))
|
|
112
|
+
.filter(Boolean)
|
|
113
|
+
const roleGates = (contract.role?.requiredGateIds || [])
|
|
114
|
+
.map((id) => contract.gates.find((gate) => gate.id === id))
|
|
115
|
+
.filter(Boolean)
|
|
116
|
+
const generatedProvenance = provenance(contract)
|
|
117
|
+
|
|
118
|
+
const controlPlane = {
|
|
119
|
+
_provenance: generatedProvenance,
|
|
120
|
+
schemaVersion: 1,
|
|
121
|
+
role: contract.roleId,
|
|
122
|
+
canonicalSources: sources,
|
|
123
|
+
rules: applicableRules(contract),
|
|
124
|
+
providers: roleProviders.map((provider) => provider.id),
|
|
125
|
+
gates: roleGates.map((gate) => gate.id),
|
|
126
|
+
skillParity: parity.records,
|
|
127
|
+
registryDigests: contract.registryDigests,
|
|
128
|
+
schemaDigests: contract.schemaDigests,
|
|
129
|
+
}
|
|
130
|
+
await writeFileWithMode(resolve(output, 'control-plane.json'), stableJson(controlPlane), 0o644)
|
|
131
|
+
|
|
132
|
+
for (const provider of roleProviders) {
|
|
133
|
+
await writeFileWithMode(resolve(output, 'providers', `${provider.id}.json`), stableJson({
|
|
134
|
+
_provenance: generatedProvenance,
|
|
135
|
+
schemaVersion: 1,
|
|
136
|
+
provider,
|
|
137
|
+
applicableRules: applicableRules(contract).map((rule) => ({ ruleId: rule.ruleId, coverage: rule.providerCoverage[provider.id] })),
|
|
138
|
+
}), 0o644)
|
|
139
|
+
await writeFileWithMode(resolve(output, 'adapters', `${provider.id}-hook.mjs`), providerAdapter(contract, provider), 0o755)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
await writeFileWithMode(resolve(output, 'PROVENANCE.md'), provenanceMarkdown(contract, sources, parity.records), 0o644)
|
|
143
|
+
const files = await inventoryTree(output)
|
|
144
|
+
const inputDigest = digest({
|
|
145
|
+
contractDigest: contract.contractDigest,
|
|
146
|
+
registryDigests: contract.registryDigests,
|
|
147
|
+
sources,
|
|
148
|
+
skillParity: parity.records,
|
|
149
|
+
})
|
|
150
|
+
const lockBody = {
|
|
151
|
+
_provenance: generatedProvenance,
|
|
152
|
+
schemaVersion: 2,
|
|
153
|
+
role: contract.roleId,
|
|
154
|
+
outputDirectory: contract.outputDirectory,
|
|
155
|
+
contractDigest: contract.contractDigest,
|
|
156
|
+
inputDigest,
|
|
157
|
+
snapshotDigest: treeDigest(files),
|
|
158
|
+
sources,
|
|
159
|
+
skillParity: parity.records,
|
|
160
|
+
files,
|
|
161
|
+
}
|
|
162
|
+
await assertArtifactSchema(contract, 'lock', lockBody)
|
|
163
|
+
await writeFileWithMode(lock, stableJson(lockBody), 0o644)
|
|
164
|
+
return { output, lock, files, lockBody, parityDiagnostics: parity.diagnostics }
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function withExpected(contract, callback) {
|
|
168
|
+
const temporary = await mkdtemp(resolve(tmpdir(), 'qijenchen-governance-'))
|
|
169
|
+
try {
|
|
170
|
+
const expected = await buildExpectedSnapshot(contract, temporary)
|
|
171
|
+
return await callback(expected, temporary)
|
|
172
|
+
} finally {
|
|
173
|
+
await rm(temporary, { recursive: true, force: true })
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async function checkWithContract(contract) {
|
|
178
|
+
return withExpected(contract, async (expected) => {
|
|
179
|
+
const actualOutput = resolveWithin(contract.repoRoot, contract.outputDirectory, 'outputDirectory')
|
|
180
|
+
const actualLock = resolveWithin(contract.repoRoot, contract.lockFile, 'lockFile')
|
|
181
|
+
const expectedInventory = await inventoryTree(expected.output)
|
|
182
|
+
const actualInventory = await inventoryTree(actualOutput)
|
|
183
|
+
const diagnostics = compareInventories(expectedInventory, actualInventory, { prefix: contract.outputDirectory })
|
|
184
|
+
const expectedLock = await inventoryFile(expected.lock, contract.lockFile)
|
|
185
|
+
const actualLockInventory = await inventoryFile(actualLock, contract.lockFile)
|
|
186
|
+
diagnostics.push(...compareInventories(expectedLock, actualLockInventory))
|
|
187
|
+
|
|
188
|
+
if (actualLockInventory.length) {
|
|
189
|
+
try {
|
|
190
|
+
const body = JSON.parse(await readFile(actualLock, 'utf8'))
|
|
191
|
+
const lockedFiles = Array.isArray(body.files) ? body.files : []
|
|
192
|
+
diagnostics.push(...compareInventories(lockedFiles, actualInventory, { prefix: contract.outputDirectory }))
|
|
193
|
+
} catch (error) {
|
|
194
|
+
diagnostics.push(diagnostic({ ruleId: 'GOV-SNAPSHOT-001', severity: 'critical', kind: 'lock-parse-error', path: contract.lockFile, message: `Governance lock is invalid JSON: ${error.message}` }))
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return { diagnostics, expectedDigest: await snapshotStateDigest(expected.output, expected.lock), actualDigest: await snapshotStateDigest(actualOutput, actualLock) }
|
|
198
|
+
})
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export async function checkRepository(options = {}) {
|
|
202
|
+
const inspected = await inspectContract(options)
|
|
203
|
+
if (!inspected.contract || hasFailure(inspected.diagnostics)) return result('check', inspected.diagnostics)
|
|
204
|
+
const parity = await inspectSkillParity(inspected.contract)
|
|
205
|
+
const checked = await checkWithContract(inspected.contract)
|
|
206
|
+
const diagnostics = [...inspected.diagnostics, ...parity.diagnostics, ...checked.diagnostics]
|
|
207
|
+
const hooksOff = Boolean(options.hooksOff)
|
|
208
|
+
if (!diagnostics.length) diagnostics.push(diagnostic({ ruleId: 'GOV-SNAPSHOT-001', severity: 'info', kind: 'snapshot-exact', path: inspected.contract.outputDirectory, outcome: 'PASS', expected: checked.expectedDigest, actual: checked.actualDigest, message: `Snapshot is exact${hooksOff ? ' with native hooks disabled' : ''}.` }))
|
|
209
|
+
return result('check', diagnostics, { role: inspected.contract.roleId, contractDigest: inspected.contract.contractDigest, snapshotDigest: checked.actualDigest, hooksOff })
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export async function doctorRepository(options = {}) {
|
|
213
|
+
const checked = await checkRepository(options)
|
|
214
|
+
const diagnostics = [...checked.diagnostics]
|
|
215
|
+
if (!diagnostics.some((item) => ['FAIL', 'BLOCK', 'ERROR'].includes(item.outcome))) {
|
|
216
|
+
diagnostics.push(diagnostic({ ruleId: 'GOV-CONTRACT-001', severity: 'info', kind: 'contract-valid', path: options.manifestPath || 'canonical/manifest.json', outcome: 'PASS', message: 'Canonical registries, source pointers, providers, gates, skills, snapshot, and lock are coherent.' }))
|
|
217
|
+
}
|
|
218
|
+
return result('doctor', diagnostics, checked.data)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async function copyExpectedToStage(expectedOutput, stageOutput) {
|
|
222
|
+
await cp(expectedOutput, stageOutput, { recursive: true, force: true, preserveTimestamps: false })
|
|
223
|
+
const entries = await inventoryTree(expectedOutput)
|
|
224
|
+
for (const entry of entries) {
|
|
225
|
+
if (entry.type === 'file') {
|
|
226
|
+
const mode = Number.parseInt(entry.mode, 8)
|
|
227
|
+
const { chmod } = await import('node:fs/promises')
|
|
228
|
+
await chmod(resolve(stageOutput, entry.path), mode)
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Exported from this module (but not the package entrypoint) so rollback can be
|
|
234
|
+
// fault-injected and proven without weakening the production CLI contract.
|
|
235
|
+
export async function installExpected(contract, expected, { transitionProbe } = {}) {
|
|
236
|
+
const targetOutput = resolveWithin(contract.repoRoot, contract.outputDirectory, 'outputDirectory')
|
|
237
|
+
const targetLock = resolveWithin(contract.repoRoot, contract.lockFile, 'lockFile')
|
|
238
|
+
await assertNoSymlinkWithin(contract.repoRoot, targetOutput, 'outputDirectory')
|
|
239
|
+
await assertNoSymlinkWithin(contract.repoRoot, targetLock, 'lockFile')
|
|
240
|
+
const token = `${process.pid}-${randomUUID()}`
|
|
241
|
+
const stageOutput = resolve(dirname(targetOutput), `.${basename(targetOutput)}.stage-${token}`)
|
|
242
|
+
const backupOutput = resolve(dirname(targetOutput), `.${basename(targetOutput)}.backup-${token}`)
|
|
243
|
+
const stageLock = resolve(dirname(targetLock), `.${basename(targetLock)}.stage-${token}`)
|
|
244
|
+
const backupLock = resolve(dirname(targetLock), `.${basename(targetLock)}.backup-${token}`)
|
|
245
|
+
let outputBackedUp = false
|
|
246
|
+
let lockBackedUp = false
|
|
247
|
+
let outputInstalled = false
|
|
248
|
+
let lockInstalled = false
|
|
249
|
+
try {
|
|
250
|
+
await mkdir(dirname(targetOutput), { recursive: true })
|
|
251
|
+
await mkdir(dirname(targetLock), { recursive: true })
|
|
252
|
+
await copyExpectedToStage(expected.output, stageOutput)
|
|
253
|
+
await cp(expected.lock, stageLock, { force: true })
|
|
254
|
+
if (await exists(targetOutput)) { await rename(targetOutput, backupOutput); outputBackedUp = true }
|
|
255
|
+
if (await exists(targetLock)) { await rename(targetLock, backupLock); lockBackedUp = true }
|
|
256
|
+
await rename(stageOutput, targetOutput); outputInstalled = true
|
|
257
|
+
if (transitionProbe) await transitionProbe('output-installed')
|
|
258
|
+
await rename(stageLock, targetLock); lockInstalled = true
|
|
259
|
+
} catch (error) {
|
|
260
|
+
const rollbackErrors = []
|
|
261
|
+
await rm(stageOutput, { recursive: true, force: true }).catch(() => {})
|
|
262
|
+
await rm(stageLock, { force: true }).catch(() => {})
|
|
263
|
+
try {
|
|
264
|
+
if (outputBackedUp) {
|
|
265
|
+
if (outputInstalled) await rm(targetOutput, { recursive: true, force: true })
|
|
266
|
+
await rename(backupOutput, targetOutput)
|
|
267
|
+
outputBackedUp = false
|
|
268
|
+
} else if (outputInstalled) {
|
|
269
|
+
await rm(targetOutput, { recursive: true, force: true })
|
|
270
|
+
}
|
|
271
|
+
} catch (rollbackError) {
|
|
272
|
+
rollbackErrors.push(rollbackError)
|
|
273
|
+
}
|
|
274
|
+
try {
|
|
275
|
+
if (lockBackedUp) {
|
|
276
|
+
if (lockInstalled) await rm(targetLock, { recursive: true, force: true })
|
|
277
|
+
await rename(backupLock, targetLock)
|
|
278
|
+
lockBackedUp = false
|
|
279
|
+
} else if (lockInstalled) {
|
|
280
|
+
await rm(targetLock, { recursive: true, force: true })
|
|
281
|
+
}
|
|
282
|
+
} catch (rollbackError) {
|
|
283
|
+
rollbackErrors.push(rollbackError)
|
|
284
|
+
}
|
|
285
|
+
if (rollbackErrors.length) throw new AggregateError([error, ...rollbackErrors], 'Governance install failed and rollback was incomplete')
|
|
286
|
+
throw error
|
|
287
|
+
} finally {
|
|
288
|
+
if (!outputBackedUp) await rm(backupOutput, { recursive: true, force: true }).catch(() => {})
|
|
289
|
+
if (!lockBackedUp) await rm(backupLock, { recursive: true, force: true }).catch(() => {})
|
|
290
|
+
}
|
|
291
|
+
await rm(backupOutput, { recursive: true, force: true }).catch(() => {})
|
|
292
|
+
await rm(backupLock, { recursive: true, force: true }).catch(() => {})
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
export async function generateRepository(options = {}) {
|
|
296
|
+
const inspected = await inspectContract(options)
|
|
297
|
+
if (!inspected.contract || hasFailure(inspected.diagnostics)) return result('generate', inspected.diagnostics)
|
|
298
|
+
const parity = await inspectSkillParity(inspected.contract)
|
|
299
|
+
const diagnostics = [...inspected.diagnostics, ...parity.diagnostics]
|
|
300
|
+
if (diagnostics.some((item) => ['FAIL', 'BLOCK', 'ERROR'].includes(item.outcome))) return result('generate', diagnostics)
|
|
301
|
+
return withExpected(inspected.contract, async (expected) => {
|
|
302
|
+
await installExpected(inspected.contract, expected)
|
|
303
|
+
const state = await snapshotState(inspected.contract)
|
|
304
|
+
diagnostics.push(diagnostic({ ruleId: 'GOV-SNAPSHOT-001', severity: 'info', kind: 'snapshot-generated', path: inspected.contract.outputDirectory, outcome: 'PASS', actual: state.digest, message: 'Canonical snapshot and committed lock were generated atomically.' }))
|
|
305
|
+
return result('generate', diagnostics, { role: inspected.contract.roleId, contractDigest: inspected.contract.contractDigest, snapshotDigest: state.digest })
|
|
306
|
+
})
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
async function snapshotStateDigest(output, lock) {
|
|
310
|
+
const files = await inventoryTree(output)
|
|
311
|
+
const lockEntries = await inventoryFile(lock, '__lock__')
|
|
312
|
+
return treeDigest([...files, ...lockEntries])
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
export async function snapshotState(contract) {
|
|
316
|
+
const output = resolveWithin(contract.repoRoot, contract.outputDirectory, 'outputDirectory')
|
|
317
|
+
const lock = resolveWithin(contract.repoRoot, contract.lockFile, 'lockFile')
|
|
318
|
+
const files = await inventoryTree(output)
|
|
319
|
+
const lockEntries = await inventoryFile(lock, '__lock__')
|
|
320
|
+
return { files, lockEntries, digest: treeDigest([...files, ...lockEntries]) }
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function actionFromDiagnostic(item) {
|
|
324
|
+
const map = {
|
|
325
|
+
'missing-file': 'add',
|
|
326
|
+
'extra-file': 'remove',
|
|
327
|
+
'content-hash-mismatch': 'replace-content',
|
|
328
|
+
'mode-mismatch': 'replace-mode',
|
|
329
|
+
'type-mismatch': 'replace-type',
|
|
330
|
+
}
|
|
331
|
+
const action = map[item.evidence?.kind]
|
|
332
|
+
return action ? { action, path: item.evidence.path } : null
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
export async function planUpgrade(options = {}) {
|
|
336
|
+
const inspected = await inspectContract(options)
|
|
337
|
+
if (!inspected.contract || hasFailure(inspected.diagnostics)) return result('upgrade.plan', inspected.diagnostics, undefined, 'FAIL')
|
|
338
|
+
const parity = await inspectSkillParity(inspected.contract)
|
|
339
|
+
const diagnostics = [...inspected.diagnostics, ...parity.diagnostics]
|
|
340
|
+
if (diagnostics.some((item) => ['FAIL', 'BLOCK', 'ERROR'].includes(item.outcome))) return result('upgrade.plan', diagnostics, undefined, 'FAIL')
|
|
341
|
+
const checked = await checkWithContract(inspected.contract)
|
|
342
|
+
const actions = checked.diagnostics.map(actionFromDiagnostic).filter(Boolean).sort((a, b) => compareUtf8Bytes(a.path, b.path) || compareUtf8Bytes(a.action, b.action))
|
|
343
|
+
const plan = canonicalize({
|
|
344
|
+
schemaVersion: 1,
|
|
345
|
+
command: 'upgrade.plan',
|
|
346
|
+
role: inspected.contract.roleId,
|
|
347
|
+
outputDirectory: inspected.contract.outputDirectory,
|
|
348
|
+
lockFile: inspected.contract.lockFile,
|
|
349
|
+
contractDigest: inspected.contract.contractDigest,
|
|
350
|
+
currentDigest: checked.actualDigest,
|
|
351
|
+
expectedDigest: checked.expectedDigest,
|
|
352
|
+
actions,
|
|
353
|
+
})
|
|
354
|
+
await assertArtifactSchema(inspected.contract, 'upgradePlan', plan)
|
|
355
|
+
diagnostics.push(diagnostic({ ruleId: 'GOV-UPGRADE-001', severity: 'info', kind: 'upgrade-plan', path: inspected.contract.outputDirectory, outcome: 'PLAN', expected: checked.expectedDigest, actual: checked.actualDigest, message: `Upgrade plan contains ${actions.length} action(s).` }))
|
|
356
|
+
if (options.planFile) {
|
|
357
|
+
const planFile = resolveWithin(inspected.contract.repoRoot, options.planFile, 'planFile')
|
|
358
|
+
await assertNoSymlinkWithin(inspected.contract.repoRoot, planFile, 'planFile')
|
|
359
|
+
await writeJsonAtomic(planFile, plan)
|
|
360
|
+
}
|
|
361
|
+
return result('upgrade.plan', diagnostics, { plan }, 'PLAN')
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
export async function applyUpgrade(options = {}) {
|
|
365
|
+
const inspected = await inspectContract(options)
|
|
366
|
+
if (!inspected.contract || hasFailure(inspected.diagnostics)) return result('upgrade.apply', inspected.diagnostics)
|
|
367
|
+
if (!options.planFile) return result('upgrade.apply', [diagnostic({ ruleId: 'GOV-UPGRADE-001', severity: 'critical', kind: 'plan-required', path: '', message: 'upgrade apply requires --plan-file.' })])
|
|
368
|
+
let plan
|
|
369
|
+
try {
|
|
370
|
+
const planFile = resolveWithin(inspected.contract.repoRoot, options.planFile, 'planFile')
|
|
371
|
+
await assertNoSymlinkWithin(inspected.contract.repoRoot, planFile, 'planFile', { allowMissing: false })
|
|
372
|
+
plan = JSON.parse(await readFile(planFile, 'utf8'))
|
|
373
|
+
await assertArtifactSchema(inspected.contract, 'upgradePlan', plan)
|
|
374
|
+
} catch (error) {
|
|
375
|
+
return result('upgrade.apply', [diagnostic({ ruleId: 'GOV-UPGRADE-001', severity: 'critical', kind: 'plan-read-error', path: options.planFile, message: `Cannot read upgrade plan: ${error.message}` })])
|
|
376
|
+
}
|
|
377
|
+
const current = await snapshotState(inspected.contract)
|
|
378
|
+
const diagnostics = [...inspected.diagnostics]
|
|
379
|
+
if (plan.command !== 'upgrade.plan') diagnostics.push(diagnostic({ ruleId: 'GOV-UPGRADE-001', severity: 'critical', kind: 'plan-schema', path: options.planFile, expected: 'upgrade.plan', actual: plan.command, message: 'File is not a governance upgrade plan.' }))
|
|
380
|
+
if (plan.contractDigest !== inspected.contract.contractDigest) diagnostics.push(diagnostic({ ruleId: 'GOV-UPGRADE-001', severity: 'critical', kind: 'contract-changed', path: options.planFile, expected: plan.contractDigest, actual: inspected.contract.contractDigest, message: 'Canonical contract changed after the plan was created.' }))
|
|
381
|
+
if (plan.currentDigest !== current.digest) diagnostics.push(diagnostic({ ruleId: 'GOV-UPGRADE-001', severity: 'critical', kind: 'stale-plan', path: inspected.contract.outputDirectory, expected: plan.currentDigest, actual: current.digest, message: 'Generated state changed after the plan was created.' }))
|
|
382
|
+
if (diagnostics.some((item) => ['FAIL', 'BLOCK', 'ERROR'].includes(item.outcome))) return result('upgrade.apply', diagnostics)
|
|
383
|
+
|
|
384
|
+
return withExpected(inspected.contract, async (expected) => {
|
|
385
|
+
const expectedDigest = await snapshotStateDigest(expected.output, expected.lock)
|
|
386
|
+
if (expectedDigest !== plan.expectedDigest) {
|
|
387
|
+
diagnostics.push(diagnostic({ ruleId: 'GOV-UPGRADE-001', severity: 'critical', kind: 'expected-state-changed', path: options.planFile, expected: plan.expectedDigest, actual: expectedDigest, message: 'Expected generated state changed after the plan was created.' }))
|
|
388
|
+
return result('upgrade.apply', diagnostics)
|
|
389
|
+
}
|
|
390
|
+
await installExpected(inspected.contract, expected)
|
|
391
|
+
diagnostics.push(diagnostic({ ruleId: 'GOV-UPGRADE-001', severity: 'info', kind: 'upgrade-applied', path: inspected.contract.outputDirectory, outcome: 'APPLIED', actual: expectedDigest, message: 'Validated upgrade plan was applied.' }))
|
|
392
|
+
return result('upgrade.apply', diagnostics, { snapshotDigest: expectedDigest }, 'APPLIED')
|
|
393
|
+
})
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
export async function attestRepository(options = {}) {
|
|
397
|
+
const doctor = await doctorRepository(options)
|
|
398
|
+
if (doctor.outcome !== 'PASS') return result('attest', doctor.diagnostics)
|
|
399
|
+
const inspected = await inspectContract(options)
|
|
400
|
+
const contract = inspected.contract
|
|
401
|
+
const state = await snapshotState(contract)
|
|
402
|
+
let gitHead = null
|
|
403
|
+
let gitTree = null
|
|
404
|
+
let inGitRepository = false
|
|
405
|
+
try {
|
|
406
|
+
const probe = runClosedGit(['rev-parse', '--is-inside-work-tree'], {
|
|
407
|
+
cwd: contract.repoRoot,
|
|
408
|
+
timeoutMs: 5000,
|
|
409
|
+
})
|
|
410
|
+
inGitRepository = probe.status === 0 && probe.stdout.trim() === 'true'
|
|
411
|
+
} catch (error) {
|
|
412
|
+
return result('attest', [diagnostic({
|
|
413
|
+
ruleId: 'GOV-ATTEST-001',
|
|
414
|
+
severity: 'critical',
|
|
415
|
+
kind: 'closed-git-unavailable',
|
|
416
|
+
path: '',
|
|
417
|
+
message: `Cannot establish the closed Git runtime required for attestation: ${error.message}`,
|
|
418
|
+
})])
|
|
419
|
+
}
|
|
420
|
+
if (inGitRepository) {
|
|
421
|
+
try {
|
|
422
|
+
assertClosedGitLocalConfiguration(contract.repoRoot)
|
|
423
|
+
} catch (error) {
|
|
424
|
+
return result('attest', [diagnostic({
|
|
425
|
+
ruleId: 'GOV-ATTEST-001',
|
|
426
|
+
severity: 'critical',
|
|
427
|
+
kind: 'unsafe-git-configuration',
|
|
428
|
+
path: '',
|
|
429
|
+
message: error.message,
|
|
430
|
+
})])
|
|
431
|
+
}
|
|
432
|
+
const identity = runClosedGit(['rev-parse', '--show-toplevel', 'HEAD', 'HEAD^{tree}'], {
|
|
433
|
+
cwd: contract.repoRoot,
|
|
434
|
+
timeoutMs: 5000,
|
|
435
|
+
})
|
|
436
|
+
const identityLines = identity.status === 0 ? identity.stdout.trim().split(/\r?\n/) : []
|
|
437
|
+
let exactRepositoryRoot = false
|
|
438
|
+
try {
|
|
439
|
+
exactRepositoryRoot = identityLines.length > 0
|
|
440
|
+
&& realpathSync(identityLines[0]) === realpathSync(contract.repoRoot)
|
|
441
|
+
} catch {}
|
|
442
|
+
if (
|
|
443
|
+
identityLines.length !== 3
|
|
444
|
+
|| !exactRepositoryRoot
|
|
445
|
+
|| !identityLines.slice(1).every(value => /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(value))
|
|
446
|
+
) {
|
|
447
|
+
return result('attest', [diagnostic({
|
|
448
|
+
ruleId: 'GOV-ATTEST-001',
|
|
449
|
+
severity: 'critical',
|
|
450
|
+
kind: 'git-identity-unavailable',
|
|
451
|
+
path: '',
|
|
452
|
+
message: 'Cannot resolve the exact Git commit and tree through the closed Git runtime.',
|
|
453
|
+
})])
|
|
454
|
+
}
|
|
455
|
+
;[, gitHead, gitTree] = identityLines
|
|
456
|
+
const status = runClosedGit([
|
|
457
|
+
'status',
|
|
458
|
+
'--porcelain=v1',
|
|
459
|
+
'-z',
|
|
460
|
+
'--untracked-files=all',
|
|
461
|
+
'--ignore-submodules=none',
|
|
462
|
+
'--no-renames',
|
|
463
|
+
], {
|
|
464
|
+
cwd: contract.repoRoot,
|
|
465
|
+
maxOutputBytes: 32 * 1024 * 1024,
|
|
466
|
+
output: 'buffer',
|
|
467
|
+
timeoutMs: 10_000,
|
|
468
|
+
})
|
|
469
|
+
if (status.status !== 0 || status.error || status.signal !== null) {
|
|
470
|
+
return result('attest', [diagnostic({
|
|
471
|
+
ruleId: 'GOV-ATTEST-001',
|
|
472
|
+
severity: 'critical',
|
|
473
|
+
kind: 'git-status-unavailable',
|
|
474
|
+
path: '',
|
|
475
|
+
message: 'Cannot resolve the complete worktree state through the closed Git runtime.',
|
|
476
|
+
})])
|
|
477
|
+
}
|
|
478
|
+
const dirty = status.stdout.length > 0
|
|
479
|
+
if (dirty) return result('attest', [diagnostic({ ruleId: 'GOV-ATTEST-001', severity: 'critical', kind: 'dirty-worktree', path: '', expected: 'clean Git snapshot', actual: 'non-empty porcelain-v1-z status', message: 'Refusing evidence for a dirty worktree; commit the exact checked snapshot first.' })])
|
|
480
|
+
if (options.gitHead != null && options.gitHead !== gitHead) return result('attest', [diagnostic({ ruleId: 'GOV-ATTEST-001', severity: 'critical', kind: 'git-head-mismatch', path: '', expected: gitHead, actual: options.gitHead, message: '--git-head does not match the checked repository HEAD.' })])
|
|
481
|
+
} else {
|
|
482
|
+
// Deterministic fixtures may supply a synthetic subject. Real release evidence is always issued
|
|
483
|
+
// from a Git repository and signed externally by GitHub OIDC artifact attestation.
|
|
484
|
+
gitHead = options.gitHead ?? null
|
|
485
|
+
}
|
|
486
|
+
const issuedAt = options.issuedAt || new Date().toISOString()
|
|
487
|
+
if (Number.isNaN(Date.parse(issuedAt))) return result('attest', [diagnostic({ ruleId: 'GOV-ATTEST-001', severity: 'critical', kind: 'invalid-issued-at', path: '', actual: issuedAt, message: '--at must be an ISO-8601 timestamp.' })])
|
|
488
|
+
const claims = ['snapshot-integrity', 'contract-doctor', 'provider-skill-parity']
|
|
489
|
+
if (options.hooksOff) claims.push('hook-independent-check')
|
|
490
|
+
claims.sort()
|
|
491
|
+
const evidenceDigest = digest({
|
|
492
|
+
role: contract.roleId,
|
|
493
|
+
contractDigest: contract.contractDigest,
|
|
494
|
+
snapshotDigest: state.digest,
|
|
495
|
+
gitHead,
|
|
496
|
+
gitTree,
|
|
497
|
+
claims,
|
|
498
|
+
})
|
|
499
|
+
const attestation = canonicalize({
|
|
500
|
+
_provenance: provenance(contract),
|
|
501
|
+
schemaVersion: 1,
|
|
502
|
+
issuedAt: new Date(issuedAt).toISOString(),
|
|
503
|
+
subject: `${contract.manifest.id}:${contract.roleId}`,
|
|
504
|
+
role: contract.roleId,
|
|
505
|
+
contractDigest: contract.contractDigest,
|
|
506
|
+
snapshotDigest: state.digest,
|
|
507
|
+
gitHead,
|
|
508
|
+
gitTree,
|
|
509
|
+
evidenceDigest,
|
|
510
|
+
claims,
|
|
511
|
+
})
|
|
512
|
+
await assertArtifactSchema(contract, 'attestation', attestation)
|
|
513
|
+
if (options.attestationFile) {
|
|
514
|
+
const attestationFile = resolveWithin(contract.repoRoot, options.attestationFile, 'attestationFile')
|
|
515
|
+
await assertNoSymlinkWithin(contract.repoRoot, attestationFile, 'attestationFile')
|
|
516
|
+
await writeJsonAtomic(attestationFile, attestation)
|
|
517
|
+
}
|
|
518
|
+
const diagnostics = [diagnostic({ ruleId: 'GOV-ATTEST-001', severity: 'info', kind: 'evidence-manifest-issued', path: options.attestationFile || '<stdout>', outcome: 'PASS', actual: evidenceDigest, message: 'Digest-bound evidence manifest issued for the exact claims actually checked; external signing is separate.' })]
|
|
519
|
+
return result('attest', diagnostics, { attestation })
|
|
520
|
+
}
|