import {readFile,writeFile} from 'node:fs/promises' import {pathToFileURL} from 'node:url' const policyKeys=['origin','expectedCondition','timeoutMs','timeoutSource','artifactPolicy','profilePolicy'] const ownFields=(value,allowed)=>value&&typeof value==='object'&&!Array.isArray(value)&&Object.keys(value).every(key=>allowed.includes(key)) const safeId=value=>typeof value==='string'&&value.length<=64&&/^[a-z][a-z0-9-]*$/.test(value) const boundedString=(value,max)=>typeof value==='string'&&value.length>0&&value.length<=max function validatePolicy(policy,path,errors){ if(!ownFields(policy,policyKeys)){errors.push(`${path} fields invalid`);return} if('origin'in policy&&!boundedString(policy.origin,200))errors.push(`${path}.origin invalid`) if('expectedCondition'in policy&&!safeId(policy.expectedCondition))errors.push(`${path}.expectedCondition invalid`) if('timeoutMs'in policy&&(!Number.isInteger(policy.timeoutMs)||policy.timeoutMs<1||policy.timeoutMs>120000))errors.push(`${path}.timeoutMs invalid`) if('timeoutSource'in policy&&!['local-policy','official-explicit'].includes(policy.timeoutSource))errors.push(`${path}.timeoutSource invalid`) if('artifactPolicy'in policy&&!['none','screenshot'].includes(policy.artifactPolicy))errors.push(`${path}.artifactPolicy invalid`) if('profilePolicy'in policy&&!['isolated','persistent'].includes(policy.profilePolicy))errors.push(`${path}.profilePolicy invalid`) } function validateMcp(mcp,path,errors){ if(!ownFields(mcp,['tool','arguments','policy'])||typeof mcp.tool!=='string'){errors.push(`${path} invalid`);return} if(mcp.tool==='browser_click'){ if(!ownFields(mcp.arguments,['target'])||!boundedString(mcp.arguments.target,256))errors.push(`${path}.arguments invalid for browser_click`) }else if(mcp.tool==='browser_navigate'){ if(!ownFields(mcp.arguments,['url'])||!boundedString(mcp.arguments.url,2048))errors.push(`${path}.arguments invalid for browser_navigate`) }else errors.push(`${path}.tool unsupported`) validatePolicy(mcp.policy,`${path}.policy`,errors) } export function validate(input){ const errors=[] if(!ownFields(input,['schema','synthetic','cases']))return['root fields invalid'] if(input.schema!=='martybytes-browser-contract-cases/v1'||input.synthetic!==true)errors.push('synthetic schema required') if(!Array.isArray(input.cases)||input.cases.length<1||input.cases.length>100)return[...errors,'cases must contain 1 to 100 entries'] const ids=new Set() input.cases.forEach((item,index)=>{ const path=`cases[${index}]` if(!ownFields(item,['id','cli','mcp'])){errors.push(`${path} fields invalid`);return} if(!safeId(item.id)||ids.has(item.id))errors.push(`${path}.id invalid or duplicate`);else ids.add(item.id) if(!ownFields(item.cli,['argv','policy'])||!Array.isArray(item.cli.argv)||item.cli.argv.length!==2||!['click','screenshot','goto'].includes(item.cli.argv[0]))errors.push(`${path}.cli invalid`) else if(!boundedString(item.cli.argv[1],item.cli.argv[0]==='goto'?2048:256))errors.push(`${path}.cli target invalid`) else validatePolicy(item.cli.policy,`${path}.cli.policy`,errors) validateMcp(item.mcp,`${path}.mcp`,errors) }) return errors } function attachPolicy(base,policy,label){ const missing=policyKeys.filter(key=>!(key in policy)) return missing.length?{incomparable:`${label} policy omitted: ${missing.join(', ')}`}:{...base,...policy} } function normalizeCli(cli){ const [command,target]=cli.argv const action=command==='goto'?'navigate':command return attachPolicy({action,target},cli.policy,'CLI') } function normalizeMcp(mcp){ const action=mcp.tool==='browser_navigate'?'navigate':'click' const target=action==='navigate'?mcp.arguments.url:mcp.arguments.target return attachPolicy({action,target},mcp.policy,'MCP') } function parsedOrigin(value){try{return new URL(value).origin}catch{return null}} function isLoopbackOrigin(origin){ try{const url=new URL(origin);return url.protocol==='http:'&&['127.0.0.1','localhost','[::1]'].includes(url.hostname)}catch{return false} } function violatesOriginBoundary(envelope){ if(!isLoopbackOrigin(envelope.origin))return true return envelope.action==='navigate'&&parsedOrigin(envelope.target)!==parsedOrigin(envelope.origin) } export function compare(input){ const errors=validate(input) if(errors.length)throw new Error(errors.join('; ')) return {schema:'martybytes-browser-contract-results/v3',synthetic:true,cases:input.cases.map(item=>{ const cli=normalizeCli(item.cli),mcp=normalizeMcp(item.mcp) if(cli.incomparable||mcp.incomparable)return{id:item.id,verdict:'incomparable',reason:cli.incomparable??mcp.incomparable,cli,mcp} if(violatesOriginBoundary(cli)||violatesOriginBoundary(mcp))return{id:item.id,verdict:'rejected-local-policy',reason:'declared or navigation-target origin is outside the matching loopback boundary',cli,mcp} if(cli.action!==mcp.action)return{id:item.id,verdict:'incomparable',reason:`different action families: ${cli.action} vs ${mcp.action}`,cli,mcp} const fields=['target',...policyKeys] const differences=fields.filter(field=>cli[field]!==mcp[field]) return{id:item.id,verdict:differences.length?'mismatch':'equivalent-declared-intent',reason:differences.length?`different fields: ${differences.join(', ')}`:'all normalized declared fields match',differences,cli,mcp} })} } async function main(){ const[inputPath,outputPath]=process.argv.slice(2) if(!inputPath||!outputPath)throw new Error('Usage: node browser-interface-contract-lab.mjs cases.json result.json') const result=compare(JSON.parse(await readFile(inputPath,'utf8'))) await writeFile(outputPath,`${JSON.stringify(result,null,2)}\n`,{flag:'wx'}) process.stdout.write(`${JSON.stringify(result,null,2)}\n`) } if(process.argv[1]&&import.meta.url===pathToFileURL(process.argv[1]).href)main().catch(error=>{process.stderr.write(`${JSON.stringify({ok:false,error:error.message})}\n`);process.exitCode=1})