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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "seisin",
3
- "version": "0.1.1",
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.com/carlostapiaolguin3-stack/seisin#readme",
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
@@ -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 what the log says about the policy
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 could not do
53
+ seisin requests what the agents asked for and cannot have
52
54
  seisin grant <n> [--reason "…"] approve one, with its provenance
53
- seisin deny <n> [--reason "…"] refuse one, and record why
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());
@@ -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
@@ -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 : []),
@@ -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
  *
@@ -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 deny <n> [--reason "…"]${C.off}\n\n`);
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`);
@@ -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)