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.
- package/README.md +151 -67
- package/package.json +3 -3
- package/src/cli.js +145 -46
- package/src/commands/check.js +3 -2
- package/src/commands/explain.js +34 -13
- package/src/commands/hook.js +14 -4
- package/src/commands/init.js +126 -30
- package/src/commands/log.js +55 -10
- package/src/commands/requests.js +124 -39
- package/src/commands/review.js +41 -16
- package/src/commands/run.js +174 -39
- package/src/commands/scan.js +18 -3
- package/src/commands/ui.js +18 -5
- package/src/commands/walls.js +25 -6
- package/src/commands/whose.js +7 -6
- package/src/commands/wire.js +156 -17
- package/src/config.js +261 -27
- package/src/controls.js +244 -0
- package/src/diagnose.js +35 -33
- package/src/env.js +11 -8
- package/src/grants.js +15 -7
- package/src/help.js +159 -0
- package/src/hook.js +49 -19
- package/src/index.js +4 -4
- package/src/inspect.js +137 -71
- package/src/intake.js +24 -16
- package/src/keys.js +13 -11
- package/src/kinds.js +207 -0
- package/src/log.js +312 -55
- package/src/mcp.js +96 -43
- package/src/notify.js +15 -10
- package/src/owners.js +323 -41
- package/src/paths.js +37 -1
- package/src/redact.js +19 -5
- package/src/render.js +81 -12
- package/src/requests.js +250 -72
- package/src/review.js +42 -21
- package/src/rundir.js +4 -6
- package/src/scan.js +177 -19
- package/src/serve.js +395 -205
- package/src/spool.js +9 -29
- package/src/srt.js +38 -21
- package/src/suggest.js +58 -0
- package/src/surface.js +203 -42
- package/src/uilink.js +10 -4
- package/src/views.js +289 -0
- package/src/violations.js +104 -16
- package/src/walls.js +19 -42
- package/src/worktree.js +3 -11
- 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,
|
|
35
|
+
import { pending, requestsPath, refuseIfBarred, shellId } from "./requests.js";
|
|
35
36
|
import { read, logPath } from "./log.js";
|
|
36
|
-
import { causesOf } from "./
|
|
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
|
|
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
|
-
"
|
|
82
|
-
"times each was asked. A
|
|
83
|
-
"to look after being denied rather than retrying. Read-only —
|
|
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
|
|
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 —
|
|
104
|
-
"
|
|
105
|
-
"
|
|
106
|
-
"
|
|
107
|
-
"
|
|
108
|
-
"
|
|
109
|
-
|
|
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
|
|
125
|
-
"seisin_requests, and this turns it into a concrete proposal
|
|
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.
|
|
142
|
+
"does not grant. Granting is a person's action, in another channel.",
|
|
128
143
|
inputSchema: {
|
|
129
144
|
type: "object",
|
|
130
|
-
properties: {
|
|
131
|
-
|
|
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 =
|
|
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
|
-
? "
|
|
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
|
-
|
|
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
|
|
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
|
|
245
|
-
|
|
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 ${
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
314
|
-
|
|
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 "
|
|
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
|
-
|
|
30
|
-
|
|
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
|
-
|
|
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
|
|
49
|
-
`
|
|
50
|
-
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">") }) };
|
|
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 "
|
|
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
|
}
|