From 261bd0dba1f0f6914ecde9ea9c70fb667fdc6cd3 Mon Sep 17 00:00:00 2001 From: valentinbvro Date: Thu, 23 Jul 2026 23:36:20 +0200 Subject: [PATCH] feat: add AI tool capability permissions and secret masking to CASL layer Extends AbilityFactory with a risk-stratified capability model for Hermes MCP tools (economic-data/capability-reasoning/legislation-search), ported from Open.Jarvis's plugin permission system: non-owner/admin roles only get low/medium risk tools by default, high/critical stay reserved. Adds maskSensitiveValue as a reusable secret/PII redaction utility for future outbox/audit logging, reinforcing the existing ai_requests hash-only storage principle. --- src/auth/ability.factory.ts | 74 +++++++++++++++++++++++++++++++--- src/security/mask-sensitive.ts | 41 +++++++++++++++++++ 2 files changed, 110 insertions(+), 5 deletions(-) create mode 100644 src/security/mask-sensitive.ts diff --git a/src/auth/ability.factory.ts b/src/auth/ability.factory.ts index 628423a..f40f523 100644 --- a/src/auth/ability.factory.ts +++ b/src/auth/ability.factory.ts @@ -1,9 +1,33 @@ import { Injectable } from '@nestjs/common'; -import { AbilityBuilder, PureAbility, type AbilityClass } from '@casl/ability'; +import { AbilityBuilder, createMongoAbility, subject, type ForcedSubject, type MongoAbility } from '@casl/ability'; -export type Action = 'manage' | 'create' | 'read' | 'update' | 'delete'; -export type Subject = 'Organization' | 'Transaction' | 'Document' | 'Membership' | 'all'; -export type AppAbility = PureAbility<[Action, Subject]>; +/** + * Nivel de risc pentru unelte AI/agenti (Hermes MCP), portat din modelul de + * capabilitati risk-stratified al Open.Jarvis (github.com/dmrr35/Open.Jarvis, + * open_jarvis/plugins/permissions.py) -- roluri non-admin primesc implicit + * doar unelte low/medium, high/critical raman rezervate owner/admin. + */ +export type AgentToolRiskLevel = 'low' | 'medium' | 'high' | 'critical'; + +export interface AgentToolDefinition { + name: string; + riskLevel: AgentToolRiskLevel; + description: string; +} + +/** Forma exacta produsa de `subject('AgentTool', toolDefinition)` -- vezi canInvokeAgentTool. */ +type AgentToolInstance = AgentToolDefinition & ForcedSubject<'AgentTool'>; + +export type Action = 'manage' | 'create' | 'read' | 'update' | 'delete' | 'invoke'; +export type Subject = + | 'Organization' + | 'Transaction' + | 'Document' + | 'Membership' + | 'AgentTool' + | AgentToolInstance + | 'all'; +export type AppAbility = MongoAbility<[Action, Subject]>; export type MembershipRole = 'owner' | 'admin' | 'member'; export interface MembershipContext { @@ -11,6 +35,31 @@ export interface MembershipContext { role: MembershipRole; } +/** + * Registrul uneltelor AI expuse prin Hermes (namespace MetaMCP 'ceo-os-endpoint'). + * `name` trebuie sa corespunda exact numelui serverului MCP raportat de + * `hermes mcp test` -- vezi project_supersrv_new_server.md pentru lista live. + */ +export const AGENT_TOOL_REGISTRY: readonly AgentToolDefinition[] = [ + { + name: 'economic-data', + riskLevel: 'low', + description: 'World Bank/ECB/Eurostat/FRED/OECD, stiri, IP/domeniu lookups', + }, + { + name: 'capability-reasoning', + riskLevel: 'low', + description: 'Potrivire ocupatii/skill-uri ESCO pentru "ce poate/nu poate face agentul"', + }, + { + name: 'legislation-search', + riskLevel: 'medium', + description: 'Cautare semantica in legislatia UK/DE/RO -- informativ, nu consultanta juridica', + }, +]; + +const DEFAULT_ROLE_INVOKABLE_RISK_LEVELS: readonly AgentToolRiskLevel[] = ['low', 'medium']; + /** * Traduce rolul de membership (blueprint 8.3: memberships.role) intr-un set de * permisiuni CASL. Regulile sunt intentionat minimale -- se extind per bounded @@ -19,15 +68,30 @@ export interface MembershipContext { @Injectable() export class AbilityFactory { createForMembership(membership: MembershipContext): AppAbility { - const { can, build } = new AbilityBuilder(PureAbility as AbilityClass); + const { can, build } = new AbilityBuilder(createMongoAbility); if (membership.role === 'owner' || membership.role === 'admin') { can('manage', 'all'); } else { can('read', 'all'); can(['create', 'update'], ['Transaction', 'Document']); + can('invoke', 'AgentTool', { riskLevel: { $in: [...DEFAULT_ROLE_INVOKABLE_RISK_LEVELS] } }); } return build(); } } + +/** + * Verifica daca o abilitate poate invoca o unealta AI dupa nume. Nume necunoscute + * (absente din AGENT_TOOL_REGISTRY) sunt respinse implicit -- acelasi deny-by-default + * ca `require_plugin_permission()` din Open.Jarvis. + */ +export function canInvokeAgentTool(ability: AppAbility, toolName: string): boolean { + const toolDefinition = AGENT_TOOL_REGISTRY.find((tool) => tool.name === toolName); + if (!toolDefinition) { + return false; + } + + return ability.can('invoke', subject('AgentTool', toolDefinition)); +} diff --git a/src/security/mask-sensitive.ts b/src/security/mask-sensitive.ts new file mode 100644 index 0000000..2ddd85b --- /dev/null +++ b/src/security/mask-sensitive.ts @@ -0,0 +1,41 @@ +const MASK = '***'; + +const SENSITIVE_KEY_PATTERN = /(key|token|secret|password)/i; +const ASSIGNMENT_PATTERN = /([A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD)[A-Z0-9_]*)=(\S+)/gi; + +/** + * Masking de secrete/PII portat din Open.Jarvis (github.com/dmrr35/Open.Jarvis, + * open_jarvis/memory/privacy_mode.py) -- aplicat inainte de orice scriere in + * outbox_events.payload / audit_log, ca strat suplimentar fata de principiul + * deja aplicat in ai_requests (se stocheaza doar context_manifest_hash, niciodata + * continutul brut). + */ +export function maskSensitiveValue(value: unknown, key?: string): unknown { + if (key !== undefined && SENSITIVE_KEY_PATTERN.test(key)) { + return MASK; + } + + if (typeof value === 'string') { + return value.replace(ASSIGNMENT_PATTERN, (_match, name: string) => `${name}=${MASK}`); + } + + if (Array.isArray(value)) { + return value.map((item) => maskSensitiveValue(item)); + } + + if (value !== null && typeof value === 'object') { + return maskSensitiveObject(value as Record); + } + + return value; +} + +function maskSensitiveObject(input: Record): Record { + const masked: Record = {}; + + for (const [key, value] of Object.entries(input)) { + masked[key] = maskSensitiveValue(value, key); + } + + return masked; +}