seisin 0.4.0 → 0.5.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.
Files changed (50) hide show
  1. package/README.md +151 -67
  2. package/package.json +3 -3
  3. package/src/cli.js +145 -46
  4. package/src/commands/check.js +3 -2
  5. package/src/commands/explain.js +34 -13
  6. package/src/commands/hook.js +14 -4
  7. package/src/commands/init.js +126 -30
  8. package/src/commands/log.js +55 -10
  9. package/src/commands/requests.js +124 -39
  10. package/src/commands/review.js +41 -16
  11. package/src/commands/run.js +174 -39
  12. package/src/commands/scan.js +18 -3
  13. package/src/commands/ui.js +18 -5
  14. package/src/commands/walls.js +25 -6
  15. package/src/commands/whose.js +7 -6
  16. package/src/commands/wire.js +156 -17
  17. package/src/config.js +261 -27
  18. package/src/controls.js +244 -0
  19. package/src/diagnose.js +35 -33
  20. package/src/env.js +11 -8
  21. package/src/grants.js +15 -7
  22. package/src/help.js +159 -0
  23. package/src/hook.js +49 -19
  24. package/src/index.js +4 -4
  25. package/src/inspect.js +137 -71
  26. package/src/intake.js +24 -16
  27. package/src/keys.js +13 -11
  28. package/src/kinds.js +207 -0
  29. package/src/log.js +312 -55
  30. package/src/mcp.js +96 -43
  31. package/src/notify.js +15 -10
  32. package/src/owners.js +323 -41
  33. package/src/paths.js +37 -1
  34. package/src/redact.js +19 -5
  35. package/src/render.js +81 -12
  36. package/src/requests.js +250 -72
  37. package/src/review.js +42 -21
  38. package/src/rundir.js +4 -6
  39. package/src/scan.js +177 -19
  40. package/src/serve.js +395 -205
  41. package/src/spool.js +9 -29
  42. package/src/srt.js +38 -21
  43. package/src/suggest.js +58 -0
  44. package/src/surface.js +203 -42
  45. package/src/uilink.js +10 -4
  46. package/src/views.js +289 -0
  47. package/src/violations.js +104 -16
  48. package/src/walls.js +19 -42
  49. package/src/worktree.js +3 -11
  50. package/ui/index.html +1366 -529
package/src/mcp.js CHANGED
@@ -29,11 +29,12 @@
29
29
  import { toRepoRelative } from "./paths.js";
30
30
  import { loadConfig, findConfig } from "./config.js";
31
31
  import { inspect } from "./inspect.js";
32
- import { explain, ownersOf } from "./owners.js";
32
+ import { explain, explainFileRead, ownersOf, readTarget } from "./owners.js";
33
+ import { CREDENTIAL_HOMES, expand } from "./grants.js";
33
34
  import { settingsFor } from "./srt.js";
34
- import { pending, requestsPath, grantFor, refuseIfBarred, markStale } from "./requests.js";
35
+ import { pending, requestsPath, refuseIfBarred, shellId } from "./requests.js";
35
36
  import { read, logPath } from "./log.js";
36
- import { causesOf } from "./serve.js";
37
+ import { causesOf, parseSince, queue as requestQueue, verdicts, wallsByRole } from "./views.js";
37
38
  import { walls, wasted } from "./walls.js";
38
39
 
39
40
  /**
@@ -53,7 +54,7 @@ const TOOLS = [
53
54
  {
54
55
  name: "seisin_state",
55
56
  description:
56
- "The permission map: every role, the folders it may write, the keys it may read, " +
57
+ "The map: every role, its territory (the folders it may write), the keys it may read, " +
57
58
  "and every way the policy silently does not hold. Read-only.",
58
59
  inputSchema: {
59
60
  type: "object",
@@ -78,15 +79,15 @@ const TOOLS = [
78
79
  {
79
80
  name: "seisin_requests",
80
81
  description:
81
- "Permissions agents asked for and cannot have yet, waiting on a human, with how many " +
82
- "times each was asked. A refusal files one of these automatically, so this is where " +
83
- "to look after being denied rather than retrying. Read-only — approving is not " +
82
+ "Requests: access agents asked for and cannot have yet, waiting on a person, with how many " +
83
+ "times each was asked. A denial files one of these automatically, so this is where " +
84
+ "to look after being denied rather than retrying. Read-only — granting is not " +
84
85
  "available here, by design.",
85
86
  inputSchema: { type: "object", properties: {} },
86
87
  },
87
88
  {
88
89
  name: "seisin_activity",
89
- description: "Recent allow/deny decisions, newest last. Read-only.",
90
+ description: "Recent log entries — allowed, denied or observed — newest last. Read-only.",
90
91
  inputSchema: {
91
92
  type: "object",
92
93
  properties: {
@@ -100,13 +101,27 @@ const TOOLS = [
100
101
  name: "seisin_causes",
101
102
  description:
102
103
  "Denials grouped by what was denied — by path, and one level coarser by the name at " +
103
- "the end of it — with how many are on paths no role owns. This is the question " +
104
- "`seisin_activity` cannot answer: the raw log has the events, this has them counted " +
105
- "against the policy as it stands, so a cause that has been granted since stops " +
106
- "counting. Reach for it before proposing a change: a day that is three quarters one " +
107
- "filename is a tooling problem, and the same volume spread across unrelated paths is " +
108
- "a question about who owns what. Read-only.",
109
- inputSchema: { type: "object", properties: { limit: { type: "number" } } },
104
+ "the end of it — over the whole log, or since an ISO date. The counts are history: " +
105
+ "what was denied. What is measured against the policy as it stands is where each " +
106
+ "path is now — `standing`: unowned (a decision nobody has made), owned, protected " +
107
+ "(closed to every role on purpose, nothing to grant) or outside (a port, a key, or " +
108
+ "outside the repository) — and, per cause, `stillRefused`, how many of the roles " +
109
+ "that hit it would hit it today. An unowned cause also carries `kind` and `hint`: " +
110
+ "git (lock or metadata), credential, temporary, build or territory, and the move " +
111
+ "that fits it (`standing.unowned.kinds` counts them). Only `territory` is a question " +
112
+ "of who owns what; do not propose an owner for the others — a lock, a test's scratch " +
113
+ "or a credential given to whoever asked widens a role. " +
114
+ "`seisin_activity` has the raw events; this has " +
115
+ "them read against the policy. Reach for it before proposing a change: a day that " +
116
+ "is three quarters one filename is a tooling problem, and the same volume spread " +
117
+ "across unrelated unowned paths is a question about who owns what. Read-only.",
118
+ inputSchema: {
119
+ type: "object",
120
+ properties: {
121
+ limit: { type: "number" },
122
+ since: { type: "string", description: "ISO date; only denials at or after it" },
123
+ },
124
+ },
110
125
  },
111
126
  {
112
127
  name: "seisin_walls",
@@ -121,14 +136,16 @@ const TOOLS = [
121
136
  name: "seisin_draft_grant",
122
137
  description:
123
138
  "Draft the change a pending request would make, as text, plus the command a person " +
124
- "runs to apply it. Reach for this after a refusal: the denial is already in " +
125
- "seisin_requests, and this turns it into a concrete proposal someone can approve. " +
139
+ "runs to apply it. Reach for this after a denial: it is already in " +
140
+ "seisin_requests, and this turns it into a concrete proposal a person can grant. " +
126
141
  "Read-only, and it is the tool where that matters most: the name says grant and it " +
127
- "does not grant. The approval is a human action in another channel.",
142
+ "does not grant. Granting is a person's action, in another channel.",
128
143
  inputSchema: {
129
144
  type: "object",
130
- properties: { number: { type: "number", description: "position in seisin_requests" } },
131
- required: ["number"],
145
+ properties: {
146
+ id: { type: "string", description: "the request's id from seisin_requests — preferred: it does not move when the queue does" },
147
+ number: { type: "number", description: "position in seisin_requests (legacy; the draft still names the id)" },
148
+ },
132
149
  },
133
150
  },
134
151
  ];
@@ -166,12 +183,22 @@ const HANDLERS = {
166
183
  // not enforced is documentation.
167
184
  if (action !== "read" && action !== "write" && action !== "mcp")
168
185
  throw new Error(`action must be "read", "write" or "mcp", got ${JSON.stringify(action)}`);
186
+ if (typeof role !== "string" || !role)
187
+ throw new Error("role must be a non-empty string");
169
188
  if (typeof target !== "string" || !target)
170
189
  throw new Error("target must be a non-empty path");
171
190
  const cfg = config();
172
191
  if (!cfg.roles[role])
173
192
  return { error: `unknown role "${role}"`, known: Object.keys(cfg.roles) };
174
193
  if (action === "mcp") return explain(cfg, role, "mcp", target);
194
+ // A read of a file outside every key dir is open, as it is for the CLI.
195
+ // The server has no working directory of its own, so the root stands in.
196
+ if (action === "read") {
197
+ const asked = readTarget(cfg, target, cfg.root);
198
+ return asked.key
199
+ ? explain(cfg, role, "read", asked.target)
200
+ : explainFileRead(cfg, role, asked.target, CREDENTIAL_HOMES.map(expand));
201
+ }
175
202
  const rel = toRepoRelative(cfg, target);
176
203
  const verdict = explain(cfg, role, action, rel);
177
204
  return { ...verdict, alsoOwnedBy: action === "write" ? ownersOf(cfg, rel) : undefined };
@@ -179,17 +206,17 @@ const HANDLERS = {
179
206
 
180
207
  seisin_requests() {
181
208
  const cfg = config();
182
- const queue = markStale(pending(requestsPath(cfg.root)), read(logPath(cfg.root)));
209
+ const queue = requestQueue(cfg.root, read(logPath(cfg.root)), cfg.keyDirs);
183
210
  return {
184
211
  pending: queue.map((r, i) => ({
185
- number: i + 1, role: r.role, action: r.action,
212
+ number: i + 1, id: r.key, role: r.role, action: r.action,
186
213
  wants: r.grant, ownedBy: r.owners, asked: r.times, lastAsked: r.last,
187
214
  ...(r.stale ? { stale: `not asked again in ${r.stale.runs} runs of ${r.role} since` } : {}),
188
215
  })),
189
216
  // Said in the payload and not only in the tool description, because a
190
217
  // model reading this is deciding what to do next.
191
218
  note: queue.length
192
- ? "Approving is not available through MCP. Tell the operator to run: seisin grant <number>"
219
+ ? "Granting is not available through MCP. Ask a person to run: seisin grant '<id>'"
193
220
  : "nothing waiting",
194
221
  };
195
222
  },
@@ -203,13 +230,20 @@ const HANDLERS = {
203
230
  * and asks it to re-derive the grouping — without the policy, which is the
204
231
  * half that makes the grouping mean anything.
205
232
  */
206
- seisin_causes({ limit }) {
233
+ seisin_causes({ limit, since }) {
207
234
  const cfg = config();
208
- const c = causesOf(cfg, read(logPath(cfg.root), { limit: 4000 }));
235
+ // The whole log, as the console reads it. This read the last 4000 lines
236
+ // after the console had stopped doing so, and the two then disagreed about
237
+ // the same log — 3,999 refusals here against 6,630 there, measured — with
238
+ // nothing telling the agent its window was half of the person's.
239
+ const from = parseSince(since);
240
+ const c = causesOf(cfg, read(logPath(cfg.root), from ? { since: from } : {}), { ask: verdicts(cfg) });
209
241
  return {
210
242
  total: c.total,
211
243
  distinct: c.distinct,
244
+ ...(from && { since: from }),
212
245
  unowned: c.unowned,
246
+ standing: c.standing,
213
247
  families: c.families,
214
248
  causes: typeof limit === "number" ? c.causes.slice(0, limit) : c.causes,
215
249
  };
@@ -217,14 +251,15 @@ const HANDLERS = {
217
251
 
218
252
  seisin_walls({ role }) {
219
253
  const cfg = config();
220
- const file = logPath(cfg.root);
221
- const names = role ? [role] : Object.keys(cfg.roles);
222
254
  if (role && !cfg.roles[role]) throw new Error(`unknown role "${role}"`);
255
+ // One read of the log for every role, not one per role (32 on a real policy).
256
+ const denied = read(logPath(cfg.root), { verdict: "denied" });
257
+ const ask = verdicts(cfg);
258
+ const byRole = role
259
+ ? Object.fromEntries([[role, walls(cfg, role, { entries: denied, ask })]].filter(([, w]) => w.length))
260
+ : wallsByRole(cfg, denied, { ask });
223
261
  const out = {};
224
- for (const r of names) {
225
- const w = walls(cfg, r, { file });
226
- if (w.length) out[r] = { walls: w, spentRetrying: wasted(w) };
227
- }
262
+ for (const [r, w] of Object.entries(byRole)) out[r] = { walls: w, spentRetrying: wasted(w) };
228
263
  return { roles: out, spentRetrying: Object.values(out).reduce((n, x) => n + x.spentRetrying, 0) };
229
264
  },
230
265
 
@@ -237,12 +272,23 @@ const HANDLERS = {
237
272
  return { entries: read(logPath(cfg.root), { role, verdict, limit: n }) };
238
273
  },
239
274
 
240
- seisin_draft_grant({ number }) {
241
- if (!Number.isInteger(Number(number)) || Number(number) < 1)
242
- throw new Error("number must be a positive integer from seisin_requests");
275
+ seisin_draft_grant({ id, number } = {}) {
243
276
  const cfg = config();
244
- const req = pending(requestsPath(cfg.root))[Number(number) - 1];
245
- if (!req) throw new Error(`no pending request #${number}`);
277
+ const queue = pending(requestsPath(cfg.root), { keyDirs: cfg.keyDirs });
278
+ let req;
279
+ if (typeof id === "string" && id) {
280
+ req = queue.find((r) => r.key === id);
281
+ if (!req) throw new Error(`request ${id} is not pending`);
282
+ } else {
283
+ if (!Number.isInteger(Number(number)) || Number(number) < 1)
284
+ throw new Error("id (preferred) or number from seisin_requests is required");
285
+ req = queue[Number(number) - 1];
286
+ if (!req) throw new Error(`no pending request #${number}`);
287
+ }
288
+ // The command is written against the id, whatever was asked with: a
289
+ // number is a position in a queue agents keep writing to, and the person
290
+ // runs this later, against the queue as it is then.
291
+ const ref = shellId(req.key);
246
292
 
247
293
  // The same refusal `seisin grant` and the console apply, asked here first.
248
294
  // Without it this drafted, as approvable, a request that `never_writes`
@@ -255,7 +301,7 @@ const HANDLERS = {
255
301
  ownedBy: req.owners,
256
302
  applyWith: null,
257
303
  note: "Not approvable as it stands: never_writes wins over writes. Decline it with " +
258
- `\`seisin decline ${number}\`, or remove the never_writes entry if the subtraction is wrong.`,
304
+ `\`seisin decline ${ref}\`, or remove the never_writes entry if the subtraction is wrong.`,
259
305
  };
260
306
  }
261
307
  const field = req.action === "read" ? "keys" : "writes";
@@ -263,7 +309,8 @@ const HANDLERS = {
263
309
  wouldAdd: { role: req.role, field, value: req.grant },
264
310
  preview: `[roles.${req.role}]\n${field} = [ …, "${req.grant}" ] # asked ${req.times}×`,
265
311
  ownedBy: req.owners,
266
- applyWith: `seisin grant ${number} --reason "<why>"`,
312
+ id: req.key,
313
+ applyWith: `seisin grant ${ref} --reason "<why>"`,
267
314
  note: "Not applied. seisin's MCP server writes nothing — a person runs the command above.",
268
315
  };
269
316
  },
@@ -284,7 +331,10 @@ function handle(message, { write, version }) {
284
331
  const reply = (id, result) => send({ jsonrpc: "2.0", id, result });
285
332
  const fail = (id, code, msg) => send({ jsonrpc: "2.0", id, error: { code, message: msg } });
286
333
 
287
- const { id, method, params = {} } = message;
334
+ const { id, method } = message;
335
+ // `?? {}` so an explicit null params (not just missing) does not crash the
336
+ // dispatcher and leave the request unanswered.
337
+ const params = message.params ?? {};
288
338
 
289
339
  // A notification has no id and takes no answer. Replying to one is a protocol
290
340
  // error that some clients tolerate and others hang on.
@@ -298,9 +348,9 @@ function handle(message, { write, version }) {
298
348
  capabilities: { tools: {} },
299
349
  serverInfo: { name: "seisin", title: "seisin — folders and keys per agent", version },
300
350
  instructions:
301
- "Read-only view of this repo's agent permissions. Ask seisin_explain before " +
351
+ "Read-only view of this repo's roles, territories and keys. Ask seisin_explain before " +
302
352
  "suggesting an agent edit a file it may not own. Granting is not available " +
303
- "here: surface seisin_requests and let the operator run `seisin grant`.",
353
+ "here: surface seisin_requests and let a person run `seisin grant`.",
304
354
  });
305
355
  }
306
356
  case "ping":
@@ -310,8 +360,11 @@ function handle(message, { write, version }) {
310
360
  case "tools/list":
311
361
  return reply(id, { tools: TOOLS });
312
362
  case "tools/call": {
313
- const fn = HANDLERS[params.name];
314
- if (!fn) return fail(id, -32602, `unknown tool "${params.name}"`);
363
+ // hasOwn, not a bare lookup: `params.name` of "constructor" or
364
+ // "hasOwnProperty" would otherwise resolve to a function on Object's
365
+ // prototype and be invoked as a tool.
366
+ const fn = Object.hasOwn(HANDLERS, params.name) ? HANDLERS[params.name] : null;
367
+ if (typeof fn !== "function") return fail(id, -32602, `unknown tool ${JSON.stringify(params.name)}`);
315
368
  try {
316
369
  const result = fn(params.arguments ?? {});
317
370
  // Content, not a bare object: every client renders `content`, and only
package/src/notify.js CHANGED
@@ -17,20 +17,20 @@
17
17
  * never holds the URL, and `SEISIN_NOTIFY_URL` is dropped from its
18
18
  * environment even if the role names it;
19
19
  * - the URL is not readable from any territory. A ntfy topic or a webhook is
20
- * a credential — whoever can read it can send the person a fake "approve
20
+ * a credential — whoever can read it can send the person a fake "grant
21
21
  * #3". So it lives in a key directory no role declares (`url_file`), or in
22
22
  * the parent's environment, and never as a plain value in seisin.toml,
23
23
  * which every role can read.
24
24
  */
25
25
  import { readFileSync } from "node:fs";
26
26
  import { basename, join } from "node:path";
27
- import { keyOf, pending, requestsPath, grantFor } from "./requests.js";
27
+ import { keyOf, pending, requestsPath, grantFor, shellId } from "./requests.js";
28
28
 
29
- export const FORMATS = ["text", "json", "slack"];
30
- export const ENV_URL = "SEISIN_NOTIFY_URL";
29
+ const FORMATS = ["text", "json", "slack"];
30
+ const ENV_URL = "SEISIN_NOTIFY_URL";
31
31
 
32
32
  /** The URL to notify, or null. Read in the parent only. */
33
- export function notifyUrl(config, env = process.env) {
33
+ function notifyUrl(config, env = process.env) {
34
34
  if (env[ENV_URL]) return env[ENV_URL].trim();
35
35
  if (!config.notify?.urlFile) return null;
36
36
  try {
@@ -45,14 +45,19 @@ export function notifyUrl(config, env = process.env) {
45
45
  export function message(config, req, number, format = "text") {
46
46
  const owner = req.owners?.length ? `It belongs to ${req.owners.join(", ")}.` : "Nobody owns it.";
47
47
  const text =
48
- `seisin (${basename(config.root)}): ${req.role} was refused ${req.action} on ${req.target}. ${owner} ` +
49
- `Approve: seisin grant ${number} · decline: seisin decline ${number}`;
50
- if (format === "slack") return { type: "application/json", body: JSON.stringify({ text }) };
48
+ `seisin (${basename(config.root)}): ${req.role} was denied ${req.action} on ${req.target}. ${owner} ` +
49
+ `Grant: seisin grant ${shellId(keyOf(req))} · decline: seisin decline ${shellId(keyOf(req))}`;
50
+ // Slack reads `<url|label>` as a link and `<!channel>` as a mention, and the
51
+ // path is chosen by the agent: `<https://evil|approve here>` in a target
52
+ // would arrive as a clickable link in the channel a person approves from.
53
+ // Its own escaping, and only these three characters, is what it asks for.
54
+ if (format === "slack")
55
+ return { type: "application/json", body: JSON.stringify({ text: text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;") }) };
51
56
  if (format === "json")
52
57
  return {
53
58
  type: "application/json",
54
59
  body: JSON.stringify({
55
- text, repo: basename(config.root), number, role: req.role, action: req.action,
60
+ text, repo: basename(config.root), number, id: keyOf(req), role: req.role, action: req.action,
56
61
  target: req.target, owners: req.owners ?? [], grant: grantFor(req),
57
62
  }),
58
63
  };
@@ -141,6 +146,6 @@ export function readNotify(table, { path, root, keyDirs, roles, own }) {
141
146
  if (holders.length)
142
147
  throw new Error(
143
148
  `${path}: [notify] url_file "${urlFile}" is declared as a key by ${holders.join(", ")} — ` +
144
- `that role could read it and send the person a fake "approve". No role may hold it.`);
149
+ `that role could read it and send the person a fake "grant this". No role may hold it.`);
145
150
  return { urlFile, format };
146
151
  }