feat(ontology): add core service catalog
This commit is contained in:
@@ -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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user