2026-10-13 16:58:15 +02:00
import { z } from 'zod' ;
import { inspectForAI , allowsConfidentialLocal } from '../../packages/core/policy.mjs' ;
import { inspector } from './inspection.mjs' ;
import enterpriseInspection from '../browser/enterprise-inspection.cjs' ;
import { traceOperation } from './telemetry.mjs' ;
2026-10-13 23:19:22 +02:00
import { isDeepStrictEqual } from 'node:util' ;
import { inspectionTextVariants } from '../../packages/core/text-variants.mjs' ;
2026-10-13 16:58:15 +02:00
function rejected ( status , message , ruleId ){ return Object . assign ( Error ( message ),{ status , ruleId });}
export function approvedAIEndpoint ( policy , config ){
const a = policy . ai , local = a . trustMode === 'local' ;
if ( ! a . enabled ||! config . aiBaseUrl || ( ! local &&! config . aiKey ) || a . model !== config . aiModel || a . endpointId !== ( config . aiEndpointId || 'default' )) throw rejected ( 409 , 'Approved AI provider is not configured' );
const endpoint = new URL ( ` ${ config . aiBaseUrl . replace ( /\/$/ , '' ) } / ${ a . provider === 'ollama' ? 'api/chat' : 'chat/completions' } ` );
if ( endpoint . username || endpoint . password || endpoint . search || endpoint . hash || ( local ?! ([ '127.0.0.1' , '[::1]' ]. includes ( endpoint . hostname ) && [ 'http:' , 'https:' ]. includes ( endpoint . protocol )) : endpoint . protocol !== 'https:' )) throw rejected ( 403 , 'AI endpoint trust policy rejected' );
return endpoint ;
}
2026-10-13 23:19:22 +02:00
export async function runApprovedAI ({ store , config , deviceId , policy , prompt , signal }){
2026-10-13 16:58:15 +02:00
return traceOperation ( 'ai.gateway' ,{ policyVersion : policy . version , bytes : Buffer . byteLength ( prompt )}, async ()=>{
2026-10-13 23:19:22 +02:00
const controller = new AbortController ();
const cancel = ()=> controller . abort ();
signal ? . addEventListener ( 'abort' , cancel ,{ once : true });
if ( signal ? . aborted ) cancel ();
function assertCurrent (){
const active = store . db . prepare ( 'SELECT id FROM devices WHERE id=? AND revoked=0' ). get ( deviceId );
const policyChanged = active &&! isDeepStrictEqual ( store . effectivePolicy ( deviceId ), policy );
if ( controller . signal . aborted ||! active || Date . parse ( policy . expiresAt ) <= Date . now () || policyChanged ){
controller . abort (); throw rejected ( policyChanged ? 409 : 403 , 'AI authorization changed during processing' , 'ai-authorization-changed' );
}
}
const deadline = setTimeout (()=> controller . abort (), 60000 );
const monitor = setInterval (()=>{ try { assertCurrent ();} catch { /* Abort pending provider work; release checks report the fixed failure. */ }}, 100 );
monitor . unref (); deadline . unref ();
2026-10-13 16:58:15 +02:00
try {
2026-10-13 23:19:22 +02:00
assertCurrent ();
2026-10-13 16:58:15 +02:00
z . string (). min ( 1 ). max ( 100000 ). parse ( prompt );
if ( Date . parse ( policy . expiresAt ) <= Date . now ()) throw rejected ( 403 , 'Policy expired' );
const endpoint = approvedAIEndpoint ( policy , config );
async function scan ( text ){
let verdict = inspectForAI ( Buffer . from ( text ), policy );
2026-10-13 23:19:22 +02:00
if ( verdict . decision !== 'allow' ) return verdict ;
let values ; try { values = inspectionTextVariants ( text , policy . dlp . maxBytes );} catch { throw rejected ( 403 , 'Content normalization could not be completed' , 'normalization-limit' );}
for ( const value of values ){
assertCurrent ();
2026-10-13 16:58:15 +02:00
if ( verdict . decision === 'allow' && ( policy . dlp . semantic . enabled || policy . security . yara . enabled )){
2026-10-13 23:19:22 +02:00
const parsed = await inspector ( config , '/inspect' ,{ data : Buffer . from ( value ). toString ( 'base64' ), policy });
assertCurrent ();
if ( parsed . decision === 'allow' && parsed . coverage ? . complete !== true ) return { decision : 'block' , ruleId : 'incomplete-inspection' };
2026-10-13 16:58:15 +02:00
if ( parsed . decision === 'block' ) verdict = allowsConfidentialLocal ( parsed , policy ) ? {... parsed , decision : 'allow' , classification : 'local-only' } : parsed ;
}
2026-10-13 23:19:22 +02:00
if ( verdict . decision === 'allow' ){ const external = await enterpriseInspection . scanEnterprise ( Buffer . from ( value ), policy , 'ai' ); assertCurrent (); if ( external . decision !== 'allow' ) verdict = external ;}
if ( verdict . decision !== 'allow' ) return verdict ;
}
2026-10-13 16:58:15 +02:00
return verdict ;
}
const input = await traceOperation ( 'ai.inspect.input' ,{ bytes : Buffer . byteLength ( prompt ), policyVersion : policy . version },()=> scan ( prompt )); store . event ( deviceId , 'ai.decision' ,{... input , policyVersion : policy . version });
if ( input . decision === 'block' ) throw rejected ( 403 , 'Company policy blocked this prompt' , input . ruleId );
2026-10-13 23:19:22 +02:00
const response = await traceOperation ( 'ai.provider.request' ,{ bytes : Buffer . byteLength ( prompt ), policyVersion : policy . version },()=>{ assertCurrent (); return fetch ( endpoint ,{ method : 'POST' , redirect : 'error' , headers : { 'content-type' : 'application/json' ,...( config . aiKey ? { authorization : `Bearer ${ config . aiKey } ` } : {})}, body : JSON . stringify ({ model : policy . ai . model , messages : [{ role : 'user' , content : prompt }], stream : false }), signal : controller . signal });});
assertCurrent ();
2026-10-13 16:58:15 +02:00
if ( ! response . ok ) throw rejected ( 502 , 'AI provider request failed' );
const text = await traceOperation ( 'ai.provider.read' ,{ policyVersion : policy . version }, async ()=>{ const chunks = []; let size = 0 ; for await ( const chunk of response . body ){ size += chunk . length ; if ( size > 2 * 1024 * 1024 ){ await response . body . cancel (). catch (()=>{}); throw Error ( 'AI response too large' );} chunks . push ( chunk );} return Buffer . concat ( chunks ). toString ( 'utf8' );});
const answer = await traceOperation ( 'ai.provider.decode' ,{ bytes : Buffer . byteLength ( text ), policyVersion : policy . version }, async ()=>{ const result = JSON . parse ( text ); return z . string (). max ( 100000 ). parse ( policy . ai . provider === 'ollama' ? result . message ? . content : result . choices ? .[ 0 ] ? . message ? . content );});
const output = await traceOperation ( 'ai.inspect.output' ,{ bytes : Buffer . byteLength ( answer ), policyVersion : policy . version },()=> scan ( answer )); store . event ( deviceId , 'ai.output-decision' ,{... output , policyVersion : policy . version });
if ( output . decision === 'block' ){ store . event ( deviceId , 'ai.output-block' ,{ ruleId : output . ruleId , policyVersion : policy . version }); throw rejected ( 403 , 'AI response blocked by company policy' , output . ruleId );}
2026-10-13 23:19:22 +02:00
assertCurrent (); return answer ;
2026-10-13 16:58:15 +02:00
} catch ( e ){ store . event ( deviceId , 'ai.failure' ,{ policyVersion : policy . version }); throw e ;}
2026-10-13 23:19:22 +02:00
finally { clearTimeout ( deadline ); clearInterval ( monitor ); signal ? . removeEventListener ( 'abort' , cancel ); controller . abort ();}
2026-10-13 16:58:15 +02:00
});
}