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/inspect.js
ADDED
|
@@ -0,0 +1,477 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What `check` knows, without printing any of it.
|
|
3
|
+
*
|
|
4
|
+
* The warnings below used to live inside the command, interleaved with the
|
|
5
|
+
* writes that printed them, which made them unreachable from a test: the only
|
|
6
|
+
* way to assert that seisin notices a repo sitting inside shared scratch was to
|
|
7
|
+
* run the binary and grep its output. They are the most valuable thing `check`
|
|
8
|
+
* does — each one is a way the policy silently does not hold — so they belong
|
|
9
|
+
* where they can be exercised directly.
|
|
10
|
+
*/
|
|
11
|
+
import { resolve, dirname, basename, relative } from "node:path";
|
|
12
|
+
import { realpathSync, lstatSync, readdirSync } from "node:fs";
|
|
13
|
+
import { ownersOf } from "./owners.js";
|
|
14
|
+
import { wired } from "./commands/wire.js";
|
|
15
|
+
import { RUNTIME_WRITES, CREDENTIAL_HOMES, expand, settingsFor } from "./srt.js";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* A report on one config: the roles, and every way the map lies.
|
|
19
|
+
*
|
|
20
|
+
* `only` narrows to a single role, and an unknown name is an error rather than
|
|
21
|
+
* an empty report — asking about a role that does not exist is a typo, and
|
|
22
|
+
* answering it with silence is how a typo becomes a belief.
|
|
23
|
+
*/
|
|
24
|
+
export function inspect(config, only = null, where = config.path) {
|
|
25
|
+
if (only && !config.roles[only]) throw new Error(`unknown role "${only}"`);
|
|
26
|
+
const roles = only ? [config.roles[only]] : Object.values(config.roles);
|
|
27
|
+
|
|
28
|
+
return {
|
|
29
|
+
where,
|
|
30
|
+
roles: roles.map((r) => ({ name: r.name, writes: r.writes, keys: r.keys })),
|
|
31
|
+
shared: sharedPaths(config, roles),
|
|
32
|
+
warnings: warningsFor(config, roles),
|
|
33
|
+
limits: LIMITS,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* What the tool cannot do at all — as opposed to `warnings`, which are things
|
|
39
|
+
* wrong with *this* config.
|
|
40
|
+
*
|
|
41
|
+
* Kept apart on purpose. A limit printed as a warning reads as something the
|
|
42
|
+
* operator could fix, and after the third run it reads as nothing at all.
|
|
43
|
+
*
|
|
44
|
+
* These are the sentences someone needs before they trust the map on screen
|
|
45
|
+
* more than it deserves, so `check` prints them every time rather than behind a
|
|
46
|
+
* flag: the moment to learn that deletes are not covered is while you are
|
|
47
|
+
* looking at the policy, not afterwards.
|
|
48
|
+
*/
|
|
49
|
+
const LIMITS = [
|
|
50
|
+
...(process.platform === "darwin" ? [] : [{
|
|
51
|
+
kind: "kernel-denials-unreadable",
|
|
52
|
+
headline: "refusals by the kernel are not recorded on this platform",
|
|
53
|
+
detail:
|
|
54
|
+
"on macOS `seisin run` reads them out of the system log, so a denial the hook never " +
|
|
55
|
+
"saw still lands in `seisin log` with its owner and still queues a request. There is no " +
|
|
56
|
+
"equivalent to read here — bubblewrap does not log refusals, and the runtime's substitute " +
|
|
57
|
+
"is not reachable from outside it. The boundary holds exactly as well; what you lose is " +
|
|
58
|
+
"seeing it work. Run `seisin wire` so the hook records what it can. The ask is upstream: " +
|
|
59
|
+
"github.com/carlostapiaolguin3-stack/seisin/blob/main/docs/upstream/cli-violations.md",
|
|
60
|
+
}]),
|
|
61
|
+
{
|
|
62
|
+
kind: "keys-only-what-you-declared",
|
|
63
|
+
headline: "key isolation covers the directory you declared, and nothing else",
|
|
64
|
+
detail:
|
|
65
|
+
"a credential sitting in the repo outside [keys] dir is ordinary readable " +
|
|
66
|
+
"content to every role. Run `seisin scan` to find them — check does not, " +
|
|
67
|
+
"because reading the whole tree on every invocation is how a command stops " +
|
|
68
|
+
"being run.",
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
kind: "observe-cannot-see-the-network",
|
|
72
|
+
headline: "`--observe` opens the filesystem, never the network",
|
|
73
|
+
detail:
|
|
74
|
+
"so it cannot tell you which domains an agent needs. The runtime does record every " +
|
|
75
|
+
"refusal the proxy makes — host, port and reason — but only a caller that embeds the " +
|
|
76
|
+
"library can read them; nothing reaches a caller that runs the binary, which is what " +
|
|
77
|
+
"seisin does. So for an agent whose endpoints are not published, the options are to " +
|
|
78
|
+
"find them another way or drop the network restriction for it. The ask is upstream: " +
|
|
79
|
+
"github.com/carlostapiaolguin3-stack/seisin/blob/main/docs/upstream/cli-violations.md",
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
kind: "unlink-uncovered",
|
|
83
|
+
headline: "a role can delete inside its own territory",
|
|
84
|
+
detail:
|
|
85
|
+
// The path that used to be here ships with the repo and not with the npm
|
|
86
|
+
// package, so for most readers it named a file they do not have.
|
|
87
|
+
"writes and deletes are one permission to the kernel, so `rm` inside a " +
|
|
88
|
+
"role's own folders succeeds. Closing it needs denyUnlink in the sandbox " +
|
|
89
|
+
"runtime: github.com/carlostapiaolguin3-stack/seisin#how-it-holds",
|
|
90
|
+
},
|
|
91
|
+
];
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Paths more than one role claims.
|
|
95
|
+
*
|
|
96
|
+
* Overlap is allowed and sometimes right — a hotfix role deliberately reaches
|
|
97
|
+
* into the territory of the role that normally owns a repo. It is reported so
|
|
98
|
+
* that it stays a decision somebody made rather than a thing that drifted.
|
|
99
|
+
*/
|
|
100
|
+
export function sharedPaths(config, roles = Object.values(config.roles)) {
|
|
101
|
+
const seen = new Set();
|
|
102
|
+
for (const r of roles)
|
|
103
|
+
for (const glob of r.writes)
|
|
104
|
+
if (ownersOf(config, glob.replace(/\/\*\*$/, "")).length > 1) seen.add(glob);
|
|
105
|
+
return [...seen];
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* What a role can still read in YOUR home, which is the limit people are most
|
|
110
|
+
* surprised by.
|
|
111
|
+
*
|
|
112
|
+
* `~/.ssh`, `~/.aws/credentials`, `~/.npmrc` and `~/.config/gh/hosts.yml` are
|
|
113
|
+
* ordinary readable files to every role unless `[runtime] isolate` says
|
|
114
|
+
* otherwise, and nothing on screen said so. Reads outside the key directories
|
|
115
|
+
* being open is a deliberate trade - an agent that cannot read the machine
|
|
116
|
+
* cannot work - but a trade nobody was shown is not a trade they made.
|
|
117
|
+
*
|
|
118
|
+
* It reports the level rather than pushing one. `credentials` closes those four
|
|
119
|
+
* and leaves the agent logged in; `home` closes the agent's own directories too
|
|
120
|
+
* and costs the session. Which of the two you want is the threat model, which
|
|
121
|
+
* is why there is no default beyond off.
|
|
122
|
+
*/
|
|
123
|
+
function homeReachWarning(config) {
|
|
124
|
+
const level = config.isolate === true ? "home" : config.isolate;
|
|
125
|
+
if (level === "home") return null; // nothing left to say
|
|
126
|
+
|
|
127
|
+
const where = CREDENTIAL_HOMES.slice(0, 4).join(" ");
|
|
128
|
+
return level === "credentials"
|
|
129
|
+
? {
|
|
130
|
+
kind: "home-partly-open",
|
|
131
|
+
headline: "your credentials are closed to every role; the agent's own directories are not",
|
|
132
|
+
detail:
|
|
133
|
+
`isolate = "credentials" denies ${where} and the rest. What stays open is ` +
|
|
134
|
+
"~/.claude and ~/.codex, which is what keeps the agent logged in, so the roles " +
|
|
135
|
+
'share one session and can read each other. `isolate = "home"` closes those ' +
|
|
136
|
+
"too, and then every CLI in the box will ask you to log in again.",
|
|
137
|
+
}
|
|
138
|
+
: {
|
|
139
|
+
kind: "home-open",
|
|
140
|
+
headline: `every role can read ${where} and the rest of your home`,
|
|
141
|
+
detail:
|
|
142
|
+
"reads outside the [keys] dir are open on purpose - an agent that cannot read the " +
|
|
143
|
+
"machine cannot work - but that includes the places credentials live. " +
|
|
144
|
+
'`[runtime] isolate = "credentials"` closes them, and the agent stays logged in.',
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
/** Every way this policy does not hold, each with what to do about it. */
|
|
150
|
+
function warningsFor(config, roles) {
|
|
151
|
+
const warnings = [];
|
|
152
|
+
|
|
153
|
+
// A pattern the kernel cannot be given is not a policy, it is a sentence that
|
|
154
|
+
// reads like one. `run` refuses it; `check` exists precisely so you find that
|
|
155
|
+
// out while reading the map rather than mid-turn.
|
|
156
|
+
for (const r of roles) {
|
|
157
|
+
try {
|
|
158
|
+
settingsFor(config, r.name);
|
|
159
|
+
} catch (e) {
|
|
160
|
+
// Whatever settingsFor refuses, `run` will refuse too. The label used to
|
|
161
|
+
// say "unenforceable-glob" for every one of them, which mislabelled the
|
|
162
|
+
// key-outside-its-directory error as a glob problem — a warning that
|
|
163
|
+
// names the wrong cause sends the reader to the wrong line.
|
|
164
|
+
warnings.push({
|
|
165
|
+
kind: "cannot-be-enforced",
|
|
166
|
+
headline: `${r.name}: ${e.message.split("\n")[0]}`,
|
|
167
|
+
detail: e.message.split("\n").slice(1).map((s) => s.trim()).filter(Boolean).join(" "),
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
const shared = sharedPaths(config, roles);
|
|
172
|
+
|
|
173
|
+
if (shared.length)
|
|
174
|
+
warnings.push({
|
|
175
|
+
kind: "shared",
|
|
176
|
+
headline: `${shared.length} path(s) claimed by more than one role: ${shared.join(" ")}`,
|
|
177
|
+
detail: "Overlap is allowed — seisin will name every owner. It is listed so it stays a decision.",
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
// Scratch is granted to every role, so a repo sitting inside it is writable
|
|
181
|
+
// by all of them no matter what the territory says. Saying so is the
|
|
182
|
+
// difference between a limitation and a trap — and it is not hypothetical:
|
|
183
|
+
// the test fixture lived in the temp dir and made a boundary test pass for
|
|
184
|
+
// the wrong reason.
|
|
185
|
+
// Both sides resolved, or the comparison is a coin flip. `expand()` follows
|
|
186
|
+
// symlinks — it has to, because on macOS /tmp is a link to /private/tmp and a
|
|
187
|
+
// grant written as /tmp grants nothing — so the root has to be followed too.
|
|
188
|
+
// Without this a repo at /tmp/x is never reported as sitting in scratch,
|
|
189
|
+
// which is the one case where the warning matters most.
|
|
190
|
+
const scratch = (config.runtimeWrites ?? RUNTIME_WRITES).map(expand);
|
|
191
|
+
const root = realOrSelf(resolve(config.root));
|
|
192
|
+
const inside = scratch.filter((s) => root === s || root.startsWith(s + "/"));
|
|
193
|
+
if (inside.length)
|
|
194
|
+
warnings.push({
|
|
195
|
+
kind: "scratch",
|
|
196
|
+
headline: `this repo lives inside shared scratch space (${inside[0]})`,
|
|
197
|
+
detail: "Every role can write scratch, so territory does not hold here. Move the repo, or set [runtime] writes = [].",
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* A role that can write code and cannot reach any inference API.
|
|
202
|
+
*
|
|
203
|
+
* Reported three separate times, by three different people, with the same
|
|
204
|
+
* shape each time: a list of what agents need, written somewhere that does
|
|
205
|
+
* not know which agent will run. The last instance cost six bench runs that
|
|
206
|
+
* finished in eight seconds with `403 Connection blocked by network
|
|
207
|
+
* allowlist` — which in a results table reads as "this model cannot do the
|
|
208
|
+
* task", not as a policy error.
|
|
209
|
+
*
|
|
210
|
+
* What is deliberately NOT here: a registry of agents and their endpoints.
|
|
211
|
+
* That is the same defect in a new place, and the reporter said so before I
|
|
212
|
+
* could. The list below is only ever consulted to decide whether to print a
|
|
213
|
+
* sentence, so being wrong about it costs a false warning — never a broken
|
|
214
|
+
* run. That asymmetry is the whole reason it is allowed to exist, and the
|
|
215
|
+
* warning says out loud that the list is ours and may not know your agent.
|
|
216
|
+
*/
|
|
217
|
+
const INFERENCE = /(anthropic|openai|googleapis|generativelanguage|bedrock|azure|openrouter|mistral|groq|together|deepseek|x\.ai|cohere|ollama|localhost|127\.0\.0\.1)/i;
|
|
218
|
+
for (const r of roles) {
|
|
219
|
+
const domains = r.network ?? config.allowedDomains ?? [];
|
|
220
|
+
if (domains.some((d) => INFERENCE.test(d))) continue;
|
|
221
|
+
warnings.push({
|
|
222
|
+
kind: "no-model-endpoint",
|
|
223
|
+
headline: domains.length
|
|
224
|
+
? `${r.name} has ${domains.length} allowed domain(s) and none looks like a model API`
|
|
225
|
+
: `${r.name} may reach no network at all`,
|
|
226
|
+
detail:
|
|
227
|
+
"an agent that cannot reach its own model fails at startup with a 403 from the " +
|
|
228
|
+
"egress proxy, which reads as a broken install or a bad model rather than a policy. " +
|
|
229
|
+
"This check knows a handful of providers and will not know yours — it is a question, " +
|
|
230
|
+
"not a verdict.",
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Without the hook, half the record is missing rather than all of it: on macOS
|
|
235
|
+
// `seisin run` reads the kernel's own refusals, so denials still land and
|
|
236
|
+
// still queue a request. What goes unrecorded is everything the kernel
|
|
237
|
+
// allowed — which is the half `init --from-observations` is built out of, and
|
|
238
|
+
// the half that tells "nothing was denied" apart from "nobody was watching".
|
|
239
|
+
// The one limit that is about YOUR machine rather than about the repo.
|
|
240
|
+
const reach = homeReachWarning(config);
|
|
241
|
+
if (reach) warnings.push(reach);
|
|
242
|
+
|
|
243
|
+
if (!wired(config.root))
|
|
244
|
+
warnings.push({
|
|
245
|
+
kind: "hook-not-wired",
|
|
246
|
+
headline: "only denials are being recorded: the agent has not been told to run the hook",
|
|
247
|
+
detail:
|
|
248
|
+
process.platform === "darwin"
|
|
249
|
+
? "the boundary holds either way, and refusals are still logged and still queue a " +
|
|
250
|
+
"request — `seisin run` reads them from the kernel. What is missing is every " +
|
|
251
|
+
"action that was allowed, so `review` cannot tell you which grants are dead and " +
|
|
252
|
+
"`init --from-observations` has nothing to build from. Run `seisin wire` once in " +
|
|
253
|
+
"this repo."
|
|
254
|
+
: "the boundary holds either way — but on this platform the kernel's refusals are " +
|
|
255
|
+
"not readable either, so log, watch, requests, grant and review all read a record " +
|
|
256
|
+
"that nobody is writing. Run `seisin wire` once in this repo.",
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
// A territory that leaves the repo is supported — a role can own its handover
|
|
260
|
+
// note one level up — but it is a different promise from "this repo, divided",
|
|
261
|
+
// and it should be a sentence somebody chose rather than a line that drifted.
|
|
262
|
+
const loose = []; // roles granting individual files
|
|
263
|
+
const listed = []; // role + folder pairs that enumerate
|
|
264
|
+
|
|
265
|
+
for (const r of roles) {
|
|
266
|
+
const out = r.writes.filter((w) => w.startsWith("/") || w.startsWith("../"));
|
|
267
|
+
if (out.length)
|
|
268
|
+
warnings.push({
|
|
269
|
+
kind: "territory-outside-repo",
|
|
270
|
+
headline: `${r.name} writes outside this repo: ${out.join(" ")}`,
|
|
271
|
+
detail: "the sandbox will grant it. Nothing here checks what lives there.",
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* A file granted by name does not come with its neighbours.
|
|
277
|
+
*
|
|
278
|
+
* Measured, in production, and it survived two human reviews of the policy:
|
|
279
|
+
* a role was granted its database file and still could not write the
|
|
280
|
+
* database. The database opens a second file beside the first — a journal —
|
|
281
|
+
* and the sibling was outside the grant, so the write failed in the
|
|
282
|
+
* database's own words ("readonly") rather than as a permission error.
|
|
283
|
+
*
|
|
284
|
+
* What is deliberately NOT here: a list of which programs write which
|
|
285
|
+
* siblings. That would ask seisin to know about databases, and the next
|
|
286
|
+
* question is why it does not know about a lock file, the temporary file of
|
|
287
|
+
* an atomic write, or an editor's backup. The list has no end and every entry
|
|
288
|
+
* is a guess about a repo nobody foresaw. The general form needs no list: the
|
|
289
|
+
* kernel grants a path and everything under it, so a grant on a file covers
|
|
290
|
+
* the file and nothing else. Whether a tool will want a neighbour is the
|
|
291
|
+
* reader's to know; that the neighbour is not granted is arithmetic.
|
|
292
|
+
*/
|
|
293
|
+
for (const r of roles) {
|
|
294
|
+
const { files, subtrees } = territoryOf(config, r);
|
|
295
|
+
// A file inside one of the role's own subtrees has its siblings covered by
|
|
296
|
+
// that subtree, so naming it changes nothing and warns about nothing.
|
|
297
|
+
const alone = files.filter((f) => !subtrees.some((s) => under(dirname(f.abs), s)));
|
|
298
|
+
if (alone.length)
|
|
299
|
+
loose.push({ role: r.name, n: alone.length, of: r.writes.length, globs: alone.map((f) => f.glob) });
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* A territory written as a list of files is a photograph of the folder.
|
|
304
|
+
*
|
|
305
|
+
* A sandbox cannot say "the source files in this folder" — one level down
|
|
306
|
+
* is not a prefix, and `src/*` is refused above for exactly that reason,
|
|
307
|
+
* correctly. So a territory that means that has to be written as the files
|
|
308
|
+
* that exist today, and the day a file is added the policy is one behind:
|
|
309
|
+
* the owning role cannot write its own new file, and nothing fails loudly.
|
|
310
|
+
*
|
|
311
|
+
* Reported as a count, never as a verdict. "Comparable" here means only
|
|
312
|
+
* "a regular file in the same folder" — not the same extension, not the
|
|
313
|
+
* same shape of name, because either of those is a guess about what the
|
|
314
|
+
* territory *meant*, and a guess about intent is the thing this check
|
|
315
|
+
* exists to avoid. The unnamed files are listed by name instead, so the
|
|
316
|
+
* reader can see in one glance whether the gap is an oversight or a
|
|
317
|
+
* decision. seisin cannot tell, and does not pretend to.
|
|
318
|
+
*
|
|
319
|
+
* Silent when a folder has fewer than two named files (one file is a
|
|
320
|
+
* grant, not an enumeration) and when the folder is also granted as a
|
|
321
|
+
* subtree (then the list is decorative).
|
|
322
|
+
*/
|
|
323
|
+
const byDir = new Map();
|
|
324
|
+
for (const f of alone) {
|
|
325
|
+
const dir = dirname(f.abs);
|
|
326
|
+
if (!byDir.has(dir)) byDir.set(dir, []);
|
|
327
|
+
byDir.get(dir).push(basename(f.abs));
|
|
328
|
+
}
|
|
329
|
+
for (const [dir, named] of byDir) {
|
|
330
|
+
if (named.length < 2) continue;
|
|
331
|
+
const onDisk = regularFilesIn(dir);
|
|
332
|
+
const unnamed = onDisk.filter((n) => !named.includes(n));
|
|
333
|
+
if (!unnamed.length) continue;
|
|
334
|
+
const shown = relative(resolve(config.root), dir);
|
|
335
|
+
const more = unnamed.length > 8 ? ` … and ${unnamed.length - 8} more` : "";
|
|
336
|
+
listed.push({ role: r.name, named: named.length, total: onDisk.length,
|
|
337
|
+
where: shown ? shown + "/" : "the repo root", unnamed });
|
|
338
|
+
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* One warning per finding, not one per role.
|
|
344
|
+
*
|
|
345
|
+
* These were emitted inside the role loop, which reads fine on the two-role
|
|
346
|
+
* fixture they were written against and falls apart on a real policy: 30
|
|
347
|
+
* roles produced 39 of these lines and took `seisin check` from 30 lines to
|
|
348
|
+
* **297**. That is the defect the detail text below is about — a check nobody
|
|
349
|
+
* finishes reading protects nobody — reached from the other side.
|
|
350
|
+
*
|
|
351
|
+
* So the count leads and a few examples follow. Whoever wants the full list
|
|
352
|
+
* has `seisin check <role>`, which is the question they were asking anyway.
|
|
353
|
+
*/
|
|
354
|
+
if (loose.length) {
|
|
355
|
+
const shown = loose.slice(0, 3).map((l) => `${l.role} (${l.n} of ${l.of})`).join(", ");
|
|
356
|
+
const more = loose.length > 3 ? `, and ${loose.length - 3} more` : "";
|
|
357
|
+
warnings.push({
|
|
358
|
+
kind: "siblings-uncovered",
|
|
359
|
+
headline:
|
|
360
|
+
`${loose.length} role(s) grant individual files rather than folders: ${shown}${more}`,
|
|
361
|
+
detail:
|
|
362
|
+
"the kernel grants exactly that path. Anything a tool creates beside it — a " +
|
|
363
|
+
"database's journal, a lock file, the temporary file of an atomic write — is a " +
|
|
364
|
+
"sibling, and a sibling is outside the grant; the failure arrives in the tool's " +
|
|
365
|
+
"own words, not as a permission error. Grant the folder where the tool needs " +
|
|
366
|
+
"neighbours. `seisin check <role>` names the paths for one role.",
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
if (listed.length) {
|
|
371
|
+
const shown = listed.slice(0, 3)
|
|
372
|
+
.map((l) => `${l.role} names ${l.named} of ${l.total} in ${l.where}`).join("; ");
|
|
373
|
+
const more = listed.length > 3 ? `; and ${listed.length - 3} more` : "";
|
|
374
|
+
warnings.push({
|
|
375
|
+
kind: "enumerated-territory",
|
|
376
|
+
headline: `${listed.length} territory(ies) list some files of a folder: ${shown}${more}`,
|
|
377
|
+
detail:
|
|
378
|
+
`not named, in the first of them: ${listed[0].unnamed.slice(0, 8).join(" ")}` +
|
|
379
|
+
`${listed[0].unnamed.length > 8 ? ` … and ${listed[0].unnamed.length - 8} more` : ""}` +
|
|
380
|
+
`. A sandbox grants a path, ` +
|
|
381
|
+
`not "the files of this kind here", so a territory that means that is the list of files ` +
|
|
382
|
+
`that existed when it was written — the next one added lands outside it, and nothing ` +
|
|
383
|
+
`fails loudly. Whether these are left out on purpose is not something a count can tell.`,
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// `env` is a hole in the [keys] model, on purpose: some tools only take a
|
|
388
|
+
// credential through the environment. It should still be loud, because it is
|
|
389
|
+
// the one place a secret reaches a role without being a declared key.
|
|
390
|
+
const SECRETISH = /(TOKEN|KEY|SECRET|PASSWORD|PASSWD|AUTH|COOKIE|SESSION|PRIVATE|CREDENTIAL)/i;
|
|
391
|
+
for (const r of roles) {
|
|
392
|
+
const risky = (r.env ?? []).filter((n) => SECRETISH.test(n));
|
|
393
|
+
if (risky.length)
|
|
394
|
+
warnings.push({
|
|
395
|
+
kind: "secret-through-env",
|
|
396
|
+
headline: `${r.name} receives ${risky.join(", ")} through the environment`,
|
|
397
|
+
detail:
|
|
398
|
+
"that bypasses [keys] entirely: it is not scoped, not redacted by name, and not " +
|
|
399
|
+
"visible in the key map. Declare it as a key file where the tool allows one.",
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
if (config.keyDirs.length === 0 && Object.values(config.roles).some((r) => r.keys.length))
|
|
404
|
+
warnings.push({
|
|
405
|
+
kind: "keys-unscoped",
|
|
406
|
+
headline: "keys are listed but [keys] dir is unset — nothing will be scoped",
|
|
407
|
+
detail: null,
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
return warnings;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* A role's territory, sorted by what the kernel will do with each line.
|
|
415
|
+
*
|
|
416
|
+
* Three shapes get through `settingsFor` (see EXPRESSIBLE in srt.js): the whole
|
|
417
|
+
* repo, a subtree `x/**`, and a literal path. The literal path is the one the
|
|
418
|
+
* text cannot settle on its own — `data/base.sqlite` and `data` are both
|
|
419
|
+
* literal, and to the kernel the second is a subtree because it is a directory.
|
|
420
|
+
* So the disk is asked, once per line: a regular file is a file, a directory is
|
|
421
|
+
* a subtree, and anything else — absent, a symlink, a socket — goes in neither
|
|
422
|
+
* count rather than being guessed at. That is one `lstat` per declared path,
|
|
423
|
+
* bounded by the length of the policy and not the size of the repo, which is
|
|
424
|
+
* the line `check` stays on.
|
|
425
|
+
*
|
|
426
|
+
* Subtrees come back as the absolute prefix the kernel will be handed, so that
|
|
427
|
+
* "is this file's folder inside that grant" is the same comparison the kernel
|
|
428
|
+
* makes — a prefix — and not a second reading of the glob.
|
|
429
|
+
*/
|
|
430
|
+
function territoryOf(config, role) {
|
|
431
|
+
const root = resolve(config.root);
|
|
432
|
+
const abs = (p) => resolve(root, p);
|
|
433
|
+
const files = [];
|
|
434
|
+
const subtrees = [];
|
|
435
|
+
for (const glob of role.writes) {
|
|
436
|
+
if (glob === "**" || glob.endsWith("/**")) {
|
|
437
|
+
subtrees.push(abs(glob === "**" ? "." : glob.slice(0, -3)));
|
|
438
|
+
continue;
|
|
439
|
+
}
|
|
440
|
+
if (/[*?[\]]/.test(glob)) continue; // already refused above as cannot-be-enforced
|
|
441
|
+
const s = lstatOrNull(abs(glob));
|
|
442
|
+
if (s?.isFile()) files.push({ glob, abs: abs(glob) });
|
|
443
|
+
else if (s?.isDirectory()) subtrees.push(abs(glob));
|
|
444
|
+
}
|
|
445
|
+
return { files, subtrees };
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/** Is `p` the prefix, or somewhere beneath it. The kernel's own test, in one line. */
|
|
449
|
+
function under(p, prefix) {
|
|
450
|
+
return p === prefix || p.startsWith(prefix + "/");
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/** The regular files directly inside `dir`, by name. Not folders, not links, not deeper. */
|
|
454
|
+
function regularFilesIn(dir) {
|
|
455
|
+
try {
|
|
456
|
+
return readdirSync(dir, { withFileTypes: true }).filter((d) => d.isFile()).map((d) => d.name).sort();
|
|
457
|
+
} catch {
|
|
458
|
+
return [];
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function lstatOrNull(p) {
|
|
463
|
+
try {
|
|
464
|
+
return lstatSync(p);
|
|
465
|
+
} catch {
|
|
466
|
+
return null;
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/** The path with symlinks followed, or the path itself if it is not on disk. */
|
|
471
|
+
function realOrSelf(p) {
|
|
472
|
+
try {
|
|
473
|
+
return realpathSync(p);
|
|
474
|
+
} catch {
|
|
475
|
+
return p;
|
|
476
|
+
}
|
|
477
|
+
}
|
package/src/layout.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where seisin's own files live. Names only — no logic, no imports.
|
|
3
|
+
*
|
|
4
|
+
* These were spread across the modules that happened to create each file, and
|
|
5
|
+
* that produced one dependency that points the wrong way: `srt.js`, whose job
|
|
6
|
+
* is translating a policy into a sandbox, imported `log.js` purely to learn the
|
|
7
|
+
* name of the state directory it has to keep writable. A module that decides
|
|
8
|
+
* what the kernel may allow should not know that logging exists.
|
|
9
|
+
*
|
|
10
|
+
* A leaf with no imports is the cheapest place to put a shared name, and it
|
|
11
|
+
* keeps the graph honest: everything here is depended upon, nothing here
|
|
12
|
+
* depends on anything.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** The policy file, looked up from the working directory upward. */
|
|
16
|
+
export const CONFIG_NAME = "seisin.toml";
|
|
17
|
+
|
|
18
|
+
/** Everything seisin writes goes under this, at the policy file's directory. */
|
|
19
|
+
export const STATE_DIR = ".seisin";
|
|
20
|
+
|
|
21
|
+
/** The append-only record of every decision, inside STATE_DIR. */
|
|
22
|
+
export const LOG_NAME = "log.jsonl";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* A value, written as a TOML string — or refused.
|
|
26
|
+
*
|
|
27
|
+
* The subset has no escapes, so a quote or a newline cannot be represented at
|
|
28
|
+
* all. Every place that emits config went its own way about this: applyGrant
|
|
29
|
+
* checked, init did not, and renderObserved built lines out of log targets,
|
|
30
|
+
* which is text an agent chose. One rule, in one place, so the next emitter
|
|
31
|
+
* inherits it instead of rediscovering it.
|
|
32
|
+
*/
|
|
33
|
+
export function tomlString(s) {
|
|
34
|
+
const v = String(s);
|
|
35
|
+
if (/["\r\n]/.test(v))
|
|
36
|
+
throw new Error(
|
|
37
|
+
`cannot write ${JSON.stringify(v)} into ${CONFIG_NAME}: the format has no way to ` +
|
|
38
|
+
`represent a quote or a newline inside a value.`);
|
|
39
|
+
return `"${v}"`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** A role name, written as a table header — or refused. */
|
|
43
|
+
export function tomlName(s) {
|
|
44
|
+
if (!/^[A-Za-z0-9_-]+$/.test(String(s)))
|
|
45
|
+
throw new Error(`cannot write role name ${JSON.stringify(s)}: letters, digits, _ and - only.`);
|
|
46
|
+
return String(s);
|
|
47
|
+
}
|
package/src/log.js
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One append-only JSONL file, and that is the whole storage design.
|
|
3
|
+
*
|
|
4
|
+
* `seisin log`, `seisin watch` and the console all read this file. There is no
|
|
5
|
+
* daemon and no database, because the moment there is one, seisin stops being a
|
|
6
|
+
* launcher you can reason about and becomes a service you have to operate. A
|
|
7
|
+
* file that only grows is the version of "live" that survives a crash, a reboot
|
|
8
|
+
* and being read by three things at once.
|
|
9
|
+
*
|
|
10
|
+
* Append-only also means the log is evidence. Nothing here rewrites a line, so
|
|
11
|
+
* what an agent tried last night reads the same today — and since the change in
|
|
12
|
+
* spool.js, nothing inside the sandbox can rewrite one either. append() below
|
|
13
|
+
* tries the parent's socket first for exactly that reason; writing the file
|
|
14
|
+
* directly is what happens when there is no parent, outside the box.
|
|
15
|
+
*/
|
|
16
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync } from "node:fs";
|
|
17
|
+
import { dirname, join } from "node:path";
|
|
18
|
+
import { STATE_DIR, LOG_NAME } from "./layout.js";
|
|
19
|
+
import { send } from "./spool.js";
|
|
20
|
+
|
|
21
|
+
export { STATE_DIR, LOG_NAME } from "./layout.js";
|
|
22
|
+
|
|
23
|
+
export function logPath(root) {
|
|
24
|
+
return join(root, STATE_DIR, LOG_NAME);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Appends one decision.
|
|
29
|
+
*
|
|
30
|
+
* Failing to write must never take the agent down with it: a permission tool
|
|
31
|
+
* that breaks the build because its disk is full has done more damage than the
|
|
32
|
+
* thing it was watching for. Errors are swallowed on purpose, and the caller
|
|
33
|
+
* gets `false` if it cares.
|
|
34
|
+
*/
|
|
35
|
+
export function append(file, entry) {
|
|
36
|
+
// Confined: the parent owns the file, we only get to send a line. See spool.js.
|
|
37
|
+
if (send("log", entry)) return true;
|
|
38
|
+
try {
|
|
39
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
40
|
+
appendFileSync(file, JSON.stringify({ at: new Date().toISOString(), ...entry }) + "\n");
|
|
41
|
+
return true;
|
|
42
|
+
} catch {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Reads entries, newest last. A malformed line is skipped, never fatal. */
|
|
48
|
+
export function read(file, { role, verdict, since, limit = 0 } = {}) {
|
|
49
|
+
if (!existsSync(file)) return [];
|
|
50
|
+
let out = [];
|
|
51
|
+
for (const line of readFileSync(file, "utf8").split("\n")) {
|
|
52
|
+
if (!line.trim()) continue;
|
|
53
|
+
let e;
|
|
54
|
+
try {
|
|
55
|
+
e = JSON.parse(line);
|
|
56
|
+
} catch {
|
|
57
|
+
continue; // a half-written line from a killed process is not an error
|
|
58
|
+
}
|
|
59
|
+
if (role && e.role !== role) continue;
|
|
60
|
+
if (verdict && e.verdict !== verdict) continue;
|
|
61
|
+
if (since && e.at < since) continue;
|
|
62
|
+
out.push(e);
|
|
63
|
+
}
|
|
64
|
+
if (limit > 0 && out.length > limit) out = out.slice(-limit);
|
|
65
|
+
return out;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Byte offset of the end of the file, for `watch` to start from. */
|
|
69
|
+
export function size(file) {
|
|
70
|
+
try {
|
|
71
|
+
return statSync(file).size;
|
|
72
|
+
} catch {
|
|
73
|
+
return 0;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* What each role actually reached for, from the log.
|
|
79
|
+
*
|
|
80
|
+
* This is the reason the log exists. A policy written by hand on day one is a
|
|
81
|
+
* guess, and the first unjustified `denied` is when a tool gets uninstalled.
|
|
82
|
+
* Observed first, written second.
|
|
83
|
+
*/
|
|
84
|
+
export function observed(entries) {
|
|
85
|
+
const roles = new Map();
|
|
86
|
+
for (const e of entries) {
|
|
87
|
+
if (!e.role || !e.target) continue;
|
|
88
|
+
const r = roles.get(e.role) ?? { writes: new Set(), keys: new Set() };
|
|
89
|
+
if (e.action === "read" && e.kind === "key") r.keys.add(e.target);
|
|
90
|
+
else if (e.action === "write") r.writes.add(e.target);
|
|
91
|
+
roles.set(e.role, r);
|
|
92
|
+
}
|
|
93
|
+
return roles;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Collapses observed file paths into directory globs.
|
|
98
|
+
*
|
|
99
|
+
* A policy listing 400 individual files is not a policy, it is a transcript.
|
|
100
|
+
* The common parent of everything a role wrote is the honest generalisation,
|
|
101
|
+
* and it is deliberately shallow: one level below the shared root, so that a
|
|
102
|
+
* role that touched `src/api/a` and `src/api/b` gets `src/api/**` rather than
|
|
103
|
+
* `src/**`, which would hand it the whole tree on the strength of two files.
|
|
104
|
+
*/
|
|
105
|
+
export function generalise(paths) {
|
|
106
|
+
const dirs = new Set();
|
|
107
|
+
const files = new Set(); // things at the repo root have no directory
|
|
108
|
+
|
|
109
|
+
for (const p of paths) {
|
|
110
|
+
const parts = p.split("/").filter(Boolean);
|
|
111
|
+
parts.pop(); // drop the file itself
|
|
112
|
+
// A path with nothing above it is a file at the root. It used to be added
|
|
113
|
+
// to `dirs` and then given a `/**` like everything else, so observing a
|
|
114
|
+
// write to NOTAS.md proposed `NOTAS.md/**` — the children of a directory
|
|
115
|
+
// that does not exist, which grants nothing over the file that was written.
|
|
116
|
+
if (parts.length === 0) files.add(p);
|
|
117
|
+
else dirs.add(parts.join("/"));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const kept = [...dirs].filter((d) => ![...dirs].some((o) => o !== d && d.startsWith(o + "/")));
|
|
121
|
+
// A root file already covered by a directory grant does not need naming.
|
|
122
|
+
const loose = [...files].filter((f) => ![...kept].some((d) => f.startsWith(d + "/")));
|
|
123
|
+
return [...kept.map((d) => d + "/**"), ...loose].sort();
|
|
124
|
+
}
|