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. |
| 44 | import { open } from './harness.mjs'; |
| 45 | |
| 46 | const BREAK = (() => { |
| 47 | const i = process.argv.indexOf('--break'); |
| 48 | return i > 0 ? String(process.argv[i + 1] || '') : ''; |
| 49 | })(); |
| 50 | |
| 51 | const ok = [], bad = []; |
| 52 | const check = (name, pass, detail) => { |
| 53 | (pass ? ok : bad).push(name + (detail ? ' — ' + detail : '')); |
| 54 | console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : '')); |
| 55 | }; |
| 56 | |
| 57 | const s = await open({ name: 'chatfence', signIn: true, connect: false }); |
| 58 | const { page } = s; |
| 59 | await page.waitForFunction(() => !!window.DaimondCore, null, { timeout: 15000 }).catch(() => {}); |
| 60 | |
| 61 | try { |
| 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 | |
| 259 | console.log('\n' + ok.length + ' ok, ' + bad.length + ' failed'); |
| 260 | if (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 | } |
| 266 | process.exit(bad.length ? 1 : 0); |