seisin 0.2.0 → 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 +72 -10
- package/package.json +7 -6
- package/src/cli.js +3 -2
- package/src/commands/explain.js +9 -2
- package/src/commands/hook.js +11 -0
- package/src/commands/log.js +27 -1
- package/src/commands/requests.js +7 -2
- package/src/commands/review.js +33 -5
- package/src/commands/run.js +112 -151
- package/src/commands/ui.js +16 -2
- package/src/commands/walls.js +8 -2
- package/src/commands/whose.js +2 -1
- package/src/commands/wire.js +30 -5
- package/src/config.js +150 -2
- package/src/diagnose.js +172 -0
- package/src/env.js +54 -1
- package/src/grants.js +239 -0
- package/src/hook.js +12 -3
- package/src/inspect.js +178 -5
- package/src/intake.js +215 -0
- package/src/keys.js +108 -6
- package/src/log.js +96 -2
- package/src/mcp.js +28 -10
- package/src/notify.js +146 -0
- package/src/owners.js +208 -6
- package/src/paths.js +50 -0
- package/src/render.js +29 -0
- package/src/requests.js +88 -0
- package/src/review.js +9 -4
- package/src/rundir.js +113 -0
- package/src/serve.js +131 -21
- package/src/spool.js +98 -20
- package/src/srt.js +123 -225
- package/src/surface.js +518 -0
- package/src/violations.js +37 -5
- package/src/walls.js +19 -5
- package/ui/favicon.svg +1 -1
- package/ui/index.html +221 -28
package/src/srt.js
CHANGED
|
@@ -15,170 +15,80 @@
|
|
|
15
15
|
*/
|
|
16
16
|
import { join, dirname } from "node:path";
|
|
17
17
|
import { STATE_DIR, CONFIG_NAME } from "./layout.js";
|
|
18
|
-
import { homedir, tmpdir } from "node:os";
|
|
19
18
|
import { entriesOf } from "./keys.js";
|
|
20
|
-
import {
|
|
21
|
-
import {
|
|
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";
|
|
22
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. */
|
|
23
37
|
/**
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
* Found by running a real cell config through the sandbox: territory alone
|
|
27
|
-
* looks correct and is unusable. An agent writes its session state under its
|
|
28
|
-
* own config directory and its tools write scratch files to the temp dir, so a
|
|
29
|
-
* policy of "your folders and nothing else" stops the agent before it starts.
|
|
30
|
-
*
|
|
31
|
-
* The list is not Claude-shaped by accident and it was Claude-shaped by
|
|
32
|
-
* mistake: the first version named `~/.claude` and `~/.codex` and stopped
|
|
33
|
-
* there, so the first agent that was neither — opencode, which logs to
|
|
34
|
-
* `~/.local/share/opencode` — died on startup with `FileSystem.open`. Hence the
|
|
35
|
-
* XDG directories, which is where a CLI that follows convention puts its state.
|
|
36
|
-
*
|
|
37
|
-
* `~/.config` is deliberately NOT here. That is where credentials live —
|
|
38
|
-
* `~/.config/gh/hosts.yml` holds a GitHub token — and while reads outside the
|
|
39
|
-
* declared key directories are open anyway, letting an agent WRITE there is a
|
|
40
|
-
* different thing. A CLI that needs it can be granted it by name.
|
|
41
|
-
*
|
|
42
|
-
* These are grants, so they are listed rather than assumed: `seisin check`
|
|
43
|
-
* prints them, and `[runtime] writes = []` turns them off for anyone who wants
|
|
44
|
-
* to find out the hard way. Note what is NOT here — the home directory, the
|
|
45
|
-
* shell profile, anything under the repo. Scratch space is not a back door.
|
|
46
|
-
*/
|
|
47
|
-
/**
|
|
48
|
-
* Where a role's home and scratch live when `[runtime] isolate = true`.
|
|
38
|
+
* The settings one role gets.
|
|
49
39
|
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
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.
|
|
53
44
|
*/
|
|
54
45
|
/**
|
|
55
|
-
*
|
|
56
|
-
* unix socket path is capped near 104 bytes on macOS and the failure when it is
|
|
57
|
-
* too long is an unhelpful EINVAL.
|
|
46
|
+
* `local_ports`, as the allowlist entries that let the proxy dial them.
|
|
58
47
|
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
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.
|
|
65
56
|
*
|
|
66
|
-
*
|
|
67
|
-
* {@link MAX_ROLE_FOR_HOME} characters.
|
|
57
|
+
* All three spellings, because a client says whichever it says.
|
|
68
58
|
*/
|
|
69
|
-
export function
|
|
70
|
-
|
|
71
|
-
* A hash of the whole path, not a slice of it.
|
|
72
|
-
*
|
|
73
|
-
* This was `base64url(root).slice(-16)` — the TAIL of the encoded path, which
|
|
74
|
-
* is the tail of the path itself. Two checkouts that end the same way get the
|
|
75
|
-
* same id: `/Users/ana/dev/proyecto` and `/Users/bob/dev/proyecto` collide, and
|
|
76
|
-
* so do `/home/a/work/api` and `/home/b/work/api`. A collision here is not a
|
|
77
|
-
* cosmetic clash — both repos' role `dev` would share one HOME, which is the
|
|
78
|
-
* session token of one handed to the other. Measured on three of four ordinary
|
|
79
|
-
* pairs; shortening the slice to fit the socket limit would have made it more
|
|
80
|
-
* likely, not less.
|
|
81
|
-
*/
|
|
82
|
-
const id = createHash("sha256").update(config.root).digest("base64url").slice(0, 8);
|
|
83
|
-
return join(tmpdir(), `sn-${id}`);
|
|
59
|
+
export function localPortDomains(ports = []) {
|
|
60
|
+
return (ports ?? []).flatMap((p) => [`localhost:${p}`, `127.0.0.1:${p}`, `[::1]:${p}`]);
|
|
84
61
|
}
|
|
85
62
|
|
|
86
|
-
/** What the runtime appends inside the role's home, at its longest. */
|
|
87
|
-
const SRT_SOCKET_TAIL = "/tmp/srt-mux-999999-0.sock".length;
|
|
88
|
-
|
|
89
|
-
/** The platform's cap on a unix socket path. macOS is the tight one. */
|
|
90
|
-
const SOCKET_MAX = 104;
|
|
91
|
-
|
|
92
63
|
/**
|
|
93
|
-
*
|
|
64
|
+
* What the runtime puts in NO_PROXY, minus loopback.
|
|
94
65
|
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
return SOCKET_MAX - (roleHome(config, role).length + SRT_SOCKET_TAIL);
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
export function roleHome(config, role) {
|
|
104
|
-
return join(roleHomeRoot(config), role);
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
/**
|
|
108
|
-
* Where credentials live in a home directory, denied at both isolate levels.
|
|
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.
|
|
109
71
|
*
|
|
110
|
-
*
|
|
111
|
-
* default-deny read set has to enumerate every library, interpreter and cache a
|
|
112
|
-
* toolchain touches, gets one wrong, and fails as an unexplainable crash inside
|
|
113
|
-
* the agent. Naming the places credentials actually live is narrower than the
|
|
114
|
-
* ideal and holds up.
|
|
115
|
-
*
|
|
116
|
-
* `~/.config` is here whole rather than `~/.config/gh`: it is where a growing
|
|
117
|
-
* number of CLIs keep their tokens, and listing them one by one is the
|
|
118
|
-
* enumeration this list exists to avoid. A tool that needs a directory under it
|
|
119
|
-
* can be granted it by name.
|
|
72
|
+
* Kept in step with sandbox-utils.js of the pinned runtime by a test.
|
|
120
73
|
*/
|
|
121
|
-
export const
|
|
122
|
-
// The order is not arbitrary: `check` names the first few, so the ones a
|
|
123
|
-
// reader recognises instantly go first. Everything in the list is denied
|
|
124
|
-
// either way.
|
|
125
|
-
"~/.ssh", "~/.aws", "~/.npmrc", "~/.config",
|
|
126
|
-
"~/.gnupg", "~/.kube", "~/.docker", "~/.netrc", "~/.git-credentials",
|
|
127
|
-
];
|
|
128
|
-
|
|
129
|
-
export const RUNTIME_WRITES = [
|
|
130
|
-
"~/.claude", "~/.codex", // the CLIs that keep state under their own name
|
|
131
|
-
"~/.local/share", "~/.local/state", // XDG data and state: where most others log
|
|
132
|
-
"~/.cache", "$TMPDIR", "/tmp",
|
|
133
|
-
];
|
|
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";
|
|
134
75
|
|
|
135
76
|
/**
|
|
136
|
-
*
|
|
77
|
+
* The command a role runs, with loopback routed through the proxy when it
|
|
78
|
+
* has `local_ports`.
|
|
137
79
|
*
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
*
|
|
141
|
-
*
|
|
142
|
-
*
|
|
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.
|
|
143
86
|
*/
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
return lstatSync(p).isSymbolicLink();
|
|
148
|
-
} catch {
|
|
149
|
-
return false;
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
/** The path with symlinks followed, or the path itself if it is not on disk. */
|
|
154
|
-
function realOrSelf(p) {
|
|
155
|
-
try {
|
|
156
|
-
return realpathSync(p);
|
|
157
|
-
} catch {
|
|
158
|
-
return p;
|
|
159
|
-
}
|
|
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];
|
|
160
90
|
}
|
|
161
91
|
|
|
162
|
-
export function expand(p) {
|
|
163
|
-
let out = p;
|
|
164
|
-
if (p === "$TMPDIR") out = tmpdir();
|
|
165
|
-
else if (p === "~" || p.startsWith("~/")) out = join(homedir(), p.slice(2));
|
|
166
|
-
try {
|
|
167
|
-
return realpathSync(out);
|
|
168
|
-
} catch {
|
|
169
|
-
return out; // not on disk yet; hand the literal through rather than drop it
|
|
170
|
-
}
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
/** Reads are denied wholesale under the key directory, then re-allowed one file at a time. */
|
|
174
|
-
/**
|
|
175
|
-
* The settings one role gets.
|
|
176
|
-
*
|
|
177
|
-
* `spool` is the path of the parent's audit socket, when a parent is holding
|
|
178
|
-
* one. It is granted by path so the hook can report what it decided without
|
|
179
|
-
* the repo's state directory being writable from inside — see spool.js. With
|
|
180
|
-
* no parent there is nothing to grant and nothing to reach.
|
|
181
|
-
*/
|
|
182
92
|
export function settingsFor(config, roleName, spool = null, observe = false) {
|
|
183
93
|
const role = config.roles[roleName];
|
|
184
94
|
if (!role) throw new Error(`unknown role "${roleName}". Known: ${Object.keys(config.roles).join(", ")}`);
|
|
@@ -322,14 +232,37 @@ export function settingsFor(config, roleName, spool = null, observe = false) {
|
|
|
322
232
|
allowRead.push(path);
|
|
323
233
|
}
|
|
324
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
|
+
|
|
325
250
|
return {
|
|
326
251
|
network: {
|
|
327
|
-
|
|
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,
|
|
328
259
|
deniedDomains: [],
|
|
329
260
|
// Exactly one socket: the parent's audit spool, when there is a parent.
|
|
330
261
|
// Granted by path, not by turning unix sockets on.
|
|
331
|
-
allowUnixSockets: spool ? [spool] : [],
|
|
332
|
-
|
|
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,
|
|
333
266
|
},
|
|
334
267
|
filesystem: {
|
|
335
268
|
denyRead,
|
|
@@ -346,18 +279,11 @@ export function settingsFor(config, roleName, spool = null, observe = false) {
|
|
|
346
279
|
// holds: the config, the state directory and the key directories stay
|
|
347
280
|
// shut even here, because observing is not a reason to hand over the
|
|
348
281
|
// paperwork of the confinement.
|
|
349
|
-
|
|
350
|
-
//
|
|
351
|
-
//
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
// parent holds the file. See spool.js.
|
|
355
|
-
// Isolated: this role's own home and scratch, and nothing shared.
|
|
356
|
-
// Otherwise: the real ~/.claude, ~/.cache and /tmp, which every role
|
|
357
|
-
// shares — convenient, and the reason `isolate` exists.
|
|
358
|
-
...(isolated
|
|
359
|
-
? [home, join(home, ".config"), join(home, ".local"), join(home, ".cache"), join(home, "tmp")]
|
|
360
|
-
: (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] : []),
|
|
361
287
|
],
|
|
362
288
|
/**
|
|
363
289
|
* The confinement's own paperwork, never writable — not even by a role
|
|
@@ -375,36 +301,52 @@ export function settingsFor(config, roleName, spool = null, observe = false) {
|
|
|
375
301
|
* relying on someone else's implementation detail staying put.
|
|
376
302
|
*/
|
|
377
303
|
denyWrite: [
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
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),
|
|
381
328
|
// The audit socket, and the directory holding it. Denying only the
|
|
382
329
|
// socket left `mv /tmp/seisin-xxxx /tmp/gone` as a way to take the
|
|
383
330
|
// channel out without ever touching the file that was protected.
|
|
384
331
|
// Connecting is `network-outbound` and both of those are
|
|
385
332
|
// `file-write-unlink`, so denying them leaves the first working.
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
* the parent runs it, unsandboxed, as you — so a role that can rewrite
|
|
393
|
-
* `bin/open-vault.sh` decides what runs outside the box. That is not a
|
|
394
|
-
* wider boundary, it is no boundary, and it arrives disguised as an
|
|
395
|
-
* ordinary file in somebody's territory.
|
|
396
|
-
*
|
|
397
|
-
* Found by writing the documentation: every worked example ended up
|
|
398
|
-
* using a script, because the config language has no escapes and a
|
|
399
|
-
* one-line shell pipeline cannot be spelled. So the shape this protects
|
|
400
|
-
* is not a corner case — it is the shape the tool pushes you into.
|
|
401
|
-
*
|
|
402
|
-
* Only a command that resolves to a path. A bare name like `security`
|
|
403
|
-
* or `op` is found on PATH, which is not this policy's to reason about.
|
|
404
|
-
*/
|
|
405
|
-
...providerPaths(config),
|
|
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] : [])] : []),
|
|
406
339
|
],
|
|
407
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 } : {}),
|
|
408
350
|
};
|
|
409
351
|
}
|
|
410
352
|
|
|
@@ -425,47 +367,3 @@ export function providerPaths(config) {
|
|
|
425
367
|
return [...new Set(out)];
|
|
426
368
|
}
|
|
427
369
|
|
|
428
|
-
/**
|
|
429
|
-
* `allowWrite` takes directories, not globs: the OS grants a subtree, it does
|
|
430
|
-
* not pattern-match. `src/api/**` and `src/api/` mean the same thing to the
|
|
431
|
-
* kernel, so the trailing glob is trimmed rather than passed through, where it
|
|
432
|
-
* would be read as a literal directory named `**` and silently grant nothing.
|
|
433
|
-
*/
|
|
434
|
-
function toWritePath(glob) {
|
|
435
|
-
if (!EXPRESSIBLE(glob)) throw new Error(unexpressible(glob));
|
|
436
|
-
if (glob === "**") return ".";
|
|
437
|
-
return glob.replace(/\/\*\*$/, "") || ".";
|
|
438
|
-
}
|
|
439
|
-
|
|
440
|
-
/**
|
|
441
|
-
* A write pattern the kernel can be given without changing its meaning.
|
|
442
|
-
*
|
|
443
|
-
* Exactly two shapes qualify: a literal path, and a subtree ending in `/**`.
|
|
444
|
-
* Both map onto what allowWrite actually is — a prefix — so the policy, the
|
|
445
|
-
* explanation and the enforcement stay the same sentence.
|
|
446
|
-
*
|
|
447
|
-
* Everything else is refused, and the one that made this necessary is `src/*`.
|
|
448
|
-
* ownersOf() reads it as one level, because `*` compiles to `[^/]*`. The old
|
|
449
|
-
* translation trimmed the `/*` and handed the kernel `src`, which is the whole
|
|
450
|
-
* subtree. So `seisin explain` said denied and the write landed — measured, not
|
|
451
|
-
* theorised. The document was tighter than the boundary, which is the one
|
|
452
|
-
* direction a permission tool must never fail in.
|
|
453
|
-
*
|
|
454
|
-
* There is no exact translation to find later: Seatbelt and bubblewrap grant
|
|
455
|
-
* prefixes, and "one level down" is not a prefix. Refusing is not a placeholder
|
|
456
|
-
* here, it is the answer.
|
|
457
|
-
*/
|
|
458
|
-
const WILD = /[*?[\]]/; // owners.js treats all four as wildcards
|
|
459
|
-
const EXPRESSIBLE = (g) =>
|
|
460
|
-
g === "**" || // the whole repo
|
|
461
|
-
!WILD.test(g) || // a literal path
|
|
462
|
-
(g.endsWith("/**") && !WILD.test(g.slice(0, -3))); // a subtree, wildcard-free above it
|
|
463
|
-
|
|
464
|
-
function unexpressible(glob) {
|
|
465
|
-
return `writes = "${glob}" cannot be enforced as written.\n` +
|
|
466
|
-
` The sandbox grants a path and everything under it — there is no way to say ` +
|
|
467
|
-
`"one level deep".\n` +
|
|
468
|
-
` Use "${glob.replace(/\/\*$/, "")}/**" for the whole subtree, or name the files.\n` +
|
|
469
|
-
` Refusing rather than widening: the old behaviour granted the subtree while ` +
|
|
470
|
-
`seisin reported the narrow pattern.`;
|
|
471
|
-
}
|