feat(ontology): add core service catalog

This commit is contained in:
Codex
2026-06-20 12:54:03 +03:00
parent 83cca4224b
commit 2d5fef3948
59 changed files with 7878 additions and 0 deletions
@@ -0,0 +1,331 @@
import fs from 'node:fs/promises'
import { loadCatalog } from './catalog.mjs'
import { resolveAssistantAccess } from './assistant-policy.mjs'
import { validateCatalog } from './validate.mjs'
function normalizeText(value) {
return String(value || '').trim().toLowerCase()
}
function tokenize(value) {
return normalizeText(value)
.split(/[^a-zа-яё0-9_]+/iu)
.filter(Boolean)
}
function unique(values) {
return [...new Set(values)]
}
function actionSearchText(action) {
return [
action.id,
action.app,
action.domain,
action.summary,
...(action.intentAliases || []),
].join(' ')
}
function scoreAction(action, input) {
if (input.actionId && input.actionId === action.id) return 1000
const query = normalizeText(input.intent || input.term || input.text || input.query)
if (!query) return 0
const aliases = (action.intentAliases || []).map(normalizeText)
if (aliases.includes(query)) return 200
if (aliases.some((alias) => alias && query.includes(alias))) return 160
const queryTokens = new Set(tokenize(query))
if (!queryTokens.size) return 0
const actionTokens = new Set(tokenize(actionSearchText(action)))
let score = 0
for (const token of queryTokens) {
if (actionTokens.has(token)) score += 10
}
if (input.app && input.app === action.app) score += 20
return score
}
function findAction(catalog, input) {
const candidates = catalog.assistantActions.actions
.map((action) => ({ action, score: scoreAction(action, input) }))
.filter((candidate) => candidate.score > 0)
.sort((a, b) => b.score - a.score || a.action.id.localeCompare(b.action.id))
return {
selected: candidates[0]?.action || null,
candidates: candidates.slice(0, 5).map(({ action, score }) => ({
id: action.id,
app: action.app,
riskLevel: action.riskLevel,
confirmationMode: action.confirmationMode,
adapterStatus: action.adapterStatus,
score,
summary: action.summary,
})),
}
}
function collectSafeAlternativeIds(action, riskPolicy) {
const ids = [...(action.safeAlternativeActionIds || [])]
for (const denyId of action.hardDenyIds || []) {
const deny = riskPolicy.hardDenies.find((candidate) => candidate.id === denyId)
ids.push(...(deny?.safeAlternatives || []))
}
return unique(ids.filter((id) => id !== action.id))
}
function describeAlternatives(ids, actionById) {
return ids.map((id) => {
const action = actionById.get(id)
return {
id,
app: action?.app || null,
riskLevel: action?.riskLevel || null,
confirmationMode: action?.confirmationMode || null,
summary: action?.summary || null,
}
})
}
function missingCapabilities(action, access) {
const allowed = new Set(access.allowedCapabilities || [])
return (action.capabilityIds || []).filter((capabilityId) => !allowed.has(capabilityId))
}
function isSelfTarget(input) {
if (!input.actorUserId || !input.targetUserId) return false
return String(input.actorUserId) === String(input.targetUserId)
}
function makeDecision(decision, action, input, access, extra = {}) {
return {
ok: decision === 'allowed',
decision,
action: action ? {
id: action.id,
app: action.app,
domain: action.domain,
riskLevel: action.riskLevel,
confirmationMode: action.confirmationMode,
selfActionPolicy: action.selfActionPolicy,
adapterId: action.adapterId,
adapterStatus: action.adapterStatus,
requiredScopes: action.requiredScopes || [],
capabilityIds: action.capabilityIds || [],
summary: action.summary,
} : null,
actor: {
assistantRole: input.assistantRole || null,
effectiveRole: access?.effectiveRole || null,
adminScope: access?.adminScope || null,
},
execution: action ? {
policyAllowed: decision === 'allowed',
adapterReady: action.adapterStatus === 'implemented',
executionReady: decision === 'allowed' && action.adapterStatus === 'implemented',
requiresConfirmation: action.confirmationMode === 'explicit' || action.confirmationMode === 'typed',
confirmed: Boolean(input.confirmed),
} : null,
...extra,
}
}
export async function resolveAssistantAction(input = {}, options = {}) {
const catalog = options.catalog || await loadCatalog()
const actionById = new Map(catalog.assistantActions.actions.map((action) => [action.id, action]))
const riskPolicy = catalog.assistantRiskPolicy
const found = findAction(catalog, input)
const action = found.selected
if (!action) {
return {
ok: false,
decision: 'action_not_registered',
reason: 'Request did not resolve to a registered assistant action card.',
candidates: found.candidates,
}
}
const access = await resolveAssistantAccess(input, { policy: catalog.assistantAccessPolicy })
const safeAlternativeIds = collectSafeAlternativeIds(action, riskPolicy)
const safeAlternatives = describeAlternatives(safeAlternativeIds, actionById)
if (action.riskLevel === 'destructive' || action.confirmationMode === 'forbidden' || action.adapterStatus === 'forbidden') {
return makeDecision('forbidden', action, input, access, {
reason: 'destructive_or_forbidden_action',
refusal: action.refusal || 'Assistant action is forbidden by risk policy.',
hardDenyIds: action.hardDenyIds || [],
safeAlternatives,
candidates: found.candidates,
})
}
if (action.selfActionPolicy === 'blocked' && isSelfTarget(input)) {
return makeDecision('denied', action, input, access, {
reason: 'self_targeting_blocked',
hardDenyIds: action.hardDenyIds || [],
safeAlternatives,
candidates: found.candidates,
})
}
if (action.selfActionPolicy === 'no_self_lockout' && isSelfTarget(input)) {
const removesOwnAccess = Boolean(input.removesOwnAccess || input.targetRole === 'assistant_blocked' || input.targetStatus === 'disabled' || input.globalStatus === 'blocked')
const hasAlternativeAdminPath = Boolean(input.hasAlternativeAdminPath)
if (removesOwnAccess && !hasAlternativeAdminPath) {
return makeDecision('denied', action, input, access, {
reason: 'self_lockout_denied',
hardDenyIds: unique([...(action.hardDenyIds || []), 'assistant.action.deny.self_lockout']),
safeAlternatives,
candidates: found.candidates,
})
}
}
const missing = missingCapabilities(action, access)
if (missing.length) {
return makeDecision('denied', action, input, access, {
reason: 'assistant_role_missing_capability',
missingCapabilities: missing,
safeAlternatives,
candidates: found.candidates,
})
}
if (action.riskLevel === 'privileged' && access.effectiveRole !== 'assistant_admin') {
return makeDecision('denied', action, input, access, {
reason: 'privileged_action_requires_assistant_admin',
safeAlternatives,
candidates: found.candidates,
})
}
if (action.adapterStatus === 'future') {
return makeDecision('future_adapter', action, input, access, {
reason: 'app_owned_adapter_not_available_yet',
safeAlternatives,
candidates: found.candidates,
})
}
if ((action.confirmationMode === 'explicit' || action.confirmationMode === 'typed') && !input.confirmed) {
return makeDecision('needs_confirmation', action, input, access, {
reason: 'explicit_confirmation_required',
confirmationRequest: {
actionId: action.id,
app: action.app,
targetUserId: input.targetUserId || null,
expectedEffect: action.summary,
},
safeAlternatives,
candidates: found.candidates,
})
}
return makeDecision('allowed', action, input, access, {
reason: action.adapterStatus === 'implemented' ? 'policy_and_adapter_ready' : 'policy_allowed_adapter_not_implemented',
safeAlternatives,
candidates: found.candidates,
})
}
async function readInputJson() {
const inputIndex = process.argv.indexOf('--input-json')
if (inputIndex >= 0) {
const inputPath = process.argv[inputIndex + 1]
if (!inputPath) throw new Error('--input-json requires a path')
return JSON.parse(await fs.readFile(inputPath, 'utf8'))
}
const inlineIndex = process.argv.indexOf('--input')
if (inlineIndex >= 0) {
return JSON.parse(process.argv[inlineIndex + 1] || '{}')
}
return null
}
function assertSmoke(condition, message) {
if (!condition) throw new Error(message)
}
async function runSmoke() {
const validation = await validateCatalog()
assertSmoke(validation.ok, 'catalog must validate before assistant action smoke')
const cases = {
deleteUser: await resolveAssistantAction({
actionId: 'hub.user.delete',
assistantRole: 'admin',
membershipRole: 'client_admin',
actorUserId: 'user_root',
targetUserId: 'user_pupa',
confirmed: true,
}),
blockAsMember: await resolveAssistantAction({
actionId: 'hub.user.block',
assistantRole: 'member',
membershipRole: 'member',
actorUserId: 'user_root',
targetUserId: 'user_pupa',
confirmed: true,
}),
blockNeedsConfirmation: await resolveAssistantAction({
actionId: 'hub.user.block',
assistantRole: 'admin',
membershipRole: 'client_admin',
actorUserId: 'user_root',
targetUserId: 'user_pupa',
}),
blockAllowed: await resolveAssistantAction({
actionId: 'hub.user.block',
assistantRole: 'admin',
membershipRole: 'client_admin',
actorUserId: 'user_root',
targetUserId: 'user_pupa',
confirmed: true,
}),
selfLockout: await resolveAssistantAction({
actionId: 'hub.membership.disable',
assistantRole: 'admin',
membershipRole: 'client_admin',
actorUserId: 'user_root',
targetUserId: 'user_root',
removesOwnAccess: true,
hasAlternativeAdminPath: false,
confirmed: true,
}),
futureEngineAdapter: await resolveAssistantAction({
actionId: 'engine.workflow.share',
assistantRole: 'admin',
membershipRole: 'client_admin',
actorUserId: 'user_root',
targetUserId: 'user_pupa',
confirmed: true,
}),
}
assertSmoke(cases.deleteUser.decision === 'forbidden', 'delete user must be forbidden')
assertSmoke(cases.blockAsMember.decision === 'denied', 'member must not block users')
assertSmoke(cases.blockNeedsConfirmation.decision === 'needs_confirmation', 'privileged write must require confirmation')
assertSmoke(cases.blockAllowed.decision === 'allowed', 'confirmed assistant admin block should be policy-allowed')
assertSmoke(cases.blockAllowed.execution.executionReady, 'implemented HUB allowlist adapter should be execution-ready after policy')
assertSmoke(cases.selfLockout.decision === 'denied', 'self lockout must be denied')
assertSmoke(cases.futureEngineAdapter.decision === 'future_adapter', 'future engine adapter must not execute')
return { ok: true, validation: validation.counts, cases }
}
if (import.meta.url === `file://${process.argv[1]}`) {
if (process.argv.includes('--smoke')) {
console.log(JSON.stringify(await runSmoke(), null, 2))
} else {
const input = await readInputJson()
if (!input) throw new Error('Use --smoke, --input <json>, or --input-json <path>')
console.log(JSON.stringify(await resolveAssistantAction(input), null, 2))
}
}