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/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, writeFileSync, existsSync } from "node:fs";
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(), owners: e.owners ?? [] };
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 — they had
107
- * already counted that. The console was repeating the log back.
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
- * These two are different, because they need the **policy** and the log does
110
- * not contain it. `unowned` is the count of causes on paths no role claims:
111
- * no grant resolves those until somebody decides who owns them, which is a
112
- * decision rather than a number. `settled` is friction that has since been
113
- * granted — real yesterday, noise today.
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 live = [...by.values()];
116
- const unowned = live.filter((g) => !(g.owners ?? []).length).length;
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
- owners: g.owners,
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, { number, decision, reason }) {
239
+ function decide(configPath, { key, decision, reason }) {
224
240
  const cfg = loadConfig(configPath);
225
241
  const file = requestsPath(cfg.root);
226
- const req = pending(file)[Number(number) - 1];
227
- if (!req) throw new Error(`no pending request #${number}`);
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
- const { toml, changed } = applyGrant(readFileSync(cfg.path, "utf8"), req, reason);
237
- if (changed) writeFileSync(cfg.path, toml);
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
- const authorized = (req) => req.headers["x-seisin-token"] === token;
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: abs(config.path ?? CONFIG_NAME), why: "the policy" },
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/anthropic-experimental/sandbox-runtime" target="_blank" rel="noopener"><i>↗</i>sandbox-runtime</a>
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,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;");}
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,"&quot;").replace(/'/g,"&#39;");}
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
- var cold = c.stillDeclined === 0;
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.owners && c.owners.length ? ' · owned by ' + esc(c.owners.join(", ")) : ' · owned by nobody') +
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 number.
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="' + (i+1) + '" data-d="granted" type="button">Approve</button>' +
1070
- '<button class="btn warn" data-n="' + (i+1) + '" data-d="denied" type="button">Decline</button>' +
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
- * The buttons go dead on click because a double-approve is not idempotent
1126
- * here — the second one would be numbered against a queue that already moved.
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 n = Number(btn.dataset.n);
1130
- var why = (document.getElementById("why-" + (n-1)) || {}).value || "";
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({ number: n, decision: btn.dataset.d, reason: why })
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; }