seisin 0.1.1 → 0.3.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 +287 -339
- package/package.json +7 -6
- package/src/cli.js +17 -3
- package/src/commands/explain.js +9 -2
- package/src/commands/hook.js +11 -0
- package/src/commands/init.js +1 -1
- package/src/commands/log.js +27 -1
- package/src/commands/requests.js +8 -3
- package/src/commands/review.js +33 -5
- package/src/commands/run.js +180 -151
- package/src/commands/ui.js +16 -2
- package/src/commands/walls.js +30 -0
- package/src/commands/whose.js +2 -1
- package/src/commands/wire.js +30 -5
- package/src/config.js +310 -5
- package/src/diagnose.js +172 -0
- package/src/env.js +54 -1
- package/src/grants.js +239 -0
- package/src/hook.js +45 -5
- package/src/index.js +12 -0
- package/src/inspect.js +332 -9
- package/src/intake.js +215 -0
- package/src/keys.js +585 -0
- package/src/log.js +96 -2
- package/src/mcp.js +87 -11
- package/src/nested.js +86 -0
- package/src/notify.js +146 -0
- package/src/owners.js +208 -6
- package/src/paths.js +50 -0
- package/src/render.js +63 -1
- package/src/requests.js +88 -0
- package/src/review.js +9 -4
- package/src/rundir.js +113 -0
- package/src/serve.js +263 -19
- package/src/spool.js +98 -20
- package/src/srt.js +148 -203
- package/src/surface.js +518 -0
- package/src/violations.js +37 -5
- package/src/walls.js +184 -0
- package/ui/favicon.svg +1 -1
- package/ui/index.html +585 -56
package/src/config.js
CHANGED
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
import { readFileSync, existsSync } from "node:fs";
|
|
12
12
|
import { resolve, dirname, join } from "node:path";
|
|
13
13
|
import { CONFIG_NAME } from "./layout.js";
|
|
14
|
+
import { parseKey, MODES, checkNames, BUILTIN } from "./keys.js";
|
|
15
|
+
import { readNotify } from "./notify.js";
|
|
14
16
|
|
|
15
17
|
export { CONFIG_NAME } from "./layout.js";
|
|
16
18
|
|
|
@@ -170,10 +172,34 @@ function readValue(value, lineNo) {
|
|
|
170
172
|
expectItem = true; i++; continue;
|
|
171
173
|
}
|
|
172
174
|
if (!expectItem) bad(`missing comma before ${JSON.stringify(body.slice(i, i + 12))}`);
|
|
175
|
+
// Whole numbers, for `local_ports`. Digits only: no sign, no decimal, no
|
|
176
|
+
// underscore — a port is none of those, and every other key still
|
|
177
|
+
// refuses a number through asArray.
|
|
178
|
+
const digits = /^\d+/.exec(body.slice(i));
|
|
179
|
+
if (digits) {
|
|
180
|
+
const after = body[i + digits[0].length];
|
|
181
|
+
if (after !== undefined && after !== "," && after !== " " && after !== "\t")
|
|
182
|
+
bad(`${JSON.stringify(body.slice(i, i + 12))} is not a whole number`);
|
|
183
|
+
items.push(Number(digits[0]));
|
|
184
|
+
i += digits[0].length;
|
|
185
|
+
expectItem = false;
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
173
188
|
if (c !== '"') bad(`array items must be double-quoted, found ${JSON.stringify(body.slice(i, i + 12))}`);
|
|
174
189
|
|
|
175
190
|
const end = body.indexOf('"', i + 1);
|
|
176
191
|
if (end === -1) bad("unterminated string inside the array");
|
|
192
|
+
// A backslash before the closing quote means somebody wrote `\"` and
|
|
193
|
+
// expected an escape. There are none — the quote just ended the string,
|
|
194
|
+
// and the error they get otherwise is about a missing comma somewhere in
|
|
195
|
+
// the middle of a shell pipeline, which names the symptom and hides the
|
|
196
|
+
// cause. Measured while writing the docs: every attempt at a one-line
|
|
197
|
+
// provider command landed here.
|
|
198
|
+
if (body[end - 1] === "\\")
|
|
199
|
+
bad(`there are no escapes in this format, so the \\" in ` +
|
|
200
|
+
`${JSON.stringify(body.slice(i, Math.min(end + 2, i + 40)))} ended the string early.\n` +
|
|
201
|
+
` A value cannot contain a double quote. If this is a shell command, put it in a ` +
|
|
202
|
+
`script and name the script instead — which is also how it stops being unreadable.`);
|
|
177
203
|
items.push(body.slice(i + 1, end));
|
|
178
204
|
i = end + 1;
|
|
179
205
|
expectItem = false; // a trailing comma is fine; a trailing item is not
|
|
@@ -181,7 +207,9 @@ function readValue(value, lineNo) {
|
|
|
181
207
|
return items;
|
|
182
208
|
}
|
|
183
209
|
|
|
184
|
-
|
|
210
|
+
if (/^\d+$/.test(v)) return Number(v);
|
|
211
|
+
|
|
212
|
+
bad("value must be a string, an array of strings, a whole number, or a boolean");
|
|
185
213
|
}
|
|
186
214
|
|
|
187
215
|
/**
|
|
@@ -273,6 +301,7 @@ export function loadConfig(path) {
|
|
|
273
301
|
// is how a tool gets kept out.
|
|
274
302
|
const keyDir = own(own(parsed, "keys"), "dir");
|
|
275
303
|
const keyDirs = keyDir === undefined ? [] : asArray(keyDir, "keys.dir");
|
|
304
|
+
const keyProviders = readProviders(own(own(parsed, "keys"), "providers"), path);
|
|
276
305
|
// `undefined` means "use the defaults"; an explicit empty array means "none".
|
|
277
306
|
// The difference matters: one is a user who has not thought about it, the
|
|
278
307
|
// other is a user who has.
|
|
@@ -280,7 +309,7 @@ export function loadConfig(path) {
|
|
|
280
309
|
const runtimeWrites = own(runtime, "writes");
|
|
281
310
|
|
|
282
311
|
const out = {
|
|
283
|
-
root: dirname(path), path, keyDirs,
|
|
312
|
+
root: dirname(path), path, keyDirs, keyProviders,
|
|
284
313
|
allowedDomains: own(own(parsed, "network"), "allow") ?? [],
|
|
285
314
|
runtimeWrites: runtimeWrites === undefined ? undefined : asArray(runtimeWrites, "runtime.writes"),
|
|
286
315
|
// `[runtime] isolate` — how much of your home a role stops reaching. See
|
|
@@ -296,22 +325,298 @@ export function loadConfig(path) {
|
|
|
296
325
|
const r = own(roles, name);
|
|
297
326
|
const writes = asArray(own(r, "writes"), `roles.${name}.writes`);
|
|
298
327
|
const keys = asArray(own(r, "keys"), `roles.${name}.keys`);
|
|
299
|
-
|
|
328
|
+
// Read once, here, so every consumer sees the same answer about what an
|
|
329
|
+
// entry is. `keys` itself stays the array of strings that was written: it
|
|
330
|
+
// is what `render`, `serve` and `init` print, and the whole point of a
|
|
331
|
+
// reference is that the written form is the reviewable one.
|
|
332
|
+
const keyEntries = keys.map((k) => {
|
|
333
|
+
try {
|
|
334
|
+
return parseKey(k);
|
|
335
|
+
} catch (e) {
|
|
336
|
+
throw new Error(`${path}: roles.${name}: ${e.message}`);
|
|
337
|
+
}
|
|
338
|
+
});
|
|
339
|
+
// Only a path needs somewhere to live. A reference does not, and demanding
|
|
340
|
+
// `[keys] dir` from a config whose secrets are all in a vault would be the
|
|
341
|
+
// tool insisting on the arrangement it was built for.
|
|
342
|
+
if (keyEntries.some((k) => k.kind === "file") && keyDirs.length === 0)
|
|
300
343
|
throw new Error(`${path}: roles.${name} lists keys, but [keys] dir is not set`);
|
|
344
|
+
/**
|
|
345
|
+
* An unknown scheme is refused, not ignored.
|
|
346
|
+
*
|
|
347
|
+
* Same rule as everywhere else here: a `keys = ["vault://x"]` with no
|
|
348
|
+
* `[keys.providers.vault]` is a configuration error, and the alternative —
|
|
349
|
+
* treating it as a filename, or as a key that quietly never arrives — is a
|
|
350
|
+
* policy that reads as protecting something it is not.
|
|
351
|
+
*/
|
|
352
|
+
for (const k of keyEntries) {
|
|
353
|
+
// `file://` ships with seisin, so it needs no declaration. It is the one
|
|
354
|
+
// builtin, and the reason is in keys.js: a secret in a plain file is the
|
|
355
|
+
// case this exists for, and making that case declare a provider that
|
|
356
|
+
// runs `cat` is a papercut on the only path most people take.
|
|
357
|
+
if (k.kind !== "ref" || BUILTIN.has(k.scheme) || own(keyProviders, k.scheme)) continue;
|
|
358
|
+
const known = Object.keys(keyProviders);
|
|
359
|
+
throw new Error(
|
|
360
|
+
`${path}: roles.${name}: key "${k.raw}" uses the "${k.scheme}" scheme, and no ` +
|
|
361
|
+
`[keys.providers.${k.scheme}] is declared.\n` +
|
|
362
|
+
` ${known.length ? `Declared providers: ${known.join(", ")}.` : "No providers are declared."}\n` +
|
|
363
|
+
` A provider is a command with a placeholder:\n` +
|
|
364
|
+
` [keys.providers.${k.scheme}]\n` +
|
|
365
|
+
` command = ["your-cli", "read", "{ref}"]`);
|
|
366
|
+
}
|
|
367
|
+
checkNames({ name, keyEntries });
|
|
368
|
+
const keyMode = own(r, "key_mode");
|
|
369
|
+
if (keyMode !== undefined && !MODES.includes(keyMode))
|
|
370
|
+
throw new Error(
|
|
371
|
+
`${path}: roles.${name}: key_mode = "${keyMode}" is not a delivery mode. ` +
|
|
372
|
+
`Known: ${MODES.join(", ")}.`);
|
|
373
|
+
const neverWrites = readNeverWrites(own(r, "never_writes"), `${path}: roles.${name}.never_writes`);
|
|
374
|
+
const localBinding = own(r, "local_binding");
|
|
375
|
+
const trustd = own(r, "trustd");
|
|
376
|
+
if (trustd !== undefined && typeof trustd !== "boolean")
|
|
377
|
+
throw new Error(`${path}: roles.${name}.trustd must be true or false, not ${JSON.stringify(trustd)}`);
|
|
378
|
+
if (localBinding !== undefined && typeof localBinding !== "boolean")
|
|
379
|
+
throw new Error(`${path}: roles.${name}.local_binding must be true or false, not ${JSON.stringify(localBinding)}`);
|
|
380
|
+
const localPorts = readLocalPorts(own(r, "local_ports"), `${path}: roles.${name}.local_ports`);
|
|
381
|
+
const mcp = readMcp(own(r, "mcp"), `${path}: roles.${name}.mcp`);
|
|
301
382
|
out.roles[name] = {
|
|
302
383
|
name,
|
|
303
|
-
|
|
384
|
+
/**
|
|
385
|
+
* `writes` is what is GRANTED; `writesDeclared` is what was written.
|
|
386
|
+
*
|
|
387
|
+
* Expanded here and not at emit time, because two answers is how the
|
|
388
|
+
* document ends up looser or tighter than the boundary. Everything
|
|
389
|
+
* downstream — `ownersOf`, `explain`, `whose`, the settings, the console
|
|
390
|
+
* — reads `writes`, so the owner of a database is the owner of its `-wal`
|
|
391
|
+
* everywhere, and the kernel and the sentence agree. The declared form is
|
|
392
|
+
* kept so that regenerating a config writes back what a person typed
|
|
393
|
+
* rather than the expansion.
|
|
394
|
+
*/
|
|
395
|
+
writes: withSidecars(writes),
|
|
396
|
+
writesDeclared: writes,
|
|
304
397
|
keys,
|
|
398
|
+
keyEntries,
|
|
399
|
+
keyMode: keyMode ?? null,
|
|
305
400
|
env: asArray(own(r, "env"), `roles.${name}.env`),
|
|
306
401
|
network: own(r, "network") === undefined ? null : asArray(own(r, "network"), `roles.${name}.network`),
|
|
402
|
+
/**
|
|
403
|
+
* What this role may never write, even inside its own `writes`.
|
|
404
|
+
*
|
|
405
|
+
* A subtraction, and the only one the format has. It exists for the case
|
|
406
|
+
* a territory glob cannot say: `repo/**` minus `repo/.git/index.lock`, for
|
|
407
|
+
* a role that works in a worktree and has its own index. Without it the
|
|
408
|
+
* alternative is enumerating every top-level entry of the repo but `.git`
|
|
409
|
+
* — a list that goes stale the day somebody adds a directory.
|
|
410
|
+
*
|
|
411
|
+
* Per role and never global: a global deny on `.git/index.lock` breaks
|
|
412
|
+
* every role that legitimately commits in the shared checkout. Absent
|
|
413
|
+
* means exactly what it meant before the key existed.
|
|
414
|
+
*/
|
|
415
|
+
// Sidecars too: `data/app.sqlite` minus its `-wal` is not a subtraction.
|
|
416
|
+
// A fabricated -wal is applied to the database the next time it opens.
|
|
417
|
+
neverWrites: withSidecars(neverWrites),
|
|
418
|
+
neverWritesDeclared: neverWrites,
|
|
419
|
+
/**
|
|
420
|
+
* Whether this role may listen on a local port — a dev server, the
|
|
421
|
+
* backend an end-to-end test drives, a test database.
|
|
422
|
+
*
|
|
423
|
+
* Off unless the role says so, and per role because most roles never
|
|
424
|
+
* start a server: an agent that edits docs has no business accepting
|
|
425
|
+
* connections. Wider than the name: the runtime also lets the role listen
|
|
426
|
+
* on every interface and connect to every localhost port. See `check`.
|
|
427
|
+
*/
|
|
428
|
+
localBinding: localBinding === true,
|
|
429
|
+
/**
|
|
430
|
+
* The ports on this machine the role may connect to, and no others.
|
|
431
|
+
*
|
|
432
|
+
* The narrow form of reaching a local service. `local_binding` opens
|
|
433
|
+
* every port on localhost (macOS), which includes whatever control API
|
|
434
|
+
* is listening there without a password. This names the ports instead,
|
|
435
|
+
* and the runtime's proxy enforces the list — the same allowlist the
|
|
436
|
+
* domains go through. See srt.js for what that costs: the client has to
|
|
437
|
+
* go through the proxy, which HTTP clients do and a MySQL driver does not.
|
|
438
|
+
*/
|
|
439
|
+
localPorts,
|
|
440
|
+
/**
|
|
441
|
+
* The MCP servers this role may load, by name, or null when the policy
|
|
442
|
+
* does not say. `[]` is "none". seisin declares this and does not
|
|
443
|
+
* enforce it: MCP servers are started by the agent's CLI, so the launcher
|
|
444
|
+
* builds the CLI's MCP config from this list (Claude Code:
|
|
445
|
+
* `--mcp-config` with only these + `--strict-mcp-config`).
|
|
446
|
+
*/
|
|
447
|
+
mcp,
|
|
448
|
+
/**
|
|
449
|
+
* Whether this role may reach com.apple.trustd.agent (macOS). Needed by
|
|
450
|
+
* Dart/Flutter and by Go built before 1.27 to verify TLS at all; Go 1.27+
|
|
451
|
+
* does without it through the SSL_CERT_FILE that env.js sets. Off unless
|
|
452
|
+
* the role says so. See srt.js for what it costs.
|
|
453
|
+
*/
|
|
454
|
+
trustd: trustd === true,
|
|
455
|
+
// Kept so `check` can name a misspelt key instead of ignoring it. An
|
|
456
|
+
// unknown key in a role table used to be dropped silently, and for a
|
|
457
|
+
// subtraction that is failing open: `never_write` would read as a rule
|
|
458
|
+
// and subtract nothing.
|
|
459
|
+
unknownKeys: Object.keys(r).filter((k) => !ROLE_KEYS.includes(k)),
|
|
307
460
|
};
|
|
308
461
|
}
|
|
462
|
+
out.notify = readNotify(own(parsed, "notify"), { path, root: out.root, keyDirs, roles: out.roles, own });
|
|
309
463
|
return out;
|
|
310
464
|
}
|
|
311
465
|
|
|
466
|
+
/**
|
|
467
|
+
* `[keys.providers.<name>]` — the commands that turn a reference into a value.
|
|
468
|
+
*
|
|
469
|
+
* Validated here and not at resolution time, because the whole argument for
|
|
470
|
+
* references is that a broken policy should be visible in `seisin check`,
|
|
471
|
+
* without asking anybody's keychain for a password to find out.
|
|
472
|
+
*
|
|
473
|
+
* `{ref}` is required. A command without it resolves every reference to the
|
|
474
|
+
* same thing, which is the kind of mistake that looks like it works: the first
|
|
475
|
+
* key comes back, so does the second, and they are both the first one.
|
|
476
|
+
*/
|
|
477
|
+
function readProviders(table, path) {
|
|
478
|
+
const out = Object.create(null);
|
|
479
|
+
if (table === undefined) return out;
|
|
480
|
+
for (const name of Object.keys(table)) {
|
|
481
|
+
const p = own(table, name);
|
|
482
|
+
// Redefining `file://` is refused rather than honoured. Two configs where
|
|
483
|
+
// the same scheme means different things is the failure this whole feature
|
|
484
|
+
// is supposed to remove — and it would be invisible, because the policy
|
|
485
|
+
// reads identically in both.
|
|
486
|
+
if (BUILTIN.has(name))
|
|
487
|
+
throw new Error(
|
|
488
|
+
`${path}: [keys.providers.${name}] — "${name}://" is built in and cannot be redefined.\n` +
|
|
489
|
+
` It reads a file, optionally one KEY out of it: "file://.secrets/all.env#TOKEN".\n` +
|
|
490
|
+
` For different behaviour, give your provider another name.`);
|
|
491
|
+
const command = own(p, "command");
|
|
492
|
+
if (!Array.isArray(command) || command.length === 0 || command.some((c) => typeof c !== "string"))
|
|
493
|
+
throw new Error(
|
|
494
|
+
`${path}: [keys.providers.${name}] needs a command, as an array of strings.\n` +
|
|
495
|
+
` e.g. command = ["security", "find-generic-password", "-w", "-s", "{ref}"]`);
|
|
496
|
+
if (!command.some((c) => c.includes("{ref}")))
|
|
497
|
+
throw new Error(
|
|
498
|
+
`${path}: [keys.providers.${name}] has no {ref} in its command, so every key of this ` +
|
|
499
|
+
`provider would resolve to the same value.\n` +
|
|
500
|
+
` Put {ref} where the reference goes: command = [${command.map((c) => `"${c}"`).join(", ")}, "{ref}"]`);
|
|
501
|
+
const mode = own(p, "mode");
|
|
502
|
+
if (mode !== undefined && !MODES.includes(mode))
|
|
503
|
+
throw new Error(
|
|
504
|
+
`${path}: [keys.providers.${name}] mode = "${mode}" is not a delivery mode. ` +
|
|
505
|
+
`Known: ${MODES.join(", ")}.`);
|
|
506
|
+
out[name] = { name, command, mode: mode ?? null };
|
|
507
|
+
}
|
|
508
|
+
return out;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* A SQLite database is four files, and a policy should say it once.
|
|
513
|
+
*
|
|
514
|
+
* `bitacora/x/equipo.sqlite` is not the database — it is one of the files the
|
|
515
|
+
* database is made of. Writing to it in WAL mode also writes `-wal` and
|
|
516
|
+
* `-shm`; in rollback mode, `-journal`. So "this role writes this database"
|
|
517
|
+
* takes four lines, and a policy with eight databases and thirty-one roles
|
|
518
|
+
* takes nine hundred and ninety-two of them. Measured on a real one: 744 of
|
|
519
|
+
* 1,248 write lines — 60% — were sidecars, and every single one had its
|
|
520
|
+
* `.sqlite` declared beside it. Not one case wanted the `-wal` without the
|
|
521
|
+
* database.
|
|
522
|
+
*
|
|
523
|
+
* **This grants no authority that was not already there**, which is the whole
|
|
524
|
+
* reason it is safe to do implicitly. The `-wal` holds that database's pending
|
|
525
|
+
* transactions: whoever can write the database can already empty it, replace
|
|
526
|
+
* it, or corrupt it. The sidecar is not a second resource — it is the same one.
|
|
527
|
+
*
|
|
528
|
+
* It is also not a glob. The kernel grants a path and everything under it, so
|
|
529
|
+
* `equipo.sqlite*` is unenforceable and `seisin check` refuses it; these are
|
|
530
|
+
* emitted as literal paths, exactly as if they had been typed.
|
|
531
|
+
*
|
|
532
|
+
* **The suffix list is closed on purpose** and this is the line that keeps the
|
|
533
|
+
* idea from becoming the exception list it could be: three suffixes, and only
|
|
534
|
+
* after a name that says SQLite. `.db` is deliberately not in it — plenty of
|
|
535
|
+
* things are called `.db` and a grant to create `whatever.db-wal` inside a
|
|
536
|
+
* directory somebody else owns would be new authority, arriving quietly. A
|
|
537
|
+
* database with another name declares its sidecars by hand, as before.
|
|
538
|
+
*/
|
|
539
|
+
const SIDECARS = ["-wal", "-shm", "-journal"];
|
|
540
|
+
const IS_SQLITE = /\.sqlite3?$/;
|
|
541
|
+
|
|
542
|
+
export function withSidecars(paths) {
|
|
543
|
+
const out = [];
|
|
544
|
+
for (const p of paths) {
|
|
545
|
+
out.push(p);
|
|
546
|
+
if (IS_SQLITE.test(p)) for (const s of SIDECARS) out.push(p + s);
|
|
547
|
+
}
|
|
548
|
+
// A policy written before this existed already lists them. Keeping the first
|
|
549
|
+
// occurrence keeps the order somebody chose.
|
|
550
|
+
return [...new Set(out)];
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/** The keys a `[roles.<name>]` table can hold. Anything else is reported by `check`. */
|
|
554
|
+
export const ROLE_KEYS = ["writes", "keys", "key_mode", "env", "network", "never_writes", "local_binding", "local_ports", "mcp", "trustd"];
|
|
555
|
+
|
|
556
|
+
/**
|
|
557
|
+
* `mcp`: names of MCP servers, as the CLI's config calls them. Absent is null,
|
|
558
|
+
* not [], so a policy that never mentions MCP changes nothing for a launcher
|
|
559
|
+
* that reads it; `mcp = []` is the explicit "none".
|
|
560
|
+
*/
|
|
561
|
+
function readMcp(value, where) {
|
|
562
|
+
if (value === undefined) return null;
|
|
563
|
+
const list = asArray(value, where);
|
|
564
|
+
const out = [];
|
|
565
|
+
for (const n of list) {
|
|
566
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(n))
|
|
567
|
+
throw new Error(`${where}: ${JSON.stringify(n)} is not an MCP server name. ` +
|
|
568
|
+
`Use the name from the CLI's config, for example mcp = ["playwright"].`);
|
|
569
|
+
if (!out.includes(n)) out.push(n);
|
|
570
|
+
}
|
|
571
|
+
return out;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* `local_ports`, refused rather than guessed when it is not a list of ports.
|
|
576
|
+
*
|
|
577
|
+
* Refused at load for the same reason as `never_writes`: a port list that loads
|
|
578
|
+
* with a typo in it opens a port nobody meant, or silently fails to open the
|
|
579
|
+
* one they did. A string is accepted when it is only digits, because TOML
|
|
580
|
+
* written by hand says `"8001"` as often as `8001`.
|
|
581
|
+
*/
|
|
582
|
+
function readLocalPorts(value, where) {
|
|
583
|
+
if (value === undefined) return [];
|
|
584
|
+
const list = Array.isArray(value) ? value : [value];
|
|
585
|
+
const out = [];
|
|
586
|
+
for (const v of list) {
|
|
587
|
+
const n = typeof v === "string" && /^\d+$/.test(v) ? Number(v) : v;
|
|
588
|
+
if (!Number.isInteger(n) || n < 1 || n > 65535)
|
|
589
|
+
throw new Error(`${where}: ${JSON.stringify(v)} is not a port. Write numbers from 1 to 65535, ` +
|
|
590
|
+
`for example local_ports = [8001, 8081].`);
|
|
591
|
+
if (!out.includes(n)) out.push(n);
|
|
592
|
+
}
|
|
593
|
+
return out;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/**
|
|
597
|
+
* `never_writes`, refused rather than guessed when it cannot mean what it says.
|
|
598
|
+
*
|
|
599
|
+
* Refused at load, not warned about: this is a list of things a role must not
|
|
600
|
+
* do, and a malformed entry that loads anyway fails open. An absolute path or a
|
|
601
|
+
* `..` would point outside the repo the rest of the policy is written against,
|
|
602
|
+
* so it cannot be the subtraction the person meant.
|
|
603
|
+
*/
|
|
604
|
+
function readNeverWrites(value, where) {
|
|
605
|
+
const list = asArray(value, where);
|
|
606
|
+
for (const g of list) {
|
|
607
|
+
if (typeof g !== "string" || g.trim() === "")
|
|
608
|
+
throw new Error(`${where}: every entry must be a non-empty string`);
|
|
609
|
+
if (g.startsWith("/"))
|
|
610
|
+
throw new Error(`${where}: "${g}" is absolute. never_writes is relative to the repo root, like writes.`);
|
|
611
|
+
if (g.split("/").includes(".."))
|
|
612
|
+
throw new Error(`${where}: "${g}" contains "..". never_writes names paths inside the repo.`);
|
|
613
|
+
}
|
|
614
|
+
return list;
|
|
615
|
+
}
|
|
616
|
+
|
|
312
617
|
function asArray(value, where) {
|
|
313
618
|
if (value === undefined) return [];
|
|
314
619
|
if (typeof value === "string") return [value];
|
|
315
|
-
if (Array.isArray(value)) return value;
|
|
620
|
+
if (Array.isArray(value) && value.every((v) => typeof v === "string")) return value;
|
|
316
621
|
throw new Error(`${where}: expected a string or an array of strings`);
|
|
317
622
|
}
|
package/src/diagnose.js
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The refusal the agent never heard, said after the fact.
|
|
3
|
+
*
|
|
4
|
+
* PreToolUse sees the path a tool call names, and that is all it sees. What the
|
|
5
|
+
* kernel refuses on its own — a child process, `/bin/rm` inside a script, a
|
|
6
|
+
* path the regex did not catch — reaches the agent as `Operation not permitted`
|
|
7
|
+
* with no path and no reason, and it retries. Measured on a real portfolio from
|
|
8
|
+
* 2026-09-14 to 2026-09-22: 5,617 refusals, all of them from the kernel, 72%
|
|
9
|
+
* the same role hitting the same path again.
|
|
10
|
+
*
|
|
11
|
+
* Two hooks close that, the shape nono uses for its sandbox diagnostics:
|
|
12
|
+
*
|
|
13
|
+
* - after a tool call fails (PostToolUseFailure, and PostToolUse for Bash,
|
|
14
|
+
* which reports a failed command as a result): read this role's kernel
|
|
15
|
+
* refusals from the last moments and hand the agent the same sentence the
|
|
16
|
+
* PreToolUse hook would have — whose it is, how many times, and that it
|
|
17
|
+
* should hand it over rather than retry. The kernel's line reaches the log
|
|
18
|
+
* about 30 ms after the refusal (measured inside the box), so this reads it.
|
|
19
|
+
* - when a session starts, resumes or is compacted (SessionStart): the role's
|
|
20
|
+
* territory and what it keeps being refused, before the first refusal —
|
|
21
|
+
* and right after compaction, which is when an agent forgets what it was
|
|
22
|
+
* already told.
|
|
23
|
+
*
|
|
24
|
+
* What it does not do, on purpose: suggest widening. nono's message offers
|
|
25
|
+
* `--allow /path`; that assumes the profile was too narrow. seisin assumes the
|
|
26
|
+
* file is somebody else's, and the way out is handing it to them.
|
|
27
|
+
*
|
|
28
|
+
* On Linux the runtime's refusals cannot be read from outside (violations.js),
|
|
29
|
+
* so the after-the-fact half finds nothing there and stays quiet.
|
|
30
|
+
*/
|
|
31
|
+
import { closeSync, existsSync, openSync, readSync, statSync } from "node:fs";
|
|
32
|
+
import { explain } from "./owners.js";
|
|
33
|
+
import { timesHit, walls } from "./walls.js";
|
|
34
|
+
import { collapseSidecars } from "./render.js";
|
|
35
|
+
|
|
36
|
+
/** What a refused write or read looks like in a tool's output. A trigger to look, not a verdict. */
|
|
37
|
+
export const DENIAL_SIGNS = /Operation not permitted|EPERM|EACCES|Permission denied|Read-only file system/i;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Refusals the runtime's profile makes that never reach the log, recognised by
|
|
41
|
+
* what the failing program printed.
|
|
42
|
+
*
|
|
43
|
+
* The log holds what the kernel refused on a path or a port. A Mach service
|
|
44
|
+
* registration is neither: the profile refuses it, the program dies with its
|
|
45
|
+
* own words, and nothing in `.seisin/` says why. Each entry here is one of those
|
|
46
|
+
* signatures, measured, with the sentence that sends the agent to the fix
|
|
47
|
+
* instead of to a retry. Kept to exact signatures — a guess here would be an
|
|
48
|
+
* explanation for a failure seisin did not cause.
|
|
49
|
+
*/
|
|
50
|
+
export const KNOWN_REFUSALS = [
|
|
51
|
+
{
|
|
52
|
+
// Measured 2026-09-23 with Playwright's Chromium under seisin on macOS. It
|
|
53
|
+
// cost a team an afternoon: the error names a Mach port, not a file.
|
|
54
|
+
sign: /bootstrap_check_in\s+org\.chromium\.Chromium\.MachPortRendezvousServer\S*:\s*Permission denied \(1100\)/,
|
|
55
|
+
say: "Chromium could not register its Mach port: the sandbox does not allow it, and no grant " +
|
|
56
|
+
"changes that. Launch it with --single-process (Playwright: launchOptions.args) and one worker.",
|
|
57
|
+
},
|
|
58
|
+
];
|
|
59
|
+
|
|
60
|
+
/** The known refusals whose signature is in this output. */
|
|
61
|
+
export function knownRefusals(evidence) {
|
|
62
|
+
return KNOWN_REFUSALS.filter((k) => k.sign.test(evidence)).map((k) => k.say);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** How far back a refusal still belongs to the call that just failed. */
|
|
66
|
+
const WINDOW_MS = 120_000;
|
|
67
|
+
/** Read only the end of the log: this runs on every failed command. */
|
|
68
|
+
const TAIL_BYTES = 512 * 1024;
|
|
69
|
+
/** How many paths to name. More than this is a wall of text nobody acts on. */
|
|
70
|
+
const MAX_NAMED = 3;
|
|
71
|
+
|
|
72
|
+
function tail(file) {
|
|
73
|
+
if (!existsSync(file)) return [];
|
|
74
|
+
const { size } = statSync(file);
|
|
75
|
+
const from = Math.max(0, size - TAIL_BYTES);
|
|
76
|
+
const fd = openSync(file, "r");
|
|
77
|
+
try {
|
|
78
|
+
const buf = Buffer.alloc(size - from);
|
|
79
|
+
readSync(fd, buf, 0, buf.length, from);
|
|
80
|
+
const lines = buf.toString("utf8").split("\n");
|
|
81
|
+
if (from > 0) lines.shift(); // the first line is cut in half
|
|
82
|
+
const out = [];
|
|
83
|
+
for (const l of lines) {
|
|
84
|
+
if (!l.trim()) continue;
|
|
85
|
+
try { out.push(JSON.parse(l)); } catch { /* a line being written */ }
|
|
86
|
+
}
|
|
87
|
+
return out;
|
|
88
|
+
} finally {
|
|
89
|
+
closeSync(fd);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** This role's refusals by the kernel since `sinceMs`, one per path, newest first. */
|
|
94
|
+
export function recentKernelDenials(file, role, sinceMs) {
|
|
95
|
+
const since = new Date(sinceMs).toISOString();
|
|
96
|
+
const seen = new Map();
|
|
97
|
+
for (const e of tail(file).reverse()) {
|
|
98
|
+
if (e.role !== role || e.verdict !== "denied" || e.source !== "kernel") continue;
|
|
99
|
+
if (!e.at || e.at < since || !e.target) continue;
|
|
100
|
+
const k = `${e.action}\u0000${e.target}`;
|
|
101
|
+
if (!seen.has(k)) seen.set(k, e);
|
|
102
|
+
}
|
|
103
|
+
return [...seen.values()];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** The sentence for one refusal, in the words the PreToolUse hook uses. */
|
|
107
|
+
function sentence(config, role, file, e) {
|
|
108
|
+
const action = e.kind === "key" ? "read" : e.action === "read" || e.action === "connect" ? e.action : "write";
|
|
109
|
+
const v = explain(config, role, action, e.target);
|
|
110
|
+
if (v.allowed) return null; // the policy moved since: nothing to say
|
|
111
|
+
const times = timesHit(file, role, action, e.target);
|
|
112
|
+
const again = times > 1 ? ` Refused ${times} times now; the next try ends the same way.` : "";
|
|
113
|
+
if (v.neverWrites) return `${v.reason}. Do not ask for it.${again}`;
|
|
114
|
+
if (v.owners?.length)
|
|
115
|
+
return `${e.target} belongs to ${v.owners.join(", ")}. It is not ${role}'s to change — ` +
|
|
116
|
+
`hand it over rather than working around it.${again}`;
|
|
117
|
+
return `${e.target}: ${v.reason}.${again}`;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const PREFACE =
|
|
121
|
+
"seisin — the sandbox this agent runs in — refused part of that. This is not a Unix permission " +
|
|
122
|
+
"or a macOS privacy prompt: chmod, sudo or a different path to the same file will not change it.";
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* After a tool call failed. Returns the hook output, or null to say nothing.
|
|
126
|
+
*
|
|
127
|
+
* `wait` retries the read a few times: the kernel's line lands ~30 ms after the
|
|
128
|
+
* refusal, and a loaded machine can be slower than the hook.
|
|
129
|
+
*/
|
|
130
|
+
export async function afterTool(config, role, event, { file, now = Date.now, wait = [0, 100, 300] } = {}) {
|
|
131
|
+
const evidence = JSON.stringify({ r: event.tool_response, e: event.error, o: event.tool_output });
|
|
132
|
+
if (!DENIAL_SIGNS.test(evidence)) return null;
|
|
133
|
+
// JSON escapes what the program printed; the signatures are written against the text.
|
|
134
|
+
const known = knownRefusals(evidence.replace(/\\n/g, "\n").replace(/\\"/g, '"'));
|
|
135
|
+
let found = [];
|
|
136
|
+
for (const ms of wait) {
|
|
137
|
+
if (ms) await new Promise((ok) => setTimeout(ok, ms));
|
|
138
|
+
found = recentKernelDenials(file, role, now() - WINDOW_MS);
|
|
139
|
+
if (found.length) break;
|
|
140
|
+
}
|
|
141
|
+
const lines = [...known, ...found.map((e) => sentence(config, role, file, e)).filter(Boolean)].slice(0, MAX_NAMED);
|
|
142
|
+
if (!lines.length) return null;
|
|
143
|
+
const total = known.length + found.length;
|
|
144
|
+
const more = total > MAX_NAMED ? ` (and ${total - MAX_NAMED} more — \`seisin walls ${role}\`)` : "";
|
|
145
|
+
return {
|
|
146
|
+
hookSpecificOutput: {
|
|
147
|
+
hookEventName: event.hook_event_name,
|
|
148
|
+
additionalContext: [PREFACE, ...lines.map((l) => `- ${l}`)].join("\n") + more,
|
|
149
|
+
},
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** When a session starts, resumes or is compacted: the map, before the first wall. */
|
|
154
|
+
export function atSessionStart(config, role, event, { file } = {}) {
|
|
155
|
+
const r = config.roles[role];
|
|
156
|
+
const writes = collapseSidecars(r.writesDeclared ?? r.writes);
|
|
157
|
+
const lines = [
|
|
158
|
+
`seisin: this session runs as the role "${role}". The kernel enforces it; nothing inside can widen it.`,
|
|
159
|
+
`You may write: ${writes.length ? writes.join(", ") : "nothing"}. Reading the rest of the repo is fine.`,
|
|
160
|
+
];
|
|
161
|
+
if ((r.neverWritesDeclared ?? r.neverWrites ?? []).length)
|
|
162
|
+
lines.push(`Never, even inside that: ${(r.neverWritesDeclared ?? r.neverWrites).join(", ")}.`);
|
|
163
|
+
if (r.keys.length) lines.push(`Keys you hold: ${r.keys.join(", ")}.`);
|
|
164
|
+
const w = file ? walls(config, role, { file, limit: 5, fresh: true }) : [];
|
|
165
|
+
if (w.length) {
|
|
166
|
+
lines.push("Already refused more than once, and still refused — do not try again, hand it over:");
|
|
167
|
+
for (const x of w)
|
|
168
|
+
lines.push(`- ${x.action} ${x.target} (${x.times}×) — ${x.owners?.length ? `belongs to ${x.owners.join(", ")}` : x.reason}`);
|
|
169
|
+
}
|
|
170
|
+
lines.push("A file outside that is somebody else's: say whose it is and hand it over, rather than working around it.");
|
|
171
|
+
return { hookSpecificOutput: { hookEventName: "SessionStart", additionalContext: lines.join("\n") } };
|
|
172
|
+
}
|
package/src/env.js
CHANGED
|
@@ -10,6 +10,9 @@
|
|
|
10
10
|
* unless it is named. The base list below is what a command needs to be a
|
|
11
11
|
* command at all, and it deliberately contains no credential-shaped names.
|
|
12
12
|
*/
|
|
13
|
+
import { existsSync } from "node:fs";
|
|
14
|
+
|
|
15
|
+
const SYSTEM_BUNDLE = "/etc/ssl/cert.pem";
|
|
13
16
|
|
|
14
17
|
/**
|
|
15
18
|
* What survives with no policy at all.
|
|
@@ -57,7 +60,34 @@ export const BASE = [
|
|
|
57
60
|
* A role that wants the old behaviour names `GIT_OPTIONAL_LOCKS` in its `env`
|
|
58
61
|
* and sets it in the parent — the loop below then overwrites this.
|
|
59
62
|
*/
|
|
60
|
-
export const DEFAULTS = {
|
|
63
|
+
export const DEFAULTS = {
|
|
64
|
+
GIT_OPTIONAL_LOCKS: "0",
|
|
65
|
+
/**
|
|
66
|
+
* Node's built-in `fetch()` ignores HTTP_PROXY/HTTPS_PROXY unless told
|
|
67
|
+
* otherwise, so inside the box it tries DNS directly and dies with ENOTFOUND
|
|
68
|
+
* while `npm`, which reads the proxy itself, works. Measured on Node 24.2:
|
|
69
|
+
* ENOTFOUND without it, 200 with it. It only makes Node use the proxy the
|
|
70
|
+
* sandbox already routes everything through — nothing is widened. Older
|
|
71
|
+
* Nodes ignore the variable.
|
|
72
|
+
*/
|
|
73
|
+
NODE_USE_ENV_PROXY: "1",
|
|
74
|
+
/**
|
|
75
|
+
* The system's CA bundle, when there is one at this path (macOS ships it).
|
|
76
|
+
*
|
|
77
|
+
* Go 1.27+ verifies against SSL_CERT_FILE instead of asking the system when
|
|
78
|
+
* the variable is set ("the native Go verifier is used", go1.27 notes), and
|
|
79
|
+
* asking the system means com.apple.trustd.agent, which the sandbox closes.
|
|
80
|
+
* Measured: `go get` fails with `x509: OSStatus -26276` without it and works
|
|
81
|
+
* with it, with trustd still shut. Binaries built with Go before 1.27 (gh
|
|
82
|
+
* 2.89 is 1.26) ignore it; for those, and for Dart, see `trustd = true`.
|
|
83
|
+
*
|
|
84
|
+
* Only if the file exists: on a system without it, pointing every TLS client
|
|
85
|
+
* at a missing file would break the ones that work today. And a parent that
|
|
86
|
+
* sets its own SSL_CERT_FILE or SSL_CERT_DIR — a corporate CA — keeps it;
|
|
87
|
+
* see buildEnv.
|
|
88
|
+
*/
|
|
89
|
+
...(existsSync(SYSTEM_BUNDLE) ? { SSL_CERT_FILE: SYSTEM_BUNDLE } : {}),
|
|
90
|
+
};
|
|
61
91
|
|
|
62
92
|
/**
|
|
63
93
|
* Names that must never ride along, even if a role asks for them by pattern.
|
|
@@ -87,8 +117,31 @@ export function buildEnv(parent, role, extra = []) {
|
|
|
87
117
|
for (const [name, value] of Object.entries(parent)) {
|
|
88
118
|
if (!wanted.has(name)) { dropped.push(name); continue; }
|
|
89
119
|
if (NEVER.test(name) && !(role.env ?? []).includes(name)) { dropped.push(name); continue; }
|
|
120
|
+
// Not even when named: whoever can read the notify URL can send the person
|
|
121
|
+
// a fake "approve #3". Only the parent uses it (notify.js).
|
|
122
|
+
if (name === "SEISIN_NOTIFY_URL") { dropped.push(name); continue; }
|
|
90
123
|
env[name] = value;
|
|
91
124
|
}
|
|
125
|
+
// A trust store the parent chose is a decision about who to trust, not an
|
|
126
|
+
// incidental variable: it crosses whether or not the role named it, and it
|
|
127
|
+
// replaces the default rather than sitting next to it. They are paths, never
|
|
128
|
+
// secrets. A role that names SSL_CERT_FILE while the parent has none opts out
|
|
129
|
+
// of the default.
|
|
130
|
+
// A parent that already says whether Node should use the proxy is answered
|
|
131
|
+
// as it asked — `=0` included — rather than overwritten by the default.
|
|
132
|
+
if (parent.NODE_USE_ENV_PROXY !== undefined) env.NODE_USE_ENV_PROXY = parent.NODE_USE_ENV_PROXY;
|
|
133
|
+
// A role with `trustd = true` can ask the system verifier, which sees the
|
|
134
|
+
// Keychain — corporate CAs included — and /etc/ssl/cert.pem does not. Setting
|
|
135
|
+
// the bundle there would make Go 1.27+ skip the verifier the role was given
|
|
136
|
+
// trustd for, and fail behind a corporate proxy (review of 2026-09-22, #7).
|
|
137
|
+
if (role.trustd === true) delete env.SSL_CERT_FILE;
|
|
138
|
+
if (parent.SSL_CERT_FILE || parent.SSL_CERT_DIR) {
|
|
139
|
+
delete env.SSL_CERT_FILE;
|
|
140
|
+
for (const k of ["SSL_CERT_FILE", "SSL_CERT_DIR"])
|
|
141
|
+
if (parent[k]) { env[k] = parent[k]; const i = dropped.indexOf(k); if (i !== -1) dropped.splice(i, 1); }
|
|
142
|
+
} else if ((role.env ?? []).includes("SSL_CERT_FILE")) {
|
|
143
|
+
delete env.SSL_CERT_FILE;
|
|
144
|
+
}
|
|
92
145
|
// A role may name a variable the parent does not have. That is not an error —
|
|
93
146
|
// it is a policy written ahead of the environment, which is the right order.
|
|
94
147
|
return { env, dropped: dropped.sort() };
|