seisin 0.2.0 → 0.4.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 CHANGED
@@ -1,12 +1,13 @@
1
1
  # seisin
2
2
 
3
- [![test](https://github.com/carlostapiaolguin3-stack/seisin/actions/workflows/test.yml/badge.svg)](https://github.com/carlostapiaolguin3-stack/seisin/actions/workflows/test.yml)
3
+ [![test](https://github.com/carlostapiacl/seisin/actions/workflows/test.yml/badge.svg)](https://github.com/carlostapiacl/seisin/actions/workflows/test.yml)
4
+ [![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/carlostapiacl/seisin/badge)](https://scorecard.dev/viewer/?uri=github.com/carlostapiacl/seisin)
4
5
  [![npm](https://img.shields.io/npm/v/seisin?color=222)](https://www.npmjs.com/package/seisin)
5
6
  [![license](https://img.shields.io/badge/license-MIT-222)](LICENSE)
6
7
  [![node](https://img.shields.io/badge/node-%E2%89%A518-222)](package.json)
7
8
  [![no dependencies](https://img.shields.io/badge/dependencies-1-222)](package.json)
8
9
 
9
- **[The short version, with the demos playing →](https://carlostapiaolguin3-stack.github.io/seisin/)** · this page is the long one.
10
+ **[The short version, with the demos playing →](https://carlostapiacl.github.io/seisin/)** · this page is the long one.
10
11
 
11
12
  > **seisin** *(n.)* — the legal possession of a piece of land. Not who owns it on paper: who holds it now.
12
13
 
@@ -57,9 +58,9 @@ flowchart LR
57
58
  style H fill:#eef,stroke:#66c
58
59
  ```
59
60
 
60
- **The kernel says no. seisin says whose.** That second half is the whole thing: every other
61
- permission layer in this space answers *yes* or *no*, and a refusal that also names an owner
62
- turns a dead end into a handoff.
61
+ **The kernel says no. seisin says whose.** That second half is the whole thing: other
62
+ permission layers in this space answer *yes* or *no*, or at best *which rule* said no — and a
63
+ refusal that also names an owner turns a dead end into a handoff.
63
64
 
64
65
  ![seisin denying a write outside a role's territory, then naming the owner](docs/img/demo.gif)
65
66
 
@@ -76,7 +77,7 @@ $ seisin run frontend -- sh -c 'echo // fix >> src/api/orders.ts'
76
77
  seisin grant <n> [--reason "…"] · seisin decline <n> [--reason "…"]
77
78
  ```
78
79
 
79
- **Whose it was, at the moment it was denied.** Every other permission layer in this space answers *yes* or *no*. Answering **"no, and it belongs to `backend`"** turns a block into a handoff — and one a person can approve in a command, rather than a line somebody has to remember to go and read.
80
+ **Whose it was, at the moment it was denied.** Other permission layers answer *yes* or *no*, and the good ones say which rule it was and how to widen it. Answering **"no, and it belongs to `backend`"** turns a block into a handoff — and one a person can approve in a command, rather than a line somebody has to remember to go and read.
80
81
 
81
82
  The kernel is what denies; the name comes from the policy. That first line is all the boundary itself can say — no path, no reason, nothing to read afterwards — so seisin reads the refusal out of the kernel's own log and answers the question it leaves open. On Linux only the hook can do that; [the ask is upstream](docs/upstream/cli-violations.md) as [issue #582](https://github.com/anthropics/sandbox-runtime/issues/582).
82
83
 
@@ -249,6 +250,47 @@ role, including the ones whose whole job is to have nothing to reach.
249
250
  > **Use an HTTPS remote for git**, and run SSH deploys outside the confined turn.
250
251
  > [Why, exactly, and whose gap it is →](docs/decisions.md#ssh-does-not-work-and-it-is-not-seisin-that-decided-that)
251
252
 
253
+ A role can also give something back. `never_writes` subtracts from its own `writes`, and it
254
+ wins however wide the grant is:
255
+
256
+ ```toml
257
+ [roles.backend]
258
+ writes = ["services/api/**"]
259
+ never_writes = ["services/api/.git/index.lock"] # works in a worktree; the canonical index is not its business
260
+ ```
261
+
262
+ It is per role on purpose — there is no global version, because a global deny on a lock file
263
+ breaks every role that legitimately commits there. A refusal it causes says so by name and
264
+ leaves **no request** in the queue: approving one would undo a subtraction somebody wrote. An
265
+ entry that no `writes` of the same role covers, or a misspelling like `never_write`, is named
266
+ by `seisin check` instead of being ignored.
267
+
268
+ A role that starts a server — a dev server, the backend an end-to-end test drives — needs
269
+ `local_binding = true`. Listening is off by default and per role: most roles never start one.
270
+ It is wider than the name: the role can also listen on every interface and **connect to every
271
+ port on localhost**, so anything listening there without authentication is within its reach.
272
+ `seisin check` says this next to the role. That is macOS: on Linux each role already has a
273
+ private loopback, serves without the key, and reaches nothing on the host either way.
274
+
275
+ If a role only needs to *reach* a local service, name the ports instead:
276
+ `local_ports = [8001, 8081]`. The role reaches those and nothing else on localhost, on macOS
277
+ and Linux. The sandbox's proxy enforces the list, so the client has to use the proxy. curl,
278
+ Node's `fetch` and Python's `urllib` do. A database driver or a raw socket don't, and get
279
+ refused. For Chromium, pass Playwright's `proxy` option from `HTTP_PROXY`; on macOS it also
280
+ needs `--single-process` to start. A listed port is open to anything that speaks `CONNECT`, not
281
+ just HTTP, so list the service, not the protocol.
282
+
283
+ `mcp = ["playwright"]` lists the MCP servers a role may load; `mcp = []` means none, and no
284
+ key means the policy doesn't say. seisin declares this and `explain` answers it
285
+ (`seisin explain qa mcp tradingview`), but the servers are started by the agent's CLI, so the
286
+ launcher has to apply it. With Claude Code: `--mcp-config` holding only those servers, plus
287
+ `--strict-mcp-config`.
288
+
289
+ Refused connections are logged too: `connect tcp:<port>` (the kernel gives the port, not the
290
+ host) or the socket path, once per target per run. `seisin walls` says whether the port is
291
+ missing from `local_ports`, listed but dialled directly, or a socket. No request is filed: a
292
+ port isn't anyone's territory. macOS only.
293
+
252
294
  `seisin init` will propose this from whatever your repo already says: `.claude/agents/`, then `CODEOWNERS`, then a blank start. It **proposes** — a generated policy you did not read is not a policy.
253
295
 
254
296
  ## What will look like a bug on the first day
@@ -354,6 +396,24 @@ writes = ["src/api/**"] # granted 2026-09-12 · asked 3× · "frontend owns ch
354
396
  person applies it, in a terminal or in the console. That asymmetry is deliberate and it is
355
397
  the reason the MCP server exists at all.
356
398
 
399
+ **A person hears about it.** With `[notify]`, the parent process sends one message per new
400
+ request — the role, the path, whose it is, and `seisin grant <n>` — to ntfy, Slack or any
401
+ webhook. The URL lives in a key directory no role declares (`url_file`) or in the parent's
402
+ `SEISIN_NOTIFY_URL`; written plainly into `seisin.toml`, which every role can read, it refuses to
403
+ load, because whoever reads it can send you a fake "approve".
404
+
405
+ ```toml
406
+ [notify]
407
+ url_file = ".secrets/notify-url.txt" # e.g. https://ntfy.sh/<your-topic>
408
+ format = "text" # or "slack", "json"
409
+ ```
410
+
411
+ **And so does the agent.** `seisin wire` installs the hook for four events. Before a tool call
412
+ it explains a refusal it can see coming; after a command fails it reads the kernel's own refusal
413
+ from the log and tells the agent whose the path was and how many times it has been refused —
414
+ the half PreToolUse never sees; and when a session starts, resumes or is compacted it hands the
415
+ agent its territory and its walls, before the first one.
416
+
357
417
  **[How a denial becomes a request, why it deduplicates, and what a grant records →](docs/permission-requests.md)**
358
418
 
359
419
  ## Ask your own assistant
@@ -614,12 +674,13 @@ This space already has good work, and seisin is not the first thing here:
614
674
  - [`XuebinMa/agent-guard`](https://github.com/XuebinMa/agent-guard) — a permission-enforcement SDK, also one global policy.
615
675
  - [`NVIDIA/OpenShell`](https://github.com/NVIDIA/openshell) — a runtime that sandboxes an agent with Landlock and seccomp, under a declarative policy. Serious, and the closest thing here by weight.
616
676
  - [`dredozubov/hazmat`](https://github.com/dredozubov/hazmat) — runs the agent as a different system user, with `pf` rules and snapshots.
677
+ - [`nolabs-ai/nono`](https://github.com/nolabs-ai/nono) — a kernel sandbox for agents on the host (Seatbelt, Landlock), with per-tool child sandboxes, a credential proxy with endpoint filtering, approval webhooks and a tamper-evident audit log. The most complete sandbox here, and on a Mac the one that enforces through the same kernel seisin does. `nono why` explains *which rule* denied something and how to allow it; it has no notion of *whose* it was.
617
678
  - *Directory ownership* is recommended in half the multi-agent write-ups. As far as I could find, nobody enforces it, and nobody names the owner in the refusal. That gap is the reason for this repo.
618
679
 
619
680
  ### One sentence to tell these apart, because the names all sound the same
620
681
 
621
- **OpenShell and hazmat isolate the agent from your machine. seisin separates roles from each
622
- other inside one repository, and when it blocks something it names the owner.**
682
+ **OpenShell, hazmat and nono isolate the agent from your machine. seisin separates roles from
683
+ each other inside one repository, and when it blocks something it names the owner.**
623
684
 
624
685
  Those are different problems and the first one is not the one this solves. If what you want
625
686
  is "this agent cannot touch anything outside its box", they do that and OpenShell does it
@@ -643,7 +704,7 @@ measured](docs/what-it-has-been-put-through.md#where-each-claim-was-actually-run
643
704
 
644
705
  ## Status
645
706
 
646
- `0.2.0`, 326 tests, of which **26 need `@anthropic-ai/sandbox-runtime` installed**
707
+ `0.4.0`, 470 tests, of which **26 need `@anthropic-ai/sandbox-runtime` installed**
647
708
  and run real commands through the real kernel — and CI fails if the sandbox half *skips*, because
648
709
  a green run that quietly tested nothing looks exactly like a real one. That is not hypothetical:
649
710
  those eighteen skipped on Linux for a day, behind a runtime check that looked for the global
@@ -664,7 +725,8 @@ already done:
664
725
  | **Windows** | the runtime has a backend. seisin has never been pointed at it, and no CI runner covers it |
665
726
  | **Deleting inside your own territory** | not covered, and not coverable here — the ask is upstream as [issue #545](https://github.com/anthropics/sandbox-runtime/issues/545), open and unanswered since 2026-09-13, [with the measurement behind it](docs/upstream/denyUnlink.md) and [a demo](docs/demo/) |
666
727
  | **`init` heuristics** | it reads `.claude/agents/` then `CODEOWNERS`. Every other convention is a guess nobody has made yet |
667
- | **SSH inside a turn** | the transport exists — the runtime's SOCKS proxy filters by `(port, host)` — and the `ProxyCommand` it wires up cannot authenticate to it, so `git` over SSH dies at the handshake. [The ask](docs/upstream/ssh-proxycommand.md) is written and verified, not filed. Use an HTTPS remote |
728
+ | **SSH inside a turn** | the `ProxyCommand` the runtime sets can't authenticate to its own proxy, so `git` over SSH fails at the handshake. Fix upstream: [PR #516](https://github.com/anthropics/sandbox-runtime/pull/516). Use an HTTPS remote |
729
+ | **A browser inside a role (macOS)** | Chromium only starts with `--single-process`, which is unstable with several browsers. Fix upstream: [PR #598](https://github.com/anthropics/sandbox-runtime/pull/598). [Measurement](docs/upstream/mach-register.md) |
668
730
 
669
731
  Every word above has one meaning, listed in [the glossary](docs/glossary.md) — the boundary
670
732
  **denies**, a person **declines**, seisin **refuses** a config it cannot enforce. A tool whose
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "seisin",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "A permission layer for AI coding agents: role-scoped folders and keys, enforced by the OS, and when it blocks it tells you whose file it was.",
5
5
  "keywords": [
6
6
  "ai-agents",
@@ -17,13 +17,13 @@
17
17
  ],
18
18
  "license": "MIT",
19
19
  "author": "Carlos Tapia",
20
- "homepage": "https://carlostapiaolguin3-stack.github.io/seisin/",
20
+ "homepage": "https://carlostapiacl.github.io/seisin/",
21
21
  "repository": {
22
22
  "type": "git",
23
- "url": "git+https://github.com/carlostapiaolguin3-stack/seisin.git"
23
+ "url": "git+https://github.com/carlostapiacl/seisin.git"
24
24
  },
25
25
  "bugs": {
26
- "url": "https://github.com/carlostapiaolguin3-stack/seisin/issues"
26
+ "url": "https://github.com/carlostapiacl/seisin/issues"
27
27
  },
28
28
  "type": "module",
29
29
  "bin": {
@@ -39,10 +39,11 @@
39
39
  "node": ">=18"
40
40
  },
41
41
  "dependencies": {
42
- "@anthropic-ai/sandbox-runtime": "^0.0.76"
42
+ "@anthropic-ai/sandbox-runtime": "0.0.77"
43
43
  },
44
44
  "scripts": {
45
- "test": "node --test test/*.test.js"
45
+ "test": "node --test test/*.test.js",
46
+ "prepublishOnly": "npm test"
46
47
  },
47
48
  "main": "./src/index.js",
48
49
  "exports": {
package/src/cli.js CHANGED
@@ -47,16 +47,17 @@ ${C.b}seisin${C.off} — give each agent its own folders and its own keys
47
47
  seisin explain <role> read|write <path>
48
48
  seisin whose <path> who owns it — safe to call from inside the box
49
49
  seisin scan find secrets outside the declared key dirs
50
- seisin review what the log says about the policy
51
- seisin walls <role> what that role keeps being denied, and what it cost
50
+ seisin review [--all] what the log says about the policy
51
+ seisin walls <role> [--all] what that role keeps being denied, and what it cost
52
52
  seisin wire let the agent record what it does
53
53
  seisin requests what the agents asked for and cannot have
54
54
  seisin grant <n> [--reason "…"] approve one, with its provenance
55
55
  seisin decline <n> [--reason "…"] turn one down, and record why
56
56
  seisin log [--role r] [--verdict denied]
57
+ seisin log verify does the log's hash chain hold
57
58
  seisin watch follow the log live
58
59
  seisin init [--from-observations] propose a ${CONFIG_NAME} for this repo
59
- seisin ui [--port n] open the console, live
60
+ seisin ui [--port n] [--link] open the console, live (--link reopens a running one)
60
61
  seisin mcp an MCP server on stdio — read-only
61
62
 
62
63
  Enforcement comes from @anthropic-ai/sandbox-runtime, which asks the OS.
@@ -5,6 +5,7 @@
5
5
  * That is the only reason the exit code is not always 0 — a denial is an answer,
6
6
  * not a failure, but a shell has one channel for both.
7
7
  */
8
+ import { toRepoRelative } from "../paths.js";
8
9
  import { isAbsolute } from "node:path";
9
10
  import { explain } from "../owners.js";
10
11
  import { twinsOf, whereIs } from "../worktree.js";
@@ -13,9 +14,15 @@ import { renderVerdict, C, out } from "../render.js";
13
14
  export function explainCommand(config, argv = []) {
14
15
  const [role, action, target] = argv;
15
16
  if (!role || !action || !target)
16
- throw new Error("usage: seisin explain <role> <read|write> <path-or-key>");
17
+ throw new Error("usage: seisin explain <role> <read|write|mcp> <path, key or MCP server>");
17
18
  if (!config.roles[role]) throw new Error(`unknown role "${role}"`);
18
19
 
20
+ if (action === "mcp") {
21
+ const verdict = explain(config, role, "mcp", target);
22
+ out(renderVerdict(role, "mcp", target, verdict));
23
+ return { ...verdict, worktree: [] };
24
+ }
25
+
19
26
  const verb = action.startsWith("r") ? "read" : "write";
20
27
 
21
28
  /**
@@ -34,7 +41,7 @@ export function explainCommand(config, argv = []) {
34
41
  * because that is a question about somewhere else and it still deserves its
35
42
  * honest "no owner".
36
43
  */
37
- const asked = target.startsWith(config.root + "/") ? target.slice(config.root.length + 1) : target;
44
+ const asked = toRepoRelative(config, target);
38
45
 
39
46
  const verdict = explain(config, role, verb, asked);
40
47
  out(renderVerdict(role, verb, target, verdict));
@@ -9,6 +9,8 @@
9
9
  */
10
10
  import { findConfig, loadConfig } from "../config.js";
11
11
  import { decide } from "../hook.js";
12
+ import { afterTool, atSessionStart } from "../diagnose.js";
13
+ import { logPath } from "../log.js";
12
14
  import { flush } from "../spool.js";
13
15
 
14
16
  export async function hook(stdin = process.stdin, env = process.env) {
@@ -33,6 +35,15 @@ export async function hook(stdin = process.stdin, env = process.env) {
33
35
  }
34
36
  if (!config.roles[role]) return null;
35
37
 
38
+ // One command for every event it is wired to, so `seisin wire` adds one name.
39
+ // The after-the-fact and session-start halves only explain; neither decides.
40
+ const event_ = event.hook_event_name;
41
+ if (event_ === "PostToolUse" || event_ === "PostToolUseFailure") {
42
+ try { return await afterTool(config, role, event, { file: logPath(config.root) }); } catch { return null; }
43
+ }
44
+ if (event_ === "SessionStart") {
45
+ try { return atSessionStart(config, role, event, { file: logPath(config.root) }); } catch { return null; }
46
+ }
36
47
  const decision = decide(config, role, event, { observe: env.SEISIN_OBSERVE === "1" });
37
48
  // The entries went to a socket, and cli.js exits as soon as we return. An
38
49
  // exit does not drain a socket, so the record would be lost precisely on the
@@ -10,7 +10,7 @@
10
10
  */
11
11
  import { createReadStream, watch as watchDir } from "node:fs";
12
12
  import { dirname, relative } from "node:path";
13
- import { read, logPath, size } from "../log.js";
13
+ import { read, logPath, size, verifyLog } from "../log.js";
14
14
  import { renderEntry, C, out } from "../render.js";
15
15
 
16
16
  const flag = (argv, name) => {
@@ -19,6 +19,7 @@ const flag = (argv, name) => {
19
19
  };
20
20
 
21
21
  export function log(config, argv = []) {
22
+ if (argv[0] === "verify") return verify(config);
22
23
  const entries = read(logPath(config.root), {
23
24
  role: flag(argv, "--role"),
24
25
  verdict: flag(argv, "--verdict"),
@@ -35,6 +36,32 @@ export function log(config, argv = []) {
35
36
  return entries;
36
37
  }
37
38
 
39
+ /**
40
+ * `seisin log verify` — does the chain hold?
41
+ *
42
+ * Exit 1 when it does not, so it can sit in CI or a pre-commit hook. Lines from
43
+ * before the chain existed are reported as such, not as tampering.
44
+ */
45
+ function verify(config) {
46
+ const r = verifyLog(logPath(config.root));
47
+ if (r.lines === 0) {
48
+ out(`\n ${C.dim}nothing recorded yet${C.off}\n\n`);
49
+ return r;
50
+ }
51
+ const head = r.unchained ? `${r.unchained} line(s) from before the chain, then ` : "";
52
+ const segs = r.segments > 1 ? ` across ${r.segments} segments` : "";
53
+ if (!r.breaks.length) {
54
+ out(`\n ${C.green}intact${C.off} ${head}${r.chained} chained line(s)${segs}\n\n`);
55
+ return r;
56
+ }
57
+ out(`\n ${C.red}broken${C.off} ${head}${r.chained} chained line(s), ${r.breaks.length} break(s):\n`);
58
+ for (const b of r.breaks.slice(0, 10))
59
+ out(` line ${b.line}: expected prev ${b.expected}, found ${b.found ?? "none"}\n`);
60
+ out(` ${C.dim}a line was edited, removed or reordered just before each of these${C.off}\n\n`);
61
+ process.exitCode = 1;
62
+ return r;
63
+ }
64
+
38
65
  /**
39
66
  * Follows the log from wherever it is now.
40
67
  *
@@ -7,7 +7,8 @@
7
7
  * whole feature rests on — see docs/permission-requests.md.
8
8
  */
9
9
  import { readFileSync, writeFileSync } from "node:fs";
10
- import { pending, settle, applyGrant, requestsPath } from "../requests.js";
10
+ import { pending, settle, applyGrant, refuseIfBarred, requestsPath, markStale } from "../requests.js";
11
+ import { read, logPath } from "../log.js";
11
12
  import { C, out } from "../render.js";
12
13
 
13
14
  /**
@@ -52,7 +53,7 @@ function pick(config, n) {
52
53
  }
53
54
 
54
55
  export function requests(config) {
55
- const queue = pending(requestsPath(config.root));
56
+ const queue = markStale(pending(requestsPath(config.root)), read(logPath(config.root)));
56
57
  out(renderQueue(queue));
57
58
  return queue;
58
59
  }
@@ -70,6 +71,9 @@ export function renderQueue(queue) {
70
71
  // The stable way to name it: a number is a position in a queue that agents
71
72
  // are still writing to, and this does not move when the queue does.
72
73
  lines.push(` ${C.dim}id ${r.key}${C.off}\n`);
74
+ // Marked, not moved: the number above is what `grant <n>` is typed against.
75
+ if (r.stale)
76
+ lines.push(` ${C.dim}not asked again in ${r.stale.runs} runs of ${r.role} since — likely no longer needed${C.off}\n`);
73
77
  const note = handoffNote(r.handoff);
74
78
  if (note) lines.push(` ${C.yellow}${note}${C.off}\n`);
75
79
  });
@@ -118,6 +122,7 @@ export function grant(config, argv = []) {
118
122
  const i = argv.indexOf("--reason");
119
123
  const reason = i === -1 ? "" : argv[i + 1] ?? "";
120
124
 
125
+ refuseIfBarred(config, req);
121
126
  const before = readFileSync(config.path, "utf8");
122
127
  const { toml, changed } = applyGrant(before, req, reason);
123
128
  if (!changed) throw new Error(`${req.role} already has ${req.grant} — nothing to add`);
@@ -20,6 +20,15 @@ import { C, out } from "../render.js";
20
20
  export function reviewCommand(config, argv = []) {
21
21
  const i = argv.indexOf("--min");
22
22
  const r = review(config, { minDenials: i === -1 ? 3 : Number(argv[i + 1]) });
23
+ // Ten rows per section unless --all: on a real 32-role log the full report
24
+ // was 1,557 lines, which nobody reads.
25
+ const all = argv.includes("--all");
26
+ const TOP = 10;
27
+ const cap = (list) => (all ? list : list.slice(0, TOP));
28
+ const more = (list) => {
29
+ if (!all && list.length > TOP) out(` ${C.dim}… ${list.length - TOP} more: seisin review --all${C.off}\n`);
30
+ };
31
+ const who = (owners) => owners.length > 3 ? `${owners.slice(0, 3).join(", ")} +${owners.length - 3}` : owners.join(", ");
23
32
 
24
33
  if (!r.entries) {
25
34
  out(`\n ${C.dim}nothing recorded yet. Run an agent through seisin first.${C.off}\n\n`);
@@ -33,10 +42,11 @@ export function reviewCommand(config, argv = []) {
33
42
  out(`\n ${C.b}Stopped, repeatedly${C.off}\n`);
34
43
  out(` ${C.dim}a role blocked on the same place over and over is a policy that is wrong,${C.off}\n`);
35
44
  out(` ${C.dim}not an agent that is misbehaving${C.off}\n\n`);
36
- for (const f of r.friction) {
37
- const whose = f.owners.length ? ` ${C.dim}— belongs to ${f.owners.join(", ")}${C.off}` : "";
45
+ for (const f of cap(r.friction)) {
46
+ const whose = f.owners.length ? ` ${C.dim}— belongs to ${who(f.owners)}${C.off}` : "";
38
47
  out(` ${C.yellow}${String(f.times).padStart(4)}×${C.off} ${C.b}${f.role}${C.off} ${f.action} ${f.where}${whose}\n`);
39
48
  }
49
+ more(r.friction);
40
50
  out(`\n ${C.dim}seisin grant, or move the territory. Either way it is a decision, not noise.${C.off}\n`);
41
51
  }
42
52
 
@@ -49,12 +59,28 @@ export function reviewCommand(config, argv = []) {
49
59
  * two roles the credential directory*. Separated, the same lines read as
50
60
  * confirmation instead of as a recommendation, which is what they are.
51
61
  */
62
+ /**
63
+ * Connections, apart from both of the above: a port is not a territory, so
64
+ * "grant, or move the territory" does not answer it, and it is not the policy
65
+ * working either when the service is one the role was meant to reach.
66
+ */
67
+ if (r.connects?.length) {
68
+ out(`\n ${C.b}Refused connections${C.off}\n`);
69
+ out(` ${C.dim}a local port or socket the role kept dialling. Not a grant: a port goes in${C.off}\n`);
70
+ out(` ${C.dim}local_ports, and a socket stays closed${C.off}\n\n`);
71
+ for (const c of cap(r.connects))
72
+ out(` ${C.yellow}${String(c.times).padStart(4)}×${C.off} ${C.b}${c.role}${C.off} ${c.where}\n`);
73
+ more(r.connects);
74
+ out(`\n ${C.dim}seisin walls <role> says, per target, which of the two it is.${C.off}\n`);
75
+ }
76
+
52
77
  if (r.guarded.length) {
53
78
  out(`\n ${C.b}Held at the keys${C.off}\n`);
54
79
  out(` ${C.dim}a [keys] dir is closed to every role, so this is the policy working${C.off}\n`);
55
80
  out(` ${C.dim}— not a territory drawn wrong, and never a thing to grant${C.off}\n\n`);
56
- for (const g of r.guarded)
81
+ for (const g of cap(r.guarded))
57
82
  out(` ${C.dim}${String(g.times).padStart(4)}×${C.off} ${C.b}${g.role}${C.off} ${g.action} ${g.where}\n`);
83
+ more(r.guarded);
58
84
  out(`\n ${C.dim}Worth a look only if a role genuinely needs one of these: that is a${C.off}\n`);
59
85
  out(` ${C.dim}\`keys\` line for the file, never a write grant on the directory.${C.off}\n`);
60
86
  }
@@ -76,14 +102,16 @@ export function reviewCommand(config, argv = []) {
76
102
  out(`\n ${C.b}Granted, never used${C.off}\n`);
77
103
  out(` ${C.dim}nothing was written here in the window above. This is the only evidence${C.off}\n`);
78
104
  out(` ${C.dim}anyone will ever have for making a permission file smaller${C.off}\n\n`);
79
- for (const u of r.unused) out(` ${C.b}${u.role}${C.off} ${u.glob}\n`);
105
+ for (const u of cap(r.unused)) out(` ${C.b}${u.role}${C.off} ${u.glob}\n`);
106
+ more(r.unused);
80
107
  out(`\n ${C.dim}A short window proves nothing. Check the dates before you delete a line.${C.off}\n`);
81
108
  }
82
109
 
83
110
  if (r.unowned.length) {
84
111
  out(`\n ${C.b}Owned by nobody${C.off}\n`);
85
112
  out(` ${C.dim}touched by an agent, claimed by no role — holes in the map${C.off}\n\n`);
86
- for (const u of r.unowned.slice(0, 10)) out(` ${String(u.times).padStart(4)}× ${u.where}\n`);
113
+ for (const u of cap(r.unowned)) out(` ${String(u.times).padStart(4)}× ${u.where}\n`);
114
+ more(r.unowned);
87
115
  }
88
116
 
89
117
  if (!r.friction.length && !r.guarded.length && !r.unused.length && !r.unowned.length && r.unusedKnowable)