@msn-control/liftoff 0.10.2 → 0.10.4
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/DEVELOPER.md +209 -1
- package/README.md +12 -1
- package/assets/governance/single-maintainer-gitflow/assessment-controls.json +39 -0
- package/assets/supported-stack.json +4 -2
- package/dist/args.d.ts +2 -2
- package/dist/args.js +41 -10
- package/dist/args.js.map +1 -1
- package/dist/artifact-lifecycle.d.ts +1 -1
- package/dist/artifact-lifecycle.js +3 -1
- package/dist/artifact-lifecycle.js.map +1 -1
- package/dist/cli.js +2 -1
- package/dist/cli.js.map +1 -1
- package/dist/commands.js +214 -57
- package/dist/commands.js.map +1 -1
- package/dist/file-system.d.ts +3 -0
- package/dist/file-system.js +51 -10
- package/dist/file-system.js.map +1 -1
- package/dist/governance-activation/commands.js +165 -7
- package/dist/governance-activation/commands.js.map +1 -1
- package/dist/governance-activation/readiness.d.ts +2 -0
- package/dist/governance-activation/readiness.js +77 -12
- package/dist/governance-activation/readiness.js.map +1 -1
- package/dist/governance-activation/seed-lifecycle.d.ts +19 -1
- package/dist/governance-activation/seed-lifecycle.js +158 -7
- package/dist/governance-activation/seed-lifecycle.js.map +1 -1
- package/dist/governance-activation/transitions.d.ts +9 -0
- package/dist/governance-activation/transitions.js +37 -4
- package/dist/governance-activation/transitions.js.map +1 -1
- package/dist/governance-assessment/catalog.d.ts +8 -0
- package/dist/governance-assessment/catalog.js +98 -0
- package/dist/governance-assessment/catalog.js.map +1 -0
- package/dist/governance-assessment/command.d.ts +10 -0
- package/dist/governance-assessment/command.js +81 -0
- package/dist/governance-assessment/command.js.map +1 -0
- package/dist/governance-assessment/engine.d.ts +19 -0
- package/dist/governance-assessment/engine.js +586 -0
- package/dist/governance-assessment/engine.js.map +1 -0
- package/dist/governance-assessment/index.d.ts +5 -0
- package/dist/governance-assessment/index.js +6 -0
- package/dist/governance-assessment/index.js.map +1 -0
- package/dist/governance-assessment/live-normalize.d.ts +19 -0
- package/dist/governance-assessment/live-normalize.js +403 -0
- package/dist/governance-assessment/live-normalize.js.map +1 -0
- package/dist/governance-assessment/live-transport.d.ts +38 -0
- package/dist/governance-assessment/live-transport.js +467 -0
- package/dist/governance-assessment/live-transport.js.map +1 -0
- package/dist/governance-assessment/live.d.ts +2 -0
- package/dist/governance-assessment/live.js +639 -0
- package/dist/governance-assessment/live.js.map +1 -0
- package/dist/governance-assessment/predicates.d.ts +27 -0
- package/dist/governance-assessment/predicates.js +330 -0
- package/dist/governance-assessment/predicates.js.map +1 -0
- package/dist/governance-assessment/project.d.ts +37 -0
- package/dist/governance-assessment/project.js +268 -0
- package/dist/governance-assessment/project.js.map +1 -0
- package/dist/governance-assessment/readers.d.ts +18 -0
- package/dist/governance-assessment/readers.js +130 -0
- package/dist/governance-assessment/readers.js.map +1 -0
- package/dist/governance-assessment/report.d.ts +23 -0
- package/dist/governance-assessment/report.js +266 -0
- package/dist/governance-assessment/report.js.map +1 -0
- package/dist/governance-assessment/sanitize.d.ts +8 -0
- package/dist/governance-assessment/sanitize.js +57 -0
- package/dist/governance-assessment/sanitize.js.map +1 -0
- package/dist/governance-assessment/types.d.ts +178 -0
- package/dist/governance-assessment/types.js +13 -0
- package/dist/governance-assessment/types.js.map +1 -0
- package/dist/governance-assessment/yaml.d.ts +6 -0
- package/dist/governance-assessment/yaml.js +30 -0
- package/dist/governance-assessment/yaml.js.map +1 -0
- package/dist/migrate-plan.d.ts +4 -0
- package/dist/migrate-plan.js +73 -2
- package/dist/migrate-plan.js.map +1 -1
- package/dist/power-apps-templates.js +4 -0
- package/dist/power-apps-templates.js.map +1 -1
- package/dist/process-runner.d.ts +4 -0
- package/dist/process-runner.js +164 -8
- package/dist/process-runner.js.map +1 -1
- package/dist/published-verifier.js +6 -0
- package/dist/published-verifier.js.map +1 -1
- package/dist/release-identity.js +19 -0
- package/dist/release-identity.js.map +1 -1
- package/dist/repository-governance.d.ts +5 -0
- package/dist/repository-governance.js +133 -3
- package/dist/repository-governance.js.map +1 -1
- package/dist/telemetry/contract.d.ts +2 -0
- package/dist/telemetry/contract.js +7 -0
- package/dist/telemetry/contract.js.map +1 -1
- package/dist/templates.js +74 -29
- package/dist/templates.js.map +1 -1
- package/docs/cli-reference.md +83 -1
- package/docs/configuration-and-manifests.md +11 -1
- package/docs/project-structure.md +8 -3
- package/docs/repository-governance.md +125 -3
- package/docs/telemetry.md +9 -3
- package/docs/workloads.md +7 -0
- package/package.json +3 -2
|
@@ -0,0 +1,586 @@
|
|
|
1
|
+
import { canonicalJson, canonicalSha256, sha256Hex } from '../governance-activation/canonical-json.js';
|
|
2
|
+
import { currentActivationIdentity, canonicalPhaseGraph } from '../governance-activation/graph.js';
|
|
3
|
+
import { evidenceContextForPhase, requiredLiveReadbackProviders, selectLatestPhaseEvidence } from '../governance-activation/evidence.js';
|
|
4
|
+
import { canonicalApprovalEnvelopeHash, evaluateApprovalForTransitionPlan, transitionPlanForPhase } from '../governance-activation/approvals.js';
|
|
5
|
+
import { planDigestFor } from '../governance-activation/transitions.js';
|
|
6
|
+
import { governanceChangeMetadataFileName, validateGovernanceChangeMetadata } from '../governance-activation/source-of-truth.js';
|
|
7
|
+
import { loadAssessmentCatalog } from './catalog.js';
|
|
8
|
+
import { inspectAssessmentGit, inspectAssessmentProject, repositoryName } from './project.js';
|
|
9
|
+
import { AssessmentFiles, AssessmentInputError, parseAssessmentJson } from './readers.js';
|
|
10
|
+
import { parseAssessmentWorkflow } from './yaml.js';
|
|
11
|
+
import { collectLiveAssessment } from './live.js';
|
|
12
|
+
import { classifyFinding, assembleAssessmentReport } from './report.js';
|
|
13
|
+
import { actionReferences, failOpenFlags, pinnedActions, protectedRefs, requiredCheckContexts, requiredContextBindings, securityPipeline, singleMaintainer, tagControls, workflowPermissions, observedRequiredContexts } from './predicates.js';
|
|
14
|
+
import { isRecord, jsonValue, notObserved, observed, sanitizeAssessmentText, source } from './sanitize.js';
|
|
15
|
+
const documentationPaths = [
|
|
16
|
+
['CONTRIBUTING.md'], ['docs', 'operations', 'github-governance.md'],
|
|
17
|
+
['docs', 'security', 'scanning.md'], ['docs', 'operations', 'alerting.md'],
|
|
18
|
+
['docs', 'operations', 'service-health.md'], ['docs', 'security', 'audit-evidence.md'],
|
|
19
|
+
['docs', 'ai-acceptable-use.md']
|
|
20
|
+
];
|
|
21
|
+
async function localFacts(files, project, capturedAt) {
|
|
22
|
+
const workflowParts = await files.list(['.github', 'workflows'], ['.yml', '.yaml']);
|
|
23
|
+
const workflows = [];
|
|
24
|
+
const workflowDigests = {};
|
|
25
|
+
for (const parts of workflowParts) {
|
|
26
|
+
const text = await files.read(parts);
|
|
27
|
+
if (text === null)
|
|
28
|
+
throw new AssessmentInputError('inputs-changed', 'Workflow disappeared during inspection.', parts.join('/'));
|
|
29
|
+
workflows.push(parseAssessmentWorkflow(text, parts.join('/')));
|
|
30
|
+
workflowDigests[parts.join('/')] = sha256Hex(text);
|
|
31
|
+
}
|
|
32
|
+
const rulesets = [];
|
|
33
|
+
const ruleDigests = {};
|
|
34
|
+
for (const parts of await files.list(['governance', 'rulesets'], ['.json'])) {
|
|
35
|
+
const text = await files.read(parts);
|
|
36
|
+
if (text === null)
|
|
37
|
+
throw new AssessmentInputError('inputs-changed', 'Ruleset disappeared during inspection.', parts.join('/'));
|
|
38
|
+
const value = parseAssessmentJson(text, parts.join('/'));
|
|
39
|
+
if (!isRecord(value) || !['branch', 'tag', 'push'].includes(String(value.target)) ||
|
|
40
|
+
!['active', 'disabled', 'evaluate'].includes(String(value.enforcement)) || !Array.isArray(value.rules) ||
|
|
41
|
+
!Array.isArray(value.bypass_actors)) {
|
|
42
|
+
throw new AssessmentInputError('malformed-ruleset', 'Ruleset payload has invalid target, enforcement, rule, or bypass fields.', parts.join('/'));
|
|
43
|
+
}
|
|
44
|
+
rulesets.push(value);
|
|
45
|
+
ruleDigests[parts.join('/')] = sha256Hex(text);
|
|
46
|
+
}
|
|
47
|
+
const codeownerFiles = [];
|
|
48
|
+
for (const parts of [['CODEOWNERS'], ['.github', 'CODEOWNERS'], ['docs', 'CODEOWNERS']]) {
|
|
49
|
+
if (await files.read(parts) !== null)
|
|
50
|
+
codeownerFiles.push(parts.join('/'));
|
|
51
|
+
}
|
|
52
|
+
const missingDocs = [];
|
|
53
|
+
const docFacts = {};
|
|
54
|
+
for (const parts of documentationPaths) {
|
|
55
|
+
const text = await files.read(parts);
|
|
56
|
+
docFacts[parts.join('/')] = text === null ? null : sha256Hex(text);
|
|
57
|
+
if (text === null)
|
|
58
|
+
missingDocs.push(parts.join('/'));
|
|
59
|
+
}
|
|
60
|
+
const runbooks = await files.list(['docs', 'runbooks'], ['.md']);
|
|
61
|
+
if (runbooks.length === 0)
|
|
62
|
+
missingDocs.push('docs/runbooks/*.md');
|
|
63
|
+
let baseline = null;
|
|
64
|
+
if (project.state?.activeChange) {
|
|
65
|
+
const active = project.state.activeChange;
|
|
66
|
+
const parts = active.kind === 'openspec'
|
|
67
|
+
? ['openspec', 'changes', active.id, governanceChangeMetadataFileName]
|
|
68
|
+
: ['specs', active.id, governanceChangeMetadataFileName];
|
|
69
|
+
const text = await files.read(parts);
|
|
70
|
+
if (text !== null) {
|
|
71
|
+
try {
|
|
72
|
+
const metadata = validateGovernanceChangeMetadata(parseAssessmentJson(text, parts.join('/')));
|
|
73
|
+
if (metadata.changeId !== active.id ||
|
|
74
|
+
metadata.workflowKind !== active.kind ||
|
|
75
|
+
canonicalJson(metadata.activationIdentity) !== canonicalJson(project.state.identity)) {
|
|
76
|
+
throw new Error('Active governance metadata does not match the recorded change and identity.');
|
|
77
|
+
}
|
|
78
|
+
baseline = metadata.baselineSha === '0'.repeat(64) ? null : metadata.baselineSha;
|
|
79
|
+
}
|
|
80
|
+
catch (error) {
|
|
81
|
+
project.diagnostics.push({
|
|
82
|
+
code: 'unverified-active-baseline', severity: 'warning', source: parts.join('/'),
|
|
83
|
+
message: sanitizeAssessmentText(error instanceof Error ? error.message : 'Active governance metadata cannot be interpreted.')
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
project.bindingBaseline = baseline;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
const docObservation = observed(missingDocs.length === 0, source('file', 'docs and CONTRIBUTING.md', capturedAt, docFacts));
|
|
90
|
+
docObservation.reason = missingDocs.length ? `Missing documentation inventory: ${missingDocs.join(', ')}` : 'Required documentation inventory exists; content and rehearsals need separate proof.';
|
|
91
|
+
return {
|
|
92
|
+
workflows, rulesets,
|
|
93
|
+
workflowSource: source('file', '.github/workflows', capturedAt, workflowDigests),
|
|
94
|
+
ruleSource: source('file', 'governance/rulesets', capturedAt, ruleDigests),
|
|
95
|
+
codeowners: observed(codeownerFiles.length > 0, source('file', 'CODEOWNERS inventory', capturedAt, codeownerFiles)),
|
|
96
|
+
documentation: docObservation, baseline
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
function boundPlan(project, phaseId, now) {
|
|
100
|
+
if (!project.state || !project.bindingBaseline || project.identity.availability !== 'known')
|
|
101
|
+
return null;
|
|
102
|
+
const candidates = project.plans.filter((plan) => plan.phaseId === phaseId && plan.baselineDigest === project.bindingBaseline &&
|
|
103
|
+
canonicalJson(plan.identity) === canonicalJson(currentActivationIdentity) &&
|
|
104
|
+
Date.parse(plan.createdAt) <= now.getTime() &&
|
|
105
|
+
Date.parse(plan.createdAt) <= Date.parse(project.state.phases[phaseId].updatedAt)).sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt));
|
|
106
|
+
const plan = candidates[0];
|
|
107
|
+
if (!plan || plan.baselineDigest === '0'.repeat(64) || plan.inputDigest === '1'.repeat(64) ||
|
|
108
|
+
candidates.some((candidate) => candidate.createdAt === plan.createdAt && candidate.planDigest !== plan.planDigest))
|
|
109
|
+
return null;
|
|
110
|
+
const phase = canonicalPhaseGraph.phases.find((entry) => entry.id === phaseId);
|
|
111
|
+
if (!phase)
|
|
112
|
+
return null;
|
|
113
|
+
const authority = transitionPlanForPhase(phase, project.state, {
|
|
114
|
+
phaseId, baselineSha: plan.baselineDigest, inputDigest: plan.inputDigest, transitionDigest: plan.transitionDigest
|
|
115
|
+
});
|
|
116
|
+
if (plan.planDigest !== planDigestFor({
|
|
117
|
+
phase, transitionDigest: plan.transitionDigest, operations: plan.operations, approvalPlanDigest: authority.planDigest
|
|
118
|
+
}))
|
|
119
|
+
return null;
|
|
120
|
+
return plan;
|
|
121
|
+
}
|
|
122
|
+
export function selectBoundAssessmentEvidence(project, phaseId, now) {
|
|
123
|
+
const plan = boundPlan(project, phaseId, now);
|
|
124
|
+
if (!plan || !project.state)
|
|
125
|
+
return null;
|
|
126
|
+
if (new Set(project.evidence.map((record) => record.evidenceId)).size !== project.evidence.length)
|
|
127
|
+
return null;
|
|
128
|
+
const phase = canonicalPhaseGraph.phases.find((entry) => entry.id === phaseId);
|
|
129
|
+
const references = project.state.phases[phaseId].evidence;
|
|
130
|
+
const candidates = project.evidence.filter((record) => record.header.phaseId === phaseId &&
|
|
131
|
+
references.some((reference) => reference.phaseId === phaseId && reference.evidenceId === record.evidenceId &&
|
|
132
|
+
reference.headerDigest === canonicalSha256(record.header) && reference.result === record.header.result));
|
|
133
|
+
const selected = selectLatestPhaseEvidence(candidates, evidenceContextForPhase(phaseId, {
|
|
134
|
+
repositoryId: project.state.repository.id, identity: currentActivationIdentity,
|
|
135
|
+
phaseGraphHash: currentActivationIdentity.phaseGraphHash, now,
|
|
136
|
+
baselineSha: plan.baselineDigest, inputDigest: plan.inputDigest, transitionDigest: plan.transitionDigest,
|
|
137
|
+
liveReadbackProviders: phase ? requiredLiveReadbackProviders(phase) : []
|
|
138
|
+
})).selected;
|
|
139
|
+
if (!selected || selected.header.result !== project.state.phases[phaseId].state)
|
|
140
|
+
return null;
|
|
141
|
+
const record = candidates.find((entry) => entry.evidenceId === selected.evidenceId &&
|
|
142
|
+
canonicalSha256(entry.header) === selected.headerDigest);
|
|
143
|
+
return record ? { ...selected, record } : null;
|
|
144
|
+
}
|
|
145
|
+
function applicability(control, project, now) {
|
|
146
|
+
if (control.applicability === 'always')
|
|
147
|
+
return 'applicable';
|
|
148
|
+
if (project.project.workload.kind === 'power-apps-code-app')
|
|
149
|
+
return 'inapplicable';
|
|
150
|
+
if (control.applicability === 'api')
|
|
151
|
+
return 'applicable';
|
|
152
|
+
const discovery = selectBoundAssessmentEvidence(project, 'phase-0-complete', now);
|
|
153
|
+
if (!project.state || discovery?.header.result !== 'verified')
|
|
154
|
+
return 'unknown';
|
|
155
|
+
return (control.applicability === 'private-dast'
|
|
156
|
+
? project.state.applicability.privateStagingDast
|
|
157
|
+
: project.state.applicability.statePath === 'bootstrap-local') ? 'applicable' : 'inapplicable';
|
|
158
|
+
}
|
|
159
|
+
export function resolveAssessmentLiveScope(project, git, now) {
|
|
160
|
+
const stateRepo = project.state ? repositoryName(project.state.repository.name, project.state.repository.id) : null;
|
|
161
|
+
let repository = git.originState === 'unavailable' ? null : git.repository ?? stateRepo;
|
|
162
|
+
if (git.repository && stateRepo) {
|
|
163
|
+
if (`${git.repository.owner}/${git.repository.name}`.toLowerCase() !== `${stateRepo.owner}/${stateRepo.name}`.toLowerCase()) {
|
|
164
|
+
repository = null;
|
|
165
|
+
project.diagnostics.push({ code: 'repository-binding-conflict', severity: 'warning', source: 'Git/activation state', message: 'Git origin and recorded repository disagree; live collection is withheld.' });
|
|
166
|
+
}
|
|
167
|
+
else
|
|
168
|
+
repository = { ...git.repository, id: stateRepo.id };
|
|
169
|
+
}
|
|
170
|
+
const scope = {
|
|
171
|
+
repository, refs: ['develop', 'main'],
|
|
172
|
+
environments: project.project.workload.kind === 'power-apps-code-app' ? [] : [...project.project.workload.environments],
|
|
173
|
+
runner: null, azure: []
|
|
174
|
+
};
|
|
175
|
+
if (!repository || !project.state)
|
|
176
|
+
return scope;
|
|
177
|
+
const fullName = `${repository.owner}/${repository.name}`;
|
|
178
|
+
for (const approval of project.approvals) {
|
|
179
|
+
const plan = boundPlan(project, approval.phaseId, now);
|
|
180
|
+
const phase = canonicalPhaseGraph.phases.find((entry) => entry.id === approval.phaseId);
|
|
181
|
+
const authority = plan && phase ? transitionPlanForPhase(phase, project.state, {
|
|
182
|
+
phaseId: phase.id, baselineSha: plan.baselineDigest, inputDigest: plan.inputDigest, transitionDigest: plan.transitionDigest
|
|
183
|
+
}) : null;
|
|
184
|
+
if (!project.bindingBaseline || approval.baselineSha !== project.bindingBaseline ||
|
|
185
|
+
project.identity.availability !== 'known' ||
|
|
186
|
+
Date.parse(approval.approvedAt) > now.getTime() ||
|
|
187
|
+
Date.parse(approval.expiresAt) <= now.getTime() ||
|
|
188
|
+
!project.state.phases[approval.phaseId].approvals.includes(approval.id) ||
|
|
189
|
+
!plan || !authority || !phase?.approvalGate.required || authority.planDigest !== approval.planDigest ||
|
|
190
|
+
evaluateApprovalForTransitionPlan(authority, [approval], { now }).approvalRequired ||
|
|
191
|
+
plan.approval.envelopeId !== approval.id ||
|
|
192
|
+
plan.approval.envelopeHash !== canonicalApprovalEnvelopeHash(approval) ||
|
|
193
|
+
!approval.destinations.some((destination) => destination.type === 'repository' && (destination.repository ?? destination.identity).toLowerCase() === fullName.toLowerCase()))
|
|
194
|
+
continue;
|
|
195
|
+
const environments = approval.destinations.filter((destination) => destination.type === 'environment' &&
|
|
196
|
+
['dev', 'staging', 'prod'].includes(destination.identity)).map((destination) => destination.identity);
|
|
197
|
+
if (new Set(environments).size !== 1)
|
|
198
|
+
continue;
|
|
199
|
+
const environment = environments[0];
|
|
200
|
+
if (environment !== 'dev' && environment !== 'staging' && environment !== 'prod')
|
|
201
|
+
continue;
|
|
202
|
+
for (const resource of approval.resources) {
|
|
203
|
+
const match = resource.identity.match(/^\/subscriptions\/([a-f0-9-]{36})\/resourceGroups\/[^/]+\/providers\/([A-Za-z0-9.]+)\/([A-Za-z0-9]+)\/[^/]+(?:\/[A-Za-z0-9]+\/[^/]+)*$/iu);
|
|
204
|
+
if (!match || !approval.destinations.some((destination) => destination.type === 'subscription' && (destination.subscriptionId ?? destination.identity).toLowerCase() === match[1].toLowerCase()))
|
|
205
|
+
continue;
|
|
206
|
+
const role = ['remote-ready', 'remote-import-verified'].includes(approval.phaseId) ? 'state'
|
|
207
|
+
: ['bootstrap-local', 'runner-ready', 'private-backend-proof'].includes(approval.phaseId) ? 'runner-network' : 'application';
|
|
208
|
+
const resourceSegments = resource.identity.split('/').slice(6);
|
|
209
|
+
scope.azure.push({
|
|
210
|
+
subscriptionId: match[1], environment, resourceId: resource.identity,
|
|
211
|
+
resourceType: [resourceSegments[0], ...resourceSegments.slice(1).filter((_, index) => index % 2 === 0)].join('/'), role
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
const runnerRecord = selectBoundAssessmentEvidence(project, 'runner-ready', now);
|
|
216
|
+
const rawRunner = runnerRecord?.record.payload;
|
|
217
|
+
if (runnerRecord?.header.result === 'verified' && isRecord(rawRunner) && rawRunner.kind === 'runner-ready.v1' &&
|
|
218
|
+
rawRunner.organization === repository.owner && Number.isInteger(rawRunner.runnerId) && Number(rawRunner.runnerId) > 0 &&
|
|
219
|
+
(rawRunner.groupId === null || Number.isInteger(rawRunner.groupId)) &&
|
|
220
|
+
(rawRunner.networkConfigurationId === null || typeof rawRunner.networkConfigurationId === 'string')) {
|
|
221
|
+
scope.runner = {
|
|
222
|
+
organization: repository.owner, runnerId: Number(rawRunner.runnerId),
|
|
223
|
+
groupId: rawRunner.groupId === null ? null : Number(rawRunner.groupId),
|
|
224
|
+
networkConfigurationId: typeof rawRunner.networkConfigurationId === 'string' ? rawRunner.networkConfigurationId : null
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
scope.azure = [...new Map(scope.azure.map((binding) => [binding.resourceId.toLowerCase(), binding])).values()]
|
|
228
|
+
.sort((a, b) => a.resourceId.localeCompare(b.resourceId, 'en'));
|
|
229
|
+
return scope;
|
|
230
|
+
}
|
|
231
|
+
function predicateObservation(check, from) {
|
|
232
|
+
if (check.value === null || from === null)
|
|
233
|
+
return notObserved(check.reason, from);
|
|
234
|
+
if (check.absent)
|
|
235
|
+
return absent(sanitizeAssessmentText(check.reason), from);
|
|
236
|
+
const observation = observed(check.value, from);
|
|
237
|
+
if (observation.availability === 'observed')
|
|
238
|
+
observation.reason = sanitizeAssessmentText(check.reason);
|
|
239
|
+
return observation;
|
|
240
|
+
}
|
|
241
|
+
function absent(reason, from) {
|
|
242
|
+
return from ? { availability: 'missing', value: null, source: from, reason } : notObserved(reason);
|
|
243
|
+
}
|
|
244
|
+
function livePredicate(live, key, evaluate) {
|
|
245
|
+
const observation = live.observations[key];
|
|
246
|
+
if (!observation || observation.availability !== 'observed')
|
|
247
|
+
return observation ?? notObserved(`Live ${key} was not collected.`);
|
|
248
|
+
const converted = predicateObservation(evaluate(observation.value), observation.source);
|
|
249
|
+
if (converted.availability === 'not-observed')
|
|
250
|
+
converted.value = observation.value;
|
|
251
|
+
else
|
|
252
|
+
converted.facts = observation.value;
|
|
253
|
+
return converted;
|
|
254
|
+
}
|
|
255
|
+
function bool(value, reason) { return { value, reason }; }
|
|
256
|
+
function rows(value) {
|
|
257
|
+
return Array.isArray(value) && value.every(isRecord) ? value : null;
|
|
258
|
+
}
|
|
259
|
+
export function findAssessmentException(control, approvals, scope, baseline, resource, now) {
|
|
260
|
+
if (!control.exceptionAllowed || !scope.repository || !baseline || !resource)
|
|
261
|
+
return null;
|
|
262
|
+
const found = approvals.find((approval) => control.phaseIds.includes(approval.phaseId) && canonicalJson(approval.identity) === canonicalJson(currentActivationIdentity) &&
|
|
263
|
+
approval.baselineSha === baseline && approval.policyExceptions.includes(control.id) &&
|
|
264
|
+
Date.parse(approval.approvedAt) <= now.getTime() && Date.parse(approval.expiresAt) > now.getTime() &&
|
|
265
|
+
approval.resources.some((entry) => entry.type === 'action-reference' && entry.identity === resource) &&
|
|
266
|
+
approval.destinations.some((entry) => entry.type === 'repository' &&
|
|
267
|
+
(entry.repository ?? entry.identity).toLowerCase() === scope.repository.toLowerCase()) &&
|
|
268
|
+
(scope.environment === null || approval.destinations.some((entry) => entry.type === 'environment' && entry.identity === scope.environment)));
|
|
269
|
+
return found ? { id: found.id, expiresAt: found.expiresAt, envelopeDigest: canonicalApprovalEnvelopeHash(found) } : null;
|
|
270
|
+
}
|
|
271
|
+
function evidenceObservation(project, git, capturedAt) {
|
|
272
|
+
if (!project.state)
|
|
273
|
+
return notObserved('No compatible activation state is available.');
|
|
274
|
+
if (project.invalidEvidence)
|
|
275
|
+
return notObserved('Some evidence is malformed, sensitive, or uses unsupported schemas.');
|
|
276
|
+
if (git.head)
|
|
277
|
+
return notObserved('Current-worktree evidence binding cannot be established from stored state alone; historical records are not fresh live proof.');
|
|
278
|
+
const complete = canonicalPhaseGraph.phases.every((phase) => {
|
|
279
|
+
const stored = project.state.phases[phase.id];
|
|
280
|
+
if (stored.state === 'inapplicable')
|
|
281
|
+
return false;
|
|
282
|
+
if (stored.state === 'approved')
|
|
283
|
+
return false;
|
|
284
|
+
const record = selectBoundAssessmentEvidence(project, phase.id, new Date(capturedAt));
|
|
285
|
+
return record?.header.result === stored.state && ['verified', 'retained', 'disposed'].includes(stored.state);
|
|
286
|
+
});
|
|
287
|
+
if (!complete)
|
|
288
|
+
return notObserved('Baseline/setup progress, approvals, applicability and current readback are not all established.');
|
|
289
|
+
return observed(true, source('evidence', 'governance/evidence', capturedAt, project.evidence.map((record) => record.header.phaseId)));
|
|
290
|
+
}
|
|
291
|
+
async function managedFindings(control, project, files, scope, capturedAt) {
|
|
292
|
+
const findings = [];
|
|
293
|
+
if (project.renderedCore.length === 0) {
|
|
294
|
+
return [classifyFinding({
|
|
295
|
+
control, scope, applicability: 'applicable',
|
|
296
|
+
observations: {
|
|
297
|
+
recorded: notObserved('Historical agent selection is not recorded.'),
|
|
298
|
+
declared: notObserved('The current handoff cannot be rendered without inferring agent configuration.')
|
|
299
|
+
}
|
|
300
|
+
})];
|
|
301
|
+
}
|
|
302
|
+
for (const artifact of project.renderedCore) {
|
|
303
|
+
const text = await files.read(artifact.pathParts);
|
|
304
|
+
const expected = `sha256:${sha256Hex(artifact.content)}`;
|
|
305
|
+
const actual = text === null ? null : `sha256:${sha256Hex(text)}`;
|
|
306
|
+
const entry = project.managedEntries.find((record) => record.logicalName === artifact.logicalName);
|
|
307
|
+
const recorded = entry ? observed(entry.contentHash, source('file', 'liftoff.manifest.json', capturedAt, entry.contentHash))
|
|
308
|
+
: notObserved(project.manifest ? 'Artifact is not in the recorded ownership inventory.' : 'Unsupported manifest identity; managed ownership is not being inferred.');
|
|
309
|
+
const declared = actual === null
|
|
310
|
+
? absent('The exact target managed path is absent.', source('file', artifact.pathParts.join('/'), capturedAt))
|
|
311
|
+
: observed(actual, source('file', artifact.pathParts.join('/'), capturedAt, actual));
|
|
312
|
+
const difference = actual !== null && actual !== expected && entry?.contentHash === actual ? 'outdated'
|
|
313
|
+
: actual !== null && actual !== expected ? 'conflicting'
|
|
314
|
+
: actual === expected && entry && entry.contentHash !== expected ? 'outdated' : undefined;
|
|
315
|
+
findings.push(classifyFinding({
|
|
316
|
+
control, scope: { ...scope, resource: artifact.pathParts.join('/') }, expected,
|
|
317
|
+
applicability: 'applicable', observations: { recorded, declared }, difference
|
|
318
|
+
}));
|
|
319
|
+
}
|
|
320
|
+
return findings;
|
|
321
|
+
}
|
|
322
|
+
function evaluateControl(control, project, facts, live, git, liveScope, now) {
|
|
323
|
+
const capturedAt = now.toISOString();
|
|
324
|
+
const scope = { repository: liveScope.repository ? `${liveScope.repository.owner}/${liveScope.repository.name}` : null, environment: null, resource: null };
|
|
325
|
+
const observations = {};
|
|
326
|
+
let expected = control.expected;
|
|
327
|
+
let exceptionResource;
|
|
328
|
+
let difference;
|
|
329
|
+
const declaredRules = (predicate) => facts.rulesets.length
|
|
330
|
+
? predicateObservation(predicate(facts.rulesets), facts.ruleSource) : absent('No declared ruleset payloads were found.', facts.ruleSource);
|
|
331
|
+
switch (control.evaluator) {
|
|
332
|
+
case 'identity': {
|
|
333
|
+
expected = jsonValue({ manifest: currentActivationIdentity, state: project.identity.stateSource === 'not-started' ? null : currentActivationIdentity });
|
|
334
|
+
observations.recorded = project.identity.recordedActivationIdentity === null
|
|
335
|
+
? notObserved('This historical manifest does not record an activation tuple.')
|
|
336
|
+
: observed({ manifest: project.identity.recordedActivationIdentity, state: project.stateIdentity }, source('file', 'manifest and activation identity', capturedAt, { manifest: project.identity.recordedActivationIdentity, state: project.stateIdentity }));
|
|
337
|
+
break;
|
|
338
|
+
}
|
|
339
|
+
case 'default-branch': {
|
|
340
|
+
const remote = live.observations['github.repository'];
|
|
341
|
+
observations.live = remote?.availability === 'observed' && isRecord(remote.value) && typeof remote.value.defaultBranch === 'string' && remote.source
|
|
342
|
+
? observed(remote.value.defaultBranch, remote.source) : remote ?? notObserved('Live repository default branch was not observed.');
|
|
343
|
+
if (project.state)
|
|
344
|
+
observations.recorded = observed(project.state.repository.defaultBranch, source('file', 'governance/activation-state.json', capturedAt, project.state.repository.defaultBranch));
|
|
345
|
+
break;
|
|
346
|
+
}
|
|
347
|
+
case 'protected-refs': {
|
|
348
|
+
const repository = live.observations['github.repository'];
|
|
349
|
+
const branch = repository?.availability === 'observed' && isRecord(repository.value) && typeof repository.value.defaultBranch === 'string'
|
|
350
|
+
? repository.value.defaultBranch : null;
|
|
351
|
+
observations.declared = declaredRules((value) => protectedRefs(value, project.state?.repository.defaultBranch ?? null));
|
|
352
|
+
observations.live = livePredicate(live, 'github.rulesets', (value) => protectedRefs(value, branch));
|
|
353
|
+
break;
|
|
354
|
+
}
|
|
355
|
+
case 'single-maintainer':
|
|
356
|
+
observations.declared = declaredRules(singleMaintainer);
|
|
357
|
+
observations.live = livePredicate(live, 'github.rulesets', singleMaintainer);
|
|
358
|
+
break;
|
|
359
|
+
case 'tag-controls': {
|
|
360
|
+
const app = live.observations['github.actions-app'];
|
|
361
|
+
if (app?.availability === 'observed' && isRecord(app.value) && typeof app.value.id === 'number' &&
|
|
362
|
+
app.value.id > 0 && app.value.slug === 'github-actions') {
|
|
363
|
+
const appId = app.value.id;
|
|
364
|
+
observations.declared = declaredRules((value) => tagControls(value, appId));
|
|
365
|
+
observations.live = livePredicate(live, 'github.rulesets', (value) => tagControls(value, appId));
|
|
366
|
+
}
|
|
367
|
+
else {
|
|
368
|
+
observations.declared = facts.rulesets.length
|
|
369
|
+
? notObserved('The declared tag actor cannot be verified without the GitHub Actions application identity.', facts.ruleSource)
|
|
370
|
+
: absent('No declared tag rulesets were found.', facts.ruleSource);
|
|
371
|
+
observations.live = notObserved('The GitHub Actions bypass application identity was not observed.');
|
|
372
|
+
}
|
|
373
|
+
break;
|
|
374
|
+
}
|
|
375
|
+
case 'no-codeowners':
|
|
376
|
+
observations.declared = facts.codeowners;
|
|
377
|
+
break;
|
|
378
|
+
case 'required-contexts': {
|
|
379
|
+
const contexts = requiredCheckContexts(facts.rulesets);
|
|
380
|
+
observations.declared = contexts === null ? notObserved('Required context declarations cannot be interpreted.')
|
|
381
|
+
: contexts.length ? predicateObservation(bool(true, `Declared contexts: ${contexts.map((entry) => entry.name).join(', ')}`), facts.ruleSource)
|
|
382
|
+
: absent('No required status check contexts were declared.', facts.ruleSource);
|
|
383
|
+
const rules = live.observations['github.rulesets'];
|
|
384
|
+
const repository = live.observations['github.repository'];
|
|
385
|
+
const branch = repository?.availability === 'observed' && isRecord(repository.value) && typeof repository.value.defaultBranch === 'string'
|
|
386
|
+
? repository.value.defaultBranch : null;
|
|
387
|
+
const declaredBindings = requiredContextBindings(facts.rulesets, liveScope.refs, branch);
|
|
388
|
+
const enforcedBindings = rules?.availability === 'observed' ? requiredContextBindings(rules.value, liveScope.refs, branch) : null;
|
|
389
|
+
observations.declared.facts = jsonValue(declaredBindings);
|
|
390
|
+
const bindingsDiffer = Boolean(contexts?.length && declaredBindings && enforcedBindings &&
|
|
391
|
+
canonicalJson(declaredBindings) !== canonicalJson(enforcedBindings));
|
|
392
|
+
if (bindingsDiffer) {
|
|
393
|
+
difference = 'conflicting';
|
|
394
|
+
const checks = live.observations['github.checks'];
|
|
395
|
+
const reason = 'Declared and enforced per-ref required context/application bindings differ.';
|
|
396
|
+
observations.live = checks?.availability === 'observed'
|
|
397
|
+
? predicateObservation(bool(false, reason), rules?.source ?? null)
|
|
398
|
+
: notObserved(`${reason} Check-run proof remains unobserved: ${checks?.reason ?? 'not collected'}`, rules?.source ?? null);
|
|
399
|
+
}
|
|
400
|
+
else {
|
|
401
|
+
observations.live = rules?.availability === 'observed' && declaredBindings && enforcedBindings
|
|
402
|
+
? livePredicate(live, 'github.checks', (value) => observedRequiredContexts(rules.value, value, branch))
|
|
403
|
+
: notObserved('Live rule and exact commit-bound check observations are required.');
|
|
404
|
+
}
|
|
405
|
+
observations.live.facts = jsonValue({
|
|
406
|
+
enforcedBindings, checkRuns: live.observations['github.checks']?.value ?? null
|
|
407
|
+
});
|
|
408
|
+
break;
|
|
409
|
+
}
|
|
410
|
+
case 'push-protection':
|
|
411
|
+
observations.live = livePredicate(live, 'github.security', (value) => {
|
|
412
|
+
if (!isRecord(value))
|
|
413
|
+
return bool(null, 'Security feature metadata is unavailable.');
|
|
414
|
+
const scanning = isRecord(value.secret_scanning) ? value.secret_scanning.status : value.secretScanning;
|
|
415
|
+
const protection = isRecord(value.secret_scanning_push_protection) ? value.secret_scanning_push_protection.status : value.pushProtection;
|
|
416
|
+
return bool(typeof scanning === 'string' && typeof protection === 'string' ? scanning === 'enabled' && protection === 'enabled' : null, 'Secret scanning and push protection must both be enabled; omitted fields are not evidence of absence.');
|
|
417
|
+
});
|
|
418
|
+
break;
|
|
419
|
+
case 'workflow-permissions':
|
|
420
|
+
observations.declared = facts.workflows.length ? predicateObservation(workflowPermissions(facts.workflows), facts.workflowSource)
|
|
421
|
+
: absent('No workflow files were found.', facts.workflowSource);
|
|
422
|
+
break;
|
|
423
|
+
case 'action-pinning': {
|
|
424
|
+
const check = pinnedActions(facts.workflows);
|
|
425
|
+
observations.declared = predicateObservation(check, facts.workflowSource);
|
|
426
|
+
exceptionResource = check.exceptionResource;
|
|
427
|
+
break;
|
|
428
|
+
}
|
|
429
|
+
case 'fail-open-flags':
|
|
430
|
+
observations.declared = predicateObservation(failOpenFlags(facts.workflows, facts.rulesets), facts.workflowSource);
|
|
431
|
+
break;
|
|
432
|
+
case 'pipeline': {
|
|
433
|
+
observations.declared = predicateObservation(securityPipeline(facts.workflows), facts.workflowSource);
|
|
434
|
+
if (observations.declared.availability === 'not-observed' && facts.workflowSource) {
|
|
435
|
+
const inventory = observed(jsonValue({ actionReferences: actionReferences(facts.workflows) }), facts.workflowSource);
|
|
436
|
+
if (inventory.availability === 'observed')
|
|
437
|
+
observations.declared.value = inventory.value;
|
|
438
|
+
else
|
|
439
|
+
observations.declared = inventory;
|
|
440
|
+
}
|
|
441
|
+
observations.live = notObserved('Complete stage-role enforcement requires execution evidence, not a generic green workflow.');
|
|
442
|
+
break;
|
|
443
|
+
}
|
|
444
|
+
case 'environments':
|
|
445
|
+
expected = true;
|
|
446
|
+
observations.live = livePredicate(live, 'github.environments', (value) => {
|
|
447
|
+
const environments = rows(value);
|
|
448
|
+
if (!environments)
|
|
449
|
+
return bool(null, 'Environment inventory is unavailable.');
|
|
450
|
+
const selected = liveScope.environments.map((name) => environments.find((entry) => entry.name === name));
|
|
451
|
+
if (selected.some((entry) => !entry))
|
|
452
|
+
return {
|
|
453
|
+
...bool(false, 'A declared deployment environment is absent from the complete live inventory.'), absent: true
|
|
454
|
+
};
|
|
455
|
+
return bool(selected.every((entry) => typeof entry?.reviewers === 'number') ? selected.every((entry) => entry?.reviewers === 0) : null, 'Declared deployment environments must not require human reviewers.');
|
|
456
|
+
});
|
|
457
|
+
break;
|
|
458
|
+
case 'runner': {
|
|
459
|
+
observations.recorded = liveScope.runner ? observed(true, source('evidence', 'runner-ready repository binding', capturedAt, jsonValue(liveScope.runner))) : notObserved('No current, exact runner/group/network binding is available.');
|
|
460
|
+
observations.live = livePredicate(live, 'github.runner', (value) => bool(isRecord(value) && value.repositoryAssigned === true
|
|
461
|
+
&& value.groupId === liveScope.runner?.groupId && value.runnerId === liveScope.runner?.runnerId ? true : null, 'Exact repository/group/network assignment, labels and runner capacity must be observed.'));
|
|
462
|
+
break;
|
|
463
|
+
}
|
|
464
|
+
case 'providers':
|
|
465
|
+
observations.live = livePredicate(live, 'azure.providers', (value) => {
|
|
466
|
+
const providers = rows(value);
|
|
467
|
+
return bool(!providers?.length ? null : providers.every((entry) => entry.registrationState === 'Registered'), 'Every explicitly required namespace must have terminal Registered readback.');
|
|
468
|
+
});
|
|
469
|
+
expected = true;
|
|
470
|
+
break;
|
|
471
|
+
case 'storage':
|
|
472
|
+
observations.live = livePredicate(live, 'azure.resources', (value) => {
|
|
473
|
+
const storage = rows(value)?.filter((entry) => String(entry.resourceType).toLowerCase() === 'microsoft.storage/storageaccounts');
|
|
474
|
+
if (!storage?.length)
|
|
475
|
+
return bool(null, 'No explicit environment/role-bound storage observation is available.');
|
|
476
|
+
const values = storage.map((entry) => {
|
|
477
|
+
const sku = isRecord(entry.sku) ? entry.sku.name : entry.sku;
|
|
478
|
+
if (typeof sku !== 'string' || !['state', 'application'].includes(String(entry.role)) || !['dev', 'staging', 'prod'].includes(String(entry.environment)))
|
|
479
|
+
return null;
|
|
480
|
+
return sku.endsWith(entry.role === 'state' || entry.environment !== 'dev' ? '_ZRS' : '_LRS');
|
|
481
|
+
});
|
|
482
|
+
return bool(values.includes(false) ? false : values.includes(null) ? null : true, 'State storage requires ZRS; application storage uses Dev LRS and Staging/Production ZRS.');
|
|
483
|
+
});
|
|
484
|
+
expected = true;
|
|
485
|
+
break;
|
|
486
|
+
case 'network':
|
|
487
|
+
observations.recorded = liveScope.azure.some((entry) => entry.role === 'runner-network') ? observed(true, source('evidence', 'approved Staging network bindings', capturedAt, jsonValue(liveScope.azure))) : notObserved('No approved Staging network bindings were available.');
|
|
488
|
+
observations.live = livePredicate(live, 'azure.resources', (value) => {
|
|
489
|
+
const network = rows(value)?.filter((entry) => entry.role === 'runner-network');
|
|
490
|
+
if (!network?.length)
|
|
491
|
+
return bool(null, 'No bound runner-network readback is available.');
|
|
492
|
+
const subnets = network.filter((entry) => String(entry.resourceType).toLowerCase() === 'microsoft.network/virtualnetworks/subnets');
|
|
493
|
+
if (subnets.some((entry) => isRecord(entry.properties) && entry.properties.defaultOutboundAccess === true))
|
|
494
|
+
return bool(false, 'A runner subnet still enables implicit default outbound access.');
|
|
495
|
+
return bool(null, 'Full subnet, route, DNS and exclusive NAT/Firewall proof is required; observed metadata alone is insufficient.');
|
|
496
|
+
});
|
|
497
|
+
break;
|
|
498
|
+
case 'evidence':
|
|
499
|
+
observations.evidence = evidenceObservation(project, git, capturedAt);
|
|
500
|
+
break;
|
|
501
|
+
case 'documentation':
|
|
502
|
+
observations.declared = facts.documentation;
|
|
503
|
+
break;
|
|
504
|
+
default:
|
|
505
|
+
for (const layer of control.proofLayers)
|
|
506
|
+
observations[layer] = notObserved(`No supported evaluator exists for ${control.id}; the required proof is not inferred.`);
|
|
507
|
+
}
|
|
508
|
+
return classifyFinding({
|
|
509
|
+
control, scope, expected, applicability: applicability(control, project, now), observations, difference,
|
|
510
|
+
exception: findAssessmentException(control, project.approvals, scope, facts.baseline, exceptionResource, now)
|
|
511
|
+
});
|
|
512
|
+
}
|
|
513
|
+
export async function assessGovernance(projectRoot, options = {}) {
|
|
514
|
+
const now = options.now ?? (() => new Date());
|
|
515
|
+
const captured = now();
|
|
516
|
+
const files = new AssessmentFiles(projectRoot);
|
|
517
|
+
let target = null;
|
|
518
|
+
let projectIdentity = {
|
|
519
|
+
availability: 'unavailable', manifestVersion: null, cliVersion: null, profile: null,
|
|
520
|
+
policyVersion: null, recordedActivationIdentity: null, stateSource: 'unavailable'
|
|
521
|
+
};
|
|
522
|
+
try {
|
|
523
|
+
const loaded = loadAssessmentCatalog();
|
|
524
|
+
target = loaded.target;
|
|
525
|
+
const project = await inspectAssessmentProject(files);
|
|
526
|
+
projectIdentity = project.identity;
|
|
527
|
+
const disabled = project.identity.profile === 'none';
|
|
528
|
+
if (disabled)
|
|
529
|
+
return assembleAssessmentReport({
|
|
530
|
+
projectRoot, mode: options.live ? 'live' : 'local', target, projectIdentity,
|
|
531
|
+
snapshot: { capturedAt: captured.toISOString(), repository: null, localHead: null, worktreeDigest: files.digest(), inputsStable: await files.stable() },
|
|
532
|
+
findings: [], diagnostics: project.diagnostics, disabled: true
|
|
533
|
+
});
|
|
534
|
+
const git = await inspectAssessmentGit(projectRoot, options.runner);
|
|
535
|
+
project.diagnostics.push(...git.issues.map((message) => ({
|
|
536
|
+
code: 'git-not-observed', severity: 'warning', message, source: 'Git metadata'
|
|
537
|
+
})));
|
|
538
|
+
const facts = await localFacts(files, project, captured.toISOString());
|
|
539
|
+
const scope = resolveAssessmentLiveScope(project, git, captured);
|
|
540
|
+
const repository = scope.repository ? `${scope.repository.owner}/${scope.repository.name}` : null;
|
|
541
|
+
const live = options.live ? await collectLiveAssessment(scope, { runner: options.runner, now })
|
|
542
|
+
: { observations: {}, diagnostics: [], refsStable: true };
|
|
543
|
+
const findings = [];
|
|
544
|
+
for (const control of loaded.catalog.controls) {
|
|
545
|
+
if (control.evaluator === 'managed-core') {
|
|
546
|
+
findings.push(...await managedFindings(control, project, files, { repository, environment: null, resource: null }, captured.toISOString()));
|
|
547
|
+
}
|
|
548
|
+
else
|
|
549
|
+
findings.push(evaluateControl(control, project, facts, live, git, scope, captured));
|
|
550
|
+
}
|
|
551
|
+
const worktreeDigest = files.digest();
|
|
552
|
+
const finalGit = await inspectAssessmentGit(projectRoot, options.runner);
|
|
553
|
+
const inputsStable = await files.stable() && live.refsStable &&
|
|
554
|
+
canonicalJson(git) === canonicalJson(finalGit);
|
|
555
|
+
if (!inputsStable) {
|
|
556
|
+
for (const finding of findings) {
|
|
557
|
+
if (finding.applicability === 'inapplicable')
|
|
558
|
+
continue;
|
|
559
|
+
finding.classification = 'not-observed';
|
|
560
|
+
finding.exception = null;
|
|
561
|
+
finding.missingProof = [...finding.requiredProof];
|
|
562
|
+
finding.reasons.push('Relevant files or refs changed during collection; rerun assessment for a consistent snapshot.');
|
|
563
|
+
for (const layer of finding.requiredProof)
|
|
564
|
+
finding.observations[layer] = notObserved('Inputs changed during collection.');
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
return assembleAssessmentReport({
|
|
568
|
+
projectRoot, mode: options.live ? 'live' : 'local', target, projectIdentity,
|
|
569
|
+
snapshot: { capturedAt: captured.toISOString(), repository, localHead: git.head, worktreeDigest, inputsStable },
|
|
570
|
+
findings, diagnostics: [...project.diagnostics, ...live.diagnostics]
|
|
571
|
+
});
|
|
572
|
+
}
|
|
573
|
+
catch (error) {
|
|
574
|
+
const diagnostic = {
|
|
575
|
+
code: error instanceof AssessmentInputError ? error.code : 'assessment-error', severity: 'error',
|
|
576
|
+
message: sanitizeAssessmentText(error instanceof Error ? error.message : 'Assessment could not inspect its inputs.'),
|
|
577
|
+
source: error instanceof AssessmentInputError && error.source ? sanitizeAssessmentText(error.source) : null
|
|
578
|
+
};
|
|
579
|
+
return assembleAssessmentReport({
|
|
580
|
+
projectRoot, mode: options.live ? 'live' : 'local', target, projectIdentity,
|
|
581
|
+
snapshot: { capturedAt: captured.toISOString(), repository: null, localHead: null, worktreeDigest: files.digest(), inputsStable: false },
|
|
582
|
+
findings: [], diagnostics: [diagnostic], failed: true
|
|
583
|
+
});
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
//# sourceMappingURL=engine.js.map
|