seisin 0.1.1 → 0.3.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/README.md +287 -339
- package/package.json +7 -6
- package/src/cli.js +17 -3
- package/src/commands/explain.js +9 -2
- package/src/commands/hook.js +11 -0
- package/src/commands/init.js +1 -1
- package/src/commands/log.js +27 -1
- package/src/commands/requests.js +8 -3
- package/src/commands/review.js +33 -5
- package/src/commands/run.js +180 -151
- package/src/commands/ui.js +16 -2
- package/src/commands/walls.js +30 -0
- package/src/commands/whose.js +2 -1
- package/src/commands/wire.js +30 -5
- package/src/config.js +310 -5
- package/src/diagnose.js +172 -0
- package/src/env.js +54 -1
- package/src/grants.js +239 -0
- package/src/hook.js +45 -5
- package/src/index.js +12 -0
- package/src/inspect.js +332 -9
- package/src/intake.js +215 -0
- package/src/keys.js +585 -0
- package/src/log.js +96 -2
- package/src/mcp.js +87 -11
- package/src/nested.js +86 -0
- package/src/notify.js +146 -0
- package/src/owners.js +208 -6
- package/src/paths.js +50 -0
- package/src/render.js +63 -1
- package/src/requests.js +88 -0
- package/src/review.js +9 -4
- package/src/rundir.js +113 -0
- package/src/serve.js +263 -19
- package/src/spool.js +98 -20
- package/src/srt.js +148 -203
- package/src/surface.js +518 -0
- package/src/violations.js +37 -5
- package/src/walls.js +184 -0
- package/ui/favicon.svg +1 -1
- package/ui/index.html +585 -56
package/src/srt.js
CHANGED
|
@@ -15,169 +15,80 @@
|
|
|
15
15
|
*/
|
|
16
16
|
import { join, dirname } from "node:path";
|
|
17
17
|
import { STATE_DIR, CONFIG_NAME } from "./layout.js";
|
|
18
|
-
import {
|
|
19
|
-
import {
|
|
20
|
-
import {
|
|
18
|
+
import { entriesOf } from "./keys.js";
|
|
19
|
+
import { enforcedNeverWrites } from "./owners.js";
|
|
20
|
+
import { realAncestor } from "./paths.js";
|
|
21
|
+
import { denyFor } from "./surface.js";
|
|
22
|
+
import { runsRootOf } from "./rundir.js";
|
|
23
|
+
import { FIFO_SUFFIX } from "./spool.js";
|
|
24
|
+
import {
|
|
25
|
+
SOCKET_MAX, isLink, realOrSelf, expand, homeFits, roleHome, roleHomeRoot,
|
|
26
|
+
CREDENTIAL_HOMES, toWritePath, writePathsOf,
|
|
27
|
+
} from "./grants.js";
|
|
21
28
|
|
|
29
|
+
// Re-exported: these were srt.js's exports before grants.js existed, and
|
|
30
|
+
// callers (the CLI, the tests, anyone embedding seisin) import them from here.
|
|
31
|
+
export {
|
|
32
|
+
expand, homeFits, roleHome, roleHomeRoot, CREDENTIAL_HOMES, RUNTIME_WRITES,
|
|
33
|
+
} from "./grants.js";
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
/** Reads are denied wholesale under the key directory, then re-allowed one file at a time. */
|
|
22
37
|
/**
|
|
23
|
-
*
|
|
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`.
|
|
38
|
+
* The settings one role gets.
|
|
48
39
|
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
40
|
+
* `spool` is the path of the parent's audit socket, when a parent is holding
|
|
41
|
+
* one. It is granted by path so the hook can report what it decided without
|
|
42
|
+
* the repo's state directory being writable from inside — see spool.js. With
|
|
43
|
+
* no parent there is nothing to grant and nothing to reach.
|
|
52
44
|
*/
|
|
53
45
|
/**
|
|
54
|
-
*
|
|
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.
|
|
46
|
+
* `local_ports`, as the allowlist entries that let the proxy dial them.
|
|
64
47
|
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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.
|
|
48
|
+
* Why the proxy and not the kernel. The kernel profile has exactly two answers
|
|
49
|
+
* for loopback: nothing, or `localhost:*` (`allowLocalBinding`), and the runtime
|
|
50
|
+
* exposes no third. The proxy is the one component that sees a destination
|
|
51
|
+
* port, and it already takes `host:port` entries. It refuses loopback unless
|
|
52
|
+
* the literal and port are listed, which is the property this relies on: 9000
|
|
53
|
+
* stays refused when 8001 is open. Measured, 2026-09-23: curl, Node's fetch
|
|
54
|
+
* and Python's urllib reach a listed port and get "Connection blocked by
|
|
55
|
+
* network allowlist" on an unlisted one.
|
|
93
56
|
*
|
|
94
|
-
*
|
|
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.
|
|
57
|
+
* All three spellings, because a client says whichever it says.
|
|
97
58
|
*/
|
|
98
|
-
export function
|
|
99
|
-
return
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
export function roleHome(config, role) {
|
|
103
|
-
return join(roleHomeRoot(config), role);
|
|
59
|
+
export function localPortDomains(ports = []) {
|
|
60
|
+
return (ports ?? []).flatMap((p) => [`localhost:${p}`, `127.0.0.1:${p}`, `[::1]:${p}`]);
|
|
104
61
|
}
|
|
105
62
|
|
|
106
63
|
/**
|
|
107
|
-
*
|
|
64
|
+
* What the runtime puts in NO_PROXY, minus loopback.
|
|
108
65
|
*
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
* the
|
|
113
|
-
*
|
|
66
|
+
* The runtime always exempts localhost from the proxy, so a client talks to
|
|
67
|
+
* 127.0.0.1:8001 directly — and the kernel, which has no port list, refuses
|
|
68
|
+
* it. For a role with `local_ports` the exemption is taken back, so loopback
|
|
69
|
+
* goes through the proxy, where the port list is. Only for those roles: for
|
|
70
|
+
* every other role loopback is refused either way and nothing changes.
|
|
114
71
|
*
|
|
115
|
-
*
|
|
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.
|
|
72
|
+
* Kept in step with sandbox-utils.js of the pinned runtime by a test.
|
|
119
73
|
*/
|
|
120
|
-
export const
|
|
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
|
-
];
|
|
74
|
+
export const NO_PROXY_WITHOUT_LOOPBACK = "169.254.0.0/16,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16";
|
|
133
75
|
|
|
134
76
|
/**
|
|
135
|
-
*
|
|
77
|
+
* The command a role runs, with loopback routed through the proxy when it
|
|
78
|
+
* has `local_ports`.
|
|
136
79
|
*
|
|
137
|
-
*
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
*
|
|
141
|
-
*
|
|
80
|
+
* An `env` in front and not a variable in the environment seisin passes: the
|
|
81
|
+
* runtime sets NO_PROXY itself, after seisin's environment, so the only place
|
|
82
|
+
* that wins is inside the box. Clients that ignore HTTP_PROXY — a MySQL driver,
|
|
83
|
+
* Chromium, which exempts loopback on its own — still get refused. They need
|
|
84
|
+
* to be pointed at the proxy, or the port needs `local_binding`, which opens
|
|
85
|
+
* every port.
|
|
142
86
|
*/
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
}
|
|
87
|
+
export function loopbackVia(role, cmd) {
|
|
88
|
+
if (!role?.localPorts?.length) return cmd;
|
|
89
|
+
return ["env", `NO_PROXY=${NO_PROXY_WITHOUT_LOOPBACK}`, `no_proxy=${NO_PROXY_WITHOUT_LOOPBACK}`, ...cmd];
|
|
170
90
|
}
|
|
171
91
|
|
|
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
92
|
export function settingsFor(config, roleName, spool = null, observe = false) {
|
|
182
93
|
const role = config.roles[roleName];
|
|
183
94
|
if (!role) throw new Error(`unknown role "${roleName}". Known: ${Object.keys(config.roles).join(", ")}`);
|
|
@@ -283,7 +194,17 @@ export function settingsFor(config, roleName, spool = null, observe = false) {
|
|
|
283
194
|
denyRead.push(realOrSelf(roleHomeRoot(config)));
|
|
284
195
|
allowRead.push(realOrSelf(home));
|
|
285
196
|
}
|
|
286
|
-
|
|
197
|
+
/**
|
|
198
|
+
* Only a key that IS a file becomes a read grant.
|
|
199
|
+
*
|
|
200
|
+
* A reference has no path, so there is nothing here to allow: it is resolved
|
|
201
|
+
* by the parent before the sandbox starts and handed over as an environment
|
|
202
|
+
* variable or as a scratch file. See keys.js. Filtering here rather than
|
|
203
|
+
* upstream keeps the two kinds from having to know about each other — the
|
|
204
|
+
* settings this function emits are still, entirely, about the filesystem.
|
|
205
|
+
*/
|
|
206
|
+
for (const entry of entriesOf(role).filter((e) => e.kind === "file")) {
|
|
207
|
+
const key = entry.raw;
|
|
287
208
|
const path = key.includes("/") ? abs(key) : abs(join(dirs[0] ?? ".", key));
|
|
288
209
|
// A key has to live in a declared key directory. Without this check a
|
|
289
210
|
// slash in the name made the path relative to the repo root instead, so
|
|
@@ -311,14 +232,37 @@ export function settingsFor(config, roleName, spool = null, observe = false) {
|
|
|
311
232
|
allowRead.push(path);
|
|
312
233
|
}
|
|
313
234
|
|
|
235
|
+
/**
|
|
236
|
+
* Other runs' directories are unreadable; this run's is readable.
|
|
237
|
+
*
|
|
238
|
+
* Every run keeps its socket and `scratch` keys under one root (rundir.js),
|
|
239
|
+
* and every role runs as the same user, so the directory mode protects
|
|
240
|
+
* nothing between roles. Measured: one role read another's scratch key while
|
|
241
|
+
* both ran. Same shape as the key directories: deny the parent, allow your own.
|
|
242
|
+
*/
|
|
243
|
+
const runs = runsRootOf(spool);
|
|
244
|
+
const isFifo = spool?.endsWith(FIFO_SUFFIX) === true;
|
|
245
|
+
if (runs) {
|
|
246
|
+
denyRead.push(runs);
|
|
247
|
+
allowRead.push(dirname(spool));
|
|
248
|
+
}
|
|
249
|
+
|
|
314
250
|
return {
|
|
315
251
|
network: {
|
|
316
|
-
|
|
252
|
+
// `local_ports` rides the same allowlist as the domains: the runtime's
|
|
253
|
+
// proxy is the one place that sees the destination PORT. See loopbackVia.
|
|
254
|
+
// Without ports the value is passed through untouched, `undefined` included: that
|
|
255
|
+
// is how a config with no network list has always read, and a spread would throw.
|
|
256
|
+
allowedDomains: role.localPorts?.length
|
|
257
|
+
? [...(role.network ?? config.allowedDomains ?? []), ...localPortDomains(role.localPorts)]
|
|
258
|
+
: role.network ?? config.allowedDomains,
|
|
317
259
|
deniedDomains: [],
|
|
318
260
|
// Exactly one socket: the parent's audit spool, when there is a parent.
|
|
319
261
|
// Granted by path, not by turning unix sockets on.
|
|
320
|
-
allowUnixSockets: spool ? [spool] : [],
|
|
321
|
-
|
|
262
|
+
allowUnixSockets: spool && !isFifo ? [spool] : [],
|
|
263
|
+
// Per role, `local_binding = true`. Off by default: listening is a
|
|
264
|
+
// capability, and most roles never need it.
|
|
265
|
+
allowLocalBinding: role.localBinding === true,
|
|
322
266
|
},
|
|
323
267
|
filesystem: {
|
|
324
268
|
denyRead,
|
|
@@ -335,18 +279,11 @@ export function settingsFor(config, roleName, spool = null, observe = false) {
|
|
|
335
279
|
// holds: the config, the state directory and the key directories stay
|
|
336
280
|
// shut even here, because observing is not a reason to hand over the
|
|
337
281
|
// paperwork of the confinement.
|
|
338
|
-
|
|
339
|
-
//
|
|
340
|
-
//
|
|
341
|
-
|
|
342
|
-
|
|
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)),
|
|
282
|
+
// One function answers "what may this role write" for the settings and
|
|
283
|
+
// for surface.js, which needs the union over every role. Two copies of
|
|
284
|
+
// that arithmetic would be two answers the day one of them changes.
|
|
285
|
+
...writePathsOf(config, role, { observe }),
|
|
286
|
+
...(isFifo ? [spool] : []),
|
|
350
287
|
],
|
|
351
288
|
/**
|
|
352
289
|
* The confinement's own paperwork, never writable — not even by a role
|
|
@@ -364,61 +301,69 @@ export function settingsFor(config, roleName, spool = null, observe = false) {
|
|
|
364
301
|
* relying on someone else's implementation detail staying put.
|
|
365
302
|
*/
|
|
366
303
|
denyWrite: [
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
304
|
+
/**
|
|
305
|
+
* The role's own subtractions, `never_writes`.
|
|
306
|
+
*
|
|
307
|
+
* Here and not as a hole cut out of allowWrite, because the profile
|
|
308
|
+
* cannot cut holes: it grants prefixes and denies prefixes, and a deny
|
|
309
|
+
* wins over any grant however wide. That is the property the key is
|
|
310
|
+
* built on, and the same one the entries below rely on.
|
|
311
|
+
*/
|
|
312
|
+
...enforcedNeverWrites(config, role)
|
|
313
|
+
.map((g) => abs(toWritePath(g, "never_writes")))
|
|
314
|
+
// Both spellings: the one written and the one the kernel will meet.
|
|
315
|
+
// With a symlink on the way (`app/data -> store`) the write lands on
|
|
316
|
+
// `store/…`, and a deny on the written path alone let it through —
|
|
317
|
+
// measured by the review of 2026-09-22 (rc=0, file created).
|
|
318
|
+
.flatMap((p) => { const r = realAncestor(p); return r === p ? [p] : [p, r]; }),
|
|
319
|
+
/**
|
|
320
|
+
* Everything the parent reads or executes, and every file that tells a
|
|
321
|
+
* program outside the box what to run — the policy, `.seisin/`, the key
|
|
322
|
+
* directories, the key providers, `file://` targets, programs on PATH
|
|
323
|
+
* installed where a role writes, and each project's hooks and settings.
|
|
324
|
+
* One list with a reason per entry, built in surface.js, which says why
|
|
325
|
+
* each member of the family is on it. `seisin check` prints it.
|
|
326
|
+
*/
|
|
327
|
+
...denyFor(config, role, { observe }).map((e) => e.path),
|
|
370
328
|
// The audit socket, and the directory holding it. Denying only the
|
|
371
329
|
// socket left `mv /tmp/seisin-xxxx /tmp/gone` as a way to take the
|
|
372
330
|
// channel out without ever touching the file that was protected.
|
|
373
331
|
// Connecting is `network-outbound` and both of those are
|
|
374
332
|
// `file-write-unlink`, so denying them leaves the first working.
|
|
375
|
-
|
|
333
|
+
// And the root all runs live under, so no role can put a symlink where
|
|
334
|
+
// the next run will write its settings and keys.
|
|
335
|
+
// A FIFO channel (Linux) is written, so it is granted below and only
|
|
336
|
+
// its directory is denied here: the role writes lines into it and can
|
|
337
|
+
// neither remove it nor put anything beside it (measured in Docker).
|
|
338
|
+
...(spool ? [...(isFifo ? [] : [spool]), dirname(spool), ...(runs ? [runs] : [])] : []),
|
|
376
339
|
],
|
|
377
340
|
},
|
|
341
|
+
/**
|
|
342
|
+
* `trustd = true`: the one Mach service TLS needs on macOS for tools that
|
|
343
|
+
* verify through the Security framework — Go before 1.27 (gh, kubectl,
|
|
344
|
+
* terraform) and every Dart/Flutter. The runtime ships it as
|
|
345
|
+
* `enableWeakerNetworkIsolation` and warns it is an exfiltration path:
|
|
346
|
+
* trustd fetches, outside the box, the URLs a certificate carries. Per role
|
|
347
|
+
* and off by default, so the answer is written next to the role that needs it.
|
|
348
|
+
*/
|
|
349
|
+
...(role.trustd === true ? { enableWeakerNetworkIsolation: true } : {}),
|
|
378
350
|
};
|
|
379
351
|
}
|
|
380
352
|
|
|
381
353
|
/**
|
|
382
|
-
*
|
|
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.
|
|
354
|
+
* The provider executables that live at a path, absolute.
|
|
406
355
|
*
|
|
407
|
-
*
|
|
408
|
-
*
|
|
409
|
-
*
|
|
356
|
+
* A command with no separator (`security`, `op`, `gpg`) is resolved by the OS
|
|
357
|
+
* from PATH and is deliberately left alone: denying "wherever gpg happens to
|
|
358
|
+
* be" would mean writing a rule about a machine rather than about a repo.
|
|
410
359
|
*/
|
|
411
|
-
|
|
412
|
-
const
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
return
|
|
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.`;
|
|
360
|
+
export function providerPaths(config) {
|
|
361
|
+
const out = [];
|
|
362
|
+
for (const p of Object.values(config.keyProviders ?? {})) {
|
|
363
|
+
const cmd = p.command?.[0];
|
|
364
|
+
if (!cmd || !cmd.includes("/")) continue;
|
|
365
|
+
out.push(cmd.startsWith("/") ? cmd : join(config.root ?? ".", cmd));
|
|
366
|
+
}
|
|
367
|
+
return [...new Set(out)];
|
|
424
368
|
}
|
|
369
|
+
|