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/mcp.js
ADDED
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An MCP server over stdio. Read-only, by construction.
|
|
3
|
+
*
|
|
4
|
+
* The point of it: configuring a permission tool by hand-editing a config is
|
|
5
|
+
* the reason permission tools get abandoned. Asking your own assistant "who
|
|
6
|
+
* owns src/api, and what is frontend waiting on?" is the version of that people
|
|
7
|
+
* actually do.
|
|
8
|
+
*
|
|
9
|
+
* ── What it cannot do, and how that is enforced ──
|
|
10
|
+
* There is no tool here that changes anything. Not the policy, not the queue,
|
|
11
|
+
* not a proposal file — nothing on disk is opened for writing by this process.
|
|
12
|
+
* `seisin_draft_grant` returns text for a human to read and a command for them
|
|
13
|
+
* to run; it does not stage a file, because a staged file is one `mv` away from
|
|
14
|
+
* being policy and the whole invariant is that the last step belongs to a
|
|
15
|
+
* person in a channel the agent does not have.
|
|
16
|
+
*
|
|
17
|
+
* If granting were a tool here, an agent holding it could widen its own
|
|
18
|
+
* territory and the record would say a human did it. See
|
|
19
|
+
* `docs/permission-requests.md`.
|
|
20
|
+
*
|
|
21
|
+
* ── Why there is no SDK ──
|
|
22
|
+
* The official SDK pulls 91 packages and 26 MB — express, hono, cors, jose —
|
|
23
|
+
* (counted against 1.30.0; it was 94 a version ago, which is the point)
|
|
24
|
+
* for a server that speaks line-delimited JSON on two file descriptors. In a
|
|
25
|
+
* tool people install to reduce their attack surface, that is the wrong trade.
|
|
26
|
+
* The cost of this choice is written down under PROTOCOLS below: we track the
|
|
27
|
+
* spec by hand, and the spec moves.
|
|
28
|
+
*/
|
|
29
|
+
import { loadConfig, findConfig } from "./config.js";
|
|
30
|
+
import { inspect } from "./inspect.js";
|
|
31
|
+
import { explain, ownersOf } from "./owners.js";
|
|
32
|
+
import { settingsFor } from "./srt.js";
|
|
33
|
+
import { pending, requestsPath, grantFor } from "./requests.js";
|
|
34
|
+
import { read, logPath } from "./log.js";
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Versions this server will agree to.
|
|
38
|
+
*
|
|
39
|
+
* Negotiation is "echo what the client asked for if we know it, otherwise offer
|
|
40
|
+
* our newest". The 2026-07-28 revision made the core stateless and moved the
|
|
41
|
+
* handshake into `_meta`, but for a stdio server exposing four read-only tools
|
|
42
|
+
* the wire shape of `tools/list` and `tools/call` is unchanged — which is the
|
|
43
|
+
* only reason hand-rolling this is defensible. If that stops being true, this
|
|
44
|
+
* list is where it will show up first.
|
|
45
|
+
*/
|
|
46
|
+
const PROTOCOLS = ["2026-07-28", "2025-11-25", "2025-06-18"];
|
|
47
|
+
const NEWEST = PROTOCOLS[0];
|
|
48
|
+
|
|
49
|
+
const TOOLS = [
|
|
50
|
+
{
|
|
51
|
+
name: "seisin_state",
|
|
52
|
+
description:
|
|
53
|
+
"The permission map: every role, the folders it may write, the keys it may read, " +
|
|
54
|
+
"and every way the policy silently does not hold. Read-only.",
|
|
55
|
+
inputSchema: {
|
|
56
|
+
type: "object",
|
|
57
|
+
properties: { role: { type: "string", description: "narrow to one role" } },
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
name: "seisin_explain",
|
|
62
|
+
description:
|
|
63
|
+
"Whether a role may read or write a path, and whose it is if not. " +
|
|
64
|
+
"Use this before suggesting an agent touch a file. Read-only.",
|
|
65
|
+
inputSchema: {
|
|
66
|
+
type: "object",
|
|
67
|
+
properties: {
|
|
68
|
+
role: { type: "string" },
|
|
69
|
+
action: { type: "string", enum: ["read", "write"] },
|
|
70
|
+
target: { type: "string", description: "path, or key filename for a read" },
|
|
71
|
+
},
|
|
72
|
+
required: ["role", "action", "target"],
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
name: "seisin_requests",
|
|
77
|
+
description:
|
|
78
|
+
"Permissions agents asked for and cannot have yet, waiting on a human, with how many " +
|
|
79
|
+
"times each was asked. A refusal files one of these automatically, so this is where " +
|
|
80
|
+
"to look after being denied rather than retrying. Read-only — approving is not " +
|
|
81
|
+
"available here, by design.",
|
|
82
|
+
inputSchema: { type: "object", properties: {} },
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
name: "seisin_activity",
|
|
86
|
+
description: "Recent allow/deny decisions, newest last. Read-only.",
|
|
87
|
+
inputSchema: {
|
|
88
|
+
type: "object",
|
|
89
|
+
properties: {
|
|
90
|
+
role: { type: "string" },
|
|
91
|
+
verdict: { type: "string", enum: ["allowed", "denied", "observed"] },
|
|
92
|
+
limit: { type: "number" },
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
name: "seisin_draft_grant",
|
|
98
|
+
description:
|
|
99
|
+
"Draft the change a pending request would make, as text, plus the command a person " +
|
|
100
|
+
"runs to apply it. Reach for this after a refusal: the denial is already in " +
|
|
101
|
+
"seisin_requests, and this turns it into a concrete proposal someone can approve. " +
|
|
102
|
+
"Writes nothing: the approval is a human action in another channel.",
|
|
103
|
+
inputSchema: {
|
|
104
|
+
type: "object",
|
|
105
|
+
properties: { number: { type: "number", description: "position in seisin_requests" } },
|
|
106
|
+
required: ["number"],
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
];
|
|
110
|
+
|
|
111
|
+
/* ── the tools ────────────────────────────────────────────────────────── */
|
|
112
|
+
|
|
113
|
+
function config() {
|
|
114
|
+
const path = findConfig();
|
|
115
|
+
if (!path) throw new Error('no seisin.toml found. Run "seisin init" in the repo first.');
|
|
116
|
+
return loadConfig(path);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const HANDLERS = {
|
|
120
|
+
seisin_state({ role }) {
|
|
121
|
+
const cfg = config();
|
|
122
|
+
const report = inspect(cfg, role ?? null, cfg.path);
|
|
123
|
+
return {
|
|
124
|
+
config: cfg.path,
|
|
125
|
+
keyDirectories: cfg.keyDirs,
|
|
126
|
+
roles: report.roles.map((r) => ({
|
|
127
|
+
...r,
|
|
128
|
+
sandbox: settingsFor(cfg, r.name).filesystem,
|
|
129
|
+
})),
|
|
130
|
+
warnings: report.warnings,
|
|
131
|
+
// The blank parts of the map travel with it. A model that can see the
|
|
132
|
+
// policy and not its limits will confidently tell someone a file is safe.
|
|
133
|
+
limits: report.limits,
|
|
134
|
+
};
|
|
135
|
+
},
|
|
136
|
+
|
|
137
|
+
seisin_explain({ role, action, target }) {
|
|
138
|
+
// The schema says enum: [read, write]. Nothing was checking, so anything
|
|
139
|
+
// that was not "read" fell into the write branch — including a typo, which
|
|
140
|
+
// would answer the wrong question confidently. A declared schema that is
|
|
141
|
+
// not enforced is documentation.
|
|
142
|
+
if (action !== "read" && action !== "write")
|
|
143
|
+
throw new Error(`action must be "read" or "write", got ${JSON.stringify(action)}`);
|
|
144
|
+
if (typeof target !== "string" || !target)
|
|
145
|
+
throw new Error("target must be a non-empty path");
|
|
146
|
+
const cfg = config();
|
|
147
|
+
if (!cfg.roles[role])
|
|
148
|
+
return { error: `unknown role "${role}"`, known: Object.keys(cfg.roles) };
|
|
149
|
+
const verdict = explain(cfg, role, action, target);
|
|
150
|
+
return { ...verdict, alsoOwnedBy: action === "write" ? ownersOf(cfg, target) : undefined };
|
|
151
|
+
},
|
|
152
|
+
|
|
153
|
+
seisin_requests() {
|
|
154
|
+
const cfg = config();
|
|
155
|
+
const queue = pending(requestsPath(cfg.root));
|
|
156
|
+
return {
|
|
157
|
+
pending: queue.map((r, i) => ({
|
|
158
|
+
number: i + 1, role: r.role, action: r.action,
|
|
159
|
+
wants: r.grant, ownedBy: r.owners, asked: r.times, lastAsked: r.last,
|
|
160
|
+
})),
|
|
161
|
+
// Said in the payload and not only in the tool description, because a
|
|
162
|
+
// model reading this is deciding what to do next.
|
|
163
|
+
note: queue.length
|
|
164
|
+
? "Approving is not available through MCP. Tell the operator to run: seisin grant <number>"
|
|
165
|
+
: "nothing waiting",
|
|
166
|
+
};
|
|
167
|
+
},
|
|
168
|
+
|
|
169
|
+
seisin_activity({ role, verdict, limit }) {
|
|
170
|
+
if (verdict !== undefined && !["allowed", "denied", "observed"].includes(verdict))
|
|
171
|
+
throw new Error(`verdict must be allowed, denied or observed, got ${JSON.stringify(verdict)}`);
|
|
172
|
+
const n = limit === undefined ? 30 : Number(limit);
|
|
173
|
+
if (!Number.isInteger(n) || n < 1 || n > 1000) throw new Error("limit must be 1..1000");
|
|
174
|
+
const cfg = config();
|
|
175
|
+
return { entries: read(logPath(cfg.root), { role, verdict, limit: n }) };
|
|
176
|
+
},
|
|
177
|
+
|
|
178
|
+
seisin_draft_grant({ number }) {
|
|
179
|
+
if (!Number.isInteger(Number(number)) || Number(number) < 1)
|
|
180
|
+
throw new Error("number must be a positive integer from seisin_requests");
|
|
181
|
+
const cfg = config();
|
|
182
|
+
const req = pending(requestsPath(cfg.root))[Number(number) - 1];
|
|
183
|
+
if (!req) throw new Error(`no pending request #${number}`);
|
|
184
|
+
|
|
185
|
+
const field = req.action === "read" ? "keys" : "writes";
|
|
186
|
+
return {
|
|
187
|
+
wouldAdd: { role: req.role, field, value: req.grant },
|
|
188
|
+
preview: `[roles.${req.role}]\n${field} = [ …, "${req.grant}" ] # asked ${req.times}×`,
|
|
189
|
+
ownedBy: req.owners,
|
|
190
|
+
applyWith: `seisin grant ${number} --reason "<why>"`,
|
|
191
|
+
note: "Not applied. seisin's MCP server writes nothing — a person runs the command above.",
|
|
192
|
+
};
|
|
193
|
+
},
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
/* ── the wire ─────────────────────────────────────────────────────────── */
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* One session's state: where to write, and what version to claim.
|
|
200
|
+
*
|
|
201
|
+
* Injected rather than read from the process, because a server that writes to a
|
|
202
|
+
* global cannot be exercised without patching the process it runs in — and a
|
|
203
|
+
* test that patches `process.stdout` also captures the test runner's own
|
|
204
|
+
* output, which is how these tests failed the first time they were written.
|
|
205
|
+
*/
|
|
206
|
+
function handle(message, { write, version }) {
|
|
207
|
+
const send = (m) => write(JSON.stringify(m) + "\n");
|
|
208
|
+
const reply = (id, result) => send({ jsonrpc: "2.0", id, result });
|
|
209
|
+
const fail = (id, code, msg) => send({ jsonrpc: "2.0", id, error: { code, message: msg } });
|
|
210
|
+
|
|
211
|
+
const { id, method, params = {} } = message;
|
|
212
|
+
|
|
213
|
+
// A notification has no id and takes no answer. Replying to one is a protocol
|
|
214
|
+
// error that some clients tolerate and others hang on.
|
|
215
|
+
if (id === undefined) return;
|
|
216
|
+
|
|
217
|
+
switch (method) {
|
|
218
|
+
case "initialize": {
|
|
219
|
+
const asked = params.protocolVersion;
|
|
220
|
+
return reply(id, {
|
|
221
|
+
protocolVersion: PROTOCOLS.includes(asked) ? asked : NEWEST,
|
|
222
|
+
capabilities: { tools: {} },
|
|
223
|
+
serverInfo: { name: "seisin", title: "seisin — folders and keys per agent", version },
|
|
224
|
+
instructions:
|
|
225
|
+
"Read-only view of this repo's agent permissions. Ask seisin_explain before " +
|
|
226
|
+
"suggesting an agent edit a file it may not own. Granting is not available " +
|
|
227
|
+
"here: surface seisin_requests and let the operator run `seisin grant`.",
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
case "ping":
|
|
231
|
+
return reply(id, {});
|
|
232
|
+
case "server/discover": // 2026-07-28 clients ask up front
|
|
233
|
+
return reply(id, { capabilities: { tools: {} }, tools: TOOLS });
|
|
234
|
+
case "tools/list":
|
|
235
|
+
return reply(id, { tools: TOOLS });
|
|
236
|
+
case "tools/call": {
|
|
237
|
+
const fn = HANDLERS[params.name];
|
|
238
|
+
if (!fn) return fail(id, -32602, `unknown tool "${params.name}"`);
|
|
239
|
+
try {
|
|
240
|
+
const result = fn(params.arguments ?? {});
|
|
241
|
+
// Content, not a bare object: every client renders `content`, and only
|
|
242
|
+
// some read `structuredContent`. Both are sent.
|
|
243
|
+
return reply(id, {
|
|
244
|
+
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
|
|
245
|
+
structuredContent: result,
|
|
246
|
+
});
|
|
247
|
+
} catch (e) {
|
|
248
|
+
// A failing tool is an error the model should see and work around, not
|
|
249
|
+
// a transport error that kills the session.
|
|
250
|
+
return reply(id, { content: [{ type: "text", text: `seisin: ${e.message}` }], isError: true });
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
default:
|
|
254
|
+
return fail(id, -32601, `method not found: ${method}`);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** Reads line-delimited JSON-RPC until the input closes. */
|
|
259
|
+
export function serveMcp(version = "0.0.0", input = process.stdin, output = process.stdout) {
|
|
260
|
+
const ctx = { version, write: (s) => output.write(s) };
|
|
261
|
+
input.setEncoding("utf8");
|
|
262
|
+
let buffer = "";
|
|
263
|
+
|
|
264
|
+
input.on("data", (chunk) => {
|
|
265
|
+
buffer += chunk;
|
|
266
|
+
// A megabyte with no newline in it is not a JSON-RPC message, it is a
|
|
267
|
+
// client — or something wearing one — filling this process's memory.
|
|
268
|
+
// spool.js already caps its input; this one did not.
|
|
269
|
+
if (buffer.length > 1_000_000) {
|
|
270
|
+
buffer = "";
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
let nl;
|
|
274
|
+
while ((nl = buffer.indexOf("\n")) !== -1) {
|
|
275
|
+
const line = buffer.slice(0, nl).trim();
|
|
276
|
+
buffer = buffer.slice(nl + 1);
|
|
277
|
+
if (!line) continue;
|
|
278
|
+
try {
|
|
279
|
+
handle(JSON.parse(line), ctx);
|
|
280
|
+
} catch {
|
|
281
|
+
// Unparseable input has no id to answer to, so there is nobody to tell.
|
|
282
|
+
// Staying alive is the only useful response.
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
return new Promise((done) => input.on("end", done));
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
export { TOOLS, HANDLERS, PROTOCOLS };
|
package/src/owners.js
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Who owns a path.
|
|
3
|
+
*
|
|
4
|
+
* This is the part no other agent-permission tool has, and it is why seisin
|
|
5
|
+
* exists. Every hook and sandbox in this space answers yes or no. Answering
|
|
6
|
+
* "no, and it belongs to `frontend`" turns a block into a handoff: the agent
|
|
7
|
+
* knows who to ask, and so do you when you read the log.
|
|
8
|
+
*
|
|
9
|
+
* Owning means being allowed to WRITE it. Reads are not partitioned by owner —
|
|
10
|
+
* agents have to read each other's code to do anything useful. Keys are the
|
|
11
|
+
* exception, and they are handled separately below.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Does this glob cover this path? Supports `**`, `*`, `?` and a trailing `/`.
|
|
16
|
+
*
|
|
17
|
+
* A wildcard-free pattern is a **prefix**, because that is what the kernel is
|
|
18
|
+
* given. `toWritePath` in srt.js hands `src/api` to `allowWrite` unchanged, and
|
|
19
|
+
* a sandbox grants a path and everything under it — so `src/api`, `src/api/`
|
|
20
|
+
* and `src/api/**` are one grant to the OS, and reading the first of them as
|
|
21
|
+
* "that path only" made seisin describe a boundary that was not there.
|
|
22
|
+
*
|
|
23
|
+
* Measured against the real kernel, not reasoned about. With
|
|
24
|
+
* `writes = ["src/api"]`:
|
|
25
|
+
*
|
|
26
|
+
* seisin explain dev write src/api/x.ts -> denied, "has no owner"
|
|
27
|
+
* seisin run dev -- sh -c 'echo > src/api/x.ts' -> the file is written
|
|
28
|
+
*
|
|
29
|
+
* That is the document being tighter than the boundary, which
|
|
30
|
+
* [decisions.md](../docs/decisions.md) names as the one direction this must
|
|
31
|
+
* never fail in — and it is worse than the `src/*` case already recorded there,
|
|
32
|
+
* because nothing looked wrong: `whose` reported the path as unowned while a
|
|
33
|
+
* role could write it, so a reader was told it was protected.
|
|
34
|
+
*
|
|
35
|
+
* Refusing is not the answer here the way it was for `src/*`. "One level down"
|
|
36
|
+
* has no exact translation and had to be refused; a subtree has one, and the
|
|
37
|
+
* kernel is already enforcing it. What was missing was seisin saying so.
|
|
38
|
+
*/
|
|
39
|
+
export function covers(glob, path) {
|
|
40
|
+
const p = normalize(path);
|
|
41
|
+
let g = normalize(glob);
|
|
42
|
+
if (glob.endsWith("/")) g += "/**";
|
|
43
|
+
// The path itself, or anything beneath it. The `/` is load-bearing: without
|
|
44
|
+
// it `src/api` would also cover `src/apifoo.ts`, which the kernel does not.
|
|
45
|
+
if (!WILD.test(g)) return p === g || p.startsWith(g + "/");
|
|
46
|
+
return toRegExp(g).test(p);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The four characters toRegExp() treats as wildcards. */
|
|
50
|
+
const WILD = /[*?[\]]/;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* One spelling per file, before anybody decides anything about it.
|
|
54
|
+
*
|
|
55
|
+
* `..` is the reason this is more than tidying. `src/web/../api/orders.ts` is
|
|
56
|
+
* backend's file, and the unresolved form let it match frontend's `src/web/**`
|
|
57
|
+
* — so `whose` named the wrong owner, the hook raised no request, and the log
|
|
58
|
+
* recorded `allowed` for a write the kernel then refused. The boundary held;
|
|
59
|
+
* every sentence seisin said about it was wrong, which is the half of the tool
|
|
60
|
+
* that is actually ours.
|
|
61
|
+
*
|
|
62
|
+
* A path that climbs above the repo root comes back as `..`, which owns
|
|
63
|
+
* nothing and matches nothing. Outside the repo has no owner by definition.
|
|
64
|
+
*/
|
|
65
|
+
export function normalize(s) {
|
|
66
|
+
const flat = s.replace(/\\/g, "/").replace(/\/+/g, "/").replace(/\/+$/, "");
|
|
67
|
+
const up = [];
|
|
68
|
+
for (const part of flat.split("/")) {
|
|
69
|
+
if (part === "" || part === ".") continue;
|
|
70
|
+
if (part === ".." && up.length && up[up.length - 1] !== "..") { up.pop(); continue; }
|
|
71
|
+
up.push(part);
|
|
72
|
+
}
|
|
73
|
+
return up.join("/");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Glob to RegExp.
|
|
78
|
+
*
|
|
79
|
+
* The wildcards are parked on sentinels before `*` is expanded, because
|
|
80
|
+
* expanding `**` first produces a `.*` whose own `*` the next pass would
|
|
81
|
+
* rewrite again. Getting that order wrong is silent: the regex still compiles
|
|
82
|
+
* and quietly matches the wrong set of files.
|
|
83
|
+
*
|
|
84
|
+
* `src/api/**` also matches `src/api` itself. Without that, the directory a
|
|
85
|
+
* role was just granted comes back ownerless, which reads as a bug every time.
|
|
86
|
+
*
|
|
87
|
+
* Dotfiles are matched like any other name: `*.env` covers `.env`. Shell globs
|
|
88
|
+
* hide them by default, and most glob libraries copy that. A permission tool
|
|
89
|
+
* must not: covering one file too many costs an argument, covering one too few
|
|
90
|
+
* is the hole. When the two readings disagree, this one takes the wider set.
|
|
91
|
+
*/
|
|
92
|
+
function toRegExp(glob) {
|
|
93
|
+
const out = glob
|
|
94
|
+
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
|
|
95
|
+
.replace(/\/\*\*$/, "\u0001")
|
|
96
|
+
.replace(/\*\*\//g, "\u0002")
|
|
97
|
+
.replace(/\*\*/g, "\u0003")
|
|
98
|
+
.replace(/\*/g, "[^/]*")
|
|
99
|
+
.replace(/\?/g, "[^/]")
|
|
100
|
+
.replace(/\u0001/g, "(?:/.*)?")
|
|
101
|
+
.replace(/\u0002/g, "(?:.*/)?")
|
|
102
|
+
.replace(/\u0003/g, ".*");
|
|
103
|
+
return new RegExp("^" + out + "$");
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Every role whose territory covers `path`. Usually one; zero is a finding. */
|
|
107
|
+
export function ownersOf(config, path) {
|
|
108
|
+
return Object.values(config.roles)
|
|
109
|
+
.filter((r) => r.writes.some((g) => covers(g, path)))
|
|
110
|
+
.map((r) => r.name);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Every role allowed to read `key`, matched with or without its extension. */
|
|
114
|
+
export function keyHolders(config, key) {
|
|
115
|
+
const dirs = config.keyDirs ?? [];
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Both sides resolved to a path before anything is compared.
|
|
119
|
+
*
|
|
120
|
+
* A declaration without a directory means the first key directory — that is
|
|
121
|
+
* what settingsFor does — and the hook sees whatever path the tool call
|
|
122
|
+
* used. So `database.txt` in the config and `.secrets/database.txt` from a
|
|
123
|
+
* Read are the same file and must match.
|
|
124
|
+
*
|
|
125
|
+
* What must NOT match is `.secrets/api.txt` against `shared/api.txt`. With
|
|
126
|
+
* two key directories those are different files, and comparing basenames
|
|
127
|
+
* made `explain` answer "allowed" for a key the sandbox would refuse. A
|
|
128
|
+
* wrong yes from the layer whose only job is explaining is worse than no
|
|
129
|
+
* answer at all.
|
|
130
|
+
*/
|
|
131
|
+
const resolve = (s) => (s.includes("/") ? s : `${dirs[0] ?? "."}/${s}`);
|
|
132
|
+
const bare = (s) => s.replace(/^.*\//, "").replace(/\.[^.]+$/, "");
|
|
133
|
+
|
|
134
|
+
const wanted = resolve(key);
|
|
135
|
+
// The extension-less form is how a person asks — `seisin explain f read
|
|
136
|
+
// netlify` — so it stays, and only for an unqualified question.
|
|
137
|
+
const loose = !key.includes("/");
|
|
138
|
+
|
|
139
|
+
return Object.values(config.roles)
|
|
140
|
+
.filter((r) => r.keys.some((k) =>
|
|
141
|
+
resolve(k) === wanted || (loose && bare(k) === bare(key))))
|
|
142
|
+
.map((r) => r.name);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* The sentence a blocked agent should read.
|
|
147
|
+
*
|
|
148
|
+
* Three outcomes, and the third is the one worth having: a path nobody owns is
|
|
149
|
+
* not a permission problem, it is a hole in the map. Saying so is more useful
|
|
150
|
+
* than denying quietly, and it is the only way the hole ever gets fixed.
|
|
151
|
+
*/
|
|
152
|
+
export function explain(config, role, action, target) {
|
|
153
|
+
if (action === "read") {
|
|
154
|
+
const holders = keyHolders(config, target);
|
|
155
|
+
if (holders.includes(role)) return { allowed: true, owners: holders, reason: `${role} declares ${target}` };
|
|
156
|
+
if (holders.length === 0)
|
|
157
|
+
return { allowed: false, owners: [], reason: `no role declares ${target} — add it under a [roles.<name>] keys list` };
|
|
158
|
+
return { allowed: false, owners: holders, reason: `${target} belongs to ${holders.join(", ")}` };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const owners = ownersOf(config, target);
|
|
162
|
+
if (owners.includes(role)) return { allowed: true, owners, reason: `${target} is inside ${role}'s territory` };
|
|
163
|
+
if (owners.length === 0)
|
|
164
|
+
return { allowed: false, owners: [], reason: `${target} has no owner — no role can write it until one claims it` };
|
|
165
|
+
return { allowed: false, owners, reason: `${target} belongs to ${owners.join(", ")}` };
|
|
166
|
+
}
|
package/src/redact.js
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Masks a role's own secrets on their way out of the process.
|
|
3
|
+
*
|
|
4
|
+
* The sandbox stops a role from *getting* a key that is not its own. It cannot
|
|
5
|
+
* stop a role from spilling one it legitimately holds — a `cat` while debugging,
|
|
6
|
+
* an error message that echoes a header, a token printed into a log that is
|
|
7
|
+
* three commits from being pushed. That is a different failure and it needs a
|
|
8
|
+
* different place to stand: the launcher already carries the child's output.
|
|
9
|
+
*
|
|
10
|
+
* ── The trade this makes, stated plainly ──
|
|
11
|
+
* To mask a value, seisin has to read it. So a role's own keys pass through this
|
|
12
|
+
* process in memory. That is a real cost and it buys the only leak that matters
|
|
13
|
+
* in practice; if you would rather it not, `[runtime] redact = false`.
|
|
14
|
+
*
|
|
15
|
+
* ── And what it cannot catch ──
|
|
16
|
+
* Only the literal value, and only on stdout and stderr. A key the agent writes
|
|
17
|
+
* straight to a file never comes through here, and neither does one it has
|
|
18
|
+
* base64'd. This narrows the blast radius of a careless print. It is not a
|
|
19
|
+
* containment boundary, and calling it one would be the sort of claim this
|
|
20
|
+
* README spends its time avoiding.
|
|
21
|
+
*/
|
|
22
|
+
import { Transform } from "node:stream";
|
|
23
|
+
|
|
24
|
+
/** Reads the values this role may read. Unreadable or huge files are skipped. */
|
|
25
|
+
export function secretsOf(settings, readFile) {
|
|
26
|
+
const out = [];
|
|
27
|
+
for (const path of settings.filesystem.allowRead) {
|
|
28
|
+
let text;
|
|
29
|
+
try {
|
|
30
|
+
text = readFile(path, "utf8");
|
|
31
|
+
} catch {
|
|
32
|
+
continue; // declared but not on disk yet: nothing to mask
|
|
33
|
+
}
|
|
34
|
+
if (text.length > 64 * 1024) continue; // not a credential; do not scan a blob
|
|
35
|
+
for (const line of text.split(/\r?\n/)) {
|
|
36
|
+
const value = line.includes("=") ? line.slice(line.indexOf("=") + 1) : line;
|
|
37
|
+
const v = value.trim().replace(/^["']|["']$/g, "");
|
|
38
|
+
// Short strings produce false positives that mangle ordinary output.
|
|
39
|
+
if (v.length >= 8) out.push(v);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
// Longest first, so a value that contains another is masked whole.
|
|
43
|
+
return [...new Set(out)].sort((a, b) => b.length - a.length);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* A stream that replaces each secret with `‹redacted:name›`.
|
|
48
|
+
*
|
|
49
|
+
* It carries the tail of each chunk forward, because a value split across a
|
|
50
|
+
* buffer boundary would otherwise sail through — which is the one case a
|
|
51
|
+
* naive implementation always gets wrong and never notices.
|
|
52
|
+
*/
|
|
53
|
+
export function redactor(secrets, label = "redacted") {
|
|
54
|
+
if (secrets.length === 0) return null;
|
|
55
|
+
const longest = secrets[0].length;
|
|
56
|
+
let tail = "";
|
|
57
|
+
|
|
58
|
+
return new Transform({
|
|
59
|
+
transform(chunk, _enc, done) {
|
|
60
|
+
// Mask the WHOLE accumulated text first, then cut. Doing it the other way
|
|
61
|
+
// — masking only the part about to be emitted — lets a secret that
|
|
62
|
+
// straddles the cut through in two innocent halves. Written that way
|
|
63
|
+
// first; the split-buffer test caught it on the first run.
|
|
64
|
+
const masked = mask(tail + chunk.toString("utf8"), secrets, label);
|
|
65
|
+
const keep = Math.min(longest - 1, masked.length);
|
|
66
|
+
this.push(masked.slice(0, masked.length - keep));
|
|
67
|
+
tail = masked.slice(masked.length - keep);
|
|
68
|
+
done();
|
|
69
|
+
},
|
|
70
|
+
flush(done) {
|
|
71
|
+
this.push(mask(tail, secrets, label));
|
|
72
|
+
tail = "";
|
|
73
|
+
done();
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function mask(text, secrets, label) {
|
|
79
|
+
let out = text;
|
|
80
|
+
for (const s of secrets) out = out.split(s).join(`‹${label}›`);
|
|
81
|
+
return out;
|
|
82
|
+
}
|