| 1 | /** Boundary evidence v1. Pure, deterministic, metadata-only; no network or trust-by-text. */ |
| 2 | import { type Category, type Trace, type WhaleEvent, type Finding, stableHash } from './model.js'; |
| 3 | export const SURFACES = ['model-api','http','mcp','git','filesystem','process','sandbox','dns','socket','message','database','artifact','browser','clipboard','other'] as const; |
| 4 | export type Surface = typeof SURFACES[number]; |
| 5 | export const STAGES = ['intent','dispatch','runtime','effect','control'] as const; |
| 6 | export type Stage = typeof STAGES[number]; |
| 7 | export const EFFECTS = ['read','write','delete','execute','publish','send','receive','connect','grant','revoke','none','unknown'] as const; |
| 8 | export type Effect = typeof EFFECTS[number]; |
| 9 | export interface Observation { |
| 10 | version: 1; id: string; runId: string; operationId?: string; |
| 11 | sourceId: string; epoch: string; sequence: number; |
| 12 | time: { wallMs: number; clockId?: string; monotonicMs?: number; taskMs?: number; uncertaintyMs?: number }; |
| 13 | receivedAt?: number; |
| 14 | subject: { agentId: string; sandboxId?: string; isolationGroup?: string }; |
| 15 | stage: Stage; surface: Surface; action: string; effect: Effect; |
| 16 | status: 'started' | 'completed' | 'error' | 'unknown'; |
| 17 | target?: { id: string; kind: string; version?: string; boundary?: 'local'|'external'|'unknown' }; |
| 18 | actionDigest?: string; |
| 19 | authority?: { grantId?: string; claimed?: boolean }; |
| 20 | correlation?: { sessionId?: string; requestId?: string; parentOperationId?: string; messageId?: string }; |
| 21 | facts: Record<string, string | number | boolean | null>; |
| 22 | } |
| 23 | export interface EvidenceSource { |
| 24 | id: string; stages: Stage[]; surfaces: Surface[]; |
| 25 | runIds: string[]; sandboxIds?: string[]; isolationGroups?: string[]; |
| 26 | heartbeatMs: number; description?: string; |
| 27 | } |
| 28 | export interface Grant { |
| 29 | id: string; runIds: string[]; sandboxIds: string[]; targetIds: string[]; |
| 30 | actions: string[]; effects: Effect[]; notBefore: number; expiresAt: number; |
| 31 | actionDigest?: string; |
| 32 | } |
| 33 | export interface BoundaryPolicy { |
| 34 | version: 1; id: string; sources: EvidenceSource[]; grants: Grant[]; |
| 35 | expectedSurfaces: Surface[]; forbiddenCrossGroup: boolean; |
| 36 | } |
| 37 | export interface EvidenceBundle { |
| 38 | format: 'whalesong.evidence/v1'; name: string; records: Observation[]; |
| 39 | policy: BoundaryPolicy; asOf: number; |
| 40 | } |
| 41 | export interface CoverageRow { |
| 42 | sourceId: string; surface: Surface; status: 'observed'|'stale'|'missing'|'unconfigured'|'disabled'|'unverified-time'; |
| 43 | count: number; lastReceivedAt: number | null; sequenceGaps: number; |
| 44 | } |
| 45 | export interface BoundaryFinding { |
| 46 | id: string; code: string; severity: 'info'|'warning'|'critical'; |
| 47 | title: string; detail: string; recordIds: string[]; time: number; |
| 48 | } |
| 49 | export interface Interaction { |
| 50 | runId: string; sandboxId?: string; group?: string; target: string; |
| 51 | kind: string; stage: Stage; effect: Effect; count: number; recordIds: string[]; |
| 52 | } |
| 53 | export interface BoundaryAnalysis { |
| 54 | version: 1; omittedFindings: number; asOf: number; inputRecords: number; visibleRecords: number; uniqueRecords: number; |
| 55 | futureRecords: number; findings: BoundaryFinding[]; coverage: CoverageRow[]; |
| 56 | interactions: Interaction[]; runs: number; sandboxes: number; externalEffects: number; |
| 57 | unknownEffects: number; authority: { permitted: number; denied: number; unknown: number }; |
| 58 | provenance: string; |
| 59 | } |
| 60 | const enumValue = <T extends string>(v:unknown, values: readonly T[], field:string): T => { |
| 61 | if(typeof v!=='string'||!values.includes(v as T))throw new Error(`Invalid ${field}.`);return v as T; |
| 62 | }; |
| 63 | const object = (v:unknown, field:string): Record<string,unknown> => { |
| 64 | if(!v||typeof v!=='object'||Array.isArray(v))throw new Error(`Invalid ${field}: object required.`);return v as Record<string,unknown>; |
| 65 | }; |
| 66 | const text = (v:unknown, field:string, max=256):string => { |
| 67 | if(typeof v!=='string'||!v.length||v.length>max||/[\u0000-\u001f\u007f]/.test(v))throw new Error(`Invalid ${field}: nonempty bounded text required.`);return v; |
| 68 | }; |
| 69 | const num = (v:unknown,field:string,min=0):number => { |
| 70 | if(typeof v!=='number'||!Number.isFinite(v)||v<min||Math.abs(v)>Number.MAX_SAFE_INTEGER)throw new Error(`Invalid ${field}.`);return v; |
| 71 | }; |
| 72 | const optionalText = (o:Record<string,unknown>,key:string):string|undefined => o[key]===undefined?undefined:text(o[key],key); |
| 73 | const strings = (v:unknown,field:string,allowEmpty=false):string[]=>{ |
| 74 | if(!Array.isArray(v)||(!allowEmpty&&!v.length)||v.length>256)throw new Error(`Invalid ${field}.`); |
| 75 | const out=v.map(x=>text(x,field));if(new Set(out).size!==out.length)throw new Error(`Duplicate ${field}.`);return out; |
| 76 | }; |
| 77 | const boolean = (v:unknown,field:string):boolean=>{if(typeof v!=='boolean')throw new Error(`Invalid ${field}.`);return v;}; |
| 78 | const optionalNumber=(o:Record<string,unknown>,key:string)=>o[key]===undefined?undefined:num(o[key],key); |
| 79 | function onlyKeys(o:Record<string,unknown>,keys:string[],label:string):void{ |
| 80 | for(const k of Object.keys(o))if(!keys.includes(k))throw new Error(`Unknown ${label} field: ${k}.`); |
| 81 | } |
| 82 | /** All unrecognized fields are rejected, never secretly retained in metadata-only evidence. */ |
| 83 | export function validateObservation(input:unknown):Observation { |
| 84 | const o=object(input,'observation'); |
| 85 | onlyKeys(o,['version','id','runId','operationId','sourceId','epoch','sequence','time','receivedAt','subject','stage','surface','action','effect','status','target','actionDigest','authority','correlation','facts'],'observation'); |
| 86 | if(o.version!==1)throw new Error('Unsupported observation version.'); |
| 87 | const t=object(o.time,'time'),s=object(o.subject,'subject'); |
| 88 | onlyKeys(t,['wallMs','clockId','monotonicMs','taskMs','uncertaintyMs'],'time');onlyKeys(s,['agentId','sandboxId','isolationGroup'],'subject'); |
| 89 | const seq=num(o.sequence,'sequence',1);if(!Number.isSafeInteger(seq))throw new Error('sequence must be an integer.'); |
| 90 | const facts=object(o.facts??{},'facts'),safeFacts:Observation['facts']={}; |
| 91 | if(Object.keys(facts).length>48)throw new Error('Too many observation facts.'); |
| 92 | for(const [k,v] of Object.entries(facts)){ |
| 93 | text(k,'fact key',80);if(['__proto__','prototype','constructor'].includes(k))throw new Error('Unsafe fact key.'); |
| 94 | if(typeof v==='string')safeFacts[k]=text(v,'fact value',512); |
| 95 | else if(typeof v==='number')safeFacts[k]=num(v,'fact value',-Number.MAX_SAFE_INTEGER); |
| 96 | else if(v===null||typeof v==='boolean')safeFacts[k]=v; |
| 97 | else throw new Error('Facts must be scalar metadata, not content objects.'); |
| 98 | } |
| 99 | let target:Observation['target'];if(o.target!==undefined){const a=object(o.target,'target');onlyKeys(a,['id','kind','version','boundary'],'target');target={id:text(a.id,'target.id'),kind:text(a.kind,'target.kind',64),version:optionalText(a,'version'),boundary:a.boundary===undefined?undefined:enumValue(a.boundary,['local','external','unknown'] as const,'boundary')};} |
| 100 | let authority:Observation['authority'];if(o.authority!==undefined){const a=object(o.authority,'authority');onlyKeys(a,['grantId','claimed'],'authority');authority={grantId:optionalText(a,'grantId'),claimed:a.claimed===undefined?undefined:boolean(a.claimed,'claimed')};} |
| 101 | let correlation:Observation['correlation'];if(o.correlation!==undefined){const a=object(o.correlation,'correlation');onlyKeys(a,['sessionId','requestId','parentOperationId','messageId'],'correlation');correlation={sessionId:optionalText(a,'sessionId'),requestId:optionalText(a,'requestId'),parentOperationId:optionalText(a,'parentOperationId'),messageId:optionalText(a,'messageId')};} |
| 102 | return {version:1,id:text(o.id,'id'),runId:text(o.runId,'runId'),operationId:optionalText(o,'operationId'),sourceId:text(o.sourceId,'sourceId'),epoch:text(o.epoch,'epoch'),sequence:seq, |
| 103 | time:{wallMs:num(t.wallMs,'wallMs'),clockId:optionalText(t,'clockId'),monotonicMs:optionalNumber(t,'monotonicMs'),taskMs:optionalNumber(t,'taskMs'),uncertaintyMs:optionalNumber(t,'uncertaintyMs')},receivedAt:optionalNumber(o,'receivedAt'), |
| 104 | subject:{agentId:text(s.agentId,'agentId'),sandboxId:optionalText(s,'sandboxId'),isolationGroup:optionalText(s,'isolationGroup')},stage:enumValue(o.stage,STAGES,'stage'),surface:enumValue(o.surface,SURFACES,'surface'),action:text(o.action,'action',128),effect:enumValue(o.effect,EFFECTS,'effect'),status:enumValue(o.status,['started','completed','error','unknown'],'status'),target,actionDigest:optionalText(o,'actionDigest'),authority,correlation,facts:safeFacts}; |
| 105 | } |
| 106 | export function validatePolicy(input:unknown):BoundaryPolicy { |
| 107 | const o=object(input,'policy');onlyKeys(o,['version','id','sources','grants','expectedSurfaces','forbiddenCrossGroup'],'policy'); |
| 108 | if(o.version!==1||!Array.isArray(o.sources)||o.sources.length>256||!Array.isArray(o.grants)||o.grants.length>2048)throw new Error('Invalid policy version or limits.'); |
| 109 | const sources=o.sources.map(x=>{const a=object(x,'source');onlyKeys(a,['id','stages','surfaces','runIds','sandboxIds','isolationGroups','heartbeatMs','description'],'source');return {id:text(a.id,'source.id'),stages:strings(a.stages,'stages').map(v=>enumValue(v,STAGES,'stage')),surfaces:strings(a.surfaces,'surfaces').map(v=>enumValue(v,SURFACES,'surface')),runIds:strings(a.runIds,'runIds'),sandboxIds:a.sandboxIds===undefined?undefined:strings(a.sandboxIds,'sandboxIds'),isolationGroups:a.isolationGroups===undefined?undefined:strings(a.isolationGroups,'isolationGroups'),heartbeatMs:num(a.heartbeatMs,'heartbeatMs',1),description:optionalText(a,'description')};}); |
| 110 | const grants=o.grants.map(x=>{const a=object(x,'grant');onlyKeys(a,['id','runIds','sandboxIds','targetIds','actions','effects','notBefore','expiresAt','actionDigest'],'grant');const g={id:text(a.id,'grant.id'),runIds:strings(a.runIds,'runIds'),sandboxIds:strings(a.sandboxIds,'sandboxIds'),targetIds:strings(a.targetIds,'targetIds'),actions:strings(a.actions,'actions'),effects:strings(a.effects,'effects').map(v=>enumValue(v,EFFECTS,'effect')),notBefore:num(a.notBefore,'notBefore'),expiresAt:num(a.expiresAt,'expiresAt'),actionDigest:optionalText(a,'actionDigest')};if(g.expiresAt<=g.notBefore)throw new Error('Grant expiry must follow its start.');return g;}); |
| 111 | if(new Set(sources.map(s=>s.id)).size!==sources.length||new Set(grants.map(g=>g.id)).size!==grants.length)throw new Error('Duplicate policy identity.'); |
| 112 | return {version:1,id:text(o.id,'policy.id'),sources,grants,expectedSurfaces:strings(o.expectedSurfaces,'expectedSurfaces',true).map(v=>enumValue(v,SURFACES,'surface')),forbiddenCrossGroup:boolean(o.forbiddenCrossGroup,'forbiddenCrossGroup')}; |
| 113 | } |
| 114 | export function validateBundle(input:unknown,maxRecords=100_000):EvidenceBundle { |
| 115 | const o=object(input,'bundle');onlyKeys(o,['format','name','records','policy','asOf'],'bundle'); |
| 116 | if(o.format!=='whalesong.evidence/v1'||!Array.isArray(o.records)||o.records.length>maxRecords)throw new Error('Invalid evidence bundle or record limit exceeded.'); |
| 117 | const records=o.records.map(validateObservation),seen=new Set<string>(); |
| 118 | for(const record of records){const key=observationKey(record);if(seen.has(key))throw new Error('Duplicate producer incarnation/sequence in evidence bundle. Import cancelled; resolve identity before import.');seen.add(key);} |
| 119 | return {format:o.format,name:text(o.name,'name'),records,policy:validatePolicy(o.policy),asOf:num(o.asOf,'asOf')}; |
| 120 | } |
| 121 | export function sourceAccepts(source:EvidenceSource,o:Observation):boolean { |
| 122 | return source.id===o.sourceId&&source.stages.includes(o.stage)&&source.surfaces.includes(o.surface)&&source.runIds.includes(o.runId)&&(!source.sandboxIds||source.sandboxIds.includes(o.subject.sandboxId??''))&&(!source.isolationGroups||source.isolationGroups.includes(o.subject.isolationGroup??'')); |
| 123 | } |
| 124 | export type Authorization = { decision:'permitted'|'denied'|'unknown'; reason:string }; |
| 125 | /** Exact allowlists; no prefix matching, text approval, or ambient default allow. Not enforcement. */ |
| 126 | export function authorize(o:Observation,policy:BoundaryPolicy):Authorization { |
| 127 | const source=policy.sources.find(s=>s.id===o.sourceId); |
| 128 | if(!source||!sourceAccepts(source,o))return {decision:'unknown',reason:'Source is not bound to this scope.'}; |
| 129 | if(o.effect==='unknown')return {decision:'unknown',reason:'Actual effect not established.'}; |
| 130 | const grant=policy.grants.find(g=>g.id===o.authority?.grantId); |
| 131 | if(!grant)return {decision:'denied',reason:'No matching operator-configured grant.'}; |
| 132 | const u=o.time.uncertaintyMs; |
| 133 | if(u===undefined)return {decision:'unknown',reason:'Clock uncertainty absent; grant validity cannot be established.'}; |
| 134 | if(o.time.wallMs-u<grant.notBefore||o.time.wallMs+u>=grant.expiresAt)return {decision:'denied',reason:'Outside grant validity interval (including clock uncertainty).'}; |
| 135 | if(!grant.runIds.includes(o.runId)||!grant.sandboxIds.includes(o.subject.sandboxId??'')||!grant.targetIds.includes(o.target?.id??'')||!grant.actions.includes(o.action)||!grant.effects.includes(o.effect))return {decision:'denied',reason:'Action, effect, target, run, or sandbox is outside the grant.'}; |
| 136 | if(grant.actionDigest&&o.actionDigest!==grant.actionDigest)return {decision:'denied',reason:'Approved action digest does not match.'}; |
| 137 | return {decision:'permitted',reason:'Matches the supplied policy; source claims still require independent verification.'}; |
| 138 | } |
| 139 | export const observationKey=(o:Observation):string=>JSON.stringify([o.sourceId,o.epoch,o.sequence]); |
| 140 | export const observationEventId=(o:Observation):string=>`obs:${encodeURIComponent(o.sourceId)}:${encodeURIComponent(o.epoch)}:${o.sequence}`; |
| 141 | export function canonicalJSON(v:unknown):string { |
| 142 | if(v===undefined)return 'null';if(v===null||typeof v!=='object')return JSON.stringify(v); |
| 143 | if(Array.isArray(v))return '['+v.map(canonicalJSON).join(',')+']'; |
| 144 | return '{'+Object.entries(v).filter(([,x])=>x!==undefined).sort(([a],[b])=>a<b?-1:a>b?1:0).map(([k,x])=>JSON.stringify(k)+':'+canonicalJSON(x)).join(',')+'}'; |
| 145 | } |
| 146 | const writeEffects=new Set<Effect>(['write','delete','publish','send','grant','revoke']); |
| 147 | const consequential=(o:Observation)=>o.stage==='effect'&&o.status==='completed'&&writeEffects.has(o.effect); |
| 148 | function definitelyAfter(a:Observation,b:Observation):boolean { |
| 149 | if(a.sourceId===b.sourceId&&a.epoch===b.epoch&&a.time.clockId&&a.time.clockId===b.time.clockId&&a.time.monotonicMs!==undefined&&b.time.monotonicMs!==undefined)return a.time.monotonicMs>b.time.monotonicMs; |
| 150 | return a.time.uncertaintyMs!==undefined&&b.time.uncertaintyMs!==undefined&&a.time.wallMs-a.time.uncertaintyMs>b.time.wallMs+b.time.uncertaintyMs; |
| 151 | } |
| 152 | /** asOf is receipt time for operator replay. Missing receipt stamps are excluded in replay. */ |
| 153 | export function analyzeEvidence(bundle:EvidenceBundle,asOf=bundle.asOf,operatorReplay=false):BoundaryAnalysis { |
| 154 | let omittedFindings=0; |
| 155 | const policy=bundle.policy,findings:BoundaryFinding[]=[],rows:CoverageRow[]=[],unique=new Map<string,Observation>(); |
| 156 | const visible=bundle.records.filter(o=>operatorReplay?o.receivedAt!==undefined&&o.receivedAt<=asOf:(o.receivedAt??o.time.wallMs)<=asOf); |
| 157 | const add=(code:string,title:string,detail:string,records:Observation[],severity:BoundaryFinding['severity']='warning')=>{ |
| 158 | if(findings.length>=2000){omittedFindings++;return;} |
| 159 | const recordIds=records.slice(0,24).map(observationEventId),time=records.reduce((n,o)=>Math.min(n,o.time.wallMs),asOf); |
| 160 | findings.push({id:`boundary-${stableHash(code+canonicalJSON(recordIds))}`,code,title,detail,recordIds,time,severity}); |
| 161 | }; |
| 162 | for(const o of visible){const key=observationKey(o),old=unique.get(key);if(old){if(canonicalJSON(old)!==canonicalJSON(o))add('sequence-conflict','Conflicting producer sequence','Two different records claim the same producer incarnation and sequence. Neither is silently treated as corroboration.',[old,o],'critical');}else unique.set(key,o);} |
| 163 | const records=[...unique.values()].sort((a,b)=>a.time.wallMs-b.time.wallMs||observationKey(a).localeCompare(observationKey(b))); |
| 164 | const sourceMap=new Map(policy.sources.map(s=>[s.id,s])),bySource=new Map<string,Observation[]>(),operations=new Map<string,Observation[]>(),resource=new Map<string,Observation[]>(),bySandbox=new Map<string,Observation[]>(); |
| 165 | const interactions=new Map<string,Interaction>(),authority={permitted:0,denied:0,unknown:0}; |
| 166 | for(const o of records){ |
| 167 | const src=sourceMap.get(o.sourceId);if(!src||!sourceAccepts(src,o))add('unbound-source','Source outside configured scope','This record is retained as an unverified assertion, not attributed to an allowed source for this run, stage, surface, and sandbox.',[o]); |
| 168 | const arr=bySource.get(o.sourceId)??[];arr.push(o);bySource.set(o.sourceId,arr); |
| 169 | if(o.operationId){const key=JSON.stringify([o.runId,o.subject.sandboxId,o.operationId]),a=operations.get(key)??[];a.push(o);operations.set(key,a);} |
| 170 | if(o.subject.sandboxId){const key=JSON.stringify([o.runId,o.subject.sandboxId]),a=bySandbox.get(key)??[];a.push(o);bySandbox.set(key,a);} |
| 171 | if(o.target){const key=JSON.stringify([o.runId,o.subject.sandboxId,o.subject.isolationGroup,o.target.id,o.target.version,o.stage,o.effect]),edge=interactions.get(key)??{runId:o.runId,sandboxId:o.subject.sandboxId,group:o.subject.isolationGroup,target:o.target.id,kind:o.target.kind,stage:o.stage,effect:o.effect,count:0,recordIds:[]};edge.count++;if(edge.recordIds.length<24)edge.recordIds.push(observationEventId(o));interactions.set(key,edge); |
| 172 | if(o.stage==='effect'&&o.status==='completed'){const a=resource.get(o.target.id)??[];a.push(o);resource.set(o.target.id,a);}} |
| 173 | if(o.stage==='dispatch'||o.stage==='effect'){ |
| 174 | const result=authorize(o,policy);authority[result.decision]++; |
| 175 | if(o.authority?.claimed&&!policy.grants.some(g=>g.id===o.authority?.grantId))add('claimed-approval','Approval claim is not a grant','Peer, tool, and transcript assertions cannot create operator policy.',[o]); |
| 176 | if(consequential(o)&&result.decision==='denied')add('out-of-scope-effect','Effect outside supplied authorization',result.reason+' This is a policy comparison, not proof of malicious intent.',[o],'critical'); |
| 177 | } |
| 178 | if(consequential(o)&&o.target?.boundary==='external')add('external-effect','External state-changing effect recorded','A discrete external effect is preserved even at whole-run zoom. Inspect its evidence and authorization; volume is not an impact measure.',[o],'info'); |
| 179 | if(o.surface==='sandbox'&&(o.facts.privileged===true||o.facts.hostNetwork===true||o.facts.engineSocket===true))add('sandbox-exposure','Broad sandbox capability reported','The recorded profile reports privilege, host networking, or engine-socket access. A sandbox label is not evidence of confinement.',[o]); |
| 180 | if(consequential(o)&&o.facts.evaluatorArtifact===true)add('evaluator-change','Evaluation artifact changed','A recorded write changed an object identified as part of evaluation. Passing results before and after may not use the same acceptance criteria.',[o]); |
| 181 | if(o.facts.monitorState==='disabled'||o.facts.monitorState==='failed')add('monitor-offline','Monitor not evaluating','This source explicitly reports disabled or failed monitoring; no clean verdict is inferred.',[o],'critical'); |
| 182 | if(typeof o.facts.dropped==='number'&&o.facts.dropped>0)add('telemetry-loss','Telemetry loss reported',`${o.facts.dropped} records were reported dropped. Recorded totals are incomplete.`,[o]); |
| 183 | } |
| 184 | for(const [key,ops] of operations){ |
| 185 | const bound=ops.filter(o=>{const src=sourceMap.get(o.sourceId);return src&&sourceAccepts(src,o);}); |
| 186 | const proposed=bound.filter(o=>o.stage==='intent'||o.stage==='dispatch'),actual=bound.filter(o=>o.stage==='runtime'||o.stage==='effect'); |
| 187 | // Keep at most two DISTINCT digests per configured source, enough to show disagreement. |
| 188 | const examples:Observation[]=[],seenDigests=new Map<string,Set<string>>(); |
| 189 | for(const o of proposed)if(o.actionDigest){const seen=seenDigests.get(o.sourceId)??new Set<string>(); |
| 190 | if(seen.size<2&&!seen.has(o.actionDigest)){seen.add(o.actionDigest);examples.push(o);seenDigests.set(o.sourceId,seen);}} |
| 191 | const mismatch=actual.some(b=>b.actionDigest&&examples.some(a=>a.sourceId!==b.sourceId&&a.actionDigest!==b.actionDigest)); |
| 192 | if(mismatch)add('action-mismatch','Dispatch and execution digests differ','Distinct configured sources disagree on the action identity for the same run/sandbox/operation. Instrumentation errors remain an alternative explanation.',ops,'critical'); |
| 193 | if(proposed.some(o=>o.effect==='read'||o.facts.readOnlyHint===true)&&actual.some(consequential))add('effect-mismatch','Read claim followed by a write effect','The same correlated operation includes a read/read-only claim and a completed state-changing effect. An HTTP method or MCP annotation was not treated as a guarantee.',ops,'critical'); |
| 194 | const stop=ops.filter(o=>o.action==='cancel.request'),after=actual.filter(o=>stop.length&&definitelyAfter(o,stop[0])); |
| 195 | if(stop.length&&after.length)add('cancel-not-stop','Execution continues after cancellation request','Cancellation is a request, not evidence of termination. The recorded clocks establish later activity for this operation.',[...stop,...after]); |
| 196 | void key; |
| 197 | } |
| 198 | if(policy.forbiddenCrossGroup)for(const [target,ops] of resource){ |
| 199 | const readers=new Map<string,Set<string>>();let unknownVersionReaderGroups=new Set<string>(); |
| 200 | for(const o of ops)if(o.effect==='read'&&o.subject.isolationGroup){const version=o.target?.version??'',groups=readers.get(version)??new Set<string>();groups.add(o.subject.isolationGroup);readers.set(version,groups);if(!version)unknownVersionReaderGroups.add(o.subject.isolationGroup);} |
| 201 | const allReaderGroups=new Set([...readers.values()].flatMap(g=>[...g])); |
| 202 | const other=(groups:Set<string>|undefined,group:string)=>!!groups&&(groups.size>1||groups.size===1&&!groups.has(group)); |
| 203 | const bridge=ops.some(w=>consequential(w)&&w.subject.isolationGroup&&(other(unknownVersionReaderGroups,w.subject.isolationGroup)||other(w.target?.version?readers.get(w.target.version):allReaderGroups,w.subject.isolationGroup))); |
| 204 | if(bridge)add('shared-write-bridge','Shared writable resource crosses isolation groups',`Multiple groups read/write ${target}. This establishes a shared-state path under the supplied isolation policy, not intent or causal influence. Missing versions weaken the association.`,ops,'critical'); |
| 205 | } |
| 206 | for(const ops of bySandbox.values()){ |
| 207 | let pendingStop:Observation|undefined,stopped:Observation|undefined,waiting:Observation|undefined; |
| 208 | const afterStop:Observation[]=[],duringWait:Observation[]=[],requests:Observation[]=[]; |
| 209 | for(const o of ops){ |
| 210 | if(o.action==='sandbox.stop.request'){pendingStop=o;requests.push(o);} |
| 211 | if(o.action==='sandbox.stopped'&&o.stage==='control'){stopped=o;if(pendingStop&&definitelyAfter(o,pendingStop))pendingStop=undefined;} |
| 212 | if(o.action==='sandbox.started'){stopped=undefined;waiting=undefined;} |
| 213 | if(o.action==='sandbox.waiting')waiting=o; |
| 214 | if(o.action==='sandbox.resumed'&&waiting&&definitelyAfter(o,waiting))waiting=undefined; |
| 215 | if(o.stage==='runtime'||o.stage==='effect'){ |
| 216 | if(stopped&&definitelyAfter(o,stopped)){if(!afterStop.length)afterStop.push(stopped);afterStop.push(o);} |
| 217 | if(waiting&&definitelyAfter(o,waiting)){if(!duringWait.length)duringWait.push(waiting);duringWait.push(o);} |
| 218 | } |
| 219 | } |
| 220 | if(pendingStop)add('stop-unconfirmed','Sandbox stop is unconfirmed','A stop request was recorded without a later stopped observation. Remote jobs and credentials are outside this lifecycle unless separately instrumented.',[pendingStop]); |
| 221 | if(afterStop.length)add('activity-after-stop','Activity recorded after sandbox stopped','Recorded execution/effects occur after a stopped assertion for this run and sandbox. Inspect identity, clocks, observer correctness, and descendants.',afterStop,'critical'); |
| 222 | if(duringWait.length)add('waiting-active','Reported waiting overlaps later activity','Waiting describes one control state, not the absence of work across all observation surfaces.',duringWait); |
| 223 | } |
| 224 | for(const src of policy.sources){ |
| 225 | const all=(bySource.get(src.id)??[]).filter(o=>sourceAccepts(src,o));let gaps=0;const gapEvidence:Observation[]=[]; |
| 226 | const epochs=new Map<string,Map<number,Observation>>();for(const o of all){const seq=epochs.get(o.epoch)??new Map<number,Observation>();seq.set(o.sequence,o);epochs.set(o.epoch,seq);} |
| 227 | for(const bySequence of epochs.values()){const a=[...bySequence.keys()].sort((a,b)=>a-b);for(let i=1;i<a.length;i++){const gap=Math.max(0,a[i]-a[i-1]-1);gaps+=gap;if(gap)gapEvidence.push(bySequence.get(a[i-1])!,bySequence.get(a[i])!);}} |
| 228 | const timeOrdered=[...all].sort((a,b)=>a.sequence-b.sequence),clockEvidence:Observation[]=[]; |
| 229 | for(let i=1;i<timeOrdered.length;i++){const a=timeOrdered[i-1],b=timeOrdered[i];if(a.epoch!==b.epoch||a.time.clockId!==b.time.clockId)continue; |
| 230 | if(a.time.monotonicMs!==undefined&&b.time.monotonicMs!==undefined&&a.time.taskMs!==undefined&&b.time.taskMs!==undefined){const real=b.time.monotonicMs-a.time.monotonicMs,task=b.time.taskMs-a.time.taskMs;if(real>0&&Math.abs(task-real)>Math.max(1000,real*.25))clockEvidence.push(a,b);}} |
| 231 | if(clockEvidence.length)add('clock-divergence','Task clock differs from runtime clock','Same-source monotonic and task-time deltas differ by over 25% and 1 second. Simulation or clock configuration can explain this; it is not proof of evasion.',clockEvidence); |
| 232 | if(gaps)add('sequence-gap','Producer sequence gaps',`${src.id} is missing ${gaps} sequence positions between observed records. Prefix and suffix completeness are unknown.`,gapEvidence); |
| 233 | for(const surface of src.surfaces){const matching=all.filter(o=>o.surface===surface),last=matching.reduce<Observation|undefined>((a,b)=>(a&&(a.receivedAt??a.time.wallMs)>(b.receivedAt??b.time.wallMs))?a:b,undefined),at=last?.receivedAt??null; |
| 234 | rows.push({sourceId:src.id,surface,status:!last?'missing':at===null?'unverified-time':last.facts.monitorState==='disabled'||last.facts.monitorState==='failed'?'disabled':asOf-at!>src.heartbeatMs?'stale':'observed',count:matching.length,lastReceivedAt:at,sequenceGaps:gaps});} |
| 235 | } |
| 236 | for(const surface of policy.expectedSurfaces)if(!policy.sources.some(s=>s.surfaces.includes(surface)))rows.push({sourceId:'—',surface,status:'unconfigured',count:0,lastReceivedAt:null,sequenceGaps:0}); |
| 237 | const absent=rows.filter(r=>r.status!=='observed');if(absent.length)add('coverage-gap','Observation coverage is incomplete',`${absent.length} configured/expected source-surface pairs are missing, stale, disabled, unconfigured, or lack recorder receipt stamps. No clean result is inferred for them.`,[]); |
| 238 | return {version:1,omittedFindings,asOf,inputRecords:bundle.records.length,visibleRecords:visible.length,uniqueRecords:records.length,futureRecords:bundle.records.length-visible.length,findings:findings.sort((a,b)=>a.time-b.time||a.id.localeCompare(b.id)),coverage:rows,interactions:[...interactions.values()],runs:new Set(records.map(o=>o.runId)).size,sandboxes:new Set(records.map(o=>o.subject.sandboxId).filter(Boolean)).size,externalEffects:records.filter(o=>consequential(o)&&o.target?.boundary==='external').length,unknownEffects:records.filter(o=>o.effect==='unknown').length,authority,provenance:'Offline source and policy declarations; not authenticated by the viewer. Observed means reported by an attributed source, not independently certified.'}; |
| 239 | } |
| 240 | const category:Record<Surface,Category>={'model-api':'reasoning',http:'network',mcp:'tool',git:'code',filesystem:'filesystem',process:'code',sandbox:'orchestration',dns:'network',socket:'network',message:'communication',database:'memory',artifact:'filesystem',browser:'browser',clipboard:'communication',other:'other'}; |
| 241 | export function evidenceToTrace(bundle:EvidenceBundle):Trace { |
| 242 | const base=bundle.records.reduce((n,o)=>Math.min(n,o.time.wallMs),bundle.asOf),id=`evidence-${stableHash(bundle.name)}`; |
| 243 | const events:WhaleEvent[]=bundle.records.map(o=>({schemaVersion:1,id:observationEventId(o),traceId:id,startTime:Math.max(0,o.time.wallMs-base),endTime:Math.max(0,o.time.wallMs-base),agentId:`${o.runId}/${o.subject.agentId}`,category:category[o.surface],name:o.action,subtype:o.stage,status:o.status==='error'?'error':o.status==='completed'?'success':o.status==='started'?'running':'unknown',targetId:o.target?.id,targetType:o.target?.kind,attributes:{'evidence.surface':o.surface,'evidence.stage':o.stage,'evidence.effect':o.effect,'evidence.run':o.runId,'evidence.source':o.sourceId,'evidence.sandbox':o.subject.sandboxId??'unknown','evidence.isolationGroup':o.subject.isolationGroup??'unknown',...o.facts},observation:o})); |
| 244 | events.sort((a,b)=>a.startTime-b.startTime||a.id.localeCompare(b.id)); |
| 245 | return {id,name:bundle.name,source:'jsonl',privacy:'metadata',events,duration:Math.max(1,bundle.asOf-base,events.reduce((n,e)=>Math.max(n,e.endTime),0)),originTime:String(base),warnings:['Boundary evidence is metadata-only but identifiers and behavioral timing remain sensitive.','Imported policy/provenance are declarations, not authenticated by this viewer.','No universal interception: only instrumented sources can be observed.'],metadata:{evidenceBundle:{...bundle,records:undefined},evidenceBase:base}}; |
| 246 | } |
| 247 | export function bundleFromTrace(trace:Trace):EvidenceBundle|undefined { |
| 248 | const records=trace.events.flatMap(e=>e.observation?[e.observation]:[]);if(!records.length)return undefined; |
| 249 | const header=trace.metadata.evidenceBundle as Omit<EvidenceBundle,'records'>|undefined; |
| 250 | if(!header)return undefined;return {...header,records}; |
| 251 | } |
| 252 | export function boundaryFindings(trace:Trace):Finding[]{ |
| 253 | const bundle=bundleFromTrace(trace);if(!bundle)return [];const base=Number(trace.metadata.evidenceBase??0); |
| 254 | return analyzeEvidence(bundle).findings.map(f=>({id:f.id,kind:'boundary',severity:f.severity,title:f.title,detail:f.detail,startTime:Math.max(0,f.time-base),endTime:Math.max(0,f.time-base)+1,eventIds:f.recordIds,evidence:{code:f.code,provenance:'declared',scope:'cross-run evidence case'}})); |
| 255 | } |
| 256 |