Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/ext/grant.js

11.3 KiB, 1 run

created by r2519314175:877, 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// The one place permission is given.
2//
3// chrome.permissions.request needs a real user gesture, and a message from a web
4// page is not one -- deliberately. So the question is put here, in the
5// extension's own window, where a click is a click and Chrome will honour it.
6//
7// Three questions are asked here, never together:
8// 'site' -- may Daimond operate this one site?
9// 'mirror' -- may Daimond photograph the tab, so the panel can show it?
10// 'hand' -- may Daimond run commands on this computer?
11//
12// The third is not a Chrome permission and cannot be: `nativeMessaging` is
13// granted at install and there is nothing to ask Chrome for a second time. So
14// for that one, this window IS the approval rather than the step before it, and
15// the extension records the answer itself -- which is also what makes it
16// revocable, since Chrome has nothing to take away.
17//
18// It is asked in the language the app is speaking. This is the one window in
19// the product that asks the user to trust something, and a question nobody can
20// read is not a question.
21//
22// TWO SCREENS, NOT TWO WINDOWS. What a person needs in order to decide is on
23// the surface: what is being asked, which folder, which page asked, and the two
24// buttons. Everything else -- the reasoning, the caveats, what the compartment
25// does and does not stop -- is behind one disclosure, a Tab and a keypress away.
26// Nothing was cut. A window nobody finishes reading is a window nobody
27// understands, and an approval given without understanding is the failure this
28// whole flow exists to avoid; but so is a short window that hides the fact that
29// a website is being handed the ability to run programs. So the first screen
30// still says, in its own words, that programs would run as this user with this
31// user's files, whether the machine can hold them to a folder, and that this is
32// the strongest thing Daimond can be allowed to do.
33
34'use strict';
35
36(() => {
37
38 const $ = (id) => document.getElementById(id);
39 const I = globalThis.DaimondExtI18n;
40 const t = (...a) => I.t(...a);
41 const q = new URLSearchParams(location.search);
42 const nonce = q.get('nonce');
43 const kind = q.get('kind') || 'site';
44 const host = q.get('host') || '';
45 const pat = q.get('pattern') || '';
46 const origin = q.get('origin') || '';
47 /// What the hand said it can enforce on this machine, space separated, from
48 /// its `hello`. Empty means it never said, which is a third answer and not
49 /// the same as saying no.
50 const caps = (q.get('caps') || '').split(/\s+/).filter(Boolean);
51
52 /// Is a compartment actually in force on this machine?
53 ///
54 /// `fence.rs` answers `fence:none` rather than an empty list when there is
55 /// nothing, precisely so silence and "no fence" are told apart, and
56 /// `fence:waived` is what a user who turned it off gets. Anything else
57 /// beginning `fence:` is a mechanism that is really there.
58 function fenced() {
59 if (caps.indexOf('fence:none') >= 0 || caps.indexOf('fence:waived') >= 0) return false;
60 return caps.some((c) => c.indexOf('fence:') === 0);
61 }
62
63 /// Is every run written down where the user can read it?
64 function journalled() {
65 return caps.some((c) => c === 'journal' || c.indexOf('journal:') === 0);
66 }
67
68 /// The folder the hand says the grant covers, or '' where it named none.
69 ///
70 /// It travels as a `root:` capability because `wire.rs` has no field for it
71 /// -- see `hand/src/main.rs`. The page cannot work the path out for itself:
72 /// the File System Access API hands it a handle and never a name. So this is
73 /// the only place the folder can come from, and a window that did not show
74 /// it would be asking about a compartment without saying where it is.
75 function rootDir() {
76 const c = caps.find((s) => s.indexOf('root:') === 0);
77 return c ? c.slice(5) : '';
78 }
79
80 /// Puts a node behind the disclosure, keeping the order it is given in.
81 function conceal() {
82 $('moreBody').prepend.apply($('moreBody'), arguments);
83 }
84
85 /// Sizes the window to its first screen.
86 ///
87 /// The window is created before anything is known about the language it will
88 /// be written in, and a height measured against English clips German while
89 /// leaving an inch of nothing under Chinese. The sheet scrolls rather than
90 /// clipping, so nothing is ever lost -- but a sentence a person has to scroll
91 /// to is a sentence they did not read, and the whole point of the split is
92 /// that the first screen IS read. So the height in background.js is a
93 /// starting guess and this corrects it, in either direction, once, before the
94 /// window has been shown long enough for anyone to have dragged it.
95 ///
96 /// Bounded both ways: never past the screen, and never so small that the two
97 /// buttons and the brand have nowhere to sit.
98 function fit() {
99 try {
100 const sheet = document.querySelector('.sheet');
101 const have = sheet.clientHeight;
102 // What the sheet WANTS. `scrollHeight` alone cannot say: it is
103 // clamped to the box, so it reports the overflow and never the slack,
104 // and a window sized from it could grow but never give anything back.
105 // Letting the sheet take its content height for one measurement is
106 // the only way to see both.
107 const flex = sheet.style.flex;
108 sheet.style.flex = '0 0 auto';
109 const need = Math.ceil(sheet.scrollHeight);
110 sheet.style.flex = flex;
111 const delta = need - have;
112 if (Math.abs(delta) < 4) return;
113 chrome.windows.getCurrent((w) => {
114 if (!w || w.height == null) return;
115 const room = Math.max(320, (screen.availHeight || 900) - 60);
116 chrome.windows.update(w.id,
117 { height: Math.max(300, Math.min(w.height + delta + 2, room)) });
118 });
119 } catch (e) { /* no windows API here; the sheet still scrolls */ }
120 }
121
122 /// Writes the question. Called once the table is in, so the window is never
123 /// read half in one language and half in another.
124 function draw() {
125 I.paint();
126 if (kind === 'mirror') {
127 $('head').textContent = t('grant_mirror_head');
128 $('body').textContent = t('grant_mirror_body');
129 $('fine').textContent = t('grant_mirror_fine');
130 $('allow').textContent = t('grant_mirror_allow');
131 } else if (kind === 'hand') {
132 $('head').textContent = t('grant_hand_head');
133 // The short of it, and the line on the first screen that decides the
134 // question. `hand/README.md`'s first release gate is that the wording
135 // is chosen from `caps` rather than hard-coded: a sentence about
136 // folders, on a machine with no fence, is a promise the code does not
137 // keep, and a promise about safety that is not kept is worse than
138 // none. Three answers, because there are three: it fences, it does
139 // not, or it did not say.
140 $('lead').hidden = false;
141 $('lead').textContent = caps.length === 0 ? t('grant_hand_lead_unknown')
142 : fenced() ? t('grant_hand_lead')
143 : t('grant_hand_lead_nofence');
144 // Which folder. A fact about the decision rather than a detail of
145 // it: approving this for a project folder and approving it for a
146 // home directory are different answers to different questions.
147 const dir = rootDir();
148 // Shown only where there is a fact to put in it: an empty grid is a
149 // gap the reader has to account for.
150 $('facts').hidden = !dir && !origin;
151 if (dir) {
152 $('folderlab').hidden = false;
153 $('folderlab').textContent = t('grant_hand_folder');
154 $('folder').hidden = false;
155 $('folder').textContent = dir;
156 }
157 // The question is about the machine, not about a site -- but it is
158 // asked BY a page, and only that page is answered by it, so the page
159 // is named. A user with the app open in two places should be able to
160 // see which one is asking.
161 if (origin) {
162 $('hostlab').hidden = false;
163 $('hostlab').textContent = t('grant_hand_asked_by');
164 $('host').hidden = false;
165 $('host').textContent = origin;
166 // A row of the facts grid here, not the boxed headline it is on
167 // the site question -- there the name IS the question, here it
168 // is one fact among several.
169 $('host').className = 'val';
170 $('facts').appendChild($('host'));
171 }
172 // Kept on the first screen deliberately, and lifted out of the fine
173 // print to get there. It is the one sentence that stops a click, and
174 // a sentence that stops a click cannot sit behind a control that a
175 // hurried person does not press.
176 $('strongest').hidden = false;
177 $('strongest').textContent = t('grant_hand_strongest');
178 // The long form of the same three answers, the capability list
179 // verbatim, and the fine print. All still said, all one click away.
180 $('body').textContent = caps.length === 0 ? t('grant_hand_body_unknown')
181 : fenced() ? t('grant_hand_body')
182 : t('grant_hand_body_nofence');
183 $('scope').hidden = false;
184 $('scope').textContent = caps.length
185 ? t('grant_hand_caps', caps.join(', '))
186 : t('grant_hand_caps_unknown');
187 // The journal is the other half of the same promise, and it is not
188 // this machine's to make either until the hand says it keeps one.
189 $('fine').textContent = journalled() ? t('grant_hand_fine') : t('grant_hand_fine_nojournal');
190 $('allow').textContent = t('grant_hand_allow');
191 conceal($('body'), $('scope'));
192 } else {
193 $('head').textContent = t('grant_site_head');
194 $('host').hidden = false;
195 $('host').textContent = host;
196 // Say what is actually being granted. The pattern is `*://*.host/*`, so it
197 // covers subdomains -- which is what a person means by "this site", and
198 // what a click that crosses to an app subdomain needs -- but a window that
199 // showed the bare host alone would be asking for more than it said.
200 $('scope').hidden = false;
201 $('scope').textContent = t('grant_site_scope', host);
202 $('body').textContent = t('grant_site_body');
203 // Set the expectation before it happens: clicking Allow hands off to
204 // Chrome's own permission prompt, whose wording is alarming by design.
205 // A user warned it is coming, and told it is the real approval, is not
206 // ambushed by it after already clicking Allow once. Chrome says it in
207 // its OWN language, which is why this sentence quotes it rather than
208 // promising the words the user will see.
209 $('fine').textContent = t('grant_site_fine');
210 $('allow').textContent = t('grant_site_allow');
211 }
212 // The cautious button holds the focus, so a keyboard alone can answer the
213 // window and a reflexive Return refuses rather than approves. Everything
214 // else -- the disclosure, then Allow -- is a Tab away.
215 $('deny').focus();
216 fit();
217 }
218
219 /// Tells the broker how the user answered, then closes.
220 ///
221 /// 'allowed' or 'declined' -- both are ANSWERS. A window that goes away
222 /// without sending one is a dismissal, and the broker reads that from the
223 /// window closing, not from here.
224 function answer(how) {
225 chrome.runtime.sendMessage({ type: 'grant', nonce, answer: how }, () => window.close());
226 }
227
228 $('allow').addEventListener('click', async () => {
229 // The machine hand has no Chrome permission behind it, so there is no
230 // second prompt to defer to. This click is the whole approval, and the
231 // broker writes it down.
232 if (kind === 'hand') return answer('allowed');
233 try {
234 // Chrome's own prompt is the real approval. Refusing it there is a
235 // refusal, however this window was clicked.
236 const ok = await chrome.permissions.request({ origins: [pat] });
237 answer(ok ? 'allowed' : 'declined');
238 } catch (e) {
239 answer('declined');
240 }
241 });
242
243 $('deny').addEventListener('click', () => answer('declined'));
244
245 I.ready().then(draw);
246
247})();