seisin 0.1.1 → 0.3.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 +287 -339
- package/package.json +7 -6
- package/src/cli.js +17 -3
- package/src/commands/explain.js +9 -2
- package/src/commands/hook.js +11 -0
- package/src/commands/init.js +1 -1
- package/src/commands/log.js +27 -1
- package/src/commands/requests.js +8 -3
- package/src/commands/review.js +33 -5
- package/src/commands/run.js +180 -151
- package/src/commands/ui.js +16 -2
- package/src/commands/walls.js +30 -0
- package/src/commands/whose.js +2 -1
- package/src/commands/wire.js +30 -5
- package/src/config.js +310 -5
- package/src/diagnose.js +172 -0
- package/src/env.js +54 -1
- package/src/grants.js +239 -0
- package/src/hook.js +45 -5
- package/src/index.js +12 -0
- package/src/inspect.js +332 -9
- package/src/intake.js +215 -0
- package/src/keys.js +585 -0
- package/src/log.js +96 -2
- package/src/mcp.js +87 -11
- package/src/nested.js +86 -0
- package/src/notify.js +146 -0
- package/src/owners.js +208 -6
- package/src/paths.js +50 -0
- package/src/render.js +63 -1
- package/src/requests.js +88 -0
- package/src/review.js +9 -4
- package/src/rundir.js +113 -0
- package/src/serve.js +263 -19
- package/src/spool.js +98 -20
- package/src/srt.js +148 -203
- package/src/surface.js +518 -0
- package/src/violations.js +37 -5
- package/src/walls.js +184 -0
- package/ui/favicon.svg +1 -1
- package/ui/index.html +585 -56
package/ui/index.html
CHANGED
|
@@ -4,12 +4,12 @@
|
|
|
4
4
|
<meta charset="utf-8">
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
6
|
<title>seisin console</title>
|
|
7
|
-
<!-- A
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
<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%
|
|
7
|
+
<!-- A boundary, which is what the word is about: seisin is the legal term for who
|
|
8
|
+
actually holds a piece of LAND. The S is a property line splitting one plot between
|
|
9
|
+
two holders, and the filled side is held. One stroke and one fill so it survives at
|
|
10
|
+
16px, where a favicon actually lives. Also in ui/favicon.svg for anyone who wants
|
|
11
|
+
the file; inline here so the console needs no second route to serve it. -->
|
|
12
|
+
<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
13
|
<meta name="description" content="Which folders each agent writes, which keys it may read, and whose a path is when the answer is no.">
|
|
14
14
|
<meta name="color-scheme" content="light dark">
|
|
15
15
|
<style>
|
|
@@ -60,6 +60,8 @@ body{margin:0;background:var(--bg);color:var(--fg);
|
|
|
60
60
|
.brand{padding:15px 14px 13px;border-bottom:1px solid var(--line);display:flex;flex-direction:column;gap:12px}
|
|
61
61
|
.brand b{font-size:15px;font-weight:600;letter-spacing:-.01em}
|
|
62
62
|
.brand small{font-size:11px;color:var(--mid);display:block;margin-top:1px}
|
|
63
|
+
.brand .id{display:flex;align-items:center;gap:10px}
|
|
64
|
+
.brand .mark{width:28px;height:28px;flex:none}
|
|
63
65
|
.theme{display:flex;gap:2px;background:var(--hover);border-radius:6px;padding:2px}
|
|
64
66
|
.theme button{flex:1;border:0;background:none;color:var(--mid);padding:4px 0;border-radius:4px;
|
|
65
67
|
cursor:pointer;font:inherit;font-size:12px}
|
|
@@ -86,7 +88,16 @@ button.btn:hover{background:var(--hover)}
|
|
|
86
88
|
button.btn.warn{border-color:var(--amber);color:var(--amber)}
|
|
87
89
|
button:focus-visible,a:focus-visible,select:focus-visible{outline:2px solid var(--blue);outline-offset:2px}
|
|
88
90
|
|
|
89
|
-
.cards{display:grid;gap:12px;grid-template-columns:repeat(auto-fit,minmax(
|
|
91
|
+
.cards{display:grid;gap:12px;grid-template-columns:repeat(auto-fit,minmax(150px,1fr))}
|
|
92
|
+
/* Time window: quick presets plus a free "from". Filters what comes from the log. */
|
|
93
|
+
.range{display:flex;align-items:center;gap:8px;flex-wrap:wrap;font-size:12px;color:var(--mid)}
|
|
94
|
+
.range .seg{display:flex;gap:2px;background:var(--hover);border-radius:6px;padding:2px}
|
|
95
|
+
.range .seg button{border:0;background:none;color:var(--mid);padding:4px 10px;border-radius:4px;
|
|
96
|
+
cursor:pointer;font:inherit;font-size:12px}
|
|
97
|
+
.range .seg button.on{background:var(--panel);color:var(--fg);box-shadow:0 1px 2px #0002}
|
|
98
|
+
.range input{font:inherit;font-size:12px;padding:4px 8px;border:1px solid var(--line2);
|
|
99
|
+
border-radius:6px;background:var(--bg);color:var(--fg)}
|
|
100
|
+
.range input.on{border-color:var(--fg)}
|
|
90
101
|
.card{background:var(--panel);border:1px solid var(--line);border-left:3px solid var(--line2);
|
|
91
102
|
border-radius:8px;padding:14px 16px;min-height:96px;display:flex;flex-direction:column}
|
|
92
103
|
.card.g{border-left-color:var(--green)} .card.a{border-left-color:var(--amber)} .card.b{border-left-color:var(--blue)}
|
|
@@ -94,12 +105,60 @@ button:focus-visible,a:focus-visible,select:focus-visible{outline:2px solid var(
|
|
|
94
105
|
.card .n{font-size:28px;font-weight:600;line-height:1;margin-top:12px;font-variant-numeric:tabular-nums;min-width:2.2ch}
|
|
95
106
|
.card .s{font-size:11px;color:var(--mid);margin-top:5px;min-height:16px}
|
|
96
107
|
.card.g .n{color:var(--green)} .card.a .n{color:var(--amber)} .card.b .n{color:var(--blue)}
|
|
108
|
+
.card.kpi{text-decoration:none;color:inherit;transition:border-color .12s,transform .12s}
|
|
109
|
+
.card.kpi:hover{border-color:var(--line2)}
|
|
110
|
+
.card.kpi:focus-visible{outline:2px solid var(--blue);outline-offset:2px}
|
|
111
|
+
/* Only when it is not zero. An indicator that shouts at rest is one people
|
|
112
|
+
learn to ignore, and these two are the ones that mean "do something". */
|
|
113
|
+
.card.kpi.live{border-left-color:var(--blue)} .card.kpi.live .n{color:var(--blue)}
|
|
114
|
+
.card.kpi.alarm{border-left-color:var(--amber)} .card.kpi.alarm .n{color:var(--amber)}
|
|
115
|
+
.card.kpi.calm .n{color:var(--dim)}
|
|
116
|
+
/* Left border follows the state too. A card that is amber at zero has cried
|
|
117
|
+
wolf before you read the number. */
|
|
118
|
+
.card.kpi{border-left-color:var(--line2)}
|
|
119
|
+
|
|
120
|
+
/* The shape of the day. A hundred identical rows is not a reading, so each cause gets a
|
|
121
|
+
bar proportional to the others and the eye does the arithmetic. */
|
|
122
|
+
.lede{font-size:14px;line-height:1.55;margin:0 0 16px;color:var(--fg)}
|
|
123
|
+
.lede b{font-weight:600}
|
|
124
|
+
.lede .q{color:var(--mid)}
|
|
125
|
+
.lede code{font-family:var(--mono);color:var(--fg);font-size:13px}
|
|
126
|
+
.pad{padding:16px}
|
|
127
|
+
.bars{display:flex;flex-direction:column;gap:10px}
|
|
128
|
+
.bar{display:grid;grid-template-columns:1fr 132px;gap:14px;align-items:baseline}
|
|
129
|
+
.bar .lbl{min-width:0;font-size:12px}
|
|
130
|
+
.bar .p{font-family:var(--mono);color:var(--fg);overflow-wrap:anywhere}
|
|
131
|
+
.bar .who{color:var(--mid);font-size:11px;margin-top:2px}
|
|
132
|
+
.bar .t{height:6px;border-radius:3px;background:var(--line);overflow:hidden;margin-top:6px}
|
|
133
|
+
.bar .f{height:100%;border-radius:3px;background:var(--blue);display:block}
|
|
134
|
+
.bar.cold .f{background:var(--line2)}
|
|
135
|
+
.bar .num{text-align:right;font-variant-numeric:tabular-nums;font-size:12px;color:var(--fg)}
|
|
136
|
+
.bar .num s{display:block;color:var(--mid);font-size:11px;text-decoration:none;margin-top:2px}
|
|
137
|
+
.bar.cold .p,.bar.cold .num{color:var(--mid)}
|
|
138
|
+
.fam{padding:14px 0;border-top:1px solid var(--line)}
|
|
139
|
+
.fam:first-child{border-top:0;padding-top:0}
|
|
140
|
+
.fam-h{display:flex;align-items:baseline;justify-content:space-between;gap:12px}
|
|
141
|
+
.fam-h code{font-family:var(--mono);font-size:13px;color:var(--fg);font-weight:600}
|
|
142
|
+
.fam-h .num{font-variant-numeric:tabular-nums;font-size:13px;color:var(--fg);white-space:nowrap}
|
|
143
|
+
.fam-h .num s{color:var(--mid);text-decoration:none;font-size:11px;margin-left:4px}
|
|
144
|
+
.fam .t{height:5px;border-radius:3px;background:var(--line);overflow:hidden;margin:8px 0 6px}
|
|
145
|
+
.fam .f{display:block;height:100%;border-radius:3px;background:var(--blue)}
|
|
146
|
+
.fam .who{font-size:11px;color:var(--mid)}
|
|
147
|
+
.places{margin:8px 0 0;padding:0;list-style:none;display:flex;flex-direction:column;gap:4px}
|
|
148
|
+
.places li{display:flex;gap:12px;justify-content:space-between;align-items:baseline;
|
|
149
|
+
font-family:var(--mono);font-size:11px;color:var(--mid)}
|
|
150
|
+
.places li span{overflow-wrap:anywhere}
|
|
151
|
+
.places li em{font-family:inherit;font-style:normal;color:var(--dim)}
|
|
152
|
+
.places li b{color:var(--fg);font-variant-numeric:tabular-nums;white-space:nowrap}
|
|
97
153
|
|
|
98
154
|
.box{background:var(--panel);border:1px solid var(--line);border-radius:8px;overflow:hidden}
|
|
99
155
|
.box>header{padding:10px 14px;border-bottom:1px solid var(--line);display:flex;align-items:baseline;
|
|
100
156
|
justify-content:space-between;gap:12px}
|
|
101
157
|
.box>header h2{font-size:13px;font-weight:600;margin:0}
|
|
102
158
|
.box>header .note{font-size:11px;color:var(--dim)}
|
|
159
|
+
.box>header .bulk{margin-left:auto;display:flex;gap:6px;align-items:center}
|
|
160
|
+
.box>header .bulk[hidden]{display:none}
|
|
161
|
+
.box>header .bulk input{font-size:12px;padding:4px 6px;min-width:180px}
|
|
103
162
|
|
|
104
163
|
.scrollx{overflow-x:auto}
|
|
105
164
|
table{width:100%;border-collapse:collapse;font-size:13px;min-width:720px}
|
|
@@ -154,7 +213,7 @@ pre .c{color:var(--dim)} pre .k{color:var(--blue)} pre .d{color:var(--amber)} pr
|
|
|
154
213
|
.ev .who em{font-style:normal;color:var(--blue);font-weight:600}
|
|
155
214
|
.ev .when{font-size:11px;color:var(--dim);flex:0 0 auto;font-variant-numeric:tabular-nums}
|
|
156
215
|
/* Which writer saw it. The hook reports an attempt and can be talked around; the kernel
|
|
157
|
-
reports a
|
|
216
|
+
reports a denial and cannot. When the two disagree about a run the reader needs to know
|
|
158
217
|
which line carries more weight, so the one that does is the one that is marked — a small
|
|
159
218
|
word in the margin, not a column. Lines without the field render exactly as before. */
|
|
160
219
|
.ev .src{font:10px/1.6 var(--mono);color:var(--dim);flex:0 0 auto;padding:0 5px;
|
|
@@ -201,6 +260,8 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
|
|
|
201
260
|
/* What a view says when it has nothing to show. Not a shrug: the two reasons a log is empty
|
|
202
261
|
need different answers, so it names them. */
|
|
203
262
|
.blank{margin:0;padding:34px 16px;color:var(--fg);font-size:13px;line-height:1.7;text-align:center}
|
|
263
|
+
.blank.quiet{padding:16px;font-size:12px;color:var(--mid)}
|
|
264
|
+
.blank.quiet span{display:inline}
|
|
204
265
|
.blank span{color:var(--mid);display:block;margin-top:6px;font-size:12px;
|
|
205
266
|
max-width:58ch;margin-inline:auto;text-wrap:pretty}
|
|
206
267
|
.blank code{font-family:var(--mono);font-size:11.5px;background:var(--hover);
|
|
@@ -208,8 +269,11 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
|
|
|
208
269
|
|
|
209
270
|
@media (max-width:980px){.two{grid-template-columns:1fr}}
|
|
210
271
|
@media (max-width:720px){
|
|
211
|
-
|
|
212
|
-
|
|
272
|
+
/* minmax(0,1fr), not 1fr: a bare 1fr never shrinks below its widest child's
|
|
273
|
+
min-content, and the nav's unwrapped links made the whole page 793px wide on a
|
|
274
|
+
375px phone. The tabs scroll inside .list instead. */
|
|
275
|
+
.frame{grid-template-columns:minmax(0,1fr)}
|
|
276
|
+
.side{border-right:0;border-bottom:1px solid var(--line);min-width:0}
|
|
213
277
|
.side .list{display:flex;overflow-x:auto;padding:6px 8px}
|
|
214
278
|
.side .cap{display:none}
|
|
215
279
|
.side a{border-left:0;border-bottom:2px solid transparent;white-space:nowrap}
|
|
@@ -221,6 +285,7 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
|
|
|
221
285
|
/* Requests. A queue nobody opens is not human-in-the-loop, so it looks like
|
|
222
286
|
something waiting rather than another table of facts. */
|
|
223
287
|
.req{display:flex;gap:14px;align-items:flex-start;padding:14px 16px;border-top:1px solid var(--line)}
|
|
288
|
+
.req.stale .who{opacity:.6}
|
|
224
289
|
.req:first-child{border-top:0}
|
|
225
290
|
.req .who{flex:1;min-width:0}
|
|
226
291
|
.req .who b{font-weight:600}
|
|
@@ -239,7 +304,7 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
|
|
|
239
304
|
|
|
240
305
|
<nav class="side">
|
|
241
306
|
<div class="brand">
|
|
242
|
-
<div><b>seisin</b><small id="source">folders and keys, per agent</small></div>
|
|
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>
|
|
243
308
|
<div class="theme" role="group" aria-label="Theme">
|
|
244
309
|
<button type="button" id="t-light">light</button>
|
|
245
310
|
<button type="button" id="t-dark">dark</button>
|
|
@@ -248,8 +313,10 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
|
|
|
248
313
|
</div>
|
|
249
314
|
<div class="list">
|
|
250
315
|
<div class="cap">Policy</div>
|
|
251
|
-
<a class="on" href="#
|
|
252
|
-
<a href="#
|
|
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>
|
|
253
320
|
<a href="#activity"><i>◷</i>Activity</a>
|
|
254
321
|
<a href="#configfile"><i>⚙</i>Config file</a>
|
|
255
322
|
<div class="cap">Docs</div>
|
|
@@ -270,15 +337,45 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
|
|
|
270
337
|
<button class="btn warn" id="kill" type="button">Unsandbox everything</button>
|
|
271
338
|
</div>
|
|
272
339
|
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
340
|
+
<!-- OUTSIDE the views, so the same five numbers are on every screen.
|
|
341
|
+
They used to live inside Roles, which meant the only live one —
|
|
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. -->
|
|
351
|
+
<div class="range" id="range" hidden>
|
|
352
|
+
<span>Window</span>
|
|
353
|
+
<div class="seg" role="group" aria-label="Time window">
|
|
354
|
+
<button type="button" data-r="1h">1h</button>
|
|
355
|
+
<button type="button" data-r="4h">4h</button>
|
|
356
|
+
<button type="button" data-r="24h">24h</button>
|
|
357
|
+
<button type="button" data-r="7d">7d</button>
|
|
358
|
+
<button type="button" data-r="30d">30d</button>
|
|
359
|
+
<button type="button" data-r="all">All</button>
|
|
360
|
+
</div>
|
|
361
|
+
<label>from <input type="datetime-local" id="range-from" aria-label="From"></label>
|
|
280
362
|
</div>
|
|
281
363
|
|
|
364
|
+
<div class="cards" id="kpis">
|
|
365
|
+
<a class="card kpi" href="#requests" id="k-wait">
|
|
366
|
+
<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
|
+
<a class="card kpi" href="#policy" id="k-open">
|
|
368
|
+
<h2>unsandboxed</h2><div class="n" id="c-off">0</div><div class="s" id="c-off-s">none are open</div></a>
|
|
369
|
+
<a class="card kpi" href="#denied" id="k-ref">
|
|
370
|
+
<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>one cause</h2><div class="n" id="c-conc">—</div><div class="s" id="c-conc-s">of all denials</div></a>
|
|
373
|
+
<a class="card kpi" href="#walls" id="k-retry">
|
|
374
|
+
<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
|
+
</div>
|
|
376
|
+
|
|
377
|
+
<section data-view="roles">
|
|
378
|
+
|
|
282
379
|
<div class="box">
|
|
283
380
|
<header id="policy">
|
|
284
381
|
<h2>Policy</h2>
|
|
@@ -312,16 +409,69 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
|
|
|
312
409
|
</div>
|
|
313
410
|
</section>
|
|
314
411
|
|
|
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
|
+
|
|
315
416
|
<section data-view="requests" hidden>
|
|
316
417
|
<div class="box" id="requests">
|
|
317
418
|
<header>
|
|
318
|
-
<h2>
|
|
319
|
-
<span class="note">
|
|
419
|
+
<h2>Pending decisions</h2>
|
|
420
|
+
<span class="note">granting edits seisin.toml · here or in the terminal, never through MCP</span>
|
|
421
|
+
<span class="bulk" id="bulk" hidden>
|
|
422
|
+
<input id="why-all" placeholder="why, for all of them" aria-label="reason for declining all">
|
|
423
|
+
<button class="btn warn" id="decline-all" type="button">Decline all</button>
|
|
424
|
+
</span>
|
|
320
425
|
</header>
|
|
321
426
|
<div id="reqs"></div>
|
|
322
427
|
</div>
|
|
323
428
|
</section>
|
|
324
429
|
|
|
430
|
+
<!-- KPI: denied. Grouped by WHAT was denied, never by who asked — per role
|
|
431
|
+
it is the same list a hundred times over. -->
|
|
432
|
+
<section data-view="denied" hidden>
|
|
433
|
+
<div class="box">
|
|
434
|
+
<header>
|
|
435
|
+
<h2>Denied, by path</h2>
|
|
436
|
+
<span class="note" id="dn-window"></span>
|
|
437
|
+
</header>
|
|
438
|
+
<div class="pad">
|
|
439
|
+
<p class="lede" id="ca-lede"></p>
|
|
440
|
+
<div class="bars" id="ca-bars"></div>
|
|
441
|
+
</div>
|
|
442
|
+
</div>
|
|
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">
|
|
455
|
+
<p class="lede" id="fa-lede"></p>
|
|
456
|
+
<div id="fa-list"></div>
|
|
457
|
+
</div>
|
|
458
|
+
</div>
|
|
459
|
+
</section>
|
|
460
|
+
|
|
461
|
+
<!-- KPI: spent retrying. -->
|
|
462
|
+
<section data-view="walls" hidden>
|
|
463
|
+
<div class="box">
|
|
464
|
+
<header>
|
|
465
|
+
<h2>Walls</h2>
|
|
466
|
+
<span class="note">denied twice or more, and the policy still denies it</span>
|
|
467
|
+
</header>
|
|
468
|
+
<div class="pad">
|
|
469
|
+
<p class="lede" id="wl-lede"></p>
|
|
470
|
+
<div id="wl-list"></div>
|
|
471
|
+
</div>
|
|
472
|
+
</div>
|
|
473
|
+
</section>
|
|
474
|
+
|
|
325
475
|
<section data-view="activity" hidden>
|
|
326
476
|
<div class="box">
|
|
327
477
|
<header><h2>Activity</h2><span class="note">every decision, with its owner</span></header>
|
|
@@ -341,7 +491,7 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
|
|
|
341
491
|
<div class="foot">
|
|
342
492
|
<b id="foot-mode">Console preview.</b> <span id="foot-text">Opened as a file, this page edits a policy in memory and shows what
|
|
343
493
|
would be generated. Copy the <b>seisin.toml</b> above to keep a change.</span><br>
|
|
344
|
-
The JSON matches <b>@anthropic-ai/sandbox-runtime 0.0.
|
|
494
|
+
The JSON matches <b>@anthropic-ai/sandbox-runtime 0.0.77</b>, whose schema the test suite exercises against the real binary.
|
|
345
495
|
</div>
|
|
346
496
|
</main>
|
|
347
497
|
</div>
|
|
@@ -526,12 +676,7 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
|
|
|
526
676
|
|
|
527
677
|
var on=0,off=0,nok=0,bl=0;
|
|
528
678
|
ROLES.forEach(function(r){r.sandbox?on++:off++; if(!r.keys.length)nok++; bl+=r.blocks;});
|
|
529
|
-
|
|
530
|
-
document.getElementById("c-on").textContent=on;
|
|
531
|
-
document.getElementById("c-off").textContent=off;
|
|
532
|
-
document.getElementById("c-off-s").textContent=off?"nothing enforces these":"none are open";
|
|
533
|
-
document.getElementById("c-nokeys").textContent=nok;
|
|
534
|
-
document.getElementById("c-blocks").textContent=bl;
|
|
679
|
+
drawKpis();
|
|
535
680
|
var pin=document.getElementById("pin");
|
|
536
681
|
if(off){pin.textContent=off;pin.hidden=false;}else{pin.hidden=true;}
|
|
537
682
|
var k=document.getElementById("kill");
|
|
@@ -558,14 +703,15 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
|
|
|
558
703
|
}
|
|
559
704
|
L.push("{");
|
|
560
705
|
L.push(' <span class="k">"network"</span>: {');
|
|
561
|
-
|
|
706
|
+
var lp=(r.localPorts||[]).map(function(n){return '"localhost:'+Number(n)+'"';});
|
|
707
|
+
L.push(' "allowedDomains": ["github.com"'+(lp.length?', <span class="g">'+lp.join(', ')+'</span>':'')+']'+(lp.length?', <span class="c">// local_ports, through the proxy</span>':','));
|
|
562
708
|
L.push(' "deniedDomains": [], "allowUnixSockets": [],');
|
|
563
|
-
L.push(' "allowLocalBinding": false');
|
|
709
|
+
L.push(' "allowLocalBinding": '+(r.localBinding?'<span class="d">true</span> <span class="c">// can reach every localhost port (macOS)</span>':'false'));
|
|
564
710
|
L.push(" },");
|
|
565
711
|
L.push(' <span class="k">"filesystem"</span>: {');
|
|
566
712
|
// KEYDIR comes from seisin.toml, which is a file the console may be
|
|
567
713
|
// pointed at without having written it. Unescaped it was HTML injection
|
|
568
|
-
// into a page that
|
|
714
|
+
// into a page that holds the run's token — and that token approves grants.
|
|
569
715
|
L.push(' <span class="d">"denyRead"</span>: ["'+esc(KEYDIR)+'"],');
|
|
570
716
|
if(r.keys.length){
|
|
571
717
|
L.push(' <span class="g">"allowRead"</span>: [');
|
|
@@ -580,8 +726,13 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
|
|
|
580
726
|
});
|
|
581
727
|
else L.push(' <span class="c">// no territory</span>');
|
|
582
728
|
L.push(" ],");
|
|
583
|
-
|
|
584
|
-
|
|
729
|
+
if((r.neverWrites||[]).length){
|
|
730
|
+
L.push(' <span class="d">"denyWrite"</span>: [ <span class="c">// never_writes</span>');
|
|
731
|
+
r.neverWrites.forEach(function(p,i){ L.push(' "'+esc(p.replace(/\/\*\*$/,""))+'"'+(i<r.neverWrites.length-1?",":"")); });
|
|
732
|
+
L.push(" ]");
|
|
733
|
+
} else L.push(' "denyWrite": []');
|
|
734
|
+
L.push(" }"+(r.trustd?",":""));
|
|
735
|
+
if(r.trustd) L.push(' <span class="d">"enableWeakerNetworkIsolation"</span>: true <span class="c">// trustd = true</span>');
|
|
585
736
|
L.push("}");
|
|
586
737
|
document.getElementById("cfg").innerHTML=L.join("\n");
|
|
587
738
|
}
|
|
@@ -650,7 +801,7 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
|
|
|
650
801
|
// Only the kernel's own lines get the mark. A hook line and an older line look the
|
|
651
802
|
// same as they always did — the absence is the default, not a third state.
|
|
652
803
|
var src=e.source==="kernel"
|
|
653
|
-
? '<span class="src" title="Recorded from the kernel: what the OS actually
|
|
804
|
+
? '<span class="src" title="Recorded from the kernel: what the OS actually denied, '+
|
|
654
805
|
'whether or not the hook saw the attempt. This line cannot be talked around.">kernel</span>'
|
|
655
806
|
: "";
|
|
656
807
|
d.innerHTML="<b>"+label+'</b><span class="body"><span class="what">'+line+"</span>"+who+
|
|
@@ -679,6 +830,8 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
|
|
|
679
830
|
|
|
680
831
|
var REQS = [];
|
|
681
832
|
var ONDISK = null; // the file on disk, when there is one
|
|
833
|
+
var CAUSES = null; // this window's denials, grouped by what was denied
|
|
834
|
+
var WALLS = {}; // per role, what it keeps being denied and still would be
|
|
682
835
|
|
|
683
836
|
/**
|
|
684
837
|
* The queue, as something a person acts on.
|
|
@@ -686,6 +839,201 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
|
|
|
686
839
|
* Hidden entirely when empty rather than shown as an empty table: the point
|
|
687
840
|
* of this panel is to be noticed on the days it has something in it.
|
|
688
841
|
*/
|
|
842
|
+
/**
|
|
843
|
+
* The shape of the window, grouped by cause.
|
|
844
|
+
*
|
|
845
|
+
* The reading comes first and the bars second, because the number that
|
|
846
|
+
* matters is almost never "how many" — it is "how much of it is one thing".
|
|
847
|
+
* A day where three quarters of the denials are a single lock file is a
|
|
848
|
+
* day where nobody disputed any territory, and that sentence is the whole
|
|
849
|
+
* value of grouping. It is written out rather than left for the reader to
|
|
850
|
+
* infer from twelve bars.
|
|
851
|
+
*/
|
|
852
|
+
function drawCauses(){
|
|
853
|
+
var f = CAUSES;
|
|
854
|
+
var lede = document.getElementById("ca-lede");
|
|
855
|
+
if (!f || !f.total) {
|
|
856
|
+
if (lede) lede.textContent = "Nothing was denied in this window.";
|
|
857
|
+
var bars = document.getElementById("ca-bars"); if (bars) bars.innerHTML = "";
|
|
858
|
+
return;
|
|
859
|
+
}
|
|
860
|
+
var top = f.causes[0];
|
|
861
|
+
var fam = (f.families || [])[0];
|
|
862
|
+
var famShare = fam ? Math.round(fam.share * 100) : 0;
|
|
863
|
+
var lede = "<b>" + f.total.toLocaleString() + "</b> denial" + (f.total === 1 ? "" : "s") +
|
|
864
|
+
" in this window, over <b>" + (f.distinct || f.causes.length).toLocaleString() +
|
|
865
|
+
"</b> distinct path" + ((f.distinct || f.causes.length) === 1 ? "" : "s") + ". ";
|
|
866
|
+
// The reading has to be taken at the level of the NAME, not the path, or it comes out
|
|
867
|
+
// backwards. Measured here: six of the top seven causes were the same lock file in six
|
|
868
|
+
// different repositories and no single path passed 17% \u2014 so "no cause dominates" was
|
|
869
|
+
// what the arithmetic said and the opposite of the truth. One KIND of thing at that scale
|
|
870
|
+
// is a tooling problem with a mechanical fix; the same volume spread across unrelated
|
|
871
|
+
// paths is a territory question and needs a person. They take opposite responses, which
|
|
872
|
+
// is why this sentence is written out instead of left to be inferred from twelve bars.
|
|
873
|
+
if (fam && famShare >= 40 && fam.paths > 1)
|
|
874
|
+
lede += "<span class=\"q\"><b>" + fam.times.toLocaleString() + "</b> of them \u2014 <b>" +
|
|
875
|
+
famShare + "%</b> \u2014 are the same name in " + fam.paths + " places: " +
|
|
876
|
+
"<code>" + esc(fam.name) + "</code>. One kind of thing at that scale is a " +
|
|
877
|
+
"tooling problem with a mechanical fix, not something to grant.</span>";
|
|
878
|
+
else if (fam && famShare >= 40)
|
|
879
|
+
lede += "<span class=\"q\"><b>" + famShare + "%</b> of it is a single path. That is " +
|
|
880
|
+
"usually mechanical rather than something to grant.</span>";
|
|
881
|
+
else
|
|
882
|
+
lede += "<span class=\"q\">No one kind of thing dominates, which is what a real boundary " +
|
|
883
|
+
"question looks like rather than a tooling one.</span>";
|
|
884
|
+
/* The half a grep cannot do, because it needs the policy: how many of these
|
|
885
|
+
are on paths nobody claims. Those are the ones no grant resolves until a
|
|
886
|
+
person decides who owns them — a decision, not a count. Added after a
|
|
887
|
+
field review pointed out that the percentage above was the console
|
|
888
|
+
reading the log back to somebody who had already read it. */
|
|
889
|
+
if (f.unowned)
|
|
890
|
+
lede += " <b>" + f.unowned + "</b> of the " + (f.distinct || f.causes.length).toLocaleString() +
|
|
891
|
+
" are on paths <b>no role owns</b> \u2014 a grant cannot settle those until " +
|
|
892
|
+
"somebody claims them.";
|
|
893
|
+
document.getElementById("ca-lede").innerHTML = lede;
|
|
894
|
+
var win = document.getElementById("dn-window");
|
|
895
|
+
if (win) win.textContent = rangeLabel() + " \u00b7 " + f.total.toLocaleString() + " denials \u00b7 " +
|
|
896
|
+
(f.distinct || f.causes.length).toLocaleString() + " distinct paths";
|
|
897
|
+
|
|
898
|
+
var max = top.times || 1;
|
|
899
|
+
document.getElementById("ca-bars").innerHTML = f.causes.map(function(c){
|
|
900
|
+
var w = Math.max(2, Math.round((c.times / max) * 100));
|
|
901
|
+
// A cause the policy no longer denies is history, not live. It is
|
|
902
|
+
// shown, greyed, rather than dropped: it explains a number somebody may
|
|
903
|
+
// remember from yesterday instead of silently changing it.
|
|
904
|
+
var cold = c.stillDeclined === 0;
|
|
905
|
+
var who = c.roles.length === 1 ? c.roles[0] : c.roles.length + " roles";
|
|
906
|
+
return '<div class="bar' + (cold ? ' cold' : '') + '">' +
|
|
907
|
+
'<div class="lbl"><div class="p">' + esc(c.action) + ' ' + esc(c.target) + '</div>' +
|
|
908
|
+
'<div class="who">' + esc(who) +
|
|
909
|
+
(c.owners && c.owners.length ? ' · owned by ' + esc(c.owners.join(", ")) : ' · owned by nobody') +
|
|
910
|
+
(cold ? ' · <b>granted since</b>' : '') + '</div>' +
|
|
911
|
+
'<div class="t"><span class="f" style="width:' + w + '%"></span></div></div>' +
|
|
912
|
+
'<div class="num">' + c.times.toLocaleString() +
|
|
913
|
+
'<s>' + (c.share * 100).toFixed(1) + '%</s></div>' +
|
|
914
|
+
'</div>';
|
|
915
|
+
}).join("");
|
|
916
|
+
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
/** Denied, by name — the cut that says what kind of day it was. */
|
|
920
|
+
function drawFamilies(){
|
|
921
|
+
var f = CAUSES; if (!f) return;
|
|
922
|
+
var fams = f.families || [];
|
|
923
|
+
var top = fams[0];
|
|
924
|
+
document.getElementById("fa-lede").innerHTML = !top ? "Nothing was denied in this window."
|
|
925
|
+
: "<b>" + Math.round(top.share * 100) + "%</b> of " + f.total.toLocaleString() +
|
|
926
|
+
" denials is <code>" + esc(top.name) + "</code>, across <b>" + top.paths + "</b> path" +
|
|
927
|
+
(top.paths === 1 ? "" : "s") + "." +
|
|
928
|
+
(top.share >= 0.4 && top.paths > 1
|
|
929
|
+
? ' <span class="q">One name at that scale is usually mechanical. Fixing the tool ' +
|
|
930
|
+
'removes the denials; granting the paths only hides them.</span>'
|
|
931
|
+
: ' <span class="q">No single name dominates \u2014 these are separate questions, ' +
|
|
932
|
+
'not one problem.</span>');
|
|
933
|
+
|
|
934
|
+
document.getElementById("fa-list").innerHTML = fams.map(function(x){
|
|
935
|
+
var w = Math.max(2, Math.round((x.times / (top.times || 1)) * 100));
|
|
936
|
+
return '<div class="fam">' +
|
|
937
|
+
'<div class="fam-h"><code>' + esc(x.name) + '</code>' +
|
|
938
|
+
'<span class="num">' + x.times.toLocaleString() + ' <s>' + (x.share*100).toFixed(1) + '%</s></span></div>' +
|
|
939
|
+
'<div class="t"><span class="f" style="width:' + w + '%"></span></div>' +
|
|
940
|
+
'<div class="who">' + x.paths + ' path' + (x.paths===1?'':'s') + ' \u00b7 ' +
|
|
941
|
+
x.roles.length + ' role' + (x.roles.length===1?'':'s') + ': ' + esc(x.roles.join(", ")) + '</div>' +
|
|
942
|
+
'<ul class="places">' + x.where.map(function(p){
|
|
943
|
+
return '<li><span>' + esc(p.target) + '</span><b>' + p.times.toLocaleString() + '</b></li>';
|
|
944
|
+
}).join("") + '</ul></div>';
|
|
945
|
+
}).join("");
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
/** Walls — per role, what it keeps hitting and what that cost. */
|
|
949
|
+
function drawWalls(){
|
|
950
|
+
var rows = Object.keys(WALLS || {}).map(function(r){
|
|
951
|
+
var w = WALLS[r];
|
|
952
|
+
return { role: r, walls: w, spent: w.reduce(function(n,x){ return n + x.times - 1; }, 0) };
|
|
953
|
+
}).sort(function(a,b){ return b.spent - a.spent; });
|
|
954
|
+
var total = rows.reduce(function(n,x){ return n + x.spent; }, 0);
|
|
955
|
+
|
|
956
|
+
document.getElementById("wl-lede").innerHTML = !rows.length
|
|
957
|
+
? "No role is repeating a denial it has already been given."
|
|
958
|
+
: "<b>" + total.toLocaleString() + "</b> call" + (total===1?"":"s") + " across <b>" +
|
|
959
|
+
rows.length + "</b> role" + (rows.length===1?"":"s") +
|
|
960
|
+
' went into retrying something already denied. <span class="q">A denial is said once, ' +
|
|
961
|
+
'mid-turn. Nothing carries it forward, so it gets tried again.</span>';
|
|
962
|
+
|
|
963
|
+
document.getElementById("wl-list").innerHTML = rows.map(function(x){
|
|
964
|
+
return '<div class="fam">' +
|
|
965
|
+
'<div class="fam-h"><code>' + esc(x.role) + '</code>' +
|
|
966
|
+
'<span class="num">' + x.spent.toLocaleString() + ' <s>calls retried</s></span></div>' +
|
|
967
|
+
'<ul class="places">' + x.walls.map(function(w){
|
|
968
|
+
return '<li><span>' + esc(w.action) + ' ' + esc(w.target) + '<br>' +
|
|
969
|
+
'<em>' + esc(w.reason) + '</em></span><b>' + w.times + '\u00d7</b></li>';
|
|
970
|
+
}).join("") + '</ul></div>';
|
|
971
|
+
}).join("");
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
/** A count beside a nav item, or nothing. Zero is not news. */
|
|
975
|
+
function pill(id, n){
|
|
976
|
+
var el = document.getElementById(id);
|
|
977
|
+
if (!el) return;
|
|
978
|
+
el.textContent = n ? (n > 999 ? Math.round(n/1000) + "k" : String(n)) : "";
|
|
979
|
+
el.hidden = !n;
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
/**
|
|
983
|
+
* The five numbers, on every screen.
|
|
984
|
+
*
|
|
985
|
+
* Chosen by one rule: a KPI has to be something that can change and that you
|
|
986
|
+
* would do something about. The row this replaces failed both — four of its
|
|
987
|
+
* five read 31, 31, 0 and 31 every single day — and the one that moved was
|
|
988
|
+
* last and the same size as the rest.
|
|
989
|
+
*
|
|
990
|
+
* Emphasis is conditional, never constant. At zero they go quiet and say so
|
|
991
|
+
* in words, because "nothing to decide" is a state and an indicator that
|
|
992
|
+
* shouts at rest is one people stop seeing.
|
|
993
|
+
*
|
|
994
|
+
* Every value is read from the globals rather than passed in, so any refresh
|
|
995
|
+
* can call it — the queue refreshes on its own, and passing the role counts
|
|
996
|
+
* in once left the row stale on the number that moves most.
|
|
997
|
+
*/
|
|
998
|
+
function drawKpis(){
|
|
999
|
+
var f = CAUSES || { total: 0, families: [], distinct: 0, causes: [] };
|
|
1000
|
+
var waiting = REQS.length;
|
|
1001
|
+
var retry = Object.keys(WALLS || {}).reduce(function(n, r){
|
|
1002
|
+
return n + WALLS[r].reduce(function(m, w){ return m + w.times - 1; }, 0);
|
|
1003
|
+
}, 0);
|
|
1004
|
+
var fam = f.families && f.families[0];
|
|
1005
|
+
var off = (ROLES || []).filter(function(r){ return !r.sandbox; }).length;
|
|
1006
|
+
|
|
1007
|
+
function set(id, cls, value, sub){
|
|
1008
|
+
var el = document.getElementById(id);
|
|
1009
|
+
if (!el) return;
|
|
1010
|
+
el.classList.remove("live","alarm","calm");
|
|
1011
|
+
if (cls) el.classList.add(cls);
|
|
1012
|
+
el.querySelector(".n").textContent = value;
|
|
1013
|
+
el.querySelector(".s").textContent = sub;
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
set("k-wait", waiting ? "live" : "calm", waiting,
|
|
1017
|
+
waiting ? (waiting === 1 ? "decision" : "decisions") : "nothing to decide");
|
|
1018
|
+
// The only number here that is an emergency, and it is almost always zero.
|
|
1019
|
+
set("k-open", off ? "alarm" : "calm", off,
|
|
1020
|
+
off ? (off === 1 ? "role nothing is enforcing" : "roles nothing is enforcing") : "every role confined");
|
|
1021
|
+
set("k-ref", f.total ? "live" : "calm", f.total.toLocaleString(),
|
|
1022
|
+
(f.total ? (f.distinct || f.causes.length).toLocaleString() + " distinct paths" : "nothing was denied") +
|
|
1023
|
+
(RANGE === "all" ? "" : " \u00b7 " + rangeLabel()));
|
|
1024
|
+
// The shape, not the size: one name at 74% is a tooling problem, the same
|
|
1025
|
+
// volume spread thin is a territory question, so the caption carries the name.
|
|
1026
|
+
set("k-conc", fam && fam.share >= 0.4 ? "live" : "calm",
|
|
1027
|
+
fam ? Math.round(fam.share * 100) + "%" : "\u2014",
|
|
1028
|
+
fam ? "is " + fam.name : "of all denials");
|
|
1029
|
+
// The only one that is money rather than state.
|
|
1030
|
+
set("k-retry", retry ? "live" : "calm", retry.toLocaleString(),
|
|
1031
|
+
retry ? "calls on walls already denied" : "nothing retried");
|
|
1032
|
+
|
|
1033
|
+
pill("dn-pin", f.total);
|
|
1034
|
+
pill("wl-pin", Object.keys(WALLS || {}).length);
|
|
1035
|
+
}
|
|
1036
|
+
|
|
689
1037
|
function drawReqs(){
|
|
690
1038
|
var box = document.getElementById("requests");
|
|
691
1039
|
var pin = document.getElementById("req-pin");
|
|
@@ -696,26 +1044,30 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
|
|
|
696
1044
|
// a page with a heading and nothing under it. It stays, and says what would fill it.
|
|
697
1045
|
box.hidden = false;
|
|
698
1046
|
if (!REQS.length) {
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
1047
|
+
// Compact when this is the landing view. The tall empty state was right
|
|
1048
|
+
// when Requests was a page of its own — a heading with nothing under it
|
|
1049
|
+
// reads as broken. At the top of the front page it is 180px of nothing
|
|
1050
|
+
// standing between you and the only content there is.
|
|
1051
|
+
host.innerHTML = '<p class="blank quiet">No pending decisions. ' +
|
|
1052
|
+
'<span>Agents land here when they ask for a path the policy denies them.</span></p>';
|
|
703
1053
|
return;
|
|
704
1054
|
}
|
|
705
1055
|
|
|
706
1056
|
host.innerHTML = REQS.map(function(r, i){
|
|
707
1057
|
var owners = (r.owners && r.owners.length) ? " — belongs to <b>" + esc(r.owners.join(", ")) + "</b>" : "";
|
|
708
1058
|
var times = r.times > 1 ? ' · <span class="asked">asked ' + r.times + "×</span>" : "";
|
|
709
|
-
|
|
1059
|
+
// Marked, not moved: Approve and Decline post this row's number.
|
|
1060
|
+
var stale = r.stale ? ' · not asked again in ' + Number(r.stale.runs) + ' runs since — likely no longer needed' : "";
|
|
1061
|
+
return '<div class="req' + (r.stale ? ' stale' : '') + '">' +
|
|
710
1062
|
'<div class="who">' +
|
|
711
1063
|
"<div><b>" + esc(r.role) + "</b> wants to " + esc(r.action) +
|
|
712
1064
|
' <span class="path">' + esc(r.grant) + "</span>" + owners + "</div>" +
|
|
713
|
-
'<div class="sub">first asked over ' + esc(r.target) + times + "</div>" +
|
|
1065
|
+
'<div class="sub">first asked over ' + esc(r.target) + times + stale + "</div>" +
|
|
714
1066
|
"</div>" +
|
|
715
1067
|
'<div class="act">' +
|
|
716
1068
|
'<input id="why-' + i + '" placeholder="why — this outlives you" aria-label="reason">' +
|
|
717
1069
|
'<button class="btn" data-n="' + (i+1) + '" data-d="granted" type="button">Approve</button>' +
|
|
718
|
-
'<button class="btn warn" data-n="' + (i+1) + '" data-d="denied" type="button">
|
|
1070
|
+
'<button class="btn warn" data-n="' + (i+1) + '" data-d="denied" type="button">Decline</button>' +
|
|
719
1071
|
"</div>" +
|
|
720
1072
|
"</div>";
|
|
721
1073
|
}).join("");
|
|
@@ -723,6 +1075,48 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
|
|
|
723
1075
|
host.querySelectorAll("button[data-n]").forEach(function(b){
|
|
724
1076
|
b.addEventListener("click", function(){ act(b); });
|
|
725
1077
|
});
|
|
1078
|
+
drawBulk();
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
/**
|
|
1082
|
+
* Decline every request on screen — two clicks, no dialog.
|
|
1083
|
+
*
|
|
1084
|
+
* There is no "approve all" on purpose: a grant is read one at a time. The
|
|
1085
|
+
* keys sent are the ones drawn now, so a request that arrives in between is
|
|
1086
|
+
* not refused unseen.
|
|
1087
|
+
*/
|
|
1088
|
+
function drawBulk(){
|
|
1089
|
+
var bulk = document.getElementById("bulk");
|
|
1090
|
+
var btn = document.getElementById("decline-all");
|
|
1091
|
+
bulk.hidden = REQS.length < 2;
|
|
1092
|
+
btn.disabled = false;
|
|
1093
|
+
btn.dataset.armed = "";
|
|
1094
|
+
btn.textContent = "Decline all (" + REQS.length + ")";
|
|
1095
|
+
btn.onclick = async function(){
|
|
1096
|
+
if (!btn.dataset.armed) {
|
|
1097
|
+
btn.dataset.armed = "1";
|
|
1098
|
+
btn.textContent = "Click again to decline " + REQS.length;
|
|
1099
|
+
setTimeout(function(){ if (btn.dataset.armed) { btn.dataset.armed = ""; btn.textContent = "Decline all (" + REQS.length + ")"; } }, 4000);
|
|
1100
|
+
return;
|
|
1101
|
+
}
|
|
1102
|
+
btn.disabled = true;
|
|
1103
|
+
try {
|
|
1104
|
+
var r = await fetch("/api/decline-all", {
|
|
1105
|
+
method: "POST",
|
|
1106
|
+
headers: { "content-type": "application/json", "x-seisin-token": TOKEN },
|
|
1107
|
+
body: JSON.stringify({ keys: REQS.map(function(q){ return q.key; }),
|
|
1108
|
+
reason: (document.getElementById("why-all") || {}).value || "declined in bulk from the console" })
|
|
1109
|
+
});
|
|
1110
|
+
var out = await r.json();
|
|
1111
|
+
if (!r.ok) { var err = new Error(out.error || "refused"); err.status = r.status; throw err; }
|
|
1112
|
+
await reload();
|
|
1113
|
+
} catch (e) {
|
|
1114
|
+
btn.disabled = false;
|
|
1115
|
+
btn.textContent = "Decline all (" + REQS.length + ")";
|
|
1116
|
+
var note = document.querySelector("#requests .note");
|
|
1117
|
+
if (note) { note.textContent = whyFailed(e, e.status); note.style.color = "var(--amber)"; }
|
|
1118
|
+
}
|
|
1119
|
+
};
|
|
726
1120
|
}
|
|
727
1121
|
|
|
728
1122
|
/**
|
|
@@ -739,22 +1133,34 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
|
|
|
739
1133
|
try {
|
|
740
1134
|
var r = await fetch("/api/decide", {
|
|
741
1135
|
method: "POST",
|
|
742
|
-
headers: { "content-type": "application/json", "x-seisin-token":
|
|
1136
|
+
headers: { "content-type": "application/json", "x-seisin-token": TOKEN },
|
|
743
1137
|
body: JSON.stringify({ number: n, decision: btn.dataset.d, reason: why })
|
|
744
1138
|
});
|
|
745
1139
|
var out = await r.json();
|
|
746
|
-
if (!r.ok)
|
|
1140
|
+
if (!r.ok) { var err = new Error(out.error || "denied"); err.status = r.status; throw err; }
|
|
747
1141
|
// Re-read rather than patch: the grant rewrote seisin.toml, so the
|
|
748
1142
|
// territory on screen is stale too, not just the queue.
|
|
749
1143
|
await reload();
|
|
750
1144
|
} catch (e) {
|
|
751
1145
|
row.querySelectorAll("button,input").forEach(function(el){ el.disabled = false; });
|
|
752
1146
|
var s = row.querySelector(".sub");
|
|
753
|
-
s.innerHTML = '<span style="color:var(--amber)">' + esc(e.
|
|
1147
|
+
s.innerHTML = '<span style="color:var(--amber)">' + esc(whyFailed(e, e.status)) + "</span>";
|
|
754
1148
|
}
|
|
755
1149
|
}
|
|
756
1150
|
|
|
757
|
-
|
|
1151
|
+
/**
|
|
1152
|
+
* One view failing must not take the others down.
|
|
1153
|
+
*
|
|
1154
|
+
* Measured the hard way: `drawCauses` kept a reference to a box that had moved
|
|
1155
|
+
* to its own page, threw on the first line, and every number on the screen —
|
|
1156
|
+
* including the five that have nothing to do with it — rendered as zero. A
|
|
1157
|
+
* console whose KPIs read zero because of a typo is worse than one that is
|
|
1158
|
+
* down, because zero is a plausible answer.
|
|
1159
|
+
*/
|
|
1160
|
+
function draw(){
|
|
1161
|
+
[drawRows, drawCfg, drawToml, drawLog, drawReqs, drawCauses, drawFamilies, drawWalls]
|
|
1162
|
+
.forEach(function(f){ try { f(); } catch (e) { console.error("seisin: " + f.name + " failed", e); } });
|
|
1163
|
+
}
|
|
758
1164
|
|
|
759
1165
|
/**
|
|
760
1166
|
* The page works two ways on purpose.
|
|
@@ -765,16 +1171,112 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
|
|
|
765
1171
|
* you can look at before installing anything. What it must never do is show
|
|
766
1172
|
* example data while looking live, so the label says which one you are in.
|
|
767
1173
|
*/
|
|
1174
|
+
/**
|
|
1175
|
+
* This run's token, from the link `seisin ui` opened.
|
|
1176
|
+
*
|
|
1177
|
+
* It arrives in the fragment (#t=…), which the browser never sends to any
|
|
1178
|
+
* server, and it is moved into this tab's storage and wiped from the address
|
|
1179
|
+
* bar and the history entry at once. The server no longer puts it in the
|
|
1180
|
+
* page: anything that could GET "/" used to walk away with it.
|
|
1181
|
+
*/
|
|
1182
|
+
var TOKEN = (function(){
|
|
1183
|
+
var m = /(?:^#|&)t=([a-f0-9]+)/.exec(location.hash || "");
|
|
1184
|
+
try {
|
|
1185
|
+
if (m) { sessionStorage.setItem("seisin-token", m[1]); }
|
|
1186
|
+
var t = m ? m[1] : (sessionStorage.getItem("seisin-token") || "");
|
|
1187
|
+
if (m) history.replaceState(null, "", location.pathname + location.search);
|
|
1188
|
+
return t;
|
|
1189
|
+
} catch (_) { return m ? m[1] : ""; }
|
|
1190
|
+
})();
|
|
1191
|
+
|
|
1192
|
+
/**
|
|
1193
|
+
* What to tell a person when a request to this server fails.
|
|
1194
|
+
*
|
|
1195
|
+
* "Failed to fetch" was all the browser said when the console had been
|
|
1196
|
+
* restarted under an open tab — the server was gone, or back with a new
|
|
1197
|
+
* token that this tab does not hold. Both have the same fix, and it is not
|
|
1198
|
+
* obvious from either message.
|
|
1199
|
+
*/
|
|
1200
|
+
function whyFailed(e, status){
|
|
1201
|
+
if (status === 403)
|
|
1202
|
+
return "this tab's link is stale — the console restarted. Open the new link `seisin ui` printed.";
|
|
1203
|
+
if (e && /network|fetch/i.test(String(e.message || e)))
|
|
1204
|
+
return "the console did not answer. If `seisin ui` restarted, open the new link it printed.";
|
|
1205
|
+
return (e && e.message) || "failed";
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
/* ── time window ──────────────────────────────────────────────────────── */
|
|
1209
|
+
// "1h" … "30d", "all", or "from:<ISO>". Remembered per browser, as a convenience.
|
|
1210
|
+
var RANGE = "all";
|
|
1211
|
+
try { RANGE = localStorage.getItem("seisin.range") || "all"; } catch (_) {}
|
|
1212
|
+
var SPANS = { "1h": 36e5, "4h": 4 * 36e5, "24h": 24 * 36e5, "7d": 7 * 864e5, "30d": 30 * 864e5 };
|
|
1213
|
+
|
|
1214
|
+
// Worked out on every request, so "last 4h" keeps moving while the page is open.
|
|
1215
|
+
function sinceIso(){
|
|
1216
|
+
if (SPANS[RANGE]) return new Date(Date.now() - SPANS[RANGE]).toISOString();
|
|
1217
|
+
if (RANGE.indexOf("from:") === 0) return RANGE.slice(5);
|
|
1218
|
+
return null;
|
|
1219
|
+
}
|
|
1220
|
+
function stateUrl(){
|
|
1221
|
+
var s = sinceIso();
|
|
1222
|
+
return "/api/state" + (s ? "?since=" + encodeURIComponent(s) : "");
|
|
1223
|
+
}
|
|
1224
|
+
function rangeLabel(){
|
|
1225
|
+
if (SPANS[RANGE]) return "last " + RANGE;
|
|
1226
|
+
if (RANGE.indexOf("from:") === 0) return "since " + new Date(RANGE.slice(5)).toLocaleString();
|
|
1227
|
+
return "all time";
|
|
1228
|
+
}
|
|
1229
|
+
function drawRange(){
|
|
1230
|
+
document.querySelectorAll("#range .seg button").forEach(function(b){
|
|
1231
|
+
b.classList.toggle("on", b.dataset.r === RANGE);
|
|
1232
|
+
});
|
|
1233
|
+
var from = document.getElementById("range-from");
|
|
1234
|
+
from.classList.toggle("on", RANGE.indexOf("from:") === 0);
|
|
1235
|
+
if (RANGE.indexOf("from:") !== 0) from.value = "";
|
|
1236
|
+
}
|
|
1237
|
+
function setRange(r){
|
|
1238
|
+
RANGE = r;
|
|
1239
|
+
try { localStorage.setItem("seisin.range", r); } catch (_) {}
|
|
1240
|
+
drawRange();
|
|
1241
|
+
reload().catch(function(){ /* server went away: keep what is on screen */ });
|
|
1242
|
+
}
|
|
1243
|
+
document.querySelectorAll("#range .seg button").forEach(function(b){
|
|
1244
|
+
b.addEventListener("click", function(){ setRange(b.dataset.r); });
|
|
1245
|
+
});
|
|
1246
|
+
document.getElementById("range-from").addEventListener("change", function(e){
|
|
1247
|
+
var t = Date.parse(e.target.value); // datetime-local is local time, which Date.parse reads as such
|
|
1248
|
+
setRange(isNaN(t) ? "all" : "from:" + new Date(t).toISOString());
|
|
1249
|
+
});
|
|
1250
|
+
|
|
768
1251
|
async function boot(){
|
|
769
|
-
let s = null;
|
|
1252
|
+
let s = null, stale = false;
|
|
770
1253
|
try {
|
|
771
|
-
const r = await fetch(
|
|
1254
|
+
const r = await fetch(stateUrl(), { cache: "no-store", headers: { "x-seisin-token": TOKEN } });
|
|
772
1255
|
if (r.ok) s = await r.json();
|
|
1256
|
+
// A live server that refuses this tab's token: `seisin ui` was restarted
|
|
1257
|
+
// and minted a new one. Falling back to the example here showed a policy
|
|
1258
|
+
// that is not the reader's, one F5 after a restart, with nothing saying why.
|
|
1259
|
+
stale = r.status === 403;
|
|
773
1260
|
} catch (_) { /* opened as a file: stay in demo */ }
|
|
774
1261
|
|
|
775
1262
|
const label = document.getElementById("source");
|
|
1263
|
+
if (stale) {
|
|
1264
|
+
label.textContent = "link from an earlier run";
|
|
1265
|
+
label.style.color = "var(--amber)";
|
|
1266
|
+
document.querySelector("main").innerHTML =
|
|
1267
|
+
'<div class="box" style="padding:22px 24px;max-width:640px">' +
|
|
1268
|
+
'<h2 style="margin:0 0 8px;font-size:17px">This tab has the link from an earlier run</h2>' +
|
|
1269
|
+
'<p style="margin:0 0 8px">`seisin ui` was restarted, and every run gets a new token. ' +
|
|
1270
|
+
'Open the new link it printed in the terminal (it starts with ' +
|
|
1271
|
+
'<code>http://127.0.0.1:' + location.port + '/#t=</code>).</p>' +
|
|
1272
|
+
'<p style="margin:0;color:var(--mid)">Nothing here is shown until then, so this page never ' +
|
|
1273
|
+
'passes the example policy off as yours.</p></div>';
|
|
1274
|
+
return;
|
|
1275
|
+
}
|
|
776
1276
|
if (!s || s.error) {
|
|
777
|
-
label.textContent = "
|
|
1277
|
+
label.textContent = location.protocol.startsWith("http") && !TOKEN
|
|
1278
|
+
? "no token — open the link `seisin ui` printed · showing an example"
|
|
1279
|
+
: "example policy — not your repo";
|
|
778
1280
|
label.style.color = "var(--amber)";
|
|
779
1281
|
draw();
|
|
780
1282
|
return;
|
|
@@ -787,10 +1289,18 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
|
|
|
787
1289
|
writes: r.writes.slice(),
|
|
788
1290
|
keys: r.keys.map(k => k.replace(/\.[^.]+$/, "")),
|
|
789
1291
|
sandbox: true,
|
|
790
|
-
blocks: r.blocks
|
|
1292
|
+
blocks: r.blocks,
|
|
1293
|
+
// The three role keys that change the profile, drawn from what the
|
|
1294
|
+
// server says rather than typed into this page (review of 2026-09-22, #5).
|
|
1295
|
+
neverWrites: r.neverWrites || [],
|
|
1296
|
+
localBinding: !!r.localBinding,
|
|
1297
|
+
localPorts: (r.localPorts || []).slice(),
|
|
1298
|
+
trustd: !!r.trustd
|
|
791
1299
|
}));
|
|
792
1300
|
LOG = fromServer(s.log);
|
|
793
1301
|
REQS = s.requests || [];
|
|
1302
|
+
CAUSES = s.causes || null;
|
|
1303
|
+
WALLS = s.walls || {};
|
|
794
1304
|
ONDISK = s.toml || null;
|
|
795
1305
|
// The demo selection does not survive the live load. `sel` starts at "frontend", a role
|
|
796
1306
|
// from the example policy, and a real repo has no reason to have one. `drawCfg` then
|
|
@@ -809,6 +1319,8 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
|
|
|
809
1319
|
// everything" against a real repo is not a preview. `dataset.live` is what `show()`
|
|
810
1320
|
// reads, so the router never brings it back when you return to Roles.
|
|
811
1321
|
document.getElementById("kill").dataset.live = "1";
|
|
1322
|
+
document.getElementById("range").hidden = false;
|
|
1323
|
+
drawRange();
|
|
812
1324
|
document.getElementById("kill").hidden = true;
|
|
813
1325
|
document.getElementById("toml-note").textContent = "the file on disk, as it stands";
|
|
814
1326
|
document.getElementById("foot-mode").textContent = "Live.";
|
|
@@ -830,7 +1342,13 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
|
|
|
830
1342
|
policy: {view:"roles", title:"Roles",
|
|
831
1343
|
sub:"Which folders each agent writes, which keys it may read, and whether it runs sandboxed."},
|
|
832
1344
|
requests: {view:"requests", title:"Waiting on you",
|
|
833
|
-
sub:"
|
|
1345
|
+
sub:"Permissions an agent asked for and cannot have. Granting one edits your policy."},
|
|
1346
|
+
denied: {view:"denied", title:"Denied",
|
|
1347
|
+
sub:"Every denial in the window, grouped by the path it was about."},
|
|
1348
|
+
causes: {view:"causes", title:"Causes",
|
|
1349
|
+
sub:"The same denials by name, which is the cut that says what kind of day it was."},
|
|
1350
|
+
walls: {view:"walls", title:"Walls",
|
|
1351
|
+
sub:"What each role keeps being denied, and what retrying it cost."},
|
|
834
1352
|
activity: {view:"activity", title:"Activity",
|
|
835
1353
|
sub:"Every decision the runtime made, newest last, each with the role that owns the path."},
|
|
836
1354
|
configfile: {view:"config", title:"Config file",
|
|
@@ -838,7 +1356,7 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
|
|
|
838
1356
|
};
|
|
839
1357
|
|
|
840
1358
|
function show(route){
|
|
841
|
-
var v = VIEWS[route] || VIEWS.
|
|
1359
|
+
var v = VIEWS[route] || VIEWS.requests;
|
|
842
1360
|
document.querySelectorAll("main section[data-view]").forEach(function(s){
|
|
843
1361
|
s.hidden = s.getAttribute("data-view") !== v.view;
|
|
844
1362
|
});
|
|
@@ -858,7 +1376,15 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
|
|
|
858
1376
|
window.scrollTo(0, 0);
|
|
859
1377
|
}
|
|
860
1378
|
|
|
861
|
-
|
|
1379
|
+
/**
|
|
1380
|
+
* The landing view is what is waiting on you, not the policy table.
|
|
1381
|
+
*
|
|
1382
|
+
* It opened on Roles, which is reference: a wall of paths you consult when you
|
|
1383
|
+
* have a question, rendered before anybody asked one. What a person opens this
|
|
1384
|
+
* for is "does anything need me", and that has to be the first screen or it is
|
|
1385
|
+
* a screen nobody reaches. Reference is looked up; decisions are shown.
|
|
1386
|
+
*/
|
|
1387
|
+
function route(){ return (location.hash || "#requests").slice(1); }
|
|
862
1388
|
window.addEventListener("hashchange", function(){ show(route()); });
|
|
863
1389
|
show(route());
|
|
864
1390
|
|
|
@@ -891,7 +1417,7 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
|
|
|
891
1417
|
|
|
892
1418
|
async function refreshLog(){
|
|
893
1419
|
try {
|
|
894
|
-
const r = await fetch(
|
|
1420
|
+
const r = await fetch(stateUrl(), { cache: "no-store", headers: { "x-seisin-token": TOKEN } });
|
|
895
1421
|
if (!r.ok) return;
|
|
896
1422
|
const s = await r.json();
|
|
897
1423
|
if (s.error) return;
|
|
@@ -900,7 +1426,7 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
|
|
|
900
1426
|
// the same way the log does: by polling. Re-rendered only when it changed,
|
|
901
1427
|
// so a reason someone is halfway through typing is not wiped every 2s.
|
|
902
1428
|
var fresh = JSON.stringify(s.requests || []);
|
|
903
|
-
if (fresh !== JSON.stringify(REQS)) { REQS = JSON.parse(fresh); drawReqs(); }
|
|
1429
|
+
if (fresh !== JSON.stringify(REQS)) { REQS = JSON.parse(fresh); drawReqs(); drawKpis(); }
|
|
904
1430
|
// Only the counters that come from the log are refreshed. Whatever the
|
|
905
1431
|
// reader has been editing on screen stays put — a page that resets your
|
|
906
1432
|
// work every two seconds is worse than one that does not update.
|
|
@@ -914,7 +1440,7 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
|
|
|
914
1440
|
|
|
915
1441
|
/** Everything again, after a decision rewrote the policy on disk. */
|
|
916
1442
|
async function reload(){
|
|
917
|
-
const r = await fetch(
|
|
1443
|
+
const r = await fetch(stateUrl(), { cache: "no-store", headers: { "x-seisin-token": TOKEN } });
|
|
918
1444
|
const s = await r.json();
|
|
919
1445
|
if (s.error) return;
|
|
920
1446
|
KEYDIR = (s.keyDirs && s.keyDirs[0]) || ".secrets";
|
|
@@ -926,8 +1452,11 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
|
|
|
926
1452
|
}));
|
|
927
1453
|
LOG = fromServer(s.log);
|
|
928
1454
|
REQS = s.requests || [];
|
|
1455
|
+
CAUSES = s.causes || null;
|
|
1456
|
+
WALLS = s.walls || {};
|
|
929
1457
|
ONDISK = s.toml || null;
|
|
930
1458
|
draw();
|
|
1459
|
+
drawKpis();
|
|
931
1460
|
}
|
|
932
1461
|
|
|
933
1462
|
boot();
|