Files
safetynet/apps/server/ai-gateway.mjs
T

69 lines
5.6 KiB
JavaScript

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';
import {isDeepStrictEqual} from 'node:util';
import {inspectionTextVariants} from '../../packages/core/text-variants.mjs';
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;
}
export async function runApprovedAI({store,config,deviceId,policy,prompt,signal}){
return traceOperation('ai.gateway',{policyVersion:policy.version,bytes:Buffer.byteLength(prompt)},async()=>{
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();
try{
assertCurrent();
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);
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();
if(verdict.decision==='allow'&&(policy.dlp.semantic.enabled||policy.security.yara.enabled)){
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'};
if(parsed.decision==='block')verdict=allowsConfidentialLocal(parsed,policy)?{...parsed,decision:'allow',classification:'local-only'}:parsed;
}
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;
}
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);
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();
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);}
assertCurrent();return answer;
}catch(e){store.event(deviceId,'ai.failure',{policyVersion:policy.version});throw e;}
finally{clearTimeout(deadline);clearInterval(monitor);signal?.removeEventListener('abort',cancel);controller.abort();}
});
}