NODEDC_PLATFORM/services/ontology-core/src/assistant-action-resolver.mjs

332 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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))
}
}