Add inspected agent viewport export with policy-bound final release
This commit is contained in:
1 parent
c8b6ff9cdd
commit
e7d778aeac
24 files changed
+483
-32
No files matched your search
+40
-1
@@ -1,6 +1,6 @@
|
|||||||
# Local browser agents
|
# 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
|
## 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: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: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 |
|
| 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}` |
|
| navigate | sessionId, url | navigate | browser:navigate | `{completed}` |
|
||||||
| back | sessionId | back | browser:navigate | `{completed}` |
|
| back | sessionId | back | browser:navigate | `{completed}` |
|
||||||
| forward | sessionId | forward | 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
|
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.
|
verification are pending; historical green builds do not prove this source state.
|
||||||
See [controlled credential policy](docs/controlled-credential-policy.md).
|
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.
|
||||||
@@ -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. |
|
| 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. |
|
| 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. |
|
| 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. |
|
| 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. |
|
| 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.
|
[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.
|
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.
|
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.
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ const crypto = require('node:crypto');
|
|||||||
const channel=require('../../packages/core/policy-channel.cjs');
|
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 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 fail = (status, message) => Object.assign(new Error(message), { status });
|
||||||
const bounded = (value, max, name) => {
|
const bounded = (value, max, name) => {
|
||||||
if (typeof value !== 'string' || !value.length || value.length > max || /\u0000/.test(value)) throw fail(400, `Invalid ${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');
|
if (!Object.hasOwn(SCOPES, action)) throw fail(400, 'Unsupported action');
|
||||||
const args = body.args ?? {};
|
const args = body.args ?? {};
|
||||||
if (!args || typeof args !== 'object' || Array.isArray(args)) throw fail(400, 'Invalid arguments');
|
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');
|
if (Object.keys(args).some(k => !keys.includes(k))) throw fail(400, 'Unsupported argument');
|
||||||
const clean = {};
|
const clean = {};
|
||||||
if(action==='sessions:save'){
|
if(action==='sessions:save'){
|
||||||
@@ -70,6 +70,7 @@ function validate(body) {
|
|||||||
}
|
}
|
||||||
// Never return adapter objects wholesale: session handles and sanitized text only.
|
// Never return adapter objects wholesale: session handles and sanitized text only.
|
||||||
function project(action, result) {
|
function project(action, result) {
|
||||||
|
if(action==='screenshot')return require('./agent-visual-export.cjs').validateVisualResult(result);
|
||||||
if(action.startsWith('sessions:')){
|
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;};
|
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)};
|
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;}
|
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(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 (!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');
|
if(!['legacy','individual'].includes(principalMode))throw new TypeError('Invalid principal mode');
|
||||||
const token = principalMode==='legacy'?crypto.randomBytes(32).toString('base64url'):undefined;
|
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();
|
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');
|
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 reply = (status, data) => { if (!res.destroyed && !res.writableEnded) { res.writeHead(status); res.end(JSON.stringify(data)); } };
|
||||||
const controller = new AbortController();
|
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 {
|
try {
|
||||||
let proof;
|
let proof;
|
||||||
if(principalMode==='individual'){
|
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');
|
if (typeof actions[action] !== 'function') throw fail(403, 'Action unavailable');
|
||||||
running.add(controller);if(principal)owners.set(controller,principal.id);
|
running.add(controller);if(principal)owners.set(controller,principal.id);
|
||||||
// Authorizers must be side-effect free: individual grants are checked at each release boundary.
|
// 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 operation = (async () => {
|
||||||
const grant = await traceOperation('agent.authorize',()=>authorize({ ...request,...(principal?{principalId:principal.id,generation:principal.generation}:{}), signal: controller.signal }));
|
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');
|
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' }));
|
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');
|
if (!enabled || (principal&&!principalLive(principal)) || controller.signal.aborted) throw fail(403, 'Agent access revoked');
|
||||||
await reauthorize();
|
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');
|
if (!enabled || (principal&&!principalLive(principal)) || controller.signal.aborted) throw fail(403, 'Agent access revoked');
|
||||||
await reauthorize();
|
await reauthorize();
|
||||||
outcome = 'completed';
|
outcome = 'completed';
|
||||||
await traceOperation('agent.audit',()=>audit({ ...(principal?{principalId:principal.id,generation:principal.generation}:{}),action, outcome,scope:request.scope,sessionId:request.sessionId }));
|
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');
|
if (!enabled || (principal&&!principalLive(principal)) || controller.signal.aborted) throw fail(403, 'Agent access revoked');
|
||||||
await reauthorize();
|
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) {
|
} catch (error) {
|
||||||
outcome=(error.status||500)>=500?'failed':'denied';
|
outcome=(error.status||500)>=500?'failed':'denied';
|
||||||
// Diagnostics expose only known static reasons, never arbitrary adapter errors.
|
// Diagnostics expose only known static reasons, never arbitrary adapter errors.
|
||||||
|
|||||||
@@ -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<end;i++)c=crcTable[(c^bytes[i])&255]^(c>>>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(offset<bytes.length){if(++chunks>4096||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};
|
||||||
@@ -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.left<innerWidth&&r.top<innerHeight;
|
||||||
|
};
|
||||||
|
function walk(root) {
|
||||||
|
state.watch(root);
|
||||||
|
for(const e of root.querySelectorAll('*')) {
|
||||||
|
if(++visited>20000){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};
|
||||||
@@ -12,6 +12,7 @@ const {OTLPLogExporter}=require('@opentelemetry/exporter-logs-otlp-http');
|
|||||||
const {trace,ROOT_CONTEXT,SpanStatusCode,TraceFlags}=require('@opentelemetry/api');
|
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 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']);
|
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']);
|
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);
|
for(const operation of ['history:list','history:go','window:state','window:control'])operations.add(operation);
|
||||||
names.add('browser.vault');names.add('browser.credential');
|
names.add('browser.vault');names.add('browser.credential');
|
||||||
|
|||||||
@@ -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});
|
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());
|
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 ['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()=>{
|
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};}
|
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);
|
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),
|
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`);},
|
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})=>{
|
authorize:({action,args,scope,principalId,generation})=>{
|
||||||
const actor={principalId,generation,action};
|
const actor={principalId,generation,action};
|
||||||
|
|||||||
@@ -52,4 +52,23 @@ function disposePageClipboardGuard(contents){
|
|||||||
owners.delete(contents);owner.healthy=false;owner.api.removeListener('detach',owner.detached);
|
owners.delete(contents);owner.healthy=false;owner.api.removeListener('detach',owner.detached);
|
||||||
if(owner.api.isAttached())owner.api.detach();
|
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};
|
||||||
@@ -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 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 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 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();});}
|
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);
|
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);}
|
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);}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
<section id="user-profiles" class="card"><p class="eyebrow">IDENTITY AND FEATURE ACCESS</p><h2>User classes and website access</h2><p>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.</p><button id="profiles-refresh" class="secondary">Refresh profiles</button><p id="profiles-status" role="status" aria-live="polite"></p><div class="table-wrap"><table><thead><tr><th>Profile</th><th>Assignments</th><th>Developer tools</th><th>Action</th></tr></thead><tbody id="profiles-rows"></tbody></table></div><div class="row"><button id="profile-new" class="secondary">Create your own profile</button><label>Starting class<select id="profile-template"><option value="employee">Employee</option><option value="developer">Developer</option><option value="high-protection">High protection</option></select></label><button id="profile-use-template" class="secondary">Use class in draft</button></div><h3 id="profile-editor-title">Create profile</h3><div class="grid"><label>Identifier<input id="profile-id" maxlength="80"></label><label>Name<input id="profile-name" maxlength="100"></label><label>Verified enterprise subject identifiers<textarea id="profile-subjects" placeholder="Exact issuer-verified subject; one per line"></textarea></label><fieldset><legend>Explicit device assignments</legend><div id="profile-devices" class="device-checklist"></div></fieldset><label>Allowed website hosts<textarea id="profile-allowed" placeholder="Empty adds no allowlist restriction"></textarea></label><label>Denied website hosts<textarea id="profile-blocked" placeholder="*.unapproved.example"></textarea></label><fieldset><legend>Denied domain categories</legend><div id="profile-categories" class="device-checklist"></div></fieldset><label>Developer tools / browser console<select id="profile-devtools"><option value="inherit">Inherit baseline</option><option value="deny">Deny</option><option value="allow">Allow within delegated baseline</option></select></label><label>Downloads<select id="profile-downloads"><option value="inherit">Inherit</option><option value="deny">Deny</option></select></label><label>AI workspace<select id="profile-ai"><option value="inherit">Inherit</option><option value="deny">Deny</option></select></label><label>Local agents<select id="profile-agents"><option value="inherit">Inherit</option><option value="deny">Deny</option></select></label><label class="checks"><input id="profile-scan" type="checkbox"> Require document analysis</label></div><p class="muted">Developer grants require central delegation; any applicable deny wins. No profile can override mandatory scanning or unlock centrally hidden settings.</p><div class="row"><button id="profile-save">Publish profile</button><button id="profile-delete" class="danger" disabled>Delete profile</button></div><h3>Preview effective device policy</h3><div class="row"><select id="profile-preview-device" aria-label="Preview device"></select><button id="profile-preview" class="secondary">Preview current draft</button></div><p id="profile-preview-summary" aria-live="polite"></p><div id="profile-preview-diff"></div><details><summary>Central categories and developer delegation</summary><p>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.</p><label class="checks"><input id="profile-delegation" type="checkbox"> Permit profiles to grant developer tools</label><h4>Website categories</h4><p>Use exact hosts or wildcard subdomains such as *.social.example. Changes stay in this local draft until explicit publication or independent approval.</p><button id="profile-category-add" class="secondary">Add website category</button><button id="profile-category-reset" class="secondary">Discard category draft</button><p id="profile-category-status" role="status" aria-live="polite"></p><div id="profile-category-editor"></div><button id="profile-config-save">Publish categories and delegation</button></details><h3>Assignment visibility</h3><div class="table-wrap"><table><thead><tr><th>Device</th><th>Verified subject</th><th>Effective profiles</th></tr></thead><tbody id="profile-device-rows"></tbody></table></div></section><section class="card" id="policy-groups"><p class="eyebrow">CONTROLLED DEPLOYMENT</p><h2>Groups and pilot rollouts</h2><p>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.</p><button id="groups-refresh" classLine truncated
|
<section id="user-profiles" class="card"><p class="eyebrow">IDENTITY AND FEATURE ACCESS</p><h2>User classes and website access</h2><p>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.</p><button id="profiles-refresh" class="secondary">Refresh profiles</button><p id="profiles-status" role="status" aria-live="polite"></p><div class="table-wrap"><table><thead><tr><th>Profile</th><th>Assignments</th><th>Developer tools</th><th>Action</th></tr></thead><tbody id="profiles-rows"></tbody></table></div><div class="row"><button id="profile-new" class="secondary">Create your own profile</button><label>Starting class<select id="profile-template"><option value="employee">Employee</option><option value="developer">Developer</option><option value="high-protection">High protection</option></select></label><button id="profile-use-template" class="secondary">Use class in draft</button></div><h3 id="profile-editor-title">Create profile</h3><div class="grid"><label>Identifier<input id="profile-id" maxlength="80"></label><label>Name<input id="profile-name" maxlength="100"></label><label>Verified enterprise subject identifiers<textarea id="profile-subjects" placeholder="Exact issuer-verified subject; one per line"></textarea></label><fieldset><legend>Explicit device assignments</legend><div id="profile-devices" class="device-checklist"></div></fieldset><label>Allowed website hosts<textarea id="profile-allowed" placeholder="Empty adds no allowlist restriction"></textarea></label><label>Denied website hosts<textarea id="profile-blocked" placeholder="*.unapproved.example"></textarea></label><fieldset><legend>Denied domain categories</legend><div id="profile-categories" class="device-checklist"></div></fieldset><label>Developer tools / browser console<select id="profile-devtools"><option value="inherit">Inherit baseline</option><option value="deny">Deny</option><option value="allow">Allow within delegated baseline</option></select></label><label>Downloads<select id="profile-downloads"><option value="inherit">Inherit</option><option value="deny">Deny</option></select></label><label>AI workspace<select id="profile-ai"><option value="inherit">Inherit</option><option value="deny">Deny</option></select></label><label>Local agents<select id="profile-agents"><option value="inherit">Inherit</option><option value="deny">Deny</option></select></label><label class="checks"><input id="profile-scan" type="checkbox"> Require document analysis</label></div><p class="muted">Developer grants require central delegation; any applicable deny wins. No profile can override mandatory scanning or unlock centrally hidden settings.</p><div class="row"><button id="profile-save">Publish profile</button><button id="profile-delete" class="danger" disabled>Delete profile</button></div><h3>Preview effective device policy</h3><div class="row"><select id="profile-preview-device" aria-label="Preview device"></select><button id="profile-preview" class="secondary">Preview current draft</button></div><p id="profile-preview-summary" aria-live="polite"></p><div id="profile-preview-diff"></div><details><summary>Central categories and developer delegation</summary><p>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.</p><label class="checks"><input id="profile-delegation" type="checkbox"> Permit profiles to grant developer tools</label><h4>Website categories</h4><p>Use exact hosts or wildcard subdomains such as *.social.example. Changes stay in this local draft until explicit publication or independent approval.</p><button id="profile-category-add" class="secondary">Add website category</button><button id="profile-category-reset" class="secondary">Discard category draft</button><p id="profile-category-status" role="status" aria-live="polite"></p><div id="profile-category-editor"></div><button id="profile-config-save">Publish categories and delegation</button></details><h3>Assignment visibility</h3><div class="table-wrap"><table><thead><tr><th>Device</th><th>Verified subject</th><th>Effective profiles</th></tr></thead><tbody id="profile-device-rows"></tbody></table></div></section><section class="card" id="policy-groups"><p class="eyebrow">CONTROLLED DEPLOYMENT</p><h2>Groups and pilot rollouts</h2><p>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.</p><button id="groups-refresh" classLine truncated
|
||||||
<section class="card" id="bookmarks"><p class="eyebrow">COMPANY LIBRARY</p><h2>Managed bookmarks</h2><p>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.</p><div class="checks"><label><input type="checkbox" id="bookmarks-add"> Allow personal additions</label><label><input type="checkbox" id="bookmarks-edit"> Allow personal edits</label><label><input type="checkbox" id="bookmarks-delete"> Allow personal deletion</label><label><input type="checkbox" id="bookmarks-admin-read"> Allow administrators to read and edit personal bookmarks</label></div><fieldset><legend>AI library organization</legend><div class="checks"><label><input id="library-ai-enabled" type="checkbox"> Enable approved AI organization suggestions</label><label><input id="library-ai-autoapply" type="checkbox"> Permit agent application without an additional user review</label></div><p class="muted">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.</p></fieldset><div class="table-wrap"><table><thead><tr><th>Title</th><th>HTTPS / HTTP URL (no query or fragment)</th><th>Folder</th><th>Actions</th></tr></thead><tbody id="managed-bookmark-rows"></tbody></table></div><button class="secondary" id="managed-bookmark-add">Add company bookmark</button><p class="muted">Company changes are drafts until you publish the signed policy. Personal permissions also respect the centrally locked or hidden Library section.</p><h3>Device personal bookmarks</h3><p>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.</p><div class="row"><select id="bookmark-device" aria-label="Device to review"></select><button id="bookmark-device-load" class="secondary">Read device bookmarks</button></div><p id="bookmark-device-status" role="status" aria-live="polite"></p><div id="bookmark-device-editor" hidden><div class="table-wrap"><table><thead><tr><th>Title</th><th>HTTPS / HTTP URL (no query or fragment)</th><th>Folder</th><th>Actions</th></tr></thead><tbody id="personal-bookmark-rows"></tbody></table></div><div class="row"><button id="personal-bookmark-add" class="secondary">Add personal bookmark</button><button id="bookmark-device-save">Save remote changes</button><button id="bookmark-device-refresh" class="secondary">Discard edits and refresh</button></div><p class="muted">Saving compares the loaded revision so another user's or administrator's updates cannot be overwritten silently.</p></div></section>
|
<section class="card" id="bookmarks"><p class="eyebrow">COMPANY LIBRARY</p><h2>Managed bookmarks</h2><p>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.</p><div class="checks"><label><input type="checkbox" id="bookmarks-add"> Allow personal additions</label><label><input type="checkbox" id="bookmarks-edit"> Allow personal edits</label><label><input type="checkbox" id="bookmarks-delete"> Allow personal deletion</label><label><input type="checkbox" id="bookmarks-admin-read"> Allow administrators to read and edit personal bookmarks</label></div><fieldset><legend>AI library organization</legend><div class="checks"><label><input id="library-ai-enabled" type="checkbox"> Enable approved AI organization suggestions</label><label><input id="library-ai-autoapply" type="checkbox"> Permit agent application without an additional user review</label></div><p class="muted">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.</p></fieldset><div class="table-wrap"><table><thead><tr><th>Title</th><th>HTTPS / HTTP URL (no query or fragment)</th><th>Folder</th><th>Actions</th></tr></thead><tbody id="managed-bookmark-rows"></tbody></table></div><button class="secondary" id="managed-bookmark-add">Add company bookmark</button><p class="muted">Company changes are drafts until you publish the signed policy. Personal permissions also respect the centrally locked or hidden Library section.</p><h3>Device personal bookmarks</h3><p>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.</p><div class="row"><select id="bookmark-device" aria-label="Device to review"></select><button id="bookmark-device-load" class="secondary">Read device bookmarks</button></div><p id="bookmark-device-status" role="status" aria-live="polite"></p><div id="bookmark-device-editor" hidden><div class="table-wrap"><table><thead><tr><th>Title</th><th>HTTPS / HTTP URL (no query or fragment)</th><th>Folder</th><th>Actions</th></tr></thead><tbody id="personal-bookmark-rows"></tbody></table></div><div class="row"><button id="personal-bookmark-add" class="secondary">Add personal bookmark</button><button id="bookmark-device-save">Save remote changes</button><button id="bookmark-device-refresh" class="secondary">Discard edits and refresh</button></div><p class="muted">Saving compares the loaded revision so another user's or administrator's updates cannot be overwritten silently.</p></div></section>
|
||||||
<section id="teaching" class="card"><p class="eyebrow">TEACH YOUR BROWSER</p><h2>Define what must stay inside</h2><p>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.</p><div class="row"><input id="rule-label" placeholder="Rule name"><input id="rule-value" placeholder="Confidential phrase"><button id="add-rule">Add phrase</button></div><label class="file">Register a confidential file fingerprint<input id="fingerprint" type="file"></label><div id="rules"></div><div class="grid"><label class="wide">Try a policy example<textarea id="test-text" placeholder="Test content is sent to your management server, never to an AI provider."></textarea></label></div><button id="test" class="secondary">Test draft policy</button><output id="test-result"></output><h2>Local semantic reference protection</h2><p>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.</p><div class="grid"><label>Category name<input id="semantic-label" placeholder="Unreleased product designs"></label><label>Similarity threshold<input id="semantic-threshold" type="number" min="0.000001" max="1" step="0.01" value="0.8"></label><label class="wide">Confidential category description<textarea id="semantic-description" placeholder="Describe the protected topic with representative examples."></textarea></label></div><button id="semantic-add" class="secondary">Create semantic reference</button><label>Internal semantic reference document (up to 1 MiB)<input id="semantic-document" type="file"></label><button id="semantic-document-add" class="secondary">Derive local document reference</button><p>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.</p><label class="checks"><input id="semantic-enabled" type="checkbox"> Enforce semantic categories</label><div id="semantic-rules"></div><p class="muted">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.</p></section>
|
<section id="teaching" class="card"><p class="eyebrow">TEACH YOUR BROWSER</p><h2>Define what must stay inside</h2><p>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.</p><div class="row"><input id="rule-label" placeholder="Rule name"><input id="rule-value" placeholder="Confidential phrase"><button id="add-rule">Add phrase</button></div><label class="file">Register a confidential file fingerprint<input id="fingerprint" type="file"></label><div id="rules"></div><div class="grid"><label class="wide">Try a policy example<textarea id="test-text" placeholder="Test content is sent to your management server, never to an AI provider."></textarea></label></div><button id="test" class="secondary">Test draft policy</button><output id="test-result"></output><h2>Local semantic reference protection</h2><p>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.</p><div class="grid"><label>Category name<input id="semantic-label" placeholder="Unreleased product designs"></label><label>Similarity threshold<input id="semantic-threshold" type="number" min="0.000001" max="1" step="0.01" value="0.8"></label><label class="wide">Confidential category description<textarea id="semantic-description" placeholder="Describe the protected topic with representative examples."></textarea></label></div><button id="semantic-add" class="secondary">Create semantic reference</button><label>Internal semantic reference document (up to 1 MiB)<input id="semantic-document" type="file"></label><button id="semantic-document-add" class="secondary">Derive local document reference</button><p>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.</p><label class="checks"><input id="semantic-enabled" type="checkbox"> Enforce semantic categories</label><div id="semantic-rules"></div><p class="muted">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.</p></section>
|
||||||
<section class="card" id="security-controls"><h2>Enterprise security and local agents</h2><p>Configure centrally signed DNS blocklists, YARA source rules, content phrases, restricted Sigma metadata detections and agent permissions. Unsupported Sigma syntax is rejected.</p><div class="grid"><label class="checks"><input id="security-dns-enabled" type="checkbox"> Enforce DNS blocklists</label><label>Blocked DNS hosts<textarea id="security-dns-hosts" placeholder="*.unapproved.example"></textarea></label><label>Blocked IP addresses<textarea id="security-dns-ips" placeholder="203.0.113.10"></textarea></label><label>Blocked content phrases<textarea id="security-content-phrases" placeholder="One phrase per line"></textarea></label><label class="checks"><input id="security-yara-enabled" type="checkbox"> Require local YARA scanning</label><label>YARA maximum scanned bytes<input id="security-yara-max" type="number" min="1024" max="20971520"></label><label class="checks"><input id="agent-enabled" type="checkbox"> Enable paired local agents</label><label>Agent destination allowlist<textarea id="agent-hosts" placeholder="*.company.example"></textarea></label><label class="checks"><input id="agent-sensitive" type="checkbox"> Permit sensitive page reads within approved scopes</label><fieldset class="wide"><legend>Agent capabilities</legend><div class="checks"><label><input id="agent-scope-sessions" type="checkbox"> Sessions</label><label><input id="agent-scope-navigate" type="checkbox"> Navigate</label><label><input id="agent-scope-back" type="checkbox"> History back</label><label><input id="agent-scope-forward" type="checkbox"> History forward</label><label><input id="agent-scope-reload" type="checkbox"> Reload page</label><label><input id="agent-scope-read" type="checkbox"> Read</label><label><input id="agent-scope-inspect" type="checkbox"> Inspect page controls and values</label><label><input id="agent-scope-scroll" type="checkbox"> Scroll pages</label><label><input id="agent-scope-history:list" type="checkbox"> Read managed browsing history</label><label><input id="agent-scope-history:go" type="checkbox"> Navigate to a history entry</label><label><input id="agent-scope-window:state" type="checkbox"> Read browser window state</label><label><input id="agent-scope-window:control" type="checkbox"> Control browser window</label><label><input id="agent-scope-sessions:save" type="checkbox"> Save managed tab sessions</label><label><input id="agent-scope-sessions:list" type="checkbox"> List saved session references</label><label><input id="agent-scope-sessions:restore" type="checkbox"> Restore an approved saved session</label><label><input id="agent-scope-vault:status" type="checkbox"> Read password vault status</label><label><input id="agent-scope-vault:list" type="checkbox"> List masked references for approved origins</label><label><input id="agent-scope-vault:fill" type="checkbox"> Fill a selected credential on its exact origin</label><label><input id="agent-scope-vault:prepare-submit" type="checkbox"> Fill and authorize one approved login POST (human-unlocked vault)</label><label><input id="agent-scope-click" type="checkbox"> Click</label><label><input id="agent-scope-type" type="checkbox"> Type</label><label><input id="agent-scope-select" type="checkbox"> Select approved form option</label><label><input id="agent-scope-check" type="checkbox"> Set checkbox state</label><label><input id="agent-scope-screenshot" type="checkbox"> Screenshot</label><label><input id="agent-scope-ai" type="checkbox"> AI gateway</label><label><input id="agent-scope-tabs:close" type="checkbox"> Close tabs</label><label><input id="agent-scope-bookmarks:list" type="checkbox"> Read bookmarks</label><label><input id="agent-scope-tabs:group" type="checkbox"> Group tabs</label><label><input id="agent-scope-tabs:activate" type="checkbox"> Activate tabs</label><label><input id="agent-scope-bookmarks:mutate" type="checkbox"> Organize user bookmarks</label><label><input id="agent-scope-application:state" type="checkbox"> Application state</label><label><input id="agent-scope-tabs:create" type="checkbox"> Create native tabs</label><label><input id="agent-scope-settings:get" type="checkbox"> Read managed settings</label><label><input id="agent-scope-settings:update" type="checkbox"> Change permitted appearance</label><label><input id="agent-scope-library:organize-plan" type="checkbox"> Propose AI library organization</label><label><input id="agent-scope-library:organize-apply" type="checkbox"> Apply approved library organization</label></div></fieldset><p class="wide" id="agent-navigation-window-help">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.</p><p class="wide" id="agent-sesLine truncated
|
<section class="card" id="security-controls"><h2>Enterprise security and local agents</h2><p>Configure centrally signed DNS blocklists, YARA source rules, content phrases, restricted Sigma metadata detections and agent permissions. Unsupported Sigma syntax is rejected.</p><div class="grid"><label class="checks"><input id="security-dns-enabled" type="checkbox"> Enforce DNS blocklists</label><label>Blocked DNS hosts<textarea id="security-dns-hosts" placeholder="*.unapproved.example"></textarea></label><label>Blocked IP addresses<textarea id="security-dns-ips" placeholder="203.0.113.10"></textarea></label><label>Blocked content phrases<textarea id="security-content-phrases" placeholder="One phrase per line"></textarea></label><label class="checks"><input id="security-yara-enabled" type="checkbox"> Require local YARA scanning</label><label>YARA maximum scanned bytes<input id="security-yara-max" type="number" min="1024" max="20971520"></label><label class="checks"><input id="agent-enabled" type="checkbox"> Enable paired local agents</label><label>Agent destination allowlist<textarea id="agent-hosts" placeholder="*.company.example"></textarea></label><label class="checks"><input id="agent-sensitive" type="checkbox"> Permit sensitive page reads within approved scopes</label><fieldset class="wide"><legend>Agent capabilities</legend><div class="checks"><label><input id="agent-scope-sessions" type="checkbox"> Sessions</label><label><input id="agent-scope-navigate" type="checkbox"> Navigate</label><label><input id="agent-scope-back" type="checkbox"> History back</label><label><input id="agent-scope-forward" type="checkbox"> History forward</label><label><input id="agent-scope-reload" type="checkbox"> Reload page</label><label><input id="agent-scope-read" type="checkbox"> Read</label><label><input id="agent-scope-inspect" type="checkbox"> Inspect page controls and values</label><label><input id="agent-scope-scroll" type="checkbox"> Scroll pages</label><label><input id="agent-scope-history:list" type="checkbox"> Read managed browsing history</label><label><input id="agent-scope-history:go" type="checkbox"> Navigate to a history entry</label><label><input id="agent-scope-window:state" type="checkbox"> Read browser window state</label><label><input id="agent-scope-window:control" type="checkbox"> Control browser window</label><label><input id="agent-scope-sessions:save" type="checkbox"> Save managed tab sessions</label><label><input id="agent-scope-sessions:list" type="checkbox"> List saved session references</label><label><input id="agent-scope-sessions:restore" type="checkbox"> Restore an approved saved session</label><label><input id="agent-scope-vault:status" type="checkbox"> Read password vault status</label><label><input id="agent-scope-vault:list" type="checkbox"> List masked references for approved origins</label><label><input id="agent-scope-vault:fill" type="checkbox"> Fill a selected credential on its exact origin</label><label><input id="agent-scope-vault:prepare-submit" type="checkbox"> Fill and authorize one approved login POST (human-unlocked vault)</label><label><input id="agent-scope-click" type="checkbox"> Click</label><label><input id="agent-scope-type" type="checkbox"> Type</label><label><input id="agent-scope-select" type="checkbox"> Select approved form option</label><label><input id="agent-scope-check" type="checkbox"> Set checkbox state</label><label><input id="agent-scope-screenshot" type="checkbox"> Export inspected foreground page viewport</label><label><input id="agent-scope-ai" type="checkbox"> AI gateway</label><label><input id="agent-scope-tabs:close" type="checkbox"> Close tabs</label><label><input id="agent-scope-bookmarks:list" type="checkbox"> Read bookmarks</label><label><input id="agent-scope-tabs:group" type="checkbox"> Group tabs</label><label><input id="agent-scope-tabs:activate" type="checkbox"> Activate tabs</label><label><input id="agent-scope-bookmarks:mutate" type="checkbox"> Organize user bookmarks</label><label><input id="agent-scope-application:state" type="checkbox"> Application state</label><label><input id="agent-scope-tabs:create" type="checkbox"> Create native tabs</label><label><input id="agent-scope-settings:get" type="checkbox"> Read managed settings</label><label><input id="agent-scope-settings:update" type="checkbox"> Change permitted appearance</label><label><input id="agent-scope-library:organize-plan" type="checkbox"> Propose AI library organization</label><label><input id="agent-scope-library:organize-apply" type="checkbox"> Apply approved library organization</label></div></fieldset><p class="wide" id="agent-navigation-window-help">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.</Line truncated
|
||||||
<section class="card" id="document-teaching"><h2>Governed document teaching</h2><p>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.</p><div class="row"><input id="teach-label" placeholder="Confidential reference label"><input id="teach-file" type="file"><button id="teach-submit">Create reviewed draft</button></div><div id="teach-drafts"></div></section><section class="card" id="password-vault"><h2>Local password vault</h2><p>Credentials stay on the browser device. Management stores permissions and lifecycle audit events only; passwords, usernames and credential identifiers are not uploaded.</p><div class="checks"><label><input id="vault-enabled" type="checkbox"> Enable password vault</label><label><input id="vault-saving" type="checkbox"> Allow saving credentials</label><label><input id="vault-autofill" type="checkbox"> Allow autofill</label></div><fieldset><legend>Controlled credential submission</legend><label class="checks"><input id="vault-controlled-submit" type="checkbox"> Allow one approved vault login POST</label><p id="vault-controlled-submit-help">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.</p><label>Approved login POST rules (metadata only, maximum 32)<textarea id="vault-submit-forms" rows="9" maxlength="150000" spellcheck="false" placeholder='[{"id":"company-login","actionUrl":"https://login.company.example/signin","usernameField":"username","passwordField":"password"}]'></textarea></label><p>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.</p></fieldset><label>Idle lock in seconds (60–900)<input id="vault-idle" type="number" min="60" max="900"></label><p>Publish signed policy to apply these controls.</p></section><section class="card" id="visual-protection"><h2>Visual impersonation protection</h2><label class="checks"><input id="visual-enabled" type="checkbox"> Block protected login designs on unauthorized origins</label><p>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.</p><p>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.</p><div class="grid"><label>Brand identifier<input id="visual-id" maxlength="80" placeholder="corporate-login"></label><label>Brand label<input id="visual-label" maxlength="100" placeholder="Corporate login"></label><label class="wide">Exact allowed HTTPS origins<textarea id="visual-origins" placeholder="https://login.company.example"></textarea></label><label>Reference scope<select id="visual-scope"><option value="viewport">Whole viewport</option><option value="login-form">Cropped login form</option></select></label><label>Trusted PNG reference<input id="visual-image" type="file" accept="image/png"></label></div><button id="visual-add">Derive reference into draft</button><p>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.</p></section><section class="card" id="clipboard-controls"><h2>Managed clipboard and ClickFix protection</h2><p>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.</p><div class="checks"><label><input id="clipboard-enabled" type="checkbox"> Enable clipboard controls</label><label><input id="clipboard-copy" type="checkbox"> Allow copy</lLine truncated
|
<section class="card" id="document-teaching"><h2>Governed document teaching</h2><p>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.</p><div class="row"><input id="teach-label" placeholder="Confidential reference label"><input id="teach-file" type="file"><button id="teach-submit">Create reviewed draft</button></div><div id="teach-drafts"></div></section><section class="card" id="password-vault"><h2>Local password vault</h2><p>Credentials stay on the browser device. Management stores permissions and lifecycle audit events only; passwords, usernames and credential identifiers are not uploaded.</p><div class="checks"><label><input id="vault-enabled" type="checkbox"> Enable password vault</label><label><input id="vault-saving" type="checkbox"> Allow saving credentials</label><label><input id="vault-autofill" type="checkbox"> Allow autofill</label></div><fieldset><legend>Controlled credential submission</legend><label class="checks"><input id="vault-controlled-submit" type="checkbox"> Allow one approved vault login POST</label><p id="vault-controlled-submit-help">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.</p><label>Approved login POST rules (metadata only, maximum 32)<textarea id="vault-submit-forms" rows="9" maxlength="150000" spellcheck="false" placeholder='[{"id":"company-login","actionUrl":"https://login.company.example/signin","usernameField":"username","passwordField":"password"}]'></textarea></label><p>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.</p></fieldset><label>Idle lock in seconds (60–900)<input id="vault-idle" type="number" min="60" max="900"></label><p>Publish signed policy to apply these controls.</p></section><section class="card" id="visual-protection"><h2>Visual impersonation protection</h2><label class="checks"><input id="visual-enabled" type="checkbox"> Block protected login designs on unauthorized origins</label><p>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.</p><p>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.</p><div class="grid"><label>Brand identifier<input id="visual-id" maxlength="80" placeholder="corporate-login"></label><label>Brand label<input id="visual-label" maxlength="100" placeholder="Corporate login"></label><label class="wide">Exact allowed HTTPS origins<textarea id="visual-origins" placeholder="https://login.company.example"></textarea></label><label>Reference scope<select id="visual-scope"><option value="viewport">Whole viewport</option><option value="login-form">Cropped login form</option></select></label><label>Trusted PNG reference<input id="visual-image" type="file" accept="image/png"></label></div><button id="visual-add">Derive reference into draft</button><p>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.</p></section><section class="card" id="clipboard-controls"><h2>Managed clipboard and ClickFix protection</h2><p>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.</p><div class="checks"><label><input id="clipboard-enabled" type="checkbox"> Enable clipboard controls</label><label><input id="clipboard-copy" type="checkbox"> Allow copy</lLine truncated
|
||||||
<section id="access-sessions" class="card"><p class="eyebrow">ADMINISTRATOR ACCESS</p><h2>Active management sessions</h2><p>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.</p><button id="sessions-load" class="secondary">Refresh active sessions</button><p id="sessions-status" role="status" aria-live="polite">Session inventory requires the administrator role.</p><div class="table-wrap"><table><thead><tr><th>Identity</th><th>Role</th><th>Expires</th><th>Action</th></tr></thead><tbody id="session-rows"></tbody></table></div></section><section id="incidents" class="card"><p class="eyebrow">SECURITY OPERATIONS</p><h2>Incident triage</h2><p>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.</p><div class="row filter-row"><label>Status<select id="incident-status-filter"><option value="">All statuses</option><option>open</option><option>investigating</option><option>resolved</option><option>dismissed</option></select></label><label>Severity<select id="incident-severity-filter"><option value="">All severities</option><option>low</option><option>medium</option><option>high</option><option>critical</option></select></label><button id="incidents-refresh" class="secondary">Refresh incidents</button></div><p id="incidents-status" role="status" aria-live="polite"></p><div class="table-wrap"><table><thead><tr><th>Created</th><th>Security event</th><th>Status</th><th>Severity</th><th>Assignee</th><th>Review</th></tr></thead><tbody id="incident-rows"></tbody></table></div><button id="incidents-more" class="secondary" hidden>Load older incidents</button><div id="incident-detail" hidden><div class="section-head"><h3 id="incident-title">Incident</h3><button id="incident-detail-refresh" class="secondary">Discard edits and refresh</button></div><p id="incident-revision" class="muted"></p><pre id="incident-event"></pre><section id="incident-evidence" class="evidence-viewer" hidden aria-labelledby="evidence-title"><div class="section-head"><h3 id="evidence-title">Device-reported protected evidence</h3><button id="evidence-close" class="secondary">Close and clear</button></div><label>Access purpose<select id="evidence-reason"><option value="">Choose a purpose</option><option value="incident-response">Incident response</option><option value="investigation">Investigation</option><option value="compliance">Compliance</option></select></label><div id="evidence-rows"></div><p id="evidence-status" role="status" aria-live="polite"></p><pre id="evidence-plaintext" hidden></pre></section><div class="grid"><label>Investigation status<select id="incident-state"><option>open</option><option>investigating</option><option>resolved</option><option>dismissed</option></select></label><label>Severity<select id="incident-severity"><option>low</option><option>medium</option><option>high</option><option>critical</option></select></label><label>Assignee<input id="incident-assignee" maxlength="160" placeholder="Team or analyst identity"></label><label class="wide">Investigation note<textarea id="incident-comment" maxlength="1000" placeholder="Metadata and investigation outcome only; do not paste secrets."></textarea></label></div><button id="incident-save">Save investigation update</button><p id="incident-detail-status" role="status" aria-live="polite"></p><h3>Investigation history</h3><div id="incident-history"></div></div></section><section class="card" id="audit-retention"><p class="eyebrow">AUDIT LIFECYCLE</p><h2>Database event retention</h2><p id="audit-retention-status" role="status">Loading deployment configuration.</p><p class="muted">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.</p></section><section id="events" class="card"><p class="eyebrow">SECURITY MONITORING</p><h2>Recent audit events</h2><div class="row"><button id="export-ecs" class="secondary">Export ECS JSON</button><button id="export-cef" class="secondary">Export CEF</button></div><div class="row filter-row"><label>Find an event<input id="event-filter" type="search" placeholder="Event type, device or metadata"></label><label>Decision<select id="event-decision-filter"><option value="all">All events</option><option value="block">Blocks</option><option value="allow">Allows</option></select></label><output id="event-filter-count" aria-live="polite"></output></div><div class="table-wrap"><table><thead><tr><th>Time</th><th>Event</th><th>Device</th><th>Metadata</th></tr></thead><tbody id="event-rows"></tbody></table></div><p class="muted">This view shows the latest 100 eventsLine truncated
|
<section id="access-sessions" class="card"><p class="eyebrow">ADMINISTRATOR ACCESS</p><h2>Active management sessions</h2><p>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.</p><button id="sessions-load" class="secondary">Refresh active sessions</button><p id="sessions-status" role="status" aria-live="polite">Session inventory requires the administrator role.</p><div class="table-wrap"><table><thead><tr><th>Identity</th><th>Role</th><th>Expires</th><th>Action</th></tr></thead><tbody id="session-rows"></tbody></table></div></section><section id="incidents" class="card"><p class="eyebrow">SECURITY OPERATIONS</p><h2>Incident triage</h2><p>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.</p><div class="row filter-row"><label>Status<select id="incident-status-filter"><option value="">All statuses</option><option>open</option><option>investigating</option><option>resolved</option><option>dismissed</option></select></label><label>Severity<select id="incident-severity-filter"><option value="">All severities</option><option>low</option><option>medium</option><option>high</option><option>critical</option></select></label><button id="incidents-refresh" class="secondary">Refresh incidents</button></div><p id="incidents-status" role="status" aria-live="polite"></p><div class="table-wrap"><table><thead><tr><th>Created</th><th>Security event</th><th>Status</th><th>Severity</th><th>Assignee</th><th>Review</th></tr></thead><tbody id="incident-rows"></tbody></table></div><button id="incidents-more" class="secondary" hidden>Load older incidents</button><div id="incident-detail" hidden><div class="section-head"><h3 id="incident-title">Incident</h3><button id="incident-detail-refresh" class="secondary">Discard edits and refresh</button></div><p id="incident-revision" class="muted"></p><pre id="incident-event"></pre><section id="incident-evidence" class="evidence-viewer" hidden aria-labelledby="evidence-title"><div class="section-head"><h3 id="evidence-title">Device-reported protected evidence</h3><button id="evidence-close" class="secondary">Close and clear</button></div><label>Access purpose<select id="evidence-reason"><option value="">Choose a purpose</option><option value="incident-response">Incident response</option><option value="investigation">Investigation</option><option value="compliance">Compliance</option></select></label><div id="evidence-rows"></div><p id="evidence-status" role="status" aria-live="polite"></p><pre id="evidence-plaintext" hidden></pre></section><div class="grid"><label>Investigation status<select id="incident-state"><option>open</option><option>investigating</option><option>resolved</option><option>dismissed</option></select></label><label>Severity<select id="incident-severity"><option>low</option><option>medium</option><option>high</option><option>critical</option></select></label><label>Assignee<input id="incident-assignee" maxlength="160" placeholder="Team or analyst identity"></label><label class="wide">Investigation note<textarea id="incident-comment" maxlength="1000" placeholder="Metadata and investigation outcome only; do not paste secrets."></textarea></label></div><button id="incident-save">Save investigation update</button><p id="incident-detail-status" role="status" aria-live="polite"></p><h3>Investigation history</h3><div id="incident-history"></div></div></section><section class="card" id="audit-retention"><p class="eyebrow">AUDIT LIFECYCLE</p><h2>Database event retention</h2><p id="audit-retention-status" role="status">Loading deployment configuration.</p><p class="muted">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.</p></section><section id="events" class="card"><p class="eyebrow">SECURITY MONITORING</p><h2>Recent audit events</h2><div class="row"><button id="export-ecs" class="secondary">Export ECS JSON</button><button id="export-cef" class="secondary">Export CEF</button></div><div class="row filter-row"><label>Find an event<input id="event-filter" type="search" placeholder="Event type, device or metadata"></label><label>Decision<select id="event-decision-filter"><option value="all">All events</option><option value="block">Blocks</option><option value="allow">Allows</option></select></label><output id="event-filter-count" aria-live="polite"></output></div><div class="table-wrap"><table><thead><tr><th>Time</th><th>Event</th><th>Device</th><th>Metadata</th></tr></thead><tbody id="event-rows"></tbody></table></div><p class="muted">This view shows the latest 100 eventsLine truncated
|
||||||
<section class="card" id="company-branding"><p class="eyebrow">COMPANY EXPERIENCE</p><h2>Company logo and start page</h2><p>Roll out your company identity through signed policy. Name and accent above apply alongside this logo.</p><div class="grid"><label>Company logo (PNG, up to 128 KiB)<input id="brand-logo-file" type="file" accept="image/png"></label><div><img id="brand-logo-preview" alt="Company logo preview" width="160" height="80" hidden><button id="brand-logo-remove" type="button" class="secondary">Remove logo</button><output id="brand-logo-status" aria-live="polite"></output></div><label>Start-page heading<input id="startpage-title" maxlength="100" placeholder="Welcome"></label><label>Start-page message<input id="startpage-subtitle" maxlength="240" placeholder="Your company workspace"></label><label><input id="startpage-news-enabled" type="checkbox"> Show company news</label><label>Company RSS / Atom feed (HTTPS)<input id="startpage-feed-url" type="url" placeholder="https://intranet.company.example/news.xml"></label></div><p>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.</p></section><section class="card" id="download-scan-controls"><h2>Download inspection</h2><div class="checks"><label><input id="download-document-dlp" type="checkbox"> Require complete document DLP for incoming downloads</label><label><input id="yara-office-macros" type="checkbox"> Scan extracted Office VBA with enabled central YARA rules</label></div><p>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.</p></section><section id="audit-integrity" class="card" hidden><p class="eyebrow">AUDIT INTEGRITY</p><div class="section-head"><h2>Verify security audit records</h2><button id="audit-integrity-verify" type="button" class="secondary">Verify stored records</button></div><p>Run an explicit consistency and signature check. This check is separate from OpenTelemetry traces and SIEM delivery.</p><p id="audit-integrity-status" class="muted" aria-live="polite">Not verified in this session.</p><div id="audit-integrity-details" style="overflow-wrap:anywhere"></div></section><section id="evidence-policy-controls" class="card"><p class="eyebrow">PROTECTED SECURITY EVIDENCE</p><h2>Protected evidence collection</h2><p>Opt in through centrally signed policy. All collection controls are off by default. Ordinary security audit records and OpenTelemetry traces remain separate.</p><div class="checks"><label><input id="evidence-policy-enabled" type="checkbox"> Enable protected evidence recording</label><label><input id="evidence-policy-downloads" type="checkbox"> Automatically capture inspected download metadata</label><label><input id="evidence-policy-interactions" type="checkbox"> Capture clipboard, agent-click and observed page-gesture metadata</label></div><p>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.</p><p>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.</p></section><section id="diagnostics-controls" class="card"><p class="eyebrow">OBSERVABILITY</p><h2>Browser OpenTelemetry</h2><p>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.</p><div class="grid"><label><input id="diagnostics-enabled" type="checkbox"> Enable browser diaLine truncated
|
<section class="card" id="company-branding"><p class="eyebrow">COMPANY EXPERIENCE</p><h2>Company logo and start page</h2><p>Roll out your company identity through signed policy. Name and accent above apply alongside this logo.</p><div class="grid"><label>Company logo (PNG, up to 128 KiB)<input id="brand-logo-file" type="file" accept="image/png"></label><div><img id="brand-logo-preview" alt="Company logo preview" width="160" height="80" hidden><button id="brand-logo-remove" type="button" class="secondary">Remove logo</button><output id="brand-logo-status" aria-live="polite"></output></div><label>Start-page heading<input id="startpage-title" maxlength="100" placeholder="Welcome"></label><label>Start-page message<input id="startpage-subtitle" maxlength="240" placeholder="Your company workspace"></label><label><input id="startpage-news-enabled" type="checkbox"> Show company news</label><label>Company RSS / Atom feed (HTTPS)<input id="startpage-feed-url" type="url" placeholder="https://intranet.company.example/news.xml"></label></div><p>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.</p></section><section class="card" id="download-scan-controls"><h2>Download inspection</h2><div class="checks"><label><input id="download-document-dlp" type="checkbox"> Require complete document DLP for incoming downloads</label><label><input id="yara-office-macros" type="checkbox"> Scan extracted Office VBA with enabled central YARA rules</label></div><p>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.</p></section><section id="audit-integrity" class="card" hidden><p class="eyebrow">AUDIT INTEGRITY</p><div class="section-head"><h2>Verify security audit records</h2><button id="audit-integrity-verify" type="button" class="secondary">Verify stored records</button></div><p>Run an explicit consistency and signature check. This check is separate from OpenTelemetry traces and SIEM delivery.</p><p id="audit-integrity-status" class="muted" aria-live="polite">Not verified in this session.</p><div id="audit-integrity-details" style="overflow-wrap:anywhere"></div></section><section id="evidence-policy-controls" class="card"><p class="eyebrow">PROTECTED SECURITY EVIDENCE</p><h2>Protected evidence collection</h2><p>Opt in through centrally signed policy. All collection controls are off by default. Ordinary security audit records and OpenTelemetry traces remain separate.</p><div class="checks"><label><input id="evidence-policy-enabled" type="checkbox"> Enable protected evidence recording</label><label><input id="evidence-policy-downloads" type="checkbox"> Automatically capture inspected download metadata</label><label><input id="evidence-policy-interactions" type="checkbox"> Capture clipboard, agent-click and observed page-gesture metadata</label></div><p>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.</p><p>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.</p></section><section id="diagnostics-controls" class="card"><p class="eyebrow">OBSERVABILITY</p><h2>Browser OpenTelemetry</h2><p>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.</p><div class="grid"><label><input id="diagnostics-enabled" type="checkbox"> Enable browser diaLine truncated
|
||||||
|
|||||||
@@ -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":"<canonical base64 PNG>"}
|
||||||
|
```
|
||||||
|
|
||||||
|
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.
|
||||||
@@ -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`
|
collector rejected writes remain zero. Evidence: `artifacts/controlled-login-native-proof.json`
|
||||||
and `artifacts/controlled-login-receiver-proof-native.log`. Earlier native failures
|
and `artifacts/controlled-login-receiver-proof-native.log`. Earlier native failures
|
||||||
and the prior successful proof remain distinct local evidence.
|
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.
|
||||||
@@ -3,8 +3,8 @@
|
|||||||
## Aktuelle nächste Abnahmen
|
## Aktuelle nächste Abnahmen
|
||||||
|
|
||||||
Gespeicherte Agent-Sitzungen und kontrollierte Credential-Vorbereitung sind für
|
Gespeicherte Agent-Sitzungen und kontrollierte Credential-Vorbereitung sind für
|
||||||
ihre dokumentierten nativen Pfade integriert und nachgewiesen. Der aktuelle
|
ihre dokumentierten nativen Pfade integriert und nachgewiesen. Die vorangegangene
|
||||||
Abgleich umfasst 668 bestandene Tests, Syntaxprüfung, stabilen Windows-Build,
|
Controlled-Login-Abnahme umfasste 668 bestandene Tests, Syntaxprüfung, stabilen Windows-Build,
|
||||||
exakten Paket-/Runtime-Vergleich und den abschließenden Receiver-/Trace-Proof
|
exakten Paket-/Runtime-Vergleich und den abschließenden Receiver-/Trace-Proof
|
||||||
(`artifacts/controlled-login-final-suite.log`,
|
(`artifacts/controlled-login-final-suite.log`,
|
||||||
`artifacts/controlled-login-final-syntax.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-final-package-proof.log`,
|
||||||
`artifacts/controlled-login-native-proof.json`). Das Executable ist unsigniert.
|
`artifacts/controlled-login-native-proof.json`). Das Executable ist unsigniert.
|
||||||
|
|
||||||
Nächste Implementierungsabnahmen: sicher geprüfter visueller Agent-Export,
|
Der aktuelle begrenzte Visual-Export-Increment besteht mit 695 Tests, Syntax und
|
||||||
beweisbare Actor-Ownership für Hintergrund-Worker, breitere lokale Medienprüfung,
|
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.
|
vollständige Analysten-Inhaltsbelege und weitergehende Datei-/Admin-Steuerung.
|
||||||
Bereits vorhanden sind begrenzte geschützte Browser-Metadaten und eine
|
Bereits vorhanden sind begrenzte geschützte Browser-Metadaten und eine
|
||||||
zweckgebundene Analystenansicht; vollständige Aufzeichnung oder Replay bleiben
|
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. |
|
| 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. |
|
| 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. |
|
| 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
|
beweisen ihre Szenarien; sie schließen weder Worker-Ownership noch allgemeine
|
||||||
JavaScript-Logins, Passwort-Isolation, vollständige Exfiltrationsabdeckung oder
|
JavaScript-Logins, Passwort-Isolation, vollständige Exfiltrationsabdeckung oder
|
||||||
die ursprünglichen Produktions-/Kundenabnahmen. Das Gesamtziel bleibt offen.
|
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.
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
const {z}=require('zod');
|
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 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)=>{
|
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((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'});
|
if(AGENT_SCOPES[value.action]!==value.scope)ctx.addIssue({code:'custom',message:'Agent action scope mismatch'});
|
||||||
|
|||||||
@@ -1,21 +1,22 @@
|
|||||||
'use strict';
|
'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);
|
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 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,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 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 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 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());}
|
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()=>{
|
app.whenReady().then(async()=>{
|
||||||
window=new BrowserWindow({show:false,width:1500,height:1100,webPreferences:{nodeIntegration:false,contextIsolation:true,sandbox:true,offscreen:true,backgroundThrottling:false}});
|
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'}});});
|
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);
|
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);
|
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 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'])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 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');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');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');
|
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);
|
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);});
|
||||||
@@ -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 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;
|
const sdk=startTelemetry(),require=createRequire(import.meta.url),directory=mkdtempSync(join(tmpdir(),'safetynet-agent-grant-admin-'));let store,server,child;
|
||||||
try {
|
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 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;
|
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);
|
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');
|
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});}
|
} 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});}
|
||||||
@@ -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);}});
|
||||||
@@ -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='<!doctype html><meta name="viewport" content="width=device-width"><style>body{margin:0;background:white;color:#123;font:28px Arial}h1{margin:70px}canvas{display:block}</style><title>Public visual fixture</title>';if(req.url==='/canvas')res.end(base+'<canvas width="1200" height="360"></canvas><script>const c=document.querySelector("canvas").getContext("2d");c.fillStyle="white";c.fillRect(0,0,1200,360);c.fillStyle="black";c.font="bold 48px Arial";c.fillText("CLASSIFIED ORCHID DOCUMENT",40,170);</script>');else if(req.url==='/sensitive')res.end(base+'<h1>Public form</h1><input type="password" value="synthetic-hidden-field" style="position:absolute;left:-9999px"><script>document.querySelectorAll=()=>[]</script>');else if(req.url==='/closed-shadow')res.end(base+`<div id=host></div><script>document.getElementById("host").attachShadow({mode:"closed"}).innerHTML='<input type="password" value="synthetic-closed-shadow-password">';document.querySelectorAll=()=>[]</script>`);else if(req.url==='/open-shadow')res.end(base+`<div id=host></div><script>document.getElementById("host").attachShadow({mode:"open"}).innerHTML='<p style="margin:70px;font:28px Arial">Public shadow content</p>'</script>`);else if(req.url==='/frames')res.end(base+'<h1>Public parent</h1><iframe src="/public"></iframe>');else res.end(base+'<h1>Public approved visual page</h1><p style="margin:70px">Research and documentation workspace.</p>');}).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);
|
||||||
@@ -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}`);
|
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(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');
|
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/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']){
|
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}`);
|
assert.deepEqual(extractFile(path.join(root,'resources/app.asar'),path.normalize(file)),await fs.readFile(file),`Packaged source is stale: ${file}`);
|
||||||
|
|||||||
@@ -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 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'"));
|
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(/<input[^>]*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'));});
|
||||||
@@ -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));});
|
||||||
@@ -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));});
|
||||||
@@ -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);});
|
||||||
@@ -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);}});
|
||||||
Reference in new issue
Block a user