Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/dev/verify_chatfence.mjs

14.2 KiB, 1 run

created by r2519314175:271, 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_chatfence.mjs — a chat's worker reads freely, writes where it was told,
2// and runs commands only where the user deliberately put something.
3//
4// The fence is on the VERB and not on the surface, and since 2026-08-13 that is
5// the rule everywhere — a chat, a chat's worker, and a Diamond's daimon alike:
6//
7// * READING IS FREE. "Summarise these ten files" must not require attaching
8// ten files first. A worker reading what the chat could already read is
9// equal reach, not greater — a person asked the question either way.
10// * WRITING GOES WHERE THE USER MARKED: the chat's own working folder, and
11// whatever they marked into its workspace.
12// * A COMMAND COUNTS AS A WRITE, because there is no way to look at an argv
13// and say whether it alters anything. No attachment, no command.
14// * AND A COMMAND'S READING IS NOT SPLIT OUT EITHER. The same sentence that
15// makes a command a write makes it opaque: it can read a million files,
16// follow a symlink out, and hold the network on a clean turn. So its fence
17// names the marked folders for both verbs, the granted root appears in
18// NEITHER list, and this file asserts that — the compartment
19// `dev/verify_scope.mjs` proves through the kernel depends on it.
20//
21// "No attachment, no command" is not enforced by a rule of its own: a chat's
22// scratch lives under `chats/`, which `is_store_path` answers for, so
23// `fence_spec` cannot map it onto the machine and `default_cwd` skips it. The
24// refusal falls out of where the folder lives, which is why it cannot drift from
25// the rule it implements.
26//
27// EACH CHECK IS PROVED AGAINST BROKEN CODE FIRST.
28//
29// node dev/verify_chatfence.mjs --break markall # 1 fails: writing widens
30// node dev/verify_chatfence.mjs --break writeopen # 2 fails: nothing in scope
31// node dev/verify_chatfence.mjs --break noscratch # 3 fails: no working folder
32// node dev/verify_chatfence.mjs # and then, clean
33//
34// The breaks are applied to the SCOPE THE PAGE ASKS FOR, not to the engine: the
35// engine is the thing under test, and a break that damaged it would prove only
36// that a damaged engine misbehaves. Each one is a plausible caller mistake.
37//
38// THERE IS NO CALLER BREAK FOR THE READ CHECKS, and that is worth saying rather
39// than leaving as a gap: no argument to `set_chat_scope` can fence a read any
40// more, because the scope it builds declares no read fence at all. That property
41// is proved red at the engine instead — `cargo test --lib tools::` with
42// `diamond_bounds` emitting `Bound::OnlyUnder`, which is the 2026-08-12
43// regression put back, turns sixteen unit tests red including the reads below.
44import { open } from './harness.mjs';
45
46const BREAK = (() => {
47 const i = process.argv.indexOf('--break');
48 return i > 0 ? String(process.argv[i + 1] || '') : '';
49})();
50
51const ok = [], bad = [];
52const check = (name, pass, detail) => {
53 (pass ? ok : bad).push(name + (detail ? ' — ' + detail : ''));
54 console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : ''));
55};
56
57const s = await open({ name: 'chatfence', signIn: true, connect: false });
58const { page } = s;
59await page.waitForFunction(() => !!window.DaimondCore, null, { timeout: 15000 }).catch(() => {});
60
61try {
62 // A HAND HAS TO BE PAIRED, or `run` refuses at the first gate — "no machine hand
63 // paired with this browser" — and never reaches the rule under test. That is a
64 // refusal for the wrong reason, and a check that reads it as the right one is a
65 // false green: it would pass with the whole fence deleted.
66 //
67 // Only the RELAY is stubbed, and it reports a real root and a real fence
68 // capability, so everything the rule depends on — `Machine::from_status`,
69 // `fence_enforced`, `default_cwd`, `fence_spec` — is the shipped code. What it
70 // records is what a command would have been ALLOWED to do, which is how "no
71 // attachment, no command" is asserted at the hand rather than at the model's
72 // reply.
73 await page.evaluate(() => {
74 window.__hand = { runs: [] };
75 window.DaimondHand = {
76 // The question `hand::present()` asks (src/wasm/hand.rs). `hand.js` installs a
77 // relay on every page, paired or not, so its mere presence says nothing and a
78 // stub that leaves this out is an UNPAIRED page however much else it answers.
79 hasHand: () => true,
80 status: async () => ({
81 paired: true, os: 'linux (stub)', root: '/home/tester/granted',
82 home: '/home/tester', caps: ['fence:linux'],
83 }),
84 // The file door (`Req::File`, 2026-08-25). A file tool whose path is under a
85 // mark goes through the hand now, so a stub without this is a hand that cannot
86 // carry one -- which the engine answers with a refusal, correctly, and which
87 // would make every write below red for a reason that is not this file's subject.
88 file: async (specJson) => {
89 const spec = JSON.parse(specJson);
90 window.__hand.files = window.__hand.files || [];
91 window.__hand.files.push(spec);
92 return JSON.stringify({ ok: true, text: '' });
93 },
94 run: async (specJson) => {
95 const spec = JSON.parse(specJson);
96 window.__hand.runs.push(spec);
97 return { exit_code: 0, stdout: 'stub ran ' + (spec.argv || []).join(' '), stderr: '' };
98 },
99 };
100 });
101
102 // Lay down two files the worker never had attached, and one folder it did.
103 // Written through an UNSCOPED app, which is what the user's own chat is.
104 //
105 // THE FOLDERS ARE MADE FIRST, and that is not tidiness. A hand is paired above and no
106 // folder is open, so `file_write` refuses a write that would INVENT a folder in browser
107 // storage rather than landing it silently in the wrong filesystem (`write_place`,
108 // src/tools.rs, 2026-08-24). A user reaches this state by attaching a folder that
109 // EXISTS; a fixture reaches it by saying so. `dev/verify_writeplace.mjs` is the same two
110 // calls asserted rather than assumed.
111 await page.evaluate(async () => {
112 const mod = await import('../pkg/oxedyne_daimond.js');
113 const app = new mod.DaimondApp('http://127.0.0.1/v1/chat/completions', '', 'none', 256, '', true);
114 await app.run_tool('dir_create', JSON.stringify({ path: 'elsewhere' }));
115 await app.run_tool('dir_create', JSON.stringify({ path: 'papers' }));
116 await app.run_tool('file_write', JSON.stringify({ path: 'elsewhere/notes.md', content: 'the user own note\n' }));
117 await app.run_tool('file_write', JSON.stringify({ path: 'papers/spec.md', content: 'attached spec\n' }));
118 window.__seed = true;
119 });
120
121 /// A chat's worker, scoped exactly as `scopeChatTo` scopes one.
122 const worker = async (attached, brk) => await page.evaluate(async ({ attached, brk }) => {
123 const mod = await import('../pkg/oxedyne_daimond.js');
124 const app = new mod.DaimondApp('http://127.0.0.1/v1/chat/completions', '', 'none', 256, '', true);
125 app.set_unsupervised();
126 let scratch = 'chats/c-test/work';
127 let list = attached.slice();
128 // The breaks, each a caller mistake rather than a damaged engine.
129 if (brk === 'markall') {
130 // The mistake ATTACH_CONTRACT §6 is about: reading the paperclip's whole
131 // list as a permission list, so a path attached only to be QUOTED is
132 // marked into the workspace and becomes writable. Now that reading is
133 // free this is a pure widening of the write fence, which is exactly the
134 // direction that must never go unnoticed.
135 list = list.concat(['elsewhere']);
136 }
137 if (brk === 'writeopen') scratch = ''; // no working folder named…
138 if (brk === 'writeopen') list = []; // …and nothing attached either
139 if (brk === 'noscratch') scratch = '';
140 app.set_chat_scope(scratch, JSON.stringify(list));
141 window.__app = app;
142 return JSON.parse(app.diamond_scope() || '{}');
143 }, { attached, brk });
144
145 const tool = (name, args) => page.evaluate(
146 ({ name, args }) => window.__app.run_tool(name, JSON.stringify(args)).then(String),
147 { name, args });
148
149 // ── With nothing attached ──
150 const bare = await worker([], BREAK);
151 check('a chat worker declares a write fence and no read fence',
152 Array.isArray(bare.write_allow) && bare.write_allow.length > 0
153 && (bare.allow || []).length === 0,
154 JSON.stringify(bare));
155
156 // THE DECISION A REVIEWER STOPS AT, asserted rather than implied: this worker
157 // was marked UNSUPERVISED above (`app.set_unsupervised()`, the same call
158 // `markAlone` makes in daimond.js), and it reads freely all the same. The
159 // answer to the fear underneath the question is the network, not the fence —
160 // an unattended actor loses it on a clean turn as much as a dirty one, which
161 // the fence check below asserts, so a worker that reads widely cannot post
162 // what it read.
163 const alone = await page.evaluate(() => window.__app.is_unsupervised());
164 check('the worker under test is genuinely unattended, or the reads below prove nothing',
165 alone === true, String(alone));
166
167 const readFar = await tool('file_read', { path: 'elsewhere/notes.md' });
168 check('reading is free, even for an unattended worker: a file nobody attached is readable',
169 /the user own note/.test(readFar), readFar.slice(0, 90));
170
171 const writeFar = await tool('file_write', { path: 'elsewhere/notes.md', content: 'clobbered\n' });
172 check('writing is not: the same file cannot be written',
173 /Refused/.test(writeFar), writeFar.slice(0, 90));
174
175 const stillThere = await tool('file_read', { path: 'elsewhere/notes.md' });
176 check('and the refusal is real — the file is untouched',
177 /the user own note/.test(stillThere) && !/clobbered/.test(stillThere),
178 stillThere.slice(0, 90));
179
180 const writeScratch = await tool('file_write', { path: 'chats/c-test/work/draft.md', content: 'mine\n' });
181 check('a worker always has its own working folder to write in',
182 !/Refused/.test(writeScratch), writeScratch.slice(0, 90));
183
184 // No attachment, no command. Asserted on the REFUSAL and on its wording being
185 // the chat's own: the Diamond sentence points at a Diamond that does not exist
186 // and at a panel that is not where this is fixed.
187 await page.evaluate(() => { window.__hand.runs = []; });
188 const ranBare = await tool('run', { argv: ['echo', 'hello'] });
189 const bareRuns = await page.evaluate(() => window.__hand.runs.length);
190 check('with nothing attached, a command is refused', /Refused/.test(ranBare), ranBare.slice(0, 110));
191 // Asked AT THE HAND. A refusal in the reply text with the command already run is
192 // the failure this is here to catch, and only the hand can tell them apart.
193 check('and nothing reached the machine', bareRuns === 0, 'the hand saw ' + bareRuns + ' run(s)');
194 check('and the refusal is about this chat, not about a Diamond',
195 /chat/i.test(ranBare) && !/this Diamond has no folder/i.test(ranBare),
196 ranBare.slice(0, 140));
197
198 // ── With a folder attached ──
199 const held = await worker(['papers'], BREAK);
200 check('an attached folder is in the write fence',
201 (held.write_allow || []).indexOf('papers') >= 0, JSON.stringify(held.write_allow));
202
203 const writeHeld = await tool('file_write', { path: 'papers/spec.md', content: 'edited by the worker\n' });
204 check('and what the user attached can be written',
205 !/Refused/.test(writeHeld), writeHeld.slice(0, 90));
206
207 const writeFar2 = await tool('file_write', { path: 'elsewhere/notes.md', content: 'clobbered\n' });
208 check('while everything else still cannot be',
209 /Refused/.test(writeFar2), writeFar2.slice(0, 90));
210
211 const readFar2 = await tool('file_read', { path: 'elsewhere/notes.md' });
212 check('and reading everything else still can',
213 /the user own note/.test(readFar2), readFar2.slice(0, 90));
214
215 // The other half of "no attachment, no command": WITH one, a command runs, and
216 // it runs inside the attached folder rather than at the granted root. A rule
217 // that only ever refuses is indistinguishable from the tool being broken.
218 await page.evaluate(() => { window.__hand.runs = []; });
219 const ranHeld = await tool('run', { argv: ['echo', 'hello'] });
220 const heldRun = await page.evaluate(() => window.__hand.runs[0] || null);
221 check('with a folder attached, a command runs', !/Refused/.test(ranHeld), ranHeld.slice(0, 110));
222 check('and it runs in the attached folder, not at the granted root',
223 !!heldRun && /\/papers$/.test(String(heldRun.cwd || '')),
224 heldRun ? String(heldRun.cwd) : 'the hand saw nothing');
225 // The fence the command actually carried, and the ONE place the verb split is
226 // deliberately not followed. `rw` is the marked folder; the granted root is in
227 // NEITHER list, so a command reads exactly where it may write and no further.
228 // A program cannot be asked what it will do, which is the same sentence that
229 // makes it a write — and a fence handing it the whole root read-only would put
230 // one `tar | curl` between a stranger's instruction and everything the user
231 // owns, and would dissolve the compartment dev/verify_scope.mjs proves through
232 // the kernel.
233 const fence = heldRun && heldRun.fence;
234 check('the fence gives the marked folder to a command and the granted root to nothing',
235 !!fence && (fence.rw || []).some(p => /\/papers$/.test(p))
236 && !(fence.rw || []).includes('/home/tester/granted')
237 && !(fence.ro || []).includes('/home/tester/granted'),
238 JSON.stringify(fence));
239 check('and Daimond\'s own directory is denied to it outright',
240 !!fence && (fence.deny || []).some(p => /\/\.daimond$/.test(p)),
241 JSON.stringify(fence && fence.deny));
242 // An unattended worker gets no network inside a command, on a clean turn as
243 // much as a dirty one: it cannot be asked about a destination, so the
244 // alternative is a process reaching anywhere with nobody in the loop.
245 check('and an unattended worker\'s command has no network',
246 !!fence && fence.net === false, JSON.stringify(fence && fence.net));
247
248 // Daimond's own directory is out of bounds in a chat's scope too — this is the
249 // one scope that reads freely otherwise, so the deny has more work to do here.
250 const readOwn = await tool('file_read', { path: '.daimond/config.json' });
251 check('Daimond\'s own directory is not readable even so',
252 /Refused/.test(readOwn), readOwn.slice(0, 90));
253} catch (e) {
254 check('no exception during the run', false, String(e && e.message || e));
255} finally {
256 try { await s.browser.close(); } catch (e) { /* ignore */ }
257}
258
259console.log('\n' + ok.length + ' ok, ' + bad.length + ' failed');
260if (BREAK) {
261 console.log(bad.length
262 ? `\nbreak '${BREAK}' produced failures, as it must.`
263 : `\nBREAK '${BREAK}' CHANGED NOTHING — the check it targets is not proving anything.`);
264 process.exit(bad.length ? 0 : 1);
265}
266process.exit(bad.length ? 1 : 0);