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/LICENSE +21 -0
- package/README.md +732 -0
- package/package.json +52 -0
- package/src/cli.js +125 -0
- package/src/commands/check.js +9 -0
- package/src/commands/explain.js +87 -0
- package/src/commands/hook.js +42 -0
- package/src/commands/init.js +181 -0
- package/src/commands/log.js +68 -0
- package/src/commands/requests.js +145 -0
- package/src/commands/review.js +94 -0
- package/src/commands/run.js +395 -0
- package/src/commands/scan.js +21 -0
- package/src/commands/ui.js +31 -0
- package/src/commands/whose.js +94 -0
- package/src/commands/wire.js +74 -0
- package/src/config.js +317 -0
- package/src/env.js +61 -0
- package/src/handoff.js +111 -0
- package/src/hook.js +195 -0
- package/src/index.js +52 -0
- package/src/inspect.js +477 -0
- package/src/layout.js +47 -0
- package/src/log.js +124 -0
- package/src/mcp.js +290 -0
- package/src/owners.js +166 -0
- package/src/redact.js +82 -0
- package/src/render.js +150 -0
- package/src/requests.js +259 -0
- package/src/review.js +167 -0
- package/src/scan.js +175 -0
- package/src/serve.js +168 -0
- package/src/spool.js +164 -0
- package/src/srt.js +424 -0
- package/src/violations.js +540 -0
- package/src/worktree.js +216 -0
- package/ui/favicon.svg +1 -0
- package/ui/index.html +937 -0
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";
|