diff --git a/AGENT-API.md b/AGENT-API.md index 8ffd0e3..9e654bf 100644 --- a/AGENT-API.md +++ b/AGENT-API.md @@ -1,6 +1,6 @@ # Local browser agents -The administrator enables `policy.agents.enabled`, chooses action-name scopes, and optionally restricts `allowedHosts`. Empty hosts inherits browser destination policy. Each native tab has a stable UUID for its lifetime. Obtain these IDs with `sessions`; an omitted sessionId captures the foreground tab once and pins the operation to it. Closed IDs are invalid. Screenshot scope is reserved but no screenshot API is implemented. +The administrator enables `policy.agents.enabled`, chooses action-name scopes, and optionally restricts `allowedHosts`. Empty hosts inherits browser destination policy. Each native tab has a stable UUID for its lifetime. Obtain these IDs with `sessions`; an omitted sessionId captures the foreground tab once and pins the operation to it. Closed IDs are invalid. The separately approved `screenshot` action exports an inspected foreground webpage viewport under the limits below. ## Windows pairing and CLI @@ -40,6 +40,7 @@ Use `agent-transport-client.cjs` or the trusted Electron helper. The generated n | vault:list | optional sessionId | vault:list | vault:list | approved origins and one-use credential references | | vault:fill | sessionId, credentialRef | vault:fill | vault:fill | `{completed}`; does not authorize submission | | vault:prepare-submit | sessionId, credentialRef | vault:prepare-submit | vault:prepare-submit | `{completed,prepared}`; never submits the form | +| screenshot | optional sessionId | screenshot | browser:screenshot | `{mime:"image/png",width,height,data}`; canonical bounded base64 PNG | | navigate | sessionId, url | navigate | browser:navigate | `{completed}` | | back | sessionId | back | browser:navigate | `{completed}` | | forward | sessionId | forward | browser:navigate | `{completed}` | @@ -189,3 +190,41 @@ residual/duplicate/action/header refusals, human handoff and central revocation. The latest source increment's final native rerun, full suite and Windows package verification are pending; historical green builds do not prove this source state. See [controlled credential policy](docs/controlled-credential-policy.md). + +## Inspected foreground visual export + +`screenshot` starts unchecked in central policy and requires the scope in both the +baseline and the individual grant. It accepts only an optional native session ID; +omission pins the current foreground tab. It cannot target a background/loading +page, full desktop, other windows or browser chrome. The supported HTTPS viewport +reply contains exactly MIME, dimensions and canonical base64 PNG data. Limits are +2 MiB, dimensions≤4096 and pixels≤8 Mi. There is no clip, resizing or full-page +scroll mode. It never exposes CDP or arbitrary capture configuration. + +Mandatory local image/OCR complete coverage precedes export, together with enabled +YARA/semantic checks and configured upload ICAP. Sensitive-read permission is no +override. All credential/card/authentication-bearing controls are refused, even +hidden/offscreen ones. Open shadow roots are observed recursively and pending +mutation records drained; newly encountered roots advance the revision. Closed/ +declarative shadow roots and child frames are refused. Active media/animations +remain a compatibility/security limitation; classifiers are not proof against +rendered secrets or steganography. + +Privileged code keeps an opaque private release guard outside the API result. +After audit, encryption and final authorization, it rechecks the original policy, +frame/process/routing identity, loading state, context token, actor and foreground +activation immediately before returning the encrypted reply. This applies to +legacy and individual callers. The PNG is byte-bound through inspection; bounded +PNG validation excludes arbitrary text/EXIF/profile chunks and allows only required +image chunks plus the supported standard sRGB marker. No image/OCR payload enters +ordinary audit or diagnostic fields. Buffers are wiped best effort; returned +base64 strings and caller copies cannot be guaranteed erased from managed memory. + +The protected channel encrypts the reply on the wire; the trusted helper decrypts +its result for the caller. Treat received pixels and stdout/request artifacts as +sensitive. This scope does not authorize sending an image onward to a cloud model. +See [agent visual export](docs/agent-visual-export.md) for native proof scope and +current validation status. The current native late-policy, final foreground and open-shadow mutation/revert +proof passes with all eight named visual phases and final request parentage. +695 tests and syntax checks pass; current Windows build/package verification +remain pending. diff --git a/README.md b/README.md index aea96b0..3873f43 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ S Browser uses translucent materials, rounded controls, light/dark themes, and a | Transfers | Bounded pre-release download scanning, byte-derived file-type checks, SHA-256 binding, configurable YARA and company ICAP. Supported clipboard, in-memory request bodies and outgoing HTTP headers receive policy checks. Ordinary Windows NTFS file uploads use held native handles and pre-release inspection; unsupported ranges, data pipes and streams remain blocked. | | Integrations | OIDC SSO; administrator/operator/auditor roles; system/fixed/PAC proxies; managed DNS; optional Windows VPN presence checks; ICAP/ICAPS inspection. | | Administration | Policy templates, diffs and history, independent publication review, groups, profiles, teaching workflows, incidents and configurable event retention. | -| AI and agents | Approved AI routing with server-held credentials; scoped local agent navigation, tabs, page interaction, settings, bookmarks, inspected history and bounded browser window controls. Separate centrally approved agent identities, scoped credentials, expiry, generation rotation and targeted revocation; administrators can disable all agent access. | +| AI and agents | Approved AI routing with server-held credentials; scoped local agent navigation, tabs, page interaction, settings, bookmarks, inspected history, bounded browser window controls and separately approved inspected foreground PNG export. Separate centrally approved agent identities, scoped credentials, expiry, generation rotation and targeted revocation; administrators can disable all agent access. | | Observability | OpenTelemetry traces, metrics and sanitized logs; durable security events; ECS/CEF exports and HTTPS/mTLS SIEM delivery. Governed protected download metadata, completed clipboard/agent-click metadata and opt-in observed foreground clicks/form gestures. Full replay and content recording remain incomplete. | | Additional controls | Local encrypted password vault with explicit autofill and separately approved agent status/masked inventory/fill after human unlock. Default-off controlled login preparation can authorize one approved native form POST; protected-brand visual similarity checks pair with trusted HTTPS origins. These features require further independent evaluation. | @@ -42,6 +42,8 @@ Individual agent mode currently blocks browser-session requests without a known [Controlled vault login preparation](docs/controlled-credential-policy.md) is separate from ordinary autofill. Administrators approve exact HTTPS action URLs and credential field names; human unlock is required, and agents need the separate `vault:prepare-submit` capability. Preparation fills the form but does not submit it. Supported native same-origin URL-encoded POSTs remain subject to current policy and inspection of every other field; arbitrary JavaScript/XHR/JSON login flows remain unsupported. The approved site can read filled credentials in its DOM. This is not password isolation or proof of website identity. +[Inspected visual export](docs/agent-visual-export.md) is limited to the foreground managed HTTPS webpage viewport. It requires complete local image/OCR inspection and configured enterprise scanners; sensitive controls, child frames and closed shadow roots are refused. It captures no desktop, other window or browser chrome. Local classification can miss rendered secrets; an allow decision is not a confidentiality guarantee. + Permissions are enforced by the backend and privileged browser code, not merely by hiding controls. Matching restrictive policies accumulate. Domain categories are curated company lists, not a subscription categorization feed. Developer tools are a privileged exception, disabled for ordinary profiles. Delegating them changes the trust boundary: DevTools can provide paths outside normal clipboard guards. Grant access only to explicitly trusted developer profiles; do not assume complete DLP coverage when they are enabled. diff --git a/apps/browser/agent-control.cjs b/apps/browser/agent-control.cjs index 3657e85..031b4b3 100644 --- a/apps/browser/agent-control.cjs +++ b/apps/browser/agent-control.cjs @@ -4,7 +4,7 @@ const crypto = require('node:crypto'); const channel=require('../../packages/core/policy-channel.cjs'); const APP_ACTIONS=Object.freeze(['vault:prepare-submit','sessions:save','sessions:list','sessions:restore','vault:status','vault:list','vault:fill','history:list','history:go','window:state','window:control','application:state','settings:get','settings:update','bookmarks:list','bookmarks:mutate','tabs:create','tabs:activate','tabs:close','tabs:group','library:organize-plan','library:organize-apply']); -const SCOPES = Object.freeze({ sessions: 'sessions:list', navigate: 'browser:navigate', back:'browser:navigate',forward:'browser:navigate',reload:'browser:navigate', read: 'browser:read', inspect:'browser:read', scroll:'browser:interact', click: 'browser:interact', type: 'browser:interact', select:'browser:interact', check:'browser:interact', ai: 'ai:request',...Object.fromEntries(APP_ACTIONS.map(action=>[action,action])) }); +const SCOPES = Object.freeze({ screenshot:'browser:screenshot', sessions: 'sessions:list', navigate: 'browser:navigate', back:'browser:navigate',forward:'browser:navigate',reload:'browser:navigate', read: 'browser:read', inspect:'browser:read', scroll:'browser:interact', click: 'browser:interact', type: 'browser:interact', select:'browser:interact', check:'browser:interact', ai: 'ai:request',...Object.fromEntries(APP_ACTIONS.map(action=>[action,action])) }); const fail = (status, message) => Object.assign(new Error(message), { status }); const bounded = (value, max, name) => { if (typeof value !== 'string' || !value.length || value.length > max || /\u0000/.test(value)) throw fail(400, `Invalid ${name}`); @@ -16,7 +16,7 @@ function validate(body) { if (!Object.hasOwn(SCOPES, action)) throw fail(400, 'Unsupported action'); const args = body.args ?? {}; if (!args || typeof args !== 'object' || Array.isArray(args)) throw fail(400, 'Invalid arguments'); - const keys = { 'sessions:save':['name','sessionIds','id','expectedRevision'],'sessions:list':[],'sessions:restore':['id','planId'],'vault:status':['sessionId'],'vault:list':['sessionId'],'vault:fill':['sessionId','credentialRef'],'vault:prepare-submit':['sessionId','credentialRef'],'history:list':['sessionId'],'history:go':['sessionId','snapshotId','entryId'],'window:state':[],'window:control':['command'],sessions: [], navigate: ['sessionId', 'url'],back:['sessionId'],forward:['sessionId'],reload:['sessionId'], read: ['sessionId'], inspect:['sessionId'], scroll:['sessionId','x','y'], click: ['sessionId', 'selector'], type: ['sessionId', 'selector', 'text'], select:['sessionId','selector','value'], check:['sessionId','selector','checked'], ai: ['sessionId', 'prompt'],'application:state':[],'settings:get':[],'settings:update':['appearance'],'bookmarks:list':[],'bookmarks:mutate':['action','bookmark','id','expectedRevision','policyVersion'],'tabs:create':['group','url'],'tabs:activate':['sessionId'],'tabs:close':['sessionId'],'tabs:group':['sessionId','group'],'library:organize-plan':[],'library:organize-apply':['proposalId','confirm','autoApply'] }[action]; + const keys = { 'sessions:save':['name','sessionIds','id','expectedRevision'],'sessions:list':[],'sessions:restore':['id','planId'],'vault:status':['sessionId'],'vault:list':['sessionId'],'vault:fill':['sessionId','credentialRef'],'vault:prepare-submit':['sessionId','credentialRef'],'history:list':['sessionId'],'history:go':['sessionId','snapshotId','entryId'],'window:state':[],'window:control':['command'],screenshot:['sessionId'], sessions: [], navigate: ['sessionId', 'url'],back:['sessionId'],forward:['sessionId'],reload:['sessionId'], read: ['sessionId'], inspect:['sessionId'], scroll:['sessionId','x','y'], click: ['sessionId', 'selector'], type: ['sessionId', 'selector', 'text'], select:['sessionId','selector','value'], check:['sessionId','selector','checked'], ai: ['sessionId', 'prompt'],'application:state':[],'settings:get':[],'settings:update':['appearance'],'bookmarks:list':[],'bookmarks:mutate':['action','bookmark','id','expectedRevision','policyVersion'],'tabs:create':['group','url'],'tabs:activate':['sessionId'],'tabs:close':['sessionId'],'tabs:group':['sessionId','group'],'library:organize-plan':[],'library:organize-apply':['proposalId','confirm','autoApply'] }[action]; if (Object.keys(args).some(k => !keys.includes(k))) throw fail(400, 'Unsupported argument'); const clean = {}; if(action==='sessions:save'){ @@ -70,6 +70,7 @@ function validate(body) { } // Never return adapter objects wholesale: session handles and sanitized text only. function project(action, result) { + if(action==='screenshot')return require('./agent-visual-export.cjs').validateVisualResult(result); if(action.startsWith('sessions:')){ const handle=value=>{if(typeof value!=='string'||!/^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/.test(value))throw fail(502,'Invalid session handle');return value;}; if(action==='sessions:save')return {id:handle(result?.id),revision:handle(result?.revision),tabCount:sessionCount(result?.tabCount)}; @@ -125,10 +126,12 @@ function project(action, result) { } function sessionCount(value){if(!Number.isSafeInteger(value)||value<1||value>32)throw fail(502,'Invalid session count');return value;} -async function startAgentControl({ authorize, actions, audit, credentialFile, persistCredential, onError, traceOperation = async (_name,fn)=>fn(), timeoutMs = 10000, maxRequestsPerMinute = 60, principalMode = 'legacy', principals = [] } = {}) { +async function startAgentControl({ authorize, actions, audit, credentialFile, persistCredential, onError, traceOperation = async (_name,fn)=>fn(), traceRelease = (_name,fn)=>fn(), timeoutMs = 10000, actionTimeouts = {}, maxRequestsPerMinute = 60, principalMode = 'legacy', principals = [] } = {}) { if(credentialFile!==undefined)throw new TypeError('Plaintext pairing files are prohibited; use protected persistCredential'); - if (typeof authorize !== 'function' || typeof audit !== 'function' || !actions) throw new TypeError('authorize, actions and audit are required'); + if (typeof authorize !== 'function' || typeof audit !== 'function' || typeof traceRelease!=='function' || !actions) throw new TypeError('authorize, actions and audit are required'); if (!Number.isInteger(timeoutMs) || timeoutMs < 10 || timeoutMs > 60000 || !Number.isInteger(maxRequestsPerMinute) || maxRequestsPerMinute < 1 || maxRequestsPerMinute > 1000) throw new TypeError('Invalid limits'); + if(!actionTimeouts||typeof actionTimeouts!=='object'||Array.isArray(actionTimeouts)||Object.entries(actionTimeouts).some(([action,limit])=>!Object.hasOwn(SCOPES,action)||!Number.isInteger(limit)||limit<10||limit>60000))throw TypeError('Invalid action deadlines'); + const deadlines=Object.freeze({...actionTimeouts}); if(!['legacy','individual'].includes(principalMode))throw new TypeError('Invalid principal mode'); const token = principalMode==='legacy'?crypto.randomBytes(32).toString('base64url'):undefined; const serviceId=crypto.randomUUID(), principalRecords=new Map(), approved=new Map(), owners=new Map(), generationFloor=new Map(); @@ -155,7 +158,7 @@ async function startAgentControl({ authorize, actions, audit, credentialFile, pe res.setHeader('X-Content-Type-Options', 'nosniff'); const reply = (status, data) => { if (!res.destroyed && !res.writableEnded) { res.writeHead(status); res.end(JSON.stringify(data)); } }; const controller = new AbortController(); - let principal, verifiedPrincipal, action, outcome = 'denied', timer, protectedContext, bundle, validatedRequest; + let principal, verifiedPrincipal, action, outcome = 'denied', timer, protectedContext, bundle, validatedRequest, releaseGuard; try { let proof; if(principalMode==='individual'){ @@ -183,23 +186,23 @@ async function startAgentControl({ authorize, actions, audit, credentialFile, pe if (typeof actions[action] !== 'function') throw fail(403, 'Action unavailable'); running.add(controller);if(principal)owners.set(controller,principal.id); // Authorizers must be side-effect free: individual grants are checked at each release boundary. - const reauthorize=async()=>{if(!principal)return;const grant=await traceOperation('agent.authorize',()=>authorize({...request,principalId:principal.id,generation:principal.generation,signal:controller.signal}));if(!enabled||!principalLive(principal)||controller.signal.aborted||grant?.allowed!==true||!['agent','admin'].includes(grant.role)||!Array.isArray(grant.scopes)||!grant.scopes.includes(request.scope))throw fail(403,'Policy denied action');}; + const reauthorize=async()=>{if(!principal&&action!=='screenshot')return;const grant=await traceOperation('agent.authorize',()=>authorize({...request,...(principal?{principalId:principal.id,generation:principal.generation}:{}),signal:controller.signal}));if(!enabled||(principal&&!principalLive(principal))||controller.signal.aborted||grant?.allowed!==true||!['agent','admin'].includes(grant.role)||!Array.isArray(grant.scopes)||!grant.scopes.includes(request.scope))throw fail(403,'Policy denied action');}; const operation = (async () => { const grant = await traceOperation('agent.authorize',()=>authorize({ ...request,...(principal?{principalId:principal.id,generation:principal.generation}:{}), signal: controller.signal })); if (!enabled || (principal&&!principalLive(principal)) || controller.signal.aborted || grant?.allowed !== true || !['agent', 'admin'].includes(grant.role) || !Array.isArray(grant.scopes) || !grant.scopes.includes(request.scope)) throw fail(403, 'Policy denied action'); await traceOperation('agent.audit',()=>audit({ ...(principal?{principalId:principal.id,generation:principal.generation}:{}),action, scope: request.scope, sessionId: request.sessionId, outcome: 'authorized' })); if (!enabled || (principal&&!principalLive(principal)) || controller.signal.aborted) throw fail(403, 'Agent access revoked'); await reauthorize(); - const value=await traceOperation('agent.dispatch',()=>actions[action](request.args, { signal: controller.signal,...(verifiedPrincipal?{principalId:verifiedPrincipal.id,generation:verifiedPrincipal.generation}:{}) }));return traceOperation('agent.project',()=>project(action,value)); + const value=await traceOperation('agent.dispatch',()=>actions[action](request.args, { signal: controller.signal,...(verifiedPrincipal?{principalId:verifiedPrincipal.id,generation:verifiedPrincipal.generation}:{}) }));if(action==='screenshot'){releaseGuard=require('./agent-visual-export.cjs').getVisualReleaseGuard(value);if(typeof releaseGuard!=='function')throw fail(403,'Visual release context unavailable');releaseGuard();}return traceOperation('agent.project',()=>project(action,value)); })(); - const result = await Promise.race([operation, new Promise((_, reject) => { timer = setTimeout(() => { controller.abort(); reject(fail(504, 'Action timed out')); }, timeoutMs); })]); + const result = await Promise.race([operation, new Promise((_, reject) => { timer = setTimeout(() => { controller.abort(); reject(fail(504, 'Action timed out')); }, deadlines[action]??timeoutMs); })]); if (!enabled || (principal&&!principalLive(principal)) || controller.signal.aborted) throw fail(403, 'Agent access revoked'); await reauthorize(); outcome = 'completed'; await traceOperation('agent.audit',()=>audit({ ...(principal?{principalId:principal.id,generation:principal.generation}:{}),action, outcome,scope:request.scope,sessionId:request.sessionId })); if (!enabled || (principal&&!principalLive(principal)) || controller.signal.aborted) throw fail(403, 'Agent access revoked'); await reauthorize(); - const encryptedReply=await traceOperation('agent.encrypt',()=>channel.response({kind:'agent-response',status:200,result},bundle,protectedContext));await reauthorize();if(!enabled||(principal&&!principalLive(principal))||controller.signal.aborted)throw fail(403,'Agent access revoked');reply(200,encryptedReply); + const encryptedReply=await traceOperation('agent.encrypt',()=>channel.response({kind:'agent-response',status:200,result},bundle,protectedContext));await reauthorize();if(!enabled||(principal&&!principalLive(principal))||controller.signal.aborted)throw fail(403,'Agent access revoked');if(action==='screenshot'){if(typeof releaseGuard!=='function')throw fail(403,'Visual release context unavailable');let called=false,refused;const once=()=>{if(!called){called=true;try{releaseGuard();}catch(error){refused=error;}}if(refused)throw refused;};try{const observation=traceRelease('visual.release',once);observation?.catch?.(()=>{});}catch{}if(!called)once();if(refused)throw fail(403,'Visual release context changed');}reply(200,encryptedReply); } catch (error) { outcome=(error.status||500)>=500?'failed':'denied'; // Diagnostics expose only known static reasons, never arbitrary adapter errors. diff --git a/apps/browser/agent-visual-export.cjs b/apps/browser/agent-visual-export.cjs new file mode 100644 index 0000000..c444794 --- /dev/null +++ b/apps/browser/agent-visual-export.cjs @@ -0,0 +1,20 @@ +'use strict'; +const {createHash}=require('node:crypto'); +const {AsyncResource}=require('node:async_hooks'); +const releaseGuards=new WeakMap(); +const getVisualReleaseGuard=result=>result&&typeof result==='object'?releaseGuards.get(result):undefined; +const refused=()=>{const error=Error('Managed visual export refused');error.status=403;throw error;}; +const MAX_BYTES=2*1024*1024,MAX_DIMENSION=4096,MAX_PIXELS=8*1024*1024; +const crcTable=Array.from({length:256},(_,n)=>{let c=n;for(let i=0;i<8;i++)c=(c&1)?0xedb88320^(c>>>1):c>>>1;return c>>>0;}); +function crc32(bytes,start,end){let c=0xffffffff;for(let i=start;i>>8);return (c^0xffffffff)>>>0;} +function pngSize(bytes){if(!Buffer.isBuffer(bytes)||bytes.length<57||bytes.length>MAX_BYTES||!bytes.subarray(0,8).equals(Buffer.from([137,80,78,71,13,10,26,10])))refused();let offset=8,dimensions,idat=false,end=false,chunks=0;while(offset4096||offset+12>bytes.length)refused();const size=bytes.readUInt32BE(offset),type=bytes.toString('ascii',offset+4,offset+8);if(!['IHDR','IDAT','IEND','sRGB'].includes(type)||size>bytes.length-offset-12)refused();if(type==='sRGB'&&(size!==1||bytes[offset+8]>3||idat))refused();if(crc32(bytes,offset+4,offset+8+size)!==bytes.readUInt32BE(offset+8+size))refused();if(!dimensions){if(type!=='IHDR'||size!==13)refused();const width=bytes.readUInt32BE(offset+8),height=bytes.readUInt32BE(offset+12);if(!width||!height||width>MAX_DIMENSION||height>MAX_DIMENSION||width*height>MAX_PIXELS||bytes[offset+16]!==8||![2,6].includes(bytes[offset+17])||bytes[offset+18]!==0||bytes[offset+19]!==0||bytes[offset+20]>1)refused();dimensions={width,height};}else if(type==='IHDR')refused();if(type==='IDAT')idat=true;if(type==='IEND'){if(size||offset+12!==bytes.length)refused();end=true;}offset+=12+size;if(end)break;}if(!end||!idat)refused();return dimensions;} +function validateVisualResult(input){let bytes;try{if(!input||typeof input!=='object'||Array.isArray(input)||Object.getPrototypeOf(input)!==Object.prototype)refused();const descriptors=Object.getOwnPropertyDescriptors(input);if(Object.keys(descriptors).sort().join(',')!=='data,height,mime,width'||Object.values(descriptors).some(d=>!('value'in d)))refused();const {mime,width,height,data}=input;if(mime!=='image/png'||typeof data!=='string'||data.length>Math.ceil(MAX_BYTES/3)*4||data.length%4||!data.length||!/^[A-Za-z0-9+/]*={0,2}$/.test(data))refused();bytes=Buffer.from(data,'base64');if(bytes.toString('base64')!==data)refused();const size=pngSize(bytes);if(size.width!==width||size.height!==height)refused();return {mime,width,height,data};}finally{bytes?.fill(0);}} +function createAgentVisualExport({getContext,contextCurrent,probeSensitive,capture,inspectLocal,inspectEnterprise,audit,traceOperation=async(_operation,fn)=>fn()}={}){ + if([getContext,contextCurrent,probeSensitive,capture,inspectLocal,inspectEnterprise,audit].some(fn=>typeof fn!=='function'))throw TypeError('Visual export callbacks required'); + const stage=async(operation,fn)=>{let called=false,result;const once=()=>{if(called)return result;called=true;try{result=Promise.resolve(fn());}catch(error){result=Promise.reject(error);}return result;};try{await traceOperation(operation,once);}catch{}if(!called)once();return await result;}; + const snapshot=(args,signal)=>{if(!args||typeof args!=='object'||Array.isArray(args)||Object.keys(args).some(k=>k!=='sessionId'))refused();const c=getContext(args.sessionId);if(signal?.aborted||!c?.ready||!c.token||!c.webContents||c.webContents.isDestroyed?.()||c.webContents.isLoading?.()||c.foreground!==true||c.interactive!==true||!c.policy?.agents?.enabled||!c.policy.agents.scopes?.includes('screenshot')||!Number.isFinite(Date.parse(c.policy.expiresAt))||Date.parse(c.policy.expiresAt)<=Date.now()||!c.actor||typeof c.actor.agentId!=='string'||!Number.isSafeInteger(c.actor.generation))refused();const url=c.webContents.getURL();let u;try{u=new URL(url);}catch{refused();}if(u.protocol!=='https:'||u.username||u.password||typeof c.destinationAllowed!=='function'||c.destinationAllowed(url,c.policy)!==true)refused();const frame=c.webContents.mainFrame;if(frame&&frame.isDestroyed?.())refused();return {...c,url,actorBinding:JSON.stringify(c.actor),nativeFrame:frame,nativeProcessId:frame?.processId,nativeRoutingId:frame?.routingId};}; + const current=(c,args,signal)=>{const n=snapshot(args,signal);if(n.policy!==c.policy||n.webContents!==c.webContents||n.sessionId!==c.sessionId||n.actorBinding!==c.actorBinding||n.url!==c.url||n.nativeFrame!==c.nativeFrame||n.nativeProcessId!==c.nativeProcessId||n.nativeRoutingId!==c.nativeRoutingId||!contextCurrent(c.token))refused();}; + const probe=async(c,args,signal,expected)=>stage('visual.probe',async()=>{const p=await probeSensitive(c);current(c,args,signal);if(!p||p.safe!==true||typeof p.revision!=='string'||!p.revision||p.revision.length>128||!Number.isSafeInteger(p.width)||!Number.isSafeInteger(p.height)||p.width<1||p.height<1||p.width>MAX_DIMENSION||p.height>MAX_DIMENSION||p.width*p.height>MAX_PIXELS)refused();const value={revision:p.revision,width:p.width,height:p.height};if(expected&&JSON.stringify(value)!==JSON.stringify(expected))refused();return value;}); + return async function screenshot(args={}, {signal}={}){let bytes;try{const c=snapshot(args,signal);current(c,args,signal);const baseline=await probe(c,args,signal);bytes=await stage('visual.capture',async()=>{const value=await capture(c);bytes=value;current(c,args,signal);if(!Buffer.isBuffer(value)||value.length>MAX_BYTES)refused();return value;});const size=await stage('visual.validate',()=>pngSize(bytes));current(c,args,signal);const binding=createHash('sha256').update(bytes).digest('hex');await probe(c,args,signal,baseline);const inspectPolicy=c.policy;await stage('visual.inspect.local',async()=>{const local=await inspectLocal(bytes,inspectPolicy,c);current(c,args,signal);if(local?.decision!=='allow'||local.contentType!=='image'||local.coverage?.complete!==true)refused();});await probe(c,args,signal,baseline);await stage('visual.inspect.enterprise',async()=>{const enterprise=await inspectEnterprise(bytes,inspectPolicy,c);current(c,args,signal);if(enterprise?.decision!=='allow'||enterprise.coverage?.complete!==true)refused();});await probe(c,args,signal,baseline);if(createHash('sha256').update(bytes).digest('hex')!==binding)refused();await stage('visual.audit',()=>audit({action:'screenshot',outcome:'authorized',sessionId:c.sessionId}));current(c,args,signal);await probe(c,args,signal,baseline);if(createHash('sha256').update(bytes).digest('hex')!==binding)refused();return await stage('visual.project',()=>{current(c,args,signal);const result=Object.freeze(validateVisualResult({mime:'image/png',...size,data:bytes.toString('base64')}));releaseGuards.set(result,AsyncResource.bind(()=>current(c,args,signal)));return result;});}catch{refused();}finally{bytes?.fill?.(0);}}; +} +module.exports={createAgentVisualExport,getVisualReleaseGuard,validateVisualResult,pngSize,MAX_BYTES,MAX_DIMENSION,MAX_PIXELS}; diff --git a/apps/browser/agent-visual-probe.cjs b/apps/browser/agent-visual-probe.cjs new file mode 100644 index 0000000..e224a71 --- /dev/null +++ b/apps/browser/agent-visual-probe.cjs @@ -0,0 +1,62 @@ +'use strict'; + +// Runs only in a private isolated world. No bridge, page callback or caller code. +function visualDocumentProbe(expectedURL) { + if (location.href !== expectedURL || document.readyState !== 'complete') throw Error('Visual probe refused'); + const key='__safetynetVisualProbeV1'; + let state=globalThis[key]; + if (!state) { + state={id:crypto.randomUUID(),revision:0,roots:new WeakSet(),watches:[]}; + const changed=()=>{state.revision++;}; + state.watch=root=>{ + if(state.roots.has(root))return; + if(state.watches.length>=129)throw Error('Visual probe refused'); + const observer=new MutationObserver(changed); + observer.observe(root,{subtree:true,childList:true,characterData:true,attributes:true}); + state.roots.add(root);state.watches.push({root,observer}); + if(root!==document)changed(); + }; + state.watch(document); + for (const name of ['input','change','scroll','resize']) addEventListener(name,changed,true); + Object.defineProperty(globalThis,key,{value:state,writable:false,configurable:false}); + } + for(const watch of [...state.watches]){ + if(watch.root!==document&&watch.root.host?.isConnected===false){watch.observer.disconnect();state.roots.delete(watch.root);state.watches.splice(state.watches.indexOf(watch),1);state.revision++;} + else if(watch.observer.takeRecords().length)state.revision++; + } + const forbidden=/password|passwd|secret|token|credential|api.?key|auth|one.?time|otp|credit.?card|cc-number|cc-csc/i; + let safe=true,visited=0; + const visible=e=>{ + for(let n=e;n;n=n.parentElement){const s=getComputedStyle(n);if(s.display==='none'||s.visibility==='hidden'||s.opacity==='0'||n.hidden)return false;} + const r=e.getBoundingClientRect();return r.width>0&&r.height>0&&r.bottom>0&&r.right>0&&r.left20000){safe=false;return;} + if(['IFRAME','FRAME','OBJECT','EMBED'].includes(e.tagName)){safe=false;return;} + // Credential-bearing controls are refused even offscreen: a transient CSS + // reveal must not evade the checks surrounding native surface capture. + if(/^(INPUT|TEXTAREA|SELECT)$/.test(e.tagName)&& + (e.type==='password'||['name','id','autocomplete','aria-label'].some(a=>forbidden.test(e.getAttribute(a)||'')))){safe=false;return;} + if(e.hasAttribute('data-sensitive')&&visible(e)){safe=false;return;} + if(e.shadowRoot){walk(e.shadowRoot);if(!safe)return;} + } + } + walk(document); + const width=Math.round(innerWidth),height=Math.round(innerHeight); + // Active media/animations are not represented by the DOM revision counter. + if(state.watches.some(w=>(w.root.getAnimations?.()||[]).some(a=>a.playState==='running'))|| + [...document.querySelectorAll('video')].some(e=>visible(e)&&!e.paused))safe=false; + return {safe,revision:[state.id,state.revision,width,height,scrollX,scrollY,devicePixelRatio,visualViewport?.scale||1].join(':'),width,height}; +} + +async function probeVisualContext(context) { + const contents=context.webContents,frame=contents.mainFrame; + if(!frame||frame.isDestroyed()||frame.framesInSubtree.length!==1||contents.isLoading())throw Error('Managed visual export refused'); + await require('./page-clipboard-guard.cjs').guardedVisualStructure(contents); + const result=await contents.executeJavaScriptInIsolatedWorld(1004,[{code:`(${visualDocumentProbe.toString()})(${JSON.stringify(context.url)})`}],false); + if(contents.isDestroyed()||contents.mainFrame!==frame||frame.isDestroyed()||frame.framesInSubtree.length!==1)throw Error('Managed visual export refused'); + return result; +} +module.exports={probeVisualContext,visualDocumentProbe}; diff --git a/apps/browser/diagnostics.cjs b/apps/browser/diagnostics.cjs index abe2c98..5558a50 100644 --- a/apps/browser/diagnostics.cjs +++ b/apps/browser/diagnostics.cjs @@ -12,6 +12,7 @@ const {OTLPLogExporter}=require('@opentelemetry/exporter-logs-otlp-http'); const {trace,ROOT_CONTEXT,SpanStatusCode,TraceFlags}=require('@opentelemetry/api'); const names=new Set(['browser.window','browser.network','browser.ipc','browser.agent','browser.management','browser.navigation','browser.dns','browser.request','browser.request.headers','browser.inspect.local','browser.inspect.enterprise','browser.download','browser.clipboard','browser.visual','browser.password.fill','browser.ai','browser.policy','browser.startup','browser.parser','browser.semantic','browser.yara','browser.icap','browser.tab','browser.security','browser.evidence']); const operations=new Set(['audit.deliver','evidence.observer.install','evidence.observer.drain','evidence.observer.dispose','policy.verify','policy.bookmarks','policy.agent-reconcile','policy.page-configure','policy.audit','evidence.capture','evidence.authorize','evidence.challenge','evidence.encrypt','evidence.send','evidence.ack','agent.request','agent.challenge','agent.decrypt','agent.transport','agent.authorize','agent.audit','agent.dispatch','agent.response','agent.project','agent.encrypt','library:organize-plan','library:organize-apply','application:state','settings:get','settings:update','bookmarks:list','bookmarks:mutate','tabs:create','tabs:activate','tabs:close','tabs:group','state','authorize-setting','settings-update','window-control','chrome-layout','share-url','bookmarks-mutate','enroll','sso','navigate','back','forward','reload','ai','inspect-file','agent-connection','ai-panel','vault-panel','vault-status','vault-create','vault-unlock','vault-lock','vault-list','vault-save','vault-delete','vault-fill','vault-generate','tab-create','tab-activate','tab-close','tab-group','sessions','read','inspect','scroll','click','type','select','check','screenshot','copy','cut','paste','scan','download','sync','create','activate','close','group','policy','request','dns','parse','embed','yara','icap']); +for(const operation of ['visual.probe','visual.capture','visual.inspect.local','visual.inspect.enterprise','visual.audit','visual.validate','visual.project','visual.release'])operations.add(operation); const components=new Set(['browser','ipc','agent','management','navigation','network','dns','dlp','inspector','parser','semantic','yara','icap','download','clipboard','visual','vault','ai','policy','tabs']); for(const operation of ['history:list','history:go','window:state','window:control'])operations.add(operation); names.add('browser.vault');names.add('browser.credential'); diff --git a/apps/browser/main.cjs b/apps/browser/main.cjs index 03590af..c12603f 100644 --- a/apps/browser/main.cjs +++ b/apps/browser/main.cjs @@ -417,13 +417,21 @@ async function ensureAgentAPI(){ const actor=agentActorStorage.getStore();await auditAgent({action:'click',outcome:'completed',sessionId:metadata.sessionId,...(actor?.principalId?{principalId:actor.principalId,generation:actor.generation}:{}),protectedInteraction:payload}); }}); Object.assign(actions,agentSessionControls()); + actions.screenshot=require('./agent-visual-export.cjs').createAgentVisualExport({ + getContext:id=>{const c=agentContext(id),actor=agentActorStorage.getStore();return {...c,ready:c.ready&&actorGrantAllowed(actor,'screenshot'),actor:c.actor||Object.freeze({agentId:'legacy-paired-agent',generation:1})};},contextCurrent:currentTabContext, + probeSensitive:require('./agent-visual-probe.cjs').probeVisualContext,capture:c=>capturePNG(c.webContents), + inspectLocal:async(bytes,p)=>{if(!localInspector)throw Error('Managed visual export refused');return diagnosticSpan('browser.inspect.local',{bytes:bytes.length},()=>localInspector.inspect(bytes,p));}, + inspectEnterprise:(bytes,p)=>diagnosticSpan('browser.inspect.enterprise',{bytes:bytes.length},()=>scanEnterprise(bytes,p,'upload')),audit:auditAgent, + traceOperation:(operation,fn)=>diagnosticSpan('browser.visual',{operation},fn) + }); for(const action of ['click','type','select','check']){const original=actions[action];actions[action]=async(...args)=>{const tab=tabs.get(args[0]?.sessionId||activeTab().id),context=tabContext(tab.id);const checked=await checkVisual(tab);if(!currentTabContext(context)||checked.decision!=='allow')throw Error('Visual login protection has not approved this page');return original(...args);};} for(const action of Object.keys(actions)){const original=actions[action];actions[action]=(args,context={})=>agentActorStorage.run({...context,action},()=>diagnosticSpan('browser.agent',{operation:action},async()=>{ if(context.principalId&&['navigate','back','forward','reload','history:go','vault:fill','vault:prepare-submit','click','type','select','check'].includes(action)){const tab=tabs.get(args.sessionId||activeTab().id);tab.security.agentAuthority={principalId:context.principalId,generation:context.generation,action};} return original(args,context); }));} - agentServiceMode=policy.agents.identityMode;agentService=await startAgentControl({actions,principalMode:agentServiceMode,principals:agentServiceMode==='individual'?effectiveAgentPolicy().agents.grants:[], + agentServiceMode=policy.agents.identityMode;agentService=await startAgentControl({actions,actionTimeouts:{screenshot:45000},principalMode:agentServiceMode,principals:agentServiceMode==='individual'?effectiveAgentPolicy().agents.grants:[], traceOperation:(operation,fn)=>diagnosticSpan('browser.agent',{operation},fn), + traceRelease:(operation,fn)=>diagnostics.spanSync('browser.visual',diagnosticContext({operation}),fn), onError:error=>{if(!app.isPackaged&&process.env.SAFETYNET_SMOKE_ARTIFACTS)fs.appendFileSync(path.join(process.env.SAFETYNET_SMOKE_ARTIFACTS,'smoke-entry.txt'),`Agent action diagnostic: ${error.reason}\n`);}, authorize:({action,args,scope,principalId,generation})=>{ const actor={principalId,generation,action}; diff --git a/apps/browser/page-clipboard-guard.cjs b/apps/browser/page-clipboard-guard.cjs index 0747d03..6a734cc 100644 --- a/apps/browser/page-clipboard-guard.cjs +++ b/apps/browser/page-clipboard-guard.cjs @@ -52,4 +52,23 @@ function disposePageClipboardGuard(contents){ owners.delete(contents);owner.healthy=false;owner.api.removeListener('detach',owner.detached); if(owner.api.isAttached())owner.api.detach(); } -module.exports={attachPageClipboardGuard,guardedScreenshot,disposePageClipboardGuard}; +async function guardedVisualStructure(contents){ + const owner=owners.get(contents); + if(!owner?.healthy||!owner.api.isAttached()||contents.isDestroyed())throw Error('Managed visual export refused'); + // Native DOM data detects closed/declarative roots that isolated DOM queries + // cannot see. It is consumed privately and never logged or returned to agents. + const result=await deadline(owner.api.sendCommand('DOM.getDocument',{depth:-1,pierce:true})); + if(owners.get(contents)!==owner||!owner.healthy||!owner.api.isAttached())throw Error('Managed visual export refused'); + const queue=[result?.root];let nodes=0,characters=0; + while(queue.length){ + const node=queue.pop();if(!node||++nodes>20000)throw Error('Managed visual export refused'); + for(const text of [node.nodeName,node.nodeValue,...(node.attributes||[])]){if(typeof text!=='string'||(characters+=text.length)>1024*1024)throw Error('Managed visual export refused');} + const shadows=node.shadowRoots||[],children=node.children||[]; + if(!Array.isArray(shadows)||!Array.isArray(children)||nodes+queue.length+shadows.length+children.length>20000)throw Error('Managed visual export refused'); + for(const root of shadows){if(root.shadowRootType==='closed')throw Error('Managed visual export refused');queue.push(root);} + if(node.contentDocument)throw Error('Managed visual export refused'); + queue.push(...children); + } + return {safe:true,nodes}; +} +module.exports={attachPageClipboardGuard,guardedScreenshot,guardedVisualStructure,disposePageClipboardGuard}; diff --git a/apps/server/public/agent-grants-admin.js b/apps/server/public/agent-grants-admin.js index d1c69a9..93a4c60 100644 --- a/apps/server/public/agent-grants-admin.js +++ b/apps/server/public/agent-grants-admin.js @@ -13,7 +13,7 @@ function renderAgentGrants(){ const enabled=agentGrantField(grid,'Enabled');enabled.type='checkbox';enabled.checked=grant.enabled;enabled.disabled=!agentGrantsEditable;enabled.addEventListener('change',()=>{grant.enabled=enabled.checked;changedAgentGrants();}); const expiry=agentGrantField(grid,'Expiry (UTC)');expiry.type='text';expiry.value=grant.expiresAt;expiry.placeholder='2026-12-31T23:59:59Z';expiry.disabled=!agentGrantsEditable;expiry.addEventListener('input',()=>{grant.expiresAt=expiry.value.trim();changedAgentGrants();}); const hosts=agentGrantField(grid,'Additional destination allowlist','textarea');hosts.value=grant.allowedHosts.join('\n');hosts.placeholder='Empty adds no destination restriction';hosts.disabled=!agentGrantsEditable;hosts.addEventListener('input',()=>{grant.allowedHosts=hosts.value.split('\n').map(v=>v.trim()).filter(Boolean);changedAgentGrants();}); - const scopeBox=document.createElement('fieldset');const scopeLegend=document.createElement('legend');scopeLegend.textContent='Additional capability limit';scopeBox.append(scopeLegend);const scopeHelp=document.createElement('p');scopeHelp.className='muted';scopeHelp.textContent='Session saving, listing and restoration require separate capabilities. Saved URLs and tab group metadata never include passwords, form values or page state. Restoration rechecks current policy and does not restore revoked agent authority. History reads and navigation require separate capabilities. Window controls are limited to supported browser operations. Vault status, masked listing and exact-origin autofill require separate capabilities and human unlock. Preparing a controlled login POST requires its separate capability, approved form policy and human unlock; preparation does not submit the form. Agents cannot unlock the vault or export passwords. All granted capabilities remain restricted by the central baseline, destination rules and DLP.';scopeBox.append(scopeHelp);const scopes=document.createElement('div');scopes.className='checks';scopeBox.append(scopes);box.append(scopeBox); + const scopeBox=document.createElement('fieldset');const scopeLegend=document.createElement('legend');scopeLegend.textContent='Additional capability limit';scopeBox.append(scopeLegend);const scopeHelp=document.createElement('p');scopeHelp.className='muted';scopeHelp.textContent='Session saving, listing and restoration require separate capabilities. Saved URLs and tab group metadata never include passwords, form values or page state. Restoration rechecks current policy and does not restore revoked agent authority. History reads and navigation require separate capabilities. Window controls are limited to supported browser operations. Vault status, masked listing and exact-origin autofill require separate capabilities and human unlock. Preparing a controlled login POST requires its separate capability, approved form policy and human unlock; preparation does not submit the form. Agents cannot unlock the vault or export passwords. Visual export requires its separate screenshot capability in the central baseline and this grant, and is limited to an inspected foreground webpage viewport, never the desktop or browser interface. All granted capabilities remain restricted by the central baseline, destination rules and DLP.';scopeBox.append(scopeHelp);const scopes=document.createElement('div');scopes.className='checks';scopeBox.append(scopes);box.append(scopeBox); for(const scope of agentScopes){const label=document.createElement('label');const check=document.createElement('input');check.type='checkbox';check.checked=grant.scopes.includes(scope);check.disabled=!agentGrantsEditable;label.append(check,document.createTextNode(scope));scopes.append(label);check.addEventListener('change',()=>{grant.scopes=agentScopes.filter(s=>s===scope?check.checked:grant.scopes.includes(s));changedAgentGrants();});} const actions=document.createElement('div');actions.className='row';const generation=document.createElement('span');generation.textContent=`Credential generation: ${grant.generation}`;const rotate=document.createElement('button');rotate.type='button';rotate.className='secondary';rotate.textContent='Invalidate previous credentials';rotate.disabled=!agentGrantsEditable||grant.generation>=Number.MAX_SAFE_INTEGER;rotate.addEventListener('click',()=>{grant.generation+=1;renderAgentGrants();changedAgentGrants();agentGrantStatus('Credential generation changed in draft. Publish after review, then pair this identity again locally.');});const revoke=document.createElement('button');revoke.type='button';revoke.className='danger';revoke.textContent='Revoke grant';revoke.disabled=!agentGrantsEditable||!grant.enabled;revoke.addEventListener('click',()=>{grant.enabled=false;renderAgentGrants();changedAgentGrants();agentGrantStatus('Grant disabled in draft. Publish after review to apply revocation.');});actions.append(generation,rotate,revoke); if(!agentGrantsPublishedIds.has(grant.id)){const remove=document.createElement('button');remove.type='button';remove.className='secondary';remove.textContent='Remove unpublished grant';remove.disabled=!agentGrantsEditable;remove.addEventListener('click',()=>{agentGrantsDraft=agentGrantsDraft.filter(g=>g!==grant);renderAgentGrants();changedAgentGrants();});actions.append(remove);} diff --git a/apps/server/public/index.html b/apps/server/public/index.html index 475cf79..3258333 100644 --- a/apps/server/public/index.html +++ b/apps/server/public/index.html @@ -12,7 +12,7 @@

IDENTITY AND FEATURE ACCESS

User classes and website access

Create profiles for any user class with your own names and controls. Employee, developer and high-protection templates are optional starting points. Assign profiles to verified enterprise subjects or explicit managed devices. Asset-owner labels are not authenticated identities. Website denials and category restrictions accumulate across matching profiles; an allowlist does not bypass a denial.

ProfileAssignmentsDeveloper toolsAction

Create profile

Explicit device assignments
Denied domain categories

Developer grants require central delegation; any applicable deny wins. No profile can override mandatory scanning or unlock centrally hidden settings.

Preview effective device policy

Central categories and developer delegation

Categories contain your approved domain lists. This is not an automatic external website reputation feed. Enabling delegation permits reviewed developer-profile grants; denying profiles still win.

Website categories

Use exact hosts or wildcard subdomains such as *.social.example. Changes stay in this local draft until explicit publication or independent approval.

Assignment visibility

DeviceVerified subjectEffective profiles

CONTROLLED DEPLOYMENT

Groups and pilot rollouts

Apply additional restrictions to explicit device groups. Start with selected pilot devices, inspect their reported policy versions, then explicitly expand the cohort. Group rules can tighten the baseline; they cannot weaken it. No automatic promotion occurs.

GroupMembersStageActions

Create a device group

Group members
Rollout cohort

Pilot and expanded devices must belong to the group. All means all group members, not the entire fleet.

Additional restrictions
Settings section restrictions

Membership, restrictions and cohort changes are signed policy changes. Mandatory independent approval applies to these changes too.

Additional company bookmarks

These managed bookmarks are added for the selected rollout cohort. They do not replace baseline company bookmarks.

TitleURLFolderAction

Effective policy preview

Reported rollout state

DeviceEffective groupsReported / target versionVersion acknowledgmentLast contact

Acknowledgment compares the device-reported version with its target. It is not security attestation or proof that every control is functioning. Last contact is shown separately.

INDEPENDENT CHANGE CONTROL

Policy approval queue

Loading approval requirements.

Prepared changes remain inactive until approved. A different administrator must review the exact payload and base version. Stale changes must be prepared again against the latest policy.

PreparedAuthorChangeBase versionStatusReview

CHANGE CONTROL

Policy history and recovery

Review persisted signed versions and compare changes. Restoring reapplies the selected security rules, permissions, bookmarks and network settings, group memberships and rollout cohorts. The original version stays preserved; restoration publishes a new version with a fresh expiry. Devices never receive a lower policy version.

VersionPublishedActorReview

Managed browser settings

Hide settings or deny changes centrally. These permissions are enforced in the native browser process as well as the interface. Hiding a section denies its user actions; publishing a new signed policy applies changes to connected devices at refresh.

SectionHide and deny accessLock user actions
Appearance
Bookmarks and tab groups
AI workspace
Password manager
Manual document scan
Local agent controls

Appearance values become mandatory when appearance is hidden, locked, or settings access is disabled. Otherwise users may choose local preferences. Vault locking remains available to keep credentials safe. Mandatory security inspection continues independently of settings visibility. Publish signed policy above to apply.

COMPANY LIBRARY

Managed bookmarks

Publish intranet and approved service links to every browser. Company bookmarks are read-only for employees. Personal bookmarks remain separate; configure whether employees may add, edit or delete them.

AI library organization

Suggestions require the approved AI gateway and Library permissions. Agents also require the organization scopes below. Company bookmarks remain protected. Keep automatic application disabled to require explicit confirmation before changes.

TitleHTTPS / HTTP URL (no query or fragment)FolderActions

Company changes are drafts until you publish the signed policy. Personal permissions also respect the centrally locked or hidden Library section.

Device personal bookmarks

Authorized administrators can review and remotely edit a device's synchronized personal bookmarks. Changes are applied when the browser next refreshes management state. Titles and URLs are never included in security audit events.

TEACH YOUR BROWSER

Define what must stay inside

Add phrases that must never leave, or register an exact file fingerprint. Reference files stay on your computer; only their SHA-256 fingerprint enters policy.

Local semantic reference protection

Describe a confidential category. Your approved local EmbeddingGemma 2 runtime creates pinned text reference vectors. Document extraction and local OCR text can be compared against these references. This workflow does not train a model or provide native video inspection.

Only normalized reference vectors and coverage features enter the policy draft. No document, extracted text or model fine tuning is retained. Complete extraction and chunk coverage are required; publish explicitly to approve.

Requires locally provisioned model weights, an exact model revision, matching dimensions and the document-v1 profile. Similarity is a detector score, not a confidence guarantee. Incomplete extraction or chunk coverage always blocks.

-

Enterprise security and local agents

Configure centrally signed DNS blocklists, YARA source rules, content phrases, restricted Sigma metadata detections and agent permissions. Unsupported Sigma syntax is rejected.

Agent capabilities

History read and history navigation are separate capabilities. History navigation remains subject to destination rules and DLP. Window state exposes bounded browser state; window control is limited to supported browser window operations and never grants OS or administrator access. Enable only the capabilities each agent requires.

Saving, listing and restoring sessions require separate capabilities. Saved sessions contain approved URLs and tab group metadata only: no passwords, form values or page state. Restoration rechecks current policy, destination permissions and DLP; it never restores revoked agent authority.

Vault status, masked listing and autofill are separate capabilities. A person must unlock the local vault; agents cannot unlock it or export passwords. Listing and filling are restricted to the current page and the credential's exact origin. Autofill also requires the central vault and autofill permissions. Grant only the capabilities an agent needs.

Individual agent identities

Individual mode requires an enrolled local identity matching an enabled, unexpired grant. Grant scopes and destinations further restrict the central baseline. Credentials stay on the device; no private keys or tokens are stored here. Revocation and generation changes take effect after the new signed policy reaches the browser.

Changes remain in this draft. Publish with the usual policy review. Disabling a grant revokes it; advancing its generation invalidates previously paired credentials and requires local pairing again. Empty capabilities permit no actions.

Advanced source configuration: YARA, Sigma and visual references

Structured controls above are authoritative for DNS, content and agent permissions. Preserve advanced rule sources when making other changes.

Agents require paired keys and authenticated post-quantum sessions; capabilities are further restricted by each token, destination rules and DLP. Disable paired local agents and publish to revoke control. Agent permissions never grant administrator privileges or unlock centrally hidden settings.

Publish signed policy above to apply. Downloads require configured local YARA or external ICAP. Every enabled scanner is mandatory; unavailable inspection blocks downloads. Scan limit is 20 MB. Sigma supports exact metadata selection only.

+

Enterprise security and local agents

Configure centrally signed DNS blocklists, YARA source rules, content phrases, restricted Sigma metadata detections and agent permissions. Unsupported Sigma syntax is rejected.

Agent capabilities

History read and history navigation are separate capabilities. History navigation remains subject to destination rules and DLP. Window state exposes bounded browser state; window control is limited to supported browser window operations and never grants OS or administrator access. Enable only the capabilities each agent requires.

Visual export is a separate opt-in capability for the active managed webpage viewport only. It does not authorize desktop, other windows or browser-interface capture. Required content inspection must succeed before pixels are returned. Grant it in both the central baseline and the individual identity; remove the scope or disable agents and publish to revoke access. Sensitive-read permission does not bypass visual inspection. Availability is limited to the implemented and tested export path.

Saving, listing and restoring sessions require separate capabilities. Saved sessions contain approved URLs and tab group metadata only: no passwords, form values or page state. Restoration rechecks current policy, destination permissions and DLP; it never restores revoked agent authority.

Vault status, masked listing and autofill are separate capabilities. A person must unlock the local vault; agents cannot unlock it or export passwords. Listing and filling are restricted to the current page and the credential's exact origin. Autofill also requires the central vault and autofill permissions. Grant only the capabilities an agent needs.

Individual agent identities

Individual mode requires an enrolled local identity matching an enabled, unexpired grant. Grant scopes and destinations further restrict the central baseline. Credentials stay on the device; no private keys or tokens are stored here. Revocation and generation changes take effect after the new signed policy reaches the browser.

Changes remain in this draft. Publish with the usual policy review. Disabling a grant revokes it; advancing its generation invalidates previously paired credentials and requires local pairing again. Empty capabilities permit no actions.

Advanced source configuration: YARA, Sigma and visual references

Structured controls above are authoritative for DNS, content and agent permissions. Preserve advanced rule sources when making other changes.

Agents require paired keys and authenticated post-quantum sessions; capabilities are further restricted by each token, destination rules and DLP. Disable paired local agents and publish to revoke control. Agent permissions never grant administrator privileges or unlock centrally hidden settings.

Publish signed policy above to apply. Downloads require configured local YARA or external ICAP. Every enabled scanner is mandatory; unavailable inspection blocks downloads. Scan limit is 20 MB. Sigma supports exact metadata selection only.

Governed document teaching

Submit a reference document to the private local inspector, then explicitly approve its fingerprint for publication. For incomplete extraction, explicitly approve available fingerprints with partial coverage; unextracted media is not fingerprinted. Only hashes, coverage counts and the reference label are retained. Full documents, extracted text and base64 are discarded after local processing and are never persisted by management.

Local password vault

Credentials stay on the browser device. Management stores permissions and lifecycle audit events only; passwords, usernames and credential identifiers are not uploaded.

Controlled credential submission

Disabled by default. A person must unlock the local vault. Ordinary autofill does not authorize submission. This permits a short-lived, one-use allowance for exact credential fields at an approved same-origin HTTPS POST target; every other field remains subject to DLP. Preparation fills the form but never submits it or exports passwords. This is not proof of website identity or a guarantee against phishing. Agent preparation additionally requires its separate capability. Changes require signed policy publication and independent review when configured.

Use exact canonical HTTPS action URLs without query strings, fragments or URL credentials. Rule identifiers and target/field signatures must be unique; username and password field names must differ and use printable ASCII (maximum 128 characters). Never enter usernames, passwords, tokens or other credential values here.

Publish signed policy to apply these controls.

Visual impersonation protection

For example, register a trusted Google login screenshot and allow only https://accounts.google.com. A matching login on another origin can then be blocked even when its domain is absent from every blocklist.

Upload your own trusted PNG of a brand login page or cropped login form. Only a derived perceptual hash, dimensions and allowed origins enter the policy draft. Reference pixels are discarded; no automatic Google or other brand profile is supplied.

Add trusted references, enable the control above and publish the signed policy. Use multiple references for different themes, languages and login steps. False positives, viewport changes and visual evasion require validation. The browser checks rendered pixels; this is not pre-render response inspection.

Managed clipboard and ClickFix protection

Control supported browser copy, cut and paste actions centrally. High-risk shell command patterns are blocked without sending clipboard text to an AI provider. Clipboard text and credentials never enter security logs.

Publish signed policy to apply. Mandatory DLP remains active when supplemental clipboard controls are disabled. Unsupported formats and unavailable required inspection fail closed. Pattern detection covers selected execution chains; it does not prove text or commands safe.

DEVICE FLEET

Enrollment and access

Enable or disable supplemental manual document analysis per browser. Mandatory upload DLP and download YARA continue regardless of this setting.

DevicePlatformIdentityPolicyLast seenAccess

ADMINISTRATOR ACCESS

Active management sessions

Administrators can review current SSO sessions and immediately revoke a session. Identity-provider group membership controls whether that person can sign in again. Bootstrap access has no stored browser session.

Session inventory requires the administrator role.

IdentityRoleExpiresAction

SECURITY OPERATIONS

Incident triage

Review security event metadata, assign an investigation and record its outcome. Protected evidence is shown separately only with explicit, audited access. Keep investigation notes free of secrets and personal data.

CreatedSecurity eventStatusSeverityAssigneeReview

AUDIT LIFECYCLE

Database event retention

Loading deployment configuration.

Retention applies only to exported database events that are not referenced by incidents. Investigation history is retained. External SIEM, file exports and backups have separate retention policies. Deployment configuration controls this setting.

SECURITY MONITORING

Recent audit events

TimeEventDeviceMetadata

This view shows the latest 100 events. Prompts and document contents are excluded. Exports contain up to 1,000 events; cursor-based APIs and durable HTTPS delivery support continuous SIEM ingestion.

COMPANY EXPERIENCE

Company logo and start page

Roll out your company identity through signed policy. Name and accent above apply alongside this logo.

Logo pixels are re-encoded locally; SVG is not accepted. News is retrieved by management only from deployment-approved feed hosts. Ask your server operator to allow the exact host. Feed URLs cannot contain credentials or fragments. Use server-configured authentication; do not put secrets in URLs. Publish and review these changes with your normal policy workflow.

Download inspection

Document DLP is required by default. Disabling it permits incoming documents after every enabled malware scanner approves them; upload protection remains enforced. Without any antivirus scanner, complete document DLP is still mandatory. Office macros are not executed. Unreadable or unsupported macro extraction blocks when local macro scanning is enabled. ICAP remains mandatory whenever enabled.

PROTECTED SECURITY EVIDENCE

Protected evidence collection

Opt in through centrally signed policy. All collection controls are off by default. Ordinary security audit records and OpenTelemetry traces remain separate.

Download evidence contains the source URL without its query or fragment, filename, SHA-256, byte count, MIME type and verdict. It does not contain the file body or the OS save path. Passwords and recognizable authentication credentials are excluded. A deployment-protected evidence key is required before enabling recording.

Disabling recording prevents new device evidence writes and removes pending native evidence payloads; ordinary security events remain. Existing encrypted evidence retains its access and retention controls. Interaction capture records the website origin, session, action and bounded target metadata. Guarded clipboard and agent effects are marked completed; trusted foreground page clicks and activated form events are marked observed, which does not prove network transmission. Sensitive forms, page paths, values, queries and fragments are excluded. It excludes clipboard contents, entered values and control labels. Collection is bounded and limited to the active main-frame page; it is not a complete replay. Publish and review this draft through the normal policy workflow.

OBSERVABILITY

Browser OpenTelemetry

Publish centrally signed operational or detailed debug collection. Spans report code stages, timing and outcomes. Confidential security evidence needs its separate protected storage; passwords and authentication tokens are excluded.

HTTPS is required except for a numeric loopback collector. Queue and export limits remain enforced. Browser logs and traces use OTLP. Durable security events are separately delivered by the management service in ECS or CEF; its HTTPS destination, TLS certificates and credentials are configured by the server operator, not exposed in browser policy. Backend service traces use the server OpenTelemetry configuration. Endpoint credentials do not belong in policy URLs. Disable and publish to stop new collection.

Review policy changes

Review the changed policy paths before publication. Existing server validation and version checks remain authoritative.

diff --git a/docs/agent-visual-export.md b/docs/agent-visual-export.md new file mode 100644 index 0000000..63916ba --- /dev/null +++ b/docs/agent-visual-export.md @@ -0,0 +1,101 @@ +# Inspected page viewport export for local agents + +Administrators separately enable the `screenshot` action scope in the signed +central baseline and individual grant. The capability starts unchecked; new +grants start disabled with no scopes. Destination restrictions, expiry, +generation rotation and targeted revocation still apply. Remove the scope or +disable agents and publish to revoke access once the policy reaches the browser. +Policy changes must follow the configured independent review workflow; auditor +controls are read-only. + +The action accepts only an optional native `sessionId`. Omission captures the +foreground tab once; explicitly selecting a background tab is refused. Its +internal transport scope is `browser:screenshot`. A successful protected reply +has exactly these fields: + +```json +{"mime":"image/png","width":1280,"height":720,"data":""} +``` + +This exports the current foreground managed HTTPS webpage viewport. There is no +clip rectangle, resizing, full-page scroll capture, offscreen target, desktop, +other-window or browser-chrome capture API. PNG bytes are bounded to 2 MiB, +dimensions to 4096 each, and pixels to 8 Mi. The example describes the reply shape, +not a real image. Agents receive sensitive image data and must handle it as such; +this API does not authorize onward cloud transfer. + +Before and after capture/inspection, privileged code checks the current native +context, document/view revision, actor/generation, policy, destination, +foreground identity and cancellation. Returning to the same URL or tab does not +restore a changed context. A fixed isolated-world probe rejects credential, +card and authentication-bearing controls, including hidden/offscreen controls. +Open shadow trees are recursively observed; queued mutation records are drained +before comparison, and newly discovered roots advance the revision. Native closed/declarative shadow roots and child +frames are initially refused; their presence is a compatibility limitation, not +an implicit permission to capture their pixels. + +Local image inspection with complete OCR coverage is mandatory. Enabled central +YARA and semantic checks, plus upload ICAP when configured, remain required. +The exact bounded PNG is inspected before return; byte binding and repeated +context probes prevent reuse of a changed buffer or inspected context. +An opaque private release guard, absent from the API projection, rechecks the +original policy, native frame/process/routing identity, loading state, context +token, actor and foreground activation after final authorization/encryption and +immediately before the protected reply is sent. Legacy callers receive the same +original-context check. + +`allowSensitiveRead` does not override visual inspection. Unavailable required +inspection, incomplete coverage, sensitive content or changed authority denies +the export. Disabling supplemental manual document scanning does not grant this +capability or waive required inspection. + +OCR/classifiers can miss rendered secrets, tiny text, image-based secrets or +steganography and can produce false positives. Complete reported OCR coverage is +not proof of absence of confidential information. Shadow/frame refusals do not +establish a universal browser side-channel guarantee. No claim is made for arbitrary +animated pages, accessibility surfaces, OS overlays or independent security review. + +Security audit records fixed action/scope/outcome/session/principal metadata. +Only required image chunks and a supported standard sRGB marker are accepted; +arbitrary ancillary text/EXIF/profile metadata are refused. The successful reply +is encrypted on the wire, but the trusted helper decrypts it for its caller. Byte +buffers are wiped best effort; immutable base64 strings and caller copies cannot +be guaranteed erased. + +PNG/base64, OCR text, page URLs and extracted secrets must not enter ordinary +audit or diagnostic fields. Named debug stages cover probe, capture, local and +enterprise inspection and audit. Diagnostics are separately centrally disableable; +a failed export is not permission to retry a protected operation automatically. + +## Verification status + +The central unchecked capability and individual-grant/read-only controls have +focused UI tests. The actual Windows/Electron admin fixture proves separate +author/reviewer publication, self-approval denial, auditor read-only controls and +reviewed removal from both baseline and grant at version 5. All 200 completed API +requests exported parent-correlated backend spans; no requests failed. Evidence: +`artifacts/agent-grants-history-admin-proof.json` and +`artifacts/agent-grants-history-admin-traces.json`. + +The first expanded fixture runs exceeded the shared IP rate bucket because their +short synthetic cookie values did not meet the real per-session bucket length +criteria. The fixture now uses realistic-length synthetic capabilities; production +rate limits and deadlines were not weakened. Those failed diagnostics are retained. + +The existing actual Windows/Electron proof exercised public viewport export, +mandatory OCR even with supplemental document scanning disabled, confidential +canvas rejection, mandatory PNG antivirus rejection, offscreen sensitive controls, +closed shadows, public open shadows, child frames, foreground/policy races and +central revocation (`artifacts/agent-visual-export-native-proof.json`). +The latest native run also rejects a policy change after classification despite +remaining screenshot/grant/host permissions, a foreground change after helper +return and an open-shadow mutation/revert. Its final release remains a child of +the outer request. Trace `aad45c8a85f25619a233b78e132d7bad` contains all eight +visual stages; final release took 0.2507 ms. ICAP recorded 26 scans, five PNG scans +and one expected block; the protected collector rejected no writes. + +695 tests and syntax checks pass (`artifacts/agent-visual-final-suite.log`, +`artifacts/agent-visual-final-syntax.log`). The final stable Windows build and exact packaged-source/runtime verification pass +(`artifacts/agent-visual-release-windows-build.log`, +`artifacts/agent-visual-final-package-proof.log`). The executable remains `NotSigned`. These narrow proofs do not establish universal secret detection +or independent security certification. diff --git a/docs/development-observability.md b/docs/development-observability.md index 362fa04..14af20c 100644 --- a/docs/development-observability.md +++ b/docs/development-observability.md @@ -193,3 +193,32 @@ fragments rather than relying on all-values concatenation. Current trace collector rejected writes remain zero. Evidence: `artifacts/controlled-login-native-proof.json` and `artifacts/controlled-login-receiver-proof-native.log`. Earlier native failures and the prior successful proof remain distinct local evidence. + + +The screenshot increment used actual traces to separate capture, structural PNG +validation, OCR, enterprise inspection, audit, projection and final release. +The first native run (`2a2507264480c9ed8fdb585a5608d292`) spent 6953.918 ms in +local inspection, with OCR 6469.757 ms, capture 358.558 ms and AV 5.039 ms. +These local cold-run observations identify OCR as the largest measured child; +they do not prove the cause of all remaining IPC/containment overhead. + +Independent review found unobserved open-shadow mutations and a late policy/tab +release gap after the helper returned. Shadow roots now have private observers +and pending-record draining; final encrypted reply requires a private actor-bound +policy/native-frame release guard checked synchronously beside reply. Latest +native trace `aad45c8a85f25619a233b78e132d7bad` verifies eight stages. The final +`visual.release` takes 0.2507 ms under the outer agent request; the seven helper +stages retain the inner screenshot action ancestry. Real tests reclassify the +page while retaining the screenshot grants, switch the foreground after helper +return and mutate/revert open-shadow content; all suppress image delivery. + +The native fixture also proves real contained OCR of a confidential canvas, +mandatory ICAP PNG blocking and secret exclusion from ordinary ECS/OTLP output. +Five PNG AV scans include one expected block; collector rejected writes are zero. +Real OTLP regression tests prove responsible-stage ERROR status, redaction and +central disable. No pixels, encoded images, raw DOM attributes or extracted OCR +text are diagnostic attributes. The local receiver is protected and bounded, +not a production forensic archive. The admin fixture's earlier 429 failure came +from short synthetic cookies sharing the IP bucket; realistic-length synthetic +capabilities resolved it without changing production limits. Its subsequent +200 completed API requests all have correlated traces. diff --git a/docs/requirements-audit.md b/docs/requirements-audit.md index e2a91cf..9764882 100644 --- a/docs/requirements-audit.md +++ b/docs/requirements-audit.md @@ -3,8 +3,8 @@ ## Aktuelle nächste Abnahmen Gespeicherte Agent-Sitzungen und kontrollierte Credential-Vorbereitung sind für -ihre dokumentierten nativen Pfade integriert und nachgewiesen. Der aktuelle -Abgleich umfasst 668 bestandene Tests, Syntaxprüfung, stabilen Windows-Build, +ihre dokumentierten nativen Pfade integriert und nachgewiesen. Die vorangegangene +Controlled-Login-Abnahme umfasste 668 bestandene Tests, Syntaxprüfung, stabilen Windows-Build, exakten Paket-/Runtime-Vergleich und den abschließenden Receiver-/Trace-Proof (`artifacts/controlled-login-final-suite.log`, `artifacts/controlled-login-final-syntax.log`, @@ -12,8 +12,14 @@ exakten Paket-/Runtime-Vergleich und den abschließenden Receiver-/Trace-Proof `artifacts/controlled-login-final-package-proof.log`, `artifacts/controlled-login-native-proof.json`). Das Executable ist unsigniert. -Nächste Implementierungsabnahmen: sicher geprüfter visueller Agent-Export, -beweisbare Actor-Ownership für Hintergrund-Worker, breitere lokale Medienprüfung, +Der aktuelle begrenzte Visual-Export-Increment besteht mit 695 Tests, Syntax und +dem abschließenden nativen Late-Policy-/Foreground-/Shadow-Proof. Der aktuelle +Windows-Build und der exakte Paket-/Runtime-Vergleich bestehen ebenfalls. Nachweise: +`artifacts/agent-visual-final-suite.log`, `artifacts/agent-visual-final-syntax.log`, +`artifacts/agent-visual-export-native-proof.json`, +`artifacts/agent-visual-release-windows-build.log`, `artifacts/agent-visual-final-package-proof.log`. + +Nächste Implementierungsabnahmen nach dieser Prüfung: beweisbare Actor-Ownership für Hintergrund-Worker, breitere lokale Medienprüfung, vollständige Analysten-Inhaltsbelege und weitergehende Datei-/Admin-Steuerung. Bereits vorhanden sind begrenzte geschützte Browser-Metadaten und eine zweckgebundene Analystenansicht; vollständige Aufzeichnung oder Replay bleiben @@ -89,7 +95,9 @@ Windows zuerst: einen zentral verwalteten Browser mit lokaler Inhaltsprüfung, f | KI als First Class, kontrolliertes Gateway | Teilweise | `apps/server/app.mjs`, `inspection.mjs`, Browser-AI-UI, `tests/enterprise-gateway.test.mjs`: freigegebene lokale Ollama/kompatible Provider, serverseitige Credentials, Prompt-/Outputprüfung. Kein vollständiges Berechtigungs-RAG, Modellinventar, Provider-Tenantnachweis oder umfassendes KI-Tool-Autorisierungsmodell. | -| Lokale Agenten steuern Sessions | Teilweise; geschützte unterstützte Aktionen geprüft | Individuelle zentral genehmigte Identitäten, Generation/Expiry, Zielhostbindung, DPAPI-Pairing, ML-KEM-1024/AES-GCM und gepinnte Ed25519/ML-DSA-87-Challenges, HMAC/Replay-Abwehr. Native Tab-UUIDs, Seiteninteraktion, Inspektion, Bookmarks, Appearance, gruppierte Tabs, geprüfte History-Navigation und begrenzte Fenstersteuerung sowie menschenentsperrtes Vault-Autofill, separat kontrollierte Login-Vorbereitung und verschlüsselte gespeicherte Sitzungen implementiert. `docs/individual-agent-grants.md`, `docs/agent-history-window.md`, `docs/agent-vault-controls.md`, `docs/agent-session-controls.md`, `docs/controlled-credential-policy.md`, jeweilige echte Native-Proofs. Sichere Screenshots, Datei-/Vault-Mutationen, umfassende Widgets und attribuierte Worker-Steuerung bleiben offen; keine freie JS/CDP- oder Credential-Export-API. | +| Lokale Agenten steuern Sessions | Teilweise; geschützte unterstützte Aktionen geprüft | Individuelle zentral genehmigte Identitäten, Generation/Expiry, Zielhostbindung, DPAPI-Pairing, ML-KEM-1024/AES-GCM und gepinnte Ed25519/ML-DSA-87-Challenges, HMAC/Replay-Abwehr. Native Tab-UUIDs, Seiteninteraktion, Inspektion, Bookmarks, Appearance, gruppierte Tabs, geprüfte History-Navigation und begrenzte Fenstersteuerung sowie menschenentsperrtes Vault-Autofill, separat kontrollierte Login-Vorbereitung und verschlüsselte gespeicherte Sitzungen implementiert. `docs/individual-agent-grants.md`, `docs/agent-history-window.md`, `docs/agent-vault-controls.md`, `docs/agent-session-controls.md`, `docs/controlled-credential-policy.md`, jeweilige echte Native-Proofs. Begrenzter geprüfter Foreground-PNG-Export ist implementiert (`apps/browser/agent-visual-export.cjs`, `agent-visual-probe.cjs`, `docs/agent-visual-export.md`); aktueller 695-Test-/Native-Proof bestanden; Build/Paket noch ausstehend. Vollständige visuelle Erfassung, Datei-/Vault-Mutationen, umfassende Widgets und attribuierte Worker-Steuerung bleiben offen; keine freie JS/CDP- oder Credential-Export-API. | +| Kontrollierter visueller Agent-Export | Eng begrenzt nativ geprüft; aktueller Build/Paket ausstehend | `apps/browser/agent-visual-export.cjs`, `agent-visual-probe.cjs`, `agent-control.cjs`, `docs/agent-visual-export.md`: optional native Session-ID, foreground HTTPS-Viewport, kanonischer PNG (2 MiB/4096/8 Mi Pixel), Pflicht-OCR und konfigurierte Scanner, kein Sensitive-Read-Override. Credential/Card/Auth-Controls auch verborgen/offscreen verweigert; Open-Shadow-Mutationen beobachtet/drained, neue Roots revisioniert; geschlossene Roots und alle Childframes blockiert. Privater Release-Guard prüft ursprüngliche Policy/Frame/Process/Routing/Loading/Token/Actor/Foreground vor finalem verschlüsseltem Reply, auch Legacy. Keine Desktop-/Chrome-/Resize-/Fullpage-API; PNG-Payload/Extraktionsdaten nicht in gewöhnlichen Logs, Buffer-Wipe nur best effort. Aktueller Late-Policy-/Foreground-/Open-Shadow-Race-Proof in `artifacts/agent-visual-export-native-proof.json`, 695 Tests und Syntax bestehen; Windows-Build/Paket noch zu verifizieren. | + | Verschlüsselte gespeicherte Sitzungen | Native Integration und finales Windows-Paket geprüft | `session-snapshot.cjs`, `agent-session-adapters.cjs`, `session-restore.cjs`, `docs/agent-session-controls.md`: benannte lokale URL/Titel/Gruppen-Sammlungen, Gerät/Principal/Generation-Bindung, DLP vor Save/List/Restore, CAS und einmalige Restore-Pläne. Echter Zwei-Prozess-Windows-Proof mit erhaltenen bestehenden Tabs; keine Formulare, Passwörter oder pageState. Kein Cross-Device-Sync oder vollständiges Session-Backup. | | Passwortmanager mit Post-Quantum-Kryptografie | Teilweise | `apps/browser/vault.cjs`, `password-fill.cjs`, `tests/vault.test.mjs`, `tests/password-fill.test.mjs`: ML-KEM-1024/AES-GCM/scrypt und Windows-DPAPI, Idle-/Blur-Lock, gebundene HTTPS-Formfüllung. Separat default-off kontrollierter nativer Login-POST in `apps/browser/credential-submission.cjs`, `tests/controlled-login-audit.test.mjs`, `artifacts/controlled-login-native-proof.json`; Vorbereitung sendet nicht. Gefüllte Passwörter sind für die freigegebene Webseite/deren JavaScript im DOM lesbar; keine Passwort-Isolation oder beliebige JS-Login-Unterstützung. Kein Sync/Recovery/Passkeys, keine unabhängige Kryptografieprüfung oder FIPS-Modulvalidierung. | @@ -800,3 +808,33 @@ Collector meldet null abgewiesene Schreibvorgänge. Diese kontrollierten Fixture beweisen ihre Szenarien; sie schließen weder Worker-Ownership noch allgemeine JavaScript-Logins, Passwort-Isolation, vollständige Exfiltrationsabdeckung oder die ursprünglichen Produktions-/Kundenabnahmen. Das Gesamtziel bleibt offen. + + +## Visual-Export-Increment: aktuelle native Abnahme und Paketprüfung + +Die tatsächliche Admin-UI prüfte `screenshot` unchecked, getrennte Baseline-/Grant- +Freigabe, Read-only-Auditor und geprüften Widerruf beider Scopes als v5. Alle 200 +abgeschlossenen API-Requests exportierten Parent-korrelierte Traces. Frühere +Fixture-Fehler durch kurze synthetische Sessioncookies im gemeinsamen IP-Bucket +bleiben dokumentiert; realistische Cookie-Längen korrigierten nur das Fixture, +keine Produktionslimits/Deadlines. + +Die neue begrenzte Pixel-API und der letzte native Release-/Policy-/Shadow-Proof +sind bestanden. 695 Tests und Syntaxprüfung bestehen. Trace +`aad45c8a85f25619a233b78e132d7bad` enthält alle acht benannten Visual-Stufen; +der endgültige Release liegt unter dem äußeren Request und dauert 0,2507 ms. +Policyänderung nach Klassifikation bei verbleibenden Screenshot-/Grant-/Hostrechten, +Foreground-Wechsel nach Helper-Rückkehr und Open-Shadow-Mutation mit Rückänderung +verweigern Pixel. ICAP: 26 Scans, fünf PNG-Scans, ein erwarteter Block. Collector: +42,7 MB aktiv, null abgewiesene Writes. Der stabile Windows-Build und exakte +Paket-/Runtime-Vergleich bestehen ebenfalls. OCR-Falschnegative, +Steganografie, Frame-/Closed-Shadow-Kompatibilität, vollständige Visual-Coverage, +Worker-Ownership und alle Produktions-/Kunden-Gates bleiben Grenzen. Aktuelle +Proof-Ergebnisse stammen aus terminal abgeschlossenen Läufen. + + +Der finale stabile Windows-Build und der exakte Paket-/Runtime-Vergleich des +aktuellen Visual-Export-Codes bestehen: `artifacts/agent-visual-release-windows-build.log` +und `artifacts/agent-visual-final-package-proof.log`. Syntax und alle 695 Tests +bestehen. Das Executable bleibt `NotSigned`; Installer-/Update-Signierung und +Produktionsabnahmen sind damit nicht abgeschlossen. diff --git a/packages/core/agent-audit.cjs b/packages/core/agent-audit.cjs index 4ebb235..8ec5ab4 100644 --- a/packages/core/agent-audit.cjs +++ b/packages/core/agent-audit.cjs @@ -1,7 +1,7 @@ 'use strict'; const {z}=require('zod'); const application=['vault:prepare-submit','sessions:save','sessions:list','sessions:restore','vault:status','vault:list','vault:fill','history:list','history:go','window:state','window:control','application:state','settings:get','settings:update','bookmarks:list','bookmarks:mutate','tabs:create','tabs:activate','tabs:close','tabs:group','library:organize-plan','library:organize-apply']; -const AGENT_SCOPES=Object.freeze({sessions:'sessions:list',navigate:'browser:navigate',back:'browser:navigate',forward:'browser:navigate',reload:'browser:navigate',read:'browser:read',inspect:'browser:read',scroll:'browser:interact',click:'browser:interact',type:'browser:interact',select:'browser:interact',check:'browser:interact',ai:'ai:request',...Object.fromEntries(application.map(action=>[action,action])),'agent-request':'agent:request'}); +const AGENT_SCOPES=Object.freeze({screenshot:'browser:screenshot',sessions:'sessions:list',navigate:'browser:navigate',back:'browser:navigate',forward:'browser:navigate',reload:'browser:navigate',read:'browser:read',inspect:'browser:read',scroll:'browser:interact',click:'browser:interact',type:'browser:interact',select:'browser:interact',check:'browser:interact',ai:'ai:request',...Object.fromEntries(application.map(action=>[action,action])),'agent-request':'agent:request'}); const AgentAuditSchema=z.object({action:z.enum(Object.keys(AGENT_SCOPES)),scope:z.enum([...new Set(Object.values(AGENT_SCOPES))]),outcome:z.enum(['authorized','completed','denied','failed']),sessionId:z.string().uuid().optional(),principalId:z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._:-]{0,79}$/).optional(),credentialGeneration:z.number().int().min(1).max(Number.MAX_SAFE_INTEGER).optional()}).strict().superRefine((value,ctx)=>{ if((value.principalId!==undefined)!==(value.credentialGeneration!==undefined))ctx.addIssue({code:'custom',message:'Agent principal attribution requires both identity and generation'}); if(AGENT_SCOPES[value.action]!==value.scope)ctx.addIssue({code:'custom',message:'Agent action scope mismatch'}); diff --git a/scripts/agent-grants-history-admin-smoke.cjs b/scripts/agent-grants-history-admin-smoke.cjs index 492b99c..173c9d9 100644 --- a/scripts/agent-grants-history-admin-smoke.cjs +++ b/scripts/agent-grants-history-admin-smoke.cjs @@ -1,21 +1,22 @@ 'use strict'; const {app,BrowserWindow,session}=require('electron'),assert=require('node:assert/strict'),fs=require('node:fs');app.disableHardwareAcceleration();app.enableSandbox();app.setPath('userData',process.env.SAFETYNET_ADMIN_SMOKE_PROFILE);let window;const deadline=setTimeout(()=>app.exit(1),55000);const evaluate=s=>window.webContents.executeJavaScript(s,true); -async function waitFor(s){for(let n=0;n<200;n++){if(await evaluate(s))return;await new Promise(r=>setTimeout(r,60));}throw Error('UI state timeout: '+s+' | '+await evaluate(`document.getElementById('status').textContent`));} -async function identity(value){await session.defaultSession.cookies.set({url:process.env.SAFETYNET_ADMIN_SMOKE_URL,name:'sn_session',value,httpOnly:true,sameSite:'strict'});await window.loadURL(process.env.SAFETYNET_ADMIN_SMOKE_URL);await waitFor(`!document.getElementById('workspace').hidden&&document.getElementById('reviews-status').textContent.includes('loaded')`);} +async function waitFor(s){for(let n=0;n<200;n++){if(await evaluate(s))return;await new Promise(r=>setTimeout(r,60));}const probe=await evaluate(`(async()=>{let error;try{await load()}catch(e){error={name:e.name,message:String(e.message).slice(0,300)}}return {workspaceHidden:document.getElementById('workspace').hidden,missingScopes:agentScopes.filter(scope=>!document.getElementById('agent-scope-'+scope)),error};})()`);fs.writeFileSync('artifacts/visual-admin-load-diagnostic.json',JSON.stringify(probe,null,2));throw Error('UI state timeout: '+s+' | '+await evaluate(`document.getElementById('status').textContent`));} +async function identity(value){await session.defaultSession.cookies.set({url:process.env.SAFETYNET_ADMIN_SMOKE_URL,name:'sn_session',value:value+'-synthetic-fixture-session-capability',httpOnly:true,sameSite:'strict'});await window.loadURL(process.env.SAFETYNET_ADMIN_SMOKE_URL);await waitFor(`!document.getElementById('workspace').hidden&&document.getElementById('reviews-status').textContent.includes('loaded')`);} async function selectReview(){await evaluate(`document.querySelector('#reviews-rows button').click()`);await waitFor(`!document.getElementById('independent-review-detail').hidden`);} async function approve(version){assert.equal(await evaluate(`document.getElementById('independent-review-approve').disabled`),true);await evaluate(`document.getElementById('independent-review-confirm').checked=true;document.getElementById('independent-review-confirm').dispatchEvent(new Event('change'));document.getElementById('independent-review-approve').click()`);await waitFor(`document.getElementById('policy-version').textContent==='v${version}'`);} async function capture(file,section){await evaluate(`document.getElementById('${section}').scrollIntoView();document.getElementById('console-theme').value='dark';document.getElementById('console-theme').dispatchEvent(new Event('change'))`);await new Promise(r=>setTimeout(r,250));fs.mkdirSync('artifacts',{recursive:true});fs.writeFileSync(file,(await window.webContents.capturePage()).toPNG());} app.whenReady().then(async()=>{ window=new BrowserWindow({show:false,width:1500,height:1100,webPreferences:{nodeIntegration:false,contextIsolation:true,sandbox:true,offscreen:true,backgroundThrottling:false}}); const proofTraceIds=[],requestsById=new Map();session.defaultSession.webRequest.onCompleted({urls:[process.env.SAFETYNET_ADMIN_SMOKE_URL+'/api/*']},details=>{const row=requestsById.get(details.id);if(row){row.outcome='completed';row.statusCode=details.statusCode;}});session.defaultSession.webRequest.onErrorOccurred({urls:[process.env.SAFETYNET_ADMIN_SMOKE_URL+'/api/*']},details=>{const row=requestsById.get(details.id);if(row)row.outcome='failed';});session.defaultSession.webRequest.onBeforeSendHeaders({urls:[process.env.SAFETYNET_ADMIN_SMOKE_URL+'/api/*']},(details,callback)=>{const crypto=require('node:crypto'),traceId=crypto.randomBytes(16).toString('hex'),spanId=crypto.randomBytes(8).toString('hex');const path=new URL(details.url).pathname,route=path==='/api/admin/policy'?'/api/admin/policy':path==='/api/admin/policy/restore'?'/api/admin/policy/restore':/^\/api\/admin\/policy-reviews\/[^/]+\/approve$/.test(path)?'/api/admin/policy-reviews/:id/approve':/^\/api\/admin\/policy-history\/[^/]+\/restore$/.test(path)?'/api/admin/policy-history/:id/restore':'other-api';const row={traceId,parentSpanId:spanId,outcome:'pending',method:details.method,route};proofTraceIds.push(row);requestsById.set(details.id,row);callback({requestHeaders:{...details.requestHeaders,traceparent:'00-'+traceId+'-'+spanId+'-01'}});}); - await identity('grant-author');assert.equal(await evaluate('typeof require'),'undefined');for(const scope of ['history:list','history:go','window:state','window:control','vault:status','vault:list','vault:fill','sessions:save','sessions:list','sessions:restore','vault:prepare-submit'])assert.deepEqual(await evaluate(`(()=>{const field=document.getElementById('agent-scope-${scope}');return {present:!!field,checked:field.checked,disabled:field.disabled};})()`),{present:true,checked:false,disabled:false});assert.equal(await evaluate(`document.getElementById('vault-controlled-submit').checked`),false);await evaluate(`document.getElementById('agent-enabled').checked=true;document.getElementById('vault-controlled-submit').checked=true;document.getElementById('vault-submit-forms').value=JSON.stringify([{id:'company-login',actionUrl:'https://login.company.example/signin',usernameField:'username',passwordField:'password'}]);document.getElementById('agent-scope-sessions').checked=true;document.getElementById('agent-scope-read').checked=true;for(const scope of ['vault:status','vault:list','vault:fill','sessions:save','sessions:list','sessions:restore','vault:prepare-submit'])document.getElementById('agent-scope-'+scope).checked=true;document.getElementById('agent-hosts').value='*.company.example';document.getElementById('agent-identity-mode').value='individual';document.getElementById('agent-identity-mode').dispatchEvent(new Event('change'));document.getElementById('agent-grant-add').click();`); + await identity('grant-author');assert.equal(await evaluate('typeof require'),'undefined');for(const scope of ['history:list','history:go','window:state','window:control','vault:status','vault:list','vault:fill','sessions:save','sessions:list','sessions:restore','vault:prepare-submit','screenshot'])assert.deepEqual(await evaluate(`(()=>{const field=document.getElementById('agent-scope-${scope}');return {present:!!field,checked:field.checked,disabled:field.disabled};})()`),{present:true,checked:false,disabled:false});assert.equal(await evaluate(`document.getElementById('vault-controlled-submit').checked`),false);await evaluate(`document.getElementById('agent-enabled').checked=true;document.getElementById('vault-controlled-submit').checked=true;document.getElementById('vault-submit-forms').value=JSON.stringify([{id:'company-login',actionUrl:'https://login.company.example/signin',usernameField:'username',passwordField:'password'}]);document.getElementById('agent-scope-sessions').checked=true;document.getElementById('agent-scope-read').checked=true;for(const scope of ['vault:status','vault:list','vault:fill','sessions:save','sessions:list','sessions:restore','vault:prepare-submit','screenshot'])document.getElementById('agent-scope-'+scope).checked=true;document.getElementById('agent-hosts').value='*.company.example';document.getElementById('agent-identity-mode').value='individual';document.getElementById('agent-identity-mode').dispatchEvent(new Event('change'));document.getElementById('agent-grant-add').click();`); assert.equal(await evaluate(`Array.from(document.querySelectorAll('#agent-grants-editor .checks input')).every(x=>!x.checked)`),true);assert.equal(await evaluate(`document.getElementById('agent-vault-help').textContent.includes('A person must unlock')&&document.getElementById('agent-vault-help').textContent.includes('cannot unlock it or export passwords')`),true);assert.equal(await evaluate(`document.getElementById('agent-grants-editor').textContent.includes('human unlock')`),true);assert.equal(await evaluate(`document.getElementById('agent-sessions-help').textContent.includes('no passwords, form values or page state')&&document.getElementById('agent-sessions-help').textContent.includes('never restores revoked agent authority')`),true); - await evaluate(`const box=document.querySelector('#agent-grants-editor>fieldset'),fields=box.querySelectorAll('.grid input,.grid textarea');fields[0].value='research-agent';fields[0].dispatchEvent(new Event('input'));fields[1].value='Research Agent';fields[1].dispatchEvent(new Event('input'));fields[2].checked=true;fields[2].dispatchEvent(new Event('change'));fields[3].value='2099-12-31T23:59:59Z';fields[3].dispatchEvent(new Event('input'));fields[4].value='*.company.example';fields[4].dispatchEvent(new Event('input'));for(const label of box.querySelectorAll('.checks label'))if(['sessions','read','vault:status','vault:list','vault:fill','sessions:save','sessions:list','sessions:restore','vault:prepare-submit'].includes(label.textContent)){const input=label.querySelector('input');input.checked=true;input.dispatchEvent(new Event('change'));}document.getElementById('publish').click()`);await waitFor(`document.getElementById('status').textContent.includes('prepared for independent approval')`);assert.equal(await evaluate(`document.getElementById('policy-version').textContent`),'v1');await selectReview();assert.equal(await evaluate(`document.getElementById('independent-review-approve').disabled`),true); + await evaluate(`const box=document.querySelector('#agent-grants-editor>fieldset'),fields=box.querySelectorAll('.grid input,.grid textarea');fields[0].value='research-agent';fields[0].dispatchEvent(new Event('input'));fields[1].value='Research Agent';fields[1].dispatchEvent(new Event('input'));fields[2].checked=true;fields[2].dispatchEvent(new Event('change'));fields[3].value='2099-12-31T23:59:59Z';fields[3].dispatchEvent(new Event('input'));fields[4].value='*.company.example';fields[4].dispatchEvent(new Event('input'));for(const label of box.querySelectorAll('.checks label'))if(['sessions','read','vault:status','vault:list','vault:fill','sessions:save','sessions:list','sessions:restore','vault:prepare-submit','screenshot'].includes(label.textContent)){const input=label.querySelector('input');input.checked=true;input.dispatchEvent(new Event('change'));}document.getElementById('publish').click()`);await waitFor(`document.getElementById('status').textContent.includes('prepared for independent approval')`);assert.equal(await evaluate(`document.getElementById('policy-version').textContent`),'v1');await selectReview();assert.equal(await evaluate(`document.getElementById('independent-review-approve').disabled`),true); assert.equal(await evaluate(`fetch('/api/admin/policy-reviews/'+selectedIndependentReview.id+'/approve',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({payloadHash:selectedIndependentReview.payloadHash,expectedVersion:selectedIndependentReview.expectedVersion,approve:true})}).then(r=>r.status)`),403); - await identity('grant-reviewer');await selectReview();const diff=await evaluate(`document.getElementById('independent-review-diff').textContent`);for(const text of ['/agents/grants','research-agent','2099','company.example','sessions','read','generation','controlledSubmission','company-login','vault:status','vault:list','vault:fill','sessions:save','sessions:list','sessions:restore','vault:prepare-submit'])assert.ok(diff.includes(text),text);await capture('artifacts/agent-grants-review-admin.png','independent-review-detail');await approve(2); - await capture('artifacts/credential-submission-admin-controls.png','password-vault');await identity('grant-auditor');for(const id of ['vault-controlled-submit','vault-submit-forms'])assert.equal(await evaluate(`document.getElementById('${id}').disabled`),true);for(const scope of ['vault:status','vault:list','vault:fill','sessions:save','sessions:list','sessions:restore','vault:prepare-submit'])assert.equal(await evaluate(`document.getElementById('agent-scope-${scope}').disabled`),true);assert.equal(await evaluate(`document.getElementById('agent-grant-add').disabled&&document.getElementById('agent-identity-mode').disabled`),true);assert.equal(await evaluate(`Array.from(document.querySelectorAll('#agent-grants-editor input, #agent-grants-editor textarea, #agent-grants-editor button')).every(x=>x.disabled)`),true); + await identity('grant-reviewer');await selectReview();const diff=await evaluate(`document.getElementById('independent-review-diff').textContent`);for(const text of ['/agents/grants','research-agent','2099','company.example','sessions','read','generation','controlledSubmission','company-login','vault:status','vault:list','vault:fill','sessions:save','sessions:list','sessions:restore','vault:prepare-submit','screenshot'])assert.ok(diff.includes(text),text);await capture('artifacts/agent-grants-review-admin.png','independent-review-detail');await approve(2); + await capture('artifacts/credential-submission-admin-controls.png','password-vault');await identity('grant-auditor');for(const id of ['vault-controlled-submit','vault-submit-forms'])assert.equal(await evaluate(`document.getElementById('${id}').disabled`),true);for(const scope of ['vault:status','vault:list','vault:fill','sessions:save','sessions:list','sessions:restore','vault:prepare-submit','screenshot'])assert.equal(await evaluate(`document.getElementById('agent-scope-${scope}').disabled`),true);assert.equal(await evaluate(`document.getElementById('agent-grant-add').disabled&&document.getElementById('agent-identity-mode').disabled`),true);assert.equal(await evaluate(`Array.from(document.querySelectorAll('#agent-grants-editor input, #agent-grants-editor textarea, #agent-grants-editor button')).every(x=>x.disabled)`),true); await identity('grant-author');assert.equal(await evaluate(`document.querySelector('#agent-grants-editor .grid input').readOnly`),true);await evaluate(`Array.from(document.querySelectorAll('#agent-grants-editor button')).find(x=>x.textContent==='Invalidate previous credentials').click();document.getElementById('publish').click()`);await waitFor(`document.getElementById('status').textContent.includes('prepared for independent approval')`);assert.equal(await evaluate(`document.getElementById('policy-version').textContent`),'v2');await identity('grant-reviewer');await selectReview();assert.ok((await evaluate(`document.getElementById('independent-review-diff').textContent`)).includes('"generation":2'));await approve(3); await identity('grant-author');await evaluate(`document.getElementById('brand-name').value='Unpublished preserved draft';document.getElementById('history-load').click()`);await waitFor(`document.querySelectorAll('#history-rows tr').length===3`);await evaluate(`document.querySelectorAll('#history-rows button')[1].click()`);await waitFor(`!document.getElementById('history-review').hidden`);assert.equal(await evaluate(`document.getElementById('brand-name').value`),'Unpublished preserved draft');assert.equal(await evaluate(`document.getElementById('policy-version').textContent`),'v3');assert.equal(await evaluate(`document.getElementById('history-restore').disabled`),true);const history=await evaluate(`document.getElementById('history-diff-list').textContent`);assert.ok(history.includes('/agents/grants'));assert.ok(history.includes('generation'));assert.ok((await evaluate(`document.getElementById('history-review-summary').textContent`)).includes('does not restore invalidated credentials'));await capture('artifacts/agent-grants-history-admin.png','history-review');await evaluate(`document.getElementById('history-approve').checked=true;document.getElementById('history-approve').dispatchEvent(new Event('change'));document.getElementById('history-restore').click()`);await waitFor(`document.getElementById('status').textContent.includes('prepared for independent approval')`);assert.equal(await evaluate(`document.getElementById('policy-version').textContent`),'v3');await selectReview();assert.equal(await evaluate(`document.getElementById('independent-review-approve').disabled`),true);await identity('grant-reviewer');await selectReview();assert.ok((await evaluate(`document.getElementById('independent-review-diff').textContent`)).includes('research-agent'));await approve(4); + await identity('grant-author');assert.equal(await evaluate(`document.getElementById('agent-scope-screenshot').checked`),true);assert.equal(await evaluate(`document.getElementById('agent-visual-export-help').textContent.includes('does not authorize desktop')`),true);await evaluate(`document.getElementById('agent-scope-screenshot').checked=false;document.getElementById('agent-scope-screenshot').dispatchEvent(new Event('input'));for(const label of document.querySelectorAll('#agent-grants-editor .checks label'))if(label.textContent==='screenshot'){label.querySelector('input').checked=false;label.querySelector('input').dispatchEvent(new Event('change'));}document.getElementById('publish').click()`);await waitFor(`document.getElementById('status').textContent.includes('prepared for independent approval')`);assert.equal(await evaluate(`document.getElementById('policy-version').textContent`),'v4');await identity('grant-reviewer');await selectReview();const revokeDiff=await evaluate(`document.getElementById('independent-review-diff').textContent`);assert.ok(revokeDiff.includes('screenshot'));await approve(5);assert.equal(await evaluate(`document.getElementById('agent-scope-screenshot').checked`),false);assert.equal(await evaluate(`Array.from(document.querySelectorAll('#agent-grants-editor .checks label')).filter(l=>l.textContent==='screenshot').every(l=>!l.querySelector('input').checked)`),true); for(let n=0;n<100&&proofTraceIds.some(row=>row.outcome==='pending');n++)await new Promise(r=>setTimeout(r,50));assert.equal(proofTraceIds.some(row=>row.outcome==='pending'),false,'API requests must settle before telemetry shutdown'); fs.writeFileSync('artifacts/agent-grants-admin-request-trace-ids.json',JSON.stringify(proofTraceIds));console.log('GRANT_ADMIN_OK');clearTimeout(deadline);window.destroy();app.exit(0); -}).catch(error=>{console.error('GRANT_ADMIN_FAILED',error.message);clearTimeout(deadline);window?.destroy();app.exit(1);}); +}).catch(error=>{console.error('GRANT_ADMIN_FAILED',error.message);try{fs.writeFileSync('artifacts/visual-admin-startup-diagnostics.json',JSON.stringify({message:error.message},null,2));}catch{}clearTimeout(deadline);window?.destroy();app.exit(1);}); diff --git a/scripts/agent-grants-history-admin-smoke.mjs b/scripts/agent-grants-history-admin-smoke.mjs index 9a64386..7004f4b 100644 --- a/scripts/agent-grants-history-admin-smoke.mjs +++ b/scripts/agent-grants-history-admin-smoke.mjs @@ -12,11 +12,12 @@ process.env.OTEL_EXPORTER_OTLP_ENDPOINT='http://127.0.0.1:4318'; const health=await fetch('http://127.0.0.1:4318/health').then(r=>r.json());assert.equal(health.rejectedWrites,0); const sdk=startTelemetry(),require=createRequire(import.meta.url),directory=mkdtempSync(join(tmpdir(),'safetynet-agent-grant-admin-'));let store,server,child; try { - store=openStore(join(directory,'management'));for(const [credential,role,sub] of [['grant-author','admin','grant-author'],['grant-reviewer','admin','grant-reviewer'],['grant-auditor','auditor','grant-auditor']])store.db.prepare('INSERT INTO sessions VALUES(?,?,?)').run(digest(credential),Date.now()+3600000,JSON.stringify({sub,role})); + store=openStore(join(directory,'management'));for(const [credential,role,sub] of [['grant-author','admin','grant-author'],['grant-reviewer','admin','grant-reviewer'],['grant-auditor','auditor','grant-auditor']])store.db.prepare('INSERT INTO sessions VALUES(?,?,?)').run(digest(credential+'-synthetic-fixture-session-capability'),Date.now()+3600000,JSON.stringify({sub,role})); const config={publicUrl:'http://127.0.0.1',secure:false,requireIndependentPolicyApproval:true};server=createApp(store,config).listen(0,'127.0.0.1');await new Promise(r=>server.once('listening',r));config.publicUrl=`http://127.0.0.1:${server.address().port}`; + const preflight=await fetch(config.publicUrl+'/api/admin/session',{headers:{cookie:'sn_session=grant-author-synthetic-fixture-session-capability'}});assert.equal(preflight.status,200,'Synthetic author session must authenticate before UI'); const env={...process.env,SAFETYNET_ADMIN_SMOKE_PROFILE:join(directory,'browser'),SAFETYNET_ADMIN_SMOKE_URL:config.publicUrl};delete env.ELECTRON_RUN_AS_NODE; child=spawn(require('electron'),['scripts/agent-grants-history-admin-smoke.cjs'],{env,windowsHide:true,stdio:['ignore','pipe','pipe']});child.stdout.on('data',d=>{if(String(d).includes('GRANT_ADMIN_OK'))console.log('Grant admin actual UI passed');});child.stderr.on('data',d=>{if(String(d).includes('GRANT_ADMIN_FAILED'))console.error(String(d).trim());});const timer=setTimeout(()=>child.kill(),60000),code=await new Promise(r=>{child.once('exit',c=>r(c??1));child.once('error',()=>r(1));});clearTimeout(timer);assert.equal(code,0); - const p=verifyPolicy(store.envelope(),store.publicKey());assert.equal(p.version,4);assert.equal(p.agents.grants[0].generation,1);assert.equal(p.agents.identityMode,'individual');for(const scope of ['vault:status','vault:list','vault:fill','sessions:save','sessions:list','sessions:restore','vault:prepare-submit']){assert.ok(p.agents.scopes.includes(scope));assert.ok(p.agents.grants[0].scopes.includes(scope));} + const p=verifyPolicy(store.envelope(),store.publicKey());assert.equal(p.version,5);assert.equal(p.agents.grants[0].generation,1);assert.equal(p.agents.identityMode,'individual');assert.equal(p.agents.scopes.includes('screenshot'),false);assert.equal(p.agents.grants[0].scopes.includes('screenshot'),false);for(const scope of ['vault:status','vault:list','vault:fill','sessions:save','sessions:list','sessions:restore','vault:prepare-submit']){assert.ok(p.agents.scopes.includes(scope));assert.ok(p.agents.grants[0].scopes.includes(scope));} assert.equal(p.passwordManager.controlledSubmission.enabled,true);assert.equal(p.passwordManager.controlledSubmission.forms[0].actionUrl,'https://login.company.example/signin'); - const traces=JSON.parse(readFileSync('artifacts/agent-grants-admin-request-trace-ids.json','utf8'));await sdk.shutdown();await new Promise(r=>setTimeout(r,100));const exported=[],failedAttempts=traces.filter(item=>item.outcome==='failed');assert.ok(traces.some(item=>item.outcome==='completed'),'Completed API requests required');assert.equal(traces.some(item=>item.outcome!=='completed'&&['POST','PUT','PATCH','DELETE'].includes(item.method)),false,'Every policy mutation must complete and export a correlated span');for(const item of traces.filter(item=>item.outcome==='completed')){const r=await fetch(`http://127.0.0.1:4318/api/trace/${item.traceId}`);assert.equal(r.ok,true,'Completed API request trace must be available');if(r.ok){const trace=await r.json();assert.ok(trace.spans.some(s=>s.name==='server.request'&&s.parentSpanId===item.parentSpanId&&s.durationMs>=0));exported.push({traceId:item.traceId,parentSpanId:item.parentSpanId,trace});}}assert.ok(exported.length,'Actual request traces required');mkdirSync('artifacts',{recursive:true});writeFileSync('artifacts/agent-grants-history-admin-traces.json',JSON.stringify(exported,null,2));writeFileSync('artifacts/agent-grants-history-admin-proof.json',JSON.stringify({actualUi:true,vaultScopesDefaultOffAndReviewed:true,sessionScopesDefaultOffAndReviewed:true,controlledCredentialPolicyReviewed:true,prepareSubmitScopeDefaultOffAndReviewed:true,separateAuthorReviewer:true,selfApprovalDenied:true,auditorReadonly:true,exactGrantDiff:true,rotationReviewed:true,historyDraftPreserved:true,restoreReviewed:true,finalVersion:p.version,completedRequests:traces.filter(item=>item.outcome==='completed').length,failedAttempts:failedAttempts.map(item=>({traceId:item.traceId,outcome:item.outcome,method:item.method,route:item.route})),traceIds:exported.map(x=>x.traceId)},null,2));console.log('Grant publication/history and exported traces verified'); + const traces=JSON.parse(readFileSync('artifacts/agent-grants-admin-request-trace-ids.json','utf8'));await sdk.shutdown();await new Promise(r=>setTimeout(r,100));const exported=[],failedAttempts=traces.filter(item=>item.outcome==='failed');assert.ok(traces.some(item=>item.outcome==='completed'),'Completed API requests required');assert.equal(traces.some(item=>item.outcome!=='completed'&&['POST','PUT','PATCH','DELETE'].includes(item.method)),false,'Every policy mutation must complete and export a correlated span');for(const item of traces.filter(item=>item.outcome==='completed')){const r=await fetch(`http://127.0.0.1:4318/api/trace/${item.traceId}`);assert.equal(r.ok,true,'Completed API request trace must be available');if(r.ok){const trace=await r.json();assert.ok(trace.spans.some(s=>s.name==='server.request'&&s.parentSpanId===item.parentSpanId&&s.durationMs>=0));exported.push({traceId:item.traceId,parentSpanId:item.parentSpanId,trace});}}assert.ok(exported.length,'Actual request traces required');mkdirSync('artifacts',{recursive:true});writeFileSync('artifacts/agent-grants-history-admin-traces.json',JSON.stringify(exported,null,2));writeFileSync('artifacts/agent-grants-history-admin-proof.json',JSON.stringify({actualUi:true,vaultScopesDefaultOffAndReviewed:true,sessionScopesDefaultOffAndReviewed:true,controlledCredentialPolicyReviewed:true,prepareSubmitScopeDefaultOffAndReviewed:true,screenshotScopeDefaultOffAndReviewed:true,screenshotBaselineAndGrantRevocationReviewed:true,separateAuthorReviewer:true,selfApprovalDenied:true,auditorReadonly:true,exactGrantDiff:true,rotationReviewed:true,historyDraftPreserved:true,restoreReviewed:true,finalVersion:p.version,completedRequests:traces.filter(item=>item.outcome==='completed').length,failedAttempts:failedAttempts.map(item=>({traceId:item.traceId,outcome:item.outcome,method:item.method,route:item.route})),traceIds:exported.map(x=>x.traceId)},null,2));console.log('Grant publication/history and exported traces verified'); } finally {child?.kill();if(server)await new Promise(r=>{server.closeAllConnections?.();server.close(r);});store?.db.close();const target=resolve(directory);if(!target.startsWith(resolve(tmpdir())+sep)||!basename(target).startsWith('safetynet-agent-grant-admin-'))throw Error('Unsafe cleanup');rmSync(target,{recursive:true,force:true,maxRetries:10,retryDelay:100});} diff --git a/scripts/agent-visual-export-smoke.cjs b/scripts/agent-visual-export-smoke.cjs new file mode 100644 index 0000000..5e0ba20 --- /dev/null +++ b/scripts/agent-visual-export-smoke.cjs @@ -0,0 +1,22 @@ +'use strict'; +const {app,BrowserWindow,session}=require('electron'),fs=require('node:fs'),path=require('node:path'),assert=require('node:assert/strict'),{X509Certificate}=require('node:crypto'); +app.setPath('userData',process.env.SAFETYNET_SMOKE_USERDATA);app.disableHardwareAcceleration();const localWitnesses=[],inspection=require('../apps/browser/inspection.cjs'),startInspector=inspection.startInspector;inspection.startInspector=(...args)=>{const client=startInspector(...args),inspect=client.inspect.bind(client);client.inspect=async(bytes,policy)=>{const result=await inspect(bytes,policy);localWitnesses.push({png:Buffer.isBuffer(bytes)&&bytes.subarray(0,8).equals(Buffer.from([137,80,78,71,13,10,26,10])),decision:result.decision,contentType:result.contentType,complete:result.coverage?.complete===true,confidentialRule:result.ruleId==='visual-confidential-fixture'});return result;};return client;};let replyGate;const control=require('../apps/browser/agent-control.cjs'),startControl=control.startAgentControl;control.startAgentControl=options=>startControl({...options,audit:async event=>{await options.audit(event);if(event.action==='screenshot'&&event.outcome==='completed'&&replyGate){const gate=replyGate;replyGate=null;gate.enter();await gate.wait;}}});require('../apps/browser/main.cjs'); +const wait=ms=>new Promise(r=>setTimeout(r,ms)),base=process.env.SAFETYNET_SMOKE_SERVER,origin=process.env.SAFETYNET_SMOKE_ORIGIN,headers={authorization:'Bearer '+process.env.SAFETYNET_SMOKE_ADMIN,'content-type':'application/json'}; +async function until(fn){for(let i=0;i<250;i++){const value=await fn();if(value)return value;await wait(100);}throw Error('Native visual fixture timeout');} +app.whenReady().then(async()=>{const pages=[];let stage='startup';try{const win=await until(()=>{const w=BrowserWindow.getAllWindows()[0];return w&&!w.webContents.isLoading()&&w.webContents.getURL().endsWith('index.html')&&w;});const fingerprint=new X509Certificate(fs.readFileSync(path.join(__dirname,'../tests/fixtures/icap/cert.pem'))).fingerprint256;session.fromPartition('persist:safetynet-managed').setCertificateVerifyProc((req,callback)=>callback(req.hostname==='127.0.0.1'&&new X509Certificate(req.certificate.data).fingerprint256===fingerprint?0:-2));const ui=async(method,value)=>{const result=await win.webContents.executeJavaScript(`window.safetynet.${method}(${value===undefined?'':JSON.stringify(value)})`);assert.equal(result.ok,true,method);return result.data;},overview=async()=>(await fetch(base+'/api/admin/overview',{headers})).json();stage='enrollment';await ui('enroll',{server:base,code:process.env.SAFETYNET_SMOKE_CODE});const page=win.contentView.children.find(v=>v.webContents!==win.webContents).webContents;pages.push(page);await until(()=>!page.isLoading()&&page.getURL()===origin+'/public');const a=await ui('agentConnection','visual-a'),b=await ui('agentConnection','visual-b'),requestFile=path.join(app.getPath('userData'),'visual-request.json');async function call(connection,action,args={}){fs.writeFileSync(requestFile,JSON.stringify({action,args}));const child=require('node:child_process').spawn(process.execPath,['scripts/agent-client.cjs','--connection',connection.credentialPath,'--request',requestFile],{windowsHide:true,stdio:['ignore','pipe','pipe']});let output='';child.stdout.on('data',v=>output+=v);child.stderr.on('data',()=>{});const exit=await new Promise(r=>{child.once('exit',r);child.once('error',()=>r(1));});return {exit,result:exit===0?JSON.parse(output).result:undefined};}const sessionId=(await call(a,'sessions')).result[0].id; + const change=async modify=>{const p=(await overview()).policy;modify(p);p.version++;const proposed=await fetch(base+'/api/admin/policy',{method:'PUT',headers,body:JSON.stringify(p)});assert.equal(proposed.status,202);const review=await proposed.json();const approval=await fetch(base+`/api/admin/policy-reviews/${review.reviewId}/approve`,{method:'POST',headers:{cookie:'sn_session='+process.env.SAFETYNET_SMOKE_REVIEWER,origin:base,'content-type':'application/json'},body:JSON.stringify({payloadHash:review.payloadHash,expectedVersion:review.expectedVersion,approve:true})});assert.equal(approval.status,200);await until(async()=>{const state=await ui('state');return state.ready&&state.policyVersion===p.version;});}; + stage='default disabled';assert.equal((await call(a,'screenshot',{sessionId})).exit,1);stage='independently approved scope';await change(p=>{p.agents.scopes.push('screenshot');p.agents.grants.find(g=>g.id==='visual-a').scopes.push('screenshot');});await until(()=>!page.isLoading());win.focus();page.focus();await wait(150); + stage='public viewport approved';const captured=await call(a,'screenshot',{sessionId});assert.equal(captured.exit,0,'Public screenshot export refused');assert.deepEqual(Object.keys(captured.result).sort(),['data','height','mime','width']);assert.equal(captured.result.mime,'image/png');const png=Buffer.from(captured.result.data,'base64');assert.equal(png.toString('base64'),captured.result.data);assert.ok(png.subarray(0,8).equals(Buffer.from([137,80,78,71,13,10,26,10])));assert.equal(png.readUInt32BE(16),captured.result.width);assert.equal(png.readUInt32BE(20),captured.result.height);assert.ok(png.length<=2097152);fs.writeFileSync(path.resolve('artifacts/agent-visual-public-viewport.png'),png);png.fill(0);captured.result.data=''; + stage='other actor denied';assert.equal((await call(b,'screenshot',{sessionId})).exit,1);stage='reject arbitrary screenshot parameters';assert.equal((await call(a,'screenshot',{sessionId,clip:{x:0,y:0,width:20,height:20}})).exit,1); + const navigate=async route=>{await ui('navigate',origin+route);await until(()=>!page.isLoading()&&page.getURL()===origin+route);win.focus();page.focus();await wait(150);}; + stage='canvas confidential text OCR blocks';await navigate('/canvas');assert.equal(await page.executeJavaScript("document.body.innerText.includes('CLASSIFIED ORCHID DOCUMENT')"),false);assert.equal((await call(a,'screenshot',{sessionId})).exit,1);assert.ok(localWitnesses.some(w=>w.png&&w.contentType==='image'&&w.decision==='block'&&w.confidentialRule)); + stage='mandatory actual PNG AV blocks';await navigate('/av-block');assert.equal((await call(a,'screenshot',{sessionId})).exit,1);stage='offscreen credential control denied';await navigate('/sensitive');assert.equal((await call(a,'screenshot',{sessionId})).exit,1);stage='closed shadow credential control denied';await navigate('/closed-shadow');assert.equal((await call(a,'screenshot',{sessionId})).exit,1);stage='open public shadow allowed';await navigate('/open-shadow');const shadowExport=await call(a,'screenshot',{sessionId});assert.equal(shadowExport.exit,0);shadowExport.result.data='';stage='child frames fail closed';await navigate('/frames');assert.ok(page.mainFrame.frames.length>0);assert.equal((await call(a,'screenshot',{sessionId})).exit,1); + async function delayedCapture(effect){let entered,release;const started=new Promise(r=>entered=r),gate=new Promise(r=>release=r),api=page.debugger,original=api.sendCommand.bind(api);api.sendCommand=async(...args)=>{const result=await original(...args);if(args[0]==='Page.captureScreenshot'){entered();await gate;}return result;};try{const pending=call(a,'screenshot',{sessionId});await Promise.race([started,new Promise((_,reject)=>setTimeout(()=>reject(Error('Native screenshot did not reach capture boundary')),10000))]);await effect();release();assert.equal((await pending).exit,1);}finally{release();api.sendCommand=original;}} + stage='foreground race denies release';await navigate('/public');let otherId;await delayedCapture(async()=>{const state=await ui('createTab',{url:origin+'/public'});otherId=state.activeTabId;const other=win.contentView.children.find(v=>v.webContents!==win.webContents&&v.webContents!==page).webContents;if(other)pages.push(other);});await ui('activateTab',sessionId);await ui('closeTab',otherId);win.focus();page.focus(); + stage='open shadow mutation and revert denies release';await navigate('/open-shadow');await delayedCapture(()=>page.executeJavaScript("const p=document.getElementById('host').shadowRoot.querySelector('p'),old=p.textContent;p.textContent='CLASSIFIED ORCHID DOCUMENT';p.textContent=old")); + async function delayedReply(effect){let enter,release;const entered=new Promise(r=>enter=r),waitForRelease=new Promise(r=>release=r);replyGate={enter,wait:waitForRelease};try{const pending=call(a,'screenshot',{sessionId});await Promise.race([entered,new Promise((_,reject)=>setTimeout(()=>reject(Error('Native screenshot did not reach completed audit boundary')),30000))]);await effect();release();const result=await pending;assert.equal(result.exit,1);assert.equal(result.result,undefined);}finally{release();replyGate=null;}} + stage='late policy classification blocks inspected image';await navigate('/public');await delayedReply(async()=>{await change(p=>{p.dlp.rules.push({id:'late-visual-classification',label:'Synthetic late visual classification',kind:'literal',value:'Public approved visual page',action:'block'});});const p=(await overview()).policy;assert.ok(p.agents.scopes.includes('screenshot'));assert.ok(p.agents.grants.find(g=>g.id==='visual-a').enabled&&p.agents.grants.find(g=>g.id==='visual-a').scopes.includes('screenshot'));});await change(p=>p.dlp.rules=p.dlp.rules.filter(r=>r.id!=='late-visual-classification'));await navigate('/public'); + stage='late foreground change blocks inspected image';let lateOther;await delayedReply(async()=>{const state=await ui('createTab',{url:origin+'/public'});lateOther=state.activeTabId;const other=win.contentView.children.find(v=>v.webContents!==win.webContents&&v.webContents!==page).webContents;if(other)pages.push(other);});await ui('activateTab',sessionId);await ui('closeTab',lateOther);win.focus();page.focus(); + stage='policy race denies release';await delayedCapture(()=>change(p=>{p.agents.scopes=p.agents.scopes.filter(s=>s!=='screenshot');p.agents.grants.find(g=>g.id==='visual-a').scopes=['sessions'];}));assert.equal((await call(a,'screenshot',{sessionId})).exit,1);await change(p=>{p.agents.scopes.push('screenshot');p.agents.grants.find(g=>g.id==='visual-a').scopes.push('screenshot');});await until(()=>!page.isLoading());stage='target revoke denies release';await change(p=>{p.agents.grants.find(g=>g.id==='visual-a').enabled=false;});assert.equal((await call(a,'screenshot',{sessionId})).exit,1); + stage='actual observability';await wait(1500);const events=[];let after=0;for(let batch=0;batch<20;batch++){const response=await fetch(base+`/api/admin/events/export?after=${after}&limit=1000&format=ecs`,{headers});assert.equal(response.status,200);const rows=(await response.text()).trim().split('\n').filter(Boolean).map(line=>JSON.parse(line));for(const row of rows){assert.ok(!JSON.stringify(row).includes('CLASSIFIED ORCHID DOCUMENT'));assert.ok(!JSON.stringify(row).includes('synthetic-hidden-field'));assert.ok(!JSON.stringify(row).includes('synthetic-closed-shadow-password'));events.push(row);}const next=Number(response.headers.get('x-safetynet-next-cursor'));if(rows.length<1000)break;assert.ok(next>after);after=next;}const event=events.find(e=>e.safetynet?.agent?.action==='screenshot'&&e.safetynet.agent.outcome==='completed'&&e.trace?.id);assert.ok(event);const trace=await(await fetch('http://127.0.0.1:4318/api/trace/'+event.trace.id)).json();assert.ok(trace.spans.some(s=>s.name==='browser.agent'&&s.operation==='screenshot'));assert.ok(trace.spans.some(s=>s.name==='browser.inspect.local'));assert.ok(trace.spans.some(s=>s.name==='inspector.ocr'));for(const operation of ['visual.probe','visual.capture','visual.validate','visual.inspect.local','visual.inspect.enterprise','visual.audit','visual.project'])assert.ok(trace.spans.some(s=>s.name==='browser.visual'&&s.operation===operation),operation);const byId=new Map(trace.spans.map(s=>[s.spanId,s]));for(const operation of ['visual.probe','visual.capture','visual.validate','visual.inspect.local','visual.inspect.enterprise','visual.audit','visual.project']){let span=trace.spans.find(s=>s.name==='browser.visual'&&s.operation===operation),found=false;const seen=new Set();while(span&&!seen.has(span.spanId)){if(span.name==='browser.agent'&&span.operation==='screenshot'){found=true;break;}seen.add(span.spanId);span=byId.get(span.parentSpanId);}assert.ok(found,'Visual stage parent mismatch');}let released=trace.spans.find(s=>s.name==='browser.visual'&&s.operation==='visual.release'),releaseAncestor=false;const releaseSeen=new Set();while(released&&!releaseSeen.has(released.spanId)){if(released.name==='browser.agent'&&released.operation==='agent.request'){releaseAncestor=true;break;}releaseSeen.add(released.spanId);released=byId.get(released.parentSpanId);}assert.ok(releaseAncestor,'Final visual release must belong to outer agent request');assert.ok(!JSON.stringify(trace).includes('CLASSIFIED ORCHID DOCUMENT'));assert.ok(!JSON.stringify(trace).includes('synthetic-hidden-field'));assert.ok(!JSON.stringify(trace).includes('synthetic-closed-shadow-password'));const health=await(await fetch('http://127.0.0.1:4318/health')).json();assert.equal(health.rejectedWrites,0);const summary=await(await fetch('http://127.0.0.1:4318/api/summary')).json();const avFacts=await new Promise((resolve,reject)=>{const request=require('node:https').get(origin+'/av-stats',{ca:fs.readFileSync(path.join(__dirname,'../tests/fixtures/icap/cert.pem')),checkServerIdentity:(_host,cert)=>new X509Certificate(cert.raw).fingerprint256===fingerprint?undefined:Error('Synthetic certificate mismatch')},response=>{let body='';response.on('data',chunk=>body+=chunk);response.on('end',()=>resolve(JSON.parse(body)));});request.on('error',reject);});assert.ok(avFacts.pngScans>=3);assert.equal(avFacts.pngBlocks,1);fs.writeFileSync(path.resolve('artifacts/agent-visual-export-native-proof.json'),JSON.stringify({actualElectron:true,syntheticPinnedTLS:true,defaultScopeDenied:true,independentPublicationApproved:true,individualScopeEnforced:true,publicViewportApproved:true,canonicalBoundedPNG:true,mandatoryOCRWithDocumentAnalysisDisabled:true,confidentialCanvasBlocked:true,mandatoryPNGAVBlocked:true,avFacts,localWitnesses,offscreenSensitiveControlDenied:true,closedShadowControlDenied:true,publicOpenShadowApproved:true,childFrameDenied:true,foregroundRaceDenied:true,lateClassificationPolicyDenied:true,lateForegroundDenied:true,openShadowMutationRevertDenied:true,policyRaceDenied:true,targetRevokeDenied:true,finalReleaseOuterRequestTraced:true,ordinaryAuditSecretFree:true,traceId:event.trace.id,stages:trace.spans.map(({name,operation,spanId,parentSpanId,durationMs})=>({name,operation,spanId,parentSpanId,durationMs})),summary,health:{storedBytes:health.storedBytes,rejectedWrites:health.rejectedWrites}},null,2));console.log('Agent visual export native proof passed');for(const p of pages)if(!p.isDestroyed())require('../apps/browser/page-clipboard-guard.cjs').disposePageClipboardGuard(p);app.exit(0); + }catch(error){fs.writeFileSync(path.resolve('artifacts/agent-visual-export-native-error.json'),JSON.stringify({stage,name:error.name,message:error.message,localWitnesses},null,2));console.error('Agent visual native failed at '+stage+': '+error.name);for(const p of pages)if(!p.isDestroyed())require('../apps/browser/page-clipboard-guard.cjs').disposePageClipboardGuard(p);app.exit(1);}}); diff --git a/scripts/agent-visual-export-smoke.mjs b/scripts/agent-visual-export-smoke.mjs new file mode 100644 index 0000000..6ce8ae5 --- /dev/null +++ b/scripts/agent-visual-export-smoke.mjs @@ -0,0 +1,26 @@ +import {createServer} from 'node:https'; +import {createRequire} from 'node:module'; +import {mkdtempSync,mkdirSync,rmSync,readFileSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join,resolve,sep} from 'node:path'; +import {spawn} from 'node:child_process'; +import {randomBytes} from 'node:crypto'; +import net from 'node:net'; +import {createApp} from '../apps/server/app.mjs'; +import {openStore,digest} from '../apps/server/store.mjs'; +import {startTelemetry} from '../apps/server/telemetry.mjs'; +const collector='http://127.0.0.1:4318';if(!(await fetch(collector+'/health')).ok)throw Error('Protected collector unavailable'); +process.env.OTEL_EXPORTER_OTLP_ENDPOINT=collector;const telemetry=startTelemetry(); +const temporary=mkdtempSync(join(tmpdir(),'safetynet-agent-visual-'));mkdirSync('artifacts',{recursive:true}); +if(!resolve(temporary).startsWith(resolve(tmpdir())+sep)||!temporary.split(/[\\/]/).at(-1).startsWith('safetynet-agent-visual-'))throw Error('Unsafe temporary fixture path'); +let blockPNG=false;const avFacts={scans:0,pngScans:0,pngBlocks:0},avSockets=new Set(),pngSignature=Buffer.from([137,80,78,71,13,10,26,10]);const av=net.createServer(socket=>{avSockets.add(socket);socket.on('close',()=>avSockets.delete(socket));socket.on('error',()=>{});let input=Buffer.alloc(0),done=false;socket.on('data',chunk=>{input=Buffer.concat([input,chunk]);if(input.length>4194304){socket.destroy();return;}if(done||input.indexOf('\r\n\r\n')<0)return;const options=input.subarray(0,7).toString()==='OPTIONS';if(!options&&!input.subarray(-5).equals(Buffer.from('0\r\n\r\n')))return;done=true;if(options){input.fill(0);socket.end('ICAP/1.0 200 OK\r\nMethods: RESPMOD, REQMOD\r\nAllow: 204\r\nISTag: "visual-fixture"\r\nEncapsulated: null-body=0\r\n\r\n');return;}avFacts.scans++;const png=input.indexOf(pngSignature)>=0;if(png)avFacts.pngScans++;const blocked=png&&blockPNG;if(blocked)avFacts.pngBlocks++;input.fill(0);socket.end(blocked?'ICAP/1.0 204 No Content\r\nISTag: "visual-fixture"\r\nX-Infection-Found: Type=0; Threat=synthetic-visual;\r\n\r\n':'ICAP/1.0 204 No Content\r\nISTag: "visual-fixture"\r\n\r\n');});}).listen(0,'127.0.0.1');await new Promise(r=>av.once('listening',r)); +let requests=0;const sink=createServer({cert:readFileSync('tests/fixtures/icap/cert.pem'),key:readFileSync('tests/fixtures/icap/key.pem')},(req,res)=>{requests++;if(req.url==='/av-stats'){res.setHeader('content-type','application/json');res.end(JSON.stringify(avFacts));return;}if(req.url==='/av-block')blockPNG=true;else if(['/public','/canvas','/sensitive','/closed-shadow','/open-shadow','/frames'].includes(req.url))blockPNG=false;res.setHeader('content-type','text/html');const base='Public visual fixture';if(req.url==='/canvas')res.end(base+'');else if(req.url==='/sensitive')res.end(base+'

Public form

');else if(req.url==='/closed-shadow')res.end(base+`
`);else if(req.url==='/open-shadow')res.end(base+`
`);else if(req.url==='/frames')res.end(base+'

Public parent

');else res.end(base+'

Public approved visual page

Research and documentation workspace.

');}).listen(0,'127.0.0.1');await new Promise(r=>sink.once('listening',r)); +const origin=`https://127.0.0.1:${sink.address().port}`,store=openStore(join(temporary,'db')),code=randomBytes(32).toString('base64url'),adminToken=randomBytes(32).toString('base64url'); +const p=store.get('policy');p.browser.homeUrl=origin+'/public';p.inspection.documentAnalysisEnabled=false;p.dlp.rules.push({id:'visual-confidential-fixture',label:'Synthetic visual confidential rule',kind:'literal',value:'CLASSIFIED ORCHID DOCUMENT',action:'block'}); +p.enterprise.icap={...p.enterprise.icap,enabled:true,url:`icap://127.0.0.1:${av.address().port}/scan`,scanUploads:true,scanDownloads:false,scanClipboard:false,scanAI:false}; +p.agents={...p.agents,enabled:true,identityMode:'individual',scopes:['sessions'],allowedHosts:['127.0.0.1'],grants:[{id:'visual-a',name:'Visual A',enabled:true,expiresAt:new Date(Date.now()+3600000).toISOString(),scopes:['sessions'],allowedHosts:['127.0.0.1'],generation:1},{id:'visual-b',name:'Visual B',enabled:true,expiresAt:new Date(Date.now()+3600000).toISOString(),scopes:['sessions'],allowedHosts:['127.0.0.1'],generation:1}]}; +p.diagnostics={...p.diagnostics,enabled:true,mode:'debug',endpoint:collector,sampleRate:1,debugSampleRate:1,flushIntervalMs:250};store.put('policy',p);store.db.prepare('INSERT INTO enrollments VALUES(?,?,?)').run(digest(code),Date.now()+600000,'vault-fixture'); +const config={secure:false,publicUrl:'http://127.0.0.1',adminToken,requirePQManagement:true,requireIndependentPolicyApproval:true};const management=createApp(store,config).listen(0,'127.0.0.1');await new Promise(r=>management.once('listening',r));config.publicUrl=`http://127.0.0.1:${management.address().port}`; +const reviewer=randomBytes(32).toString('base64url');store.db.prepare('INSERT INTO sessions VALUES(?,?,?)').run(digest(reviewer),Date.now()+600000,JSON.stringify({sub:'independent-vault-reviewer',role:'admin'})); +const child=spawn(createRequire(import.meta.url)('electron'),['scripts/agent-visual-export-smoke.cjs'],{windowsHide:true,stdio:['ignore','pipe','pipe'],env:{...process.env,SAFETYNET_SMOKE_SERVER:config.publicUrl,SAFETYNET_SMOKE_CODE:code,SAFETYNET_SMOKE_ADMIN:adminToken,SAFETYNET_SMOKE_REVIEWER:reviewer,SAFETYNET_SMOKE_USERDATA:join(temporary,'browser'),SAFETYNET_SMOKE_ARTIFACTS:resolve('artifacts'),SAFETYNET_SMOKE_ORIGIN:origin}});child.stdout.on('data',v=>process.stdout.write(v));child.stderr.on('data',v=>process.stderr.write(v));const deadline=setTimeout(()=>child.kill(),240000);const exit=await new Promise(r=>{child.once('exit',v=>r(v??1));child.once('error',()=>r(1));});clearTimeout(deadline); +for(const socket of avSockets)socket.destroy();await new Promise(r=>av.close(r));for(const server of [management,sink])server.closeAllConnections();await Promise.all([new Promise(r=>management.close(r)),new Promise(r=>sink.close(r))]);store.db.close();await telemetry?.shutdown();rmSync(temporary,{recursive:true,force:true});if(exit===0&&(avFacts.pngScans<3||avFacts.pngBlocks!==1))throw Error('Actual visual AV exchanges incomplete');process.exit(exit); diff --git a/scripts/verify-package.mjs b/scripts/verify-package.mjs index b4b26f8..94def04 100644 --- a/scripts/verify-package.mjs +++ b/scripts/verify-package.mjs @@ -14,7 +14,7 @@ assert.deepEqual(extractFile(path.join(root,'resources/app.asar'),path.normalize for(const file of ['apps/browser/upload-lease.cjs','apps/browser/file-upload-gate.cjs','apps/browser/request-source.cjs'])assert.deepEqual(extractFile(path.join(root,'resources/app.asar'),path.normalize(file)),await fs.readFile(file),`Packaged upload binding source is stale: ${file}`); assert.deepEqual(await fs.readFile(path.join(root,'resources/inspector/upload_lease.py')),await fs.readFile('apps/inspector/upload_lease.py'),'Packaged Windows upload lease helper is stale'); assert.deepEqual(extractFile(path.join(root,'resources/app.asar'),path.normalize('apps/browser/header-inspection.cjs')),await fs.readFile('apps/browser/header-inspection.cjs'),'Packaged outgoing header DLP source is stale'); -for(const file of ['apps/browser/credential-submission.cjs','packages/core/credential-policy.mjs','apps/browser/session-snapshot.cjs','apps/browser/agent-session-adapters.cjs','apps/browser/session-restore.cjs','apps/browser/vault-agent-controls.cjs','apps/browser/vault.cjs','apps/browser/window-controls.cjs','apps/browser/history-snapshot.cjs','apps/browser/human-interaction.cjs','packages/core/siem.mjs','packages/core/evidence.mjs','packages/core/evidence-payload.cjs','packages/core/interaction-facts.cjs','apps/browser/evidence-capture.cjs','apps/browser/evidence-delivery.cjs','packages/core/request-transfer-facts.cjs','packages/core/transfer-facts.cjs','packages/core/inspection-stages.cjs','apps/browser/history-navigation.cjs','apps/browser/agent-transport-client.cjs','apps/browser/agent-client.cjs','apps/browser/library-organization.cjs','packages/core/library.mjs','packages/core/branding.mjs','packages/core/file-transfer.cjs','packages/core/file-transfer.mjs'])assert.deepEqual(extractFile(path.join(root,'resources/app.asar'),path.normalize(file)),await fs.readFile(file),`Packaged new security/branding source is stale: ${file}`); +for(const file of ['apps/browser/agent-visual-export.cjs','apps/browser/agent-visual-probe.cjs','apps/browser/credential-submission.cjs','packages/core/credential-policy.mjs','apps/browser/session-snapshot.cjs','apps/browser/agent-session-adapters.cjs','apps/browser/session-restore.cjs','apps/browser/vault-agent-controls.cjs','apps/browser/vault.cjs','apps/browser/window-controls.cjs','apps/browser/history-snapshot.cjs','apps/browser/human-interaction.cjs','packages/core/siem.mjs','packages/core/evidence.mjs','packages/core/evidence-payload.cjs','packages/core/interaction-facts.cjs','apps/browser/evidence-capture.cjs','apps/browser/evidence-delivery.cjs','packages/core/request-transfer-facts.cjs','packages/core/transfer-facts.cjs','packages/core/inspection-stages.cjs','apps/browser/history-navigation.cjs','apps/browser/agent-transport-client.cjs','apps/browser/agent-client.cjs','apps/browser/library-organization.cjs','packages/core/library.mjs','packages/core/branding.mjs','packages/core/file-transfer.cjs','packages/core/file-transfer.mjs'])assert.deepEqual(extractFile(path.join(root,'resources/app.asar'),path.normalize(file)),await fs.readFile(file),`Packaged new security/branding source is stale: ${file}`); for(const file of ['apps/browser/agent-grants.cjs','apps/browser/policy-assignment.cjs','packages/core/policy-groups.mjs','apps/browser/tab-registry.cjs','apps/browser/agent-adapters.cjs','apps/browser/agent-control.cjs','apps/browser/password-fill.cjs','apps/browser/diagnostics.cjs','packages/core/diagnostics.mjs','packages/core/security.mjs'])assert.deepEqual(extractFile(path.join(root,'resources/app.asar'),path.normalize(file)),await fs.readFile(file),`Packaged device/session policy source is stale: ${file}`); for(const file of ['apps/browser/main.cjs','apps/browser/preload.cjs','apps/browser/site-preload.cjs','apps/browser/clipboard-deny.cjs','apps/browser/clipboard-guard.cjs','apps/browser/clipboard-dom.cjs','apps/browser/page-clipboard-guard.cjs','apps/browser/startup-security.cjs','apps/browser/visual-capture.cjs','apps/browser/renderer.js','apps/browser/index.html','apps/browser/style.css','packages/core/clipboard.mjs']){ assert.deepEqual(extractFile(path.join(root,'resources/app.asar'),path.normalize(file)),await fs.readFile(file),`Packaged source is stale: ${file}`); diff --git a/tests/admin-controls-ui.test.mjs b/tests/admin-controls-ui.test.mjs index c9f6f93..4bcb7ee 100644 --- a/tests/admin-controls-ui.test.mjs +++ b/tests/admin-controls-ui.test.mjs @@ -24,3 +24,5 @@ test('controlled credential submission and agent preparation start off and round const fields=new Map(),get=id=>{if(!fields.has(id))fields.set(id,{value:'',checked:false});return fields.get(id);};const p={passwordManager:{enabled:true,allowSaving:true,allowAutofill:true,idleLockSeconds:300,controlledSubmission:{enabled:true,forms:[{id:'login',actionUrl:'https://login.example/signin',usernameField:'username',passwordField:'password'}]}}},context=vm.createContext({$:get,p}); const fillLine=source.split('\n').find(line=>line.includes("$('vault-enabled').checked=p.passwordManager.enabled"));vm.runInContext(fillLine,context);assert.equal(get('vault-controlled-submit').checked,true);const draftLine=source.split('\n').find(line=>line.includes("const forms=JSON.parse($('vault-submit-forms')"));vm.runInContext(draftLine,context);assert.deepEqual(JSON.parse(JSON.stringify(p.passwordManager.controlledSubmission)),{enabled:true,forms:[{id:'login',actionUrl:'https://login.example/signin',usernameField:'username',passwordField:'password'}]});assert.equal(p.passwordManager.allowAutofill,true);get('vault-submit-forms').value='{}';assert.throws(()=>vm.runInContext(draftLine,vm.createContext({$:get,p})));assert.ok(source.includes("'password-vault'")); }); + +test('visual export remains separate unchecked page-only opt-in in central and individual grants',()=>{const source=readFileSync(new URL('../apps/server/public/admin.js',import.meta.url),'utf8'),scopes=vm.runInNewContext(source.match(/const agentScopes=(\[[^;]+\]);/)[1]);assert.ok(scopes.includes('screenshot'));const tag=html.match(/]*id="agent-scope-screenshot"[^>]*>/)[0];assert.equal(/\bchecked\b/.test(tag),false);for(const text of ['Export inspected foreground page viewport','does not authorize desktop, other windows or browser-interface capture','Sensitive-read permission does not bypass visual inspection'])assert.ok(html.includes(text));const editor=readFileSync(new URL('../apps/server/public/agent-grants-admin.js',import.meta.url),'utf8');assert.ok(editor.includes('for(const scope of agentScopes)'));assert.ok(editor.includes('check.checked=grant.scopes.includes(scope)'));assert.ok(editor.includes('check.disabled=!agentGrantsEditable'));assert.ok(editor.includes('never the desktop or browser interface'));}); diff --git a/tests/agent-visual-diagnostics.test.mjs b/tests/agent-visual-diagnostics.test.mjs new file mode 100644 index 0000000..06c3446 --- /dev/null +++ b/tests/agent-visual-diagnostics.test.mjs @@ -0,0 +1,6 @@ +import test from 'node:test';import assert from 'node:assert/strict';import {createRequire} from 'node:module';import {createServer} from 'node:http';import {deflateSync} from 'node:zlib';import {DiagnosticsSchema} from '../packages/core/diagnostics.mjs';const require=createRequire(import.meta.url),{createDiagnostics}=require('../apps/browser/diagnostics.cjs'),{createAgentVisualExport}=require('../apps/browser/agent-visual-export.cjs'); +function png(){const chunk=(type,data)=>{const name=Buffer.from(type),body=Buffer.concat([name,data]);let c=0xffffffff;for(const b of body){c^=b;for(let i=0;i<8;i++)c=(c&1)?0xedb88320^(c>>>1):c>>>1;}const length=Buffer.alloc(4),sum=Buffer.alloc(4);length.writeUInt32BE(data.length);sum.writeUInt32BE((c^0xffffffff)>>>0);return Buffer.concat([length,body,sum]);},header=Buffer.alloc(13);header.writeUInt32BE(1);header.writeUInt32BE(1,4);header[8]=8;header[9]=6;return Buffer.concat([Buffer.from([137,80,78,71,13,10,26,10]),chunk('IHDR',header),chunk('IDAT',deflateSync(Buffer.alloc(5))),chunk('IEND',Buffer.alloc(0))]);} +async function fixture(t){const payloads=[],server=createServer((req,res)=>{const chunks=[];req.on('data',c=>chunks.push(c));req.on('end',()=>{payloads.push(JSON.parse(Buffer.concat(chunks).toString()));res.setHeader('content-type','application/json');res.end('{}');});}).listen(0,'127.0.0.1');await new Promise(r=>server.once('listening',r));const diagnostics=createDiagnostics();diagnostics.configure(DiagnosticsSchema.parse({enabled:true,mode:'debug',endpoint:`http://127.0.0.1:${server.address().port}`,sampleRate:1,debugSampleRate:1,flushIntervalMs:10000}));t.after(async()=>{await diagnostics.shutdown();server.closeAllConnections();await new Promise(r=>server.close(r));});const bytes=png(),context={ready:true,foreground:true,interactive:true,policy:{expiresAt:new Date(Date.now()+60000).toISOString(),agents:{enabled:true,scopes:['screenshot']}},sessionId:'00000000-0000-4000-8000-000000000001',token:{},actor:{agentId:'PRIVATE_ACTOR',generation:1},webContents:{getURL:()=> 'https://private.example/page',isDestroyed:()=>false},destinationAllowed:()=>true};let local={decision:'allow',contentType:'image',coverage:{complete:true}},safe=true;const exporter=createAgentVisualExport({getContext:()=>context,contextCurrent:()=>true,probeSensitive:async()=>({safe,revision:'PRIVATE_REVISION',width:1,height:1}),capture:async()=>bytes,inspectLocal:async()=>local,inspectEnterprise:async()=>({decision:'allow',coverage:{complete:true}}),audit:async()=>{},traceOperation:(operation,fn)=>diagnostics.span('browser.agent',{operation,url:'https://private.example/page',image:bytes.toString('base64'),actor:'PRIVATE_ACTOR'},fn)});return {bytes,diagnostics,payloads,run:()=>diagnostics.span('browser.agent',{operation:'screenshot'},()=>exporter()),block:()=>local={decision:'block',ruleId:'PRIVATE_RULE',contentType:'image',coverage:{complete:true}},unsafe:()=>safe=false,spans:()=>payloads.flatMap(p=>p.resourceSpans||[]).flatMap(p=>p.scopeSpans||[]).flatMap(p=>p.spans||[])};} +test('visual export produces real OTLP correlated named timings without image/page/actor data',async t=>{const f=await fixture(t),image=f.bytes.toString('base64');await f.run();await f.diagnostics.flush();const spans=f.spans(),op=s=>s.attributes.find(a=>a.key==='safetynet.operation')?.value.stringValue,parent=spans.find(s=>op(s)==='screenshot');assert.ok(parent);for(const operation of ['visual.probe','visual.capture','visual.validate','visual.inspect.local','visual.inspect.enterprise','visual.audit','visual.project']){const child=spans.find(s=>op(s)===operation);assert.ok(child,operation);assert.equal(child.parentSpanId,parent.spanId);assert.equal(child.traceId,parent.traceId);assert.ok(BigInt(child.endTimeUnixNano)>=BigInt(child.startTimeUnixNano));}for(const value of ['PRIVATE_ACTOR','PRIVATE_REVISION','private.example',image])assert.equal(JSON.stringify(f.payloads).includes(value),false);assert.equal(f.diagnostics.status().lifetimeFailedExports,0);}); +test('blocked local OCR and unsafe probe export ERROR on the responsible named stage',async t=>{for(const mode of ['local','probe']){const f=await fixture(t);if(mode==='local')f.block();else f.unsafe();await assert.rejects(f.run(),e=>e.status===403&&e.message==='Managed visual export refused');await f.diagnostics.flush();const operation=mode==='local'?'visual.inspect.local':'visual.probe',stage=f.spans().find(s=>s.attributes.some(a=>a.key==='safetynet.operation'&&a.value.stringValue===operation));assert.ok(stage);assert.equal(stage.status.code,2);assert.equal(JSON.stringify(f.payloads).includes('PRIVATE_RULE'),false);if(mode==='local')assert.ok(f.bytes.every(b=>b===0));}}); +test('admin diagnostics disable preserves screenshot inspection without any exported spans',async t=>{const f=await fixture(t);f.diagnostics.configure({enabled:false});assert.equal((await f.run()).mime,'image/png');await f.diagnostics.flush();assert.equal(f.spans().length,0);assert.ok(f.bytes.every(b=>b===0));}); diff --git a/tests/agent-visual-export.test.mjs b/tests/agent-visual-export.test.mjs new file mode 100644 index 0000000..e611c1a --- /dev/null +++ b/tests/agent-visual-export.test.mjs @@ -0,0 +1,23 @@ +import {AsyncLocalStorage} from 'node:async_hooks'; +import test from 'node:test';import assert from 'node:assert/strict';import {createRequire} from 'node:module';import {deflateSync} from 'node:zlib';const require=createRequire(import.meta.url),{createAgentVisualExport,getVisualReleaseGuard,validateVisualResult,pngSize}=require('../apps/browser/agent-visual-export.cjs'); +function png(width=2,height=2,metadata){const crc=bytes=>{let c=0xffffffff;for(const b of bytes){c^=b;for(let i=0;i<8;i++)c=(c&1)?0xedb88320^(c>>>1):c>>>1;}return (c^0xffffffff)>>>0;},chunk=(type,data)=>{const size=Buffer.alloc(4),name=Buffer.from(type),sum=Buffer.alloc(4);size.writeUInt32BE(data.length);sum.writeUInt32BE(crc(Buffer.concat([name,data])));return Buffer.concat([size,name,data,sum]);};const ihdr=Buffer.alloc(13);ihdr.writeUInt32BE(width);ihdr.writeUInt32BE(height,4);ihdr[8]=8;ihdr[9]=6;return Buffer.concat([Buffer.from([137,80,78,71,13,10,26,10]),chunk('IHDR',ihdr),chunk('IDAT',deflateSync(Buffer.alloc(18))),...(metadata?[chunk('tEXt',Buffer.from(metadata))]:[]),chunk('IEND',Buffer.alloc(0))]);} +function fixture(){const bytes=png(),events=[],stages=[],policy={expiresAt:new Date(Date.now()+60000).toISOString(),agents:{enabled:true,scopes:['screenshot']}},c={ready:true,foreground:true,interactive:true,policy,sessionId:'tab1',token:{},actor:{agentId:'actor',generation:1},webContents:{getURL:()=> 'https://example.com/docs',isDestroyed:()=>false},destinationAllowed:()=>true};let revision='rev1',safe=true,valid=true;const deps={getContext:()=>c,contextCurrent:()=>valid,probeSensitive:async()=>({safe,revision,width:2,height:2}),capture:async()=>bytes,inspectLocal:async()=>({decision:'allow',contentType:'image',coverage:{complete:true}}),inspectEnterprise:async()=>({decision:'allow',coverage:{complete:true}}),audit:async e=>events.push(e),traceOperation:async(op,fn)=>{stages.push(op);return fn();}};return {c,bytes,deps,events,stages,changeRevision:()=>revision='rev2',unsafe:()=>safe=false,invalidate:()=>valid=false,make:()=>createAgentVisualExport(deps)};} +test('native PNG export requires OCR and enterprise allow, exports only canonical bounded payload',async()=>{const f=fixture(),expected=f.bytes.toString('base64'),result=await f.make()();assert.deepEqual(result,{mime:'image/png',width:2,height:2,data:expected});assert.ok(f.bytes.every(b=>b===0));assert.deepEqual(f.events,[{action:'screenshot',outcome:'authorized',sessionId:'tab1'}]);assert.deepEqual(f.stages.filter(s=>s!=='visual.probe'),['visual.capture','visual.validate','visual.inspect.local','visual.inspect.enterprise','visual.audit','visual.project']);}); +test('scope defaults off and unsafe visible fields/frames deny before capture',async()=>{for(const mode of ['scope','unsafe','foreground','interactive']){const f=fixture();let captured=0;f.deps.capture=async()=>{captured++;return f.bytes;};if(mode==='scope')f.c.policy.agents.scopes=[];if(mode==='unsafe')f.unsafe();if(mode==='foreground')f.c.foreground=false;if(mode==='interactive')f.c.interactive=false;await assert.rejects(f.make()());assert.equal(captured,0);}}); +test('local image OCR is mandatory and incomplete/blocked/nonimage verdicts fail closed',async()=>{for(const verdict of [{decision:'block',coverage:{complete:true}},{decision:'allow',contentType:'image',coverage:{complete:false}},{decision:'allow',contentType:'text',coverage:{complete:true}}]){const f=fixture();f.deps.inspectLocal=async()=>verdict;await assert.rejects(f.make()());assert.ok(f.bytes.every(b=>b===0));assert.equal(f.events.length,0);}}); +test('capture/local/enterprise/audit waits reject changed native authority and content revision',async()=>{for(const stage of ['capture','inspectLocal','inspectEnterprise','audit'])for(const change of ['policy','actor','revision']){const f=fixture(),fn=f.deps[stage];f.deps[stage]=async(...args)=>{const result=await fn(...args);if(change==='policy')f.c.policy={...f.c.policy};if(change==='actor')f.c.actor={agentId:'other',generation:1};if(change==='revision')f.changeRevision();return result;};await assert.rejects(f.make()());assert.ok(f.bytes.every(b=>b===0));}}); +test('PNG source mutation, malformed CRC, dimensions and arbitrary arguments are rejected',async()=>{const f=fixture();f.deps.inspectEnterprise=async()=>{f.bytes[40]^=1;return {decision:'allow',coverage:{complete:true}};};await assert.rejects(f.make()());for(const data of [png(4097,2),png(4096,4096),Buffer.from('not-png')])assert.throws(()=>pngSize(data));const malformed=png();malformed[32]^=1;assert.throws(()=>pngSize(malformed));const g=fixture();await assert.rejects(g.make()({rect:{x:0,y:0,width:10,height:10}}));}); +test('all failures sanitize replies and zero acquired buffers',async()=>{const f=fixture();f.deps.inspectLocal=async()=>{throw Error('PRIVATE IMAGE DATA');};await assert.rejects(f.make()(),{message:'Managed visual export refused'});assert.ok(f.bytes.every(b=>b===0));}); + +test('diagnostic failures and duplicate invocations never replay capture or alter output',async()=>{for(const mode of ['before','after','twice']){const f=fixture();let captures=0;f.deps.capture=async()=>{captures++;return f.bytes;};f.deps.traceOperation=async(_op,fn)=>{if(mode==='before')throw Error('Diagnostic failure');const result=await fn();if(mode==='after')throw Error('Diagnostic failure');await fn();return result;};assert.equal((await f.make()()).mime,'image/png');assert.equal(captures,1);assert.ok(f.bytes.every(b=>b===0));}}); +test('aborted local inspection prevents enterprise inspection and wipes captured PNG',async()=>{const f=fixture(),controller=new AbortController();let enterprise=0;f.deps.inspectLocal=async()=>{controller.abort();return {decision:'allow',contentType:'image',coverage:{complete:true}};};f.deps.inspectEnterprise=async()=>{enterprise++;return {decision:'allow',coverage:{complete:true}};};await assert.rejects(f.make()({}, {signal:controller.signal}));assert.equal(enterprise,0);assert.ok(f.bytes.every(b=>b===0));const g=fixture();await assert.rejects(g.make()({}, {signal:AbortSignal.abort()}));}); +test('pure visual projection requires canonical PNG and rejects mismatched or extra metadata',()=>{const bytes=png(),result={mime:'image/png',width:2,height:2,data:bytes.toString('base64')};assert.deepEqual(validateVisualResult(result),result);for(const value of [{...result,width:3},{...result,data:result.data+'\n'},{...result,url:'https://secret.example'},{...result,mime:'image/jpeg'}])assert.throws(()=>validateVisualResult(value));let reads=0;const hostile={...result};Object.defineProperty(hostile,'data',{get(){reads++;return result.data;}});assert.throws(()=>validateVisualResult(hostile));assert.equal(reads,0);}); +test('PNG ancillary content-bearing chunks are denied before export',()=>{const validPngWithText=png(2,2,'comment\\0PRIVATE_METADATA');assert.throws(()=>pngSize(validPngWithText));}); + + + +test('private release guard invalidates policy, actor, token, navigation and cancellation after helper return',async()=>{for(const mode of ['policy','actor','token','navigation','abort']){const f=fixture(),controller=new AbortController();const result=await f.make()({}, {signal:controller.signal}),guard=getVisualReleaseGuard(result);assert.equal(typeof guard,'function');assert.equal(Object.isFrozen(result),true);assert.deepEqual(Object.keys(result).sort(),['data','height','mime','width']);guard();if(mode==='policy')f.c.policy={...f.c.policy};if(mode==='actor')f.c.actor={agentId:'other',generation:1};if(mode==='token')f.invalidate();if(mode==='navigation')f.c.webContents.getURL=()=> 'https://example.com/changed';if(mode==='abort')controller.abort();assert.throws(guard,e=>e.status===403);assert.equal(getVisualReleaseGuard({...result}),undefined);assert.equal(getVisualReleaseGuard(JSON.parse(JSON.stringify(result))),undefined);}}); +test('private release guard restores original actor async context when transport calls outside it',async()=>{const f=fixture(),actors=new AsyncLocalStorage();let currentActor={agentId:'original-agent',generation:1};f.deps.getContext=()=>({...f.c,actor:actors.getStore()});const result=await actors.run(currentActor,()=>f.make()()),guard=getVisualReleaseGuard(result);assert.equal(actors.getStore(),undefined);assert.doesNotThrow(guard);f.c.policy={...f.c.policy};assert.throws(guard,e=>e.status===403);}); + +test('native frame identity, process/routing and loading state invalidate private release guard',async()=>{for(const mode of ['frame','process','routing','loading']){const f=fixture();f.c.webContents.mainFrame={processId:101,routingId:202,isDestroyed:()=>false};let loading=false;f.c.webContents.isLoading=()=>loading;const result=await f.make()(),guard=getVisualReleaseGuard(result);guard();if(mode==='frame')f.c.webContents.mainFrame={...f.c.webContents.mainFrame};if(mode==='process')f.c.webContents.mainFrame.processId++;if(mode==='routing')f.c.webContents.mainFrame.routingId++;if(mode==='loading')loading=true;assert.throws(guard,e=>e.status===403);}}); +test('same URL native navigation during inspection cannot export a stale screenshot',async()=>{const f=fixture();let loading=false;f.c.webContents.isLoading=()=>loading;f.c.webContents.mainFrame={processId:1,routingId:2,isDestroyed:()=>false};f.deps.inspectEnterprise=async()=>{loading=true;return {decision:'allow',coverage:{complete:true}};};await assert.rejects(f.make()(),e=>e.status===403);assert.ok(f.bytes.every(b=>b===0));}); diff --git a/tests/agent-visual-probe.test.mjs b/tests/agent-visual-probe.test.mjs new file mode 100644 index 0000000..a8488df --- /dev/null +++ b/tests/agent-visual-probe.test.mjs @@ -0,0 +1,32 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {createRequire} from 'node:module'; +import {EventEmitter} from 'node:events'; +import vm from 'node:vm'; +const require=createRequire(import.meta.url); +const {visualDocumentProbe,probeVisualContext}=require('../apps/browser/agent-visual-probe.cjs'); +const {attachPageClipboardGuard,guardedVisualStructure,disposePageClipboardGuard}=require('../apps/browser/page-clipboard-guard.cjs'); + +function node(tag='DIV',attributes={},visible=true){return {tagName:tag,localName:tag.toLowerCase(),type:attributes.type||'',hidden:!visible,parentElement:null,getAttribute:key=>attributes[key]??null,hasAttribute:key=>Object.hasOwn(attributes,key),getBoundingClientRect:()=>({left:1,top:1,right:10,bottom:10,width:9,height:9})};} +function probe(nodes){let change;const watched=new Map();const realm={location:{href:'https://example.com'},document:{readyState:'complete',querySelectorAll:selector=>selector==='video'?[]:nodes,getAnimations:()=>[]},crypto:{randomUUID:()=> 'private-document'},MutationObserver:class{constructor(fn){change=fn;this.records=[];}observe(root){watched.set(root,this);}takeRecords(){return this.records.splice(0);}disconnect(){}},addEventListener(){},innerWidth:1280,innerHeight:720,scrollX:0,scrollY:0,devicePixelRatio:1,visualViewport:{scale:1},getComputedStyle:()=>({display:'block',visibility:'visible',opacity:'1'})};vm.createContext(realm);return {run:()=>vm.runInContext(`(${visualDocumentProbe.toString()})('https://example.com')`,realm),change:()=>change(),mutate:root=>watched.get(root).records.push({})};} +test('isolated probe rejects credential controls even offscreen and probes open shadow content',()=>{ + assert.equal(probe([node()]).run().safe,true); + for(const attributes of [{type:'password'},{autocomplete:'section-login current-password'},{name:'otp'},{id:'api_key'}])assert.equal(probe([node('INPUT',attributes,false)]).run().safe,false); + const host=node();host.shadowRoot={querySelectorAll:()=>[node('INPUT',{type:'password'})]};assert.equal(probe([host]).run().safe,false); + assert.equal(probe([node('IFRAME')]).run().safe,false); + const p=probe([node()]),first=p.run();p.change();assert.notEqual(p.run().revision,first.revision); +}); + +async function guard(t,root){const api=new EventEmitter();let attached=false;api.isAttached=()=>attached;api.attach=()=>{attached=true;};api.detach=()=>{attached=false;api.emit('detach');};api.sendCommand=async method=>method==='Page.addScriptToEvaluateOnNewDocument'?{identifier:'private'}:method==='DOM.getDocument'?{root}:{};const contents={debugger:api,isDestroyed:()=>false};await attachPageClipboardGuard(contents,()=>{});t.after(()=>disposePageClipboardGuard(contents));return contents;} +const dom=(extra={})=>({nodeName:'DIV',nodeValue:'',attributes:[],...extra}); +test('private native structure catches closed/declarative shadow roots and refuses budgets/frames',async t=>{ + for(const root of [dom({shadowRoots:[dom({shadowRootType:'closed'})]}),dom({contentDocument:dom()}),dom({nodeValue:'x'.repeat(1024*1024+1)})])await assert.rejects(guardedVisualStructure(await guard(t,root))); + const contents=await guard(t,dom({shadowRoots:[dom({shadowRootType:'open'})]}));assert.equal((await guardedVisualStructure(contents)).safe,true); + await assert.rejects(guardedVisualStructure({isDestroyed:()=>false})); +}); +test('native subframes cannot fall back to untrusted page-world probes',async()=>{ + let executed=false;const frame={isDestroyed:()=>false,framesInSubtree:[{},{}]},contents={mainFrame:frame,isLoading:()=>false,executeJavaScriptInIsolatedWorld:()=>{executed=true;}}; + await assert.rejects(probeVisualContext({webContents:contents,url:'https://example.com'}));assert.equal(executed,false); +}); + +test('shadow-root mutation and reversal cannot preserve screenshot revision',()=>{const host=node(),root={host:{isConnected:true},querySelectorAll:()=>[]};host.shadowRoot=root;const p=probe([host]),baseline=p.run();p.mutate(root);p.mutate(root);assert.equal(p.run().safe,true);assert.notEqual(p.run().revision,baseline.revision);const existing=node(),q=probe([existing]),before=q.run();existing.shadowRoot={host:{isConnected:true},querySelectorAll:()=>[]};assert.notEqual(q.run().revision,before.revision);}); diff --git a/tests/agent-visual-transport.test.mjs b/tests/agent-visual-transport.test.mjs new file mode 100644 index 0000000..d6716be --- /dev/null +++ b/tests/agent-visual-transport.test.mjs @@ -0,0 +1,16 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {createRequire} from 'node:module'; +const require=createRequire(import.meta.url),{PNG}=require('pngjs'); +const {startAgentControl}=require('../apps/browser/agent-control.cjs'); +const {requestAgent}=require('../apps/browser/agent-transport-client.cjs'); +const {createAgentVisualExport}=require('../apps/browser/agent-visual-export.cjs'); +const image=()=>{const p=new PNG({width:16,height:16});p.data.fill(255);const bytes=PNG.sync.write(p);return {mime:'image/png',width:16,height:16,data:bytes.toString('base64')};}; +async function api(t,options={}){const events=[],result=image(),context=options.context||{ready:true,foreground:true,interactive:true,policy:{expiresAt:new Date(Date.now()+60000).toISOString(),agents:{enabled:true,scopes:['screenshot']}},token:{},sessionId:'fixture',actor:{agentId:'fixture',generation:1},webContents:{getURL:()=> 'https://example.com',isDestroyed:()=>false},destinationAllowed:()=>true};const screenshot=createAgentVisualExport({getContext:()=>context,contextCurrent:()=>true,probeSensitive:async()=>({safe:true,revision:'fixed',width:16,height:16}),capture:async()=>Buffer.from(result.data,'base64'),inspectLocal:async()=>{if(options.delayInspection)await new Promise(r=>setTimeout(r,options.delayInspection));return {decision:'allow',contentType:'image',coverage:{complete:true}};},inspectEnterprise:async()=>({decision:'allow',coverage:{complete:true}}),audit:async()=>{}});const service=await startAgentControl({authorize:async({scope})=>({allowed:true,role:'agent',scopes:[scope]}),actions:{screenshot},audit:async event=>events.push(event),...options});t.after(()=>service.close());const request=async(action='screenshot',args={})=>{const response=await requestAgent(service,{action,args});return {status:response.status,...await response.json()};};return {events,result,request,service,screenshot,context};} +test('inspected PNG projection traverses authenticated PQ response and excludes extra arguments',async t=>{const f=await api(t),response=await f.request();assert.equal(response.status,200);assert.deepEqual(response.result,f.result);assert.ok(f.events.every(event=>event.scope==='browser:screenshot'));assert.equal(JSON.stringify(f.events).includes(f.result.data),false);for(const args of [{rect:{}},{desktop:true},{format:'jpeg'}])assert.equal((await f.request('screenshot',args)).status,400);}); +test('forged PNG metadata and revocation before encryption return no image',async t=>{const bad=await api(t,{actions:{screenshot:async()=>({...image(),width:17})}});const result=await bad.request();assert.notEqual(result.status,200);assert.equal(result.result,undefined);let allowed=true;const revoked=await api(t,{authorize:async({scope})=>({allowed,role:'agent',scopes:[scope]}),audit:async event=>{if(event.outcome==='completed')allowed=false;}});const denied=await revoked.request();assert.equal(denied.status,403);assert.equal(denied.result,undefined);}); +test('long inspection deadline is action-specific and ordinary operations retain their deadline',async t=>{const f=await api(t,{timeoutMs:20,actionTimeouts:{screenshot:1000},delayInspection:60});const read=await api(t,{timeoutMs:20,actions:{read:async()=>{await new Promise(r=>setTimeout(r,60));return {text:'public'};}}});assert.equal((await f.request()).status,200);assert.equal((await read.request('read')).status,504);await assert.rejects(startAgentControl({authorize:()=>{},actions:{},audit:()=>{},actionTimeouts:{unknown:100}}));}); + +test('original inspected context is required after completed audit even if grants and host still allow',async t=>{for(const change of ['policy','foreground','actor']){let f;f=await api(t,{audit:async event=>{if(event.outcome==='completed'){if(change==='policy')f.context.policy={...f.context.policy,dlp:{rules:[{id:'new-block'}]}};if(change==='foreground')f.context.foreground=false;if(change==='actor')f.context.actor={agentId:'other',generation:1};}}});const response=await f.request();assert.equal(response.status,403,change);assert.equal(response.result,undefined);}}); + +test('synchronous release diagnostics cannot skip or repeat the final native authority check',async t=>{for(const mode of ['before','after','twice','missing']){let f;const traceRelease=(_name,fn)=>{if(mode==='before')throw Error('Diagnostic unavailable');if(mode==='missing')return;fn();if(mode==='after')throw Error('Diagnostic unavailable');if(mode==='twice')fn();};f=await api(t,{traceRelease,audit:async event=>{if(event.outcome==='completed')f.context.foreground=false;}});const denied=await f.request();assert.equal(denied.status,403,mode);assert.equal(denied.result,undefined);const allowed=await api(t,{traceRelease});assert.equal((await allowed.request()).status,200,mode);}});