← Commit history

v0.0.21: code-enforced ownership guard (no_target/not_owned) + teaching hints + coverage gate + profiles-first preflight in skills

John Lauer ·6c51e84f50 ·4mo ago ·parent ad0cb32
7 files changed +208−31
extension/manifest.json+1−1
@@ -2,7 +2,7 @@   "manifest_version": 3,   "name": "Adom for Chrome & Edge",   "short_name": "Adom",-  "version": "0.0.20",+  "version": "0.0.21",   "description": "Drive your real, logged-in Chrome/Edge from the Adom ecosystem \u2014 the native-browser counterpart to pup.",   "minimum_chrome_version": "116",   "background": {
extension/src/native.js+57−11
@@ -87,9 +87,49 @@ async function assertDriveableWindow(tabId, args) {   } } -// Resolve which tab a verb targets: explicit tabId > active tab of the session > active tab overall.-async function resolveTabId(args) {+// ⛔ OWNERSHIP GUARD — the agent may only ACT on windows IT opened (nbrowser_open_window).+// sessionWindows doubles as the ownership registry: entries are created ONLY by openWindow,+// persisted, and pruned when the window closes. Every mutating verb refuses a target outside it,+// and refuses a BARE call (no sessionId/tabId) outright — the active-tab fallback is what let an+// agent navigate a user's working tab and wipe their other AI threads. There is deliberately NO+// agent-passable override; skills/hooks reinforce this, the code enforces it.+export class GuardError extends Error {+  constructor(code, msg, hint) { super(msg); this.code = code; this.hintText = hint; }+}++const OPEN_WINDOW_HINT =+  'Correct flow: nbrowser_profiles (map profiles → Google identities) → nbrowser_open_window ' ++  '{profile:"chrome:<email>", url:…} (opens in the BACKGROUND and auto-pins its sessionId) → drive ' ++  'ONLY via that sessionId. There is NO agent-side override for driving the user\'s own tabs; if the ' ++  'user wants you inside one of their tabs, they must say so and you still work in a window you open.';++function ownedWindowSet() { return new Set(sessionWindows.values()); }++function requireTarget(args, verb) {+  if (args && (args.tabId != null || args.sessionId != null)) return;+  throw new GuardError('no_target',+    `⛔ no_target: ${verb} with no sessionId/tabId would act on the USER'S ACTIVE TAB — refused.`,+    `The active tab is whatever the user is working in RIGHT NOW; a bare ${verb} once wiped a user's other AI threads. ${OPEN_WINDOW_HINT}`);+}++async function assertOwned(tabId, verb) {   await ensureLoaded();+  let tab;+  try { tab = await chrome.tabs.get(Number(tabId)); } catch { return; } // let the verb surface the real error+  if (!ownedWindowSet().has(tab.windowId)) {+    throw new GuardError('not_owned',+      `⛔ not_owned: tab ${tabId} ("${(tab.title || '').slice(0, 60)}") is in a window the USER opened — ${verb} refused.`,+      `You may only drive windows you created this connection. ${OPEN_WINDOW_HINT}`);+  }+}++// Resolve which tab a verb targets: explicit tabId > active tab of the session.+// opts.requireOwned (set for every MUTATING verb) additionally (a) rejects bare calls instead of+// falling through to the user's active tab, and (b) rejects targets outside agent-owned windows.+async function resolveTabId(args, opts = {}) {+  await ensureLoaded();+  const verb = opts.verb || 'this verb';+  if (opts.requireOwned) requireTarget(args, verb);   let tabId;   if (args && args.tabId != null) {     tabId = Number(args.tabId);@@ -106,6 +146,7 @@ async function resolveTabId(args) {     tabId = tab.id;   }   await assertDriveableWindow(tabId, args); // ⛔ PWA guard on EVERY resolution path+  if (opts.requireOwned) await assertOwned(tabId, verb);   return tabId; } @@ -224,6 +265,11 @@ export async function rescan() { export async function openTab(args = {}) {   await ensureLoaded();   const windowId = resolveWindowId(args);+  if (windowId == null) {+    throw new GuardError('no_target',+      "\u26d4 no_target: nbrowser_open_tab without the sessionId of a window YOU opened would drop a tab into the USER'S OWN window \u2014 refused.",+      'Pass the sessionId from nbrowser_open_window, or open a new window instead. ' + OPEN_WINDOW_HINT);+  }   // BACKGROUND by default — a new tab must not steal the user's focus or yank their active tab.   // chrome.tabs.create defaults to active:true; force active:false unless the user asked (focused:true).   const base = { url: args.url, active: args.focused === true };@@ -232,13 +278,13 @@ export async function openTab(args = {}) { }  export async function closeTab(args = {}) {-  const tabId = await resolveTabId(args);+  const tabId = await resolveTabId(args, { requireOwned: true, verb: 'nbrowser_close_tab' });   await chrome.tabs.remove(tabId);   return { ok: true, tabId: String(tabId) }; }  export async function switchTab(args = {}) {-  const tabId = await resolveTabId(args);+  const tabId = await resolveTabId(args, { requireOwned: true, verb: 'nbrowser_switch_tab' });   await chrome.tabs.update(tabId, { active: true });   return { ok: true, tabId: String(tabId) }; }@@ -253,7 +299,7 @@ export async function listTabs(args = {}) {  // ---- navigation ------------------------------------------------------------ export async function navigate(args = {}) {-  const tabId = await resolveTabId(args);+  const tabId = await resolveTabId(args, { requireOwned: true, verb: 'nbrowser_navigate' });   await chrome.tabs.update(tabId, { url: args.url });   await waitForLoad(tabId);   const t = await chrome.tabs.get(tabId);@@ -261,7 +307,7 @@ export async function navigate(args = {}) { }  export async function reload(args = {}) {-  const tabId = await resolveTabId(args);+  const tabId = await resolveTabId(args, { requireOwned: true, verb: 'nbrowser_reload' });   await chrome.tabs.reload(tabId);   await waitForLoad(tabId);   return { ok: true, tabId: String(tabId), sessionId: args.sessionId };@@ -272,7 +318,7 @@ export async function reload(args = {}) { // (chrome.tabs.update + a lazily-attached debugger), even when window.history genuinely has entries. // history.back()/forward() in the page context is reliable and stays banner-free (no debugger attach). export async function back(args = {}) {-  const tabId = await resolveTabId(args);+  const tabId = await resolveTabId(args, { requireOwned: true, verb: 'nbrowser_back' });   const [{ result } = {}] = await chrome.scripting.executeScript({     target: { tabId },     func: () => { if (history.length <= 1) return 'no-history'; history.back(); return 'ok'; },@@ -283,7 +329,7 @@ export async function back(args = {}) { }  export async function forward(args = {}) {-  const tabId = await resolveTabId(args);+  const tabId = await resolveTabId(args, { requireOwned: true, verb: 'nbrowser_forward' });   await chrome.scripting.executeScript({ target: { tabId }, func: () => { history.forward(); } });   await waitForLoad(tabId);   return { ok: true, tabId: String(tabId), sessionId: args.sessionId };@@ -299,7 +345,7 @@ export async function wait(args = {}) { // visible text contains a substring, or timeoutMs elapses. Deterministic post-click/nav wait instead // of fixed sleeps + coordinate races. Pass one or more of {selector, urlIncludes, textIncludes}. export async function waitFor(args = {}) {-  const tabId = await resolveTabId(args);+  const tabId = await resolveTabId(args, { requireOwned: true, verb: 'nbrowser_wait_for' });   const { selector, urlIncludes, textIncludes } = args;   if (!selector && !urlIncludes && !textIncludes) throw new Error('wait_for needs one of: selector, urlIncludes, textIncludes');   const timeoutMs = Math.min(Number(args.timeoutMs) || 15000, 120000);@@ -481,7 +527,7 @@ function injectCaption(o) { }  export async function caption(args = {}) {-  const tabId = await resolveTabId(args);+  const tabId = await resolveTabId(args, { requireOwned: true, verb: 'nbrowser_caption' });   await chrome.scripting.executeScript({     target: { tabId },     func: injectCaption,@@ -501,7 +547,7 @@ export async function caption(args = {}) { // above other apps (OS foreground-lock blocks a background app from stealing focus — you get a // taskbar flash, by design, so the user's focus is never yanked). This is the desired behavior. export async function focus(args = {}) {-  const tabId = await resolveTabId(args);+  const tabId = await resolveTabId(args, { requireOwned: true, verb: 'nbrowser_focus' });   const tab = await chrome.tabs.get(tabId);   await chrome.windows.update(tab.windowId, { focused: true, drawAttention: true });   await chrome.tabs.update(tabId, { active: true });
extension/src/sw.js+28−17
@@ -14,6 +14,7 @@ const VERSION = chrome.runtime.getManifest().version; let port = null; let connecting = false; let gotMessage = false;+let _safetySent = false;  // Bounded reconnect: exponential backoff + a give-up state. NEVER a fixed-rate spin — a fast reconnect // loop here re-spawns native-host processes, which is what exhausted the machine's sockets on@@ -123,9 +124,19 @@ async function onMessage(msg) {   const { id, verb, args } = msg;   if (id == null || !verb) return;   try {-    reply(id, true, await dispatch(verb, args || {}));+    let payload = await dispatch(verb, args || {});+    // First-contact safety banner: teach the cardinal rule once per SW lifetime, in-band.+    if (!_safetySent && payload && typeof payload === 'object' && !Array.isArray(payload)) {+      _safetySent = true;+      payload._safety = '\u26d4 Cardinal rule: only drive windows YOU open (nbrowser_open_window \u2192 sessionId). ' ++        'Bare mutating verbs and targets in user-opened windows are refused (no_target / not_owned). ' ++        'Start with nbrowser_profiles to pick the profile matching the identity your task needs; work in the BACKGROUND.';+    }+    reply(id, true, payload);   } catch (e) {-    reply(id, false, undefined, String((e && e.message) || e), 'verb threw in extension');+    // GuardError (ownership/no-target refusals) carries a teaching hint — deliver it verbatim so+    // the calling AI is corrected at the moment of need, not pointed at a skill it may never read.+    reply(id, false, undefined, String((e && e.message) || e), (e && e.hintText) || 'verb threw in extension');   } } @@ -203,37 +214,37 @@ async function dispatch(verb, args) {      // --- input (CDP, isTrusted) ------------------------------------------     case 'nbrowser_input_dispatch':-      return cdp.inputDispatch(await native.resolveTabId(args), args);+      return cdp.inputDispatch(await native.resolveTabId(args, { requireOwned: true, verb: 'nbrowser_input_dispatch' }), args);     case 'nbrowser_click':-      return cdp.clickChanged(await native.resolveTabId(args), args); // #5 robust: reports changed:bool+      return cdp.clickChanged(await native.resolveTabId(args, { requireOwned: true, verb: 'nbrowser_click' }), args); // #5 robust: reports changed:bool     case 'nbrowser_select': // open a custom/native dropdown, pick option by VISIBLE TEXT, return selected value-      return cdp.selectOption(await native.resolveTabId(args), args);+      return cdp.selectOption(await native.resolveTabId(args, { requireOwned: true, verb: 'nbrowser_select' }), args);     case 'nbrowser_set_date':      // #7 react-aria segmented date field-      return cdp.setDate(await native.resolveTabId(args), args);+      return cdp.setDate(await native.resolveTabId(args, { requireOwned: true, verb: 'nbrowser_set_date' }), args);     case 'nbrowser_totp':          // #3 type the bridge-computed 6-digit code (args.__code) into the focused 2FA field-      return cdp.typeTotp(await native.resolveTabId(args), args);+      return cdp.typeTotp(await native.resolveTabId(args, { requireOwned: true, verb: 'nbrowser_totp' }), args);     case 'nbrowser_download_wait': // #6 wait for a download to complete; optional saveToSubfolder       return native.downloadWait(args);     case 'nbrowser_type':-      return cdp.inputDispatch(await native.resolveTabId(args), { ...args, type: 'type' });+      return cdp.inputDispatch(await native.resolveTabId(args, { requireOwned: true, verb: 'nbrowser_type' }), { ...args, type: 'type' });     case 'nbrowser_press_key':-      return cdp.inputDispatch(await native.resolveTabId(args), { ...args, type: 'key' });+      return cdp.inputDispatch(await native.resolveTabId(args, { requireOwned: true, verb: 'nbrowser_press_key' }), { ...args, type: 'key' });     case 'nbrowser_hover':-      return cdp.inputDispatch(await native.resolveTabId(args), { ...args, type: 'hover' });+      return cdp.inputDispatch(await native.resolveTabId(args, { requireOwned: true, verb: 'nbrowser_hover' }), { ...args, type: 'hover' });     case 'nbrowser_double_click':-      return cdp.inputDispatch(await native.resolveTabId(args), { ...args, type: 'dblclick' });+      return cdp.inputDispatch(await native.resolveTabId(args, { requireOwned: true, verb: 'nbrowser_double_click' }), { ...args, type: 'dblclick' });     case 'nbrowser_right_click':-      return cdp.inputDispatch(await native.resolveTabId(args), { ...args, type: 'rightclick' });+      return cdp.inputDispatch(await native.resolveTabId(args, { requireOwned: true, verb: 'nbrowser_right_click' }), { ...args, type: 'rightclick' });     case 'nbrowser_login_form':       // Detect a login form's state (autofilled? SSO? 2FA?) and recommend the smartest path. ALWAYS call       // before driving a login: if the form is already autofilled, just submit (background) — no popup/SSO.-      return cdp.detectLoginForm(await native.resolveTabId(args));+      return cdp.detectLoginForm(await native.resolveTabId(args, { requireOwned: true, verb: 'nbrowser_login_form' }));     case 'nbrowser_autologin':       // Bridge injected args.__secret (the laptop-decrypted saved password). The extension injects it via       // CDP and returns ONLY {ok,loggedIn,needs2FA} — never the secret. See cdp.autologin.-      return cdp.autologin(await native.resolveTabId(args), args);+      return cdp.autologin(await native.resolveTabId(args, { requireOwned: true, verb: 'nbrowser_autologin' }), args);     case 'nbrowser_cursor': {-      const tabId = await native.resolveTabId(args);+      const tabId = await native.resolveTabId(args, { requireOwned: true, verb: 'nbrowser_cursor' });       await cdp.cursor(tabId, args.action || 'move', args.x, args.y, args.label || args.intent);       return { ok: true, tabId: String(tabId), action: args.action || 'move', label: args.label || args.intent || 'Adom' };     }@@ -250,7 +261,7 @@ async function dispatch(verb, args) {     case 'nbrowser_download':      return native.download(args);      // --- recording (tab → WebM via offscreen MediaRecorder) ----------------    case 'nbrowser_record_start':  return record.recordStart(await native.resolveTabId(args), args);+    case 'nbrowser_record_start':  return record.recordStart(await native.resolveTabId(args, { requireOwned: true, verb: 'nbrowser_record_start' }), args);     case 'nbrowser_record_stop':   return record.recordStop(args);     case 'nbrowser_record_status': return record.recordStatus(args);     case 'nbrowser_record_list':   return record.recordList();@@ -262,7 +273,7 @@ async function dispatch(verb, args) {       return cdp.screenshot(await native.resolveTabId(args), { fullPage: !!args.fullPage, resize: false });     case 'nbrowser_eval':     case 'nbrowser_evaluate': {-      const tabId = await native.resolveTabId(args);+      const tabId = await native.resolveTabId(args, { requireOwned: true, verb: 'nbrowser_eval' });       const result = await cdp.evaluate(tabId, String(args.expr ?? args.expression ?? ''));       return { ok: true, result, tabId: String(tabId), sessionId: args.sessionId };     }
skills/driving-the-extension/SKILL.md+24
@@ -23,6 +23,30 @@ then `desktop_navigate`), do your work and capture in THAT window, and **close i only thing you may do to one of their existing windows is `desktop_bring_to_front` to surface it for them - never to drive or navigate it. +**As of extension v0.0.21 this rule is ENFORCED IN CODE, not just documented.** Every mutating verb+(`navigate, reload, back, forward, click, type, press_key, hover, input_dispatch, select, set_date,+eval, cursor, caption, focus, close_tab, switch_tab, wait_for, totp, autologin, login_form,+record_start`) refuses:+- a **bare call** (no `sessionId`/`tabId`) → `no_target` — the old active-tab fallback is gone; it is+  how an agent navigated a user's working tab and wiped their other AI threads;+- a **target outside a window the agent opened** → `not_owned` (`nbrowser_open_window`'s session map+  IS the ownership registry). `open_tab` likewise requires an owned session's `sessionId`.+There is **NO agent-passable override**. Refusals carry a teaching `_hint` with the corrected flow,+and the first response of each connection carries a one-time `_safety` banner stating this rule.+(`tools/check-guard-coverage.mjs` fails the build if a new verb ships unclassified or ungated.)++## ⭐ MANDATORY PREFLIGHT — profiles first, then your own window++Before driving ANYTHING, in this order:+1. `nbrowser_profiles` (read-only) → the connected `browser:email` keys (Chrome AND Edge). **Never+   assume which browser holds an account** — the currently-active browser is NOT "the" browser.+2. Pick the profile whose **email matches the identity the task needs** (setting up a tool for+   `[email protected]` → drive `chrome:[email protected]`, not whatever profile happens to be focused).+   If the needed profile isn't listed, ask the user to open a window in it — don't improvise in+   another one.+3. `nbrowser_open_window {profile:"<key>", url:…}` → a BACKGROUND window auto-pinned to that profile.+4. Drive ONLY via that `sessionId`, and `nbrowser_close_window` when done.+ ## ⛔ Background by default - NEVER foreground unless the user asks  **The user is almost always doing something else on their computer** - reading, typing, on a call,
skills/native-browser-safety/SKILL.md+12−2
@@ -21,8 +21,18 @@ window + a `sessionId`; do ALL your driving through that `sessionId`. - and **not with the OS-level `desktop_navigate` either** (same destruction, different door).  Why it is this strict: navigating a user's active tab yanks their live page out from under them and-**wipes anything unsaved - including other running AI threads**. That actually happened. There is no-"probably fine" version of this. If you need a page, open a new window and use that.+**wipes anything unsaved - including other running AI threads**. That actually happened - twice+(2026-06 and again 2026-07-08, mid-Meet-call), which is why as of **extension v0.0.21 the code+enforces it**: every mutating verb refuses a bare call (`no_target` - the active-tab fallback is+gone) and refuses any target outside an agent-opened window (`not_owned`). There is **no+agent-passable override**; refusals return a teaching `_hint`, and each connection's first response+carries a one-time `_safety` banner. Do not attempt to work around a refusal - including+"restorative" moves like `history.back()` on the user's tab. If you damaged their state, STOP and+tell them; more uninvited commands make it worse.++**Map identities BEFORE driving.** `nbrowser_profiles` first, every time: pick the `browser:email`+profile matching the identity the task needs (a task about `[email protected]` → that profile, never+"the browser that happens to be active" - Chrome and Edge each hold different accounts).  The only thing you may do to one of the user's existing windows is `desktop_bring_to_front` to surface it for them - and only when they explicitly ask to see it.
tools/check-guard-coverage.mjsadded+32
@@ -0,0 +1,32 @@+#!/usr/bin/env node+// Guard/hint coverage gate. Every verb dispatched in sw.js MUST be classified in+// tools/verbs-policy.json, and every 'mutating' verb MUST be wired through the ownership+// guard (its `verb: 'nbrowser_x'` requireOwned tag must appear in the source). A new verb+// that isn't classified — or a mutating verb that isn't gated — fails this check, so the+// safety doctrine can't silently decay as the surface grows. Run: node tools/check-guard-coverage.mjs+import { readFileSync } from 'node:fs';+import { dirname, join } from 'node:path';+import { fileURLToPath } from 'node:url';++const root = join(dirname(fileURLToPath(import.meta.url)), '..');+const sw = readFileSync(join(root, 'extension/src/sw.js'), 'utf8');+const native = readFileSync(join(root, 'extension/src/native.js'), 'utf8');+const policy = JSON.parse(readFileSync(join(root, 'tools/verbs-policy.json'), 'utf8'));++const dispatched = [...new Set([...sw.matchAll(/case 'nbrowser_([a-z_]+)'/g)].map((m) => m[1]))];+const src = sw + native;+const errors = [];++for (const verb of dispatched) {+  const cls = policy[verb];+  if (!cls) { errors.push(`UNCLASSIFIED verb 'nbrowser_${verb}' — add it to tools/verbs-policy.json (readonly|meta|mutating|owned_implicit|global|alias)`); continue; }+  if (cls === 'mutating' && !src.includes(`verb: 'nbrowser_${verb}'`)) {+    errors.push(`UNGATED mutating verb 'nbrowser_${verb}' — its resolveTabId call must pass { requireOwned: true, verb: 'nbrowser_${verb}' }`);+  }+}+for (const verb of Object.keys(policy)) {+  if (!dispatched.includes(verb)) errors.push(`STALE policy entry 'nbrowser_${verb}' — not dispatched in sw.js`);+}++if (errors.length) { console.error('guard-coverage FAILED:\n  ' + errors.join('\n  ')); process.exit(1); }+console.log(`guard-coverage OK: ${dispatched.length} verbs classified, ${Object.values(policy).filter((c) => c === 'mutating').length} mutating verbs gated`);
tools/verbs-policy.jsonadded+54
@@ -0,0 +1,54 @@+{+  "status": "readonly",+  "whoami": "readonly",+  "ping": "readonly",+  "dev_reload": "meta",+  "open_window": "owned_implicit",+  "close_window": "owned_implicit",+  "switch_window": "owned_implicit",+  "list_windows": "readonly",+  "window_state": "owned_implicit",+  "fullscreen": "owned_implicit",+  "login_state": "readonly",+  "clear_cookies": "global",+  "rescan": "readonly",+  "caption": "mutating",+  "focus": "mutating",+  "open_tab": "owned_implicit",+  "close_tab": "mutating",+  "switch_tab": "mutating",+  "list_tabs": "readonly",+  "navigate": "mutating",+  "reload": "mutating",+  "back": "mutating",+  "forward": "mutating",+  "wait": "readonly",+  "wait_for": "mutating",+  "keepalive": "owned_implicit",+  "input_dispatch": "mutating",+  "click": "mutating",+  "select": "mutating",+  "set_date": "mutating",+  "totp": "mutating",+  "download_wait": "readonly",+  "type": "mutating",+  "press_key": "mutating",+  "hover": "mutating",+  "double_click": "mutating",+  "right_click": "mutating",+  "login_form": "mutating",+  "autologin": "mutating",+  "cursor": "mutating",+  "errors": "readonly",+  "events": "readonly",+  "fetch_url": "readonly",+  "download": "global",+  "record_start": "mutating",+  "record_stop": "readonly",+  "record_status": "readonly",+  "record_list": "readonly",+  "screenshot": "readonly",+  "screenshot_full_res": "readonly",+  "eval": "mutating",+  "evaluate": "alias"+}