oxedyne/daimond/dev/verify_workerconsent.mjs
15.2 KiB, 1 run
created by r2519314175:817, which is this file's identity for as long as the history lasts, whatever it is later renamed to
download · who wrote it · its history
| 1 | // verify_workerconsent.mjs — a worker cannot ask, so the APP asks for it. |
| 2 | // |
| 3 | // `SAFETY_CLAUSE` (src/prompts.rs) tells every tool-holding role: "Never take an |
| 4 | // action the user cannot undo — a purchase, a payment, a message sent, a form |
| 5 | // submitted — without putting it to them first and getting a plain yes." |
| 6 | // `DEFAULT_WORKER` tells a dispatched worker: "You cannot ask questions." |
| 7 | // |
| 8 | // Both sentences are composed onto the same agent. In a Diamond the fence was |
| 9 | // said to contain the contradiction; it does not, and cannot, because the fence |
| 10 | // is a list of PATHS and the acts in that clause are BUTTONS. What actually |
| 11 | // gates them is `egress_needs_consent(mode, tainted)` — false in Guarded, the |
| 12 | // default rung, on a turn that has read nothing. So a dispatched worker can |
| 13 | // click Buy, or submit a form, on a page the user is signed into, and nobody is |
| 14 | // asked at all. |
| 15 | // |
| 16 | // This file proves that hole exists and then proves it closed. THE RED RUN IS |
| 17 | // THE POINT: run it against a build without the fix and checks 1 and 2 fail for |
| 18 | // a Diamond worker as well as a chat one. |
| 19 | // |
| 20 | // node dev/verify_workerconsent.mjs |
| 21 | // |
| 22 | // The properties: |
| 23 | // |
| 24 | // 1. AN UNSUPERVISED ACTOR'S CLICK IS PUT TO THE USER, in the default rung, |
| 25 | // on a clean turn. Asserted by the dialog appearing AND by the click never |
| 26 | // reaching the driver when the answer is no — a gate that asks and acts |
| 27 | // anyway is worse than no gate. |
| 28 | // 2. THE SAME FOR TYPING INTO A PAGE, which is the form-post channel, and the |
| 29 | // text is shown so the person deciding can see what would be sent. |
| 30 | // 3. THE USER'S OWN CHAT IS NOT ASKED. Same rung, same clean turn, same host. |
| 31 | // A gate that fires for everybody is a gate the user learns to wave |
| 32 | // through, and the whole claim here is that autonomy is what narrows. |
| 33 | // 4. CONSENT IS NOT REMEMBERED FOR AN AUTONOMOUS ACTOR. Acting is remembered |
| 34 | // per host for a supervised chat, which is right — a run of clicks is not a |
| 35 | // run of prompts. For a worker it is wrong: "you allowed one click on |
| 36 | // shop.test" is not a yes to the next one, and the whole clause is about |
| 37 | // the act, not the host. |
| 38 | // 5. BYPASS IS STILL BYPASS. The rung the user chose deliberately, once, is |
| 39 | // not quietly re-armed by this. |
| 40 | // |
| 41 | // The Web panel's driver is STUBBED — and only the driver. `window.DaimondWeb` |
| 42 | // is an iframe or the Hands extension in the product, neither of which belongs |
| 43 | // in a consent test; everything below it (the Rust gate, the payload, the real |
| 44 | // `__daimondEgressAllowed` bridge, the real dialog) is the shipped code. The |
| 45 | // stub also RECORDS what reached it, which is how check 1's second half is |
| 46 | // asked at the driver rather than at the model's reply. |
| 47 | import { open } from './harness.mjs'; |
| 48 | |
| 49 | const ok = [], bad = []; |
| 50 | const check = (name, pass, detail) => { |
| 51 | (pass ? ok : bad).push(name + (detail ? ' — ' + detail : '')); |
| 52 | console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : '')); |
| 53 | }; |
| 54 | |
| 55 | const s = await open({ name: 'workerconsent', signIn: true, connect: false }); |
| 56 | const { page } = s; |
| 57 | await page.waitForFunction(() => !!window.DaimondCore && !!window.__daimondEgressAllowed, |
| 58 | null, { timeout: 15000 }).catch(() => {}); |
| 59 | |
| 60 | // ── The driver stub ────────────────────────────────────────────────── |
| 61 | // |
| 62 | // It answers `status` with a page already open, so `current_url()` has a real |
| 63 | // host to name — an empty URL is denied by the bridge without asking anybody, |
| 64 | // which would let every check below pass for the wrong reason. |
| 65 | await page.evaluate(() => { |
| 66 | window.__drv = { clicks: [], types: [] }; |
| 67 | window.DaimondWeb = { |
| 68 | status: async () => ({ driver: 'stub', url: 'https://shop.test/cart', open: true }), |
| 69 | open: async (u) => ({ ok: true, url: u }), |
| 70 | fetch: async () => 'stub page', |
| 71 | snapshot: async () => ({ nodes: [] }), |
| 72 | read: async () => 'stub page', |
| 73 | click: async (ref) => { window.__drv.clicks.push(ref); return { ok: true }; }, |
| 74 | type: async (ref, text, submit) => { window.__drv.types.push({ ref, text, submit }); return { ok: true }; }, |
| 75 | scroll: async () => ({ ok: true }), |
| 76 | close: async () => ({ ok: true }), |
| 77 | }; |
| 78 | }); |
| 79 | |
| 80 | /// Build an agent of one kind or the other and hold it on `window`. |
| 81 | /// |
| 82 | /// `alone` is what a dispatched worker carries and the user's own chat does not. |
| 83 | /// It is set through the SAME call the app uses at dispatch, so a build where |
| 84 | /// that call does not exist fails here rather than silently testing nothing. |
| 85 | async function mint(key, alone) { |
| 86 | return await page.evaluate(async ({ key, alone }) => { |
| 87 | const mod = await import('../pkg/oxedyne_daimond.js'); |
| 88 | const app = new mod.DaimondApp('http://127.0.0.1/v1/chat/completions', '', 'none', 256, '', true); |
| 89 | let marked = false; |
| 90 | if (alone) { |
| 91 | if (typeof app.set_unsupervised === 'function') { app.set_unsupervised(); marked = true; } |
| 92 | } |
| 93 | window[key] = app; |
| 94 | return { minted: true, marked, |
| 95 | // Reported so a red run says WHY it is red: no such call in this build. |
| 96 | hasSetter: typeof app.set_unsupervised === 'function' }; |
| 97 | }, { key, alone }); |
| 98 | } |
| 99 | |
| 100 | /// Run `fn` in the page, answering the first dialog that appears with `answer`. |
| 101 | /// Reports whether one appeared at all, and what it said. |
| 102 | /// |
| 103 | /// `body` is the dialog's WHOLE text, not a prefix of it: the thing being |
| 104 | /// authorised is quoted inside that text, and a check that read the first 500 |
| 105 | /// characters could not tell a full quote from a shortened one — which is the |
| 106 | /// defect being guarded against here. `msg` measures the paragraph as RENDERED, |
| 107 | /// because "it is in the DOM" and "the person can see it" are different claims: |
| 108 | /// an unbroken run wider than the card is present and invisible. |
| 109 | /// `quote` names a substring whose RENDERED position is wanted — the text a |
| 110 | /// consent dialog is quoting — and comes back as `msg.quoteLeft`. |
| 111 | async function withDialog(fn, answer, arg, quote) { |
| 112 | const seenOut = { asked: false, title: '', body: '', msg: null }; |
| 113 | const runner = page.evaluate(fn, arg); |
| 114 | for (let i = 0; i < 60; i++) { |
| 115 | await page.waitForTimeout(100); |
| 116 | const seen = await page.evaluate((q) => { |
| 117 | const d = document.querySelector('.dlg, dialog[open], .modal-dialog'); |
| 118 | if (!d) return null; |
| 119 | const p = d.querySelector('.dlg-msg'); |
| 120 | let msg = null; |
| 121 | if (p) { |
| 122 | const box = p.getBoundingClientRect(); |
| 123 | msg = { text: p.textContent || '', scrollW: p.scrollWidth, |
| 124 | clientW: p.clientWidth, left: box.left, |
| 125 | quoteLeft: null, drop: null, |
| 126 | lineH: parseFloat(getComputedStyle(p).lineHeight) || 0 }; |
| 127 | // How far the quote sits BELOW the sentence that introduces it. |
| 128 | // The string is written with a blank line either side, so a |
| 129 | // paragraph that keeps them drops two lines; one that folds them |
| 130 | // into spaces drops none, and a long run that merely wrapped |
| 131 | // drops one. Measured rather than read off the stylesheet: the |
| 132 | // question is what the person sees, not what the CSS says. |
| 133 | const at = q ? msg.text.indexOf(q) : -1; |
| 134 | if (at >= 0 && p.firstChild) { |
| 135 | const r = document.createRange(); |
| 136 | r.setStart(p.firstChild, at); |
| 137 | r.setEnd(p.firstChild, at + 1); |
| 138 | const qb = r.getBoundingClientRect(); |
| 139 | msg.quoteLeft = qb.left; |
| 140 | let j = at - 1; |
| 141 | while (j > 0 && /\s/.test(msg.text[j])) j--; |
| 142 | if (j > 0) { |
| 143 | const pr = document.createRange(); |
| 144 | pr.setStart(p.firstChild, j); |
| 145 | pr.setEnd(p.firstChild, j + 1); |
| 146 | msg.drop = qb.top - pr.getBoundingClientRect().top; |
| 147 | } |
| 148 | } |
| 149 | } |
| 150 | return { title: (d.querySelector('h2, .dlg-title')?.textContent || ''), |
| 151 | body: (d.textContent || ''), msg }; |
| 152 | }, quote || ''); |
| 153 | if (!seen) continue; |
| 154 | seenOut.asked = true; seenOut.title = seen.title; seenOut.body = seen.body; |
| 155 | seenOut.msg = seen.msg; |
| 156 | // BY CLASS, not by reading the button text. The dialog carries a `×` closer in |
| 157 | // its heading row as well as its two answers, and a "the first button that is |
| 158 | // not Cancel" heuristic picks the closer — which dismisses. That is how this |
| 159 | // check reported a refusal for an approval it had just given, and it is the |
| 160 | // same shape of mistake as asserting on wording: the DOM says which button |
| 161 | // confirms, so ask the DOM. |
| 162 | await page.evaluate((yes) => { |
| 163 | const d = document.querySelector('.dlg, dialog[open], .modal-dialog'); |
| 164 | const okBtn = d.querySelector('.dlg-ok'); |
| 165 | const cancelB = d.querySelector('.dlg-cancel'); |
| 166 | const pick = yes ? okBtn : (cancelB || okBtn); |
| 167 | if (!pick) throw new Error('the dialog has no button to answer with'); |
| 168 | pick.click(); |
| 169 | }, answer); |
| 170 | break; |
| 171 | } |
| 172 | const result = await runner; |
| 173 | return { result, ...seenOut }; |
| 174 | } |
| 175 | |
| 176 | const setMode = (name) => page.evaluate(async (n) => { |
| 177 | const mod = await import('../pkg/oxedyne_daimond.js'); |
| 178 | mod.set_permission_mode(n); |
| 179 | return mod.permission_mode(); |
| 180 | }, name); |
| 181 | |
| 182 | const drv = () => page.evaluate(() => ({ |
| 183 | clicks: window.__drv.clicks.slice(), types: window.__drv.types.slice() })); |
| 184 | const resetDrv = () => page.evaluate(() => { window.__drv.clicks = []; window.__drv.types = []; }); |
| 185 | |
| 186 | try { |
| 187 | const rung = await setMode('guarded'); |
| 188 | check('the default rung is the one under test', rung === 'guarded', rung); |
| 189 | |
| 190 | // The two dispatched workers of the user's own scenario, and the chat that |
| 191 | // sent them. Both workers are built exactly as `Workers.start` builds one. |
| 192 | const w1 = await mint('__wChat', true); |
| 193 | const w2 = await mint('__wDiamond', true); |
| 194 | const c1 = await mint('__chat', false); |
| 195 | check('a worker can be marked as acting alone', w1.hasSetter && w1.marked && w2.marked, |
| 196 | w1.hasSetter ? 'marked' : 'this build has no set_unsupervised — the hole is open'); |
| 197 | check('an agent is minted for each of the three actors', w1.minted && w2.minted && c1.minted); |
| 198 | |
| 199 | // ── 1. A worker's click is put to the user ── |
| 200 | await resetDrv(); |
| 201 | const wClick = await withDialog(async () => { |
| 202 | return String(await window.__wChat.run_tool('web_click', JSON.stringify({ ref: 7 }))); |
| 203 | }, false); |
| 204 | check('a chat-dispatched worker\'s click is put to the user', wClick.asked, |
| 205 | wClick.asked ? wClick.title : 'no dialog: the click went through unasked'); |
| 206 | const afterNo = await drv(); |
| 207 | check('and declining stops the click reaching the page', |
| 208 | afterNo.clicks.length === 0, 'driver saw ' + afterNo.clicks.length + ' click(s)'); |
| 209 | |
| 210 | await resetDrv(); |
| 211 | const dClick = await withDialog(async () => { |
| 212 | return String(await window.__wDiamond.run_tool('web_click', JSON.stringify({ ref: 7 }))); |
| 213 | }, false); |
| 214 | check('a DIAMOND-dispatched worker\'s click is put to the user too', dClick.asked, |
| 215 | dClick.asked ? dClick.title : 'no dialog: a Diamond worker clicks Buy unasked'); |
| 216 | |
| 217 | // ── 2. Typing, and the user can see what would be sent ── |
| 218 | await resetDrv(); |
| 219 | const wType = await withDialog(async () => { |
| 220 | return String(await window.__wChat.run_tool('web_type', |
| 221 | JSON.stringify({ ref: 3, text: 'card-4111-1111-1111-1111', submit: true }))); |
| 222 | }, false); |
| 223 | check('a worker typing into a page is put to the user', wType.asked, wType.title); |
| 224 | check('and the person deciding is shown what would be sent', |
| 225 | /card-4111/.test(wType.body || ''), (wType.body || '').slice(0, 80)); |
| 226 | const typedAfterNo = await drv(); |
| 227 | check('declining stops the text going anywhere', |
| 228 | typedAfterNo.types.length === 0, JSON.stringify(typedAfterNo.types)); |
| 229 | |
| 230 | // ── 2a. A LONG text is shown whole, and shown legibly ── |
| 231 | // |
| 232 | // The body used to be cut at 300 characters, with an ellipsis and no sentence |
| 233 | // saying so: the reader was shown a prefix and told it was "this". A card |
| 234 | // number sitting past the cut was approved by somebody who had not seen it, |
| 235 | // which is the exact act this gate exists to put to them. So the payload here |
| 236 | // is an unbroken run with the number well past 300, and three things are |
| 237 | // asked of the RENDERED paragraph — the whole of it is there, nothing was |
| 238 | // elided, and none of it is hidden sideways off a 420px card. |
| 239 | await resetDrv(); |
| 240 | const pad = 'x'.repeat(700); |
| 241 | const long = pad + 'card-4111-2222-3333-4444' + pad; |
| 242 | const wLong = await withDialog(async (payload) => |
| 243 | String(await window.__wChat.run_tool('web_type', |
| 244 | JSON.stringify({ ref: 5, text: payload, submit: true }))), |
| 245 | false, long, long.slice(0, 24)); |
| 246 | check('a long text is quoted in full, not to the first 300 characters', |
| 247 | (wLong.msg?.text || '').includes(long), |
| 248 | 'shown ' + ((wLong.msg?.text || '').length) + ' chars of a ' |
| 249 | + long.length + '-char payload'); |
| 250 | check('and the number past the old cut is on screen, not elided', |
| 251 | /card-4111-2222-3333-4444/.test(wLong.msg?.text || '') |
| 252 | && !/…/.test(wLong.msg?.text || ''), |
| 253 | (wLong.msg?.text || '').includes('…') ? 'an ellipsis is still there' : ''); |
| 254 | check('and an unbroken run wraps instead of running off the card', |
| 255 | !!wLong.msg && wLong.msg.scrollW <= wLong.msg.clientW + 1, |
| 256 | wLong.msg ? wLong.msg.scrollW + 'px of text in a ' + wLong.msg.clientW + 'px box' |
| 257 | : 'no message paragraph found'); |
| 258 | check('and the quote is set apart from the sentence, not run into it', |
| 259 | !!wLong.msg && wLong.msg.drop !== null && wLong.msg.lineH > 0 |
| 260 | && wLong.msg.drop >= wLong.msg.lineH * 1.5 |
| 261 | && Math.abs(wLong.msg.quoteLeft - wLong.msg.left) < 2, |
| 262 | wLong.msg ? 'quote drops ' + Math.round(wLong.msg.drop) + 'px below the sentence, ' |
| 263 | + 'one line being ' + Math.round(wLong.msg.lineH) + 'px' : ''); |
| 264 | const longAfterNo = await drv(); |
| 265 | check('and declining still stops it', longAfterNo.types.length === 0, |
| 266 | JSON.stringify(longAfterNo.types).slice(0, 60)); |
| 267 | |
| 268 | // ── 3. The user's own chat is not asked ── |
| 269 | await resetDrv(); |
| 270 | const chatClick = await page.evaluate(async () => |
| 271 | String(await window.__chat.run_tool('web_click', JSON.stringify({ ref: 7 })))); |
| 272 | const chatDrv = await drv(); |
| 273 | check('the user\'s own chat is not asked, and acts', |
| 274 | chatDrv.clicks.length === 1, 'driver saw ' + chatDrv.clicks.length |
| 275 | + ' click(s); result ' + String(chatClick).slice(0, 60)); |
| 276 | |
| 277 | // ── 4. A worker's consent is not remembered ── |
| 278 | // The chat's click above approved shop.test for acting, which is right for a |
| 279 | // supervised actor. It must not carry to a worker, and a worker's own yes |
| 280 | // must not carry to its next act. |
| 281 | await resetDrv(); |
| 282 | const wAgain = await withDialog(async () => |
| 283 | String(await window.__wChat.run_tool('web_click', JSON.stringify({ ref: 9 }))), true); |
| 284 | check('a worker is asked even where the chat already approved the host', wAgain.asked); |
| 285 | const yesDrv = await drv(); |
| 286 | check('and a plain yes lets that one act through', |
| 287 | yesDrv.clicks.length === 1, 'driver saw ' + yesDrv.clicks.length); |
| 288 | |
| 289 | await resetDrv(); |
| 290 | const wThird = await withDialog(async () => |
| 291 | String(await window.__wChat.run_tool('web_click', JSON.stringify({ ref: 11 }))), false); |
| 292 | check('and the yes is not remembered: the next act asks again', wThird.asked); |
| 293 | |
| 294 | // ── 5. Bypass is still bypass ── |
| 295 | await setMode('bypass'); |
| 296 | await resetDrv(); |
| 297 | const byp = await page.evaluate(async () => |
| 298 | String(await window.__wChat.run_tool('web_click', JSON.stringify({ ref: 13 })))); |
| 299 | const bypDrv = await drv(); |
| 300 | check('a rung the user chose deliberately is not re-armed by this', |
| 301 | bypDrv.clicks.length === 1, 'driver saw ' + bypDrv.clicks.length |
| 302 | + '; result ' + String(byp).slice(0, 60)); |
| 303 | await setMode('guarded'); |
| 304 | } catch (e) { |
| 305 | check('no exception during the run', false, String(e && e.message || e)); |
| 306 | } finally { |
| 307 | try { await s.browser.close(); } catch (e) { /* ignore */ } |
| 308 | } |
| 309 | |
| 310 | console.log('\n' + ok.length + ' ok, ' + bad.length + ' failed'); |
| 311 | process.exit(bad.length ? 1 : 0); |