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/srt.js
ADDED
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turns one role into a settings file for @anthropic-ai/sandbox-runtime.
|
|
3
|
+
*
|
|
4
|
+
* The schema is not guessed: it was read off the installed package and checked
|
|
5
|
+
* against the real binary. Two things it taught us, both load-bearing here:
|
|
6
|
+
*
|
|
7
|
+
* 1. `network` and `filesystem` are top level. They are NOT nested under a
|
|
8
|
+
* `sandbox` key — that shape belongs to Claude Code's settings.json, which
|
|
9
|
+
* is a different file with a similar vocabulary. Mixing them up produces a
|
|
10
|
+
* config that parses as empty and denies everything.
|
|
11
|
+
* 2. Every field is required. Leaving out `network.deniedDomains` or
|
|
12
|
+
* `filesystem.denyWrite` makes the runtime refuse to start rather than fall
|
|
13
|
+
* back to its defaults. Emitting the whole object removes that class of
|
|
14
|
+
* error, which is most of what this file is for.
|
|
15
|
+
*/
|
|
16
|
+
import { join, dirname } from "node:path";
|
|
17
|
+
import { STATE_DIR, CONFIG_NAME } from "./layout.js";
|
|
18
|
+
import { homedir, tmpdir } from "node:os";
|
|
19
|
+
import { realpathSync, lstatSync } from "node:fs";
|
|
20
|
+
import { createHash } from "node:crypto";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* What every agent needs to write no matter which role it is.
|
|
24
|
+
*
|
|
25
|
+
* Found by running a real cell config through the sandbox: territory alone
|
|
26
|
+
* looks correct and is unusable. An agent writes its session state under its
|
|
27
|
+
* own config directory and its tools write scratch files to the temp dir, so a
|
|
28
|
+
* policy of "your folders and nothing else" stops the agent before it starts.
|
|
29
|
+
*
|
|
30
|
+
* The list is not Claude-shaped by accident and it was Claude-shaped by
|
|
31
|
+
* mistake: the first version named `~/.claude` and `~/.codex` and stopped
|
|
32
|
+
* there, so the first agent that was neither — opencode, which logs to
|
|
33
|
+
* `~/.local/share/opencode` — died on startup with `FileSystem.open`. Hence the
|
|
34
|
+
* XDG directories, which is where a CLI that follows convention puts its state.
|
|
35
|
+
*
|
|
36
|
+
* `~/.config` is deliberately NOT here. That is where credentials live —
|
|
37
|
+
* `~/.config/gh/hosts.yml` holds a GitHub token — and while reads outside the
|
|
38
|
+
* declared key directories are open anyway, letting an agent WRITE there is a
|
|
39
|
+
* different thing. A CLI that needs it can be granted it by name.
|
|
40
|
+
*
|
|
41
|
+
* These are grants, so they are listed rather than assumed: `seisin check`
|
|
42
|
+
* prints them, and `[runtime] writes = []` turns them off for anyone who wants
|
|
43
|
+
* to find out the hard way. Note what is NOT here — the home directory, the
|
|
44
|
+
* shell profile, anything under the repo. Scratch space is not a back door.
|
|
45
|
+
*/
|
|
46
|
+
/**
|
|
47
|
+
* Where a role's home and scratch live when `[runtime] isolate = true`.
|
|
48
|
+
*
|
|
49
|
+
* Under the state directory but outside the repo tree the roles write, so one
|
|
50
|
+
* role cannot reach another's. Kept out of `.seisin/` inside the repo because
|
|
51
|
+
* that whole directory is denyWrite now.
|
|
52
|
+
*/
|
|
53
|
+
/**
|
|
54
|
+
* Short on purpose, and the reason is the same one spool.js already names: a
|
|
55
|
+
* unix socket path is capped near 104 bytes on macOS and the failure when it is
|
|
56
|
+
* too long is an unhelpful EINVAL.
|
|
57
|
+
*
|
|
58
|
+
* This file forgot that one layer down. The runtime creates its multiplexing
|
|
59
|
+
* socket INSIDE the role's home — `<home>/tmp/srt-mux-<pid>-0.sock`, 25 bytes —
|
|
60
|
+
* and the name here used to be `seisin-home-` plus 16 characters. On macOS
|
|
61
|
+
* `tmpdir()` is 48 bytes on its own, so the total cleared 104 for **every role
|
|
62
|
+
* name**, `qa` included: `isolate = true` did not misbehave, it failed to start.
|
|
63
|
+
* Measured, and it is why `homeFits` below exists rather than a comment.
|
|
64
|
+
*
|
|
65
|
+
* Eleven bytes instead of twenty-eight leaves room for a role name up to
|
|
66
|
+
* {@link MAX_ROLE_FOR_HOME} characters.
|
|
67
|
+
*/
|
|
68
|
+
export function roleHomeRoot(config) {
|
|
69
|
+
/**
|
|
70
|
+
* A hash of the whole path, not a slice of it.
|
|
71
|
+
*
|
|
72
|
+
* This was `base64url(root).slice(-16)` — the TAIL of the encoded path, which
|
|
73
|
+
* is the tail of the path itself. Two checkouts that end the same way get the
|
|
74
|
+
* same id: `/Users/ana/dev/proyecto` and `/Users/bob/dev/proyecto` collide, and
|
|
75
|
+
* so do `/home/a/work/api` and `/home/b/work/api`. A collision here is not a
|
|
76
|
+
* cosmetic clash — both repos' role `dev` would share one HOME, which is the
|
|
77
|
+
* session token of one handed to the other. Measured on three of four ordinary
|
|
78
|
+
* pairs; shortening the slice to fit the socket limit would have made it more
|
|
79
|
+
* likely, not less.
|
|
80
|
+
*/
|
|
81
|
+
const id = createHash("sha256").update(config.root).digest("base64url").slice(0, 8);
|
|
82
|
+
return join(tmpdir(), `sn-${id}`);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** What the runtime appends inside the role's home, at its longest. */
|
|
86
|
+
const SRT_SOCKET_TAIL = "/tmp/srt-mux-999999-0.sock".length;
|
|
87
|
+
|
|
88
|
+
/** The platform's cap on a unix socket path. macOS is the tight one. */
|
|
89
|
+
const SOCKET_MAX = 104;
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Whether a role's isolated home leaves room for the runtime's socket.
|
|
93
|
+
*
|
|
94
|
+
* Returns the room left over, negative when it does not fit. Callers refuse
|
|
95
|
+
* rather than let the runtime fail with EINVAL and no explanation — a boundary
|
|
96
|
+
* that cannot start should say so in its own words.
|
|
97
|
+
*/
|
|
98
|
+
export function homeFits(config, role) {
|
|
99
|
+
return SOCKET_MAX - (roleHome(config, role).length + SRT_SOCKET_TAIL);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function roleHome(config, role) {
|
|
103
|
+
return join(roleHomeRoot(config), role);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Where credentials live in a home directory, denied at both isolate levels.
|
|
108
|
+
*
|
|
109
|
+
* Denies rather than a read allowlist, and the reasoning is above: a
|
|
110
|
+
* default-deny read set has to enumerate every library, interpreter and cache a
|
|
111
|
+
* toolchain touches, gets one wrong, and fails as an unexplainable crash inside
|
|
112
|
+
* the agent. Naming the places credentials actually live is narrower than the
|
|
113
|
+
* ideal and holds up.
|
|
114
|
+
*
|
|
115
|
+
* `~/.config` is here whole rather than `~/.config/gh`: it is where a growing
|
|
116
|
+
* number of CLIs keep their tokens, and listing them one by one is the
|
|
117
|
+
* enumeration this list exists to avoid. A tool that needs a directory under it
|
|
118
|
+
* can be granted it by name.
|
|
119
|
+
*/
|
|
120
|
+
export const CREDENTIAL_HOMES = [
|
|
121
|
+
// The order is not arbitrary: `check` names the first few, so the ones a
|
|
122
|
+
// reader recognises instantly go first. Everything in the list is denied
|
|
123
|
+
// either way.
|
|
124
|
+
"~/.ssh", "~/.aws", "~/.npmrc", "~/.config",
|
|
125
|
+
"~/.gnupg", "~/.kube", "~/.docker", "~/.netrc", "~/.git-credentials",
|
|
126
|
+
];
|
|
127
|
+
|
|
128
|
+
export const RUNTIME_WRITES = [
|
|
129
|
+
"~/.claude", "~/.codex", // the CLIs that keep state under their own name
|
|
130
|
+
"~/.local/share", "~/.local/state", // XDG data and state: where most others log
|
|
131
|
+
"~/.cache", "$TMPDIR", "/tmp",
|
|
132
|
+
];
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* `~` and `$TMPDIR` are the only expansions; everything else is a literal path.
|
|
136
|
+
*
|
|
137
|
+
* Symlinks are then resolved, and that is not a nicety. On macOS `/tmp` is a
|
|
138
|
+
* link to `/private/tmp`, and the sandbox enforces on the destination — so a
|
|
139
|
+
* grant written as `/tmp` grants exactly nothing, silently. Measured: with the
|
|
140
|
+
* literal path, writing to /tmp inside the sandbox failed while the policy
|
|
141
|
+
* looked correct on screen.
|
|
142
|
+
*/
|
|
143
|
+
/** Is this exact path a symlink? False if it is not on disk. */
|
|
144
|
+
function isLink(p) {
|
|
145
|
+
try {
|
|
146
|
+
return lstatSync(p).isSymbolicLink();
|
|
147
|
+
} catch {
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** The path with symlinks followed, or the path itself if it is not on disk. */
|
|
153
|
+
function realOrSelf(p) {
|
|
154
|
+
try {
|
|
155
|
+
return realpathSync(p);
|
|
156
|
+
} catch {
|
|
157
|
+
return p;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function expand(p) {
|
|
162
|
+
let out = p;
|
|
163
|
+
if (p === "$TMPDIR") out = tmpdir();
|
|
164
|
+
else if (p === "~" || p.startsWith("~/")) out = join(homedir(), p.slice(2));
|
|
165
|
+
try {
|
|
166
|
+
return realpathSync(out);
|
|
167
|
+
} catch {
|
|
168
|
+
return out; // not on disk yet; hand the literal through rather than drop it
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Reads are denied wholesale under the key directory, then re-allowed one file at a time. */
|
|
173
|
+
/**
|
|
174
|
+
* The settings one role gets.
|
|
175
|
+
*
|
|
176
|
+
* `spool` is the path of the parent's audit socket, when a parent is holding
|
|
177
|
+
* one. It is granted by path so the hook can report what it decided without
|
|
178
|
+
* the repo's state directory being writable from inside — see spool.js. With
|
|
179
|
+
* no parent there is nothing to grant and nothing to reach.
|
|
180
|
+
*/
|
|
181
|
+
export function settingsFor(config, roleName, spool = null, observe = false) {
|
|
182
|
+
const role = config.roles[roleName];
|
|
183
|
+
if (!role) throw new Error(`unknown role "${roleName}". Known: ${Object.keys(config.roles).join(", ")}`);
|
|
184
|
+
|
|
185
|
+
const abs = (p) => (p.startsWith("/") ? p : join(config.root, p));
|
|
186
|
+
// "credentials" closes the places credentials live; "home" does that and
|
|
187
|
+
// gives the role a home of its own. Only the second one needs a new HOME,
|
|
188
|
+
// and only the second one signs every CLI in the box out. See config.js.
|
|
189
|
+
//
|
|
190
|
+
// `true` is normalised here as well as in the config reader, because a caller
|
|
191
|
+
// embedding the library builds this object itself and `isolate: true` has
|
|
192
|
+
// meant "home" since before there was a second level.
|
|
193
|
+
const level = config.isolate === true ? "home" : config.isolate;
|
|
194
|
+
const shielded = level === "credentials" || level === "home";
|
|
195
|
+
const isolated = level === "home";
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Refuse before the runtime does, because the runtime's refusal says nothing.
|
|
199
|
+
*
|
|
200
|
+
* With an isolated home too deep for the socket the runtime puts inside it,
|
|
201
|
+
* `srt` dies on `listen EINVAL: invalid argument` and a path — no role, no
|
|
202
|
+
* mention of `isolate`, nothing a reader could act on. This is the same
|
|
203
|
+
* failure seisin already documents in spool.js, reached from the other side.
|
|
204
|
+
*/
|
|
205
|
+
if (isolated) {
|
|
206
|
+
const room = homeFits(config, roleName);
|
|
207
|
+
if (room < 0)
|
|
208
|
+
throw new Error(
|
|
209
|
+
`\`isolate = true\` cannot hold role "${roleName}" here: its home leaves ` +
|
|
210
|
+
`${-room} byte(s) too little for the runtime's socket, and a unix socket path ` +
|
|
211
|
+
`is capped near ${SOCKET_MAX} bytes.\n` +
|
|
212
|
+
` Shorten the role name, or run with a shorter TMPDIR.`,
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const home = isolated ? roleHome(config, roleName) : null;
|
|
217
|
+
const denyRead = [];
|
|
218
|
+
const allowRead = [];
|
|
219
|
+
|
|
220
|
+
// Every declared directory is denied, then each key the role names is
|
|
221
|
+
// re-allowed. A key written without a directory resolves against the first
|
|
222
|
+
// one, which keeps the ordinary single-directory config short.
|
|
223
|
+
const dirs = config.keyDirs ?? [];
|
|
224
|
+
for (const dir of dirs) {
|
|
225
|
+
// A key *file* that is a symlink is refused below. The directory itself is
|
|
226
|
+
// the same hole one level up: denyRead names the path as written, and the
|
|
227
|
+
// runtime enforces on the destination — so `.secrets -> /tmp/elsewhere`
|
|
228
|
+
// gives a deny that covers nothing and an allow that reaches out of the
|
|
229
|
+
// repo. Refused rather than resolved, because a key directory that is not
|
|
230
|
+
// where it says it is has nothing to gain from being clever about.
|
|
231
|
+
const here = abs(dir);
|
|
232
|
+
// lstat on the directory itself, not a comparison of resolved paths: on
|
|
233
|
+
// macOS /var is a link to /private/var, so a repo under a temp directory
|
|
234
|
+
// would fail this check for an ancestor it does not control.
|
|
235
|
+
if (isLink(here))
|
|
236
|
+
throw new Error(
|
|
237
|
+
`[keys] dir "${dir}" is a symlink to ${realOrSelf(here)}.\n` +
|
|
238
|
+
` The sandbox enforces on the destination, so the deny would not cover what the ` +
|
|
239
|
+
`allow reaches. Point [keys] dir at the real location instead.`);
|
|
240
|
+
denyRead.push(here);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Isolated mode also narrows what a role may READ, which the ordinary mode
|
|
245
|
+
* does not touch at all.
|
|
246
|
+
*
|
|
247
|
+
* By default the model is "read anything except the declared key
|
|
248
|
+
* directories", and that is a real limit worth saying out loud: `~/.ssh`,
|
|
249
|
+
* `~/.aws`, `~/.npmrc`, `~/.config/gh/hosts.yml` and `~/.kube/config` are all
|
|
250
|
+
* ordinary readable files to every role unless they happen to sit under a
|
|
251
|
+
* key directory. For agents you run yourself that is a reasonable trade —
|
|
252
|
+
* they need to read the machine to work. For anything you would not trust,
|
|
253
|
+
* it is the whole game.
|
|
254
|
+
*
|
|
255
|
+
* These are denies rather than an allowlist on purpose. A default-deny read
|
|
256
|
+
* set has to enumerate every library, interpreter and cache a toolchain
|
|
257
|
+
* touches, gets one wrong, and fails as an unexplainable crash inside the
|
|
258
|
+
* agent. Naming the places credentials actually live is narrower than the
|
|
259
|
+
* ideal and it is a boundary that holds up in practice.
|
|
260
|
+
*/
|
|
261
|
+
if (shielded) denyRead.push(...CREDENTIAL_HOMES.map(expand));
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* `~/.claude` and `~/.codex` are closed only at the `home` level, and the
|
|
265
|
+
* difference is the whole reason the two levels exist.
|
|
266
|
+
*
|
|
267
|
+
* They hold a session, so closing them is right when each role is meant to be
|
|
268
|
+
* a separate identity. But on macOS the agent's credential is not in there at
|
|
269
|
+
* all — it is in the login keychain, reached through `$HOME`. At the
|
|
270
|
+
* `credentials` level HOME is untouched, so the agent stays logged in and
|
|
271
|
+
* every role shares that one session, exactly as they already do today.
|
|
272
|
+
* Closing these two here would cost the session without buying the
|
|
273
|
+
* separation, which is the worst of both.
|
|
274
|
+
*/
|
|
275
|
+
if (isolated) {
|
|
276
|
+
denyRead.push(...["~/.claude", "~/.codex"].map(expand));
|
|
277
|
+
// And the other roles' homes. Giving each role its own HOME closed writing
|
|
278
|
+
// between them and left reading wide open — so `a` could read the session
|
|
279
|
+
// token `b`'s CLI had just written. Worse, the audit page said this was
|
|
280
|
+
// closed, because the only thing measured was the write.
|
|
281
|
+
//
|
|
282
|
+
// Same shape as the key directories: deny the parent, allow your own.
|
|
283
|
+
denyRead.push(realOrSelf(roleHomeRoot(config)));
|
|
284
|
+
allowRead.push(realOrSelf(home));
|
|
285
|
+
}
|
|
286
|
+
for (const key of role.keys) {
|
|
287
|
+
const path = key.includes("/") ? abs(key) : abs(join(dirs[0] ?? ".", key));
|
|
288
|
+
// A key has to live in a declared key directory. Without this check a
|
|
289
|
+
// slash in the name made the path relative to the repo root instead, so
|
|
290
|
+
// `keys = ["../.ssh/id_rsa"]` re-opened a read outside `.secrets` — which
|
|
291
|
+
// is a read grant written in the one list nobody reads twice, because
|
|
292
|
+
// everything in it is supposed to be a key.
|
|
293
|
+
// Resolved, not compared as text. The sandbox enforces on the destination
|
|
294
|
+
// of a symlink — the same property that made a `/tmp` grant grant nothing —
|
|
295
|
+
// so `.secrets/github-token.txt -> ~/.ssh/id_rsa` is a read grant on the
|
|
296
|
+
// ssh key, written in the one list nobody audits twice. Measured: the read
|
|
297
|
+
// succeeded through the link and through the real path.
|
|
298
|
+
const real = realOrSelf(path);
|
|
299
|
+
const inside = dirs.some((d) => {
|
|
300
|
+
const root = realOrSelf(abs(d));
|
|
301
|
+
return real === root || real.startsWith(root.endsWith("/") ? root : root + "/");
|
|
302
|
+
});
|
|
303
|
+
if (!inside)
|
|
304
|
+
throw new Error(
|
|
305
|
+
`keys = "${key}" for role ${role.name} resolves to ${real}, outside every [keys] dir ` +
|
|
306
|
+
`(${dirs.join(", ") || "none declared"}).\n` +
|
|
307
|
+
`${real === path ? "" : ` (it is a symlink: ${path} → ${real})\n`}` +
|
|
308
|
+
` A key must live in a declared key directory. To use another location, declare it: ` +
|
|
309
|
+
`[keys] dir = ["${dirs[0] ?? ".secrets"}", "<the other one>"].`
|
|
310
|
+
);
|
|
311
|
+
allowRead.push(path);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
return {
|
|
315
|
+
network: {
|
|
316
|
+
allowedDomains: role.network ?? config.allowedDomains,
|
|
317
|
+
deniedDomains: [],
|
|
318
|
+
// Exactly one socket: the parent's audit spool, when there is a parent.
|
|
319
|
+
// Granted by path, not by turning unix sockets on.
|
|
320
|
+
allowUnixSockets: spool ? [spool] : [],
|
|
321
|
+
allowLocalBinding: false,
|
|
322
|
+
},
|
|
323
|
+
filesystem: {
|
|
324
|
+
denyRead,
|
|
325
|
+
allowRead,
|
|
326
|
+
allowWrite: [
|
|
327
|
+
// Observing means the kernel stops refusing, because otherwise there is
|
|
328
|
+
// nothing to observe. `--observe` used to relax only the hook, so the
|
|
329
|
+
// sandbox denied the write anyway and the banner said "nothing denied"
|
|
330
|
+
// over a transcript of denials — and `init --from-observations` builds
|
|
331
|
+
// a policy out of that transcript. It would read "the agent needs
|
|
332
|
+
// nothing outside its territory", which is the opposite of the truth.
|
|
333
|
+
//
|
|
334
|
+
// The repo, and only the repo. Everything in denyWrite below still
|
|
335
|
+
// holds: the config, the state directory and the key directories stay
|
|
336
|
+
// shut even here, because observing is not a reason to hand over the
|
|
337
|
+
// paperwork of the confinement.
|
|
338
|
+
...(observe ? [abs(".")] : role.writes.map(toWritePath).map(abs)),
|
|
339
|
+
// `.seisin/` is deliberately NOT here. It used to be, because the hook
|
|
340
|
+
// runs inside the box and has to record what it decided — which made
|
|
341
|
+
// the log and the queue writable by the process they are a record of.
|
|
342
|
+
// The hook now sends its lines to the parent over a socket and the
|
|
343
|
+
// parent holds the file. See spool.js.
|
|
344
|
+
// Isolated: this role's own home and scratch, and nothing shared.
|
|
345
|
+
// Otherwise: the real ~/.claude, ~/.cache and /tmp, which every role
|
|
346
|
+
// shares — convenient, and the reason `isolate` exists.
|
|
347
|
+
...(isolated
|
|
348
|
+
? [home, join(home, ".config"), join(home, ".local"), join(home, ".cache"), join(home, "tmp")]
|
|
349
|
+
: (config.runtimeWrites ?? RUNTIME_WRITES).map(expand)),
|
|
350
|
+
],
|
|
351
|
+
/**
|
|
352
|
+
* The confinement's own paperwork, never writable — not even by a role
|
|
353
|
+
* that was granted the whole repo.
|
|
354
|
+
*
|
|
355
|
+
* `writes = ["**"]` is a reasonable thing to write for a solo agent, and
|
|
356
|
+
* it used to hand that agent its own policy file: rewrite seisin.toml,
|
|
357
|
+
* get a wider territory on the next run. Measured against the real
|
|
358
|
+
* kernel, not reasoned about. denyWrite wins over allowWrite in the
|
|
359
|
+
* profile, so this holds however wide the territory gets.
|
|
360
|
+
*
|
|
361
|
+
* The key directories are already unreachable through denyRead — the
|
|
362
|
+
* runtime turns a read-denied path into an unlink/create denial too — but
|
|
363
|
+
* they are named here as well, because relying on that coupling is
|
|
364
|
+
* relying on someone else's implementation detail staying put.
|
|
365
|
+
*/
|
|
366
|
+
denyWrite: [
|
|
367
|
+
abs(config.path ?? CONFIG_NAME),
|
|
368
|
+
abs(STATE_DIR),
|
|
369
|
+
...dirs.map(abs),
|
|
370
|
+
// The audit socket, and the directory holding it. Denying only the
|
|
371
|
+
// socket left `mv /tmp/seisin-xxxx /tmp/gone` as a way to take the
|
|
372
|
+
// channel out without ever touching the file that was protected.
|
|
373
|
+
// Connecting is `network-outbound` and both of those are
|
|
374
|
+
// `file-write-unlink`, so denying them leaves the first working.
|
|
375
|
+
...(spool ? [spool, dirname(spool)] : []),
|
|
376
|
+
],
|
|
377
|
+
},
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* `allowWrite` takes directories, not globs: the OS grants a subtree, it does
|
|
383
|
+
* not pattern-match. `src/api/**` and `src/api/` mean the same thing to the
|
|
384
|
+
* kernel, so the trailing glob is trimmed rather than passed through, where it
|
|
385
|
+
* would be read as a literal directory named `**` and silently grant nothing.
|
|
386
|
+
*/
|
|
387
|
+
function toWritePath(glob) {
|
|
388
|
+
if (!EXPRESSIBLE(glob)) throw new Error(unexpressible(glob));
|
|
389
|
+
if (glob === "**") return ".";
|
|
390
|
+
return glob.replace(/\/\*\*$/, "") || ".";
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* A write pattern the kernel can be given without changing its meaning.
|
|
395
|
+
*
|
|
396
|
+
* Exactly two shapes qualify: a literal path, and a subtree ending in `/**`.
|
|
397
|
+
* Both map onto what allowWrite actually is — a prefix — so the policy, the
|
|
398
|
+
* explanation and the enforcement stay the same sentence.
|
|
399
|
+
*
|
|
400
|
+
* Everything else is refused, and the one that made this necessary is `src/*`.
|
|
401
|
+
* ownersOf() reads it as one level, because `*` compiles to `[^/]*`. The old
|
|
402
|
+
* translation trimmed the `/*` and handed the kernel `src`, which is the whole
|
|
403
|
+
* subtree. So `seisin explain` said denied and the write landed — measured, not
|
|
404
|
+
* theorised. The document was tighter than the boundary, which is the one
|
|
405
|
+
* direction a permission tool must never fail in.
|
|
406
|
+
*
|
|
407
|
+
* There is no exact translation to find later: Seatbelt and bubblewrap grant
|
|
408
|
+
* prefixes, and "one level down" is not a prefix. Refusing is not a placeholder
|
|
409
|
+
* here, it is the answer.
|
|
410
|
+
*/
|
|
411
|
+
const WILD = /[*?[\]]/; // owners.js treats all four as wildcards
|
|
412
|
+
const EXPRESSIBLE = (g) =>
|
|
413
|
+
g === "**" || // the whole repo
|
|
414
|
+
!WILD.test(g) || // a literal path
|
|
415
|
+
(g.endsWith("/**") && !WILD.test(g.slice(0, -3))); // a subtree, wildcard-free above it
|
|
416
|
+
|
|
417
|
+
function unexpressible(glob) {
|
|
418
|
+
return `writes = "${glob}" cannot be enforced as written.\n` +
|
|
419
|
+
` The sandbox grants a path and everything under it — there is no way to say ` +
|
|
420
|
+
`"one level deep".\n` +
|
|
421
|
+
` Use "${glob.replace(/\/\*$/, "")}/**" for the whole subtree, or name the files.\n` +
|
|
422
|
+
` Refusing rather than widening: the old behaviour granted the subtree while ` +
|
|
423
|
+
`seisin reported the narrow pattern.`;
|
|
424
|
+
}
|