seisin 0.4.2 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +506 -162
- package/package.json +14 -6
- package/src/browsers.js +52 -0
- package/src/canary.js +149 -0
- package/src/cli.js +175 -52
- package/src/commands/check.js +157 -5
- package/src/commands/explain.js +31 -13
- package/src/commands/hook.js +20 -6
- package/src/commands/init.js +183 -88
- package/src/commands/log.js +48 -9
- package/src/commands/requests.js +122 -70
- package/src/commands/review.js +73 -21
- package/src/commands/run.js +422 -78
- package/src/commands/scan.js +18 -3
- package/src/commands/ui.js +18 -5
- package/src/commands/walls.js +62 -11
- package/src/commands/whose.js +9 -7
- package/src/commands/wire.js +156 -17
- package/src/config.js +505 -37
- package/src/controls.js +244 -0
- package/src/diagnose.js +102 -49
- package/src/env.js +14 -8
- package/src/grants.js +122 -9
- package/src/handoff.js +285 -10
- package/src/help.js +179 -0
- package/src/hook.js +101 -42
- package/src/index.d.ts +217 -0
- package/src/index.js +24 -16
- package/src/inspect/codex.js +64 -0
- package/src/inspect/credentials.js +48 -0
- package/src/inspect/enforce.js +24 -0
- package/src/inspect/files.js +71 -0
- package/src/inspect/home.js +43 -0
- package/src/inspect/inject.js +68 -0
- package/src/inspect/keys.js +74 -0
- package/src/inspect/layout.js +258 -0
- package/src/inspect/limits.js +98 -0
- package/src/inspect/machine.js +107 -0
- package/src/inspect/network.js +37 -0
- package/src/inspect/providers.js +71 -0
- package/src/inspect/read-territory.js +105 -0
- package/src/inspect/role-keys.js +136 -0
- package/src/inspect/shared.js +37 -0
- package/src/inspect/warnings.js +58 -0
- package/src/inspect.js +20 -756
- package/src/intake.js +150 -35
- package/src/keys.js +231 -73
- package/src/kinds.js +249 -0
- package/src/lines.js +26 -0
- package/src/log.js +242 -52
- package/src/mcp.js +57 -58
- package/src/nested.js +12 -3
- package/src/notify.js +66 -20
- package/src/owners.js +358 -39
- package/src/paths.js +54 -2
- package/src/provider-run.js +81 -0
- package/src/redact.js +46 -13
- package/src/render.js +101 -17
- package/src/requests.js +506 -87
- package/src/review.js +50 -25
- package/src/rundir.js +33 -14
- package/src/sandboxes/command.js +93 -0
- package/src/sandboxes/index.js +113 -0
- package/src/sandboxes/mach-profile.js +31 -0
- package/src/sandboxes/srt-mach.mjs +43 -0
- package/src/sandboxes/srt.js +163 -0
- package/src/sandboxes/tartaros.js +80 -0
- package/src/scan.js +181 -19
- package/src/serve.js +396 -230
- package/src/spool.js +16 -45
- package/src/srt.js +296 -49
- package/src/suggest.js +58 -0
- package/src/surface.js +314 -54
- package/src/territory.js +261 -0
- package/src/trace.js +33 -0
- package/src/uilink.js +24 -9
- package/src/views.js +315 -0
- package/src/violations.js +247 -23
- package/src/walls.js +76 -46
- package/src/worktree.js +3 -11
- package/ui/index.html +1429 -538
- package/ui/favicon.svg +0 -1
package/ui/index.html
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
<!doctype html>
|
|
2
|
+
<!-- Built from ui/src/ by ui/build.js. Edit there, then run: npm run build:ui -->
|
|
2
3
|
<html lang="en">
|
|
3
4
|
<head>
|
|
4
5
|
<meta charset="utf-8">
|
|
@@ -10,7 +11,7 @@
|
|
|
10
11
|
16px, where a favicon actually lives. Also in ui/favicon.svg for anyone who wants
|
|
11
12
|
the file; inline here so the console needs no second route to serve it. -->
|
|
12
13
|
<link rel="icon" href="data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2032%2032%22%3E%3Crect%20width%3D%2232%22%20height%3D%2232%22%20rx%3D%227%22%20fill%3D%22%231f2328%22%2F%3E%3Cpath%20d%3D%22M16%205.5C8%205.5%208%2016%2016%2016S24%2026.5%2016%2026.5H26.5V5.5Z%22%20fill%3D%22%232da44e%22%2F%3E%3Cpath%20d%3D%22M16%205.5C8%205.5%208%2016%2016%2016S24%2026.5%2016%2026.5%22%20fill%3D%22none%22%20stroke%3D%22%23fff%22%20stroke-width%3D%222.8%22%20stroke-linecap%3D%22round%22%2F%3E%3C%2Fsvg%3E">
|
|
13
|
-
<meta name="description" content="Which
|
|
14
|
+
<meta name="description" content="Which territory each role writes, which keys it may read, and whose a path is when the answer is no.">
|
|
14
15
|
<meta name="color-scheme" content="light dark">
|
|
15
16
|
<style>
|
|
16
17
|
/* Nothing moves on its own: fixed widths on the columns that change,
|
|
@@ -19,8 +20,11 @@
|
|
|
19
20
|
:root{
|
|
20
21
|
--bg:#fff; --panel:#fff; --side:#f6f8fa;
|
|
21
22
|
--line:#d1d9e0; --line2:#d8dee4;
|
|
22
|
-
|
|
23
|
-
|
|
23
|
+
/* --dim carries informative text at 10–12px, so it is held to AA (4.5:1) on every surface it
|
|
24
|
+
sits on — white, the sidebar and a hovered row. It was #818b98: 3.45:1 on white. */
|
|
25
|
+
--fg:#1f2328; --mid:#59636e; --dim:#656d76;
|
|
26
|
+
--green:#1a7f37; --amber:#8a5a00; --blue:#0969da; --on-blue:#fff;
|
|
27
|
+
--amber-bg:#fff8c5;
|
|
24
28
|
--hover:#eaeef2; --active:#ddf4ff;
|
|
25
29
|
/* Declared, and it was not: `.req .path` asked for `var(--mono)` against a variable that
|
|
26
30
|
never existed, so the one place showing a requested path fell back to the body sans —
|
|
@@ -34,16 +38,18 @@
|
|
|
34
38
|
@media (prefers-color-scheme:dark){ :root:not([data-theme="light"]){
|
|
35
39
|
--bg:#0e1116; --panel:#161b22; --side:#10151c;
|
|
36
40
|
--line:#262d38; --line2:#30363d;
|
|
37
|
-
--fg:#e6edf3; --mid:#
|
|
38
|
-
--green:#3fb950; --amber:#d29922; --blue:#58a6ff;
|
|
41
|
+
--fg:#e6edf3; --mid:#9ea7b3; --dim:#848d97;
|
|
42
|
+
--green:#3fb950; --amber:#d29922; --blue:#58a6ff; --on-blue:#0e1116;
|
|
43
|
+
--amber-bg:#2b2111;
|
|
39
44
|
--hover:#1c2430; --active:#1f2d3d;
|
|
40
45
|
color-scheme:dark;
|
|
41
46
|
}}
|
|
42
47
|
:root[data-theme="dark"]{
|
|
43
48
|
--bg:#0e1116; --panel:#161b22; --side:#10151c;
|
|
44
49
|
--line:#262d38; --line2:#30363d;
|
|
45
|
-
--fg:#e6edf3; --mid:#
|
|
46
|
-
--green:#3fb950; --amber:#d29922; --blue:#58a6ff;
|
|
50
|
+
--fg:#e6edf3; --mid:#9ea7b3; --dim:#848d97;
|
|
51
|
+
--green:#3fb950; --amber:#d29922; --blue:#58a6ff; --on-blue:#0e1116;
|
|
52
|
+
--amber-bg:#2b2111;
|
|
47
53
|
--hover:#1c2430; --active:#1f2d3d;
|
|
48
54
|
color-scheme:dark;
|
|
49
55
|
}
|
|
@@ -86,7 +92,14 @@ button.btn{font:inherit;font-size:12px;font-weight:600;cursor:pointer;padding:6p
|
|
|
86
92
|
border:1px solid var(--line2);background:var(--panel);color:var(--fg)}
|
|
87
93
|
button.btn:hover{background:var(--hover)}
|
|
88
94
|
button.btn.warn{border-color:var(--amber);color:var(--amber)}
|
|
89
|
-
|
|
95
|
+
/* The consequential action is the filled one. Decline was amber and Grant plain, so the eye
|
|
96
|
+
went to the safe button and read the one that edits the policy as secondary. */
|
|
97
|
+
button.btn.primary{background:var(--blue);border-color:var(--blue);color:var(--on-blue)}
|
|
98
|
+
button.btn.primary:hover{filter:brightness(1.08);background:var(--blue)}
|
|
99
|
+
button.btn.armed{background:var(--amber-bg);border-color:var(--amber);color:var(--fg)}
|
|
100
|
+
button.btn:disabled{opacity:.5;cursor:not-allowed}
|
|
101
|
+
button:focus-visible,a:focus-visible,select:focus-visible,input:focus-visible,[tabindex]:focus-visible,summary:focus-visible{outline:2px solid var(--blue);outline-offset:2px}
|
|
102
|
+
.sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}
|
|
90
103
|
|
|
91
104
|
.cards{display:grid;gap:12px;grid-template-columns:repeat(auto-fit,minmax(150px,1fr))}
|
|
92
105
|
/* Time window: quick presets plus a free "from". Filters what comes from the log. */
|
|
@@ -158,7 +171,8 @@ button:focus-visible,a:focus-visible,select:focus-visible{outline:2px solid var(
|
|
|
158
171
|
.box>header .note{font-size:11px;color:var(--dim)}
|
|
159
172
|
.box>header .bulk{margin-left:auto;display:flex;gap:6px;align-items:center}
|
|
160
173
|
.box>header .bulk[hidden]{display:none}
|
|
161
|
-
.box>header .bulk input{font-size:12px;padding:
|
|
174
|
+
.box>header .bulk input{font:inherit;font-size:12px;padding:5px 8px;min-width:230px;border:1px solid var(--line2);
|
|
175
|
+
border-radius:6px;background:var(--bg);color:var(--fg)}
|
|
162
176
|
|
|
163
177
|
.scrollx{overflow-x:auto}
|
|
164
178
|
table{width:100%;border-collapse:collapse;font-size:13px;min-width:720px}
|
|
@@ -204,7 +218,7 @@ pre .c{color:var(--dim)} pre .k{color:var(--blue)} pre .d{color:var(--amber)} pr
|
|
|
204
218
|
.ev{display:flex;align-items:baseline;gap:8px;padding:9px 14px;border-bottom:1px solid var(--line)}
|
|
205
219
|
.ev:last-child{border-bottom:0}
|
|
206
220
|
.ev b{font-weight:600;font-size:12px;flex:0 0 auto;width:52px}
|
|
207
|
-
.ev.deny b{color:var(--amber)} .ev.allow b{color:var(--green)} .ev.admin b{color:var(--blue)}
|
|
221
|
+
.ev.deny b{color:var(--amber)} .ev.allow b{color:var(--green)} .ev.admin b{color:var(--blue)} .ev.observed b{color:var(--dim)}
|
|
208
222
|
.ev .body{flex:1 1 auto;min-width:0;display:flex;flex-direction:column;gap:2px}
|
|
209
223
|
.ev .what{font:11px ui-monospace,SFMono-Regular,monospace;color:var(--dim);
|
|
210
224
|
overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
@@ -291,12 +305,148 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
|
|
|
291
305
|
.req .who b{font-weight:600}
|
|
292
306
|
.req .path{font-family:var(--mono);font-size:12.5px;color:var(--fg)}
|
|
293
307
|
.req .sub{font-size:12px;color:var(--dim);margin-top:3px}
|
|
308
|
+
.req .handoff{font-size:12px;color:var(--dim);margin-top:3px}
|
|
309
|
+
.req .handoff.person{color:var(--amber)}
|
|
294
310
|
.req .act{display:flex;gap:8px;align-items:center;flex-shrink:0;flex-wrap:wrap;justify-content:flex-end}
|
|
295
311
|
.req input{font:inherit;font-size:12.5px;padding:6px 9px;border:1px solid var(--line);border-radius:6px;
|
|
296
312
|
background:var(--bg);color:var(--fg);width:min(30vw,220px)}
|
|
297
313
|
.req input:focus{outline:2px solid var(--blue);outline-offset:1px}
|
|
298
314
|
.req .empty{color:var(--dim);padding:18px 16px}
|
|
299
315
|
.asked{font-variant-numeric:tabular-nums}
|
|
316
|
+
|
|
317
|
+
/* Control files. The one place on Roles that writes — so it never writes on the click
|
|
318
|
+
that changes it: the toggle asks the server what the profile would become, the
|
|
319
|
+
answer is shown, and only the second, separate button saves. */
|
|
320
|
+
.ctl{display:flex;flex-direction:column}
|
|
321
|
+
.ctl-row{display:flex;gap:14px;align-items:center;justify-content:space-between;padding:12px 16px;
|
|
322
|
+
border-top:1px solid var(--line)}
|
|
323
|
+
.ctl-row:first-child{border-top:0}
|
|
324
|
+
.ctl-row.glob{background:var(--side)}
|
|
325
|
+
.ctl-row .lbl{min-width:0}
|
|
326
|
+
.ctl-row .lbl b{font-weight:600;font-size:13px}
|
|
327
|
+
.ctl-row .lbl code{font-family:var(--mono);font-size:11.5px;color:var(--mid)}
|
|
328
|
+
.ctl-row .lbl p{margin:2px 0 0;font-size:12px;color:var(--mid);max-width:70ch}
|
|
329
|
+
button.sw[data-state="shut"]{color:var(--green)} button.sw[data-state="shut"] i{background:var(--green)}
|
|
330
|
+
button.sw[data-state="open"]{color:var(--amber);border-color:var(--amber)} button.sw[data-state="open"] i{background:var(--amber)}
|
|
331
|
+
button.sw:disabled{opacity:.45;cursor:not-allowed}
|
|
332
|
+
button.sw:disabled:hover{background:var(--bg)}
|
|
333
|
+
.preview{border-top:1px solid var(--line);padding:14px 16px;background:var(--side);display:flex;flex-direction:column;gap:10px}
|
|
334
|
+
.preview[hidden]{display:none}
|
|
335
|
+
.preview h3{margin:0;font-size:13px;font-weight:600}
|
|
336
|
+
.preview p{margin:0;font-size:12px;color:var(--mid)}
|
|
337
|
+
.preview p b{color:var(--fg)}
|
|
338
|
+
.preview code{font-family:var(--mono);font-size:11.5px;color:var(--fg)}
|
|
339
|
+
.diff{margin:0;padding:0;list-style:none;display:flex;flex-direction:column;gap:4px}
|
|
340
|
+
.diff li{display:grid;grid-template-columns:14px 1fr;gap:6px;font-family:var(--mono);font-size:11.5px}
|
|
341
|
+
.diff li span{overflow-wrap:anywhere;color:var(--fg)}
|
|
342
|
+
.diff li em{display:block;font-family:ui-sans-serif,system-ui,sans-serif;font-style:normal;color:var(--dim);font-size:11px}
|
|
343
|
+
.diff li.open b{color:var(--amber)} .diff li.shut b{color:var(--green)}
|
|
344
|
+
.preview .act{display:flex;gap:8px;align-items:center;flex-wrap:wrap}
|
|
345
|
+
.preview input{font:inherit;font-size:12.5px;padding:6px 9px;border:1px solid var(--line);border-radius:6px;
|
|
346
|
+
background:var(--bg);color:var(--fg);width:min(40vw,260px)}
|
|
347
|
+
.preview .err{color:var(--amber)}
|
|
348
|
+
|
|
349
|
+
/* ── added with the review of 2026-09-30 ────────────────────────────────── */
|
|
350
|
+
|
|
351
|
+
/* One sentence on top that says which page this is. The example and a real repo look the
|
|
352
|
+
same otherwise, and an offline tab looks exactly like a live one. */
|
|
353
|
+
.banner{border:1px solid var(--line);border-left:3px solid var(--blue);border-radius:8px;
|
|
354
|
+
padding:10px 14px;font-size:13px;line-height:1.55;background:var(--panel)}
|
|
355
|
+
.banner[hidden]{display:none}
|
|
356
|
+
section[data-view]{display:flex;flex-direction:column;gap:14px}
|
|
357
|
+
section[data-view][hidden]{display:none}
|
|
358
|
+
.places li em{font-family:ui-sans-serif,system-ui,sans-serif}
|
|
359
|
+
.banner.off{border-left-color:var(--amber);background:var(--amber-bg)}
|
|
360
|
+
.banner code,.notice code,.req code{font-family:var(--mono);font-size:12px}
|
|
361
|
+
.banner b{font-weight:600}
|
|
362
|
+
|
|
363
|
+
/* What a decision did, left on screen until the next one — not a toast that is gone before
|
|
364
|
+
it is read. */
|
|
365
|
+
.notice{display:flex;gap:10px;align-items:flex-start;justify-content:space-between;
|
|
366
|
+
padding:10px 16px;border-bottom:1px solid var(--line);background:var(--side);font-size:12.5px}
|
|
367
|
+
.notice[hidden]{display:none}
|
|
368
|
+
.notice .txt{min-width:0;overflow-wrap:anywhere}
|
|
369
|
+
.notice .ln{display:block;margin-top:4px;font-family:var(--mono);font-size:11.5px;color:var(--mid)}
|
|
370
|
+
.notice a{color:var(--blue)}
|
|
371
|
+
.notice button{flex:none;border:0;background:none;color:var(--mid);cursor:pointer;font:inherit;font-size:12px;text-decoration:underline}
|
|
372
|
+
|
|
373
|
+
/* A request: who, what was asked, what granting covers — in that order. */
|
|
374
|
+
.req .scope{font-size:12.5px;margin-top:4px;color:var(--fg)}
|
|
375
|
+
.req .scope.wide>b{color:var(--amber)}
|
|
376
|
+
.req .scope .own{display:block;color:var(--mid);font-size:12px;margin-top:2px}
|
|
377
|
+
.req .pick{display:flex;gap:14px;flex-wrap:wrap;font-size:12.5px;margin-top:4px;color:var(--mid)}
|
|
378
|
+
.req .pick label{display:inline-flex;gap:5px;align-items:center;cursor:pointer}
|
|
379
|
+
.req .pick input{padding:0;margin:0;width:auto;border:0}
|
|
380
|
+
.req .fnote{font-size:12px;color:var(--amber);margin-top:3px}
|
|
381
|
+
.req .fnote[hidden]{display:none}
|
|
382
|
+
.req .err{font-size:12px;color:var(--amber);margin-top:4px}
|
|
383
|
+
.req .err[hidden]{display:none}
|
|
384
|
+
.req input[aria-invalid="true"]{border-color:var(--amber)}
|
|
385
|
+
|
|
386
|
+
/* Denied: tabs, and the parts that are not paths. */
|
|
387
|
+
.tabs{display:flex;gap:2px;background:var(--hover);border-radius:6px;padding:2px}
|
|
388
|
+
.tabs a{font-size:12px;padding:3px 10px;border-radius:4px;color:var(--mid);text-decoration:none}
|
|
389
|
+
.tabs a[aria-selected="true"]{background:var(--panel);color:var(--fg);box-shadow:0 1px 2px #0002}
|
|
390
|
+
.showall{margin-top:12px;font:inherit;font-size:12px;border:0;background:none;color:var(--blue);cursor:pointer;padding:0;text-decoration:underline}
|
|
391
|
+
.other{margin-top:20px;border-top:1px solid var(--line);padding-top:14px}
|
|
392
|
+
.other h3{font-size:12px;font-weight:600;margin:0 0 2px}
|
|
393
|
+
.other p{font-size:12px;color:var(--mid);margin:0 0 8px}
|
|
394
|
+
|
|
395
|
+
/* Roles, read-only when live. */
|
|
396
|
+
.ro{font-size:11px;color:var(--mid)}
|
|
397
|
+
td.role button{font:inherit;font-weight:500;color:inherit;background:none;border:0;padding:0;cursor:pointer;
|
|
398
|
+
display:inline-flex;align-items:center;gap:8px;text-align:left}
|
|
399
|
+
td.role button i{font-style:normal;font-size:11px;color:var(--dim)}
|
|
400
|
+
tr.g td.role i{color:var(--green)} tr.a td.role i{color:var(--amber)}
|
|
401
|
+
.state{font-size:12px;color:var(--green);display:inline-flex;align-items:center;gap:6px}
|
|
402
|
+
.state i{width:7px;height:7px;border-radius:50%;background:var(--green)}
|
|
403
|
+
|
|
404
|
+
/* The control-files preview: grouped, and the exact line. */
|
|
405
|
+
.diff li.shut b,.diff li.shut .cnt{color:var(--amber)}
|
|
406
|
+
.diff li.open b,.diff li.open .cnt{color:var(--blue)}
|
|
407
|
+
.diff details{margin-top:4px}
|
|
408
|
+
.diff summary{cursor:pointer;color:var(--mid);font-family:ui-sans-serif,system-ui,sans-serif;font-size:11px}
|
|
409
|
+
.diff details ul{margin:4px 0 0;padding-left:14px;color:var(--mid)}
|
|
410
|
+
.tomlline{margin:0;padding:8px 10px;border:1px solid var(--line);border-radius:6px;background:var(--bg);
|
|
411
|
+
font:11.5px/1.6 var(--mono);white-space:pre-wrap;overflow-wrap:anywhere;min-height:0}
|
|
412
|
+
.tomlline .d{color:var(--amber)} .tomlline .g{color:var(--green)}
|
|
413
|
+
pre mark{background:var(--amber-bg);color:var(--fg);outline:1px solid var(--amber);border-radius:2px}
|
|
414
|
+
|
|
415
|
+
/* KPIs: full cards on the landing view, a strip everywhere else. */
|
|
416
|
+
#kpis.compact{display:flex;flex-wrap:wrap;gap:8px}
|
|
417
|
+
#kpis.compact .card{min-height:0;padding:6px 12px;flex-direction:row;align-items:baseline;gap:8px;flex:1 1 150px}
|
|
418
|
+
#kpis.compact .card .n{font-size:16px;margin-top:0;order:-1;min-width:0}
|
|
419
|
+
#kpis.compact .card .s{display:none}
|
|
420
|
+
#kpis.compact .card h2{font-size:10px}
|
|
421
|
+
.range .custom[hidden]{display:none}
|
|
422
|
+
.range[hidden]{display:none}
|
|
423
|
+
.range label{display:inline-flex;align-items:center;gap:6px}
|
|
424
|
+
|
|
425
|
+
@media (max-width:720px){
|
|
426
|
+
/* A request stacks: the sentence gets the width, the actions go under it at full width. */
|
|
427
|
+
.req{flex-direction:column;align-items:stretch}
|
|
428
|
+
.req .act{justify-content:stretch}
|
|
429
|
+
.req input{width:100%;flex:1 1 100%}
|
|
430
|
+
.req .act .btn{flex:1 1 0}
|
|
431
|
+
.box>header{flex-wrap:wrap}
|
|
432
|
+
.box>header .note{flex-basis:100%}
|
|
433
|
+
.box>header .bulk{margin-left:0;flex-basis:100%;flex-wrap:wrap}
|
|
434
|
+
.box>header .bulk input{flex:1 1 100%;min-width:0}
|
|
435
|
+
.box>header .bulk .btn{flex:1 1 100%}
|
|
436
|
+
/* The roles table becomes one card per role: a column name beside each value. */
|
|
437
|
+
table.roles{min-width:0}
|
|
438
|
+
table.roles thead{display:none}
|
|
439
|
+
table.roles tr{display:block;border-bottom:1px solid var(--line);padding:6px 0}
|
|
440
|
+
table.roles tbody tr:last-child{border-bottom:0}
|
|
441
|
+
table.roles td{display:grid;grid-template-columns:76px minmax(0,1fr);gap:8px;border:0;padding:4px 14px;width:auto;text-align:left}
|
|
442
|
+
table.roles td::before{content:attr(data-label);font-size:10px;font-weight:600;letter-spacing:.06em;
|
|
443
|
+
text-transform:uppercase;color:var(--dim);padding-top:3px}
|
|
444
|
+
#kpis.compact{flex-wrap:nowrap;overflow-x:auto}
|
|
445
|
+
#kpis.compact .card{flex:0 0 auto}
|
|
446
|
+
.ctl-row{flex-direction:column;align-items:flex-start}
|
|
447
|
+
.preview input{width:100%}
|
|
448
|
+
.places li{flex-direction:column;gap:2px}
|
|
449
|
+
}
|
|
300
450
|
</style>
|
|
301
451
|
</head>
|
|
302
452
|
<body>
|
|
@@ -304,7 +454,7 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
|
|
|
304
454
|
|
|
305
455
|
<nav class="side">
|
|
306
456
|
<div class="brand">
|
|
307
|
-
<div class="id"><svg class="mark" viewBox="0 0 32 32" aria-hidden="true"><rect width="32" height="32" rx="7" fill="var(--fg)"/><path d="M16 5.5C8 5.5 8 16 16 16S24 26.5 16 26.5H26.5V5.5Z" fill="var(--green)"/><path d="M16 5.5C8 5.5 8 16 16 16S24 26.5 16 26.5" fill="none" stroke="var(--bg)" stroke-width="2.8" stroke-linecap="round"/></svg><div><b>seisin</b><small id="source">folders and keys, per agent</small></div></div>
|
|
457
|
+
<div class="id"><svg class="mark" viewBox="0 0 32 32" aria-hidden="true"><rect width="32" height="32" rx="7" fill="var(--fg)"/><path d="M16 5.5C8 5.5 8 16 16 16S24 26.5 16 26.5H26.5V5.5Z" fill="var(--green)"/><path d="M16 5.5C8 5.5 8 16 16 16S24 26.5 16 26.5" fill="none" stroke="var(--bg)" stroke-width="2.8" stroke-linecap="round"/></svg><div><b>seisin</b><small id="source" role="status">folders and keys, per agent</small></div></div>
|
|
308
458
|
<div class="theme" role="group" aria-label="Theme">
|
|
309
459
|
<button type="button" id="t-light">light</button>
|
|
310
460
|
<button type="button" id="t-dark">dark</button>
|
|
@@ -313,13 +463,15 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
|
|
|
313
463
|
</div>
|
|
314
464
|
<div class="list">
|
|
315
465
|
<div class="cap">Policy</div>
|
|
316
|
-
<a class="on" href="#requests" id="nav-req" aria-current="page"><i>⚖</i>Waiting on you<span class="pin" id="req-pin"></span></a>
|
|
317
|
-
<a href="#denied"><i>⊘</i>Denied<span class="pin" id="dn-pin"></span></a>
|
|
318
|
-
<a href="#walls"><i>▦</i>Walls<span class="pin" id="wl-pin"></span></a>
|
|
319
|
-
<a href="#policy"><i>⚷</i>Roles<span class="pin" id="pin"></span></a>
|
|
320
|
-
<a href="#activity"><i>◷</i>Activity</a>
|
|
321
|
-
<a href="#configfile"><i>⚙</i>Config file</a>
|
|
466
|
+
<a class="on" href="#requests" id="nav-req" aria-current="page" title="Requests: access an agent asked for and cannot have"><i>⚖</i>Waiting on you<span class="pin" id="req-pin"></span></a>
|
|
467
|
+
<a href="#denied" title="Every denial in the window: what an agent tried and the OS denied"><i>⊘</i>Denied<span class="pin" id="dn-pin"></span></a>
|
|
468
|
+
<a href="#walls" title="A wall: something a role was denied twice or more and would still be denied today"><i>▦</i>Walls<span class="pin" id="wl-pin"></span></a>
|
|
469
|
+
<a href="#policy" title="Each role's territory (the folders it may write) and the keys it may read"><i>⚷</i>Roles<span class="pin" id="pin"></span></a>
|
|
470
|
+
<a href="#activity" title="The log: every entry, newest first"><i>◷</i>Activity</a>
|
|
471
|
+
<a href="#configfile" title="seisin.toml, the policy file"><i>⚙</i>Config file</a>
|
|
322
472
|
<div class="cap">Docs</div>
|
|
473
|
+
<a href="https://github.com/carlostapiacl/seisin#readme" target="_blank" rel="noopener"><i>↗</i>seisin README</a>
|
|
474
|
+
<a href="https://github.com/carlostapiacl/seisin/blob/main/docs/glossary.md" target="_blank" rel="noopener"><i>↗</i>Glossary</a>
|
|
323
475
|
<a href="https://github.com/anthropics/sandbox-runtime" target="_blank" rel="noopener"><i>↗</i>sandbox-runtime</a>
|
|
324
476
|
</div>
|
|
325
477
|
</nav>
|
|
@@ -329,48 +481,55 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
|
|
|
329
481
|
24 roles, and "Activity" meant "keep going". Each of these answers a different
|
|
330
482
|
question and none of them is a footnote to Roles, so each gets the screen. -->
|
|
331
483
|
<main>
|
|
484
|
+
<!-- Which page this is. Opened as a file it is an example with nothing behind it; a live tab
|
|
485
|
+
whose server went away looked exactly like one that was still answering. -->
|
|
486
|
+
<div class="banner" id="demo-banner" hidden>
|
|
487
|
+
<b>Preview with example data — nothing here is saved.</b>
|
|
488
|
+
seisin gives each AI coding agent its own folders and keys, has the OS enforce them, and
|
|
489
|
+
when it says no, tells you whose file it was. To see your own repository, run
|
|
490
|
+
<code>seisin ui</code> in it.
|
|
491
|
+
</div>
|
|
492
|
+
<div class="banner off" id="off-banner" role="alert" hidden></div>
|
|
493
|
+
|
|
332
494
|
<div class="head">
|
|
333
495
|
<div>
|
|
334
496
|
<h1 id="v-title">Roles</h1>
|
|
335
|
-
<p id="v-sub">Which
|
|
497
|
+
<p id="v-sub">Which territory each role writes, and which keys it may read.</p>
|
|
336
498
|
</div>
|
|
337
|
-
<button class="btn warn" id="kill" type="button">
|
|
499
|
+
<button class="btn warn" id="kill" type="button">Run every role unconfined</button>
|
|
338
500
|
</div>
|
|
339
501
|
|
|
340
|
-
<!--
|
|
341
|
-
|
|
342
|
-
denials — was invisible from the three other pages, and four of the
|
|
343
|
-
five were constants: 31 roles, 31 sandboxed, 0 unsandboxed, 31 with no
|
|
344
|
-
keys. A number that reads the same every day is not an indicator, it
|
|
345
|
-
is a caption, and putting it beside a live one teaches people to stop
|
|
346
|
-
reading the row.
|
|
347
|
-
|
|
348
|
-
Each one is a link to the screen that explains it. A number you cannot
|
|
349
|
-
act on is decoration; a number that takes you to the thing it counts
|
|
350
|
-
is the top of a path. -->
|
|
502
|
+
<!-- The window filters what is read from the log. Not the request queue: that is what is
|
|
503
|
+
waiting now, whatever the window. -->
|
|
351
504
|
<div class="range" id="range" hidden>
|
|
352
|
-
<span>Window</span>
|
|
353
|
-
<div class="seg" role="group" aria-
|
|
505
|
+
<span id="range-lbl">Window</span>
|
|
506
|
+
<div class="seg" role="group" aria-labelledby="range-lbl">
|
|
354
507
|
<button type="button" data-r="1h">1h</button>
|
|
355
508
|
<button type="button" data-r="4h">4h</button>
|
|
356
509
|
<button type="button" data-r="24h">24h</button>
|
|
357
510
|
<button type="button" data-r="7d">7d</button>
|
|
358
511
|
<button type="button" data-r="30d">30d</button>
|
|
359
|
-
<button type="button" data-r="all">All</button>
|
|
512
|
+
<button type="button" data-r="all" class="on">All</button>
|
|
513
|
+
<button type="button" data-r="custom" id="range-custom">From a date…</button>
|
|
360
514
|
</div>
|
|
361
|
-
<label
|
|
515
|
+
<label class="custom" id="range-from-box" hidden>since <input type="datetime-local" id="range-from"></label>
|
|
362
516
|
</div>
|
|
363
517
|
|
|
518
|
+
<!-- Each number is a link to the screen that explains it. Full cards on the landing view,
|
|
519
|
+
a strip on the others: the same five cards on every screen pushed every view's own
|
|
520
|
+
content below the fold, on a phone by a whole screen. -->
|
|
364
521
|
<div class="cards" id="kpis">
|
|
365
522
|
<a class="card kpi" href="#requests" id="k-wait">
|
|
366
523
|
<h2>waiting on you</h2><div class="n" id="c-wait">0</div><div class="s" id="c-wait-s">nothing to decide</div></a>
|
|
367
|
-
|
|
368
|
-
|
|
524
|
+
<!-- Example only: seisin.toml cannot express an unconfined role, so live this card would
|
|
525
|
+
always say 0 and is removed with the what-if button. -->
|
|
526
|
+
<a class="card kpi" href="#policy" id="k-open" title="Roles run without a sandbox in this example: nothing enforces their territory">
|
|
527
|
+
<h2>unconfined</h2><div class="n" id="c-off">0</div><div class="s" id="c-off-s">every role confined</div></a>
|
|
369
528
|
<a class="card kpi" href="#denied" id="k-ref">
|
|
370
529
|
<h2>denied</h2><div class="n" id="c-blocks">0</div><div class="s" id="c-blocks-s">in this window</div></a>
|
|
371
|
-
<a class="card kpi" href="#causes" id="k-conc">
|
|
372
|
-
<h2>
|
|
373
|
-
<a class="card kpi" href="#walls" id="k-retry">
|
|
530
|
+
<a class="card kpi" href="#causes" id="k-conc" title="How much of the window's denials share one file name">
|
|
531
|
+
<h2>top cause</h2><div class="n" id="c-conc">—</div><div class="s" id="c-conc-s">of all denials</div></a>
|
|
532
|
+
<a class="card kpi" href="#walls" id="k-retry" title="Calls made after the first denial of the same thing">
|
|
374
533
|
<h2>spent retrying</h2><div class="n" id="c-retry">0</div><div class="s" id="c-retry-s">calls on known walls</div></a>
|
|
375
534
|
</div>
|
|
376
535
|
|
|
@@ -379,20 +538,21 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
|
|
|
379
538
|
<div class="box">
|
|
380
539
|
<header id="policy">
|
|
381
540
|
<h2>Policy</h2>
|
|
541
|
+
<span class="note ro" id="roles-note"></span>
|
|
382
542
|
<!-- The filter takes a path as readily as a role name, because "whose is this path"
|
|
383
543
|
is the question the CLI answers with `seisin whose` and the console could not
|
|
384
544
|
ask at all. A map you cannot query is a poster. -->
|
|
385
545
|
<div class="filter">
|
|
386
546
|
<label class="note" for="f">filter</label>
|
|
387
547
|
<input id="f" type="search" spellcheck="false" autocomplete="off"
|
|
388
|
-
placeholder="a role, or a path like src/api"
|
|
548
|
+
placeholder="a role, or a path like src/api">
|
|
389
549
|
<span class="hits" id="f-hits" aria-live="polite"></span>
|
|
390
550
|
</div>
|
|
391
551
|
</header>
|
|
392
552
|
<div class="scrollx">
|
|
393
|
-
<table>
|
|
553
|
+
<table class="roles">
|
|
394
554
|
<thead><tr>
|
|
395
|
-
<th>role</th><th class="terr">writes</th><th>keys</th><th class="st">
|
|
555
|
+
<th>role</th><th class="terr" title="Territory: the paths a role may write">writes (territory)</th><th title="Credentials the role may read">keys</th><th class="st" title="Whether the OS enforces this role's territory">confined</th><th class="num">denials</th>
|
|
396
556
|
</tr></thead>
|
|
397
557
|
<tbody id="rows"></tbody>
|
|
398
558
|
</table>
|
|
@@ -400,70 +560,75 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
|
|
|
400
560
|
</div>
|
|
401
561
|
</div>
|
|
402
562
|
|
|
563
|
+
<!-- Policy-wide first, then per role. The one global switch used to sit inside the
|
|
564
|
+
selected role's box, so turning it on read as a change to that role alone. -->
|
|
565
|
+
<div class="box">
|
|
566
|
+
<header><h2>Policy-wide</h2><span class="note">applies to every role</span></header>
|
|
567
|
+
<div class="ctl" id="pw"></div>
|
|
568
|
+
<div class="preview" id="pw-preview" hidden aria-live="polite"></div>
|
|
569
|
+
<div class="notice" id="pw-done" role="status" hidden></div>
|
|
570
|
+
</div>
|
|
571
|
+
|
|
572
|
+
<div class="box">
|
|
573
|
+
<header><h2 id="ctl-title">Control files</h2><span class="note" id="ctl-note" title="Control files: files an editor or the next agent session acts on — editor settings and instruction files">files an editor or the next agent session acts on — protected unless the policy hands them over</span></header>
|
|
574
|
+
<div class="ctl" id="ctl"></div>
|
|
575
|
+
<div class="preview" id="ctl-preview" hidden aria-live="polite"></div>
|
|
576
|
+
<div class="notice" id="ctl-done" role="status" hidden></div>
|
|
577
|
+
</div>
|
|
578
|
+
|
|
403
579
|
<!-- The generated settings stay with Roles: they are not a fifth subject, they are what
|
|
404
|
-
the row you clicked resolves to.
|
|
405
|
-
content depends on a selection made somewhere you can no longer see. -->
|
|
580
|
+
the row you clicked resolves to. -->
|
|
406
581
|
<div class="box">
|
|
407
|
-
<header><h2 id="cfg-title">.seisin/….json</h2><span class="note">what seisin hands the runtime for the selected role</span></header>
|
|
582
|
+
<header><h2 id="cfg-title">.seisin/….json</h2><span class="note" id="cfg-note">what seisin hands the runtime for the selected role</span></header>
|
|
408
583
|
<pre id="cfg"></pre>
|
|
409
584
|
</div>
|
|
410
585
|
</section>
|
|
411
586
|
|
|
412
|
-
<!-- One view per KPI. A number you can click is the top of a path; a number
|
|
413
|
-
you cannot is decoration, and the five up there all pointed at two
|
|
414
|
-
pages. Each of these is the detail of exactly one of them. -->
|
|
415
|
-
|
|
416
587
|
<section data-view="requests" hidden>
|
|
417
588
|
<div class="box" id="requests">
|
|
418
589
|
<header>
|
|
419
590
|
<h2>Pending decisions</h2>
|
|
420
|
-
<span class="note">granting edits seisin.toml · here or in the terminal, never through MCP</span>
|
|
591
|
+
<span class="note" id="req-note">granting edits seisin.toml · here or in the terminal, never through MCP</span>
|
|
421
592
|
<span class="bulk" id="bulk" hidden>
|
|
422
|
-
<input id="why-all" placeholder="
|
|
423
|
-
<button class="btn
|
|
593
|
+
<input id="why-all" placeholder="reason, for all of them (required)" aria-label="Reason for declining all">
|
|
594
|
+
<button class="btn" id="decline-all" type="button" data-write>Decline all</button>
|
|
424
595
|
</span>
|
|
425
596
|
</header>
|
|
597
|
+
<div class="notice" id="decided" role="status" hidden></div>
|
|
426
598
|
<div id="reqs"></div>
|
|
427
599
|
</div>
|
|
428
600
|
</section>
|
|
429
601
|
|
|
430
|
-
<!--
|
|
431
|
-
|
|
602
|
+
<!-- Denied, by path or by name: one view with two cuts. "Causes" was a view the nav never
|
|
603
|
+
listed, reachable only from a KPI. #causes still lands on the name cut. -->
|
|
432
604
|
<section data-view="denied" hidden>
|
|
433
605
|
<div class="box">
|
|
434
606
|
<header>
|
|
435
|
-
<h2>Denied
|
|
607
|
+
<h2>Denied</h2>
|
|
608
|
+
<nav class="tabs" role="tablist" aria-label="Group denials">
|
|
609
|
+
<a href="#denied" role="tab" id="tab-path">by path</a>
|
|
610
|
+
<a href="#causes" role="tab" id="tab-name">by name</a>
|
|
611
|
+
</nav>
|
|
436
612
|
<span class="note" id="dn-window"></span>
|
|
437
613
|
</header>
|
|
438
|
-
<div class="pad">
|
|
614
|
+
<div class="pad" id="dn-path" role="tabpanel" aria-labelledby="tab-path">
|
|
439
615
|
<p class="lede" id="ca-lede"></p>
|
|
440
616
|
<div class="bars" id="ca-bars"></div>
|
|
617
|
+
<div id="ca-more"></div>
|
|
618
|
+
<div id="ca-other"></div>
|
|
441
619
|
</div>
|
|
442
|
-
|
|
443
|
-
</section>
|
|
444
|
-
|
|
445
|
-
<!-- KPI: one cause. The same denials one level coarser — by the name at the
|
|
446
|
-
end of the path. This is the cut that says whether a day was a tooling
|
|
447
|
-
problem or a territory one, and by path alone it reads backwards. -->
|
|
448
|
-
<section data-view="causes" hidden>
|
|
449
|
-
<div class="box">
|
|
450
|
-
<header>
|
|
451
|
-
<h2>Denied, by name</h2>
|
|
452
|
-
<span class="note">the same denials, grouped one level coarser</span>
|
|
453
|
-
</header>
|
|
454
|
-
<div class="pad">
|
|
620
|
+
<div class="pad" id="dn-name" role="tabpanel" aria-labelledby="tab-name" hidden>
|
|
455
621
|
<p class="lede" id="fa-lede"></p>
|
|
456
622
|
<div id="fa-list"></div>
|
|
457
623
|
</div>
|
|
458
624
|
</div>
|
|
459
625
|
</section>
|
|
460
626
|
|
|
461
|
-
<!-- KPI: spent retrying. -->
|
|
462
627
|
<section data-view="walls" hidden>
|
|
463
628
|
<div class="box">
|
|
464
629
|
<header>
|
|
465
630
|
<h2>Walls</h2>
|
|
466
|
-
<span class="note">denied twice or more, and the policy still denies it</span>
|
|
631
|
+
<span class="note">denied twice or more, and the policy still denies it · a retry is every call after the first denial</span>
|
|
467
632
|
</header>
|
|
468
633
|
<div class="pad">
|
|
469
634
|
<p class="lede" id="wl-lede"></p>
|
|
@@ -474,24 +639,24 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
|
|
|
474
639
|
|
|
475
640
|
<section data-view="activity" hidden>
|
|
476
641
|
<div class="box">
|
|
477
|
-
<header><h2>Activity</h2><span class="note">
|
|
642
|
+
<header><h2>Activity</h2><span class="note">newest first · each denial with whose it was</span></header>
|
|
478
643
|
<div id="log"></div>
|
|
479
644
|
</div>
|
|
480
645
|
</section>
|
|
481
646
|
|
|
482
647
|
<section data-view="config" hidden>
|
|
483
648
|
<div class="box">
|
|
484
|
-
<header id="configfile"><h2>seisin.toml</h2><span class="note" id="toml-note">
|
|
649
|
+
<header id="configfile"><h2>seisin.toml</h2><span class="note" id="toml-note">generated from the example policy in this page — there is no file behind it</span></header>
|
|
485
650
|
<pre id="toml"></pre>
|
|
486
651
|
</div>
|
|
487
652
|
</section>
|
|
488
653
|
|
|
489
654
|
<!-- The footer stays outside the views: it says what mode the page is in and what the
|
|
490
|
-
JSON is measured against, and that is true on all
|
|
655
|
+
JSON is measured against, and that is true on all of them. -->
|
|
491
656
|
<div class="foot">
|
|
492
|
-
<b id="foot-mode">
|
|
493
|
-
would be generated.
|
|
494
|
-
The JSON matches <b>@anthropic-ai/sandbox-runtime 0.0.
|
|
657
|
+
<b id="foot-mode">Preview.</b> <span id="foot-text">Opened as a file, this page edits an example policy in memory and
|
|
658
|
+
shows what would be generated. Nothing is saved; run <b>seisin ui</b> in a repository to see yours.</span><br>
|
|
659
|
+
The JSON matches <b>@anthropic-ai/sandbox-runtime 0.0.78</b>, whose schema the test suite exercises against the real binary.
|
|
495
660
|
</div>
|
|
496
661
|
</main>
|
|
497
662
|
</div>
|
|
@@ -500,51 +665,229 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
|
|
|
500
665
|
(function(){
|
|
501
666
|
"use strict";
|
|
502
667
|
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
668
|
+
/* ── the example ──────────────────────────────────────────────────────────
|
|
669
|
+
* Opened as a file there is no server, so the page shows an example. It used
|
|
670
|
+
* to be three unrelated lists — a policy, five log lines, and nothing else —
|
|
671
|
+
* so Activity showed denials while the KPIs said none and Waiting on you was
|
|
672
|
+
* empty. Now the example is a log and a queue, and every screen is computed
|
|
673
|
+
* from them by the same functions that draw the live state (demoState below).
|
|
674
|
+
* All of it is illustrative: the names are made up. */
|
|
675
|
+
var DEMO = {
|
|
676
|
+
keys: ["netlify-token", "database-url", "sentry-dsn", "registry-token", "cloud-token"],
|
|
677
|
+
roles: [
|
|
678
|
+
{name:"frontend", writes:["src/web/**","public/**"], keys:["netlify-token"], controlFiles:["ide"]},
|
|
679
|
+
{name:"backend", writes:["src/api/**","migrations/**"], keys:["database-url","sentry-dsn"]},
|
|
680
|
+
{name:"docs", writes:["docs/**","README.md"], keys:[]},
|
|
681
|
+
{name:"release", writes:[".github/**"], keys:["registry-token","cloud-token"]}
|
|
682
|
+
],
|
|
683
|
+
// [minutes ago, role, action, target, verdict, kind, source]
|
|
684
|
+
log: [
|
|
685
|
+
[240,"backend","write","migrations/014_stock.sql","allowed"],
|
|
686
|
+
[200,"frontend","write","src/web/App.tsx","allowed"],
|
|
687
|
+
[190,"backend","write",".git/index.lock","denied"],
|
|
688
|
+
[188,"backend","write",".git/index.lock","denied"],
|
|
689
|
+
[150,"frontend","write","src/api/orders.ts","denied"],
|
|
690
|
+
[149,"frontend","write","src/api/orders.ts","denied"],
|
|
691
|
+
[140,"backend","connect","api.stripe.com:443","denied","network","kernel"],
|
|
692
|
+
[121,"backend","write",".env.local","denied"],
|
|
693
|
+
[120,"backend","write",".env.local","denied"],
|
|
694
|
+
[118,"backend","write",".env.local","denied"],
|
|
695
|
+
[95,"frontend","write","dist/index.html","denied"],
|
|
696
|
+
[94,"frontend","write","dist/index.html","denied"],
|
|
697
|
+
[80,"docs","write","CHANGELOG.md","denied"],
|
|
698
|
+
[70,"docs","use","mcp__github__create_issue","denied","tool"],
|
|
699
|
+
[61,"release","read",".secrets/database-url","denied","key"],
|
|
700
|
+
[60,"backend","write",".git/index.lock","denied"],
|
|
701
|
+
[45,"frontend","write","src/api/orders.ts","denied"],
|
|
702
|
+
[44,"frontend","write","src/web/Cart.tsx","allowed"],
|
|
703
|
+
[30,"docs","write","src/api/orders.ts","denied"],
|
|
704
|
+
[21,"frontend","write","scripts/deploy.sh","denied","file","kernel"],
|
|
705
|
+
[12,"release","write","docs/release-notes.md","denied"],
|
|
706
|
+
[9,"frontend","write","src/api/orders.ts","denied"],
|
|
707
|
+
[4,"frontend","write","src/web/Header.tsx","allowed"]
|
|
708
|
+
],
|
|
709
|
+
requests: [
|
|
710
|
+
{role:"frontend", action:"write", target:"src/api/orders.ts", grant:"src/api/**", times:4, first:150},
|
|
711
|
+
{role:"docs", action:"write", target:"CHANGELOG.md", grant:"CHANGELOG.md", times:1, first:80},
|
|
712
|
+
{role:"release", action:"write", target:"docs/release-notes.md", grant:"docs/**", times:1, first:12}
|
|
713
|
+
]
|
|
714
|
+
};
|
|
512
715
|
|
|
513
|
-
|
|
716
|
+
var KEYS = DEMO.keys.slice();
|
|
717
|
+
var ROLES = [];
|
|
718
|
+
var KEYDIR = ".secrets";
|
|
719
|
+
// `[protect]` and the hash of the policy text the console last read, which a
|
|
720
|
+
// control-files save sends back so a file that moved in between is refused.
|
|
721
|
+
var PROTECT = { instructions: false };
|
|
722
|
+
var BASE = null;
|
|
723
|
+
var ROOT = "";
|
|
514
724
|
var sel = "frontend";
|
|
515
|
-
|
|
516
|
-
let LOG = [
|
|
517
|
-
{t:"21:04:12", kind:"deny", role:"frontend", act:"read", target:".secrets/database-url.txt", owners:["backend"]},
|
|
518
|
-
{t:"21:02:58", kind:"deny", role:"docs", act:"write", target:"src/api/orders.ts", owners:["backend"]},
|
|
519
|
-
{t:"20:58:31", kind:"allow", role:"backend", act:"write", target:"migrations/014_stock.sql", owners:[]},
|
|
520
|
-
{t:"20:51:07", kind:"deny", role:"frontend", act:"write", target:"scripts/deploy.sh", owners:[], source:"kernel"},
|
|
521
|
-
{t:"20:47:44", kind:"admin", role:"you", act:"unsandboxed", target:"scratch", owners:[]}
|
|
522
|
-
];
|
|
725
|
+
var LOG = [];
|
|
523
726
|
|
|
524
727
|
function esc(s){return String(s).replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">");}
|
|
525
728
|
// For attribute values: the text an agent chose (a request's id is its path)
|
|
526
729
|
// must not be able to close the quote it sits in.
|
|
527
730
|
function escA(s){return esc(s).replace(/"/g,""").replace(/'/g,"'");}
|
|
528
731
|
function pad(n){return String(n).padStart(2,"0");}
|
|
732
|
+
function plural(n, one, many){ return n.toLocaleString() + " " + (n === 1 ? one : (many || one + "s")); }
|
|
733
|
+
|
|
734
|
+
/** "3 min ago · 14:02", with the exact instant in the tooltip. */
|
|
735
|
+
function when(iso){
|
|
736
|
+
var t = Date.parse(iso);
|
|
737
|
+
if (isNaN(t)) return { text: "", title: "" };
|
|
738
|
+
var d = new Date(t), s = Math.max(0, (Date.now() - t) / 1000);
|
|
739
|
+
var ago = s < 45 ? "just now" : s < 3600 ? Math.round(s / 60) + " min ago" :
|
|
740
|
+
s < 86400 ? Math.round(s / 3600) + " h ago" : Math.round(s / 86400) + " d ago";
|
|
741
|
+
var clock = pad(d.getHours()) + ":" + pad(d.getMinutes());
|
|
742
|
+
if (s >= 86400) clock = d.toLocaleDateString(undefined, { month: "short", day: "numeric" }) + " " + clock;
|
|
743
|
+
return { text: ago + " · " + clock, title: d.toISOString() };
|
|
744
|
+
}
|
|
529
745
|
|
|
530
746
|
function theme(v){
|
|
531
747
|
var r=document.documentElement;
|
|
532
748
|
if(v==="auto") r.removeAttribute("data-theme"); else r.setAttribute("data-theme",v);
|
|
533
749
|
[["light","light"],["dark","dark"],["auto","auto"]].forEach(function(p){
|
|
534
|
-
document.getElementById("t-"+p[0])
|
|
750
|
+
var b=document.getElementById("t-"+p[0]);
|
|
751
|
+
b.classList.toggle("on", v===p[1]);
|
|
752
|
+
b.setAttribute("aria-pressed", v===p[1] ? "true" : "false");
|
|
535
753
|
});
|
|
536
754
|
}
|
|
537
755
|
document.getElementById("t-light").onclick=function(){theme("light");};
|
|
538
756
|
document.getElementById("t-dark").onclick=function(){theme("dark");};
|
|
539
757
|
document.getElementById("t-auto").onclick=function(){theme("auto");};
|
|
540
758
|
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
LOG=LOG.slice(0,6);
|
|
759
|
+
/** A change made in the example, written into the example's log. */
|
|
760
|
+
function note(act, target){
|
|
761
|
+
DEMO.log.push([new Date(), "you", act, target, "admin"]);
|
|
762
|
+
demoRefresh();
|
|
546
763
|
}
|
|
547
764
|
|
|
765
|
+
/* ── the example, computed the way the server computes the live state ── */
|
|
766
|
+
|
|
767
|
+
/** What kind of thing an unowned path is — kinds.js's own sentences, word for word. */
|
|
768
|
+
var KIND_HINTS = {
|
|
769
|
+
credential: "credential-shaped: never grant a write to it; if a role needs to read it, declare it as a key",
|
|
770
|
+
git: "git's own bookkeeping, not territory: grant the repository to the role that runs git there, never the file",
|
|
771
|
+
temporary: "temporary files a tool or a test makes while it runs: point it at $TMPDIR (already writable through [runtime] writes), or fix it; do not grant it in the repo",
|
|
772
|
+
build: "build output or installed dependencies: grant the folder to the role that builds it, or build outside the repo",
|
|
773
|
+
territory: "an ownable path no role claims: a real decision — give it an owner, or tell the agent not to write there"
|
|
774
|
+
};
|
|
775
|
+
function demoKind(p){
|
|
776
|
+
var name = p.split("/").pop();
|
|
777
|
+
if (p.indexOf(KEYDIR + "/") === 0 || /^\.env(\..+)?$/.test(name)) return "credential";
|
|
778
|
+
if (/(^|\/)\.git(\/|$)/.test(p)) return "git";
|
|
779
|
+
if (/(^|\/)(tmp|\.cache)\//.test(p) || /\.(tmp|log|lock)$/.test(name)) return "temporary";
|
|
780
|
+
if (/(^|\/)(dist|build|node_modules)\//.test(p)) return "build";
|
|
781
|
+
return "territory";
|
|
782
|
+
}
|
|
783
|
+
function covers(glob, p){
|
|
784
|
+
if (/\/\*\*$/.test(glob)) { var d = glob.slice(0, -3); return p === d || p.indexOf(d + "/") === 0; }
|
|
785
|
+
return glob === p;
|
|
786
|
+
}
|
|
787
|
+
function ownersIn(roles, p){ return roles.filter(function(r){ return r.writes.some(function(g){ return covers(g, p); }); }).map(function(r){ return r.name; }); }
|
|
788
|
+
|
|
789
|
+
function demoState(){
|
|
790
|
+
var now = Date.now();
|
|
791
|
+
var roles = ROLES.length ? ROLES : DEMO.roles.map(function(r){ return Object.assign({ sandbox: true, controlFiles: [] }, r, { writes: r.writes.slice(), keys: r.keys.slice() }); });
|
|
792
|
+
var entries = DEMO.log.map(function(x){
|
|
793
|
+
var about = x[5] || "file";
|
|
794
|
+
return { at: new Date(x[0] instanceof Date ? x[0] : now - x[0] * 6e4).toISOString(), role: x[1], action: x[2], target: x[3],
|
|
795
|
+
verdict: x[4], kind: about, source: x[6],
|
|
796
|
+
owners: about === "file" ? ownersIn(roles, x[3]) : [] };
|
|
797
|
+
});
|
|
798
|
+
var denied = entries.filter(function(e){ return e.verdict === "denied"; });
|
|
799
|
+
var still = function(role, e){
|
|
800
|
+
var r = roles.filter(function(x){ return x.name === role; })[0];
|
|
801
|
+
if (!r) return false;
|
|
802
|
+
if (e.kind === "key") return r.keys.indexOf(e.target.split("/").pop()) === -1;
|
|
803
|
+
if (e.kind !== "file") return true;
|
|
804
|
+
return !r.writes.some(function(g){ return covers(g, e.target); });
|
|
805
|
+
};
|
|
806
|
+
var by = {}, fams = {}, about = { file:{paths:0,denials:0}, key:{paths:0,denials:0}, network:{paths:0,denials:0}, tool:{paths:0,denials:0} };
|
|
807
|
+
denied.forEach(function(e){
|
|
808
|
+
var k = e.action + "\u0000" + e.target;
|
|
809
|
+
var g = by[k] || (by[k] = { about: e.kind, action: e.action, target: e.target, times: 0, roles: [], owners: e.owners });
|
|
810
|
+
g.times++; if (g.roles.indexOf(e.role) === -1) g.roles.push(e.role);
|
|
811
|
+
});
|
|
812
|
+
var groups = Object.keys(by).map(function(k){ return by[k]; });
|
|
813
|
+
var total = denied.length, unowned = 0, kinds = {};
|
|
814
|
+
Object.keys(KIND_HINTS).forEach(function(k){ kinds[k] = { paths: 0, denials: 0 }; });
|
|
815
|
+
groups.forEach(function(g){
|
|
816
|
+
about[g.about].paths++; about[g.about].denials += g.times;
|
|
817
|
+
g.share = g.times / total;
|
|
818
|
+
g.stillDenied = g.roles.filter(function(r){ return still(r, g); }).length;
|
|
819
|
+
if (g.about !== "file") { g.standing = "outside"; return; }
|
|
820
|
+
if (g.owners.length) { g.standing = "owned"; return; }
|
|
821
|
+
g.standing = "unowned"; unowned++;
|
|
822
|
+
g.kind = demoKind(g.target); g.hint = KIND_HINTS[g.kind];
|
|
823
|
+
kinds[g.kind].paths++; kinds[g.kind].denials += g.times;
|
|
824
|
+
});
|
|
825
|
+
groups.forEach(function(g){
|
|
826
|
+
var name = g.target.split("/").pop();
|
|
827
|
+
var f = fams[name] || (fams[name] = { name: name, times: 0, paths: 0, where: [], roles: [] });
|
|
828
|
+
f.times += g.times; f.paths++; f.where.push({ target: g.target, times: g.times });
|
|
829
|
+
g.roles.forEach(function(r){ if (f.roles.indexOf(r) === -1) f.roles.push(r); });
|
|
830
|
+
});
|
|
831
|
+
var families = Object.keys(fams).map(function(k){ var f = fams[k]; f.share = f.times / total; return f; })
|
|
832
|
+
.sort(function(a, b){ return b.times - a.times; });
|
|
833
|
+
groups.sort(function(a, b){ return b.times - a.times; });
|
|
834
|
+
|
|
835
|
+
var walls = {};
|
|
836
|
+
roles.forEach(function(r){
|
|
837
|
+
var mine = {};
|
|
838
|
+
denied.forEach(function(e){
|
|
839
|
+
if (e.role !== r.name) return;
|
|
840
|
+
var k = e.action + "\u0000" + e.target;
|
|
841
|
+
(mine[k] = mine[k] || { e: e, times: 0 }).times++;
|
|
842
|
+
});
|
|
843
|
+
var list = Object.keys(mine).map(function(k){ return mine[k]; })
|
|
844
|
+
.filter(function(w){ return w.times >= 2 && still(r.name, w.e); })
|
|
845
|
+
.map(function(w){
|
|
846
|
+
var x = { action: w.e.action, target: w.e.target, times: w.times, retried: w.times - 1,
|
|
847
|
+
owners: w.e.owners, about: w.e.kind, reason: "" };
|
|
848
|
+
if (x.about === "file" && !x.owners.length) { x.standing = "unowned"; x.kind = demoKind(x.target); x.hint = KIND_HINTS[x.kind]; }
|
|
849
|
+
return x;
|
|
850
|
+
}).sort(function(a, b){ return b.times - a.times; });
|
|
851
|
+
if (list.length) walls[r.name] = list;
|
|
852
|
+
});
|
|
853
|
+
|
|
854
|
+
return {
|
|
855
|
+
live: false,
|
|
856
|
+
allKeys: DEMO.keys,
|
|
857
|
+
protect: PROTECT,
|
|
858
|
+
roles: roles.map(function(r){
|
|
859
|
+
return { name: r.name, writes: r.writes.slice(), keys: r.keys.slice(), sandbox: r.sandbox !== false,
|
|
860
|
+
controlFiles: (r.controlFiles || []).slice(), neverWrites: [],
|
|
861
|
+
blocks: denied.filter(function(e){ return e.role === r.name; }).length };
|
|
862
|
+
}),
|
|
863
|
+
requests: DEMO.requests.map(function(q){
|
|
864
|
+
var owners = ownersIn(roles, q.target);
|
|
865
|
+
return Object.assign({ key: q.role + ":" + q.action + ":" + q.target, owners: owners,
|
|
866
|
+
handoffNote: demoHandoff(q, owners),
|
|
867
|
+
last: new Date(now - q.first * 6e4).toISOString() }, q);
|
|
868
|
+
}),
|
|
869
|
+
causes: { total: total, distinct: groups.length, unowned: unowned, about: about,
|
|
870
|
+
standing: { unowned: { paths: unowned, kinds: kinds } },
|
|
871
|
+
families: families.slice(0, 8), causes: groups },
|
|
872
|
+
walls: walls,
|
|
873
|
+
log: entries.slice().reverse()
|
|
874
|
+
};
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
/** Where an example request goes. Live, the server says it (handoff.js); the example has no
|
|
878
|
+
* server, so it says the three cases its requests can reach, in the same words. */
|
|
879
|
+
function demoHandoff(q, owners){
|
|
880
|
+
if (owners.length === 1) return owners[0] === q.role
|
|
881
|
+
? "no handoff needed — the policy moved and " + q.role + " has it now" : "handed to " + owners[0];
|
|
882
|
+
return owners.length ? "a person decides: more than one role owns this (" + owners.join(", ") + ")"
|
|
883
|
+
: "a person decides: no role owns this";
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
/** The example's state again, after a change made in it. */
|
|
887
|
+
function demoRefresh(){ if (!LIVE) applyState(demoState()); }
|
|
888
|
+
|
|
889
|
+
/* ── roles ────────────────────────────────────────────────────────────── */
|
|
890
|
+
|
|
548
891
|
/** Above this many paths the territory cell folds into folder counts. Four is what fits on
|
|
549
892
|
* one line at the narrowest column this table is allowed to reach. */
|
|
550
893
|
var FOLD_AT=4;
|
|
@@ -553,9 +896,7 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
|
|
|
553
896
|
var OPEN={};
|
|
554
897
|
var FILTER="";
|
|
555
898
|
|
|
556
|
-
/** Paths grouped by the folder that holds them, biggest group first.
|
|
557
|
-
* The group is the parent directory, with `/**` and a trailing filename removed, because
|
|
558
|
-
* that is the unit a reader thinks in: "what does this role write under src/". */
|
|
899
|
+
/** Paths grouped by the folder that holds them, biggest group first. */
|
|
559
900
|
function byFolder(paths){
|
|
560
901
|
var m={};
|
|
561
902
|
paths.forEach(function(p){
|
|
@@ -566,9 +907,7 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
|
|
|
566
907
|
.sort(function(a,b){return b.n-a.n||a.dir.localeCompare(b.dir);});
|
|
567
908
|
}
|
|
568
909
|
|
|
569
|
-
/** Does this role match what was typed? Name OR any path it writes.
|
|
570
|
-
* Matching on paths is the point: typing a path answers "whose is this", which until now
|
|
571
|
-
* you could only ask the CLI. */
|
|
910
|
+
/** Does this role match what was typed? Name OR any path it writes. */
|
|
572
911
|
function hits(r,q){
|
|
573
912
|
if(!q) return true;
|
|
574
913
|
q=q.toLowerCase();
|
|
@@ -576,6 +915,16 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
|
|
|
576
915
|
return r.writes.some(function(p){return p.toLowerCase().indexOf(q)!==-1;});
|
|
577
916
|
}
|
|
578
917
|
|
|
918
|
+
/**
|
|
919
|
+
* The roles table.
|
|
920
|
+
*
|
|
921
|
+
* Live, it is read-only. It used to offer × on every path, "+ folder" and key
|
|
922
|
+
* toggles against a real repo, and every one of those edits was thrown away on
|
|
923
|
+
* the next poll without a word — a page that looks like it saved and did not.
|
|
924
|
+
* Live, the only writes are a decision on a request and a control-files save;
|
|
925
|
+
* territories and keys are changed in seisin.toml. In the example the table
|
|
926
|
+
* stays editable, under a banner that says nothing is saved.
|
|
927
|
+
*/
|
|
579
928
|
function drawRows(){
|
|
580
929
|
var tb=document.getElementById("rows"); tb.textContent="";
|
|
581
930
|
var shown=ROLES.filter(function(r){return hits(r,FILTER);});
|
|
@@ -585,32 +934,34 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
|
|
|
585
934
|
if(vacio) vacio.hidden=!(FILTER&&!shown.length);
|
|
586
935
|
if(vacio&&FILTER&&!shown.length)
|
|
587
936
|
vacio.innerHTML="No role writes anything matching <b>"+esc(FILTER)+"</b>. "+
|
|
588
|
-
"If an agent needs it, that is a request nobody can
|
|
937
|
+
"If an agent needs it, that is a request nobody can grant yet — "+
|
|
589
938
|
"no role owns the path.";
|
|
939
|
+
document.getElementById("roles-note").textContent = LIVE
|
|
940
|
+
? "read-only here — change territories and keys in seisin.toml"
|
|
941
|
+
: "example — edits stay in this page";
|
|
590
942
|
|
|
591
943
|
shown.forEach(function(r){
|
|
592
944
|
var tr=document.createElement("tr");
|
|
593
945
|
tr.className=(r.sandbox?"g":"a")+(r.name===sel?" on":"");
|
|
594
946
|
|
|
595
|
-
var c1=document.createElement("td");
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
947
|
+
var c1=document.createElement("td"); c1.className="role"; c1.setAttribute("data-label","role");
|
|
948
|
+
var rb=document.createElement("button"); rb.type="button";
|
|
949
|
+
rb.innerHTML="<i aria-hidden=\"true\">●</i>"+esc(r.name);
|
|
950
|
+
rb.setAttribute("aria-pressed", r.name===sel ? "true" : "false");
|
|
951
|
+
rb.setAttribute("aria-label", r.name+" — show its control files and sandbox settings");
|
|
952
|
+
rb.onclick=function(){sel=r.name;drawRows();drawCfg();drawControls();};
|
|
953
|
+
c1.appendChild(rb); tr.appendChild(c1);
|
|
601
954
|
|
|
602
|
-
var c2=document.createElement("td"); c2.className="terr";
|
|
955
|
+
var c2=document.createElement("td"); c2.className="terr"; c2.setAttribute("data-label","writes");
|
|
603
956
|
var t2=document.createElement("div"); t2.className="tags";
|
|
604
957
|
function tagFor(p,i){
|
|
605
958
|
var s=document.createElement("span"); s.className="tag w"; s.textContent=p;
|
|
959
|
+
if (LIVE) return s;
|
|
606
960
|
var x=document.createElement("button"); x.type="button"; x.className="x"; x.textContent="×";
|
|
607
961
|
x.setAttribute("aria-label","Remove "+p+" from "+r.name);
|
|
608
|
-
x.onclick=function(){r.writes.splice(i,1);note("
|
|
962
|
+
x.onclick=function(){r.writes.splice(i,1);note("removed folder",r.name+" ✕ "+p);};
|
|
609
963
|
s.appendChild(x); return s;
|
|
610
964
|
}
|
|
611
|
-
// Below the threshold there is nothing to summarise and a fold would only hide what
|
|
612
|
-
// already fits. Above it, the folder counts ARE the reading: "this role writes 43
|
|
613
|
-
// files under src/" is the fact, and the forty-three names are the footnote.
|
|
614
965
|
if(r.writes.length<=FOLD_AT||OPEN[r.name]){
|
|
615
966
|
r.writes.forEach(function(p,i){ t2.appendChild(tagFor(p,i)); });
|
|
616
967
|
if(r.writes.length>FOLD_AT){
|
|
@@ -620,12 +971,10 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
|
|
|
620
971
|
}
|
|
621
972
|
}else{
|
|
622
973
|
byFolder(r.writes).forEach(function(f){
|
|
623
|
-
var s=document.createElement("
|
|
974
|
+
var s=document.createElement("button"); s.type="button"; s.className="tag fold";
|
|
624
975
|
s.innerHTML=esc(f.dir)+" <b>"+f.n+"</b>";
|
|
625
976
|
s.title=f.paths.slice(0,12).join("\n")+(f.n>12?"\n… and "+(f.n-12)+" more":"");
|
|
626
|
-
function
|
|
627
|
-
s.onclick=open;
|
|
628
|
-
s.onkeydown=function(e){if(e.key==="Enter"||e.key===" "){e.preventDefault();open();}};
|
|
977
|
+
s.onclick=function(){OPEN[r.name]=true;drawRows();};
|
|
629
978
|
t2.appendChild(s);
|
|
630
979
|
});
|
|
631
980
|
var more=document.createElement("button"); more.type="button"; more.className="more";
|
|
@@ -633,43 +982,58 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
|
|
|
633
982
|
more.onclick=function(){OPEN[r.name]=true;drawRows();};
|
|
634
983
|
t2.appendChild(more);
|
|
635
984
|
}
|
|
636
|
-
var
|
|
637
|
-
|
|
638
|
-
var
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
985
|
+
if (!r.writes.length) { var none=document.createElement("span"); none.className="ro"; none.textContent="no territory"; t2.appendChild(none); }
|
|
986
|
+
if (!LIVE) {
|
|
987
|
+
var add=document.createElement("button"); add.type="button"; add.className="add"; add.textContent="+ folder";
|
|
988
|
+
add.onclick=function(){
|
|
989
|
+
var v=prompt("Path from the repo root, with /** for the subtree:","src/");
|
|
990
|
+
if(v&&v.trim()){r.writes.push(v.trim());note("added folder",r.name+" + "+v.trim());}
|
|
991
|
+
};
|
|
992
|
+
t2.appendChild(add);
|
|
993
|
+
}
|
|
994
|
+
c2.appendChild(t2); tr.appendChild(c2);
|
|
995
|
+
|
|
996
|
+
var c3=document.createElement("td"); c3.setAttribute("data-label","keys");
|
|
644
997
|
var t3=document.createElement("div"); t3.className="tags";
|
|
645
|
-
|
|
998
|
+
if (LIVE) {
|
|
999
|
+
r.keys.forEach(function(k){ var s=document.createElement("span"); s.className="tag"; s.textContent=k; t3.appendChild(s); });
|
|
1000
|
+
if (!r.keys.length) { var nk=document.createElement("span"); nk.className="ro"; nk.textContent="none"; t3.appendChild(nk); }
|
|
1001
|
+
} else KEYS.forEach(function(k){
|
|
646
1002
|
var on=r.keys.indexOf(k)!==-1;
|
|
647
1003
|
var b=document.createElement("button"); b.type="button"; b.className="tag"; b.textContent=k;
|
|
648
1004
|
b.setAttribute("aria-pressed",on?"true":"false");
|
|
649
|
-
b.setAttribute("aria-label",(on?"
|
|
1005
|
+
b.setAttribute("aria-label",k+" — "+(on?"held by ":"not held by ")+r.name);
|
|
650
1006
|
b.onclick=function(){
|
|
651
1007
|
if(on) r.keys.splice(r.keys.indexOf(k),1); else r.keys.push(k);
|
|
652
|
-
note(
|
|
653
|
-
draw();
|
|
1008
|
+
note(on?"removed key":"added key",r.name+(on?" ✕ ":" + ")+k);
|
|
654
1009
|
};
|
|
655
1010
|
t3.appendChild(b);
|
|
656
1011
|
});
|
|
657
1012
|
c3.appendChild(t3); tr.appendChild(c3);
|
|
658
1013
|
|
|
659
|
-
var c4=document.createElement("td"); c4.className="st";
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
1014
|
+
var c4=document.createElement("td"); c4.className="st"; c4.setAttribute("data-label","confined");
|
|
1015
|
+
if (LIVE) {
|
|
1016
|
+
// The last canary is evidence from a run, not a promise inferred from the policy.
|
|
1017
|
+
if (r.canary) {
|
|
1018
|
+
var passed=r.canary.confined===true;
|
|
1019
|
+
c4.innerHTML='<span class="state" title="'+(passed?'Last canary passed':'Last canary failed')+'"><i></i>'+
|
|
1020
|
+
(passed?'canary passed':'canary failed')+'</span>';
|
|
1021
|
+
if(!passed)c4.querySelector(".state").classList.add("warn");
|
|
1022
|
+
} else c4.innerHTML='<span class="state" title="No run has recorded a canary yet"><i></i>not run</span>';
|
|
1023
|
+
} else {
|
|
1024
|
+
var sw=document.createElement("button"); sw.type="button"; sw.className="sw";
|
|
1025
|
+
var txt=r.sandbox?"confined":"unconfined";
|
|
1026
|
+
sw.setAttribute("data-on",r.sandbox?"true":"false");
|
|
1027
|
+
sw.setAttribute("aria-pressed",r.sandbox?"true":"false");
|
|
1028
|
+
sw.setAttribute("aria-label",txt+" — "+r.name);
|
|
1029
|
+
sw.title = r.sandbox ? "The OS enforces this role's territory" : "Unconfined: nothing stops a write outside its territory";
|
|
1030
|
+
sw.innerHTML="<i></i>"+txt;
|
|
1031
|
+
sw.onclick=function(){ r.sandbox=!r.sandbox; note(r.sandbox?"confined":"unconfined",r.name); };
|
|
1032
|
+
c4.appendChild(sw);
|
|
1033
|
+
}
|
|
1034
|
+
tr.appendChild(c4);
|
|
1035
|
+
|
|
1036
|
+
var c5=document.createElement("td"); c5.className="num"; c5.setAttribute("data-label","denials");
|
|
673
1037
|
c5.innerHTML=r.blocks?'<span style="color:var(--amber);font-weight:600">'+r.blocks+"</span>"
|
|
674
1038
|
:'<span style="color:var(--dim)">—</span>';
|
|
675
1039
|
tr.appendChild(c5);
|
|
@@ -677,48 +1041,52 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
|
|
|
677
1041
|
tb.appendChild(tr);
|
|
678
1042
|
});
|
|
679
1043
|
|
|
680
|
-
var
|
|
681
|
-
ROLES.forEach(function(r){r.sandbox?on++:off++; if(!r.keys.length)nok++; bl+=r.blocks;});
|
|
1044
|
+
var off=ROLES.filter(function(r){return !r.sandbox;}).length;
|
|
682
1045
|
drawKpis();
|
|
683
|
-
|
|
684
|
-
if(off){pin.textContent=off;pin.hidden=false;}else{pin.hidden=true;}
|
|
1046
|
+
pill("pin", off, off === 1 ? "role unconfined" : "roles unconfined");
|
|
685
1047
|
var k=document.getElementById("kill");
|
|
686
|
-
k.textContent=off?"
|
|
1048
|
+
k.textContent=off?"Confine every role":"Run every role unconfined";
|
|
687
1049
|
k.classList.toggle("warn",off===0);
|
|
688
1050
|
}
|
|
689
1051
|
|
|
690
1052
|
function drawCfg(){
|
|
691
1053
|
var r=ROLES.filter(function(x){return x.name===sel;})[0];
|
|
692
|
-
// Belt and braces. Whatever leaves `sel` pointing at a role that is not there, one panel
|
|
693
|
-
// going blank must not blank the ones drawn after it: the throw here was swallowing two.
|
|
694
1054
|
if(!r){
|
|
695
|
-
document.getElementById("cfg-title").textContent=".seisin
|
|
1055
|
+
document.getElementById("cfg-title").textContent=".seisin/….json";
|
|
696
1056
|
document.getElementById("cfg").textContent =
|
|
697
1057
|
ROLES.length ? "pick a role above" : "no roles in this policy";
|
|
698
1058
|
return;
|
|
699
1059
|
}
|
|
700
1060
|
document.getElementById("cfg-title").textContent=".seisin/"+r.name+".json";
|
|
1061
|
+
document.getElementById("cfg-note").textContent = LIVE
|
|
1062
|
+
? "what seisin hands the runtime for the selected role"
|
|
1063
|
+
: "illustrative — the shape of what seisin hands the runtime, from the example";
|
|
1064
|
+
if(LIVE){
|
|
1065
|
+
var got=SETTINGS[r.name];
|
|
1066
|
+
if(!got){ document.getElementById("cfg").textContent="reading "+r.name+"’s settings…"; loadSettings(r.name); return; }
|
|
1067
|
+
if(got.error){ document.getElementById("cfg").textContent=got.error; return; }
|
|
1068
|
+
document.getElementById("cfg").innerHTML=
|
|
1069
|
+
'<span class="c">// seisin run '+esc(r.name)+' -- claude -p "…"</span>\n'+
|
|
1070
|
+
esc(JSON.stringify(got.settings,null,2)).replace(/^(\s*)("[^"\n]*")(:)/gm,'$1<span class="k">$2</span>$3');
|
|
1071
|
+
return;
|
|
1072
|
+
}
|
|
701
1073
|
var L=[];
|
|
702
|
-
L.push('<span class="c">// seisin run '+esc(r.name)+' -- claude -p "…"</span>');
|
|
1074
|
+
L.push('<span class="c">// illustrative: seisin run '+esc(r.name)+' -- claude -p "…"</span>');
|
|
703
1075
|
if(!r.sandbox){
|
|
704
|
-
L.push('<span class="d">//
|
|
1076
|
+
L.push('<span class="d">// UNCONFINED. seisin still names the owner, but</span>');
|
|
705
1077
|
L.push('<span class="d">// nothing stops the write. Only the log will know.</span>');
|
|
706
1078
|
}
|
|
707
1079
|
L.push("{");
|
|
708
1080
|
L.push(' <span class="k">"network"</span>: {');
|
|
709
|
-
|
|
710
|
-
L.push(' "allowedDomains": ["github.com"'+(lp.length?', <span class="g">'+lp.join(', ')+'</span>':'')+']'+(lp.length?', <span class="c">// local_ports, through the proxy</span>':','));
|
|
1081
|
+
L.push(' "allowedDomains": ["github.com"],');
|
|
711
1082
|
L.push(' "deniedDomains": [], "allowUnixSockets": [],');
|
|
712
|
-
L.push(' "allowLocalBinding":
|
|
1083
|
+
L.push(' "allowLocalBinding": false');
|
|
713
1084
|
L.push(" },");
|
|
714
1085
|
L.push(' <span class="k">"filesystem"</span>: {');
|
|
715
|
-
// KEYDIR comes from seisin.toml, which is a file the console may be
|
|
716
|
-
// pointed at without having written it. Unescaped it was HTML injection
|
|
717
|
-
// into a page that holds the run's token — and that token approves grants.
|
|
718
1086
|
L.push(' <span class="d">"denyRead"</span>: ["'+esc(KEYDIR)+'"],');
|
|
719
1087
|
if(r.keys.length){
|
|
720
1088
|
L.push(' <span class="g">"allowRead"</span>: [');
|
|
721
|
-
r.keys.forEach(function(k,i){L.push(' "'+esc(KEYDIR)+"/"+esc(k)+'
|
|
1089
|
+
r.keys.forEach(function(k,i){L.push(' "'+esc(KEYDIR)+"/"+esc(k)+'"'+(i<r.keys.length-1?",":""));});
|
|
722
1090
|
L.push(" ],");
|
|
723
1091
|
}else{
|
|
724
1092
|
L.push(' <span class="g">"allowRead"</span>: [], <span class="c">// declares none</span>');
|
|
@@ -729,13 +1097,8 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
|
|
|
729
1097
|
});
|
|
730
1098
|
else L.push(' <span class="c">// no territory</span>');
|
|
731
1099
|
L.push(" ],");
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
r.neverWrites.forEach(function(p,i){ L.push(' "'+esc(p.replace(/\/\*\*$/,""))+'"'+(i<r.neverWrites.length-1?",":"")); });
|
|
735
|
-
L.push(" ]");
|
|
736
|
-
} else L.push(' "denyWrite": []');
|
|
737
|
-
L.push(" }"+(r.trustd?",":""));
|
|
738
|
-
if(r.trustd) L.push(' <span class="d">"enableWeakerNetworkIsolation"</span>: true <span class="c">// trustd = true</span>');
|
|
1100
|
+
L.push(' "denyWrite": []');
|
|
1101
|
+
L.push(" }");
|
|
739
1102
|
L.push("}");
|
|
740
1103
|
document.getElementById("cfg").innerHTML=L.join("\n");
|
|
741
1104
|
}
|
|
@@ -743,217 +1106,504 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
|
|
|
743
1106
|
/**
|
|
744
1107
|
* The policy, as TOML.
|
|
745
1108
|
*
|
|
746
|
-
*
|
|
747
|
-
*
|
|
748
|
-
*
|
|
749
|
-
*
|
|
750
|
-
* has no comments. Rendering the model in live mode and labelling it
|
|
751
|
-
* "seisin.toml · copy this back into your repo" was an instruction to paste
|
|
752
|
-
* away the one record of why a permission exists.
|
|
1109
|
+
* Served live, it prints the file on disk verbatim — comments, order and the
|
|
1110
|
+
* provenance a grant wrote — with the line the last decision wrote marked.
|
|
1111
|
+
* Opened as a file, there is no disk to read, so it renders the example's
|
|
1112
|
+
* model, and says so.
|
|
753
1113
|
*/
|
|
1114
|
+
var MARK = null; // the line number the last save wrote, marked until the next one
|
|
754
1115
|
function drawToml(){
|
|
1116
|
+
var host = document.getElementById("toml");
|
|
755
1117
|
if (ONDISK !== null) {
|
|
756
|
-
|
|
757
|
-
.replace(/^(\[[^\]]+\])
|
|
758
|
-
|
|
759
|
-
|
|
1118
|
+
host.innerHTML = ONDISK.split("\n").map(function(l, i){
|
|
1119
|
+
var h = esc(l).replace(/^(\[[^\]]+\])$/, '<span class="k">$1</span>').replace(/(#[^\n]*)$/, '<span class="c">$1</span>');
|
|
1120
|
+
return MARK === i + 1 ? '<mark id="toml-mark">' + h + "</mark>" : h;
|
|
1121
|
+
}).join("\n");
|
|
760
1122
|
return;
|
|
761
1123
|
}
|
|
762
|
-
var L=['<span class="c"># Anything not listed is denied.</span>',"",
|
|
1124
|
+
var L=['<span class="c"># Example policy, generated in this page. Anything not listed is denied.</span>',"",
|
|
763
1125
|
"[keys]",'dir = "'+esc(KEYDIR)+'"',"","[network]",'allow = ["github.com", "*.github.com"]',""];
|
|
1126
|
+
if(PROTECT.instructions) L.push('<span class="k">[protect]</span>',"instructions = true","");
|
|
764
1127
|
ROLES.forEach(function(r){
|
|
765
1128
|
L.push('<span class="k">[roles.'+esc(r.name)+"]</span>");
|
|
766
1129
|
L.push("writes = ["+r.writes.map(function(w){return '"'+esc(w)+'"';}).join(", ")+"]");
|
|
767
|
-
L.push("keys = ["+r.keys.map(function(k){return '"'+esc(k)+'
|
|
1130
|
+
L.push("keys = ["+r.keys.map(function(k){return '"'+esc(k)+'"';}).join(", ")+"]");
|
|
1131
|
+
if((r.controlFiles||[]).length)
|
|
1132
|
+
L.push("control_files = ["+r.controlFiles.map(function(f){return '"'+esc(f)+'"';}).join(", ")+"]");
|
|
768
1133
|
L.push("");
|
|
769
1134
|
});
|
|
770
|
-
|
|
1135
|
+
host.innerHTML=L.join("\n");
|
|
771
1136
|
}
|
|
772
1137
|
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
1138
|
+
/** What a denial with no owner was about, said of the thing it was — not "no role owns this
|
|
1139
|
+
* path" of a port or a tool, which is not a path and was never anybody's. */
|
|
1140
|
+
function unownedSays(about){
|
|
1141
|
+
return about === "network" ? "not in the network allow list"
|
|
1142
|
+
: about === "tool" ? "MCP server not allowed for this role"
|
|
1143
|
+
: about === "key" ? "a key this role does not declare"
|
|
1144
|
+
: "no role owns this path";
|
|
1145
|
+
}
|
|
776
1146
|
|
|
777
1147
|
function drawLog(){
|
|
778
1148
|
var c=document.getElementById("log"); c.textContent="";
|
|
779
|
-
// An empty log is not a broken page, and now that Activity is a view of its own it has
|
|
780
|
-
// a whole screen to say nothing on. It means one of two things and they are worth
|
|
781
|
-
// telling apart: nothing has run under seisin yet, or everything that ran was allowed.
|
|
782
1149
|
if(!LOG.length){
|
|
783
1150
|
var z=document.createElement("p"); z.className="blank";
|
|
784
1151
|
z.innerHTML="No decisions recorded yet.<br><span>The log fills when an agent actually "+
|
|
785
|
-
"runs inside the
|
|
1152
|
+
"runs inside the sandbox — <code>seisin run <role> -- …</code>. "+
|
|
786
1153
|
"Asking <code>seisin explain</code> or reading this console does not write to it.</span>";
|
|
787
1154
|
c.appendChild(z); return;
|
|
788
1155
|
}
|
|
789
1156
|
LOG.forEach(function(e){
|
|
790
1157
|
var d=document.createElement("div");
|
|
791
1158
|
d.className="ev "+e.kind;
|
|
792
|
-
var label=e.kind==="admin"?"you":
|
|
1159
|
+
var label=e.kind==="admin"?"you":e.kind==="deny"?"denied":e.kind==="observed"?"observed":"allowed";
|
|
793
1160
|
var line,who="";
|
|
794
1161
|
if(e.kind==="admin"){
|
|
795
|
-
line=esc(
|
|
1162
|
+
line=esc(e.act)+" <em>"+esc(e.target)+"</em>";
|
|
796
1163
|
}else{
|
|
797
1164
|
line="<em>"+esc(e.role)+"</em> "+esc(e.act)+" "+esc(e.target);
|
|
798
1165
|
if(e.kind==="deny"){
|
|
799
1166
|
who=e.owners.length
|
|
800
1167
|
? '<div class="who">belongs to <em>'+e.owners.map(esc).join("</em>, <em>")+"</em></div>"
|
|
801
|
-
: '<div class="who">
|
|
1168
|
+
: '<div class="who">'+esc(unownedSays(e.about))+"</div>";
|
|
802
1169
|
}
|
|
803
1170
|
}
|
|
804
1171
|
// Only the kernel's own lines get the mark. A hook line and an older line look the
|
|
805
1172
|
// same as they always did — the absence is the default, not a third state.
|
|
806
1173
|
var src=e.source==="kernel"
|
|
807
|
-
? '<span class="src" title="
|
|
808
|
-
'
|
|
1174
|
+
? '<span class="src" title="The OS denied it: recorded from the kernel, whether or not the hook saw the attempt. '+
|
|
1175
|
+
'This line cannot be talked around.">OS denied</span>'
|
|
809
1176
|
: "";
|
|
1177
|
+
var w=when(e.at);
|
|
810
1178
|
d.innerHTML="<b>"+label+'</b><span class="body"><span class="what">'+line+"</span>"+who+
|
|
811
|
-
"</span>"+src+'<span class="when">'+esc(
|
|
1179
|
+
"</span>"+src+'<span class="when" title="'+escA(w.title)+'">'+esc(w.text)+"</span>";
|
|
812
1180
|
c.appendChild(d);
|
|
813
1181
|
});
|
|
814
1182
|
}
|
|
815
1183
|
|
|
816
1184
|
/**
|
|
817
|
-
* A what-if, and only where a what-if is honest.
|
|
818
|
-
*
|
|
819
|
-
* seisin.toml cannot express an unsandboxed role — this shows what the map
|
|
820
|
-
* would be worth without the boundary, which is a useful thing to see once
|
|
821
|
-
* and a lie to leave on screen next to real decisions read from disk. It
|
|
822
|
-
* writes an entry into the Activity panel, and in live mode that panel is
|
|
823
|
-
* the log. Hidden when live for that reason; boot() does the hiding.
|
|
1185
|
+
* A what-if, and only where a what-if is honest: the example. seisin.toml cannot
|
|
1186
|
+
* express an unconfined role, so live this button does not exist.
|
|
824
1187
|
*/
|
|
825
1188
|
document.getElementById("kill").onclick=function(){
|
|
826
1189
|
var anyOpen=ROLES.some(function(r){return !r.sandbox;});
|
|
827
1190
|
ROLES.forEach(function(r){r.sandbox=anyOpen;});
|
|
828
|
-
note(
|
|
829
|
-
|
|
1191
|
+
note(anyOpen?"confined":"unconfined","every role");
|
|
1192
|
+
};
|
|
1193
|
+
|
|
1194
|
+
/* ── control files ────────────────────────────────────────────────────── */
|
|
1195
|
+
|
|
1196
|
+
/**
|
|
1197
|
+
* The two families a policy can hand a role, and the one global switch.
|
|
1198
|
+
*
|
|
1199
|
+
* A toggle never saves. It asks the server (dryRun) what each sandbox profile
|
|
1200
|
+
* would gain or lose and who could then write each family; that answer is
|
|
1201
|
+
* shown, and a separate button saves it. The server's list is the only list:
|
|
1202
|
+
* the families no role is ever handed are not offered here.
|
|
1203
|
+
*/
|
|
1204
|
+
var FAM = {
|
|
1205
|
+
ide: { label: "Editor settings", files: ".vscode/ · .cursor/ · .windsurf/",
|
|
1206
|
+
what: "Tasks, terminal profiles and settings the editor applies the moment they change, outside the sandbox." },
|
|
1207
|
+
instructions: { label: "Instruction files", files: "CLAUDE.md · AGENTS.md · GEMINI.md · .cursorrules …",
|
|
1208
|
+
what: "What the next agent session reads as its instructions — how one role reaches another’s context." }
|
|
830
1209
|
};
|
|
1210
|
+
var PENDING = null; // { edit, from, fam, where, plan, error, busy, demo } — the change being looked at
|
|
1211
|
+
|
|
1212
|
+
function selRole(){ return ROLES.filter(function(x){ return x.name === sel; })[0]; }
|
|
1213
|
+
|
|
1214
|
+
function ctlRow(o){
|
|
1215
|
+
var row = document.createElement("div"); row.className = "ctl-row" + (o.glob ? " glob" : "");
|
|
1216
|
+
var l = document.createElement("div"); l.className = "lbl";
|
|
1217
|
+
l.innerHTML = "<b>" + esc(o.title) + "</b> <code>" + esc(o.code) + "</code><p>" + o.desc + "</p>";
|
|
1218
|
+
var b = document.createElement("button"); b.type = "button"; b.className = "sw"; b.id = o.id;
|
|
1219
|
+
b.setAttribute("data-state", o.open ? "open" : "shut");
|
|
1220
|
+
b.setAttribute("aria-pressed", o.pressed ? "true" : "false");
|
|
1221
|
+
// The accessible name starts with the words on the button (WCAG 2.5.3): a voice user says
|
|
1222
|
+
// what they see. It was "Protect instruction files for every role" on a button reading
|
|
1223
|
+
// "not protected".
|
|
1224
|
+
b.setAttribute("aria-label", o.text + " — " + o.aria);
|
|
1225
|
+
b.innerHTML = "<i></i>" + esc(o.text);
|
|
1226
|
+
if (o.disabled) { b.disabled = true; b.title = o.why || ""; }
|
|
1227
|
+
else b.onclick = o.click;
|
|
1228
|
+
row.appendChild(l); row.appendChild(b);
|
|
1229
|
+
return row;
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
function drawControls(){
|
|
1233
|
+
var pw = document.getElementById("pw");
|
|
1234
|
+
var host = document.getElementById("ctl"); if (!host || !pw) return;
|
|
1235
|
+
var r = selRole();
|
|
1236
|
+
document.getElementById("ctl-title").textContent = r ? "Control files · " + r.name : "Control files";
|
|
1237
|
+
host.textContent = ""; pw.textContent = "";
|
|
1238
|
+
var prot = !!PROTECT.instructions;
|
|
1239
|
+
pw.appendChild(ctlRow({
|
|
1240
|
+
glob: true, id: "ctl-sw-protect", title: "Protect instruction files", code: "[protect] instructions",
|
|
1241
|
+
desc: prot
|
|
1242
|
+
? "On: instruction files are denied to every role not handed them below."
|
|
1243
|
+
: "Off: every role may edit instruction files inside its own territory.",
|
|
1244
|
+
open: !prot, pressed: prot, text: prot ? "protected" : "not protected",
|
|
1245
|
+
aria: "instruction files, for every role",
|
|
1246
|
+
click: function(){ propose({ protect: !prot }, "ctl-sw-protect", "instructions", "pw"); }
|
|
1247
|
+
}));
|
|
1248
|
+
if (!r) { drawPreview(); return; }
|
|
1249
|
+
["ide", "instructions"].forEach(function(f){
|
|
1250
|
+
var mine = (r.controlFiles || []).indexOf(f) !== -1;
|
|
1251
|
+
var idle = f === "instructions" && !prot;
|
|
1252
|
+
var desc = idle
|
|
1253
|
+
? "Nothing to hand over while <code>[protect] instructions</code> is off: no role is denied these. Turn protection on under Policy-wide first."
|
|
1254
|
+
: (mine ? esc(r.name) + " may edit these inside its own territory. "
|
|
1255
|
+
: "Protected: " + esc(r.name) + " is denied writing them. ") + esc(FAM[f].what);
|
|
1256
|
+
host.appendChild(ctlRow({
|
|
1257
|
+
id: "ctl-sw-" + f, title: FAM[f].label + " (" + f + ")", code: FAM[f].files, desc: desc,
|
|
1258
|
+
open: mine || idle, pressed: mine, text: idle ? "not protected" : mine ? "may edit" : "protected",
|
|
1259
|
+
aria: FAM[f].label.toLowerCase() + ", " + r.name,
|
|
1260
|
+
disabled: idle, why: "Turn on [protect] instructions first",
|
|
1261
|
+
click: function(){
|
|
1262
|
+
var next = (r.controlFiles || []).filter(function(x){ return x !== f; });
|
|
1263
|
+
if (!mine) next.push(f);
|
|
1264
|
+
next = ["ide", "instructions"].filter(function(x){ return next.indexOf(x) !== -1; });
|
|
1265
|
+
propose({ role: r.name, families: next }, "ctl-sw-" + f, f, "ctl");
|
|
1266
|
+
}
|
|
1267
|
+
}));
|
|
1268
|
+
});
|
|
1269
|
+
drawPreview();
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
async function callControls(body){
|
|
1273
|
+
var r = await fetch("/api/control-files", {
|
|
1274
|
+
method: "POST",
|
|
1275
|
+
headers: { "content-type": "application/json", "x-seisin-token": TOKEN },
|
|
1276
|
+
body: JSON.stringify(body)
|
|
1277
|
+
});
|
|
1278
|
+
var out = await r.json();
|
|
1279
|
+
if (!r.ok) { var err = new Error(out.error || "the console did not accept that"); err.status = r.status; throw err; }
|
|
1280
|
+
return out;
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
function sameEdit(a, b){ return JSON.stringify(a) === JSON.stringify(b); }
|
|
1284
|
+
|
|
1285
|
+
/** A toggle pressed: ask what it would do. Pressing the same one again puts it back. */
|
|
1286
|
+
async function propose(edit, from, fam, where){
|
|
1287
|
+
if (PENDING && sameEdit(PENDING.edit, edit)) { cancelEdit(); return; }
|
|
1288
|
+
var mine = PENDING = { edit: edit, from: from, fam: fam, where: where, plan: null, error: null, busy: LIVE, demo: !LIVE };
|
|
1289
|
+
drawPreview();
|
|
1290
|
+
if (!LIVE) return;
|
|
1291
|
+
try { var out = await callControls(Object.assign({ dryRun: true }, edit)); if (PENDING === mine) mine.plan = out; }
|
|
1292
|
+
catch (e) { if (PENDING === mine) mine.error = whyFailed(e, e.status); }
|
|
1293
|
+
if (PENDING !== mine) return;
|
|
1294
|
+
mine.busy = false;
|
|
1295
|
+
drawPreview();
|
|
1296
|
+
// Focus goes to the decision, so the keyboard path is toggle → Enter → reason → Save.
|
|
1297
|
+
var next = document.getElementById("ctl-why") || document.getElementById("ctl-save") || document.getElementById("ctl-cancel");
|
|
1298
|
+
if (next) next.focus();
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
function cancelEdit(){
|
|
1302
|
+
var from = PENDING && PENDING.from;
|
|
1303
|
+
PENDING = null;
|
|
1304
|
+
drawPreview();
|
|
1305
|
+
var b = from && document.getElementById(from);
|
|
1306
|
+
if (b) b.focus();
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
/** A path as a reader names it: relative to the repo, and a pattern for a file not
|
|
1310
|
+
* there yet (`.curso[r]`, see surface.js `exactly`) written as the name it matches. */
|
|
1311
|
+
function rel(p){
|
|
1312
|
+
var q = ROOT && p.indexOf(ROOT + "/") === 0 ? p.slice(ROOT.length + 1) : p;
|
|
1313
|
+
return q.replace(/\[([^\]\/])\]$/, "$1");
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
function describe(edit){
|
|
1317
|
+
if (edit.protect !== undefined)
|
|
1318
|
+
return (edit.protect ? "Turn on" : "Turn off") + " protection of instruction files, for every role";
|
|
1319
|
+
return "Set " + edit.role + "’s control_files to [" +
|
|
1320
|
+
edit.families.map(function(f){ return '"' + f + '"'; }).join(", ") + "]";
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
function whoMay(list){ return list && list.length ? "<b>" + list.map(esc).join(", ") + "</b>" : "<b>no role</b>"; }
|
|
1324
|
+
|
|
1325
|
+
/**
|
|
1326
|
+
* The profile changes, grouped: one line per direction ("7 instruction files in frontend,
|
|
1327
|
+
* backend and ops become denied"), the paths behind a disclosure. It listed every path of
|
|
1328
|
+
* every role, and turning protection on in a policy of twelve roles was eighty-four lines
|
|
1329
|
+
* saying the same thing.
|
|
1330
|
+
*/
|
|
1331
|
+
function groupedDiff(diff, fam){
|
|
1332
|
+
var dirs = { shut: { roles: [], n: 0, created: 0, per: {} }, open: { roles: [], n: 0, created: 0, per: {} } };
|
|
1333
|
+
diff.forEach(function(x){
|
|
1334
|
+
if (x.error) return;
|
|
1335
|
+
[["shut", x.added], ["open", x.removed]].forEach(function(p){
|
|
1336
|
+
if (!p[1].length) return;
|
|
1337
|
+
var g = dirs[p[0]];
|
|
1338
|
+
g.roles.push(x.role); g.n += p[1].length;
|
|
1339
|
+
g.per[x.role] = p[1];
|
|
1340
|
+
p[1].forEach(function(e){ if (/if it is created$/.test(e.why)) g.created++; });
|
|
1341
|
+
});
|
|
1342
|
+
});
|
|
1343
|
+
var what = FAM[fam] ? FAM[fam].label.toLowerCase().replace(/s$/, "") : "control file";
|
|
1344
|
+
var h = "";
|
|
1345
|
+
[["shut", "become denied", "an agent that edits these today will be denied"],
|
|
1346
|
+
["open", "become writable", "the role may edit them inside its territory"]].forEach(function(d){
|
|
1347
|
+
var g = dirs[d[0]]; if (!g.n) return;
|
|
1348
|
+
var exist = g.n - g.created;
|
|
1349
|
+
h += '<li class="' + d[0] + '"><b aria-hidden="true">' + (d[0] === "shut" ? "+" : "−") + "</b><span>" +
|
|
1350
|
+
'<span class="cnt">' + g.n + " " + esc(what) + (g.n === 1 ? "" : "s") + "</span> in " +
|
|
1351
|
+
esc(g.roles.join(", ")) + " " + d[1] +
|
|
1352
|
+
"<em>" + d[2] + (g.created ? " · " + exist + " exist now, " + g.created + " only if created" : "") + "</em>" +
|
|
1353
|
+
"<details><summary>show the " + g.n + " path" + (g.n === 1 ? "" : "s") + "</summary><ul>" +
|
|
1354
|
+
g.roles.map(function(r){
|
|
1355
|
+
return "<li>" + esc(r) + ": " + g.per[r].map(function(e){ return esc(rel(e.path)); }).join(", ") + "</li>";
|
|
1356
|
+
}).join("") + "</ul></details></span></li>";
|
|
1357
|
+
});
|
|
1358
|
+
diff.forEach(function(x){ if (x.error) h += '<li><b>!</b><span>' + esc(x.role) + ': <span class="err">' + esc(x.error) + "</span></span></li>"; });
|
|
1359
|
+
return h ? '<ul class="diff">' + h + "</ul>" : "";
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
/** The exact lines of seisin.toml an edit writes, the way a diff shows them. */
|
|
1363
|
+
function tomlLines(lines, note){
|
|
1364
|
+
if (!lines) return "";
|
|
1365
|
+
var body = lines.removed.map(function(l){ return '<span class="d">- ' + esc(l) + "</span>"; })
|
|
1366
|
+
.concat(lines.added.map(function(l){ return '<span class="g">+ ' + esc(l) + "</span>"; })).join("\n");
|
|
1367
|
+
return "<p>" + note + " at line " + lines.at + ":</p>" + '<pre class="tomlline">' + body + "</pre>";
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
function drawPreview(){
|
|
1371
|
+
["pw", "ctl"].forEach(function(where){
|
|
1372
|
+
var box = document.getElementById(where + "-preview"); if (!box) return;
|
|
1373
|
+
var p = PENDING && PENDING.where === where ? PENDING : null;
|
|
1374
|
+
if (!p) { box.hidden = true; box.innerHTML = ""; return; }
|
|
1375
|
+
box.hidden = false;
|
|
1376
|
+
var h = "<h3>" + esc(describe(p.edit)) + "</h3>";
|
|
1377
|
+
var buttons = '<button class="btn" id="ctl-cancel" type="button">Cancel</button>';
|
|
1378
|
+
if (p.busy) h += "<p>Asking the server what this changes…</p>";
|
|
1379
|
+
else if (p.error) h += '<p class="err">' + esc(p.error) + "</p>";
|
|
1380
|
+
else if (p.demo) {
|
|
1381
|
+
h += "<p>This is the example, and nothing here is saved. Run by <code>seisin ui</code>, this panel lists " +
|
|
1382
|
+
"what each role’s sandbox settings gain or lose — computed by the server from the edited " +
|
|
1383
|
+
"policy — and the exact line written to seisin.toml, and nothing is written until you confirm.</p>";
|
|
1384
|
+
buttons = '<button class="btn primary" id="ctl-save" type="button">Apply to the example</button>' + buttons;
|
|
1385
|
+
} else if (p.plan && !p.plan.changed) {
|
|
1386
|
+
h += "<p>seisin.toml already says this. Nothing to save.</p>";
|
|
1387
|
+
} else if (p.plan) {
|
|
1388
|
+
var d = p.plan.diff || [];
|
|
1389
|
+
h += groupedDiff(d, p.fam);
|
|
1390
|
+
var idle = p.plan.unaffected || [];
|
|
1391
|
+
if (idle.length)
|
|
1392
|
+
h += "<p>No change for <b>" + esc(idle.join(", ")) + "</b>: no territory of " +
|
|
1393
|
+
(idle.length === 1 ? "that role" : "those roles") + " holds a project where these files are or could be created.</p>";
|
|
1394
|
+
var a = p.plan.access || {};
|
|
1395
|
+
h += "<p>Then editor settings may be edited by " + whoMay(a.ide) + "; instruction files " +
|
|
1396
|
+
(a.instructions === null || a.instructions === undefined
|
|
1397
|
+
? "are not protected — <b>every role</b> may edit them in its own territory."
|
|
1398
|
+
: "by " + whoMay(a.instructions) + ".") + "</p>";
|
|
1399
|
+
h += tomlLines(p.plan.lines, "Written to seisin.toml (your reason is added at the end of the line)");
|
|
1400
|
+
buttons = '<input id="ctl-why" placeholder="reason (required) — written beside the line" aria-label="Reason for this change">' +
|
|
1401
|
+
'<button class="btn primary" id="ctl-save" type="button" data-write>Save to seisin.toml</button>' + buttons;
|
|
1402
|
+
}
|
|
1403
|
+
box.innerHTML = h + '<div class="act">' + buttons + '</div><p class="err" id="ctl-err" hidden></p>';
|
|
1404
|
+
document.getElementById("ctl-cancel").onclick = cancelEdit;
|
|
1405
|
+
var save = document.getElementById("ctl-save");
|
|
1406
|
+
if (save) save.onclick = saveEdit;
|
|
1407
|
+
box.onkeydown = function(e){ if (e.key === "Escape") { e.preventDefault(); cancelEdit(); } };
|
|
1408
|
+
});
|
|
1409
|
+
lockWrites();
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1412
|
+
/** What a save wrote, left under the box until the next change. */
|
|
1413
|
+
function doneNotice(where, html){
|
|
1414
|
+
["pw", "ctl"].forEach(function(w){
|
|
1415
|
+
var el = document.getElementById(w + "-done");
|
|
1416
|
+
if (!el) return;
|
|
1417
|
+
if (w !== where || !html) { el.hidden = true; el.innerHTML = ""; return; }
|
|
1418
|
+
el.hidden = false;
|
|
1419
|
+
el.innerHTML = '<span class="txt">' + html + '</span><button type="button">dismiss</button>';
|
|
1420
|
+
el.querySelector("button").onclick = function(){ el.hidden = true; el.innerHTML = ""; };
|
|
1421
|
+
});
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1424
|
+
/** The confirmation: the edit that was previewed, against the file it was previewed on. */
|
|
1425
|
+
async function saveEdit(){
|
|
1426
|
+
var p = PENDING; if (!p) return;
|
|
1427
|
+
if (p.demo) {
|
|
1428
|
+
if (p.edit.protect !== undefined) PROTECT = { instructions: p.edit.protect };
|
|
1429
|
+
else { var r = ROLES.filter(function(x){ return x.name === p.edit.role; })[0]; if (r) r.controlFiles = p.edit.families.slice(); }
|
|
1430
|
+
PENDING = null;
|
|
1431
|
+
doneNotice(p.where, "Applied to the example: " + esc(describe(p.edit)) + ". Nothing was saved — " +
|
|
1432
|
+
'<a href="#configfile">see the example seisin.toml</a>.');
|
|
1433
|
+
note("control files", describe(p.edit));
|
|
1434
|
+
var b = document.getElementById(p.from); if (b) b.focus();
|
|
1435
|
+
return;
|
|
1436
|
+
}
|
|
1437
|
+
var why = document.getElementById("ctl-why");
|
|
1438
|
+
if (why && !why.value.trim()) {
|
|
1439
|
+
why.setAttribute("aria-invalid", "true"); why.focus();
|
|
1440
|
+
var er = document.getElementById("ctl-err");
|
|
1441
|
+
er.hidden = false; er.textContent = "Write a reason first — it is saved beside the line in seisin.toml.";
|
|
1442
|
+
return;
|
|
1443
|
+
}
|
|
1444
|
+
var box = document.getElementById(p.where + "-preview");
|
|
1445
|
+
box.querySelectorAll("button,input").forEach(function(e){ e.disabled = true; });
|
|
1446
|
+
try {
|
|
1447
|
+
var out = await callControls(Object.assign({ base: p.plan.base, reason: (document.getElementById("ctl-why") || {}).value || "" }, p.edit));
|
|
1448
|
+
PENDING = null;
|
|
1449
|
+
var ln = out.lines;
|
|
1450
|
+
MARK = ln && ln.added.length ? ln.at : null;
|
|
1451
|
+
doneNotice(p.where, "Saved to seisin.toml: " + esc(describe(p.edit)) + ". The next <code>seisin run</code> of a role uses it." +
|
|
1452
|
+
(ln && ln.added.length ? '<span class="ln">line ' + ln.at + ": " +
|
|
1453
|
+
ln.added.filter(function(l){ return l.trim(); }).map(esc).join(" · ") + "</span>" : "") +
|
|
1454
|
+
' <a href="#configfile">see Config file' + (MARK ? ", line " + MARK : "") + "</a>");
|
|
1455
|
+
await reload();
|
|
1456
|
+
drawPreview();
|
|
1457
|
+
var b2 = document.getElementById(p.from); if (b2) b2.focus();
|
|
1458
|
+
} catch (e) {
|
|
1459
|
+
if (PENDING === p) { p.error = whyFailed(e, e.status); p.plan = null; }
|
|
1460
|
+
drawPreview();
|
|
1461
|
+
}
|
|
1462
|
+
}
|
|
831
1463
|
|
|
832
1464
|
/* ── requests ─────────────────────────────────────────────────────────── */
|
|
833
1465
|
|
|
834
1466
|
var REQS = [];
|
|
1467
|
+
var LIVE = false; // served by `seisin ui`, reading a real repo
|
|
1468
|
+
var SETTINGS = {}; // role -> { settings } or { error }, from /api/settings; cleared when the policy changes
|
|
835
1469
|
var ONDISK = null; // the file on disk, when there is one
|
|
836
1470
|
var CAUSES = null; // this window's denials, grouped by what was denied
|
|
837
1471
|
var WALLS = {}; // per role, what it keeps being denied and still would be
|
|
1472
|
+
var SHOW_ALL = false; // Denied: every cause, not the first twelve
|
|
1473
|
+
|
|
1474
|
+
/** The kind as a person reads it. The stored value "territory" reads as its opposite — a
|
|
1475
|
+
* path nobody owns — so it is said "ownable"; the value itself is a published field. */
|
|
1476
|
+
function kindSays(k){ return k === "territory" ? "ownable" : k; }
|
|
1477
|
+
|
|
1478
|
+
/** One cause's line under its path: whose it is, or what kind of thing it is. */
|
|
1479
|
+
function causeSays(c){
|
|
1480
|
+
if (c.about && c.about !== "file") return unownedSays(c.about);
|
|
1481
|
+
// Refused correctly and nothing to grant: said as expected, before "protected".
|
|
1482
|
+
if (c.expected) return "expected · " + c.expected.hint;
|
|
1483
|
+
if (c.standing === "protected") return "protected: " + (c.why || "");
|
|
1484
|
+
if (c.standing === "outside") return c.why || "outside the map";
|
|
1485
|
+
if (c.owners && c.owners.length) return "owned by " + c.owners.join(", ");
|
|
1486
|
+
return "unowned" + (c.kind ? " · " + kindSays(c.kind) + (c.kind !== "territory" && c.hint ? ": " + c.hint : "") : "");
|
|
1487
|
+
}
|
|
1488
|
+
|
|
1489
|
+
function bar(c, max){
|
|
1490
|
+
var w = Math.max(2, Math.round((c.times / max) * 100));
|
|
1491
|
+
// A cause the policy no longer denies is history, not live: shown greyed, not dropped.
|
|
1492
|
+
var cold = c.stillDenied === 0;
|
|
1493
|
+
var who = c.roles.length === 1 ? c.roles[0] : c.roles.length + " roles";
|
|
1494
|
+
return '<div class="bar' + (cold ? ' cold' : '') + '">' +
|
|
1495
|
+
'<div class="lbl"><div class="p">' + esc(c.action) + ' ' + esc(c.target) + '</div>' +
|
|
1496
|
+
'<div class="who">' + esc(who) + " · " + esc(causeSays(c)) + (cold ? ' · <b>granted since</b>' : '') + '</div>' +
|
|
1497
|
+
'<div class="t"><span class="f" style="width:' + w + '%"></span></div></div>' +
|
|
1498
|
+
'<div class="num">' + plural(c.times, "denial") + '<s>' + (c.share * 100).toFixed(1) + '% of all</s></div>' +
|
|
1499
|
+
'</div>';
|
|
1500
|
+
}
|
|
838
1501
|
|
|
839
|
-
/**
|
|
840
|
-
* The queue, as something a person acts on.
|
|
841
|
-
*
|
|
842
|
-
* Hidden entirely when empty rather than shown as an empty table: the point
|
|
843
|
-
* of this panel is to be noticed on the days it has something in it.
|
|
844
|
-
*/
|
|
845
1502
|
/**
|
|
846
1503
|
* The shape of the window, grouped by cause.
|
|
847
1504
|
*
|
|
848
|
-
* The reading comes first and the bars second,
|
|
849
|
-
*
|
|
850
|
-
*
|
|
851
|
-
* day where nobody disputed any territory, and that sentence is the whole
|
|
852
|
-
* value of grouping. It is written out rather than left for the reader to
|
|
853
|
-
* infer from twelve bars.
|
|
1505
|
+
* The reading comes first and the bars second. Files first, then keys, the network and MCP
|
|
1506
|
+
* tools each apart: "no role owns this path" is a sentence about paths. The list is cut at
|
|
1507
|
+
* twelve and says so — it used to stop at twelve with nothing to say there were more.
|
|
854
1508
|
*/
|
|
855
1509
|
function drawCauses(){
|
|
856
1510
|
var f = CAUSES;
|
|
857
1511
|
var lede = document.getElementById("ca-lede");
|
|
1512
|
+
var bars = document.getElementById("ca-bars"), more = document.getElementById("ca-more"), other = document.getElementById("ca-other");
|
|
858
1513
|
if (!f || !f.total) {
|
|
859
|
-
|
|
860
|
-
|
|
1514
|
+
lede.textContent = "Nothing was denied in this window.";
|
|
1515
|
+
bars.innerHTML = ""; more.innerHTML = ""; other.innerHTML = "";
|
|
861
1516
|
return;
|
|
862
1517
|
}
|
|
863
|
-
var top = f.causes[0];
|
|
864
1518
|
var fam = (f.families || [])[0];
|
|
865
1519
|
var famShare = fam ? Math.round(fam.share * 100) : 0;
|
|
866
|
-
var
|
|
867
|
-
|
|
868
|
-
"</b> distinct
|
|
1520
|
+
var distinct = f.distinct || f.causes.length;
|
|
1521
|
+
var h = "<b>" + plural(f.total, "denial") + "</b> in this window, over <b>" + distinct.toLocaleString() +
|
|
1522
|
+
"</b> distinct " + (distinct === 1 ? "thing" : "things") + ". ";
|
|
869
1523
|
// The reading has to be taken at the level of the NAME, not the path, or it comes out
|
|
870
|
-
// backwards
|
|
871
|
-
// different repositories and no single path passed 17% \u2014 so "no cause dominates" was
|
|
872
|
-
// what the arithmetic said and the opposite of the truth. One KIND of thing at that scale
|
|
873
|
-
// is a tooling problem with a mechanical fix; the same volume spread across unrelated
|
|
874
|
-
// paths is a territory question and needs a person. They take opposite responses, which
|
|
875
|
-
// is why this sentence is written out instead of left to be inferred from twelve bars.
|
|
1524
|
+
// backwards: six lock files in six repositories, none above 17%, is one kind of thing.
|
|
876
1525
|
if (fam && famShare >= 40 && fam.paths > 1)
|
|
877
|
-
|
|
878
|
-
famShare + "%</b>
|
|
1526
|
+
h += "<span class=\"q\"><b>" + fam.times.toLocaleString() + "</b> of them — <b>" +
|
|
1527
|
+
famShare + "%</b> — are the same name in " + fam.paths + " places: " +
|
|
879
1528
|
"<code>" + esc(fam.name) + "</code>. One kind of thing at that scale is a " +
|
|
880
1529
|
"tooling problem with a mechanical fix, not something to grant.</span>";
|
|
881
1530
|
else if (fam && famShare >= 40)
|
|
882
|
-
|
|
1531
|
+
h += "<span class=\"q\"><b>" + famShare + "%</b> of it is a single path. That is " +
|
|
883
1532
|
"usually mechanical rather than something to grant.</span>";
|
|
884
1533
|
else
|
|
885
|
-
|
|
1534
|
+
h += "<span class=\"q\">No one kind of thing dominates, which is what a real boundary " +
|
|
886
1535
|
"question looks like rather than a tooling one.</span>";
|
|
887
|
-
/* The half a grep cannot do, because it needs the policy: how many of these
|
|
888
|
-
are on paths nobody claims. Those are the ones no grant resolves until a
|
|
889
|
-
person decides who owns them — a decision, not a count. Added after a
|
|
890
|
-
field review pointed out that the percentage above was the console
|
|
891
|
-
reading the log back to somebody who had already read it. */
|
|
892
|
-
/* Where each path stands against the policy NOW, not what the log wrote the day of the
|
|
893
|
-
refusal: that was read here once, and every grant made since kept its paths counted as
|
|
894
|
-
nobody's. Protected paths and ports are said apart, because they ask the opposite of a
|
|
895
|
-
decision \u2014 there is nothing to grant. */
|
|
896
1536
|
var st = f.standing || {};
|
|
897
1537
|
if (f.unowned)
|
|
898
|
-
|
|
899
|
-
"
|
|
900
|
-
|
|
1538
|
+
h += " <b>" + f.unowned.toLocaleString() + "</b> " + (f.unowned === 1 ? "is a path" : "are paths") +
|
|
1539
|
+
" <b>no role owns</b> — a grant cannot settle those until somebody claims them.";
|
|
1540
|
+
var kinds = st.unowned && st.unowned.kinds;
|
|
1541
|
+
if (f.unowned && kinds && kinds.territory && kinds.territory.paths < f.unowned)
|
|
1542
|
+
h += " Only <b>" + kinds.territory.paths.toLocaleString() + "</b> of those " +
|
|
1543
|
+
(kinds.territory.paths === 1 ? "is" : "are") + " ownable, a question of who owns what; the rest is " +
|
|
1544
|
+
["git", "temporary", "build", "credential"].filter(function(k){ return kinds[k] && kinds[k].paths; })
|
|
1545
|
+
.map(function(k){ return kinds[k].paths.toLocaleString() + " " + k; }).join(", ") +
|
|
1546
|
+
", each with its own fix and none of them an owner.";
|
|
1547
|
+
var ex = f.expected;
|
|
1548
|
+
if (ex && ex.denials)
|
|
1549
|
+
h += " <b>" + plural(ex.denials, "denial") + "</b> " + (ex.denials === 1 ? "is" : "are") +
|
|
1550
|
+
" expected: Claude Code updating its plugins from inside the sandbox. They run and update outside it — nothing to grant.";
|
|
901
1551
|
var closed = (st.protected ? st.protected.paths : 0);
|
|
902
1552
|
if (closed)
|
|
903
|
-
|
|
904
|
-
" protected on purpose
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
var
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
1553
|
+
h += " <b>" + closed.toLocaleString() + "</b> " + (closed === 1 ? "is" : "are") +
|
|
1554
|
+
" protected on purpose — the boundary working, nothing to grant.";
|
|
1555
|
+
lede.innerHTML = h;
|
|
1556
|
+
document.getElementById("dn-window").textContent = rangeLabel() + " · " + plural(f.total, "denial");
|
|
1557
|
+
|
|
1558
|
+
var files = f.causes.filter(function(c){ return (c.about || "file") === "file"; });
|
|
1559
|
+
var max = Math.max.apply(null, f.causes.map(function(c){ return c.times; }).concat([1]));
|
|
1560
|
+
var shown = SHOW_ALL ? files : files.slice(0, 12);
|
|
1561
|
+
var fileTotal = f.about ? f.about.file.paths : files.length;
|
|
1562
|
+
bars.innerHTML = (files.length ? '<h3 class="sr">Files</h3>' : "") + shown.map(function(c){ return bar(c, max); }).join("");
|
|
1563
|
+
more.innerHTML = "";
|
|
1564
|
+
if (fileTotal > 12) {
|
|
1565
|
+
var b = document.createElement("button"); b.type = "button"; b.className = "showall";
|
|
1566
|
+
b.textContent = SHOW_ALL ? "show the first 12" :
|
|
1567
|
+
shown.length + " of " + fileTotal.toLocaleString() + " paths · show all" +
|
|
1568
|
+
(files.length < fileTotal ? " (the first " + files.length + " are listed here)" : "");
|
|
1569
|
+
b.onclick = function(){ SHOW_ALL = !SHOW_ALL; drawCauses(); };
|
|
1570
|
+
more.appendChild(b);
|
|
1571
|
+
}
|
|
1572
|
+
var parts = [
|
|
1573
|
+
["key", "Keys", "reads of a key the role does not declare"],
|
|
1574
|
+
["network", "Network", "connections to a destination not in the network allow list"],
|
|
1575
|
+
["tool", "MCP tools", "tools from an MCP server the role is not allowed to use"]
|
|
1576
|
+
];
|
|
1577
|
+
other.innerHTML = parts.map(function(p){
|
|
1578
|
+
var list = f.causes.filter(function(c){ return c.about === p[0]; });
|
|
1579
|
+
var n = f.about ? f.about[p[0]] : { paths: list.length, denials: list.reduce(function(s, c){ return s + c.times; }, 0) };
|
|
1580
|
+
if (!n || !n.denials) return "";
|
|
1581
|
+
return '<div class="other"><h3>' + p[1] + " · " + plural(n.denials, "denial") + "</h3><p>" + p[2] + '.</p><div class="bars">' +
|
|
1582
|
+
list.map(function(c){ return bar(c, max); }).join("") + "</div></div>";
|
|
931
1583
|
}).join("");
|
|
932
|
-
|
|
933
1584
|
}
|
|
934
1585
|
|
|
935
1586
|
/** Denied, by name — the cut that says what kind of day it was. */
|
|
936
1587
|
function drawFamilies(){
|
|
937
|
-
var f = CAUSES;
|
|
938
|
-
var fams = f.families || [];
|
|
1588
|
+
var f = CAUSES;
|
|
1589
|
+
var fams = (f && f.families) || [];
|
|
939
1590
|
var top = fams[0];
|
|
940
1591
|
document.getElementById("fa-lede").innerHTML = !top ? "Nothing was denied in this window."
|
|
941
|
-
: "<b>" + Math.round(top.share * 100) + "%</b> of " +
|
|
942
|
-
"
|
|
943
|
-
(top.paths === 1 ? "" : "s") + "." +
|
|
1592
|
+
: "The most common name is <code>" + esc(top.name) + "</code>: <b>" + Math.round(top.share * 100) + "%</b> of " +
|
|
1593
|
+
plural(f.total, "denial") + ", across <b>" + top.paths + "</b> path" + (top.paths === 1 ? "" : "s") + "." +
|
|
944
1594
|
(top.share >= 0.4 && top.paths > 1
|
|
945
1595
|
? ' <span class="q">One name at that scale is usually mechanical. Fixing the tool ' +
|
|
946
1596
|
'removes the denials; granting the paths only hides them.</span>'
|
|
947
|
-
: ' <span class="q">No single name dominates
|
|
1597
|
+
: ' <span class="q">No single name dominates — these are separate questions, ' +
|
|
948
1598
|
'not one problem.</span>');
|
|
949
1599
|
|
|
950
1600
|
document.getElementById("fa-list").innerHTML = fams.map(function(x){
|
|
951
1601
|
var w = Math.max(2, Math.round((x.times / (top.times || 1)) * 100));
|
|
952
1602
|
return '<div class="fam">' +
|
|
953
1603
|
'<div class="fam-h"><code>' + esc(x.name) + '</code>' +
|
|
954
|
-
'<span class="num">' + x.times
|
|
1604
|
+
'<span class="num">' + plural(x.times, "denial") + ' <s>' + (x.share*100).toFixed(1) + '%</s></span></div>' +
|
|
955
1605
|
'<div class="t"><span class="f" style="width:' + w + '%"></span></div>' +
|
|
956
|
-
'<div class="who">' + x.paths + ' path' + (x.paths===1?'':'s') + '
|
|
1606
|
+
'<div class="who">' + x.paths + ' path' + (x.paths===1?'':'s') + ' · ' +
|
|
957
1607
|
x.roles.length + ' role' + (x.roles.length===1?'':'s') + ': ' + esc(x.roles.join(", ")) + '</div>' +
|
|
958
1608
|
'<ul class="places">' + x.where.map(function(p){
|
|
959
1609
|
return '<li><span>' + esc(p.target) + '</span><b>' + p.times.toLocaleString() + '</b></li>';
|
|
@@ -961,62 +1611,69 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
|
|
|
961
1611
|
}).join("");
|
|
962
1612
|
}
|
|
963
1613
|
|
|
964
|
-
/**
|
|
1614
|
+
/** Retries on a list of walls: every call after the first denial of each. */
|
|
1615
|
+
function retriesOf(list){ return list.reduce(function(n, w){ return n + (w.expected ? 0 : w.retried !== undefined ? w.retried : w.times - 1); }, 0); }
|
|
1616
|
+
|
|
1617
|
+
/**
|
|
1618
|
+
* What a wall says under its path: the sentence Denied gives the same path, from the same
|
|
1619
|
+
* function. Two of them drifted — Walls put "has an owner" before "protected" and had no
|
|
1620
|
+
* case for a path outside the repository, so it told a person to add ~/.npmrc to a role's
|
|
1621
|
+
* keys where Denied said there was nothing to grant.
|
|
1622
|
+
*/
|
|
1623
|
+
function wallSays(w){ return causeSays(w); }
|
|
1624
|
+
|
|
1625
|
+
/**
|
|
1626
|
+
* Walls — per role, what it keeps hitting and what that cost.
|
|
1627
|
+
*
|
|
1628
|
+
* One unit per number, said beside it. The role's figure was retries (times − 1 per wall)
|
|
1629
|
+
* and each row's was times, so a role with walls of 5× and 3× read "6" above two numbers
|
|
1630
|
+
* that add up to eight.
|
|
1631
|
+
*/
|
|
965
1632
|
function drawWalls(){
|
|
966
1633
|
var rows = Object.keys(WALLS || {}).map(function(r){
|
|
967
1634
|
var w = WALLS[r];
|
|
968
|
-
return { role: r, walls: w, spent: w.reduce(function(n,x){ return n + x.times
|
|
1635
|
+
return { role: r, walls: w, spent: retriesOf(w), hits: w.reduce(function(n, x){ return n + x.times; }, 0) };
|
|
969
1636
|
}).sort(function(a,b){ return b.spent - a.spent; });
|
|
970
1637
|
var total = rows.reduce(function(n,x){ return n + x.spent; }, 0);
|
|
971
1638
|
|
|
972
1639
|
document.getElementById("wl-lede").innerHTML = !rows.length
|
|
973
1640
|
? "No role is repeating a denial it has already been given."
|
|
974
|
-
: "<b>" + total
|
|
975
|
-
|
|
976
|
-
' went into retrying something already denied. <span class="q">A denial is said once, ' +
|
|
1641
|
+
: "<b>" + plural(total, "retry", "retries") + "</b> across <b>" + plural(rows.length, "role") +
|
|
1642
|
+
'</b>: calls that tried again something already denied. <span class="q">A denial is said once, ' +
|
|
977
1643
|
'mid-turn. Nothing carries it forward, so it gets tried again.</span>';
|
|
978
1644
|
|
|
979
1645
|
document.getElementById("wl-list").innerHTML = rows.map(function(x){
|
|
980
1646
|
return '<div class="fam">' +
|
|
981
1647
|
'<div class="fam-h"><code>' + esc(x.role) + '</code>' +
|
|
982
|
-
'<span class="num">' + x.spent
|
|
1648
|
+
'<span class="num">' + plural(x.spent, "retry", "retries") + ' <s>of ' + plural(x.hits, "denial") + '</s></span></div>' +
|
|
983
1649
|
'<ul class="places">' + x.walls.map(function(w){
|
|
984
1650
|
return '<li><span>' + esc(w.action) + ' ' + esc(w.target) + '<br>' +
|
|
985
|
-
'<em>' + esc(w
|
|
1651
|
+
'<em>' + esc(wallSays(w)) + '</em></span><b>' + plural(w.times, "denial") + " · " +
|
|
1652
|
+
(w.retried !== undefined ? w.retried : w.times - 1) + " retried</b></li>";
|
|
986
1653
|
}).join("") + '</ul></div>';
|
|
987
1654
|
}).join("");
|
|
988
1655
|
}
|
|
989
1656
|
|
|
990
|
-
/** A count beside a nav item, or nothing
|
|
991
|
-
function pill(id, n){
|
|
1657
|
+
/** A count beside a nav item, or nothing — with its unit for a screen reader and a tooltip. */
|
|
1658
|
+
function pill(id, n, unit){
|
|
992
1659
|
var el = document.getElementById(id);
|
|
993
1660
|
if (!el) return;
|
|
994
|
-
el.
|
|
1661
|
+
el.innerHTML = n ? esc(n > 999 ? Math.round(n/1000) + "k" : String(n)) + '<span class="sr"> ' + esc(unit) + "</span>" : "";
|
|
1662
|
+
el.title = n ? n.toLocaleString() + " " + unit : "";
|
|
995
1663
|
el.hidden = !n;
|
|
996
1664
|
}
|
|
997
1665
|
|
|
998
1666
|
/**
|
|
999
|
-
* The five numbers
|
|
1667
|
+
* The five numbers.
|
|
1000
1668
|
*
|
|
1001
1669
|
* Chosen by one rule: a KPI has to be something that can change and that you
|
|
1002
|
-
* would do something about.
|
|
1003
|
-
*
|
|
1004
|
-
* last and the same size as the rest.
|
|
1005
|
-
*
|
|
1006
|
-
* Emphasis is conditional, never constant. At zero they go quiet and say so
|
|
1007
|
-
* in words, because "nothing to decide" is a state and an indicator that
|
|
1008
|
-
* shouts at rest is one people stop seeing.
|
|
1009
|
-
*
|
|
1010
|
-
* Every value is read from the globals rather than passed in, so any refresh
|
|
1011
|
-
* can call it — the queue refreshes on its own, and passing the role counts
|
|
1012
|
-
* in once left the row stale on the number that moves most.
|
|
1670
|
+
* would do something about. Emphasis is conditional, never constant: at zero
|
|
1671
|
+
* they go quiet and say so in words.
|
|
1013
1672
|
*/
|
|
1014
1673
|
function drawKpis(){
|
|
1015
1674
|
var f = CAUSES || { total: 0, families: [], distinct: 0, causes: [] };
|
|
1016
1675
|
var waiting = REQS.length;
|
|
1017
|
-
var retry = Object.keys(WALLS || {}).reduce(function(n, r){
|
|
1018
|
-
return n + WALLS[r].reduce(function(m, w){ return m + w.times - 1; }, 0);
|
|
1019
|
-
}, 0);
|
|
1676
|
+
var retry = Object.keys(WALLS || {}).reduce(function(n, r){ return n + retriesOf(WALLS[r]); }, 0);
|
|
1020
1677
|
var fam = f.families && f.families[0];
|
|
1021
1678
|
var off = (ROLES || []).filter(function(r){ return !r.sandbox; }).length;
|
|
1022
1679
|
|
|
@@ -1031,59 +1688,104 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
|
|
|
1031
1688
|
|
|
1032
1689
|
set("k-wait", waiting ? "live" : "calm", waiting,
|
|
1033
1690
|
waiting ? (waiting === 1 ? "decision" : "decisions") : "nothing to decide");
|
|
1034
|
-
// The only number here that is an emergency, and it is almost always zero.
|
|
1035
1691
|
set("k-open", off ? "alarm" : "calm", off,
|
|
1036
|
-
off ? (off === 1 ? "role
|
|
1692
|
+
off ? (off === 1 ? "role running unconfined" : "roles running unconfined") : "every role confined");
|
|
1037
1693
|
set("k-ref", f.total ? "live" : "calm", f.total.toLocaleString(),
|
|
1038
|
-
(f.total ? (f.distinct || f.causes.length).toLocaleString() + " distinct
|
|
1039
|
-
(RANGE === "all" ? "" : "
|
|
1040
|
-
//
|
|
1041
|
-
//
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1694
|
+
(f.total ? (f.distinct || f.causes.length).toLocaleString() + " distinct things" : "nothing was denied") +
|
|
1695
|
+
(RANGE === "all" ? "" : " · " + rangeLabel()));
|
|
1696
|
+
// Under 40% no name is "the" cause, and the Denied page says so in words. The card said
|
|
1697
|
+
// "18% is index.lock" beside a lede saying nothing dominates.
|
|
1698
|
+
if (fam && fam.share >= 0.4)
|
|
1699
|
+
set("k-conc", "live", Math.round(fam.share * 100) + "%", "of denials are " + fam.name);
|
|
1700
|
+
else
|
|
1701
|
+
set("k-conc", "calm", fam ? "none" : "—",
|
|
1702
|
+
fam ? "no dominant cause · largest: " + fam.name + " " + Math.round(fam.share * 100) + "%" : "nothing was denied");
|
|
1046
1703
|
set("k-retry", retry ? "live" : "calm", retry.toLocaleString(),
|
|
1047
|
-
retry ? "
|
|
1704
|
+
retry ? "retries on walls already denied" : "nothing retried");
|
|
1705
|
+
|
|
1706
|
+
pill("dn-pin", f.total, f.total === 1 ? "denial" : "denials");
|
|
1707
|
+
pill("wl-pin", retry, retry === 1 ? "retry on a wall" : "retries on walls");
|
|
1708
|
+
}
|
|
1709
|
+
|
|
1710
|
+
/** What granting covers, said before the click: the file that was asked for, the folder
|
|
1711
|
+
* that would be granted, and whose it is. It said "wants to write docs/**" for a request
|
|
1712
|
+
* over docs/api.md, and the widening was a detail in grey under it. */
|
|
1713
|
+
function scopeOf(r){
|
|
1714
|
+
var owners = r.owners || [];
|
|
1715
|
+
var own = owners.length
|
|
1716
|
+
? '<span class="own">' + esc(r.grant) + " belongs to <b>" + esc(owners.join(", ")) + "</b>. Granting lets <b>" +
|
|
1717
|
+
esc(r.role) + "</b> write it too.</span>"
|
|
1718
|
+
: '<span class="own">No role owns it today.</span>';
|
|
1719
|
+
if (r.action === "read")
|
|
1720
|
+
return '<div class="scope">Granting lets <b>' + esc(r.role) + "</b> read the key <code>" + esc(r.grant) + "</code>.</div>";
|
|
1721
|
+
if (r.grant === r.target)
|
|
1722
|
+
return '<div class="scope">Granting covers exactly <code>' + esc(r.grant) + "</code>." + own + "</div>";
|
|
1723
|
+
return '<div class="scope wide">Asked for <code>' + esc(r.target) + "</code> → granting covers <b>all of <code>" +
|
|
1724
|
+
esc(r.grant) + "</code></b>, not only that file." + own + "</div>";
|
|
1725
|
+
}
|
|
1726
|
+
|
|
1727
|
+
/** Where the request goes, as the server decided it: handed to its owner, or a person decides.
|
|
1728
|
+
* Not worked out here — the page shows the decision seisin requests and the MCP show. */
|
|
1729
|
+
function handoffOf(r){
|
|
1730
|
+
if (typeof r.handoffNote !== "string" || !r.handoffNote) return "";
|
|
1731
|
+
var routed = /^handed to /.test(r.handoffNote);
|
|
1732
|
+
return '<div class="handoff' + (routed ? "" : " person") + '">' + esc(r.handoffNote) + "</div>";
|
|
1733
|
+
}
|
|
1734
|
+
|
|
1735
|
+
/** A write keyed by a folder can be granted as the one file instead. */
|
|
1736
|
+
function narrowable(r){ return r.action === "write" && r.grant !== r.target; }
|
|
1048
1737
|
|
|
1049
|
-
|
|
1050
|
-
|
|
1738
|
+
/** The line a one-file grant carries. Live, the server says it from the role's last run;
|
|
1739
|
+
* the example has no runs, so it says it conditionally. */
|
|
1740
|
+
function fileNoteOf(r){
|
|
1741
|
+
if (typeof r.fileNote === "string") return r.fileNote;
|
|
1742
|
+
return "If " + r.role + " runs Claude Code, a one-file grant will not let it edit: Claude Code's Write and Edit " +
|
|
1743
|
+
"save to a temporary beside the file and rename it over, and the temporary is outside a one-file grant (EPERM).";
|
|
1744
|
+
}
|
|
1745
|
+
|
|
1746
|
+
/** Folder (as before) or only the file: the choice, and what the file choice costs. */
|
|
1747
|
+
function scopeChoice(r, i){
|
|
1748
|
+
if (!narrowable(r)) return "";
|
|
1749
|
+
return '<div class="pick" role="radiogroup" aria-label="What to grant">' +
|
|
1750
|
+
'<label><input type="radio" name="scope-' + i + '" value="folder" checked> the folder <code>' + esc(r.grant) + "</code></label>" +
|
|
1751
|
+
'<label><input type="radio" name="scope-' + i + '" value="file"> only the file <code>' + esc(r.target) + "</code></label>" +
|
|
1752
|
+
"</div>" +
|
|
1753
|
+
'<div class="fnote" id="fnote-' + i + '" hidden>' + esc(fileNoteOf(r)) + "</div>";
|
|
1754
|
+
}
|
|
1755
|
+
|
|
1756
|
+
/** What the row's Grant button would write: the folder, or the one file chosen instead. */
|
|
1757
|
+
function chosen(r, i){
|
|
1758
|
+
var f = document.querySelector('input[name="scope-' + i + '"][value="file"]');
|
|
1759
|
+
return narrowable(r) && f && f.checked ? { scope: "file", grant: r.target } : { scope: "folder", grant: r.grant };
|
|
1051
1760
|
}
|
|
1052
1761
|
|
|
1053
1762
|
function drawReqs(){
|
|
1054
|
-
var box = document.getElementById("requests");
|
|
1055
|
-
var pin = document.getElementById("req-pin");
|
|
1056
1763
|
var host = document.getElementById("reqs");
|
|
1057
|
-
pin
|
|
1058
|
-
// The box used to hide itself when empty — right, when it sat under the roles table and
|
|
1059
|
-
// an empty one was just noise. Now that Requests is a view of its own, hiding it leaves
|
|
1060
|
-
// a page with a heading and nothing under it. It stays, and says what would fill it.
|
|
1061
|
-
box.hidden = false;
|
|
1764
|
+
pill("req-pin", REQS.length, REQS.length === 1 ? "request" : "requests");
|
|
1062
1765
|
if (!REQS.length) {
|
|
1063
|
-
// Compact when this is the landing view. The tall empty state was right
|
|
1064
|
-
// when Requests was a page of its own — a heading with nothing under it
|
|
1065
|
-
// reads as broken. At the top of the front page it is 180px of nothing
|
|
1066
|
-
// standing between you and the only content there is.
|
|
1067
1766
|
host.innerHTML = '<p class="blank quiet">No pending decisions. ' +
|
|
1068
1767
|
'<span>Agents land here when they ask for a path the policy denies them.</span></p>';
|
|
1768
|
+
drawBulk();
|
|
1069
1769
|
return;
|
|
1070
1770
|
}
|
|
1071
1771
|
|
|
1072
1772
|
host.innerHTML = REQS.map(function(r, i){
|
|
1073
|
-
var
|
|
1074
|
-
var
|
|
1075
|
-
// Marked, not moved: Approve and Decline post this row's id.
|
|
1773
|
+
var times = r.times > 1 ? 'asked ' + r.times + "×" : "asked once";
|
|
1774
|
+
var w = when(r.last || r.first);
|
|
1076
1775
|
var stale = r.stale ? ' · not asked again in ' + Number(r.stale.runs) + ' runs since — likely no longer needed' : "";
|
|
1077
1776
|
return '<div class="req' + (r.stale ? ' stale' : '') + '">' +
|
|
1078
1777
|
'<div class="who">' +
|
|
1079
|
-
"<div><b>" + esc(r.role) + "</b> wants to " + esc(r.action) +
|
|
1080
|
-
|
|
1081
|
-
'<div class="sub"
|
|
1778
|
+
"<div><b>" + esc(r.role) + "</b> wants to " + esc(r.action) + ' <span class="path">' + esc(r.target) + "</span></div>" +
|
|
1779
|
+
scopeOf(r) + handoffOf(r) + scopeChoice(r, i) +
|
|
1780
|
+
'<div class="sub"><span class="asked">' + times + "</span>" +
|
|
1781
|
+
(w.text ? ' · last <span title="' + escA(w.title) + '">' + esc(w.text) + "</span>" : "") + stale + "</div>" +
|
|
1782
|
+
'<div class="err" id="err-' + i + '" role="alert" hidden></div>' +
|
|
1082
1783
|
"</div>" +
|
|
1083
1784
|
'<div class="act">' +
|
|
1084
|
-
'<input id="why-' + i + '" placeholder="
|
|
1085
|
-
|
|
1086
|
-
'<button class="btn
|
|
1785
|
+
'<input id="why-' + i + '" placeholder="reason (required)" aria-label="Reason for ' +
|
|
1786
|
+
escA(r.role) + '’s request" aria-describedby="err-' + i + '">' +
|
|
1787
|
+
'<button class="btn primary" data-n="' + i + '" data-k="' + escA(r.key) + '" data-d="granted" type="button" data-write>Grant</button>' +
|
|
1788
|
+
'<button class="btn" data-n="' + i + '" data-k="' + escA(r.key) + '" data-d="declined" type="button" data-write>Decline</button>' +
|
|
1087
1789
|
"</div>" +
|
|
1088
1790
|
"</div>";
|
|
1089
1791
|
}).join("");
|
|
@@ -1091,28 +1793,56 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
|
|
|
1091
1793
|
host.querySelectorAll("button[data-n]").forEach(function(b){
|
|
1092
1794
|
b.addEventListener("click", function(){ act(b); });
|
|
1093
1795
|
});
|
|
1796
|
+
host.querySelectorAll('input[type="radio"][name^="scope-"]').forEach(function(x){
|
|
1797
|
+
x.addEventListener("change", function(){
|
|
1798
|
+
var n = x.name.slice(6), note = document.getElementById("fnote-" + n);
|
|
1799
|
+
if (note) note.hidden = !(x.value === "file" && x.checked && note.textContent);
|
|
1800
|
+
});
|
|
1801
|
+
});
|
|
1094
1802
|
drawBulk();
|
|
1803
|
+
lockWrites();
|
|
1804
|
+
}
|
|
1805
|
+
|
|
1806
|
+
/** Two clicks: the first arms the button and says what the second does. */
|
|
1807
|
+
function arm(btn, text, rest){
|
|
1808
|
+
btn.dataset.armed = "1";
|
|
1809
|
+
btn.classList.add("armed");
|
|
1810
|
+
btn.textContent = text;
|
|
1811
|
+
clearTimeout(btn._t);
|
|
1812
|
+
btn._t = setTimeout(function(){ disarm(btn, rest); }, 5000);
|
|
1813
|
+
}
|
|
1814
|
+
function disarm(btn, rest){
|
|
1815
|
+
btn.dataset.armed = "";
|
|
1816
|
+
btn.classList.remove("armed");
|
|
1817
|
+
btn.textContent = rest;
|
|
1095
1818
|
}
|
|
1096
1819
|
|
|
1097
1820
|
/**
|
|
1098
|
-
* Decline every request on screen — two clicks,
|
|
1821
|
+
* Decline every request on screen — two clicks, with the reason in view.
|
|
1099
1822
|
*
|
|
1100
|
-
* There is no "
|
|
1823
|
+
* There is no "grant all" on purpose: a grant is read one at a time. The
|
|
1101
1824
|
* keys sent are the ones drawn now, so a request that arrives in between is
|
|
1102
|
-
* not
|
|
1825
|
+
* not declined unseen. A reason is required; an empty box is filled with a
|
|
1826
|
+
* default on the first click, so what is sent is on screen before it is sent.
|
|
1103
1827
|
*/
|
|
1104
1828
|
function drawBulk(){
|
|
1105
1829
|
var bulk = document.getElementById("bulk");
|
|
1106
1830
|
var btn = document.getElementById("decline-all");
|
|
1831
|
+
var why = document.getElementById("why-all");
|
|
1107
1832
|
bulk.hidden = REQS.length < 2;
|
|
1108
1833
|
btn.disabled = false;
|
|
1109
|
-
|
|
1110
|
-
btn
|
|
1834
|
+
var rest = "Decline all (" + REQS.length + ")";
|
|
1835
|
+
disarm(btn, rest);
|
|
1111
1836
|
btn.onclick = async function(){
|
|
1112
1837
|
if (!btn.dataset.armed) {
|
|
1113
|
-
|
|
1114
|
-
btn
|
|
1115
|
-
|
|
1838
|
+
if (!why.value.trim()) why.value = "declined in bulk from the console";
|
|
1839
|
+
arm(btn, "Click again to decline " + REQS.length + " with this reason", rest);
|
|
1840
|
+
return;
|
|
1841
|
+
}
|
|
1842
|
+
if (!LIVE) {
|
|
1843
|
+
DEMO.requests = [];
|
|
1844
|
+
decided("Declined " + plural(REQS.length, "request") + " in the example · reason «" + esc(why.value.trim()) + "». Nothing was saved.");
|
|
1845
|
+
note("declined all", why.value.trim());
|
|
1116
1846
|
return;
|
|
1117
1847
|
}
|
|
1118
1848
|
btn.disabled = true;
|
|
@@ -1120,82 +1850,134 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
|
|
|
1120
1850
|
var r = await fetch("/api/decline-all", {
|
|
1121
1851
|
method: "POST",
|
|
1122
1852
|
headers: { "content-type": "application/json", "x-seisin-token": TOKEN },
|
|
1123
|
-
body: JSON.stringify({ keys: REQS.map(function(q){ return q.key; }),
|
|
1124
|
-
reason: (document.getElementById("why-all") || {}).value || "declined in bulk from the console" })
|
|
1853
|
+
body: JSON.stringify({ keys: REQS.map(function(q){ return q.key; }), reason: why.value.trim() })
|
|
1125
1854
|
});
|
|
1126
1855
|
var out = await r.json();
|
|
1127
|
-
if (!r.ok) { var err = new Error(out.error || "
|
|
1856
|
+
if (!r.ok) { var err = new Error(out.error || "the console did not accept that"); err.status = r.status; throw err; }
|
|
1857
|
+
decided("Declined " + plural(out.declined, "request") + " · reason «" + esc(why.value.trim()) +
|
|
1858
|
+
"», recorded in the request queue. The policy was not changed.");
|
|
1859
|
+
why.value = "";
|
|
1128
1860
|
await reload();
|
|
1129
1861
|
} catch (e) {
|
|
1130
1862
|
btn.disabled = false;
|
|
1131
|
-
btn
|
|
1132
|
-
var
|
|
1133
|
-
|
|
1863
|
+
disarm(btn, rest);
|
|
1864
|
+
var n = document.getElementById("req-note");
|
|
1865
|
+
n.textContent = whyFailed(e, e.status); n.style.color = "var(--amber)";
|
|
1134
1866
|
}
|
|
1135
1867
|
};
|
|
1136
1868
|
}
|
|
1137
1869
|
|
|
1870
|
+
/** What the last decision did, until the next one. */
|
|
1871
|
+
function decided(html){
|
|
1872
|
+
var el = document.getElementById("decided");
|
|
1873
|
+
if (!html) { el.hidden = true; el.innerHTML = ""; return; }
|
|
1874
|
+
el.hidden = false;
|
|
1875
|
+
el.innerHTML = '<span class="txt">' + html + '</span><button type="button">dismiss</button>';
|
|
1876
|
+
el.querySelector("button").onclick = function(){ decided(null); };
|
|
1877
|
+
}
|
|
1878
|
+
|
|
1138
1879
|
/**
|
|
1139
1880
|
* One decision, sent once.
|
|
1140
1881
|
*
|
|
1141
|
-
* By the request's id, taken from the row as it was drawn
|
|
1142
|
-
*
|
|
1143
|
-
*
|
|
1882
|
+
* By the request's id, taken from the row as it was drawn. A reason is required
|
|
1883
|
+
* for either answer. Granting a path that is another role's takes a second
|
|
1884
|
+
* click that names what is shared; declining with an empty box fills in the
|
|
1885
|
+
* default and asks again, so what is recorded is on screen before it is.
|
|
1144
1886
|
*/
|
|
1145
1887
|
async function act(btn){
|
|
1146
1888
|
var i = Number(btn.dataset.n);
|
|
1889
|
+
var req = REQS[i];
|
|
1147
1890
|
var key = btn.dataset.k;
|
|
1148
|
-
var
|
|
1891
|
+
var granting = btn.dataset.d === "granted";
|
|
1892
|
+
var input = document.getElementById("why-" + i);
|
|
1893
|
+
var err = document.getElementById("err-" + i);
|
|
1149
1894
|
var row = btn.closest(".req");
|
|
1895
|
+
var rest = granting ? "Grant" : "Decline";
|
|
1896
|
+
function fail(msg){ err.hidden = false; err.textContent = msg; }
|
|
1897
|
+
err.hidden = true;
|
|
1898
|
+
input.removeAttribute("aria-invalid");
|
|
1899
|
+
|
|
1900
|
+
var filled = false;
|
|
1901
|
+
if (!input.value.trim()) {
|
|
1902
|
+
if (granting) {
|
|
1903
|
+
input.setAttribute("aria-invalid", "true");
|
|
1904
|
+
fail("Write a reason first — it is saved next to the grant in seisin.toml.");
|
|
1905
|
+
input.focus();
|
|
1906
|
+
return;
|
|
1907
|
+
}
|
|
1908
|
+
input.value = "declined from the console";
|
|
1909
|
+
filled = true;
|
|
1910
|
+
}
|
|
1911
|
+
var pick = chosen(req, i);
|
|
1912
|
+
var needsArm = granting ? (req.owners || []).length > 0 : filled;
|
|
1913
|
+
if (needsArm && !btn.dataset.armed) {
|
|
1914
|
+
arm(btn, granting ? "Click again to share " + pick.grant + " with " + req.role
|
|
1915
|
+
: "Click again to decline with this reason", rest);
|
|
1916
|
+
return;
|
|
1917
|
+
}
|
|
1918
|
+
disarm(btn, rest);
|
|
1919
|
+
var why = input.value.trim();
|
|
1920
|
+
|
|
1921
|
+
if (!LIVE) {
|
|
1922
|
+
if (granting) {
|
|
1923
|
+
var r0 = ROLES.filter(function(x){ return x.name === req.role; })[0];
|
|
1924
|
+
if (r0 && r0.writes.indexOf(pick.grant) === -1) r0.writes.push(pick.grant);
|
|
1925
|
+
}
|
|
1926
|
+
DEMO.requests = DEMO.requests.filter(function(q){ return q.role + ":" + q.action + ":" + q.target !== key; });
|
|
1927
|
+
decided(granting
|
|
1928
|
+
? "Granted <code>" + esc(pick.grant) + "</code> to <b>" + esc(req.role) + "</b> in the example. Live, this line is written to seisin.toml:" +
|
|
1929
|
+
'<span class="ln">"' + esc(pick.grant) + '" # granted … · «' + esc(why) + "»</span>Nothing was saved. " +
|
|
1930
|
+
'<a href="#configfile">see the example seisin.toml</a>'
|
|
1931
|
+
: "Declined <b>" + esc(req.role) + "</b>’s request for <code>" + esc(req.grant) + "</code> in the example · reason «" + esc(why) + "».");
|
|
1932
|
+
note(granting ? "granted" : "declined", req.role + " · " + (granting ? pick.grant : req.grant));
|
|
1933
|
+
return;
|
|
1934
|
+
}
|
|
1935
|
+
|
|
1150
1936
|
row.querySelectorAll("button,input").forEach(function(e){ e.disabled = true; });
|
|
1151
1937
|
try {
|
|
1152
1938
|
var r = await fetch("/api/decide", {
|
|
1153
1939
|
method: "POST",
|
|
1154
1940
|
headers: { "content-type": "application/json", "x-seisin-token": TOKEN },
|
|
1155
|
-
body: JSON.stringify({ key: key, decision: btn.dataset.d, reason: why })
|
|
1941
|
+
body: JSON.stringify({ key: key, decision: btn.dataset.d, reason: why, scope: granting ? pick.scope : "folder" })
|
|
1156
1942
|
});
|
|
1157
1943
|
var out = await r.json();
|
|
1158
|
-
if (!r.ok) { var
|
|
1944
|
+
if (!r.ok) { var e2 = new Error(out.error || "the console did not accept that"); e2.status = r.status; throw e2; }
|
|
1945
|
+
if (granting) {
|
|
1946
|
+
MARK = out.line ? out.line.number : null;
|
|
1947
|
+
decided("Granted <code>" + esc(out.grant) + "</code> to <b>" + esc(out.role) + "</b>" +
|
|
1948
|
+
(out.line
|
|
1949
|
+
? " · written to seisin.toml, line " + out.line.number + ':<span class="ln">' + esc(out.line.text) + "</span>"
|
|
1950
|
+
: " · seisin.toml already had it; nothing new was written. ") +
|
|
1951
|
+
'<a href="#configfile">see Config file' + (out.line ? ", line " + out.line.number : "") + "</a>");
|
|
1952
|
+
} else {
|
|
1953
|
+
decided("Declined <b>" + esc(out.role) + "</b>’s request for <code>" + esc(out.grant) + "</code> · reason «" + esc(why) +
|
|
1954
|
+
"», recorded in the request queue. The policy was not changed.");
|
|
1955
|
+
}
|
|
1159
1956
|
// Re-read rather than patch: the grant rewrote seisin.toml, so the
|
|
1160
1957
|
// territory on screen is stale too, not just the queue.
|
|
1161
1958
|
await reload();
|
|
1162
1959
|
} catch (e) {
|
|
1163
1960
|
row.querySelectorAll("button,input").forEach(function(el){ el.disabled = false; });
|
|
1164
|
-
|
|
1165
|
-
|
|
1961
|
+
fail(whyFailed(e, e.status));
|
|
1962
|
+
lockWrites();
|
|
1166
1963
|
}
|
|
1167
1964
|
}
|
|
1168
1965
|
|
|
1169
1966
|
/**
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
* Measured the hard way: `drawCauses` kept a reference to a box that had moved
|
|
1173
|
-
* to its own page, threw on the first line, and every number on the screen —
|
|
1174
|
-
* including the five that have nothing to do with it — rendered as zero. A
|
|
1175
|
-
* console whose KPIs read zero because of a typo is worse than one that is
|
|
1176
|
-
* down, because zero is a plausible answer.
|
|
1177
|
-
*/
|
|
1178
|
-
function draw(){
|
|
1179
|
-
[drawRows, drawCfg, drawToml, drawLog, drawReqs, drawCauses, drawFamilies, drawWalls]
|
|
1180
|
-
.forEach(function(f){ try { f(); } catch (e) { console.error("seisin: " + f.name + " failed", e); } });
|
|
1181
|
-
}
|
|
1182
|
-
|
|
1183
|
-
/**
|
|
1184
|
-
* The page works two ways on purpose.
|
|
1185
|
-
*
|
|
1186
|
-
* Served by `seisin ui`, it reads the real config and the real log. Opened as
|
|
1187
|
-
* a plain file — from the repo, from a docs site — the fetch fails and it
|
|
1188
|
-
* falls back to the example policy below, so the console is still something
|
|
1189
|
-
* you can look at before installing anything. What it must never do is show
|
|
1190
|
-
* example data while looking live, so the label says which one you are in.
|
|
1967
|
+
* One view failing must not take the others down: a console whose KPIs read zero because
|
|
1968
|
+
* of a typo is worse than one that is down, because zero is a plausible answer.
|
|
1191
1969
|
*/
|
|
1970
|
+
function draw(){
|
|
1971
|
+
[drawRows, drawCfg, drawControls, drawToml, drawLog, drawReqs, drawCauses, drawFamilies, drawWalls]
|
|
1972
|
+
.forEach(function(f){ try { f(); } catch (e) { console.error("seisin: " + f.name + " failed", e); } });
|
|
1973
|
+
}
|
|
1974
|
+
|
|
1192
1975
|
/**
|
|
1193
1976
|
* This run's token, from the link `seisin ui` opened.
|
|
1194
1977
|
*
|
|
1195
1978
|
* It arrives in the fragment (#t=…), which the browser never sends to any
|
|
1196
1979
|
* server, and it is moved into this tab's storage and wiped from the address
|
|
1197
|
-
* bar and the history entry at once.
|
|
1198
|
-
* page: anything that could GET "/" used to walk away with it.
|
|
1980
|
+
* bar and the history entry at once.
|
|
1199
1981
|
*/
|
|
1200
1982
|
var TOKEN = (function(){
|
|
1201
1983
|
var m = /(?:^#|&)t=([a-f0-9]+)/.exec(location.hash || "");
|
|
@@ -1207,31 +1989,62 @@ function draw(){
|
|
|
1207
1989
|
} catch (_) { return m ? m[1] : ""; }
|
|
1208
1990
|
})();
|
|
1209
1991
|
|
|
1210
|
-
/**
|
|
1211
|
-
* What to tell a person when a request to this server fails.
|
|
1212
|
-
*
|
|
1213
|
-
* "Failed to fetch" was all the browser said when the console had been
|
|
1214
|
-
* restarted under an open tab — the server was gone, or back with a new
|
|
1215
|
-
* token that this tab does not hold. Both have the same fix, and it is not
|
|
1216
|
-
* obvious from either message.
|
|
1217
|
-
*/
|
|
1992
|
+
/** What to tell a person when a request to this server fails. */
|
|
1218
1993
|
function whyFailed(e, status){
|
|
1219
|
-
if (status === 403)
|
|
1220
|
-
return "this tab's link
|
|
1994
|
+
if (status === 401 || status === 403)
|
|
1995
|
+
return "this tab's link has expired — the console was restarted. Open the new one: seisin ui --link";
|
|
1221
1996
|
if (e && /network|fetch/i.test(String(e.message || e)))
|
|
1222
|
-
return "the console did not answer. If `seisin ui
|
|
1997
|
+
return "the console did not answer. If you stopped `seisin ui`, start it again and open the link it prints.";
|
|
1223
1998
|
return (e && e.message) || "failed";
|
|
1224
1999
|
}
|
|
1225
2000
|
|
|
2001
|
+
/* ── offline ──────────────────────────────────────────────────────────────
|
|
2002
|
+
* The header said "live" in green for as long as the tab was open, whether or
|
|
2003
|
+
* not anything was answering, and Grant stayed clickable against a server
|
|
2004
|
+
* that was gone or had a new token. After two failed polls in a row the page
|
|
2005
|
+
* says which of the two it is, since when, and turns the writes off. */
|
|
2006
|
+
var FAILS = 0, OFFLINE = null;
|
|
2007
|
+
function setOffline(kind){
|
|
2008
|
+
if (kind && OFFLINE && OFFLINE.kind === kind) return;
|
|
2009
|
+
OFFLINE = kind ? { kind: kind, since: (OFFLINE && OFFLINE.since) || new Date() } : null;
|
|
2010
|
+
var label = document.getElementById("source");
|
|
2011
|
+
var ban = document.getElementById("off-banner");
|
|
2012
|
+
if (!OFFLINE) {
|
|
2013
|
+
label.textContent = LIVE_LABEL; label.style.color = "var(--green)";
|
|
2014
|
+
ban.hidden = true; ban.innerHTML = "";
|
|
2015
|
+
} else {
|
|
2016
|
+
var t = pad(OFFLINE.since.getHours()) + ":" + pad(OFFLINE.since.getMinutes());
|
|
2017
|
+
label.style.color = "var(--amber)";
|
|
2018
|
+
if (kind === "expired") {
|
|
2019
|
+
label.textContent = "link expired — open the new one";
|
|
2020
|
+
ban.innerHTML = "<b>This tab's link has expired</b> (since " + t + "): the console was restarted with a new one. " +
|
|
2021
|
+
"Open the new link — run <code>seisin ui --link</code> in the repository. Decisions and saves are off in this tab.";
|
|
2022
|
+
} else {
|
|
2023
|
+
label.textContent = "offline since " + t;
|
|
2024
|
+
ban.innerHTML = "<b>The console is not answering</b> (since " + t + "). What is on screen is as of then. " +
|
|
2025
|
+
"Decisions and saves are off until it answers again; if you stopped <code>seisin ui</code>, start it and open the link it prints.";
|
|
2026
|
+
}
|
|
2027
|
+
ban.hidden = false;
|
|
2028
|
+
}
|
|
2029
|
+
lockWrites();
|
|
2030
|
+
}
|
|
2031
|
+
/** Every control that writes is off while the server is not there to take it. */
|
|
2032
|
+
function lockWrites(){
|
|
2033
|
+
document.querySelectorAll("[data-write]").forEach(function(b){
|
|
2034
|
+
if (OFFLINE) { b.disabled = true; b.title = "the console is not answering"; }
|
|
2035
|
+
else if (b.title === "the console is not answering") { b.disabled = false; b.title = ""; }
|
|
2036
|
+
});
|
|
2037
|
+
}
|
|
2038
|
+
|
|
1226
2039
|
/* ── time window ──────────────────────────────────────────────────────── */
|
|
1227
2040
|
// "1h" … "30d", "all", or "from:<ISO>". Remembered per browser, as a convenience.
|
|
1228
2041
|
var RANGE = "all";
|
|
1229
2042
|
try { RANGE = localStorage.getItem("seisin.range") || "all"; } catch (_) {}
|
|
1230
2043
|
var SPANS = { "1h": 36e5, "4h": 4 * 36e5, "24h": 24 * 36e5, "7d": 7 * 864e5, "30d": 30 * 864e5 };
|
|
2044
|
+
if (!SPANS[RANGE] && RANGE !== "all" && RANGE.indexOf("from:") !== 0) RANGE = "all";
|
|
1231
2045
|
|
|
1232
|
-
// Worked out on every request, so "last 4h" keeps moving while the page is open.
|
|
1233
2046
|
function sinceIso(){
|
|
1234
|
-
if (SPANS[RANGE]) return new Date(Date.now() - SPANS[RANGE]).toISOString();
|
|
2047
|
+
if (SPANS[RANGE]) return new Date(Math.floor((Date.now() - SPANS[RANGE]) / 6e4) * 6e4).toISOString();
|
|
1235
2048
|
if (RANGE.indexOf("from:") === 0) return RANGE.slice(5);
|
|
1236
2049
|
return null;
|
|
1237
2050
|
}
|
|
@@ -1244,165 +2057,270 @@ function draw(){
|
|
|
1244
2057
|
if (RANGE.indexOf("from:") === 0) return "since " + new Date(RANGE.slice(5)).toLocaleString();
|
|
1245
2058
|
return "all time";
|
|
1246
2059
|
}
|
|
2060
|
+
var CUSTOM = false; // the date field is open
|
|
1247
2061
|
function drawRange(){
|
|
2062
|
+
var from = document.getElementById("range-from");
|
|
2063
|
+
var isFrom = RANGE.indexOf("from:") === 0;
|
|
1248
2064
|
document.querySelectorAll("#range .seg button").forEach(function(b){
|
|
1249
|
-
b.
|
|
2065
|
+
var on = b.dataset.r === "custom" ? (isFrom || CUSTOM) : (b.dataset.r === RANGE && !CUSTOM);
|
|
2066
|
+
b.classList.toggle("on", on);
|
|
2067
|
+
b.setAttribute("aria-pressed", on ? "true" : "false");
|
|
1250
2068
|
});
|
|
1251
|
-
|
|
1252
|
-
from.
|
|
1253
|
-
if (RANGE.indexOf("from:") !== 0) from.value = "";
|
|
2069
|
+
document.getElementById("range-from-box").hidden = !(isFrom || CUSTOM);
|
|
2070
|
+
if (!isFrom) from.value = "";
|
|
1254
2071
|
}
|
|
1255
2072
|
function setRange(r){
|
|
1256
2073
|
RANGE = r;
|
|
2074
|
+
CUSTOM = false;
|
|
1257
2075
|
try { localStorage.setItem("seisin.range", r); } catch (_) {}
|
|
1258
2076
|
drawRange();
|
|
1259
2077
|
reload().catch(function(){ /* server went away: keep what is on screen */ });
|
|
1260
2078
|
}
|
|
1261
2079
|
document.querySelectorAll("#range .seg button").forEach(function(b){
|
|
1262
|
-
b.addEventListener("click", function(){
|
|
2080
|
+
b.addEventListener("click", function(){
|
|
2081
|
+
if (b.dataset.r === "custom") { CUSTOM = true; drawRange(); document.getElementById("range-from").focus(); return; }
|
|
2082
|
+
setRange(b.dataset.r);
|
|
2083
|
+
});
|
|
1263
2084
|
});
|
|
1264
2085
|
document.getElementById("range-from").addEventListener("change", function(e){
|
|
1265
2086
|
var t = Date.parse(e.target.value); // datetime-local is local time, which Date.parse reads as such
|
|
1266
2087
|
setRange(isNaN(t) ? "all" : "from:" + new Date(t).toISOString());
|
|
1267
2088
|
});
|
|
1268
2089
|
|
|
2090
|
+
/**
|
|
2091
|
+
* One read of the state, for boot, the poll and the reload after a decision.
|
|
2092
|
+
* The poll sends back the tag of what it last got; 304 when nothing moved.
|
|
2093
|
+
*/
|
|
2094
|
+
var LAST = { url: null, etag: null };
|
|
2095
|
+
async function fetchState(fresh){
|
|
2096
|
+
var url = stateUrl();
|
|
2097
|
+
var h = { "x-seisin-token": TOKEN };
|
|
2098
|
+
if (!fresh && LAST.etag && LAST.url === url) h["if-none-match"] = LAST.etag;
|
|
2099
|
+
var r = await fetch(url, { cache: "no-store", headers: h });
|
|
2100
|
+
if (r.status === 304) return { same: true };
|
|
2101
|
+
if (!r.ok) return { status: r.status };
|
|
2102
|
+
var s = await r.json();
|
|
2103
|
+
if (s.error) return { status: r.status, error: s.error };
|
|
2104
|
+
LAST = { url: url, etag: r.headers.get("etag") };
|
|
2105
|
+
return { s: s };
|
|
2106
|
+
}
|
|
2107
|
+
|
|
2108
|
+
/**
|
|
2109
|
+
* What the server (or the example) said, applied — and only what changed is redrawn,
|
|
2110
|
+
* so a table someone is reading or a reason being typed is not thrown away every two
|
|
2111
|
+
* seconds.
|
|
2112
|
+
*/
|
|
2113
|
+
var SEEN = {};
|
|
2114
|
+
function changed(part, v){
|
|
2115
|
+
var j = JSON.stringify(v === undefined ? null : v);
|
|
2116
|
+
if (SEEN[part] === j) return false;
|
|
2117
|
+
SEEN[part] = j;
|
|
2118
|
+
return true;
|
|
2119
|
+
}
|
|
2120
|
+
function applyState(s){
|
|
2121
|
+
var shape = s.roles.map(function(x){ var y = Object.assign({}, x); delete y.blocks; return y; });
|
|
2122
|
+
var policy = changed("toml", s.toml);
|
|
2123
|
+
policy = changed("shape", shape) || policy;
|
|
2124
|
+
policy = changed("keyDirs", s.keyDirs) || policy;
|
|
2125
|
+
policy = changed("protect", s.protect) || policy;
|
|
2126
|
+
BASE = s.base || null;
|
|
2127
|
+
if (policy && PENDING && PENDING.plan && PENDING.plan.base !== BASE) {
|
|
2128
|
+
PENDING.plan = null;
|
|
2129
|
+
PENDING.error = "seisin.toml changed on disk since this preview. Toggle again to see the change against what is there now.";
|
|
2130
|
+
}
|
|
2131
|
+
if (policy) {
|
|
2132
|
+
KEYDIR = (s.keyDirs && s.keyDirs[0]) || ".secrets";
|
|
2133
|
+
KEYS = s.allKeys ? s.allKeys.slice() : [...new Set(s.roles.flatMap(r => r.keys))].map(k => k.replace(/\.[^.]+$/, ""));
|
|
2134
|
+
ROLES = s.roles.map(r => ({
|
|
2135
|
+
name: r.name,
|
|
2136
|
+
writes: r.writes.slice(),
|
|
2137
|
+
keys: r.keys.map(k => k.replace(/\.[^.]+$/, "")),
|
|
2138
|
+
sandbox: r.sandbox !== false,
|
|
2139
|
+
blocks: r.blocks,
|
|
2140
|
+
neverWrites: r.neverWrites || [],
|
|
2141
|
+
localBinding: !!r.localBinding,
|
|
2142
|
+
localPorts: (r.localPorts || []).slice(),
|
|
2143
|
+
trustd: !!r.trustd,
|
|
2144
|
+
controlFiles: (r.controlFiles || []).slice()
|
|
2145
|
+
}));
|
|
2146
|
+
PROTECT = s.protect || { instructions: false };
|
|
2147
|
+
ROOT = s.root || "";
|
|
2148
|
+
ONDISK = s.toml || null;
|
|
2149
|
+
SETTINGS = {};
|
|
2150
|
+
if (!ROLES.some(function(r){ return r.name === sel; })) sel = (ROLES[0] || {}).name || "";
|
|
2151
|
+
}
|
|
2152
|
+
var blocks = changed("blocks", s.roles.map(function(x){ return [x.name, x.blocks]; }));
|
|
2153
|
+
if (blocks && !policy) ROLES.forEach(function(r2){
|
|
2154
|
+
var fresh = s.roles.find(function(x){ return x.name === r2.name; });
|
|
2155
|
+
if (fresh) r2.blocks = fresh.blocks;
|
|
2156
|
+
});
|
|
2157
|
+
var log = changed("log", s.log);
|
|
2158
|
+
if (log) LOG = fromServer(s.log);
|
|
2159
|
+
var reqs = changed("requests", s.requests || []);
|
|
2160
|
+
if (reqs) REQS = s.requests || [];
|
|
2161
|
+
var causes = changed("causes", s.causes);
|
|
2162
|
+
if (causes) CAUSES = s.causes || null;
|
|
2163
|
+
var walls = changed("walls", s.walls);
|
|
2164
|
+
if (walls) WALLS = s.walls || {};
|
|
2165
|
+
|
|
2166
|
+
var views = [];
|
|
2167
|
+
if (policy) views.push(drawRows, drawCfg, drawControls, drawToml);
|
|
2168
|
+
else if (blocks) views.push(drawRows);
|
|
2169
|
+
if (log) views.push(drawLog);
|
|
2170
|
+
if (reqs) views.push(drawReqs);
|
|
2171
|
+
if (causes) views.push(drawCauses, drawFamilies);
|
|
2172
|
+
if (walls) views.push(drawWalls);
|
|
2173
|
+
views.push(drawKpis);
|
|
2174
|
+
views.forEach(function(f){ try { f(); } catch (e) { console.error("seisin: " + f.name + " failed", e); } });
|
|
2175
|
+
}
|
|
2176
|
+
|
|
2177
|
+
/** One role's settings, asked of the server once per policy. */
|
|
2178
|
+
var ASKING = {};
|
|
2179
|
+
async function loadSettings(name){
|
|
2180
|
+
if (ASKING[name]) return;
|
|
2181
|
+
ASKING[name] = true;
|
|
2182
|
+
try {
|
|
2183
|
+
var r = await fetch("/api/settings?role=" + encodeURIComponent(name),
|
|
2184
|
+
{ cache: "no-store", headers: { "x-seisin-token": TOKEN } });
|
|
2185
|
+
var out = await r.json();
|
|
2186
|
+
SETTINGS[name] = r.ok ? { settings: out.settings } : { error: out.error || "could not read the settings" };
|
|
2187
|
+
} catch (e) {
|
|
2188
|
+
SETTINGS[name] = { error: whyFailed(e) };
|
|
2189
|
+
} finally {
|
|
2190
|
+
delete ASKING[name];
|
|
2191
|
+
}
|
|
2192
|
+
if (name === sel) { try { drawCfg(); } catch (e) { console.error("seisin: drawCfg failed", e); } }
|
|
2193
|
+
}
|
|
2194
|
+
|
|
2195
|
+
var LIVE_LABEL = "";
|
|
1269
2196
|
async function boot(){
|
|
1270
|
-
let
|
|
2197
|
+
let got = null, stale = false;
|
|
1271
2198
|
try {
|
|
1272
|
-
|
|
1273
|
-
if (
|
|
2199
|
+
// Opened as a file there is no server to ask; the fetch only printed a CORS error.
|
|
2200
|
+
if (location.protocol === "file:") throw new Error("no server");
|
|
2201
|
+
got = await fetchState(true);
|
|
1274
2202
|
// A live server that refuses this tab's token: `seisin ui` was restarted
|
|
1275
2203
|
// and minted a new one. Falling back to the example here showed a policy
|
|
1276
2204
|
// that is not the reader's, one F5 after a restart, with nothing saying why.
|
|
1277
|
-
stale =
|
|
1278
|
-
} catch (_) { /* opened as a file: stay in
|
|
2205
|
+
stale = got.status === 403 || got.status === 401;
|
|
2206
|
+
} catch (_) { /* opened as a file: stay in the example */ }
|
|
2207
|
+
const s = got && got.s;
|
|
1279
2208
|
|
|
1280
2209
|
const label = document.getElementById("source");
|
|
1281
2210
|
if (stale) {
|
|
1282
|
-
label.textContent = "link
|
|
2211
|
+
label.textContent = "link expired — open the new one";
|
|
1283
2212
|
label.style.color = "var(--amber)";
|
|
1284
2213
|
document.querySelector("main").innerHTML =
|
|
1285
2214
|
'<div class="box" style="padding:22px 24px;max-width:640px">' +
|
|
1286
2215
|
'<h2 style="margin:0 0 8px;font-size:17px">This tab has the link from an earlier run</h2>' +
|
|
1287
|
-
'<p style="margin:0 0 8px"
|
|
1288
|
-
'Open the new
|
|
1289
|
-
'<code>http://127.0.0.1:' + location.port + '/#t=</code>).</p>' +
|
|
2216
|
+
'<p style="margin:0 0 8px"><code>seisin ui</code> was restarted, and every run gets a new link. ' +
|
|
2217
|
+
'Open the new one: run <code>seisin ui --link</code> in the repository, or use the link it printed in the terminal.</p>' +
|
|
1290
2218
|
'<p style="margin:0;color:var(--mid)">Nothing here is shown until then, so this page never ' +
|
|
1291
2219
|
'passes the example policy off as yours.</p></div>';
|
|
1292
2220
|
return;
|
|
1293
2221
|
}
|
|
1294
|
-
if (!s
|
|
2222
|
+
if (!s) {
|
|
1295
2223
|
label.textContent = location.protocol.startsWith("http") && !TOKEN
|
|
1296
|
-
? "no token —
|
|
1297
|
-
: "example
|
|
2224
|
+
? "no token — showing an example"
|
|
2225
|
+
: "example — not your repo";
|
|
1298
2226
|
label.style.color = "var(--amber)";
|
|
2227
|
+
document.getElementById("demo-banner").hidden = false;
|
|
2228
|
+
if (location.protocol.startsWith("http") && !TOKEN)
|
|
2229
|
+
document.getElementById("demo-banner").innerHTML += " This tab has no link token: open the link <code>seisin ui</code> printed, or run <code>seisin ui --link</code>.";
|
|
2230
|
+
applyState(demoState());
|
|
1299
2231
|
draw();
|
|
2232
|
+
show(route());
|
|
1300
2233
|
return;
|
|
1301
2234
|
}
|
|
1302
2235
|
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
writes: r.writes.slice(),
|
|
1308
|
-
keys: r.keys.map(k => k.replace(/\.[^.]+$/, "")),
|
|
1309
|
-
sandbox: true,
|
|
1310
|
-
blocks: r.blocks,
|
|
1311
|
-
// The three role keys that change the profile, drawn from what the
|
|
1312
|
-
// server says rather than typed into this page (review of 2026-09-22, #5).
|
|
1313
|
-
neverWrites: r.neverWrites || [],
|
|
1314
|
-
localBinding: !!r.localBinding,
|
|
1315
|
-
localPorts: (r.localPorts || []).slice(),
|
|
1316
|
-
trustd: !!r.trustd
|
|
1317
|
-
}));
|
|
1318
|
-
LOG = fromServer(s.log);
|
|
1319
|
-
REQS = s.requests || [];
|
|
1320
|
-
CAUSES = s.causes || null;
|
|
1321
|
-
WALLS = s.walls || {};
|
|
1322
|
-
ONDISK = s.toml || null;
|
|
1323
|
-
// The demo selection does not survive the live load. `sel` starts at "frontend", a role
|
|
1324
|
-
// from the example policy, and a real repo has no reason to have one. `drawCfg` then
|
|
1325
|
-
// reads `.name` off `undefined` and throws — which takes down everything `draw` renders
|
|
1326
|
-
// after it, so the generated-settings box, the activity list AND the seisin.toml box all
|
|
1327
|
-
// come up blank at once while the data behind them is fine. One stale default, three
|
|
1328
|
-
// empty panels, and no error anywhere the reader can see.
|
|
1329
|
-
if (!ROLES.some(function(r){ return r.name === sel; })) sel = (ROLES[0] || {}).name || "";
|
|
1330
|
-
label.textContent = s.config.replace(/^.*\//, "") + " · live";
|
|
2236
|
+
LIVE = true;
|
|
2237
|
+
applyState(s);
|
|
2238
|
+
LIVE_LABEL = s.config.replace(/^.*\//, "") + " · live";
|
|
2239
|
+
label.textContent = LIVE_LABEL;
|
|
1331
2240
|
label.style.color = "var(--green)";
|
|
1332
|
-
//
|
|
1333
|
-
//
|
|
1334
|
-
// otherwise in the one place a cautious reader looks was the worst of the
|
|
1335
|
-
// two possible lies.
|
|
1336
|
-
// Live, the button goes for good: this page edits a policy in memory, and "unsandbox
|
|
1337
|
-
// everything" against a real repo is not a preview. `dataset.live` is what `show()`
|
|
1338
|
-
// reads, so the router never brings it back when you return to Roles.
|
|
2241
|
+
// Live, the what-if button goes for good: "run every role unconfined" against a real repo is
|
|
2242
|
+
// not a preview. `dataset.live` is what `show()` reads.
|
|
1339
2243
|
document.getElementById("kill").dataset.live = "1";
|
|
1340
2244
|
document.getElementById("range").hidden = false;
|
|
1341
2245
|
drawRange();
|
|
1342
2246
|
document.getElementById("kill").hidden = true;
|
|
2247
|
+
// And so does the card that counts what the button would do: live it could only say 0.
|
|
2248
|
+
document.getElementById("k-open").style.display = "none";
|
|
1343
2249
|
document.getElementById("toml-note").textContent = "the file on disk, as it stands";
|
|
1344
2250
|
document.getElementById("foot-mode").textContent = "Live.";
|
|
1345
2251
|
document.getElementById("foot-text").innerHTML =
|
|
1346
|
-
"This page reads <b>" + esc(s.config) + "</b> and the log on disk.
|
|
1347
|
-
"
|
|
2252
|
+
"This page reads <b>" + esc(s.config) + "</b> and the log on disk. Two actions write, each in place and stamped " +
|
|
2253
|
+
"with its reason: a decision on a request, and saving a control-files change after its preview. Territories " +
|
|
2254
|
+
"and keys are changed in seisin.toml itself.";
|
|
1348
2255
|
draw();
|
|
1349
|
-
|
|
2256
|
+
show(route());
|
|
2257
|
+
poll();
|
|
2258
|
+
}
|
|
2259
|
+
|
|
2260
|
+
/** Every two seconds after the last answer, not every two seconds regardless. */
|
|
2261
|
+
function poll(){
|
|
2262
|
+
setTimeout(async function(){
|
|
2263
|
+
try { await refresh(); } finally { poll(); }
|
|
2264
|
+
}, 2000);
|
|
1350
2265
|
}
|
|
1351
2266
|
|
|
1352
|
-
/* ── the
|
|
2267
|
+
/* ── the views ────────────────────────────────────────────────────────────
|
|
1353
2268
|
* Hash routing and not a framework: this page is one file a server hands over whole, and
|
|
1354
|
-
* it has to keep working opened straight off the disk with no server at all.
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
}
|
|
2269
|
+
* it has to keep working opened straight off the disk with no server at all. */
|
|
2270
|
+
function views(){
|
|
2271
|
+
return {
|
|
2272
|
+
policy: {view:"roles", title:"Roles",
|
|
2273
|
+
sub:"Each role's territory — the folders it may write — and the keys it may read. " +
|
|
2274
|
+
(LIVE ? "Read-only here: the policy is seisin.toml." : "In this example you can edit them; nothing is saved.")},
|
|
2275
|
+
requests: {view:"requests", title:"Waiting on you",
|
|
2276
|
+
sub:"Requests: access an agent asked for and cannot have. Granting one adds a line to seisin.toml."},
|
|
2277
|
+
denied: {view:"denied", title:"Denied", tab:"path",
|
|
2278
|
+
sub:"Everything an agent tried in the window and the OS denied, grouped by what it was about."},
|
|
2279
|
+
causes: {view:"denied", title:"Denied", tab:"name",
|
|
2280
|
+
sub:"The same denials grouped by file name — the cut that says whether a day was a tooling problem or a territory question."},
|
|
2281
|
+
walls: {view:"walls", title:"Walls",
|
|
2282
|
+
sub:"A wall is something a role was denied twice or more and would still be denied today. Retrying one costs a call every time."},
|
|
2283
|
+
activity: {view:"activity", title:"Activity",
|
|
2284
|
+
sub:"Every log entry, newest first — hook and kernel — each denial with whose it was."},
|
|
2285
|
+
configfile: {view:"config", title:"Config file",
|
|
2286
|
+
sub: LIVE ? "The policy as it stands on disk. Decisions and control-files saves write here, stamped."
|
|
2287
|
+
: "The example policy, generated in this page. Live, this is your seisin.toml as it is on disk."}
|
|
2288
|
+
};
|
|
2289
|
+
}
|
|
1375
2290
|
|
|
1376
2291
|
function show(route){
|
|
1377
|
-
var
|
|
2292
|
+
var all = views();
|
|
2293
|
+
var v = all[route] || all.requests;
|
|
1378
2294
|
document.querySelectorAll("main section[data-view]").forEach(function(s){
|
|
1379
2295
|
s.hidden = s.getAttribute("data-view") !== v.view;
|
|
1380
2296
|
});
|
|
1381
2297
|
document.getElementById("v-title").textContent = v.title;
|
|
1382
2298
|
document.getElementById("v-sub").textContent = v.sub;
|
|
1383
|
-
|
|
1384
|
-
|
|
2299
|
+
if (v.view === "denied") {
|
|
2300
|
+
var byName = v.tab === "name";
|
|
2301
|
+
document.getElementById("dn-path").hidden = byName;
|
|
2302
|
+
document.getElementById("dn-name").hidden = !byName;
|
|
2303
|
+
document.getElementById("tab-path").setAttribute("aria-selected", byName ? "false" : "true");
|
|
2304
|
+
document.getElementById("tab-name").setAttribute("aria-selected", byName ? "true" : "false");
|
|
2305
|
+
}
|
|
2306
|
+
// Full cards on the landing view; a strip elsewhere.
|
|
2307
|
+
document.getElementById("kpis").classList.toggle("compact", v.view !== "requests");
|
|
1385
2308
|
var kill = document.getElementById("kill");
|
|
1386
2309
|
if (kill) kill.hidden = (v.view !== "roles") || kill.dataset.live === "1";
|
|
2310
|
+
var navRoute = route === "causes" ? "denied" : route;
|
|
1387
2311
|
document.querySelectorAll(".side .list a[href^='#']").forEach(function(a){
|
|
1388
|
-
var on = a.getAttribute("href").slice(1) ===
|
|
2312
|
+
var on = a.getAttribute("href").slice(1) === navRoute;
|
|
1389
2313
|
a.classList.toggle("on", on);
|
|
1390
2314
|
if (on) a.setAttribute("aria-current", "page"); else a.removeAttribute("aria-current");
|
|
1391
2315
|
});
|
|
1392
|
-
// Back to the top on a view change: landing halfway down a table you have never seen is
|
|
1393
|
-
// what the anchors used to do.
|
|
1394
2316
|
window.scrollTo(0, 0);
|
|
2317
|
+
// Straight to the line the last save wrote.
|
|
2318
|
+
var m = v.view === "config" && document.getElementById("toml-mark");
|
|
2319
|
+
if (m) m.scrollIntoView({ block: "center" });
|
|
1395
2320
|
}
|
|
1396
2321
|
|
|
1397
|
-
/**
|
|
1398
|
-
|
|
1399
|
-
*
|
|
1400
|
-
* It opened on Roles, which is reference: a wall of paths you consult when you
|
|
1401
|
-
* have a question, rendered before anybody asked one. What a person opens this
|
|
1402
|
-
* for is "does anything need me", and that has to be the first screen or it is
|
|
1403
|
-
* a screen nobody reaches. Reference is looked up; decisions are shown.
|
|
1404
|
-
*/
|
|
1405
|
-
function route(){ return (location.hash || "#requests").slice(1); }
|
|
2322
|
+
/** The landing view is what is waiting on you, not the policy table. */
|
|
2323
|
+
function route(){ return (location.hash || "#requests").slice(1); }
|
|
1406
2324
|
window.addEventListener("hashchange", function(){ show(route()); });
|
|
1407
2325
|
show(route());
|
|
1408
2326
|
|
|
@@ -1411,8 +2329,6 @@ function route(){ return (location.hash || "#requests").slice(1); }
|
|
|
1411
2329
|
var f = document.getElementById("f");
|
|
1412
2330
|
if (!f) return;
|
|
1413
2331
|
f.addEventListener("input", function(){ FILTER = f.value.trim(); drawRows(); });
|
|
1414
|
-
// Escape clears without reaching for the mouse: the field is meant to be typed into and
|
|
1415
|
-
// abandoned dozens of times while reading, not filled in once like a form.
|
|
1416
2332
|
f.addEventListener("keydown", function(e){
|
|
1417
2333
|
if (e.key === "Escape" && f.value) { e.preventDefault(); f.value = ""; FILTER = ""; drawRows(); }
|
|
1418
2334
|
});
|
|
@@ -1421,60 +2337,35 @@ function route(){ return (location.hash || "#requests").slice(1); }
|
|
|
1421
2337
|
/** The server's entries, in the shape the renderer already speaks. */
|
|
1422
2338
|
function fromServer(entries){
|
|
1423
2339
|
return (entries || []).map(e => ({
|
|
1424
|
-
|
|
1425
|
-
kind: e.verdict === "denied" ? "deny" : e.verdict === "observed" ? "admin" : "allow",
|
|
2340
|
+
at: e.at || "",
|
|
2341
|
+
kind: e.verdict === "denied" ? "deny" : e.verdict === "observed" ? "observed" : e.verdict === "admin" ? "admin" : "allow",
|
|
1426
2342
|
role: e.role,
|
|
1427
2343
|
act: e.action,
|
|
1428
2344
|
target: e.target,
|
|
1429
2345
|
owners: e.owners || [],
|
|
1430
|
-
|
|
1431
|
-
// kernel could speak, and absent is what the renderer treats as "the hook".
|
|
2346
|
+
about: e.kind || "file",
|
|
1432
2347
|
source: e.source
|
|
1433
2348
|
}));
|
|
1434
2349
|
}
|
|
1435
2350
|
|
|
1436
|
-
async function
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
if (
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
if (fresh !== JSON.stringify(REQS)) { REQS = JSON.parse(fresh); drawReqs(); drawKpis(); }
|
|
1448
|
-
// Only the counters that come from the log are refreshed. Whatever the
|
|
1449
|
-
// reader has been editing on screen stays put — a page that resets your
|
|
1450
|
-
// work every two seconds is worse than one that does not update.
|
|
1451
|
-
ROLES.forEach(r2 => {
|
|
1452
|
-
const fresh = s.roles.find(x => x.name === r2.name);
|
|
1453
|
-
if (fresh) r2.blocks = fresh.blocks;
|
|
1454
|
-
});
|
|
1455
|
-
drawLog(); drawRows();
|
|
1456
|
-
} catch (_) { /* server went away: keep what is on screen */ }
|
|
2351
|
+
async function refresh(){
|
|
2352
|
+
var got;
|
|
2353
|
+
try { got = await fetchState(false); } catch (_) { got = { down: true }; }
|
|
2354
|
+
if (got.s || got.same) {
|
|
2355
|
+
FAILS = 0;
|
|
2356
|
+
if (OFFLINE) setOffline(null);
|
|
2357
|
+
if (got.s) applyState(got.s);
|
|
2358
|
+
return;
|
|
2359
|
+
}
|
|
2360
|
+
FAILS++;
|
|
2361
|
+
if (FAILS >= 2) setOffline(got.status === 401 || got.status === 403 ? "expired" : "down");
|
|
1457
2362
|
}
|
|
1458
2363
|
|
|
1459
2364
|
/** Everything again, after a decision rewrote the policy on disk. */
|
|
1460
2365
|
async function reload(){
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
if (s.
|
|
1464
|
-
KEYDIR = (s.keyDirs && s.keyDirs[0]) || ".secrets";
|
|
1465
|
-
KEYS = [...new Set(s.roles.flatMap(x => x.keys))].map(k => k.replace(/\.[^.]+$/, ""));
|
|
1466
|
-
ROLES = s.roles.map(x => ({
|
|
1467
|
-
name: x.name, writes: x.writes.slice(),
|
|
1468
|
-
keys: x.keys.map(k => k.replace(/\.[^.]+$/, "")),
|
|
1469
|
-
sandbox: true, blocks: x.blocks
|
|
1470
|
-
}));
|
|
1471
|
-
LOG = fromServer(s.log);
|
|
1472
|
-
REQS = s.requests || [];
|
|
1473
|
-
CAUSES = s.causes || null;
|
|
1474
|
-
WALLS = s.walls || {};
|
|
1475
|
-
ONDISK = s.toml || null;
|
|
1476
|
-
draw();
|
|
1477
|
-
drawKpis();
|
|
2366
|
+
if (!LIVE) { demoRefresh(); return; }
|
|
2367
|
+
var got = await fetchState(true);
|
|
2368
|
+
if (got.s) applyState(got.s);
|
|
1478
2369
|
}
|
|
1479
2370
|
|
|
1480
2371
|
boot();
|