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/grants.js
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a line of policy grants on disk, as paths.
|
|
3
|
+
*
|
|
4
|
+
* Pulled out of srt.js, which used to hold both halves: translating a policy
|
|
5
|
+
* into paths, and assembling the runtime's settings object out of them. The
|
|
6
|
+
* first half has a second reader now — surface.js asks "what can ANY role
|
|
7
|
+
* write?" to decide what the parent must not trust — and a module that needs
|
|
8
|
+
* the answer should not have to import the settings builder to get it.
|
|
9
|
+
*
|
|
10
|
+
* No policy decisions live here. Every function answers "which path does this
|
|
11
|
+
* mean", and the callers decide what to do with it.
|
|
12
|
+
*/
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import { homedir, tmpdir } from "node:os";
|
|
15
|
+
import { realpathSync, lstatSync } from "node:fs";
|
|
16
|
+
import { createHash } from "node:crypto";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* What every agent needs to write no matter which role it is.
|
|
20
|
+
*
|
|
21
|
+
* Found by running a real cell config through the sandbox: territory alone
|
|
22
|
+
* looks correct and is unusable. An agent writes its session state under its
|
|
23
|
+
* own config directory and its tools write scratch files to the temp dir, so a
|
|
24
|
+
* policy of "your folders and nothing else" stops the agent before it starts.
|
|
25
|
+
*
|
|
26
|
+
* The list is not Claude-shaped by accident and it was Claude-shaped by
|
|
27
|
+
* mistake: the first version named `~/.claude` and `~/.codex` and stopped
|
|
28
|
+
* there, so the first agent that was neither — opencode, which logs to
|
|
29
|
+
* `~/.local/share/opencode` — died on startup with `FileSystem.open`. Hence the
|
|
30
|
+
* XDG directories, which is where a CLI that follows convention puts its state.
|
|
31
|
+
*
|
|
32
|
+
* `~/.config` is deliberately NOT here. That is where credentials live —
|
|
33
|
+
* `~/.config/gh/hosts.yml` holds a GitHub token — and while reads outside the
|
|
34
|
+
* declared key directories are open anyway, letting an agent WRITE there is a
|
|
35
|
+
* different thing. A CLI that needs it can be granted it by name.
|
|
36
|
+
*
|
|
37
|
+
* These are grants, so they are listed rather than assumed: `seisin check`
|
|
38
|
+
* prints them, and `[runtime] writes = []` turns them off for anyone who wants
|
|
39
|
+
* to find out the hard way. Note what is NOT here — the home directory, the
|
|
40
|
+
* shell profile, anything under the repo. Scratch space is not a back door.
|
|
41
|
+
*/
|
|
42
|
+
/**
|
|
43
|
+
* Where a role's home and scratch live when `[runtime] isolate = true`.
|
|
44
|
+
*
|
|
45
|
+
* Under the state directory but outside the repo tree the roles write, so one
|
|
46
|
+
* role cannot reach another's. Kept out of `.seisin/` inside the repo because
|
|
47
|
+
* that whole directory is denyWrite now.
|
|
48
|
+
*/
|
|
49
|
+
/**
|
|
50
|
+
* Short on purpose, and the reason is the same one spool.js already names: a
|
|
51
|
+
* unix socket path is capped near 104 bytes on macOS and the failure when it is
|
|
52
|
+
* too long is an unhelpful EINVAL.
|
|
53
|
+
*
|
|
54
|
+
* This file forgot that one layer down. The runtime creates its multiplexing
|
|
55
|
+
* socket INSIDE the role's home — `<home>/tmp/srt-mux-<pid>-0.sock`, 25 bytes —
|
|
56
|
+
* and the name here used to be `seisin-home-` plus 16 characters. On macOS
|
|
57
|
+
* `tmpdir()` is 48 bytes on its own, so the total cleared 104 for **every role
|
|
58
|
+
* name**, `qa` included: `isolate = true` did not misbehave, it failed to start.
|
|
59
|
+
* Measured, and it is why `homeFits` below exists rather than a comment.
|
|
60
|
+
*
|
|
61
|
+
* Eleven bytes instead of twenty-eight leaves room for a role name up to
|
|
62
|
+
* {@link MAX_ROLE_FOR_HOME} characters.
|
|
63
|
+
*/
|
|
64
|
+
export function roleHomeRoot(config) {
|
|
65
|
+
/**
|
|
66
|
+
* A hash of the whole path, not a slice of it.
|
|
67
|
+
*
|
|
68
|
+
* This was `base64url(root).slice(-16)` — the TAIL of the encoded path, which
|
|
69
|
+
* is the tail of the path itself. Two checkouts that end the same way get the
|
|
70
|
+
* same id: `/Users/ana/dev/proyecto` and `/Users/bob/dev/proyecto` collide, and
|
|
71
|
+
* so do `/home/a/work/api` and `/home/b/work/api`. A collision here is not a
|
|
72
|
+
* cosmetic clash — both repos' role `dev` would share one HOME, which is the
|
|
73
|
+
* session token of one handed to the other. Measured on three of four ordinary
|
|
74
|
+
* pairs; shortening the slice to fit the socket limit would have made it more
|
|
75
|
+
* likely, not less.
|
|
76
|
+
*/
|
|
77
|
+
const id = createHash("sha256").update(config.root).digest("base64url").slice(0, 8);
|
|
78
|
+
return join(tmpdir(), `sn-${id}`);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** What the runtime appends inside the role's home, at its longest. */
|
|
82
|
+
const SRT_SOCKET_TAIL = "/tmp/srt-mux-999999-0.sock".length;
|
|
83
|
+
|
|
84
|
+
/** The platform's cap on a unix socket path. macOS is the tight one. */
|
|
85
|
+
export const SOCKET_MAX = 104;
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Whether a role's isolated home leaves room for the runtime's socket.
|
|
89
|
+
*
|
|
90
|
+
* Returns the room left over, negative when it does not fit. Callers refuse
|
|
91
|
+
* rather than let the runtime fail with EINVAL and no explanation — a boundary
|
|
92
|
+
* that cannot start should say so in its own words.
|
|
93
|
+
*/
|
|
94
|
+
export function homeFits(config, role) {
|
|
95
|
+
return SOCKET_MAX - (roleHome(config, role).length + SRT_SOCKET_TAIL);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function roleHome(config, role) {
|
|
99
|
+
return join(roleHomeRoot(config), role);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Where credentials live in a home directory, denied at both isolate levels.
|
|
104
|
+
*
|
|
105
|
+
* Denies rather than a read allowlist, and the reasoning is above: a
|
|
106
|
+
* default-deny read set has to enumerate every library, interpreter and cache a
|
|
107
|
+
* toolchain touches, gets one wrong, and fails as an unexplainable crash inside
|
|
108
|
+
* the agent. Naming the places credentials actually live is narrower than the
|
|
109
|
+
* ideal and holds up.
|
|
110
|
+
*
|
|
111
|
+
* `~/.config` is here whole rather than `~/.config/gh`: it is where a growing
|
|
112
|
+
* number of CLIs keep their tokens, and listing them one by one is the
|
|
113
|
+
* enumeration this list exists to avoid. A tool that needs a directory under it
|
|
114
|
+
* can be granted it by name.
|
|
115
|
+
*/
|
|
116
|
+
export const CREDENTIAL_HOMES = [
|
|
117
|
+
// The order is not arbitrary: `check` names the first few, so the ones a
|
|
118
|
+
// reader recognises instantly go first. Everything in the list is denied
|
|
119
|
+
// either way.
|
|
120
|
+
"~/.ssh", "~/.aws", "~/.npmrc", "~/.config",
|
|
121
|
+
"~/.gnupg", "~/.kube", "~/.docker", "~/.netrc", "~/.git-credentials",
|
|
122
|
+
];
|
|
123
|
+
|
|
124
|
+
export const RUNTIME_WRITES = [
|
|
125
|
+
"~/.claude", "~/.codex", // the CLIs that keep state under their own name
|
|
126
|
+
"~/.local/share", "~/.local/state", // XDG data and state: where most others log
|
|
127
|
+
"~/.cache", "$TMPDIR", "/tmp",
|
|
128
|
+
];
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* `~` and `$TMPDIR` are the only expansions; everything else is a literal path.
|
|
132
|
+
*
|
|
133
|
+
* Symlinks are then resolved, and that is not a nicety. On macOS `/tmp` is a
|
|
134
|
+
* link to `/private/tmp`, and the sandbox enforces on the destination — so a
|
|
135
|
+
* grant written as `/tmp` grants exactly nothing, silently. Measured: with the
|
|
136
|
+
* literal path, writing to /tmp inside the sandbox failed while the policy
|
|
137
|
+
* looked correct on screen.
|
|
138
|
+
*/
|
|
139
|
+
/** Is this exact path a symlink? False if it is not on disk. */
|
|
140
|
+
export function isLink(p) {
|
|
141
|
+
try {
|
|
142
|
+
return lstatSync(p).isSymbolicLink();
|
|
143
|
+
} catch {
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** The path with symlinks followed, or the path itself if it is not on disk. */
|
|
149
|
+
export function realOrSelf(p) {
|
|
150
|
+
try {
|
|
151
|
+
return realpathSync(p);
|
|
152
|
+
} catch {
|
|
153
|
+
return p;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function expand(p) {
|
|
158
|
+
let out = p;
|
|
159
|
+
if (p === "$TMPDIR") out = tmpdir();
|
|
160
|
+
else if (p === "~" || p.startsWith("~/")) out = join(homedir(), p.slice(2));
|
|
161
|
+
try {
|
|
162
|
+
return realpathSync(out);
|
|
163
|
+
} catch {
|
|
164
|
+
return out; // not on disk yet; hand the literal through rather than drop it
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* `allowWrite` takes directories, not globs: the OS grants a subtree, it does
|
|
170
|
+
* not pattern-match. `src/api/**` and `src/api/` mean the same thing to the
|
|
171
|
+
* kernel, so the trailing glob is trimmed rather than passed through, where it
|
|
172
|
+
* would be read as a literal directory named `**` and silently grant nothing.
|
|
173
|
+
*/
|
|
174
|
+
export function toWritePath(glob, key = "writes") {
|
|
175
|
+
if (!EXPRESSIBLE(glob)) throw new Error(unexpressible(glob, key));
|
|
176
|
+
if (glob === "**") return ".";
|
|
177
|
+
return glob.replace(/\/\*\*$/, "") || ".";
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* A write pattern the kernel can be given without changing its meaning.
|
|
182
|
+
*
|
|
183
|
+
* Exactly two shapes qualify: a literal path, and a subtree ending in `/**`.
|
|
184
|
+
* Both map onto what allowWrite actually is — a prefix — so the policy, the
|
|
185
|
+
* explanation and the enforcement stay the same sentence.
|
|
186
|
+
*
|
|
187
|
+
* Everything else is refused, and the one that made this necessary is `src/*`.
|
|
188
|
+
* ownersOf() reads it as one level, because `*` compiles to `[^/]*`. The old
|
|
189
|
+
* translation trimmed the `/*` and handed the kernel `src`, which is the whole
|
|
190
|
+
* subtree. So `seisin explain` said denied and the write landed — measured, not
|
|
191
|
+
* theorised. The document was tighter than the boundary, which is the one
|
|
192
|
+
* direction a permission tool must never fail in.
|
|
193
|
+
*
|
|
194
|
+
* There is no exact translation to find later: Seatbelt and bubblewrap grant
|
|
195
|
+
* prefixes, and "one level down" is not a prefix. Refusing is not a placeholder
|
|
196
|
+
* here, it is the answer.
|
|
197
|
+
*/
|
|
198
|
+
const WILD = /[*?[\]]/; // owners.js treats all four as wildcards
|
|
199
|
+
const EXPRESSIBLE = (g) =>
|
|
200
|
+
g === "**" || // the whole repo
|
|
201
|
+
!WILD.test(g) || // a literal path
|
|
202
|
+
(g.endsWith("/**") && !WILD.test(g.slice(0, -3))); // a subtree, wildcard-free above it
|
|
203
|
+
|
|
204
|
+
function unexpressible(glob, key = "writes") {
|
|
205
|
+
return `${key} = "${glob}" cannot be enforced as written.\n` +
|
|
206
|
+
` The sandbox grants a path and everything under it — there is no way to say ` +
|
|
207
|
+
`"one level deep".\n` +
|
|
208
|
+
` Use "${glob.replace(/\/\*$/, "")}/**" for the whole subtree, or name the files.\n` +
|
|
209
|
+
` Refusing rather than widening: the old behaviour granted the subtree while ` +
|
|
210
|
+
`seisin reported the narrow pattern.`;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Every directory a role may write, absolute, as its settings will say it.
|
|
215
|
+
*
|
|
216
|
+
* Observe mode is not a role property — any role can be run with it — and it
|
|
217
|
+
* grants the whole repo, so `observe: true` answers for that case.
|
|
218
|
+
*
|
|
219
|
+
* `.seisin/` is deliberately NOT here. It used to be, because the hook runs
|
|
220
|
+
* inside the box and has to record what it decided — which made the log and
|
|
221
|
+
* the queue writable by the process they are a record of. The hook now sends
|
|
222
|
+
* its lines to the parent over a socket and the parent holds the file. See
|
|
223
|
+
* spool.js.
|
|
224
|
+
*
|
|
225
|
+
* Isolated: this role's own home and scratch, and nothing shared. Otherwise:
|
|
226
|
+
* the real ~/.claude, ~/.cache and /tmp, which every role shares — convenient,
|
|
227
|
+
* and the reason `isolate` exists.
|
|
228
|
+
*/
|
|
229
|
+
export function writePathsOf(config, role, { observe = false } = {}) {
|
|
230
|
+
const abs = (p) => (p.startsWith("/") ? p : join(config.root, p));
|
|
231
|
+
const level = config.isolate === true ? "home" : config.isolate;
|
|
232
|
+
const home = level === "home" ? roleHome(config, role.name) : null;
|
|
233
|
+
return [
|
|
234
|
+
...(observe ? [abs(".")] : role.writes.map((g) => abs(toWritePath(g)))),
|
|
235
|
+
...(home
|
|
236
|
+
? [home, join(home, ".config"), join(home, ".local"), join(home, ".cache"), join(home, "tmp")]
|
|
237
|
+
: (config.runtimeWrites ?? RUNTIME_WRITES).map(expand)),
|
|
238
|
+
];
|
|
239
|
+
}
|
package/src/hook.js
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
* is a minor one here, because the enforcer is the kernel and it is not fooled.
|
|
17
17
|
* What escapes this file goes unexplained, not unblocked.
|
|
18
18
|
*/
|
|
19
|
+
import { toRepoRelative } from "./paths.js";
|
|
19
20
|
import { explain, normalize } from "./owners.js";
|
|
20
21
|
import { append, logPath } from "./log.js";
|
|
21
22
|
import { timesHit } from "./walls.js";
|
|
@@ -139,7 +140,7 @@ export function decide(config, role, event, { observe = false, now = append, ask
|
|
|
139
140
|
const verdicts = [];
|
|
140
141
|
|
|
141
142
|
for (const t of targets) {
|
|
142
|
-
const rel =
|
|
143
|
+
const rel = toRepoRelative(config, t.path);
|
|
143
144
|
const kind = kindOf(config, rel);
|
|
144
145
|
|
|
145
146
|
// Reading an ordinary file is never a policy question. Territory divides
|
|
@@ -168,7 +169,11 @@ export function decide(config, role, event, { observe = false, now = append, ask
|
|
|
168
169
|
// the refusal stops being a dead end and becomes something a person can act
|
|
169
170
|
// on in one command. Observing records nothing to approve — there was no
|
|
170
171
|
// denial to answer. See docs/permission-requests.md.
|
|
171
|
-
|
|
172
|
+
//
|
|
173
|
+
// Except a `never_writes` refusal. That one is the policy saying no on
|
|
174
|
+
// purpose; a request for it asks a person to undo a subtraction somebody
|
|
175
|
+
// wrote, and approving it is how the subtraction disappears unnoticed.
|
|
176
|
+
if (!observe && !v.allowed && !v.neverWrites && !v.gitMetadata)
|
|
172
177
|
ask(queue, { role, action: kind === "key" ? "read" : t.action, target: rel, owners: v.owners ?? [] });
|
|
173
178
|
|
|
174
179
|
// The action as it was RECORDED, not as the tool named it: a read of a key
|
|
@@ -216,7 +221,11 @@ export function decide(config, role, event, { observe = false, now = append, ask
|
|
|
216
221
|
permissionDecisionReason:
|
|
217
222
|
// The sentence has to name a next step, not just a refusal. An agent
|
|
218
223
|
// told "no" retries; an agent told whose it is asks, or moves on.
|
|
219
|
-
|
|
224
|
+
denied.neverWrites
|
|
225
|
+
? `${denied.reason}. Do not ask for it: nothing was queued, because the answer is already written down.` + again
|
|
226
|
+
: denied.gitMetadata
|
|
227
|
+
? `${denied.reason}. Nothing was queued.` + again
|
|
228
|
+
: (denied.owners?.length
|
|
220
229
|
? wasRead
|
|
221
230
|
? `${denied.target} is declared for ${denied.owners.join(", ")}, not ${role}. Ask for what you need from it rather than reading the key.`
|
|
222
231
|
: `${denied.target} belongs to ${denied.owners.join(", ")}. It is not ${role}'s to change — hand it over rather than working around it.`
|
package/src/inspect.js
CHANGED
|
@@ -10,11 +10,13 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import { resolve, dirname, basename, relative, join, delimiter } from "node:path";
|
|
12
12
|
import { realpathSync, lstatSync, readdirSync, existsSync, readFileSync, statSync } from "node:fs";
|
|
13
|
-
import { ownersOf } from "./owners.js";
|
|
13
|
+
import { ownersOf, covers, enforcedNeverWrites } from "./owners.js";
|
|
14
|
+
import { ROLE_KEYS } from "./config.js";
|
|
14
15
|
import { entriesOf } from "./keys.js";
|
|
15
16
|
import { SHAPES } from "./scan.js";
|
|
16
17
|
import { wired } from "./commands/wire.js";
|
|
17
18
|
import { RUNTIME_WRITES, CREDENTIAL_HOMES, expand, settingsFor } from "./srt.js";
|
|
19
|
+
import { protections, resolveExecutable } from "./surface.js";
|
|
18
20
|
|
|
19
21
|
/**
|
|
20
22
|
* A report on one config: the roles, and every way the map lies.
|
|
@@ -29,7 +31,7 @@ export function inspect(config, only = null, where = config.path) {
|
|
|
29
31
|
|
|
30
32
|
return {
|
|
31
33
|
where,
|
|
32
|
-
roles: roles.map((r) => ({ name: r.name, writes: r.writes, keys: r.keys })),
|
|
34
|
+
roles: roles.map((r) => ({ name: r.name, writes: r.writes, keys: r.keys, neverWrites: r.neverWrites ?? [], localBinding: r.localBinding === true, localPorts: r.localPorts ?? [], mcp: r.mcp ?? null, trustd: r.trustd === true })),
|
|
33
35
|
/**
|
|
34
36
|
* The provider commands this config would run, listed because they are the
|
|
35
37
|
* one thing in a `seisin.toml` that **executes**, and it executes in the
|
|
@@ -42,6 +44,14 @@ export function inspect(config, only = null, where = config.path) {
|
|
|
42
44
|
name: p.name, command: p.command, mode: p.mode,
|
|
43
45
|
})),
|
|
44
46
|
shared: sharedPaths(config, roles),
|
|
47
|
+
/**
|
|
48
|
+
* What the kernel will refuse a role inside its own territory, and why:
|
|
49
|
+
* programs installed where a role writes, the hooks and settings of each
|
|
50
|
+
* project, provider scripts and `file://` targets. Each takes something
|
|
51
|
+
* away from a territory as written, so it is shown rather than applied
|
|
52
|
+
* quietly. See surface.js for the family and the measurements.
|
|
53
|
+
*/
|
|
54
|
+
protected: protections(config, roles),
|
|
45
55
|
warnings: warningsFor(config, roles),
|
|
46
56
|
limits: LIMITS,
|
|
47
57
|
};
|
|
@@ -69,7 +79,7 @@ const LIMITS = [
|
|
|
69
79
|
"equivalent to read here — bubblewrap does not log refusals, and the runtime's substitute " +
|
|
70
80
|
"is not reachable from outside it. The boundary holds exactly as well; what you lose is " +
|
|
71
81
|
"seeing it work. Run `seisin wire` so the hook records what it can. The ask is upstream: " +
|
|
72
|
-
"github.com/
|
|
82
|
+
"github.com/carlostapiacl/seisin/blob/main/docs/upstream/cli-violations.md",
|
|
73
83
|
}]),
|
|
74
84
|
{
|
|
75
85
|
kind: "keys-only-what-you-declared",
|
|
@@ -89,7 +99,25 @@ const LIMITS = [
|
|
|
89
99
|
"library can read them; nothing reaches a caller that runs the binary, which is what " +
|
|
90
100
|
"seisin does. So for an agent whose endpoints are not published, the options are to " +
|
|
91
101
|
"find them another way or drop the network restriction for it. The ask is upstream: " +
|
|
92
|
-
"github.com/
|
|
102
|
+
"github.com/carlostapiacl/seisin/blob/main/docs/upstream/cli-violations.md",
|
|
103
|
+
},
|
|
104
|
+
...(process.platform === "linux" ? [{
|
|
105
|
+
kind: "control-files-linux",
|
|
106
|
+
headline: "a project's hooks and settings are protected only where they already exist",
|
|
107
|
+
detail:
|
|
108
|
+
"bubblewrap denies a path by mounting over it, and for a path that is not there it would " +
|
|
109
|
+
"create it on your disk. So on Linux a role can still create a .git/hooks entry or a " +
|
|
110
|
+
".claude/settings.json in a project of its territory that had none. On macOS the kernel " +
|
|
111
|
+
"refuses creating them too.",
|
|
112
|
+
}] : []),
|
|
113
|
+
{
|
|
114
|
+
kind: "shared-scratch",
|
|
115
|
+
headline: "roles share ~/.cache, ~/.local/share and the temp dir unless isolate = \"home\"",
|
|
116
|
+
detail:
|
|
117
|
+
"programs installed there that are on your PATH are protected, and so are Claude Code's " +
|
|
118
|
+
"settings and plugins. Anything else a program outside the box reads from those places — a " +
|
|
119
|
+
"cache it executes, a tool not on PATH — is writable by every role. isolate = \"home\" " +
|
|
120
|
+
"gives each role its own.",
|
|
93
121
|
},
|
|
94
122
|
{
|
|
95
123
|
kind: "unlink-uncovered",
|
|
@@ -99,10 +127,127 @@ const LIMITS = [
|
|
|
99
127
|
// package, so for most readers it named a file they do not have.
|
|
100
128
|
"writes and deletes are one permission to the kernel, so `rm` inside a " +
|
|
101
129
|
"role's own folders succeeds. Closing it needs denyUnlink in the sandbox " +
|
|
102
|
-
"runtime: github.com/
|
|
130
|
+
"runtime: github.com/carlostapiacl/seisin#how-it-holds",
|
|
103
131
|
},
|
|
104
132
|
];
|
|
105
133
|
|
|
134
|
+
/**
|
|
135
|
+
* What is wrong with a role table's keys, as opposed to its paths.
|
|
136
|
+
*
|
|
137
|
+
* Two things, both about `never_writes` and both ways a subtraction can look
|
|
138
|
+
* written and not be:
|
|
139
|
+
*
|
|
140
|
+
* - a key seisin does not know. It used to be dropped without a word, which
|
|
141
|
+
* for `writes` is a role with less than it thinks, and for a misspelt
|
|
142
|
+
* `never_writes` is a role with MORE than its owner thinks — the one
|
|
143
|
+
* direction a permission file must not fail in. Named with the nearest
|
|
144
|
+
* known key when there is one close enough to be the intended spelling.
|
|
145
|
+
* - a `never_writes` entry that no `writes` of the same role covers. It
|
|
146
|
+
* subtracts from nothing, so it is either a typo in the path or a rule
|
|
147
|
+
* that stopped applying when the territory moved. Either way it reads as
|
|
148
|
+
* protection and is not.
|
|
149
|
+
*
|
|
150
|
+
* Silent when `never_writes` is absent. Absent is valid; a check that asks for
|
|
151
|
+
* the key would turn an optional subtraction into a required migration.
|
|
152
|
+
*/
|
|
153
|
+
function roleKeyWarnings(roles, config = null) {
|
|
154
|
+
const warnings = [];
|
|
155
|
+
for (const r of roles) {
|
|
156
|
+
const enforced = new Set(enforcedNeverWrites(config, r));
|
|
157
|
+
const skipped = (r.neverWritesDeclared ?? r.neverWrites ?? []).filter((g) => !enforced.has(g));
|
|
158
|
+
if (skipped.length)
|
|
159
|
+
warnings.push({
|
|
160
|
+
kind: "never-writes-not-enforced-here",
|
|
161
|
+
headline: `${r.name}: never_writes ${skipped.join(" ")} — not enforced on Linux until it exists`,
|
|
162
|
+
detail: "bubblewrap denies a path by mounting over it, and would create a missing one on the host " +
|
|
163
|
+
"for the whole run — for a lock file, locking everyone else out, and for good if the role is " +
|
|
164
|
+
"killed. seisin skips those, and explain says allowed for them, so the sentence matches the kernel.",
|
|
165
|
+
});
|
|
166
|
+
for (const k of r.unknownKeys ?? []) {
|
|
167
|
+
const near = nearest(k, ROLE_KEYS);
|
|
168
|
+
warnings.push({
|
|
169
|
+
kind: "unknown-role-key",
|
|
170
|
+
headline: `${r.name}: unknown key "${k}" in [roles.${r.name}] — ignored` +
|
|
171
|
+
(near ? `. Did you mean "${near}"?` : ""),
|
|
172
|
+
detail: `Known keys: ${ROLE_KEYS.join(", ")}. ` +
|
|
173
|
+
(near === "never_writes"
|
|
174
|
+
? "As written this subtracts nothing: the role can still write every path it lists."
|
|
175
|
+
: "Nothing in it takes effect."),
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
// macOS only, and not by omission: on Linux the runtime removes the network
|
|
179
|
+
// namespace, so every role already has a loopback of its own — it can serve
|
|
180
|
+
// and reach its own server with or without the key, and reaches nothing on
|
|
181
|
+
// the host either way (measured in Docker, Debian 12, bwrap 0.8.0).
|
|
182
|
+
if (r.localBinding && process.platform === "darwin")
|
|
183
|
+
warnings.push({
|
|
184
|
+
kind: "local-binding-reaches-localhost",
|
|
185
|
+
headline: `${r.name}: local_binding lets it connect to every port on localhost, not only listen`,
|
|
186
|
+
detail: "The runtime grants bind on any interface, inbound, and outbound to localhost:*. " +
|
|
187
|
+
"Anything listening on this machine without authentication — a local control API, a " +
|
|
188
|
+
"database, a dev server — is within this role's reach, outside its territory.",
|
|
189
|
+
});
|
|
190
|
+
// Both at once is a config that says "these ports" and grants "every port".
|
|
191
|
+
// macOS only: on Linux local_binding reaches nothing on the host, so there
|
|
192
|
+
// local_ports is the only one of the two that does anything.
|
|
193
|
+
if (r.localBinding && r.localPorts?.length && process.platform === "darwin")
|
|
194
|
+
warnings.push({
|
|
195
|
+
kind: "local-ports-moot",
|
|
196
|
+
headline: `${r.name}: local_ports names ${r.localPorts.join(", ")}, but local_binding already opens every localhost port`,
|
|
197
|
+
detail: "local_binding grants outbound to localhost:* in the kernel profile, so the port list " +
|
|
198
|
+
"restricts nothing for this role. Drop local_binding if the role only needs to reach " +
|
|
199
|
+
"those ports; keep it only if the role has to listen.",
|
|
200
|
+
});
|
|
201
|
+
// trustd is a macOS service; on Linux the key changes nothing.
|
|
202
|
+
if (r.trustd && process.platform === "darwin")
|
|
203
|
+
warnings.push({
|
|
204
|
+
kind: "trustd-open",
|
|
205
|
+
headline: `${r.name}: trustd = true opens com.apple.trustd.agent, a path out that the domain list does not see`,
|
|
206
|
+
detail: "trustd verifies certificates for Go (before 1.27) and Dart on macOS, and to do it fetches, " +
|
|
207
|
+
"outside the sandbox, the URLs a certificate carries. The runtime calls it an exfiltration vector; " +
|
|
208
|
+
"no public demonstration exists. Go 1.27+ does not need it: seisin sets SSL_CERT_FILE.",
|
|
209
|
+
});
|
|
210
|
+
for (const g of r.neverWrites ?? []) {
|
|
211
|
+
if (r.writes.some((w) => covers(w, g) || covers(w, g.replace(/\/\*\*$/, ""))))
|
|
212
|
+
continue;
|
|
213
|
+
warnings.push({
|
|
214
|
+
kind: "never-writes-subtracts-nothing",
|
|
215
|
+
headline: `${r.name}: never_writes "${g}" is not inside any of its writes`,
|
|
216
|
+
detail: "It denies something that was already denied, so it protects nothing. " +
|
|
217
|
+
"Check the path, or drop the entry if the territory moved.",
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return warnings;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** The candidate within edit distance 3 of `word`, or null. */
|
|
225
|
+
function nearest(word, candidates) {
|
|
226
|
+
// Spellings of the same idea in another vocabulary, checked first because
|
|
227
|
+
// letters mislead here: `no_writes` is three edits from `writes`, and
|
|
228
|
+
// suggesting the grant for a misspelt subtraction is the worst answer.
|
|
229
|
+
if (/deny|never|no_?write/i.test(word)) return "never_writes";
|
|
230
|
+
let best = null, bestD = 4;
|
|
231
|
+
for (const c of candidates) {
|
|
232
|
+
const d = distance(word.toLowerCase(), c);
|
|
233
|
+
if (d < bestD) { best = c; bestD = d; }
|
|
234
|
+
}
|
|
235
|
+
return best;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function distance(a, b) {
|
|
239
|
+
const row = Array.from({ length: b.length + 1 }, (_, i) => i);
|
|
240
|
+
for (let i = 1; i <= a.length; i++) {
|
|
241
|
+
let prev = row[0]; row[0] = i;
|
|
242
|
+
for (let j = 1; j <= b.length; j++) {
|
|
243
|
+
const cur = row[j];
|
|
244
|
+
row[j] = Math.min(row[j] + 1, row[j - 1] + 1, prev + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
245
|
+
prev = cur;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
return row[b.length];
|
|
249
|
+
}
|
|
250
|
+
|
|
106
251
|
/**
|
|
107
252
|
* Paths more than one role claims.
|
|
108
253
|
*
|
|
@@ -247,6 +392,34 @@ function warningsFor(config, roles) {
|
|
|
247
392
|
});
|
|
248
393
|
}
|
|
249
394
|
}
|
|
395
|
+
warnings.push(...roleKeyWarnings(roles, config));
|
|
396
|
+
|
|
397
|
+
// A provider the parent could not find where no role writes. `run` refuses
|
|
398
|
+
// it with the same sentence; here it is found before anybody runs anything.
|
|
399
|
+
for (const [scheme, p] of Object.entries(config.keyProviders ?? {})) {
|
|
400
|
+
const cmd = p.command?.[0];
|
|
401
|
+
if (!cmd || cmd.includes("/")) continue;
|
|
402
|
+
try { resolveExecutable(cmd, config); } catch (e) {
|
|
403
|
+
if (!/a role can write/.test(e.message)) continue;
|
|
404
|
+
warnings.push({
|
|
405
|
+
kind: "provider-in-territory",
|
|
406
|
+
headline: `the ${scheme}:// provider "${cmd}" is only found where a role can write`,
|
|
407
|
+
detail: e.message.split("\n").slice(1).map((l) => l.trim()).join(" "),
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// PATH entries that are not absolute resolve against wherever the parent is
|
|
413
|
+
// standing, which is the repo. They are ignored for every lookup seisin makes
|
|
414
|
+
// outside the box; said here because a lookup that skips them can find a
|
|
415
|
+
// different program than your shell does.
|
|
416
|
+
const unanchored = (process.env.PATH ?? "").split(delimiter).filter((d) => !d || !d.startsWith("/"));
|
|
417
|
+
if (unanchored.length)
|
|
418
|
+
warnings.push({
|
|
419
|
+
kind: "relative-path",
|
|
420
|
+
headline: `PATH has ${unanchored.length} relative entr${unanchored.length === 1 ? "y" : "ies"} (${unanchored.map((d) => JSON.stringify(d)).join(", ")})`,
|
|
421
|
+
detail: "They resolve inside the repo, where roles write, so seisin skips them when it looks for a program to run outside the box.",
|
|
422
|
+
});
|
|
250
423
|
const shared = sharedPaths(config, roles);
|
|
251
424
|
|
|
252
425
|
if (shared.length)
|