seisin 0.4.0 → 0.4.2
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 +12 -16
- package/package.json +1 -1
- package/src/commands/init.js +13 -5
- package/src/commands/log.js +9 -3
- package/src/commands/requests.js +26 -18
- package/src/commands/run.js +15 -2
- package/src/config.js +79 -7
- package/src/intake.js +4 -1
- package/src/log.js +83 -11
- package/src/mcp.js +63 -24
- package/src/notify.js +3 -3
- package/src/owners.js +140 -15
- package/src/render.js +13 -2
- package/src/requests.js +90 -13
- package/src/review.js +15 -7
- package/src/serve.js +65 -23
- package/src/surface.js +15 -1
- package/ui/index.html +30 -12
package/src/serve.js
CHANGED
|
@@ -11,16 +11,16 @@
|
|
|
11
11
|
* key each role can read, which is a map of where the credentials live.
|
|
12
12
|
*/
|
|
13
13
|
import { createServer } from "node:http";
|
|
14
|
-
import { randomBytes } from "node:crypto";
|
|
15
|
-
import { readFileSync,
|
|
14
|
+
import { randomBytes, timingSafeEqual } from "node:crypto";
|
|
15
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
16
16
|
import { join, dirname } from "node:path";
|
|
17
17
|
import { fileURLToPath } from "node:url";
|
|
18
18
|
import { loadConfig } from "./config.js";
|
|
19
19
|
import { read, logPath } from "./log.js";
|
|
20
20
|
import { settingsFor } from "./srt.js";
|
|
21
|
-
import { pending, requestsPath, settle, applyGrant, refuseIfBarred, markStale } from "./requests.js";
|
|
21
|
+
import { pending, requestsPath, settle, applyGrant, refuseIfBarred, markStale, editPolicy } from "./requests.js";
|
|
22
22
|
import { walls } from "./walls.js";
|
|
23
|
-
import { explain } from "./owners.js";
|
|
23
|
+
import { explain, standingOf } from "./owners.js";
|
|
24
24
|
|
|
25
25
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
26
26
|
|
|
@@ -54,7 +54,7 @@ export function causesOf(cfg, entries) {
|
|
|
54
54
|
for (const e of entries) {
|
|
55
55
|
if (e.verdict !== "denied" || !e.target || !e.action) continue;
|
|
56
56
|
const k = `${e.action}\u0000${e.target}`;
|
|
57
|
-
const g = by.get(k) ?? { action: e.action, target: e.target, times: 0, roles: new Set(),
|
|
57
|
+
const g = by.get(k) ?? { action: e.action, target: e.target, kind: e.kind, times: 0, roles: new Set(), ownersThen: e.owners ?? [] };
|
|
58
58
|
g.times++;
|
|
59
59
|
g.roles.add(e.role);
|
|
60
60
|
by.set(k, g);
|
|
@@ -99,25 +99,38 @@ export function causesOf(cfg, entries) {
|
|
|
99
99
|
}));
|
|
100
100
|
|
|
101
101
|
/**
|
|
102
|
-
* What a `grep` over the log cannot tell you
|
|
102
|
+
* What a `grep` over the log cannot tell you: where each refused path stands
|
|
103
|
+
* against the policy as it is now.
|
|
103
104
|
*
|
|
104
105
|
* The headline used to be "74% of it is one name", and a field review
|
|
105
106
|
* applied this project's own test to it: *did the number tell you something
|
|
106
|
-
* you did not know?* For somebody who reads the raw log, no
|
|
107
|
-
*
|
|
107
|
+
* you did not know?* For somebody who reads the raw log, no. So the console
|
|
108
|
+
* added how many causes are on paths no role owns — a decision rather than a
|
|
109
|
+
* number — and then computed it from the `owners` each log line carried,
|
|
110
|
+
* which is the one thing the log already had. It said the policy and read
|
|
111
|
+
* the past: every grant made since left its refusals counted as "nobody's",
|
|
112
|
+
* and protected surfaces, ports and paths outside the repository, which
|
|
113
|
+
* nobody can ever own, were counted as waiting for somebody to claim them.
|
|
108
114
|
*
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
115
|
+
* Now every cause is classified by `standing` (owners.js), and the four
|
|
116
|
+
* piles are reported in paths and in refusals. `unowned` keeps its name and
|
|
117
|
+
* now means what the page always said it meant. The owners the log recorded
|
|
118
|
+
* are kept on each cause as `ownersThen`: evidence of what was true, not
|
|
119
|
+
* authority over what is.
|
|
114
120
|
*/
|
|
115
|
-
const
|
|
116
|
-
const
|
|
121
|
+
const standOf = standingOf(cfg);
|
|
122
|
+
const piles = Object.fromEntries(["unowned", "owned", "protected", "outside"].map((k) => [k, { paths: 0, denials: 0 }]));
|
|
123
|
+
for (const g of by.values()) {
|
|
124
|
+
const s = standOf({ kind: g.kind, target: g.target });
|
|
125
|
+
g.standing = s;
|
|
126
|
+
piles[s.kind].paths++;
|
|
127
|
+
piles[s.kind].denials += g.times;
|
|
128
|
+
}
|
|
117
129
|
|
|
118
130
|
return {
|
|
119
131
|
total,
|
|
120
|
-
unowned,
|
|
132
|
+
unowned: piles.unowned.paths,
|
|
133
|
+
standing: piles,
|
|
121
134
|
// The real number of distinct causes, not the length of the list below.
|
|
122
135
|
// The page says "over N distinct paths" and the list is capped at twelve,
|
|
123
136
|
// so taking N from the list reported the cap as if it were the count —
|
|
@@ -133,7 +146,10 @@ export function causesOf(cfg, entries) {
|
|
|
133
146
|
times: g.times,
|
|
134
147
|
share: total ? g.times / total : 0,
|
|
135
148
|
roles: [...g.roles].sort(),
|
|
136
|
-
|
|
149
|
+
standing: g.standing.kind,
|
|
150
|
+
...(g.standing.why && { why: g.standing.why }),
|
|
151
|
+
owners: g.standing.owners,
|
|
152
|
+
ownersThen: g.ownersThen,
|
|
137
153
|
// How many of the roles that hit this would still hit it. `some` and
|
|
138
154
|
// not `every`: two roles out of three still blocked is still friction,
|
|
139
155
|
// and requiring all of them would quietly retire a live cause the day
|
|
@@ -220,11 +236,22 @@ export function state(configPath, { since = null } = {}) {
|
|
|
220
236
|
* an exception for loopback. That is what lets the console hold the half the
|
|
221
237
|
* MCP server deliberately does not.
|
|
222
238
|
*/
|
|
223
|
-
function decide(configPath, {
|
|
239
|
+
function decide(configPath, { key, decision, reason }) {
|
|
224
240
|
const cfg = loadConfig(configPath);
|
|
225
241
|
const file = requestsPath(cfg.root);
|
|
226
|
-
|
|
227
|
-
|
|
242
|
+
/**
|
|
243
|
+
* By the request's id, never by its place in the queue.
|
|
244
|
+
*
|
|
245
|
+
* It took a row number, counted against the queue as it stood when the
|
|
246
|
+
* request arrived — not when the person read the page. Agents keep writing
|
|
247
|
+
* to that queue, and another channel can settle an entry in between, so
|
|
248
|
+
* "approve #2" could approve whatever had moved into second place, with the
|
|
249
|
+
* reason typed for something else. Re-sending the POST approved the next one.
|
|
250
|
+
* The CLI stopped doing this long ago; the console had not.
|
|
251
|
+
*/
|
|
252
|
+
if (typeof key !== "string" || !key) throw new Error("key is required — the id of the request on screen");
|
|
253
|
+
const req = pending(file).find((r) => r.key === key);
|
|
254
|
+
if (!req) throw new Error(`request ${key} is no longer pending — reload to see the queue as it is now`);
|
|
228
255
|
if (decision !== "granted" && decision !== "denied")
|
|
229
256
|
throw new Error(`decision must be granted or denied`);
|
|
230
257
|
|
|
@@ -233,8 +260,14 @@ function decide(configPath, { number, decision, reason }) {
|
|
|
233
260
|
// before the queue is settled: if this throws, the request is still open
|
|
234
261
|
// rather than marked done against a file that never changed.
|
|
235
262
|
refuseIfBarred(cfg, req);
|
|
236
|
-
|
|
237
|
-
|
|
263
|
+
// Locked and atomic, so a grant from another channel is serialised with
|
|
264
|
+
// this one instead of one silently overwriting the other. See editPolicy.
|
|
265
|
+
try {
|
|
266
|
+
editPolicy(cfg, (before) => applyGrant(before, req, reason));
|
|
267
|
+
} catch (e) {
|
|
268
|
+
if (e.code === "ELOCKED") throw new Error("another grant is in progress — try again in a moment");
|
|
269
|
+
throw e;
|
|
270
|
+
}
|
|
238
271
|
}
|
|
239
272
|
settle(file, req.key, decision, reason ?? "");
|
|
240
273
|
return { ok: true, role: req.role, grant: req.grant, decision };
|
|
@@ -315,7 +348,16 @@ export function serve(configPath, port = 4178) {
|
|
|
315
348
|
* from there, keeps it for the tab, and removes it from the address bar and
|
|
316
349
|
* the history entry. Anything that fetches `/` gets a page with no token in it.
|
|
317
350
|
*/
|
|
318
|
-
|
|
351
|
+
// Constant-time compare: `===` on a secret leaks its length and a prefix
|
|
352
|
+
// through timing. Loopback with a Host check makes it near-theoretical, but a
|
|
353
|
+
// role with local_binding can reach this port, so it is not free.
|
|
354
|
+
const tokenBuf = Buffer.from(token);
|
|
355
|
+
const authorized = (req) => {
|
|
356
|
+
const got = req.headers["x-seisin-token"];
|
|
357
|
+
if (typeof got !== "string") return false;
|
|
358
|
+
const gotBuf = Buffer.from(got);
|
|
359
|
+
return gotBuf.length === tokenBuf.length && timingSafeEqual(gotBuf, tokenBuf);
|
|
360
|
+
};
|
|
319
361
|
|
|
320
362
|
/**
|
|
321
363
|
* The Host header has to name this server.
|
package/src/surface.js
CHANGED
|
@@ -235,8 +235,13 @@ function dedupe(entries) {
|
|
|
235
235
|
*/
|
|
236
236
|
export function parentInputs(config, { env = process.env } = {}) {
|
|
237
237
|
const abs = (p) => (isAbsolute(p) ? p : join(config.root, p));
|
|
238
|
+
const policy = abs(config.path ?? CONFIG_NAME);
|
|
238
239
|
const out = [
|
|
239
|
-
{ path:
|
|
240
|
+
{ path: policy, why: "the policy" },
|
|
241
|
+
// `seisin.toml.observed` is the proposal `init --from-observations` writes
|
|
242
|
+
// for a person to diff and move onto the policy; a role that could write it
|
|
243
|
+
// could plant a policy the operator then promotes in good faith.
|
|
244
|
+
{ path: policy + ".observed", why: "the policy" },
|
|
240
245
|
{ path: abs(STATE_DIR), why: "seisin's log and request queue" },
|
|
241
246
|
...(config.keyDirs ?? []).map((d) => ({ path: abs(d), why: "a key directory" })),
|
|
242
247
|
];
|
|
@@ -447,6 +452,15 @@ function computeDenies(config, role, { env, platform, observe }) {
|
|
|
447
452
|
? repoDirs.flatMap((d) => [
|
|
448
453
|
`${d}/**/.claude`, `${d}/**/.claude/**`,
|
|
449
454
|
`${d}/**/.git/hooks/**`, `${d}/**/.git/config`,
|
|
455
|
+
// What git runs that lives beside, not in, `.git/config`: a submodule's
|
|
456
|
+
// own config and hooks, and the files that redirect a worktree to
|
|
457
|
+
// another gitdir or config. Ordinary commits never write these, so
|
|
458
|
+
// denying them costs a working role nothing; a role that could write
|
|
459
|
+
// one would leave a command for the next `git` outside the box. The
|
|
460
|
+
// subtree of `.git` stays writable on purpose, so the agent can commit.
|
|
461
|
+
`${d}/**/.git/config.worktree`,
|
|
462
|
+
`${d}/**/.git/modules/**/config`, `${d}/**/.git/modules/**/hooks/**`,
|
|
463
|
+
`${d}/**/.git/worktrees/**/config.worktree`, `${d}/**/.git/worktrees/**/commondir`,
|
|
450
464
|
`${d}/**/.mcp.json`, `${d}/**/.envrc`, `${d}/**/.codex/config.toml`,
|
|
451
465
|
]).map((path) => ({ path, why: "any project's control files in this territory, by pattern" }))
|
|
452
466
|
: [];
|
package/ui/index.html
CHANGED
|
@@ -320,7 +320,7 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
|
|
|
320
320
|
<a href="#activity"><i>◷</i>Activity</a>
|
|
321
321
|
<a href="#configfile"><i>⚙</i>Config file</a>
|
|
322
322
|
<div class="cap">Docs</div>
|
|
323
|
-
<a href="https://github.com/
|
|
323
|
+
<a href="https://github.com/anthropics/sandbox-runtime" target="_blank" rel="noopener"><i>↗</i>sandbox-runtime</a>
|
|
324
324
|
</div>
|
|
325
325
|
</nav>
|
|
326
326
|
|
|
@@ -522,6 +522,9 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
|
|
|
522
522
|
];
|
|
523
523
|
|
|
524
524
|
function esc(s){return String(s).replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">");}
|
|
525
|
+
// For attribute values: the text an agent chose (a request's id is its path)
|
|
526
|
+
// must not be able to close the quote it sits in.
|
|
527
|
+
function escA(s){return esc(s).replace(/"/g,""").replace(/'/g,"'");}
|
|
525
528
|
function pad(n){return String(n).padStart(2,"0");}
|
|
526
529
|
|
|
527
530
|
function theme(v){
|
|
@@ -886,10 +889,19 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
|
|
|
886
889
|
person decides who owns them — a decision, not a count. Added after a
|
|
887
890
|
field review pointed out that the percentage above was the console
|
|
888
891
|
reading the log back to somebody who had already read it. */
|
|
892
|
+
/* Where each path stands against the policy NOW, not what the log wrote the day of the
|
|
893
|
+
refusal: that was read here once, and every grant made since kept its paths counted as
|
|
894
|
+
nobody's. Protected paths and ports are said apart, because they ask the opposite of a
|
|
895
|
+
decision \u2014 there is nothing to grant. */
|
|
896
|
+
var st = f.standing || {};
|
|
889
897
|
if (f.unowned)
|
|
890
|
-
lede += " <b>" + f.unowned + "</b> of the " + (f.distinct || f.causes.length).toLocaleString() +
|
|
898
|
+
lede += " <b>" + f.unowned.toLocaleString() + "</b> of the " + (f.distinct || f.causes.length).toLocaleString() +
|
|
891
899
|
" are on paths <b>no role owns</b> \u2014 a grant cannot settle those until " +
|
|
892
900
|
"somebody claims them.";
|
|
901
|
+
var closed = (st.protected ? st.protected.paths : 0);
|
|
902
|
+
if (closed)
|
|
903
|
+
lede += " <b>" + closed.toLocaleString() + "</b> " + (closed === 1 ? "is" : "are") +
|
|
904
|
+
" protected on purpose \u2014 the boundary working, nothing to grant.";
|
|
893
905
|
document.getElementById("ca-lede").innerHTML = lede;
|
|
894
906
|
var win = document.getElementById("dn-window");
|
|
895
907
|
if (win) win.textContent = rangeLabel() + " \u00b7 " + f.total.toLocaleString() + " denials \u00b7 " +
|
|
@@ -901,12 +913,16 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
|
|
|
901
913
|
// A cause the policy no longer denies is history, not live. It is
|
|
902
914
|
// shown, greyed, rather than dropped: it explains a number somebody may
|
|
903
915
|
// remember from yesterday instead of silently changing it.
|
|
904
|
-
|
|
916
|
+
// `stillRefused` is what the server sends. This read `stillDeclined`, which it never
|
|
917
|
+
// did, so nothing was ever greyed.
|
|
918
|
+
var cold = c.stillRefused === 0;
|
|
905
919
|
var who = c.roles.length === 1 ? c.roles[0] : c.roles.length + " roles";
|
|
906
920
|
return '<div class="bar' + (cold ? ' cold' : '') + '">' +
|
|
907
921
|
'<div class="lbl"><div class="p">' + esc(c.action) + ' ' + esc(c.target) + '</div>' +
|
|
908
922
|
'<div class="who">' + esc(who) +
|
|
909
|
-
(c.
|
|
923
|
+
(c.standing === "protected" ? ' · protected: ' + esc(c.why || "") :
|
|
924
|
+
c.standing === "outside" ? ' · ' + esc(c.why || "outside the map") :
|
|
925
|
+
c.owners && c.owners.length ? ' · owned by ' + esc(c.owners.join(", ")) : ' · owned by nobody') +
|
|
910
926
|
(cold ? ' · <b>granted since</b>' : '') + '</div>' +
|
|
911
927
|
'<div class="t"><span class="f" style="width:' + w + '%"></span></div></div>' +
|
|
912
928
|
'<div class="num">' + c.times.toLocaleString() +
|
|
@@ -1056,7 +1072,7 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
|
|
|
1056
1072
|
host.innerHTML = REQS.map(function(r, i){
|
|
1057
1073
|
var owners = (r.owners && r.owners.length) ? " — belongs to <b>" + esc(r.owners.join(", ")) + "</b>" : "";
|
|
1058
1074
|
var times = r.times > 1 ? ' · <span class="asked">asked ' + r.times + "×</span>" : "";
|
|
1059
|
-
// Marked, not moved: Approve and Decline post this row's
|
|
1075
|
+
// Marked, not moved: Approve and Decline post this row's id.
|
|
1060
1076
|
var stale = r.stale ? ' · not asked again in ' + Number(r.stale.runs) + ' runs since — likely no longer needed' : "";
|
|
1061
1077
|
return '<div class="req' + (r.stale ? ' stale' : '') + '">' +
|
|
1062
1078
|
'<div class="who">' +
|
|
@@ -1066,8 +1082,8 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
|
|
|
1066
1082
|
"</div>" +
|
|
1067
1083
|
'<div class="act">' +
|
|
1068
1084
|
'<input id="why-' + i + '" placeholder="why — this outlives you" aria-label="reason">' +
|
|
1069
|
-
'<button class="btn" data-n="' +
|
|
1070
|
-
'<button class="btn warn" data-n="' +
|
|
1085
|
+
'<button class="btn" data-n="' + i + '" data-k="' + escA(r.key) + '" data-d="granted" type="button">Approve</button>' +
|
|
1086
|
+
'<button class="btn warn" data-n="' + i + '" data-k="' + escA(r.key) + '" data-d="denied" type="button">Decline</button>' +
|
|
1071
1087
|
"</div>" +
|
|
1072
1088
|
"</div>";
|
|
1073
1089
|
}).join("");
|
|
@@ -1122,19 +1138,21 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
|
|
|
1122
1138
|
/**
|
|
1123
1139
|
* One decision, sent once.
|
|
1124
1140
|
*
|
|
1125
|
-
*
|
|
1126
|
-
*
|
|
1141
|
+
* By the request's id, taken from the row as it was drawn: a position would
|
|
1142
|
+
* be counted against a queue that agents are still writing to. A second click
|
|
1143
|
+
* finds the request already settled and is refused.
|
|
1127
1144
|
*/
|
|
1128
1145
|
async function act(btn){
|
|
1129
|
-
var
|
|
1130
|
-
var
|
|
1146
|
+
var i = Number(btn.dataset.n);
|
|
1147
|
+
var key = btn.dataset.k;
|
|
1148
|
+
var why = (document.getElementById("why-" + i) || {}).value || "";
|
|
1131
1149
|
var row = btn.closest(".req");
|
|
1132
1150
|
row.querySelectorAll("button,input").forEach(function(e){ e.disabled = true; });
|
|
1133
1151
|
try {
|
|
1134
1152
|
var r = await fetch("/api/decide", {
|
|
1135
1153
|
method: "POST",
|
|
1136
1154
|
headers: { "content-type": "application/json", "x-seisin-token": TOKEN },
|
|
1137
|
-
body: JSON.stringify({
|
|
1155
|
+
body: JSON.stringify({ key: key, decision: btn.dataset.d, reason: why })
|
|
1138
1156
|
});
|
|
1139
1157
|
var out = await r.json();
|
|
1140
1158
|
if (!r.ok) { var err = new Error(out.error || "denied"); err.status = r.status; throw err; }
|