Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/dev/verify_kitfence.mjs

16.5 KiB, 1 run

created by r2519314175:497, 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_kitfence.mjs — the toolchain clamp, end to end, through the relay.
2//
3// The two clamps on a fence disagreed with each other, and the disagreement was
4// invisible because the toolkit path had only ever been driven over a direct
5// pipe. Over a pipe there is no relay, and the relay is the end that refused.
6//
7// `ext/hand.js` refused every fence root outside the granted folder.
8// A toolchain is outside the granted folder by construction
9// -- cargo lives under ~/.cargo -- so a granted Rust toolkit
10// made every command fail, while `prompts::machine_note` had
11// already told the daimon that cargo was on its PATH.
12// `hand/src/exec.rs` `vet_roots` allowed EVERY toolchain folder, to every
13// fence, at either level, whether or not any toolkit had been
14// granted. ~/.local/bin is first on PATH and is a READ grant
15// in the app's own table; the clamp accepted it as writable.
16// A file called `ls` written there is unfenced execution as
17// the user on the next shell command.
18//
19// The second was unreachable through Chrome only because the first sat in front
20// of it, so fixing either alone was dangerous. This drives both, in one browser,
21// against the real binary.
22//
23// ── What is proved, and what could fake it ──────────────────────────
24//
25// Every check names a REAL directory on this machine and asks a REAL kernel. The
26// two ends are told apart by their sentences, which is the whole point: a
27// refusal from the relay and a refusal from the hand are different failures with
28// different fixes, and a test that only asserted "it was refused" would have
29// passed against the broken code for the wrong reason.
30//
31// the relay's: "outside ... which is the folder this machine's hand was granted"
32// the hand's: "asks to WRITE ..." / "is not a folder one of the toolkits this request named"
33//
34// The shim case is the exploit itself, not a model of it: the file is written to
35// a real path on PATH, and its absence afterwards is checked on disk.
36//
37// ── Running it ──────────────────────────────────────────────────────
38//
39// xvfb-run -a -s "-screen 0 1400x900x24" node dev/verify_kitfence.mjs
40//
41// --keep leave the scratch tree behind for inspection
42//
43// Headed, because Chromium loads an unpacked extension in no other mode.
44import fs from 'node:fs';
45import net from 'node:net';
46import os from 'node:os';
47import path from 'node:path';
48import { spawn, spawnSync } from 'node:child_process';
49import { fileURLToPath } from 'node:url';
50
51import { open as openApp, scratch } from './harness.mjs';
52import { whyStaleBinary, whyStaleWasm, refuse } from './staleguard.mjs';
53
54const HERE = path.dirname(fileURLToPath(import.meta.url));
55const ROOT = path.join(HERE, '..');
56const SRC = path.join(ROOT, 'ext');
57const EXTID = 'mpliijponglmmffjnonahhignkpkhmij';
58const INSTALL = path.join(ROOT, 'hand/install/install.sh');
59const HAND = path.join(ROOT, 'hand/target/release/daimond-hand');
60
61const KEEP = process.argv.slice(2).includes('--keep');
62
63const BASE = scratch('kitfence');
64const PROFILE = path.join(BASE, 'profile');
65const JOURNAL = path.join(BASE, 'journal');
66const GRANT = path.join(BASE, 'work');
67const HOSTS = path.join(PROFILE, 'NativeMessagingHosts');
68
69const HOME = os.homedir();
70/// A name nothing on this machine has, in the directory the exploit targets.
71const SHIM = path.join(HOME, '.local/bin', 'zzz_daimond_kitfence_probe');
72
73const ok = [], bad = [];
74const check = (name, pass, detail) => {
75 (pass ? ok : bad).push(name);
76 console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : ''));
77};
78const note = (s) => console.log(' · ' + s);
79const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
80
81function listening(port) {
82 return new Promise((resolve) => {
83 const s = net.connect(port, '127.0.0.1');
84 s.once('connect', () => { s.destroy(); resolve(true); });
85 s.once('error', () => resolve(false));
86 });
87}
88
89const started = [];
90async function serve(name, args, port) {
91 if (await listening(port)) { note(`${name} already up on ${port}`); return; }
92 const p = spawn('node', args, { cwd: ROOT, stdio: 'ignore' });
93 started.push(p);
94 for (let i = 0; i < 100; i++) {
95 if (await listening(port)) { note(`started ${name} on ${port}`); return; }
96 await sleep(100);
97 }
98 throw new Error(`${name} did not come up on ${port}`);
99}
100
101// ── Build and install ───────────────────────────────────────────────
102
103fs.rmSync(BASE, { recursive: true, force: true });
104for (const d of [PROFILE, JOURNAL, GRANT, HOSTS]) fs.mkdirSync(d, { recursive: true });
105fs.chmodSync(JOURNAL, 0o700);
106fs.writeFileSync(path.join(GRANT, 'inside.txt'), 'a file inside the grant\n');
107
108// `CARGO_TARGET_DIR` is REMOVED from the build's environment, and it is not a
109// tidying: an agent working in this tree usually has one set, cargo would then
110// write the new binary there, and `hand/target/release/daimond-hand` — the path
111// the installer registers and this test runs — would be whatever was built last
112// week. Measured: with one inherited, this file passed its relay checks and
113// wrote a real shim into ~/.local/bin, because the binary under test was the one
114// from before the clamp existed. A stale artefact is the second finding of the
115// 2026-08-02 audit and this is exactly how it happens.
116const buildEnv = { ...process.env };
117delete buildEnv.CARGO_TARGET_DIR;
118// `DAIMOND_NO_BUILD` skips the build and NOTHING else: the staleness guard below
119// runs either way, so the variable cannot make this file report success against
120// code it did not test — it can only make it refuse. It is here because cargo
121// relinks an output it finds backdated, so with the build in the way there is no
122// arrangement of this tree in which the guard can be watched refusing, and a
123// guard nobody has watched work is the thing this file exists to be rid of. The
124// same hatch, for the same reason, as `PTYEDGE_NO_BUILD` in verify_ptyedge.mjs.
125const built = process.env.DAIMOND_NO_BUILD ? { status: 0, stderr: '' }
126 : spawnSync('cargo', ['build', '--release', '--manifest-path', 'hand/Cargo.toml'],
127 { cwd: ROOT, encoding: 'utf8', env: buildEnv });
128check('the hand builds from source', built.status === 0 && fs.existsSync(HAND),
129 (built.stderr || '').split('\n').filter((l) => /^error/.test(l)).slice(0, 3).join(' | '));
130if (built.status !== 0) { console.log('\n0 ok, 1 failed'); process.exit(1); }
131
132// And the artefact is newer than the source it was supposedly built from, or
133// this test is measuring a binary somebody else's build left behind.
134//
135// That comparison was against four hand-picked files — exec.rs, main.rs,
136// wire.rs, codec.rs — which left out `hand/src/journal.rs`, `fence.rs`,
137// `seccomp.rs` and `pty.rs`, and every fe2o3 crate under all of them. A clamp
138// moved in `fence.rs` would not have registered at all. Cargo already writes
139// down what it linked, so the list is not picked: `daimond-hand.d` names every
140// source that went into the binary, and one of them being newer is the refusal.
141// The same oracle as `shipping_hand` in `hand/src/exec.rs` and
142// `dev/verify_ptyedge.mjs`.
143refuse(whyStaleBinary(HAND, {
144 subject: 'The toolchain clamp',
145 what: 'hand',
146 rebuild: 'cargo build --release --manifest-path hand/Cargo.toml',
147}));
148
149// The relay's half of the clamp is composed in the wasm — `toolkit_bounds`, and
150// the roots it puts in a fence — so a stale bundle would measure a clamp that is
151// no longer there. No dep-info exists for a wasm bundle (see
152// `dev/staleguard.mjs`), so the oracle is every `.rs` under `src/`.
153refuse(whyStaleWasm(path.join(ROOT, 'www/pkg/oxedyne_daimond_bg.wasm'), path.join(ROOT, 'src'), {
154 subject: 'The relay\'s half of the clamp',
155 holds: '`toolkit_bounds` and the roots it puts in a fence',
156}));
157
158fs.writeFileSync(path.join(JOURNAL, 'root.txt'),
159 `# The one folder Daimond's machine hand may work in.\n${GRANT}\n`);
160
161const inst = spawnSync('bash', [INSTALL, '--dir', HOSTS, HAND], { cwd: ROOT, encoding: 'utf8' });
162check('install.sh registers the real binary in the test profile', inst.status === 0,
163 (inst.stderr || inst.stdout || '').trim().split('\n').slice(-1)[0]);
164
165process.env.DAIMOND_HAND_JOURNAL_DIR = JOURNAL;
166delete process.env.DAIMOND_HAND_ROOT;
167
168// What the children will bind: `serve.mjs` reads DAIMOND_PORT and `mockllm.mjs`
169// DAIMOND_MOCK_PORT, so the wait below is asking about the port they chose.
170const APP_PORT = Number(process.env.DAIMOND_PORT || 8777);
171const MOCK_PORT = Number(process.env.DAIMOND_MOCK_PORT || 9099);
172await serve('dev server', ['dev/serve.mjs'], APP_PORT);
173await serve('mock provider', ['dev/mockllm.mjs'], MOCK_PORT);
174
175// The one toolchain folder this machine is asked about. Read only, never
176// written: the point is that a fence may NAME it, not that anything changes.
177const CARGO_BIN = path.join(HOME, '.cargo/bin');
178const haveCargo = fs.existsSync(CARGO_BIN);
179if (!haveCargo) note(`~/.cargo/bin is absent, so the "it runs" half is skipped`);
180
181// The shim must not exist before this starts, or its absence afterwards proves
182// nothing and its presence would be somebody else's file.
183if (fs.existsSync(SHIM)) {
184 console.log(` refusing to run: ${SHIM} already exists, and this test would delete it.`);
185 process.exit(2);
186}
187fs.mkdirSync(path.dirname(SHIM), { recursive: true });
188
189// ── The browser ─────────────────────────────────────────────────────
190
191const s = await openApp({ headed: true, name: 'kitfence', extension: SRC, profile: PROFILE });
192const b = s.browser;
193const page = s.page;
194
195/// Click Allow in the extension's grant window, in the background.
196async function allowHand(ms = 30000) {
197 const until = Date.now() + ms;
198 while (Date.now() < until) {
199 for (const p of b.pages()) {
200 if (/grant\.html/.test(p.url())) {
201 await p.waitForLoadState('domcontentloaded').catch(() => {});
202 await sleep(300);
203 await p.click('#allow').catch(() => {});
204 return true;
205 }
206 }
207 await sleep(150);
208 }
209 return false;
210}
211
212/// Send one exec down the relay, exactly as `Tool::run` composes it, and return
213/// what came back — the answer, or the sentence that refused it.
214///
215/// # Arguments
216/// * `spec` - The `exec` request, fence and toolkits included.
217async function send(spec) {
218 const raw = await page.evaluate((sp) =>
219 window.DaimondHand.run(JSON.stringify(sp))
220 .then((v) => ({ ok: v }), (e) => ({ err: (e && e.message) || String(e) })), spec);
221 if (raw.err) return { refused: raw.err, from: 'link' };
222 let v = {};
223 try { v = JSON.parse(raw.ok); } catch (e) { return { refused: raw.ok, from: 'unreadable' }; }
224 if (v.refused) {
225 // Which end refused it. The relay screens before anything is forwarded;
226 // the hand refuses in its own voice, and the two are different failures.
227 const relay = /is the folder this machine's hand was granted|no root at all|working directory/.test(v.refused);
228 return { refused: v.refused, from: relay ? 'relay' : 'hand' };
229 }
230 return { out: v };
231}
232
233let seq = 0;
234const exec = (argv, fence, toolkits) => ({
235 t: 'exec', id: 'kf-' + (++seq), argv, cwd: GRANT, env: [], stdin: null,
236 timeout_ms: 30000, capture: 'both', fence, toolkits,
237});
238const F = (rw, ro) => ({ rw: [GRANT].concat(rw || []), ro: ro || [], deny: [], net: false });
239
240try {
241 await sleep(500);
242 check('the extension announced itself to the app',
243 await page.evaluate(() => !!document.documentElement.dataset.daimondHands));
244
245 await page.evaluate(() => window.DaimondHand._setWaitsForTest({ grace: 30000, slack: 60000, hello: 20000 }));
246
247 // The first command opens the grant window; everything after it is quiet.
248 const grant = allowHand();
249 const first = await send(exec(['/bin/cat', 'inside.txt'], F(), []));
250 await grant;
251 check('a real hand paired and ran a command inside the grant',
252 !!first.out && /a file inside the grant/.test(JSON.stringify(first.out)),
253 JSON.stringify(first).slice(0, 240));
254
255 const st = JSON.parse(await page.evaluate(() => window.DaimondHand.status()));
256 check('the hand reports the home directory the clamp resolves against',
257 (st.caps || []).some((c) => c.indexOf('home:') === 0), (st.caps || []).join(' '));
258
259 // ── 0b: a granted toolchain reaches the relay at all ────────────
260 //
261 // This is the check that fails against the code before today: the relay
262 // refused the root and forwarded NOTHING, so the daimon met a refusal about
263 // a folder it could do nothing about.
264 if (haveCargo) {
265 const r = await send(exec(['/bin/ls', CARGO_BIN], F([], [CARGO_BIN]), ['rust']));
266 check('a granted toolchain folder is forwarded by the relay and read by the command',
267 !!r.out, JSON.stringify(r).slice(0, 300));
268 check('and the relay did not refuse it for being outside the grant',
269 r.from !== 'relay', String(r.refused || '').slice(0, 200));
270 }
271
272 // ── The relay still holds the line where nothing was granted ────
273 if (haveCargo) {
274 const r = await send(exec(['/bin/ls', CARGO_BIN], F([], [CARGO_BIN]), []));
275 check('the same fence with NO toolkit granted is refused by the relay',
276 r.from === 'relay', `${r.from}: ${String(r.refused || '').slice(0, 200)}`);
277 check('and the relay says which of the two things was missing',
278 /granted no toolchain/.test(String(r.refused || '')), String(r.refused || '').slice(0, 240));
279 }
280
281 // ── The hand's exact clamp: the toolkit has to be the right one ─
282 if (haveCargo) {
283 const r = await send(exec(['/bin/ls', CARGO_BIN], F([], [CARGO_BIN]), ['node']));
284 check('a Rust folder named by a request that granted only Node is refused by the HAND',
285 r.from === 'hand' && /toolkits this request named/.test(String(r.refused || '')),
286 `${r.from}: ${String(r.refused || '').slice(0, 240)}`);
287 }
288
289 // ── 0c: the shim. The exploit, on the real path, at the real level ─
290 //
291 // `~/.local/bin` is a READ grant in the app's table and first on PATH. The
292 // request names python, so the relay forwards it — which is exactly the
293 // interaction that made 0c dangerous once 0b was fixed — and the hand has to
294 // be the thing that refuses it.
295 const LOCALBIN = path.join(HOME, '.local/bin');
296 const shimSpec = exec(
297 ['/bin/sh', '-c', `printf '#!/bin/sh\\necho OWNED\\n' > ${SHIM} && chmod 755 ${SHIM}`],
298 F([LOCALBIN], []), ['python']);
299 const shim = await send(shimSpec);
300 check('writing a shim into ~/.local/bin is refused',
301 !shim.out, JSON.stringify(shim).slice(0, 300));
302 check('and it is the HAND that refuses it, on the level and not on the folder',
303 shim.from === 'hand' && /asks to WRITE/.test(String(shim.refused || '')),
304 `${shim.from}: ${String(shim.refused || '').slice(0, 260)}`);
305 check('and no shim was written to the real directory on PATH',
306 !fs.existsSync(SHIM), SHIM);
307
308 // The control, without which the absence above proves nothing: the same file
309 // is perfectly writable with nothing fencing it.
310 fs.writeFileSync(SHIM, '#!/bin/sh\necho control\n');
311 const controlWrote = fs.existsSync(SHIM);
312 fs.rmSync(SHIM, { force: true });
313 check('while that same path is writable with nothing fencing it', controlWrote, SHIM);
314
315 // ── And the cache a build genuinely writes is still allowed ─────
316 //
317 // A clamp that refuses `~/.cargo/registry` is a clamp that stops cargo, and a
318 // security check that breaks the build is one somebody switches off.
319 const REG = path.join(HOME, '.cargo/registry');
320 if (fs.existsSync(REG)) {
321 const r = await send(exec(['/bin/ls', REG], F([REG], []), ['rust']));
322 check('the writable cache a granted Rust toolkit needs is accepted at rw',
323 !!r.out, JSON.stringify(r).slice(0, 240));
324 }
325
326 // ── What the review could not break must stay unbroken ──────────
327 for (const bad of ['/etc', '/', path.join(HOME, '.ssh'), HOME]) {
328 const r = await send(exec(['/bin/ls', bad], F([bad], []), ['rust', 'node', 'python', 'go']));
329 check(`rw:["${bad}"] is refused even with every toolkit named`, !r.out,
330 JSON.stringify(r).slice(0, 200));
331 }
332
333 const noise = s.errs.filter((e) => !/favicon|ERR_ABORTED|502|Bad Gateway/i.test(e));
334 check('the page threw nothing along the way', noise.length === 0, noise.slice(0, 3).join(' | '));
335} finally {
336 fs.rmSync(SHIM, { force: true });
337 await b.close().catch(() => {});
338 for (const p of started) { try { p.kill(); } catch (e) { /* already gone */ } }
339 if (!KEEP) fs.rmSync(BASE, { recursive: true, force: true });
340}
341
342console.log('\n' + ok.length + ' ok, ' + bad.length + ' failed');
343process.exit(bad.length ? 1 : 0);