diff --git a/AGENT-API.md b/AGENT-API.md
index 111222a..5ca978e 100644
--- a/AGENT-API.md
+++ b/AGENT-API.md
@@ -33,6 +33,9 @@ Use `agent-transport-client.cjs` or the trusted Electron helper. The generated n
| Action | Arguments | Policy scope | Internal scope | Result |
|---|---|---|---|---|
| sessions | none | sessions | sessions:list | `[{id,title,foreground}]` |
+| sessions:save | name, sessionIds, optional id and expectedRevision | sessions:save | sessions:save | owned collection ID, revision, tabCount |
+| sessions:list | none | sessions:list | sessions:list | revision and DLP-inspected owned collections |
+| sessions:restore | id OR planId | sessions:restore | sessions:restore | one-use plan, then restored native tab IDs |
| navigate | sessionId, url | navigate | browser:navigate | `{completed}` |
| back | sessionId | back | browser:navigate | `{completed}` |
| forward | sessionId | forward | browser:navigate | `{completed}` |
diff --git a/README.md b/README.md
index 4613ebf..7bc0a85 100644
--- a/README.md
+++ b/README.md
@@ -38,7 +38,7 @@ S Browser uses translucent materials, rounded controls, light/dark themes, and a
| 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; protected-brand visual similarity checks paired with trusted HTTPS origins. These features require further independent evaluation. |
-Individual agent mode currently blocks browser-session requests without a known guarded tab owner, including unattributed background-worker traffic. Reliable agent ownership for these requests remains open. See [individual agent administration](docs/individual-agent-grants.md) and [agent API](AGENT-API.md).
+Individual agent mode currently blocks browser-session requests without a known guarded tab owner, including unattributed background-worker traffic. Reliable agent ownership for these requests remains open. See [individual agent administration](docs/individual-agent-grants.md) and [agent API](AGENT-API.md). Separately approved [saved sessions](docs/agent-session-controls.md) retain locally encrypted URL/title/group collections; restoration rechecks current policy and creates managed tabs without saving form or authentication state.
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.
diff --git a/apps/browser/agent-control.cjs b/apps/browser/agent-control.cjs
index 2851ee8..e729c5a 100644
--- a/apps/browser/agent-control.cjs
+++ b/apps/browser/agent-control.cjs
@@ -3,7 +3,7 @@ const http = require('node:http');
const crypto = require('node:crypto');
const channel=require('../../packages/core/policy-channel.cjs');
-const APP_ACTIONS=Object.freeze(['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(['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 fail = (status, message) => Object.assign(new Error(message), { status });
const bounded = (value, max, name) => {
@@ -16,9 +16,20 @@ function validate(body) {
if (!Object.hasOwn(SCOPES, action)) throw fail(400, 'Unsupported action');
const args = body.args ?? {};
if (!args || typeof args !== 'object' || Array.isArray(args)) throw fail(400, 'Invalid arguments');
- const keys = { 'vault:status':['sessionId'],'vault:list':['sessionId'],'vault:fill':['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'],'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];
if (Object.keys(args).some(k => !keys.includes(k))) throw fail(400, 'Unsupported argument');
const clean = {};
+ if(action==='sessions:save'){
+ clean.name=bounded(args.name,80,'session name');
+ if(!Array.isArray(args.sessionIds)||args.sessionIds.length<1||args.sessionIds.length>32||new Set(args.sessionIds).size!==args.sessionIds.length)throw fail(400,'Invalid session selection');
+ clean.sessionIds=args.sessionIds.map(id=>bounded(id,128,'session id'));
+ for(const key of ['id','expectedRevision'])if(args[key]!==undefined)clean[key]=bounded(args[key],36,key);
+ }
+ if(action==='sessions:restore'){
+ if((args.id!==undefined)===(args.planId!==undefined))throw fail(400,'Invalid session restore');
+ const key=args.planId!==undefined?'planId':'id';clean[key]=bounded(args[key],36,key);
+ }
+ if(action.startsWith('sessions:'))for(const key of ['id','expectedRevision','planId'])if(clean[key]!==undefined&&!/^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/.test(clean[key]))throw fail(400,'Invalid session handle');
if(action.startsWith('vault:')){
if(args.sessionId!==undefined||action==='vault:fill')clean.sessionId=bounded(args.sessionId,128,'sessionId');
if(action==='vault:fill'){clean.credentialRef=bounded(args.credentialRef,36,'credentialRef');if(!/^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/.test(clean.credentialRef))throw fail(400,'Invalid credential reference');}
@@ -59,6 +70,17 @@ function validate(body) {
}
// Never return adapter objects wholesale: session handles and sanitized text only.
function project(action, result) {
+ if(action.startsWith('sessions:')){
+ const handle=value=>{if(typeof value!=='string'||!/^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/.test(value))throw fail(502,'Invalid session handle');return value;};
+ if(action==='sessions:save')return {id:handle(result?.id),revision:handle(result?.revision),tabCount:sessionCount(result?.tabCount)};
+ if(action==='sessions:restore'){
+ if(result?.planId)return {planId:handle(result.planId),id:handle(result.id),tabCount:sessionCount(result.tabCount)};
+ if(result?.completed!==true||!Array.isArray(result.sessionIds)||result.sessionIds.length<1||result.sessionIds.length>32||new Set(result.sessionIds).size!==result.sessionIds.length||!result.sessionIds.includes(result.activeSessionId))throw fail(502,'Invalid restored sessions');
+ return {completed:true,sessionIds:result.sessionIds.map(handle),activeSessionId:handle(result.activeSessionId)};
+ }
+ if(!Array.isArray(result?.collections)||result.collections.length>16)throw fail(502,'Invalid session collections');
+ return {revision:handle(result.revision),collections:result.collections.map(row=>{if(!Array.isArray(row.tabs)||row.tabs.length<1||row.tabs.length>32||!Number.isInteger(row.activeIndex)||row.activeIndex<0||row.activeIndex>=row.tabs.length)throw fail(502,'Invalid session collection');return {id:handle(row.id),name:bounded(row.name,80,'session name'),activeIndex:row.activeIndex,tabs:row.tabs.map(require('./agent-session-adapters.cjs').safeSessionTab)};})};
+ }
if(action==='vault:status'){
if(!result||!['exists','locked','enabled','allowAutofill'].every(key=>typeof result[key]==='boolean'))throw fail(502,'Invalid vault response');
return Object.fromEntries(['exists','locked','enabled','allowAutofill'].map(key=>[key,result[key]]));
@@ -100,6 +122,7 @@ function project(action, result) {
}
return { completed: result?.completed !== false };
}
+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 = [] } = {}) {
if(credentialFile!==undefined)throw new TypeError('Plaintext pairing files are prohibited; use protected persistCredential');
diff --git a/apps/browser/agent-session-adapters.cjs b/apps/browser/agent-session-adapters.cjs
new file mode 100644
index 0000000..69a521f
--- /dev/null
+++ b/apps/browser/agent-session-adapters.cjs
@@ -0,0 +1,21 @@
+'use strict';
+const {randomUUID}=require('node:crypto');
+const credential=/password|passwd|api.?key|access.?token|refresh.?token|client.?secret|authorization|credential|session.?id|-----BEGIN|\b(?:sk-|gh[pousr]_)[A-Za-z0-9_-]{16,}/i;
+function safeText(value,max){if(typeof value!=='string'||value.length>max||/[\u0000-\u001f]/.test(value))throw Error('Invalid session metadata');let decoded=value;for(let i=0;i<4;i++){if(credential.test(decoded))throw Error('Sensitive session metadata');try{const next=decodeURIComponent(decoded);if(next===decoded)break;decoded=next;}catch{break;}}return value;}
+function sensitiveURL(value){for(let i=0;i<5;i++){if(/[?](?:password|passwd|token|access[_-]?token|refresh[_-]?token|api[_-]?key|secret|client[_-]?secret|code|authorization|credential|session|sid|auth|samlresponse|samlrequest|assertion|ticket|id_token)=/i.test(value)||/\/(?:login|signin|sign-in|auth|oauth|sso|password|reset|callback)(?:\/|[?#]|$)/i.test(value))return true;try{const next=decodeURIComponent(value);if(next===value)break;value=next;}catch{return true;}}return false;}
+function safeTab(row){if(!row||Object.keys(row).some(k=>!['url','title','group'].includes(k)))throw Error('Invalid session metadata');const title=safeText(row.title,200),raw=safeText(row.url,4096),url=new URL(raw);if(!['http:','https:'].includes(url.protocol)||url.username||url.password||sensitiveURL(raw)||/\/(?:login|signin|sign-in|auth|oauth|sso|password|reset|callback)(?:\/|$)/i.test(decodeURIComponent(url.pathname)))throw Error('Unsafe session destination');return {url:url.href,title,...(row.group===undefined?{}:{group:safeText(row.group,80)})};}
+function createAgentSessionAdapters({getContext,getSessions,contextCurrent,getOwner,store,inspect,restore,traceOperation=async(_name,fn)=>fn(),now=Date.now}={}){
+ if([getContext,getSessions,contextCurrent,getOwner,inspect,restore].some(fn=>typeof fn!=='function')||!store)throw TypeError('Session callbacks required');
+ const plans=new Map();
+ const snapshot=(action,signal)=>{const c=getContext();if(signal?.aborted||!c?.ready||!c.policy?.agents?.enabled||!c.policy.agents.scopes?.includes(action)||(c.policy.expiresAt&&Date.parse(c.policy.expiresAt)<=now()))throw Error('Agent session scope denied');const owner=getOwner(c);if(!owner||typeof owner.deviceId!=='string'||typeof owner.ownerId!=='string')throw Error('Session owner unavailable');return {...c,action,owner,actorBinding:JSON.stringify(c.actor??null),version:c.policyVersion??c.policy.version};};
+ const current=(c,signal,allowActivation=false)=>{const n=snapshot(c.action,signal);if(n.policy!==c.policy||n.version!==c.version||n.actorBinding!==c.actorBinding||JSON.stringify(n.owner)!==JSON.stringify(c.owner)||(c.token&&!contextCurrent(allowActivation&&typeof c.token==='object'?{...c.token,foreground:false,activation:null}:c.token)))throw Error('Session authority changed');};
+ const destination=(tab,c)=>{const url=new URL(tab.url),hosts=c.policy.agents.allowedHosts;if(typeof c.destinationAllowed!=='function'||c.destinationAllowed(tab.url,c.policy)!==true||!Array.isArray(hosts)||(hosts.length&&!hosts.some(h=>url.hostname===h||(h.startsWith('*.')&&url.hostname.endsWith(h.slice(1))))))throw Error('Session destination denied');};
+ const classify=async(value,c,signal)=>{const text=JSON.stringify(value);if(text.length>200000)throw Error('Session metadata too large');const v=await traceOperation('inspect',()=>inspect(text,c));current(c,signal);if(v?.decision!=='allow'||v.coverage?.complete===false||/uninspectable|size-limit|inspection-incomplete|unavailable|failure|failed|timeout|unsupported|encrypted|parser/i.test(v.ruleId||''))throw Error('Session metadata inspection denied');};
+ const load=async(c,id)=>{const result=await traceOperation('load',()=>store.load({...c.owner,id}));if(!result||typeof result.revision!=='string')throw Error('Session collection unavailable');const {revision,...collection}=result;return {revision,collection};};
+ return {
+ 'sessions:save':async(args={}, {signal}={})=>{const c=snapshot('sessions:save',signal),name=safeText(args.name,80);if(!name.trim()||!Array.isArray(args.sessionIds)||args.sessionIds.length<1||args.sessionIds.length>32||new Set(args.sessionIds).size!==args.sessionIds.length)throw Error('Invalid session selection');const inventory=getSessions(),bindings=[];const tabs=args.sessionIds.map(id=>{const row=inventory.find(r=>r.id===id),tab=getContext(id);if(!row||!tab?.ready||!tab.token||!contextCurrent(tab.token))throw Error('Session selection changed');const value=safeTab({title:row.title,url:tab.webContents.getURL(),...(row.group===undefined?{}:{group:row.group})});destination(value,c);bindings.push({id,token:tab.token,url:value.url,title:row.title,group:row.group});return value;});const assertCurrent=()=>{current(c,signal);const rows=getSessions();for(const b of bindings){const tab=getContext(b.id);if(!contextCurrent(b.token)||tab?.webContents?.getURL()!==b.url||rows.find(r=>r.id===b.id)?.title!==b.title||rows.find(r=>r.id===b.id)?.group!==b.group)throw Error('Session selection changed');}};await classify({name,tabs},c,signal);assertCurrent();const existing=await store.list(c.owner);assertCurrent();if(args.expectedRevision!==undefined&&args.expectedRevision!==existing.revision)throw Error('Session collection changed');const activeIndex=Math.max(0,args.sessionIds.indexOf(c.sessionId));const result=await traceOperation('save',()=>store.save({...(args.id?{id:args.id}:{}),name,tabs,activeIndex},{...c.owner,expectedRevision:existing.revision,assertCurrent}));assertCurrent();return {id:result.id,revision:result.revision,tabCount:tabs.length};},
+ 'sessions:list':async(_args={}, {signal}={})=>{const c=snapshot('sessions:list',signal),result=await traceOperation('list',()=>store.list(c.owner));current(c,signal);if(!Array.isArray(result.collections)||result.collections.length>16)throw Error('Invalid session collections');const collections=result.collections.map(row=>({id:row.id,name:safeText(row.name,80),tabs:row.tabs.map(safeTab),activeIndex:row.activeIndex}));for(const row of collections)for(const tab of row.tabs)destination(tab,c);await classify(collections,c,signal);current(c,signal);if((await store.list(c.owner)).revision!==result.revision)throw Error('Session collection changed');current(c,signal);return {revision:result.revision,collections};},
+ 'sessions:restore':async(args={}, {signal}={})=>{const c=snapshot('sessions:restore',signal);if(!args.planId){const result=await load(c,args.id);current(c,signal);const collection={id:result.collection.id,name:safeText(result.collection.name,80),tabs:result.collection.tabs.map(safeTab),activeIndex:result.collection.activeIndex};if(!collection.tabs.length||collection.tabs.length>32)throw Error('Invalid session collection');for(const tab of collection.tabs)destination(tab,c);await classify(collection,c,signal);if((await store.list(c.owner)).revision!==result.revision)throw Error('Session collection changed');current(c,signal);for(const [id,p] of plans)if(p.expires<=now())plans.delete(id);while(plans.size>=32)plans.delete(plans.keys().next().value);const planId=randomUUID();plans.set(planId,{c,collection,revision:result.revision,expires:now()+60000});return {planId,id:collection.id,tabCount:collection.tabs.length};}const plan=plans.get(args.planId);plans.delete(args.planId);if(!plan||plan.expires<=now()||plan.c.policy!==c.policy||plan.c.actorBinding!==c.actorBinding||JSON.stringify(plan.c.owner)!==JSON.stringify(c.owner))throw Error('Session restore plan unavailable');current(plan.c,signal);const result=await load(c,plan.collection.id);if(result.revision!==plan.revision||JSON.stringify(result.collection)!==JSON.stringify(plan.collection))throw Error('Session collection changed');for(const tab of plan.collection.tabs)destination(tab,c);await classify(plan.collection,c,signal);if((await store.list(c.owner)).revision!==plan.revision)throw Error('Session collection changed');current(c,signal);return traceOperation('restore',()=>restore(plan.collection.tabs,{activeIndex:plan.collection.activeIndex,signal,assertCurrent:()=>{current(c,signal,true);for(const tab of plan.collection.tabs)destination(tab,c);},revision:plan.revision,owner:c.owner}));}
+ };
+}
+module.exports={createAgentSessionAdapters,safeSessionTab:safeTab};
diff --git a/apps/browser/diagnostics.cjs b/apps/browser/diagnostics.cjs
index 090c00d..5dbe484 100644
--- a/apps/browser/diagnostics.cjs
+++ b/apps/browser/diagnostics.cjs
@@ -16,6 +16,11 @@ const components=new Set(['browser','ipc','agent','management','navigation','net
for(const operation of ['history:list','history:go','window:state','window:control'])operations.add(operation);
names.add('browser.vault');
for(const operation of ['vault:status','vault:list','vault:fill','vault.inventory.inspect','vault.fill.visual','vault.fill.native'])operations.add(operation);
+// Session collection stages use fixed identifiers; names and URLs are never attributes.
+names.add('browser.session');
+components.add('sessions');
+for(const operation of ['sessions:save','sessions:list','sessions:restore','sessions.inspect','sessions.load','sessions.list','sessions.save','sessions.restore','sessions.authorize','sessions.preflight','sessions.native','sessions.rollback'])operations.add(operation);
+for(const operation of ['audit.serialize','audit.encrypt','audit.write','audit.fsync','audit.commit'])operations.add(operation);
const decisions=new Set(['allow','block','deny','unknown']),statuses=new Set(['ok','error','cancelled','timeout']);
const requestTypes=new Set(['mainFrame','subFrame','stylesheet','script','image','font','object','xhr','ping','cspReport','media','webSocket','other']);
for(const name of ['browser.upload.lease','browser.upload.part','browser.upload.release','browser.upload.fault'])names.add(name);
@@ -64,6 +69,11 @@ function createDiagnostics(){const storage=new AsyncLocalStorage();let generatio
async function span(name,input,fn){if(typeof fn!=='function')throw TypeError('Diagnostics operation requires a function');const current=generation;if(!current?.active||!names.has(name)||activeSpans>=128)return fn();let nativeSpan;try{const parent=storage.getStore(),context=parent?.generation===current?trace.setSpan(ROOT_CONTEXT,parent.span):ROOT_CONTEXT;nativeSpan=current.tracer.startSpan(name,{attributes:{...current.context,...attributes(input)}},context);}catch{return fn();}
activeSpans++;const started=performance.now();let status='ok';try{const result=await storage.run({generation:current,span:nativeSpan},fn);try{nativeSpan.setStatus({code:SpanStatusCode.OK});}catch{}return result;}catch(error){status='error';try{nativeSpan.setStatus({code:SpanStatusCode.ERROR});nativeSpan.setAttribute('error.type',error instanceof TypeError?'TypeError':error instanceof RangeError?'RangeError':'Error');}catch{}throw error;}finally{activeSpans--;try{const sampled=nativeSpan.isRecording();nativeSpan.setAttribute('safetynet.status',status);nativeSpan.setAttribute('safetynet.duration.ms',Math.max(0,performance.now()-started));nativeSpan.end();if(current.active&&sampled)current.logger.emit({severityNumber:9,severityText:'INFO',body:name,attributes:{...current.context,...attributes(input),'safetynet.status':status,'safetynet.duration.ms':Math.max(0,performance.now()-started)},context:trace.setSpan(ROOT_CONTEXT,nativeSpan)});}catch{}}
}
+ // Synchronous persistence must retain its native return/throw contract. Never
+ // wrap fsync or DPAPI in a promise merely to add diagnostic timing.
+ function spanSync(name,input,fn){if(typeof fn!=='function')throw TypeError('Diagnostics operation requires a function');const current=generation;if(!current?.active||!names.has(name)||activeSpans>=128)return fn();let nativeSpan;try{const parent=storage.getStore(),context=parent?.generation===current?trace.setSpan(ROOT_CONTEXT,parent.span):ROOT_CONTEXT;nativeSpan=current.tracer.startSpan(name,{attributes:{...current.context,...attributes(input)}},context);}catch{return fn();}
+ activeSpans++;const started=performance.now();let status='ok';try{const result=storage.run({generation:current,span:nativeSpan},fn);try{nativeSpan.setStatus({code:SpanStatusCode.OK});}catch{}return result;}catch(error){status='error';try{nativeSpan.setStatus({code:SpanStatusCode.ERROR});nativeSpan.setAttribute('error.type',error instanceof TypeError?'TypeError':error instanceof RangeError?'RangeError':'Error');}catch{}throw error;}finally{activeSpans--;try{const sampled=nativeSpan.isRecording();nativeSpan.setAttribute('safetynet.status',status);nativeSpan.setAttribute('safetynet.duration.ms',Math.max(0,performance.now()-started));nativeSpan.end();if(current.active&&sampled)current.logger.emit({severityNumber:9,severityText:'INFO',body:name,attributes:{...current.context,...attributes(input),'safetynet.status':status,'safetynet.duration.ms':Math.max(0,performance.now()-started)},context:trace.setSpan(ROOT_CONTEXT,nativeSpan)});}catch{}}
+ }
// Durable events may be delivered by a flusher started under another event's
// async context. Adopt only data-property IDs; never inherit that unrelated
// parent, invoke caller getters, or allow diagnostic failure to repeat work.
@@ -84,6 +94,6 @@ function createDiagnostics(){const storage=new AsyncLocalStorage();let generatio
}
async function flush(){const current=generation;if(!current?.active)return;await Promise.allSettled([current.tracerProvider.forceFlush({timeoutMillis:current.config.exportTimeoutMs}),current.loggerProvider.forceFlush({timeoutMillis:current.config.exportTimeoutMs})]);}
async function shutdown(){if(closed)return;await flush();closed=true;const current=generation;generation=null;retire(current);await Promise.allSettled([...retiring]);}
- return {configure,span,event,importStages,withCorrelation,flush,shutdown,correlation:()=>{const parent=storage.getStore();if(!parent||parent.generation!==generation||!generation?.active||!parent.span.isRecording())return {};const ids=parent.span.spanContext();return {traceId:ids.traceId,spanId:ids.spanId};},status:()=>({enabled:!!generation?.active,mode:generation?.config.mode||'operational',failedExports:generation?.failedExports||0,lifetimeFailedExports,activeSpans})};
+ return {configure,span,spanSync,event,importStages,withCorrelation,flush,shutdown,correlation:()=>{const parent=storage.getStore();if(!parent||parent.generation!==generation||!generation?.active||!parent.span.isRecording())return {};const ids=parent.span.spanContext();return {traceId:ids.traceId,spanId:ids.spanId};},status:()=>({enabled:!!generation?.active,mode:generation?.config.mode||'operational',failedExports:generation?.failedExports||0,lifetimeFailedExports,activeSpans})};
}
module.exports={createDiagnostics,attributes,endpointAllowed};
diff --git a/apps/browser/main.cjs b/apps/browser/main.cjs
index 7b76442..e15abbf 100644
--- a/apps/browser/main.cjs
+++ b/apps/browser/main.cjs
@@ -192,7 +192,8 @@ function requireVault(){
}
function persistAudit(){
requireEncryption();const target=path.join(app.getPath('userData'),'audit-queue.bin'),temporary=target+'.'+require('node:crypto').randomUUID()+'.tmp';let fd;
- try{fd=fs.openSync(temporary,'wx',0o600);fs.writeFileSync(fd,safeStorage.encryptString(JSON.stringify(auditQueue)));fs.fsyncSync(fd);fs.closeSync(fd);fd=undefined;fs.renameSync(temporary,target);}finally{if(fd!==undefined)fs.closeSync(fd);try{fs.unlinkSync(temporary);}catch{}}
+ const stage=(operation,fn)=>diagnostics.spanSync('browser.security',diagnosticContext({operation}),fn);
+ try{const serialized=stage('audit.serialize',()=>JSON.stringify(auditQueue));const encrypted=stage('audit.encrypt',()=>safeStorage.encryptString(serialized));stage('audit.write',()=>{fd=fs.openSync(temporary,'wx',0o600);fs.writeFileSync(fd,encrypted);});stage('audit.fsync',()=>fs.fsyncSync(fd));stage('audit.commit',()=>{fs.closeSync(fd);fd=undefined;fs.renameSync(temporary,target);});}finally{if(fd!==undefined)fs.closeSync(fd);try{fs.unlinkSync(temporary);}catch{}}
}
function enforceEvidencePolicy(){
if(!policy)return;const delivery=require('./evidence-delivery.cjs');
@@ -415,6 +416,7 @@ async function ensureAgentAPI(){
const payload=await diagnosticSpan('browser.evidence',{operation:'evidence.capture',sessionId:metadata.sessionId},()=>require('./evidence-capture.cjs').buildInteractionEvidence({...metadata,url:metadata.pageUrl,captureSource:'agent-adapter'},policy));if(!payload)return;
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());
for(const action of ['click','type','select','check']){const original=actions[action];actions[action]=async(...args)=>{const tab=tabs.get(args[0]?.sessionId||activeTab().id),context=tabContext(tab.id);const checked=await checkVisual(tab);if(!currentTabContext(context)||checked.decision!=='allow')throw Error('Visual login protection has not approved this page');return original(...args);};}
for(const action of Object.keys(actions)){const original=actions[action];actions[action]=(args,context={})=>agentActorStorage.run({...context,action},()=>diagnosticSpan('browser.agent',{operation:action},async()=>{
if(context.principalId&&['navigate','back','forward','reload','history:go','vault:fill','click','type','select','check'].includes(action)){const tab=tabs.get(args.sessionId||activeTab().id);tab.security.agentAuthority={principalId:context.principalId,generation:context.generation,action};}
@@ -427,7 +429,7 @@ async function ensureAgentAPI(){
const actor={principalId,generation,action};
if(!actorGrantAllowed(actor,action))return {allowed:false};
const checkURL=url=>actorDestinationAllowed(url,actor);
- if(action==='sessions')return {allowed:true,role:'agent',scopes:[scope]};
+ if(action==='sessions'||['sessions:save','sessions:list','sessions:restore'].includes(action))return {allowed:true,role:'agent',scopes:[scope]};
if(action==='bookmarks:mutate'&&args.bookmark?.url&&!checkURL(args.bookmark.url))return {allowed:false};
if(action==='bookmarks:mutate'&&args.action!=='add'){const existing=personalBookmarks.items.find(row=>row.id===(args.id||args.bookmark?.id));if(!existing||!checkURL(existing.url))return {allowed:false};}
if(action==='tabs:create'&&args.url&&!checkURL(args.url))return {allowed:false};
@@ -627,21 +629,34 @@ function installSiteHooks(tab){
});
}
function createNativeTab(input={}){return diagnosticSpan('browser.tab',{operation:'create'},()=>createNativeTabImpl(input));}
-async function createNativeTabImpl(input={}){
+async function createNativeTabImpl(input={},controls={}){
if(!input||typeof input!=='object'||Object.keys(input).some(key=>!['group','url'].includes(key)))throw Error('Invalid native tab request');
if(input.group)requireManagedSection('library');
- const tab=tabs.create({group:input.group||''});tab.security.agentAuthority=input.agentAuthority||agentActorStorage.getStore()?.principalId&&{...agentActorStorage.getStore()};installSiteHooks(tab);
+ controls.assertCurrent?.();const tab=tabs.create({group:input.group||''});
try{
+ controls.onCreated?.(tab);tab.security.agentAuthority=agentActorStorage.getStore()?.principalId&&{...agentActorStorage.getStore()};installSiteHooks(tab);
await tab.view.webContents.loadURL('about:blank');
+ controls.assertCurrent?.();
await diagnosticSpan('browser.clipboard',{operation:'create',sessionId:tab.id},()=>attachPageClipboardGuard(tab.view.webContents,()=>{clipboardFatal=true;quarantine('Managed clipboard protection failed; restart the browser');audit('clipboard.block','clipboard-primitive-unavailable');app.quit();}));
- tab.guarded=true;selectTab(tab.id);
- if(input.url){if(tab.security.agentAuthority){await agentActorStorage.run(tab.security.agentAuthority,()=>navigate({url:input.url,tabId:tab.id}));}else await navigate({url:input.url,tabId:tab.id});}emit();return state();
+ controls.assertCurrent?.();tab.guarded=true;selectTab(tab.id);controls.onActivated?.(tab);controls.assertCurrent?.();
+ if(input.url){if(tab.security.agentAuthority){await agentActorStorage.run(tab.security.agentAuthority,()=>navigate({url:input.url,tabId:tab.id},undefined,{signal:controls.signal}));}else await navigate({url:input.url,tabId:tab.id},undefined,{signal:controls.signal});}controls.assertCurrent?.();emit();return state();
}catch(error){if(tabs.all().includes(tab)){tabs.remove(tab.id);if(tabs.active()?.guarded)selectTab(tabs.active().id);}throw error;}
}
function updateManagedAppearance(input){requireManagedSection('appearance');appearancePreferences={...appearancePreferences,...validateAppearance(input)};persistAppearance();emit();return managedSettings();}
async function closeNativeTab(id){tabs.remove(id);if(!tabs.all().length)await createNativeTab();else selectTab(tabs.active().id);emit();return state();}
function groupNativeTab(input){requireManagedSection('library');if(libraryOrganizer?.isBusy())throw Error('Library organization is running');if(!input||typeof input.group!=='string'||input.group.length>40)throw Error('Invalid native tab group');tabs.get(input.id).group=input.group;emit();return state();}
+let sessionSnapshotStore;
+function agentSessionControls(){
+ requireEncryption();sessionSnapshotStore??=require('./session-snapshot.cjs').createSessionSnapshotStore({filePath:path.join(app.getPath('userData'),'managed-sessions.bin'),protect:bytes=>safeStorage.encryptString(bytes.toString('base64')),unprotect:bytes=>Buffer.from(safeStorage.decryptString(bytes),'base64')});
+ const sessionContext=id=>{const context=agentContext(id),actor=agentActorStorage.getStore();return {...context,ready:context.ready&&actorGrantAllowed(actor,actor?.action),actor:context.actor||Object.freeze({agentId:'legacy-paired-agent',generation:1})};};
+ return require('./agent-session-adapters.cjs').createAgentSessionAdapters({getContext:sessionContext,getSessions:()=>tabs.all().map(tab=>({id:tab.id,title:tab.title,group:tab.group})),contextCurrent:currentTabContext,
+ getOwner:context=>({deviceId:device.id,ownerId:JSON.stringify([policy.agents.identityMode,context.actor.agentId,context.actor.generation])}),store:sessionSnapshotStore,inspect:inspectManagedAgentText,
+ traceOperation:(operation,fn)=>diagnosticSpan('browser.session',{operation:'sessions.'+operation},fn),
+ restore:(collection,options)=>require('./session-restore.cjs').restoreNativeSession(collection,options,{registry:tabs,store:sessionSnapshotStore,
+ create:async(input,controls)=>{const result=await diagnosticSpan('browser.tab',{operation:'create'},()=>createNativeTabImpl(input,controls));return result.activeTabId;},activate:id=>{selectTab(id);emit();},traceOperation:(operation,fn)=>diagnosticSpan('browser.session',{operation:'sessions.'+operation},fn)})
+ });
+}
function agentAppControls(){
const actorIdentity=()=>{const actor=agentActorStorage.getStore();return actor?.principalId?`${actor.principalId}:${actor.generation}`:'legacy';};
const windowControls=require('./window-controls.cjs').createWindowControls({getWindow:()=>win,getContext:()=>({ready:ready&&featureAllowed(managedSettings(),'agents'),policy,policyVersion:policy?.version,deviceId:device?.id,actor:actorIdentity()}),isCurrent:context=>ready&&policy===context.policy&&device?.id===context.deviceId&&actorIdentity()===context.actor,authorize:action=>actorGrantAllowed(agentActorStorage.getStore(),action),traceOperation:(operation,fn)=>diagnosticSpan('browser.window',{operation},fn)});
diff --git a/apps/browser/session-restore.cjs b/apps/browser/session-restore.cjs
new file mode 100644
index 0000000..91d9011
--- /dev/null
+++ b/apps/browser/session-restore.cjs
@@ -0,0 +1,19 @@
+'use strict';
+// Restoration creates managed native tabs; it never imports renderer pageState.
+async function restoreNativeSession(tabs,{activeIndex,signal,assertCurrent,revision,owner},{registry,store,create,activate,traceOperation=async(_name,fn)=>fn()}={}){
+ if(!Array.isArray(tabs)||!tabs.length||tabs.length>32||!Number.isInteger(activeIndex)||activeIndex<0||activeIndex>=tabs.length||typeof assertCurrent!=='function')throw Error('Invalid managed session restore');
+ const originals=registry.all().map(tab=>({id:tab.id,contents:tab.contents})),initialActive=registry.active()?.id,created=[];let expectedActive=initialActive;const epoch=()=>{const value=registry.context(registry.active()?.id,{foreground:true}).activation;if(!Number.isSafeInteger(value)||value<0)throw Error('Managed restore activation unavailable');return value;};let expectedEpoch=epoch();const ownActivation=id=>{const next=epoch(),required=expectedEpoch+(id===expectedActive?0:1);if(next!==required||registry.active()?.id!==id)throw Error('Managed restore activation changed');expectedActive=id;expectedEpoch=next;};
+ const current=()=>{
+ assertCurrent();if(signal?.aborted||store.list(owner).revision!==revision||registry.all().length!==originals.length+created.length||registry.active()?.id!==expectedActive||epoch()!==expectedEpoch)throw Error('Managed restore context changed');
+ for(const row of [...originals,...created])if(registry.get(row.id).contents!==row.contents||row.contents.isDestroyed())throw Error('Managed restore tab changed');
+ };
+ await traceOperation('preflight',()=>{current();if(originals.length+tabs.length>32)throw Error('Native tab capacity reached');});
+ try{
+ for(const tab of tabs){current();const result=await traceOperation('native',()=>create({url:tab.url,...(tab.group?{group:tab.group}:{})},{signal,assertCurrent:current,onCreated:native=>{if(!native||originals.some(row=>row.id===native.id)||created.some(row=>row.id===native.id)||!registry.all().some(row=>row.id===native.id&&row.contents===native.contents))throw Error('Managed restore ownership mismatch');created.push({id:native.id,contents:native.contents});},onActivated:native=>ownActivation(native.id)}));current();if(result!==created.at(-1)?.id)throw Error('Managed restore tab mismatch');}
+ current();activate(created[activeIndex].id);ownActivation(created[activeIndex].id);current();return {completed:true,sessionIds:created.map(row=>row.id),activeSessionId:expectedActive};
+ }catch(error){
+ // Closing owned tabs cannot undo network requests already sent before failure.
+ let rollbackFailed=false;await traceOperation('rollback',()=>{for(const row of [...created].reverse()){try{if(registry.all().some(tab=>tab.id===row.id&&tab.contents===row.contents))registry.remove(row.id);}catch{rollbackFailed=true;}}try{if(initialActive&®istry.all().some(tab=>tab.id===initialActive&&!tab.contents.isDestroyed()))activate(initialActive);}catch{rollbackFailed=true;}});if(rollbackFailed)throw Error('Managed session rollback incomplete',{cause:error});throw error;
+ }
+}
+module.exports={restoreNativeSession};
diff --git a/apps/browser/session-snapshot.cjs b/apps/browser/session-snapshot.cjs
new file mode 100644
index 0000000..2e27cd1
--- /dev/null
+++ b/apps/browser/session-snapshot.cjs
@@ -0,0 +1,32 @@
+'use strict';
+const fs=require('node:fs'),path=require('node:path'),{randomUUID}=require('node:crypto');
+const MAX_FILE=1048576, UUID=/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
+const fail=()=>{throw Error('Managed session collection unavailable');};
+const secret=s=>/-----BEGIN (?:[A-Z ]+ )?PRIVATE KEY-----|\b(?:AKIA|ASIA)[A-Z0-9]{16}\b|\b(?:sk-[A-Za-z0-9_-]{16,}|gh[pousr]_[A-Za-z0-9]{20,})\b|\b(?:password|passwd|api[_ -]?key|access[_ -]?token|client[_ -]?secret)\s*[:=]\s*["']?[^\s"']{4,}/i.test(s);
+function safeText(value,max){if(typeof value!=='string'||value.length>max||/[\u0000-\u001f\u007f]/.test(value))fail();let decoded=value;for(let i=0;i<5;i++){if(secret(decoded))fail();try{const next=decodeURIComponent(decoded);if(next===decoded)break;decoded=next;}catch{break;}}if(secret(decoded))fail();return value;}
+// Preserve benign query/fragment semantics; identifiable authentication material
+// and authentication routes are never reusable collection destinations.
+function unsafeURL(url){let decoded=url.href;for(let i=0;i<5;i++){if(/[?](?:password|passwd|token|access[_-]?token|refresh[_-]?token|api[_-]?key|secret|client[_-]?secret|code|authorization|auth|credential|session|sid|samlresponse|samlrequest|assertion|ticket|id[_-]?token)=/i.test(decoded))return true;try{const next=decodeURIComponent(decoded);if(next===decoded)break;decoded=next;}catch{break;}}let pathname=url.pathname;for(let i=0;i<5;i++){try{const next=decodeURIComponent(pathname);if(next===pathname)break;pathname=next;}catch{break;}}return /\/(?:login|signin|sign-in|auth|oauth|sso|password|reset|callback)(?:\/|$)/i.test(pathname);}
+function sanitizeSessionCollection(input){
+ if(!input||typeof input!=='object'||Array.isArray(input)||Object.keys(input).some(k=>!['id','name','tabs','activeIndex'].includes(k)))fail();
+ if(input.id!==undefined&&!UUID.test(input.id))fail();
+ const name=safeText(input.name,100);if(!name.trim()||!Array.isArray(input.tabs)||input.tabs.length<1||input.tabs.length>32||!Number.isInteger(input.activeIndex)||input.activeIndex<0||input.activeIndex>=input.tabs.length)fail();
+ const tabs=input.tabs.map(row=>{if(!row||typeof row!=='object'||Array.isArray(row)||Object.keys(row).some(k=>!['url','title','group'].includes(k)))fail();const value=safeText(row.url,4096);let url;try{url=new URL(value);}catch{fail();}if(!['http:','https:'].includes(url.protocol)||url.username||url.password||unsafeURL(url))fail();return {url:url.href,title:safeText(row.title,200),...(row.group===undefined?{}:{group:safeText(row.group,100)})};});
+ const result={...(input.id?{id:input.id}:{}),name,tabs,activeIndex:input.activeIndex};if(Buffer.byteLength(JSON.stringify(result))>196608)fail();return result;
+}
+function createSessionSnapshotStore({filePath,protect,unprotect}={}){
+ if(typeof filePath!=='string'||!path.isAbsolute(filePath)||typeof protect!=='function'||typeof unprotect!=='function')fail();
+ const binding=({deviceId,ownerId}={})=>{if(typeof deviceId!=='string'||!UUID.test(deviceId)||typeof ownerId!=='string'||ownerId.length<1||ownerId.length>512)fail();return {deviceId,ownerId};};
+ function read(){if(!fs.existsSync(filePath))return {format:1,revision:randomUUID(),collections:[]};const stat=fs.lstatSync(filePath);if(!stat.isFile()||stat.isSymbolicLink()||stat.nlink!==1||stat.size<1||stat.size>MAX_FILE)fail();let encrypted,plain,fd;try{fd=fs.openSync(filePath,fs.constants.O_RDONLY|(fs.constants.O_NOFOLLOW||0));const current=fs.fstatSync(fd);if(current.ino!==stat.ino||current.dev!==stat.dev||current.nlink!==1||current.size>MAX_FILE)fail();encrypted=fs.readFileSync(fd);if(encrypted.length>MAX_FILE)fail();plain=unprotect(encrypted);if(!Buffer.isBuffer(plain)||plain.length>MAX_FILE)fail();const data=JSON.parse(plain.toString('utf8'));if(data.format!==1||!UUID.test(data.revision)||!Array.isArray(data.collections)||data.collections.length>16)fail();const ids=new Set();data.collections=data.collections.map(row=>{binding(row);const collection=sanitizeSessionCollection(row.collection);if(!collection.id||ids.has(collection.id))fail();ids.add(collection.id);return {deviceId:row.deviceId,ownerId:row.ownerId,collection};});return data;}catch{fail();}finally{if(fd!==undefined)fs.closeSync(fd);plain?.fill(0);encrypted?.fill(0);}}
+ function write(data,options){let plain,encrypted,temp,fd;try{plain=Buffer.from(JSON.stringify(data));if(plain.length>MAX_FILE)fail();encrypted=protect(plain);if(!Buffer.isBuffer(encrypted)||encrypted.length<1||encrypted.length>MAX_FILE)fail();fs.mkdirSync(path.dirname(filePath),{recursive:true});if(fs.existsSync(filePath)){const stat=fs.lstatSync(filePath);if(!stat.isFile()||stat.isSymbolicLink()||stat.nlink!==1)fail();}temp=filePath+'.'+randomUUID()+'.tmp';fd=fs.openSync(temp,'wx',0o600);fs.writeFileSync(fd,encrypted);fs.fsyncSync(fd);fs.closeSync(fd);fd=undefined;if(state().revision!==options.expectedRevision)fail();const checked=options.assertCurrent?.();if(checked&&typeof checked.then==='function')fail();if(state().revision!==options.expectedRevision)fail();fs.renameSync(temp,filePath);temp=null;}finally{if(fd!==undefined)fs.closeSync(fd);if(temp)try{fs.unlinkSync(temp);}catch{}plain?.fill(0);encrypted?.fill(0);}}
+ let emptyRevision=randomUUID();
+ const state=()=>fs.existsSync(filePath)?read():{format:1,revision:emptyRevision,collections:[]};
+ const matches=(row,b)=>row.deviceId===b.deviceId&&row.ownerId===b.ownerId;
+ return {
+ list(options){const b=binding(options),data=state();return {revision:data.revision,collections:data.collections.filter(row=>matches(row,b)).map(row=>structuredClone(row.collection))};},
+ load(options){const b=binding(options);if(!UUID.test(options.id))fail();const data=state(),row=data.collections.find(row=>matches(row,b)&&row.collection.id===options.id);if(!row)fail();return {revision:data.revision,...structuredClone(row.collection)};},
+ save(input,options){const b=binding(options),data=state();if(options.expectedRevision!==data.revision)fail();const collection=sanitizeSessionCollection(input);const existing=collection.id&&data.collections.find(row=>row.collection.id===collection.id);if(collection.id&&(!existing||!matches(existing,b)))fail();if(!existing&&data.collections.length>=16)fail();collection.id??=randomUUID();if(existing)existing.collection=collection;else data.collections.push({...b,collection});data.revision=randomUUID();write(data,options);emptyRevision=data.revision;return {id:collection.id,revision:data.revision};},
+ remove(options){const b=binding(options),data=state();if(options.expectedRevision!==data.revision||!UUID.test(options.id))fail();const index=data.collections.findIndex(row=>matches(row,b)&&row.collection.id===options.id);if(index<0)fail();data.collections.splice(index,1);data.revision=randomUUID();write(data,options);return {revision:data.revision};}
+ };
+}
+module.exports={sanitizeSessionCollection,createSessionSnapshotStore};
diff --git a/apps/server/public/admin.js b/apps/server/public/admin.js
index 7ea6c55..27bbb4e 100644
--- a/apps/server/public/admin.js
+++ b/apps/server/public/admin.js
@@ -6,7 +6,7 @@ let credential='',policy,presetPreview,managedBookmarks=[],personalBookmarks=[],
const managedSections=['appearance','library','ai','passwords','documentScan','agents'];
-const agentScopes=['sessions','navigate','back','forward','reload','read','click','type','select','check','screenshot','ai','application:state','settings:get','settings:update','bookmarks:list','bookmarks:mutate','tabs:create','tabs:activate','tabs:close','tabs:group','library:organize-plan','library:organize-apply','inspect','scroll','history:list','history:go','window:state','window:control','vault:status','vault:list','vault:fill'];
+const agentScopes=['sessions','navigate','back','forward','reload','read','click','type','select','check','screenshot','ai','application:state','settings:get','settings:update','bookmarks:list','bookmarks:mutate','tabs:create','tabs:activate','tabs:close','tabs:group','library:organize-plan','library:organize-apply','inspect','scroll','history:list','history:go','window:state','window:control','vault:status','vault:list','vault:fill','sessions:save','sessions:list','sessions:restore'];
function status(message,error=false){$('status').textContent=message;$('status').className=error?'error':'';}
diff --git a/apps/server/public/agent-grants-admin.js b/apps/server/public/agent-grants-admin.js
index 6250d74..602545d 100644
--- a/apps/server/public/agent-grants-admin.js
+++ b/apps/server/public/agent-grants-admin.js
@@ -13,7 +13,7 @@ function renderAgentGrants(){
const enabled=agentGrantField(grid,'Enabled');enabled.type='checkbox';enabled.checked=grant.enabled;enabled.disabled=!agentGrantsEditable;enabled.addEventListener('change',()=>{grant.enabled=enabled.checked;changedAgentGrants();});
const expiry=agentGrantField(grid,'Expiry (UTC)');expiry.type='text';expiry.value=grant.expiresAt;expiry.placeholder='2026-12-31T23:59:59Z';expiry.disabled=!agentGrantsEditable;expiry.addEventListener('input',()=>{grant.expiresAt=expiry.value.trim();changedAgentGrants();});
const hosts=agentGrantField(grid,'Additional destination allowlist','textarea');hosts.value=grant.allowedHosts.join('\n');hosts.placeholder='Empty adds no destination restriction';hosts.disabled=!agentGrantsEditable;hosts.addEventListener('input',()=>{grant.allowedHosts=hosts.value.split('\n').map(v=>v.trim()).filter(Boolean);changedAgentGrants();});
- const scopeBox=document.createElement('fieldset');const scopeLegend=document.createElement('legend');scopeLegend.textContent='Additional capability limit';scopeBox.append(scopeLegend);const scopeHelp=document.createElement('p');scopeHelp.className='muted';scopeHelp.textContent='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. 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. 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);
for(const scope of agentScopes){const label=document.createElement('label');const check=document.createElement('input');check.type='checkbox';check.checked=grant.scopes.includes(scope);check.disabled=!agentGrantsEditable;label.append(check,document.createTextNode(scope));scopes.append(label);check.addEventListener('change',()=>{grant.scopes=agentScopes.filter(s=>s===scope?check.checked:grant.scopes.includes(s));changedAgentGrants();});}
const actions=document.createElement('div');actions.className='row';const generation=document.createElement('span');generation.textContent=`Credential generation: ${grant.generation}`;const rotate=document.createElement('button');rotate.type='button';rotate.className='secondary';rotate.textContent='Invalidate previous credentials';rotate.disabled=!agentGrantsEditable||grant.generation>=Number.MAX_SAFE_INTEGER;rotate.addEventListener('click',()=>{grant.generation+=1;renderAgentGrants();changedAgentGrants();agentGrantStatus('Credential generation changed in draft. Publish after review, then pair this identity again locally.');});const revoke=document.createElement('button');revoke.type='button';revoke.className='danger';revoke.textContent='Revoke grant';revoke.disabled=!agentGrantsEditable||!grant.enabled;revoke.addEventListener('click',()=>{grant.enabled=false;renderAgentGrants();changedAgentGrants();agentGrantStatus('Grant disabled in draft. Publish after review to apply revocation.');});actions.append(generation,rotate,revoke);
if(!agentGrantsPublishedIds.has(grant.id)){const remove=document.createElement('button');remove.type='button';remove.className='secondary';remove.textContent='Remove unpublished grant';remove.disabled=!agentGrantsEditable;remove.addEventListener('click',()=>{agentGrantsDraft=agentGrantsDraft.filter(g=>g!==grant);renderAgentGrants();changedAgentGrants();});actions.append(remove);}
diff --git a/apps/server/public/index.html b/apps/server/public/index.html
index 3e10b9e..20b5dc6 100644
--- a/apps/server/public/index.html
+++ b/apps/server/public/index.html
@@ -12,7 +12,7 @@
IDENTITY AND FEATURE ACCESS
User classes and website access
Create profiles for any user class with your own names and controls. Employee, developer and high-protection templates are optional starting points. Assign profiles to verified enterprise subjects or explicit managed devices. Asset-owner labels are not authenticated identities. Website denials and category restrictions accumulate across matching profiles; an allowlist does not bypass a denial.
Profile
Assignments
Developer tools
Action
Create profile
Developer grants require central delegation; any applicable deny wins. No profile can override mandatory scanning or unlock centrally hidden settings.
Preview effective device policy
Central categories and developer delegation
Categories contain your approved domain lists. This is not an automatic external website reputation feed. Enabling delegation permits reviewed developer-profile grants; denying profiles still win.
Website categories
Use exact hosts or wildcard subdomains such as *.social.example. Changes stay in this local draft until explicit publication or independent approval.
Assignment visibility
Device
Verified subject
Effective profiles
CONTROLLED DEPLOYMENT
Groups and pilot rollouts
Apply additional restrictions to explicit device groups. Start with selected pilot devices, inspect their reported policy versions, then explicitly expand the cohort. Group rules can tighten the baseline; they cannot weaken it. No automatic promotion occurs.
Group
Members
Stage
Actions
Create a device group
Membership, restrictions and cohort changes are signed policy changes. Mandatory independent approval applies to these changes too.
Additional company bookmarks
These managed bookmarks are added for the selected rollout cohort. They do not replace baseline company bookmarks.
Title
URL
Folder
Action
Effective policy preview
Reported rollout state
Device
Effective groups
Reported / target version
Version acknowledgment
Last contact
Acknowledgment compares the device-reported version with its target. It is not security attestation or proof that every control is functioning. Last contact is shown separately.
INDEPENDENT CHANGE CONTROL
Policy approval queue
Loading approval requirements.
Prepared changes remain inactive until approved. A different administrator must review the exact payload and base version. Stale changes must be prepared again against the latest policy.
Prepared
Author
Change
Base version
Status
Review
CHANGE CONTROL
Policy history and recovery
Review persisted signed versions and compare changes. Restoring reapplies the selected security rules, permissions, bookmarks and network settings, group memberships and rollout cohorts. The original version stays preserved; restoration publishes a new version with a fresh expiry. Devices never receive a lower policy version.
Version
Published
Actor
Review
Managed browser settings
Hide settings or deny changes centrally. These permissions are enforced in the native browser process as well as the interface. Hiding a section denies its user actions; publishing a new signed policy applies changes to connected devices at refresh.
Section
Hide and deny access
Lock user actions
Appearance
Bookmarks and tab groups
AI workspace
Password manager
Manual document scan
Local agent controls
Appearance values become mandatory when appearance is hidden, locked, or settings access is disabled. Otherwise users may choose local preferences. Vault locking remains available to keep credentials safe. Mandatory security inspection continues independently of settings visibility. Publish signed policy above to apply.
COMPANY LIBRARY
Managed bookmarks
Publish intranet and approved service links to every browser. Company bookmarks are read-only for employees. Personal bookmarks remain separate; configure whether employees may add, edit or delete them.
Title
HTTPS / HTTP URL (no query or fragment)
Folder
Actions
Company changes are drafts until you publish the signed policy. Personal permissions also respect the centrally locked or hidden Library section.
Device personal bookmarks
Authorized administrators can review and remotely edit a device's synchronized personal bookmarks. Changes are applied when the browser next refreshes management state. Titles and URLs are never included in security audit events.
Title
HTTPS / HTTP URL (no query or fragment)
Folder
Actions
Saving compares the loaded revision so another user's or administrator's updates cannot be overwritten silently.
TEACH YOUR BROWSER
Define what must stay inside
Add phrases that must never leave, or register an exact file fingerprint. Reference files stay on your computer; only their SHA-256 fingerprint enters policy.
Local semantic reference protection
Describe a confidential category. Your approved local EmbeddingGemma 2 runtime creates pinned text reference vectors. Document extraction and local OCR text can be compared against these references. This workflow does not train a model or provide native video inspection.
Only normalized reference vectors and coverage features enter the policy draft. No document, extracted text or model fine tuning is retained. Complete extraction and chunk coverage are required; publish explicitly to approve.
Requires locally provisioned model weights, an exact model revision, matching dimensions and the document-v1 profile. Similarity is a detector score, not a confidence guarantee. Incomplete extraction or chunk coverage always blocks.
-
Enterprise security and local agents
Configure centrally signed DNS blocklists, YARA source rules, content phrases, restricted Sigma metadata detections and agent permissions. Unsupported Sigma syntax is rejected.
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.
Vault status, masked listing and autofill are separate capabilities. A person must unlock the local vault; agents cannot unlock it or export passwords. Listing and filling are restricted to the current page and the credential's exact origin. Autofill also requires the central vault and autofill permissions. Grant only the capabilities an agent needs.
Individual agent identities
Individual mode requires an enrolled local identity matching an enabled, unexpired grant. Grant scopes and destinations further restrict the central baseline. Credentials stay on the device; no private keys or tokens are stored here. Revocation and generation changes take effect after the new signed policy reaches the browser.
Changes remain in this draft. Publish with the usual policy review. Disabling a grant revokes it; advancing its generation invalidates previously paired credentials and requires local pairing again. Empty capabilities permit no actions.
Advanced source configuration: YARA, Sigma and visual references
Structured controls above are authoritative for DNS, content and agent permissions. Preserve advanced rule sources when making other changes.
Agents require paired keys and authenticated post-quantum sessions; capabilities are further restricted by each token, destination rules and DLP. Disable paired local agents and publish to revoke control. Agent permissions never grant administrator privileges or unlock centrally hidden settings.
Publish signed policy above to apply. Downloads require configured local YARA or external ICAP. Every enabled scanner is mandatory; unavailable inspection blocks downloads. Scan limit is 20 MB. Sigma supports exact metadata selection only.
+
Enterprise security and local agents
Configure centrally signed DNS blocklists, YARA source rules, content phrases, restricted Sigma metadata detections and agent permissions. Unsupported Sigma syntax is rejected.
History read and history navigation are separate capabilities. History navigation remains subject to destination rules and DLP. Window state exposes bounded browser state; window control is limited to supported browser window operations and never grants OS or administrator access. Enable only the capabilities each agent requires.
Saving, listing and restoring sessions require separate capabilities. Saved sessions contain approved URLs and tab group metadata only: no passwords, form values or page state. Restoration rechecks current policy, destination permissions and DLP; it never restores revoked agent authority.
Vault status, masked listing and autofill are separate capabilities. A person must unlock the local vault; agents cannot unlock it or export passwords. Listing and filling are restricted to the current page and the credential's exact origin. Autofill also requires the central vault and autofill permissions. Grant only the capabilities an agent needs.
Individual agent identities
Individual mode requires an enrolled local identity matching an enabled, unexpired grant. Grant scopes and destinations further restrict the central baseline. Credentials stay on the device; no private keys or tokens are stored here. Revocation and generation changes take effect after the new signed policy reaches the browser.
Changes remain in this draft. Publish with the usual policy review. Disabling a grant revokes it; advancing its generation invalidates previously paired credentials and requires local pairing again. Empty capabilities permit no actions.
Advanced source configuration: YARA, Sigma and visual references
Structured controls above are authoritative for DNS, content and agent permissions. Preserve advanced rule sources when making other changes.
Agents require paired keys and authenticated post-quantum sessions; capabilities are further restricted by each token, destination rules and DLP. Disable paired local agents and publish to revoke control. Agent permissions never grant administrator privileges or unlock centrally hidden settings.
Publish signed policy above to apply. Downloads require configured local YARA or external ICAP. Every enabled scanner is mandatory; unavailable inspection blocks downloads. Scan limit is 20 MB. Sigma supports exact metadata selection only.
Governed document teaching
Submit a reference document to the private local inspector, then explicitly approve its fingerprint for publication. For incomplete extraction, explicitly approve available fingerprints with partial coverage; unextracted media is not fingerprinted. Only hashes, coverage counts and the reference label are retained. Full documents, extracted text and base64 are discarded after local processing and are never persisted by management.
Local password vault
Credentials stay on the browser device. Management stores permissions and lifecycle audit events only; passwords, usernames and credential identifiers are not uploaded.
Publish signed policy to apply these controls.
Visual impersonation protection
For example, register a trusted Google login screenshot and allow only https://accounts.google.com. A matching login on another origin can then be blocked even when its domain is absent from every blocklist.
Upload your own trusted PNG of a brand login page or cropped login form. Only a derived perceptual hash, dimensions and allowed origins enter the policy draft. Reference pixels are discarded; no automatic Google or other brand profile is supplied.
Add trusted references, enable the control above and publish the signed policy. Use multiple references for different themes, languages and login steps. False positives, viewport changes and visual evasion require validation. The browser checks rendered pixels; this is not pre-render response inspection.
Managed clipboard and ClickFix protection
Control supported browser copy, cut and paste actions centrally. High-risk shell command patterns are blocked without sending clipboard text to an AI provider. Clipboard text and credentials never enter security logs.
Publish signed policy to apply. Mandatory DLP remains active when supplemental clipboard controls are disabled. Unsupported formats and unavailable required inspection fail closed. Pattern detection covers selected execution chains; it does not prove text or commands safe.
DEVICE FLEET
Enrollment and access
Enable or disable supplemental manual document analysis per browser. Mandatory upload DLP and download YARA continue regardless of this setting.
Device
Platform
Identity
Policy
Last seen
Access
ADMINISTRATOR ACCESS
Active management sessions
Administrators can review current SSO sessions and immediately revoke a session. Identity-provider group membership controls whether that person can sign in again. Bootstrap access has no stored browser session.
Session inventory requires the administrator role.
Identity
Role
Expires
Action
SECURITY OPERATIONS
Incident triage
Review security event metadata, assign an investigation and record its outcome. Protected evidence is shown separately only with explicit, audited access. Keep investigation notes free of secrets and personal data.
Created
Security event
Status
Severity
Assignee
Review
Incident
Device-reported protected evidence
Investigation history
AUDIT LIFECYCLE
Database event retention
Loading deployment configuration.
Retention applies only to exported database events that are not referenced by incidents. Investigation history is retained. External SIEM, file exports and backups have separate retention policies. Deployment configuration controls this setting.
SECURITY MONITORING
Recent audit events
Time
Event
Device
Metadata
This view shows the latest 100 events. Prompts and document contents are excluded. Exports contain up to 1,000 events; cursor-based APIs and durable HTTPS delivery support continuous SIEM ingestion.
COMPANY EXPERIENCE
Company logo and start page
Roll out your company identity through signed policy. Name and accent above apply alongside this logo.
Logo pixels are re-encoded locally; SVG is not accepted. News is retrieved by management only from deployment-approved feed hosts. Ask your server operator to allow the exact host. Feed URLs cannot contain credentials or fragments. Use server-configured authentication; do not put secrets in URLs. Publish and review these changes with your normal policy workflow.
Download inspection
Document DLP is required by default. Disabling it permits incoming documents after every enabled malware scanner approves them; upload protection remains enforced. Without any antivirus scanner, complete document DLP is still mandatory. Office macros are not executed. Unreadable or unsupported macro extraction blocks when local macro scanning is enabled. ICAP remains mandatory whenever enabled.
AUDIT INTEGRITY
Verify security audit records
Run an explicit consistency and signature check. This check is separate from OpenTelemetry traces and SIEM delivery.
Not verified in this session.
PROTECTED SECURITY EVIDENCE
Protected evidence collection
Opt in through centrally signed policy. All collection controls are off by default. Ordinary security audit records and OpenTelemetry traces remain separate.
Download evidence contains the source URL without its query or fragment, filename, SHA-256, byte count, MIME type and verdict. It does not contain the file body or the OS save path. Passwords and recognizable authentication credentials are excluded. A deployment-protected evidence key is required before enabling recording.
Disabling recording prevents new device evidence writes and removes pending native evidence payloads; ordinary security events remain. Existing encrypted evidence retains its access and retention controls. Interaction capture records the website origin, session, action and bounded target metadata. Guarded clipboard and agent effects are marked completed; trusted foreground page clicks and activated form events are marked observed, which does not prove network transmission. Sensitive forms, page paths, values, queries and fragments are excluded. It excludes clipboard contents, entered values and control labels. Collection is bounded and limited to the active main-frame page; it is not a complete replay. Publish and review this draft through the normal policy workflow.
OBSERVABILITY
Browser OpenTelemetry
Publish centrally signed operational or detailed debug collection. Spans report code stages, timing and outcomes. Confidential security evidence needs its separate protected storage; passwords and authentication tokens are excluded.
HTTPS is required except for a numeric loopback collector. Queue and export limits remain enforced. Browser logs and traces use OTLP. Durable security events are separately delivered by the management service in ECS or CEF; its HTTPS destination, TLS certificates and credentials are configured by the server operator, not exposed in browser policy. Backend service traces use the server OpenTelemetry configuration. Endpoint credentials do not belong in policy URLs. Disable and publish to stop new collection.