返回 CodeWhale
evidence.ts
根目录 / pet / src / core / evidence.ts
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
256 lines TYPESCRIPT