seisin 0.1.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/ui/index.html ADDED
@@ -0,0 +1,937 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>seisin console</title>
7
+ <!-- A plot map, which is what the word means: seisin is the legal term for possession
8
+ of a freehold estate in LAND. The square is the repo, the lines divide it into
9
+ parcels, and one parcel is claimed. Two strokes and one fill so the divisions
10
+ survive at 16px, where a favicon actually lives. Also in ui/favicon.svg for anyone
11
+ who wants 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%228%22%20fill%3D%22%2312161c%22%2F%3E%3Cg%20clip-path%3D%22url%28%23p%29%22%3E%3CclipPath%20id%3D%22p%22%3E%3Crect%20x%3D%226%22%20y%3D%226%22%20width%3D%2220%22%20height%3D%2220%22%20rx%3D%224%22%2F%3E%3C%2FclipPath%3E%3Crect%20x%3D%226%22%20y%3D%226%22%20width%3D%229%22%20height%3D%2211%22%20fill%3D%22%233fb950%22%2F%3E%3C%2Fg%3E%3Crect%20x%3D%226%22%20y%3D%226%22%20width%3D%2220%22%20height%3D%2220%22%20rx%3D%224%22%20fill%3D%22none%22%20stroke%3D%22%23e6edf3%22%20stroke-width%3D%222.6%22%2F%3E%3Cpath%20d%3D%22M15%206v11M6%2017h20%22%20stroke%3D%22%23e6edf3%22%20stroke-width%3D%222.6%22%20stroke-linecap%3D%22square%22%2F%3E%3C%2Fsvg%3E">
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
+ <meta name="color-scheme" content="light dark">
15
+ <style>
16
+ /* Nothing moves on its own: fixed widths on the columns that change,
17
+ min-heights on the cards, tabular numerals everywhere a digit updates.
18
+ A console that reflows while you read it makes you lose your place. */
19
+ :root{
20
+ --bg:#fff; --panel:#fff; --side:#f6f8fa;
21
+ --line:#d1d9e0; --line2:#d8dee4;
22
+ --fg:#1f2328; --mid:#59636e; --dim:#818b98;
23
+ --green:#1a7f37; --amber:#9a6700; --blue:#0969da;
24
+ --hover:#eaeef2; --active:#ddf4ff;
25
+ /* Declared, and it was not: `.req .path` asked for `var(--mono)` against a variable that
26
+ never existed, so the one place showing a requested path fell back to the body sans —
27
+ the exact place where a path has to read as a path. */
28
+ --mono:ui-monospace,SFMono-Regular,"SF Mono",Menlo,monospace;
29
+ /* One duration for everything that responds to a pointer. The reduced-motion rule at the
30
+ bottom already switched transitions off; there were none to switch. */
31
+ --quick:140ms cubic-bezier(.2,0,.2,1);
32
+ color-scheme:light;
33
+ }
34
+ @media (prefers-color-scheme:dark){ :root:not([data-theme="light"]){
35
+ --bg:#0e1116; --panel:#161b22; --side:#10151c;
36
+ --line:#262d38; --line2:#30363d;
37
+ --fg:#e6edf3; --mid:#8b949e; --dim:#6e7681;
38
+ --green:#3fb950; --amber:#d29922; --blue:#58a6ff;
39
+ --hover:#1c2430; --active:#1f2d3d;
40
+ color-scheme:dark;
41
+ }}
42
+ :root[data-theme="dark"]{
43
+ --bg:#0e1116; --panel:#161b22; --side:#10151c;
44
+ --line:#262d38; --line2:#30363d;
45
+ --fg:#e6edf3; --mid:#8b949e; --dim:#6e7681;
46
+ --green:#3fb950; --amber:#d29922; --blue:#58a6ff;
47
+ --hover:#1c2430; --active:#1f2d3d;
48
+ color-scheme:dark;
49
+ }
50
+ *{box-sizing:border-box}
51
+ html,body{max-width:100%;overflow-x:clip}
52
+ body{margin:0;background:var(--bg);color:var(--fg);
53
+ font:14px/1.5 ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif}
54
+ .mono{font-family:ui-monospace,SFMono-Regular,"SF Mono",Menlo,monospace}
55
+
56
+ /* `dvh`, not `vh`: on iOS Safari the address bar moves in and out of the measurement and the
57
+ page jumps while you scroll. Same pixel on a desktop. */
58
+ .frame{display:grid;grid-template-columns:216px 1fr;min-height:100dvh}
59
+ .side{background:var(--side);border-right:1px solid var(--line);display:flex;flex-direction:column}
60
+ .brand{padding:15px 14px 13px;border-bottom:1px solid var(--line);display:flex;flex-direction:column;gap:12px}
61
+ .brand b{font-size:15px;font-weight:600;letter-spacing:-.01em}
62
+ .brand small{font-size:11px;color:var(--mid);display:block;margin-top:1px}
63
+ .theme{display:flex;gap:2px;background:var(--hover);border-radius:6px;padding:2px}
64
+ .theme button{flex:1;border:0;background:none;color:var(--mid);padding:4px 0;border-radius:4px;
65
+ cursor:pointer;font:inherit;font-size:12px}
66
+ .theme button.on{background:var(--panel);color:var(--fg);box-shadow:0 1px 2px #0002}
67
+ .side .list{padding:8px 0}
68
+ .side .cap{font-size:10px;font-weight:600;letter-spacing:.08em;text-transform:uppercase;
69
+ color:var(--dim);padding:12px 14px 6px}
70
+ .side a{display:flex;align-items:center;gap:9px;padding:5px 14px;color:var(--mid);
71
+ text-decoration:none;font-size:13px;border-left:2px solid transparent}
72
+ .side a:hover{background:var(--hover);color:var(--fg)}
73
+ .side a.on{background:var(--active);color:var(--blue);border-left-color:var(--blue)}
74
+ .side a i{font-style:normal;font-size:11px;width:12px;opacity:.8}
75
+ .side a .pin{margin-left:auto;font-size:10px;font-weight:600;min-width:17px;height:17px;padding:0 5px;
76
+ border-radius:9px;background:var(--active);color:var(--blue);display:grid;place-items:center;
77
+ font-variant-numeric:tabular-nums}
78
+
79
+ main{min-width:0;overflow-x:hidden;padding:20px 22px 40px;display:flex;flex-direction:column;gap:18px}
80
+ .head{display:flex;align-items:flex-end;justify-content:space-between;gap:16px;flex-wrap:wrap}
81
+ .head h1{font-size:19px;font-weight:600;margin:0;letter-spacing:-.01em}
82
+ .head p{margin:2px 0 0;font-size:12px;color:var(--mid)}
83
+ button.btn{font:inherit;font-size:12px;font-weight:600;cursor:pointer;padding:6px 12px;border-radius:6px;
84
+ border:1px solid var(--line2);background:var(--panel);color:var(--fg)}
85
+ button.btn:hover{background:var(--hover)}
86
+ button.btn.warn{border-color:var(--amber);color:var(--amber)}
87
+ button:focus-visible,a:focus-visible,select:focus-visible{outline:2px solid var(--blue);outline-offset:2px}
88
+
89
+ .cards{display:grid;gap:12px;grid-template-columns:repeat(auto-fit,minmax(168px,1fr))}
90
+ .card{background:var(--panel);border:1px solid var(--line);border-left:3px solid var(--line2);
91
+ border-radius:8px;padding:14px 16px;min-height:96px;display:flex;flex-direction:column}
92
+ .card.g{border-left-color:var(--green)} .card.a{border-left-color:var(--amber)} .card.b{border-left-color:var(--blue)}
93
+ .card h2{font-size:11px;font-weight:600;margin:0;letter-spacing:.06em;text-transform:uppercase;color:var(--dim)}
94
+ .card .n{font-size:28px;font-weight:600;line-height:1;margin-top:12px;font-variant-numeric:tabular-nums;min-width:2.2ch}
95
+ .card .s{font-size:11px;color:var(--mid);margin-top:5px;min-height:16px}
96
+ .card.g .n{color:var(--green)} .card.a .n{color:var(--amber)} .card.b .n{color:var(--blue)}
97
+
98
+ .box{background:var(--panel);border:1px solid var(--line);border-radius:8px;overflow:hidden}
99
+ .box>header{padding:10px 14px;border-bottom:1px solid var(--line);display:flex;align-items:baseline;
100
+ justify-content:space-between;gap:12px}
101
+ .box>header h2{font-size:13px;font-weight:600;margin:0}
102
+ .box>header .note{font-size:11px;color:var(--dim)}
103
+
104
+ .scrollx{overflow-x:auto}
105
+ table{width:100%;border-collapse:collapse;font-size:13px;min-width:720px}
106
+ th{text-align:left;font-size:10px;font-weight:600;letter-spacing:.07em;text-transform:uppercase;
107
+ color:var(--dim);padding:9px 12px 8px;border-bottom:1px solid var(--line);white-space:nowrap}
108
+ td{padding:9px 12px;border-bottom:1px solid var(--line);vertical-align:middle}
109
+ tbody tr:last-child td{border-bottom:0}
110
+ tbody tr:hover td{background:var(--hover)}
111
+ tbody tr.on td{background:var(--active)}
112
+ td.role{font-weight:500;white-space:nowrap;width:132px;cursor:pointer}
113
+ td.role i{font-style:normal;margin-right:8px;font-size:11px;color:var(--dim)}
114
+ tr.g td.role i{color:var(--green)} tr.a td.role i{color:var(--amber)}
115
+ td.num{text-align:right;font-variant-numeric:tabular-nums;width:70px}
116
+ td.st{width:122px}
117
+ td.terr{min-width:200px}
118
+
119
+ .tags{display:flex;flex-wrap:wrap;gap:4px;align-items:center}
120
+ .tag{font:11px/1.45 ui-monospace,SFMono-Regular,monospace;padding:2px 6px;border-radius:6px;
121
+ border:1px solid var(--line2);color:var(--mid);background:var(--bg);white-space:nowrap;
122
+ display:inline-flex;align-items:center;gap:5px}
123
+ .tag.w{border-color:var(--blue);color:var(--blue);background:var(--active)}
124
+ .tag .x{border:0;background:none;color:inherit;cursor:pointer;opacity:.6;font-size:12px;line-height:1;padding:0;font-family:inherit}
125
+ .tag .x:hover{opacity:1}
126
+ button.tag{cursor:pointer;font-family:ui-monospace,SFMono-Regular,monospace}
127
+ button.tag:hover{border-color:var(--blue)}
128
+ button.tag[aria-pressed="true"]{border-color:var(--green);color:var(--green)}
129
+ button.add{font:11px inherit;font-family:inherit;padding:2px 7px;border-radius:6px;border:1px dashed var(--line2);
130
+ background:none;color:var(--dim);cursor:pointer}
131
+ button.add:hover{border-color:var(--blue);color:var(--blue)}
132
+
133
+ button.sw{display:inline-flex;align-items:center;gap:7px;border:1px solid var(--line2);background:var(--bg);
134
+ border-radius:6px;padding:4px 9px;cursor:pointer;font:inherit;font-size:12px;color:var(--mid);width:114px}
135
+ button.sw i{font-style:normal;width:7px;height:7px;border-radius:50%;background:var(--dim);flex:0 0 7px}
136
+ button.sw[data-on="true"]{color:var(--green)} button.sw[data-on="true"] i{background:var(--green)}
137
+ button.sw[data-on="false"]{color:var(--amber);border-color:var(--amber)} button.sw[data-on="false"] i{background:var(--amber)}
138
+ button.sw:hover{background:var(--hover)}
139
+
140
+ .two{display:grid;grid-template-columns:1fr 1fr;gap:14px;align-items:start}
141
+ pre{margin:0;padding:13px 14px;font:12px/1.65 ui-monospace,SFMono-Regular,monospace;color:var(--mid);
142
+ overflow-x:auto;white-space:pre;min-height:286px}
143
+ pre .c{color:var(--dim)} pre .k{color:var(--blue)} pre .d{color:var(--amber)} pre .g{color:var(--green)}
144
+
145
+ .ev{display:flex;align-items:baseline;gap:8px;padding:9px 14px;border-bottom:1px solid var(--line)}
146
+ .ev:last-child{border-bottom:0}
147
+ .ev b{font-weight:600;font-size:12px;flex:0 0 auto;width:52px}
148
+ .ev.deny b{color:var(--amber)} .ev.allow b{color:var(--green)} .ev.admin b{color:var(--blue)}
149
+ .ev .body{flex:1 1 auto;min-width:0;display:flex;flex-direction:column;gap:2px}
150
+ .ev .what{font:11px ui-monospace,SFMono-Regular,monospace;color:var(--dim);
151
+ overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
152
+ .ev .what em{font-style:normal;color:var(--fg)}
153
+ .ev .who{font-size:11px;color:var(--mid)}
154
+ .ev .who em{font-style:normal;color:var(--blue);font-weight:600}
155
+ .ev .when{font-size:11px;color:var(--dim);flex:0 0 auto;font-variant-numeric:tabular-nums}
156
+ /* Which writer saw it. The hook reports an attempt and can be talked around; the kernel
157
+ reports a refusal and cannot. When the two disagree about a run the reader needs to know
158
+ which line carries more weight, so the one that does is the one that is marked — a small
159
+ word in the margin, not a column. Lines without the field render exactly as before. */
160
+ .ev .src{font:10px/1.6 var(--mono);color:var(--dim);flex:0 0 auto;padding:0 5px;
161
+ border:1px solid var(--line2);border-radius:4px;letter-spacing:.02em;cursor:default}
162
+
163
+ .foot{font-size:11px;color:var(--dim);line-height:1.7;border-top:1px solid var(--line);padding-top:14px}
164
+ .foot b{color:var(--mid);font-weight:600}
165
+
166
+ /* Things that answer a pointer, answer visibly. Colour only — nothing moves position, so a
167
+ row cannot shift under the cursor while you are reading it. The press is the one exception
168
+ and it is 1px: enough to feel, too little to lose your place. */
169
+ .side a,button.btn,button.tag,button.add,button.sw,.tag .x,td.role{
170
+ transition:background-color var(--quick),border-color var(--quick),color var(--quick)}
171
+ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform:translateY(1px)}
172
+
173
+ /* THE TERRITORY COLUMN, which is where this table stops being readable.
174
+ A role whose policy names files one by one — because an OS sandbox cannot express
175
+ "the .py in this folder but not its subfolders" — arrives with dozens of paths, and forty
176
+ tags in a table cell is not a list, it is a wall. Collapsed, the cell says which folders
177
+ the role writes and how many paths in each; expanded, every path is there with its own
178
+ remove button. The summary is the useful reading and the detail is the one you ask for. */
179
+ .terr .fold{border-style:dashed;color:var(--mid);cursor:pointer}
180
+ .terr .fold b{font-weight:600;color:var(--fg);font-variant-numeric:tabular-nums}
181
+ .terr .fold:hover{border-color:var(--blue);color:var(--blue)}
182
+ .terr .more{font-size:11px;color:var(--blue);background:none;border:0;cursor:pointer;padding:2px 4px;
183
+ font:inherit;font-size:11px;text-decoration:underline;text-underline-offset:2px}
184
+
185
+ /* The filter. The console could show you the map but not ask it anything — the one question
186
+ this tool exists to answer, "whose is this path", was only reachable from the CLI. */
187
+ .filter{display:flex;align-items:center;gap:8px}
188
+ .filter input{font:inherit;font-size:12px;padding:5px 9px;border:1px solid var(--line2);
189
+ border-radius:6px;background:var(--bg);color:var(--fg);width:min(46vw,260px);
190
+ font-family:var(--mono)}
191
+ .filter input::placeholder{color:var(--dim);font-family:inherit}
192
+ .filter .hits{font-size:11px;color:var(--dim);font-variant-numeric:tabular-nums;min-width:7ch}
193
+ .nohits{padding:22px 14px;color:var(--mid);font-size:13px}
194
+ .nohits b{color:var(--fg);font-family:var(--mono)}
195
+
196
+ /* An empty count is not a count. `.pin` has a background and a min-width, so a role with no
197
+ pending anything rendered as a small grey blob next to the label — a badge that says
198
+ nothing but looks like it is trying to. */
199
+ .side a .pin:empty{display:none}
200
+
201
+ /* What a view says when it has nothing to show. Not a shrug: the two reasons a log is empty
202
+ need different answers, so it names them. */
203
+ .blank{margin:0;padding:34px 16px;color:var(--fg);font-size:13px;line-height:1.7;text-align:center}
204
+ .blank span{color:var(--mid);display:block;margin-top:6px;font-size:12px;
205
+ max-width:58ch;margin-inline:auto;text-wrap:pretty}
206
+ .blank code{font-family:var(--mono);font-size:11.5px;background:var(--hover);
207
+ padding:1px 5px;border-radius:4px;color:var(--fg)}
208
+
209
+ @media (max-width:980px){.two{grid-template-columns:1fr}}
210
+ @media (max-width:720px){
211
+ .frame{grid-template-columns:1fr}
212
+ .side{border-right:0;border-bottom:1px solid var(--line)}
213
+ .side .list{display:flex;overflow-x:auto;padding:6px 8px}
214
+ .side .cap{display:none}
215
+ .side a{border-left:0;border-bottom:2px solid transparent;white-space:nowrap}
216
+ .side a.on{border-left-color:transparent;border-bottom-color:var(--blue)}
217
+ main{padding:16px}
218
+ }
219
+ @media (prefers-reduced-motion:reduce){*{transition:none!important}}
220
+
221
+ /* Requests. A queue nobody opens is not human-in-the-loop, so it looks like
222
+ something waiting rather than another table of facts. */
223
+ .req{display:flex;gap:14px;align-items:flex-start;padding:14px 16px;border-top:1px solid var(--line)}
224
+ .req:first-child{border-top:0}
225
+ .req .who{flex:1;min-width:0}
226
+ .req .who b{font-weight:600}
227
+ .req .path{font-family:var(--mono);font-size:12.5px;color:var(--fg)}
228
+ .req .sub{font-size:12px;color:var(--dim);margin-top:3px}
229
+ .req .act{display:flex;gap:8px;align-items:center;flex-shrink:0;flex-wrap:wrap;justify-content:flex-end}
230
+ .req input{font:inherit;font-size:12.5px;padding:6px 9px;border:1px solid var(--line);border-radius:6px;
231
+ background:var(--bg);color:var(--fg);width:min(30vw,220px)}
232
+ .req input:focus{outline:2px solid var(--blue);outline-offset:1px}
233
+ .req .empty{color:var(--dim);padding:18px 16px}
234
+ .asked{font-variant-numeric:tabular-nums}
235
+ </style>
236
+ </head>
237
+ <body>
238
+ <div class="frame">
239
+
240
+ <nav class="side">
241
+ <div class="brand">
242
+ <div><b>seisin</b><small id="source">folders and keys, per agent</small></div>
243
+ <div class="theme" role="group" aria-label="Theme">
244
+ <button type="button" id="t-light">light</button>
245
+ <button type="button" id="t-dark">dark</button>
246
+ <button type="button" id="t-auto" class="on">auto</button>
247
+ </div>
248
+ </div>
249
+ <div class="list">
250
+ <div class="cap">Policy</div>
251
+ <a class="on" href="#policy" aria-current="page"><i>⚷</i>Roles<span class="pin" id="pin"></span></a>
252
+ <a href="#requests" id="nav-req"><i>⚖</i>Requests<span class="pin" id="req-pin"></span></a>
253
+ <a href="#activity"><i>◷</i>Activity</a>
254
+ <a href="#configfile"><i>⚙</i>Config file</a>
255
+ <div class="cap">Docs</div>
256
+ <a href="https://github.com/anthropic-experimental/sandbox-runtime" target="_blank" rel="noopener"><i>↗</i>sandbox-runtime</a>
257
+ </div>
258
+ </nav>
259
+
260
+ <!-- FOUR VIEWS, not four anchors into one long page. The sidebar used to jump you down a
261
+ scroll it had already rendered: the Config file link landed on a box under a table of
262
+ 24 roles, and "Activity" meant "keep going". Each of these answers a different
263
+ question and none of them is a footnote to Roles, so each gets the screen. -->
264
+ <main>
265
+ <div class="head">
266
+ <div>
267
+ <h1 id="v-title">Roles</h1>
268
+ <p id="v-sub">Which folders each agent writes, which keys it may read, and whether it runs sandboxed.</p>
269
+ </div>
270
+ <button class="btn warn" id="kill" type="button">Unsandbox everything</button>
271
+ </div>
272
+
273
+ <section data-view="roles">
274
+ <div class="cards">
275
+ <div class="card"><h2>roles</h2><div class="n" id="c-roles">0</div><div class="s">declared in seisin.toml</div></div>
276
+ <div class="card g"><h2>sandboxed</h2><div class="n" id="c-on">0</div><div class="s">enforced by the OS</div></div>
277
+ <div class="card a"><h2>unsandboxed</h2><div class="n" id="c-off">0</div><div class="s" id="c-off-s">nothing enforces these</div></div>
278
+ <div class="card"><h2>no keys</h2><div class="n" id="c-nokeys">0</div><div class="s">declare none</div></div>
279
+ <div class="card b"><h2>blocks today</h2><div class="n" id="c-blocks">0</div><div class="s">asked outside territory</div></div>
280
+ </div>
281
+
282
+ <div class="box">
283
+ <header id="policy">
284
+ <h2>Policy</h2>
285
+ <!-- The filter takes a path as readily as a role name, because "whose is this path"
286
+ is the question the CLI answers with `seisin whose` and the console could not
287
+ ask at all. A map you cannot query is a poster. -->
288
+ <div class="filter">
289
+ <label class="note" for="f">filter</label>
290
+ <input id="f" type="search" spellcheck="false" autocomplete="off"
291
+ placeholder="a role, or a path like src/api" aria-label="Filter roles by name or path">
292
+ <span class="hits" id="f-hits" aria-live="polite"></span>
293
+ </div>
294
+ </header>
295
+ <div class="scrollx">
296
+ <table>
297
+ <thead><tr>
298
+ <th>role</th><th class="terr">writes</th><th>keys</th><th class="st">sandbox</th><th class="num">blocks</th>
299
+ </tr></thead>
300
+ <tbody id="rows"></tbody>
301
+ </table>
302
+ <p class="nohits" id="nohits" hidden></p>
303
+ </div>
304
+ </div>
305
+
306
+ <!-- The generated settings stay with Roles: they are not a fifth subject, they are what
307
+ the row you clicked resolves to. Splitting them off would leave a view whose whole
308
+ content depends on a selection made somewhere you can no longer see. -->
309
+ <div class="box">
310
+ <header><h2 id="cfg-title">.seisin/….json</h2><span class="note">what seisin hands the runtime for the selected role</span></header>
311
+ <pre id="cfg"></pre>
312
+ </div>
313
+ </section>
314
+
315
+ <section data-view="requests" hidden>
316
+ <div class="box" id="requests">
317
+ <header>
318
+ <h2>Waiting on you</h2>
319
+ <span class="note">what an agent asked for and cannot have yet — approving happens here or in the terminal, never through MCP</span>
320
+ </header>
321
+ <div id="reqs"></div>
322
+ </div>
323
+ </section>
324
+
325
+ <section data-view="activity" hidden>
326
+ <div class="box">
327
+ <header><h2>Activity</h2><span class="note">every decision, with its owner</span></header>
328
+ <div id="log"></div>
329
+ </div>
330
+ </section>
331
+
332
+ <section data-view="config" hidden>
333
+ <div class="box">
334
+ <header id="configfile"><h2>seisin.toml</h2><span class="note" id="toml-note">copy this back into your repo</span></header>
335
+ <pre id="toml"></pre>
336
+ </div>
337
+ </section>
338
+
339
+ <!-- The footer stays outside the views: it says what mode the page is in and what the
340
+ JSON is measured against, and that is true on all four. -->
341
+ <div class="foot">
342
+ <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
+ 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.76</b>, whose schema the test suite exercises against the real binary.
345
+ </div>
346
+ </main>
347
+ </div>
348
+
349
+ <script>
350
+ (function(){
351
+ "use strict";
352
+
353
+ let KEYS = ["netlify-token","database-url","sentry-dsn","registry-token","hetzner-ro"];
354
+
355
+ let ROLES = [
356
+ {name:"frontend", writes:["src/web/**","public/**"], keys:["netlify-token"], sandbox:true, blocks:3},
357
+ {name:"backend", writes:["src/api/**","migrations/**"], keys:["database-url","sentry-dsn"], sandbox:true, blocks:2},
358
+ {name:"docs", writes:["docs/**","README.md"], keys:[], sandbox:true, blocks:0},
359
+ {name:"release", writes:[".github/**"], keys:["registry-token"], sandbox:true, blocks:1},
360
+ {name:"scratch", writes:["tmp/**"], keys:[], sandbox:false, blocks:0}
361
+ ];
362
+
363
+ let KEYDIR = ".secrets";
364
+ var sel = "frontend";
365
+
366
+ let LOG = [
367
+ {t:"21:04:12", kind:"deny", role:"frontend", act:"read", target:".secrets/database-url.txt", owners:["backend"]},
368
+ {t:"21:02:58", kind:"deny", role:"docs", act:"write", target:"src/api/orders.ts", owners:["backend"]},
369
+ {t:"20:58:31", kind:"allow", role:"backend", act:"write", target:"migrations/014_stock.sql", owners:[]},
370
+ {t:"20:51:07", kind:"deny", role:"frontend", act:"write", target:"scripts/deploy.sh", owners:[], source:"kernel"},
371
+ {t:"20:47:44", kind:"admin", role:"you", act:"unsandboxed", target:"scratch", owners:[]}
372
+ ];
373
+
374
+ function esc(s){return String(s).replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;");}
375
+ function pad(n){return String(n).padStart(2,"0");}
376
+
377
+ function theme(v){
378
+ var r=document.documentElement;
379
+ if(v==="auto") r.removeAttribute("data-theme"); else r.setAttribute("data-theme",v);
380
+ [["light","light"],["dark","dark"],["auto","auto"]].forEach(function(p){
381
+ document.getElementById("t-"+p[0]).classList.toggle("on", v===p[1]);
382
+ });
383
+ }
384
+ document.getElementById("t-light").onclick=function(){theme("light");};
385
+ document.getElementById("t-dark").onclick=function(){theme("dark");};
386
+ document.getElementById("t-auto").onclick=function(){theme("auto");};
387
+
388
+ function note(kind,act,target,owners){
389
+ var d=new Date();
390
+ LOG.unshift({t:pad(d.getHours())+":"+pad(d.getMinutes())+":"+pad(d.getSeconds()),
391
+ kind:kind, role:"you", act:act, target:target, owners:owners||[]});
392
+ LOG=LOG.slice(0,6);
393
+ }
394
+
395
+ /** Above this many paths the territory cell folds into folder counts. Four is what fits on
396
+ * one line at the narrowest column this table is allowed to reach. */
397
+ var FOLD_AT=4;
398
+ /** Which roles the reader has unfolded. Kept outside `draw` so a poll every two seconds
399
+ * does not close a list someone is reading — the rule this file opens with. */
400
+ var OPEN={};
401
+ var FILTER="";
402
+
403
+ /** Paths grouped by the folder that holds them, biggest group first.
404
+ * The group is the parent directory, with `/**` and a trailing filename removed, because
405
+ * that is the unit a reader thinks in: "what does this role write under src/". */
406
+ function byFolder(paths){
407
+ var m={};
408
+ paths.forEach(function(p){
409
+ var dir=p.replace(/\/\*\*$/,"").replace(/\/[^/]*$/,"")||"/";
410
+ (m[dir]=m[dir]||[]).push(p);
411
+ });
412
+ return Object.keys(m).map(function(d){return {dir:d+"/",n:m[d].length,paths:m[d]};})
413
+ .sort(function(a,b){return b.n-a.n||a.dir.localeCompare(b.dir);});
414
+ }
415
+
416
+ /** Does this role match what was typed? Name OR any path it writes.
417
+ * Matching on paths is the point: typing a path answers "whose is this", which until now
418
+ * you could only ask the CLI. */
419
+ function hits(r,q){
420
+ if(!q) return true;
421
+ q=q.toLowerCase();
422
+ if(r.name.toLowerCase().indexOf(q)!==-1) return true;
423
+ return r.writes.some(function(p){return p.toLowerCase().indexOf(q)!==-1;});
424
+ }
425
+
426
+ function drawRows(){
427
+ var tb=document.getElementById("rows"); tb.textContent="";
428
+ var shown=ROLES.filter(function(r){return hits(r,FILTER);});
429
+ var cuenta=document.getElementById("f-hits");
430
+ if(cuenta) cuenta.textContent=FILTER?(shown.length+" of "+ROLES.length):"";
431
+ var vacio=document.getElementById("nohits");
432
+ if(vacio) vacio.hidden=!(FILTER&&!shown.length);
433
+ if(vacio&&FILTER&&!shown.length)
434
+ vacio.innerHTML="No role writes anything matching <b>"+esc(FILTER)+"</b>. "+
435
+ "If an agent needs it, that is a request nobody can approve yet — "+
436
+ "no role owns the path.";
437
+
438
+ shown.forEach(function(r){
439
+ var tr=document.createElement("tr");
440
+ tr.className=(r.sandbox?"g":"a")+(r.name===sel?" on":"");
441
+
442
+ var c1=document.createElement("td");
443
+ c1.className="role"; c1.innerHTML="<i>●</i>"+esc(r.name); c1.tabIndex=0;
444
+ function pick(){sel=r.name;drawRows();drawCfg();}
445
+ c1.onclick=pick;
446
+ c1.onkeydown=function(e){if(e.key==="Enter"||e.key===" "){e.preventDefault();pick();}};
447
+ tr.appendChild(c1);
448
+
449
+ var c2=document.createElement("td"); c2.className="terr";
450
+ var t2=document.createElement("div"); t2.className="tags";
451
+ function tagFor(p,i){
452
+ var s=document.createElement("span"); s.className="tag w"; s.textContent=p;
453
+ var x=document.createElement("button"); x.type="button"; x.className="x"; x.textContent="×";
454
+ x.setAttribute("aria-label","Remove "+p+" from "+r.name);
455
+ x.onclick=function(){r.writes.splice(i,1);note("admin","revoked folder",r.name+" ✕ "+p);draw();};
456
+ s.appendChild(x); return s;
457
+ }
458
+ // Below the threshold there is nothing to summarise and a fold would only hide what
459
+ // already fits. Above it, the folder counts ARE the reading: "this role writes 43
460
+ // files under equipo/" is the fact, and the forty-three names are the footnote.
461
+ if(r.writes.length<=FOLD_AT||OPEN[r.name]){
462
+ r.writes.forEach(function(p,i){ t2.appendChild(tagFor(p,i)); });
463
+ if(r.writes.length>FOLD_AT){
464
+ var less=document.createElement("button"); less.type="button"; less.className="more";
465
+ less.textContent="show folders"; less.onclick=function(){OPEN[r.name]=false;drawRows();};
466
+ t2.appendChild(less);
467
+ }
468
+ }else{
469
+ byFolder(r.writes).forEach(function(f){
470
+ var s=document.createElement("span"); s.className="tag fold"; s.tabIndex=0;
471
+ s.innerHTML=esc(f.dir)+" <b>"+f.n+"</b>";
472
+ s.title=f.paths.slice(0,12).join("\n")+(f.n>12?"\n… and "+(f.n-12)+" more":"");
473
+ function open(){OPEN[r.name]=true;drawRows();}
474
+ s.onclick=open;
475
+ s.onkeydown=function(e){if(e.key==="Enter"||e.key===" "){e.preventDefault();open();}};
476
+ t2.appendChild(s);
477
+ });
478
+ var more=document.createElement("button"); more.type="button"; more.className="more";
479
+ more.textContent="show all "+r.writes.length+" paths";
480
+ more.onclick=function(){OPEN[r.name]=true;drawRows();};
481
+ t2.appendChild(more);
482
+ }
483
+ var add=document.createElement("button"); add.type="button"; add.className="add"; add.textContent="+ folder";
484
+ add.onclick=function(){
485
+ var v=prompt("Path from the repo root, with /** for the subtree:","src/");
486
+ if(v&&v.trim()){r.writes.push(v.trim());note("admin","granted folder",r.name+" + "+v.trim());draw();}
487
+ };
488
+ t2.appendChild(add); c2.appendChild(t2); tr.appendChild(c2);
489
+
490
+ var c3=document.createElement("td");
491
+ var t3=document.createElement("div"); t3.className="tags";
492
+ KEYS.forEach(function(k){
493
+ var on=r.keys.indexOf(k)!==-1;
494
+ var b=document.createElement("button"); b.type="button"; b.className="tag"; b.textContent=k;
495
+ b.setAttribute("aria-pressed",on?"true":"false");
496
+ b.setAttribute("aria-label",(on?"Revoke":"Grant")+" key "+k+" for "+r.name);
497
+ b.onclick=function(){
498
+ if(on) r.keys.splice(r.keys.indexOf(k),1); else r.keys.push(k);
499
+ note("admin",(on?"revoked key":"granted key"),r.name+(on?" ✕ ":" + ")+k);
500
+ draw();
501
+ };
502
+ t3.appendChild(b);
503
+ });
504
+ c3.appendChild(t3); tr.appendChild(c3);
505
+
506
+ var c4=document.createElement("td"); c4.className="st";
507
+ var sw=document.createElement("button"); sw.type="button"; sw.className="sw";
508
+ sw.setAttribute("data-on",r.sandbox?"true":"false");
509
+ sw.setAttribute("aria-pressed",r.sandbox?"true":"false");
510
+ sw.setAttribute("aria-label","Sandbox for "+r.name);
511
+ sw.innerHTML="<i></i>"+(r.sandbox?"sandboxed":"open");
512
+ sw.onclick=function(){
513
+ r.sandbox=!r.sandbox;
514
+ note("admin",r.sandbox?"sandboxed":"unsandboxed",r.name);
515
+ draw();
516
+ };
517
+ c4.appendChild(sw); tr.appendChild(c4);
518
+
519
+ var c5=document.createElement("td"); c5.className="num";
520
+ c5.innerHTML=r.blocks?'<span style="color:var(--amber);font-weight:600">'+r.blocks+"</span>"
521
+ :'<span style="color:var(--dim)">—</span>';
522
+ tr.appendChild(c5);
523
+
524
+ tb.appendChild(tr);
525
+ });
526
+
527
+ var on=0,off=0,nok=0,bl=0;
528
+ ROLES.forEach(function(r){r.sandbox?on++:off++; if(!r.keys.length)nok++; bl+=r.blocks;});
529
+ document.getElementById("c-roles").textContent=ROLES.length;
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;
535
+ var pin=document.getElementById("pin");
536
+ if(off){pin.textContent=off;pin.hidden=false;}else{pin.hidden=true;}
537
+ var k=document.getElementById("kill");
538
+ k.textContent=off?"Sandbox everything":"Unsandbox everything";
539
+ k.classList.toggle("warn",off===0);
540
+ }
541
+
542
+ function drawCfg(){
543
+ var r=ROLES.filter(function(x){return x.name===sel;})[0];
544
+ // Belt and braces. Whatever leaves `sel` pointing at a role that is not there, one panel
545
+ // going blank must not blank the ones drawn after it: the throw here was swallowing two.
546
+ if(!r){
547
+ document.getElementById("cfg-title").textContent=".seisin/\u2026.json";
548
+ document.getElementById("cfg").textContent =
549
+ ROLES.length ? "pick a role above" : "no roles in this policy";
550
+ return;
551
+ }
552
+ document.getElementById("cfg-title").textContent=".seisin/"+r.name+".json";
553
+ var L=[];
554
+ L.push('<span class="c">// seisin run '+esc(r.name)+' -- claude -p "…"</span>');
555
+ if(!r.sandbox){
556
+ L.push('<span class="d">// UNSANDBOXED. seisin still names the owner, but</span>');
557
+ L.push('<span class="d">// nothing stops the write. Only the log will know.</span>');
558
+ }
559
+ L.push("{");
560
+ L.push(' <span class="k">"network"</span>: {');
561
+ L.push(' "allowedDomains": ["github.com"],');
562
+ L.push(' "deniedDomains": [], "allowUnixSockets": [],');
563
+ L.push(' "allowLocalBinding": false');
564
+ L.push(" },");
565
+ L.push(' <span class="k">"filesystem"</span>: {');
566
+ // KEYDIR comes from seisin.toml, which is a file the console may be
567
+ // pointed at without having written it. Unescaped it was HTML injection
568
+ // into a page that carries SEISIN_TOKEN — and that token approves grants.
569
+ L.push(' <span class="d">"denyRead"</span>: ["'+esc(KEYDIR)+'"],');
570
+ if(r.keys.length){
571
+ L.push(' <span class="g">"allowRead"</span>: [');
572
+ r.keys.forEach(function(k,i){L.push(' "'+esc(KEYDIR)+"/"+esc(k)+'.txt"'+(i<r.keys.length-1?",":""));});
573
+ L.push(" ],");
574
+ }else{
575
+ L.push(' <span class="g">"allowRead"</span>: [], <span class="c">// declares none</span>');
576
+ }
577
+ L.push(' <span class="g">"allowWrite"</span>: [');
578
+ if(r.writes.length) r.writes.forEach(function(p,i){
579
+ L.push(' "'+esc(p.replace(/\/\*\*$/,""))+'"'+(i<r.writes.length-1?",":""));
580
+ });
581
+ else L.push(' <span class="c">// no territory</span>');
582
+ L.push(" ],");
583
+ L.push(' "denyWrite": []');
584
+ L.push(" }");
585
+ L.push("}");
586
+ document.getElementById("cfg").innerHTML=L.join("\n");
587
+ }
588
+
589
+ /**
590
+ * The policy, as TOML.
591
+ *
592
+ * Two different things depending on where the page is running, and the
593
+ * difference matters. Served live, it prints the file on disk verbatim —
594
+ * comments, order and the provenance a grant wrote. Opened as a file, there
595
+ * is no disk to read, so it renders the in-memory model, which by definition
596
+ * has no comments. Rendering the model in live mode and labelling it
597
+ * "seisin.toml · copy this back into your repo" was an instruction to paste
598
+ * away the one record of why a permission exists.
599
+ */
600
+ function drawToml(){
601
+ if (ONDISK !== null) {
602
+ var html = esc(ONDISK)
603
+ .replace(/^(\[[^\]]+\])$/gm, '<span class="k">$1</span>')
604
+ .replace(/(#[^\n]*)$/gm, '<span class="c">$1</span>');
605
+ document.getElementById("toml").innerHTML = html;
606
+ return;
607
+ }
608
+ var L=['<span class="c"># Anything not listed is denied.</span>',"",
609
+ "[keys]",'dir = "'+esc(KEYDIR)+'"',"","[network]",'allow = ["github.com", "*.github.com"]',""];
610
+ ROLES.forEach(function(r){
611
+ L.push('<span class="k">[roles.'+esc(r.name)+"]</span>");
612
+ L.push("writes = ["+r.writes.map(function(w){return '"'+esc(w)+'"';}).join(", ")+"]");
613
+ L.push("keys = ["+r.keys.map(function(k){return '"'+esc(k)+'.txt"';}).join(", ")+"]");
614
+ L.push("");
615
+ });
616
+ document.getElementById("toml").innerHTML=L.join("\n");
617
+ }
618
+
619
+ var VERB={"granted folder":"granted folder","revoked folder":"revoked folder",
620
+ "granted key":"granted key","revoked key":"revoked key",
621
+ "sandboxed":"sandboxed","unsandboxed":"unsandboxed"};
622
+
623
+ function drawLog(){
624
+ var c=document.getElementById("log"); c.textContent="";
625
+ // An empty log is not a broken page, and now that Activity is a view of its own it has
626
+ // a whole screen to say nothing on. It means one of two things and they are worth
627
+ // telling apart: nothing has run under seisin yet, or everything that ran was allowed.
628
+ if(!LOG.length){
629
+ var z=document.createElement("p"); z.className="blank";
630
+ z.innerHTML="No decisions recorded yet.<br><span>The log fills when an agent actually "+
631
+ "runs inside the box \u2014 <code>seisin run &lt;role&gt; -- \u2026</code>. "+
632
+ "Asking <code>seisin explain</code> or reading this console does not write to it.</span>";
633
+ c.appendChild(z); return;
634
+ }
635
+ LOG.forEach(function(e){
636
+ var d=document.createElement("div");
637
+ d.className="ev "+e.kind;
638
+ var label=e.kind==="admin"?"you":(e.kind==="deny"?"denied":"allowed");
639
+ var line,who="";
640
+ if(e.kind==="admin"){
641
+ line=esc(VERB[e.act]||e.act)+" <em>"+esc(e.target)+"</em>";
642
+ }else{
643
+ line="<em>"+esc(e.role)+"</em> "+esc(e.act)+" "+esc(e.target);
644
+ if(e.kind==="deny"){
645
+ who=e.owners.length
646
+ ? '<div class="who">belongs to <em>'+e.owners.map(esc).join("</em>, <em>")+"</em></div>"
647
+ : '<div class="who">no role owns this path</div>';
648
+ }
649
+ }
650
+ // Only the kernel's own lines get the mark. A hook line and an older line look the
651
+ // same as they always did — the absence is the default, not a third state.
652
+ var src=e.source==="kernel"
653
+ ? '<span class="src" title="Recorded from the kernel: what the OS actually refused, '+
654
+ 'whether or not the hook saw the attempt. This line cannot be talked around.">kernel</span>'
655
+ : "";
656
+ d.innerHTML="<b>"+label+'</b><span class="body"><span class="what">'+line+"</span>"+who+
657
+ "</span>"+src+'<span class="when">'+esc(e.t)+"</span>";
658
+ c.appendChild(d);
659
+ });
660
+ }
661
+
662
+ /**
663
+ * A what-if, and only where a what-if is honest.
664
+ *
665
+ * seisin.toml cannot express an unsandboxed role — this shows what the map
666
+ * would be worth without the boundary, which is a useful thing to see once
667
+ * and a lie to leave on screen next to real decisions read from disk. It
668
+ * writes an entry into the Activity panel, and in live mode that panel is
669
+ * the log. Hidden when live for that reason; boot() does the hiding.
670
+ */
671
+ document.getElementById("kill").onclick=function(){
672
+ var anyOpen=ROLES.some(function(r){return !r.sandbox;});
673
+ ROLES.forEach(function(r){r.sandbox=anyOpen;});
674
+ note("admin",anyOpen?"sandboxed":"unsandboxed","every role");
675
+ draw();
676
+ };
677
+
678
+ /* ── requests ─────────────────────────────────────────────────────────── */
679
+
680
+ var REQS = [];
681
+ var ONDISK = null; // the file on disk, when there is one
682
+
683
+ /**
684
+ * The queue, as something a person acts on.
685
+ *
686
+ * Hidden entirely when empty rather than shown as an empty table: the point
687
+ * of this panel is to be noticed on the days it has something in it.
688
+ */
689
+ function drawReqs(){
690
+ var box = document.getElementById("requests");
691
+ var pin = document.getElementById("req-pin");
692
+ var host = document.getElementById("reqs");
693
+ pin.textContent = REQS.length ? String(REQS.length) : "";
694
+ // The box used to hide itself when empty — right, when it sat under the roles table and
695
+ // an empty one was just noise. Now that Requests is a view of its own, hiding it leaves
696
+ // a page with a heading and nothing under it. It stays, and says what would fill it.
697
+ box.hidden = false;
698
+ if (!REQS.length) {
699
+ host.innerHTML = '<p class="blank">Nothing waiting on you.' +
700
+ '<span>An agent lands here when the policy denies it a path and it asks for the path ' +
701
+ 'rather than working around it. Approving is the one action in this console that ' +
702
+ 'writes to your policy \u2014 it edits <code>seisin.toml</code> in place, with your reason.</span></p>';
703
+ return;
704
+ }
705
+
706
+ host.innerHTML = REQS.map(function(r, i){
707
+ var owners = (r.owners && r.owners.length) ? " — belongs to <b>" + esc(r.owners.join(", ")) + "</b>" : "";
708
+ var times = r.times > 1 ? ' · <span class="asked">asked ' + r.times + "×</span>" : "";
709
+ return '<div class="req">' +
710
+ '<div class="who">' +
711
+ "<div><b>" + esc(r.role) + "</b> wants to " + esc(r.action) +
712
+ ' <span class="path">' + esc(r.grant) + "</span>" + owners + "</div>" +
713
+ '<div class="sub">first asked over ' + esc(r.target) + times + "</div>" +
714
+ "</div>" +
715
+ '<div class="act">' +
716
+ '<input id="why-' + i + '" placeholder="why — this outlives you" aria-label="reason">' +
717
+ '<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">Refuse</button>' +
719
+ "</div>" +
720
+ "</div>";
721
+ }).join("");
722
+
723
+ host.querySelectorAll("button[data-n]").forEach(function(b){
724
+ b.addEventListener("click", function(){ act(b); });
725
+ });
726
+ }
727
+
728
+ /**
729
+ * One decision, sent once.
730
+ *
731
+ * The buttons go dead on click because a double-approve is not idempotent
732
+ * here — the second one would be numbered against a queue that already moved.
733
+ */
734
+ async function act(btn){
735
+ var n = Number(btn.dataset.n);
736
+ var why = (document.getElementById("why-" + (n-1)) || {}).value || "";
737
+ var row = btn.closest(".req");
738
+ row.querySelectorAll("button,input").forEach(function(e){ e.disabled = true; });
739
+ try {
740
+ var r = await fetch("/api/decide", {
741
+ method: "POST",
742
+ headers: { "content-type": "application/json", "x-seisin-token": window.SEISIN_TOKEN || "" },
743
+ body: JSON.stringify({ number: n, decision: btn.dataset.d, reason: why })
744
+ });
745
+ var out = await r.json();
746
+ if (!r.ok) throw new Error(out.error || "refused");
747
+ // Re-read rather than patch: the grant rewrote seisin.toml, so the
748
+ // territory on screen is stale too, not just the queue.
749
+ await reload();
750
+ } catch (e) {
751
+ row.querySelectorAll("button,input").forEach(function(el){ el.disabled = false; });
752
+ var s = row.querySelector(".sub");
753
+ s.innerHTML = '<span style="color:var(--amber)">' + esc(e.message) + "</span>";
754
+ }
755
+ }
756
+
757
+ function draw(){drawRows();drawCfg();drawToml();drawLog();drawReqs();}
758
+
759
+ /**
760
+ * The page works two ways on purpose.
761
+ *
762
+ * Served by `seisin ui`, it reads the real config and the real log. Opened as
763
+ * a plain file — from the repo, from a docs site — the fetch fails and it
764
+ * falls back to the example policy below, so the console is still something
765
+ * you can look at before installing anything. What it must never do is show
766
+ * example data while looking live, so the label says which one you are in.
767
+ */
768
+ async function boot(){
769
+ let s = null;
770
+ try {
771
+ const r = await fetch("/api/state", { cache: "no-store" });
772
+ if (r.ok) s = await r.json();
773
+ } catch (_) { /* opened as a file: stay in demo */ }
774
+
775
+ const label = document.getElementById("source");
776
+ if (!s || s.error) {
777
+ label.textContent = "example policy — not your repo";
778
+ label.style.color = "var(--amber)";
779
+ draw();
780
+ return;
781
+ }
782
+
783
+ KEYDIR = (s.keyDirs && s.keyDirs[0]) || ".secrets";
784
+ KEYS = [...new Set(s.roles.flatMap(r => r.keys))].map(k => k.replace(/\.[^.]+$/, ""));
785
+ ROLES = s.roles.map(r => ({
786
+ name: r.name,
787
+ writes: r.writes.slice(),
788
+ keys: r.keys.map(k => k.replace(/\.[^.]+$/, "")),
789
+ sandbox: true,
790
+ blocks: r.blocks
791
+ }));
792
+ LOG = fromServer(s.log);
793
+ REQS = s.requests || [];
794
+ ONDISK = s.toml || null;
795
+ // The demo selection does not survive the live load. `sel` starts at "frontend", a role
796
+ // from the example policy, and a real repo has no reason to have one. `drawCfg` then
797
+ // reads `.name` off `undefined` and throws — which takes down everything `draw` renders
798
+ // after it, so the generated-settings box, the activity list AND the seisin.toml box all
799
+ // come up blank at once while the data behind them is fine. One stale default, three
800
+ // empty panels, and no error anywhere the reader can see.
801
+ if (!ROLES.some(function(r){ return r.name === sel; })) sel = (ROLES[0] || {}).name || "";
802
+ label.textContent = s.config.replace(/^.*\//, "") + " · live";
803
+ label.style.color = "var(--green)";
804
+ // The footer used to claim this page never touches your repo. It reads it
805
+ // on every poll, and approving a request rewrites seisin.toml. Saying
806
+ // otherwise in the one place a cautious reader looks was the worst of the
807
+ // two possible lies.
808
+ // Live, the button goes for good: this page edits a policy in memory, and "unsandbox
809
+ // everything" against a real repo is not a preview. `dataset.live` is what `show()`
810
+ // reads, so the router never brings it back when you return to Roles.
811
+ document.getElementById("kill").dataset.live = "1";
812
+ document.getElementById("kill").hidden = true;
813
+ document.getElementById("toml-note").textContent = "the file on disk, as it stands";
814
+ document.getElementById("foot-mode").textContent = "Live.";
815
+ document.getElementById("foot-text").innerHTML =
816
+ "This page reads <b>" + esc(s.config) + "</b> and the log on disk. Editing the policy above is a preview \u2014 copy the " +
817
+ "<b>seisin.toml</b> to keep it. Approving a request is the one action that writes: it edits your config in place, with its reason.";
818
+ draw();
819
+ setInterval(refreshLog, 2000);
820
+ }
821
+
822
+ /* ── the four views ─────────────────────────────────────────────────────────────────
823
+ * Hash routing and not a framework: this page is one file a server hands over whole, and
824
+ * it has to keep working opened straight off the disk with no server at all. A hash also
825
+ * means a view is a URL you can send someone — "look at Activity" is a link.
826
+ *
827
+ * The old hrefs (`#requests`, `#activity`, `#configfile`) are kept as the route names they
828
+ * already were, so a bookmark from before still lands where it used to. */
829
+ var VIEWS = {
830
+ policy: {view:"roles", title:"Roles",
831
+ sub:"Which folders each agent writes, which keys it may read, and whether it runs sandboxed."},
832
+ requests: {view:"requests", title:"Waiting on you",
833
+ sub:"What an agent asked for and cannot have yet. Approving is the one action that writes to your policy."},
834
+ activity: {view:"activity", title:"Activity",
835
+ sub:"Every decision the runtime made, newest last, each with the role that owns the path."},
836
+ configfile: {view:"config", title:"Config file",
837
+ sub:"The policy as it stands on disk. Editing the table is a preview \u2014 this is the file."}
838
+ };
839
+
840
+ function show(route){
841
+ var v = VIEWS[route] || VIEWS.policy;
842
+ document.querySelectorAll("main section[data-view]").forEach(function(s){
843
+ s.hidden = s.getAttribute("data-view") !== v.view;
844
+ });
845
+ document.getElementById("v-title").textContent = v.title;
846
+ document.getElementById("v-sub").textContent = v.sub;
847
+ // "Unsandbox everything" acts on the policy table. Offering it from Activity would be a
848
+ // button that changes something you cannot see.
849
+ var kill = document.getElementById("kill");
850
+ if (kill) kill.hidden = (v.view !== "roles") || kill.dataset.live === "1";
851
+ document.querySelectorAll(".side .list a[href^='#']").forEach(function(a){
852
+ var on = a.getAttribute("href").slice(1) === route;
853
+ a.classList.toggle("on", on);
854
+ if (on) a.setAttribute("aria-current", "page"); else a.removeAttribute("aria-current");
855
+ });
856
+ // Back to the top on a view change: landing halfway down a table you have never seen is
857
+ // what the anchors used to do.
858
+ window.scrollTo(0, 0);
859
+ }
860
+
861
+ function route(){ return (location.hash || "#policy").slice(1); }
862
+ window.addEventListener("hashchange", function(){ show(route()); });
863
+ show(route());
864
+
865
+ // Wired once, outside `draw`, so a redraw never drops the listener or the typed text.
866
+ (function(){
867
+ var f = document.getElementById("f");
868
+ if (!f) return;
869
+ f.addEventListener("input", function(){ FILTER = f.value.trim(); drawRows(); });
870
+ // Escape clears without reaching for the mouse: the field is meant to be typed into and
871
+ // abandoned dozens of times while reading, not filled in once like a form.
872
+ f.addEventListener("keydown", function(e){
873
+ if (e.key === "Escape" && f.value) { e.preventDefault(); f.value = ""; FILTER = ""; drawRows(); }
874
+ });
875
+ })();
876
+
877
+ /** The server's entries, in the shape the renderer already speaks. */
878
+ function fromServer(entries){
879
+ return (entries || []).map(e => ({
880
+ t: (e.at || "").slice(11, 19),
881
+ kind: e.verdict === "denied" ? "deny" : e.verdict === "observed" ? "admin" : "allow",
882
+ role: e.role,
883
+ act: e.action,
884
+ target: e.target,
885
+ owners: e.owners || [],
886
+ // Which writer produced it. Absent on hook lines and on every line from before the
887
+ // kernel could speak, and absent is what the renderer treats as "the hook".
888
+ source: e.source
889
+ }));
890
+ }
891
+
892
+ async function refreshLog(){
893
+ try {
894
+ const r = await fetch("/api/state", { cache: "no-store" });
895
+ if (!r.ok) return;
896
+ const s = await r.json();
897
+ if (s.error) return;
898
+ LOG = fromServer(s.log);
899
+ // The queue is filled by the hook, in another process, so it arrives here
900
+ // the same way the log does: by polling. Re-rendered only when it changed,
901
+ // so a reason someone is halfway through typing is not wiped every 2s.
902
+ var fresh = JSON.stringify(s.requests || []);
903
+ if (fresh !== JSON.stringify(REQS)) { REQS = JSON.parse(fresh); drawReqs(); }
904
+ // Only the counters that come from the log are refreshed. Whatever the
905
+ // reader has been editing on screen stays put — a page that resets your
906
+ // work every two seconds is worse than one that does not update.
907
+ ROLES.forEach(r2 => {
908
+ const fresh = s.roles.find(x => x.name === r2.name);
909
+ if (fresh) r2.blocks = fresh.blocks;
910
+ });
911
+ drawLog(); drawRows();
912
+ } catch (_) { /* server went away: keep what is on screen */ }
913
+ }
914
+
915
+ /** Everything again, after a decision rewrote the policy on disk. */
916
+ async function reload(){
917
+ const r = await fetch("/api/state", { cache: "no-store" });
918
+ const s = await r.json();
919
+ if (s.error) return;
920
+ KEYDIR = (s.keyDirs && s.keyDirs[0]) || ".secrets";
921
+ KEYS = [...new Set(s.roles.flatMap(x => x.keys))].map(k => k.replace(/\.[^.]+$/, ""));
922
+ ROLES = s.roles.map(x => ({
923
+ name: x.name, writes: x.writes.slice(),
924
+ keys: x.keys.map(k => k.replace(/\.[^.]+$/, "")),
925
+ sandbox: true, blocks: x.blocks
926
+ }));
927
+ LOG = fromServer(s.log);
928
+ REQS = s.requests || [];
929
+ ONDISK = s.toml || null;
930
+ draw();
931
+ }
932
+
933
+ boot();
934
+ })();
935
+ </script>
936
+ </body>
937
+ </html>