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

42 lines
4.0 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';
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}){
return traceOperation('ai.gateway',{policyVersion:policy.version,bytes:Buffer.byteLength(prompt)},async()=>{
try{
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'&&(policy.dlp.semantic.enabled||policy.security.yara.enabled)){
const parsed=await inspector(config,'/inspect',{data:Buffer.from(text).toString('base64'),policy});
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(text),policy,'ai');if(external.decision!=='allow')verdict=external;}
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},()=>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:AbortSignal.timeout(60000)}));
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);}
return answer;
}catch(e){store.event(deviceId,'ai.failure',{policyVersion:policy.version});throw e;}
});
}