seisin 0.1.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/src/handoff.js ADDED
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Who a refused write should be handed to — decided, not executed.
3
+ *
4
+ * Nothing in here spawns, connects, reads a file or talks to a model. It takes
5
+ * the request, the policy as it stands right now, the chain it belongs to and
6
+ * what the sender and receiver already have running, and returns one decision.
7
+ * Something else acts on it.
8
+ *
9
+ * That split is the whole reason this file exists separately. The rules below
10
+ * are the security semantics of a handoff, and security semantics that live
11
+ * inside a function which also does `child_process.spawn` cannot be tested
12
+ * without starting an agent — so in practice they stop being tested.
13
+ *
14
+ * ── The one property everything here serves ──
15
+ * A model may influence content, which path it reaches for, and therefore the
16
+ * *demand* for work. It may not determine authority, routing identity,
17
+ * admission or policy. Every input below is either derived from the boundary
18
+ * or supplied by the caller; none of them is a role name a model chose.
19
+ */
20
+ import { ownersOf } from "./owners.js";
21
+
22
+ /** Small on purpose: a fuse for a topology nobody predicted, not a budget. */
23
+ export const MAX_DEPTH = 4;
24
+
25
+ /**
26
+ * Ownership is recomputed here, and the stored `owners` on the request is not
27
+ * consulted at all.
28
+ *
29
+ * A request carries the owners as they were when the denial happened. Between
30
+ * then and now a person may have changed the policy — that is the entire point
31
+ * of the queue. The stored value is historical evidence; the authority is the
32
+ * policy in front of us.
33
+ */
34
+ export function decideHandoff({
35
+ request,
36
+ config,
37
+ chain = null,
38
+ limits = {},
39
+ load = {},
40
+ revision = null,
41
+ }) {
42
+ if (!request || !request.role || !request.target)
43
+ throw new Error("decideHandoff: request needs at least { role, target }");
44
+ if (!config || !config.roles) throw new Error("decideHandoff: config needs roles");
45
+
46
+ const depthMax = limits.depth ?? MAX_DEPTH;
47
+ const visited = chain?.visited ?? [request.role];
48
+ const depth = chain?.depth ?? 0;
49
+ const at = (type, extra = {}) => ({ type, revision, chainId: chain?.id ?? null, ...extra });
50
+
51
+ const owners = ownersOf(config, request.target);
52
+
53
+ /**
54
+ * Authority first, admission second, and the order is not cosmetic.
55
+ *
56
+ * The two outcomes below start no worker at all, so they must not be able to
57
+ * be turned into a throttle or a cycle report by load or by chain shape. A
58
+ * request whose target nobody owns is a hole in the policy whichever way the
59
+ * budget happens to be sitting, and one that the asker now owns is finished.
60
+ * Reporting either as CYCLE or THROTTLED would send a person to look at the
61
+ * wrong thing.
62
+ */
63
+ if (owners.length === 0) return at("human", { reason: "unowned", owners });
64
+ if (owners.length > 1) return at("human", { reason: "ambiguous", owners });
65
+
66
+ const [owner] = owners;
67
+
68
+ /**
69
+ * The policy moved and the asker owns it now. Nobody granted anything, so
70
+ * this is not `granted` — it is a request that stopped existing, and saying
71
+ * so is what keeps the human queue readable. Sending this to a person would
72
+ * be asking them to decide something already decided.
73
+ */
74
+ if (owner === request.role)
75
+ return at("resolved", { resolution: "policy_changed", role: owner });
76
+
77
+ /**
78
+ * A role already in this chain does not get re-entered, even for a different
79
+ * file. Work that crosses a boundary and needs to come back has stopped being
80
+ * a handoff and become multi-role coordination; that is a decomposition a
81
+ * person should make on purpose.
82
+ */
83
+ if (visited.includes(owner))
84
+ return at("cycle", { role: owner, visited: [...visited] });
85
+
86
+ if (depth + 1 > depthMax)
87
+ return at("depth", { role: owner, depth: depth + 1, max: depthMax });
88
+
89
+ /**
90
+ * Bounded invocation. A role cannot write another role's territory, but it
91
+ * can *reach for* it as often as it likes, and each refusal is a way to make
92
+ * another role run. Filesystem authority was already closed; this closes
93
+ * cross-role invocation, which is a separate resource.
94
+ *
95
+ * Throttling is never silent: the decision says which limit stopped it, so a
96
+ * queue can show work that is waiting rather than let it evaporate.
97
+ */
98
+ const senderMax = limits.senderChains ?? Infinity;
99
+ if ((load.senderOpenChains ?? 0) >= senderMax)
100
+ return at("throttled", { role: owner, limit: "senderChains", max: senderMax });
101
+
102
+ const receiverMax = limits.receiverConcurrent ?? Infinity;
103
+ if ((load.receiverRunning ?? 0) >= receiverMax)
104
+ return at("throttled", { role: owner, limit: "receiverConcurrent", max: receiverMax });
105
+
106
+ return at("route", {
107
+ role: owner,
108
+ depth: depth + 1,
109
+ visited: [...visited, owner],
110
+ });
111
+ }
package/src/hook.js ADDED
@@ -0,0 +1,195 @@
1
+ /**
2
+ * The PreToolUse hook: the only place that can see an attempt.
3
+ *
4
+ * The kernel is silent by design. When the sandbox refuses a write, all that
5
+ * reaches anyone is the operating system's `Operation not permitted` on the
6
+ * child's stderr — no path, no reason, and nothing to read afterwards. That is
7
+ * correct behaviour for an enforcer and useless as an instrument.
8
+ *
9
+ * A hook runs one layer up. It sees the path *before* the attempt, which means
10
+ * it can do the two things the kernel cannot: write down what was tried, and
11
+ * say whose file it was.
12
+ *
13
+ * ── Why this file is allowed to be imperfect ──
14
+ * It reads a Bash command with regular expressions, and a determined command
15
+ * can hide its target from that. That would be a fatal flaw in an enforcer and
16
+ * is a minor one here, because the enforcer is the kernel and it is not fooled.
17
+ * What escapes this file goes unexplained, not unblocked.
18
+ */
19
+ import { explain, normalize } from "./owners.js";
20
+ import { append, logPath } from "./log.js";
21
+ import { record, requestsPath } from "./requests.js";
22
+
23
+ /** Tools whose input names a file directly. */
24
+ const FILE_TOOLS = {
25
+ Write: "write", Edit: "write", MultiEdit: "write", NotebookEdit: "write",
26
+ Read: "read", NotebookRead: "read",
27
+ };
28
+
29
+ /**
30
+ * Paths a shell command is going to write.
31
+ *
32
+ * Redirections and the handful of commands whose job is to put bytes
33
+ * somewhere. Deliberately not exhaustive — see the note at the top about what
34
+ * this file is for.
35
+ */
36
+ const REDIRECT = /(?:^|\s|;|&&|\|\|)>{1,2}\s*("[^"]+"|'[^']+'|[^\s;|&<>]+)/g;
37
+ const WRITERS = /(?:^|\s|;|&&|\|\|)(?:cp|mv|install|tee|touch|mkdir|rm|rmdir|truncate|dd)\s+([^\n;|&]+)/g;
38
+
39
+ export function targetsOf(tool, input = {}) {
40
+ const kind = FILE_TOOLS[tool];
41
+ if (kind) {
42
+ const p = input.file_path ?? input.path ?? input.notebook_path;
43
+ return p ? [{ action: kind, path: p }] : [];
44
+ }
45
+ if (tool !== "Bash" || typeof input.command !== "string") return [];
46
+
47
+ const out = [];
48
+ const seen = new Set();
49
+ const add = (raw) => {
50
+ const p = String(raw).replace(/^["']|["']$/g, "");
51
+ if (!p || p.startsWith("-") || seen.has(p)) return;
52
+ if (!looksLikeAPath(p)) return;
53
+ seen.add(p);
54
+ out.push({ action: "write", path: p });
55
+ };
56
+
57
+ for (const m of input.command.matchAll(REDIRECT)) add(m[1]);
58
+ for (const m of input.command.matchAll(WRITERS)) {
59
+ for (const arg of m[1].split(/\s+/)) if (!arg.startsWith("-")) add(arg);
60
+ }
61
+ return out;
62
+ }
63
+
64
+ /**
65
+ * Is this a filename, or shell debris?
66
+ *
67
+ * Reading a command with regular expressions picks up things that are not
68
+ * paths: a stray `2>`, the word `test` out of `[ -w x ] && test ...`, a device
69
+ * node. Each one becomes a line in the log that says a role was denied
70
+ * something it never asked for, and a log with fabricated entries in it stops
71
+ * being read. Better to explain less and be believed.
72
+ */
73
+ const SHELL_NOISE = new Set(["test", "true", "false", "then", "else", "fi", "do", "done", "&&", "||", ";"]);
74
+
75
+ function looksLikeAPath(p) {
76
+ if (SHELL_NOISE.has(p)) return false;
77
+ if (/^\d*[<>&|]/.test(p)) return false; // 2>, >&1, |
78
+ if (p.startsWith("/dev/")) return false; // /dev/null and friends
79
+ if (!/[/.]/.test(p) && !/^[.\w-]+$/.test(p)) return false;
80
+ return /[/.]/.test(p) || p.length > 2;
81
+ }
82
+
83
+ /** A path inside a declared key directory is a key, and reads of it are policy. */
84
+ /**
85
+ * Is this path inside a declared key directory?
86
+ *
87
+ * By prefix, not by `includes`. The loose version matched a key directory's
88
+ * name anywhere in the path, so `src/web/.secrets/readme.md` was treated as a
89
+ * credential — denied with a message about keys, for a file that is nothing of
90
+ * the sort. Wrong in the harmless direction, and still wrong: the reason a
91
+ * denial gives is the entire product.
92
+ */
93
+ function kindOf(config, path) {
94
+ const p = normalize(path);
95
+ return (config.keyDirs ?? []).some((d) => {
96
+ const k = normalize(d);
97
+ return p === k || p.startsWith(k + "/");
98
+ })
99
+ ? "key"
100
+ : "file";
101
+ }
102
+
103
+ /**
104
+ * The half of the refusal that nothing inside the box could otherwise know.
105
+ *
106
+ * `ask()` has already filed a request by the time this sentence is built — and
107
+ * until now it did so silently, so an agent could not tell the person who sent
108
+ * it that anything was pending. It closes two loops the bare refusal leaves
109
+ * open: retrying, because the queue deduplicates and a second attempt only
110
+ * raises a counter; and waiting, because nothing inside the sandbox can
111
+ * approve.
112
+ *
113
+ * It deliberately says nothing about the MCP server. The hook cannot know
114
+ * whether one is configured — `wire` writes `.claude/settings.json` and MCP
115
+ * servers live elsewhere — and naming a tool the agent may not hold costs it a
116
+ * turn to find out. An agent that does hold the tools discovers them the normal
117
+ * way; that is what their descriptions are for.
118
+ */
119
+ const QUEUED = "Already queued for a person to answer — retrying or waiting will not move it.";
120
+
121
+ /**
122
+ * Decides and records one tool call.
123
+ *
124
+ * `observe` is the mode that makes the whole thing usable: it records exactly
125
+ * the same decisions and returns none of them, so a policy can be written from
126
+ * what a real run did instead of from what someone imagined it would do.
127
+ */
128
+ export function decide(config, role, event, { observe = false, now = append, ask = record } = {}) {
129
+ const targets = targetsOf(event.tool_name, event.tool_input);
130
+ const file = logPath(config.root);
131
+ const queue = requestsPath(config.root);
132
+ const verdicts = [];
133
+
134
+ for (const t of targets) {
135
+ const rel = t.path.startsWith(config.root + "/") ? t.path.slice(config.root.length + 1) : t.path;
136
+ const kind = kindOf(config, rel);
137
+
138
+ // Reading an ordinary file is never a policy question. Territory divides
139
+ // who may CHANGE something; an agent that cannot read the rest of the repo
140
+ // cannot do the work at all. Only keys are scoped on read.
141
+ //
142
+ // This was wrong once and it was not subtle: the hook denied a plain Read
143
+ // of a markdown file, the agent burned its turns unable to look at what it
144
+ // had been asked to edit, and the log showed a tidy `denied` that looked
145
+ // like the system working.
146
+ if (t.action === "read" && kind !== "key") continue;
147
+
148
+ const v = explain(config, role, kind === "key" ? "read" : "write", rel);
149
+
150
+ now(file, {
151
+ role,
152
+ tool: event.tool_name,
153
+ action: t.action,
154
+ kind,
155
+ target: rel,
156
+ verdict: observe ? "observed" : v.allowed ? "allowed" : "denied",
157
+ owners: v.owners ?? [],
158
+ reason: v.reason,
159
+ });
160
+ // A denial already carries everything a request needs, so leave one behind:
161
+ // the refusal stops being a dead end and becomes something a person can act
162
+ // on in one command. Observing records nothing to approve — there was no
163
+ // denial to answer. See docs/permission-requests.md.
164
+ if (!observe && !v.allowed)
165
+ ask(queue, { role, action: kind === "key" ? "read" : t.action, target: rel, owners: v.owners ?? [] });
166
+
167
+ verdicts.push({ ...v, target: rel, kind });
168
+ }
169
+
170
+ if (observe) return { decision: null, logged: verdicts.length };
171
+
172
+ const denied = verdicts.find((v) => !v.allowed);
173
+ if (!denied) return { decision: null, logged: verdicts.length };
174
+ const wasRead = denied.kind === "key";
175
+
176
+ // The hook advises; it does not enforce. Returning `deny` here stops the call
177
+ // early and — the part that matters — hands the agent a sentence it can act
178
+ // on, instead of an errno it can only retry.
179
+ return {
180
+ decision: "deny",
181
+ logged: verdicts.length,
182
+ hookSpecificOutput: {
183
+ hookEventName: "PreToolUse",
184
+ permissionDecision: "deny",
185
+ permissionDecisionReason:
186
+ // The sentence has to name a next step, not just a refusal. An agent
187
+ // told "no" retries; an agent told whose it is asks, or moves on.
188
+ (denied.owners?.length
189
+ ? wasRead
190
+ ? `${denied.target} is declared for ${denied.owners.join(", ")}, not ${role}. Ask for what you need from it rather than reading the key.`
191
+ : `${denied.target} belongs to ${denied.owners.join(", ")}. It is not ${role}'s to change — hand it over rather than working around it.`
192
+ : `${denied.reason} (seisin)`) + " " + QUEUED,
193
+ },
194
+ };
195
+ }
package/src/index.js ADDED
@@ -0,0 +1,52 @@
1
+ /**
2
+ * The public surface. Everything not re-exported here is internal and may move.
3
+ *
4
+ * Drawing this line is the point of the file. Without it every function in
5
+ * `src/` is de facto public, which means either nothing can be refactored or
6
+ * somebody's build breaks when it is. The rule applied below: a thing is public
7
+ * if you would need it to make a decision — what is allowed, who owns a path,
8
+ * what the policy resolves to. Rendering, dispatch and process handling are not.
9
+ *
10
+ * import { loadConfig, explain } from "seisin";
11
+ *
12
+ * The CLI is the other entry point and uses exactly the same functions.
13
+ */
14
+
15
+ /** Reading and validating a policy file. */
16
+ export { findConfig, loadConfig, parseToml } from "./config.js";
17
+
18
+ /** Where seisin's own files live. */
19
+ export { CONFIG_NAME, STATE_DIR, LOG_NAME } from "./layout.js";
20
+
21
+ /** Who owns a path, and the sentence to hand a blocked agent. */
22
+ export { covers, ownersOf, keyHolders, explain } from "./owners.js";
23
+
24
+ /** A policy, resolved into sandbox settings and a child environment. */
25
+ export { settingsFor, RUNTIME_WRITES, expand } from "./srt.js";
26
+ export { buildEnv, BASE as BASE_ENV } from "./env.js";
27
+
28
+ /** The report `check` prints, without the printing. */
29
+ export { inspect, sharedPaths } from "./inspect.js";
30
+
31
+ /** Credential-shaped content outside the declared key directories. */
32
+ export { scan, SHAPES, DEFAULT_IGNORE } from "./scan.js";
33
+
34
+ /** The append-only record, and what can be learned from it. */
35
+ export { read as readLog, logPath, observed, generalise } from "./log.js";
36
+
37
+ /**
38
+ * The queue of refusals waiting on a person.
39
+ *
40
+ * `pending` and `grantFor` are public because reading the queue and drafting a
41
+ * proposal are things a caller — including an MCP server — legitimately does.
42
+ * `settle` and `applyGrant` are NOT exported: approving is not a tool call, and
43
+ * the surface should make that hard to get wrong rather than merely document
44
+ * it. See docs/permission-requests.md.
45
+ */
46
+ export { pending as pendingRequests, requestsPath, grantFor } from "./requests.js";
47
+
48
+ /** One PreToolUse decision. */
49
+ export { decide, targetsOf } from "./hook.js";
50
+
51
+ /** Masking a role's own secrets on the way out. */
52
+ export { secretsOf, redactor } from "./redact.js";