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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "seisin",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.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://github.
|
|
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
|
@@ -30,6 +30,7 @@ import { hook } from "./commands/hook.js";
|
|
|
30
30
|
import { ui } from "./commands/ui.js";
|
|
31
31
|
import { requests, grant, deny } from "./commands/requests.js";
|
|
32
32
|
import { whose } from "./commands/whose.js";
|
|
33
|
+
import { wallsCommand } from "./commands/walls.js";
|
|
33
34
|
import { reviewCommand } from "./commands/review.js";
|
|
34
35
|
import { wire } from "./commands/wire.js";
|
|
35
36
|
import { serveMcp } from "./mcp.js";
|
|
@@ -46,12 +47,14 @@ ${C.b}seisin${C.off} — give each agent its own folders and its own keys
|
|
|
46
47
|
seisin explain <role> read|write <path>
|
|
47
48
|
seisin whose <path> who owns it — safe to call from inside the box
|
|
48
49
|
seisin scan find secrets outside the declared key dirs
|
|
49
|
-
seisin review
|
|
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
|
|
50
52
|
seisin wire let the agent record what it does
|
|
51
|
-
seisin requests what the agents asked for and
|
|
53
|
+
seisin requests what the agents asked for and cannot have
|
|
52
54
|
seisin grant <n> [--reason "…"] approve one, with its provenance
|
|
53
|
-
seisin
|
|
55
|
+
seisin decline <n> [--reason "…"] turn one down, and record why
|
|
54
56
|
seisin log [--role r] [--verdict denied]
|
|
57
|
+
seisin log verify does the log's hash chain hold
|
|
55
58
|
seisin watch follow the log live
|
|
56
59
|
seisin init [--from-observations] propose a ${CONFIG_NAME} for this repo
|
|
57
60
|
seisin ui [--port n] open the console, live
|
|
@@ -90,8 +93,19 @@ const COMMANDS = {
|
|
|
90
93
|
whose: () => void whose(config(), argv),
|
|
91
94
|
wire: () => void wire(config()),
|
|
92
95
|
review: () => (reviewCommand(config(), argv).friction.length ? 1 : 0),
|
|
96
|
+
walls: () => (wallsCommand(config(), argv).length ? 1 : 0),
|
|
93
97
|
requests: () => (requests(config()).length ? 1 : 0),
|
|
94
98
|
grant: () => void grant(config(), argv),
|
|
99
|
+
/**
|
|
100
|
+
* A person DECLINES a request; the boundary DENIES a write. One word each,
|
|
101
|
+
* because they are opposite events and `deny` used to name both — a queue
|
|
102
|
+
* entry said `first refused on …` (the kernel) and the answer said
|
|
103
|
+
* `refused frontend ✕ …` (you).
|
|
104
|
+
*
|
|
105
|
+
* `deny` stays and is not documented. Renaming a published command over a
|
|
106
|
+
* word is not worth breaking somebody's script; teaching the new one is.
|
|
107
|
+
*/
|
|
108
|
+
decline: () => void deny(config(), argv),
|
|
95
109
|
deny: () => void deny(config(), argv),
|
|
96
110
|
mcp: async () => {
|
|
97
111
|
await serveMcp(version());
|
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/init.js
CHANGED
|
@@ -126,7 +126,7 @@ export function renderObserved(config, entries) {
|
|
|
126
126
|
const declared = config.roles[name];
|
|
127
127
|
const seen = roles.get(name);
|
|
128
128
|
|
|
129
|
-
const from = declared ? declared.writes : [];
|
|
129
|
+
const from = declared ? declared.writesDeclared ?? declared.writes : [];
|
|
130
130
|
const found = seen ? generalise([...seen.writes]).filter((w) => !from.includes(w)) : [];
|
|
131
131
|
const keys = [
|
|
132
132
|
...(declared ? declared.keys : []),
|
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, verifyChain } 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,31 @@ 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 = verifyChain(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
|
+
if (!r.breaks.length) {
|
|
53
|
+
out(`\n ${C.green}intact${C.off} ${head}${r.chained} chained line(s)\n\n`);
|
|
54
|
+
return r;
|
|
55
|
+
}
|
|
56
|
+
out(`\n ${C.red}broken${C.off} ${head}${r.chained} chained line(s), ${r.breaks.length} break(s):\n`);
|
|
57
|
+
for (const b of r.breaks.slice(0, 10))
|
|
58
|
+
out(` line ${b.line}: expected prev ${b.expected}, found ${b.found ?? "none"}\n`);
|
|
59
|
+
out(` ${C.dim}a line was edited, removed or reordered just before each of these${C.off}\n\n`);
|
|
60
|
+
process.exitCode = 1;
|
|
61
|
+
return r;
|
|
62
|
+
}
|
|
63
|
+
|
|
38
64
|
/**
|
|
39
65
|
* Follows the log from wherever it is now.
|
|
40
66
|
*
|
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,10 +71,13 @@ 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
|
});
|
|
76
|
-
lines.push(`\n ${C.dim}seisin grant <n> [--reason "…"] · seisin
|
|
80
|
+
lines.push(`\n ${C.dim}seisin grant <n> [--reason "…"] · seisin decline <n> [--reason "…"]${C.off}\n\n`);
|
|
77
81
|
return lines.join("");
|
|
78
82
|
}
|
|
79
83
|
|
|
@@ -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)
|