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 , or --input-json ') console.log(JSON.stringify(await resolveAssistantAction(input), null, 2)) } }