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 +72 -10
- package/package.json +7 -6
- package/src/cli.js +4 -3
- package/src/commands/explain.js +9 -2
- package/src/commands/hook.js +11 -0
- package/src/commands/log.js +28 -1
- package/src/commands/requests.js +7 -2
- package/src/commands/review.js +33 -5
- package/src/commands/run.js +112 -151
- package/src/commands/ui.js +72 -6
- package/src/commands/walls.js +8 -2
- package/src/commands/whose.js +2 -1
- package/src/commands/wire.js +30 -5
- package/src/config.js +150 -2
- package/src/diagnose.js +172 -0
- package/src/env.js +54 -1
- package/src/grants.js +239 -0
- package/src/hook.js +62 -3
- package/src/inspect.js +178 -5
- package/src/intake.js +243 -0
- package/src/keys.js +108 -6
- package/src/log.js +203 -3
- package/src/mcp.js +28 -10
- package/src/notify.js +146 -0
- package/src/owners.js +208 -6
- package/src/paths.js +50 -0
- package/src/render.js +29 -0
- package/src/requests.js +88 -0
- package/src/review.js +9 -4
- package/src/rundir.js +113 -0
- package/src/serve.js +134 -22
- package/src/spool.js +98 -20
- package/src/srt.js +123 -225
- package/src/surface.js +518 -0
- package/src/uilink.js +55 -0
- package/src/violations.js +37 -5
- package/src/walls.js +19 -5
- package/ui/favicon.svg +1 -1
- package/ui/index.html +221 -28
package/README.md
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
# seisin
|
|
2
2
|
|
|
3
|
-
[](https://github.com/carlostapiacl/seisin/actions/workflows/test.yml)
|
|
4
|
+
[](https://scorecard.dev/viewer/?uri=github.com/carlostapiacl/seisin)
|
|
4
5
|
[](https://www.npmjs.com/package/seisin)
|
|
5
6
|
[](LICENSE)
|
|
6
7
|
[](package.json)
|
|
7
8
|
[](package.json)
|
|
8
9
|
|
|
9
|
-
**[The short version, with the demos playing →](https://
|
|
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:
|
|
61
|
-
permission
|
|
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
|

|
|
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.**
|
|
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
|
|
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.
|
|
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
|
|
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.
|
|
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://
|
|
20
|
+
"homepage": "https://carlostapiacl.github.io/seisin/",
|
|
21
21
|
"repository": {
|
|
22
22
|
"type": "git",
|
|
23
|
-
"url": "git+https://github.com/
|
|
23
|
+
"url": "git+https://github.com/carlostapiacl/seisin.git"
|
|
24
24
|
},
|
|
25
25
|
"bugs": {
|
|
26
|
-
"url": "https://github.com/
|
|
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": "
|
|
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
|
|
51
|
-
seisin walls <role>
|
|
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]
|
|
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.
|
package/src/commands/explain.js
CHANGED
|
@@ -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
|
|
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 =
|
|
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));
|
package/src/commands/hook.js
CHANGED
|
@@ -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
|
package/src/commands/log.js
CHANGED
|
@@ -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
|
*
|
package/src/commands/requests.js
CHANGED
|
@@ -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`);
|
package/src/commands/review.js
CHANGED
|
@@ -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
|
|
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
|
|
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)
|