Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/dev/verify_egressconvo.mjs

22.0 KiB, 1 run

created by r2519314175:395, 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_egressconvo.mjs — one ask per conversation, and it covers every website.
2//
3// THE DEFECT, reported by a tester on 2026-08-26 and ruled on by the owner the
4// next day. The tester wrote: *"Permission sought for every new web_fetch, yet
5// the 'THE NETWORK' in the Permission mode dialog says 'Always allow', so I'm
6// confused."* A lane read that as a LABEL fault — the setting governs whether a
7// command keeps its network after the turn has read a stranger's words, and a
8// page fetch goes through a different door — renamed the head to "Commands and
9// the network", and shipped a line saying what it is not. That reading of the
10// two mechanisms was right and is still right.
11//
12// THE OWNER THEN RULED THAT THE BEHAVIOUR WAS ALSO THE BUG: *"I should only be
13// asked once in a session (i.e. new ordinary chat or fresh daimon) for
14// permission to access ANY (not a specific website) ... At the moment, every
15// website is triggering a new permission request for that url."*
16//
17// WHAT IT ACTUALLY DID. `_egressOk` in www/js/daimond.js was an object keyed by
18// HOST, living as long as the page. So the scope was wrong twice over and in
19// opposite directions: every new site asked again, and a yes given in one chat
20// silently answered for the next chat, and for every daimon in the tab. The
21// answer now lives on the engine's `TurnState`, where a conversation is a thing
22// that exists, and `web_step` in src/tools.rs decides from it.
23//
24// NINE PROPERTIES. The first four are the owner's sentence, in order.
25//
26// 1. A CLEAN TURN IS NEVER ASKED. Held first, because every check after it is
27// only meaningful about a turn where the question is genuinely live — and
28// because a gate that asked on every fetch would pass 2 and 3 by asking
29// nobody anything.
30// 2. TWO SITES, ONE ASK. The reported defect exactly: two `web_fetch` calls to
31// two different hosts in one turn used to be two dialogs.
32// 3. AND WHAT THE ASK SAYS. Half the deliverable, and the half a count cannot
33// see. The user is agreeing that this conversation may reach ANY website,
34// which is materially bigger than "may reach example.com" — so the dialog
35// has to say so. A wide grant collected in the words of a narrow one is the
36// failure the label change was fixing, arriving from the other direction.
37// 4. A LATER TURN IN THE SAME CHAT IS NOT ASKED. Separate from 2 because the
38// grant surviving one turn and the grant surviving the conversation are
39// different claims, and the old code satisfied neither.
40// 5. A NEW CHAT ASKS AGAIN. The narrowing half of the ruling, and the one a
41// wider fix would have quietly lost: `_egressOk` outlived every chat.
42// 6. A DAIMON IS ITS OWN CONVERSATION. The chat's yes does not reach it. This
43// is the sharp one, because a Diamond's client is CACHED BY PROVIDER AND
44// MODEL (`diamondApp`), so the daimon shares one engine cache with every
45// other Diamond on that model; an answer held in a plain `Option` there
46// would be every Diamond's answer. Held in Rust as well, by
47// `test_one_diamonds_yes_is_not_another_diamonds_on_a_shared_client`.
48// 7. AND A LONG ADDRESS IS STILL ASKED ABOUT. The limit on the grant, and the
49// reason the grant is safe to give: approving the web is not approving
50// `somewhere.test/?everything-I-know=…`, which is the exfiltration this
51// whole gate exists to catch. Measured AFTER the conversation has granted
52// the web, which is the only state in which it can fail.
53// 9. AND SO IS THE DIAMOND NEXT TO IT. Added 2026-08-28. Check 6 says a chat's
54// yes does not answer for a daimon; this says a Diamond's READING does not
55// cross to the Diamond beside it on the same client. `TurnState::tainted` was
56// a bare `bool` on a cache every Diamond on one model shares, so a Research
57// Diamond that fetched a page cut an Accounts Diamond's network and stamped
58// its dispatched workers as carrying a stranger's words. The sharing is
59// asserted before the property is, which is the lesson the Rust test next door
60// taught by not doing it.
61// 8. AND OUR OWN PAGES GRANT NOTHING. The same-origin shortcut answers with
62// nobody asked, and the word it answers in now decides whether a standing
63// grant is written. A fetch of Daimond's own address — which the model can
64// write for itself — must not hand over every site in silence. Held as a
65// pair: the shortcut still passing is not the property, the next site being
66// asked about is.
67//
68// PROVED AGAINST BROKEN CODE FIRST, each break chosen to survive every check but
69// the ones under test:
70//
71// node dev/verify_egressconvo.mjs --break perhost # 2, 4, 6: ask about every site again
72// node dev/verify_egressconvo.mjs --break narrowask # 3: the wide grant, narrow words
73// node dev/verify_egressconvo.mjs --break heavyfree # 7: a long address rides the grant
74// node dev/verify_egressconvo.mjs --break sameorigin # 8: our own pages record a grant
75// node dev/verify_egressconvo.mjs # and then, clean
76//
77// `perhost` is the sharp one: it restores the reported defect exactly, by making
78// the page answer a granted conversation as though it had never been asked.
79//
80// `sameorigin` is the subtle one. The same-origin shortcut answers `allow` for
81// Daimond's own pages, with nobody asked — and `allow` is now the word that
82// RECORDS a standing grant. Restoring it grants the whole conversation on the
83// strength of a request nobody saw.
84//
85// IT REDDENED NOTHING AT FIRST, and that was a finding about the checks rather
86// than about the break: nothing in the run fetched our own origin, so the
87// shortcut was never reached. Check 8 exists because of it.
88//
89// THE MARK IS SET DIRECTLY, through `DaimondCore.markRead`, which is the same
90// one-way flag every real path ends at. Which reads produce it is a Rust
91// question and is answered there.
92//
93// eval "$(bash dev/world.sh 4 --up)"
94// node dev/verify_egressconvo.mjs
95//
96// Needs dev/serve.mjs and the mock. No gateway. Needs the wasm to have been
97// rebuilt since src/ last changed.
98import fs from 'node:fs';
99import path from 'node:path';
100import { fileURLToPath } from 'node:url';
101import { open, newChat, scratch, shot } from './harness.mjs';
102
103const HERE = path.dirname(fileURLToPath(import.meta.url));
104const WWW = path.join(HERE, '..', 'www');
105
106const BREAK = (() => {
107 const i = process.argv.indexOf('--break');
108 return i > 0 ? String(process.argv[i + 1] || '') : '';
109})();
110
111// Each break is one real edit to one real file, served in its place. All four
112// live in the page, because that is the half a served file can reach; the engine
113// half is broken in Rust, by dev/../src/tools.rs's own tests.
114const BREAKS = {
115 // The conversation's grant never taken into account: every fetch draws the
116 // dialog again, which is the defect as reported.
117 perhost: {
118 file: 'js/daimond.js',
119 find: "\t\tif (granted) return 'allow-once';",
120 with: "\t\tif (false) return 'allow-once';",
121 },
122 // The wide grant asked for in the words of a narrow one — the old per-site
123 // question, in front of a yes that now covers everything.
124 narrowask: {
125 file: 'js/daimond.js',
126 find: "\t\t\tt('egress.any_body', { host: host }),\n\t\t\tt('egress.any_ok'),\n\t\t\t{ title: t('egress.any_title'), danger: true });",
127 with: "\t\t\tt('egress.reach_body', { host: host }),\n\t\t\tt('egress.reach_ok', { host: host }),\n\t\t\t{ title: t('egress.reach_title', { host: host }), danger: true });",
128 },
129 // The payload test moved below the grant, where it can no longer fire — which
130 // is the one edit that would make this widening genuinely dangerous.
131 heavyfree: {
132 file: 'js/daimond.js',
133 find: "\t\tif (load.heavy) {",
134 with: "\t\tif (load.heavy && !granted) {",
135 },
136 // The daimon's mark put on the shared client WITHOUT naming the Diamond, which
137 // is where it landed before the taint was keyed by conversation: on the client's
138 // own key, which is the empty string and belongs to no Diamond at all. Reddens
139 // check 9's precondition -- and check 6 with it, honestly: a mark that lands on
140 // nobody's conversation is not the daimon's either. It is the only half of that
141 // fault a served file can reach; the bleed itself is a Rust field, broken and
142 // held there by
143 // `test_one_diamonds_reading_does_not_taint_another_on_a_shared_client`.
144 unnamedmark: {
145 file: 'js/daimond.js',
146 find: "\t\t\t\tif (da && da.set_tainted) { da.set_tainted(current.diamondId); return true; }",
147 with: "\t\t\t\tif (da && da.set_tainted) { da.set_tainted(); return true; }",
148 },
149 // Our own origin answering in the word that records a standing grant.
150 sameorigin: {
151 file: 'js/daimond.js',
152 find: "\t\tif (!strict && host === location.host) return reading ? 'allow-once' : 'allow';",
153 with: "\t\tif (!strict && host === location.host) return 'allow';",
154 },
155};
156if (BREAK && !BREAKS[BREAK]) {
157 console.error(`unknown break '${BREAK}'; one of: ${Object.keys(BREAKS).join(', ')}`);
158 process.exit(2);
159}
160
161let bad = 0;
162const check = (pass, name, detail) => {
163 if (!pass) bad++;
164 console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : ''));
165};
166
167const stub = async (page) => {
168 if (!BREAK) return;
169 const spec = BREAKS[BREAK];
170 const src = fs.readFileSync(path.join(WWW, spec.file), 'utf8');
171 // An anchor that is not there exactly once patches nothing and the run would
172 // pass quietly, which is worse than a red.
173 if (src.split(spec.find).length !== 2) {
174 console.error(`break '${BREAK}': its anchor is not in ${spec.file} exactly once`);
175 process.exit(2);
176 }
177 const body = src.replace(spec.find, spec.with);
178 await page.route('**/' + spec.file, (r) => r.fulfill({
179 status: 200, contentType: 'application/javascript', body,
180 }));
181};
182
183const s = await open({
184 name: 'egressconvo',
185 profile: scratch('pw', 'egressconvo' + (BREAK ? '-' + BREAK : '')),
186 route: stub,
187});
188const { page: p } = s;
189if (BREAK) console.log(`\n*** RUNNING UNDER --break ${BREAK}: failures below are the point ***\n`);
190
191/// Every dialog raised while `body` runs, answered the way `yes` says, with the
192/// text of each one kept.
193///
194/// COUNTED BY ANSWERING THEM, not by watching for one and stopping. A gate that
195/// raised two dialogs would otherwise look exactly like a gate that raised one:
196/// the second sits unanswered behind the first, the turn never finishes, and the
197/// check reads "a dialog appeared" and passes.
198const asks = async (body, { yes = true, snap = '' } = {}) => {
199 const seen = [];
200 let stop = false;
201 const pump = (async () => {
202 while (!stop) {
203 // READ, then photograph, then answer -- three steps and not one, because a
204 // dialog that has already been clicked is not on screen to be photographed,
205 // and the wording is half of what this file is for.
206 const got = await p.evaluate(() => {
207 const card = [...document.querySelectorAll('.dlg-card')]
208 .filter(c => c.getClientRects().length).pop();
209 if (!card) return null;
210 return {
211 msg: (card.querySelector('.dlg-msg') || card).textContent || '',
212 title: (card.querySelector('h2') || {}).textContent || '',
213 ok: (card.querySelector('.dlg-ok') || {}).textContent || '',
214 };
215 }).catch(() => null);
216 if (got) {
217 if (snap && !seen.length) await shot(s, snap);
218 await p.evaluate((ok) => {
219 const card = [...document.querySelectorAll('.dlg-card')]
220 .filter(c => c.getClientRects().length).pop();
221 if (!card) return;
222 const b = card.querySelector(ok ? '.dlg-ok' : '.dlg-cancel')
223 || card.querySelector('.dlg-ok');
224 if (b) b.click();
225 }, yes).catch(() => {});
226 seen.push(got);
227 }
228 await p.waitForTimeout(250);
229 }
230 })();
231 await body();
232 stop = true;
233 await pump;
234 return seen;
235};
236
237/// One turn in whatever conversation is in focus, and the dialogs it raised.
238const turn = (text, { yes = true, wait = 6000, snap = '' } = {}) => asks(async () => {
239 await p.fill('#chat-input', text);
240 await p.click('#chat-send', { force: true });
241 await p.waitForTimeout(wait);
242}, { yes: yes, snap: snap });
243
244const mark = () => p.evaluate(() => !!(window.DaimondCore && DaimondCore.markRead()));
245
246const FETCH = (u) => `@tool web_fetch {"url":"${u}"}`;
247
248try {
249 // ── 1. A clean turn is never asked ───────────────────────────
250 await newChat(s);
251 const clean = await turn(FETCH('https://alpha.test/one'));
252 check(clean.length === 0,
253 'A TURN THAT HAS READ NOTHING IS NOT ASKED AT ALL',
254 `${clean.length} dialog(s)`);
255
256 // ── 2 and 3. Two sites, one ask, and what it says ────────────
257 const marked = await mark();
258 check(marked, 'the conversation can be marked as having read outside content',
259 marked ? '' : 'DaimondCore.markRead did not take');
260 // TWO CALLS IN ONE TURN, to two different hosts. Every one of these was its
261 // own dialog before, and the second is the one the tester was reporting.
262 const two = await asks(async () => {
263 await p.fill('#chat-input',
264 '@tools web_fetch {"url":"https://alpha.test/one"} ;; web_fetch {"url":"https://beta.test/two"}');
265 await p.click('#chat-send', { force: true });
266 await p.waitForTimeout(9000);
267 }, { snap: 'egressconvo-ask' });
268 check(two.length === 1,
269 'TWO DIFFERENT SITES IN ONE TURN RAISE EXACTLY ONE ASK',
270 `${two.length} dialog(s): ${JSON.stringify(two.map(x => x.title))}`);
271 const said = two[0] || { msg: '', title: '', ok: '' };
272 const whole = (said.title + ' ' + said.msg + ' ' + said.ok).toLowerCase();
273 // AGAINST THE APP'S OWN STRING, not against words this file chose: the copy
274 // will be reworded, and a literal from today's draft would leave the check
275 // unable to fail for the right reason later.
276 const wide = await p.evaluate(() => ({
277 title: DaimondI18n.t('egress.any_title'),
278 ok: DaimondI18n.t('egress.any_ok'),
279 body: DaimondI18n.t('egress.any_body', { host: 'alpha.test' }),
280 narrow: DaimondI18n.t('egress.reach_title', { host: 'alpha.test' }),
281 }));
282 check(said.title.trim() === wide.title.trim() && said.ok.trim() === wide.ok.trim(),
283 'and it is the WIDE question, not the old one about a single site',
284 `title=${JSON.stringify(said.title)} ok=${JSON.stringify(said.ok)}`);
285 // The pair, because either half alone is satisfied by a reworded narrow
286 // dialog: it must say the grant covers ANY site, and it must say where the
287 // grant stops.
288 check(/any website/.test(whole) && /this conversation/.test(whole),
289 'AND IT SAYS PLAINLY WHAT IS BEING GRANTED — any website, this conversation',
290 JSON.stringify(said.msg.slice(0, 180)));
291
292 // ── 4. A later turn in the same chat ─────────────────────────
293 const third = await turn(FETCH('https://gamma.test/three'));
294 check(third.length === 0,
295 'A THIRD SITE, IN A LATER TURN OF THE SAME CHAT, IS NOT ASKED',
296 `${third.length} dialog(s)`);
297
298 // ── 7. And a long address is still its own question ──────────
299 //
300 // Here, while the conversation HAS granted the web, because that is the only
301 // state in which this can fail.
302 const heavy = await turn(FETCH(
303 'https://gamma.test/x?carry=' + 'A'.repeat(200)), { wait: 8000 });
304 check(heavy.length === 1,
305 'AN ADDRESS CARRYING A PAYLOAD IS STILL ASKED ABOUT, grant or no grant',
306 `${heavy.length} dialog(s): ${JSON.stringify(heavy.map(x => x.title))}`);
307
308 // ── 5. A new chat asks again ─────────────────────────────────
309 //
310 // A TURN FIRST, and it is not a flourish: `ensureApp` builds a chat's engine on
311 // its first send, so a chat that has said nothing has no engine to mark and
312 // `markRead` silently answers false. Written the other way round first, and the
313 // check duly read "not asked" about a chat that was never tainted — which is a
314 // check that cannot fail rather than a check that passed.
315 await newChat(s);
316 await turn('@text a brand new chat');
317 const remarked = await mark();
318 check(remarked, 'the new chat can be marked too',
319 remarked ? '' : 'markRead did not take on the new chat');
320 const fresh = await turn(FETCH('https://alpha.test/one'));
321 check(fresh.length === 1,
322 'A NEW CHAT ASKS AGAIN — the grant does not cross a conversation',
323 `${fresh.length} dialog(s)`);
324
325 // ── 6. A daimon is its own conversation ──────────────────────
326 //
327 // Made the way a person makes one, and steered through the same composer, so
328 // what is measured is the daimon's own engine and not a second path.
329 await p.evaluate(() => document.getElementById('new-diamond-btn').click());
330 await p.waitForSelector('.dlg-card', { timeout: 8000 });
331 await p.evaluate(() => {
332 const card = [...document.querySelectorAll('.dlg-card')].filter(c => c.getClientRects().length).pop();
333 const inp = card.querySelector('input.dlg-input');
334 inp.value = 'Reaching';
335 inp.dispatchEvent(new Event('input', { bubbles: true }));
336 card.querySelector('.dlg-ok').click();
337 });
338 await p.waitForTimeout(1800);
339 await p.$$eval('.diamond-box', els => els[0] && els[0].click());
340 await p.waitForTimeout(1200);
341 const dmark = await mark();
342 check(dmark, 'the daimon can be marked as having read outside content',
343 dmark ? '' : 'markRead did not reach the daimon');
344 const daimon = await turn(FETCH('https://alpha.test/one'), { wait: 9000 });
345 check(daimon.length === 1,
346 'A DAIMON IS ITS OWN CONVERSATION — a chat’s yes does not answer for it',
347 `${daimon.length} dialog(s)`);
348
349 // ── 9. AND SO IS THE DIAMOND NEXT TO IT ──────────────────────
350 //
351 // TWO DIAMONDS ON ONE MODEL. `diamondApp` caches one client per provider and
352 // model, a daimon turn clones that client's `read_seen` cache, and until
353 // 2026-08-28 the taint on it was a bare `bool`. So a Research Diamond that
354 // fetched a page took the network away from an Accounts Diamond that had
355 // touched nothing external, and stamped its dispatched workers as carrying a
356 // stranger's words. Held in Rust by
357 // `test_one_diamonds_reading_does_not_taint_another_on_a_shared_client`; held
358 // HERE because the Rust half cannot see whether the app names the Diamond when
359 // it marks and reads the flag, and unnamed the mark lands on the shared
360 // client's own conversation, which is nobody's.
361 //
362 // THE SHARING IS ASSERTED, not assumed. Two Diamonds that happened to sit on
363 // two clients would pass every check below while proving nothing at all -- which
364 // is the fault the Rust test next door was carrying, and the reason this line
365 // exists.
366 await p.evaluate(() => document.getElementById('new-diamond-btn').click());
367 await p.waitForSelector('.dlg-card', { timeout: 8000 });
368 await p.evaluate(() => {
369 const card = [...document.querySelectorAll('.dlg-card')].filter(c => c.getClientRects().length).pop();
370 const inp = card.querySelector('input.dlg-input');
371 inp.value = 'Accounts';
372 inp.dispatchEvent(new Event('input', { bubbles: true }));
373 card.querySelector('.dlg-ok').click();
374 });
375 await p.waitForTimeout(1800);
376 const twoD = await p.evaluate(() => {
377 const rows = [...document.querySelectorAll('.diamond-box[data-id]')]
378 .map(e => ({ id: e.dataset.id, name: (e.textContent || '').trim() }));
379 const read = rows.find(r => /Reaching/.test(r.name)) || null;
380 const clean = rows.find(r => /Accounts/.test(r.name)) || null;
381 // ONE CLIENT, ASKED ABOUT BOTH. `diamondApp()` is the client for the starred
382 // model, which is the model a Diamond made with no pin runs on -- so this is
383 // the object both Diamonds' daimon turns clone their cache from.
384 const app = window.DaimondCore.diamondApp();
385 const ask = (r) => (r && app && app.is_tainted) ? !!app.is_tainted(r.id) : null;
386 return { read, clean, dirtyMark: ask(read), cleanMark: ask(clean) };
387 });
388 check(!!twoD.read && !!twoD.clean,
389 'a second Diamond exists beside the one that read a page',
390 JSON.stringify([twoD.read, twoD.clean]));
391 // THE PRECONDITION, STATED. This client knowing about the FIRST Diamond's
392 // reading is what proves the two share a cache at all; without it the check
393 // below would be two Diamonds on two clients, which never had the fault and
394 // would pass for no reason.
395 check(twoD.dirtyMark === true,
396 'and the shared client carries the first Diamond’s reading, or nothing below is about sharing',
397 `mark=${String(twoD.dirtyMark)}`);
398 check(twoD.cleanMark === false,
399 'A DIAMOND THAT READ NOTHING IS NOT MARKED BY THE ONE THAT DID',
400 `mark=${String(twoD.cleanMark)}`);
401 // AND WHAT THE USER MEETS: the clean Diamond keeps its network and is not
402 // interrupted. Measured by driving it, because the flag reading clean and the
403 // gate acting on it are two claims.
404 await p.evaluate((id) => {
405 const esc = (window.CSS && CSS.escape) ? CSS.escape(id) : id;
406 const box = document.querySelector('.diamond-box[data-id="' + esc + '"]');
407 if (box) box.click();
408 }, twoD.clean ? twoD.clean.id : '');
409 await p.waitForTimeout(1200);
410 const untouchedDaimon = await turn(FETCH('https://delta.test/four'), { wait: 9000 });
411 check(untouchedDaimon.length === 0,
412 'and it is never asked — a stranger’s words stop at the Diamond that read them',
413 `${untouchedDaimon.length} dialog(s)`);
414
415 // ── 8. Our own pages grant nothing ───────────────────────────
416 //
417 // The same-origin shortcut answers without asking anybody, and the word it
418 // answers in is now the difference between "this one page" and "the whole
419 // conversation". If it says the wide word, a fetch of Daimond's own address —
420 // which the model can write for itself — hands over every site, with no
421 // dialog ever drawn. Measured as a PAIR, because the shortcut passing is not
422 // the property: what matters is that the NEXT site is still asked about.
423 await newChat(s);
424 await turn('@text a chat for our own pages');
425 await mark();
426 const ownUrl = await p.evaluate(() => location.origin + '/index.html');
427 const own = await turn(FETCH(ownUrl));
428 check(own.length === 0,
429 'Daimond’s own pages are not put to the user',
430 `${own.length} dialog(s)`);
431 const after = await turn(FETCH('https://alpha.test/one'));
432 check(after.length === 1,
433 'AND THEY GRANT NOTHING — the next site is still asked about',
434 `${after.length} dialog(s)`);
435} finally {
436 await s.close();
437}
438
439console.log(bad ? `\n${bad} check(s) FAILED` : '\nall checks passed');
440process.exit(bad ? 1 : 0);