Oregami
Repositories/oxedyne/daimond

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.
47import { open } from './harness.mjs';
48
49const ok = [], bad = [];
50const check = (name, pass, detail) => {
51 (pass ? ok : bad).push(name + (detail ? ' — ' + detail : ''));
52 console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : ''));
53};
54
55const s = await open({ name: 'workerconsent', signIn: true, connect: false });
56const { page } = s;
57await 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.
65await 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.
85async 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`.
111async 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
176const 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
182const drv = () => page.evaluate(() => ({
183 clicks: window.__drv.clicks.slice(), types: window.__drv.types.slice() }));
184const resetDrv = () => page.evaluate(() => { window.__drv.clicks = []; window.__drv.types = []; });
185
186try {
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
310console.log('\n' + ok.length + ' ok, ' + bad.length + ' failed');
311process.exit(bad.length ? 1 : 0);