364 lines
20 KiB
JavaScript
364 lines
20 KiB
JavaScript
import fs from 'node:fs/promises'
|
|
import path from 'node:path'
|
|
import { loadCatalog, serviceRoot } from './catalog.mjs'
|
|
|
|
const ID_RE = /^[a-z][a-z0-9]*(?:\.[a-z][a-z0-9_]*)+$/
|
|
const RULE_ID_RE = /^[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)+$/
|
|
const ROLE_ID_RE = /^[a-z][a-z0-9_]*$/
|
|
const ALLOWED_RELATION_STATUSES = new Set([
|
|
'source-confirmed',
|
|
'source-evidenced',
|
|
'owner-confirmed',
|
|
'product-required',
|
|
'future-concept',
|
|
'tech-debt-noncanonical',
|
|
'pending',
|
|
])
|
|
|
|
function assert(condition, message, errors) {
|
|
if (!condition) errors.push(message)
|
|
}
|
|
|
|
function assertUnique(ids, label, errors) {
|
|
const seen = new Set()
|
|
for (const id of ids) {
|
|
assert(!seen.has(id), `${label} duplicate id: ${id}`, errors)
|
|
seen.add(id)
|
|
}
|
|
}
|
|
|
|
function assertEntityRefs(refs, entityById, label, errors) {
|
|
for (const ref of refs || []) {
|
|
assert(entityById.has(ref), `${label} references unknown entity: ${ref}`, errors)
|
|
}
|
|
}
|
|
|
|
function assertRelationRef(ref, relationById, label, errors) {
|
|
assert(relationById.has(ref), `${label} references unknown relation: ${ref}`, errors)
|
|
}
|
|
|
|
function assertContextRefs(refs, contextById, label, errors) {
|
|
for (const ref of refs || []) {
|
|
assert(contextById.has(ref), `${label} references unknown context: ${ref}`, errors)
|
|
}
|
|
}
|
|
|
|
function assertRefsObject(refs, label, errors) {
|
|
assert(refs && typeof refs === 'object' && !Array.isArray(refs), `${label} refs must be object`, errors)
|
|
for (const [key, value] of Object.entries(refs || {})) {
|
|
assert(/^[a-z][a-z0-9_]*$/i.test(key), `${label} refs has invalid key: ${key}`, errors)
|
|
assert(value !== null && value !== undefined && String(value).trim() !== '', `${label} refs.${key} is empty`, errors)
|
|
}
|
|
}
|
|
|
|
async function pathExists(relativePath) {
|
|
try {
|
|
await fs.access(path.join(serviceRoot, relativePath))
|
|
return true
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
export async function validateCatalog() {
|
|
const catalog = await loadCatalog()
|
|
const errors = []
|
|
const warnings = []
|
|
const {
|
|
entities,
|
|
relations,
|
|
aliases,
|
|
guardrails,
|
|
evidence,
|
|
resolverRules,
|
|
assistantAccessPolicy,
|
|
assistantActions,
|
|
assistantRiskPolicy,
|
|
contextBindings,
|
|
entityById,
|
|
relationById,
|
|
contextById,
|
|
bindingTypeById,
|
|
} = catalog
|
|
|
|
const statusVocabulary = new Set(entities.statusVocabulary || [])
|
|
|
|
assertUnique(entities.entities.map((entity) => entity.id), 'entity', errors)
|
|
for (const entity of entities.entities) {
|
|
assert(ID_RE.test(entity.id), `entity id has invalid format: ${entity.id}`, errors)
|
|
assert(entity.name, `entity ${entity.id} missing name`, errors)
|
|
assert(entity.surface, `entity ${entity.id} missing surface`, errors)
|
|
assert(Array.isArray(entity.status) && entity.status.length > 0, `entity ${entity.id} missing status`, errors)
|
|
for (const status of entity.status || []) {
|
|
assert(statusVocabulary.has(status), `entity ${entity.id} uses unknown status: ${status}`, errors)
|
|
}
|
|
}
|
|
|
|
assertUnique(relations.relations.map((relation) => relation.id), 'relation', errors)
|
|
for (const relation of relations.relations) {
|
|
assert(RULE_ID_RE.test(relation.id), `relation id has invalid format: ${relation.id}`, errors)
|
|
assert(ALLOWED_RELATION_STATUSES.has(relation.status), `relation ${relation.id} uses unknown status: ${relation.status}`, errors)
|
|
assertEntityRefs(relation.from, entityById, `relation ${relation.id}.from`, errors)
|
|
assertEntityRefs(relation.to, entityById, `relation ${relation.id}.to`, errors)
|
|
}
|
|
|
|
assertUnique(aliases.aliases.map((alias) => alias.alias.toLowerCase()), 'alias', errors)
|
|
for (const alias of aliases.aliases) {
|
|
assert(alias.alias, 'alias missing alias text', errors)
|
|
assert(entityById.has(alias.canonicalId), `alias ${alias.alias} points to unknown entity: ${alias.canonicalId}`, errors)
|
|
}
|
|
|
|
assertUnique(guardrails.rules.map((rule) => rule.id), 'guardrail', errors)
|
|
for (const rule of guardrails.rules) {
|
|
assert(RULE_ID_RE.test(rule.id), `guardrail id has invalid format: ${rule.id}`, errors)
|
|
assert(['error', 'warning'].includes(rule.severity), `guardrail ${rule.id} invalid severity`, errors)
|
|
assertEntityRefs(rule.entityIds, entityById, `guardrail ${rule.id}`, errors)
|
|
}
|
|
for (const pair of guardrails.blockedConflations || []) {
|
|
assert(Array.isArray(pair) && pair.length === 2, `blocked conflation must be a pair: ${JSON.stringify(pair)}`, errors)
|
|
assertEntityRefs(pair, entityById, 'blocked conflation', errors)
|
|
}
|
|
|
|
for (const ledger of evidence.ledgers || []) {
|
|
assert(await pathExists(ledger.path), `evidence ledger path missing: ${ledger.path}`, errors)
|
|
assertEntityRefs(ledger.entityIds, entityById, `ledger ${ledger.id}`, errors)
|
|
}
|
|
for (const baselineDoc of evidence.baselineDocs || []) {
|
|
assert(await pathExists(baselineDoc), `baseline doc path missing: ${baselineDoc}`, errors)
|
|
}
|
|
|
|
assertUnique(resolverRules.rules.map((rule) => rule.id), 'resolver rule', errors)
|
|
for (const rule of resolverRules.rules) {
|
|
assert(RULE_ID_RE.test(rule.id), `resolver rule id has invalid format: ${rule.id}`, errors)
|
|
assert(rule.fromSurface, `resolver rule ${rule.id} missing fromSurface`, errors)
|
|
assert(rule.outputSurface, `resolver rule ${rule.id} missing outputSurface`, errors)
|
|
assertRelationRef(rule.relationId, relationById, `resolver rule ${rule.id}`, errors)
|
|
assertEntityRefs(rule.inputEntityIds, entityById, `resolver rule ${rule.id}.inputEntityIds`, errors)
|
|
assertEntityRefs(rule.outputEntityIds, entityById, `resolver rule ${rule.id}.outputEntityIds`, errors)
|
|
if (rule.status !== 'product-required') {
|
|
warnings.push(`resolver rule ${rule.id} has non-product-required status: ${rule.status}`)
|
|
}
|
|
}
|
|
|
|
const assistantRoleIds = new Set((assistantAccessPolicy.roleVocabulary || []).map((role) => role.id))
|
|
const assistantCapabilityIds = new Set((assistantAccessPolicy.capabilities || []).map((capability) => capability.id))
|
|
const assistantOperationIds = new Set((assistantAccessPolicy.operations || []).map((operation) => operation.id))
|
|
const assistantHardDenyIds = new Set((assistantAccessPolicy.hardDenies || []).map((deny) => deny.id))
|
|
const assistantRiskLevelIds = new Set((assistantRiskPolicy.riskLevels || []).map((riskLevel) => riskLevel.id))
|
|
const assistantConfirmationModeIds = new Set((assistantRiskPolicy.confirmationModes || []).map((mode) => mode.id))
|
|
const assistantSelfActionPolicyIds = new Set((assistantRiskPolicy.selfActionPolicies || []).map((policy) => policy.id))
|
|
const assistantActionHardDenyIds = new Set((assistantRiskPolicy.hardDenies || []).map((deny) => deny.id))
|
|
const assistantAppIds = new Set((assistantActions.apps || []).map((app) => app.id))
|
|
const assistantAdapterStatuses = new Set(assistantActions.adapterStatuses || [])
|
|
const assistantActionIds = new Set((assistantActions.actions || []).map((action) => action.id))
|
|
|
|
assertUnique([...assistantRoleIds], 'assistant role', errors)
|
|
for (const role of assistantAccessPolicy.roleVocabulary || []) {
|
|
assert(ROLE_ID_RE.test(role.id), `assistant role id has invalid format: ${role.id}`, errors)
|
|
assert(role.name, `assistant role ${role.id} missing name`, errors)
|
|
assert(role.summary, `assistant role ${role.id} missing summary`, errors)
|
|
}
|
|
|
|
assertUnique((assistantAccessPolicy.roleAliases || []).map((alias) => alias.alias.toLowerCase()), 'assistant role alias', errors)
|
|
for (const alias of assistantAccessPolicy.roleAliases || []) {
|
|
assert(alias.alias, 'assistant role alias missing alias text', errors)
|
|
assert(assistantRoleIds.has(alias.roleId), `assistant role alias ${alias.alias} references unknown role: ${alias.roleId}`, errors)
|
|
}
|
|
|
|
for (const [key, roleId] of Object.entries(assistantAccessPolicy.defaultResolution || {})) {
|
|
if (key.endsWith('Role')) {
|
|
assert(assistantRoleIds.has(roleId), `assistant defaultResolution.${key} references unknown role: ${roleId}`, errors)
|
|
}
|
|
}
|
|
|
|
for (const sourceAuthority of assistantAccessPolicy.sourceAuthorities || []) {
|
|
assert(RULE_ID_RE.test(sourceAuthority.id), `assistant source authority id has invalid format: ${sourceAuthority.id}`, errors)
|
|
assertEntityRefs(sourceAuthority.entityIds, entityById, `assistant source authority ${sourceAuthority.id}`, errors)
|
|
}
|
|
|
|
assertUnique([...assistantCapabilityIds], 'assistant capability', errors)
|
|
for (const capability of assistantAccessPolicy.capabilities || []) {
|
|
assert(RULE_ID_RE.test(capability.id), `assistant capability id has invalid format: ${capability.id}`, errors)
|
|
assert(entityById.has(capability.entityId), `assistant capability ${capability.id} references unknown entity: ${capability.entityId}`, errors)
|
|
assert(capability.summary, `assistant capability ${capability.id} missing summary`, errors)
|
|
}
|
|
|
|
assertUnique((assistantAccessPolicy.roleMatrix || []).map((matrix) => matrix.roleId), 'assistant role matrix', errors)
|
|
for (const matrix of assistantAccessPolicy.roleMatrix || []) {
|
|
assert(assistantRoleIds.has(matrix.roleId), `assistant matrix references unknown role: ${matrix.roleId}`, errors)
|
|
for (const capabilityId of matrix.allowedCapabilities || []) {
|
|
assert(assistantCapabilityIds.has(capabilityId), `assistant matrix ${matrix.roleId} allows unknown capability: ${capabilityId}`, errors)
|
|
}
|
|
for (const capabilityId of matrix.deniedCapabilities || []) {
|
|
if (capabilityId === '*') continue
|
|
assert(assistantCapabilityIds.has(capabilityId), `assistant matrix ${matrix.roleId} denies unknown capability: ${capabilityId}`, errors)
|
|
}
|
|
for (const capabilityId of matrix.deniedWhenNoAdminScope || []) {
|
|
assert(assistantCapabilityIds.has(capabilityId), `assistant matrix ${matrix.roleId} no-scope denies unknown capability: ${capabilityId}`, errors)
|
|
}
|
|
for (const conditional of matrix.conditionalCapabilities || []) {
|
|
assert(assistantCapabilityIds.has(conditional.capabilityId), `assistant matrix ${matrix.roleId} conditional unknown capability: ${conditional.capabilityId}`, errors)
|
|
assert(conditional.requires, `assistant matrix ${matrix.roleId} conditional ${conditional.capabilityId} missing requires`, errors)
|
|
}
|
|
}
|
|
|
|
assertUnique([...assistantOperationIds], 'assistant operation', errors)
|
|
for (const operation of assistantAccessPolicy.operations || []) {
|
|
assert(RULE_ID_RE.test(operation.id), `assistant operation id has invalid format: ${operation.id}`, errors)
|
|
assert(assistantCapabilityIds.has(operation.capabilityId), `assistant operation ${operation.id} references unknown capability: ${operation.capabilityId}`, errors)
|
|
for (const denyId of operation.hardDenies || []) {
|
|
assert(assistantHardDenyIds.has(denyId), `assistant operation ${operation.id} references unknown hard deny: ${denyId}`, errors)
|
|
}
|
|
assert(operation.summary, `assistant operation ${operation.id} missing summary`, errors)
|
|
}
|
|
|
|
assertUnique((assistantAccessPolicy.hardDenies || []).map((deny) => deny.id), 'assistant hard deny', errors)
|
|
for (const deny of assistantAccessPolicy.hardDenies || []) {
|
|
assert(RULE_ID_RE.test(deny.id), `assistant hard deny id has invalid format: ${deny.id}`, errors)
|
|
assertEntityRefs(deny.entityIds, entityById, `assistant hard deny ${deny.id}`, errors)
|
|
}
|
|
|
|
assertUnique([...assistantRiskLevelIds], 'assistant risk level', errors)
|
|
for (const riskLevel of assistantRiskPolicy.riskLevels || []) {
|
|
assert(ROLE_ID_RE.test(riskLevel.id), `assistant risk level id has invalid format: ${riskLevel.id}`, errors)
|
|
assert(riskLevel.summary, `assistant risk level ${riskLevel.id} missing summary`, errors)
|
|
}
|
|
|
|
assertUnique([...assistantConfirmationModeIds], 'assistant confirmation mode', errors)
|
|
for (const mode of assistantRiskPolicy.confirmationModes || []) {
|
|
assert(ROLE_ID_RE.test(mode.id), `assistant confirmation mode id has invalid format: ${mode.id}`, errors)
|
|
assert(mode.summary, `assistant confirmation mode ${mode.id} missing summary`, errors)
|
|
}
|
|
|
|
assertUnique([...assistantSelfActionPolicyIds], 'assistant self action policy', errors)
|
|
for (const policy of assistantRiskPolicy.selfActionPolicies || []) {
|
|
assert(ROLE_ID_RE.test(policy.id), `assistant self action policy id has invalid format: ${policy.id}`, errors)
|
|
assert(policy.summary, `assistant self action policy ${policy.id} missing summary`, errors)
|
|
}
|
|
|
|
assertUnique([...assistantActionHardDenyIds], 'assistant action hard deny', errors)
|
|
for (const deny of assistantRiskPolicy.hardDenies || []) {
|
|
assert(RULE_ID_RE.test(deny.id), `assistant action hard deny id has invalid format: ${deny.id}`, errors)
|
|
for (const actionId of deny.safeAlternatives || []) {
|
|
assert(assistantActionIds.has(actionId), `assistant action hard deny ${deny.id} references unknown safe alternative: ${actionId}`, errors)
|
|
}
|
|
}
|
|
|
|
assertUnique((assistantRiskPolicy.globalRules || []).map((rule) => rule.id), 'assistant risk global rule', errors)
|
|
for (const rule of assistantRiskPolicy.globalRules || []) {
|
|
assert(RULE_ID_RE.test(rule.id), `assistant risk global rule id has invalid format: ${rule.id}`, errors)
|
|
assert(['error', 'warning'].includes(rule.severity), `assistant risk global rule ${rule.id} invalid severity`, errors)
|
|
assert(rule.summary, `assistant risk global rule ${rule.id} missing summary`, errors)
|
|
}
|
|
|
|
assertUnique([...assistantAppIds], 'assistant app', errors)
|
|
for (const app of assistantActions.apps || []) {
|
|
assert(ROLE_ID_RE.test(app.id), `assistant app id has invalid format: ${app.id}`, errors)
|
|
assert(app.name, `assistant app ${app.id} missing name`, errors)
|
|
assert(app.adapterId, `assistant app ${app.id} missing adapterId`, errors)
|
|
assertEntityRefs(app.authorityEntityIds, entityById, `assistant app ${app.id}.authorityEntityIds`, errors)
|
|
}
|
|
|
|
assertUnique([...assistantAdapterStatuses], 'assistant adapter status', errors)
|
|
for (const status of assistantActions.adapterStatuses || []) {
|
|
assert(ROLE_ID_RE.test(status), `assistant adapter status has invalid format: ${status}`, errors)
|
|
}
|
|
|
|
assertUnique([...assistantActionIds], 'assistant action', errors)
|
|
for (const action of assistantActions.actions || []) {
|
|
assert(RULE_ID_RE.test(action.id), `assistant action id has invalid format: ${action.id}`, errors)
|
|
assert(assistantAppIds.has(action.app), `assistant action ${action.id} references unknown app: ${action.app}`, errors)
|
|
assert(action.domain, `assistant action ${action.id} missing domain`, errors)
|
|
assert(Array.isArray(action.intentAliases), `assistant action ${action.id} intentAliases must be array`, errors)
|
|
assertEntityRefs(action.entityIds, entityById, `assistant action ${action.id}.entityIds`, errors)
|
|
for (const capabilityId of action.capabilityIds || []) {
|
|
assert(assistantCapabilityIds.has(capabilityId), `assistant action ${action.id} references unknown capability: ${capabilityId}`, errors)
|
|
}
|
|
assert(assistantRiskLevelIds.has(action.riskLevel), `assistant action ${action.id} references unknown riskLevel: ${action.riskLevel}`, errors)
|
|
assert(assistantConfirmationModeIds.has(action.confirmationMode), `assistant action ${action.id} references unknown confirmationMode: ${action.confirmationMode}`, errors)
|
|
assert(assistantSelfActionPolicyIds.has(action.selfActionPolicy), `assistant action ${action.id} references unknown selfActionPolicy: ${action.selfActionPolicy}`, errors)
|
|
assert(action.adapterId, `assistant action ${action.id} missing adapterId`, errors)
|
|
assert(assistantAdapterStatuses.has(action.adapterStatus), `assistant action ${action.id} references unknown adapterStatus: ${action.adapterStatus}`, errors)
|
|
for (const denyId of action.hardDenyIds || []) {
|
|
assert(assistantActionHardDenyIds.has(denyId), `assistant action ${action.id} references unknown hard deny: ${denyId}`, errors)
|
|
}
|
|
for (const actionId of action.safeAlternativeActionIds || []) {
|
|
assert(assistantActionIds.has(actionId), `assistant action ${action.id} references unknown safe alternative action: ${actionId}`, errors)
|
|
}
|
|
if (action.riskLevel === 'destructive') {
|
|
assert(action.confirmationMode === 'forbidden', `assistant action ${action.id} destructive action must use confirmationMode=forbidden`, errors)
|
|
assert(action.adapterStatus === 'forbidden', `assistant action ${action.id} destructive action must use adapterStatus=forbidden`, errors)
|
|
}
|
|
if (action.confirmationMode === 'forbidden') {
|
|
assert(action.refusal, `assistant action ${action.id} forbidden action missing refusal`, errors)
|
|
}
|
|
if (action.riskLevel === 'privileged') {
|
|
assert(Array.isArray(action.requiredScopes) && action.requiredScopes.length > 0, `assistant action ${action.id} privileged action missing requiredScopes`, errors)
|
|
}
|
|
}
|
|
|
|
const contextStatuses = new Set(contextBindings.contextStatuses || [])
|
|
const bindingStatuses = new Set(contextBindings.bindingStatuses || [])
|
|
|
|
assertUnique(contextBindings.contexts.map((context) => context.id), 'context', errors)
|
|
for (const context of contextBindings.contexts) {
|
|
assert(RULE_ID_RE.test(context.id), `context id has invalid format: ${context.id}`, errors)
|
|
assert(entityById.has(context.entityId), `context ${context.id} references unknown entity: ${context.entityId}`, errors)
|
|
assert(contextStatuses.has(context.status), `context ${context.id} has invalid status: ${context.status}`, errors)
|
|
assert(context.surface, `context ${context.id} missing surface`, errors)
|
|
assert(context.sourceSystem, `context ${context.id} missing sourceSystem`, errors)
|
|
assertRefsObject(context.refs, `context ${context.id}`, errors)
|
|
if (context.parentContextId) {
|
|
assertContextRefs([context.parentContextId], contextById, `context ${context.id}`, errors)
|
|
}
|
|
}
|
|
|
|
assertUnique(contextBindings.bindingTypes.map((bindingType) => bindingType.id), 'binding type', errors)
|
|
for (const bindingType of contextBindings.bindingTypes) {
|
|
assert(RULE_ID_RE.test(bindingType.id), `binding type id has invalid format: ${bindingType.id}`, errors)
|
|
assertRelationRef(bindingType.relationId, relationById, `binding type ${bindingType.id}`, errors)
|
|
assertEntityRefs(bindingType.fromEntityIds, entityById, `binding type ${bindingType.id}.fromEntityIds`, errors)
|
|
assertEntityRefs(bindingType.toEntityIds, entityById, `binding type ${bindingType.id}.toEntityIds`, errors)
|
|
}
|
|
|
|
assertUnique(contextBindings.bindings.map((binding) => binding.id), 'binding', errors)
|
|
for (const binding of contextBindings.bindings) {
|
|
assert(RULE_ID_RE.test(binding.id), `binding id has invalid format: ${binding.id}`, errors)
|
|
assert(bindingStatuses.has(binding.status), `binding ${binding.id} has invalid status: ${binding.status}`, errors)
|
|
assert(bindingTypeById.has(binding.typeId), `binding ${binding.id} references unknown binding type: ${binding.typeId}`, errors)
|
|
assertContextRefs([binding.fromContextId, binding.toContextId], contextById, `binding ${binding.id}`, errors)
|
|
}
|
|
|
|
return {
|
|
ok: errors.length === 0,
|
|
errors,
|
|
warnings,
|
|
counts: {
|
|
entities: entities.entities.length,
|
|
relations: relations.relations.length,
|
|
aliases: aliases.aliases.length,
|
|
guardrails: guardrails.rules.length,
|
|
evidenceLedgers: evidence.ledgers.length,
|
|
resolverRules: resolverRules.rules.length,
|
|
assistantRoles: assistantAccessPolicy.roleVocabulary.length,
|
|
assistantCapabilities: assistantAccessPolicy.capabilities.length,
|
|
assistantOperations: assistantAccessPolicy.operations.length,
|
|
assistantActions: assistantActions.actions.length,
|
|
assistantApps: assistantActions.apps.length,
|
|
assistantRiskRules: assistantRiskPolicy.globalRules.length,
|
|
contexts: contextBindings.contexts.length,
|
|
bindingTypes: contextBindings.bindingTypes.length,
|
|
bindings: contextBindings.bindings.length,
|
|
},
|
|
}
|
|
}
|
|
|
|
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
const result = await validateCatalog()
|
|
if (!result.ok) {
|
|
console.error(JSON.stringify(result, null, 2))
|
|
process.exit(1)
|
|
}
|
|
console.log(JSON.stringify(result, null, 2))
|
|
}
|