332 lines
11 KiB
JavaScript
332 lines
11 KiB
JavaScript
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))
|
||
}
|
||
}
|