70 lines
5.9 KiB
JavaScript
70 lines
5.9 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();let cancellationError;
|
|
const abortWith=error=>{cancellationError??=error;controller.abort();};
|
|
const cancel=()=>abortWith(rejected(403,'AI authorization changed during processing','ai-authorization-changed'));
|
|
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){
|
|
abortWith(rejected(policyChanged?409:403,'AI authorization changed during processing','ai-authorization-changed'));throw cancellationError;
|
|
}
|
|
}
|
|
const deadline=setTimeout(()=>abortWith(rejected(504,'AI request timed out','ai-request-timeout')),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 cancellationError||e;}
|
|
finally{clearTimeout(deadline);clearInterval(monitor);signal?.removeEventListener('abort',cancel);controller.abort();}
|
|
});
|
|
}
|