diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 6bbdf2c..13a1ede 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -20,6 +20,7 @@ jobs:
- run: pnpm lint
- run: pnpm typecheck
- run: pnpm test:unit
+ - run: pnpm audit --audit-level high
- run: pnpm format:check
e2e:
@@ -34,6 +35,6 @@ jobs:
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm build
- - run: pnpm --filter @iris/e2e exec playwright install --with-deps chromium
+ - run: pnpm --filter @syrin/iris-e2e --fail-if-no-match exec playwright install --with-deps chromium
- name: Run e2e battery (boots api+demo+next-smoke, drives Iris headless)
- run: pnpm --filter @iris/e2e run e2e:ci
+ run: pnpm --filter @syrin/iris-e2e --fail-if-no-match run e2e:ci
diff --git a/apps/demo/.env.example b/apps/demo/.env.example
index caeadc1..82c6fed 100644
--- a/apps/demo/.env.example
+++ b/apps/demo/.env.example
@@ -5,3 +5,6 @@ VITE_IRIS_WS_URL=ws://localhost:4400/iris
# Pairing token — must match IRIS_TOKEN in the @syrin/iris-server .env (blank for localhost dev).
VITE_IRIS_TOKEN=
+
+# Explicit opt-in for a deployed page or non-localhost bridge. Requires VITE_IRIS_TOKEN.
+VITE_IRIS_ALLOW_NON_LOCALHOST=false
diff --git a/apps/demo/package.json b/apps/demo/package.json
index 30d755f..96c1796 100644
--- a/apps/demo/package.json
+++ b/apps/demo/package.json
@@ -19,12 +19,15 @@
"zustand": "^5.0.14"
},
"devDependencies": {
+ "@babel/core": "^7.29.7",
+ "@rolldown/plugin-babel": "^0.2.3",
"@syrin/iris-babel-plugin": "workspace:*",
+ "@types/babel__core": "^7.20.5",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
- "@vitejs/plugin-react": "^4.3.4",
+ "@vitejs/plugin-react": "^6.0.2",
"agentation": "^3.0.2",
"agentation-mcp": "^1.2.0",
- "vite": "^6.0.7"
+ "vite": "^8.0.16"
}
}
diff --git a/apps/demo/src/iris-dev.ts b/apps/demo/src/iris-dev.ts
index 0751b7c..95a3882 100644
--- a/apps/demo/src/iris-dev.ts
+++ b/apps/demo/src/iris-dev.ts
@@ -91,7 +91,20 @@ export function installIris(): void {
const present = params.has('present');
const session = params.get('session') ?? SESSION_AUTO;
const irisPort: number = typeof __IRIS_PORT__ !== 'undefined' ? __IRIS_PORT__ : 4400;
- iris.connect({ session, present, url: `ws://localhost:${irisPort}/iris` });
+ const token = import.meta.env.VITE_IRIS_TOKEN;
+ const configuredUrl = import.meta.env.VITE_IRIS_WS_URL;
+ const url =
+ typeof configuredUrl === 'string' && configuredUrl.length > 0
+ ? configuredUrl
+ : `ws://localhost:${irisPort}/iris`;
+ const allowNonLocalhost = import.meta.env.VITE_IRIS_ALLOW_NON_LOCALHOST === 'true';
+ iris.connect({
+ session,
+ present,
+ url,
+ ...(allowNonLocalhost ? { allowNonLocalhost: true } : {}),
+ ...(typeof token === 'string' && token.length > 0 ? { token } : {}),
+ });
registerStore('app', () => useApp.getState());
registerCapabilities({
testids: TESTIDS,
diff --git a/apps/demo/src/vite-env.d.ts b/apps/demo/src/vite-env.d.ts
index 4487e9c..33a9fea 100644
--- a/apps/demo/src/vite-env.d.ts
+++ b/apps/demo/src/vite-env.d.ts
@@ -1,3 +1,13 @@
///
+interface ImportMetaEnv {
+ readonly VITE_IRIS_ALLOW_NON_LOCALHOST?: string;
+ readonly VITE_IRIS_TOKEN?: string;
+ readonly VITE_IRIS_WS_URL?: string;
+}
+
+interface ImportMeta {
+ readonly env: ImportMetaEnv;
+}
+
declare const __IRIS_PORT__: number;
diff --git a/apps/demo/vite.config.ts b/apps/demo/vite.config.ts
index 4f7b5cd..f333287 100644
--- a/apps/demo/vite.config.ts
+++ b/apps/demo/vite.config.ts
@@ -1,5 +1,6 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
+import babel from '@rolldown/plugin-babel';
import irisSource from '@syrin/iris-babel-plugin';
// The Iris showcase dashboard runs on a dedicated port (4310) so it never collides with other
@@ -10,7 +11,7 @@ const IRIS_PORT = Number(process.env['IRIS_PORT'] ?? 4400);
export default defineConfig({
// Stamp data-iris-source on host elements in dev so iris_inspect can map DOM -> file:line
// (React 19 removed _debugSource). Dev-only; harmless in prod builds.
- plugins: [react({ babel: { plugins: [irisSource] } })],
+ plugins: [babel({ plugins: [irisSource] }), react()],
server: { port: 4310 },
define: { __IRIS_PORT__: IRIS_PORT },
});
diff --git a/apps/e2e/run-ci.sh b/apps/e2e/run-ci.sh
index 91a9afa..d81e19e 100755
--- a/apps/e2e/run-ci.sh
+++ b/apps/e2e/run-ci.sh
@@ -23,6 +23,7 @@ for _ in $(seq 1 120); do
sleep 2
done
curl -s -o /dev/null http://localhost:8787/api/health || { echo "api never came up"; cat /tmp/e2e-api.log; exit 1; }
+curl -s -o /dev/null http://localhost:4310 || { echo "demo never came up"; cat /tmp/e2e-demo.log; exit 1; }
curl -s -o /dev/null http://localhost:3100 || { echo "next never came up"; cat /tmp/e2e-next.log; exit 1; }
echo "==> running e2e battery"
diff --git a/apps/e2e/specs/next-smoke-test.mjs b/apps/e2e/specs/next-smoke-test.mjs
index 80d2881..15a8958 100644
--- a/apps/e2e/specs/next-smoke-test.mjs
+++ b/apps/e2e/specs/next-smoke-test.mjs
@@ -46,7 +46,7 @@ check('new task rendered after click', added.pass, added.failureReason ?? '');
console.log('\nTASK B — API call (Next route handler) + modal + no console errors');
const since = (await T('iris_act', { ref: await refOf('testid', 'ping-button'), action: 'click' })).since;
const verdict = await T('iris_assert', {
- timeout_ms: 4000,
+ timeout_ms: 10000,
predicate: { kind: 'allOf', predicates: [
{ kind: 'net', method: 'GET', urlContains: '/api/ping', status: 200, since },
{ kind: 'element', query: { role: 'dialog', name: 'Server reply' }, state: 'visible' },
diff --git a/apps/e2e/specs/p3a-test.mjs b/apps/e2e/specs/p3a-test.mjs
index f507b1f..78cf626 100644
--- a/apps/e2e/specs/p3a-test.mjs
+++ b/apps/e2e/specs/p3a-test.mjs
@@ -17,10 +17,10 @@ for(let i=0;i<200&&server.bridge.sessions.count()===0;i++) await sleep(50);
const refOf=async(by,value)=>{for(let i=0;i<30;i++){const r=(await T('iris_query',{by,value})).elements?.[0]?.ref;if(r)return r;await sleep(100);}throw new Error('not found '+value);};
console.log('\n=== M8 Stage A: record → .iris/ flow → replay → drift (real browser) ===');
// record + save
-await T('iris_record_start',{name:'addtask'});
+await T('iris_record_start',{recordingName:'addtask'});
await T('iris_act',{ref:await refOf('testid','add-task'),action:'click'});
-await T('iris_record_stop',{name:'addtask'});
-const saved=await T('iris_flow_save',{name:'addtask'});
+await T('iris_record_stop',{recordingName:'addtask'});
+const saved=await T('iris_flow_save',{flowName:'addtask'});
const flowFile=path.join(irisRoot,'flows','addtask.json');
chk('flow saved to .iris/flows/addtask.json on disk', nfs.existsSync(flowFile), flowFile);
const raw=nfs.readFileSync(flowFile,'utf8');
@@ -28,11 +28,11 @@ chk('flow anchors on testid (no eXX refs leaked)', raw.includes('add-task') && !
const list=await T('iris_flow_list',{});
chk('iris_flow_list returns the saved flow', JSON.stringify(list).includes('addtask'));
// replay happy path
-const rep=await T('iris_flow_replay',{name:'addtask'});
+const rep=await T('iris_flow_replay',{flowName:'addtask'});
chk('iris_flow_replay re-resolves anchors + runs green', (rep.ok!==false)&&!rep.drift, JSON.stringify(rep).slice(0,90));
// drift: corrupt the testid, replay, expect legible drift with nearest match
nfs.writeFileSync(flowFile, raw.replaceAll('add-task','add-tassk'));
-const drift=await T('iris_flow_replay',{name:'addtask'});
+const drift=await T('iris_flow_replay',{flowName:'addtask'});
const ds=JSON.stringify(drift);
chk('renamed testid → legible drift with a nearest-match', /drift/i.test(ds) && /add-task/.test(ds), ds.slice(0,140));
console.log(`\n${fail===0?'✅ M8 STAGE A VERIFIED':'❌ FAILED'} (${pass} passed, ${fail} failed)`);
diff --git a/apps/e2e/specs/p3b-test.mjs b/apps/e2e/specs/p3b-test.mjs
index 5a97bf0..412eb3a 100644
--- a/apps/e2e/specs/p3b-test.mjs
+++ b/apps/e2e/specs/p3b-test.mjs
@@ -13,19 +13,19 @@ await p.goto('http://localhost:3100/',{waitUntil:'networkidle'});
for(let i=0;i<200&&server.bridge.sessions.count()===0;i++) await sleep(50);
const refOf=async(by,v)=>{for(let i=0;i<30;i++){const r=(await T('iris_query',{by,value:v})).elements?.[0]?.ref;if(r)return r;await sleep(100);}throw new Error('nf '+v);};
console.log('\n=== M8 Stage B: self-healing rebind (real browser) ===');
-await T('iris_record_start',{name:'ht'});
+await T('iris_record_start',{recordingName:'ht'});
await T('iris_act',{ref:await refOf('testid','add-task'),action:'click'});
-await T('iris_record_stop',{name:'ht'});
-await T('iris_flow_save',{name:'ht'});
+await T('iris_record_stop',{recordingName:'ht'});
+await T('iris_flow_save',{flowName:'ht'});
const file=path.join(irisRoot,'flows','ht.json');
// corrupt the testid
nfs.writeFileSync(file, nfs.readFileSync(file,'utf8').replaceAll('add-task','add-tassk'));
const bytesBefore=nfs.readFileSync(file,'utf8');
-const proposeOnly=await T('iris_flow_heal',{name:'ht',apply:false});
+const proposeOnly=await T('iris_flow_heal',{flowName:'ht',apply:false});
chk('heal(apply:false) proposes a rebind but does NOT write', /add-task/.test(JSON.stringify(proposeOnly)) && nfs.readFileSync(file,'utf8')===bytesBefore, JSON.stringify(proposeOnly).slice(0,120));
-const applied=await T('iris_flow_heal',{name:'ht',apply:true});
+const applied=await T('iris_flow_heal',{flowName:'ht',apply:true});
chk('heal(apply:true) rewrites the anchor back to add-task', nfs.readFileSync(file,'utf8').includes('add-task') && applied.applied===true, JSON.stringify(applied).slice(0,110));
-const rep=await T('iris_flow_replay',{name:'ht'});
+const rep=await T('iris_flow_replay',{flowName:'ht'});
chk('replay is green again after self-heal', rep.status==='ok'||rep.ok!==false&&!rep.drift, JSON.stringify(rep).slice(0,90));
console.log(`\n${fail===0?'✅ M8 STAGE B SELF-HEAL VERIFIED':'❌ FAILED'} (${pass} passed, ${fail} failed)`);
await b.close(); await server.close(); nfs.rmSync(path.dirname(irisRoot),{recursive:true,force:true}); process.exit(fail===0?0:1);
diff --git a/apps/e2e/specs/project-history-test.mjs b/apps/e2e/specs/project-history-test.mjs
index c2d1bba..337d93e 100644
--- a/apps/e2e/specs/project-history-test.mjs
+++ b/apps/e2e/specs/project-history-test.mjs
@@ -22,14 +22,14 @@ const refOf=async(by,value)=>{for(let i=0;i<30;i++){const r=(await T('iris_query
console.log('\n=== 0.3.7 RUNHISTORY: replay → .iris/project.json → iris_project diff (real browser) ===');
// Record + save a one-step flow.
-await T('iris_record_start',{name:'addtask'});
+await T('iris_record_start',{recordingName:'addtask'});
await T('iris_act',{ref:await refOf('testid','add-task'),action:'click'});
-await T('iris_record_stop',{name:'addtask'});
-await T('iris_flow_save',{name:'addtask'});
+await T('iris_record_stop',{recordingName:'addtask'});
+await T('iris_flow_save',{flowName:'addtask'});
// Replay twice — each replay should auto-record a run.
-await T('iris_flow_replay',{name:'addtask'});
-await T('iris_flow_replay',{name:'addtask'});
+await T('iris_flow_replay',{flowName:'addtask'});
+await T('iris_flow_replay',{flowName:'addtask'});
// 1) project.json exists on disk and holds flow_replay records.
const projFile=path.join(irisRoot,'project.json');
@@ -41,13 +41,13 @@ chk('each run carries status + driftSteps evidence + at', onDisk.runs.every(r=>r
// 2) iris_project { name } returns scoped history + lastRun + diff-vs-last.
const proj=await T('iris_project',{name:'addtask'});
chk('iris_project returns scoped runs', Array.isArray(proj.runs)&&proj.runs.length===2, `runs=${proj.runs?.length}`);
-chk('iris_project returns lastRun', proj.lastRun&&proj.lastRun.name==='addtask', JSON.stringify(proj.lastRun).slice(0,80));
-chk('iris_project returns a diff-vs-last block', proj.diff&&typeof proj.diff.regressed==='boolean', JSON.stringify(proj.diff).slice(0,100));
+chk('iris_project returns lastRun', proj.lastRun&&proj.lastRun.name==='addtask', JSON.stringify(proj.lastRun)?.slice(0,80));
+chk('iris_project returns a diff-vs-last block', proj.diff&&typeof proj.diff.regressed==='boolean', JSON.stringify(proj.diff)?.slice(0,100));
// 3) iris_run_record appends a manual run that lastRun then sees.
await T('iris_run_record',{name:'addtask',status:'pass',summary:'manual smoke'});
const after=await T('iris_project',{name:'addtask'});
-chk('iris_run_record appends a manual run', after.lastRun?.kind==='manual'&&after.lastRun?.summary==='manual smoke', JSON.stringify(after.lastRun).slice(0,100));
+chk('iris_run_record appends a manual run', after.lastRun?.kind==='manual'&&after.lastRun?.summary==='manual smoke', JSON.stringify(after.lastRun)?.slice(0,100));
console.log(`\n${fail===0?'✅ RUNHISTORY VERIFIED':'❌ FAILED'} (${pass} passed, ${fail} failed)`);
await b.close(); await server.close(); nfs.rmSync(path.dirname(irisRoot),{recursive:true,force:true}); process.exit(fail===0?0:1);
diff --git a/apps/e2e/specs/real-world-tests.mjs b/apps/e2e/specs/real-world-tests.mjs
index 3995ecd..423ba56 100644
--- a/apps/e2e/specs/real-world-tests.mjs
+++ b/apps/e2e/specs/real-world-tests.mjs
@@ -26,7 +26,7 @@ const refOf = async (by, value) => { for (let i = 0; i < 40; i++) { const r = (a
const b = await chromium.launch({ headless: true });
const p = await b.newPage();
-await p.goto('http://localhost:4310/', { waitUntil: 'networkidle' });
+await p.goto('http://localhost:4310/?session=demo', { waitUntil: 'networkidle' });
for (let i = 0; i < 200 && server.bridge.sessions.count() === 0; i++) await sleep(50);
console.log('\n=== Iris × showcase dashboard (:4310) ===');
diff --git a/apps/e2e/specs/spa-nav-realinput-test.mjs b/apps/e2e/specs/spa-nav-realinput-test.mjs
index d154f8d..d094ec5 100644
--- a/apps/e2e/specs/spa-nav-realinput-test.mjs
+++ b/apps/e2e/specs/spa-nav-realinput-test.mjs
@@ -19,7 +19,7 @@ const refOf=async()=>{for(let i=0;i<30;i++){const r=(await T('iris_query',{by:'t
console.log('\n=== bug #1: real input survives SPA navigation (real Chromium + CDP) ===');
// baseline on "/"
let ref=await refOf();
-const a1=await T('iris_act',{ref,action:'click'});
+const a1=await T('iris_act',{ref,action:'click',args:{native:true}});
chk('pre-nav: iris_act is REAL', a1.inputMode==='real', `inputMode=${a1.inputMode} url=${sess.url}`);
chk('pre-nav: realInputAvailable true', (await provider.isAvailableFor(sess.url))===true);
// CLIENT-SIDE NAV (pushState) — no full reload; SDK stays connected and emits route.change
@@ -27,10 +27,12 @@ await page.evaluate(()=>history.pushState({},'','/workspace?script=42'));
for(let i=0;i<40&&!/\/workspace\?script=42/.test(sess.url);i++) await sleep(50); // wait for route.change → server
chk('after pushState: session.url tracks the SPA route (THE FIX)', /\/workspace\?script=42$/.test(sess.url), `url=${sess.url}`);
chk('after pushState: page.url matches session.url', page.url()===sess.url, `cdp=${page.url()}`);
-chk('after pushState: realInputAvailable STILL true', (await provider.isAvailableFor(sess.url))===true);
+let available=false;
+for(let i=0;i<40&&!available;i++){available=await provider.isAvailableFor(sess.url);if(!available)await sleep(50);}
+chk('after pushState: realInputAvailable STILL true', available);
// real input must STILL engage post-nav (the button is still mounted; pushState didn't re-render)
ref=await refOf();
-const a2=await T('iris_act',{ref,action:'click'});
+const a2=await T('iris_act',{ref,action:'click',args:{native:true}});
chk('after pushState: iris_act is STILL REAL (was synthetic before the fix)', a2.inputMode==='real', `inputMode=${a2.inputMode}`);
console.log(`\n${fail===0?'✅ BUG #1 FIXED':'❌ STILL BROKEN'} (${pass} passed, ${fail} failed)`);
await browser.close(); await server.close(); process.exit(fail===0?0:1);
diff --git a/apps/next-smoke/.env.example b/apps/next-smoke/.env.example
new file mode 100644
index 0000000..b0d881e
--- /dev/null
+++ b/apps/next-smoke/.env.example
@@ -0,0 +1,2 @@
+# Optional browser/bridge pairing token. Must match IRIS_TOKEN.
+NEXT_PUBLIC_IRIS_TOKEN=
diff --git a/apps/next-smoke/app/iris-dev.tsx b/apps/next-smoke/app/iris-dev.tsx
index 19de8ea..13367b1 100644
--- a/apps/next-smoke/app/iris-dev.tsx
+++ b/apps/next-smoke/app/iris-dev.tsx
@@ -19,7 +19,12 @@ export function IrisDev() {
testids: ['ping-button', 'add-task', 'edit-field', 'show-toast'],
signals: ['field:committed'],
});
- iris.connect({ session: 'next-smoke', present: true });
+ const token = process.env['NEXT_PUBLIC_IRIS_TOKEN'];
+ iris.connect({
+ session: 'next-smoke',
+ present: true,
+ ...(typeof token === 'string' && token.length > 0 ? { token } : {}),
+ });
})();
}, []);
return null;
diff --git a/docs/flows.md b/docs/flows.md
index c81ef7a..c58b084 100644
--- a/docs/flows.md
+++ b/docs/flows.md
@@ -99,7 +99,8 @@ iris_flow_replay({ flowName: "create-task" }) // re-resolve each anchor against
- `drift` — an anchor missed (a testid was renamed, or a signal never fired). The result is
**legible**: `{ step, anchor, drift: { reasonKind: "testid_not_found", nearest: "send-message" } }`
— never a blind failure. (This is the "whose fault is it" principle.)
-- `error` — the flow file is missing/invalid; no steps ran.
+- `error` — the flow file is missing/invalid, or a resolved action failed. Runtime failures include
+ the failed step and a top-level error envelope.
A testid-_preserving_ refactor (you moved markup but kept the testids) still replays green.
diff --git a/package.json b/package.json
index 5a2917d..2d7b346 100644
--- a/package.json
+++ b/package.json
@@ -5,14 +5,14 @@
"description": "Iris — eyes for coding agents. Observe and verify a running web app over MCP, no screenshots.",
"packageManager": "pnpm@10.33.2",
"engines": {
- "node": ">=22"
+ "node": ">=22.12"
},
"scripts": {
"build": "turbo run build",
"dev": "turbo run dev",
"lint": "turbo run lint",
"typecheck": "turbo run typecheck",
- "test": "turbo run test",
+ "test": "turbo run test:unit",
"test:unit": "turbo run test:unit",
"format": "prettier --write .",
"format:check": "prettier --check .",
@@ -20,6 +20,10 @@
"publish:dry-run": "pnpm build && pnpm -r publish --access public --no-git-checks --dry-run"
},
"pnpm": {
+ "overrides": {
+ "esbuild@>=0.18.0 <0.28.1": "0.28.1",
+ "postcss@<8.5.10": "8.5.15"
+ },
"onlyBuiltDependencies": [
"esbuild"
]
@@ -27,7 +31,7 @@
"devDependencies": {
"@eslint/js": "^9.18.0",
"@types/node": "^22.10.0",
- "@vitest/coverage-v8": "^4.1.8",
+ "@vitest/coverage-v8": "^3.2.6",
"eslint": "^9.18.0",
"eslint-plugin-react-hooks": "^5.1.0",
"jsdom": "^29.1.1",
@@ -36,7 +40,7 @@
"turbo": "^2.3.3",
"typescript": "^5.7.3",
"typescript-eslint": "^8.20.0",
- "vitest": "^3.0.0",
+ "vitest": "^3.2.6",
"ws": "^8.21.0"
}
}
diff --git a/packages/babel-plugin/package.json b/packages/babel-plugin/package.json
index 3627e46..4efed57 100644
--- a/packages/babel-plugin/package.json
+++ b/packages/babel-plugin/package.json
@@ -37,7 +37,7 @@
"build": "tsc -b",
"typecheck": "tsc -b",
"lint": "eslint src",
- "test:unit": "vitest run --passWithNoTests",
+ "test:unit": "vitest run src --passWithNoTests",
"prepack": "tsc -b && find dist -name \"*.test.*\" -delete"
},
"peerDependencies": {
diff --git a/packages/browser/package.json b/packages/browser/package.json
index 553435b..f31c4c6 100644
--- a/packages/browser/package.json
+++ b/packages/browser/package.json
@@ -15,7 +15,7 @@
"build": "tsc -b",
"typecheck": "tsc -b",
"lint": "eslint src",
- "test:unit": "vitest run --passWithNoTests",
+ "test:unit": "vitest run src --passWithNoTests",
"prepack": "tsc -b && find dist -name \"*.test.*\" -delete"
},
"dependencies": {
diff --git a/packages/browser/src/actions/actions.effect.test.ts b/packages/browser/src/actions/actions.effect.test.ts
index 359f102..cc5e13c 100644
--- a/packages/browser/src/actions/actions.effect.test.ts
+++ b/packages/browser/src/actions/actions.effect.test.ts
@@ -235,13 +235,13 @@ describe('action result: hover enter/leave warning (F3)', () => {
describe('action result: testid normalization (G6)', () => {
it('includes data-testid of the resolved element', async () => {
document.body.innerHTML = '';
- const r = await executeAction(refOf('button'), 'click');
+ const r = await executeAction(refOf('button'), 'click', { confirmDangerous: true });
expect(r.testid).toBe('pay-btn');
});
it('omits testid when the element has none', async () => {
document.body.innerHTML = '';
- const r = await executeAction(refOf('button'), 'click');
+ const r = await executeAction(refOf('button'), 'click', { confirmDangerous: true });
expect(r.testid).toBeUndefined();
});
diff --git a/packages/browser/src/actions/actions.interactions.test.ts b/packages/browser/src/actions/actions.interactions.test.ts
index 6121ac1..7efa721 100644
--- a/packages/browser/src/actions/actions.interactions.test.ts
+++ b/packages/browser/src/actions/actions.interactions.test.ts
@@ -73,6 +73,36 @@ describe('webmcp passthrough', () => {
expect(callTool).toHaveBeenCalledWith('search', { q: 'x' });
expect(result).toEqual({ called: 'search' });
});
+
+ it('blocks dangerous tools without explicit confirmation', async () => {
+ const callTool = vi.fn(() => Promise.resolve({ ok: true }));
+ (navigator as unknown as Record)['modelContext'] = { callTool };
+ const reg = createCommandRegistry();
+ const handler = reg.get('act');
+ if (handler === undefined) throw new Error('no act handler');
+ await expect(
+ handler({ action: 'webmcp', args: { tool: 'delete_account', params: {} } }),
+ ).rejects.toThrow(/confirmDangerous/);
+ await handler({
+ action: 'webmcp',
+ args: { tool: 'delete_account', params: {}, confirmDangerous: true },
+ });
+ expect(callTool).toHaveBeenCalledOnce();
+ });
+});
+
+describe('dangerous action confirmation', () => {
+ it('blocks a destructive click until explicitly confirmed', async () => {
+ document.body.innerHTML = '';
+ const button = document.querySelector('button') as HTMLButtonElement;
+ const ref = refs.refFor(button);
+ const clicked = vi.fn();
+ button.addEventListener('click', clicked);
+ await expect(executeAction(ref, 'click')).rejects.toThrow(/confirmDangerous/);
+ expect(clicked).not.toHaveBeenCalled();
+ await executeAction(ref, 'click', { confirmDangerous: true });
+ expect(clicked).toHaveBeenCalledOnce();
+ });
});
describe('dev overlay', () => {
diff --git a/packages/browser/src/actions/actions.ts b/packages/browser/src/actions/actions.ts
index 9a67d2d..770d7fb 100644
--- a/packages/browser/src/actions/actions.ts
+++ b/packages/browser/src/actions/actions.ts
@@ -1,6 +1,13 @@
-import { ActionType, ActionWarning, ElementState, SettleReason } from '@syrin/iris-protocol';
+import {
+ ActionType,
+ ActionWarning,
+ DANGEROUS_ACTION_CONFIRM_ARG,
+ ElementState,
+ isDangerousActionText,
+ SettleReason,
+} from '@syrin/iris-protocol';
import { refs } from '../dom/refs.js';
-import { isVisible, getStates } from '../dom/a11y.js';
+import { getAccessibleName, isVisible, getStates } from '../dom/a11y.js';
import { elementHasHoverHandlers } from '../registry/adapters.js';
import { nativeSetTimeout, nativeFrame, settle } from '../timers/native-timers.js';
@@ -114,6 +121,47 @@ const isFillLike = (action: string): boolean => FILL_LIKE.has(action);
/** Actions that resolve to a point and so benefit from off-viewport scroll + occlusion hit-test. */
const CLICK_LIKE = new Set([ActionType.CLICK, ActionType.DBLCLICK]);
+function dangerousActionContext(el: HTMLElement): string {
+ const form = el.closest('form');
+ return [
+ getAccessibleName(el),
+ el.textContent ?? '',
+ el.getAttribute('value') ?? '',
+ el.getAttribute('title') ?? '',
+ el.getAttribute('aria-label') ?? '',
+ el.getAttribute('href') ?? '',
+ form?.getAttribute('action') ?? '',
+ form?.textContent ?? '',
+ ].join(' ');
+}
+
+export function requiresDangerousConfirmation(text: string): boolean {
+ return isDangerousActionText(text);
+}
+
+function assertActionAllowed(el: HTMLElement, action: string, args: Record): void {
+ const canTrigger =
+ action === ActionType.CLICK ||
+ action === ActionType.DBLCLICK ||
+ action === ActionType.DRAG ||
+ action === ActionType.SUBMIT ||
+ (action === ActionType.PRESS && asString(args['key'], 'Enter') === 'Enter');
+ const dragTarget = action === ActionType.DRAG ? refs.resolve(asString(args['toRef'])) : null;
+ const context =
+ dragTarget instanceof HTMLElement
+ ? `${dangerousActionContext(el)} ${dangerousActionContext(dragTarget)}`
+ : dangerousActionContext(el);
+ if (
+ canTrigger &&
+ requiresDangerousConfirmation(context) &&
+ args[DANGEROUS_ACTION_CONFIRM_ARG] !== true
+ ) {
+ throw new Error(
+ `potentially destructive action blocked; retry with args.${DANGEROUS_ACTION_CONFIRM_ARG}=true`,
+ );
+ }
+}
+
/** Best-effort click-point geometry. All-false default when there is nothing measurable to test. */
interface ClickGeometry {
occluded: boolean;
@@ -345,6 +393,7 @@ export async function executeAction(
args: Record = {},
): Promise {
const el = requireElement(ref);
+ assertActionAllowed(el, action, args);
const visible = isVisible(el);
const enabled = enabledOf(el);
const prevFocus = activeRef(el);
@@ -488,7 +537,13 @@ async function dragElement(
export async function dispatchWebMcp(
tool: string,
params: Record,
+ confirmDangerous = false,
): Promise {
+ if (requiresDangerousConfirmation(tool) && !confirmDangerous) {
+ throw new Error(
+ `potentially destructive WebMCP tool blocked; retry with ${DANGEROUS_ACTION_CONFIRM_ARG}=true`,
+ );
+ }
const mc = (
navigator as unknown as { modelContext?: { callTool?: (n: string, p: unknown) => unknown } }
).modelContext;
diff --git a/packages/browser/src/commands/commands.test.ts b/packages/browser/src/commands/commands.test.ts
index 3043332..9ba5a1f 100644
--- a/packages/browser/src/commands/commands.test.ts
+++ b/packages/browser/src/commands/commands.test.ts
@@ -5,7 +5,7 @@ import {
type ComponentStateResult,
type MatchResult,
} from '@syrin/iris-protocol';
-import { createCommandRegistry } from './commands.js';
+import { createCommandRegistry, resolveNavigationUrl } from './commands.js';
import { refs } from '../dom/refs.js';
import { registerStore, unregisterStore } from '../registry/stores.js';
import { registerAdapter } from '../registry/adapters.js';
@@ -26,6 +26,19 @@ function run(name: string, args: Record = {}): unknown {
}
describe('command registry (driven by the bridge)', () => {
+ it('allows relative/http(s) navigation and rejects executable protocols', () => {
+ expect(resolveNavigationUrl('/next', 'https://app.example/current')).toBe(
+ 'https://app.example/next',
+ );
+ expect(resolveNavigationUrl('https://safe.example/path', 'https://app.example/')).toBe(
+ 'https://safe.example/path',
+ );
+ expect(resolveNavigationUrl('javascript:globalThis.pwned=true', 'https://app.example/')).toBe(
+ null,
+ );
+ expect(resolveNavigationUrl('data:text/html,boom', 'https://app.example/')).toBe(null);
+ });
+
it('SNAPSHOT returns a tree with status', () => {
document.body.innerHTML = '';
const result = run(IrisCommand.SNAPSHOT, {}) as { tree: string; status: { route: string } };
diff --git a/packages/browser/src/commands/commands.ts b/packages/browser/src/commands/commands.ts
index 8ce39f8..3c8f5cf 100644
--- a/packages/browser/src/commands/commands.ts
+++ b/packages/browser/src/commands/commands.ts
@@ -1,9 +1,11 @@
import {
ActionType,
ComponentStateReason,
+ DANGEROUS_ACTION_CONFIRM_ARG,
ElementQuerySchema,
IrisCommand,
SnapshotMode,
+ TRANSPORT_LIMITS,
type ComponentStateResult,
type ElementQuery,
type ElementState,
@@ -63,6 +65,15 @@ function inspect(ref: string): unknown {
return {
...describe(el),
tag: el.tagName.toLowerCase(),
+ href: el.getAttribute('href') ?? undefined,
+ formAction:
+ el instanceof HTMLButtonElement || el instanceof HTMLInputElement
+ ? (el.form?.getAttribute('action') ?? undefined)
+ : undefined,
+ formText:
+ el instanceof HTMLButtonElement || el instanceof HTMLInputElement
+ ? (el.form?.textContent ?? undefined)
+ : undefined,
box: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
styles,
component,
@@ -123,6 +134,16 @@ function listAnimations(): unknown {
return { animations };
}
+export function resolveNavigationUrl(rawUrl: string, baseUrl: string): string | null {
+ if (rawUrl.length === 0 || rawUrl.length > TRANSPORT_LIMITS.MAX_URL_LENGTH) return null;
+ try {
+ const url = new URL(rawUrl, baseUrl);
+ return url.protocol === 'http:' || url.protocol === 'https:' ? url.toString() : null;
+ } catch {
+ return null;
+ }
+}
+
/** Map browser command names to handlers. Used by the transport on each COMMAND. */
export function createCommandRegistry(): Map {
const reg = new Map();
@@ -143,7 +164,11 @@ export function createCommandRegistry(): Map {
const action = str(args['action']) ?? '';
if (action === ActionType.WEBMCP) {
const inner = record(args['args']);
- return dispatchWebMcp(str(inner['tool']) ?? '', record(inner['params']));
+ return dispatchWebMcp(
+ str(inner['tool']) ?? '',
+ record(inner['params']),
+ inner[DANGEROUS_ACTION_CONFIRM_ARG] === true,
+ );
}
return executeAction(str(args['ref']) ?? '', action, record(args['args']));
});
@@ -174,8 +199,10 @@ export function createCommandRegistry(): Map {
);
});
reg.set(IrisCommand.NAVIGATE, (args) => {
- const url = str(args['url']);
- if (url === undefined || url.length === 0) return { ok: false, reason: 'url required' };
+ const rawUrl = str(args['url']);
+ if (rawUrl === undefined || rawUrl.length === 0) return { ok: false, reason: 'url required' };
+ const url = resolveNavigationUrl(rawUrl, window.location.href);
+ if (url === null) return { ok: false, reason: 'only http(s) navigation is allowed' };
window.location.assign(url);
return { ok: true, url };
});
diff --git a/packages/browser/src/dom/a11y.security.test.ts b/packages/browser/src/dom/a11y.security.test.ts
new file mode 100644
index 0000000..202392f
--- /dev/null
+++ b/packages/browser/src/dom/a11y.security.test.ts
@@ -0,0 +1,25 @@
+import { describe, expect, it } from 'vitest';
+import { REDACTED_VALUE } from '@syrin/iris-protocol';
+import { buildSnapshot } from './snapshot.js';
+import { describe as describeElement, getValue } from './a11y.js';
+
+describe('sensitive form values', () => {
+ it('redacts password values from descriptors and snapshots', () => {
+ document.body.innerHTML = '';
+ const input = document.querySelector('input') as HTMLInputElement;
+ expect(getValue(input)).toBe(REDACTED_VALUE);
+ expect(describeElement(input).value).toBe(REDACTED_VALUE);
+ const snapshot = buildSnapshot();
+ expect(snapshot.tree).toContain(`[value="${REDACTED_VALUE}"]`);
+ expect(snapshot.tree).not.toContain('supersecret');
+ });
+
+ it('redacts values identified by autocomplete and field names', () => {
+ document.body.innerHTML = `
+
+
+ `;
+ const fields = [...document.querySelectorAll('input, textarea')];
+ expect(fields.map(getValue)).toEqual([REDACTED_VALUE, REDACTED_VALUE]);
+ });
+});
diff --git a/packages/browser/src/dom/a11y.ts b/packages/browser/src/dom/a11y.ts
index ae8c42d..995114e 100644
--- a/packages/browser/src/dom/a11y.ts
+++ b/packages/browser/src/dom/a11y.ts
@@ -1,5 +1,6 @@
-import { ElementState, type ElementDescriptor } from '@syrin/iris-protocol';
+import { ElementState, REDACTED_VALUE, type ElementDescriptor } from '@syrin/iris-protocol';
import { refs } from './refs.js';
+import { isSensitiveKey } from '../security/serialization.js';
/** Roles whose accessible name comes from their text content. */
const NAME_FROM_CONTENT = new Set([
@@ -183,6 +184,22 @@ export function getValue(el: Element): string | undefined {
el instanceof HTMLTextAreaElement ||
el instanceof HTMLSelectElement
) {
+ const autocomplete = el.getAttribute('autocomplete') ?? '';
+ const identifiers = [
+ el.getAttribute('name') ?? '',
+ el.id,
+ el.getAttribute('data-testid') ?? '',
+ el.getAttribute('aria-label') ?? '',
+ ];
+ const sensitiveAutocomplete =
+ /current-password|new-password|cc-number|cc-csc|one-time-code/i.test(autocomplete);
+ if (
+ (el instanceof HTMLInputElement && el.type.toLowerCase() === 'password') ||
+ sensitiveAutocomplete ||
+ identifiers.some(isSensitiveKey)
+ ) {
+ return REDACTED_VALUE;
+ }
return el.value;
}
const valueNow = el.getAttribute('aria-valuenow');
diff --git a/packages/browser/src/iris.session.test.ts b/packages/browser/src/iris.session.test.ts
index 26c74a4..6626c03 100644
--- a/packages/browser/src/iris.session.test.ts
+++ b/packages/browser/src/iris.session.test.ts
@@ -1,6 +1,6 @@
import { describe, it, expect } from 'vitest';
-import { SESSION_AUTO } from '@syrin/iris-protocol';
-import { resolveSessionLabel } from './iris.js';
+import { SESSION_AUTO, TRANSPORT_LIMITS } from '@syrin/iris-protocol';
+import { connectionPolicy, resolveSessionLabel } from './iris.js';
describe('resolveSessionLabel', () => {
const gen = (): string => 'unique-123';
@@ -17,3 +17,56 @@ describe('resolveSessionLabel', () => {
expect(resolveSessionLabel('alianpost', gen)).toBe('alianpost');
});
});
+
+describe('connectionPolicy', () => {
+ it('allows local pages and local bridges without a token', () => {
+ expect(connectionPolicy('localhost', 'ws://127.0.0.1:4400/iris', false, undefined)).toEqual({
+ allowed: true,
+ });
+ });
+
+ it('requires explicit opt-in and a token outside localhost', () => {
+ expect(connectionPolicy('app.example', 'wss://bridge.example/iris', false, 'token')).toEqual({
+ allowed: false,
+ reason: 'Iris is disabled outside localhost unless allowNonLocalhost is explicitly enabled',
+ });
+ expect(connectionPolicy('app.example', 'wss://bridge.example/iris', true, undefined)).toEqual({
+ allowed: false,
+ reason: 'a pairing token is required outside localhost',
+ });
+ expect(connectionPolicy('app.example', 'wss://bridge.example/iris', true, 'token')).toEqual({
+ allowed: true,
+ });
+ });
+
+ it('requires encrypted transport for a non-local bridge', () => {
+ expect(connectionPolicy('localhost', 'ws://bridge.example/iris', true, 'token')).toEqual({
+ allowed: false,
+ reason: 'a non-local Iris bridge must use wss://',
+ });
+ });
+
+ it('does not treat loopback-lookalike DNS names as localhost', () => {
+ expect(
+ connectionPolicy('127.evil.example', 'ws://127.0.0.1:4400/iris', false, undefined),
+ ).toEqual({
+ allowed: false,
+ reason: 'Iris is disabled outside localhost unless allowNonLocalhost is explicitly enabled',
+ });
+ });
+
+ it('rejects tokens beyond the wire-schema limit before connecting', () => {
+ expect(
+ connectionPolicy(
+ 'localhost',
+ 'ws://127.0.0.1:4400/iris',
+ false,
+ 'x'.repeat(TRANSPORT_LIMITS.MAX_TOKEN_LENGTH + 1),
+ ).allowed,
+ ).toBe(false);
+ });
+
+ it('rejects non-WebSocket bridge URLs', () => {
+ expect(connectionPolicy('localhost', 'javascript:alert(1)', true, 'token').allowed).toBe(false);
+ });
+});
diff --git a/packages/browser/src/iris.ts b/packages/browser/src/iris.ts
index 85a0489..fb51059 100644
--- a/packages/browser/src/iris.ts
+++ b/packages/browser/src/iris.ts
@@ -8,6 +8,8 @@ import {
PresenterMode,
SESSION_AUTO,
SessionState,
+ TRANSPORT_LIMITS,
+ isLoopbackHostname,
type CommandMessage,
type HelloMessage,
type IrisEvent,
@@ -47,6 +49,10 @@ export interface IrisConnectOptions {
url?: string;
/** Human-friendly session label so the agent can target the right tab. */
session?: string;
+ /** Browser/bridge pairing token. Required when either endpoint is non-localhost. */
+ token?: string;
+ /** Explicitly allow Iris on a non-localhost page or bridge. Requires token. */
+ allowNonLocalhost?: boolean;
/** Show a small in-page status chip (connection + event count). */
overlay?: boolean;
/** Presenter mode: glow border, animated cursor, click/hover effects, narration HUD. */
@@ -73,6 +79,45 @@ export interface IrisConnectOptions {
idleEndMs?: number;
}
+export function connectionPolicy(
+ pageHostname: string,
+ bridgeUrl: string,
+ allowNonLocalhost: boolean,
+ token: string | undefined,
+): { allowed: boolean; reason?: string } {
+ let bridge: URL;
+ try {
+ bridge = new URL(bridgeUrl);
+ } catch {
+ return { allowed: false, reason: 'invalid Iris bridge URL' };
+ }
+ if (bridge.protocol !== 'ws:' && bridge.protocol !== 'wss:') {
+ return { allowed: false, reason: 'Iris bridge URL must use ws:// or wss://' };
+ }
+ if ((token?.length ?? 0) > TRANSPORT_LIMITS.MAX_TOKEN_LENGTH) {
+ return {
+ allowed: false,
+ reason: `Iris pairing token exceeds ${String(TRANSPORT_LIMITS.MAX_TOKEN_LENGTH)} characters`,
+ };
+ }
+ const remoteBridge = !isLoopbackHostname(bridge.hostname);
+ if (remoteBridge && bridge.protocol !== 'wss:') {
+ return { allowed: false, reason: 'a non-local Iris bridge must use wss://' };
+ }
+ const remote = !isLoopbackHostname(pageHostname) || remoteBridge;
+ if (!remote) return { allowed: true };
+ if (!allowNonLocalhost) {
+ return {
+ allowed: false,
+ reason: 'Iris is disabled outside localhost unless allowNonLocalhost is explicitly enabled',
+ };
+ }
+ if (token === undefined || token.length === 0) {
+ return { allowed: false, reason: 'a pairing token is required outside localhost' };
+ }
+ return { allowed: true };
+}
+
function str(value: unknown, fallback = ''): string {
return typeof value === 'string' ? value : fallback;
}
@@ -120,6 +165,7 @@ export class Iris {
#presenter: Presenter | undefined;
#recorder: RecorderHandle | undefined;
#eventCount = 0;
+ #token: string | undefined;
/** Act-row log handle for the in-flight act/act_sequence, so its outcome stamps the right row. */
#actHandle: LogHandle | undefined;
@@ -127,11 +173,28 @@ export class Iris {
if (this.#connected) return;
if (typeof window === 'undefined' || typeof document === 'undefined') return;
- this.#session = resolveSessionLabel(options.session, () => `s${Date.now().toString(36)}`);
+ const url = options.url ?? `ws://localhost:${String(IRIS_DEFAULT_PORT)}${IRIS_WS_PATH}`;
+ const policy = connectionPolicy(
+ window.location.hostname,
+ url,
+ options.allowNonLocalhost === true,
+ options.token,
+ );
+ if (!policy.allowed) {
+ globalThis.console.warn(`[Iris] ${policy.reason ?? 'connection blocked'}`);
+ return;
+ }
+
+ this.#session = resolveSessionLabel(options.session, () =>
+ typeof globalThis.crypto?.randomUUID === 'function'
+ ? `s${globalThis.crypto.randomUUID()}`
+ : `s${Date.now().toString(36)}`,
+ );
+ this.#token =
+ options.token !== undefined && options.token.length > 0 ? options.token : undefined;
this.#start = performance.now();
this.#registry = createCommandRegistry();
- const url = options.url ?? `ws://localhost:${String(IRIS_DEFAULT_PORT)}${IRIS_WS_PATH}`;
this.#transport = new Transport({
url,
hello: () => this.#hello(),
@@ -261,6 +324,7 @@ export class Iris {
url: location.href,
title: document.title,
adapters: adapterNames(),
+ ...(this.#token === undefined ? {} : { token: this.#token }),
hasCapabilities: hasCapabilities(),
};
}
diff --git a/packages/browser/src/observers/console.ts b/packages/browser/src/observers/console.ts
index 7ce4b26..2fa8203 100644
--- a/packages/browser/src/observers/console.ts
+++ b/packages/browser/src/observers/console.ts
@@ -1,6 +1,7 @@
/* eslint-disable no-console -- this module's whole purpose is to wrap console.{log,warn,error} */
import { EventType } from '@syrin/iris-protocol';
import type { Emit, Teardown } from './types.js';
+import { safeStringify } from '../security/serialization.js';
type ConsoleMethod = 'log' | 'warn' | 'error';
@@ -15,11 +16,7 @@ function stringifyArgs(args: unknown[]): string {
.map((a) => {
if (typeof a === 'string') return a;
if (a instanceof Error) return a.message;
- try {
- return JSON.stringify(a);
- } catch {
- return String(a);
- }
+ return safeStringify(a);
})
.join(' ');
}
diff --git a/packages/browser/src/presenter/presenter.test.ts b/packages/browser/src/presenter/presenter.test.ts
index 8ad0939..b214f18 100644
--- a/packages/browser/src/presenter/presenter.test.ts
+++ b/packages/browser/src/presenter/presenter.test.ts
@@ -768,7 +768,7 @@ describe('presenter glow state machine', () => {
// Go quiet: jump clock past the idle window, let native timers fire the fade-out.
t += 1000;
- await wait(FAST_IDLE_MS + FAST_FADE_MS + 20);
+ expect(await until(() => p.glowPhase() === 'idle')).toBe(true);
await flush();
expect(flips.enters).toBe(1);
@@ -796,7 +796,7 @@ describe('presenter glow state machine', () => {
expect(p.glowPhase()).toBe('busy');
t += 1000;
- await wait(FAST_IDLE_MS + FAST_FADE_MS + 20);
+ expect(await until(() => p.glowPhase() === 'idle')).toBe(true);
await flush();
expect(p.glowPhase()).toBe('idle');
@@ -822,7 +822,7 @@ describe('presenter glow state machine', () => {
p.status('one');
t += 1000;
- await wait(FAST_IDLE_MS + 10); // idle check fires -> begins fade
+ expect(await until(() => p.glowPhase() === 'fading')).toBe(true);
expect(p.glowPhase()).toBe('fading');
p.status('resumed'); // activity during fade
diff --git a/packages/browser/src/registry/stores.test.ts b/packages/browser/src/registry/stores.test.ts
index 302d8b0..90164d1 100644
--- a/packages/browser/src/registry/stores.test.ts
+++ b/packages/browser/src/registry/stores.test.ts
@@ -30,6 +30,20 @@ describe('store registry', () => {
unregisterStore('ws_ok');
});
+ it('returns redacted, JSON-safe state for secrets, BigInt, and cycles', () => {
+ const state: Record = { password: 'secret', count: 2n };
+ state['self'] = state;
+ registerStore('ws_safe', () => state);
+ const out = readStores('ws_safe');
+ expect(out['ws_safe']).toEqual({
+ password: '[REDACTED]',
+ count: '2',
+ self: '[CIRCULAR]',
+ });
+ expect(() => JSON.stringify(out)).not.toThrow();
+ unregisterStore('ws_safe');
+ });
+
it('unregisterStore removes it', () => {
registerStore('ws_d', () => 0);
expect(storeNames()).toContain('ws_d');
diff --git a/packages/browser/src/registry/stores.ts b/packages/browser/src/registry/stores.ts
index 3513d37..86fcbb7 100644
--- a/packages/browser/src/registry/stores.ts
+++ b/packages/browser/src/registry/stores.ts
@@ -1,5 +1,6 @@
-/** Store registry — lets the agent pull live framework/store state on demand. */
+import { sanitizeForTransport } from '../security/serialization.js';
+/** Store registry — lets the agent pull live framework/store state on demand. */
export type StoreGetter = () => unknown;
// Persist on a global so registrations survive HMR re-evaluation (see adapters.ts / feedback #7).
@@ -25,7 +26,7 @@ export function readStores(only?: string): Record {
for (const [name, getter] of stores) {
if (only !== undefined && name !== only) continue;
try {
- out[name] = getter();
+ out[name] = sanitizeForTransport(getter());
} catch (error) {
out[name] = { __error: error instanceof Error ? error.message : String(error) };
}
diff --git a/packages/browser/src/security/serialization.test.ts b/packages/browser/src/security/serialization.test.ts
new file mode 100644
index 0000000..15b8e16
--- /dev/null
+++ b/packages/browser/src/security/serialization.test.ts
@@ -0,0 +1,60 @@
+import { describe, expect, it } from 'vitest';
+import { REDACTED_VALUE, TRANSPORT_LIMITS } from '@syrin/iris-protocol';
+import { safeStringify, sanitizeForTransport } from './serialization.js';
+
+describe('transport serialization', () => {
+ it('redacts sensitive keys at every depth', () => {
+ expect(
+ sanitizeForTransport({
+ password: 'open-sesame',
+ nested: { apiKey: 'key-123', value: 1 },
+ }),
+ ).toEqual({
+ password: REDACTED_VALUE,
+ nested: { apiKey: REDACTED_VALUE, value: 1 },
+ });
+ });
+
+ it('handles BigInt and cycles without throwing', () => {
+ const value: Record = { count: 2n };
+ value['self'] = value;
+ expect(() => safeStringify(value)).not.toThrow();
+ expect(JSON.parse(safeStringify(value))).toEqual({
+ count: '2',
+ self: '[CIRCULAR]',
+ });
+ });
+
+ it('omits undefined object properties and preserves array positions', () => {
+ expect(
+ JSON.parse(
+ safeStringify({
+ omitted: undefined,
+ items: [undefined, () => undefined, Symbol('value')],
+ }),
+ ),
+ ).toEqual({ items: [null, null, null] });
+ });
+
+ it('contains hostile proxy failures', () => {
+ const proxy = new Proxy(
+ {},
+ {
+ ownKeys: () => {
+ throw new Error('blocked');
+ },
+ },
+ );
+ expect(safeStringify(proxy)).toBe('"[UNSERIALIZABLE]"');
+ });
+
+ it('bounds long strings and collections', () => {
+ const result = sanitizeForTransport({
+ text: 'x'.repeat(TRANSPORT_LIMITS.MAX_STRING_LENGTH + 100),
+ items: Array.from({ length: TRANSPORT_LIMITS.MAX_COLLECTION_ITEMS + 10 }, (_, i) => i),
+ }) as { text: string; items: unknown[] };
+ expect(result.text.length).toBeLessThanOrEqual(TRANSPORT_LIMITS.MAX_STRING_LENGTH);
+ expect(result.text.endsWith('[TRUNCATED]')).toBe(true);
+ expect(result.items).toHaveLength(TRANSPORT_LIMITS.MAX_COLLECTION_ITEMS);
+ });
+});
diff --git a/packages/browser/src/security/serialization.ts b/packages/browser/src/security/serialization.ts
new file mode 100644
index 0000000..1dd1101
--- /dev/null
+++ b/packages/browser/src/security/serialization.ts
@@ -0,0 +1,119 @@
+import { REDACTED_VALUE, TRANSPORT_LIMITS } from '@syrin/iris-protocol';
+
+const TRUNCATED_VALUE = '[TRUNCATED]';
+const UNSERIALIZABLE_VALUE = '[UNSERIALIZABLE]';
+const OMIT_VALUE = Symbol('omit');
+const MAX_KEY_LENGTH = 256;
+const MAX_TOTAL_CHARACTERS = Math.floor(TRANSPORT_LIMITS.MAX_MESSAGE_BYTES / 8);
+const MAX_TOTAL_NODES = TRANSPORT_LIMITS.MAX_COLLECTION_ITEMS * 5;
+
+const SENSITIVE_KEY =
+ /password|passwd|passcode|secret|token|authorization|api[-_]?key|access[-_]?key|private[-_]?key|client[-_]?secret|credit[-_]?card|card[-_]?number|cvv|cvc|ssn/i;
+
+export function isSensitiveKey(key: string): boolean {
+ return SENSITIVE_KEY.test(key);
+}
+
+interface SanitizeState {
+ readonly seen: WeakSet